summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukas Eipert <git@leipert.io>2018-04-10 10:36:59 +0200
committerLukas Eipert <git@leipert.io>2018-04-10 10:36:59 +0200
commit8e66411488e87d59dde65c690892d9495292fe86 (patch)
tree482d2bd7b2e7142ee5ab74406d586d84c91ba56a
parentdaa6195f84f53cc484ad0730f2c5a88c6e654b15 (diff)
parent91a3a4b526214f4174f4f12de23dfb5baf033211 (diff)
downloadgitlab-ce-8e66411488e87d59dde65c690892d9495292fe86.tar.gz
Merge branch 'master' into winh-single-karma-test
# Conflicts: # spec/javascripts/test_bundle.js
-rw-r--r--.babelrc25
-rw-r--r--.codeclimate.yml7
-rw-r--r--.eslintignore5
-rw-r--r--.eslintrc31
-rw-r--r--.flayignore3
-rw-r--r--.gitignore7
-rw-r--r--.gitlab-ci.yml554
-rw-r--r--.gitlab/issue_templates/Bug.md6
-rw-r--r--.gitlab/merge_request_templates/Database Changes.md11
-rw-r--r--.gitlab/merge_request_templates/Documentation.md27
-rw-r--r--.prettierignore5
-rw-r--r--.prettierrc13
-rw-r--r--.rubocop.yml1274
-rw-r--r--.rubocop_todo.yml705
-rw-r--r--.ruby-version2
-rw-r--r--.scss-lint.yml2
-rw-r--r--CHANGELOG.md4291
-rw-r--r--CONTRIBUTING.md52
-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--Gemfile142
-rw-r--r--Gemfile.lock395
-rw-r--r--Gemfile.rails57
-rw-r--r--Gemfile.rails5.lock1228
-rw-r--r--PROCESS.md48
-rw-r--r--Procfile1
-rw-r--r--VERSION2
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_canceled.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_created.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_failed.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_manual.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_not_found.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_pending.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_running.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_skipped.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_success.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_warning.icobin0 -> 4286 bytes
-rw-r--r--app/assets/images/favicon-yellow.icobin0 -> 5430 bytes
-rw-r--r--app/assets/images/file_icons.svg1
-rw-r--r--app/assets/images/icons.json1
-rw-r--r--app/assets/images/icons.svg1
-rw-r--r--app/assets/images/illustrations/clusters_empty.svg1
-rw-r--r--app/assets/images/illustrations/convdev/convdev_no_data.svg1
-rw-r--r--app/assets/images/illustrations/convdev/convdev_no_index.svg1
-rw-r--r--app/assets/images/illustrations/convdev/convdev_overview.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_1.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_10.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_2.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_3.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_4.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_5.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_6.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_7.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_8.svg1
-rw-r--r--app/assets/images/illustrations/convdev/i2p_step_9.svg1
-rw-r--r--app/assets/images/illustrations/epics.svg1
-rw-r--r--app/assets/images/illustrations/gitlab_logo.svg1
-rw-r--r--app/assets/images/illustrations/image_comment_light_cursor.svg1
-rw-r--r--app/assets/images/illustrations/image_comment_light_cursor@2x.svg1
-rw-r--r--app/assets/images/illustrations/issues.svg1
-rw-r--r--app/assets/images/illustrations/labels.svg1
-rw-r--r--app/assets/images/illustrations/logos/go_logo.svg1
-rw-r--r--app/assets/images/illustrations/logos/mattermost_logo.svg1
-rw-r--r--app/assets/images/illustrations/merge_request_changes_empty.svg1
-rw-r--r--app/assets/images/illustrations/merge_requests.svg1
-rw-r--r--app/assets/images/illustrations/monitoring/getting_started.svg1
-rw-r--r--app/assets/images/illustrations/monitoring/loading.svg1
-rw-r--r--app/assets/images/illustrations/monitoring/unable_to_connect.svg1
-rw-r--r--app/assets/images/illustrations/no_commits.svg1
-rw-r--r--app/assets/images/illustrations/pipelines_empty.svg1
-rw-r--r--app/assets/images/illustrations/pipelines_failed.svg1
-rw-r--r--app/assets/images/illustrations/pipelines_pending.svg1
-rw-r--r--app/assets/images/illustrations/priority_labels.svg1
-rw-r--r--app/assets/images/illustrations/slack_logo.svg1
-rw-r--r--app/assets/images/illustrations/todos_all_done.svg1
-rw-r--r--app/assets/images/illustrations/todos_empty.svg1
-rw-r--r--app/assets/images/illustrations/welcome/add_new_group.svg1
-rw-r--r--app/assets/images/illustrations/welcome/add_new_project.svg1
-rw-r--r--app/assets/images/illustrations/welcome/add_new_user.svg1
-rw-r--r--app/assets/images/illustrations/welcome/configure_server.svg1
-rw-r--r--app/assets/images/illustrations/welcome/ee_trial.svg1
-rw-r--r--app/assets/images/illustrations/welcome/globe.svg1
-rw-r--r--app/assets/images/illustrations/welcome/lightbulb.svg1
-rw-r--r--app/assets/images/illustrations/wiki-fro-logged-out-users.svg1
-rw-r--r--app/assets/images/multi-editor-off.pngbin0 -> 4884 bytes
-rw-r--r--app/assets/images/multi-editor-on.pngbin0 -> 3971 bytes
-rw-r--r--app/assets/javascripts/abuse_reports.js36
-rw-r--r--app/assets/javascripts/activities.js1
-rw-r--r--app/assets/javascripts/admin.js59
-rw-r--r--app/assets/javascripts/ajax_loading_spinner.js2
-rw-r--r--app/assets/javascripts/api.js230
-rw-r--r--app/assets/javascripts/autosave.js32
-rw-r--r--app/assets/javascripts/awards_handler.js29
-rw-r--r--app/assets/javascripts/badges/components/badge.vue121
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue219
-rw-r--r--app/assets/javascripts/badges/components/badge_list.vue57
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue89
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue70
-rw-r--r--app/assets/javascripts/badges/constants.js2
-rw-r--r--app/assets/javascripts/badges/empty_badge.js7
-rw-r--r--app/assets/javascripts/badges/store/actions.js167
-rw-r--r--app/assets/javascripts/badges/store/index.js13
-rw-r--r--app/assets/javascripts/badges/store/mutation_types.js21
-rw-r--r--app/assets/javascripts/badges/store/mutations.js158
-rw-r--r--app/assets/javascripts/badges/store/state.js13
-rw-r--r--app/assets/javascripts/behaviors/copy_as_gfm.js481
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js3
-rw-r--r--app/assets/javascripts/behaviors/details_behavior.js1
-rw-r--r--app/assets/javascripts/behaviors/index.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js501
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js17
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js38
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js57
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js5
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js3
-rw-r--r--app/assets/javascripts/behaviors/secret_values.js29
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js8
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js4
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js4
-rw-r--r--app/assets/javascripts/blob/blob_fork_suggestion.js2
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js6
-rw-r--r--app/assets/javascripts/blob/notebook/index.js80
-rw-r--r--app/assets/javascripts/blob/notebook_viewer.js2
-rw-r--r--app/assets/javascripts/blob/pdf/index.js6
-rw-r--r--app/assets/javascripts/blob/pdf_viewer.js2
-rw-r--r--app/assets/javascripts/blob/sketch_viewer.js4
-rw-r--r--app/assets/javascripts/blob/stl_viewer.js4
-rw-r--r--app/assets/javascripts/blob/template_selector.js4
-rw-r--r--app/assets/javascripts/blob/viewer/index.js56
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js14
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js16
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js241
-rw-r--r--app/assets/javascripts/boards/components/board.js10
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js91
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue98
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue47
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js1
-rw-r--r--app/assets/javascripts/boards/components/board_list.js207
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue219
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js100
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue144
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js16
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js16
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js11
-rw-r--r--app/assets/javascripts/boards/components/modal/header.js6
-rw-r--r--app/assets/javascripts/boards/components/modal/index.js5
-rw-r--r--app/assets/javascripts/boards/components/modal/list.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.js3
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.js6
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js14
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue129
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.js17
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js9
-rw-r--r--app/assets/javascripts/boards/index.js244
-rw-r--r--app/assets/javascripts/boards/mixins/modal_mixins.js4
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js14
-rw-r--r--app/assets/javascripts/boards/models/issue.js16
-rw-r--r--app/assets/javascripts/boards/models/label.js15
-rw-r--r--app/assets/javascripts/boards/models/list.js6
-rw-r--r--app/assets/javascripts/boards/models/project.js6
-rw-r--r--app/assets/javascripts/boards/services/board_service.js101
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js4
-rw-r--r--app/assets/javascripts/boards/stores/modal_store.js5
-rw-r--r--app/assets/javascripts/boards/utils/query_data.js2
-rw-r--r--app/assets/javascripts/branches/branches_delete_modal.js2
-rw-r--r--app/assets/javascripts/breadcrumb.js2
-rw-r--r--app/assets/javascripts/broadcast_message.js28
-rw-r--r--app/assets/javascripts/build_artifacts.js2
-rw-r--r--app/assets/javascripts/build_variables.js4
-rw-r--r--app/assets/javascripts/ci_lint_editor.js12
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js117
-rw-r--r--app/assets/javascripts/ci_variable_list/ci_variable_list.js222
-rw-r--r--app/assets/javascripts/ci_variable_list/native_form_variable_list.js27
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js25
-rw-r--r--app/assets/javascripts/clusters/clusters_index.js68
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue222
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue320
-rw-r--r--app/assets/javascripts/clusters/constants.js1
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js1
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js23
-rw-r--r--app/assets/javascripts/commit/image_file.js5
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js4
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue51
-rw-r--r--app/assets/javascripts/commit_merge_requests.js74
-rw-r--r--app/assets/javascripts/commits.js101
-rw-r--r--app/assets/javascripts/commons/bootstrap.js4
-rw-r--r--app/assets/javascripts/commons/jquery.js2
-rw-r--r--app/assets/javascripts/commons/polyfills.js3
-rw-r--r--app/assets/javascripts/commons/polyfills/element.js19
-rw-r--r--app/assets/javascripts/commons/vue.js1
-rw-r--r--app/assets/javascripts/compare.js51
-rw-r--r--app/assets/javascripts/compare_autocomplete.js22
-rw-r--r--app/assets/javascripts/confirm_danger_modal.js57
-rw-r--r--app/assets/javascripts/contextual_sidebar.js1
-rw-r--r--app/assets/javascripts/create_item_dropdown.js119
-rw-r--r--app/assets/javascripts/create_label.js2
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js157
-rw-r--r--app/assets/javascripts/cycle_analytics/components/banner.vue18
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue21
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.vue42
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_component.vue39
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue35
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.vue58
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue65
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.vue69
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.vue30
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js27
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue24
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue47
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue38
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue12
-rw-r--r--app/assets/javascripts/deploy_keys/index.js10
-rw-r--r--app/assets/javascripts/diff.js12
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js1
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js1
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js3
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js2
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js20
-rw-r--r--app/assets/javascripts/diff_notes/models/discussion.js1
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js5
-rw-r--r--app/assets/javascripts/dispatcher.js784
-rw-r--r--app/assets/javascripts/docs/docs_bundle.js7
-rw-r--r--app/assets/javascripts/droplab/constants.js2
-rw-r--r--app/assets/javascripts/droplab/drop_down.js13
-rw-r--r--app/assets/javascripts/dropzone_input.js36
-rw-r--r--app/assets/javascripts/due_date_select.js55
-rw-r--r--app/assets/javascripts/environments/components/container.vue19
-rw-r--r--app/assets/javascripts/environments/components/empty_state.vue14
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue95
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue46
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue905
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue42
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue73
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue85
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue55
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue31
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue81
-rw-r--r--app/assets/javascripts/environments/environments_bundle.js39
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js4
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue15
-rw-r--r--app/assets/javascripts/environments/index.js39
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js2
-rw-r--r--app/assets/javascripts/experimental_flags.js1
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight.js66
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js60
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_options.js12
-rw-r--r--app/assets/javascripts/files_comment_button.js2
-rw-r--r--app/assets/javascripts/filterable_list.js32
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js101
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue104
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js10
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js16
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js12
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js14
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js20
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js11
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js15
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js103
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js174
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js46
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js8
-rw-r--r--app/assets/javascripts/flash.js4
-rw-r--r--app/assets/javascripts/fly_out_nav.js4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js31
-rw-r--r--app/assets/javascripts/gl_dropdown.js57
-rw-r--r--app/assets/javascripts/gl_field_error.js2
-rw-r--r--app/assets/javascripts/gl_field_errors.js1
-rw-r--r--app/assets/javascripts/gl_form.js14
-rw-r--r--app/assets/javascripts/gpg_badges.js18
-rw-r--r--app/assets/javascripts/graphs/graphs_bundle.js4
-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.js125
-rw-r--r--app/assets/javascripts/graphs/stat_graph_contributors_graph.js294
-rw-r--r--app/assets/javascripts/group.js2
-rw-r--r--app/assets/javascripts/group_avatar.js2
-rw-r--r--app/assets/javascripts/group_label_subscription.js28
-rw-r--r--app/assets/javascripts/groups/components/app.vue157
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue11
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue25
-rw-r--r--app/assets/javascripts/groups/components/groups.vue69
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue44
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue15
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue153
-rw-r--r--app/assets/javascripts/groups/components/item_stats_value.vue68
-rw-r--r--app/assets/javascripts/groups/components/item_type_icon.vue13
-rw-r--r--app/assets/javascripts/groups/constants.js6
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js18
-rw-r--r--app/assets/javascripts/groups/index.js4
-rw-r--r--app/assets/javascripts/groups/service/groups_service.js4
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js3
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js36
-rw-r--r--app/assets/javascripts/groups_select.js27
-rw-r--r--app/assets/javascripts/header.js1
-rw-r--r--app/assets/javascripts/help/help.js15
-rw-r--r--app/assets/javascripts/how_to_merge.js27
-rw-r--r--app/assets/javascripts/ide/components/changed_file_icon.vue31
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue65
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue18
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue72
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue94
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue130
-rw-r--r--app/assets/javascripts/ide/components/ide.vue89
-rw-r--r--app/assets/javascripts/ide/components/ide_context_bar.vue89
-rw-r--r--app/assets/javascripts/ide/components/ide_external_links.vue43
-rw-r--r--app/assets/javascripts/ide/components/ide_file_buttons.vue84
-rw-r--r--app/assets/javascripts/ide/components/ide_project_branches_tree.vue52
-rw-r--r--app/assets/javascripts/ide/components/ide_project_tree.vue40
-rw-r--r--app/assets/javascripts/ide/components/ide_repo_tree.vue75
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue89
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue71
-rw-r--r--app/assets/javascripts/ide/components/mr_file_icon.vue23
-rw-r--r--app/assets/javascripts/ide/components/new_branch_form.vue108
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue52
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue131
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue36
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue261
-rw-r--r--app/assets/javascripts/ide/components/repo_edit_button.vue57
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue188
-rw-r--r--app/assets/javascripts/ide/components/repo_file.vue254
-rw-r--r--app/assets/javascripts/ide/components/repo_file_buttons.vue56
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue39
-rw-r--r--app/assets/javascripts/ide/components/repo_loading_file.vue8
-rw-r--r--app/assets/javascripts/ide/components/repo_prev_directory.vue32
-rw-r--r--app/assets/javascripts/ide/components/repo_preview.vue65
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue81
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue91
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue85
-rw-r--r--app/assets/javascripts/ide/eventhub.js3
-rw-r--r--app/assets/javascripts/ide/ide_router.js154
-rw-r--r--app/assets/javascripts/ide/index.js36
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js50
-rw-r--r--app/assets/javascripts/ide/lib/common/model_manager.js24
-rw-r--r--app/assets/javascripts/ide/lib/decorations/controller.js2
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js3
-rw-r--r--app/assets/javascripts/ide/lib/editor.js149
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js18
-rw-r--r--app/assets/javascripts/ide/lib/themes/gl_theme.js14
-rw-r--r--app/assets/javascripts/ide/monaco_loader.js5
-rw-r--r--app/assets/javascripts/ide/services/index.js35
-rw-r--r--app/assets/javascripts/ide/stores/actions.js193
-rw-r--r--app/assets/javascripts/ide/stores/actions/branch.js43
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js204
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js84
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js30
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js217
-rw-r--r--app/assets/javascripts/ide/stores/getters.js42
-rw-r--r--app/assets/javascripts/ide/stores/index.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js218
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/constants.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/state.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js32
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js94
-rw-r--r--app/assets/javascripts/ide/stores/mutations/branch.js14
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js125
-rw-r--r--app/assets/javascripts/ide/stores/mutations/merge_request.js33
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js26
-rw-r--r--app/assets/javascripts/ide/stores/state.js18
-rw-r--r--app/assets/javascripts/ide/stores/utils.js131
-rw-r--r--app/assets/javascripts/ide/stores/workers/files_decorator_worker.js90
-rw-r--r--app/assets/javascripts/image_diff/image_diff.js1
-rw-r--r--app/assets/javascripts/importer_status.js139
-rw-r--r--app/assets/javascripts/init_changes_dropdown.js1
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js3
-rw-r--r--app/assets/javascripts/init_labels.js19
-rw-r--r--app/assets/javascripts/init_legacy_filters.js2
-rw-r--r--app/assets/javascripts/integrations/index.js7
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js53
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js14
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js15
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js4
-rw-r--r--app/assets/javascripts/issuable_context.js1
-rw-r--r--app/assets/javascripts/issuable_form.js41
-rw-r--r--app/assets/javascripts/issuable_index.js35
-rw-r--r--app/assets/javascripts/issue.js116
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue580
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue20
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue52
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue26
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue30
-rw-r--r--app/assets/javascripts/issue_show/services/index.js21
-rw-r--r--app/assets/javascripts/issue_status_select.js4
-rw-r--r--app/assets/javascripts/job.js70
-rw-r--r--app/assets/javascripts/jobs/components/header.vue35
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue31
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_details_block.vue87
-rw-r--r--app/assets/javascripts/jobs/job_details_bundle.js17
-rw-r--r--app/assets/javascripts/label_manager.js36
-rw-r--r--app/assets/javascripts/labels.js2
-rw-r--r--app/assets/javascripts/labels_select.js233
-rw-r--r--app/assets/javascripts/layout_nav.js3
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js32
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js14
-rw-r--r--app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js96
-rw-r--r--app/assets/javascripts/lib/utils/csrf.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js124
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js7
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js2
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js131
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js36
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js12
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js8
-rw-r--r--app/assets/javascripts/line_highlighter.js4
-rw-r--r--app/assets/javascripts/logo.js2
-rw-r--r--app/assets/javascripts/main.js214
-rw-r--r--app/assets/javascripts/member_expiration_date.js1
-rw-r--r--app/assets/javascripts/members.js6
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.js21
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_service.js14
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js26
-rw-r--r--app/assets/javascripts/merge_request.js17
-rw-r--r--app/assets/javascripts/merge_request_tabs.js59
-rw-r--r--app/assets/javascripts/milestone.js21
-rw-r--r--app/assets/javascripts/milestone_select.js404
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js28
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue244
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue176
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue502
-rw-r--r--app/assets/javascripts/monitoring/components/graph/axis.vue142
-rw-r--r--app/assets/javascripts/monitoring/components/graph/deployment.vue195
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue244
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue253
-rw-r--r--app/assets/javascripts/monitoring/components/graph/path.vue67
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_info.vue29
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_line.vue36
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue20
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js5
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js23
-rw-r--r--app/assets/javascripts/monitoring/services/monitoring_service.js3
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js2
-rw-r--r--app/assets/javascripts/monitoring/utils/date_time_formatters.js16
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js86
-rw-r--r--app/assets/javascripts/mr_notes/index.js47
-rw-r--r--app/assets/javascripts/namespace_select.js2
-rw-r--r--app/assets/javascripts/network/branch_graph.js17
-rw-r--r--app/assets/javascripts/network/network.js19
-rw-r--r--app/assets/javascripts/network/network_bundle.js17
-rw-r--r--app/assets/javascripts/new_branch_form.js2
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue29
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue37
-rw-r--r--app/assets/javascripts/notebook/cells/output/image.vue31
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue134
-rw-r--r--app/assets/javascripts/notebook/cells/prompt.vue19
-rw-r--r--app/assets/javascripts/notebook/index.vue10
-rw-r--r--app/assets/javascripts/notes.js880
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue544
-rw-r--r--app/assets/javascripts/notes/components/diff_file_header.vue94
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue101
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue121
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue28
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue266
-rw-r--r--app/assets/javascripts/notes/components/note_attachment.vue24
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue351
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue143
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue55
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue247
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue134
-rw-r--r--app/assets/javascripts/notes/components/note_signed_out_widget.vue24
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue426
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue263
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue263
-rw-r--r--app/assets/javascripts/notes/constants.js13
-rw-r--r--app/assets/javascripts/notes/index.js77
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js10
-rw-r--r--app/assets/javascripts/notes/mixins/noteable.js15
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js55
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js17
-rw-r--r--app/assets/javascripts/notes/stores/actions.js267
-rw-r--r--app/assets/javascripts/notes/stores/getters.js63
-rw-r--r--app/assets/javascripts/notes/stores/index.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js6
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js85
-rw-r--r--app/assets/javascripts/notes/stores/utils.js14
-rw-r--r--app/assets/javascripts/notifications_dropdown.js5
-rw-r--r--app/assets/javascripts/notifications_form.js39
-rw-r--r--app/assets/javascripts/oauth_remember_me.js32
-rw-r--r--app/assets/javascripts/pager.js36
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js37
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/admin.js60
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js36
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/cohorts/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/cohorts/usage_ping.js13
-rw-r--r--app/assets/javascripts/pages/admin/conversational_development_index/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/groups/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js9
-rw-r--r--app/assets/javascripts/pages/admin/groups/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue49
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js28
-rw-r--r--app/assets/javascripts/pages/admin/labels/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/labels/new/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js9
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue125
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/index.js38
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue174
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js44
-rw-r--r--app/assets/javascripts/pages/constants.js6
-rw-r--r--app/assets/javascripts/pages/dashboard/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/groups/index/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js7
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js7
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/index/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/show/index.js9
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js151
-rw-r--r--app/assets/javascripts/pages/explore/groups/index.js16
-rw-r--r--app/assets/javascripts/pages/explore/projects/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/boards/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index/index.js11
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js11
-rw-r--r--app/assets/javascripts/pages/groups/labels/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/labels/index/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/labels/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js11
-rw-r--r--app/assets/javascripts/pages/groups/milestones/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/milestones/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/milestones/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/settings/badges/index.js10
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js12
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js22
-rw-r--r--app/assets/javascripts/pages/help/index/index.js8
-rw-r--r--app/assets/javascripts/pages/help/show/index.js3
-rw-r--r--app/assets/javascripts/pages/help/ui/index.js3
-rw-r--r--app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js3
-rw-r--r--app/assets/javascripts/pages/import/gitlab_projects/new/index.js3
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue110
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue68
-rw-r--r--app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js84
-rw-r--r--app/assets/javascripts/pages/milestones/shared/event_hub.js3
-rw-r--r--app/assets/javascripts/pages/milestones/shared/index.js7
-rw-r--r--app/assets/javascripts/pages/milestones/shared/init_milestones_show.js11
-rw-r--r--app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js84
-rw-r--r--app/assets/javascripts/pages/omniauth_callbacks/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/accounts/show/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/index.js17
-rw-r--r--app/assets/javascripts/pages/profiles/index/index.js7
-rw-r--r--app/assets/javascripts/pages/profiles/notifications/show/index.js7
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js15
-rw-r--r--app/assets/javascripts/pages/projects/activity/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/browse/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/file/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/blame/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js29
-rw-r--r--app/assets/javascripts/pages/projects/boards/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/branches/new/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js19
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/clusters/destroy/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/update/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js26
-rw-r--r--app/assets/javascripts/pages/projects/commits/show/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/compare/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/constants.js6
-rw-r--r--app/assets/javascripts/pages/projects/cycle_analytics/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js16
-rw-r--r--app/assets/javascripts/pages/projects/environments/folder/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/environments/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/environments/terminal/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/find_file/show/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js62
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/index.js24
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js126
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js296
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js (renamed from app/assets/javascripts/graphs/stat_graph_contributors_util.js)0
-rw-r--r--app/assets/javascripts/pages/projects/imports/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js36
-rw-r--r--app/assets/javascripts/pages/projects/init_form.js7
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js18
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js13
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/jobs/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue88
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/labels/event_hub.js3
-rw-r--r--app/assets/javascripts/pages/projects/labels/index/index.js93
-rw-r--r--app/assets/javascripts/pages/projects/labels/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js20
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js15
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js20
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js32
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/milestones/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/milestones/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/milestones/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/milestones/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/network/network.js20
-rw-r--r--app/assets/javascripts/pages/projects/network/show/index.js17
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue160
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue67
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js54
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js68
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg (renamed from app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg)0
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js50
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/builds/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/charts/index.js57
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/failures/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js40
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/init_pipelines.js16
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/new/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/project.js135
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/registry/repositories/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/releases/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/services/edit/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/settings/badges/index/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js25
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js19
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue111
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue51
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue328
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js (renamed from app/assets/javascripts/projects/permissions/constants.js)0
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/external.js (renamed from app/assets/javascripts/projects/permissions/external.js)0
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/index.js (renamed from app/assets/javascripts/projects/permissions/index.js)0
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_avatar.js15
-rw-r--r--app/assets/javascripts/pages/projects/shared/project_new.js152
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js41
-rw-r--r--app/assets/javascripts/pages/projects/snippets/edit/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/snippets/new/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/snippets/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/tags/new/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js39
-rw-r--r--app/assets/javascripts/pages/projects/wikis/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js60
-rw-r--r--app/assets/javascripts/pages/search/init_filtered_search.js23
-rw-r--r--app/assets/javascripts/pages/search/show/index.js3
-rw-r--r--app/assets/javascripts/pages/search/show/search.js116
-rw-r--r--app/assets/javascripts/pages/sessions/index.js3
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js12
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js34
-rw-r--r--app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js55
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js134
-rw-r--r--app/assets/javascripts/pages/shared/mount_badge_settings.js24
-rw-r--r--app/assets/javascripts/pages/snippets/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/snippets/form.js8
-rw-r--r--app/assets/javascripts/pages/snippets/new/index.js7
-rw-r--r--app/assets/javascripts/pages/snippets/show/index.js11
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js243
-rw-r--r--app/assets/javascripts/pages/users/index.js28
-rw-r--r--app/assets/javascripts/pages/users/show/index.js3
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js200
-rw-r--r--app/assets/javascripts/pdf/index.vue22
-rw-r--r--app/assets/javascripts/pdf/page/index.vue32
-rw-r--r--app/assets/javascripts/performance_bar.js63
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue93
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue191
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue52
-rw-r--r--app/assets/javascripts/performance_bar/components/simple_metric.vue30
-rw-r--r--app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue20
-rw-r--r--app/assets/javascripts/performance_bar/index.js37
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js45
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js39
-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/pipeline_schedule_form_bundle.js46
-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.js73
-rw-r--r--app/assets/javascripts/pipelines/components/async_button.vue125
-rw-r--r--app/assets/javascripts/pipelines/components/blank_state.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/empty_state.vue47
-rw-r--r--app/assets/javascripts/pipelines/components/error_state.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue97
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue20
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue92
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue192
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue133
-rw-r--r--app/assets/javascripts/pipelines/components/nav_controls.vue81
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue298
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.vue34
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.vue33
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table.vue88
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue446
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue248
-rw-r--r--app/assets/javascripts/pipelines/components/time_ago.vue33
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js31
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js47
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediatior.js58
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js62
-rw-r--r--app/assets/javascripts/pipelines/pipelines_bundle.js27
-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/pipelines_service.js1
-rw-r--r--app/assets/javascripts/preview_markdown.js363
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue127
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue121
-rw-r--r--app/assets/javascripts/profile/account/index.js59
-rw-r--r--app/assets/javascripts/profile/gl_crop.js1
-rw-r--r--app/assets/javascripts/profile/profile.js157
-rw-r--r--app/assets/javascripts/profile/profile_bundle.js2
-rw-r--r--app/assets/javascripts/project.js133
-rw-r--r--app/assets/javascripts/project_avatar.js13
-rw-r--r--app/assets/javascripts/project_edit.js2
-rw-r--r--app/assets/javascripts/project_find_file.js29
-rw-r--r--app/assets/javascripts/project_fork.js2
-rw-r--r--app/assets/javascripts/project_label_subscription.js12
-rw-r--r--app/assets/javascripts/project_new.js151
-rw-r--r--app/assets/javascripts/project_select.js16
-rw-r--r--app/assets/javascripts/project_select_combo_button.js1
-rw-r--r--app/assets/javascripts/project_visibility.js2
-rw-r--r--app/assets/javascripts/projects/permissions/components/project_feature_setting.vue104
-rw-r--r--app/assets/javascripts/projects/permissions/components/project_setting_row.vue36
-rw-r--r--app/assets/javascripts/projects/permissions/components/settings_panel.vue312
-rw-r--r--app/assets/javascripts/projects/project_import_gitlab_project.js9
-rw-r--r--app/assets/javascripts/projects/project_new.js41
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue120
-rw-r--r--app/assets/javascripts/projects/tree/services/commit_pipeline_service.js11
-rw-r--r--app/assets/javascripts/projects_dropdown/components/app.vue32
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue50
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_item.vue131
-rw-r--r--app/assets/javascripts/projects_dropdown/components/search.vue81
-rw-r--r--app/assets/javascripts/projects_dropdown/index.js8
-rw-r--r--app/assets/javascripts/projects_dropdown/service/projects_service.js1
-rw-r--r--app/assets/javascripts/prometheus_metrics/index.js6
-rw-r--r--app/assets/javascripts/prometheus_metrics/prometheus_metrics.js79
-rw-r--r--app/assets/javascripts/protected_branches/index.js9
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js15
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_dropdown.js90
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js40
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit_list.js1
-rw-r--r--app/assets/javascripts/protected_tags/index.js9
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js14
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js88
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js31
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit_list.js1
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js6
-rw-r--r--app/assets/javascripts/registry/components/app.vue32
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue50
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue149
-rw-r--r--app/assets/javascripts/registry/index.js4
-rw-r--r--app/assets/javascripts/render_gfm.js15
-rw-r--r--app/assets/javascripts/render_math.js51
-rw-r--r--app/assets/javascripts/render_mermaid.js32
-rw-r--r--app/assets/javascripts/right_sidebar.js42
-rw-r--r--app/assets/javascripts/search.js113
-rw-r--r--app/assets/javascripts/search_autocomplete.js74
-rw-r--r--app/assets/javascripts/settings_panels.js4
-rw-r--r--app/assets/javascripts/shared/milestones/form.js10
-rw-r--r--app/assets/javascripts/shared/sessions/u2f.js17
-rw-r--r--app/assets/javascripts/shortcuts.js36
-rw-r--r--app/assets/javascripts/shortcuts_blob.js2
-rw-r--r--app/assets/javascripts/shortcuts_find_file.js3
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js18
-rw-r--r--app/assets/javascripts/shortcuts_navigation.js3
-rw-r--r--app/assets/javascripts/shortcuts_network.js2
-rw-r--r--app/assets/javascripts/shortcuts_wiki.js8
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.js59
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.js224
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue232
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js92
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue95
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue54
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue23
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form.vue52
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue23
-rw-r--r--app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue77
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue151
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue34
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue30
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue111
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js96
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue102
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js89
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue93
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.js34
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js163
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue168
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js4
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js27
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js3
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js8
-rw-r--r--app/assets/javascripts/signin_tabs_memoizer.js53
-rw-r--r--app/assets/javascripts/single_file_diff.js31
-rw-r--r--app/assets/javascripts/smart_interval.js2
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js15
-rw-r--r--app/assets/javascripts/sortable/sortable_config.js7
-rw-r--r--app/assets/javascripts/star.js26
-rw-r--r--app/assets/javascripts/subscription_select.js4
-rw-r--r--app/assets/javascripts/syntax_highlight.js2
-rw-r--r--app/assets/javascripts/task_list.js19
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js9
-rw-r--r--app/assets/javascripts/templates/issuable_template_selectors.js2
-rw-r--r--app/assets/javascripts/terminal/index.js9
-rw-r--r--app/assets/javascripts/terminal/terminal.js12
-rw-r--r--app/assets/javascripts/terminal/terminal_bundle.js9
-rw-r--r--app/assets/javascripts/test.js1
-rw-r--r--app/assets/javascripts/todos.js156
-rw-r--r--app/assets/javascripts/toggle_buttons.js61
-rw-r--r--app/assets/javascripts/tree.js2
-rw-r--r--app/assets/javascripts/two_factor_auth.js14
-rw-r--r--app/assets/javascripts/u2f/authenticate.js27
-rw-r--r--app/assets/javascripts/u2f/register.js28
-rw-r--r--app/assets/javascripts/u2f/util.js42
-rw-r--r--app/assets/javascripts/ui_development_kit.js5
-rw-r--r--app/assets/javascripts/usage_ping.js12
-rw-r--r--app/assets/javascripts/user_callout.js3
-rw-r--r--app/assets/javascripts/username_validator.js133
-rw-r--r--app/assets/javascripts/users/activity_calendar.js237
-rw-r--r--app/assets/javascripts/users/index.js25
-rw-r--r--app/assets/javascripts/users/user_tabs.js196
-rw-r--r--app/assets/javascripts/users_select.js191
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue144
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue170
-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.vue53
-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_author_time.vue42
-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.js113
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue157
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue20
-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_merge_help.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue125
-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_related_links.vue43
-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/mr_widget_status_icon.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue34
-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_archived.vue31
-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_auto_merge_failed.vue55
-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_checking.vue23
-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_closed.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js47
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue61
-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_failed_to_merge.vue105
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js124
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue147
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js139
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue192
-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_merging.vue35
-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_missing_branch.vue62
-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_not_allowed.vue25
-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_blocked.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js50
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue138
-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_unresolved_discussions.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue47
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js67
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js61
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue68
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue173
-rw-r--r--app/assets/javascripts/vue_shared/components/expand_button.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js589
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue101
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue183
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_warning.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue122
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_icon.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue53
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.js117
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/modal.vue131
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/panel_resizer.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar/image.vue146
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue173
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue78
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue93
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue63
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue127
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.vue255
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue4
-rw-r--r--app/assets/javascripts/vue_shared/directives/popover.js2
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js2
-rw-r--r--app/assets/javascripts/vue_shared/models/label.js13
-rw-r--r--app/assets/javascripts/wikis.js60
-rw-r--r--app/assets/javascripts/zen_mode.js6
-rw-r--r--app/assets/stylesheets/framework.scss15
-rw-r--r--app/assets/stylesheets/framework/avatar.scss2
-rw-r--r--app/assets/stylesheets/framework/awards.scss11
-rw-r--r--app/assets/stylesheets/framework/blocks.scss1
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss (renamed from app/assets/stylesheets/framework/broadcast-messages.scss)0
-rw-r--r--app/assets/stylesheets/framework/buttons.scss51
-rw-r--r--app/assets/stylesheets/framework/ci_variable_list.scss99
-rw-r--r--app/assets/stylesheets/framework/common.scss10
-rw-r--r--app/assets/stylesheets/framework/contextual-sidebar.scss474
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss457
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss58
-rw-r--r--app/assets/stylesheets/framework/emoji_sprites.scss (renamed from app/assets/stylesheets/framework/emoji-sprites.scss)0
-rw-r--r--app/assets/stylesheets/framework/feature_highlight.scss103
-rw-r--r--app/assets/stylesheets/framework/files.scss13
-rw-r--r--app/assets/stylesheets/framework/filters.scss2
-rw-r--r--app/assets/stylesheets/framework/flash.scss6
-rw-r--r--app/assets/stylesheets/framework/forms.scss3
-rw-r--r--app/assets/stylesheets/framework/gfm.scss28
-rw-r--r--app/assets/stylesheets/framework/gitlab-theme.scss274
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss351
-rw-r--r--app/assets/stylesheets/framework/header.scss120
-rw-r--r--app/assets/stylesheets/framework/icons.scss6
-rw-r--r--app/assets/stylesheets/framework/images.scss50
-rw-r--r--app/assets/stylesheets/framework/issue_box.scss8
-rw-r--r--app/assets/stylesheets/framework/layout.scss12
-rw-r--r--app/assets/stylesheets/framework/lists.scss88
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss12
-rw-r--r--app/assets/stylesheets/framework/mobile.scss4
-rw-r--r--app/assets/stylesheets/framework/modal.scss48
-rw-r--r--app/assets/stylesheets/framework/page_header.scss (renamed from app/assets/stylesheets/framework/page-header.scss)0
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary-navigation-elements.scss376
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss379
-rw-r--r--app/assets/stylesheets/framework/selects.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss3
-rw-r--r--app/assets/stylesheets/framework/stacked_progress_bar.scss54
-rw-r--r--app/assets/stylesheets/framework/tw_bootstrap_variables.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss15
-rw-r--r--app/assets/stylesheets/framework/variables.scss103
-rw-r--r--app/assets/stylesheets/framework/wells.scss6
-rw-r--r--app/assets/stylesheets/pages/boards.scss47
-rw-r--r--app/assets/stylesheets/pages/branches.scss21
-rw-r--r--app/assets/stylesheets/pages/builds.scss2
-rw-r--r--app/assets/stylesheets/pages/clusters.scss2
-rw-r--r--app/assets/stylesheets/pages/commits.scss76
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss41
-rw-r--r--app/assets/stylesheets/pages/diff.scss9
-rw-r--r--app/assets/stylesheets/pages/environments.scss96
-rw-r--r--app/assets/stylesheets/pages/events.scss17
-rw-r--r--app/assets/stylesheets/pages/groups.scss13
-rw-r--r--app/assets/stylesheets/pages/issuable.scss68
-rw-r--r--app/assets/stylesheets/pages/issues.scss88
-rw-r--r--app/assets/stylesheets/pages/labels.scss60
-rw-r--r--app/assets/stylesheets/pages/lint.scss21
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss39
-rw-r--r--app/assets/stylesheets/pages/milestone.scss4
-rw-r--r--app/assets/stylesheets/pages/note_form.scss22
-rw-r--r--app/assets/stylesheets/pages/notes.scss26
-rw-r--r--app/assets/stylesheets/pages/pages.scss60
-rw-r--r--app/assets/stylesheets/pages/pipeline_schedules.scss85
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss59
-rw-r--r--app/assets/stylesheets/pages/profile.scss7
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss16
-rw-r--r--app/assets/stylesheets/pages/projects.scss153
-rw-r--r--app/assets/stylesheets/pages/repo.scss476
-rw-r--r--app/assets/stylesheets/pages/repo.scss.orig786
-rw-r--r--app/assets/stylesheets/pages/search.scss2
-rw-r--r--app/assets/stylesheets/pages/settings.scss38
-rw-r--r--app/assets/stylesheets/pages/todos.scss1
-rw-r--r--app/assets/stylesheets/pages/wiki.scss13
-rw-r--r--app/assets/stylesheets/pages/xterm.scss29
-rw-r--r--app/assets/stylesheets/performance_bar.scss43
-rw-r--r--app/controllers/admin/appearances_controller.rb18
-rw-r--r--app/controllers/admin/application_controller.rb14
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb5
-rw-r--r--app/controllers/admin/cohorts_controller.rb2
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb4
-rw-r--r--app/controllers/admin/gitaly_servers_controller.rb5
-rw-r--r--app/controllers/admin/groups_controller.rb4
-rw-r--r--app/controllers/admin/hooks_controller.rb6
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb1
-rw-r--r--app/controllers/admin/jobs_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb1
-rw-r--r--app/controllers/admin/services_controller.rb5
-rw-r--r--app/controllers/admin/users_controller.rb2
-rw-r--r--app/controllers/application_controller.rb50
-rw-r--r--app/controllers/boards/issues_controller.rb26
-rw-r--r--app/controllers/ci/lints_controller.rb18
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb3
-rw-r--r--app/controllers/concerns/boards_responses.rb44
-rw-r--r--app/controllers/concerns/controller_with_cross_project_access_check.rb24
-rw-r--r--app/controllers/concerns/creates_commit.rb8
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb6
-rw-r--r--app/controllers/concerns/group_tree.rb9
-rw-r--r--app/controllers/concerns/issuable_actions.rb20
-rw-r--r--app/controllers/concerns/issuable_collections.rb27
-rw-r--r--app/controllers/concerns/issues_action.rb10
-rw-r--r--app/controllers/concerns/lfs_request.rb6
-rw-r--r--app/controllers/concerns/membership_actions.rb64
-rw-r--r--app/controllers/concerns/merge_requests_action.rb8
-rw-r--r--app/controllers/concerns/notes_actions.rb16
-rw-r--r--app/controllers/concerns/requires_whitelisted_monitoring_client.rb4
-rw-r--r--app/controllers/concerns/routable_actions.rb9
-rw-r--r--app/controllers/concerns/send_file_upload.rb17
-rw-r--r--app/controllers/concerns/service_params.rb1
-rw-r--r--app/controllers/concerns/uploads_actions.rb70
-rw-r--r--app/controllers/concerns/with_performance_bar.rb15
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/dashboard/application_controller.rb4
-rw-r--r--app/controllers/dashboard/groups_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb1
-rw-r--r--app/controllers/dashboard/snippets_controller.rb2
-rw-r--r--app/controllers/dashboard_controller.rb19
-rw-r--r--app/controllers/groups/application_controller.rb6
-rw-r--r--app/controllers/groups/avatars_controller.rb2
-rw-r--r--app/controllers/groups/boards_controller.rb27
-rw-r--r--app/controllers/groups/children_controller.rb1
-rw-r--r--app/controllers/groups/group_members_controller.rb35
-rw-r--r--app/controllers/groups/labels_controller.rb27
-rw-r--r--app/controllers/groups/milestones_controller.rb8
-rw-r--r--app/controllers/groups/settings/badges_controller.rb13
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb1
-rw-r--r--app/controllers/groups/uploads_controller.rb30
-rw-r--r--app/controllers/groups/variables_controller.rb57
-rw-r--r--app/controllers/groups_controller.rb27
-rw-r--r--app/controllers/health_controller.rb3
-rw-r--r--app/controllers/help_controller.rb2
-rw-r--r--app/controllers/import/base_controller.rb24
-rw-r--r--app/controllers/import/bitbucket_controller.rb24
-rw-r--r--app/controllers/import/fogbugz_controller.rb16
-rw-r--r--app/controllers/import/github_controller.rb37
-rw-r--r--app/controllers/import/gitlab_controller.rb18
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb5
-rw-r--r--app/controllers/import/google_code_controller.rb16
-rw-r--r--app/controllers/invites_controller.rb4
-rw-r--r--app/controllers/jwt_controller.rb3
-rw-r--r--app/controllers/koding_controller.rb2
-rw-r--r--app/controllers/metrics_controller.rb1
-rw-r--r--app/controllers/oauth/applications_controller.rb6
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb58
-rw-r--r--app/controllers/passwords_controller.rb4
-rw-r--r--app/controllers/profiles/passwords_controller.rb1
-rw-r--r--app/controllers/profiles_controller.rb24
-rw-r--r--app/controllers/projects/application_controller.rb16
-rw-r--r--app/controllers/projects/artifacts_controller.rb12
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb4
-rw-r--r--app/controllers/projects/blob_controller.rb13
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/branches_controller.rb59
-rw-r--r--app/controllers/projects/ci/lints_controller.rb27
-rw-r--r--app/controllers/projects/clusters/gcp_controller.rb26
-rw-r--r--app/controllers/projects/clusters_controller.rb11
-rw-r--r--app/controllers/projects/commit_controller.rb14
-rw-r--r--app/controllers/projects/commits_controller.rb5
-rw-r--r--app/controllers/projects/compare_controller.rb6
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb5
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb10
-rw-r--r--app/controllers/projects/deploy_tokens_controller.rb10
-rw-r--r--app/controllers/projects/deployments_controller.rb2
-rw-r--r--app/controllers/projects/discussions_controller.rb46
-rw-r--r--app/controllers/projects/forks_controller.rb5
-rw-r--r--app/controllers/projects/git_http_client_controller.rb1
-rw-r--r--app/controllers/projects/git_http_controller.rb15
-rw-r--r--app/controllers/projects/hooks_controller.rb11
-rw-r--r--app/controllers/projects/issues_controller.rb36
-rw-r--r--app/controllers/projects/jobs_controller.rb42
-rw-r--r--app/controllers/projects/labels_controller.rb11
-rw-r--r--app/controllers/projects/lfs_api_controller.rb4
-rw-r--r--app/controllers/projects/lfs_locks_api_controller.rb70
-rw-r--r--app/controllers/projects/lfs_storage_controller.rb59
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb1
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb18
-rw-r--r--app/controllers/projects/merge_requests_controller.rb35
-rw-r--r--app/controllers/projects/milestones_controller.rb34
-rw-r--r--app/controllers/projects/network_controller.rb30
-rw-r--r--app/controllers/projects/notes_controller.rb35
-rw-r--r--app/controllers/projects/pages_controller.rb22
-rw-r--r--app/controllers/projects/pages_domains_controller.rb45
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb6
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb28
-rw-r--r--app/controllers/projects/project_members_controller.rb31
-rw-r--r--app/controllers/projects/prometheus/metrics_controller.rb32
-rw-r--r--app/controllers/projects/prometheus_controller.rb24
-rw-r--r--app/controllers/projects/protected_branches_controller.rb8
-rw-r--r--app/controllers/projects/protected_refs_controller.rb14
-rw-r--r--app/controllers/projects/protected_tags_controller.rb8
-rw-r--r--app/controllers/projects/raw_controller.rb3
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/repositories_controller.rb22
-rw-r--r--app/controllers/projects/runners_controller.rb8
-rw-r--r--app/controllers/projects/services_controller.rb35
-rw-r--r--app/controllers/projects/settings/badges_controller.rb13
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb62
-rw-r--r--app/controllers/projects/settings/repository_controller.rb34
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects/uploads_controller.rb21
-rw-r--r--app/controllers/projects/variables_controller.rb59
-rw-r--r--app/controllers/projects/wikis_controller.rb8
-rw-r--r--app/controllers/projects_controller.rb37
-rw-r--r--app/controllers/registrations_controller.rb6
-rw-r--r--app/controllers/root_controller.rb10
-rw-r--r--app/controllers/search_controller.rb9
-rw-r--r--app/controllers/sessions_controller.rb3
-rw-r--r--app/controllers/uploads_controller.rb75
-rw-r--r--app/controllers/user_callouts_controller.rb23
-rw-r--r--app/controllers/users_controller.rb20
-rw-r--r--app/finders/admin/projects_finder.rb6
-rw-r--r--app/finders/autocomplete_users_finder.rb22
-rw-r--r--app/finders/branches_finder.rb2
-rw-r--r--app/finders/concerns/finder_methods.rb51
-rw-r--r--app/finders/concerns/finder_with_cross_project_access.rb70
-rw-r--r--app/finders/events_finder.rb4
-rw-r--r--app/finders/group_descendants_finder.rb44
-rw-r--r--app/finders/group_projects_finder.rb12
-rw-r--r--app/finders/issuable_finder.rb153
-rw-r--r--app/finders/issues_finder.rb62
-rw-r--r--app/finders/labels_finder.rb59
-rw-r--r--app/finders/members_finder.rb57
-rw-r--r--app/finders/merge_request_target_project_finder.rb2
-rw-r--r--app/finders/merge_requests_finder.rb32
-rw-r--r--app/finders/milestones_finder.rb10
-rw-r--r--app/finders/notes_finder.rb14
-rw-r--r--app/finders/projects_finder.rb2
-rw-r--r--app/finders/snippets_finder.rb101
-rw-r--r--app/finders/todos_finder.rb20
-rw-r--r--app/finders/user_recent_events_finder.rb64
-rw-r--r--app/helpers/appearances_helper.rb16
-rw-r--r--app/helpers/application_helper.rb57
-rw-r--r--app/helpers/application_settings_helper.rb29
-rw-r--r--app/helpers/auth_helper.rb10
-rw-r--r--app/helpers/auto_devops_helper.rb25
-rw-r--r--app/helpers/avatars_helper.rb14
-rw-r--r--app/helpers/blob_helper.rb148
-rw-r--r--app/helpers/boards_helper.rb34
-rw-r--r--app/helpers/branches_helper.rb25
-rw-r--r--app/helpers/commits_helper.rb29
-rw-r--r--app/helpers/dashboard_helper.rb24
-rw-r--r--app/helpers/deploy_tokens_helper.rb12
-rw-r--r--app/helpers/diff_helper.rb10
-rw-r--r--app/helpers/emails_helper.rb20
-rw-r--r--app/helpers/explore_helper.rb16
-rw-r--r--app/helpers/form_helper.rb2
-rw-r--r--app/helpers/graph_helper.rb8
-rw-r--r--app/helpers/groups_helper.rb57
-rw-r--r--app/helpers/icons_helper.rb7
-rw-r--r--app/helpers/import_helper.rb85
-rw-r--r--app/helpers/issuables_helper.rb43
-rw-r--r--app/helpers/issues_helper.rb23
-rw-r--r--app/helpers/javascript_helper.rb5
-rw-r--r--app/helpers/labels_helper.rb44
-rw-r--r--app/helpers/markup_helper.rb1
-rw-r--r--app/helpers/merge_requests_helper.rb13
-rw-r--r--app/helpers/namespaces_helper.rb4
-rw-r--r--app/helpers/nav_helper.rb33
-rw-r--r--app/helpers/notes_helper.rb33
-rw-r--r--app/helpers/page_layout_helper.rb5
-rw-r--r--app/helpers/preferences_helper.rb40
-rw-r--r--app/helpers/profiles_helper.rb2
-rw-r--r--app/helpers/projects_helper.rb151
-rw-r--r--app/helpers/search_helper.rb6
-rw-r--r--app/helpers/selects_helper.rb1
-rw-r--r--app/helpers/services_helper.rb15
-rw-r--r--app/helpers/sidekiq_helper.rb6
-rw-r--r--app/helpers/snippets_helper.rb1
-rw-r--r--app/helpers/submodule_helper.rb11
-rw-r--r--app/helpers/todos_helper.rb13
-rw-r--r--app/helpers/tree_helper.rb25
-rw-r--r--app/helpers/u2f_helper.rb5
-rw-r--r--app/helpers/user_callouts_helper.rb14
-rw-r--r--app/helpers/users_helper.rb14
-rw-r--r--app/helpers/version_check_helper.rb2
-rw-r--r--app/helpers/visibility_level_helper.rb4
-rw-r--r--app/helpers/webpack_helper.rb39
-rw-r--r--app/helpers/wiki_helper.rb18
-rw-r--r--app/helpers/workhorse_helper.rb4
-rw-r--r--app/mailers/abuse_report_mailer.rb6
-rw-r--r--app/mailers/base_mailer.rb4
-rw-r--r--app/mailers/emails/issues.rb33
-rw-r--r--app/mailers/emails/members.rb11
-rw-r--r--app/mailers/emails/merge_requests.rb46
-rw-r--r--app/mailers/emails/pages_domains.rb43
-rw-r--r--app/mailers/notify.rb5
-rw-r--r--app/models/ability.rb30
-rw-r--r--app/models/appearance.rb5
-rw-r--r--app/models/application_setting.rb16
-rw-r--r--app/models/badge.rb51
-rw-r--r--app/models/badges/group_badge.rb5
-rw-r--r--app/models/badges/project_badge.rb15
-rw-r--r--app/models/blob.rb2
-rw-r--r--app/models/board.rb8
-rw-r--r--app/models/chat_name.rb21
-rw-r--r--app/models/ci/artifact_blob.rb7
-rw-r--r--app/models/ci/build.rb280
-rw-r--r--app/models/ci/build_metadata.rb35
-rw-r--r--app/models/ci/group_variable.rb7
-rw-r--r--app/models/ci/job_artifact.rb18
-rw-r--r--app/models/ci/pipeline.rb47
-rw-r--r--app/models/ci/pipeline_schedule.rb3
-rw-r--r--app/models/ci/pipeline_schedule_variable.rb2
-rw-r--r--app/models/ci/runner.rb47
-rw-r--r--app/models/ci/trigger.rb3
-rw-r--r--app/models/ci/variable.rb7
-rw-r--r--app/models/clusters/applications/helm.rb17
-rw-r--r--app/models/clusters/applications/ingress.rb41
-rw-r--r--app/models/clusters/applications/prometheus.rb61
-rw-r--r--app/models/clusters/applications/runner.rb69
-rw-r--r--app/models/clusters/cluster.rb18
-rw-r--r--app/models/clusters/concerns/application_core.rb34
-rw-r--r--app/models/clusters/concerns/application_data.rb23
-rw-r--r--app/models/clusters/concerns/application_status.rb2
-rw-r--r--app/models/clusters/platforms/kubernetes.rb31
-rw-r--r--app/models/commit.rb52
-rw-r--r--app/models/commit_status.rb2
-rw-r--r--app/models/compare.rb39
-rw-r--r--app/models/concerns/access_requestable.rb2
-rw-r--r--app/models/concerns/artifact_migratable.rb3
-rw-r--r--app/models/concerns/atomic_internal_id.rb46
-rw-r--r--app/models/concerns/avatarable.rb25
-rw-r--r--app/models/concerns/cache_markdown_field.rb2
-rw-r--r--app/models/concerns/chronic_duration_attribute.rb39
-rw-r--r--app/models/concerns/deployment_platform.rb57
-rw-r--r--app/models/concerns/discussion_on_diff.rb12
-rw-r--r--app/models/concerns/internal_id.rb23
-rw-r--r--app/models/concerns/issuable.rb21
-rw-r--r--app/models/concerns/loaded_in_group_list.rb1
-rw-r--r--app/models/concerns/milestoneish.rb12
-rw-r--r--app/models/concerns/nonatomic_internal_id.rb22
-rw-r--r--app/models/concerns/note_on_diff.rb4
-rw-r--r--app/models/concerns/presentable.rb8
-rw-r--r--app/models/concerns/project_features_compatibility.rb1
-rw-r--r--app/models/concerns/prometheus_adapter.rb48
-rw-r--r--app/models/concerns/protected_ref_access.rb3
-rw-r--r--app/models/concerns/redis_cacheable.rb41
-rw-r--r--app/models/concerns/relative_positioning.rb18
-rw-r--r--app/models/concerns/resolvable_discussion.rb27
-rw-r--r--app/models/concerns/routable.rb8
-rw-r--r--app/models/concerns/sha_attribute.rb1
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb65
-rw-r--r--app/models/concerns/taskable.rb4
-rw-r--r--app/models/concerns/triggerable_hooks.rb40
-rw-r--r--app/models/concerns/updated_at_filterable.rb12
-rw-r--r--app/models/cycle_analytics.rb6
-rw-r--r--app/models/cycle_analytics/summary.rb0
-rw-r--r--app/models/deploy_key.rb26
-rw-r--r--app/models/deploy_keys_project.rb10
-rw-r--r--app/models/deploy_token.rb61
-rw-r--r--app/models/deployment.rb32
-rw-r--r--app/models/diff_discussion.rb9
-rw-r--r--app/models/diff_note.rb6
-rw-r--r--app/models/environment.rb33
-rw-r--r--app/models/event.rb29
-rw-r--r--app/models/external_issue.rb2
-rw-r--r--app/models/fork_network.rb2
-rw-r--r--app/models/global_milestone.rb8
-rw-r--r--app/models/group.rb48
-rw-r--r--app/models/hooks/project_hook.rb27
-rw-r--r--app/models/hooks/system_hook.rb16
-rw-r--r--app/models/hooks/web_hook.rb1
-rw-r--r--app/models/identity.rb15
-rw-r--r--app/models/internal_id.rb125
-rw-r--r--app/models/issue.rb52
-rw-r--r--app/models/issue_assignee.rb2
-rw-r--r--app/models/key.rb12
-rw-r--r--app/models/label.rb6
-rw-r--r--app/models/legacy_diff_note.rb6
-rw-r--r--app/models/lfs_file_lock.rb12
-rw-r--r--app/models/lfs_object.rb21
-rw-r--r--app/models/member.rb23
-rw-r--r--app/models/members/project_member.rb6
-rw-r--r--app/models/merge_request.rb116
-rw-r--r--app/models/merge_request/metrics.rb10
-rw-r--r--app/models/merge_request_diff.rb16
-rw-r--r--app/models/merge_request_diff_commit.rb2
-rw-r--r--app/models/milestone.rb10
-rw-r--r--app/models/namespace.rb66
-rw-r--r--app/models/network/commit.rb7
-rw-r--r--app/models/network/graph.rb8
-rw-r--r--app/models/note.rb31
-rw-r--r--app/models/notification_reason.rb19
-rw-r--r--app/models/notification_recipient.rb54
-rw-r--r--app/models/notification_setting.rb7
-rw-r--r--app/models/pages_domain.rb77
-rw-r--r--app/models/project.rb432
-rw-r--r--app/models/project_auto_devops.rb17
-rw-r--r--app/models/project_deploy_token.rb8
-rw-r--r--app/models/project_services/asana_service.rb4
-rw-r--r--app/models/project_services/assembla_service.rb4
-rw-r--r--app/models/project_services/bamboo_service.rb12
-rw-r--r--app/models/project_services/buildkite_service.rb2
-rw-r--r--app/models/project_services/campfire_service.rb9
-rw-r--r--app/models/project_services/chat_notification_service.rb28
-rw-r--r--app/models/project_services/drone_ci_service.rb2
-rw-r--r--app/models/project_services/emails_on_push_service.rb2
-rw-r--r--app/models/project_services/external_wiki_service.rb4
-rw-r--r--app/models/project_services/gemnasium_service.rb2
-rw-r--r--app/models/project_services/hipchat_service.rb7
-rw-r--r--app/models/project_services/irker_service.rb2
-rw-r--r--app/models/project_services/issue_tracker_service.rb8
-rw-r--r--app/models/project_services/jira_service.rb31
-rw-r--r--app/models/project_services/kubernetes_service.rb58
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb2
-rw-r--r--app/models/project_services/mock_ci_service.rb2
-rw-r--r--app/models/project_services/monitoring_service.rb4
-rw-r--r--app/models/project_services/packagist_service.rb2
-rw-r--r--app/models/project_services/pipelines_email_service.rb6
-rw-r--r--app/models/project_services/pivotaltracker_service.rb4
-rw-r--r--app/models/project_services/prometheus_service.rb80
-rw-r--r--app/models/project_services/pushover_service.rb9
-rw-r--r--app/models/project_services/slash_commands_service.rb6
-rw-r--r--app/models/project_services/teamcity_service.rb12
-rw-r--r--app/models/project_statistics.rb2
-rw-r--r--app/models/project_team.rb37
-rw-r--r--app/models/project_wiki.rb12
-rw-r--r--app/models/protected_branch.rb6
-rw-r--r--app/models/push_event.rb3
-rw-r--r--app/models/redirect_route.rb28
-rw-r--r--app/models/repository.rb389
-rw-r--r--app/models/route.rb32
-rw-r--r--app/models/service.rb79
-rw-r--r--app/models/snippet.rb29
-rw-r--r--app/models/todo.rb19
-rw-r--r--app/models/tree.rb20
-rw-r--r--app/models/upload.rb67
-rw-r--r--app/models/user.rb245
-rw-r--r--app/models/user_callout.rb13
-rw-r--r--app/models/user_interacted_project.rb59
-rw-r--r--app/models/user_synced_attributes_metadata.rb2
-rw-r--r--app/models/wiki_page.rb71
-rw-r--r--app/policies/base_policy.rb3
-rw-r--r--app/policies/ci/pipeline_schedule_policy.rb8
-rw-r--r--app/policies/deploy_token_policy.rb11
-rw-r--r--app/policies/group_policy.rb15
-rw-r--r--app/policies/issuable_policy.rb1
-rw-r--r--app/policies/issue_policy.rb1
-rw-r--r--app/policies/merge_request_policy.rb1
-rw-r--r--app/policies/project_policy.rb79
-rw-r--r--app/policies/protected_branch_policy.rb9
-rw-r--r--app/presenters/ci/build_metadata_presenter.rb18
-rw-r--r--app/presenters/ci/build_presenter.rb16
-rw-r--r--app/presenters/ci/group_variable_presenter.rb10
-rw-r--r--app/presenters/ci/variable_presenter.rb10
-rw-r--r--app/presenters/merge_request_presenter.rb28
-rw-r--r--app/presenters/project_presenter.rb338
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb2
-rw-r--r--app/serializers/analytics_stage_entity.rb3
-rw-r--r--app/serializers/build_details_entity.rb2
-rw-r--r--app/serializers/build_metadata_entity.rb6
-rw-r--r--app/serializers/cluster_application_entity.rb1
-rw-r--r--app/serializers/deploy_key_entity.rb9
-rw-r--r--app/serializers/deploy_keys_project_entity.rb4
-rw-r--r--app/serializers/diff_file_entity.rb41
-rw-r--r--app/serializers/discussion_entity.rb42
-rw-r--r--app/serializers/event_entity.rb4
-rw-r--r--app/serializers/group_child_entity.rb26
-rw-r--r--app/serializers/group_variable_entity.rb7
-rw-r--r--app/serializers/group_variable_serializer.rb3
-rw-r--r--app/serializers/issue_entity.rb1
-rw-r--r--app/serializers/job_entity.rb2
-rw-r--r--app/serializers/lfs_file_lock_entity.rb11
-rw-r--r--app/serializers/lfs_file_lock_serializer.rb3
-rw-r--r--app/serializers/merge_request_basic_entity.rb1
-rw-r--r--app/serializers/merge_request_metrics_entity.rb6
-rw-r--r--app/serializers/merge_request_widget_entity.rb74
-rw-r--r--app/serializers/note_entity.rb26
-rw-r--r--app/serializers/note_serializer.rb3
-rw-r--r--app/serializers/project_note_entity.rb25
-rw-r--r--app/serializers/project_note_serializer.rb3
-rw-r--r--app/serializers/project_serializer.rb3
-rw-r--r--app/serializers/status_entity.rb12
-rw-r--r--app/serializers/variable_entity.rb7
-rw-r--r--app/serializers/variable_serializer.rb3
-rw-r--r--app/services/akismet_service.rb6
-rw-r--r--app/services/auth/container_registry_authentication_service.rb25
-rw-r--r--app/services/badges/base_service.rb11
-rw-r--r--app/services/badges/build_service.rb12
-rw-r--r--app/services/badges/create_service.rb10
-rw-r--r--app/services/badges/update_service.rb12
-rw-r--r--app/services/base_service.rb1
-rw-r--r--app/services/boards/issues/list_service.rb11
-rw-r--r--app/services/boards/issues/move_service.rb9
-rw-r--r--app/services/boards/list_service.rb6
-rw-r--r--app/services/boards/lists/create_service.rb10
-rw-r--r--app/services/chat_names/find_user_service.rb4
-rw-r--r--app/services/check_gcp_project_billing_service.rb11
-rw-r--r--app/services/ci/create_pipeline_service.rb5
-rw-r--r--app/services/ci/create_pipeline_stages_service.rb20
-rw-r--r--app/services/ci/ensure_stage_service.rb12
-rw-r--r--app/services/ci/fetch_kubernetes_token_service.rb2
-rw-r--r--app/services/ci/pipeline_trigger_service.rb12
-rw-r--r--app/services/ci/register_job_service.rb21
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/clusters/applications/base_helm_service.rb2
-rw-r--r--app/services/clusters/applications/check_ingress_ip_address_service.rb36
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb2
-rw-r--r--app/services/clusters/applications/install_service.rb2
-rw-r--r--app/services/clusters/create_service.rb2
-rw-r--r--app/services/clusters/gcp/finalize_creation_service.rb4
-rw-r--r--app/services/clusters/gcp/verify_provision_status_service.rb2
-rw-r--r--app/services/compare_service.rb9
-rw-r--r--app/services/create_deployment_service.rb1
-rw-r--r--app/services/delete_merged_branches_service.rb18
-rw-r--r--app/services/deploy_tokens/create_service.rb7
-rw-r--r--app/services/event_create_service.rb2
-rw-r--r--app/services/files/create_service.rb12
-rw-r--r--app/services/files/multi_service.rb41
-rw-r--r--app/services/git_push_service.rb20
-rw-r--r--app/services/gravatar_service.rb4
-rw-r--r--app/services/groups/destroy_service.rb3
-rw-r--r--app/services/groups/nested_create_service.rb10
-rw-r--r--app/services/groups/transfer_service.rb96
-rw-r--r--app/services/issuable/destroy_service.rb1
-rw-r--r--app/services/issuable_base_service.rb25
-rw-r--r--app/services/issues/close_service.rb1
-rw-r--r--app/services/issues/fetch_referenced_merge_requests_service.rb12
-rw-r--r--app/services/issues/move_service.rb42
-rw-r--r--app/services/issues/update_service.rb17
-rw-r--r--app/services/labels/find_or_create_service.rb22
-rw-r--r--app/services/labels/promote_service.rb12
-rw-r--r--app/services/lfs/file_transformer.rb66
-rw-r--r--app/services/lfs/lock_file_service.rb39
-rw-r--r--app/services/lfs/locks_finder_service.rb17
-rw-r--r--app/services/lfs/unlock_file_service.rb43
-rw-r--r--app/services/members/approve_access_request_service.rb45
-rw-r--r--app/services/members/authorized_destroy_service.rb60
-rw-r--r--app/services/members/base_service.rb49
-rw-r--r--app/services/members/create_service.rb15
-rw-r--r--app/services/members/destroy_service.rb41
-rw-r--r--app/services/members/request_access_service.rb13
-rw-r--r--app/services/members/update_service.rb16
-rw-r--r--app/services/merge_request_metrics_service.rb19
-rw-r--r--app/services/merge_requests/base_service.rb23
-rw-r--r--app/services/merge_requests/build_service.rb35
-rw-r--r--app/services/merge_requests/close_service.rb13
-rw-r--r--app/services/merge_requests/conflicts/list_service.rb10
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb1
-rw-r--r--app/services/merge_requests/create_service.rb39
-rw-r--r--app/services/merge_requests/merge_request_diff_cache_service.rb11
-rw-r--r--app/services/merge_requests/post_merge_service.rb11
-rw-r--r--app/services/merge_requests/rebase_service.rb32
-rw-r--r--app/services/merge_requests/refresh_service.rb31
-rw-r--r--app/services/merge_requests/reopen_service.rb13
-rw-r--r--app/services/merge_requests/update_service.rb11
-rw-r--r--app/services/merge_requests/working_copy_base_service.rb24
-rw-r--r--app/services/notes/build_service.rb9
-rw-r--r--app/services/notes/post_process_service.rb8
-rw-r--r--app/services/notes/quick_actions_service.rb8
-rw-r--r--app/services/notification_recipient_service.rb53
-rw-r--r--app/services/notification_service.rb85
-rw-r--r--app/services/projects/autocomplete_service.rb14
-rw-r--r--app/services/projects/base_move_relations_service.rb22
-rw-r--r--app/services/projects/create_from_template_service.rb10
-rw-r--r--app/services/projects/create_service.rb44
-rw-r--r--app/services/projects/destroy_service.rb34
-rw-r--r--app/services/projects/fork_service.rb14
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb38
-rw-r--r--app/services/projects/hashed_storage/migrate_attachments_service.rb4
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb4
-rw-r--r--app/services/projects/housekeeping_service.rb10
-rw-r--r--app/services/projects/import_export/export_service.rb41
-rw-r--r--app/services/projects/import_service.rb8
-rw-r--r--app/services/projects/move_access_service.rb25
-rw-r--r--app/services/projects/move_deploy_keys_projects_service.rb31
-rw-r--r--app/services/projects/move_forks_service.rb42
-rw-r--r--app/services/projects/move_lfs_objects_projects_service.rb29
-rw-r--r--app/services/projects/move_notification_settings_service.rb38
-rw-r--r--app/services/projects/move_project_authorizations_service.rb40
-rw-r--r--app/services/projects/move_project_group_links_service.rb40
-rw-r--r--app/services/projects/move_project_members_service.rb40
-rw-r--r--app/services/projects/move_users_star_projects_service.rb20
-rw-r--r--app/services/projects/overwrite_project_service.rb69
-rw-r--r--app/services/projects/transfer_service.rb9
-rw-r--r--app/services/projects/update_pages_configuration_service.rb16
-rw-r--r--app/services/projects/update_pages_service.rb61
-rw-r--r--app/services/projects/update_service.rb27
-rw-r--r--app/services/prometheus/adapter_service.rb36
-rw-r--r--app/services/protected_branches/create_service.rb17
-rw-r--r--app/services/protected_branches/destroy_service.rb9
-rw-r--r--app/services/protected_branches/update_service.rb2
-rw-r--r--app/services/protected_tags/destroy_service.rb7
-rw-r--r--app/services/quick_actions/interpret_service.rb56
-rw-r--r--app/services/reset_project_cache_service.rb5
-rw-r--r--app/services/submit_usage_ping_service.rb9
-rw-r--r--app/services/system_hooks_service.rb13
-rw-r--r--app/services/system_note_service.rb53
-rw-r--r--app/services/test_hooks/base_service.rb2
-rw-r--r--app/services/test_hooks/system_service.rb7
-rw-r--r--app/services/todo_service.rb3
-rw-r--r--app/services/upload_service.rb4
-rw-r--r--app/services/users/build_service.rb6
-rw-r--r--app/services/users/destroy_service.rb9
-rw-r--r--app/services/verify_pages_domain_service.rb111
-rw-r--r--app/services/web_hook_service.rb18
-rw-r--r--app/uploaders/attachment_uploader.rb10
-rw-r--r--app/uploaders/avatar_uploader.rb19
-rw-r--r--app/uploaders/file_mover.rb16
-rw-r--r--app/uploaders/file_uploader.rb162
-rw-r--r--app/uploaders/gitlab_uploader.rb87
-rw-r--r--app/uploaders/job_artifact_uploader.rb31
-rw-r--r--app/uploaders/legacy_artifact_uploader.rb27
-rw-r--r--app/uploaders/lfs_object_uploader.rb21
-rw-r--r--app/uploaders/namespace_file_uploader.rb25
-rw-r--r--app/uploaders/object_storage.rb432
-rw-r--r--app/uploaders/personal_file_uploader.rb49
-rw-r--r--app/uploaders/records_uploads.rb80
-rw-r--r--app/uploaders/uploader_helper.rb9
-rw-r--r--app/uploaders/workhorse.rb7
-rw-r--r--app/validators/abstract_path_validator.rb6
-rw-r--r--app/validators/certificate_fingerprint_validator.rb9
-rw-r--r--app/validators/certificate_validator.rb2
-rw-r--r--app/validators/importable_url_validator.rb6
-rw-r--r--app/validators/namespace_path_validator.rb4
-rw-r--r--app/validators/project_path_validator.rb4
-rw-r--r--app/validators/top_level_group_validator.rb7
-rw-r--r--app/validators/url_placeholder_validator.rb32
-rw-r--r--app/validators/user_path_validator.rb15
-rw-r--r--app/validators/variable_duplicates_validator.rb23
-rw-r--r--app/views/admin/application_settings/_abuse.html.haml12
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml39
-rw-r--r--app/views/admin/application_settings/_background_jobs.html.haml30
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml47
-rw-r--r--app/views/admin/application_settings/_email.html.haml26
-rw-r--r--app/views/admin/application_settings/_form.html.haml829
-rw-r--r--app/views/admin/application_settings/_gitaly.html.haml27
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml22
-rw-r--r--app/views/admin/application_settings/_influx.html.haml68
-rw-r--r--app/views/admin/application_settings/_ip_limits.html.haml54
-rw-r--r--app/views/admin/application_settings/_koding.html.haml24
-rw-r--r--app/views/admin/application_settings/_logging.html.haml36
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml12
-rw-r--r--app/views/admin/application_settings/_pages.html.haml22
-rw-r--r--app/views/admin/application_settings/_performance.html.haml19
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml16
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml20
-rw-r--r--app/views/admin/application_settings/_prometheus.html.haml28
-rw-r--r--app/views/admin/application_settings/_realtime.html.haml19
-rw-r--r--app/views/admin/application_settings/_registry.html.haml10
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml62
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml58
-rw-r--r--app/views/admin/application_settings/_signin.html.haml59
-rw-r--r--app/views/admin/application_settings/_signup.html.haml58
-rw-r--r--app/views/admin/application_settings/_spam.html.haml65
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml13
-rw-r--r--app/views/admin/application_settings/_usage.html.haml37
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml66
-rw-r--r--app/views/admin/application_settings/show.html.haml305
-rw-r--r--app/views/admin/broadcast_messages/preview.js.haml1
-rw-r--r--app/views/admin/conversational_development_index/show.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml70
-rw-r--r--app/views/admin/deploy_keys/index.html.haml8
-rw-r--r--app/views/admin/gitaly_servers/index.html.haml31
-rw-r--r--app/views/admin/groups/show.html.haml4
-rw-r--r--app/views/admin/health_check/show.html.haml8
-rw-r--r--app/views/admin/hooks/_form.html.haml47
-rw-r--r--app/views/admin/hooks/edit.html.haml2
-rw-r--r--app/views/admin/hooks/index.html.haml58
-rw-r--r--app/views/admin/identities/_form.html.haml2
-rw-r--r--app/views/admin/identities/_identity.html.haml2
-rw-r--r--app/views/admin/jobs/index.html.haml10
-rw-r--r--app/views/admin/projects/_projects.html.haml9
-rw-r--r--app/views/admin/projects/show.html.haml16
-rw-r--r--app/views/admin/runners/_runner.html.haml2
-rw-r--r--app/views/admin/runners/index.html.haml6
-rw-r--r--app/views/admin/runners/show.html.haml10
-rw-r--r--app/views/admin/users/_user.html.haml25
-rw-r--r--app/views/admin/users/index.html.haml3
-rw-r--r--app/views/admin/users/projects.html.haml4
-rw-r--r--app/views/admin/users/show.html.haml24
-rw-r--r--app/views/ci/lints/show.html.haml34
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml28
-rw-r--r--app/views/ci/runner/_how_to_setup_shared_runner.html.haml3
-rw-r--r--app/views/ci/runner/_how_to_setup_specific_runner.html.haml26
-rw-r--r--app/views/ci/status/_badge.html.haml4
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml7
-rw-r--r--app/views/ci/variables/_content.html.haml4
-rw-r--r--app/views/ci/variables/_form.html.haml19
-rw-r--r--app/views/ci/variables/_index.html.haml34
-rw-r--r--app/views/ci/variables/_show.html.haml9
-rw-r--r--app/views/ci/variables/_table.html.haml32
-rw-r--r--app/views/ci/variables/_variable_row.html.haml47
-rw-r--r--app/views/dashboard/_activity_head.html.haml4
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/_projects_head.html.haml2
-rw-r--r--app/views/dashboard/_snippets_head.html.haml4
-rw-r--r--app/views/dashboard/activity.html.haml4
-rw-r--r--app/views/dashboard/groups/_groups.html.haml2
-rw-r--r--app/views/dashboard/groups/index.html.haml3
-rw-r--r--app/views/dashboard/issues.html.haml14
-rw-r--r--app/views/dashboard/merge_requests.html.haml12
-rw-r--r--app/views/dashboard/projects/_nav.html.haml4
-rw-r--r--app/views/dashboard/projects/index.html.haml3
-rw-r--r--app/views/dashboard/projects/starred.html.haml3
-rw-r--r--app/views/dashboard/todos/index.html.haml2
-rw-r--r--app/views/devise/confirmations/almost_there.haml4
-rw-r--r--app/views/devise/sessions/two_factor.html.haml4
-rw-r--r--app/views/devise/shared/_signin_box.html.haml2
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml31
-rw-r--r--app/views/discussions/_discussion.html.haml4
-rw-r--r--app/views/doorkeeper/applications/show.html.haml2
-rw-r--r--app/views/email_rejection_mailer/rejection.text.haml3
-rw-r--r--app/views/errors/access_denied.html.haml10
-rw-r--r--app/views/events/_event.atom.builder2
-rw-r--r--app/views/explore/groups/_groups.html.haml2
-rw-r--r--app/views/explore/groups/index.html.haml3
-rw-r--r--app/views/groups/_children.html.haml5
-rw-r--r--app/views/groups/boards/index.html.haml1
-rw-r--r--app/views/groups/boards/show.html.haml1
-rw-r--r--app/views/groups/edit.html.haml20
-rw-r--r--app/views/groups/group_members/update.js.haml4
-rw-r--r--app/views/groups/issues.html.haml11
-rw-r--r--app/views/groups/labels/index.html.haml6
-rw-r--r--app/views/groups/labels/new.html.haml1
-rw-r--r--app/views/groups/merge_requests.html.haml6
-rw-r--r--app/views/groups/projects.html.haml2
-rw-r--r--app/views/groups/settings/badges/index.html.haml4
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml9
-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/index.html.haml25
-rw-r--r--app/views/help/show.html.haml2
-rw-r--r--app/views/help/ui.html.haml4
-rw-r--r--app/views/ide/index.html.haml12
-rw-r--r--app/views/import/_githubish_status.html.haml22
-rw-r--r--app/views/import/base/create.js.haml13
-rw-r--r--app/views/import/base/unauthorized.js.haml14
-rw-r--r--app/views/import/github/new.html.haml35
-rw-r--r--app/views/import/github/status.html.haml6
-rw-r--r--app/views/import/gitlab_projects/new.html.haml8
-rw-r--r--app/views/invites/show.html.haml2
-rw-r--r--app/views/issues/_issue.atom.builder2
-rw-r--r--app/views/koding/index.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml6
-rw-r--r--app/views/layouts/_mailer.html.haml10
-rw-r--r--app/views/layouts/_page.html.haml3
-rw-r--r--app/views/layouts/devise.html.haml12
-rw-r--r--app/views/layouts/devise_empty.html.haml4
-rw-r--r--app/views/layouts/header/_default.html.haml37
-rw-r--r--app/views/layouts/header/_new_dropdown.haml2
-rw-r--r--app/views/layouts/header/_read_only_banner.html.haml7
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml113
-rw-r--r--app/views/layouts/nav/_explore.html.haml21
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml187
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml79
-rw-r--r--app/views/layouts/nav_only.html.haml5
-rw-r--r--app/views/layouts/notify.html.haml2
-rw-r--r--app/views/layouts/notify.text.erb2
-rw-r--r--app/views/layouts/project.html.haml2
-rw-r--r--app/views/notify/_note_email.html.haml2
-rw-r--r--app/views/notify/_note_email.text.erb2
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_user_email.html.haml2
-rw-r--r--app/views/notify/pages_domain_disabled_email.html.haml15
-rw-r--r--app/views/notify/pages_domain_disabled_email.text.haml13
-rw-r--r--app/views/notify/pages_domain_enabled_email.html.haml11
-rw-r--r--app/views/notify/pages_domain_enabled_email.text.haml9
-rw-r--r--app/views/notify/pages_domain_verification_failed_email.html.haml17
-rw-r--r--app/views/notify/pages_domain_verification_failed_email.text.haml14
-rw-r--r--app/views/notify/pages_domain_verification_succeeded_email.html.haml13
-rw-r--r--app/views/notify/pages_domain_verification_succeeded_email.text.haml10
-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_moved_email.html.haml2
-rw-r--r--app/views/notify/push_to_merge_request_email.html.haml26
-rw-r--r--app/views/notify/push_to_merge_request_email.text.haml13
-rw-r--r--app/views/peek/_bar.html.haml12
-rw-r--r--app/views/peek/views/_gc.html.haml7
-rw-r--r--app/views/peek/views/_gitaly.html.haml7
-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/_redis.html.haml7
-rw-r--r--app/views/peek/views/_sidekiq.html.haml7
-rw-r--r--app/views/peek/views/_sql.html.haml13
-rw-r--r--app/views/profiles/_head.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml30
-rw-r--r--app/views/profiles/audit_log.html.haml3
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/profiles/chat_names/index.html.haml1
-rw-r--r--app/views/profiles/emails/index.html.haml1
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml6
-rw-r--r--app/views/profiles/keys/index.html.haml5
-rw-r--r--app/views/profiles/keys/show.html.haml3
-rw-r--r--app/views/profiles/notifications/show.html.haml7
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml8
-rw-r--r--app/views/profiles/preferences/show.html.haml1
-rw-r--r--app/views/profiles/show.html.haml22
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml16
-rw-r--r--app/views/projects/_commit_button.html.haml4
-rw-r--r--app/views/projects/_export.html.haml8
-rw-r--r--app/views/projects/_home_panel.html.haml11
-rw-r--r--app/views/projects/_issuable_by_email.html.haml9
-rw-r--r--app/views/projects/_last_push.html.haml4
-rw-r--r--app/views/projects/_merge_request_fast_forward_settings.html.haml4
-rw-r--r--app/views/projects/_merge_request_rebase_settings.html.haml2
-rw-r--r--app/views/projects/_new_project_fields.html.haml10
-rw-r--r--app/views/projects/_new_project_push_tip.html.haml11
-rw-r--r--app/views/projects/_readme.html.haml8
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml8
-rw-r--r--app/views/projects/activity.html.haml3
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/blob/_header.html.haml4
-rw-r--r--app/views/projects/blob/_new_dir.html.haml6
-rw-r--r--app/views/projects/blob/_upload.html.haml11
-rw-r--r--app/views/projects/blob/_viewer.html.haml3
-rw-r--r--app/views/projects/blob/edit.html.haml1
-rw-r--r--app/views/projects/blob/new.html.haml1
-rw-r--r--app/views/projects/blob/show.html.haml7
-rw-r--r--app/views/projects/blob/viewers/_balsamiq.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_download.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_notebook.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_pdf.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_sketch.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml3
-rw-r--r--app/views/projects/branches/_branch.html.haml140
-rw-r--r--app/views/projects/branches/_panel.html.haml19
-rw-r--r--app/views/projects/branches/index.html.haml53
-rw-r--r--app/views/projects/branches/new.html.haml1
-rw-r--r--app/views/projects/buttons/_download.html.haml11
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml53
-rw-r--r--app/views/projects/buttons/_koding.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml2
-rw-r--r--app/views/projects/ci/builds/_build.html.haml6
-rw-r--r--app/views/projects/ci/lints/_create.html.haml (renamed from app/views/ci/lints/_create.html.haml)0
-rw-r--r--app/views/projects/ci/lints/show.html.haml27
-rw-r--r--app/views/projects/clusters/_advanced_settings.html.haml8
-rw-r--r--app/views/projects/clusters/_banner.html.haml19
-rw-r--r--app/views/projects/clusters/_cluster.html.haml11
-rw-r--r--app/views/projects/clusters/_dropdown.html.haml6
-rw-r--r--app/views/projects/clusters/_empty_state.html.haml9
-rw-r--r--app/views/projects/clusters/_enabled.html.haml17
-rw-r--r--app/views/projects/clusters/_integration_form.html.haml38
-rw-r--r--app/views/projects/clusters/_sidebar.html.haml6
-rw-r--r--app/views/projects/clusters/gcp/_form.html.haml8
-rw-r--r--app/views/projects/clusters/gcp/_header.html.haml12
-rw-r--r--app/views/projects/clusters/gcp/_show.html.haml8
-rw-r--r--app/views/projects/clusters/gcp/login.html.haml6
-rw-r--r--app/views/projects/clusters/gcp/new.html.haml6
-rw-r--r--app/views/projects/clusters/index.html.haml10
-rw-r--r--app/views/projects/clusters/new.html.haml10
-rw-r--r--app/views/projects/clusters/show.html.haml27
-rw-r--r--app/views/projects/clusters/user/_form.html.haml6
-rw-r--r--app/views/projects/clusters/user/_header.html.haml6
-rw-r--r--app/views/projects/clusters/user/_show.html.haml8
-rw-r--r--app/views/projects/clusters/user/new.html.haml6
-rw-r--r--app/views/projects/commit/_change.html.haml7
-rw-r--r--app/views/projects/commit/_commit_box.html.haml10
-rw-r--r--app/views/projects/commit/_limit_exceeded_message.html.haml2
-rw-r--r--app/views/projects/commit/_other_user_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/_pipelines_list.haml4
-rw-r--r--app/views/projects/commit/_same_user_different_email_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/_unverified_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/_verified_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/show.html.haml3
-rw-r--r--app/views/projects/commits/_commit.atom.builder6
-rw-r--r--app/views/projects/commits/_commit.html.haml20
-rw-r--r--app/views/projects/compare/_form.html.haml16
-rw-r--r--app/views/projects/compare/index.html.haml18
-rw-r--r--app/views/projects/compare/show.html.haml15
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml3
-rw-r--r--app/views/projects/deploy_keys/_form.html.haml18
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml2
-rw-r--r--app/views/projects/deploy_tokens/_form.html.haml29
-rw-r--r--app/views/projects/deploy_tokens/_index.html.haml18
-rw-r--r--app/views/projects/deploy_tokens/_new_deploy_token.html.haml14
-rw-r--r--app/views/projects/deploy_tokens/_revoke_modal.html.haml17
-rw-r--r--app/views/projects/deploy_tokens/_table.html.haml31
-rw-r--r--app/views/projects/deployments/_commit.html.haml2
-rw-r--r--app/views/projects/diffs/_file.html.haml4
-rw-r--r--app/views/projects/diffs/_file_header.html.haml3
-rw-r--r--app/views/projects/diffs/_stats.html.haml13
-rw-r--r--app/views/projects/edit.html.haml36
-rw-r--r--app/views/projects/empty.html.haml56
-rw-r--r--app/views/projects/environments/folder.html.haml4
-rw-r--r--app/views/projects/environments/index.html.haml4
-rw-r--r--app/views/projects/environments/metrics.html.haml11
-rw-r--r--app/views/projects/environments/terminal.html.haml1
-rw-r--r--app/views/projects/forks/error.html.haml3
-rw-r--r--app/views/projects/forks/index.html.haml4
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml4
-rw-r--r--app/views/projects/graphs/charts.html.haml36
-rw-r--r--app/views/projects/graphs/show.html.haml4
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-rw-r--r--app/views/projects/imports/show.html.haml13
-rw-r--r--app/views/projects/issues/_discussion.html.haml13
-rw-r--r--app/views/projects/issues/_issue.html.haml8
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml2
-rw-r--r--app/views/projects/issues/_new_branch.html.haml61
-rw-r--r--app/views/projects/issues/index.html.haml4
-rw-r--r--app/views/projects/issues/show.html.haml12
-rw-r--r--app/views/projects/jobs/_empty_state.html.haml18
-rw-r--r--app/views/projects/jobs/_empty_states.html.haml9
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml12
-rw-r--r--app/views/projects/jobs/_table.html.haml2
-rw-r--r--app/views/projects/jobs/_user.html.haml2
-rw-r--r--app/views/projects/jobs/show.html.haml72
-rw-r--r--app/views/projects/labels/index.html.haml1
-rw-r--r--app/views/projects/merge_requests/_how_to_merge.html.haml3
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml10
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_diffs.html.haml6
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml2
-rw-r--r--app/views/projects/merge_requests/diffs/_diffs.html.haml19
-rw-r--r--app/views/projects/merge_requests/index.html.haml7
-rw-r--r--app/views/projects/merge_requests/show.html.haml51
-rw-r--r--app/views/projects/milestones/index.html.haml3
-rw-r--r--app/views/projects/milestones/show.html.haml26
-rw-r--r--app/views/projects/network/_head.html.haml2
-rw-r--r--app/views/projects/network/show.html.haml6
-rw-r--r--app/views/projects/network/show.json.erb4
-rw-r--r--app/views/projects/new.html.haml37
-rw-r--r--app/views/projects/no_repo.html.haml6
-rw-r--r--app/views/projects/pages/_https_only.html.haml10
-rw-r--r--app/views/projects/pages/_list.html.haml32
-rw-r--r--app/views/projects/pages/show.html.haml6
-rw-r--r--app/views/projects/pages_domains/_form.html.haml56
-rw-r--r--app/views/projects/pages_domains/edit.html.haml11
-rw-r--r--app/views/projects/pages_domains/new.html.haml10
-rw-r--r--app/views/projects/pages_domains/show.html.haml51
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml20
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml7
-rw-r--r--app/views/projects/pipeline_schedules/_tabs.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_variable_row.html.haml17
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml4
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml14
-rw-r--r--app/views/projects/pipelines/charts.html.haml3
-rw-r--r--app/views/projects/pipelines/charts/_pipeline_times.haml4
-rw-r--r--app/views/projects/pipelines/charts/_pipelines.haml8
-rw-r--r--app/views/projects/pipelines/index.html.haml11
-rw-r--r--app/views/projects/pipelines/new.html.haml1
-rw-r--r--app/views/projects/pipelines/show.html.haml4
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml160
-rw-r--r--app/views/projects/project_members/update.js.haml4
-rw-r--r--app/views/projects/protected_branches/_index.html.haml3
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml4
-rw-r--r--app/views/projects/protected_branches/shared/_dropdown.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_tags/_index.html.haml3
-rw-r--r--app/views/projects/protected_tags/shared/_dropdown.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_tags_list.html.haml4
-rw-r--r--app/views/projects/registry/repositories/index.html.haml7
-rw-r--r--app/views/projects/repositories/_feed.html.haml2
-rw-r--r--app/views/projects/runners/_form.html.haml11
-rw-r--r--app/views/projects/runners/_runner.html.haml4
-rw-r--r--app/views/projects/runners/_shared_runners.html.haml11
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml5
-rw-r--r--app/views/projects/runners/show.html.haml6
-rw-r--r--app/views/projects/services/_deprecated_message.html.haml3
-rw-r--r--app/views/projects/services/_form.html.haml18
-rw-r--r--app/views/projects/services/edit.html.haml2
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_configuration_banner.html.haml26
-rw-r--r--app/views/projects/services/prometheus/_help.html.haml9
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml21
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml2
-rw-r--r--app/views/projects/settings/badges/index.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/_badge.html.haml (renamed from app/views/projects/pipelines_settings/_badge.html.haml)0
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml164
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml21
-rw-r--r--app/views/projects/settings/integrations/_project_hook.html.haml4
-rw-r--r--app/views/projects/settings/repository/show.html.haml5
-rw-r--r--app/views/projects/show.html.haml69
-rw-r--r--app/views/projects/tags/_tag.html.haml4
-rw-r--r--app/views/projects/tags/index.html.haml1
-rw-r--r--app/views/projects/tags/new.html.haml1
-rw-r--r--app/views/projects/tree/_tree_header.html.haml13
-rw-r--r--app/views/projects/update.js.haml2
-rw-r--r--app/views/projects/variables/show.html.haml1
-rw-r--r--app/views/projects/wikis/_form.html.haml8
-rw-r--r--app/views/projects/wikis/edit.html.haml5
-rw-r--r--app/views/search/_category.html.haml17
-rw-r--r--app/views/search/_filter.html.haml2
-rw-r--r--app/views/search/_results.html.haml7
-rw-r--r--app/views/search/results/_issue.html.haml2
-rw-r--r--app/views/search/results/_merge_request.html.haml2
-rw-r--r--app/views/search/results/_note.html.haml2
-rw-r--r--app/views/search/results/_snippet_blob.html.haml2
-rw-r--r--app/views/search/results/_snippet_title.html.haml4
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml2
-rw-r--r--app/views/shared/_choose_group_avatar_button.html.haml11
-rw-r--r--app/views/shared/_clone_panel.html.haml2
-rw-r--r--app/views/shared/_delete_label_modal.html.haml20
-rw-r--r--app/views/shared/_event_filter.html.haml25
-rw-r--r--app/views/shared/_field.html.haml11
-rw-r--r--app/views/shared/_group_form.html.haml2
-rw-r--r--app/views/shared/_import_form.html.haml16
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml8
-rw-r--r--app/views/shared/_label.html.haml46
-rw-r--r--app/views/shared/_label_row.html.haml18
-rw-r--r--app/views/shared/_milestones_filter.html.haml2
-rw-r--r--app/views/shared/_new_commit_form.html.haml9
-rw-r--r--app/views/shared/_recaptcha_form.html.haml5
-rw-r--r--app/views/shared/_ref_switcher.html.haml12
-rw-r--r--app/views/shared/_service_settings.html.haml8
-rw-r--r--app/views/shared/badges/_badge_settings.html.haml4
-rw-r--r--app/views/shared/boards/_show.html.haml10
-rw-r--r--app/views/shared/boards/components/_board.html.haml3
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_assignee.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_due_date.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml3
-rw-r--r--app/views/shared/boards/components/sidebar/_milestone.html.haml3
-rw-r--r--app/views/shared/builds/_tabs.html.haml2
-rw-r--r--app/views/shared/dashboard/_no_filter_selected.html.haml8
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml19
-rw-r--r--app/views/shared/form_elements/_description.html.haml2
-rw-r--r--app/views/shared/groups/_dropdown.html.haml6
-rw-r--r--app/views/shared/icons/_icon_status_notfound_borderless.svg1
-rw-r--r--app/views/shared/icons/_icon_status_success_borderless.svg1
-rw-r--r--app/views/shared/issuable/_filter.html.haml11
-rw-r--r--app/views/shared/issuable/_form.html.haml6
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml11
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml11
-rw-r--r--app/views/shared/issuable/_nav.html.haml13
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml3
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml62
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml18
-rw-r--r--app/views/shared/issuable/_sidebar_todo.html.haml8
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml5
-rw-r--r--app/views/shared/issuable/form/_contribution.html.haml20
-rw-r--r--app/views/shared/issuable/form/_title.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml4
-rw-r--r--app/views/shared/members/update.js.haml6
-rw-r--r--app/views/shared/milestones/_issuable.html.haml6
-rw-r--r--app/views/shared/milestones/_milestone.html.haml27
-rw-r--r--app/views/shared/milestones/_participants_tab.html.haml2
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml21
-rw-r--r--app/views/shared/milestones/_top.html.haml7
-rw-r--r--app/views/shared/notes/_form.html.haml35
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml10
-rw-r--r--app/views/shared/plugins/_index.html.haml23
-rw-r--r--app/views/shared/projects/_edit_information.html.haml6
-rw-r--r--app/views/shared/projects/_project.html.haml8
-rw-r--r--app/views/shared/snippets/_form.html.haml1
-rw-r--r--app/views/shared/snippets/_snippet.html.haml4
-rw-r--r--app/views/shared/web_hooks/_form.html.haml9
-rw-r--r--app/views/snippets/_snippets_scope_menu.html.haml2
-rw-r--r--app/views/u2f/_authenticate.html.haml1
-rw-r--r--app/views/u2f/_register.html.haml1
-rw-r--r--app/views/users/show.html.haml90
-rw-r--r--app/workers/all_queues.yml13
-rw-r--r--app/workers/archive_trace_worker.rb10
-rw-r--r--app/workers/authorized_projects_worker.rb38
-rw-r--r--app/workers/background_migration_worker.rb45
-rw-r--r--app/workers/build_finished_worker.rb8
-rw-r--r--app/workers/check_gcp_project_billing_worker.rb92
-rw-r--r--app/workers/cluster_wait_for_ingress_ip_address_worker.rb11
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb2
-rw-r--r--app/workers/concerns/object_storage_queue.rb8
-rw-r--r--app/workers/concerns/pipeline_background_queue.rb10
-rw-r--r--app/workers/concerns/project_import_options.rb4
-rw-r--r--app/workers/concerns/waitable_worker.rb44
-rw-r--r--app/workers/emails_on_push_worker.rb2
-rw-r--r--app/workers/git_garbage_collect_worker.rb16
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb2
-rw-r--r--app/workers/group_destroy_worker.rb2
-rw-r--r--app/workers/new_note_worker.rb2
-rw-r--r--app/workers/object_storage/background_move_worker.rb29
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb214
-rw-r--r--app/workers/object_storage_upload_worker.rb21
-rw-r--r--app/workers/pages_domain_verification_cron_worker.rb10
-rw-r--r--app/workers/pages_domain_verification_worker.rb11
-rw-r--r--app/workers/pages_worker.rb3
-rw-r--r--app/workers/plugin_worker.rb15
-rw-r--r--app/workers/post_receive.rb2
-rw-r--r--app/workers/process_commit_worker.rb13
-rw-r--r--app/workers/project_cache_worker.rb2
-rw-r--r--app/workers/project_export_worker.rb13
-rw-r--r--app/workers/rebase_worker.rb12
-rw-r--r--app/workers/remove_expired_members_worker.rb2
-rw-r--r--app/workers/repository_check/single_repository_worker.rb5
-rw-r--r--app/workers/repository_fork_worker.rb44
-rw-r--r--app/workers/repository_import_worker.rb10
-rw-r--r--app/workers/stuck_import_jobs_worker.rb40
-rw-r--r--app/workers/update_head_pipeline_for_merge_request_worker.rb2
-rw-r--r--app/workers/upload_checksum_worker.rb2
-rwxr-xr-xbin/changelog7
-rwxr-xr-xbin/profile-url57
-rwxr-xr-xbin/rails15
-rwxr-xr-xbin/rake13
-rwxr-xr-xbin/rspec6
-rwxr-xr-xbin/setup50
-rwxr-xr-xbin/update29
-rwxr-xr-xbin/upgrade.rb3
-rw-r--r--changelogs/archive.md3248
-rw-r--r--changelogs/unreleased/13634-broadcast-message.yml5
-rw-r--r--changelogs/unreleased/13695-order-contributors-in-api.yml5
-rw-r--r--changelogs/unreleased/15588-fix-empty-dropdown-on-create-new-pipeline-in-case-of-validation-errors.yml5
-rw-r--r--changelogs/unreleased/15774-fix-39233-500-in-merge-request.yml5
-rw-r--r--changelogs/unreleased/15832-fix-access-level-update-for-requesters.yml5
-rw-r--r--changelogs/unreleased/15922-validate-file-status-when-commiting-multiple-files.yml5
-rw-r--r--changelogs/unreleased/15955-improve-search-query.yml5
-rw-r--r--changelogs/unreleased/17516-nested-restore-changelog.yml5
-rw-r--r--changelogs/unreleased/17939-osw-patch-support-gfm.yml5
-rw-r--r--changelogs/unreleased/18040-rubocop-line-break-after-guard-clause.yml5
-rw-r--r--changelogs/unreleased/20394-protected-branches-wildcard.yml5
-rw-r--r--changelogs/unreleased/21143-customize-branch-name-when-using-create-branch-in-an-issue.yml5
-rw-r--r--changelogs/unreleased/22680-unlabel-slash-command-limit-autocomplete-to-applied-labels.yml5
-rw-r--r--changelogs/unreleased/23460-send-email-when-pushing-more-commits-to-the-merge-request.yml5
-rw-r--r--changelogs/unreleased/25317-prioritize-author-date-over-commit.yml5
-rw-r--r--changelogs/unreleased/27210-add-cancel-btn-to-new-page-domain.yml5
-rw-r--r--changelogs/unreleased/28004-consider-refactoring-member-view-by-using-presenter.yml4
-rw-r--r--changelogs/unreleased/28377-add-edit-button-to-mobile-file-view.yml5
-rw-r--r--changelogs/unreleased/29483-no-feedback-when-checking-on-checklist-if-potential-spam-was-detected.yml5
-rw-r--r--changelogs/unreleased/31114-internal-ids-are-not-atomic.yml5
-rw-r--r--changelogs/unreleased/31591-project-deploy-tokens-to-allow-permanent-access.yml5
-rw-r--r--changelogs/unreleased/32057_support_ordering_project_notes_in_notes_api.yml5
-rw-r--r--changelogs/unreleased/32098-pipelines-navigation.yml6
-rw-r--r--changelogs/unreleased/32617-fix-template-selector-menu-visibility.yml6
-rw-r--r--changelogs/unreleased/32878-merge-request-from-email.yml5
-rw-r--r--changelogs/unreleased/33028-event-tag-links.yml5
-rw-r--r--changelogs/unreleased/33338-internationalization-support-for-prometheus-service-configuration.yml5
-rw-r--r--changelogs/unreleased/33609-hide-pagination.yml5
-rw-r--r--changelogs/unreleased/33803-drop-json-support-in-project-milestone.yml5
-rw-r--r--changelogs/unreleased/33926-update-issuable-icons.yml5
-rw-r--r--changelogs/unreleased/34600-performance-wiki-pages.yml5
-rw-r--r--changelogs/unreleased/34604-fix-generated-url-for-external-repository.yml5
-rw-r--r--changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml5
-rw-r--r--changelogs/unreleased/35475-lazy-diff.yml5
-rw-r--r--changelogs/unreleased/35616-move-k8-to-cluster-page.yml5
-rw-r--r--changelogs/unreleased/35724-animate-sidebar.yml5
-rw-r--r--changelogs/unreleased/36020-private-npm-modules.yml5
-rw-r--r--changelogs/unreleased/36400-trigger-job.yml5
-rw-r--r--changelogs/unreleased/36958-enable-ordering-projects-subgroups-by-name.yml5
-rw-r--r--changelogs/unreleased/37354-pipelines-update.yml5
-rw-r--r--changelogs/unreleased/38019-hide-runner-token.yml5
-rw-r--r--changelogs/unreleased/38032-deploy-markers-should-be-more-verbose.yml5
-rw-r--r--changelogs/unreleased/38075_allow_refernce_integer_labels.yml5
-rw-r--r--changelogs/unreleased/38145_ux_issues_in_system_info_page.yml5
-rw-r--r--changelogs/unreleased/38167-ui-bug-when-creating-new-branch.yml5
-rw-r--r--changelogs/unreleased/38239-update-toggle-design.yml5
-rw-r--r--changelogs/unreleased/38318-search-merge-requests-with-api.yml5
-rw-r--r--changelogs/unreleased/38393-Milestone-duration-error-message-is-not-accurate-enough.yml5
-rw-r--r--changelogs/unreleased/38541-cancel-alignment.yml5
-rw-r--r--changelogs/unreleased/38822-oauth-search-case-insensitive.yml5
-rw-r--r--changelogs/unreleased/38862-email-notifications-not-sent-as-expected.yml6
-rw-r--r--changelogs/unreleased/38869-templates.yml5
-rw-r--r--changelogs/unreleased/38877-disable-autocomplete-in-filtered-search.yml5
-rw-r--r--changelogs/unreleased/38962-automatically-run-a-pipeline-when-auto-devops-is-turned-on-in-project-settings.yml5
-rw-r--r--changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml5
-rw-r--r--changelogs/unreleased/39298-list-of-avatars-2.yml5
-rw-r--r--changelogs/unreleased/39335-add-time-spend-to-milestones.yml5
-rw-r--r--changelogs/unreleased/39364-in-issue-board-url-doesn-t-take-in-account-hostname-settings.yml5
-rw-r--r--changelogs/unreleased/39367-fix-new-email-session-path.yml5
-rw-r--r--changelogs/unreleased/39436-pages-api-administrative.yml5
-rw-r--r--changelogs/unreleased/39455-clone-dropdown-should-not-have-a-tooltip.yml5
-rw-r--r--changelogs/unreleased/39497-inline-edit-issue-on-mobile.yml5
-rw-r--r--changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml5
-rw-r--r--changelogs/unreleased/39601-create-issuable-destroy-service.yml5
-rw-r--r--changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml5
-rw-r--r--changelogs/unreleased/39608-comment-on-image-discussions-tab-alignment.yml5
-rw-r--r--changelogs/unreleased/39720-group-milestone-sorting.yml5
-rw-r--r--changelogs/unreleased/39727-add-axios-to-common.yml5
-rw-r--r--changelogs/unreleased/39821-fix-commits-list-with-multi-file-editor.yml5
-rw-r--r--changelogs/unreleased/39827-fix-projects-dropdown-overflow.yml5
-rw-r--r--changelogs/unreleased/39869_show_closed_status_of_links_to_issues_on_wiki_pages.yml5
-rw-r--r--changelogs/unreleased/39880-merge-method-api.yml5
-rw-r--r--changelogs/unreleased/39884-fix-pipeline-transition-with-single-manual-action.yml6
-rw-r--r--changelogs/unreleased/39895-cant-set-mattermost-username-channel-from-api.yml5
-rw-r--r--changelogs/unreleased/39977-gitlab-shell-default-timeout.yml5
-rw-r--r--changelogs/unreleased/40016-log-header.yml5
-rw-r--r--changelogs/unreleased/40031-include-assset_sync-gem.yml5
-rw-r--r--changelogs/unreleased/40040-decouple-multi-file-editor-from-file-list.yml5
-rw-r--r--changelogs/unreleased/40063-markdown-editor-improvements.yml5
-rw-r--r--changelogs/unreleased/40122-only-one-note-webhook-is-triggered-when-a-comment-with-time-spent-is-added.yml5
-rw-r--r--changelogs/unreleased/40146_fix_special_charecter_search_in_filenames.yml5
-rw-r--r--changelogs/unreleased/40161-extra-margin-on-svg-logo-in-header.yml5
-rw-r--r--changelogs/unreleased/40286-hide-full-namespace-groups-tree.yml6
-rw-r--r--changelogs/unreleased/40290-remove-rake-gitlab-sidekiq-drop-post-receive.yml5
-rw-r--r--changelogs/unreleased/40373-fix-issue-note-submit-disabled-on-paste.yml6
-rw-r--r--changelogs/unreleased/40481-bump-jquery-to-2-2-4.yml5
-rw-r--r--changelogs/unreleased/40508-snippets-zen-mode.yml5
-rw-r--r--changelogs/unreleased/40509_sorting_tags_api.yml5
-rw-r--r--changelogs/unreleased/40530-merge-request-generates-wrong-diff-when-branch-and-tag-have-the-same-name.yml5
-rw-r--r--changelogs/unreleased/40561-environment-scope-value-is-not-trimmed.yml5
-rw-r--r--changelogs/unreleased/40568-bump-seed-fu-to-2-3-7.yml5
-rw-r--r--changelogs/unreleased/40573-rename-gke-as-kubernetes-engine.yml5
-rw-r--r--changelogs/unreleased/40770-doc-elasticsearch.yml5
-rw-r--r--changelogs/unreleased/40781-os-to-ce.yml5
-rw-r--r--changelogs/unreleased/4080-align-retry-btn.yml5
-rw-r--r--changelogs/unreleased/40871-todo-notification-count-shows-notification-without-having-a-todo.yml5
-rw-r--r--changelogs/unreleased/40895-fix-frequent-projects-stale-path.yml5
-rw-r--r--changelogs/unreleased/41016-import-gitlab-shell-projects.yml6
-rw-r--r--changelogs/unreleased/41224-pipeline-icons.yml5
-rw-r--r--changelogs/unreleased/41758-after-changing-username-url-still-redirects-to-old-route.yml5
-rw-r--r--changelogs/unreleased/41902-add-api-option-to-overwrite-project-description-on-project-export.yml5
-rw-r--r--changelogs/unreleased/41967_issue_api_closed_by_info.yml5
-rw-r--r--changelogs/unreleased/41981-allow-group-owner-to-enable-runners-from-subgroups.yml5
-rw-r--r--changelogs/unreleased/42028-xss-diffs.yml5
-rw-r--r--changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml5
-rw-r--r--changelogs/unreleased/42448-change-commit-row-actions-and-sha-design-for-project-commit-list.yml6
-rw-r--r--changelogs/unreleased/42568-pipeline-empty-state.yml5
-rw-r--r--changelogs/unreleased/42579-fix-sidebar-dropdown-hover-style.yml5
-rw-r--r--changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml5
-rw-r--r--changelogs/unreleased/43098-controller-projects-issuescontroller-show-executes-more-than-100-sql-queries.yml5
-rw-r--r--changelogs/unreleased/43215-update-design-for-verifying-domains.yml5
-rw-r--r--changelogs/unreleased/43246-checkfilter.yml6
-rw-r--r--changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml5
-rw-r--r--changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml5
-rw-r--r--changelogs/unreleased/43512-add-support-for-omniauth-jwt-provider.yml5
-rw-r--r--changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml5
-rw-r--r--changelogs/unreleased/43552-user-owned-projects-query-performance-improvement.yml5
-rw-r--r--changelogs/unreleased/43603-ci-lint-support.yml5
-rw-r--r--changelogs/unreleased/43702-update-label-dropdown-wording.yml5
-rw-r--r--changelogs/unreleased/43717-breadcrumb-on-admin-runner-page.yml5
-rw-r--r--changelogs/unreleased/43720-update-fe-webpack-docs.yml6
-rw-r--r--changelogs/unreleased/43745-store-metadata-checksum-for-artifacts.yml5
-rw-r--r--changelogs/unreleased/43771-improve-avatar-error-message.yml5
-rw-r--r--changelogs/unreleased/43786-on-the-issuable-list-add-tooltips-to-icons.yml5
-rw-r--r--changelogs/unreleased/43794-fix-domain-verification-validation-errors.yml5
-rw-r--r--changelogs/unreleased/43805-list-gitaly-calls-and-arguments-in-the-performance-bar.yml5
-rw-r--r--changelogs/unreleased/43806-update-ruby-saml-to-1-7-2.yml5
-rw-r--r--changelogs/unreleased/43933-always-notify-mentions.yml6
-rw-r--r--changelogs/unreleased/43949-verify-job-artifacts.yml5
-rw-r--r--changelogs/unreleased/43976-fix-access-token-clipboard-button-style.yml5
-rw-r--r--changelogs/unreleased/44022-singular-1-diff.yml5
-rw-r--r--changelogs/unreleased/44139-fix-issue-boards-dup-keys.yml6
-rw-r--r--changelogs/unreleased/44160-update-foreman-to-0-84-0.yml5
-rw-r--r--changelogs/unreleased/44191-reduce-redis-usage-from-merge-request-diffs-caching.yml5
-rw-r--r--changelogs/unreleased/44218-add-internationalization-support-for-the-prometheus-merge-request-widget.yml5
-rw-r--r--changelogs/unreleased/44224-remove-gl.yml5
-rw-r--r--changelogs/unreleased/44235-update-knapsack-to-1-16-0.yml5
-rw-r--r--changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml5
-rw-r--r--changelogs/unreleased/44269-show-failure-reason-on-upgrade-tooltip-of-jobs.yml5
-rw-r--r--changelogs/unreleased/44280-fix-code-search.yml5
-rw-r--r--changelogs/unreleased/44291-usage-ping-for-kubernetes-integration.yml5
-rw-r--r--changelogs/unreleased/44296-commit-path.yml6
-rw-r--r--changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml5
-rw-r--r--changelogs/unreleased/44383-cleanup-framework-header.yml5
-rw-r--r--changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml5
-rw-r--r--changelogs/unreleased/44386-better-ux-for-long-name-branches.yml5
-rw-r--r--changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml5
-rw-r--r--changelogs/unreleased/44389-always-allow-http-for-ci-git-operations.yml5
-rw-r--r--changelogs/unreleased/44392-resolve-projects-creation-silently-failing-on-after-create-error.yml5
-rw-r--r--changelogs/unreleased/44425-use-gitlab_environment.yml5
-rw-r--r--changelogs/unreleased/44508-fix-fork-namespace-images.yml5
-rw-r--r--changelogs/unreleased/44649-reference-parsing-conflicting-with-auto-linking.yml5
-rw-r--r--changelogs/unreleased/44657-reuse-root_ref_hash-on-branches.yml5
-rw-r--r--changelogs/unreleased/44665-fix-db-trace-stream-by-raw-access.yml5
-rw-r--r--changelogs/unreleased/44712-update-asciidoctor-from-1-5-3-to-1-5-6-2.yml5
-rw-r--r--changelogs/unreleased/44717-no-resolve-issue.yml5
-rw-r--r--changelogs/unreleased/44774-migrate-upload-task-fails-for-upload-with-store-nil.yml5
-rw-r--r--changelogs/unreleased/44776-fix-upload-migrate-fails-for-group.yml5
-rw-r--r--changelogs/unreleased/44870-remove-extra-space-around-comment-form-on-merge-requests.yml5
-rw-r--r--changelogs/unreleased/44878-update-brakeman-3-6-1-to-4-2-1.yml5
-rw-r--r--changelogs/unreleased/44902-remove-rake-test-ci.yml5
-rw-r--r--changelogs/unreleased/45070-prometheus-integration-via-kubernetes-is-broken.yml5
-rw-r--r--changelogs/unreleased/Link_to_project_labels_page.yml5
-rw-r--r--changelogs/unreleased/ab-37462-cache-personal-projects-count.yml5
-rw-r--r--changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml5
-rw-r--r--changelogs/unreleased/ab-44467-remove-index.yml5
-rw-r--r--changelogs/unreleased/ac-fix-use_file-race.yml5
-rw-r--r--changelogs/unreleased/ac-lfs-direct-upload-ee-to-ce.yml5
-rw-r--r--changelogs/unreleased/ac-pages-port.yml5
-rw-r--r--changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml5
-rw-r--r--changelogs/unreleased/add-canary-favicon.yml5
-rw-r--r--changelogs/unreleased/add-cpu-mem-totals.yml5
-rw-r--r--changelogs/unreleased/add-milestone-path-to-dashboard-milestones-breadcrumb-link.yml5
-rw-r--r--changelogs/unreleased/add-per-runner-job-timeout.yml5
-rw-r--r--changelogs/unreleased/add-query-counts-to-profiler-output.yml5
-rw-r--r--changelogs/unreleased/add-tcp-check-rake-task.yml5
-rw-r--r--changelogs/unreleased/add_project_ci_config_path_leading_slash_validation.yml6
-rw-r--r--changelogs/unreleased/admin-welcome-new-group-link.yml5
-rw-r--r--changelogs/unreleased/ajax-requests-in-performance-bar.yml5
-rw-r--r--changelogs/unreleased/an-gitaly-timeouts.yml5
-rw-r--r--changelogs/unreleased/anchor-issue-references.yml6
-rw-r--r--changelogs/unreleased/blackst0ne-bump-html-pipeline-gem.yml5
-rw-r--r--changelogs/unreleased/blackst0ne-rails5-update-state_machines-activerecord-gem.yml5
-rw-r--r--changelogs/unreleased/blackst0ne-replace-spinach-project-issues-issues-feature.yml5
-rw-r--r--changelogs/unreleased/blackst0ne-replace-spinach-project-issues-labels-feature.yml5
-rw-r--r--changelogs/unreleased/brand_header_change.yml5
-rw-r--r--changelogs/unreleased/bvl-circuitbreaker-process.yml5
-rw-r--r--changelogs/unreleased/bvl-delete-empty-fork-networks.yml5
-rw-r--r--changelogs/unreleased/bvl-export-import-lfs.yml5
-rw-r--r--changelogs/unreleased/bvl-fix-unlinking-with-lfs-objects.yml6
-rw-r--r--changelogs/unreleased/bvl-import-zip-multiple-assignees.yml5
-rw-r--r--changelogs/unreleased/bvl-limit-fork-queries-on-project-show.yml5
-rw-r--r--changelogs/unreleased/bvl-no-permanent-redirect.yml5
-rw-r--r--changelogs/unreleased/bvl-override-import-params.yml5
-rw-r--r--changelogs/unreleased/bvl-subgroup-in-dropdowns.yml5
-rw-r--r--changelogs/unreleased/ci-pipeline-commit-lookup.yml5
-rw-r--r--changelogs/unreleased/cleanup-issues-schema.yml5
-rw-r--r--changelogs/unreleased/commit-title-wrapping.yml5
-rw-r--r--changelogs/unreleased/da-gitaly-calculate-repository-checksum.yml5
-rw-r--r--changelogs/unreleased/dashboard-view-user-choices-issues-merge-requests.yml5
-rw-r--r--changelogs/unreleased/deploy-keys-loading-icon.yml5
-rw-r--r--changelogs/unreleased/direct-upload-of-uploads.yml5
-rw-r--r--changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml5
-rw-r--r--changelogs/unreleased/dm-deploy-keys-default-user.yml5
-rw-r--r--changelogs/unreleased/dm-flatten-tree-plus-chars.yml5
-rw-r--r--changelogs/unreleased/dm-image-blob-diff-full-url.yml5
-rw-r--r--changelogs/unreleased/dm-internal-user-namespace.yml5
-rw-r--r--changelogs/unreleased/dm-ldap-email-readonly.yml5
-rw-r--r--changelogs/unreleased/dm-refs-contains-sha-encoding.yml5
-rw-r--r--changelogs/unreleased/dm-search-pattern.yml5
-rw-r--r--changelogs/unreleased/docs-add-why-do-i-get-signed-out-authentication-section.yml5
-rw-r--r--changelogs/unreleased/dz-improve-app-settings-2.yml5
-rw-r--r--changelogs/unreleased/enable-scss-lint-unnecessary-mantissa.yml5
-rw-r--r--changelogs/unreleased/escape-autocomplete-values-for-markdown.yml5
-rw-r--r--changelogs/unreleased/expose-commits-mr-api.yml5
-rw-r--r--changelogs/unreleased/feature-40842-provide-oracles-webgate-cookies-to-jira-requests.yml6
-rw-r--r--changelogs/unreleased/feature-add_target_to_tags.yml5
-rw-r--r--changelogs/unreleased/feature-custom-text-for-new-projects.yml5
-rw-r--r--changelogs/unreleased/feature-disable-password-authentication.yml5
-rw-r--r--changelogs/unreleased/feature-gb-variables-expressions-in-only-except.yml5
-rw-r--r--changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml5
-rw-r--r--changelogs/unreleased/feature_add_mermaid.yml5
-rw-r--r--changelogs/unreleased/feature_detect_co_authored_commits.yml6
-rw-r--r--changelogs/unreleased/fix-40798-namespace-forking.yml5
-rw-r--r--changelogs/unreleased/fix-42459---in-branch.yml5
-rw-r--r--changelogs/unreleased/fix-500-error-when-mr-ref-is-not-yet-fetched.yml6
-rw-r--r--changelogs/unreleased/fix-auth0-unsafe-login.yml5
-rw-r--r--changelogs/unreleased/fix-create-mr-from-issue-with-template.yml5
-rw-r--r--changelogs/unreleased/fix-dashboard-sorting.yml5
-rw-r--r--changelogs/unreleased/fix-docs-help-shortcut.yml5
-rw-r--r--changelogs/unreleased/fix-emoji-popup.yml5
-rw-r--r--changelogs/unreleased/fix-event-target-author-preloading.yml5
-rw-r--r--changelogs/unreleased/fix-filter-by-my-reaction.yml5
-rw-r--r--changelogs/unreleased/fix-gb-fix-background-pipeline-stages-migration.yml5
-rw-r--r--changelogs/unreleased/fix-mattermost-delete-team.yml5
-rw-r--r--changelogs/unreleased/fix-n-plus-one-when-getting-notification-settings-for-recipients.yml5
-rw-r--r--changelogs/unreleased/fix-new-project-guidelines-styling.yml5
-rw-r--r--changelogs/unreleased/fix-onion-skin-reenter.yml5
-rw-r--r--changelogs/unreleased/fix-projects-no-repository-placeholder.yml5
-rw-r--r--changelogs/unreleased/fix-protected-branches-descriptions.yml5
-rw-r--r--changelogs/unreleased/fix-sm-31771-do-not-allow-jobs-to-be-erased-new.yml5
-rw-r--r--changelogs/unreleased/fix-sm-37991-avoid-deactivation-when-pipeline-schedules-execute-a-commit-includes-ci-skip.yml6
-rw-r--r--changelogs/unreleased/fix-todos-last-page.yml5
-rw-r--r--changelogs/unreleased/fix_build_count_in_pipeline_success_maild.yml5
-rw-r--r--changelogs/unreleased/fj-15329-services-callbacks-ssrf.yml5
-rw-r--r--changelogs/unreleased/fj-174-better-ldap-connection-handling.yml5
-rw-r--r--changelogs/unreleased/fj-40279-normalize-ldap-dn-api.yml5
-rw-r--r--changelogs/unreleased/fj-40407-missing-order-paginate.yml5
-rw-r--r--changelogs/unreleased/fj-40752-forks-api-not-using-services.yml5
-rw-r--r--changelogs/unreleased/fj-41900-import-endpoint-with-overwrite-support.yml5
-rw-r--r--changelogs/unreleased/fj-42685-extend-project-export-endpoint.yml5
-rw-r--r--changelogs/unreleased/fl-fix-annoying-actions.yml5
-rw-r--r--changelogs/unreleased/fl-upgrade-svg.yml5
-rw-r--r--changelogs/unreleased/group-new-miletone-breadcrumb.yml5
-rw-r--r--changelogs/unreleased/hashed-storage-attachments-migration-path.yml5
-rw-r--r--changelogs/unreleased/ide-file-row-hover-style.yml5
-rw-r--r--changelogs/unreleased/ide-folder-button-path.yml5
-rw-r--r--changelogs/unreleased/ide-project-avatar-identicon.yml5
-rw-r--r--changelogs/unreleased/improve-jobs-queuing-time-metric.yml5
-rw-r--r--changelogs/unreleased/improved-changes-dropdown.yml5
-rw-r--r--changelogs/unreleased/increase-unicorn-memory-killer-limits.yml5
-rw-r--r--changelogs/unreleased/issue-description-field-typo.yml5
-rw-r--r--changelogs/unreleased/issue_25542.yml5
-rw-r--r--changelogs/unreleased/issue_39238.yml5
-rw-r--r--changelogs/unreleased/issue_40915.yml5
-rw-r--r--changelogs/unreleased/issue_42443.yml5
-rw-r--r--changelogs/unreleased/issue_44270.yml5
-rw-r--r--changelogs/unreleased/issue_44551.yml5
-rw-r--r--changelogs/unreleased/jej-commit-api-tracks-lfs.yml5
-rw-r--r--changelogs/unreleased/jej-mattermost-notification-confidentiality.yml5
-rw-r--r--changelogs/unreleased/jivl-change-copy-text-promote-milestones-labels.yml5
-rw-r--r--changelogs/unreleased/jivl-realtime-update-adding-file.yml5
-rw-r--r--changelogs/unreleased/jivl-summary-statistics-prometheus-dashboard.yml5
-rw-r--r--changelogs/unreleased/jprovazn-issueref.yml6
-rw-r--r--changelogs/unreleased/jramsay-38830-tarball.yml5
-rw-r--r--changelogs/unreleased/lfs-badge.yml5
-rw-r--r--changelogs/unreleased/merge-request-lock-icon-size-fix.yml5
-rw-r--r--changelogs/unreleased/merge-request-widget-source-branch-improvements.yml6
-rw-r--r--changelogs/unreleased/merge-requests-schema-cleanup.yml5
-rw-r--r--changelogs/unreleased/mk-add-old-attachments-to-uploads-table.yml5
-rw-r--r--changelogs/unreleased/mk-add-user-rate-limits.yml6
-rw-r--r--changelogs/unreleased/mk-fix-schema-dump-of-untracked-files-for-uploads.yml5
-rw-r--r--changelogs/unreleased/move-board-blank-state-vue-component.yml5
-rw-r--r--changelogs/unreleased/move-email-footer-info-to-single-line.yml5
-rw-r--r--changelogs/unreleased/move-registry-after-cicd-project-nav-sidebar.yml5
-rw-r--r--changelogs/unreleased/multiple-clusters-single-list.yml5
-rw-r--r--changelogs/unreleased/optimize-issues-avoid-noop-empty-cache-updates2.yml6
-rw-r--r--changelogs/unreleased/optimize-projects-for-imported-projects.yml6
-rw-r--r--changelogs/unreleased/optional-api-delimiter.yml5
-rw-r--r--changelogs/unreleased/osw-41401-render-mr-commit-sha-instead-diffs.yml5
-rw-r--r--changelogs/unreleased/osw-44295-adjust-authorization-for-discussions-show.yml5
-rw-r--r--changelogs/unreleased/osw-isolate-mr-widget-exposed-attributes.yml5
-rw-r--r--changelogs/unreleased/outdated-browser-position-fix.yml5
-rw-r--r--changelogs/unreleased/pages_force_https.yml5
-rw-r--r--changelogs/unreleased/patch-24.yml5
-rw-r--r--changelogs/unreleased/pawel-reduce_cardinality_of_prometheus_metrics.yml5
-rw-r--r--changelogs/unreleased/pawel-update_prometheus_gem_to_well_tested_version.yml5
-rw-r--r--changelogs/unreleased/perform-sql-matching-of-tags.yml5
-rw-r--r--changelogs/unreleased/poc-upload-hashing-path.yml5
-rw-r--r--changelogs/unreleased/reduce-query-count-for-mergerequestscontroller-show.yml5
-rw-r--r--changelogs/unreleased/refactor-move-assignee-title-vue-component.yml5
-rw-r--r--changelogs/unreleased/refactor-move-mr-widget-memory-usage-and-graph-components.yml5
-rw-r--r--changelogs/unreleased/refactor-move-mr-widget-nothing-to-merge-vue-component.yml5
-rw-r--r--changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml5
-rw-r--r--changelogs/unreleased/refactor-move-mr-widget-unresolved-discussions-vue-component.yml5
-rw-r--r--changelogs/unreleased/refactor-move-time-tracking-comparison-pane-vue-component.yml5
-rw-r--r--changelogs/unreleased/refactor-move-time-tracking-vue-components.yml5
-rw-r--r--changelogs/unreleased/remove-incorrect-guidance.yml6
-rw-r--r--changelogs/unreleased/remove-links-mr-empty-state.yml5
-rw-r--r--changelogs/unreleased/remove-pages-tar-support.yml5
-rw-r--r--changelogs/unreleased/remove-tabindexes-from-tag-form.yml5
-rw-r--r--changelogs/unreleased/sh-add-cleanup-rpc-gitaly.yml5
-rw-r--r--changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml5
-rw-r--r--changelogs/unreleased/sh-appearance-cache-key-version.yml5
-rw-r--r--changelogs/unreleased/sh-cleanup-pages-worker.yml5
-rw-r--r--changelogs/unreleased/sh-gitlab-sidekiq-logger.yml5
-rw-r--r--changelogs/unreleased/sh-move-sidekiq-exporter-logs.yml5
-rw-r--r--changelogs/unreleased/sh-remove-allocation-tracking-influxdb.yml5
-rw-r--r--changelogs/unreleased/show-inline-edit-btn.yml5
-rw-r--r--changelogs/unreleased/skip_confirmation_user_API.yml7
-rw-r--r--changelogs/unreleased/sophie-h-gitlab-ce-patch-15.yml5
-rw-r--r--changelogs/unreleased/tc-correct-email-in-reply-to.yml5
-rw-r--r--changelogs/unreleased/tc-re-add-read-only-banner.yml5
-rw-r--r--changelogs/unreleased/text-utils.yml5
-rw-r--r--changelogs/unreleased/throttle-touching-of-objects.yml5
-rw-r--r--changelogs/unreleased/tm-feature-list-runners-jobs-api.yml5
-rw-r--r--changelogs/unreleased/tm-feature-namespace-by-id-api.yml5
-rw-r--r--changelogs/unreleased/ui-mr-counter-cache.yml5
-rw-r--r--changelogs/unreleased/update-emoji-digests-with-latest-from-gemojione.yml6
-rw-r--r--changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml5
-rw-r--r--changelogs/unreleased/update-merge-worker-metrics.yml5
-rw-r--r--changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml5
-rw-r--r--changelogs/unreleased/update-unresolved-discussions-vue-component.yml5
-rw-r--r--changelogs/unreleased/update_mr_changes_empty_page.yml5
-rw-r--r--changelogs/unreleased/use-chronic-duration-attribute-for-project-build-timeout.yml5
-rw-r--r--changelogs/unreleased/use-count_commits-directly.yml5
-rw-r--r--changelogs/unreleased/use-merge-requests-diff-id-column.yml5
-rw-r--r--changelogs/unreleased/user-agent-gke-api.yml5
-rw-r--r--changelogs/unreleased/winh-41174-projects-groups-badges-ui.yml5
-rw-r--r--changelogs/unreleased/winh-deprecate-old-modal.yml5
-rw-r--r--changelogs/unreleased/winh-subgroups-api.yml5
-rw-r--r--changelogs/unreleased/winh-translate-contributors-page-dates.yml5
-rw-r--r--changelogs/unreleased/workhorse-gitaly-mandatory.yml5
-rw-r--r--changelogs/unreleased/zj-bump-gitaly.yml5
-rw-r--r--changelogs/unreleased/zj-commit-show-n-1.yml5
-rw-r--r--changelogs/unreleased/zj-empty-repo-importer.yml5
-rw-r--r--changelogs/unreleased/zj-feature-gate-remove-http-api.yml5
-rw-r--r--changelogs/unreleased/zj-find-license-opt-out.yml5
-rw-r--r--changelogs/unreleased/zj-memoization-mr-commits.yml5
-rw-r--r--changelogs/unreleased/zj-opt-out-delete-refs.yml5
-rw-r--r--changelogs/unreleased/zj-opt-out-list-commits-by-oid.yml5
-rw-r--r--changelogs/unreleased/zj-remote-repo-exists.yml5
-rw-r--r--config.ru10
-rw-r--r--config/application.rb28
-rw-r--r--config/boot.rb11
-rw-r--r--config/dependency_decisions.yml31
-rw-r--r--config/environment.rb8
-rw-r--r--config/environments/production.rb6
-rw-r--r--config/environments/test.rb8
-rw-r--r--config/gitlab.yml.example124
-rw-r--r--config/initializers/0_as_concern.rb25
-rw-r--r--config/initializers/0_post_deployment_migrations.rb12
-rw-r--r--config/initializers/1_settings.rb67
-rw-r--r--config/initializers/6_validations.rb6
-rw-r--r--config/initializers/8_metrics.rb1
-rw-r--r--config/initializers/active_record_array_type_casting.rb31
-rw-r--r--config/initializers/active_record_data_types.rb7
-rw-r--r--config/initializers/active_record_locking.rb108
-rw-r--r--config/initializers/application_controller_renderer.rb12
-rw-r--r--config/initializers/ar5_batching.rb72
-rw-r--r--config/initializers/ar5_pg_10_support.rb58
-rw-r--r--config/initializers/ar_native_database_types.rb11
-rw-r--r--config/initializers/artifacts_direct_upload_support.rb7
-rw-r--r--config/initializers/asset_sync.rb4
-rw-r--r--config/initializers/backtrace_silencers.rb9
-rw-r--r--config/initializers/carrierwave.rb12
-rw-r--r--config/initializers/date_time_formats.rb2
-rw-r--r--config/initializers/devise.rb51
-rw-r--r--config/initializers/doorkeeper.rb2
-rw-r--r--config/initializers/doorkeeper_openid_connect.rb1
-rw-r--r--config/initializers/flipper.rb2
-rw-r--r--config/initializers/fog_google_https_private_urls.rb20
-rw-r--r--config/initializers/forbid_sidekiq_in_transactions.rb23
-rw-r--r--config/initializers/gollum.rb102
-rw-r--r--config/initializers/grape_route_helpers_fix.rb16
-rw-r--r--config/initializers/lograge.rb25
-rw-r--r--config/initializers/mime_types.rb2
-rw-r--r--config/initializers/new_framework_defaults.rb29
-rw-r--r--config/initializers/omniauth.rb4
-rw-r--r--config/initializers/peek.rb12
-rw-r--r--config/initializers/query_limiting.rb9
-rw-r--r--config/initializers/rack_attack_global.rb5
-rw-r--r--config/initializers/rugged_use_gitlab_git_attributes.rb28
-rw-r--r--config/initializers/sidekiq.rb11
-rw-r--r--config/initializers/warden.rb4
-rw-r--r--config/karma.config.js10
-rw-r--r--config/locales/doorkeeper.en.yml2
-rw-r--r--config/locales/en.yml12
-rw-r--r--config/prometheus/additional_metrics.yml40
-rw-r--r--config/routes.rb10
-rw-r--r--config/routes/admin.rb4
-rw-r--r--config/routes/ci.rb2
-rw-r--r--config/routes/git_http.rb9
-rw-r--r--config/routes/group.rb15
-rw-r--r--config/routes/project.rb60
-rw-r--r--config/routes/repository.rb8
-rw-r--r--config/routes/uploads.rb10
-rw-r--r--config/routes/user.rb4
-rw-r--r--config/sidekiq_queues.yml5
-rw-r--r--config/spring.rb6
-rw-r--r--config/svg.config.js48
-rw-r--r--config/unicorn.rb.example.development13
-rw-r--r--config/webpack.config.js333
-rw-r--r--db/fixtures/development/01_admin.rb1
-rw-r--r--db/fixtures/development/04_project.rb3
-rw-r--r--db/fixtures/development/06_teams.rb2
-rw-r--r--db/fixtures/development/22_labeled_issues_seed.rb103
-rw-r--r--db/migrate/20160301174731_add_fingerprint_index.rb17
-rw-r--r--db/migrate/20160621123729_add_rebase_commit_sha_to_merge_requests.rb22
-rw-r--r--db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb2
-rw-r--r--db/migrate/20161220141214_remove_dot_git_from_group_names.rb2
-rw-r--r--db/migrate/20161226122833_remove_dot_git_from_usernames.rb4
-rw-r--r--db/migrate/20170425112128_create_pipeline_schedules_table.rb2
-rw-r--r--db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb8
-rw-r--r--db/migrate/20170531180233_add_authorized_keys_enabled_to_application_settings.rb19
-rw-r--r--db/migrate/20170601163708_add_artifacts_store_to_ci_build.rb10
-rw-r--r--db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb12
-rw-r--r--db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb12
-rw-r--r--db/migrate/20170825015534_add_file_store_to_lfs_objects.rb31
-rw-r--r--db/migrate/20170827123848_add_index_on_merge_request_diff_commit_sha.rb17
-rw-r--r--db/migrate/20170918072949_add_file_store_job_artifacts.rb10
-rw-r--r--db/migrate/20170928124105_create_fork_networks.rb1
-rw-r--r--db/migrate/20170928133643_create_fork_network_members.rb1
-rw-r--r--db/migrate/20170929131201_populate_fork_networks.rb16
-rw-r--r--db/migrate/20171019141859_fix_dev_timezone_schema.rb25
-rw-r--r--db/migrate/20171106151218_issues_moved_to_id_foreign_key.rb16
-rw-r--r--db/migrate/20171127151038_add_events_related_columns_to_merge_request_metrics.rb37
-rw-r--r--db/migrate/20171207185153_add_merge_request_state_index.rb18
-rw-r--r--db/migrate/20171211145425_add_can_push_to_deploy_keys_projects.rb15
-rw-r--r--db/migrate/20171212203433_create_clusters_applications_prometheus.rb18
-rw-r--r--db/migrate/20171214144320_add_store_column_to_uploads.rb12
-rw-r--r--db/migrate/20171215113714_populate_can_push_from_deploy_keys_projects.rb64
-rw-r--r--db/migrate/20171216111734_clean_up_for_members.rb31
-rw-r--r--db/migrate/20171216112339_add_foreign_key_for_members.rb21
-rw-r--r--db/migrate/20171220191323_add_index_on_namespaces_lower_name.rb32
-rw-r--r--db/migrate/20171222115326_add_confidential_note_events_to_web_hooks.rb15
-rw-r--r--db/migrate/20171222183504_add_jobs_cache_index_to_project.rb13
-rw-r--r--db/migrate/20171229225929_change_user_project_limit_not_null_and_remove_default.rb38
-rw-r--r--db/migrate/20171230123729_add_rebase_commit_sha_to_merge_requests_ce.rb15
-rw-r--r--db/migrate/20180102220145_add_pages_https_only_to_projects.rb9
-rw-r--r--db/migrate/20180103123548_add_confidential_note_events_to_services.rb16
-rw-r--r--db/migrate/20180105212544_add_commits_count_to_merge_request_diff.rb29
-rw-r--r--db/migrate/20180109183319_change_default_value_for_pages_https_only.rb13
-rw-r--r--db/migrate/20180113220114_rework_redirect_routes_indexes.rb68
-rw-r--r--db/migrate/20180115201419_add_index_updated_at_to_issues.rb15
-rw-r--r--db/migrate/20180116193854_create_lfs_file_locks.rb30
-rw-r--r--db/migrate/20180119135717_add_uploader_index_to_uploads.rb20
-rw-r--r--db/migrate/20180119160751_optimize_ci_job_artifacts.rb23
-rw-r--r--db/migrate/20180122162010_add_auto_devops_domain_to_application_settings.rb13
-rw-r--r--db/migrate/20180125214301_create_user_callouts.rb16
-rw-r--r--db/migrate/20180129193323_add_uploads_builder_context.rb14
-rw-r--r--db/migrate/20180201102129_add_unique_constraint_to_trending_projects_project_id.rb19
-rw-r--r--db/migrate/20180201110056_add_foreign_keys_to_todos.rb38
-rw-r--r--db/migrate/20180201145907_migrate_remaining_issues_closed_at.rb55
-rw-r--r--db/migrate/20180206200543_reset_events_primary_key_sequence.rb35
-rw-r--r--db/migrate/20180208183958_schedule_populate_untracked_uploads_if_needed.rb47
-rw-r--r--db/migrate/20180209165249_add_closed_by_to_issues.rb20
-rw-r--r--db/migrate/20180212030105_add_external_ip_to_clusters_applications_ingress.rb9
-rw-r--r--db/migrate/20180213131630_add_partial_index_to_projects_for_index_only_scans.rb21
-rw-r--r--db/migrate/20180214093516_create_badges.rb17
-rw-r--r--db/migrate/20180214155405_create_clusters_applications_runners.rb32
-rw-r--r--db/migrate/20180215181245_users_name_lower_index.rb29
-rw-r--r--db/migrate/20180216120000_add_pages_domain_verification.rb8
-rw-r--r--db/migrate/20180216120010_add_pages_domain_verified_at_index.rb15
-rw-r--r--db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb7
-rw-r--r--db/migrate/20180216120030_add_pages_domain_enabled_until.rb7
-rw-r--r--db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb17
-rw-r--r--db/migrate/20180216120050_pages_domains_verification_grace_period.rb26
-rw-r--r--db/migrate/20180219153455_add_maximum_timeout_to_ci_runners.rb9
-rw-r--r--db/migrate/20180221151752_add_allow_maintainer_to_push_to_merge_requests.rb18
-rw-r--r--db/migrate/20180222043024_add_ip_address_to_runner.rb9
-rw-r--r--db/migrate/20180223120443_create_user_interacted_projects_table.rb20
-rw-r--r--db/migrate/20180223144945_add_allow_local_requests_from_hooks_and_services_to_application_settings.rb18
-rw-r--r--db/migrate/20180226050030_add_checksum_to_ci_job_artifacts.rb7
-rw-r--r--db/migrate/20180227182112_add_group_id_to_boards_ce.rb34
-rw-r--r--db/migrate/20180301010859_create_ci_builds_metadata_table.rb20
-rw-r--r--db/migrate/20180302152117_ensure_foreign_keys_on_clusters_applications.rb50
-rw-r--r--db/migrate/20180305095250_create_internal_ids_table.rb15
-rw-r--r--db/migrate/20180305144721_add_privileged_to_runner.rb18
-rw-r--r--db/migrate/20180306134842_add_missing_indexes_acts_as_taggable_on_engine.rb21
-rw-r--r--db/migrate/20180308052825_add_section_name_id_index_on_ci_build_trace_sections.rb23
-rw-r--r--db/migrate/20180309121820_reschedule_commits_count_for_merge_request_diff.rb30
-rw-r--r--db/migrate/20180309160427_add_partial_indexes_on_todos.rb28
-rw-r--r--db/migrate/20180319190020_create_deploy_tokens.rb19
-rw-r--r--db/migrate/20180320182229_add_indexes_for_user_activity_queries.rb40
-rw-r--r--db/migrate/20180323150945_add_push_to_merge_request_to_notification_settings.rb9
-rw-r--r--db/migrate/20180327101207_remove_index_from_events_table.rb18
-rw-r--r--db/migrate/20180405142733_create_project_deploy_tokens.rb16
-rw-r--r--db/post_migrate/20170518200835_rename_users_with_renamed_namespace.rb1
-rw-r--r--db/post_migrate/20170717111152_cleanup_move_system_upload_folder_symlink.rb2
-rw-r--r--db/post_migrate/20170907170235_delete_conflicting_redirect_routes.rb28
-rw-r--r--db/post_migrate/20171124104327_migrate_kubernetes_service_to_new_clusters_architectures.rb151
-rw-r--r--db/post_migrate/20171124150326_reschedule_fork_network_creation.rb16
-rw-r--r--db/post_migrate/20171128214150_schedule_populate_merge_request_metrics_with_events_data.rb38
-rw-r--r--db/post_migrate/20171207150300_remove_project_labels_group_id_copy.rb21
-rw-r--r--db/post_migrate/20171207150343_remove_soft_removed_objects.rb208
-rw-r--r--db/post_migrate/20171207150344_remove_deleted_at_columns.rb31
-rw-r--r--db/post_migrate/20171215121205_post_populate_can_push_from_deploy_keys_projects.rb63
-rw-r--r--db/post_migrate/20171215121259_remove_can_push_from_keys.rb17
-rw-r--r--db/post_migrate/20171221140220_schedule_issues_closed_at_type_change.rb45
-rw-r--r--db/post_migrate/20180104131052_schedule_set_confidential_note_events_on_webhooks.rb23
-rw-r--r--db/post_migrate/20180119121225_remove_redundant_pipeline_stages.rb66
-rw-r--r--db/post_migrate/20180122154930_schedule_set_confidential_note_events_on_services.rb23
-rw-r--r--db/post_migrate/20180202111106_remove_project_labels_group_id.rb19
-rw-r--r--db/post_migrate/20180204200836_change_author_id_to_not_null_in_todos.rb26
-rw-r--r--db/post_migrate/20180212101828_add_tmp_partial_null_index_to_builds.rb14
-rw-r--r--db/post_migrate/20180212101928_schedule_build_stage_migration.rb14
-rw-r--r--db/post_migrate/20180212102028_remove_tmp_partial_null_index_from_builds.rb14
-rw-r--r--db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb41
-rw-r--r--db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb16
-rw-r--r--db/post_migrate/20180220150310_remove_empty_extern_uid_auth0_identities.rb25
-rw-r--r--db/post_migrate/20180223124427_build_user_interacted_projects_table.rb170
-rw-r--r--db/post_migrate/20180301084653_change_project_namespace_id_not_null.rb29
-rw-r--r--db/post_migrate/20180305100050_remove_permanent_from_redirect_routes.rb37
-rw-r--r--db/post_migrate/20180306074045_migrate_create_trace_artifact_sidekiq_queue.rb13
-rw-r--r--db/post_migrate/20180306164012_add_path_index_to_redirect_routes.rb38
-rw-r--r--db/post_migrate/20180307012445_migrate_update_head_pipeline_for_merge_request_sidekiq_queue.rb15
-rw-r--r--db/post_migrate/20180405101928_reschedule_builds_stages_migration.rb33
-rw-r--r--db/schema.rb210
-rw-r--r--doc/README.md235
-rw-r--r--doc/administration/auth/crowd.md12
-rw-r--r--doc/administration/auth/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png (renamed from doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png)bin27877 -> 27877 bytes
-rw-r--r--doc/administration/auth/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif (renamed from doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif)bin222162 -> 222162 bytes
-rw-r--r--doc/administration/auth/how_to_configure_ldap_gitlab_ce/img/user_auth.gif (renamed from doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif)bin110971 -> 110971 bytes
-rw-r--r--doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md270
-rw-r--r--doc/administration/auth/img/crowd_application_authorisation.pngbin0 -> 126994 bytes
-rw-r--r--doc/administration/auth/jwt.md72
-rw-r--r--doc/administration/auth/ldap.md42
-rw-r--r--doc/administration/auth/okta.md2
-rw-r--r--doc/administration/container_registry.md2
-rw-r--r--doc/administration/custom_hooks.md5
-rw-r--r--doc/administration/environment_variables.md2
-rw-r--r--doc/administration/gitaly/index.md24
-rw-r--r--doc/administration/high_availability/gitlab.md10
-rw-r--r--doc/administration/high_availability/nfs.md4
-rw-r--r--doc/administration/high_availability/redis.md2
-rw-r--r--doc/administration/img/circuitbreaker_config.pngbin335073 -> 99523 bytes
-rw-r--r--doc/administration/img/repository_storages_admin_ui.pngbin17760 -> 70416 bytes
-rw-r--r--doc/administration/incoming_email.md332
-rw-r--r--doc/administration/index.md27
-rw-r--r--doc/administration/integration/plantuml.md32
-rw-r--r--doc/administration/issue_closing_pattern.md4
-rw-r--r--doc/administration/job_artifacts.md128
-rw-r--r--doc/administration/job_traces.md42
-rw-r--r--doc/administration/logs.md37
-rw-r--r--doc/administration/monitoring/index.md3
-rw-r--r--doc/administration/monitoring/performance/img/performance_bar.pngbin170256 -> 344274 bytes
-rw-r--r--doc/administration/monitoring/performance/img/performance_bar_gitaly_calls.pngbin0 -> 278693 bytes
-rw-r--r--doc/administration/monitoring/performance/performance_bar.md12
-rw-r--r--doc/administration/monitoring/prometheus/index.md9
-rw-r--r--doc/administration/operations/fast_ssh_key_lookup.md176
-rw-r--r--doc/administration/operations/img/write_to_authorized_keys_setting.pngbin0 -> 94218 bytes
-rw-r--r--doc/administration/operations/index.md3
-rw-r--r--doc/administration/operations/speed_up_ssh.md1
-rw-r--r--doc/administration/pages/index.md27
-rw-r--r--doc/administration/plugins.md80
-rw-r--r--doc/administration/raketasks/check.md57
-rw-r--r--doc/administration/raketasks/maintenance.md28
-rw-r--r--doc/administration/raketasks/uploads/migrate.md74
-rw-r--r--doc/administration/reply_by_email.md354
-rw-r--r--doc/administration/reply_by_email_postfix_setup.md12
-rw-r--r--doc/administration/repository_storage_types.md95
-rw-r--r--doc/administration/uploads.md210
-rw-r--r--doc/api/README.md6
-rw-r--r--doc/api/applications.md37
-rw-r--r--doc/api/award_emoji.md6
-rw-r--r--doc/api/boards.md115
-rw-r--r--doc/api/branches.md1
-rw-r--r--doc/api/commits.md116
-rw-r--r--doc/api/deploy_keys.md48
-rw-r--r--doc/api/deployments.md2
-rw-r--r--doc/api/discussions.md411
-rw-r--r--doc/api/events.md4
-rw-r--r--doc/api/features.md8
-rw-r--r--doc/api/group_badges.md194
-rw-r--r--doc/api/group_boards.md284
-rw-r--r--doc/api/group_milestones.md2
-rw-r--r--doc/api/groups.md8
-rw-r--r--doc/api/issues.md101
-rw-r--r--doc/api/jobs.md14
-rw-r--r--doc/api/merge_requests.md184
-rw-r--r--doc/api/milestones.md15
-rw-r--r--doc/api/namespaces.md10
-rw-r--r--doc/api/notes.md79
-rw-r--r--doc/api/notification_settings.md4
-rw-r--r--doc/api/pages_domains.md1
-rw-r--r--doc/api/pipeline_triggers.md5
-rw-r--r--doc/api/project_badges.md191
-rw-r--r--doc/api/project_import_export.md174
-rw-r--r--doc/api/project_snippets.md9
-rw-r--r--doc/api/projects.md51
-rw-r--r--doc/api/repositories.md3
-rw-r--r--doc/api/repository_files.md13
-rw-r--r--doc/api/runners.md47
-rw-r--r--doc/api/search.md800
-rw-r--r--doc/api/services.md328
-rw-r--r--doc/api/settings.md2
-rw-r--r--doc/api/snippets.md17
-rw-r--r--doc/api/system_hooks.md3
-rw-r--r--doc/api/tags.md6
-rw-r--r--doc/api/users.md21
-rw-r--r--doc/articles/how_to_configure_ldap_gitlab_ce/index.md268
-rw-r--r--doc/articles/how_to_install_git/index.md67
-rw-r--r--doc/articles/index.md125
-rw-r--r--doc/articles/laravel_with_gitlab_and_envoy/index.md681
-rw-r--r--doc/articles/numerous_undo_possibilities_in_git/index.md498
-rw-r--r--doc/articles/openshift_and_gitlab/index.md511
-rw-r--r--doc/articles/runner_autoscale_aws/index.md411
-rw-r--r--doc/ci/README.md160
-rw-r--r--doc/ci/autodeploy/index.md2
-rw-r--r--doc/ci/caching/img/clear_runners_cache.pngbin0 -> 16029 bytes
-rw-r--r--doc/ci/caching/index.md518
-rw-r--r--doc/ci/docker/using_docker_build.md91
-rw-r--r--doc/ci/docker/using_docker_images.md63
-rw-r--r--doc/ci/environments.md17
-rw-r--r--doc/ci/examples/README.md101
-rw-r--r--doc/ci/examples/artifactory_and_gitlab/index.md10
-rw-r--r--doc/ci/examples/browser_performance.md97
-rw-r--r--doc/ci/examples/code_climate.md28
-rw-r--r--doc/ci/examples/container_scanning.md55
-rw-r--r--doc/ci/examples/dast.md63
-rw-r--r--doc/ci/examples/deployment/README.md2
-rw-r--r--doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/aws_config_window.pngbin0 -> 22046 bytes
-rw-r--r--doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/gitlab_config.pngbin0 -> 27620 bytes
-rw-r--r--doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/test_pipeline_pass.pngbin0 -> 18789 bytes
-rw-r--r--doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md526
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png)bin4730 -> 4730 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png)bin56091 -> 56091 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg)bin93531 -> 93531 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png)bin339666 -> 339666 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/environment_page.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/environment_page.png)bin185393 -> 185393 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/environments_page.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/environments_page.png)bin134742 -> 134742 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png)bin5785 -> 5785 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png)bin177704 -> 177704 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipeline_page.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/pipeline_page.png)bin172664 -> 172664 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipelines_page.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page.png)bin119955 -> 119955 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png)bin141393 -> 141393 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png)bin11082 -> 11082 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png)bin21993 -> 21993 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/img/secret_variables_page.png (renamed from doc/articles/laravel_with_gitlab_and_envoy/img/secret_variables_page.png)bin233764 -> 233764 bytes
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/index.md684
-rw-r--r--doc/ci/examples/php.md4
-rw-r--r--doc/ci/examples/sast_docker.md1
-rw-r--r--doc/ci/examples/test-and-deploy-python-application-to-heroku.md2
-rw-r--r--doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md5
-rw-r--r--doc/ci/examples/test-phoenix-application.md59
-rw-r--r--doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/job-succeeded.pngbin0 -> 62101 bytes
-rw-r--r--doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-new.pngbin0 -> 16112 bytes
-rw-r--r--doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-server.pngbin0 -> 73726 bytes
-rw-r--r--doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/pipelines.pngbin0 -> 40381 bytes
-rw-r--r--doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select-template.pngbin0 -> 29968 bytes
-rw-r--r--doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/setup-ci.pngbin0 -> 46405 bytes
-rw-r--r--doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md423
-rw-r--r--doc/ci/pipelines.md7
-rw-r--r--doc/ci/quick_start/README.md15
-rw-r--r--doc/ci/runners/README.md96
-rw-r--r--doc/ci/runners/img/shared_runner_ip_address.pngbin0 -> 69821 bytes
-rw-r--r--doc/ci/runners/img/specific_runner_ip_address.pngbin0 -> 42055 bytes
-rw-r--r--doc/ci/ssh_keys/README.md4
-rw-r--r--doc/ci/triggers/README.md2
-rw-r--r--doc/ci/variables/README.md114
-rw-r--r--doc/ci/variables/img/secret_variables.pngbin15658 -> 32886 bytes
-rw-r--r--doc/ci/yaml/README.md984
-rw-r--r--doc/customization/branded_login_page.md2
-rw-r--r--doc/development/README.md2
-rw-r--r--doc/development/architecture.md8
-rw-r--r--doc/development/automatic_ce_ee_merge.md126
-rw-r--r--doc/development/background_migrations.md14
-rw-r--r--doc/development/changelog.md13
-rw-r--r--doc/development/database_debugging.md35
-rw-r--r--doc/development/doc_styleguide.md325
-rw-r--r--doc/development/ee_features.md370
-rw-r--r--doc/development/emails.md74
-rw-r--r--doc/development/fe_guide/axios.md23
-rw-r--r--doc/development/fe_guide/components.md61
-rw-r--r--doc/development/fe_guide/dropdowns.md33
-rw-r--r--doc/development/fe_guide/img/gl-modal.pngbin0 -> 25893 bytes
-rw-r--r--doc/development/fe_guide/index.md13
-rw-r--r--doc/development/fe_guide/performance.md142
-rw-r--r--doc/development/fe_guide/style_guide_js.md129
-rw-r--r--doc/development/fe_guide/style_guide_scss.md4
-rw-r--r--doc/development/fe_guide/vue.md24
-rw-r--r--doc/development/feature_flags.md7
-rw-r--r--doc/development/file_storage.md108
-rw-r--r--doc/development/gitaly.md23
-rw-r--r--doc/development/i18n/externalization.md218
-rw-r--r--doc/development/i18n/index.md37
-rw-r--r--doc/development/i18n/proofreader.md74
-rw-r--r--doc/development/i18n/translation.md45
-rw-r--r--doc/development/licensing.md2
-rw-r--r--doc/development/migration_style_guide.md23
-rw-r--r--doc/development/new_fe_guide/dependencies.md3
-rw-r--r--doc/development/new_fe_guide/development/accessibility.md3
-rw-r--r--doc/development/new_fe_guide/development/components.md3
-rw-r--r--doc/development/new_fe_guide/development/design_patterns.md3
-rw-r--r--doc/development/new_fe_guide/development/index.md29
-rw-r--r--doc/development/new_fe_guide/development/network_requests.md3
-rw-r--r--doc/development/new_fe_guide/development/performance.md16
-rw-r--r--doc/development/new_fe_guide/development/security.md14
-rw-r--r--doc/development/new_fe_guide/development/testing.md3
-rw-r--r--doc/development/new_fe_guide/index.md28
-rw-r--r--doc/development/new_fe_guide/initiatives.md3
-rw-r--r--doc/development/new_fe_guide/principles.md35
-rw-r--r--doc/development/new_fe_guide/style/html.md53
-rw-r--r--doc/development/new_fe_guide/style/index.md15
-rw-r--r--doc/development/new_fe_guide/style/javascript.md195
-rw-r--r--doc/development/new_fe_guide/style/prettier.md45
-rw-r--r--doc/development/new_fe_guide/style/scss.md3
-rw-r--r--doc/development/new_fe_guide/style/vue.md3
-rw-r--r--doc/development/new_fe_guide/tips.md3
-rw-r--r--doc/development/performance.md3
-rw-r--r--doc/development/profiling.md56
-rw-r--r--doc/development/query_count_limits.md64
-rw-r--r--doc/development/rake_tasks.md8
-rw-r--r--doc/development/sidekiq_style_guide.md3
-rw-r--r--doc/development/testing_guide/best_practices.md3
-rw-r--r--doc/development/testing_guide/end_to_end_tests.md80
-rw-r--r--doc/development/testing_guide/index.md8
-rw-r--r--doc/development/testing_guide/testing_levels.md7
-rw-r--r--doc/development/utilities.md137
-rw-r--r--doc/development/ux_guide/animation.md2
-rw-r--r--doc/development/ux_guide/components.md6
-rw-r--r--doc/development/ux_guide/copy.md2
-rw-r--r--doc/development/ux_guide/index.md2
-rw-r--r--doc/development/what_requires_downtime.md57
-rw-r--r--doc/development/writing_documentation.md343
-rw-r--r--doc/downgrade_ee_to_ce/README.md2
-rw-r--r--doc/gitlab-basics/create-project.md35
-rw-r--r--doc/img/devops_lifecycle.pngbin0 -> 65043 bytes
-rw-r--r--doc/install/README.md8
-rw-r--r--doc/install/azure/index.md11
-rw-r--r--doc/install/database_mysql.md1
-rw-r--r--doc/install/google_cloud_platform/img/boot_disk.pngbin0 -> 124175 bytes
-rw-r--r--doc/install/google_cloud_platform/img/change_admin_passwd_email.pngbin7193 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/chrome_not_secure_page.pngbin21705 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/first_signin.pngbin0 -> 69516 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gcp_gitlab_being_deployed.pngbin23486 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gcp_gitlab_overview.pngbin42028 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gcp_landing.pngbin59912 -> 15025 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gcp_launcher_console_home_page.pngbin42090 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gcp_search_for_gitlab.pngbin7648 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gitlab_deployed_page.pngbin35573 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gitlab_first_sign_in.pngbin20054 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/gitlab_launch_button.pngbin5198 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/launch_vm.pngbin0 -> 47201 bytes
-rw-r--r--doc/install/google_cloud_platform/img/new_gitlab_deployment_settings.pngbin50014 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/ssh_terminal.pngbin0 -> 94161 bytes
-rw-r--r--doc/install/google_cloud_platform/img/ssh_via_button.pngbin3062 -> 0 bytes
-rw-r--r--doc/install/google_cloud_platform/img/vm_created.pngbin0 -> 13809 bytes
-rw-r--r--doc/install/google_cloud_platform/img/vm_details.pngbin0 -> 76519 bytes
-rw-r--r--doc/install/google_cloud_platform/index.md86
-rw-r--r--doc/install/installation.md44
-rw-r--r--doc/install/kubernetes/gitlab_chart.md12
-rw-r--r--doc/install/kubernetes/gitlab_omnibus.md11
-rw-r--r--doc/install/kubernetes/gitlab_runner_chart.md4
-rw-r--r--doc/install/kubernetes/index.md10
-rw-r--r--doc/install/openshift_and_gitlab/img/add-gitlab-to-project.png (renamed from doc/articles/openshift_and_gitlab/img/add-gitlab-to-project.png)bin37386 -> 37386 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/add-to-project.png (renamed from doc/articles/openshift_and_gitlab/img/add-to-project.png)bin21672 -> 21672 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/create-project-ui.png (renamed from doc/articles/openshift_and_gitlab/img/create-project-ui.png)bin22290 -> 22290 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/gitlab-logs.png (renamed from doc/articles/openshift_and_gitlab/img/gitlab-logs.png)bin70858 -> 70858 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/gitlab-overview.png (renamed from doc/articles/openshift_and_gitlab/img/gitlab-overview.png)bin106432 -> 106432 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/gitlab-running.png (renamed from doc/articles/openshift_and_gitlab/img/gitlab-running.png)bin107993 -> 107993 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/gitlab-scale.png (renamed from doc/articles/openshift_and_gitlab/img/gitlab-scale.png)bin36628 -> 36628 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/gitlab-settings.png (renamed from doc/articles/openshift_and_gitlab/img/gitlab-settings.png)bin111366 -> 111366 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/no-resources.png (renamed from doc/articles/openshift_and_gitlab/img/no-resources.png)bin34669 -> 34669 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/openshift-infra-project.png (renamed from doc/articles/openshift_and_gitlab/img/openshift-infra-project.png)bin95725 -> 95725 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/pods-overview.png (renamed from doc/articles/openshift_and_gitlab/img/pods-overview.png)bin106861 -> 106861 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/rc-name.png (renamed from doc/articles/openshift_and_gitlab/img/rc-name.png)bin51390 -> 51390 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/running-pods.png (renamed from doc/articles/openshift_and_gitlab/img/running-pods.png)bin29818 -> 29818 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/storage-volumes.png (renamed from doc/articles/openshift_and_gitlab/img/storage-volumes.png)bin49584 -> 49584 bytes
-rw-r--r--doc/install/openshift_and_gitlab/img/web-console.png (renamed from doc/articles/openshift_and_gitlab/img/web-console.png)bin34774 -> 34774 bytes
-rw-r--r--doc/install/openshift_and_gitlab/index.md524
-rw-r--r--doc/install/requirements.md14
-rw-r--r--doc/integration/auth0.md7
-rw-r--r--doc/integration/google.md2
-rw-r--r--doc/integration/omniauth.md1
-rw-r--r--doc/integration/openid_connect_provider.md1
-rw-r--r--doc/integration/saml.md9
-rw-r--r--doc/integration/slash_commands.md3
-rw-r--r--doc/policy/maintenance.md2
-rw-r--r--doc/raketasks/README.md1
-rw-r--r--doc/raketasks/backup_restore.md49
-rw-r--r--doc/security/rack_attack.md136
-rw-r--r--doc/ssh/README.md51
-rw-r--r--doc/system_hooks/system_hooks.md149
-rw-r--r--doc/topics/authentication/index.md2
-rw-r--r--doc/topics/autodevops/img/auto_devops_settings.pngbin95233 -> 0 bytes
-rw-r--r--doc/topics/autodevops/index.md122
-rw-r--r--doc/topics/autodevops/quick_start_guide.md5
-rw-r--r--doc/topics/git/how_to_install_git/index.md69
-rw-r--r--doc/topics/git/index.md65
-rw-r--r--doc/topics/git/numerous_undo_possibilities_in_git/img/branching.png (renamed from doc/articles/numerous_undo_possibilities_in_git/img/branching.png)bin26245 -> 26245 bytes
-rw-r--r--doc/topics/git/numerous_undo_possibilities_in_git/img/rebase_reset.png (renamed from doc/articles/numerous_undo_possibilities_in_git/img/rebase_reset.png)bin43609 -> 43609 bytes
-rw-r--r--doc/topics/git/numerous_undo_possibilities_in_git/img/revert.png (renamed from doc/articles/numerous_undo_possibilities_in_git/img/revert.png)bin28112 -> 28112 bytes
-rw-r--r--doc/topics/git/numerous_undo_possibilities_in_git/index.md500
-rw-r--r--doc/university/glossary/README.md2
-rw-r--r--doc/university/high-availability/aws/README.md8
-rw-r--r--doc/university/high-availability/aws/img/reference-arch.pngbin0 -> 183997 bytes
-rw-r--r--doc/update/10.1-to-10.2.md4
-rw-r--r--doc/update/10.2-to-10.3.md11
-rw-r--r--doc/update/10.3-to-10.4.md361
-rw-r--r--doc/update/10.4-to-10.5.md361
-rw-r--r--doc/update/10.5-to-10.6.md361
-rw-r--r--doc/update/mysql_to_postgresql.md264
-rw-r--r--doc/user/admin_area/monitoring/img/convdev_index.pngbin116112 -> 119743 bytes
-rw-r--r--doc/user/admin_area/settings/email.md5
-rw-r--r--doc/user/admin_area/settings/img/update-available.pngbin0 -> 89748 bytes
-rw-r--r--doc/user/admin_area/settings/usage_statistics.md26
-rw-r--r--doc/user/admin_area/settings/visibility_and_access_controls.md12
-rw-r--r--doc/user/feature_highlight.md15
-rw-r--r--doc/user/gitlab_com/index.md342
-rw-r--r--doc/user/group/index.md28
-rw-r--r--doc/user/group/subgroups/img/create_subgroup_button.pngbin8402 -> 11161 bytes
-rw-r--r--doc/user/group/subgroups/index.md3
-rw-r--r--doc/user/img/feature_highlight_example.pngbin0 -> 27262 bytes
-rw-r--r--doc/user/index.md21
-rw-r--r--doc/user/markdown.md57
-rw-r--r--doc/user/permissions.md34
-rw-r--r--doc/user/profile/account/delete_account.md2
-rw-r--r--doc/user/profile/account/two_factor_authentication.md2
-rw-r--r--doc/user/profile/preferences.md4
-rw-r--r--doc/user/project/badges.md73
-rw-r--r--doc/user/project/clusters/index.md405
-rw-r--r--doc/user/project/container_registry.md11
-rw-r--r--doc/user/project/deploy_tokens/img/deploy_tokens.pngbin0 -> 75650 bytes
-rw-r--r--doc/user/project/deploy_tokens/index.md76
-rw-r--r--doc/user/project/img/label_priority_sort_order.pngbin101667 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_assign_label_sidebar.pngbin11767 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_assign_label_sidebar_saved.pngbin9741 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_default.pngbin24404 -> 22975 bytes
-rw-r--r--doc/user/project/img/labels_description_tooltip.pngbin8538 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_filter.pngbin19071 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_generate.pngbin13628 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_generate_default.pngbin0 -> 65549 bytes
-rw-r--r--doc/user/project/img/labels_group_issues.pngbin0 -> 264573 bytes
-rw-r--r--doc/user/project/img/labels_list.pngbin0 -> 207736 bytes
-rw-r--r--doc/user/project/img/labels_new_label.pngbin10720 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_new_label_on_the_fly.pngbin4625 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_new_label_on_the_fly_create.pngbin6389 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_prioritize.pngbin24194 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_prioritized.pngbin0 -> 156020 bytes
-rw-r--r--doc/user/project/img/labels_promotion.pngbin0 -> 121824 bytes
-rw-r--r--doc/user/project/img/labels_sidebar.pngbin0 -> 31123 bytes
-rw-r--r--doc/user/project/img/labels_sidebar_assign.pngbin0 -> 28033 bytes
-rw-r--r--doc/user/project/img/labels_sidebar_inline.pngbin0 -> 28423 bytes
-rw-r--r--doc/user/project/img/labels_sort_label_priority.pngbin0 -> 110154 bytes
-rw-r--r--doc/user/project/img/labels_sort_priority.pngbin0 -> 108780 bytes
-rw-r--r--doc/user/project/img/labels_subscribe.pngbin5336 -> 0 bytes
-rw-r--r--doc/user/project/img/labels_subscriptions.pngbin0 -> 87502 bytes
-rw-r--r--doc/user/project/img/new_label_from_sidebar.gifbin0 -> 759243 bytes
-rw-r--r--doc/user/project/img/project_overview_badges.pngbin0 -> 40188 bytes
-rw-r--r--doc/user/project/import/github.md2
-rw-r--r--doc/user/project/import/img/import_projects_from_repo_url.pngbin0 -> 150259 bytes
-rw-r--r--doc/user/project/import/index.md1
-rw-r--r--doc/user/project/import/perforce.md6
-rw-r--r--doc/user/project/import/repo_by_url.md12
-rw-r--r--doc/user/project/index.md21
-rw-r--r--doc/user/project/integrations/bugzilla.md6
-rw-r--r--doc/user/project/integrations/custom_issue_tracker.md2
-rw-r--r--doc/user/project/integrations/emails_on_push.md2
-rw-r--r--doc/user/project/integrations/img/jira_workflow_screenshot.pngbin66685 -> 0 bytes
-rw-r--r--doc/user/project/integrations/img/prometheus_dashboard.pngbin0 -> 26112 bytes
-rw-r--r--doc/user/project/integrations/img/prometheus_deploy.pngbin0 -> 27258 bytes
-rw-r--r--doc/user/project/integrations/img/prometheus_gcp_firewall_rule.pngbin15247 -> 0 bytes
-rw-r--r--doc/user/project/integrations/img/prometheus_gcp_node_name.pngbin52622 -> 0 bytes
-rw-r--r--doc/user/project/integrations/irker.md6
-rw-r--r--doc/user/project/integrations/jira.md22
-rw-r--r--doc/user/project/integrations/kubernetes.md19
-rw-r--r--doc/user/project/integrations/project_services.md2
-rw-r--r--doc/user/project/integrations/prometheus.md136
-rw-r--r--doc/user/project/integrations/prometheus_library/kubernetes.md31
-rw-r--r--doc/user/project/integrations/prometheus_library/nginx_ingress.md28
-rw-r--r--doc/user/project/integrations/redmine.md7
-rw-r--r--doc/user/project/integrations/samples/prometheus.yml107
-rw-r--r--doc/user/project/integrations/webhooks.md8
-rw-r--r--doc/user/project/issue_board.md22
-rw-r--r--doc/user/project/issues/create_new_issue.md22
-rw-r--r--doc/user/project/issues/crosslinking_issues.md2
-rw-r--r--doc/user/project/issues/img/new_issue_from_email.pngbin0 -> 13461 bytes
-rw-r--r--doc/user/project/issues/index.md8
-rw-r--r--doc/user/project/issues/issues_functionalities.md14
-rw-r--r--doc/user/project/labels.md189
-rw-r--r--doc/user/project/members/share_project_with_groups.md24
-rw-r--r--doc/user/project/merge_requests/fast_forward_merge.md4
-rw-r--r--doc/user/project/merge_requests/img/allow_maintainer_push.pngbin0 -> 49216 bytes
-rw-r--r--doc/user/project/merge_requests/img/ff_merge_mr.pngbin21380 -> 0 bytes
-rw-r--r--doc/user/project/merge_requests/img/ff_merge_rebase.pngbin0 -> 26945 bytes
-rw-r--r--doc/user/project/merge_requests/img/remove_source_branch_status.pngbin0 -> 32649 bytes
-rw-r--r--doc/user/project/merge_requests/index.md60
-rw-r--r--doc/user/project/merge_requests/maintainer_access.md18
-rw-r--r--doc/user/project/merge_requests/work_in_progress_merge_requests.md3
-rw-r--r--doc/user/project/milestones/img/milestone_create.pngbin40591 -> 0 bytes
-rw-r--r--doc/user/project/milestones/img/milestone_group_create.pngbin35526 -> 0 bytes
-rw-r--r--doc/user/project/milestones/img/milestones_new_group_milestone.pngbin0 -> 156704 bytes
-rw-r--r--doc/user/project/milestones/img/milestones_new_project_milestone.pngbin0 -> 173762 bytes
-rw-r--r--doc/user/project/milestones/img/milestones_project_milestone_page.pngbin0 -> 489382 bytes
-rw-r--r--doc/user/project/milestones/img/milestones_promote_milestone.pngbin0 -> 350399 bytes
-rw-r--r--doc/user/project/milestones/img/sidebar.pngbin89947 -> 0 bytes
-rw-r--r--doc/user/project/milestones/index.md128
-rw-r--r--doc/user/project/new_ci_build_permissions_model.md2
-rw-r--r--doc/user/project/pages/getting_started_part_four.md32
-rw-r--r--doc/user/project/pages/getting_started_part_one.md39
-rw-r--r--doc/user/project/pages/getting_started_part_three.md120
-rw-r--r--doc/user/project/pages/getting_started_part_two.md69
-rw-r--r--doc/user/project/pages/img/verify_your_domain.pngbin0 -> 30163 bytes
-rw-r--r--doc/user/project/pages/index.md9
-rw-r--r--doc/user/project/pages/introduction.md43
-rw-r--r--doc/user/project/pipelines/img/pipeline_schedule_play.pngbin0 -> 39142 bytes
-rw-r--r--doc/user/project/pipelines/img/pipeline_schedules_list.pngbin14665 -> 38034 bytes
-rw-r--r--doc/user/project/pipelines/schedules.md17
-rw-r--r--doc/user/project/pipelines/settings.md11
-rw-r--r--doc/user/project/quick_actions.md2
-rw-r--r--doc/user/project/repository/branches/index.md2
-rw-r--r--doc/user/project/repository/gpg_signed_commits/index.md7
-rw-r--r--doc/user/project/repository/img/jupyter_notebook.pngbin0 -> 63326 bytes
-rw-r--r--doc/user/project/repository/index.md36
-rw-r--r--doc/user/project/repository/web_editor.md2
-rw-r--r--doc/user/project/settings/import_export.md6
-rw-r--r--doc/user/project/settings/index.md4
-rw-r--r--doc/user/project/wiki/img/wiki_move_page_1.pngbin0 -> 54550 bytes
-rw-r--r--doc/user/project/wiki/img/wiki_move_page_2.pngbin0 -> 33535 bytes
-rw-r--r--doc/user/project/wiki/index.md12
-rw-r--r--doc/workflow/lfs/img/lfs-icon.pngbin0 -> 4317 bytes
-rw-r--r--doc/workflow/lfs/lfs_administration.md123
-rw-r--r--doc/workflow/lfs/manage_large_binaries_with_git_lfs.md77
-rw-r--r--doc/workflow/notifications.md35
-rw-r--r--doc/workflow/todos.md3
-rw-r--r--features/explore/groups.feature105
-rw-r--r--features/group/members.feature12
-rw-r--r--features/group/milestones.feature48
-rw-r--r--features/groups.feature73
-rw-r--r--features/invites.feature45
-rw-r--r--features/profile/profile.feature85
-rw-r--r--features/project/active_tab.feature138
-rw-r--r--features/project/ff_merge_requests.feature17
-rw-r--r--features/project/fork.feature49
-rw-r--r--features/project/graph.feature33
-rw-r--r--features/project/issues/issues.feature180
-rw-r--r--features/project/issues/labels.feature48
-rw-r--r--features/project/issues/milestones.feature8
-rw-r--r--features/project/network_graph.feature46
-rw-r--r--features/project/pages.feature87
-rw-r--r--features/project/redirects.feature38
-rw-r--r--features/steps/explore/groups.rb88
-rw-r--r--features/steps/group/members.rb15
-rw-r--r--features/steps/group/milestones.rb135
-rw-r--r--features/steps/groups.rb147
-rw-r--r--features/steps/invites.rb80
-rw-r--r--features/steps/profile/profile.rb226
-rw-r--r--features/steps/project/active_tab.rb127
-rw-r--r--features/steps/project/commits/commits.rb8
-rw-r--r--features/steps/project/deploy_keys.rb4
-rw-r--r--features/steps/project/ff_merge_requests.rb22
-rw-r--r--features/steps/project/fork.rb85
-rw-r--r--features/steps/project/forked_merge_requests.rb2
-rw-r--r--features/steps/project/graph.rb50
-rw-r--r--features/steps/project/issues/issues.rb181
-rw-r--r--features/steps/project/issues/labels.rb100
-rw-r--r--features/steps/project/issues/milestones.rb9
-rw-r--r--features/steps/project/network_graph.rb116
-rw-r--r--features/steps/project/pages.rb152
-rw-r--r--features/steps/project/project.rb2
-rw-r--r--features/steps/project/redirects.rb67
-rw-r--r--features/steps/project/source/browse_files.rb2
-rw-r--r--features/steps/project/source/markdown_render.rb8
-rw-r--r--features/steps/shared/builds.rb2
-rw-r--r--features/steps/shared/group.rb2
-rw-r--r--features/steps/shared/issuable.rb23
-rw-r--r--features/steps/shared/markdown.rb37
-rw-r--r--features/steps/shared/note.rb22
-rw-r--r--features/steps/shared/paths.rb31
-rw-r--r--features/steps/shared/project.rb31
-rw-r--r--features/steps/shared/user.rb4
-rw-r--r--features/steps/user.rb38
-rw-r--r--features/support/capybara.rb3
-rw-r--r--features/support/capybara_helpers.rb10
-rw-r--r--features/support/db_cleaner.rb2
-rw-r--r--features/support/env.rb13
-rw-r--r--features/user.feature86
-rw-r--r--lib/after_commit_queue.rb10
-rw-r--r--lib/api/access_requests.rb14
-rw-r--r--lib/api/api.rb22
-rw-r--r--lib/api/api_guard.rb6
-rw-r--r--lib/api/applications.rb27
-rw-r--r--lib/api/badges.rb135
-rw-r--r--lib/api/boards.rb84
-rw-r--r--lib/api/boards_responses.rb50
-rw-r--r--lib/api/branches.rb18
-rw-r--r--lib/api/circuit_breakers.rb4
-rw-r--r--lib/api/commits.rb56
-rw-r--r--lib/api/deploy_keys.rb70
-rw-r--r--lib/api/deployments.rb4
-rw-r--r--lib/api/discussions.rb195
-rw-r--r--lib/api/entities.rb216
-rw-r--r--lib/api/features.rb7
-rw-r--r--lib/api/group_boards.rb117
-rw-r--r--lib/api/groups.rb29
-rw-r--r--lib/api/helpers.rb70
-rw-r--r--lib/api/helpers/badges_helpers.rb28
-rw-r--r--lib/api/helpers/common_helpers.rb6
-rw-r--r--lib/api/helpers/custom_attributes.rb28
-rw-r--r--lib/api/helpers/internal_helpers.rb32
-rw-r--r--lib/api/helpers/notes_helpers.rb76
-rw-r--r--lib/api/helpers/pagination.rb17
-rw-r--r--lib/api/helpers/projects_helpers.rb38
-rw-r--r--lib/api/helpers/related_resources_helpers.rb2
-rw-r--r--lib/api/helpers/runner.rb39
-rw-r--r--lib/api/internal.rb37
-rw-r--r--lib/api/issues.rb24
-rw-r--r--lib/api/job_artifacts.rb21
-rw-r--r--lib/api/jobs.rb3
-rw-r--r--lib/api/labels.rb4
-rw-r--r--lib/api/members.rb29
-rw-r--r--lib/api/merge_requests.rb100
-rw-r--r--lib/api/notes.rb94
-rw-r--r--lib/api/pages_domains.rb10
-rw-r--r--lib/api/pipelines.rb3
-rw-r--r--lib/api/project_export.rb63
-rw-r--r--lib/api/project_hooks.rb2
-rw-r--r--lib/api/project_import.rb81
-rw-r--r--lib/api/project_milestones.rb9
-rw-r--r--lib/api/project_snippets.rb3
-rw-r--r--lib/api/projects.rb67
-rw-r--r--lib/api/protected_branches.rb13
-rw-r--r--lib/api/repositories.rb3
-rw-r--r--lib/api/runner.rb43
-rw-r--r--lib/api/runners.rb1
-rw-r--r--lib/api/search.rb111
-rw-r--r--lib/api/services.rb221
-rw-r--r--lib/api/settings.rb2
-rw-r--r--lib/api/system_hooks.rb1
-rw-r--r--lib/api/templates.rb8
-rw-r--r--lib/api/todos.rb2
-rw-r--r--lib/api/triggers.rb2
-rw-r--r--lib/api/users.rb30
-rw-r--r--lib/api/v3/branches.rb2
-rw-r--r--lib/api/v3/builds.rb7
-rw-r--r--lib/api/v3/commits.rb3
-rw-r--r--lib/api/v3/deploy_keys.rb46
-rw-r--r--lib/api/v3/entities.rb17
-rw-r--r--lib/api/v3/issues.rb6
-rw-r--r--lib/api/v3/labels.rb2
-rw-r--r--lib/api/v3/members.rb18
-rw-r--r--lib/api/v3/merge_requests.rb5
-rw-r--r--lib/api/v3/pipelines.rb2
-rw-r--r--lib/api/v3/project_hooks.rb1
-rw-r--r--lib/api/v3/project_snippets.rb1
-rw-r--r--lib/api/v3/projects.rb14
-rw-r--r--lib/api/v3/repositories.rb3
-rw-r--r--lib/api/v3/services.rb2
-rw-r--r--lib/api/v3/snippets.rb1
-rw-r--r--lib/api/v3/templates.rb8
-rw-r--r--lib/api/v3/todos.rb2
-rw-r--r--lib/api/v3/triggers.rb2
-rw-r--r--lib/backup/artifacts.rb6
-rw-r--r--lib/backup/builds.rb4
-rw-r--r--lib/backup/database.rb1
-rw-r--r--lib/backup/files.rb24
-rw-r--r--lib/backup/helper.rb17
-rw-r--r--lib/backup/lfs.rb4
-rw-r--r--lib/backup/manager.rb9
-rw-r--r--lib/backup/pages.rb4
-rw-r--r--lib/backup/registry.rb4
-rw-r--r--lib/backup/repository.rb29
-rw-r--r--lib/backup/uploads.rb4
-rw-r--r--lib/banzai/color_parser.rb44
-rw-r--r--lib/banzai/cross_project_reference.rb4
-rw-r--r--lib/banzai/filter/abstract_reference_filter.rb31
-rw-r--r--lib/banzai/filter/autolink_filter.rb99
-rw-r--r--lib/banzai/filter/color_filter.rb31
-rw-r--r--lib/banzai/filter/commit_range_reference_filter.rb2
-rw-r--r--lib/banzai/filter/commit_reference_filter.rb3
-rw-r--r--lib/banzai/filter/commit_trailers_filter.rb152
-rw-r--r--lib/banzai/filter/emoji_filter.rb6
-rw-r--r--lib/banzai/filter/gollum_tags_filter.rb6
-rw-r--r--lib/banzai/filter/html_entity_filter.rb2
-rw-r--r--lib/banzai/filter/inline_diff_filter.rb2
-rw-r--r--lib/banzai/filter/issuable_state_filter.rb16
-rw-r--r--lib/banzai/filter/label_reference_filter.rb49
-rw-r--r--lib/banzai/filter/markdown_engines/common_mark.rb45
-rw-r--r--lib/banzai/filter/markdown_engines/redcarpet.rb32
-rw-r--r--lib/banzai/filter/markdown_filter.rb41
-rw-r--r--lib/banzai/filter/merge_request_reference_filter.rb39
-rw-r--r--lib/banzai/filter/mermaid_filter.rb11
-rw-r--r--lib/banzai/filter/milestone_reference_filter.rb4
-rw-r--r--lib/banzai/filter/relative_link_filter.rb57
-rw-r--r--lib/banzai/filter/syntax_highlight_filter.rb35
-rw-r--r--lib/banzai/filter/upload_link_filter.rb60
-rw-r--r--lib/banzai/filter/wiki_link_filter/rewriter.rb4
-rw-r--r--lib/banzai/pipeline/broadcast_message_pipeline.rb1
-rw-r--r--lib/banzai/pipeline/commit_description_pipeline.rb11
-rw-r--r--lib/banzai/pipeline/gfm_pipeline.rb6
-rw-r--r--lib/banzai/redactor.rb46
-rw-r--r--lib/banzai/reference_parser/issuable_parser.rb2
-rw-r--r--lib/banzai/reference_parser/issue_parser.rb25
-rw-r--r--lib/banzai/renderer/common_mark/html.rb21
-rw-r--r--lib/banzai/renderer/html.rb13
-rw-r--r--lib/banzai/renderer/redcarpet/html.rb15
-rw-r--r--lib/bitbucket/connection.rb2
-rw-r--r--lib/carrier_wave_string_file.rb5
-rw-r--r--lib/constraints/group_url_constrainer.rb12
-rw-r--r--lib/constraints/project_url_constrainer.rb20
-rw-r--r--lib/constraints/user_url_constrainer.rb12
-rw-r--r--lib/container_registry/client.rb5
-rw-r--r--lib/container_registry/registry.rb2
-rw-r--r--lib/declarative_policy.rb4
-rw-r--r--lib/declarative_policy/delegate_dsl.rb16
-rw-r--r--lib/declarative_policy/dsl.rb103
-rw-r--r--lib/declarative_policy/policy_dsl.rb44
-rw-r--r--lib/declarative_policy/preferred_scope.rb2
-rw-r--r--lib/declarative_policy/rule_dsl.rb45
-rw-r--r--lib/email_template_interceptor.rb4
-rw-r--r--lib/extracts_path.rb4
-rw-r--r--lib/forever.rb13
-rw-r--r--lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb2
-rw-r--r--lib/gitaly/server.rb43
-rw-r--r--lib/gitlab.rb1
-rw-r--r--lib/gitlab/asciidoc.rb11
-rw-r--r--lib/gitlab/auth.rb60
-rw-r--r--lib/gitlab/auth/blocked_user_tracker.rb36
-rw-r--r--lib/gitlab/auth/database/authentication.rb16
-rw-r--r--lib/gitlab/auth/ldap/access.rb91
-rw-r--r--lib/gitlab/auth/ldap/adapter.rb137
-rw-r--r--lib/gitlab/auth/ldap/auth_hash.rb48
-rw-r--r--lib/gitlab/auth/ldap/authentication.rb58
-rw-r--r--lib/gitlab/auth/ldap/config.rb237
-rw-r--r--lib/gitlab/auth/ldap/dn.rb303
-rw-r--r--lib/gitlab/auth/ldap/ldap_connection_error.rb7
-rw-r--r--lib/gitlab/auth/ldap/person.rb122
-rw-r--r--lib/gitlab/auth/ldap/user.rb54
-rw-r--r--lib/gitlab/auth/o_auth/auth_hash.rb92
-rw-r--r--lib/gitlab/auth/o_auth/authentication.rb22
-rw-r--r--lib/gitlab/auth/o_auth/provider.rb73
-rw-r--r--lib/gitlab/auth/o_auth/session.rb21
-rw-r--r--lib/gitlab/auth/o_auth/user.rb249
-rw-r--r--lib/gitlab/auth/request_authenticator.rb8
-rw-r--r--lib/gitlab/auth/result.rb2
-rw-r--r--lib/gitlab/auth/saml/auth_hash.rb19
-rw-r--r--lib/gitlab/auth/saml/config.rb21
-rw-r--r--lib/gitlab/auth/saml/user.rb52
-rw-r--r--lib/gitlab/auth/user_auth_finders.rb4
-rw-r--r--lib/gitlab/background_migration/add_merge_request_diff_commits_count.rb26
-rw-r--r--lib/gitlab/background_migration/cleanup_concurrent_type_change.rb54
-rw-r--r--lib/gitlab/background_migration/copy_column.rb41
-rw-r--r--lib/gitlab/background_migration/create_fork_network_memberships_range.rb15
-rw-r--r--lib/gitlab/background_migration/delete_conflicting_redirect_routes_range.rb36
-rw-r--r--lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb2
-rw-r--r--lib/gitlab/background_migration/migrate_build_stage.rb49
-rw-r--r--lib/gitlab/background_migration/migrate_events_to_push_event_payloads.rb16
-rw-r--r--lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb496
-rw-r--r--lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data.rb135
-rw-r--r--lib/gitlab/background_migration/populate_untracked_uploads.rb160
-rw-r--r--lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb201
-rw-r--r--lib/gitlab/background_migration/prepare_untracked_uploads.rb34
-rw-r--r--lib/gitlab/background_migration/set_confidential_note_events_on_services.rb26
-rw-r--r--lib/gitlab/background_migration/set_confidential_note_events_on_webhooks.rb26
-rw-r--r--lib/gitlab/badge/coverage/report.rb2
-rw-r--r--lib/gitlab/badge/coverage/template.rb4
-rw-r--r--lib/gitlab/bare_repository_import/importer.rb8
-rw-r--r--lib/gitlab/bare_repository_import/repository.rb45
-rw-r--r--lib/gitlab/bitbucket_import/importer.rb4
-rw-r--r--lib/gitlab/checks/change_access.rb84
-rw-r--r--lib/gitlab/checks/commit_check.rb61
-rw-r--r--lib/gitlab/checks/force_push.rb4
-rw-r--r--lib/gitlab/checks/lfs_integrity.rb3
-rw-r--r--lib/gitlab/checks/post_push_message.rb46
-rw-r--r--lib/gitlab/checks/project_created.rb31
-rw-r--r--lib/gitlab/checks/project_moved.rb50
-rw-r--r--lib/gitlab/ci/ansi2html.rb7
-rw-r--r--lib/gitlab/ci/build/artifacts/metadata/entry.rb2
-rw-r--r--lib/gitlab/ci/build/policy/kubernetes.rb2
-rw-r--r--lib/gitlab/ci/build/policy/refs.rb2
-rw-r--r--lib/gitlab/ci/build/policy/specification.rb2
-rw-r--r--lib/gitlab/ci/build/policy/variables.rb24
-rw-r--r--lib/gitlab/ci/build/step.rb4
-rw-r--r--lib/gitlab/ci/charts.rb15
-rw-r--r--lib/gitlab/ci/config.rb3
-rw-r--r--lib/gitlab/ci/config/entry/node.rb4
-rw-r--r--lib/gitlab/ci/config/entry/policy.rb20
-rw-r--r--lib/gitlab/ci/config/entry/validators.rb16
-rw-r--r--lib/gitlab/ci/config/loader.rb2
-rw-r--r--lib/gitlab/ci/pipeline/chain/command.rb2
-rw-r--r--lib/gitlab/ci/pipeline/chain/create.rb31
-rw-r--r--lib/gitlab/ci/pipeline/chain/populate.rb45
-rw-r--r--lib/gitlab/ci/pipeline/chain/skip.rb6
-rw-r--r--lib/gitlab/ci/pipeline/chain/validate/config.rb6
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexeme/base.rb25
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexeme/equals.rb26
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexeme/null.rb25
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexeme/operator.rb15
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexeme/string.rb25
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexeme/value.rb15
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexeme/variable.rb25
-rw-r--r--lib/gitlab/ci/pipeline/expression/lexer.rb59
-rw-r--r--lib/gitlab/ci/pipeline/expression/parser.rb40
-rw-r--r--lib/gitlab/ci/pipeline/expression/statement.rb49
-rw-r--r--lib/gitlab/ci/pipeline/expression/token.rb28
-rw-r--r--lib/gitlab/ci/pipeline/seed/base.rb21
-rw-r--r--lib/gitlab/ci/pipeline/seed/build.rb48
-rw-r--r--lib/gitlab/ci/pipeline/seed/stage.rb47
-rw-r--r--lib/gitlab/ci/stage/seed.rb58
-rw-r--r--lib/gitlab/ci/status/build/action.rb5
-rw-r--r--lib/gitlab/ci/status/build/cancelable.rb4
-rw-r--r--lib/gitlab/ci/status/build/canceled.rb21
-rw-r--r--lib/gitlab/ci/status/build/created.rb22
-rw-r--r--lib/gitlab/ci/status/build/erased.rb21
-rw-r--r--lib/gitlab/ci/status/build/factory.rb12
-rw-r--r--lib/gitlab/ci/status/build/failed.rb40
-rw-r--r--lib/gitlab/ci/status/build/failed_allowed.rb12
-rw-r--r--lib/gitlab/ci/status/build/manual.rb22
-rw-r--r--lib/gitlab/ci/status/build/pending.rb22
-rw-r--r--lib/gitlab/ci/status/build/play.rb4
-rw-r--r--lib/gitlab/ci/status/build/retried.rb17
-rw-r--r--lib/gitlab/ci/status/build/retryable.rb4
-rw-r--r--lib/gitlab/ci/status/build/skipped.rb21
-rw-r--r--lib/gitlab/ci/status/build/stop.rb4
-rw-r--r--lib/gitlab/ci/status/core.rb18
-rw-r--r--lib/gitlab/ci/trace.rb59
-rw-r--r--lib/gitlab/ci/trace/http_io.rb187
-rw-r--r--lib/gitlab/ci/trace/stream.rb6
-rw-r--r--lib/gitlab/ci/variables/collection.rb44
-rw-r--r--lib/gitlab/ci/variables/collection/item.rb50
-rw-r--r--lib/gitlab/ci/yaml_processor.rb46
-rw-r--r--lib/gitlab/conflict/file_collection.rb42
-rw-r--r--lib/gitlab/contributions_calendar.rb8
-rw-r--r--lib/gitlab/cross_project_access.rb67
-rw-r--r--lib/gitlab/cross_project_access/check_collection.rb47
-rw-r--r--lib/gitlab/cross_project_access/check_info.rb66
-rw-r--r--lib/gitlab/cross_project_access/class_methods.rb48
-rw-r--r--lib/gitlab/current_settings.rb108
-rw-r--r--lib/gitlab/cycle_analytics/base_query.rb8
-rw-r--r--lib/gitlab/cycle_analytics/base_stage.rb29
-rw-r--r--lib/gitlab/cycle_analytics/production_helper.rb4
-rw-r--r--lib/gitlab/cycle_analytics/test_stage.rb6
-rw-r--r--lib/gitlab/cycle_analytics/usage_data.rb72
-rw-r--r--lib/gitlab/data_builder/build.rb2
-rw-r--r--lib/gitlab/data_builder/note.rb4
-rw-r--r--lib/gitlab/data_builder/pipeline.rb1
-rw-r--r--lib/gitlab/database.rb9
-rw-r--r--lib/gitlab/database/grant.rb50
-rw-r--r--lib/gitlab/database/median.rb130
-rw-r--r--lib/gitlab/database/migration_helpers.rb233
-rw-r--r--lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb2
-rw-r--r--lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb1
-rw-r--r--lib/gitlab/database/sha_attribute.rb51
-rw-r--r--lib/gitlab/dependency_linker/composer_json_linker.rb2
-rw-r--r--lib/gitlab/dependency_linker/gemfile_linker.rb2
-rw-r--r--lib/gitlab/dependency_linker/podspec_linker.rb2
-rw-r--r--lib/gitlab/diff/diff_refs.rb6
-rw-r--r--lib/gitlab/diff/file.rb52
-rw-r--r--lib/gitlab/diff/file_collection/merge_request_diff.rb30
-rw-r--r--lib/gitlab/diff/highlight.rb13
-rw-r--r--lib/gitlab/diff/inline_diff_marker.rb7
-rw-r--r--lib/gitlab/ee_compat_check.rb152
-rw-r--r--lib/gitlab/email/handler/create_issue_handler.rb2
-rw-r--r--lib/gitlab/email/handler/create_merge_request_handler.rb1
-rw-r--r--lib/gitlab/email/reply_parser.rb2
-rw-r--r--lib/gitlab/encoding_helper.rb40
-rw-r--r--lib/gitlab/exclusive_lease.rb21
-rw-r--r--lib/gitlab/file_detector.rb22
-rw-r--r--lib/gitlab/file_finder.rb5
-rw-r--r--lib/gitlab/fogbugz_import/importer.rb3
-rw-r--r--lib/gitlab/gfm/uploads_rewriter.rb4
-rw-r--r--lib/gitlab/git.rb3
-rw-r--r--lib/gitlab/git/attributes.rb136
-rw-r--r--lib/gitlab/git/attributes_at_ref_parser.rb14
-rw-r--r--lib/gitlab/git/attributes_parser.rb115
-rw-r--r--lib/gitlab/git/blame.rb4
-rw-r--r--lib/gitlab/git/blob.rb73
-rw-r--r--lib/gitlab/git/branch.rb16
-rw-r--r--lib/gitlab/git/commit.rb118
-rw-r--r--lib/gitlab/git/commit_stats.rb9
-rw-r--r--lib/gitlab/git/conflict/file.rb18
-rw-r--r--lib/gitlab/git/conflict/parser.rb5
-rw-r--r--lib/gitlab/git/conflict/resolution.rb15
-rw-r--r--lib/gitlab/git/conflict/resolver.rb82
-rw-r--r--lib/gitlab/git/diff.rb2
-rw-r--r--lib/gitlab/git/env.rb53
-rw-r--r--lib/gitlab/git/gitlab_projects.rb182
-rw-r--r--lib/gitlab/git/gitmodules_parser.rb4
-rw-r--r--lib/gitlab/git/hook.rb24
-rw-r--r--lib/gitlab/git/hook_env.rb51
-rw-r--r--lib/gitlab/git/index.rb12
-rw-r--r--lib/gitlab/git/info_attributes.rb49
-rw-r--r--lib/gitlab/git/lfs_changes.rb29
-rw-r--r--lib/gitlab/git/lfs_pointer_file.rb32
-rw-r--r--lib/gitlab/git/operation_service.rb10
-rw-r--r--lib/gitlab/git/path_helper.rb2
-rw-r--r--lib/gitlab/git/popen.rb4
-rw-r--r--lib/gitlab/git/ref.rb6
-rw-r--r--lib/gitlab/git/remote_mirror.rb89
-rw-r--r--lib/gitlab/git/repository.rb1130
-rw-r--r--lib/gitlab/git/repository_mirroring.rb2
-rw-r--r--lib/gitlab/git/rev_list.rb65
-rw-r--r--lib/gitlab/git/storage/checker.rb2
-rw-r--r--lib/gitlab/git/storage/circuit_breaker.rb2
-rw-r--r--lib/gitlab/git/storage/forked_storage_check.rb1
-rw-r--r--lib/gitlab/git/tag.rb2
-rw-r--r--lib/gitlab/git/tree.rb25
-rw-r--r--lib/gitlab/git/wiki.rb54
-rw-r--r--lib/gitlab/git/wiki_page.rb3
-rw-r--r--lib/gitlab/git_access.rb161
-rw-r--r--lib/gitlab/git_access_wiki.rb8
-rw-r--r--lib/gitlab/gitaly_client.rb122
-rw-r--r--lib/gitlab/gitaly_client/blob_service.rb83
-rw-r--r--lib/gitlab/gitaly_client/blobs_stitcher.rb47
-rw-r--r--lib/gitlab/gitaly_client/commit_service.rb144
-rw-r--r--lib/gitlab/gitaly_client/conflict_files_stitcher.rb47
-rw-r--r--lib/gitlab/gitaly_client/conflicts_service.rb72
-rw-r--r--lib/gitlab/gitaly_client/health_check_service.rb19
-rw-r--r--lib/gitlab/gitaly_client/operation_service.rb164
-rw-r--r--lib/gitlab/gitaly_client/ref_service.rb83
-rw-r--r--lib/gitlab/gitaly_client/remote_service.rb81
-rw-r--r--lib/gitlab/gitaly_client/repository_service.rb168
-rw-r--r--lib/gitlab/gitaly_client/server_service.rb16
-rw-r--r--lib/gitlab/gitaly_client/storage_settings.rb35
-rw-r--r--lib/gitlab/gitaly_client/util.rb16
-rw-r--r--lib/gitlab/gitaly_client/wiki_page.rb5
-rw-r--r--lib/gitlab/gitaly_client/wiki_service.rb80
-rw-r--r--lib/gitlab/github_import/client.rb11
-rw-r--r--lib/gitlab/github_import/importer/pull_requests_importer.rb5
-rw-r--r--lib/gitlab/github_import/importer/repository_importer.rb12
-rw-r--r--lib/gitlab/github_import/representation/diff_note.rb2
-rw-r--r--lib/gitlab/github_import/representation/note.rb2
-rw-r--r--lib/gitlab/gitlab_import/client.rb2
-rw-r--r--lib/gitlab/gon_helper.rb10
-rw-r--r--lib/gitlab/google_code_import/importer.rb3
-rw-r--r--lib/gitlab/gpg/commit.rb30
-rw-r--r--lib/gitlab/grape_logging/loggers/user_logger.rb18
-rw-r--r--lib/gitlab/health_checks/fs_shards_check.rb2
-rw-r--r--lib/gitlab/health_checks/gitaly_check.rb53
-rw-r--r--lib/gitlab/health_checks/metric.rb2
-rw-r--r--lib/gitlab/health_checks/result.rb2
-rw-r--r--lib/gitlab/hook_data/issuable_builder.rb15
-rw-r--r--lib/gitlab/hook_data/issue_builder.rb1
-rw-r--r--lib/gitlab/hook_data/merge_request_builder.rb1
-rw-r--r--lib/gitlab/http.rb13
-rw-r--r--lib/gitlab/i18n.rb5
-rw-r--r--lib/gitlab/import_export.rb2
-rw-r--r--lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb83
-rw-r--r--lib/gitlab/import_export/after_export_strategies/download_notification_strategy.rb17
-rw-r--r--lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb61
-rw-r--r--lib/gitlab/import_export/after_export_strategy_builder.rb24
-rw-r--r--lib/gitlab/import_export/command_line_util.rb4
-rw-r--r--lib/gitlab/import_export/file_importer.rb8
-rw-r--r--lib/gitlab/import_export/import_export.yml16
-rw-r--r--lib/gitlab/import_export/importer.rb56
-rw-r--r--lib/gitlab/import_export/lfs_restorer.rb43
-rw-r--r--lib/gitlab/import_export/lfs_saver.rb55
-rw-r--r--lib/gitlab/import_export/project_creator.rb23
-rw-r--r--lib/gitlab/import_export/project_tree_restorer.rb26
-rw-r--r--lib/gitlab/import_export/project_tree_saver.rb7
-rw-r--r--lib/gitlab/import_export/relation_factory.rb38
-rw-r--r--lib/gitlab/import_export/repo_restorer.rb2
-rw-r--r--lib/gitlab/import_export/repo_saver.rb2
-rw-r--r--lib/gitlab/import_export/saver.rb2
-rw-r--r--lib/gitlab/import_export/shared.rb45
-rw-r--r--lib/gitlab/import_export/statistics_restorer.rb17
-rw-r--r--lib/gitlab/import_export/uploads_saver.rb8
-rw-r--r--lib/gitlab/import_export/wiki_repo_saver.rb2
-rw-r--r--lib/gitlab/import_export/wiki_restorer.rb23
-rw-r--r--lib/gitlab/import_sources.rb1
-rw-r--r--lib/gitlab/insecure_key_fingerprint.rb23
-rw-r--r--lib/gitlab/job_waiter.rb8
-rw-r--r--lib/gitlab/kubernetes/config_map.rb37
-rw-r--r--lib/gitlab/kubernetes/helm.rb90
-rw-r--r--lib/gitlab/kubernetes/helm/api.rb45
-rw-r--r--lib/gitlab/kubernetes/helm/base_command.rb40
-rw-r--r--lib/gitlab/kubernetes/helm/init_command.rb19
-rw-r--r--lib/gitlab/kubernetes/helm/install_command.rb48
-rw-r--r--lib/gitlab/kubernetes/helm/pod.rb81
-rw-r--r--lib/gitlab/kubernetes/namespace.rb2
-rw-r--r--lib/gitlab/ldap/access.rb87
-rw-r--r--lib/gitlab/ldap/adapter.rb108
-rw-r--r--lib/gitlab/ldap/auth_hash.rb40
-rw-r--r--lib/gitlab/ldap/authentication.rb70
-rw-r--r--lib/gitlab/ldap/config.rb230
-rw-r--r--lib/gitlab/ldap/dn.rb301
-rw-r--r--lib/gitlab/ldap/person.rb109
-rw-r--r--lib/gitlab/ldap/user.rb52
-rw-r--r--lib/gitlab/legacy_github_import/client.rb2
-rw-r--r--lib/gitlab/legacy_github_import/importer.rb2
-rw-r--r--lib/gitlab/legacy_github_import/project_creator.rb13
-rw-r--r--lib/gitlab/metrics.rb4
-rw-r--r--lib/gitlab/metrics/influx_db.rb289
-rw-r--r--lib/gitlab/metrics/method_call.rb38
-rw-r--r--lib/gitlab/metrics/methods.rb129
-rw-r--r--lib/gitlab/metrics/methods/metric_options.rb61
-rw-r--r--lib/gitlab/metrics/null_metric.rb2
-rw-r--r--lib/gitlab/metrics/prometheus.rb93
-rw-r--r--lib/gitlab/metrics/sidekiq_metrics_exporter.rb12
-rw-r--r--lib/gitlab/metrics/subscribers/action_view.rb24
-rw-r--r--lib/gitlab/metrics/subscribers/active_record.rb18
-rw-r--r--lib/gitlab/metrics/transaction.rb84
-rw-r--r--lib/gitlab/middleware/go.rb17
-rw-r--r--lib/gitlab/middleware/multipart.rb10
-rw-r--r--lib/gitlab/middleware/read_only.rb83
-rw-r--r--lib/gitlab/middleware/read_only/controller.rb86
-rw-r--r--lib/gitlab/middleware/release_env.rb14
-rw-r--r--lib/gitlab/middleware/static.rb2
-rw-r--r--lib/gitlab/multi_collection_paginator.rb1
-rw-r--r--lib/gitlab/o_auth/auth_hash.rb90
-rw-r--r--lib/gitlab/o_auth/provider.rb54
-rw-r--r--lib/gitlab/o_auth/session.rb19
-rw-r--r--lib/gitlab/o_auth/user.rb234
-rw-r--r--lib/gitlab/omniauth_initializer.rb75
-rw-r--r--lib/gitlab/path_regex.rb12
-rw-r--r--lib/gitlab/performance_bar.rb6
-rw-r--r--lib/gitlab/plugin.rb26
-rw-r--r--lib/gitlab/plugin_logger.rb7
-rw-r--r--lib/gitlab/polling_interval.rb6
-rw-r--r--lib/gitlab/popen.rb27
-rw-r--r--lib/gitlab/popen/runner.rb46
-rw-r--r--lib/gitlab/profiler.rb147
-rw-r--r--lib/gitlab/project_search_results.rb50
-rw-r--r--lib/gitlab/project_transfer.rb16
-rw-r--r--lib/gitlab/prometheus/additional_metrics_parser.rb21
-rw-r--r--lib/gitlab/prometheus/metric_group.rb7
-rw-r--r--lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb1
-rw-r--r--lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb1
-rw-r--r--lib/gitlab/prometheus/queries/base_query.rb4
-rw-r--r--lib/gitlab/prometheus/queries/deployment_query.rb5
-rw-r--r--lib/gitlab/prometheus/queries/environment_query.rb5
-rw-r--r--lib/gitlab/prometheus/queries/matched_metric_query.rb80
-rw-r--r--lib/gitlab/prometheus/queries/matched_metrics_query.rb80
-rw-r--r--lib/gitlab/prometheus/queries/query_additional_metrics.rb30
-rw-r--r--lib/gitlab/prometheus_client.rb70
-rw-r--r--lib/gitlab/protocol_access.rb6
-rw-r--r--lib/gitlab/proxy_http_connection_adapter.rb34
-rw-r--r--lib/gitlab/query_limiting.rb36
-rw-r--r--lib/gitlab/query_limiting/active_support_subscriber.rb13
-rw-r--r--lib/gitlab/query_limiting/middleware.rb55
-rw-r--r--lib/gitlab/query_limiting/transaction.rb77
-rw-r--r--lib/gitlab/quick_actions/command_definition.rb15
-rw-r--r--lib/gitlab/quick_actions/dsl.rb5
-rw-r--r--lib/gitlab/quick_actions/extractor.rb11
-rw-r--r--lib/gitlab/quick_actions/spend_time_and_date_separator.rb2
-rw-r--r--lib/gitlab/recaptcha.rb10
-rw-r--r--lib/gitlab/redis/cache.rb2
-rw-r--r--lib/gitlab/redis/wrapper.rb2
-rw-r--r--lib/gitlab/regex.rb12
-rw-r--r--lib/gitlab/repo_path.rb6
-rw-r--r--lib/gitlab/repository_cache.rb33
-rw-r--r--lib/gitlab/repository_cache_adapter.rb84
-rw-r--r--lib/gitlab/saml/auth_hash.rb17
-rw-r--r--lib/gitlab/saml/config.rb19
-rw-r--r--lib/gitlab/saml/user.rb50
-rw-r--r--lib/gitlab/search_results.rb91
-rw-r--r--lib/gitlab/seeder.rb12
-rw-r--r--lib/gitlab/sentry.rb4
-rw-r--r--lib/gitlab/setup_helper.rb61
-rw-r--r--lib/gitlab/shell.rb210
-rw-r--r--lib/gitlab/sherlock/file_sample.rb2
-rw-r--r--lib/gitlab/sherlock/middleware.rb2
-rw-r--r--lib/gitlab/sherlock/query.rb4
-rw-r--r--lib/gitlab/sidekiq_logging/json_formatter.rb21
-rw-r--r--lib/gitlab/sidekiq_logging/structured_logger.rb96
-rw-r--r--lib/gitlab/sidekiq_middleware/memory_killer.rb67
-rw-r--r--lib/gitlab/sidekiq_middleware/shutdown.rb133
-rw-r--r--lib/gitlab/slash_commands/base_command.rb7
-rw-r--r--lib/gitlab/slash_commands/command.rb6
-rw-r--r--lib/gitlab/slash_commands/issue_move.rb45
-rw-r--r--lib/gitlab/slash_commands/presenters/issue_move.rb53
-rw-r--r--lib/gitlab/slash_commands/presenters/issue_new.rb2
-rw-r--r--lib/gitlab/slash_commands/presenters/issue_show.rb2
-rw-r--r--lib/gitlab/slash_commands/result.rb2
-rw-r--r--lib/gitlab/snippet_search_results.rb2
-rw-r--r--lib/gitlab/sql/pattern.rb14
-rw-r--r--lib/gitlab/ssh_public_key.rb28
-rw-r--r--lib/gitlab/storage_check/cli.rb2
-rw-r--r--lib/gitlab/string_placeholder_replacer.rb27
-rw-r--r--lib/gitlab/string_range_marker.rb2
-rw-r--r--lib/gitlab/string_regex_marker.rb12
-rw-r--r--lib/gitlab/task_helpers.rb169
-rw-r--r--lib/gitlab/testing/request_blocker_middleware.rb2
-rw-r--r--lib/gitlab/timeless.rb1
-rw-r--r--lib/gitlab/upgrader.rb7
-rw-r--r--lib/gitlab/uploads_transfer.rb2
-rw-r--r--lib/gitlab/url_blocker.rb85
-rw-r--r--lib/gitlab/usage_data.rb20
-rw-r--r--lib/gitlab/user_access.rb22
-rw-r--r--lib/gitlab/utils.rb17
-rw-r--r--lib/gitlab/utils/override.rb111
-rw-r--r--lib/gitlab/verify/batch_verifier.rb64
-rw-r--r--lib/gitlab/verify/job_artifacts.rb27
-rw-r--r--lib/gitlab/verify/lfs_objects.rb27
-rw-r--r--lib/gitlab/verify/rake_task.rb53
-rw-r--r--lib/gitlab/verify/uploads.rb27
-rw-r--r--lib/gitlab/visibility_level.rb17
-rw-r--r--lib/gitlab/workhorse.rb85
-rw-r--r--lib/google_api/auth.rb2
-rw-r--r--lib/google_api/cloud_platform/client.rb24
-rw-r--r--lib/haml_lint/inline_javascript.rb8
-rw-r--r--lib/mattermost/session.rb28
-rw-r--r--lib/mattermost/team.rb5
-rw-r--r--lib/microsoft_teams/notifier.rb5
-rw-r--r--lib/peek/views/gitaly.rb20
-rw-r--r--lib/peek/views/host.rb9
-rw-r--r--lib/repository_cache.rb30
-rw-r--r--lib/rouge/plugins/common_mark.rb20
-rw-r--r--lib/support/nginx/gitlab4
-rw-r--r--lib/support/nginx/gitlab-ssl4
-rw-r--r--lib/system_check/app/git_version_check.rb2
-rw-r--r--lib/system_check/helpers.rb4
-rw-r--r--lib/system_check/orphans/namespace_check.rb4
-rw-r--r--lib/system_check/orphans/repository_check.rb6
-rw-r--r--lib/system_check/simple_executor.rb1
-rw-r--r--lib/tasks/dev.rake5
-rw-r--r--lib/tasks/flay.rake4
-rw-r--r--lib/tasks/gemojione.rake2
-rw-r--r--lib/tasks/gitlab/artifacts/check.rake8
-rw-r--r--lib/tasks/gitlab/artifacts/migrate.rake25
-rw-r--r--lib/tasks/gitlab/backup.rake38
-rw-r--r--lib/tasks/gitlab/check.rake84
-rw-r--r--lib/tasks/gitlab/cleanup.rake17
-rw-r--r--lib/tasks/gitlab/dev.rake1
-rw-r--r--lib/tasks/gitlab/exclusive_lease.rake9
-rw-r--r--lib/tasks/gitlab/git.rake41
-rw-r--r--lib/tasks/gitlab/gitaly.rake71
-rw-r--r--lib/tasks/gitlab/helpers.rake4
-rw-r--r--lib/tasks/gitlab/info.rake4
-rw-r--r--lib/tasks/gitlab/lfs/check.rake8
-rw-r--r--lib/tasks/gitlab/lfs/migrate.rake22
-rw-r--r--lib/tasks/gitlab/list_repos.rake1
-rw-r--r--lib/tasks/gitlab/setup.rake2
-rw-r--r--lib/tasks/gitlab/shell.rake20
-rw-r--r--lib/tasks/gitlab/task_helpers.rb170
-rw-r--r--lib/tasks/gitlab/traces.rake24
-rw-r--r--lib/tasks/gitlab/two_factor.rake2
-rw-r--r--lib/tasks/gitlab/update_templates.rake1
-rw-r--r--lib/tasks/gitlab/uploads/check.rake8
-rw-r--r--lib/tasks/gitlab/uploads/migrate.rake34
-rw-r--r--lib/tasks/gitlab/workhorse.rake4
-rw-r--r--lib/tasks/lint.rake61
-rw-r--r--lib/tasks/migrate/migrate_iids.rake3
-rw-r--r--lib/tasks/migrate/setup_postgresql.rake22
-rw-r--r--lib/tasks/plugins.rake16
-rw-r--r--lib/tasks/test.rake5
-rw-r--r--lib/uploaded_file.rb40
-rw-r--r--locale/bg/gitlab.po3030
-rw-r--r--locale/de/gitlab.po3038
-rw-r--r--locale/eo/gitlab.po3020
-rw-r--r--locale/es/gitlab.po3320
-rw-r--r--locale/fil_PH/gitlab.po5288
-rw-r--r--locale/fr/gitlab.po3196
-rw-r--r--locale/gitlab.pot1755
-rw-r--r--locale/id_ID/gitlab.po5257
-rw-r--r--locale/it/gitlab.po3262
-rw-r--r--locale/ja/gitlab.po3004
-rw-r--r--locale/ko/gitlab.po3008
-rw-r--r--locale/nl_NL/gitlab.po2996
-rw-r--r--locale/pl_PL/gitlab.po3033
-rw-r--r--locale/pt_BR/gitlab.po3190
-rw-r--r--locale/ru/gitlab.po3273
-rw-r--r--locale/tr_TR/gitlab.po5288
-rw-r--r--locale/uk/gitlab.po3419
-rw-r--r--locale/zh_CN/gitlab.po3218
-rw-r--r--locale/zh_HK/gitlab.po3010
-rw-r--r--locale/zh_TW/gitlab.po3240
-rw-r--r--package.json78
-rwxr-xr-xplugins/examples/save_to_file.clj3
-rwxr-xr-xplugins/examples/save_to_file.rb3
-rw-r--r--public/robots.txt1
-rw-r--r--qa/Gemfile2
-rw-r--r--qa/Gemfile.lock40
-rw-r--r--qa/README.md43
-rw-r--r--qa/qa.rb92
-rw-r--r--qa/qa/factory/base.rb41
-rw-r--r--qa/qa/factory/dependency.rb7
-rw-r--r--qa/qa/factory/product.rb18
-rw-r--r--qa/qa/factory/repository/push.rb4
-rw-r--r--qa/qa/factory/resource/deploy_key.rb43
-rw-r--r--qa/qa/factory/resource/issue.rb32
-rw-r--r--qa/qa/factory/resource/merge_request.rb58
-rw-r--r--qa/qa/factory/resource/personal_access_token.rb27
-rw-r--r--qa/qa/factory/resource/project.rb4
-rw-r--r--qa/qa/factory/resource/runner.rb47
-rw-r--r--qa/qa/factory/resource/sandbox.rb2
-rw-r--r--qa/qa/factory/resource/secret_variable.rb29
-rw-r--r--qa/qa/factory/settings/hashed_storage.rb14
-rw-r--r--qa/qa/git/location.rb32
-rw-r--r--qa/qa/git/repository.rb21
-rw-r--r--qa/qa/page/README.md122
-rw-r--r--qa/qa/page/admin/menu.rb15
-rw-r--r--qa/qa/page/admin/settings.rb18
-rw-r--r--qa/qa/page/admin/settings/main.rb21
-rw-r--r--qa/qa/page/admin/settings/repository_storage.rb23
-rw-r--r--qa/qa/page/base.rb89
-rw-r--r--qa/qa/page/component/dropzone.rb31
-rw-r--r--qa/qa/page/dashboard/groups.rb9
-rw-r--r--qa/qa/page/dashboard/projects.rb13
-rw-r--r--qa/qa/page/element.rb32
-rw-r--r--qa/qa/page/group/new.rb11
-rw-r--r--qa/qa/page/group/show.rb48
-rw-r--r--qa/qa/page/main/login.rb63
-rw-r--r--qa/qa/page/main/menu.rb50
-rw-r--r--qa/qa/page/main/oauth.rb4
-rw-r--r--qa/qa/page/mattermost/login.rb7
-rw-r--r--qa/qa/page/mattermost/main.rb7
-rw-r--r--qa/qa/page/menu/admin.rb15
-rw-r--r--qa/qa/page/menu/main.rb78
-rw-r--r--qa/qa/page/menu/profile.rb27
-rw-r--r--qa/qa/page/menu/side.rb84
-rw-r--r--qa/qa/page/merge_request/new.rb31
-rw-r--r--qa/qa/page/merge_request/show.rb47
-rw-r--r--qa/qa/page/profile/personal_access_tokens.rb33
-rw-r--r--qa/qa/page/project/activity.rb15
-rw-r--r--qa/qa/page/project/issue/index.rb17
-rw-r--r--qa/qa/page/project/issue/new.rb33
-rw-r--r--qa/qa/page/project/issue/show.rb40
-rw-r--r--qa/qa/page/project/job/show.rb19
-rw-r--r--qa/qa/page/project/new.rb13
-rw-r--r--qa/qa/page/project/pipeline/index.rb19
-rw-r--r--qa/qa/page/project/pipeline/show.rb46
-rw-r--r--qa/qa/page/project/settings/advanced.rb33
-rw-r--r--qa/qa/page/project/settings/ci_cd.rb28
-rw-r--r--qa/qa/page/project/settings/common.rb19
-rw-r--r--qa/qa/page/project/settings/deploy_keys.rb56
-rw-r--r--qa/qa/page/project/settings/main.rb21
-rw-r--r--qa/qa/page/project/settings/merge_request.rb27
-rw-r--r--qa/qa/page/project/settings/repository.rb21
-rw-r--r--qa/qa/page/project/settings/runners.rb35
-rw-r--r--qa/qa/page/project/settings/secret_variables.rb46
-rw-r--r--qa/qa/page/project/show.rb66
-rw-r--r--qa/qa/page/settings/common.rb25
-rw-r--r--qa/qa/page/validator.rb52
-rw-r--r--qa/qa/page/view.rb55
-rw-r--r--qa/qa/runtime/address.rb20
-rw-r--r--qa/qa/runtime/api.rb82
-rw-r--r--qa/qa/runtime/browser.rb66
-rw-r--r--qa/qa/runtime/env.rb51
-rw-r--r--qa/qa/runtime/namespace.rb6
-rw-r--r--qa/qa/runtime/rsa_key.rb21
-rw-r--r--qa/qa/runtime/user.rb20
-rw-r--r--qa/qa/scenario/bootable.rb2
-rw-r--r--qa/qa/scenario/entrypoint.rb34
-rw-r--r--qa/qa/scenario/taggable.rb17
-rw-r--r--qa/qa/scenario/test/instance.rb27
-rw-r--r--qa/qa/scenario/test/integration/ldap.rb11
-rw-r--r--qa/qa/scenario/test/integration/mattermost.rb6
-rw-r--r--qa/qa/scenario/test/sanity/selectors.rb54
-rw-r--r--qa/qa/service/omnibus.rb20
-rw-r--r--qa/qa/service/runner.rb49
-rw-r--r--qa/qa/service/shellout.rb25
-rw-r--r--qa/qa/shell/omnibus.rb39
-rw-r--r--qa/qa/specs/features/api/users_spec.rb42
-rw-r--r--qa/qa/specs/features/login/ldap_spec.rb15
-rw-r--r--qa/qa/specs/features/login/standard_spec.rb2
-rw-r--r--qa/qa/specs/features/mattermost/group_create_spec.rb2
-rw-r--r--qa/qa/specs/features/merge_request/create_spec.rb17
-rw-r--r--qa/qa/specs/features/merge_request/rebase_spec.rb39
-rw-r--r--qa/qa/specs/features/project/activity_spec.rb20
-rw-r--r--qa/qa/specs/features/project/add_deploy_key_spec.rb20
-rw-r--r--qa/qa/specs/features/project/add_secret_variable_spec.rb24
-rw-r--r--qa/qa/specs/features/project/create_issue_spec.rb18
-rw-r--r--qa/qa/specs/features/project/create_spec.rb4
-rw-r--r--qa/qa/specs/features/project/deploy_key_clone_spec.rb81
-rw-r--r--qa/qa/specs/features/project/pipelines_spec.rb102
-rw-r--r--qa/qa/specs/features/repository/push_spec.rb5
-rw-r--r--qa/qa/specs/runner.rb6
-rw-r--r--qa/spec/factory/base_spec.rb78
-rw-r--r--qa/spec/factory/dependency_spec.rb13
-rw-r--r--qa/spec/factory/product_spec.rb35
-rw-r--r--qa/spec/fixtures/banana_sample.gifbin0 -> 71759 bytes
-rw-r--r--qa/spec/git/location_spec.rb55
-rw-r--r--qa/spec/page/base_spec.rb62
-rw-r--r--qa/spec/page/element_spec.rb51
-rw-r--r--qa/spec/page/validator_spec.rb79
-rw-r--r--qa/spec/page/view_spec.rb70
-rw-r--r--qa/spec/runtime/api_client_spec.rb30
-rw-r--r--qa/spec/runtime/api_request_spec.rb42
-rw-r--r--qa/spec/runtime/env_spec.rb79
-rw-r--r--qa/spec/runtime/rsa_key.rb9
-rw-r--r--qa/spec/scenario/entrypoint_spec.rb44
-rw-r--r--qa/spec/scenario/test/instance_spec.rb45
-rw-r--r--qa/spec/scenario/test/sanity/selectors_spec.rb40
-rw-r--r--qa/spec/spec_helper.rb2
-rw-r--r--qa/spec/support/stub_env.rb38
-rw-r--r--rubocop/cop/active_record_dependent.rb26
-rw-r--r--rubocop/cop/active_record_serialize.rb18
-rw-r--r--rubocop/cop/custom_error_class.rb64
-rw-r--r--rubocop/cop/gem_fetcher.rb37
-rw-r--r--rubocop/cop/gitlab/has_many_through_scope.rb45
-rw-r--r--rubocop/cop/gitlab/httparty.rb62
-rw-r--r--rubocop/cop/gitlab/module_with_instance_variables.rb4
-rw-r--r--rubocop/cop/gitlab/predicate_memoization.rb39
-rw-r--r--rubocop/cop/in_batches.rb16
-rw-r--r--rubocop/cop/include_sidekiq_worker.rb4
-rw-r--r--rubocop/cop/line_break_after_guard_clauses.rb100
-rw-r--r--rubocop/cop/line_break_around_conditional_block.rb119
-rw-r--r--rubocop/cop/migration/add_column.rb2
-rw-r--r--rubocop/cop/migration/add_concurrent_foreign_key.rb2
-rw-r--r--rubocop/cop/migration/add_concurrent_index.rb2
-rw-r--r--rubocop/cop/migration/add_index.rb2
-rw-r--r--rubocop/cop/migration/add_timestamps.rb2
-rw-r--r--rubocop/cop/migration/datetime.rb4
-rw-r--r--rubocop/cop/migration/hash_index.rb2
-rw-r--r--rubocop/cop/migration/remove_column.rb2
-rw-r--r--rubocop/cop/migration/remove_concurrent_index.rb2
-rw-r--r--rubocop/cop/migration/remove_index.rb2
-rw-r--r--rubocop/cop/migration/reversible_add_column_with_default.rb4
-rw-r--r--rubocop/cop/migration/safer_boolean_column.rb4
-rw-r--r--rubocop/cop/migration/timestamps.rb2
-rw-r--r--rubocop/cop/migration/update_column_in_batches.rb4
-rw-r--r--rubocop/cop/migration/update_large_table.rb4
-rw-r--r--rubocop/cop/polymorphic_associations.rb23
-rw-r--r--rubocop/cop/project_path_helper.rb2
-rw-r--r--rubocop/cop/redirect_with_status.rb44
-rw-r--r--rubocop/cop/rspec/env_assignment.rb5
-rw-r--r--rubocop/cop/rspec/factories_in_migration_specs.rb40
-rw-r--r--rubocop/cop/rspec/single_line_hook.rb38
-rw-r--r--rubocop/cop/rspec/verbose_include_metadata.rb74
-rw-r--r--rubocop/cop/sidekiq_options_queue.rb4
-rw-r--r--rubocop/model_helpers.rb11
-rw-r--r--rubocop/rubocop.rb22
-rw-r--r--rubocop/spec_helpers.rb13
-rw-r--r--scripts/create_mysql_user.sh2
-rw-r--r--scripts/frontend/frontend_script_utils.js24
-rw-r--r--scripts/frontend/prettier.js117
-rwxr-xr-xscripts/gitaly-test-build16
-rwxr-xr-xscripts/lint-changelog-yaml4
-rwxr-xr-xscripts/lint-doc.sh34
-rwxr-xr-xscripts/lint-rugged34
-rw-r--r--scripts/prepare_build.sh2
-rwxr-xr-xscripts/security-harness57
-rwxr-xr-xscripts/static-analysis66
-rwxr-xr-xscripts/trigger-build-docs46
-rwxr-xr-xscripts/trigger-build-omnibus3
-rw-r--r--spec/controllers/admin/application_settings_controller_spec.rb7
-rw-r--r--spec/controllers/admin/gitaly_servers_controller_spec.rb15
-rw-r--r--spec/controllers/admin/hooks_controller_spec.rb6
-rw-r--r--spec/controllers/admin/projects_controller_spec.rb10
-rw-r--r--spec/controllers/admin/users_controller_spec.rb2
-rw-r--r--spec/controllers/autocomplete_controller_spec.rb14
-rw-r--r--spec/controllers/boards/issues_controller_spec.rb7
-rw-r--r--spec/controllers/boards/lists_controller_spec.rb4
-rw-r--r--spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb146
-rw-r--r--spec/controllers/concerns/issuable_collections_spec.rb4
-rw-r--r--spec/controllers/concerns/send_file_upload_spec.rb89
-rw-r--r--spec/controllers/dashboard/groups_controller_spec.rb20
-rw-r--r--spec/controllers/dashboard/milestones_controller_spec.rb2
-rw-r--r--spec/controllers/dashboard/todos_controller_spec.rb2
-rw-r--r--spec/controllers/dashboard_controller_spec.rb4
-rw-r--r--spec/controllers/groups/boards_controller_spec.rb136
-rw-r--r--spec/controllers/groups/children_controller_spec.rb24
-rw-r--r--spec/controllers/groups/labels_controller_spec.rb33
-rw-r--r--spec/controllers/groups/milestones_controller_spec.rb2
-rw-r--r--spec/controllers/groups/uploads_controller_spec.rb4
-rw-r--r--spec/controllers/groups/variables_controller_spec.rb53
-rw-r--r--spec/controllers/groups_controller_spec.rb107
-rw-r--r--spec/controllers/health_check_controller_spec.rb2
-rw-r--r--spec/controllers/health_controller_spec.rb2
-rw-r--r--spec/controllers/help_controller_spec.rb2
-rw-r--r--spec/controllers/import/bitbucket_controller_spec.rb98
-rw-r--r--spec/controllers/import/gitlab_controller_spec.rb95
-rw-r--r--spec/controllers/import/gitlab_projects_controller_spec.rb38
-rw-r--r--spec/controllers/notification_settings_controller_spec.rb2
-rw-r--r--spec/controllers/oauth/applications_controller_spec.rb3
-rw-r--r--spec/controllers/oauth/authorizations_controller_spec.rb2
-rw-r--r--spec/controllers/omniauth_callbacks_controller_spec.rb128
-rw-r--r--spec/controllers/profiles_controller_spec.rb65
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb60
-rw-r--r--spec/controllers/projects/avatars_controller_spec.rb10
-rw-r--r--spec/controllers/projects/blame_controller_spec.rb2
-rw-r--r--spec/controllers/projects/blob_controller_spec.rb8
-rw-r--r--spec/controllers/projects/boards_controller_spec.rb12
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb67
-rw-r--r--spec/controllers/projects/ci/lints_controller_spec.rb123
-rw-r--r--spec/controllers/projects/clusters/applications_controller_spec.rb2
-rw-r--r--spec/controllers/projects/clusters/gcp_controller_spec.rb21
-rw-r--r--spec/controllers/projects/clusters_controller_spec.rb20
-rw-r--r--spec/controllers/projects/commits_controller_spec.rb10
-rw-r--r--spec/controllers/projects/compare_controller_spec.rb2
-rw-r--r--spec/controllers/projects/cycle_analytics_controller_spec.rb4
-rw-r--r--spec/controllers/projects/deploy_keys_controller_spec.rb4
-rw-r--r--spec/controllers/projects/deployments_controller_spec.rb6
-rw-r--r--spec/controllers/projects/discussions_controller_spec.rb77
-rw-r--r--spec/controllers/projects/find_file_controller_spec.rb2
-rw-r--r--spec/controllers/projects/forks_controller_spec.rb2
-rw-r--r--spec/controllers/projects/graphs_controller_spec.rb2
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb2
-rw-r--r--spec/controllers/projects/hooks_controller_spec.rb28
-rw-r--r--spec/controllers/projects/imports_controller_spec.rb4
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb106
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb119
-rw-r--r--spec/controllers/projects/labels_controller_spec.rb2
-rw-r--r--spec/controllers/projects/mattermosts_controller_spec.rb2
-rw-r--r--spec/controllers/projects/merge_requests/creations_controller_spec.rb110
-rw-r--r--spec/controllers/projects/merge_requests/diffs_controller_spec.rb2
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb58
-rw-r--r--spec/controllers/projects/milestones_controller_spec.rb21
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb14
-rw-r--r--spec/controllers/projects/pages_controller_spec.rb37
-rw-r--r--spec/controllers/projects/pages_domains_controller_spec.rb101
-rw-r--r--spec/controllers/projects/pipeline_schedules_controller_spec.rb18
-rw-r--r--spec/controllers/projects/pipelines_settings_controller_spec.rb57
-rw-r--r--spec/controllers/projects/project_members_controller_spec.rb26
-rw-r--r--spec/controllers/projects/prometheus/metrics_controller_spec.rb72
-rw-r--r--spec/controllers/projects/prometheus_controller_spec.rb59
-rw-r--r--spec/controllers/projects/protected_branches_controller_spec.rb97
-rw-r--r--spec/controllers/projects/raw_controller_spec.rb58
-rw-r--r--spec/controllers/projects/refs_controller_spec.rb2
-rw-r--r--spec/controllers/projects/releases_controller_spec.rb2
-rw-r--r--spec/controllers/projects/repositories_controller_spec.rb21
-rw-r--r--spec/controllers/projects/services_controller_spec.rb50
-rw-r--r--spec/controllers/projects/settings/ci_cd_controller_spec.rb148
-rw-r--r--spec/controllers/projects/settings/integrations_controller_spec.rb2
-rw-r--r--spec/controllers/projects/templates_controller_spec.rb2
-rw-r--r--spec/controllers/projects/todos_controller_spec.rb8
-rw-r--r--spec/controllers/projects/tree_controller_spec.rb2
-rw-r--r--spec/controllers/projects/uploads_controller_spec.rb8
-rw-r--r--spec/controllers/projects/variables_controller_spec.rb56
-rw-r--r--spec/controllers/projects_controller_spec.rb102
-rw-r--r--spec/controllers/root_controller_spec.rb24
-rw-r--r--spec/controllers/search_controller_spec.rb26
-rw-r--r--spec/controllers/uploads_controller_spec.rb21
-rw-r--r--spec/controllers/user_callouts_controller_spec.rb49
-rw-r--r--spec/controllers/users_controller_spec.rb29
-rw-r--r--spec/factories/appearances.rb17
-rw-r--r--spec/factories/badge.rb14
-rw-r--r--spec/factories/boards.rb25
-rw-r--r--spec/factories/ci/builds.rb45
-rw-r--r--spec/factories/ci/job_artifacts.rb19
-rw-r--r--spec/factories/clusters/applications/helm.rb6
-rw-r--r--spec/factories/clusters/applications/ingress.rb35
-rw-r--r--spec/factories/clusters/clusters.rb4
-rw-r--r--spec/factories/commits.rb2
-rw-r--r--spec/factories/deploy_keys_projects.rb4
-rw-r--r--spec/factories/deploy_tokens.rb14
-rw-r--r--spec/factories/deployments.rb3
-rw-r--r--spec/factories/events.rb2
-rw-r--r--spec/factories/groups.rb2
-rw-r--r--spec/factories/internal_ids.rb7
-rw-r--r--spec/factories/issues.rb3
-rw-r--r--spec/factories/keys.rb137
-rw-r--r--spec/factories/lfs_file_locks.rb7
-rw-r--r--spec/factories/lfs_objects.rb10
-rw-r--r--spec/factories/merge_requests.rb2
-rw-r--r--spec/factories/notes.rb14
-rw-r--r--spec/factories/pages_domains.rb35
-rw-r--r--spec/factories/project_deploy_tokens.rb6
-rw-r--r--spec/factories/project_hooks.rb1
-rw-r--r--spec/factories/project_wikis.rb2
-rw-r--r--spec/factories/projects.rb17
-rw-r--r--spec/factories/protected_branches.rb1
-rw-r--r--spec/factories/redirect_routes.rb6
-rw-r--r--spec/factories/sent_notifications.rb2
-rw-r--r--spec/factories/services.rb4
-rw-r--r--spec/factories/snippets.rb1
-rw-r--r--spec/factories/subscriptions.rb2
-rw-r--r--spec/factories/timelogs.rb2
-rw-r--r--spec/factories/todos.rb4
-rw-r--r--spec/factories/uploads.rb37
-rw-r--r--spec/factories/user_callouts.rb7
-rw-r--r--spec/factories/users.rb2
-rw-r--r--spec/factories/users_star_projects.rb6
-rw-r--r--spec/features/admin/admin_builds_spec.rb16
-rw-r--r--spec/features/admin/admin_deploy_keys_spec.rb14
-rw-r--r--spec/features/admin/admin_disables_git_access_protocol_spec.rb15
-rw-r--r--spec/features/admin/admin_groups_spec.rb2
-rw-r--r--spec/features/admin/admin_health_check_spec.rb4
-rw-r--r--spec/features/admin/admin_hooks_spec.rb75
-rw-r--r--spec/features/admin/admin_projects_spec.rb8
-rw-r--r--spec/features/admin/admin_runners_spec.rb14
-rw-r--r--spec/features/admin/admin_settings_spec.rb225
-rw-r--r--spec/features/admin/admin_users_spec.rb10
-rw-r--r--spec/features/admin/services/admin_activates_prometheus_spec.rb21
-rw-r--r--spec/features/atom/dashboard_issues_spec.rb21
-rw-r--r--spec/features/atom/dashboard_spec.rb2
-rw-r--r--spec/features/atom/issues_spec.rb2
-rw-r--r--spec/features/atom/users_spec.rb6
-rw-r--r--spec/features/auto_deploy_spec.rb77
-rw-r--r--spec/features/boards/add_issues_modal_spec.rb2
-rw-r--r--spec/features/boards/boards_spec.rb19
-rw-r--r--spec/features/boards/issue_ordering_spec.rb2
-rw-r--r--spec/features/boards/keyboard_shortcut_spec.rb36
-rw-r--r--spec/features/boards/modal_filter_spec.rb6
-rw-r--r--spec/features/boards/new_issue_spec.rb2
-rw-r--r--spec/features/boards/sidebar_spec.rb14
-rw-r--r--spec/features/boards/sub_group_project_spec.rb2
-rw-r--r--spec/features/ci_lint_spec.rb51
-rw-r--r--spec/features/commits_spec.rb10
-rw-r--r--spec/features/copy_as_gfm_spec.rb686
-rw-r--r--spec/features/cycle_analytics_spec.rb23
-rw-r--r--spec/features/dashboard/activity_spec.rb7
-rw-r--r--spec/features/dashboard/archived_projects_spec.rb4
-rw-r--r--spec/features/dashboard/datetime_on_tooltips_spec.rb4
-rw-r--r--spec/features/dashboard/groups_list_spec.rb8
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb36
-rw-r--r--spec/features/dashboard/issues_spec.rb19
-rw-r--r--spec/features/dashboard/merge_requests_spec.rb40
-rw-r--r--spec/features/dashboard/milestones_spec.rb2
-rw-r--r--spec/features/dashboard/project_member_activity_index_spec.rb2
-rw-r--r--spec/features/dashboard/projects_spec.rb10
-rw-r--r--spec/features/dashboard/todos/todos_filtering_spec.rb16
-rw-r--r--spec/features/dashboard/todos/todos_sorting_spec.rb2
-rw-r--r--spec/features/dashboard/user_filters_projects_spec.rb4
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb7
-rw-r--r--spec/features/explore/groups_spec.rb87
-rw-r--r--spec/features/global_search_spec.rb4
-rw-r--r--spec/features/group_variables_spec.rb69
-rw-r--r--spec/features/groups/activity_spec.rb25
-rw-r--r--spec/features/groups/empty_states_spec.rb124
-rw-r--r--spec/features/groups/group_settings_spec.rb21
-rw-r--r--spec/features/groups/issues_spec.rb75
-rw-r--r--spec/features/groups/members/manage_members_spec.rb (renamed from spec/features/groups/members/manage_members.rb)0
-rw-r--r--spec/features/groups/members/search_members_spec.rb29
-rw-r--r--spec/features/groups/merge_requests_spec.rb16
-rw-r--r--spec/features/groups/milestone_spec.rb151
-rw-r--r--spec/features/groups/settings/group_badges_spec.rb124
-rw-r--r--spec/features/groups/show_spec.rb52
-rw-r--r--spec/features/groups/user_browse_projects_group_page_spec.rb29
-rw-r--r--spec/features/invites_spec.rb97
-rw-r--r--spec/features/issuables/discussion_lock_spec.rb2
-rw-r--r--spec/features/issues/award_emoji_spec.rb2
-rw-r--r--spec/features/issues/bulk_assignment_labels_spec.rb3
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb4
-rw-r--r--spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/dropdown_assignee_spec.rb8
-rw-r--r--spec/features/issues/filtered_search/dropdown_author_spec.rb8
-rw-r--r--spec/features/issues/filtered_search/dropdown_emoji_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_hint_spec.rb7
-rw-r--r--spec/features/issues/filtered_search/dropdown_milestone_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb9
-rw-r--r--spec/features/issues/filtered_search/recent_searches_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb2
-rw-r--r--spec/features/issues/form_spec.rb49
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb2
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb69
-rw-r--r--spec/features/issues/keyboard_shortcut_spec.rb36
-rw-r--r--spec/features/issues/move_spec.rb12
-rw-r--r--spec/features/issues/notes_on_issues_spec.rb2
-rw-r--r--spec/features/issues/spam_issues_spec.rb4
-rw-r--r--spec/features/issues/todo_spec.rb2
-rw-r--r--spec/features/issues/update_issues_spec.rb2
-rw-r--r--spec/features/issues/user_uses_slash_commands_spec.rb41
-rw-r--r--spec/features/issues_spec.rb9
-rw-r--r--spec/features/labels_hierarchy_spec.rb305
-rw-r--r--spec/features/login_spec.rb370
-rw-r--r--spec/features/markdown/copy_as_gfm_spec.rb782
-rw-r--r--spec/features/markdown/gitlab_flavored_markdown_spec.rb (renamed from spec/features/gitlab_flavored_markdown_spec.rb)0
-rw-r--r--spec/features/markdown/markdown_spec.rb337
-rw-r--r--spec/features/markdown/math_spec.rb22
-rw-r--r--spec/features/markdown/mermaid_spec.rb24
-rw-r--r--spec/features/markdown_spec.rb329
-rw-r--r--spec/features/merge_request/maintainer_edits_fork_spec.rb44
-rw-r--r--spec/features/merge_request/user_allows_a_maintainer_to_push_spec.rb85
-rw-r--r--spec/features/merge_request/user_assigns_themselves_spec.rb49
-rw-r--r--spec/features/merge_request/user_awards_emoji_spec.rb49
-rw-r--r--spec/features/merge_request/user_cherry_picks_spec.rb45
-rw-r--r--spec/features/merge_request/user_creates_image_diff_notes_spec.rb206
-rw-r--r--spec/features/merge_request/user_creates_mr_spec.rb31
-rw-r--r--spec/features/merge_request/user_customizes_merge_commit_message_spec.rb54
-rw-r--r--spec/features/merge_request/user_edits_mr_spec.rb11
-rw-r--r--spec/features/merge_request/user_locks_discussion_spec.rb49
-rw-r--r--spec/features/merge_request/user_merges_immediately_spec.rb41
-rw-r--r--spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb145
-rw-r--r--spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb192
-rw-r--r--spec/features/merge_request/user_posts_diff_notes_spec.rb281
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb168
-rw-r--r--spec/features/merge_request/user_resolves_conflicts_spec.rb195
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb527
-rw-r--r--spec/features/merge_request/user_resolves_outdated_diff_discussions_spec.rb78
-rw-r--r--spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb38
-rw-r--r--spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb192
-rw-r--r--spec/features/merge_request/user_sees_closing_issues_message_spec.rb76
-rw-r--r--spec/features/merge_request/user_sees_deleted_target_branch_spec.rb22
-rw-r--r--spec/features/merge_request/user_sees_deployment_widget_spec.rb56
-rw-r--r--spec/features/merge_request/user_sees_diff_spec.rb98
-rw-r--r--spec/features/merge_request/user_sees_discussions_spec.rb87
-rw-r--r--spec/features/merge_request/user_sees_empty_state_spec.rb30
-rw-r--r--spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb61
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb328
-rw-r--r--spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb125
-rw-r--r--spec/features/merge_request/user_sees_mr_from_deleted_forked_project_spec.rb24
-rw-r--r--spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb34
-rw-r--r--spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb35
-rw-r--r--spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb34
-rw-r--r--spec/features/merge_request/user_sees_pipelines_spec.rb102
-rw-r--r--spec/features/merge_request/user_sees_system_notes_spec.rb31
-rw-r--r--spec/features/merge_request/user_sees_versions_spec.rb217
-rw-r--r--spec/features/merge_request/user_sees_wip_help_message_spec.rb59
-rw-r--r--spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb180
-rw-r--r--spec/features/merge_request/user_toggles_whitespace_changes_spec.rb25
-rw-r--r--spec/features/merge_request/user_uses_slash_commands_spec.rb202
-rw-r--r--spec/features/merge_requests/assign_issues_spec.rb51
-rw-r--r--spec/features/merge_requests/award_spec.rb49
-rw-r--r--spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb69
-rw-r--r--spec/features/merge_requests/cherry_pick_spec.rb45
-rw-r--r--spec/features/merge_requests/closes_issues_spec.rb78
-rw-r--r--spec/features/merge_requests/conflicts_spec.rb196
-rw-r--r--spec/features/merge_requests/create_new_mr_from_fork_spec.rb89
-rw-r--r--spec/features/merge_requests/create_new_mr_spec.rb181
-rw-r--r--spec/features/merge_requests/created_from_fork_spec.rb94
-rw-r--r--spec/features/merge_requests/deleted_source_branch_spec.rb36
-rw-r--r--spec/features/merge_requests/diff_notes_avatars_spec.rb194
-rw-r--r--spec/features/merge_requests/diff_notes_resolve_spec.rb527
-rw-r--r--spec/features/merge_requests/diffs_spec.rb98
-rw-r--r--spec/features/merge_requests/discussion_lock_spec.rb49
-rw-r--r--spec/features/merge_requests/discussion_spec.rb90
-rw-r--r--spec/features/merge_requests/edit_mr_spec.rb73
-rw-r--r--spec/features/merge_requests/filter_by_labels_spec.rb93
-rw-r--r--spec/features/merge_requests/filter_by_milestone_spec.rb97
-rw-r--r--spec/features/merge_requests/filter_merge_requests_spec.rb337
-rw-r--r--spec/features/merge_requests/filters_generic_behavior_spec.rb81
-rw-r--r--spec/features/merge_requests/form_spec.rb301
-rw-r--r--spec/features/merge_requests/image_diff_notes_spec.rb225
-rw-r--r--spec/features/merge_requests/merge_commit_message_toggle_spec.rb57
-rw-r--r--spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb49
-rw-r--r--spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb160
-rw-r--r--spec/features/merge_requests/mini_pipeline_graph_spec.rb126
-rw-r--r--spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb150
-rw-r--r--spec/features/merge_requests/pipelines_spec.rb102
-rw-r--r--spec/features/merge_requests/reset_filters_spec.rb136
-rw-r--r--spec/features/merge_requests/resolve_outdated_diff_discussions.rb78
-rw-r--r--spec/features/merge_requests/target_branch_spec.rb33
-rw-r--r--spec/features/merge_requests/toggle_whitespace_changes_spec.rb22
-rw-r--r--spec/features/merge_requests/toggler_behavior_spec.rb28
-rw-r--r--spec/features/merge_requests/update_merge_requests_spec.rb133
-rw-r--r--spec/features/merge_requests/user_filters_by_assignees_spec.rb36
-rw-r--r--spec/features/merge_requests/user_filters_by_labels_spec.rb49
-rw-r--r--spec/features/merge_requests/user_filters_by_milestones_spec.rb62
-rw-r--r--spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb38
-rw-r--r--spec/features/merge_requests/user_lists_merge_requests_spec.rb4
-rw-r--r--spec/features/merge_requests/user_mass_updates_spec.rb133
-rw-r--r--spec/features/merge_requests/user_posts_diff_notes_spec.rb282
-rw-r--r--spec/features/merge_requests/user_posts_notes_spec.rb166
-rw-r--r--spec/features/merge_requests/user_sees_system_notes_spec.rb31
-rw-r--r--spec/features/merge_requests/user_uses_slash_commands_spec.rb207
-rw-r--r--spec/features/merge_requests/versions_spec.rb234
-rw-r--r--spec/features/merge_requests/widget_deployments_spec.rb59
-rw-r--r--spec/features/merge_requests/widget_spec.rb304
-rw-r--r--spec/features/merge_requests/wip_message_spec.rb59
-rw-r--r--spec/features/milestone_spec.rb28
-rw-r--r--spec/features/oauth_login_spec.rb3
-rw-r--r--spec/features/password_reset_spec.rb19
-rw-r--r--spec/features/profile_spec.rb8
-rw-r--r--spec/features/profiles/account_spec.rb16
-rw-r--r--spec/features/profiles/keys_spec.rb1
-rw-r--r--spec/features/profiles/oauth_applications_spec.rb8
-rw-r--r--spec/features/profiles/password_spec.rb86
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb58
-rw-r--r--spec/features/profiles/user_manages_applications_spec.rb39
-rw-r--r--spec/features/profiles/user_visits_notifications_tab_spec.rb4
-rw-r--r--spec/features/profiles/user_visits_profile_authentication_log_spec.rb25
-rw-r--r--spec/features/profiles/user_visits_profile_spec.rb51
-rw-r--r--spec/features/project_variables_spec.rb18
-rw-r--r--spec/features/projects/activity/rss_spec.rb2
-rw-r--r--spec/features/projects/actve_tabs_spec.rb137
-rw-r--r--spec/features/projects/badges/coverage_spec.rb6
-rw-r--r--spec/features/projects/badges/list_spec.rb4
-rw-r--r--spec/features/projects/blobs/blob_show_spec.rb25
-rw-r--r--spec/features/projects/blobs/edit_spec.rb30
-rw-r--r--spec/features/projects/branches/download_buttons_spec.rb4
-rw-r--r--spec/features/projects/branches_spec.rb141
-rw-r--r--spec/features/projects/ci/lint_spec.rb74
-rw-r--r--spec/features/projects/clusters/applications_spec.rb44
-rw-r--r--spec/features/projects/clusters/gcp_spec.rb183
-rw-r--r--spec/features/projects/clusters/interchangeability_spec.rb2
-rw-r--r--spec/features/projects/clusters/user_spec.rb26
-rw-r--r--spec/features/projects/clusters_spec.rb22
-rw-r--r--spec/features/projects/commit/builds_spec.rb2
-rw-r--r--spec/features/projects/commit/cherry_pick_spec.rb2
-rw-r--r--spec/features/projects/commits/rss_spec.rb2
-rw-r--r--spec/features/projects/commits/user_browses_commits_spec.rb15
-rw-r--r--spec/features/projects/compare_spec.rb2
-rw-r--r--spec/features/projects/deploy_keys_spec.rb2
-rw-r--r--spec/features/projects/developer_views_empty_project_instructions_spec.rb2
-rw-r--r--spec/features/projects/edit_spec.rb2
-rw-r--r--spec/features/projects/environments/environment_spec.rb4
-rw-r--r--spec/features/projects/environments/environments_spec.rb2
-rw-r--r--spec/features/projects/features_visibility_spec.rb6
-rw-r--r--spec/features/projects/files/browse_files_spec.rb2
-rw-r--r--spec/features/projects/files/creating_a_file_spec.rb2
-rw-r--r--spec/features/projects/files/dockerfile_dropdown_spec.rb2
-rw-r--r--spec/features/projects/files/download_buttons_spec.rb2
-rw-r--r--spec/features/projects/files/edit_file_soft_wrap_spec.rb2
-rw-r--r--spec/features/projects/files/editing_a_file_spec.rb2
-rw-r--r--spec/features/projects/files/files_sort_submodules_with_folders_spec.rb2
-rw-r--r--spec/features/projects/files/find_file_keyboard_spec.rb2
-rw-r--r--spec/features/projects/files/gitignore_dropdown_spec.rb2
-rw-r--r--spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb2
-rw-r--r--spec/features/projects/files/project_owner_creates_license_file_spec.rb2
-rw-r--r--spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb2
-rw-r--r--spec/features/projects/files/template_selector_menu_spec.rb66
-rw-r--r--spec/features/projects/files/template_type_dropdown_spec.rb2
-rw-r--r--spec/features/projects/files/undo_template_spec.rb2
-rw-r--r--spec/features/projects/fork_spec.rb104
-rw-r--r--spec/features/projects/graph_spec.rb75
-rw-r--r--spec/features/projects/guest_navigation_menu_spec.rb2
-rw-r--r--spec/features/projects/hook_logs/user_reads_log_spec.rb21
-rw-r--r--spec/features/projects/import_export/export_file_spec.rb2
-rw-r--r--spec/features/projects/import_export/import_file_spec.rb3
-rw-r--r--spec/features/projects/import_export/namespace_export_file_spec.rb57
-rw-r--r--spec/features/projects/import_export/test_project_export.tar.gzbin343232 -> 341299 bytes
-rw-r--r--spec/features/projects/issuable_templates_spec.rb4
-rw-r--r--spec/features/projects/issues/rss_spec.rb2
-rw-r--r--spec/features/projects/issues/user_comments_on_issue_spec.rb73
-rw-r--r--spec/features/projects/issues/user_creates_issue_spec.rb87
-rw-r--r--spec/features/projects/issues/user_edits_issue_spec.rb25
-rw-r--r--spec/features/projects/issues/user_sorts_issues_spec.rb39
-rw-r--r--spec/features/projects/issues/user_toggles_subscription_spec.rb28
-rw-r--r--spec/features/projects/issues/user_views_issue_spec.rb16
-rw-r--r--spec/features/projects/issues/user_views_issues_spec.rb120
-rw-r--r--spec/features/projects/jobs/user_browses_job_spec.rb35
-rw-r--r--spec/features/projects/jobs/user_browses_jobs_spec.rb11
-rw-r--r--spec/features/projects/jobs_spec.rb145
-rw-r--r--spec/features/projects/labels/subscription_spec.rb2
-rw-r--r--spec/features/projects/labels/update_prioritization_spec.rb4
-rw-r--r--spec/features/projects/labels/user_creates_labels_spec.rb88
-rw-r--r--spec/features/projects/labels/user_edits_labels_spec.rb25
-rw-r--r--spec/features/projects/labels/user_removes_labels_spec.rb52
-rw-r--r--spec/features/projects/labels/user_views_labels_spec.rb23
-rw-r--r--spec/features/projects/main/download_buttons_spec.rb2
-rw-r--r--spec/features/projects/main/rss_spec.rb2
-rw-r--r--spec/features/projects/members/anonymous_user_sees_members_spec.rb2
-rw-r--r--spec/features/projects/members/group_members_spec.rb2
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb2
-rw-r--r--spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb2
-rw-r--r--spec/features/projects/members/master_manages_access_requests_spec.rb6
-rw-r--r--spec/features/projects/members/member_cannot_request_access_to_his_project_spec.rb2
-rw-r--r--spec/features/projects/members/member_leaves_project_spec.rb2
-rw-r--r--spec/features/projects/members/share_with_group_spec.rb7
-rw-r--r--spec/features/projects/members/user_requests_access_spec.rb2
-rw-r--r--spec/features/projects/merge_request_button_spec.rb4
-rw-r--r--spec/features/projects/merge_requests/list_spec.rb44
-rw-r--r--spec/features/projects/merge_requests/user_manages_subscription_spec.rb20
-rw-r--r--spec/features/projects/milestones/milestones_sorting_spec.rb1
-rw-r--r--spec/features/projects/network_graph_spec.rb108
-rw-r--r--spec/features/projects/new_project_spec.rb20
-rw-r--r--spec/features/projects/pages_spec.rb286
-rw-r--r--spec/features/projects/pipeline_schedules_spec.rb34
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb18
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb114
-rw-r--r--spec/features/projects/redirects_spec.rb74
-rw-r--r--spec/features/projects/services/disable_triggers_spec.rb35
-rw-r--r--spec/features/projects/services/user_activates_issue_tracker_spec.rb92
-rw-r--r--spec/features/projects/services/user_activates_jira_spec.rb33
-rw-r--r--spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb2
-rw-r--r--spec/features/projects/services/user_activates_prometheus_spec.rb23
-rw-r--r--spec/features/projects/services/user_activates_slack_slash_command_spec.rb2
-rw-r--r--spec/features/projects/settings/integration_settings_spec.rb2
-rw-r--r--spec/features/projects/settings/merge_requests_settings_spec.rb2
-rw-r--r--spec/features/projects/settings/pipelines_settings_spec.rb2
-rw-r--r--spec/features/projects/settings/project_badges_spec.rb125
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb37
-rw-r--r--spec/features/projects/settings/user_manages_project_members_spec.rb2
-rw-r--r--spec/features/projects/settings/visibility_settings_spec.rb2
-rw-r--r--spec/features/projects/show_project_spec.rb339
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb2
-rw-r--r--spec/features/projects/snippets/show_spec.rb2
-rw-r--r--spec/features/projects/tags/download_buttons_spec.rb2
-rw-r--r--spec/features/projects/tree/create_directory_spec.rb6
-rw-r--r--spec/features/projects/tree/create_file_spec.rb8
-rw-r--r--spec/features/projects/tree/rss_spec.rb2
-rw-r--r--spec/features/projects/tree/tree_show_spec.rb14
-rw-r--r--spec/features/projects/tree/upload_file_spec.rb4
-rw-r--r--spec/features/projects/user_browses_files_spec.rb2
-rw-r--r--spec/features/projects/user_creates_directory_spec.rb4
-rw-r--r--spec/features/projects/user_creates_files_spec.rb17
-rw-r--r--spec/features/projects/user_deletes_files_spec.rb4
-rw-r--r--spec/features/projects/user_edits_files_spec.rb8
-rw-r--r--spec/features/projects/user_replaces_files_spec.rb4
-rw-r--r--spec/features/projects/user_uploads_files_spec.rb4
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_updates_wiki_page_spec.rb264
-rw-r--r--spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_views_wiki_page_spec.rb202
-rw-r--r--spec/features/projects_spec.rb32
-rw-r--r--spec/features/protected_branches_spec.rb5
-rw-r--r--spec/features/protected_tags_spec.rb5
-rw-r--r--spec/features/read_only_spec.rb25
-rw-r--r--spec/features/runners_spec.rb34
-rw-r--r--spec/features/search/user_searches_for_code_spec.rb2
-rw-r--r--spec/features/search/user_searches_for_issues_spec.rb2
-rw-r--r--spec/features/search/user_searches_for_merge_requests_spec.rb2
-rw-r--r--spec/features/search/user_searches_for_milestones_spec.rb2
-rw-r--r--spec/features/search/user_searches_for_wiki_pages_spec.rb2
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb127
-rw-r--r--spec/features/search/user_uses_search_filters_spec.rb6
-rw-r--r--spec/features/signed_commits_spec.rb6
-rw-r--r--spec/features/signup_spec.rb103
-rw-r--r--spec/features/tags/master_creates_tag_spec.rb2
-rw-r--r--spec/features/tags/master_deletes_tag_spec.rb2
-rw-r--r--spec/features/tags/master_updates_tag_spec.rb2
-rw-r--r--spec/features/tags/master_views_tags_spec.rb2
-rw-r--r--spec/features/triggers_spec.rb6
-rw-r--r--spec/features/u2f_spec.rb6
-rw-r--r--spec/features/user_can_display_performance_bar_spec.rb34
-rw-r--r--spec/features/user_sorts_things_spec.rb57
-rw-r--r--spec/features/users/login_spec.rb402
-rw-r--r--spec/features/users/logout_spec.rb (renamed from spec/features/logout_spec.rb)0
-rw-r--r--spec/features/users/projects_spec.rb29
-rw-r--r--spec/features/users/show_spec.rb17
-rw-r--r--spec/features/users/signup_spec.rb135
-rw-r--r--spec/features/users/user_browses_projects_on_user_page_spec.rb129
-rw-r--r--spec/features/users_spec.rb114
-rw-r--r--spec/features/variables_spec.rb145
-rw-r--r--spec/finders/access_requests_finder_spec.rb4
-rw-r--r--spec/finders/clusters_finder_spec.rb2
-rw-r--r--spec/finders/concerns/finder_methods_spec.rb70
-rw-r--r--spec/finders/concerns/finder_with_cross_project_access_spec.rb118
-rw-r--r--spec/finders/events_finder_spec.rb8
-rw-r--r--spec/finders/group_descendants_finder_spec.rb20
-rw-r--r--spec/finders/group_projects_finder_spec.rb80
-rw-r--r--spec/finders/issues_finder_spec.rb92
-rw-r--r--spec/finders/labels_finder_spec.rb90
-rw-r--r--spec/finders/merge_requests_finder_spec.rb116
-rw-r--r--spec/finders/milestones_finder_spec.rb41
-rw-r--r--spec/finders/move_to_project_finder_spec.rb34
-rw-r--r--spec/finders/notes_finder_spec.rb18
-rw-r--r--spec/finders/personal_projects_finder_spec.rb2
-rw-r--r--spec/finders/snippets_finder_spec.rb89
-rw-r--r--spec/finders/todos_finder_spec.rb27
-rw-r--r--spec/finders/user_recent_events_finder_spec.rb31
-rw-r--r--spec/fixtures/api/schemas/cluster_status.json3
-rw-r--r--spec/fixtures/api/schemas/deployment.json45
-rw-r--r--spec/fixtures/api/schemas/deployments.json44
-rw-r--r--spec/fixtures/api/schemas/entities/issue.json1
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_basic.json4
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_metrics.json21
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_widget.json21
-rw-r--r--spec/fixtures/api/schemas/entities/user.json17
-rw-r--r--spec/fixtures/api/schemas/issue.json2
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/blobs.json19
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/board.json86
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/boards.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commit/detail.json7
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/commits_details.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issue.json96
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issues.json95
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/merge_requests.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/milestones.json24
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/notes.json35
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json6
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pipelines.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/project/export_status.json24
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/project/identity.json21
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/projects.json36
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/snippets.json33
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/tag.json1
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/user/basic.json6
-rw-r--r--spec/fixtures/api/schemas/variable.json17
-rw-r--r--spec/fixtures/api/schemas/variables.json11
-rw-r--r--spec/fixtures/emails/attachment.eml28
-rw-r--r--spec/fixtures/emails/update_commands_only_reply.eml38
-rw-r--r--spec/fixtures/exported-project.gzbin0 -> 2306 bytes
-rw-r--r--spec/fixtures/markdown.md.erb12
-rw-r--r--spec/fixtures/trace/sample_trace1185
-rw-r--r--spec/helpers/application_helper_spec.rb52
-rw-r--r--spec/helpers/auto_devops_helper_spec.rb35
-rw-r--r--spec/helpers/avatars_helper_spec.rb16
-rw-r--r--spec/helpers/blob_helper_spec.rb15
-rw-r--r--spec/helpers/boards_helper_spec.rb28
-rw-r--r--spec/helpers/dashboard_helper_spec.rb24
-rw-r--r--spec/helpers/diff_helper_spec.rb40
-rw-r--r--spec/helpers/events_helper_spec.rb4
-rw-r--r--spec/helpers/explore_helper_spec.rb18
-rw-r--r--spec/helpers/graph_helper_spec.rb6
-rw-r--r--spec/helpers/groups_helper_spec.rb39
-rw-r--r--spec/helpers/import_helper_spec.rb31
-rw-r--r--spec/helpers/issuables_helper_spec.rb96
-rw-r--r--spec/helpers/issues_helper_spec.rb15
-rw-r--r--spec/helpers/labels_helper_spec.rb74
-rw-r--r--spec/helpers/markup_helper_spec.rb2
-rw-r--r--spec/helpers/members_helper_spec.rb10
-rw-r--r--spec/helpers/nav_helper_spec.rb53
-rw-r--r--spec/helpers/notes_helper_spec.rb8
-rw-r--r--spec/helpers/page_layout_helper_spec.rb5
-rw-r--r--spec/helpers/preferences_helper_spec.rb101
-rw-r--r--spec/helpers/projects_helper_spec.rb76
-rw-r--r--spec/helpers/todos_helper_spec.rb4
-rw-r--r--spec/helpers/tree_helper_spec.rb19
-rw-r--r--spec/helpers/u2f_helper_spec.rb49
-rw-r--r--spec/helpers/user_callouts_helper_spec.rb47
-rw-r--r--spec/helpers/users_helper_spec.rb13
-rw-r--r--spec/helpers/version_check_helper_spec.rb6
-rw-r--r--spec/initializers/6_validations_spec.rb14
-rw-r--r--spec/initializers/artifacts_direct_upload_support_spec.rb71
-rw-r--r--spec/initializers/fog_google_https_private_urls_spec.rb24
-rw-r--r--spec/initializers/gollum_spec.rb62
-rw-r--r--spec/initializers/grape_route_helpers_fix_spec.rb14
-rw-r--r--spec/initializers/settings_spec.rb4
-rw-r--r--spec/javascripts/abuse_reports_spec.js41
-rw-r--r--spec/javascripts/activities_spec.js1
-rw-r--r--spec/javascripts/ajax_loading_spinner_spec.js7
-rw-r--r--spec/javascripts/api_spec.js331
-rw-r--r--spec/javascripts/autosave_spec.js56
-rw-r--r--spec/javascripts/awards_handler_spec.js26
-rw-r--r--spec/javascripts/badges/components/badge_form_spec.js171
-rw-r--r--spec/javascripts/badges/components/badge_list_row_spec.js97
-rw-r--r--spec/javascripts/badges/components/badge_list_spec.js88
-rw-r--r--spec/javascripts/badges/components/badge_settings_spec.js109
-rw-r--r--spec/javascripts/badges/components/badge_spec.js147
-rw-r--r--spec/javascripts/badges/dummy_badge.js23
-rw-r--r--spec/javascripts/badges/store/actions_spec.js607
-rw-r--r--spec/javascripts/badges/store/mutations_spec.js418
-rw-r--r--spec/javascripts/behaviors/autosize_spec.js1
-rw-r--r--spec/javascripts/behaviors/copy_as_gfm_spec.js2
-rw-r--r--spec/javascripts/behaviors/quick_submit_spec.js1
-rw-r--r--spec/javascripts/behaviors/requires_input_spec.js3
-rw-r--r--spec/javascripts/behaviors/secret_values_spec.js110
-rw-r--r--spec/javascripts/blob/blob_file_dropzone_spec.js3
-rw-r--r--spec/javascripts/blob/notebook/index_spec.js48
-rw-r--r--spec/javascripts/blob/viewer/index_spec.js38
-rw-r--r--spec/javascripts/boards/board_blank_state_spec.js27
-rw-r--r--spec/javascripts/boards/board_card_spec.js15
-rw-r--r--spec/javascripts/boards/board_list_spec.js34
-rw-r--r--spec/javascripts/boards/board_new_issue_spec.js25
-rw-r--r--spec/javascripts/boards/boards_store_spec.js17
-rw-r--r--spec/javascripts/boards/components/board_spec.js3
-rw-r--r--spec/javascripts/boards/issue_card_spec.js13
-rw-r--r--spec/javascripts/boards/issue_spec.js5
-rw-r--r--spec/javascripts/boards/list_spec.js25
-rw-r--r--spec/javascripts/boards/mock_data.js79
-rw-r--r--spec/javascripts/boards/modal_store_spec.js5
-rw-r--r--spec/javascripts/boards/utils/query_data_spec.js27
-rw-r--r--spec/javascripts/bootstrap_jquery_spec.js1
-rw-r--r--spec/javascripts/ci_variable_list/ajax_variable_list_spec.js213
-rw-r--r--spec/javascripts/ci_variable_list/ci_variable_list_spec.js216
-rw-r--r--spec/javascripts/ci_variable_list/native_form_variable_list_spec.js31
-rw-r--r--spec/javascripts/clusters/clusters_bundle_spec.js39
-rw-r--r--spec/javascripts/clusters/clusters_index_spec.js58
-rw-r--r--spec/javascripts/clusters/components/application_row_spec.js2
-rw-r--r--spec/javascripts/clusters/components/applications_spec.js76
-rw-r--r--spec/javascripts/clusters/services/mock_data.js7
-rw-r--r--spec/javascripts/clusters/stores/clusters_store_spec.js9
-rw-r--r--spec/javascripts/collapsed_sidebar_todo_spec.js139
-rw-r--r--spec/javascripts/commit/commit_pipeline_status_component_spec.js104
-rw-r--r--spec/javascripts/commit/pipelines/pipelines_spec.js6
-rw-r--r--spec/javascripts/commit_merge_requests_spec.js60
-rw-r--r--spec/javascripts/commits_spec.js56
-rw-r--r--spec/javascripts/create_item_dropdown_spec.js184
-rw-r--r--spec/javascripts/cycle_analytics/banner_spec.js5
-rw-r--r--spec/javascripts/cycle_analytics/total_time_component_spec.js8
-rw-r--r--spec/javascripts/datetime_utility_spec.js64
-rw-r--r--spec/javascripts/deploy_keys/components/app_spec.js1
-rw-r--r--spec/javascripts/deploy_keys/components/key_spec.js20
-rw-r--r--spec/javascripts/droplab/constants_spec.js16
-rw-r--r--spec/javascripts/droplab/drop_down_spec.js113
-rw-r--r--spec/javascripts/environments/emtpy_state_spec.js2
-rw-r--r--spec/javascripts/environments/environment_item_spec.js4
-rw-r--r--spec/javascripts/environments/environment_table_spec.js2
-rw-r--r--spec/javascripts/environments/environments_app_spec.js18
-rw-r--r--spec/javascripts/environments/folder/environments_folder_view_spec.js5
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_helper_spec.js232
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_options_spec.js30
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_spec.js143
-rw-r--r--spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js9
-rw-r--r--spec/javascripts/filtered_search/dropdown_user_spec.js38
-rw-r--r--spec/javascripts/filtered_search/dropdown_utils_spec.js98
-rw-r--r--spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js25
-rw-r--r--spec/javascripts/filtered_search/filtered_search_manager_spec.js78
-rw-r--r--spec/javascripts/filtered_search/filtered_search_token_keys_spec.js40
-rw-r--r--spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js26
-rw-r--r--spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js38
-rw-r--r--spec/javascripts/fixtures/clusters.rb15
-rw-r--r--spec/javascripts/fixtures/create_item_dropdown.html.haml13
-rw-r--r--spec/javascripts/fixtures/environments.rb30
-rw-r--r--spec/javascripts/fixtures/gl_dropdown.html.haml3
-rw-r--r--spec/javascripts/fixtures/groups.rb29
-rw-r--r--spec/javascripts/fixtures/jobs.rb2
-rw-r--r--spec/javascripts/fixtures/merge_requests.rb42
-rw-r--r--spec/javascripts/fixtures/one_white_pixel.pngbin0 -> 68 bytes
-rw-r--r--spec/javascripts/fixtures/pipeline_schedules.rb43
-rw-r--r--spec/javascripts/fixtures/pipelines.html.haml4
-rw-r--r--spec/javascripts/fixtures/projects.json2
-rw-r--r--spec/javascripts/fixtures/projects.rb49
-rw-r--r--spec/javascripts/fixtures/search.rb18
-rw-r--r--spec/javascripts/flash_spec.js12
-rw-r--r--spec/javascripts/fly_out_nav_spec.js32
-rw-r--r--spec/javascripts/gfm_auto_complete_spec.js24
-rw-r--r--spec/javascripts/gl_dropdown_spec.js30
-rw-r--r--spec/javascripts/gl_field_errors_spec.js1
-rw-r--r--spec/javascripts/gl_form_spec.js21
-rw-r--r--spec/javascripts/gpg_badges_spec.js50
-rw-r--r--spec/javascripts/graphs/stat_graph_contributors_graph_spec.js2
-rw-r--r--spec/javascripts/graphs/stat_graph_contributors_spec.js4
-rw-r--r--spec/javascripts/graphs/stat_graph_contributors_util_spec.js2
-rw-r--r--spec/javascripts/groups/components/app_spec.js94
-rw-r--r--spec/javascripts/groups/components/group_item_spec.js3
-rw-r--r--spec/javascripts/groups/components/groups_spec.js3
-rw-r--r--spec/javascripts/groups/components/item_actions_spec.js49
-rw-r--r--spec/javascripts/groups/components/item_caret_spec.js12
-rw-r--r--spec/javascripts/groups/components/item_stats_spec.js58
-rw-r--r--spec/javascripts/groups/components/item_stats_value_spec.js81
-rw-r--r--spec/javascripts/groups/components/item_type_icon_spec.js11
-rw-r--r--spec/javascripts/groups/mock_data.js22
-rw-r--r--spec/javascripts/header_spec.js1
-rw-r--r--spec/javascripts/helpers/class_spec_helper_spec.js20
-rw-r--r--spec/javascripts/helpers/user_mock_data_helper.js2
-rw-r--r--spec/javascripts/helpers/vue_component_helper.js3
-rw-r--r--spec/javascripts/helpers/vue_mount_component_helper.js6
-rw-r--r--spec/javascripts/helpers/vuex_action_helper.js72
-rw-r--r--spec/javascripts/ide/components/changed_file_icon_spec.js46
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/actions_spec.js35
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js28
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/list_item_spec.js95
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/list_spec.js53
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js130
-rw-r--r--spec/javascripts/ide/components/ide_context_bar_spec.js37
-rw-r--r--spec/javascripts/ide/components/ide_external_links_spec.js43
-rw-r--r--spec/javascripts/ide/components/ide_file_buttons_spec.js61
-rw-r--r--spec/javascripts/ide/components/ide_project_tree_spec.js39
-rw-r--r--spec/javascripts/ide/components/ide_repo_tree_spec.js43
-rw-r--r--spec/javascripts/ide/components/ide_side_bar_spec.js42
-rw-r--r--spec/javascripts/ide/components/ide_spec.js41
-rw-r--r--spec/javascripts/ide/components/new_dropdown/index_spec.js84
-rw-r--r--spec/javascripts/ide/components/new_dropdown/modal_spec.js82
-rw-r--r--spec/javascripts/ide/components/new_dropdown/upload_spec.js87
-rw-r--r--spec/javascripts/ide/components/repo_commit_section_spec.js173
-rw-r--r--spec/javascripts/ide/components/repo_editor_spec.js247
-rw-r--r--spec/javascripts/ide/components/repo_file_spec.js80
-rw-r--r--spec/javascripts/ide/components/repo_loading_file_spec.js63
-rw-r--r--spec/javascripts/ide/components/repo_tab_spec.js165
-rw-r--r--spec/javascripts/ide/components/repo_tabs_spec.js85
-rw-r--r--spec/javascripts/ide/helpers.js22
-rw-r--r--spec/javascripts/ide/lib/common/disposable_spec.js (renamed from spec/javascripts/repo/lib/common/disposable_spec.js)0
-rw-r--r--spec/javascripts/ide/lib/common/model_manager_spec.js132
-rw-r--r--spec/javascripts/ide/lib/common/model_spec.js123
-rw-r--r--spec/javascripts/ide/lib/decorations/controller_spec.js120
-rw-r--r--spec/javascripts/ide/lib/diff/controller_spec.js196
-rw-r--r--spec/javascripts/ide/lib/diff/diff_spec.js (renamed from spec/javascripts/repo/lib/diff/diff_spec.js)0
-rw-r--r--spec/javascripts/ide/lib/editor_options_spec.js11
-rw-r--r--spec/javascripts/ide/lib/editor_spec.js271
-rw-r--r--spec/javascripts/ide/monaco_loader_spec.js15
-rw-r--r--spec/javascripts/ide/stores/actions/file_spec.js573
-rw-r--r--spec/javascripts/ide/stores/actions/merge_request_spec.js110
-rw-r--r--spec/javascripts/ide/stores/actions/tree_spec.js166
-rw-r--r--spec/javascripts/ide/stores/actions_spec.js306
-rw-r--r--spec/javascripts/ide/stores/getters_spec.js75
-rw-r--r--spec/javascripts/ide/stores/modules/commit/actions_spec.js505
-rw-r--r--spec/javascripts/ide/stores/modules/commit/getters_spec.js128
-rw-r--r--spec/javascripts/ide/stores/modules/commit/mutations_spec.js42
-rw-r--r--spec/javascripts/ide/stores/mutations/branch_spec.js (renamed from spec/javascripts/repo/stores/mutations/branch_spec.js)0
-rw-r--r--spec/javascripts/ide/stores/mutations/file_spec.js272
-rw-r--r--spec/javascripts/ide/stores/mutations/merge_request_spec.js65
-rw-r--r--spec/javascripts/ide/stores/mutations/tree_spec.js69
-rw-r--r--spec/javascripts/ide/stores/mutations_spec.js79
-rw-r--r--spec/javascripts/ide/stores/utils_spec.js66
-rw-r--r--spec/javascripts/importer_status_spec.js112
-rw-r--r--spec/javascripts/integrations/integration_settings_form_spec.js129
-rw-r--r--spec/javascripts/issuable_spec.js27
-rw-r--r--spec/javascripts/issuable_time_tracker_spec.js3
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js161
-rw-r--r--spec/javascripts/issue_show/components/description_spec.js3
-rw-r--r--spec/javascripts/issue_show/components/fields/description_template_spec.js2
-rw-r--r--spec/javascripts/issue_show/components/form_spec.js2
-rw-r--r--spec/javascripts/issue_show/mock_data.js10
-rw-r--r--spec/javascripts/issue_spec.js146
-rw-r--r--spec/javascripts/job_spec.js324
-rw-r--r--spec/javascripts/jobs/header_spec.js37
-rw-r--r--spec/javascripts/jobs/job_details_mediator_spec.js8
-rw-r--r--spec/javascripts/jobs/mock_data.js12
-rw-r--r--spec/javascripts/jobs/sidebar_detail_row_spec.js21
-rw-r--r--spec/javascripts/jobs/sidebar_details_block_spec.js6
-rw-r--r--spec/javascripts/labels_issue_sidebar_spec.js45
-rw-r--r--spec/javascripts/labels_select_spec.js44
-rw-r--r--spec/javascripts/lib/utils/ajax_cache_spec.js68
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js95
-rw-r--r--spec/javascripts/lib/utils/text_markdown_spec.js10
-rw-r--r--spec/javascripts/lib/utils/text_utility_spec.js26
-rw-r--r--spec/javascripts/lib/utils/users_cache_spec.js4
-rw-r--r--spec/javascripts/line_highlighter_spec.js1
-rw-r--r--spec/javascripts/matchers.js35
-rw-r--r--spec/javascripts/merge_request_notes_spec.js9
-rw-r--r--spec/javascripts/merge_request_spec.js44
-rw-r--r--spec/javascripts/merge_request_tabs_spec.js96
-rw-r--r--spec/javascripts/mini_pipeline_graph_dropdown_spec.js74
-rw-r--r--spec/javascripts/monitoring/dashboard_spec.js70
-rw-r--r--spec/javascripts/monitoring/dashboard_state_spec.js73
-rw-r--r--spec/javascripts/monitoring/graph/axis_spec.js65
-rw-r--r--spec/javascripts/monitoring/graph/deployment_spec.js144
-rw-r--r--spec/javascripts/monitoring/graph/flag_spec.js114
-rw-r--r--spec/javascripts/monitoring/graph/legend_spec.js108
-rw-r--r--spec/javascripts/monitoring/graph/track_info_spec.js44
-rw-r--r--spec/javascripts/monitoring/graph/track_line_spec.js52
-rw-r--r--spec/javascripts/monitoring/graph_spec.js25
-rw-r--r--spec/javascripts/monitoring/mock_data.js14737
-rw-r--r--spec/javascripts/namespace_select_spec.js1
-rw-r--r--spec/javascripts/new_branch_spec.js1
-rw-r--r--spec/javascripts/notebook/cells/markdown_spec.js14
-rw-r--r--spec/javascripts/notebook/cells/output/html_sanitize_tests.js66
-rw-r--r--spec/javascripts/notebook/cells/output/html_spec.js29
-rw-r--r--spec/javascripts/notes/components/comment_form_spec.js48
-rw-r--r--spec/javascripts/notes/components/diff_file_header_spec.js93
-rw-r--r--spec/javascripts/notes/components/diff_with_note_spec.js64
-rw-r--r--spec/javascripts/notes/components/note_app_spec.js97
-rw-r--r--spec/javascripts/notes/components/note_body_spec.js27
-rw-r--r--spec/javascripts/notes/components/note_form_spec.js9
-rw-r--r--spec/javascripts/notes/components/note_header_spec.js32
-rw-r--r--spec/javascripts/notes/components/noteable_discussion_spec.js18
-rw-r--r--spec/javascripts/notes/components/noteable_note_spec.js24
-rw-r--r--spec/javascripts/notes/helpers.js12
-rw-r--r--spec/javascripts/notes/mock_data.js871
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js271
-rw-r--r--spec/javascripts/notes/stores/getters_spec.js6
-rw-r--r--spec/javascripts/notes/stores/mutation_spec.js84
-rw-r--r--spec/javascripts/notes_spec.js494
-rw-r--r--spec/javascripts/oauth_remember_me_spec.js3
-rw-r--r--spec/javascripts/pager_spec.js81
-rw-r--r--spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js42
-rw-r--r--spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js63
-rw-r--r--spec/javascripts/pages/labels/components/promote_label_modal_spec.js89
-rw-r--r--spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js95
-rw-r--r--spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js84
-rw-r--r--spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js178
-rw-r--r--spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js106
-rw-r--r--spec/javascripts/performance_bar/components/detailed_metric_spec.js80
-rw-r--r--spec/javascripts/performance_bar/components/performance_bar_app_spec.js88
-rw-r--r--spec/javascripts/performance_bar/components/request_selector_spec.js47
-rw-r--r--spec/javascripts/performance_bar/components/simple_metric_spec.js47
-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.js17
-rw-r--r--spec/javascripts/pipelines/blank_state_spec.js29
-rw-r--r--spec/javascripts/pipelines/empty_state_spec.js28
-rw-r--r--spec/javascripts/pipelines/error_state_spec.js27
-rw-r--r--spec/javascripts/pipelines/graph/action_component_spec.js25
-rw-r--r--spec/javascripts/pipelines/graph/job_component_spec.js12
-rw-r--r--spec/javascripts/pipelines/graph/mock_data.js467
-rw-r--r--spec/javascripts/pipelines/graph/stage_column_component_spec.js15
-rw-r--r--spec/javascripts/pipelines/nav_controls_spec.js84
-rw-r--r--spec/javascripts/pipelines/pipeline_details_mediator_spec.js3
-rw-r--r--spec/javascripts/pipelines/pipeline_store_spec.js1
-rw-r--r--spec/javascripts/pipelines/pipelines_spec.js680
-rw-r--r--spec/javascripts/pipelines/pipelines_table_row_spec.js7
-rw-r--r--spec/javascripts/pipelines/pipelines_table_spec.js5
-rw-r--r--spec/javascripts/pipelines/stage_spec.js1
-rw-r--r--spec/javascripts/profile/account/components/delete_account_modal_spec.js10
-rw-r--r--spec/javascripts/profile/account/components/update_username_spec.js172
-rw-r--r--spec/javascripts/project_select_combo_button_spec.js1
-rw-r--r--spec/javascripts/projects/project_import_gitlab_project_spec.js2
-rw-r--r--spec/javascripts/projects/project_new_spec.js37
-rw-r--r--spec/javascripts/projects_dropdown/components/app_spec.js2
-rw-r--r--spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js2
-rw-r--r--spec/javascripts/projects_dropdown/components/projects_list_item_spec.js2
-rw-r--r--spec/javascripts/projects_dropdown/components/projects_list_search_spec.js2
-rw-r--r--spec/javascripts/projects_dropdown/components/search_spec.js2
-rw-r--r--spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js46
-rw-r--r--spec/javascripts/registry/components/app_spec.js5
-rw-r--r--spec/javascripts/registry/stores/actions_spec.js87
-rw-r--r--spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js33
-rw-r--r--spec/javascripts/repo/components/commit_sidebar/list_item_spec.js53
-rw-r--r--spec/javascripts/repo/components/commit_sidebar/list_spec.js59
-rw-r--r--spec/javascripts/repo/components/ide_context_bar_spec.js49
-rw-r--r--spec/javascripts/repo/components/ide_repo_tree_spec.js63
-rw-r--r--spec/javascripts/repo/components/ide_side_bar_spec.js43
-rw-r--r--spec/javascripts/repo/components/ide_spec.js37
-rw-r--r--spec/javascripts/repo/components/new_branch_form_spec.js114
-rw-r--r--spec/javascripts/repo/components/new_dropdown/index_spec.js76
-rw-r--r--spec/javascripts/repo/components/new_dropdown/modal_spec.js235
-rw-r--r--spec/javascripts/repo/components/new_dropdown/upload_spec.js156
-rw-r--r--spec/javascripts/repo/components/repo_commit_section_spec.js138
-rw-r--r--spec/javascripts/repo/components/repo_edit_button_spec.js83
-rw-r--r--spec/javascripts/repo/components/repo_editor_spec.js60
-rw-r--r--spec/javascripts/repo/components/repo_file_buttons_spec.js49
-rw-r--r--spec/javascripts/repo/components/repo_file_spec.js113
-rw-r--r--spec/javascripts/repo/components/repo_loading_file_spec.js63
-rw-r--r--spec/javascripts/repo/components/repo_prev_directory_spec.js45
-rw-r--r--spec/javascripts/repo/components/repo_preview_spec.js37
-rw-r--r--spec/javascripts/repo/components/repo_tab_spec.js108
-rw-r--r--spec/javascripts/repo/components/repo_tabs_spec.js37
-rw-r--r--spec/javascripts/repo/helpers.js16
-rw-r--r--spec/javascripts/repo/lib/common/model_manager_spec.js81
-rw-r--r--spec/javascripts/repo/lib/common/model_spec.js84
-rw-r--r--spec/javascripts/repo/lib/decorations/controller_spec.js120
-rw-r--r--spec/javascripts/repo/lib/diff/controller_spec.js176
-rw-r--r--spec/javascripts/repo/lib/editor_options_spec.js7
-rw-r--r--spec/javascripts/repo/lib/editor_spec.js128
-rw-r--r--spec/javascripts/repo/monaco_loader_spec.js13
-rw-r--r--spec/javascripts/repo/stores/actions/branch_spec.js44
-rw-r--r--spec/javascripts/repo/stores/actions/file_spec.js424
-rw-r--r--spec/javascripts/repo/stores/actions/tree_spec.js350
-rw-r--r--spec/javascripts/repo/stores/actions_spec.js435
-rw-r--r--spec/javascripts/repo/stores/getters_spec.js114
-rw-r--r--spec/javascripts/repo/stores/mutations/file_spec.js131
-rw-r--r--spec/javascripts/repo/stores/mutations/tree_spec.js71
-rw-r--r--spec/javascripts/repo/stores/mutations_spec.js125
-rw-r--r--spec/javascripts/repo/stores/utils_spec.js119
-rw-r--r--spec/javascripts/right_sidebar_spec.js27
-rw-r--r--spec/javascripts/search_autocomplete_spec.js215
-rw-r--r--spec/javascripts/search_spec.js41
-rw-r--r--spec/javascripts/settings_panels_spec.js29
-rw-r--r--spec/javascripts/shortcuts_issuable_spec.js3
-rw-r--r--spec/javascripts/shortcuts_spec.js1
-rw-r--r--spec/javascripts/sidebar/assignee_title_spec.js2
-rw-r--r--spec/javascripts/sidebar/assignees_spec.js2
-rw-r--r--spec/javascripts/sidebar/confidential_issue_sidebar_spec.js18
-rw-r--r--spec/javascripts/sidebar/lock/edit_form_buttons_spec.js2
-rw-r--r--spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js18
-rw-r--r--spec/javascripts/sidebar/mock_data.js75
-rw-r--r--spec/javascripts/sidebar/participants_spec.js2
-rw-r--r--spec/javascripts/sidebar/sidebar_assignees_spec.js5
-rw-r--r--spec/javascripts/sidebar/sidebar_mediator_spec.js1
-rw-r--r--spec/javascripts/sidebar/sidebar_move_issue_spec.js8
-rw-r--r--spec/javascripts/sidebar/sidebar_store_spec.js6
-rw-r--r--spec/javascripts/sidebar/sidebar_subscriptions_spec.js2
-rw-r--r--spec/javascripts/sidebar/subscriptions_spec.js14
-rw-r--r--spec/javascripts/signin_tabs_memoizer_spec.js9
-rw-r--r--spec/javascripts/smart_interval_spec.js2
-rw-r--r--spec/javascripts/syntax_highlight_spec.js1
-rw-r--r--spec/javascripts/test_bundle.js35
-rw-r--r--spec/javascripts/test_constants.js4
-rw-r--r--spec/javascripts/todos_spec.js3
-rw-r--r--spec/javascripts/toggle_buttons_spec.js121
-rw-r--r--spec/javascripts/u2f/authenticate_spec.js7
-rw-r--r--spec/javascripts/u2f/register_spec.js5
-rw-r--r--spec/javascripts/u2f/util_spec.js45
-rw-r--r--spec/javascripts/version_check_image_spec.js1
-rw-r--r--spec/javascripts/vue_mr_widget/components/deployment_spec.js172
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js58
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js79
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js181
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js301
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js40
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js109
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js79
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js42
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js115
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_related_links_spec.js131
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_status_icon_spec.js44
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_archived_spec.js37
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js57
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_checking_spec.js36
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_closed_spec.js103
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js142
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js123
-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.js145
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js205
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js34
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js34
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js29
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js6
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js30
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js78
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js33
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js8
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js4
-rw-r--r--spec/javascripts/vue_mr_widget/mock_data.js387
-rw-r--r--spec/javascripts/vue_mr_widget/mr_widget_options_spec.js155
-rw-r--r--spec/javascripts/vue_shared/components/ci_badge_link_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/clipboard_button_spec.js36
-rw-r--r--spec/javascripts/vue_shared/components/commit_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js70
-rw-r--r--spec/javascripts/vue_shared/components/deprecated_modal_spec.js67
-rw-r--r--spec/javascripts/vue_shared/components/expand_button_spec.js32
-rw-r--r--spec/javascripts/vue_shared/components/file_icon_spec.js83
-rw-r--r--spec/javascripts/vue_shared/components/gl_modal_spec.js193
-rw-r--r--spec/javascripts/vue_shared/components/header_ci_component_spec.js81
-rw-r--r--spec/javascripts/vue_shared/components/icon_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/issue/issue_warning_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/loading_button_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/loading_icon_spec.js3
-rw-r--r--spec/javascripts/vue_shared/components/markdown/field_spec.js7
-rw-r--r--spec/javascripts/vue_shared/components/markdown/toolbar_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/memory_graph_spec.js26
-rw-r--r--spec/javascripts/vue_shared/components/mock_data.js2
-rw-r--r--spec/javascripts/vue_shared/components/modal_spec.js12
-rw-r--r--spec/javascripts/vue_shared/components/navigation_tabs_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/panel_resizer_spec.js59
-rw-r--r--spec/javascripts/vue_shared/components/pikaday_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js11
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js107
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js82
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js94
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js68
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js36
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js37
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js39
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js42
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js74
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js94
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js50
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js77
-rw-r--r--spec/javascripts/vue_shared/components/table_pagination_spec.js3
-rw-r--r--spec/javascripts/vue_shared/components/toggle_button_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js1
-rw-r--r--spec/javascripts/vue_shared/directives/tooltip_spec.js1
-rw-r--r--spec/javascripts/zen_mode_spec.js3
-rw-r--r--spec/lib/api/helpers/related_resources_helpers_spec.rb41
-rw-r--r--spec/lib/backup/files_spec.rb66
-rw-r--r--spec/lib/backup/manager_spec.rb10
-rw-r--r--spec/lib/backup/repository_spec.rb48
-rw-r--r--spec/lib/banzai/color_parser_spec.rb90
-rw-r--r--spec/lib/banzai/commit_renderer_spec.rb2
-rw-r--r--spec/lib/banzai/cross_project_reference_spec.rb10
-rw-r--r--spec/lib/banzai/filter/autolink_filter_spec.rb143
-rw-r--r--spec/lib/banzai/filter/color_filter_spec.rb61
-rw-r--r--spec/lib/banzai/filter/commit_range_reference_filter_spec.rb4
-rw-r--r--spec/lib/banzai/filter/commit_reference_filter_spec.rb37
-rw-r--r--spec/lib/banzai/filter/commit_trailers_filter_spec.rb171
-rw-r--r--spec/lib/banzai/filter/external_issue_reference_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/html_entity_filter_spec.rb9
-rw-r--r--spec/lib/banzai/filter/image_link_filter_spec.rb4
-rw-r--r--spec/lib/banzai/filter/issuable_state_filter_spec.rb16
-rw-r--r--spec/lib/banzai/filter/issue_reference_filter_spec.rb6
-rw-r--r--spec/lib/banzai/filter/label_reference_filter_spec.rb30
-rw-r--r--spec/lib/banzai/filter/merge_request_reference_filter_spec.rb39
-rw-r--r--spec/lib/banzai/filter/mermaid_filter_spec.rb4
-rw-r--r--spec/lib/banzai/filter/redactor_filter_spec.rb8
-rw-r--r--spec/lib/banzai/filter/relative_link_filter_spec.rb128
-rw-r--r--spec/lib/banzai/filter/snippet_reference_filter_spec.rb6
-rw-r--r--spec/lib/banzai/filter/syntax_highlight_filter_spec.rb57
-rw-r--r--spec/lib/banzai/filter/upload_link_filter_spec.rb133
-rw-r--r--spec/lib/banzai/filter/user_reference_filter_spec.rb10
-rw-r--r--spec/lib/banzai/filter/wiki_link_filter_spec.rb8
-rw-r--r--spec/lib/banzai/redactor_spec.rb61
-rw-r--r--spec/lib/banzai/reference_parser/issue_parser_spec.rb70
-rw-r--r--spec/lib/banzai/reference_parser/merge_request_parser_spec.rb30
-rw-r--r--spec/lib/banzai/reference_parser/user_parser_spec.rb6
-rw-r--r--spec/lib/constraints/group_url_constrainer_spec.rb2
-rw-r--r--spec/lib/constraints/project_url_constrainer_spec.rb2
-rw-r--r--spec/lib/constraints/user_url_constrainer_spec.rb2
-rw-r--r--spec/lib/file_size_validator_spec.rb2
-rw-r--r--spec/lib/forever_spec.rb21
-rw-r--r--spec/lib/gitaly/server_spec.rb30
-rw-r--r--spec/lib/gitlab/asciidoc_spec.rb10
-rw-r--r--spec/lib/gitlab/auth/blocked_user_tracker_spec.rb53
-rw-r--r--spec/lib/gitlab/auth/ldap/access_spec.rb186
-rw-r--r--spec/lib/gitlab/auth/ldap/adapter_spec.rb164
-rw-r--r--spec/lib/gitlab/auth/ldap/auth_hash_spec.rb110
-rw-r--r--spec/lib/gitlab/auth/ldap/authentication_spec.rb58
-rw-r--r--spec/lib/gitlab/auth/ldap/config_spec.rb373
-rw-r--r--spec/lib/gitlab/auth/ldap/dn_spec.rb224
-rw-r--r--spec/lib/gitlab/auth/ldap/person_spec.rb160
-rw-r--r--spec/lib/gitlab/auth/ldap/user_spec.rb241
-rw-r--r--spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb111
-rw-r--r--spec/lib/gitlab/auth/o_auth/provider_spec.rb42
-rw-r--r--spec/lib/gitlab/auth/o_auth/user_spec.rb782
-rw-r--r--spec/lib/gitlab/auth/saml/auth_hash_spec.rb40
-rw-r--r--spec/lib/gitlab/auth/saml/user_spec.rb403
-rw-r--r--spec/lib/gitlab/auth/user_auth_finders_spec.rb10
-rw-r--r--spec/lib/gitlab/auth_spec.rb148
-rw-r--r--spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb62
-rw-r--r--spec/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range_spec.rb13
-rw-r--r--spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb7
-rw-r--r--spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb70
-rw-r--r--spec/lib/gitlab/background_migration/migrate_events_to_push_event_payloads_spec.rb14
-rw-r--r--spec/lib/gitlab/background_migration/migrate_system_uploads_to_new_folder_spec.rb4
-rw-r--r--spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb130
-rw-r--r--spec/lib/gitlab/background_migration/populate_untracked_uploads_dependencies/untracked_file_spec.rb262
-rw-r--r--spec/lib/gitlab/background_migration/populate_untracked_uploads_spec.rb418
-rw-r--r--spec/lib/gitlab/background_migration/prepare_untracked_uploads_spec.rb211
-rw-r--r--spec/lib/gitlab/background_migration/set_confidential_note_events_on_services_spec.rb31
-rw-r--r--spec/lib/gitlab/background_migration/set_confidential_note_events_on_webhooks_spec.rb31
-rw-r--r--spec/lib/gitlab/badge/coverage/template_spec.rb18
-rw-r--r--spec/lib/gitlab/bare_repository_import/importer_spec.rb37
-rw-r--r--spec/lib/gitlab/bare_repository_import/repository_spec.rb130
-rw-r--r--spec/lib/gitlab/bitbucket_import/importer_spec.rb2
-rw-r--r--spec/lib/gitlab/checks/change_access_spec.rb42
-rw-r--r--spec/lib/gitlab/checks/force_push_spec.rb10
-rw-r--r--spec/lib/gitlab/checks/lfs_integrity_spec.rb22
-rw-r--r--spec/lib/gitlab/checks/project_created_spec.rb46
-rw-r--r--spec/lib/gitlab/checks/project_moved_spec.rb77
-rw-r--r--spec/lib/gitlab/ci/ansi2html_spec.rb59
-rw-r--r--spec/lib/gitlab/ci/build/policy/variables_spec.rb72
-rw-r--r--spec/lib/gitlab/ci/build/step_spec.rb12
-rw-r--r--spec/lib/gitlab/ci/charts_spec.rb45
-rw-r--r--spec/lib/gitlab/ci/config/entry/key_spec.rb62
-rw-r--r--spec/lib/gitlab/ci/config/entry/policy_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/config/loader_spec.rb10
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/create_spec.rb35
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb158
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb22
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexeme/equals_spec.rb39
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexeme/null_spec.rb22
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb108
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexeme/variable_spec.rb44
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb70
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb26
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb130
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/token_spec.rb45
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build_spec.rb232
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb123
-rw-r--r--spec/lib/gitlab/ci/stage/seed_spec.rb83
-rw-r--r--spec/lib/gitlab/ci/status/build/action_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/status/build/cancelable_spec.rb22
-rw-r--r--spec/lib/gitlab/ci/status/build/canceled_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/status/build/common_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/status/build/created_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/status/build/erased_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/status/build/factory_spec.rb59
-rw-r--r--spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb23
-rw-r--r--spec/lib/gitlab/ci/status/build/failed_spec.rb83
-rw-r--r--spec/lib/gitlab/ci/status/build/manual_spec.rb34
-rw-r--r--spec/lib/gitlab/ci/status/build/pending_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/status/build/play_spec.rb20
-rw-r--r--spec/lib/gitlab/ci/status/build/retried_spec.rb96
-rw-r--r--spec/lib/gitlab/ci/status/build/retryable_spec.rb22
-rw-r--r--spec/lib/gitlab/ci/status/build/skipped_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/status/build/stop_spec.rb24
-rw-r--r--spec/lib/gitlab/ci/status/external/common_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/status/external/factory_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/status/pipeline/common_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/status/pipeline/factory_spec.rb2
-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/status/success_warning_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/trace/http_io_spec.rb315
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb245
-rw-r--r--spec/lib/gitlab/ci/variables/collection/item_spec.rb58
-rw-r--r--spec/lib/gitlab/ci/variables/collection_spec.rb114
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb737
-rw-r--r--spec/lib/gitlab/closing_issue_extractor_spec.rb23
-rw-r--r--spec/lib/gitlab/conflict/file_collection_spec.rb32
-rw-r--r--spec/lib/gitlab/contributions_calendar_spec.rb34
-rw-r--r--spec/lib/gitlab/cross_project_access/check_collection_spec.rb55
-rw-r--r--spec/lib/gitlab/cross_project_access/check_info_spec.rb111
-rw-r--r--spec/lib/gitlab/cross_project_access/class_methods_spec.rb46
-rw-r--r--spec/lib/gitlab/cross_project_access_spec.rb84
-rw-r--r--spec/lib/gitlab/current_settings_spec.rb40
-rw-r--r--spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb2
-rw-r--r--spec/lib/gitlab/cycle_analytics/events_spec.rb24
-rw-r--r--spec/lib/gitlab/cycle_analytics/permissions_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/usage_data_spec.rb140
-rw-r--r--spec/lib/gitlab/data_builder/build_spec.rb2
-rw-r--r--spec/lib/gitlab/data_builder/note_spec.rb8
-rw-r--r--spec/lib/gitlab/data_builder/pipeline_spec.rb1
-rw-r--r--spec/lib/gitlab/database/median_spec.rb17
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb238
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb2
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb16
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb13
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb2
-rw-r--r--spec/lib/gitlab/database/sha_attribute_spec.rb8
-rw-r--r--spec/lib/gitlab/database_spec.rb23
-rw-r--r--spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb16
-rw-r--r--spec/lib/gitlab/diff/file_spec.rb37
-rw-r--r--spec/lib/gitlab/diff/highlight_spec.rb22
-rw-r--r--spec/lib/gitlab/email/attachment_uploader_spec.rb2
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb31
-rw-r--r--spec/lib/gitlab/email/handler_spec.rb24
-rw-r--r--spec/lib/gitlab/encoding_helper_spec.rb42
-rw-r--r--spec/lib/gitlab/exclusive_lease_spec.rb27
-rw-r--r--spec/lib/gitlab/gfm/reference_rewriter_spec.rb2
-rw-r--r--spec/lib/gitlab/gfm/uploads_rewriter_spec.rb6
-rw-r--r--spec/lib/gitlab/git/attributes_at_ref_parser_spec.rb28
-rw-r--r--spec/lib/gitlab/git/attributes_parser_spec.rb153
-rw-r--r--spec/lib/gitlab/git/attributes_spec.rb150
-rw-r--r--spec/lib/gitlab/git/blob_spec.rb205
-rw-r--r--spec/lib/gitlab/git/branch_spec.rb64
-rw-r--r--spec/lib/gitlab/git/commit_spec.rb118
-rw-r--r--spec/lib/gitlab/git/conflict/file_spec.rb50
-rw-r--r--spec/lib/gitlab/git/conflict/parser_spec.rb7
-rw-r--r--spec/lib/gitlab/git/env_spec.rb134
-rw-r--r--spec/lib/gitlab/git/gitlab_projects_spec.rb212
-rw-r--r--spec/lib/gitlab/git/gitmodules_parser_spec.rb3
-rw-r--r--spec/lib/gitlab/git/hook_env_spec.rb119
-rw-r--r--spec/lib/gitlab/git/info_attributes_spec.rb43
-rw-r--r--spec/lib/gitlab/git/lfs_changes_spec.rb38
-rw-r--r--spec/lib/gitlab/git/lfs_pointer_file_spec.rb37
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb1343
-rw-r--r--spec/lib/gitlab/git/rev_list_spec.rb71
-rw-r--r--spec/lib/gitlab/git/tree_spec.rb20
-rw-r--r--spec/lib/gitlab/git/wiki_spec.rb40
-rw-r--r--spec/lib/gitlab/git_access_spec.rb417
-rw-r--r--spec/lib/gitlab/git_access_wiki_spec.rb18
-rw-r--r--spec/lib/gitlab/gitaly_client/blob_service_spec.rb60
-rw-r--r--spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb36
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb53
-rw-r--r--spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb54
-rw-r--r--spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb61
-rw-r--r--spec/lib/gitlab/gitaly_client/health_check_service_spec.rb41
-rw-r--r--spec/lib/gitlab/gitaly_client/operation_service_spec.rb49
-rw-r--r--spec/lib/gitlab/gitaly_client/ref_service_spec.rb2
-rw-r--r--spec/lib/gitlab/gitaly_client/remote_service_spec.rb71
-rw-r--r--spec/lib/gitlab/gitaly_client/repository_service_spec.rb61
-rw-r--r--spec/lib/gitlab/gitaly_client/util_spec.rb11
-rw-r--r--spec/lib/gitlab/gitaly_client_spec.rb39
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/importer/repository_importer_spec.rb16
-rw-r--r--spec/lib/gitlab/google_code_import/importer_spec.rb2
-rw-r--r--spec/lib/gitlab/gpg/commit_spec.rb50
-rw-r--r--spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb4
-rw-r--r--spec/lib/gitlab/health_checks/fs_shards_check_spec.rb6
-rw-r--r--spec/lib/gitlab/health_checks/gitaly_check_spec.rb57
-rw-r--r--spec/lib/gitlab/hook_data/issue_builder_spec.rb1
-rw-r--r--spec/lib/gitlab/hook_data/merge_request_builder_spec.rb1
-rw-r--r--spec/lib/gitlab/http_spec.rb49
-rw-r--r--spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb104
-rw-r--r--spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb36
-rw-r--r--spec/lib/gitlab/import_export/after_export_strategy_builder_spec.rb29
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml14
-rw-r--r--spec/lib/gitlab/import_export/avatar_restorer_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/avatar_saver_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/file_importer_spec.rb60
-rw-r--r--spec/lib/gitlab/import_export/fork_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/importer_spec.rb104
-rw-r--r--spec/lib/gitlab/import_export/lfs_restorer_spec.rb75
-rw-r--r--spec/lib/gitlab/import_export/lfs_saver_spec.rb62
-rw-r--r--spec/lib/gitlab/import_export/project.group.json2
-rw-r--r--spec/lib/gitlab/import_export/project.json1531
-rw-r--r--spec/lib/gitlab/import_export/project.light.json1
-rw-r--r--spec/lib/gitlab/import_export/project_tree_restorer_spec.rb72
-rw-r--r--spec/lib/gitlab/import_export/project_tree_saver_spec.rb46
-rw-r--r--spec/lib/gitlab/import_export/reader_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/relation_factory_spec.rb1
-rw-r--r--spec/lib/gitlab/import_export/repo_restorer_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/repo_saver_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml28
-rw-r--r--spec/lib/gitlab/import_export/uploads_restorer_spec.rb15
-rw-r--r--spec/lib/gitlab/import_export/uploads_saver_spec.rb10
-rw-r--r--spec/lib/gitlab/import_export/version_checker_spec.rb3
-rw-r--r--spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/wiki_restorer_spec.rb45
-rw-r--r--spec/lib/gitlab/insecure_key_fingerprint_spec.rb18
-rw-r--r--spec/lib/gitlab/kubernetes/config_map_spec.rb25
-rw-r--r--spec/lib/gitlab/kubernetes/helm/api_spec.rb87
-rw-r--r--spec/lib/gitlab/kubernetes/helm/base_command_spec.rb44
-rw-r--r--spec/lib/gitlab/kubernetes/helm/init_command_spec.rb24
-rw-r--r--spec/lib/gitlab/kubernetes/helm/install_command_spec.rb86
-rw-r--r--spec/lib/gitlab/kubernetes/helm/pod_spec.rb84
-rw-r--r--spec/lib/gitlab/kubernetes/helm_spec.rb100
-rw-r--r--spec/lib/gitlab/kubernetes/namespace_spec.rb2
-rw-r--r--spec/lib/gitlab/ldap/access_spec.rb166
-rw-r--r--spec/lib/gitlab/ldap/adapter_spec.rb140
-rw-r--r--spec/lib/gitlab/ldap/auth_hash_spec.rb86
-rw-r--r--spec/lib/gitlab/ldap/authentication_spec.rb58
-rw-r--r--spec/lib/gitlab/ldap/config_spec.rb365
-rw-r--r--spec/lib/gitlab/ldap/dn_spec.rb224
-rw-r--r--spec/lib/gitlab/ldap/person_spec.rb79
-rw-r--r--spec/lib/gitlab/ldap/user_spec.rb241
-rw-r--r--spec/lib/gitlab/metrics/method_call_spec.rb44
-rw-r--r--spec/lib/gitlab/metrics/methods_spec.rb137
-rw-r--r--spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb22
-rw-r--r--spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb4
-rw-r--r--spec/lib/gitlab/metrics/subscribers/action_view_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/subscribers/active_record_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb7
-rw-r--r--spec/lib/gitlab/metrics_spec.rb2
-rw-r--r--spec/lib/gitlab/middleware/go_spec.rb91
-rw-r--r--spec/lib/gitlab/middleware/multipart_spec.rb10
-rw-r--r--spec/lib/gitlab/middleware/read_only_spec.rb25
-rw-r--r--spec/lib/gitlab/middleware/release_env_spec.rb16
-rw-r--r--spec/lib/gitlab/o_auth/auth_hash_spec.rb111
-rw-r--r--spec/lib/gitlab/o_auth/provider_spec.rb42
-rw-r--r--spec/lib/gitlab/o_auth/user_spec.rb706
-rw-r--r--spec/lib/gitlab/omniauth_initializer_spec.rb65
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb10
-rw-r--r--spec/lib/gitlab/performance_bar_spec.rb6
-rw-r--r--spec/lib/gitlab/plugin_spec.rb68
-rw-r--r--spec/lib/gitlab/popen/runner_spec.rb139
-rw-r--r--spec/lib/gitlab/popen_spec.rb16
-rw-r--r--spec/lib/gitlab/profiler_spec.rb187
-rw-r--r--spec/lib/gitlab/project_authorizations_spec.rb2
-rw-r--r--spec/lib/gitlab/project_search_results_spec.rb139
-rw-r--r--spec/lib/gitlab/project_transfer_spec.rb59
-rw-r--r--spec/lib/gitlab/prometheus/queries/matched_metric_query_spec.rb134
-rw-r--r--spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb134
-rw-r--r--spec/lib/gitlab/prometheus_client_spec.rb46
-rw-r--r--spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb34
-rw-r--r--spec/lib/gitlab/query_limiting/middleware_spec.rb72
-rw-r--r--spec/lib/gitlab/query_limiting/transaction_spec.rb132
-rw-r--r--spec/lib/gitlab/query_limiting_spec.rb67
-rw-r--r--spec/lib/gitlab/quick_actions/command_definition_spec.rb26
-rw-r--r--spec/lib/gitlab/quick_actions/dsl_spec.rb2
-rw-r--r--spec/lib/gitlab/reference_extractor_spec.rb14
-rw-r--r--spec/lib/gitlab/regex_spec.rb8
-rw-r--r--spec/lib/gitlab/repo_path_spec.rb8
-rw-r--r--spec/lib/gitlab/repository_cache_adapter_spec.rb76
-rw-r--r--spec/lib/gitlab/repository_cache_spec.rb50
-rw-r--r--spec/lib/gitlab/saml/auth_hash_spec.rb40
-rw-r--r--spec/lib/gitlab/saml/user_spec.rb403
-rw-r--r--spec/lib/gitlab/search_results_spec.rb121
-rw-r--r--spec/lib/gitlab/shell_spec.rb498
-rw-r--r--spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb31
-rw-r--r--spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb101
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb63
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/shutdown_spec.rb88
-rw-r--r--spec/lib/gitlab/slash_commands/command_spec.rb10
-rw-r--r--spec/lib/gitlab/slash_commands/deploy_spec.rb3
-rw-r--r--spec/lib/gitlab/slash_commands/issue_move_spec.rb117
-rw-r--r--spec/lib/gitlab/slash_commands/issue_new_spec.rb5
-rw-r--r--spec/lib/gitlab/slash_commands/issue_search_spec.rb7
-rw-r--r--spec/lib/gitlab/slash_commands/issue_show_spec.rb5
-rw-r--r--spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb26
-rw-r--r--spec/lib/gitlab/sql/pattern_spec.rb6
-rw-r--r--spec/lib/gitlab/ssh_public_key_spec.rb61
-rw-r--r--spec/lib/gitlab/string_placeholder_replacer_spec.rb38
-rw-r--r--spec/lib/gitlab/string_regex_marker_spec.rb35
-rw-r--r--spec/lib/gitlab/url_blocker_spec.rb45
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb30
-rw-r--r--spec/lib/gitlab/user_access_spec.rb71
-rw-r--r--spec/lib/gitlab/utils/override_spec.rb158
-rw-r--r--spec/lib/gitlab/utils_spec.rb32
-rw-r--r--spec/lib/gitlab/verify/job_artifacts_spec.rb35
-rw-r--r--spec/lib/gitlab/verify/lfs_objects_spec.rb51
-rw-r--r--spec/lib/gitlab/verify/uploads_spec.rb60
-rw-r--r--spec/lib/gitlab/visibility_level_spec.rb36
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb47
-rw-r--r--spec/lib/google_api/cloud_platform/client_spec.rb27
-rw-r--r--spec/lib/mattermost/command_spec.rb5
-rw-r--r--spec/lib/mattermost/session_spec.rb2
-rw-r--r--spec/lib/mattermost/team_spec.rb109
-rw-r--r--spec/lib/repository_cache_spec.rb34
-rw-r--r--spec/lib/uploaded_file_spec.rb116
-rw-r--r--spec/mailers/emails/pages_domains_spec.rb71
-rw-r--r--spec/mailers/notify_spec.rb187
-rw-r--r--spec/mailers/previews/email_rejection_mailer_preview.rb5
-rw-r--r--spec/mailers/previews/notify_preview.rb85
-rw-r--r--spec/mailers/previews/repository_check_mailer_preview.rb5
-rw-r--r--spec/migrations/README.md2
-rw-r--r--spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb44
-rw-r--r--spec/migrations/add_foreign_keys_to_todos_spec.rb65
-rw-r--r--spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb20
-rw-r--r--spec/migrations/calculate_conv_dev_index_percentages_spec.rb4
-rw-r--r--spec/migrations/clean_up_for_members_spec.rb78
-rw-r--r--spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb12
-rw-r--r--spec/migrations/cleanup_nonexisting_namespace_pending_delete_projects_spec.rb8
-rw-r--r--spec/migrations/convert_custom_notification_settings_to_columns_spec.rb4
-rw-r--r--spec/migrations/delete_conflicting_redirect_routes_spec.rb22
-rw-r--r--spec/migrations/enqueue_verify_pages_domain_workers_spec.rb23
-rw-r--r--spec/migrations/fix_wrongly_renamed_routes_spec.rb65
-rw-r--r--spec/migrations/issues_moved_to_id_foreign_key_spec.rb25
-rw-r--r--spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb66
-rw-r--r--spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb52
-rw-r--r--spec/migrations/migrate_issues_to_ghost_user_spec.rb6
-rw-r--r--spec/migrations/migrate_kubernetes_service_to_new_clusters_architectures_spec.rb312
-rw-r--r--spec/migrations/migrate_old_artifacts_spec.rb22
-rw-r--r--spec/migrations/migrate_process_commit_worker_jobs_spec.rb4
-rw-r--r--spec/migrations/migrate_stage_id_reference_in_background_spec.rb6
-rw-r--r--spec/migrations/migrate_stages_statuses_spec.rb8
-rw-r--r--spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb64
-rw-r--r--spec/migrations/migrate_user_activities_to_users_last_activity_on_spec.rb6
-rw-r--r--spec/migrations/migrate_user_project_view_spec.rb4
-rw-r--r--spec/migrations/move_personal_snippets_files_spec.rb16
-rw-r--r--spec/migrations/normalize_ldap_extern_uids_spec.rb6
-rw-r--r--spec/migrations/populate_can_push_from_deploy_keys_projects_spec.rb43
-rw-r--r--spec/migrations/remove_dot_git_from_usernames_spec.rb8
-rw-r--r--spec/migrations/remove_duplicate_mr_events_spec.rb20
-rw-r--r--spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb22
-rw-r--r--spec/migrations/remove_empty_fork_networks_spec.rb17
-rw-r--r--spec/migrations/remove_project_labels_group_id_spec.rb21
-rw-r--r--spec/migrations/remove_redundant_pipeline_stages_spec.rb59
-rw-r--r--spec/migrations/remove_soft_removed_objects_spec.rb77
-rw-r--r--spec/migrations/rename_more_reserved_project_names_spec.rb6
-rw-r--r--spec/migrations/rename_reserved_project_names_spec.rb14
-rw-r--r--spec/migrations/rename_users_with_renamed_namespace_spec.rb14
-rw-r--r--spec/migrations/reschedule_builds_stages_migration_spec.rb37
-rw-r--r--spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb37
-rw-r--r--spec/migrations/schedule_create_gpg_key_subkeys_from_gpg_keys_spec.rb16
-rw-r--r--spec/migrations/schedule_merge_request_diff_migrations_spec.rb6
-rw-r--r--spec/migrations/schedule_merge_request_diff_migrations_take_two_spec.rb6
-rw-r--r--spec/migrations/schedule_merge_request_latest_merge_request_diff_id_migrations_spec.rb6
-rw-r--r--spec/migrations/schedule_populate_merge_request_metrics_with_events_data_spec.rb30
-rw-r--r--spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb44
-rw-r--r--spec/migrations/track_untracked_uploads_spec.rb14
-rw-r--r--spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb8
-rw-r--r--spec/migrations/update_retried_for_ci_build_spec.rb8
-rw-r--r--spec/migrations/update_upload_paths_to_system_spec.rb58
-rw-r--r--spec/models/ability_spec.rb101
-rw-r--r--spec/models/application_setting_spec.rb34
-rw-r--r--spec/models/badge_spec.rb94
-rw-r--r--spec/models/badges/group_badge_spec.rb11
-rw-r--r--spec/models/badges/project_badge_spec.rb43
-rw-r--r--spec/models/chat_name_spec.rb20
-rw-r--r--spec/models/ci/artifact_blob_spec.rb13
-rw-r--r--spec/models/ci/build_metadata_spec.rb61
-rw-r--r--spec/models/ci/build_spec.rb471
-rw-r--r--spec/models/ci/group_variable_spec.rb2
-rw-r--r--spec/models/ci/job_artifact_spec.rb47
-rw-r--r--spec/models/ci/pipeline_schedule_spec.rb1
-rw-r--r--spec/models/ci/pipeline_spec.rb364
-rw-r--r--spec/models/ci/runner_spec.rb108
-rw-r--r--spec/models/ci/trigger_spec.rb2
-rw-r--r--spec/models/ci/variable_spec.rb2
-rw-r--r--spec/models/clusters/applications/helm_spec.rb97
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb124
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb119
-rw-r--r--spec/models/clusters/applications/runner_spec.rb111
-rw-r--r--spec/models/clusters/cluster_spec.rb48
-rw-r--r--spec/models/clusters/platforms/kubernetes_spec.rb2
-rw-r--r--spec/models/commit_range_spec.rb6
-rw-r--r--spec/models/commit_spec.rb44
-rw-r--r--spec/models/compare_spec.rb40
-rw-r--r--spec/models/concerns/avatarable_spec.rb39
-rw-r--r--spec/models/concerns/chronic_duration_attribute_spec.rb129
-rw-r--r--spec/models/concerns/deployment_platform_spec.rb73
-rw-r--r--spec/models/concerns/discussion_on_diff_spec.rb10
-rw-r--r--spec/models/concerns/issuable_spec.rb10
-rw-r--r--spec/models/concerns/mentionable_spec.rb4
-rw-r--r--spec/models/concerns/milestoneish_spec.rb4
-rw-r--r--spec/models/concerns/prometheus_adapter_spec.rb121
-rw-r--r--spec/models/concerns/protected_ref_access_spec.rb31
-rw-r--r--spec/models/concerns/redis_cacheable_spec.rb39
-rw-r--r--spec/models/concerns/resolvable_discussion_spec.rb2
-rw-r--r--spec/models/concerns/routable_spec.rb2
-rw-r--r--spec/models/concerns/triggerable_hooks_spec.rb43
-rw-r--r--spec/models/cycle_analytics/code_spec.rb20
-rw-r--r--spec/models/cycle_analytics/issue_spec.rb8
-rw-r--r--spec/models/cycle_analytics/plan_spec.rb8
-rw-r--r--spec/models/cycle_analytics/production_spec.rb14
-rw-r--r--spec/models/cycle_analytics/review_spec.rb4
-rw-r--r--spec/models/cycle_analytics/staging_spec.rb14
-rw-r--r--spec/models/cycle_analytics/test_spec.rb16
-rw-r--r--spec/models/cycle_analytics_spec.rb30
-rw-r--r--spec/models/deploy_key_spec.rb21
-rw-r--r--spec/models/deploy_keys_project_spec.rb2
-rw-r--r--spec/models/deploy_token_spec.rb134
-rw-r--r--spec/models/deployment_spec.rb16
-rw-r--r--spec/models/diff_discussion_spec.rb48
-rw-r--r--spec/models/diff_note_spec.rb16
-rw-r--r--spec/models/environment_spec.rb87
-rw-r--r--spec/models/event_spec.rb36
-rw-r--r--spec/models/generic_commit_status_spec.rb2
-rw-r--r--spec/models/group_spec.rb28
-rw-r--r--spec/models/hooks/system_hook_spec.rb7
-rw-r--r--spec/models/hooks/web_hook_spec.rb6
-rw-r--r--spec/models/identity_spec.rb33
-rw-r--r--spec/models/internal_id_spec.rb106
-rw-r--r--spec/models/issue_collection_spec.rb2
-rw-r--r--spec/models/issue_spec.rb99
-rw-r--r--spec/models/key_spec.rb43
-rw-r--r--spec/models/lfs_file_lock_spec.rb57
-rw-r--r--spec/models/lfs_object_spec.rb85
-rw-r--r--spec/models/member_spec.rb12
-rw-r--r--spec/models/members/project_member_spec.rb31
-rw-r--r--spec/models/merge_request/metrics_spec.rb15
-rw-r--r--spec/models/merge_request_diff_commit_spec.rb6
-rw-r--r--spec/models/merge_request_diff_spec.rb22
-rw-r--r--spec/models/merge_request_spec.rb317
-rw-r--r--spec/models/namespace_spec.rb315
-rw-r--r--spec/models/note_spec.rb21
-rw-r--r--spec/models/notification_recipient_spec.rb16
-rw-r--r--spec/models/pages_domain_spec.rb268
-rw-r--r--spec/models/project_auto_devops_spec.rb45
-rw-r--r--spec/models/project_deploy_token_spec.rb14
-rw-r--r--spec/models/project_feature_spec.rb2
-rw-r--r--spec/models/project_group_link_spec.rb2
-rw-r--r--spec/models/project_services/asana_service_spec.rb2
-rw-r--r--spec/models/project_services/hipchat_service_spec.rb19
-rw-r--r--spec/models/project_services/jira_service_spec.rb30
-rw-r--r--spec/models/project_services/kubernetes_service_spec.rb103
-rw-r--r--spec/models/project_services/mattermost_slash_commands_service_spec.rb11
-rw-r--r--spec/models/project_services/microsoft_teams_service_spec.rb4
-rw-r--r--spec/models/project_services/pipelines_email_service_spec.rb2
-rw-r--r--spec/models/project_services/prometheus_service_spec.rb150
-rw-r--r--spec/models/project_spec.rb568
-rw-r--r--spec/models/project_statistics_spec.rb6
-rw-r--r--spec/models/project_team_spec.rb4
-rw-r--r--spec/models/project_wiki_spec.rb21
-rw-r--r--spec/models/push_event_spec.rb26
-rw-r--r--spec/models/repository_spec.rb387
-rw-r--r--spec/models/route_spec.rb130
-rw-r--r--spec/models/service_spec.rb91
-rw-r--r--spec/models/todo_spec.rb1
-rw-r--r--spec/models/upload_spec.rb81
-rw-r--r--spec/models/user_callout_spec.rb16
-rw-r--r--spec/models/user_interacted_project_spec.rb60
-rw-r--r--spec/models/user_spec.rb266
-rw-r--r--spec/models/wiki_page_spec.rb240
-rw-r--r--spec/policies/ci/build_policy_spec.rb4
-rw-r--r--spec/policies/ci/pipeline_schedule_policy_spec.rb14
-rw-r--r--spec/policies/ci/trigger_policy_spec.rb10
-rw-r--r--spec/policies/deploy_token_policy_spec.rb45
-rw-r--r--spec/policies/issuable_policy_spec.rb8
-rw-r--r--spec/policies/issue_policy_spec.rb100
-rw-r--r--spec/policies/personal_snippet_policy_spec.rb1
-rw-r--r--spec/policies/project_policy_spec.rb51
-rw-r--r--spec/policies/project_snippet_policy_spec.rb7
-rw-r--r--spec/policies/protected_branch_policy_spec.rb22
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb93
-rw-r--r--spec/presenters/ci/group_variable_presenter_spec.rb17
-rw-r--r--spec/presenters/ci/variable_presenter_spec.rb17
-rw-r--r--spec/presenters/merge_request_presenter_spec.rb71
-rw-r--r--spec/presenters/project_presenter_spec.rb397
-rw-r--r--spec/requests/api/access_requests_spec.rb4
-rw-r--r--spec/requests/api/applications_spec.rb86
-rw-r--r--spec/requests/api/award_emoji_spec.rb2
-rw-r--r--spec/requests/api/badges_spec.rb367
-rw-r--r--spec/requests/api/boards_spec.rb190
-rw-r--r--spec/requests/api/branches_spec.rb21
-rw-r--r--spec/requests/api/commit_statuses_spec.rb1
-rw-r--r--spec/requests/api/commits_spec.rb145
-rw-r--r--spec/requests/api/deploy_keys_spec.rb16
-rw-r--r--spec/requests/api/deployments_spec.rb56
-rw-r--r--spec/requests/api/discussions_spec.rb33
-rw-r--r--spec/requests/api/environments_spec.rb2
-rw-r--r--spec/requests/api/features_spec.rb43
-rw-r--r--spec/requests/api/files_spec.rb2
-rw-r--r--spec/requests/api/group_boards_spec.rb54
-rw-r--r--spec/requests/api/group_variables_spec.rb4
-rw-r--r--spec/requests/api/groups_spec.rb17
-rw-r--r--spec/requests/api/helpers_spec.rb6
-rw-r--r--spec/requests/api/internal_spec.rb283
-rw-r--r--spec/requests/api/issues_spec.rb121
-rw-r--r--spec/requests/api/jobs_spec.rb213
-rw-r--r--spec/requests/api/labels_spec.rb2
-rw-r--r--spec/requests/api/members_spec.rb29
-rw-r--r--spec/requests/api/merge_request_diffs_spec.rb2
-rw-r--r--spec/requests/api/merge_requests_spec.rb233
-rw-r--r--spec/requests/api/notes_spec.rb592
-rw-r--r--spec/requests/api/pages_domains_spec.rb26
-rw-r--r--spec/requests/api/pipeline_schedules_spec.rb23
-rw-r--r--spec/requests/api/pipelines_spec.rb4
-rw-r--r--spec/requests/api/project_export_spec.rb370
-rw-r--r--spec/requests/api/project_hooks_spec.rb16
-rw-r--r--spec/requests/api/project_import_spec.rb168
-rw-r--r--spec/requests/api/project_milestones_spec.rb44
-rw-r--r--spec/requests/api/project_snippets_spec.rb13
-rw-r--r--spec/requests/api/projects_spec.rb109
-rw-r--r--spec/requests/api/protected_branches_spec.rb40
-rw-r--r--spec/requests/api/runner_spec.rb300
-rw-r--r--spec/requests/api/runners_spec.rb5
-rw-r--r--spec/requests/api/search_spec.rb318
-rw-r--r--spec/requests/api/services_spec.rb14
-rw-r--r--spec/requests/api/snippets_spec.rb21
-rw-r--r--spec/requests/api/system_hooks_spec.rb20
-rw-r--r--spec/requests/api/templates_spec.rb2
-rw-r--r--spec/requests/api/todos_spec.rb12
-rw-r--r--spec/requests/api/users_spec.rb18
-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/builds_spec.rb113
-rw-r--r--spec/requests/api/v3/commits_spec.rb41
-rw-r--r--spec/requests/api/v3/deploy_keys_spec.rb2
-rw-r--r--spec/requests/api/v3/deployments_spec.rb2
-rw-r--r--spec/requests/api/v3/environments_spec.rb2
-rw-r--r--spec/requests/api/v3/files_spec.rb2
-rw-r--r--spec/requests/api/v3/groups_spec.rb2
-rw-r--r--spec/requests/api/v3/issues_spec.rb40
-rw-r--r--spec/requests/api/v3/labels_spec.rb2
-rw-r--r--spec/requests/api/v3/members_spec.rb14
-rw-r--r--spec/requests/api/v3/merge_request_diffs_spec.rb2
-rw-r--r--spec/requests/api/v3/merge_requests_spec.rb38
-rw-r--r--spec/requests/api/v3/milestones_spec.rb8
-rw-r--r--spec/requests/api/v3/notes_spec.rb6
-rw-r--r--spec/requests/api/v3/pipelines_spec.rb4
-rw-r--r--spec/requests/api/v3/project_hooks_spec.rb12
-rw-r--r--spec/requests/api/v3/projects_spec.rb18
-rw-r--r--spec/requests/api/v3/services_spec.rb4
-rw-r--r--spec/requests/api/v3/templates_spec.rb2
-rw-r--r--spec/requests/api/v3/todos_spec.rb10
-rw-r--r--spec/requests/api/variables_spec.rb4
-rw-r--r--spec/requests/api/wikis_spec.rb30
-rw-r--r--spec/requests/git_http_spec.rb110
-rw-r--r--spec/requests/lfs_http_spec.rb247
-rw-r--r--spec/requests/lfs_locks_api_spec.rb159
-rw-r--r--spec/requests/openid_connect_spec.rb27
-rw-r--r--spec/requests/projects/cycle_analytics_events_spec.rb18
-rw-r--r--spec/requests/rack_attack_global_spec.rb10
-rw-r--r--spec/routing/project_routing_spec.rb30
-rw-r--r--spec/routing/routing_spec.rb20
-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/custom_error_class_spec.rb111
-rw-r--r--spec/rubocop/cop/gem_fetcher_spec.rb46
-rw-r--r--spec/rubocop/cop/gitlab/has_many_through_scope_spec.rb74
-rw-r--r--spec/rubocop/cop/gitlab/httparty_spec.rb74
-rw-r--r--spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb4
-rw-r--r--spec/rubocop/cop/gitlab/predicate_memoization_spec.rb100
-rw-r--r--spec/rubocop/cop/in_batches_spec.rb19
-rw-r--r--spec/rubocop/cop/include_sidekiq_worker_spec.rb6
-rw-r--r--spec/rubocop/cop/line_break_after_guard_clauses_spec.rb160
-rw-r--r--spec/rubocop/cop/line_break_around_conditional_block_spec.rb411
-rw-r--r--spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb6
-rw-r--r--spec/rubocop/cop/migration/add_concurrent_index_spec.rb6
-rw-r--r--spec/rubocop/cop/migration/add_timestamps_spec.rb12
-rw-r--r--spec/rubocop/cop/migration/datetime_spec.rb16
-rw-r--r--spec/rubocop/cop/migration/hash_index_spec.rb8
-rw-r--r--spec/rubocop/cop/migration/remove_column_spec.rb10
-rw-r--r--spec/rubocop/cop/migration/remove_concurrent_index_spec.rb6
-rw-r--r--spec/rubocop/cop/migration/remove_index_spec.rb4
-rw-r--r--spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb6
-rw-r--r--spec/rubocop/cop/migration/safer_boolean_column_spec.rb10
-rw-r--r--spec/rubocop/cop/migration/timestamps_spec.rb12
-rw-r--r--spec/rubocop/cop/migration/update_column_in_batches_spec.rb8
-rw-r--r--spec/rubocop/cop/migration/update_large_table_spec.rb10
-rw-r--r--spec/rubocop/cop/polymorphic_associations_spec.rb33
-rw-r--r--spec/rubocop/cop/project_path_helper_spec.rb6
-rw-r--r--spec/rubocop/cop/redirect_with_status_spec.rb86
-rw-r--r--spec/rubocop/cop/rspec/env_assignment_spec.rb6
-rw-r--r--spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb48
-rw-r--r--spec/rubocop/cop/rspec/single_line_hook_spec.rb66
-rw-r--r--spec/rubocop/cop/rspec/verbose_include_metadata_spec.rb53
-rw-r--r--spec/rubocop/cop/sidekiq_options_queue_spec.rb6
-rw-r--r--spec/serializers/build_serializer_spec.rb22
-rw-r--r--spec/serializers/cluster_application_entity_spec.rb18
-rw-r--r--spec/serializers/deploy_key_entity_spec.rb15
-rw-r--r--spec/serializers/diff_file_entity_spec.rb24
-rw-r--r--spec/serializers/discussion_entity_spec.rb36
-rw-r--r--spec/serializers/event_entity_spec.rb13
-rw-r--r--spec/serializers/group_child_entity_spec.rb12
-rw-r--r--spec/serializers/group_variable_entity_spec.rb14
-rw-r--r--spec/serializers/job_entity_spec.rb26
-rw-r--r--spec/serializers/lfs_file_lock_entity_spec.rb19
-rw-r--r--spec/serializers/merge_request_widget_entity_spec.rb97
-rw-r--r--spec/serializers/note_entity_spec.rb39
-rw-r--r--spec/serializers/pipeline_entity_spec.rb2
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb1
-rw-r--r--spec/serializers/project_note_entity_spec.rb29
-rw-r--r--spec/serializers/stage_entity_spec.rb2
-rw-r--r--spec/serializers/status_entity_spec.rb7
-rw-r--r--spec/serializers/variable_entity_spec.rb14
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb18
-rw-r--r--spec/services/boards/create_service_spec.rb30
-rw-r--r--spec/services/boards/issues/create_service_spec.rb2
-rw-r--r--spec/services/boards/issues/list_service_spec.rb164
-rw-r--r--spec/services/boards/issues/move_service_spec.rb119
-rw-r--r--spec/services/boards/list_service_spec.rb32
-rw-r--r--spec/services/boards/lists/create_service_spec.rb89
-rw-r--r--spec/services/boards/lists/destroy_service_spec.rb39
-rw-r--r--spec/services/boards/lists/generate_service_spec.rb2
-rw-r--r--spec/services/boards/lists/list_service_spec.rb36
-rw-r--r--spec/services/boards/lists/move_service_spec.rb100
-rw-r--r--spec/services/chat_names/find_user_service_spec.rb25
-rw-r--r--spec/services/check_gcp_project_billing_service_spec.rb32
-rw-r--r--spec/services/ci/ensure_stage_service_spec.rb51
-rw-r--r--spec/services/ci/process_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/register_job_service_spec.rb83
-rw-r--r--spec/services/ci/retry_build_service_spec.rb45
-rw-r--r--spec/services/ci/stop_environments_service_spec.rb6
-rw-r--r--spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb73
-rw-r--r--spec/services/clusters/applications/check_installation_progress_service_spec.rb4
-rw-r--r--spec/services/clusters/applications/install_service_spec.rb14
-rw-r--r--spec/services/clusters/applications/schedule_installation_service_spec.rb2
-rw-r--r--spec/services/clusters/create_service_spec.rb2
-rw-r--r--spec/services/create_deployment_service_spec.rb2
-rw-r--r--spec/services/delete_branch_service_spec.rb2
-rw-r--r--spec/services/deploy_tokens/create_service_spec.rb45
-rw-r--r--spec/services/discussions/resolve_service_spec.rb2
-rw-r--r--spec/services/files/create_service_spec.rb78
-rw-r--r--spec/services/files/delete_service_spec.rb2
-rw-r--r--spec/services/files/multi_service_spec.rb78
-rw-r--r--spec/services/files/update_service_spec.rb14
-rw-r--r--spec/services/git_push_service_spec.rb12
-rw-r--r--spec/services/git_tag_push_service_spec.rb2
-rw-r--r--spec/services/groups/destroy_service_spec.rb6
-rw-r--r--spec/services/groups/transfer_service_spec.rb414
-rw-r--r--spec/services/issuable/bulk_update_service_spec.rb8
-rw-r--r--spec/services/issuable/destroy_service_spec.rb14
-rw-r--r--spec/services/issues/build_service_spec.rb2
-rw-r--r--spec/services/issues/close_service_spec.rb12
-rw-r--r--spec/services/issues/create_service_spec.rb18
-rw-r--r--spec/services/issues/fetch_referenced_merge_requests_service_spec.rb35
-rw-r--r--spec/services/issues/move_service_spec.rb66
-rw-r--r--spec/services/issues/reopen_service_spec.rb4
-rw-r--r--spec/services/issues/resolve_discussions_spec.rb2
-rw-r--r--spec/services/issues/update_service_spec.rb48
-rw-r--r--spec/services/labels/find_or_create_service_spec.rb78
-rw-r--r--spec/services/labels/promote_service_spec.rb13
-rw-r--r--spec/services/lfs/file_transformer_spec.rb97
-rw-r--r--spec/services/lfs/lock_file_service_spec.rb62
-rw-r--r--spec/services/lfs/locks_finder_service_spec.rb101
-rw-r--r--spec/services/lfs/unlock_file_service_spec.rb105
-rw-r--r--spec/services/members/approve_access_request_service_spec.rb70
-rw-r--r--spec/services/members/authorized_destroy_service_spec.rb66
-rw-r--r--spec/services/members/create_service_spec.rb8
-rw-r--r--spec/services/members/destroy_service_spec.rb218
-rw-r--r--spec/services/members/request_access_service_spec.rb6
-rw-r--r--spec/services/members/update_service_spec.rb59
-rw-r--r--spec/services/merge_requests/assign_issues_service_spec.rb2
-rw-r--r--spec/services/merge_requests/build_service_spec.rb106
-rw-r--r--spec/services/merge_requests/close_service_spec.rb21
-rw-r--r--spec/services/merge_requests/conflicts/list_service_spec.rb42
-rw-r--r--spec/services/merge_requests/conflicts/resolve_service_spec.rb20
-rw-r--r--spec/services/merge_requests/create_from_issue_service_spec.rb21
-rw-r--r--spec/services/merge_requests/create_service_spec.rb114
-rw-r--r--spec/services/merge_requests/ff_merge_service_spec.rb7
-rw-r--r--spec/services/merge_requests/merge_request_diff_cache_service_spec.rb36
-rw-r--r--spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb2
-rw-r--r--spec/services/merge_requests/post_merge_service_spec.rb15
-rw-r--r--spec/services/merge_requests/rebase_service_spec.rb154
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb104
-rw-r--r--spec/services/merge_requests/reopen_service_spec.rb21
-rw-r--r--spec/services/merge_requests/update_service_spec.rb50
-rw-r--r--spec/services/milestones/close_service_spec.rb2
-rw-r--r--spec/services/milestones/create_service_spec.rb2
-rw-r--r--spec/services/milestones/destroy_service_spec.rb2
-rw-r--r--spec/services/notes/create_service_spec.rb53
-rw-r--r--spec/services/notes/post_process_service_spec.rb20
-rw-r--r--spec/services/notes/quick_actions_service_spec.rb40
-rw-r--r--spec/services/notes/update_service_spec.rb6
-rw-r--r--spec/services/notification_service_spec.rb280
-rw-r--r--spec/services/projects/autocomplete_service_spec.rb19
-rw-r--r--spec/services/projects/create_from_template_service_spec.rb8
-rw-r--r--spec/services/projects/create_service_spec.rb37
-rw-r--r--spec/services/projects/destroy_service_spec.rb28
-rw-r--r--spec/services/projects/fork_service_spec.rb21
-rw-r--r--spec/services/projects/gitlab_projects_import_service_spec.rb65
-rw-r--r--spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb6
-rw-r--r--spec/services/projects/hashed_storage/migrate_repository_service_spec.rb8
-rw-r--r--spec/services/projects/hashed_storage_migration_service_spec.rb2
-rw-r--r--spec/services/projects/import_export/export_service_spec.rb128
-rw-r--r--spec/services/projects/import_service_spec.rb4
-rw-r--r--spec/services/projects/move_access_service_spec.rb114
-rw-r--r--spec/services/projects/move_deploy_keys_projects_service_spec.rb58
-rw-r--r--spec/services/projects/move_forks_service_spec.rb96
-rw-r--r--spec/services/projects/move_lfs_objects_projects_service_spec.rb55
-rw-r--r--spec/services/projects/move_notification_settings_service_spec.rb56
-rw-r--r--spec/services/projects/move_project_authorizations_service_spec.rb56
-rw-r--r--spec/services/projects/move_project_group_links_service_spec.rb65
-rw-r--r--spec/services/projects/move_project_members_service_spec.rb65
-rw-r--r--spec/services/projects/move_users_star_projects_service_spec.rb42
-rw-r--r--spec/services/projects/overwrite_project_service_spec.rb198
-rw-r--r--spec/services/projects/transfer_service_spec.rb27
-rw-r--r--spec/services/projects/update_pages_service_spec.rb152
-rw-r--r--spec/services/projects/update_service_spec.rb72
-rw-r--r--spec/services/prometheus/adapter_service_spec.rb40
-rw-r--r--spec/services/protected_branches/create_service_spec.rb29
-rw-r--r--spec/services/protected_branches/destroy_service_spec.rb30
-rw-r--r--spec/services/protected_branches/update_service_spec.rb11
-rw-r--r--spec/services/protected_tags/destroy_service_spec.rb17
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb20
-rw-r--r--spec/services/reset_project_cache_service_spec.rb28
-rw-r--r--spec/services/search/snippet_service_spec.rb4
-rw-r--r--spec/services/system_hooks_service_spec.rb25
-rw-r--r--spec/services/system_note_service_spec.rb80
-rw-r--r--spec/services/test_hooks/system_service_spec.rb20
-rw-r--r--spec/services/todo_service_spec.rb13
-rw-r--r--spec/services/update_merge_request_metrics_service_spec.rb42
-rw-r--r--spec/services/users/destroy_service_spec.rb23
-rw-r--r--spec/services/users/update_service_spec.rb4
-rw-r--r--spec/services/verify_pages_domain_service_spec.rb289
-rw-r--r--spec/services/web_hook_service_spec.rb14
-rw-r--r--spec/spec_helper.rb60
-rw-r--r--spec/support/api/boards_shared_examples.rb180
-rw-r--r--spec/support/api/milestones_shared_examples.rb4
-rw-r--r--spec/support/background_migrations_matchers.rb15
-rw-r--r--spec/support/bare_repo_operations.rb14
-rw-r--r--spec/support/capybara.rb9
-rw-r--r--spec/support/commit_trailers_spec_helper.rb41
-rw-r--r--spec/support/controllers/githubish_import_controller_shared_examples.rb152
-rw-r--r--spec/support/cookie_helper.rb17
-rw-r--r--spec/support/cycle_analytics_helpers.rb80
-rw-r--r--spec/support/db_cleaner.rb26
-rw-r--r--spec/support/devise_helpers.rb15
-rw-r--r--spec/support/email_helpers.rb4
-rw-r--r--spec/support/factory_bot.rb (renamed from spec/support/factory_girl.rb)0
-rw-r--r--spec/support/features/discussion_comments_shared_example.rb27
-rw-r--r--spec/support/features/issuable_slash_commands_shared_examples.rb35
-rw-r--r--spec/support/features/variable_list_shared_examples.rb271
-rw-r--r--spec/support/filtered_search_helpers.rb25
-rw-r--r--spec/support/fixture_helpers.rb8
-rwxr-xr-xspec/support/generate-seed-repo-rb1
-rw-r--r--spec/support/gitaly.rb6
-rw-r--r--spec/support/gitlab-git-test.git/packed-refs2
-rw-r--r--spec/support/gitlab_verify.rb45
-rw-r--r--spec/support/google_api/cloud_platform_helpers.rb45
-rw-r--r--spec/support/helpers/expect_offense.rb20
-rw-r--r--spec/support/helpers/features/notes_helpers.rb27
-rw-r--r--spec/support/helpers/features/sorting_helpers.rb26
-rw-r--r--spec/support/http_io/http_io_helpers.rb64
-rw-r--r--spec/support/issuables_list_metadata_shared_examples.rb8
-rw-r--r--spec/support/issuables_requiring_filter_shared_examples.rb15
-rw-r--r--spec/support/javascript_fixtures_helpers.rb1
-rw-r--r--spec/support/ldap_helpers.rb17
-rw-r--r--spec/support/login_helpers.rb17
-rw-r--r--spec/support/markdown_feature.rb4
-rw-r--r--spec/support/matchers/access_matchers_for_controller.rb1
-rw-r--r--spec/support/matchers/issuable_matchers.rb11
-rw-r--r--spec/support/matchers/markdown_matchers.rb21
-rw-r--r--spec/support/matchers/match_ids.rb24
-rw-r--r--spec/support/matchers/pagination_matcher.rb6
-rw-r--r--spec/support/mentionable_shared_examples.rb2
-rw-r--r--spec/support/migrations_helpers.rb42
-rw-r--r--spec/support/migrations_helpers/track_untracked_uploads_helpers.rb128
-rw-r--r--spec/support/project_forks_helper.rb4
-rw-r--r--spec/support/prometheus/additional_metrics_shared_examples.rb16
-rw-r--r--spec/support/quick_actions_helpers.rb10
-rw-r--r--spec/support/reactive_caching_helpers.rb6
-rw-r--r--spec/support/reference_parser_helpers.rb30
-rw-r--r--spec/support/reference_parser_shared_examples.rb2
-rw-r--r--spec/support/select2_helper.rb1
-rw-r--r--spec/support/services/issuable_create_service_slash_commands_shared_examples.rb4
-rw-r--r--spec/support/services_shared_context.rb16
-rw-r--r--spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb73
-rw-r--r--spec/support/shared_examples/controllers/variables_shared_examples.rb123
-rw-r--r--spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb99
-rw-r--r--spec/support/shared_examples/features/editable_merge_request_shared_examples.rb140
-rw-r--r--spec/support/shared_examples/models/atomic_internal_id_spec.rb40
-rw-r--r--spec/support/shared_examples/models/cluster_application_core_shared_examples.rb70
-rw-r--r--spec/support/shared_examples/models/cluster_application_status_shared_examples.rb31
-rw-r--r--spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb158
-rw-r--r--spec/support/shared_examples/requests/api/discussions.rb169
-rw-r--r--spec/support/shared_examples/requests/api/issuable_participants_examples.rb29
-rw-r--r--spec/support/shared_examples/requests/api/notes.rb206
-rw-r--r--spec/support/shared_examples/serializers/note_entity_examples.rb42
-rw-r--r--spec/support/shared_examples/services/boards/boards_create_service.rb27
-rw-r--r--spec/support/shared_examples/services/boards/boards_list_service.rb29
-rw-r--r--spec/support/shared_examples/services/boards/issues_list_service.rb60
-rw-r--r--spec/support/shared_examples/services/boards/issues_move_service.rb100
-rw-r--r--spec/support/shared_examples/services/boards/lists_destroy_service.rb30
-rw-r--r--spec/support/shared_examples/services/boards/lists_list_service.rb23
-rw-r--r--spec/support/shared_examples/services/boards/lists_move_service.rb93
-rw-r--r--spec/support/shared_examples/uploaders/gitlab_uploader_shared_examples.rb48
-rw-r--r--spec/support/shared_examples/uploaders/object_storage_shared_examples.rb138
-rw-r--r--spec/support/slack_mattermost_notifications_shared_examples.rb81
-rw-r--r--spec/support/snippet_visibility.rb322
-rw-r--r--spec/support/sorting_helper.rb18
-rw-r--r--spec/support/stored_repositories.rb6
-rw-r--r--spec/support/stub_configuration.rb12
-rw-r--r--spec/support/stub_env.rb3
-rw-r--r--spec/support/stub_object_storage.rb48
-rw-r--r--spec/support/test_env.rb25
-rw-r--r--spec/support/track_untracked_uploads_helpers.rb20
-rw-r--r--spec/support/unique_ip_check_shared_examples.rb6
-rw-r--r--spec/support/updating_mentions_shared_examples.rb2
-rw-r--r--spec/support/wait_for_requests.rb1
-rw-r--r--spec/tasks/gitlab/artifacts/check_rake_spec.rb34
-rw-r--r--spec/tasks/gitlab/artifacts/migrate_rake_spec.rb118
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb17
-rw-r--r--spec/tasks/gitlab/check_rake_spec.rb8
-rw-r--r--spec/tasks/gitlab/cleanup_rake_spec.rb7
-rw-r--r--spec/tasks/gitlab/git_rake_spec.rb41
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb18
-rw-r--r--spec/tasks/gitlab/lfs/check_rake_spec.rb28
-rw-r--r--spec/tasks/gitlab/lfs/migrate_rake_spec.rb37
-rw-r--r--spec/tasks/gitlab/shell_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/task_helpers_spec.rb1
-rw-r--r--spec/tasks/gitlab/traces_rake_spec.rb55
-rw-r--r--spec/tasks/gitlab/uploads/check_rake_spec.rb28
-rw-r--r--spec/tasks/gitlab/uploads/migrate_rake_spec.rb143
-rw-r--r--spec/uploaders/attachment_uploader_spec.rb40
-rw-r--r--spec/uploaders/avatar_uploader_spec.rb43
-rw-r--r--spec/uploaders/file_mover_spec.rb24
-rw-r--r--spec/uploaders/file_uploader_spec.rb150
-rw-r--r--spec/uploaders/gitlab_uploader_spec.rb6
-rw-r--r--spec/uploaders/job_artifact_uploader_spec.rb80
-rw-r--r--spec/uploaders/legacy_artifact_uploader_spec.rb53
-rw-r--r--spec/uploaders/lfs_object_uploader_spec.rb74
-rw-r--r--spec/uploaders/namespace_file_uploader_spec.rb35
-rw-r--r--spec/uploaders/object_storage_spec.rb621
-rw-r--r--spec/uploaders/personal_file_uploader_spec.rb44
-rw-r--r--spec/uploaders/records_uploads_spec.rb73
-rw-r--r--spec/uploaders/workers/object_storage/background_move_worker_spec.rb146
-rw-r--r--spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb119
-rw-r--r--spec/validators/url_placeholder_validator_spec.rb39
-rw-r--r--spec/validators/url_validator_spec.rb46
-rw-r--r--spec/validators/user_path_validator_spec.rb38
-rw-r--r--spec/validators/variable_duplicates_validator_spec.rb67
-rw-r--r--spec/views/ci/lints/show.html.haml_spec.rb97
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb2
-rw-r--r--spec/views/projects/_home_panel.html.haml_spec.rb54
-rw-r--r--spec/views/projects/buttons/_dropdown.html.haml_spec.rb39
-rw-r--r--spec/views/projects/ci/lints/show.html.haml_spec.rb99
-rw-r--r--spec/views/projects/diffs/_stats.html.haml_spec.rb56
-rw-r--r--spec/views/projects/imports/new.html.haml_spec.rb2
-rw-r--r--spec/views/projects/jobs/show.html.haml_spec.rb20
-rw-r--r--spec/views/projects/merge_requests/_commits.html.haml_spec.rb2
-rw-r--r--spec/views/projects/merge_requests/show.html.haml_spec.rb2
-rw-r--r--spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb47
-rw-r--r--spec/views/projects/pipelines_settings/_show.html.haml_spec.rb62
-rw-r--r--spec/views/projects/services/_form.haml_spec.rb46
-rw-r--r--spec/views/projects/settings/ci_cd/_form.html.haml_spec.rb62
-rw-r--r--spec/views/projects/tree/show.html.haml_spec.rb1
-rw-r--r--spec/views/shared/notes/_form.html.haml_spec.rb2
-rw-r--r--spec/views/shared/projects/_project.html.haml_spec.rb1
-rw-r--r--spec/workers/archive_trace_worker_spec.rb27
-rw-r--r--spec/workers/authorized_projects_worker_spec.rb79
-rw-r--r--spec/workers/background_migration_worker_spec.rb23
-rw-r--r--spec/workers/build_finished_worker_spec.rb14
-rw-r--r--spec/workers/check_gcp_project_billing_worker_spec.rb116
-rw-r--r--spec/workers/cluster_wait_for_ingress_ip_address_worker_spec.rb30
-rw-r--r--spec/workers/concerns/gitlab/github_import/object_importer_spec.rb2
-rw-r--r--spec/workers/concerns/pipeline_background_queue_spec.rb19
-rw-r--r--spec/workers/concerns/waitable_worker_spec.rb98
-rw-r--r--spec/workers/git_garbage_collect_worker_spec.rb6
-rw-r--r--spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_issue_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_note_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb2
-rw-r--r--spec/workers/gitlab_shell_worker_spec.rb12
-rw-r--r--spec/workers/merge_worker_spec.rb2
-rw-r--r--spec/workers/namespaceless_project_destroy_worker_spec.rb4
-rw-r--r--spec/workers/new_issue_worker_spec.rb5
-rw-r--r--spec/workers/new_merge_request_worker_spec.rb6
-rw-r--r--spec/workers/object_storage_upload_worker_spec.rb108
-rw-r--r--spec/workers/pages_domain_verification_cron_worker_spec.rb21
-rw-r--r--spec/workers/pages_domain_verification_worker_spec.rb27
-rw-r--r--spec/workers/plugin_worker_spec.rb25
-rw-r--r--spec/workers/post_receive_spec.rb12
-rw-r--r--spec/workers/process_commit_worker_spec.rb69
-rw-r--r--spec/workers/project_export_worker_spec.rb28
-rw-r--r--spec/workers/rebase_worker_spec.rb27
-rw-r--r--spec/workers/repository_fork_worker_spec.rb99
-rw-r--r--spec/workers/repository_import_worker_spec.rb16
-rw-r--r--spec/workers/storage_migrator_worker_spec.rb2
-rw-r--r--spec/workers/stuck_import_jobs_worker_spec.rb58
-rw-r--r--spec/workers/upload_checksum_worker_spec.rb29
-rw-r--r--vendor/assets/fonts/KaTeX_AMS-Regular.eotbin71656 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_AMS-Regular.ttfbin71428 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_AMS-Regular.woffbin40200 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_AMS-Regular.woff2bin33188 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Bold.eotbin19836 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Bold.ttfbin19588 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Bold.woffbin12136 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Bold.woff2bin10604 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Regular.eotbin19220 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Regular.ttfbin18960 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Regular.woffbin11868 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Caligraphic-Regular.woff2bin10396 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Bold.eotbin36200 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Bold.ttfbin35968 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Bold.woffbin23388 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Bold.woff2bin20476 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Regular.eotbin34896 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Regular.ttfbin34652 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Regular.woffbin22844 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Fraktur-Regular.woff2bin19868 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Bold.eotbin60688 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Bold.ttfbin60468 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Bold.woffbin35480 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Bold.woff2bin29492 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Italic.eotbin44132 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Italic.ttfbin43904 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Italic.woffbin24880 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Italic.woff2bin21032 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Regular.eotbin68228 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Regular.ttfbin67996 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Regular.woffbin37620 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Main-Regular.woff2bin31220 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-BoldItalic.eotbin39990 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-BoldItalic.ttfbin39744 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-BoldItalic.woffbin23192 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-BoldItalic.woff2bin20036 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Italic.eotbin41676 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Italic.ttfbin41448 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Italic.woffbin23820 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Italic.woff2bin20432 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Regular.eotbin41536 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Regular.ttfbin41304 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Regular.woffbin23712 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Math-Regular.woff2bin20344 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Bold.eotbin34204 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Bold.ttfbin33964 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Bold.woffbin19196 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Bold.woff2bin16020 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Italic.eotbin31320 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Italic.ttfbin31072 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Italic.woffbin18080 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Italic.woff2bin15152 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Regular.eotbin30212 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Regular.ttfbin29960 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Regular.woffbin16744 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_SansSerif-Regular.woff2bin13908 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Script-Regular.eotbin25104 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Script-Regular.ttfbin24864 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Script-Regular.woffbin13856 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Script-Regular.woff2bin12276 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size1-Regular.eotbin13408 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size1-Regular.ttfbin13172 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size1-Regular.woffbin6980 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size1-Regular.woff2bin5820 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size2-Regular.eotbin12648 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size2-Regular.ttfbin12412 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size2-Regular.woffbin6684 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size2-Regular.woff2bin5560 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size3-Regular.eotbin8596 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size3-Regular.ttfbin8360 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size3-Regular.woffbin4776 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size3-Regular.woff2bin3856 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size4-Regular.eotbin11520 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size4-Regular.ttfbin11284 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size4-Regular.woffbin6456 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Size4-Regular.woff2bin5172 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Typewriter-Regular.eotbin35784 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Typewriter-Regular.ttfbin35528 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Typewriter-Regular.woffbin20712 -> 0 bytes
-rw-r--r--vendor/assets/fonts/KaTeX_Typewriter-Regular.woff2bin17344 -> 0 bytes
-rw-r--r--vendor/assets/javascripts/Chart.js3477
-rw-r--r--vendor/assets/javascripts/jasmine-jquery.js851
-rw-r--r--vendor/assets/javascripts/jquery.atwho.js4
-rw-r--r--vendor/assets/javascripts/jquery.endless-scroll.js2
-rw-r--r--vendor/assets/javascripts/jquery.waitforimages.js144
-rw-r--r--vendor/assets/javascripts/katex.js8685
-rw-r--r--vendor/assets/javascripts/peek.js70
-rw-r--r--vendor/assets/stylesheets/katex.scss977
-rw-r--r--vendor/gitignore/Android.gitignore7
-rw-r--r--vendor/gitignore/Dart.gitignore3
-rw-r--r--vendor/gitignore/Eagle.gitignore10
-rw-r--r--vendor/gitignore/Global/Eclipse.gitignore3
-rw-r--r--vendor/gitignore/Global/JetBrains.gitignore4
-rw-r--r--vendor/gitignore/Global/Matlab.gitignore20
-rw-r--r--vendor/gitignore/Go.gitignore1
-rw-r--r--vendor/gitignore/Node.gitignore2
-rw-r--r--vendor/gitignore/Python.gitignore1
-rw-r--r--vendor/gitignore/Qt.gitignore8
-rw-r--r--vendor/gitignore/R.gitignore3
-rw-r--r--vendor/gitignore/ROS.gitignore2
-rw-r--r--vendor/gitignore/Rails.gitignore4
-rw-r--r--vendor/gitignore/TeX.gitignore9
-rw-r--r--vendor/gitignore/Umbraco.gitignore7
-rw-r--r--vendor/gitignore/VisualStudio.gitignore12
-rw-r--r--vendor/gitignore/WordPress.gitignore1
-rw-r--r--vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml161
-rw-r--r--vendor/gitlab-ci-yml/Mono.gitlab-ci.yml42
-rw-r--r--vendor/gitlab-ci-yml/Rust.gitlab-ci.yml2
-rw-r--r--vendor/ingress/values.yaml9
-rw-r--r--vendor/licenses.csv1453
-rw-r--r--vendor/project_templates/express.tar.gzbin5651 -> 5614 bytes
-rw-r--r--vendor/project_templates/rails.tar.gzbin25065 -> 25007 bytes
-rw-r--r--vendor/project_templates/spring.tar.gzbin50845 -> 50945 bytes
-rw-r--r--vendor/prometheus/values.yaml141
-rw-r--r--vendor/runner/values.yaml23
-rw-r--r--yarn.lock4169
5797 files changed, 251797 insertions, 106963 deletions
diff --git a/.babelrc b/.babelrc
index 2bae7ca9fbf..8cf07b73420 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,19 +1,20 @@
{
- "presets": [
- ["latest", { "es2015": { "modules": false } }],
- "stage-2"
- ],
+ "presets": [["latest", { "es2015": { "modules": false } }], "stage-2"],
"env": {
"coverage": {
"plugins": [
- ["istanbul", {
- "exclude": [
- "spec/javascripts/**/*"
- ]
- }],
- ["transform-define", {
- "process.env.BABEL_ENV": "coverage"
- }]
+ [
+ "istanbul",
+ {
+ "exclude": ["spec/javascripts/**/*", "app/assets/javascripts/locale/**/app.js"]
+ }
+ ],
+ [
+ "transform-define",
+ {
+ "process.env.BABEL_ENV": "coverage"
+ }
+ ]
]
}
}
diff --git a/.codeclimate.yml b/.codeclimate.yml
index 42afed54371..8699a903f2a 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -1,7 +1,5 @@
---
engines:
- brakeman:
- enabled: true
bundler-audit:
enabled: true
duplication:
@@ -12,10 +10,6 @@ engines:
- javascript
exclude_paths:
- "lib/api/v3/*"
- eslint:
- enabled: true
- rubocop:
- enabled: true
ratings:
paths:
- Gemfile.lock
@@ -45,3 +39,4 @@ exclude_paths:
- log/
- backups/
- coverage-javascript/
+- plugins/
diff --git a/.eslintignore b/.eslintignore
index 1623b996213..33a8186fade 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,11 +1,12 @@
+/app/assets/javascripts/locale/**/app.js
+/config/
/builds/
/coverage/
/coverage-javascript/
/node_modules/
/public/
+/scripts/
/tmp/
/vendor/
karma.config.js
webpack.config.js
-svg.config.js
-/app/assets/javascripts/locale/**/app.js
diff --git a/.eslintrc b/.eslintrc
index 44ad6a4896c..3f187db0c07 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,18 +1,21 @@
{
"env": {
- "jquery": true,
"browser": true,
"es6": true
},
- "extends": "airbnb-base",
+ "extends": [
+ "airbnb-base",
+ "plugin:vue/recommended"
+ ],
"globals": {
"__webpack_public_path__": true,
- "_": false,
"gl": false,
"gon": false,
"localStorage": false
},
- "parser": "babel-eslint",
+ "parserOptions": {
+ "parser": "babel-eslint"
+ },
"plugins": [
"filenames",
"import",
@@ -20,7 +23,7 @@
"promise"
],
"settings": {
- "html/html-extensions": [".html", ".html.raw", ".vue"],
+ "html/html-extensions": [".html", ".html.raw"],
"import/resolver": {
"webpack": {
"config": "./config/webpack.config.js"
@@ -32,6 +35,22 @@
"import/no-commonjs": "error",
"no-multiple-empty-lines": ["error", { "max": 1 }],
"promise/catch-or-return": "error",
- "no-underscore-dangle": ["error", { "allow": ["__"]}]
+ "no-underscore-dangle": ["error", { "allow": ["__", "_links"] }],
+ "no-mixed-operators": 0,
+ "space-before-function-paren": 0,
+ "curly": 0,
+ "arrow-parens": 0,
+ "vue/html-self-closing": [
+ "error",
+ {
+ "html": {
+ "void": "always",
+ "normal": "never",
+ "component": "always"
+ },
+ "svg": "always",
+ "math": "always"
+ }
+ ]
}
}
diff --git a/.flayignore b/.flayignore
index acac0ce14c9..3d69bb2c985 100644
--- a/.flayignore
+++ b/.flayignore
@@ -6,3 +6,6 @@ app/models/concerns/relative_positioning.rb
app/workers/stuck_merge_jobs_worker.rb
lib/gitlab/redis/*.rb
lib/gitlab/gitaly_client/operation_service.rb
+lib/gitlab/background_migration/*
+app/models/project_services/kubernetes_service.rb
+lib/gitlab/workhorse.rb
diff --git a/.gitignore b/.gitignore
index 4933575332b..e9ff0048c1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
*.swp
*.mo
*.edit.po
+*.rej
.DS_Store
.bundle
.chef
@@ -10,6 +11,7 @@
eslint-report.html
/.gitlab_shell_secret
.idea
+/.vscode/*
/.rbenv-version
.rbx/
/.ruby-gemset
@@ -21,6 +23,9 @@ eslint-report.html
/.yarn-cache
/.byebug_history
/Vagrantfile
+/app/assets/images/icons.json
+/app/assets/images/icons.svg
+/app/assets/images/illustrations/
/app/assets/javascripts/locale/**/app.js
/backups/*
/config/aws.yml
@@ -45,6 +50,7 @@ eslint-report.html
/db/data.yml
/doc/code/*
/dump.rdb
+/jsconfig.json
/log/*.log*
/node_modules/
/nohup.out
@@ -65,3 +71,4 @@ eslint-report.html
/locale/**/LC_MESSAGES
/locale/**/*.time_stamp
/.rspec
+/plugins/*
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c26e7f0aeba..4659722854e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,4 +1,4 @@
-image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6"
+image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git-2.17-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6"
.dedicated-runner: &dedicated-runner
retry: 1
@@ -6,7 +6,7 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git
- gitlab-org
.default-cache: &default-cache
- key: "ruby-235-with-yarn"
+ key: "ruby-2.3.7-with-yarn"
paths:
- vendor/ruby
- .yarn-cache/
@@ -35,8 +35,14 @@ variables:
before_script:
- bundle --version
+ - date
- source scripts/utils.sh
+ - date
- source scripts/prepare_build.sh
+ - date
+
+after_script:
+ - date
stages:
- build
@@ -61,6 +67,9 @@ stages:
.use-pg: &use-pg
services:
+ # As of Jan 2018, we don't have a strong reason to upgrade to 9.6 for CI yet,
+ # so using the least common denominator ensures backwards compatibility
+ # (as many users are still using 9.2).
- postgres:9.2
- redis:alpine
@@ -69,6 +78,19 @@ stages:
- mysql:latest
- redis:alpine
+.rails5-variables: &rails5-variables
+ script:
+ - export RAILS5=${RAILS5}
+ - export BUNDLE_GEMFILE=${BUNDLE_GEMFILE}
+
+.rails5: &rails5
+ allow_failure: true
+ only:
+ - /rails5/
+ variables:
+ BUNDLE_GEMFILE: "Gemfile.rails5"
+ RAILS5: "true"
+
# 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
@@ -85,10 +107,31 @@ stages:
- /(^docs[\/-].*|.*-docs$)/
- /(^qa[\/-].*|.*-qa$)/
+# Jobs that only need to pull cache
+.dedicated-no-docs-pull-cache-job: &dedicated-no-docs-pull-cache-job
+ <<: *dedicated-runner
+ <<: *except-docs-and-qa
+ <<: *pull-cache
+ dependencies:
+ - setup-test-env
+ stage: test
+
+# Jobs that do not need a DB
+.dedicated-no-docs-no-db-pull-cache-job: &dedicated-no-docs-no-db-pull-cache-job
+ <<: *dedicated-no-docs-pull-cache-job
+ variables:
+ SETUP_DB: "false"
+
+.rake-exec: &rake-exec
+ <<: *dedicated-no-docs-no-db-pull-cache-job
+ script:
+ - bundle exec rake $CI_JOB_NAME
+
.rspec-metadata: &rspec-metadata
<<: *dedicated-runner
<<: *except-docs-and-qa
<<: *pull-cache
+ <<: *rails5-variables
stage: test
script:
- JOB_NAME=( $CI_JOB_NAME )
@@ -119,14 +162,23 @@ stages:
<<: *rspec-metadata
<<: *use-pg
+.rspec-metadata-pg-rails5: &rspec-metadata-pg-rails5
+ <<: *rspec-metadata-pg
+ <<: *rails5
+
.rspec-metadata-mysql: &rspec-metadata-mysql
<<: *rspec-metadata
<<: *use-mysql
+.rspec-metadata-mysql-rails5: &rspec-metadata-mysql-rails5
+ <<: *rspec-metadata-mysql
+ <<: *rails5
+
.spinach-metadata: &spinach-metadata
<<: *dedicated-runner
<<: *except-docs-and-qa
<<: *pull-cache
+ <<: *rails5-variables
stage: test
script:
- JOB_NAME=( $CI_JOB_NAME )
@@ -150,10 +202,18 @@ stages:
<<: *spinach-metadata
<<: *use-pg
+.spinach-metadata-pg-rails5: &spinach-metadata-pg-rails5
+ <<: *spinach-metadata-pg
+ <<: *rails5
+
.spinach-metadata-mysql: &spinach-metadata-mysql
<<: *spinach-metadata
<<: *use-mysql
+.spinach-metadata-mysql-rails5: &spinach-metadata-mysql-rails5
+ <<: *spinach-metadata-mysql
+ <<: *rails5
+
.only-canonical-masters: &only-canonical-masters
only:
- master@gitlab-org/gitlab-ce
@@ -161,21 +221,23 @@ stages:
- master@gitlab/gitlabhq
- master@gitlab/gitlab-ee
-##
-# Trigger a package build in omnibus-gitlab repository
-#
-package-qa:
- <<: *dedicated-runner
- image: ruby:2.4-alpine
- before_script: []
- stage: build
- cache: {}
- when: manual
+.gitlab-setup: &gitlab-setup
+ <<: *dedicated-no-docs-pull-cache-job
+ <<: *use-pg
+ variables:
+ CREATE_DB_USER: "true"
script:
- - scripts/trigger-build-omnibus
- only:
- - //@gitlab-org/gitlab-ce
- - //@gitlab-org/gitlab-ee
+ # Manually clone gitlab-test and only seed this project in
+ # db/fixtures/development/04_project.rb thanks to SIZE=1 below
+ - git clone https://gitlab.com/gitlab-org/gitlab-test.git
+ /home/git/repositories/gitlab-org/gitlab-test.git
+ - scripts/gitaly-test-spawn
+ - force=yes SIZE=1 FIXTURE_PATH="db/fixtures/development" bundle exec rake gitlab:setup
+ artifacts:
+ when: on_failure
+ expire_in: 1d
+ paths:
+ - log/development.log
# Review docs base
.review-docs: &review-docs
@@ -198,6 +260,57 @@ package-qa:
only:
- branches
+# DB migration, rollback, and seed jobs
+.db-migrate-reset: &db-migrate-reset
+ <<: *dedicated-no-docs-pull-cache-job
+ script:
+ - bundle exec rake db:migrate:reset
+
+.migration-paths: &migration-paths
+ <<: *dedicated-no-docs-pull-cache-job
+ variables:
+ CREATE_DB_USER: "true"
+ script:
+ - git fetch https://gitlab.com/gitlab-org/gitlab-ce.git v9.3.0
+ - git checkout -f FETCH_HEAD
+ - bundle install $BUNDLE_INSTALL_FLAGS
+ - date
+ - cp config/gitlab.yml.example config/gitlab.yml
+ - bundle exec rake db:drop db:create db:schema:load db:seed_fu
+ - date
+ - git checkout $CI_COMMIT_SHA
+ - bundle install $BUNDLE_INSTALL_FLAGS
+ - date
+ - . scripts/prepare_build.sh
+ - date
+ - bundle exec rake db:migrate
+
+##
+# Trigger a package build in omnibus-gitlab repository
+#
+package-and-qa:
+ <<: *dedicated-runner
+ image: ruby:2.4-alpine
+ before_script: []
+ stage: build
+ cache: {}
+ when: manual
+ variables:
+ GIT_STRATEGY: none
+ retry: 0
+ before_script:
+ # We need to download the script rather than clone the repo since the
+ # package-and-qa job will not be able to run when the branch gets
+ # deleted (when merging the MR).
+ - apk add --update openssl
+ - wget https://gitlab.com/$CI_PROJECT_PATH/raw/$CI_COMMIT_SHA/scripts/trigger-build-omnibus
+ - chmod 755 trigger-build-omnibus
+ script:
+ - ./trigger-build-omnibus
+ only:
+ - //@gitlab-org/gitlab-ce
+ - //@gitlab-org/gitlab-ee
+
# Trigger a docs build in gitlab-docs
# Useful to preview the docs changes live
review-docs-deploy:
@@ -262,7 +375,7 @@ update-tests-metadata:
flaky-examples-check:
<<: *dedicated-runner
- image: ruby:2.3-alpine
+ image: ruby:2.4-alpine
services: []
before_script: []
variables:
@@ -287,7 +400,7 @@ flaky-examples-check:
- scripts/merge-reports ${NEW_FLAKY_SPECS_REPORT} rspec_flaky/new_*_*.json
- scripts/detect-new-flaky-examples $NEW_FLAKY_SPECS_REPORT
-setup-test-env:
+compile-assets:
<<: *dedicated-runner
<<: *except-docs
<<: *use-pg
@@ -296,104 +409,173 @@ setup-test-env:
<<: *default-cache
script:
- node --version
+ - date
- yarn install --frozen-lockfile --cache-folder .yarn-cache
+ - date
- 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:
- node_modules
- public/assets
- - tmp/tests
-rspec-pg 0 26: *rspec-metadata-pg
-rspec-pg 1 26: *rspec-metadata-pg
-rspec-pg 2 26: *rspec-metadata-pg
-rspec-pg 3 26: *rspec-metadata-pg
-rspec-pg 4 26: *rspec-metadata-pg
-rspec-pg 5 26: *rspec-metadata-pg
-rspec-pg 6 26: *rspec-metadata-pg
-rspec-pg 7 26: *rspec-metadata-pg
-rspec-pg 8 26: *rspec-metadata-pg
-rspec-pg 9 26: *rspec-metadata-pg
-rspec-pg 10 26: *rspec-metadata-pg
-rspec-pg 11 26: *rspec-metadata-pg
-rspec-pg 12 26: *rspec-metadata-pg
-rspec-pg 13 26: *rspec-metadata-pg
-rspec-pg 14 26: *rspec-metadata-pg
-rspec-pg 15 26: *rspec-metadata-pg
-rspec-pg 16 26: *rspec-metadata-pg
-rspec-pg 17 26: *rspec-metadata-pg
-rspec-pg 18 26: *rspec-metadata-pg
-rspec-pg 19 26: *rspec-metadata-pg
-rspec-pg 20 26: *rspec-metadata-pg
-rspec-pg 21 26: *rspec-metadata-pg
-rspec-pg 22 26: *rspec-metadata-pg
-rspec-pg 23 26: *rspec-metadata-pg
-rspec-pg 24 26: *rspec-metadata-pg
-rspec-pg 25 26: *rspec-metadata-pg
-
-rspec-mysql 0 26: *rspec-metadata-mysql
-rspec-mysql 1 26: *rspec-metadata-mysql
-rspec-mysql 2 26: *rspec-metadata-mysql
-rspec-mysql 3 26: *rspec-metadata-mysql
-rspec-mysql 4 26: *rspec-metadata-mysql
-rspec-mysql 5 26: *rspec-metadata-mysql
-rspec-mysql 6 26: *rspec-metadata-mysql
-rspec-mysql 7 26: *rspec-metadata-mysql
-rspec-mysql 8 26: *rspec-metadata-mysql
-rspec-mysql 9 26: *rspec-metadata-mysql
-rspec-mysql 10 26: *rspec-metadata-mysql
-rspec-mysql 11 26: *rspec-metadata-mysql
-rspec-mysql 12 26: *rspec-metadata-mysql
-rspec-mysql 13 26: *rspec-metadata-mysql
-rspec-mysql 14 26: *rspec-metadata-mysql
-rspec-mysql 15 26: *rspec-metadata-mysql
-rspec-mysql 16 26: *rspec-metadata-mysql
-rspec-mysql 17 26: *rspec-metadata-mysql
-rspec-mysql 18 26: *rspec-metadata-mysql
-rspec-mysql 19 26: *rspec-metadata-mysql
-rspec-mysql 20 26: *rspec-metadata-mysql
-rspec-mysql 21 26: *rspec-metadata-mysql
-rspec-mysql 22 26: *rspec-metadata-mysql
-rspec-mysql 23 26: *rspec-metadata-mysql
-rspec-mysql 24 26: *rspec-metadata-mysql
-rspec-mysql 25 26: *rspec-metadata-mysql
-
-spinach-pg 0 4: *spinach-metadata-pg
-spinach-pg 1 4: *spinach-metadata-pg
-spinach-pg 2 4: *spinach-metadata-pg
-spinach-pg 3 4: *spinach-metadata-pg
-
-spinach-mysql 0 4: *spinach-metadata-mysql
-spinach-mysql 1 4: *spinach-metadata-mysql
-spinach-mysql 2 4: *spinach-metadata-mysql
-spinach-mysql 3 4: *spinach-metadata-mysql
-
-# Static analysis jobs
-.ruby-static-analysis: &ruby-static-analysis
- <<: *pull-cache
- variables:
- SIMPLECOV: "false"
- SETUP_DB: "false"
-
-.rake-exec: &rake-exec
+setup-test-env:
<<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
- <<: *ruby-static-analysis
- stage: test
+ <<: *except-docs
+ <<: *use-pg
+ stage: prepare
+ cache:
+ <<: *default-cache
script:
- - bundle exec rake $CI_JOB_NAME
+ - 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:
+ - tmp/tests
+ - config/secrets.yml
+
+rspec-pg 0 28: *rspec-metadata-pg
+rspec-pg 1 28: *rspec-metadata-pg
+rspec-pg 2 28: *rspec-metadata-pg
+rspec-pg 3 28: *rspec-metadata-pg
+rspec-pg 4 28: *rspec-metadata-pg
+rspec-pg 5 28: *rspec-metadata-pg
+rspec-pg 6 28: *rspec-metadata-pg
+rspec-pg 7 28: *rspec-metadata-pg
+rspec-pg 8 28: *rspec-metadata-pg
+rspec-pg 9 28: *rspec-metadata-pg
+rspec-pg 10 28: *rspec-metadata-pg
+rspec-pg 11 28: *rspec-metadata-pg
+rspec-pg 12 28: *rspec-metadata-pg
+rspec-pg 13 28: *rspec-metadata-pg
+rspec-pg 14 28: *rspec-metadata-pg
+rspec-pg 15 28: *rspec-metadata-pg
+rspec-pg 16 28: *rspec-metadata-pg
+rspec-pg 17 28: *rspec-metadata-pg
+rspec-pg 18 28: *rspec-metadata-pg
+rspec-pg 19 28: *rspec-metadata-pg
+rspec-pg 20 28: *rspec-metadata-pg
+rspec-pg 21 28: *rspec-metadata-pg
+rspec-pg 22 28: *rspec-metadata-pg
+rspec-pg 23 28: *rspec-metadata-pg
+rspec-pg 24 28: *rspec-metadata-pg
+rspec-pg 25 28: *rspec-metadata-pg
+rspec-pg 26 28: *rspec-metadata-pg
+rspec-pg 27 28: *rspec-metadata-pg
+
+rspec-mysql 0 28: *rspec-metadata-mysql
+rspec-mysql 1 28: *rspec-metadata-mysql
+rspec-mysql 2 28: *rspec-metadata-mysql
+rspec-mysql 3 28: *rspec-metadata-mysql
+rspec-mysql 4 28: *rspec-metadata-mysql
+rspec-mysql 5 28: *rspec-metadata-mysql
+rspec-mysql 6 28: *rspec-metadata-mysql
+rspec-mysql 7 28: *rspec-metadata-mysql
+rspec-mysql 8 28: *rspec-metadata-mysql
+rspec-mysql 9 28: *rspec-metadata-mysql
+rspec-mysql 10 28: *rspec-metadata-mysql
+rspec-mysql 11 28: *rspec-metadata-mysql
+rspec-mysql 12 28: *rspec-metadata-mysql
+rspec-mysql 13 28: *rspec-metadata-mysql
+rspec-mysql 14 28: *rspec-metadata-mysql
+rspec-mysql 15 28: *rspec-metadata-mysql
+rspec-mysql 16 28: *rspec-metadata-mysql
+rspec-mysql 17 28: *rspec-metadata-mysql
+rspec-mysql 18 28: *rspec-metadata-mysql
+rspec-mysql 19 28: *rspec-metadata-mysql
+rspec-mysql 20 28: *rspec-metadata-mysql
+rspec-mysql 21 28: *rspec-metadata-mysql
+rspec-mysql 22 28: *rspec-metadata-mysql
+rspec-mysql 23 28: *rspec-metadata-mysql
+rspec-mysql 24 28: *rspec-metadata-mysql
+rspec-mysql 25 28: *rspec-metadata-mysql
+rspec-mysql 26 28: *rspec-metadata-mysql
+rspec-mysql 27 28: *rspec-metadata-mysql
+
+spinach-pg 0 2: *spinach-metadata-pg
+spinach-pg 1 2: *spinach-metadata-pg
+
+spinach-mysql 0 2: *spinach-metadata-mysql
+spinach-mysql 1 2: *spinach-metadata-mysql
+
+rspec-pg-rails5 0 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 1 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 2 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 3 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 4 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 5 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 6 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 7 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 8 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 9 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 10 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 11 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 12 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 13 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 14 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 15 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 16 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 17 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 18 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 19 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 20 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 21 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 22 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 23 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 24 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 25 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 26 28: *rspec-metadata-pg-rails5
+rspec-pg-rails5 27 28: *rspec-metadata-pg-rails5
+
+rspec-mysql-rails5 0 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 1 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 2 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 3 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 4 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 5 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 6 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 7 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 8 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 9 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 10 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 11 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 12 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 13 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 14 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 15 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 16 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 17 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 18 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 19 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 20 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 21 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 22 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 23 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 24 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 25 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 26 28: *rspec-metadata-mysql-rails5
+rspec-mysql-rails5 27 28: *rspec-metadata-mysql-rails5
+
+spinach-pg-rails5 0 2: *spinach-metadata-pg-rails5
+spinach-pg-rails5 1 2: *spinach-metadata-pg-rails5
+
+spinach-mysql-rails5 0 2: *spinach-metadata-mysql-rails5
+spinach-mysql-rails5 1 2: *spinach-metadata-mysql-rails5
static-analysis:
- <<: *dedicated-runner
- <<: *except-docs
- <<: *ruby-static-analysis
- stage: test
+ <<: *dedicated-no-docs-no-db-pull-cache-job
+ dependencies:
+ - compile-assets
+ - setup-test-env
script:
- scripts/static-analysis
+ cache:
+ key: "ruby-2.3.7-with-yarn-and-rubocop"
+ paths:
+ - vendor/ruby
+ - .yarn-cache/
+ - tmp/rubocop_cache
# Documentation checks:
# - Check validity of relative links
@@ -431,6 +613,7 @@ ee_compat_check:
- master
- tags
- /^[\d-]+-stable(-ee)?/
+ - /^security-/
- branches@gitlab-org/gitlab-ee
- branches@gitlab/gitlab-ee
retry: 0
@@ -441,15 +624,6 @@ ee_compat_check:
paths:
- ee_compat_check/patches/*.patch
-# DB migration, rollback, and seed jobs
-.db-migrate-reset: &db-migrate-reset
- <<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
- stage: test
- script:
- - bundle exec rake db:migrate:reset
-
db:migrate:reset-pg:
<<: *db-migrate-reset
<<: *use-pg
@@ -464,25 +638,6 @@ db:check-schema-pg:
script:
- source scripts/schema_changed.sh
-.migration-paths: &migration-paths
- <<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
- stage: test
- variables:
- SETUP_DB: "false"
- CREATE_DB_USER: "true"
- script:
- - git fetch https://gitlab.com/gitlab-org/gitlab-ce.git v9.3.0
- - 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
@@ -492,12 +647,9 @@ migration:path-mysql:
<<: *use-mysql
.db-rollback: &db-rollback
- <<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
- stage: test
+ <<: *dedicated-no-docs-pull-cache-job
script:
- - bundle exec rake db:rollback STEP=119
+ - bundle exec rake db:migrate VERSION=20170523121229
- bundle exec rake db:migrate
db:rollback-pg:
@@ -508,39 +660,17 @@ db:rollback-mysql:
<<: *db-rollback
<<: *use-mysql
-.db-seed_fu: &db-seed_fu
- <<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
- stage: test
- variables:
- SIZE: "1"
- SETUP_DB: "false"
- CREATE_DB_USER: "true"
- script:
- - git clone https://gitlab.com/gitlab-org/gitlab-test.git
- /home/git/repositories/gitlab-org/gitlab-test.git
- - bundle exec rake db:setup db:seed_fu
- artifacts:
- when: on_failure
- expire_in: 1d
- paths:
- - log/development.log
-
-db:seed_fu-pg:
- <<: *db-seed_fu
+gitlab:setup-pg:
+ <<: *gitlab-setup
<<: *use-pg
-db:seed_fu-mysql:
- <<: *db-seed_fu
+gitlab:setup-mysql:
+ <<: *gitlab-setup
<<: *use-mysql
# Frontend-related jobs
gitlab:assets:compile:
- <<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
- stage: test
+ <<: *dedicated-no-docs-no-db-pull-cache-job
dependencies: []
variables:
NODE_ENV: "production"
@@ -550,7 +680,9 @@ gitlab:assets:compile:
WEBPACK_REPORT: "true"
NO_COMPRESSION: "true"
script:
+ - date
- yarn install --frozen-lockfile --production --cache-folder .yarn-cache
+ - date
- bundle exec rake gitlab:assets:compile
artifacts:
name: webpack-report
@@ -559,17 +691,16 @@ gitlab:assets:compile:
- webpack-report/
karma:
- <<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
+ <<: *dedicated-no-docs-pull-cache-job
<<: *use-pg
- stage: test
- variables:
- BABEL_ENV: "coverage"
- CHROME_LOG_FILE: "chrome_debug.log"
+ dependencies:
+ - compile-assets
+ - setup-test-env
script:
+ - export BABEL_ENV=coverage CHROME_LOG_FILE=chrome_debug.log
+ - date
- scripts/gitaly-test-spawn
- - bundle exec rake gettext:po_to_json
+ - date
- bundle exec rake karma
coverage: '/^Statements *: (\d+\.\d+%)/'
artifacts:
@@ -577,50 +708,68 @@ karma:
expire_in: 31d
when: always
paths:
- - chrome_debug.log
- - coverage-javascript/
+ - chrome_debug.log
+ - coverage-javascript/
codequality:
- <<: *except-docs
- <<: *pull-cache
+ <<: *dedicated-no-docs-no-db-pull-cache-job
+ image: docker:stable
+ allow_failure: true
+ # gitlab-org runners set `privileged: false` but we need to have it set to true
+ # since we're using Docker in Docker
+ tags: []
before_script: []
- image: docker:latest
- stage: test
- variables:
- SETUP_DB: "false"
- DOCKER_DRIVER: overlay
services:
- docker:dind
+ variables:
+ SETUP_DB: "false"
+ DOCKER_DRIVER: overlay2
+ cache: {}
+ dependencies: []
script:
- - 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
+ # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products
+ - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
+ - docker run --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
artifacts:
paths: [codeclimate.json]
+ expire_in: 1 week
-qa:internal:
- <<: *dedicated-runner
+sast:
<<: *except-docs
- stage: test
+ image: registry.gitlab.com/gitlab-org/gl-sast:latest
variables:
- SETUP_DB: "false"
+ CONFIDENCE_LEVEL: 2
+ before_script: []
+ script:
+ - /app/bin/run .
+ artifacts:
+ paths: [gl-sast-report.json]
+
+qa:internal:
+ <<: *dedicated-no-docs-no-db-pull-cache-job
services: []
script:
- cd qa/
- bundle install
- bundle exec rspec
+qa:selectors:
+ <<: *dedicated-no-docs-no-db-pull-cache-job
+ services: []
+ script:
+ - cd qa/
+ - bundle install
+ - bundle exec bin/qa Test::Sanity::Selectors
+
coverage:
+ # Don't include dedicated-no-docs-no-db-pull-cache-job here since we need to
+ # download artifacts from all the rspec jobs instead of from setup-test-env only
<<: *dedicated-runner
<<: *except-docs-and-qa
<<: *pull-cache
- stage: post-test
- services: []
variables:
SETUP_DB: "false"
- USE_BUNDLE_INSTALL: "true"
+ stage: post-test
script:
- bundle exec scripts/merge-simplecov
coverage: '/LOC \((\d+\.\d+%)\) covered.$/'
@@ -632,25 +781,25 @@ coverage:
- coverage/assets/
lint:javascript:report:
- <<: *dedicated-runner
- <<: *except-docs-and-qa
- <<: *pull-cache
+ <<: *dedicated-no-docs-no-db-pull-cache-job
stage: post-test
dependencies:
+ - compile-assets
- setup-test-env
before_script: []
script:
+ - date
- find app/ spec/ -name '*.js' -exec sed --in-place 's|/\* eslint-disable .*\*/||' {} \; # run report over all files
+ - date
- yarn run eslint-report || true # ignore exit code
artifacts:
name: eslint-report
expire_in: 31d
paths:
- - eslint-report.html
+ - eslint-report.html
pages:
- <<: *dedicated-runner
- <<: *pull-cache
+ <<: *dedicated-no-docs-no-db-pull-cache-job
before_script: []
stage: pages
dependencies:
@@ -675,12 +824,7 @@ pages:
# Insurance in case a gem needed by one of our releases gets yanked from
# rubygems.org in the future.
cache gems:
- <<: *dedicated-runner
- <<: *pull-cache
- only:
- - tags
- variables:
- SETUP_DB: "false"
+ <<: *dedicated-no-docs-no-db-pull-cache-job
script:
- bundle package --all --all-platforms
artifacts:
@@ -689,12 +833,14 @@ cache gems:
only:
- master@gitlab-org/gitlab-ce
- master@gitlab-org/gitlab-ee
+ - tags
gitlab_git_test:
<<: *dedicated-runner
<<: *except-docs-and-qa
- <<: *pull-cache
variables:
SETUP_DB: "false"
+ before_script: []
+ cache: {}
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 aec734870d6..3e58d2a867e 100644
--- a/.gitlab/issue_templates/Bug.md
+++ b/.gitlab/issue_templates/Bug.md
@@ -1,3 +1,4 @@
+<!---
Please read this!
Before opening a new issue, make sure to search for keywords in the issues
@@ -14,10 +15,7 @@ For the Enterprise Edition issue tracker:
- 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
diff --git a/.gitlab/merge_request_templates/Database Changes.md b/.gitlab/merge_request_templates/Database Changes.md
index 8302b3b30c7..68bc0fd1c7f 100644
--- a/.gitlab/merge_request_templates/Database Changes.md
+++ b/.gitlab/merge_request_templates/Database Changes.md
@@ -33,13 +33,16 @@ When removing columns, tables, indexes or other 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)
+- [ ] [Changelog entry](https://docs.gitlab.com/ee/development/changelog.html) added, if necessary
+- [ ] [Documentation created/updated](https://docs.gitlab.com/ee/development/doc_styleguide.html)
- [ ] API support added
- [ ] Tests added for this feature/bug
- Review
- [ ] 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)
+- [ ] Conform by the [merge request performance guides](https://docs.gitlab.com/ee/development/merge_request_performance_guidelines.html)
+- [ ] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ee/blob/master/CONTRIBUTING.md#style-guides)
- [ ] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits)
+- [ ] Internationalization required/considered
+- [ ] If paid feature, have we considered GitLab.com plan and how it works for groups and is there a design for promoting it to users who aren't on the correct plan
+- [ ] End-to-end tests pass (`package-qa` manual pipeline job)
diff --git a/.gitlab/merge_request_templates/Documentation.md b/.gitlab/merge_request_templates/Documentation.md
index 9b541aadad1..da38a703c3c 100644
--- a/.gitlab/merge_request_templates/Documentation.md
+++ b/.gitlab/merge_request_templates/Documentation.md
@@ -1,14 +1,29 @@
-See the general Documentation guidelines http://docs.gitlab.com/ce/development/doc_styleguide.html
+<!--See the general Documentation guidelines https://docs.gitlab.com/ce/development/writing_documentation.html -->
## What does this MR do?
-(briefly describe what this MR is about)
+<!-- Briefly describe what this MR is about -->
+
+## Related issues
+
+<!-- Mention the issue(s) this MR closes or is related to -->
+
+Closes
## Moving docs to a new location?
-See the guidelines: http://docs.gitlab.com/ce/development/doc_styleguide.html#changing-document-location
+Read the guidelines:
+https://docs.gitlab.com/ce/development/writing_documentation.html#changing-document-location
-- [ ] Make sure the old link is not removed and has its contents replaced with a link to the new location.
+- [ ] Make sure the old link is not removed and has its contents replaced with
+ a link to the new location.
- [ ] Make sure internal links pointing to the document in question are not broken.
-- [ ] Search and replace any links referring to old docs in GitLab Rails app, specifically under the `app/views/` directory.
-- [ ] If working on CE, submit an MR to EE with the changes as well.
+- [ ] Search and replace any links referring to old docs in GitLab Rails app,
+ specifically under the `app/views/` and `ee/app/views` (for GitLab EE) directories.
+- [ ] Make sure to add [`redirect_from`](https://docs.gitlab.com/ce/development/writing_documentation.html#redirections-for-pages-with-disqus-comments)
+ to the new document if there are any Disqus comments on the old document thread.
+- [ ] If working on CE and the `ee-compat-check` jobs fails, submit an MR to EE
+ with the changes as well (https://docs.gitlab.com/ce/development/writing_documentation.html#cherry-picking-from-ce-to-ee).
+- [ ] Ping one of the technical writers for review.
+
+/label ~Documentation
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000000..b674ccd50cf
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,5 @@
+/app/assets/javascripts/locale/**/app.js
+/node_modules/
+/public/
+/vendor/
+/tmp/
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000000..3384551aea5
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,13 @@
+{
+ "printWidth": 100,
+ "singleQuote": true,
+ "trailingComma": "es5",
+ "overrides": [
+ {
+ "files": ["**/app/**/*", "**/spec/**/*"],
+ "options": {
+ "trailingComma": "all"
+ }
+ }
+ ]
+}
diff --git a/.rubocop.yml b/.rubocop.yml
index 7721cfaf850..0582bfe8d70 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,462 +1,24 @@
-require:
- - rubocop-rspec
- - rubocop-gitlab-security
- - ./rubocop/rubocop
+inherit_gem:
+ gitlab-styles:
+ - rubocop-default.yml
inherit_from: .rubocop_todo.yml
+require: ./rubocop/rubocop
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.
- DisplayCopNames: true
- # Style guide URLs are not displayed in offense messages by default. Change
- # behavior by overriding DisplayStyleGuide, or by giving the
- # -S/--display-style-guide option.
- DisplayStyleGuide: false
- # Exclude some GitLab files
Exclude:
- 'vendor/**/*'
- 'node_modules/**/*'
- - 'db/*'
+ - 'db/**/*'
- 'db/fixtures/**/*'
+ - 'ee/db/**/*'
- 'tmp/**/*'
- 'bin/**/*'
- 'generator_templates/**/*'
- 'builds/**/*'
-
-# Gems in consecutive lines should be alphabetically sorted
-Bundler/OrderedGems:
- Enabled: false
-
-# Layout ######################################################################
-
-# Check indentation of private/protected visibility modifiers.
-Layout/AccessModifierIndentation:
- Enabled: true
-
-# Align the elements of an array literal if they span more than one line.
-Layout/AlignArray:
- Enabled: true
-
-# Align the elements of a hash literal if they span more than one line.
-Layout/AlignHash:
- Enabled: true
-
-# Here we check if the parameters on a multi-line method call or
-# definition are aligned.
-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:
- Enabled: true
-
-# Use `Array#join` instead of `Array#*`.
-Style/ArrayJoin:
- Enabled: true
-
-# Use only ascii symbols in comments.
-Style/AsciiComments:
- Enabled: true
-
-# Use only ascii symbols in identifiers.
-Style/AsciiIdentifiers:
- Enabled: true
-
-# Checks for uses of Module#attr.
-Style/Attr:
- Enabled: true
-
-# Avoid the use of BEGIN blocks.
-Style/BeginBlock:
- Enabled: true
-
-# Do not use block comments.
-Style/BlockComments:
- Enabled: true
-
-# Avoid using {...} for multi-line blocks (multiline chaining is # always
-# ugly). Prefer {...} over do...end for single-line blocks.
-Style/BlockDelimiters:
- Enabled: true
-
- # This cop checks for braces around the last parameter in a method call
-# if the last parameter is a hash.
-Style/BracesAroundHashParameters:
- Enabled: false
-
-# This cop checks for uses of the case equality operator(===).
-Style/CaseEquality:
- Enabled: false
-
-# Checks for uses of character literals.
-Style/CharacterLiteral:
- Enabled: true
-
-# Use CamelCase for classes and modules.'
-Style/ClassAndModuleCamelCase:
- Enabled: true
-
-# Checks style of children classes and modules.
-Style/ClassAndModuleChildren:
- Enabled: false
-
-# Enforces consistent use of `Object#is_a?` or `Object#kind_of?`.
-Style/ClassCheck:
- Enabled: true
-
-# Use self when defining module/class methods.
-Style/ClassMethods:
- Enabled: true
-
-# Avoid the use of class variables.
-Style/ClassVars:
- Enabled: true
-
-# This cop checks for methods invoked via the :: operator instead
-# of the . operator (like FileUtils::rmdir instead of FileUtils.rmdir).
-Style/ColonMethodCall:
- Enabled: true
-
-# This cop checks that comment annotation keywords are written according
-# to guidelines.
-Style/CommentAnnotation:
- Enabled: false
-
-# 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.
-Style/ConditionalAssignment:
- Enabled: true
-
-# Constants should use SCREAMING_SNAKE_CASE.
-Style/ConstantName:
- Enabled: true
-
-# Use def with parentheses when there are arguments.
-Style/DefWithParentheses:
- Enabled: true
-
-# Document classes and non-namespace modules.
-Style/Documentation:
- Enabled: false
-
-# This cop checks for uses of double negation (!!) to convert something
-# to a boolean value. As this is both cryptic and usually redundant, it
-# should be avoided.
-Style/DoubleNegation:
- Enabled: false
-
-# Avoid the use of END blocks.
-Style/EndBlock:
- Enabled: true
-
-# Favor the use of Fixnum#even? && Fixnum#odd?
-Style/EvenOdd:
- Enabled: true
-
-# Use snake_case for source file names.
-Style/FileName:
- Enabled: true
-
-# Checks for flip flops.
-Style/FlipFlop:
- Enabled: true
-
-# Checks use of for or each in multiline loops.
-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
-
-# Do not introduce global variables.
-Style/GlobalVars:
- Enabled: true
- Exclude:
- - 'lib/backup/**/*'
- - 'lib/tasks/**/*'
-
-# Prefer Ruby 1.9 hash syntax `{ a: 1, b: 2 }`
-# over 1.8 syntax `{ :a => 1, :b => 2 }`.
-Style/HashSyntax:
- Enabled: true
-
-# Checks that conditional statements do not have an identical line at the
-# end of each branch, which can validly be moved out of the conditional.
-Style/IdenticalConditionalBranches:
- Enabled: true
-
-# Do not use if x; .... Use the ternary operator instead.
-Style/IfWithSemicolon:
- 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
-
-# Checks if the method definitions have or don't have parentheses.
-Style/MethodDefParentheses:
- Enabled: true
-
-# Use the configured style when naming methods.
-Style/MethodName:
- Enabled: true
-
-# Checks for usage of `extend self` in modules.
-Style/ModuleFunction:
- Enabled: false
-
-# Avoid multi-line chains of blocks.
-Style/MultilineBlockChain:
- Enabled: true
-
-# Do not use then for multi-line if/unless.
-Style/MultilineIfThen:
- Enabled: true
-
-# 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
+ - 'plugins/**/*'
+ CacheRootDirectory: tmp
# This cop checks whether some constant value isn't a
# mutable literal (e.g. array or hash).
@@ -465,725 +27,81 @@ Style/MutableConstant:
Exclude:
- 'db/migrate/**/*'
- 'db/post_migrate/**/*'
+ - 'ee/db/migrate/**/*'
+ - 'ee/db/post_migrate/**/*'
+ - 'ee/db/geo/migrate/**/*'
-# Favor unless over if for negative conditions (or control flow or).
-Style/NegatedIf:
- Enabled: true
-
-# Avoid using nested modifiers.
-Style/NestedModifier:
- Enabled: true
-
-# Use one expression per branch in a ternary operator.
-Style/NestedTernaryOperator:
- Enabled: true
-
-# Prefer x.nil? to x == nil.
-Style/NilComparison:
- Enabled: true
-
-# Checks for redundant nil checks.
-Style/NonNilCheck:
- Enabled: true
-
-# Use ! instead of not.
-Style/Not:
- Enabled: true
-
-# Add underscores to large numeric literals to improve their readability.
-Style/NumericLiterals:
- Enabled: false
-
-# Favor the ternary operator(?:) over if/then/else/end constructs.
-Style/OneLineConditional:
- Enabled: true
-
-# When defining binary operators, name the argument other.
-Style/OpMethod:
- Enabled: true
-
-# Don't use parentheses around the condition of an if/unless/while.
-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
-
-# Checks for parentheses that seem not to serve any purpose.
-Style/RedundantParentheses:
- Enabled: true
-
-# Don't use semicolons to terminate expressions.
-Style/Semicolon:
- Enabled: true
-
-# Checks for proper usage of fail and raise.
-Style/SignalException:
- EnforcedStyle: only_raise
- Enabled: true
-
-# Check for the usage of parentheses around stabby lambda arguments.
-Style/StabbyLambdaParentheses:
- EnforcedStyle: require_parentheses
- Enabled: true
-
-# Checks if uses of quotes match the configured preference.
-Style/StringLiterals:
- Enabled: false
-
-# Checks if configured preferred methods are used over non-preferred.
-Style/StringMethods:
- PreferredMethods:
- intern: to_sym
- 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: 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:
- Enabled: true
-
-# Checks for %q/%Q when single quotes or double quotes would do.
-Style/UnneededPercentQ:
- Enabled: false
-
-# Don't interpolate global, instance and class variables directly in strings.
-Style/VariableInterpolation:
- Enabled: true
-
-# Use the configured style when naming variables.
-Style/VariableName:
- EnforcedStyle: snake_case
- Enabled: true
-
-# Use the configured style when numbering variables.
-Style/VariableNumber:
- Enabled: false
-
-# Use when x then ... for one-line cases.
-Style/WhenThen:
- Enabled: true
-
-# Checks for redundant do after while or until.
-Style/WhileUntilDo:
- Enabled: true
-
-# Favor modifier while/until usage when you have a single-line body.
-Style/WhileUntilModifier:
- Enabled: true
-
-# Use %w or %W for arrays of words.
-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
-
-# Use `spam?` instead of `is_spam?`
-# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist.
-# NamePrefix: is_, has_, have_
-# NamePrefixBlacklist: is_, has_, have_
-# NameWhitelist: is_a?
-Style/PredicateName:
- Enabled: true
- NamePrefixBlacklist: is_
+Naming/FileName:
+ ExpectMatchingDefinition: true
Exclude:
- 'spec/**/*'
- 'features/**/*'
-
-# Metrics #####################################################################
-
-# A calculated magnitude based on number of assignments,
-# branches, and conditions.
-Metrics/AbcSize:
- Enabled: true
- Max: 54.28
-
-# This cop checks if the length of a block exceeds some maximum value.
-Metrics/BlockLength:
- Enabled: false
-
-# Avoid excessive block nesting.
-Metrics/BlockNesting:
- Enabled: true
- Max: 4
-
-# Avoid classes longer than 100 lines of code.
-Metrics/ClassLength:
- Enabled: false
-
-# A complexity metric that is strongly correlated to the number
-# of test cases needed to validate a method.
-Metrics/CyclomaticComplexity:
- Enabled: true
- Max: 13
-
-# Limit lines to 80 characters.
-Metrics/LineLength:
- Enabled: false
-
-# Avoid methods longer than 10 lines of code.
-Metrics/MethodLength:
- Enabled: false
-
-# Avoid modules longer than 100 lines of code.
-Metrics/ModuleLength:
- Enabled: false
-
-# Avoid parameter lists longer than three or four parameters.
-Metrics/ParameterLists:
- Enabled: true
- Max: 8
-
-# A complexity metric geared towards measuring complexity for a human reader.
-Metrics/PerceivedComplexity:
- Enabled: true
- Max: 14
-
-# 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:
- Enabled: true
-
-# This cop checks for ambiguous regexp literals in the first argument of
-# a method invocation without parentheses.
-Lint/AmbiguousRegexpLiteral:
- Enabled: false
-
-# This cop checks for assignments in the conditions of
-# if/while/until.
-Lint/AssignmentInCondition:
- Enabled: false
-
-# Align block ends correctly.
-Lint/BlockAlignment:
- Enabled: true
-
-# Default values in optional keyword arguments and optional ordinal arguments
-# should not refer back to the name of the argument.
-Lint/CircularArgumentReference:
- Enabled: true
-
-# Checks for condition placed in a confusing position relative to the keyword.
-Lint/ConditionPosition:
- Enabled: true
-
-# Check for debugger calls.
-Lint/Debugger:
- Enabled: true
-
-# Align ends corresponding to defs correctly.
-Lint/DefEndAlignment:
- Enabled: true
-
-# Check for deprecated class method calls.
-Lint/DeprecatedClassMethods:
- Enabled: true
-
-# Check for immutable argument given to each_with_object.
-Lint/EachWithObjectArgument:
- Enabled: true
-
-# Check for odd code arrangement in an else block.
-Lint/ElseLayout:
- Enabled: true
-
-# Checks for empty ensure block.
-Lint/EmptyEnsure:
- Enabled: true
-
-# Checks for the presence of `when` branches without a body.
-Lint/EmptyWhen:
- Enabled: true
-
-# Align ends correctly.
-Lint/EndAlignment:
- Enabled: true
-
-# END blocks should not be placed inside method definitions.
-Lint/EndInMethod:
- Enabled: true
-
-# Do not use return in an ensure block.
-Lint/EnsureReturn:
- Enabled: true
-
-# Catches floating-point literals too large or small for Ruby to represent.
-Lint/FloatOutOfRange:
- Enabled: true
-
-# The number of parameters to format/sprint must match the fields.
-Lint/FormatParameterMismatch:
- Enabled: true
-
-# This cop checks for *rescue* blocks with no body.
-Lint/HandleExceptions:
- Enabled: false
-
-# Checks for adjacent string literals on the same line, which could better be
-# represented as a single string literal.
-Lint/ImplicitStringConcatenation:
- Enabled: true
-
-# Checks for attempts to use `private` or `protected` to set the visibility
-# of a class method, which does not work.
-Lint/IneffectiveAccessModifier:
- Enabled: false
-
-# Checks for invalid character literals with a non-escaped whitespace
-# character.
-Lint/InvalidCharacterLiteral:
- Enabled: true
-
-# Checks of literals used in conditions.
-Lint/LiteralInCondition:
- Enabled: true
-
-# Checks for literals used in interpolation.
-Lint/LiteralInInterpolation:
- Enabled: true
-
-# This cop checks for uses of *begin...end while/until something*.
-Lint/Loop:
- Enabled: false
-
-# Do not use nested method definitions.
-Lint/NestedMethodDefinition:
- Enabled: true
-
-# Do not omit the accumulator when calling `next` in a `reduce`/`inject` block.
-Lint/NextWithoutAccumulator:
- Enabled: true
-
-# Checks for method calls with a space before the opening parenthesis.
-Lint/ParenthesesAsGroupedExpression:
- Enabled: true
-
-# Checks for `rand(1)` calls. Such calls always return `0` and most likely
-# a mistake.
-Lint/RandOne:
- Enabled: true
-
-# Use parentheses in the method call to avoid confusion about precedence.
-Lint/RequireParentheses:
- Enabled: true
-
-# Avoid rescuing the Exception class.
-Lint/RescueException:
- Enabled: true
-
-# Checks for the order which exceptions are rescued to avoid rescueing a less specific exception before a more specific exception.
-Lint/ShadowedException:
- Enabled: false
-
-# This cop looks for use of the same name as outer local variables
-# for block arguments or block local variables.
-Lint/ShadowingOuterLocalVariable:
- Enabled: false
-
-# Checks for Object#to_s usage in string interpolation.
-Lint/StringConversionInInterpolation:
- Enabled: true
-
-# Do not use prefix `_` for a variable that is used.
-Lint/UnderscorePrefixedVariableName:
- Enabled: true
-
-# This cop checks for using Fixnum or Bignum constant
-Lint/UnifiedInteger:
- Enabled: true
-
-# Checks for rubocop:disable comments that can be removed.
-# Note: this cop is not disabled when disabling all cops.
-# It must be explicitly disabled.
-Lint/UnneededDisable:
- Enabled: false
-
-# This cop checks for unneeded usages of splat expansion
-Lint/UnneededSplatExpansion:
- Enabled: false
-
-# Unreachable code.
-Lint/UnreachableCode:
- Enabled: true
-
-# This cop checks for unused block arguments.
-Lint/UnusedBlockArgument:
- Enabled: false
-
-# This cop checks for unused method arguments.
-Lint/UnusedMethodArgument:
- Enabled: false
-
-# Checks for useless access modifiers.
-Lint/UselessAccessModifier:
- Enabled: true
-
-# Checks for useless assignment to a local variable.
-Lint/UselessAssignment:
- Enabled: true
-
-# Checks for comparison of something with itself.
-Lint/UselessComparison:
- Enabled: true
-
-# Checks for useless `else` in `begin..end` without `rescue`.
-Lint/UselessElseWithoutRescue:
- Enabled: true
-
-# Checks for useless setter call to a local variable.
-Lint/UselessSetterCall:
- Enabled: true
-
-# Possible use of operator/literal/variable in void context.
-Lint/Void:
- Enabled: true
-
-# Performance #################################################################
-
-# Use `caller(n..n)` instead of `caller`.
-Performance/Caller:
- Enabled: false
-
-# Use `casecmp` rather than `downcase ==`.
-Performance/Casecmp:
- Enabled: true
-
-# Use `str.{start,end}_with?(x, ..., y, ...)` instead of
-# `str.{start,end}_with?(x, ...) || str.{start,end}_with?(y, ...)`.
-Performance/DoubleStartEndWith:
- Enabled: true
-
-# Use `strip` instead of `lstrip.rstrip`.
-Performance/LstripRstrip:
- Enabled: true
-
-# Use `Range#cover?` instead of `Range#include?`.
-Performance/RangeInclude:
- Enabled: true
-
-# This cop identifies the use of a `&block` parameter and `block.call`
-# where `yield` would do just as well.
-Performance/RedundantBlockCall:
- Enabled: true
-
-# This cop identifies use of `Regexp#match` or `String#match in a context
-# where the integral return value of `=~` would do just as well.
-Performance/RedundantMatch:
- Enabled: true
-
-# This cop identifies places where `Hash#merge!` can be replaced by
-# `Hash#[]=`.
-Performance/RedundantMerge:
- Enabled: true
- MaxKeyValuePairs: 1
-
-# Use `sort` instead of `sort_by { |x| x }`.
-Performance/RedundantSortBy:
- Enabled: true
-
-# Use `start_with?` instead of a regex match anchored to the beginning of a
-# string.
-Performance/StartWith:
- Enabled: true
-
-# Use `tr` instead of `gsub` when you are replacing the same number of
-# characters. Use `delete` instead of `gsub` when you are deleting
-# characters.
-Performance/StringReplacement:
- Enabled: true
-
-# Checks for `.times.map` calls.
-Performance/TimesMap:
- Enabled: true
-
-# Security ####################################################################
-
-# This cop checks for the use of JSON class methods which have potential
-# security issues.
-Security/JSONLoad:
- Enabled: true
-
-# This cop checks for the use of *Kernel#eval*.
-Security/Eval:
- Enabled: true
-
-# Rails #######################################################################
-
-# Enables Rails cops.
-Rails:
- Enabled: true
-
-# Enforces consistent use of action filter methods.
-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: false
-
-# This cop checks dynamic `find_by_*` methods.
-Rails/DynamicFindBy:
- Enabled: false
-
-# This cop enforces that 'exit' calls are not used within a rails app.
-Rails/Exit:
- Enabled: true
- Exclude:
- - lib/gitlab/upgrader.rb
- - 'lib/backup/**/*'
-
-# Prefer `find_by` over `where.first`.
-Rails/FindBy:
- Enabled: true
-
-# Prefer `all.find_each` over `all.find`.
-Rails/FindEach:
- Enabled: true
-
-# Prefer has_many :through to has_and_belongs_to_many.
-Rails/HasAndBelongsToMany:
- Enabled: true
-
-# This cop is used to identify usages of http methods like `get`, `post`,
-# `put`, `patch` without the usage of keyword arguments in your tests and
-# change them to use keyword args.
-Rails/HttpPositionalArguments:
- Enabled: false
-
-# Checks for calls to puts, print, etc.
-Rails/Output:
- Enabled: true
- Exclude:
- - lib/gitlab/seeder.rb
- - lib/gitlab/upgrader.rb
- - 'lib/backup/**/*'
- - 'lib/tasks/**/*'
-
-# This cop checks for the use of output safety calls like html_safe and
-# raw.
-Rails/OutputSafety:
- Enabled: false
-
-# Checks for incorrect grammar when using methods like `3.day.ago`.
-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
-
-# This cop checks for the use of Time methods without zone.
-Rails/TimeZone:
- Enabled: false
-
-# This cop checks for the use of old-style attribute validation macros.
-Rails/Validation:
- Enabled: true
-
-# RSpec #######################################################################
-
-# Check that instances are not being stubbed globally.
-RSpec/AnyInstance:
- Enabled: false
-
-# Check for expectations where `be(...)` can replace `eql(...)`.
-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
-
-# Checks that the second argument to `describe` specifies a method.
-RSpec/DescribeMethod:
- Enabled: false
-
-# Avoid describing symbols.
-RSpec/DescribeSymbol:
- Enabled: true
-
-# Checks that tests use `described_class`.
-RSpec/DescribedClass:
- 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:
- Enabled: false
- Max: 5
-
-# Do not use should when describing your tests.
-RSpec/ExampleWording:
- Enabled: false
- CustomTransform:
- be: is
- have: has
- not: does not
- IgnoredWords: []
-
-# Checks for `expect(...)` calls containing literal values.
-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: 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
-
-# Checks for `subject` definitions that come after `let` definitions.
-RSpec/LeadingSubject:
- Enabled: false
-
-# Checks unreferenced `let!` calls being used for test setup.
-RSpec/LetSetup:
- Enabled: false
-
-# Check that chains of messages are not being stubbed.
-RSpec/MessageChain:
- Enabled: false
-
-# Checks that message expectations are set using spies.
-RSpec/MessageSpies:
- Enabled: false
-
-# Checks for multiple top-level describes.
-RSpec/MultipleDescribes:
- Enabled: false
-
-# Checks if examples contain too many `expect` calls.
-RSpec/MultipleExpectations:
- Enabled: false
-
-# Checks for explicitly referenced test subjects.
-RSpec/NamedSubject:
- Enabled: false
-
-# Checks for nested example groups.
-RSpec/NestedGroups:
- Enabled: false
-
-# Enforces the usage of the same method on all negative message expectations.
-RSpec/NotToNot:
- EnforcedStyle: not_to
- Enabled: true
-
-# Check for repeated description strings in example groups.
-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
-
-# Prefer using verifying doubles over normal doubles.
-RSpec/VerifiedDoubles:
- Enabled: false
+ - 'ee/spec/**/*'
+ - 'qa/spec/**/*'
+ - 'qa/qa/specs/**/*'
+ - 'qa/bin/*'
+ - 'config/**/*'
+ - 'lib/generators/**/*'
+ - 'ee/lib/generators/**/*'
+ IgnoreExecutableScripts: true
+ AllowedAcronyms:
+ - EE
+ - JSON
+ - LDAP
+ - IO
+ - HMAC
+ - QA
+ - ENV
+ - STL
+ - PDF
+ - SVG
+ - CTE
+ - DN
+ - RSA
+ - CI
+ - CD
+ - OAuth
+ # default ones:
+ - CLI
+ - DSL
+ - ACL
+ - API
+ - ASCII
+ - CPU
+ - CSS
+ - DNS
+ - EOF
+ - GUID
+ - HTML
+ - HTTP
+ - HTTPS
+ - ID
+ - IP
+ - JSON
+ - LHS
+ - QPS
+ - RAM
+ - RHS
+ - RPC
+ - SLA
+ - SMTP
+ - SQL
+ - SSH
+ - TCP
+ - TLS
+ - TTL
+ - UDP
+ - UI
+ - UID
+ - UUID
+ - URI
+ - URL
+ - UTF8
+ - VM
+ - XML
+ - XMPP
+ - XSRF
+ - XSS
# Gitlab ###################################################################
@@ -1192,23 +110,16 @@ Gitlab/ModuleWithInstanceVariables:
Exclude:
# We ignore Rails helpers right now because it's hard to workaround it
- app/helpers/**/*_helper.rb
+ - ee/app/helpers/**/*_helper.rb
# We ignore Rails mailers right now because it's hard to workaround it
- app/mailers/emails/**/*.rb
+ - ee/**/emails/**/*.rb
# We ignore spec helpers because it usually doesn't matter
- spec/support/**/*.rb
- features/steps/**/*.rb
-# GitlabSecurity ###########################################################
-
-GitlabSecurity/DeepMunge:
+Gitlab/HTTParty:
Enabled: true
- Exclude:
- - 'lib/**/*.rake'
- - 'spec/**/*'
-
-# To be enabled by https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13610
-GitlabSecurity/JsonSerialization:
- Enabled: false
GitlabSecurity/PublicSend:
Enabled: true
@@ -1219,21 +130,6 @@ GitlabSecurity/PublicSend:
- '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/**/*'
+ - 'ee/db/**/*'
+ - 'ee/lib/**/*.rake'
+ - 'ee/spec/**/*'
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index cdf97d1d842..d443238b9e1 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,53 +1,101 @@
# This configuration was generated by
-# `rubocop --auto-gen-config --exclude-limit 0`
-# on 2017-07-10 01:48:30 +0900 using RuboCop version 0.49.1.
+# `rubocop --auto-gen-config`
+# on 2018-01-18 18:23:26 +0100 using RuboCop version 0.52.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: 181
+Capybara/CurrentPathExpectation:
+ Enabled: false
+
+# Offense count: 956
+Capybara/FeatureMethods:
+ Enabled: false
+
+# Offense count: 23
+FactoryBot/DynamicAttributeDefinedStatically:
+ Exclude:
+ - 'spec/factories/broadcast_messages.rb'
+ - 'spec/factories/ci/builds.rb'
+ - 'spec/factories/ci/runners.rb'
+ - 'spec/factories/clusters/applications/helm.rb'
+ - 'spec/factories/clusters/platforms/kubernetes.rb'
+ - 'spec/factories/emails.rb'
+ - 'spec/factories/gpg_keys.rb'
+ - 'spec/factories/group_members.rb'
+ - 'spec/factories/merge_requests.rb'
+ - 'spec/factories/notes.rb'
+ - 'spec/factories/oauth_access_grants.rb'
+ - 'spec/factories/project_members.rb'
+ - 'spec/factories/todos.rb'
+ - 'spec/factories/uploads.rb'
+
+# Offense count: 167
+# Cop supports --auto-correct.
+Layout/EmptyLinesAroundArguments:
+ Enabled: false
+
+# Offense count: 253
# Cop supports --auto-correct.
# Configuration parameters: AllowForAlignment, ForceEqualSignAlignment.
Layout/ExtraSpacing:
Enabled: false
-# Offense count: 119
+# Offense count: 83
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth.
+# Configuration parameters: EnforcedStyle, IndentationWidth.
# SupportedStyles: special_inside_parentheses, consistent, align_brackets
Layout/IndentArray:
Enabled: false
-# Offense count: 208
+# Offense count: 237
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth.
+# Configuration parameters: EnforcedStyle, IndentationWidth.
# SupportedStyles: special_inside_parentheses, consistent, align_braces
Layout/IndentHash:
Enabled: false
-# Offense count: 8
+# Offense count: 11
# Cop supports --auto-correct.
# Configuration parameters: AllowForAlignment.
Layout/SpaceBeforeFirstArg:
- Enabled: false
-
-# Offense count: 64
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+ Exclude:
+ - 'config/routes/project.rb'
+ - 'db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb'
+ - 'features/steps/project/source/browse_files.rb'
+ - 'features/steps/project/source/markdown_render.rb'
+ - 'lib/api/runners.rb'
+ - 'spec/features/search/user_uses_search_filters_spec.rb'
+ - 'spec/routing/project_routing_spec.rb'
+ - 'spec/services/system_note_service_spec.rb'
+
+# Offense count: 93
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: require_no_space, require_space
Layout/SpaceInLambdaLiteral:
Enabled: false
-# Offense count: 256
+# Offense count: 1
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBrackets.
+# SupportedStyles: space, no_space, compact
+# SupportedStylesForEmptyBrackets: space, no_space
+Layout/SpaceInsideArrayLiteralBrackets:
+ Exclude:
+ - 'spec/lib/gitlab/import_export/relation_factory_spec.rb'
+
+# Offense count: 327
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces, SpaceBeforeBlockParameters.
+# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters.
# SupportedStyles: space, no_space
# SupportedStylesForEmptyBraces: space, no_space
Layout/SpaceInsideBlockBraces:
Enabled: false
-# Offense count: 135
+# Offense count: 156
# Cop supports --auto-correct.
Layout/SpaceInsideParens:
Enabled: false
@@ -55,178 +103,530 @@ Layout/SpaceInsideParens:
# Offense count: 14
# Cop supports --auto-correct.
Layout/SpaceInsidePercentLiteralDelimiters:
+ Exclude:
+ - 'lib/gitlab/git_access.rb'
+ - 'lib/gitlab/health_checks/fs_shards_check.rb'
+ - 'spec/lib/gitlab/health_checks/fs_shards_check_spec.rb'
+
+# Offense count: 26
+Lint/DuplicateMethods:
+ Exclude:
+ - 'app/models/application_setting.rb'
+ - 'app/models/commit.rb'
+ - 'app/models/note.rb'
+ - 'app/services/merge_requests/merge_service.rb'
+ - 'lib/bitbucket/representation/repo.rb'
+ - 'lib/declarative_policy/base.rb'
+ - 'lib/gitlab/ci/build/artifacts/metadata/entry.rb'
+ - 'lib/gitlab/cycle_analytics/base_event_fetcher.rb'
+ - 'lib/gitlab/diff/formatters/base_formatter.rb'
+ - 'lib/gitlab/git/blob.rb'
+ - 'lib/gitlab/git/repository.rb'
+ - 'lib/gitlab/git/tree.rb'
+ - 'lib/gitlab/git/wiki_page.rb'
+ - 'lib/gitlab/auth/ldap/person.rb'
+ - 'lib/gitlab/auth/o_auth/user.rb'
+
+# Offense count: 4
+Lint/InterpolationCheck:
+ Exclude:
+ - 'spec/features/issues/filtered_search/filter_issues_spec.rb'
+ - 'spec/features/users_spec.rb'
+ - 'spec/services/quick_actions/interpret_service_spec.rb'
+
+# Offense count: 206
+# Configuration parameters: MaximumRangeSize.
+Lint/MissingCopEnableDirective:
+ Enabled: false
+
+# Offense count: 2
+Lint/NestedPercentLiteral:
+ Exclude:
+ - 'lib/gitlab/git/repository.rb'
+ - 'spec/support/email_format_shared_examples.rb'
+
+# Offense count: 1
+Lint/ReturnInVoidContext:
+ Exclude:
+ - 'app/models/project.rb'
+
+# Offense count: 1
+# Configuration parameters: IgnoreImplicitReferences.
+Lint/ShadowedArgument:
+ Exclude:
+ - 'lib/gitlab/database/sha_attribute.rb'
+
+# Offense count: 3
+# Cop supports --auto-correct.
+Lint/UnneededRequireStatement:
+ Exclude:
+ - 'db/post_migrate/20161221153951_rename_reserved_project_names.rb'
+ - 'db/post_migrate/20170313133418_rename_more_reserved_project_names.rb'
+ - 'lib/declarative_policy.rb'
+
+# Offense count: 9
+Lint/UriEscapeUnescape:
+ Exclude:
+ - 'app/controllers/application_controller.rb'
+ - 'app/models/project_services/drone_ci_service.rb'
+ - 'spec/lib/google_api/auth_spec.rb'
+ - 'spec/requests/api/files_spec.rb'
+ - 'spec/requests/api/internal_spec.rb'
+ - 'spec/requests/api/issues_spec.rb'
+ - 'spec/requests/api/v3/issues_spec.rb'
+
+# Offense count: 1
+# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
+# URISchemes: http, https
+Metrics/LineLength:
+ Max: 1310
+
+# Offense count: 2
+Naming/ConstantName:
+ Exclude:
+ - 'lib/gitlab/import_sources.rb'
+ - 'lib/gitlab/ssh_public_key.rb'
+
+# Offense count: 11
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: lowercase, uppercase
+Naming/HeredocDelimiterCase:
+ Exclude:
+ - 'spec/lib/gitlab/diff/parser_spec.rb'
+ - 'spec/lib/json_web_token/rsa_token_spec.rb'
+ - 'spec/models/commit_spec.rb'
+ - 'spec/support/repo_helpers.rb'
+ - 'spec/support/seed_repo.rb'
+
+# Offense count: 112
+# Configuration parameters: Blacklist.
+# Blacklist: END, (?-mix:EO[A-Z]{1})
+Naming/HeredocDelimiterNaming:
+ Enabled: false
+
+# Offense count: 27
+# Cop supports --auto-correct.
+# Configuration parameters: AutoCorrect.
+Performance/HashEachMethods:
+ Enabled: false
+
+# Offense count: 1
+Performance/UnfreezeString:
+ Exclude:
+ - 'features/steps/project/commits/commits.rb'
+
+# Offense count: 1
+# Cop supports --auto-correct.
+Performance/UriDefaultParser:
+ Exclude:
+ - 'lib/gitlab/url_sanitizer.rb'
+
+# Offense count: 3821
+# Configuration parameters: Prefixes.
+# Prefixes: when, with, without
+RSpec/ContextWording:
Enabled: false
-# Offense count: 272
+# Offense count: 293
RSpec/EmptyLineAfterFinalLet:
Enabled: false
-# Offense count: 181
+# Offense count: 188
RSpec/EmptyLineAfterSubject:
Enabled: false
-# Offense count: 9
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+# Offense count: 258
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: method_call, block
+RSpec/ExpectChange:
+ Enabled: false
+
+# Offense count: 221
+RSpec/ExpectInHook:
+ Enabled: false
+
+# Offense count: 7
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: implicit, each, example
+RSpec/HookArgument:
+ Exclude:
+ - 'spec/spec_helper.rb'
+ - 'spec/support/carrierwave.rb'
+ - 'spec/support/db_cleaner.rb'
+ - 'spec/support/gitaly.rb'
+ - 'spec/support/setup_builds_storage.rb'
+
+# Offense count: 19
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: it_behaves_like, it_should_behave_like
RSpec/ItBehavesLike:
- Enabled: false
+ Exclude:
+ - 'spec/lib/gitlab/git/commit_spec.rb'
+ - 'spec/lib/gitlab/git/repository_spec.rb'
+ - 'spec/lib/gitlab/shell_spec.rb'
+ - 'spec/services/notification_service_spec.rb'
+ - 'spec/workers/git_garbage_collect_worker_spec.rb'
-# Offense count: 4
+# Offense count: 5
RSpec/IteratedExpectation:
- Enabled: false
+ Exclude:
+ - 'spec/features/admin/admin_settings_spec.rb'
+ - 'spec/features/merge_requests/diff_notes_resolve_spec.rb'
+ - 'spec/features/projects/awards/user_interacts_with_awards_in_issue_spec.rb'
+ - 'spec/lib/gitlab/gitlab_import/client_spec.rb'
+ - 'spec/lib/gitlab/legacy_github_import/client_spec.rb'
+
+# Offense count: 75
+RSpec/LetBeforeExamples:
+ Exclude:
+ - 'spec/controllers/projects/commit_controller_spec.rb'
+ - 'spec/lib/banzai/filter/issue_reference_filter_spec.rb'
+ - 'spec/lib/banzai/filter/user_reference_filter_spec.rb'
+ - 'spec/lib/gitlab/email/handler/create_issue_handler_spec.rb'
+ - 'spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb'
+ - 'spec/lib/gitlab/email/handler/create_note_handler_spec.rb'
+ - 'spec/models/commit_range_spec.rb'
+ - 'spec/models/milestone_spec.rb'
+ - 'spec/models/project_services/packagist_service_spec.rb'
+ - 'spec/models/repository_spec.rb'
+ - 'spec/rubocop/cop/migration/update_column_in_batches_spec.rb'
+ - 'spec/serializers/pipeline_details_entity_spec.rb'
+ - 'spec/views/ci/lints/show.html.haml_spec.rb'
-# Offense count: 2
+# Offense count: 1
+RSpec/MultipleSubjects:
+ Exclude:
+ - 'spec/services/merge_requests/create_from_issue_service_spec.rb'
+
+# Offense count: 4
RSpec/OverwritingSetup:
+ Exclude:
+ - 'spec/lib/gitlab/background_migration/migrate_events_to_push_event_payloads_spec.rb'
+ - 'spec/models/email_spec.rb'
+ - 'spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb'
+ - 'spec/services/notes/quick_actions_service_spec.rb'
+
+# Offense count: 965
+# Configuration parameters: Strict, EnforcedStyle.
+# SupportedStyles: inflected, explicit
+RSpec/PredicateMatcher:
Enabled: false
-# Offense count: 36
+# Offense count: 35
RSpec/RepeatedExample:
Enabled: false
-# Offense count: 86
+# Offense count: 140
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: and_return, block
+RSpec/ReturnFromStub:
+ Enabled: false
+
+# Offense count: 112
RSpec/ScatteredLet:
Enabled: false
-# Offense count: 20
+# Offense count: 22
RSpec/ScatteredSetup:
- Enabled: false
+ Exclude:
+ - 'spec/controllers/projects/templates_controller_spec.rb'
+ - 'spec/lib/gitlab/bitbucket_import/importer_spec.rb'
+ - 'spec/lib/gitlab/git/env_spec.rb'
+ - 'spec/requests/api/jobs_spec.rb'
+ - 'spec/requests/api/v3/builds_spec.rb'
+ - 'spec/requests/api/v3/projects_spec.rb'
+ - 'spec/services/projects/create_service_spec.rb'
# Offense count: 1
RSpec/SharedContext:
+ Exclude:
+ - 'spec/features/admin/admin_groups_spec.rb'
+
+# Offense count: 5
+RSpec/VoidExpect:
+ Exclude:
+ - 'spec/features/projects/artifacts/download_spec.rb'
+ - 'spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb'
+ - 'spec/models/ci/group_spec.rb'
+ - 'spec/models/ci/runner_spec.rb'
+ - 'spec/services/users/destroy_service_spec.rb'
+
+# Offense count: 41
+# Configuration parameters: Include.
+# Include: db/migrate/*.rb
+Rails/CreateTableWithTimestamps:
Enabled: false
-# Offense count: 115
+# Offense count: 155
Rails/FilePath:
Enabled: false
+# Offense count: 121
+# Configuration parameters: Include.
+# Include: app/models/**/*.rb
+Rails/HasManyOrHasOneDependent:
+ Enabled: false
+
+# Offense count: 157
+# Configuration parameters: Include.
+# Include: app/models/**/*.rb
+Rails/InverseOf:
+ Enabled: false
+
+# Offense count: 48
+# Configuration parameters: Include.
+# Include: app/controllers/**/*.rb
+Rails/LexicallyScopedActionFilter:
+ Enabled: false
+
+# Offense count: 14
+# Cop supports --auto-correct.
+Rails/Presence:
+ Exclude:
+ - 'app/controllers/projects/blob_controller.rb'
+ - 'app/models/ci/pipeline.rb'
+ - 'app/models/clusters/platforms/kubernetes.rb'
+ - 'app/models/concerns/mentionable.rb'
+ - 'app/models/concerns/token_authenticatable.rb'
+ - 'app/models/project_services/hipchat_service.rb'
+ - 'app/models/project_services/irker_service.rb'
+ - 'app/models/project_services/jira_service.rb'
+ - 'app/models/project_services/kubernetes_service.rb'
+ - 'app/models/project_services/packagist_service.rb'
+ - 'app/models/wiki_page.rb'
+ - 'lib/gitlab/git/hook.rb'
+ - 'lib/gitlab/github_import/importer/releases_importer.rb'
+
# Offense count: 2
# Configuration parameters: Include.
# Include: db/migrate/*.rb
Rails/ReversibleMigration:
- Enabled: false
+ Exclude:
+ - 'db/migrate/20160824103857_drop_unused_ci_tables.rb'
-# Offense count: 336
+# Offense count: 446
# 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: 11
+# Offense count: 1
+# Configuration parameters: Environments.
+# Environments: development, test, production
+Rails/UnknownEnv:
+ Exclude:
+ - 'db/migrate/20171124125748_populate_missing_merge_request_statuses.rb'
+
+# Offense count: 13
# Cop supports --auto-correct.
Security/YAMLLoad:
- Enabled: false
+ Exclude:
+ - 'config/initializers/carrierwave.rb'
+ - 'lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb'
+ - 'lib/gitlab/redis/wrapper.rb'
+ - 'lib/system_check/incoming_email/imap_authentication_check.rb'
+ - 'spec/config/mail_room_spec.rb'
+ - 'spec/initializers/secret_token_spec.rb'
+ - 'spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb'
+ - 'spec/models/clusters/platforms/kubernetes_spec.rb'
+ - 'spec/models/project_services/kubernetes_service_spec.rb'
-# Offense count: 58
+# Offense count: 64
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: percent_q, bare_percent
Style/BarePercentLiterals:
Enabled: false
-# Offense count: 6
-# Cop supports --auto-correct.
-Style/EachWithObject:
+# Offense count: 5
+Style/CommentedKeyword:
+ Exclude:
+ - 'lib/tasks/gitlab/backup.rake'
+ - 'spec/tasks/gitlab/backup_rake_spec.rb'
+
+# Offense count: 30
+Style/DateTime:
Enabled: false
-# Offense count: 31
+# Offense count: 1
+# Cop supports --auto-correct.
+Style/Dir:
+ Exclude:
+ - 'qa/qa.rb'
+
+# Offense count: 9
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+Style/EachWithObject:
+ Exclude:
+ - 'config/initializers/gollum.rb'
+ - 'lib/expand_variables.rb'
+ - 'lib/gitlab/ci/ansi2html.rb'
+ - 'lib/gitlab/ee_compat_check.rb'
+ - 'lib/gitlab/hook_data/issuable_builder.rb'
+ - 'lib/gitlab/i18n/po_linter.rb'
+ - 'lib/gitlab/import_export/members_mapper.rb'
+ - 'lib/gitlab/import_export/relation_factory.rb'
+ - 'scripts/static-analysis'
+
+# Offense count: 24
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: empty, nil, both
Style/EmptyElse:
Enabled: false
-# Offense count: 9
+# Offense count: 14
# Cop supports --auto-correct.
-Style/EmptyLiteral:
- Enabled: false
+Style/EmptyLambdaParameter:
+ Exclude:
+ - 'app/models/ci/build.rb'
+ - 'app/models/ci/runner.rb'
-# Offense count: 78
+# Offense count: 12
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+Style/EmptyLiteral:
+ Exclude:
+ - 'features/steps/project/commits/commits.rb'
+ - 'lib/gitlab/fogbugz_import/importer.rb'
+ - 'lib/gitlab/git/diff_collection.rb'
+ - 'lib/gitlab/gitaly_client.rb'
+ - 'scripts/trigger-build-omnibus'
+ - 'spec/features/merge_requests/versions_spec.rb'
+ - 'spec/helpers/merge_requests_helper_spec.rb'
+ - 'spec/lib/gitlab/request_context_spec.rb'
+ - 'spec/lib/gitlab/workhorse_spec.rb'
+ - 'spec/requests/api/jobs_spec.rb'
+ - 'spec/support/chat_slash_commands_shared_examples.rb'
+
+# Offense count: 102
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: compact, expanded
Style/EmptyMethod:
Enabled: false
# Offense count: 23
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+Style/Encoding:
+ Enabled: false
+
+# Offense count: 2
+Style/EvalWithLocation:
+ Exclude:
+ - 'app/models/service.rb'
+
+# Offense count: 35
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: format, sprintf, percent
Style/FormatString:
Enabled: false
-# Offense count: 301
+# Offense count: 384
# Configuration parameters: MinBodyLength.
Style/GuardClause:
Enabled: false
-# Offense count: 18
+# Offense count: 22
Style/IfInsideElse:
Enabled: false
-# Offense count: 182
+# Offense count: 809
# Cop supports --auto-correct.
-# Configuration parameters: MaxLineLength.
Style/IfUnlessModifier:
Enabled: false
-# Offense count: 52
+# Offense count: 75
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: line_count_dependent, lambda, literal
Style/Lambda:
Enabled: false
-# Offense count: 6
+# Offense count: 11
# Cop supports --auto-correct.
Style/LineEndConcatenation:
- Enabled: false
+ Exclude:
+ - 'app/helpers/tree_helper.rb'
+ - 'spec/features/issuables/markdown_references_spec.rb'
+ - 'spec/lib/gitlab/checks/project_moved_spec.rb'
+ - 'spec/lib/gitlab/gfm/reference_rewriter_spec.rb'
+ - 'spec/lib/gitlab/incoming_email_spec.rb'
-# Offense count: 40
+# Offense count: 39
# Cop supports --auto-correct.
Style/MethodCallWithoutArgsParentheses:
Enabled: false
-# Offense count: 13
+# Offense count: 18
Style/MethodMissing:
Enabled: false
+# Offense count: 7
+Style/MixinUsage:
+ Exclude:
+ - 'features/support/env.rb'
+ - 'spec/factories/ci/builds.rb'
+ - 'spec/factories/ci/job_artifacts.rb'
+ - 'spec/factories/lfs_objects.rb'
+ - 'spec/factories/notes.rb'
+ - 'spec/lib/gitlab/import_export/project_tree_restorer_spec.rb'
+ - 'spec/lib/gitlab/import_export/version_checker_spec.rb'
+
# Offense count: 6
# Cop supports --auto-correct.
Style/MultilineIfModifier:
- Enabled: false
-
-# Offense count: 26
-# Cop supports --auto-correct.
+ Exclude:
+ - 'app/helpers/snippets_helper.rb'
+ - 'app/models/project_wiki.rb'
+ - 'app/services/ci/process_pipeline_service.rb'
+ - 'app/services/create_deployment_service.rb'
+ - 'lib/api/commit_statuses.rb'
+ - 'lib/gitlab/ci/trace.rb'
+
+# Offense count: 25
+# Cop supports --auto-correct.
+# Configuration parameters: Whitelist.
+# Whitelist: be, be_a, be_an, be_between, be_falsey, be_kind_of, be_instance_of, be_truthy, be_within, eq, eql, end_with, include, match, raise_error, respond_to, start_with
Style/NestedParenthesizedCalls:
Enabled: false
-# Offense count: 20
+# Offense count: 19
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, MinBodyLength, SupportedStyles.
+# Configuration parameters: EnforcedStyle, MinBodyLength.
# SupportedStyles: skip_modifier_ifs, always
Style/Next:
Enabled: false
-# Offense count: 45
+# Offense count: 61
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles.
+# Configuration parameters: EnforcedOctalStyle.
# SupportedOctalStyles: zero_with_o, zero_only
Style/NumericLiteralPrefix:
Enabled: false
-# Offense count: 98
+# Offense count: 114
# Cop supports --auto-correct.
-# Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles.
+# Configuration parameters: AutoCorrect, EnforcedStyle.
# SupportedStyles: predicate, comparison
Style/NumericPredicate:
Enabled: false
-# Offense count: 42
+# Offense count: 4
+# Cop supports --auto-correct.
+Style/OrAssignment:
+ Exclude:
+ - 'app/models/concerns/token_authenticatable.rb'
+ - 'lib/api/commit_statuses.rb'
+ - 'lib/api/v3/members.rb'
+ - 'lib/gitlab/project_transfer.rb'
+
+# Offense count: 50
# Cop supports --auto-correct.
Style/ParallelAssignment:
Enabled: false
-# Offense count: 800
+# Offense count: 917
# Cop supports --auto-correct.
# Configuration parameters: PreferredDelimiters.
Style/PercentLiteralDelimiters:
@@ -235,106 +635,196 @@ Style/PercentLiteralDelimiters:
# Offense count: 15
# Cop supports --auto-correct.
Style/PerlBackrefs:
- Enabled: false
-
-# Offense count: 58
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+ Exclude:
+ - 'app/controllers/projects/application_controller.rb'
+ - 'app/helpers/submodule_helper.rb'
+ - 'lib/backup/manager.rb'
+ - 'lib/banzai/filter/abstract_reference_filter.rb'
+ - 'lib/banzai/filter/autolink_filter.rb'
+ - 'lib/banzai/filter/emoji_filter.rb'
+ - 'lib/banzai/filter/gollum_tags_filter.rb'
+ - 'lib/expand_variables.rb'
+ - 'lib/gitlab/diff/highlight.rb'
+ - 'lib/gitlab/search_results.rb'
+ - 'lib/gitlab/sherlock/query.rb'
+
+# Offense count: 87
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: compact, exploded
Style/RaiseArgs:
Enabled: false
-# Offense count: 6
+# Offense count: 9
# Cop supports --auto-correct.
Style/RedundantBegin:
- Enabled: false
+ Exclude:
+ - 'app/controllers/projects/clusters/gcp_controller.rb'
+ - 'app/models/merge_request.rb'
+ - 'app/services/projects/import_service.rb'
+ - 'lib/api/branches.rb'
+ - 'lib/gitlab/current_settings.rb'
+ - 'lib/gitlab/git/commit.rb'
+ - 'lib/gitlab/health_checks/base_abstract_check.rb'
+ - 'lib/tasks/gitlab/task_helpers.rb'
-# Offense count: 37
+# Offense count: 1
+# Cop supports --auto-correct.
+Style/RedundantConditional:
+ Exclude:
+ - 'lib/system_check/helpers.rb'
+
+# Offense count: 57
# Cop supports --auto-correct.
Style/RedundantFreeze:
Enabled: false
-# Offense count: 14
+# Offense count: 15
# Cop supports --auto-correct.
# Configuration parameters: AllowMultipleReturnValues.
Style/RedundantReturn:
- Enabled: false
-
-# Offense count: 406
+ Exclude:
+ - 'app/controllers/application_controller.rb'
+ - 'app/controllers/concerns/issuable_actions.rb'
+ - 'app/controllers/groups/application_controller.rb'
+ - 'app/controllers/omniauth_callbacks_controller.rb'
+ - 'app/controllers/profiles/keys_controller.rb'
+ - 'app/controllers/projects/application_controller.rb'
+ - 'app/services/access_token_validation_service.rb'
+ - 'lib/gitlab/utils.rb'
+ - 'lib/google_api/auth.rb'
+
+# Offense count: 460
# Cop supports --auto-correct.
Style/RedundantSelf:
Enabled: false
-# Offense count: 115
+# Offense count: 142
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes.
+# Configuration parameters: EnforcedStyle, AllowInnerSlashes.
# SupportedStyles: slashes, percent_r, mixed
Style/RegexpLiteral:
- Enabled: false
+ Enabled: true
+ EnforcedStyle: mixed
+ AllowInnerSlashes: false
-# Offense count: 29
+# Offense count: 36
# Cop supports --auto-correct.
Style/RescueModifier:
Enabled: false
+# Offense count: 107
+# Cop supports --auto-correct.
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: implicit, explicit
+Style/RescueStandardError:
+ Enabled: false
+
+# Offense count: 92
+# Cop supports --auto-correct.
+# Configuration parameters: ConvertCodeThatCanStartToReturnNil.
+Style/SafeNavigation:
+ Enabled: false
+
# Offense count: 8
# Cop supports --auto-correct.
Style/SelfAssignment:
- Enabled: false
+ Exclude:
+ - 'app/models/concerns/bulk_member_access_load.rb'
+ - 'app/serializers/base_serializer.rb'
+ - 'app/services/notification_service.rb'
+ - 'lib/api/runners.rb'
+ - 'spec/features/merge_requests/diff_notes_resolve_spec.rb'
+ - 'spec/features/projects/clusters/interchangeability_spec.rb'
+ - 'spec/support/import_export/configuration_helper.rb'
# Offense count: 50
# Cop supports --auto-correct.
# Configuration parameters: AllowIfMethodIsEmpty.
Style/SingleLineMethods:
- Enabled: false
+ Exclude:
+ - 'lib/gitlab/ci/ansi2html.rb'
-# Offense count: 64
+# Offense count: 66
# Cop supports --auto-correct.
-# Configuration parameters: SupportedStyles.
+# Configuration parameters: .
# SupportedStyles: use_perl_names, use_english_names
Style/SpecialGlobalVars:
EnforcedStyle: use_perl_names
-# Offense count: 44
+# Offense count: 1
+# Cop supports --auto-correct.
+Style/StderrPuts:
+ Exclude:
+ - 'config/initializers/rspec_profiling.rb'
+
+# Offense count: 45
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
+# Configuration parameters: EnforcedStyle.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiteralsInInterpolation:
Enabled: false
-# Offense count: 84
+# Offense count: 106
# Cop supports --auto-correct.
# Configuration parameters: IgnoredMethods.
# IgnoredMethods: respond_to, define_method
Style/SymbolProc:
Enabled: false
-# Offense count: 8
+# Offense count: 9
# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles, AllowSafeAssignment.
+# Configuration parameters: EnforcedStyle, AllowSafeAssignment.
# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex
Style/TernaryParentheses:
- Enabled: false
+ Exclude:
+ - 'app/finders/projects_finder.rb'
+ - 'app/helpers/namespaces_helper.rb'
+ - 'features/support/capybara.rb'
+ - 'lib/api/v3/projects.rb'
+ - 'lib/gitlab/ci/build/artifacts/metadata/entry.rb'
+ - 'spec/requests/api/pipeline_schedules_spec.rb'
+ - 'spec/support/capybara.rb'
# Offense count: 17
# Cop supports --auto-correct.
# Configuration parameters: AllowNamedUnderscoreVariables.
Style/TrailingUnderscoreVariable:
- Enabled: false
+ Exclude:
+ - 'app/controllers/admin/background_jobs_controller.rb'
+ - 'app/controllers/invites_controller.rb'
+ - 'app/helpers/tab_helper.rb'
+ - 'lib/backup/manager.rb'
+ - 'lib/gitlab/logger.rb'
+ - 'lib/gitlab/upgrader.rb'
+ - 'lib/system_check/app/migrations_are_up_check.rb'
+ - 'lib/system_check/incoming_email/mail_room_running_check.rb'
+ - 'lib/tasks/gitlab/check.rake'
+ - 'lib/tasks/gitlab/task_helpers.rb'
+ - 'spec/lib/gitlab/etag_caching/middleware_spec.rb'
+ - 'spec/services/quick_actions/interpret_service_spec.rb'
-# Offense count: 4
+# Offense count: 5
# 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
+ Exclude:
+ - 'app/models/external_issue.rb'
+ - 'app/serializers/base_serializer.rb'
+ - 'lib/gitlab/auth/ldap/person.rb'
+ - 'lib/system_check/base_check.rb'
-# Offense count: 5
+# Offense count: 4
# Cop supports --auto-correct.
Style/UnlessElse:
- Enabled: false
+ Exclude:
+ - 'lib/backup/manager.rb'
+ - 'lib/gitlab/project_search_results.rb'
+ - 'lib/tasks/gitlab/check.rake'
+ - 'spec/features/issues/award_emoji_spec.rb'
-# Offense count: 28
+# Offense count: 31
# Cop supports --auto-correct.
Style/UnneededInterpolation:
Enabled: false
@@ -342,4 +832,19 @@ Style/UnneededInterpolation:
# Offense count: 11
# Cop supports --auto-correct.
Style/ZeroLengthPredicate:
- Enabled: false
+ Exclude:
+ - 'app/models/deploy_key.rb'
+ - 'app/models/network/commit.rb'
+ - 'app/models/network/graph.rb'
+ - 'app/models/project_services/asana_service.rb'
+ - 'app/services/boards/create_service.rb'
+ - 'app/services/merge_requests/conflicts/list_service.rb'
+ - 'lib/declarative_policy/dsl.rb'
+ - 'lib/extracts_path.rb'
+ - 'lib/gitlab/git/repository.rb'
+
+# Offense count: 22840
+# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
+# URISchemes: http, https
+Metrics/LineLength:
+ Max: 1310
diff --git a/.ruby-version b/.ruby-version
index cc6c9a491e0..00355e29d11 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-2.3.5
+2.3.7
diff --git a/.scss-lint.yml b/.scss-lint.yml
index dcd4cac780a..180d377d6f8 100644
--- a/.scss-lint.yml
+++ b/.scss-lint.yml
@@ -59,6 +59,8 @@ linters:
# Reports when you define the same property twice in a single rule set.
DuplicateProperty:
enabled: true
+ ignore_consecutive:
+ - cursor
# Separate rule, function, and mixin declarations with empty lines.
EmptyLineBetweenBlocks:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd7825b5f82..9109f04fb17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,1024 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 10.6.3 (2018-04-03)
+
+### Security (2 changes)
+
+- Fix XSS on diff view stored on filenames.
+- Adds confidential notes channel for Slack/Mattermost.
+
+
+## 10.6.2 (2018-03-29)
+
+### Fixed (2 changes, 1 of them is from the community)
+
+- Don't capture trailing punctuation when autolinking. !17965
+- Cloning a repository over HTTPS with LDAP credentials causes a HTTP 401 Access denied. (Horatiu Eugen Vlad)
+
+
+## 10.6.1 (2018-03-27)
+
+### Security (1 change)
+
+- Bump rails-html-sanitizer to 1.0.4.
+
+### Fixed (2 changes)
+
+- Prevent auto-retry AccessDenied error from stopping transition to failed. !17862
+- Fix 500 error when trying to resolve non-ASCII conflicts in the editor. !17962
+
+### Performance (1 change)
+
+- Add indexes for user activity queries. !17890
+
+### Other (1 change)
+
+- Add documentation for runner IP address (#44232). !17837
+
+
+## 10.6.0 (2018-03-22)
+
+### Security (4 changes)
+
+- Fixed some SSRF vulnerabilities in services, hooks and integrations. !2337
+- Ensure that OTP backup codes are always invalidated.
+- Add verification for GitLab Pages custom domains.
+- Fix GitLab Auth0 integration signing in the wrong user.
+
+### Fixed (75 changes, 17 of them are from the community)
+
+- Ensure users cannot create environments with leading or trailing slashes (Fixes #39885). !15273
+- Fix new project path input overlapping. !16755 (George Tsiolis)
+- Respect description and visibility when creating project from template. !16820 (George Tsiolis)
+- Remove user notification settings for groups and projects when user leaves. !16906 (Jacopo Beschi @jacopo-beschi)
+- Fix Teleporting Emoji. !16963 (Jared Deckard <jared.deckard@gmail.com>)
+- Fix duplicate system notes when merging a merge request. !17035
+- Fix breadcrumb on labels page for groups. !17045 (Onuwa Nnachi Isaac)
+- Fix user avatar's vertical align on the issues and merge requests pages. !17072 (Laszlo Karpati)
+- Fix settings panels not expanding when fragment hash linked. !17074
+- Fix 404 when listing archived projects in a group where all projects have been archived. !17077 (Ashley Dumaine)
+- Allow to call PUT /projects/:id API with only ci_config_path specified. !17105 (Laszlo Karpati)
+- Fix long list of recipients on group request membership email. !17121 (Jacopo Beschi @jacopo-beschi)
+- Remove duplicated error message on duplicate variable validation. !17135
+- Keep "Import project" tab/form active when validation fails trying to import "Repo by URL". !17136
+- Fixed bug with unauthenticated requests through git ssh. !17149
+- Allows project rename after validation error. !17150
+- Fix "Remove source branch" button in Merge request widget during merge when pipeline succeeds state. !17192
+- Add missing pagination on the commit diff endpoint. !17203 (Maxime Roussin-Bélanger)
+- Fix get a single pages domain when project path contains a period. !17206 (Travis Miller)
+- remove avater underline. !17219 (Ken Ding)
+- Allows the usage of /milestone quick action for group milestones. !17239 (Jacopo Beschi @jacopo-beschi)
+- Encode branch name as binary before creating a RPC request to copy attributes. !17291
+- Restart Unicorn and Sidekiq when GRPC throws 14:Endpoint read failed. !17293
+- Do not persist Google Project verification flash errors after a page reload. !17299
+- Ensure group issues and merge requests pages show results from subgroups when there are no results from the current group. !17312
+- Prevent trace artifact migration to incur data loss. !17313
+- Fixes gpg popover layout. !17323
+- Return a 404 instead of 403 if the repository does not exist on disk. !17341
+- Fix Slack/Mattermost notifications not respecting `notify_only_default_branch` setting for pushes. !17345
+- Fix Group labels load failure when there are duplicate labels present. !17353
+- Allow Prometheus application to be installed from Cluster applications. !17372
+- Fixes Prometheus admin configuration page. !17377
+- Enable filtering MR list based on clicked label in MR sidebar. !17390
+- Fix code and wiki search results pages when non-ASCII text is displayed. !17413
+- Count comments on diffs and discussions as contributions for the contributions calendar. !17418 (Riccardo Padovani)
+- Add Assignees vue component missing data container. !17426 (George Tsiolis)
+- Update tooltip on pipeline cancel to Stop (#42946). !17444
+- Removing the two factor check when the user sets a new password. !17457
+- Fix quick actions for users who cannot update issues and merge requests. !17482
+- Stop loading spinner on error of milestone update on issue. !17507 (Takuya Noguchi)
+- Set margins around dropdown dividers to 4px. !17517
+- Fix pages flaky failure by reloading stale object. !17522
+- Remove extra breadcrumb on tags. !17562 (Takuya Noguchi)
+- Fix missing uploads after group transfer. !17658
+- Fix markdown table showing extra column. !17669
+- Ensure the API returns https links when https is configured. !17681
+- Sanitize extra blank spaces used when uploading a SSH key. !40552
+- Render htmlentities correctly for links not supported by Rinku.
+- Keep link when redacting unauthorized object links.
+- Handle empty state in Pipelines page.
+- Revert Project.public_or_visible_to_user changes and only apply to snippets.
+- Release libgit2 cache and open file descriptors after `git gc` run.
+- Fix project dashboard showing the wrong timestamps.
+- Fix "Can't modify frozen hash" error when project is destroyed.
+- Fix Error 500 when viewing a commit with a GPG signature in Geo.
+- Don't error out in system hook if user has `nil` datetime columns.
+- Remove double caching of Repository#empty?.
+- Don't delete todos or unassign issues and MRs when a user leaves a project.
+- Don't cache a nil repository root ref to prevent caching issues.
+- Escape HTML entities in commit messages.
+- Verify project import status again before marking as failed.
+- [GitHub Import] Create an empty wiki if wiki import failed.
+- Create empty wiki when import from GitLab and wiki is not there.
+- Make sure wiki exists when it's enabled.
+- Fix broken loading state for close issue button.
+- Fix code and wiki search results when filename is non-ASCII.
+- Fix file upload on project show page.
+- Fix squashing when a file is renamed.
+- Show loading button inline in refresh button in MR widget.
+- Fix close button on issues not working on mobile.
+- Adds tooltip in environment names to increase readability.
+- Fixed issue edit shortcut not opening edit form.
+- Fix 500 error being shown when diff has context marker with invalid encoding.
+- Render modified icon for moved file in changes dropdown.
+- Remember assignee when moving an issue.
+
+### Changed (16 changes, 9 of them are from the community)
+
+- Allow including custom attributes in API responses. !16526 (Markus Koller)
+- Apply new default and inline label design. !16956 (George Tsiolis)
+- Remove whitespace from the username/email sign in form field. !17020 (Peter lauck)
+- CI charts now include the current day. !17032 (Dakkaron)
+- Hide CI secret variable values after saving. !17044
+- Add new modal Vue component. !17108
+- Asciidoc now support inter-document cross references between files in repository. !17125 (Turo Soisenniemi)
+- Update issue closing pattern to allow variations in punctuation. !17198 (Vicky Chijwani)
+- Add a button to deploy a runner to a Kubernetes cluster in the settings page. !17278
+- Pages custom domain: allow update of key/certificate. !17376 (rfwatson)
+- Clear the Labels dropdown search filter after a selection is made. !17393 (Andrew Torres)
+- Hook data for pipelines includes detailed_status. !17607
+- Avoid showing unnecessary Trigger checkboxes for project Integrations with only one event. !17607
+- Display a link to external issue tracker when enabled.
+- Allow token authentication on go-get request.
+- Update SSH key link to include existing keys. (Brendan O'Leary)
+
+### Performance (24 changes, 5 of them are from the community)
+
+- Add catch-up background migration to migrate pipeline stages. !15741
+- Move BoardNewIssue vue component. !16947 (George Tsiolis)
+- Move IssuableTimeTracker vue component. !16948 (George Tsiolis)
+- Move RecentSearchesDropdownContent vue component. !16951 (George Tsiolis)
+- Move Assignees vue component. !16952 (George Tsiolis)
+- Improve performance of pipeline page by reducing DB queries. !17168
+- Store sha256 checksum to job artifacts. !17354
+- Move SidebarAssignees vue component. !17398 (George Tsiolis)
+- Improve database response time for user activity listing. !17454
+- Use persisted/memoized value for MRs shas instead of doing git lookups. !17555
+- Cache MergeRequests can_be_resolved_in_ui? git operations. !17589
+- Prevent the graphs page from generating unnecessary Gitaly requests. !37602
+- Use a user object in ApplicationHelper#avatar_icon where possible to avoid N+1 queries. !42800
+- Submit a single batch blob RPC to Gitaly per HTTP request when viewing diffs.
+- Avoid re-fetching merge-base SHA from Gitaly unnecessarily.
+- Don't use ProjectsFinder in TodosFinder.
+- Adding missing indexes on taggings table.
+- Add index on section_name_id on ci_build_trace_sections table.
+- Cache column_exists? for application settings.
+- Cache table_exists?('application_settings') to reduce repeated schema reloads.
+- Make --prune a configurable parameter in fetching a git remote.
+- Fix timeouts loading /admin/projects page.
+- Add partial indexes on todos to handle users with many todos.
+- Optimize search queries on the search page by setting a limit for matching records in project scope.
+
+### Added (30 changes, 9 of them are from the community)
+
+- Add CommonMark markdown engine (experimental). !14835 (blackst0ne)
+- API: Get references a commit is pushed to. !15026 (Robert Schilling)
+- Add overview of branches and a filter for active/stale branches. !15402 (Takuya Noguchi)
+- Add project export API. !15860 (Travis Miller)
+- expose more metrics in merge requests api. !16589 (haseebeqx)
+- #28481: Display time tracking totals on milestone page. !16753 (Riccardo Padovani)
+- Add a button on the project page to set up a Kubernetes cluster and enable Auto DevOps. !16900
+- Include cycle time in usage ping data. !16973
+- Add ability to use external plugins as an alternative to system hooks. !17003
+- Add search param to Branches API. !17005 (bunufi)
+- API endpoint for importing a project export. !17025
+- Display ingress IP address in the Kubernetes page. !17052
+- Implemented badge API endpoints. !17082
+- Allow installation of GitLab Runner with a single click. !17134
+- Allow commits endpoint to work over all commits of a repository. !17182
+- Display Runner IP Address. !17286
+- Add archive feature to trace. !17314
+- Allow maintainers to push to forks of their projects when a merge request is open. !17395
+- Foreground verification of uploads and LFS objects. !17402
+- Adds updated_at filter to issues and merge_requests API. !17417 (Jacopo Beschi @jacopo-beschi)
+- Port /wip quick action command to Merge Request creation (on description). !17463 (Adam Pahlevi)
+- Add a paragraph about security implications on Cluster's page. !17486
+- Add plugins list to the system hooks page. !17518
+- Enable privileged mode for GitLab Runner. !17528
+- Expose GITLAB_FEATURES as CI/CD variable (fixes #40994).
+- Upgrade GitLab Workhorse to 4.0.0.
+- Add discussions API for Issues and Snippets.
+- Add one group board to Libre.
+- Add support for filtering by source and target branch to merge requests API.
+
+### Other (18 changes, 7 of them are from the community)
+
+- Group MRs on issue page by project and namespace. !8494 (Jeff Stubler)
+- Make oauth provider login generic. !8809 (Horatiu Eugen Vlad)
+- Add email button to new issue by email. !10942 (Islam Wazery)
+- Update vue component naming guidelines. !17018 (George Tsiolis)
+- Added new design for promotion modals. !17197
+- Update to github-linguist 5.3.x. !17241 (Ken Ding)
+- update toml-rb to 1.0.0. !17259 (Ken Ding)
+- Keep track of projects a user interacted with. !17327
+- Moved o_auth/saml/ldap modules under gitlab/auth. !17359 (Horatiu Eugen Vlad)
+- Enables eslint in codeclimate job. !17392
+- Port Labels Select dropdown to Vue. !17411
+- Add NOT NULL constraint to projects.namespace_id. !17448
+- Ensure foreign keys on clusters applications. !17488
+- Started translation into Turkish, Indonesian and Filipino. !17526
+- Add documentation for displayed K8s Ingress IP address (#44330). !17836
+- Move Ruby endpoints to OPT_OUT.
+- Upgrade Workhorse to version 3.8.0 to support structured logging.
+- Use host URL to build JIRA remote link icon.
+
+
+## 10.5.7 (2018-04-03)
+
+### Security (2 changes)
+
+- Fix XSS on diff view stored on filenames.
+- Adds confidential notes channel for Slack/Mattermost.
+
+
+## 10.5.6 (2018-03-16)
+
+### Security (2 changes)
+
+- Fixed some SSRF vulnerabilities in services, hooks and integrations. !2337
+- Fix GitLab Auth0 integration signing in the wrong user.
+
+
+## 10.5.5 (2018-03-15)
+
+### Fixed (3 changes)
+
+- Fix missing uploads after group transfer. !17658
+- Fix code and wiki search results when filename is non-ASCII.
+- Remove double caching of Repository#empty?.
+
+### Performance (2 changes)
+
+- Adding missing indexes on taggings table.
+- Add index on section_name_id on ci_build_trace_sections table.
+
+
+## 10.5.4 (2018-03-08)
+
+### Fixed (11 changes)
+
+- Encode branch name as binary before creating a RPC request to copy attributes. !17291
+- Restart Unicorn and Sidekiq when GRPC throws 14:Endpoint read failed. !17293
+- Ensure group issues and merge requests pages show results from subgroups when there are no results from the current group. !17312
+- Prevent trace artifact migration to incur data loss. !17313
+- Return a 404 instead of 403 if the repository does not exist on disk. !17341
+- Allow Prometheus application to be installed from Cluster applications. !17372
+- Fixes Prometheus admin configuration page. !17377
+- Fix code and wiki search results pages when non-ASCII text is displayed. !17413
+- Fix pages flaky failure by reloading stale object. !17522
+- Fixed issue edit shortcut not opening edit form.
+- Revert Project.public_or_visible_to_user changes and only apply to snippets.
+
+### Performance (1 change)
+
+- Don't use ProjectsFinder in TodosFinder.
+
+
+## 10.5.3 (2018-03-01)
+
+### Security (1 change)
+
+- Ensure that OTP backup codes are always invalidated.
+
+
+## 10.5.2 (2018-02-25)
+
+### Fixed (7 changes)
+
+- Fix single digit value clipping for stacked progress bar. !17217
+- Fix issue with cache key being empty when variable used as the key. !17260
+- Enable Legacy Authorization by default on Cluster creations. !17302
+- Allow branch names to be named the same as the sha it points to.
+- Fix 500 error when loading an invalid upload URL.
+- Don't attempt to update user tracked fields if database is in read-only.
+- Prevent MR Widget error when no CI configured.
+
+### Performance (5 changes)
+
+- Improve query performance for snippets dashboard. !17088
+- Only check LFS integrity for first ref in a push to avoid timeout. !17098
+- Improve query performance of MembersFinder. !17190
+- Increase feature flag cache TTL to one hour.
+- Improve performance of searching for and autocompleting of users.
+
+
+## 10.5.1 (2018-02-22)
+
+- No changes.
+
+## 10.5.0 (2018-02-22)
+
+### Security (3 changes, 1 of them is from the community)
+
+- Update marked from 0.3.6 to 0.3.12. !16480 (Takuya Noguchi)
+- Update nokogiri to 1.8.2. !16807
+- Add verification for GitLab Pages custom domains.
+
+### Fixed (77 changes, 25 of them are from the community)
+
+- Fix the Projects API with_issues_enabled filter behaving incorrectly any user. !12724 (Jan Christophersen)
+- Hide pipeline schedule take ownership for current owner. !12986
+- Handle special characters on API request of issuable templates. !15323 (Takuya Noguchi)
+- Shows signin tab after new user email confirmation. !16174 (Jacopo Beschi @jacopo-beschi)
+- Make project README containers wider on fixed layout. !16181 (Takuya Noguchi)
+- Fix dashboard projects nav links height. !16204 (George Tsiolis)
+- Fix error on empty query for Members API. !16235
+- Issue board: fix for dragging an issue to the very bottom in long lists. !16250 (David Kuri)
+- Make rich blob viewer wider for PC. !16262 (Takuya Noguchi)
+- Substitute deprecated ui_charcoal with new default ui_indigo. !16271 (Takuya Noguchi)
+- Generate HTTP URLs for custom Pages domains when appropriate. !16279
+- Make modal dialog common for Groups tree app. !16311
+- Allow moving wiki pages from the UI. !16313
+- Filter groups and projects dropdowns of search page on backend. !16336
+- Adjust layout width for fixed layout. !16337 (George Tsiolis)
+- Fix custom header logo design nitpick: Remove unneeded margin on empty logo text. !16383 (Markus Doits)
+- File Upload UI can create LFS pointers based on .gitattributes. !16412
+- Fix Ctrl+Enter keyboard shortcut saving comment/note edit. !16415
+- Fix file search results when they match file contents with a number between two colons. !16462
+- Fix tooltip displayed for running manual actions. !16489
+- Allow trailing + on labels in board filters. !16490
+- Prevent JIRA issue identifier from being humanized. !16491 (Andrew McCallum)
+- Add horizontal scroll to wiki tables. !16527 (George Tsiolis)
+- Fix a bug calculating artifact size for project statistics. !16539
+- Stop loading spinner on error of issuable templates. !16600 (Takuya Noguchi)
+- Allows html text in commits atom feed. !16603 (Jacopo Beschi @jacopo-beschi)
+- Disable MR check out button when source branch is deleted. !16631 (Jacopo Beschi @jacopo-beschi)
+- Fix export removal for hashed-storage projects within a renamed or deleted namespace. !16658
+- Default to HTTPS for all Gravatar URLs. !16666
+- Login via OAuth now only marks new users as external. !16672
+- Fix default avatar icon missing when Gravatar is disabled. !16681 (Felix Geyer)
+- Change button group width on mobile. !16726 (George Tsiolis)
+- Fix version information not showing on help page if commercial content display was disabled. !16743
+- Adds spacing between edit and delete tag btn in tag list. !16757 (Jacopo Beschi @jacopo-beschi)
+- Fix 500 error when loading a merge request with an invalid comment. !16795
+- Deleting an upload will correctly clean up the filesystem. !16799
+- Cleanup new branch/merge request form in issues. !16854
+- Fix GitLab import leaving group_id on ProjectLabel. !16877
+- Fix forking projects when no restricted visibility levels are defined applicationwide. !16881
+- Trigger change event on filename input when file template is applied. !16911 (Sebastian Klingler)
+- Fixes different margins between buttons in tag list. !16927 (Jacopo Beschi @jacopo-beschi)
+- Close low level rugged repository in project cache worker. !16930 (Bastian Blank)
+- Override group sidebar links. !16942 (George Tsiolis)
+- Avoid running `PopulateForkNetworksRange`-migration multiple times. !16988
+- Resolve PrepareUntrackedUploads PostgreSQL syntax error. !17019
+- Fix monaco editor features which were incompatable with GitLab CDN settings. !17021
+- Fixed error 500 when removing an identity with synced attributes and visiting the profile page. !17054
+- Fix cnacel edit note button reverting changes. !42462
+- For issues display time of last edit of title or description instead of time of any attribute change.
+- Handle all Psych YAML parser exceptions (fixes #41209).
+- Fix validation of environment scope of variables.
+- Display user friendly error message if rebase fails.
+- Hide new branch and tag links for projects with an empty repo.
+- Fix protected branches API to accept name parameter with dot.
+- Closes #38540 - Remove .ssh/environment file that now breaks the gitlab:check rake task.
+- Keep subscribers when promoting labels to group labels.
+- Replace verified badge icons and uniform colors.
+- Fix error on changes tab when merge request cannot be created.
+- Ignore leading slashes when searching for files within context of repository. (Andrew McCallum)
+- Close and do not reload MR diffs when source branch is deleted.
+- Bypass commits title markdown on notes.
+- Reload MRs memoization after diffs creation.
+- Return more consistent values for merge_status on MR APIs.
+- Contribution calendar label was cut off. (Branka Martinovic)
+- LDAP Person no longer throws exception on invalid entry.
+- Fix bug where award emojis would be lost when moving issues between projects.
+- Fix not all events being shown in group dashboard.
+- Fix JIRA not working when a trailing slash is included.
+- Fix squash not working when diff contained non-ASCII data.
+- Remove erroneous text in shared runners page that suggested more runners available.
+- Execute system hooks after-commit when executing project hooks.
+- Makes forking protect default branch on completion.
+- Validate user, group and project paths consistently, and only once.
+- Validate user namespace before saving so that errors persist on model.
+- Permits 'password_authentication_enabled_for_git' parameter for ApplicationSettingsController.
+- Fix duplicate item in protected branch/tag dropdown.
+- Open visibility level help in a new tab. (Jussi Räsänen)
+
+### Deprecated (1 change)
+
+- Add note within ux documentation that further changes should be made within the design.gitlab project.
+
+### Changed (20 changes, 7 of them are from the community)
+
+- Show coverage to two decimal points in coverage badge. !10083 (Jeff Stubler)
+- Update 'removed assignee' note to include old assignee reference. !16301 (Maurizio De Santis)
+- Move row containing Projects, Users and Groups count to the top in admin dashboard. !16421
+- Add Auto DevOps Domain application setting. !16604
+- Changes Revert this merge request text. !16611 (Jacopo Beschi @jacopo-beschi)
+- Link Auto DevOps settings to Clusters page. !16641
+- Internationalize charts page. !16687 (selrahman)
+- Internationalize graph page selrahman. !16688 (Shah El-Rahman)
+- Save traces as artifacts. !16702
+- Hide variable values on pipeline schedule edit page. !16729
+- Update runner info on all authenticated requests. !16756
+- Improve issue note dropdown and mr button. !16758 (George Tsiolis)
+- Replace "cluster" with "Kubernetes cluster". !16778
+- Enable Prometheus metrics for deployed Ingresses. !16866 (joshlambert)
+- Rename button to enable CI/CD configuration to "Set up CI/CD". !16870
+- Double padding for file-content wiki class on larger screens.
+- Improve wording about additional costs for Ingress on custom clusters.
+- Last push widget will show banner for new pushes to previously merged branch.
+- Save user ID and username in Grape API log (api_json.log).
+- Include subgroup issues and merge requests on the group page.
+
+### Performance (14 changes, 1 of them is from the community)
+
+- Fix double query execution on groups page. !16314
+- Speed up loading merged merge requests when they contained a lot of commits before merging. !16320
+- Properly memoize some predicate methods. !16329
+- Reduce the number of Prometheus metrics. !16443
+- Only highlight search results under the highlighting size limit. !16462
+- Add fast-blank. !16468
+- Move BoardList vue component to vue file. !16888 (George Tsiolis)
+- Fix N+1 query problem for snippets dashboard. !16944
+- Optimize search queries on the search page by setting a limit for matching records.
+- Store number of commits in merge_request_diffs table.
+- Improve performance of target branch dropdown.
+- Remove duplicate calls of MergeRequest#can_be_reverted?.
+- Stop checking if discussions are in a mergeable state if the MR isn't.
+- Remove N+1 queries with /projects/:project_id/{access_requests,members} API endpoints.
+
+### Added (28 changes, 10 of them are from the community)
+
+- Add link on commit page to merge request that introduced that commit. !13713 (Hiroyuki Sato)
+- System hooks for Merge Requests. !14387 (Alexis Reigel)
+- Add `pipelines` endpoint to merge requests API. !15454 (Tony Rom <thetonyrom@gmail.com>)
+- Adds Rubocop rule for line break around conditionals. !15739 (Jacopo Beschi @jacopo-beschi)
+- Add Colors to GitLab Flavored Markdown. !16095 (Tony Rom <thetonyrom@gmail.com>)
+- Initial work to add notification reason to emails. !16160 (Mario de la Ossa)
+- Implement multi server support and use kube proxy to connect to Prometheus servers inside K8S cluster. !16182
+- Add ability to transfer a group into another group. !16302
+- Add blue dot feature highlight to make GKE Clusters more visible to users. !16379
+- Add section headers to plus button dropdown. !16394 (George Tsiolis)
+- Support PostgreSQL 10. !16471
+- Enables Project Milestone Deletion via the API. !16478 (Jacopo Beschi @jacopo-beschi)
+- Add realtime ci status for the repository -> files view. !16523
+- User can now git push to create a new project. !16547
+- Improve empty project overview. !16617 (George Tsiolis)
+- Added uploader metadata to the uploads. !16779
+- Added ldap config setting to lower case the username. !16791
+- Add search support into the API. !16878
+- Backport of LFS File Locking API. !16935
+- Add a link to documentation on how to get external ip in the Kubernetes cluster details page. !16937
+- Add sorting options for /users API (admin only). !16945
+- Adds sorting to deployments API. (Jacopo Beschi @jacopo-beschi)
+- Add rake task to check integrity of uploaded files.
+- Add backend for persistently dismissably callouts.
+- Track and act upon the number of executed queries.
+- Add a gRPC health check to ensure Gitaly is up.
+- Log and send a system hook if a blocked user attempts to login.
+- Add Gitaly Servers admin dashboard.
+
+### Other (25 changes, 7 of them are from the community)
+
+- Updated the katex library. !15864
+- Add modal for deleting a milestone. !16229
+- Remove unused CSS selectors for Cycle Analytics. !16270 (Takuya Noguchi)
+- Add reason to keep postgresql 9.2 for CI. !16277 (Takuya Noguchi)
+- Adjust modal style to new design. !16310
+- Default to Gitaly for 'git push' HTTP/SSH, and make Gitaly mandatory for SSH pull. !16586
+- Set timezone for karma to UTC. !16602 (Takuya Noguchi)
+- Make Gitaly RepositoryExists opt-out. !16680
+- Update minimum git version to 2.9.5. !16683
+- Disable throwOnError in KaTeX to reveal user where is the problem. !16684 (Jakub Jirutka)
+- fix documentation about node version. !16720 (Tobias Gurtzick)
+- Enable RuboCop Style/RegexpLiteral. !16752 (Takuya Noguchi)
+- Add confirmation-input component. !16816
+- Add unique constraint to trending_projects#project_id. !16846
+- Add foreign key and NOT NULL constraints to todos table. !16849
+- Include branch in mobile view for pipelines. !16910 (George Tsiolis)
+- Downgrade google-protobuf gem. !16941
+- Refactors mr widget components into vue files and adds i18n.
+- increase-readability-of-colored-text-in-job-output-log.
+- Finish any remaining jobs for issues.closed_at.
+- Translate issuable sidebar.
+- Set standard disabled state for all buttons.
+- Upgrade GitLab Workhorse to v3.6.0.
+- Improve readability of underlined links for dyslexic users.
+- Adds empty state illustration for pending job.
+
+
+## 10.4.7 (2018-04-03)
+
+### Security (2 changes)
+
+- Fix XSS on diff view stored on filenames.
+- Adds confidential notes channel for Slack/Mattermost.
+
+
+## 10.4.6 (2018-03-16)
+
+### Security (2 changes)
+
+- Fixed some SSRF vulnerabilities in services, hooks and integrations. !2337
+- Fix GitLab Auth0 integration signing in the wrong user.
+
+
+## 10.4.5 (2018-03-01)
+
+### Security (1 change)
+
+- Ensure that OTP backup codes are always invalidated.
+
+
+## 10.4.4 (2018-02-16)
+
+### Security (1 change)
+
+- Update nokogiri to 1.8.2. !16807
+
+### Fixed (9 changes)
+
+- Fix 500 error when loading a merge request with an invalid comment. !16795
+- Cleanup new branch/merge request form in issues. !16854
+- Fix GitLab import leaving group_id on ProjectLabel. !16877
+- Fix forking projects when no restricted visibility levels are defined applicationwide. !16881
+- Resolve PrepareUntrackedUploads PostgreSQL syntax error. !17019
+- Fixed error 500 when removing an identity with synced attributes and visiting the profile page. !17054
+- Validate user namespace before saving so that errors persist on model.
+- LDAP Person no longer throws exception on invalid entry.
+- Fix JIRA not working when a trailing slash is included.
+
+
+## 10.4.3 (2018-02-05)
+
+### Security (4 changes)
+
+- Fix namespace access issue for GitHub, BitBucket, and GitLab.com project importers.
+- Fix stored XSS in code blocks that ignore highlighting.
+- Fix wilcard protected tags protecting all branches.
+- Restrict Todo API mark_as_done endpoint to the user's todos only.
+
+
+## 10.4.2 (2018-01-30)
+
+### Fixed (6 changes)
+
+- Fix copy/paste on iOS devices due to a bug in webkit. !15804
+- Fix missing "allow users to request access" option in public project permissions. !16485
+- Fix encoding issue when counting commit count. !16637
+- Fixes destination already exists, and some particular service errors on Import/Export error. !16714
+- Fix cache clear bug withg using : on Windows. !16740
+- Use has_table_privilege for TRIGGER on PostgreSQL.
+
+### Changed (1 change)
+
+- Vendor Auto DevOps template with DAST security checks enabled. !16691
+
+
+## 10.4.1 (2018-01-24)
+
+### Fixed (4 changes)
+
+- Ensure that users can reclaim a namespace or project path that is blocked by an orphaned route. !16242
+- Correctly escape UTF-8 path elements for uploads. !16560
+- Fix issues when rendering groups and their children. !16584
+- Fix bug in which projects with forks could not change visibility settings from Private to Public. !16595
+
+### Performance (2 changes)
+
+- rework indexes on redirect_routes.
+- Remove unecessary query from labels filter.
+
+
+## 10.4.0 (2018-01-22)
+
+### Security (8 changes, 1 of them is from the community)
+
+- Upgrade Ruby to 2.3.6 to include security patches. !16016
+- Prevent a SQL injection in the MilestonesFinder.
+- Check user authorization for source and target projects when creating a merge request.
+- Fix path traversal in gitlab-ci.yml cache:key.
+- Fix writable shared deploy keys.
+- Filter out sensitive fields from the project services API. (Robert Schilling)
+- Fix RCE via project import mechanism.
+- Prevent OAuth login POST requests when a provider has been disabled.
+
+### Fixed (68 changes, 24 of them are from the community)
+
+- Update comment on image cursor and icons. !15760
+- Fixes the wording of headers in system info page. !15802 (Gilbert Roulot)
+- Reset todo counters when the target is deleted. !15807
+- Execute quick actions (if present) when creating MR from issue. !15810
+- fix build count in pipeline success mail. !15827 (Christiaan Van den Poel)
+- Fix error that was preventing users to change the access level of access requests for Groups or Projects. !15832
+- Last push event widget width for fixed layout. !15862 (George Tsiolis)
+- Hide link to issues/MRs from labels list if issues/MRs are disabled. !15863 (Sophie Herold)
+- Use relative URL for projects to avoid storing domains. !15876
+- Fix gitlab-rake gitlab:import:repos import schedule. !15931
+- Removed incorrect guidance stating blocked users will be removed from groups and project as members. !15947 (CesarApodaca)
+- Fix some POST/DELETE requests in IE by switching some bundles to Axios for Ajax requests. !15951
+- Fixing error 500 when member exist but not the user. !15970
+- show None when issue is in closed list and no labels assigned. !15976 (Christiaan Van den Poel)
+- Fix tags in the Activity tab not being clickable. !15996 (Mario de la Ossa)
+- Disable Vue pagination when only one page of content is available. !15999 (Mario de la Ossa)
+- disables shortcut to issue boards when issues are not enabled. !16020 (Christiaan Van den Poel)
+- Ignore lost+found folder during backup on a volume. !16036 (Julien Millau)
+- Fix abuse reports link url in admin area navbar. !16068 (megos)
+- Keep typographic hierarchy in User Settings. !16090 (George Tsiolis)
+- Adjust content width for User Settings, GPG Keys. !16093 (George Tsiolis)
+- Fix gitlab-rake gitlab:import:repos import schedule. !16115
+- Fix import project url not updating project name. !16120
+- Fix activity inline event line height on mobile. !16121 (George Tsiolis)
+- Fix slash commands dropdown description mis-alignment on Firefox. !16125 (Maurizio De Santis)
+- Remove unnecessary sidebar element realignment. !16159 (George Tsiolis)
+- User#projects_limit remove DB default and added NOT NULL constraint. !16165 (Mario de la Ossa)
+- Fix API endpoints to edit wiki pages where project belongs to a group. !16170
+- Fix breadcrumbs in User Settings. !16172 (rfwatson)
+- Move 2FA disable button. !16177 (George Tsiolis)
+- Fixing bug when wiki last version. !16197
+- Protected branch is now created for default branch on import. !16198
+- Prevent excessive DB load due to faulty DeleteConflictingRedirectRoutes background migration. !16205
+- Force Auto DevOps kubectl version to 1.8.6. !16218
+- Fix missing references to pipeline objects when restoring project with import/export feature. !16221
+- Fix inconsistent downcase of filenames in prefilled `Add` commit messages. !16232 (James Ramsay)
+- Default merge request title is set correctly again when external issue tracker is activated. !16356 (Ben305)
+- Ensure that emails contain absolute, rather than relative, links to user uploads. !16364
+- Prevent invalid Route path if path is unchanged. !16397
+- Fixing rack request mime type when using rack attack. !16427
+- Prevent RevList failing on non utf8 paths. !16440
+- Fix giant fork icons on forks page. !16474
+- Fix links to uploaded files on wiki pages. !16499
+- Modify `LDAP::Person` to return username value based on attributes.
+- Fixed merge request status badge not updating after merging.
+- Remove related links in MR widget when empty state.
+- Gracefully handle garbled URIs in Markdown.
+- Fix hooks not being set up properly for bare import Rake task.
+- Fix Mermaid drawings not loading on some browsers.
+- Humanize the units of "Showing last X KiB of log" in job trace.
+- Avoid leaving a push event empty if payload cannot be created.
+- Show authored date rather than committed date on the commit list.
+- Fix when branch creation fails don't post system note. (Mateusz Bajorski)
+- Fix viewing merge request diffs where the underlying blobs are unavailable.
+- Fix 500 error when visiting a commit where the blobs do not exist.
+- Set target_branch to the ref branch when creating MR from issue.
+- Fix closed text for issues on Todos page.
+- [API] Fix creating issue when assignee_id is empty.
+- Fix false positive issue references in merge requests caused by header anchor links.
+- Fixed chanages dropdown ellipsis positioning.
+- Fix shortcut links on help page.
+- Clears visual token on second backspace. (Martin Wortschack)
+- Fix onion-skin re-entering state.
+- fix button alignment on MWPS component.
+- Add optional search param for Merge Requests API.
+- Normalizing Identity extern_uid when saving the record.
+- Fixed typo for issue description field declaration. (Marcus Amargi)
+- Fix ANSI 256 bold colors in pipelines job output.
+
+### Changed (18 changes, 3 of them are from the community)
+
+- Make mail notifications of discussion notes In-Reply-To of each other. !14289
+- Migrate existing data from KubernetesService to Clusters::Platforms::Kubernetes. !15589
+- Implement checking GCP project billing status in cluster creation form. !15665
+- Present multiple clusters in a single list instead of a tabbed view. !15669
+- Remove soft removals related code. !15789
+- Only mark import and fork jobs as failed once all Sidekiq retries get exhausted. !15844
+- Translate date ranges on contributors page. !15846
+- Update issuable status icons. !15898
+- Update feature toggle design to use icons and make it i18n friendly. !15904
+- Update groups tree to use GitLab SVG icons, add last updated at information for projects. !15980
+- Allow forking a public project to a private group. !16050
+- Expose project_id on /api/v4/pages/domains. !16200 (Luc Didry)
+- Display graph values on hover within monitoring page. !16261
+- removed tabindexes from tag form. (Marcus Amargi)
+- Move edit button to second row on issue page (and change it to a pencil icon).
+- Run background migrations with a minimum interval.
+- Provide additional cookies to JIRA service requests to allow Oracle WebGates Basic Auth. (Stanislaw Wozniak)
+- Hide markdown toolbar in preview mode.
+
+### Performance (11 changes)
+
+- Improve the performance for counting diverging commits. Show 999+ if it is more than 1000 commits. !15963
+- Treat empty markdown and html strings as valid cached text, not missing cache that needs to be updated.
+- Cache merged and closed events data in merge_request_metrics table.
+- Speed up generation of commit stats by using Rugged native methods.
+- Improve search query for issues.
+- Improve search query for merge requests.
+- Eager load event target authors whenever possible.
+- Use simple Next/Prev paging for jobs to avoid large count queries on arbitrarily large sets of historical jobs.
+- Improve performance of MR discussions on large diffs.
+- Add index on namespaces lower(name) for UsersController#exists.
+- Fix timeout when filtering issues by label.
+
+### Added (26 changes, 8 of them are from the community)
+
+- Support new chat notifications parameters in Services API. !11435
+- Add online and status attribute to runner api entity. !11750
+- Adds ordering to projects contributors in API. !15469 (Jacopo Beschi @jacopo-beschi)
+- Add assets_sync gem to Gemfile. !15734
+- Add a gitlab:tcp_check rake task. !15759
+- add support for sorting in tags api. !15772 (haseebeqx)
+- Add Prometheus to available Cluster applications. !15895
+- Validate file status when commiting multiple files. !15922
+- List of avatars should never show +1. !15972 (Jacopo Beschi @jacopo-beschi)
+- Do not generate NPM links for private NPM modules in blob view. !16002 (Mario de la Ossa)
+- Backport fast database lookup of SSH authorized_keys from EE. !16014
+- Add i18n helpers to branch comparison view. !16031 (James Ramsay)
+- Add pause/resume button to project runners. !16032 (Mario de la Ossa)
+- Added option to user preferences to enable the multi file editor. !16056
+- Implement project jobs cache reset. !16067
+- Rendering of emoji's in Group-Overview. !16098 (Jacopo Beschi @jacopo-beschi)
+- Allow automatic creation of Kubernetes Integration from template. !16104
+- API: get participants from merge_requests & issues. !16187 (Brent Greeff)
+- Added option to disable commits stats in the commit endpoint. !16309
+- Disable creation of new Kubernetes Integrations unless they're active or created from template. !41054
+- Added badge to tree & blob views to indicate LFS tracked files.
+- Enable ordering of groups and their children by name.
+- Add button to run scheduled pipeline immediately.
+- Allow user to rebase merge requests.
+- Handle GitLab hashed storage repositories using the repo import task.
+- Hide runner token in CI/CD settings page.
+
+### Other (12 changes, 3 of them are from the community)
+
+- Adds the multi file editor as a new beta feature. !15430
+- Use relative URLs when linking to uploaded files. !15751
+- Add docs for why you might be signed out when using the Remember me token. !15756
+- Replace '.team << [user, role]' with 'add_role(user)' in specs. !16069 (@blackst0ne)
+- Add id to modal.vue to support data-toggle="modal". !16189
+- Update scss-lint to 0.56.0. !16278 (Takuya Noguchi)
+- Fix web ide user preferences copy and buttons. !41789
+- Update redis-rack to 2.0.4.
+- Import some code and functionality from gitlab-shell to improve subprocess handling.
+- Update Browse file to Choose file in all occurences.
+- Bump mysql2 gem version from 0.4.5 to 0.4.10. (asaparov)
+- Use a background migration for issues.closed_at.
+
+
+## 10.3.9 (2018-03-16)
+
+### Security (3 changes)
+
+- Fixed some SSRF vulnerabilities in services, hooks and integrations. !2337
+- Update nokogiri to 1.8.2. !16807
+- Fix GitLab Auth0 integration signing in the wrong user.
+
+
+## 10.3.8 (2018-03-01)
+
+### Security (1 change)
+
+- Ensure that OTP backup codes are always invalidated.
+
+
+## 10.3.7 (2018-02-05)
+
+### Security (4 changes)
+
+- Fix namespace access issue for GitHub, BitBucket, and GitLab.com project importers.
+- Fix stored XSS in code blocks that ignore highlighting.
+- Fix wilcard protected tags protecting all branches.
+- Restrict Todo API mark_as_done endpoint to the user's todos only.
+
+
+## 10.3.6 (2018-01-22)
+
+### Fixed (17 changes, 2 of them are from the community)
+
+- Fix abuse reports link url in admin area navbar. !16068 (megos)
+- Fix gitlab-rake gitlab:import:repos import schedule. !16115
+- Fixing bug when wiki last version. !16197
+- Prevent excessive DB load due to faulty DeleteConflictingRedirectRoutes background migration. !16205
+- Default merge request title is set correctly again when external issue tracker is activated. !16356 (Ben305)
+- Prevent invalid Route path if path is unchanged. !16397
+- Fixing rack request mime type when using rack attack. !16427
+- Prevent RevList failing on non utf8 paths. !16440
+- Fix 500 error when visiting a commit where the blobs do not exist.
+- Fix viewing merge request diffs where the underlying blobs are unavailable.
+- Gracefully handle garbled URIs in Markdown.
+- Fix hooks not being set up properly for bare import Rake task.
+- Fix Mermaid drawings not loading on some browsers.
+- Fixed chanages dropdown ellipsis positioning.
+- Avoid leaving a push event empty if payload cannot be created.
+- Set target_branch to the ref branch when creating MR from issue.
+- Fix shortcut links on help page.
+
+
+## 10.3.5 (2018-01-18)
+
+- Fix error that prevented the 'deploy_keys' migration from working in MySQL databases.
+
+## 10.3.4 (2018-01-10)
+
+### Security (7 changes, 1 of them is from the community)
+
+- Prevent a SQL injection in the MilestonesFinder.
+- Fix RCE via project import mechanism.
+- Prevent OAuth login POST requests when a provider has been disabled.
+- Filter out sensitive fields from the project services API. (Robert Schilling)
+- Check user authorization for source and target projects when creating a merge request.
+- Fix path traversal in gitlab-ci.yml cache:key.
+- Fix writable shared deploy keys.
+
+
+## 10.3.3 (2018-01-02)
+
+### Fixed (3 changes)
+
+- Fix links to old commits in merge request comments.
+- Fix 404 errors after a user edits an issue description and solves the reCAPTCHA.
+- Gracefully handle orphaned write deploy keys in /internal/post_receive.
+
+
+## 10.3.2 (2017-12-28)
+
+### Fixed (1 change)
+
+- Fix migration for removing orphaned issues.moved_to_id values in MySQL and PostgreSQL.
+
+
+## 10.3.1 (2017-12-27)
+
+### Fixed (3 changes)
+
+- Don't link LFS objects to a project when unlinking forks when they were already linked. !16006
+- Execute project hooks and services after commit when moving an issue.
+- Fix Error 500s with anonymous clones for a project that has moved.
+
+### Changed (1 change)
+
+- Reduce the number of buckets in gitlab_cache_operation_duration_seconds metric. !15881
+
+
+## 10.3.0 (2017-12-22)
+
+### Security (1 change, 1 of them is from the community)
+
+- Upgrade jQuery to 2.2.4. !15570 (Takuya Noguchi)
+
+### Fixed (55 changes, 8 of them are from the community)
+
+- Fail jobs if its dependency is missing. !14009
+- Fix errors when selecting numeric-only labels in the labels autocomplete selector. !14607 (haseebeqx)
+- Fix pipeline status transition for single manual job. This would also fix pipeline duration becuse it is depending on status transition. !15251
+- Fix acceptance of username for Mattermost service update. !15275
+- Set the default gitlab-shell timeout to 3 hours. !15292
+- Make sure a user can add projects to subgroups they have access to. !15294
+- OAuth identity lookups case-insensitive. !15312
+- Fix filter by my reaction is not working. !15345 (Hiroyuki Sato)
+- Avoid deactivation when pipeline schedules execute a branch includes `[ci skip]` comment. !15405
+- Add recaptcha modal to issue updates detected as spam. !15408
+- Fix item name and namespace text overflow in Projects dropdown. !15451
+- Removed unused rake task, 'rake gitlab:sidekiq:drop_post_receive'. !15493
+- Fix commits page throwing 500 when the multi-file editor was enabled. !15502
+- Fix Issue comment submit button being disabled when pasting content from another GFM note. !15530
+- Reenable Prometheus metrics, add more control over Prometheus method instrumentation. !15558
+- Fix broadcast message not showing up on login page. !15578
+- Initializes the branches dropdown when the 'Start new pipeline' failed due to validation errors. !15588 (Christiaan Van den Poel)
+- Fix merge requests where the source or target branch name matches a tag name. !15591
+- Create a fork network for forks with a deleted source. !15595
+- Fix search results when a filename would contain a special character. !15606 (haseebeqx)
+- Strip leading & trailing whitespaces in CI/CD secret variable keys. !15615
+- Correctly link to a forked project from the new fork page. !15653
+- Fix the fork project functionality for projects with hashed storage. !15671
+- Added default order to UsersFinder. !15679
+- Fix graph notes number duplication. !15696 (Vladislav Kaverin)
+- Fix updateEndpoint undefined error for issue_show app root. !15698
+- Change boards page boards_data absolute urls to paths. !15703
+- Using appropiate services in the API for managing forks. !15709
+- Confirming email with invalid token should no longer generate an error. !15726
+- fix #39233 - 500 in merge request. !15774 (Martin Nowak)
+- Use Markdown styling for new project guidelines. !15785 (Markus Koller)
+- Fix error during schema dump. !15866
+- Fix broken illustration images for monitoring page empty states. !15889
+- Make sure user email is read only when synced with LDAP. !15915
+- Fixed outdated browser flash positioning.
+- Fix gitlab:import:repos Rake task moving repositories into the wrong location.
+- Gracefully handle case when repository's root ref does not exist.
+- Fix GitHub importer using removed interface.
+- Align retry button with job title with new grid size.
+- Fixed admin welcome screen new group path.
+- Fix related branches/Merge requests failing to load when the hostname setting is changed.
+- Init zen mode in snippets pages.
+- Remove extra margin from wordmark in header.
+- Fixed long commit links not wrapping correctly.
+- Fixed deploy keys remove button loading state not resetting.
+- Use app host instead of asset host when rendering image blob or diff.
+- Hide log size for mobile screens.
+- Fix sending notification emails to users with the mention level set who were mentioned in an issue or merge request description.
+- Changed validation error message on wrong milestone dates. (Xurxo Méndez Pérez)
+- Fix access to the final page of todos.
+- Fixed new group milestone breadcrumbs.
+- Fix image diff notification email from showing wrong content.
+- Fixed merge request lock icon size.
+- Make sure head pippeline always corresponds with the head sha of an MR.
+- Prevent 500 error when inspecting job after trigger was removed.
+
+### Changed (14 changes, 2 of them are from the community)
+
+- Only owner or master can erase jobs. !15216
+- Allow password authentication to be disabled entirely. !15223 (Markus Koller)
+- Add the option to automatically run a pipeline after updating AutoDevOps settings. !15380
+- Add total_time_spent to the `changes` hash in issuable Webhook payloads. !15381
+- Monitor NFS shards for circuitbreaker in a separate process. !15426
+- Add inline editing to issues on mobile. !15438
+- Add custom brand text on new project pages. !15541 (Markus Koller)
+- Show only group name by default and put full namespace in tooltip in Groups tree. !15650
+- Use custom user agent header in all GCP API requests. !15705
+- Changed the deploy markers on the prometheus dashboard to be more verbose. !38032
+- Animate contextual sidebar on collapse/expand.
+- Update emojis. Add :gay_pride_flag: and :speech_left:. Remove extraneous comma in :cartwheel_tone4:.
+- When a custom header logo is present, don't show GitLab type logo.
+- Improved diff changed files dropdown design.
+
+### Performance (19 changes)
+
+- Add timeouts for Gitaly calls. !15047
+- Performance issues when loading large number of wiki pages. !15276
+- Add performance logging to UpdateMergeRequestsWorker. !15360
+- Keep track of all circuitbreaker keys in a set. !15613
+- Improve the performance for counting commits. !15628
+- Reduce requests for project forks on show page of projects that have forks. !15663
+- Perform SQL matching of Build&Runner tags to greatly speed-up job picking.
+- Only load branch names for protected branch checks.
+- Optimize API /groups/:id/projects by preloading associations.
+- Remove allocation tracking code from InfluxDB sampler for performance.
+- Throttle the number of UPDATEs triggered by touch.
+- Make finding most recent merge request diffs more efficient.
+- Fetch blobs in bulk when generating diffs.
+- Cache commits for MergeRequest diffs.
+- Use fuzzy search with minimum length of 3 characters where appropriate.
+- Add axios to common file.
+- Remove template selector from global namespace.
+- check the import_status field before doing SQL operations to check the import url.
+- Stop sending milestone and labels data over the wire for MR widget requests.
+
+### Added (22 changes, 15 of them are from the community)
+
+- Limit autocomplete menu to applied labels. !11110 (Vitaliy @blackst0ne Klachkov)
+- Make diff notes created on a commit in a merge request to persist a rebase. !12148
+- Allow creation of merge request from email. !13817 (janp)
+- Add an ability to use a custom branch name on creation from issues. !13884 (Vitaliy @blackst0ne Klachkov)
+- Add anonymous rate limit per IP, and authenticated (web or API) rate limits per user. !14708
+- Create a new form to add Existing Kubernetes Cluster. !14805
+- Add support of Mermaid (generation of diagrams and flowcharts from text). !15107 (Vitaliy @blackst0ne Klachkov)
+- Add total time spent to milestones. !15116 (George Andrinopoulos)
+- Add /groups/:id/subgroups endpoint to API. !15142 (marbemac)
+- Add administrative endpoint to list all pages domains. !15160 (Travis Miller)
+- Adds Rubocop rule for line break after guard clause. !15188 (Jacopo Beschi @jacopo-beschi)
+- Add edit button to mobile file view. !15199 (Travis Miller)
+- Add dropdown sort to group milestones. !15230 (George Andrinopoulos)
+- added support for ordering and sorting in notes api. !15342 (haseebeqx)
+- Hashed Storage migration script now supports migrating project attachments. !15352
+- New API endpoint - list jobs for a specified runner. !15432
+- Add new API endpoint - get a namespace by ID. !15442
+- Disables autocomplete in filtered searc. !15477 (Jacopo Beschi @jacopo-beschi)
+- Update empty state page of merge request 'changes' tab. !15611 (Vitaliy @blackst0ne Klachkov)
+- Allow git pull/push on group/user/project redirects. !15670
+- show status of gitlab reference links in wiki. !15694 (haseebeqx)
+- Add email confirmation parameters for user creation and update via API. (Daniel Juarez)
+
+### Other (17 changes, 7 of them are from the community)
+
+- Enable UnnecessaryMantissa in scss-lint. !15255 (Takuya Noguchi)
+- Add untracked files to uploads table. !15270
+- Move update_project_counter_caches? out of issue and merge request. !15300 (George Andrinopoulos)
+- Removed tooltip from clone dropdown. !15334
+- Clean up empty fork networks. !15373
+- Create issuable destroy service. !15604 (George Andrinopoulos)
+- Upgrade seed-fu to 2.3.7. !15607 (Takuya Noguchi)
+- Rename GKE as Kubernetes Engine. !15608 (Takuya Noguchi)
+- Prefer ci_config_path validation for leading slashes instead of sanitizing the input. !15672 (Christiaan Van den Poel)
+- Fix typo in docs about Elasticsearch. !15699 (Takuya Noguchi)
+- Add internationalization support for the prometheus integration. !33338
+- Export text utils functions as es6 module and add tests.
+- Stop reloading the page when using pagination and tabs - use API calls - in Pipelines table.
+- Clean up schema of the "issues" table.
+- Clarify wording of protected branch settings for the default branch.
+- Update svg external depencency.
+- Clean up schema of the "merge_requests" table.
+
+
+## 10.2.8 (2018-02-07)
+
+### Security (4 changes)
+
+- Fix namespace access issue for GitHub, BitBucket, and GitLab.com project importers.
+- Fix stored XSS in code blocks that ignore highlighting.
+- Fix wilcard protected tags protecting all branches.
+- Restrict Todo API mark_as_done endpoint to the user's todos only.
+
+
+## 10.2.7 (2018-01-18)
+
+- No changes.
+
+## 10.2.6 (2018-01-11)
+
+### Security (9 changes, 1 of them is from the community)
+
+- Fix writable shared deploy keys.
+- Filter out sensitive fields from the project services API. (Robert Schilling)
+- Fix RCE via project import mechanism.
+- Fixed IPython notebook output not being sanitized.
+- Prevent OAuth login POST requests when a provider has been disabled.
+- Prevent a SQL injection in the MilestonesFinder.
+- Check user authorization for source and target projects when creating a merge request.
+- Fix path traversal in gitlab-ci.yml cache:key.
+- Fix XSS vulnerability in pipeline job trace.
+
+
## 10.2.5 (2017-12-15)
### Fixed (8 changes)
@@ -268,6 +1286,24 @@ entry.
- Add Gitaly metrics to the performance bar.
+## 10.1.7 (2018-01-18)
+
+- No changes.
+
+## 10.1.6 (2018-01-11)
+
+### Security (8 changes, 1 of them is from the community)
+
+- Fix writable shared deploy keys.
+- Filter out sensitive fields from the project services API. (Robert Schilling)
+- Fix RCE via project import mechanism.
+- Prevent OAuth login POST requests when a provider has been disabled.
+- Prevent a SQL injection in the MilestonesFinder.
+- Check user authorization for source and target projects when creating a merge request.
+- Fix path traversal in gitlab-ci.yml cache:key.
+- Fix XSS vulnerability in pipeline job trace.
+
+
## 10.1.5 (2017-12-07)
### Security (5 changes)
@@ -772,6 +1808,11 @@ entry.
- Added type to CHANGELOG entries. (Jacopo Beschi @jacopo-beschi)
- [BUGIFX] Improves subgroup creation permissions. !13418
+## 9.5.10 (2017-11-08)
+
+- [SECURITY] Add SSRF protections for hostnames that will never resolve but will still connect to localhost
+- [SECURITY] Include X-Content-Type-Options (XCTO) header into API responses
+
## 9.5.9 (2017-10-16)
- [SECURITY] Move project repositories between namespaces when renaming users.
@@ -3018,3254 +4059,6 @@ entry.
- Add margin to markdown math blocks.
- Add hover state to MR comment reply button.
-## 8.15.8 (2017-03-19)
-
-- Only show public emails in atom feeds.
-- To protect against Server-side Request Forgery project import URLs are now prohibited against localhost or the server IP except for the assigned instance URL and port. Imports are also prohibited from ports below 1024 with the exception of ports 22, 80, and 443.
-
-## 8.15.7 (2017-02-15)
-
-- No changes.
-
-## 8.15.6 (2017-02-14)
-
-- Patch Asciidocs rendering to block XSS.
-- Fix XSS vulnerability in SVG attachments.
-- 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
-- Speed up group milestone index by passing group_id to IssuesFinder. !8363
-- Don't instrument 405 Grape calls. !8445
-- Update the gitlab-markup gem to the version 1.5.1. !8509
-- Updated Turbolinks to mitigate potential XSS attacks.
-- Re-order update steps in the 8.14 -> 8.15 upgrade guide.
-- Re-add Google Cloud Storage as a backup strategy.
-
-## 8.15.3 (2017-01-06)
-
-- Rename wiki_events to wiki_page_events in project hooks API to avoid errors. !8425
-- Rename projects wth reserved names. !8234
-- Cache project authorizations even when user has access to zero projects. !8327
-- Fix a minor grammar error in merge request widget. !8337
-- Fix unclear closing issue behaviour on Merge Request show page. !8345 (Gabriel Gizotti)
-- fix border in login session tabs. !8346
-- Copy, don't move uploaded avatar files. !8396
-- Increases width of mini-pipeline-graph dropdown to prevent wrong position on chrome on ubuntu. !8399
-- Removes invalid html and unneed CSS to prevent shaking in the pipelines tab. !8411
-- Gitlab::LDAP::Person uses LDAP attributes configuration. !8418
-- Fix 500 errors when creating a user with identity via API. !8442
-- Whitelist next project names: assets, profile, public. !8470
-- Fixed regression of note-headline-light where it was always placed on 2 lines, even on wide viewports.
-- Fix 500 error when visit group from admin area if group name contains dot.
-- Fix cross-project references copy to include the project reference.
-- Fix 500 error renaming group.
-- Fixed GFM dropdown not showing on new lines.
-
-## 8.15.2 (2016-12-27)
-
-- Fix finding the latest pipeline. !8301
-- Fix mr list timestamp alignment. !8271
-- Fix discussion overlap text in regular screens. !8273
-- Fixes mini-pipeline-graph dropdown animation and stage position in chrome, firefox and safari. !8282
-- Fix line breaking in nodes of the pipeline graph in firefox. !8292
-- Fixes confendential warning text alignment. !8293
-- Hide Scroll Top button for failed build page. !8295
-- Fix finding the latest pipeline. !8301
-- Disable PostgreSQL statement timeouts when removing unneeded services. !8322
-- Fix timeout when MR contains large files marked as binary by .gitattributes.
-- Rename "autodeploy" to "auto deploy".
-- Fixed GFM autocomplete error when no data exists.
-- Fixed resolve discussion note button color.
-
-## 8.15.1 (2016-12-23)
-
-- Push payloads schedule at most 100 commits, instead of all commits.
-- Fix Mattermost command creation by specifying username.
-- Do not override incoming webhook for mattermost and slack.
-- Adds background color for disabled state to merge when succeeds dropdown. !8222
-- Standardises font-size for titles in Issues, Merge Requests and Merge Request widget. !8235
-- Fix Pipeline builds list blank on MR. !8255
-- Do not show retried builds in pipeline stage dropdown. !8260
-
-## 8.15.0 (2016-12-22)
-
-- Whitelist next project names: notes, services.
-- Use Grape's new Route methods.
-- Fixed issue boards scrolling with a lot of lists & issues.
-- Remove unnecessary sentences for status codes in the API documentation. (Luis Alonso Chavez Armendariz)
-- Allow unauthenticated access to Repositories Files API GET endpoints.
-- Add note to the invite page when the logged in user email is not the same as the invitation.
-- Don't accidentally mark unsafe diff lines as HTML safe.
-- Add git diff context to notifications of new notes on merge requests. (Heidi Hoopes)
-- Shows group members in project members list.
-- Gem update: Update grape to 0.18.0. (Robert Schilling)
-- API: Expose merge status for branch API. (Robert Schilling)
-- Displays milestone remaining days only when it's present.
-- API: Expose committer details for commits. (Robert Schilling)
-- API: Ability to set 'should_remove_source_branch' on merge requests. (Robert Schilling)
-- Fix project import label priorities error.
-- Fix Import/Export merge requests error while importing.
-- Refactor Bitbucket importer to use BitBucket API Version 2.
-- Fix Import/Export duplicated builds error.
-- Ci::Builds have same ref as Ci::Pipeline in dev fixtures. (twonegatives)
-- For single line git commit messages, the close quote should be on the same line as the open quote.
-- Use authorized projects in ProjectTeam.
-- Destroy a user's session when they delete their own account.
-- Edit help text to clarify annotated tag creation. (Liz Lam)
-- Fixed file template dropdown for the "New File" editor for smaller/zoomed screens.
-- Fix Route#rename_children behavior.
-- Add nested groups support on data level.
-- Allow projects with 'dashboard' as path.
-- Disabled emoji buttons when user is not logged in.
-- Remove unused and void services from the database.
-- Add issue search slash command.
-- Accept issue new as command to create an issue.
-- Non members cannot create labels through the API.
-- API: expose pipeline coverage.
-- Validate state param when filtering issuables.
-- Username exists check respects relative root path.
-- Bump Git version requirement to 2.8.4.
-- Updates the font weight of button styles because of the change to system fonts.
-- Update API spec files to describe the correct class. (Livier)
-- Fixed timeago re-rendering every timeago.
-- Enable ColorVariable in scss-lint. (Sam Rose)
-- Various small emoji positioning adjustments.
-- Add shortcuts for adding users to a project team with a specific role. (Nikolay Ponomarev and Dino M)
-- Additional rounded label fixes.
-- Remove unnecessary database indices.
-- 24726 Remove Across GitLab from side navigation.
-- Changed cursor icon to pointer when mousing over stages on the Cycle Analytics pages. (Ryan Harris)
-- Add focus state to dropdown items.
-- Fixes Environments displaying incorrect date since 8.14 upgrade.
-- Improve bulk assignment for issuables.
-- Stop supporting Google and Azure as backup strategies.
-- Fix broken README.md UX guide link.
-- Allow public access to some Tag API endpoints.
-- Encode input when migrating ProcessCommitWorker jobs to prevent migration errors.
-- Adjust the width of project avatars to fix alignment within their container. (Ryan Harris)
-- Sentence cased the nav tab headers on the project dashboard page. (Ryan Harris)
-- Adds hoverstates for collapsed Issue/Merge Request sidebar.
-- Make CI badge hitboxes match parent.
-- Add a starting date to milestones.
-- Adjusted margins for Build Status and Coverage Report rows to match those of the CI/CD Pipeline row. (Ryan Harris)
-- Updated members dropdowns.
-- Move all action buttons to project header.
-- Replace issue access checks with use of IssuableFinder.
-- Fix missing Note access checks by moving Note#search to updated NoteFinder.
-- Centered Accept Merge Request button within MR widget and added padding for viewports smaller than 768px. (Ryan Harris)
-- Fix missing access checks on issue lookup using IssuableFinder.
-- Added top margin to Build status page header for mobile views. (Ryan Harris)
-- Fixes "ActionView::Template::Error: undefined method `text?` for nil:NilClass" on MR pages.
-- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
-- Replace MR access checks with use of MergeRequestsFinder.
-- Fix information disclosure in `Projects::BlobController#update`.
-- Allow branch names with dots on API endpoint.
-- Changed Housekeeping button on project settings page to default styling. (Ryan Harris)
-- Ensure issuable state changes only fire webhooks once.
-- Fix bad selection on dropdown menu for tags filter. (Luis Alonso Chavez Armendariz)
-- Fix title case to sentence case. (Luis Alonso Chavez Armendariz)
-- Fix appearance in error pages. (Luis Alonso Chavez Armendariz)
-- Create mattermost service.
-- 25617 Fix placeholder color of todo filters.
-- Made the padding on the plus button in the breadcrumb menu even. (Ryan Harris)
-- Allow to delete tag release note.
-- Ensure nil User-Agent doesn't break the CI API.
-- Replace Rack::Multipart with GitLab-Workhorse based solution. !5867
-- Add scopes for personal access tokens and OAuth tokens. !5951
-- API: Endpoint to expose personal snippets as /snippets. !6373 (Bernard Guyzmo Pratz)
-- New `gitlab:workhorse:install` rake task. !6574
-- Filter protocol-relative URLs in ExternalLinkFilter. Fixes issue #22742. !6635 (Makoto Scott-Hinkle)
-- Add support for setting the GitLab Runners Registration Token during initial database seeding. !6642
-- Guests can read builds when public. !6842
-- Made comment autocomplete more performant and removed some loading bugs. !6856
-- Add GitLab host to 2FA QR code and manual info. !6941
-- Add sorting functionality for group/project members. !7032
-- Rename Merge When Build Succeeds to Merge When Pipeline Succeeds. !7135
-- Resolve all discussions in a merge request by creating an issue collecting them. !7180 (Bob Van Landuyt)
-- Add Human Readable format for rake backup. !7188 (David Gerő)
-- post_receive: accept any user email from last commit. !7225 (Elan Ruusamäe)
-- Add support for Dockerfile templates. !7247
-- Add shorthand support to gitlab markdown references. !7255 (Oswaldo Ferreira)
-- Display error code for U2F errors. !7305 (winniehell)
-- Fix wrong tab selected when loggin fails and multiple login tabs exists. !7314 (Jacopo Beschi @jacopo-beschi)
-- Clean up common_utils.js. !7318 (winniehell)
-- Show commit status from latest pipeline. !7333
-- Remove the help text under the sidebar subscribe button and style it inline. !7389
-- Update wiki page design. !7429
-- Add nested groups support to the routing. !7459
-- Changed eslint airbnb config to the base airbnb config and corrected eslintrc plugins and envs. !7470 (Luke "Jared" Bennett)
-- Fix cancelling created or external pipelines. !7508
-- Allow admins to stop impersonating users without e-mail addresses. !7550 (Oren Kanner)
-- Remove unnecessary self from user model. !7551 (Semyon Pupkov)
-- Homogenize filter and sort dropdown look'n'feel. !7583 (David Wagner)
-- Create dynamic fixture for build_spec. !7589 (winniehell)
-- Moved Leave Project and Leave Group buttons to access_request_buttons from the settings dropdown. !7600
-- Remove unnecessary require_relative calls from service classes. !7601 (Semyon Pupkov)
-- Simplify copy on "Create a new list" dropdown in Issue Boards. !7605 (Victor Rodrigues)
-- Refactor create service spec. !7609 (Semyon Pupkov)
-- Shows unconfirmed email status in profile. !7611
-- The admin user projects view now has a clickable group link. !7620 (James Gregory)
-- Prevent DOM ID collisions resulting from user-generated content anchors. !7631
-- Replace static fixture for abuse_reports_spec. !7644 (winniehell)
-- Define common helper for describe pagination params in api. !7646 (Semyon Pupkov)
-- Move abuse report spinach test to rspec. !7659 (Semyon Pupkov)
-- Replace static fixture for awards_handler_spec. !7661 (winniehell)
-- API: Add ability to unshare a project from a group. !7662 (Robert Schilling)
-- Replace references to MergeRequestDiff#commits with st_commits when we care only about the number of commits. !7668
-- Add issue events filter and make all really show all events. !7673 (Oxan van Leeuwen)
-- Replace static fixture for notes_spec. !7683 (winniehell)
-- Replace static fixture for shortcuts_issuable_spec. !7685 (winniehell)
-- Replace static fixture for zen_mode_spec. !7686 (winniehell)
-- Replace static fixture for right_sidebar_spec. !7687 (winniehell)
-- Add online terminal support for Kubernetes. !7690
-- Move admin abuse report spinach test to rspec. !7691 (Semyon Pupkov)
-- Move admin spam spinach test to Rspec. !7708 (Semyon Pupkov)
-- Make API::Helpers find a project with only one query. !7714
-- Create builds in transaction to avoid empty pipelines. !7742
-- Render SVG images in diffs and notes. !7747 (andrebsguedes)
-- Add setting to enable/disable HTML emails. !7749
-- Use SmartInterval for MR widget and improve visibilitychange functionality. !7762
-- Resolve "Remove Builds tab from Merge Requests and Commits". !7763
-- Moved new projects button below new group button on the welcome screen. !7770
-- fix display hook error message. !7775 (basyura)
-- Refactor issuable_filters_present to reduce duplications. !7776 (Semyon Pupkov)
-- Redirect to sign-in page when unauthenticated user tries to create a snippet. !7786
-- Fix Archived project merge requests add to group's Merge Requests. !7790 (Jacopo Beschi @jacopo-beschi)
-- Update generic/external build status to match normal build status template. !7811
-- Enable AsciiDoctor admonition icons. !7812 (Horacio Sanson)
-- Do not raise error in AutocompleteController#users when not authorized. !7817 (Semyon Pupkov)
-- fix: 24982- Remove'Signed in successfully' message After this change the sign-in-success flash message will not be shown. !7837 (jnoortheen)
-- Fix Latest deployment link is broken. !7839
-- Don't display prompt to add SSH keys if SSH protocol is disabled. !7840 (Andrew Smith (EspadaV8))
-- Allow unauthenticated access to some Project API GET endpoints. !7843
-- Refactor presenters ChatCommands. !7846
-- Improve help message for issue create slash command. !7850
-- change text around timestamps to make it clear which timestamp is displayed. !7860 (BM5k)
-- Improve Build Log scrolling experience. !7895
-- Change ref property to commitRef in vue commit component. !7901
-- Prevent user creating issue or MR without signing in for a group. !7902
-- Provides a sensible default message when adding a README to a project. !7903
-- Bump ruby version to 2.3.3. !7904
-- Fix comments activity tab visibility condition. !7913 (Rydkin Maxim)
-- Remove unnecessary target branch link from MR page in case of deleted target branch. !7916 (Rydkin Maxim)
-- Add image controls to MR diffs. !7919
-- Remove wrong '.builds-feature' class from the MR settings fieldset. !7930
-- Resolve "Manual actions on pipeline graph". !7931
-- Avoid escaping relative links in Markdown twice. !7940 (winniehell)
-- Move admin hooks spinach to rspec. !7942 (Semyon Pupkov)
-- Move admin logs spinach test to rspec. !7945 (Semyon Pupkov)
-- fix: removed signed_out notification. !7958 (jnoortheen)
-- Accept environment variables from the `pre-receive` script. !7967
-- Do not reload diff for merge request made from fork when target branch in fork is updated. !7973
-- Fixes left align issue for long system notes. !7982
-- Add a slug to environments. !7983
-- Fix lookup of project by unknown ref when caching is enabled. !7988
-- Resolve "Provide SVG as a prop instead of hiding and copy them in environments table". !7992
-- Introduce deployment services, starting with a KubernetesService. !7994
-- Adds tests for custom event polyfill. !7996
-- Allow all alphanumeric characters in file names. !8002 (winniehell)
-- Added support for math rendering, using KaTeX, in Markdown and asciidoc. !8003 (Munken)
-- Remove unnecessary commits order message. !8004
-- API: Memoize the current_user so that sudo can work properly. !8017
-- group authors in contribution graph with case insensitive email handle comparison. !8021
-- Move admin active tab spinach tests to rspec. !8037 (Semyon Pupkov)
-- Add Authentiq as Oauth provider. !8038 (Alexandros Keramidas)
-- API: Ability to cherry pick a commit. !8047 (Robert Schilling)
-- Fix Slack pipeline message from pipelines made by API. !8059
-- API: Simple representation of group's projects. !8060 (Robert Schilling)
-- Prevent overflow with vertical scroll when we have space to show content. !8061
-- Allow to auto-configure Mattermost. !8070
-- Introduce $CI_BUILD_REF_SLUG. !8072
-- Added go back anchor on error pages. !8087
-- Convert CI YAML variables keys into strings. !8088
-- Adds Direct link from pipeline list to builds. !8097
-- Cache last commit id for path. !8098 (Hiroyuki Sato)
-- Pass variables from deployment project services to CI runner. !8107
-- New Gitea importer. !8116
-- Introduce "Set up autodeploy" button to help configure GitLab CI for deployment. !8135
-- Prevent enviroment table to overflow when name has underscores. !8142
-- Fix missing service error importing from EE to CE. !8144
-- Milestoneish SQL performance partially improved and memoized. !8146
-- Allow unauthenticated access to Repositories API GET endpoints. !8148
-- fix colors and margins for adjacent alert banners. !8151
-- Hides new issue button for non loggedin user. !8175
-- Fix N+1 queries on milestone show pages. !8185
-- Rename groups with .git in the end of the path. !8199
-- Whitelist next project names: help, ci, admin, search. !8227
-- Adds back CSS for progress-bars. !8237
-
-## 8.14.10 (2017-02-15)
-
-- No changes.
-
-## 8.14.9 (2017-02-14)
-
-- Patch Asciidocs rendering to block XSS.
-- Fix XSS vulnerability in SVG attachments.
-- 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.14.8 (2017-01-25)
-
-- Accept environment variables from the `pre-receive` script. !7967
-- Milestoneish SQL performance partially improved and memoized. !8146
-- Fix N+1 queries on milestone show pages. !8185
-- 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
-- Updated Turbolinks to mitigate potential XSS attacks.
-
-## 8.14.5 (2016-12-14)
-
-- Moved Leave Project and Leave Group buttons to access_request_buttons from the settings dropdown. !7600
-- fix display hook error message. !7775 (basyura)
-- Remove wrong '.builds-feature' class from the MR settings fieldset. !7930
-- Avoid escaping relative links in Markdown twice. !7940 (winniehell)
-- API: Memoize the current_user so that sudo can work properly. !8017
-- Displays milestone remaining days only when it's present.
-- Allow branch names with dots on API endpoint.
-- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
-- Shows group members in project members list.
-- Encode input when migrating ProcessCommitWorker jobs to prevent migration errors.
-- Fixed timeago re-rendering every timeago.
-- Fix missing Note access checks by moving Note#search to updated NoteFinder.
-
-## 8.14.4 (2016-12-08)
-
-- Fix diff view permalink highlighting. !7090
-- Fix pipeline author for Slack and use pipeline id for pipeline link. !7506
-- Fix compatibility with Internet Explorer 11 for merge requests. !7525 (Steffen Rauh)
-- Reenables /user API request to return private-token if user is admin and request is made with sudo. !7615
-- Fix Cicking on tabs on pipeline page should set URL. !7709
-- Authorize users into imported GitLab project.
-- Destroy a user's session when they delete their own account.
-- Don't accidentally mark unsafe diff lines as HTML safe.
-- Replace MR access checks with use of MergeRequestsFinder.
-- Remove visible content caching.
-
-## 8.14.3 (2016-12-02)
-
-- Pass commit data to ProcessCommitWorker to reduce Git overhead. !7744
-- Speed up issuable dashboards.
-- Don't change relative URLs to absolute URLs in the Help page.
-- Fixes "ActionView::Template::Error: undefined method `text?` for nil:NilClass" on MR pages.
-- Fix branch validation for GitHub PR where repo/fork was renamed/deleted.
-- Validate state param when filtering issuables.
-
-## 8.14.2 (2016-12-01)
-
-- Remove caching of events data. !6578
-- Rephrase some system notes to be compatible with new system note style. !7692
-- Pass tag SHA to post-receive hook when tag is created via UI. !7700
-- Prevent error when submitting a merge request and pipeline is not defined. !7707
-- Fixes system note style in commit discussion. !7721
-- Use a Redis lease for updating authorized projects. !7733
-- Refactor JiraService by moving code out of JiraService#execute method. !7756
-- Update GitLab Workhorse to v1.0.1. !7759
-- Fix pipelines info being hidden in merge request widget. !7808
-- Fixed commit timeago not rendering after initial page.
-- Fix for error thrown in cycle analytics events if build has not started.
-- Fixed issue boards issue sorting when dragging issue into list.
-- Allow access to the wiki with git when repository feature disabled.
-- Fixed timeago not rendering when resolving a discussion.
-- Update Sidekiq-cron to fix compatibility issues with Sidekiq 4.2.1.
-- Timeout creating and viewing merge request for binary file.
-- Gracefully recover from Redis connection failures in Sidekiq initializer.
-
-## 8.14.1 (2016-11-28)
-
-- Fix deselecting calendar days on contribution graph. !6453 (ClemMakesApps)
-- Update grape entity to 0.6.0. !7491
-- If Build running change accept merge request when build succeeds button from orange to blue. !7577
-- Changed import sources buttons to checkboxes. !7598 (Luke "Jared" Bennett)
-- Last minute CI Style tweaks for 8.14. !7643
-- Fix exceptions when loading build trace. !7658
-- Fix wrong template rendered when CI/CD settings aren't update successfully. !7665
-- fixes last_deployment call environment is nil. !7671
-- Sort builds by name within pipeline graph. !7681
-- Correctly determine mergeability of MR with no discussions.
-- Sidekiq stats in the admin area will now show correctly on different platforms. (blackst0ne)
-- Fixed issue boards dragging card removing random issues.
-- Fix information disclosure in `Projects::BlobController#update`.
-- Fix missing access checks on issue lookup using IssuableFinder.
-- Replace issue access checks with use of IssuableFinder.
-- Non members cannot create labels through the API.
-- Fix cycle analytics plan stage when commits are missing.
-
-## 8.14.0 (2016-11-22)
-
-- Use separate email-token for incoming email and revert back the inactive feature. !5914
-- API: allow recursive tree request. !6088 (Rebeca Mendez)
-- Replace jQuery.timeago with timeago.js. !6274 (ClemMakesApps)
-- Add CI notifications. Who triggered a pipeline would receive an email after the pipeline is succeeded or failed. Users could also update notification settings accordingly. !6342
-- Add button to delete all merged branches. !6449 (Toon Claes)
-- Finer-grained Git gargage collection. !6588
-- Introduce better credential and error checking to `rake gitlab:ldap:check`. !6601
-- Centralize LDAP config/filter logic. !6606
-- Make system notes less intrusive. !6755
-- Process commits using a dedicated Sidekiq worker. !6802
-- Show random messages when the To Do list is empty. !6818 (Josep Llaneras)
-- Precalculate user's authorized projects in database. !6839
-- Fix record not found error on NewNoteWorker processing. !6863 (Oswaldo Ferreira)
-- Show avatars in mention dropdown. !6865
-- Fix expanding a collapsed diff when converting a symlink to a regular file. !6953
-- Defer saving project services to the database if there are no user changes. !6958
-- Omniauth auto link LDAP user falls back to find by DN when user cannot be found by UID. !7002
-- Display "folders" for environments. !7015
-- Make it possible to trigger builds from webhooks. !7022 (Dmitry Poray)
-- Fix showing pipeline status for a given commit from correct branch. !7034
-- Add link to build pipeline within individual build pages. !7082
-- Add api endpoint `/groups/owned`. !7103 (Borja Aparicio)
-- Add query param to filter users by external & blocked type. !7109 (Yatish Mehta)
-- Issues atom feed url reflect filters on dashboard. !7114 (Lucas Deschamps)
-- Add setting to only allow merge requests to be merged when all discussions are resolved. !7125 (Rodolfo Arruda)
-- Remove an extra leading space from diff paste data. !7133 (Hiroyuki Sato)
-- Fix trace patching feature - update the updated_at value. !7146
-- Fix 404 on network page when entering non-existent git revision. !7172 (Hiroyuki Sato)
-- Rewrite git blame spinach feature tests to rspec feature tests. !7197 (Lisanne Fellinger)
-- Add api endpoint for creating a pipeline. !7209 (Ido Leibovich)
-- Allow users to subscribe to group labels. !7215
-- Reduce API calls needed when importing issues and pull requests from GitHub. !7241 (Andrew Smith (EspadaV8))
-- Only skip group when it's actually a group in the "Share with group" select. !7262
-- Introduce round-robin project creation to spread load over multiple shards. !7266
-- Ensure merge request's "remove branch" accessors return booleans. !7267
-- Fix no "Register" tab if ldap auth is enabled (#24038). !7274 (Luc Didry)
-- Expose label IDs in API. !7275 (Rares Sfirlogea)
-- Fix invalid filename validation on eslint. !7281
-- API: Ability to retrieve version information. !7286 (Robert Schilling)
-- Added ability to throttle Sidekiq Jobs. !7292
-- Set default Sidekiq retries to 3. !7294
-- Fix double event and ajax request call on MR page. !7298 (YarNayar)
-- Unify anchor link format for MR diff files. !7298 (YarNayar)
-- Require projects before creating milestone. !7301 (gfyoung)
-- Fix error when using invalid branch name when creating a new pipeline. !7324
-- Return 400 when creating a system hook fails. !7350 (Robert Schilling)
-- Auto-close environment when branch is deleted. !7355
-- Rework cache invalidation so only changed data is refreshed. !7360
-- Navigation bar issuables counters reflects dashboard issuables counters. !7368 (Lucas Deschamps)
-- Fix cache for commit status in commits list to respect branches. !7372
-- fixes 500 error on project show when user is not logged in and project is still empty. !7376
-- Removed gray button styling from todo buttons in sidebars. !7387
-- Fix project records with invalid visibility_level values. !7391
-- Use 'Forking in progress' title when appropriate. !7394 (Philip Karpiak)
-- Fix error links in help index page. !7396 (Fu Xu)
-- Add support for reply-by-email when the email only contains HTML. !7397
-- [Fix] Extra divider issue in dropdown. !7398
-- Project download buttons always show. !7405 (Philip Karpiak)
-- Give search-input correct padding-right value. !7407 (Philip Karpiak)
-- Remove additional padding on right-aligned items in MR widget. !7411 (Didem Acet)
-- Fix issue causing Labels not to appear in sidebar on MR page. !7416 (Alex Sanford)
-- Allow mail_room idle_timeout option to be configurable. !7423
-- Fix misaligned buttons on admin builds page. !7424 (Didem Acet)
-- Disable "Request Access" functionality by default for new projects and groups. !7425
-- fix shibboleth misconfigurations resulting in authentication bypass. !7428
-- Added Mattermost slash command. !7438
-- Allow to connect Chat account with GitLab. !7450
-- Make New Group form respect default visibility application setting. !7454 (Jacopo Beschi @jacopo-beschi)
-- Fix Error 500 when creating a merge request that contains an image that was deleted and added. !7457
-- Fix labels API by adding missing current_user parameter. !7458 (Francesco Coda Zabetta)
-- Changed restricted visibility admin buttons to checkboxes. !7463
-- Send credentials (currently for registry only) with build data to GitLab Runner. !7474
-- Fix POST /internal/allowed to cope with gitlab-shell v4.0.0 project paths. !7480
-- Adds es6-promise Polyfill. !7482
-- Added colored labels to related MR list. !7486 (Didem Acet)
-- Use setter for key instead AR callback. !7488 (Semyon Pupkov)
-- Limit labels returned for a specific project as an administrator. !7496
-- Change slack notification comment link. !7498 (Herbert Kagumba)
-- Allow registering users whose username contains dots. !7500 (Timothy Andrew)
-- Fix race condition during group deletion and remove stale records present due to this bug. !7528 (Timothy Andrew)
-- Check all namespaces on validation of new username. !7537
-- Pass correct tag target to post-receive hook when creating tag via UI. !7556
-- Add help message for configuring Mattermost slash commands. !7558
-- Fix typo in Build page JavaScript. !7563 (winniehell)
-- Make job script a required configuration entry. !7566
-- Fix errors happening when source branch of merge request is removed and then restored. !7568
-- Fix a wrong "The build for this merge request failed" message. !7579
-- Fix Margins look weird in Project page with pinned sidebar in project stats bar. !7580
-- Fix regression causing bad error message to appear on Merge Request form. !7599 (Alex Sanford)
-- Fix activity page endless scroll on large viewports. !7608
-- Fix 404 on some group pages when name contains dot. !7614
-- Do not create a new TODO when failed build is allowed to fail. !7618
-- Add deployment command to ChatOps. !7619
-- Fix 500 error when group name ends with git. !7630
-- Fix undefined error in CI linter. !7650
-- Show events per stage on Cycle Analytics page. !23449
-- Add JIRA remotelinks and prevent duplicated closing messages.
-- Fixed issue boards counter border when unauthorized.
-- Add placeholder for the example text for custom hex color on label creation popup. (Luis Alonso Chavez Armendariz)
-- Add an index for project_id in project_import_data to improve performance.
-- Fix broken commits search.
-- Assignee dropdown now searches author of issue or merge request.
-- Clicking "force remove source branch" label now toggles the checkbox again.
-- More aggressively preload on merge request and issue index pages.
-- Fix broken link to observatory cli on Frontend Dev Guide. (Sam Rose)
-- Fixing the issue of the project fork url giving 500 when not signed instead of being redirected to sign in page. (Cagdas Gerede)
-- Fix: Guest sees some repository details and gets 404.
-- Add logging for rack attack events to production.log.
-- Add environment info to builds page.
-- Allow commit note to be visible if repo is visible.
-- Bump omniauth-gitlab to 1.0.2 to fix incompatibility with omniauth-oauth2.
-- Redesign pipelines page.
-- Faster search inside Project.
-- Search for a filename in a project.
-- Allow sorting groups in the API.
-- Fix: Todos Filter Shows All Users.
-- Use the Gitlab Workhorse HTTP header in the admin dashboard. (Chris Wright)
-- Fixed multiple requests sent when opening dropdowns.
-- Added permissions per stage to cycle analytics endpoint.
-- Fix project Visibility Level selector not using default values.
-- Add events per stage to cycle analytics.
-- Allow to test JIRA service settings without having a repository.
-- Fix JIRA references for project snippets.
-- Allow enabling and disabling commit and MR events for JIRA.
-- simplify url generation. (Jarka Kadlecova)
-- Show correct environment log in admin/logs (@duk3luk3 !7191)
-- Fix Milestone dropdown not stay selected for `Upcoming` and `No Milestone` option !7117
-- Diff collapse won't shift when collapsing.
-- Backups do not fail anymore when using tar on annex and custom_hooks only. !5814
-- Adds user project membership expired event to clarify why user was removed (Callum Dryden)
-- Trim leading and trailing whitespace on project_path (Linus Thiel)
-- Prevent award emoji via notes for issues/MRs authored by user (barthc)
-- Adds support for the `token` attribute in project hooks API (Gauvain Pocentek)
-- Change auto selection behaviour of emoji and slash commands to be more UX/Type friendly (Yann Gravrand)
-- Adds an optional path parameter to the Commits API to filter commits by path (Luis HGO)
-- Fix Markdown styling inside reference links (Jan Zdráhal)
-- Create new issue board list after creating a new label
-- Fix extra space on Build sidebar on Firefox !7060
-- Fail gracefully when creating merge request with non-existing branch (alexsanford)
-- Fix mobile layout issues in admin user overview page !7087
-- Fix HipChat notifications rendering (airatshigapov, eisnerd)
-- Removed unneeded "Builds" and "Environments" link from project titles
-- Remove 'Edit' button from wiki edit view !7143 (Hiroyuki Sato)
-- Cleaned up global namespace JS !19661 (Jose Ivan Vargas)
-- Refactor Jira service to use jira-ruby gem
-- Improved todos empty state
-- Add hover to trash icon in notes !7008 (blackst0ne)
-- Hides project activity tabs when features are disabled
-- Only show one error message for an invalid email !5905 (lycoperdon)
-- Added guide describing how to upgrade PostgreSQL using Slony
-- Fix sidekiq stats in admin area (blackst0ne)
-- Added label description as tooltip to issue board list title
-- Created cycle analytics bundle JavaScript file
-- Make the milestone page more responsive (yury-n)
-- Hides container registry when repository is disabled
-- API: Fix booleans not recognized as such when using the `to_boolean` helper
-- Removed delete branch tooltip !6954
-- Stop unauthorized users dragging on milestone page (blackst0ne)
-- Restore issue boards welcome message when a project is created !6899
-- Check that JavaScript file names match convention !7238 (winniehell)
-- Do not show tooltip for active element !7105 (winniehell)
-- Escape ref and path for relative links !6050 (winniehell)
-- Fixed link typo on /help/ui to Alerts section. !6915 (Sam Rose)
-- Fix broken issue/merge request links in JIRA comments. !6143 (Brian Kintz)
-- Fix filtering of milestones with quotes in title (airatshigapov)
-- Fix issue boards dragging bug in Safari
-- Refactor less readable existance checking code from CoffeeScript !6289 (jlogandavison)
-- Update mail_room and enable sentinel support to Reply By Email (!7101)
-- Add task completion status in Issues and Merge Requests tabs: "X of Y tasks completed" (!6527, @gmesalazar)
-- Simpler arguments passed to named_route on toggle_award_url helper method
-- Fix typo in framework css class. !7086 (Daniel Voogsgerd)
-- New issue board list dropdown stays open after adding a new list
-- Fix: Backup restore doesn't clear cache
-- Optimize Event queries by removing default order
-- Add new icon for skipped builds
-- Show created icon in pipeline mini-graph
-- Remove duplicate links from sidebar
-- API: Fix project deploy keys 400 and 500 errors when adding an existing key. !6784 (Joshua Welsh)
-- Add Rake task to create/repair GitLab Shell hooks symlinks !5634
-- Add job for removal of unreferenced LFS objects from both the database and the filesystem (Frank Groeneveld)
-- Replace jquery.cookie plugin with js.cookie !7085
-- Use MergeRequestsClosingIssues cache data on Issue#closed_by_merge_requests method
-- Fix Sign in page 'Forgot your password?' link overlaps on medium-large screens
-- Show full status link on MR & commit pipelines
-- Fix documents and comments on Build API `scope`
-- Initialize Sidekiq with the list of queues used by GitLab
-- Refactor email, use setter method instead AR callbacks for email attribute (Semyon Pupkov)
-- Shortened merge request modal to let clipboard button not overlap
-- Adds JavaScript validation for group path editing field
-- In all filterable drop downs, put input field in focus only after load is complete (Ido @leibo)
-- Improve search query parameter naming in /admin/users !7115 (YarNayar)
-- Fix table pagination to be responsive
-- Fix applying GitHub-imported labels when importing job is interrupted
-- Allow to search for user by secondary email address in the admin interface(/admin/users) !7115 (YarNayar)
-- Updated commit SHA styling on the branches page.
-- 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
-- Updated Turbolinks to mitigate potential XSS attacks.
-
-## 8.13.10 (2016-12-14)
-
-- API: Memoize the current_user so that sudo can work properly. !8017
-- Filter `authentication_token`, `incoming_email_token` and `runners_token` parameters.
-- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
-- Fix missing Note access checks by moving Note#search to updated NoteFinder.
-
-## 8.13.9 (2016-12-08)
-
-- Reenables /user API request to return private-token if user is admin and request is made with sudo. !7615
-- Replace MR access checks with use of MergeRequestsFinder.
-
-## 8.13.8 (2016-12-02)
-
-- Pass tag SHA to post-receive hook when tag is created via UI. !7700
-- Validate state param when filtering issuables.
-
-## 8.13.7 (2016-11-28)
-
-- fixes 500 error on project show when user is not logged in and project is still empty. !7376
-- Update grape entity to 0.6.0. !7491
-- Fix information disclosure in `Projects::BlobController#update`.
-- Fix missing access checks on issue lookup using IssuableFinder.
-- Replace issue access checks with use of IssuableFinder.
-- Non members cannot create labels through the API.
-
-## 8.13.6 (2016-11-17)
-
-- Omniauth auto link LDAP user falls back to find by DN when user cannot be found by UID. !7002
-- Fix Milestone dropdown not stay selected for `Upcoming` and `No Milestone` option. !7117
-- Fix relative links in Markdown wiki when displayed in "Project" tab. !7218
-- Fix no "Register" tab if ldap auth is enabled (#24038). !7274 (Luc Didry)
-- Fix cache for commit status in commits list to respect branches. !7372
-- Fix issue causing Labels not to appear in sidebar on MR page. !7416 (Alex Sanford)
-- Limit labels returned for a specific project as an administrator. !7496
-- Clicking "force remove source branch" label now toggles the checkbox again.
-- Allow commit note to be visible if repo is visible.
-- Fix project Visibility Level selector not using default values.
-
-## 8.13.5 (2016-11-08)
-
-- Restore unauthenticated access to public container registries
-- Fix showing pipeline status for a given commit from correct branch. !7034
-- Only skip group when it's actually a group in the "Share with group" select. !7262
-- Introduce round-robin project creation to spread load over multiple shards. !7266
-- Ensure merge request's "remove branch" accessors return booleans. !7267
-- Ensure external users are not able to clone disabled repositories.
-- Fix XSS issue in Markdown autolinker.
-- Respect event visibility in Gitlab::ContributionsCalendar.
-- Honour issue and merge request visibility in their respective finders.
-- Disable reference Markdown for unavailable features.
-- Fix lightweight tags not processed correctly by GitTagPushService. !6532
-- Allow owners to fetch source code in CI builds. !6943
-- Return conflict error in label API when title is taken by group label. !7014
-- Reduce the overhead to calculate number of open/closed issues and merge requests within the group or project. !7123
-- Fix builds tab visibility. !7178
-- Fix project features default values. !7181
-
-## 8.13.4
-
-- Pulled due to packaging error.
-
-## 8.13.3 (2016-11-02)
-
-- Removes any symlinks before importing a project export file. CVE-2016-9086
-- Fixed Import/Export foreign key issue to do with project members.
-- Changed build dropdown list length to be 6,5 builds long in the pipeline graph
-
-## 8.13.2 (2016-10-31)
-
-- Fix encoding issues on pipeline commits. !6832
-- Use Hash rocket syntax to fix cycle analytics under Ruby 2.1. !6977
-- Modify GitHub importer to be retryable. !7003
-- Fix refs dropdown selection with special characters. !7061
-- Fix horizontal padding for highlight blocks. !7062
-- Pass user instance to `Labels::FindOrCreateService` or `skip_authorization: true`. !7093
-- Fix builds dropdown overlapping bug. !7124
-- Fix applying labels for GitHub-imported MRs. !7139
-- Fix importing MR comments from GitHub. !7139
-- Fix project member access for group links. !7144
-- API: Fix booleans not recognized as such when using the `to_boolean` helper. !7149
-- Fix and improve `Sortable.highest_label_priority`. !7165
-- Fixed sticky merge request tabs when sidebar is pinned. !7167
-- Only remove right connector of first build of last stage. !7179
-
-## 8.13.1 (2016-10-25)
-
-- Fix branch protection API. !6215
-- Fix hidden pipeline graph on commit and MR page. !6895
-- Fix Cycle analytics not showing correct data when filtering by date. !6906
-- Ensure custom provider tab labels don't break layout. !6993
-- Fix issue boards user link when in subdirectory. !7018
-- Refactor and add new environment functionality to CI yaml reference. !7026
-- Fix typo in project settings that prevents users from enabling container registry. !7037
-- Fix events order in `users/:id/events` endpoint. !7039
-- Remove extra line for empty issue description. !7045
-- Don't append issue/MR templates to any existing text. !7050
-- Fix error in generating labels. !7055
-- Stop clearing the database cache on `rake cache:clear`. !7056
-- Only show register tab if signup enabled. !7058
-- Fix lightweight tags not processed correctly by GitTagPushService
-- Expire and build repository cache after project import. !7064
-- Fix bug where labels would be assigned to issues that were moved. !7065
-- Fix reply-by-email not working due to queue name mismatch. !7068
-- Fix 404 for group pages when GitLab setup uses relative url. !7071
-- Fix `User#to_reference`. !7088
-- Reduce overhead of `LabelFinder` by avoiding `#presence` call. !7094
-- Fix unauthorized users dragging on issue boards. !7096
-- Only schedule `ProjectCacheWorker` jobs when needed. !7099
-
-## 8.13.0 (2016-10-22)
-
-- Fix save button on project pipeline settings page. (!6955)
-- All Sidekiq workers now use their own queue
-- Avoid race condition when asynchronously removing expired artifacts. (!6881)
-- Improve Merge When Build Succeeds triggers and execute on pipeline success. (!6675)
-- Respond with 404 Not Found for non-existent tags (Linus Thiel)
-- Truncate long labels with ellipsis in labels page
-- Improve tabbing usability for sign in page (ClemMakesApps)
-- Enforce TrailingSemicolon and EmptyLineBetweenBlocks in scss-lint
-- Adding members no longer silently fails when there is extra whitespace
-- Update runner version only when updating contacted_at
-- Add link from system note to compare with previous version
-- Use gitlab-shell v3.6.6
-- Ignore references to internal issues when using external issues tracker
-- Ability to resolve merge request conflicts with editor !6374
-- Add `/projects/visible` API endpoint (Ben Boeckel)
-- Fix centering of custom header logos (Ashley Dumaine)
-- Keep around commits only pipeline creation as pipeline data doesn't change over time
-- Update duration at the end of pipeline
-- ExpireBuildArtifactsWorker query builds table without ordering enqueuing one job per build to cleanup
-- Add group level labels. (!6425)
-- Add an example for testing a phoenix application with Gitlab CI in the docs (Manthan Mallikarjun)
-- Cancelled pipelines could be retried. !6927
-- Updating verbiage on git basics to be more intuitive
-- Fix project_feature record not generated on project creation
-- Clarify documentation for Runners API (Gennady Trafimenkov)
-- Use optimistic locking for pipelines and builds
-- The instrumentation for Banzai::Renderer has been restored
-- Change user & group landing page routing from /u/:username to /:username
-- Added documentation for .gitattributes files
-- Move Pipeline Metrics to separate worker
-- AbstractReferenceFilter caches project_refs on RequestStore when active
-- Replaced the check sign to arrow in the show build view. !6501
-- Add a /wip slash command to toggle the Work In Progress status of a merge request. !6259 (tbalthazar)
-- ProjectCacheWorker updates caches at most once per 15 minutes per project
-- Fix Error 500 when viewing old merge requests with bad diff data
-- Create a new /templates namespace for the /licenses, /gitignores and /gitlab_ci_ymls API endpoints. !5717 (tbalthazar)
-- Fix viewing merged MRs when the source project has been removed !6991
-- Speed-up group milestones show page
-- Fix inconsistent options dropdown caret on mobile viewports (ClemMakesApps)
-- Extract project#update_merge_requests and SystemHooks to its own worker from GitPushService
-- Fix discussion thread from emails for merge requests. !7010
-- Don't include archived projects when creating group milestones. !4940 (Jeroen Jacobs)
-- Add tag shortcut from the Commit page. !6543
-- Keep refs for each deployment
-- Close open tooltips on page navigation (Linus Thiel)
-- Allow browsing branches that end with '.atom'
-- Log LDAP lookup errors and don't swallow unrelated exceptions. !6103 (Markus Koller)
-- Replace unique keyframes mixin with keyframe mixin with specific names (ClemMakesApps)
-- Add more tests for calendar contribution (ClemMakesApps)
-- Update Gitlab Shell to fix some problems with moving projects between storages
-- Cache rendered markdown in the database, rather than Redis
-- Add todo toggle event (ClemMakesApps)
-- Avoid database queries on Banzai::ReferenceParser::BaseParser for nodes without references
-- Simplify Mentionable concern instance methods
-- API: Ability to retrieve version information (Robert Schilling)
-- Fix permission for setting an issue's due date
-- API: Multi-file commit !6096 (mahcsig)
-- Unicode emoji are now converted to images
-- Revert "Label list shows all issues (opened or closed) with that label"
-- Expose expires_at field when sharing project on API
-- Fix VueJS template tags being rendered in code comments
-- Added copy file path button to merge request diff files
-- Fix issue with page scrolling to top when closing or pinning sidebar (lukehowell)
-- Add Issue Board API support (andrebsguedes)
-- Allow the Koding integration to be configured through the API
-- Add new issue button to each list on Issues Board
-- Execute specific named route method from toggle_award_url helper method
-- Added soft wrap button to repository file/blob editor
-- Update namespace validation to forbid reserved names (.git and .atom) (Will Starms)
-- Show the time ago a merge request was deployed to an environment
-- Add RTL support to markdown renderer (Ebrahim Byagowi)
-- Add word-wrap to issue title on issue and milestone boards (ClemMakesApps)
-- Fix todos page mobile viewport layout (ClemMakesApps)
-- Make issues search less finicky
-- Fix inconsistent highlighting of already selected activity nav-links (ClemMakesApps)
-- Remove redundant mixins (ClemMakesApps)
-- Added 'Download' button to the Snippets page (Justin DiPierro)
-- Add visibility level to project repository
-- Fix robots.txt disallowing access to groups starting with "s" (Matt Harrison)
-- Close open merge request without source project (Katarzyna Kobierska Ula Budziszewska)
-- Fix showing commits from source project for merge request !6658
-- Fix that manual jobs would no longer block jobs in the next stage. !6604
-- Add configurable email subject suffix (Fu Xu)
-- Use defined colour for a language when available !6748 (nilsding)
-- Added tooltip to fork count on project show page. (Justin DiPierro)
-- Use a ConnectionPool for Rails.cache on Sidekiq servers
-- Replace `alias_method_chain` with `Module#prepend`
-- Enable GitLab Import/Export for non-admin users.
-- Preserve label filters when sorting !6136 (Joseph Frazier)
-- MergeRequest#new form load diff asynchronously
-- Only update issuable labels if they have been changed
-- Take filters in account in issuable counters. !6496
-- Use custom Ruby images to test builds (registry.dev.gitlab.org/gitlab/gitlab-build-images:*)
-- Replace static issue fixtures by script !6059 (winniehell)
-- Append issue template to existing description !6149 (Joseph Frazier)
-- Trending projects now only show public projects and the list of projects is cached for a day
-- Memoize Gitlab Shell's secret token (!6599, Justin DiPierro)
-- Revoke button in Applications Settings underlines on hover.
-- Use higher size on Gitlab::Redis connection pool on Sidekiq servers
-- Add missing values to linter !6276 (Katarzyna Kobierska Ula Budziszewska)
-- Revert avoid touching file system on Build#artifacts?
-- Stop using a Redis lease when updating the project activity timestamp whenever a new event is created
-- Add disabled delete button to protected branches (ClemMakesApps)
-- Add broadcast messages and alerts below sub-nav
-- Better empty state for Groups view
-- API: New /users/:id/events endpoint
-- Update ruby-prof to 0.16.2. !6026 (Elan Ruusamäe)
-- Replace bootstrap caret with fontawesome caret (ClemMakesApps)
-- Fix unnecessary escaping of reserved HTML characters in milestone title. !6533
-- Add organization field to user profile
-- Change user pages routing from /u/:username/PATH to /users/:username/PATH. Old routes will redirect to the new ones for the time being.
-- Fix enter key when navigating search site search dropdown. !6643 (Brennan Roberts)
-- Fix deploy status responsiveness error !6633
-- Make searching for commits case insensitive
-- Fix resolved discussion display in side-by-side diff view !6575
-- Optimize GitHub importing for speed and memory
-- API: expose pipeline data in builds API (!6502, Guilherme Salazar)
-- Notify the Merger about merge after successful build (Dimitris Karakasilis)
-- Reduce queries needed to find users using their SSH keys when pushing commits
-- Prevent rendering the link to all when the author has no access (Katarzyna Kobierska Ula Budziszewska)
-- Fix broken repository 500 errors in project list
-- Fix the diff in the merge request view when converting a symlink to a regular file
-- Fix Pipeline list commit column width should be adjusted
-- Close todos when accepting merge requests via the API !6486 (tonygambone)
-- Ability to batch assign issues relating to a merge request to the author. !5725 (jamedjo)
-- Changed Slack service user referencing from full name to username (Sebastian Poxhofer)
-- Retouch environments list and deployments list
-- Add multiple command support for all label related slash commands !6780 (barthc)
-- Add Container Registry on/off status to Admin Area !6638 (the-undefined)
-- Add Nofollow for uppercased scheme in external urls !6820 (the-undefined)
-- Allow empty merge requests !6384 (Artem Sidorenko)
-- Grouped pipeline dropdown is a scrollable container
-- Cleanup Ci::ApplicationController. !6757 (Takuya Noguchi)
-- Fixes padding in all clipboard icons that have .btn class
-- Fix a typo in doc/api/labels.md
-- Fix double-escaping in activities tab (Alexandre Maia)
-- API: all unknown routing will be handled with 404 Not Found
-- Add docs for request profiling
-- Delete dynamic environments
-- Fix buggy iOS tooltip layering behavior.
-- Make guests unable to view MRs on private projects
-- Fix broken Project API docs (Takuya Noguchi)
-- Migrate invalid project members (owner -> master)
-
-## 8.12.12 (2016-12-08)
-
-- Replace MR access checks with use of MergeRequestsFinder
-- Reenables /user API request to return private-token if user is admin and request is made with sudo
-
-## 8.12.11 (2016-12-02)
-
-- No changes
-
-## 8.12.10 (2016-11-28)
-
-- Fix information disclosure in `Projects::BlobController#update`
-- Fix missing access checks on issue lookup using IssuableFinder
-- Replace issue access checks with use of IssuableFinder
-
-## 8.12.9 (2016-11-07)
-
-- Fix XSS issue in Markdown autolinker
-
-## 8.12.8 (2016-11-02)
-
-- Removes any symlinks before importing a project export file. CVE-2016-9086
-- Fixed Import/Export foreign key issue to do with project members.
-
-## 8.12.7
-
- - Prevent running `GfmAutocomplete` setup for each diff note. !6569
- - Fix long commit messages overflow viewport in file tree. !6573
- - Use `gitlab-markup` gem instead of `github-markup` to fix `.rst` file rendering. !6659
- - Prevent flash alert text from being obscured when container is fluid. !6694
- - Fix due date being displayed as `NaN` in Safari. !6797
- - Fix JS bug with select2 because of missing `data-field` attribute in select box. !6812
- - Do not alter `force_remove_source_branch` options on MergeRequest unless specified. !6817
- - Fix GFM autocomplete setup being called several times. !6840
- - Handle case where deployment ref no longer exists. !6855
-
-## 8.12.6
-
- - Update mailroom to 0.8.1 in Gemfile.lock !6814
-
-## 8.12.5
-
- - Switch from request to env in ::API::Helpers. !6615
- - Update the mail_room gem to 0.8.1 to fix a race condition with the mailbox watching thread. !6714
- - Improve issue load time performance by avoiding ORDER BY in find_by call. !6724
- - Add a new gitlab:users:clear_all_authentication_tokens task. !6745
- - Don't send Private-Token (API authentication) headers to Sentry
- - Share projects via the API only with groups the authenticated user can access
-
-## 8.12.4
-
- - Fix "Copy to clipboard" tooltip to say "Copied!" when clipboard button is clicked. !6294 (lukehowell)
- - Fix padding in build sidebar. !6506
- - Changed compare dropdowns to dropdowns with isolated search input. !6550
- - Fix race condition on LFS Token. !6592
- - Fix type mismatch bug when closing Jira issue. !6619
- - Fix lint-doc error. !6623
- - Skip wiki creation when GitHub project has wiki enabled. !6665
- - Fix issues importing services via Import/Export. !6667
- - Restrict failed login attempts for users with 2FA enabled. !6668
- - Fix failed project deletion when feature visibility set to private. !6688
- - Prevent claiming associated model IDs via import.
- - Set GitLab project exported file permissions to owner only
- - Improve the way merge request versions are compared with each other
-
-## 8.12.3
-
- - Update Gitlab Shell to support low IO priority for storage moves
-
-## 8.12.2
-
- - Fix Import/Export not recognising correctly the imported services.
- - Fix snippets pagination
- - Fix "Create project" button layout when visibility options are restricted
- - Fix List-Unsubscribe header in emails
- - Fix IssuesController#show degradation including project on loaded notes
- - Fix an issue with the "Commits" section of the cycle analytics summary. !6513
- - Fix errors importing project feature and milestone models using GitLab project import
- - Make JWT messages Docker-compatible
- - Fix duplicate branch entry in the merge request version compare dropdown
- - Respect the fork_project permission when forking projects
- - Only update issuable labels if they have been changed
- - Fix bug where 'Search results' repeated many times when a search in the emoji search form is cleared (Xavier Bick) (@zeiv)
- - Fix resolve discussion buttons endpoint path
- - Refactor remnants of CoffeeScript destructured opts and super !6261
-
-## 8.12.1
-
- - Fix a memory leak in HTML::Pipeline::SanitizationFilter::WHITELIST
- - Fix issue with search filter labels not displaying
-
-## 8.12.0 (2016-09-22)
-
- - Removes inconsistency regarding tagging immediatelly as merged once you create a new branch. !6408
- - Update the rouge gem to 2.0.6, which adds highlighting support for JSX, Prometheus, and others. !6251
- - Only check :can_resolve permission if the note is resolvable
- - Bump fog-aws to v0.11.0 to support ap-south-1 region
- - Add ability to fork to a specific namespace using API. (ritave)
- - Allow to set request_access_enabled for groups and projects
- - Cleanup misalignments in Issue list view !6206
- - Only create a protected branch upon a push to a new branch if a rule for that branch doesn't exist
- - Add Pipelines for Commit
- - Prune events older than 12 months. (ritave)
- - Prepend blank line to `Closes` message on merge request linked to issue (lukehowell)
- - Fix issues/merge-request templates dropdown for forked projects
- - Filter tags by name !6121
- - Update gitlab shell secret file also when it is empty. !3774 (glensc)
- - Give project selection dropdowns responsive width, make non-wrapping.
- - Fix note form hint showing slash commands supported for commits.
- - Make push events have equal vertical spacing.
- - API: Ensure invitees are not returned in Members API.
- - Preserve applied filters on issues search.
- - Add two-factor recovery endpoint to internal API !5510
- - Pass the "Remember me" value to the U2F authentication form
- - Display stages in valid order in stages dropdown on build page
- - Only update projects.last_activity_at once per hour when creating a new event
- - Cycle analytics (first iteration) !5986
- - Remove vendor prefixes for linear-gradient CSS (ClemMakesApps)
- - Move pushes_since_gc from the database to Redis
- - Limit number of shown environments on Merge Request: show only environments for target_branch, source_branch and tags
- - Add font color contrast to external label in admin area (ClemMakesApps)
- - Fix find file navigation links (ClemMakesApps)
- - Change logo animation to CSS (ClemMakesApps)
- - Instructions for enabling Git packfile bitmaps !6104
- - Use Search::GlobalService.new in the `GET /projects/search/:query` endpoint
- - Fix long comments in diffs messing with table width
- - Add spec covering 'Gitlab::Git::committer_hash' !6433 (dandunckelman)
- - Fix pagination on user snippets page
- - Honor "fixed layout" preference in more places !6422
- - Run CI builds with the permissions of users !5735
- - Fix sorting of issues in API
- - Fix download artifacts button links !6407
- - Sort project variables by key. !6275 (Diego Souza)
- - Ensure specs on sorting of issues in API are deterministic on MySQL
- - Added ability to use predefined CI variables for environment name
- - Added ability to specify URL in environment configuration in gitlab-ci.yml
- - Escape search term before passing it to Regexp.new !6241 (winniehell)
- - Fix pinned sidebar behavior in smaller viewports !6169
- - Fix file permissions change when updating a file on the Gitlab UI !5979
- - Added horizontal padding on build page sidebar on code coverage block. !6196 (Vitaly Baev)
- - Change merge_error column from string to text type
- - Fix issue with search filter labels not displaying
- - Reduce contributions calendar data payload (ClemMakesApps)
- - Show all pipelines for merge requests even from discarded commits !6414
- - Replace contributions calendar timezone payload with dates (ClemMakesApps)
- - Changed MR widget build status to pipeline status !6335
- - Add `web_url` field to issue, merge request, and snippet API objects (Ben Boeckel)
- - Enable pipeline events by default !6278
- - Add pipeline email service !6019
- - Move parsing of sidekiq ps into helper !6245 (pascalbetz)
- - Added go to issue boards keyboard shortcut
- - Expose `sha` and `merge_commit_sha` in merge request API (Ben Boeckel)
- - Emoji can be awarded on Snippets !4456
- - Set path for all JavaScript cookies to honor GitLab's subdirectory setting !5627 (Mike Greiling)
- - Fix blame table layout width
- - Spec testing if issue authors can read issues on private projects
- - Fix bug where pagination is still displayed despite all todos marked as done (ClemMakesApps)
- - Request only the LDAP attributes we need !6187
- - Center build stage columns in pipeline overview (ClemMakesApps)
- - Fix bug with tooltip not hiding on discussion toggle button
- - Rename behaviour to behavior in bug issue template for consistency (ClemMakesApps)
- - Fix bug stopping issue description being scrollable after selecting issue template
- - Remove suggested colors hover underline (ClemMakesApps)
- - Fix jump to discussion button being displayed on commit notes
- - Shorten task status phrase (ClemMakesApps)
- - Fix project visibility level fields on settings
- - Add hover color to emoji icon (ClemMakesApps)
- - Increase ci_builds artifacts_size column to 8-byte integer to allow larger files
- - Add textarea autoresize after comment (ClemMakesApps)
- - Do not write SSH public key 'comments' to authorized_keys !6381
- - Add due date to issue todos
- - Refresh todos count cache when an Issue/MR is deleted
- - Fix branches page dropdown sort alignment (ClemMakesApps)
- - Hides merge request button on branches page is user doesn't have permissions
- - Add white background for no readme container (ClemMakesApps)
- - API: Expose issue confidentiality flag. (Robert Schilling)
- - Fix markdown anchor icon interaction (ClemMakesApps)
- - Test migration paths from 8.5 until current release !4874
- - Replace animateEmoji timeout with eventListener (ClemMakesApps)
- - Show badges in Milestone tabs. !5946 (Dan Rowden)
- - Optimistic locking for Issues and Merge Requests (title and description overriding prevention)
- - Require confirmation when not logged in for unsubscribe links !6223 (Maximiliano Perez Coto)
- - Add `wiki_page_events` to project hook APIs (Ben Boeckel)
- - Remove Gitorious import
- - Loads GFM autocomplete source only when required
- - Fix issue with slash commands not loading on new issue page
- - Fix inconsistent background color for filter input field (ClemMakesApps)
- - Remove prefixes from transition CSS property (ClemMakesApps)
- - Add Sentry logging to API calls
- - Add BroadcastMessage API
- - Merge request tabs are fixed when scrolling page
- - Use 'git update-ref' for safer web commits !6130
- - Sort pipelines requested through the API
- - Automatically expand hidden discussions when accessed by a permalink !5585 (Mike Greiling)
- - Fix issue boards loading on large screens
- - Change pipeline duration to be jobs running time instead of simple wall time from start to end !6084
- - Show queued time when showing a pipeline !6084
- - Remove unused mixins (ClemMakesApps)
- - Fix issue board label filtering appending already filtered labels
- - Add search to all issue board lists
- - Scroll active tab into view on mobile
- - Fix groups sort dropdown alignment (ClemMakesApps)
- - Add horizontal scrolling to all sub-navs on mobile viewports (ClemMakesApps)
- - Use JavaScript tooltips for mentions !5301 (winniehell)
- - Add hover state to todos !5361 (winniehell)
- - Fix icon alignment of star and fork buttons !5451 (winniehell)
- - Fix alignment of icon buttons !5887 (winniehell)
- - Added Ubuntu 16.04 support for packager.io (JonTheNiceGuy)
- - Fix markdown help references (ClemMakesApps)
- - Add last commit time to repo view (ClemMakesApps)
- - Fix accessibility and visibility of project list dropdown button !6140
- - Fix missing flash messages on service edit page (airatshigapov)
- - Added project-specific enable/disable setting for LFS !5997
- - Added group-specific enable/disable setting for LFS !6164
- - Add optional 'author' param when making commits. !5822 (dandunckelman)
- - Don't expose a user's token in the `/api/v3/user` API (!6047)
- - Remove redundant js-timeago-pending from user activity log (ClemMakesApps)
- - Ability to manage project issues, snippets, wiki, merge requests and builds access level
- - Remove inconsistent font weight for sidebar's labels (ClemMakesApps)
- - Align add button on repository view (ClemMakesApps)
- - Fix contributions calendar month label truncation (ClemMakesApps)
- - Import release note descriptions from GitHub (EspadaV8)
- - Added tests for diff notes
- - Add pipeline events to Slack integration !5525
- - Add a button to download latest successful artifacts for branches and tags !5142
- - Remove redundant pipeline tooltips (ClemMakesApps)
- - Expire commit info views after one day, instead of two weeks, to allow for user email updates
- - Add delimiter to project stars and forks count (ClemMakesApps)
- - Fix badge count alignment (ClemMakesApps)
- - Remove green outline from `New branch unavailable` button on issue page !5858 (winniehell)
- - Fix repo title alignment (ClemMakesApps)
- - Change update interval of contacted_at
- - Add LFS support to SSH !6043
- - Fix branch title trailing space on hover (ClemMakesApps)
- - Don't include 'Created By' tag line when importing from GitHub if there is a linked GitLab account (EspadaV8)
- - Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison)
- - Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison)
- - Order award emoji tooltips in order they were added (EspadaV8)
- - Fix spacing and vertical alignment on build status icon on commits page (ClemMakesApps)
- - Update merge_requests.md with a simpler way to check out a merge request. !5944
- - Fix button missing type (ClemMakesApps)
- - Gitlab::Checks is now instrumented
- - Move to project dropdown with infinite scroll for better performance
- - Fix leaking of submit buttons outside the width of a main container !18731 (originally by @pavelloz)
- - Load branches asynchronously in Cherry Pick and Revert dialogs.
- - Convert datetime coffeescript spec to ES6 (ClemMakesApps)
- - Add merge request versions !5467
- - Change using size to use count and caching it for number of group members. !5935
- - Replace play icon font with svg (ClemMakesApps)
- - Added 'only_allow_merge_if_build_succeeds' project setting in the API. !5930 (Duck)
- - Reduce number of database queries on builds tab
- - Wrap text in commit message containers
- - Capitalize mentioned issue timeline notes (ClemMakesApps)
- - Fix inconsistent checkbox alignment (ClemMakesApps)
- - Use the default branch for displaying the project icon instead of master !5792 (Hannes Rosenögger)
- - Adds response mime type to transaction metric action when it's not HTML
- - Fix hover leading space bug in pipeline graph !5980
- - Avoid conflict with admin labels when importing GitHub labels
- - User can edit closed MR with deleted fork (Katarzyna Kobierska Ula Budziszewska) !5496
- - Fix repository page ui issues
- - Avoid protected branches checks when verifying access without branch name
- - Add information about user and manual build start to runner as variables !6201 (Sergey Gnuskov)
- - Fixed invisible scroll controls on build page on iPhone
- - Fix error on raw build trace download for old builds stored in database !4822
- - Refactor the triggers page and documentation !6217
- - Show values of CI trigger variables only when clicked (Katarzyna Kobierska Ula Budziszewska)
- - Use default clone protocol on "check out, review, and merge locally" help page URL
- - Let the user choose a namespace and name on GitHub imports
- - API for Ci Lint !5953 (Katarzyna Kobierska Urszula Budziszewska)
- - Allow bulk update merge requests from merge requests index page
- - Ensure validation messages are shown within the milestone form
- - Add notification_settings API calls !5632 (mahcsig)
- - Remove duplication between project builds and admin builds view !5680 (Katarzyna Kobierska Ula Budziszewska)
- - Fix URLs with anchors in wiki !6300 (houqp)
- - Deleting source project with existing fork link will close all related merge requests !6177 (Katarzyna Kobierska Ula Budziszeska)
- - Return 204 instead of 404 for /ci/api/v1/builds/register.json if no builds are scheduled for a runner !6225
- - Fix Gitlab::Popen.popen thread-safety issue
- - Add specs to removing project (Katarzyna Kobierska Ula Budziszewska)
- - Clean environment variables when running git hooks
- - Fix Import/Export issues importing protected branches and some specific models
- - Fix non-master branch readme display in tree view
- - Add UX improvements for merge request version diffs
-
-## 8.11.11 (2016-11-07)
-
-- Fix XSS issue in Markdown autolinker
-
-## 8.11.10 (2016-11-02)
-
-- Removes any symlinks before importing a project export file. CVE-2016-9086
-
-## 8.11.9
-
- - Don't send Private-Token (API authentication) headers to Sentry
- - Share projects via the API only with groups the authenticated user can access
-
-## 8.11.8
-
- - Respect the fork_project permission when forking projects
- - Set a restrictive CORS policy on the API for credentialed requests
- - API: disable rails session auth for non-GET/HEAD requests
- - Escape HTML nodes in builds commands in CI linter
-
-## 8.11.7
-
- - Avoid conflict with admin labels when importing GitHub labels. !6158
- - Restores `fieldName` to allow only string values in `gl_dropdown.js`. !6234
- - Allow the Rails cookie to be used for API authentication.
- - Login/Register UX upgrade !6328
-
-## 8.11.6
-
- - Fix unnecessary horizontal scroll area in pipeline visualizations. !6005
- - Make merge conflict file size limit 200 KB, to match the docs. !6052
- - Fix an error where we were unable to create a CommitStatus for running state. !6107
- - Optimize discussion notes resolving and unresolving. !6141
- - Fix GitLab import button. !6167
- - Restore SSH Key title auto-population behavior. !6186
- - Fix DB schema to match latest migration. !6256
- - Exclude some pending or inactivated rows in Member scopes.
-
-## 8.11.5
-
- - Optimize branch lookups and force a repository reload for Repository#find_branch. !6087
- - Fix member expiration date picker after update. !6184
- - Fix suggested colors options for new labels in the admin area. !6138
- - Optimize discussion notes resolving and unresolving
- - Fix GitLab import button
- - Fix confidential issues being exposed as public using gitlab.com export
- - Remove gitorious from import_sources. !6180
- - Scope webhooks/services that will run for confidential issues
- - Remove gitorious from import_sources
- - Fix confidential issues being exposed as public using gitlab.com export
- - Use oj gem for faster JSON processing
-
-## 8.11.4
-
- - Fix resolving conflicts on forks. !6082
- - Fix diff commenting on merge requests created prior to 8.10. !6029
- - Fix pipelines tab layout regression. !5952
- - Fix "Wiki" link not appearing in navigation for projects with external wiki. !6057
- - Do not enforce using hash with hidden key in CI configuration. !6079
- - Fix hover leading space bug in pipeline graph !5980
- - Fix sorting issues by "last updated" doesn't work after import from GitHub
- - GitHub importer use default project visibility for non-private projects
- - Creating an issue through our API now emails label subscribers !5720
- - Block concurrent updates for Pipeline
- - Don't create groups for unallowed users when importing projects
- - Fix issue boards leak private label names and descriptions
- - Fix broken gitlab:backup:restore because of bad permissions on repo storage !6098 (Dirk Hörner)
- - Remove gitorious. !5866
- - Allow compare merge request versions
-
-## 8.11.3
-
- - Allow system info page to handle case where info is unavailable
- - Label list shows all issues (opened or closed) with that label
- - Don't show resolve conflicts link before MR status is updated
- - Fix IE11 fork button bug !5982
- - Don't prevent viewing the MR when git refs for conflicts can't be found on disk
- - Fix external issue tracker "Issues" link leading to 404s
- - Don't try to show merge conflict resolution info if a merge conflict contains non-UTF-8 characters
- - Automatically expand hidden discussions when accessed by a permalink !5585 (Mike Greiling)
- - Issues filters reset button
-
-## 8.11.2
-
- - Show "Create Merge Request" widget for push events to fork projects on the source project. !5978
- - Use gitlab-workhorse 0.7.11 !5983
- - Does not halt the GitHub import process when an error occurs. !5763
- - Fix file links on project page when default view is Files !5933
- - Fixed enter key in search input not working !5888
-
-## 8.11.1
-
- - Pulled due to packaging error.
-
-## 8.11.0 (2016-08-22)
-
- - Use test coverage value from the latest successful pipeline in badge. !5862
- - Add test coverage report badge. !5708
- - Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar)
- - Add Koding (online IDE) integration
- - Ability to specify branches for Pivotal Tracker integration (Egor Lynko)
- - Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres)
- - Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres)
- - Fix adding line comments on the initial commit to a repo !5900
- - Fix the title of the toggle dropdown button. !5515 (herminiotorres)
- - Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
- - Update to Ruby 2.3.1. !4948
- - Add Issues Board !5548
- - Allow resolving merge conflicts in the UI !5479
- - Improve diff performance by eliminating redundant checks for text blobs
- - Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
- - Convert switch icon into icon font (ClemMakesApps)
- - API: Endpoints for enabling and disabling deploy keys
- - API: List access requests, request access, approve, and deny access requests to a project or a group. !4833
- - Use long options for curl examples in documentation !5703 (winniehell)
- - Added tooltip listing label names to the labels value in the collapsed issuable sidebar
- - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
- - GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository
- - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell)
- - Allow naming U2F devices !5833
- - Ignore URLs starting with // in Markdown links !5677 (winniehell)
- - Fix CI status icon link underline (ClemMakesApps)
- - The Repository class is now instrumented
- - Fix commit mention font inconsistency (ClemMakesApps)
- - Do not escape URI when extracting path !5878 (winniehell)
- - Fix filter label tooltip HTML rendering (ClemMakesApps)
- - Cache the commit author in RequestStore to avoid extra lookups in PostReceive
- - Expand commit message width in repo view (ClemMakesApps)
- - Cache highlighted diff lines for merge requests
- - Pre-create all builds for a Pipeline when the new Pipeline is created !5295
- - Allow merge request diff notes and discussions to be explicitly marked as resolved
- - API: Add deployment endpoints
- - API: Add Play endpoint on Builds
- - Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
- - Show wall clock time when showing a pipeline. !5734
- - Show member roles to all users on members page
- - Project.visible_to_user is instrumented again
- - Fix awardable button mutuality loading spinners (ClemMakesApps)
- - Sort todos by date and priority
- - Add support for using RequestStore within Sidekiq tasks via SIDEKIQ_REQUEST_STORE env variable
- - Optimize maximum user access level lookup in loading of notes
- - Send notification emails to users newly mentioned in issue and MR edits !5800
- - Add "No one can push" as an option for protected branches. !5081
- - Improve performance of AutolinkFilter#text_parse by using XPath
- - Add experimental Redis Sentinel support !1877
- - Rendering of SVGs as blobs is now limited to SVGs with a size smaller or equal to 2MB
- - Fix branches page dropdown sort initial state (ClemMakesApps)
- - Environments have an url to link to
- - Various redundant database indexes have been removed
- - Update `timeago` plugin to use multiple string/locale settings
- - Remove unused images (ClemMakesApps)
- - Get issue and merge request description templates from repositories
- - Enforce 2FA restrictions on API authentication endpoints !5820
- - Limit git rev-list output count to one in forced push check
- - Show deployment status on merge requests with external URLs
- - Clean up unused routes (Josef Strzibny)
- - Fix issue on empty project to allow developers to only push to protected branches if given permission
- - API: Add enpoints for pipelines
- - Add green outline to New Branch button. !5447 (winniehell)
- - Optimize generating of cache keys for issues and notes
- - Fix repository push email formatting in Outlook
- - Improve performance of syntax highlighting Markdown code blocks
- - Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects
- - Remove delay when hitting "Reply..." button on page with a lot of discussions
- - Retrieve rendered HTML from cache in one request
- - Fix renaming repository when name contains invalid chararacters under project settings
- - Upgrade Grape from 0.13.0 to 0.15.0. !4601
- - Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries
- - Fix devise deprecation warnings.
- - Check for 2FA when using Git over HTTP and only allow PersonalAccessTokens as password in that case !5764
- - Update version_sorter and use new interface for faster tag sorting
- - Optimize checking if a user has read access to a list of issues !5370
- - Store all DB secrets in secrets.yml, under descriptive names !5274
- - Fix syntax highlighting in file editor
- - Support slash commands in issue and merge request descriptions as well as comments. !5021
- - Nokogiri's various parsing methods are now instrumented
- - Add archived badge to project list !5798
- - Add simple identifier to public SSH keys (muteor)
- - Admin page now references docs instead of a specific file !5600 (AnAverageHuman)
- - Fix filter input alignment (ClemMakesApps)
- - Include old revision in merge request update hooks (Ben Boeckel)
- - Add build event color in HipChat messages (David Eisner)
- - Make fork counter always clickable. !5463 (winniehell)
- - Document that webhook secret token is sent in X-Gitlab-Token HTTP header !5664 (lycoperdon)
- - Gitlab::Highlight is now instrumented
- - All created issues, API or WebUI, can be submitted to Akismet for spam check !5333
- - Allow users to import cross-repository pull requests from GitHub
- - The overhead of instrumented method calls has been reduced
- - Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le)
- - Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
- - Add pipeline events hook
- - Bump gitlab_git to speedup DiffCollection iterations
- - Rewrite description of a blocked user in admin settings. (Elias Werberich)
- - Make branches sortable without push permission !5462 (winniehell)
- - Check for Ci::Build artifacts at database level on pipeline partial
- - Convert image diff background image to CSS (ClemMakesApps)
- - Remove unnecessary index_projects_on_builds_enabled index from the projects table
- - Make "New issue" button in Issue page less obtrusive !5457 (winniehell)
- - Gitlab::Metrics.current_transaction needs to be public for RailsQueueDuration
- - Fix search for notes which belongs to deleted objects
- - Allow Akismet to be trained by submitting issues as spam or ham !5538
- - Add GitLab Workhorse version to admin dashboard (Katarzyna Kobierska Ula Budziszewska)
- - Allow branch names ending with .json for graph and network page !5579 (winniehell)
- - Add the `sprockets-es6` gem
- - Improve OAuth2 client documentation (muteor)
- - Fix diff comments inverted toggle bug (ClemMakesApps)
- - Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska)
- - Profile requests when a header is passed
- - Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab.
- - Speedup DiffNote#active? on discussions, preloading noteables and avoid touching git repository to return diff_refs when possible
- - Add commit stats in commit api. !5517 (dixpac)
- - Add CI configuration button on project page
- - Fix merge request new view not changing code view rendering style
- - edit_blob_link will use blob passed onto the options parameter
- - Make error pages responsive (Takuya Noguchi)
- - The performance of the project dropdown used for moving issues has been improved
- - Fix skip_repo parameter being ignored when destroying a namespace
- - Add all builds into stage/job dropdowns on builds page
- - Change requests_profiles resource constraint to catch virtually any file
- - Bump gitlab_git to lazy load compare commits
- - Reduce number of queries made for merge_requests/:id/diffs
- - Add the option to set the expiration date for the project membership when giving a user access to a project. !5599 (Adam Niedzielski)
- - Sensible state specific default sort order for issues and merge requests !5453 (tomb0y)
- - Fix bug where destroying a namespace would not always destroy projects
- - Fix RequestProfiler::Middleware error when code is reloaded in development
- - Allow horizontal scrolling of code blocks in issue body
- - Catch what warden might throw when profiling requests to re-throw it
- - Avoid commit lookup on diff_helper passing existing local variable to the helper method
- - Add description to new_issue email and new_merge_request_email in text/plain content type. !5663 (dixpac)
- - Speed up and reduce memory usage of Commit#repo_changes, Repository#expire_avatar_cache and IrkerWorker
- - Add unfold links for Side-by-Side view. !5415 (Tim Masliuchenko)
- - Adds support for pending invitation project members importing projects
- - Add pipeline visualization/graph on pipeline page
- - Update devise initializer to turn on changed password notification emails. !5648 (tombell)
- - Avoid to show the original password field when password is automatically set. !5712 (duduribeiro)
- - Fix importing GitLab projects with an invalid MR source project
- - Sort folders with submodules in Files view !5521
- - Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0
- - Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska)
- - Add pipelines tab to merge requests
- - Fix notification_service argument error of declined invitation emails
- - Fix a memory leak caused by Banzai::Filter::SanitizationFilter
- - Speed up todos queries by limiting the projects set we join with
- - Ensure file editing in UI does not overwrite commited changes without warning user
- - Eliminate unneeded calls to Repository#blob_at when listing commits with no path
- - Update gitlab_git gem to 10.4.7
- - Simplify SQL queries of marking a todo as done
-
-## 8.10.13 (2016-11-02)
-
-- Removes any symlinks before importing a project export file. CVE-2016-9086
-
-## 8.10.12
-
- - Don't send Private-Token (API authentication) headers to Sentry
- - Share projects via the API only with groups the authenticated user can access
-
-## 8.10.11
-
- - Respect the fork_project permission when forking projects
- - Set a restrictive CORS policy on the API for credentialed requests
- - API: disable rails session auth for non-GET/HEAD requests
- - Escape HTML nodes in builds commands in CI linter
-
-## 8.10.10
-
- - Allow the Rails cookie to be used for API authentication.
-
-## 8.10.9
-
- - Exclude some pending or inactivated rows in Member scopes
-
-## 8.10.8
-
- - Fix information disclosure in issue boards.
- - Fix privilege escalation in project import.
-
-## 8.10.7
-
- - Upgrade Hamlit to 2.6.1. !5873
- - Upgrade Doorkeeper to 4.2.0. !5881
-
-## 8.10.6
-
- - Upgrade Rails to 4.2.7.1 for security fixes. !5781
- - Restore "Largest repository" sort option on Admin > Projects page. !5797
- - Fix privilege escalation via project export.
- - Require administrator privileges to perform a project import.
-
-## 8.10.5
-
- - Add a data migration to fix some missing timestamps in the members table. !5670
- - Revert the "Defend against 'Host' header injection" change in the source NGINX templates. !5706
- - Cache project count for 5 minutes to reduce DB load. !5746 & !5754
-
-## 8.10.4
-
- - Don't close referenced upstream issues from a forked project.
- - Fixes issue with dropdowns `enter` key not working correctly. !5544
- - Fix Import/Export project import not working in HA mode. !5618
- - Fix Import/Export error checking versions. !5638
-
-## 8.10.3
-
- - Fix Import/Export issue importing milestones and labels not associated properly. !5426
- - Fix timing problems running imports on production. !5523
- - Add a log message when a project is scheduled for destruction for debugging. !5540
- - Fix hooks missing on imported GitLab projects. !5549
- - Properly abort a merge when merge conflicts occur. !5569
- - Fix importer for GitHub Pull Requests when a branch was removed. !5573
- - Ignore invalid IPs in X-Forwarded-For when trusted proxies are configured. !5584
- - Trim extra displayed carriage returns in diffs and files with CRLFs. !5588
- - Fix label already exist error message in the right sidebar.
-
-## 8.10.2
-
- - User can now search branches by name. !5144
- - Page is now properly rendered after committing the first file and creating the first branch. !5399
- - Add branch or tag icon to ref in builds page. !5434
- - Fix backup restore. !5459
- - Use project ID in repository cache to prevent stale data from persisting across projects. !5460
- - Fix issue with autocomplete search not working with enter key. !5466
- - Add iid to MR API response. !5468
- - Disable MySQL foreign key checks before dropping all tables. !5472
- - Ensure relative paths for video are rewritten as we do for images. !5474
- - Ensure current user can retry a build before showing the 'Retry' button. !5476
- - Add ENV variable to skip repository storages validations. !5478
- - Added `*.js.es6 gitlab-language=javascript` to `.gitattributes`. !5486
- - Don't show comment button in gutter of diffs on MR discussion tab. !5493
- - Rescue Rugged::OSError (lock exists) when creating references. !5497
- - Fix expand all diffs button in compare view. !5500
- - Show release notes in tags list. !5503
- - Fix a bug where forking a project from a repository storage to another would fail. !5509
- - Fix missing schema update for `20160722221922`. !5512
- - Update `gitlab-shell` version to 3.2.1 in the 8.9->8.10 update guide. !5516
-
-## 8.10.1
-
- - Refactor repository storages documentation. !5428
- - Gracefully handle case when keep-around references are corrupted or exist already. !5430
- - Add detailed info on storage path mountpoints. !5437
- - Fix Error 500 when creating Wiki pages with hyphens or spaces. !5444
- - Fix bug where replies to commit notes displayed in the MR discussion tab wouldn't show up on the commit page. !5446
- - Ignore invalid trusted proxies in X-Forwarded-For header. !5454
- - Add links to the real markdown.md file for all GFM examples. !5458
-
-## 8.10.0 (2016-07-22)
-
- - Fix profile activity heatmap to show correct day name (eanplatter)
- - Speed up ExternalWikiHelper#get_project_wiki_path
- - Expose {should,force}_remove_source_branch (Ben Boeckel)
- - Add the functionality to be able to rename a file. !5049
- - Disable PostgreSQL statement timeout during migrations
- - Fix projects dropdown loading performance with a simplified api cal. !5113
- - Fix commit builds API, return all builds for all pipelines for given commit. !4849
- - Replace Haml with Hamlit to make view rendering faster. !3666
- - Refresh the branch cache after `git gc` runs
- - Allow to disable request access button on projects/groups
- - Refactor repository paths handling to allow multiple git mount points
- - Optimize system note visibility checking by memoizing the visible reference count. !5070
- - Add Application Setting to configure default Repository Path for new projects
- - Delete award emoji when deleting a user
- - Remove pinTo from Flash and make inline flash messages look nicer. !4854 (winniehell)
- - Add an API for downloading latest successful build from a particular branch or tag. !5347
- - Avoid data-integrity issue when cleaning up repository archive cache.
- - Add link to profile to commit avatar. !5163 (winniehell)
- - Wrap code blocks on Activies and Todos page. !4783 (winniehell)
- - Align flash messages with left side of page content. !4959 (winniehell)
- - Display tooltip for "Copy to Clipboard" button. !5164 (winniehell)
- - Use default cursor for table header of project files. !5165 (winniehell)
- - Store when and yaml variables in builds table
- - Display last commit of deleted branch in push events. !4699 (winniehell)
- - Escape file extension when parsing search results. !5141 (winniehell)
- - Add "passing with warnings" to the merge request pipeline possible statuses, this happens when builds that allow failures have failed. !5004
- - Add image border in Markdown preview. !5162 (winniehell)
- - Apply the trusted_proxies config to the rack request object for use with rack_attack
- - Added the ability to block sign ups using a domain blacklist. !5259
- - Upgrade to Rails 4.2.7. !5236
- - Extend exposed environment variables for CI builds
- - Deprecate APIs "projects/:id/keys/...". Use "projects/:id/deploy_keys/..." instead
- - Add API "deploy_keys" for admins to get all deploy keys
- - Allow to pull code with deploy key from public projects
- - Use limit parameter rather than hardcoded value in `ldap:check` rake task (Mike Ricketts)
- - Add Sidekiq queue duration to transaction metrics.
- - Add a new column `artifacts_size` to table `ci_builds`. !4964
- - Let Workhorse serve format-patch diffs
- - Display tooltip for mentioned users and groups. !5261 (winniehell)
- - Allow build email service to be tested
- - Added day name to contribution calendar tooltips
- - Refactor user authorization check for a single project to avoid querying all user projects
- - Make images fit to the size of the viewport. !4810
- - Fix check for New Branch button on Issue page. !4630 (winniehell)
- - Fix GFM autocomplete not working on wiki pages
- - Fixed enter key not triggering click on first row when searching in a dropdown
- - Updated dropdowns in issuable form to use new GitLab dropdown style
- - Make images fit to the size of the viewport !4810
- - Fix check for New Branch button on Issue page !4630 (winniehell)
- - Fix MR-auto-close text added to description. !4836
- - Support U2F devices in Firefox. !5177
- - Fix issue, preventing users w/o push access to sort tags. !5105 (redetection)
- - Add Spring EmojiOne updates.
- - Added Rake task for tracking deployments. !5320
- - Fix fetching LFS objects for private CI projects
- - Add the new 2016 Emoji! Adds 72 new emoji including bacon, facepalm, and selfie. !5237
- - Add syntax for multiline blockquote using `>>>` fence. !3954
- - Fix viewing notification settings when a project is pending deletion
- - Updated compare dropdown menus to use GL dropdown
- - Redirects back to issue after clicking login link
- - Eager load award emoji on notes
- - Allow to define manual actions/builds on Pipelines and Environments
- - Fix pagination when sorting by columns with lots of ties (like priority)
- - The Markdown reference parsers now re-use query results to prevent running the same queries multiple times. !5020
- - Updated project header design
- - Issuable collapsed assignee tooltip is now the users name
- - Fix compare view not changing code view rendering style
- - Exclude email check from the standard health check
- - Updated layout for Projects, Groups, Users on Admin area. !4424
- - Fix changing issue state columns in milestone view
- - Update health_check gem to version 2.1.0
- - Add notification settings dropdown for groups
- - Render inline diffs for multiple changed lines following eachother
- - Wildcards for protected branches. !4665
- - Allow importing from Github using Personal Access Tokens. (Eric K Idema)
- - API: Expose `due_date` for issues (Robert Schilling)
- - API: Todos. !3188 (Robert Schilling)
- - API: Expose shared groups for projects and shared projects for groups. !5050 (Robert Schilling)
- - API: Expose `developers_can_push` and `developers_can_merge` for branches. !5208 (Robert Schilling)
- - Add "Enabled Git access protocols" to Application Settings
- - Diffs will create button/diff form on demand no on server side
- - Reduce size of HTML used by diff comment forms
- - Protected branches have a "Developers can Merge" setting. !4892 (original implementation by Mathias Vestergaard)
- - Fix user creation with stronger minimum password requirements. !4054 (nathan-pmt)
- - Only show New Snippet button to users that can create snippets.
- - PipelinesFinder uses git cache data
- - Track a user who created a pipeline
- - Actually render old and new sections of parallel diff next to each other
- - Throttle the update of `project.pushes_since_gc` to 1 minute.
- - Allow expanding and collapsing files in diff view. !4990
- - Collapse large diffs by default (!4990)
- - Fix mentioned users list on diff notes
- - Add support for inline videos in GitLab Flavored Markdown. !5215 (original implementation by Eric Hayes)
- - Fix creation of deployment on build that is retried, redeployed or rollback
- - Don't parse Rinku returned value to DocFragment when it didn't change the original html string.
- - Check for conflicts with existing Project's wiki path when creating a new project.
- - Show last push widget in upstream after push to fork
- - Fix stage status shown for pipelines
- - Cache todos pending/done dashboard query counts.
- - Don't instantiate a git tree on Projects show default view
- - Bump Rinku to 2.0.0
- - Remove unused front-end variable -> default_issues_tracker
- - ObjectRenderer retrieve renderer content using Rails.cache.read_multi
- - Better caching of git calls on ProjectsController#show.
- - Avoid to retrieve MR closes_issues as much as possible.
- - Hide project name in project activities. !5068 (winniehell)
- - Add API endpoint for a group issues. !4520 (mahcsig)
- - Add Bugzilla integration. !4930 (iamtjg)
- - Fix new snippet style bug (elliotec)
- - Instrument Rinku usage
- - Be explicit to define merge request discussion variables
- - Use cache for todos counter calling TodoService
- - Metrics for Rouge::Plugins::Redcarpet and Rouge::Formatters::HTMLGitlab
- - RailsCache metris now includes fetch_hit/fetch_miss and read_hit/read_miss info.
- - Allow [ci skip] to be in any case and allow [skip ci]. !4785 (simon_w)
- - Made project list visibility icon fixed width
- - Set import_url validation to be more strict
- - Memoize MR merged/closed events retrieval
- - Don't render discussion notes when requesting diff tab through AJAX
- - Add basic system information like memory and disk usage to the admin panel
- - Don't garbage collect commits that have related DB records like comments
- - Allow to setup event by channel on slack service
- - More descriptive message for git hooks and file locks
- - Aliases of award emoji should be stored as original name. !5060 (dixpac)
- - Handle custom Git hook result in GitLab UI
- - Allow to access Container Registry for Public and Internal projects
- - Allow '?', or '&' for label names
- - Support redirected blobs for Container Registry integration
- - Fix importer for GitHub Pull Requests when a branch was reused across Pull Requests
- - Add date when user joined the team on the member page
- - Fix 404 redirect after validation fails importing a GitLab project
- - Added setting to set new users by default as external. !4545 (Dravere)
- - Add min value for project limit field on user's form. !3622 (jastkand)
- - Reset project pushes_since_gc when we enqueue the git gc call
- - Add reminder to not paste private SSH keys. !4399 (Ingo Blechschmidt)
- - Collapsed diffs lines/size don't acumulate to overflow diffs.
- - Remove duplicate `description` field in `MergeRequest` entities (Ben Boeckel)
- - Style of import project buttons were fixed in the new project page. !5183 (rdemirbay)
- - Fix GitHub client requests when rate limit is disabled
- - Optimistic locking for Issues and Merge Requests (Title and description overriding prevention)
- - Redesign Builds and Pipelines pages
- - Change status color and icon for running builds
- - Fix commenting issue in side by side diff view for unchanged lines
- - Fix markdown rendering for: consecutive labels references, label references that begin with a digit or contains `.`
- - Project export filename now includes the project and namespace path
- - Fix last update timestamp on issues not preserved on gitlab.com and project imports
- - Fix issues importing projects from EE to CE
- - Fix creating group with space in group path
- - Improve cron_jobs loading error messages. !5318 / !5360
- - Prevent toggling sidebar when clipboard icon clicked
- - Create Todos for Issue author when assign or mention himself (Katarzyna Kobierska)
- - Limit the number of retries on error to 3 for exporting projects
- - Allow empty repositories on project import/export
- - Render only commit message title in builds (Katarzyna Kobierska Ula Budziszewska)
- - Allow bulk (un)subscription from issues in issue index
- - Fix MR diff encoding issues exporting GitLab projects
- - Move builds settings out of project settings and rename Pipelines
- - Add builds badge to Pipelines settings page
- - Export and import avatar as part of project import/export
- - Fix migration corrupting import data for old version upgrades
- - Show tooltip on GitLab export link in new project page
- - Fix import_data wrongly saved as a result of an invalid import_url !5206
-
-## 8.9.11
-
- - Respect the fork_project permission when forking projects
- - Set a restrictive CORS policy on the API for credentialed requests
- - API: disable rails session auth for non-GET/HEAD requests
- - Escape HTML nodes in builds commands in CI linter
-
-## 8.9.10
-
- - Allow the Rails cookie to be used for API authentication.
-
-## 8.9.9
-
- - Exclude some pending or inactivated rows in Member scopes
-
-## 8.9.8
-
- - Upgrade Doorkeeper to 4.2.0. !5881
-
-## 8.9.7
-
- - Upgrade Rails to 4.2.7.1 for security fixes. !5781
- - Require administrator privileges to perform a project import.
-
-## 8.9.6
-
- - Fix importing of events under notes for GitLab projects. !5154
- - Fix log statements in import/export. !5129
- - Fix commit avatar alignment in compare view. !5128
- - Fix broken migration in MySQL. !5005
- - Overwrite Host and X-Forwarded-Host headers in NGINX !5213
- - Keeps issue number when importing from Gitlab.com
- - Add Pending tab for Builds (Katarzyna Kobierska, Urszula Budziszewska)
-
-## 8.9.5
-
- - Add more debug info to import/export and memory killer. !5108
- - Fixed avatar alignment in new MR view. !5095
- - Fix diff comments not showing up in activity feed. !5069
- - Add index on both Award Emoji user and name. !5061
- - Downgrade to Redis 3.2.2 due to massive memory leak with Sidekiq. !5056
- - Re-enable import button when import process fails due to namespace already being taken. !5053
- - Fix snippets comments not displayed. !5045
- - Fix emoji paths in relative root configurations. !5027
- - Fix issues importing events in Import/Export. !4987
- - Fixed 'use shortcuts' button on docs. !4979
- - Admin should be able to turn shared runners into specific ones. !4961
- - Update RedCloth to 4.3.2 for CVE-2012-6684. !4929 (Takuya Noguchi)
- - Improve the request / withdraw access button. !4860
-
-## 8.9.4
-
- - Fix privilege escalation issue with OAuth external users.
- - Ensure references to private repos aren't shown to logged-out users.
- - Fixed search field blur not removing focus. !4704
- - Resolve "Sub nav isn't showing on file view". !4890
- - Fixes middle click and double request when navigating through the file browser. !4891
- - Fixed URL on label button when filtering. !4897
- - Fixed commit avatar alignment. !4933
- - Do not show build retry link when build is active. !4967
- - Fix restore Rake task warning message output. !4980
- - Handle external issues in IssueReferenceFilter. !4988
- - Expiry date on pinned nav cookie. !5009
- - Updated breakpoint for sidebar pinning. !5019
-
-## 8.9.3
-
- - Fix encrypted data backwards compatibility after upgrading attr_encrypted gem. !4963
- - Fix rendering of commit notes. !4953
- - Resolve "Pin should show up at 1280px min". !4947
- - Switched mobile button icons to ellipsis and angle. !4944
- - Correctly returns todo ID after creating todo. !4941
- - Better debugging for memory killer middleware. !4936
- - Remove duplicate new page btn from edit wiki. !4904
- - Use clock_gettime for all performance timestamps. !4899
- - Use memorized tags array when searching tags by name. !4859
- - Fixed avatar alignment in new MR view. !4901
- - Removed fade when filtering results. !4932
- - Fix missing avatar on system notes. !4954
- - Reduce overhead and optimize ProjectTeam#max_member_access performance. !4973
- - Use update_columns to bypass all the dirty code on active_record. !4985
- - Fix restore Rake task warning message output !4980
-
-## 8.9.2
-
- - Fix visibility of snippets when searching.
- - Fix an information disclosure when requesting access to a group containing private projects.
- - Update omniauth-saml to 1.6.0 !4951
-
-## 8.9.1
-
- - Refactor labels documentation. !3347
- - Eager load award emoji on notes. !4628
- - Fix some CI wording in documentation. !4660
- - Document `GIT_STRATEGY` and `GIT_DEPTH`. !4720
- - Add documentation for the export & import features. !4732
- - Add some docs for Docker Registry configuration. !4738
- - Ensure we don't send the "access request declined" email to access requesters on project deletion. !4744
- - Display group/project access requesters separately in the admin area. !4798
- - Add documentation and examples for configuring cloud storage for registry images. !4812
- - Clarifies documentation about artifact expiry. !4831
- - Fix the Network graph links. !4832
- - Fix MR-auto-close text added to description. !4836
- - Add documentation for award emoji now that comments can be awarded with emojis. !4839
- - Fix typo in export failure email. !4847
- - Fix header vertical centering. !4170
- - Fix subsequent SAML sign ins. !4718
- - Set button label when picking an option from status dropdown. !4771
- - Prevent invalid URLs from raising exceptions in WikiLink Filter. !4775
- - Handle external issues in IssueReferenceFilter. !4789
- - Support for rendering/redacting multiple documents. !4828
- - Update Todos documentation and screenshots to include new functionality. !4840
- - Hide nav arrows by default. !4843
- - Added bottom padding to label color suggestion link. !4845
- - Use jQuery objects in ref dropdown. !4850
- - Fix GitLab project import issues related to notes and builds. !4855
- - Restrict header logo to 36px so it doesn't overflow. !4861
- - Fix unwanted label unassignment. !4863
- - Fix mobile Safari bug where horizontal nav arrows would flicker on scroll. !4869
- - Restore old behavior around diff notes to outdated discussions. !4870
- - Fix merge requests project settings help link anchor. !4873
- - Fix 404 when accessing pipelines as guest user on public projects. !4881
- - Remove width restriction for logo on sign-in page. !4888
- - Bump gitlab_git to 10.2.3 to fix false truncated warnings with ISO-8559 files. !4884
- - Apply selected value as label. !4886
- - Change Retry to Re-deploy on Deployments page
- - Fix temp file being deleted after the request while importing a GitLab project. !4894
- - Fix pagination when sorting by columns with lots of ties (like priority)
- - Implement Subresource Integrity for CSS and JavaScript assets. This prevents malicious assets from loading in the case of a CDN compromise.
- - Fix user creation with stronger minimum password requirements !4054 (nathan-pmt)
- - Fix a wrong MR status when merge_when_build_succeeds & project.only_allow_merge_if_build_succeeds are true. !4912
- - Add SMTP as default delivery method to match gitlab-org/omnibus-gitlab!826. !4915
- - Remove duplicate 'New Page' button on edit wiki page
-
-## 8.9.0 (2016-06-22)
-
- - Fix group visibility form layout in application settings
- - Fix builds API response not including commit data
- - Fix error when CI job variables key specified but not defined
- - Fix pipeline status when there are no builds in pipeline
- - Fix Error 500 when using closes_issues API with an external issue tracker
- - Add more information into RSS feed for issues (Alexander Matyushentsev)
- - Bulk assign/unassign labels to issues.
- - Ability to prioritize labels !4009 / !3205 (Thijs Wouters)
- - Show Star and Fork buttons on mobile.
- - Performance improvements on RelativeLinkFilter
- - Fix endless redirections when accessing user OAuth applications when they are disabled
- - Allow enabling wiki page events from Webhook management UI
- - Bump rouge to 1.11.0
- - Fix issue with arrow keys not working in search autocomplete dropdown
- - Fix an issue where note polling stopped working if a window was in the
- background during a refresh.
- - Pre-processing Markdown now only happens when needed
- - Make EmailsOnPushWorker use Sidekiq mailers queue
- - Redesign all Devise emails. !4297
- - Don't show 'Leave Project' to group members
- - Fix wiki page events' webhook to point to the wiki repository
- - Add a border around images to differentiate them from the background.
- - Don't show tags for revert and cherry-pick operations
- - Show image ID on registry page
- - Fix issue todo not remove when leave project !4150 (Long Nguyen)
- - Allow customisable text on the 'nearly there' page after a user signs up
- - Bump recaptcha gem to 3.0.0 to remove deprecated stoken support
- - Fix SVG sanitizer to allow more elements
- - Allow forking projects with restricted visibility level
- - Added descriptions to notification settings dropdown
- - Improve note validation to prevent errors when creating invalid note via API
- - Reduce number of fog gem dependencies
- - Add number of merge requests for a given milestone to the milestones view.
- - Implement a fair usage of shared runners
- - Remove project notification settings associated with deleted projects
- - Fix 404 page when viewing TODOs that contain milestones or labels in different projects
- - Add a metric for the number of new Redis connections created by a transaction
- - Fix Error 500 when viewing a blob with binary characters after the 1024-byte mark
- - Redesign navigation for project pages
- - Fix images in sign-up confirmation email
- - Added shortcut 'y' for copying a files content hash URL #14470
- - Fix groups API to list only user's accessible projects
- - Fix horizontal scrollbar for long commit message.
- - GitLab Performance Monitoring now tracks the total method execution time and call count per method
- - Add Environments and Deployments
- - Redesign account and email confirmation emails
- - Don't fail builds for projects that are deleted
- - Support Docker Registry manifest v1
- - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix
- - Bump nokogiri to 1.6.8
- - Use gitlab-shell v3.0.0
- - Fixed alignment of download dropdown in merge requests
- - Upgrade to jQuery 2
- - Adds selected branch name to the dropdown toggle
- - Add API endpoint for Sidekiq Metrics !4653
- - Refactoring Award Emoji with API support for Issues and MergeRequests
- - Use Knapsack to evenly distribute tests across multiple nodes
- - Add `sha` parameter to MR merge API, to ensure only reviewed changes are merged
- - Don't allow MRs to be merged when commits were added since the last review / page load
- - Add DB index on users.state
- - Limit email on push diff size to 30 files / 150 KB
- - Add rake task 'gitlab:db:configure' for conditionally seeding or migrating the database
- - Changed the Slack build message to use the singular duration if necessary (Aran Koning)
- - Fix race condition on merge when build succeeds
- - Added shortcut to focus filter search fields and added documentation #18120
- - Links from a wiki page to other wiki pages should be rewritten as expected
- - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos)
- - Added navigation shortcuts to the project pipelines, milestones, builds and forks page. !4393
- - Fix issues filter when ordering by milestone
- - Disable SAML account unlink feature
- - Added artifacts:when to .gitlab-ci.yml - this requires GitLab Runner 1.3
- - Bamboo Service: Fix missing credentials & URL handling when base URL contains a path (Benjamin Schmid)
- - TeamCity Service: Fix URL handling when base URL contains a path
- - Todos will display target state if issuable target is 'Closed' or 'Merged'
- - Validate only and except regexp
- - Fix bug when sorting issues by milestone due date and filtering by two or more labels
- - POST to API /projects/:id/runners/:runner_id would give 409 if the runner was already enabled for this project
- - Add support for using Yubikeys (U2F) for two-factor authentication
- - Link to blank group icon doesn't throw a 404 anymore
- - Remove 'main language' feature
- - Toggle whitespace button now available for compare branches diffs #17881
- - Pipelines can be canceled only when there are running builds
- - Allow authentication using personal access tokens
- - Use downcased path to container repository as this is expected path by Docker
- - Allow to use CI token to fetch LFS objects
- - Custom notification settings
- - Projects pending deletion will render a 404 page
- - Measure queue duration between gitlab-workhorse and Rails
- - Added Gfm autocomplete for labels
- - Added edit note 'up' shortcut documentation to the help panel and docs screenshot #18114
- - Make Omniauth providers specs to not modify global configuration
- - Remove unused JiraIssue class and replace references with ExternalIssue. !4659 (Ilan Shamir)
- - Make authentication service for Container Registry to be compatible with < Docker 1.11
- - Make it possible to lock a runner from being enabled for other projects
- - Add Application Setting to configure Container Registry token expire delay (default 5min)
- - Cache assigned issue and merge request counts in sidebar nav
- - Use Knapsack only in CI environment
- - Updated project creation page to match new UI #2542
- - Cache project build count in sidebar nav
- - Add milestone expire date to the right sidebar
- - Manually mark a issue or merge request as a todo
- - Fix markdown_spec to use before instead of before(:all) to properly cleanup database after testing
- - Reduce number of queries needed to render issue labels in the sidebar
- - Improve error handling importing projects
- - Remove duplicated notification settings
- - Put project Files and Commits tabs under Code tab
- - Decouple global notification level from user model
- - Replace Colorize with Rainbow for coloring console output in Rake tasks.
- - Add workhorse controller and API helpers
- - An indicator is now displayed at the top of the comment field for confidential issues.
- - Show categorised search queries in the search autocomplete
- - RepositoryCheck::SingleRepositoryWorker public and private methods are now instrumented
- - Dropdown for `.gitlab-ci.yml` templates
- - Improve issuables APIs performance when accessing notes !4471
- - Add sorting dropdown to tags page !4423
- - External links now open in a new tab
- - Prevent default actions of disabled buttons and links
- - Markdown editor now correctly resets the input value on edit cancellation !4175
- - Toggling a task list item in a issue/mr description does not creates a Todo for mentions
- - Improved UX of date pickers on issue & milestone forms
- - Cache on the database if a project has an active external issue tracker.
- - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav
- - GitLab project import and export functionality
- - All classes in the Banzai::ReferenceParser namespace are now instrumented
- - Remove deprecated issues_tracker and issues_tracker_id from project model
- - Allow users to create confidential issues in private projects
- - Measure CPU time for instrumented methods
- - Instrument private methods and private instance methods by default instead just public methods
- - Only show notes through JSON on confidential issues that the user has access to
- - Updated the allocations Gem to version 1.0.5
- - The background sampler now ignores classes without names
- - Update design for `Close` buttons
- - New custom icons for navigation
- - Horizontally scrolling navigation on project, group, and profile settings pages
- - Hide global side navigation by default
- - Fix project Star/Unstar project button tooltip
- - Remove tanuki logo from side navigation; center on top nav
- - Include user relationships when retrieving award_emoji
- - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed
- - Set inverse_of for Project/Service association to reduce the number of queries
- - Update tanuki logo highlight/loading colors
- - Remove explicit Gitlab::Metrics.action assignments, are already automatic.
- - Use Git cached counters for branches and tags on project page
- - Cache participable participants in an instance variable.
- - Filter parameters for request_uri value on instrumented transactions.
- - Remove duplicated keys add UNIQUE index to keys fingerprint column
- - ExtractsPath get ref_names from repository cache, if not there access git.
- - Show a flash warning about the error detail of XHR requests which failed with status code 404 and 500
- - Cache user todo counts from TodoService
- - Ensure Todos counters doesn't count Todos for projects pending delete
- - Add left/right arrows horizontal navigation
- - Add tooltip to pin/unpin navbar
- - Add new sub nav style to Wiki and Graphs sub navigation
-
-## 8.8.9
-
- - Upgrade Doorkeeper to 4.2.0. !5881
-
-## 8.8.8
-
- - Upgrade Rails to 4.2.7.1 for security fixes. !5781
-
-## 8.8.7
-
- - Fix privilege escalation issue with OAuth external users.
- - Ensure references to private repos aren't shown to logged-out users.
-
-## 8.8.6
-
- - Fix visibility of snippets when searching.
- - Update omniauth-saml to 1.6.0 !4951
-
-## 8.8.5
-
- - Import GitHub repositories respecting the API rate limit !4166
- - Fix todos page throwing errors when you have a project pending deletion !4300
- - Disable Webhooks before proceeding with the GitHub import !4470
- - Fix importer for GitHub comments on diff !4488
- - Adjust the SAML control flow to allow LDAP identities to be added to an existing SAML user !4498
- - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace !4541
- - Prevent unauthorized access for projects build traces
- - Forbid scripting for wiki files
- - Only show notes through JSON on confidential issues that the user has access to
- - Banzai::Filter::UploadLinkFilter use XPath instead CSS expressions
- - Banzai::Filter::ExternalLinkFilter use XPath instead CSS expressions
-
-## 8.8.4
-
- - Fix LDAP-based login for users with 2FA enabled. !4493
- - Added descriptions to notification settings dropdown
- - Due date can be removed from milestones
-
-## 8.8.3
-
- - Fix 404 page when viewing TODOs that contain milestones or labels in different projects. !4312
- - Fixed JS error when trying to remove discussion form. !4303
- - Fixed issue with button color when no CI enabled. !4287
- - Fixed potential issue with 2 CI status polling events happening. !3869
- - Improve design of Pipeline view. !4230
- - Fix gitlab importer failing to import new projects due to missing credentials. !4301
- - Fix import URL migration not rescuing with the correct Error. !4321
- - Fix health check access token changing due to old application settings being used. !4332
- - Make authentication service for Container Registry to be compatible with Docker versions before 1.11. !4363
- - Add Application Setting to configure Container Registry token expire delay (default 5 min). !4364
- - Pass the "Remember me" value to the 2FA token form. !4369
- - Fix incorrect links on pipeline page when merge request created from fork. !4376
- - Use downcased path to container repository as this is expected path by Docker. !4420
- - Fix wiki project clone address error (chujinjin). !4429
- - Fix serious performance bug with rendering Markdown with InlineDiffFilter. !4392
- - Fix missing number on generated ordered list element. !4437
- - Prevent disclosure of notes on confidential issues in search results.
-
-## 8.8.2
-
- - Added remove due date button. !4209
- - Fix Error 500 when accessing application settings due to nil disabled OAuth sign-in sources. !4242
- - Fix Error 500 in CI charts by gracefully handling commits with no durations. !4245
- - Fix table UI on CI builds page. !4249
- - Fix backups if registry is disabled. !4263
- - Fixed issue with merge button color. !4211
- - Fixed issue with enter key selecting wrong option in dropdown. !4210
- - When creating a .gitignore file a dropdown with templates will be provided. !4075
- - Fix concurrent request when updating build log in browser. !4183
-
-## 8.8.1
-
- - Add documentation for the "Health Check" feature
- - Allow anonymous users to access a public project's pipelines !4233
- - Fix MySQL compatibility in zero downtime migrations helpers
- - Fix the CI login to Container Registry (the gitlab-ci-token user)
-
-## 8.8.0 (2016-05-22)
-
- - Implement GFM references for milestones (Alejandro Rodríguez)
- - Snippets tab under user profile. !4001 (Long Nguyen)
- - Fix error when using link to uploads in global snippets
- - Fix Error 500 when attempting to retrieve project license when HEAD points to non-existent ref
- - Assign labels and milestone to target project when moving issue. !3934 (Long Nguyen)
- - Use a case-insensitive comparison in sanitizing URI schemes
- - Toggle sign-up confirmation emails in application settings
- - Make it possible to prevent tagged runner from picking untagged jobs
- - Added `InlineDiffFilter` to the markdown parser. (Adam Butler)
- - Added inline diff styling for `change_title` system notes. (Adam Butler)
- - Project#open_branches has been cleaned up and no longer loads entire records into memory.
- - Escape HTML in commit titles in system note messages
- - Improve design of Pipeline View
- - Fix scope used when accessing container registry
- - Fix creation of Ci::Commit object which can lead to pending, failed in some scenarios
- - Improve multiple branch push performance by memoizing permission checking
- - Log to application.log when an admin starts and stops impersonating a user
- - Changing the confidentiality of an issue now creates a new system note (Alex Moore-Niemi)
- - Updated gitlab_git to 10.1.0
- - GitAccess#protected_tag? no longer loads all tags just to check if a single one exists
- - Reduce delay in destroying a project from 1-minute to immediately
- - Make build status canceled if any of the jobs was canceled and none failed
- - Upgrade Sidekiq to 4.1.2
- - Added /health_check endpoint for checking service status
- - Make 'upcoming' filter for milestones work better across projects
- - Sanitize repo paths in new project error message
- - Bump mail_room to 0.7.0 to fix stuck IDLE connections
- - Remove future dates from contribution calendar graph.
- - Support e-mail notifications for comments on project snippets
- - Fix API leak of notes of unauthorized issues, snippets and merge requests
- - Use ActionDispatch Remote IP for Akismet checking
- - Fix error when visiting commit builds page before build was updated
- - Add 'l' shortcut to open Label dropdown on issuables and 'i' to create new issue on a project
- - Update SVG sanitizer to conform to SVG 1.1
- - Speed up push emails with multiple recipients by only generating the email once
- - Updated search UI
- - Added authentication service for Container Registry
- - Display informative message when new milestone is created
- - Sanitize milestones and labels titles
- - Support multi-line tag messages. !3833 (Calin Seciu)
- - Force users to reset their password after an admin changes it
- - Allow "NEWS" and "CHANGES" as alternative names for CHANGELOG. !3768 (Connor Shea)
- - Added button to toggle whitespaces changes on diff view
- - Backport GitHub Enterprise import support from EE
- - Create tags using Rugged for performance reasons. !3745
- - Allow guests to set notification level in projects
- - API: Expose Issue#user_notes_count. !3126 (Anton Popov)
- - Don't show forks button when user can't view forks
- - Fix atom feed links and rendering
- - Files over 5MB can only be viewed in their raw form, files over 1MB without highlighting !3718
- - Add support for supressing text diffs using .gitattributes on the default branch (Matt Oakes)
- - Add eager load paths to help prevent dependency load issues in Sidekiq workers. !3724
- - Added multiple colors for labels in dropdowns when dups happen.
- - Show commits in the same order as `git log`
- - Improve description for the Two-factor Authentication sign-in screen. (Connor Shea)
- - API support for the 'since' and 'until' operators on commit requests (Paco Guzman)
- - Fix Gravatar hint in user profile when Gravatar is disabled. !3988 (Artem Sidorenko)
- - Expire repository exists? and has_visible_content? caches after a push if necessary
- - Fix unintentional filtering bug in Issue/MR sorted by milestone due (Takuya Noguchi)
- - Fix adding a todo for private group members (Ahmad Sherif)
- - Bump ace-rails-ap gem version from 2.0.1 to 4.0.2 which upgrades Ace Editor from 1.1.2 to 1.2.3
- - Total method execution timings are no longer tracked
- - Allow Admins to remove the Login with buttons for OAuth services and still be able to import !4034. (Andrei Gliga)
- - Add API endpoints for un/subscribing from/to a label. !4051 (Ahmad Sherif)
- - Hide left sidebar on phone screens to give more space for content
- - Redesign navigation for profile and group pages
- - Add counter metrics for rails cache
- - Import pull requests from GitHub where the source or target branches were removed
- - All Grape API helpers are now instrumented
- - Improve Issue formatting for the Slack Service (Jeroen van Baarsen)
- - Fixed advice on invalid permissions on upload path !2948 (Ludovic Perrine)
- - Allows MR authors to have the source branch removed when merging the MR. !2801 (Jeroen Jacobs)
- - When creating a .gitignore file a dropdown with templates will be provided
- - Shows the issue/MR list search/filter form and corrects the mobile styling for guest users. #17562
-
-## 8.7.9
-
- - Fix privilege escalation issue with OAuth external users.
- - Ensure references to private repos aren't shown to logged-out users.
-
-## 8.7.8
-
- - Fix visibility of snippets when searching.
- - Update omniauth-saml to 1.6.0 !4951
-
-## 8.7.7
-
- - Fix import by `Any Git URL` broken if the URL contains a space
- - Prevent unauthorized access to other projects build traces
- - Forbid scripting for wiki files
- - Only show notes through JSON on confidential issues that the user has access to
-
-## 8.7.6
-
- - Fix links on wiki pages for relative url setups. !4131 (Artem Sidorenko)
- - Fix import from GitLab.com to a private instance failure. !4181
- - Fix external imports not finding the import data. !4106
- - Fix notification delay when changing status of an issue
- - Bump Workhorse to 0.7.5 so it can serve raw diffs
-
-## 8.7.5
-
- - Fix relative links in wiki pages. !4050
- - Fix always showing build notification message when switching between merge requests !4086
- - Fix an issue when filtering merge requests with more than one label. !3886
- - Fix short note for the default scope on build page (Takuya Noguchi)
-
-## 8.7.4
-
- - Links for Redmine issue references are generated correctly again !4048 (Benedikt Huss)
- - Fix setting trusted proxies !3970
- - Fix BitBucket importer bug when throwing exceptions !3941
- - Use sign out path only if not empty !3989
- - Running rake gitlab:db:drop_tables now drops tables with cascade !4020
- - Running rake gitlab:db:drop_tables uses "IF EXISTS" as a precaution !4100
- - Use a case-insensitive comparison in sanitizing URI schemes
-
-## 8.7.3
-
- - Emails, Gitlab::Email::Message, Gitlab::Diff, and Premailer::Adapter::Nokogiri are now instrumented
- - Merge request widget displays TeamCity build state and code coverage correctly again.
- - Fix the line code when importing PR review comments from GitHub. !4010
- - Wikis are now initialized on legacy projects when checking repositories
- - Remove animate.css in favor of a smaller subset of animations. !3937 (Connor Shea)
-
-## 8.7.2
-
- - The "New Branch" button is now loaded asynchronously
- - Fix error 500 when trying to create a wiki page
- - Updated spacing between notification label and button
- - Label titles in filters are now escaped properly
-
-## 8.7.1
-
- - Throttle the update of `project.last_activity_at` to 1 minute. !3848
- - Fix .gitlab-ci.yml parsing issue when hidde job is a template without script definition. !3849
- - Fix license detection to detect all license files, not only known licenses. !3878
- - Use the `can?` helper instead of `current_user.can?`. !3882
- - Prevent users from deleting Webhooks via API they do not own
- - Fix Error 500 due to stale cache when projects are renamed or transferred
- - Update width of search box to fix Safari bug. !3900 (Jedidiah)
- - Use the `can?` helper instead of `current_user.can?`
-
-## 8.7.0 (2016-04-22)
-
- - Gitlab::GitAccess and Gitlab::GitAccessWiki are now instrumented
- - Fix vulnerability that made it possible to gain access to private labels and milestones
- - The number of InfluxDB points stored per UDP packet can now be configured
- - Fix error when cross-project label reference used with non-existent project
- - Transactions for /internal/allowed now have an "action" tag set
- - Method instrumentation now uses Module#prepend instead of aliasing methods
- - Repository.clean_old_archives is now instrumented
- - Add support for environment variables on a job level in CI configuration file
- - SQL query counts are now tracked per transaction
- - The Projects::HousekeepingService class has extra instrumentation
- - All service classes (those residing in app/services) are now instrumented
- - Developers can now add custom tags to transactions
- - Loading of an issue's referenced merge requests and related branches is now done asynchronously
- - Enable gzip for assets, makes the page size significantly smaller. !3544 / !3632 (Connor Shea)
- - Add support to cherry-pick any commit into any branch in the web interface (Minqi Pan)
- - Project switcher uses new dropdown styling
- - Load award emoji images separately unless opening the full picker. Saves several hundred KBs of data for most pages. (Connor Shea)
- - Do not include award_emojis in issue and merge_request comment_count !3610 (Lucas Charles)
- - Restrict user profiles when public visibility level is restricted.
- - Add ability set due date to issues, sort and filter issues by due date (Mehmet Beydogan)
- - All images in discussions and wikis now link to their source files !3464 (Connor Shea).
- - Return status code 303 after a branch DELETE operation to avoid project deletion (Stan Hu)
- - Add setting for customizing the list of trusted proxies !3524
- - Allow projects to be transfered to a lower visibility level group
- - Fix `signed_in_ip` being set to 127.0.0.1 when using a reverse proxy !3524
- - Improved Markdown rendering performance !3389
- - Make shared runners text in box configurable
- - Don't attempt to look up an avatar in repo if repo directory does not exist (Stan Hu)
- - API: Ability to subscribe and unsubscribe from issues and merge requests (Robert Schilling)
- - Expose project badges in project settings
- - Make /profile/keys/new redirect to /profile/keys for back-compat. !3717
- - Preserve time notes/comments have been updated at when moving issue
- - Make HTTP(s) label consistent on clone bar (Stan Hu)
- - Add support for `after_script`, requires Runner 1.2 (Kamil Trzciński)
- - Expose label description in API (Mariusz Jachimowicz)
- - API: Ability to update a group (Robert Schilling)
- - API: Ability to move issues (Robert Schilling)
- - Fix Error 500 after renaming a project path (Stan Hu)
- - Fix a bug whith trailing slash in teamcity_url (Charles May)
- - Allow back dating on issues when created or updated through the API
- - Allow back dating on issue notes when created through the API
- - Propose license template when creating a new LICENSE file
- - API: Expose /licenses and /licenses/:key
- - Fix avatar stretching by providing a cropping feature
- - API: Expose `subscribed` for issues and merge requests (Robert Schilling)
- - Allow SAML to handle external users based on user's information !3530
- - Allow Omniauth providers to be marked as `external` !3657
- - Add endpoints to archive or unarchive a project !3372
- - Fix a bug whith trailing slash in bamboo_url
- - Add links to CI setup documentation from project settings and builds pages
- - Display project members page to all members
- - Handle nil descriptions in Slack issue messages (Stan Hu)
- - Add automated repository integrity checks (OFF by default)
- - API: Expose open_issues_count, closed_issues_count, open_merge_requests_count for labels (Robert Schilling)
- - API: Ability to star and unstar a project (Robert Schilling)
- - Add default scope to projects to exclude projects pending deletion
- - Allow to close merge requests which source projects(forks) are deleted.
- - Ensure empty recipients are rejected in BuildsEmailService
- - Use rugged to change HEAD in Project#change_head (P.S.V.R)
- - API: Ability to filter milestones by state `active` and `closed` (Robert Schilling)
- - API: Fix milestone filtering by `iid` (Robert Schilling)
- - Make before_script and after_script overridable on per-job (Kamil Trzciński)
- - API: Delete notes of issues, snippets, and merge requests (Robert Schilling)
- - Implement 'Groups View' as an option for dashboard preferences !3379 (Elias W.)
- - Better errors handling when creating milestones inside groups
- - Fix high CPU usage when PostReceive receives refs/merge-requests/<id>
- - Hide `Create a group` help block when creating a new project in a group
- - Implement 'TODOs View' as an option for dashboard preferences !3379 (Elias W.)
- - Allow issues and merge requests to be assigned to the author !2765
- - Make Ci::Commit to group only similar builds and make it stateful (ref, tag)
- - Gracefully handle notes on deleted commits in merge requests (Stan Hu)
- - Decouple membership and notifications
- - Fix creation of merge requests for orphaned branches (Stan Hu)
- - API: Ability to retrieve a single tag (Robert Schilling)
- - While signing up, don't persist the user password across form redisplays
- - Fall back to `In-Reply-To` and `References` headers when sub-addressing is not available (David Padilla)
- - Remove "Congratulations!" tweet button on newly-created project. (Connor Shea)
- - Fix admin/projects when using visibility levels on search (PotHix)
- - Build status notifications
- - Update email confirmation interface
- - API: Expose user location (Robert Schilling)
- - API: Do not leak group existence via return code (Robert Schilling)
- - ClosingIssueExtractor regex now also works with colons. e.g. "Fixes: #1234" !3591
- - Update number of Todos in the sidebar when it's marked as "Done". !3600
- - Sanitize branch names created for confidential issues
- - API: Expose 'updated_at' for issue, snippet, and merge request notes (Robert Schilling)
- - API: User can leave a project through the API when not master or owner. !3613
- - Fix repository cache invalidation issue when project is recreated with an empty repo (Stan Hu)
- - Fix: Allow empty recipients list for builds emails service when pushed is added (Frank Groeneveld)
- - Improved markdown forms
- - Diff design updates (colors, button styles, etc)
- - Copying and pasting a diff no longer pastes the line numbers or +/-
- - Add null check to formData when updating profile content to fix Firefox bug
- - Disable spellcheck and autocorrect for username field in admin page
- - Delete tags using Rugged for performance reasons (Robert Schilling)
- - Add Slack notifications when Wiki is edited (Sebastian Klier)
- - Diffs load at the correct point when linking from from number
- - Selected diff rows highlight
- - Fix emoji categories in the emoji picker
- - API: Properly display annotated tags for GET /projects/:id/repository/tags (Robert Schilling)
- - Add encrypted credentials for imported projects and migrate old ones
- - Properly format all merge request references with ! rather than # !3740 (Ben Bodenmiller)
- - Author and participants are displayed first on users autocompletion
- - Show number sign on external issue reference text (Florent Baldino)
- - Updated print style for issues
- - Use GitHub Issue/PR number as iid to keep references
- - Import GitHub labels
- - Add option to filter by "Owned projects" on dashboard page
- - Import GitHub milestones
- - Execute system web hooks on push to the project
- - Allow enable/disable push events for system hooks
- - Fix GitHub project's link in the import page when provider has a custom URL
- - Add RAW build trace output and button on build page
- - Add incremental build trace update into CI API
-
-## 8.6.9
-
- - Prevent unauthorized access to other projects build traces
- - Forbid scripting for wiki files
- - Only show notes through JSON on confidential issues that the user has access to
-
-## 8.6.8
-
- - Prevent privilege escalation via "impersonate" feature
- - Prevent privilege escalation via notes API
- - Prevent privilege escalation via project webhook API
- - Prevent XSS via Git branch and tag names
- - Prevent XSS via custom issue tracker URL
- - Prevent XSS via `window.opener`
- - Prevent XSS via label drop-down
- - Prevent information disclosure via milestone API
- - Prevent information disclosure via snippet API
- - Prevent information disclosure via project labels
- - Prevent information disclosure via new merge request page
-
-## 8.6.7
-
- - Fix persistent XSS vulnerability in `commit_person_link` helper
- - Fix persistent XSS vulnerability in Label and Milestone dropdowns
- - Fix vulnerability that made it possible to enumerate private projects belonging to group
-
-## 8.6.6
-
- - Expire the exists cache before deletion to ensure project dir actually exists (Stan Hu). !3413
- - Fix error on language detection when repository has no HEAD (e.g., master branch) (Jeroen Bobbeldijk). !3654
- - Fix revoking of authorized OAuth applications (Connor Shea). !3690
- - Fix error on language detection when repository has no HEAD (e.g., master branch). !3654 (Jeroen Bobbeldijk)
- - Issuable header is consistent between issues and merge requests
- - Improved spacing in issuable header on mobile
-
-## 8.6.5
-
- - Fix importing from GitHub Enterprise. !3529
- - Perform the language detection after updating merge requests in `GitPushService`, leading to faster visual feedback for the end-user. !3533
- - Check permissions when user attempts to import members from another project. !3535
- - Only update repository language if it is not set to improve performance. !3556
- - Return status code 303 after a branch DELETE operation to avoid project deletion (Stan Hu). !3583
- - Unblock user when active_directory is disabled and it can be found !3550
- - Fix a 2FA authentication spoofing vulnerability.
-
-## 8.6.4
-
- - Don't attempt to fetch any tags from a forked repo (Stan Hu)
- - Redesign the Labels page
-
-## 8.6.3
-
- - Mentions on confidential issues doesn't create todos for non-members. !3374
- - Destroy related todos when an Issue/MR is deleted. !3376
- - Fix error 500 when target is nil on todo list. !3376
- - Fix copying uploads when moving issue to another project. !3382
- - Ensuring Merge Request API returns boolean values for work_in_progress (Abhi Rao). !3432
- - Fix raw/rendered diff producing different results on merge requests. !3450
- - Fix commit comment alignment (Stan Hu). !3466
- - Fix Error 500 when searching for a comment in a project snippet. !3468
- - Allow temporary email as notification email. !3477
- - Fix issue with dropdowns not selecting values. !3478
- - Update gitlab-shell version and doc to 2.6.12. gitlab-org/gitlab-ee!280
-
-## 8.6.2
-
- - Fix dropdown alignment. !3298
- - Fix issuable sidebar overlaps on tablet. !3299
- - Make dropdowns pixel perfect. !3337
- - Fix order of steps to prevent PostgreSQL errors when running migration. !3355
- - Fix bold text in issuable sidebar. !3358
- - Fix error with anonymous token in applications settings. !3362
- - Fix the milestone 'upcoming' filter. !3364 + !3368
- - Fix comments on confidential issues showing up in activity feed to non-members. !3375
- - Fix `NoMethodError` when visiting CI root path at `/ci`. !3377
- - Add a tooltip to new branch button in issue page. !3380
- - Fix an issue hiding the password form when signed-in with a linked account. !3381
- - Add links to CI setup documentation from project settings and builds pages. !3384
- - Fix an issue with width of project select dropdown. !3386
- - Remove redundant `require`s from Banzai files. !3391
- - Fix error 500 with cancel button on issuable edit form. !3392 + !3417
- - Fix background when editing a highlighted note. !3423
- - Remove tabstop from the WIP toggle links. !3426
- - Ensure private project snippets are not viewable by unauthorized people.
- - Gracefully handle notes on deleted commits in merge requests (Stan Hu). !3402
- - Fixed issue with notification settings not saving. !3452
-
-## 8.6.1
-
- - Add option to reload the schema before restoring a database backup. !2807
- - Display navigation controls on mobile. !3214
- - Fixed bug where participants would not work correctly on merge requests. !3329
- - Fix sorting issues by votes on the groups issues page results in SQL errors. !3333
- - Restrict notifications for confidential issues. !3334
- - Do not allow to move issue if it has not been persisted. !3340
- - Add a confirmation step before deleting an issuable. !3341
- - Fixes issue with signin button overflowing on mobile. !3342
- - Auto collapses the navigation sidebar when resizing. !3343
- - Fix build dependencies, when the dependency is a string. !3344
- - Shows error messages when trying to create label in dropdown menu. !3345
- - Fixes issue with assign milestone not loading milestone list. !3346
- - Fix an issue causing the Dashboard/Milestones page to be blank. !3348
-
-## 8.6.0 (2016-03-22)
-
- - Add ability to move issue to another project
- - Prevent tokens in the import URL to be showed by the UI
- - Fix bug where wrong commit ID was being used in a merge request diff to show old image (Stan Hu)
- - Add confidential issues
- - Bump gitlab_git to 9.0.3 (Stan Hu)
- - Fix diff image view modes (2-up, swipe, onion skin) not working (Stan Hu)
- - Support Golang subpackage fetching (Stan Hu)
- - Bump Capybara gem to 2.6.2 (Stan Hu)
- - New branch button appears on issues where applicable
- - Contributions to forked projects are included in calendar
- - Improve the formatting for the user page bio (Connor Shea)
- - Easily (un)mark merge request as WIP using link
- - Use specialized system notes when MR is (un)marked as WIP
- - Removed the default password from the initial admin account created during
- setup. A password can be provided during setup (see installation docs), or
- GitLab will ask the user to create a new one upon first visit.
- - Fix issue when pushing to projects ending in .wiki
- - Properly display YAML front matter in Markdown
- - Add support for wiki with UTF-8 page names (Hiroyuki Sato)
- - Fix wiki search results point to raw source (Hiroyuki Sato)
- - Don't load all of GitLab in mail_room
- - Add information about `image` and `services` field at `job` level in the `.gitlab-ci.yml` documentation (Pat Turner)
- - HTTP error pages work independently from location and config (Artem Sidorenko)
- - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set
- - Memoize @group in Admin::GroupsController (Yatish Mehta)
- - Indicate how much an MR diverged from the target branch (Pierre de La Morinerie)
- - Added omniauth-auth0 Gem (Daniel Carraro)
- - Add label description in tooltip to labels in issue index and sidebar
- - Strip leading and trailing spaces in URL validator (evuez)
- - Add "last_sign_in_at" and "confirmed_at" to GET /users/* API endpoints for admins (evuez)
- - Return empty array instead of 404 when commit has no statuses in commit status API
- - Decrease the font size and the padding of the `.anchor` icons used in the README (Roberto Dip)
- - Rewrite logo to simplify SVG code (Sean Lang)
- - Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach)
- - Ignore jobs that start with `.` (hidden jobs)
- - Hide builds from project's settings when the feature is disabled
- - Allow to pass name of created artifacts archive in `.gitlab-ci.yml`
- - Refactor and greatly improve search performance
- - Add support for cross-project label references
- - Ensure "new SSH key" email do not ends up as dead Sidekiq jobs
- - Update documentation to reflect Guest role not being enforced on internal projects
- - Allow search for logged out users
- - Allow to define on which builds the current one depends on
- - Allow user subscription to a label: get notified for issues/merge requests related to that label (Timothy Andrew)
- - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio)
- - Don't show Issues/MRs from archived projects in Groups view
- - Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs
- - Fix empty source_sha on Merge Request when there is no diff (Pierre de La Morinerie)
- - Increase the notes polling timeout over time (Roberto Dip)
- - Add shortcut to toggle markdown preview (Florent Baldino)
- - Show labels in dashboard and group milestone views
- - Fix an issue when the target branch of a MR had been deleted
- - Add main language of a project in the list of projects (Tiago Botelho)
- - Add #upcoming filter to Milestone filter (Tiago Botelho)
- - Add ability to show archived projects on dashboard, explore and group pages
- - Remove fork link closes all merge requests opened on source project (Florent Baldino)
- - Move group activity to separate page
- - Create external users which are excluded of internal and private projects unless access was explicitly granted
- - Continue parameters are checked to ensure redirection goes to the same instance
- - User deletion is now done in the background so the request can not time out
- - Canceled builds are now ignored in compound build status if marked as `allowed to fail`
- - Trigger a todo for mentions on commits page
- - Let project owners and admins soft delete issues and merge requests
-
-## 8.5.13
-
- - Prevent unauthorized access to other projects build traces
- - Forbid scripting for wiki files
-
-## 8.5.12
-
- - Prevent privilege escalation via "impersonate" feature
- - Prevent privilege escalation via notes API
- - Prevent privilege escalation via project webhook API
- - Prevent XSS via Git branch and tag names
- - Prevent XSS via custom issue tracker URL
- - Prevent XSS via `window.opener`
- - Prevent information disclosure via snippet API
- - Prevent information disclosure via project labels
- - Prevent information disclosure via new merge request page
-
-## 8.5.11
-
- - Fix persistent XSS vulnerability in `commit_person_link` helper
-
-## 8.5.10
-
- - Fix a 2FA authentication spoofing vulnerability.
-
-## 8.5.9
-
- - Don't attempt to fetch any tags from a forked repo (Stan Hu).
-
-## 8.5.8
-
- - Bump Git version requirement to 2.7.4
-
-## 8.5.7
-
- - Bump Git version requirement to 2.7.3
-
-## 8.5.6
-
- - Obtain a lease before querying LDAP
-
-## 8.5.5
-
- - Ensure removing a project removes associated Todo entries
- - Prevent a 500 error in Todos when author was removed
- - Fix pagination for filtered dashboard and explore pages
- - Fix "Show all" link behavior
-
-## 8.5.4
-
- - Do not cache requests for badges (including builds badge)
-
-## 8.5.3
-
- - Flush repository caches before renaming projects
- - Sort starred projects on dashboard based on last activity by default
- - Show commit message in JIRA mention comment
- - Makes issue page and merge request page usable on mobile browsers.
- - Improved UI for profile settings
-
-## 8.5.2
-
- - Fix sidebar overlapping content when screen width was below 1200px
- - Don't repeat labels listed on Labels tab
- - Bring the "branded appearance" feature from EE to CE
- - Fix error 500 when commenting on a commit
- - Show days remaining instead of elapsed time for Milestone
- - Fix broken icons on installations with relative URL (Artem Sidorenko)
- - Fix issue where tag list wasn't refreshed after deleting a tag
- - Fix import from gitlab.com (KazSawada)
- - Improve implementation to check read access to forks and add pagination
- - Don't show any "2FA required" message if it's not actually required
- - Fix help keyboard shortcut on relative URL setups (Artem Sidorenko)
- - Update Rails to 4.2.5.2
- - Fix permissions for deprecated CI build status badge
- - Don't show "Welcome to GitLab" when the search didn't return any projects
- - Add Todos documentation
-
-## 8.5.1
-
- - Fix group projects styles
- - Show Crowd login tab when sign in is disabled and Crowd is enabled (Peter Hudec)
- - Fix a set of small UI glitches in project, profile, and wiki pages
- - Restrict permissions on public/uploads
- - Fix the merge request side-by-side view after loading diff results
- - Fix the look of tooltip for the "Revert" button
- - Add when the Builds & Runners API changes got introduced
- - Fix error 500 on some merged merge requests
- - Fix an issue causing the content of the issuable sidebar to disappear
- - Fix error 500 when trying to mark an already done todo as "done"
- - Fix an issue where MRs weren't sortable
- - Issues can now be dragged & dropped into empty milestone lists. This is also
- possible with MRs
- - Changed padding & background color for highlighted notes
- - Re-add the newrelic_rpm gem which was removed without any deprecation or warning (Stan Hu)
- - Update sentry-raven gem to 0.15.6
- - Add build coverage in project's builds page (Steffen Köhler)
- - Changed # to ! for merge requests in activity view
-
-## 8.5.0 (2016-02-22)
-
- - Fix duplicate "me" in tooltip of the "thumbsup" awards Emoji (Stan Hu)
- - Cache various Repository methods to improve performance
- - Fix duplicated branch creation/deletion Webhooks/service notifications when using Web UI (Stan Hu)
- - Ensure rake tasks that don't need a DB connection can be run without one
- - Update New Relic gem to 3.14.1.311 (Stan Hu)
- - Add "visibility" flag to GET /projects api endpoint
- - Add an option to supply root email through an environmental variable (Koichiro Mikami)
- - Ignore binary files in code search to prevent Error 500 (Stan Hu)
- - Render sanitized SVG images (Stan Hu)
- - Support download access by PRIVATE-TOKEN header (Stan Hu)
- - Upgrade gitlab_git to 7.2.23 to fix commit message mentions in first branch push
- - Add option to include the sender name in body of Notify email (Jason Lee)
- - New UI for pagination
- - Don't prevent sign out when 2FA enforcement is enabled and user hasn't yet
- set it up
- - API: Added "merge_requests/:merge_request_id/closes_issues" (Gal Schlezinger)
- - Fix diff comments loaded by AJAX to load comment with diff in discussion tab
- - Fix relative links in other markup formats (Ben Boeckel)
- - Whitelist raw "abbr" elements when parsing Markdown (Benedict Etzel)
- - Fix label links for a merge request pointing to issues list
- - Don't vendor minified JS
- - Increase project import timeout to 15 minutes
- - Be more permissive with email address validation: it only has to contain a single '@'
- - Display 404 error on group not found
- - Track project import failure
- - Support Two-factor Authentication for LDAP users
- - Display database type and version in Administration dashboard
- - Allow limited Markdown in Broadcast Messages
- - Fix visibility level text in admin area (Zeger-Jan van de Weg)
- - Warn admin during OAuth of granting admin rights (Zeger-Jan van de Weg)
- - Update the ExternalIssue regex pattern (Blake Hitchcock)
- - Remember user's inline/side-by-side diff view preference in a cookie (Kirill Katsnelson)
- - Optimized performance of finding issues to be closed by a merge request
- - Add `avatar_url`, `description`, `git_ssh_url`, `git_http_url`, `path_with_namespace`
- and `default_branch` in `project` in push, issue, merge-request and note webhooks data (Kirill Zaitsev)
- - Deprecate the `ssh_url` in favor of `git_ssh_url` and `http_url` in favor of `git_http_url`
- in `project` for push, issue, merge-request and note webhooks data (Kirill Zaitsev)
- - Deprecate the `repository` key in push, issue, merge-request and note webhooks data, use `project` instead (Kirill Zaitsev)
- - API: Expose MergeRequest#merge_status (Andrei Dziahel)
- - Revert "Add IP check against DNSBLs at account sign-up"
- - Actually use the `skip_merges` option in Repository#commits (Tony Chu)
- - Fix API to keep request parameters in Link header (Michael Potthoff)
- - Deprecate API "merge_request/:merge_request_id/comments". Use "merge_requests/:merge_request_id/notes" instead
- - Deprecate API "merge_request/:merge_request_id/...". Use "merge_requests/:merge_request_id/..." instead
- - Prevent parse error when name of project ends with .atom and prevent path issues
- - Discover branches for commit statuses ref-less when doing merge when succeeded
- - Mark inline difference between old and new paths when a file is renamed
- - Support Akismet spam checking for creation of issues via API (Stan Hu)
- - API: Allow to set or update a merge-request's milestone (Kirill Skachkov)
- - Improve UI consistency between projects and groups lists
- - Add sort dropdown to dashboard projects page
- - Fixed logo animation on Safari (Roman Rott)
- - Fix Merge When Succeeded when multiple stages
- - Hide remove source branch button when the MR is merged but new commits are pushed (Zeger-Jan van de Weg)
- - In seach autocomplete show only groups and projects you are member of
- - Don't process cross-reference notes from forks
- - Fix: init.d script not working on OS X
- - Faster snippet search
- - Added API to download build artifacts
- - Title for milestones should be unique (Zeger-Jan van de Weg)
- - Validate correctness of maximum attachment size application setting
- - Replaces "Create merge request" link with one to the "Merge Request" when one exists
- - Fix CI builds badge, add a new link to builds badge, deprecate the old one
- - Fix broken link to project in build notification emails
- - Ability to see and sort on vote count from Issues and MR lists
- - Fix builds scheduler when first build in stage was allowed to fail
- - User project limit is reached notice is hidden if the projects limit is zero
- - Add API support for managing runners and project's runners
- - Allow SAML users to login with no previous account without having to allow
- all Omniauth providers to do so.
- - Allow existing users to auto link their SAML credentials by logging in via SAML
- - Make it possible to erase a build (trace, artifacts) using UI and API
- - Ability to revert changes from a Merge Request or Commit
- - Emoji comment on diffs are not award emoji
- - Add label description (Nuttanart Pornprasitsakul)
- - Show label row when filtering issues or merge requests by label (Nuttanart Pornprasitsakul)
- - Add Todos
-
-## 8.4.11
-
- - Prevent unauthorized access to other projects build traces
- - Forbid scripting for wiki files
-
-## 8.4.10
-
- - Prevent privilege escalation via "impersonate" feature
- - Prevent privilege escalation via notes API
- - Prevent privilege escalation via project webhook API
- - Prevent XSS via Git branch and tag names
- - Prevent XSS via custom issue tracker URL
- - Prevent XSS via `window.opener`
- - Prevent information disclosure via snippet API
- - Prevent information disclosure via project labels
- - Prevent information disclosure via new merge request page
-
-## 8.4.9
-
- - Fix persistent XSS vulnerability in `commit_person_link` helper
-
-## 8.4.8
-
- - Fix a 2FA authentication spoofing vulnerability.
-
-## 8.4.7
-
- - Don't attempt to fetch any tags from a forked repo (Stan Hu).
-
-## 8.4.6
-
- - Bump Git version requirement to 2.7.4
-
-## 8.4.5
-
- - No CE-specific changes
-
-## 8.4.4
-
- - Update omniauth-saml gem to 1.4.2
- - Prevent long-running backup tasks from timing out the database connection
- - Add a Project setting to allow guests to view build logs (defaults to true)
- - Sort project milestones by due date including issue editor (Oliver Rogers / Orih)
-
-## 8.4.3
-
- - Increase lfs_objects size column to 8-byte integer to allow files larger
- than 2.1GB
- - Correctly highlight MR diff when MR has merge conflicts
- - Fix highlighting in blame view
- - Update sentry-raven gem to prevent "Not a git repository" console output
- when running certain commands
- - Add instrumentation to additional Gitlab::Git and Rugged methods for
- performance monitoring
- - Allow autosize textareas to also be manually resized
-
-## 8.4.2
-
- - Bump required gitlab-workhorse version to bring in a fix for missing
- artifacts in the build artifacts browser
- - Get rid of those ugly borders on the file tree view
- - Fix updating the runner information when asking for builds
- - Bump gitlab_git version to 7.2.24 in order to bring in a performance
- improvement when checking if a repository was empty
- - Add instrumentation for Gitlab::Git::Repository instance methods so we can
- track them in Performance Monitoring.
- - Increase contrast between highlighted code comments and inline diff marker
- - Fix method undefined when using external commit status in builds
- - Fix highlighting in blame view.
-
-## 8.4.1
-
- - Apply security updates for Rails (4.2.5.1), rails-html-sanitizer (1.0.3),
- and Nokogiri (1.6.7.2)
- - Fix redirect loop during import
- - Fix diff highlighting for all syntax themes
- - Delete project and associations in a background worker
-
-## 8.4.0 (2016-01-22)
-
- - Allow LDAP users to change their email if it was not set by the LDAP server
- - Ensure Gravatar host looks like an actual host
- - Consider re-assign as a mention from a notification point of view
- - Add pagination headers to already paginated API resources
- - Properly generate diff of orphan commits, like the first commit in a repository
- - Improve the consistency of commit titles, branch names, tag names, issue/MR titles, on their respective project pages
- - Autocomplete data is now always loaded, instead of when focusing a comment text area
- - Improved performance of finding issues for an entire group
- - Added custom application performance measuring system powered by InfluxDB
- - Add syntax highlighting to diffs
- - Gracefully handle invalid UTF-8 sequences in Markdown links (Stan Hu)
- - Bump fog to 1.36.0 (Stan Hu)
- - Add user's last used IP addresses to admin page (Stan Hu)
- - Add housekeeping function to project settings page
- - The default GitLab logo now acts as a loading indicator
- - Fix caching issue where build status was not updating in project dashboard (Stan Hu)
- - Accept 2xx status codes for successful Webhook triggers (Stan Hu)
- - Fix missing date of month in network graph when commits span a month (Stan Hu)
- - Expire view caches when application settings change (e.g. Gravatar disabled) (Stan Hu)
- - Don't notify users twice if they are both project watchers and subscribers (Stan Hu)
- - Remove gray background from layout in UI
- - Fix signup for OAuth providers that don't provide a name
- - Implement new UI for group page
- - Implement search inside emoji picker
- - Let the CI runner know about builds that this build depends on
- - Add API support for looking up a user by username (Stan Hu)
- - Add project permissions to all project API endpoints (Stan Hu)
- - Link to milestone in "Milestone changed" system note
- - Only allow group/project members to mention `@all`
- - Expose Git's version in the admin area (Trey Davis)
- - Add "Frequently used" category to emoji picker
- - Add CAS support (tduehr)
- - Add link to merge request on build detail page
- - Fix: Problem with projects ending with .keys (Jose Corcuera)
- - Revert back upvote and downvote button to the issue and MR pages
- - Swap position of Assignee and Author selector on Issuables (Zeger-Jan van de Weg)
- - Add system hook messages for project rename and transfer (Steve Norman)
- - Fix version check image in Safari
- - Show 'All' tab by default in the builds page
- - Add Open Graph and Twitter Card data to all pages
- - Fix API project lookups when querying with a namespace with dots (Stan Hu)
- - Enable forcing Two-factor authentication sitewide, with optional grace period
- - Import GitHub Pull Requests into GitLab
- - Change single user API endpoint to return more detailed data (Michael Potthoff)
- - Update version check images to use SVG
- - Validate README format before displaying
- - Enable Microsoft Azure OAuth2 support (Janis Meybohm)
- - Properly set task-list class on single item task lists
- - Add file finder feature in tree view (Kyungchul Shin)
- - Ajax filter by message for commits page
- - API: Add support for deleting a tag via the API (Robert Schilling)
- - Allow subsequent validations in CI Linter
- - Show referenced MRs & Issues only when the current viewer can access them
- - Fix Encoding::CompatibilityError bug when markdown content has some complex URL (Jason Lee)
- - Add API support for managing project's builds
- - Add API support for managing project's build triggers
- - Add API support for managing project's build variables
- - Allow broadcast messages to be edited
- - Autosize Markdown textareas
- - Import GitHub wiki into GitLab
- - Add reporters ability to download and browse build artifacts (Andrew Johnson)
- - Autofill referring url in message box when reporting user abuse.
- - Remove leading comma on award emoji when the user is the first to award the emoji (Zeger-Jan van de Weg)
- - Add build artifacts browser
- - Improve UX in builds artifacts browser
- - Increase default size of `data` column in `events` table when using MySQL
- - Expose button to CI Lint tool on project builds page
- - Fix: Creator should be added as a master of the project on creation
- - Added X-GitLab-... headers to emails from CI and Email On Push services (Anton Baklanov)
- - Add IP check against DNSBLs at account sign-up
- - Added cache:key to .gitlab-ci.yml allowing to fine tune the caching
-
-## 8.3.10
-
- - Prevent unauthorized access to other projects build traces
- - Forbid scripting for wiki files
-
-## 8.3.9
-
- - Prevent privilege escalation via "impersonate" feature
- - Prevent privilege escalation via notes API
- - Prevent privilege escalation via project webhook API
- - Prevent XSS via custom issue tracker URL
- - Prevent XSS via `window.opener`
- - Prevent information disclosure via project labels
- - Prevent information disclosure via new merge request page
-
-## 8.3.8
-
- - Fix persistent XSS vulnerability in `commit_person_link` helper
-
-## 8.3.7
-
- - Fix a 2FA authentication spoofing vulnerability.
-
-## 8.3.6
-
- - Don't attempt to fetch any tags from a forked repo (Stan Hu).
-
-## 8.3.5
-
- - Bump Git version requirement to 2.7.4
-
-## 8.3.4
-
- - Use gitlab-workhorse 0.5.4 (fixes API routing bug)
-
-## 8.3.3
-
- - Preserve CE behavior with JIRA integration by only calling API if URL is set
- - Fix duplicated branch creation/deletion events when using Web UI (Stan Hu)
- - Add configurable LDAP server query timeout
- - Get "Merge when build succeeds" to work when commits were pushed to MR target branch while builds were running
- - Suppress e-mails on failed builds if allow_failure is set (Stan Hu)
- - Fix project transfer e-mail sending incorrect paths in e-mail notification (Stan Hu)
- - Better support for referencing and closing issues in Asana service (Mike Wyatt)
- - Enable "Add key" button when user fills in a proper key (Stan Hu)
- - Fix error in processing reply-by-email messages (Jason Lee)
- - Fix Error 500 when visiting build page of project with nil runners_token (Stan Hu)
- - Use WOFF versions of SourceSansPro fonts
- - Fix regression when builds were not generated for tags created through web/api interface
- - Fix: maintain milestone filter between Open and Closed tabs (Greg Smethells)
- - Fix missing artifacts and build traces for build created before 8.3
-
-## 8.3.2
-
- - Disable --follow in `git log` to avoid loading duplicate commit data in infinite scroll (Stan Hu)
- - Add support for Google reCAPTCHA in user registration
-
-## 8.3.1
-
- - Fix Error 500 when global milestones have slashes (Stan Hu)
- - Fix Error 500 when doing a search in dashboard before visiting any project (Stan Hu)
- - Fix LDAP identity and user retrieval when special characters are used
- - Move Sidekiq-cron configuration to gitlab.yml
-
-## 8.3.0 (2015-12-22)
-
- - Bump rack-attack to 4.3.1 for security fix (Stan Hu)
- - API support for starred projects for authorized user (Zeger-Jan van de Weg)
- - Add open_issues_count to project API (Stan Hu)
- - Expand character set of usernames created by Omniauth (Corey Hinshaw)
- - Add button to automatically merge a merge request when the build succeeds (Zeger-Jan van de Weg)
- - Add unsubscribe link in the email footer (Zeger-Jan van de Weg)
- - Provide better diagnostic message upon project creation errors (Stan Hu)
- - Bump devise to 3.5.3 to fix reset token expiring after account creation (Stan Hu)
- - Remove api credentials from link to build_page
- - Deprecate GitLabCiService making it to always be inactive
- - Bump gollum-lib to 4.1.0 (Stan Hu)
- - Fix broken group avatar upload under "New group" (Stan Hu)
- - Update project repositorize size and commit count during import:repos task (Stan Hu)
- - Fix API setting of 'public' attribute to false will make a project private (Stan Hu)
- - Handle and report SSL errors in Webhook test (Stan Hu)
- - Bump Redis requirement to 2.8 for Sidekiq 4 (Stan Hu)
- - Fix: Assignee selector is empty when 'Unassigned' is selected (Jose Corcuera)
- - WIP identifier on merge requests no longer requires trailing space
- - Add rake tasks for git repository maintainance (Zeger-Jan van de Weg)
- - Fix 500 error when update group member permission
- - Fix: As an admin, cannot add oneself as a member to a group/project
- - Trim leading and trailing whitespace of milestone and issueable titles (Jose Corcuera)
- - Recognize issue/MR/snippet/commit links as references
- - Backport JIRA features from EE to CE
- - Add ignore whitespace change option to commit view
- - Fire update hook from GitLab
- - Allow account unlock via email
- - Style warning about mentioning many people in a comment
- - Fix: sort milestones by due date once again (Greg Smethells)
- - Migrate all CI::Services and CI::WebHooks to Services and WebHooks
- - Don't show project fork event as "imported"
- - Add API endpoint to fetch merge request commits list
- - Don't create CI status for refs that doesn't have .gitlab-ci.yml, even if the builds are enabled
- - Expose events API with comment information and author info
- - Fix: Ensure "Remove Source Branch" button is not shown when branch is being deleted. #3583
- - Run custom Git hooks when branch is created or deleted.
- - Fix bug when simultaneously accepting multiple MRs results in MRs that are of "merged" status, but not merged to the target branch
- - Add languages page to graphs
- - Block LDAP user when they are no longer found in the LDAP server
- - Improve wording on project visibility levels (Zeger-Jan van de Weg)
- - Fix editing notes on a merge request diff
- - Automatically select default clone protocol based on user preferences (Eirik Lygre)
- - Make Network page as sub tab of Commits
- - Add copy-to-clipboard button for Snippets
- - Add indication to merge request list item that MR cannot be merged automatically
- - Default target branch to patch-n when editing file in protected branch
- - Add Builds tab to merge request detail page
- - Allow milestones, issues and MRs to be created from dashboard and group indexes
- - Use new style for wiki
- - Use new style for milestone detail page
- - Fix sidebar tooltips when collapsed
- - Prevent possible XSS attack with award-emoji
- - Upgraded Sidekiq to 4.x
- - Accept COPYING,COPYING.lesser, and licence as license file (Zeger-Jan van de Weg)
- - Fix emoji aliases problem
- - Fix award-emojis Flash alert's width
- - Fix deleting notes on a merge request diff
- - Display referenced merge request statuses in the issue description (Greg Smethells)
- - Implement new sidebar for issue and merge request pages
- - Emoji picker improvements
- - Suppress warning about missing `.gitlab-ci.yml` if builds are disabled
- - Do not show build status unless builds are enabled and `.gitlab-ci.yml` is present
- - Persist runners registration token in database
- - Fix online editor should not remove newlines at the end of the file
- - Expose Git's version in the admin area
- - Show "New Merge Request" buttons on canonical repos when you have a fork (Josh Frye)
-
-## 8.2.6
-
- - Prevent unauthorized access to other projects build traces
- - Forbid scripting for wiki files
-
-## 8.2.5
-
- - Prevent privilege escalation via "impersonate" feature
- - Prevent privilege escalation via notes API
- - Prevent privilege escalation via project webhook API
- - Prevent XSS via `window.opener`
- - Prevent information disclosure via project labels
- - Prevent information disclosure via new merge request page
-
-## 8.2.4
-
- - Bump Git version requirement to 2.7.4
-
-## 8.2.3
-
- - Fix application settings cache not expiring after changes (Stan Hu)
- - Fix Error 500s when creating global milestones with Unicode characters (Stan Hu)
- - Update documentation for "Guest" permissions
- - Properly convert Emoji-only comments into Award Emojis
- - Enable devise paranoid mode to prevent user enumeration attack
- - Webhook payload has an added, modified and removed properties for each commit
- - Fix 500 error when creating a merge request that removes a submodule
-
-## 8.2.2
-
- - Fix 404 in redirection after removing a project (Stan Hu)
- - Ensure cached application settings are refreshed at startup (Stan Hu)
- - Fix Error 500 when viewing user's personal projects from admin page (Stan Hu)
- - Fix: Raw private snippets access workflow
- - Prevent "413 Request entity too large" errors when pushing large files with LFS
- - Fix invalid links within projects dashboard header
- - Make current user the first user in assignee dropdown in issues detail page (Stan Hu)
- - Fix: duplicate email notifications on issue comments
-
-## 8.2.1
-
- - Forcefully update builds that didn't want to update with state machine
- - Fix: saving GitLabCiService as Admin Template
-
-## 8.2.0 (2015-11-22)
-
- - Improved performance of finding projects and groups in various places
- - Improved performance of rendering user profile pages and Atom feeds
- - Expose build artifacts path as config option
- - Fix grouping of contributors by email in graph.
- - Improved performance of finding issues with/without labels
- - Fix Drone CI service template not saving properly (Stan Hu)
- - Fix avatars not showing in Atom feeds and project issues when Gravatar disabled (Stan Hu)
- - Added a GitLab specific profiling tool called "Sherlock" (see GitLab CE merge request #1749)
- - Upgrade gitlab_git to 7.2.20 and rugged to 0.23.3 (Stan Hu)
- - Improved performance of finding users by one of their Email addresses
- - Add allow_failure field to commit status API (Stan Hu)
- - Commits without .gitlab-ci.yml are marked as skipped
- - Save detailed error when YAML syntax is invalid
- - Since GitLab CI is enabled by default, remove enabling it by pushing .gitlab-ci.yml
- - Added build artifacts
- - Improved performance of replacing references in comments
- - Show last project commit to default branch on project home page
- - Highlight comment based on anchor in URL
- - Adds ability to remove the forked relationship from project settings screen. (Han Loong Liauw)
- - Improved performance of sorting milestone issues
- - Allow users to select the Files view as default project view (Cristian Bica)
- - Show "Empty Repository Page" for repository without branches (Artem V. Navrotskiy)
- - Fix: Inability to reply to code comments in the MR view, if the MR comes from a fork
- - Use git follow flag for commits page when retrieve history for file or directory
- - Show merge request CI status on merge requests index page
- - Send build name and stage in CI notification e-mail
- - Extend yml syntax for only and except to support specifying repository path
- - Enable shared runners to all new projects
- - Bump GitLab-Workhorse to 0.4.1
- - Allow to define cache in `.gitlab-ci.yml`
- - Fix: 500 error returned if destroy request without HTTP referer (Kazuki Shimizu)
- - Remove deprecated CI events from project settings page
- - Use issue editor as cross reference comment author when issue is edited with a new mention.
- - Add graphs of commits ahead and behind default branch (Jeff Stubler)
- - Improve personal snippet access workflow (Douglas Alexandre)
- - [API] Add ability to fetch the commit ID of the last commit that actually touched a file
- - Fix omniauth documentation setting for omnibus configuration (Jon Cairns)
- - Add "New file" link to dropdown on project page
- - Include commit logs in project search
- - Add "added", "modified" and "removed" properties to commit object in webhook
- - Rename "Back to" links to "Go to" because its not always a case it point to place user come from
- - Allow groups to appear in the search results if the group owner allows it
- - Add email notification to former assignee upon unassignment (Adam Lieskovský)
- - New design for project graphs page
- - Remove deprecated dumped yaml file generated from previous job definitions
- - Show specific runners from projects where user is master or owner
- - MR target branch is now visible on a list view when it is different from project's default one
- - Improve Continuous Integration graphs page
- - Make color of "Accept Merge Request" button consistent with current build status
- - Add ignore white space option in merge request diff and commit and compare view
- - Ability to add release notes (markdown text and attachments) to git tags (aka Releases)
- - Relative links from a repositories README.md now link to the default branch
- - Fix trailing whitespace issue in merge request/issue title
- - Fix bug when milestone/label filter was empty for dashboard issues page
- - Add ability to create milestone in group projects from single form
- - Add option to create merge request when editing/creating a file (Dirceu Tiegs)
- - Prevent the last owner of a group from being able to delete themselves by 'adding' themselves as a master (James Lopez)
- - Add Award Emoji to issue and merge request pages
-
-## 8.1.4
-
- - Fix bug where manually merged branches in a MR would end up with an empty diff (Stan Hu)
- - Prevent redirect loop when home_page_url is set to the root URL
- - Fix incoming email config defaults
- - Remove CSS property preventing hard tabs from rendering in Chromium 45 (Stan Hu)
-
-## 8.1.3
-
- - Force update refs/merge-requests/X/head upon a push to the source branch of a merge request (Stan Hu)
- - Spread out runner contacted_at updates
- - Use issue editor as cross reference comment author when issue is edited with a new mention
- - Add Facebook authentication
-
-## 8.1.2
-
- - Fix cloning Wiki repositories via HTTP (Stan Hu)
- - Add migration to remove satellites directory
- - Fix specific runners visibility
- - Fix 500 when editing CI service
- - Require CI jobs to be named
- - Fix CSS for runner status
- - Fix CI badge
- - Allow developer to manage builds
-
-## 8.1.1
-
- - Removed, see 8.1.2
-
-## 8.1.0 (2015-10-22)
-
- - Ensure MySQL CI limits DB migrations occur after the fields have been created (Stan Hu)
- - Fix duplicate repositories in GitHub import page (Stan Hu)
- - Redirect to a default path if HTTP_REFERER is not set (Stan Hu)
- - Adds ability to create directories using the web editor (Ben Ford)
- - Cleanup stuck CI builds
- - Send an email to admin email when a user is reported for spam (Jonathan Rochkind)
- - Show notifications button when user is member of group rather than project (Grzegorz Bizon)
- - Fix bug preventing mentioned issued from being closed when MR is merged using fast-forward merge.
- - Fix nonatomic database update potentially causing project star counts to go negative (Stan Hu)
- - Don't show "Add README" link in an empty repository if user doesn't have access to push (Stan Hu)
- - Fix error preventing displaying of commit data for a directory with a leading dot (Stan Hu)
- - Speed up load times of issue detail pages by roughly 1.5x
- - Fix CI rendering regressions
- - If a merge request is to close an issue, show this on the issue page (Zeger-Jan van de Weg)
- - Add a system note and update relevant merge requests when a branch is deleted or re-added (Stan Hu)
- - Make diff file view easier to use on mobile screens (Stan Hu)
- - Improved performance of finding users by username or Email address
- - Fix bug where merge request comments created by API would not trigger notifications (Stan Hu)
- - Add support for creating directories from Files page (Stan Hu)
- - Allow removing of project without confirmation when JavaScript is disabled (Stan Hu)
- - Support filtering by "Any" milestone or issue and fix "No Milestone" and "No Label" filters (Stan Hu)
- - Improved performance of the trending projects page
- - Remove CI migration task
- - Improved performance of finding projects by their namespace
- - Add assignee data to Issuables' hook_data (Bram Daams)
- - Fix bug where transferring a project would result in stale commit links (Stan Hu)
- - Fix build trace updating
- - Include full path of source and target branch names in New Merge Request page (Stan Hu)
- - Add user preference to view activities as default dashboard (Stan Hu)
- - Add option to admin area to sign in as a specific user (Pavel Forkert)
- - Show CI status on all pages where commits list is rendered
- - Automatically enable CI when push .gitlab-ci.yml file to repository
- - Move CI charts to project graphs area
- - Fix cases where Markdown did not render links in activity feed (Stan Hu)
- - Add first and last to pagination (Zeger-Jan van de Weg)
- - Added Commit Status API
- - Added Builds View
- - Added when to .gitlab-ci.yml
- - Show CI status on commit page
- - Added CI_BUILD_TAG, _STAGE, _NAME and _TRIGGERED to CI builds
- - Show CI status on Your projects page and Starred projects page
- - Remove "Continuous Integration" page from dashboard
- - Add notes and SSL verification entries to hook APIs (Ben Boeckel)
- - Fix grammar in admin area "labels" .nothing-here-block when no labels exist.
- - Move CI runners page to project settings area
- - Move CI variables page to project settings area
- - Move CI triggers page to project settings area
- - Move CI project settings page to CE project settings area
- - Fix bug when removed file was not appearing in merge request diff
- - Show warning when build cannot be served by any of the available CI runners
- - Note the original location of a moved project when notifying users of the move
- - Improve error message when merging fails
- - Add support of multibyte characters in LDAP UID (Roman Petrov)
- - Show additions/deletions stats on merge request diff
- - Remove footer text in emails (Zeger-Jan van de Weg)
- - Ensure code blocks are properly highlighted after a note is updated
- - Fix wrong access level badge on MR comments
- - Hide password in the service settings form
- - Move CI webhooks page to project settings area
- - Fix User Identities API. It now allows you to properly create or update user's identities.
- - Add user preference to change layout width (Peter Göbel)
- - Use commit status in merge request widget as preferred source of CI status
- - Integrate CI commit and build pages into project pages
- - Move CI services page to project settings area
- - Add "Quick Submit" behavior to input fields throughout the application. Use
- Cmd+Enter on Mac and Ctrl+Enter on Windows/Linux.
- - Fix position of hamburger in header for smaller screens (Han Loong Liauw)
- - Fix bug where Emojis in Markdown would truncate remaining text (Sakata Sinji)
- - Persist filters when sorting on admin user page (Jerry Lukins)
- - Update style of snippets pages (Han Loong Liauw)
- - Allow dashboard and group issues/MRs to be filtered by label
- - Add spellcheck=false to certain input fields
- - Invalidate stored service password if the endpoint URL is changed
- - Project names are not fully shown if group name is too big, even on group page view
- - Apply new design for Files page
- - Add "New Page" button to Wiki Pages tab (Stan Hu)
- - Only render 404 page from /public
- - Hide passwords from services API (Alex Lossent)
- - Fix: Images cannot show when projects' path was changed
- - Let gitlab-git-http-server generate and serve 'git archive' downloads
- - Optimize query when filtering on issuables (Zeger-Jan van de Weg)
- - Fix padding of outdated discussion item.
- - Animate the logo on hover
-
-## 8.0.5
-
- - Correct lookup-by-email for LDAP logins
- - Fix loading spinner sometimes not being hidden on Merge Request tab switches
-
-## 8.0.4
-
- - Fix Message-ID header to be RFC 2111-compliant to prevent e-mails being dropped (Stan Hu)
- - Fix referrals for :back and relative URL installs
- - Fix anchors to comments in diffs
- - Remove CI token from build traces
- - Fix "Assign All" button on Runner admin page
- - Fix search in Files
- - Add full project namespace to payload of system webhooks (Ricardo Band)
-
-## 8.0.3
-
- - Fix URL shown in Slack notifications
- - Fix bug where projects would appear to be stuck in the forked import state (Stan Hu)
- - Fix Error 500 in creating merge requests with > 1000 diffs (Stan Hu)
- - Add work_in_progress key to MR webhooks (Ben Boeckel)
-
-## 8.0.2
-
- - Fix default avatar not rendering in network graph (Stan Hu)
- - Skip check_initd_configured_correctly on omnibus installs
- - Prevent double-prefixing of help page paths
- - Clarify confirmation text on user deletion
- - Make commit graphs responsive to window width changes (Stan Hu)
- - Fix top margin for sign-in button on public pages
- - Fix LDAP attribute mapping
- - Remove git refs used internally by GitLab from network graph (Stan Hu)
- - Use standard Markdown font in Markdown preview instead of fixed-width font (Stan Hu)
- - Fix Reply by email for non-UTF-8 messages.
- - Add option to use StartTLS with Reply by email IMAP server.
- - Allow AWS S3 Server-Side Encryption with Amazon S3-Managed Keys for backups (Paul Beattie)
-
-## 8.0.1
-
- - Improve CI migration procedure and documentation
-
-## 8.0.0 (2015-09-22)
-
- - Fix Markdown links not showing up in dashboard activity feed (Stan Hu)
- - Remove milestones from merge requests when milestones are deleted (Stan Hu)
- - Fix HTML link that was improperly escaped in new user e-mail (Stan Hu)
- - Fix broken sort in merge request API (Stan Hu)
- - Bump rouge to 1.10.1 to remove warning noise and fix other syntax highlighting bugs (Stan Hu)
- - Gracefully handle errors in syntax highlighting by leaving the block unformatted (Stan Hu)
- - Add "replace" and "upload" functionalities to allow user replace existing file and upload new file into current repository
- - Fix URL construction for merge requests, issues, notes, and commits for relative URL config (Stan Hu)
- - Fix emoji URLs in Markdown when relative_url_root is used (Stan Hu)
- - Omit filename in Content-Disposition header in raw file download to avoid RFC 6266 encoding issues (Stan HU)
- - Fix broken Wiki Page History (Stan Hu)
- - Import forked repositories asynchronously to prevent large repositories from timing out (Stan Hu)
- - Prevent anchors from being hidden by header (Stan Hu)
- - Fix bug where only the first 15 Bitbucket issues would be imported (Stan Hu)
- - Sort issues by creation date in Bitbucket importer (Stan Hu)
- - Prevent too many redirects upon login when home page URL is set to external_url (Stan Hu)
- - Improve dropdown positioning on the project home page (Hannes Rosenögger)
- - Upgrade browser gem to 1.0.0 to avoid warning in IE11 compatibilty mode (Stan Hu)
- - Remove user OAuth tokens from the database and request new tokens each session (Stan Hu)
- - Restrict users API endpoints to use integer IDs (Stan Hu)
- - Only show recent push event if the branch still exists or a recent merge request has not been created (Stan Hu)
- - Remove satellites
- - Better performance for web editor (switched from satellites to rugged)
- - Faster merge
- - Ability to fetch merge requests from refs/merge-requests/:id
- - Allow displaying of archived projects in the admin interface (Artem Sidorenko)
- - Allow configuration of import sources for new projects (Artem Sidorenko)
- - Search for comments should be case insensetive
- - Create cross-reference for closing references on commits pushed to non-default branches (Maël Valais)
- - Ability to search milestones
- - Gracefully handle SMTP user input errors (e.g. incorrect email addresses) to prevent Sidekiq retries (Stan Hu)
- - Move dashboard activity to separate page (for your projects and starred projects)
- - Improve performance of git blame
- - Limit content width to 1200px for most of pages to improve readability on big screens
- - Fix 500 error when submit project snippet without body
- - Improve search page usability
- - Bring more UI consistency in way how projects, snippets and groups lists are rendered
- - Make all profiles and group public
- - Fixed login failure when extern_uid changes (Joel Koglin)
- - Don't notify users without access to the project when they are (accidentally) mentioned in a note.
- - Retrieving oauth token with LDAP credentials
- - Load Application settings from running database unless env var USE_DB=false
- - Added Drone CI integration (Kirill Zaitsev)
- - Allow developers to retry builds
- - Hide advanced project options for non-admin users
- - Fail builds if no .gitlab-ci.yml is found
- - Refactored service API and added automatically service docs generator (Kirill Zaitsev)
- - Added web_url key project hook_attrs (Kirill Zaitsev)
- - Add ability to get user information by ID of an SSH key via the API
- - Fix bug which IE cannot show image at markdown when the image is raw file of gitlab
- - Add support for Crowd
- - Global Labels that are available to all projects
- - Fix highlighting of deleted lines in diffs.
- - Project notification level can be set on the project page itself
- - Added service API endpoint to retrieve service parameters (Petheő Bence)
- - Add FogBugz project import (Jared Szechy)
- - Sort users autocomplete lists by user (Allister Antosik)
- - Webhook for issue now contains repository field (Jungkook Park)
- - Add ability to add custom text to the help page (Jeroen van Baarsen)
- - Add pg_schema to backup config
- - Fix references to target project issues in Merge Requests markdown preview and textareas (Francesco Levorato)
- - Redirect from incorrectly cased group or project path to correct one (Francesco Levorato)
- - Removed API calls from CE to CI
-
-## 7.14.3 through 0.8.0
+## 8.15.8 through 0.8.0
- See [changelogs/archive.md](changelogs/archive.md)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 01d4a546b97..9c8fdc1275b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,7 +26,7 @@ _This notice should stay as the first item in the CONTRIBUTING.md file._
- [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/CD", ~Discussion, ~Edge, ~Platform, etc.)](#team-labels-cicd-discussion-edge-platform-etc)
- - [Priority labels (~Deliverable and ~Stretch)](#priority-labels-deliverable-and-stretch)
+ - [Priority labels (~Deliverable, ~Stretch, ~"Next Patch Release")](#priority-labels-deliverable-stretch-next-patch-release)
- [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)
@@ -50,6 +50,9 @@ _This notice should stay as the first item in the CONTRIBUTING.md file._
## Contribute to GitLab
+For a first-time step-by-step guide to the contribution process, see
+["Contributing to GitLab"](https://about.gitlab.com/contributing/).
+
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.
@@ -95,8 +98,8 @@ coach is going to finish the merge request we assign the
## Helping others
-Please help other GitLab users when you can. The channels people will reach out
-on can be found on the [getting help page][getting-help].
+Please help other GitLab users when you can.
+The methods people will use to seek help can be found on the [getting help page][getting-help].
Sign up for the mailing list, answer GitLab questions on StackOverflow or
respond in the IRC channel. You can also sign up on [CodeTriage][codetriage] to help with
@@ -104,9 +107,13 @@ the remaining issues on the GitHub issue tracker.
## I want to contribute!
-If you want to contribute to GitLab, [issues with the label `Accepting Merge Requests` and small weight][accepting-mrs-weight] is a great place to start. Issues with a lower weight (1 or 2) are deemed suitable for beginners.
-These issues will be of reasonable size and challenge, for anyone to start
-contributing to GitLab.
+If you want to contribute to GitLab [issues with the label `Accepting Merge Requests` and small weight][accepting-mrs-weight]
+is a great place to start. Issues with a lower weight (1 or 2) are deemed
+suitable for beginners. These issues will be of reasonable size and challenge,
+for anyone to start contributing to GitLab. If you have any questions or need help visit [Getting Help](https://about.gitlab.com/getting-help/#discussion) to
+learn how to communicate with GitLab. If you're looking for a Gitter or Slack channel
+please consider we favor
+[asynchronous communication](https://about.gitlab.com/handbook/communication/#internal-communication) over real time communication. Thanks for your contribution!
## Workflow labels
@@ -119,7 +126,7 @@ Most issues will have labels for at least one of the following:
- Type: ~"feature proposal", ~bug, ~customer, etc.
- Subject: ~wiki, ~"container registry", ~ldap, ~api, ~frontend, etc.
- Team: ~"CI/CD", ~Discussion, ~Edge, ~Platform, etc.
-- Priority: ~Deliverable, ~Stretch
+- Priority: ~Deliverable, ~Stretch, ~"Next Patch Release"
All labels, their meaning and priority are defined on the
[labels page][labels-page].
@@ -167,7 +174,7 @@ Assigning a team label makes sure issues get the attention of the appropriate
people.
The current team labels are ~Build, ~"CI/CD", ~Discussion, ~Documentation, ~Edge,
-~Geo, ~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX".
+~Geo, ~Gitaly, ~Monitoring, ~Platform, ~Release, ~"Security Products" and ~"UX".
The descriptions on the [labels page][labels-page] explain what falls under the
responsibility of each team.
@@ -178,7 +185,7 @@ 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.
-### Priority labels (~Deliverable and ~Stretch)
+### Priority labels (~Deliverable, ~Stretch, ~"Next Patch Release")
Priority labels help us clearly communicate expectations of the work for the
release. There are two levels of priority labels:
@@ -188,6 +195,24 @@ release. There are two levels of priority labels:
- ~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.
+- ~"Next Patch Release": Issues to put in the next patch release. Work on these
+ first, and add the "Pick Into X" label to the merge request, along with the
+ appropriate milestone.
+
+Each issue scheduled for the current milestone should be labeled ~Deliverable
+or ~"Stretch". Any open issue for a previous milestone should be labeled
+~"Next Patch Release", or otherwise rescheduled to a different milestone.
+
+### Severity labels (~S1, ~S2, etc.)
+
+Severity labels help us clearly communicate the impact of a ~bug on users.
+
+| Label | Meaning | Example |
+|-------|------------------------------------------|---------|
+| ~S1 | Feature broken, no workaround | Unable to create an issue |
+| ~S2 | Feature broken, workaround unacceptable | Can push commits, but only via the command line |
+| ~S3 | Feature broken, workaround acceptable | Can create merge requests only from the Merge Requests page, not through the Issue |
+| ~S4 | Cosmetic issue | Label colors are incorrect / not being displayed |
### Label for community contributors (~"Accepting Merge Requests")
@@ -390,9 +415,9 @@ For issues related to the open source stewardship of GitLab,
there is the ~"stewardship" label.
This label is to be used for issues in which the stewardship of GitLab
-is a topic of discussion. For instance if GitLab Inc. is planning to remove
-features from GitLab CE to make exclusive in GitLab EE, related issues
-would be labelled with ~"stewardship".
+is a topic of discussion. For instance if GitLab Inc. is planning to add
+features from GitLab EE to GitLab CE, related issues would be labelled with
+~"stewardship".
A recent example of this was the issue for
[bringing the time tracking API to GitLab CE][time-tracking-issue].
@@ -553,7 +578,7 @@ 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 system tests][testing] that pass on the CI server
+1. [Unit, integration, 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][changelog], if necessary
@@ -668,3 +693,4 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor
[^1]: Please note that specs other than JavaScript specs are considered backend
code.
+ \ No newline at end of file
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 7e750b4ebf3..c5c735103b2 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-0.60.0
+0.94.0
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index a918a2aa18d..a3df0a6959e 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-0.6.0
+0.8.0
diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION
index e030a0157c9..a8a18875682 100644
--- a/GITLAB_SHELL_VERSION
+++ b/GITLAB_SHELL_VERSION
@@ -1 +1 @@
-5.10.3
+7.1.2
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index bea438e9ade..fcdb2e109f6 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-3.3.1
+4.0.0
diff --git a/Gemfile b/Gemfile
index b6ffaf80f24..647138cd90f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,6 +1,18 @@
+# --- Special code for migrating to Rails 5.0 ---
+def rails5?
+ %w[1 true].include?(ENV["RAILS5"])
+end
+
+gem_versions = {}
+gem_versions['activerecord_sane_schema_dumper'] = rails5? ? '1.0' : '0.2'
+gem_versions['default_value_for'] = rails5? ? '~> 3.0.5' : '~> 3.0.0'
+gem_versions['rails'] = rails5? ? '5.0.6' : '4.2.10'
+gem_versions['rails-i18n'] = rails5? ? '~> 5.1' : '~> 4.0.9'
+# --- The end of special code for migrating to Rails 5.0 ---
+
source 'https://rubygems.org'
-gem 'rails', '4.2.10'
+gem 'rails', gem_versions['rails']
gem 'rails-deprecated_sanitizer', '~> 1.0.3'
# Responders respond_to and respond_with
@@ -9,36 +21,37 @@ gem 'responders', '~> 2.0'
gem 'sprockets', '~> 3.7.0'
# Default values for AR models
-gem 'default_value_for', '~> 3.0.0'
+gem 'default_value_for', gem_versions['default_value_for']
# Supported DBs
-gem 'mysql2', '~> 0.4.5', group: :mysql
+gem 'mysql2', '~> 0.4.10', group: :mysql
gem 'pg', '~> 0.18.2', group: :postgres
-gem 'rugged', '~> 0.26.0'
+gem 'rugged', '~> 0.27'
gem 'grape-route-helpers', '~> 2.1.0'
gem 'faraday', '~> 0.12'
# Authentication libraries
gem 'devise', '~> 4.2'
-gem 'doorkeeper', '~> 4.2.0'
-gem 'doorkeeper-openid_connect', '~> 1.2.0'
-gem 'omniauth', '~> 1.4.2'
-gem 'omniauth-auth0', '~> 1.4.1'
+gem 'doorkeeper', '~> 4.3'
+gem 'doorkeeper-openid_connect', '~> 1.3'
+gem 'omniauth', '~> 1.8'
+gem 'omniauth-auth0', '~> 2.0.0'
gem 'omniauth-azure-oauth2', '~> 0.0.9'
gem 'omniauth-cas3', '~> 1.1.4'
gem 'omniauth-facebook', '~> 4.0.0'
gem 'omniauth-github', '~> 1.1.1'
gem 'omniauth-gitlab', '~> 1.0.2'
-gem 'omniauth-google-oauth2', '~> 0.5.2'
+gem 'omniauth-google-oauth2', '~> 0.5.3'
gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos
gem 'omniauth-oauth2-generic', '~> 0.2.2'
-gem 'omniauth-saml', '~> 1.7.0'
+gem 'omniauth-saml', '~> 1.10'
gem 'omniauth-shibboleth', '~> 1.2.0'
-gem 'omniauth-twitter', '~> 1.2.0'
+gem 'omniauth-twitter', '~> 1.4'
gem 'omniauth_crowd', '~> 2.2.0'
gem 'omniauth-authentiq', '~> 0.3.1'
+gem 'omniauth-jwt', '~> 0.0.2'
gem 'rack-oauth2', '~> 1.2.1'
gem 'jwt', '~> 1.5.6'
@@ -69,16 +82,24 @@ gem 'net-ldap'
# Git Wiki
# Required manually in config/initializers/gollum.rb to control load order
+# Before updating this gem, check if
+# https://github.com/gollum/gollum-lib/pull/292 has been merged.
+# If it has, then remove the monkey patch for update_page, rename_page and raw_data_in_committer
+# in config/initializers/gollum.rb
gem 'gollum-lib', '~> 4.2', require: false
+
+# Before updating this gem, check if
+# https://github.com/gollum/rugged_adapter/pull/28 has been merged.
+# If it has, then remove the monkey patch for tree_entry in config/initializers/gollum.rb
gem 'gollum-rugged_adapter', '~> 0.4.4', require: false
# Language detection
-gem 'github-linguist', '~> 4.7.0', require: 'linguist'
+gem 'github-linguist', '~> 5.3.3', require: 'linguist'
# API
gem 'grape', '~> 1.0'
gem 'grape-entity', '~> 0.6.0'
-gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
+gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
# Disable strong_params so that Mash does not respond to :permitted?
gem 'hashie-forbidden_attributes'
@@ -96,39 +117,40 @@ gem 'carrierwave', '~> 1.2'
gem 'dropzonejs-rails', '~> 0.7.1'
# for backups
-gem 'fog-aws', '~> 1.4'
+gem 'fog-aws', '~> 2.0.1'
gem 'fog-core', '~> 1.44'
-gem 'fog-google', '~> 0.5'
+gem 'fog-google', '~> 1.3.3'
gem 'fog-local', '~> 0.3'
gem 'fog-openstack', '~> 0.1'
gem 'fog-rackspace', '~> 0.1.1'
gem 'fog-aliyun', '~> 0.2.0'
# for Google storage
-gem 'google-api-client', '~> 0.13.6'
+gem 'google-api-client', '~> 0.19.8'
# for aws storage
gem 'unf', '~> 0.1.4'
# Seed data
-gem 'seed-fu', '2.3.6' # Upgrade to > 2.3.7 once https://github.com/mbleigh/seed-fu/issues/123 is solved
+gem 'seed-fu', '~> 2.3.7'
# Markdown and HTML processing
-gem 'html-pipeline', '~> 1.11.0'
+gem 'html-pipeline', '~> 2.7.1'
gem 'deckar01-task_list', '2.0.0'
gem 'gitlab-markup', '~> 1.6.2'
gem 'redcarpet', '~> 3.4'
+gem 'commonmarker', '~> 0.17'
gem 'RedCloth', '~> 4.3.2'
gem 'rdoc', '~> 4.2'
gem 'org-ruby', '~> 0.9.12'
gem 'creole', '~> 0.5.0'
gem 'wikicloth', '0.8.1'
-gem 'asciidoctor', '~> 1.5.2'
-gem 'asciidoctor-plantuml', '0.0.7'
+gem 'asciidoctor', '~> 1.5.6'
+gem 'asciidoctor-plantuml', '0.0.8'
gem 'rouge', '~> 2.0'
gem 'truncato', '~> 0.7.9'
gem 'bootstrap_form', '~> 2.7.0'
-gem 'nokogiri', '~> 1.8.1'
+gem 'nokogiri', '~> 1.8.2'
# Diffs
gem 'diffy', '~> 3.1.0'
@@ -140,10 +162,10 @@ group :unicorn do
end
# State machine
-gem 'state_machines-activerecord', '~> 0.4.0'
+gem 'state_machines-activerecord', '~> 0.5.1'
# Issue tags
-gem 'acts-as-taggable-on', '~> 4.0'
+gem 'acts-as-taggable-on', '~> 5.0'
# Background jobs
gem 'sidekiq', '~> 5.0'
@@ -199,7 +221,7 @@ gem 'asana', '~> 0.6.0'
gem 'ruby-fogbugz', '~> 0.2.1'
# Kubernetes integration
-gem 'kubeclient', '~> 2.2.0'
+gem 'kubeclient', '~> 3.0'
# d3
gem 'd3_rails', '~> 3.5.0'
@@ -209,10 +231,10 @@ gem 'sanitize', '~> 2.0'
gem 'babosa', '~> 1.0.2'
# Sanitizes SVG input
-gem 'loofah', '~> 2.0.3'
+gem 'loofah', '~> 2.2'
# Working with license
-gem 'licensee', '~> 8.7.0'
+gem 'licensee', '~> 8.9'
# Protect against bruteforcing
gem 'rack-attack', '~> 4.4.1'
@@ -226,8 +248,8 @@ gem 'mousetrap-rails', '~> 1.4.6'
# Detect and convert string character encoding
gem 'charlock_holmes', '~> 0.7.5'
-# Faster JSON
-gem 'oj', '~> 2.17.4'
+# Faster blank
+gem 'fast_blank'
# Parse time & duration
gem 'chronic', '~> 0.10.2'
@@ -245,22 +267,21 @@ gem 'font-awesome-rails', '~> 4.7'
gem 'gemojione', '~> 3.3'
gem 'gon', '~> 6.1.0'
gem 'jquery-atwho-rails', '~> 1.3.2'
-gem 'jquery-rails', '~> 4.3.1'
gem 'request_store', '~> 1.3'
gem 'select2-rails', '~> 3.5.9'
gem 'virtus', '~> 1.0.1'
gem 'base32', '~> 0.3.0'
# Sentry integration
-gem 'sentry-raven', '~> 2.5.3'
+gem 'sentry-raven', '~> 2.7'
gem 'premailer-rails', '~> 1.9.7'
# I18n
gem 'ruby_parser', '~> 3.8', require: false
-gem 'rails-i18n', '~> 4.0.9'
+gem 'rails-i18n', gem_versions['rails-i18n']
gem 'gettext_i18n_rails', '~> 1.8.0'
-gem 'gettext_i18n_rails_js', '~> 1.2.0'
+gem 'gettext_i18n_rails_js', '~> 1.3'
gem 'gettext', '~> 3.2.2', require: false, group: :development
gem 'batch-loader', '~> 1.2.1'
@@ -268,7 +289,6 @@ gem 'batch-loader', '~> 1.2.1'
# 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
@@ -283,13 +303,13 @@ group :metrics do
gem 'influxdb', '~> 0.2', require: false
# Prometheus
- gem 'prometheus-client-mmap', '~> 0.7.0.beta43'
+ gem 'prometheus-client-mmap', '~> 0.9.1'
gem 'raindrops', '~> 0.18'
end
group :development do
- gem 'foreman', '~> 0.78.0'
- gem 'brakeman', '~> 3.6.0', require: false
+ gem 'foreman', '~> 0.84.0'
+ gem 'brakeman', '~> 4.2', require: false
gem 'letter_opener_web', '~> 1.3.0'
gem 'rblineprof', '~> 0.3.6', platform: :mri, require: false
@@ -318,7 +338,7 @@ group :development, :test do
gem 'spinach-rerun-reporter', '~> 0.0.2'
gem 'rspec_profiling', '~> 0.0.5'
gem 'rspec-set', '~> 0.1.3'
- gem 'rspec-parameterized'
+ gem 'rspec-parameterized', require: false
# Prevent occasions where minitest is not bundled in packaged versions of ruby (see #3826)
gem 'minitest', '~> 5.7.0'
@@ -334,25 +354,29 @@ group :development, :test do
gem 'spring-commands-rspec', '~> 1.0.4'
gem 'spring-commands-spinach', '~> 1.1.0'
- gem 'rubocop', '~> 0.49.1', require: false
- gem 'rubocop-rspec', '~> 1.15.1', require: false
- gem 'rubocop-gitlab-security', '~> 0.1.0', require: false
- gem 'scss_lint', '~> 0.54.0', require: false
+ gem 'gitlab-styles', '~> 2.3', require: false
+ # Pin these dependencies, otherwise a new rule could break the CI pipelines
+ gem 'rubocop', '~> 0.52.1'
+ gem 'rubocop-rspec', '~> 1.22.1'
+
+ gem 'scss_lint', '~> 0.56.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 'flay', '~> 2.10.0', require: false
gem 'bundler-audit', '~> 0.5.0', require: false
gem 'benchmark-ips', '~> 2.3.0', require: false
gem 'license_finder', '~> 3.1', require: false
- gem 'knapsack', '~> 1.11.0'
+ gem 'knapsack', '~> 1.16'
- gem 'activerecord_sane_schema_dumper', '0.2'
+ gem 'activerecord_sane_schema_dumper', gem_versions['activerecord_sane_schema_dumper']
gem 'stackprof', '~> 0.2.10', require: false
gem 'simple_po_parser', '~> 1.1.2', require: false
+
+ gem 'timecop', '~> 0.8.0'
end
group :test do
@@ -360,28 +384,25 @@ group :test do
gem 'email_spec', '~> 1.6.0'
gem 'json-schema', '~> 2.8.0'
gem 'webmock', '~> 2.3.2'
- gem 'test_after_commit', '~> 1.1'
+ gem 'rails-controller-testing' if rails5? # Rails5 only gem.
+ gem 'test_after_commit', '~> 1.1' unless rails5? # Remove this gem when migrated to rails 5.0. It's been integrated to rails 5.0.
gem 'sham_rack', '~> 1.3.6'
- gem 'timecop', '~> 0.8.0'
gem 'concurrent-ruby', '~> 1.0.5'
gem 'test-prof', '~> 0.2.5'
end
-gem 'octokit', '~> 4.6.2'
+gem 'octokit', '~> 4.8'
gem 'mail_room', '~> 0.9.1'
gem 'email_reply_trimmer', '~> 0.1'
gem 'html2text'
-gem 'ruby-prof', '~> 0.16.2'
+gem 'ruby-prof', '~> 0.17.0'
# OAuth
gem 'oauth2', '~> 1.4'
-# Soft deletion
-gem 'paranoia', '~> 2.3.1'
-
# Health check
gem 'health_check', '~> 2.6.0'
@@ -390,7 +411,8 @@ gem 'vmstat', '~> 2.3.0'
gem 'sys-filesystem', '~> 1.1.6'
# SSH host key support
-gem 'net-ssh', '~> 4.1.0'
+gem 'net-ssh', '~> 4.2.0'
+gem 'sshkey', '~> 1.9.0'
# Required for ED25519 SSH host key support
group :ed25519 do
@@ -400,14 +422,18 @@ group :ed25519 do
end
# Gitaly GRPC client
-gem 'gitaly-proto', '~> 0.61.0', require: 'gitaly'
+gem 'gitaly-proto', '~> 0.94.0', require: 'gitaly'
+gem 'grpc', '~> 1.10.0'
-gem 'toml-rb', '~> 0.3.15', require: false
+# Locked until https://github.com/google/protobuf/issues/4210 is closed
+gem 'google-protobuf', '= 3.5.1'
+
+gem 'toml-rb', '~> 1.0.0', require: false
# Feature toggles
-gem 'flipper', '~> 0.11.0'
-gem 'flipper-active_record', '~> 0.11.0'
-gem 'flipper-active_support_cache_store', '~> 0.11.0'
+gem 'flipper', '~> 0.13.0'
+gem 'flipper-active_record', '~> 0.13.0'
+gem 'flipper-active_support_cache_store', '~> 0.13.0'
# Structured logging
gem 'lograge', '~> 0.5'
@@ -415,3 +441,5 @@ gem 'grape_logging', '~> 1.7'
# Asset synchronization
gem 'asset_sync', '~> 2.2.0'
+
+gem 'goldiloader', '~> 2.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index a6e3c9e27cc..76e1a17155f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -40,13 +40,14 @@ GEM
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
- acts-as-taggable-on (4.0.0)
- activerecord (>= 4.0)
+ acts-as-taggable-on (5.0.0)
+ activerecord (>= 4.2.8)
adamantium (0.2.0)
ice_nine (~> 0.11.0)
memoizable (~> 0.4.0)
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
+ aes_key_wrap (1.0.1)
akismet (2.0.0)
allocations (1.0.5)
arel (6.0.4)
@@ -55,15 +56,15 @@ GEM
faraday_middleware (~> 0.9)
faraday_middleware-multi_json (~> 0.0)
oauth2 (~> 1.0)
- asciidoctor (1.5.3)
- asciidoctor-plantuml (0.0.7)
+ asciidoctor (1.5.6.2)
+ asciidoctor-plantuml (0.0.8)
asciidoctor (~> 1.5)
asset_sync (2.2.0)
activemodel (>= 4.1.0)
fog-core
mime-types (>= 2.99)
unf
- ast (2.3.0)
+ ast (2.4.0)
atomic (1.1.99)
attr_encrypted (3.0.3)
encryptor (~> 3.0.0)
@@ -86,7 +87,7 @@ GEM
coderay (>= 1.0.0)
erubis (>= 2.6.6)
rack (>= 0.9.0)
- bindata (2.4.1)
+ bindata (2.4.3)
binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1)
blankslate (2.1.2.4)
@@ -94,7 +95,7 @@ GEM
autoprefixer-rails (>= 5.2.1)
sass (>= 3.3.4)
bootstrap_form (2.7.0)
- brakeman (3.6.1)
+ brakeman (4.2.1)
browser (2.2.0)
builder (3.2.3)
bullet (5.5.1)
@@ -119,7 +120,7 @@ GEM
activesupport (>= 4.0.0)
mime-types (>= 1.16)
cause (0.1)
- charlock_holmes (0.7.5)
+ charlock_holmes (0.7.6)
childprocess (0.7.0)
ffi (~> 1.0, >= 1.0.11)
chronic (0.10.2)
@@ -131,6 +132,8 @@ GEM
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
colorize (0.7.7)
+ commonmarker (0.17.8)
+ ruby-enum (~> 0.5)
concord (0.1.5)
adamantium (~> 0.2.0)
equalizer (~> 0.0.9)
@@ -140,6 +143,7 @@ GEM
connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
+ crass (1.0.3)
creole (0.5.0)
css_parser (1.5.0)
addressable
@@ -172,12 +176,12 @@ GEM
diff-lcs (1.3)
diffy (3.1.0)
docile (1.1.5)
- domain_name (0.5.20161021)
+ domain_name (0.5.20170404)
unf (>= 0.0.5, < 1.0.0)
- doorkeeper (4.2.6)
+ doorkeeper (4.3.1)
railties (>= 4.2)
- doorkeeper-openid_connect (1.2.0)
- doorkeeper (~> 4.0)
+ doorkeeper-openid_connect (1.3.0)
+ doorkeeper (~> 4.3)
json-jwt (~> 1.6)
dropzonejs-rails (0.7.2)
rails (> 3.1)
@@ -192,7 +196,7 @@ GEM
et-orbi (1.0.3)
tzinfo
eventmachine (1.0.8)
- excon (0.57.1)
+ excon (0.60.0)
execjs (2.6.0)
expression_parser (0.9.0)
factory_bot (4.8.2)
@@ -207,21 +211,22 @@ GEM
faraday_middleware-multi_json (0.0.6)
faraday_middleware
multi_json
- fast_gettext (1.4.0)
+ fast_blank (1.0.0)
+ fast_gettext (1.6.0)
ffaker (2.4.0)
ffi (1.9.18)
- flay (2.8.1)
+ flay (2.10.0)
erubis (~> 2.7.0)
path_expander (~> 1.0)
ruby_parser (~> 3.0)
sexp_processor (~> 4.0)
- flipper (0.11.0)
- flipper-active_record (0.11.0)
+ flipper (0.13.0)
+ flipper-active_record (0.13.0)
activerecord (>= 3.2, < 6)
- flipper (~> 0.11.0)
- flipper-active_support_cache_store (0.11.0)
+ flipper (~> 0.13.0)
+ flipper-active_support_cache_store (0.13.0)
activesupport (>= 3.2, < 6)
- flipper (~> 0.11.0)
+ flipper (~> 0.13.0)
flowdock (0.7.1)
httparty (~> 0.7)
multi_json
@@ -230,19 +235,20 @@ GEM
fog-json (~> 1.0)
ipaddress (~> 0.8)
xml-simple (~> 1.1)
- fog-aws (1.4.0)
+ fog-aws (2.0.1)
fog-core (~> 1.38)
fog-json (~> 1.0)
fog-xml (~> 0.1)
ipaddress (~> 0.8)
- fog-core (1.44.3)
+ fog-core (1.45.0)
builder
- excon (~> 0.49)
+ excon (~> 0.58)
formatador (~> 0.2)
- fog-google (0.5.3)
+ fog-google (1.3.3)
fog-core
fog-json
fog-xml
+ google-api-client (~> 0.19.1)
fog-json (1.0.2)
fog-core (~> 1.0)
multi_json (~> 1.10)
@@ -262,7 +268,7 @@ GEM
nokogiri (>= 1.5.11, < 2.0.0)
font-awesome-rails (4.7.0.1)
railties (>= 3.2, < 5.1)
- foreman (0.78.0)
+ foreman (0.84.0)
thor (~> 0.19.1)
formatador (0.2.5)
fuubar (2.2.0)
@@ -273,25 +279,25 @@ GEM
gemojione (3.3.0)
json
get_process_mem (0.2.0)
- gettext (3.2.2)
+ gettext (3.2.9)
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_i18n_rails_js (1.3.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-proto (0.61.0)
+ gitaly-proto (0.94.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
- github-linguist (4.7.6)
- charlock_holmes (~> 0.7.3)
+ github-linguist (5.3.3)
+ charlock_holmes (~> 0.7.5)
escape_utils (~> 1.1.0)
mime-types (>= 1.19)
- rugged (>= 0.23.0b)
+ rugged (>= 0.25.1)
github-markup (1.6.1)
gitlab-flowdock-git-hook (1.0.1)
flowdock (~> 0.7)
@@ -303,6 +309,10 @@ GEM
mime-types (>= 1.16)
posix-spawn (~> 0.3)
gitlab-markup (1.6.3)
+ gitlab-styles (2.3.2)
+ rubocop (~> 0.51)
+ rubocop-gitlab-security (~> 0.1.0)
+ rubocop-rspec (~> 1.19)
gitlab_omniauth-ldap (2.0.4)
net-ldap (~> 0.16)
omniauth (~> 1.3)
@@ -310,6 +320,9 @@ GEM
rubyntlm (~> 0.5)
globalid (0.4.1)
activesupport (>= 4.2.0)
+ goldiloader (2.0.1)
+ activerecord (>= 4.2, < 5.2)
+ activesupport (>= 4.2, < 5.2)
gollum-grit_adapter (1.0.1)
gitlab-grit (~> 2.7, >= 2.7.1)
gollum-lib (4.2.7)
@@ -328,17 +341,19 @@ GEM
json
multi_json
request_store (>= 1.0)
- google-api-client (0.13.6)
+ google-api-client (0.19.8)
addressable (~> 2.5, >= 2.5.1)
- googleauth (~> 0.5)
+ googleauth (>= 0.5, < 0.7.0)
httpclient (>= 2.8.1, < 3.0)
mime-types (~> 3.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
- google-protobuf (3.4.1.1)
- googleauth (0.5.3)
+ google-protobuf (3.5.1)
+ googleapis-common-protos-types (1.0.1)
+ google-protobuf (~> 3.0)
+ googleauth (0.6.2)
faraday (~> 0.12)
- jwt (~> 1.4)
+ jwt (>= 1.4, < 3.0)
logging (~> 2.0)
memoist (~> 0.12)
multi_json (~> 1.11)
@@ -346,7 +361,7 @@ GEM
signet (~> 0.7)
gpgme (2.0.13)
mini_portile2 (~> 2.1)
- grape (1.0.0)
+ grape (1.0.2)
activesupport
builder
mustermann-grape (~> 1.0.0)
@@ -362,9 +377,10 @@ GEM
rake
grape_logging (1.7.0)
grape
- grpc (1.4.5)
+ grpc (1.10.0)
google-protobuf (~> 3.1)
- googleauth (~> 0.5.1)
+ googleapis-common-protos-types (~> 1.0.0)
+ googleauth (>= 0.5.1, < 0.7)
haml (4.0.7)
tilt
haml_lint (0.26.0)
@@ -378,7 +394,7 @@ GEM
thor
tilt
hashdiff (0.3.4)
- hashie (3.5.6)
+ hashie (3.5.7)
hashie-forbidden_attributes (0.1.1)
hashie (>= 3.0)
health_check (2.6.0)
@@ -386,26 +402,26 @@ GEM
hipchat (1.5.2)
httparty
mimemagic
- html-pipeline (1.11.0)
+ html-pipeline (2.7.1)
activesupport (>= 2)
- nokogiri (~> 1.4)
+ nokogiri (>= 1.4)
html2text (0.2.0)
nokogiri (~> 1.6)
htmlentities (4.3.4)
- http (0.9.8)
+ http (2.2.2)
addressable (~> 2.3)
http-cookie (~> 1.0)
http-form_data (~> 1.0.1)
http_parser.rb (~> 0.6.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
- http-form_data (1.0.1)
+ http-form_data (1.0.3)
http_parser.rb (0.6.0)
httparty (0.13.7)
json (~> 1.8)
multi_xml (>= 0.5.2)
- httpclient (2.8.2)
- i18n (0.9.1)
+ httpclient (2.8.3)
+ i18n (0.9.5)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
influxdb (0.2.3)
@@ -417,15 +433,11 @@ GEM
multipart-post
oauth (~> 0.5, >= 0.5.0)
jquery-atwho-rails (1.3.2)
- jquery-rails (4.3.1)
- rails-dom-testing (>= 1, < 3)
- railties (>= 4.2.0)
- thor (>= 0.14, < 2.0)
json (1.8.6)
- json-jwt (1.7.2)
+ json-jwt (1.9.2)
activesupport
+ aes_key_wrap
bindata
- multi_json (>= 1.3)
securecompare
url_safe_base64
json-schema (2.8.0)
@@ -444,13 +456,13 @@ GEM
kaminari-core (= 1.0.1)
kaminari-core (1.0.1)
kgio (2.10.0)
- knapsack (1.11.0)
+ knapsack (1.16.0)
rake
timecop (>= 0.1.0)
- kubeclient (2.2.0)
- http (= 0.9.8)
- recursive-open-struct (= 1.0.0)
- rest-client
+ kubeclient (3.0.0)
+ http (~> 2.2.2)
+ recursive-open-struct (~> 1.0.4)
+ rest-client (~> 2.0)
launchy (2.4.3)
addressable (~> 2.3)
letter_opener (1.4.1)
@@ -467,7 +479,7 @@ GEM
toml (= 0.1.2)
with_env (> 1.0)
xml-simple
- licensee (8.7.0)
+ licensee (8.9.2)
rugged (~> 0.24)
little-plugger (1.1.4)
locale (2.1.2)
@@ -478,7 +490,8 @@ GEM
actionpack (>= 4, < 5.2)
activesupport (>= 4, < 5.2)
railties (>= 4, < 5.2)
- loofah (2.0.3)
+ loofah (2.2.2)
+ crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.0)
mini_mime (>= 0.1.1)
@@ -491,38 +504,37 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521)
mimemagic (0.3.0)
- mini_mime (0.1.4)
+ mini_mime (1.0.0)
mini_portile2 (2.3.0)
minitest (5.7.0)
mousetrap-rails (1.4.6)
- multi_json (1.12.2)
+ multi_json (1.13.1)
multi_xml (0.6.0)
multipart-post (2.0.0)
- mustermann (1.0.0)
+ mustermann (1.0.2)
mustermann-grape (1.0.0)
mustermann (~> 1.0.0)
- mysql2 (0.4.5)
+ mysql2 (0.4.10)
net-ldap (0.16.0)
- net-ssh (4.1.0)
+ net-ssh (4.2.0)
netrc (0.11.0)
- nokogiri (1.8.1)
+ nokogiri (1.8.2)
mini_portile2 (~> 2.3.0)
numerizer (0.1.1)
- oauth (0.5.1)
+ oauth (0.5.4)
oauth2 (1.4.0)
faraday (>= 0.8, < 0.13)
jwt (~> 1.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
- octokit (4.6.2)
+ octokit (4.8.0)
sawyer (~> 0.8.0, >= 0.5.3)
- oj (2.17.5)
- omniauth (1.4.2)
- hashie (>= 1.2, < 4)
- rack (>= 1.0, < 3)
- omniauth-auth0 (1.4.1)
- omniauth-oauth2 (~> 1.1)
+ omniauth (1.8.1)
+ hashie (>= 3.4.6, < 3.6.0)
+ rack (>= 1.6.2, < 3)
+ omniauth-auth0 (2.0.0)
+ omniauth-oauth2 (~> 1.4)
omniauth-authentiq (0.3.1)
omniauth-oauth2 (~> 1.3, >= 1.3.1)
omniauth-azure-oauth2 (0.0.9)
@@ -541,11 +553,13 @@ GEM
omniauth-gitlab (1.0.2)
omniauth (~> 1.0)
omniauth-oauth2 (~> 1.0)
- omniauth-google-oauth2 (0.5.2)
- jwt (~> 1.5)
- multi_json (~> 1.3)
+ omniauth-google-oauth2 (0.5.3)
+ jwt (>= 1.5)
omniauth (>= 1.1.1)
- omniauth-oauth2 (>= 1.3.1)
+ omniauth-oauth2 (>= 1.5)
+ omniauth-jwt (0.0.2)
+ jwt
+ omniauth (~> 1.1)
omniauth-kerberos (0.3.0)
omniauth-multipassword
timfel-krb5-auth (~> 0.8)
@@ -554,19 +568,19 @@ GEM
omniauth-oauth (1.1.0)
oauth
omniauth (~> 1.0)
- omniauth-oauth2 (1.4.0)
- oauth2 (~> 1.0)
+ omniauth-oauth2 (1.5.0)
+ oauth2 (~> 1.1)
omniauth (~> 1.2)
omniauth-oauth2-generic (0.2.2)
omniauth-oauth2 (~> 1.0)
- omniauth-saml (1.7.0)
- omniauth (~> 1.3)
- ruby-saml (~> 1.4)
+ omniauth-saml (1.10.0)
+ omniauth (~> 1.3, >= 1.3.2)
+ ruby-saml (~> 1.7)
omniauth-shibboleth (1.2.1)
omniauth (>= 1.0.0)
- omniauth-twitter (1.2.1)
- json (~> 1.3)
+ omniauth-twitter (1.4.0)
omniauth-oauth (~> 1.1)
+ rack
omniauth_crowd (2.2.3)
activesupport
nokogiri (>= 1.4.4)
@@ -575,27 +589,23 @@ GEM
rubypants (~> 0.2)
orm_adapter (0.5.0)
os (0.9.6)
- parallel (1.12.0)
- paranoia (2.3.1)
- activerecord (>= 4.0, < 5.2)
- parser (2.4.0.2)
- ast (~> 2.3)
+ parallel (1.12.1)
+ parser (2.5.0.5)
+ ast (~> 2.4.0)
parslet (1.5.0)
blankslate (~> 2.0)
- path_expander (1.0.1)
+ path_expander (1.0.2)
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-performance_bar (1.3.1)
peek (>= 0.1.0)
peek-pg (1.3.0)
concurrent-ruby
@@ -630,7 +640,7 @@ GEM
parser
unparser
procto (0.0.3)
- prometheus-client-mmap (0.7.0.beta43)
+ prometheus-client-mmap (0.9.1)
pry (0.10.4)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
@@ -640,21 +650,21 @@ GEM
pry (~> 0.10)
pry-rails (0.3.5)
pry (>= 0.9.10)
- public_suffix (3.0.0)
+ public_suffix (3.0.2)
pyu-ruby-sasl (0.0.3.3)
- rack (1.6.8)
+ rack (1.6.9)
rack-accept (0.4.5)
rack (>= 0.4)
rack-attack (4.4.1)
rack
- rack-cors (0.4.0)
+ rack-cors (1.0.2)
rack-oauth2 (1.2.3)
activesupport (>= 2.3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
multi_json (>= 1.3.6)
rack (>= 1.1)
- rack-protection (1.5.3)
+ rack-protection (2.0.1)
rack
rack-proxy (0.6.0)
rack
@@ -673,12 +683,12 @@ GEM
sprockets-rails
rails-deprecated_sanitizer (1.0.3)
activesupport (>= 4.2.0.alpha)
- rails-dom-testing (1.0.8)
- activesupport (>= 4.2.0.beta, < 5.0)
+ rails-dom-testing (1.0.9)
+ activesupport (>= 4.2.0, < 5.0)
nokogiri (~> 1.6)
rails-deprecated_sanitizer (>= 1.0.1)
- rails-html-sanitizer (1.0.3)
- loofah (~> 2.0)
+ rails-html-sanitizer (1.0.4)
+ loofah (~> 2.2, >= 2.2.2)
rails-i18n (4.0.9)
i18n (~> 0.7)
railties (~> 4.0)
@@ -691,6 +701,9 @@ GEM
rake
raindrops (0.18.0)
rake (12.3.0)
+ rb-fsevent (0.10.2)
+ rb-inotify (0.9.10)
+ ffi (>= 0.5.0, < 2)
rblineprof (0.3.6)
debugger-ruby_core_source (~> 1.3)
rbnacl (4.0.2)
@@ -702,9 +715,9 @@ GEM
re2 (1.1.1)
recaptcha (3.0.0)
json
- recursive-open-struct (1.0.0)
+ recursive-open-struct (1.0.5)
redcarpet (3.4.0)
- redis (3.3.3)
+ redis (3.3.5)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3)
@@ -714,7 +727,7 @@ GEM
redis-store (>= 1.3, < 2)
redis-namespace (1.5.2)
redis (~> 3.0, >= 3.0.4)
- redis-rack (2.0.3)
+ redis-rack (2.0.4)
rack (>= 1.5, < 3)
redis-store (>= 1.2, < 2)
redis-rails (5.0.2)
@@ -730,7 +743,7 @@ GEM
request_store (1.3.1)
responders (2.3.0)
railties (>= 4.2.0, < 5.1)
- rest-client (2.0.0)
+ rest-client (2.0.2)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
@@ -777,22 +790,24 @@ GEM
pg
rails
sqlite3
- rubocop (0.49.1)
+ rubocop (0.52.1)
parallel (~> 1.10)
- parser (>= 2.3.3.1, < 3.0)
+ parser (>= 2.4.0.2, < 3.0)
powerpack (~> 0.1)
- rainbow (>= 1.99.1, < 3.0)
+ rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
- rubocop-gitlab-security (0.1.0)
- rubocop (>= 0.47.1)
- rubocop-rspec (1.15.1)
- rubocop (>= 0.42.0)
+ rubocop-gitlab-security (0.1.1)
+ rubocop (>= 0.51)
+ rubocop-rspec (1.22.1)
+ rubocop (>= 0.52.1)
+ ruby-enum (0.7.2)
+ i18n
ruby-fogbugz (0.2.1)
crack (~> 0.4)
- ruby-prof (0.16.2)
- ruby-progressbar (1.8.1)
- ruby-saml (1.4.1)
+ ruby-prof (0.17.0)
+ ruby-progressbar (1.9.0)
+ ruby-saml (1.7.2)
nokogiri (>= 1.5.10)
ruby_parser (3.9.0)
sexp_processor (~> 4.1)
@@ -801,11 +816,15 @@ GEM
rubyzip (1.2.1)
rufus-scheduler (3.4.0)
et-orbi (~> 1.0)
- rugged (0.26.0)
+ rugged (0.27.0)
safe_yaml (1.0.4)
sanitize (2.1.0)
nokogiri (>= 1.4.4)
- sass (3.4.22)
+ sass (3.5.5)
+ sass-listen (~> 4.0.0)
+ sass-listen (4.0.0)
+ rb-fsevent (~> 0.9, >= 0.9.4)
+ rb-inotify (~> 0.9, >= 0.9.7)
sass-rails (5.0.6)
railties (>= 4.0.0, < 6)
sass (~> 3.1)
@@ -815,11 +834,11 @@ GEM
sawyer (0.8.1)
addressable (>= 2.3.5, < 2.6)
faraday (~> 0.8, < 1.0)
- scss_lint (0.54.0)
+ scss_lint (0.56.0)
rake (>= 0.9, < 13)
- sass (~> 3.4.20)
+ sass (~> 3.5.3)
securecompare (1.0.0)
- seed-fu (2.3.6)
+ seed-fu (2.3.7)
activerecord (>= 3.1)
activesupport (>= 3.1)
select2-rails (3.5.9.3)
@@ -827,7 +846,7 @@ GEM
selenium-webdriver (3.5.0)
childprocess (~> 0.5)
rubyzip (~> 1.0)
- sentry-raven (2.5.3)
+ sentry-raven (2.7.2)
faraday (>= 0.7.6, < 1.0)
settingslogic (2.0.9)
sexp_processor (4.9.0)
@@ -835,20 +854,20 @@ GEM
rack
shoulda-matchers (3.1.2)
activesupport (>= 4.0.0)
- sidekiq (5.0.4)
+ sidekiq (5.0.5)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0)
- redis (~> 3.3, >= 3.3.3)
+ redis (>= 3.3.4, < 5)
sidekiq-cron (0.6.0)
rufus-scheduler (>= 3.3.0)
sidekiq (>= 4.2.1)
sidekiq-limit_fetch (3.4.0)
sidekiq (>= 4)
- signet (0.7.3)
+ signet (0.8.1)
addressable (~> 2.3)
faraday (~> 0.9)
- jwt (~> 1.5)
+ jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simple_po_parser (1.1.2)
simplecov (0.14.1)
@@ -882,14 +901,15 @@ GEM
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sqlite3 (1.3.13)
+ sshkey (1.9.0)
stackprof (0.2.10)
- state_machines (0.4.0)
- state_machines-activemodel (0.4.0)
- activemodel (>= 4.1, < 5.1)
- state_machines (>= 0.4.0)
- state_machines-activerecord (0.4.0)
- activerecord (>= 4.1, < 5.1)
- state_machines-activemodel (>= 0.3.0)
+ state_machines (0.5.0)
+ state_machines-activemodel (0.5.1)
+ activemodel (>= 4.1, < 6.0)
+ state_machines (>= 0.5.0)
+ state_machines-activerecord (0.5.1)
+ activerecord (>= 4.1, < 6.0)
+ state_machines-activemodel (>= 0.5.0)
stringex (2.7.1)
sys-filesystem (1.1.6)
ffi
@@ -910,12 +930,12 @@ GEM
timfel-krb5-auth (0.8.3)
toml (0.1.2)
parslet (~> 1.5.0)
- toml-rb (0.3.15)
+ toml-rb (1.0.0)
citrus (~> 3.0, > 3.0)
truncato (0.7.10)
htmlentities (~> 4.3.1)
nokogiri (~> 1.8.0, >= 1.7.0)
- tzinfo (1.2.4)
+ tzinfo (1.2.5)
thread_safe (~> 0.1)
u2f (0.2.1)
uber (0.1.0)
@@ -924,7 +944,7 @@ GEM
json (>= 1.8.0)
unf (0.1.4)
unf_ext
- unf_ext (0.0.7.4)
+ unf_ext (0.0.7.5)
unicode-display_width (1.3.0)
unicorn (5.1.0)
kgio (~> 2.6)
@@ -933,13 +953,13 @@ GEM
get_process_mem (~> 0)
unicorn (>= 4, < 6)
uniform_notifier (1.10.0)
- unparser (0.2.6)
+ unparser (0.2.7)
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)
+ parser (>= 2.3.1.2, < 2.6)
procto (~> 0.0.2)
url_safe_base64 (0.2.2)
validates_hostname (1.0.6)
@@ -976,13 +996,13 @@ DEPENDENCIES
RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.1.0)
activerecord_sane_schema_dumper (= 0.2)
- acts-as-taggable-on (~> 4.0)
+ acts-as-taggable-on (~> 5.0)
addressable (~> 2.5.2)
akismet (~> 2.0)
allocations (~> 1.0)
asana (~> 0.6.0)
- asciidoctor (~> 1.5.2)
- asciidoctor-plantuml (= 0.0.7)
+ asciidoctor (~> 1.5.6)
+ asciidoctor-plantuml (= 0.0.8)
asset_sync (~> 2.2.0)
attr_encrypted (~> 3.0.0)
awesome_print (~> 1.2.0)
@@ -995,7 +1015,7 @@ DEPENDENCIES
binding_of_caller (~> 0.7.2)
bootstrap-sass (~> 3.3.0)
bootstrap_form (~> 2.7.0)
- brakeman (~> 3.6.0)
+ brakeman (~> 4.2)
browser (~> 2.2)
bullet (~> 5.5.0)
bundler-audit (~> 0.5.0)
@@ -1005,6 +1025,7 @@ DEPENDENCIES
charlock_holmes (~> 0.7.5)
chronic (~> 0.10.2)
chronic_duration (~> 0.10.6)
+ commonmarker (~> 0.17)
concurrent-ruby (~> 1.0.5)
connection_pool (~> 2.0)
creole (~> 0.5.0)
@@ -1015,100 +1036,102 @@ DEPENDENCIES
devise (~> 4.2)
devise-two-factor (~> 3.0.0)
diffy (~> 3.1.0)
- doorkeeper (~> 4.2.0)
- doorkeeper-openid_connect (~> 1.2.0)
+ doorkeeper (~> 4.3)
+ doorkeeper-openid_connect (~> 1.3)
dropzonejs-rails (~> 0.7.1)
email_reply_trimmer (~> 0.1)
email_spec (~> 1.6.0)
factory_bot_rails (~> 4.8.2)
faraday (~> 0.12)
+ fast_blank
ffaker (~> 2.4)
- flay (~> 2.8.0)
- flipper (~> 0.11.0)
- flipper-active_record (~> 0.11.0)
- flipper-active_support_cache_store (~> 0.11.0)
+ flay (~> 2.10.0)
+ flipper (~> 0.13.0)
+ flipper-active_record (~> 0.13.0)
+ flipper-active_support_cache_store (~> 0.13.0)
fog-aliyun (~> 0.2.0)
- fog-aws (~> 1.4)
+ fog-aws (~> 2.0.1)
fog-core (~> 1.44)
- fog-google (~> 0.5)
+ fog-google (~> 1.3.3)
fog-local (~> 0.3)
fog-openstack (~> 0.1)
fog-rackspace (~> 0.1.1)
font-awesome-rails (~> 4.7)
- foreman (~> 0.78.0)
+ foreman (~> 0.84.0)
fuubar (~> 2.2.0)
gemnasium-gitlab-service (~> 0.2)
gemojione (~> 3.3)
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
- gettext_i18n_rails_js (~> 1.2.0)
- gitaly-proto (~> 0.61.0)
- github-linguist (~> 4.7.0)
+ gettext_i18n_rails_js (~> 1.3)
+ gitaly-proto (~> 0.94.0)
+ github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.6.2)
+ gitlab-styles (~> 2.3)
gitlab_omniauth-ldap (~> 2.0.4)
+ goldiloader (~> 2.0)
gollum-lib (~> 4.2)
gollum-rugged_adapter (~> 0.4.4)
gon (~> 6.1.0)
- google-api-client (~> 0.13.6)
+ google-api-client (~> 0.19.8)
+ google-protobuf (= 3.5.1)
gpgme
grape (~> 1.0)
grape-entity (~> 0.6.0)
grape-route-helpers (~> 2.1.0)
grape_logging (~> 1.7)
+ grpc (~> 1.10.0)
haml_lint (~> 0.26.0)
hamlit (~> 2.6.1)
hashie-forbidden_attributes
health_check (~> 2.6.0)
hipchat (~> 1.5.0)
- html-pipeline (~> 1.11.0)
+ html-pipeline (~> 2.7.1)
html2text
httparty (~> 0.13.3)
influxdb (~> 0.2)
jira-ruby (~> 1.4)
jquery-atwho-rails (~> 1.3.2)
- jquery-rails (~> 4.3.1)
json-schema (~> 2.8.0)
jwt (~> 1.5.6)
kaminari (~> 1.0)
- knapsack (~> 1.11.0)
- kubeclient (~> 2.2.0)
+ knapsack (~> 1.16)
+ kubeclient (~> 3.0)
letter_opener_web (~> 1.3.0)
license_finder (~> 3.1)
- licensee (~> 8.7.0)
+ licensee (~> 8.9)
lograge (~> 0.5)
- loofah (~> 2.0.3)
+ loofah (~> 2.2)
mail_room (~> 0.9.1)
method_source (~> 0.8)
minitest (~> 5.7.0)
mousetrap-rails (~> 1.4.6)
- mysql2 (~> 0.4.5)
+ mysql2 (~> 0.4.10)
net-ldap
- net-ssh (~> 4.1.0)
- nokogiri (~> 1.8.1)
+ net-ssh (~> 4.2.0)
+ nokogiri (~> 1.8.2)
oauth2 (~> 1.4)
- octokit (~> 4.6.2)
- oj (~> 2.17.4)
- omniauth (~> 1.4.2)
- omniauth-auth0 (~> 1.4.1)
+ octokit (~> 4.8)
+ omniauth (~> 1.8)
+ omniauth-auth0 (~> 2.0.0)
omniauth-authentiq (~> 0.3.1)
omniauth-azure-oauth2 (~> 0.0.9)
omniauth-cas3 (~> 1.1.4)
omniauth-facebook (~> 4.0.0)
omniauth-github (~> 1.1.1)
omniauth-gitlab (~> 1.0.2)
- omniauth-google-oauth2 (~> 0.5.2)
+ omniauth-google-oauth2 (~> 0.5.3)
+ omniauth-jwt (~> 0.0.2)
omniauth-kerberos (~> 0.3.0)
omniauth-oauth2-generic (~> 0.2.2)
- omniauth-saml (~> 1.7.0)
+ omniauth-saml (~> 1.10)
omniauth-shibboleth (~> 1.2.0)
- omniauth-twitter (~> 1.2.0)
+ omniauth-twitter (~> 1.4)
omniauth_crowd (~> 2.2.0)
org-ruby (~> 0.9.12)
- 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)
@@ -1117,11 +1140,11 @@ DEPENDENCIES
peek-sidekiq (~> 1.0.3)
pg (~> 0.18.2)
premailer-rails (~> 1.9.7)
- prometheus-client-mmap (~> 0.7.0.beta43)
+ prometheus-client-mmap (~> 0.9.1)
pry-byebug (~> 3.4.1)
pry-rails (~> 0.3.4)
rack-attack (~> 4.4.1)
- rack-cors (~> 0.4.0)
+ rack-cors (~> 1.0.0)
rack-oauth2 (~> 1.2.1)
rack-proxy (~> 0.6.0)
rails (= 4.2.10)
@@ -1148,21 +1171,20 @@ DEPENDENCIES
rspec-retry (~> 0.4.5)
rspec-set (~> 0.1.3)
rspec_profiling (~> 0.0.5)
- rubocop (~> 0.49.1)
- rubocop-gitlab-security (~> 0.1.0)
- rubocop-rspec (~> 1.15.1)
+ rubocop (~> 0.52.1)
+ rubocop-rspec (~> 1.22.1)
ruby-fogbugz (~> 0.2.1)
- ruby-prof (~> 0.16.2)
+ ruby-prof (~> 0.17.0)
ruby_parser (~> 3.8)
rufus-scheduler (~> 3.4)
- rugged (~> 0.26.0)
+ rugged (~> 0.27)
sanitize (~> 2.0)
sass-rails (~> 5.0.6)
- scss_lint (~> 0.54.0)
- seed-fu (= 2.3.6)
+ scss_lint (~> 0.56.0)
+ seed-fu (~> 2.3.7)
select2-rails (~> 3.5.9)
selenium-webdriver (~> 3.5)
- sentry-raven (~> 2.5.3)
+ sentry-raven (~> 2.7)
settingslogic (~> 2.0.9)
sham_rack (~> 1.3.6)
shoulda-matchers (~> 3.1.2)
@@ -1178,14 +1200,15 @@ DEPENDENCIES
spring-commands-rspec (~> 1.0.4)
spring-commands-spinach (~> 1.1.0)
sprockets (~> 3.7.0)
+ sshkey (~> 1.9.0)
stackprof (~> 0.2.10)
- state_machines-activerecord (~> 0.4.0)
+ state_machines-activerecord (~> 0.5.1)
sys-filesystem (~> 1.1.6)
test-prof (~> 0.2.5)
test_after_commit (~> 1.1)
thin (~> 1.7.0)
timecop (~> 0.8.0)
- toml-rb (~> 0.3.15)
+ toml-rb (~> 1.0.0)
truncato (~> 0.7.9)
u2f (~> 0.2.1)
uglifier (~> 2.7.2)
@@ -1201,4 +1224,4 @@ DEPENDENCIES
wikicloth (= 0.8.1)
BUNDLED WITH
- 1.16.0
+ 1.16.1
diff --git a/Gemfile.rails5 b/Gemfile.rails5
new file mode 100644
index 00000000000..2b526b19ba0
--- /dev/null
+++ b/Gemfile.rails5
@@ -0,0 +1,7 @@
+# BUNDLE_GEMFILE=Gemfile.rails5 bundle install
+
+ENV["RAILS5"] = "true"
+
+gemfile = File.expand_path("../Gemfile", __FILE__)
+
+eval(File.read(gemfile), nil, gemfile)
diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock
new file mode 100644
index 00000000000..03fe5f2ed26
--- /dev/null
+++ b/Gemfile.rails5.lock
@@ -0,0 +1,1228 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ RedCloth (4.3.2)
+ abstract_type (0.0.7)
+ ace-rails-ap (4.1.4)
+ actioncable (5.0.6)
+ actionpack (= 5.0.6)
+ nio4r (>= 1.2, < 3.0)
+ websocket-driver (~> 0.6.1)
+ actionmailer (5.0.6)
+ actionpack (= 5.0.6)
+ actionview (= 5.0.6)
+ activejob (= 5.0.6)
+ mail (~> 2.5, >= 2.5.4)
+ rails-dom-testing (~> 2.0)
+ actionpack (5.0.6)
+ actionview (= 5.0.6)
+ activesupport (= 5.0.6)
+ rack (~> 2.0)
+ rack-test (~> 0.6.3)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
+ actionview (5.0.6)
+ activesupport (= 5.0.6)
+ builder (~> 3.1)
+ erubis (~> 2.7.0)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
+ activejob (5.0.6)
+ activesupport (= 5.0.6)
+ globalid (>= 0.3.6)
+ activemodel (5.0.6)
+ activesupport (= 5.0.6)
+ activerecord (5.0.6)
+ activemodel (= 5.0.6)
+ activesupport (= 5.0.6)
+ arel (~> 7.0)
+ activerecord_sane_schema_dumper (1.0)
+ rails (>= 5, < 6)
+ activesupport (5.0.6)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (~> 0.7)
+ minitest (~> 5.1)
+ tzinfo (~> 1.1)
+ acts-as-taggable-on (5.0.0)
+ activerecord (>= 4.2.8)
+ adamantium (0.2.0)
+ ice_nine (~> 0.11.0)
+ memoizable (~> 0.4.0)
+ addressable (2.5.2)
+ public_suffix (>= 2.0.2, < 4.0)
+ aes_key_wrap (1.0.1)
+ akismet (2.0.0)
+ allocations (1.0.5)
+ arel (7.1.4)
+ asana (0.6.3)
+ faraday (~> 0.9)
+ faraday_middleware (~> 0.9)
+ faraday_middleware-multi_json (~> 0.0)
+ oauth2 (~> 1.0)
+ asciidoctor (1.5.6.1)
+ asciidoctor-plantuml (0.0.8)
+ asciidoctor (~> 1.5)
+ asset_sync (2.2.0)
+ activemodel (>= 4.1.0)
+ fog-core
+ mime-types (>= 2.99)
+ unf
+ ast (2.4.0)
+ atomic (1.1.100)
+ attr_encrypted (3.0.3)
+ encryptor (~> 3.0.0)
+ attr_required (1.0.1)
+ autoprefixer-rails (8.1.0.1)
+ execjs
+ awesome_print (1.2.0)
+ axiom-types (0.1.1)
+ descendants_tracker (~> 0.0.4)
+ ice_nine (~> 0.11.0)
+ thread_safe (~> 0.3, >= 0.3.1)
+ babosa (1.0.2)
+ base32 (0.3.2)
+ batch-loader (1.2.1)
+ bcrypt (3.1.11)
+ bcrypt_pbkdf (1.0.0)
+ benchmark-ips (2.3.0)
+ better_errors (2.1.1)
+ coderay (>= 1.0.0)
+ erubis (>= 2.6.6)
+ rack (>= 0.9.0)
+ bindata (2.4.3)
+ binding_of_caller (0.7.3)
+ debug_inspector (>= 0.0.1)
+ blankslate (2.1.2.4)
+ bootstrap-sass (3.3.7)
+ autoprefixer-rails (>= 5.2.1)
+ sass (>= 3.3.4)
+ bootstrap_form (2.7.0)
+ brakeman (4.2.1)
+ browser (2.5.3)
+ builder (3.2.3)
+ bullet (5.5.1)
+ activesupport (>= 3.0.0)
+ uniform_notifier (~> 1.10.0)
+ bundler-audit (0.5.0)
+ bundler (~> 1.2)
+ thor (~> 0.18)
+ byebug (9.0.6)
+ capybara (2.18.0)
+ addressable
+ mini_mime (>= 0.1.3)
+ nokogiri (>= 1.3.3)
+ rack (>= 1.0.0)
+ rack-test (>= 0.5.4)
+ xpath (>= 2.0, < 4.0)
+ capybara-screenshot (1.0.18)
+ capybara (>= 1.0, < 3)
+ launchy
+ carrierwave (1.2.2)
+ activemodel (>= 4.0.0)
+ activesupport (>= 4.0.0)
+ mime-types (>= 1.16)
+ charlock_holmes (0.7.5)
+ childprocess (0.9.0)
+ ffi (~> 1.0, >= 1.0.11)
+ chronic (0.10.2)
+ chronic_duration (0.10.6)
+ numerizer (~> 0.1.1)
+ chunky_png (1.3.10)
+ citrus (3.0.2)
+ coderay (1.1.2)
+ coercible (1.0.0)
+ descendants_tracker (~> 0.0.1)
+ colorize (0.8.1)
+ commonmarker (0.17.9)
+ ruby-enum (~> 0.5)
+ 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)
+ crass (1.0.3)
+ creole (0.5.0)
+ css_parser (1.6.0)
+ addressable
+ d3_rails (3.5.17)
+ railties (>= 3.1.0)
+ daemons (1.2.6)
+ database_cleaner (1.5.3)
+ debug_inspector (0.0.3)
+ debugger-ruby_core_source (1.3.8)
+ deckar01-task_list (2.0.0)
+ html-pipeline
+ declarative (0.0.10)
+ declarative-option (0.1.0)
+ default_value_for (3.0.5)
+ activerecord (>= 3.2.0, < 5.2)
+ descendants_tracker (0.0.4)
+ thread_safe (~> 0.3, >= 0.3.1)
+ devise (4.4.1)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 4.1.0, < 5.2)
+ responders
+ warden (~> 1.2.3)
+ devise-two-factor (3.0.2)
+ activesupport (< 5.2)
+ attr_encrypted (>= 1.3, < 4, != 2)
+ devise (~> 4.0)
+ railties (< 5.2)
+ rotp (~> 2.0)
+ diff-lcs (1.3)
+ diffy (3.1.0)
+ docile (1.1.5)
+ domain_name (0.5.20170404)
+ unf (>= 0.0.5, < 1.0.0)
+ doorkeeper (4.3.1)
+ railties (>= 4.2)
+ doorkeeper-openid_connect (1.3.0)
+ doorkeeper (~> 4.3)
+ json-jwt (~> 1.6)
+ dropzonejs-rails (0.7.4)
+ rails (> 3.1)
+ email_reply_trimmer (0.1.10)
+ email_spec (1.6.0)
+ launchy (~> 2.1)
+ mail (~> 2.2)
+ encryptor (3.0.0)
+ equalizer (0.0.11)
+ erubis (2.7.0)
+ escape_utils (1.1.1)
+ et-orbi (1.0.9)
+ tzinfo
+ eventmachine (1.2.5)
+ excon (0.60.0)
+ execjs (2.7.0)
+ expression_parser (0.9.0)
+ factory_bot (4.8.2)
+ activesupport (>= 3.0.0)
+ factory_bot_rails (4.8.2)
+ factory_bot (~> 4.8.2)
+ railties (>= 3.0.0)
+ faraday (0.12.2)
+ multipart-post (>= 1.2, < 3)
+ faraday_middleware (0.12.2)
+ faraday (>= 0.7.4, < 1.0)
+ faraday_middleware-multi_json (0.0.6)
+ faraday_middleware
+ multi_json
+ fast_blank (1.0.0)
+ fast_gettext (1.6.0)
+ ffaker (2.8.1)
+ ffi (1.9.23)
+ flay (2.10.0)
+ erubis (~> 2.7.0)
+ path_expander (~> 1.0)
+ ruby_parser (~> 3.0)
+ sexp_processor (~> 4.0)
+ flipper (0.13.0)
+ flipper-active_record (0.13.0)
+ activerecord (>= 3.2, < 6)
+ flipper (~> 0.13.0)
+ flipper-active_support_cache_store (0.13.0)
+ activesupport (>= 3.2, < 6)
+ flipper (~> 0.13.0)
+ flowdock (0.7.1)
+ httparty (~> 0.7)
+ multi_json
+ fog-aliyun (0.2.0)
+ fog-core (~> 1.27)
+ fog-json (~> 1.0)
+ ipaddress (~> 0.8)
+ xml-simple (~> 1.1)
+ fog-aws (2.0.1)
+ fog-core (~> 1.38)
+ fog-json (~> 1.0)
+ fog-xml (~> 0.1)
+ ipaddress (~> 0.8)
+ fog-core (1.45.0)
+ builder
+ excon (~> 0.58)
+ formatador (~> 0.2)
+ fog-google (1.3.3)
+ fog-core
+ fog-json
+ fog-xml
+ google-api-client (~> 0.19.1)
+ fog-json (1.0.2)
+ fog-core (~> 1.0)
+ multi_json (~> 1.10)
+ fog-local (0.5.0)
+ fog-core (>= 1.27, < 3.0)
+ fog-openstack (0.1.24)
+ fog-core (~> 1.40)
+ fog-json (>= 1.0)
+ ipaddress (>= 0.8)
+ fog-rackspace (0.1.5)
+ fog-core (>= 1.35)
+ fog-json (>= 1.0)
+ fog-xml (>= 0.1)
+ ipaddress (>= 0.8)
+ fog-xml (0.1.3)
+ fog-core
+ nokogiri (>= 1.5.11, < 2.0.0)
+ font-awesome-rails (4.7.0.3)
+ railties (>= 3.2, < 5.2)
+ foreman (0.84.0)
+ thor (~> 0.19.1)
+ formatador (0.2.5)
+ fuubar (2.2.0)
+ rspec-core (~> 3.0)
+ ruby-progressbar (~> 1.4)
+ gemnasium-gitlab-service (0.2.6)
+ rugged (~> 0.21)
+ gemojione (3.3.0)
+ json
+ get_process_mem (0.2.1)
+ gettext (3.2.9)
+ locale (>= 2.0.5)
+ text (>= 1.3.0)
+ gettext_i18n_rails (1.8.0)
+ fast_gettext (>= 0.9.0)
+ gettext_i18n_rails_js (1.3.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-proto (0.94.0)
+ google-protobuf (~> 3.1)
+ grpc (~> 1.0)
+ github-linguist (5.3.3)
+ charlock_holmes (~> 0.7.5)
+ escape_utils (~> 1.1.0)
+ mime-types (>= 1.19)
+ rugged (>= 0.25.1)
+ github-markup (1.7.0)
+ gitlab-flowdock-git-hook (1.0.1)
+ flowdock (~> 0.7)
+ gitlab-grit (>= 2.4.1)
+ multi_json
+ gitlab-grit (2.8.2)
+ charlock_holmes (~> 0.6)
+ diff-lcs (~> 1.1)
+ mime-types (>= 1.16)
+ posix-spawn (~> 0.3)
+ gitlab-markup (1.6.3)
+ gitlab-styles (2.3.2)
+ rubocop (~> 0.51)
+ rubocop-gitlab-security (~> 0.1.0)
+ rubocop-rspec (~> 1.19)
+ 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.4.1)
+ activesupport (>= 4.2.0)
+ gollum-grit_adapter (1.0.1)
+ gitlab-grit (~> 2.7, >= 2.7.1)
+ gollum-lib (4.2.7)
+ gemojione (~> 3.2)
+ github-markup (~> 1.6)
+ gollum-grit_adapter (~> 1.0)
+ nokogiri (>= 1.6.1, < 2.0)
+ rouge (~> 2.1)
+ sanitize (~> 2.1)
+ stringex (~> 2.6)
+ gollum-rugged_adapter (0.4.4)
+ mime-types (>= 1.15)
+ rugged (~> 0.25)
+ gon (6.1.0)
+ actionpack (>= 3.0)
+ json
+ multi_json
+ request_store (>= 1.0)
+ google-api-client (0.19.8)
+ addressable (~> 2.5, >= 2.5.1)
+ googleauth (>= 0.5, < 0.7.0)
+ httpclient (>= 2.8.1, < 3.0)
+ mime-types (~> 3.0)
+ representable (~> 3.0)
+ retriable (>= 2.0, < 4.0)
+ google-protobuf (3.5.1)
+ googleapis-common-protos-types (1.0.1)
+ google-protobuf (~> 3.0)
+ googleauth (0.6.2)
+ faraday (~> 0.12)
+ jwt (>= 1.4, < 3.0)
+ logging (~> 2.0)
+ memoist (~> 0.12)
+ multi_json (~> 1.11)
+ os (~> 0.9)
+ signet (~> 0.7)
+ gpgme (2.0.16)
+ mini_portile2 (~> 2.3)
+ grape (1.0.2)
+ activesupport
+ builder
+ mustermann-grape (~> 1.0.0)
+ rack (>= 1.3.0)
+ rack-accept
+ virtus (>= 1.0.0)
+ grape-entity (0.6.1)
+ activesupport (>= 5.0.0)
+ multi_json (>= 1.3.2)
+ grape-route-helpers (2.1.0)
+ activesupport
+ grape (>= 0.16.0)
+ rake
+ grape_logging (1.7.0)
+ grape
+ grpc (1.10.0)
+ google-protobuf (~> 3.1)
+ googleapis-common-protos-types (~> 1.0.0)
+ googleauth (>= 0.5.1, < 0.7)
+ haml (4.0.7)
+ tilt
+ haml_lint (0.26.0)
+ haml (>= 4.0, < 5.1)
+ rainbow
+ rake (>= 10, < 13)
+ rubocop (>= 0.49.0)
+ sysexits (~> 1.1)
+ hamlit (2.6.2)
+ temple (~> 0.7.6)
+ thor
+ tilt
+ hashdiff (0.3.7)
+ hashie (3.5.7)
+ hashie-forbidden_attributes (0.1.1)
+ hashie (>= 3.0)
+ health_check (2.6.0)
+ rails (>= 4.0)
+ hipchat (1.5.4)
+ httparty
+ mimemagic
+ html-pipeline (2.7.1)
+ activesupport (>= 2)
+ nokogiri (>= 1.4)
+ html2text (0.2.1)
+ nokogiri (~> 1.6)
+ htmlentities (4.3.4)
+ http (2.2.2)
+ addressable (~> 2.3)
+ http-cookie (~> 1.0)
+ http-form_data (~> 1.0.1)
+ http_parser.rb (~> 0.6.0)
+ http-cookie (1.0.3)
+ domain_name (~> 0.5)
+ http-form_data (1.0.3)
+ http_parser.rb (0.6.0)
+ httparty (0.13.7)
+ json (~> 1.8)
+ multi_xml (>= 0.5.2)
+ httpclient (2.8.3)
+ i18n (0.9.5)
+ concurrent-ruby (~> 1.0)
+ ice_nine (0.11.2)
+ influxdb (0.5.3)
+ ipaddress (0.8.3)
+ jira-ruby (1.5.0)
+ activesupport
+ multipart-post
+ oauth (~> 0.5, >= 0.5.0)
+ jquery-atwho-rails (1.3.2)
+ json (1.8.6)
+ json-jwt (1.9.2)
+ activesupport
+ aes_key_wrap
+ bindata
+ securecompare
+ url_safe_base64
+ json-schema (2.8.0)
+ addressable (>= 2.4)
+ jwt (1.5.6)
+ kaminari (1.1.1)
+ activesupport (>= 4.1.0)
+ kaminari-actionview (= 1.1.1)
+ kaminari-activerecord (= 1.1.1)
+ kaminari-core (= 1.1.1)
+ kaminari-actionview (1.1.1)
+ actionview
+ kaminari-core (= 1.1.1)
+ kaminari-activerecord (1.1.1)
+ activerecord
+ kaminari-core (= 1.1.1)
+ kaminari-core (1.1.1)
+ kgio (2.11.2)
+ knapsack (1.16.0)
+ rake
+ kubeclient (3.0.0)
+ http (~> 2.2.2)
+ recursive-open-struct (~> 1.0.4)
+ rest-client (~> 2.0)
+ launchy (2.4.3)
+ addressable (~> 2.3)
+ letter_opener (1.6.0)
+ launchy (~> 2.2)
+ letter_opener_web (1.3.3)
+ actionmailer (>= 3.2)
+ letter_opener (~> 1.0)
+ railties (>= 3.2)
+ license_finder (3.1.1)
+ bundler
+ httparty
+ rubyzip
+ thor
+ toml (= 0.1.2)
+ with_env (> 1.0)
+ xml-simple
+ licensee (8.9.2)
+ rugged (~> 0.24)
+ little-plugger (1.1.4)
+ locale (2.1.2)
+ logging (2.2.2)
+ little-plugger (~> 1.1)
+ multi_json (~> 1.10)
+ lograge (0.9.0)
+ actionpack (>= 4)
+ activesupport (>= 4)
+ railties (>= 4)
+ request_store (~> 1.0)
+ loofah (2.2.2)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.5.9)
+ mail (2.7.0)
+ mini_mime (>= 0.1.1)
+ mail_room (0.9.1)
+ memoist (0.16.0)
+ memoizable (0.4.2)
+ thread_safe (~> 0.3, >= 0.3.1)
+ method_source (0.9.0)
+ mime-types (3.1)
+ mime-types-data (~> 3.2015)
+ mime-types-data (3.2016.0521)
+ mimemagic (0.3.2)
+ mini_mime (1.0.0)
+ mini_portile2 (2.3.0)
+ minitest (5.7.0)
+ mousetrap-rails (1.4.6)
+ multi_json (1.13.1)
+ multi_xml (0.6.0)
+ multipart-post (2.0.0)
+ mustermann (1.0.2)
+ mustermann-grape (1.0.0)
+ mustermann (~> 1.0.0)
+ mysql2 (0.4.10)
+ net-ldap (0.16.1)
+ net-ssh (4.2.0)
+ netrc (0.11.0)
+ nio4r (2.2.0)
+ nokogiri (1.8.2)
+ mini_portile2 (~> 2.3.0)
+ numerizer (0.1.1)
+ oauth (0.5.4)
+ oauth2 (1.4.0)
+ faraday (>= 0.8, < 0.13)
+ jwt (~> 1.0)
+ multi_json (~> 1.3)
+ multi_xml (~> 0.5)
+ rack (>= 1.2, < 3)
+ octokit (4.8.0)
+ sawyer (~> 0.8.0, >= 0.5.3)
+ omniauth (1.8.1)
+ hashie (>= 3.4.6, < 3.6.0)
+ rack (>= 1.6.2, < 3)
+ omniauth-auth0 (2.0.0)
+ omniauth-oauth2 (~> 1.4)
+ omniauth-authentiq (0.3.1)
+ omniauth-oauth2 (~> 1.3, >= 1.3.1)
+ omniauth-azure-oauth2 (0.0.9)
+ jwt (~> 1.0)
+ omniauth (~> 1.0)
+ omniauth-oauth2 (~> 1.4)
+ omniauth-cas3 (1.1.4)
+ addressable (~> 2.3)
+ nokogiri (~> 1.7, >= 1.7.1)
+ omniauth (~> 1.2)
+ omniauth-facebook (4.0.0)
+ omniauth-oauth2 (~> 1.2)
+ omniauth-github (1.1.2)
+ omniauth (~> 1.0)
+ omniauth-oauth2 (~> 1.1)
+ omniauth-gitlab (1.0.3)
+ omniauth (~> 1.0)
+ omniauth-oauth2 (~> 1.0)
+ omniauth-google-oauth2 (0.5.3)
+ jwt (>= 1.5)
+ omniauth (>= 1.1.1)
+ omniauth-oauth2 (>= 1.5)
+ omniauth-jwt (0.0.2)
+ jwt
+ omniauth (~> 1.1)
+ omniauth-kerberos (0.3.0)
+ omniauth-multipassword
+ timfel-krb5-auth (~> 0.8)
+ omniauth-multipassword (0.4.2)
+ omniauth (~> 1.0)
+ omniauth-oauth (1.1.0)
+ oauth
+ omniauth (~> 1.0)
+ omniauth-oauth2 (1.5.0)
+ oauth2 (~> 1.1)
+ omniauth (~> 1.2)
+ omniauth-oauth2-generic (0.2.4)
+ omniauth-oauth2 (~> 1.0)
+ omniauth-saml (1.10.0)
+ omniauth (~> 1.3, >= 1.3.2)
+ ruby-saml (~> 1.7)
+ omniauth-shibboleth (1.2.1)
+ omniauth (>= 1.0.0)
+ omniauth-twitter (1.4.0)
+ omniauth-oauth (~> 1.1)
+ rack
+ omniauth_crowd (2.2.3)
+ activesupport
+ nokogiri (>= 1.4.4)
+ omniauth (~> 1.0)
+ org-ruby (0.9.12)
+ rubypants (~> 0.2)
+ orm_adapter (0.5.0)
+ os (0.9.6)
+ parallel (1.12.1)
+ parser (2.5.0.5)
+ ast (~> 2.4.0)
+ parslet (1.5.0)
+ blankslate (~> 2.0)
+ path_expander (1.0.2)
+ 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-mysql2 (1.1.0)
+ atomic (>= 1.0.0)
+ mysql2
+ peek
+ peek-performance_bar (1.3.1)
+ 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)
+ posix-spawn (0.3.13)
+ powerpack (0.1.1)
+ premailer (1.11.1)
+ addressable
+ css_parser (>= 1.6.0)
+ htmlentities (>= 4.0.0)
+ 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.9.1)
+ pry (0.11.3)
+ coderay (~> 1.1.0)
+ method_source (~> 0.9.0)
+ pry-byebug (3.4.3)
+ byebug (>= 9.0, < 9.1)
+ pry (~> 0.10)
+ pry-rails (0.3.6)
+ pry (>= 0.10.4)
+ public_suffix (3.0.2)
+ pyu-ruby-sasl (0.0.3.3)
+ rack (2.0.4)
+ rack-accept (0.4.5)
+ rack (>= 0.4)
+ rack-attack (4.4.1)
+ rack
+ rack-cors (1.0.2)
+ rack-oauth2 (1.2.3)
+ activesupport (>= 2.3)
+ attr_required (>= 0.0.5)
+ httpclient (>= 2.4)
+ multi_json (>= 1.3.6)
+ rack (>= 1.1)
+ rack-protection (2.0.1)
+ rack
+ rack-proxy (0.6.4)
+ rack
+ rack-test (0.6.3)
+ rack (>= 1.0)
+ rails (5.0.6)
+ actioncable (= 5.0.6)
+ actionmailer (= 5.0.6)
+ actionpack (= 5.0.6)
+ actionview (= 5.0.6)
+ activejob (= 5.0.6)
+ activemodel (= 5.0.6)
+ activerecord (= 5.0.6)
+ activesupport (= 5.0.6)
+ bundler (>= 1.3.0)
+ railties (= 5.0.6)
+ sprockets-rails (>= 2.0.0)
+ rails-controller-testing (1.0.2)
+ actionpack (~> 5.x, >= 5.0.1)
+ actionview (~> 5.x, >= 5.0.1)
+ activesupport (~> 5.x)
+ rails-deprecated_sanitizer (1.0.3)
+ activesupport (>= 4.2.0.alpha)
+ rails-dom-testing (2.0.3)
+ activesupport (>= 4.2.0)
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.0.3)
+ loofah (~> 2.0)
+ rails-i18n (5.1.1)
+ i18n (>= 0.7, < 2)
+ railties (>= 5.0, < 6)
+ railties (5.0.6)
+ actionpack (= 5.0.6)
+ activesupport (= 5.0.6)
+ method_source
+ rake (>= 0.8.7)
+ thor (>= 0.18.1, < 2.0)
+ rainbow (2.2.2)
+ rake
+ raindrops (0.19.0)
+ rake (12.3.0)
+ rb-fsevent (0.10.3)
+ rb-inotify (0.9.10)
+ ffi (>= 0.5.0, < 2)
+ rblineprof (0.3.7)
+ debugger-ruby_core_source (~> 1.3)
+ rbnacl (4.0.2)
+ ffi
+ rbnacl-libsodium (1.0.16)
+ rbnacl (>= 3.0.1)
+ rdoc (4.3.0)
+ re2 (1.1.1)
+ recaptcha (3.4.0)
+ json
+ recursive-open-struct (1.0.5)
+ redcarpet (3.4.0)
+ redis (3.3.5)
+ redis-actionpack (5.0.2)
+ actionpack (>= 4.0, < 6)
+ redis-rack (>= 1, < 3)
+ redis-store (>= 1.1.0, < 2)
+ redis-activesupport (5.0.4)
+ activesupport (>= 3, < 6)
+ redis-store (>= 1.3, < 2)
+ redis-namespace (1.5.3)
+ redis (~> 3.0, >= 3.0.4)
+ redis-rack (2.0.4)
+ rack (>= 1.5, < 3)
+ redis-store (>= 1.2, < 2)
+ redis-rails (5.0.2)
+ redis-actionpack (>= 5.0, < 6)
+ redis-activesupport (>= 5.0, < 6)
+ redis-store (>= 1.2, < 2)
+ redis-store (1.4.1)
+ redis (>= 2.2, < 5)
+ representable (3.0.4)
+ declarative (< 0.1.0)
+ declarative-option (< 0.2.0)
+ uber (< 0.2.0)
+ request_store (1.4.0)
+ rack (>= 1.4)
+ responders (2.4.0)
+ actionpack (>= 4.2.0, < 5.3)
+ railties (>= 4.2.0, < 5.3)
+ rest-client (2.0.2)
+ http-cookie (>= 1.0.2, < 2.0)
+ mime-types (>= 1.16, < 4.0)
+ netrc (~> 0.8)
+ retriable (3.1.1)
+ rinku (2.0.4)
+ rotp (2.1.2)
+ rouge (2.2.1)
+ rqrcode (0.10.1)
+ chunky_png (~> 1.0)
+ rqrcode-rails3 (0.1.7)
+ rqrcode (>= 0.4.2)
+ 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.6.0)
+ rspec-mocks (3.6.0)
+ diff-lcs (>= 1.2.0, < 2.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.1)
+ actionpack (>= 3.0)
+ activesupport (>= 3.0)
+ railties (>= 3.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.6)
+ rspec-core
+ rspec-set (0.1.3)
+ rspec-support (3.6.0)
+ rspec_profiling (0.0.5)
+ activerecord
+ pg
+ rails
+ sqlite3
+ rubocop (0.52.1)
+ parallel (~> 1.10)
+ parser (>= 2.4.0.2, < 3.0)
+ powerpack (~> 0.1)
+ rainbow (>= 2.2.2, < 4.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (~> 1.0, >= 1.0.1)
+ rubocop-gitlab-security (0.1.1)
+ rubocop (>= 0.51)
+ rubocop-rspec (1.22.2)
+ rubocop (>= 0.52.1)
+ ruby-enum (0.7.2)
+ i18n
+ ruby-fogbugz (0.2.1)
+ crack (~> 0.4)
+ ruby-prof (0.17.0)
+ ruby-progressbar (1.9.0)
+ ruby-saml (1.7.2)
+ nokogiri (>= 1.5.10)
+ ruby_parser (3.11.0)
+ sexp_processor (~> 4.9)
+ rubyntlm (0.6.2)
+ rubypants (0.7.0)
+ rubyzip (1.2.1)
+ rufus-scheduler (3.4.2)
+ et-orbi (~> 1.0)
+ rugged (0.27.0)
+ safe_yaml (1.0.4)
+ sanitize (2.1.0)
+ nokogiri (>= 1.4.4)
+ sass (3.5.5)
+ sass-listen (~> 4.0.0)
+ sass-listen (4.0.0)
+ rb-fsevent (~> 0.9, >= 0.9.4)
+ rb-inotify (~> 0.9, >= 0.9.7)
+ sass-rails (5.0.7)
+ railties (>= 4.0.0, < 6)
+ sass (~> 3.1)
+ sprockets (>= 2.8, < 4.0)
+ sprockets-rails (>= 2.0, < 4.0)
+ tilt (>= 1.1, < 3)
+ sawyer (0.8.1)
+ addressable (>= 2.3.5, < 2.6)
+ faraday (~> 0.8, < 1.0)
+ scss_lint (0.56.0)
+ rake (>= 0.9, < 13)
+ sass (~> 3.5.3)
+ securecompare (1.0.0)
+ seed-fu (2.3.7)
+ activerecord (>= 3.1)
+ activesupport (>= 3.1)
+ select2-rails (3.5.10)
+ thor (~> 0.14)
+ selenium-webdriver (3.11.0)
+ childprocess (~> 0.5)
+ rubyzip (~> 1.2)
+ sentry-raven (2.7.2)
+ faraday (>= 0.7.6, < 1.0)
+ settingslogic (2.0.9)
+ sexp_processor (4.10.1)
+ sham_rack (1.3.6)
+ rack
+ shoulda-matchers (3.1.2)
+ activesupport (>= 4.0.0)
+ sidekiq (5.1.1)
+ concurrent-ruby (~> 1.0)
+ connection_pool (~> 2.2, >= 2.2.0)
+ rack-protection (>= 1.5.0)
+ redis (>= 3.3.5, < 5)
+ sidekiq-cron (0.6.3)
+ rufus-scheduler (>= 3.3.0)
+ sidekiq (>= 4.2.1)
+ sidekiq-limit_fetch (3.4.0)
+ sidekiq (>= 4)
+ signet (0.8.1)
+ addressable (~> 2.3)
+ faraday (~> 0.9)
+ jwt (>= 1.5, < 3.0)
+ multi_json (~> 1.10)
+ simple_po_parser (1.1.3)
+ simplecov (0.14.1)
+ docile (~> 1.1.0)
+ json (>= 1.8, < 3)
+ simplecov-html (~> 0.10.0)
+ simplecov-html (0.10.2)
+ slack-notifier (1.5.1)
+ spinach (0.10.1)
+ colorize
+ gherkin-ruby (>= 0.3.2)
+ json
+ spinach-rails (0.2.1)
+ capybara (>= 2.0.0)
+ railties (>= 3)
+ spinach (>= 0.4)
+ spinach-rerun-reporter (0.0.2)
+ spinach (~> 0.8)
+ spring (2.0.2)
+ activesupport (>= 4.2)
+ spring-commands-rspec (1.0.4)
+ spring (>= 0.9.1)
+ spring-commands-spinach (1.1.0)
+ spring (>= 0.9.1)
+ sprockets (3.7.1)
+ concurrent-ruby (~> 1.0)
+ rack (> 1, < 3)
+ sprockets-rails (3.2.1)
+ actionpack (>= 4.0)
+ activesupport (>= 4.0)
+ sprockets (>= 3.0.0)
+ sqlite3 (1.3.13)
+ sshkey (1.9.0)
+ stackprof (0.2.11)
+ state_machines (0.5.0)
+ state_machines-activemodel (0.5.1)
+ activemodel (>= 4.1, < 6.0)
+ state_machines (>= 0.5.0)
+ state_machines-activerecord (0.5.1)
+ activerecord (>= 4.1, < 6.0)
+ state_machines-activemodel (>= 0.5.0)
+ stringex (2.8.4)
+ sys-filesystem (1.1.9)
+ ffi
+ sysexits (1.2.0)
+ temple (0.7.7)
+ test-prof (0.2.5)
+ text (1.3.1)
+ thin (1.7.2)
+ daemons (~> 1.0, >= 1.0.9)
+ eventmachine (~> 1.0, >= 1.0.4)
+ rack (>= 1, < 3)
+ thor (0.19.4)
+ thread_safe (0.3.6)
+ tilt (2.0.8)
+ timecop (0.8.1)
+ timfel-krb5-auth (0.8.3)
+ toml (0.1.2)
+ parslet (~> 1.5.0)
+ toml-rb (1.0.0)
+ citrus (~> 3.0, > 3.0)
+ truncato (0.7.10)
+ htmlentities (~> 4.3.1)
+ nokogiri (~> 1.8.0, >= 1.7.0)
+ tzinfo (1.2.5)
+ thread_safe (~> 0.1)
+ u2f (0.2.1)
+ uber (0.1.0)
+ uglifier (2.7.2)
+ execjs (>= 0.3.0)
+ json (>= 1.8.0)
+ unf (0.1.4)
+ unf_ext
+ unf_ext (0.0.7.5)
+ unicode-display_width (1.3.0)
+ unicorn (5.1.0)
+ kgio (~> 2.6)
+ raindrops (~> 0.7)
+ unicorn-worker-killer (0.4.4)
+ get_process_mem (~> 0)
+ unicorn (>= 4, < 6)
+ uniform_notifier (1.10.0)
+ unparser (0.2.7)
+ 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.6)
+ procto (~> 0.0.2)
+ url_safe_base64 (0.2.2)
+ validates_hostname (1.0.8)
+ activerecord (>= 3.0)
+ activesupport (>= 3.0)
+ version_sorter (2.1.0)
+ virtus (1.0.5)
+ axiom-types (~> 0.1)
+ coercible (~> 1.0)
+ descendants_tracker (~> 0.0, >= 0.0.3)
+ equalizer (~> 0.0, >= 0.0.9)
+ vmstat (2.3.0)
+ warden (1.2.7)
+ rack (>= 1.0)
+ webmock (2.3.2)
+ addressable (>= 2.3.6)
+ crack (>= 0.3.2)
+ hashdiff
+ webpack-rails (0.9.11)
+ railties (>= 3.2.0)
+ websocket-driver (0.6.5)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.3)
+ wikicloth (0.8.1)
+ builder
+ expression_parser
+ rinku
+ with_env (1.1.0)
+ xml-simple (1.1.5)
+ xpath (3.0.0)
+ nokogiri (~> 1.8)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ RedCloth (~> 4.3.2)
+ ace-rails-ap (~> 4.1.0)
+ activerecord_sane_schema_dumper (= 1.0)
+ acts-as-taggable-on (~> 5.0)
+ addressable (~> 2.5.2)
+ akismet (~> 2.0)
+ allocations (~> 1.0)
+ asana (~> 0.6.0)
+ asciidoctor (~> 1.5.6)
+ asciidoctor-plantuml (= 0.0.8)
+ asset_sync (~> 2.2.0)
+ attr_encrypted (~> 3.0.0)
+ awesome_print (~> 1.2.0)
+ babosa (~> 1.0.2)
+ base32 (~> 0.3.0)
+ batch-loader (~> 1.2.1)
+ 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 (~> 4.2)
+ browser (~> 2.2)
+ bullet (~> 5.5.0)
+ bundler-audit (~> 0.5.0)
+ capybara (~> 2.15)
+ capybara-screenshot (~> 1.0.0)
+ carrierwave (~> 1.2)
+ charlock_holmes (~> 0.7.5)
+ chronic (~> 0.10.2)
+ chronic_duration (~> 0.10.6)
+ commonmarker (~> 0.17)
+ 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 (= 2.0.0)
+ default_value_for (~> 3.0.5)
+ devise (~> 4.2)
+ devise-two-factor (~> 3.0.0)
+ diffy (~> 3.1.0)
+ doorkeeper (~> 4.3)
+ doorkeeper-openid_connect (~> 1.3)
+ dropzonejs-rails (~> 0.7.1)
+ email_reply_trimmer (~> 0.1)
+ email_spec (~> 1.6.0)
+ factory_bot_rails (~> 4.8.2)
+ faraday (~> 0.12)
+ fast_blank
+ ffaker (~> 2.4)
+ flay (~> 2.10.0)
+ flipper (~> 0.13.0)
+ flipper-active_record (~> 0.13.0)
+ flipper-active_support_cache_store (~> 0.13.0)
+ fog-aliyun (~> 0.2.0)
+ fog-aws (~> 2.0.1)
+ fog-core (~> 1.44)
+ fog-google (~> 1.3.3)
+ fog-local (~> 0.3)
+ fog-openstack (~> 0.1)
+ fog-rackspace (~> 0.1.1)
+ font-awesome-rails (~> 4.7)
+ foreman (~> 0.84.0)
+ fuubar (~> 2.2.0)
+ gemnasium-gitlab-service (~> 0.2)
+ gemojione (~> 3.3)
+ gettext (~> 3.2.2)
+ gettext_i18n_rails (~> 1.8.0)
+ gettext_i18n_rails_js (~> 1.3)
+ gitaly-proto (~> 0.94.0)
+ github-linguist (~> 5.3.3)
+ gitlab-flowdock-git-hook (~> 1.0.1)
+ gitlab-markup (~> 1.6.2)
+ gitlab-styles (~> 2.3)
+ gitlab_omniauth-ldap (~> 2.0.4)
+ gollum-lib (~> 4.2)
+ gollum-rugged_adapter (~> 0.4.4)
+ gon (~> 6.1.0)
+ google-api-client (~> 0.19.8)
+ google-protobuf (= 3.5.1)
+ gpgme
+ grape (~> 1.0)
+ grape-entity (~> 0.6.0)
+ grape-route-helpers (~> 2.1.0)
+ grape_logging (~> 1.7)
+ grpc (~> 1.10.0)
+ haml_lint (~> 0.26.0)
+ hamlit (~> 2.6.1)
+ hashie-forbidden_attributes
+ health_check (~> 2.6.0)
+ hipchat (~> 1.5.0)
+ html-pipeline (~> 2.7.1)
+ html2text
+ httparty (~> 0.13.3)
+ influxdb (~> 0.2)
+ jira-ruby (~> 1.4)
+ jquery-atwho-rails (~> 1.3.2)
+ json-schema (~> 2.8.0)
+ jwt (~> 1.5.6)
+ kaminari (~> 1.0)
+ knapsack (~> 1.16)
+ kubeclient (~> 3.0)
+ letter_opener_web (~> 1.3.0)
+ license_finder (~> 3.1)
+ licensee (~> 8.9)
+ lograge (~> 0.5)
+ loofah (~> 2.2)
+ mail_room (~> 0.9.1)
+ method_source (~> 0.8)
+ minitest (~> 5.7.0)
+ mousetrap-rails (~> 1.4.6)
+ mysql2 (~> 0.4.10)
+ net-ldap
+ net-ssh (~> 4.2.0)
+ nokogiri (~> 1.8.2)
+ oauth2 (~> 1.4)
+ octokit (~> 4.8)
+ omniauth (~> 1.8)
+ omniauth-auth0 (~> 2.0.0)
+ omniauth-authentiq (~> 0.3.1)
+ omniauth-azure-oauth2 (~> 0.0.9)
+ omniauth-cas3 (~> 1.1.4)
+ omniauth-facebook (~> 4.0.0)
+ omniauth-github (~> 1.1.1)
+ omniauth-gitlab (~> 1.0.2)
+ omniauth-google-oauth2 (~> 0.5.3)
+ omniauth-jwt (~> 0.0.2)
+ omniauth-kerberos (~> 0.3.0)
+ omniauth-oauth2-generic (~> 0.2.2)
+ omniauth-saml (~> 1.10)
+ omniauth-shibboleth (~> 1.2.0)
+ omniauth-twitter (~> 1.4)
+ omniauth_crowd (~> 2.2.0)
+ org-ruby (~> 0.9.12)
+ peek (~> 1.0.1)
+ peek-gc (~> 0.0.2)
+ 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)
+ premailer-rails (~> 1.9.7)
+ prometheus-client-mmap (~> 0.9.1)
+ pry-byebug (~> 3.4.1)
+ pry-rails (~> 0.3.4)
+ rack-attack (~> 4.4.1)
+ rack-cors (~> 1.0.0)
+ rack-oauth2 (~> 1.2.1)
+ rack-proxy (~> 0.6.0)
+ rails (= 5.0.6)
+ rails-controller-testing
+ rails-deprecated_sanitizer (~> 1.0.3)
+ rails-i18n (~> 5.1)
+ rainbow (~> 2.2)
+ raindrops (~> 0.18)
+ rblineprof (~> 0.3.6)
+ rbnacl (~> 4.0)
+ rbnacl-libsodium
+ rdoc (~> 4.2)
+ re2 (~> 1.1.1)
+ recaptcha (~> 3.0)
+ redcarpet (~> 3.4)
+ redis (~> 3.2)
+ redis-namespace (~> 1.5.2)
+ redis-rails (~> 5.0.2)
+ request_store (~> 1.3)
+ responders (~> 2.0)
+ rouge (~> 2.0)
+ rqrcode-rails3 (~> 0.1.7)
+ rspec-parameterized
+ rspec-rails (~> 3.6.0)
+ rspec-retry (~> 0.4.5)
+ rspec-set (~> 0.1.3)
+ rspec_profiling (~> 0.0.5)
+ rubocop (~> 0.52.1)
+ rubocop-rspec (~> 1.22.1)
+ ruby-fogbugz (~> 0.2.1)
+ ruby-prof (~> 0.17.0)
+ ruby_parser (~> 3.8)
+ rufus-scheduler (~> 3.4)
+ rugged (~> 0.27)
+ sanitize (~> 2.0)
+ sass-rails (~> 5.0.6)
+ scss_lint (~> 0.56.0)
+ seed-fu (~> 2.3.7)
+ select2-rails (~> 3.5.9)
+ selenium-webdriver (~> 3.5)
+ sentry-raven (~> 2.7)
+ settingslogic (~> 2.0.9)
+ sham_rack (~> 1.3.6)
+ shoulda-matchers (~> 3.1.2)
+ sidekiq (~> 5.0)
+ sidekiq-cron (~> 0.6.0)
+ sidekiq-limit_fetch (~> 3.4)
+ simple_po_parser (~> 1.1.2)
+ simplecov (~> 0.14.0)
+ slack-notifier (~> 1.5.1)
+ spinach-rails (~> 0.2.1)
+ spinach-rerun-reporter (~> 0.0.2)
+ spring (~> 2.0.0)
+ spring-commands-rspec (~> 1.0.4)
+ spring-commands-spinach (~> 1.1.0)
+ sprockets (~> 3.7.0)
+ sshkey (~> 1.9.0)
+ stackprof (~> 0.2.10)
+ state_machines-activerecord (~> 0.5.1)
+ sys-filesystem (~> 1.1.6)
+ test-prof (~> 0.2.5)
+ thin (~> 1.7.0)
+ timecop (~> 0.8.0)
+ toml-rb (~> 1.0.0)
+ truncato (~> 0.7.9)
+ u2f (~> 0.2.1)
+ uglifier (~> 2.7.2)
+ unf (~> 0.1.4)
+ unicorn (~> 5.1.0)
+ unicorn-worker-killer (~> 0.4.4)
+ validates_hostname (~> 1.0.6)
+ version_sorter (~> 2.1.0)
+ virtus (~> 1.0.1)
+ vmstat (~> 2.3.0)
+ webmock (~> 2.3.2)
+ webpack-rails (~> 0.9.10)
+ wikicloth (= 0.8.1)
+
+BUNDLED WITH
+ 1.16.1
diff --git a/PROCESS.md b/PROCESS.md
index 3fcf676b302..f206506f7c5 100644
--- a/PROCESS.md
+++ b/PROCESS.md
@@ -53,7 +53,7 @@ Below we describe the contributing process to GitLab for two reasons:
Several people from the [GitLab team][team] are helping community members to get
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/.
+What you can expect from them is described at https://about.gitlab.com/roles/merge-request-coach/.
## Assigning issues
@@ -71,11 +71,15 @@ star, smile, etc.). Some good tips about code reviews can be found in our
## Feature freeze on the 7th for the release on the 22nd
-After the 7th (Pacific Standard Time Zone) of each month, RC1 of the upcoming release (to be shipped on the 22nd) is created and deployed to GitLab.com and the stable branch for this release is frozen, which means master is no longer merged into it.
+After 7th at 23:59 (Pacific Time Zone) of each month, RC1 of the upcoming release (to be shipped on the 22nd) is created and deployed to GitLab.com and the stable branch for this release is frozen, which means master is no longer merged into it.
Merge requests may still be merged into master during this period,
but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch.
+
By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things.
+Any release candidate that gets created after this date can become a final release,
+hence the name release candidate.
+
### Between the 1st and the 7th
These types of merge requests for the upcoming release need special consideration:
@@ -85,7 +89,8 @@ These types of merge requests for the upcoming release need special consideratio
and a dedicated team with front-end, back-end, and UX.
* **Small features**: any other feature request.
-**Large features** must be with a maintainer **by the 1st**. This means that:
+It is strongly recommended that **large features** be with a maintainer **by the
+1st**. This means that:
* There is a merge request (even if it's WIP).
* The person (or people, if it needs a frontend and backend maintainer) who will
@@ -100,14 +105,37 @@ The maintainer can also choose to assign a reviewer to perform an initial
review, but this way the maintainer is unlikely to be surprised by receiving an
MR later in the cycle.
-**Small features** must be with a reviewer (not necessarily maintainer) **by the
-3rd**.
+It is strongly recommended that **small features** be with a reviewer (not
+necessarily a maintainer) **by the 3rd**.
Most merge requests from the community do not have a specific release
target. However, if one does and falls into either of the above categories, it's
the reviewer's responsibility to manage the above communication and assignment
on behalf of the community member.
+#### What happens if these deadlines are missed?
+
+If a small or large feature is _not_ with a maintainer or reviewer by the
+recommended date, this does _not_ mean that maintainers or reviewers will refuse
+to review or merge it, or that the feature will definitely not make it in before
+the feature freeze.
+
+However, with every day that passes without review, it will become more likely
+that the feature will slip, because maintainers and reviewers may not have
+enough time to do a thorough review, and developers may not have enough time to
+adequately address any feedback that may come back.
+
+A maintainer or reviewer may also determine that it will not be possible to
+finish the current scope of the feature in time, but that it is possible to
+reduce the scope so that something can still ship this month, with the remaining
+scope moving to the next release. The sooner this decision is made, in
+conversation with the Product Manager and developer, the more time there is to
+extract that which is now out of scope, and to finish that which remains in scope.
+
+For these reasons, it is strongly recommended to follow the guidelines above,
+to maximize the chances of your feature making it in before the feature freeze,
+and to prevent any last minute surprises.
+
### On the 7th
Merge requests should still be complete, following the
@@ -169,11 +197,13 @@ to be backported down to the `9.5` release, you will need to assign it the
### 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:
+you can ask for an exception to be made.
+
+Go to [Release tasks issue tracker](https://gitlab.com/gitlab-org/release/tasks/issues/new) and create an issue
+using the `Exception-request` issue template.
-1. a Release Manager
-2. an Engineering Lead
-3. an Engineering Director, the VP of Engineering, or the CTO
+**Do not** set the relevant `Pick into X.Y` label (see above) before request an
+exception; this should be done after the exception is approved.
You can find who is who on the [team page](https://about.gitlab.com/team/).
diff --git a/Procfile b/Procfile
index cad738d4292..1776fd97942 100644
--- a/Procfile
+++ b/Procfile
@@ -4,4 +4,3 @@
#
web: RAILS_ENV=development bin/web start_foreground
worker: RAILS_ENV=development bin/background_jobs start_foreground
-# mail_room: bundle exec mail_room -q -c config/mail_room.yml
diff --git a/VERSION b/VERSION
index 73cdb768a24..7a86eda5728 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-10.3.0-pre
+10.7.0-pre
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico
new file mode 100644
index 00000000000..48b1095370d
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_created.ico b/app/assets/images/ci_favicons/canary/favicon_status_created.ico
new file mode 100644
index 00000000000..623c728faf6
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_created.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico
new file mode 100644
index 00000000000..3073fe5a761
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico
new file mode 100644
index 00000000000..6c713d7b675
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico
new file mode 100644
index 00000000000..dbf855fdafd
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico
new file mode 100644
index 00000000000..ccd00606aeb
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_running.ico b/app/assets/images/ci_favicons/canary/favicon_status_running.ico
new file mode 100644
index 00000000000..968e7c4c2d4
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_running.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico
new file mode 100644
index 00000000000..7e3be35cc3a
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_success.ico b/app/assets/images/ci_favicons/canary/favicon_status_success.ico
new file mode 100644
index 00000000000..a1fb6e91d65
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_success.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico
new file mode 100644
index 00000000000..5d931619fb2
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico
Binary files differ
diff --git a/app/assets/images/favicon-yellow.ico b/app/assets/images/favicon-yellow.ico
new file mode 100644
index 00000000000..b650f277fb6
--- /dev/null
+++ b/app/assets/images/favicon-yellow.ico
Binary files differ
diff --git a/app/assets/images/file_icons.svg b/app/assets/images/file_icons.svg
new file mode 100644
index 00000000000..26ec1a6b388
--- /dev/null
+++ b/app/assets/images/file_icons.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol viewBox="0 0 24 24" id="actionscript" xmlns="http://www.w3.org/2000/svg"><text style="line-height:113.99999857%" x="5.605" y="15.892" transform="scale(.91325 1.095)" font-weight="400" font-size="42.822" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#f44336"/><path style="line-height:125%" d="M4.744 2.031c-1.157 0-1.994.31-2.51.93-.515.612-.771 1.678-.771 3.197v2.467c0 1.408-.402 2.111-1.201 2.111v2.035c.8 0 1.2.679 1.2 2.036v2.654c0 1.512.26 2.562.78 3.152.52.59 1.355.885 2.502.885V19.43c-.447 0-.77-.151-.97-.453-.195-.303-.292-.815-.292-1.538v-2.267c0-1.807-.404-2.937-1.214-3.395v-.045c.81-.464 1.214-1.581 1.214-3.351V6.025c0-1.283.42-1.925 1.262-1.925V2.03zm14.66 0V4.1c.842 0 1.262.642 1.262 1.925v2.268c0 1.843.402 2.996 1.207 3.46v.046c-.805.442-1.207 1.544-1.207 3.306v2.356c0 .715-.099 1.22-.299 1.516-.2.302-.52.453-.963.453v2.068c1.152 0 1.984-.295 2.494-.885.516-.59.772-1.663.772-3.218V14.84c0-1.379.404-2.069 1.209-2.069v-2.035c-.805 0-1.21-.696-1.21-2.09V6.113c0-1.49-.255-2.54-.77-3.152-.516-.62-1.348-.93-2.495-.93zm-3.054 4.46c-.455 0-.886.057-1.293.173a3.056 3.056 0 0 0-1.078.527c-.308.241-.551.549-.731.924-.18.37-.27.817-.27 1.336 0 .663.165 1.227.493 1.695.33.468.831.864 1.502 1.188.263.125.509.249.736.37.227.12.422.244.586.374.168.13.299.271.394.424a.963.963 0 0 1 .145.521c0 .144-.03.28-.09.405a.9.9 0 0 1-.275.318c-.12.088-.272.158-.455.21a2.34 2.34 0 0 1-.635.075c-.415 0-.825-.083-1.233-.25a3.644 3.644 0 0 1-1.13-.763v2.222a3.68 3.68 0 0 0 1.101.418c.427.093.875.139 1.346.139.459 0 .894-.05 1.305-.152a3.002 3.002 0 0 0 1.09-.5c.31-.237.556-.543.736-.918.183-.38.275-.849.275-1.405 0-.403-.052-.755-.156-1.056a2.542 2.542 0 0 0-.45-.813 3.295 3.295 0 0 0-.704-.633 6.754 6.754 0 0 0-.922-.535 12.4 12.4 0 0 1-.676-.348c-.2-.115-.37-.231-.51-.347a1.502 1.502 0 0 1-.322-.375.91.91 0 0 1-.115-.453c0-.153.033-.288.101-.408a.948.948 0 0 1 .29-.32c.123-.089.275-.156.454-.202a2.18 2.18 0 0 1 .598-.078c.16 0 .326.015.502.043.18.028.36.07.539.13.18.056.354.13.522.218.171.088.329.188.472.304V6.871a4.039 4.039 0 0 0-.957-.285 6.448 6.448 0 0 0-1.185-.096zm-8.774.165l-3.123 9.967h2.094l.605-2.217h3.053l.61 2.217h2.107L9.869 6.656H7.576zm1.072 1.78h.047c.028.347.077.646.145.896l.922 3.35H7.564l.934-3.377c.08-.288.13-.578.15-.87z" font-weight="400" font-size="51.019" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="android" xmlns="http://www.w3.org/2000/svg"><path d="M15 5h-1V4h1m-5 1H9V4h1m5.53-1.84L16.84.85c.19-.19.19-.51 0-.71a.513.513 0 0 0-.71 0l-1.48 1.48C13.85 1.23 12.95 1 12 1c-.96 0-1.86.23-2.66.63L7.85.14a.501.501 0 0 0-.7 0c-.2.2-.2.52 0 .71l1.31 1.31C6.97 3.26 6 5 6 7h12c0-2-1-3.75-2.47-4.84M20.5 8A1.5 1.5 0 0 0 19 9.5v7a1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 20.5 8m-17 0A1.5 1.5 0 0 0 2 9.5v7A1.5 1.5 0 0 0 3.5 18 1.5 1.5 0 0 0 5 16.5v-7A1.5 1.5 0 0 0 3.5 8M6 18a1 1 0 0 0 1 1h1v3.5A1.5 1.5 0 0 0 9.5 24a1.5 1.5 0 0 0 1.5-1.5V19h2v3.5a1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5V19h1a1 1 0 0 0 1-1V8H6v10z" fill="#c0ca33"/></symbol><symbol viewBox="0 0 24 24" id="angular" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#e53935"/></symbol><symbol viewBox="0 0 24 24" id="angular-component" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#0288d1"/></symbol><symbol viewBox="0 0 24 24" id="angular-directive" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#ab47bc"/></symbol><symbol viewBox="0 0 24 24" id="angular-guard" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-pipe" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#00897b"/></symbol><symbol viewBox="0 0 24 24" id="angular-resolver" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-routing" xmlns="http://www.w3.org/2000/svg"><path d="M11 10H5L3 8l2-2h6V3l1-1 1 1v1h6l2 2-2 2h-6v2h6l2 2-2 2h-6v6a2 2 0 0 1 2 2H9a2 2 0 0 1 2-2V10z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-service" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#ffca28"/></symbol><symbol viewBox="0 0 100 100" id="apiblueprint" xmlns="http://www.w3.org/2000/svg"><title>api-blueprint</title><path d="M50.133 7.521A16.998 16.998 0 0 0 33.135 24.52a16.998 16.998 0 0 0 4.945 11.974L24.861 57.398a16.998 16.998 0 0 0-3.175-.308A16.998 16.998 0 0 0 4.688 74.088a16.998 16.998 0 0 0 16.998 16.998 16.998 16.998 0 0 0 16.998-16.998 16.998 16.998 0 0 0-7.063-13.773l12.576-19.89a16.998 16.998 0 0 0 5.936 1.093 16.998 16.998 0 0 0 6.154-1.155l12.537 19.83a16.998 16.998 0 0 0-7.244 13.895 16.998 16.998 0 0 0 16.998 17 16.998 16.998 0 0 0 16.998-17A16.998 16.998 0 0 0 78.578 57.09a16.998 16.998 0 0 0-2.95.262L62.337 36.327A16.998 16.998 0 0 0 67.13 24.52 16.998 16.998 0 0 0 50.132 7.522z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="applescript" xmlns="http://www.w3.org/2000/svg"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" fill="#78909c"/></symbol><symbol viewBox="0 0 24 24" id="appveyor" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c-.084 0-.165.008-.248.01a10 10 0 0 0-.266.01 9.952 9.952 0 0 0-.754.066 10 10 0 0 0-.148.018 9.855 9.855 0 0 0-.93.177 10 10 0 0 0-.07.02c-.196.049-.392.1-.584.16v.012a10 10 0 0 0-2 .875V3.34c-.02.012-.038.027-.059.039a10 10 0 0 0-.953.635c-.09.067-.172.142-.26.213a10 10 0 0 0-.628.546c-.109.104-.211.211-.315.319a10 10 0 0 0-.476.539c-.1.12-.201.237-.295.361a10 10 0 0 0-.52.766c-.088.143-.17.288-.252.435a10 10 0 0 0-.363.723c-.072.161-.136.327-.2.492a10 10 0 0 0-.269.778c-.02.067-.044.131-.062.199a10 10 0 0 0-.008.027c-.098.364-.166.728-.22 1.09-.012.077-.024.153-.034.23a9.85 9.85 0 0 0-.08 1.182c0 .03-.006.057-.006.086a10 10 0 0 0 .008.148c.001.094-.002.188.002.282l.011.004a10 10 0 0 0 .333 2.158l-.012-.004c.012.047.033.091.047.139a10 10 0 0 0 .322.955c.02.052.037.106.059.158a10 10 0 0 0 .503 1.035c.065.116.14.226.21.34a10 10 0 0 0 .423.64c.092.128.187.252.285.375a10 10 0 0 0 .448.52c.112.123.222.248.341.365a10 10 0 0 0 .803.719 10 10 0 0 0 .01.006c.099.078.207.146.309.22a10 10 0 0 0 .648.442c.138.085.28.163.424.242a10 10 0 0 0 .715.358c.114.051.226.106.343.154a10 10 0 0 0 1.133.389c.016.004.031.01.047.015a10 10 0 0 0 .461.098 10 10 0 0 0 .482.103 10 10 0 0 0 .418.051 10 10 0 0 0 .575.065 10 10 0 0 0 .144.005A10 10 0 0 0 12 22a10 10 0 0 0 .197-.01 10 10 0 0 0 .496-.025 10 10 0 0 0 .49-.043 10 10 0 0 0 .489-.074 10 10 0 0 0 .51-.098 10 10 0 0 0 .47-.12 10 10 0 0 0 .477-.14 10 10 0 0 0 .47-.172 10 10 0 0 0 .481-.197 10 10 0 0 0 .414-.201 10 10 0 0 0 .475-.252 10 10 0 0 0 .39-.238 10 10 0 0 0 .452-.301 10 10 0 0 0 .38-.291 10 10 0 0 0 .385-.315 10 10 0 0 0 .375-.347 10 10 0 0 0 .36-.363 10 10 0 0 0 .293-.334 10 10 0 0 0 .353-.434 10 10 0 0 0 .28-.393 10 10 0 0 0 .263-.4 10 10 0 0 0 .264-.461 10 10 0 0 0 .228-.436 10 10 0 0 0 .195-.437 10 10 0 0 0 .196-.48 10 10 0 0 0 .228-.69 10 10 0 0 0 .028-.094 10 10 0 0 0 .021-.066 10 10 0 0 0 .098-.461 10 10 0 0 0 .103-.482 10 10 0 0 0 .051-.418 10 10 0 0 0 .065-.575 10 10 0 0 0 .005-.144A10 10 0 0 0 22 12a10 10 0 0 0-.01-.197 10 10 0 0 0-.025-.496 10 10 0 0 0-.043-.49 10 10 0 0 0-.074-.489 10 10 0 0 0-.098-.51 10 10 0 0 0-.12-.47 10 10 0 0 0-.14-.477 10 10 0 0 0-.172-.47 10 10 0 0 0-.197-.481 10 10 0 0 0-.201-.414 10 10 0 0 0-.252-.475 10 10 0 0 0-.238-.39 10 10 0 0 0-.301-.452 10 10 0 0 0-.291-.38 10 10 0 0 0-.315-.385 10 10 0 0 0-.347-.375 10 10 0 0 0-.363-.36 10 10 0 0 0-.334-.293 10 10 0 0 0-.434-.353 10 10 0 0 0-.393-.28 10 10 0 0 0-.4-.263 10 10 0 0 0-.461-.264 10 10 0 0 0-.436-.228 10 10 0 0 0-.437-.196 10 10 0 0 0-.48-.195 10 10 0 0 0-.69-.228 10 10 0 0 0-.094-.028 10 10 0 0 0-.066-.021 10 10 0 0 0-.461-.098 10 10 0 0 0-.482-.103 10 10 0 0 0-.418-.051 10 10 0 0 0-.575-.065 10 10 0 0 0-.144-.005A10 10 0 0 0 12 2zm-.016 5.002a5 5 0 0 1 .262.01 5 5 0 0 1 .227.011 5 5 0 0 1 .341.05 5 5 0 0 1 .135.019 5 5 0 0 1 .014.004 5 5 0 0 1 .115.025 5 5 0 0 1 .303.076 5 5 0 0 1 .265.086 5 5 0 0 1 .2.074 5 5 0 0 1 .242.106 5 5 0 0 1 .228.11 5 5 0 0 1 .196.109 5 5 0 0 1 .244.15 5 5 0 0 1 .17.12 5 5 0 0 1 .224.171 5 5 0 0 1 .186.16 5 5 0 0 1 .176.164 5 5 0 0 1 .172.18 5 5 0 0 1 .177.203 5 5 0 0 1 .133.172 5 5 0 0 1 .16.223 5 5 0 0 1 .133.214 5 5 0 0 1 .12.21 5 5 0 0 1 .107.216 5 5 0 0 1 .109.24 5 5 0 0 1 .084.223 5 5 0 0 1 .08.242 5 5 0 0 1 .07.264 5 5 0 0 1 .047.207 5 5 0 0 1 .045.277 5 5 0 0 1 .028.227 5 5 0 0 1 .02.351 5 5 0 0 1 .003.079 5 5 0 0 1-.012.271 5 5 0 0 1-.011.227 5 5 0 0 1-.05.341 5 5 0 0 1-.019.135 5 5 0 0 1-.004.014 5 5 0 0 1-.025.115 5 5 0 0 1-.076.303 5 5 0 0 1-.086.265 5 5 0 0 1-.074.2 5 5 0 0 1-.106.242 5 5 0 0 1-.11.228 5 5 0 0 1-.109.196 5 5 0 0 1-.15.244 5 5 0 0 1-.12.17 5 5 0 0 1-.171.224 5 5 0 0 1-.16.186 5 5 0 0 1-.164.176 5 5 0 0 1-.18.172 5 5 0 0 1-.203.177l-.002.002c-.018.019-.028.035-.047.053l-3.959 5.09-3.05-.979a141.684 141.684 0 0 0 3.177-3.084 5 5 0 0 1-.103-.015 5 5 0 0 1-.149-.024 5 5 0 0 1-.115-.025 5 5 0 0 1-3.57-3.04 5.072 5.072 0 0 1-.206-.661 5 5 0 0 1-.033-.147c-.025-.118-.036-.24-.054-.36-.987.993-1.964 1.993-2.954 3.05l-.98-3.053 5.092-3.957c.043-.044.082-.07.125-.11a5 5 0 0 1 .71-.634c.18-.13.367-.25.561-.356a5 5 0 0 1 .16-.08 4.94 4.94 0 0 1 .516-.222 5 5 0 0 1 .147-.057c.211-.07.43-.123.654-.164a5 5 0 0 1 .172-.027c.236-.035.476-.058.722-.059zM12 9a3 3 0 0 0-.053.002 3 3 0 0 0-.166.01 3 3 0 0 0-.133.011 3 3 0 0 0-.17.026 3 3 0 0 0-.113.021 3 3 0 0 0-.19.05 3 3 0 0 0-.103.03 3 3 0 0 0-.16.057 3 3 0 0 0-.129.053 3 3 0 0 0-.146.072 3 3 0 0 0-.12.063 3 3 0 0 0-.132.082 3 3 0 0 0-.123.08 3 3 0 0 0-.116.088 3 3 0 0 0-.126.105 3 3 0 0 0-.1.094 3 3 0 0 0-.111.111 3 3 0 0 0-.096.107 3 3 0 0 0-.094.116 3 3 0 0 0-.098.136 3 3 0 0 0-.072.11 3 3 0 0 0-.076.133 3 3 0 0 0-.07.132 3 3 0 0 0-.063.14 3 3 0 0 0-.054.14 3 3 0 0 0-.077.228 3 3 0 0 0-.007.026 3 3 0 0 0-.03.138 3 3 0 0 0-.031.149 3 3 0 0 0-.014.11 3 3 0 0 0-.02.183 3 3 0 0 0-.001.052A3 3 0 0 0 9 12a3 3 0 0 0 .002.053 3 3 0 0 0 .01.166 3 3 0 0 0 .011.133 3 3 0 0 0 .026.17 3 3 0 0 0 .021.113 3 3 0 0 0 .05.19 3 3 0 0 0 .03.103 3 3 0 0 0 .057.16 3 3 0 0 0 .053.129 3 3 0 0 0 .072.146 3 3 0 0 0 .063.12 3 3 0 0 0 .082.132 3 3 0 0 0 .08.123 3 3 0 0 0 .088.116 3 3 0 0 0 .105.126 3 3 0 0 0 .094.1 3 3 0 0 0 .111.111 3 3 0 0 0 .107.096 3 3 0 0 0 .116.094 3 3 0 0 0 .136.098 3 3 0 0 0 .11.072 3 3 0 0 0 .133.076 3 3 0 0 0 .132.07 3 3 0 0 0 .135.06 3 3 0 0 0 .153.061 3 3 0 0 0 .216.07 3 3 0 0 0 .004.003 3 3 0 0 0 .026.007 3 3 0 0 0 .138.03 3 3 0 0 0 .149.031 3 3 0 0 0 .11.014 3 3 0 0 0 .183.02 3 3 0 0 0 .011.001 3 3 0 0 0 .041 0A3 3 0 0 0 12 15a3 3 0 0 0 .053-.002 3 3 0 0 0 .166-.01 3 3 0 0 0 .133-.011 3 3 0 0 0 .17-.026 3 3 0 0 0 .113-.021 3 3 0 0 0 .19-.05 3 3 0 0 0 .103-.03 3 3 0 0 0 .16-.057 3 3 0 0 0 .129-.053 3 3 0 0 0 .146-.072 3 3 0 0 0 .12-.063 3 3 0 0 0 .132-.082 3 3 0 0 0 .123-.08 3 3 0 0 0 .116-.088 3 3 0 0 0 .126-.105 3 3 0 0 0 .1-.094 3 3 0 0 0 .111-.111 3 3 0 0 0 .096-.107 3 3 0 0 0 .094-.116 3 3 0 0 0 .098-.136 3 3 0 0 0 .072-.11 3 3 0 0 0 .076-.133 3 3 0 0 0 .07-.132 3 3 0 0 0 .06-.135 3 3 0 0 0 .061-.153 3 3 0 0 0 .07-.216 3 3 0 0 0 .003-.004 3 3 0 0 0 .007-.026 3 3 0 0 0 .03-.138 3 3 0 0 0 .031-.149 3 3 0 0 0 .002-.008 3 3 0 0 0 .012-.101 3 3 0 0 0 .02-.184 3 3 0 0 0 .001-.011 3 3 0 0 0 0-.041A3 3 0 0 0 15 12a3 3 0 0 0-.002-.053 3 3 0 0 0-.01-.166 3 3 0 0 0-.011-.133 3 3 0 0 0-.026-.17 3 3 0 0 0-.021-.113 3 3 0 0 0-.05-.19 3 3 0 0 0-.03-.103 3 3 0 0 0-.057-.16 3 3 0 0 0-.053-.129 3 3 0 0 0-.072-.146 3 3 0 0 0-.063-.12 3 3 0 0 0-.082-.132 3 3 0 0 0-.08-.123 3 3 0 0 0-.088-.116 3 3 0 0 0-.105-.126 3 3 0 0 0-.094-.1 3 3 0 0 0-.111-.111 3 3 0 0 0-.107-.096 3 3 0 0 0-.116-.094 3 3 0 0 0-.136-.098 3 3 0 0 0-.11-.072 3 3 0 0 0-.133-.076 3 3 0 0 0-.132-.07 3 3 0 0 0-.14-.063 3 3 0 0 0-.14-.054 3 3 0 0 0-.228-.077 3 3 0 0 0-.026-.007 3 3 0 0 0-.138-.03 3 3 0 0 0-.149-.031 3 3 0 0 0-.008-.002 3 3 0 0 0-.101-.012 3 3 0 0 0-.184-.02 3 3 0 0 0-.011-.001 3 3 0 0 0-.041 0A3 3 0 0 0 12 9z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 720 720" id="arduino" xmlns="http://www.w3.org/2000/svg"><defs><symbol id="ana" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke-opacity="100%" stroke-width="60" stroke="#00979c" d="M174 30a10.5 10.1 0 0 0 0 280C364 320 344 30 544 30a10.5 10.1 0 0 1 0 280C354 320 374 30 174 30"/><path d="M528 205v-32.8h-32.5v-13.7H528V126h13.9v32.5h32.5v13.7h-32.5V205H528z" text-anchor="middle" fill="#00979c" stroke-width="20" stroke="#00979c" font-family="sans-serif" font-size="167"/><path fill="#00979c" stroke="#00979c" stroke-width="23.6" transform="matrix(1.56 0 0 .64 -366 .528)" d="M321 266v-17.4h53.3V266H321z"/></symbol></defs><title>Layer 1</title><use x="20.063" y="360.85" transform="matrix(.997 0 0 .997 -18.596 -159.19)" xlink:href="#ana"/></symbol><symbol viewBox="0 0 24 24" id="assembly" xmlns="http://www.w3.org/2000/svg"><path d="M1.746 1.566v20.905H5.13v-2.088H3.438V3.656h1.69v-2.09H1.747zm17.219 0v2.09h1.693v16.727h-1.693v2.09h3.383V1.566h-3.383zM15.196 3.988c-.5 0-.93.076-1.29.225-.359.15-.652.372-.877.671-.226.302-.39.673-.494 1.108a6.715 6.715 0 0 0-.155 1.54c0 .573.049 1.083.15 1.528.1.442.264.811.49 1.11.222.298.512.524.872.676.36.153.795.23 1.304.23.518 0 .954-.075 1.308-.224.353-.153.643-.376.869-.671.219-.29.38-.661.484-1.112.104-.454.156-.967.156-1.54 0-.573-.052-1.079-.152-1.515a2.92 2.92 0 0 0-.485-1.106 2.09 2.09 0 0 0-.868-.686c-.354-.155-.79-.234-1.312-.234zm-6.814.12a.941.941 0 0 1-.138.458.849.849 0 0 1-.356.296A1.71 1.71 0 0 1 7.385 5a5.244 5.244 0 0 1-.631.037v1.11H8.19v3.6H6.754v1.188h4.545V9.745H9.894V4.11H8.382zm6.814 1.138c.375 0 .643.176.805.527.161.348.241.933.241 1.756 0 .814-.082 1.399-.247 1.756-.164.356-.43.534-.799.534-.369 0-.636-.178-.8-.534-.165-.357-.248-.941-.248-1.749 0-.829.082-1.415.243-1.763.162-.35.43-.527.805-.527zm-6.33 7.64c-.5 0-.93.073-1.29.223-.359.15-.651.374-.877.673-.225.302-.39.67-.494 1.106a6.715 6.715 0 0 0-.155 1.54c0 .573.05 1.082.15 1.527.1.442.264.814.49 1.112.222.3.514.525.874.677.36.152.793.229 1.302.229.519 0 .954-.076 1.308-.225.354-.153.643-.376.869-.672.22-.29.38-.66.484-1.111.104-.455.156-.967.156-1.54 0-.573-.05-1.079-.15-1.515a2.923 2.923 0 0 0-.487-1.106 2.084 2.084 0 0 0-.867-.686c-.353-.156-.791-.232-1.313-.232zm5.846.119a.941.941 0 0 1-.138.457.85.85 0 0 1-.356.296 1.71 1.71 0 0 1-.503.137 5.245 5.245 0 0 1-.631.037v1.112h1.435v3.597h-1.435v1.189h4.545v-1.189h-1.405v-5.636h-1.512zm-5.846 1.137c.375 0 .643.176.805.527.162.347.241.933.241 1.756 0 .813-.08 1.399-.245 1.755-.164.357-.432.534-.8.534-.37 0-.637-.177-.802-.534-.164-.356-.245-.939-.245-1.746 0-.83.08-1.418.242-1.765.161-.35.43-.527.804-.527z" fill="#ff6e40"/></symbol><symbol viewBox="0 0 24 24" id="aurelia" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="api" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#apa"/><linearGradient id="apa" x1="-3.881" x2="2.377" y1="-1.442" y2="4.304"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#apb"/><linearGradient id="apb" x1=".729" x2="-.971" y1=".844" y2="-1.477"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="apk" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#apc"/><linearGradient id="apc" x1="-2.839" x2="2.875" y1="-6.936" y2="1.017"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apl" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#apd"/><linearGradient id="apd" x1="-8.212" x2="1.02" y1="-4.691" y2="2.882"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apm" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#ape"/><linearGradient id="ape" x1="-1.404" x2="4.19" y1="-2.309" y2="2.62"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apn" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#apf"/><linearGradient id="apf" x1="1.911" x2=".204" y1="2.539" y2=".204"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="apo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#apg"/><linearGradient id="apg" x1="-3.881" x2="2.377" y1="-1.738" y2="5.19"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="app" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#aph"/><linearGradient id="aph" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><g transform="rotate(11.282 -1.694 21.569) scale(.47102)" clip-rule="evenodd" fill="none" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#api)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#apj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#apk)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#apl)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#apm)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#apn)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#apo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#app)"/></g></symbol><symbol viewBox="0 0 24 24" id="autohotkey" xmlns="http://www.w3.org/2000/svg"><path d="M5 3c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5zm3.668 3.447a.9.9 0 0 1 .652.256.84.84 0 0 1 .262.625c0 .34-.014.852-.041 1.537-.022.68-.033 1.19-.033 1.53 0 .111-.016.326-.047.644a6.149 6.149 0 0 0-.033.68l2.578-.485c1.007-.179 1.874-.281 2.603-.308.018-.3.048-1.105.088-2.416.01-.345.115-.742.317-1.19.25-.55.533-.826.851-.826.237 0 .448.08.631.236.197.17.295.382.295.637a.775.775 0 0 1-.025.201c-.09.327-.135.612-.135.854 0 .125-.014.32-.041.584-.023.26-.033.453-.033.578 0 .425-.022 1.056-.067 1.893a38.963 38.963 0 0 0-.068 1.892c0 .327.025.816.074 1.465.05.649.074 1.136.074 1.463a.84.84 0 0 1-.261.625.893.893 0 0 1-.65.254 1 1 0 0 1-.686-.254.777.777 0 0 1-.29-.611c0-.327-.015-.818-.046-1.471a39.552 39.552 0 0 1-.041-1.47c0-.256.004-.482.013-.679-.702.032-1.57.142-2.603.33-.86.157-1.719.316-2.578.477-.01.304-.042.812-.096 1.523a22.354 22.354 0 0 0-.066 1.538.84.84 0 0 1-.262.625.893.893 0 0 1-.65.253.898.898 0 0 1-.653-.253.84.84 0 0 1-.262-.625c0-.452.038-1.128.114-2.028.08-.9.12-1.575.12-2.027 0-.573.015-1.436.042-2.586.027-1.155.04-2.017.04-2.59a.84.84 0 0 1 .263-.625.895.895 0 0 1 .65-.256z" fill="#4caf50"/></symbol><symbol viewBox="0 0 24 24" id="autoit" xmlns="http://www.w3.org/2000/svg"><defs id="ardefs8"><style id="arstyle4482">.cls-1{fill:#5d83ac}.cls-2{fill:#f0f0f0;fill-rule:evenodd}</style><style id="arstyle4510">.cls-1{fill:#5d83ac}.cls-2{fill:#f0f0f0;fill-rule:evenodd}</style></defs><g id="arg4522" transform="translate(-59.538 -26.404) scale(.0555)"><path d="M12.8 2.133A10.666 10.666 0 0 0 2.136 12.799 10.666 10.666 0 0 0 12.8 23.465a10.666 10.666 0 0 0 10.668-10.666A10.666 10.666 0 0 0 12.8 2.133zm.15 4.713c.456 0 .836.105 1.142.314.306.21.565.469.78.78l6.089 8.812H9.627l1.82-2.506h3.36c.315 0 .589.01.822.03a11.93 11.93 0 0 1-.473-.663 39.13 39.13 0 0 0-.517-.75l-1.748-2.578-4.577 6.467H4.746l6.25-8.813c.204-.281.46-.534.772-.757.31-.224.705-.336 1.181-.336z" transform="matrix(16.89188 0 0 16.89188 1072.761 475.745)" id="arcircle4514" fill="#1976d2" stroke-width=".026"/></g></symbol><symbol viewBox="0 0 213.33333 213.33333" id="babel" xmlns="http://www.w3.org/2000/svg"><path d="M50.22 199.659c-.875-.406-1.261-1.6-.857-2.652.404-1.053.12-1.914-.63-1.914s-1.615.748-1.92 1.663c-.328.983-1.27.302-2.304-1.667-.962-1.831-3.718-5.533-6.126-8.226-9.418-10.535-7.71-27.444 5.432-53.77 12.459-24.96 23.117-39.033 45.966-60.696 30.229-28.66 52.679-46.223 70.587-55.22 10.98-5.518 13.025-5.059 2.778.624-11.004 6.102-11.378 6.359-10.512 7.226.33.33 7.306-2.67 15.504-6.667 15.87-7.737 16.34-7.912 16.34-6.082 0 .652-4.95 3.738-11 6.858-13.062 6.736-12.722 6.48-10.472 7.872 1.117.69 5.428-.582 11.54-3.406 5.367-2.48 10.397-4.508 11.179-4.508 2.755 0-3.928 5.302-11.541 9.157-20.437 10.35-68.937 46.043-68.07 50.097.166.777-5.792 7.639-13.241 15.248-15.257 15.587-26.14 30.002-33.748 44.706-6.379 12.326-7.457 17.734-5.385 26.996 3.482 15.56 11.592 18.366 31.482 10.895 28.228-10.603 45.758-28.704 47.022-48.556.602-9.442-1.317-13.479-8.52-17.93-4.01-2.48-5.268-2.621-12.065-1.365-4.173.771-10.153 2.906-13.289 4.744s-6.455 3.34-7.377 3.34c-.922 0-3.216 1.336-5.096 2.968-1.88 1.633.48-1.13 5.247-6.14 6.82-7.167 7.956-8.9 5.333-8.132-5.208 1.525-10.194 4.33-15.649 8.803-2.76 2.264-.923.175 4.08-4.641 11.565-11.131 21.183-15.97 33.088-16.641 17.097-.966 27.254 5.805 31.964 21.31 2.435 8.017 2.609 10.24 1.353 17.37-1.65 9.361-7.034 21.553-15.593 35.307-4.398 7.067-8.434 11.427-15.588 16.844-9.166 6.94-15.654 11.02-15.654 9.845 0-.295 2.455-2.161 5.455-4.147 8.818-5.835 5.075-5.377-8.326 1.02-6.854 3.27-15.199 6.593-18.542 7.38-7.106 1.675-30.527 3.164-32.846 2.089zm-8.408-19.899c0-1.1-.6-2-1.333-2-.734 0-1.334.9-1.334 2s.6 2 1.333 2c.734 0 1.334-.9 1.334-2zm89.255-8.204c1.53-1.945 2.473-3.845 2.097-4.222-.377-.377-.836-.435-1.02-.13-.182.306-1.787 2.206-3.565 4.223-1.778 2.016-2.571 3.666-1.763 3.666s2.72-1.591 4.25-3.536zm-77.644-1.745c-.82-2.172-1.74-3.7-2.045-3.396-.951.952 1.088 7.345 2.343 7.345.656 0 .522-1.777-.298-3.95zm82.303-27.915c-.837-.837-3.217 2.55-3.184 4.53.012.734.896.178 1.965-1.235 1.07-1.413 1.618-2.896 1.219-3.295zm-66.238-36.904c-1.312-1.312-3.676.702-3.676 3.133 0 2.035.175 2.031 2.254-.047 1.24-1.24 1.88-2.628 1.422-3.086zm39.657.768c4.403-2.196 6.8-3.986 5.333-3.982-2.838.01-16.667 6.028-16.667 7.254 0 1.6 3.717.527 11.333-3.272zm16.667-5.333c0-.733-.9-1.333-2-1.333s-2 .6-2 1.333.9 1.333 2 1.333 2-.6 2-1.333zm-3.334-3.923l5.334-1.104-7.334-.133c-4.033-.073-8.233.45-9.333 1.16-2.539 1.64 3.572 1.682 11.333.077zm35.738-63.976c2.788-1.69 4.765-3.376 4.393-3.748-.947-.947-11.942 5.654-14.237 8.548-1.792 2.258-1.714 2.276 1.44.329a1452.76 1452.76 0 0 1 8.403-5.13z" fill="#ffca28" stroke-width="1.333"/></symbol><symbol viewBox="0 0 400 400" fill-opacity=".05" id="bithound" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.88 0 0 .88 24.121 2.895)" fill="#e53935" fill-opacity="1"><path d="M370.5 207c-1.5-14.8-4.8-29.9-9.5-44-13.5-40.3-38.6-81.6-70.3-110.1-1.4-1.2-6.7-4.4-8.7-3.3-5.2 2.9 4.6 22.8 5.8 26.4 7.4 22 12.1 45.3 6.8 68.3-7.1 30.4-30.4 51.7-61.5 54.3-17.1 1.4-34.3-.5-51.4 1.5-25.6 3-51.7 11.8-68 32.8-1.9 2.4-3.6 5.1-5.2 7.9h-.4c-6.3.7-12.6-2-15.7-3.7-.8-.5-1.6-.9-2.2-1.2-19-10.5-33-34-41.6-53.4-3.9-9-7.2-18.4-9.3-27.9-1-4.3-1.1-8.8-1.3-13.2-.1-2.7.3-6.5-1.2-8.9-3.3-5.2-7.5-.2-8.2 4-1.1 6.9-2.1 13.7-1.8 20.7.5 11.8 3.8 23.5 8 34.5 6.2 16.2 14.9 31.1 26.2 44.4 4.7 5.5 9.7 10.6 15.1 15.3 4.8 4.3 10.9 7.7 14.5 13.2 4.2 6.3 4.9 14.1 4.5 21.4-1 19.3-1.6 37.4 3.9 56.2 4.8 16.7 10.8 33.8 20.8 48.1 5 7.1 11.2 14.6 18 19.9 4.6 3.6 13.3 4 8.3-9.2-11.1-29.3-12.1-59.7 5.2-87.1 14.5-22.8 40.1-43.1 69-39.5 42.5 5.3 72.1 44.3 70 86-.6 11.7-1 21.7-4.7 32.7-1.5 4.4-2.6 10-1.5 14.6 1.8 7.8 10.5 4.9 14.3-.2 10.3-14 21.1-27.6 30.8-42 31.6-47.2 47-101.8 41.3-158.5z"/><path d="M132.4 92.1c.7 2.3 1.4 4.8 1.9 7.5.1 1.1.4 2.3 1 3.4 2.6 6.8 8.9 10.5 14.8 14 3.6 2.2 10.1 4.3 14.1 5.9 5.2 2.1 16.4-.6 21.7-1 12.2-1 23.5-5.3 34.7 1.2-57.4 67.3-3.2 82.3 38.8 49.9 48-37 2.8-124.3 2.8-124.3s-1-6.8-19.2-10.8c-1.7-.9-3.4-1.7-5.1-2.4-18-8.3-34.2 5.3-47.2 16.4-3.8 3.2-7.5 6.4-11.5 9.4-5.4 4-11.2 7.3-17.3 10.2-6.4 3-14 6.4-21.1 6.7-1 0-2.9.2-4.9.6-3.1.3-4.7 1.1-5.4 2.5-1.2 1-2 2.4-1.8 4.2.2 2.5 1.4 4.6 2.7 6.2.4.1.7.3 1 .4z"/></g></symbol><symbol viewBox="0 0 400.00001 399.99999" id="bower" xmlns="http://www.w3.org/2000/svg"><g transform="translate(12.061 33.203) scale(.81733)"><path d="M447.61 200.08c-23.139-22.234-138.85-36.114-175.36-40.154a107.137 107.137 0 0 0 4.517-12.944 146.107 146.107 0 0 1 15.905-5.901c.677 1.997 3.865 9.648 5.682 13.279 73.415 2.025 77.184-54.557 80.17-70.058 2.92-15.157 2.771-29.802 27.953-56.575-37.516-10.933-91.467 16.945-109.54 58.437-6.79-2.545-13.597-4.424-20.328-5.586-4.824-19.46-29.944-73.672-95.863-73.672-83.46 0-174.43 68.853-174.43 185.41 0 97.976 66.891 183.84 104.68 183.84 16.505 0 30.703-12.36 34.036-23.44 2.795 7.597 11.368 31.213 14.184 37.225 4.162 8.89 23.41 16.583 31.833 7.357 10.83 6.017 30.703 9.641 41.534-6.405 20.86 4.412 39.3-8.026 39.702-22.868 10.235-.546 15.256-14.918 13.021-26.363-1.647-8.426-19.248-38.66-26.113-49.098 13.59 11.054 48.013 14.183 52.194.007 21.911 17.198 56.057 8.171 58.765-5.815 26.624 6.917 57.16-8.276 52.146-26.676 42.771-2.958 37.296-48.464 25.296-59.996z" fill="#543729" stroke-width=".973"/><path d="M328.514 103.025c9.212-18.277 20.788-38.234 35.409-50.58-16.093 6.485-31.981 25.873-41.375 46.595a144.914 144.914 0 0 0-14.552-8.132c13.105-27.972 43.555-51.332 77.112-53.157-22.477 20.385-14.498 62.754-32.979 85.183-5.288-5.311-17.43-15.562-23.615-19.909zm-14.53 29.762c.01-.7.272-6.094.763-8.557-1.288-.304-9.3-1.87-13.476-1.772-.304 5.245 2.204 14.17 4.684 19.541 17.075-.358 29.408-5.471 36.667-10.172-6.18-2.88-16.726-5.442-24.745-6.974-.894 1.851-3.097 6.568-3.892 7.934z" fill="#00acee"/><g stroke-width=".973"><path d="M250.54 277.39c.004.024.015.057.018.082-2.165-4.657-4.463-10.314-7.208-17.708 10.688 15.557 44.184 7.533 42.427-6.407 16.395 12.336 50.143-2.055 42.471-19.353 16.423 7.653 35.168-7.745 30.964-14.455 28 5.4 54.832 10.783 63.256 12.938-5.595 9.123-18.339 15.566-37.549 11.089 10.38 14.14-9.773 31.105-37.844 21.76 6.18 13.883-18.814 26.38-47.22 11.91.361 13.889-35.24 15.488-49.315.143zm55.543-70.194c32.497 2.495 86.238 7.34 119.51 11.997-2.102-10.828-7.844-13.921-25.905-18.772-19.425 2.072-68.706 6.913-93.604 6.776z" fill="#2baf2b"/><path d="M285.78 253.36c16.395 12.336 50.143-2.055 42.471-19.353 16.423 7.653 35.168-7.745 30.964-14.455-33.103-6.383-67.84-12.788-75.719-13.908 4.78.254 12.702.797 22.59 1.556 24.899.137 74.18-4.704 93.604-6.775-31.452-7.975-95.666-19.613-140.01-22.48-2.055 3.003-5.833 8.097-12.413 13.51-19.403 41.053-54.557 68.34-93.454 68.34-11.335 0-24.018-1.912-38.233-6.456-8.865 9.497-46.661 16.694-77.329 1.641 24.326 56.961 80.74 94.984 143.19 94.984 52.591 0 75.912-53.704 70.808-67.914-1.238-3.45-6.145-14.889-8.891-22.283 10.689 15.556 44.185 7.532 42.429-6.408z" fill="#ffcc2f"/><path d="M253.91 145.27c4.644-2.526 20.69-12.253 35.981-15.908a67.843 67.843 0 0 1-.536-5.12c-10.032 2.403-28.945 10.51-39.784-.661 22.866 6.9 34.283-6.149 51.09-6.149 10.014 0 24.305 2.798 35.57 7.22-9.061-8.37-38.772-33.63-75.558-33.717-8.213 9.957-17.09 31.526-6.764 54.334z" fill="#cecece"/><path d="M115.58 253.33c14.215 4.544 26.898 6.457 38.233 6.457 38.896 0 74.05-27.29 93.454-68.341-14.351 11.978-39.291 22.228-78.241 22.228 34.694-7.866 64.56-25.156 79.753-50.427-10.68-16.998-22.263-54.603 7.07-84.33-4.512-14.497-26.475-52.766-75.095-52.766-84.85 0-155.17 71.001-155.17 166.15 0 22.525 4.547 43.65 12.67 62.664 30.666 15.054 68.462 7.858 77.327-1.64z" fill="#ef5734"/><path d="M141.03 108.45c0 21.644 17.546 39.191 39.19 39.191s39.192-17.548 39.192-39.191c0-21.644-17.548-39.191-39.192-39.191-21.644 0-39.19 17.547-39.19 39.191z" fill="#ffcc2f"/><path d="M156.76 108.45c0 12.958 10.507 23.463 23.463 23.463 12.96 0 23.464-10.506 23.464-23.463 0-12.959-10.504-23.464-23.464-23.464-12.957 0-23.463 10.506-23.463 23.464z" fill="#543729"/><ellipse cx="180.22" cy="98.044" rx="13.673" ry="8.501" fill="#fff"/></g></g></symbol><symbol viewBox="0 0 140 140" id="browserlist" xmlns="http://www.w3.org/2000/svg"><title>Browserslist logo</title><path d="M70.314 10.066a59.828 59.828 0 0 0-59.828 59.828 59.828 59.828 0 0 0 59.828 59.828 59.828 59.828 0 0 0 59.828-59.828 59.828 59.828 0 0 0-59.828-59.828zm-4.836 8.785c.496 4.043 1.352 7.322 2.572 10.223 4.779-4.287 10.265-7.546 16.041-9.02-.981 3.938-1.357 7.295-1.261 10.43 6.026-2.314 12.349-3.404 18.3-2.706-3.182 2.413-5.482 4.717-7.128 7.015-2.201 12.074 6.858 20.43 14.779 24.551a5.128 5.128 0 0 1 5.183-3.888 5.128 5.128 0 0 1 3.7 8.435v.002c-.487 1.055-2.002 2.343-3.497 3.219-4.075 2.39-11.172 5.736-20.914 7.39.045 1.214.077 2.453.077 3.747 0 4.817-.485 8.291-1.385 10.699-3.3 13.313-12.648 26.76-24.695 31.95.357-4.083.197-7.485-.402-10.591-5.582 3.218-11.646 5.278-17.623 5.52h-.002c1.785-3.662 2.855-6.878 3.412-9.976-6.347.996-12.727.742-18.377-1.17 2.93-2.732 5.054-5.314 6.673-7.96-6.292-1.344-12.169-3.87-16.766-7.686 3.822-1.544 6.795-3.239 9.3-5.197-5.426-3.517-10.034-7.998-12.972-13.23 4.012-.07 7.321-.568 10.3-1.453-3.786-5.215-6.468-11.032-7.333-16.951 3.861 1.405 7.196 2.133 10.36 2.355-1.662-6.22-2.081-12.605-.768-18.436 3.03 2.634 5.824 4.48 8.63 5.815.678-6.406 2.576-12.52 5.893-17.496 1.926 3.622 3.914 6.391 6.111 8.672 2.93-5.754 6.9-10.798 11.791-14.262zm26.465 19.557c-2.395 5.514-1.665 11.297-.555 18.732a2.138 2.138 0 0 0 .28-4.178 3.419 3.419 0 1 1 .092 6.704c.574 3.882 1.157 8.18 1.421 13.125a67.143 67.143 0 0 0 3.25-.649c6.616-1.487 12.258-3.801 16.871-6.506.45-.264.884-.563 1.276-.867.366-.557.333-.957.035-1.285-4.831-1.245-10.891-4.53-15.258-8.795-4.764-4.653-7.428-10.164-7.412-16.281z" fill="#ffca28" stroke-width=".855"/></symbol><symbol viewBox="0 0 140 140" id="browserlist_light" xmlns="http://www.w3.org/2000/svg"><title>Browserslist logo</title><g transform="translate(10.823 10.1)" stroke-width=".855"><circle cx="59.492" cy="59.795" r="59.828" fill="#ffca28"/><path d="M54.656 8.752c-4.89 3.464-8.862 8.508-11.791 14.262-2.198-2.28-4.185-5.05-6.111-8.672-3.318 4.976-5.216 11.09-5.893 17.496-2.807-1.335-5.6-3.18-8.63-5.814-1.314 5.83-.895 12.216.767 18.436-3.164-.223-6.498-.95-10.36-2.356.865 5.92 3.548 11.737 7.333 16.951-2.978.885-6.287 1.383-10.3 1.453 2.939 5.233 7.547 9.714 12.972 13.23-2.505 1.959-5.478 3.654-9.299 5.198 4.596 3.815 10.474 6.341 16.766 7.685-1.62 2.647-3.743 5.228-6.674 7.96 5.65 1.912 12.03 2.166 18.377 1.17-.556 3.098-1.626 6.314-3.412 9.975h.002c5.977-.24 12.042-2.3 17.623-5.52.6 3.108.76 6.51.402 10.593 12.047-5.19 21.395-18.638 24.695-31.951.9-2.408 1.385-5.881 1.385-10.7 0-1.293-.031-2.531-.076-3.745 9.742-1.655 16.839-5.001 20.914-7.39 1.494-.877 3.01-2.165 3.496-3.22v-.002a5.128 5.128 0 0 0-3.7-8.435 5.128 5.128 0 0 0-5.183 3.889c-7.92-4.122-16.98-12.477-14.779-24.551 1.646-2.299 3.947-4.603 7.13-7.016-5.952-.698-12.276.392-18.302 2.707-.095-3.135.28-6.492 1.262-10.43-5.776 1.473-11.262 4.733-16.041 9.02-1.22-2.902-2.076-6.18-2.572-10.223zm26.465 19.557c-.015 6.117 2.648 11.628 7.412 16.281 4.366 4.265 10.426 7.55 15.258 8.795.298.328.331.728-.035 1.285-.392.304-.825.603-1.275.867-4.613 2.704-10.256 5.019-16.871 6.506-1.071.24-2.154.458-3.25.649-.265-4.945-.848-9.243-1.422-13.125a3.419 3.419 0 1 0-.092-6.703 2.138 2.138 0 0 1-.28 4.177c-1.11-7.435-1.84-13.218.555-18.732z" fill="#37474f"/></g></symbol><symbol viewBox="0 0 24 24" id="bucklescript" xmlns="http://www.w3.org/2000/svg"><path d="M3 3v18h18V3H3zm14.1 8.858a5.5 5.5 0 0 1 1.26.145c.417.093.778.213 1.082.357v1.723h-.18a3.281 3.281 0 0 0-.959-.603 2.867 2.867 0 0 0-1.155-.247c-.14 0-.277.011-.416.035a1.4 1.4 0 0 0-.395.12.756.756 0 0 0-.291.231.54.54 0 0 0-.123.348c0 .198.065.35.196.456.13.104.376.2.738.288.237.057.466.11.683.164.22.054.455.128.706.222.496.188.86.444 1.095.77.238.32.357.738.357 1.253 0 .737-.271 1.336-.813 1.798-.538.46-1.27.689-2.197.689a5.447 5.447 0 0 1-1.402-.161 6.725 6.725 0 0 1-1.117-.416v-1.794h.183c.344.318.73.563 1.155.734.429.17.839.256 1.233.256.1 0 .235-.01.4-.03.166-.02.3-.055.403-.102a.97.97 0 0 0 .313-.225c.084-.09.127-.223.127-.4a.568.568 0 0 0-.183-.424c-.119-.12-.294-.213-.526-.276-.243-.067-.5-.128-.773-.185a5.523 5.523 0 0 1-.76-.227c-.544-.204-.936-.48-1.177-.828-.237-.351-.357-.786-.357-1.305 0-.697.27-1.265.81-1.703.54-.442 1.235-.663 2.083-.663zm-8.981.135h2.51c.521 0 .903.02 1.143.06.243.041.484.13.721.266.246.144.43.338.548.583.121.24.181.518.181.83 0 .36-.082.68-.247.959a1.697 1.697 0 0 1-.7.642v.04c.423.098.758.298 1.004.603.249.305.373.706.373 1.205 0 .361-.063.686-.19.97-.125.285-.296.52-.516.707a2.31 2.31 0 0 1-.845.472c-.304.094-.69.141-1.159.141H8.12v-7.478zm1.659 1.372v1.582h.262c.263 0 .486-.007.672-.017.185-.01.332-.043.44-.1.15-.077.248-.175.294-.295.046-.124.07-.266.07-.427a.91.91 0 0 0-.083-.371.518.518 0 0 0-.282-.277 1.187 1.187 0 0 0-.456-.086c-.18-.007-.433-.01-.76-.01h-.157zm0 2.873V18.1H9.9c.469 0 .804-.002 1.007-.006.202-.003.39-.046.56-.13a.712.712 0 0 0 .357-.33c.067-.142.099-.302.099-.483 0-.237-.04-.42-.121-.547-.078-.13-.214-.228-.405-.291a1.842 1.842 0 0 0-.538-.072 49.47 49.47 0 0 0-.716-.003h-.366z" fill="#26a69a" stroke-width="1.067"/></symbol><symbol viewBox="0 0 24 24" id="c" xmlns="http://www.w3.org/2000/svg"><path d="M15.45 15.97l.42 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96-1.14-1.27-1.68-2.88-1.68-4.83C6 9.9 6.68 8.13 8 6.89 9.28 5.64 10.92 5 12.9 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.6 2.49-1.04-.34c-.4-.1-.87-.15-1.4-.15-1.15-.01-2.11.36-2.86 1.1-.76.73-1.14 1.85-1.18 3.34.01 1.36.37 2.42 1.08 3.2.71.77 1.7 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.09-.32z" fill="#0277bd"/></symbol><symbol viewBox="0 0 300 300" id="cabal" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -822.52)" fill-rule="evenodd" color="#000"><rect transform="matrix(-.98339 .18149 .60192 .79856 0 0)" x="405.55" y="967.22" width="107.25" height="156.59" rx="12.306" ry="12.31" fill="#2d9bbd"/><rect transform="matrix(-.98528 .17093 -.59175 .80612 0 0)" x="-1156.5" y="1461.9" width="108.34" height="123.15" rx="10.69" ry="12.31" fill="#4a4bcd"/><path d="M52.112 965.158c-1.343 3.515-26.292 23.248-25.744 27.277.548 4.03 29.812 16.023 32.04 19.027s10.545 41.668 13.603 42.5 18.828-31.274 21.548-32.932c2.72-1.658 32.808 2.503 34.15-1.01 1.343-3.515-18.174-35.352-18.721-39.381-.548-4.03 9.732-40.12 7.502-43.125-2.229-3.005-30.06 9.427-33.118 8.594-3.059-.833-26.793-27.3-29.514-25.643-2.72 1.657-.405 41.177-1.747 44.693z" fill="#2e5bc1"/></g></symbol><symbol viewBox="0 0 24 24" id="cake" xmlns="http://www.w3.org/2000/svg"><path d="M12.254 6.621a1.807 1.807 0 0 0 1.808-1.807c0-.344-.09-.66-.262-.932l-1.546-2.684-1.546 2.684a1.72 1.72 0 0 0-.262.932 1.808 1.808 0 0 0 1.808 1.807m4.158 9.04l-.967-.976-.976.976c-1.175 1.166-3.236 1.175-4.42 0l-.959-.976-.994.976a3.134 3.134 0 0 1-3.977.353v4.167a.904.904 0 0 0 .904.904h14.463a.904.904 0 0 0 .904-.904v-4.167a3.134 3.134 0 0 1-3.977-.353m1.265-6.328h-4.52V7.525H11.35v1.808H6.83a2.712 2.712 0 0 0-2.711 2.712v1.392c0 .977.795 1.772 1.771 1.772.489 0 .94-.18 1.248-.515l1.952-1.926 1.908 1.926c.669.669 1.835.669 2.504 0l1.916-1.926 1.944 1.926c.316.334.768.515 1.247.515.976 0 1.78-.795 1.78-1.772v-1.392a2.712 2.712 0 0 0-2.711-2.712z" fill="#ff7043" stroke-width=".904"/></symbol><symbol viewBox="0 0 24 24" id="certificate" xmlns="http://www.w3.org/2000/svg"><path d="M4 3c-1.11 0-2 .89-2 2v10a2 2 0 0 0 2 2h8v5l3-3 3 3v-5h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4m8 2l3 2 3-2v3.5l3 1.5-3 1.5V15l-3-2-3 2v-3.5L9 10l3-1.5V5M4 5h5v2H4V5m0 4h3v2H4V9m0 4h5v2H4v-2z" fill="#ff5722"/></symbol><symbol viewBox="0 0 24 24" id="changelog" xmlns="http://www.w3.org/2000/svg"><path d="M11 7v5.11l4.71 2.79.79-1.28-4-2.37V7m0-5C8.97 2 5.91 3.92 4.27 6.77L2 4.5V11h6.5L5.75 8.25C6.96 5.73 9.5 4 12.5 4a7.5 7.5 0 0 1 7.5 7.5 7.5 7.5 0 0 1-7.5 7.5c-3.27 0-6.03-2.09-7.06-5h-2.1c1.1 4.03 4.77 7 9.16 7 5.24 0 9.5-4.25 9.5-9.5A9.5 9.5 0 0 0 12.5 2z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 24 24" id="clojure" xmlns="http://www.w3.org/2000/svg"><path d="M3.355 1.78c-.845 0-1.525.68-1.525 1.525v17.441c0 .845.68 1.525 1.525 1.525h17.442c.845 0 1.525-.68 1.525-1.525V3.305c0-.845-.68-1.526-1.525-1.526H3.355zm6.168 2.572h1.963l6.368 14.931H15.93l-3.38-8.086-3.349 8.086H7.21l4.346-10.38-2.032-4.551z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="cmake" xmlns="http://www.w3.org/2000/svg"><path d="M11.99 2.965L2.977 20.999l9.874-8.47-.863-9.564z" fill="#1e88e5"/><path d="M12.007 2.963l.002.29 1.312 14.498-.001.006.023.26 7.362 2.979h.416l-.158-.311-.114-.228h-.002l-8.84-17.494z" fill="#e53935"/><path d="M8.607 16.11L2.98 20.995h17.743v-.016L8.607 16.11z" fill="#7cb342"/></symbol><symbol class="bfmain_logo__svg" viewBox="0 0 300 300.00001" id="code-climate" xmlns="http://www.w3.org/2000/svg"><path class="bfsymbol" d="M196.19 75.562l-51.846 51.561 30.766 30.766 21.08-21.08 59.252 59.537 30.481-30.766zm-61.246 60.961l-30.481-30.481-78.053 78.053-11.964 11.964 30.766 30.766 11.964-12.249 39.596-39.312 7.691-7.691 30.481 30.48 28.772 28.773 30.766-30.766-28.772-28.772z" fill="#eee" stroke-width="2.849"/></symbol><symbol class="bgmain_logo__svg" viewBox="0 0 300 300.00001" id="code-climate_light" xmlns="http://www.w3.org/2000/svg"><path class="bgsymbol" d="M196.19 75.562l-51.846 51.561 30.766 30.766 21.08-21.08 59.252 59.537 30.481-30.766zm-61.246 60.961l-30.481-30.481-78.053 78.053-11.964 11.964 30.766 30.766 11.964-12.249 39.596-39.312 7.691-7.691 30.481 30.48 28.772 28.773 30.766-30.766-28.772-28.772z" fill="#455a64" stroke-width="2.849"/></symbol><symbol viewBox="0 0 24 24" id="coffee" xmlns="http://www.w3.org/2000/svg"><path d="M2 21h18v-2H2M20 8h-2V5h2m0-2H4v10a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4v-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="coldfusion" xmlns="http://www.w3.org/2000/svg"><rect transform="rotate(90)" x="2.283" y="-21.86" width="19.487" height="19.487" ry="0" fill="#0d3858" stroke="#4dd0e1" stroke-width=".7"/><text x="6.653" y="16.426" fill="#4dd0e1" font-family="Calibri" font-size="29.001" font-weight="bold" letter-spacing="0" stroke-width=".725" word-spacing="0" style="line-height:1.25"><tspan x="6.653" y="16.426" font-family="'Segoe UI'" font-size="10.634" font-weight="normal">C<tspan font-size="11.844">f</tspan></tspan></text></symbol><symbol viewBox="0 0 24 24" id="conduct" xmlns="http://www.w3.org/2000/svg"><path d="M10 17l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9m-6-6a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#cddc39"/></symbol><symbol viewBox="0 0 24 24" id="console" xmlns="http://www.w3.org/2000/svg"><path d="M20 19V7H4v12h16m0-16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16m-7 14v-2h5v2h-5m-3.42-4L5.57 9H8.4l3.3 3.3c.39.39.39 1.03 0 1.42L8.42 17H5.59z" fill="#ff7043"/></symbol><symbol viewBox="0 0 24 24" id="contributing" xmlns="http://www.w3.org/2000/svg"><path d="M17 9H7V7h10m0 6H7v-2h10m-3 6H7v-2h7M12 3a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="cpp" xmlns="http://www.w3.org/2000/svg"><path d="M10.5 15.97l.41 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96C1.56 15.77 1 14.16 1 12.21c.05-2.31.72-4.08 2-5.32C4.32 5.64 5.96 5 7.94 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.58 2.49-1.06-.34c-.4-.1-.86-.15-1.39-.15-1.16-.01-2.12.36-2.87 1.1-.76.73-1.15 1.85-1.18 3.34 0 1.36.37 2.42 1.08 3.2.71.77 1.71 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.1-.32M11 11h2V9h2v2h2v2h-2v2h-2v-2h-2v-2m7 0h2V9h2v2h2v2h-2v2h-2v-2h-2v-2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="credits" xmlns="http://www.w3.org/2000/svg"><path d="M3 3h18v2H3V3m4 4h10v2H7V7m-4 4h18v2H3v-2m4 4h10v2H7v-2m-4 4h18v2H3v-2z" fill="#9ccc65"/></symbol><symbol viewBox="0 0 200 200" id="crystal" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:none}</style><path d="M179.363 121.67l-57.623 57.507c-.23.23-.576.346-.806.23l-78.713-21.09c-.346-.115-.577-.345-.577-.576L20.44 79.144c-.115-.345 0-.576.23-.806L78.294 20.83c.23-.23.576-.346.807-.23l78.713 21.09c.345.114.576.345.576.575l21.09 78.597c.23.346.115.577-.115.807zM102.148 59.09l-77.33 20.63c-.115 0-.23.23-.115.345l56.586 56.47c.115.115.346.115.346-.115l20.744-77.215c.115 0-.115-.23-.23-.116z" stroke-width="1.153" fill="#cfd8dc"/></symbol><symbol viewBox="0 0 200 200" id="crystal_light" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:none}</style><path d="M179.363 121.67l-57.623 57.507c-.23.23-.576.346-.806.23l-78.713-21.09c-.346-.115-.577-.345-.577-.576L20.44 79.144c-.115-.345 0-.576.23-.806L78.294 20.83c.23-.23.576-.346.807-.23l78.713 21.09c.345.114.576.345.576.575l21.09 78.597c.23.346.115.577-.115.807zM102.148 59.09l-77.33 20.63c-.115 0-.23.23-.115.345l56.586 56.47c.115.115.346.115.346-.115l20.744-77.215c.115 0-.115-.23-.23-.116z" fill="#37474f" stroke-width="1.153"/></symbol><symbol viewBox="0 0 24 24" id="csharp" xmlns="http://www.w3.org/2000/svg"><path d="M11.5 15.97l.41 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96C2.56 15.77 2 14.16 2 12.21c.05-2.31.72-4.08 2-5.32C5.32 5.64 6.96 5 8.94 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.58 2.49-1.06-.34c-.4-.1-.86-.15-1.39-.15-1.16-.01-2.12.36-2.87 1.1-.76.73-1.15 1.85-1.18 3.34 0 1.36.37 2.42 1.08 3.2.71.77 1.71 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.1-.32M13.89 19l.61-4H13l.34-2h1.5l.32-2h-1.5L14 9h1.5l.61-4h2l-.61 4h1l.61-4h2l-.61 4H22l-.34 2h-1.5l-.32 2h1.5L21 15h-1.5l-.61 4h-2l.61-4h-1l-.61 4h-2m2.95-6h1l.32-2h-1l-.32 2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="css" xmlns="http://www.w3.org/2000/svg"><path d="M5 3l-.65 3.34h13.59L17.5 8.5H3.92l-.66 3.33h13.59l-.76 3.81-5.48 1.81-4.75-1.81.33-1.64H2.85l-.79 4 7.85 3 9.05-3 1.2-6.03.24-1.21L21.94 3H5z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="css-map" xmlns="http://www.w3.org/2000/svg"><path d="M18 8v2h2v10H10v-2H8v4h14V8h-4z" fill="#42a5f5"/><path d="M4.676 3l-.488 2.51h10.211l-.33 1.623H3.864l-.496 2.502H13.58l-.57 2.863-4.119 1.36-3.569-1.36.248-1.232H3.06l-.593 3.005 5.898 2.254 6.8-2.254.902-4.53.18-.91L17.406 3H4.675z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 33 33" id="cucumber" xmlns="http://www.w3.org/2000/svg"><title>cucumber-mark-transparent-pips</title><g transform="translate(0 -5)" fill="none" fill-rule="evenodd"><path d="M-4-1h40v40H-4z"/><path d="M16.641 7.092c-7.028 0-12.714 5.686-12.714 12.714 0 6.187 4.435 11.327 10.288 12.471v3.64C21.824 34.77 28.561 28.73 29.063 20.8c.303-4.772-2.076-9.644-6.09-12.01a10.575 10.575 0 0 0-1.455-.728l-.243-.097c-.223-.082-.448-.175-.68-.242a12.614 12.614 0 0 0-3.954-.632zm2.62 4.707a1.387 1.387 0 0 0-1.213.485c-.233.31-.379.611-.534.923-.466 1.087-.31 2.251.388 3.105 1.087-.233 2.01-.927 2.475-2.014a2.45 2.45 0 0 0 .243-1.02c.048-.824-.634-1.404-1.359-1.479zm-5.654.073c-.708.068-1.382.63-1.382 1.407 0 .31.087.709.243 1.02.466 1.086 1.46 1.78 2.546 2.013.621-.854.782-2.018.316-3.105-.155-.311-.3-.617-.534-.85a1.364 1.364 0 0 0-1.188-.485zm-3.809 3.735c-1.224.063-1.77 1.602-.752 2.402.31.233.612.403.922.559 1.087.466 2.344.306 3.275-.316-.233-1.009-1.023-1.936-2.11-2.402-.388-.155-.703-.243-1.092-.243-.087-.009-.161-.004-.243 0zm11.961 4.708a3.551 3.551 0 0 0-2.013.582c.233 1.01 1.023 1.936 2.11 2.401.389.156.705.244 1.093.244 1.397.077 2.08-1.65.994-2.427-.31-.233-.611-.379-.922-.534a3.354 3.354 0 0 0-1.262-.266zm-10.603.072a3.376 3.376 0 0 0-1.261.267c-.389.155-.69.325-.923.558-1.009.854-.33 2.48 1.068 2.402.388 0 .782-.087 1.092-.243 1.087-.465 1.859-1.392 2.014-2.401a3.474 3.474 0 0 0-1.99-.582zm3.931 2.378c-1.087.233-2.009.927-2.475 2.014-.155.31-.243.684-.243.995-.077 1.32 1.724 2.028 2.5 1.02.233-.312.378-.613.534-.923.466-1.01.306-2.174-.316-3.106zm2.887.073c-.621.854-.781 2.019-.315 3.106.155.31.3.615.534.848.854.932 2.65.243 2.572-.921 0-.31-.088-.71-.243-1.02-.466-1.087-1.46-1.78-2.547-2.013z" fill="#4caf50" stroke-width=".776"/></g></symbol><symbol id="cuda" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><style>.bust0{fill:#76b900}</style><title>NVIDIA-Logo</title><path id="buEye_Mark" class="bust0" d="M76.362 75.199V64.116c1.095-.068 2.19-.137 3.284-.137 30.377-.958 50.286 26.135 50.286 26.135s-21.483 29.83-44.539 29.83c-3.079 0-6.089-.48-8.962-1.438v-33.66c11.836 1.436 14.23 6.636 21.277 18.471l15.804-13.273s-11.562-15.12-30.992-15.12c-2.053-.068-4.105.069-6.158.274m0-36.67v16.556l3.284-.205c42.213-1.437 69.784 34.618 69.784 34.618s-31.608 38.45-64.516 38.45c-2.873 0-5.678-.274-8.483-.753v10.262c2.326.274 4.72.48 7.046.48 30.65 0 52.817-15.668 74.3-34.14 3.558 2.874 18.13 9.784 21.14 12.794-20.388 17.104-67.937 30.856-94.893 30.856-2.6 0-5.062-.137-7.525-.41v14.436h116.44V38.532zm0 79.977v8.757C48.038 122.2 40.17 92.712 40.17 92.712s13.615-15.05 36.192-17.514v9.579h-.068c-11.836-1.437-21.14 9.646-21.14 9.646s5.268 18.678 21.209 24.082M26.077 91.481S42.839 66.714 76.43 64.115v-9.03C39.213 58.094 7.057 89.565 7.057 89.565s18.199 52.68 69.305 57.47v-9.579c-37.492-4.652-50.286-45.975-50.286-45.975z" fill="#8bc34a" stroke-width=".684"/></symbol><symbol viewBox="0 0 24 24" id="dart" xmlns="http://www.w3.org/2000/svg"><title>Dart</title><path d="M12.486 1.385a.978.978 0 0 0-.682.281l-.01.007-6.387 3.692 6.371 6.372v.004l7.659 7.659 1.46-2.63-5.265-12.64-2.456-2.457a.972.972 0 0 0-.69-.288z" fill="#00ca94"/><path d="M5.422 5.35L1.73 11.733l-.007.01a.967.967 0 0 0 .006 1.371l3.059 3.061 11.963 4.706 2.704-1.502-.073-.073-.018.002-7.5-7.512h-.01L5.423 5.35z" fill="#1565c0"/><path d="M5.405 5.353l6.518 6.525h.01l7.502 7.51 2.855-.544.005-8.449-3.016-2.955c-.66-.647-1.675-1.064-2.695-1.202l.002-.032-11.181-.853z" fill="#1565c0"/><path d="M5.414 5.361l6.521 6.522v.009l7.506 7.506-.546 2.855h-8.448l-2.954-3.017c-.647-.66-1.064-1.676-1.2-2.696l-.033.003L5.414 5.36z" fill="#00ee94"/></symbol><symbol viewBox="0 0 24 24" id="database" xmlns="http://www.w3.org/2000/svg"><path d="M12 3C7.58 3 4 4.79 4 7s3.58 4 8 4 8-1.79 8-4-3.58-4-8-4M4 9v3c0 2.21 3.58 4 8 4s8-1.79 8-4V9c0 2.21-3.58 4-8 4s-8-1.79-8-4m0 5v3c0 2.21 3.58 4 8 4s8-1.79 8-4v-3c0 2.21-3.58 4-8 4s-8-1.79-8-4z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="diff" xmlns="http://www.w3.org/2000/svg"><path d="M3 1c-1.11 0-2 .89-2 2v11c0 1.11.89 2 2 2h2v-2H3V3h11v2h2V3c0-1.11-.89-2-2-2H3m6 6c-1.11 0-2 .89-2 2v2h2V9h2V7H9m4 0v2h1v1h2V7h-3m5 0v2h2v11H9v-2H7v2c0 1.11.89 2 2 2h11c1.11 0 2-.89 2-2V9c0-1.11-.89-2-2-2h-2m-4 5v2h-2v2h2c1.11 0 2-.89 2-2v-2h-2m-7 1v3h3v-2H9v-1H7z" fill="#42a5f5"/></symbol><symbol id="docker" viewBox="0 0 41 34.5" xmlns="http://www.w3.org/2000/svg"><style id="bystyle2">.byst0{fill:#fff}.byst1{clip-path:url(#bySVGID_4_)}</style><g id="byg34" transform="translate(.292 1.9)" fill="#0087c9"><g id="byg32"><g id="byg30"><title id="bytitle4">Group 3</title><g id="byg28"><g id="byg26"><g id="byg9"><path id="bySVGID_1_" class="byst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2c1.2 0 2.1.9 2.1 2s-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></g><g id="byg24"><defs id="bydefs12"><path id="bySVGID_2_" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2c1.2 0 2.1.9 2.1 2s-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></defs><clipPath id="bySVGID_4_"><use xlink:href="#bySVGID_2_" id="byuse14" width="100%" height="100%" overflow="visible"/></clipPath><g class="byst1" clip-path="url(#bySVGID_4_)" id="byg22"><g id="byg20"><g id="byg18"><path id="bySVGID_3_" class="byst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></g></g></g></g></g></g></g></symbol><symbol viewBox="0 0 24 24" id="document" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m9 16v-2H6v2h9m3-4v-2H6v2h12z" fill="#42a5f5"/></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" id="drone" xmlns="http://www.w3.org/2000/svg"><g fill="#e0e0e0" transform="translate(9.063 22.346) scale(.71044)"><path d="M128.22.723C32.095.723.39 84.566.39 115.222h77.928S89.36 75.275 128.22 75.275s49.906 39.947 49.906 39.947h77.476c0-30.66-31.257-114.5-127.38-114.5m98.82 134.45h-48.914s-8.55 39.946-49.906 39.946c-41.355 0-49.902-39.948-49.902-39.948H30.255c0 10.25 37.727 82.708 98.443 82.708 60.714 0 98.344-59.604 98.344-82.708"/><circle cx="128" cy="126.08" r="32.768"/></g></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" id="drone_light" xmlns="http://www.w3.org/2000/svg"><g fill="#424242" transform="translate(9.063 22.346) scale(.71044)"><path d="M128.22.723C32.095.723.39 84.566.39 115.222h77.928S89.36 75.275 128.22 75.275s49.906 39.947 49.906 39.947h77.476c0-30.66-31.257-114.5-127.38-114.5m98.82 134.45h-48.914s-8.55 39.946-49.906 39.946c-41.355 0-49.902-39.948-49.902-39.948H30.255c0 10.25 37.727 82.708 98.443 82.708 60.714 0 98.344-59.604 98.344-82.708"/><circle cx="128" cy="126.08" r="32.768"/></g></symbol><symbol viewBox="0 0 3473 3473" id="editorconfig" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><defs id="ccdefs4"><style id="ccstyle2">.ccfil2{fill:#020202}.ccfil0{fill:#e3e3f8}.ccfil5{fill:#efefef}.ccfil6{fill:#faf1f1}.ccfil3{fill:#fdf2f2}.ccfil1{fill:#fdfdfd}.ccfil4{fill:#fef3f3}</style></defs><g id="ccLayer_x0020_1" transform="matrix(.8945 0 0 .8945 138.649 275.985)"><g id="cc_631799120"><g id="ccg11"><path class="ccfil0" d="M967 1895c46-30 84-105 61-158-63 27-60 89-61 158z" id="ccpath7" fill="#e3e3f8"/><path class="ccfil0" d="M1679 2067c50-16 98-72 71-130-39 27-64 64-71 130z" id="ccpath9" fill="#e3e3f8"/></g><g id="ccg21"><path class="ccfil1" d="M280 2895c0 63 16 131 60 155 162 91 730 20 923-23 101-23 183-98 278-139 214-93 369-168 540-293 124-91 321-347 342-500l-169-38c-4 172-43 211-196 251-103 28-304 34-409 16-139-23-202-96-265-179-122-162 27-275-166-286-203 249-561 70-718 45-67 97-224 727-222 871 97-33 158 3 245 37 308 119 39 224-84 193-84-20-110-75-159-110z" id="ccpath13" fill="#fdfdfd"/><path class="ccfil1" d="M683 1458c125 24 236 76 342 129 173 86 204 74 220 194 2 22-2 34 61 54 106 33-61-26 223-25 169 1 556 69 681 148 52 33 42 75 218 70-2-207-57-516-138-706-99-230-230-265-497-351-156-50-614-105-756-17-133 83-158 182-282 356-36 51-49 90-72 148z" id="ccpath15" fill="#fdfdfd"/><path class="ccfil1" d="M1784 1883c100 41-5 306-144 242-45-127 62-199 91-256-60-9-231-36-282-17-66 25-81 166-47 232 160 314 867 247 792 3-30-99-58-115-159-149-81-27-162-55-251-55z" id="ccpath17" fill="#fdfdfd"/><path class="ccfil1" d="M527 1848c80 77 261 89 378 95 15-155 28-271 152-262 61 83 29 181-35 244 109-1 172-83 156-202-92-66-371-198-511-217-39 42-135 272-140 342z" id="ccpath19" fill="#fdfdfd"/></g><path class="ccfil2" d="M339 2838c66-6 238 44 252 100-107 13-243 3-252-100zm-59 57c49 35 75 90 159 110 123 31 392-74 84-193-87-34-148-70-245-37-2-144 155-774 222-871 157 25 515 204 718-45 193 11 44 124 166 286 63 83 126 156 265 179 105 18 306 12 409-16 153-40 192-79 196-251l169 38c-21 153-218 409-342 500-171 125-326 200-540 293-95 41-177 116-278 139-193 43-761 114-923 23-44-24-60-92-60-155zm1399-828c7-66 32-103 71-130 27 58-21 114-71 130zm105-184c89 0 170 28 251 55 101 34 129 50 159 149 75 244-632 311-792-3-34-66-19-207 47-232 51-19 222 8 282 17-29 57-136 129-91 256 139 64 244-201 144-242zm-817 12c1-69-2-131 61-158 23 53-15 128-61 158zm-440-47c5-70 101-300 140-342 140 19 419 151 511 217 16 119-47 201-156 202 64-63 96-161 35-244-124-9-137 107-152 262-117-6-298-18-378-95zm-100-80c-37-102-37-261 120-274l-80 223c-21 48-21 37-40 51zm256-310c23-58 36-97 72-148 124-174 149-273 282-356 142-88 600-33 756 17 267 86 398 121 497 351 81 190 136 499 138 706-176 5-166-37-218-70-125-79-512-147-681-148-284-1-117 58-223 25-63-20-59-32-61-54-16-120-47-108-220-194-106-53-217-105-342-129zm1770-49c-19-63 16-59 77-102 35-25 63-51 106-75 161-90 461-105 589 2 52 43 137 127 124 237-27 219-177 339-300 439-125 102-333 207-548 137-18-44-4-323-25-426-19-92-9-102 44-157 156-162 494-280 686-141 81 60 58 83 100 129 52-56-45-244-403-232-243 8-348 198-450 189zM997 840c5-139 133-427 261-527 155-120 317-233 555-98 59 33 56 50 62 132 5 79-2 108-22 172-158 510-290 217-796 338 19-166 163-314 243-391 137-133 236-219 442-191 57 95 63 155-6 266-92 148-115 139-101 240 72-18 94-88 127-158 201-420-91-471-270-394-120 51-334 287-404 429-14 28-29 64-42 95zm792 21c21-125 145-156 145-541 0-166-204-315-471-204-229 94-264 166-386 350-115 174-111 365-210 526-29 46-55 62-87 108-23 34-40 77-63 117-47 77-95 133-133 225-120 3-221 5-233 129-16 170 64 212 64 276-1 69-281 765-203 1180 22 114 97 115 217 129 289 35 664 23 923-81l470-225c119-67 319-194 408-287 63-65 96-120 150-197 74-108 76-106 92-253 98 18 281 61 342 114-7 69-41 36-41 98 39 1 104-48 120-102-41-60-84-50-143-98 47-37 132-54 197-81 140-58 379-234 438-394 47-129 12-344-64-428-80-88-266-133-418-133-181 0-368 130-514 186-56-49-60-105-101-159-47-64-353-224-499-255z" id="ccpath23" fill="#020202"/><path class="ccfil3" d="M2453 1409c102 9 207-181 450-189 358-12 455 176 403 232-42-46-19-69-100-129-192-139-530-21-686 141-53 55-63 65-44 157 21 103 7 382 25 426 215 70 423-35 548-137 123-100 273-220 300-439 13-110-72-194-124-237-128-107-428-92-589-2-43 24-71 50-106 75-61 43-96 39-77 102z" id="ccpath25" fill="#fdf2f2"/><path class="ccfil4" d="M997 840l49-87c13-31 28-67 42-95 70-142 284-378 404-429 179-77 471-26 270 394-33 70-55 140-127 158-14-101 9-92 101-240 69-111 63-171 6-266-206-28-305 58-442 191-80 77-224 225-243 391 506-121 638 172 796-338 20-64 27-93 22-172-6-82-3-99-62-132-238-135-400-22-555 98-128 100-256 388-261 527z" id="ccpath27" fill="#fef3f3"/><path class="ccfil5" d="M427 1768c19-14 19-3 40-51l80-223c-157 13-157 172-120 274z" id="ccpath29" fill="#efefef"/><path class="ccfil6" d="M591 2938c-14-56-186-106-252-100 9 103 145 113 252 100z" id="ccpath31" fill="#faf1f1"/></g></g></symbol><symbol viewBox="0 0 24 24" id="elixir" xmlns="http://www.w3.org/2000/svg"><path d="M12.431 22.383c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.774-8.172 4.916-10.91 1.014-1.295 2.931-2.321 2.931-2.321s-.982 5.238 1.683 7.318c2.365 1.847 4.105 4.25 4.105 6.363 0 4.232-2.784 7.68-6.645 7.68z" fill="#9575cd" stroke-width="1.256"/></symbol><symbol viewBox="0 0 323.00001 322.99999" id="elm" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.8053 0 0 .8053 30.106 31.524)"><path fill="#f0ad00" d="M160.8 153.865l68.028-68.03H92.77z"/><path fill="#7fd13b" d="M160.983 5.098H12.033l68.524 68.525H229.51z"/><path fill="#7fd13b" stroke-width=".974" d="M243.906 88.021l74.136 74.137-74.474 74.475-74.137-74.137z"/><path fill="#60b5cc" d="M318.2 145.045V5.098H178.252z"/><path fill="#5a6378" d="M152.164 162.499L3.4 13.733v297.533z"/><path fill="#f0ad00" d="M252.205 245.27l65.995 65.996v-131.99z"/><path fill="#60b5cc" d="M160.8 171.134L12.034 319.899h297.53z"/></g></symbol><symbol viewBox="0 0 24 24" id="email" xmlns="http://www.w3.org/2000/svg"><path d="M20 8l-8 5-8-5V6l8 5 8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 30 30" id="erlang" xmlns="http://www.w3.org/2000/svg"><path style="line-height:1.25;-inkscape-font-specification:'Wide Latin'" d="M5.217 4.367c-.048.052-.097.1-.144.153C2.697 7.182 1.51 10.798 1.51 15.366c0 4.418 1.156 7.862 3.46 10.34h19.414c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52-1.454 1.381c-.866.773-.845.931-2.314 1.78-1.496.674-3.04.966-4.634.966-2.516 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.097-6.68l17.458.067-.182-1.472s-.847-7.129-2.542-9.372zm8.76.846c1.565 0 3.22.535 3.96 1.471.742.937.932 1.667.974 3.524H9.12c.111-1.955.436-2.81 1.372-3.697.937-.888 2.03-1.298 3.484-1.298z" font-weight="400" font-size="48" font-family="Wide Latin" letter-spacing="0" word-spacing="0" fill="#f44336" stroke-width=".97"/></symbol><symbol viewBox="0 0 299.99999 300.00001" id="eslint" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-2.88 18.438) scale(1.0344)"><path d="M97.021 99.016l48.432-27.962c1.212-.7 2.706-.7 3.918 0l48.433 27.962a3.92 3.92 0 0 1 1.959 3.393v55.924a3.924 3.924 0 0 1-1.959 3.394l-48.433 27.962c-1.212.7-2.706.7-3.918 0l-48.432-27.962a3.92 3.92 0 0 1-1.959-3.394v-55.924a3.922 3.922 0 0 1 1.959-3.393" fill="#7986cb"/><path d="M273.34 124.49L215.473 23.82c-2.102-3.64-5.985-6.325-10.188-6.325H89.545c-4.204 0-8.088 2.685-10.19 6.325L21.488 124.27c-2.102 3.641-2.102 8.236 0 11.877l57.867 99.847c2.102 3.64 5.986 5.501 10.19 5.501h115.74c4.203 0 8.087-1.805 10.188-5.446l57.867-100.01c2.104-3.639 2.104-7.907.001-11.547m-47.917 48.41c0 1.48-.891 2.849-2.174 3.59l-73.71 42.527a4.194 4.194 0 0 1-4.17 0l-73.767-42.527c-1.282-.741-2.179-2.109-2.179-3.59V87.847c0-1.481.884-2.849 2.167-3.59l73.707-42.527a4.185 4.185 0 0 1 4.168 0l73.772 42.527c1.283.741 2.186 2.109 2.186 3.59z" fill="#3f51b5"/></g></symbol><symbol viewBox="0 0 24 24" id="exe" xmlns="http://www.w3.org/2000/svg"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14m0 14V8H5v10h14z" fill="#e64a19"/></symbol><symbol viewBox="0 0 24 24" id="favicon" xmlns="http://www.w3.org/2000/svg"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2 9.19 8.62 2 9.24l5.45 4.73L5.82 21 12 17.27z" fill="#ffd54f"/></symbol><symbol viewBox="0 0 24 24" id="file" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m5 2H6v16h12v-9h-7V4z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 400 400" id="firebase" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 103)"><path d="M72.55 208.77l44.456-292.29 56.209 90.445L195.49-37.57 330.6 209.28z" fill="#ffa712"/><path d="M195.7 276.73l134.9-67.45-46.5-224.83L72.55 208.77z" fill="#fcca3f"/><path d="M173.22 6.932L72.56 208.772l136.35-144.58z" fill="#f6820c"/></g></symbol><symbol viewBox="0 0 24 24" id="flash" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="cma"><stop offset="0" stop-color="#d92f3c"/><stop offset="1" stop-color="#791223"/></linearGradient><linearGradient xlink:href="#cma" id="cmb" x1="2.373" y1="12.027" x2="21.86" y2="12.027" gradientUnits="userSpaceOnUse" gradientTransform="translate(-.09 -24.144)"/></defs><rect width="19.487" height="19.487" x="2.283" y="-21.86" transform="rotate(90)" ry="0" fill="url(#cmb)"/><path style="line-height:125%" d="M16.802 5.768l-.013.002a6.43 6.43 0 0 0-1.182.192 5.062 5.062 0 0 0-1.494.718c-.428.323-.817.72-1.17 1.191-.34.48-.682 1.032-1.022 1.66-.12.228-.233.424-.35.636v.002h-.004l-1.34 2.394-.005-.002c-.238.443-.461.847-.665 1.198a4.358 4.358 0 0 1-.716.94 2.79 2.79 0 0 1-.907.594c-.072.027-.161.042-.242.063h-.989v2.414h.989v-.002a6.427 6.427 0 0 0 1.185-.192 5.062 5.062 0 0 0 1.494-.718 5.94 5.94 0 0 0 1.171-1.191c.34-.48.681-1.033 1.021-1.66.12-.228.235-.425.353-.637l.006.002.003-.005.037-.066h2.53v.002h1.124v-2.408h-.33v-.001h-1.98c.22-.407.432-.789.621-1.115.214-.37.452-.682.717-.94a2.79 2.79 0 0 1 .906-.594c.07-.027.16-.041.239-.061h.992V8.18h-.002V5.77h-.977v-.002z" font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#fff"/></symbol><symbol class="cnflow-logo" viewBox="0 0 299.99999 300" id="flow" xmlns="http://www.w3.org/2000/svg"><title>Flow logo</title><path d="M38.75 33.427l77.461 77.47H54.436l61.145 61.16H38.437l93.462 93.478v-77.158l.01-.01v-77.47h-.01V66.982h46.691l20.394 20.393H153.57v76.531h22.05l24.474 24.473h-15.806l-.01-.01v.01h-31.665l-.01-.01v.01h-.313l.313.313v77.148h109.149l-39.2-39.2v-15.806l8.465 8.466v-77.37h-15.682l.017-38.191 30.09 30.086V56.362h-64.874l-22.94-22.934H113.71z" fill="#fbc02d" fill-opacity=".976" stroke-width=".955" class="cnflow-logo-mark"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-aurelia" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="coa" x1="-388.15%" x2="237.68%" y1="-144.18%" y2="430.41%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cob" x1="72.945%" x2="-97.052%" y1="84.424%" y2="-147.7%"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="coc" x1="-283.88%" x2="287.54%" y1="-693.6%" y2="101.71%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cod" x1="-821.19%" x2="101.99%" y1="-469.05%" y2="288.24%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="coe" x1="-140.36%" x2="419.01%" y1="-230.93%" y2="261.98%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cof" x1="191.08%" x2="20.358%" y1="253.95%" y2="20.403%"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cog" x1="-388.09%" x2="237.67%" y1="-173.85%" y2="518.99%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="coi" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#coa"/><linearGradient id="coj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#cob"/><linearGradient id="cok" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#coc"/><linearGradient id="col" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#cod"/><linearGradient id="com" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#coe"/><linearGradient id="con" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#cof"/><linearGradient id="coo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cog"/><linearGradient id="cop" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#coh"/><linearGradient id="coh" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#f06292" fill-rule="nonzero"/><g transform="matrix(.31022 .0619 -.0619 .31022 11.807 7.546)" fill="none"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#coi)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#coj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#cok)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#col)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#com)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#con)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#coo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#cop)"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-aurelia-open" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="cpi" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cpa"/><linearGradient id="cpa" x1="-3.881" x2="2.377" y1="-1.442" y2="4.304"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#cpb"/><linearGradient id="cpb" x1=".729" x2="-.971" y1=".844" y2="-1.477"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cpk" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#cpc"/><linearGradient id="cpc" x1="-2.839" x2="2.875" y1="-6.936" y2="1.017"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpl" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#cpd"/><linearGradient id="cpd" x1="-8.212" x2="1.02" y1="-4.691" y2="2.882"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpm" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#cpe"/><linearGradient id="cpe" x1="-1.404" x2="4.19" y1="-2.309" y2="2.62"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpn" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#cpf"/><linearGradient id="cpf" x1="1.911" x2=".204" y1="2.539" y2=".204"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cpo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cpg"/><linearGradient id="cpg" x1="-3.881" x2="2.377" y1="-1.738" y2="5.19"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpp" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#cph"/><linearGradient id="cph" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#f06292" fill-rule="nonzero"/><g transform="matrix(.31022 .0619 -.0619 .31022 11.807 7.546)" fill="none"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#cpi)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#cpj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#cpk)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#cpl)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#cpm)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#cpn)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#cpo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#cpp)"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-components" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#cddc39" fill-rule="nonzero"/><path d="M11.185 9.613h5.346v2.9l3.782-3.775 3.775 3.775-3.775 3.782h2.9v5.346h-5.346v-5.346h2.446l-3.782-3.782v2.446h-5.346V9.613m0 6.682h5.346v5.346h-5.346z" fill="#f0f4c3" stroke-width=".668"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-components-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#cddc39"/><path d="M11.185 9.613h5.346v2.9l3.782-3.775 3.775 3.775-3.775 3.782h2.9v5.346h-5.346v-5.346h2.446l-3.782-3.782v2.446h-5.346V9.613m0 6.682h5.346v5.346h-5.346z" fill="#f0f4c3" stroke-width=".668"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-config" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00acc1" fill-rule="nonzero"/><path d="M17.293 17.786a2.308 2.308 0 0 1-2.308-2.308 2.308 2.308 0 0 1 2.308-2.307 2.308 2.308 0 0 1 2.308 2.307 2.308 2.308 0 0 1-2.308 2.308m4.899-1.668c.026-.211.046-.422.046-.64 0-.217-.02-.435-.046-.659l1.391-1.075a.333.333 0 0 0 .08-.422l-1.32-2.28c-.079-.146-.257-.205-.402-.146l-1.641.66a4.779 4.779 0 0 0-1.115-.647l-.244-1.747a.333.333 0 0 0-.33-.277h-2.637a.333.333 0 0 0-.33.277l-.243 1.747a4.78 4.78 0 0 0-1.114.646l-1.642-.659a.324.324 0 0 0-.402.145l-1.319 2.281a.325.325 0 0 0 .08.422l1.39 1.075c-.026.224-.046.442-.046.66s.02.428.046.639l-1.39 1.094a.325.325 0 0 0-.08.422l1.319 2.282c.079.145.257.197.402.145l1.642-.666c.342.264.698.488 1.114.653l.244 1.747a.333.333 0 0 0 .33.277h2.637a.333.333 0 0 0 .33-.277l.243-1.747a4.802 4.802 0 0 0 1.115-.653l1.641.666c.145.052.323 0 .403-.145l1.318-2.282a.333.333 0 0 0-.079-.422z" fill="#80deea" stroke-width=".659"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-config-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00acc1"/><path d="M17.293 17.786a2.308 2.308 0 0 1-2.308-2.308 2.308 2.308 0 0 1 2.308-2.307 2.308 2.308 0 0 1 2.308 2.307 2.308 2.308 0 0 1-2.308 2.308m4.899-1.668c.026-.211.046-.422.046-.64 0-.217-.02-.435-.046-.659l1.391-1.075a.333.333 0 0 0 .08-.422l-1.32-2.28c-.079-.146-.257-.205-.402-.146l-1.641.66a4.779 4.779 0 0 0-1.115-.647l-.244-1.747a.333.333 0 0 0-.33-.277h-2.637a.333.333 0 0 0-.33.277l-.243 1.747a4.78 4.78 0 0 0-1.114.646l-1.642-.659a.324.324 0 0 0-.402.145l-1.319 2.281a.325.325 0 0 0 .08.422l1.39 1.075c-.026.224-.046.442-.046.66s.02.428.046.639l-1.39 1.094a.325.325 0 0 0-.08.422l1.319 2.282c.079.145.257.197.402.145l1.642-.666c.342.264.698.488 1.114.653l.244 1.747a.333.333 0 0 0 .33.277h2.637a.333.333 0 0 0 .33-.277l.243-1.747a4.802 4.802 0 0 0 1.115-.653l1.641.666c.145.052.323 0 .403-.145l1.318-2.282a.333.333 0 0 0-.079-.422z" fill="#80deea" stroke-width=".659"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-css" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#42a5f5" fill-rule="nonzero"/><path d="M12.488 9.415l-.44 2.259h9.188l-.298 1.46h-9.18l-.447 2.251H20.5l-.514 2.576-3.705 1.224-3.211-1.224.223-1.109h-2.258l-.534 2.704 5.307 2.029 6.118-2.029.812-4.076.162-.818 1.041-5.247H12.488z" fill-rule="nonzero" fill="#bbdefb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-css-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#42a5f5" fill-rule="nonzero"/><path d="M12.488 9.415l-.44 2.259h9.188l-.298 1.46h-9.18l-.447 2.251H20.5l-.514 2.576-3.705 1.224-3.211-1.224.223-1.109h-2.258l-.534 2.704 5.307 2.029 6.118-2.029.812-4.076.162-.818 1.041-5.247H12.488z" fill-rule="nonzero" fill="#bbdefb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-dist" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#e57373" fill-rule="nonzero"/><path d="M18.575 11.113h-2.576V9.825h2.576m3.864 1.288h-2.576V9.825l-1.288-1.288h-2.576L14.71 9.825v1.288h-2.577c-.715 0-1.288.573-1.288 1.288v7.085a1.288 1.288 0 0 0 1.288 1.288H22.44a1.288 1.288 0 0 0 1.288-1.288V12.4c0-.715-.58-1.288-1.288-1.288z" fill="#ffcdd2" stroke-width=".644"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-dist-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#e57373"/><path d="M18.575 11.113h-2.576V9.825h2.576m3.864 1.288h-2.576V9.825l-1.288-1.288h-2.576L14.71 9.825v1.288h-2.577c-.715 0-1.288.573-1.288 1.288v7.085a1.288 1.288 0 0 0 1.288 1.288H22.44a1.288 1.288 0 0 0 1.288-1.288V12.4c0-.715-.58-1.288-1.288-1.288z" fill="#ffcdd2" fill-rule="evenodd" stroke-width=".644"/></symbol><symbol id="folder-docker" clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs id="cydefs10"><path id="cySVGID_2_" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></defs><path id="cypath2" d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><style id="cystyle2">.cyst0{fill:#fff}.cyst1{clip-path:url(#cySVGID_4_)}</style><g id="cyg34" transform="translate(8.319 9.626) scale(.39491)" fill="#b3e5fc"><g id="cyg32"><g id="cyg30"><title id="cytitle4">Group 3</title><g id="cyg28"><g id="cyg26"><g id="cyg9"><path id="cySVGID_1_" class="cyst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></g><g id="cyg24"><clipPath id="cySVGID_4_"><use id="cyuse14" width="100%" height="100%" xlink:href="#cySVGID_2_"/></clipPath><g id="cyg22" class="cyst1" clip-path="url(#cySVGID_4_)"><g id="cyg20"><g id="cyg18"><path id="cySVGID_3_" class="cyst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></g></g></g></g></g></g></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docker-open" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="cza"><use width="100%" height="100%" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#SVGID_2_"/></clipPath></defs><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><g transform="matrix(.3949 0 0 .39489 8.319 9.626)" fill="#b3e5fc"><title>Group 3</title><path class="czst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/><g class="czst1" clip-path="url(#cza)"><path class="czst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docs" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#0277bd" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m6.075 10.8v-1.35H13.85v1.35h6.075m2.025-2.7v-1.35h-8.1v1.35h8.1z" fill-rule="nonzero" fill="#b3e5fc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docs-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#0277bd" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m6.075 10.8v-1.35H13.85v1.35h6.075m2.025-2.7v-1.35h-8.1v1.35h8.1z" fill-rule="nonzero" fill="#b3e5fc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-expo" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#01579b" fill-rule="nonzero"/><style>.dcst0{fill:#1173b6}.st1{fill:#585d67}</style><path class="dcst0" d="M18.575 9.82c-.489-.745-.605-.844-1.6-.844h-.024c-.996 0-1.106.099-1.601.844-.46.699-5.024 9.058-5.024 9.291 0 .338.087.658.402 1.112.32.46.873.716 1.275.309.273-.274 3.201-5.321 4.616-7.23a.425.425 0 0 1 .693 0c1.414 1.909 4.343 6.956 4.616 7.23.402.407.955.15 1.275-.309.314-.454.402-.774.402-1.112-.006-.233-4.57-8.598-5.03-9.291z" fill="#1173b6" stroke-width=".058"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-expo-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#01579b"/><path class="ddst0" d="M18.575 9.82c-.489-.745-.605-.844-1.6-.844h-.024c-.996 0-1.106.099-1.601.844-.46.699-5.024 9.058-5.024 9.291 0 .338.087.658.402 1.112.32.46.873.716 1.275.309.273-.274 3.201-5.321 4.616-7.23a.425.425 0 0 1 .693 0c1.414 1.909 4.343 6.956 4.616 7.23.402.407.955.15 1.275-.309.314-.454.402-.774.402-1.112-.006-.233-4.57-8.598-5.03-9.291z" fill="#1173b6" stroke-width=".058" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-font" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef9a9a" fill-rule="nonzero"/><path d="M14.62 17.403l2.38-6.33 2.37 6.33m-3.37-9l-5.5 14h2.25l1.12-3h6.25l1.13 3h2.25l-5.5-14h-2z" fill="#f44336" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-font-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef9a9a" fill-rule="nonzero"/><path d="M14.62 17.403l2.38-6.33 2.37 6.33m-3.37-9l-5.5 14h2.25l1.12-3h6.25l1.13 3h2.25l-5.5-14h-2z" fill="#f44336" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-git" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ff8a65" fill-rule="nonzero"/><path d="M10.43 14.14l4.044-4.052 1.183 1.19a1.387 1.387 0 0 0 .65 1.56v3.877c-.42.238-.699.693-.699 1.21 0 .768.632 1.4 1.4 1.4.767 0 1.4-.632 1.4-1.4 0-.517-.28-.972-.7-1.21v-3.4l1.448 1.462c-.05.105-.05.224-.05.35 0 .767.633 1.399 1.4 1.399.768 0 1.4-.632 1.4-1.4 0-.767-.632-1.4-1.4-1.4-.126 0-.245 0-.35.05l-1.798-1.799a1.385 1.385 0 0 0-.805-1.637c-.3-.112-.615-.14-.895-.063l-1.19-1.183.553-.545a1.381 1.381 0 0 1 1.973 0l5.591 5.59a1.381 1.381 0 0 1 0 1.974l-5.59 5.591a1.381 1.381 0 0 1-1.974 0l-5.591-5.59a1.381 1.381 0 0 1 0-1.974z" fill="#e64a19" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-git-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ff8a65" fill-rule="nonzero"/><path d="M10.43 14.14l4.044-4.052 1.183 1.19a1.387 1.387 0 0 0 .65 1.56v3.877c-.42.238-.699.693-.699 1.21 0 .768.632 1.4 1.4 1.4.767 0 1.4-.632 1.4-1.4 0-.517-.28-.972-.7-1.21v-3.4l1.448 1.462c-.05.105-.05.224-.05.35 0 .767.633 1.399 1.4 1.399.768 0 1.4-.632 1.4-1.4 0-.767-.632-1.4-1.4-1.4-.126 0-.245 0-.35.05l-1.798-1.799a1.385 1.385 0 0 0-.805-1.637c-.3-.112-.615-.14-.895-.063l-1.19-1.183.553-.545a1.381 1.381 0 0 1 1.973 0l5.591 5.59a1.381 1.381 0 0 1 0 1.974l-5.59 5.591a1.381 1.381 0 0 1-1.974 0l-5.591-5.59a1.381 1.381 0 0 1 0-1.974z" fill="#e64a19" fill-rule="nonzero"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-global" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#5c6bc0" fill-rule="nonzero"/><path d="M21.132 18.585a1.22 1.22 0 0 0-1.156-.846h-.609v-1.825a.608.608 0 0 0-.608-.609h-3.65v-1.217h1.216a.608.608 0 0 0 .609-.608v-1.217h1.217a1.217 1.217 0 0 0 1.216-1.217v-.25a4.858 4.858 0 0 1 1.765 7.79m-4.198 1.545a4.86 4.86 0 0 1-4.26-4.826c0-.377.049-.742.128-1.089l2.915 2.915v.608a1.217 1.217 0 0 0 1.217 1.217m.608-9.735a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.085-6.085 6.085 6.085 0 0 0-6.085-6.084z" fill="#c5cae9" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-global-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#5c6bc0"/><path d="M21.133 18.585a1.22 1.22 0 0 0-1.156-.846h-.609v-1.825a.608.608 0 0 0-.608-.609h-3.65v-1.217h1.216a.608.608 0 0 0 .609-.608v-1.217h1.217a1.217 1.217 0 0 0 1.216-1.217v-.25a4.858 4.858 0 0 1 1.765 7.79m-4.198 1.545a4.86 4.86 0 0 1-4.26-4.826c0-.377.049-.742.128-1.089l2.915 2.915v.608a1.217 1.217 0 0 0 1.216 1.217m.609-9.735a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.085-6.085 6.085 6.085 0 0 0-6.085-6.084z" fill="#c5cae9" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-i18n" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#5c6bc0" fill-rule="nonzero"/><path d="M17.293 17.786l-1.53-1.512.018-.018a10.555 10.555 0 0 0 2.235-3.934h1.765v-1.205h-4.217V9.912h-1.205v1.205h-4.217v1.205h6.73a9.5 9.5 0 0 1-1.91 3.223 9.424 9.424 0 0 1-1.392-2.018h-1.205c.44.982 1.042 1.91 1.795 2.747l-3.067 3.024.856.856 3.012-3.013 1.874 1.874.458-1.229m3.392-3.054H19.48l-2.711 7.23h1.205l.674-1.808h2.862l.68 1.807h1.206l-2.711-7.23m-1.579 4.218l.976-2.609.976 2.609z" fill="#c5cae9" stroke-width=".602"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-i18n-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#5c6bc0"/><path d="M17.293 17.786l-1.53-1.512.018-.018a10.555 10.555 0 0 0 2.235-3.934h1.765v-1.205h-4.217V9.912h-1.205v1.205h-4.217v1.205h6.73a9.5 9.5 0 0 1-1.91 3.223 9.424 9.424 0 0 1-1.392-2.018h-1.205c.44.982 1.042 1.91 1.795 2.747l-3.067 3.024.856.856 3.012-3.013 1.874 1.874.458-1.229m3.392-3.054H19.48l-2.711 7.23h1.205l.674-1.808h2.862l.68 1.807h1.206l-2.711-7.23m-1.579 4.218l.976-2.609.976 2.609z" fill="#c5cae9" stroke-width=".602"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-images" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#009688" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m0 12.15h8.1v-5.4l-2.7 2.7-1.35-1.35-4.05 4.05m1.35-7.425c-.74 0-1.35.61-1.35 1.35s.61 1.35 1.35 1.35 1.35-.61 1.35-1.35-.61-1.35-1.35-1.35z" fill-rule="nonzero" fill="#b2dfdb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-images-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#009688" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m0 12.15h8.1v-5.4l-2.7 2.7-1.35-1.35-4.05 4.05m1.35-7.425c-.74 0-1.35.61-1.35 1.35s.61 1.35 1.35 1.35 1.35-.61 1.35-1.35-.61-1.35-1.35-1.35z" fill-rule="nonzero" fill="#b2dfdb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-include" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><path d="M20.788 15.981h-2.434v2.434h-1.217V15.98h-2.434v-1.217h2.434V12.33h1.217v2.434h2.434m-3.042-5.476a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.084-6.085 6.085 6.085 0 0 0-6.084-6.084z" fill="#b3e5fc" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-include-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><path d="M20.788 15.981h-2.434v2.434h-1.217V15.98h-2.434v-1.217h2.434V12.33h1.217v2.434h2.434m-3.042-5.476a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.084-6.085 6.085 6.085 0 0 0-6.084-6.084z" fill="#b3e5fc" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-javascript" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ffca28" fill-rule="nonzero"/><path d="M17.935 18.374a2.18 2.18 0 0 0 1.972 1.213c.829 0 1.354-.415 1.354-.987 0-.682-.542-.927-1.452-1.324l-.502-.216c-1.435-.613-2.404-1.378-2.404-3.005 0-1.5 1.167-2.638 2.917-2.638a2.957 2.957 0 0 1 2.842 1.599l-1.552.999a1.362 1.362 0 0 0-1.29-.858.873.873 0 0 0-.957.858c0 .583.374.84 1.226 1.213l.502.216c1.697.733 2.654 1.47 2.654 3.139 0 1.798-1.411 2.783-3.308 2.783a3.839 3.839 0 0 1-3.618-2.046zm-7.048.175c.315.583.583 1.027 1.283 1.027s1.066-.256 1.066-1.255v-6.774h1.998v6.804c0 2.064-1.214 3.01-2.982 3.01a3.104 3.104 0 0 1-2.993-1.826z" fill-rule="nonzero" fill="#ffecb3"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-javascript-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ffca28"/><path d="M17.935 18.374a2.18 2.18 0 0 0 1.972 1.213c.829 0 1.354-.415 1.354-.987 0-.682-.542-.927-1.452-1.324l-.502-.216c-1.435-.613-2.404-1.378-2.404-3.005 0-1.5 1.167-2.638 2.917-2.638a2.957 2.957 0 0 1 2.842 1.599l-1.552.999a1.362 1.362 0 0 0-1.29-.858.873.873 0 0 0-.957.858c0 .583.374.84 1.226 1.213l.502.216c1.697.733 2.654 1.47 2.654 3.139 0 1.798-1.412 2.783-3.308 2.783a3.839 3.839 0 0 1-3.618-2.046zm-7.048.175c.315.583.583 1.027 1.283 1.027s1.066-.256 1.066-1.255v-6.774h1.998v6.804c0 2.064-1.214 3.01-2.982 3.01a3.104 3.104 0 0 1-2.993-1.826z" fill="#ffecb3"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-lib" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#c0ca33" fill-rule="nonzero"/><path d="M17.39 12.544a2.05 2.05 0 0 0 2.05-2.05 2.05 2.05 0 0 0-2.05-2.052 2.05 2.05 0 0 0-2.05 2.051 2.05 2.05 0 0 0 2.05 2.051m0 2.42a8.992 8.992 0 0 0-6.152-2.42v7.52c2.392 0 4.539.923 6.152 2.42a8.992 8.992 0 0 1 6.152-2.42v-7.52c-2.392 0-4.539.923-6.152 2.42z" fill="#f0f4c3" stroke-width=".684"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-lib-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#c0ca33"/><path d="M17.391 12.543a2.05 2.05 0 0 0 2.05-2.05 2.05 2.05 0 0 0-2.05-2.052 2.05 2.05 0 0 0-2.05 2.051 2.05 2.05 0 0 0 2.05 2.051m0 2.42a8.992 8.992 0 0 0-6.152-2.42v7.52c2.392 0 4.539.923 6.152 2.42a8.992 8.992 0 0 1 6.152-2.42v-7.52c-2.392 0-4.539.923-6.152 2.42z" fill="#f0f4c3" stroke-width=".684"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-actions" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ab47bc" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#e1bee7" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-actions-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ab47bc"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#e1bee7" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-effects" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00bcd4" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#b2ebf2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-effects-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00bcd4"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#b2ebf2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef5350" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#ffcdd2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-reducer-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef5350"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#ffcdd2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-state" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#dcedc8" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-state-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#dcedc8" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-node" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.25 8.403c-.188 0-.382.048-.542.139l-5.166 2.986a1.096 1.096 0 0 0-.542.944v5.959c0 .388.208.75.542.944l1.354.778c.66.32.882.326 1.187.326.973 0 1.535-.59 1.535-1.618V12.98a.154.154 0 0 0-.153-.153h-.646c-.09 0-.16.07-.16.153v5.882c0 .458-.472.91-1.228.528l-1.424-.813a.181.181 0 0 1-.076-.145v-5.959c0-.062.027-.118.076-.146l5.167-2.979a.15.15 0 0 1 .152 0l5.167 2.98a.164.164 0 0 1 .076.145v5.959a.181.181 0 0 1-.076.145l-5.167 2.98c-.041.027-.11.027-.16 0l-1.305-.792c-.055-.021-.111-.028-.146-.007-.368.208-.437.25-.778.354-.083.028-.215.076.05.222l1.721 1.021c.167.097.348.146.542.146s.375-.049.542-.146l5.166-2.979c.334-.194.542-.556.542-.944v-5.959c0-.389-.208-.75-.542-.944l-5.166-2.986a1.103 1.103 0 0 0-.542-.14m1.389 4.272c-1.472 0-2.354.618-2.354 1.66 0 1.117.875 1.444 2.291 1.583 1.688.166 1.82.416 1.82.75 0 .576-.465.82-1.549.82-1.375 0-1.666-.341-1.77-1.022a.157.157 0 0 0-.153-.125h-.667c-.083 0-.146.063-.146.153 0 .861.472 1.903 2.736 1.903 1.632 0 2.57-.646 2.57-1.771 0-1.118-.75-1.41-2.34-1.625-1.605-.208-1.765-.32-1.765-.694 0-.313.14-.73 1.327-.73 1.042 0 1.451.23 1.611.945.014.07.076.118.146.118h.673a.134.134 0 0 0 .105-.049c.027-.028.048-.07.034-.11-.097-1.237-.916-1.806-2.57-1.806z" fill-rule="nonzero" fill="#f1f8e9"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-node-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.25 8.403c-.188 0-.382.048-.542.139l-5.166 2.986a1.096 1.096 0 0 0-.542.944v5.959c0 .388.208.75.542.944l1.354.778c.66.32.882.326 1.187.326.973 0 1.535-.59 1.535-1.618V12.98a.154.154 0 0 0-.153-.153h-.646c-.09 0-.16.07-.16.153v5.882c0 .458-.472.91-1.228.528l-1.424-.813a.181.181 0 0 1-.076-.145v-5.959c0-.062.027-.118.076-.146l5.167-2.979a.15.15 0 0 1 .152 0l5.167 2.98a.164.164 0 0 1 .076.145v5.959a.181.181 0 0 1-.076.145l-5.167 2.98c-.041.027-.11.027-.16 0l-1.305-.792c-.055-.021-.111-.028-.146-.007-.368.208-.437.25-.778.354-.083.028-.215.076.05.222l1.721 1.021c.167.097.348.146.542.146s.375-.049.542-.146l5.166-2.979c.334-.194.542-.556.542-.944v-5.959c0-.389-.208-.75-.542-.944l-5.166-2.986a1.103 1.103 0 0 0-.542-.14m1.389 4.272c-1.472 0-2.354.618-2.354 1.66 0 1.117.875 1.444 2.291 1.583 1.688.166 1.82.416 1.82.75 0 .576-.465.82-1.549.82-1.375 0-1.666-.341-1.77-1.022a.157.157 0 0 0-.153-.125h-.667c-.083 0-.146.063-.146.153 0 .861.472 1.903 2.736 1.903 1.632 0 2.57-.646 2.57-1.771 0-1.118-.75-1.41-2.34-1.625-1.605-.208-1.765-.32-1.765-.694 0-.313.14-.73 1.327-.73 1.042 0 1.451.23 1.611.945.014.07.076.118.146.118h.673a.134.134 0 0 0 .105-.049c.027-.028.048-.07.034-.11-.097-1.237-.916-1.806-2.57-1.806z" fill-rule="nonzero" fill="#f1f8e9"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-public" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><path d="M20.036 16.746c.05-.408.087-.817.087-1.237s-.037-.83-.087-1.238h2.091c.099.396.16.81.16 1.238a5.1 5.1 0 0 1-.16 1.237m-3.186 3.44c.371-.687.656-1.43.854-2.203h1.825a4.968 4.968 0 0 1-2.679 2.203m-.155-3.44h-2.895c-.062-.408-.099-.817-.099-1.237s.037-.835.1-1.238h2.894c.056.403.1.817.1 1.238s-.044.829-.1 1.237m-1.447 3.687a8.39 8.39 0 0 1-1.182-2.45h2.363a8.39 8.39 0 0 1-1.181 2.45m-2.475-7.399h-1.806a4.902 4.902 0 0 1 2.672-2.202c-.37.686-.65 1.429-.866 2.202m-1.806 4.95h1.806c.217.773.495 1.515.866 2.202a4.954 4.954 0 0 1-2.672-2.203m-.508-1.237a5.099 5.099 0 0 1-.16-1.237 5.1 5.1 0 0 1 .16-1.238h2.091c-.049.409-.086.817-.086 1.238s.037.829.086 1.237m2.698-6.168a8.425 8.425 0 0 1 1.181 2.456h-2.363a8.426 8.426 0 0 1 1.182-2.456m4.28 2.456h-1.824a9.682 9.682 0 0 0-.854-2.202 4.94 4.94 0 0 1 2.679 2.202m-4.281-3.712a6.193 6.193 0 0 0-6.187 6.187 6.186 6.186 0 0 0 6.187 6.186 6.186 6.186 0 0 0 6.186-6.186 6.186 6.186 0 0 0-6.186-6.187z" fill="#b3e5fc" stroke-width=".619"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-public-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><path d="M20.037 16.746c.05-.408.087-.817.087-1.237s-.037-.83-.087-1.238h2.091c.099.396.16.81.16 1.238a5.1 5.1 0 0 1-.16 1.237m-3.186 3.44c.371-.687.656-1.43.854-2.203h1.825a4.967 4.967 0 0 1-2.68 2.203m-.154-3.44h-2.895c-.062-.408-.099-.817-.099-1.237s.037-.835.099-1.238h2.895c.056.403.1.817.1 1.238s-.044.829-.1 1.237m-1.447 3.687a8.39 8.39 0 0 1-1.182-2.45h2.363a8.39 8.39 0 0 1-1.181 2.45m-2.475-7.399H13.06a4.902 4.902 0 0 1 2.672-2.202c-.371.686-.65 1.429-.866 2.202m-1.806 4.95h1.806c.217.773.495 1.515.866 2.202a4.954 4.954 0 0 1-2.672-2.203m-.508-1.237a5.099 5.099 0 0 1-.16-1.237 5.1 5.1 0 0 1 .16-1.238h2.091c-.05.409-.086.817-.086 1.238s.037.829.086 1.237m2.698-6.168a8.425 8.425 0 0 1 1.181 2.456h-2.363a8.426 8.426 0 0 1 1.182-2.456m4.28 2.456h-1.824a9.682 9.682 0 0 0-.854-2.202 4.941 4.941 0 0 1 2.679 2.202M17.34 9.322a6.193 6.193 0 0 0-6.187 6.187 6.186 6.186 0 0 0 6.187 6.186 6.186 6.186 0 0 0 6.186-6.186 6.186 6.186 0 0 0-6.186-6.187z" fill="#b3e5fc" stroke-width=".619"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-react-components" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00bcd4" fill-rule="nonzero"/><path d="M16.473 13.927c.723 0 1.313.59 1.313 1.327 0 .703-.59 1.3-1.313 1.3a1.318 1.318 0 0 1-1.313-1.3c0-.737.59-1.327 1.313-1.327m-3.252 6.946c.443.267 1.412-.14 2.529-1.194a17.015 17.015 0 0 1-1.06-1.335 15.945 15.945 0 0 1-1.686-.252c-.358 1.502-.225 2.535.217 2.78m.499-4.03l-.204-.359a5.558 5.558 0 0 0-.203.604c.19.042.4.078.618.113l-.211-.359m4.593-.533l.569-1.054-.569-1.053c-.21-.372-.435-.702-.639-1.032-.38-.022-.78-.022-1.2-.022-.422 0-.823 0-1.202.022-.204.33-.428.66-.639 1.032l-.569 1.053.57 1.054c.21.372.434.702.638 1.032.38.021.78.021 1.201.021.421 0 .822 0 1.201-.02.204-.331.428-.661.639-1.033m-1.84-4.72c-.133.155-.274.316-.414.506h.828c-.14-.19-.28-.351-.414-.506m0 7.332c.133-.154.274-.316.414-.505h-.828c.14.19.28.35.414.505m3.245-9.284c-.436-.267-1.405.14-2.522 1.194.366.414.724.864 1.06 1.334.577.057 1.146.14 1.686.253.359-1.503.225-2.535-.224-2.78m-.492 4.03l.204.358c.077-.203.154-.407.203-.604-.19-.042-.4-.077-.618-.112l.211.358m1.018-4.95c1.033.589 1.145 2.141.71 3.953 1.784.527 3.069 1.398 3.069 2.584 0 1.187-1.285 2.058-3.07 2.585.436 1.812.324 3.364-.709 3.954-1.025.59-2.423-.085-3.77-1.37-1.35 1.285-2.747 1.96-3.78 1.37-1.025-.59-1.137-2.142-.702-3.954-1.783-.527-3.069-1.398-3.069-2.585s1.286-2.057 3.07-2.584c-.436-1.812-.324-3.364.702-3.954 1.032-.59 2.43.084 3.778 1.37 1.348-1.286 2.746-1.96 3.771-1.37m-.203 6.538c.239.527.45 1.054.625 1.588 1.475-.443 2.303-1.075 2.303-1.588 0-.512-.828-1.144-2.303-1.587a15.81 15.81 0 0 1-.625 1.587m-7.136 0a15.806 15.806 0 0 1-.625-1.587c-1.474.443-2.303 1.075-2.303 1.587 0 .513.829 1.145 2.303 1.588.176-.534.387-1.06.625-1.588m6.321 1.588l-.21.358c.217-.035.428-.07.617-.113-.049-.196-.126-.4-.203-.604l-.204.359m-2.03 2.837c1.117 1.053 2.086 1.46 2.522 1.194.45-.246.583-1.278.224-2.781-.54.112-1.11.196-1.685.253-.337.47-.695.92-1.06 1.334m-3.477-6.012l.21-.358c-.217.035-.428.07-.617.113.049.196.126.4.203.604l.204-.359m2.03-2.837c-1.117-1.053-2.086-1.46-2.529-1.194-.442.246-.576 1.278-.217 2.781.54-.112 1.11-.196 1.685-.253.337-.47.695-.92 1.06-1.334z" fill="#b2ebf2" stroke-width=".702"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-react-components-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00bcd4"/><path d="M16.473 13.928c.723 0 1.313.59 1.313 1.327 0 .703-.59 1.3-1.313 1.3a1.318 1.318 0 0 1-1.313-1.3c0-.737.59-1.327 1.313-1.327m-3.252 6.946c.443.267 1.412-.14 2.529-1.194a16.997 16.997 0 0 1-1.06-1.335 15.945 15.945 0 0 1-1.686-.252c-.358 1.502-.225 2.535.217 2.78m.499-4.03l-.204-.359c-.077.204-.154.408-.203.604.19.042.4.078.618.113l-.211-.359m4.593-.533l.569-1.054-.57-1.053c-.21-.372-.434-.702-.638-1.032-.38-.022-.78-.022-1.201-.022-.421 0-.822 0-1.2.022-.205.33-.43.66-.64 1.032l-.569 1.053.569 1.054c.21.372.435.702.64 1.032.378.021.779.021 1.2.021.421 0 .822 0 1.2-.02.205-.33.43-.661.64-1.033m-1.84-4.72c-.133.155-.274.316-.414.506h.828c-.14-.19-.28-.351-.414-.506m0 7.332c.133-.154.274-.316.414-.505h-.828c.14.19.28.35.414.505m3.244-9.284c-.435-.267-1.404.14-2.52 1.194.364.414.723.864 1.06 1.334.575.057 1.144.14 1.685.253.358-1.503.225-2.535-.225-2.78m-.491 4.03l.203.358c.078-.203.155-.407.204-.604-.19-.042-.4-.077-.618-.112l.21.358m1.02-4.95c1.032.589 1.144 2.141.708 3.953 1.784.527 3.07 1.398 3.07 2.584 0 1.187-1.286 2.058-3.07 2.585.436 1.812.323 3.364-.709 3.954-1.025.59-2.423-.085-3.771-1.37-1.348 1.285-2.746 1.96-3.778 1.37-1.026-.59-1.138-2.142-.703-3.954-1.783-.527-3.069-1.398-3.069-2.585s1.286-2.057 3.07-2.584c-.436-1.812-.324-3.364.702-3.954 1.032-.59 2.43.084 3.778 1.37 1.348-1.286 2.746-1.96 3.771-1.37m-.204 6.538c.24.527.45 1.054.625 1.588 1.475-.443 2.304-1.075 2.304-1.588 0-.512-.829-1.144-2.304-1.587a15.81 15.81 0 0 1-.625 1.587m-7.135 0a15.808 15.808 0 0 1-.625-1.587c-1.475.443-2.303 1.075-2.303 1.587 0 .513.828 1.145 2.303 1.588.176-.534.386-1.06.625-1.588m6.32 1.588l-.21.358c.218-.035.428-.07.618-.113a5.56 5.56 0 0 0-.204-.604l-.203.359m-2.03 2.837c1.117 1.053 2.086 1.46 2.521 1.194.45-.246.583-1.278.225-2.781-.54.112-1.11.196-1.685.253-.338.47-.696.92-1.06 1.334m-3.477-6.012l.21-.358c-.217.035-.428.07-.617.112.049.197.126.4.203.604l.204-.358m2.03-2.837c-1.117-1.053-2.086-1.46-2.529-1.194-.442.246-.576 1.278-.217 2.781.54-.112 1.11-.196 1.685-.253.337-.47.695-.92 1.06-1.334z" fill="#b2ebf2" stroke-width=".702"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-actions" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ab47bc" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#e1bee7" stroke="#e1bee7" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-actions-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ab47bc"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#e1bee7" stroke="#e1bee7" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef5350" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#ffcdd2" stroke="#ffcdd2" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-reducer-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef5350"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#ffcdd2" stroke="#ffcdd2" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-store" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#dcedc8" stroke="#dcedc8" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-store-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#dcedc8" stroke="#dcedc8" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-resource" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#fbc02d" fill-rule="nonzero"/><path d="M21.598 12.059h-6.085v-1.217h6.085m-2.434 6.085h-3.65V15.71h3.65m2.434-1.217h-6.085v-1.217h6.085m.608-4.26h-7.301a1.217 1.217 0 0 0-1.217 1.218v7.301a1.217 1.217 0 0 0 1.217 1.217h7.301a1.217 1.217 0 0 0 1.217-1.217v-7.301a1.217 1.217 0 0 0-1.217-1.217m-9.735 2.434h-1.217v8.518a1.217 1.217 0 0 0 1.217 1.217h8.519v-1.217H12.47z" fill="#fff9c4" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-resource-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#fbc02d"/><path d="M21.598 12.059h-6.085v-1.217h6.085m-2.434 6.085h-3.65V15.71h3.65m2.434-1.217h-6.085v-1.217h6.085m.608-4.26h-7.301a1.217 1.217 0 0 0-1.217 1.218v7.301a1.217 1.217 0 0 0 1.217 1.217h7.301a1.217 1.217 0 0 0 1.217-1.217v-7.301a1.217 1.217 0 0 0-1.217-1.217m-9.735 2.433h-1.217v8.519a1.217 1.217 0 0 0 1.217 1.217h8.519v-1.217H12.47z" fill="#fff9c4" stroke-width=".608"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" id="folder-sass" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#f8bbd0" fill-rule="nonzero"/><path d="M23.36 10.506c-.39-1.527-2.922-2.03-5.319-1.178-1.426.507-2.97 1.302-4.08 2.34-1.32 1.235-1.53 2.31-1.444 2.759.306 1.584 2.477 2.62 3.37 3.388v.005c-.264.13-2.19 1.104-2.64 2.1-.476 1.052.075 1.806.44 1.908 1.131.315 2.292-.251 2.916-1.182.602-.897.551-2.056.29-2.633.36-.095.781-.138 1.316-.076 1.508.177 1.804 1.118 1.748 1.513-.057.394-.373.61-.48.676-.105.065-.137.088-.129.137.013.07.062.068.152.053.125-.021.792-.321.821-1.048.037-.924-.849-1.958-2.416-1.93-.646.01-1.052.072-1.345.181-.022-.024-.044-.05-.067-.073-.969-1.034-2.76-1.765-2.684-3.156.027-.505.203-1.835 3.442-3.45 2.653-1.322 4.777-.957 5.145-.151.524 1.152-1.136 3.293-3.891 3.601-1.05.118-1.603-.289-1.74-.44-.145-.16-.166-.167-.22-.137-.088.049-.033.19 0 .274.082.214.42.594.995.782.506.166 1.739.258 3.23-.319 1.669-.646 2.972-2.443 2.59-3.944zm-7.103 7.783a2.2 2.2 0 0 1-.065 1.413 2.405 2.405 0 0 1-.453.704c-.5.546-1.198.752-1.497.579-.323-.188-.161-.956.418-1.568.623-.66 1.52-1.083 1.52-1.083l-.002-.002.079-.043z" fill="#ec407a" fill-rule="nonzero" stroke="#ec407a" stroke-width=".5199012000000001"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" id="folder-sass-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#f8bbd0" fill-rule="nonzero"/><path d="M23.36 10.506c-.39-1.527-2.922-2.03-5.319-1.178-1.426.507-2.97 1.302-4.08 2.34-1.32 1.235-1.53 2.31-1.444 2.759.306 1.584 2.477 2.62 3.37 3.388v.005c-.264.13-2.19 1.104-2.64 2.1-.476 1.052.075 1.806.44 1.908 1.131.315 2.292-.251 2.916-1.182.602-.897.551-2.056.29-2.633.36-.095.781-.138 1.316-.076 1.508.177 1.804 1.118 1.748 1.513-.057.394-.373.61-.48.676-.105.065-.137.088-.129.137.013.07.062.068.152.053.125-.021.792-.321.821-1.048.037-.924-.849-1.958-2.416-1.93-.646.01-1.052.072-1.345.181-.022-.024-.044-.05-.067-.073-.969-1.034-2.76-1.765-2.684-3.156.027-.505.203-1.835 3.442-3.45 2.653-1.322 4.777-.957 5.145-.151.524 1.152-1.136 3.293-3.891 3.601-1.05.118-1.603-.289-1.74-.44-.145-.16-.166-.167-.22-.137-.088.049-.033.19 0 .274.082.214.42.594.995.782.506.166 1.739.258 3.23-.319 1.669-.646 2.972-2.443 2.59-3.944zm-7.103 7.783a2.2 2.2 0 0 1-.065 1.413 2.405 2.405 0 0 1-.453.704c-.5.546-1.198.752-1.497.579-.323-.188-.161-.956.418-1.568.623-.66 1.52-1.083 1.52-1.083l-.002-.002.079-.043z" fill="#ec407a" fill-rule="nonzero" stroke="#ec407a" stroke-width=".5199012000000001"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-scripts" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#546e7a" fill-rule="nonzero"/><path d="M18.466 20.241c.69 0 1.259-.568 1.259-1.258v-8.18H15.32a.632.632 0 0 0-.63.63v6.292h-1.887v-6.922c0-1.036.852-1.888 1.888-1.888h6.921c1.036 0 1.888.852 1.888 1.888v.63h-2.517v8.18a1.896 1.896 0 0 1-1.888 1.887h-6.292a1.896 1.896 0 0 1-1.888-1.888v-.629h6.293c0 .69.568 1.258 1.258 1.258z" fill-rule="nonzero" fill="#cfd8dc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-scripts-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#546e7a" fill-rule="nonzero"/><path d="M18.466 20.241c.69 0 1.259-.568 1.259-1.258v-8.18H15.32a.632.632 0 0 0-.63.63v6.292h-1.887v-6.922c0-1.036.852-1.888 1.888-1.888h6.921c1.036 0 1.888.852 1.888 1.888v.63h-2.517v8.18a1.896 1.896 0 0 1-1.888 1.887h-6.292a1.896 1.896 0 0 1-1.888-1.888v-.629h6.293c0 .69.568 1.258 1.258 1.258z" fill-rule="nonzero" fill="#cfd8dc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-src" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#4caf50" fill-rule="nonzero"/><g fill="#c8e6c9" transform="translate(2.065 -.225) scale(.70678)"><path d="M19.146 30.989a.902.902 0 0 1-.207-.025 1.045 1.045 0 0 1-.726-1.213l2.709-14.431c.049-.279.209-.525.444-.683a.891.891 0 0 1 .7-.122c.519.152.837.684.727 1.213L20.077 30.16a1.032 1.032 0 0 1-.442.681.895.895 0 0 1-.489.148zM24.578 28.944h-.068a.932.932 0 0 1-.668-.377 1.104 1.104 0 0 1 .1-1.419l4.658-4.553-4.638-4.239a1.105 1.105 0 0 1-.141-1.416.938.938 0 0 1 .661-.4.9.9 0 0 1 .709.237l5.47 5c.386.372.448.974.144 1.416a1.05 1.05 0 0 1-.142.163l-5.447 5.324a.913.913 0 0 1-.638.264zM16.423 28.947a.917.917 0 0 1-.639-.267l-5.452-5.327a.874.874 0 0 1-.132-.153 1.097 1.097 0 0 1 .141-1.414l5.471-5a.882.882 0 0 1 .7-.238.939.939 0 0 1 .665.4 1.104 1.104 0 0 1-.14 1.417L12.4 22.6l4.659 4.551c.377.382.42.988.1 1.419a.928.928 0 0 1-.669.377z" fill-rule="nonzero"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-src-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#4caf50"/><g fill="#c8e6c9" fill-rule="evenodd" transform="translate(2.064 -.224) scale(.70678)"><path d="M19.146 30.989a.902.902 0 0 1-.207-.025 1.045 1.045 0 0 1-.726-1.213l2.709-14.431c.049-.279.209-.525.444-.683a.891.891 0 0 1 .7-.122c.519.152.837.684.727 1.213L20.077 30.16a1.032 1.032 0 0 1-.442.681.895.895 0 0 1-.489.148zM24.578 28.944h-.068a.932.932 0 0 1-.668-.377 1.104 1.104 0 0 1 .1-1.419l4.658-4.553-4.638-4.239a1.105 1.105 0 0 1-.141-1.416.938.938 0 0 1 .661-.4.9.9 0 0 1 .709.237l5.47 5c.386.372.448.974.144 1.416a1.05 1.05 0 0 1-.142.163l-5.447 5.324a.913.913 0 0 1-.638.264zM16.423 28.947a.917.917 0 0 1-.639-.267l-5.452-5.327a.874.874 0 0 1-.132-.153 1.097 1.097 0 0 1 .141-1.414l5.471-5a.882.882 0 0 1 .7-.238.939.939 0 0 1 .665.4 1.104 1.104 0 0 1-.14 1.417L12.4 22.6l4.659 4.551c.377.382.42.988.1 1.419a.928.928 0 0 1-.669.377z" fill-rule="nonzero"/></g></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-test" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#1de9b6" fill-rule="nonzero"/><path d="M14 8.097v1.39h.695v9.732A2.794 2.794 0 0 0 17.475 22a2.794 2.794 0 0 0 2.781-2.78V9.486h.695v-1.39H14m2.78 9.732c-.417 0-.695-.278-.695-.695 0-.417.278-.695.696-.695.417 0 .695.278.695.695 0 .417-.278.695-.695.695m1.39-2.78c-.417 0-.695-.278-.695-.696 0-.417.278-.695.695-.695.417 0 .695.278.695.695 0 .418-.278.696-.695.696m.695-3.476h-2.78V9.487h2.78v2.086z" fill="#00897b" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-test-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#1de9b6" fill-rule="nonzero"/><path d="M14 8.097v1.39h.695v9.732A2.794 2.794 0 0 0 17.475 22a2.794 2.794 0 0 0 2.781-2.78V9.486h.695v-1.39H14m2.78 9.732c-.417 0-.695-.278-.695-.695 0-.417.278-.695.696-.695.417 0 .695.278.695.695 0 .417-.278.695-.695.695m1.39-2.78c-.417 0-.695-.278-.695-.696 0-.417.278-.695.695-.695.417 0 .695.278.695.695 0 .418-.278.696-.695.696m.695-3.476h-2.78V9.487h2.78v2.086z" fill="#00897b" fill-rule="nonzero"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-tools" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#1e88e5" fill-rule="nonzero"/><path d="M21.043 15.266a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141-2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-3.569 0a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141 2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m3.925-6.424a6.424 6.424 0 0 0-6.423 6.424 6.424 6.424 0 0 0 6.423 6.424 1.07 1.07 0 0 0 1.071-1.07c0-.28-.107-.53-.278-.715a1.105 1.105 0 0 1-.271-.713 1.07 1.07 0 0 1 1.07-1.071h1.263a3.569 3.569 0 0 0 3.57-3.569c0-3.154-2.877-5.71-6.425-5.71z" fill="#bbdefb" stroke-width=".714"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-tools-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#1e88e5"/><path d="M21.043 15.266a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141-2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-3.569 0a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141 2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m3.925-6.424a6.424 6.424 0 0 0-6.423 6.424 6.424 6.424 0 0 0 6.423 6.424 1.07 1.07 0 0 0 1.071-1.07c0-.28-.107-.53-.278-.715a1.105 1.105 0 0 1-.271-.713 1.07 1.07 0 0 1 1.07-1.071h1.263a3.569 3.569 0 0 0 3.57-3.569c0-3.154-2.877-5.71-6.425-5.71z" fill="#bbdefb" stroke-width=".714"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-views" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ff8a65" fill-rule="nonzero"/><path d="M12.487 21.868L11.384 9.5H23.5l-1.104 12.366-4.961 1.375-4.948-1.373zm4.464-3.2l-3.926-2.36v-.855l3.926-2.361v1.323l-2.504 1.465 2.504 1.465v1.323zm.982-.001v-1.323l2.522-1.464-2.522-1.464v-1.323l3.926 2.35v.874l-3.926 2.35z" fill="#e44d26"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-views-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ff8a65" fill-rule="nonzero"/><path d="M12.487 21.868L11.384 9.5H23.5l-1.104 12.366-4.961 1.375-4.948-1.373zm4.464-3.2l-3.926-2.36v-.855l3.926-2.361v1.323l-2.504 1.465 2.504 1.465v1.323zm.982-.001v-1.323l2.522-1.464-2.522-1.464v-1.323l3.926 2.35v.874l-3.926 2.35z" fill="#e44d26"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vscode" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#42a5f5" fill-rule="nonzero"/><path d="M20.811 8.52l-5.988 5.506-3.346-2.522-1.383.805 3.298 3.03-3.298 3.032 1.383.807 3.346-2.522 5.988 5.503 2.921-1.419V9.94zm0 3.622v6.396l-4.245-3.198z" fill="#bbdefb" stroke-width=".974"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vscode-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#42a5f5" fill-rule="nonzero"/><path d="M20.81 8.52l-5.988 5.506-3.346-2.522-1.384.805 3.3 3.03-3.3 3.032 1.384.807 3.346-2.522 5.988 5.503 2.921-1.419V9.94zm0 3.621v6.397l-4.245-3.198z" fill="#bbdefb" stroke-width=".974"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vue" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#009688" fill-rule="nonzero"/><g transform="translate(8.459 6.362) scale(.69572)"><path d="M1.821 4.15l10.21 17.618L22.239 4.235v-.084h-7.692l-2.434 4.178-2.422-4.178z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179z" fill="#35495e"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vue-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#009688"/><g transform="translate(8.458 6.362) scale(.69572)"><path d="M1.821 4.15l10.21 17.618L22.239 4.235v-.084h-7.692l-2.434 4.178-2.422-4.178z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179z" fill="#35495e"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-webpack" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#03a9f4" fill-rule="nonzero"/><g transform="translate(9.192 7.48) scale(.66328)"><path d="M19.376 15.988l-7.708 4.45-7.709-4.45v-8.9l7.709-4.451 7.708 4.45z" fill="#fff" fill-opacity=".785"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18s.41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.94v2.103h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#1c78c0"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-webpack-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#03a9f4"/><g transform="translate(9.193 7.48) scale(.66328)"><path d="M19.376 15.988l-7.708 4.45-7.709-4.45v-8.9l7.709-4.451 7.708 4.45z" fill="#fff" fill-opacity=".785"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18s.41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.94v2.103h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#1c78c0"/></g></symbol><symbol viewBox="0 0 24 24" id="font" xmlns="http://www.w3.org/2000/svg"><path d="M9.62 12L12 5.67 14.37 12M11 3L5.5 17h2.25l1.12-3h6.25l1.13 3h2.25L13 3h-2z" fill="#f44336"/></symbol><symbol viewBox="0 0 500 500" id="fsharp" xmlns="http://www.w3.org/2000/svg"><path d="M235.906 36.66L21.963 250.601l213.943 213.943v-84.36L106.209 250.487l129.697-129.696z" fill="#378bba" stroke-width="14.706"/><path d="M235.906 156.614l-93.622 93.62 93.622 93.622z" fill="#378bba" stroke-width="15.006"/><path d="M263.417 36.64L477.36 250.583 263.417 464.526v-84.36l129.696-129.697-129.696-129.696z" fill="#30b9db" stroke-width="14.706"/></symbol><symbol viewBox="0 0 152.99 160.01" id="fusebox" xmlns="http://www.w3.org/2000/svg"><defs id="fkdefs4"><style id="fkstyle2">.fkcls-1{fill:#fff}.fkcls-2{fill:#515151}.fkcls-3{fill:#1d79bf}.fkcls-4{fill:#383838}</style></defs><title id="fktitle6">Asset 3</title><g id="fkLayer_2" data-name="Layer 2" transform="matrix(.87285 0 0 .87285 10.17 10.175)"><g id="fkFuse_Box" data-name="Fuse Box"><g id="fkLOGO"><path class="fkcls-1" id="fkpolygon8" fill="#fff" d="M76.56 2.19l74.22 24.93-7.7 87.77-65.41 42.66-69.79-43.93-5.7-86.13z"/><path class="fkcls-2" d="M77.69 160L5.87 114.81 0 26 76.55 0 153 25.67l-7.94 90.4zM9.88 112.43l67.77 42.66 63.45-41.39 7.47-85.13-72-24.18L4.36 28.95z" id="fkpath10" fill="#515151"/><path class="fkcls-3" id="fkpolygon12" fill="#1d79bf" d="M76.4 148.8V61.68l66.93-29.82-5.99 78.77z"/><path id="fkF" class="fkcls-4" fill="#383838" d="M76.4 148.8l-60.35-37.39L9.63 31.8 76.4 61.68z"/><path class="fkcls-1" d="M25.58 52.73l.54 15.93 37.35 18.18.12 14.69-37-18.21 1.64 37.1-14.56-9-5.05-80.55 67.79 30.82v15.46z" id="fkpath15" fill="#fff"/><path class="fkcls-1" d="M135.91 90.77c-.08 13.12-6.33 26.59-16.77 33.12l-42.8 27.93V61.71l42.27-18.84c5.16-2.41 9.51-1.43 12.4 3.11 1.9 3 2.89 7.23 2.86 12.21A35.69 35.69 0 0 1 129.34 76c4.29 2 6.66 6.55 6.57 14.77zM123 63.76c0-4.64-2-6.93-4.92-5.45l-29 14.48L89 90l29.44-15.59c2.5-1.32 4.56-5.91 4.56-10.65zM125.15 96c0-5.71-2.42-8.24-6.55-5.93L89 106.64v19.58l29.34-17.46c4.43-2.64 6.79-7.27 6.81-12.76z" id="fkpath17" fill="#fff"/><path id="fkTOP" class="fkcls-4" fill="#383838" d="M76.4 8.82L9.71 31.77l109.77 2.38-84.02 9.21L76.4 61.68l20.76-9.25-27.73-1.37 49.78-8.46 24.12-10.74z"/></g></g></g></symbol><symbol viewBox="0 0 24 24" id="git" xmlns="http://www.w3.org/2000/svg"><path d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2 2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2 2 2 0 0 0 2-2 2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82z" fill="#e64a19"/></symbol><symbol viewBox="0 0 494 455" id="gitlab" xmlns="http://www.w3.org/2000/svg"><title>logo</title><defs><path id="fma" d="M0 1173.3h2000V0H0v1173.3z"/></defs><g transform="matrix(.88256 0 0 -.88256 -286.767 742.766)" fill="none" fill-rule="evenodd"><mask id="fmb" fill="#fff"><use width="100%" height="100%" xlink:href="#fma"/></mask><g mask="url(#fmb)"><g transform="translate(358.67 358.67)"><path d="M492.532 195.445l-27.559 84.815-54.617 168.1c-2.81 8.648-15.045 8.648-17.856 0l-54.619-168.1h-181.37l-54.62 168.1c-2.81 8.648-15.045 8.648-17.856 0l-54.617-168.1-27.557-84.815a18.775 18.775 0 0 1 6.82-20.992l238.51-173.29 238.51 173.29a18.777 18.777 0 0 1 6.82 20.992" fill="#fc6d26"/><path d="M247.2 1.16l90.684 279.1h-181.37z" fill="#e24329"/><path d="M247.201 1.16l-90.684 279.09H29.427z" fill="#fc6d26"/><path d="M29.422 280.256L1.862 195.44a18.774 18.774 0 0 1 6.822-20.991L247.194 1.16z" fill="#fca326"/><path d="M29.422 280.26h127.09l-54.619 168.1c-2.81 8.65-15.047 8.65-17.856 0z" fill="#e24329"/><path d="M247.2 1.16l90.684 279.09h127.09z" fill="#fc6d26"/><path d="M464.98 280.256l27.559-84.815a18.774 18.774 0 0 0-6.821-20.991L247.208 1.16z" fill="#fca326"/><path d="M464.97 280.26H337.88l54.619 168.1c2.81 8.65 15.047 8.65 17.856 0z" fill="#e24329"/></g></g></g></symbol><symbol viewBox="0 0 24 24" id="go" xmlns="http://www.w3.org/2000/svg"><path d="M10.575 1.695c-2.634 0-4.756 2.453-4.756 5.502v4.6l-.027-.003v4.71c0 3.05 2.123 5.502 4.757 5.502h2.286c2.634 0 4.757-2.453 4.757-5.502v-4.6a5.1 5.1 0 0 0 .026.003v-4.71c0-3.049-2.122-5.502-4.756-5.502h-2.287z" fill="#73cddc"/><rect width="2.289" height="3.335" x="-1.178" y="6.092" ry="1.125" transform="matrix(.4849 -.87457 .85979 .51065 0 0)" fill="#73cddc"/><rect width="2.297" height="3.39" x="10.261" y="-15.076" ry="1.143" transform="matrix(.44646 .8948 -.89204 .45195 0 0)" fill="#73cddc"/><circle cx="9.267" cy="5.13" r="2.054" fill="#fff" stroke="#5e5d5b" stroke-width=".1"/><circle cx="14.214" cy="5.116" r="2.054" fill="#fff" stroke="#5e5d5b" stroke-width=".1"/><ellipse cx="8.039" cy="5.051" rx=".792" ry=".901" fill="#030d18"/><path d="M11.792 9.556l.763.138a.403.689 0 0 1 .008.138.403.689 0 0 1-.402.69.403.689 0 0 1-.404-.69.403.689 0 0 1 .035-.276z" fill="#fff" stroke="#fff" stroke-width=".155"/><ellipse cx="8.51" cy="5.365" rx=".138" ry=".166" fill="#fff"/><ellipse cx="12.945" cy="5.189" rx=".792" ry=".901" fill="#030d18"/><ellipse cx="13.414" cy="5.446" rx=".138" ry=".166" fill="#fff"/><ellipse cx="-12.982" cy="-3.409" rx=".708" ry="1.026" transform="rotate(-129.403)" fill="#f6d2a1" stroke-width=".4"/><path d="M11.772 9.553l-.757.135a.4.672 0 0 0-.008.135.4.672 0 0 0 .4.672.4.672 0 0 0 .4-.672.4.672 0 0 0-.035-.27z" fill="#fff" stroke="#fff" stroke-width=".153"/><ellipse cx="1.841" cy="-21.563" rx=".707" ry="1.026" transform="scale(1 -1) rotate(50.597)" fill="#f6d2a1" stroke-width=".4"/><ellipse cx="-17.281" cy="-21.784" rx=".864" ry="1.27" transform="matrix(.3054 -.95222 -.97065 -.2405 0 0)" fill="#f6d2a1" stroke-width=".4"/><ellipse cx="22.885" cy="2.587" rx=".864" ry="1.27" transform="matrix(.22652 .974 .95652 -.29167 0 0)" fill="#f6d2a1" stroke-width=".4"/><path d="M10.708 8.392a.594.594 0 0 0-.594.597v.115c0 .331.264.598.594.598h.386a.973.772 0 0 1 .697-.235.973.772 0 0 1 .698.235h.334c.33 0 .594-.267.594-.598V8.99a.595.595 0 0 0-.594-.597h-2.115z" fill="#f6d2a1" stroke="#657075" stroke-width=".1"/><ellipse cx="11.734" cy="8.203" rx="1.208" ry=".68" fill="#030d18" stroke="#fff" stroke-width=".162"/></symbol><symbol viewBox="0 0 24 24" id="gradle" xmlns="http://www.w3.org/2000/svg"><path d="M21.718 5.503c-.731-1.315-2.04-1.708-2.963-1.727-1.133-.023-2.065.605-1.888 1.017.037.088.25.55.38.741.19.275.527.064.646 0 .353-.187.73-.248 1.16-.198.409.048.954.3 1.319 1.001.859 1.652-1.794 5.05-5.114 2.697-3.32-2.353-6.548-1.574-8.01-1.1-1.462.475-2.135.952-1.556 2.055.785 1.498.524 1.038 1.285 2.28 1.21 1.97 3.856-.908 3.856-.908-1.972 2.906-3.662 2.204-4.31 1.188a15.864 15.864 0 0 1-1.038-1.97c-4.993 1.76-3.642 9.534-3.642 9.534h2.48c.632-2.862 2.892-2.757 3.28 0h1.892c1.673-5.59 5.914 0 5.914 0h2.466c-.69-3.812 1.388-5.01 2.697-7.246 1.31-2.235 2.551-4.969 1.146-7.364zm-6.362 7.362c-1.304-.426-.837-1.723-.837-1.723s1.139.368 2.68.87c-.09.403-.856 1.175-1.843.853z" fill="#0097a7" stroke-width=".47"/></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 300 300" id="graphcool" xmlns="http://www.w3.org/2000/svg"><path d="M246.886 107.727c-12.237-6.892-27.616 2.1-30.081 3.646l-52.834 29.965c-7.8-6.196-18.914-5.933-26.412.625-7.499 6.558-9.24 17.537-4.14 26.094 5.102 8.556 15.588 12.246 24.923 8.768 9.335-3.478 14.852-13.129 13.111-22.937l52.688-29.9.321-.196c3.464-2.188 11.5-5.462 15.256-3.34 2.706 1.524 4.252 6.629 4.376 14.148h-.066v66.092a17.313 17.313 0 0 1-8.635 14.95l-75.739 43.755a17.312 17.312 0 0 1-17.261 0l-75.74-43.756a17.312 17.312 0 0 1-8.634-14.95V113.22c.01-6.165 3.3-11.86 8.634-14.95l68.549-39.562c6.522 7.482 17.451 9.25 26 4.206s12.283-15.468 8.886-24.794c-3.397-9.327-12.962-14.904-22.751-13.27-9.79 1.636-17.022 10.02-17.204 19.944L59.397 85.632a31.932 31.932 0 0 0-15.978 27.588v87.454a31.933 31.933 0 0 0 15.927 27.602l75.74 43.755a31.934 31.934 0 0 0 31.846 0l75.74-43.755a31.933 31.933 0 0 0 15.927-27.58V137.12h.05c.373-14.913-3.616-24.794-11.762-29.389z" fill="#27ae60" stroke="#27ae60" stroke-width="7.883622079999999"/></symbol><symbol viewBox="0 0 400 400" id="graphql" xmlns="http://www.w3.org/2000/svg"><path d="M67.008 293.022l-13.143-7.588L200.282 31.839l13.143 7.588z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M50.855 265.174H343.69v15.177H50.855z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M203.122 358.269L56.649 273.7l7.589-13.143 146.472 84.568zm127.24-220.407L183.889 53.293l7.589-13.143 146.472 84.568z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M64.278 137.803l-7.588-13.142 146.472-84.568 7.588 13.143z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M327.661 293.025L181.244 39.43l13.143-7.589 146.417 253.596zM62.466 114.597h15.176v169.136H62.466zm254.528 0h15.176v169.136h-15.176z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M200.538 351.845l-6.628-11.481L321.3 266.812l6.629 11.48z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M352.284 288.67c-8.777 15.268-28.342 20.48-43.61 11.703-15.268-8.777-20.48-28.342-11.703-43.61 8.777-15.268 28.342-20.48 43.61-11.703 15.36 8.869 20.57 28.342 11.703 43.61M97.574 141.567c-8.778 15.268-28.343 20.48-43.61 11.703-15.269-8.777-20.48-28.342-11.703-43.61 8.777-15.268 28.342-20.48 43.61-11.703 15.268 8.869 20.479 28.342 11.702 43.61M42.353 288.67c-8.777-15.268-3.566-34.741 11.702-43.61 15.268-8.776 34.741-3.565 43.61 11.703 8.776 15.268 3.565 34.741-11.703 43.61-15.36 8.776-34.833 3.565-43.61-11.703m254.71-147.103c-8.776-15.268-3.565-34.741 11.703-43.61 15.268-8.776 34.742-3.565 43.61 11.703 8.777 15.268 3.566 34.741-11.702 43.61-15.268 8.776-34.833 3.565-43.61-11.703m-99.745 236.608c-17.645 0-31.907-14.262-31.907-31.907s14.262-31.907 31.907-31.907 31.907 14.262 31.907 31.907c0 17.554-14.262 31.907-31.907 31.907m0-294.206c-17.645 0-31.907-14.262-31.907-31.907s14.262-31.907 31.907-31.907 31.907 14.262 31.907 31.907-14.262 31.907-31.907 31.907" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/></symbol><symbol viewBox="0 0 24 24" id="groovy" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.982a10.119 10.119 0 0 0-10.12 10.12A10.119 10.119 0 0 0 12 22.22 10.119 10.119 0 0 0 22.12 12.1 10.119 10.119 0 0 0 12 1.983zm1.254 2.422c.91 0 1.647.261 2.213.78.571.518.857 1.188.857 2.013 0 .889-.319 1.673-.959 2.35-.64.677-1.376 1.015-2.207 1.015-.486 0-.89-.119-1.213-.357-.317-.238-.476-.532-.476-.88 0-.212.06-.4.181-.563.127-.164.274-.246.438-.246.159 0 .238.092.238.277 0 .164.06.29.182.38.121.09.261.136.42.136.423 0 .828-.29 1.215-.866.391-.582.587-1.202.587-1.863 0-.465-.151-.844-.453-1.135-.301-.296-.69-.445-1.166-.445-.714 0-1.406.318-2.078.953-.666.635-1.211 1.47-1.635 2.506-.417 1.031-.627 2.014-.627 2.945 0 .857.185 1.54.555 2.047.37.503.863.754 1.477.754 1.037 0 2.027-.734 2.974-2.2l1.493-.212c.185-.026.277.018.277.135 0 .053-.072.28-.215.681-.143.402-.337 1.074-.586 2.016.82-.476 1.455-1.003 1.904-1.58v.914c-.36.418-1.046.888-2.062 1.412-.212 1.407-.682 2.493-1.406 3.26-.725.772-1.54 1.16-2.444 1.16-.433 0-.775-.102-1.023-.303-.243-.2-.365-.477-.365-.832 0-.984.955-1.94 2.865-2.865.2-.714.395-1.356.586-1.928-.333.482-.817.907-1.451 1.278-.635.37-1.225.554-1.77.554-.889 0-1.628-.383-2.22-1.15-.588-.772-.881-1.748-.881-2.928 0-1.243.333-2.42 1-3.531a7.747 7.747 0 0 1 2.625-2.674c1.084-.672 2.134-1.008 3.15-1.008zM12.03 16.592c-1.375.687-2.062 1.365-2.062 2.031 0 .354.169.533.508.533.666 0 1.184-.856 1.554-2.564z" fill="#26c6da"/></symbol><symbol viewBox="0 0 24 24" id="gulp" xmlns="http://www.w3.org/2000/svg"><path d="M8.37 15.94a596.238 596.238 0 0 1-.482-4.982c.002-.042-.225-.077-.505-.077h-.508V8.95h3.966V5.198l1.871-1.124c1.14-.685 1.978-1.125 2.144-1.125.4 0 .866.506.866.939 0 .19-.057.422-.127.517-.07.095-.722.53-1.45.966l-1.321.792-.029 1.393-.028 1.393h3.972v1.932h-.98l-.495 4.983-.495 4.983H8.854l-.485-4.906z" fill="#e53935"/></symbol><symbol viewBox="0 0 24 24" id="h" xmlns="http://www.w3.org/2000/svg"><path d="M16.745 19.818h-3.007v-5.882q0-2.381-1.736-2.381-.869 0-1.438.663-.56.662-.56 1.718v5.882H6.988V4.533h3.016v6.508h.037q1.186-1.802 3.193-1.802 3.511 0 3.511 4.239z" stroke-width=".478" fill="#0277bd"/></symbol><symbol viewBox="0 0 253.6 253.6" id="hack" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-29.243 -29.515) scale(1.2301)"><path fill="#607d8b" d="M69.496 159.551v52.576l51.77-52.576zM123.507 41.523l-54.01 52.755v55.084l54.01-54.009z"/><path fill="#eceff1" d="M130.023 95.663v51.501l52.128-51.5z"/><path fill="#607d8b" d="M185.465 101.867l-55.442 55.174v55.083l55.442-55.262z"/><path fill="#ffa000" d="M73.068 154.283l50.427.09v-50.248z"/></g></symbol><symbol viewBox="0 0 300 300.00001" id="haml" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 165.6)"><path d="M78.42-132.307c-12.047-.302-26.924 5.998-26.924 5.998l49.195 99.791L74.605 85.005c23.81 20.134 50.07 10.504 50.07 10.504L136.76 9.212c1.526 1.446 3.146 2.77 4.777 3.995 5.244 3.714 10.925 6.553 16.606 8.738 5.68 2.185 11.583 3.933 17.482 5.244 3.933.874 7.645 1.53 11.578 1.967-1.748 3.933-2.84 8.083-2.621 12.672 0 .437.22.873.656 1.092h.217c4.152 2.185 8.521 3.934 13.328 5.027 4.589.874 9.615 1.312 14.422.656 5.026-.655 10.051-2.623 13.984-5.9 3.933-3.278 6.774-7.648 8.522-12.237l.219-.218v-.217l.656-5.899v-.22c2.185-1.311 4.37-2.621 6.555-4.37 2.622-2.184 5.025-4.589 6.773-7.648 1.748-3.059 2.84-6.774 2.621-10.488-.218-3.496-1.53-6.99-3.06-10.049-1.53-3.059-3.495-5.901-5.68-8.523-4.37-5.026-9.614-9.176-15.295-12.454-5.462-3.496-11.581-6.338-17.7-8.304l-2.404-.656-1.962-.655c-1.311-.437-2.406-1.092-3.498-1.53-2.185-1.31-3.717-2.622-4.809-4.37-2.185-3.278-2.403-8.301-1.31-13.545.218-1.311.656-2.623 1.093-3.934a96.064 96.064 0 0 0 1.31-4.152c.314-1.412.51-2.829.598-4.402l29.203-25.553c-2.275-8.404-27.488-17.158-27.488-17.158l-74.931 63.726-43.243-81.584c-1.553-.35-3.218-.527-4.94-.57zm107.682 73.14c-.449 2.336-.647 4.795-.647 7.258.219 3.715 1.311 7.87 3.715 11.366 2.403 3.496 5.68 6.117 8.957 7.646a29.663 29.663 0 0 0 5.027 1.967l2.623.654 2.184.438c5.68 1.53 11.142 3.714 16.168 6.554 5.025 2.84 9.833 6.337 13.766 10.27s6.992 8.959 7.43 13.984c.218 3.496-.22 6.118-1.313 8.303-1.093 2.404-2.84 4.588-4.807 6.555-.874.874-1.966 1.747-2.84 2.402a27.11 27.11 0 0 0-.654-5.898c-.219-1.093-.438-1.966-.875-3.059-.437-.874-.872-1.966-1.965-2.621-.218 0-.44-.001-.44.217-1.31 3.277-3.494 6.12-5.898 8.086-2.403 1.966-5.462 2.84-8.521 3.058-3.06.219-6.338-.436-9.616-1.31-3.277-.874-6.552-1.968-9.83-3.06l-.439-.22c-.656-.218-1.526.002-1.963.44-1.748 2.185-3.06 4.149-4.59 6.334a58.435 58.435 0 0 0-2.84 5.027c-3.933-1.53-7.649-2.841-11.582-4.37-5.462-2.186-10.925-4.37-15.95-6.991-5.245-2.404-10.268-5.246-14.638-8.524-3.15-2.363-6.062-4.845-8.185-7.681l2.404-17.172z" fill="#f4511e" stroke-width="0" stroke-linejoin="round"/></g></symbol><symbol viewBox="0 0 24 24" id="handlebars" xmlns="http://www.w3.org/2000/svg"><path d="M8.55 10.32c-2.753 0-4.202 3.48-5.793 3.48-.98 0-1.126-.677-1.126-.915 0-.332.236-.706.564-.706.59 0 .414.77.414.77s.798-.555.272-1.298c-.42-.595-1.31-.623-1.92-.17-.617.458-1.057 1.146-.853 2.287.1.551.468 1.35 1.233 1.805.764.455 1.925.566 2.335.566 2.194 0 4.342-1.633 6.639-2.322a5.513 5.513 0 0 1 1.497-.222 6.19 6.19 0 0 1 1.92.226c2.296.689 4.444 2.323 6.638 2.323.41 0 1.57-.11 2.335-.566.765-.455 1.132-1.256 1.231-1.807.204-1.14-.235-1.829-.853-2.287-.61-.453-1.497-.423-1.918.172-.526.743.27 1.297.27 1.297s-.176-.77.414-.77c.329 0 .565.373.565.705 0 .238-.147.914-1.126.914-1.592 0-3.04-3.478-5.794-3.478-2.565 0-3.076 1.177-3.462 1.718-.004.005-.005.011-.008.016-.005-.006-.007-.013-.012-.02-.386-.54-.896-1.717-3.461-1.717z" fill="#ff7043" fill-rule="evenodd" stroke-width=".3"/></symbol><symbol viewBox="0 0 300.00001 300" id="haskell" xmlns="http://www.w3.org/2000/svg"><g stroke-width="2.422"><path d="M23.928 240.5l59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z" fill="#ef5350"/><path d="M83.869 240.5l59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z" fill="#ffa726"/><path d="M228.72 188.08l-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924l-19.98-29.953h99.901v29.953z" fill="#ffee58"/></g></symbol><symbol viewBox="0 0 210 210" id="haxe" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -87)"><path fill="#f68712" stroke-width=".221" d="M42.78 191.545l63.431-63.43 63.431 63.43-63.431 63.431z"/><path d="M42.8 191.592L31.193 148.28 19.59 104.97 62.9 116.575l43.311 11.605-31.706 31.706z" fill="#fab20b" stroke-width=".266"/><path d="M105.956 128.111l-43.19-11.544-43.177-11.597 22.927.185 23.228.294 20.264 11.36z" fill="#fbc707" stroke-width=".265"/><path d="M19.59 104.97l11.596 43.176 11.545 43.19-11.303-19.948-11.36-20.263-.294-23.228z" fill="#fff200" stroke-width=".265"/><path d="M106.23 128.133l43.312-11.605 43.311-11.605-11.605 43.31-11.605 43.312-31.706-31.706z" fill="#f47216" stroke-width=".266"/><path d="M169.711 191.289l11.545-43.19 11.597-43.176-.185 22.927-.294 23.228-11.36 20.263z" fill="#f1471d" stroke-width=".265"/><path d="M192.853 104.923l-43.176 11.597-43.19 11.544 19.947-11.303 20.264-11.36 23.228-.293z" fill="#fbc707" stroke-width=".265"/><path d="M169.643 191.545l11.605 43.31 11.605 43.312-43.311-11.605-43.311-11.606 31.706-31.705z" fill="#f25c19" stroke-width=".266"/><path d="M106.487 255.025l43.19 11.544 43.176 11.598-22.927-.185-23.228-.294-20.264-11.36z" fill="#f68712" stroke-width=".265"/><path d="M192.853 278.167l-11.597-43.176-11.545-43.19 11.303 19.947 11.36 20.264.294 23.228z" fill="#f1471d" stroke-width=".265"/><path d="M106.211 254.976l-43.31 11.605-43.312 11.605 11.605-43.31L42.8 191.563l31.706 31.706z" fill="#f89c0e" stroke-width=".266"/><path d="M42.731 191.82l-11.545 43.19-11.597 43.176.185-22.927.294-23.228 11.36-20.263z" fill="#fff200" stroke-width=".265"/><path d="M19.59 278.186l43.175-11.597 43.19-11.544-19.947 11.303-20.264 11.36-23.228.293z" fill="#f25c19" stroke-width=".265"/></g></symbol><symbol viewBox="0 0 144 152" id="heroku" xmlns="http://www.w3.org/2000/svg"><path d="M118.68 13.279H26.865c-6.337 0-11.476 5.139-11.476 11.476V129.32c0 6.338 5.139 11.477 11.476 11.477h91.813c6.338 0 11.477-5.14 11.477-11.477V24.755c0-6.337-5.139-11.476-11.477-11.476zM44.08 121.669V96.165l14.346 12.752zm44.632 0v-38.08c-.063-2.976-1.496-6.551-7.97-6.551-12.966 0-27.51 6.52-27.654 6.586l-9.008 4.08V32.407h12.752v36.201c6.366-2.072 15.266-4.321 23.91-4.321 7.882 0 12.6 3.099 15.17 5.698 5.484 5.547 5.56 12.613 5.551 13.43v38.255zm3.188-68.54H79.149c5.011-6.576 8.158-13.496 9.564-20.723h12.751c-.86 7.243-3.796 14.187-9.563 20.722z" fill="#6963b9"/></symbol><symbol viewBox="0 0 24 24" id="hpp" xmlns="http://www.w3.org/2000/svg"><path d="M9.757 19.818H6.751v-5.882q0-2.381-1.737-2.381-.868 0-1.438.663-.56.662-.56 1.718v5.882H0V4.533h3.016v6.508h.037Q4.24 9.239 6.247 9.239q3.51 0 3.51 4.239z" stroke-width=".478" fill="#0277bd"/><path d="M13.073 11.448v2h-2v2h2v2h2v-2h2v-2h-2v-2zm7 0v2h-2v2h2v2h2v-2h2v-2h-2v-2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="html" xmlns="http://www.w3.org/2000/svg"><path d="M12 17.56l4.07-1.13.55-6.1H9.38L9.2 8.3h7.6l.2-1.99H7l.56 6.01h6.89l-.23 2.58-2.22.6-2.22-.6-.14-1.66h-2l.29 3.19L12 17.56M4.07 3h15.86L18.5 19.2 12 21l-6.5-1.8L4.07 3z" fill="#e44d26"/></symbol><symbol viewBox="0 0 24 24" id="http" xmlns="http://www.w3.org/2000/svg"><path d="M16.046 13.784c.074-.613.13-1.225.13-1.856s-.056-1.244-.13-1.856h3.137c.148.594.241 1.215.241 1.856a7.65 7.65 0 0 1-.241 1.856m-4.78 5.16c.557-1.03.984-2.144 1.281-3.304h2.738a7.452 7.452 0 0 1-4.019 3.304m-.232-5.16H9.828a12.314 12.314 0 0 1-.149-1.856c0-.631.056-1.253.149-1.856h4.343c.084.603.149 1.225.149 1.856 0 .63-.065 1.243-.149 1.856M12 19.315c-.77-1.113-1.393-2.348-1.773-3.675h3.545c-.38 1.327-1.002 2.562-1.773 3.675m-3.712-11.1h-2.71a7.353 7.353 0 0 1 4.01-3.304c-.557 1.03-.975 2.144-1.3 3.304m-2.71 7.425h2.71c.325 1.16.743 2.274 1.3 3.304a7.433 7.433 0 0 1-4.01-3.304m-.761-1.856a7.65 7.65 0 0 1-.241-1.856c0-.64.093-1.262.241-1.856h3.137c-.074.612-.13 1.225-.13 1.856 0 .63.056 1.243.13 1.856m4.046-9.253c.77 1.114 1.393 2.357 1.773 3.684h-3.545c.38-1.327 1.002-2.57 1.773-3.684m6.422 3.684h-2.738a14.523 14.523 0 0 0-1.28-3.304 7.412 7.412 0 0 1 4.018 3.304m-6.423-5.568c-5.132 0-9.28 4.176-9.28 9.28a9.28 9.28 0 0 0 9.28 9.282 9.28 9.28 0 0 0 9.281-9.281A9.28 9.28 0 0 0 12 2.647z" fill="#e53935" stroke-width=".928"/></symbol><symbol viewBox="0 0 24 24" id="image" xmlns="http://www.w3.org/2000/svg"><path d="M13.009 9.202h5.368l-5.368-5.368v5.368M6.177 2.37h7.808l5.856 5.856v11.711a1.952 1.952 0 0 1-1.952 1.952H6.178a1.951 1.951 0 0 1-1.952-1.952V4.322c0-1.083.868-1.952 1.952-1.952m0 17.567h11.71V12.13l-3.903 3.903-1.952-1.951-5.856 5.855M8.13 9.202a1.952 1.952 0 0 0-1.952 1.952 1.952 1.952 0 0 0 1.952 1.952 1.952 1.952 0 0 0 1.952-1.952A1.952 1.952 0 0 0 8.13 9.202z" fill="#26a69a" stroke-width=".976"/></symbol><symbol viewBox="0 0 512 512" id="ionic" xmlns="http://www.w3.org/2000/svg"><g fill="#4f8ff7"><path d="M423.592 132.804A31.855 31.855 0 0 0 429 115c0-17.675-14.33-32-32-32a31.853 31.853 0 0 0-17.805 5.409C344.709 63.015 302.11 48 256 48 141.125 48 48 141.125 48 256c0 114.877 93.125 208 208 208 114.873 0 208-93.123 208-208 0-46.111-15.016-88.71-40.408-123.196zM391.83 391.832c-17.646 17.646-38.191 31.499-61.064 41.174-23.672 10.012-48.826 15.089-74.766 15.089-25.94 0-51.095-5.077-74.767-15.089-22.873-9.675-43.417-23.527-61.064-41.174s-31.5-38.191-41.174-61.064C68.982 307.096 63.905 281.94 63.905 256c0-25.94 5.077-51.095 15.089-74.767 9.674-22.873 23.527-43.417 41.174-61.064s38.191-31.5 61.064-41.174c23.673-10.013 48.828-15.09 74.768-15.09 25.939 0 51.094 5.077 74.766 15.089a191.221 191.221 0 0 1 37.802 21.327A31.853 31.853 0 0 0 365 115c0 17.675 14.327 32 32 32 5.293 0 10.28-1.293 14.678-3.568a191.085 191.085 0 0 1 21.327 37.801c10.013 23.672 15.09 48.827 15.09 74.767 0 25.939-5.077 51.096-15.09 74.768-9.675 22.873-23.527 43.418-41.175 61.064z"/><circle cx="256.003" cy="256" r="96"/></g></symbol><symbol viewBox="0 0 24 24" id="java" xmlns="http://www.w3.org/2000/svg"><path d="M2 21h18v-2H2M20 8h-2V5h2m0-2H4v10a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4v-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="javascript" xmlns="http://www.w3.org/2000/svg"><path d="M3 3h18v18H3V3m4.73 15.04c.4.85 1.19 1.55 2.54 1.55 1.5 0 2.53-.8 2.53-2.55v-5.78h-1.7V17c0 .86-.35 1.08-.9 1.08-.58 0-.82-.4-1.09-.87l-1.38.83m5.98-.18c.5.98 1.51 1.73 3.09 1.73 1.6 0 2.8-.83 2.8-2.36 0-1.41-.81-2.04-2.25-2.66l-.42-.18c-.73-.31-1.04-.52-1.04-1.02 0-.41.31-.73.81-.73.48 0 .8.21 1.09.73l1.31-.87c-.55-.96-1.33-1.33-2.4-1.33-1.51 0-2.48.96-2.48 2.23 0 1.38.81 2.03 2.03 2.55l.42.18c.78.34 1.24.55 1.24 1.13 0 .48-.45.83-1.15.83-.83 0-1.31-.43-1.67-1.03l-1.38.8z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="javascript-map" xmlns="http://www.w3.org/2000/svg"><path d="M18 8v2h2v10H10v-2H8v4h14V8h-4z" fill="#ffca28"/><path d="M2.444 2.506h14.135v14.136H2.444V2.506m3.714 11.811c.315.668.935 1.218 1.995 1.218 1.178 0 1.987-.629 1.987-2.003V8.993H8.805v4.508c0 .675-.275.848-.707.848-.455 0-.644-.314-.856-.683l-1.084.651m4.697-.14c.392.769 1.185 1.358 2.426 1.358 1.257 0 2.199-.652 2.199-1.854 0-1.107-.636-1.602-1.767-2.089l-.33-.141c-.573-.243-.816-.408-.816-.801 0-.322.243-.573.636-.573.377 0 .628.165.856.573l1.028-.683c-.432-.754-1.044-1.045-1.884-1.045-1.186 0-1.948.754-1.948 1.752 0 1.083.636 1.594 1.594 2.002l.33.141c.613.267.974.432.974.888 0 .377-.354.652-.903.652-.652 0-1.029-.338-1.312-.81l-1.083.63z" fill="#ffca28"/></symbol><symbol viewBox="0 0 180 180" id="jenkins" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="gia"><path transform="scale(1 -1)" fill="#37474f" d="M.899-144.42h144.42V0H.899z"/></clipPath></defs><g transform="matrix(1.0691 0 0 -1.0691 9.4 166.143)" clip-path="url(#gia)"><g fill-rule="evenodd"><path d="M107.96 30.661l-12.506-1.876-16.883-1.876-10.943-.312-10.629.312-8.13 2.502-7.19 7.815-5.628 15.945-1.25 3.44-7.504 2.5-4.377 7.191-3.126 10.317 3.44 9.067 8.128 2.814 6.565-3.127 3.127-6.878 3.752.626 1.25 1.563-1.25 7.19-.313 9.068 1.876 12.505-.074 7.143 5.701 9.114 10.005 7.19 17.508 7.504 19.383-2.814 16.883-12.193 7.817-12.505 5.002-9.067 1.25-22.51-3.752-19.384-6.877-17.195-6.566-9.066" fill="#f0d6b7"/><path d="M97.334-23.425l-44.709-1.876v-7.503l3.752-26.262-1.876-2.19-31.264 10.63-2.19 3.752-3.126 35.328-7.19 21.26-1.563 5.002 25.01 17.195 7.818 3.127 6.877-8.441 5.94-5.315 6.88-2.188 3.125-.938L68.57 1.899l2.814-3.44 7.19 2.502-5.002-9.693 27.2-12.818-3.439-1.876" fill="#335061"/><path d="M23.238 85.687l8.128 2.814 6.566-3.127 3.127-6.878 3.751.626.938 3.751-1.876 7.19 1.876 17.197-1.563 9.379 5.627 6.565 12.193 9.692-3.44 4.69-17.194-8.442-7.191-5.627-4.064-8.754-6.253-8.442-1.876-10.005 1.251-10.63" fill="#6d6b6d"/><path d="M36.055 115.07s4.69 11.567 23.448 17.195c18.759 5.628.938 4.065.938 4.065l-20.321-7.817-7.817-7.816-3.438-6.253 7.19.626M26.676 87.875s-6.566 21.886 18.446 25.012l-.938 3.752-17.195-4.065-5.003-16.257 1.251-10.63 3.439 2.188" fill="#dcd9d8"/></g><g fill="#f7e4cd"><path d="M36.681 58.799l4.094 3.966s1.847-.214 2.16-2.402c.312-2.19 1.25-21.886 14.693-32.516 1.227-.97-10.004 1.564-10.004 1.564L37.62 45.042M94.209 64.739s.729 9.477 3.28 8.748c2.553-.729 2.553-3.28 2.553-3.28s-6.198-4.01-5.833-5.468" fill-rule="evenodd"/><path d="M120.16 99.442s-5.153-1.088-5.628-5.628c-.474-4.54 5.628-.938 6.566-.625M82.327 99.129s-6.879-.938-6.879-5.314c0-4.378 7.817-4.065 10.005-2.19"/><g fill-rule="evenodd"><path d="M39.807 78.808s-11.881 7.191-13.131.312c-1.25-6.877-4.065-11.88 1.876-19.07l-4.064 1.25-3.752 9.691-1.25 9.38 7.19 7.504 8.129-.626 4.69-3.751.312-4.69M45.435 98.504s5.315 27.512 32.203 32.827c22.136 4.375 33.765-.938 38.142-5.94 0 0-19.696 23.447-38.455 16.257-18.759-7.191-32.514-20.322-32.202-28.762.532-14.377.313-14.382.313-14.382M117.97 122.27s-9.066.312-9.38-7.817c0 0 0-1.25.625-2.5 0 0 7.192 8.129 11.568 3.751"/><path d="M78.268 111.1s-1.56 12.477-12.199 5.223c-6.878-4.69-6.252-11.255-5.002-12.505s.91-3.77 1.862-2.04c.952 1.728.638 7.356 4.078 8.918 3.439 1.564 9.077 3.31 11.26.404"/></g></g><g fill="#49728b" fill-rule="evenodd"><path d="M48.874 26.597L19.486 13.466s12.193-48.46 5.94-63.467l-4.377 1.563-.313 18.446-8.128 35.015-3.44 9.692 30.639 20.633 9.067-8.753M51.896-.206l4.17-5.087v-18.76h-5.003s-.625 13.132-.625 14.696c0 1.563.624 7.19.624 7.19M52-26.866l-14.069-.625 4.065-2.813L52-31.868"/></g><g fill-rule="evenodd"><path d="M100.15-23.739l11.567.313 2.814-28.764-11.881-1.563-2.5 30.014" fill="#335061"/><path d="M103.27-23.739l17.508.938s7.19 18.133 7.19 19.07c0 .939 6.253 26.263 6.253 26.263l-14.069 14.694-2.813 2.501-7.504-7.503V3.148l-6.565-26.887" fill="#335061"/><path d="M111.09-21.55l-10.942-2.188 1.563-8.755c4.064-1.876 10.943 3.127 10.943 3.127M111.4 33.162l21.885-16.257.626 7.503-16.57 15.32-5.94-6.566" fill="#49728b"/><path d="M62.85-85.332l-6.473 26.266-3.22 19.38-.531 14.385 29.296 1.56 18.226.003-1.658-32.83 2.814-25.324-.312-4.69-23.76-1.876-14.382 3.126" fill="#fff"/><path d="M96.083-23.426s-1.563-32.515 3.127-55.65c0 0-9.38-5.94-23.136-7.503l26.262.938 3.126 1.875-3.752 51.273-.938 10.944" fill="#dcd9d8"/><path d="M115.06-49.691l12.193 3.44 23.135 1.25 3.44 10.629-6.254 18.446-7.19.938-10.005-3.127-9.599-4.686-5.095.935-3.972-1.56" fill="#fff"/><path d="M114.84-43.435s8.128 3.751 9.38 3.438L120.78-22.8l4.065 1.563s2.814-16.257 2.814-18.133c0 0 17.507-.938 19.07-.938 0 0 3.752 7.191 2.814 14.694l3.44-10.005.312-5.628-5.002-7.503-5.627-1.25-9.38.312-3.126 4.064-10.943-1.563-3.44-1.25" fill="#dcd9d8"/></g><path d="M102.56-21.241L95.682-3.733l-7.19 10.317s1.562 4.377 3.75 4.377h7.192l6.878-2.501-.625-11.568-3.127-18.134" fill="#fff"/><path d="M103.9-15.297S95.145 1.585 95.145 4.086c0 0 1.563 3.752 3.752 2.814 2.19-.938 6.879-3.439 6.879-3.439v5.94l-10.63 2.19-7.19-.939 12.193-28.763 2.5-.313" fill="#dcd9d8" fill-rule="evenodd"/><path d="M65.664 25.968l-8.661.942-8.13 2.501v-2.814l3.972-4.38 12.506-5.627" fill="#fff"/><path d="M51.689 25.031s9.693-4.065 12.819-3.127l.311-3.748-8.752 1.872-5.316 3.752.938 1.251" fill="#dcd9d8" fill-rule="evenodd"/><path d="M115.03 9.897c-5.305.156-10.098.786-14.294 1.97.285 1.72-.249 3.408.18 4.647 1.17.843 3.13.83 4.898 1.027-1.529.752-3.677 1.049-5.44.615-.042 1.194-.578 1.934-.902 2.868 2.982 1.064 10.024 8.044 13.984 5.732 1.887-1.099 2.689-7.377 2.835-10.43.122-2.533-.23-5.088-1.261-6.43" fill="#d33833" fill-rule="evenodd"/><path d="M115.03 9.897c-5.305.156-10.098.786-14.294 1.97.285 1.72-.249 3.408.18 4.647 1.17.843 3.13.83 4.898 1.027-1.529.752-3.677 1.049-5.44.615-.042 1.194-.578 1.934-.902 2.868 2.982 1.064 10.024 8.044 13.984 5.732 1.887-1.099 2.689-7.377 2.835-10.43.122-2.533-.23-5.088-1.261-6.43z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M89.66 18.569c-.014-.401-.03-.806-.047-1.21-1.656-1.089-4.33-1.076-6.148-1.99 2.68-.117 4.79-.763 6.614-1.672l-.118-3.033c-3.036-2.078-5.81-5.173-9.384-7.122-1.69-.922-7.622-3.294-9.42-2.875-1.017.236-1.109 1.499-1.516 2.689-.866 2.548-2.861 6.605-3.035 10.44-.222 4.846-.71 12.967 4.51 11.969 4.213-.804 9.113-2.745 12.375-4.527 1.993-1.09 3.146-2.436 6.17-2.669" fill="#d33833" fill-rule="evenodd"/><path d="M89.66 18.569c-.014-.401-.03-.806-.047-1.21-1.656-1.089-4.33-1.076-6.148-1.99 2.68-.117 4.79-.763 6.614-1.672l-.118-3.033c-3.036-2.078-5.81-5.173-9.384-7.122-1.69-.922-7.622-3.294-9.42-2.875-1.017.236-1.109 1.499-1.516 2.689-.866 2.548-2.861 6.605-3.035 10.44-.222 4.846-.71 12.967 4.51 11.969 4.213-.804 9.113-2.745 12.375-4.527 1.993-1.09 3.146-2.436 6.17-2.669z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M92.675 12.788c-.463 2.64-.999 3.393-.792 5.695 7.04 4.693 8.361-8.061.792-5.695" fill="#d33833" fill-rule="evenodd"/><path d="M92.675 12.788c-.463 2.64-.999 3.393-.792 5.695 7.04 4.693 8.361-8.061.792-5.695z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M102.87 10.649s-2.19 3.127-.626 4.065c1.564.938 3.127 0 4.065 1.563s0 2.501.313 4.377 1.877 2.189 3.44 2.501c1.562.313 5.94.938 6.565-.625l-1.876 5.627-3.752 1.25-11.88-6.877-.626-3.44v-6.877M70.041.331c-.376 4.88-.773 9.752-1.215 14.626-.662 7.279 1.748 6.009 8.057 6.009.964 0 5.933-1.15 6.289-1.876 1.705-3.483-2.851-2.709 1.964-5.335 4.065-2.216 11.246 1.346 9.603 6.273-.919 1.095-4.789.341-6.176 1.06l-7.327 3.8c-3.108 1.612-10.29 3.962-13.603 1.709-8.395-5.71.53-19.974 3.524-25.93" fill="#ef3d3a" fill-rule="evenodd"/><g fill="#231f20" fill-rule="evenodd"><path d="M78.268 111.1c-8.521 1.985-12.755-3.566-15.338-9.323-2.306.559-1.389 3.695-.806 5.294 1.525 4.194 7.672 9.778 12.694 9.02 2.161-.325 5.086-2.301 3.45-4.99M119.79 101.4l.404-.016c1.926-4 3.593-8.238 6.022-11.769-1.628-3.79-12.322-7.144-12.157-.338 2.313 1.01 6.305.206 8.356 1.497-1.186 3.254-2.897 6.024-2.625 10.626M82.63 101.29c1.827-3.35 2.422-6.868 5.019-9.4 1.17-1.14 3.444-2.529 2.316-5.698-.263-.747-2.189-2.414-3.3-2.741-4.06-1.2-13.521-.248-10.317 4.814 3.358-.157 7.871-2.18 10.38.257-1.927 3.081-5.363 9.177-4.098 12.768M118.26 67.253c-6.113-3.927-12.93-8.197-22.947-7.207-2.14 1.86-2.956 6.002-.877 8.737 1.082-1.861.402-5.284 3.419-5.799 5.684-.972 12.299 3.477 16.387 5.032 2.535 4.275-.219 5.847-2.503 8.597-4.675 5.636-10.947 12.622-10.72 21.06 1.89 1.37 2.053-2.092 2.325-2.722 2.44-5.714 8.585-13.021 13.07-17.912 1.1-1.205 2.914-2.36 3.115-3.157.582-2.315-1.513-5.09-1.27-6.63M37.668 71.387c-1.916 1.094-2.372 5.91-4.622 6.048-3.215.195-2.629-6.25-2.616-10.018-2.213 2.009-2.602 8.194-.976 11.37-1.853.91-2.68-1.003-3.708-1.677 1.32 9.595 14.036 4.45 11.922-5.723M122.15 63.257c-2.846-5.417-6.871-11.382-15.222-11.555-.17 1.75-.3 4.411.009 5.464 6.384.614 10.325 3.863 15.212 6.091M82.149 59.745c5.326-2.8 15.114-3.102 22.353-2.89.388-1.586.379-3.545.394-5.48-9.305-.463-20.307 1.84-22.747 8.37M81.136 54.523c3.683-9.247 16.341-8.182 27.016-7.927-.47-1.2-1.489-2.62-2.755-3.132-3.42-1.392-12.855-2.448-17.604.074-3.011 1.601-4.946 5.219-6.596 7.34-.797 1.024-4.765 3.64-.06 3.645"/></g><path d="M117.82 3.516c-4.322-7.402-8.457-15.005-13.585-21.534 2.15 6.32 3.07 16.9 3.394 24.965 4.498 2.105 8.349-.474 10.191-3.43" fill="#81b0c4" fill-rule="evenodd"/><g fill="#231f20" fill-rule="evenodd"><path d="M141.07-23.089c-4.839-.969-8.239-5.671-12.959-5.37 2.594 3.658 7.14 5.2 12.959 5.37M143.21-30.661c-3.944-.417-8.576-1.055-12.577-.726 1.894 2.892 9.19 1.894 12.577.726M144.58-37.19c-4.433-.096-9.942-.008-14.155.346 2.492 2.677 11.28.993 14.155-.346"/></g><g fill-rule="evenodd"><path d="M109.48-55.057c.636-5.567 2.843-11.207 2.566-17.304-2.45-.827-3.858-1.55-7.142-1.545-.232 5.181-.925 13.102-.718 18.041 1.615-.107 3.997 1.154 5.294.808" fill="#dcd9d8"/><path d="M102.33 26.985c-2.226-1.453-4.121-3.267-6.259-4.818-4.74-.235-7.327.328-10.81 3.05.057.219.407.121.42.39 5.075-2.262 11.524.92 16.648 1.378" fill="#f0d6b7"/><path d="M75.694-7.603c1.394 6.04 6.857 9.17 11.817 12.497 5.12-6.498 8.234-14.855 11.663-22.92-8.102 2.443-16.38 6.406-23.481 10.423" fill="#81b0c4"/><path d="M104.18-55.865c-.207-4.94.486-12.86.718-18.041 3.283-.004 4.691.718 7.142 1.545.276 6.096-1.93 11.737-2.566 17.304-1.298.346-3.679-.914-5.294-.808zm-51.13 28.09c2.165-19.906 5.301-36.639 11.054-54.266 12.766-3.876 28.157-4.214 39.441-.716-2.072 9.948-1.167 22.06-2.378 32.677-.912 7.98-.447 16.009-1.698 24.15-13.673 2.844-33 .665-46.418-1.845zm49.651 1.72c-.115-8.549.383-16.982 1.036-25.542 3.282.493 5.51.822 8.56 1.49-.99 8.241-.869 17.514-2.886 24.804-2.332-.023-4.385.027-6.71-.752zm16.653 1.378c-1.558.357-3.372.014-4.86-.015.7-6.969 2.397-14.659 2.995-21.974 2.342-.073 3.593 1.032 5.52 1.403.102 6.421-.562 15.268-3.655 20.586zm25.215-23.038c4.882 1.186 7.952 7.165 6.586 13.305-.916 4.127-2.548 11.898-4.295 14.538-1.29 1.953-4.79 4.51-7.584 2.72-4.545-2.91-12.552-3.755-15.867-7.278 1.662-5.534 2.178-13.135 2.864-20.146 5.678-.354 12.665 1.562 17.387-.471-3.297-1.068-7.575-1.077-10.423-2.633 2.328-1.125 7.778-.897 11.332-.035zM99.17-18.025c-3.43 8.063-6.543 16.42-11.663 22.918-4.96-3.327-10.423-6.456-11.817-12.497 7.1-4.017 15.379-7.98 23.481-10.422zm8.453 24.971c-.325-8.065-1.245-18.644-3.395-24.965 5.128 6.53 9.263 14.132 13.585 21.534-1.842 2.957-5.693 5.536-10.19 3.431zm-9.582 3.405c-1.943.21-3.592-2.233-6.117-1.177-.58-.64-1.105-1.333-1.695-1.958 5.579-6.723 8.114-16.262 12.423-24.163 2.312 7.59 2.045 15.904 2.555 24.188-3.177-.201-4.94 2.873-7.166 3.11zm-6.161 8.132c-.208-2.303.328-3.056.791-5.695 7.57-2.367 6.248 10.388-.791 5.695zm-8.394 2.755c-3.261 1.782-8.161 3.723-12.374 4.527-5.222.999-4.732-7.123-4.51-11.968.173-3.836 2.168-7.893 3.035-10.441.406-1.19.498-2.453 1.515-2.69 1.798-.418 7.73 1.954 9.42 2.875 3.575 1.95 6.348 5.045 9.384 7.123.04 1.011.078 2.021.119 3.032-1.826.91-3.935 1.555-6.615 1.673 1.818.914 4.492.901 6.148 1.989.016.405.033.81.047 1.21-3.024.234-4.176 1.58-6.17 2.67zm-31.152 5.659c-2.707-2.748 7.592-6.494 10.871-6.696-.018 1.739.991 3.378.788 4.626-3.895.684-9.013.232-11.66 2.07zm33.345-1.29c-.013-.27-.363-.172-.42-.39 3.482-2.722 6.07-3.285 10.81-3.05 2.137 1.551 4.033 3.365 6.259 4.818-5.124-.458-11.574-3.64-16.648-1.379zm30.606-9.282c-.146 3.053-.948 9.332-2.835 10.431-3.961 2.312-11.002-4.668-13.984-5.732.324-.934.86-1.674.901-2.868 1.764.434 3.912.137 5.44-.615-1.767-.198-3.727-.185-4.897-1.027-.429-1.239.105-2.927-.18-4.647 4.196-1.184 8.989-1.814 14.294-1.97 1.032 1.341 1.383 3.896 1.261 6.429zM47.777 24.24c-.85.606-6.6 8.087-7.388 7.777-10.405-4.103-20.134-11.199-28.828-17.91 8.29-17.787 11.635-39.579 12.227-60.582 9.496-4.441 17.836-10.844 30.722-11.512-1.491 10.55-2.852 19.962-3.699 29.895-3.237 1.365-7.882-.062-10.913.423-.025 3.651 4.628 1.6 5.015 4.054.292 1.858-2.56 1.998-1.631 4.923 2.368-.861 3.612-2.763 6.138-3.477 2.309 5.05-.032 13.985.3 18.205.064.792.397 4.39 2.172 3.759 1.57-.559-.09-9.569.082-13.563.157-3.68-.444-7.242 1.046-9.552a355.817 355.817 0 0 0 38.576 3.16c-2.964 1.272-6.485 2.475-10.345 4.651-2.093 1.18-8.69 3.635-9.293 5.622-.964 3.167 2.528 4.855 3.125 7.57-6.285-3.428-7.511 3.286-8.998 8.042-1.347 4.308-2.114 7.526-2.445 10.01-5.414 2.581-11.203 5.195-15.863 8.505zm63.009 6.872c8.67 4.204 10.232-15.711 6.834-22.127.525-1.914 2.331-2.646 3.069-4.366-4.838-8.667-10.211-16.756-15.148-25.32 3.672 2.286 8.917.409 13.238 2.12 1.58.624 2.722 4.24 3.918 7.133 3.29 7.958 6.743 17.99 8.28 25.586.346 1.73 1.292 5.5 1.08 7.04-.378 2.758-4.12 4.803-6.022 6.508-3.506 3.15-5.714 5.921-9.371 8.866-1.483-2.189-4.666-3.66-5.878-5.44zM27.95 107.99c-4.13-4.545-3.266-13.062-2.766-19.121 7.467 4.697 17.377-.372 17.284-8.36 3.565.094 1.332 4.452.687 7.259-2.107 9.169 3.55 19.13.256 27.516-6.395-.485-11.649-3.097-15.46-7.294zm29.558 26.38c-9.352-2.65-21.337-9.446-25.18-17.847 2.976.432 5.041 1.933 7.977 2.119 1.11.072 2.563-.466 3.838-.148 2.54.63 4.685 6.327 6.602 8.447 1.868 2.07 4.114 2.954 5.651 4.841.988.477 2.448.444 2.504 1.927-.428.457-.879.806-1.392.66zm48.681-2.493c-9.707 5.477-26.136 9.596-36.462 4.449-8.331-4.155-19.593-11.027-23.433-19.737 3.587-8.405-1.062-16.106-1.36-24.64-.157-4.54 2.139-8.504 2.315-13.446-1.228-2.025-4.978-2.275-7.574-2.136-.873 4.372-2.403 9.287-6.906 9.78-6.371.697-11.03-4.576-11.319-10.085-.342-6.48 4.978-17.22 12.517-16.475 2.913.287 3.629 3.207 6.802 3.177 1.72-3.432-2.653-4.51-3.103-6.964-.117-.634.363-3.112.642-4.274 1.37-5.658 4.422-12.982 7.427-17.29 3.814-5.464 11.307-6.288 19.37-6.823 1.44 3.101 6.743 2.846 10.2 2.035-4.143 1.64-7.993 5.617-11.185 9.137-3.665 4.039-7.378 8.371-7.566 13.65 6.927-9.61 12.65-18.003 25.246-22.23 9.53-3.196 20.662 1.465 27.986 6.608 3.039 2.137 4.853 5.529 7.013 8.634 8.082 11.626 11.854 28.219 11.024 44.303-.342 6.633-.327 13.244-2.552 17.706-2.326 4.666-10.193 8.84-14.8 4.62-.853 4.537 3.83 7.344 9.331 5.71-3.922 5.063-8.039 11.145-13.614 14.29zm18.084-149.66c7.585 3.77 21.757 10.149 26.512-.014 1.755-3.746 3.814-10.079 4.723-13.946 1.284-5.456-1.392-16.923-7-18.754-4.953-1.617-10.733-1.518-16.7-.32-.702.585-1.484 1.603-2.03 2.665-4.261.165-8.25-.229-11.615-1.98.319-3.15-1.812-3.656-3.81-4.305-1.48-5.872 2.963-13.541 1.9-18.896-.76-3.815-5.453-4.405-8.902-5.118-.113-2.12.15-3.89.386-5.683-.789-2.907-4.327-4.561-7.679-4.967-11.029-1.326-27.775-1.922-38.384 1.893-2.96 7.261-5.292 16.093-7.758 24.384-10.346-1.105-18.715 4.464-26.603 8.113-2.731 1.266-6.51 1.964-7.53 4.138-.99 2.105-.584 6.14-.83 9.95-.625 9.733-1.16 19.12-3.73 29.086-1.154 4.472-3.165 8.418-4.568 12.727C9.358 5.184 7.092 10.12 6.5 14.1c-.877 5.903 4.681 6.232 8.235 8.79 5.494 3.954 9.806 6.142 15.756 9.711 1.762 1.057 7.077 3.733 7.681 4.966 1.202 2.443-2.062 5.888-2.935 7.803-1.38 3.03-2.1 5.602-2.298 8.59-4.992.789-8.775 3.76-11.06 7.109-3.781 5.543-6.403 15.798-3.132 23.599.257.614 1.536 1.822 1.725 2.765.372 1.858-.7 4.329-.768 6.305-.343 10.14 1.716 18.875 8.541 21.932 2.771 11.038 12.688 14.71 22.032 20.195 3.493 2.05 7.343 3.36 11.32 4.824 14.263 5.25 36.15 4.261 47.987-4.692 5.02-3.797 13.044-11.813 15.914-17.617 7.58-15.323 7.042-40.931 1.74-59.571-.712-2.503-1.746-6.181-3.19-9.187-1.006-2.1-4.134-6.3-3.754-8.153.391-1.916 7.132-7.034 8.577-8.428 2.603-2.51 7.548-5.843 7.948-9.012.43-3.372-1.485-7.984-2.456-11.238-3.245-10.858-6.412-20.895-10.091-30.576" fill="#231f20"/><path d="M73.674 57.38c.411.548 2.674 1.38 5.84-.144 0 0-3.752-.626-3.44-6.881l-1.564.313s-1.615 5.672-.836 6.712" fill="#f7e4cd"/><path d="M101.09 3.617a1.72 1.72 0 1 0-3.44.001 1.72 1.72 0 0 0 3.44-.001M102.81-4.355a1.72 1.72 0 1 0-3.44 0 1.72 1.72 0 0 0 3.44 0" fill="#1d1919"/></g><g><rect transform="matrix(.8 0 0 -.8 0 144)" x="16.854" y="177.38" width="70.412" height="4.12" rx=".983" ry=".983"/><rect transform="scale(1 -1)" x="78.502" y="-2.097" width="50.037" height="3.296" rx=".786" ry=".786"/><rect transform="scale(1 -1)" x="13.483" y="-3.697" width="54.831" height="3.296" rx=".786" ry=".786"/><rect transform="scale(1 -1)" x="83.296" y="-3.697" width="45.243" height="3.296" rx=".786" ry=".786"/></g></g></symbol><symbol viewBox="0 0 24 24" id="json" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#fbc02d"/></symbol><symbol viewBox="0 0 50 50" id="julia" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -247)" stroke-width="5.673"><circle cx="13.497" cy="281.632" r="9.555" fill="#bc342d"/><circle cx="36.081" cy="281.632" r="9.555" fill="#864e9f"/><circle cx="24.722" cy="262.389" r="9.555" fill="#328a22"/></g></symbol><symbol viewBox="0 0 64 64" id="karma" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -233)"><path d="M38.556 288.413l-20.29-26.687 9.532-7.246 20.29 26.686h-.001.002l5.527 7.247z" fill="#359b8b" stroke-width=".173"/><path d="M35.681 241.172L24.92 255.327v-14.13H12.947v13.817l7.84 33.235h4.132v-13.147l.003.003 20.29-26.686-.008-.006 5.504-7.24H35.84v.12z" fill="#3cbeae" stroke-width=".206"/></g></symbol><symbol viewBox="0 0 24 24" id="key" xmlns="http://www.w3.org/2000/svg"><path d="M7 14a2 2 0 0 1-2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2 2 2 0 0 1-2 2m5.65-4A5.99 5.99 0 0 0 7 6a6 6 0 0 0-6 6 6 6 0 0 0 6 6 5.99 5.99 0 0 0 5.65-4H17v4h4v-4h2v-4H12.65z" fill="#26a69a"/></symbol><symbol viewBox="0 0 24 24" id="kivy" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.89 0 0 1.89 -12.157 -11.429)" fill="#90a4ae"><path d="M7.026 8.63v4.474l1.928-1.928a.437.437 0 0 0 0-.619zM9.38 16.072v-4.474l-1.927 1.927a.437.437 0 0 0 0 .62zM18.576 10.412l-5.346.564-.017.018 2.39 2.39zM9.922 8.502s.023 3.304-.003 4.452c-.02.856.371 1.114.746 1.507.538.564 1.599 1.57 1.599 1.57a.53.53 0 0 0 .75 0l1.843-1.844a.53.53 0 0 0 0-.75z"/></g></symbol><symbol viewBox="0 0 24 24" id="kl" xmlns="http://www.w3.org/2000/svg"><defs><style>.a{fill:#3aaae1}.b{fill:#fdfeff}</style></defs><title>kl</title><path d="M12.033 1.737c-.25-.003-.5.11-.729.337C8.225 5.15 5.15 8.227 2.078 11.31c-.144.144-.229.346-.341.521v.41c.16.223.294.474.485.666a3259.51 3259.51 0 0 0 8.936 8.937c.193.192.443.325.666.486h.41c.205-.142.436-.256.609-.428 3.046-3.041 6.09-6.083 9.133-9.127.47-.47.472-1.005.006-1.472l-9.218-9.217c-.23-.23-.48-.347-.731-.35zm-1.062 4.545l1.386.832c.702.422 1.403.846 2.109 1.262a.544.544 0 0 1 .04.026l.016.013.017.013c.061.056.089.123.088.224a510.281 510.281 0 0 0 0 3.794.463.463 0 0 1-.007.094c-.015.069-.054.103-.142.109a.464.464 0 0 1-.044.002c-.045-.002-.09-.002-.136-.003-.323-.006-.648-.001-.998-.001v-.527-1.34-.671-.003l.004-.668c0-.147-.039-.231-.17-.308-.893-.528-1.78-1.066-2.67-1.6-.051-.03-.101-.065-.173-.111l.001-.003h-.001zm.362 3.39c.068-.003.119.043.173.138.085.148.174.293.264.44l.015.025c.096.154.194.31.292.47l-1.915 1.176c-.337.207-.673.417-1.014.617-.113.067-.154.143-.154.277.01.977.01 1.955.014 2.932V16H7.7V16h-.002c-.004-.053-.014-.112-.014-.17-.005-1.25-.006-2.501-.015-3.751 0-.142.045-.222.164-.294a467.13 467.13 0 0 0 3.353-2.054l.016-.01a.606.606 0 0 1 .032-.017l.016-.008a.308.308 0 0 1 .033-.013l.012-.004a.157.157 0 0 1 .028-.005l.01-.001zm5.677 3.126l.314.54.346.594v.001c-.158.094-.298.178-.438.259l-3.097 1.798c-.106.062-.189.071-.3.01l-.893-.496-1.524-.843-.895-.493c-.035-.02-.068-.044-.129-.085h.001l.137-.25.495-.902 1.446.795c.442.243.886.483 1.323.734.121.07.212.072.334 0 .894-.525 1.792-1.043 2.689-1.563.057-.034.118-.061.191-.1z" fill="#29b6f6" stroke-width=".041"/></symbol><symbol viewBox="0 0 24 24" id="kotlin" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="gpb"><stop offset="0" stop-color="#cb55c0"/><stop offset="1" stop-color="#f28e0e"/></linearGradient><linearGradient id="gpa"><stop offset="0" stop-color="#0296d8"/><stop offset="1" stop-color="#8371d9"/></linearGradient><linearGradient xlink:href="#gpa" id="gpc" x1="1.725" y1="22.67" x2="22.185" y2="1.982" gradientUnits="userSpaceOnUse" gradientTransform="translate(1.638 1.155) scale(.89324)"/><linearGradient xlink:href="#gpb" id="gpd" x1="1.869" y1="22.382" x2="22.798" y2="3.377" gradientUnits="userSpaceOnUse" gradientTransform="translate(1.638 1.155) scale(.89324)"/></defs><path d="M3.307 3.003v18.048h18.05v-.03L16.88 16.51l-4.48-4.515 4.48-4.515 4.443-4.477H3.307z" fill="url(#gpc)"/><path d="M12.538 3.003l-9.23 9.23v8.818h.083l9.032-9.032-.025-.024 4.48-4.515 4.444-4.477h-8.784z" fill="url(#gpd)"/></symbol><symbol viewBox="0 0 240 240" id="laravel" xmlns="http://www.w3.org/2000/svg"><path d="M216.05 119.036c-1.433.343-24.945 6.673-24.945 6.673l-19.227-28.622c-.537-.828-.99-1.656.359-1.849 1.345-.196 23.195-4.477 24.182-4.723.99-.245 1.837-.536 3.053 1.267 1.21 1.8 17.836 24.626 18.464 25.506.627.877-.447 1.41-1.883 1.748m-4.101 49.326c.588 1.003 1.176 1.64-.67 2.367-1.843.73-62.243 22.847-63.418 23.39-1.173.546-2.092.73-3.607-1.637-1.51-2.362-21.16-39.264-21.16-39.264l64.03-18.075c1.876-.644 2.317-.405 3.103.822 1.074 1.68 21.143 31.403 21.726 32.4m-103.7-21.087c-.78.202-37.566 9.733-39.525 10.22-1.965.485-1.965.246-2.188-.49-.226-.727-43.728-98.053-44.333-99.271-.605-1.214-.574-2.177 0-2.177.571 0 34.734-3.313 35.944-3.383 1.207-.07 1.08.205 1.526 1.033l49.025 91.818c.84 1.58 1.239 1.81-.452 2.248m94.588-59.77c-3.5-4.58-5.2-3.751-7.357-3.41-2.154.336-27.277 4.915-30.194 5.449-2.918.536-4.758 1.803-2.963 4.53 1.597 2.422 18.113 27.824 21.751 33.42l-65.663 17.066L66.18 49.832c-2.075-3.342-2.507-4.514-7.236-4.28-4.735.23-40.969 3.495-43.55 3.731-2.58.233-5.416 1.479-2.835 8.09 2.583 6.612 43.734 102.82 44.88 105.62 1.149 2.803 4.128 7.345 11.11 5.527 7.157-1.871 31.969-8.894 45.52-12.742 7.163 14.07 21.77 42.619 24.473 46.707 3.607 5.459 6.089 4.56 11.626 2.738 4.325-1.42 67.65-26.129 70.502-27.4 2.855-1.273 4.613-2.184 2.685-5.275-1.419-2.28-18.124-26.558-26.876-39.26 5.993-1.733 27.305-7.888 29.575-8.557 2.646-.779 3.008-2.19 1.572-3.94-1.436-1.755-21.293-28.72-24.79-33.296z" fill="#ff5722" stroke="#ff5722" stroke-width="8.852" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" id="less" xmlns="http://www.w3.org/2000/svg"><path d="M13.696 2.999V5h2.002v5a2 2 0 0 0 1.999 2 2 2 0 0 0-2 2v5h-2v2h2a2 2 0 0 0 2-2v-4a2 2 0 0 1 2-2h1V11h-1a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2.001zm-.03 12.766v.47a1 1 0 0 0 .03-.236 1 1 0 0 0-.03-.234zM10.566 21v-2.001H8.565v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2.001v-2H8.565a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-.999V13h1a2 2 0 0 1 2 2v3.999A2 2 0 0 0 8.564 21zm.03-12.766v-.47a1 1 0 0 0-.03.236 1 1 0 0 0 .03.234z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="lib" xmlns="http://www.w3.org/2000/svg"><path d="M19 7H9V5h10m-4 10H9v-2h6m4-2H9V9h10m1-7H8a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2M4 6H2v14a2 2 0 0 0 2 2h14v-2H4V6z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 40 40" id="livescript" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -257)" fill="#317eac"><path stroke-width="3.299" d="M5.419 260.18h3.685v34.207H5.419z"/><path stroke-width="3.299" d="M37.074 288.197v3.685H2.867v-3.685z"/><path stroke-width="2.894" d="M29.612 265.658l2.004 2.005L7.428 291.85l-2.004-2.005z"/><path stroke-width="2.325" d="M10.73 262.471h2.835v22.08H10.73z"/><path stroke-width="2.063" d="M15.36 262.519h2.835v17.382H15.36z"/><path stroke-width="1.77" d="M19.99 262.471h2.835v12.802H19.99z"/><path stroke-width="1.422" d="M24.526 262.491h2.835v8.254h-2.835z"/><path stroke-width="1.128" d="M28.783 262.463h2.835v5.197h-2.835z"/><path stroke-width="2.325" d="M34.801 286.545v-2.835h-22.08v2.835z"/><path stroke-width="2.063" d="M34.753 281.914v-2.835H17.371v2.835z"/><path stroke-width="1.77" d="M34.801 277.284v-2.835H21.999v2.835z"/><path stroke-width="1.422" d="M34.781 272.749v-2.835h-8.254v2.835z"/><path stroke-width="1.128" d="M34.809 268.492v-2.835h-5.197v2.835z"/></g></symbol><symbol viewBox="0 0 24 24" id="lock" xmlns="http://www.w3.org/2000/svg"><path d="M12 17a2 2 0 0 0 2-2 2 2 0 0 0-2-2 2 2 0 0 0-2 2 2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5 5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3z" fill="#ffd54f"/></symbol><symbol viewBox="0 0 24 24" id="lua" xmlns="http://www.w3.org/2000/svg"><circle cx="12.203" cy="12.102" r="10.322" fill="none" stroke="#42a5f5"/><path d="M12.33 5.746a6.483 6.381 0 0 0-6.482 6.381 6.483 6.381 0 0 0 6.482 6.38 6.483 6.381 0 0 0 6.484-6.38 6.483 6.381 0 0 0-6.484-6.38zm1.86 1.916a2.329 2.292 0 0 1 2.33 2.293 2.329 2.292 0 0 1-2.33 2.291 2.329 2.292 0 0 1-2.329-2.29 2.329 2.292 0 0 1 2.328-2.294z" fill="#42a5f5" fill-rule="evenodd"/><ellipse cy="4.615" cx="19.631" rx="2.329" ry="2.292" fill="#42a5f5" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" id="markdown" xmlns="http://www.w3.org/2000/svg"><path d="M2 16V8h2l3 3 3-3h2v8h-2v-5.17l-3 3-3-3V16H2m14-8h3v4h2.5l-4 4.5-4-4.5H16V8z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" preserveAspectRatio="xMidYMid" id="markojs" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -120.96)" stroke-width=".984"><path d="M4.002 126.482c-.655 1.07-1.32 2.14-1.976 3.21-.655 1.06-1.308 2.142-1.963 3.212l.002.002-.002.002c.655 1.07 1.308 2.15 1.963 3.211.655 1.07 1.32 2.141 1.976 3.211h3.33c-.664-1.07-1.318-2.14-1.974-3.21-.653-1.069-1.307-2.145-1.961-3.214.654-1.068 1.308-2.146 1.961-3.215a601.93 601.93 0 0 1 1.974-3.209z" fill="#2196f3"/><path d="M3.999 126.482l-.002.002c.655 1.07 1.31 2.15 1.964 3.212.655 1.07 1.32 2.14 1.974 3.21h3.331c-.664-1.07-1.319-2.14-1.974-3.21-.653-1.068-1.306-2.146-1.96-3.214z" fill="#26a69a"/><path d="M15.203 126.482l.002.002c-.655 1.07-1.31 2.15-1.965 3.212-.655 1.07-1.319 2.14-1.974 3.21h-3.33c.664-1.07 1.318-2.14 1.973-3.21.654-1.069 1.307-2.146 1.961-3.214z" fill="#8bc34a"/><path d="M11.874 126.484c.664 1.07 1.318 2.14 1.974 3.21.653 1.068 1.307 2.146 1.961 3.214-.654 1.069-1.308 2.145-1.961 3.213-.656 1.07-1.31 2.14-1.974 3.21h3.33c.655-1.07 1.319-2.14 1.974-3.21.655-1.06 1.31-2.14 1.966-3.21l-.002-.003.002-.002c-.656-1.07-1.311-2.152-1.966-3.213-.655-1.07-1.319-2.138-1.974-3.209z" fill="#ffc107"/><path d="M16.74 126.482c.665 1.07 1.319 2.14 1.974 3.21.654 1.068 1.306 2.146 1.96 3.214-.654 1.069-1.306 2.145-1.96 3.213-.655 1.07-1.31 2.141-1.974 3.211h3.33c.656-1.07 1.32-2.14 1.974-3.21.655-1.062 1.31-2.141 1.966-3.212l-.002-.002.002-.002c-.655-1.07-1.31-2.152-1.966-3.213-.655-1.07-1.318-2.138-1.973-3.209z" fill="#f44336"/></g></symbol><symbol viewBox="0 0 23 24" id="mathematica" xmlns="http://www.w3.org/2000/svg"><path d="M11.512 1.523l-.073.025-.46.794-.454.763-1.217 2.09H9.29L5.435 3.5l-.1-.047h-.018v.092l.025.163v.086l.132 1.226v.082l.032.252v.082l.22 2.137v.075l.018.082v.06l-2.348.507-.04.015-.457.1-.025.01h-.042l-1.096.244-.04.007-.17.036v.082l.018.01 1.859 2.086.053.052.114.132.804.909v.005l-.053.05-.22.257-2.564 2.875-.01.007v.082l.071.006.295.075 1.697.366v.006l2.139.472h.015v.047l-.036.252v.08l-.046.412v.082l-.036.244v.082l-.045.412v.08l-.05.41v.08l-.036.244v.082l-.046.412v.082l-.05.407v.082l-.032.248V20l-.05.407v.104h.037l3.642-1.6.294-.134h.018l.177.312.539.911.015.032.854 1.465.16.262.404.695.007.022h.092l.005-.022.017-.025.56-.947.014-.042.6-1.033.316-.539.644-1.091.05.013 3.906 1.721h.035v-.085l-.138-1.32v-.082l-.032-.244v-.082l-.035-.245v-.085l-.033-.244v-.081l-.032-.245v-.082l-.032-.244v-.085l-.035-.245v-.082l-.032-.245v-.082l-.033-.244v-.085l-.025-.17v-.053l1.632-.354.043-.008.458-.107h.028v-.01l.23-.05.03-.01h.042l.382-.09.025-.01h.043l.194-.05h.033l1.015-.23.07-.007v-.064l-.015-.013-1.19-1.342-.028-.028-.197-.22-1.428-1.604v-.006l.295-.323.4-.457 2.148-2.408.015-.01v-.065l-.035-.008-1.288-.28-.372-.084-.047-.01-2.481-.544v-.045l.432-4.265v-.02h-.042l-.302.135-.01.014h-.025l-3.307 1.45-.297.135h-.015l-2.028-3.483-.099-.145-.014-.045zm-.001 1.114l1.365 2.323.34.592-.008.025-1.18 1.511-.517.66-.012-.01-.258-.335-.04-.05-1.397-1.787.03-.063 1.378-2.365.287-.491zm4.908 2.039l-.007.025-.168.225-.538.066zm-9.817.004l.053.02.677.3h-.499l-.224-.3zM16.947 5l-.123 1.248-.113-.928.226-.307zm-9.26.156l.053.024.705.309-.757-.175zm7.388.116l.02.168-1.318.403.003-.003.16-.071 1.015-.444zM9.669 6.388l.944 1.204v.01L9.483 7.2zm3.55.172l.21.682-.234.084-.089.022-.702.255.008-.022.776-.982zm-5 .836l.986.356.898.312.048.02 1.054.373.011 3.086-.362-.117-.67-.224-.081-.038-.735-.245-.77-.256-.29-.1-.011-.255-.032-1.195-.01-.287-.015-.894-.013-.297zm6.583 0l-.011.227-.028.9-.008.303-.032 1.475-.01.262-.337.117-.734.245-.77.256-.712.245-.355.117.01-3.086 1.632-.578zm.585.437l.09.735.79-.097-.915 1.302-.018.006.01-.183.018-.877zm-9.451.536l.152.22 1.447 2.049-2.607.968-.05.015-1.972-2.214-.28-.312.003-.01.115-.018.424-.1.14-.021.337-.078.042-.01zm11.146.003l3.284.713.029.01-.022.025-1.954 2.192-.277.312-.092-.036-2.564-.95.475-.681.152-.216zM6.787 8.52h.86l.036 1.258-.013-.006-.763-1.078zm1.358 2.625l.152.06.77.252.712.245.746.247.49.167-.065.092-1.723 2.334-1.015-.302-.082-.017-.035-.015-1.902-.56.938-1.22.981-1.277zm6.73 0l.033.006 1.787 2.327.132.17-.128.036-.032.014-2.196.642-.105.032-.564.17-.018-.003-1.053-1.44-.174-.239-.547-.726-.007-.018.469-.16.769-.254.713-.245.77-.252zm-7.766.305l-.007.02-.405.523-.291-.291.657-.245zm8.802 0l.043.007.578.212.714.27-.661.394-.375-.479-.03-.042-.262-.342zm-10.843.75l-.67.668.355-.397.207-.23zm12.911.016l.068.025.045.042.554.627.042.043.204.228-.255.135zm-6.473.265l.022.015 1.38 1.872.032.05.343.465.008.031-.088.117-.422.629-.047.074-.245.343-.97 1.43-.013.007-1.18-1.72-.096-.16-.493-.708-.008-.037 1.618-2.191.007-.01zm7.827 1.194l.565.633.063.082-.272-.093-.037-.013zm-15.785.148l.297.299-.637.218-.152.05.038-.058zm13.224.47l-.855.448.346.66-.185-.058-.27-.088-1.092-.348.012-.01zm-9.687.255l1.222.356-.006.007-.458.145-.443.135-.032.01-.49.157zm-2.765.048l.318.32 2.007.517-.567.18-.055.004-2.103-.469-.744-.156.007-.006zm14.966.205l.548.188v.003l-.457.1-.043.014-1.069.23zm-10.23.507l.007.227.01.347.025 1.363.025.691-.007.255-.24.107-2.863 1.255.032-.372.033-.255.017-.227.031-.256.037-.407.045-.42.018-.23.032-.251.032-.412.05-.414.013-.14 1.455-.457.003-.014.301-.098zm4.908 0l1.245.39v.014l.312.1 1.146.362.022.23.03.255.043.408.04.42.017.23.033.251.032.412.042.325.078.848-.078-.04-3.025-1.322-.004-.305.06-2.368zm-4.295.617l.015.007.067.107.6.875-.64.531-.034-1.438zm3.671 0h.008l-.005.06-.02.678-.005.214-.479-.223zm-2.888 3.605l.763.915.001.37-.017-.006-.025-.05-.464-.791-.012-.018zm1.53.61l.184.083-.343.586-.018.007.002-.532z" fill="#f44336" fill-rule="evenodd" stroke="#f44336" stroke-width=".7747499999999999" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 720 720" id="matlab" xmlns="http://www.w3.org/2000/svg"><title>Layer 1</title><path d="M209.247 329.98L52.368 387.638l121.325 85.822 96.752-95.804-61.198-47.674z" fill="#4db6ac" fill-rule="evenodd" stroke-width=".3"/><path d="M480.193 71.446c-13.123 1.784-9.565 1.013-28.4 16.09-18.008 14.418-69.925 100.347-97.673 129.256-24.688 25.722-34.46 12.199-60.102 33.661-25.68 21.494-65.273 64.464-65.273 64.464l63.978 47.32L394.15 222.754c23.948-32.932 23.694-37.266 36.744-71.82 6.384-16.907 17.76-29.9 27.756-45.809 12.488-19.874 30.186-34.855 21.543-33.68z" fill="#00897b" fill-rule="evenodd" stroke-width=".3"/><path d="M478.206 69.796c-31.268-.189-62.068 137.245-115.56 242.691-54.543 107.519-162.235 176.82-162.235 176.82 18.156 8.243 34.681 4.91 54.236 23.394 13.375 16.164 52.09 95.976 75.174 146.117 0 0 18.964-10.297 42.994-27.695 24.03-17.397 53.124-41.896 73.384-70.3 26.883-37.692 47.897-61.043 65.703-75.271 17.806-14.23 32.404-19.336 46.458-20.54 50.238-4.305 124.582 85.792 124.582 85.792S527.267 70.09 478.206 69.796z" fill="#ffb74d" fill-rule="evenodd" stroke-width=".3"/></symbol><symbol viewBox="0 0 24 24" id="merlin" xmlns="http://www.w3.org/2000/svg"><text style="line-height:1.25;-inkscape-font-specification:'Century Gothic Bold'" x="1.953" y="21.178" transform="scale(.99582 1.0042)" font-weight="700" font-size="30.255" font-family="Century Gothic" letter-spacing="0" word-spacing="0" fill="#42a5f5" stroke-width=".756"><tspan x="1.953" y="21.178" style="-inkscape-font-specification:'Century Gothic Bold'" font-size="22.745">M</tspan></text></symbol><symbol viewBox="0 0 192 191.99999" id="mocha" xmlns="http://www.w3.org/2000/svg"><title>Mocha Logo</title><g transform="translate(-354.75 -262.42) scale(4.835)" fill="#a1887f"><path d="M103.6 69.6c0-.5-.4-1-1-1H83.8c-.5 0-1 .4-1 1 0 3.4.5 15.1 5.5 20.8.2.2.4.3.7.3h8.4c.3 0 .5-.1.7-.3 5-5.6 5.5-17.3 5.5-20.8zm-7.4 18.2h-5.9c-.3 0-.5-.1-.7-.3-3.4-4-3.8-12-3.9-14.8 0-.5.4-1 1-1h13.2c.5 0 1 .4 1 1 0 2.8-.5 10.7-3.9 14.8-.3.2-.5.3-.8.3zM95.1 66.6s3.6-2.1 1.4-5.9c-1.3-2-1.9-3.7-1.4-4.4-1.3 1.6-3.5 3.3-1.1 6.9.8.9 1.2 2.8 1.1 3.4zM91.1 66.9s2.4-1.4.9-4c-.9-1.3-1.3-2.5-.9-2.9-.9 1.1-2.3 2.2-.7 4.7.5.5.7 1.8.7 2.2z"/><path d="M99.3 78.5c-.4 2.7-1.2 5.8-2.9 7.8-.2.2-.4.3-.6.3h-5c-.2 0-.5-.1-.6-.3-1.2-1.5-2-3.5-2.5-5.6 0 0 5.8.8 9.1-.4 2.4-.9 2.5-1.8 2.5-1.8z"/></g></symbol><symbol viewBox="0 0 24 24" id="movie" xmlns="http://www.w3.org/2000/svg"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V4h-4z" fill="#ff9800"/></symbol><symbol viewBox="0 0 24 24" id="music" xmlns="http://www.w3.org/2000/svg"><path d="M16 9V7h-4v5.5c-.42-.31-.93-.5-1.5-.5A2.5 2.5 0 0 0 8 14.5a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5V9h3m-4-7a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2z" fill="#ef5350"/></symbol><symbol viewBox="0 0 24 24" id="mxml" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5l3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41-3.74 3.74m11.16 0l-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41 3.74-3.74z" fill="#ffa726"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-actions" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#ab47bc" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-effects" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#26c6da" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#e53935" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-state" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#9ccc65" stroke-width="12.914"/></symbol><symbol viewBox="0 0 24 24" id="nim" xmlns="http://www.w3.org/2000/svg"><path d="M4.464 15.75L2.288 3.78l5.985 7.617L12.08 3.78l3.809 7.617 5.985-7.617-2.177 11.97H4.464m15.234 3.264a1.088 1.088 0 0 1-1.088 1.088H5.553a1.088 1.088 0 0 1-1.089-1.088v-1.089h15.234z" stroke-width="1.088" fill="#ffca28"/></symbol><symbol viewBox="0 0 500 500" id="nix" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-1.965 36.302)" stroke-width=".395"><path d="M135.59 415.7c0-.295-2.752-5.283-6.116-11.084-3.364-5.801-6.116-10.776-6.116-11.055s9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694 0 .14-11.836.21-26.302.154-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.365.96h-13.229c-10.896 0-13.229-.095-13.229-.538zm167.58-125.61c-.134-.216 1.188-2.863 2.938-5.882 6.924-11.944 84.291-145.75 96.491-166.88 7.143-12.371 13.142-22.465 13.333-22.433.363.062 25.861 43.105 25.861 43.655 0 .174-6.761 11.952-15.026 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.564.564 30.213.564h29.996l.958 1.48c.526.814 3.296 5.547 6.155 10.518 2.859 4.971 5.45 9.29 5.756 9.597.706.705.704.724-.16 1.572-.395.388-3.36 5.323-6.587 10.965-3.228 5.643-6.056 10.387-6.285 10.543-.23.156-19.695.171-43.256.034l-42.84-.249-.804 1.15c-.441.632-7.504 12.736-15.696 26.897l-14.892 25.747H339.03c-8.517 0-20.015.116-25.55.259-6.55.168-10.15.121-10.309-.135zM169.42 132.23c-56.373-.055-102.5-.182-102.5-.282 0-.1 5.617-10.132 12.481-22.294l12.481-22.112h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427-8.139-14.199-14.797-25.917-14.797-26.04 0-.123 2.682-4.853 5.96-10.51s6.003-10.578 6.055-10.934c.086-.586 1.376-.648 13.572-.648 7.413 0 13.463.143 13.446.317-.017.174.222.707.531 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.118.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z" fill="#5075c1"/><path d="M25.289 203.14c-6.098 10.563-6.69 11.711-6.225 12.078.283.224 3.18 5.044 6.44 10.712 3.261 5.668 6.017 10.355 6.124 10.417.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.472.273-1.08 3.094-14.637 26.574L62.06 289.793l12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956.086.05 23.257-39.831 51.492-88.624 11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.097-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.024.037c-.011.003-1.62-.001-1.825 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.568.03-1.147-.03-1.29-.132-.142-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.199 21.131 89.393 155.03 96.276 167 1.502 2.613 2.92 4.803 3.443 5.348.9-1.249 3.531-5.63 7.954-13.219a1342.88 1342.88 0 0 1 10.049-17.76l6.606-11.443c.692-1.403.754-1.818.653-2.117-.162-.48-6.904-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.991-26.32a.73.73 0 0 1-.009-.366l-.426-.913L359.42 72.5c3.69-6.307 6.425-11.042 9.47-16.29 9.159-15.948 12.037-21.189 11.896-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.145.035-.42.112-5.454.195-11.189.185-5.734-.01-11.22.024-12.188.073l-1.76.089-14.997 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.272.05-.255-.148-7.143-11.902-15.306-26.119l-14.36-25.016c-.115-.186-.444-.744-.457-.752-.477-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.042.48.329 1.05 25.338 43.735 25.772 43.985.207.119 14.178.239 31.05.266 26.651.044 30.75.152 31.234.832.308.43 9.988 17.214 21.513 37.296s21.152 36.627 21.394 36.767c.242.14 5.927.243 12.633.23 6.706-.013 12.401.099 12.657.246.132.076.382-.141.852-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.441-9.774-.195-.712-.065-.822 1.156-.98 1.956-.252 57.397-.057 58.07.205.238.092.79-.569 2.594-3.497 1.866-3.067 5.03-8.524 11-18.866 7.22-12.505 13.044-22.784 12.942-22.843-.102-.059-.771-.051-1.489.016l-.046.001c-4.452.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.121.01-2.095.076-2.66.172a.25.25 0 0 0-.062.083z" fill="#7db7e1"/></g></symbol><symbol viewBox="0 0 24 24" id="nodejs" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.85c-.27 0-.55.07-.78.2l-7.44 4.3c-.48.28-.78.8-.78 1.36v8.58c0 .56.3 1.08.78 1.36l1.95 1.12c.95.46 1.27.47 1.71.47 1.4 0 2.21-.85 2.21-2.33V8.44c0-.12-.1-.22-.22-.22H8.5c-.13 0-.23.1-.23.22v8.47c0 .66-.68 1.31-1.77.76L4.45 16.5a.26.26 0 0 1-.11-.21V7.71c0-.09.04-.17.11-.21l7.44-4.29c.06-.04.16-.04.22 0l7.44 4.29c.07.04.11.12.11.21v8.58c0 .08-.04.16-.11.21l-7.44 4.29c-.06.04-.16.04-.23 0L10 19.65c-.08-.03-.16-.04-.21-.01-.53.3-.63.36-1.12.51-.12.04-.31.11.07.32l2.48 1.47c.24.14.5.21.78.21s.54-.07.78-.21l7.44-4.29c.48-.28.78-.8.78-1.36V7.71c0-.56-.3-1.08-.78-1.36l-7.44-4.3c-.23-.13-.5-.2-.78-.2M14 8c-2.12 0-3.39.89-3.39 2.39 0 1.61 1.26 2.08 3.3 2.28 2.43.24 2.62.6 2.62 1.08 0 .83-.67 1.18-2.23 1.18-1.98 0-2.4-.49-2.55-1.47a.226.226 0 0 0-.22-.18h-.96c-.12 0-.21.09-.21.22 0 1.24.68 2.74 3.94 2.74 2.35 0 3.7-.93 3.7-2.55 0-1.61-1.08-2.03-3.37-2.34-2.31-.3-2.54-.46-2.54-1 0-.45.2-1.05 1.91-1.05 1.5 0 2.09.33 2.32 1.36.02.1.11.17.21.17h.97c.05 0 .11-.02.15-.07.04-.04.07-.1.05-.16C17.56 8.82 16.38 8 14 8z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 300 300" id="nodemon" xmlns="http://www.w3.org/2000/svg"><title>nodemon</title><path d="M149.868 20.62c-2.124 0-4.25.55-6.154 1.648L41.899 81.083a12.306 12.306 0 0 0-6.15 10.652v117.633a12.29 12.29 0 0 0 6.152 10.646l101.815 58.766h.001a12.282 12.282 0 0 0 12.291 0l101.84-58.766a12.29 12.29 0 0 0 6.153-10.652V91.738a12.31 12.31 0 0 0-6.146-10.652L156.015 22.27a12.302 12.302 0 0 0-6.153-1.648zM83.303 70.93s11.789 33.031 35.477 31.934l27.74-15.961a7.348 7.348 0 0 1 3.414-.99h.641a7.233 7.233 0 0 1 3.404.99l27.738 15.961c23.69 1.094 35.475-31.934 35.475-31.934 5.233 23.154 1.06 38.641-5.924 48.942l4.541 2.614h.002c2.321 1.327 3.734 3.795 3.737 6.49l-.12 95.811a3.724 3.724 0 0 1-1.855 3.227 3.624 3.624 0 0 1-3.735 0L177.1 206.971c-2.311-1.363-3.742-3.818-3.742-6.48v-44.763a7.44 7.44 0 0 0-3.737-6.465l-15.642-9.01a7.28 7.28 0 0 0-3.715-1.01 7.378 7.378 0 0 0-3.742 1.01l-15.648 9.01c-2.316 1.323-3.729 3.798-3.729 6.467v44.762c0 2.663-1.413 5.1-3.738 6.48l-36.748 21.041a3.571 3.571 0 0 1-3.71 0c-1.173-.65-1.864-1.887-1.864-3.224l-.137-95.812a7.483 7.483 0 0 1 3.74-6.49l4.541-2.615c-6.982-10.302-11.16-25.79-5.925-48.942z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 990 990" id="npm" xmlns="http://www.w3.org/2000/svg"><defs><style>.hncls-1{fill:#cb3837}.cls-2{fill:#fff}</style></defs><title>n</title><path class="hncls-1" d="M113.26 876.74V113.27h763.47v763.47zm143.59-620.4v476.18h240.61V355.63h140.21v376.96h95.457V256.34z" fill="#e53935" stroke-width=".771"/></symbol><symbol id="nunjucks" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.host0{fill:#388e3c}</style><path class="host0" d="M11.2 21.1H8.1l-2.3-7.9v7.9H2.7V2.9h3.1l2.3 7.4V2.9h3.1zM21.3 19.2c0 1-.8 1.9-1.9 1.9h-4.8c-1 0-1.9-.8-1.9-1.9v-3.8l3.2-.7V18h2.3V7.2h3.1v12z"/></symbol><symbol viewBox="0 0 150 150.00001" id="ocaml" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.76136 0 0 .76136 11.616 19.98)"><path d="M83.02 101.645l.023-.062c-.035-.159-.047-.195-.024.062z" fill="none" stroke-width="1.028"/><linearGradient id="hpa" gradientUnits="userSpaceOnUse" x1="-696.735" y1="97.7" x2="-696.735" y2="142.997" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M82.313 138.79c-.471-1.004-1.904-3.621-2.624-4.46-1.562-1.828-1.927-1.966-2.386-4.275-.799-4.02-2.913-11.31-5.405-16.341-1.286-2.596-3.426-4.777-5.385-6.66-1.71-1.652-5.565-4.431-6.237-4.294-6.296 1.257-8.249 7.432-11.21 12.323-1.638 2.705-3.374 5.007-4.665 7.885-1.192 2.646-1.087 5.577-3.128 7.849-2.093 2.333-3.454 4.814-4.48 7.829-.194.574-.747 6.596-1.348 8.015l9.357-.659c8.719.594 6.2 3.936 19.81 3.208l21.487-.665c-.666-1.97-1.584-4.25-1.938-4.991-.599-1.248-1.352-3.69-1.848-4.763z" fill="url(#hpa)" stroke-width="1.028"/><linearGradient id="hpb" gradientUnits="userSpaceOnUse" x1="-666.972" y1="142.12" x2="-666.972" y2="142.12" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><linearGradient id="hpc" gradientUnits="userSpaceOnUse" x1="-675.228" y1="-1.28" x2="-675.228" y2="142.967" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M109.553 94.296c-1.652 1.193-4.88 4.06-11.902 5.145-3.152.487-6.1.527-9.335.365-1.584-.076-3.077-.157-4.665-.177-.936-.008-4.074-.107-3.919.193l-.349.871c.054.287.169 1.004.2 1.177.129.704.165 1.265.192 1.912.048 1.331-.11 2.719-.043 4.062.141 2.787 1.175 5.326 1.306 8.137.143 3.13 1.69 6.442 3.188 8.998.569.973 1.434 1.084 1.811 2.283.442 1.373.024 2.83.239 4.293.842 5.675 2.477 11.606 5.032 16.728.018.043.038.09.06.128 3.156-.53 6.318-1.665 10.418-2.271 7.517-1.115 17.972-.54 24.688-1.17 16.993-1.597 26.216 6.97 41.478 3.459V22.459c0-11.84-9.594-21.438-21.435-21.438H19.239C7.4 1.021-2.197 10.62-2.197 22.458v46.774c3.067-1.11 7.479-7.635 8.861-9.222 2.419-2.775 2.858-6.315 4.062-8.544 2.743-5.078 3.215-8.57 9.451-8.57 2.907 0 4.061.67 6.027 3.31 1.368 1.834 3.731 5.224 4.837 7.49 1.277 2.615 3.357 6.153 4.272 6.867.677.53 1.35.928 1.976 1.163 1.012.38 1.848-.316 2.525-.855.863-.687 1.235-2.088 2.035-3.957 1.152-2.696 2.408-5.926 3.122-7.054 1.237-1.949 1.658-4.261 2.993-5.381 1.97-1.652 4.54-1.768 5.246-1.908 3.957-.781 5.755 1.906 7.704 3.645 1.276 1.138 3.019 3.432 4.256 6.507.967 2.4 2.199 4.622 2.714 6.008.497 1.339 1.725 3.484 2.453 6.055.661 2.336 2.43 4.125 3.102 5.235 0 0 1.029 2.882 7.285 5.516 1.357.572 4.1 1.501 5.736 2.096 2.718.988 5.351.86 8.704.458 2.391 0 3.686-3.462 4.772-6.234.643-1.639 1.259-6.334 1.678-7.667.406-1.297-.544-2.3.265-3.437.946-1.327 1.508-1.399 2.054-3.129 1.172-3.704 7.95-3.89 11.761-3.89 3.176 0 2.772 3.083 8.16 2.028 3.086-.605 6.059.398 9.335 1.265 2.758.732 5.352 1.566 6.906 3.385 1.005 1.178 3.5 7.08.958 7.331.244.3.423.84.88 1.135-.566 2.226-3.03.64-4.4.355-1.845-.383-3.147.057-4.952.856-3.085 1.374-7.598 1.214-10.286 3.452-2.281 1.898-2.277 6.133-3.34 8.507-.002-.001-2.955 7.6-9.402 12.248z" fill="url(#hpc)" stroke-width="1.028"/><linearGradient id="hpd" gradientUnits="userSpaceOnUse" x1="-735.137" y1="90.833" x2="-735.137" y2="141.967" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M38.247 105.09c-1.467-.15-2.83-.317-4.256-.605-2.662-.536-5.57-1.06-8.193-1.688-1.592-.385-6.895-2.263-8.048-2.792-2.702-1.246-4.496-4.63-6.609-4.282-1.348.22-2.662.682-3.5 2.042-.685 1.11-.917 3.016-1.391 4.294-.55 1.485-1.5 2.87-2.331 4.284-1.53 2.595-4.282 4.941-5.468 7.469-.239.52-.45 1.101-.649 1.708V144.415a48.57 48.57 0 0 1 4.45.96c11.955 3.19 14.872 3.46 26.598 2.119l1.1-.146c.897-1.867 1.59-8.227 2.171-10.195.454-1.51 1.077-2.712 1.313-4.253.223-1.463-.02-2.858-.146-4.188-.329-3.332 2.427-4.522 3.742-7.384 1.186-2.589 1.871-5.535 2.853-8.181.941-2.54 2.41-6.13 4.918-7.408-.305-.355-5.237-.518-6.554-.65z" fill="url(#hpd)" stroke-width="1.028"/></g></symbol><symbol viewBox="0 0 24 24" id="pdf" xmlns="http://www.w3.org/2000/svg"><path d="M14 9h5.5L14 3.5V9M7 2h8l6 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m4.93 10.44c.41.9.93 1.64 1.53 2.15l.41.32c-.87.16-2.07.44-3.34.93l-.11.04.5-1.04c.45-.87.78-1.66 1.01-2.4m6.48 3.81c.18-.18.27-.41.28-.66.03-.2-.02-.39-.12-.55-.29-.47-1.04-.69-2.28-.69l-1.29.07-.87-.58c-.63-.52-1.2-1.43-1.6-2.56l.04-.14c.33-1.33.64-2.94-.02-3.6a.853.853 0 0 0-.61-.24h-.24c-.37 0-.7.39-.79.77-.37 1.33-.15 2.06.22 3.27v.01c-.25.88-.57 1.9-1.08 2.93l-.96 1.8-.89.49c-1.2.75-1.77 1.59-1.88 2.12-.04.19-.02.36.05.54l.03.05.48.31.44.11c.81 0 1.73-.95 2.97-3.07l.18-.07c1.03-.33 2.31-.56 4.03-.75 1.03.51 2.24.74 3 .74.44 0 .74-.11.91-.3m-.41-.71l.09.11c-.01.1-.04.11-.09.13h-.04l-.19.02c-.46 0-1.17-.19-1.9-.51.09-.1.13-.1.23-.1 1.4 0 1.8.25 1.9.35M8.83 17c-.65 1.19-1.24 1.85-1.69 2 .05-.38.5-1.04 1.21-1.69l.48-.31m3.02-6.91c-.23-.9-.24-1.63-.07-2.05l.07-.12.15.05c.17.24.19.56.09 1.1l-.03.16-.16.82-.05.04z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="perl" xmlns="http://www.w3.org/2000/svg"><path d="M12 14c-1 0-3 1-3 2 0 2 3 2 3 2v-1a1 1 0 0 1-1-1 1 1 0 0 1 1-1v-1m0 5s-4-.5-4-2.5c0-3 3-3.75 4-3.75V11.5c-1 0-5 1.5-5 4.5 0 4 5 4 5 4v-1M10.07 7.03l1.19.53c.43-2.44 1.58-4.06 1.58-4.06-.43 1.03-.71 1.88-.89 2.55C13.16 3.55 15.61 2 15.61 2a15.916 15.916 0 0 0-2.64 3.53c1.58-1.68 3.77-2.78 3.77-2.78-2.69 1.72-3.9 4.45-4.2 5.21l.55.08c0 .52 0 1 .25 1.38C14.1 11.31 18 11.47 18 16s-4.03 6-6.17 6C9.69 22 5 21.03 5 16s4.95-5.07 5.83-7.08c.12-.38-.76-1.89-.76-1.89z" fill="#9575cd"/></symbol><symbol viewBox="0 0 24 24" id="php" xmlns="http://www.w3.org/2000/svg"><path d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09-.28.22-.71.33-1.29.33h-.87l.53-2.76h.99m-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59l-1.28 6.58m7.25-8.33l-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18.11.12.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35h-1.44M18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76h1m-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75l-1.29 6.58z" fill="#1E88E5"/></symbol><symbol viewBox="0 0 79 78" id="postcss" xmlns="http://www.w3.org/2000/svg"><title>postcss-logo-symbol</title><g transform="translate(5.48 5.52) scale(.85425)" fill="#e53935" fill-rule="evenodd" stroke="#e53935" stroke-width="1.519"><path d="M15.447 32.623c.106.08.29.132.106.29-.132.184-.29.342-.395.553-.105.185-.184.237-.342.106.21-.343.42-.66.63-.95zM68.342 60.24c0 .078.026.13.026.21.053-.105.053-.158.08-.21zm0 .236v-.026zm-5.368 10.277l-4.58-25.402c-.078-.025-.183-.077-.368-.13.053.105.08.184.106.263.13-.026.184-.026.236-.052 0-.026 0-.052.027-.08l4.58 25.404zm-4.737-31.12c-.026.078-.026.158-.026.237 0-.08 0-.16.028-.238zm.026.526c-.026 0-.026 0-.052-.028v.026c.028.026.028.026.054 0zm-.052.21v-.185c-.077.026-.156.026-.262.053.132.05.264.078.264.13z"/><path d="M78.71 33.967c-.052-1.028-.078-2.056-.184-3.083-.184-1.397-.368-2.82-.684-4.19-.237-1.133-.63-2.214-1.026-3.294-.5-1.265-1-2.556-1.632-3.768-1.026-1.95-2.368-3.69-3.605-5.508-.818-1.16-1.87-2.108-2.66-3.294-.447-.685-1.105-1.264-1.763-1.79-1.053-.845-2.158-1.61-3.263-2.347a32.525 32.525 0 0 0-2.58-1.634c-.71-.397-1.473-.713-2.21-1.056-.842-.395-1.658-.87-2.605-1.054-.238-.05-.448-.13-.685-.21-.605-.21-1.184-.447-1.79-.632-.92-.29-1.815-.632-2.763-.87C50.342 1 49.394.843 48.446.71 47.394.555 46.316.5 45.262.397a26.83 26.83 0 0 0-2.026-.184C42.236.16 41.21.16 40.21.134c-.5-.027-1.026-.08-1.526-.053-.763.026-1.526.105-2.29.21-.736.08-1.473.21-2.183.317-.867.105-1.735.158-2.604.264-.816.106-1.658.264-2.473.396-.29.053-.58.158-.87.21-.63.132-1.288.185-1.92.396-1.13.344-2.263.74-3.368 1.16-1.027.422-2.027.87-3 1.397-1 .552-1.948 1.21-2.895 1.844a45.325 45.325 0 0 0-2.66 1.923c-.84.66-1.63 1.397-2.394 2.135-.42.42-.763.922-1.158 1.396-.657.765-1.315 1.502-1.947 2.293-.524.66-1 1.344-1.5 2.03-.893 1.21-1.656 2.502-2.366 3.794-.29.527-.553 1.054-.816 1.58-.395.79-.816 1.555-1.184 2.372-.264.554-.474 1.16-.632 1.766-.367 1.292-.736 2.61-1.078 3.9-.316 1.16-.395 2.372-.42 3.558-.027 1.054.078 2.082.183 3.136.027.264-.13.58.184.79-.105.29-.026.45.13.5-.182.29.08.476-.024.74-.027.052.08.157.13.236 0 .08-.025.185 0 .264.028.237.133.474.133.738 0 .184.157.395.21.58.026.078 0 .21-.053.263-.158.184-.132.342.105.448.133.342.08.5.054.66.052.236-.027.315 0 .368.21.422.29.896.315 1.37 0 .106.053.212.106.343.026 0 0 .5 0 .5.13-.078.237-.104.368-.157.08.342.158.66.263.95.132.21.132.314.08.34.105.474.157.922.34 1.37 0-.5-.05-1-.13-1.475.368.132.684.263.895.263.027-.08.053-.184.08-.237-.158-.157-.29-.394-.448-.552.053.21 0 .29 0 .37-.105-.054-.237-.107-.368-.16.105-.13.21-.263.368-.42 0-.238-.13-.45-.5-.423.158-.052.316-.13.5-.184.29-.157-.026-.447-.026-.816.026-.447-.237-.895-.316-1.37-.132-.737-.105-1.844-.184-2.582-.158-.132-.29.21-.316.237.08.632.158 1.264.21 1.897-.157-.527-.263-1.107-.394-1.74-.027.185-.053.264-.053.37-.13.13-.026.29.053.474-.184-.08-.395-.052-.395-.052v.738c-.262-.264-.34-.474-.473-.66-.052-.21-.08-.42-.13-.63.05-.133 0-.212 0-.29a15.968 15.968 0 0 1-.08-.634c.026-.026-.026-.42-.026-.42.21.025.343.05.474.05-.263-.34-.08-.552.027-.763.053-.106.237-.13.29-.238.21-.395.553-.71.553-1.212 0-.237.08-.5.105-.738.053-.448.105-.896.13-1.344.054-.58 0-1.16.133-1.713.212-.92.475-1.843.764-2.766.21-.66.448-1.29.71-1.95.395-1.028.764-2.056 1.264-3.03.71-1.424 1.526-2.794 2.316-4.19.5-.87 1.026-1.687 1.58-2.53.525-.817 1.05-1.66 1.657-2.425a21.452 21.452 0 0 1 2.79-2.978c1.053-.948 2.053-1.923 3.184-2.793a32.218 32.218 0 0 1 4.685-3.005c1.343-.71 2.737-1.266 4.132-1.793.895-.342 1.868-.5 2.79-.79 1.052-.343 2.105-.5 3.21-.527.71-.027 1.395-.106 2.105-.185.632-.05 1.263-.104 1.948-.183-.08.105-.106.158-.132.21-.288.422-.604.844-.894 1.265-.237.343-.5.712-.737 1.054-.422.555-.87 1.108-1.264 1.688-.605.87-1.158 1.766-1.79 2.635-.63.843-1.315 1.634-1.973 2.45-.868 1.134-1.684 2.293-2.552 3.426-.79 1.08-1.63 2.11-2.394 3.19-.684.947-1.29 1.95-1.948 2.923-.973 1.45-1.947 2.872-2.92 4.322a271.93 271.93 0 0 1-2.316 3.294c-.053.08-.132.104-.21.157-.21.342-.21.527-.29.685-.21.395-.42.79-.658 1.16-.132.21-.316.394-.474.605-.026-.316.42-.474.21-.87-.13.212-.263.396-.394.607l-.316.63c.105.08.29.133.105.29-.08.133-.158.29-.237.423a.954.954 0 0 0 .29-.264c0 .29-.158.526-.29.763-.105.21-.368.37-.552.527.026.027.21.106.237.132.237-.08.316-.21.343-.132.08-.105.158-.184.184-.263.104-.264.262-.474.525-.58.106-.053.184-.132.263-.21.79-.818 1.606-1.608 2.316-2.478 1.106-1.345 2.106-2.74 3.16-4.11.446-.58.973-1.16 1.446-1.714.078.606.026 1.185 0 1.74-.08.974-.132 1.95-.21 2.95-.027.395 0 .79-.027 1.186 0 .105-.08.184-.08.29 0 .263.08.553.08.817-.08.975-.186 1.923-.265 2.898-.027.21.078.422.13.607-.13 1.422.16 2.925-.078 4.427.184-.29.237-.474.237-.658.025-.158 0-.316 0-.5v-.264c.025-.475.13-.975.078-1.45-.053-.527-.053-1.027.053-1.528.053-.21-.026-.474.106-.738v.395c-.026 1.5.027 3.003-.183 4.505-.027.132.08.37-.21.343-.238.474.052.817-.21 1.08-.054.053.05.29.077.448-.106.317-.106.317.052.343.026.58.08 1.106.105 1.66.42-1 .21-2.03.396-3.058.026.422.053.844.026 1.29 0 .687-.026 1.345-.052 2.03 0 .132-.027.264-.053.396-.08.37-.105.738-.237 1.08-.105.264-.052.66-.052.975v1.003c.105.448-.027.685.052.948-.08.265-.105.344-.08.423l.08.395c.527-.053.29.343.5.553-.158.212-.105.29-.105.397 0 .237-.025.448-.052.685 0 .606-.026 1.212-.026 1.792 0 .08.026.157.026.236 0 .054-.026.74-.026.74.053.078 0 .157-.08.236-.025 0-.104-3.347-.104-3.347h-.395c-.052 1.58.08 3.003-.21 4.48-.316.025-.42.078-.764.078-.816 0-1.632 0-2.448.026-.974 0-1.92.026-2.895.026-.472 0-.972.054-1.446.054-.632 0-1.29-.08-1.92-.08-.975 0-1.922.08-2.896.106-.71.026-1.42.026-2.13.053-.475.025-.95.05-1.422.104-.21.026-.395.105-.658.184-.08 0-.263-.026-.42 0-.265.053-.5.21-.765.264-.395.08-.5.184-.448.58v.263c-.026.052.58-.08.58-.08-.054 0-.08.158-.16.29.212-.08.343-.132.475-.184.395.185.737.08 1.052.16 1.026.262 2.078.37 3.13.473.685.053 1.343.08 2.027.105.973.053 1.947.106 2.92.106.816 0 1.606-.08 2.42-.08 1.13 0 2.264.052 3.395.08.237 0 .5-.028.763-.028h1.92c1.712-.052 3.422-.08 5.133-.13.975-.028 1.975-.08 2.948-.107l3-.08c1.158-.026 2.316-.026 3.448-.05.868 0 1.71-.03 2.58-.055.972-.026 1.972-.105 2.946-.157.527-.027 1.054-.08 1.58-.132.632-.052 1.29-.13 1.92-.157.948-.054 1.922-.08 2.87-.133 1.184-.078 2.368-.183 3.578-.21 1.106-.052 2.237-.026 3.343-.052.974-.027 1.948-.08 2.948-.106l1.66-.08s1.104-.026 1.657-.08c.947-.052 1.894-.157 2.842-.183.604-.027 1.21 0 1.815-.027.973-.026 1.973-.08 2.947-.08.367 0 .762.054 1.236.08-.21.185-.342.29-.5.422.105.026.21.08.316.132a.71.71 0 0 1-.42.13c-.054.133-.107.186-.16.45h.474c-.184 0-.342.237-.526.395-.21-.054-.395 0-.5.29.184.104.158.183.132.29-.316.104-.553.21-.42.552-.107.052-.238.105-.37.184-.13.21-.368.263-.316.553.106.025.21.08.29.104-.132.053-.263.132-.395.184-.473.29-.262.422-.157.554-.08.053-.158.105-.237.132.052.237.13.29.157.29a9.3 9.3 0 0 0-.395.316c-.08.237-.185.342-.29.5s-.158.37-.29.527c-.552.607-.947 1.32-1.657 1.793-.264.185-.5.422-.737.66-.474.447-.895.948-1.395 1.37a29.595 29.595 0 0 1-2.052 1.554 151.56 151.56 0 0 1-2.604 1.792c-.474.315-1 .552-1.5.842s-.974.554-1.474.843c-.316.21-.606.5-.948.66-.868.37-1.79.685-2.684 1.028-.87.37-1.5.685-2.158.922-.605.21-1.237.37-1.868.5-.21.054-.448 0-.685.027-.448.08-.895.186-1.343.238-1.158.158-2.316.264-3.473.422-.685.08-1.343.21-2.027.29-.473.026-.973-.026-1.447-.026-.342 0-.71.08-1.053.027-.552-.08-1.105-.21-1.658-.316-.13-.026-.316-.08-.42-.026-.21.106-.396-.052-.607 0-.13.027-.262-.08-.394-.08-.106-.025-.238.028-.37 0-.29-.078-.552-.183-.87-.157-.313.026-.63-.132-.97-.21-.475-.106-.92-.21-1.396-.317a2.38 2.38 0 0 1-.525-.237c-.685 0-1.133-.026-1.554-.185-.368-.13-.71-.315-1.105-.262-.104.026-.183-.026-.29-.026-.08-.106-.157-.317-.235-.317-.526.027-.842-.42-1.29-.553-.236-.08-.42-.343-.657-.422-.58-.237-1.052-.737-1.71-.816-.21-.027-.42-.132-.658-.21.08.104.13.183.21.262-.763-.37-1.473-.79-2.184-1.186-.104-.026-.183-.13-.262-.184l-.71-.474c-.395.08-.553-.08-.66-.132-.71-.5-1.525-.817-2.21-1.37-.29-.238-.63-.396-.84-.686-.37-.448-.817-.764-1.317-1.027-.394-.21-.762-.448-1.13-.685-.185-.132-.37-.29-.37-.58 0-.185-.078-.37-.315-.264-.105-.158-.21-.342-.342-.395-.316-.13-.526-.37-.763-.58s-.42-.5-.71-.605c-.527-.21-.843-.658-1.158-1.027-.738-.87-1.396-1.82-2.08-2.74-.053-.08-.158-.133-.237-.212.105.29.237.527.368.79-.262-.105-.446-.29-.604-.474-.027.027 1.815 3.057 1.815 3.057.16.237.29.475.448.712a.813.813 0 0 1-.79-.422c-.236-.42-.5-.684-1.026-.63a4.588 4.588 0 0 1-.13-.58c-.107 0-.185 0-.37-.027.37.58.685 1.08 1.027 1.66-.133-.08-.21-.132-.265-.158.473.5.815 1.133 1.42 1.45.132.605.816.895.974 1.475-.13-.027-.238-.053-.37-.08-.21-.263-.447-.526-.683-.816.052.184.13.342.236.474.316.395.606.79.974 1.133.132.134.316.187.316.424.21.105.29.13.368.13.054.16-.025.397.29.344.21.395.42.395.71.264.343.343.528.37.764.16 0 .13.026.262.026.368.105-.053.08-.132.08-.264.13.105.21.158.262.21.263.37.5.712.868 1.002.5.422.948.87 1.42 1.265.922.765 1.95 1.398 2.975 1.977 1.264.712 2.475 1.476 3.764 2.16 1.552.818 3.21 1.372 4.92 1.767.632.132 1.237.263 1.87.42.55.16 1.104.397 1.657.528.842.185 1.71.343 2.552.5.183.027.37.054.58.08.235.053.524-.053.577.027.132.21.237.104.395.078.184-.053.395-.053.605-.053.737.026 1.447.184 2.184.132.16 0 .396-.133.528.13.236-.105.368-.105.473-.13.028.236 0 .236-.05.262-.054.026-.133.053-.238.132.947.184 1.842.21 2.63 0 1.37.105 2.554-.053 3.686-.448.105.132.184.316.342.053.052-.08.184-.107.29-.133.236-.053.526-.158.736-.08.238.08.317-.13.5-.13.317 0 .606-.027.896-.08.158-.026.316-.105.5-.158a1.285 1.285 0 0 0-.58-.133c.317-.158.606-.29.896-.42-.053.078-.106.183-.21.183h.367c-.08 0-.185.237-.316.395.946-.237 1.814-.448 2.657-.66-.29-.552.315-.367.526-.684-.263.08-.526.158-.79.21.895-.447 1.816-.842 2.71-1.237-.13.158-.29.237-.525.37.158.025.263.025.342.05.42.133.316-.262.447-.5.5 0 .71-.078.947-.158.263-.08.526-.158.79-.263.42-.184.815-.42 1.236-.63.08-.028.21 0 .316 0 .29-.186.394-.344.473-.318.37.053.63-.08.736-.42.184-.133.316-.238.447-.318.578-.316 1.13-.632 1.71-.948.21 0 .316 0 .368-.027.344-.16.66-.342.975-.527a2.258 2.258 0 0 1-.263-.13c.262-.054.34-.08.5-.133.63-.74 1.5-1.24 2.157-1.82.29-.026.29-.105.29-.157.104-.132.21-.29.34-.396.58-.527 1.21-.975 1.737-1.528a37.16 37.16 0 0 0 2.184-2.374c.63-.738 1.264-1.475 1.79-2.292.737-1.133 1.368-2.293 2.026-3.48.474-.842.895-1.685 1.37-2.528.05-.08.157-.185.236-.185.71-.08 1.422-.13 2.106-.21.158-.026.342-.13.5-.21-.08-.132-.132-.29-.21-.422-.106-.16-.264-.29-.37-.45-.104-.13-.183-.29-.262-.447-.08-.13-.158-.236-.237-.37a9.7 9.7 0 0 1-.45-.894c-.026-.08-.08-.21-.052-.29.474-1.027.658-2.134 1.105-3.162.447-1.054.58-2.24.79-3.373.184-1.08.29-2.16.42-3.24.08-.764.185-1.502.21-2.266.16-1.212.106-2.346.08-3.48-.026-1-.08-2.028-.13-3.03zM12.685 66.405c-.184-.21-.342-.448-.526-.658l.08-.08c.287.317.577.633.866.976-.158-.08-.342-.132-.42-.238zm.42.238c.08-.027.16-.027.238-.053.08.132.132.29.21.448-.368-.027-.552-.185-.447-.395zm27.37 10.883v-.08c.5-.052.973-.105 1.473-.157v.077c-.5.08-.973.13-1.473.158zm6.63-.685c-.367.08-.762.133-1.13.186-.132.026-.29.158-.342-.08-.053.027-.106.027-.158.054.13.394.447.078.71.236-.58.08-1.13.132-1.684.21v-.052c.16-.026.343-.053.5-.08v-.078a7.743 7.743 0 0 0-.79-.053c-.077 0-.183.106-.262.132-.105.026-.21.053-.342.053-.447.026-.894.026-1.316.052-.027 0-.08-.026-.106-.026v-.08c1.763-.236 3.5-.473 5.263-.71.027.052.027.105.053.157-.158 0-.263.055-.395.08zm.396-.262c.606-.08 1.16-.132 1.738-.21-1.21.342-1.605.394-1.737.21zM24.58 23.374c.84-1.16 1.71-2.32 2.552-3.505.263-.345.473-.714.736-1.056.08-.106.185-.158.316-.264l-.026-.05c.105-.133.21-.24.263-.344.134-.21.213-.448.318-.685a.385.385 0 0 1 .105-.103c.37.184.37-.21.5-.343.237-.264.474-.553.684-.817.158-.21.316-.395.448-.632.026-.08-.053-.21-.08-.317h-.078c.08-.052.158-.13.237-.184.026 0 .026 0 .052-.026.158-.238.316-.475.474-.686.315-.42.657-.842 1.025-1.21-.052.13-.105.263-.158.368.027 0 .027.027.053.027.316-.422.658-.817.974-1.24-.027-.025-.053-.052-.08-.052-.13.132-.236.264-.368.396-.026-.027-.052-.053-.08-.053.265-.343.528-.685.79-1.08.053.08.106.184.21.395.107-.263.212-.447.29-.632-.078.08-.183.158-.262.238l-.08-.08.474-.71c.5-.712 1-1.45 1.5-2.162.185-.263.42-.474.58-.738.5-1 1.29-1.792 1.894-2.714.132-.184.316-.342.474-.5.13-.16.237-.106.342.026.71.896 1.42 1.818 2.13 2.714.528.66 1.054 1.29 1.554 1.976.605.844 1.184 1.687 1.79 2.53.684.975 1.368 1.95 2.026 2.95 1 1.477 1.947 2.953 2.947 4.428.737 1.08 1.474 2.135 2.184 3.215h-1.344c-1.236-.025-2.5-.13-3.736-.078-1.684.08-3.394.264-5.078.396-2.132.185-4.29.21-6.42.21-.765 0-1.528.107-2.29.16-.922.052-1.817.105-2.738.13-1.08.054-2.13.08-3.21.107-.606.026-1.237 0-1.895 0zm30.183 12.12v.238c-.026 0-.052.027-.105.027-.105-.37-.21-.766-.342-1.135-.263-.765-.553-1.53-1.027-2.214-.528-.737-1-1.5-1.528-2.265-.13-.185-.316-.343-.474-.5-.553-.607-1.106-1.24-1.816-1.687a21.485 21.485 0 0 0-3.29-1.688 7.374 7.374 0 0 1-.92-.474h.63l4.5-.08c.974-.025 1.922-.025 2.895-.078.236 0 .368.08.5.29.236.395.473.79.736 1.186.027.052.08.13.08.21 0 .58 0 1.186.026 1.766.025.606.08 1.186.104 1.792 0 .606-.053 1.238-.026 1.87.027.897.053 1.82.053 2.74zM26.447 26.67c1.237-.053 2.42-.132 3.632-.185.945-.053 1.92-.08 2.866-.132.395-.025.764-.05 1.158 0-.42.212-.842.423-1.21.686-.474.316-.92.737-1.395 1.08-.475.342-.896.764-1.29 1.212-.5.605-1.053 1.132-1.58 1.712-.37.422-.79.817-1.105 1.265-.447.58-.842 1.21-1.263 1.87.132-2.504.29-4.98.184-7.51zm17.185 25.35c-.843.21-1.71.448-2.58.553-.736.106-1.5.08-2.263.08a25.42 25.42 0 0 1-2.028-.08c-.763-.078-1.526-.157-2.263-.5-.633-.29-1.29-.553-1.92-.87-.634-.316-1.265-.684-1.74-1.264-.34-.423-.815-.765-1.236-1.134.08.316.263.58.553.764-.132.158-.316.08-.58-.343-.078.053-.157.08-.21.106.08-.185.158-.37.237-.527-.105-.21-.237-.448-.342-.66-.21-.342-.42-.71-.605-1.053-.053-.08-.053-.158-.105-.237a5.893 5.893 0 0 1-.37-.475c-.21-.315-.394-.657-.657-.974 0 .08.027.158.027.264-.027 0-.053.026-.053.026l-.554-1.344c-.026 0-.026 0-.052.026l.473 1.74c-.026 0-.052.025-.08.025-.077-.104-.156-.21-.21-.34-.052-.212-.21-.212-.34-.133-.08.053-.133.237-.106.316.185.448.395.896.606 1.344.052.158.105.29.184.448.027.053.106.105.106.184.106.21.185.42.316.606.237.316.5.632.737.948.235.316.445.66.656.975.026.053.105.053.13.08.133.395.58.684.896.526.08.606.737.817 1 1.397a11.957 11.957 0 0 1-.763-.343c-.027.026-.027.052-.054.105.316.158.632.316.92.5.265.16.528.317.765.5.316.29.685.45 1.13.554a.282.282 0 0 0-.05-.107c.736.343 1.5.712 2.078 1-2.737.054-5.658.107-8.685.16 0-.5-.026-.975-.026-1.476 0-.21.052-.395.025-.606-.08-1.21-.08-2.424-.237-3.61-.157-1.264-.157-2.503-.13-3.77.025-.683-.027-1.394-.054-2.08 0-.922 0-1.82.028-2.74 0-.132.053-.237.106-.37h.08c.025.054 0 .133.05.16.08.08.212.21.265.184.157-.106.394-.21.447-.37.13-.315.184-.658.184-.974 0-.236.106-.394.21-.553.054-.08.08-.158.133-.263-.105-.08-.21-.132-.342-.237.106-.29.08-.633.475-.79.052-.027.052-.16.08-.238.025-.213.05-.45.078-.66.052.08.08.105.13.157a.42.42 0 0 1 .054-.08c0-.104-.026-.315 0-.315.316-.053.184-.395.342-.553.025-.028-.027-.107-.027-.16 0-.052 0-.13.026-.13.367-.08.315-.475.552-.66.08-.053.105-.13.21-.263.21.368-.158.553-.184.816.446-.263.578-.895.315-1.08.105-.08.21-.184.29-.29.29-.316.604-.606.868-.922.185-.236.29-.526.474-.763.106-.132.316-.237.474-.317.474-.262.92-.552 1.21-1 .053-.053.132-.105.21-.158.08-.053.238-.053.264-.132.027-.052-.052-.184-.105-.263.104-.053.21-.158.42-.264-.08.158-.105.264-.158.37l.13.13c.238-.184.606-.394.843-.552 0-.025-.132-.13-.132-.13-.157.08-.394.21-.63.316.05-.08.05-.132.08-.158.367-.237.735-.474 1.13-.66.92-.42 1.842-.842 2.763-1.237.158-.08.37-.026.553-.026.078 0 .13 0 .21-.026.42-.132.842-.264 1.263-.37.183-.052.393-.078.58-.078.787.025 1.577.025 2.366.078.342.026.658.105.974.21a9.88 9.88 0 0 1 1.184.5c.447.24.868.502 1.29.792.763.5 1.473 1.054 2.236 1.502.737.448 1.316 1.054 1.79 1.74.58.816 1.237 1.554 1.5 2.555l.394 1.74c.08.316.264.632.185 1-.133.66-.238 1.345-.343 2.004-.052.265-.105.53-.078.79.05.82-.265 1.53-.58 2.268-.106.237-.264.475-.395.738a.798.798 0 0 0 .21.106l.237-.474c.027 0 .027 0 .053.027-.132.368-.237.764-.37 1.133-.314.817-.63 1.66-1.025 2.45-.21.448-.58.817-.842 1.24-.262.368-.473.763-.736 1.106-.237.29-.473.58-.79.79-.71.527-1.447 1.054-2.21 1.476-.473.29-1.026.448-1.552.58zm-14.027-1.4l-.026.027c-.055-.026-.134-.052-.186-.105l-.632-.95c-.052-.078-.08-.157-.052-.262.29.448.58.87.895 1.29zm16.37 3.61c1.183-.5 2.157-1.21 3.05-2.028.133-.132.264-.263.422-.37 1.106-.684 1.92-1.633 2.658-2.687.842-1.212 1.395-2.582 2.08-3.873a2.73 2.73 0 0 1 .157-.29c-.053 3.004.29 5.955.684 8.933-2.973.105-6 .21-9.052.316zm26.683-.79c-.026.053-.08.106-.105.16-.027-.054-.027-.133-.053-.24-.158.423-.5.212-.737.212-1.42.027-2.868.027-4.29.027-1.368 0-2.762 0-4.13.024-.448 0-.922.105-1.37.132-1.078.052-2.157.08-3.236.105-.08 0-.158-.13-.29-.236a1.81 1.81 0 0 1-.158.237c-.028-.052-.08-.104-.133-.183-.026.08-.053.158-.08.21H58c-.053-.368-.158-.71-.158-1.08 0-.79.08-1.58.105-2.372.027-.368 0-.71 0-1.054.106.08.185.133.29.21.052-.103.105-.182.158-.26 0 0-.053-.028-.106-.08.05-.027.104-.08.104-.106.026-.08.08-.158.08-.21 0-.185-.054-.343-.08-.5.026 0 .052 0 .08-.028l.157.79h.08c-.106-.183.236-.342-.053-.552-.026-.027.026-.185.026-.264-.08-.157-.13-.315-.21-.526.026-.026.105-.053.184-.08-.105-.052-.184-.104-.263-.13.263-.238.263-.37.026-.633.054-.025.106-.025.106-.05 0-.238 0-.475-.052-.71-.053-.266.08-.58-.316-.74a.79.79 0 0 0 .105.21s-.08.027-.158.08c-.342-.317-.13-.74-.21-1.213.184.053.316.106.447.16-.053-.186-.184-.397-.263-.634h-.107v-1.74c0 .027.184.027.29.054 0-.027.025-.053.025-.08-.08-.105-.185-.21-.29-.342l.053-.053c-.21-.262-.105-.63-.105-.71V39.4c.264.264-.13.606.264.764v-.263h-.027c-.026-.395-.026-.79-.052-1.186h-.052c-.027.054-.027.08-.054.133h-.052l.158-6.298c.263.342.552.66.736 1 .606 1.108 1.395 2.057 2.132 3.058.632.87 1.21 1.818 1.79 2.714.71 1.08 1.394 2.16 2.105 3.24a81.41 81.41 0 0 0 1.63 2.426c.5.71 1.028 1.396 1.554 2.082.446.606.92 1.212 1.367 1.818.527.738 1.053 1.475 1.58 2.187.262.368.552.737.84 1.106.16.21.396.37.554.5-.025 0-.052 0-.104-.026.08.105.13.184.184.237.29.158.316.316.158.554zM74 46.854v-.185c0 .052.026.13 0 .184zm.895-11.62c-.027 0-.184-.16-.21-.186-.027.08 0 .158-.053.264-.027-.078-.21-.052-.21-.13-.027.368.157.737.13 1.106.08-.053.395-.08.474-.158.027.026.08.052.106.052-.527.396-.395.79-.158 1.24.052.104.21.315.052.526-.052.053.027.21.053.343h.077v.05l-.237.08c-.052-.08-.367-.236-.367-.37v1.346c.263.08.263.448.368.633a.768.768 0 0 0 .107-.21l.027.024c-.027.158-.053.316-.106.475-.052.236-.105.447-.13.684 0 .026.05.08.05.105-.288.66-.13 1.396-.235 2.08-.08.5 0 1.03-.053 1.556-.054.448-.16.922-.264 1.37-.027.08-.08.105-.21.158.052-.316.026-.527-.027-.817-.028 0-.37-.184-.397-.184 0 .37.21.87.29 1.29-.08-.026-.395-.21-.42-.21-.054.316-.054.738-.08 1.08-.027.264-.263.5-.29.79 0 .16.184.264.158.528h.21c0-.526.238-1 .238-1.554h.078c.027.053.106.106.08.132-.053.29-.16.606-.132.896 0 .158.13.316.08.5-.054.16-.08.317-.107.554-.027-.132-.053-.184-.053-.263-.026 0-.263-.027-.29-.027-.026.158.185.316.158.448-.026.026-.052.026-.105.053l-.868-1.266c-.686-1-1.37-2.003-2.054-3.03a6.312 6.312 0 0 1-.475-.79 37.09 37.09 0 0 0-2.71-4.033c-.762-.974-1.37-2.03-2.08-3.055-.656-.975-1.314-1.924-1.972-2.9-.237-.315-.526-.605-.737-.948-.683-1.08-1.29-2.187-1.972-3.267-.58-.897-1.21-1.767-1.816-2.636-.21-.29-.42-.607-.632-.923a.37.37 0 0 1-.052-.182c-.053-.58-.106-1.16-.132-1.713 0-.527.053-1.054.053-1.608v-.474c0-.132.025-.237.025-.37.025-.025.052-.078.078-.104-.763 0-1.553-.028-2.316 0-.5.025-.763-.186-1.105-.555-1-1.133-1.737-2.424-2.605-3.636a162.42 162.42 0 0 0-2.5-3.427c-.685-.922-1.37-1.818-2.053-2.74-.764-1.054-1.5-2.108-2.29-3.162a381.983 381.983 0 0 0-2.895-3.794c-.45-.58-.95-1.133-1.45-1.74.343.054.66.106.975.133l1.264.08c.947.077 1.894.13 2.84.26.79.107 1.58.265 2.396.396 1.738.29 3.448.765 5.106 1.318.974.316 1.92.738 2.87 1.133 2.13.87 4.157 1.924 6.157 3.03.63.343 1 .896 1.472 1.397.685.712 1.37 1.423 2.027 2.16.762.87 1.472 1.766 2.21 2.662.657.79 1.34 1.58 2 2.372.21.237.37.527.552.79.42.633.895 1.24 1.263 1.924.262.502.42 1.082.604 1.635.262.817.526 1.607.79 2.424.183.606.34 1.24.472 1.87.106.423.08.87.21 1.29.16.556 0 1.16.16 1.715.025.053.05.132.078.185.105.104.184.21.026.368-.025.026-.025.13 0 .21.054-.052.08-.105.133-.184 0 .053.025.08.025.105 0 .104-.027.21 0 .315 0 .052.052.13.078.184.053-.054.105-.08.21-.16.237.897.264 1.793.264 2.715 0 .87.157 1.74-.21 2.583.078-.29-.106-.555-.027-.818z"/><path d="M58.08 45.482c.025 0 .052.027.052.027l-.027-.03c0-.025 0-.025-.026 0zm4.157 26.036c-.29.21-.58.395-.948.474-.028-.026-.028-.053-.054-.08.29-.184.605-.368.895-.553.027.05.08.104.106.157zM12.895 35.81c.29-.367.58-.736.894-1.105.025.026.235.08.262.105-.29.37-.685.87-.974 1.265-.054-.053-.133-.237-.185-.264zM5.42 48.725c-.21-.448-.42-.923-.63-1.37a.91.91 0 0 1 .236-.106c.29.42.42.92.632 1.37 0 0-.21.105-.237.105zm6.712-12.65c-.158.238-.316.502-.474.74-.026-.028-.316.104-.342.078.158-.237.552-.66.71-.896.027.026.053.053.106.08zM59.422 72.6c.025 0 .025-.026.052-.026.184.026.394.052.605.052-.344.237-.555.21-.66-.026zm-47.24-35.418c.028-.08.08-.158.133-.237.052 0 .13-.027.13-.027.107-.184.107-.316.212-.474-.026-.026-.053-.026-.08-.053-.157.108-.315.24-.473.345.053.052.053.08.053.132-.21-.027-.29.08-.395.368-.026.08-.158.106-.29.21-.026.054-.052.186-.105.317l.027.028c-.053.053-.132.08-.132.08-.158.157-.342.29-.5.447-.026.08-.052.158-.052.237.185-.184.5-.527.737-.738l.027.027c.105-.158.184-.316.29-.474.025.026.025.052.052.08-.08.21-.158.446-.237.657-.055.026-.134.08-.134.053-.105.08-.184.184-.29.263l-.473.316c-.263.237-.526.447-.816.685-.184.29-.368.553-.58.896.317-.08.396.053.37.317.368.052.395-.237.5-.448.026-.054.053-.16.105-.186.237-.21.5-.394.763-.605.053-.053.053-.16.053-.238 0-.026-.133-.026-.212-.053.237-.264.58-.71.816-1 .132-.08.263-.186.263-.265-.026-.29.158-.368.37-.474-.106-.08-.133-.157-.133-.183z"/><path d="M12.71 36.892c-.105.184-.21.342-.315.527l-.158-.08c-.105.605-.474 1.132-.842 1.237.105.053.21.106.29.08.078-.027.13-.16.183-.238l.71-1.028.238-.396-.105-.105zM3.948 48.46c.132 0 .264.026.42.026 0-.105.133-.08.133-.184h.08c0 .132.026.237.026.37h-.552c-.027-.027-.132-.186-.106-.212zm-.21-1.212c-.08-.08-.21-.158-.21-.237-.027-.104.052-.235.13-.367.054.184.08.342.132.527-.027.025-.053.052-.053.078zm.658-1.687c.105.266.21.556.316.82a.798.798 0 0 0-.21.105c-.105-.264-.237-.554-.342-.817a.652.652 0 0 1 .237-.106zm58.58 25.194c.13-.052.288-.08.5-.13-.238.183-.422.315-.58.473-.027-.026-.053-.053-.08-.053.053-.105.106-.184.16-.29zM30.63 15.074c.157-.106.29-.185.447-.29l.052.052c-.16.21-.29.42-.475.685-.026-.183-.026-.29-.053-.42-.026 0 0 0 .027-.026zm7.71 13.333c.237-.106.474-.21.763-.343-.026.158-.026.264-.026.37a.927.927 0 0 0-.264-.054c-.158.027-.448.238-.58.264-.025 0 .106-.21.106-.237zm19.74 22.346c.052.263.552.395.052.658.08.055.157.08.236.134a.2.2 0 0 1-.052.106c-.053.025-.158.078-.21.05-.027 0-.08-.104-.08-.157 0-.237.027-.474.053-.79z"/></g></symbol><symbol viewBox="0 0 24 24" id="powerpoint" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5M8 11v2h1v6H8v1h4v-1h-1v-2h2a3 3 0 0 0 3-3 3 3 0 0 0-3-3H8m5 2a1 1 0 0 1 1 1 1 1 0 0 1-1 1h-2v-2h2z" fill="#d14524"/></symbol><symbol viewBox="0 0 67.47 70" id="powershell" xmlns="http://www.w3.org/2000/svg"><path d="M18.545 12.4c-3.014 0-6.08 2.34-6.873 5.248L1.91 53.438c-.793 2.908.996 5.248 4.01 5.248h42.887c3.014 0 6.08-2.34 6.873-5.248l9.761-35.79c.794-2.908-.993-5.248-4.007-5.248h-42.89zm4.848 6.243c.652.04 1.29.33 1.76.86l7.96 9.013-3.957 3.246 3.957-3.244 4.832 5.47c.037.042.06.088.094.131.026.034.057.06.082.096.02.028.032.057.05.086.057.087.105.176.15.267.028.06.055.117.08.178a2.546 2.546 0 0 1 .171.764c.005.073.01.146.008.219-.002.09-.01.178-.021.267a2.53 2.53 0 0 1-.036.217 2.56 2.56 0 0 1-.07.252c-.024.076-.048.15-.08.224a2.547 2.547 0 0 1-.111.22 2.503 2.503 0 0 1-.133.218 2.546 2.546 0 0 1-.147.187c-.058.07-.118.137-.185.202-.027.026-.048.057-.076.082-.037.032-.077.054-.116.084-.038.03-.07.065-.11.093L16.8 52.271a2.552 2.552 0 0 1-3.563-.626 2.553 2.553 0 0 1 .63-3.563l18.349-12.853-3.06-3.467-7.839-8.873a2.549 2.549 0 0 1 .225-3.608 2.546 2.546 0 0 1 1.85-.638zm22.441 28.214c1.377 0 2.255 1.083 1.969 2.43-.287 1.347-1.627 2.433-3.004 2.434l-9.957.006c-1.378 0-2.256-1.083-1.969-2.43.287-1.347 1.626-2.433 3.004-2.434l9.957-.006z" fill="#03a9f4" stroke-width="5.342" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 210 210" id="prettier" xmlns="http://www.w3.org/2000/svg"><title>prettier-icon-dark</title><g transform="matrix(.9 0 0 .9 10.5 10.5)" fill="none" fill-rule="evenodd"><rect fill="#56B3B4" x="165" y="40" width="20" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="200" width="60" height="10" rx="5"/><rect fill="#BF85BF" x="135" y="120" width="40" height="10" rx="5"/><rect fill="#EA5E5E" x="75" y="120" width="50" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="120" width="50" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="160" width="60" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="80" width="60" height="10" rx="5"/><rect fill="#F7BA3E" x="65" y="20" width="110" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="20" width="40" height="10" rx="5"/><rect fill="#F7BA3E" x="55" y="180" width="20" height="10" rx="5"/><rect fill="#56B3B4" x="55" y="60" width="20" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="180" width="30" height="10" rx="5"/><rect fill="#F7BA3E" x="15" y="60" width="30" height="10" rx="5"/><rect fill="#56B3B4" x="95" y="100" width="90" height="10" rx="5"/><rect fill="#F7BA3E" x="45" y="100" width="40" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="100" width="20" height="10" rx="5"/><rect fill="#BF85BF" x="105" y="40" width="50" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="40" width="80" height="10" rx="5"/><rect fill="#F7BA3E" x="45" y="140" width="100" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="140" width="20" height="10" rx="5"/><rect fill="#EA5E5E" x="135" y="60" width="60" height="10" rx="5"/><rect fill="#F7BA3E" x="135" y="80" width="60" height="10" rx="5"/><rect fill="#56B3B4" x="15" width="130" height="10" rx="5"/></g></symbol><symbol viewBox="0 0 80 80" id="protractor" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="hxa"><path transform="scale(1 -1)" fill="#564b55" stroke-width="27.224" d="M-2.983-69.251h69.412v67.108H-2.983z"/></clipPath></defs><g transform="matrix(1.13039 0 0 -1.13039 5.714 82.137)" clip-path="url(#hxa)"><g transform="scale(.1)"><path d="M1180.54 92.324c-5.53 0-9.93-1.797-13.23-5.39-3.29-3.614-5.22-8.594-5.81-14.97h36.02c0 6.583-1.47 11.622-4.4 15.126-2.93 3.496-7.12 5.234-12.58 5.234zm2.84-62.656c-10.19 0-18.22 3.086-24.11 9.297-5.88 6.21-8.83 14.824-8.83 25.84 0 11.101 2.73 19.922 8.21 26.464 5.45 6.524 12.81 9.805 22.02 9.805 8.63 0 15.46-2.851 20.48-8.523 5.03-5.676 7.55-13.157 7.55-22.461v-6.613h-47.45c.21-8.086 2.26-14.22 6.12-18.418 3.89-4.18 9.34-6.29 16.38-6.29 7.42 0 14.76 1.563 22 4.669V34.14c-3.68-1.602-7.18-2.746-10.48-3.438-3.28-.684-7.24-1.035-11.89-1.035M1272.34 30.918v44.57c0 5.606-1.28 9.805-3.82 12.559-2.56 2.773-6.56 4.16-12.02 4.16-7.2 0-12.49-1.953-15.84-5.851-3.34-3.895-5.03-10.32-5.03-19.286V30.918h-10.42v68.887h8.47l1.71-9.422h.5c2.14 3.387 5.14 6.023 8.99 7.887 3.85 1.867 8.15 2.804 12.88 2.804 8.29 0 14.54-2.011 18.73-6.015 4.19-3.985 6.28-10.391 6.28-19.192V30.918h-10.43M1328.96 38.406c7.1 0 12.27 1.938 15.48 5.813 3.22 3.879 4.81 10.129 4.81 18.758v2.199c0 9.765-1.62 16.726-4.87 20.898-3.25 4.18-8.44 6.25-15.56 6.25-6.11 0-10.79-2.383-14.04-7.129-3.26-4.746-4.88-11.472-4.88-20.136 0-8.797 1.61-15.45 4.84-19.93 3.23-4.484 7.97-6.723 14.22-6.723zm20.85 1.762h-.56c-4.83-7.004-12.02-10.5-21.62-10.5-9.01 0-16.03 3.066-21.04 9.238-5 6.153-7.5 14.922-7.5 26.27 0 11.355 2.51 20.176 7.54 26.465 5.03 6.289 12.03 9.433 21 9.433 9.34 0 16.5-3.398 21.49-10.195h.81l-.43 4.96-.25 4.845v28.039h10.43V30.918h-8.49l-1.38 9.25M1434.91 38.27c1.85 0 3.63.136 5.34.421 1.72.274 3.09.547 4.1.84v-7.976c-1.15-.559-2.81-.996-5.01-1.36-2.18-.351-4.17-.527-5.94-.527-13.32 0-19.97 7.012-19.97 21.055V91.71h-9.88v5.027l9.88 4.336 4.38 14.707h6.04V99.805h20V91.71h-20V51.16c0-4.15.98-7.333 2.96-9.56 1.97-2.206 4.67-3.331 8.1-3.331M1463.81 65.43c0-8.809 1.76-15.508 5.27-20.118 3.53-4.609 8.69-6.906 15.53-6.906s12.01 2.297 15.56 6.875c3.53 4.602 5.3 11.301 5.3 20.149 0 8.75-1.77 15.41-5.3 19.953-3.55 4.539-8.77 6.824-15.69 6.824-6.82 0-11.99-2.246-15.47-6.73-3.46-4.48-5.2-11.16-5.2-20.047zm52.47 0c0-11.23-2.83-20-8.48-26.309-5.66-6.309-13.47-9.453-23.44-9.453-6.17 0-11.64 1.445-16.42 4.336-4.78 2.89-8.46 7.031-11.06 12.45-2.59 5.401-3.88 11.73-3.88 18.976 0 11.23 2.8 19.968 8.41 26.242 5.61 6.258 13.4 9.402 23.38 9.402 9.64 0 17.3-3.222 22.97-9.62 5.69-6.415 8.52-15.087 8.52-26.024M1591.71 92.324c-5.54 0-9.94-1.797-13.23-5.39-3.3-3.614-5.24-8.594-5.81-14.97h36c0 6.583-1.46 11.622-4.39 15.126-2.93 3.496-7.13 5.234-12.57 5.234zm2.83-62.656c-10.19 0-18.22 3.086-24.11 9.297-5.89 6.21-8.83 14.824-8.83 25.84 0 11.101 2.74 19.922 8.2 26.464 5.46 6.524 12.81 9.805 22.04 9.805 8.62 0 15.45-2.851 20.48-8.523 5.03-5.676 7.54-13.157 7.54-22.461v-6.613h-47.45c.21-8.086 2.25-14.22 6.13-18.418 3.87-4.18 9.33-6.29 16.36-6.29 7.43 0 14.77 1.563 22.01 4.669V34.14c-3.69-1.602-7.17-2.746-10.46-3.438-3.3-.684-7.27-1.035-11.91-1.035M1683.5 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12.01 4.16-7.2 0-12.48-1.953-15.83-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.48l1.69-9.422h.51c2.14 3.387 5.14 6.023 8.99 7.887 3.84 1.867 8.15 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M1740.11 38.406c7.12 0 12.28 1.938 15.49 5.813 3.21 3.879 4.81 10.129 4.81 18.758v2.199c0 9.765-1.62 16.726-4.87 20.898-3.25 4.18-8.43 6.25-15.56 6.25-6.12 0-10.8-2.383-14.05-7.129-3.24-4.746-4.88-11.472-4.88-20.136 0-8.797 1.64-15.45 4.85-19.93 3.22-4.484 7.96-6.723 14.21-6.723zm20.87 1.762h-.57c-4.82-7.004-12.03-10.5-21.62-10.5-9.01 0-16.02 3.066-21.03 9.238-5 6.153-7.52 14.922-7.52 26.27 0 11.355 2.52 20.176 7.55 26.465 5.02 6.289 12.02 9.433 21 9.433 9.34 0 16.5-3.398 21.48-10.195h.83l-.44 4.96-.25 4.845v28.039h10.43V30.918h-8.49l-1.37 9.25M1846.07 38.27c1.85 0 3.64.136 5.36.421 1.7.274 3.07.547 4.08.84v-7.976c-1.13-.559-2.8-.996-5-1.36-2.2-.351-4.18-.527-5.94-.527-13.33 0-19.99 7.012-19.99 21.055V91.71h-9.86v5.027l9.86 4.336 4.4 14.707h6.04V99.805H1855V91.71h-19.98V51.16c0-4.15.98-7.333 2.95-9.56 1.97-2.206 4.68-3.331 8.1-3.331M1894.26 92.324c-5.53 0-9.94-1.797-13.22-5.39-3.31-3.614-5.25-8.594-5.83-14.97h36.01c0 6.583-1.45 11.622-4.38 15.126-2.95 3.496-7.13 5.234-12.58 5.234zm2.83-62.656c-10.19 0-18.22 3.086-24.1 9.297-5.9 6.21-8.84 14.824-8.84 25.84 0 11.101 2.73 19.922 8.2 26.464 5.47 6.524 12.81 9.805 22.03 9.805 8.63 0 15.46-2.851 20.49-8.523 5.03-5.676 7.55-13.157 7.55-22.461v-6.613h-47.46c.22-8.086 2.26-14.22 6.13-18.418 3.87-4.18 9.33-6.29 16.37-6.29 7.42 0 14.75 1.563 22 4.669V34.14c-3.7-1.602-7.17-2.746-10.47-3.438-3.28-.684-7.25-1.035-11.9-1.035M1983.36 49.727c0-6.426-2.4-11.368-7.18-14.844-4.77-3.477-11.47-5.215-20.11-5.215-9.13 0-16.26 1.445-21.37 4.336v9.687a51.32 51.32 0 0 1 10.65-3.964c3.79-.977 7.45-1.457 10.97-1.457 5.46 0 9.64.87 12.57 2.609 2.95 1.738 4.41 4.394 4.41 7.95 0 2.694-1.17 4.98-3.5 6.894-2.32 1.914-6.85 4.152-13.6 6.757-6.41 2.383-10.97 4.473-13.67 6.25-2.71 1.778-4.72 3.81-6.04 6.067-1.31 2.254-1.98 4.96-1.98 8.113 0 5.606 2.29 10.04 6.86 13.281 4.57 3.25 10.84 4.883 18.79 4.883 7.42 0 14.66-1.515 21.74-4.531l-3.71-8.496c-6.9 2.851-13.17 4.277-18.79 4.277-4.94 0-8.67-.77-11.18-2.324-2.52-1.543-3.78-3.691-3.78-6.406 0-1.844.48-3.418 1.42-4.707.95-1.309 2.46-2.54 4.56-3.711 2.09-1.184 6.11-2.871 12.07-5.086 8.16-2.98 13.69-5.98 16.55-8.996 2.87-3.02 4.32-6.809 4.32-11.367M2021.28 38.27c1.85 0 3.64.136 5.35.421 1.71.274 3.09.547 4.09.84v-7.976c-1.14-.559-2.81-.996-5.01-1.36-2.18-.351-4.18-.527-5.93-.527-13.33 0-19.99 7.012-19.99 21.055V91.71h-9.87v5.027l9.87 4.336 4.4 14.707h6.02V99.805h20V91.71h-20V51.16c0-4.15 1-7.333 2.97-9.56 1.98-2.206 4.67-3.331 8.1-3.331M2053.61 30.918h-10.42v68.887h10.42zm-11.31 87.559c0 2.39.59 4.14 1.76 5.253 1.18 1.106 2.65 1.661 4.42 1.661 1.67 0 3.1-.567 4.32-1.7 1.22-1.132 1.82-2.871 1.82-5.214 0-2.344-.6-4.09-1.82-5.247-1.22-1.16-2.65-1.726-4.32-1.726-1.77 0-3.24.566-4.42 1.726-1.17 1.157-1.76 2.903-1.76 5.247M2121.59 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12 4.16-7.21 0-12.49-1.953-15.84-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.49l1.69-9.422h.5c2.15 3.387 5.14 6.023 8.99 7.887 3.85 1.867 8.16 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M2159.29 77.742c0-4.812 1.35-8.465 4.08-10.926 2.72-2.48 6.51-3.71 11.37-3.71 10.19 0 15.28 4.953 15.28 14.831 0 10.344-5.16 15.532-15.47 15.532-4.9 0-8.67-1.32-11.31-3.965-2.63-2.649-3.95-6.555-3.95-11.762zm-5.67-58.387c0-3.73 1.58-6.55 4.72-8.488 3.14-1.922 7.65-2.879 13.52-2.879 8.75 0 15.24 1.309 19.45 3.926 4.21 2.617 6.31 6.172 6.31 10.652 0 3.723-1.15 6.32-3.45 7.754-2.31 1.457-6.65 2.168-13.01 2.168h-12.51c-4.74 0-8.43-1.12-11.06-3.386-2.65-2.266-3.97-5.508-3.97-9.747zm54.94 80.45v-6.582l-12.76-1.512c1.18-1.477 2.23-3.39 3.15-5.754.91-2.371 1.37-5.039 1.37-8.02 0-6.746-2.29-12.128-6.91-16.152-4.61-4.012-10.93-6.023-18.98-6.023-2.05 0-3.98.156-5.78.5-4.45-2.356-6.67-5.305-6.67-8.871 0-1.883.77-3.282 2.34-4.176 1.54-.902 4.21-1.36 7.97-1.36h12.2c7.46 0 13.19-1.574 17.19-4.707 4-3.144 6-7.714 6-13.71 0-7.618-3.06-13.426-9.17-17.43C2192.38 2.004 2183.46 0 2171.72 0c-9 0-15.95 1.68-20.82 5.027-4.88 3.352-7.34 8.079-7.34 14.211 0 4.18 1.35 7.813 4.03 10.88 2.68 3.046 6.45 5.116 11.32 6.21-1.77.8-3.24 2.031-4.44 3.711-1.19 1.68-1.78 3.633-1.78 5.84 0 2.52.66 4.707 2.01 6.602 1.34 1.882 3.44 3.71 6.34 5.468-3.56 1.465-6.46 3.953-8.71 7.48-2.23 3.516-3.35 7.54-3.35 12.06 0 7.55 2.26 13.37 6.79 17.452 4.52 4.082 10.93 6.133 19.22 6.133 3.6 0 6.86-.429 9.75-1.27h23.82M2284.61 91.71h-17.54V30.919h-10.43v60.793h-12.31v4.707l12.31 3.766v3.839c0 16.922 7.4 25.391 22.19 25.391 3.65 0 7.93-.73 12.82-2.195l-2.7-8.364c-4.03 1.301-7.46 1.946-10.31 1.946-3.93 0-6.85-1.309-8.73-3.926-1.89-2.617-2.84-6.816-2.84-12.598v-4.472h17.54V91.71M2302.87 65.43c0-8.809 1.76-15.508 5.28-20.118 3.52-4.609 8.7-6.906 15.52-6.906 6.84 0 12.02 2.297 15.57 6.875 3.54 4.602 5.3 11.301 5.3 20.149 0 8.75-1.76 15.41-5.3 19.953-3.55 4.539-8.78 6.824-15.69 6.824-6.83 0-11.99-2.246-15.46-6.73-3.48-4.48-5.22-11.16-5.22-20.047zm52.48 0c0-11.23-2.82-20-8.47-26.309-5.67-6.309-13.48-9.453-23.46-9.453-6.15 0-11.62 1.445-16.4 4.336-4.77 2.89-8.47 7.031-11.06 12.45-2.59 5.401-3.9 11.73-3.9 18.976 0 11.23 2.81 19.968 8.43 26.242 5.6 6.258 13.4 9.402 23.38 9.402 9.63 0 17.28-3.222 22.97-9.62 5.68-6.415 8.51-15.087 8.51-26.024M2403.79 101.074c3.07 0 5.8-.254 8.22-.761l-1.43-9.676c-2.86.633-5.37.933-7.55.933-5.58 0-10.33-2.261-14.3-6.785-3.95-4.531-5.94-10.156-5.94-16.902V30.918h-10.43v68.887h8.62l1.19-12.754h.5c2.56 4.48 5.63 7.949 9.23 10.37 3.61 2.423 7.56 3.653 11.89 3.653M2500.33 69.766l-10.68 28.476c-1.39 3.594-2.81 8.028-4.28 13.262-.93-4.024-2.24-8.438-3.96-13.262l-10.81-28.476zm14.77-38.848l-11.44 29.227h-36.83l-11.32-29.227h-10.81l36.34 92.273h8.98l36.13-92.273h-11.05M2583.07 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12 4.16-7.21 0-12.49-1.953-15.84-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.48l1.69-9.422h.51c2.14 3.387 5.14 6.023 8.99 7.887 3.84 1.867 8.15 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M2620.76 77.742c0-4.812 1.36-8.465 4.08-10.926 2.73-2.48 6.53-3.71 11.37-3.71 10.2 0 15.28 4.953 15.28 14.831 0 10.344-5.15 15.532-15.45 15.532-4.91 0-8.68-1.32-11.32-3.965-2.64-2.649-3.96-6.555-3.96-11.762zm-5.66-58.387c0-3.73 1.57-6.55 4.71-8.488 3.15-1.922 7.65-2.879 13.53-2.879 8.75 0 15.23 1.309 19.44 3.926 4.21 2.617 6.31 6.172 6.31 10.652 0 3.723-1.14 6.32-3.45 7.754-2.31 1.457-6.64 2.168-13 2.168h-12.51c-4.74 0-8.43-1.12-11.07-3.386-2.63-2.266-3.96-5.508-3.96-9.747zm54.94 80.45v-6.582l-12.76-1.512c1.18-1.477 2.22-3.39 3.14-5.754.92-2.371 1.38-5.039 1.38-8.02 0-6.746-2.3-12.128-6.92-16.152-4.61-4.012-10.92-6.023-18.97-6.023-2.05 0-3.99.156-5.78.5-4.46-2.356-6.67-5.305-6.67-8.871 0-1.883.78-3.282 2.33-4.176 1.55-.902 4.21-1.36 7.98-1.36h12.2c7.46 0 13.18-1.574 17.18-4.707 4.01-3.144 6-7.714 6-13.71 0-7.618-3.06-13.426-9.17-17.43C2653.87 2.004 2644.94 0 2633.2 0c-9 0-15.95 1.68-20.83 5.027-4.88 3.352-7.33 8.079-7.33 14.211 0 4.18 1.35 7.813 4.02 10.88 2.69 3.046 6.47 5.116 11.32 6.21-1.77.8-3.23 2.031-4.43 3.711-1.19 1.68-1.79 3.633-1.79 5.84 0 2.52.66 4.707 2.01 6.602 1.35 1.882 3.45 3.71 6.35 5.468-3.56 1.465-6.47 3.953-8.71 7.48-2.23 3.516-3.35 7.54-3.35 12.06 0 7.55 2.25 13.37 6.79 17.452 4.52 4.082 10.92 6.133 19.21 6.133 3.62 0 6.86-.429 9.75-1.27h23.83M2692.7 99.805V55.117c0-5.605 1.27-9.805 3.83-12.566 2.56-2.766 6.57-4.145 12.01-4.145 7.2 0 12.47 1.965 15.81 5.903 3.33 3.945 4.99 10.379 4.99 19.304v36.192h10.44V30.918h-8.62l-1.5 9.25h-.58c-2.13-3.41-5.1-5.988-8.88-7.793-3.8-1.809-8.13-2.707-12.99-2.707-8.37 0-14.65 1.992-18.81 5.977-4.18 3.964-6.26 10.351-6.26 19.101v45.059h10.56M2760.61 30.918h10.43v97.805h-10.43zM2810.67 38.27c6.5 0 11.6 1.789 15.31 5.343 3.71 3.575 5.56 8.555 5.56 14.961v6.23l-10.44-.448c-8.3-.286-14.27-1.583-17.94-3.868-3.66-2.273-5.5-5.82-5.5-10.644 0-3.781 1.14-6.64 3.42-8.613 2.29-1.973 5.48-2.961 9.59-2.961zm23.57-7.352l-2.07 9.805h-.51c-3.44-4.305-6.86-7.227-10.27-8.77-3.42-1.523-7.68-2.285-12.8-2.285-6.83 0-12.17 1.758-16.05 5.273-3.87 3.528-5.81 8.536-5.81 15.032 0 13.906 11.12 21.199 33.37 21.875l11.7.359v4.277c0 5.418-1.17 9.395-3.5 11.985-2.32 2.566-6.03 3.855-11.15 3.855-5.74 0-12.24-1.758-19.49-5.273l-3.21 7.988c3.4 1.836 7.11 3.281 11.16 4.324a47.81 47.81 0 0 0 12.16 1.575c8.23 0 14.3-1.817 18.27-5.461 3.96-3.66 5.93-9.5 5.93-17.54V30.919h-7.73M2893.6 101.074c3.07 0 5.8-.254 8.25-.761l-1.46-9.676c-2.84.633-5.35.933-7.54.933-5.56 0-10.33-2.261-14.3-6.785-3.96-4.531-5.93-10.156-5.93-16.902V30.918h-10.44v68.887h8.61l1.19-12.754h.5c2.57 4.48 5.65 7.949 9.25 10.37 3.6 2.423 7.56 3.653 11.87 3.653M2901.63 6.727c-3.94 0-7.04.558-9.31 1.691v9.121c2.97-.84 6.08-1.25 9.31-1.25 4.14 0 7.3 1.25 9.45 3.77 2.16 2.507 3.24 6.132 3.24 10.859v91.895h10.69V31.797c0-7.95-2.01-14.121-6.04-18.496-4.02-4.383-9.8-6.574-17.34-6.574M2999.96 55.371c0-8.086-2.93-14.394-8.8-18.918-5.87-4.52-13.83-6.785-23.88-6.785-10.9 0-19.27 1.406-25.14 4.219v10.3c3.77-1.59 7.88-2.847 12.31-3.765 4.45-.93 8.85-1.399 13.21-1.399 7.12 0 12.49 1.36 16.09 4.063 3.59 2.695 5.4 6.465 5.4 11.277 0 3.196-.63 5.805-1.91 7.832-1.29 2.024-3.42 3.907-6.42 5.625-2.99 1.711-7.56 3.664-13.67 5.84-8.55 3.059-14.66 6.692-18.32 10.871-3.66 4.2-5.51 9.668-5.51 16.407 0 7.089 2.68 12.714 7.99 16.914 5.32 4.191 12.36 6.289 21.12 6.289 9.13 0 17.54-1.68 25.2-5.032l-3.32-9.304c-7.59 3.183-14.96 4.785-22.13 4.785-5.66 0-10.07-1.223-13.26-3.652-3.19-2.43-4.78-5.809-4.78-10.118 0-3.191.59-5.8 1.76-7.832 1.17-2.031 3.14-3.886 5.95-5.597 2.78-1.688 7.04-3.563 12.79-5.625 9.63-3.426 16.26-7.118 19.89-11.063 3.62-3.937 5.43-9.043 5.43-15.332M741.648 375.406h30c28.965 0 50.227 5.039 63.774 15.117 13.531 10.079 20.32 25.821 20.32 47.247 0 19.832-6.074 34.628-18.191 44.402-12.141 9.758-31.028 14.641-56.692 14.641h-39.211zm172.192 64.246c0-36.062-11.809-63.691-35.434-82.898-23.621-19.219-57.234-28.82-100.847-28.82h-35.911V198.73h-56.445v345.329h99.438c43.14 0 75.457-8.829 96.961-26.465 21.496-17.637 32.238-43.614 32.238-77.942M1099.26 464.691c11.17 0 20.39-.789 27.63-2.371l-5.43-51.718c-7.88 1.894-16.07 2.832-24.57 2.832-22.2 0-40.19-7.246-53.97-21.731-13.78-14.48-20.66-33.301-20.66-56.453V198.73h-55.514v261.227h43.464l7.32-46.055h2.83c8.66 15.594 19.96 27.95 33.9 37.09 13.93 9.141 28.93 13.699 45 13.699M1206.88 329.82c0-60.308 22.28-90.465 66.85-90.465 44.08 0 66.13 30.157 66.13 90.465 0 59.688-22.21 89.512-66.61 89.512-23.31 0-40.2-7.707-50.67-23.144-10.47-15.43-15.7-37.54-15.7-66.368zm190.13 0c0-42.672-10.95-75.972-32.83-99.898-21.89-23.945-52.35-35.918-91.41-35.918-24.41 0-45.97 5.508-64.7 16.543-18.75 11.016-33.16 26.836-43.23 47.48-10.08 20.625-15.11 44.551-15.11 71.793 0 42.364 10.86 75.43 32.58 99.2 21.73 23.777 52.36 35.671 91.89 35.671 37.79 0 67.7-12.156 89.75-36.492 22.05-24.328 33.06-57.121 33.06-98.379M1558.11 238.887c13.54 0 27.07 2.129 40.62 6.386v-41.816c-6.13-2.676-14.05-4.922-23.73-6.738-9.69-1.797-19.73-2.715-30.12-2.715-52.59 0-78.88 27.715-78.88 83.144v140.778h-35.68v24.558l38.26 20.325 18.9 55.261h34.26v-58.113h74.39v-42.031h-74.39v-139.84c0-13.379 3.34-23.242 10.03-29.629 6.69-6.387 15.48-9.57 26.34-9.57M1783.44 464.691c11.17 0 20.38-.789 27.62-2.371l-5.43-51.718c-7.88 1.894-16.06 2.832-24.56 2.832-22.2 0-40.2-7.246-53.97-21.731-13.78-14.48-20.66-33.301-20.66-56.453V198.73h-55.52v261.227h43.46l7.34-46.055h2.82c8.66 15.594 19.95 27.95 33.9 37.09 13.92 9.141 28.93 13.699 45 13.699M1925.05 236.523c20.15 0 36.32 5.625 48.52 16.895 12.21 11.25 18.31 27.051 18.31 47.344v22.676l-33.54-1.407c-26.13-.937-45.16-5.312-57.04-13.105-11.89-7.793-17.82-19.727-17.82-35.781 0-11.661 3.45-20.665 10.39-27.051 6.91-6.387 17.32-9.571 31.18-9.571zm82.66-37.793l-11.11 36.387h-1.87c-12.62-15.918-25.29-26.738-38.04-32.48-12.74-5.742-29.13-8.633-49.13-8.633-25.67 0-45.7 6.934-60.1 20.801-14.41 13.847-21.62 33.457-21.62 58.808 0 26.934 10 47.246 30 60.934 19.99 13.691 50.45 21.172 91.41 22.441l45.09 1.414v13.938c0 16.699-3.88 29.16-11.68 37.441-7.79 8.262-19.88 12.383-36.25 12.383-13.39 0-26.23-1.953-38.5-5.891a294.638 294.638 0 0 1-35.44-13.933l-17.94 39.668c14.17 7.41 29.68 13.035 46.52 16.894 16.85 3.868 32.77 5.789 47.72 5.789 33.22 0 58.31-7.246 75.22-21.726 16.94-14.492 25.4-37.246 25.4-68.262V198.73h-39.68M2220.04 194.004c-39.52 0-69.55 11.543-90.1 34.609-20.55 23.067-30.82 56.172-30.82 99.321 0 43.925 10.74 77.707 32.23 101.339 21.5 23.614 52.56 35.418 93.18 35.418 27.56 0 52.35-5.117 74.41-15.359l-16.78-44.641c-23.46 9.133-42.82 13.704-58.1 13.704-45.19 0-67.79-29.993-67.79-89.981 0-29.293 5.63-51.305 16.89-66.031 11.26-14.707 27.76-22.09 49.48-22.09 24.72 0 48.11 6.152 70.15 18.437v-48.417c-9.92-5.84-20.5-10-31.76-12.52-11.26-2.52-24.93-3.789-40.99-3.789M2451.52 238.887c13.54 0 27.08 2.129 40.63 6.386v-41.816c-6.15-2.676-14.05-4.922-23.73-6.738-9.69-1.797-19.73-2.715-30.12-2.715-52.6 0-78.9 27.715-78.9 83.144v140.778h-35.66v24.558l38.26 20.325 18.9 55.261h34.26v-58.113h74.39v-42.031h-74.39v-139.84c0-13.379 3.34-23.242 10.03-29.629 6.69-6.387 15.47-9.57 26.33-9.57M2585.92 329.82c0-60.308 22.28-90.465 66.84-90.465 44.09 0 66.15 30.157 66.15 90.465 0 59.688-22.22 89.512-66.62 89.512-23.31 0-40.2-7.707-50.67-23.144-10.47-15.43-15.7-37.54-15.7-66.368zm190.13 0c0-42.672-10.94-75.972-32.83-99.898-21.89-23.945-52.36-35.918-91.4-35.918-24.42 0-45.98 5.508-64.72 16.543-18.74 11.016-33.14 26.836-43.22 47.48-10.07 20.625-15.12 44.551-15.12 71.793 0 42.364 10.87 75.43 32.59 99.2 21.74 23.777 52.36 35.671 91.89 35.671 37.79 0 67.7-12.156 89.75-36.492 22.04-24.328 33.06-57.121 33.06-98.379M2972.33 464.691c11.18 0 20.38-.789 27.63-2.371l-5.43-51.718c-7.87 1.894-16.05 2.832-24.57 2.832-22.2 0-40.19-7.246-53.96-21.731-13.78-14.48-20.67-33.301-20.67-56.453V198.73h-55.51v261.227h43.46l7.33-46.055h2.83c8.66 15.594 19.96 27.95 33.89 37.09 13.94 9.141 28.94 13.699 45 13.699" fill="#100f0d"/><path d="M610.11 372.83c0-170.584-138.257-308.862-308.846-308.862-170.602 0-308.846 138.278-308.846 308.863 0 170.576 138.244 308.846 308.846 308.846 170.59 0 308.846-138.27 308.846-308.846" fill="#e53935" stroke-width="1.029"/><path d="M460.694 521.792l-105.04.958-61.415 61.415-72.096-47.883 12.445-12.438-29.207.26-99.129-166.817H67.357l24.39-24.402-24.57-41.363L294.66 64.049c2.192-.04 4.399-.08 6.603-.08 170.416 0 308.585 138.055 308.846 308.408L460.694 521.792" fill="#d51c2f" stroke-width="1.029"/><path d="M149.093 350.258c0 84.048 68.13 152.151 152.171 152.151 84.028 0 152.139-68.103 152.139-152.151zm342.063-7.017v14.046h44.015c-1.75 59.337-25.556 113.104-63.54 153.419L438.75 477.81l-9.925 9.94 32.875 32.887c-40.314 37.983-94.081 61.79-153.41 63.527l-.015-44.003h-14.035v44.003c-59.34-1.737-113.096-25.556-153.41-63.527l32.887-32.887-9.945-9.92-32.883 32.875c-37.975-40.315-61.781-94.082-63.53-153.419h44.002l-.008-14.034H67.176v-51.511h468.176v51.5h-44.196" fill="#f5f5f5" stroke-width="1.029"/></g></g></symbol><symbol id="pug" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:#c1272d}.hyst1{fill:#efcca3}.st2{fill:#ed1c24}.hyst3{fill:#ccac8d}.hyst4{fill:#fff}.st5{fill:#ff931e}.st6{fill:#ffb81e}.hyst7{fill:#56332b}.hyst8{fill:#442823}.hyst9{fill:#7f4a41}.hyst10{fill:#331712}.st11{fill:#fc6}.st12{fill:#ccc}.st13{fill:#b3b3b3}.st14{fill:#989898}.st15{fill:#323232}.st16{fill:#1e1e1e}.st17{fill:#4c4c4c}.st18{fill:#e6e6e6}.st19{fill:#606060}</style><path class="hyst1" d="M107.4 50.9c-.2-4.4.4-8.3-1.6-11.6-4.8-8.2-16.8-13-40.8-13v.7h-.5.5v-.7c-24 0-36.6 4.8-41.4 13.1-1.9 3.4-1.7 7.2-2 11.6-.2 3.5-1.8 7.2-1.1 11.2.8 5.2 1.1 10.4 1.9 15.2.6 3.9 6 7.2 6.5 10.9 1.4 10.2 12 14.9 36 14.9v.8h-.6.7v-.8c24 0 34.2-4.7 35.5-14.9.5-3.8 5.5-7 6.1-10.9.8-4.8 1.1-10 1.9-15.2.7-4-.9-7.8-1.1-11.3z"/><path class="hyst3" d="M64.6 54.5c4.3.1 7.3 2.8 10.1 5.3 3.3 2.9 8.9 4.9 11.2 7.4 2.3 2.5 5.3 5 6.4 8.9 1.1 3.9 1.4 8.9 1.4 10.2 0 1.3.7 1 2.7 0 4.7-2.3 9.9-8.5 9.9-8.5-.6 3.9-5.7 7.4-6.2 11.1C98.9 99.1 89 104 64.5 104h-.1.6"/><path class="hyst3" d="M80.4 46.7c.9 3.1 4.1 13.6-2.1 10.1 0 0 2.6 1.5 4.2 7.2 1.7 5.7 5.8 6.4 5.8 6.4s6.7 1.3 11.7-3c4.2-3.6 4.9-10 3.1-14.9-1.8-4.8-5-6.3-9.7-7.3-4.7-1.1-14.1-2-13 1.5z"/><circle cx="92.3" cy="58.1" r="8.8"/><circle class="hyst4" cx="90" cy="54.2" r="2.3"/><path class="hyst1" d="M78.9 57.7s7.9 5.4 12.2 10.7c4.3 5.3 4.2 6.3 4.2 6.3l-3.1 1.4s-4.4-8.3-9.8-11.4c-5.5-3.1-6.1-5.7-6.1-5.7l2.6-1.3z"/><path class="hyst3" d="M64.9 54.5c-4.3.1-7.5 2.8-10.4 5.3-3.3 2.9-9.1 4.9-11.4 7.4-2.3 2.5-5.4 5-6.5 8.9-1.1 3.9-1.5 8.9-1.5 10.2 0 1.3.2 1.4-2.7 0-4.7-2.2-9.9-8.5-9.9-8.5.6 3.9 5.7 7.4 6.2 11.1C30.1 99.1 40 104 64.5 104h.5"/><path class="hyst7" d="M88.1 71.4C83.3 65.5 75.6 60 64.9 60h-.1c-10.7 0-18.4 5.5-23.2 11.4-5 6.1-4.6 8.5-4.6 14.3 0 21 7.4 15 12.3 17.6 5 2.5 10.2 1.7 15.5 1.7h.1c5.4 0 10.5.7 15.5-1.8 4.9-2.5 12.3 3.7 12.3-17.3.1-5.8.4-8.4-4.6-14.5z"/><path class="hyst8" d="M64.4 65.2s-.7 9.7-2.1 11.6l2.6-.6-.5-11z"/><path class="hyst8" d="M65.1 65.2s.7 9.7 2.1 11.6l-2.6-.6.5-11z"/><path class="hyst7" d="M56.7 62.9c-1-2.3 2.6-6 8.3-6.1 5.7 0 9.3 3.7 8.3 6.1-1 2.4-4.6 3.1-8.3 3.2-3.6-.1-7.3-.8-8.3-3.2z"/><path d="M65 65.2c0-.4 3.4-.5 5.2-1.7 0 0-3.7 1.2-4.5.7-.8-.4-1-1.6-1-1.6s-.3 1.2-.9 1.6c-.7.4-4.9-.7-4.9-.7s5.6 1.4 5.6 1.7c0 .3-.1 1.3-.1 2 0 2.5 0 8.7.4 9.2.6.9.4-6.7.4-9.2-.1-.8-.1-1.6-.2-2z"/><path class="hyst9" d="M65.2 78.6c1.7 0 4.7 1.2 7.4 3.1-2.6-2.9-5.7-4.9-7.4-4.9-1.8 0-5.6 2.2-8.3 5.4 2.8-2.2 6.4-3.6 8.3-3.6z"/><path class="hyst8" d="M64.5 96.3c-3.8 0-7.5-1.2-10.9-2.1-.7-.2-1.4.3-2.1.1-6.3-2-11.4-5.4-14.5-9.7v1c0 21 7.4 15.1 12.3 17.6 5 2.5 10.2 1.7 15.5 1.7h.1c5.4 0 10.5.7 15.5-1.8 4.9-2.5 12.3 3.6 12.3-17.4 0-.8 0-1.6.1-2.3-2.9 4.7-8.2 8.4-14.8 10.6-.6.2-2-.3-2.6-.2-3.6 1.2-6.8 2.5-10.9 2.5z"/><path class="hyst8" d="M55 85s-2.5 7.5-.8 10.8l-2.3-1s1.7-7.6 3.1-9.8zM74.8 85s2.5 7.5.8 10.8l2.3-1s-1.8-7.6-3.1-9.8z"/><path class="hyst3" d="M48.6 46.7c-.9 3.1-4.1 13.6 2.1 10.1 0 0-2.6 1.5-4.2 7.2s-5.8 6.4-5.8 6.4-6.7 1.3-11.7-3c-4.2-3.6-4.9-10-3.1-14.9s5-6.3 9.7-7.3c4.7-1.1 14-2 13 1.5z"/><path d="M64.9 76.8c2.7 0 11.1 5.8 11.2 12.9v-.4c0-7.4-6.8-13.3-11.2-13.3-4.4 0-11.2 6-11.2 13.3v.4c.1-7.1 8.5-12.9 11.2-12.9z"/><ellipse transform="rotate(-14.465 66.712 61.468)" class="hyst10" cx="66.7" cy="61.5" rx=".8" ry="1.5"/><ellipse transform="rotate(17.235 62.371 61.462)" class="hyst10" cx="62.4" cy="61.5" rx=".8" ry="1.5"/><circle cx="37.2" cy="58.1" r="8.8"/><circle class="hyst4" cx="39.5" cy="54.2" r="2.3"/><path class="hyst9" d="M67.5 58.2c0-.1-2.3 1-2.9 1.1-.6-.1-2.9-1.2-2.9-1.1h5.8z"/><path class="hyst1" d="M50 57.7s-7.9 5.4-12.2 10.7c-4.3 5.3-4.2 6.3-4.2 6.3l3.1 1.4s4.4-8.3 9.8-11.4 6.1-5.7 6.1-5.7L50 57.7z"/><path class="hyst3" d="M32.7 41.7S30 49.1 24 52.2c0 0 9.4-1.1 8.7-10.5zM95.8 41.7s2.7 7.4 8.7 10.5c0 0-9.4-1.1-8.7-10.5zM78.7 55.5s-5.9-6.2-13.8-6.4h.1.1c-8 .2-13.8 6.4-13.8 6.4 6.9-4.8 12.8-4.7 13.8-4.7-.1 0 6.7-.1 13.6 4.7zM71.8 42.5s-3-4.2-7-4.3h.2c-3 .1-6.9 4.3-6.9 4.3 3.4-3.3 6.9-3.2 6.9-3.2s3.3-.1 6.8 3.2zM37.2 73.2s-4.7 2.3-8.1.9H29c-3-1.7-4.5-6.8-4.5-6.8s3 9 12.7 5.9zM92 73.2s4.7 2.3 8.1.9c4-1.7 4.6-6.8 4.6-6.8s-3 9-12.7 5.9z"/><path class="hyst3" d="M42.6 41.2c2.6-.5 6.9-.6 10.3.5 4.3 1.5.8 7 1.7 7.3.9.3 2.1-3.8 10.1-3.4 8.1.4 9 4 10.1 3.4s-1.1-10 11-7.8c0 0-12.7-3.4-12.1 5.8 0 0-7.3-5.6-17.5-.6.1 0 2.7-8.6-13.6-5.2zM86.9 41.2c.2 0 .3.1.4.1.1 0-.1-.1-.4-.1zM86.9 41.2zM39.1 28.9S28.3 42.5 26.7 47.7c-1.6 5.3-2.8 27-4.2 30.1l-5-21.4 9.2-22.3 12.4-5.2zM89.9 28.9s10.8 13.6 12.4 18.8c1.6 5.3 2.8 27 4.2 30.1l5-21.4-9.2-22.3-12.4-5.2z"/><path class="hyst7" d="M89.4 28.9s11.6 9.7 15 20.9c3.4 11.2 2 24.8 4.6 26.5 3.7 2.4 7.9-11.9 9.3-13.4 2.2-2.4 9.5-8.5 10-9.6.5-1.1-14.8-17.8-21.5-21.1-8.1-3.8-18.1-4.1-17.4-3.3z"/><path class="hyst8" d="M99.3 34.9s13.7 17.5 13.5 39.3l5.5-11.2c-.1 0-4.9-14.3-19-28.1z"/><path class="hyst7" d="M39.1 28.9s-11.6 9.7-15 20.9-2 24.8-4.6 26.5c-3.7 2.4-7.9-11.9-9.3-13.4C8 60.5.7 54.4.2 53.3-.3 52.2 15 35.5 21.7 32.2c8.1-3.8 18.1-4.1 17.4-3.3z"/><path class="hyst8" d="M29.2 34.9S15.5 52.4 15.7 74.2L10.3 63s4.8-14.3 18.9-28.1z"/><path class="hyst3" d="M21.8 74.6s1 5.4 2.6 7.1.5-1.3.5-1.3-1.7-.9-1.4-7.8-1.7 2-1.7 2zM107.1 74.6s-1 5.4-2.6 7.1-.5-1.3-.5-1.3 1.7-.9 1.4-7.8 1.7 2 1.7 2z"/><g><circle class="hyst8" cx="54.5" cy="70.5" r=".8"/><circle class="hyst8" cx="49.9" cy="75.3" r=".8"/><circle class="hyst8" cx="48.4" cy="70.5" r=".8"/></g><g><circle class="hyst8" cx="74" cy="70.5" r=".8"/><circle class="hyst8" cx="78.6" cy="75.3" r=".8"/><circle class="hyst8" cx="80.1" cy="70.5" r=".8"/></g></symbol><symbol viewBox="0 0 50 50" id="puppet" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -247)" fill="#fbc02d"><path stroke-width=".283" d="M11.559 249.467h13.587v13.587H11.559zM27.435 265.056h13.587v13.587H27.435zM11.559 281.074h13.587v13.587H11.559z"/><path stroke-width=".256" d="M16.62 251.615l18.305 18.305-3.236 3.236-18.305-18.305z"/><path stroke-width=".256" d="M37.834 271.331L19.53 289.636l-3.237-3.237 18.305-18.304z"/></g></symbol><symbol viewBox="0 0 100 99.999997" id="purescript" xmlns="http://www.w3.org/2000/svg"><path clip-path="url(#SVGID_2_)" d="M98.079 38.548L79.22 19.68l-5.087 5.088L90.447 41.09 74.134 57.41l5.087 5.087 18.858-18.86a3.59 3.59 0 0 0 1.055-2.55 3.578 3.578 0 0 0-1.055-2.54M25.483 42.794l-5.09-5.089L1.53 56.568a3.566 3.566 0 0 0-1.05 2.545c0 .961.373 1.863 1.05 2.542L20.394 80.52l5.089-5.086L9.162 59.113z" fill="#42a5f5" stroke-width="1.192"/><path clip-path="url(#SVGID_2_)" transform="matrix(1.19175 0 0 1.19175 -306.84 -629.047)" fill="#42a5f5" d="M281.841 551.736l6.461 6.037h28.379l-6.461-6.037zM288.302 566.861l-6.463 6.035h28.381l6.463-6.035zM281.838 581.982l6.464 6.035h28.381l-6.463-6.035z"/></symbol><symbol viewBox="0 0 24 24" id="python" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 7.5A2.86 2.86 0 0 1 22 10.36v3.78A2.86 2.86 0 0 1 19.14 17H12c0 .39.32.96.71.96H17v1.68a2.86 2.86 0 0 1-2.86 2.86H9.86A2.86 2.86 0 0 1 7 19.64v-3.75a2.85 2.85 0 0 1 2.86-2.85h5.25a2.85 2.85 0 0 0 2.85-2.86V7.5h1.18m-4.28 11.79c-.4 0-.72.3-.72.89 0 .59.32.71.72.71a.71.71 0 0 0 .71-.71c0-.59-.32-.89-.71-.89m-10-1.79A2.86 2.86 0 0 1 2 14.64v-3.78A2.86 2.86 0 0 1 4.86 8H12c0-.39-.32-.96-.71-.96H7V5.36A2.86 2.86 0 0 1 9.86 2.5h4.28A2.86 2.86 0 0 1 17 5.36v3.75a2.85 2.85 0 0 1-2.86 2.85H8.89a2.85 2.85 0 0 0-2.85 2.86v2.68H4.86M9.14 5.71c.4 0 .72-.3.72-.89 0-.59-.32-.71-.72-.71-.39 0-.71.12-.71.71s.32.89.71.89z"/><path d="M9.264 22.379c-.895-.24-1.581-.799-1.947-1.582-.228-.489-.237-.606-.238-2.957-.001-2.745.057-3.074.666-3.785.193-.226.568-.517.833-.648.47-.23.579-.239 3.839-.288 3.131-.048 3.386-.065 3.814-.264.626-.291 1.07-.687 1.4-1.247.27-.46.278-.522.311-2.29l.034-1.82.932.051c1.075.058 1.504.211 2.098.748.853.77.869.841.869 3.957 0 2.434-.02 2.783-.18 3.075a3.365 3.365 0 0 1-1.337 1.33l-.517.273-3.95.031-3.951.031.068.274c.037.151.164.377.282.503.209.224.262.229 2.433.229h2.22v1.05c0 1.653-.394 2.437-1.54 3.072l-.545.302-2.644.018c-1.455.01-2.782-.018-2.95-.063zm6.12-1.692c.22-.222.253-.325.206-.675-.07-.523-.278-.73-.732-.73-.467 0-.672.217-.735.78-.042.372-.012.496.163.672.3.3.77.28 1.097-.047z" fill="#fc0" stroke="#fc0" stroke-width=".102"/><path d="M9.349 22.38c-.911-.15-1.936-1.074-2.176-1.963-.073-.273-.101-1.279-.079-2.868.033-2.317.047-2.473.27-2.926.13-.263.401-.623.603-.8.674-.592.87-.63 3.484-.675 4.399-.076 4.927-.166 5.705-.967.642-.662.706-.9.774-2.883l.061-1.784.951.055c.523.031 1.11.122 1.304.204.54.225 1.358 1.042 1.472 1.47.153.572.243 3.18.16 4.617-.071 1.23-.093 1.327-.395 1.78-.193.288-.577.647-.966.903l-.647.425-3.922.008c-2.157.004-3.942.028-3.966.052-.115.115.354.82.587.883.14.038 1.181.073 2.314.079l2.06.01v.91c0 1.739-.326 2.446-1.454 3.162l-.631.4-2.543-.011c-1.398-.007-2.733-.043-2.966-.081zm5.98-1.718c.285-.256.313-.328.251-.658-.09-.483-.301-.682-.722-.682-.436 0-.625.193-.715.73-.065.384-.044.453.2.663.358.308.595.295.985-.053z" fill="#fdd835" stroke-width=".102"/><path d="M4.281 17.396c-.88-.215-1.714-.935-2.024-1.747-.149-.389-.168-.804-.142-3.041.027-2.26.054-2.638.215-2.962.259-.519.851-1.092 1.392-1.346.437-.206.632-.217 4.408-.245l3.95-.03-.067-.275a1.367 1.367 0 0 0-.282-.504c-.21-.224-.263-.23-2.433-.23h-2.22l.002-1.143c.003-1.338.157-1.795.84-2.493.746-.763 1.103-.838 4.025-.838 2.961 0 3.28.06 4.067.768.37.333.572.621.728 1.037.201.539.213.735.183 3.072-.035 2.777-.045 2.824-.78 3.598-.787.829-.76.824-4.59.883-3.812.06-3.797.057-4.61.806-.765.706-.917 1.2-.964 3.133l-.04 1.653-.677-.01c-.371-.007-.813-.045-.98-.086zM9.59 5.551c.237-.204.286-.326.286-.72 0-.547-.201-.763-.71-.763-.502 0-.765.248-.765.724 0 .492.141.782.439.902.345.14.444.12.75-.143z" fill="#3c78aa"/></symbol><symbol viewBox="0 0 24 24" id="r" xmlns="http://www.w3.org/2000/svg"><path d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1.081 1.081 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z" fill="#1976d2" stroke-width="1.035"/></symbol><symbol viewBox="0 0 24 24" id="raml" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="razor" xmlns="http://www.w3.org/2000/svg"><path d="M15.45 11.91c-.11-2.21-1.75-3.54-3.73-3.54h-.08c-2.29 0-3.55 1.8-3.55 3.84 0 2.29 1.53 3.74 3.54 3.74 2.25 0 3.72-1.65 3.83-3.59m-3.81-5.97c1.53 0 2.97.68 4.02 1.74 0-.51.33-.89.83-.89h.11c.74 0 .89.7.89.92v7.9c-.04.52.54.78.87.44 1.27-1.29 2.78-6.69-.79-9.81-3.33-2.92-7.8-2.44-10.18-.8-2.52 1.74-4.14 5.61-2.57 9.22 1.71 3.95 6.61 5.13 9.52 3.95 1.48-.59 2.15 1.4.65 2.05-2.34.99-8.77.89-11.78-4.32-2.03-3.52-1.93-9.71 3.46-12.92C10.81 1.42 16.24 2.1 19.5 5.5c3.45 3.6 3.25 10.3-.1 12.91-1.51 1.18-3.76.03-3.74-1.7l-.02-.56a5.611 5.611 0 0 1-3.99 1.66C8.63 17.81 6 15.15 6 12.13c0-3.05 2.63-5.74 5.65-5.74z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="react" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.11c1.03 0 1.87.84 1.87 1.89 0 1-.84 1.85-1.87 1.85-1.03 0-1.87-.85-1.87-1.85 0-1.05.84-1.89 1.87-1.89M7.37 20c.63.38 2.01-.2 3.6-1.7-.52-.59-1.03-1.23-1.51-1.9a22.7 22.7 0 0 1-2.4-.36c-.51 2.14-.32 3.61.31 3.96m.71-5.74l-.29-.51c-.11.29-.22.58-.29.86.27.06.57.11.88.16l-.3-.51m6.54-.76l.81-1.5-.81-1.5c-.3-.53-.62-1-.91-1.47C13.17 9 12.6 9 12 9c-.6 0-1.17 0-1.71.03-.29.47-.61.94-.91 1.47L8.57 12l.81 1.5c.3.53.62 1 .91 1.47.54.03 1.11.03 1.71.03.6 0 1.17 0 1.71-.03.29-.47.61-.94.91-1.47M12 6.78c-.19.22-.39.45-.59.72h1.18c-.2-.27-.4-.5-.59-.72m0 10.44c.19-.22.39-.45.59-.72h-1.18c.2.27.4.5.59.72M16.62 4c-.62-.38-2 .2-3.59 1.7.52.59 1.03 1.23 1.51 1.9.82.08 1.63.2 2.4.36.51-2.14.32-3.61-.32-3.96m-.7 5.74l.29.51c.11-.29.22-.58.29-.86-.27-.06-.57-.11-.88-.16l.3.51m1.45-7.05c1.47.84 1.63 3.05 1.01 5.63 2.54.75 4.37 1.99 4.37 3.68 0 1.69-1.83 2.93-4.37 3.68.62 2.58.46 4.79-1.01 5.63-1.46.84-3.45-.12-5.37-1.95-1.92 1.83-3.91 2.79-5.38 1.95-1.46-.84-1.62-3.05-1-5.63-2.54-.75-4.37-1.99-4.37-3.68 0-1.69 1.83-2.93 4.37-3.68-.62-2.58-.46-4.79 1-5.63 1.47-.84 3.46.12 5.38 1.95 1.92-1.83 3.91-2.79 5.37-1.95M17.08 12c.34.75.64 1.5.89 2.26 2.1-.63 3.28-1.53 3.28-2.26 0-.73-1.18-1.63-3.28-2.26-.25.76-.55 1.51-.89 2.26M6.92 12c-.34-.75-.64-1.5-.89-2.26-2.1.63-3.28 1.53-3.28 2.26 0 .73 1.18 1.63 3.28 2.26.25-.76.55-1.51.89-2.26m9 2.26l-.3.51c.31-.05.61-.1.88-.16-.07-.28-.18-.57-.29-.86l-.29.51m-2.89 4.04c1.59 1.5 2.97 2.08 3.59 1.7.64-.35.83-1.82.32-3.96-.77.16-1.58.28-2.4.36-.48.67-.99 1.31-1.51 1.9M8.08 9.74l.3-.51c-.31.05-.61.1-.88.16.07.28.18.57.29.86l.29-.51m2.89-4.04C9.38 4.2 8 3.62 7.37 4c-.63.35-.82 1.82-.31 3.96a22.7 22.7 0 0 1 2.4-.36c.48-.67.99-1.31 1.51-1.9z" fill="#00bcd4"/></symbol><symbol viewBox="0 0 24 24" id="readme" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="reason" xmlns="http://www.w3.org/2000/svg"><path d="M3 3v18h18V3H3zm5.119 8.993h2.798c.382 0 .71.025.985.075.275.05.534.159.774.326.244.168.435.386.577.654.145.265.218.598.218 1 0 .552-.112 1.001-.335 1.35-.22.348-.536.638-.947.87l2.16 3.203H12.31l-1.763-2.742h-.77v2.742H8.12v-7.478zm6.594 0h4.676v1.447h-3.018v1.29h2.802v1.447h-2.802v1.848h3.018v1.446h-4.676v-7.478zM9.778 13.37v2.014h.513c.266 0 .49-.014.67-.044.18-.03.329-.1.45-.207a.96.96 0 0 0 .253-.34c.055-.128.082-.297.082-.508 0-.187-.034-.35-.1-.483a.698.698 0 0 0-.343-.317 1.086 1.086 0 0 0-.395-.095 6.012 6.012 0 0 0-.526-.02h-.604z" fill="#f44336" stroke-width="1.067"/></symbol><symbol viewBox="0 0 172 193" id="restql" xmlns="http://www.w3.org/2000/svg"><title>Group</title><g transform="translate(14.767 16.713) scale(.82795)" fill="none"><path d="M171.39 55.799c-.975-6.147-4.673-11.642-10.15-14.805L96.381 3.546C93.217 1.72 89.615.756 85.964.756s-7.253.964-10.415 2.788L10.69 40.992A20.896 20.896 0 0 0 .272 59.035v74.89a20.894 20.894 0 0 0 10.416 18.042l64.859 37.446c3.165 1.827 6.767 2.791 10.417 2.791s7.252-.964 10.415-2.79l64.859-37.445c5.479-3.166 9.178-8.66 10.152-14.808zm-16.516 85.147L90.017 178.39a8.104 8.104 0 0 1-8.108 0l-64.857-37.444a8.109 8.109 0 0 1-4.053-7.021v-74.89a8.109 8.109 0 0 1 4.053-7.021l64.857-37.446c1.254-.725 2.654-1.086 4.054-1.086s2.8.361 4.054 1.086l64.857 37.446a8.106 8.106 0 0 1 4.053 7.021v74.89a8.109 8.109 0 0 1-4.053 7.021z" fill="#83e8c2"/><path d="M158.93 59.035a8.109 8.109 0 0 0-4.053-7.021L90.02 14.568c-1.254-.725-2.654-1.086-4.054-1.086s-2.8.361-4.054 1.086L17.055 52.014a8.106 8.106 0 0 0-4.053 7.021v74.89a8.109 8.109 0 0 0 4.053 7.021l64.857 37.444a8.104 8.104 0 0 0 8.108 0l64.857-37.444a8.109 8.109 0 0 0 4.053-7.021zm-46.766 31.681c.119-.069.242-.118.365-.149.044-.012.088-.01.131-.018.076-.012.152-.029.228-.029l.015.001c.02.001.038.005.059.006.093.005.184.019.273.04l.1.03c.077.025.15.057.223.095.028.014.057.027.084.043.094.057.184.122.263.199.007.008.013.017.021.024.07.071.133.15.188.235.018.029.033.059.05.09.04.072.072.148.099.229a1.512 1.512 0 0 1 .081.46v16.209l-3.278 1.893a1.548 1.548 0 0 0-.678.83 1.533 1.533 0 0 0-.098.514v3.785l-14.038 8.104-.01.004a1.55 1.55 0 0 1-.354.146c-.045.012-.09.011-.135.018-.074.012-.15.029-.225.029l-.014-.001c-.02-.001-.039-.005-.059-.006a1.463 1.463 0 0 1-.273-.041c-.034-.008-.066-.019-.1-.03a1.318 1.318 0 0 1-.223-.094c-.029-.015-.057-.027-.084-.044a1.45 1.45 0 0 1-.263-.198c-.009-.008-.015-.019-.023-.027a1.495 1.495 0 0 1-.185-.232c-.019-.029-.034-.06-.051-.09a1.422 1.422 0 0 1-.098-.229 1.702 1.702 0 0 1-.033-.101 1.487 1.487 0 0 1-.048-.358l-.001-.002v-20.053a1.446 1.446 0 0 1 .727-1.255zM85.24 31.369a1.449 1.449 0 0 1 1.452 0l45.741 26.41a1.45 1.45 0 0 1 0 2.512l-17.366 10.027a1.457 1.457 0 0 1-1.452 0l-15.49-8.943 1.727-.996a1.552 1.552 0 0 0 0-2.688l-13.111-7.57c-.239-.139-.508-.207-.775-.207s-.535.068-.775.207l-3.278 1.893-14.038-8.104a1.451 1.451 0 0 1 0-2.513zM57.59 47.558c.251 0 .501.065.726.194l15.489 8.942-1.727.997a1.552 1.552 0 0 0 0 2.688l1.727.996-15.488 8.943a1.457 1.457 0 0 1-1.452 0L39.499 60.291a1.45 1.45 0 0 1 0-2.512l17.366-10.027c.225-.129.475-.194.725-.194zm-9.56 92.328c-.241 0-.489-.062-.724-.196l-17.365-10.026a1.45 1.45 0 0 1-.726-1.256V75.59c0-.847.694-1.453 1.452-1.453.242 0 .49.062.724.197l17.366 10.025c.449.26.726.738.726 1.257v17.886l-1.727-.997a1.552 1.552 0 0 0-2.327 1.344v15.139c0 .555.295 1.067.775 1.344l3.278 1.894v16.209a1.45 1.45 0 0 1-1.452 1.451zm29.828 14.929a1.452 1.452 0 0 1-2.177 1.257l-17.365-10.026a1.452 1.452 0 0 1-.726-1.257v-17.885l1.726.996c.25.145.515.211.773.211.811 0 1.554-.648 1.554-1.555v-1.993l15.489 8.942c.449.26.726.738.726 1.257zm0-32.768c0 .127-.02.246-.049.36-.009.035-.021.067-.032.101-.026.08-.059.157-.099.229-.017.03-.032.061-.05.09a1.48 1.48 0 0 1-.188.235l-.021.025a1.51 1.51 0 0 1-.264.199c-.026.016-.055.028-.082.043a1.597 1.597 0 0 1-.324.124 1.362 1.362 0 0 1-.278.041c-.018.001-.036.006-.055.006l-.015.001c-.077 0-.155-.018-.233-.03-.043-.007-.084-.005-.125-.017a1.484 1.484 0 0 1-.366-.149l-14.035-8.104v-3.784a1.545 1.545 0 0 0-.776-1.343l-3.276-1.892V91.976c0-.127.02-.246.049-.361.009-.034.021-.066.032-.1a1.33 1.33 0 0 1 .099-.229c.017-.03.032-.062.051-.091.054-.084.116-.163.187-.234l.021-.025c.079-.076.168-.142.263-.199.027-.016.056-.029.084-.043a1.476 1.476 0 0 1 .601-.166c.019 0 .036-.005.055-.005l.015-.001c.078 0 .157.018.236.03.04.007.081.005.122.017.124.031.246.08.366.149l17.361 10.023a1.456 1.456 0 0 1 .726 1.259zm-9.984-45.373a1.448 1.448 0 0 1-.544-.55 1.466 1.466 0 0 1 0-1.413c.121-.219.303-.41.544-.55l14.038-8.104 3.277 1.892c.48.276 1.071.276 1.551 0l3.278-1.893 14.038 8.105a1.45 1.45 0 0 1 0 2.513L86.691 86.7a1.447 1.447 0 0 1-1.452 0zm74.842 51.733c0 .518-.276.997-.726 1.256l-45.741 26.409a1.452 1.452 0 0 1-2.177-1.257v-20.053c0-.519.277-.997.727-1.257l15.488-8.941v1.992c0 .906.743 1.555 1.553 1.555.26 0 .523-.066.774-.21l13.11-7.57a1.55 1.55 0 0 0 .776-1.344v-3.784l14.038-8.105a1.452 1.452 0 0 1 2.177 1.257v20.052zm0-32.764c0 .519-.276.997-.726 1.256l-15.489 8.943v-1.993c0-.906-.744-1.554-1.554-1.554a1.519 1.519 0 0 0-.773.21l-1.727.996V85.616c0-.519.277-.997.727-1.257l17.365-10.025c.234-.135.482-.197.724-.197.758 0 1.453.606 1.453 1.453z" fill="#111d5a"/><g fill="#83e8c2"><path d="M59.402 90.568zM94.485 123.06zM94.771 123.29zM77.775 122.51zM77.072 123.33zM77.418 123.09zM77.856 122.05zM76.749 123.45zM94.119 122.41zM77.131 133.51l-15.489-8.942v1.993c0 .906-.743 1.555-1.554 1.555a1.53 1.53 0 0 1-.773-.211l-1.726-.996v17.885c0 .519.276.997.726 1.257l17.365 10.026a1.452 1.452 0 0 0 2.177-1.257v-20.053a1.454 1.454 0 0 0-.726-1.257zM94.25 122.74zM110.28 111.42zM94.494 100.98c.088-.089.189-.168.303-.232l17.365-10.026-17.365 10.026a1.392 1.392 0 0 0-.303.232zM77.627 122.83zM58.027 90.936zM58.374 90.693zM59.044 90.521l-.015.001c.083-.001.167.015.251.029-.079-.012-.158-.03-.236-.03zM57.819 91.195zM58.696 90.568zM57.589 91.977zM76.043 123.46zM57.67 91.516zM75.677 123.31l-14.035-8.11zM76.401 123.5l.015-.001c-.082.001-.166-.016-.248-.029.078.012.156.03.233.03zM112.16 90.716zM77.662 101.27zM113.64 90.734zM96.237 123.31zM113.33 90.597zM112.89 90.52c-.075 0-.151.018-.228.029.081-.014.162-.029.242-.028l-.014-.001zM141.26 74.137c-.241 0-.489.062-.724.197l-17.365 10.025c-.449.26-.727.738-.727 1.257v17.885l1.727-.996c.25-.145.515-.211.773-.21.81 0 1.554.647 1.554 1.554v1.993l15.489-8.943a1.45 1.45 0 0 0 .726-1.256V75.59c0-.847-.695-1.453-1.453-1.453zM112.96 90.526zM95.523 123.5c.074 0 .15-.018.225-.029-.08.013-.159.028-.238.028l.013.001zM95.451 123.5zM85.238 86.7zM95.078 123.43zM141.26 106.9c-.241 0-.489.062-.724.196l-14.038 8.105v3.784c0 .555-.296 1.067-.776 1.344l-13.11 7.57c-.251.144-.515.21-.774.21-.81 0-1.553-.648-1.553-1.555v-1.992l-15.488 8.941c-.449.26-.727.738-.727 1.257v20.053a1.452 1.452 0 0 0 2.177 1.257l45.741-26.409a1.45 1.45 0 0 0 .726-1.256v-20.053a1.454 1.454 0 0 0-1.454-1.452zM67.871 41.396a1.451 1.451 0 0 0 0 2.513l14.038 8.104 3.278-1.893c.24-.139.508-.207.775-.207s.536.068.775.207l13.111 7.57a1.552 1.552 0 0 1 0 2.688l-1.727.996 15.49 8.943a1.457 1.457 0 0 0 1.452 0l17.366-10.027a1.45 1.45 0 0 0 0-2.512l-45.741-26.41a1.449 1.449 0 0 0-1.452 0zM39.497 57.779a1.45 1.45 0 0 0 0 2.512l17.366 10.027a1.457 1.457 0 0 0 1.452 0l15.488-8.943-1.727-.996a1.552 1.552 0 0 1 0-2.688l1.727-.997-15.489-8.942a1.458 1.458 0 0 0-1.451 0zM49.481 138.43v-16.209l-3.278-1.894a1.55 1.55 0 0 1-.775-1.344v-15.139c0-.906.743-1.555 1.554-1.554.259 0 .523.065.773.21l1.727.997V85.611a1.45 1.45 0 0 0-.726-1.257L31.39 74.33a1.436 1.436 0 0 0-.724-.197c-.758 0-1.452.606-1.452 1.453v52.817c0 .518.276.997.726 1.256l17.365 10.026a1.45 1.45 0 0 0 2.176-1.255zM114.34 108.18l-3.278 1.893 3.278-1.893V91.971zM114.11 91.193zM114.16 91.283z"/></g><g fill="#de5941"><path d="M94.494 100.98a1.45 1.45 0 0 0-.424 1.023v20.053l.001.002c0 .126.02.244.048.358.01.034.021.066.033.101.026.08.059.156.098.229.017.03.032.061.051.09.055.084.115.162.185.232.009.009.015.02.023.027.079.077.169.142.263.198.027.017.055.029.084.044a1.46 1.46 0 0 0 .596.165c.02.001.039.005.059.006.079 0 .158-.016.238-.028.045-.007.09-.006.135-.018.119-.031.238-.08.354-.146l.01-.004 14.038-8.104v-3.785c0-.18.04-.35.098-.514.122-.343.353-.643.678-.83l3.278-1.893V91.977c0-.127-.021-.246-.049-.361-.009-.033-.021-.065-.032-.099a1.266 1.266 0 0 0-.099-.229c-.017-.031-.032-.061-.05-.09a1.425 1.425 0 0 0-.188-.235l-.021-.024a1.41 1.41 0 0 0-.263-.199c-.027-.016-.056-.029-.084-.043a1.509 1.509 0 0 0-.323-.125 1.591 1.591 0 0 0-.273-.04c-.021-.001-.039-.005-.059-.006-.08-.001-.161.015-.242.028-.043.008-.087.006-.131.018-.123.031-.246.08-.365.149l-17.365 10.026a1.447 1.447 0 0 0-.302.233zM77.13 100.74L59.769 90.717a1.424 1.424 0 0 0-.366-.149c-.041-.012-.082-.01-.122-.017-.084-.015-.168-.03-.251-.029-.019 0-.036.005-.055.005-.095.005-.188.02-.278.041-.034.009-.065.02-.099.03a1.406 1.406 0 0 0-.224.095c-.028.014-.057.027-.084.043a1.515 1.515 0 0 0-.263.199l-.021.025c-.07.071-.133.15-.187.234-.019.029-.034.061-.051.091-.04.073-.072.149-.099.229a1.463 1.463 0 0 0-.081.461v16.206l3.276 1.892a1.547 1.547 0 0 1 .776 1.343v3.784l14.035 8.104c.119.068.242.117.366.149.041.012.082.01.125.017.082.014.166.03.248.029.019 0 .037-.005.055-.006.095-.004.188-.019.278-.041.034-.008.065-.019.099-.029.077-.025.152-.058.225-.095.027-.015.056-.027.082-.043.095-.058.185-.123.264-.199l.021-.025c.07-.071.133-.15.188-.235.018-.029.033-.06.05-.09.04-.072.072-.149.099-.229a1.448 1.448 0 0 0 .081-.461v-20.047a1.456 1.456 0 0 0-.726-1.259zM86.689 86.7l17.365-10.026a1.45 1.45 0 0 0 0-2.513l-14.038-8.105-3.278 1.893a1.556 1.556 0 0 1-1.551 0l-3.277-1.892-14.038 8.104c-.241.14-.423.331-.544.55a1.466 1.466 0 0 0 0 1.413c.121.218.303.41.544.55L85.238 86.7a1.447 1.447 0 0 0 1.451 0z"/></g></g></symbol><symbol viewBox="0 0 24 24" id="riot" xmlns="http://www.w3.org/2000/svg"><defs><path d="M13.26 3.04l.58.05.54.07.52.09.49.11.46.13.44.14.41.16.39.17.36.19.33.21.32.22.29.23.26.25.22.22.2.22.19.24.17.24.15.25.15.26.12.27.12.28.1.29.08.31.07.31.05.32.04.34.02.35.01.37v.05l-.02.51-.05.49-.09.48-.13.45-.15.43-.19.4-.22.39-.26.37-.28.34-.31.33-.33.3-.37.28-.39.27-.41.24-.44.22L21 21h-7.04l-3.48-5.14H9.17V21H3V3h9.01l.64.01.61.03zm-4.09 8.52h2.66l.99-.11.75-.35.47-.55.16-.74v-.05l-.17-.75-.47-.54-.74-.32-.96-.11H9.17v3.52z" id="ija"/></defs><use xlink:href="#ija" fill="#ff1744"/><use xlink:href="#ija" fill-opacity="0" stroke="#000" stroke-opacity="0"/></symbol><symbol viewBox="0 0 24 24" id="robot" xmlns="http://www.w3.org/2000/svg"><path d="M12.05 2.804a1.787 1.787 0 0 1 1.788 1.788c0 .661-.357 1.242-.893 1.546v1.135h.893a6.256 6.256 0 0 1 6.256 6.256h.894a.894.894 0 0 1 .893.893v2.681a.894.894 0 0 1-.893.894h-.894v.894a1.787 1.787 0 0 1-1.787 1.787H5.795a1.787 1.787 0 0 1-1.787-1.787v-.894h-.894a.894.894 0 0 1-.894-.894v-2.68a.894.894 0 0 1 .894-.894h.894a6.256 6.256 0 0 1 6.255-6.256h.894V6.138a1.773 1.773 0 0 1-.894-1.546 1.787 1.787 0 0 1 1.788-1.788m-4.022 9.83a2.234 2.234 0 0 0-2.234 2.235 2.234 2.234 0 0 0 2.234 2.234 2.234 2.234 0 0 0 2.234-2.234 2.234 2.234 0 0 0-2.234-2.234m8.043 0a2.234 2.234 0 0 0-2.234 2.234 2.234 2.234 0 0 0 2.234 2.234 2.234 2.234 0 0 0 2.235-2.234 2.234 2.234 0 0 0-2.235-2.234z" fill="#ff5722" stroke-width=".894"/></symbol><symbol viewBox="100 100 800 800" id="rollup" xmlns="http://www.w3.org/2000/svg"><style>.ilst0{fill:url(#ilXMLID_4_)}.ilst1{fill:url(#ilXMLID_5_)}.ilst2{fill:url(#ilXMLID_8_)}.ilst3{fill:url(#ilXMLID_9_)}.ilst4{fill:url(#ilXMLID_11_)}.ilst5{opacity:.3;fill:url(#ilXMLID_16_)}</style><g id="ilXMLID_14_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_4_" x1="444.47" x2="598.47" y1="526.05" y2="562.05" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_15_" class="ilst0" d="M721 410c0-33.6-8.8-65.1-24.3-92.4-41.1-42.3-130.5-52.1-152.7-.2-22.8 53.2 38.3 112.4 65 107.7 34-6-6-84-6-84 52 98 40 68-54 158S359 779 345 787c-.6.4-1.2.7-1.9 1h368.7c6.5 0 10.7-6.9 7.8-12.7l-96.4-190.8c-2.1-4.1-.6-9.2 3.4-11.5C683 540.6 721 479.8 721 410z" fill="url(#ilXMLID_4_)"/></g><g id="ilXMLID_2_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_5_" x1="420.38" x2="696.38" y1="475" y2="689" gradientUnits="userSpaceOnUse"><stop stop-color="#BF3338" offset="0"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_10_" class="ilst1" d="M721 410c0-33.6-8.8-65.1-24.3-92.4-41.1-42.3-130.5-52.1-152.7-.2-22.8 53.2 38.3 112.4 65 107.7 34-6-6-84-6-84 52 98 40 68-54 158S359 779 345 787c-.6.4-1.2.7-1.9 1h368.7c6.5 0 10.7-6.9 7.8-12.7l-96.4-190.8c-2.1-4.1-.6-9.2 3.4-11.5C683 540.6 721 479.8 721 410z" fill="url(#ilXMLID_5_)"/></g><linearGradient id="ilXMLID_8_" x1="429.39" x2="469.39" y1="517.16" y2="559.16" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_3_" class="ilst2" d="M329.82 813.46c15.58-8.903 122.41-220.34 227.02-320.5s117.96-66.771 60.094-175.83c0 0-221.46 310.49-301.58 464.06" fill="url(#ilXMLID_8_)" stroke-width="1.113"/><g id="ilXMLID_7_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_9_" x1="502.11" x2="490.11" y1="589.46" y2="417.46" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_12_" class="ilst3" d="M373 537c134.4-247.1 152-272 222-272 36.8 0 73.9 16.6 97.9 46.1-32.7-52.7-90.6-88-156.9-89H307.7c-4.8 0-8.7 3.9-8.7 8.7V691c13.6-35.1 36.7-85.3 74-154z" fill="url(#ilXMLID_9_)"/></g><linearGradient id="ilXMLID_11_" x1="450.12" x2="506.94" y1="514.21" y2="552.85" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FBB040" offset="0"/><stop stop-color="#FB8840" offset="1"/></linearGradient><path id="ilXMLID_6_" class="ilst4" d="M556.84 492.96c-104.61 100.16-211.44 311.6-227.02 320.5s-41.732 10.016-55.643-5.564c-14.801-16.582-37.837-43.401 86.802-272.65 149.57-274.99 169.15-302.7 247.05-302.7 40.953 0 82.24 18.473 108.95 51.302 1.447 2.337 2.893 4.785 4.34 7.233-45.738-47.074-145.23-57.98-169.93-.222-25.373 59.204 42.622 125.08 72.335 119.85 37.837-6.677-6.677-93.48-6.677-93.48 57.757 108.95 44.403 75.563-60.205 175.72z" fill="url(#ilXMLID_11_)" stroke-width="1.113"/><linearGradient id="ilXMLID_16_" x1="508.33" x2="450.33" y1="295.76" y2="933.76" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFF" offset="0"/><stop stop-color="#FFF" stop-opacity="0" offset="1"/></linearGradient><path id="ilXMLID_13_" class="ilst5" d="M373.22 547.49c149.57-274.99 169.15-302.7 247.05-302.7 33.719 0 67.661 12.575 93.48 35.277-26.708-30.492-66.326-47.519-105.72-47.519-77.9 0-97.486 27.71-247.05 302.7-124.64 229.25-101.6 256.07-86.802 272.65 2.114 2.337 4.563 4.34 7.122 6.01-13.02-18.919-18.807-62.877 91.922-266.42z" fill="url(#ilXMLID_16_)" opacity=".3" stroke-width="1.113"/></symbol><symbol viewBox="0 0 24 24" id="ruby" xmlns="http://www.w3.org/2000/svg"><path d="M16 9h3l-5 7m-4-7h4l-2 8M5 9h3l2 7m5-12h2l2 3h-3m-5-3h2l1 3h-4M7 4h2L8 7H5m1-5L2 8l10 14L22 8l-4-6H6z" fill="#f44336"/></symbol><symbol viewBox="0 0 144 144" id="rust" xmlns="http://www.w3.org/2000/svg"><path d="M68.252 26.206a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0M25.766 58.451a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m84.97.166a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m-74.661 4.88a3.252 3.252 0 0 0 1.651-4.29l-1.58-3.574h6.214v28.01H29.823a43.847 43.847 0 0 1-1.42-16.738zm25.994.688v-8.256h14.798c.764 0 5.397.883 5.397 4.347 0 2.877-3.553 3.908-6.475 3.908zm-20.203 44.452a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m52.769.166a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m1.101-8.076a3.246 3.246 0 0 0-3.856 2.498l-1.787 8.342a43.847 43.847 0 0 1-36.566-.175l-1.787-8.342a3.246 3.246 0 0 0-3.854-2.497l-7.365 1.581a43.847 43.847 0 0 1-3.808-4.488h35.834c.406 0 .676-.074.676-.443V84.527c0-.369-.27-.442-.676-.442h-10.48V76.05h11.335c1.035 0 5.532.296 6.97 6.045.45 1.768 1.44 7.519 2.116 9.36.674 2.065 3.417 6.19 6.34 6.19h18.501a43.847 43.847 0 0 1-4.06 4.7zm19.898-33.468a43.847 43.847 0 0 1 .093 7.612h-4.499c-.45 0-.631.296-.631.737v2.066c0 4.863-2.742 5.92-5.145 6.19-2.288.258-4.825-.958-5.138-2.358-1.35-7.593-3.6-9.214-7.152-12.016 4.409-2.8 8.996-6.93 8.996-12.457 0-5.97-4.092-9.729-6.881-11.572-3.914-2.58-8.246-3.096-9.415-3.096H39.336A43.847 43.847 0 0 1 63.867 28.52l5.484 5.753a3.243 3.243 0 0 0 4.59.105l6.137-5.869a43.847 43.847 0 0 1 30.017 21.38l-4.201 9.487a3.256 3.256 0 0 0 1.652 4.29zm10.477.154l-.143-1.467 4.327-4.036c.88-.82.55-2.472-.574-2.891l-5.532-2.068-.433-1.428 3.45-4.792c.704-.974.058-2.53-1.127-2.724l-5.833-.949-.7-1.31 2.45-5.38c.502-1.095-.43-2.496-1.636-2.45l-5.92.206-.935-1.135 1.36-5.766c.275-1.17-.913-2.36-2.084-2.085l-5.765 1.359-1.136-.935.207-5.92c.046-1.198-1.357-2.135-2.45-1.637l-5.379 2.452-1.31-.703-.95-5.833c-.193-1.183-1.75-1.83-2.723-1.128l-4.796 3.45-1.425-.432-2.068-5.532c-.42-1.127-2.072-1.452-2.89-.576l-4.036 4.33-1.467-.143-3.117-5.036c-.63-1.02-2.318-1.02-2.946 0l-3.117 5.036-1.467.143-4.037-4.33c-.819-.876-2.47-.551-2.89.576l-2.069 5.532-1.426.432-4.795-3.45c-.974-.703-2.53-.055-2.723 1.128l-.951 5.833-1.31.703-5.379-2.452c-1.093-.5-2.496.439-2.45 1.637l.206 5.92-1.136.935-5.765-1.36c-1.171-.272-2.36.915-2.086 2.086l1.358 5.766-.933 1.135-5.92-.206c-1.193-.035-2.134 1.355-1.637 2.45l2.453 5.38-.703 1.31-5.832.949c-1.185.192-1.827 1.75-1.128 2.724l3.45 4.792-.433 1.428-5.532 2.068c-1.123.42-1.452 2.07-.574 2.891l4.328 4.036-.143 1.467-5.035 3.116c-1.02.63-1.02 2.318 0 2.946l5.035 3.117.143 1.467-4.328 4.037c-.878.818-.549 2.468.574 2.89l5.532 2.068.433 1.428-3.45 4.793c-.701.976-.056 2.532 1.129 2.723l5.831.948.703 1.312-2.453 5.378c-.5 1.093.444 2.5 1.638 2.451l5.917-.207.935 1.136-1.358 5.768c-.275 1.168.915 2.355 2.086 2.08l5.765-1.357 1.137.932-.207 5.921c-.046 1.199 1.357 2.136 2.45 1.636l5.379-2.45 1.31.702.95 5.83c.193 1.187 1.75 1.829 2.725 1.13l4.792-3.453 1.427.435 2.069 5.53c.42 1.123 2.072 1.454 2.89.574l4.037-4.328 1.467.146 3.117 5.035c.628 1.016 2.316 1.018 2.946 0l3.117-5.035 1.467-.146 4.036 4.328c.818.88 2.47.549 2.89-.574l2.068-5.53 1.428-.435 4.793 3.453c.974.699 2.53.055 2.722-1.13l.952-5.83 1.31-.703 5.378 2.451c1.093.5 2.493-.435 2.45-1.636l-.206-5.92 1.135-.933 5.765 1.357c1.171.275 2.36-.912 2.085-2.08l-1.358-5.768.932-1.136 5.92.207c1.194.048 2.138-1.358 1.636-2.451l-2.45-5.378.7-1.312 5.833-.948c1.187-.19 1.831-1.747 1.127-2.723l-3.45-4.793.433-1.428 5.532-2.068c1.125-.422 1.454-2.072.574-2.89l-4.327-4.037.143-1.467 5.035-3.117c1.02-.628 1.021-2.315.001-2.946z" fill="#ff7043" stroke-width="1.146"/></symbol><symbol viewBox="0 0 500 500" id="sass" xmlns="http://www.w3.org/2000/svg"><path d="M422.676 96.573c-12.192-47.839-91.508-63.557-166.575-36.892-44.68 15.877-93.029 40.786-127.81 73.311-41.349 38.675-47.943 72.328-45.216 86.395 9.583 49.622 77.585 82.069 105.535 106.126v.144c-8.246 4.05-68.565 34.584-82.684 65.799-14.893 32.932 2.372 56.556 13.804 59.742 35.424 9.859 71.764-7.866 91.311-37.01 18.853-28.12 17.28-64.422 9.086-82.487 11.3-2.976 24.476-4.314 41.218-2.36 47.248 5.52 56.517 35.017 54.747 47.366-1.77 12.35-11.681 19.14-14.998 21.186-3.317 2.045-4.326 2.766-4.05 4.287.405 2.215 1.94 2.137 4.758 1.652 3.894-.656 24.804-10.042 25.709-32.828 1.14-28.933-26.587-61.302-75.684-60.45-20.216.354-32.933 2.268-42.123 5.69-.681-.774-1.363-1.547-2.084-2.307-30.35-32.382-86.46-55.285-84.088-98.824.866-15.823 6.372-57.5 107.817-108.052 83.104-41.415 149.637-30.009 161.135-4.76 16.427 36.08-35.554 103.137-121.858 112.812-32.88 3.684-50.198-9.059-54.498-13.804-4.536-4.995-5.204-5.218-6.909-4.287-2.753 1.533-1.01 5.938 0 8.574 2.583 6.712 13.15 18.603 31.176 24.515 15.863 5.205 54.459 8.063 101.156-9.99 52.283-20.255 93.12-76.523 81.125-123.548zM200.213 340.34c3.92 14.5 3.487 28.016-.564 40.248a65.289 65.289 0 0 1-3.225 7.97c-3.12 6.477-7.316 12.534-12.442 18.132-15.653 17.069-37.507 23.532-46.88 18.092-10.122-5.874-5.048-29.944 13.083-49.11 19.52-20.636 47.602-33.903 47.602-33.903l-.039-.079 2.465-1.35z" fill="#ec407a" stroke="#ec407a" stroke-width="16.286552999999998"/></symbol><symbol viewBox="0 0 300 300" id="sbt" xmlns="http://www.w3.org/2000/svg"><path d="M105.46 209.517c-7.875 0-13.452-7.521-13.452-15.37v-.327c0-7.848 5.578-13.735 13.452-13.735h164.05c1.476-4.905 2.625-11.446 3.281-17.986h-137.81c-7.875 0-14.273-6.05-14.273-13.898s6.398-13.898 14.273-13.898h137.31c-.82-6.54-1.969-13.081-3.773-17.986h-104.01c-7.875 0-14.273-6.05-14.273-13.898s6.398-13.898 14.273-13.898h91.87c-21.327-37.607-60.864-61.315-106.14-61.315-67.918 0-123.04 54.448-123.04 122.3 0 67.856 55.122 123.28 123.04 123.28 46.59 0 87.112-25.507 107.95-63.114h-152.73z" fill="#0277bd" stroke-width="1.638"/></symbol><symbol viewBox="0 0 256 256" id="scala" xmlns="http://www.w3.org/2000/svg"><path fill="#f44336" fill-rule="evenodd" stroke-width=".3" d="M59.607 50.647l149.097-21.982v49.488L59.607 100.135zM59.593 114.08L208.69 92.098v49.488L59.593 163.568zM59.587 177.358l149.097-21.982v49.488L59.587 226.846z"/><path fill="#f44336" fill-rule="evenodd" stroke-width=".3" d="M62.425 91.414l95.605 30.923-2.832 8.757-95.605-30.922zM113.084 61.13l95.604 30.922-2.832 8.757-95.605-30.922zM62.425 154.79l95.605 30.922-2.833 8.758-95.604-30.923zM113.097 124.408l95.604 30.923-2.832 8.757-95.605-30.922z"/></symbol><symbol viewBox="0 0 24 24" id="settings" xmlns="http://www.w3.org/2000/svg"><path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97 0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="shaderlab" xmlns="http://www.w3.org/2000/svg"><path d="M9.11 17H6.5l-4.91-5L6.5 7h2.61l1.31-2.26L17.21 3l1.87 6.74L17.77 12l1.31 2.26L17.21 21l-6.79-1.74L9.11 17m.14-.25l5.13 1.38L11.42 13H5.5l3.75 3.75m6.87.38L17.5 12l-1.38-5.13L13.15 12l2.97 5.13M9.25 7.25L5.5 11h5.92l2.96-5.13-5.13 1.38z" fill="#1976d2"/></symbol><symbol viewBox="0 0 24 24" id="slim" xmlns="http://www.w3.org/2000/svg"><path d="M6.959 2.5a4.605 4.605 0 0 0-4.615 4.615v9.957a4.605 4.605 0 0 0 4.615 4.615h9.957a4.605 4.605 0 0 0 4.615-4.615V7.115A4.605 4.605 0 0 0 16.916 2.5zm4.938 2.691a6.811 6.811 0 0 1 6.81 6.813H13.43L9.938 7.287l.699 4.717H5.086a6.811 6.811 0 0 1 6.81-6.813z" fill="#f57f17"/></symbol><symbol id="smarty" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.iust0{fill:#ffce00}</style><path class="iust0" d="M9.14 20.606c0 .556.398.953.954.953h3.812c.556 0 .953-.397.953-.953v-.953H9.141zM12 2.5c-3.653 0-6.671 3.018-6.671 6.671 0 2.303 1.112 4.289 2.859 5.48v2.144c0 .556.397.953.953.953h5.718c.556 0 .953-.397.953-.953V14.65c1.747-1.191 2.86-3.177 2.86-5.48 0-3.653-3.019-6.671-6.672-6.671zm2.7 10.563l-.794.555v2.224h-3.812v-2.224l-.794-.555A4.712 4.712 0 0 1 7.235 9.17 4.78 4.78 0 0 1 12 4.405a4.78 4.78 0 0 1 4.765 4.765 4.712 4.712 0 0 1-2.065 3.892z"/></symbol><symbol viewBox="0 0 200 200" id="snyk" xmlns="http://www.w3.org/2000/svg"><title>Group 2</title><g transform="translate(15.255 18.22) scale(1.8477)" fill="none" fill-rule="evenodd"><path d="M65.161 24.997c-1.656 5.974-5.255 23.587-5.255 23.587s-6.618-2.464-14.148-2.476h-.055c-.413.002-.822.012-1.23.026v41.649h6.677v.003h5.815v-.003h20.858c.111-8.177-2.036-27.066-2.036-27.066-1.088-2.279.46-7.668.46-7.668-8.869-9.092-11.086-28.051-11.086-28.051zm-3.357 43.958c5.476 0 1.381 4.64.9 5.168H52.35c.944-1.18 4.504-5.168 9.453-5.168z" fill="#607d8b" stroke-width="1.6"/><path d="M26.366 24.995s-2.217 18.961-11.087 28.053c0 0 1.548 5.391.46 7.669 0 0-2.15 18.895-2.038 27.066h19.273v.003h7.079v-.003h5.744V46.107h-.025c-7.532.013-14.151 2.478-14.151 2.478s-3.6-17.615-5.255-23.59zm3.264 43.96c4.95 0 8.51 3.987 9.452 5.168H28.73c-.479-.528-4.573-5.168.9-5.168z" fill="#90a4ae" stroke-width="1.6"/><g transform="translate(23.76 77.45) scale(1.5998)"><g transform="translate(17.526)"><path d="M7.357.06H.177v.075C.177 2.64 2.345 4.67 4.89 4.67 7.431 4.67 9.6 2.64 9.6.135V.059z" fill="#455a64"/><path d="M1.972.06v.075a2.692 2.692 0 1 0 5.386 0V.059z" fill="#fff"/><path d="M5.496.06H4.234c-.012 0-.023.005-.034.007.157.033.243.388.21.624a.721.721 0 0 1-.71.617c.102.471.487.85.997.922a1.188 1.188 0 0 0 1.35-1.007C6.112.743 5.881.06 5.495.06z" fill="#37474f"/></g><path d="M7.552.06H.372v.075c0 2.505 2.17 4.535 4.712 4.535 2.544 0 4.712-2.03 4.712-4.535V.059z" fill="#455a64"/><path d="M2.168.06v.075a2.692 2.692 0 1 0 5.385 0V.059z" fill="#fff"/><path d="M5.692.06H4.428c-.01 0-.022.005-.032.007.156.033.242.388.21.624a.72.72 0 0 1-.712.617c.104.471.488.85.999.922A1.187 1.187 0 0 0 6.24 1.223C6.308.743 6.078.06 5.69.06z" fill="#37474f"/></g><path d="M25.514-.27l-4.202 7.697C19.838 10.17 6.858 34.465 6.858 43.243v.516L12.8 59.573c-.8 7.258-2.203 21.643-1.78 28.21h5.73c-.354-3.787.648-17.008 1.903-28.25l.076-.677-1.075-2.892c3.694-3.868 6.285-9.193 8.073-14.261l.174 1.235 5.869 9.629 2.291-.983c.058-.024 5.935-2.523 11.643-2.523 5.672 0 11.646 2.5 11.702 2.525l2.29.976 5.86-9.626.23-1.608c1.769 5.117 4.358 10.536 8.07 14.49l-1.127 3.035.076.678c1.259 11.286 2.266 24.564 1.916 28.252h5.677c.406-6.567-1.05-20.952-1.848-28.208l5.838-15.817v-.514c0-8.779-12.876-33.074-14.347-35.816L65.923-.27l-5.897 41.229-2.723 4.478c-2.628-.882-7.1-2.11-11.603-2.11-4.498 0-8.94 1.225-11.557 2.108l-2.722-4.476-2.07-14.452a.832.832 0 0 0 .006-.071l-.016-.004zm-3.166 18.39l1.206 8.407c-.46 3.143-2.561 15.47-8.198 23.24l-2.598-6.99c.325-4.554 5.067-15.462 9.59-24.656zm46.763 0c4.523 9.194 9.267 20.104 9.592 24.657L76.166 49.6c-6.09-8.553-8-22.459-8.166-23.73z" fill="#607d8b" stroke-width="1.6"/></g></symbol><symbol viewBox="0 0 24 24" id="solidity" xmlns="http://www.w3.org/2000/svg"><path d="M5.8 14.05l6.253 8.61 6.252-8.61-6.254 3.807z" fill="#0288d1" stroke-width="4.553" stroke-linejoin="round"/><path d="M12.051 1.347L5.8 11.833l6.252 3.807 6.254-3.807z" fill="#0288d1" stroke-width="5.025" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 120 120" id="sonar" xmlns="http://www.w3.org/2000/svg"><style>.a,.b{fill:#fff}.b{stroke:#fff;stroke-miterlimit:10}</style><path d="M115.45 23.033S97.961 33.27 97.534 33.412c-.427.284-.852.57-1.137.854-1.422 1.421-1.848 3.41-1.422 5.26.285.852.711 1.849 1.422 2.56.711.71 1.564 1.137 2.559 1.422 1.848.426 3.84 0 5.262-1.422.426-.427.709-.853.851-1.28l.143-.427 2.56-4.692zm-39.102 9.242c-27.441 0-31.99 13.08-31.99 29.29 0 3.838.569 7.962-1.99 11.942-3.84 5.972-8.957 5.828-10.236 5.828-1.706 0-7.962-.993-8.246-2.841h.994c6.682 0 11.658-5.404 11.658-12.655v-2.56h-5.686c-4.123 0-7.82 1.849-10.238 5.12-2.417-3.271-6.113-5.12-10.236-5.12h-5.83v2.56c0 7.11 5.688 12.795 12.797 12.795h1.848c0 4.124 5.687 20.332 47.63 20.332 16.352 0 40.665-2.843 40.665-33.697 0-5.829-1.848-11.23-4.691-15.78-.996.284-1.992.568-3.13.568a8.92 8.92 0 0 1-8.956-8.957c0-.995.141-1.991.425-2.986-4.265-2.702-8.53-3.838-14.787-3.838z" fill="#1e88e5" stroke-width="1.422"/></symbol><symbol viewBox="0 0 412 395" id="stylelint" xmlns="http://www.w3.org/2000/svg"><title>stylelint-icon-white</title><g transform="translate(31.478 29.499) scale(.84775)" fill="#cfd8dc" fill-rule="evenodd"><path d="M208.8 393.05c45.057-161.12 43.75-161.85 76.32-276.73l7.832 4.523c4.255 2.458 7.738.448 7.738-4.455V61.602c8.643-30.27 15.416-53.66 17.4-60.693h35.287l58.618 54.304-38.498 33.27 29.11 31.473-191.86 273.09c-.938 1.542-2.244 1.19-1.947 0zm20.96-347.28c1.733 0 3.148.958 3.148 2.147v28.077c0 1.186-1.415 2.15-3.147 2.15h-47.396c-1.742 0-3.153-.96-3.153-2.15V47.917c0-1.185 1.41-2.147 3.153-2.147h47.396z"/><path d="M288.26 14.688l-52.14 30.1c.605.92.973 1.98.973 3.136v28.078c0 1.457-.565 2.77-1.496 3.83l52.663 30.402c3.59 2.073 6.535.377 6.535-3.764V18.456c0-4.145-2.944-5.836-6.535-3.768zM175.02 76V47.923c0-1.15.368-2.21.966-3.13l-52.14-30.105c-3.588-2.068-6.53-.376-6.53 3.768v88.013c0 4.14 2.938 5.84 6.53 3.76l52.66-30.405c-.926-1.06-1.487-2.37-1.487-3.827z"/><path d="M201.25 393.05h1.947c-45.05-161.12-43.753-161.85-76.32-276.73l-7.833 4.523c-4.253 2.458-7.737.448-7.737-4.455V61.602C102.662 31.332 95.892 7.942 93.902.909H58.619L.002 55.213l38.494 33.27-29.11 31.473z"/><circle cx="204.57" cy="122.54" r="14.231"/><circle cx="204.57" cy="207.16" r="14.231"/><circle cx="204.57" cy="291.78" r="14.23"/></g></symbol><symbol viewBox="0 0 412 395" id="stylelint_light" xmlns="http://www.w3.org/2000/svg"><title>stylelint-icon-black</title><g transform="translate(31.478 29.499) scale(.84775)" fill="#546e7a" fill-rule="evenodd"><path d="M208.8 393.05c45.057-161.12 43.75-161.85 76.32-276.73l7.832 4.523c4.255 2.458 7.738.448 7.738-4.455V61.602c8.643-30.27 15.416-53.66 17.4-60.693h35.287l58.618 54.304-38.498 33.27 29.11 31.473-191.86 273.09c-.938 1.542-2.244 1.19-1.947 0zm20.96-347.28c1.733 0 3.148.958 3.148 2.147v28.077c0 1.186-1.415 2.15-3.147 2.15h-47.396c-1.742 0-3.153-.96-3.153-2.15V47.917c0-1.185 1.41-2.147 3.153-2.147h47.396z"/><path d="M288.26 14.688l-52.14 30.1c.605.92.973 1.98.973 3.136v28.078c0 1.457-.565 2.77-1.496 3.83l52.663 30.402c3.59 2.073 6.535.377 6.535-3.764V18.456c0-4.145-2.944-5.836-6.535-3.768zM175.02 76V47.923c0-1.15.368-2.21.966-3.13l-52.14-30.105c-3.588-2.068-6.53-.376-6.53 3.768v88.013c0 4.14 2.938 5.84 6.53 3.76l52.66-30.405c-.926-1.06-1.487-2.37-1.487-3.827z"/><path d="M201.25 393.05h1.947c-45.05-161.12-43.753-161.85-76.32-276.73l-7.833 4.523c-4.253 2.458-7.737.448-7.737-4.455V61.602C102.662 31.332 95.892 7.942 93.902.909H58.619L.002 55.213l38.494 33.27-29.11 31.473z"/><circle cx="204.57" cy="122.54" r="14.231"/><circle cx="204.57" cy="207.16" r="14.231"/><circle cx="204.57" cy="291.78" r="14.23"/></g></symbol><symbol viewBox="0 0 200.00001 200.00001" id="stylus" xmlns="http://www.w3.org/2000/svg"><path d="M126.814 155.9c14.64-17.51 16.362-35.595 5.024-69.18-7.177-21.24-19.09-37.602-10.334-50.807 9.329-14.065 29.135-.43 12.63 18.371l3.301 2.297c19.806 2.296 29.566-24.83 14.783-32.58C113.179 3.621 79.02 42.803 94.09 88.156c6.458 19.232 15.5 39.613 8.18 55.83-6.314 13.923-18.514 22.103-26.695 22.39-17.079.862-5.74-38.32 13.922-48.08 1.722-.861 4.162-2.01 1.866-4.88-24.256-2.727-38.464 8.468-46.645 24.112-23.825 45.497 45.21 62.29 82.095 18.371z" fill="#c0ca33" stroke-width="1.435"/></symbol><symbol viewBox="0 0 24 24" id="swc" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="jba"><stop offset="0" stop-color="#791223"/><stop offset="1" stop-color="#d92f3c"/></linearGradient><linearGradient xlink:href="#jba" id="jbb" x1="12.356" y1="21.559" x2="12.356" y2="2.949" gradientUnits="userSpaceOnUse"/></defs><path d="M6 3c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6 3 6.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.5c0-.5-.17-.93-.46-1.27l-1.39-1.68C18.88 3.21 18.47 3 18 3H6zm-.07 1h12l.94 1H5.12l.81-1z" fill="url(#jbb)"/><path style="line-height:125%" d="M11.053 11.918h-.008c-.244.022-.475.054-.676.11a2.9 2.9 0 0 0-.856.412 3.399 3.399 0 0 0-.67.683 9.36 9.36 0 0 0-.586.95c-.07.131-.134.244-.201.365v.001h-.002l-.768 1.372-.003-.001c-.136.253-.264.485-.38.686-.123.212-.26.39-.411.539a1.599 1.599 0 0 1-.52.34c-.04.016-.092.024-.138.036h-.567v1.383H5.834v-.001c.245-.02.477-.053.679-.11a2.9 2.9 0 0 0 .856-.411c.245-.185.469-.413.67-.683.195-.275.39-.591.585-.95.07-.131.135-.244.202-.366l.004.001.002-.002.02-.038H10.948v-1.378h-.19v-.001H9.624c.125-.234.246-.452.355-.64.123-.21.259-.39.41-.538.152-.148.325-.26.52-.34.04-.015.091-.024.136-.035h.57V13.3h-.002v-1.381h-.56v-.001z" font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#fff"/></symbol><symbol viewBox="0 0 24 24" id="swift" xmlns="http://www.w3.org/2000/svg"><path d="M17.09 19.72c-2.36 1.36-5.59 1.5-8.86.1A13.807 13.807 0 0 1 2 14.5c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17z" fill="#fe5e2f"/></symbol><symbol viewBox="0 0 24 24" id="table" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5m4 7.5h-4v2h1l-2 1.67L10 13h1v-2H7v2h1l3 2.5L8 18H7v2h4v-2h-1l2-1.67L14 18h-1v2h4v-2h-1l-3-2.5 3-2.5h1v-2z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 200 200" id="terraform" xmlns="http://www.w3.org/2000/svg"><g transform="translate(177.03 -58.705) scale(.92881)" fill="#5c6bc0" stroke="#b0aff5" stroke-linejoin="round"><g stroke-width=".288"><path transform="skewY(26.439) scale(.89541 1)" d="M-203.8 170.95h64.714v51.88H-203.8zM-124.37 171.04h64.714v51.88h-64.714zM-124.37 236.09h64.714v51.88h-64.714z"/></g><path transform="skewY(-22.59) scale(-.92328 1)" stroke-width=".284" d="M-19.172 128.27h62.76v51.88h-62.76z"/></g></symbol><symbol viewBox="0 0 24 24" id="test-js" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="test-jsx" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#00bcd4"/></symbol><symbol viewBox="0 0 24 24" id="test-ts" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#0288d1"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="tex" xmlns="http://www.w3.org/2000/svg"><g font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#42a5f5" stroke-linejoin="miter"><text style="line-height:125%" x="9.914" y="364.919"><tspan x="9.914" y="364.919" font-size="287.5">T</tspan></text><text style="line-height:125%" x="136.374" y="435.558"><tspan x="136.374" y="435.558" font-size="287.5">E</tspan></text><text style="line-height:125%" x="307.819" y="361.201"><tspan x="307.819" y="361.201" font-size="287.5">X</tspan></text></g></symbol><symbol viewBox="0 0 24 24" id="todo" xmlns="http://www.w3.org/2000/svg"><path d="M3 5h6v6H3V5m2 2v2h2V7H5m6 0h10v2H11V7m0 8h10v2H11v-2m-6 5l-3.5-3.5 1.41-1.41L5 17.17l4.59-4.58L11 14l-6 6z" fill="#42a5f5"/></symbol><symbol id="travis" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><style id="jkstyle2">.jkst0{fill:#cb3349}.jkst1{fill:#f4edae}.jkst2{fill:#e6ccad}.jkst3{fill:#656c67}.jkst4{fill:#e5caa3}.jkst5{fill:#c7b39a}.jkst6{fill:#ebd599}.jkst7{fill:#2d3136}.jkst8{fill:#edf6fa}.jkst9{opacity:.8}.jkst10{opacity:.75;fill:#ebd599}</style><g id="jkg99" transform="translate(11.017 12.484) scale(.8858)"><g id="jkg10"><path class="jkst0" d="M47.781 86.572s-31.118 21.903-32.335 30.247l2.335-.48S55.045 91.64 84.584 88.628l.669-3.749z" id="jkpath4" fill="#cb3349"/><path class="jkst0" d="M96.629 83.442l-24.511 17.385 1.325 1.063c.999-.806 43.539-13.798 43.539-13.798l8.969-5.623c-6.018.749-29.322.973-29.322.973z" id="jkpath6" fill="#cb3349"/><path class="jkst0" d="M117.932 104.469c17.405 0 43.495-17.046 43.495-17.046l-8.434-1.605c-.417.417-13.6-.462-13.6-.462l-6.258-1.738-14.951 17.036-1.217 2.956c1.075-.437.965.859.965.859z" id="jkpath8" fill="#cb3349"/></g><path class="jkst0" d="M174.728 158.832l-5.377 1.514-24.843-.537-15.541-12.085-18.784 4.7-21.726-1.88-12.166 13.294-22.828 6.819-11.398-3.534-.574-.494 5.116 12.527s11.588 12.424 18.061 13.885c6.472 1.461 18.165-.105 26.935-1.463 8.769-1.357 15.764-4.489 18.582-9.603 2.818-5.117 3.236-6.578 3.236-6.578s8.353 11.797 15.556 13.155c7.203 1.357 28.605-5.952 28.605-5.952s13.051-3.549 15.346-8.038c2.297-4.489 8.353-19.209 8.353-19.209zM44.456 169.038l-.361-.166-2.013-1.736z" id="jkpath12" fill="#cb3349"/><g id="jkg97"><path class="jkst1" d="M195.832 70.085a48.125 48.125 0 0 0-.21-2.009 26.472 26.472 0 0 0-.215-1.424c-1.793-1.509-3.831-2.851-5.952-4.071-2.299-1.343-4.704-2.546-7.159-3.663-2.438-1.15-4.942-2.191-7.461-3.207a134.313 134.313 0 0 0-3.798-1.477c-1.269-.495-2.55-.956-3.835-1.424 2.697.447 5.366 1.059 8.015 1.741 1.723.446 3.437.945 5.14 1.477-12.112-31.655-41.07-52.27-72.687-52.27-31.622 0-60.577 20.615-72.686 52.27a109.044 109.044 0 0 1 5.137-1.477c2.653-.682 5.323-1.294 8.018-1.741-1.289.468-2.567.929-3.84 1.424-1.267.472-2.536.967-3.798 1.477-2.519 1.016-5.016 2.057-7.46 3.207-2.45 1.117-4.857 2.32-7.156 3.663-2.121 1.219-4.157 2.562-5.957 4.071-.075.457-.151.951-.21 1.424a51.768 51.768 0 0 0-.21 2.009 51.354 51.354 0 0 0-.177 4.061 59.216 59.216 0 0 0 .5 8.11c.37 2.692.864 5.366 1.595 7.951.36 1.295.768 2.572 1.24 3.808.237.617.495 1.225.764 1.816.134.294.274.585.413.864l.172.328c.199.101.408.204.607.3l1.204.575c.671.305 1.6.746 2.368 1.09.043-.037.086-.075.123-.114l-2.235-8.513c.474-.13 4.718-1.225 12.032-2.617a38.816 38.816 0 0 1-1.772-.381c-1.665-.414-3.309-.919-4.899-1.564a22.415 22.415 0 0 1-2.309-1.115c-.742-.426-1.472-.908-2.037-1.548 8.036 2.622 24.64 1.434 39.399-.091 13.499-1.391 27.029-2.293 40.63-2.32 13.602.027 27.137.929 40.63 2.32 14.766 1.525 31.37 2.713 39.405.091-.564.64-1.293 1.123-2.035 1.548a22.5 22.5 0 0 1-2.308 1.115c-1.592.645-3.234 1.15-4.899 1.564-.247.059-.496.113-.743.166 8.02 1.488 12.689 2.697 13.188 2.831l-2.138 8.11c.43-.194.864-.381 1.29-.574l1.202-.575c.2-.097.403-.199.607-.3l.166-.328c.146-.279.286-.57.419-.864.27-.591.528-1.199.764-1.816a42.235 42.235 0 0 0 1.241-3.808c.731-2.585 1.225-5.259 1.595-7.951.345-2.685.526-5.398.501-8.11a50.874 50.874 0 0 0-.179-4.059z" id="jkpath14" fill="#f4edae"/><path class="jkst2" d="M116.787 182.661c-1.064.16-2.128.295-3.186.375-.682.033-1.404.102-2.059.102l-.242.005c.822-1.837 1.446-3.26 1.919-4.339.963 1.08 2.188 2.417 3.568 3.857z" id="jkpath16" fill="#e6ccad"/><path class="jkst2" d="M119.101 185.018c3.304 3.272 7.398 5.146 11.904 5.479-7.569 3.074-14.702 4.26-20.197 4.63-5.478.367-11.032-.279-16.474-1.771.456-.082.79-.14 1.193-.189.447-.054 10.206-1.327 14.605-7.868l.413.009 1.08-.009c.731 0 1.395-.06 2.094-.087a43.69 43.69 0 0 0 4.878-.703c.167.171.333.338.504.509z" id="jkpath18" fill="#e6ccad"/><path class="jkst3" d="M128.464 87.071a98.82 98.82 0 0 1-1.048 1.343c-1.933 2.444-4.614 5.57-7.794 8.627a369.585 369.585 0 0 0-11.404-.177c-6.46 0-12.655.171-18.537.457 8.311-3.449 18.296-6.818 29.109-8.842a113.323 113.323 0 0 1 9.674-1.408z" id="jkpath20" fill="#656c67"/><path class="jkst3" d="M79.821 90.792c-2.966 2.084-6.317 4.744-9.566 7.971a360.155 360.155 0 0 0-21.567 2.81c9.207-4.232 19.713-8.127 31.133-10.781z" id="jkpath22" fill="#656c67"/><path class="jkst3" d="M181.48 107.969l-3.384 23.679-16.212 11.355-42.283-4.807-6.365-20.961a1.383 1.383 0 0 0-1.108-.971c-1.567-.253-2.953-.382-4.108-.382-1.16 0-2.541.129-4.115.382-.522.086-.95.461-1.106.971l-6.209 20.45-42.047 9.357-16.662-11.672-3.283-26.572c.715-.404 1.441-.806 2.176-1.209 1.031-.222 2.191-.457 3.475-.704l3.094 25.073c.048.392.264.741.586.967l11.462 8.032a1.425 1.425 0 0 0 1.101.213l34.57-7.692c.119-.027.237-.069.344-.124a1.39 1.39 0 0 0 .682-.827l6.225-20.498c1.67-.43 5.947-1.429 9.706-1.429 3.749 0 8.03.999 9.701 1.429l6.225 20.498c.161.532.624.912 1.176.977l34.57 3.927c.335.037.677-.05.952-.242l11.469-8.025c.31-.22.52-.566.573-.946l3.062-21.421c2.301.444 4.224.846 5.733 1.172z" id="jkpath24" fill="#656c67"/><path class="jkst3" d="M185.751 93.119l-2.976 11.29c-6.086-1.342-19.456-3.975-37.654-5.747 5.946-2.535 12-5.715 17.531-9.69 10.829 1.53 18.78 3.169 23.099 4.147z" id="jkpath26" fill="#656c67"/><g id="jkg32"><path class="jkst4" d="M63.841 128.441c2.357-1.274 5.021-1.085 9.19-1.079.447.011.908.005 1.39-.005.41-.005.822-.011 1.258-.022 4.296-.042 7.869.366 7.806-6.381-.065-6.746-3.062-12.198-7.354-12.155-4.297.037-8.454 5.564-8.197 12.306.07 1.756.328 3.023.742 3.937-3.745.938-4.777 3.254-4.835 3.399zm51.657-27.749a46.634 46.634 0 0 1-5.249 3.712l-6.097 3.68a52.065 52.065 0 0 0-7.331 1.467 1.216 1.216 0 0 0-.317.14 1.406 1.406 0 0 0-.629.794l-6.209 20.46-33.185 7.38-10.452-7.321-3.041-24.634c5.936-1.09 13.874-2.352 23.41-3.42a56.802 56.802 0 0 0-2.955 3.855l-5.677 8.149 8.266-5.511c.123-.086 5.387-3.549 13.998-7.761a377.407 377.407 0 0 1 35.468-.99z" id="jkpath28" fill="#e5caa3"/><path class="jkst4" d="M151.835 125.675c-.042-.16-.945-2.873-4.942-2.397.461-1.003.666-2.356.521-4.21-.528-6.731-4.443-12.08-8.735-11.931-4.292.152-7.042 5.731-6.805 12.478.236 6.741 3.84 6.694 8.132 6.543 5.77-.107 8.939-1.88 11.829-.483zm21.18-19.385l-2.992 20.944-10.539 7.379-33.141-3.766-6.183-20.363a1.41 1.41 0 0 0-.945-.934c-.205-.06-4.308-1.23-8.659-1.607l.795-.053c.687-.049 12.118-1.451 25.767-6.157 15.115 1.161 27.458 3.02 35.897 4.557z" id="jkpath30" fill="#e5caa3"/></g><g id="jkg38"><path class="jkst5" d="M63.841 128.441c2.357-1.274 5.021-1.085 9.19-1.079.447.011.908.005 1.39-.005.41-.005.822-.011 1.258-.022 4.296-.042 7.869.366 7.806-6.381-.065-6.746-3.062-12.198-7.354-12.155-4.297.037-8.454 5.564-8.197 12.306.07 1.756.328 3.023.742 3.937-3.745.938-4.777 3.254-4.835 3.399zm51.657-27.749a46.634 46.634 0 0 1-5.249 3.712l-6.097 3.68a52.065 52.065 0 0 0-7.331 1.467 1.216 1.216 0 0 0-.317.14 1.406 1.406 0 0 0-.629.794l-6.209 20.46-33.185 7.38-10.452-7.321-3.041-24.634c5.936-1.09 13.874-2.352 23.41-3.42a56.802 56.802 0 0 0-2.955 3.855l-5.677 8.149 8.266-5.511c.123-.086 5.387-3.549 13.998-7.761a377.407 377.407 0 0 1 35.468-.99z" id="jkpath34" fill="#c7b39a"/><path class="jkst5" d="M151.835 125.675c-.042-.16-.945-2.873-4.942-2.397.461-1.003.666-2.356.521-4.21-.528-6.731-4.443-12.08-8.735-11.931-4.292.152-7.042 5.731-6.805 12.478.236 6.741 3.84 6.694 8.132 6.543 5.77-.107 8.939-1.88 11.829-.483zm21.18-19.385l-2.992 20.944-10.539 7.379-33.141-3.766-6.183-20.363a1.41 1.41 0 0 0-.945-.934c-.205-.06-4.308-1.23-8.659-1.607l.795-.053c.687-.049 12.118-1.451 25.767-6.157 15.115 1.161 27.458 3.02 35.897 4.557z" id="jkpath36" fill="#c7b39a"/></g><path class="jkst2" d="M187.481 115.502c.508.419.911 1.504.456 6.558-.559 6.188-3.16 17.049-4.771 18.8-1.778.344-5.505-.064-7.778-.595.393-1.559.505-2.306.822-3.9l3.975-2.781c.317-.22.526-.566.58-.941l2.778-19.466c1.686.912 3.421 1.899 3.938 2.325z" id="jkpath40" fill="#e6ccad"/><path class="jkst2" d="M40.937 140.908c.199.704.408 1.407.624 2.1-2.139.628-6.495 1.23-8.465.886-1.633-1.645-4.679-12.966-5.345-18.978-.543-4.871-.162-5.924.333-6.334.575-.483 2.728-1.708 4.593-2.707l2.519 20.449c.048.393.257.741.586.967z" id="jkpath42" fill="#e6ccad"/><path class="jkst2" d="M121.347 141.194l-.151 1.305s-4.581 4.248-11.956 5.199c-7.375.95-13.171-3.582-13.171-3.582.242.788.586 2.567 2.256 4.086a53.184 53.184 0 0 0-6.313-.393c-.804 0-1.616.023-2.401.061-4.539.237-10.924 7.1-15.414 14.014-2.203.697-9.089 2.883-17.06 5.237-7.44-10.309-11.098-20.842-11.469-21.932l.005-.006c-.15-.419-.301-.839-.441-1.268l1.913 1.338v.005l4.726 3.309 1.58 1.101c.236.167.515.253.794.253.102 0 .204-.011.305-.031l43.435-9.67a1.385 1.385 0 0 0 1.025-.95l6.194-20.39c1.069-.145 2.008-.22 2.814-.22.801 0 1.746.075 2.815.22l6.374 20.997c.162.532.624.919 1.171.977z" id="jkpath44" fill="#e6ccad"/><path class="jkst2" d="M170.926 140.066l1.402-.984c-.232.973-.484 1.94-.747 2.896-1.949 6.248-4.25 11.774-6.805 16.656-.565.039-1.161.061-1.8.061-1.972 0-3.986-.167-6.215-.371-3.868-.355-10.007-1.058-11.946-1.283-1.67-1.332-7.385-5.873-12.14-9.615-.187-.151-.348-.291-.505-.42-.837-.708-1.789-1.513-3.717-1.513-1.751 0-4.308.638-10.489 2.508 3.212-2.401 3.233-5.5 3.233-5.5l.151-1.305 40.748 4.629a1.41 1.41 0 0 0 .955-.241l4.094-2.868z" id="jkpath46" fill="#e6ccad"/><path class="jkst6" d="M140.937 54.337c.124 3.625.033 10.194-1.655 16.345a1.335 1.335 0 0 0 0 .704 259.298 259.298 0 0 0-6.446-.591c2.412-5.054 2.938-10.436 3.052-12.332 1.852-1.317 3.696-2.896 5.049-4.126z" id="jkpath48" fill="#ebd599"/><path class="jkst6" d="M79.456 58.462c.112 1.896.638 7.267 3.046 12.317-2.149.171-4.297.37-6.441.596a1.328 1.328 0 0 0 0-.694c-1.686-6.139-1.772-12.714-1.654-16.345 1.353 1.231 3.19 2.81 5.049 4.126z" id="jkpath50" fill="#ebd599"/><path class="jkst7" d="M151.835 125.675c-2.89-1.396-6.059.377-11.828.484-4.292.151-7.896.198-8.132-6.543-.237-6.747 2.513-12.326 6.805-12.478 4.292-.15 8.207 5.2 8.735 11.931.145 1.854-.06 3.207-.521 4.21 3.996-.477 4.899 2.235 4.941 2.396zm-13.488-9.878a2.203 2.203 0 0 0 2.154-2.235 2.186 2.186 0 0 0-2.235-2.153 2.194 2.194 0 0 0 .081 4.388z" id="jkpath52" fill="#2d3136"/><circle transform="rotate(-1.049 138.093 113.428)" class="jkst8" cx="138.307" cy="113.602" id="jkellipse54" r="2.194" fill="#edf6fa"/><path class="jkst7" d="M83.484 120.953c.063 6.747-3.509 6.339-7.806 6.381-.435.011-.848.016-1.258.022-.482.011-.944.016-1.39.005-4.168-.005-6.833-.194-9.19 1.079.058-.145 1.09-2.461 4.835-3.4-.414-.914-.673-2.181-.742-3.937-.257-6.741 3.9-12.269 8.197-12.306 4.292-.042 7.289 5.411 7.354 12.156zm-6.634-3.529a2.195 2.195 0 1 0-.122-4.388 2.195 2.195 0 0 0 .122 4.388z" id="jkpath56" fill="#2d3136"/><circle transform="rotate(-1.473 76.78 115.216)" class="jkst8" cx="76.79" cy="115.23" id="jkellipse58" r="2.195" fill="#edf6fa"/><g class="jkst9" id="jkg64" opacity=".8"><path class="jkst6" d="M50.691 75.155s.667-8.692 2.03-12.023c.702-1.717 4.996-2.81 8.276-3.591 3.278-.78 8.508-2.342 9.524 2.264 1.015 4.606 2.653 7.963 3.746 9.446l-1.404-18.97-22.562 5.464-1.484 16.786.703 1.327 1.171-.703" id="jkpath60" fill="#ebd599"/><path class="jkst6" d="M164.855 75.155s-.666-8.692-2.029-12.023c-.703-1.717-4.997-2.81-8.275-3.591-3.28-.78-8.51-2.342-9.526 2.264-1.013 4.606-2.654 7.963-3.748 9.446l1.407-18.97 22.562 5.464 1.483 16.786-.703 1.327-1.171-.703" id="jkpath62" fill="#ebd599"/></g><path class="jkst10" d="M132.965 18.378s-.598 45.49-11.224 45.49h-14.875-12.752c-10.626 0-11.484-45.47-11.484-45.47l-5.22 15.438.085 21.183 3.707 2.947 1.685 9.096 2.357 5.307 45.482.084 2.105-3.791 1.769-6.4.254-4.043 5.023-14.341z" id="jkpath66" opacity=".75" fill="#ebd599"/><path class="jkst10" d="M166.429 60.794s2.187 15.692 7.974 18.522c5.788 2.829 0 0 0 0l-8.103-2.444z" id="jkpath68" opacity=".75" fill="#ebd599"/><path class="jkst10" d="M48.908 60.794s-2.187 15.692-7.975 18.522c-5.788 2.829 0 0 0 0l8.104-2.444z" id="jkpath70" opacity=".75" fill="#ebd599"/><path class="jkst7" d="M167.987 76.8c2.755.902 5.526 1.858 8.036 3.325-1.343-.532-2.729-.913-4.126-1.257a70.385 70.385 0 0 0-4.201-.924c-2.82-.531-5.65-.982-8.498-1.327-2.841-.37-5.687-.682-8.546-.924-2.858-.241-5.709-.483-8.573-.65-11.446-.704-22.924-.88-34.41-.892-11.483.006-22.962.221-34.409.897-2.862.166-5.715.409-8.572.651-2.857.241-5.71.548-8.546.923-2.847.345-5.678.796-8.498 1.327-1.407.264-2.81.57-4.206.919-1.391.344-2.783.725-4.126 1.257 2.509-1.466 5.28-2.427 8.041-3.331.232-.075.467-.139.703-.214-.015-.059-.032-.113-.043-.177-.048-.317-1.069-7.859.709-18.645.086-.516.456-.935.962-1.075l2.917-.831c.634-22.625 9.952-33.266 10.243-33.594-8.326 13.397-8.25 29.286-8.106 32.986l18.128-5.152c.016-.005.026-.005.042-.01.076-.016.151-.027.226-.032.021 0 .049-.006.075-.006a1.19 1.19 0 0 1 .297.027c.015 0 .031.011.053.016.075.016.145.042.224.075.033.016.054.033.086.049.058.033.119.07.177.112.016.011.034.016.049.033l.032.032c.016.016.037.027.054.044.012.016.494.493 1.262 1.209-.182-5.973.102-23.108 8.262-37.31-.172.498-6.646 19.428-4.415 40.645.724.58 1.486 1.149 2.229 1.649.359.247.58.655.585 1.09.006.07.161 6.833 3.148 12.586.042.086.074.177.102.268 7.429-.505 14.878-.709 22.312-.714 7.436.005 14.88.22 22.307.731.027-.097.06-.193.109-.285 2.986-5.753 3.142-12.516 3.142-12.586.01-.436.231-.843.591-1.09.741-.5 1.493-1.069 2.224-1.649 2.234-21.217-4.24-40.147-4.411-40.645 8.153 14.201 8.444 31.336 8.262 37.31a62.536 62.536 0 0 0 1.261-1.209c.016-.016.039-.027.053-.044.012-.01.018-.021.033-.032.016-.016.033-.022.049-.033.06-.042.119-.079.177-.118.028-.01.054-.027.081-.043.081-.033.155-.059.236-.08.016 0 .033-.011.049-.011.096-.021.2-.032.296-.027.027 0 .049.006.07.006.075.005.156.016.231.032.012.006.028.006.042.01l18.129 5.152c.146-3.7.221-19.59-8.104-32.986.289.328 9.609 10.969 10.237 33.594l2.922.831c.499.14.875.559.962 1.075 1.777 10.786.752 18.328.708 18.645-.01.065-.026.124-.042.182.239.07.47.139.707.215zm-3.297-.968c.14-1.207.789-7.809-.591-16.801l-20.52-5.833c.184 3.475.265 11.012-1.707 18.199a1.619 1.619 0 0 1-.101.258c.203.021.408.037.606.064 5.769.661 11.511 1.584 17.189 2.83 1.712.398 3.426.823 5.124 1.283zm-25.409-5.151c1.688-6.15 1.779-12.72 1.655-16.345-1.353 1.23-3.197 2.809-5.049 4.125-.114 1.896-.64 7.278-3.052 12.332 2.149.173 4.298.366 6.446.591a1.33 1.33 0 0 1 0-.703zm-56.78.098c-2.408-5.05-2.934-10.422-3.046-12.317-1.858-1.316-3.696-2.895-5.049-4.125-.119 3.631-.032 10.206 1.654 16.345.065.237.058.473 0 .694 2.145-.227 4.292-.425 6.441-.597zm-8.933.864a1.65 1.65 0 0 1-.098-.247c-1.975-7.187-1.889-14.723-1.712-18.199L51.244 59.03c-1.38 8.982-.736 15.583-.597 16.797 1.703-.462 3.411-.887 5.131-1.284 2.835-.628 5.693-1.154 8.556-1.638 2.869-.478 5.747-.843 8.626-1.192.205-.027.404-.042.608-.07z" id="jkpath72" fill="#2d3136"/><g id="jkXMLID_1_"><g id="jkg78"><path class="jkst7" d="M129.293 18.973v17.025h-12.068v-4.974h-2.72v22.981h4.109v12.85H97.505v-12.85h4.092v-22.98h-2.711v4.974h-12.06V18.973zm-3.626 13.408v-9.789H90.443v9.789h4.816v-4.974h9.964v30.225h-4.1v5.606h13.865v-5.606h-4.1V27.407h9.964v4.974z" id="jkpath74" fill="#2d3136"/><path class="jkst0" id="jkpolygon76" fill="#cb3349" d="M101.123 57.632h4.1V27.407h-9.964v4.974h-4.816v-9.79h35.224v9.79h-4.816v-4.974h-9.964v30.225h4.1v5.606h-13.864z"/></g></g><path class="jkst3" d="M30.694 93.119c1.759-.399 4.136-.907 7.051-1.47a104.37 104.37 0 0 0-6.222 4.597z" id="jkpath83" fill="#656c67"/><path class="jkst5" d="M95.111 139.78s.492 3.165-3.938 4.519c-4.428 1.355-32.482 9.716-35.682 9.263-3.199-.451-11.319-5.874-11.319-5.874l-1.969-7.004 12.016 7.492z" id="jkpath85" fill="#c7b39a"/><path class="jkst5" d="M120.242 139.167s-.354 3.182 4.131 4.345c4.484 1.161 32.875 8.295 36.05 7.704 3.176-.591 11.053-6.361 11.053-6.361l1.663-7.084-11.045 6.588z" id="jkpath87" fill="#c7b39a"/><path class="jkst5" d="M28.412 133.956s3.887 7.775 10.166 5.083l4.485 1.645-.448 3.29-9.419 1.195-2.541-1.494z" id="jkpath89" fill="#c7b39a"/><path class="jkst5" d="M187.551 131.822s-6.353 8.115-12.632 5.424l-2.019 1.302.448 3.289 9.419 1.196 2.54-1.495z" id="jkpath91" fill="#c7b39a"/><path class="jkst5" d="M89.279 192.904s23.03 11.611 49.106-4.188l-8.374-.571s-18.272 7.232-32.738 3.235z" id="jkpath93" fill="#c7b39a"/><path class="jkst7" d="M112.626 171.509l1.594 1.899c.036.046 3.577 4.26 7.906 8.552 2.879 2.853 6.357 4.297 10.343 4.297 1.361 0 2.791-.175 4.235-.523 1.34-.326 2.796-.673 4.287-1.03 5.384-1.287 11.482-2.749 14.438-3.577.585-.166 1.238-.315 1.925-.472 3.935-.909 9.329-2.163 12.187-7.889 2.149-4.297 5.047-9.874 7.197-13.961-1.863.859-3.816 1.79-5.203 2.52-2.138 1.123-4.938 1.667-8.558 1.667-2.152 0-4.266-.181-6.605-.389-4.675-.43-12.586-1.361-12.667-1.372l-.606-.067-.478-.383c-.071-.052-7.003-5.575-12.606-9.981-.227-.186-.434-.358-.621-.513-.59-.503-.59-.503-.942-.503-1.797 0-7.02 1.62-18.462 5.167l-.703.223-.689-.26c-.078-.026-7.585-2.81-16.581-2.81-.736 0-1.47.019-2.185.056-.901.046-5.958 2.448-12.425 12.68l-.419.657-.741.238c-.107.037-11.238 3.63-23.042 7.005l-.766.218-.725-.337c-.077-.031-4.696-2.174-9.091-4.194 2.397 3.541 5.462 7.958 8.159 11.422 4.711 6.067 10.649 11.674 22.034 11.674 1.428 0 2.945-.088 4.503-.265 11.581-1.309 14.563-1.837 16.168-2.117.543-.092.973-.171 1.522-.238.088-.011 9.571-1.237 12.232-7.206 2.744-6.134 3.298-7.595 3.319-7.651l.968-2.583s.12-.669.317-.877c0 .005 0 .005.005.005l.019.016c.305.219.757.902.757.902zM40.499 55.71c-2.516 1.014-5.016 2.06-7.46 3.209-2.449 1.119-4.856 2.32-7.155 3.66-2.121 1.222-4.157 2.563-5.954 4.076-.077.455-.149.952-.211 1.423a51.357 51.357 0 0 0-.388 6.068c-.026 2.713.16 5.426.502 8.112.372 2.692.864 5.369 1.594 7.952a41.963 41.963 0 0 0 1.243 3.804c.233.623.492 1.228.762 1.818.134.294.274.585.413.864l.172.326c.201.104.409.207.605.3l1.206.574c.673.311 1.6.751 2.366 1.093.046-.037.088-.078.124-.114l-2.231-8.511c.471-.129 4.717-1.227 12.032-2.619a33.744 33.744 0 0 1-1.775-.379 36.704 36.704 0 0 1-4.898-1.563 22.857 22.857 0 0 1-2.309-1.119c-.741-.425-1.471-.905-2.035-1.547 8.035 2.624 24.637 1.433 39.398-.088 13.501-1.393 27.028-2.293 40.628-2.325 13.6.031 27.138.931 40.63 2.325 14.77 1.522 31.374 2.713 39.406.088-.564.642-1.293 1.122-2.034 1.547-.739.42-1.522.782-2.309 1.119a36.965 36.965 0 0 1-4.903 1.563c-.244.056-.492.114-.741.166 8.02 1.486 12.689 2.697 13.186 2.832l-2.138 8.107c.43-.192.864-.377 1.288-.574l1.207-.574c.196-.094.404-.196.606-.3l.166-.326c.144-.279.284-.57.419-.864.27-.591.528-1.196.767-1.818.471-1.231.879-2.51 1.236-3.804.731-2.583 1.228-5.26 1.595-7.952.346-2.686.528-5.4.502-8.112a52.755 52.755 0 0 0-.176-4.059 51.573 51.573 0 0 0-.213-2.009 29.83 29.83 0 0 0-.213-1.423c-1.797-1.513-3.831-2.853-5.954-4.076-2.299-1.34-4.704-2.541-7.159-3.66-2.438-1.149-4.943-2.195-7.46-3.209a140.105 140.105 0 0 0-3.801-1.476c-1.267-.491-2.552-.956-3.835-1.423 2.696.445 5.369 1.06 8.013 1.739 1.724.446 3.444.948 5.141 1.481-12.11-31.658-41.07-52.272-72.685-52.272-31.622 0-60.576 20.614-72.684 52.272a107.832 107.832 0 0 1 5.135-1.481c2.651-.678 5.322-1.294 8.02-1.739-1.29.466-2.568.931-3.842 1.423-1.268.47-2.535.967-3.799 1.475zm159.43 18.316a53.972 53.972 0 0 1-.258 8.733 55.462 55.462 0 0 1-1.619 8.605c-.4 1.414-.86 2.811-1.404 4.198a38.295 38.295 0 0 1-.89 2.071c-.161.341-.331.678-.523 1.025l-.284.512a8.975 8.975 0 0 1-.348.574l-.294.457-.461.237c-.492.254-.895.445-1.342.653l-1.298.585a88.22 88.22 0 0 1-2.62 1.065c-.611.239-1.15.457-1.662.674l-1.444 5.487c-.036-.009-.471-.12-1.283-.315l-.078.574c1.594.833 4.726 2.522 5.793 3.403 2.148 1.775 2.299 4.587 1.823 9.841-.244 2.697-1.139 7.946-2.381 12.767-2.144 8.298-3.283 9.273-4.753 9.649-.746.192-1.894.383-3.008.383-2.266 0-5.353.063-7.429-.439-.533 1.888-2.055 6.812-5.068 12.962.151-.073.3-.135.435-.207 3.717-1.952 10.861-5.064 11.162-5.199l5.643-2.452-2.89 5.435c-.067.118-6.264 11.773-10.059 19.383-3.769 7.538-10.835 9.179-15.065 10.151-.637.151-1.241.291-1.733.425-3.035.854-9.18 2.319-14.599 3.623-.064.016-.13.033-.197.042a64.057 64.057 0 0 1-10.955 5.411c-14.568 5.518-29.923 5.208-43.844.092a647.05 647.05 0 0 1-9.193 1.097 45.12 45.12 0 0 1-4.985.291c-13.264 0-20.294-6.736-25.425-13.331-5.493-7.062-12.212-17.546-12.497-17.985L31 158.426l6.585 2.961c3.152 1.419 12.524 5.757 15.205 7 .217-.061.43-.124.642-.186-4.457-6.357-8.112-13.605-10.695-21.634-2.195.662-5.576 1.175-8.206 1.175-.961 0-1.822-.072-2.484-.228-1.471-.336-3.148-1.754-5.431-9.795-1.325-4.668-2.314-9.764-2.603-12.387-.57-5.121-.466-7.864 1.662-9.636 1.283-1.071 5.611-3.344 6.507-3.809l-.192-1.58c-13.75 8.08-21.991 15.22-22.157 15.366L0 134.302l7.005-11.047c5.544-8.755 11.948-15.832 17.84-21.284-.244-.098-.471-.196-.71-.294l-1.299-.585a34.907 34.907 0 0 1-1.34-.653l-.461-.237-.295-.457c-.166-.249-.238-.388-.347-.574l-.29-.512c-.181-.347-.358-.684-.518-1.025a30.878 30.878 0 0 1-.89-2.071 44.74 44.74 0 0 1-1.404-4.198 54.745 54.745 0 0 1-1.62-8.605 54.664 54.664 0 0 1-.259-8.733c.078-1.455.218-2.909.419-4.354.104-.725.213-1.45.358-2.17.15-.734.296-1.418.518-2.221l.155-.564.404-.317c2.294-1.802 4.768-3.163 7.284-4.369a78.87 78.87 0 0 1 6.311-2.616c5.943-16.493 16.162-31.118 29.591-41.311C74.337 5.57 90.664 0 107.671 0s33.334 5.57 47.218 16.106c13.43 10.193 23.649 24.819 29.588 41.307a78.282 78.282 0 0 1 6.316 2.62c2.515 1.206 4.99 2.567 7.283 4.369l.404.317.156.564c.227.803.372 1.487.517 2.221.146.72.26 1.445.357 2.17.203 1.443.348 2.897.419 4.352zm-11.995 48.031c.456-5.052.058-6.139-.455-6.554-.513-.43-2.247-1.412-3.935-2.329l-2.779 19.464a1.39 1.39 0 0 1-.58.942l-3.977 2.781c-.315 1.593-.429 2.345-.817 3.903 2.273.528 5.999.938 7.775.595 1.612-1.748 4.214-12.61 4.768-18.802zm-5.161-17.648l2.977-11.29c-4.318-.978-12.27-2.615-23.1-4.148-5.53 3.976-11.582 7.155-17.53 9.691 18.199 1.771 31.57 4.406 37.653 5.747zm-4.68 27.237l3.385-23.676a240.127 240.127 0 0 0-5.731-1.169l-3.059 21.422a1.415 1.415 0 0 1-.575.943l-11.472 8.023c-.27.192-.616.28-.947.243l-34.572-3.929a1.391 1.391 0 0 1-1.176-.973l-6.227-20.5c-1.668-.431-5.949-1.43-9.696-1.43-3.764 0-8.041.999-9.708 1.43l-6.228 20.5a1.388 1.388 0 0 1-1.025.947l-34.572 7.692a1.483 1.483 0 0 1-.306.033 1.36 1.36 0 0 1-.792-.25l-11.467-8.029a1.396 1.396 0 0 1-.585-.968l-3.091-25.072c-1.284.249-2.443.487-3.479.703-.734.405-1.46.809-2.174 1.213l3.281 26.568 16.666 11.675 42.047-9.354 6.207-20.449a1.389 1.389 0 0 1 1.108-.975c1.574-.253 2.95-.382 4.116-.382 1.153 0 2.536.129 4.105.382.528.083.957.461 1.108.975l6.366 20.956 42.282 4.808zm-8.07-4.411l2.992-20.948c-8.439-1.536-20.78-3.394-35.897-4.554-13.647 4.707-25.077 6.108-25.766 6.155l-.797.057c4.353.374 8.454 1.544 8.66 1.605.452.135.804.481.944.933l6.186 20.366 33.138 3.764zm2.303 11.845l-1.404.983-3.779 2.651-4.095 2.868c-.279.192-.621.28-.954.243l-40.746-4.633-2.966-.337a1.39 1.39 0 0 1-1.171-.977l-6.377-20.998c-1.066-.145-2.014-.219-2.81-.219-.809 0-1.751.073-2.817.219l-6.192 20.392a1.383 1.383 0 0 1-1.025.946l-43.435 9.672c-.103.02-.206.03-.305.03-.279 0-.559-.083-.798-.253l-1.578-1.098-4.726-3.307v-.011l-1.91-1.335c.135.43.289.85.441 1.268l-.006.006c.368 1.092 4.028 11.622 11.467 21.929a873.96 873.96 0 0 0 17.057-5.234c4.488-6.917 10.877-13.777 15.418-14.014a51.12 51.12 0 0 1 2.402-.061c2.221 0 4.344.16 6.31.393-1.671-1.517-2.013-3.298-2.256-4.085 0 0 5.793 4.53 13.17 3.584 7.378-.953 11.959-5.204 11.959-5.204s-.021 3.102-3.236 5.503c6.182-1.869 8.739-2.511 10.489-2.511 1.931 0 2.883.808 3.717 1.519.161.129.322.268.507.419a3519.302 3519.302 0 0 1 12.141 9.614c1.936.227 8.075.926 11.943 1.283 2.23.201 4.245.372 6.217.372.637 0 1.233-.026 1.797-.063 2.558-4.88 4.857-10.411 6.808-16.653.261-.96.516-1.928.743-2.901zm-15.034-51.593c-.01-.006-.02-.012-.031-.012a551.624 551.624 0 0 0-9.826-.651 905.6 905.6 0 0 0-13.667-.668 72.95 72.95 0 0 1-1.574 2.225c-2.479 3.355-7.398 9.51-13.704 14.729 8.926-1.6 24.409-5.56 37.803-14.905.336-.238.668-.486.999-.718zm-29.876.926c.377-.471.729-.926 1.044-1.34-3.281.331-6.512.808-9.67 1.408-10.814 2.024-20.801 5.389-29.11 8.837a383.259 383.259 0 0 1 18.54-.455c3.908 0 7.708.067 11.404.176 3.179-3.056 5.861-6.182 7.792-8.626zm3.587 102.085c-4.503-.332-8.598-2.205-11.903-5.477a271.86 271.86 0 0 0-.502-.512 44.25 44.25 0 0 1-4.881.704c-.698.026-1.361.087-2.091.087l-1.083.011-.413-.011c-4.396 6.539-14.159 7.813-14.605 7.87-.403.046-.734.103-1.191.186 5.442 1.491 10.996 2.138 16.474 1.77 5.492-.367 12.627-1.558 20.195-4.628zm-17.4-7.461a45.604 45.604 0 0 0 3.184-.378 138.958 138.958 0 0 1-3.568-3.857 398.441 398.441 0 0 1-1.92 4.339h.243c.658.001 1.378-.071 2.061-.104zm-3.354-78.632c1.827-1.103 3.582-2.366 5.249-3.712a422.33 422.33 0 0 0-7.278-.072c-10.137 0-19.606.415-28.189 1.061-8.61 4.209-13.875 7.672-13.998 7.76l-8.268 5.514 5.679-8.149a52.452 52.452 0 0 1 2.956-3.857c-9.536 1.066-17.477 2.329-23.41 3.422l3.038 24.632 10.453 7.321 33.184-7.378 6.212-20.464c.104-.337.331-.621.627-.793.098-.063.202-.109.315-.14.192-.052 3.51-.999 7.336-1.465zm3.816-18.788c-2.31-.036-4.623-.057-6.933-.062h-.005c-3.39.005-6.787.041-10.189.109l-6.269 2.971c-.005.005-.041.021-.088.048-.942.46-9.174 4.613-16.919 12.021 6.943-3.65 17.146-8.418 29.153-12.115a144.186 144.186 0 0 1 11.25-2.972zM70.251 98.761c3.251-3.225 6.605-5.886 9.567-7.967-11.415 2.651-21.923 6.543-31.128 10.778a360.846 360.846 0 0 1 21.561-2.811zm2.159-9.949a150.122 150.122 0 0 1 11.813-2.796c-5.798.212-11.6.481-17.393.808-3.366.186-6.715.414-10.065.667-1.678.129-3.345.263-5.007.445-.476.046-.942.098-1.418.16-4.369 2.614-21.127 13.134-32.631 26.889 11.179-7.769 30.654-19.443 54.701-26.173zm-30.85 54.197a68.861 68.861 0 0 1-.621-2.102l-5.162-3.612a1.391 1.391 0 0 1-.586-.969l-2.516-20.449c-1.864.999-4.017 2.225-4.592 2.707-.497.409-.875 1.46-.336 6.332.668 6.01 3.712 17.333 5.348 18.979 1.968.347 6.327-.258 8.465-.886zm-3.815-51.36a229.005 229.005 0 0 0-7.051 1.47l.829 3.127a103.93 103.93 0 0 1 6.222-4.597z" id="jkpath95" fill="#2d3136"/></g></g></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="tune" xmlns="http://www.w3.org/2000/svg"><path d="M6.85 2.852h-2v6h2v-6m12 0h-2v10h2v-10m-16 10h2v8h2v-8h2v-2h-6v2m12-6h-2v-4h-2v4h-2v2h6v-2m-4 14h2v-10h-2v10m4-6v2h2v4h2v-4h2v-2h-6z" fill="#fbc02d" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 50 50" id="twig" xmlns="http://www.w3.org/2000/svg"><path d="M9.727 47.556c-.125-.223-.297-2.168-.183-2.087.034.025.171.267.304.537.132.27.282.487.332.482.123-.011.075-1.196-.1-2.454-.331-2.398-1.176-4.435-2.358-5.69-.2-.212-.344-.4-.319-.419.093-.067 1.327.843 1.842 1.359.293.293.735.825.981 1.181.328.474.465.618.51.534.078-.147-.21-9.903-.376-12.701-.074-1.255.063-1.023.61 1.035 1.064 4.006 1.858 7.922 2.342 11.55.086.637.173 1.172.195 1.19.022.016.092.001.157-.034.888-.483 1.524-.667 2.55-.736.727-.048.945.062.35.178-1.15.222-1.99 1.013-2.344 2.201-.315 1.061-.327 2.707-.024 3.434.152.366.037.426-1.067.56-.716.088-.977.096-1.202.037-.356-.092-1.118-.098-1.195-.008-.031.036-.243.066-.47.066-.38 0-.423-.017-.535-.215zm1.974-3.233c.152-.205.072-.41-.204-.522-.225-.09-.263-.088-.437.025-.21.137-.252.43-.08.554.18.13.607.096.72-.057zm1.248.086a.763.763 0 0 0 .214-.203c.241-.33-.352-.622-.745-.366-.406.265.08.785.531.569zm2.288 3.094c-.033-.039.117-.387.334-.775.216-.387.411-.665.433-.618.07.152-.201 1.28-.33 1.372-.15.108-.354.117-.437.02zM8.2 47.092c-.29-.343-.221-.434.14-.182.176.123.321.263.321.31 0 .165-.279.087-.46-.128zm8.649-.145c0-.053.102-.18.227-.282.25-.204.312-.113.143.207-.095.18-.37.236-.37.075zm8.065-.827c-.243-.025-.48-.088-.527-.141-.11-.125-.114-3.043-.004-3.043.045 0 .132.149.193.331.127.38.228.42.31.124.094-.337.065-3.472-.039-4.297-.449-3.55-1.865-6.124-4.342-7.89-1.086-.774-2.653-1.436-4.047-1.711-.764-.15-.522-.224.598-.182 2.364.089 4.167.706 5.847 2.001a11.046 11.046 0 0 1 2.32 2.502c.453.682.64.854.64.584 0-.07.063-.882.139-1.805.679-8.26 2.396-15.1 4.984-19.86 1.86-3.422 5.108-6.817 7.885-8.244 1.397-.718 2.539-.988 4.02-.952.933.023 1.01.036 1.77.307a6.822 6.822 0 0 1 1.363.662c.612.407 1.309 1.004 1.235 1.058-.026.018-.343-.165-.705-.407-2.657-1.771-5.062-1.52-7.12.742-1.108 1.22-2.651 3.53-3.634 5.443-2.828 5.503-4.541 11.464-5.291 18.413-.163 1.509-.282 3.76-.195 3.703.032-.022.266-.52.518-1.108 1.597-3.723 3.578-6.428 5.79-7.908.672-.449 1.612-.904 1.715-.83.022.016-.172.22-.432.454-1.957 1.754-3.248 3.76-4.232 6.572-.938 2.68-1.366 5.588-1.368 9.3-.002 1.741.188 4.385.366 5.101.125.505.08.546-.585.546-.55 0-2.306.138-3.416.27-.414.05-.817.04-1.609-.036-.58-.056-1.129-.119-1.218-.14-.165-.037-.18-.014-.2.302-.01.186-.098.203-.728.139zm2.507-6.725c.294-.11.375-.22.375-.517 0-.63-1.309-.706-1.524-.088-.074.211.13.51.42.616.297.108.413.106.73-.011zm2.369-.052c.277-.222.318-.364.174-.611-.4-.691-1.755-.307-1.428.404.121.266.299.35.738.354.227 0 .387-.045.516-.147zm3.011 6.681c-.027-.05.088-.268.256-.484.879-1.135 1.22-1.544 1.284-1.544.04 0 .056.037.036.082l-.423.964c-.212.485-.445.924-.519.977-.169.122-.57.125-.634.005zm2.446-.596c0-.121.853-.683.896-.59.018.04-.056.209-.166.376-.168.259-.238.305-.464.305-.164 0-.266-.035-.266-.091zm-13.04-.124c-.177-.159-.493-.656-.462-.725.018-.038.248.1.512.309.264.207.457.405.428.438-.075.088-.371.074-.478-.022z" fill="#9bb92f" stroke-width=".078"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="typescript" xmlns="http://www.w3.org/2000/svg"><path d="M49 51h408v408H49V51zm246.669 314.879l19.463-1.702c.922 7.8 3.067 14.199 6.435 19.198 3.368 4.998 8.597 9.04 15.688 12.124 7.09 3.085 15.067 4.627 23.93 4.627 7.87 0 14.819-1.17 20.845-3.51 6.027-2.34 10.512-5.548 13.455-9.625 2.942-4.077 4.413-8.526 4.413-13.348 0-4.892-1.418-9.164-4.254-12.816-2.836-3.651-7.516-6.718-14.039-9.2-4.183-1.63-13.436-4.165-27.759-7.604s-24.355-6.683-30.099-9.732c-7.445-3.899-12.993-8.739-16.644-14.517-3.652-5.779-5.478-12.249-5.478-19.41 0-7.871 2.234-15.227 6.701-22.069 4.467-6.842 10.99-12.036 19.569-15.581 8.58-3.546 18.116-5.318 28.61-5.318 11.557 0 21.75 1.861 30.577 5.584 8.828 3.722 15.617 9.199 20.368 16.432 4.75 7.232 7.303 15.421 7.657 24.568l-19.782 1.489c-1.064-9.856-4.662-17.301-10.795-22.335-6.133-5.034-15.191-7.551-27.174-7.551-12.479 0-21.573 2.286-27.281 6.86-5.707 4.573-8.561 10.086-8.561 16.538 0 5.602 2.021 10.21 6.062 13.826 3.971 3.617 14.34 7.321 31.109 11.115 16.769 3.793 28.273 7.108 34.513 9.944 9.076 4.183 15.776 9.483 20.101 15.9 4.325 6.417 6.488 13.809 6.488 22.175 0 8.296-2.375 16.113-7.126 23.452-4.751 7.338-11.575 13.046-20.474 17.123-8.898 4.077-18.913 6.116-30.045 6.116-14.11 0-25.933-2.056-35.47-6.169-9.537-4.112-17.017-10.299-22.441-18.559-5.424-8.26-8.278-17.602-8.562-28.025zm-65.728 50.094V278.454h51.583v-18.399H157.938v18.399h51.37v137.519h20.633z" fill="#0288d1"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="typescript-def" xmlns="http://www.w3.org/2000/svg"><path d="M457 459H49V51h408v408zM69 71v368h368V71H69z" fill="#0288d1"/><text x="342.219" y="344.544" font-family="ArialMT" font-size="12" fill="#0288d1" transform="translate(-6058.94 -5838) scale(18.1514)"><tspan style="-inkscape-font-specification:sans-serif" font-family="sans-serif" font-weight="400">TS</tspan></text></symbol><symbol viewBox="0 0 24 24" id="url" xmlns="http://www.w3.org/2000/svg"><path d="M16 6h-3v1.9h3a4.1 4.1 0 0 1 4.1 4.1 4.1 4.1 0 0 1-4.1 4.1h-3V18h3a6 6 0 0 0 6-6c0-3.32-2.69-6-6-6M3.9 12A4.1 4.1 0 0 1 8 7.9h3V6H8a6 6 0 0 0-6 6 6 6 0 0 0 6 6h3v-1.9H8c-2.26 0-4.1-1.84-4.1-4.1M8 13h8v-2H8v2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="verilog" xmlns="http://www.w3.org/2000/svg"><path d="M17.282 17.08H6.718V6.513h10.564m4.226 4.226V8.627h-2.113V6.514c0-1.173-.95-2.113-2.113-2.113H15.17V2.288h-2.113v2.113h-2.112V2.288H8.83v2.113H6.718c-1.173 0-2.113.94-2.113 2.113v2.113H2.492v2.113h2.113v2.113H2.492v2.113h2.113v2.113a2.113 2.113 0 0 0 2.113 2.113H8.83v2.113h2.113v-2.113h2.112v2.113h2.113v-2.113h2.113a2.113 2.113 0 0 0 2.113-2.113v-2.113h2.113v-2.113h-2.113V10.74m-6.339 2.113h-2.112V10.74h2.112m2.113-2.113H8.831v6.34h6.338z" fill="#ff7043" stroke-width="1.056"/></symbol><symbol viewBox="0 0 24 23.999999" id="vfl" xmlns="http://www.w3.org/2000/svg"><defs><style>.jra{fill:#f05223}.jrb{fill:url(#jra)}</style><radialGradient id="jra" cx="205.45" cy="208.29" r="225.35" gradientTransform="matrix(.04556 0 0 .0456 2.888 2.88)" gradientUnits="userSpaceOnUse"><stop stop-color="#ffd104" offset="0"/><stop stop-color="#faa60e" offset=".35"/><stop stop-color="#f05023" offset="1"/></radialGradient></defs><title>houdinibadge</title><g stroke-width=".046"><path class="jra" d="M19.97 3H4.03A1.03 1.031 0 0 0 3 4.031v4.135C4.548 6.977 6.563 6.21 8.948 6.21c5.107.003 8.35 3.574 8.348 8.081 0 3.13-1.46 5.485-3.746 6.71h6.42A1.03 1.031 0 0 0 21 19.968V4.031a1.03 1.031 0 0 0-1.03-1.03z" fill="#f4511e"/><path class="jrb" d="M3 17.722v2.247A1.03 1.031 0 0 0 4.03 21h1.837C4.474 20.21 3.49 19 3 17.722z" fill="url(#jra)"/><path class="jra" d="M8.948 8.231c-2.586-.09-4.598.86-5.948 2.264v3.163c.918-2.654 3.447-3.87 5.565-3.85 2.647.027 4.689 2.025 4.7 4.284.012 2.159-.892 3.748-3.33 4.14-1.33.213-3.411-.567-3.318-2.578.046-1.037.854-1.622 1.777-1.58-.905 1.213.293 2.102 1.139 1.921 1.048-.224 1.475-1.156 1.475-1.878 0-.762-.718-1.994-2.498-1.951-2.204.052-3.591 1.639-3.638 3.602-.056 2.468 2.253 4.091 4.622 4.121 3.48.046 5.543-2.24 5.539-5.586-.005-3.029-2.434-5.946-6.085-6.072z" fill="#f05223"/></g></symbol><symbol viewBox="0 0 24 24" id="virtual" xmlns="http://www.w3.org/2000/svg"><path d="M21 14H3V4h18m0-2H3c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h7l-2 3v1h8v-1l-2-3h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 281.25 281.25" id="visualstudio" xmlns="http://www.w3.org/2000/svg"><path d="M196.18 101.74l-52.778 42.444 52.778 40.889V101.74m-136.67 110l-30-18.889v-100L62.843 81.74l47.778 37 96.666-89.222 44.444 27.778v172.22l-55.555 22.222-85.111-81.555-51.555 41.555m3.333-48.889l20.667-19.111-20.667-19.778z" fill="#ab47bc" stroke-width="11.111"/></symbol><symbol viewBox="0 0 300 300" id="vscode" xmlns="http://www.w3.org/2000/svg"><defs><style>.icon-canvas-transparent{fill:#f6f6f6;opacity:0}.icon-white{fill:#fff}</style></defs><title>BrandVisualStudioCode</title><path d="M218.62 29.953l-105.41 96.92L54.301 82.47 29.955 96.64l58.068 53.359-58.068 53.359 24.346 14.212 58.909-44.402 105.41 96.878 51.424-24.976V54.93zm0 63.744v112.6l-74.719-56.302z" fill="#2196f3" stroke-width="17.15"/></symbol><symbol viewBox="0 0 24 24" id="vue" xmlns="http://www.w3.org/2000/svg"><path d="M1.821 4.15l10.21 17.618L22.24 4.235V4.15h-7.692L12.113 8.33 9.691 4.15H1.82z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179H5.937z" fill="#35495e"/></symbol><symbol viewBox="0 0 420 419" id="watchman" xmlns="http://www.w3.org/2000/svg"><g stroke="#fff" stroke-linecap="round" stroke-linejoin="bevel"><path d="M166.95 145.32a93.935 123.23 0 0 1 92.934 3.263" fill="none" stroke-width="18.467"/><path d="M162.92 137.96L44.63 256.25a174.07 173.93 0 0 0 5.705 16.486l123.68-123.68-11.096-11.096zM266.54 144.04l-11.096 11.096 117.16 117.16a174.07 173.93 0 0 0 5.691-16.5l-111.76-111.76zm170.65 170.65v22.193l17.1 17.1 11.096-11.098-28.195-28.195z" fill="#fff" stroke-width="1.963"/><path d="M167.52 273.36a93.935 123.23 0 0 1 92.934-3.263" fill="none" stroke-width="18.467"/><path d="M49.516 144.56a174.07 173.93 0 0 0-.809 2.213 174.07 173.93 0 0 0-4.757 14.344 174.07 173.93 0 0 0-.016.055l119.56 119.56 11.098-11.096-125.07-125.07zM454.87 64.703l-17.668 17.668v22.191l28.764-28.764-11.096-11.096zm-80.984 80.984l-117.86 117.86 11.098 11.096 112.18-112.18a174.07 173.93 0 0 0-5.416-16.777z" fill="#fff" stroke-width="1.963"/></g><image x="21.229" y="20.262" width="378" height="377.1" preserveAspectRatio="none" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAaQAAAGjCAYAAABjSWGNAAAgAElEQVR4AeydB3hUVdrH/+fOpNMF JAFUmivFXtZuIBRRQUUTil1RV3et6Lr6rSu6rg3B1dXVtXeBCCioCASIimLDhkFsgAIJSAkhPZmZ 8z3vjReGMJlMueeWmfc88MzMLaf8zp3855zznvcV4MQEkoxA7sVr01PrUrv4pdZNBNA1AHQRkJ0g ZAcJrYOA7Aipv8/Q6JgUKdBkG0iZISDSDVwSaAfAY3z+/bVSAD56L+mfwA5INAqgSkrUCKA2IOQO IcQOIUU5pNwhNZQLia2Q2OyH3OJBxqaiwk4VzfLlj0wg4QmIhG8hNzDJCEgxJH9DjgfevgGBAyCx vxSyh5DoAWA/CHQH0MEFUOoBbJbAeiHEBiED6wMSv0DgF02In1J2Vq+ZP78fXcOJCSQMARakhOnK 5GrICaO3tE3P8B0kERgkgP6Qop8E+gqgL4Bdo5gEphKAwHpIrIHAjxL4DpCrAo1yVfHsHhsSuN3c tAQmwIKUwJ2bGE2TYsjY33qLQOBwCHk4JA6HhgE08kmM9ilpRQUgvwPE1xD4MiC0r2R67dfFz/eq U1IaZ8oETCLAgmQSSM7GHAIj8td38sN7HAROkpDHATgMTWs15hSQvLn4JLBaQHwqEPgQQiwvmpHz XfLi4JY7kQALkhN7JYnqlDd2w4GQnuMk5AkCOBHAQQD4ubTgGZDAFg1ieQCBj6Dhg7SKmhW8LmUB eC6iRQL8xW8RDZ8wn4AUQ8eVHib9YjCELj4nAOhqfjmcY4wE6iGxAkIsCwDvSel5v7iwa1WMefFt TCBqAixIUSPjG6IhkDtmQw/N4xkKTQ4TEkNZgKKhZ/u1DZD4CAKLIMSik/p3WzF5sgjYXiuuQMIS YEFK2K61p2GTJ0tt2Xebj5EyMArAab+vAdlTGS7VVAI0xSeAd6QQb2uBtIW8V8pUvJwZz9XzM2AG AdpoKqpTh3uEdlYA8gwBdDEjX87D0QQaALlMCLzha5Rz2NTc0X3lmsrxCMk1XeWsig6/YFOWr0Ge JoBzICWNhNo6q4ZcGwsJkFOKT4TUZiOgzSqate8aC8vmohKIAAtSAnWm6qaMHPljWl2bzBGapk2A lKMBZKguk/N3JYHPpMCrHuGbuWj6fqWubAFX2hYCLEi2YHdToVIMKSg7SQAXABgDoJObas91tZWA H5DvCeCV2rrUwg/ndqm0tTZcuOMJsCA5vovsqeDwszd19af4LwbEZQAOtKcWXGoCEaiCwAwB7emi Gd0+TqB2cVNMJMCCZCJMt2dFFnIflJQNh8BEgKbkRIrb28T1dySBlULIpzyBwCsLCntud2QNuVK2 EGBBsgW7swqlvUIer+cSQE7UPWI7q3pcm8QlUAtgtibFU4sKu70PCJm4TeWWRUKABSkSSgl5DTkt 3XSqJuVfJDAiRFyfhGw1N8qxBH4AxJP+hrpnit/otcOxteSKKSXAgqQUr/My18216wMXCOB6AH9w Xg25RklOoEoI8bzw+R5ZNKvnj0nOIumaz4KUJF0+YsyWbL/Xdx0gr5BAxyRpNjfTvQQCkPIdDdqD iwqz33NvM7jm0RBgQYqGlguvbfKmrd2MJrPtNBc2gauc5ASElJ8EIO4/eWD2m+xLL7EfBhakBO3f wfmlR2oCtwI4G4CWoM3kZiURASmwGpAPVLTPeXnFk6IxiZqeNE1lQUqwrs4bW3Y0AoF/QIjT2Vdh gnUuN8cgsFYK3LNPoPzFwsKBDcZBfnU/ARYk9/eh3oIh+WXHaELeIZs8bCdIq7gZTCAsgXUQuK9T oPw5FqawnFxzkgXJNV0VuqJD8zcdDCHvlvpG1tDX8FEmkOAE1gohJp/Yv9vLvMbk7p5mQXJp/w0Z u7mPkP7JACbwGpFLO5GrbS4BiRII/H3xzOw3eZOtuWityo0FySrSJpWTO760s8cv7wQEeVVINSlb zoYJJBAB+bGQ4uaiwpxlCdSopGgKC5JLuvm4/PUZmcJ7NSD/DqCDS6rN1WQCdhKY45f+vxYX9vzJ zkpw2ZETYEGKnJVNV1L4h9JxAuI+9jNnUxdwsW4mQFZ4//E31N/NLomc340sSA7uo2H5Gw7zC+3f AjjFwdXkqjEBNxDYKgVuPbl/9rNs+ODc7mJBcmDf5J61toMnNfVfgLgCgNeBVeQqMQF3EpD4XGja NRyTyZndx4LkqH6RYkj+pouEkFMAdHZU1bgyTCBxCEgIPFvvabx52av7lydOs9zfEhYkh/Th0HM2 95Ye//8ADHVIlbgaTCDRCWwWENcWzcyemegNdUv7WJBs7qncXOnVum66XoBMuZFpc3W4eCaQdAQE xFyfz//n4tk9NiRd4x3WYBYkGzuEjBak0J6WwJE2VoOLZgJMANgJIW49qX+3J9jowb7HgQXJBva0 pyhLeO+QkJPYaMGGDuAimUDLBJYJgSuKZuR81/IlfEYVARYkVWRbyHfouLJcBPCUhOzbwiV8mAkw AXsJ1EOKe3Z07HYvh7mwtiNYkCzinXvx2nRvTdq9EriOw0JYBJ2LYQLxEfgCUlywuDB7VXzZ8N2R EmBBipRUHNcNGVt6hJB4CcCAOLLhW5kAE7CeQB1tqF0yI/thdtiqHj4LkkLG+fnSUy7KbpWQ/wBE isKiOGsmwASUEpBLPBouXji9+3qlxSR55ixIih6A3DEbemhe7WV2+6MIMGfLBKwnsA3AxMUzc96w vujkKJEFSUE/D87fOFoT4lkA+yjInrNkAkzAVgLyv/7MhknFz/eqs7UaCVg4C5KJnTpy5I9p9W3b TBGQf2HDBRPBclZMwHkEVnr8KFg4K2e186rm3hqxIJnUd0PGlO4vPHgdAkeZlCVnwwSYgLMJVEHI yxfP6D7d2dV0T+1YkEzoq6H5ZadLIV/gKToTYHIWTMBlBIQQj6bsrLpp/vx+9S6ruuOqy4IUR5fo VnRa2Z1S4jaeoosDJN/KBNxP4DMhcW5RYc6v7m+KfS1gQYqRvR6zKCXtVQiMjDELvo0JMIEEIiCB LR4p8hcVZr+XQM2ytCmapaUlSGF5+WUDvKnpn7EYJUiHcjOYgAkEBNAlIAKLho7deI0J2SVlFjxC irLbh+SXni0EXgTQJspb+XImwASShICAeC6lsuoqXleKrsNZkKLglVdQeiuAf/F6URTQ+FImkLwE lnkatXMWzun2W/IiiK7lLEgR8KL9RQ1tsyia60URXM6XMAEmwAQMAmsDHjFq6WvZJcYBfm2ZAAtS y2z0M7njSzt7/JgD4MRWLuXTTIAJMIFQBCqkkAVLZnRfGOokH9tNgI0adrPY611u/vq+Xr9YzmK0 Fxo+wASYQOQE2gsp3h4ytnRi5Lck55UsSC30+5D8smM8wvMhB9JrARAfZgJMIBoCXiHxVN7YjXcC kmemWiDHYEKAyRu76QzIALkDyQpxmg8xASbABGImQBZ4vt+6XVFcLHwxZ5KgN7IgNevYvPyNl0EI MmDwNDvFH5kAE2AC5hCQmJ9Zh3PnzcupMSfDxMiFp+yC+nFIQdlNEOIpFqMgKPyWCTAB8wkIjKxJ xyLy+GJ+5u7NkUdIet9JkVdQRvuLaJ8RJybABJiAVQS+9kvvqcWFXTdZVaCTy0l6QZo8WWrLVpU9 JoE/ObmjuG5MgAkkJgEB8ZNPw7Di6dnrErOFkbcqqQWJvHVvF2UU2fXCyJHxlUyACTAB0wn86pf+ vOLCnj+ZnrOLMkzaNaTcXOndLja9zGLkoqeVq8oEEpfAfh7hfW/4OaUHJW4TW29ZUo6Q8vNLUreh 42tCYEzriPgKJsAEmIBlBDZDiiGLC7NXWVaigwpKOkEiMSoXnQol5GgH9QNXhQkwASagE6C4SprU 8ooKu61MNiRJNWVH03Q0MmIxSrbHnNvLBNxDgOIqSREoGjq2tL97am1OTZNmhEQGDNtE6asCosAc dJwLE2ACTEAlAVkGIXMXz+jxg8pSnJR3UoyQfreme57FyEmPHteFCTCB8ARENqS2mJw8h78ucc4m gSBJUS7KHgVwfuJ0G7eECTCBJCHQwyM8i3LHbOiRDO1NeEEaMrbsHt70mgyPMreRCSQsgQM8Hu3d kfllXRK2hb83LKEFaUjBxluExN8SvRO5fUyACSQ4AYGBjQjMG5q/vX0itzRhBSlvbOmVAuLeRO48 bhsTYALJQ0AK8UcpamfT1pVEbXVCCtLg/I2jIfEYgKSxIkzUB5TbxQSYQDABMWS76Phsogb5SzhB Gjxu0x81IV7jEBLBDzG/ZwJMIIEInJc3tuyhBGrPrqYklCCReaQIBOYByNzVQn7DBJgAE0g0AhLX Dc0vTbj18YSZ0iILlEaBjyRk0tjsJ9p3jNvDBJhAVAQkpBy/uLD7jKjucvDFCTFCokW+Bsg3WIwc /KRx1ZgAEzCbgIAQz9IyhdkZ25VfAgiSFPoin8DxdkHkcpkAE2ACNhHI1AKBuYmycdb1gjS0oOz/ AJxn08PAxTIBJsAE7CbQ1ePV3kmEPUquFqS8/LJzJXCX3U8Dl88EmAATsJnAwVLUv+R2c3CPzRBj Ln5o/qaDIeRbABJ2k1jMcPhGJsAEkpHAH3oPrNTWlkxb6tbGu9LKLu/sDfsgRfsEQB+3gud6MwEm wAQUEJBC4NyiGTmzFeStPEvXTdlRKAmkaLTxlcVI+ePBBTABJuAyAkJKvKDPILms4lRd1wlSudj0 LwDDXMiaq8wEmAATsIJAG6kFZrnRyMFVgkQ+6iTkX63oUS6DCTABJuBaAhL9Aqhznc871wjS0HM2 99aEeIEdprr2K8IVZwJMwEICQmBMXsGmGy0sMu6iXGHUkHvx2nRPddpHEDg87hZzBkyACTCBpCEg G4UUQ4oKc5a5ocmuGCF5a9IeYjFyw+PEdWQCTMBZBESKFGKGbpnsrIqFrI3jBWno2NIxHII8ZN/x QSbABJhABARkDlI8z7lh06yjBWnImNL9IfF0BMT5EibABJgAE2iRgByVl192bYunHXLCsWtItN9o O8reBztNdcijwtVgAkzA5QTqhSaPK5re/UuntsOxI6RyUXo7i5FTHxuuFxNgAi4kkBaQ4tVRo0od G8DUkYI0JL/sGAlBXrw5MQEmwASYgEkEhMRBtZnifpOyMz0bx03ZDb9gU5a/PvAFgANNby1nyASY ABNgAlIKeeqSGd0XOg2F40ZI/nr/AyxGTntMuD5MgAkkEAEhpHh2RP76Tk5rk6MEacjYjcMBcZXT IHF9mAATYAIJRqC7X3gedVqbHDNld8LoLW3T0xu/BbCf0yBxfZgAE2ACiUhASoxZUpgzxyltc8wI KT29cQqLkVMeC64HE2ACyUBACPnYiRN+6eiUtjpCkIbll50C4AqnQOF6MAEmwASSg4DITvN7pzml rbZP2ZFNfE0GvuGAe055JLgeTIAJJB0BiZGLC3Petbvdto+QajPEP1iM7H4MuHwmwASSmoDA407Y MGurIOWNX3+IRMBV8TqS+qHlxjMBJpCoBA6oyRB32N042wRp8mSpwe99AhApdkPg8pkAE2ACTEDe OHjshkPt5GCbIC0r2XQlII+zs/FcNhNgAkyACewi4NWkeEIfLOw6ZO0bWwRpZH5ZFynkPdY2lUtj AkyACTCB8ATEse9/V3Zp+GvUnbVFkBqaxKiDumZxzkyACTABJhATAYl7cs9aa8vfZ8sFadi40qMA 2KbAMXUQ38QEmAATSBICAuiipabfZUdzLRYkKQIB+R8AFpdrB1oukwkwASbgTgIC8uph+RsOs7r2 lgrDkPxNFwHiWKsbyeUxASbABJhAVAQ8AWgPRXWHCRdb5qnhd48MPwDobkK9OQsmwASYABNQTEAI eVbRjO5vKi5mV/aWjZBq0sVNLEa7uPMbJsAEmIDjCUgpHjjyCmnZXlFLBGnEmC3ZEPJmx9PnCjIB JsAEmEAwgQM7lJddHXxA5XtLBMmX0vhPAG1UNoTzZgJMgAkwAQUEBG63KkSFckEafk7pQZC4SAEm zpIJMAEmwATUE9gn1Z/yV/XFWGB+7feARkdeKxrDZTABJsAEmID5BITENXnjN+9rfs575qh0hPT7 Jthz9iySPzEBJsAEmIDLCGSJQODvquusVJACAZC/OstMy1XD4vyZABNgAslKQEp5xfD8Tb1Utl+Z IA0pKD0ZwDCVlee8mQATYAJMwDICqX7NTwFVlSVlgiQgbQ/2pIwaZ8wEmAATSEYCUpw/ZOzmPqqa rkSQ8saVngSIIaoqzfkyASbABJiALQS8Aj5la0lKBAkBOdkWVFwoE2ACTIAJqCUgxflDz9ncW0Uh pgvS0PzSE3l0pKKrOE8mwASYgCMIeOFRY3FnuiBB4FZHIONKMAEmwASYgBICEvK84eM29jQ7c1MF KW/8+kMkMNLsSnJ+TIAJMAEm4CgCqb6AuMHsGpkqSPB7yL0E7zsyu5c4PybABJiAwwgI4PIR+es7 mVkt0wQpd1zZAQDGmlk5zosJMAEmwAQcS6CNT3j/bGbtTBMkLYAb2WedmV3DeTEBJsAEnE5AXntc /voMs2ppitPT3LPWdhCQl5hVKc6HCdhNwOsVaN9WQ1amQFaGQEaGhox0gdQUAaEBaali19x0Q6PU j1OdG30S/gDQ0CDh9wPVtQFUVUlUVgdQXRNATa20u2lcPhMwk0DnLOE5H8BTZmRqiiBpqekTAcnx jszoEc7DUgIkLL3396JvrxT03T8FPXK8yNnXgy77eJTUo7pG4tdSH9Zv9OGXDT78vK4Rq39uRFV1 QEl5nCkTUE1AAtcD8mlAxP1rK24DhNxc6fV0LfsZwH6qG875M4F4CXTqoOHQAWk4dGAqBv0hFQf0 NOU3WbzV0gXqu58asXJ1A75YWY/NW/xx58kZMAGrCAjg1KKZOQviLS/ub6O3a9mZksUo3n7g+xUR SEkROKR/Kv54eJr+v3t23I+8kpr27O4F/R9+StN0/MZNPnzxTQM++7oeK76pR31D3D8+ldSbM2UC REBKXAUgbkGKe4SUl1+6FAK53C1MwCkEaK3n2CPSMfiEdBx/VLpTqhVXPZZ9WocPP6vDxyvq9fWo uDLjm5mA+QQCwu/pVzRr3zXxZB2XIOXllw2AkCXxVIDvZQJmEKCR0PFHpWHw8Rk48ZjEEKGWuJAw LXq/Fp98WY/GRh45tcSJj1tLQEDcXzQz+2/xlBqfIBVsfBQQptqhx9MYvjf5CPQ5IAUjB2fgrFOz kq/xAN4qqsE7i2vww5rGpGw/N9pRBLZ2kuXdCwsHNsRaq5gFadSo0syaDGwE0CHWwvk+JhALAbKM yzsxA6OGZ6Jfr5RYski4e0iQ5i2qwdIPa1FXz6OmhOtglzRISHleUWH3V2OtbsyCNLRg46US4plY C+b7mEC0BPbt4sGoYZkYdybvMAjHbtbb1Zi7qAYby3zhLuNzTMB8AhIfLC7MoWjhMaWYBElKeeCM udULXp1TeQDtq+DEBFQSGHRQKkYPy8SQE03bEK6yuo7J+73ldZi7sBpfr4p5BsUxbeGKuIOApkE+ PLnTjQMOSv93LDWOWpBIjAB8T4XNW1iDp1/bCRalWNDzPa0ROO7IdH1a7pjD0lq7lM+HIfDNqgZ9 xFT8UW2Yq/gUE4iPAE2fP35fZyOTvwsh/mV8iPQ1FkG6F8AuSwoWpUhR83WREsg9PkMfER0yIDXS W/i6CAjQxtt5C6t1Cz3JExsREONLIiXQe78UPDlllxgZt3mFEFHt8I5KkKSU5E9lr4lpFiWDP7/G Q+CUY2lElIXDBrIQxcMxknsfeqoCbxfVRHIpX8MEwhJoQYzontOEEPPD3tzsZLSCdCqAkAWQKP3v 5Z1s4dMMMH9snQBNyZHFHE3RcbKOwKofGnTLPNrTxIkJxEIgjBhRdjOFEFGFJIpWkF4EcEFLFSdR eviZipZO83EmsAeBA3unYPTwTJw6OHOP4/zBWgLkmohMxskbBCcmECkB8gP59INdWru8vRBiZ2sX GecjFiQpJf3VqDZubOmVRaklMnzcINCxvaabbp9zuns3s1IYCWMdJjNDoLau6XNKCnaFojDa65ZX GimRVd53P/ImW7f0mV31pC0Y40a30Wc2WqnDJUKI51u5ZtfpaASJhl7Td90Z5g2LUhg4SXxKCGD8 WW1w6bi2jqZAISHWl/qwcZMf5OR0yzY/tu8IoKIygIqdgV1CFK4RXg/QsYMHnTtq6NTRg25dPdi/ hxf75XhBZuxOTrSPqfCtamzdHtV6tJObxHUzkUAUYkSlLhJCDI+0+GgE6U0AoyPNmEUpUlLJcR15 Vrj1Guc59aA/urRP59vVDbr7nTW/+pT7hyNh7pHtxYADU3BQ31TdiKNnjvO8kD8/sxKvzK6KSICT 4ynmVkYpRgawbCHEJuNDuNeIBElK2RHA9nAZhTrHohSKSnIdoz+8Z52a6Shfcx9/UY9PvqjDFysb 9BGQE3qka2cPjjg4DUcflgayNnRSuu/RHSj6gA0fnNQndtQlRjGiql4jhHg0kjpHKkgUnvzZSDJs fg2LUnMiyfP5vDFtcMlYZ0zP0R/UpR/V4qtvGxwfW4g8lx91SBpOOCYNp+Y6w+CDnLjSd/nnX3h9 KXm+wbtb2r6thosL2kayZrT7pt3vioUQg3d/bPldpIL0NtmUt5xN+DMsSuH5JNpZp3hYIKuxhe/V 6kHu3BqmgcSJggtS4D4nxHZ66fUqvPh6JU/jJdqXNkx7sjIFJo5vF6sYGTnvK4T4zfjQ0murgiSl bA9gR0sZRHqcRSlSUu69zuMBLhvXDgWj7bOeW7fBh/lLavQpJjJASKTUrq2GYSdl4KqL2tnerH8+ VI73PmYzcds7QnEFTBIjquWVQognW6tuJII0HkDM7sSDK8CiFEwjsd4POzkDt/zZPqMFGg3NXViD L1bWJxbYEK0ho4hDB6TqXi3sXG+a/U41XplTpVsehqgmH3I5ARPFiEgsFEKMaA1JJII0E0B+axlF ep5FKVJS7riOwoVfPLYtzjnNnlHRzHnVmLeoGmWbk9NEOWdfjy5M+WfYw5+e0keeqdB/DLjjieVa RkKA9tZdPiHuabrmRXUQQoT1nNCqIAUCcqYQ5gkS1ZBFqXk/ufOznaOiFwsrMXt+DaqqE2taLtYn gf6AnD0yyzYjkjcX1GD2/GqOwRRrBzrsvusuax/vmlGoFh0phPgi1AnjWKuCNOutquvHnJ71kHGD Wa8sSmaRtD4fWiuiX0/n2vCr/IXCSsycW+14Sznre6WpRK9XYMxpmbjiPHvWmZ54aSdef6tVhy52 4eFyIyCgSIyoZE0IEdbPPHnvDpu2+f5y7uqfGo/PO8nc4Gh/6JOCju09+OTLxJ/zDwvYZSfJ0uvZ aV0w4EBrvQ2QEP3ffeX63iF/cs7ORfSkBAJAyfeN+tpOba3EkYdYG0vqqEPT0KG9B6Wb/dhZyaPX iDrNQRepEqM/3bK14sWnMqesWnVnfILUa9Ckh0s3+Tuv/qkRLEoOenJsqArtKbpuIhldWpdmzK3C P6aU47Ov6sFCFDl38rNX8kMjXn2jGo0+icMHWSdM9GPzrFOzUF0r2S9e5F1m+5WqxOiav28lLyjp dahatGbV1F/DNTTsCGnI2M19hJR3UQbk14tFKRzKxD3X94AUfVOclc5Q58yvxgOPV2Dph3U8PRfH o0UjppXfNWDGvGpoAjjYQj96Rx+ahvbtPPh5nU93PhtHM/hWxQRUidH1d2zDqh9+30wtsGltydQl 4ZoSVpB6DbxxrADOMDIgUfplgw+nHMfTdwaTRH+lX7p33dwR/XqnWNLU5Svq8PgLO/HGuzU85WMi cRpdfvltAxYva9o71L+fNVOuB/VNAVkA/rYtgJ/WsZcHE7vUtKyuubQ9Ro8w3yMIiRH5iAxKaWtL pj4d9Hmvt2GNGobkl84SAmOa30V7H26/gdzbmZvY0MFcnvHmRhswrTTnfvQ5EiJnLoiTFdthA9NA 01H7dfcip5sHbTI1tMnSdMwNjVIfBZBn8A2lPtAG3a9K6rH2170CLMfbLabcT/uYRg/PwinHWec3 j4wdyOiBk3MIkBidaY0YUaP9XunvuqCwZ4t+UVsUpPx86dkuyrYCCLnbUZUokfnof54Na6runN5M 0JrQegMFzjvpj9b8saJ1ohdmVoH+qDsppaUKDD4hA8NPzsAhA2IbUWwvD+CDT+vwzmJn+oEbNSzT 0nXBjz6v04MB0pogJ3sJqBKjG+7YhpV7jox2N1SK/MWF2a/vPrDnuxYFKS9/w3EQ2kd7Xr7nJxal PXkkwieaXrnyAmtMht9bTt4VqvXwD05iRzvUzz29DS44t42p1fr863p9Ayn9UXZS0jToa4QTzja3 veHa+NyMptAW4a7hc+oIqBKj2+7djk/D/dgQeHLxjJwrW2pZi2tIvQbedJEQGNLSjXSc1pNUrCnR vDMthn7KJuHh8Jt+jqboLjjXGu/cT76yU18r2rzFOTbcNCKiP8r33baP7prHbMA53bz6iOsPfVJR XRvAxjJntJ0s8mh9icJy0B6zvr3UrxfSKJy+4zSlSdF3OVlH4M8Xt1MSDqZVMWpqYoe1JVMfaam1 LY6QhuaXLpACEUX645FSS3jdcZz2FNHUDXleUJ30Hf3vVDsmDhG1lzaTjh1tvZcDGimR/z0aOTkp 0ZoC/YK2Kt37nx1YvIzjLVnBm8SIPHqYnSIUI71YKf09lhT23BiqDiFHSLm50ivaVP0XQEQT5zxS CoXWHcdGDc/EnZM6os/+6n8VP/x0BV6aVYXKKmdsmKTRwLgz22DaHftYuk/HeDIoSuzQkzJ0I4kd lQE4ZbT4/c+NmPVONVJThCUboGmt0uMR+KpkD4ssAxO/mkTACWJETdEgVqxZNW1lqGaFFKRex151 tJDy6lA3tHSMRaklMs49ftn4tpg4Qf16EVnO0eZWChXuhETesmmt7N93ddajtNpdpwN6pmBEbia6 dfVie7kfW7fbL9iNjdBHbr+W+nGyBRFsDx63vWsAACAASURBVOmfqntuIevE6hqewjP7mVQlRpOn lmP5iihH+EJsXVsy9a1QbQwpSH37T5oAgWGhbgh3jEUpHB3nnOvYXsOV57eDFRtdyWKSgrrV1tn/ R4aEiPZVPfqvziAXN05LfQ5IwWl5mejU0YMt2wIor7BfmNat9+mjWlpfG/SHiCZMYsZKJvW0zWB9 qR9ULidzCKgSI4qJ9cEnMRnoZK0tmfpYqNaFXEPKKyibC8hRoW6I5BivKUVCyZ5rjjksDffc2kl5 4bQ2MnNuFTY5wGiBhIj+0N9wuXXrImYApvW2eQur9T1NZuQXbx4nHJ2OO28yf/9hqHqRN/cXX68K dYqPRUHgTxeocYIcb4DGVCm6zi/M3tK8KSFHSL0GTnpYADGvfPFIqTlmZ3ymxcxbrwm5rczUCj79 WiWeea0SVQ6YeqHQ3/+7vwsorLrbElmbjh6RpW++JWG321np+tKm0VJ6qoaBikdLhw5MA0XI/ea7 BvicYYzotscHThUjAtkoRPG6kqk/Noe6lyANz9/USwp5W/MLo/3MohQtMbXXTxzfFpeOU2vSvWRZ LR5+Zifo1e405MQMkH8uFRZFVreN3PzQVGN6uoYNZfavsaxYWY9fNvpwyrFqrTIP6puqm+GvXN2I Tb+xKkXz3CkTo3+bE7peCPnz2pJpxc3btJcgHXDwDacC4tzmF8bymUUpFmrm3pOWJnD1Re2Vxy4i bwsPP70Tv2219w8HucK59rL2utFC1857Pd7mwrU4NxqV0BoLWb+tXe9DXb1963L03SaHrRlpAqr9 4tEot6IyALL+49Q6AVViNOVxMs+Pac0oRKVFw9qSqS81P7HXN7b3oJsmAji2+YWxfmZRipVc/PfR lM+F57bF6UPNd5wYXLsHn6jQg+YFH7P6PcVposXb8We1Qbcuez3WVldHaXmDDkpFwag2ujB9v6YR ZBFnRyKHrZ99XY/ynQEce4TaKdE/Hp6u7xejDbycWiYwcUJbFIw23+MGidGCYlNnPjpflP/gA8XF e8ZH2suoIa9g43JAmCZIBjo2dDBIWPOad2KG8vUicoY7Y16VrdMpZC1HfvdIkJI1vTK7Cq+9UWXr iClnXw/yz2ijIuz1Ht1Khh5Pv7rTEVabe1TMAR9IjGhfndlJgRjpVfT40X/hrJzVwfXd46ckOVSt FVX/BmD6LkkeKQVjV/ueHkrVgfTojyB5bq6qtmfaiJydXj6haR8VbTBN5kR7eMjlEfXEdz82gmIg WZ0qq6Ue/ZnqQF7RVSUa9Xfr6sGOioDt08Oq2hhLvm4TI72NQi5vvkF2D0HKOfjKgwBcHwuQSO5h UYqEUnzXkPHChflqjRfuf2yHvpM/vprGdnf/vim4dFw7XH1RO9CGUjsTTR9R8Luff/FhQ5kfASn1 zZ121YmE4PwxbdDogx5M0w5h+mZVA35c68OQE9QZPPTaLwWnDs7E9h0BikRqF27HlKtKjMizyjtL TJ2m24OZEFi7pmTaouCDe0zZ5Y0tPQ8SLwdfoOL9iNwM3HyV+ebHyR66QtUGOOMZoCm619+2xw9d v14p+nTQaUPUrocZbW3p1fDYTYEEySlp89Q2S9MNSM4bY/7USfOyWvtMDmxnvV1tS+j3lBShj2DH nBbz7pHWmqeff3lWFZ6fWRnRtYl4kSoxeuSZCt3PokpmUorFSwqzhwaXsccIqffASRcBOD74AhXv KaTx5q1+0EY7M1Oyegnvso8Hl09ohzNPVfflnzm3Gv99YaflfugO6OnVR3yTrmwPEiW7Erk9euqV nfr+KtqP01KimE7kk418wdEIhabT7EpHHpKGC85pq6+30FSelYnaTgYPZAlI9VCVaOqW9iuFDXmg qnCb8724oK0+VWt2NawQI6qzEGi/tmTqA8H133OEVFBKw6c9FCv4YrPf80gpfqL0B2/a5H3izyhM DnZEcqV1IfJArvoXdphm66dKvm/QfynG6o2a3DSRqfa4s+wfMVE/vrmgOuTIrjUO8Zy3IuAjjd4L 36pC6WZ7tx3Ewymae0mMzj/H/GfKKjEy2iok9i8qzPl112fjDb3mFWwsBUR28DHV71WJ0pz51Xjs +cQOl2yFJd3N/9ymx8pR/RwY+dOUF5luF4xWN9ozymrt9YH/7sDC98yZQ9+3iwdnjshCwSj72/XQ UxV4u6imteabep6CAF51oZrQB8EVjSYMQvB9bnqfKGKkM5cYubgw512D/64puxMn/NLRG/DcbZyw 6lXV9B1t1mvbRkOihkomx6g3XKHONxv5orvlX9vx68aWp6fMfkbox8kjd3dW7pamtXrTH2zyTk7P plmJPFiv+KYeRR/U6lN5FIPKrkRulAb8IVWfygs3/Whm/Wi9jb6LZHBxxMHqpvDyTsrAth0B/Jig xg6qxOh/L+3EnPnW/kjRny8hvlpbMnW58aztEqQDD/rrURC41Dhh5SuLUnS06aGk0BGqEnldePyF naD1EKsSmS3/5RJ1AhtJO2hK6//u367UcovMo8kwYulHTUYRqr0ctNTunH29GHx8Bsj4wMrNpt+u brJKpLJVJRJcenLJ4i+REhnKXKTAgpbEqPCtaptQiQ1rS6bONQrfJUi9Dp40EsAZxgmrX1mUIiNO bkFUrkfQw0lB9KxMNNq74jz1cZlaahO1+dZ7t+um0i1dY/ZxcpRKI4YPP6unxV0c2Nseg42DD0rV nbeSAYJViUZlbxXVIC1NA4WcUJHIBD4zQ8Pn31jXLhXtMPIkMbpkrPk/Qu0VI2qdrF9bMu0Zo527 BKn3oEkXmOkyyCggmlcWpfC0/nJJO6WL/HdOK8f8peasmYRvye6zfQ9IsSykwe5Sm96RR3ISom+/ t9YCLbgeFPPo4y/qdXGiUOoUE8nqRKO01FSBL1ZaN6Kg+FiffFkP8rWoKs4STYt2bO9ByQ+Nlo72 ze6/xBUjIiXarS2Zep/BbJcg9Rk46RoA/YwTdr2yKO1Nnhb6aUGYFsVVJLJQuu/RHVi52ro/SEY7 aPqxn8Wjg+dmkBCV61M6ofYSGXWz8pWixH74WR2+WtUACoZn9aZf8o9nbFy3st0kghSm5OjD1Kwr 0QisXRtND3hIG2ndllSJ0bPTKzFjrl3TdHv0QlrvQ/76xNpvH9Qrs0uQeg2c9A8Anfe41KYPLEq7 we/X3YsJZ6nzETb7nWrQ2okdsXbItFvFBund9PZ890JhJf7vvnJ9zcQOLwZ71ib0p81b/Hj/kzpQ yIWMdIH9e1jnFql9O49pVoWhWxf6KO2RIiexZDWqItEPnjOGZeplbCxzj1m4KjGiH2SvzrF2Wj5c v2oy8Maakmnr6RpdkJp82FVOBcQugQqXgRXnWJSAwwel4rF7OiubZ6fQ4k+/at8ud/rCDein3tqM /O7d/sB2fP51gy1eC2L5vlD8n/eW12HVj43IzBCwwl8f+Yhb9UOjLXt5SCjeXlyL9FSh7Hknwftt WwA/rbNvijbSZyH/jCxMnGD+uiqJEX0fHJWE9t6akqlfU510Aeox8KoDpMCNjqokoJvdqvDo4AaT 8NzjM3D3X9WFGievC9PftPfBJFdH7dtqyh676W9UYfK0cny8ot62EA3xNo42epJF3k/rfPo2hpxu akdM6ekCxcvNinkTXeuNdSUKRKgqIi15hSfvEbSu5NREYnTlBUkiRmTWAJSsLZm6lPpDf7r98PcF 9nDa4Ji+MmJwmD21Y0QSdeLmWfJQoNJb9z8p6qNNf3SMB8vrgbJf/TPnVYOmIrdud8/0jMGlpdeP Pq8D/acfKqOHZYJc5qhIJx6Trlv92bm29uTLO1G+w6/kjzIxu+L8droFnhN94CWbGFF/aEAf41nW BUlqYn9h3ZYTo+yIX5NJlFQ9kAbsa2/fhlU/WG+8YJRvvHbsYP7sMDkSJQ8dm7YkjhAZvIzX4o9q Qf8piuqo4Vkg7+dmp306emwXc9oXQ/14x40dzW6enh+53UlPE3oIFSUFxJCpqu8+OaB13DRdEB8p sb/xUf+r0HvQjWcC4hTjoBNfk2FNidZUVMwbU3+SJR25VdlQZp73gXieE/pjkD/KHF9cNBp64L8V IH9zZLGVDIlCXsxfUqOviZgdnHDRB7Uod4BFGnkJ+fDzemiK9mmRWTgZcnz6pf17lc4ckYmrLzZ/ Y7grvKELYG3J1Ifoe6uPkITUekp9b7Ozv8qJPFJS5RKEetSJfv0aTJjCp82VJLQ//2JCZs5+9Fus 3btLa7CguAan5WXihsvN/4PWYsEWnfh5XaPuZd7nB+iPttmJ8iTBe+H1Sj3on9n5R5IfRTy+5lLz +84VYtQEKCc3V3qLi4VPHyEdMOCma4RA70jg2X1NIo6UKKYJuc5RkWio/uQr9lnStdQm2ogZT7hl cm1EFkO0sZQTdN9t5GFjZ6XEMYfHt6eHhN4JIySjX/1+6KMYVcYOtFcpI03Tpwgrdlr7PJEYXXtZ UosRdbOW0rby6Z+/nbZTN3ESQvY0Ot8NrzRSomiGZicydCDLLyuT7groTDVi9NQrlfofbSvbw2XZ R4AMEd54txpTHt9hXyUUlkzGDuRdQ0UaNTwTz0ztAnKlZFVSJUZkPetEg41wXGUA+9F5w+a2R7iL nXhu3qIaUOwOs5OVokTid+4Zarwv/PupCpCTVE5MIJEIvPZGFR58wvzvvcHooTv3gdlrckbewa8q xcjOvYXBbYzmfUBqetgjbdSoUpqYNX9yNpraxHgthUhwqyjRnLFheh5j81u87Z8PlevOK1u8gE8w ARcToHUzitOlKt11c0cMO1mN1wiqM4VZUTFNRyMjN4oRMZEIdKNXrTrTY2lAPrMfIpWiRNNpZidy B3PdZe2VLNBSXW+6axve+9iejY1ms+L8mEBLBChkxgXX/qYbtbR0TTzHb/lzB9AoxuykKiCpm8WI GAsNXejVK4Xs7OQ9SJE8ECRKlMz+1WFMpz3xkjmRZ7MyBSaObwearzY7kbXZ7Hersd7CgHpmt4Hz YwLRECjb7Mdjz1cgINVY4NHfE/JcMdMkJ6QsRmF6V4ocOuvVAgFXj5CMJjpdlLp29mD8mWqcpJIY Pf3aTlBUUk5MIF4CNbXWWprFU18yB//PsxXw+SQorpbZieJ0kfd18vsYT1IlRq+/Ve3aabpmPHXH 3l4BdEmUP2NOFaVuXTwYO1qNGJFVFXnr5sQEzCIg3aNHu5r8+Is7dR91tLnc7ERRWj2aiNlyjdw9 me36jNpIYmTW7I3ZzKLPT+xL93ghRQe4fc4uqPVOE6Xe+6XgySlqonok1gMZ1In8lgnEQID2pdXU Slx+nvmRVcnVkNeLqEcjpxybjr9f1yGG1oS/JfG++1KHpEnR9CZ88911lkRJhfUdrSlFY+jQr5c6 MaJFzMT5deSu54tr61wCtNVh2pNqzMJpI3c0338So9tvMN8XX+KJkf486aA0IPEEiZpntyhRBM7H 71MzMiKXIG4173TunzKuWaIQeGdxDf4xpVxJcyL9UapKjMgNWIL+EG0aIQkI8yVcyaMQfaZ2iRIF 1vv3nftEX+EI7qBpCbftwo6gWXwJEzCVAIXquO4favYqkSiF8+hysqKRkRN9UprYaem5F69NJ08N 5jtSMrGW8WZltSgdc1gaptyuRoz+99JOR7uRj7ev+H4mYCaBku8bcOmNW5TsVaJN7aEcotL3/x8K pukSXIyaur06q4NXAubv/jTzqTIhLxIlSqr3KdHDeM+taqK8PvxMhZIvlgl4OQsm4FgCFMLi+cIm /3dm7/8zPIXTd5OSqu9/UogRgFQEMryQSHdosFhTH3LVovTFynplYnT/Yzuw6P1aU3lwZkwgWQiQ B+9HnlWzgdYQueUr6pR8/5NFjOhZDABtvBCIz1e9i55qlaJkeHUwG8fkqeVY9im7AjKbK+eXXATI EzptoA0EpOk+JEmUDGEyk+qbC2rw2PPJs8fQ70EmBehL6DWk5g+IKlFqXo4ZnynC66df2R/N0oy2 cB5MwAkE6A88xVdS9QPSrDaSGJGAJlMSgUAaCVLSjJCMznWDKN04eRu++a7BqDK/MgEmYBIBMpuu b5BQ4dXBjComoxgRN02KtmRll24GRLflQaLk1OHw9XewGLnteeL6uosAbZ+g/XxOS8kqRkY/0Agp aRMtGHo9wJUKwkzECvWKm7diza+Nsd7O9zEBJhAhAdrP1+iTuGSs+a6GIqzCHpeRk+Rkm6YLBiAR aJPUgkQwCt+q1pnYLUr0ML65sBrr1vuC+4jfMwEmoJDAK7Or0NAgbf9RSt9/w3xcYXMdnbUAPCRI zvh5YCMqu0WJHsbpc6uweYvfRgpcNBNITgL0/acwFuG8L6gkw2K0my4JEq0jJX2yS5ToYXx5dhW2 lbMYJf1DyABsI0DT936/NH3zfGsNYjHak1DST9kF47BalOhh5MB6wT3A75mAfQTI0Ims71TELgrV Khajvanw6KgZExIl8hmnOrEYqSbM+TOB6AksKK7FPx9S4yk8uDb0/f/fy+r/zgSX6Yb3JEgujA+p Fq1qUWIxUtt/nDsTiIfAex/XKRUl4/tfV58osbrjob3nvSRITZ4H9zye1J+yMgVy9lU3m0luRkYO yUxqxtx4JuBkAocMUOcvgL7/+WeYH2rdyTwjrZu6v7qR1sBh15EYTRzfTolvquCmUuRJEWR2Hnwu Gd5npFPrOTEB5xG47rL2yr//FBKdEsc2293/EvCzIO3mAavEyCiS9j4JAcyc17QXyjieDK8eXr1M hm52VRszMwQun6D+x6gBhUXJINH0KqBV0Z8FdiUNWC5GRldccX47x/rUMurIr0wg0Ql0bK9ZKkYG TxKliwuSfiuogQM0Qkp6d9JWj4x20f/9DbkuSfEKHr43BxPmcy0vCIehw6eiIdCtiwdjR7dRPk3X Up14pNREJiBkJQlScvk4b/ZU0DDdijWjZsXu9ZEeSq8XePpVtjHZC06IA7SJkRMTiJdA7/1S8OSU zvFmE/f99P0nv3rkyihZk9S0eg0yuUdIVs4Zt/agjTuzDSZO4OF7a5z4PBMwg0D/fs4QI6MtNFPi 1JAYRh1Vvnr8qNEgkncNyQprmmg7kESJLPA4MQG7CGRkJL4F5CEDUvGfu+0fGTXv42QWJQ2o0gSQ lNuFnShGxsNJ0SztcvRo1IFf3UugPs64jhr9VUjgdPxR6Zh2xz6ObWGyilIDtNqkXENyshgZ35Kz R2aB/jAkc3wUgwW/RkegsZHX11oiNuzkDNzy5w4tnXbMcRIlSkm1ppRVvUOTkOodNzmmmwFVYvT6 73GVzGzqmSMy9fqmpyX2L1YzmXFeTKAlAqOHZyoTI4pAa3YiUco/I8vsbJ2aX13x873qvIDY4dQa ml0vVWJ0273b8elXTdbzNN1mZiI3I5QK36pC6WYOUWEmW84reQgUjMoC7flTkcgZK/m/o2SMbMwq xwgcakQiMCtfB+aj65AmZHIIkhVi9MRLO6FipESi9OIjXXFQ3xQHPkdcJSbgbAIXntvGEjGi6bWX Z5lvtk2ilAQjJX2mToOQCT9CskKMjK8kiZIqV0CP/qszjj5MndNHow38ygQShQBto7gwX81Witsf 2D0yMniRbzoWJYNGNK9NAyNNAluiuc1t11opRgabJ1/eielvmv9LifK/99ZOGHJChlEUvzIBJtAC Ado+QdsoVKS/3bMdy1eE9rqmUpTMXhJQwSa2POVmuk8LaFpZbBk4/y47xMigQh4XVPxSovxvu7YD Rg3j8BUGa35lAs0JXHNpe6j64339Hdvw+dfhPa6pEiUSWTLOSMC0ldrkFVLobxKtgXaKkcGSHkpy B2L2Qiflf93E9qANjDPnJp+ncIMvvzKB5gQ6ddBwwTltlfmlu/KWrfh5XWPzYkN+pu8/JcNXXciL Yjh47WXt9bso5HrCJCFLqS3erBp/WU2CzQA5QYyMB4UWOn0+4PLzzJ/HvuK8dsjK0KDC5NSoP78y AbcQ+EOfFDx2jxrvCxTldfa71Vi/0RcVDhIl8lFp9tRhoomSDDQtHWnz5uWQzCaM1DpJjIwnd8bc KvzvJTUOMcj31VUXqjFnNerPr0zA6QTI+4JKMZo+typqMTKY0fS9ijVlEqVEmb4T0DYRLyNM2gYD nptfad7Y2LdjZjuC9xnFmi/tI3jkGTWO1c85PQvU9pQU3kAba//wfe4lcFpeJu66uaOSBtDI6OnX dmLzlvj2AKoUpTOGun9NSRMB3ZZBjxgrpVgvhDxQSY9alCn9QSbPBmanux4q37XpNd68ac63vkHi 5qvMd11CbScXZLPnV2N9aXTTCvG2i+9nAnYRoDhGKqbDqT1vLqjBo89VQJrkickILWP29N31l7fX 16oXFNfa1Q1xlys0/EqZ6CMkoQXWx52jjRmoEiPagf3+7zuwzWoePTQkcioSjQ6fe6gLDh/Ee5VU 8OU8nUWAjIVUidGc+dW6H0mzxMggp2qkRD9yR+S61hjA37ApRx8hNU3ZSbhWkFSKkeEOxHiYzHol kbv5n9vNym6vfKbc3glDT3Ltw7lXe/gAE2hO4OqL2imLHUTeVh57Xs2aL7WDREmFRxcXi1JpcbHQ p3V0QZJC6MOl5p3u9M9uFCOD6Zff1uNPt2zFO4vV2JP87S8dcM5p5vrVM+ruhFdaxD7qEB4JOqEv rKxD504e3eHwGEXP9qtzqkDeVlQnVW7GXClKQQMifQ0JkL8C7loQd7MYGQ/7T+sa8eLrVfD7ocQY 46qL2qFDew3PvGa+J2KjDXa9nnB0Ouh/8Ue1mLuoBt+sijMIkF0NMbFcjwf6jxBVTkRNrGpMWR06 IBVTFcYxou0TVoZ7MITP7A28JEqNPmDJMpesKQUNiHRB8gY8P/pFIKaHxI6bEkGMDG5bt/vx3xeb fpGpsBAcf1YbZKQLPPqc+l99RpusfM09PgP0f+F7tZi3sBrf/RTZpkUr66i6LCEAGjEksvk/rY/Q H1pVibZl2OFRW5Uo3XZNB/h9cpcXclXczMlXrjHy8dCbIwZOqawVlbcCQv9snHTiayKJkcE3EAA+ +bIebdto6N8v1Ths2utBfVPRsb0Hm7b4UbHTGT88AhIgsTQr9TkgBWT+26mjB1u2BVBe4Yx2mtW+ UPmQEJ11ahZ0p7uHmjd9+eaCauxwyHNC7R53Vhtcc0mTd4JQHOI99u+nKvDGu2qmziOp2+ff1CMz Q8OAA8397p9yXAZ+2eDT/0dSD7uuEZDPrymZ9iWVrwvQqlV3yt4DbzofgHPj+gL6XhsVpt3B8Uzs 6hQq97Ov1DyYlDftYj9zRBbW/OrDr1HuNlfBhKYUzj2jjel7pw7snaL7+WvXVmsS4MrEFKbTh2bi v/d2xjGHmydERj/TNHJdvUm2zkamMb6SN5ILzjXvh0vzakyeWo6iD+yf2iJRUvGD1BWipImpa7+d qtsx7BoR9Rk4aSSAfs07zCmfE3FkFIotPZgej8Ah/c39tWSURdNbldUSqx0wtdW/Xwp65vy+jGlU 0KRXGhWSAGdladj0mx87qxJDmIafkoHrr1DnXLeqOoDnZ6rxVB9N12ZmCPzpwvagTd+q0rW3b8OK b8I7SVVVdqh86QdpMoqSP+C9bd2qKfpDt0uQeg2adAyAY0OBsvtYsoiRwfmrkgb9F+qRiqzIjjks TRc9KsfOJCFw8rHpSqswoF+qPq2Vnq5hQ5kP1TXO+OUfbaPph8S1l7bX14q6dNr1tY02m1avX/5F vel771ottNkFBx+UiosK2mLkYPM3ulNR5H2BRkY0neW0pFKUflzr078DDmvzzqWF3f5u1GnXk917 0KT9AZxhnHDKa7KJkcG95IdG/LY1gOOPVvMHm0ZgNK21YmW9aTvRjbpH+kpThxeca77T2VDlD/xD qm6BlpoisHa9zzFTUqHqGnzsxGPS8eeLm+L67Ntl19c1+BJT39Pifumm+NzkxFMhcoMzeVJH9NpP TXRk8r7wyLMVqKl17g8TVaJEcdRoZmSjjf0b4tn4Zm3J1KeM47ue8D4DJmVA4FLjhBNek1WMDPZk Fk4PUJ6iTa40rUW/trduD2BbufVTWrQLnkYsVkbBHXRQKgpGNa1d/bCmEY0ONcr74xFpuPrC9nro gpx91UxrGs+Z8frdj436pk3js9Wv5HlBpck6bUb97wvusDZVJUr0t8RhorR4bcnUN4xnbZcg9Rt4 fVVAaLcYJ+x+/csl7fSpFrPr4RQDhkjbRb9mPvi0DgJCN0yI9L5Ir+vbKwW0QL55qx8/r7N+CoP+ CLZv58FBfdX8Im6JA00LkZUfrddRHWgvmBPSUYem4U8XtsPFBW3RI9saITLa/ez0Sqz5xfpngJwC k+cF+qGgKlGwzKddth+PREnFd8NJoiQhX1tbMu1Do993CdLPqx6q7T3wxisBYc0cilGDEK80RXH2 SPMXM+9/bAeWfBg67HCIajjm0I6KAFaubkCbTE2JKFFDaZMppa9t2GBK0Tc7tPMoa1u4jqSpSwrh QRM4q35oBJng25EOGZCKyye0xcQJ7ZQZeoRr18x51SicZ32wx4EHpuLisW11k/1w9Yvn3JMv78Qr c+w31IilDZ9+mdiipEnt32tWTf3RYLNLkOhArwE3jxQCvY2TdryqEqMpj+/QN0/a0SYzyiQzaZV7 laiOhw5M09eVSJSsHDHQ1N2n9GuwrfUjJaNvDhuYpk+PEWea0rBKmPr3TcGl49rpI4QDelo7SjTa To5En/h9c7ZxzIrXkUOawkb03l9du+/9zw68VWTfHiMzOKoUpa9WNcQdWiOeNgrg1jWrpu6Ky7OH IPUeNOkQAMfHU0A896oUIze7Zg9mSsN4r1fgYEVm4bSuRCOGb75r0PfxBJet+j198eoaJFRZF0ZS /yMObhKm2jqJ739uVGbw0Wf/FN2SjEIH0KZeuxK5ynnyFetdS106ri2uPF9tYMnrbt+m/4izi62Z 5aoSpRG5mfji2wb8ttWWOettiwtzCjVkfQAAIABJREFU/i+Y056CNHBSVwBjgi+w6j2LUeSkv/y2 AVWKjQGGn5Jpy36lku8b8frb1boQqBLdSEjTWs4F57RFTZ3U15giuSeSa2jf1QXntMHNV3cAbeK1 Ky1fUYfHX9iJtxU5922pXZ06aLj8vHYw239bcHlk1v23e7Y70cQ5uJpRv1clSqcOtkeUpBTL166a +mIwiD0Eqc+gSWRz9OfgC6x4z2IUPWVaiCevC7Q/RVWi/UoZGZrlmwdp2oxEd96iGgT8AFnG2ZVI mC48ty12VjWNmGKtR86+Hpx/Tlvcek0HJe6hIq0X+fp7bnqlbk1ntfkvmbBTmHHyGqIqGaEjGhqc a9YdT9tJlMgNmNkM7RAlTcjZa0qmLQrmsYcgHTngwfJaUXUTAHVPTHDppH6KDBhozShRpumaIdv1 kfbxqLTAo4Jo0blrZ3tMw8l9DU0nLHivVl/Tor1EdiVy0UPCtG1HAD+uidxWvMs+TUL09+s7mu6r LFoW0/5XgUeeqcDPNljSnX9OG9D0pMr0YmElnplu/fSjyjaFypvWkhNBlITEY2tWTVsZ3Ma9Yk7k FWxcDghLPDawGAV3RezvyckmsSRHmyoT/TGjMOx2pe7ZXowelqnUnUwkbaNRBnkWX/R+bYtrTB3b a/pGXHIManci56E0NWd29NNI2kWbry/KbwsVPiiDy3/46Qp9RB18LNHfX3dZeyVha668ZSt+Xhf5 j65YOQuBAUUzcr4Lvn9vQRpb+m9IXBd8kYr3LEbmU504vq3uGdn8nHfnSFMihsv83Uetfdeze5Mw qdgaEG1LSJRopEpGEB4N6NhBQ98DUkBTfXYnCjlCnrvtECJqO7mF+scNHZVj+L/7tieM8UK0sFSJ 0hU3b8WaX5WKUsVJA7I7TZ68Z9yjEIK0cRykeC1aMNFcz2IUDa3orqXF4j9doNZ6iUK70wjBbl94 ZKlGMaTI3Qyn3QTI/Y9hGLL7qLXvaGMvTdOpTGS88PLsKmwrt8VCTGXTosrbpaK0aPHMnOHNG7rH GhKd7HPQLTXQpLIREotR8y4w9zNt7ly3wQdyO68qHdDDC7LCa2gEvv3ePgetFPPo4y/q9bAdZApv p/m0KtbR5EuRgW+9dzu+/V7pL9uwVdqvu1f3MpE/Su30MW3kJTdANDJN9kTfQRWb5unH3rJP69XE FhPylbUl04qb991eIyS6YEhB6W8C6NL84ng/sxjFSzDy+7t38+Lc07OUzDEH14KcVU5/swpbttn/ K5W8HdAak0rLw+C2O+X9C4WVeO2Navh89v5xpj9g9GtddbIruqvqdsWTf1amwMTx7ZR835VM3wlt 1OIZ3d5q3ua9Rkh0Qe+BN51EMd2aXxzPZxajeOhFf29lVUCfV8/KND8SZXBtyAcdCd9v2wIgZ7B2 ps1b/Hj/kzqQp3QK206/1hM5kX82GhF9sbLBMs8SoXimpQndKSo5R1Wd/jGlPOGtZ2NhSE6CVY6U Fr5Xq+99jKVuoe5Jlbj+p1VT97KQakGQbuwJiGGhMorlGDlOHHOa+UP4ZDDtjoV38D3kJ67BB5AH ApXp+KPS9TDMX5XUg8KT25nKNvtRvLwOZA1Hgd5UBQG0q43kXeH2B7brU5VWungK1V4ajf7v/s6g uFMqE60X3fVQue7WSWU5bs5bpSileIW+FGBSPLHvFhbmTAnFOqQg9Rn0Vz8gJ4a6IdpjtMCuIuoj mbLOX2J/6OFoedhx/berG/S9J4MVbqKldg04MFXf/PnLRp8jgp9RXJ+lH9Xhp3U+PRJnTjd3j5im v1GFO6eVY/mKekeEzZg4oa3ug0/1M134VjUee36na4MrquYTnD+JEq0jZ2WY64iZNuKmmidKs9eW TN1ruo7aEVKQ9usyZZOWVTUJQFw/e0iMVLgIof0wtIufU+QE1pf68M6SWqSlqgljEVyTU47NAE3j kLcFJyRqe9EHtfi11I8O7TRYEejOzHbTAv49/9mBDz6pc0RgwWOPSMNVF7XHqbnqrRtpi8FLr7vT U7eZz0A0eTU0Sn00QwJipkcHs0RJSPlQ8w2xRvtCCtK6dXcG+gyYdAoE+hgXRvuqUozs3JwZLQcn XU9RMmmXN8WfoXhAKtOgP6Tqng0oTLRTQkWvW+/TvT5s2uJHp44ePTihSgbx5j3r7Wrc/98KFH9U 65jRAe11u3Zie0tiNd3yr+1Y/AHPgsTyHNHUGlnbOlGU/H55/brvpoWMlBhSkAjAAQMn7S8EhsQC g8UoFmrW3UMjF4oSe9xRasKjB7eEzM8zMzR8/Z29C+/BdSLXOfOX1FjGILjsSN6/8W41pjxeoY/q yDjFCYnWCGktmLxDq07U/kl3bQeNbDnFTkC1KJERRdQRlwXWLH29+90ttapFQeo98AYfhLispRtb Os5i1BIZZx3/cW2jbpGmKhJtcGv1taUxbWyLShtcl+D3xOClWbSxMoDjjlQvzsFlh3pPcXumPlGh W5FV7HSGENH+LgoaSBGcaSuB6vTcjErQfiq7jTVUt9Oq/FWKEu19ilqUBApbWj8iJi0K0lEDp5bV iqprAUT8TWUxsuoxM6ecHTubTMPT0zRY4biUotKSb7MNZT49tIU5rYg/F3KWSsK0s1KCnKhancik lox0SJBos69T0ojcDDx+b2dLng1qM5l0v2NxOAynsFZZDxKlX0t9GGNyFG5aU4pWlATEA2tKppa0 1N6QG2ONi/MKSmcDONv4HO6VxSgcHeefOy0vEzdeoX5To0Hi6dcqQVZjTkvkqJb2VV2p2P0StZvW huYuqsE3NoSND8edfAWeOTxTubNeow40RUcjI/rDyUkdgd77peDJKZ1NL4BM8p9+LSIryIBX+rss KOy5vaVKtDhCohv6DLipEwROb+lm4ziLkUHCva80fbXkwzp4NHMtc1oiQvuiaCqPDC2ctlZAZrOv zKlCQ4Oa/VsffV6nmzFPf7Pa1vDRofrmgnPb4I4bO4IiB1uR6IfJs9Mro1+LsKJyCVYGjb7JFRB5 1DAzGSOllasbQLHMwqTPFxX2eCTMeYQdIeXmr+/rEZ4fw2XAYhSOjjvP0Y57CmNuVZozv1p3Bkqe FpyWPB6A9m+dPjQzbstEmpKjX5M//2KvR4tQjIeckKH/oVJtfWmU7RQHvUZ9kulV5Ujp4WcqwqG8 Z/HMPUOWN784rCDRxXkFpd8DOLD5jfSZxSgUlcQ4RtE9J09SHzogmBYFWCPvzQHnLKMEVw8d2ms4 8uA0HH5wKg7okYIe2R60ydL2uMb4QKbltFb2/U+NesTdVT80wOc8vdVDZYwengmasrUq0b6qJ18O afVrVRWSvhw7RElInFRUmLMsHPxIBOlBALRJdo/EYrQHjoT8QPuVaDf+OQrcPoUDZncgwHB1a36O XBNlpGtITxfw+yRq6qTugbqx0dnrISSk487MwrgzrRsJE7up/yMPK7ypvflzZMfnfr1S8Ph9ataU QoyUtneS2V0LC0XYn2Vh15AIUu+BN9H8wkXBwFiMgmkk7nsaqZAvvPKdARx7RMTGlnED+eMR6Ti4 f6rulYCC3zk50Zw5hUCg/UJVNRL1DdKxIzziqGnAhLPb4P7/64RBijdHB/cbGS7c8eAOfPOdM7x3 BNctWd9v3xHAF9824NTB5o6OaU2JQqzTJvygNOutwnazgj6HfNuqIHU64cGN6XW7zb9ZjEJyTOiD P/zciIXv18LjESDv3lak7K5e5B6Xgb69UnQT8dLNYX9YWVElV5dB1oMU4v4/d3fG4YOsNW0n9z/P z6zi2EUOfIJ+2+q3RJSkEHevLZm6qjUErU7ZUQZDC0pnSiCfpm9UDPEp0Nbsd6pbqyufdwABKyLS hmqmU02kQ9XVScdIiGh96IbLrTPpN9pPBhxvLqwGuWzi5GwCNFr+9537mF5Jipf2n2d3NAqZ0aWo sFNYiwcqPCJByhu7cdzE8e1eUyFGHGzL9GdAeYb79/DirBHqg/+FasiyT+t0x7orvtljOiDUpUl9 jIRoxCmZuOkq64WIwD/1SiVmzHXePrOkfihaabwqUVr6Ue36ISdk7tdK8frpiASp9LfGU7O7eOdH kmE017AYRUPLeddSWJGrLmxnS8U+/6ZeN6H+8LM6W8p3aqFkpj5qWJbu6seOOvKoyA7q5pV5+KBU TLnd/JESgH5CiJ9aq2lEgiSlfA3AuNYyi+Y8i1E0tJx7bbcuHj3ECK1P2JHW/NqoC9O7xbVwumWb Sj5tszR9H9Gl49RHbW2pHbRW9PpbPPXeEh+3HD/msDTcc2sns6vbTQixubVMIxWkCwG80FpmkZ5n MYqUlHuus9r1UCgy09+swluLakB7gJIl0X4S2nk/api5llLR8CMLOgqi58SNzdG0g6/dTcBkUSoW QgzenXvL7yIVpA4AylvOJvIzLEaRs3LblakpAhcXtEXBaHtGSwavT76ox4L3an6PrOrs/UBGnaN5 peCHucel47QhmZY5Pm2pfryvqCUy7j9uoihdI4R4NBIiEQkSZSSlfBPA6EgybekaFqOWyCTW8aMO SdN/tZN3b7sTWfkUfVCD1T81QrpYm8hI4ZD+qcg7McNSrwot9R+NRskHnVO9arRUbz4eHQGTRClb CLEpkpKjEaSxAKZHkmmoa1iMQlFJ7GMFo7Jwxfn2GD2EIkvrGxQGfNWPDa4QJxKhAf1SccIx6SCW TkhLljV5KP92NW9wdUJ/WFEH+mF5500xuxFbJIQYHmk9oxEkmqSOacWSxSjS7ki869q20XDe2W10 wwcnta7og1p8+mU9Vqysh1OC4RGfrEyBwwam4Y9HpOlTck5i9uhzO0HrRZySj8Apx6bj9htiEqVL hBDPR0osYkGiDKWULwK4INLM6ToWo2hoJe61hw5IxejhWTjlOPun8ZpTXrfBh5XfNej/v1/TiNJN PstGUB3bayBXKxQgkTwoWOUJozmDcJ9fnVOFFwo5ims4RslwLkZRai+EiNiTbrSCdCqAiPcjsRgl w2MaXRuHnZyBW/5MNjLOTuRzjTwMbNzkQ+kmP0o3+0ARdmMZTZGT2k4dNHTu6EGPHA96ZnuxXw+v 7mm7a+dWvXfZBmreoqZwGWRaz4kJEIEoRalQCFEQDbloBYm+PRH5AWExiqYbkutaWhspGNUGl59n 356ZWIn7/UBFZUDf80SOVBt9TZYSXo8Atcv4nJmu6Y5MMzMFaI+QmxJZKc5dVA165cQEmhOIQpRO F0K80/z+cJ+jEiTKSEp5L4C/hcuUxSgcHT5nEKCRw4Sz2oCilHJyBoEHn6jAu0s5PIQzesO5tYhw psMrRPhwE81bGIsg/QHA6uYZGZ/Zh5VBgl8jJdC+rYb8M7Iw7iwWpkiZmX0dGSy8uaDasrUzs+vP +VlPYERuBm6+qsXp9/uEELdGW6uoBYkKkFJ+AODE5oU9N6MSr8xmh4rNufDnyAjQesroYZksTJHh MuUqms14/W0WIlNgJmEmYUTpICEERRuPKsUqSJcCeCa4JBajYBr8Ph4CZHlGUWp5xBQPxfD3khDN nl8NWhPjxATiIRBClJYJIU6KJc9YBYl26e0aCrEYxYKe72mNAIXZPuvUTN0dUWvX8vnICDz8dAXe XlzDHhYiw8VXRUigmSidJ4R4NcJb97gsJkGiHKSU1wJ4+LHnd2LOfN4stwdV/mAqAa9X4PS8DFxz qT2xfUxtjA2Zfb2qAXMXVuP9j+t4jcgG/slSZJssbdurj+57aVaWmBtrm2MWJCrw7oe3n7j0wzpa T+LEBJQTILPqIw9Jw+jhmTj+KOdtsFUOIMoCFhTXYt6iat2PX5S38uVMIGoCAuL+opnZYS2wW8s0 LkGizPPyS5dCILe1gvg8EzCTABlAnDE0ExPOZsu85lzJ6ek7S2qwoyLQ/BR/ZgKqCASE39OvaNa+ a+IpwARBKjsXQhbGUwm+lwnESsDrAY45PB3DT8nAicck76jpvY/r9FhQX5XU87RcrA8T3xczAQEx t2hm9pkxZ/D7jXELUm6u9Hq6lv0MIKKY6fFWmO9nAi0RoP1Mg0/I0GMFDTootaXLEub4N6sasPjD Wn1tqLKKR0MJ07EubIgATi2ambMg3qrHLUhUgSEFZTcJyCnxVobvZwJmEdinowcn/TEdJx+brscR Mitfu/MhH3sffV6H95bXYcs2ttm2uz+4fJ3AqsUzswcBIu6IY6YIUu5Zazt4UtPWA+AJfX5CHUeA zMePPCQVfzw8HUcflgba5+SmRNNxH6+ow8df1INHQm7queSoqwCuKJqZ85QZrTVFkKgiQwrKHhGQ 15hRKc6DCagk0LO7F4f2T8XB/VP10VOXfZzjcZsct67+sRG0FvTFtw1Y84u7I92q7EfO2xEEttZI /37LC3vWmlEb0wRpeP6mXn4R+AGA14yKcR5MwCoC7dtpOLB3CvockIJ+vVLQM8eD7vt6kZZm2tcj ZFPKNvvx60Yfftnow/c/NYBiMW36jafhQsLig84kIMUdiwuz7zKrcqZ+4/IKSl8GcJ5ZleN8mICd BMhIonMnD9q1FaBpP4p+2yZT6EKV4hWgDbspv//8CgRoszjg+X2wVVsndW8IdQ0S9fUSOysD+nTb zqqAbo69dbsfPtYeO7uXy46fQJVX+vdfUNhze/xZNeVg7mjG438Afs8EAKYKnVmN5XyYQDQEaPqM /nNiAkxgbwISeMpMMaISTF3dXfxaz29EFBFl924iH2ECTIAJMAEXEGjwavIhs+tpqiBR5aSG+8yu JOfHBJgAE2ACziEgIF5ZOL07WVabmkwXpMXTcz4A5BJTa8mZMQEmwASYgFMI+ODX7lZRGdMFiSop Ie5UUVnOkwkwASbABGwmIOTL8fqsa6kFSgRpycyc9wEsbalQPs4EmAATYAKuJODzBwL/UlVzJYJE lZXAZFWV5nyZABNgAkzAFgKvFBf2/ElVycoE6fdR0iJVFed8mQATYAJMwFICDR6pKV2OUSZIhEnT cFvTYMlSaFwYE2ACTIAJmE5APrWwsNta07MNylCpIC2anvM5gFlB5fFbJsAEmAATcB+BGq8vVdna kYFDqSBRIR4/bgfgMwrkVybABJgAE3AXASnwyILZXcpU11q5IC2clbMaAi+obgjnzwSYABNgAkoI bGvwND6gJOdmmSoXJCrP25hCo6TqZmXzRybABJgAE3A4ASlw97JX9y+3opqWCBIN9SQER5S1oke5 DCbABJiAWQQEfqxon/2YWdm1lo8lgkSVyKqlEOeitLUK8XkmwASYABNwBgEZwC0rnhSNVtXGMkGa Ny+nRorA361qGJfDBJgAE2ACsROQwHtLCnPmxJ5D9HdaJkhUtZP757wACTIF58QEmAATYALOJeD3 yMD1VlfP8kB6eWPLjoaUH5sdi8lqcFweE2ACTCBRCUghH18yo/vVVrfP0hESNW7xjOzPADxrdUO5 PCbABJgAE2idgAS2BOobyMuO5clyQaIWpkpBjd1heWu5QCbABJgAEwhPQOC24jd62fL32RZBml+Y vQVC/F94KnyWCTABJsAErCQgpPzk5P7Zts1g2SJIBPik/t2eAPS1JCt5c1lMgAkwASYQmoDPr8kr J08WgdCn1R+13KghuEmDx244VJMaWd15g4/zeybABJgAE7CYgJBTFs/o/leLS92jONtGSFSLpTN6 fA2IaXvUiD8wASbABJiA1QTWeVI9SmMdRdIgWwWJKphZKwmC0hgbkYDga5gAE2ACyUpACnH1wpe6 2e5v1HZBIg8Omha4nAP5JetXgdvNBJiAzQReXjIje77NddCLt12QqBaLpvdYDOBJJwDhOjABJsAE kojAJq/0X+eU9jpCkAhGXV3KzQB+dQoYrgcTYAJMINEJSImrFxT23O6UdjpGkD6c26VSCslTd055 MrgeTIAJJDgBMd1q56mtAXWMIFFFl8zovhCQj7dWaT7PBJgAE2AC8RAQpV7p+3M8Oai411GCRA30 pHnIDv4HFY3lPJkAE2ACTABSQF7qpKk6o08cJ0hkehjQtAsB+IxK8isTYAJMgAmYRUD+t2hmzgKz cjMzH8cJEjVu6fRun0DIe8xsKOfFBJgAE0h2AlJgdWatsNUbQ7g+cKQgUYX9m3P+CYmPwlWezzEB JsAEmEDEBBoAnEd7PyO+w+ILHStIxcXCJ/2YIIByi5lwcUyACTCBxCMg8dclM3K+cHLDHCtIBG3J 7JxfpBRXOBkg140JMAEm4AICby8uzH7E6fV0tCARvMWF2a8L4Amng+T6MQEmwAScSUCU+j24GBDS mfXbXSvHCxJVtVr6b4TEl7urze+YABNgAkwgAgI+aHJc8Ws5WyO41vZLXCFIywt71krNkw+gwnZi XAEmwASYgEsISIhbF0/P+cAl1YUrBIlgLpmx789CyIvYK7hbHi2uJxNgAjYTmLNkZrepNtchquJd I0jUqqIZ3d+EkA9G1UK+mAkwASaQbAQEfhQy/RI3rBsFd42rBIkq3imQc6uUgsJVcGICTIAJMIG9 CVSLgHZOUWEn1y1xuE6QCguFPw0YD4Ff9u4HPsIEmAATSGoCUgAXFRV2W+lGCq4TJII8vzB7SwCB MwE4dsexGx8GrjMTYALuJiCBfxXNzJnl1la4UpAI9tIZPb6WQlzMRg5uffS43kyACZhKQMq3Th6Q fYepeVqcmbC4PNOLyysoux2Qd5meMWfIBJgAE3APgZVCpp/kxnWjYMSuFyRAiryCspfIaWBww/g9 E2ACTCAZCEhgS8AXOKJ4do8Nbm+va6fsdoMXspMsvxSQH+8+xu+YABNgAklBoEZq2qhEECPqrQQY ITU9dCPzy7o0CnwkIfsmxWPIjWQCTCDZCUgJed6Smd1fSxQQCTBCauoKsrzzSd9IGr4mSudwO5gA E2ACLREQErclkhhROxNmhGR02uBxm/6oBQJLAGQax/iVCTABJpBIBKSQjy+Z0f3qRGoTtSVhRkhG x1D484CU4ynorHGMX5kAE2ACCUTglX0COdckUHt2NSXhBIlatrSw+1wJ/IX3KO3qZ37DBJhAQhCQ S8iIizzWJERzmjUi4absgts3NL/0b1Lg3uBj/J4JMAEm4FICnwqZPtzte43CsU/IEZLR4KLCnPsE xP3GZ35lAkyACbiUwKpUKc5IZDGifknoEVLTg6dvnH0cwJUufRC52kyACSQzAYFf/I2BExNlr1G4 rkzoEVJTw2njbPafAbwSDgSfYwJMgAk4kMAGf8A/NBnEiNgnwQip6RHLz5eebaL0VQFR4MCHjqvE BJgAE2hGQJZByNzFM3r80OxEwn5MGkGiHjzyCpnSvrxsuhAYk7A9yg1jAkzA9QRog78mcErRjJzv XN+YKBqQBFN2u2mseFI07oPy8QJi7u6j/I4JMAEm4BwCuhhJLS/ZxIh6IKkEiRpcWDiwoaPcni8l ZjvnEeSaMAEmwAR0ApulRwx2a8TXePswqabsgmHl5kqvZ99NL0PKscHH+T0TYAJMwB4CotTjl3kL Z+Wstqd8+0tNuhGSgby4WPg6BbpRDKWXjWP8ygSYABOwicB6v/SdksxiRNyTVpCo8eR+o5PMpjDo /7PpIeRimQATSHICAuInvyZOLi7s+VOSo0ges+/wHS3FkLFl9wiJv4W/js8yASbABEwkIMU3fnhG FBd23WRirq7NKqlHSLt7TcglM3JuFVL8lR2y7qbC75gAE1BIQOKj+pSGXBaj3YyT1qhhN4I93w0Z WzpRSDwBwLPnGf7EBJgAEzCNwLuZtThn3rycGtNyTICMWJBCdOKQgo2jBASFBc4KcZoPMQEmwARi JiAgnivv0O1K2hcZcyYJeiMLUgsdS5FnRSAwTwBdWriEDzMBJsAEoiQg/7l4Zs4dgJBR3pgUl7Mg henm3Pz1fb3CO19C9g1zGZ9iAkyACbRGwC+Aq4pm5jzV2oXJfJ4FqZXezx1f2tnjxxwAJ7ZyKZ9m AkyACYQiUCGAsUUzcxaEOsnHdhNgK7vdLEK+K34tZ2tqZfVQ3kAbEg8fZAJMIDyBtQGPOIHFKDwk 4yyPkAwSEbzmFZTeCuBfyRS2IwIsfAkTYAIhCYjlnkZx1sI53X4LeZoP7kWABWkvJOEPDMkvPVsI vAigTfgr+SwTYAJJS0DI51N31vxp/vx+9UnLIIaGsyDFAG342E2DAlLOYWOHGODxLUwgsQn4hJA3 Fs3o/p/Ebqaa1rEgxcj1xAm/dEzzpbwK4NQYs+DbmAATSCACehwjTRQUTc8uTqBmWdoUNmqIEfey V/cv7ySzzwBwL7sbihEi38YEEoWAxOfw4WgWo/g6lEdI8fHT7x6aX3a6FPIFAPuYkB1nwQSYgIsI CCEeTdlZdROvF8XfaSxI8TPUcxiev6mXH4GZEDjKpCw5GybABJxNoAoCf1o8I+cVZ1fTPbXjKTuT +mphYbe1qVXVJ9KvJZ7CMwkqZ8MEnEtgpcePo1mMzO0gHiGZy1PPbejYjWdKKZ7hKTwFcDlLJmAz ASnk44GMhhuLn+9VZ3NVEq54FiRFXZo7ZkMPzau9LIBTFBXB2TIBJmAtge1SYuKSwhxyJcZJAQGe slMAlbIsnt1jwz4yOw8Q/wAku5lXxJmzZQIWEVgqJA5nMVJLm0dIavnquQ8bV3pUIACywhtgQXFc BBNgAuYRqBPAbScOyH548mQRMC9bzikUARakUFQUHDsuf31Gpua5FxLXsi88BYA5SyZgNgGJLwFx /uLC7FVmZ835hSbAghSai7KjeQVlgwXw5P+3d26xVVRRGP7/PbW2oqYgSIGC8a6QoPLgJYKppYL1 Fi+p0AcUrw/6oDHxHhU1XuOLiYmJqVGjRvAIGjERQ9GCGNEgmGgUlYhSORRRqQhKy5lZZho1EUs9 9zMz5386ObPXWnutb03yZ86Zvbe2HSoZYgUWgUIJDBj48K8NjQ/rVNdCUebmL0HKjVdRrAeflliz ALBbANQUJaiCiIAIFEzAgA/DPS8CAAAGbElEQVRovF5PRQWjzCuABCkvbMVxap275RQL2AlgWnEi KooIiECeBHYCvGvG5Man9V9RngSL4CZBKgLEQkI0N1uNO7z3ZsIeAFBfSCz5ioAI5EOAS/2Mf0P4 Zmw+3vIpHgEJUvFYFhSp9bJtRwUueIa0mQUFkrMIiEC2BLbB7KYVqQmLsnWQXWkJSJBKyzfH6MaW Oen5ND4OYHSOzjIXARHIjoABfK7GMre+k5r4S3YusioHAQlSOSjnOMfs9p5RGboHAV6vlx5yhCdz ERiewDrS3di1qHHN8GYarQQBCVIlqGc55+BLDz6fBDEjSxeZiYAIDE3gJxrunj5lXKdeWhgaUBSu SpCi0IVhczC2XJ6eS/BRAJOGNdWgCIjAvgQGQDzl9/c/2P3GkX37Dup7tAhIkKLVj/1mE65dGgHv JiNuB9CwX0MNiIAI/E3gdd/827pTEzf+fUGf0SYgQYp2f/6TXXNHerTn2/0ArgN4wH8MdEEEqpwA zT6C5+7QceLxuxEkSPHr2WDGze09x3j0FgDoAKBd22PaR6VdVAJfkLina9G41wFaUSMrWFkISJDK grl0k8zs6JmKjHsI5AWlm0WRRSDCBIjvYbxvlDW+lErRj3CmSu1/CEiQ/gdQXIZb5/SebkFwL4i2 uOSsPEWgQAKbaXx0JH55NpWaMlBgLLlHgIAEKQJNKGYKLe1bTyXtXgDnFzOuYolAhAh8R8MjI7Hj eQlRhLpShFQkSEWAGMUQ4aGAvo87SVys/5ii2CHllAeBrwh7fEfD+Bd1LEQe9GLgIkGKQZMKSfHs 9i3HO8dbYZgHoLaQWPIVgQoR+JjEY9NPHPeGFrVWqANlmlaCVCbQlZ7mnLmbxwfm3QzjdVrHVOlu aP4sCAQElhn4xIpXx72Xhb1MEkBAgpSAJuZSwqx5vSP8Absy3OUYwHG5+MpWBMpAYDdgL4D25IpF TV+XYT5NESECEqQINaOcqSxYYG7lF73nerAbDZgNwCvn/JpLBP5FgPjGjM8EA3s6tcXPv8hU1RcJ UlW1e+hiW9vTkwLyKsKu1n55QzPS1ZIQ6AfwGh07uxY2rtRi1pIwjlVQCVKs2lXaZMOnplVf9s5G YNeSdqG2Jiot7yqO/hkMz9bAf1HnEVXxXTBE6RKkIaDoEjCzY9tY8zPzSV4Dw7FiIgIFEtgF8FWY 37ki1fRhgbHknlACEqSENrZ4ZYXHX2ydQeIKGi41YGTxYitSwgkEAFbC7OXa2rrU2y8ftjPh9aq8 AglIkAoEWE3ubW3fHDhwyIg2g3UQvBBAfTXVr1qzJGBYC+AV52UWLl84KZ2ll8xEABIk3QR5ETjz ou2H1B04cB7Jyww4D8CIvALJKQkEDLCPaG4JAre4a/HYb5NQlGooPwEJUvmZJ27G8PDAg+iFr45f YkAbgTGJK1IF7UsgA9gqI5cGe4PXupc0/bCvgb6LQK4EJEi5EpP9sATa28372UufxoAXwHg+aFOH ddBgnAj8BGAZzN4i6pd1pUb9GqfklWv0CUiQot+jWGfYfOkPTTU1nGVw5xhspp6eYtXO8EiHNQSW +84tH+2PXavzhmLVv9glK0GKXcvim3C4zmn1hvRJ8F2rITgL5HTtqxepfvowrIfDKgZ8NwNvZXfq 8F2RylDJJJqABCnR7Y12ceHPezu89FTzeRaIGQBOBzAh2lknKrvdMKwDsdrI9/v/qFn9wZtjfktU hSomVgQkSLFqV/KTbWnvmeCcdxrMzjDwVAAnAzg0+ZWXvMKMARsIfGLAGs+CNXu3T/i8u5uZks+s CUQgSwISpCxByaxSBIwtc348CgimATaNNihQk7Xn3rD92EmzL438FMT6gO7Tg3cHny1dOv73Yb00 KAIVJiBBqnADNH1+BMJ1ULUH+ZO9wJ9ixAk0d7yZHQ3iaAB1+UWNlZcBCF+13mjARho2mLPPXcAN Xanxm2NViZIVgb8ISJB0KySMgHHW3HSTH7hjBp+iDEcYrYmGpsHvxMSY/AQY7oS9zYAewLYQDD83 G7DJkRsz9f2bup8/ck/CmqdyqpyABKnKb4BqLL95/qa62j21Y3xzjQDHGvwxNDfKYA2ObDCzBpg1 GFy9ozWY8QA4Oxhm9QT/efraz75+vxMIxQTh9gUg+mDYS2CXGcKxPwJaH8k+GnfArC8g+hyw3Rx+ 9PZie+DqtmqNTzXemar5T7boKrYfCqI6AAAAAElFTkSuQmCC"/></symbol><symbol viewBox="0 0 24 24" id="webpack" xmlns="http://www.w3.org/2000/svg"><path d="M19.376 15.988l-7.709 4.45-7.708-4.45V7.087l7.708-4.45 7.709 4.45z" fill="#fff" fill-opacity=".785" stroke-width="0"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18.21 0 .41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.939v2.104h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07l7-3.94zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83-4.9-2.83zm-5 5.08v3.58l4 2.308v-3.58l-4-2.308zm10 0l-4 2.308v3.58l4-2.308v-3.58z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83-4.9-2.83zm-5 5.08v3.58l4 2.308v-3.58l-4-2.308zm10 0l-4 2.308v3.58l4-2.308v-3.58z" fill="#1c78c0"/></symbol><symbol viewBox="0 0 24 24" id="wolframlanguage" xmlns="http://www.w3.org/2000/svg"><title>wolframLanguage</title><g transform="scale(.12121)" fill="none" fill-rule="evenodd"><circle cx="99.197" cy="98.946" r="83.28" fill="#212121" stroke-width=".841"/><path d="M182.529 98.828a83.406 83.406 0 0 1-39.14 70.721.064.064 0 0 1-.038.019l-28.62-35.665 23.71 2.612s11.385 1.177 13.978 0c2.373-.938 15.175-18.963 15.175-18.963s-36.75-23.23-49.312-36.032c1.434-21.575-1.656-50.269-1.656-50.03-9.251 9.234-10.429 10.669-19.68 19.203-4.028-13.04-5.923-17.547-9.95-30.588-12.104 9.95-21.337 26.799-27.977 46.48a78.68 78.68 0 0 0-4.23 5.094 109.774 109.774 0 0 0-2.667 3.66 114.558 114.558 0 0 0-5.132 8.002 172.555 172.555 0 0 0-3.403 6.051c-7.706 14.475-14.034 31.066-19.515 46.001a.858.858 0 0 1-.092-.184c-14.988-30.912-9.502-67.85 13.822-93.072 23.325-25.223 59.723-33.575 91.71-21.045 31.988 12.53 53.029 43.382 53.017 77.736z" fill="#e53935"/><path d="M101.452 69.178s-1.416-8.295-2.373-11.367c6.401-6.18 7.357-7.118 13.52-13.04.477 11.845.238 18.006-.479 32.481-3.55-3.568-10.668-8.074-10.668-8.074zm-27.737 40.778s-6.64-4.029-11.624-4.728c1.435-3.329 5.223-7.596 6.18-8.773-1.913.699-15.653 6.86-17.087 12.084a74.804 74.804 0 0 1 11.385 3.79 35.993 35.993 0 0 0-8.774 20.158s21.815-3.33 38.185-1.196c.283.168.609.251.938.24l8.534.239 27.111 45.136.221.35c-.037.018-.055.037-.073.037-51.133 18.485-88.085-15.543-95.976-27.443.034-.102.058-.206.074-.313 7.1-30.017 15.855-65.939 30-76.552 7.356-12.82 9.49-31.783 22.751-41.734 3.33 9.951 8.553 30.588 12.103 40.539 15.653 15.652 39.361 35.094 55.234 43.15 1.656.956 3.79 7.596 3.79 7.596l-6.401 8.056-68.276-6.879a54.462 54.462 0 0 0-4.58-.183 86.848 86.848 0 0 0-14.144 1.36c3.311-8.295 10.43-14.935 10.43-14.935zm22.054-8.774c3.789-.46 7.817.956 12.323 3.568 4.267-1.195 4.745-1.434 9.013-2.612-5.463-4.028-11.386-8.295-19.442-7.118a47.249 47.249 0 0 0-1.894 6.162z" fill="#fff" stroke-width=".936"/></g></symbol><symbol viewBox="0 0 24 24" id="word" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5M7 13l1.5 7h2l1.5-3 1.5 3h2l1.5-7h1v-2h-4v2h1l-.9 4.2L13 15h-2l-1.1 2.2L9 13h1v-2H6v2h1z" fill="#01579b"/></symbol><symbol viewBox="0 0 24 24" id="xaml" xmlns="http://www.w3.org/2000/svg"><path d="M18.93 12l-3.47 6H8.54l-3.47-6 3.47-6h6.92l3.47 6m4.84 0l-4.04 7L18 18l3.46-6L18 6l1.73-1 4.04 7M.23 12l4.04-7L6 6l-3.46 6L6 18l-1.73 1-4.04-7z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="xml" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5l3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41-3.74 3.74m11.16 0l-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41 3.74-3.74z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 24 24" id="yaml" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="yang" xmlns="http://www.w3.org/2000/svg"><path d="M12 2a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2m0 2a8 8 0 0 0-8 8 8 8 0 0 0 8 8 4 4 0 0 1-4-4 4 4 0 0 1 4-4 4 4 0 0 0 4-4 4 4 0 0 0-4-4m0 2.5A1.5 1.5 0 0 1 13.5 8 1.5 1.5 0 0 1 12 9.5 1.5 1.5 0 0 1 10.5 8 1.5 1.5 0 0 1 12 6.5m0 8a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 289.99999 290.00001" id="yarn" xmlns="http://www.w3.org/2000/svg"><path d="M250.733 218.418c-12.39 2.943-18.661 5.653-33.993 15.641-24.004 15.487-50.176 22.688-50.176 22.688s-2.168 3.252-8.44 4.723c-10.84 2.633-51.647 4.878-55.364 4.956-9.988.077-16.105-2.555-17.809-6.66-5.188-12.388 7.434-17.809 7.434-17.809s-2.788-1.703-4.414-3.252c-1.471-1.47-3.02-4.413-3.484-3.33-1.936 4.724-2.943 16.261-8.13 21.45-7.125 7.2-20.598 4.8-28.573.619-8.75-4.646.62-15.564.62-15.564s-4.724 2.788-8.518-2.942c-3.407-5.266-6.582-14.248-5.73-25.32 1.084-12.777 15.176-25.011 15.176-25.011s-2.477-18.661 5.653-37.787c7.356-17.422 27.179-31.437 27.179-31.437s-16.648-18.352-10.454-35c4.027-10.84 5.653-10.763 6.97-11.227 4.645-1.781 9.136-3.717 12.466-7.356 16.648-17.964 37.864-14.557 37.864-14.557s9.911-30.431 19.203-24.469c2.865 1.859 13.163 24.778 13.163 24.778s10.996-6.426 12.235-4.026c6.659 12.931 7.433 37.632 4.49 52.654-4.955 24.778-17.344 38.096-22.3 46.459-1.161 1.936 13.319 8.053 22.456 33.373 8.44 23.152.929 42.587 2.245 44.756.232.387.31.542.31.542s9.679.774 29.114-11.228c10.376-6.427 22.688-13.628 36.703-13.783 13.55-.232 14.247 15.719 4.104 18.12z" fill="#2c8ebb" stroke-width=".774"/></symbol><symbol viewBox="0 0 24 24" id="zip" xmlns="http://www.w3.org/2000/svg"><path d="M14 17h-2v-2h-2v-2h2v2h2m0-6h-2v2h2v2h-2v-2h-2V9h2V7h-2V5h2v2h2m5-4H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#afb42b"/></symbol></svg> \ No newline at end of file
diff --git a/app/assets/images/icons.json b/app/assets/images/icons.json
deleted file mode 100644
index 68d6528758b..00000000000
--- a/app/assets/images/icons.json
+++ /dev/null
@@ -1 +0,0 @@
-{"iconCount":181,"spriteSize":81482,"icons":["abuse","account","admin","angle-double-left","angle-double-right","angle-down","angle-left","angle-right","angle-up","appearance","applications","approval","arrow-down","arrow-right","assignee","bold","book","branch","bullhorn","calendar","cancel","chart","chevron-down","chevron-left","chevron-right","chevron-up","clock","close","code","collapse","comment-dots","comment-next","comment","comments","commit","credit-card","cut","dashboard","disk","doc_code","doc_image","doc_text","double-headed-arrow","download","duplicate","earth","ellipsis_v","emoji_slightly_smiling_face","emoji_smile","emoji_smiley","epic","external-link","eye-slash","eye","file-addition","file-deletion","file-modified","filter","folder","fork","geo-nodes","git-merge","group","history","home","hook","hourglass","image-comment-dark","image-comment-light","import","issue-block","issue-child","issue-close","issue-duplicate","issue-new","issue-open-m","issue-open","issue-parent","issues","italic","key-2","key","label","labels","leave","level-up","license","link","list-bulleted","list-numbered","location-dot","location","lock-open","lock","log","mail","menu","merge-request-close","messages","mobile-issue-close","monitor","more","notifications-off","notifications","overview","pencil-square","pencil","pipeline","play","plus-square-o","plus-square","plus","preferences","profile","project","push-rules","question-o","question","quote","redo","remove","repeat","retry","scale","screen-full","screen-normal","scroll_down","scroll_up","search","settings","shield","slight-frown","slight-smile","smile","smiley","snippet","spam","spinner","star-o","star","status_canceled_borderless","status_canceled","status_closed","status_created_borderless","status_created","status_failed_borderless","status_failed","status_manual_borderless","status_manual","status_notfound_borderless","status_open","status_pending_borderless","status_pending","status_running_borderless","status_running","status_skipped_borderless","status_skipped","status_success_borderless","status_success_solid","status_success","status_warning_borderless","status_warning","stop","task-done","template","terminal","thumb-down","thumb-up","thumbtack","timer","todo-add","todo-done","token","unapproval","unassignee","unlink","user","users","volume-up","warning","work"]} \ No newline at end of file
diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg
deleted file mode 100644
index fd8f7862911..00000000000
--- a/app/assets/images/icons.svg
+++ /dev/null
@@ -1 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol viewBox="0 0 16 16" id="abuse" xmlns="http://www.w3.org/2000/svg"><path d="M11.408.328l4.029 3.222A1.5 1.5 0 0 1 16 4.72v6.555a1.5 1.5 0 0 1-.563 1.171l-4.026 3.224a1.5 1.5 0 0 1-.937.329H5.529a1.5 1.5 0 0 1-.937-.328L.563 12.45A1.5 1.5 0 0 1 0 11.28V4.724a1.5 1.5 0 0 1 .563-1.171L4.589.329A1.5 1.5 0 0 1 5.526 0h4.945c.34 0 .67.116.937.328zM10.296 2H5.702L2 4.964v6.074L5.704 14h4.594L14 11.036V4.962L10.296 2zM8 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"/></symbol><symbol viewBox="0 0 16 16" id="account" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.195 9.965l-.568-.875a.25.25 0 0 1 .015-.294l.405-.5a.25.25 0 0 1 .283-.075l.938.36c.257-.183.543-.325.851-.42l.322-.988A.25.25 0 0 1 11.679 7h.642a.25.25 0 0 1 .238.173l.322.988c.308.095.594.237.851.42l.938-.36a.25.25 0 0 1 .283.076l.405.5a.25.25 0 0 1 .015.293l-.568.875c.113.297.18.616.193.95l.898.54a.25.25 0 0 1 .115.27l-.144.626a.25.25 0 0 1-.222.193l-1.115.098a3.015 3.015 0 0 1-.512.608l.165 1.18a.25.25 0 0 1-.138.259l-.577.281a.25.25 0 0 1-.29-.05l-.874-.905a3.035 3.035 0 0 1-.608 0l-.875.904a.25.25 0 0 1-.289.051l-.577-.281a.25.25 0 0 1-.138-.26l.165-1.18a3.015 3.015 0 0 1-.512-.607l-1.115-.098a.25.25 0 0 1-.222-.193l-.144-.626a.25.25 0 0 1 .115-.27l.898-.54c.013-.334.08-.653.193-.95zM6.789 8.023A12.845 12.845 0 0 0 6 8c-5.036 0-6 2.74-6 4.48C0 14.22.076 15 6 15c.553 0 1.055-.006 1.51-.02A5.977 5.977 0 0 1 6 11c0-1.083.287-2.1.79-2.977zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zM12 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="admin" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.162 2.5a3.5 3.5 0 0 1-3.163 5.479L6.08 14.766a1.5 1.5 0 0 1-2.598-1.5L7.4 6.479A3.5 3.5 0 0 1 10.564 1L8.9 3.88l2.599 1.5 1.663-2.88zm-8.63 11.949a.5.5 0 1 0 .5-.866.5.5 0 0 0-.5.866z"/></symbol><symbol viewBox="0 0 16 16" id="angle-double-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.414 7.95l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 1 0 1.414-1.415L10.414 7.95zm-7 0l4.243-4.243a1 1 0 0 0-1.414-1.414l-4.95 4.95a.997.997 0 0 0 0 1.414l4.95 4.95a1 1 0 0 0 1.414-1.415L3.414 7.95z"/></symbol><symbol viewBox="0 0 16 16" id="angle-double-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.536 7.95L1.293 3.707a1 1 0 0 1 1.414-1.414l4.95 4.95a.997.997 0 0 1 0 1.414l-4.95 4.95a1 1 0 1 1-1.414-1.415L5.536 7.95zm7 0L8.293 3.707a1 1 0 0 1 1.414-1.414l4.95 4.95a.997.997 0 0 1 0 1.414l-4.95 4.95a1 1 0 0 1-1.414-1.415l4.243-4.242z"/></symbol><symbol viewBox="0 0 16 16" id="angle-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 10.243l-4.95-4.95a1 1 0 0 0-1.414 1.414l5.657 5.657a.997.997 0 0 0 1.414 0l5.657-5.657a1 1 0 0 0-1.414-1.414L8 10.243z"/></symbol><symbol viewBox="0 0 16 16" id="angle-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.757 8l4.95-4.95a1 1 0 1 0-1.414-1.414L3.636 7.293a.997.997 0 0 0 0 1.414l5.657 5.657a1 1 0 0 0 1.414-1.414L5.757 8z"/></symbol><symbol viewBox="0 0 16 16" id="angle-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.243 8l-4.95-4.95a1 1 0 0 1 1.414-1.414l5.657 5.657a.997.997 0 0 1 0 1.414l-5.657 5.657a1 1 0 0 1-1.414-1.414L10.243 8z"/></symbol><symbol viewBox="0 0 16 16" id="angle-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 6.757l-4.95 4.95a1 1 0 1 1-1.414-1.414l5.657-5.657a.997.997 0 0 1 1.414 0l5.657 5.657a1 1 0 0 1-1.414 1.414L8 6.757z"/></symbol><symbol viewBox="0 0 16 16" id="appearance" xmlns="http://www.w3.org/2000/svg"><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.858a23.85 23.85 0 0 1-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-.023a9.7 9.7 0 0 0 .478-.035c-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"/></symbol><symbol viewBox="0 0 16 16" id="applications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="approval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.536 10.657l2.828-2.829a1 1 0 0 1 1.414 1.415l-3.535 3.535a.997.997 0 0 1-1.415 0l-2.12-2.121A1 1 0 1 1 9.12 9.243l1.415 1.414zM7.632 8.109A2 2 0 0 0 7 11.364l2.121 2.121a1.996 1.996 0 0 0 2.807.021C11.686 14.554 10.627 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.6 0 1.142.038 1.632.109zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol><symbol viewBox="0 0 16 16" id="arrow-down" xmlns="http://www.w3.org/2000/svg"><path d="M10.472 7.282a.862.862 0 0 1 1.26-.006c.357.364.357.958 0 1.285L8.627 11.73A.886.886 0 0 1 8 12a.849.849 0 0 1-.627-.27L4.275 8.561a.904.904 0 0 1-.013-1.285.861.861 0 0 1 1.26-.007l2.486 2.527z"/></symbol><symbol viewBox="0 0 16 16" id="arrow-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9 6H2a2 2 0 1 0 0 4h7v2.586a1 1 0 0 0 1.707.707l4.586-4.586a1 1 0 0 0 0-1.414l-4.586-4.586A1 1 0 0 0 9 3.414V6z"/></symbol><symbol viewBox="0 0 16 16" id="assignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 5V4a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V7h-1a1 1 0 0 1 0-2h1zM5.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"/></symbol><symbol viewBox="0 0 16 16" id="bold" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4 12.5v-9A1.5 1.5 0 0 1 5.5 2h2.104c2.182 0 3.879.681 3.879 2.982 0 1.067-.517 2.227-1.374 2.595v.073C11.176 7.963 12 8.865 12 10.466 12 12.914 10.19 14 7.911 14H5.5A1.5 1.5 0 0 1 4 12.5zm2.376-5.696H7.49c1.164 0 1.665-.552 1.665-1.417 0-.94-.534-1.289-1.649-1.289h-1.13v2.706zm0 5.098h1.341c1.293 0 1.956-.515 1.956-1.62 0-1.049-.647-1.472-1.956-1.472H6.376v3.092z"/></symbol><symbol viewBox="0 0 16 16" id="book" xmlns="http://www.w3.org/2000/svg"><path d="M7 2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2v4.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 7 6.191V2zM5 0h6a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"/></symbol><symbol viewBox="0 0 16 16" id="branch" xmlns="http://www.w3.org/2000/svg"><path d="M6 11.978v.29a2 2 0 1 1-2 0V3.732a2 2 0 1 1 2 0v3.849c.592-.491 1.31-.854 2.15-1.081 1.308-.353 1.875-.882 1.893-1.743a2 2 0 1 1 2.002-.051C12.053 6.54 10.857 7.84 8.67 8.43 7.056 8.867 6.195 9.98 6 11.978zM5 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm6 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="bullhorn" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6.143 10H7V4H3a3 3 0 1 0 0 6h.143l.734 5.141a1 1 0 0 0 .99.859h1.556a.5.5 0 0 0 .495-.57L6.143 10zM8 4c1.034.02 2.039-.274 3.014-.883.727-.455 1.836-1.334 3.328-2.637A1 1 0 0 1 16 1.233v10.764a1 1 0 0 1-1.595.803c-1.658-1.227-2.788-1.992-3.392-2.294-.781-.39-1.785-.559-3.013-.506V4z"/></symbol><symbol viewBox="0 0 16 16" id="calendar" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12 2h2a2 2 0 0 1 2 2H0a2 2 0 0 1 2-2h2V1a1 1 0 1 1 2 0v1h4V1a1 1 0 1 1 2 0v1zM0 4h16v9a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V4zm2 2.5V13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5zM5 8h2a1 1 0 1 1 0 2H5a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="cancel" xmlns="http://www.w3.org/2000/svg"><path d="M3.11 4.523a6 6 0 0 0 8.367 8.367L3.109 4.524zM4.522 3.11l8.368 8.368A6 6 0 0 0 4.524 3.11zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol><symbol viewBox="0 0 16 16" id="chart" xmlns="http://www.w3.org/2000/svg"><path d="M15 14a1 1 0 0 1 0 2H2a2 2 0 0 1-2-2V1a1 1 0 1 1 2 0v13h13zM3.142 8.735l2.502-2.561a.5.5 0 0 1 .714-.003L8 7.833l3.592-4.553a.5.5 0 0 1 .796.015l2.516 3.454a.5.5 0 0 1 .096.295V12.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5V9.085a.5.5 0 0 1 .142-.35z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.078 8.2l3.535-3.536a2 2 0 0 1 2.828 2.828l-4.949 4.95c-.39.39-.902.586-1.414.586a1.994 1.994 0 0 1-1.414-.586l-4.95-4.95a2 2 0 1 1 2.828-2.828l3.536 3.535z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-left" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.977 7.998l3.535-3.535a2 2 0 1 0-2.828-2.828l-4.95 4.949c-.39.39-.586.902-.586 1.414 0 .512.196 1.024.586 1.414l4.95 4.95a2 2 0 1 0 2.828-2.828L7.977 7.998z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-right" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.22 7.998L4.683 4.463a2 2 0 0 1 2.828-2.828l4.95 4.949c.39.39.586.902.586 1.414a1.99 1.99 0 0 1-.586 1.414l-4.95 4.95a2 2 0 0 1-2.828-2.828l3.535-3.536z"/></symbol><symbol viewBox="0 0 16 16" id="chevron-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.778 8.957l3.535 3.535a2 2 0 1 0 2.828-2.828l-4.949-4.95a1.994 1.994 0 0 0-1.414-.586c-.512 0-1.024.196-1.414.586l-4.95 4.95a2 2 0 1 0 2.828 2.828l3.536-3.535z"/></symbol><symbol viewBox="0 0 16 16" id="clock" xmlns="http://www.w3.org/2000/svg"><path d="M9 7h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V5a1 1 0 1 1 2 0v2zm-1 9A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm0-2A6 6 0 1 0 8 2a6 6 0 0 0 0 12z"/></symbol><symbol viewBox="0 0 16 16" id="close" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9.414 8l4.95-4.95a1 1 0 0 0-1.414-1.414L8 6.586l-4.95-4.95A1 1 0 0 0 1.636 3.05L6.586 8l-4.95 4.95a1 1 0 1 0 1.414 1.414L8 9.414l4.95 4.95a1 1 0 1 0 1.414-1.414L9.414 8z"/></symbol><symbol viewBox="0 0 16 16" id="code" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M15.871 8.243a.997.997 0 0 0-.293-.707L12.75 4.707a1 1 0 0 0-1.414 1.414l2.12 2.122-2.12 2.121a1 1 0 0 0 1.414 1.414l2.828-2.828a.997.997 0 0 0 .293-.707zm-13.243 0L4.75 6.12a1 1 0 1 0-1.414-1.414L.507 7.536a.997.997 0 0 0 0 1.414l2.829 2.828a1 1 0 1 0 1.414-1.414L2.628 8.243zm6.407-4.107a1 1 0 0 1 .707 1.225L8.19 11.157a1 1 0 1 1-1.931-.518L7.81 4.843a1 1 0 0 1 1.224-.707z"/></symbol><symbol viewBox="0 0 9 13" id="collapse"><path d="M.084.25C.01.18-.015.12.008.071.031.024.093 0 .194 0h8.521c.1 0 .162.024.185.072.023.048-.002.107-.075.177l-4.11 3.935a.372.372 0 0 1-.11.072h-.301a.508.508 0 0 1-.11-.072L.084.249zM.377 6.88a.364.364 0 0 1-.26-.105.334.334 0 0 1-.11-.25v-.709c0-.096.036-.179.11-.249a.364.364 0 0 1 .26-.105h8.15c.101 0 .188.035.261.105.074.07.11.153.11.25v.709c0 .096-.036.179-.11.249a.364.364 0 0 1-.26.105H.377zM.084 12.132c-.074.07-.099.129-.076.177.023.048.085.072.186.072h8.521c.1 0 .162-.024.185-.072.023-.048-.002-.107-.075-.177l-4.11-3.935a.372.372 0 0 0-.11-.072h-.301a.508.508 0 0 0-.11.072l-4.11 3.935z"/></symbol><symbol viewBox="0 0 16 16" id="comment" xmlns="http://www.w3.org/2000/svg"><path d="M1.707 15.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.414l-3.707 3.707zM2 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"/></symbol><symbol viewBox="0 0 16 16" id="comment-dots" xmlns="http://www.w3.org/2000/svg"><path d="M1.707 15.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.414l-3.707 3.707zM2 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.586zM5 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="comment-next" xmlns="http://www.w3.org/2000/svg"><path d="M8 5V4a.5.5 0 0 1 .8-.4l2.667 2a.5.5 0 0 1 0 .8L8.8 8.4A.5.5 0 0 1 8 8V7H6a1 1 0 1 1 0-2h2zM1.707 15.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.414l-3.707 3.707zM2 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"/></symbol><symbol viewBox="0 0 16 16" id="comments" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.75 10L0 13V3a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3.75zM13 5h1a2 2 0 0 1 2 2v8l-2.667-2H8a2 2 0 0 1-2-2h4a3 3 0 0 0 3-3V5z"/></symbol><symbol viewBox="0 0 16 16" id="commit" xmlns="http://www.w3.org/2000/svg"><path d="M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3.876-1.008a4.002 4.002 0 0 1-7.752 0A1.01 1.01 0 0 1 4 9H1a1 1 0 1 1 0-2h3c.042 0 .083.003.124.008a4.002 4.002 0 0 1 7.752 0A1.01 1.01 0 0 1 12 7h3a1 1 0 0 1 0 2h-3a1.01 1.01 0 0 1-.124-.008z"/></symbol><symbol viewBox="0 0 16 16" id="credit-card" xmlns="http://www.w3.org/2000/svg"><path d="M14 5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1h12zm0 3H2v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm6.5 8h3a.5.5 0 1 1 0 1h-3a.5.5 0 1 1 0-1z"/></symbol><symbol viewBox="0 0 16 16" id="cut" xmlns="http://www.w3.org/2000/svg"><rect width="16" height="2" y="7" fill-rule="evenodd" rx="1"/></symbol><symbol viewBox="0 0 16 16" id="dashboard" xmlns="http://www.w3.org/2000/svg"><path d="M7.709 10.021l.696-2.6a.5.5 0 0 1 .966.26l-.657 2.45A2 2 0 0 1 10 12H6a2 2 0 0 1 1.709-1.979zM0 8.9a8 8 0 0 1 15.998 0H16v3.6a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5V8.9zM14 9A6 6 0 1 0 2 9v3.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V9zM3.5 9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-7-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm5 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z"/></symbol><symbol viewBox="0 0 16 16" id="disk" xmlns="http://www.w3.org/2000/svg"><path d="M16 11.764V3a3 3 0 0 0-3-3H3a3 3 0 0 0-3 3v8.764A2.989 2.989 0 0 1 2 11V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v8c.768 0 1.47.289 2 .764zM2 12h12a2 2 0 1 1 0 4H2a2 2 0 1 1 0-4zm10 1a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="doc_code" xmlns="http://www.w3.org/2000/svg"><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.414zm1.036 7.607a.498.498 0 0 1-.147.354l-1.414 1.414a.5.5 0 0 1-.707-.707l1.06-1.06-1.06-1.061a.5.5 0 0 1 .707-.707l1.414 1.414a.498.498 0 0 1 .147.353zm-4.822 0l1.06 1.061a.5.5 0 0 1-.706.707l-1.414-1.414a.498.498 0 0 1 0-.707l1.414-1.414a.5.5 0 1 1 .707.707l-1.06 1.06zM5 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-4z"/></symbol><symbol viewBox="0 0 16 16" id="doc_image" xmlns="http://www.w3.org/2000/svg"><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.414zM7.333 9.667l1.313-1.313a.5.5 0 0 1 .708 0L12 11H4l2.188-1.75a.5.5 0 0 1 .624 0l.521.417zM5 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 8a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM4 11h8v.7a.3.3 0 0 1-.3.3H4.3a.3.3 0 0 1-.3-.3V11z"/></symbol><symbol viewBox="0 0 16 16" id="doc_text" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 105 26" id="double-headed-arrow" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1.018 11.089L15.138.614c1.23-.911 3.086-.795 4.147.26.461.46.715 1.045.715 1.651v20.95C20 24.869 18.684 26 17.06 26a3.238 3.238 0 0 1-1.921-.614L1.019 14.911C-.212 14-.347 12.405.714 11.35c.094-.094.195-.18.303-.261zm102.964 0c.108.08.21.167.303.26 1.061 1.056.925 2.65-.303 3.562l-14.12 10.475A3.238 3.238 0 0 1 87.94 26C86.316 26 85 24.87 85 23.475V2.525c0-.606.254-1.192.715-1.65 1.061-1.056 2.917-1.172 4.146-.26l14.12 10.474zM35 17a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm18 0a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm18 0a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"/></symbol><symbol viewBox="0 0 16 16" id="download" xmlns="http://www.w3.org/2000/svg"><path d="M9 12h1a.5.5 0 0 1 .4.8l-2 2.667a.5.5 0 0 1-.8 0l-2-2.667A.5.5 0 0 1 6 12h1V8a1 1 0 1 1 2 0v4zM4 9a1 1 0 1 1 0 2 4 4 0 0 1-1.971-7.481 4 4 0 0 1 6.633-2.505 3.999 3.999 0 0 1 3.82 2.014A4 4 0 0 1 12 11a1 1 0 0 1 0-2 2 2 0 1 0 0-4h-1a2 2 0 0 0-3.112-1.662A2 2 0 1 0 4.268 5H4a2 2 0 1 0 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="duplicate" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-3a1 1 0 0 1-1-1V6H8.527A.527.527 0 0 0 8 6.527V13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-3zm-4-7H8.527c-.18 0-.355.013-.527.04V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2v2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3zM8.527 4h2.323a.5.5 0 0 1 .35.143l4.65 4.551a.5.5 0 0 1 .15.357V13a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V6.527A2.527 2.527 0 0 1 8.527 4z"/></symbol><symbol viewBox="0 0 16 16" id="earth" xmlns="http://www.w3.org/2000/svg"><path d="M8.7 2.04l-.082.177c.283.223.422.413.417.571-.008.237-.311.057-.444.274-.133.218.038.542-.112.637-.15.096-.398-.386-.479-.46-.054-.049-.166-.257-.336-.625l-.216-.225a.844.844 0 0 0-.418-.035c-.177.038-.075.1-.035.132.04.032.32.037.452.2.132.164.03.224-.05.298-.054.05-.157.062-.31.035H5.952l-.402.398.03.325.229.455.324-.463c.008-.206.058-.342.15-.41.14-.1.342-.15.534-.085.191.066-.057.218.011.271.068.053.204-.098.313-.02.11.08.07.155.104.322.036.167.254.114.398.328.144.215.19.29.147.483-.043.195-.168.26-.305.232-.138-.028-.107-.246-.275-.348-.168-.102-.266-.114-.386-.054-.12.06-.016.129.023.235.04.106.274.321.224.43-.05.107-.108.116-.42 0-.21-.077-.414-.007-.615.212l-.76.722c-.153.715-.3 1.13-.44 1.243-.211.17-.177-.483-.483-.656-.306-.174-.494-.047-.8-.07-.307-.023-.42.65-.38.873a.434.434 0 0 0 .221.321c.236-.141.39-.184.465-.128.11.084-.144.267-.074.425.07.158.314.069.386.283.073.213.084.48-.05.706-.135.227-.275.178-.4.053-.127-.126-.033-.375-.255-.704-.223-.329-.381-.337-.63-.787-.158-.287-.35-.743-.575-1.366a6 6 0 0 0 3.21 7.198l.001-.075c0-.577-.004-.944-.012-1.102-.011-.236-.95-.945-1.104-1.2-.154-.256-.34-.595-.355-.746-.016-.151.185-.232.344-.325.16-.093-.11-.367.028-.626.137-.258.395-.438.496-.356.101.081.058.228.267.333.209.104.077-.213.456-.178.38.035.143.201.252.216.11.016.113-.127.299-.143.186-.015.282.445.471.622.19.178.452.008.611.043.159.034.267.09.402.255.136.166-.03.352.073.557.103.205 1.07.22 1.433.255.364.034.371.011.371.324s-.166.314-.453.507c-.286.193-.166.462-.38.762-.212.3-.316.062-.622.14-.306.077-.413.382-.452.568-.039.186-.386.094-.877.232-.29.082-.429.144-.569.204a6.002 6.002 0 0 0 7.682-4.3c-.094-.384-.18-.63-.258-.74-.213-.297-.36.21-.924.49-.564.278-.57-.288-.81-.49-.16-.133-.212-.44-.158-.92-.005-.478.02-.828.077-1.049.057-.221.126-.543.207-.965.351-.373.606-.572.764-.595.237-.034.336.374.658.3a.315.315 0 0 0 .035-.01 5.993 5.993 0 0 0-.475-.824l-.309-.043a.646.646 0 0 0-.332-.117c-.205-.02-.025.128-.089.24-.064.112-.235.724-.437.685-.201-.039-.204-.374-.17-.668.036-.294-.077-.35-.2-.412-.124-.062-.325-.213-.556-.295-.232-.082-.123-.175-.093-.274.03-.1.208-.015.193-.058-.014-.044-.313-.135-.266-.167.03-.02.2-.02.506.003l.216-.012.293-.163a.58.58 0 0 0-.376-.22c-.233-.036-.513-.034-.73-.142-.205-.103-.458-.36-.643-.638A5.965 5.965 0 0 0 8.7 2.04zM8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/></symbol><symbol viewBox="0 0 1600 1600" id="ellipsis_v" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 18 18" id="emoji_slightly_smiling_face" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445 2.91 2.91 0 0 0 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 18 18" id="emoji_smile" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 18 18" id="emoji_smiley" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z"/></symbol><symbol viewBox="0 0 16 16" id="epic" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M14.985 8.044l-.757 2.272a1 1 0 0 1-.949.684H1.618a1 1 0 0 1-.894-1.447l.318-.637A2 2 0 0 0 1.618 9h11.661a2 2 0 0 0 1.706-.956zm0 3l-.757 2.272a1 1 0 0 1-.949.684H1.618a1 1 0 0 1-.894-1.447l.318-.637a2 2 0 0 0 .576.084h11.661a2 2 0 0 0 1.706-.956zM3.618 2h10.995a1 1 0 0 1 .948 1.316l-1.333 4a1 1 0 0 1-.949.684H1.618a1 1 0 0 1-.894-1.447l2-4A1 1 0 0 1 3.618 2zm-.382 4h9.322l.667-2H4.236l-1 2z"/></symbol><symbol viewBox="0 0 16 16" id="external-link" xmlns="http://www.w3.org/2000/svg"><path d="M13.121 4.177l-4.95 4.95a1 1 0 1 1-1.414-1.414l4.95-4.95-1.386-1.386a.5.5 0 0 1 .299-.85l4.709-.524a.5.5 0 0 1 .552.552l-.523 4.71a.5.5 0 0 1-.851.297l-1.386-1.385zM12 8.884a1 1 0 0 1 2 0v4a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3v-8a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-4z"/></symbol><symbol viewBox="0 0 16 16" id="eye" xmlns="http://www.w3.org/2000/svg"><path d="M8 14C4.816 14 2.253 12.284.393 8.981a2 2 0 0 1 0-1.962C2.253 3.716 4.816 2 8 2s5.747 1.716 7.607 5.019a2 2 0 0 1 0 1.962C13.747 12.284 11.184 14 8 14zm0-2c2.41 0 4.338-1.29 5.864-4C12.338 5.29 10.411 4 8 4 5.59 4 3.662 5.29 2.136 8 3.662 10.71 5.589 12 8 12zm0-1a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm1-3a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="eye-slash" xmlns="http://www.w3.org/2000/svg"><path d="M13.618 2.62L1.62 14.619a1 1 0 0 1-.985-1.668l1.525-1.526C1.516 10.742.926 9.927.393 8.981a2 2 0 0 1 0-1.962C2.253 3.716 4.816 2 8 2c1.074 0 2.076.195 3.006.58l.944-.944a1 1 0 0 1 1.668.985zM8.068 11a3 3 0 0 0 2.931-2.932l-2.931 2.931zm-3.02-2.462a3 3 0 0 1 3.49-3.49l.884-.884A6.044 6.044 0 0 0 8 4C5.59 4 3.662 5.29 2.136 8c.445.79.924 1.46 1.439 2.011l1.473-1.473zm.421 5.06l1.658-1.658c.283.04.575.06.873.06 2.41 0 4.338-1.29 5.864-4a11.023 11.023 0 0 0-1.133-1.664l1.418-1.418a12.799 12.799 0 0 1 1.458 2.1 2 2 0 0 1 0 1.963C13.747 12.284 11.184 14 8 14a7.883 7.883 0 0 1-2.53-.402z"/></symbol><symbol viewBox="0 0 16 16" id="file-addition" xmlns="http://www.w3.org/2000/svg"><path d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3z"/></symbol><symbol viewBox="0 0 16 16" id="file-deletion" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm2 6h6a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="file-modified" xmlns="http://www.w3.org/2000/svg"><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 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3zm5 4a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></symbol><symbol viewBox="0 0 16 16" id="filter" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 6v9l-3.724-1.862A.5.5 0 0 1 6 12.691V6L1.854 1.854A.5.5 0 0 1 2.207 1h11.586a.5.5 0 0 1 .353.854L10 6z"/></symbol><symbol viewBox="0 0 16 16" id="folder" xmlns="http://www.w3.org/2000/svg"><path d="M7.228 5l-.475-1.335A1 1 0 0 0 5.81 3H2v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H7.228zM13 3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a2 2 0 0 1 2-2h3.81a3 3 0 0 1 2.827 1.995L13 3z"/></symbol><symbol viewBox="0 0 16 16" id="fork" xmlns="http://www.w3.org/2000/svg"><path d="M9 12.268a2 2 0 1 1-2 0V8.874A4.002 4.002 0 0 1 4 5V3.732a2 2 0 1 1 2 0V5a2 2 0 1 0 4 0V3.732a2 2 0 1 1 2 0V5a4.002 4.002 0 0 1-3 3.874v3.394zM11 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="geo-nodes" xmlns="http://www.w3.org/2000/svg"><path d="M9.7 13.1l-.2.2c-.7.8-2 .9-2.8.1-.1 0-.1-.1-.1-.1l-.2-.2c-2 .2-3.4.7-3.4 1.4 0 .8 2.2 1.5 5 1.5s5-.7 5-1.5c0-.7-1.4-1.2-3.3-1.4M7.3 12.7c.4.4 1 .3 1.4-.1C11.6 9.5 13 7 13 5.3 13 2.4 10.8 0 8 0S3 2.4 3 5.3C3 7 4.4 9.5 7.3 12.7M8 2c1.6 0 3 1.4 3 3.3 0 1-1 2.8-3 5.2-2-2.4-3-4.2-3-5.2C5 3.4 6.4 2 8 2"/><circle cx="8" cy="5" r="1"/></symbol><symbol viewBox="0 0 16 16" id="git-merge" xmlns="http://www.w3.org/2000/svg"><path d="M11 12.268V5a1 1 0 0 0-1-1v1a.5.5 0 0 1-.8.4l-2.667-2a.5.5 0 0 1 0-.8L9.2.6a.5.5 0 0 1 .8.4v1a3 3 0 0 1 3 3v7.268a2 2 0 1 1-2 0zm-6 0a2 2 0 1 1-2 0V4.732a2 2 0 1 1 2 0v7.536zM4 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm8 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="group" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.048 11.997C-.377 11.975.013 11.782.013 10.56.013 9.235.653 8 4 8c.444 0 .84.022 1.194.062.164.435.426.82.76 1.132-1.786.389-2.721 1.353-2.906 2.803zm2.94-7.222a2.993 2.993 0 0 0-.976 1.95 2 2 0 1 1 .975-1.95zm6.964 7.222c-.185-1.45-1.12-2.414-2.906-2.803.334-.311.596-.697.76-1.132C11.16 8.022 11.556 8 12 8c3.346 0 3.987 1.235 3.987 2.56 0 1.222.39 1.415-3.035 1.437zm-1.964-5.272a2.993 2.993 0 0 0-.976-1.95 2 2 0 1 1 .976 1.95zM8 9a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 5c-2.177 0-3.987-.115-3.987-1.44S4.653 10 8 10c3.346 0 3.987 1.235 3.987 2.56S10.177 14 8 14z"/></symbol><symbol viewBox="0 0 16 16" id="history" xmlns="http://www.w3.org/2000/svg"><path d="M2.868 3.24a7 7 0 1 1-.043 9.475 1 1 0 0 1 1.478-1.348 5 5 0 1 0 .124-6.865l.796.645a.5.5 0 0 1-.193.873l-3.232.814a.5.5 0 0 1-.622-.504L1.3 3a.5.5 0 0 1 .814-.37l.754.61zM9 8h1a1 1 0 0 1 0 2H8a.997.997 0 0 1-1-1V6a1 1 0 1 1 2 0v2z"/></symbol><symbol viewBox="0 0 16 16" id="home" xmlns="http://www.w3.org/2000/svg"><path d="M8.462 2.177a.505.505 0 0 1-.038.044l.038-.044zm-.787 0l.038.043a.5.5 0 0 1-.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"/></symbol><symbol viewBox="0 0 16 16" id="hook" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="hourglass" xmlns="http://www.w3.org/2000/svg"><path d="M10.331 4.889A2.988 2.988 0 0 0 11 3V2H5v1c0 .362.064.709.182 1.03l5.15.859zM3 14v-1c0-1.78.93-3.342 2.33-4.228.447-.327.67-.582.67-.764 0-.19-.242-.46-.725-.815A4.996 4.996 0 0 1 3 3V2H2a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2h-1v1a4.997 4.997 0 0 1-2.39 4.266c-.407.3-.61.545-.61.734 0 .19.203.434.61.734A4.997 4.997 0 0 1 13 13v1h1a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2h1zm8 0v-1a3 3 0 0 0-6 0v1h6z"/></symbol><symbol viewBox="0 0 38 38" id="image-comment-dark" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#1F78D1"/><path fill="#FFF" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></symbol><symbol viewBox="0 0 38 38" id="image-comment-light" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#FFF"/><path fill="#1F78D1" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></symbol><symbol viewBox="0 0 16 16" id="import" xmlns="http://www.w3.org/2000/svg"><path d="M9 8h1a.5.5 0 0 1 .4.8l-2 2.667a.5.5 0 0 1-.8 0L5.6 8.8A.5.5 0 0 1 6 8h1V1a1 1 0 1 1 2 0v7zM0 8a1 1 0 1 1 2 0 6 6 0 1 0 12 0 1 1 0 0 1 2 0A8 8 0 1 1 0 8z"/></symbol><symbol viewBox="0 0 16 16" id="issue-block" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.803 8a5.97 5.97 0 0 0-.462 1H4.5a.5.5 0 0 1 0-1h1.303zM4.5 5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1zm7.5.083a6.04 6.04 0 0 0-2 0V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.083a5.96 5.96 0 0 0 .72 2H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v2.083zm1.121 3.796zM11 16a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm-1.293-2.292a3 3 0 0 0 4.001-4.001l-4.001 4zm-1.415-1.415l4.001-4a3 3 0 0 0-4.001 4.001z"/></symbol><symbol viewBox="0 0 16 16" id="issue-child" xmlns="http://www.w3.org/2000/svg"><path d="M11 8H5v1h1a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2V7a.997.997 0 0 1 1-1h3V4H4.5a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9v2h3a.997.997 0 0 1 1 1v2h2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h1V8zm-9 3v2h3v-2H2zm9 0v2h3v-2h-3z"/></symbol><symbol viewBox="0 0 16 16" id="issue-close" xmlns="http://www.w3.org/2000/svg"><path d="M7.536 8.657l2.828-2.829a1 1 0 0 1 1.414 1.415l-3.535 3.535a.997.997 0 0 1-1.415 0l-2.12-2.121A1 1 0 0 1 6.12 7.243l1.415 1.414zM8 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 12z"/></symbol><symbol viewBox="0 0 16 16" id="issue-duplicate" xmlns="http://www.w3.org/2000/svg"><path d="M10.874 2H12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-2c-.918 0-1.74-.413-2.29-1.063a3.987 3.987 0 0 0 1.988-.984A1 1 0 0 0 10 14h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-1V3c0-.345-.044-.68-.126-1zM4 0h3a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H4z"/></symbol><symbol viewBox="0 0 16 16" id="issue-new" xmlns="http://www.w3.org/2000/svg"><path d="M10 2V1a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2h-1v1a1 1 0 0 1-2 0V4H9a1 1 0 1 1 0-2h1zm0 6a1 1 0 0 1 2 0v5a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h1a1 1 0 1 1 0 2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V8z"/></symbol><symbol viewBox="0 0 16 16" id="issue-open" xmlns="http://www.w3.org/2000/svg"><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 12zm0-2a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="issue-open-m" xmlns="http://www.w3.org/2000/svg"><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 12z"/></symbol><symbol viewBox="0 0 16 16" id="issue-parent" xmlns="http://www.w3.org/2000/svg"><path d="M11 11H5v1h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H3v-2a.997.997 0 0 1 1-1h3V7H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H9v2h3a.997.997 0 0 1 1 1v2h2.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5H11v-1zM6 3v2h4V3H6z"/></symbol><symbol viewBox="0 0 16 16" id="issues" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="italic" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.5 12l2-8H6a1 1 0 1 1 0-2h6a1 1 0 0 1 0 2h-1.5l-2 8H10a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2h1.5z"/></symbol><symbol viewBox="0 0 16 16" id="key" xmlns="http://www.w3.org/2000/svg"><path d="M7.575 6.689a4.002 4.002 0 0 1 6.274-4.86 4 4 0 0 1-4.86 6.274l-2.21 2.21.706.708a1 1 0 1 1-1.414 1.414l-.707-.707-.707.707.707.707a1 1 0 1 1-1.414 1.414l-.707-.707a1 1 0 0 1-1.414-1.414l5.746-5.746zm2.032-.618a2 2 0 1 0 2.828-2.828A2 2 0 0 0 9.607 6.07z"/></symbol><symbol viewBox="0 0 16 16" id="key-2" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="label" xmlns="http://www.w3.org/2000/svg"><path d="M11.782 14.718a3 3 0 0 1-4.242 0L1.652 8.829a2 2 0 0 1-.565-1.702l.54-3.703a2 2 0 0 1 1.69-1.69l3.703-.54a2 2 0 0 1 1.703.564l5.888 5.888a3 3 0 0 1 0 4.243l-2.829 2.829zm1.415-5.657L7.309 3.173l-3.703.54-.54 3.702 5.888 5.888a1 1 0 0 0 1.414 0l2.829-2.828a1 1 0 0 0 0-1.414zM5.732 5.525A1 1 0 1 1 7.146 6.94a1 1 0 0 1-1.414-1.414z"/></symbol><symbol viewBox="0 0 16 16" id="labels" xmlns="http://www.w3.org/2000/svg"><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-.667zM.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"/></symbol><symbol viewBox="0 0 16 16" id="leave" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V5.883a.5.5 0 0 1 .757-.429l3.528 2.117a.5.5 0 0 1 0 .858l-3.528 2.117a.5.5 0 0 1-.757-.43V9H7a1 1 0 1 1 0-2h4zm-2 6.256a1 1 0 0 1 2 0A2.744 2.744 0 0 1 8.256 16H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h5.19A2.81 2.81 0 0 1 11 2.81a1 1 0 0 1-2 0A.81.81 0 0 0 8.19 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h5.256c.41 0 .744-.333.744-.744z"/></symbol><symbol viewBox="0 0 16 16" id="level-up" xmlns="http://www.w3.org/2000/svg"><path fill="#2E2E2E" fill-rule="evenodd" d="M7 6h3.489a.5.5 0 0 0 .373-.832L6.374.117a.5.5 0 0 0-.748 0l-4.488 5.05A.5.5 0 0 0 1.51 6H5v7a3 3 0 0 0 3 3h6a1 1 0 0 0 0-2H8a1 1 0 0 1-1-1V6z"/></symbol><symbol viewBox="0 0 16 16" id="license" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.56 8.9l2.66 4.606a.3.3 0 0 1-.243.45l-1.678.094a.1.1 0 0 0-.078.044l-.953 1.432a.3.3 0 0 1-.51-.016L9.097 10.9a5.994 5.994 0 0 0 3.464-2zm-5.23 2.063L4.707 15.51a.3.3 0 0 1-.51.016l-.953-1.432a.1.1 0 0 0-.078-.044l-1.678-.094a.3.3 0 0 1-.243-.45l2.48-4.297a5.983 5.983 0 0 0 3.607 1.754zM8 10A5 5 0 1 1 8 0a5 5 0 0 1 0 10zm0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-1a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="link" xmlns="http://www.w3.org/2000/svg"><path d="M6.986 3.35l2.12-2.122a4 4 0 0 1 5.657 5.657l-2.828 2.829a4 4 0 0 1-5.657 0 1 1 0 0 1 1.414-1.415 2 2 0 0 0 2.829 0l2.828-2.828a2 2 0 1 0-2.828-2.828l-1.001 1a5.018 5.018 0 0 0-2.534-.294zm2.12 9.192l-2.12 2.121a4 4 0 1 1-5.658-5.656l2.829-2.829a4 4 0 0 1 5.657 0 1 1 0 1 1-1.415 1.414 2 2 0 0 0-2.828 0l-2.828 2.829a2 2 0 1 0 2.828 2.828l1.001-1.001a5.018 5.018 0 0 0 2.534.294z"/></symbol><symbol viewBox="0 0 16 16" id="list-bulleted" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-7h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm0 5h10a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm-4 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4-2h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="list-numbered" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6 2h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2zm0 5h8a1 1 0 0 1 0 2H6a1 1 0 0 1 0-2zM1.156 5v-.828h.816V2.204h-.72v-.636c.432-.084.708-.192.996-.372h.756v2.976h.684V5H1.156zm-.18 5v-.588c.9-.828 1.596-1.464 1.596-1.98 0-.342-.192-.504-.468-.504-.252 0-.444.18-.624.36l-.552-.552c.396-.42.756-.612 1.32-.612.768 0 1.308.492 1.308 1.248 0 .612-.576 1.284-1.092 1.812.192-.024.468-.048.636-.048h.636V10H.976zm1.26 5.072c-.618 0-1.068-.204-1.356-.54l.468-.648c.234.216.51.36.78.36.336 0 .552-.12.552-.36 0-.288-.15-.456-.948-.456v-.72c.636 0 .828-.168.828-.432 0-.228-.138-.348-.396-.348-.252 0-.432.108-.672.312l-.516-.624c.372-.312.768-.492 1.236-.492.84 0 1.38.384 1.38 1.074 0 .366-.204.642-.612.822v.024c.432.132.732.432.732.912 0 .72-.684 1.116-1.476 1.116z"/></symbol><symbol viewBox="0 0 16 16" id="location" xmlns="http://www.w3.org/2000/svg"><path d="M8.755 15.144a1 1 0 0 1-1.51 0C3.748 11.114 2 8.065 2 6a6 6 0 1 1 12 0c0 2.065-1.748 5.113-5.245 9.144zM12 6a4 4 0 1 0-8 0c0 1.314 1.312 3.71 4 6.944C10.688 9.71 12 7.314 12 6zM8 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="location-dot" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6.314 13.087C4.382 13.295 3 13.85 3 14.5c0 .828 2.239 1.5 5 1.5s5-.672 5-1.5c0-.65-1.382-1.205-3.314-1.413l-.202.225a2 2 0 0 1-2.968 0l-.202-.225zm2.428-.445a1 1 0 0 1-1.484 0C4.419 9.5 3 7.037 3 5.252 3 2.353 5.239 0 8 0s5 2.352 5 5.253c0 1.784-1.42 4.247-4.258 7.389zM11 5.252C11 3.436 9.634 2 8 2S5 3.435 5 5.253c0 1.027.974 2.824 3 5.203 2.026-2.38 3-4.176 3-5.203zM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="lock" xmlns="http://www.w3.org/2000/svg"><path d="M10 5V4h2v1a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3V4h2v1h4zM4 7a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H4zm0-3a4 4 0 1 1 8 0h-2a2 2 0 1 0-4 0H4z"/></symbol><symbol viewBox="0 0 16 16" id="lock-open" xmlns="http://www.w3.org/2000/svg"><path d="M4.044 4a4 4 0 0 1 6.99-2.658 1 1 0 1 1-1.495 1.33A2 2 0 0 0 6.044 4a.998.998 0 0 1-.07.367v.701H12a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3v-5a3 3 0 0 1 2.974-3V4h.07zM4 7.07a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H4z"/></symbol><symbol viewBox="0 0 16 16" id="log" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="mail" xmlns="http://www.w3.org/2000/svg"><path d="M14 5.6L9.338 9.796a2 2 0 0 1-2.676 0L2 5.6V11a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5.6zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm.212 2L8 8.31 12.788 4H3.212z"/></symbol><symbol viewBox="0 0 16 16" id="menu" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1.143 2h13.714C15.488 2 16 2.448 16 3s-.512 1-1.143 1H1.143C.512 4 0 3.552 0 3s.512-1 1.143-1zm0 5h13.714C15.488 7 16 7.448 16 8s-.512 1-1.143 1H1.143C.512 9 0 8.552 0 8s.512-1 1.143-1zm0 5h13.714c.631 0 1.143.448 1.143 1s-.512 1-1.143 1H1.143C.512 14 0 13.552 0 13s.512-1 1.143-1z"/></symbol><symbol viewBox="0 0 16 16" id="merge-request-close" xmlns="http://www.w3.org/2000/svg"><path d="M9.414 8l1.414 1.414a1 1 0 1 1-1.414 1.414L8 9.414l-1.414 1.414a1 1 0 1 1-1.414-1.414L6.586 8 5.172 6.586a1 1 0 1 1 1.414-1.414L8 6.586l1.414-1.414a1 1 0 1 1 1.414 1.414L9.414 8zM8 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 12z"/></symbol><symbol viewBox="0 0 16 16" id="messages" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="mobile-issue-close" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5.657 10.728L2.12 7.192A1 1 0 1 0 .707 8.607l4.243 4.242a.997.997 0 0 0 1.414 0l8.485-8.485a1 1 0 1 0-1.414-1.414l-7.778 7.778z"/></symbol><symbol viewBox="0 0 16 16" id="monitor" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="more" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 4a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></symbol><symbol viewBox="0 0 16 16" id="notifications" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="notifications-off" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.26 5.089c.243.757.382 1.478.5 2.017.187.86.74 2.188 1.66 3.982A2 2 0 0 1 13.64 14H10a2 2 0 1 1-4 0H4.35l2-2h7.29c-.993-1.937-1.6-3.396-1.835-4.468-.07-.326-.129-.59-.178-.81l1.634-1.633zM10.943 1.75l-1.48 1.48C9.07 3.076 8.612 3 8.069 3c-1.608 0-2.49.718-3.103 2.197-.28.676-.356.982-.654 2.428-.065.317-.17.673-.317 1.073L.45 12.242a1.99 1.99 0 0 1 .224-1.19c.962-1.787 1.521-3.064 1.68-3.831.322-1.566.947-5.501 4.65-6.134a1 1 0 1 1 1.994-.024 4.867 4.867 0 0 1 1.944.688zm2.932-.105a1 1 0 0 1 0 1.415L2.561 14.374a1 1 0 1 1-1.415-1.414L12.46 1.646a1 1 0 0 1 1.414 0z"/></symbol><symbol viewBox="0 0 16 16" id="overview" xmlns="http://www.w3.org/2000/svg"><path d="M2 0h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm0 2v3h3V2H2zm9-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-2zm0 2v3h3V2h-3zM2 9h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2zm0 2v3h3v-3H2zm9-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-2zm0 2v3h3v-3h-3z"/></symbol><symbol viewBox="0 0 16 16" id="pencil" xmlns="http://www.w3.org/2000/svg"><path d="M13.02 1.293l1.414 1.414a1 1 0 0 1 0 1.414L4.119 14.436a1 1 0 0 1-.704.293l-2.407.008L1 12.316a1 1 0 0 1 .293-.71L11.605 1.292a1 1 0 0 1 1.414 0zm-1.416 1.415l-.707.707L12.31 4.83l.707-.707-1.414-1.415zM3.411 13.73l1.123-1.122H3.12v-1.415L2 12.312l.005 1.422 1.406-.005z"/></symbol><symbol viewBox="0 0 16 16" id="pencil-square" xmlns="http://www.w3.org/2000/svg"><path d="M12 9a1 1 0 0 1 2 0v4a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9zm.778-7.179l1.414 1.415-6.476 6.476a1 1 0 0 1-.498.27l-1.51.325.323-1.512a1 1 0 0 1 .27-.497l6.477-6.477zM15.607.407a1 1 0 0 1 0 1.414l-.708.707-1.414-1.414.707-.707a1 1 0 0 1 1.415 0z"/></symbol><symbol viewBox="0 0 16 16" id="pipeline" xmlns="http://www.w3.org/2000/svg"><path d="M8.969 7.25a2 2 0 1 1-1.938 0A1.002 1.002 0 0 1 7 7V5.083a.2.2 0 0 1 .06-.142l.877-.87a.1.1 0 0 1 .141 0l.864.87A.2.2 0 0 1 9 5.083V7c0 .086-.01.17-.031.25zM8 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 12zm4.5-4a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-5 9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-9a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm-2 6a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0-3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zM8 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="play" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2.765 15.835c-.545.321-1.258.159-1.593-.363A1.075 1.075 0 0 1 1 14.89V1.11C1 .496 1.518 0 2.158 0c.214 0 .424.057.607.165l11.684 6.89c.544.321.714 1.005.38 1.526a1.135 1.135 0 0 1-.38.364l-11.684 6.89z"/></symbol><symbol viewBox="0 0 16 16" id="plus" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7V1a1 1 0 1 1 2 0v6h6a1 1 0 0 1 0 2H9v6a1 1 0 0 1-2 0V9H1a1 1 0 1 1 0-2h6z"/></symbol><symbol viewBox="0 0 16 16" id="plus-square" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M9 7V4a1 1 0 1 0-2 0v3H4a1 1 0 1 0 0 2h3v3a1 1 0 0 0 2 0V9h3a1 1 0 0 0 0-2H9zM3 0h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z"/></symbol><symbol viewBox="0 0 16 16" id="plus-square-o" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7 7V5a1 1 0 1 1 2 0v2h2a1 1 0 0 1 0 2H9v2a1 1 0 0 1-2 0V9H5a1 1 0 1 1 0-2h2zM3 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-1H3z"/></symbol><symbol viewBox="0 0 16 16" id="preferences" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M5 12h10a1 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-2zm11-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 2h9V7zM6 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 2h2V2z"/></symbol><symbol viewBox="0 0 16 16" id="profile" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="project" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="push-rules" xmlns="http://www.w3.org/2000/svg"><path d="M6.268 9a2 2 0 0 1 3.464 0H11a1 1 0 0 1 0 2H9.732a2 2 0 0 1-3.464 0H5a1 1 0 0 1 0-2h1.268zM7 2H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1h-1v3.515a.3.3 0 0 1-.434.268l-1.432-.716a.3.3 0 0 0-.268 0l-1.432.716A.3.3 0 0 1 7 5.515V2zM4 0h8a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm4 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="question" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm-1.46-5.602h2.233a3.97 3.97 0 0 1 .051-.558c.029-.17.073-.326.133-.469.06-.143.14-.28.242-.41.102-.13.228-.263.38-.399.26-.24.504-.467.733-.683a5.03 5.03 0 0 0 .598-.668c.17-.23.302-.477.399-.742a2.66 2.66 0 0 0 .144-.907c0-.505-.083-.95-.25-1.335a2.55 2.55 0 0 0-.723-.97 3.2 3.2 0 0 0-1.152-.589 5.441 5.441 0 0 0-1.531-.2c-.516 0-.998.063-1.445.188a3.19 3.19 0 0 0-1.168.59c-.331.268-.594.61-.79 1.027-.195.417-.295.917-.3 1.5h2.64c.006-.224.04-.416.102-.578.062-.161.142-.293.238-.394a.921.921 0 0 1 .332-.227 1.04 1.04 0 0 1 .39-.074c.34 0 .593.095.763.285.169.19.254.488.254.895 0 .328-.106.63-.317.906-.21.276-.499.565-.863.867-.214.182-.39.374-.531.574-.141.2-.253.42-.336.657a3.656 3.656 0 0 0-.176.777 7.89 7.89 0 0 0-.05.937zm-.321 2.375c0 .188.035.362.105.524.07.161.17.3.301.418.13.117.284.21.46.277.178.068.376.102.595.102.218 0 .416-.034.593-.102.178-.068.331-.16.461-.277a1.2 1.2 0 0 0 .301-.418c.07-.162.106-.336.106-.524a1.3 1.3 0 0 0-.106-.523 1.2 1.2 0 0 0-.3-.418 1.461 1.461 0 0 0-.462-.277 1.651 1.651 0 0 0-.593-.102c-.22 0-.417.034-.594.102a1.46 1.46 0 0 0-.461.277 1.2 1.2 0 0 0-.3.418 1.284 1.284 0 0 0-.106.523z"/></symbol><symbol viewBox="0 0 16 16" id="question-o" xmlns="http://www.w3.org/2000/svg"><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-.778-4.151c0-.301.014-.575.044-.82a3.2 3.2 0 0 1 .154-.68c.073-.208.17-.4.294-.575.123-.176.278-.343.465-.503a4.81 4.81 0 0 0 .755-.758c.185-.242.277-.506.277-.793 0-.356-.074-.617-.222-.783-.148-.166-.37-.25-.667-.25a.92.92 0 0 0-.342.065.806.806 0 0 0-.29.199 1.04 1.04 0 0 0-.209.345 1.5 1.5 0 0 0-.088.506H5.082c.005-.51.092-.948.263-1.313.171-.364.401-.664.69-.899.29-.234.63-.406 1.023-.516a4.66 4.66 0 0 1 1.264-.164c.497 0 .944.058 1.34.174.397.117.733.289 1.008.517.276.227.487.51.633.847.146.337.218.727.218 1.17 0 .295-.042.56-.126.792a2.52 2.52 0 0 1-.349.65 4.4 4.4 0 0 1-.523.584c-.2.19-.414.389-.642.598a2.73 2.73 0 0 0-.332.349c-.089.114-.16.233-.212.359a1.868 1.868 0 0 0-.116.41 3.39 3.39 0 0 0-.044.489H7.222zm-.28 2.078c0-.164.03-.317.092-.458a1.05 1.05 0 0 1 .263-.366c.114-.103.248-.183.403-.243a1.45 1.45 0 0 1 .52-.089c.191 0 .364.03.52.09.154.059.289.14.403.242.114.103.201.224.263.366.061.141.092.294.092.458 0 .164-.03.316-.092.458a1.05 1.05 0 0 1-.263.365 1.278 1.278 0 0 1-.404.243 1.43 1.43 0 0 1-.52.089c-.19 0-.364-.03-.519-.089-.155-.06-.29-.14-.403-.243a1.05 1.05 0 0 1-.263-.365 1.135 1.135 0 0 1-.093-.458z"/></symbol><symbol viewBox="0 0 16 16" id="quote" xmlns="http://www.w3.org/2000/svg"><path d="M15 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9h-2a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1zM7 3v8a3 3 0 0 1-3 3 1 1 0 0 1 0-2 1 1 0 0 0 1-1V9H3a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3a1 1 0 0 1 1 1z"/></symbol><symbol viewBox="0 0 16 16" id="redo" xmlns="http://www.w3.org/2000/svg"><path d="M4.666 4.423a5 5 0 1 1-.203 6.944 1 1 0 1 0-1.478 1.347 7 7 0 1 0 .12-9.556L1.842 2.137a.5.5 0 0 0-.815.385L1 7.26a.5.5 0 0 0 .607.492l4.629-1.013a.5.5 0 0 0 .207-.877L4.666 4.423z"/></symbol><symbol viewBox="0 0 16 16" id="remove" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 3a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2v10a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V3zm3-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1H5zM4 3v10a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3H4zm2.5 2a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol><symbol viewBox="0 0 16 16" id="repeat" xmlns="http://www.w3.org/2000/svg"><path d="M11.494 4.423a5 5 0 1 0 .203 6.944 1 1 0 1 1 1.478 1.347 7 7 0 1 1-.12-9.556l1.262-1.021a.5.5 0 0 1 .815.385l.028 4.738a.5.5 0 0 1-.607.492L9.924 6.739a.5.5 0 0 1-.207-.877l1.777-1.439z"/></symbol><symbol viewBox="0 0 16 16" id="retry" xmlns="http://www.w3.org/2000/svg"><path d="M4.114 6.958a4 4 0 0 0 5.283 4.775 1 1 0 1 1 .712 1.87A6 6 0 0 1 2.182 6.44l-.741-.2a.5.5 0 0 1-.12-.915l2.195-1.268a.5.5 0 0 1 .683.183l1.268 2.196a.5.5 0 0 1-.563.733l-.79-.212zm7.777 2.084a4 4 0 0 0-5.284-4.775 1 1 0 0 1-.712-1.87 6 6 0 0 1 7.927 7.162l.742.2a.5.5 0 0 1 .12.915l-2.196 1.268a.5.5 0 0 1-.683-.183l-1.267-2.196a.5.5 0 0 1 .562-.733l.79.212z"/></symbol><symbol viewBox="0 0 16 16" id="scale" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M13.99 9a.792.792 0 0 0-.078-.231L13 7l-.912 1.769a.791.791 0 0 0-.077.231h1.978zm-10 0a.792.792 0 0 0-.078-.231L3 7l-.912 1.769A.791.791 0 0 0 2.011 9h1.978zM2 0h12a1 1 0 0 1 0 2H2a1 1 0 1 1 0-2zm3 14h6a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2zM8 4a1 1 0 0 1 1 1v9H7V5a1 1 0 0 1 1-1zm-4.53-.714l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 3 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158L2.53 3.286a.53.53 0 0 1 .94 0zm10 0l2.265 4.735c.68 1.42.006 3.091-1.504 3.73A3.161 3.161 0 0 1 13 12c-1.657 0-3-1.263-3-2.821 0-.4.09-.794.264-1.158l2.266-4.735a.53.53 0 0 1 .94 0z"/></symbol><symbol viewBox="0 0 16 16" id="screen-full" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M14 14v-2a1 1 0 0 1 2 0v3a.997.997 0 0 1-1 1h-3a1 1 0 0 1 0-2h2zM2 14v-2a1 1 0 0 0-2 0v3a1 1 0 0 0 1 1h3a1 1 0 0 0 0-2H2zM15.707.293A.997.997 0 0 1 16 1v3a1 1 0 0 1-2 0V2h-2a1 1 0 0 1 0-2h3c.276 0 .526.112.707.293zM2 2v2a1 1 0 1 1-2 0V1a.997.997 0 0 1 1-1h3a1 1 0 1 1 0 2H2zm4 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol><symbol viewBox="0 0 16 16" id="screen-normal" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 3V1a1 1 0 1 1 2 0v3a.997.997 0 0 1-1 1H1a1 1 0 1 1 0-2h2zm10 0h2a1 1 0 0 1 0 2h-3a.997.997 0 0 1-1-1V1a1 1 0 0 1 2 0v2zM3 13H1a1 1 0 0 1 0-2h3a.997.997 0 0 1 1 1v3a1 1 0 0 1-2 0v-2zm10 0v2a1 1 0 0 1-2 0v-3a.997.997 0 0 1 1-1h3a1 1 0 0 1 0 2h-2zM6.5 7h3a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"/></symbol><symbol viewBox="0 0 12 16" id="scroll_down" xmlns="http://www.w3.org/2000/svg"><path class="ewfirst-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.043a.51.51 0 0 0 .321-.105c.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/><path class="ewsecond-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="ewthird-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-.136.09h-.37a.626.626 0 0 1-.136-.09"/></symbol><symbol viewBox="0 0 12 16" id="scroll_up" 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.043a.51.51 0 0 1 .321.105c.09.07.136.154.136.25v.71c0 .095-.045.178-.136.249a.508.508 0 0 1-.32.105M.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.09M.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-.136-.09h-.37a.626.626 0 0 0-.136.09"/></symbol><symbol viewBox="0 0 16 16" id="search" xmlns="http://www.w3.org/2000/svg"><path d="M8.853 8.854a3.5 3.5 0 1 0-4.95-4.95 3.5 3.5 0 0 0 4.95 4.95zm.207 2.328a5.5 5.5 0 1 1 2.121-2.121l3.329 3.328a1.5 1.5 0 0 1-2.121 2.121L9.06 11.182z"/></symbol><symbol viewBox="0 0 16 16" id="settings" xmlns="http://www.w3.org/2000/svg"><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.918a5.97 5.97 0 0 1 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"/></symbol><symbol viewBox="0 0 16 16" id="shield" xmlns="http://www.w3.org/2000/svg"><path d="M4 0h8a3 3 0 0 1 3 3v7.186a3 3 0 0 1-1.426 2.554l-4 2.465a3 3 0 0 1-3.148 0l-4-2.465A3 3 0 0 1 1 10.186V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v7.186a1 1 0 0 0 .475.852l4 2.464a1 1 0 0 0 1.05 0l4-2.464a1 1 0 0 0 .475-.852V3a1 1 0 0 0-1-1H4zm0 1.5a.5.5 0 0 1 .5-.5h4v8.837a.5.5 0 0 1-.753.431l-3.5-2.052A.5.5 0 0 1 4 9.785V3.5z"/></symbol><symbol viewBox="0 0 16 16" id="slight-frown" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="slight-smile" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zm-5.163 2.254a.5.5 0 1 1 .865-.502 1.499 1.499 0 0 0 2.607-.018.5.5 0 1 1 .871.49 2.499 2.499 0 0 1-4.343.03z"/></symbol><symbol viewBox="0 0 16 16" id="smile" xmlns="http://www.w3.org/2000/svg"><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 12zM6.18 6.27a.5.5 0 0 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zm6 0a.5.5 0 1 1-.873.487.5.5 0 0 0-.872-.003.5.5 0 1 1-.87-.495 1.5 1.5 0 0 1 2.616.012zM5 9a3 3 0 0 0 6 0H5z"/></symbol><symbol viewBox="0 0 16 16" id="smiley" xmlns="http://www.w3.org/2000/svg"><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 12zM5 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 2zM5 9h6a3 3 0 0 1-6 0z"/></symbol><symbol viewBox="0 0 16 16" id="snippet" xmlns="http://www.w3.org/2000/svg"><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 0 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 0 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"/></symbol><symbol viewBox="0 0 16 16" id="spam" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 14 14" id="spinner" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle cx="7" cy="7" r="6" stroke="#000" stroke-opacity=".1" stroke-width="2"/><path fill="#000" fill-opacity=".1" fill-rule="nonzero" d="M7 0a7 7 0 0 1 7 7h-2a5 5 0 0 0-5-5V0z"/></g></symbol><symbol viewBox="0 0 16 16" id="star" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.609 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol><symbol viewBox="0 0 16 16" id="star-o" xmlns="http://www.w3.org/2000/svg"><path d="M10.975 10.99a3 3 0 0 1 .655-2.083l1.54-1.916-2.219-.576a3 3 0 0 1-1.825-1.37L8 3.15 6.874 5.044a3 3 0 0 1-1.825 1.371l-2.218.576 1.54 1.916a3 3 0 0 1 .654 2.083l-.165 2.4 1.965-.836a3 3 0 0 1 2.348 0l1.965.836-.164-2.399zM7.61 14.394l-3.465 1.473a1 1 0 0 1-1.39-.989l.276-4.024a1 1 0 0 0-.219-.694L.303 7.037A1 1 0 0 1 .83 5.443l3.715-.964a1 1 0 0 0 .609-.457L7.14.682a1 1 0 0 1 1.72 0l1.985 3.34a1 1 0 0 0 .609.457l3.715.964a1 1 0 0 1 .528 1.594L13.19 10.16a1 1 0 0 0-.219.694l.275 4.024a1 1 0 0 1-1.389.989l-3.465-1.473a1 1 0 0 0-.782 0z"/></symbol><symbol viewBox="0 0 14 14" id="status_canceled" 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="M5.2 3.8l4.9 4.9c.2.2.2.5 0 .7l-.7.7c-.2.2-.5.2-.7 0L3.8 5.2c-.2-.2-.2-.5 0-.7l.7-.7c.2-.2.5-.2.7 0"/></g></symbol><symbol viewBox="0 0 22 22" id="status_canceled_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M8.171 5.971l7.7 7.7a.76.76 0 0 1 0 1.1l-1.1 1.1a.76.76 0 0 1-1.1 0l-7.7-7.7a.76.76 0 0 1 0-1.1l1.1-1.1a.76.76 0 0 1 1.1 0"/></symbol><symbol viewBox="0 0 16 16" id="status_closed" xmlns="http://www.w3.org/2000/svg"><path d="M7.536 8.657l2.828-2.83a1 1 0 0 1 1.414 1.416l-3.535 3.535a1 1 0 0 1-1.415.001l-2.12-2.12a1 1 0 1 1 1.413-1.415zM8 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 12z"/></symbol><symbol viewBox="0 0 14 14" id="status_created" 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"/><circle cx="7" cy="7" r="3.25"/></g></symbol><symbol viewBox="0 0 22 22" id="status_created_borderless" xmlns="http://www.w3.org/2000/svg"><circle cx="11" cy="11" r="5.107"/></symbol><symbol viewBox="0 0 14 14" id="status_failed" 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 5.969L5.599 4.568a.29.29 0 0 0-.413.004l-.614.614a.294.294 0 0 0-.004.413L5.968 7l-1.4 1.401a.29.29 0 0 0 .004.413l.614.614c.113.114.3.117.413.004L7 8.032l1.401 1.4a.29.29 0 0 0 .413-.004l.614-.614a.294.294 0 0 0 .004-.413L8.032 7l1.4-1.401a.29.29 0 0 0-.004-.413l-.614-.614a.294.294 0 0 0-.413-.004L7 5.968z"/></g></symbol><symbol viewBox="0 0 22 22" id="status_failed_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M11 9.38L8.798 7.178a.455.455 0 0 0-.65.006l-.964.965a.462.462 0 0 0-.006.65L9.38 11l-2.202 2.202a.455.455 0 0 0 .006.65l.965.964a.462.462 0 0 0 .65.006L11 12.62l2.202 2.202a.455.455 0 0 0 .65-.006l.964-.965a.462.462 0 0 0 .006-.65L12.62 11l2.202-2.202a.455.455 0 0 0-.006-.65l-.965-.964a.462.462 0 0 0-.65-.006L11 9.38z"/></symbol><symbol viewBox="0 0 14 14" id="status_manual" 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="M10.5 7.63V6.37l-.787-.13c-.044-.175-.132-.349-.263-.61l.481-.652-.918-.913-.657.478a2.346 2.346 0 0 0-.612-.26L7.656 3.5H6.388l-.132.783c-.219.043-.394.13-.612.26l-.657-.478-.918.913.437.652c-.131.218-.175.392-.262.61l-.744.086v1.261l.787.13c.044.218.132.392.263.61l-.438.651.92.913.655-.434c.175.086.394.173.613.26l.131.783h1.313l.131-.783c.219-.043.394-.13.613-.26l.656.478.918-.913-.48-.652c.13-.218.218-.435.262-.61l.656-.13zM7 8.283a1.285 1.285 0 0 1-1.313-1.305c0-.739.57-1.304 1.313-1.304.744 0 1.313.565 1.313 1.304 0 .74-.57 1.305-1.313 1.305z"/></g></symbol><symbol viewBox="0 0 22 22" id="status_manual_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M16.5 11.99v-1.98l-1.238-.206c-.068-.273-.206-.546-.412-.956l.756-1.025-1.444-1.435-1.03.752a3.686 3.686 0 0 0-.963-.41L12.03 5.5h-1.994l-.206 1.23c-.343.068-.618.205-.962.41l-1.031-.752-1.444 1.435.687 1.025c-.206.341-.275.615-.412.956L5.5 9.941v1.981l1.237.205c.07.342.207.615.413.957l-.688 1.025 1.444 1.434 1.032-.683c.274.137.618.274.962.41l.206 1.23h2.063l.206-1.23c.344-.068.619-.205.963-.41l1.03.752 1.444-1.435-.756-1.025c.207-.341.344-.683.413-.956l1.031-.205zM11 13.017c-1.169 0-2.063-.889-2.063-2.05 0-1.162.894-2.05 2.063-2.05s2.063.888 2.063 2.05c0 1.161-.894 2.05-2.063 2.05z"/></symbol><symbol viewBox="0 0 22 22" id="status_notfound_borderless" 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 0v-1.43a5.9 5.9 0 0 0 .827-.492z"/><ellipse cx="10.825" cy="16.711" rx="1.275" ry="1.322"/></symbol><symbol viewBox="0 0 14 14" id="status_open" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><path d="M7 9.219a2.218 2.218 0 1 0 0-4.436A2.218 2.218 0 0 0 7 9.22zm0 1.12a3.338 3.338 0 1 1 0-6.676 3.338 3.338 0 0 1 0 6.676z"/></symbol><symbol viewBox="0 0 14 14" id="status_pending" 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="M4.7 5.3c0-.2.1-.3.3-.3h.9c.2 0 .3.1.3.3v3.4c0 .2-.1.3-.3.3H5c-.2 0-.3-.1-.3-.3V5.3m3 0c0-.2.1-.3.3-.3h.9c.2 0 .3.1.3.3v3.4c0 .2-.1.3-.3.3H8c-.2 0-.3-.1-.3-.3V5.3"/></g></symbol><symbol viewBox="0 0 22 22" id="status_pending_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M7.386 8.329c0-.315.157-.472.471-.472h1.414c.315 0 .472.157.472.472v5.342c0 .315-.157.472-.472.472H7.857c-.314 0-.471-.157-.471-.472V8.33m4.714 0c0-.315.157-.472.471-.472h1.415c.314 0 .471.157.471.472v5.342c0 .315-.157.472-.471.472H12.57c-.314 0-.471-.157-.471-.472V8.33"/></symbol><symbol viewBox="0 0 14 14" id="status_running" 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 3c2.2 0 4 1.8 4 4s-1.8 4-4 4c-1.3 0-2.5-.7-3.3-1.7L7 7V3"/></g></symbol><symbol viewBox="0 0 22 22" id="status_running_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M11 4.714c3.457 0 6.286 2.829 6.286 6.286 0 3.457-2.829 6.286-6.286 6.286-2.043 0-3.929-1.1-5.186-2.672L11 11V4.714"/></symbol><symbol viewBox="0 0 14 14" id="status_skipped" 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"/><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"/></symbol><symbol viewBox="0 0 22 22" id="status_skipped_borderless" 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"/></symbol><symbol viewBox="0 0 14 14" id="status_success" 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="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></g></symbol><symbol viewBox="0 0 22 22" id="status_success_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M9.866 12.095l-1.95-1.95a.462.462 0 0 0-.647.01l-.964.964a.46.46 0 0 0-.01.646l3.013 3.014a.787.787 0 0 0 1.106.008l.425-.425 4.854-4.853a.462.462 0 0 0 .002-.659l-.964-.964a.468.468 0 0 0-.658.002l-4.207 4.207z"/></symbol><symbol viewBox="0 0 14 14" id="status_success_solid" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7zm6.278.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 14 14" id="status_warning" 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="M6 3.5c0-.3.2-.5.5-.5h1c.3 0 .5.2.5.5v4c0 .3-.2.5-.5.5h-1c-.3 0-.5-.2-.5-.5v-4m0 6c0-.3.2-.5.5-.5h1c.3 0 .5.2.5.5v1c0 .3-.2.5-.5.5h-1c-.3 0-.5-.2-.5-.5v-1"/></g></symbol><symbol viewBox="0 0 22 22" id="status_warning_borderless" xmlns="http://www.w3.org/2000/svg"><path d="M9.429 5.5c0-.471.314-.786.785-.786h1.572c.471 0 .785.315.785.786v6.286c0 .471-.314.785-.785.785h-1.572c-.471 0-.785-.314-.785-.785V5.5m0 9.429c0-.472.314-.786.785-.786h1.572c.471 0 .785.314.785.786V16.5c0 .471-.314.786-.785.786h-1.572c-.471 0-.785-.315-.785-.786v-1.571"/></symbol><symbol viewBox="0 0 16 16" id="stop" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M2 0h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2z"/></symbol><symbol viewBox="0 0 16 16" id="task-done" xmlns="http://www.w3.org/2000/svg"><path d="M7.536 8.657l2.828-2.829a1 1 0 0 1 1.414 1.415l-3.535 3.535a.997.997 0 0 1-1.415 0l-2.12-2.121A1 1 0 0 1 6.12 7.243l1.415 1.414zM3 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-1H3z"/></symbol><symbol viewBox="0 0 16 16" id="template" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="terminal" xmlns="http://www.w3.org/2000/svg"><path d="M7 8a.997.997 0 0 1-.293.707l-1.414 1.414a1 1 0 1 1-1.414-1.414L4.586 8l-.707-.707a1 1 0 1 1 1.414-1.414l1.414 1.414A.997.997 0 0 1 7 8zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H4zm5 7h2a1 1 0 0 1 0 2H9a1 1 0 0 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="thumb-down" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 11h5.282a2 2 0 0 0 1.963-2.38l-.563-2.905a3 3 0 0 0-.243-.732l-1.103-2.286A3 3 0 0 0 10.964 1H7a3 3 0 0 0-3 3v6.3a2 2 0 0 0 .436 1.247l3.11 3.9a.632.632 0 0 0 .941.053l.137-.137a1 1 0 0 0 .28-.87L8.329 11zM1 10h2V3H1a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1z"/></symbol><symbol viewBox="0 0 16 16" id="thumb-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8.33 5h5.282a2 2 0 0 1 1.963 2.38l-.563 2.905a3 3 0 0 1-.243.732l-1.103 2.286A3 3 0 0 1 10.964 15H7a3 3 0 0 1-3-3V5.7a2 2 0 0 1 .436-1.247l3.11-3.9A.632.632 0 0 1 8.487.5l.137.137a1 1 0 0 1 .28.87L8.329 5zM1 6h2v7H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></symbol><symbol viewBox="0 0 16 16" id="thumbtack" xmlns="http://www.w3.org/2000/svg"><path d="M7.125 9h-2.19a.5.5 0 0 1-.417-.777L6 6V2L5.362.724A.5.5 0 0 1 5.809 0h4.382a.5.5 0 0 1 .447.724L10 2v4l1.482 2.223a.5.5 0 0 1-.416.777H8.875L8 16l-.875-7z" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 16 16" id="timer" xmlns="http://www.w3.org/2000/svg"><path d="M12.022 3.27l.77-.77a1 1 0 0 1 1.415 1.414l-.728.729a7 7 0 1 1-1.456-1.372zM8 14A5 5 0 1 0 8 4a5 5 0 0 0 0 10zm0-9a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zM6 0h4a1 1 0 0 1 0 2H6a1 1 0 1 1 0-2z"/></symbol><symbol viewBox="0 0 16 16" id="todo-add" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 4V2a1 1 0 0 1 2 0v2h2a1 1 0 0 1 0 2h-2v2a1 1 0 0 1-2 0V6H8a1 1 0 1 1 0-2h2zm2 7a1 1 0 0 1 2 0v2a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h2a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-2z"/></symbol><symbol viewBox="0 0 16 16" id="todo-done" xmlns="http://www.w3.org/2000/svg"><path d="M8.243 7.485l4.95-4.95a1 1 0 1 1 1.414 1.415L8.95 9.607a.997.997 0 0 1-1.414 0L4.707 6.778a1 1 0 0 1 1.414-1.414l2.122 2.121zM12 11a1 1 0 0 1 2 0v2a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h2a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-2z"/></symbol><symbol viewBox="0 0 16 16" id="token" xmlns="http://www.w3.org/2000/svg"><path d="M3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3zm1 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="unapproval" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.95 8.536l1.06-1.061a1 1 0 0 1 1.415 1.414l-1.061 1.06 1.06 1.061a1 1 0 0 1-1.414 1.415l-1.06-1.061-1.06 1.06a1 1 0 1 1-1.415-1.414l1.06-1.06-1.06-1.06a1 1 0 0 1 1.414-1.415l1.06 1.06zm-3.768-.33c.006.503.201 1.006.586 1.39l.353.354-.353.353a2 2 0 1 0 2.828 2.829l.354-.354.047.048C11.964 14.363 11.527 15 6 15c-5.924 0-6-.78-6-2.52S.964 8 6 8c.834 0 1.557.074 2.182.205zM5.976 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol><symbol viewBox="0 0 16 16" id="unassignee" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11 5h4a1 1 0 0 1 0 2h-4a1 1 0 0 1 0-2zM5.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"/></symbol><symbol viewBox="0 0 16 16" id="unlink" xmlns="http://www.w3.org/2000/svg"><path d="M11.295 8.845l-.659-1.664a1.78 1.78 0 0 0 .04-.04l1.415-1.414c.586-.586.654-1.468.152-1.97s-1.384-.434-1.97.152L8.859 5.323a1.781 1.781 0 0 0-.04.04l-1.664-.658c.141-.208.305-.408.491-.594l1.415-1.414c1.366-1.367 3.424-1.525 4.596-.354 1.171 1.172 1.013 3.23-.354 4.596L11.89 8.354c-.186.186-.386.35-.594.491zm-2.45 2.45a4.075 4.075 0 0 1-.491.594l-1.415 1.414c-1.366 1.367-3.424 1.525-4.596.354-1.171-1.172-1.013-3.23.354-4.596L4.11 7.646c.186-.186.386-.35.594-.491l.659 1.664a1.781 1.781 0 0 0-.04.04l-1.415 1.414c-.586.586-.654 1.468-.152 1.97s1.384.434 1.97-.152l1.414-1.414a1.78 1.78 0 0 0 .04-.04l1.664.658zm3.812-2.088h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-.05a.5.5 0 0 1 .5-.5zm-.384 2.116l1.415 1.414a.5.5 0 0 1 0 .708l-.037.036a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 0-.707l.036-.037a.5.5 0 0 1 .707 0zm-2.823 1.09a.5.5 0 0 1 .5-.5h.052a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H9.95a.5.5 0 0 1-.5-.5v-2zm-2.748-9.16a.5.5 0 0 1-.5.5h-.05a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5h.05a.5.5 0 0 1 .5.5v2zm-2.116.383a.5.5 0 0 1 0 .707l-.036.036a.5.5 0 0 1-.707 0L2.428 2.965a.5.5 0 0 1 0-.707l.037-.036a.5.5 0 0 1 .707 0l1.414 1.414zm-1.09 2.823h-2a.5.5 0 0 1-.5-.5v-.051a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v.05a.5.5 0 0 1-.5.5z"/></symbol><symbol viewBox="0 0 16 16" id="user" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M8 7a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 8c-6.888 0-6.976-.78-6.976-2.52S2.144 8 8 8s6.976 2.692 6.976 4.48c0 1.788-.088 2.52-6.976 2.52z"/></symbol><symbol viewBox="0 0 16 16" id="users" xmlns="http://www.w3.org/2000/svg"><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"/></symbol><symbol viewBox="0 0 16 16" id="volume-up" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M1 5h1v6H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm2 0l4.445-2.964A1 1 0 0 1 9 2.87v10.26a1 1 0 0 1-1.555.833L3 11V5zm10.283 7.89a.5.5 0 0 1-.66-.752A5.485 5.485 0 0 0 14.5 8c0-1.601-.687-3.09-1.865-4.128a.5.5 0 0 1 .661-.75A6.484 6.484 0 0 1 15.5 8a6.485 6.485 0 0 1-2.217 4.89zm-2.002-2.236a.5.5 0 1 1-.652-.758c.55-.472.871-1.157.871-1.896 0-.732-.315-1.411-.856-1.883a.5.5 0 0 1 .658-.753A3.492 3.492 0 0 1 12.5 8c0 1.033-.45 1.994-1.219 2.654z"/></symbol><symbol viewBox="0 0 16 16" id="warning" xmlns="http://www.w3.org/2000/svg"><path d="M15.34 10.479A3 3 0 0 1 12.756 15h-9.51A3 3 0 0 1 .66 10.479l4.755-8.083a3 3 0 0 1 5.172 0l4.755 8.083zm-6.478-7.07a1 1 0 0 0-1.724 0l-4.755 8.084A1 1 0 0 0 3.245 13h9.51a1 1 0 0 0 .862-1.507L8.862 3.41zM8 5a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></symbol><symbol viewBox="0 0 16 16" id="work" xmlns="http://www.w3.org/2000/svg"><path d="M12 3h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h1V2a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1zM6 2v1h4V2H6zM3 5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H3zm1.5 1a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5zm7 0a.5.5 0 0 1 .5.5v6a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 .5-.5z"/></symbol></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/clusters_empty.svg b/app/assets/images/illustrations/clusters_empty.svg
deleted file mode 100644
index 39627a1c314..00000000000
--- a/app/assets/images/illustrations/clusters_empty.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg height="128" viewBox="0 0 142 128" width="142" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M94 62h20v4H94z" fill="#f0edf8"/><path d="M84.828 84l17.678 17.678-2.828 2.828L82 86.828z" fill="#fee1d3"/><path d="M42.828 24l17.678 17.678-2.828 2.828L40 26.828zM40 101.678L57.678 84l2.828 2.828-17.678 17.678z" fill="#f0edf8"/><path d="M82 41.678L99.678 24l2.828 2.828-17.678 17.678zM28 62h20v4H28zM3 52h24v24H3z" fill="#fee1d3"/><path d="M31 3h24v24H31z" fill="#f0edf8"/><path d="M87 3h24v24H87z" fill="#fef0e8"/><path d="M115 52h24v24h-24z" fill="#f0edf8"/><path d="M87 101h24v24H87z" fill="#fee1d3"/><path d="M31 101h24v24H31z" fill="#f0edf8"/><path d="M49 42h44v44H49z" fill="#c3b8e3"/><g fill-rule="nonzero"><path d="M5 53a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V54a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5V54a5 5 0 0 1 5-5z" fill="#fdc4a8"/><path d="M56 43a6 6 0 0 0-6 6v30a6 6 0 0 0 6 6h30a6 6 0 0 0 6-6V49a6 6 0 0 0-6-6zm0-4h30c5.523 0 10 4.477 10 10v30c0 5.523-4.477 10-10 10H56c-5.523 0-10-4.477-10-10V49c0-5.523 4.477-10 10-10z" fill="#6b4fbb"/><path d="M89 4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H89a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z" fill="#fee1d3"/><path d="M89 102a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1v-20a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H89a5 5 0 0 1-5-5v-20a5 5 0 0 1 5-5z" fill="#fdc4a8"/><path d="M117 53a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V54a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5h-20a5 5 0 0 1-5-5V54a5 5 0 0 1 5-5zM33 102a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1v-20a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H33a5 5 0 0 1-5-5v-20a5 5 0 0 1 5-5zM33 4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h20a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm0-4h20a5 5 0 0 1 5 5v20a5 5 0 0 1-5 5H33a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z" fill="#e1dbf1"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/convdev_no_data.svg b/app/assets/images/illustrations/convdev/convdev_no_data.svg
deleted file mode 100644
index b90eddcccfa..00000000000
--- a/app/assets/images/illustrations/convdev/convdev_no_data.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><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.994a1.999 1.999 0 0 0 2.503 1.94c.162.04.33.063.506.063h7.98a2 2 0 0 0 .001-4H58z"/><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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><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.8A21.93 21.93 0 0 0 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 16a15.935 15.935 0 0 1-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.998A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.004A1.995 1.995 0 0 1 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.988A8 8 0 0 0 12.005 168h89.99a8.007 8.007 0 0 0 8.005-8.006V12.006A8 8 0 0 0 101.995 4h-89.99A8.007 8.007 0 0 0 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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/convdev_no_index.svg b/app/assets/images/illustrations/convdev/convdev_no_index.svg
deleted file mode 100644
index 4aaf505e0b8..00000000000
--- a/app/assets/images/illustrations/convdev/convdev_no_index.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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.988A8 8 0 0 0 10.005 166h89.99a8.007 8.007 0 0 0 8.005-8.006V10.006A8 8 0 0 0 99.995 2h-89.99A8.007 8.007 0 0 0 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.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><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 44a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 44zM34 56a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 34 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 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.77a2 2 0 0 0-2.773-2.883A11.974 11.974 0 0 0 0 12.006a2 2 0 1 0 4 0zM14.388 4h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm18 0h8a2 2 0 0 0 0-4h-8a2 2 0 1 0 0 4zm17.51.227a8.015 8.015 0 0 1 5.022 3.756 2 2 0 1 0 3.458-2.011A12.01 12.01 0 0 0 104.844.34a2 2 0 0 0-.946 3.887zM110 16.78v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm0 18v8a2 2 0 0 0 4 0v-8a2 2 0 1 0-4 0zm-.024 17.844a7.99 7.99 0 0 1-2.903 5.558 2 2 0 0 0 2.54 3.09 11.977 11.977 0 0 0 4.35-8.338 2.002 2.002 0 0 0-1.838-2.15 2.003 2.003 0 0 0-2.15 1.84zM98.826 168h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-18 0h-8a2 2 0 0 0 0 4h8a2 2 0 1 0 0-4zm-17.334-.4a8.032 8.032 0 0 1-4.71-4.143 1.998 1.998 0 0 0-2.667-.938 1.997 1.997 0 0 0-.938 2.667 12.022 12.022 0 0 0 7.063 6.21 1.998 1.998 0 1 0 1.252-3.798zM4 154.434v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0zm0-18v-8a2 2 0 0 0-4 0v8a2 2 0 1 0 4 0z"/><path fill="#FEE1D3" d="M44 44a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 44zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 44zM34 56a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 34 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 44 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 54 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 64 56zm10 0a2 2 0 0 1 1.998-2h2.004a2 2 0 0 1 0 4h-2.004A1.994 1.994 0 0 1 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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/convdev_overview.svg b/app/assets/images/illustrations/convdev/convdev_overview.svg
deleted file mode 100644
index a06d70812ca..00000000000
--- a/app/assets/images/illustrations/convdev/convdev_overview.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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.426a.993.993 0 0 0-1.413-.028A7.075 7.075 0 0 0 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 0H185a4.95 4.95 0 0 1 3.254 1.215.995.995 0 0 0 1.41-.108c.36-.423.312-1.06-.107-1.422A6.944 6.944 0 0 0 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.446a5.06 5.06 0 0 1-2.572 2.985 1.01 1.01 0 0 0-.46 1.348c.24.5.84.708 1.336.464a7.06 7.06 0 0 0 3.598-4.178c.17-.53-.12-1.098-.644-1.27a1 1 0 0 0-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-.116a5.009 5.009 0 0 1-3.19-2.3.994.994 0 0 0-1.373-.333c-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215a1 1 0 0 0 1.192-.768 1.005 1.005 0 0 0-.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.992a.994.994 0 0 1-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992a.994.994 0 0 1-.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.783a.998.998 0 0 0 1.01-1V3c0-.55-.45-1-1.01-1H3.2a.998.998 0 0 0-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.98a3.004 3.004 0 0 1 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.506A1.003 1.003 0 0 1 3.5 47.188zm0 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.506A1.003 1.003 0 0 1 3.5 53.212z"/><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 24a6 6 0 1 0 0-12 6 6 0 0 0 0 12zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8a8 8 0 0 1 0 16z"/><path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3a.997.997 0 0 0 .998 1h3c.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.955A3.626 3.626 0 0 1 .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.992a.994.994 0 0 1-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992a.994.994 0 0 1-.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.59a1.001 1.001 0 0 1-1.003-1.004c0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15a1 1 0 0 1-1.004-1.004 1.01 1.01 0 0 1 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.037a1 1 0 0 1 1.002 1.004c0 .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.627H16.87z"/><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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_1.svg b/app/assets/images/illustrations/convdev/i2p_step_1.svg
deleted file mode 100644
index 67467b1513d..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_1.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001a20.014 20.014 0 0 0-6.493 4.268 19.798 19.798 0 0 0-4.346 6.381 19.135 19.135 0 0 0-1.525 7.537c0 2.066.33 4.118.983 6.104a20.142 20.142 0 0 0 1.83 3.937 5.983 5.983 0 0 0-2.086 4.538c0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425a18.13 18.13 0 0 1-1.642-3.533 17.467 17.467 0 0 1-.881-5.472c0-2.351.459-4.623 1.391-6.814a17.721 17.721 0 0 1 3.88-5.675 18.057 18.057 0 0 1 5.85-3.845c4.329-1.778 9.392-1.79 13.78.002a18.077 18.077 0 0 1 5.843 3.84c3.39 3.34 5.257 7.776 5.257 12.493a17.463 17.463 0 0 1-.878 5.481 17.451 17.451 0 0 1-2.569 4.923c-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384H32.215a1 1 0 1 0 0 2h11.739a1 1 0 0 0 .999-.947c.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331a19.422 19.422 0 0 0 2.864-5.489c.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92a20.079 20.079 0 0 0-6.49-4.265M28.761 51.612c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4M40 74h-4a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2M42 70h-8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2M38 10a1 1 0 0 0 1-1V1a1 1 0 1 0-2 0v8a1 1 0 0 0 1 1M20.828 15.828a.999.999 0 0 0 .707-1.707l-5.656-5.656a.999.999 0 1 0-1.414 1.414l5.656 5.656a.997.997 0 0 0 .707.293M10 33H2a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2M60.12 8.465l-5.656 5.656a.999.999 0 1 0 1.414 1.414l5.656-5.656a.999.999 0 1 0-1.414-1.414M74 33h-8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2M43 66H33a1 1 0 1 0 0 2h10a1 1 0 1 0 0-2"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_10.svg b/app/assets/images/illustrations/convdev/i2p_step_10.svg
deleted file mode 100644
index 588ecd81414..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_10.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M5 43a1 1 0 1 0 2 0v-4h4a1 1 0 1 0 0-2H7v-4a1 1 0 1 0-2 0v4H1a1 1 0 1 0 0 2h4v4M75 37h-4v-4a1 1 0 1 0-2 0v4h-4a1 1 0 1 0 0 2h4v4a1 1 0 1 0 2 0v-4h4a1 1 0 1 0 0-2M21 38a1 1 0 0 0 .47.848l8 5a.999.999 0 0 0 1.061-1.696L23.887 38l6.644-4.152a1 1 0 1 0-1.061-1.695l-8 5A.998.998 0 0 0 21 38M55 38a1 1 0 0 0-.47-.848l-8-5a.999.999 0 1 0-1.061 1.695L52.113 38l-6.644 4.152a1 1 0 1 0 1.061 1.696l8-5A1 1 0 0 0 55 38M41.803 26.05a1 1 0 0 0-1.256.65l-7 22a1.001 1.001 0 0 0 .953 1.303 1 1 0 0 0 .953-.697l7-22a1.001 1.001 0 0 0-.65-1.256M62 7c3.859 0 7 3.141 7 7v11a1 1 0 1 0 2 0V14c0-4.963-4.04-9-9-9H45.91c-.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-5H62m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4M6 26a1 1 0 0 0 1-1V14c0-3.859 3.141-7 7-7h11.09l-3.293 3.293a.999.999 0 1 0 1.414 1.414l5-5a.999.999 0 0 0 0-1.414l-5-5a.999.999 0 1 0-1.414 1.414L25.09 5H14c-4.963 0-9 4.04-9 9v11a1 1 0 0 0 1 1M36 64c-2.967 0-5.431 2.167-5.91 5H14c-3.859 0-7-3.141-7-7V51a1 1 0 1 0-2 0v11c0 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 4M70 50a1 1 0 0 0-1 1v11c0 3.859-3.141 7-7 7H50.91l3.293-3.293a.999.999 0 1 0-1.414-1.414l-5 5a.999.999 0 0 0 0 1.414l5 5a.997.997 0 0 0 1.414 0 .999.999 0 0 0 0-1.414L50.91 71H62c4.963 0 9-4.04 9-9V51a1 1 0 0 0-1-1"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_2.svg b/app/assets/images/illustrations/convdev/i2p_step_2.svg
deleted file mode 100644
index 4280024c23c..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_2.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M42.26 40.44a.989.989 0 0 0 1.109-.877l2.625-22.444a.997.997 0 0 0-.993-1.117h-14a1 1 0 0 0-.994 1.108l3.454 31.575a6.981 6.981 0 0 0-2.46 5.317c0 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.761L41.383 39.33a1 1 0 0 0 .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.749a1 1 0 0 0-1.854.75A35.788 35.788 0 0 1 74 38c0 19.851-16.149 36-36 36S2 57.851 2 38 18.149 2 38 2c7.6 0 14.83 2.332 20.965 6.74A5.955 5.955 0 0 0 58 12c0 1.603.624 3.109 1.758 4.242A5.956 5.956 0 0 0 64 18a5.956 5.956 0 0 0 4.242-1.758C69.376 15.109 70 13.603 70 12s-.624-3.109-1.758-4.242A5.956 5.956 0 0 0 64 6a5.943 5.943 0 0 0-3.668 1.259C53.812 2.512 46.104 0 38 0 17.047 0 0 17.047 0 38s17.047 38 38 38 38-17.047 38-38c0-4.93-.93-9.725-2.764-14.251zM64 8c1.068 0 2.072.416 2.828 1.172S68 10.932 68 12s-.416 2.072-1.172 2.828c-1.512 1.512-4.145 1.512-5.656 0C60.416 14.072 60 13.068 60 12s.416-2.072 1.172-2.828S62.932 8 64 8z"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_3.svg b/app/assets/images/illustrations/convdev/i2p_step_3.svg
deleted file mode 100644
index 7690f91b420..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_3.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M12 8c0-3.309-2.691-6-6-6S0 4.691 0 8c0 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.91M2 8c0-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 4M21 6h54a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M21 12h35a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M75 24H21a1 1 0 1 0 0 2h54a1 1 0 1 0 0-2M21 32h34a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M75 44H21a1 1 0 1 0 0 2h54a1 1 0 1 0 0-2M21 52h34a1 1 0 1 0 0-2H21a1 1 0 1 0 0 2M75 64H21a1 1 0 1 0 0 2h54a1 1 0 1 0 0-2M55 70H21a1 1 0 1 0 0 2h34a1 1 0 1 0 0-2"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_4.svg b/app/assets/images/illustrations/convdev/i2p_step_4.svg
deleted file mode 100644
index ba21b9e2c3a..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_4.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M67.7 10h-6.751C60.442 4.402 55.728 0 50 0c-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10H67.7c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2H8.3C7.031 74 6 73.013 6 71.8V14.2C6 12.987 7.031 12 8.3 12h15.15a1 1 0 1 0 0-2H8.3C5.929 10 4 11.884 4 14.2v57.6C4 74.116 5.929 76 8.3 76h59.4c2.371 0 4.3-1.884 4.3-4.2V14.2c0-2.316-1.929-4.2-4.3-4.2M50 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="M21.293 29.29a.999.999 0 0 0 0 1.414l12.975 12.975-12.975 12.974a.999.999 0 1 0 1.414 1.414l13.682-13.682a.999.999 0 0 0 0-1.414L22.707 29.29a.999.999 0 0 0-1.414 0M54 59a1 1 0 1 0 0-2H42a1 1 0 1 0 0 2h12"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_5.svg b/app/assets/images/illustrations/convdev/i2p_step_5.svg
deleted file mode 100644
index 3c8f8422a97..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_5.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M48.949 37C48.442 31.402 43.728 27 38 27s-10.442 4.402-10.949 10h-13.05a1 1 0 1 0 0 2h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24a1 1 0 1 0 0-2h-12.24M38 47c-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.749a1 1 0 0 0-1.854.75A35.788 35.788 0 0 1 74 38c0 19.851-16.149 36-36 36S2 57.851 2 38 18.149 2 38 2c7.6 0 14.83 2.332 20.965 6.74A5.955 5.955 0 0 0 58 12c0 1.603.624 3.109 1.758 4.242A5.956 5.956 0 0 0 64 18a5.956 5.956 0 0 0 4.242-1.758C69.376 15.109 70 13.603 70 12s-.624-3.109-1.758-4.242A5.956 5.956 0 0 0 64 6a5.943 5.943 0 0 0-3.668 1.259C53.812 2.512 46.104 0 38 0 17.047 0 0 17.047 0 38s17.047 38 38 38 38-17.047 38-38c0-4.93-.93-9.725-2.764-14.251zM64 8c1.068 0 2.072.416 2.828 1.172S68 10.932 68 12s-.416 2.072-1.172 2.828c-1.512 1.512-4.145 1.512-5.656 0C60.416 14.072 60 13.068 60 12s.416-2.072 1.172-2.828S62.932 8 64 8z"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_6.svg b/app/assets/images/illustrations/convdev/i2p_step_6.svg
deleted file mode 100644
index 933860798ad..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_6.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M14.267 7.32l-4.896 5.277-1.702-1.533a.999.999 0 1 0-1.338 1.486l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062a.99.99 0 0 0 .752-.016c.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6a1 1 0 0 0-1.466-1.36M31 9h44a1 1 0 1 0 0-2H31a1 1 0 1 0 0 2M31 15h24a1 1 0 1 0 0-2H31a1 1 0 1 0 0 2"/><path d="M11 0C4.93 0 0 4.935 0 11s4.935 11 11 11 11-4.935 11-11S17.065 0 11 0m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9M14.267 34.32l-4.896 5.277-1.702-1.533a1 1 0 1 0-1.338 1.486l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062a.99.99 0 0 0 .752-.016c.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6a1 1 0 0 0-1.466-1.36M75 34H31a1 1 0 1 0 0 2h44a1 1 0 1 0 0-2M31 42h24a1 1 0 1 0 0-2H31a1 1 0 1 0 0 2"/><path d="M11 27C4.93 27 0 31.935 0 38s4.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 9M14.267 61.32l-4.896 5.277-1.702-1.533a1 1 0 1 0-1.338 1.486l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062a.99.99 0 0 0 .752-.016c.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6a1 1 0 0 0-1.466-1.36"/><path d="M11 54C4.93 54 0 58.935 0 65s4.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 9M75 61H31a1 1 0 1 0 0 2h44a1 1 0 1 0 0-2M55 67H31a1 1 0 1 0 0 2h24a1 1 0 1 0 0-2"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_7.svg b/app/assets/images/illustrations/convdev/i2p_step_7.svg
deleted file mode 100644
index d97c8f7c2d4..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_7.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M73.236 23.749a1 1 0 1 0-1.854.75A35.788 35.788 0 0 1 74 38c0 19.851-16.149 36-36 36S2 57.851 2 38 18.149 2 38 2c7.6 0 14.83 2.332 20.965 6.74A5.955 5.955 0 0 0 58 12c0 1.603.624 3.109 1.758 4.242A5.956 5.956 0 0 0 64 18a5.956 5.956 0 0 0 4.242-1.758C69.376 15.109 70 13.603 70 12s-.624-3.109-1.758-4.242A5.956 5.956 0 0 0 64 6a5.943 5.943 0 0 0-3.668 1.259C53.812 2.512 46.104 0 38 0 17.047 0 0 17.047 0 38s17.047 38 38 38 38-17.047 38-38c0-4.93-.93-9.725-2.764-14.251zM64 8c1.068 0 2.072.416 2.828 1.172S68 10.932 68 12s-.416 2.072-1.172 2.828c-1.512 1.512-4.145 1.512-5.656 0C60.416 14.072 60 13.068 60 12s.416-2.072 1.172-2.828S62.932 8 64 8z"/><path d="M27.19 32.17a.997.997 0 0 0-1.366-.364L13.17 39.132a1 1 0 0 0 0 1.73l12.654 7.326a1 1 0 0 0 1.002-1.73l-11.159-6.461 11.159-6.461a.998.998 0 0 0 .364-1.366M48.808 47.827a1 1 0 0 0 1.366.364l12.654-7.326a1 1 0 0 0 0-1.73l-12.654-7.326a1 1 0 0 0-1.002 1.73L60.331 40l-11.159 6.461a.998.998 0 0 0-.364 1.366M42.71 23.06L31.398 56.29a1 1 0 0 0 1.892.645l11.312-33.23a1 1 0 0 0-1.892-.645"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_8.svg b/app/assets/images/illustrations/convdev/i2p_step_8.svg
deleted file mode 100644
index 919bbeff319..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_8.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="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.3a6.94 6.94 0 0 0-4.699 2.039c-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.051a6.951 6.951 0 0 0 2.051-4.949 6.955 6.955 0 0 0-2.051-4.949c-.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.54a.997.997 0 0 0 .25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106a.403.403 0 0 1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328a1.002 1.002 0 0 0-.886-.535H30.024c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072a.366.366 0 0 1-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338a.525.525 0 0 1 .049-.34L25.26 44.682a1 1 0 0 0-1.492-1.332L13.539 54.803c-.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-.003a2.396 2.396 0 0 0 1.544-.694c.313-.316.504-.646.598-1.022l4.557-17.451a2.502 2.502 0 0 0-.518-2.066M29.01 30.001c0 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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/convdev/i2p_step_9.svg b/app/assets/images/illustrations/convdev/i2p_step_9.svg
deleted file mode 100644
index 2d1b10d430d..00000000000
--- a/app/assets/images/illustrations/convdev/i2p_step_9.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M68 67c-1.725 0-3.36.541-4.723 1.545A12.998 12.998 0 0 0 52 62c-2.734 0-5.359.853-7.555 2.43L42.159 49h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003a2.07 2.07 0 0 0 1.316-.595c.264-.266.433-.559.514-.882l3.433-13.145a2.138 2.138 0 0 0-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641C49.532 14.863 46.684 7.908 39.393.564c-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463a5.95 5.95 0 0 0-3.36 1.686C22.624 19.891 22 21.397 22 23s.624 3.109 1.758 4.242C24.891 28.376 26.397 29 28 29s3.109-.624 4.242-1.758C33.376 26.109 34 24.603 34 23s-.624-3.109-1.758-4.242a5.952 5.952 0 0 0-3.098-1.648c1.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.91a.999.999 0 0 0 .25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15a1.001 1.001 0 0 0-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588a1 1 0 0 0-1.492-1.332l-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.43a12.914 12.914 0 0 0-7.555-2.43c-4.685 0-8.979 2.53-11.277 6.545a7.943 7.943 0 0 0-4.723-1.545c-4.411 0-8 3.589-8 8a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1c0-4.411-3.589-8-8-8m-36-44a3.973 3.973 0 0 1-1.172 2.828c-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 51a6.01 6.01 0 0 1 5.917-5c1.638 0 3.17.652 4.313 1.836a.998.998 0 0 0 1.634-.289 11.011 11.011 0 0 1 10.05-6.547c2.836 0 5.532 1.085 7.593 3.055a1.001 1.001 0 0 0 1.681-.576l2.588-17.479h4.275l2.589 17.479a.999.999 0 1 0 1.681.576 10.945 10.945 0 0 1 7.593-3.055c4.343 0 8.288 2.57 10.05 6.547a.998.998 0 0 0 1.634.289 5.948 5.948 0 0 1 4.313-1.836 6.01 6.01 0 0 1 5.917 5H2.076"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/epics.svg b/app/assets/images/illustrations/epics.svg
deleted file mode 100644
index 1a37e6bba5f..00000000000
--- a/app/assets/images/illustrations/epics.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="430" height="300" viewBox="0 0 430 300"><g fill="none" fill-rule="evenodd"><g transform="translate(75 53)"><rect width="284" height="208" y="5" fill="#F9F9F9" rx="10"/><rect width="284" height="208" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M10 4a6 6 0 0 0-6 6v188a6 6 0 0 0 6 6h264a6 6 0 0 0 6-6V10a6 6 0 0 0-6-6H10zm0-4h264c5.523 0 10 4.477 10 10v188c0 5.523-4.477 10-10 10H10c-5.523 0-10-4.477-10-10V10C0 4.477 4.477 0 10 0z"/><path fill="#EEE" fill-rule="nonzero" d="M25.168 153.995c3.837-.215 7.173.028 10.119.691a3 3 0 1 0 1.318-5.853c-3.509-.79-7.4-1.074-11.773-.828a3 3 0 1 0 .336 5.99zm19.043 4.66c2.401 1.704 4.388 3.61 7.569 7.083a3 3 0 0 0 4.424-4.054c-3.448-3.763-5.686-5.911-8.522-7.923a3 3 0 1 0-3.471 4.894zm15.575 15.173c3.181 2.675 6.52 4.665 10.397 6.039a3 3 0 0 0 2.004-5.655c-3.162-1.121-5.884-2.743-8.54-4.976a3 3 0 1 0-3.861 4.592zm22.133 8.148c1.02.037 2.067.045 3.143.023a72.664 72.664 0 0 0 8.346-.638 3 3 0 1 0-.812-5.945c-2.442.334-4.996.53-7.658.585a48.55 48.55 0 0 1-2.796-.021 3 3 0 0 0-.223 5.996zm22.778-3.286c3.9-1.37 7.427-3.15 10.54-5.305a3 3 0 0 0-3.415-4.933c-2.665 1.845-5.712 3.382-9.114 4.578a3 3 0 0 0 1.989 5.66zm19.156-13.62a33.752 33.752 0 0 0 5.276-10.817 3 3 0 1 0-5.773-1.633 27.753 27.753 0 0 1-4.341 8.9 3 3 0 1 0 4.838 3.55zm6.577-22.657c-.187-3.817-.926-7.71-2.204-11.596a3 3 0 0 0-5.7 1.874c1.113 3.384 1.75 6.745 1.91 10.016a3 3 0 1 0 5.994-.294zm-7.097-22.26c-1.897-3.2-4.152-6.325-6.748-9.344a3 3 0 0 0-4.55 3.913c2.372 2.756 4.421 5.597 6.136 8.49a3 3 0 0 0 5.162-3.06zm-11.546-17.793c-.938-3.025-1.402-6.42-1.365-9.976a3 3 0 0 0-6-.063c-.043 4.163.506 8.177 1.634 11.816a3 3 0 1 0 5.731-1.777zm.053-20.107c.905-3.341 2.22-6.538 3.904-9.448a3 3 0 0 0-5.194-3.004c-1.948 3.368-3.463 7.048-4.501 10.884a3 3 0 1 0 5.791 1.568zm10.134-17.305c2.475-2.28 5.265-4.09 8.335-5.374a3 3 0 1 0-2.314-5.536c-3.725 1.558-7.105 3.75-10.086 6.497a3 3 0 1 0 4.065 4.413zm18.177-7.586c3.202-.18 6.599.092 10.18.843a3 3 0 0 0 1.23-5.872c-4.086-.857-8.009-1.172-11.747-.962a3 3 0 1 0 .337 5.99zm20.047 3.95c3.068 1.268 6.232 2.842 9.487 4.728a3 3 0 0 0 3.009-5.191c-3.48-2.017-6.883-3.71-10.204-5.083a3 3 0 1 0-2.292 5.545zm19.578 9.955c3.711 1.586 7.376 2.77 10.997 3.565a3 3 0 0 0 1.286-5.86c-3.248-.713-6.555-1.782-9.925-3.222a3 3 0 1 0-2.358 5.517zm22.591 4.789c3.94-.04 7.808-.553 11.61-1.513a3 3 0 1 0-1.468-5.817 43.358 43.358 0 0 1-10.203 1.33 3 3 0 0 0 .061 6zm22.52-5.558c3.335-1.637 6.607-3.613 9.845-5.916a3 3 0 1 0-3.477-4.89c-2.984 2.122-5.98 3.931-9.011 5.42a3 3 0 1 0 2.643 5.386zm18.678-13.054a3 3 0 0 1-4.02-4.454 130.547 130.547 0 0 0 5.31-5.088 3 3 0 1 1 4.265 4.22 136.507 136.507 0 0 1-5.555 5.322zm-48.722 25.641a3 3 0 1 1 4.314-4.17c3.056 3.16 5.075 6.744 6.172 10.754a3 3 0 0 1-5.787 1.584c-.834-3.047-2.35-5.739-4.699-8.168zm5.347 18.049a3 3 0 1 1 5.978.52c-.282 3.232-.805 6.273-1.832 11.206a3 3 0 0 1-5.874-1.222c.981-4.717 1.473-7.572 1.728-10.504zm-3.777 21.555a3 3 0 0 1 5.953.747c-.5 3.988-.397 7.09.399 9.67a3 3 0 1 1-5.733 1.769c-1.087-3.52-1.217-7.426-.62-12.186zm7.393 22.444a3 3 0 0 1 4.461-4.013c2.703 3.005 5.224 5.296 7.594 6.947a3 3 0 0 1-3.429 4.924c-2.775-1.932-5.632-4.53-8.626-7.858zm20.352 12.28a3 3 0 1 1 .334-5.99c2.77.154 5.453-.554 9.224-2.254a3 3 0 0 1 2.466 5.47c-4.57 2.06-8.103 2.993-12.024 2.775zm21.784-7.058a3 3 0 0 1-1.815-5.719c4.227-1.342 8.24-1.61 12.496-.572a3 3 0 0 1-1.421 5.83c-3.116-.76-6.025-.566-9.26.46zM106.53 56.038a3 3 0 1 1-3.45 4.909c-1.074-.755-6.723-6.044-8.083-7.204a68.019 68.019 0 0 0-.332-.281 3 3 0 1 1 3.865-4.59l.362.306c1.643 1.402 6.971 6.391 7.638 6.86zM88.536 42.422a3 3 0 0 1-2.285 5.548c-3.14-1.293-5.78-1.34-8.105-.05a3 3 0 0 1-2.91-5.247c4.087-2.266 8.597-2.187 13.3-.25zM66.698 48.73a3 3 0 0 1 2.029 5.647c-4.432 1.592-8.786.835-13.166-1.88a3 3 0 1 1 3.16-5.1c2.93 1.816 5.425 2.25 7.977 1.333zm-15.636-8.038a3 3 0 0 1-4.352 4.13c-.911-.96-1.85-1.98-3.061-3.32-.295-.325-2.437-2.703-3.07-3.4-.47-.518-.9-.988-1.313-1.436a3 3 0 0 1 4.41-4.068c.425.46.866.942 1.346 1.47.642.709 2.79 3.092 3.076 3.41a180.865 180.865 0 0 0 2.964 3.214z"/><path fill="#E1DBF1" d="M254.66 72.196l2-3.464a2 2 0 1 0-3.464-2l-2 3.464-3.464-2a2 2 0 0 0-2 3.464l3.464 2-2 3.464a2 2 0 0 0 3.464 2l2-3.464 3.464 2a2 2 0 1 0 2-3.464l-3.464-2zm-151.904 78.732l2.829-2.828a2 2 0 0 0-2.829-2.829l-2.828 2.829-2.828-2.829a2 2 0 0 0-2.829 2.829l2.829 2.828-2.829 2.829a2 2 0 1 0 2.829 2.828l2.828-2.828 2.828 2.828a2 2 0 1 0 2.829-2.828l-2.829-2.829z"/><path fill="#6B4FBB" d="M210.66 173.66l3.464-2a2 2 0 1 0-2-3.464l-3.464 2-2-3.464a2 2 0 0 0-3.464 2l2 3.464-3.464 2a2 2 0 1 0 2 3.464l3.464-2 2 3.464a2 2 0 1 0 3.464-2l-2-3.464z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M27 181a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm0-4a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M138 85a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M200 57a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#FC6D26" fill-rule="nonzero" d="M222.647 121.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M103.647 28.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FC6D26" fill-rule="nonzero" d="M85 103.488L81.841 108h6.318L85 103.488zm6.436 2.218A4 4 0 0 1 88.159 112H81.84a4 4 0 0 1-3.277-6.294l3.16-4.512a4 4 0 0 1 6.553 0l3.159 4.512z"/></g><path fill="#F9F9F9" d="M334.376 99.43A48.805 48.805 0 0 0 366 111c27.062 0 49-21.938 49-49s-21.938-49-49-49-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71z"/><path fill="#FFF" d="M339.376 94.43A48.805 48.805 0 0 0 371 106c27.062 0 49-21.938 49-49S398.062 8 371 8s-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71z"/><path fill="#EEE" fill-rule="nonzero" d="M329.85 99.072a4.5 4.5 0 0 1-5.516-5.517l2.827-10.48C322.501 75.258 320 66.31 320 57c0-28.167 22.833-51 51-51s51 22.833 51 51-22.833 51-51 51c-11.859 0-23.096-4.064-32.102-11.37l-9.048 2.442zm10.817-6.169C349.091 100.027 359.737 104 371 104c25.957 0 47-21.043 47-47s-21.043-47-47-47-47 21.043-47 47c0 8.859 2.453 17.351 7.016 24.716l.456.737-3.277 12.144c.072.527.347.685.613.613l11.059-2.984.8.677z"/><g transform="translate(354 34)"><path fill="#E1DBF1" fill-rule="nonzero" d="M13 4a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-8zm0-4h8a5 5 0 0 1 5 5v1a5 5 0 0 1-5 5h-8a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M5 11a1 1 0 0 0 0 2h24a1 1 0 0 0 0-2H5zm0-4h24a5 5 0 0 1 0 10H5A5 5 0 0 1 5 7z"/><rect width="12" height="4" x="11" y="31" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="19" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="37" fill="#E1DBF1" rx="2"/><rect width="12" height="4" x="11" y="43" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="25" fill="#E1DBF1" rx="2"/></g><path fill="#F9F9F9" d="M344.238 225.072A38.83 38.83 0 0 1 368 217c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213z"/><path fill="#FFF" d="M348.238 221.072A38.83 38.83 0 0 1 372 213c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213z"/><path fill="#EEE" fill-rule="nonzero" d="M336.85 215.928a4.5 4.5 0 0 0-5.516 5.517l3.543 13.13A40.848 40.848 0 0 0 331 252c0 22.644 18.356 41 41 41s41-18.356 41-41-18.356-41-41-41a40.82 40.82 0 0 0-24.182 7.887l-10.968-2.96zm12.608 6.73A36.824 36.824 0 0 1 372 215c20.435 0 37 16.565 37 37s-16.565 37-37 37-37-16.565-37-37c0-5.747 1.31-11.304 3.795-16.343l.334-.677-3.934-14.577a.5.5 0 0 1 .613-.613l12.865 3.471.785-.604z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M356.097 255.962a7 7 0 0 0 8.81 10.88l1.093-.885v1.454a7 7 0 1 0 14 0v-1.454l1.092.885a7 7 0 1 0 8.81-10.88l-1.185-.96 1.455-.337a7 7 0 1 0-3.15-13.64l-1.4.323.623-1.278a7 7 0 0 0-12.583-6.137l-.662 1.356-.662-1.356a7 7 0 0 0-12.583 6.137l.623 1.278-1.4-.324a7 7 0 1 0-3.15 13.641l1.455.336-1.186.96zm5.464-.913a11.914 11.914 0 0 1-.444-1.95l-.19-1.362-4.2-.97a3 3 0 0 1 1.35-5.845l4.178.964.768-1.145c.373-.557.793-1.082 1.254-1.57l.95-1.006-1.877-3.849a3 3 0 0 1 5.393-2.63l1.892 3.879 1.363-.113a12.188 12.188 0 0 1 2.004 0l1.363.113 1.892-3.879a3 3 0 0 1 5.393 2.63l-1.877 3.849.95 1.006c.461.488.88 1.013 1.254 1.57l.768 1.145 4.178-.964a3 3 0 1 1 1.35 5.846l-4.2.97-.19 1.36a11.914 11.914 0 0 1-.444 1.95l-.413 1.302 3.36 2.72a3 3 0 1 1-3.776 4.663l-3.32-2.688-1.196.706a11.94 11.94 0 0 1-1.808.873l-1.286.492v4.295a3 3 0 1 1-6 0v-4.295l-1.286-.492a11.94 11.94 0 0 1-1.808-.873l-1.196-.706-3.32 2.688a3 3 0 1 1-3.776-4.663l3.36-2.72-.413-1.301z"/><path fill="#FC6D26" fill-rule="nonzero" d="M373 245.411a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/><g><path fill="#F9F9F9" d="M94.624 162.43A48.805 48.805 0 0 1 63 174c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71z"/><path fill="#FFF" stroke="#EEE" stroke-width="4" d="M89.624 157.43A48.805 48.805 0 0 1 58 169c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71z"/><path fill="#EEE" fill-rule="nonzero" d="M99.15 162.072a4.5 4.5 0 0 0 5.516-5.517l-2.827-10.48C106.499 138.258 109 129.31 109 120c0-28.167-22.833-51-51-51S7 91.833 7 120s22.833 51 51 51c11.859 0 23.096-4.064 32.102-11.37l9.048 2.442zm-10.817-6.169C79.909 163.027 69.263 167 58 167c-25.957 0-47-21.043-47-47s21.043-47 47-47 47 21.043 47 47c0 8.859-2.453 17.351-7.016 24.716l-.456.737 3.277 12.144c-.072.527-.347.685-.613.613l-11.059-2.984-.8.677z"/><g fill-rule="nonzero"><path fill="#FEE1D3" d="M55.47 94.47l-16.148 6.688a4 4 0 0 0-2.164 2.164l-6.689 16.147a4 4 0 0 0 0 3.062l6.689 16.147a4 4 0 0 0 2.164 2.164l16.147 6.689a4 4 0 0 0 3.062 0l16.147-6.689a4 4 0 0 0 2.164-2.164l6.689-16.147a4 4 0 0 0 0-3.062l-6.689-16.147a4 4 0 0 0-2.164-2.164L58.53 94.469a4 4 0 0 0-3.062 0zM57 98.164l16.147 6.688L79.835 121l-6.688 16.147L57 143.835l-16.147-6.688L34.165 121l6.688-16.147L57 98.165zM57 107a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 20c6.075 0 11-4.925 11-11s-4.925-11-11-11-11 4.925-11 11 4.925 11 11 11zm0-4a7 7 0 1 1 0-14 7 7 0 0 1 0 14z"/><path fill="#FC6D26" d="M57 126.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0-3a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z"/></g></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/gitlab_logo.svg b/app/assets/images/illustrations/gitlab_logo.svg
deleted file mode 100644
index 8dbd75a340e..00000000000
--- a/app/assets/images/illustrations/gitlab_logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="492.509" height="453.68" viewBox="0 0 492.50943 453.67966"><g fill="none" fill-rule="evenodd"><path d="M491.589 259.398l-27.559-84.814L409.413 6.486c-2.81-8.648-15.045-8.648-17.856 0l-54.619 168.098H155.572L100.952 6.486c-2.81-8.648-15.046-8.648-17.856 0L28.478 174.584.921 259.398a18.775 18.775 0 0 0 6.82 20.992l238.513 173.29L484.77 280.39a18.777 18.777 0 0 0 6.82-20.992" fill="#fc6d26"/><path d="M246.255 453.68l90.684-279.096H155.57z" fill="#e24329"/><path d="M246.255 453.68L155.57 174.583H28.479z" fill="#fc6d26"/><path d="M28.479 174.584L.92 259.4a18.773 18.773 0 0 0 6.821 20.99l238.514 173.29z" fill="#fca326"/><path d="M28.479 174.584H155.57L100.952 6.487c-2.81-8.65-15.047-8.65-17.856 0z" fill="#e24329"/><path d="M246.255 453.68l90.684-279.096H464.03z" fill="#fc6d26"/><path d="M464.03 174.584l27.56 84.815a18.773 18.773 0 0 1-6.822 20.99L246.255 453.68z" fill="#fca326"/><path d="M464.03 174.584H336.94L391.557 6.487c2.811-8.65 15.047-8.65 17.856 0z" fill="#e24329"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/image_comment_light_cursor.svg b/app/assets/images/illustrations/image_comment_light_cursor.svg
deleted file mode 100644
index ac712ea0c96..00000000000
--- a/app/assets/images/illustrations/image_comment_light_cursor.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 38 38"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#FFF"/><path fill="#1F78D1" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/image_comment_light_cursor@2x.svg b/app/assets/images/illustrations/image_comment_light_cursor@2x.svg
deleted file mode 100644
index 02943acd9d7..00000000000
--- a/app/assets/images/illustrations/image_comment_light_cursor@2x.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 38 38"><g fill="none" fill-rule="evenodd"><circle cx="19" cy="19" r="18" fill="#FFF"/><path fill="#1F78D1" fill-rule="nonzero" d="M19 38C8.507 38 0 29.493 0 19S8.507 0 19 0s19 8.507 19 19-8.507 19-19 19zm0-2c9.389 0 17-7.611 17-17S28.389 2 19 2 2 9.611 2 19s7.611 17 17 17zm-6.293-8.293c-.63.63-1.707.184-1.707-.707V15a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-7.586l-3.707 3.707zM13 24.586l2.293-2.293A1 1 0 0 1 16 22h8a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H14a1 1 0 0 0-1 1v9.586z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/issues.svg b/app/assets/images/illustrations/issues.svg
deleted file mode 100644
index c8e0504732d..00000000000
--- a/app/assets/images/illustrations/issues.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="790 253 425 254" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="25" height="8.942" x="25" y="88.423" rx="2"/><mask id="h" width="25" height="8.942" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M16 29.801h43v61.603H16z"/><mask id="i" width="43" height="61.603" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><path id="c" d="M57 60.603l13.187 9.358c.449.32.876 1.015.955 1.568l3.575 24.863c.157 1.086-.253 1.257-.912.384L66 86.436l-9-6.955"/><mask id="j" width="17.75" height="36.731" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><path id="d" d="M.25 60.603l13.186 9.358c.45.32.876 1.015.956 1.568l3.575 24.863c.156 1.086-.253 1.257-.912.384l-7.806-10.34-9-6.955"/><mask id="k" width="17.75" height="36.731" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><path id="e" d="M16 29.801L35.786 1.456c.947-1.357 2.48-1.36 3.428 0L59 29.8"/><mask id="l" width="43" height="29.364" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><rect id="f" width="26.265" height="35.509" x="6.367" rx="13.133"/><mask id="m" width="26.265" height="35.509" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><rect id="g" width="16.837" height="22.386" x="4.082" rx="8.418"/><mask id="n" width="16.837" height="22.386" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(792 255)"><path d="M225.437 59.587c-.059.59-.132 1.27-.22 2.03a178.367 178.367 0 0 1-.965 7.07 1.5 1.5 0 1 0 2.963.465c.4-2.553.726-4.975.982-7.19a137.446 137.446 0 0 0 .297-2.832 1.5 1.5 0 1 0-2.989-.26c-.01.123-.033.365-.068.717zm-5.563 28.354a1.5 1.5 0 0 0 2.853.929c.975-2.997 1.849-6.283 2.628-9.797a1.5 1.5 0 1 0-2.928-.65c-.76 3.426-1.61 6.62-2.553 9.518zm-9.947 15.225a1.5 1.5 0 1 0 1.001 2.828c2.98-1.055 5.542-3.68 7.78-7.627a1.5 1.5 0 0 0-2.61-1.48c-1.915 3.378-3.995 5.508-6.171 6.279zm-19.488 4.417a1.5 1.5 0 1 0 1.164 2.765c3.12-1.314 6.272-2.324 9.258-2.981a1.5 1.5 0 1 0-.645-2.93c-3.167.697-6.491 1.763-9.777 3.146zm-17.208 11.043a1.5 1.5 0 0 0 2.066 2.175c2.282-2.169 4.866-4.162 7.676-5.946a1.5 1.5 0 0 0-1.608-2.533c-2.97 1.885-5.707 3.998-8.134 6.304zm-10.777 17.623a1.5 1.5 0 1 0 2.91.732c.768-3.054 2.041-5.977 3.78-8.748a1.5 1.5 0 0 0-2.54-1.595c-1.903 3.032-3.302 6.244-4.15 9.611zm-.265 20.444a1.5 1.5 0 1 0 2.977-.375c-.367-2.91-.58-6.137-.645-9.817a1.5 1.5 0 0 0-3 .053c.067 3.783.287 7.116.668 10.139zm6.219 19.472a1.5 1.5 0 0 0 2.652-1.403c-1.674-3.162-2.903-5.995-3.848-8.943a1.5 1.5 0 1 0-2.857.916c1.003 3.127 2.302 6.12 4.053 9.43zm7.566 12.77a595.837 595.837 0 0 1 2.73 4.475 1.5 1.5 0 0 0 2.569-1.551 626.463 626.463 0 0 0-2.744-4.495c.08.13-1.954-3.173-2.486-4.04a1.5 1.5 0 1 0-2.558 1.567c.534.87 2.571 4.178 2.489 4.045zm8.856 22.447a1.5 1.5 0 0 0 3-.039 32.214 32.214 0 0 0-1.837-10.326 1.5 1.5 0 0 0-2.828.999 29.212 29.212 0 0 1 1.665 9.366zm-5.483 18.028a1.5 1.5 0 0 0 2.497 1.662 36.203 36.203 0 0 0 4.488-9.416 1.5 1.5 0 0 0-2.868-.882 33.197 33.197 0 0 1-4.117 8.636z" fill="#FDE5D8"/><g transform="rotate(60 126.799 371.622)"><path stroke="#FDE5D8" stroke-width="3" d="M19 154l10-52.66m16 0L55 154" stroke-linecap="round"/><rect width="3" height="38.75" x="35" y="99.353" fill="#FDE5D8" rx="1.5"/><use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#h)" xlink:href="#a"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#i)" xlink:href="#b"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#j)" xlink:href="#c"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#k)" transform="matrix(-1 0 0 1 18.25 0)" xlink:href="#d"/><use stroke="#FDE5D8" stroke-width="6" mask="url(#l)" xlink:href="#e"/><ellipse cx="28.5" cy="82.958" fill="#FC8A51" rx="1.5" ry="1.49"/><ellipse cx="34.5" cy="82.958" fill="#FC8A51" rx="1.5" ry="1.49"/><ellipse cx="40.5" cy="82.958" fill="#FC8A51" rx="1.5" ry="1.49"/><ellipse cx="46.5" cy="82.958" fill="#FC8A51" rx="1.5" ry="1.49"/><ellipse cx="37.5" cy="55.138" stroke="#FDE5D8" stroke-width="3" rx="10.5" ry="10.433"/><ellipse cx="37.5" cy="55.138" stroke="#FDE5D8" stroke-width="3" rx="5.5" ry="5.465"/></g><path fill="#EEE" d="M96.043 37.21c-.152 1.688.081 3.816.997 6.147a1.016 1.016 0 0 0 1.89-.74c-.791-2.014-.99-3.832-.865-5.226.01-.114.02-.186.024-.211a1.015 1.015 0 1 0-2.002-.333 5.06 5.06 0 0 0-.044.363zm11.487 15.683c.491.24 1.098.063 1.355-.394.257-.456.068-1.02-.424-1.26-1.866-.907-3.458-1.914-4.794-3.007a1.058 1.058 0 0 0-1.417.085.888.888 0 0 0 .091 1.317c1.458 1.192 3.183 2.283 5.19 3.26zm13.131 6.06a1.032 1.032 0 0 0 1.293-.7 1.06 1.06 0 0 0-.686-1.32 376.355 376.355 0 0 1-5.915-1.882 1.031 1.031 0 0 0-1.303.681 1.06 1.06 0 0 0 .668 1.33c1.729.569 2.905.94 5.943 1.891zm11.934 3.928c.45.246 1.022.098 1.28-.33a.872.872 0 0 0-.346-1.221c-1.494-.819-3.192-1.545-5.267-2.275-.486-.17-1.025.067-1.204.53-.18.464.07.978.555 1.149 1.984.697 3.59 1.384 4.982 2.147zm9.382 10.502c.205.494.81.742 1.349.554.54-.188.81-.74.605-1.234-.85-2.048-1.853-3.796-3.037-5.305-.337-.429-.99-.527-1.459-.218-.469.308-.575.906-.238 1.335 1.074 1.368 1.992 2.97 2.78 4.868zm2.632 13.642c.018.553.568.99 1.228.975.66-.016 1.18-.477 1.163-1.03-.073-2.204-.27-4.206-.622-6.12-.101-.547-.712-.923-1.365-.838-.652.084-1.1.597-.999 1.144.336 1.825.525 3.745.595 5.869z"/><path fill="#E5E5E5" d="M144.142 95.73a244.285 244.285 0 0 0-.142 5.254c-.007.553.396 1.008.902 1.016.506.008.923-.433.93-.985.02-1.467.056-2.681.142-5.211l.026-.767c.018-.552-.377-1.016-.882-1.036-.506-.02-.931.41-.95.963l-.026.766zm.797 19.471c.12.545.673.892 1.236.777.562-.116.921-.651.802-1.196-.417-1.9-.71-3.84-.897-5.864-.052-.554-.558-.964-1.131-.914-.573.05-.996.54-.945 1.094.195 2.102.5 4.121.935 6.103zm5.056 12.324c.296.454.953.61 1.467.348.514-.261.69-.841.395-1.295a40.725 40.725 0 0 1-2.79-4.991c-.227-.485-.855-.715-1.403-.515-.548.2-.81.755-.582 1.239a42.56 42.56 0 0 0 2.913 5.214zm4.814 7.701a33.475 33.475 0 0 0 3.543 3.531 1.021 1.021 0 0 0 1.393-.066.908.908 0 0 0-.07-1.326 31.562 31.562 0 0 1-3.34-3.328 59.092 59.092 0 0 1-.576-.682 1.02 1.02 0 0 0-1.386-.152.909.909 0 0 0-.16 1.32c.196.234.394.469.596.703zm15.825 11.677c.48.242 1.052.017 1.276-.501.224-.52.016-1.136-.464-1.378a49.756 49.756 0 0 1-4.986-2.872c-.453-.298-1.044-.144-1.32.345-.276.488-.133 1.126.32 1.424a51.568 51.568 0 0 0 5.174 2.982z"/><path fill="#EEE" d="M184.733 151.97c.553.141 1.108-.226 1.239-.82.131-.595-.21-1.192-.763-1.333a72.17 72.17 0 0 1-5.863-1.763c-.54-.188-1.12.13-1.296.712-.175.581.121 1.205.662 1.393a74.018 74.018 0 0 0 6.021 1.81zm13.2 2.028c.554.04 1.03-.445 1.065-1.083.035-.639-.386-1.188-.939-1.228a71.842 71.842 0 0 1-5.92-.676c-.55-.086-1.055.358-1.13.991-.074.634.31 1.217.86 1.303a73.28 73.28 0 0 0 6.065.693zm14.188-1.392c.55-.055.94-.457.871-.9-.068-.441-.569-.755-1.118-.7-1.917.192-3.893.32-5.91.382-.554.017-.985.392-.963.837.021.445.487.792 1.04.774a88.939 88.939 0 0 0 6.08-.393zm14.245-2.657c.53-.22.776-.816.55-1.332a1.053 1.053 0 0 0-1.367-.535 44.421 44.421 0 0 1-5.777 1.923 1.012 1.012 0 0 0-.736 1.243c.15.542.721.863 1.277.717a46.532 46.532 0 0 0 6.054-2.016zm11.483-9.532c.292-.435.148-1.006-.32-1.277-.47-.27-1.087-.138-1.379.297-.957 1.424-2.225 2.734-3.784 3.92a.88.88 0 0 0-.138 1.304c.35.396.98.453 1.408.128 1.723-1.31 3.136-2.771 4.213-4.372zm7.824-9.73a.965.965 0 0 0 .09-1.358.958.958 0 0 0-1.355-.09 44.935 44.935 0 0 0-4.17 4.163.965.965 0 0 0 .089 1.359.957.957 0 0 0 1.354-.089 43.05 43.05 0 0 1 3.991-3.985zm11.808-7.817c.476-.257.657-.858.405-1.342a.967.967 0 0 0-1.319-.412 67.097 67.097 0 0 0-5.123 3.059c-.451.298-.58.913-.287 1.373.294.46.898.59 1.35.292a65.257 65.257 0 0 1 4.974-2.97zm12.795-5.948c.55-.169.851-.724.672-1.241-.179-.518-.77-.8-1.32-.632a92.308 92.308 0 0 0-5.975 2.054c-.536.205-.794.78-.576 1.283.218.504.83.746 1.366.541a90.115 90.115 0 0 1 5.833-2.005z"/><circle cx="145" cy="90" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><circle cx="238" cy="138" r="5" fill="#FFF" stroke="#EEE" stroke-width="2"/><path stroke="#B5A7DD" stroke-width="3" d="M20.06 56s-17.47 33-12 53c5.47 20 17 32 38 44s32.44-5 60.94 6 29 43 29 43" stroke-linecap="round" stroke-dasharray="8 10"/><g stroke="#EEE" stroke-width="3" transform="translate(108 173)"><path fill="#FFF" d="M154 77c0-42.526-34.474-77-77-77S0 34.474 0 77" stroke-linecap="round"/><circle cx="108" cy="41" r="16"/><circle cx="42.5" cy="30.5" r="8.5"/><circle cx="22" cy="58" r="5"/></g><g fill="#FC8A51" transform="rotate(15 101.633 923.121)"><path d="M.398 11.298h2.388c0-4.234 3.385-7.666 7.56-7.666V1.21C4.853 1.21.399 5.727.399 11.298z"/><ellipse cx="10.745" cy="2.018" rx="1.99" ry="2.018"/></g><g fill="#FC8A51" transform="scale(-1 1) rotate(-15 -102.031 920.099)"><path d="M.398 11.298h2.388c0-4.234 3.385-7.666 7.56-7.666V1.21C4.853 1.21.399 5.727.399 11.298z"/><ellipse cx="10.745" cy="2.018" rx="1.99" ry="2.018"/></g><g transform="rotate(15 71.738 842.306)"><g fill="#FC8A51" transform="translate(29.449 11.298)"><rect width="7.959" height="2" x=".796" y="8.877" rx="1"/><rect width="7.959" height="2" x=".796" y="16.14" transform="rotate(15 4.776 17.14)" rx="1"/><rect width="7.959" height="2" x=".915" y="1.807" transform="rotate(-15 4.895 2.807)" rx="1"/></g><g fill="#FC8A51" transform="matrix(-1 0 0 1 9.551 11.298)"><rect width="7.959" height="2" x=".796" y="8.877" rx="1"/><rect width="7.959" height="2" x=".796" y="16.14" transform="rotate(15 4.776 17.14)" rx="1"/><rect width="7.959" height="2" x=".915" y="1.807" transform="rotate(-15 4.895 2.807)" rx="1"/></g><use stroke="#FC8A51" stroke-width="6" mask="url(#m)" xlink:href="#f"/><path fill="#FC8A51" d="M7.163 12.912h23.878v3H7.163z"/></g><g fill="#EEE" transform="scale(-1 1) rotate(15 -60.75 -335.206)"><path d="M.255 7.123h1.53a4.84 4.84 0 0 1 4.848-4.834V.763C3.11.763.255 3.611.255 7.123z"/><ellipse cx="6.888" cy="1.272" rx="1.276" ry="1.272"/></g><g fill="#EEE" transform="rotate(-15 60.494 -337.144)"><path d="M.255 7.123h1.53a4.84 4.84 0 0 1 4.848-4.834V.763C3.11.763.255 3.611.255 7.123z"/><ellipse cx="6.888" cy="1.272" rx="1.276" ry="1.272"/></g><g transform="scale(-1 1) rotate(15 -79.491 -386.955)"><g fill="#EEE" transform="translate(18.878 7.123)"><rect width="5.102" height="2" x=".51" y="5.596" rx="1"/><rect width="5.102" height="2" x=".51" y="10.175" transform="rotate(15 3.061 11.175)" rx="1"/><rect width="5.102" height="2" x=".587" y="1.139" transform="rotate(-15 3.138 2.14)" rx="1"/></g><g fill="#EEE" transform="matrix(-1 0 0 1 6.122 7.123)"><rect width="5.102" height="2" x=".51" y="5.596" rx="1"/><rect width="5.102" height="2" x=".51" y="10.175" transform="rotate(15 3.061 11.175)" rx="1"/><rect width="5.102" height="2" x=".587" y="1.139" transform="rotate(-15 3.138 2.14)" rx="1"/></g><use stroke="#EEE" stroke-width="4" mask="url(#n)" xlink:href="#g"/><path fill="#EEE" d="M4.592 8.14h15.306v2H4.592z"/></g><g fill="#FFF" transform="translate(0 103)"><circle cx="8.5" cy="8.5" r="8.5" stroke="#B5A7DD" stroke-width="4"/><circle cx="171.5" cy="20.5" r="6.5"/></g><g transform="translate(39 142)"><ellipse cx="12.5" cy="12.5" fill="#FFF" stroke="#6B4FBB" stroke-width="4" rx="12.5" ry="12.5"/><path fill="#FC8A51" d="M10.732 13.475l-1.766-1.767a1.5 1.5 0 1 0-2.122 2.122l2.826 2.826h.001v.001c.59.59 1.535.587 2.119.003l6.37-6.37a1.504 1.504 0 0 0-.003-2.118 1.494 1.494 0 0 0-2.118-.004l-5.307 5.307z"/></g><circle cx="171.5" cy="122.5" r="6.5" fill="#FFF" stroke="#FC8A51" stroke-width="3"/><circle cx="22" cy="52" r="6" fill="#FFF" stroke="#B5A7DD" stroke-width="3"/><path fill="#FFF" stroke="#B5A7DD" stroke-width="3.6" d="M188.151 141.596c8.704-7.746 11.013-20.925 4.862-31.578-7.02-12.16-22.405-16.422-34.362-9.518-11.958 6.904-15.96 22.358-8.939 34.518 6.236 10.8 19.068 15.37 30.238 11.42l10.899 18.879a4.765 4.765 0 0 0 6.508 1.748 4.768 4.768 0 0 0 1.74-6.51l-10.946-18.959zm-8.434-4.609c7.857-4.536 10.487-14.692 5.873-22.683-4.613-7.991-14.723-10.791-22.58-6.255-7.858 4.537-10.488 14.693-5.875 22.684 4.614 7.99 14.724 10.791 22.582 6.254z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/labels.svg b/app/assets/images/illustrations/labels.svg
deleted file mode 100644
index 3a2d521323b..00000000000
--- a/app/assets/images/illustrations/labels.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="787 240 386 274" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><circle id="a" cx="37" cy="107" r="8"/><mask id="e" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><circle id="b" cx="37" cy="75" r="8"/><mask id="f" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><circle id="c" cx="42" cy="93" r="8"/><mask id="g" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><circle id="d" cx="43" cy="75" r="8"/><mask id="h" width="16" height="16" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(791 244)"><g transform="rotate(30 49.554 229.722)"><rect width="74" height="124" x="8.6" y="95.9" fill="#FAFAFA" rx="8"/><rect width="74" height="124" y="87" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><circle cx="26.5" cy="178.5" r="3.5" fill="#FC8A51"/><circle cx="47.5" cy="178.5" r="3.5" fill="#FC8A51"/><rect width="50" height="4" x="12" y="127" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="18" y="139" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#e)" stroke-linecap="round" xlink:href="#a"/><path stroke="#EEE" stroke-width="4" d="M37.3 107S10.5 18.3 81 .6" stroke-linecap="round"/><path fill="#FDE5D8" d="M31 189c0 3.3 2.7 6 6 6s6-2.7 6-6"/></g><g transform="translate(105 47)"><rect width="74" height="124" y="64" fill="#FAFAFA" rx="8"/><rect width="74" height="124" y="55" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><rect width="50" height="4" x="12" y="95" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="18" y="107" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#f)" stroke-linecap="round" xlink:href="#b"/><path fill="#B5A7DD" d="M56 149.7c-.6-1-.2-2 .7-2.7l1.8-1c1-.6 2-.2 2.7.7.5 1 .2 2.2-.7 2.8l-1.8 1c-1 .5-2 .2-2.7-.8zm-37.8 0c.5-1 .2-2-.7-2.7l-1.8-1c-1-.6-2-.2-2.7.7-.6 1-.2 2.2.7 2.8l1.8 1c1 .5 2 .2 2.7-.8zM33 151h9v4h-9v-4z"/><path fill="#6B4FBB" d="M59 153c0-5.5-4.6-10-10-10-5.7 0-10 4.5-10 10s4.3 10 10 10c5.4 0 10-4.5 10-10zm-16 0c0-3.3 2.6-6 6-6 3.2 0 6 2.7 6 6s-2.8 6-6 6c-3.4 0-6-2.7-6-6zm-8 0c0-5.5-4.6-10-10-10-5.7 0-10 4.5-10 10s4.3 10 10 10c5.4 0 10-4.5 10-10zm-16 0c0-3.3 2.6-6 6-6 3.2 0 6 2.7 6 6s-2.8 6-6 6c-3.4 0-6-2.7-6-6z"/><path stroke="#EEE" stroke-width="4" d="M37 75S30 0 80 0" stroke-linecap="round"/></g><g transform="rotate(15 -82.507 752.644)"><rect width="74" height="124" x="14.6" y="81.8" fill="#FAFAFA" rx="8"/><rect width="74" height="124" x="5" y="73" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><path fill="#FDE5D8" d="M41 147c0-1 1-2 2-2s2 1 2 2v3c0 1-1 2-2 2s-2-1-2-2v-3zm16.8 6.2c.8-.7 2-.6 2.8.3.7.8.5 2-.3 2.8L58 158c-1 .8-2.2.7-3 0-.6-1-.4-2.3.4-3l2.4-1.8zm-32 3c-1-.6-1-2-.4-2.7.7-1 2-1 2.8-.3l2.4 1.8c.8.7 1 2 .3 3-.8.7-2 1-3 0l-2.3-1.7z"/><rect width="2" height="7" x="39" y="168" fill="#FC8A51" rx="1"/><rect width="2" height="7" x="45" y="168" fill="#FC8A51" rx="1"/><circle cx="40" cy="169" r="2" fill="#FC8A51"/><circle cx="46" cy="169" r="2" fill="#FC8A51"/><rect width="22" height="18" x="32" y="158" stroke="#FC8A51" stroke-width="4" rx="8"/><rect width="34" height="5" x="26" y="174" fill="#FC8A51" rx="2.5"/><rect width="50" height="4" x="17" y="113" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="23" y="125" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#g)" stroke-linecap="round" xlink:href="#c"/><path stroke="#EEE" stroke-width="4" d="M42 93S50 0 0 0" stroke-linecap="round"/></g><g transform="rotate(-15 276.18 -697.744)"><rect width="74" height="124" x="18.7" y="65.6" fill="#FAFAFA" rx="8"/><rect width="74" height="124" x="6" y="55" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="8"/><g transform="translate(25 129)"><path stroke="#B5A7DD" stroke-width="4" d="M32 14c0-7.7-6.3-14-14-14S4 6.3 4 14" stroke-linecap="round"/><path stroke="#B5A7DD" stroke-width="2" d="M33 15v13c0 4.4-3.6 8-8 8" stroke-linecap="round"/><rect width="7" height="4" x="20" y="34" fill="#6B4FBB" rx="2"/><rect width="7" height="13" y="15" fill="#FFF" stroke="#6B4FBB" stroke-width="3" stroke-linejoin="round" rx="3.5"/><rect width="7" height="13" x="29" y="15" fill="#FFF" stroke="#6B4FBB" stroke-width="3" stroke-linejoin="round" transform="matrix(-1 0 0 1 65 0)" rx="3.5"/></g><rect width="50" height="4" x="18" y="95" fill="#E5E5E5" rx="2"/><rect width="38" height="4" x="24" y="107" fill="#E5E5E5" rx="2"/><use stroke="#E5E5E5" stroke-width="8" mask="url(#h)" stroke-linecap="round" xlink:href="#d"/><path stroke="#EEE" stroke-width="4" d="M43 75S50 0 0 0" stroke-linecap="round"/></g><circle cx="193" cy="47" r="12" fill="#FFF" stroke="#FDE5D8" stroke-width="4"/><circle cx="193" cy="47" r="5" fill="#FFF" stroke="#FDE5D8" stroke-width="4"/><g opacity=".2"><path fill="#FC8A51" d="M30.7 254.8l-2.6 1c-1 .5-1.7 0-1.7-1v-3l-1-2.7c-.4-1 .2-1.7 1.2-1.7h3l2.6-1c1.2-.4 2 .2 2 1.2l-.2 3 1 2.6c.5 1.2 0 2-1 2l-3-.2zm344-121l-2.6 1c-1 .5-1.7 0-1.7-1v-3l-1-2.7c-.4-1 .2-1.7 1.2-1.7h3l2.6-1c1.2-.4 2 .2 2 1.2l-.2 3 1 2.6c.5 1.2 0 2-1 2l-3-.2zM5.6 95H1.8c-1.3.2-2-.8-1.4-2l1.4-3.4-.2-3.8c0-1.3 1-2 2-1.4l3.6 1.4 3.7-.2c1.2 0 2 1 1.4 2L11 91.3V95c.2 1.2-.8 2-2 1.4L5.6 95z"/><path fill="#6B4FBB" d="M308.8 62l-2-2.3c-.7-.8-.5-1.7.6-2l2.8-1 2-2c1-.6 1.8-.4 2.2.7l.8 2.8 2 2c.8 1 .5 1.8-.5 2.2l-2.8.8-2.3 2c-.8.8-1.7.5-2-.5l-1-2.8zm9.2 164.6h-3c-1-.2-1.4-1-1-2l1.4-2.5v-3c.2-1 1-1.4 2-1l2.6 1.4h3c1 .2 1.5 1 1 2l-1.4 2.6v3c-.2 1-1 1.5-2 1l-2.5-1.4zM121.8 8l-2-2.3c-.7-.8-.5-1.7.6-2l2.8-1 2-2c1-.6 1.8-.4 2.2.7l.8 2.8 2 2c.8 1 .5 1.8-.5 2.2l-2.8.8-2.3 2c-.8.8-1.7.5-2-.5l-1-2.8z"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/logos/go_logo.svg b/app/assets/images/illustrations/logos/go_logo.svg
deleted file mode 100644
index 7fd49118006..00000000000
--- a/app/assets/images/illustrations/logos/go_logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 16 16"><g fill-rule="evenodd"><path d="M14 16.01h1V7.99C15 4.128 11.866.999 8 .999c-3.858 0-7 3.13-7 6.991v8.02h1V7.99c0-3.306 2.691-5.991 6-5.991 3.314 0 6 2.682 6 5.991v8.02M3.48 2.656a2 2 0 1 0-2.155 3.228c.102-.321.226-.631.371-.93a1.001 1.001 0 1 1 1.069-1.599 6.96 6.96 0 0 1 .717-.699m9.04-.002a2 2 0 1 1 2.155 3.23 6.835 6.835 0 0 0-.37-.931 1 1 0 1 0-1.068-1.599 6.96 6.96 0 0 0-.717-.699"/><path d="M5.726 8.04h1.557v.124c0 .283-.033.534-.1.752a1.583 1.583 0 0 1-.33.566c-.35.394-.795.591-1.335.591-.527 0-.979-.19-1.355-.571a1.893 1.893 0 0 1-.564-1.377c0-.547.191-1.01.574-1.391a1.902 1.902 0 0 1 1.396-.574c.295 0 .57.06.825.181.244.12.484.316.72.586l-.405.388c-.309-.412-.686-.618-1.13-.618-.399 0-.733.138-1 .413-.27.27-.405.609-.405 1.015 0 .42.151.766.452 1.037.282.252.587.378.915.378.28 0 .531-.094.754-.283.223-.19.347-.418.373-.683h-.94v-.535m2.884.061c0-.53.194-.986.583-1.367a1.919 1.919 0 0 1 1.396-.571c.537 0 .998.192 1.382.576.386.384.578.845.578 1.384 0 .542-.194 1-.581 1.379a1.944 1.944 0 0 1-1.408.569c-.487 0-.923-.168-1.311-.505-.426-.373-.64-.861-.64-1.465m.574.007c0 .417.14.759.42 1.028.278.269.6.403.964.403.395 0 .729-.137 1-.41.272-.277.408-.613.408-1.01 0-.402-.134-.739-.403-1.01a1.33 1.33 0 0 0-.991-.41c-.392 0-.723.137-.993.41a1.36 1.36 0 0 0-.405 1m-.184 3.918c.525.026.812.063.812.063.271.025.324-.096.116-.273 0 0-.775-.813-1.933-.813-1.159 0-1.923.813-1.923.813-.211.174-.153.3.12.273 0 0 .286-.037.81-.063v.477c0 .268.224.5.5.5.268 0 .5-.223.5-.498v-.252.25c0 .268.224.5.5.5.268 0 .5-.223.5-.498v-.478m-1-1.023c.552 0 1-.224 1-.5s-.448-.5-1-.5-1 .224-1 .5.448.5 1 .5"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/logos/mattermost_logo.svg b/app/assets/images/illustrations/logos/mattermost_logo.svg
deleted file mode 100644
index b577c0599aa..00000000000
--- a/app/assets/images/illustrations/logos/mattermost_logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" version="1" viewBox="0 0 501 501"><path d="M236 .7C137.7 7.5 54 68.2 18.2 158.5c-32 81-19.6 172.8 33 242.5 39.8 53 97.2 87 164.3 97 16.5 2.7 48 3.2 63.5 1.2 48.7-6.3 92.2-24.6 129-54.2 13-10.5 33-31.2 42.2-43.7 26.4-35.5 42.8-75.8 49-120.3 1.6-12.3 1.6-48.7 0-61-4-28.3-12-54.8-24.2-79.5-12.8-26-26.5-45.3-46.8-65.8C417.8 64 400.2 49 398.4 49c-.6 0-.4 10.5.3 26l1.3 26 7 8.7c19 23.7 32.8 53.5 38.2 83 2.5 14 3 43 1 55.8-4.5 27.8-15.2 54-31 76.5-8.6 12.2-28 31.6-40.2 40.2-24 17-50 27.6-80 33-10 1.8-49 1.8-59 0-43-7.7-78.8-26-107.2-54.8-29.3-29.7-46.5-64-52.4-104.4-2-14-1.5-42 1-55C90 121.4 132 72 192 49.7c8-3 18.4-5.8 29.5-8.2 1.7-.4 34.4-38 35.3-40.6.3-1-10.2-1-20.8-.4z"/><path d="M322.2 24.6c-1.3.8-8.4 9.3-16 18.7-7.4 9.5-22.4 28-33.2 41.2-51 62.2-66 81.6-70.6 91-6 12-8.4 21-9 33-1.2 19.8 5 36 19 50C222 268 230 273 243 277.2c9 3 10.4 3.2 24 3.2 13.8 0 15 0 22.6-3 23.2-9 39-28.4 45-55.7 2-8.2 2-28.7.4-79.7l-2-72c-1-36.8-1.4-41.8-3-44-2-3-4.8-3.6-7.8-1.4z"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/merge_request_changes_empty.svg b/app/assets/images/illustrations/merge_request_changes_empty.svg
deleted file mode 100644
index 707efa736e4..00000000000
--- a/app/assets/images/illustrations/merge_request_changes_empty.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 374 268" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="d" width="230" height="176" rx="10" fill="#fff"/><rect id="e" width="14" rx="2" height="4"/><rect id="f" width="14" x="40" rx="2" height="4"/><rect id="g" width="14" x="40" y="24" rx="2" height="4"/><rect id="h" width="7" x="20" y="12" rx="2" height="4"/><rect id="i" width="7" y="24" rx="2" height="4"/><rect id="j" width="7" x="33" y="12" rx="2" height="4"/><circle id="l" cx="31" cy="31" r="31"/><circle id="c" cx="35" cy="35" r="35"/><circle id="a" cx="44" cy="44" r="44"/><circle id="b" cx="31" cy="31" r="31"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 94)"><circle cx="57" cy="57" r="44" fill="#f9f9f9"/><g transform="rotate(-7.999 120.507 -22.508)"><use fill="#fff" xlink:href="#a"/><circle cx="44" cy="44" r="42" stroke="#eee" stroke-width="4"/><path fill="#fee1d3" fill-rule="nonzero" d="M34.394 55.736A4 4 0 0 1 36.706 55H56a6 6 0 0 0 6-6V35a6 6 0 0 0-6-6H34a6 6 0 0 0-6 6v25.26l6.394-4.529m2.312 3.264l-7.972 5.647A3.001 3.001 0 0 1 24 62.194v-27.2c0-5.523 4.477-10 10-10h22c5.523 0 10 4.477 10 10v14c0 5.523-4.477 10-10 10H36.706"/><path fill="#fc6d26" d="M38 40a2 2 0 1 1 .001 3.999A2 2 0 0 1 38 40m7 0a2 2 0 1 1 .001 3.999A2 2 0 0 1 45 40m7 0a2 2 0 1 1 .001 3.999A2 2 0 0 1 52 40"/></g></g><g transform="translate(48)"><circle cx="41" cy="41" r="31" fill="#f9f9f9"/><g transform="rotate(-7.999 84.554 -15.551)"><use fill="#fff" xlink:href="#b"/><circle cx="31" cy="31" r="29" stroke="#eee" stroke-width="4"/><rect width="20" height="4" x="21" y="29" fill="#6b4fbb" rx="2"/></g></g><path fill="#f9f9f9" d="M235.58 229H102c-6.627 0-12-5.373-12-12V65c0-6.627 5.373-12 12-12h206c6.627 0 12 5.373 12 12v18.399A34.834 34.834 0 0 1 337 79c19.33 0 35 15.67 35 35s-15.67 35-35 35a34.831 34.831 0 0 1-17-4.399v72.4c0 6.627-5.373 12-12 12h-11.58c.381 1.941.58 3.947.58 6 0 17.12-13.879 31-31 31-17.12 0-31-13.879-31-31 0-2.053.2-4.059.58-6"/><g transform="translate(87 50)"><g transform="rotate(7.999 -44.933 1563.894)"><use fill="#fff" xlink:href="#c"/><circle cx="35" cy="35" r="33" stroke="#eee" stroke-width="4"/><g transform="translate(20 19)"><circle cx="15" cy="16" r="15" fill="#f4f1fa" stroke="#6b4fbb" stroke-width="3"/><g fill="#6b4fbb"><path d="M19.419 6.996h-.007L16.959 4l-2.454 2.997H14.5L12.046 4 9.591 6.998h-.003L7.133 4 4.677 6.999H2.001c2.605-4.204 7.231-7 12.502-7 5.269 0 9.892 2.793 12.498 6.994h-2.676l-2.452-2.994-2.453 2.996"/><circle cx="9.5" cy="17.5" r="1.5"/><circle cx="20.5" cy="17.5" r="1.5"/></g></g></g><use xlink:href="#d"/><rect width="226" height="172" x="2" y="2" stroke="#eee" stroke-width="4" rx="10"/><rect width="4" height="122" x="33" y="42" fill="#eee" rx="2"/><g transform="translate(13 59)"><rect width="10" height="4" fill="#fee1d3" rx="2"/><rect width="10" height="4" y="12" fill="#f0edf8" rx="2"/><rect width="10" height="4" y="24" fill="#fef0e9" rx="2"/><rect width="10" height="4" y="36" fill="#fee1d3" rx="2"/><rect width="10" height="4" y="48" fill="#e1dbf1" rx="2"/><rect width="10" height="4" y="60" fill="#f0edf8" rx="2"/><rect width="10" height="4" y="72" fill="#fef0e9" rx="2"/><rect width="10" height="4" y="84" fill="#fee1d3" rx="2"/></g><g transform="translate(55 59)"><use fill="#6b4fbb" xlink:href="#e"/><rect width="14" height="4" x="20" fill="#f0edf8" rx="2"/><use fill="#fef0e9" xlink:href="#f"/><rect width="14" height="4" y="12" fill="#f0edf8" rx="2"/><use fill="#fef0e9" xlink:href="#g"/><rect width="14" height="4" y="48" fill="#e1dbf1" rx="2"/><rect width="14" height="4" x="40" y="36" fill="#fef0e9" rx="2"/><use fill="#fee1d3" xlink:href="#h"/><rect width="7" height="4" x="27" y="36" fill="#6b4fbb" rx="2"/><rect width="7" height="4" x="20" y="48" fill="#fee1d3" rx="2"/><use fill="#fc6d26" xlink:href="#i"/><rect width="21" height="4" x="13" y="24" fill="#e1dbf1" rx="2"/><rect width="21" height="4" y="36" fill="#eee" rx="2"/><use fill="#6b4fbb" xlink:href="#j"/><g transform="translate(98)"><use fill="#fee1d3" xlink:href="#e"/><rect width="14" height="4" x="20" fill="#f0edf8" rx="2"/><use fill="#fc6d26" xlink:href="#f"/><rect width="14" height="4" y="12" fill="#fef0e9" rx="2" id="k"/><use fill="#e1dbf1" xlink:href="#g"/><rect width="14" height="4" y="48" fill="#f0edf8" rx="2"/><rect width="14" height="4" x="40" y="36" fill="#fee1d3" rx="2"/><use fill="#fc6d26" xlink:href="#h"/><rect width="7" height="4" x="27" y="36" fill="#6b4fbb" rx="2"/><rect width="7" height="4" x="20" y="48" fill="#fc6d26" rx="2"/><use fill="#6b4fbb" xlink:href="#i"/><rect width="21" height="4" x="13" y="24" fill="#fee1d3" rx="2"/><rect width="21" height="4" y="36" fill="#fef0e9" rx="2"/><use fill="#6b4fbb" xlink:href="#j"/></g><g transform="translate(0 60)"><use fill="#f0edf8" xlink:href="#e"/><rect width="14" height="4" x="20" fill="#6b4fbb" rx="2"/><use fill="#e1dbf1" xlink:href="#f"/><use xlink:href="#k"/><use fill="#fee1d3" xlink:href="#g"/><use fill="#eee" xlink:href="#h"/><use fill="#6b4fbb" xlink:href="#i"/><rect width="21" height="4" x="13" y="24" fill="#fef0e9" rx="2"/><use fill="#fc6d26" xlink:href="#j"/></g><rect width="4" height="63" x="74" y="13" fill="#eee" rx="2"/></g><rect width="230" height="4" y="27" fill="#eee" rx="2"/></g><g transform="rotate(7.999 -1289.786 1797.583)"><use fill="#fff" xlink:href="#l"/><circle cx="31" cy="31" r="29" stroke="#eee" stroke-width="4"/><path fill="#fc6d26" d="M29 29h-6a2 2 0 1 0 0 4h6v6a2 2 0 1 0 4 0v-6h6a2 2 0 1 0 0-4h-6v-6a2 2 0 1 0-4 0v6"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/merge_requests.svg b/app/assets/images/illustrations/merge_requests.svg
deleted file mode 100644
index b9b8f0058e6..00000000000
--- a/app/assets/images/illustrations/merge_requests.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="755 221 385 225" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="278" height="179" rx="10"/><mask id="d" width="278" height="179" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M13.6 49H57c5.5 0 10-4.5 10-10V10c0-5.5-4.5-10-10-10H10C4.5 0 0 4.5 0 10v42c0 5.5 3.2 7 7.2 3l6.4-6z"/><mask id="e" width="67" height="57.2" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><path id="c" d="M13.6 49H57c5.5 0 10-4.5 10-10V10c0-5.5-4.5-10-10-10H10C4.5 0 0 4.5 0 10v42c0 5.5 3.2 7 7.2 3l6.4-6z"/><mask id="f" width="67" height="57.2" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#F9F9F9" transform="translate(752 227)"><rect width="120" height="22" x="30" rx="11"/><rect width="132" height="22" y="44" rx="11"/><rect width="190" height="22" x="208" y="66" rx="11"/><rect width="158" height="22" x="129" y="197" rx="11"/><rect width="158" height="22" x="66" y="154" rx="11"/><rect width="350" height="22" x="31" y="110" rx="11"/><path d="M153 22H21h21.5c6 0 11 5 11 11s-5 11-11 11H21h132-36.5c-6 0-11-5-11-11s5-11 11-11H153zm252 66H288h36.5c6 0 11 5 11 11s-5 11-11 11H288h117-36.5c-6 0-11-5-11-11s5-11 11-11H405zm-244 44H44h36.5c6 0 11 5 11 11s-5 11-11 11H44h117-36.5c-6 0-11-5-11-11s5-11 11-11H161zm75 44H119h21.5c6 0 11 5 11 11s-5 11-11 11H119h117-51.5c-6 0-11-5-11-11s5-11 11-11H236z"/></g><g transform="translate(812 240)"><use fill="#FFF" stroke="#EEE" stroke-width="8" mask="url(#d)" xlink:href="#a"/><path fill="#EEE" d="M4 29h271v4H4z"/><g transform="translate(34 60)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(34 93)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#FC6D26" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#FC6D26" opacity=".5" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#EEE" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" opacity=".5" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(34 126)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#EEE" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" opacity=".5" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(157 59)"><rect width="6" height="2" y="1" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="48" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#6B4FBB" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="23" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="34" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" y="33" fill="#EEE" rx="2"/><rect width="15" height="4" x="58" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="55" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="15" height="4" x="29" y="44" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="48" y="33" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="15" y="55" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" y="33" fill="#EEE" rx="2"/><rect width="10" height="4" x="15" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="48" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="62" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="77" y="22" fill="#EEE" rx="2"/><rect width="6" height="2" y="45" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="56" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="67" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" y="66" fill="#6B4FBB" rx="2"/><rect width="15" height="4" x="39" y="88" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="77" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="88" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="77" fill="#6B4FBB" rx="2"/><rect width="10" height="4" x="34" y="66" fill="#EEE" rx="2"/><rect width="10" height="4" x="72" y="77" fill="#EEE" rx="2"/><rect width="10" height="4" x="15" y="77" fill="#EEE" rx="2"/><rect width="6" height="2" y="78" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="89" fill="#FDE5D8" rx="1"/></g></g><g transform="translate(1057 221)"><use fill="#FFF" stroke="#FDE5D8" stroke-width="8" mask="url(#e)" xlink:href="#b"/><rect width="29" height="3" x="14" y="14" fill="#FDB692" rx="1.5"/><rect width="39" height="3" x="14" y="23" fill="#FDB692" rx="1.5"/><rect width="29" height="3" x="14" y="32" fill="#FDB692" rx="1.5"/></g><g transform="translate(1046 285)"><circle cx="16" cy="15" r="15" fill="#FFF7F4" stroke="#FC6D26" stroke-width="3"/><path stroke="#FC6D26" stroke-width="2" d="M0 14h1c5 0 9.2-2.7 11.4-6.7M14 1V0"/><path stroke="#FC6D26" stroke-width="2" d="M7.8 3c3 4.3 7.8 7 13.2 7 3.3 0 6.3-1 9-2.7"/><circle cx="10.5" cy="17.5" r="1.5" fill="#FC6D26"/><circle cx="21.5" cy="17.5" r="1.5" fill="#FC6D26"/></g><g transform="translate(825 370)"><circle cx="15" cy="16" r="15" fill="#F4F1FA" stroke="#6B4FBB" stroke-width="3"/><path fill="#6B4FBB" d="M25 7h2.7C25 2.8 20.4 0 15 0 9.6 0 5 2.8 2.3 7H5l2.5-3L10 7l2.5-3L15 7l2.5-3L20 7l2.5-3L25 7z"/><circle cx="9.5" cy="17.5" r="1.5" fill="#6B4FBB"/><circle cx="20.5" cy="17.5" r="1.5" fill="#6B4FBB"/></g><g transform="matrix(-1 0 0 1 840 306)"><use fill="#FFF" stroke="#E2DCF2" stroke-width="8" mask="url(#f)" xlink:href="#c"/><rect width="29" height="3" x="24" y="14" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="19" height="3" x="34" y="23" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="19" height="3" x="34" y="32" fill="#6B4FBB" opacity=".5" rx="1.5"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/monitoring/getting_started.svg b/app/assets/images/illustrations/monitoring/getting_started.svg
deleted file mode 100644
index ff783bdd388..00000000000
--- a/app/assets/images/illustrations/monitoring/getting_started.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="159.8" height="127.81" x=".196" y="5" rx="10"/><rect id="b" width="160" height="128" x=".666" y=".41" rx="10"/><rect id="c" width="160.19" height="128.19" x=".339" y=".59" rx="10"/><mask id="d" width="159.8" height="127.81" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><mask id="e" width="160" height="128" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><mask id="f" width="160.19" height="128.19" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(12 3)"><rect width="160" height="128" x="122.08" y="146.08" fill="#f9f9f9" transform="rotate(5 202.071 210.085)" rx="10"/><g transform="rotate(15 -104.714 891.23)"><rect width="159.8" height="127.81" x="1.64" y="10.06" fill="#f9f9f9" rx="8"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#d)" xlink:href="#a"/><path fill="#d2caea" fill-rule="nonzero" d="M96.153 81.151a2.001 2.001 0 0 0 2.184-.496l35.956-38.34a2 2 0 1 0-2.918-2.736l-35.03 37.36-41.888-16.285a2 2 0 0 0-2.16.471l-26.368 27.16a2 2 0 1 0 2.87 2.786l25.444-26.21 41.911 16.294"/><g fill="#fff" transform="translate(24.368 36.951)"><circle cx="5.716" cy="5.104" r="5" stroke="#6b4fbb" stroke-width="4" transform="translate(65.917 34.945)"/><g stroke="#fb722e"><ellipse cx="4.632" cy="50.05" stroke-width="3.2" rx="4" ry="3.999"/><g stroke-width="4"><ellipse cx="29.632" cy="27.05" rx="4" ry="3.999"/><ellipse cx="107.63" cy="4.048" rx="4" ry="3.999"/></g></g></g></g><rect width="160.19" height="128.19" x="36.28" y="86.74" fill="#f9f9f9" transform="rotate(-5 116.372 150.825)" rx="10"/><g transform="rotate(5 -1514.687 1518.752)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#e)" xlink:href="#b"/><path fill="#6b4fbb" stroke="#6b4fbb" stroke-width="3.2" d="M84.67 28.41c18.225 0 33 15.07 33 33.651h-33V28.41" stroke-linecap="round" stroke-linejoin="round"/><path fill="#d2caea" fill-rule="nonzero" d="M78.67 66.41h30a2 2 0 0 1 2 2c0 18.778-15.222 34-34 34s-34-15.222-34-34 15.222-34 34-34a2 2 0 0 1 2 2v30m-32 2c0 16.569 13.431 30 30 30 15.896 0 28.905-12.364 29.934-28H76.67a2 2 0 0 1-2-2V38.476c-15.636 1.029-28 14.04-28 29.934"/></g><g transform="rotate(-5 1023.06 -299.524)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#f)" xlink:href="#c"/><path fill="#fef0ea" d="M42 47.391c0-.768.628-1.391 1.4-1.391h9.2c.773 0 1.4.626 1.4 1.391V97H42V47.391"/><path fill="#fb722e" d="M108 55.406c0-.777.628-1.406 1.4-1.406h9.2a1.4 1.4 0 0 1 1.4 1.406V97h-12V55.406"/><path fill="#6b4fbb" d="M64 35.404c0-.776.628-1.404 1.4-1.404h9.2a1.4 1.4 0 0 1 1.4 1.404v61.6H64v-61.6"/><path fill="#d2caea" d="M86 73.4a1.4 1.4 0 0 1 1.4-1.398h9.2c.773 0 1.4.618 1.4 1.398v23.602H86V73.4"/></g><g fill="#fee8dc"><path d="M3.592 93.86l-2.454-1.562c-.93-.592-.924-1.554 0-2.143l2.454-1.562 1.562-2.454c.592-.93 1.554-.925 2.143 0l1.562 2.454 2.454 1.562c.93.591.924 1.554 0 2.143L8.86 93.86l-1.562 2.454c-.591.93-1.554.924-2.143 0L3.592 93.86M309.489 52.07l-3.14-1.998c-1.12-.713-1.128-1.863 0-2.581l3.14-2 1.999-3.14c.713-1.12 1.863-1.127 2.58 0l2 3.14 3.14 2c1.12.713 1.128 1.863 0 2.58l-3.14 2-2 3.14c-.712 1.12-1.862 1.128-2.58 0l-1.999-3.14"/></g><path fill="#e1dcf1" d="M128.073 11.066l-1.99 3.126c-.718 1.129-1.88 1.131-2.6 0l-1.99-3.126-3.126-1.989c-1.128-.718-1.13-1.88 0-2.6l3.127-1.99 1.989-3.126c.718-1.128 1.88-1.13 2.6 0l1.99 3.126 3.126 1.99c1.128.718 1.13 1.88 0 2.6l-3.126 1.99"/><path fill="#d2caea" d="M378.07 243.068l-1.989 3.126c-.718 1.129-1.88 1.131-2.6 0l-1.99-3.126-3.126-1.989c-1.128-.718-1.13-1.88 0-2.6l3.127-1.99 1.989-3.126c.718-1.128 1.88-1.13 2.6 0l1.99 3.126 3.126 1.99c1.128.718 1.13 1.88 0 2.6l-3.126 1.99"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/monitoring/loading.svg b/app/assets/images/illustrations/monitoring/loading.svg
deleted file mode 100644
index 1e196fc8ad1..00000000000
--- a/app/assets/images/illustrations/monitoring/loading.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="c" width="161" height="100" x="92" y="181" rx="10"/><rect id="d" width="151" height="32" x="20" rx="10"/><rect id="a" width="191" height="62" y="10" rx="10"/><circle id="b" cx="23" cy="41" r="9"/><circle id="k" cx="36.5" cy="36.5" r="36.5"/><circle id="e" cx="262.5" cy="169.5" r="15.5"/><circle id="g" cx="79.5" cy="169.5" r="15.5"/><circle id="j" cx="45" cy="41" r="9"/><circle id="f" cx="30.5" cy="30.5" r="30.5"/><circle id="h" cx="18" cy="34" r="3"/><ellipse id="i" cx="43.5" cy="43.5" rx="43.5" ry="43.5"/><mask id="t" width="191" height="62" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><mask id="u" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><mask id="r" width="161" height="100" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><mask id="s" width="151" height="32" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><mask id="p" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><mask id="l" width="61" height="61" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><mask id="q" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask><mask id="m" width="6" height="6" x="0" y="0" fill="#fff"><use xlink:href="#h"/></mask><mask id="o" width="87" height="87" x="0" y="0" fill="#fff"><use xlink:href="#i"/></mask><mask id="v" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#j"/></mask><mask id="n" width="73" height="73" x="0" y="0" fill="#fff"><use xlink:href="#k"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(28 2)"><g transform="translate(133 87)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#l)" xlink:href="#f"/><path stroke="#d2caea" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M19 32l2-9 5 17 4-12 4 5 6-10 3 5"/><g fill="#fff" stroke="#fb722e"><use stroke-width="4" mask="url(#m)" xlink:href="#h"/><circle cx="44" cy="30" r="2" stroke-width="2"/></g></g><g transform="translate(188 29)"><circle cx="36.5" cy="41.5" r="36.5" fill="#f9f9f9"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#n)" xlink:href="#k"/><rect width="27" height="4" x="23" y="27" fill="#d2caea" rx="2"/><rect width="10.5" height="4" x="23" y="27" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="36" fill="#d2caea" rx="2"/><rect width="19" height="4" x="23" y="36" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="45" fill="#d2caea" rx="2"/><rect width="7" height="4" x="23" y="45" fill="#6b4fbb" rx="2"/></g><path fill="#eee" fill-rule="nonzero" d="M247 292v1c0 5.519-4.469 9.993-10.01 9.993H111c-5.177 0-9.436-3.927-9.954-8.96a9.96 9.96 0 0 0 4.705 1.883 6.008 6.008 0 0 0 5.248 3.077h125.99a6 6 0 0 0 5.526-3.637 10.027 10.027 0 0 0 4.48-3.359m1.947-8.962a10.001 10.001 0 0 1-9.95 8.958h-131.99a10 10 0 0 1-9.851-8.25 9.942 9.942 0 0 0 4.649 1.248 6 6 0 0 0 5.202 3h131.99a6.002 6.002 0 0 0 5.245-3.076 9.943 9.943 0 0 0 4.705-1.882"/><g transform="translate(79)"><ellipse cx="43.5" cy="47.5" fill="#f9f9f9" rx="43.5" ry="43.5"/><g fill="#fff"><g stroke="#eee"><use stroke-width="8" mask="url(#o)" xlink:href="#i"/><path stroke-width="4" d="M18.595 49C21.11 60.44 31.305 69 43.5 69 57.58 69 69 57.583 69 43.5c0-12.195-8.56-22.391-20-24.905v15.959c3 1.848 5 5.164 5 8.946C54 49.299 49.299 54 43.5 54c-3.782 0-7.098-2-8.946-5H18.595" stroke-linejoin="round"/></g><path stroke="#d2caea" stroke-width="4" d="M18 44a27.69 27.69 0 0 1-.005-.5c0-14.08 11.417-25.5 25.5-25.5.167 0 .334.002.5.005v15.01a10.365 10.365 0 0 0-.5-.012c-5.799 0-10.5 4.701-10.5 10.5 0 .168.004.334.012.5h-15.01" stroke-linejoin="round"/></g></g><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#p)" xlink:href="#e"/><use mask="url(#q)" xlink:href="#g"/><use mask="url(#r)" xlink:href="#c"/></g><g fill="#eee"><rect width="15" height="2" x="226" y="247" rx="1"/><rect width="15" height="2" x="226" y="242" rx="1"/><rect width="15" height="2" x="226" y="252" rx="1"/></g><rect width="10" height="52" x="118" y="196" fill="#d2caea" rx="2"/><rect width="10" height="47" x="154" y="196" fill="#6b4fbb" rx="2"/><rect width="10" height="37" x="190" y="196" fill="#d2caea" rx="2"/><g fill="#fee8dc"><rect width="10" height="52" x="132" y="185" rx="2"/><rect width="10" height="38" x="168" y="185" rx="2"/></g><rect width="10" height="58" x="204" y="185" fill="#fb722e" rx="2"/><g fill="#fff" stroke="#eee" stroke-width="8" transform="translate(76 128)"><use mask="url(#s)" xlink:href="#d"/><use mask="url(#t)" xlink:href="#a"/></g><g fill="#d2caea" transform="translate(76 128)"><rect width="16" height="4" x="156" y="35" rx="2"/><rect width="16" height="4" x="156" y="43" rx="2"/></g><g fill="#fff" stroke-width="8" transform="translate(76 128)"><use stroke="#fee8dc" mask="url(#u)" xlink:href="#b"/><use stroke="#fb722e" mask="url(#v)" xlink:href="#j"/></g><g fill="#fb722e"><path d="M3.597 219.858l-2.455-1.562c-.929-.59-.924-1.553 0-2.142l2.455-1.562 1.562-2.455c.59-.929 1.553-.924 2.142 0l1.562 2.455 2.454 1.562c.93.591.925 1.553 0 2.142l-2.454 1.562-1.562 2.455c-.591.929-1.553.924-2.142 0l-1.562-2.455M253.597 8.859l-2.454-1.562c-.93-.592-.925-1.554 0-2.143l2.454-1.562 1.562-2.454c.591-.93 1.554-.925 2.143 0l1.562 2.454 2.454 1.562c.93.591.924 1.554 0 2.143l-2.454 1.562-1.562 2.454c-.592.93-1.554.924-2.143 0l-1.562-2.454" opacity=".2"/></g><path fill="#fee8dc" d="M309.49 149.07l-3.141-1.999c-1.12-.712-1.128-1.863 0-2.58l3.14-2 2-3.14c.712-1.12 1.863-1.128 2.58 0l2 3.14 3.14 2c1.12.712 1.127 1.863 0 2.58l-3.14 2-2 3.14c-.713 1.12-1.863 1.128-2.58 0l-2-3.14"/><path fill="#6b4fbb" d="M47.068 79.067l-1.99 3.126c-.718 1.129-1.88 1.13-2.6 0l-1.99-3.126-3.125-1.99c-1.129-.718-1.131-1.88 0-2.6l3.126-1.989 1.989-3.126c.718-1.129 1.88-1.13 2.6 0l1.99 3.126 3.126 1.99c1.128.718 1.13 1.88 0 2.6l-3.126 1.989" opacity=".2"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/monitoring/unable_to_connect.svg b/app/assets/images/illustrations/monitoring/unable_to_connect.svg
deleted file mode 100644
index 314c052f931..00000000000
--- a/app/assets/images/illustrations/monitoring/unable_to_connect.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><use id="g" xlink:href="#a"/><use id="f" xlink:href="#a"/><use id="h" xlink:href="#a"/><path id="e" d="M74 93h26v47H74z"/><path id="c" d="M74 93h26v47H74z"/><rect id="b" width="65" height="14" x="55" y="135" rx="4"/><rect id="d" width="175" height="118" rx="10"/><rect id="a" width="159" rx="10" height="56"/><rect id="i" width="160" y="2" rx="10" height="56" fill="#f9f9f9"/><mask id="q" width="65" height="14" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><mask id="p" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><mask id="r" width="175" height="118" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><mask id="o" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><mask id="k" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><mask id="j" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#g"/></mask><mask id="l" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#h"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(245 65)"><use xlink:href="#i"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#j)" xlink:href="#g"/><g fill-rule="nonzero"><path fill="#fb722e" d="M134 31a2 2 0 1 0 .001-3.999A2 2 0 0 0 134 31m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fee8dc" d="M117 31a2 2 0 1 0 .001-3.999A2 2 0 0 0 117 31m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12m-17-4a2 2 0 1 0 .001-3.999A2 2 0 0 0 100 31m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/></g><g fill="#d2caea"><rect width="50" height="4" x="19" y="20" rx="2"/><rect width="50" height="4" x="19" y="34" rx="2"/></g><g transform="translate(0 59)"><use xlink:href="#i"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#k)" xlink:href="#f"/><g fill-rule="nonzero"><path fill="#fee8dc" d="M134 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 134 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fb722e" d="M117 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 117 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fee8dc" d="M100 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 100 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/></g><rect width="50" height="4" x="19" y="19" fill="#d2caea" rx="2" id="m"/><rect width="50" height="4" x="19" y="33" fill="#d2caea" rx="2" id="n"/></g><g transform="translate(0 118)"><use xlink:href="#i"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#l)" xlink:href="#h"/><g fill-rule="nonzero"><path fill="#fb722e" d="M134 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 134 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/><path fill="#fee8dc" d="M117 30a2 2 0 1 0 .001-3.999A2 2 0 0 0 117 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12m-17-4a2 2 0 1 0 .001-3.999A2 2 0 0 0 100 30m0 4a6 6 0 1 1 0-12 6 6 0 0 1 0 12"/></g><use xlink:href="#m"/><use xlink:href="#n"/></g></g><g fill="#eee" transform="translate(164 120)"><rect width="29" height="4" y="29" rx="2"/><rect width="28" height="4" x="55" y="29" rx="2"/></g><g transform="translate(180 120)"><circle cx="30" cy="30" r="24" fill="#fef0ea"/><g fill="#fb722e"><circle cx="30.5" cy="30.5" r="30.5" opacity=".1"/><circle cx="30.5" cy="30.5" r="19.5" opacity=".1"/></g><circle cx="30.5" cy="30.5" r="13.5" fill="#fff"/><path fill="#fb722e" d="M32.621 30.5l2.481-2.481a1.492 1.492 0 0 0-.006-2.115 1.491 1.491 0 0 0-2.115-.006L30.5 28.379l-2.481-2.481a1.492 1.492 0 0 0-2.115.006 1.491 1.491 0 0 0-.006 2.115l2.481 2.481-2.481 2.481a1.492 1.492 0 0 0 .006 2.115c.59.59 1.533.589 2.115.006l2.481-2.481 2.481 2.481c.586.586 1.529.58 2.115-.006.59-.59.589-1.533.006-2.115L32.621 30.5"/></g><g transform="translate(1 78)"><rect width="65" height="14" x="55" y="137" fill="#f9f9f9" rx="4"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#o)" xlink:href="#e"/><rect width="175" height="118" y="3" fill="#f9f9f9" rx="10"/><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#p)" xlink:href="#c"/><use mask="url(#q)" xlink:href="#b"/><use mask="url(#r)" xlink:href="#d"/></g><g fill-rule="nonzero"><path fill="#eee" d="M163 105V12H11v93h152M7 11.99A3.998 3.998 0 0 1 10.995 8h152.01A3.999 3.999 0 0 1 167 11.99v93.02a3.998 3.998 0 0 1-3.995 3.99H10.995A3.999 3.999 0 0 1 7 105.01V11.99"/><path fill="#d2caea" d="M86 92c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21m0-4c9.389 0 17-7.611 17-17s-7.611-17-17-17-17 7.611-17 17 7.611 17 17 17"/></g><path fill="#6b4fbb" d="M83 63a3.001 3.001 0 0 1 6 0v7.993a3.001 3.001 0 0 1-6 0V63m3 18.997a3 3 0 1 1 0-6 3 3 0 0 1 0 6"/><g fill="#eee"><rect width="134" height="4" x="20" y="30" rx="2"/><rect width="14" height="4" x="20" y="20" rx="2"/><circle cx="87" cy="21" r="5"/></g></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/no_commits.svg b/app/assets/images/illustrations/no_commits.svg
deleted file mode 100644
index 76fa25156dd..00000000000
--- a/app/assets/images/illustrations/no_commits.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 107" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill="#eee" fill-rule="evenodd"><path d="M4.01 2h1.102a1 1 0 0 0 0-2H4.01A4.001 4.001 0 0 0 0 4a1 1 0 0 0 2 0c0-1.108.892-2 2.01-2m12.702 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7m11.6 0a1 1 0 0 0 0-2h-5.7a1 1 0 0 0 0 2h5.7M164 2c.822 0 1.554.503 1.86 1.254a1 1 0 1 0 1.853-.753 4.01 4.01 0 0 0-3.712-2.5h-2.188a1 1 0 0 0 0 2h2.188m2.01 12.518a1 1 0 0 0 2 0v-5.7a1 1 0 0 0-2 0v5.7m0 11.6a1 1 0 0 0 2 0v-5.7a1 1 0 0 0-2 0v5.7m0 11.6a1 1 0 0 0 2 0v-5.7a1 1 0 0 0-2 0v5.7m0 6.282c0 1.108-.892 2-2.01 2h-.72a1 1 0 0 0 0 2h.72a4.001 4.001 0 0 0 4.01-4v-.382a1 1 0 0 0-2 0v.382m-14.325 2a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-11.6 0a1 1 0 0 0 0 2h5.7a1 1 0 0 0 0-2h-5.7m-8.47 0a2.01 2.01 0 0 1-1.782-1.085 1 1 0 0 0-1.775.923 4.007 4.007 0 0 0 3.556 2.162h2.57a1 1 0 0 0 0-2h-2.57m-2.01-12.136a1 1 0 0 0-2 0v5.7a1 1 0 0 0 2 0v-5.7m0-11.6a1 1 0 0 0-2 0v5.7a1 1 0 0 0 2 0v-5.7m0-11.6a1 1 0 0 0-2 0v5.7a1 1 0 0 0 2 0v-5.7m0-6.664a1 1 0 0 0-2 0v.764a1 1 0 0 0 2 0v-.764" id="a"/><circle cx="21" cy="24" r="10"/><rect width="33" height="3" x="37" y="18" rx="1.5" id="b"/><rect width="53" height="3" x="37" y="27" rx="1.5" id="c"/><path d="M131 29c0 .552.447.999.996.999h22.01c.545 0 .996-.451.996-.999v-9a.998.998 0 0 0-.996-.999h-22.01c-.545 0-.996.451-.996.999v9m.996-12h22.01a2.998 2.998 0 0 1 2.996 2.999v9a3.003 3.003 0 0 1-2.996 2.999h-22.01A2.998 2.998 0 0 1 129 28.999v-9A3.003 3.003 0 0 1 131.996 17" id="d"/><g transform="translate(0 59)"><use xlink:href="#a"/><circle cx="21" cy="24" r="10"/><use xlink:href="#b"/><use xlink:href="#c"/><use xlink:href="#d"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/pipelines_empty.svg b/app/assets/images/illustrations/pipelines_empty.svg
deleted file mode 100644
index f3107c8f062..00000000000
--- a/app/assets/images/illustrations/pipelines_empty.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd"><g fill="#e5e5e5" transform="translate(0 102)"><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-4A1.994 1.994 0 0 1 152 23m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.994 1.994 0 0 1 166 23m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.994 1.994 0 0 1 180 23m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.994 1.994 0 0 1 194 23m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4A1.994 1.994 0 0 1 208 23"/></g><g fill="#31af64"><path fill-rule="nonzero" d="M19 144c-10.493 0-19-8.507-19-19s8.507-19 19-19 19 8.507 19 19-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 127.02l-2.829-2.829a1.995 1.995 0 0 0-2.828 0 1.995 1.995 0 0 0 0 2.828l4.243 4.243a1.995 1.995 0 0 0 2.822.006l7.79-7.79a1.997 1.997 0 0 0-.006-2.823 1.992 1.992 0 0 0-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a"><path fill-rule="nonzero" d="M126 149.5c-12.979 0-23.5-10.521-23.5-23.5s10.521-23.5 23.5-23.5 23.5 10.521 23.5 23.5-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5s-8.283-18.5-18.5-18.5-18.5 8.283-18.5 18.5 8.283 18.5 18.5 18.5"/><path d="M130.24 126l2.833-2.833a3 3 0 0 0-4.243-4.243l-2.833 2.833-2.833-2.833a3 3 0 0 0-4.243 4.243l2.833 2.833-2.833 2.833a3 3 0 0 0 4.243 4.243l2.833-2.833 2.833 2.833a3 3 0 0 0 4.243-4.243L130.24 126"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="M236 139c-7.732 0-14-6.268-14-14s6.268-14 14-14 14 6.268 14 14-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10"/><g transform="translate(73 4)"><path stroke="#e5e5e5" stroke-width="4" d="M64.82 76H98c4.419 0 8-3.579 8-7.99V7.99C106 3.577 102.417 0 98 0H8.009c-4.419 0-8 3.579-8 7.99v60.02c0 4.413 3.583 7.99 8 7.99h31.935l9.263 9.855a4.357 4.357 0 0 0 6.354 0L64.824 76"/><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="M151.869 77.403c-13.26 9.264-31.649 7.977-43.484-3.858-13.279-13.279-13.279-34.806 0-48.084 13.278-13.278 34.805-13.278 48.083 0 11.836 11.836 13.118 30.23 3.858 43.485.133.111.262.229.387.354l15.556 15.555a6.004 6.004 0 0 1 0 8.486 5.997 5.997 0 0 1-8.486 0l-15.555-15.556a6.051 6.051 0 0 1-.355-.387m-1.06-9.512c10.154-10.154 10.154-26.617 0-36.77-10.153-10.154-26.616-10.154-36.77 0-10.153 10.153-10.153 26.616 0 36.77 10.154 10.153 26.617 10.153 36.77 0"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/pipelines_failed.svg b/app/assets/images/illustrations/pipelines_failed.svg
deleted file mode 100644
index 8daf7da86ed..00000000000
--- a/app/assets/images/illustrations/pipelines_failed.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 446 249" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M260.03 114h23.972v-.013c19.972-.53 36-16.887 36-36.987 0-20.435-16.565-37-37-37-.993 0-1.977.039-2.95.116-4.95-14.605-18.773-25.12-35.05-25.12a36.87 36.87 0 0 0-15.32 3.311c-6.649-9.841-17.909-16.311-30.68-16.311-20.435 0-37 16.565-37 37 0 .701.019 1.397.058 2.088C145.95 45.083 134 59.645 134 76.996c0 20.435 16.565 37 37 37 .324 0 .646-.004.968-.012"/><ellipse id="b" cx="41" cy="41" rx="41" ry="41"/><mask id="c" width="186" height="112" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><mask id="d" width="82" height="82" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask></defs><g fill="none" fill-rule="evenodd"><path stroke="#b5a7dd" stroke-width="4" d="M228.415 137.792c8.443 17.156 21.89 32.082 39.688 42.358"/><path fill="#fb722e" d="M284.464 183.822a2.006 2.006 0 0 1 2.74-.727l6.914 3.992a2.001 2.001 0 0 1 .741 2.737 2.006 2.006 0 0 1-2.74.727l-6.914-3.992a2.001 2.001 0 0 1-.74-2.737m-5 8.66a2.006 2.006 0 0 1 2.74-.726l6.913 3.991a2.001 2.001 0 0 1 .741 2.737 2.006 2.006 0 0 1-2.74.727l-6.914-3.991a2.001 2.001 0 0 1-.74-2.737"/><path fill="#fde5d8" fill-rule="nonzero" d="M267.072 189.947l5.196 3a5.998 5.998 0 0 0 8.195-2.194l3.005-5.205a5.995 5.995 0 0 0-2.198-8.193l-5.196-3-9 15.588m6.032-18.447a3.005 3.005 0 0 1 4.098-1.11l6.07 3.505c4.784 2.761 6.426 8.871 3.662 13.658l-3.005 5.204c-2.76 4.782-8.875 6.42-13.659 3.658l-6.07-3.505a2.999 2.999 0 0 1-1.088-4.104l9.992-17.306"/><g fill-rule="nonzero"><path fill="#e5e5e5" d="M260.597 18.747C266.208 9.657 276.116 4 287 4c17.12 0 31 13.879 31 31 0 7.02-2.34 13.685-6.58 19.1l3.149 2.466A34.855 34.855 0 0 0 322 35.001c0-19.33-15.67-35-35-35-12.286 0-23.476 6.384-29.808 16.647l3.404 2.1"/><path fill="#b5a7dd" d="M281.982 23.991l-2.526 1.154-2.992-2.993a.4.4 0 0 0-.564.009l-1.738 1.738a.392.392 0 0 0-.009.564l2.987 2.987-1.147 2.524a12.26 12.26 0 0 0-1.04 3.883l-.269 2.76-4.08 1.093a.399.399 0 0 0-.275.492l.636 2.375c.06.223.273.346.485.29l4.087-1.096 1.611 2.262a12.017 12.017 0 0 0 2.827 2.828l2.26 1.612-1.094 4.08a.399.399 0 0 0 .29.485l2.374.636a.393.393 0 0 0 .493-.275l1.093-4.08 2.763-.267a12.14 12.14 0 0 0 3.862-1.035l2.526-1.154 2.992 2.992a.4.4 0 0 0 .564-.008l1.738-1.738a.392.392 0 0 0 .009-.564l-2.987-2.987 1.147-2.524a12.26 12.26 0 0 0 1.04-3.883l.27-2.76 4.08-1.093a.399.399 0 0 0 .274-.493l-.636-2.374a.393.393 0 0 0-.485-.29l-4.087 1.096-1.611-2.262a12.017 12.017 0 0 0-2.826-2.828l-2.26-1.612 1.093-4.08a.399.399 0 0 0-.29-.485l-2.373-.636a.393.393 0 0 0-.493.274l-1.094 4.081-2.763.266c-1.336.129-2.64.48-3.862 1.036m3.48-5.02l.375-1.4a4.393 4.393 0 0 1 5.392-3.103l2.375.636a4.399 4.399 0 0 1 3.117 5.383l-.375 1.401a16.077 16.077 0 0 1 3.761 3.767l1.405-.376a4.397 4.397 0 0 1 5.386 3.118l.636 2.375a4.398 4.398 0 0 1-3.103 5.39l-1.402.376a16.217 16.217 0 0 1-1.378 5.143l1.027 1.026a4.392 4.392 0 0 1-.008 6.22l-1.739 1.738a4.4 4.4 0 0 1-6.224.008l-1.028-1.028a16.09 16.09 0 0 1-5.14 1.381l-.376 1.4a4.393 4.393 0 0 1-5.392 3.104l-2.374-.636a4.399 4.399 0 0 1-3.118-5.383l.376-1.401a16.077 16.077 0 0 1-3.762-3.767l-1.404.376a4.397 4.397 0 0 1-5.386-3.118l-.637-2.374a4.398 4.398 0 0 1 3.103-5.391l1.402-.376a16.217 16.217 0 0 1 1.378-5.143l-1.026-1.026a4.392 4.392 0 0 1 .008-6.22l1.738-1.738a4.4 4.4 0 0 1 6.224-.008l1.028 1.028a16.09 16.09 0 0 1 5.141-1.381"/><path fill="#6b4fbb" d="M286.367 37.355a2.439 2.439 0 1 0 1.262-4.711 2.439 2.439 0 0 0-1.262 4.711m-1.035 3.864a6.44 6.44 0 1 1 3.333-12.44 6.44 6.44 0 0 1-3.333 12.44"/></g><use fill="#fff" stroke="#e5e5e5" stroke-width="8" mask="url(#c)" stroke-linejoin="round" xlink:href="#a"/><g transform="translate(175 58)"><use fill="#fff" stroke="#e5e5e5" stroke-width="8" mask="url(#d)" xlink:href="#b"/><g fill-rule="nonzero"><path fill="#e5e5e5" d="M41 78c20.435 0 37-16.565 37-37S61.435 4 41 4 4 20.565 4 41s16.565 37 37 37m0 4C18.356 82 0 63.644 0 41S18.356 0 41 0s41 18.356 41 41-18.356 41-41 41"/><path fill="#b5a7dd" d="M34.363 26.44l-2.527 1.154-3.211-3.211a1.495 1.495 0 0 0-2.117-.005l-2.131 2.13a1.504 1.504 0 0 0 .005 2.117l3.206 3.206-1.147 2.524a16.09 16.09 0 0 0-.897 2.503 16.08 16.08 0 0 0-.475 2.616l-.269 2.76-4.379 1.174a1.495 1.495 0 0 0-1.063 1.83l.78 2.911a1.504 1.504 0 0 0 1.836 1.054l4.387-1.176 1.612 2.263a15.954 15.954 0 0 0 3.737 3.742l2.26 1.612-1.173 4.38a1.495 1.495 0 0 0 1.053 1.835l2.908.78a1.504 1.504 0 0 0 1.83-1.063l1.174-4.38 2.763-.266a15.977 15.977 0 0 0 5.108-1.372l2.527-1.154 3.211 3.212a1.495 1.495 0 0 0 2.117.005l2.131-2.131a1.504 1.504 0 0 0-.005-2.117l-3.206-3.206 1.147-2.524a16.09 16.09 0 0 0 .897-2.503 16.1 16.1 0 0 0 .475-2.616l.269-2.76 4.379-1.173a1.495 1.495 0 0 0 1.063-1.83l-.78-2.912a1.504 1.504 0 0 0-1.836-1.054l-4.387 1.176-1.612-2.262a15.954 15.954 0 0 0-3.737-3.743l-2.26-1.612 1.173-4.38a1.495 1.495 0 0 0-1.053-1.835l-2.908-.779a1.504 1.504 0 0 0-1.83 1.063l-1.174 4.38-2.763.265c-1.767.17-3.493.636-5.108 1.373m4.726-5.355l.455-1.699a5.504 5.504 0 0 1 6.73-3.89l2.907.778a5.495 5.495 0 0 1 3.882 6.735l-.455 1.699a19.95 19.95 0 0 1 4.673 4.68l1.704-.457a5.503 5.503 0 0 1 6.734 3.886l.78 2.91a5.493 5.493 0 0 1-3.894 6.73l-1.701.455a20.134 20.134 0 0 1-.593 3.265 20.134 20.134 0 0 1-1.119 3.124l1.245 1.246a5.507 5.507 0 0 1 .008 7.774l-2.13 2.13a5.5 5.5 0 0 1-7.775-.001l-1.248-1.248c-2 .914-4.157 1.502-6.387 1.717l-.455 1.699a5.504 5.504 0 0 1-6.73 3.89l-2.907-.778a5.495 5.495 0 0 1-3.882-6.735l.455-1.699a19.95 19.95 0 0 1-4.673-4.68l-1.704.457a5.503 5.503 0 0 1-6.734-3.886l-.78-2.91a5.493 5.493 0 0 1 3.894-6.73l1.701-.455a20.258 20.258 0 0 1 1.712-6.389l-1.245-1.246a5.507 5.507 0 0 1-.008-7.774l2.13-2.13a5.5 5.5 0 0 1 7.775.001l1.248 1.248c2-.914 4.157-1.502 6.387-1.717"/><path fill="#6b4fbb" d="M39.965 44.863a4 4 0 1 0 2.07-7.727 4 4 0 0 0-2.07 7.727m-1.036 3.864a8 8 0 1 1 4.142-15.455 8 8 0 0 1-4.142 15.455"/></g></g><path fill="#e5e5e5" fill-rule="nonzero" d="M144 169.541v30.01a4.002 4.002 0 0 0 4 3.995h20c2.209 0 4-1.789 4-3.995v-30.01a4.002 4.002 0 0 0-4-3.995h-20c-2.209 0-4 1.789-4 3.995m-4 0c0-4.416 3.583-7.995 8-7.995h20c4.416 0 8 3.584 8 7.995v30.01c0 4.416-3.583 7.995-8 7.995h-20c-4.416 0-8-3.584-8-7.995v-30.01"/><g fill="#fb722e" transform="translate(140 161)"><rect width="4" height="11" x="10" y="18.545" rx="2"/><rect width="4" height="11" x="21" y="18.545" rx="2"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="M445.16 245.34c-16.874-11.778-110.62-20.336-222.14-20.336-111.61 0-205.4 8.571-222.18 20.364a2 2 0 1 0 2.3 3.272c15.756-11.07 109.46-19.636 219.88-19.636 110.34 0 203.99 8.55 219.85 19.617a2.001 2.001 0 0 0 2.29-3.28"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/pipelines_pending.svg b/app/assets/images/illustrations/pipelines_pending.svg
deleted file mode 100644
index 25038366e92..00000000000
--- a/app/assets/images/illustrations/pipelines_pending.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="430" height="220" viewBox="0 0 430 220"><g fill="none" fill-rule="evenodd"><path fill="#EEE" fill-rule="nonzero" d="M189.8 182l2.4-12H114c-5.523 0-10-4.477-10-10V34c0-5.523 4.477-10 10-10h200c5.523 0 10 4.477 10 10v126c0 5.523-4.477 10-10 10h-78.2l2.4 12h22.52a9.651 9.651 0 0 1 9.28 7 5.491 5.491 0 0 1-5.28 7H164.159a5.787 5.787 0 0 1-5.659-7 8.855 8.855 0 0 1 8.659-7H189.8zM114 28a6 6 0 0 0-6 6v126a6 6 0 0 0 6 6h200a6 6 0 0 0 6-6V34a6 6 0 0 0-6-6H114zm5 6h190a5 5 0 0 1 5 5v116a5 5 0 0 1-5 5H119a5 5 0 0 1-5-5V39a5 5 0 0 1 5-5zm0 4a1 1 0 0 0-1 1v116a1 1 0 0 0 1 1h190a1 1 0 0 0 1-1V39a1 1 0 0 0-1-1H119zm112.72 132h-35.44l-2.4 12h40.24l-2.4-12zm-64.561 16c-2.29 0-4.268 1.6-4.748 3.838A1.787 1.787 0 0 0 164.16 192h100.56a1.491 1.491 0 0 0 1.435-1.901A5.651 5.651 0 0 0 260.72 186h-93.561z"/><path fill="#FEF0E8" d="M177.965 99H194a2 2 0 1 1 0 4h-16.322c-1.374 6.29-6.976 11-13.678 11-6.702 0-12.304-4.71-13.678-11h-3.365l-7.395 9.249a2 2 0 0 1-3.049.089L128.11 103h-5.844a2 2 0 1 1 0-4H129a2 2 0 0 1 1.487.662l7.423 8.248 6.523-8.159a2 2 0 0 1 1.562-.751h4.04c.513-7.265 6.57-13 13.965-13 7.396 0 13.452 5.735 13.965 13zM164 110c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10z"/><path fill="#EFEDF8" d="M273.847 103c-.962 6.23-6.347 11-12.847 11-6.5 0-11.885-4.77-12.847-11H232a2 2 0 0 1 0-4h16.153c.962-6.23 6.347-11 12.847-11 6.5 0 11.885 4.77 12.847 11h3.998l8.404-9.338a2 2 0 0 1 3.048.09L296.692 99H305a2 2 0 0 1 0 4h-9.27a2 2 0 0 1-1.562-.751l-6.523-8.16-7.423 8.249a2 2 0 0 1-1.487.662h-4.888zM261 110a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M213 119c-10.493 0-19-8.507-19-19s8.507-19 19-19 19 8.507 19 19-8.507 19-19 19zm0-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 15z"/><path fill="#FC6D26" d="M211.586 101.828L208.757 99a2 2 0 1 0-2.828 2.828l4.243 4.243c.39.39.902.586 1.414.586.512 0 1.023-.195 1.414-.586L220.071 99a2 2 0 1 0-2.828-2.828l-5.657 5.656z"/><path fill="#FDC4A8" d="M162.95 101.07l-1.768-1.767a1.5 1.5 0 0 0-2.121 2.121l2.828 2.829c.293.293.677.439 1.06.439.385 0 .769-.146 1.062-.44l4.242-4.242a1.5 1.5 0 1 0-2.121-2.121l-3.182 3.182z"/><path fill="#6B4FBB" d="M256.39 104.841A6 6 0 1 0 261 95v6l-4.61 3.841z"/><path fill="#FEF0E8" fill-rule="nonzero" d="M99 99h-5a2 2 0 1 0 0 4h5a2 2 0 1 0 0-4zm-16 0h-5a2 2 0 1 0 0 4h5a2 2 0 1 0 0-4zm-14.384-.078l-3.643-3.425a2 2 0 1 0-2.74 2.914l3.643 3.425a2 2 0 1 0 2.74-2.914zm-11.657-10.96l-3.642-3.425a2 2 0 1 0-2.74 2.914l3.642 3.425a2 2 0 0 0 2.74-2.914zm-11.656-10.96l-3.643-3.425a2 2 0 0 0-2.74 2.914l3.643 3.425a2 2 0 1 0 2.74-2.914zm-14.367-3.885l-3.593 3.477a2 2 0 0 0 2.782 2.875l3.593-3.477a2 2 0 0 0-2.782-2.875zM19.44 84.244l-3.593 3.477a2 2 0 1 0 2.781 2.874l3.593-3.477a2 2 0 0 0-2.781-2.874zM7.94 95.371l-3.593 3.477a2 2 0 1 0 2.782 2.874l3.593-3.477a2 2 0 1 0-2.782-2.874z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M423.611 99.56l-3.598 3.472a2 2 0 0 0 2.777 2.879l3.599-3.472a2 2 0 0 0-2.778-2.878zm-11.514 11.11l-3.598 3.472a2 2 0 0 0 2.777 2.878l3.598-3.471a2 2 0 0 0-2.777-2.879zm-11.514 11.11l-3.599 3.471a2 2 0 1 0 2.778 2.879l3.598-3.472a2 2 0 1 0-2.777-2.879zm-8.799 4.48l-3.642-3.426a2 2 0 0 0-2.74 2.915l3.642 3.425a2 2 0 0 0 2.74-2.915zm-11.656-10.96l-3.643-3.426a2 2 0 1 0-2.74 2.914l3.643 3.426a2 2 0 1 0 2.74-2.915zm-11.657-10.96l-3.643-3.426a2 2 0 1 0-2.74 2.914l3.643 3.425a2 2 0 1 0 2.74-2.914zM353.001 99h-5a2 2 0 1 0 0 4h5a2 2 0 0 0 0-4zm-16 0h-5a2 2 0 1 0 0 4h5a2 2 0 0 0 0-4z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/priority_labels.svg b/app/assets/images/illustrations/priority_labels.svg
deleted file mode 100644
index b79c551d3d7..00000000000
--- a/app/assets/images/illustrations/priority_labels.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="116" height="68" viewBox="181 0 116 68"><g fill="none" fill-rule="evenodd" transform="translate(182)"><rect width="78" height="34" x="37" y="34" fill="#FAFAFA" rx="3"/><rect width="78" height="34" x="31" y="28" fill="#FFF" stroke="#EEE" stroke-width="4" stroke-linecap="round" rx="3"/><path fill="#FFF" stroke="#FC6D26" stroke-width="3" d="M34 35.8c-.6 0-1.4 0-1.8.4L29 38.8c-1 .7-1.7.4-2-.7l-.6-4c0-.5-.5-1.2-1-1.5L22 30.2c-1-.6-1-1.5 0-2l3.7-2c.5-.2 1-.8 1.2-1.3l1-4.2c.3-1 1-1.3 2-.5l3 3c.3.3 1 .6 1.6.6l4.2-.3c1 0 1.5.7 1 1.7L38 29c-.3.6-.3 1.4 0 2l1.3 3.8c.4 1 0 1.8-1.2 1.6l-4-.6z" stroke-linecap="round"/><path fill="#FDE5D8" d="M51.6 14.3c-.2-.2-.8-.4-1-.3l-2.8.5c-.7 0-1-.4-.8-1l1-2.8V9.5L46.6 7c-.3-.7 0-1.2.8-1h2.7c.3 0 .8-.2 1-.5l2-2c.6-.5 1-.4 1.3.3l.7 2.8c0 .3.4.8.7 1l2.3 1.2c.7.3.7 1 0 1.3l-2.2 1.7-.6 1-.4 3c-.2.6-.7.8-1.3.4l-2-1.7zM5.4 43.2c-.2-.2-.5-.2-.7-.2l-1.8.3c-.6 0-1-.2-.7-.7l.7-1.8V40l-1-1.7c0-.4 0-.7.6-.7h1.8c.3 0 .6 0 .8-.2L6.5 36c.3-.3.7-.2.8.2l.5 2 .5.5 1.6.8c.3.2.3.7 0 1l-1.6 1c-.2 0-.4.4-.4.7l-.4 2c0 .3-.4.5-.8.2l-1.4-1.2zm5-34C10.2 9 10 9 9.7 9L8 9.3c-.6 0-1-.2-.7-.7L8 6.8V6L7 4.3c0-.4 0-.7.6-.7h1.8c.3 0 .6 0 .8-.2L11.5 2c.3-.3.7-.2.8.2l.5 2 .5.5 1.6.8c.3.2.3.7 0 1l-1.6 1c-.2 0-.4.4-.4.7l-.4 2c0 .3-.4.5-.8.2l-1.4-1.2z"/><rect width="52" height="4" x="43" y="38" fill="#EEE" rx="2"/><rect width="36" height="4" x="43" y="48" fill="#EEE" rx="2"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/slack_logo.svg b/app/assets/images/illustrations/slack_logo.svg
deleted file mode 100644
index b8d7906c2e1..00000000000
--- a/app/assets/images/illustrations/slack_logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" viewBox="0 0 121.94154 121.84154" width="121.942" height="121.842"><style id="style200">.st0{fill:#ecb32d}.st1{fill:#63c1a0}.st2{fill:#e01a59}.st3{fill:#331433}.st4{fill:#d62027}.st5{fill:#89d3df}.st6{fill:#258b74}.st7{fill:#819c3c}</style><path class="st0" d="M79.03 7.511c-1.9-5.7-8-8.8-13.7-7-5.7 1.9-8.8 8-7 13.7l28.1 86.4c1.9 5.3 7.7 8.3 13.2 6.7 5.8-1.7 9.3-7.8 7.4-13.4 0-.2-28-86.4-28-86.4z" id="path202" fill="#ecb32d"/><path class="st1" d="M35.53 21.611c-1.9-5.7-8-8.8-13.7-7-5.7 1.9-8.8 8-7 13.7l28.1 86.4c1.9 5.3 7.7 8.3 13.2 6.7 5.8-1.7 9.3-7.8 7.4-13.4 0-.2-28-86.4-28-86.4z" id="path204" fill="#63c1a0"/><path class="st2" d="M114.43 79.011c5.7-1.9 8.8-8 7-13.7-1.9-5.7-8-8.8-13.7-7l-86.5 28.2c-5.3 1.9-8.3 7.7-6.7 13.2 1.7 5.8 7.8 9.3 13.4 7.4.2 0 86.5-28.1 86.5-28.1z" id="path206" fill="#e01a59"/><path class="st3" d="M39.23 103.511c5.6-1.8 12.9-4.2 20.7-6.7-1.8-5.6-4.2-12.9-6.7-20.7l-20.7 6.7z" id="path208" fill="#331433"/><path class="st4" d="M82.83 89.311c7.8-2.5 15.1-4.9 20.7-6.7-1.8-5.6-4.2-12.9-6.7-20.7l-20.7 6.7z" id="path210" fill="#d62027"/><path class="st5" d="M100.23 35.511c5.7-1.9 8.8-8 7-13.7-1.9-5.7-8-8.8-13.7-7l-86.4 28.1c-5.3 1.9-8.3 7.7-6.7 13.2 1.7 5.8 7.8 9.3 13.4 7.4.2 0 86.4-28 86.4-28z" id="path212" fill="#89d3df"/><path class="st6" d="M25.13 59.911c5.6-1.8 12.9-4.2 20.7-6.7-2.5-7.8-4.9-15.1-6.7-20.7l-20.7 6.7z" id="path214" fill="#258b74"/><path class="st7" d="M68.63 45.811c7.8-2.5 15.1-4.9 20.7-6.7-2.5-7.8-4.9-15.1-6.7-20.7l-20.7 6.7z" id="path216" fill="#819c3c"/></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/todos_all_done.svg b/app/assets/images/illustrations/todos_all_done.svg
deleted file mode 100644
index 6387497a6fb..00000000000
--- a/app/assets/images/illustrations/todos_all_done.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 293 216"><g fill="none" fill-rule="evenodd"><g transform="rotate(-5 211.388 -693.89)"><rect width="163.6" height="200" x=".2" fill="#FFF" stroke="#EEE" stroke-width="3" stroke-linecap="round" stroke-dasharray="6 9" rx="6"/><g transform="translate(24 38)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="76" height="3" x="40" y="11" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#6B4FBB" opacity=".5" rx="1.5"/></g><g transform="translate(24 83)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="76" height="3" x="40" y="11" fill="#B5A7DD" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#B5A7DD" rx="1.5"/></g><g transform="translate(24 130)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="76" height="3" x="40" y="11" fill="#B5A7DD" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#B5A7DD" rx="1.5"/></g></g><path fill="#FFCE29" d="M30 11l-1.8 4-2-4-4-1.8 4-2 2-4 2 4 4 2M286 60l-2.7 6.3-3-6-6-3 6-3 3-6 2.8 6.2 6.6 2.8M263 97l-2 4-2-4-4-2 4-2 2-4 2 4 4 2M12 85l-2.7 6.3-3-6-6-3 6-3 3-6 2.8 6.2 6.6 2.8"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/todos_empty.svg b/app/assets/images/illustrations/todos_empty.svg
deleted file mode 100644
index 4de6cb403b9..00000000000
--- a/app/assets/images/illustrations/todos_empty.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 284 337" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="180" height="220" x="66.2" y="74.4" rx="6"/><mask id="l" width="180" height="220" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><rect id="b" width="180" height="220" rx="6"/><mask id="m" width="180" height="220" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><rect id="c" width="28" height="28" rx="4"/><mask id="n" width="28" height="28" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask><rect id="d" width="28" height="28" rx="4"/><mask id="o" width="28" height="28" x="0" y="0" fill="#fff"><use xlink:href="#d"/></mask><circle id="e" cx="21.5" cy="21.5" r="21.5"/><mask id="p" width="43" height="43" x="0" y="0" fill="#fff"><use xlink:href="#e"/></mask><circle id="f" cx="26.5" cy="26.5" r="26.5"/><mask id="q" width="53" height="53" x="0" y="0" fill="#fff"><use xlink:href="#f"/></mask><circle id="g" cx="9.5" cy="4.5" r="4.5"/><mask id="r" width="13" height="13" x="-2" y="-2"><path fill="#fff" d="M3-2h13v13H3z"/><use xlink:href="#g"/></mask><circle id="h" cx="26.5" cy="26.5" r="26.5"/><mask id="s" width="53" height="53" x="0" y="0" fill="#fff"><use xlink:href="#h"/></mask><circle id="i" cx="21.5" cy="21.5" r="21.5"/><mask id="t" width="43" height="43" x="0" y="0" fill="#fff"><use xlink:href="#i"/></mask><path id="j" d="M18 38h15c10.5 0 19-8.5 19-19S43.5 0 33 0H19C8.5 0 0 8.5 0 19c0 6.3 3 12 7.8 15.3l5.2 9c.6 1 1.4 1 2 0l3-5.3z"/><mask id="u" width="52" height="44" x="0" y="0" fill="#fff"><use xlink:href="#j"/></mask><circle id="k" cx="18.5" cy="18.5" r="18.5"/><mask id="v" width="37" height="37" x="0" y="0" fill="#fff"><use xlink:href="#k"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(-6 -4)"><use stroke="#EEE" stroke-width="6" mask="url(#l)" transform="rotate(-5 156.245 184.425)" xlink:href="#a"/><g transform="rotate(5 -707.333 618.042)"><use fill="#FFF" stroke="#EEE" stroke-width="6" mask="url(#m)" xlink:href="#b"/><g transform="translate(29 24)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="86" height="3" x="40" y="11" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#6B4FBB" opacity=".5" rx="1.5"/></g><g transform="translate(29 69)"><path fill="#FC6D26" d="M18.2 14l-4-3.8c-.4-.6-1.4-.6-2 0-.6.6-.6 1.5 0 2l5 5c.3.4.6.5 1 .5s.8 0 1-.4L28 8.8c.6-.6.6-1.5 0-2-.6-.7-1.6-.7-2 0L18 14z"/><path stroke="#6B4FBB" stroke-width="3" d="M27 23.3V27c0 2.3-1.7 4-4 4H4c-2.3 0-4-1.7-4-4V8c0-2.3 1.7-4 4-4h3.8" stroke-linecap="round"/><rect width="86" height="3" x="40" y="11" fill="#B5A7DD" rx="1.5"/><rect width="43" height="3" x="40" y="21" fill="#B5A7DD" rx="1.5"/></g><g transform="translate(28 160)"><use stroke="#E5E5E5" stroke-width="6" mask="url(#n)" opacity=".7" xlink:href="#c"/><rect width="26" height="3" x="41" y="7" fill="#ECECEC" rx="1.5"/><rect width="43" height="3" x="41" y="17" fill="#ECECEC" rx="1.5"/></g><g transform="translate(28 116)"><use stroke="#E5E5E5" stroke-width="6" mask="url(#o)" xlink:href="#d"/><rect width="86" height="3" x="41" y="7" fill="#E5E5E5" rx="1.5"/><rect width="43" height="3" x="41" y="17" fill="#E5E5E5" rx="1.5"/></g></g><g transform="rotate(-15 601.917 -782.362)"><use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#p)" xlink:href="#e"/><text fill="#6B4FBB" font-family="SourceSansPro-Black, Source Sans Pro" font-size="20" font-weight="700" letter-spacing="-.1"><tspan x="12" y="27">@</tspan></text></g><g transform="rotate(15 -686.59 1035.907)"><use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#q)" xlink:href="#f"/><path fill="#FC6D26" d="M26.5 38.2c3.3 0 9.5-2.5 9.5-9.6 0-7-2.4-6.6-9.5-6.6-7 0-9.5-.4-9.5 6.6s6.2 9.6 9.5 9.6z"/><g transform="translate(17 14)"><use fill="#FC6D26" xlink:href="#g"/><use stroke="#FFF" stroke-width="4" mask="url(#r)" xlink:href="#g"/></g></g><g transform="rotate(15 -85.125 65.185)"><use fill="#FFF" stroke="#B5A7DD" stroke-width="6" mask="url(#s)" xlink:href="#h"/><path fill="#6B4FBB" d="M24 18.5c0-1.4 1-2.5 2.5-2.5 1.4 0 2.5 1 2.5 2.5v9c0 1.4-1 2.5-2.5 2.5-1.4 0-2.5-1-2.5-2.5v-9zM26.5 37c1.4 0 2.5-1 2.5-2.5 0-1.4-1-2.5-2.5-2.5-1.4 0-2.5 1-2.5 2.5 0 1.4 1 2.5 2.5 2.5z"/></g><g transform="rotate(-15 716.492 78.873)"><use fill="#FFF" stroke="#FDE5D8" stroke-width="6" mask="url(#t)" xlink:href="#i"/><path fill="#FC6D26" d="M20 23v-3h3v3h-3zm0 3v1.5c0 .8-.7 1.5-1.5 1.5s-1.5-.7-1.5-1.5V26h-2.5c-.8 0-1.5-.7-1.5-1.5s.7-1.5 1.5-1.5H17v-3h-1.5c-.8 0-1.5-.7-1.5-1.5s.7-1.5 1.5-1.5H17v-2.5c0-.8.7-1.5 1.5-1.5s1.5.7 1.5 1.5V17h3v-1.5c0-.8.7-1.5 1.5-1.5s1.5.7 1.5 1.5V17h2.5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5H26v3h1.5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5H26v2.5c0 .8-.7 1.5-1.5 1.5s-1.5-.7-1.5-1.5V26h-3z"/></g><g transform="rotate(-15 129.114 -585.74)"><use stroke="#FDE5D8" stroke-width="6" mask="url(#u)" xlink:href="#j"/><circle cx="16" cy="20" r="2" fill="#FC6D26"/><circle cx="27" cy="20" r="2" fill="#FC6D26"/><circle cx="38" cy="20" r="2" fill="#FC6D26"/></g><g transform="rotate(-15 1254.8 -458.986)"><use stroke="#FDE5D8" stroke-width="6" mask="url(#v)" xlink:href="#k"/><path fill="#FC6D26" d="M10.6 19l2-2c.5-.5.5-1 0-1.5-.3-.4-1-.4-1.3 0l-2.8 2.8c-.2.2-.3.4-.3.7 0 .3 0 .5.3.7l2.8 2.8c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-2-2zm14.8 0l-2-2c-.5-.5-.5-1 0-1.5.3-.4 1-.4 1.3 0l2.8 2.8c.2.2.3.4.3.7 0 .3 0 .5-.3.7l-2.8 2.8c-.4.4-1 .4-1.4 0-.4-.4-.4-1 0-1.4l2-2z"/><rect width="2" height="7" x="17" y="15.1" fill="#FC6D26" opacity=".5" transform="rotate(15 18.002 18.64)" rx="1"/></g></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/add_new_group.svg b/app/assets/images/illustrations/welcome/add_new_group.svg
deleted file mode 100644
index b10a3ae8812..00000000000
--- a/app/assets/images/illustrations/welcome/add_new_group.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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-4a12.996 12.996 0 0 0-18.76 0h5.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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/add_new_project.svg b/app/assets/images/illustrations/welcome/add_new_project.svg
deleted file mode 100644
index 4b8dc34c088..00000000000
--- a/app/assets/images/illustrations/welcome/add_new_project.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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 76S3.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" fill-rule="nonzero" d="M30 24c-2.21 0-4 1.79-4 4v22c0 2.21 1.79 4 4 4h18c2.21 0 4-1.79 4-4V28c0-2.21-1.79-4-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="#6B4FBB" 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/assets/images/illustrations/welcome/add_new_user.svg b/app/assets/images/illustrations/welcome/add_new_user.svg
deleted file mode 100644
index d4c184989bf..00000000000
--- a/app/assets/images/illustrations/welcome/add_new_user.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/configure_server.svg b/app/assets/images/illustrations/welcome/configure_server.svg
deleted file mode 100644
index f9dda816f11..00000000000
--- a/app/assets/images/illustrations/welcome/configure_server.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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.15a4.012 4.012 0 0 1-.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-.87a4.011 4.011 0 0 1-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> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/ee_trial.svg b/app/assets/images/illustrations/welcome/ee_trial.svg
deleted file mode 100644
index 6d0dcf0020c..00000000000
--- a/app/assets/images/illustrations/welcome/ee_trial.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="330" height="132" viewBox="0 0 330 132"><g fill="none" fill-rule="evenodd"><path fill="#000" fill-opacity=".03" d="M174.12 42c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M211 78c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S230.33 4 211 4s-35 15.67-35 35 15.67 35 35 35z"/><g fill-rule="nonzero"><path fill="#FEE1D3" d="M211.5 51c-6.42 0-12.26-2.84-17.43-8.4a4.008 4.008 0 0 1-.27-5.13C199 30.57 204.92 27 211.5 27s12.5 3.56 17.7 10.47a3.994 3.994 0 0 1-.27 5.12c-5.17 5.53-11 8.4-17.43 8.4zm0-4c5.25 0 10.05-2.34 14.5-7.13-4.5-5.98-9.3-8.87-14.5-8.87-5.2 0-10 2.9-14.5 8.87 4.45 4.8 9.25 7.13 14.5 7.13z"/><path fill="#FC6D26" d="M211 47c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-4c2.2 0 4-1.8 4-4s-1.8-4-4-4-4 1.8-4 4 1.8 4 4 4zm0-1c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/></g><path fill="#000" fill-opacity=".03" d="M88.12 83c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M125 119c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M116 86.34c2.33.83 4 3.05 4 5.66 0 3.3-2.7 6-6 6s-6-2.7-6-6c0-2.6 1.67-4.83 4-5.66V72h4v14.34zM128 66c5.52 0 10 4.48 10 10v12h-4V76c0-3.3-2.7-6-6-6v1.83c0 .55-.45 1-1 1-.24 0-.47-.1-.65-.24l-4.46-3.87c-.46-.36-.5-1-.15-1.4.03-.05.07-.1.1-.12l4.47-3.82c.42-.35 1.05-.3 1.4.1.16.2.25.43.25.66V66zm-14 28c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/><path fill="#FC6D26" fill-rule="nonzero" d="M114 74c-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 2zm22 28c-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"/><path fill="#000" fill-opacity=".03" d="M2.12 52C2.04 53 2 54 2 55c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 71.03 58.42 86 39 86S3.65 71.03 2.12 52z"/><path fill="#EEE" fill-rule="nonzero" d="M39 88C17.46 88 0 70.54 0 49s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 14 39 14 4 29.67 4 49s15.67 35 35 35z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M48 41h-4c0-2.76-2.24-5-5-5s-5 2.24-5 5h-4a9 9 0 0 1 18 0zm-18 0h4v3h-4v-3zm14 0h4v3h-4v-3z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M30 47c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V48c0-.55-.45-1-1-1H30zm0-4h18c2.76 0 5 2.24 5 5v12c0 2.76-2.24 5-5 5H30c-2.76 0-5-2.24-5-5V48c0-2.76 2.24-5 5-5z"/><path fill="#6B4FBB" d="M38 53.73c-.6-.34-1-1-1-1.73 0-1.1.9-2 2-2s2 .9 2 2c0 .74-.4 1.4-1 1.73V55c0 .55-.45 1-1 1s-1-.45-1-1v-1.27z"/><path fill="#000" fill-opacity=".03" d="M254.12 92c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M291 128c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#6B4BBE" fill-rule="nonzero" d="M292 78c5.52 0 10 4.48 10 10 0 2.28-.76 4.43-2.14 6.18-1.03 1.3-.8 3.2.5 4.22 1.3 1.02 3.2.8 4.2-.5 2.22-2.8 3.44-6.26 3.44-9.9 0-8.84-7.16-16-16-16v-3.13c0-.2-.06-.4-.17-.56-.3-.42-.93-.54-1.38-.23l-9.2 6.13c-.1.06-.2.16-.28.27-.3.45-.18 1.08.28 1.38l9.2 6.13c.16.1.35.17.55.17.55 0 1-.45 1-1V78z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M290 100c-5.52 0-10-4.48-10-10 0-2.25.74-4.38 2.1-6.12 1-1.3.77-3.2-.54-4.2-1.3-1.02-3.2-.78-4.2.53A15.796 15.796 0 0 0 274 90c0 8.84 7.16 16 16 16v3.13c0 .55.45 1 1 1 .2 0 .4-.06.55-.17l9.2-6.13c.46-.3.6-.93.28-1.38-.07-.1-.17-.2-.28-.28l-9.2-6.13c-.45-.3-1.08-.2-1.38.27-.1.2-.17.4-.17.6v3.1z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/welcome/globe.svg b/app/assets/images/illustrations/welcome/globe.svg
deleted file mode 100644
index c2daae5f317..00000000000
--- a/app/assets/images/illustrations/welcome/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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/assets/images/illustrations/welcome/lightbulb.svg b/app/assets/images/illustrations/welcome/lightbulb.svg
deleted file mode 100644
index fce10312085..00000000000
--- a/app/assets/images/illustrations/welcome/lightbulb.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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 76S3.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="#6B4FBB" d="M33 52h12a2 2 0 1 1 0 4H33a2 2 0 1 1 0-4zm1 5h10a2 2 0 1 1 0 4H34a2 2 0 1 1 0-4z"/><path fill="#E1DBF2" fill-rule="nonzero" d="M45.542 46.932l.346-2.36a8.004 8.004 0 0 1 1.566-3.705c3.025-3.946 4.485-7.29 4.547-9.96C52.153 24.41 46.843 20 39 20c-7.777 0-13 4.374-13 11 0 2.4 1.462 5.73 4.573 9.846a8.009 8.009 0 0 1 1.536 3.683l.353 2.456 13.08-.054zm-17.038.624L28.15 45.1a3.997 3.997 0 0 0-.768-1.842C23.794 38.51 22 34.424 22 31c0-9.39 7.61-15 17-15s17.218 5.614 17 15c-.085 3.64-1.875 7.74-5.37 12.3a3.99 3.99 0 0 0-.784 1.853l-.346 2.36a4.003 4.003 0 0 1-3.942 3.42l-13.08.053a4 4 0 0 1-3.974-3.43z"/><path fill="#6B4FBB" d="M41 38.732a2 2 0 1 1 2 0V42a1 1 0 0 1-2 0v-3.268zm-6 0a2 2 0 1 1 2 0V42a1 1 0 0 1-2 0v-3.268z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/illustrations/wiki-fro-logged-out-users.svg b/app/assets/images/illustrations/wiki-fro-logged-out-users.svg
deleted file mode 100644
index c71841f72e5..00000000000
--- a/app/assets/images/illustrations/wiki-fro-logged-out-users.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="412" height="260" viewBox="0 0 412 260" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M6.447.894L12 12H0L5.553.894a.5.5 0 0 1 .894 0z"/></defs><g fill="none" fill-rule="evenodd"><path fill="#FEF0E8" fill-rule="nonzero" d="M338 50.287C322.695 41.45 303.124 46.694 294.287 62c-8.836 15.305-3.592 34.876 11.713 43.712 15.306 8.837 34.877 3.593 43.713-11.712 8.837-15.306 3.593-34.877-11.713-43.713zm2-3.464C357.22 56.763 363.118 78.78 353.177 96c-9.941 17.218-31.958 23.118-49.177 13.176-17.218-9.94-23.118-31.958-13.177-49.176C300.764 42.78 322.782 36.88 340 46.823z"/><g transform="rotate(-150 171.003 8.53)"><path fill="#FC6D26" fill-rule="nonzero" d="M4 16v25a2 2 0 1 0 4 0V16H4zm8-4v29a6 6 0 1 1-12 0V12h12z"/><use fill="#D8D8D8" xlink:href="#a"/><path stroke="#FDC4A8" stroke-width="4" d="M6 4.472L3.236 10h5.528L6 4.472z"/><path fill="#FC6D26" d="M9 6L6.447.894a.5.5 0 0 0-.894 0L3 6c.836.628 1.874 1 3 1a4.978 4.978 0 0 0 3-1z"/></g><path fill="#F9F9F9" d="M263.116 237.116A10.002 10.002 0 0 1 254 243h-86c-11.046 0-20-8.954-20-20V121c0-4.056 2.414-7.547 5.884-9.116A9.964 9.964 0 0 0 153 116v106c0 8.837 7.163 16 16 16h90c1.467 0 2.86-.316 4.116-.884z"/><path fill="#EEE" fill-rule="nonzero" d="M214.5 106H163c-5.523 0-10 4.477-10 10v106c0 8.837 7.163 16 16 16h90c5.523 0 10-4.477 10-10v-17.999a10.036 10.036 0 0 1-4 3.167V228a6 6 0 0 1-6 6h-90c-6.627 0-12-5.373-12-12V116a6 6 0 0 1 6-6h7v-4h44.5z"/><path fill="#EEE" fill-rule="nonzero" d="M260 218.268V214h-90a6 6 0 0 0 0 12h86a4 4 0 0 0 4-4v-.268a1.99 1.99 0 0 1-1 .268h-50a2 2 0 0 1 0-4h50c.364 0 .706.097 1 .268zM170 210h90.5a3.5 3.5 0 0 1 3.5 3.5v8.5a8 8 0 0 1-8 8h-86c-5.523 0-10-4.477-10-10s4.477-10 10-10z"/><path fill="#EEE" fill-rule="nonzero" d="M174 110v100h87a6 6 0 0 0 6-6v-88a6 6 0 0 0-6-6h-87zm-4-4h91c5.523 0 10 4.477 10 10v88c0 5.523-4.477 10-10 10h-91V106z"/><path fill="#EFEDF8" d="M230 99h18a6 6 0 0 1 6 6v31.35a3 3 0 0 1-4.68 2.484l-9.277-6.274a1.5 1.5 0 0 0-1.664-.01l-9.731 6.395a3 3 0 0 1-4.648-2.507V105a6 6 0 0 1 6-6z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M236.182 129.207a5.5 5.5 0 0 1 6.102.04l7.716 5.219V105a2 2 0 0 0-2-2h-18a2 2 0 0 0-2 2v29.584l8.182-5.377zM230 99h18a6 6 0 0 1 6 6v31.35a3 3 0 0 1-4.68 2.484l-9.277-6.274a1.5 1.5 0 0 0-1.664-.01l-9.731 6.395a3 3 0 0 1-4.648-2.507V105a6 6 0 0 1 6-6z"/><g fill-rule="nonzero"><path fill="#EFEDF8" d="M156 74c14.912 0 27-12.088 27-27s-12.088-27-27-27-27 12.088-27 27 12.088 27 27 27zm0 4c-17.12 0-31-13.88-31-31s13.88-31 31-31 31 13.88 31 31-13.88 31-31 31z"/><path fill="#6B4FBB" d="M147.535 44.916l-.116 1.086a8.446 8.446 0 0 0 .093 2.44l.2 1.08-2.262 1.202a.495.495 0 0 0-.213.678l.941 1.77c.128.239.434.332.68.201l2.25-1.196.785.775a8.544 8.544 0 0 0 1.967 1.45l.975.522-.486 2.5a.495.495 0 0 0 .392.59l1.968.383a.504.504 0 0 0 .585-.401l.489-2.515 1.086-.13a8.584 8.584 0 0 0 2.363-.633l1.005-.43 1.68 1.933a.495.495 0 0 0 .708.055l1.513-1.315a.504.504 0 0 0 .044-.708l-1.67-1.922.583-.94c.431-.696.761-1.45.978-2.239l.292-1.063 2.547-.089a.495.495 0 0 0 .488-.515l-.07-2.003a.504.504 0 0 0-.523-.48l-2.56.09-.367-1.037a8.446 8.446 0 0 0-1.139-2.159l-.644-.882 1.509-2.076a.495.495 0 0 0-.106-.702l-1.621-1.178a.504.504 0 0 0-.7.116l-1.494 2.057-1.05-.362a8.459 8.459 0 0 0-2.398-.455l-1.1-.047-.66-2.466a.495.495 0 0 0-.613-.36l-1.936.519a.504.504 0 0 0-.35.617l.661 2.466-.93.59a8.459 8.459 0 0 0-1.848 1.594l-.728.838-2.322-1.034a.495.495 0 0 0-.665.25l-.815 1.83a.504.504 0 0 0 .26.661l2.344 1.044zm-3.565 1.697a3.504 3.504 0 0 1-1.78-4.622l.815-1.83a3.495 3.495 0 0 1 4.626-1.77l.346.154c.259-.245.529-.477.81-.697l-.106-.394a3.504 3.504 0 0 1 2.471-4.292l1.936-.519a3.495 3.495 0 0 1 4.286 2.481l.106.395c.353.05.703.116 1.05.198l.222-.306a3.504 3.504 0 0 1 4.89-.78l1.622 1.178a3.495 3.495 0 0 1 .769 4.892l-.258.355c.184.312.354.633.508.962l.42-.014a3.504 3.504 0 0 1 3.625 3.373l.07 2.003a3.495 3.495 0 0 1-3.382 3.618l-.4.014c-.127.332-.27.659-.426.978l.256.294a3.504 3.504 0 0 1-.34 4.941l-1.512 1.315a3.495 3.495 0 0 1-4.94-.351l-.283-.325a11.669 11.669 0 0 1-1.05.28l-.082.424a3.504 3.504 0 0 1-4.103 2.774l-1.967-.382a3.495 3.495 0 0 1-2.765-4.11l.075-.383a11.547 11.547 0 0 1-.858-.633l-.354.188a3.504 3.504 0 0 1-4.738-1.442l-.94-1.77a3.495 3.495 0 0 1 1.453-4.734l.37-.197a11.436 11.436 0 0 1-.041-1.088l-.4-.178zm13.326 5.608a5.5 5.5 0 1 1-2.847-10.625 5.5 5.5 0 0 1 2.847 10.625zm-.776-2.898a2.5 2.5 0 1 0-1.294-4.83 2.5 2.5 0 0 0 1.294 4.83z"/></g><g fill-rule="nonzero"><path fill="#EFEDF8" d="M326.979 222.047c14.403 3.86 29.209-4.688 33.068-19.092 3.86-14.403-4.688-29.209-19.092-33.068-14.403-3.86-29.209 4.688-33.068 19.092-3.86 14.404 4.688 29.209 19.092 33.068zm-1.035 3.864c-16.538-4.431-26.352-21.43-21.92-37.967 4.43-16.538 21.429-26.352 37.966-21.92 16.538 4.43 26.352 21.429 21.92 37.966-4.43 16.538-21.429 26.352-37.966 21.92z"/><path fill="#6B4FBB" d="M329.376 201.598c-4.668-2.621-7.155-8.157-5.706-13.566 1.715-6.402 8.295-10.201 14.697-8.486 6.402 1.716 10.2 8.296 8.485 14.697-1.45 5.41-6.371 8.96-11.725 8.897a3.03 3.03 0 0 1-.074.365l-1.812 6.761a3 3 0 0 1-5.795-1.552l1.812-6.762a3.03 3.03 0 0 1 .118-.354zm3.815-2.733a8 8 0 1 0 4.14-15.455 8 8 0 0 0-4.14 15.455z"/></g><path fill="#FEF0E8" fill-rule="nonzero" d="M91.373 193c17.071-4.574 27.202-22.12 22.628-39.191-4.575-17.071-22.121-27.202-39.192-22.628-17.071 4.574-27.202 22.121-22.628 39.192 4.574 17.071 22.121 27.202 39.192 22.627zm1.035 3.864c-19.204 5.146-38.945-6.25-44.09-25.456-5.146-19.204 6.25-38.945 25.455-44.09 19.205-5.146 38.945 6.25 44.091 25.455 5.146 19.205-6.25 38.945-25.456 44.091z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M70.067 152.122l6.73 25.114 19.318-5.176-6.73-25.114-19.318 5.176zm-1.035-3.864l19.318-5.176a4 4 0 0 1 4.9 2.828l6.729 25.114a4 4 0 0 1-2.829 4.9L77.832 181.1a4 4 0 0 1-4.9-2.829l-6.729-25.114a4 4 0 0 1 2.829-4.899z"/><path fill="#FC6D26" d="M76.898 154.433l7.727-2.07a2 2 0 0 1 1.036 3.863l-7.728 2.07a2 2 0 1 1-1.035-3.863zm1.812 6.761l5.795-1.553a2 2 0 0 1 1.035 3.864l-5.795 1.553a2 2 0 1 1-1.035-3.864zm1.811 6.762l7.728-2.07a2 2 0 0 1 1.035 3.863l-7.727 2.07a2 2 0 1 1-1.036-3.863z"/></g></svg> \ No newline at end of file
diff --git a/app/assets/images/multi-editor-off.png b/app/assets/images/multi-editor-off.png
new file mode 100644
index 00000000000..82a6127f853
--- /dev/null
+++ b/app/assets/images/multi-editor-off.png
Binary files differ
diff --git a/app/assets/images/multi-editor-on.png b/app/assets/images/multi-editor-on.png
new file mode 100644
index 00000000000..d51b68da985
--- /dev/null
+++ b/app/assets/images/multi-editor-on.png
Binary files differ
diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js
deleted file mode 100644
index d2d3a257c0d..00000000000
--- a/app/assets/javascripts/abuse_reports.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { truncate } from './lib/utils/text_utility';
-
-const MAX_MESSAGE_LENGTH = 500;
-const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
-
-export default class AbuseReports {
- constructor() {
- $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
- $(document)
- .off('click', MESSAGE_CELL_SELECTOR)
- .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
- }
-
- truncateLongMessage() {
- const $messageCellElement = $(this);
- const reportMessage = $messageCellElement.text();
- if (reportMessage.length > MAX_MESSAGE_LENGTH) {
- $messageCellElement.data('original-message', reportMessage);
- $messageCellElement.data('message-truncated', 'true');
- $messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH));
- }
- }
-
- toggleMessageTruncation() {
- const $messageCellElement = $(this);
- const originalMessage = $messageCellElement.data('original-message');
- if (!originalMessage) return;
- if ($messageCellElement.data('message-truncated') === 'true') {
- $messageCellElement.data('message-truncated', 'false');
- $messageCellElement.text(originalMessage);
- } else {
- $messageCellElement.data('message-truncated', 'true');
- $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
- }
- }
-}
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 6a0662ba903..c117d080bda 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign, class-methods-use-this */
+import $ from 'jquery';
import Cookies from 'js-cookie';
import Pager from './pager';
import { localTimeAgo } from './lib/utils/datetime_utility';
diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js
deleted file mode 100644
index c1f7fa2aced..00000000000
--- a/app/assets/javascripts/admin.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { refreshCurrentPage } from './lib/utils/url_utility';
-
-function showBlacklistType() {
- if ($('input[name="blacklist_type"]:checked').val() === 'file') {
- $('.blacklist-file').show();
- $('.blacklist-raw').hide();
- } else {
- $('.blacklist-file').hide();
- $('.blacklist-raw').show();
- }
-}
-
-export default function adminInit() {
- const modal = $('.change-owner-holder');
-
- $('input#user_force_random_password').on('change', function randomPasswordClick() {
- const $elems = $('#user_password, #user_password_confirmation');
- if ($(this).attr('checked')) {
- $elems.val('').attr('disabled', true);
- } else {
- $elems.removeAttr('disabled');
- }
- });
-
- $('body').on('click', '.js-toggle-colors-link', (e) => {
- e.preventDefault();
- $('.js-toggle-colors-container').toggle();
- });
-
- $('.log-tabs a').on('click', function logTabsClick(e) {
- e.preventDefault();
- $(this).tab('show');
- });
-
- $('.log-bottom').on('click', (e) => {
- e.preventDefault();
- const $visibleLog = $('.file-content:visible');
- $visibleLog.animate({
- scrollTop: $visibleLog.find('ol').height(),
- }, 'fast');
- });
-
- $('.change-owner-link').on('click', function changeOwnerLinkClick(e) {
- e.preventDefault();
- $(this).hide();
- modal.show();
- });
-
- $('.change-owner-cancel-link').on('click', (e) => {
- e.preventDefault();
- modal.hide();
- $('.change-owner-link').show();
- });
-
- $('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
-
- $("input[name='blacklist_type']").on('click', showBlacklistType);
- showBlacklistType();
-}
diff --git a/app/assets/javascripts/ajax_loading_spinner.js b/app/assets/javascripts/ajax_loading_spinner.js
index 2bc77859c26..bd08308904c 100644
--- a/app/assets/javascripts/ajax_loading_spinner.js
+++ b/app/assets/javascripts/ajax_loading_spinner.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default class AjaxLoadingSpinner {
static init() {
const $elements = $('.js-ajax-loading-spinner');
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 21d8c790e90..8ad3d18b302 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,15 +1,19 @@
import $ from 'jquery';
+import _ from 'underscore';
import axios from './lib/utils/axios_utils';
const Api = {
groupsPath: '/api/:version/groups.json',
- groupPath: '/api/:version/groups/:id.json',
+ groupPath: '/api/:version/groups/:id',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
projectLabelsPath: '/:namespace_path/:project_path/labels',
- groupLabelsPath: '/groups/:namespace_path/labels',
+ mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
+ mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
+ mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
+ groupLabelsPath: '/groups/:namespace_path/-/labels',
licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key',
gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key',
@@ -21,44 +25,49 @@ const Api = {
createBranchPath: '/api/:version/projects/:id/repository/branches',
group(groupId, callback) {
- const url = Api.buildUrl(Api.groupPath)
- .replace(':id', groupId);
- return $.ajax({
- url,
- dataType: 'json',
- })
- .done(group => callback(group));
+ const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
+ return axios.get(url).then(({ data }) => {
+ callback(data);
+
+ return data;
+ });
},
// Return groups list. Filtered by query
- groups(query, options, callback) {
+ groups(query, options, callback = $.noop) {
const url = Api.buildUrl(Api.groupsPath);
- return $.ajax({
- url,
- data: Object.assign({
- search: query,
- per_page: 20,
- }, options),
- dataType: 'json',
- })
- .done(groups => callback(groups));
+ return axios
+ .get(url, {
+ params: Object.assign(
+ {
+ search: query,
+ per_page: 20,
+ },
+ options,
+ ),
+ })
+ .then(({ data }) => {
+ callback(data);
+
+ return data;
+ });
},
// Return namespaces list. Filtered by query
namespaces(query, callback) {
const url = Api.buildUrl(Api.namespacesPath);
- return $.ajax({
- url,
- data: {
- search: query,
- per_page: 20,
- },
- dataType: 'json',
- }).done(namespaces => callback(namespaces));
+ return axios
+ .get(url, {
+ params: {
+ search: query,
+ per_page: 20,
+ },
+ })
+ .then(({ data }) => callback(data));
},
// Return projects list. Filtered by query
- projects(query, options, callback) {
+ projects(query, options, callback = _.noop) {
const url = Api.buildUrl(Api.projectsPath);
const defaults = {
search: query,
@@ -70,18 +79,45 @@ const Api = {
defaults.membership = true;
}
- return $.ajax({
- url,
- data: Object.assign(defaults, options),
- dataType: 'json',
- })
- .done(projects => callback(projects));
+ return axios
+ .get(url, {
+ params: Object.assign(defaults, options),
+ })
+ .then(({ data }) => {
+ callback(data);
+
+ return data;
+ });
},
// Return single project
project(projectPath) {
- const url = Api.buildUrl(Api.projectPath)
- .replace(':id', encodeURIComponent(projectPath));
+ const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath));
+
+ return axios.get(url);
+ },
+
+ // Return Merge Request for project
+ mergeRequest(projectPath, mergeRequestId) {
+ const url = Api.buildUrl(Api.mergeRequestPath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':mrid', mergeRequestId);
+
+ return axios.get(url);
+ },
+
+ mergeRequestChanges(projectPath, mergeRequestId) {
+ const url = Api.buildUrl(Api.mergeRequestChangesPath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':mrid', mergeRequestId);
+
+ return axios.get(url);
+ },
+
+ mergeRequestVersions(projectPath, mergeRequestId) {
+ const url = Api.buildUrl(Api.mergeRequestVersionsPath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':mrid', mergeRequestId);
return axios.get(url);
},
@@ -97,108 +133,92 @@ const Api = {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
}
- return $.ajax({
- url,
- type: 'POST',
- data: { label: data },
- dataType: 'json',
- })
- .done(label => callback(label))
- .fail(message => callback(message.responseJSON));
+ return axios
+ .post(url, {
+ label: data,
+ })
+ .then(res => callback(res.data))
+ .catch(e => callback(e.response.data));
},
// Return group projects list. Filtered by query
groupProjects(groupId, query, callback) {
- const url = Api.buildUrl(Api.groupProjectsPath)
- .replace(':id', groupId);
- return $.ajax({
- url,
- data: {
- search: query,
- per_page: 20,
- },
- dataType: 'json',
- })
- .done(projects => callback(projects));
+ const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId);
+ return axios
+ .get(url, {
+ params: {
+ search: query,
+ per_page: 20,
+ },
+ })
+ .then(({ data }) => callback(data));
},
commitMultiple(id, data) {
// see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
- const url = Api.buildUrl(Api.commitPath)
- .replace(':id', encodeURIComponent(id));
- return this.wrapAjaxCall({
- url,
- type: 'POST',
- contentType: 'application/json; charset=utf-8',
- data: JSON.stringify(data),
- dataType: 'json',
+ const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id));
+ return axios.post(url, JSON.stringify(data), {
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ },
});
},
branchSingle(id, branch) {
const url = Api.buildUrl(Api.branchSinglePath)
.replace(':id', encodeURIComponent(id))
- .replace(':branch', branch);
+ .replace(':branch', encodeURIComponent(branch));
- return this.wrapAjaxCall({
- url,
- type: 'GET',
- contentType: 'application/json; charset=utf-8',
- dataType: 'json',
- });
+ return axios.get(url);
},
// Return text for a specific license
licenseText(key, data, callback) {
- const url = Api.buildUrl(Api.licensePath)
- .replace(':key', key);
- return $.ajax({
- url,
- data,
- })
- .done(license => callback(license));
+ const url = Api.buildUrl(Api.licensePath).replace(':key', key);
+ return axios
+ .get(url, {
+ params: data,
+ })
+ .then(res => callback(res.data));
},
gitignoreText(key, callback) {
- const url = Api.buildUrl(Api.gitignorePath)
- .replace(':key', key);
- return $.get(url, gitignore => callback(gitignore));
+ const url = Api.buildUrl(Api.gitignorePath).replace(':key', key);
+ return axios.get(url).then(({ data }) => callback(data));
},
gitlabCiYml(key, callback) {
- const url = Api.buildUrl(Api.gitlabCiYmlPath)
- .replace(':key', key);
- return $.get(url, file => callback(file));
+ const url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key);
+ return axios.get(url).then(({ data }) => callback(data));
},
dockerfileYml(key, callback) {
const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
- $.get(url, callback);
+ return axios.get(url).then(({ data }) => callback(data));
},
issueTemplate(namespacePath, projectPath, key, type, callback) {
const url = Api.buildUrl(Api.issuableTemplatePath)
- .replace(':key', key)
+ .replace(':key', encodeURIComponent(key))
.replace(':type', type)
.replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath);
- $.ajax({
- url,
- dataType: 'json',
- })
- .done(file => callback(null, file))
- .fail(callback);
+ return axios
+ .get(url)
+ .then(({ data }) => callback(null, data))
+ .catch(callback);
},
users(query, options) {
const url = Api.buildUrl(this.usersPath);
- return Api.wrapAjaxCall({
- url,
- data: Object.assign({
- search: query,
- per_page: 20,
- }, options),
- dataType: 'json',
+ return axios.get(url, {
+ params: Object.assign(
+ {
+ search: query,
+ per_page: 20,
+ },
+ options,
+ ),
});
},
@@ -209,20 +229,6 @@ const Api = {
}
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);
- },
- );
- });
- },
};
export default Api;
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 0f28bd233ac..0da872db7e5 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -3,10 +3,10 @@
import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
- constructor(field, key, resource) {
+ constructor(field, key) {
this.field = field;
+
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
- this.resource = resource;
if (key.join != null) {
key = key.join('/');
}
@@ -17,31 +17,27 @@ export default class Autosave {
}
restore() {
- var text;
-
if (!this.isLocalStorageAvailable) return;
+ if (!this.field.length) return;
- text = window.localStorage.getItem(this.key);
+ const text = window.localStorage.getItem(this.key);
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
- if (!this.resource && this.resource !== 'issue') {
- this.field.trigger('input');
- } else {
- // v-model does not update with jQuery trigger
- // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
- const event = new Event('change', { bubbles: true, cancelable: false });
- const field = this.field.get(0);
- if (field) {
- field.dispatchEvent(event);
- }
- }
+
+ this.field.trigger('input');
+ // v-model does not update with jQuery trigger
+ // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
+ const event = new Event('change', { bubbles: true, cancelable: false });
+ const field = this.field.get(0);
+ field.dispatchEvent(event);
}
save() {
- var text;
- text = this.field.val();
+ if (!this.field.length) return;
+
+ const text = this.field.val();
if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
return window.localStorage.setItem(this.key, text);
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 622764107ad..976d32abe9b 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,8 +1,13 @@
/* eslint-disable class-methods-use-this */
+
+import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
-import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils';
-import Flash from './flash';
+import { __ } from './locale';
+import { updateTooltipTitle } from './lib/utils/common_utils';
+import { isInVueNoteablePage } from './lib/utils/dom_utils';
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -48,10 +53,8 @@ class AwardsHandler {
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) {
+ $('.js-awards-block.current').removeClass('current');
if ($('.emoji-menu').is(':visible')) {
$('.js-add-award.is-active').removeClass('is-active');
this.hideMenuElement($('.emoji-menu'));
@@ -239,9 +242,9 @@ class AwardsHandler {
}
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
- const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
+ const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
- if (isInIssuePage() && !isMainAwardsBlock) {
+ if (isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu'));
@@ -294,7 +297,7 @@ class AwardsHandler {
}
getVotesBlock() {
- if (isInIssuePage()) {
+ if (isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
@@ -312,7 +315,7 @@ class AwardsHandler {
}
getAwardUrl() {
- return this.getVotesBlock().data('award-url');
+ return this.getVotesBlock().data('awardUrl');
}
checkMutuality(votesBlock, emoji) {
@@ -441,13 +444,15 @@ class AwardsHandler {
if (this.isUserAuthored($emojiButton)) {
this.userAuthored($emojiButton);
} else {
- $.post(awardUrl, {
+ axios.post(awardUrl, {
name: emoji,
- }, (data) => {
+ })
+ .then(({ data }) => {
if (data.ok) {
callback();
}
- }).fail(() => new Flash('Something went wrong on our end.'));
+ })
+ .catch(() => flash(__('Something went wrong on our end.')));
}
}
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
new file mode 100644
index 00000000000..6e6cb31e3ac
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -0,0 +1,121 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import Tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ name: 'Badge',
+ components: {
+ Icon,
+ LoadingIcon,
+ Tooltip,
+ },
+ directives: {
+ Tooltip,
+ },
+ props: {
+ imageUrl: {
+ type: String,
+ required: true,
+ },
+ linkUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hasError: false,
+ isLoading: true,
+ numRetries: 0,
+ };
+ },
+ computed: {
+ imageUrlWithRetries() {
+ if (this.numRetries === 0) {
+ return this.imageUrl;
+ }
+
+ return `${this.imageUrl}#retries=${this.numRetries}`;
+ },
+ },
+ watch: {
+ imageUrl() {
+ this.hasError = false;
+ this.isLoading = true;
+ this.numRetries = 0;
+ },
+ },
+ methods: {
+ onError() {
+ this.isLoading = false;
+ this.hasError = true;
+ },
+ onLoad() {
+ this.isLoading = false;
+ },
+ reloadImage() {
+ this.hasError = false;
+ this.isLoading = true;
+ this.numRetries += 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <a
+ v-show="!isLoading && !hasError"
+ :href="linkUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <img
+ class="project-badge"
+ :src="imageUrlWithRetries"
+ @load="onLoad"
+ @error="onError"
+ aria-hidden="true"
+ />
+ </a>
+
+ <loading-icon
+ v-show="isLoading"
+ :inline="true"
+ />
+
+ <div
+ v-show="hasError"
+ class="btn-group"
+ >
+ <div class="btn btn-default btn-xs disabled">
+ <icon
+ class="prepend-left-8 append-right-8"
+ name="doc_image"
+ :size="16"
+ aria-hidden="true"
+ />
+ </div>
+ <div
+ class="btn btn-default btn-xs disabled"
+ >
+ <span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span>
+ </div>
+ </div>
+
+ <button
+ v-show="hasError"
+ class="btn btn-transparent btn-xs text-primary"
+ type="button"
+ v-tooltip
+ :title="s__('Badges|Reload badge image')"
+ @click="reloadImage"
+ >
+ <icon
+ name="retry"
+ :size="16"
+ />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
new file mode 100644
index 00000000000..ae942b2c1a7
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -0,0 +1,219 @@
+<script>
+import _ from 'underscore';
+import { mapActions, mapState } from 'vuex';
+import createFlash from '~/flash';
+import { s__, sprintf } from '~/locale';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import createEmptyBadge from '../empty_badge';
+import Badge from './badge.vue';
+
+const badgePreviewDelayInMilliseconds = 1500;
+
+export default {
+ name: 'BadgeForm',
+ components: {
+ Badge,
+ LoadingButton,
+ LoadingIcon,
+ },
+ props: {
+ isEditing: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'badgeInAddForm',
+ 'badgeInEditForm',
+ 'docsUrl',
+ 'isRendering',
+ 'isSaving',
+ 'renderedBadge',
+ ]),
+ badge() {
+ if (this.isEditing) {
+ return this.badgeInEditForm;
+ }
+
+ return this.badgeInAddForm;
+ },
+ canSubmit() {
+ return (
+ this.badge !== null &&
+ this.badge.imageUrl &&
+ this.badge.imageUrl.trim() !== '' &&
+ this.badge.linkUrl &&
+ this.badge.linkUrl.trim() !== '' &&
+ !this.isSaving
+ );
+ },
+ helpText() {
+ const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha']
+ .map(placeholder => `<code>%{${placeholder}}</code>`)
+ .join(', ');
+ return sprintf(
+ s__('Badges|The %{docsLinkStart}variables%{docsLinkEnd} GitLab supports: %{placeholders}'),
+ {
+ docsLinkEnd: '</a>',
+ docsLinkStart: `<a href="${_.escape(this.docsUrl)}">`,
+ placeholders,
+ },
+ false,
+ );
+ },
+ renderedImageUrl() {
+ return this.renderedBadge ? this.renderedBadge.renderedImageUrl : '';
+ },
+ renderedLinkUrl() {
+ return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : '';
+ },
+ imageUrl: {
+ get() {
+ return this.badge ? this.badge.imageUrl : '';
+ },
+ set(imageUrl) {
+ const badge = this.badge || createEmptyBadge();
+ this.updateBadgeInForm({
+ ...badge,
+ imageUrl,
+ });
+ },
+ },
+ linkUrl: {
+ get() {
+ return this.badge ? this.badge.linkUrl : '';
+ },
+ set(linkUrl) {
+ const badge = this.badge || createEmptyBadge();
+ this.updateBadgeInForm({
+ ...badge,
+ linkUrl,
+ });
+ },
+ },
+ submitButtonLabel() {
+ if (this.isEditing) {
+ return s__('Badges|Save changes');
+ }
+ return s__('Badges|Add badge');
+ },
+ },
+ methods: {
+ ...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']),
+ debouncedPreview: _.debounce(function preview() {
+ this.renderBadge();
+ }, badgePreviewDelayInMilliseconds),
+ onCancel() {
+ this.stopEditing();
+ },
+ onSubmit() {
+ if (!this.canSubmit) {
+ return Promise.resolve();
+ }
+
+ if (this.isEditing) {
+ return this.saveBadge()
+ .then(() => {
+ createFlash(s__('Badges|The badge was saved.'), 'notice');
+ })
+ .catch(error => {
+ createFlash(
+ s__('Badges|Saving the badge failed, please check the entered URLs and try again.'),
+ );
+ throw error;
+ });
+ }
+
+ return this.addBadge()
+ .then(() => {
+ createFlash(s__('Badges|A new badge was added.'), 'notice');
+ })
+ .catch(error => {
+ createFlash(
+ s__('Badges|Adding the badge failed, please check the entered URLs and try again.'),
+ );
+ throw error;
+ });
+ },
+ },
+ badgeImageUrlPlaceholder:
+ 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/<badge>.svg',
+ badgeLinkUrlPlaceholder: 'https://example.gitlab.com/%{project_path}',
+};
+</script>
+
+<template>
+ <form
+ class="prepend-top-default append-bottom-default"
+ @submit.prevent.stop="onSubmit"
+ >
+ <div class="form-group">
+ <label for="badge-link-url">{{ s__('Badges|Link') }}</label>
+ <input
+ id="badge-link-url"
+ type="text"
+ class="form-control"
+ v-model="linkUrl"
+ :placeholder="$options.badgeLinkUrlPlaceholder"
+ @input="debouncedPreview"
+ />
+ <span
+ class="help-block"
+ v-html="helpText"
+ ></span>
+ </div>
+
+ <div class="form-group">
+ <label for="badge-image-url">{{ s__('Badges|Badge image URL') }}</label>
+ <input
+ id="badge-image-url"
+ type="text"
+ class="form-control"
+ v-model="imageUrl"
+ :placeholder="$options.badgeImageUrlPlaceholder"
+ @input="debouncedPreview"
+ />
+ <span
+ class="help-block"
+ v-html="helpText"
+ ></span>
+ </div>
+
+ <div class="form-group">
+ <label for="badge-preview">{{ s__('Badges|Badge image preview') }}</label>
+ <badge
+ id="badge-preview"
+ v-show="renderedBadge && !isRendering"
+ :image-url="renderedImageUrl"
+ :link-url="renderedLinkUrl"
+ />
+ <p v-show="isRendering">
+ <loading-icon
+ :inline="true"
+ />
+ </p>
+ <p
+ v-show="!renderedBadge && !isRendering"
+ class="disabled-content"
+ >{{ s__('Badges|No image to preview') }}</p>
+ </div>
+
+ <div class="row-content-block">
+ <loading-button
+ type="submit"
+ container-class="btn btn-success"
+ :disabled="!canSubmit"
+ :loading="isSaving"
+ :label="submitButtonLabel"
+ />
+ <button
+ class="btn btn-cancel"
+ type="button"
+ v-if="isEditing"
+ @click="onCancel"
+ >{{ __('Cancel') }}</button>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
new file mode 100644
index 00000000000..ca7197e1e0f
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -0,0 +1,57 @@
+<script>
+import { mapState } from 'vuex';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import BadgeListRow from './badge_list_row.vue';
+import { GROUP_BADGE } from '../constants';
+
+export default {
+ name: 'BadgeList',
+ components: {
+ BadgeListRow,
+ LoadingIcon,
+ },
+ computed: {
+ ...mapState(['badges', 'isLoading', 'kind']),
+ hasNoBadges() {
+ return !this.isLoading && (!this.badges || !this.badges.length);
+ },
+ isGroupBadge() {
+ return this.kind === GROUP_BADGE;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ {{ s__('Badges|Your badges') }}
+ <span
+ v-show="!isLoading"
+ class="badge"
+ >{{ badges.length }}</span>
+ </div>
+ <loading-icon
+ v-show="isLoading"
+ class="panel-body"
+ size="2"
+ />
+ <div
+ v-if="hasNoBadges"
+ class="panel-body"
+ >
+ <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span>
+ <span v-else>{{ s__('Badges|This project has no badges') }}</span>
+ </div>
+ <div
+ v-else
+ class="panel-body"
+ >
+ <badge-list-row
+ v-for="badge in badges"
+ :key="badge.id"
+ :badge="badge"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
new file mode 100644
index 00000000000..af062bdf8c6
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -0,0 +1,89 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { s__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import { PROJECT_BADGE } from '../constants';
+import Badge from './badge.vue';
+
+export default {
+ name: 'BadgeListRow',
+ components: {
+ Badge,
+ Icon,
+ LoadingIcon,
+ },
+ props: {
+ badge: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['kind']),
+ badgeKindText() {
+ if (this.badge.kind === PROJECT_BADGE) {
+ return s__('Badges|Project Badge');
+ }
+
+ return s__('Badges|Group Badge');
+ },
+ canEditBadge() {
+ return this.badge.kind === this.kind;
+ },
+ },
+ methods: {
+ ...mapActions(['editBadge', 'updateBadgeInModal']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-responsive-table-row-layout gl-responsive-table-row">
+ <badge
+ class="table-section section-30"
+ :image-url="badge.renderedImageUrl"
+ :link-url="badge.renderedLinkUrl"
+ />
+ <span class="table-section section-50 str-truncated">{{ badge.linkUrl }}</span>
+ <div class="table-section section-10">
+ <span class="badge">{{ badgeKindText }}</span>
+ </div>
+ <div class="table-section section-10 table-button-footer">
+ <div
+ v-if="canEditBadge"
+ class="table-action-buttons">
+ <button
+ class="btn btn-default append-right-8"
+ type="button"
+ :disabled="badge.isDeleting"
+ @click="editBadge(badge)"
+ >
+ <icon
+ name="pencil"
+ :size="16"
+ :aria-label="__('Edit')"
+ />
+ </button>
+ <button
+ class="btn btn-danger"
+ type="button"
+ data-toggle="modal"
+ data-target="#delete-badge-modal"
+ :disabled="badge.isDeleting"
+ @click="updateBadgeInModal(badge)"
+ >
+ <icon
+ name="remove"
+ :size="16"
+ :aria-label="__('Delete')"
+ />
+ </button>
+ <loading-icon
+ v-show="badge.isDeleting"
+ :inline="true"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
new file mode 100644
index 00000000000..83f78394238
--- /dev/null
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -0,0 +1,70 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+import Badge from './badge.vue';
+import BadgeForm from './badge_form.vue';
+import BadgeList from './badge_list.vue';
+
+export default {
+ name: 'BadgeSettings',
+ components: {
+ Badge,
+ BadgeForm,
+ BadgeList,
+ GlModal,
+ },
+ computed: {
+ ...mapState(['badgeInModal', 'isEditing']),
+ deleteModalText() {
+ return s__(
+ 'Badges|You are going to delete this badge. Deleted badges <strong>cannot</strong> be restored.',
+ );
+ },
+ },
+ methods: {
+ ...mapActions(['deleteBadge']),
+ onSubmitModal() {
+ this.deleteBadge(this.badgeInModal)
+ .then(() => {
+ createFlash(s__('Badges|The badge was deleted.'), 'notice');
+ })
+ .catch(error => {
+ createFlash(s__('Badges|Deleting the badge failed, please try again.'));
+ throw error;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="badge-settings">
+ <gl-modal
+ id="delete-badge-modal"
+ :header-title-text="s__('Badges|Delete badge?')"
+ footer-primary-button-variant="danger"
+ :footer-primary-button-text="s__('Badges|Delete badge')"
+ @submit="onSubmitModal">
+ <div class="well">
+ <badge
+ :image-url="badgeInModal ? badgeInModal.renderedImageUrl : ''"
+ :link-url="badgeInModal ? badgeInModal.renderedLinkUrl : ''"
+ />
+ </div>
+ <p v-html="deleteModalText"></p>
+ </gl-modal>
+
+ <badge-form
+ v-show="isEditing"
+ :is-editing="true"
+ />
+
+ <badge-form
+ v-show="!isEditing"
+ :is-editing="false"
+ />
+ <badge-list v-show="!isEditing" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js
new file mode 100644
index 00000000000..8fbe3db5ef1
--- /dev/null
+++ b/app/assets/javascripts/badges/constants.js
@@ -0,0 +1,2 @@
+export const GROUP_BADGE = 'group';
+export const PROJECT_BADGE = 'project';
diff --git a/app/assets/javascripts/badges/empty_badge.js b/app/assets/javascripts/badges/empty_badge.js
new file mode 100644
index 00000000000..49a9b5e1be8
--- /dev/null
+++ b/app/assets/javascripts/badges/empty_badge.js
@@ -0,0 +1,7 @@
+export default () => ({
+ imageUrl: '',
+ isDeleting: false,
+ linkUrl: '',
+ renderedImageUrl: '',
+ renderedLinkUrl: '',
+});
diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js
new file mode 100644
index 00000000000..5542278b3e0
--- /dev/null
+++ b/app/assets/javascripts/badges/store/actions.js
@@ -0,0 +1,167 @@
+import axios from '~/lib/utils/axios_utils';
+import types from './mutation_types';
+
+export const transformBackendBadge = badge => ({
+ id: badge.id,
+ imageUrl: badge.image_url,
+ kind: badge.kind,
+ linkUrl: badge.link_url,
+ renderedImageUrl: badge.rendered_image_url,
+ renderedLinkUrl: badge.rendered_link_url,
+ isDeleting: false,
+});
+
+export default {
+ requestNewBadge({ commit }) {
+ commit(types.REQUEST_NEW_BADGE);
+ },
+ receiveNewBadge({ commit }, newBadge) {
+ commit(types.RECEIVE_NEW_BADGE, newBadge);
+ },
+ receiveNewBadgeError({ commit }) {
+ commit(types.RECEIVE_NEW_BADGE_ERROR);
+ },
+ addBadge({ dispatch, state }) {
+ const newBadge = state.badgeInAddForm;
+ const endpoint = state.apiEndpointUrl;
+ dispatch('requestNewBadge');
+ return axios
+ .post(endpoint, {
+ image_url: newBadge.imageUrl,
+ link_url: newBadge.linkUrl,
+ })
+ .catch(error => {
+ dispatch('receiveNewBadgeError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveNewBadge', transformBackendBadge(res.data));
+ });
+ },
+ requestDeleteBadge({ commit }, badgeId) {
+ commit(types.REQUEST_DELETE_BADGE, badgeId);
+ },
+ receiveDeleteBadge({ commit }, badgeId) {
+ commit(types.RECEIVE_DELETE_BADGE, badgeId);
+ },
+ receiveDeleteBadgeError({ commit }, badgeId) {
+ commit(types.RECEIVE_DELETE_BADGE_ERROR, badgeId);
+ },
+ deleteBadge({ dispatch, state }, badge) {
+ const badgeId = badge.id;
+ dispatch('requestDeleteBadge', badgeId);
+ const endpoint = `${state.apiEndpointUrl}/${badgeId}`;
+ return axios
+ .delete(endpoint)
+ .catch(error => {
+ dispatch('receiveDeleteBadgeError', badgeId);
+ throw error;
+ })
+ .then(() => {
+ dispatch('receiveDeleteBadge', badgeId);
+ });
+ },
+
+ editBadge({ commit }, badge) {
+ commit(types.START_EDITING, badge);
+ },
+
+ requestLoadBadges({ commit }, data) {
+ commit(types.REQUEST_LOAD_BADGES, data);
+ },
+ receiveLoadBadges({ commit }, badges) {
+ commit(types.RECEIVE_LOAD_BADGES, badges);
+ },
+ receiveLoadBadgesError({ commit }) {
+ commit(types.RECEIVE_LOAD_BADGES_ERROR);
+ },
+
+ loadBadges({ dispatch, state }, data) {
+ dispatch('requestLoadBadges', data);
+ const endpoint = state.apiEndpointUrl;
+ return axios
+ .get(endpoint)
+ .catch(error => {
+ dispatch('receiveLoadBadgesError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveLoadBadges', res.data.map(transformBackendBadge));
+ });
+ },
+
+ requestRenderedBadge({ commit }) {
+ commit(types.REQUEST_RENDERED_BADGE);
+ },
+ receiveRenderedBadge({ commit }, renderedBadge) {
+ commit(types.RECEIVE_RENDERED_BADGE, renderedBadge);
+ },
+ receiveRenderedBadgeError({ commit }) {
+ commit(types.RECEIVE_RENDERED_BADGE_ERROR);
+ },
+
+ renderBadge({ dispatch, state }) {
+ const badge = state.isEditing ? state.badgeInEditForm : state.badgeInAddForm;
+ const { linkUrl, imageUrl } = badge;
+ if (!linkUrl || linkUrl.trim() === '' || !imageUrl || imageUrl.trim() === '') {
+ return Promise.resolve(badge);
+ }
+
+ dispatch('requestRenderedBadge');
+
+ const parameters = [
+ `link_url=${encodeURIComponent(linkUrl)}`,
+ `image_url=${encodeURIComponent(imageUrl)}`,
+ ].join('&');
+ const renderEndpoint = `${state.apiEndpointUrl}/render?${parameters}`;
+ return axios
+ .get(renderEndpoint)
+ .catch(error => {
+ dispatch('receiveRenderedBadgeError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveRenderedBadge', transformBackendBadge(res.data));
+ });
+ },
+
+ requestUpdatedBadge({ commit }) {
+ commit(types.REQUEST_UPDATED_BADGE);
+ },
+ receiveUpdatedBadge({ commit }, updatedBadge) {
+ commit(types.RECEIVE_UPDATED_BADGE, updatedBadge);
+ },
+ receiveUpdatedBadgeError({ commit }) {
+ commit(types.RECEIVE_UPDATED_BADGE_ERROR);
+ },
+
+ saveBadge({ dispatch, state }) {
+ const badge = state.badgeInEditForm;
+ const endpoint = `${state.apiEndpointUrl}/${badge.id}`;
+ dispatch('requestUpdatedBadge');
+ return axios
+ .put(endpoint, {
+ image_url: badge.imageUrl,
+ link_url: badge.linkUrl,
+ })
+ .catch(error => {
+ dispatch('receiveUpdatedBadgeError');
+ throw error;
+ })
+ .then(res => {
+ dispatch('receiveUpdatedBadge', transformBackendBadge(res.data));
+ });
+ },
+
+ stopEditing({ commit }) {
+ commit(types.STOP_EDITING);
+ },
+
+ updateBadgeInForm({ commit }, badge) {
+ commit(types.UPDATE_BADGE_IN_FORM, badge);
+ },
+
+ updateBadgeInModal({ commit }, badge) {
+ commit(types.UPDATE_BADGE_IN_MODAL, badge);
+ },
+};
diff --git a/app/assets/javascripts/badges/store/index.js b/app/assets/javascripts/badges/store/index.js
new file mode 100644
index 00000000000..7a5df403a0e
--- /dev/null
+++ b/app/assets/javascripts/badges/store/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+});
diff --git a/app/assets/javascripts/badges/store/mutation_types.js b/app/assets/javascripts/badges/store/mutation_types.js
new file mode 100644
index 00000000000..d73f91b6005
--- /dev/null
+++ b/app/assets/javascripts/badges/store/mutation_types.js
@@ -0,0 +1,21 @@
+export default {
+ RECEIVE_DELETE_BADGE: 'RECEIVE_DELETE_BADGE',
+ RECEIVE_DELETE_BADGE_ERROR: 'RECEIVE_DELETE_BADGE_ERROR',
+ RECEIVE_LOAD_BADGES: 'RECEIVE_LOAD_BADGES',
+ RECEIVE_LOAD_BADGES_ERROR: 'RECEIVE_LOAD_BADGES_ERROR',
+ RECEIVE_NEW_BADGE: 'RECEIVE_NEW_BADGE',
+ RECEIVE_NEW_BADGE_ERROR: 'RECEIVE_NEW_BADGE_ERROR',
+ RECEIVE_RENDERED_BADGE: 'RECEIVE_RENDERED_BADGE',
+ RECEIVE_RENDERED_BADGE_ERROR: 'RECEIVE_RENDERED_BADGE_ERROR',
+ RECEIVE_UPDATED_BADGE: 'RECEIVE_UPDATED_BADGE',
+ RECEIVE_UPDATED_BADGE_ERROR: 'RECEIVE_UPDATED_BADGE_ERROR',
+ REQUEST_DELETE_BADGE: 'REQUEST_DELETE_BADGE',
+ REQUEST_LOAD_BADGES: 'REQUEST_LOAD_BADGES',
+ REQUEST_NEW_BADGE: 'REQUEST_NEW_BADGE',
+ REQUEST_RENDERED_BADGE: 'REQUEST_RENDERED_BADGE',
+ REQUEST_UPDATED_BADGE: 'REQUEST_UPDATED_BADGE',
+ START_EDITING: 'START_EDITING',
+ STOP_EDITING: 'STOP_EDITING',
+ UPDATE_BADGE_IN_FORM: 'UPDATE_BADGE_IN_FORM',
+ UPDATE_BADGE_IN_MODAL: 'UPDATE_BADGE_IN_MODAL',
+};
diff --git a/app/assets/javascripts/badges/store/mutations.js b/app/assets/javascripts/badges/store/mutations.js
new file mode 100644
index 00000000000..bd84e68c00f
--- /dev/null
+++ b/app/assets/javascripts/badges/store/mutations.js
@@ -0,0 +1,158 @@
+import types from './mutation_types';
+import { PROJECT_BADGE } from '../constants';
+
+const reorderBadges = badges =>
+ badges.sort((a, b) => {
+ if (a.kind !== b.kind) {
+ return a.kind === PROJECT_BADGE ? 1 : -1;
+ }
+
+ return a.id - b.id;
+ });
+
+export default {
+ [types.RECEIVE_NEW_BADGE](state, newBadge) {
+ Object.assign(state, {
+ badgeInAddForm: null,
+ badges: reorderBadges(state.badges.concat(newBadge)),
+ isSaving: false,
+ renderedBadge: null,
+ });
+ },
+ [types.RECEIVE_NEW_BADGE_ERROR](state) {
+ Object.assign(state, {
+ isSaving: false,
+ });
+ },
+ [types.REQUEST_NEW_BADGE](state) {
+ Object.assign(state, {
+ isSaving: true,
+ });
+ },
+
+ [types.RECEIVE_UPDATED_BADGE](state, updatedBadge) {
+ const badges = state.badges.map(badge => {
+ if (badge.id === updatedBadge.id) {
+ return updatedBadge;
+ }
+ return badge;
+ });
+ Object.assign(state, {
+ badgeInEditForm: null,
+ badges,
+ isEditing: false,
+ isSaving: false,
+ renderedBadge: null,
+ });
+ },
+ [types.RECEIVE_UPDATED_BADGE_ERROR](state) {
+ Object.assign(state, {
+ isSaving: false,
+ });
+ },
+ [types.REQUEST_UPDATED_BADGE](state) {
+ Object.assign(state, {
+ isSaving: true,
+ });
+ },
+
+ [types.RECEIVE_LOAD_BADGES](state, badges) {
+ Object.assign(state, {
+ badges: reorderBadges(badges),
+ isLoading: false,
+ });
+ },
+ [types.RECEIVE_LOAD_BADGES_ERROR](state) {
+ Object.assign(state, {
+ isLoading: false,
+ });
+ },
+ [types.REQUEST_LOAD_BADGES](state, data) {
+ Object.assign(state, {
+ kind: data.kind, // project or group
+ apiEndpointUrl: data.apiEndpointUrl,
+ docsUrl: data.docsUrl,
+ isLoading: true,
+ });
+ },
+
+ [types.RECEIVE_DELETE_BADGE](state, badgeId) {
+ const badges = state.badges.filter(badge => badge.id !== badgeId);
+ Object.assign(state, {
+ badges,
+ });
+ },
+ [types.RECEIVE_DELETE_BADGE_ERROR](state, badgeId) {
+ const badges = state.badges.map(badge => {
+ if (badge.id === badgeId) {
+ return {
+ ...badge,
+ isDeleting: false,
+ };
+ }
+
+ return badge;
+ });
+ Object.assign(state, {
+ badges,
+ });
+ },
+ [types.REQUEST_DELETE_BADGE](state, badgeId) {
+ const badges = state.badges.map(badge => {
+ if (badge.id === badgeId) {
+ return {
+ ...badge,
+ isDeleting: true,
+ };
+ }
+
+ return badge;
+ });
+ Object.assign(state, {
+ badges,
+ });
+ },
+
+ [types.RECEIVE_RENDERED_BADGE](state, renderedBadge) {
+ Object.assign(state, { isRendering: false, renderedBadge });
+ },
+ [types.RECEIVE_RENDERED_BADGE_ERROR](state) {
+ Object.assign(state, { isRendering: false });
+ },
+ [types.REQUEST_RENDERED_BADGE](state) {
+ Object.assign(state, { isRendering: true });
+ },
+
+ [types.START_EDITING](state, badge) {
+ Object.assign(state, {
+ badgeInEditForm: { ...badge },
+ isEditing: true,
+ renderedBadge: { ...badge },
+ });
+ },
+ [types.STOP_EDITING](state) {
+ Object.assign(state, {
+ badgeInEditForm: null,
+ isEditing: false,
+ renderedBadge: null,
+ });
+ },
+
+ [types.UPDATE_BADGE_IN_FORM](state, badge) {
+ if (state.isEditing) {
+ Object.assign(state, {
+ badgeInEditForm: badge,
+ });
+ } else {
+ Object.assign(state, {
+ badgeInAddForm: badge,
+ });
+ }
+ },
+
+ [types.UPDATE_BADGE_IN_MODAL](state, badge) {
+ Object.assign(state, {
+ badgeInModal: badge,
+ });
+ },
+};
diff --git a/app/assets/javascripts/badges/store/state.js b/app/assets/javascripts/badges/store/state.js
new file mode 100644
index 00000000000..43413aeb5bb
--- /dev/null
+++ b/app/assets/javascripts/badges/store/state.js
@@ -0,0 +1,13 @@
+export default () => ({
+ apiEndpointUrl: null,
+ badgeInAddForm: null,
+ badgeInEditForm: null,
+ badgeInModal: null,
+ badges: [],
+ docsUrl: null,
+ renderedBadge: null,
+ isEditing: false,
+ isLoading: false,
+ isRendering: false,
+ isSaving: false,
+});
diff --git a/app/assets/javascripts/behaviors/copy_as_gfm.js b/app/assets/javascripts/behaviors/copy_as_gfm.js
deleted file mode 100644
index e7dc4ef8304..00000000000
--- a/app/assets/javascripts/behaviors/copy_as_gfm.js
+++ /dev/null
@@ -1,481 +0,0 @@
-/* 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 */
-
-import _ from 'underscore';
-import { insertText, getSelectedFragment, nodeMatchesSelector } from '../lib/utils/common_utils';
-import { placeholderImage } from '../lazy_loader';
-
-const gfmRules = {
- // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
- // GitLab Flavored Markdown (GFM) to HTML.
- // These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
- // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
- // from GFM should have a handler here, in reverse order.
- // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
- InlineDiffFilter: {
- 'span.idiff.addition'(el, text) {
- return `{+${text}+}`;
- },
- 'span.idiff.deletion'(el, text) {
- return `{-${text}-}`;
- },
- },
- TaskListFilter: {
- 'input[type=checkbox].task-list-item-checkbox'(el) {
- return `[${el.checked ? 'x' : ' '}]`;
- },
- },
- ReferenceFilter: {
- '.tooltip'(el) {
- return '';
- },
- 'a.gfm:not([data-link=true])'(el, text) {
- return el.dataset.original || text;
- },
- },
- AutolinkFilter: {
- 'a'(el, text) {
- // Fallback on the regular MarkdownFilter's `a` handler.
- if (text !== el.getAttribute('href')) return false;
-
- return text;
- },
- },
- TableOfContentsFilter: {
- 'ul.section-nav'(el) {
- return '[[_TOC_]]';
- },
- },
- EmojiFilter: {
- 'img.emoji'(el) {
- return el.getAttribute('alt');
- },
- 'gl-emoji'(el) {
- return `:${el.getAttribute('data-name')}:`;
- },
- },
- ImageLinkFilter: {
- 'a.no-attachment-icon'(el, text) {
- return text;
- },
- },
- ImageLazyLoadFilter: {
- 'img'(el, text) {
- return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
- },
- },
- VideoLinkFilter: {
- '.video-container'(el) {
- const videoEl = el.querySelector('video');
- if (!videoEl) return false;
-
- return CopyAsGFM.nodeToGFM(videoEl);
- },
- 'video'(el) {
- return `![${el.dataset.title}](${el.getAttribute('src')})`;
- },
- },
- MathFilter: {
- 'pre.code.math[data-math-style=display]'(el, text) {
- return `\`\`\`math\n${text.trim()}\n\`\`\``;
- },
- 'code.code.math[data-math-style=inline]'(el, text) {
- return `$\`${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) {
- const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
- if (!mathAnnotation) return false;
-
- return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
- },
- 'span.katex-html'(el) {
- // We don't want to include the content of this element in the copied text.
- return '';
- },
- 'annotation[encoding="application/x-tex"]'(el, text) {
- return text.trim();
- },
- },
- SanitizationFilter: {
- 'a[name]:not([href]):empty'(el) {
- return el.outerHTML;
- },
- 'dl'(el, text) {
- let lines = text.trim().split('\n');
- // Add two spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- lines = lines.map((l) => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- });
-
- return `<dl>\n${lines.join('\n')}\n</dl>`;
- },
- 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) {
- const tag = el.nodeName.toLowerCase();
- return `<${tag}>${text}</${tag}>`;
- },
- },
- SyntaxHighlightFilter: {
- 'pre.code.highlight'(el, t) {
- const text = t.trimRight();
-
- let lang = el.getAttribute('lang');
- if (!lang || lang === 'plaintext') {
- lang = '';
- }
-
- // Prefixes lines with 4 spaces if the code contains triple backticks
- if (lang === '' && text.match(/^```/gm)) {
- return text.split('\n').map((l) => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- }).join('\n');
- }
-
- return `\`\`\`${lang}\n${text}\n\`\`\``;
- },
- 'pre > code'(el, text) {
- // Don't wrap code blocks in ``
- return text;
- },
- },
- MarkdownFilter: {
- 'br'(el) {
- // Two spaces at the end of a line are turned into a BR
- return ' ';
- },
- 'code'(el, text) {
- let backtickCount = 1;
- const backtickMatch = text.match(/`+/);
- if (backtickMatch) {
- backtickCount = backtickMatch[0].length + 1;
- }
-
- const backticks = Array(backtickCount + 1).join('`');
- const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
-
- return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
- },
- 'blockquote'(el, text) {
- return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
- },
- '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
- return text;
- },
- 'a'(el, text) {
- return `[${text}](${el.getAttribute('href')})`;
- },
- 'li'(el, text) {
- const lines = text.trim().split('\n');
- const firstLine = `- ${lines.shift()}`;
- // Add four spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- const nextLines = lines.map((s) => {
- if (s.trim().length === 0) return '';
-
- return ` ${s}`;
- });
-
- return `${firstLine}\n${nextLines.join('\n')}`;
- },
- 'ul'(el, text) {
- return text;
- },
- 'ol'(el, text) {
- // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
- return text.replace(/^- /mg, '1. ');
- },
- 'h1'(el, text) {
- return `# ${text.trim()}`;
- },
- 'h2'(el, text) {
- return `## ${text.trim()}`;
- },
- 'h3'(el, text) {
- return `### ${text.trim()}`;
- },
- 'h4'(el, text) {
- return `#### ${text.trim()}`;
- },
- 'h5'(el, text) {
- return `##### ${text.trim()}`;
- },
- 'h6'(el, text) {
- return `###### ${text.trim()}`;
- },
- 'strong'(el, text) {
- return `**${text}**`;
- },
- 'em'(el, text) {
- return `_${text}_`;
- },
- 'del'(el, text) {
- return `~~${text}~~`;
- },
- 'sup'(el, text) {
- return `^${text}`;
- },
- 'hr'(el) {
- return '-----';
- },
- 'table'(el) {
- const theadEl = el.querySelector('thead');
- const tbodyEl = el.querySelector('tbody');
- if (!theadEl || !tbodyEl) return false;
-
- const theadText = CopyAsGFM.nodeToGFM(theadEl);
- const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
-
- return [theadText, tbodyText].join('\n');
- },
- 'thead'(el, text) {
- const cells = _.map(el.querySelectorAll('th'), (cell) => {
- let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
-
- let before = '';
- let after = '';
- switch (cell.style.textAlign) {
- case 'center':
- before = ':';
- after = ':';
- chars -= 2;
- break;
- case 'right':
- after = ':';
- chars -= 1;
- break;
- default:
- break;
- }
-
- chars = Math.max(chars, 3);
-
- const middle = Array(chars + 1).join('-');
-
- return before + middle + after;
- });
-
- const separatorRow = `|${cells.join('|')}|`;
-
- return [text, separatorRow].join('\n');
- },
- '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(' | ')} |`;
- },
- },
-};
-
-export class CopyAsGFM {
- constructor() {
- $(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);
- }
-
- static copyAsGFM(e, transformer) {
- const clipboardData = e.originalEvent.clipboardData;
- if (!clipboardData) return;
-
- const documentFragment = getSelectedFragment();
- if (!documentFragment) return;
-
- const el = transformer(documentFragment.cloneNode(true), e.currentTarget);
- if (!el) return;
-
- e.preventDefault();
- e.stopPropagation();
-
- clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
- }
-
- 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, (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) {
- const gfmElements = documentFragment.querySelectorAll('.md, .wiki');
- switch (gfmElements.length) {
- case 0: {
- return documentFragment;
- }
- case 1: {
- return gfmElements[0];
- }
- default: {
- const allGfmElement = document.createElement('div');
-
- for (let i = 0; i < gfmElements.length; i += 1) {
- const gfmElement = gfmElements[i];
- allGfmElement.appendChild(gfmElement);
- allGfmElement.appendChild(document.createTextNode('\n\n'));
- }
-
- return allGfmElement;
- }
- }
- }
-
- static transformCodeSelection(documentFragment, target) {
- let lineSelector = '.line';
-
- if (target) {
- const lineClass = ['left-side', 'right-side'].filter(name => target.classList.contains(name))[0];
- if (lineClass) {
- lineSelector = `.line_content.${lineClass} ${lineSelector}`;
- }
- }
-
- const lineElements = documentFragment.querySelectorAll(lineSelector);
-
- let codeElement;
- if (lineElements.length > 1) {
- codeElement = document.createElement('pre');
- codeElement.className = 'code highlight';
-
- const lang = lineElements[0].getAttribute('lang');
- if (lang) {
- codeElement.setAttribute('lang', lang);
- }
- } else {
- codeElement = document.createElement('code');
- }
-
- if (lineElements.length > 0) {
- for (let i = 0; i < lineElements.length; i += 1) {
- const lineElement = lineElements[i];
- codeElement.appendChild(lineElement);
- codeElement.appendChild(document.createTextNode('\n'));
- }
- } else {
- codeElement.appendChild(documentFragment);
- }
-
- return codeElement;
- }
-
- static nodeToGFM(node, respectWhitespaceParam = false) {
- if (node.nodeType === Node.COMMENT_NODE) {
- return '';
- }
-
- if (node.nodeType === Node.TEXT_NODE) {
- return node.textContent;
- }
-
- const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
-
- const text = this.innerGFM(node, respectWhitespace);
-
- if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
- return text;
- }
-
- for (const filter in gfmRules) {
- const rules = gfmRules[filter];
-
- for (const selector in rules) {
- const func = rules[selector];
-
- if (!nodeMatchesSelector(node, selector)) continue;
-
- 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;
- }
- }
-
- return text;
- }
-
- static innerGFM(parentNode, respectWhitespace = false) {
- const nodes = parentNode.childNodes;
-
- const clonedParentNode = parentNode.cloneNode(true);
- const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
-
- for (let i = 0; i < nodes.length; i += 1) {
- const node = nodes[i];
- const clonedNode = clonedNodes[i];
-
- const text = this.nodeToGFM(node, respectWhitespace);
-
- // `clonedNode.replaceWith(text)` is not yet widely supported
- clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
- }
-
- let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
-
- if (!respectWhitespace) {
- nodeText = nodeText.trim();
- }
-
- return nodeText;
- }
-}
-
-// Export CopyAsGFM as a global for rspec to access
-// see /spec/features/copy_as_gfm_spec.rb
-if (process.env.NODE_ENV !== 'production') {
- window.CopyAsGFM = CopyAsGFM;
-}
-
-export default function initCopyAsGFM() {
- return new CopyAsGFM();
-}
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index cdea625fc8c..e2a73a1797c 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -1,8 +1,9 @@
+import $ from 'jquery';
import Clipboard from 'clipboard';
function showTooltip(target, title) {
const $target = $(target);
- const originalTitle = $target.data('original-title');
+ const originalTitle = $target.data('originalTitle');
if (!$target.data('hideTooltip')) {
$target
diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js
index 7c9dbcc8d6e..1d63f5baeee 100644
--- a/app/assets/javascripts/behaviors/details_behavior.js
+++ b/app/assets/javascripts/behaviors/details_behavior.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
$(() => {
$('body').on('click', '.js-details-target', function target() {
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 34e905222b4..84fef4d8b4f 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -1,12 +1,14 @@
import './autosize';
import './bind_in_out';
-import initCopyAsGFM from './copy_as_gfm';
+import './markdown/render_gfm';
+import initCopyAsGFM from './markdown/copy_as_gfm';
import initCopyToClipboard from './copy_to_clipboard';
import './details_behavior';
import installGlEmojiElement from './gl_emoji';
import './quick_submit';
import './requires_input';
import './toggler_behavior';
+import '../preview_markdown';
installGlEmojiElement();
initCopyAsGFM();
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
new file mode 100644
index 00000000000..75cf90de0b5
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -0,0 +1,501 @@
+/* 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 */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils';
+import { placeholderImage } from '~/lazy_loader';
+
+const gfmRules = {
+ // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
+ // GitLab Flavored Markdown (GFM) to HTML.
+ // These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
+ // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
+ // from GFM should have a handler here, in reverse order.
+ // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
+ InlineDiffFilter: {
+ 'span.idiff.addition'(el, text) {
+ return `{+${text}+}`;
+ },
+ 'span.idiff.deletion'(el, text) {
+ return `{-${text}-}`;
+ },
+ },
+ TaskListFilter: {
+ 'input[type=checkbox].task-list-item-checkbox'(el) {
+ return `[${el.checked ? 'x' : ' '}]`;
+ },
+ },
+ ReferenceFilter: {
+ '.tooltip'(el) {
+ return '';
+ },
+ 'a.gfm:not([data-link=true])'(el, text) {
+ return el.dataset.original || text;
+ },
+ },
+ AutolinkFilter: {
+ 'a'(el, text) {
+ // Fallback on the regular MarkdownFilter's `a` handler.
+ if (text !== el.getAttribute('href')) return false;
+
+ return text;
+ },
+ },
+ TableOfContentsFilter: {
+ 'ul.section-nav'(el) {
+ return '[[_TOC_]]';
+ },
+ },
+ EmojiFilter: {
+ 'img.emoji'(el) {
+ return el.getAttribute('alt');
+ },
+ 'gl-emoji'(el) {
+ return `:${el.getAttribute('data-name')}:`;
+ },
+ },
+ ImageLinkFilter: {
+ 'a.no-attachment-icon'(el, text) {
+ return text;
+ },
+ },
+ ImageLazyLoadFilter: {
+ 'img'(el, text) {
+ return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
+ },
+ },
+ VideoLinkFilter: {
+ '.video-container'(el) {
+ const videoEl = el.querySelector('video');
+ if (!videoEl) return false;
+
+ return CopyAsGFM.nodeToGFM(videoEl);
+ },
+ 'video'(el) {
+ return `![${el.dataset.title}](${el.getAttribute('src')})`;
+ },
+ },
+ MermaidFilter: {
+ 'svg.mermaid'(el, text) {
+ const sourceEl = el.querySelector('text.source');
+ if (!sourceEl) return false;
+
+ return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``;
+ },
+ 'svg.mermaid style, svg.mermaid g'(el, text) {
+ // We don't want to include the content of these elements in the copied text.
+ return '';
+ },
+ },
+ MathFilter: {
+ 'pre.code.math[data-math-style=display]'(el, text) {
+ return `\`\`\`math\n${text.trim()}\n\`\`\``;
+ },
+ 'code.code.math[data-math-style=inline]'(el, text) {
+ return `$\`${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) {
+ const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
+ if (!mathAnnotation) return false;
+
+ return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
+ },
+ 'span.katex-html'(el) {
+ // We don't want to include the content of this element in the copied text.
+ return '';
+ },
+ 'annotation[encoding="application/x-tex"]'(el, text) {
+ return text.trim();
+ },
+ },
+ SanitizationFilter: {
+ 'a[name]:not([href]):empty'(el) {
+ return el.outerHTML;
+ },
+ 'dl'(el, text) {
+ let lines = text.trim().split('\n');
+ // Add two spaces to the front of subsequent list items lines,
+ // or leave the line entirely blank.
+ lines = lines.map((l) => {
+ const line = l.trim();
+ if (line.length === 0) return '';
+
+ return ` ${line}`;
+ });
+
+ return `<dl>\n${lines.join('\n')}\n</dl>`;
+ },
+ 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) {
+ const tag = el.nodeName.toLowerCase();
+ return `<${tag}>${text}</${tag}>`;
+ },
+ },
+ SyntaxHighlightFilter: {
+ 'pre.code.highlight'(el, t) {
+ const text = t.trimRight();
+
+ let lang = el.getAttribute('lang');
+ if (!lang || lang === 'plaintext') {
+ lang = '';
+ }
+
+ // Prefixes lines with 4 spaces if the code contains triple backticks
+ if (lang === '' && text.match(/^```/gm)) {
+ return text.split('\n').map((l) => {
+ const line = l.trim();
+ if (line.length === 0) return '';
+
+ return ` ${line}`;
+ }).join('\n');
+ }
+
+ return `\`\`\`${lang}\n${text}\n\`\`\``;
+ },
+ 'pre > code'(el, text) {
+ // Don't wrap code blocks in ``
+ return text;
+ },
+ },
+ MarkdownFilter: {
+ 'br'(el) {
+ // Two spaces at the end of a line are turned into a BR
+ return ' ';
+ },
+ 'code'(el, text) {
+ let backtickCount = 1;
+ const backtickMatch = text.match(/`+/);
+ if (backtickMatch) {
+ backtickCount = backtickMatch[0].length + 1;
+ }
+
+ const backticks = Array(backtickCount + 1).join('`');
+ const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
+
+ return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
+ },
+ 'blockquote'(el, text) {
+ return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
+ },
+ '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
+ return text;
+ },
+ 'a'(el, text) {
+ return `[${text}](${el.getAttribute('href')})`;
+ },
+ 'li'(el, text) {
+ const lines = text.trim().split('\n');
+ const firstLine = `- ${lines.shift()}`;
+ // Add four spaces to the front of subsequent list items lines,
+ // or leave the line entirely blank.
+ const nextLines = lines.map((s) => {
+ if (s.trim().length === 0) return '';
+
+ return ` ${s}`;
+ });
+
+ return `${firstLine}\n${nextLines.join('\n')}`;
+ },
+ 'ul'(el, text) {
+ return text;
+ },
+ 'ol'(el, text) {
+ // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
+ return text.replace(/^- /mg, '1. ');
+ },
+ 'h1'(el, text) {
+ return `# ${text.trim()}`;
+ },
+ 'h2'(el, text) {
+ return `## ${text.trim()}`;
+ },
+ 'h3'(el, text) {
+ return `### ${text.trim()}`;
+ },
+ 'h4'(el, text) {
+ return `#### ${text.trim()}`;
+ },
+ 'h5'(el, text) {
+ return `##### ${text.trim()}`;
+ },
+ 'h6'(el, text) {
+ return `###### ${text.trim()}`;
+ },
+ 'strong'(el, text) {
+ return `**${text}**`;
+ },
+ 'em'(el, text) {
+ return `_${text}_`;
+ },
+ 'del'(el, text) {
+ return `~~${text}~~`;
+ },
+ 'sup'(el, text) {
+ return `^${text}`;
+ },
+ 'hr'(el) {
+ return '-----';
+ },
+ 'table'(el) {
+ const theadEl = el.querySelector('thead');
+ const tbodyEl = el.querySelector('tbody');
+ if (!theadEl || !tbodyEl) return false;
+
+ const theadText = CopyAsGFM.nodeToGFM(theadEl);
+ const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
+
+ return [theadText, tbodyText].join('\n');
+ },
+ 'thead'(el, text) {
+ const cells = _.map(el.querySelectorAll('th'), (cell) => {
+ let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
+
+ let before = '';
+ let after = '';
+ switch (cell.style.textAlign) {
+ case 'center':
+ before = ':';
+ after = ':';
+ chars -= 2;
+ break;
+ case 'right':
+ after = ':';
+ chars -= 1;
+ break;
+ default:
+ break;
+ }
+
+ chars = Math.max(chars, 3);
+
+ const middle = Array(chars + 1).join('-');
+
+ return before + middle + after;
+ });
+
+ const separatorRow = `|${cells.join('|')}|`;
+
+ return [text, separatorRow].join('\n');
+ },
+ '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(' | ')} |`;
+ },
+ },
+};
+
+export class CopyAsGFM {
+ constructor() {
+ // iOS currently does not support clipboardData.setData(). This bug should
+ // be fixed in iOS 12, but for now we'll disable this for all iOS browsers
+ // ref: https://trac.webkit.org/changeset/222228/webkit
+ const userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
+ const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
+ if (isIOS) return;
+
+ $(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);
+ }
+
+ static copyAsGFM(e, transformer) {
+ const clipboardData = e.originalEvent.clipboardData;
+ if (!clipboardData) return;
+
+ const documentFragment = getSelectedFragment();
+ if (!documentFragment) return;
+
+ const el = transformer(documentFragment.cloneNode(true), e.currentTarget);
+ if (!el) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ clipboardData.setData('text/plain', el.textContent);
+ clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
+ }
+
+ 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, (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) {
+ const gfmElements = documentFragment.querySelectorAll('.md, .wiki');
+ switch (gfmElements.length) {
+ case 0: {
+ return documentFragment;
+ }
+ case 1: {
+ return gfmElements[0];
+ }
+ default: {
+ const allGfmElement = document.createElement('div');
+
+ for (let i = 0; i < gfmElements.length; i += 1) {
+ const gfmElement = gfmElements[i];
+ allGfmElement.appendChild(gfmElement);
+ allGfmElement.appendChild(document.createTextNode('\n\n'));
+ }
+
+ return allGfmElement;
+ }
+ }
+ }
+
+ static transformCodeSelection(documentFragment, target) {
+ let lineSelector = '.line';
+
+ if (target) {
+ const lineClass = ['left-side', 'right-side'].filter(name => target.classList.contains(name))[0];
+ if (lineClass) {
+ lineSelector = `.line_content.${lineClass} ${lineSelector}`;
+ }
+ }
+
+ const lineElements = documentFragment.querySelectorAll(lineSelector);
+
+ let codeElement;
+ if (lineElements.length > 1) {
+ codeElement = document.createElement('pre');
+ codeElement.className = 'code highlight';
+
+ const lang = lineElements[0].getAttribute('lang');
+ if (lang) {
+ codeElement.setAttribute('lang', lang);
+ }
+ } else {
+ codeElement = document.createElement('code');
+ }
+
+ if (lineElements.length > 0) {
+ for (let i = 0; i < lineElements.length; i += 1) {
+ const lineElement = lineElements[i];
+ codeElement.appendChild(lineElement);
+ codeElement.appendChild(document.createTextNode('\n'));
+ }
+ } else {
+ codeElement.appendChild(documentFragment);
+ }
+
+ return codeElement;
+ }
+
+ static nodeToGFM(node, respectWhitespaceParam = false) {
+ if (node.nodeType === Node.COMMENT_NODE) {
+ return '';
+ }
+
+ if (node.nodeType === Node.TEXT_NODE) {
+ return node.textContent;
+ }
+
+ const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
+
+ const text = this.innerGFM(node, respectWhitespace);
+
+ if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+ return text;
+ }
+
+ for (const filter in gfmRules) {
+ const rules = gfmRules[filter];
+
+ for (const selector in rules) {
+ const func = rules[selector];
+
+ if (!nodeMatchesSelector(node, selector)) continue;
+
+ 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;
+ }
+ }
+
+ return text;
+ }
+
+ static innerGFM(parentNode, respectWhitespace = false) {
+ const nodes = parentNode.childNodes;
+
+ const clonedParentNode = parentNode.cloneNode(true);
+ const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
+
+ for (let i = 0; i < nodes.length; i += 1) {
+ const node = nodes[i];
+ const clonedNode = clonedNodes[i];
+
+ const text = this.nodeToGFM(node, respectWhitespace);
+
+ // `clonedNode.replaceWith(text)` is not yet widely supported
+ clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
+ }
+
+ let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
+
+ if (!respectWhitespace) {
+ nodeText = nodeText.trim();
+ }
+
+ return nodeText;
+ }
+}
+
+// Export CopyAsGFM as a global for rspec to access
+// see /spec/features/copy_as_gfm_spec.rb
+if (process.env.NODE_ENV !== 'production') {
+ window.CopyAsGFM = CopyAsGFM;
+}
+
+export default function initCopyAsGFM() {
+ return new CopyAsGFM();
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
new file mode 100644
index 00000000000..dbff2bd4b10
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import syntaxHighlight from '~/syntax_highlight';
+import renderMath from './render_math';
+import renderMermaid from './render_mermaid';
+
+// Render Gitlab flavoured Markdown
+//
+// Delegates to syntax highlight and render math & mermaid diagrams.
+//
+$.fn.renderGFM = function renderGFM() {
+ syntaxHighlight(this.find('.js-syntax-highlight'));
+ renderMath(this.find('.js-render-math'));
+ renderMermaid(this.find('.js-render-mermaid'));
+ return this;
+};
+
+$(() => $('body').renderGFM());
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
new file mode 100644
index 00000000000..eb4e59d12b1
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -0,0 +1,38 @@
+import $ from 'jquery';
+import { __ } from '~/locale';
+import flash from '~/flash';
+
+// Renders math using KaTeX in any element with the
+// `js-render-math` class
+//
+// ### Example Markup
+//
+// <code class="js-render-math"></div>
+//
+
+// Loop over all math elements and render math
+function renderWithKaTeX(elements, katex) {
+ elements.each(function katexElementsLoop() {
+ const mathNode = $('<span></span>');
+ const $this = $(this);
+
+ const display = $this.attr('data-math-style') === 'display';
+ try {
+ katex.render($this.text(), mathNode.get(0), { displayMode: display, throwOnError: false });
+ mathNode.insertAfter($this);
+ $this.remove();
+ } catch (err) {
+ throw err;
+ }
+ });
+}
+
+export default function renderMath($els) {
+ if (!$els.length) return;
+ Promise.all([
+ import(/* webpackChunkName: 'katex' */ 'katex'),
+ import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.min.css'),
+ ]).then(([katex]) => {
+ renderWithKaTeX($els, katex);
+ }).catch(() => flash(__('An error occurred while rendering KaTeX')));
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
new file mode 100644
index 00000000000..56b1896e9f1
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -0,0 +1,57 @@
+import flash from '~/flash';
+
+// Renders diagrams and flowcharts from text using Mermaid in any element with the
+// `js-render-mermaid` class.
+//
+// Example markup:
+//
+// <pre class="js-render-mermaid">
+// graph TD;
+// A-- > B;
+// A-- > C;
+// B-- > D;
+// C-- > D;
+// </pre>
+//
+
+export default function renderMermaid($els) {
+ if (!$els.length) return;
+
+ import(/* webpackChunkName: 'mermaid' */ 'blackst0ne-mermaid').then((mermaid) => {
+ mermaid.initialize({
+ // mermaid core options
+ mermaid: {
+ startOnLoad: false,
+ },
+ // mermaidAPI options
+ theme: 'neutral',
+ });
+
+ $els.each((i, el) => {
+ const source = el.textContent;
+
+ // Remove any extra spans added by the backend syntax highlighting.
+ Object.assign(el, { textContent: source });
+
+ mermaid.init(undefined, el, (id) => {
+ const svg = document.getElementById(id);
+
+ svg.classList.add('mermaid');
+
+ // pre > code > svg
+ svg.closest('pre').replaceWith(svg);
+
+ // We need to add the original source into the DOM to allow Copy-as-GFM
+ // to access it.
+ const sourceEl = document.createElement('text');
+ sourceEl.classList.add('source');
+ sourceEl.setAttribute('display', 'none');
+ sourceEl.textContent = source;
+
+ svg.appendChild(sourceEl);
+ });
+ });
+ }).catch((err) => {
+ flash(`Can't load mermaid module: ${err}`);
+ });
+}
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 2cf8f4fa935..3ec932bdb73 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import '../commons/bootstrap';
import { isInIssuePage } from '../lib/utils/common_utils';
@@ -43,7 +44,7 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
const $form = $(e.target).closest('form');
const $submitButton = $form.find('input[type=submit], button[type=submit]').first();
- if (!$submitButton.attr('disabled')) {
+ if (!$submitButton.prop('disabled')) {
$submitButton.trigger('click', [e]);
if (!isInIssuePage()) {
@@ -72,5 +73,5 @@ $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-q
title,
trigger: 'manual',
});
- $this.tooltip('show').one('blur', () => $this.tooltip('hide'));
+ $this.tooltip('show').one('blur click', () => $this.tooltip('hide'));
});
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index 035a7e5c431..ffff4ddb71a 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import _ from 'underscore';
import '../commons/bootstrap';
@@ -40,7 +41,7 @@ $.fn.requiresInput = function requiresInput() {
// based on the option selected
function hideOrShowHelpBlock(form) {
const selected = $('.js-select-namespace option:selected');
- if (selected.length && selected.data('options-parent') === 'groups') {
+ if (selected.length && selected.data('optionsParent') === 'groups') {
form.find('.help-block').hide();
} else if (selected.length) {
form.find('.help-block').show();
diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js
index 1cf0b960eb0..0d6e0dbefcc 100644
--- a/app/assets/javascripts/behaviors/secret_values.js
+++ b/app/assets/javascripts/behaviors/secret_values.js
@@ -2,22 +2,25 @@ import { n__ } from '../locale';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
export default class SecretValues {
- constructor(container) {
+ constructor({
+ container,
+ valueSelector = '.js-secret-value',
+ placeholderSelector = '.js-secret-value-placeholder',
+ }) {
this.container = container;
+ this.valueSelector = valueSelector;
+ this.placeholderSelector = placeholderSelector;
}
init() {
- this.values = this.container.querySelectorAll('.js-secret-value');
- this.placeholders = this.container.querySelectorAll('.js-secret-value-placeholder');
this.revealButton = this.container.querySelector('.js-secret-value-reveal-button');
- this.revealText = n__('Reveal value', 'Reveal values', this.values.length);
- this.hideText = n__('Hide value', 'Hide values', this.values.length);
+ if (this.revealButton) {
+ const isRevealed = convertPermissionToBoolean(this.revealButton.dataset.secretRevealStatus);
+ this.updateDom(isRevealed);
- const isRevealed = convertPermissionToBoolean(this.revealButton.dataset.secretRevealStatus);
- this.updateDom(isRevealed);
-
- this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this));
+ this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this));
+ }
}
onRevealButtonClicked() {
@@ -28,15 +31,17 @@ export default class SecretValues {
}
updateDom(isRevealed) {
- this.values.forEach((value) => {
+ const values = this.container.querySelectorAll(this.valueSelector);
+ values.forEach((value) => {
value.classList.toggle('hide', !isRevealed);
});
- this.placeholders.forEach((placeholder) => {
+ const placeholders = this.container.querySelectorAll(this.placeholderSelector);
+ placeholders.forEach((placeholder) => {
placeholder.classList.toggle('hide', isRevealed);
});
- this.revealButton.textContent = isRevealed ? this.hideText : this.revealText;
+ this.revealButton.textContent = isRevealed ? n__('Hide value', 'Hide values', values.length) : n__('Reveal value', 'Reveal values', values.length);
this.revealButton.dataset.secretRevealStatus = isRevealed;
}
}
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index 417ac31fc86..4446be0e52f 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -1,3 +1,6 @@
+import $ from 'jquery';
+import { getLocationHash } from '../lib/utils/url_utility';
+
// Toggle button. Show/hide content inside parent container.
// Button does not change visibility. If button has icon - it changes chevron style.
//
@@ -5,14 +8,13 @@
// %button.js-toggle-button
// %div.js-toggle-content
//
-import { getLocationHash } from '../lib/utils/url_utility';
$(() => {
function toggleContainer(container, toggleState) {
const $container = $(container);
$container
- .find('.js-toggle-button .fa')
+ .find('.js-toggle-button .fa-chevron-up, .js-toggle-button .fa-chevron-down')
.toggleClass('fa-chevron-up', toggleState)
.toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined);
@@ -22,7 +24,7 @@ $(() => {
}
$('body').on('click', '.js-toggle-button', function toggleButton(e) {
- e.target.classList.toggle('open');
+ e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'open');
toggleContainer($(this).closest('.js-toggle-container'));
const targetTag = e.currentTarget.tagName.toLowerCase();
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
index 062577af385..06ef86ecb77 100644
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -7,7 +7,7 @@ function onError() {
return flash;
}
-function loadBalsamiqFile() {
+export default function loadBalsamiqFile() {
const viewer = document.getElementById('js-balsamiq-viewer');
if (!(viewer instanceof Element)) return;
@@ -17,5 +17,3 @@ function loadBalsamiqFile() {
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 f7ae6f1cd12..ff1739b1679 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -1,9 +1,13 @@
/* eslint-disable func-names, object-shorthand, prefer-arrow-callback */
+
+import $ from 'jquery';
import Dropzone from 'dropzone';
import { visitUrl } from '../lib/utils/url_utility';
import { HIDDEN_CLASS } from '../lib/utils/constants';
import csrf from '../lib/utils/csrf';
+Dropzone.autoDiscover = false;
+
function toggleLoading($el, $icon, loading) {
if (loading) {
$el.disable();
diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js
index 47c431fb809..476b9405a9e 100644
--- a/app/assets/javascripts/blob/blob_fork_suggestion.js
+++ b/app/assets/javascripts/blob/blob_fork_suggestion.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
const defaults = {
// Buttons that will show the `suggestionSections`
// has `data-fork-path`, and `data-action`
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 583e5faa506..ff1cbcad145 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -1,4 +1,6 @@
/* eslint-disable class-methods-use-this */
+
+import $ from 'jquery';
import Flash from '../flash';
import FileTemplateTypeSelector from './template_selectors/type_selector';
import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
@@ -92,7 +94,7 @@ export default class FileTemplateMediator {
const hash = urlPieces[1];
if (hash === 'preview') {
this.hideTemplateSelectorMenu();
- } else if (hash === 'editor') {
+ } else if (hash === 'editor' && !this.typeSelector.isHidden()) {
this.showTemplateSelectorMenu();
}
});
@@ -235,7 +237,7 @@ export default class FileTemplateMediator {
}
setFilename(name) {
- this.$filenameInput.val(name);
+ this.$filenameInput.val(name).trigger('change');
}
getSelected() {
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index 5ae30990aea..02228434a29 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default class FileTemplateSelector {
constructor(mediator) {
this.mediator = mediator;
@@ -30,6 +32,10 @@ export default class FileTemplateSelector {
}
}
+ isHidden() {
+ return this.$wrapper.hasClass('hidden');
+ }
+
getToggleText() {
return this.$dropdownToggleText.text();
}
diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js
index c858a6bb7b4..6f1350e80fc 100644
--- a/app/assets/javascripts/blob/notebook/index.js
+++ b/app/assets/javascripts/blob/notebook/index.js
@@ -1,15 +1,16 @@
/* eslint-disable no-new */
import Vue from 'vue';
-import VueResource from 'vue-resource';
+import axios from '../../lib/utils/axios_utils';
import notebookLab from '../../notebook/index.vue';
-Vue.use(VueResource);
-
export default () => {
const el = document.getElementById('js-notebook-viewer');
new Vue({
el,
+ components: {
+ notebookLab,
+ },
data() {
return {
error: false,
@@ -18,8 +19,41 @@ export default () => {
json: {},
};
},
- components: {
- notebookLab,
+ mounted() {
+ if (gon.katex_css_url) {
+ const katexStyles = document.createElement('link');
+ katexStyles.setAttribute('rel', 'stylesheet');
+ katexStyles.setAttribute('href', gon.katex_css_url);
+ document.head.appendChild(katexStyles);
+ }
+
+ if (gon.katex_js_url) {
+ const katexScript = document.createElement('script');
+ katexScript.addEventListener('load', () => {
+ this.loadFile();
+ });
+ katexScript.setAttribute('src', gon.katex_js_url);
+ document.head.appendChild(katexScript);
+ } else {
+ this.loadFile();
+ }
+ },
+ methods: {
+ loadFile() {
+ axios.get(el.dataset.endpoint)
+ .then(res => res.data)
+ .then((data) => {
+ this.json = data;
+ this.loading = false;
+ })
+ .catch((e) => {
+ if (e.status !== 200) {
+ this.loadError = true;
+ }
+
+ this.error = true;
+ });
+ },
},
template: `
<div class="container-fluid md prepend-top-default append-bottom-default">
@@ -48,41 +82,5 @@ export default () => {
</p>
</div>
`,
- methods: {
- loadFile() {
- this.$http.get(el.dataset.endpoint)
- .then(response => response.json())
- .then((res) => {
- this.json = res;
- this.loading = false;
- })
- .catch((e) => {
- if (e.status) {
- this.loadError = true;
- }
-
- this.error = true;
- });
- },
- },
- mounted() {
- if (gon.katex_css_url) {
- const katexStyles = document.createElement('link');
- katexStyles.setAttribute('rel', 'stylesheet');
- katexStyles.setAttribute('href', gon.katex_css_url);
- document.head.appendChild(katexStyles);
- }
-
- if (gon.katex_js_url) {
- const katexScript = document.createElement('script');
- katexScript.addEventListener('load', () => {
- this.loadFile();
- });
- katexScript.setAttribute('src', gon.katex_js_url);
- document.head.appendChild(katexScript);
- } else {
- this.loadFile();
- }
- },
});
};
diff --git a/app/assets/javascripts/blob/notebook_viewer.js b/app/assets/javascripts/blob/notebook_viewer.js
index b7a0a195a92..226ae69893e 100644
--- a/app/assets/javascripts/blob/notebook_viewer.js
+++ b/app/assets/javascripts/blob/notebook_viewer.js
@@ -1,3 +1,3 @@
import renderNotebook from './notebook';
-document.addEventListener('DOMContentLoaded', renderNotebook);
+export default renderNotebook;
diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js
index 7109f356540..70136cc4087 100644
--- a/app/assets/javascripts/blob/pdf/index.js
+++ b/app/assets/javascripts/blob/pdf/index.js
@@ -7,6 +7,9 @@ export default () => {
return new Vue({
el,
+ components: {
+ pdfLab,
+ },
data() {
return {
error: false,
@@ -15,9 +18,6 @@ export default () => {
pdf: el.dataset.endpoint,
};
},
- components: {
- pdfLab,
- },
methods: {
onLoad() {
this.loading = false;
diff --git a/app/assets/javascripts/blob/pdf_viewer.js b/app/assets/javascripts/blob/pdf_viewer.js
index 91abe9dd699..cabbb396ea7 100644
--- a/app/assets/javascripts/blob/pdf_viewer.js
+++ b/app/assets/javascripts/blob/pdf_viewer.js
@@ -1,3 +1,3 @@
import renderPDF from './pdf';
-document.addEventListener('DOMContentLoaded', renderPDF);
+export default renderPDF;
diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js
index 0640dd26855..2c1c6339fdb 100644
--- a/app/assets/javascripts/blob/sketch_viewer.js
+++ b/app/assets/javascripts/blob/sketch_viewer.js
@@ -1,8 +1,8 @@
/* eslint-disable no-new */
import SketchLoader from './sketch';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const el = document.getElementById('js-sketch-viewer');
new SketchLoader(el);
-});
+};
diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js
index f611c4fe640..63236b6477f 100644
--- a/app/assets/javascripts/blob/stl_viewer.js
+++ b/app/assets/javascripts/blob/stl_viewer.js
@@ -1,6 +1,6 @@
import Renderer from './3d_viewer';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const viewer = new Renderer(document.getElementById('js-stl-viewer'));
[].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => {
@@ -16,4 +16,4 @@ document.addEventListener('DOMContentLoaded', () => {
viewer.changeObjectMaterials(target.dataset.type);
});
});
-});
+};
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 888883163c5..9dfdb06007d 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -1,5 +1,7 @@
/* eslint-disable class-methods-use-this, no-unused-vars */
+import $ from 'jquery';
+
export default class TemplateSelector {
constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) {
this.pattern = pattern;
@@ -76,7 +78,7 @@ export default class TemplateSelector {
if (!skipFocus) this.editor.focus();
- if (this.editor instanceof jQuery) {
+ if (this.editor instanceof $) {
this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent);
}
}
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 54132e8537b..137e1f5a099 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,9 +1,12 @@
+import $ from 'jquery';
import Flash from '../../flash';
import { handleLocationHash } from '../../lib/utils/common_utils';
+import axios from '../../lib/utils/axios_utils';
export default class BlobViewer {
constructor() {
BlobViewer.initAuxiliaryViewer();
+ BlobViewer.initRichViewer();
this.initMainViewers();
}
@@ -15,6 +18,38 @@ export default class BlobViewer {
BlobViewer.loadViewer(auxiliaryViewer);
}
+ static initRichViewer() {
+ const viewer = document.querySelector('.blob-viewer[data-type="rich"]');
+ if (!viewer || !viewer.dataset.richType) return;
+
+ const initViewer = promise => promise
+ .then(module => module.default(viewer))
+ .catch((error) => {
+ Flash('Error loading file viewer.');
+ throw error;
+ });
+
+ switch (viewer.dataset.richType) {
+ case 'balsamiq':
+ initViewer(import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer'));
+ break;
+ case 'notebook':
+ initViewer(import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'));
+ break;
+ case 'pdf':
+ initViewer(import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer'));
+ break;
+ case 'sketch':
+ initViewer(import(/* webpackChunkName: 'sketch_viewer' */ '../sketch_viewer'));
+ break;
+ case 'stl':
+ initViewer(import(/* webpackChunkName: 'stl_viewer' */ '../stl_viewer'));
+ break;
+ default:
+ break;
+ }
+ }
+
initMainViewers() {
this.$fileHolder = $('.file-holder');
if (!this.$fileHolder.length) return;
@@ -127,25 +162,18 @@ export default class BlobViewer {
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;
- }
+ if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
+ return Promise.resolve(viewer);
+ }
- viewer.setAttribute('data-loading', 'true');
+ viewer.setAttribute('data-loading', 'true');
- $.ajax({
- url,
- dataType: 'JSON',
- })
- .fail(reject)
- .done((data) => {
+ return axios.get(url)
+ .then(({ data }) => {
viewer.innerHTML = data.html;
viewer.setAttribute('data-loaded', 'true');
- resolve(viewer);
+ return viewer;
});
- });
}
}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 6b06344f5ba..4424232f642 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,19 +1,21 @@
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, vars-on-top, no-unused-vars, no-new, max-len */
/* global EditBlob */
+
+import $ from 'jquery';
import NewCommitForm from '../new_commit_form';
import EditBlob from './edit_blob';
import BlobFileDropzone from '../blob/blob_file_dropzone';
-$(() => {
+export default () => {
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');
- const assetsPath = editBlobForm.data('assets-prefix');
- const blobLanguage = editBlobForm.data('blob-language');
- const currentAction = $('.js-file-title').data('current-action');
+ const urlRoot = editBlobForm.data('relativeUrlRoot');
+ const assetsPath = editBlobForm.data('assetsPrefix');
+ const blobLanguage = editBlobForm.data('blobLanguage');
+ const currentAction = $('.js-file-title').data('currentAction');
new EditBlob(`${urlRoot}${assetsPath}`, blobLanguage, currentAction);
new NewCommitForm(editBlobForm);
@@ -34,4 +36,4 @@ $(() => {
if (deleteBlobForm.length) {
new NewCommitForm(deleteBlobForm);
}
-});
+};
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index b37988a674d..82a3d494b67 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -1,5 +1,9 @@
/* global ace */
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
import TemplateSelectorMediator from '../blob/file_template_mediator';
export default class EditBlob {
@@ -56,12 +60,14 @@ export default class EditBlob {
if (paneId === '#preview') {
this.$toggleButton.hide();
- return $.post(currentLink.data('preview-url'), {
+ axios.post(currentLink.data('previewUrl'), {
content: this.editor.getValue(),
- }, (response) => {
- currentPane.empty().append(response);
- return currentPane.renderGFM();
- });
+ })
+ .then(({ data }) => {
+ currentPane.empty().append(data);
+ currentPane.renderGFM();
+ })
+ .catch(() => createFlash(__('An error occurred previewing the blob')));
}
this.$toggleButton.show();
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
deleted file mode 100644
index 20d23162940..00000000000
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ /dev/null
@@ -1,241 +0,0 @@
-/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */
-
-import _ from 'underscore';
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-import Flash from '../flash';
-import { __ } from '../locale';
-import FilteredSearchBoards from './filtered_search_boards';
-import eventHub from './eventhub';
-import sidebarEventHub from '../sidebar/event_hub';
-import './models/issue';
-import './models/label';
-import './models/list';
-import './models/milestone';
-import './models/assignee';
-import './stores/boards_store';
-import './stores/modal_store';
-import BoardService from './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);
-
-$(() => {
- const $boardApp = document.getElementById('board-app');
- const Store = gl.issueBoards.BoardsStore;
- const ModalStore = gl.issueBoards.ModalStore;
-
- window.gl = window.gl || {};
-
- if (gl.IssueBoardsApp) {
- gl.IssueBoardsApp.$destroy(true);
- }
-
- Store.create();
-
- // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore
- gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args);
- gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args);
-
- gl.IssueBoardsApp = new Vue({
- el: $boardApp,
- components: {
- 'board': gl.issueBoards.Board,
- 'board-sidebar': gl.issueBoards.BoardSidebar,
- 'board-add-issues-modal': gl.issueBoards.IssuesModal,
- },
- data: {
- state: Store.state,
- loading: true,
- boardsEndpoint: $boardApp.dataset.boardsEndpoint,
- listsEndpoint: $boardApp.dataset.listsEndpoint,
- boardId: $boardApp.dataset.boardId,
- disabled: $boardApp.dataset.disabled === 'true',
- issueLinkBase: $boardApp.dataset.issueLinkBase,
- rootPath: $boardApp.dataset.rootPath,
- bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: Store.detail,
- defaultAvatar: $boardApp.dataset.defaultAvatar,
- },
- computed: {
- detailIssueVisible () {
- return Object.keys(this.detailIssue.issue).length;
- },
- },
- created () {
- gl.boardService = new BoardService({
- boardsEndpoint: this.boardsEndpoint,
- listsEndpoint: this.listsEndpoint,
- bulkUpdatePath: this.bulkUpdatePath,
- boardId: this.boardId,
- });
- Store.rootPath = this.boardsEndpoint;
-
- eventHub.$on('updateTokens', this.updateTokens);
- eventHub.$on('newDetailIssue', this.updateDetailIssue);
- eventHub.$on('clearDetailIssue', this.clearDetailIssue);
- sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
- },
- beforeDestroy() {
- eventHub.$off('updateTokens', this.updateTokens);
- eventHub.$off('newDetailIssue', this.updateDetailIssue);
- eventHub.$off('clearDetailIssue', this.clearDetailIssue);
- sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
- },
- mounted () {
- this.filterManager = new FilteredSearchBoards(Store.filter, true);
- this.filterManager.setup();
-
- Store.disabled = this.disabled;
- gl.boardService.all()
- .then(response => response.json())
- .then((resp) => {
- 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;
- }
- });
-
- this.state.lists = _.sortBy(this.state.lists, 'position');
-
- Store.addBlankState();
- this.loading = false;
- })
- .catch(() => new Flash('An error occurred. Please try again.'));
- },
- methods: {
- updateTokens() {
- this.filterManager.updateTokens();
- },
- updateDetailIssue(newIssue) {
- const sidebarInfoEndpoint = newIssue.sidebarInfoEndpoint;
- if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
- newIssue.setFetchingState('subscriptions', true);
- BoardService.getIssueInfo(sidebarInfoEndpoint)
- .then(res => res.json())
- .then((data) => {
- newIssue.setFetchingState('subscriptions', false);
- newIssue.updateData({
- subscribed: data.subscribed,
- });
- })
- .catch(() => {
- newIssue.setFetchingState('subscriptions', false);
- Flash(__('An error occurred while fetching sidebar data'));
- });
- }
-
- Store.detail.issue = newIssue;
- },
- clearDetailIssue() {
- Store.detail.issue = {};
- },
- toggleSubscription(id) {
- const issue = Store.detail.issue;
- if (issue.id === id && issue.toggleSubscriptionEndpoint) {
- issue.setFetchingState('subscriptions', true);
- BoardService.toggleIssueSubscription(issue.toggleSubscriptionEndpoint)
- .then(() => {
- issue.setFetchingState('subscriptions', false);
- issue.updateData({
- subscribed: !issue.subscribed,
- });
- })
- .catch(() => {
- issue.setFetchingState('subscriptions', false);
- Flash(__('An error occurred when toggling the notification subscription'));
- });
- }
- }
- },
- });
-
- gl.IssueBoardsSearch = new Vue({
- el: document.getElementById('js-add-list'),
- data: {
- filters: Store.state.filters,
- },
- mounted () {
- gl.issueBoards.newListDropdownInit();
- },
- });
-
- gl.IssueBoardsModalAddBtn = new Vue({
- mixins: [gl.issueBoards.ModalMixins],
- el: document.getElementById('js-add-issues-btn'),
- data() {
- return {
- modal: ModalStore.store,
- store: Store.state,
- };
- },
- watch: {
- disabled() {
- this.updateTooltip();
- },
- },
- computed: {
- disabled() {
- if (!this.store) {
- return true;
- }
- return !this.store.lists.filter(list => !list.preset).length;
- },
- tooltipTitle() {
- if (this.disabled) {
- return 'Please add a list to your board first';
- }
-
- return '';
- },
- },
- methods: {
- updateTooltip() {
- const $tooltip = $(this.$refs.addIssuesButton);
-
- this.$nextTick(() => {
- if (this.disabled) {
- $tooltip.tooltip();
- } else {
- $tooltip.tooltip('destroy');
- }
- });
- },
- openModal() {
- if (!this.disabled) {
- this.toggleModal(true);
- }
- },
- },
- mounted() {
- this.updateTooltip();
- },
- template: `
- <div class="board-extra-actions">
- <button
- class="btn btn-create prepend-left-10"
- type="button"
- data-placement="bottom"
- ref="addIssuesButton"
- :class="{ 'disabled': disabled }"
- :title="tooltipTitle"
- :aria-disabled="disabled"
- @click="openModal">
- Add issues
- </button>
- </div>
- `,
- });
-});
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index adb7360327c..bea818010a4 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,9 +1,11 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
-/* global Sortable */
+
+import $ from 'jquery';
+import Sortable from 'vendor/Sortable';
import Vue from 'vue';
import AccessorUtilities from '../../lib/utils/accessor';
-import boardList from './board_list';
-import boardBlankState from './board_blank_state';
+import boardList from './board_list.vue';
+import BoardBlankState from './board_blank_state.vue';
import './board_delete';
const Store = gl.issueBoards.BoardsStore;
@@ -16,7 +18,7 @@ gl.issueBoards.Board = Vue.extend({
components: {
boardList,
'board-delete': gl.issueBoards.BoardDelete,
- boardBlankState,
+ BoardBlankState,
},
props: {
list: Object,
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js
deleted file mode 100644
index edfe7c326db..00000000000
--- a/app/assets/javascripts/boards/components/board_blank_state.js
+++ /dev/null
@@ -1,91 +0,0 @@
-/* global ListLabel */
-
-import _ from 'underscore';
-import Cookies from 'js-cookie';
-
-const Store = gl.issueBoards.BoardsStore;
-
-export default {
- template: `
- <div class="board-blank-state">
- <p>
- Add the following default lists to your Issue Board with one click:
- </p>
- <ul class="board-blank-state-list">
- <li v-for="label in predefinedLabels">
- <span
- class="label-color"
- :style="{ backgroundColor: label.color }">
- </span>
- {{ label.title }}
- </li>
- </ul>
- <p>
- Starting out with the default set of lists will get you right on the way to making the most of your board.
- </p>
- <button
- class="btn btn-create btn-inverted btn-block"
- type="button"
- @click.stop="addDefaultLists">
- Add default lists
- </button>
- <button
- class="btn btn-default btn-block"
- type="button"
- @click.stop="clearBlankState">
- Nevermind, I'll use my own
- </button>
- </div>
- `,
- data() {
- return {
- predefinedLabels: [
- new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
- new ListLabel({ title: 'Doing', color: '#5CB85C' }),
- ],
- };
- },
- methods: {
- addDefaultLists() {
- this.clearBlankState();
-
- this.predefinedLabels.forEach((label, i) => {
- Store.addList({
- title: label.title,
- position: i,
- list_type: 'label',
- label: {
- title: label.title,
- color: label.color,
- },
- });
- });
-
- Store.state.lists = _.sortBy(Store.state.lists, 'position');
-
- // Save the labels
- gl.boardService.generateDefaultLists()
- .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()
- .catch(() => {
- // TODO: handle request error
- });
- });
- })
- .catch(() => {
- Store.removeList(undefined, 'label');
- Cookies.remove('issue_board_welcome_hidden', {
- path: '',
- });
- Store.addBlankState();
- });
- },
- clearBlankState: Store.removeBlankState.bind(Store),
- },
-};
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
new file mode 100644
index 00000000000..2049eeb9c30
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -0,0 +1,98 @@
+<script>
+/* global ListLabel */
+import _ from 'underscore';
+import Cookies from 'js-cookie';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+ data() {
+ return {
+ predefinedLabels: [
+ new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
+ new ListLabel({ title: 'Doing', color: '#5CB85C' }),
+ ],
+ };
+ },
+ methods: {
+ addDefaultLists() {
+ this.clearBlankState();
+
+ this.predefinedLabels.forEach((label, i) => {
+ Store.addList({
+ title: label.title,
+ position: i,
+ list_type: 'label',
+ label: {
+ title: label.title,
+ color: label.color,
+ },
+ });
+ });
+
+ Store.state.lists = _.sortBy(Store.state.lists, 'position');
+
+ // Save the labels
+ gl.boardService.generateDefaultLists()
+ .then(res => res.data)
+ .then((data) => {
+ data.forEach((listObj) => {
+ const list = Store.findList('title', listObj.title);
+
+ list.id = listObj.id;
+ list.label.id = listObj.label.id;
+ list.getIssues()
+ .catch(() => {
+ // TODO: handle request error
+ });
+ });
+ })
+ .catch(() => {
+ Store.removeList(undefined, 'label');
+ Cookies.remove('issue_board_welcome_hidden', {
+ path: '',
+ });
+ Store.addBlankState();
+ });
+ },
+ clearBlankState: Store.removeBlankState.bind(Store),
+ },
+};
+
+</script>
+
+<template>
+ <div class="board-blank-state">
+ <p>
+ Add the following default lists to your Issue Board with one click:
+ </p>
+ <ul class="board-blank-state-list">
+ <li
+ v-for="(label, index) in predefinedLabels"
+ :key="index"
+ >
+ <span
+ class="label-color"
+ :style="{ backgroundColor: label.color }">
+ </span>
+ {{ label.title }}
+ </li>
+ </ul>
+ <p>
+ Starting out with the default set of lists will get you
+ right on the way to making the most of your board.
+ </p>
+ <button
+ class="btn btn-create btn-inverted btn-block"
+ type="button"
+ @click.stop="addDefaultLists">
+ Add default lists
+ </button>
+ <button
+ class="btn btn-default btn-block"
+ type="button"
+ @click.stop="clearBlankState">
+ Nevermind, I'll use my own
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 0b220a56e0b..84885ca9306 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable vue/require-default-prop */
import './issue_card_inner';
import eventHub from '../eventhub';
@@ -10,12 +11,33 @@ export default {
'issue-card-inner': gl.issueBoards.IssueCardInner,
},
props: {
- list: Object,
- issue: Object,
- issueLinkBase: String,
- disabled: Boolean,
- index: Number,
- rootPath: String,
+ list: {
+ type: Object,
+ default: () => ({}),
+ },
+ issue: {
+ type: Object,
+ default: () => ({}),
+ },
+ issueLinkBase: {
+ type: String,
+ default: '',
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ },
+ index: {
+ type: Number,
+ default: 0,
+ },
+ rootPath: {
+ type: String,
+ default: '',
+ },
+ groupId: {
+ type: Number,
+ },
},
data() {
return {
@@ -54,8 +76,13 @@ export default {
</script>
<template>
- <li class="card"
- :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
+ <li
+ class="card"
+ :class="{
+ 'user-can-drag': !disabled && issue.id,
+ 'is-disabled': disabled || !issue.id,
+ 'is-active': issueDetailVisible
+ }"
:index="index"
:data-issue-id="issue.id"
@mousedown="mouseDown"
@@ -65,7 +92,9 @@ export default {
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
+ :group-id="groupId"
:root-path="rootPath"
- :update-filters="true" />
+ :update-filters="true"
+ />
</li>
</template>
diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js
index 8a1b177bba8..7be98825fda 100644
--- a/app/assets/javascripts/boards/components/board_delete.js
+++ b/app/assets/javascripts/boards/components/board_delete.js
@@ -1,5 +1,6 @@
/* eslint-disable comma-dangle, space-before-function-paren, no-alert */
+import $ from 'jquery';
import Vue from 'vue';
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js
deleted file mode 100644
index 29aeb8e84aa..00000000000
--- a/app/assets/javascripts/boards/components/board_list.js
+++ /dev/null
@@ -1,207 +0,0 @@
-/* global Sortable */
-import boardNewIssue from './board_new_issue';
-import boardCard from './board_card.vue';
-import eventHub from '../eventhub';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-
-const Store = gl.issueBoards.BoardsStore;
-
-export default {
- name: 'BoardList',
- props: {
- disabled: {
- type: Boolean,
- required: true,
- },
- list: {
- type: Object,
- required: true,
- },
- issues: {
- type: Array,
- required: true,
- },
- loading: {
- type: Boolean,
- required: true,
- },
- issueLinkBase: {
- type: String,
- required: true,
- },
- rootPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- scrollOffset: 250,
- filters: Store.state.filters,
- showCount: false,
- showIssueForm: false,
- };
- },
- components: {
- boardCard,
- boardNewIssue,
- loadingIcon,
- },
- methods: {
- listHeight() {
- return this.$refs.list.getBoundingClientRect().height;
- },
- scrollHeight() {
- return this.$refs.list.scrollHeight;
- },
- scrollTop() {
- return this.$refs.list.scrollTop + this.listHeight();
- },
- scrollToTop() {
- this.$refs.list.scrollTop = 0;
- },
- loadNextPage() {
- const getIssues = this.list.nextPage();
- const loadingDone = () => {
- this.list.loadingMore = false;
- };
-
- if (getIssues) {
- this.list.loadingMore = true;
- getIssues
- .then(loadingDone)
- .catch(loadingDone);
- }
- },
- toggleForm() {
- this.showIssueForm = !this.showIssueForm;
- },
- onScroll() {
- if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
- this.loadNextPage();
- }
- },
- },
- watch: {
- filters: {
- handler() {
- this.list.loadingMore = false;
- this.$refs.list.scrollTop = 0;
- },
- deep: true,
- },
- issues() {
- this.$nextTick(() => {
- if (this.scrollHeight() <= this.listHeight() &&
- this.list.issuesSize > this.list.issues.length) {
- this.list.page += 1;
- this.list.getIssues(false)
- .catch(() => {
- // TODO: handle request error
- });
- }
-
- if (this.scrollHeight() > Math.ceil(this.listHeight())) {
- this.showCount = true;
- } else {
- this.showCount = false;
- }
- });
- },
- },
- 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({
- scroll: document.querySelectorAll('.boards-list')[0],
- group: 'issues',
- disabled: this.disabled,
- filter: '.board-list-count, .is-disabled',
- dataIdAttr: 'data-issue-id',
- onStart: (e) => {
- const card = this.$refs.issue[e.oldIndex];
-
- card.showDetail = false;
- Store.moving.list = card.list;
- Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId);
-
- gl.issueBoards.onStart();
- },
- onAdd: (e) => {
- gl.issueBoards.BoardsStore
- .moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex);
-
- this.$nextTick(() => {
- e.item.remove();
- });
- },
- onUpdate: (e) => {
- const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
- gl.issueBoards.BoardsStore
- .moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
- },
- onMove(e) {
- return !e.related.classList.contains('board-list-count');
- },
- });
-
- this.sortable = Sortable.create(this.$refs.list, options);
-
- // Scroll event on list to load more
- this.$refs.list.addEventListener('scroll', this.onScroll);
- },
- 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: `
- <div class="board-list-component">
- <div
- class="board-list-loading text-center"
- aria-label="Loading issues"
- v-if="loading">
- <loading-icon />
- </div>
- <board-new-issue
- :list="list"
- v-if="list.type !== 'closed' && showIssueForm"/>
- <ul
- class="board-list"
- v-show="!loading"
- ref="list"
- :data-board="list.id"
- :class="{ 'is-smaller': showIssueForm }">
- <board-card
- v-for="(issue, index) in issues"
- ref="issue"
- :index="index"
- :list="list"
- :issue="issue"
- :issue-link-base="issueLinkBase"
- :root-path="rootPath"
- :disabled="disabled"
- :key="issue.id" />
- <li
- class="board-list-count text-center"
- v-if="showCount"
- data-id="-1">
-
- <loading-icon
- v-show="list.loadingMore"
- label="Loading more issues"
- />
-
- <span v-if="list.issues.length === list.issuesSize">
- Showing all issues
- </span>
- <span v-else>
- Showing {{ list.issues.length }} of {{ list.issuesSize }} issues
- </span>
- </li>
- </ul>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
new file mode 100644
index 00000000000..0d03c1c419c
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -0,0 +1,219 @@
+<script>
+import Sortable from 'vendor/Sortable';
+import boardNewIssue from './board_new_issue.vue';
+import boardCard from './board_card.vue';
+import eventHub from '../eventhub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+ name: 'BoardList',
+ components: {
+ boardCard,
+ boardNewIssue,
+ loadingIcon,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ list: {
+ type: Object,
+ required: true,
+ },
+ issues: {
+ type: Array,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ issueLinkBase: {
+ type: String,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ scrollOffset: 250,
+ filters: Store.state.filters,
+ showCount: false,
+ showIssueForm: false,
+ };
+ },
+ watch: {
+ filters: {
+ handler() {
+ this.list.loadingMore = false;
+ this.$refs.list.scrollTop = 0;
+ },
+ deep: true,
+ },
+ issues() {
+ this.$nextTick(() => {
+ if (this.scrollHeight() <= this.listHeight() &&
+ this.list.issuesSize > this.list.issues.length) {
+ this.list.page += 1;
+ this.list.getIssues(false)
+ .catch(() => {
+ // TODO: handle request error
+ });
+ }
+
+ if (this.scrollHeight() > Math.ceil(this.listHeight())) {
+ this.showCount = true;
+ } else {
+ this.showCount = false;
+ }
+ });
+ },
+ },
+ 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({
+ scroll: true,
+ group: 'issues',
+ disabled: this.disabled,
+ filter: '.board-list-count, .is-disabled',
+ dataIdAttr: 'data-issue-id',
+ onStart: (e) => {
+ const card = this.$refs.issue[e.oldIndex];
+
+ card.showDetail = false;
+ Store.moving.list = card.list;
+ Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId);
+
+ gl.issueBoards.onStart();
+ },
+ onAdd: (e) => {
+ gl.issueBoards.BoardsStore
+ .moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex);
+
+ this.$nextTick(() => {
+ e.item.remove();
+ });
+ },
+ onUpdate: (e) => {
+ const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
+ gl.issueBoards.BoardsStore
+ .moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray);
+ },
+ onMove(e) {
+ return !e.related.classList.contains('board-list-count');
+ },
+ });
+
+ this.sortable = Sortable.create(this.$refs.list, options);
+
+ // Scroll event on list to load more
+ this.$refs.list.addEventListener('scroll', this.onScroll);
+ },
+ 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);
+ },
+ methods: {
+ listHeight() {
+ return this.$refs.list.getBoundingClientRect().height;
+ },
+ scrollHeight() {
+ return this.$refs.list.scrollHeight;
+ },
+ scrollTop() {
+ return this.$refs.list.scrollTop + this.listHeight();
+ },
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
+ loadNextPage() {
+ const getIssues = this.list.nextPage();
+ const loadingDone = () => {
+ this.list.loadingMore = false;
+ };
+
+ if (getIssues) {
+ this.list.loadingMore = true;
+ getIssues
+ .then(loadingDone)
+ .catch(loadingDone);
+ }
+ },
+ toggleForm() {
+ this.showIssueForm = !this.showIssueForm;
+ },
+ onScroll() {
+ if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
+ this.loadNextPage();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="board-list-component">
+ <div
+ class="board-list-loading text-center"
+ aria-label="Loading issues"
+ v-if="loading">
+ <loading-icon />
+ </div>
+ <board-new-issue
+ :group-id="groupId"
+ :list="list"
+ v-if="list.type !== 'closed' && showIssueForm"/>
+ <ul
+ class="board-list"
+ v-show="!loading"
+ ref="list"
+ :data-board="list.id"
+ :class="{ 'is-smaller': showIssueForm }">
+ <board-card
+ v-for="(issue, index) in issues"
+ ref="issue"
+ :index="index"
+ :list="list"
+ :issue="issue"
+ :issue-link-base="issueLinkBase"
+ :group-id="groupId"
+ :root-path="rootPath"
+ :disabled="disabled"
+ :key="issue.id" />
+ <li
+ class="board-list-count text-center"
+ v-if="showCount"
+ data-issue-id="-1">
+ <loading-icon
+ v-show="list.loadingMore"
+ label="Loading more issues"
+ />
+ <span
+ v-if="list.issues.length === list.issuesSize"
+ >
+ Showing all issues
+ </span>
+ <span
+ v-else
+ >
+ Showing {{ list.issues.length }} of {{ list.issuesSize }} issues
+ </span>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
deleted file mode 100644
index bc28f7f45f4..00000000000
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/* global ListIssue */
-import eventHub from '../eventhub';
-
-const Store = gl.issueBoards.BoardsStore;
-
-export default {
- name: 'BoardNewIssue',
- props: {
- list: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- title: '',
- error: false,
- };
- },
- methods: {
- submit(e) {
- e.preventDefault();
- if (this.title.trim() === '') return Promise.resolve();
-
- this.error = false;
-
- const labels = this.list.label ? [this.list.label] : [];
- const issue = new ListIssue({
- title: this.title,
- labels,
- subscribed: true,
- assignees: [],
- });
-
- 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();
-
- Store.detail.issue = issue;
- Store.detail.list = this.list;
- })
- .catch(() => {
- // Need this because our jQuery very kindly disables buttons on ALL form submissions
- $(this.$refs.submitButton).enable();
-
- // Remove the issue
- this.list.removeIssue(issue);
-
- // Show error message
- this.error = true;
- });
- },
- cancel() {
- this.title = '';
- eventHub.$emit(`hide-issue-form-${this.list.id}`);
- },
- },
- mounted() {
- this.$refs.input.focus();
- },
- template: `
- <div class="card board-new-issue-form">
- <form @submit="submit($event)">
- <div class="flash-container"
- v-if="error">
- <div class="flash-alert">
- An error occurred. Please try again.
- </div>
- </div>
- <label class="label-light"
- :for="list.id + '-title'">
- Title
- </label>
- <input class="form-control"
- 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"
- type="submit"
- :disabled="title === ''"
- ref="submit-button">
- Submit issue
- </button>
- <button class="btn btn-default pull-right"
- type="button"
- @click="cancel">
- Cancel
- </button>
- </div>
- </form>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
new file mode 100644
index 00000000000..8d84c1735b8
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -0,0 +1,144 @@
+<script>
+import $ from 'jquery';
+import eventHub from '../eventhub';
+import ProjectSelect from './project_select.vue';
+import ListIssue from '../models/issue';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+ name: 'BoardNewIssue',
+ components: {
+ ProjectSelect,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ list: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ title: '',
+ error: false,
+ selectedProject: {},
+ };
+ },
+ computed: {
+ disabled() {
+ if (this.groupId) {
+ return this.title === '' || !this.selectedProject.name;
+ }
+ return this.title === '';
+ },
+ },
+ mounted() {
+ this.$refs.input.focus();
+ eventHub.$on('setSelectedProject', this.setSelectedProject);
+ },
+ methods: {
+ submit(e) {
+ e.preventDefault();
+ if (this.title.trim() === '') return Promise.resolve();
+
+ this.error = false;
+
+ const labels = this.list.label ? [this.list.label] : [];
+ const issue = new ListIssue({
+ title: this.title,
+ labels,
+ subscribed: true,
+ assignees: [],
+ project_id: this.selectedProject.id,
+ });
+
+ 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();
+
+ Store.detail.issue = issue;
+ Store.detail.list = this.list;
+ })
+ .catch(() => {
+ // Need this because our jQuery very kindly disables buttons on ALL form submissions
+ $(this.$refs.submitButton).enable();
+
+ // Remove the issue
+ this.list.removeIssue(issue);
+
+ // Show error message
+ this.error = true;
+ });
+ },
+ cancel() {
+ this.title = '';
+ eventHub.$emit(`hide-issue-form-${this.list.id}`);
+ },
+ setSelectedProject(selectedProject) {
+ this.selectedProject = selectedProject;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="board-new-issue-form">
+ <div class="card">
+ <form @submit="submit($event)">
+ <div
+ class="flash-container"
+ v-if="error"
+ >
+ <div class="flash-alert">
+ An error occurred. Please try again.
+ </div>
+ </div>
+ <label
+ class="label-light"
+ :for="list.id + '-title'"
+ >
+ Title
+ </label>
+ <input
+ class="form-control"
+ type="text"
+ v-model="title"
+ ref="input"
+ autocomplete="off"
+ :id="list.id + '-title'"
+ />
+ <project-select
+ v-if="groupId"
+ :group-id="groupId"
+ />
+ <div class="clearfix prepend-top-10">
+ <button
+ class="btn btn-success pull-left"
+ type="submit"
+ :disabled="disabled"
+ ref="submit-button"
+ >
+ Submit issue
+ </button>
+ <button
+ class="btn btn-default pull-right"
+ type="button"
+ @click="cancel"
+ >
+ Cancel
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+</template>
+
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 616de2347e1..c4ee4f6c855 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -1,17 +1,19 @@
/* eslint-disable comma-dangle, space-before-function-paren, no-new */
-/* global MilestoneSelect */
+import $ from 'jquery';
import Vue from 'vue';
import Flash from '../../flash';
+import { __ } from '../../locale';
import Sidebar from '../../right_sidebar';
import eventHub from '../../sidebar/event_hub';
-import assigneeTitle from '../../sidebar/components/assignees/assignee_title';
-import assignees from '../../sidebar/components/assignees/assignees';
+import assigneeTitle from '../../sidebar/components/assignees/assignee_title.vue';
+import assignees from '../../sidebar/components/assignees/assignees.vue';
import DueDateSelectors from '../../due_date_select';
import './sidebar/remove_issue';
import IssuableContext from '../../issuable_context';
import LabelsSelect from '../../labels_select';
import subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue';
+import MilestoneSelect from '../../milestone_select';
const Store = gl.issueBoards.BoardsStore;
@@ -58,10 +60,6 @@ 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
},
@@ -89,13 +87,13 @@ gl.issueBoards.BoardSidebar = Vue.extend({
saveAssignees () {
this.loadingAssignees = true;
- gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
+ gl.issueBoards.BoardsStore.detail.issue.update()
.then(() => {
this.loadingAssignees = false;
})
.catch(() => {
this.loadingAssignees = false;
- return new Flash('An error occurred while saving assignees');
+ Flash(__('An error occurred while saving assignees'));
});
},
},
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index bf474879024..84fe9b1288a 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -1,5 +1,6 @@
+import $ from 'jquery';
import Vue from 'vue';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
@@ -31,6 +32,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({
required: false,
default: false,
},
+ groupId: {
+ type: Number,
+ required: false,
+ },
},
data() {
return {
@@ -40,7 +45,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
};
},
components: {
- userAvatarLink,
+ UserAvatarLink,
},
computed: {
numberOverLimit() {
@@ -63,9 +68,6 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
- cardUrl() {
- return `${this.issueLinkBase}/${this.issue.iid}`;
- },
issueId() {
if (this.issue.iid) {
return `#${this.issue.iid}`;
@@ -142,13 +144,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({
/>
<a
class="js-no-trigger"
- :href="cardUrl"
+ :href="issue.path"
:title="issue.title">{{ issue.title }}</a>
<span
class="card-number"
v-if="issueId"
>
- {{ issueId }}
+ {{ issue.referencePath }}
</span>
</h4>
<div class="card-assignee">
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js
index e571b11a83d..9e37f95cdd6 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.js
+++ b/app/assets/javascripts/boards/components/modal/empty_state.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalEmptyState = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
data() {
return ModalStore.store;
},
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index 182957113a2..9735e0ddacc 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -1,14 +1,13 @@
-/* eslint-disable no-new */
-
import Vue from 'vue';
import Flash from '../../../flash';
+import { __ } from '../../../locale';
import './lists_dropdown';
import { pluralize } from '../../../lib/utils/text_utility';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalFooter = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
data() {
return {
modal: ModalStore.store,
@@ -36,7 +35,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
gl.boardService.bulkUpdate(issueIds, {
add_label_ids: [list.label.id],
}).catch(() => {
- new Flash('Failed to update issues, please try again.', 'alert');
+ Flash(__('Failed to update issues, please try again.'));
selectedIssues.forEach((issue) => {
list.removeIssue(issue);
diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js
index 31f59d295bf..67c29ebca72 100644
--- a/app/assets/javascripts/boards/components/modal/header.js
+++ b/app/assets/javascripts/boards/components/modal/header.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
import modalFilters from './filters';
import './tabs';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalHeader = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
props: {
projectId: {
type: Number,
diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js
index d2044f20ebe..3083b3e4405 100644
--- a/app/assets/javascripts/boards/components/modal/index.js
+++ b/app/assets/javascripts/boards/components/modal/index.js
@@ -7,8 +7,7 @@ import './header';
import './list';
import './footer';
import './empty_state';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
gl.issueBoards.IssuesModal = Vue.extend({
props: {
@@ -89,7 +88,7 @@ gl.issueBoards.IssuesModal = Vue.extend({
page: this.page,
per: this.perPage,
}))
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
if (clearIssues) {
this.issues = [];
diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js
index 7c62134b3a3..6b04a6c7a6c 100644
--- a/app/assets/javascripts/boards/components/modal/list.js
+++ b/app/assets/javascripts/boards/components/modal/list.js
@@ -2,8 +2,7 @@
import Vue from 'vue';
import bp from '../../../breakpoints';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
gl.issueBoards.ModalList = Vue.extend({
props: {
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
index 4684ea76647..e644de2d4fc 100644
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js
+++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
gl.issueBoards.ModalFooterListsDropdown = Vue.extend({
data() {
diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js
index 3e5d08e3d75..b6465a88e5e 100644
--- a/app/assets/javascripts/boards/components/modal/tabs.js
+++ b/app/assets/javascripts/boards/components/modal/tabs.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
-
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../../stores/modal_store';
+import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalTabs = Vue.extend({
- mixins: [gl.issueBoards.ModalMixins],
+ mixins: [modalMixin],
data() {
return ModalStore.store;
},
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index c19c989680d..71f49319c36 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -1,5 +1,7 @@
-/* eslint-disable func-names, no-new, space-before-function-paren, one-var,
- promise/catch-or-return */
+/* eslint-disable func-names, no-new, space-before-function-paren, one-var, promise/catch-or-return, max-len */
+
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
import _ from 'underscore';
import CreateLabelDropdown from '../../create_label';
@@ -24,13 +26,13 @@ $(document).off('created.label').on('created.label', (e, label) => {
gl.issueBoards.newListDropdownInit = () => {
$('.js-new-board-list').each(function () {
const $this = $(this);
- new CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path'));
+ new CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespacePath'), $this.data('projectPath'));
$this.glDropdown({
data(term, callback) {
- $.get($this.attr('data-list-labels-path'))
- .then((resp) => {
- callback(resp);
+ axios.get($this.attr('data-list-labels-path'))
+ .then(({ data }) => {
+ callback(data);
});
},
renderRow (label) {
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
new file mode 100644
index 00000000000..371774098b9
--- /dev/null
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -0,0 +1,129 @@
+<script>
+ /* global ListIssue */
+
+ import $ from 'jquery';
+ import _ from 'underscore';
+ import eventHub from '../eventhub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import Api from '../../api';
+
+ export default {
+ name: 'BoardProjectSelect',
+ components: {
+ loadingIcon,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: true,
+ default: 0,
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ selectedProject: {},
+ };
+ },
+ computed: {
+ selectedProjectName() {
+ return this.selectedProject.name || 'Select a project';
+ },
+ },
+ mounted() {
+ $(this.$refs.projectsDropdown).glDropdown({
+ filterable: true,
+ filterRemote: true,
+ search: {
+ fields: ['name_with_namespace'],
+ },
+ clicked: ({ $el, e }) => {
+ e.preventDefault();
+ this.selectedProject = {
+ id: $el.data('project-id'),
+ name: $el.data('project-name'),
+ };
+ eventHub.$emit('setSelectedProject', this.selectedProject);
+ },
+ selectable: true,
+ data: (term, callback) => {
+ this.loading = true;
+ return Api.groupProjects(this.groupId, term, (projects) => {
+ this.loading = false;
+ callback(projects);
+ });
+ },
+ renderRow(project) {
+ return `
+ <li>
+ <a href='#' class='dropdown-menu-link' data-project-id="${project.id}" data-project-name="${project.name}">
+ ${_.escape(project.name)}
+ </a>
+ </li>
+ `;
+ },
+ text: project => project.name,
+ });
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <label class="label-light prepend-top-10">
+ Project
+ </label>
+ <div
+ ref="projectsDropdown"
+ class="dropdown"
+ >
+ <button
+ class="dropdown-menu-toggle wide"
+ type="button"
+ data-toggle="dropdown"
+ aria-expanded="false"
+ >
+ {{ selectedProjectName }}
+ <i
+ class="fa fa-chevron-down"
+ aria-hidden="true"
+ >
+ </i>
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
+ <div class="dropdown-title">
+ <span>Projects</span>
+ <button
+ aria-label="Close"
+ type="button"
+ class="dropdown-title-button dropdown-menu-close"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon"
+ >
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input
+ class="dropdown-input-field"
+ type="search"
+ placeholder="Search projects"
+ />
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-search dropdown-input-search"
+ >
+ </i>
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-loading">
+ <loading-icon />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
index 1ad97211934..0a0820ec5fd 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js
@@ -1,7 +1,6 @@
-/* eslint-disable no-new */
-
import Vue from 'vue';
import Flash from '../../../flash';
+import { __ } from '../../../locale';
const Store = gl.issueBoards.BoardsStore;
@@ -18,14 +17,10 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object,
required: true,
},
- issueUpdate: {
- type: String,
- required: true,
- },
},
computed: {
updateUrl() {
- return this.issueUpdate;
+ return this.issue.path;
},
},
methods: {
@@ -33,19 +28,23 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
const issue = this.issue;
const lists = issue.getLists();
const listLabelIds = lists.map(list => list.label.id);
- let labelIds = this.issue.labels
+
+ let labelIds = issue.labels
.map(label => label.id)
.filter(id => !listLabelIds.includes(id));
if (labelIds.length === 0) {
labelIds = [''];
}
+
const data = {
issue: {
label_ids: labelIds,
},
};
+
+ // Post the remove data
Vue.http.patch(this.updateUrl, data).catch(() => {
- new Flash('Failed to remove issue from board, please try again.', 'alert');
+ Flash(__('Failed to remove issue from board, please try again.'));
lists.forEach((list) => {
list.addIssue(issue);
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 184665f395c..70367c4f711 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -1,9 +1,14 @@
/* eslint-disable class-methods-use-this */
import FilteredSearchContainer from '../filtered_search/container';
+import FilteredSearchManager from '../filtered_search/filtered_search_manager';
-export default class FilteredSearchBoards extends gl.FilteredSearchManager {
+export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
- super('boards');
+ super({
+ page: 'boards',
+ isGroupDecendent: true,
+ stateFiltersSelector: '.issues-state-filters',
+ });
this.store = store;
this.updateUrl = updateUrl;
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
new file mode 100644
index 00000000000..a6f8681cfac
--- /dev/null
+++ b/app/assets/javascripts/boards/index.js
@@ -0,0 +1,244 @@
+/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import Vue from 'vue';
+
+import Flash from '~/flash';
+import { __ } from '~/locale';
+import '~/vue_shared/models/label';
+
+import FilteredSearchBoards from './filtered_search_boards';
+import eventHub from './eventhub';
+import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import/first
+import './models/issue';
+import './models/list';
+import './models/milestone';
+import './models/project';
+import './models/assignee';
+import './stores/boards_store';
+import ModalStore from './stores/modal_store';
+import BoardService from './services/board_service';
+import modalMixin from './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'; // eslint-disable-line import/first
+
+export default () => {
+ const $boardApp = document.getElementById('board-app');
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+
+ if (gl.IssueBoardsApp) {
+ gl.IssueBoardsApp.$destroy(true);
+ }
+
+ Store.create();
+
+ // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore
+ gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args);
+ gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args);
+
+ gl.IssueBoardsApp = new Vue({
+ el: $boardApp,
+ components: {
+ 'board': gl.issueBoards.Board,
+ 'board-sidebar': gl.issueBoards.BoardSidebar,
+ 'board-add-issues-modal': gl.issueBoards.IssuesModal,
+ },
+ data: {
+ state: Store.state,
+ loading: true,
+ boardsEndpoint: $boardApp.dataset.boardsEndpoint,
+ listsEndpoint: $boardApp.dataset.listsEndpoint,
+ boardId: $boardApp.dataset.boardId,
+ disabled: $boardApp.dataset.disabled === 'true',
+ issueLinkBase: $boardApp.dataset.issueLinkBase,
+ rootPath: $boardApp.dataset.rootPath,
+ bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
+ detailIssue: Store.detail,
+ defaultAvatar: $boardApp.dataset.defaultAvatar,
+ },
+ computed: {
+ detailIssueVisible () {
+ return Object.keys(this.detailIssue.issue).length;
+ },
+ },
+ created () {
+ gl.boardService = new BoardService({
+ boardsEndpoint: this.boardsEndpoint,
+ listsEndpoint: this.listsEndpoint,
+ bulkUpdatePath: this.bulkUpdatePath,
+ boardId: this.boardId,
+ });
+ Store.rootPath = this.boardsEndpoint;
+
+ eventHub.$on('updateTokens', this.updateTokens);
+ eventHub.$on('newDetailIssue', this.updateDetailIssue);
+ eventHub.$on('clearDetailIssue', this.clearDetailIssue);
+ sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
+ },
+ beforeDestroy() {
+ eventHub.$off('updateTokens', this.updateTokens);
+ eventHub.$off('newDetailIssue', this.updateDetailIssue);
+ eventHub.$off('clearDetailIssue', this.clearDetailIssue);
+ sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
+ },
+ mounted () {
+ this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
+ this.filterManager.setup();
+
+ Store.disabled = this.disabled;
+ gl.boardService.all()
+ .then(res => res.data)
+ .then((data) => {
+ data.forEach((board) => {
+ const list = Store.addList(board, this.defaultAvatar);
+
+ if (list.type === 'closed') {
+ list.position = Infinity;
+ } else if (list.type === 'backlog') {
+ list.position = -1;
+ }
+ });
+
+ this.state.lists = _.sortBy(this.state.lists, 'position');
+
+ Store.addBlankState();
+ this.loading = false;
+ })
+ .catch(() => {
+ Flash('An error occurred while fetching the board lists. Please try again.');
+ });
+ },
+ methods: {
+ updateTokens() {
+ this.filterManager.updateTokens();
+ },
+ updateDetailIssue(newIssue) {
+ const sidebarInfoEndpoint = newIssue.sidebarInfoEndpoint;
+ if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
+ newIssue.setFetchingState('subscriptions', true);
+ BoardService.getIssueInfo(sidebarInfoEndpoint)
+ .then(res => res.data)
+ .then((data) => {
+ newIssue.setFetchingState('subscriptions', false);
+ newIssue.updateData({
+ subscribed: data.subscribed,
+ });
+ })
+ .catch(() => {
+ newIssue.setFetchingState('subscriptions', false);
+ Flash(__('An error occurred while fetching sidebar data'));
+ });
+ }
+
+ Store.detail.issue = newIssue;
+ },
+ clearDetailIssue() {
+ Store.detail.issue = {};
+ },
+ toggleSubscription(id) {
+ const issue = Store.detail.issue;
+ if (issue.id === id && issue.toggleSubscriptionEndpoint) {
+ issue.setFetchingState('subscriptions', true);
+ BoardService.toggleIssueSubscription(issue.toggleSubscriptionEndpoint)
+ .then(() => {
+ issue.setFetchingState('subscriptions', false);
+ issue.updateData({
+ subscribed: !issue.subscribed,
+ });
+ })
+ .catch(() => {
+ issue.setFetchingState('subscriptions', false);
+ Flash(__('An error occurred when toggling the notification subscription'));
+ });
+ }
+ }
+ },
+ });
+
+ gl.IssueBoardsSearch = new Vue({
+ el: document.getElementById('js-add-list'),
+ data: {
+ filters: Store.state.filters,
+ },
+ mounted () {
+ gl.issueBoards.newListDropdownInit();
+ },
+ });
+
+ gl.IssueBoardsModalAddBtn = new Vue({
+ el: document.getElementById('js-add-issues-btn'),
+ mixins: [modalMixin],
+ data() {
+ return {
+ modal: ModalStore.store,
+ store: Store.state,
+ canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
+ };
+ },
+ computed: {
+ disabled() {
+ if (!this.store) {
+ return true;
+ }
+ return !this.store.lists.filter(list => !list.preset).length;
+ },
+ tooltipTitle() {
+ if (this.disabled) {
+ return 'Please add a list to your board first';
+ }
+
+ return '';
+ },
+ },
+ watch: {
+ disabled() {
+ this.updateTooltip();
+ },
+ },
+ mounted() {
+ this.updateTooltip();
+ },
+ methods: {
+ updateTooltip() {
+ const $tooltip = $(this.$refs.addIssuesButton);
+
+ this.$nextTick(() => {
+ if (this.disabled) {
+ $tooltip.tooltip();
+ } else {
+ $tooltip.tooltip('destroy');
+ }
+ });
+ },
+ openModal() {
+ if (!this.disabled) {
+ this.toggleModal(true);
+ }
+ },
+ },
+ template: `
+ <div class="board-extra-actions">
+ <button
+ class="btn btn-create prepend-left-10"
+ type="button"
+ data-placement="bottom"
+ ref="addIssuesButton"
+ :class="{ 'disabled': disabled }"
+ :title="tooltipTitle"
+ :aria-disabled="disabled"
+ v-if="canAdminList"
+ @click="openModal">
+ Add issues
+ </button>
+ </div>
+ `,
+ });
+};
diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js
index 2b0a1aaa89f..6c97e1629bf 100644
--- a/app/assets/javascripts/boards/mixins/modal_mixins.js
+++ b/app/assets/javascripts/boards/mixins/modal_mixins.js
@@ -1,6 +1,6 @@
-const ModalStore = gl.issueBoards.ModalStore;
+import ModalStore from '../stores/modal_store';
-gl.issueBoards.ModalMixins = {
+export default {
methods: {
toggleModal(toggle) {
ModalStore.store.showAddIssuesModal = toggle;
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index 38a0eb12f92..ac316c31deb 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -1,6 +1,9 @@
/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */
/* global DocumentTouch */
+import $ from 'jquery';
+import sortableConfig from '../../sortable/sortable_config';
+
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
@@ -18,19 +21,14 @@ gl.issueBoards.onEnd = () => {
gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch;
gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
- const defaultSortOptions = {
- animation: 200,
- forceFallback: true,
- fallbackClass: 'is-dragging',
- fallbackOnBody: true,
- ghostClass: 'is-ghost',
+ const defaultSortOptions = Object.assign({}, sortableConfig, {
filter: '.board-delete, .btn',
delay: gl.issueBoards.touchEnabled ? 100 : 0,
scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
scrollSpeed: 20,
onStart: gl.issueBoards.onStart,
- onEnd: gl.issueBoards.onEnd
- };
+ onEnd: gl.issueBoards.onEnd,
+ });
Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
return defaultSortOptions;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 81edd95bf2b..b381d48d625 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -4,6 +4,7 @@
/* global ListAssignee */
import Vue from 'vue';
+import IssueProject from './project';
class ListIssue {
constructor (obj, defaultAvatar) {
@@ -22,7 +23,15 @@ class ListIssue {
};
this.isLoading = {};
this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
+ this.referencePath = obj.reference_path;
+ this.path = obj.real_path;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
+ this.milestone_id = obj.milestone_id;
+ this.project_id = obj.project_id;
+
+ if (obj.project) {
+ this.project = new IssueProject(obj.project);
+ }
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
@@ -91,7 +100,7 @@ class ListIssue {
this.isLoading[key] = value;
}
- update (url) {
+ update () {
const data = {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
@@ -105,8 +114,11 @@ class ListIssue {
data.issue.label_ids = [''];
}
- return Vue.http.patch(url, data);
+ const projectPath = this.project ? this.project.path : '';
+ return Vue.http.patch(`${this.path}.json`, data);
}
}
window.ListIssue = ListIssue;
+
+export default ListIssue;
diff --git a/app/assets/javascripts/boards/models/label.js b/app/assets/javascripts/boards/models/label.js
deleted file mode 100644
index 98c1ec014c4..00000000000
--- a/app/assets/javascripts/boards/models/label.js
+++ /dev/null
@@ -1,15 +0,0 @@
-/* eslint-disable no-unused-vars, space-before-function-paren */
-
-class ListLabel {
- constructor (obj) {
- this.id = obj.id;
- this.title = obj.title;
- this.type = obj.type;
- this.color = obj.color;
- this.textColor = obj.text_color;
- this.description = obj.description;
- this.priority = (obj.priority !== null) ? obj.priority : Infinity;
- }
-}
-
-window.ListLabel = ListLabel;
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index df2809e1805..e210d69895e 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -40,7 +40,7 @@ class List {
save () {
return gl.boardService.createList(this.label.id)
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
this.id = data.id;
this.type = data.list_type;
@@ -90,7 +90,7 @@ class List {
}
return gl.boardService.getIssuesForList(this.id, data)
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
this.loading = false;
this.issuesSize = data.size;
@@ -108,7 +108,7 @@ class List {
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
- .then(resp => resp.json())
+ .then(res => res.data)
.then((data) => {
issue.id = data.id;
issue.iid = data.iid;
diff --git a/app/assets/javascripts/boards/models/project.js b/app/assets/javascripts/boards/models/project.js
new file mode 100644
index 00000000000..a3d5c7af7ac
--- /dev/null
+++ b/app/assets/javascripts/boards/models/project.js
@@ -0,0 +1,6 @@
+export default class IssueProject {
+ constructor(obj) {
+ this.id = obj.id;
+ this.path = obj.path;
+ }
+}
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index fa7ddd25e1f..7c90597f77c 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -1,82 +1,79 @@
-/* eslint-disable space-before-function-paren, comma-dangle, no-param-reassign, camelcase, max-len, no-unused-vars */
-
-import Vue from 'vue';
+import axios from '../../lib/utils/axios_utils';
+import { mergeUrlParams } from '../../lib/utils/url_utility';
export default class BoardService {
- constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
- this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
- issues: {
- method: 'GET',
- url: `${gon.relative_url_root}/-/boards/${boardId}/issues.json`,
- }
- });
- this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
- generate: {
- method: 'POST',
- url: `${listsEndpoint}/generate.json`
- }
- });
- this.issue = Vue.resource(`${gon.relative_url_root}/-/boards/${boardId}/issues{/id}`, {});
- this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
- bulkUpdate: {
- method: 'POST',
- url: bulkUpdatePath,
- },
- });
+ constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
+ this.boardsEndpoint = boardsEndpoint;
+ this.boardId = boardId;
+ this.listsEndpoint = listsEndpoint;
+ this.listsEndpointGenerate = `${listsEndpoint}/generate.json`;
+ this.bulkUpdatePath = bulkUpdatePath;
+ }
+
+ generateBoardsPath(id) {
+ return `${this.boardsEndpoint}${id ? `/${id}` : ''}.json`;
}
- all () {
- return this.lists.get();
+ generateIssuesPath(id) {
+ return `${this.listsEndpoint}${id ? `/${id}` : ''}/issues`;
}
- generateDefaultLists () {
- return this.lists.generate({});
+ static generateIssuePath(boardId, id) {
+ return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${id ? `/${id}` : ''}`;
}
- createList (label_id) {
- return this.lists.save({}, {
+ all() {
+ return axios.get(this.listsEndpoint);
+ }
+
+ generateDefaultLists() {
+ return axios.post(this.listsEndpointGenerate, {});
+ }
+
+ createList(labelId) {
+ return axios.post(this.listsEndpoint, {
list: {
- label_id
- }
+ label_id: labelId,
+ },
});
}
- updateList (id, position) {
- return this.lists.update({ id }, {
+ updateList(id, position) {
+ return axios.put(`${this.listsEndpoint}/${id}`, {
list: {
- position
- }
+ position,
+ },
});
}
- destroyList (id) {
- return this.lists.delete({ id });
+ destroyList(id) {
+ return axios.delete(`${this.listsEndpoint}/${id}`);
}
- getIssuesForList (id, filter = {}) {
+ getIssuesForList(id, filter = {}) {
const data = { id };
Object.keys(filter).forEach((key) => { data[key] = filter[key]; });
- return this.issues.get(data);
+ return axios.get(mergeUrlParams(data, this.generateIssuesPath(id)));
}
- moveIssue (id, from_list_id = null, to_list_id = null, move_before_id = null, move_after_id = null) {
- return this.issue.update({ id }, {
- from_list_id,
- to_list_id,
- move_before_id,
- move_after_id,
+ moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) {
+ return axios.put(BoardService.generateIssuePath(this.boardId, id), {
+ from_list_id: fromListId,
+ to_list_id: toListId,
+ move_before_id: moveBeforeId,
+ move_after_id: moveAfterId,
});
}
- newIssue (id, issue) {
- return this.issues.save({ id }, {
- issue
+ newIssue(id, issue) {
+ return axios.post(this.generateIssuesPath(id), {
+ issue,
});
}
getBacklog(data) {
- return this.boards.issues(data);
+ return axios.get(mergeUrlParams(data, `${gon.relative_url_root}/-/boards/${this.boardId}/issues.json`));
}
bulkUpdate(issueIds, extraData = {}) {
@@ -86,15 +83,15 @@ export default class BoardService {
}),
};
- return this.issues.bulkUpdate(data);
+ return axios.post(this.bulkUpdatePath, data);
}
static getIssueInfo(endpoint) {
- return Vue.http.get(endpoint);
+ return axios.get(endpoint);
}
static toggleIssueSubscription(endpoint) {
- return Vue.http.post(endpoint);
+ return axios.post(endpoint);
}
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 798d7e0d147..20e78edf2a2 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,8 +1,10 @@
/* eslint-disable comma-dangle, space-before-function-paren, one-var, no-shadow, dot-notation, max-len */
/* global List */
+
+import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
-import { getUrlParamsArray } from '../../lib/utils/common_utils';
+import { getUrlParamsArray } from '~/lib/utils/common_utils';
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js
index 4fdc925c825..a4220cd840d 100644
--- a/app/assets/javascripts/boards/stores/modal_store.js
+++ b/app/assets/javascripts/boards/stores/modal_store.js
@@ -1,6 +1,3 @@
-window.gl = window.gl || {};
-window.gl.issueBoards = window.gl.issueBoards || {};
-
class ModalStore {
constructor() {
this.store = {
@@ -95,4 +92,4 @@ class ModalStore {
}
}
-gl.issueBoards.ModalStore = new ModalStore();
+export default new ModalStore();
diff --git a/app/assets/javascripts/boards/utils/query_data.js b/app/assets/javascripts/boards/utils/query_data.js
index 2cd3c146f11..65315979df7 100644
--- a/app/assets/javascripts/boards/utils/query_data.js
+++ b/app/assets/javascripts/boards/utils/query_data.js
@@ -5,7 +5,7 @@ export default (path, extraData) => path.split('&').reduce((dataParam, filterPar
const paramSplit = filterParam.split('=');
const paramKeyNormalized = paramSplit[0].replace('[]', '');
const isArray = paramSplit[0].indexOf('[]');
- const value = decodeURIComponent(paramSplit[1]).replace(/\+/g, ' ');
+ const value = decodeURIComponent(paramSplit[1].replace(/\+/g, ' '));
if (isArray !== -1) {
if (!data[paramKeyNormalized]) {
diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js
index cbc28374b80..839e369eaf6 100644
--- a/app/assets/javascripts/branches/branches_delete_modal.js
+++ b/app/assets/javascripts/branches/branches_delete_modal.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
const MODAL_SELECTOR = '#modal-delete-branch';
class DeleteModal {
diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js
index 10fbcfe96cf..1474d93dde6 100644
--- a/app/assets/javascripts/breadcrumb.js
+++ b/app/assets/javascripts/breadcrumb.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export const addTooltipToEl = (el) => {
const textEl = el.querySelector('.js-breadcrumb-item-text');
diff --git a/app/assets/javascripts/broadcast_message.js b/app/assets/javascripts/broadcast_message.js
deleted file mode 100644
index ff88083a4b4..00000000000
--- a/app/assets/javascripts/broadcast_message.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export default function initBroadcastMessagesForm() {
- $('input#broadcast_message_color').on('input', function onMessageColorInput() {
- const previewColor = $(this).val();
- $('div.broadcast-message-preview').css('background-color', previewColor);
- });
-
- $('input#broadcast_message_font').on('input', function onMessageFontInput() {
- const previewColor = $(this).val();
- $('div.broadcast-message-preview').css('color', previewColor);
- });
-
- const previewPath = $('textarea#broadcast_message_message').data('preview-path');
-
- $('textarea#broadcast_message_message').on('input', _.debounce(function onMessageInput() {
- const message = $(this).val();
- if (message === '') {
- $('.js-broadcast-message-preview').text('Your message here');
- } else {
- $.ajax({
- url: previewPath,
- type: 'POST',
- data: {
- broadcast_message: { message },
- },
- });
- }
- }, 250));
-}
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index ace89398943..3fa16517388 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,4 +1,6 @@
/* eslint-disable func-names, prefer-arrow-callback, no-return-assign */
+
+import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
import { convertPermissionToBoolean } from './lib/utils/common_utils';
diff --git a/app/assets/javascripts/build_variables.js b/app/assets/javascripts/build_variables.js
index 35edf3e0017..d398e4a4c83 100644
--- a/app/assets/javascripts/build_variables.js
+++ b/app/assets/javascripts/build_variables.js
@@ -1,9 +1,9 @@
-/* eslint-disable func-names*/
+import $ from 'jquery';
export default function handleRevealVariables() {
$('.js-reveal-variables')
.off('click')
- .on('click', function () {
+ .on('click', function click() {
$('.js-build-variables').toggle();
$(this).hide();
});
diff --git a/app/assets/javascripts/ci_lint_editor.js b/app/assets/javascripts/ci_lint_editor.js
deleted file mode 100644
index b9469e5b7cb..00000000000
--- a/app/assets/javascripts/ci_lint_editor.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export default class CILintEditor {
- constructor() {
- this.editor = window.ace.edit('ci-editor');
- this.textarea = document.querySelector('#content');
-
- this.editor.getSession().setMode('ace/mode/yaml');
- this.editor.on('input', () => {
- const content = this.editor.getSession().getValue();
- this.textarea.value = content;
- });
- }
-}
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
new file mode 100644
index 00000000000..b33adff609f
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
@@ -0,0 +1,117 @@
+import _ from 'underscore';
+import axios from '../lib/utils/axios_utils';
+import { s__ } from '../locale';
+import Flash from '../flash';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+import statusCodes from '../lib/utils/http_status';
+import VariableList from './ci_variable_list';
+
+function generateErrorBoxContent(errors) {
+ const errorList = [].concat(errors).map(errorString => `
+ <li>
+ ${_.escape(errorString)}
+ </li>
+ `);
+
+ return `
+ <p>
+ ${s__('CiVariable|Validation failed')}
+ </p>
+ <ul>
+ ${errorList.join('')}
+ </ul>
+ `;
+}
+
+// Used for the variable list on CI/CD projects/groups settings page
+export default class AjaxVariableList {
+ constructor({
+ container,
+ saveButton,
+ errorBox,
+ formField = 'variables',
+ saveEndpoint,
+ }) {
+ this.container = container;
+ this.saveButton = saveButton;
+ this.errorBox = errorBox;
+ this.saveEndpoint = saveEndpoint;
+
+ this.variableList = new VariableList({
+ container: this.container,
+ formField,
+ });
+
+ this.bindEvents();
+ this.variableList.init();
+ }
+
+ bindEvents() {
+ this.saveButton.addEventListener('click', this.onSaveClicked.bind(this));
+ }
+
+ onSaveClicked() {
+ const loadingIcon = this.saveButton.querySelector('.js-secret-variables-save-loading-icon');
+ loadingIcon.classList.toggle('hide', false);
+ this.errorBox.classList.toggle('hide', true);
+ // We use this to prevent a user from changing a key before we have a chance
+ // to match it up in `updateRowsWithPersistedVariables`
+ this.variableList.toggleEnableRow(false);
+
+ return axios.patch(this.saveEndpoint, {
+ variables_attributes: this.variableList.getAllData(),
+ }, {
+ // We want to be able to process the `res.data` from a 400 error response
+ // and print the validation messages such as duplicate variable keys
+ validateStatus: status => (
+ status >= statusCodes.OK &&
+ status < statusCodes.MULTIPLE_CHOICES
+ ) ||
+ status === statusCodes.BAD_REQUEST,
+ })
+ .then((res) => {
+ loadingIcon.classList.toggle('hide', true);
+ this.variableList.toggleEnableRow(true);
+
+ if (res.status === statusCodes.OK && res.data) {
+ this.updateRowsWithPersistedVariables(res.data.variables);
+ this.variableList.hideValues();
+ } else if (res.status === statusCodes.BAD_REQUEST) {
+ // Validation failed
+ this.errorBox.innerHTML = generateErrorBoxContent(res.data);
+ this.errorBox.classList.toggle('hide', false);
+ }
+ })
+ .catch(() => {
+ loadingIcon.classList.toggle('hide', true);
+ this.variableList.toggleEnableRow(true);
+ Flash(s__('CiVariable|Error occured while saving variables'));
+ });
+ }
+
+ updateRowsWithPersistedVariables(persistedVariables = []) {
+ const persistedVariableMap = [].concat(persistedVariables).reduce((variableMap, variable) => ({
+ ...variableMap,
+ [variable.key]: variable,
+ }), {});
+
+ this.container.querySelectorAll('.js-row').forEach((row) => {
+ // If we submitted a row that was destroyed, remove it so we don't try
+ // to destroy it again which would cause a BE error
+ const destroyInput = row.querySelector('.js-ci-variable-input-destroy');
+ if (convertPermissionToBoolean(destroyInput.value)) {
+ row.remove();
+ // Update the ID input so any future edits and `_destroy` will apply on the BE
+ } else {
+ const key = row.querySelector('.js-ci-variable-input-key').value;
+ const persistedVariable = persistedVariableMap[key];
+
+ if (persistedVariable) {
+ // eslint-disable-next-line no-param-reassign
+ row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id;
+ row.setAttribute('data-is-persisted', 'true');
+ }
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
new file mode 100644
index 00000000000..e177a3bfdc7
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
@@ -0,0 +1,222 @@
+import $ from 'jquery';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+import { s__ } from '../locale';
+import setupToggleButtons from '../toggle_buttons';
+import CreateItemDropdown from '../create_item_dropdown';
+import SecretValues from '../behaviors/secret_values';
+
+const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments');
+
+function createEnvironmentItem(value) {
+ return {
+ title: value === '*' ? ALL_ENVIRONMENTS_STRING : value,
+ id: value,
+ text: value === '*' ? s__('CiVariable|* (All environments)') : value,
+ };
+}
+
+export default class VariableList {
+ constructor({
+ container,
+ formField,
+ }) {
+ this.$container = $(container);
+ this.formField = formField;
+ this.environmentDropdownMap = new WeakMap();
+
+ this.inputMap = {
+ id: {
+ selector: '.js-ci-variable-input-id',
+ default: '',
+ },
+ key: {
+ selector: '.js-ci-variable-input-key',
+ default: '',
+ },
+ secret_value: {
+ selector: '.js-ci-variable-input-value',
+ default: '',
+ },
+ protected: {
+ selector: '.js-ci-variable-input-protected',
+ default: 'false',
+ },
+ environment_scope: {
+ // We can't use a `.js-` class here because
+ // gl_dropdown replaces the <input> and doesn't copy over the class
+ // See https://gitlab.com/gitlab-org/gitlab-ce/issues/42458
+ selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`,
+ default: '*',
+ },
+ _destroy: {
+ selector: '.js-ci-variable-input-destroy',
+ default: '',
+ },
+ };
+
+ this.secretValues = new SecretValues({
+ container: this.$container[0],
+ valueSelector: '.js-row:not(:last-child) .js-secret-value',
+ placeholderSelector: '.js-row:not(:last-child) .js-secret-value-placeholder',
+ });
+ }
+
+ init() {
+ this.bindEvents();
+ this.secretValues.init();
+ }
+
+ bindEvents() {
+ this.$container.find('.js-row').each((index, rowEl) => {
+ this.initRow(rowEl);
+ });
+
+ this.$container.on('click', '.js-row-remove-button', (e) => {
+ e.preventDefault();
+ this.removeRow($(e.currentTarget).closest('.js-row'));
+ });
+
+ const inputSelector = Object.keys(this.inputMap)
+ .map(name => this.inputMap[name].selector)
+ .join(',');
+
+ // Remove any empty rows except the last row
+ this.$container.on('blur', inputSelector, (e) => {
+ const $row = $(e.currentTarget).closest('.js-row');
+
+ if ($row.is(':not(:last-child)') && !this.checkIfRowTouched($row)) {
+ this.removeRow($row);
+ }
+ });
+
+ // Always make sure there is an empty last row
+ this.$container.on('input trigger-change', inputSelector, () => {
+ const $lastRow = this.$container.find('.js-row').last();
+
+ if (this.checkIfRowTouched($lastRow)) {
+ this.insertRow($lastRow);
+ }
+ });
+ }
+
+ initRow(rowEl) {
+ const $row = $(rowEl);
+
+ setupToggleButtons($row[0]);
+
+ // Reset the resizable textarea
+ $row.find(this.inputMap.secret_value.selector).css('height', '');
+
+ const $environmentSelect = $row.find('.js-variable-environment-toggle');
+ if ($environmentSelect.length) {
+ const createItemDropdown = new CreateItemDropdown({
+ $dropdown: $environmentSelect,
+ defaultToggleLabel: ALL_ENVIRONMENTS_STRING,
+ fieldName: `${this.formField}[variables_attributes][][environment_scope]`,
+ getData: (term, callback) => callback(this.getEnvironmentValues()),
+ createNewItemFromValue: createEnvironmentItem,
+ onSelect: () => {
+ // Refresh the other dropdowns in the variable list
+ // so they have the new value we just picked
+ this.refreshDropdownData();
+
+ $row.find(this.inputMap.environment_scope.selector).trigger('trigger-change');
+ },
+ });
+
+ // Clear out any data that might have been left-over from the row clone
+ createItemDropdown.clearDropdown();
+
+ this.environmentDropdownMap.set($row[0], createItemDropdown);
+ }
+ }
+
+ insertRow($row) {
+ const $rowClone = $row.clone();
+ $rowClone.removeAttr('data-is-persisted');
+
+ // Reset the inputs to their defaults
+ Object.keys(this.inputMap).forEach((name) => {
+ const entry = this.inputMap[name];
+ $rowClone.find(entry.selector).val(entry.default);
+ });
+
+ this.initRow($rowClone);
+
+ $row.after($rowClone);
+ }
+
+ removeRow(row) {
+ const $row = $(row);
+ const isPersisted = convertPermissionToBoolean($row.attr('data-is-persisted'));
+
+ if (isPersisted) {
+ $row.hide();
+ $row
+ // eslint-disable-next-line no-underscore-dangle
+ .find(this.inputMap._destroy.selector)
+ .val(true);
+ } else {
+ $row.remove();
+ }
+
+ // Refresh the other dropdowns in the variable list
+ // so any value with the variable deleted is gone
+ this.refreshDropdownData();
+ }
+
+ checkIfRowTouched($row) {
+ return Object.keys(this.inputMap).some((name) => {
+ const entry = this.inputMap[name];
+ const $el = $row.find(entry.selector);
+ return $el.length && $el.val() !== entry.default;
+ });
+ }
+
+ toggleEnableRow(isEnabled = true) {
+ this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled);
+ this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled);
+ }
+
+ hideValues() {
+ this.secretValues.updateDom(false);
+ }
+
+ getAllData() {
+ // Ignore the last empty row because we don't want to try persist
+ // a blank variable and run into validation problems.
+ const validRows = this.$container.find('.js-row').toArray().slice(0, -1);
+
+ return validRows.map((rowEl) => {
+ const resultant = {};
+ Object.keys(this.inputMap).forEach((name) => {
+ const entry = this.inputMap[name];
+ const $input = $(rowEl).find(entry.selector);
+ if ($input.length) {
+ resultant[name] = $input.val();
+ }
+ });
+
+ return resultant;
+ });
+ }
+
+ getEnvironmentValues() {
+ const valueMap = this.$container.find(this.inputMap.environment_scope.selector).toArray()
+ .reduce((prevValueMap, envInput) => ({
+ ...prevValueMap,
+ [envInput.value]: envInput.value,
+ }), {});
+
+ return Object.keys(valueMap).map(createEnvironmentItem);
+ }
+
+ refreshDropdownData() {
+ this.$container.find('.js-row').each((index, rowEl) => {
+ const environmentDropdown = this.environmentDropdownMap.get(rowEl);
+ if (environmentDropdown) {
+ environmentDropdown.refreshData();
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
new file mode 100644
index 00000000000..7cd5916ac9c
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
@@ -0,0 +1,27 @@
+import $ from 'jquery';
+import VariableList from './ci_variable_list';
+
+// Used for the variable list on scheduled pipeline edit page
+export default function setupNativeFormVariableList({
+ container,
+ formField = 'variables',
+}) {
+ const $container = $(container);
+
+ const variableList = new VariableList({
+ container: $container,
+ formField,
+ });
+ variableList.init();
+
+ // Clear out the names in the empty last row so it
+ // doesn't get submitted and throw validation errors
+ $container.closest('form').on('submit trigger-submit', () => {
+ const $lastRow = $container.find('.js-row').last();
+
+ const isTouched = variableList.checkIfRowTouched($lastRow);
+ if (!isTouched) {
+ $lastRow.find('input, textarea').attr('name', '');
+ }
+ });
+}
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 2cfd6179a25..01aec4f36af 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -14,6 +14,7 @@ import {
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import applications from './components/applications.vue';
+import setupToggleButtons from '../toggle_buttons';
/**
* Cluster page has 2 separate parts:
@@ -30,13 +31,18 @@ export default class Clusters {
installHelmPath,
installIngressPath,
installRunnerPath,
+ installPrometheusPath,
+ managePrometheusPath,
clusterStatus,
clusterStatusReason,
helpPath,
+ ingressHelpPath,
+ ingressDnsHelpPath,
} = document.querySelector('.js-edit-cluster-form').dataset;
this.store = new ClustersStore();
- this.store.setHelpPath(helpPath);
+ this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
+ this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
this.service = new ClustersService({
@@ -44,14 +50,12 @@ export default class Clusters {
installHelmEndpoint: installHelmPath,
installIngressEndpoint: installIngressPath,
installRunnerEndpoint: installRunnerPath,
+ installPrometheusEndpoint: installPrometheusPath,
});
- this.toggle = this.toggle.bind(this);
this.installApplication = this.installApplication.bind(this);
this.showToken = this.showToken.bind(this);
- this.toggleButton = document.querySelector('.js-toggle-cluster');
- this.toggleInput = document.querySelector('.js-toggle-input');
this.errorContainer = document.querySelector('.js-cluster-error');
this.successContainer = document.querySelector('.js-cluster-success');
this.creatingContainer = document.querySelector('.js-cluster-creating');
@@ -61,6 +65,7 @@ export default class Clusters {
this.tokenField = document.querySelector('.js-cluster-token');
initSettingsPanels();
+ setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area'));
this.initApplications();
if (this.store.state.status !== 'created') {
@@ -92,6 +97,9 @@ export default class Clusters {
props: {
applications: this.state.applications,
helpPath: this.state.helpPath,
+ ingressHelpPath: this.state.ingressHelpPath,
+ managePrometheusPath: this.state.managePrometheusPath,
+ ingressDnsHelpPath: this.state.ingressDnsHelpPath,
},
});
},
@@ -99,13 +107,11 @@ export default class Clusters {
}
addListeners() {
- this.toggleButton.addEventListener('click', this.toggle);
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
eventHub.$on('installApplication', this.installApplication);
}
removeListeners() {
- this.toggleButton.removeEventListener('click', this.toggle);
if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken);
eventHub.$off('installApplication', this.installApplication);
}
@@ -149,11 +155,6 @@ export default class Clusters {
this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason);
}
- toggle() {
- this.toggleButton.classList.toggle('is-checked');
- this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('is-checked').toString());
- }
-
showToken() {
const type = this.tokenField.getAttribute('type');
@@ -178,7 +179,7 @@ export default class Clusters {
.map(appId => newApplicationMap[appId].title);
if (appTitles.length > 0) {
- const text = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your cluster'), {
+ const text = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster'), {
appList: appTitles.join(', '),
});
Flash(text, 'notice', this.successApplicationContainer);
diff --git a/app/assets/javascripts/clusters/clusters_index.js b/app/assets/javascripts/clusters/clusters_index.js
index 6844d1dbd83..2e3ad244375 100644
--- a/app/assets/javascripts/clusters/clusters_index.js
+++ b/app/assets/javascripts/clusters/clusters_index.js
@@ -1,58 +1,20 @@
import Flash from '../flash';
import { s__ } from '../locale';
+import setupToggleButtons from '../toggle_buttons';
import ClustersService from './services/clusters_service';
-/**
- * Toggles loading and disabled classes.
- * @param {HTMLElement} button
- */
-const toggleLoadingButton = (button) => {
- if (button.getAttribute('disabled')) {
- button.removeAttribute('disabled');
- } else {
- button.setAttribute('disabled', true);
- }
-
- button.classList.toggle('is-loading');
-};
-/**
- * Toggles checked class for the given button
- * @param {HTMLElement} button
- */
-const toggleValue = (button) => {
- button.classList.toggle('is-checked');
+export default () => {
+ const clusterList = document.querySelector('.js-clusters-list');
+ // The empty state won't have a clusterList
+ if (clusterList) {
+ setupToggleButtons(
+ document.querySelector('.js-clusters-list'),
+ (value, toggle) =>
+ ClustersService.updateCluster(toggle.dataset.endpoint, { cluster: { enabled: value } })
+ .catch((err) => {
+ Flash(s__('ClusterIntegration|Something went wrong on our end.'));
+ throw err;
+ }),
+ );
+ }
};
-
-/**
- * Handles toggle buttons in the cluster's table.
- *
- * When the user clicks the toggle button for each cluster, it:
- * - toggles the button
- * - shows a loading and disables button
- * - Makes a put request to the given endpoint
- * Once we receive the response, either:
- * 1) Show updated status in case of successfull response
- * 2) Show initial status in case of failed response
- */
-export default function setClusterTableToggles() {
- document.querySelectorAll('.js-toggle-cluster-list')
- .forEach(button => button.addEventListener('click', (e) => {
- const toggleButton = e.currentTarget;
- const endpoint = toggleButton.getAttribute('data-endpoint');
-
- toggleValue(toggleButton);
- toggleLoadingButton(toggleButton);
-
- const value = toggleButton.classList.contains('is-checked');
-
- ClustersService.updateCluster(endpoint, { cluster: { enabled: value } })
- .then(() => {
- toggleLoadingButton(toggleButton);
- })
- .catch(() => {
- toggleLoadingButton(toggleButton);
- toggleValue(toggleButton);
- Flash(s__('ClusterIntegration|Something went wrong on our end.'));
- });
- }));
-}
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 872abf03ef1..c2a35341eb2 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -1,108 +1,118 @@
<script>
-import { s__, sprintf } from '../../locale';
-import eventHub from '../event_hub';
-import loadingButton from '../../vue_shared/components/loading_button.vue';
-import {
- APPLICATION_NOT_INSTALLABLE,
- APPLICATION_SCHEDULED,
- APPLICATION_INSTALLABLE,
- APPLICATION_INSTALLING,
- APPLICATION_INSTALLED,
- APPLICATION_ERROR,
- REQUEST_LOADING,
- REQUEST_SUCCESS,
- REQUEST_FAILURE,
-} from '../constants';
+ /* eslint-disable vue/require-default-prop */
+ import { s__, sprintf } from '../../locale';
+ import eventHub from '../event_hub';
+ import loadingButton from '../../vue_shared/components/loading_button.vue';
+ import {
+ APPLICATION_NOT_INSTALLABLE,
+ APPLICATION_SCHEDULED,
+ APPLICATION_INSTALLABLE,
+ APPLICATION_INSTALLING,
+ APPLICATION_INSTALLED,
+ APPLICATION_ERROR,
+ REQUEST_LOADING,
+ REQUEST_SUCCESS,
+ REQUEST_FAILURE,
+ } from '../constants';
-export default {
- props: {
- id: {
- type: String,
- required: true,
+ export default {
+ components: {
+ loadingButton,
},
- title: {
- type: String,
- required: true,
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ titleLink: {
+ type: String,
+ required: false,
+ },
+ manageLink: {
+ type: String,
+ required: false,
+ },
+ status: {
+ type: String,
+ required: false,
+ },
+ statusReason: {
+ type: String,
+ required: false,
+ },
+ requestStatus: {
+ type: String,
+ required: false,
+ },
+ requestReason: {
+ type: String,
+ required: false,
+ },
},
- titleLink: {
- type: String,
- required: false,
- },
- description: {
- type: String,
- required: true,
- },
- status: {
- type: String,
- required: false,
- },
- statusReason: {
- type: String,
- required: false,
- },
- requestStatus: {
- type: String,
- required: false,
- },
- requestReason: {
- type: String,
- required: false,
- },
- },
- components: {
- loadingButton,
- },
- computed: {
- rowJsClass() {
- return `js-cluster-application-row-${this.id}`;
- },
- installButtonLoading() {
- return !this.status ||
- this.status === APPLICATION_SCHEDULED ||
- this.status === APPLICATION_INSTALLING ||
- this.requestStatus === REQUEST_LOADING;
- },
- installButtonDisabled() {
- // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but
- // we already made a request to install and are just waiting for the real-time
- // to sync up.
- return (this.status !== APPLICATION_INSTALLABLE && this.status !== APPLICATION_ERROR) ||
- this.requestStatus === REQUEST_LOADING ||
- this.requestStatus === REQUEST_SUCCESS;
- },
- installButtonLabel() {
- let label;
- if (
- this.status === APPLICATION_NOT_INSTALLABLE ||
- this.status === APPLICATION_INSTALLABLE ||
- this.status === APPLICATION_ERROR
- ) {
- label = s__('ClusterIntegration|Install');
- } else if (this.status === APPLICATION_SCHEDULED || this.status === APPLICATION_INSTALLING) {
- label = s__('ClusterIntegration|Installing');
- } else if (this.status === APPLICATION_INSTALLED) {
- label = s__('ClusterIntegration|Installed');
- }
+ computed: {
+ rowJsClass() {
+ return `js-cluster-application-row-${this.id}`;
+ },
+ installButtonLoading() {
+ return !this.status ||
+ this.status === APPLICATION_SCHEDULED ||
+ this.status === APPLICATION_INSTALLING ||
+ this.requestStatus === REQUEST_LOADING;
+ },
+ installButtonDisabled() {
+ // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but
+ // we already made a request to install and are just waiting for the real-time
+ // to sync up.
+ return (this.status !== APPLICATION_INSTALLABLE
+ && this.status !== APPLICATION_ERROR) ||
+ this.requestStatus === REQUEST_LOADING ||
+ this.requestStatus === REQUEST_SUCCESS;
+ },
+ installButtonLabel() {
+ let label;
+ if (
+ this.status === APPLICATION_NOT_INSTALLABLE ||
+ this.status === APPLICATION_INSTALLABLE ||
+ this.status === APPLICATION_ERROR
+ ) {
+ label = s__('ClusterIntegration|Install');
+ } else if (this.status === APPLICATION_SCHEDULED ||
+ this.status === APPLICATION_INSTALLING) {
+ label = s__('ClusterIntegration|Installing');
+ } else if (this.status === APPLICATION_INSTALLED) {
+ label = s__('ClusterIntegration|Installed');
+ }
- return label;
+ return label;
+ },
+ showManageButton() {
+ return this.manageLink && this.status === APPLICATION_INSTALLED;
+ },
+ manageButtonLabel() {
+ return s__('ClusterIntegration|Manage');
+ },
+ hasError() {
+ return this.status === APPLICATION_ERROR ||
+ this.requestStatus === REQUEST_FAILURE;
+ },
+ generalErrorDescription() {
+ return sprintf(
+ s__('ClusterIntegration|Something went wrong while installing %{title}'), {
+ title: this.title,
+ },
+ );
+ },
},
- hasError() {
- return this.status === APPLICATION_ERROR || this.requestStatus === REQUEST_FAILURE;
+ methods: {
+ installClicked() {
+ eventHub.$emit('installApplication', this.id);
+ },
},
- generalErrorDescription() {
- return sprintf(
- s__('ClusterIntegration|Something went wrong while installing %{title}'), {
- title: this.title,
- },
- );
- },
- },
- methods: {
- installClicked() {
- eventHub.$emit('installApplication', this.id);
- },
- },
-};
+ };
</script>
<template>
@@ -134,12 +144,24 @@ export default {
class="table-section section-wrap"
role="gridcell"
>
- <div v-html="description"></div>
+ <slot name="description"></slot>
</div>
<div
- class="table-section table-button-footer section-15 section-align-top"
+ class="table-section table-button-footer section-align-top"
+ :class="{ 'section-20': showManageButton, 'section-15': !showManageButton }"
role="gridcell"
>
+ <div
+ v-if="showManageButton"
+ class="btn-group table-action-buttons"
+ >
+ <a
+ class="btn"
+ :href="manageLink"
+ >
+ {{ manageButtonLabel }}
+ </a>
+ </div>
<div class="btn-group table-action-buttons">
<loading-button
class="js-cluster-application-install-button"
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index e5ae439d26e..f8dcdf3f60a 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,78 +1,126 @@
<script>
-import _ from 'underscore';
-import { s__, sprintf } from '../../locale';
-import applicationRow from './application_row.vue';
+ import _ from 'underscore';
+ import { s__, sprintf } from '../../locale';
+ import applicationRow from './application_row.vue';
+ import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
+ import {
+ APPLICATION_INSTALLED,
+ INGRESS,
+ } from '../constants';
-export default {
- props: {
- applications: {
- type: Object,
- required: false,
- default: () => ({}),
+ export default {
+ components: {
+ applicationRow,
+ clipboardButton,
},
- helpPath: {
- type: String,
- required: false,
+ props: {
+ applications: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ ingressHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ ingressDnsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ managePrometheusPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
- },
- components: {
- applicationRow,
- },
- computed: {
- generalApplicationDescription() {
- return sprintf(
- _.escape(s__('ClusterIntegration|Install applications on your cluster. Read more about %{helpLink}')), {
- helpLink: `<a href="${this.helpPath}">
- ${_.escape(s__('ClusterIntegration|installing applications'))}
- </a>`,
- },
- false,
- );
- },
- helmTillerDescription() {
- return _.escape(s__(
- `ClusterIntegration|Helm streamlines installing and managing Kubernets applications.
- Tiller runs inside of your Kubernetes Cluster, and manages
- releases of your charts.`,
- ));
- },
- ingressDescription() {
- const descriptionParagraph = _.escape(s__(
- `ClusterIntegration|Ingress gives you a way to route requests to services based on the
- request host or path, centralizing a number of services into a single entrypoint.`,
- ));
+ computed: {
+ generalApplicationDescription() {
+ return sprintf(
+ _.escape(s__(
+ `ClusterIntegration|Install applications on your Kubernetes cluster.
+ Read more about %{helpLink}`,
+ )), {
+ helpLink: `<a href="${this.helpPath}">
+ ${_.escape(s__('ClusterIntegration|installing applications'))}
+ </a>`,
+ },
+ false,
+ );
+ },
+ ingressId() {
+ return INGRESS;
+ },
+ ingressInstalled() {
+ return this.applications.ingress.status === APPLICATION_INSTALLED;
+ },
+ ingressExternalIp() {
+ return this.applications.ingress.externalIp;
+ },
+ ingressDescription() {
+ const extraCostParagraph = sprintf(
+ _.escape(s__(
+ `ClusterIntegration|%{boldNotice} This will add some extra resources
+ like a load balancer, which may incur additional costs depending on
+ the hosting provider your Kubernetes cluster is installed on. If you are using GKE,
+ you can %{pricingLink}.`,
+ )), {
+ boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`,
+ pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|check the pricing here'))}</a>`,
+ },
+ false,
+ );
- const extraCostParagraph = sprintf(
- _.escape(s__('ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which incur additional costs. See %{pricingLink}')), {
- boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`,
- pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer">
- ${_.escape(s__('ClusterIntegration|GKE pricing'))}
- </a>`,
- },
- false,
- );
+ const externalIpParagraph = sprintf(
+ _.escape(s__(
+ `ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS
+ at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}`,
+ )), {
+ ingressHelpLink: `<a href="${this.ingressHelpPath}">
+ ${_.escape(s__('ClusterIntegration|More information'))}
+ </a>`,
+ },
+ false,
+ );
- return `
- <p>
- ${descriptionParagraph}
- </p>
- <p class="append-bottom-0">
- ${extraCostParagraph}
- </p>
- `;
- },
- gitlabRunnerDescription() {
- return _.escape(s__(
- `ClusterIntegration|GitLab Runner is the open source project that is used to run your jobs
- and send the results back to GitLab.`,
- ));
+ return `
+ <p>
+ ${extraCostParagraph}
+ </p>
+ <p class="settings-message append-bottom-0">
+ ${externalIpParagraph}
+ </p>
+ `;
+ },
+ prometheusDescription() {
+ return sprintf(
+ _.escape(s__(
+ `ClusterIntegration|Prometheus is an open-source monitoring system
+ with %{gitlabIntegrationLink} to monitor deployed applications.`,
+ )), {
+ gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
+ target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|GitLab Integration'))}</a>`,
+ },
+ false,
+ );
+ },
},
- },
-};
+ };
</script>
<template>
- <section class="settings no-animate expanded">
+ <section
+ id="cluster-applications"
+ class="settings no-animate expanded"
+ >
<div class="settings-header">
<h4>
{{ s__('ClusterIntegration|Applications') }}
@@ -90,23 +138,141 @@ export default {
id="helm"
:title="applications.helm.title"
title-link="https://docs.helm.sh/"
- :description="helmTillerDescription"
:status="applications.helm.status"
:status-reason="applications.helm.statusReason"
:request-status="applications.helm.requestStatus"
:request-reason="applications.helm.requestReason"
- />
+ >
+ <div slot="description">
+ {{ s__(`ClusterIntegration|Helm streamlines installing
+ and managing Kubernetes applications.
+ Tiller runs inside of your Kubernetes Cluster,
+ and manages releases of your charts.`) }}
+ </div>
+ </application-row>
+ <application-row
+ :id="ingressId"
+ :title="applications.ingress.title"
+ title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
+ :status="applications.ingress.status"
+ :status-reason="applications.ingress.statusReason"
+ :request-status="applications.ingress.requestStatus"
+ :request-reason="applications.ingress.requestReason"
+ >
+ <div slot="description">
+ <p>
+ {{ s__(`ClusterIntegration|Ingress gives you a way to route
+ requests to services based on the request host or path,
+ centralizing a number of services into a single entrypoint.`) }}
+ </p>
+
+ <template v-if="ingressInstalled">
+ <div class="form-group">
+ <label for="ingress-ip-address">
+ {{ s__('ClusterIntegration|Ingress IP Address') }}
+ </label>
+ <div
+ v-if="ingressExternalIp"
+ class="input-group"
+ >
+ <input
+ type="text"
+ id="ingress-ip-address"
+ class="form-control js-ip-address"
+ :value="ingressExternalIp"
+ readonly
+ />
+ <span class="input-group-btn">
+ <clipboard-button
+ :text="ingressExternalIp"
+ :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')"
+ class="js-clipboard-btn"
+ />
+ </span>
+ </div>
+ <input
+ v-else
+ type="text"
+ class="form-control js-ip-address"
+ readonly
+ value="?"
+ />
+ </div>
+
+ <p
+ v-if="!ingressExternalIp"
+ class="settings-message js-no-ip-message"
+ >
+ {{ s__(`ClusterIntegration|The IP address is in
+ the process of being assigned. Please check your Kubernetes
+ cluster or Quotas on GKE if it takes a long time.`) }}
+
+ <a
+ :href="ingressHelpPath"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('More information') }}
+ </a>
+ </p>
+
+ <p>
+ {{ s__(`ClusterIntegration|Point a wildcard DNS to this
+ generated IP address in order to access
+ your application after it has been deployed.`) }}
+ <a
+ :href="ingressDnsHelpPath"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('More information') }}
+ </a>
+ </p>
+
+ </template>
+ <div
+ v-else
+ v-html="ingressDescription"
+ >
+ </div>
+ </div>
+ </application-row>
+ <application-row
+ id="prometheus"
+ :title="applications.prometheus.title"
+ title-link="https://prometheus.io/docs/introduction/overview/"
+ :manage-link="managePrometheusPath"
+ :status="applications.prometheus.status"
+ :status-reason="applications.prometheus.statusReason"
+ :request-status="applications.prometheus.requestStatus"
+ :request-reason="applications.prometheus.requestReason"
+ >
+ <div
+ slot="description"
+ v-html="prometheusDescription"
+ >
+ </div>
+ </application-row>
<application-row
- id="ingress"
- :title="applications.ingress.title"
- title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
- :description="ingressDescription"
- :status="applications.ingress.status"
- :status-reason="applications.ingress.statusReason"
- :request-status="applications.ingress.requestStatus"
- :request-reason="applications.ingress.requestReason"
- />
- <!-- NOTE: Don't forget to update `clusters.scss` min-height for this block and uncomment `application_spec` tests -->
+ id="runner"
+ :title="applications.runner.title"
+ title-link="https://docs.gitlab.com/runner/"
+ :status="applications.runner.status"
+ :status-reason="applications.runner.statusReason"
+ :request-status="applications.runner.requestStatus"
+ :request-reason="applications.runner.requestReason"
+ >
+ <div slot="description">
+ {{ s__(`ClusterIntegration|GitLab Runner connects to this
+ project's repository and executes CI/CD jobs,
+ pushing results back and deploying,
+ applications to production.`) }}
+ </div>
+ </application-row>
+ <!--
+ NOTE: Don't forget to update `clusters.scss`
+ min-height for this block and uncomment `application_spec` tests
+ -->
<!-- Add GitLab Runner row, all other plumbing is complete -->
</div>
</div>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 93223aefff8..b7179f52bb3 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -10,3 +10,4 @@ export const APPLICATION_ERROR = 'errored';
export const REQUEST_LOADING = 'request-loading';
export const REQUEST_SUCCESS = 'request-success';
export const REQUEST_FAILURE = 'request-failure';
+export const INGRESS = 'ingress';
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 755c2981c2e..13468578f4f 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -7,6 +7,7 @@ export default class ClusterService {
helm: this.options.installHelmEndpoint,
ingress: this.options.installIngressEndpoint,
runner: this.options.installRunnerEndpoint,
+ prometheus: this.options.installPrometheusEndpoint,
};
}
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index e731cdc3042..348bbec3b25 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,9 +1,11 @@
import { s__ } from '../../locale';
+import { INGRESS } from '../constants';
export default class ClusterStore {
constructor() {
this.state = {
helpPath: null,
+ ingressHelpPath: null,
status: null,
statusReason: null,
applications: {
@@ -20,6 +22,7 @@ export default class ClusterStore {
statusReason: null,
requestStatus: null,
requestReason: null,
+ externalIp: null,
},
runner: {
title: s__('ClusterIntegration|GitLab Runner'),
@@ -28,12 +31,25 @@ export default class ClusterStore {
requestStatus: null,
requestReason: null,
},
+ prometheus: {
+ title: s__('ClusterIntegration|Prometheus'),
+ status: null,
+ statusReason: null,
+ requestStatus: null,
+ requestReason: null,
+ },
},
};
}
- setHelpPath(helpPath) {
+ setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath) {
this.state.helpPath = helpPath;
+ this.state.ingressHelpPath = ingressHelpPath;
+ this.state.ingressDnsHelpPath = ingressDnsHelpPath;
+ }
+
+ setManagePrometheusPath(managePrometheusPath) {
+ this.state.managePrometheusPath = managePrometheusPath;
}
updateStatus(status) {
@@ -51,6 +67,7 @@ export default class ClusterStore {
updateStateFromServer(serverState = {}) {
this.state.status = serverState.status;
this.state.statusReason = serverState.status_reason;
+
serverState.applications.forEach((serverAppEntry) => {
const {
name: appId,
@@ -63,6 +80,10 @@ export default class ClusterStore {
status,
statusReason,
};
+
+ if (appId === INGRESS) {
+ this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
+ }
});
}
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index b6a0ece7907..7f3d04655a7 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,5 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-use-before-define, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, quotes, one-var, one-var-declaration-per-line, no-unused-vars, no-return-assign, comma-dangle, quote-props, no-unused-expressions, no-sequences, object-shorthand, max-len */
-import 'vendor/jquery.waitforimages';
+
+import $ from 'jquery';
// Width where images must fits in, for 2-up this gets divided by 2
const availWidth = 900;
@@ -94,7 +95,7 @@ export default class ImageFile {
});
return [maxWidth, maxHeight];
}
-
+ // eslint-disable-next-line
views = {
'two-up': function() {
return $('.two-up.view .wrap', this.file).each((function(_this) {
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 1f9153d95bd..3d89bf1316e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -15,7 +15,7 @@ const CommitPipelinesTable = Vue.extend(commitPipelinesTable);
window.gl = window.gl || {};
window.gl.CommitPipelinesTable = CommitPipelinesTable;
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
if (pipelineTableViewEl) {
@@ -43,4 +43,4 @@ document.addEventListener('DOMContentLoaded', () => {
pipelineTableViewEl.appendChild(table.$el);
}
}
-});
+};
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index e9a0dbaa59d..466a5b5d635 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -4,6 +4,9 @@
import pipelinesMixin from '../../pipelines/mixins/pipelines';
export default {
+ mixins: [
+ pipelinesMixin,
+ ],
props: {
endpoint: {
type: String,
@@ -17,10 +20,6 @@
type: String,
required: true,
},
- emptyStateSvgPath: {
- type: String,
- required: true,
- },
errorStateSvgPath: {
type: String,
required: true,
@@ -31,9 +30,6 @@
default: 'child',
},
},
- mixins: [
- pipelinesMixin,
- ],
data() {
const store = new PipelineStore();
@@ -45,23 +41,14 @@
},
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;
},
+ shouldRenderErrorState() {
+ return this.hasError && !this.isLoading;
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
@@ -92,31 +79,29 @@
<div class="content-list pipelines">
<loading-icon
- label="Loading pipelines"
+ :label="s__('Pipelines|Loading Pipelines')"
size="3"
v-if="isLoading"
- />
+ class="prepend-top-20"
+ />
- <empty-state
- v-if="shouldRenderEmptyState"
- :help-page-path="helpPagePath"
- :empty-state-svg-path="emptyStateSvgPath"
- />
-
- <error-state
- v-if="shouldRenderErrorState"
- :error-state-svg-path="errorStateSvgPath"
- />
+ <svg-blank-state
+ v-else-if="shouldRenderErrorState"
+ :svg-path="errorStateSvgPath"
+ :message="s__(`Pipelines|There was an error fetching the pipelines.
+ Try again in a few moments or contact your support team.`)"
+ />
<div
class="table-holder"
- v-if="shouldRenderTable">
+ v-else-if="shouldRenderTable"
+ >
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
- />
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js
new file mode 100644
index 00000000000..102b4ee8463
--- /dev/null
+++ b/app/assets/javascripts/commit_merge_requests.js
@@ -0,0 +1,74 @@
+/* global Flash */
+
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import { n__, s__ } from './locale';
+
+export function getHeaderText(childElementCount, mergeRequestCount) {
+ if (childElementCount === 0) {
+ return `${mergeRequestCount} ${n__('merge request', 'merge requests', mergeRequestCount)}`;
+ }
+ return ',';
+}
+
+export function createHeader(childElementCount, mergeRequestCount) {
+ const headerText = getHeaderText(childElementCount, mergeRequestCount);
+
+ return $('<span />', {
+ class: 'append-right-5',
+ text: headerText,
+ });
+}
+
+export function createLink(mergeRequest) {
+ return $('<a />', {
+ class: 'append-right-5',
+ href: mergeRequest.path,
+ text: `!${mergeRequest.iid}`,
+ });
+}
+
+export function createTitle(mergeRequest) {
+ return $('<span />', {
+ text: mergeRequest.title,
+ });
+}
+
+export function createItem(mergeRequest) {
+ const $item = $('<span />');
+ const $link = createLink(mergeRequest);
+ const $title = createTitle(mergeRequest);
+ $item.append($link);
+ $item.append($title);
+
+ return $item;
+}
+
+export function createContent(mergeRequests) {
+ const $content = $('<span />');
+
+ if (mergeRequests.length === 0) {
+ $content.text(s__('Commits|No related merge requests found'));
+ } else {
+ mergeRequests.forEach((mergeRequest) => {
+ const $header = createHeader($content.children().length, mergeRequests.length);
+ const $item = createItem(mergeRequest);
+ $content.append($header);
+ $content.append($item);
+ });
+ }
+
+ return $content;
+}
+
+export function fetchCommitMergeRequests() {
+ const $container = $('.merge-requests');
+
+ axios.get($container.data('projectCommitPath'))
+ .then((response) => {
+ const $content = createContent(response.data);
+
+ $container.html($content);
+ })
+ .catch(() => Flash(s__('Commits|An error occurred while fetching merge requests data.')));
+}
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index 3a03cbf6b90..7e2a3573f81 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -1,78 +1,65 @@
-/* eslint-disable func-names, wrap-iife, consistent-return,
- no-return-assign, no-param-reassign, one-var-declaration-per-line, no-unused-vars,
- prefer-template, object-shorthand, prefer-arrow-callback */
-
+import $ from 'jquery';
import { pluralize } from './lib/utils/text_utility';
import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
+import axios from './lib/utils/axios_utils';
-export default (function () {
- const CommitsList = {};
-
- CommitsList.timer = null;
+export default class CommitsList {
+ constructor(limit = 0) {
+ this.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');
- e.stopPropagation();
- return false;
- }
- });
-
- Pager.init(parseInt(limit, 10), false, false, this.processCommits);
+ Pager.init(parseInt(limit, 10), false, false, this.processCommits.bind(this));
this.content = $('#commits-list');
this.searchField = $('#commits-search');
this.lastSearch = this.searchField.val();
- return this.initSearch();
- };
+ this.initSearch();
+ }
- CommitsList.initSearch = function () {
+ initSearch() {
this.timer = null;
- return this.searchField.keyup((function (_this) {
- return function () {
- clearTimeout(_this.timer);
- return _this.timer = setTimeout(_this.filterResults, 500);
- };
- })(this));
- };
+ this.searchField.on('keyup', () => {
+ clearTimeout(this.timer);
+ this.timer = setTimeout(this.filterResults.bind(this), 500);
+ });
+ }
- CommitsList.filterResults = function () {
+ filterResults() {
const form = $('.commits-search-form');
- const search = CommitsList.searchField.val();
- if (search === CommitsList.lastSearch) return;
- const commitsUrl = form.attr('action') + '?' + form.serialize();
- CommitsList.content.fadeTo('fast', 0.5);
- return $.ajax({
- type: 'GET',
- url: form.attr('action'),
- data: form.serialize(),
- complete: function () {
- return CommitsList.content.fadeTo('fast', 1.0);
- },
- success: function (data) {
- CommitsList.lastSearch = search;
- CommitsList.content.html(data.html);
- return history.replaceState({
- page: commitsUrl,
+ const search = this.searchField.val();
+ if (search === this.lastSearch) return Promise.resolve();
+ const commitsUrl = `${form.attr('action')}?${form.serialize()}`;
+ this.content.fadeTo('fast', 0.5);
+ const params = form.serializeArray().reduce((acc, obj) => Object.assign(acc, {
+ [obj.name]: obj.value,
+ }), {});
+
+ return axios.get(form.attr('action'), {
+ params,
+ })
+ .then(({ data }) => {
+ this.lastSearch = search;
+ this.content.html(data.html);
+ this.content.fadeTo('fast', 1.0);
+
// Change url so if user reload a page - search results are saved
+ history.replaceState({
+ page: commitsUrl,
}, document.title, commitsUrl);
- },
- error: function () {
- CommitsList.lastSearch = null;
- },
- dataType: 'json',
- });
- };
+ })
+ .catch(() => {
+ this.content.fadeTo('fast', 1.0);
+ this.lastSearch = null;
+ });
+ }
// Prepare loaded data.
- CommitsList.processCommits = (data) => {
+ processCommits(data) {
let processedData = data;
const $processedData = $(processedData);
- const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last();
+ const $commitsHeadersLast = this.$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');
@@ -95,7 +82,5 @@ export default (function () {
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 c11b7d5f340..db96da4ccba 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -13,6 +13,6 @@ import 'bootstrap-sass/assets/javascripts/bootstrap/popover';
// custom jQuery functions
$.fn.extend({
- disable() { return $(this).attr('disabled', 'disabled').addClass('disabled'); },
- enable() { return $(this).removeAttr('disabled').removeClass('disabled'); },
+ disable() { return $(this).prop('disabled', true).addClass('disabled'); },
+ enable() { return $(this).prop('disabled', false).removeClass('disabled'); },
});
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index b93e94a3c97..a7ed175f7a4 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -6,5 +6,5 @@ import 'vendor/jquery.endless-scroll';
import 'vendor/jquery.caret';
import 'vendor/jquery.atwho';
import 'vendor/jquery.scrollTo';
-import 'vendor/jquery.waitforimages';
+import 'jquery.waitforimages';
import 'select2/select2';
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index ff9e4485916..d62d3c23654 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -1,4 +1,5 @@
// ECMAScript polyfills
+import 'core-js/fn/array/fill';
import 'core-js/fn/array/find';
import 'core-js/fn/array/find-index';
import 'core-js/fn/array/from';
@@ -8,6 +9,8 @@ import 'core-js/fn/promise';
import 'core-js/fn/string/code-point-at';
import 'core-js/fn/string/from-code-point';
import 'core-js/fn/symbol';
+import 'core-js/es6/map';
+import 'core-js/es6/weak-map';
// Browser polyfills
import 'classlist-polyfill';
diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js
index 9a1f73bf2ac..b593bde6aa2 100644
--- a/app/assets/javascripts/commons/polyfills/element.js
+++ b/app/assets/javascripts/commons/polyfills/element.js
@@ -18,3 +18,22 @@ Element.prototype.matches = Element.prototype.matches ||
while (i >= 0 && elms.item(i) !== this) { i -= 1; }
return i > -1;
};
+
+// From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill
+((arr) => {
+ arr.forEach((item) => {
+ if (Object.prototype.hasOwnProperty.call(item, 'remove')) {
+ return;
+ }
+ Object.defineProperty(item, 'remove', {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ value: function remove() {
+ if (this.parentNode !== null) {
+ this.parentNode.removeChild(this);
+ }
+ },
+ });
+ });
+})([Element.prototype, CharacterData.prototype, DocumentType.prototype]);
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index 8b62d78c043..798623b94fb 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import '../vue_shared/vue_resource_interceptor';
if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false;
diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js
index 144caf1d278..303a5bf4a53 100644
--- a/app/assets/javascripts/compare.js
+++ b/app/assets/javascripts/compare.js
@@ -1,5 +1,8 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */
+
+import $ from 'jquery';
import { localTimeAgo } from './lib/utils/datetime_utility';
+import axios from './lib/utils/axios_utils';
export default class Compare {
constructor(opts) {
@@ -12,7 +15,7 @@ export default class Compare {
$dropdown = $(dropdown);
return $dropdown.glDropdown({
selectable: true,
- fieldName: $dropdown.data('field-name'),
+ fieldName: $dropdown.data('fieldName'),
filterable: true,
id: function(obj, $el) {
return $el.data('id');
@@ -41,17 +44,14 @@ export default class Compare {
}
getTargetProject() {
- return $.ajax({
- url: this.opts.targetProjectUrl,
- data: {
- target_project_id: $("input[name='merge_request[target_project_id]']").val()
- },
- beforeSend: function() {
- return $('.mr_target_commit').empty();
+ $('.mr_target_commit').empty();
+
+ return axios.get(this.opts.targetProjectUrl, {
+ params: {
+ target_project_id: $("input[name='merge_request[target_project_id]']").val(),
},
- success: function(html) {
- return $('.js-target-branch-dropdown .dropdown-content').html(html);
- }
+ }).then(({ data }) => {
+ $('.js-target-branch-dropdown .dropdown-content').html(data);
});
}
@@ -68,22 +68,19 @@ export default class Compare {
});
}
- static sendAjax(url, loading, target, data) {
- var $target;
- $target = $(target);
- return $.ajax({
- url: url,
- data: data,
- beforeSend: function() {
- loading.show();
- return $target.empty();
- },
- success: function(html) {
- loading.hide();
- $target.html(html);
- var className = '.' + $target[0].className.replace(' ', '.');
- localTimeAgo($('.js-timeago', className));
- }
+ static sendAjax(url, loading, target, params) {
+ const $target = $(target);
+
+ loading.show();
+ $target.empty();
+
+ return axios.get(url, {
+ params,
+ }).then(({ data }) => {
+ loading.hide();
+ $target.html(data);
+ const className = '.' + $target[0].className.replace(' ', '.');
+ localTimeAgo($('.js-timeago', className));
});
}
}
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index e633ef8a29e..260c91cac24 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -1,29 +1,33 @@
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, object-shorthand, comma-dangle, prefer-arrow-callback, no-else-return, newline-per-chained-call, wrap-iife, max-len */
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+
export default function initCompareAutocomplete() {
$('.js-compare-dropdown').each(function() {
var $dropdown, selected;
$dropdown = $(this);
selected = $dropdown.data('selected');
const $dropdownContainer = $dropdown.closest('.dropdown');
- const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer);
+ const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer);
const $filterInput = $('input[type="search"]', $dropdownContainer);
$dropdown.glDropdown({
data: function(term, callback) {
- return $.ajax({
- url: $dropdown.data('refs-url'),
- data: {
+ axios.get($dropdown.data('refsUrl'), {
+ params: {
ref: $dropdown.data('ref'),
search: term,
- }
- }).done(function(refs) {
- return callback(refs);
- });
+ },
+ }).then(({ data }) => {
+ callback(data);
+ }).catch(() => flash(__('Error fetching refs')));
},
selectable: true,
filterable: true,
filterRemote: true,
- fieldName: $dropdown.data('field-name'),
+ fieldName: $dropdown.data('fieldName'),
filterInput: 'input[type="search"]',
renderRow: function(ref) {
var link;
diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js
index eae4a7eab55..1638e09132b 100644
--- a/app/assets/javascripts/confirm_danger_modal.js
+++ b/app/assets/javascripts/confirm_danger_modal.js
@@ -1,31 +1,32 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, camelcase, one-var-declaration-per-line, no-else-return, max-len */
+import $ from 'jquery';
import { rstrip } from './lib/utils/common_utils';
-window.ConfirmDangerModal = (function() {
- function ConfirmDangerModal(form, text) {
- var project_path, submit;
- this.form = form;
- $('.js-confirm-text').text(text || '');
- $('.js-confirm-danger-input').val('');
- $('#modal-confirm-danger').modal('show');
- project_path = $('.js-confirm-danger-match').text();
- submit = $('.js-confirm-danger-submit');
- submit.disable();
- $('.js-confirm-danger-input').off('input');
- $('.js-confirm-danger-input').on('input', function() {
- if (rstrip($(this).val()) === project_path) {
- return submit.enable();
- } else {
- return submit.disable();
- }
- });
- $('.js-confirm-danger-submit').off('click');
- $('.js-confirm-danger-submit').on('click', (function(_this) {
- return function() {
- return _this.form.submit();
- };
- })(this));
- }
+function openConfirmDangerModal($form, text) {
+ $('.js-confirm-text').text(text || '');
+ $('.js-confirm-danger-input').val('');
+ $('#modal-confirm-danger').modal('show');
- return ConfirmDangerModal;
-})();
+ const confirmTextMatch = $('.js-confirm-danger-match').text();
+ const $submit = $('.js-confirm-danger-submit');
+ $submit.disable();
+
+ $('.js-confirm-danger-input').off('input').on('input', function handleInput() {
+ const confirmText = rstrip($(this).val());
+ if (confirmText === confirmTextMatch) {
+ $submit.enable();
+ } else {
+ $submit.disable();
+ }
+ });
+ $('.js-confirm-danger-submit').off('click').on('click', () => $form.submit());
+}
+
+export default function initConfirmDangerModal() {
+ $(document).on('click', '.js-confirm-danger', (e) => {
+ e.preventDefault();
+ const $btn = $(e.target);
+ const $form = $btn.closest('form');
+ const text = $btn.data('confirmDangerMessage');
+ openConfirmDangerModal($form, text);
+ });
+}
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 74520675a7c..3a50e73ad85 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Cookies from 'js-cookie';
import _ from 'underscore';
import bp from './breakpoints';
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
new file mode 100644
index 00000000000..42e9e568170
--- /dev/null
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -0,0 +1,119 @@
+import _ from 'underscore';
+
+export default class CreateItemDropdown {
+ /**
+ * @param {Object} options containing
+ * `$dropdown` target element
+ * `onSelect` event callback
+ * $dropdown must be an element created using `dropdown_tag()` rails helper
+ */
+ constructor(options) {
+ this.defaultToggleLabel = options.defaultToggleLabel;
+ this.fieldName = options.fieldName;
+ this.onSelect = options.onSelect || (() => {});
+ this.getDataOption = options.getData;
+ this.createNewItemFromValueOption = options.createNewItemFromValue;
+ this.$dropdown = options.$dropdown;
+ this.$dropdownContainer = this.$dropdown.parent();
+ this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
+ this.$createButton = this.$dropdownContainer.find('.js-dropdown-create-new-item');
+
+ this.buildDropdown();
+ this.bindEvents();
+
+ // Hide footer
+ this.toggleFooter(true);
+ }
+
+ buildDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.getData.bind(this),
+ filterable: true,
+ remote: false,
+ search: {
+ fields: ['text'],
+ },
+ selectable: true,
+ toggleLabel(selected) {
+ return (selected && 'id' in selected) ? _.escape(selected.title) : this.defaultToggleLabel;
+ },
+ fieldName: this.fieldName,
+ text(item) {
+ return _.escape(item.text);
+ },
+ id(item) {
+ return _.escape(item.id);
+ },
+ onFilter: this.toggleCreateNewButton.bind(this),
+ clicked: (options) => {
+ options.e.preventDefault();
+ this.onSelect();
+ },
+ });
+ }
+
+ clearDropdown() {
+ this.$dropdownContainer.find('.dropdown-content').html('');
+ this.$dropdownContainer.find('.dropdown-input-field').val('');
+ }
+
+ bindEvents() {
+ this.$createButton.on('click', this.onClickCreateWildcard.bind(this));
+ }
+
+ onClickCreateWildcard(e) {
+ e.preventDefault();
+
+ this.refreshData();
+ this.$dropdown.data('glDropdown').selectRowAtIndex();
+ }
+
+ refreshData() {
+ // Refresh the dropdown's data, which ends up calling `getData`
+ this.$dropdown.data('glDropdown').remote.execute();
+ }
+
+ getData(term, callback) {
+ this.getDataOption(term, (data = []) => {
+ // Ensure the selected item isn't already in the data to avoid duplicates
+ const alreadyHasSelectedItem = this.selectedItem && data.some(item =>
+ item.id === this.selectedItem.id,
+ );
+
+ let uniqueData = data;
+ if (!alreadyHasSelectedItem) {
+ uniqueData = data.concat(this.selectedItem || []);
+ }
+
+ callback(uniqueData);
+ });
+ }
+
+ createNewItemFromValue(newValue) {
+ if (this.createNewItemFromValueOption) {
+ return this.createNewItemFromValueOption(newValue);
+ }
+
+ return {
+ title: newValue,
+ id: newValue,
+ text: newValue,
+ };
+ }
+
+ toggleCreateNewButton(newValue) {
+ if (newValue) {
+ this.selectedItem = this.createNewItemFromValue(newValue);
+
+ this.$dropdownContainer
+ .find('.js-dropdown-create-new-item code')
+ .text(newValue);
+ }
+
+ this.toggleFooter(!newValue);
+ }
+
+ toggleFooter(toggleState) {
+ this.$dropdownFooter.toggleClass('hidden', toggleState);
+ }
+}
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 9a4c9bfcc80..a999c21b2e9 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,4 +1,6 @@
/* eslint-disable func-names, prefer-arrow-callback */
+
+import $ from 'jquery';
import Api from './api';
import { humanize } from './lib/utils/text_utility';
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 23425672b16..fb1fc9cd32e 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -1,4 +1,6 @@
/* eslint-disable no-new */
+import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
import Flash from './flash';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
@@ -73,60 +75,52 @@ export default class CreateMergeRequestDropdown {
}
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();
+ this.setUnavailableButtonState();
+
+ axios.get(this.canCreatePath)
+ .then(({ 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();
}
- } 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.');
- });
+ })
+ .catch(() => {
+ this.unavailable();
+ this.disable();
+ Flash('Failed to check if a new branch can be created.');
+ });
}
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.'));
+ this.isCreatingBranch = true;
+
+ return axios.post(this.createBranchPath)
+ .then(({ data }) => {
+ this.branchCreated = true;
+ window.location.href = data.url;
+ })
+ .catch(() => Flash('Failed to create a branch for this issue. Please try again.'));
}
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.'));
+ this.isCreatingMergeRequest = true;
+
+ return axios.post(this.createMrPath)
+ .then(({ data }) => {
+ this.mergeRequestCreated = true;
+ window.location.href = data.url;
+ })
+ .catch(() => Flash('Failed to create Merge Request. Please try again.'));
}
disable() {
@@ -186,6 +180,7 @@ export default class CreateMergeRequestDropdown {
valueAttribute: 'data-text',
},
],
+ hideOnClick: false,
};
}
@@ -199,39 +194,33 @@ export default class CreateMergeRequestDropdown {
getRef(ref, target = 'all') {
if (!ref) return false;
- return $.ajax({
- method: 'GET',
- dataType: 'json',
- url: this.refsPath + ref,
- beforeSend: () => {
- this.isGettingRef = true;
- },
- })
- .always(() => {
- this.isGettingRef = false;
- })
- .done((data) => {
- const branches = data[Object.keys(data)[0]];
- const tags = data[Object.keys(data)[1]];
- let result;
+ return axios.get(this.refsPath + ref)
+ .then(({ data }) => {
+ const branches = data[Object.keys(data)[0]];
+ const tags = data[Object.keys(data)[1]];
+ let result;
+
+ if (target === 'branch') {
+ result = CreateMergeRequestDropdown.findByValue(branches, ref);
+ } else {
+ result = CreateMergeRequestDropdown.findByValue(branches, ref, true) ||
+ CreateMergeRequestDropdown.findByValue(tags, ref, true);
+ this.suggestedRef = result;
+ }
- if (target === 'branch') {
- result = CreateMergeRequestDropdown.findByValue(branches, ref);
- } else {
- result = CreateMergeRequestDropdown.findByValue(branches, ref, true) ||
- CreateMergeRequestDropdown.findByValue(tags, ref, true);
- this.suggestedRef = result;
- }
+ this.isGettingRef = false;
- return this.updateInputState(target, ref, result);
- })
- .fail(() => {
- this.unavailable();
- this.disable();
- new Flash('Failed to get ref.');
+ return this.updateInputState(target, ref, result);
+ })
+ .catch(() => {
+ this.unavailable();
+ this.disable();
+ new Flash('Failed to get ref.');
- return false;
- });
+ this.isGettingRef = false;
+
+ return false;
+ });
}
getTargetData(target) {
@@ -276,13 +265,13 @@ export default class CreateMergeRequestDropdown {
let target;
let value;
- if (event.srcElement === this.branchInput) {
+ if (event.target === this.branchInput) {
target = 'branch';
value = this.branchInput.value;
- } else if (event.srcElement === this.refInput) {
+ } else if (event.target === this.refInput) {
target = 'ref';
- value = event.srcElement.value.slice(0, event.srcElement.selectionStart) +
- event.srcElement.value.slice(event.srcElement.selectionEnd);
+ value = event.target.value.slice(0, event.target.selectionStart) +
+ event.target.value.slice(event.target.selectionEnd);
} else {
return false;
}
@@ -331,12 +320,12 @@ export default class CreateMergeRequestDropdown {
xhr = this.createBranch();
}
- xhr.fail(() => {
+ xhr.catch(() => {
this.isCreatingMergeRequest = false;
this.isCreatingBranch = false;
- });
- xhr.always(() => this.enable());
+ this.enable();
+ });
this.disable();
}
diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue
index 732697c134e..3204b8dd8e7 100644
--- a/app/assets/javascripts/cycle_analytics/components/banner.vue
+++ b/app/assets/javascripts/cycle_analytics/components/banner.vue
@@ -26,28 +26,34 @@
class="js-ca-dismiss-button dismiss-button"
type="button"
:aria-label="__('Dismiss Cycle Analytics introduction box')"
- @click="dismissOverviewDialog">
+ @click="dismissOverviewDialog"
+ >
<i
class="fa fa-times"
aria-hidden="true">
</i>
</button>
- <div class="svg-container" v-html="iconCycleAnalyticsSplash">
+ <div
+ class="svg-container"
+ v-html="iconCycleAnalyticsSplash"
+ >
</div>
<div class="inner-content">
<h4>
- {{__('Introducing Cycle Analytics')}}
+ {{ __('Introducing Cycle Analytics') }}
</h4>
<p>
- {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }}
+ {{ __(`Cycle Analytics gives an overview
+of how much time it takes to go from idea to production in your project.`) }}
</p>
<p>
<a
:href="documentationLink"
target="_blank"
rel="nofollow"
- class="btn">
- {{__('Read more')}}
+ class="btn"
+ >
+ {{ __('Read more') }}
</a>
</p>
</div>
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
index 6e94ba929b2..32ae0cc1476 100644
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
@@ -2,25 +2,34 @@
import tooltip from '../../vue_shared/directives/tooltip';
export default {
+ directives: {
+ tooltip,
+ },
props: {
count: {
type: Number,
required: true,
},
},
- directives: {
- tooltip,
- },
};
</script>
<template>
- <span v-if="count === 50" class="events-info pull-right">
+ <span
+ v-if="count === 50"
+ class="events-info pull-right"
+ >
<i
class="fa fa-warning"
v-tooltip
aria-hidden="true"
- :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)"
- data-placement="top"></i>
+ :title="n__(
+ 'Limited to showing %d event at most',
+ 'Limited to showing %d events at most',
+ 50
+ )"
+ data-placement="top"
+ >
+ </i>
{{ n__('Showing %d event', 'Showing %d events', 50) }}
</span>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue
index 45930145b0a..a71dcf78103 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue
@@ -4,15 +4,21 @@
import totalTime from './total_time_component.vue';
export default {
- props: {
- items: Array,
- stage: Object,
- },
components: {
userAvatarImage,
limitWarning,
totalTime,
},
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
};
</script>
<template>
@@ -22,28 +28,44 @@
<limit-warning :count="items.length" />
</div>
<ul class="stage-event-list">
- <li v-for="mergeRequest in items" class="stage-event-item">
+ <li
+ v-for="(mergeRequest, i) in items"
+ :key="i"
+ class="stage-event-item"
+ >
<div class="item-details">
<!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl" />
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
</a>
</h5>
- <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
+ <a
+ :href="mergeRequest.url"
+ class="issue-link">
+ !{{ mergeRequest.iid }}
+ </a>
&middot;
<span>
{{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
+ <a
+ :href="mergeRequest.url"
+ class="issue-date">
+ {{ mergeRequest.createdAt }}
+ </a>
</span>
<span>
{{ s__('ByAuthor|by') }}
- <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
+ <a
+ :href="mergeRequest.author.webUrl"
+ class="issue-author-link">
+ {{ mergeRequest.author.name }}
+ </a>
</span>
</div>
<div class="item-time">
- <total-time :time="mergeRequest.totalTime"></total-time>
+ <total-time :time="mergeRequest.totalTime" />
</div>
</li>
</ul>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_component.vue
index 8c98bd249a1..907638d798a 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_component.vue
@@ -4,15 +4,21 @@
import totalTime from './total_time_component.vue';
export default {
- props: {
- items: Array,
- stage: Object,
- },
components: {
userAvatarImage,
limitWarning,
totalTime,
},
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
};
</script>
<template>
@@ -25,30 +31,43 @@
<li
v-for="(issue, i) in items"
:key="i"
- class="stage-event-item">
+ class="stage-event-item"
+ >
<div class="item-details">
<!-- 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">
+ <a
+ class="issue-title"
+ :href="issue.url"
+ >
{{ issue.title }}
</a>
</h5>
- <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
+ <a
+ :href="issue.url"
+ class="issue-link"
+ >#{{ issue.iid }}</a>
&middot;
<span>
{{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
+ <a
+ :href="issue.url"
+ class="issue-date"
+ >{{ issue.createdAt }}</a>
</span>
<span>
{{ s__('ByAuthor|by') }}
- <a :href="issue.author.webUrl" class="issue-author-link">
+ <a
+ :href="issue.author.webUrl"
+ class="issue-author-link"
+ >
{{ issue.author.name }}
</a>
</span>
</div>
<div class="item-time">
- <total-time :time="issue.totalTime"/>
+ <total-time :time="issue.totalTime" />
</div>
</li>
</ul>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue
index 75d2f1fd70c..cee294b4ac2 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue
@@ -5,15 +5,21 @@
import totalTime from './total_time_component.vue';
export default {
- props: {
- items: Array,
- stage: Object,
- },
components: {
userAvatarImage,
totalTime,
limitWarning,
},
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
computed: {
iconCommit() {
return iconCommit;
@@ -31,10 +37,11 @@
<li
v-for="(commit, i) in items"
:key="i"
- class="stage-event-item">
+ class="stage-event-item"
+ >
<div class="item-details item-conmmit-component">
<!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image :img-src="commit.author.avatarUrl"/>
+ <user-avatar-image :img-src="commit.author.avatarUrl" />
<h5 class="item-title commit-title">
<a :href="commit.commitUrl">
{{ commit.title }}
@@ -42,10 +49,20 @@
</h5>
<span>
{{ s__('FirstPushedBy|First') }}
- <span class="commit-icon" v-html="iconCommit"></span>
- <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a>
+ <span
+ class="commit-icon"
+ v-html="iconCommit"
+ >
+ </span>
+ <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">
+ <a
+ :href="commit.author.webUrl"
+ class="commit-author-link"
+ >
{{ commit.author.name }}
</a>
</span>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue
index f54ea7df522..34aa04083e6 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue
@@ -2,16 +2,24 @@
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import limitWarning from './limit_warning_component.vue';
import totalTime from './total_time_component.vue';
+ import icon from '../../vue_shared/components/icon.vue';
export default {
- props: {
- items: Array,
- stage: Object,
- },
components: {
userAvatarImage,
totalTime,
limitWarning,
+ icon,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
},
};
</script>
@@ -25,40 +33,62 @@
<li
v-for="(mergeRequest, i) in items"
:key="i"
- class="stage-event-item">
+ class="stage-event-item"
+ >
<div class="item-details">
<!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
+ <user-avatar-image :img-src="mergeRequest.author.avatarUrl" />
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
</a>
</h5>
- <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
+ <a
+ :href="mergeRequest.url"
+ class="issue-link"
+ >!{{ mergeRequest.iid }}</a>
&middot;
<span>
{{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
+ <a
+ :href="mergeRequest.url"
+ class="issue-date"
+ >{{ mergeRequest.createdAt }}</a>
</span>
<span>
{{ s__('ByAuthor|by') }}
- <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
+ <a
+ :href="mergeRequest.author.webUrl"
+ class="issue-author-link"
+ >{{ mergeRequest.author.name }}</a>
</span>
<template v-if="mergeRequest.state === 'closed'">
<span class="merge-request-state">
- <i class="fa fa-ban"></i>
+ <i
+ class="fa fa-ban"
+ aria-hidden="true"
+ >
+ </i>
{{ mergeRequest.state.toUpperCase() }}
</span>
</template>
<template v-else>
- <span class="merge-request-branch" v-if="mergeRequest.branch">
- <i class= "fa fa-code-fork"></i>
- <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a>
+ <span
+ class="merge-request-branch"
+ v-if="mergeRequest.branch"
+ >
+ <icon
+ name="fork"
+ :size="16"
+ />
+ <a :href="mergeRequest.branch.url">
+ {{ mergeRequest.branch.name }}
+ </a>
</span>
</template>
</div>
<div class="item-time">
- <total-time :time="mergeRequest.totalTime"/>
+ <total-time :time="mergeRequest.totalTime" />
</div>
</li>
</ul>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
index 5d95ddcd90e..92f2a95a66a 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
@@ -3,16 +3,24 @@
import iconBranch from '../svg/icon_branch.svg';
import limitWarning from './limit_warning_component.vue';
import totalTime from './total_time_component.vue';
+ import icon from '../../vue_shared/components/icon.vue';
export default {
- props: {
- items: Array,
- stage: Object,
- },
components: {
userAvatarImage,
totalTime,
limitWarning,
+ icon,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
},
computed: {
iconBranch() {
@@ -31,27 +39,58 @@
<li
v-for="(build, i) in items"
class="stage-event-item item-build-component"
- :key="i">
+ :key="i"
+ >
<div class="item-details">
<!-- 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="ref-name">{{ build.branch.name }}</a>
- <span class="icon-branch" v-html="iconBranch"></span>
- <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
+ <a
+ :href="build.url"
+ class="pipeline-id"
+ >
+ #{{ build.id }}
+ </a>
+ <icon
+ name="fork"
+ :size="16"
+ />
+ <a
+ :href="build.branch.url"
+ class="ref-name"
+ >
+ {{ build.branch.name }}
+ </a>
+ <span
+ class="icon-branch"
+ v-html="iconBranch"
+ >
+ </span>
+ <a
+ :href="build.commitUrl"
+ class="commit-sha"
+ >
+ {{ build.shortSha }}
+ </a>
</h5>
<span>
- <a :href="build.url" class="build-date">{{ build.date }}</a>
+ <a
+ :href="build.url"
+ class="build-date"
+ >
+ {{ build.date }}
+ </a>
{{ s__('ByAuthor|by') }}
- <a :href="build.author.webUrl" class="issue-author-link">
+ <a
+ :href="build.author.webUrl"
+ class="issue-author-link"
+ >
{{ build.author.name }}
</a>
</span>
</div>
<div class="item-time">
- <total-time :time="build.totalTime"/>
+ <total-time :time="build.totalTime" />
</div>
</li>
</ul>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
index 04d5440b77b..b84bb6ed792 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
@@ -3,15 +3,23 @@
import iconBranch from '../svg/icon_branch.svg';
import limitWarning from './limit_warning_component.vue';
import totalTime from './total_time_component.vue';
+ import icon from '../../vue_shared/components/icon.vue';
export default {
- props: {
- items: Array,
- stage: Object,
- },
components: {
totalTime,
limitWarning,
+ icon,
+ },
+ props: {
+ items: {
+ type: Array,
+ default: () => [],
+ },
+ stage: {
+ type: Object,
+ default: () => ({}),
+ },
},
computed: {
iconBuildStatus() {
@@ -33,26 +41,59 @@
<li
v-for="(build, i) in items"
:key="i"
- class="stage-event-item item-build-component">
+ class="stage-event-item item-build-component"
+ >
<div class="item-details">
<h5 class="item-title">
- <span class="icon-build-status" v-html="iconBuildStatus"></span>
- <a :href="build.url" class="item-build-name">{{ build.name }}</a>
+ <span
+ class="icon-build-status"
+ v-html="iconBuildStatus"
+ >
+ </span>
+ <a
+ :href="build.url"
+ class="item-build-name"
+ >
+ {{ build.name }}
+ </a>
&middot;
- <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
- <i class="fa fa-code-fork"></i>
- <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a>
- <span class="icon-branch" v-html="iconBranch"></span>
- <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a>
+ <a
+ :href="build.url"
+ class="pipeline-id"
+ >
+ #{{ build.id }}
+ </a>
+ <icon
+ name="fork"
+ :size="16"
+ />
+ <a
+ :href="build.branch.url"
+ class="ref-name"
+ >
+ {{ build.branch.name }}
+ </a>
+ <span
+ class="icon-branch"
+ v-html="iconBranch"
+ >
+ </span>
+ <a
+ :href="build.commitUrl"
+ class="commit-sha">
+ {{ build.shortSha }}
+ </a>
</h5>
<span>
- <a :href="build.url" class="issue-date">
+ <a
+ :href="build.url"
+ class="issue-date">
{{ build.date }}
</a>
</span>
</div>
<div class="item-time">
- <total-time :time="build.totalTime"/>
+ <total-time :time="build.totalTime" />
</div>
</li>
</ul>
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
index 62efd4f9c28..7758bf0cb3f 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
@@ -17,13 +17,33 @@
<template>
<span class="total-time">
<template v-if="hasData">
- <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 && hasData === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|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 && hasData === 1 || time.seconds === 0">
+ {{ time.seconds }}
+ <span>
+ {{ s__('Time|s') }}
+ </span>
+ </template>
</template>
<template v-else>
--
</template>
- </span>
+ </span>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 49bb6c52180..87f8854f940 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
import Flash from '../flash';
@@ -14,12 +15,22 @@ import CycleAnalyticsStore from './cycle_analytics_store';
Vue.use(Translate);
-$(() => {
+export default () => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
- gl.cycleAnalyticsApp = new Vue({
+ new Vue({ // eslint-disable-line no-new
el: '#cycle-analytics',
name: 'CycleAnalytics',
+ components: {
+ banner,
+ 'stage-issue-component': stageComponent,
+ 'stage-plan-component': stagePlanComponent,
+ 'stage-code-component': stageCodeComponent,
+ 'stage-test-component': stageTestComponent,
+ 'stage-review-component': stageReviewComponent,
+ 'stage-staging-component': stageStagingComponent,
+ 'stage-production-component': stageComponent,
+ },
data() {
const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
const cycleAnalyticsService = new CycleAnalyticsService({
@@ -43,16 +54,6 @@ $(() => {
return this.store.currentActiveStage();
},
},
- components: {
- banner,
- 'stage-issue-component': stageComponent,
- 'stage-plan-component': stagePlanComponent,
- 'stage-code-component': stageCodeComponent,
- 'stage-test-component': stageTestComponent,
- 'stage-review-component': stageReviewComponent,
- 'stage-staging-component': stageStagingComponent,
- 'stage-production-component': stageComponent,
- },
created() {
this.fetchCycleAnalyticsData();
},
@@ -132,4 +133,4 @@ $(() => {
},
},
});
-});
+};
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
index f9f2f9bf693..b839b9f286f 100644
--- a/app/assets/javascripts/deploy_keys/components/action_btn.vue
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -3,10 +3,8 @@
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
- data() {
- return {
- isLoading: false,
- };
+ components: {
+ loadingIcon,
},
props: {
deployKey: {
@@ -23,11 +21,16 @@
default: 'btn-default',
},
},
-
- components: {
- loadingIcon,
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ computed: {
+ text() {
+ return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
+ },
},
-
methods: {
doAction() {
this.isLoading = true;
@@ -37,11 +40,6 @@
});
},
},
- computed: {
- text() {
- return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
- },
- },
};
</script>
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index fe046449054..5a782237b7d 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -7,11 +7,9 @@
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
- data() {
- return {
- isLoading: false,
- store: new DeployKeysStore(),
- };
+ components: {
+ keysPanel,
+ loadingIcon,
},
props: {
endpoint: {
@@ -19,6 +17,12 @@
required: true,
},
},
+ data() {
+ return {
+ isLoading: false,
+ store: new DeployKeysStore(),
+ };
+ },
computed: {
hasKeys() {
return Object.keys(this.keys).length;
@@ -27,9 +31,20 @@
return this.store.keys;
},
},
- components: {
- keysPanel,
- loadingIcon,
+ 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);
},
methods: {
fetchKeys() {
@@ -59,21 +74,6 @@
}
},
},
- 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>
@@ -87,6 +87,7 @@
<div v-else-if="hasKeys">
<keys-panel
title="Enabled deploy keys for this project"
+ class="qa-project-deploy-keys"
:keys="keys.enabled_keys"
:store="store"
:endpoint="endpoint"
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 2a05c6f001e..c6091efd62f 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -1,8 +1,15 @@
<script>
import actionBtn from './action_btn.vue';
import { getTimeago } from '../../lib/utils/datetime_utility';
+ import tooltip from '../../vue_shared/directives/tooltip';
export default {
+ components: {
+ actionBtn,
+ },
+ directives: {
+ tooltip,
+ },
props: {
deployKey: {
type: Object,
@@ -17,9 +24,6 @@
required: true,
},
},
- components: {
- actionBtn,
- },
computed: {
timeagoDate() {
return getTimeago().format(this.deployKey.created_at);
@@ -32,6 +36,9 @@
isEnabled(id) {
return this.store.findEnabledKey(id) !== undefined;
},
+ tooltipTitle(project) {
+ return project.can_push ? 'Write access allowed' : 'Read access only';
+ },
},
};
</script>
@@ -46,26 +53,29 @@
</i>
</div>
<div class="deploy-key-content key-list-item-info">
- <strong class="title">
+ <strong class="title qa-key-title">
{{ deployKey.title }}
</strong>
- <div class="description">
+ <div class="description qa-key-fingerprint">
{{ 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"
+ v-for="(deployKeysProject, i) in deployKey.deploy_keys_projects"
+ :key="i"
class="label deploy-project-label"
- :href="project.full_path"
+ :href="deployKeysProject.project.full_path"
+ :title="tooltipTitle(deployKeysProject)"
+ v-tooltip
>
- {{ project.full_name }}
+ {{ deployKeysProject.project.full_name }}
+ <i
+ v-if="!deployKeysProject.can_push"
+ aria-hidden="true"
+ class="fa fa-lock"
+ >
+ </i>
</a>
</div>
<div class="deploy-key-content">
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index 9e6fb244af6..822b0323156 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -2,6 +2,9 @@
import key from './key.vue';
export default {
+ components: {
+ key,
+ },
props: {
title: {
type: String,
@@ -25,9 +28,6 @@
required: true,
},
},
- components: {
- key,
- },
};
</script>
@@ -37,12 +37,14 @@
{{ title }}
({{ keys.length }})
</h5>
- <ul class="well-list"
+ <ul
+ class="well-list"
v-if="keys.length"
>
<li
v-for="deployKey in keys"
- :key="deployKey.id">
+ :key="deployKey.id"
+ >
<key
:deploy-key="deployKey"
:store="store"
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
index a5f232f950a..b727261648c 100644
--- a/app/assets/javascripts/deploy_keys/index.js
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -1,16 +1,16 @@
import Vue from 'vue';
import deployKeysApp from './components/app.vue';
-document.addEventListener('DOMContentLoaded', () => new Vue({
+export default () => new Vue({
el: document.getElementById('js-deploy-keys'),
+ components: {
+ deployKeysApp,
+ },
data() {
return {
endpoint: this.$options.el.dataset.endpoint,
};
},
- components: {
- deployKeysApp,
- },
render(createElement) {
return createElement('deploy-keys-app', {
props: {
@@ -18,4 +18,4 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
},
});
},
-}));
+});
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index a162424b3cf..a044fc1ab42 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,3 +1,7 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
import { getLocationHash } from './lib/utils/url_utility';
import FilesCommentButton from './files_comment_button';
import SingleFileDiff from './single_file_diff';
@@ -65,11 +69,13 @@ export default class Diff {
}
const file = $target.parents('.diff-file');
- const link = file.data('blob-diff-path');
+ const link = file.data('blobDiffPath');
const view = file.data('view');
const params = { since, to, bottom, offset, unfold, view };
- $.get(link, params, response => $target.parent().replaceWith(response));
+ axios.get(link, { params })
+ .then(({ data }) => $target.parent().replaceWith(data))
+ .catch(() => flash(__('An error occurred while loading diff')));
}
openAnchoredDiff(cb) {
@@ -116,7 +122,7 @@ export default class Diff {
}
// eslint-disable-next-line class-methods-use-this
diffViewType() {
- return $('.inline-parallel-buttons a.active').data('view-type');
+ return $('.inline-parallel-buttons a.active').data('viewType');
}
// eslint-disable-next-line class-methods-use-this
lineNumbers(line) {
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
index aed7cac4e62..d1260ff5373 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -1,6 +1,7 @@
/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, quotes, no-lonely-if, max-len */
/* global CommentsStore */
+import $ from 'jquery';
import Vue from 'vue';
const CommentAndResolveBtn = Vue.extend({
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 300b02da663..180a6bd67e7 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -1,5 +1,6 @@
/* global CommentsStore */
+import $ from 'jquery';
import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
import Notes from '../../notes';
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 e77910a83d4..8f9186dfb9a 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -2,6 +2,7 @@
/* global DiscussionMixins */
/* global CommentsStore */
+import $ from 'jquery';
import Vue from 'vue';
import '../mixins/discussion';
@@ -197,7 +198,7 @@ const JumpToDiscussion = Vue.extend({
}
$.scrollTo($target, {
- offset: 0
+ offset: -150
});
}
},
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
index 20ddcbfb8bd..df4c72ba0ed 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -2,6 +2,7 @@
/* global CommentsStore */
/* global ResolveService */
+import $ from 'jquery';
import Vue from 'vue';
import Flash from '../../flash';
@@ -87,6 +88,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip();
})
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index e0422057090..e17daec6a92 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, comma-dangle, new-cap, no-new, max-len */
/* global ResolveCount */
+import $ from 'jquery';
import Vue from 'vue';
import './models/discussion';
import './models/note';
@@ -14,8 +15,9 @@ import './components/resolve_count';
import './components/resolve_discussion_btn';
import './components/diff_note_avatars';
import './components/new_issue_for_discussion';
+import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
-$(() => {
+export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
const projectPath = projectPathHolder.dataset.projectPath;
const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
@@ -67,12 +69,14 @@ $(() => {
gl.diffNotesCompileComponents();
- new Vue({
- el: '#resolve-count-app',
- components: {
- 'resolve-count': ResolveCount
- }
- });
+ if (!hasVueMRDiscussionsCookie()) {
+ new Vue({
+ el: '#resolve-count-app',
+ components: {
+ 'resolve-count': ResolveCount
+ },
+ });
+ }
$(window).trigger('resize.nav');
-});
+};
diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js
index 1b8a9af9390..c97c559dd14 100644
--- a/app/assets/javascripts/diff_notes/models/discussion.js
+++ b/app/assets/javascripts/diff_notes/models/discussion.js
@@ -1,6 +1,7 @@
/* eslint-disable space-before-function-paren, camelcase, guard-for-in, no-restricted-syntax, no-unused-vars, max-len */
/* global NoteModel */
+import $ from 'jquery';
import Vue from 'vue';
import { localTimeAgo } from '../../lib/utils/datetime_utility';
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index 96fe23640af..d16f9297de1 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -8,8 +8,8 @@ window.gl = window.gl || {};
class ResolveServiceClass {
constructor(root) {
- this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`);
- this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`);
+ this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
+ this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
}
resolve(noteId) {
@@ -45,6 +45,7 @@ class ResolveServiceClass {
if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
})
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
}
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 118437b82a3..72f21f13860 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -1,711 +1,91 @@
/* 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 */
-import { s__ } from './locale';
-import projectSelect from './project_select';
-import IssuableIndex from './issuable_index';
-import Milestone from './milestone';
-import IssuableForm from './issuable_form';
-import LabelsSelect from './labels_select';
-/* global MilestoneSelect */
-import NewBranchForm from './new_branch_form';
-import NotificationsForm from './notifications_form';
-import notificationsDropdown from './notifications_dropdown';
-import groupAvatar from './group_avatar';
-import GroupLabelSubscription from './group_label_subscription';
-import LineHighlighter from './line_highlighter';
-import BuildArtifacts from './build_artifacts';
-import CILintEditor from './ci_lint_editor';
-import groupsSelect from './groups_select';
-import Search from './search';
-import initAdmin from './admin';
-import NamespaceSelect from './namespace_select';
-import NewCommitForm from './new_commit_form';
-import Project from './project';
-import projectAvatar from './project_avatar';
-import MergeRequest from './merge_request';
-import Compare from './compare';
-import initCompareAutocomplete from './compare_autocomplete';
-import ProjectFindFile from './project_find_file';
-import ProjectNew from './project_new';
-import projectImport from './project_import';
-import Labels from './labels';
-import LabelManager from './label_manager';
-import Sidebar from './right_sidebar';
-import IssuableTemplateSelectors from './templates/issuable_template_selectors';
+
+import $ from 'jquery';
import Flash from './flash';
-import CommitsList from './commits';
-import Issue from './issue';
-import BindInOut from './behaviors/bind_in_out';
-import SecretValues from './behaviors/secret_values';
-import DeleteModal from './branches/branches_delete_modal';
-import Group from './group';
-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 ShortcutsWiki from './shortcuts_wiki';
-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 initBroadcastMessagesForm from './broadcast_message';
-import initNotes from './init_notes';
-import initLegacyFilters from './init_legacy_filters';
-import initIssuableSidebar from './init_issuable_sidebar';
-import initProjectVisibilitySelector from './project_visibility';
-import GpgBadges from './gpg_badges';
-import initChangesDropdown from './init_changes_dropdown';
-import NewGroupChild from './groups/new_group_child';
-import AbuseReports from './abuse_reports';
-import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
-import AjaxLoadingSpinner from './ajax_loading_spinner';
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
import GlFieldErrors from './gl_field_errors';
-import GLForm from './gl_form';
import Shortcuts from './shortcuts';
-import ShortcutsNavigation from './shortcuts_navigation';
-import ShortcutsFindFile from './shortcuts_find_file';
-import ShortcutsIssuable from './shortcuts_issuable';
-import U2FAuthenticate from './u2f/authenticate';
-import Members from './members';
-import memberExpirationDate from './member_expiration_date';
-import DueDateSelectors from './due_date_select';
-import Diff from './diff';
-import ProjectLabelSubscription from './project_label_subscription';
import SearchAutocomplete from './search_autocomplete';
-import Activities from './activities';
-
-(function() {
- var Dispatcher;
-
- Dispatcher = (function() {
- function Dispatcher() {
- this.initSearch();
- this.initFieldErrors();
- this.initPageScripts();
- }
-
- Dispatcher.prototype.initPageScripts = function() {
- var path, shortcut_handler, fileBlobPermalinkUrlElement, fileBlobPermalinkUrl;
- const page = $('body').attr('data-page');
- if (!page) {
- return false;
- }
-
- const fail = () => Flash('Error loading dynamic module');
-
- path = page.split(':');
- shortcut_handler = null;
-
- $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
- const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
- const enableGFM = convertPermissionToBoolean(el.dataset.supportsAutocomplete);
- gfm.setup($(el), {
- emojis: true,
- members: enableGFM,
- issues: enableGFM,
- milestones: enableGFM,
- mergeRequests: enableGFM,
- labels: enableGFM,
- });
- });
-
- function initBlob() {
- new LineHighlighter();
-
- new BlobLinePermalinkUpdater(
- document.querySelector('#blob-content-holder'),
- '.diff-line-num[data-line-number]',
- document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
- );
-
- shortcut_handler = new ShortcutsNavigation();
- fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
- fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
- new ShortcutsBlob({
- skipResetBindings: true,
- fileBlobPermalinkUrl,
- });
-
- new BlobForkSuggestion({
- openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'),
- forkButtons: document.querySelectorAll('.js-fork-suggestion-button'),
- cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'),
- suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'),
- actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'),
- })
- .init();
- }
-
- const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search');
-
- switch (page) {
- case 'profiles:preferences:show':
- initExperimentalFlags();
- break;
- case 'sessions:new':
- new UsernameValidator();
- new SigninTabsMemoizer();
- new OAuthRememberMe({ container: $(".omniauth-container") }).bindEvents();
- break;
- case 'projects:boards:show':
- case 'projects:boards:index':
- shortcut_handler = new ShortcutsNavigation();
- new UsersSelect();
- break;
- case 'projects:merge_requests:index':
- case 'projects:issues:index':
- if (filteredSearchEnabled) {
- const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
- filteredSearchManager.setup();
- }
- const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
- new IssuableIndex(pagePrefix);
- shortcut_handler = new ShortcutsNavigation();
- new UsersSelect();
- break;
- case 'projects:issues:show':
- new Issue();
- shortcut_handler = new ShortcutsIssuable();
- new ZenMode();
- initIssuableSidebar();
- break;
- case 'dashboard:milestones:index':
- projectSelect();
- break;
- case 'projects:milestones:show':
- case 'groups:milestones:show':
- case 'dashboard:milestones:show':
- new Milestone();
- new Sidebar();
- break;
- case 'dashboard:issues':
- case 'dashboard:merge_requests':
- projectSelect();
- initLegacyFilters();
- break;
- case 'groups:issues':
- case 'groups:merge_requests':
- if (filteredSearchEnabled) {
- const filteredSearchManager = new gl.FilteredSearchManager(page === 'groups:issues' ? 'issues' : 'merge_requests');
- filteredSearchManager.setup();
- }
- projectSelect();
- break;
- case 'dashboard:todos:index':
- new Todos();
- break;
- case 'dashboard:projects:index':
- case 'dashboard:projects:starred':
- case 'explore:projects:index':
- case 'explore:projects:trending':
- case 'explore:projects:starred':
- case 'admin:projects:index':
- new ProjectsList();
- break;
- 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':
- case 'projects:milestones:update':
- new ZenMode();
- new DueDateSelectors();
- new GLForm($('.milestone-form'), true);
- break;
- case 'groups:milestones:new':
- case 'groups:milestones:edit':
- case 'groups:milestones:update':
- new ZenMode();
- new DueDateSelectors();
- new GLForm($('.milestone-form'), false);
- break;
- case 'projects:compare:show':
- new Diff();
- const paddingTop = 16;
- initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
- 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':
- AjaxLoadingSpinner.init();
- new DeleteModal();
- break;
- case 'projects:issues:new':
- case 'projects:issues:edit':
- shortcut_handler = new ShortcutsNavigation();
- new GLForm($('.issue-form'), true);
- new IssuableForm($('.issue-form'));
- new LabelsSelect();
- new MilestoneSelect();
- new IssuableTemplateSelectors();
- break;
- 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 Diff();
- shortcut_handler = new ShortcutsNavigation();
- new GLForm($('.merge-request-form'), true);
- new IssuableForm($('.merge-request-form'));
- new LabelsSelect();
- new MilestoneSelect();
- new IssuableTemplateSelectors();
- new AutoWidthDropdownSelect($('.js-target-branch-select')).init();
- break;
- case 'projects:tags:new':
- new ZenMode();
- new GLForm($('.tag-form'), true);
- new RefSelectDropdown($('.js-branch-select'));
- break;
- case 'projects:snippets:show':
- initNotes();
- new ZenMode();
- break;
- case 'projects:snippets:new':
- case 'projects:snippets:edit':
- case 'projects:snippets:create':
- case 'projects:snippets:update':
- new GLForm($('.snippet-form'), true);
- new ZenMode();
- break;
- case 'snippets:new':
- case 'snippets:edit':
- case 'snippets:create':
- case 'snippets:update':
- new GLForm($('.snippet-form'), false);
- new ZenMode();
- break;
- case 'projects:releases:edit':
- new ZenMode();
- new GLForm($('.release-form'), true);
- break;
- case 'projects:merge_requests:show':
- new Diff();
- new ZenMode();
+function initSearch() {
+ // Only when search form is present
+ if ($('.search').length) {
+ return new SearchAutocomplete();
+ }
+}
- initIssuableSidebar();
- initNotes();
-
- const mrShowNode = document.querySelector('.merge-request');
- window.mergeRequest = new MergeRequest({
- action: mrShowNode.dataset.mrAction,
- });
-
- shortcut_handler = new ShortcutsIssuable(true);
- break;
- case 'dashboard:activity':
- new Activities();
- break;
- case 'projects:commit:show':
- new Diff();
- new ZenMode();
- shortcut_handler = new ShortcutsNavigation();
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- initNotes();
- const stickyBarPaddingTop = 16;
- initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - stickyBarPaddingTop);
- $('.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:activity':
- new 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();
- new UserCallout({
- setCalloutPerProject: true,
- className: 'js-autodevops-banner',
- });
-
- if ($('#tree-slider').length) new TreeView();
- if ($('.blob-viewer').length) new BlobViewer();
- if ($('.project-show-activity').length) new Activities();
- $('#tree-slider').waitForImages(function() {
- ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
- });
- break;
- case 'projects:edit':
- setupProjectEdit();
- // Initialize expandable settings panels
- initSettingsPanels();
- break;
- case 'projects:imports:show':
- projectImport();
- break;
- case 'projects:pipelines:new':
- case 'projects:pipelines:create':
- 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 Pipelines({
- initTabs: true,
- pipelineStatusUrl,
- tabsOptions: {
- action: controllerAction,
- defaultAction: 'pipelines',
- parentEl: '.pipelines-tabs',
- },
- });
- break;
- case 'groups:activity':
- new Activities();
- break;
- case 'groups:show':
- const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
- shortcut_handler = new ShortcutsNavigation();
- new NotificationsForm();
- notificationsDropdown();
- new ProjectsList();
-
- if (newGroupChildWrapper) {
- new NewGroupChild(newGroupChildWrapper);
- }
- break;
- case 'groups:group_members:index':
- memberExpirationDate();
- new Members();
- new UsersSelect();
- break;
- case 'projects:project_members:index':
- memberExpirationDate('.js-access-expiration-date-groups');
- groupsSelect();
- memberExpirationDate();
- new Members();
- new UsersSelect();
- break;
- case 'groups:new':
- case 'admin:groups:new':
- case 'groups:create':
- case 'admin:groups:create':
- BindInOut.initAll();
- new Group();
- groupAvatar();
- break;
- case 'groups:edit':
- case 'admin:groups:edit':
- groupAvatar();
- break;
- case 'projects:tree:show':
- shortcut_handler = new ShortcutsNavigation();
- new TreeView();
- new BlobViewer();
- new NewCommitForm($('.js-create-dir-form'));
- $('#tree-slider').waitForImages(function() {
- 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:show':
- new BlobViewer();
- initBlob();
- break;
- case 'projects:blame:show':
- initBlob();
- break;
- case 'groups:labels:new':
- case 'groups:labels:edit':
- case 'projects:labels:new':
- case 'projects:labels:edit':
- new Labels();
- break;
- case 'groups:labels:index':
- case 'projects:labels:index':
- if ($('.prioritized-labels').length) {
- new LabelManager();
- }
- $('.label-subscription').each((i, el) => {
- const $el = $(el);
-
- if ($el.find('.dropdown-group-label').length) {
- new GroupLabelSubscription($el);
- } else {
- new ProjectLabelSubscription($el);
- }
- });
- break;
- case 'projects:network:show':
- // Ensure we don't create a particular shortcut handler here. This is
- // already created, where the network graph is created.
- shortcut_handler = true;
- break;
- case 'projects:forks:new':
- import(/* webpackChunkName: 'project_fork' */ './project_fork')
- .then(fork => fork.default())
- .catch(() => {});
- break;
- case 'projects:artifacts:browse':
- new ShortcutsNavigation();
- new BuildArtifacts();
- break;
- case 'projects:artifacts:file':
- new ShortcutsNavigation();
- new BlobViewer();
- break;
- case 'help:index':
- VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
- break;
- case 'search:show':
- new Search();
- break;
- case 'projects:settings:repository:show':
- // Initialize expandable settings panels
- initSettingsPanels();
- break;
- case 'projects:settings:ci_cd:show':
- // Initialize expandable settings panels
- initSettingsPanels();
-
- const runnerToken = document.querySelector('.js-secret-runner-token');
- if (runnerToken) {
- const runnerTokenSecretValue = new SecretValues(runnerToken);
- runnerTokenSecretValue.init();
- }
- case 'groups:settings:ci_cd:show':
- const secretVariableTable = document.querySelector('.js-secret-variable-table');
- if (secretVariableTable) {
- const secretVariableTableValues = new SecretValues(secretVariableTable);
- secretVariableTableValues.init();
- }
- break;
- case 'ci:lints:create':
- case 'ci:lints:show':
- new CILintEditor();
- break;
- case 'users:show':
- import('./pages/users/show').then(m => m.default()).catch(fail);
- break;
- case 'admin:conversational_development_index:show':
- new UserCallout();
- break;
- case 'snippets:show':
- new LineHighlighter();
- new BlobViewer();
- initNotes();
- new ZenMode();
- break;
- case 'import:fogbugz:new_user_map':
- new UsersSelect();
- break;
- case 'profiles:personal_access_tokens:index':
- case 'admin:impersonation_tokens:index':
- new DueDateSelectors();
- break;
- case 'projects:clusters:show':
- import(/* webpackChunkName: "clusters" */ './clusters/clusters_bundle')
- .then(cluster => new cluster.default()) // eslint-disable-line new-cap
- .catch((err) => {
- Flash(s__('ClusterIntegration|Problem setting up the cluster'));
- throw err;
- });
- break;
- case 'projects:clusters:index':
- import(/* webpackChunkName: "clusters_index" */ './clusters/clusters_index')
- .then(clusterIndex => clusterIndex.default())
- .catch((err) => {
- Flash(s__('ClusterIntegration|Problem setting up the clusters list'));
- throw err;
- });
- break;
- }
- switch (path[0]) {
- case 'sessions':
- case 'omniauth_callbacks':
- if (!gon.u2f) break;
- const u2fAuthenticate = new U2FAuthenticate(
- $('#js-authenticate-u2f'),
- '#js-login-u2f-form',
- gon.u2f,
- document.querySelector('#js-login-2fa-device'),
- document.querySelector('.js-2fa-form'),
- );
- u2fAuthenticate.start();
- // needed in rspec
- gl.u2fAuthenticate = u2fAuthenticate;
- case 'admin':
- initAdmin();
- switch (path[1]) {
- case 'broadcast_messages':
- initBroadcastMessagesForm();
- break;
- case 'cohorts':
- new UsagePing();
- break;
- case 'groups':
- new UsersSelect();
- break;
- case 'projects':
- document.querySelectorAll('.js-namespace-select')
- .forEach(dropdown => new NamespaceSelect({ dropdown }));
- break;
- case 'labels':
- switch (path[2]) {
- case 'new':
- case 'edit':
- new Labels();
- }
- case 'abuse_reports':
- new AbuseReports();
- break;
- }
- break;
- case 'dashboard':
- case 'root':
- new UserCallout();
- break;
- case 'profiles':
- new NotificationsForm();
- notificationsDropdown();
- break;
- case 'projects':
- new Project();
- projectAvatar();
- switch (path[1]) {
- case 'compare':
- initCompareAutocomplete();
- break;
- case 'edit':
- shortcut_handler = new ShortcutsNavigation();
- new ProjectNew();
- import(/* webpackChunkName: 'project_permissions' */ './projects/permissions')
- .then(permissions => permissions.default())
- .catch(() => {});
- break;
- case 'new':
- new ProjectNew();
- initProjectVisibilitySelector();
- break;
- case 'show':
- new Star();
- new ProjectNew();
- notificationsDropdown();
- break;
- case 'wikis':
- new Wikis();
- shortcut_handler = new ShortcutsWiki();
- new ZenMode();
- new GLForm($('.wiki-form'), true);
- break;
- case 'snippets':
- shortcut_handler = new ShortcutsNavigation();
- if (path[2] === 'show') {
- new ZenMode();
- new LineHighlighter();
- new BlobViewer();
- }
- break;
- case 'labels':
- case 'graphs':
- case 'compare':
- case 'pipelines':
- case 'forks':
- case 'milestones':
- case 'project_members':
- case 'deploy_keys':
- case 'builds':
- case 'hooks':
- case 'services':
- case 'protected_branches':
- shortcut_handler = new ShortcutsNavigation();
- }
- 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() {
- // Only when search form is present
- if ($('.search').length) {
- return new SearchAutocomplete();
- }
- };
-
- Dispatcher.prototype.initFieldErrors = function() {
- $('.gl-show-field-errors').each((i, form) => {
- new GlFieldErrors(form);
- });
- };
-
- return Dispatcher;
- })();
-
- $(window).on('load', function() {
- new Dispatcher();
+function initFieldErrors() {
+ $('.gl-show-field-errors').each((i, form) => {
+ new GlFieldErrors(form);
+ });
+}
+
+function initPageShortcuts(page) {
+ const pagesWithCustomShortcuts = [
+ 'projects:activity',
+ 'projects:artifacts:browse',
+ 'projects:artifacts:file',
+ 'projects:blame:show',
+ 'projects:blob:show',
+ 'projects:commit:show',
+ 'projects:commits:show',
+ 'projects:find_file:show',
+ 'projects:issues:edit',
+ 'projects:issues:index',
+ 'projects:issues:new',
+ 'projects:issues:show',
+ 'projects:merge_requests:creations:diffs',
+ 'projects:merge_requests:creations:new',
+ 'projects:merge_requests:edit',
+ 'projects:merge_requests:index',
+ 'projects:merge_requests:show',
+ 'projects:network:show',
+ 'projects:show',
+ 'projects:tree:show',
+ 'groups:show',
+ ];
+
+ if (pagesWithCustomShortcuts.indexOf(page) === -1) {
+ new Shortcuts();
+ }
+}
+
+function initGFMInput() {
+ $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
+ const gfm = new GfmAutoComplete(
+ gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
+ );
+ const enableGFM = convertPermissionToBoolean(
+ el.dataset.supportsAutocomplete,
+ );
+ gfm.setup($(el), {
+ emojis: true,
+ members: enableGFM,
+ issues: enableGFM,
+ milestones: enableGFM,
+ mergeRequests: enableGFM,
+ labels: enableGFM,
+ });
});
-}).call(window);
+}
+
+function initPerformanceBar() {
+ if (document.querySelector('#js-peek')) {
+ import('./performance_bar')
+ .then(m => new m.default({ container: '#js-peek' })) // eslint-disable-line new-cap
+ .catch(() => Flash('Error loading performance bar module'));
+ }
+}
+
+export default () => {
+ initSearch();
+ initFieldErrors();
+
+ const page = $('body').attr('data-page');
+ if (page) {
+ initPageShortcuts(page);
+ initGFMInput();
+ initPerformanceBar();
+ }
+};
diff --git a/app/assets/javascripts/docs/docs_bundle.js b/app/assets/javascripts/docs/docs_bundle.js
index a32bd6d0fc7..897439f56b0 100644
--- a/app/assets/javascripts/docs/docs_bundle.js
+++ b/app/assets/javascripts/docs/docs_bundle.js
@@ -4,10 +4,7 @@ function addMousetrapClick(el, key) {
el.addEventListener('click', () => Mousetrap.trigger(key));
}
-function domContentLoaded() {
+export default () => {
addMousetrapClick(document.querySelector('.js-trigger-shortcut'), '?');
addMousetrapClick(document.querySelector('.js-trigger-search-bar'), 's');
-}
-
-document.addEventListener('DOMContentLoaded', domContentLoaded);
-
+};
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js
index 673e9bb4c0f..868d47e91b3 100644
--- a/app/assets/javascripts/droplab/constants.js
+++ b/app/assets/javascripts/droplab/constants.js
@@ -3,7 +3,6 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
-const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding';
// Matches `{{anything}}` and `{{ everything }}`.
const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
@@ -14,5 +13,4 @@ export {
ACTIVE_CLASS,
TEMPLATE_REGEX,
IGNORE_CLASS,
- IGNORE_HIDING_CLASS,
};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index 5eb0a339a1c..3cc316c3f3e 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -1,13 +1,14 @@
import utils from './utils';
-import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants';
+import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
class DropDown {
- constructor(list, config = {}) {
+ constructor(list, config = { }) {
this.currentIndex = 0;
this.hidden = true;
this.list = typeof list === 'string' ? document.querySelector(list) : list;
this.items = [];
this.eventWrapper = {};
+ this.hideOnClick = config.hideOnClick !== false;
if (config.addActiveClassToDropdownButton) {
this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle');
@@ -37,15 +38,17 @@ class DropDown {
clickEvent(e) {
if (e.target.tagName === 'UL') return;
- if (e.target.classList.contains(IGNORE_CLASS)) return;
+ if (e.target.closest(`.${IGNORE_CLASS}`)) return;
- const selected = utils.closest(e.target, 'LI');
+ const selected = e.target.closest('li');
if (!selected) return;
this.addSelectedClass(selected);
e.preventDefault();
- if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide();
+ if (this.hideOnClick) {
+ this.hide();
+ }
const listEvent = new CustomEvent('click.dl', {
detail: {
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index c84be42649a..5528ad9f38d 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,7 +1,11 @@
+import $ from 'jquery';
import Dropzone from 'dropzone';
import _ from 'underscore';
import './preview_markdown';
import csrf from './lib/utils/csrf';
+import axios from './lib/utils/axios_utils';
+
+Dropzone.autoDiscover = false;
export default function dropzoneInput(form) {
const divHover = '<div class="div-dropzone-hover"></div>';
@@ -233,25 +237,21 @@ export default function dropzoneInput(form) {
uploadFile = (item, filename) => {
const formData = new FormData();
formData.append('file', item, filename);
- return $.ajax({
- url: uploadsPath,
- type: 'POST',
- data: formData,
- dataType: 'json',
- processData: false,
- contentType: false,
- headers: csrf.headers,
- beforeSend: () => {
- showSpinner();
- return closeAlertMessage();
- },
- success: (e, text, response) => {
- const md = response.responseJSON.link.markdown;
+
+ showSpinner();
+ closeAlertMessage();
+
+ axios.post(uploadsPath, formData)
+ .then(({ data }) => {
+ const md = data.link.markdown;
+
insertToTextArea(filename, md);
- },
- error: response => showError(response.responseJSON.message),
- complete: () => closeSpinner(),
- });
+ closeSpinner();
+ })
+ .catch((e) => {
+ showError(e.response.data.message);
+ closeSpinner();
+ });
};
updateAttachingMessage = (files, messageContainer) => {
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index ada985913bb..842a4255f08 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -1,6 +1,8 @@
/* global dateFormat */
+import $ from 'jquery';
import Pikaday from 'pikaday';
+import axios from './lib/utils/axios_utils';
import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
class DueDateSelect {
@@ -16,9 +18,9 @@ class DueDateSelect {
this.$value = $block.find('.value');
this.$valueContent = $block.find('.value-content');
this.$sidebarValue = $('.js-due-date-sidebar-value', $block);
- this.fieldName = $dropdown.data('field-name');
- this.abilityName = $dropdown.data('ability-name');
- this.issueUpdateURL = $dropdown.data('issue-update');
+ this.fieldName = $dropdown.data('fieldName');
+ this.abilityName = $dropdown.data('abilityName');
+ this.issueUpdateURL = $dropdown.data('issueUpdate');
this.rawSelectedDate = null;
this.displayedDate = null;
@@ -125,37 +127,30 @@ class DueDateSelect {
}
submitSelectedDate(isDropdown) {
- return $.ajax({
- type: 'PUT',
- url: this.issueUpdateURL,
- data: this.datePayload,
- dataType: 'json',
- beforeSend: () => {
- const selectedDateValue = this.datePayload[this.abilityName].due_date;
- const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value';
+ const selectedDateValue = this.datePayload[this.abilityName].due_date;
+ const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value';
- this.$loading.removeClass('hidden').fadeIn();
+ this.$loading.removeClass('hidden').fadeIn();
- if (isDropdown) {
- this.$dropdown.trigger('loading.gl.dropdown');
- this.$selectbox.hide();
- }
+ if (isDropdown) {
+ this.$dropdown.trigger('loading.gl.dropdown');
+ this.$selectbox.hide();
+ }
- this.$value.css('display', '');
- this.$valueContent.html(`<span class='${displayedDateStyle}'>${this.displayedDate}</span>`);
- this.$sidebarValue.html(this.displayedDate);
+ this.$value.css('display', '');
+ this.$valueContent.html(`<span class='${displayedDateStyle}'>${this.displayedDate}</span>`);
+ this.$sidebarValue.html(this.displayedDate);
- return selectedDateValue.length ?
- $('.js-remove-due-date-holder').removeClass('hidden') :
- $('.js-remove-due-date-holder').addClass('hidden');
- },
- }).done(() => {
- if (isDropdown) {
- this.$dropdown.trigger('loaded.gl.dropdown');
- this.$dropdown.dropdown('toggle');
- }
- return this.$loading.fadeOut();
- });
+ $('.js-remove-due-date-holder').toggleClass('hidden', selectedDateValue.length);
+
+ return axios.put(this.issueUpdateURL, this.datePayload)
+ .then(() => {
+ if (isDropdown) {
+ this.$dropdown.trigger('loaded.gl.dropdown');
+ this.$dropdown.dropdown('toggle');
+ }
+ return this.$loading.fadeOut();
+ });
}
}
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 3236077c3cf..dbee81fa320 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -4,6 +4,11 @@
import environmentTable from '../components/environments_table.vue';
export default {
+ components: {
+ environmentTable,
+ loadingIcon,
+ tablePagination,
+ },
props: {
isLoading: {
type: Boolean,
@@ -26,12 +31,6 @@
required: true,
},
},
- components: {
- environmentTable,
- loadingIcon,
- tablePagination,
- },
-
methods: {
onChangePage(page) {
this.$emit('onChangePage', page);
@@ -47,7 +46,7 @@
label="Loading environments"
v-if="isLoading"
size="3"
- />
+ />
<slot name="emptyState"></slot>
@@ -59,13 +58,13 @@
:environments="environments"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
- />
+ />
<table-pagination
v-if="pagination && pagination.totalPages > 1"
:change="onChangePage"
- :pageInfo="pagination"
- />
+ :page-info="pagination"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
index 2646f08c8e6..00e63c3467a 100644
--- a/app/assets/javascripts/environments/components/empty_state.vue
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -1,6 +1,6 @@
<script>
export default {
- name: 'environmentsEmptyState',
+ name: 'EnvironmentsEmptyState',
props: {
newPath: {
type: String,
@@ -21,21 +21,23 @@
<div class="blank-state-row">
<div class="blank-state-center">
<h2 class="blank-state-title js-blank-state-title">
- {{s__("Environments|You don't have any environments right now.")}}
+ {{ s__("Environments|You don't have any environments right now.") }}
</h2>
<p class="blank-state-text">
- {{s__("Environments|Environments are places where code gets deployed, such as staging or production.")}}
+ {{ s__(`Environments|Environments are places where
+code gets deployed, such as staging or production.`) }}
<br />
<a :href="helpPath">
- {{s__("Environments|Read more about environments")}}
+ {{ s__("Environments|Read more about environments") }}
</a>
</p>
<a
v-if="canCreateEnvironment"
:href="newPath"
- class="btn btn-create js-new-environment-button">
- {{s__("Environments|New environment")}}
+ class="btn btn-create js-new-environment-button"
+ >
+ {{ s__("Environments|New environment") }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index e7495677e7c..16bd2f5feb3 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,55 +1,54 @@
<script>
-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';
+ 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: false,
- default: () => [],
+ export default {
+ directives: {
+ tooltip,
},
- },
- directives: {
- tooltip,
- },
-
- components: {
- loadingIcon,
- },
+ components: {
+ loadingIcon,
+ },
+ props: {
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
- data() {
- return {
- playIconSvg,
- isLoading: false,
- };
- },
+ data() {
+ return {
+ playIconSvg,
+ isLoading: false,
+ };
+ },
- computed: {
- title() {
- return 'Deploy to...';
+ computed: {
+ title() {
+ return 'Deploy to...';
+ },
},
- },
- methods: {
- onClickAction(endpoint) {
- this.isLoading = true;
+ methods: {
+ onClickAction(endpoint) {
+ this.isLoading = true;
- eventHub.$emit('postAction', endpoint);
- },
+ eventHub.$emit('postAction', endpoint);
+ },
- isActionDisabled(action) {
- if (action.playable === undefined) {
- return false;
- }
+ isActionDisabled(action) {
+ if (action.playable === undefined) {
+ return false;
+ }
- return !action.playable;
+ return !action.playable;
+ },
},
- },
-};
+ };
</script>
<template>
<div
@@ -63,27 +62,33 @@ export default {
data-toggle="dropdown"
:title="title"
:aria-label="title"
- :disabled="isLoading">
+ :disabled="isLoading"
+ >
<span>
<span v-html="playIconSvg"></span>
<i
class="fa fa-caret-down"
- aria-hidden="true"/>
+ aria-hidden="true"
+ >
+ </i>
<loading-icon v-if="isLoading" />
</span>
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="action in actions">
+ <li
+ v-for="(action, i) in actions"
+ :key="i">
<button
type="button"
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)"
:class="{ disabled: isActionDisabled(action) }"
- :disabled="isActionDisabled(action)">
+ :disabled="isActionDisabled(action)"
+ >
<span v-html="playIconSvg"></span>
<span>
- {{action.name}}
+ {{ action.name }}
</span>
</button>
</li>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index 520c3ac8ace..c9a68cface6 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -1,28 +1,27 @@
<script>
-import tooltip from '../../vue_shared/directives/tooltip';
-import { s__ } from '../../locale';
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import { s__ } from '../../locale';
-/**
- * Renders the external url link in environments table.
- */
-export default {
- props: {
- externalUrl: {
- type: String,
- required: true,
+ /**
+ * Renders the external url link in environments table.
+ */
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ externalUrl: {
+ type: String,
+ required: true,
+ },
},
- },
-
- directives: {
- tooltip,
- },
- computed: {
- title() {
- return s__('Environments|Open');
+ computed: {
+ title() {
+ return s__('Environments|Open');
+ },
},
- },
-};
+ };
</script>
<template>
<a
@@ -33,9 +32,12 @@ export default {
rel="noopener noreferrer nofollow"
:title="title"
:aria-label="title"
- :href="externalUrl">
+ :href="externalUrl"
+ >
<i
class="fa fa-external-link"
- aria-hidden="true" />
+ aria-hidden="true"
+ >
+ </i>
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 2f0e397aa45..79326ca3487 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,423 +1,429 @@
<script>
-import Timeago from 'timeago.js';
-import _ from 'underscore';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import { humanize } from '../../lib/utils/text_utility';
-import ActionsComponent from './environment_actions.vue';
-import ExternalUrlComponent from './environment_external_url.vue';
-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.vue';
-import eventHub from '../event_hub';
-
-/**
- * Envrionment Item Component
- *
- * Renders a table row for each environment.
- */
-const timeagoInstance = new Timeago();
-
-export default {
- components: {
- userAvatarLink,
- 'commit-component': CommitComponent,
- 'actions-component': ActionsComponent,
- 'external-url-component': ExternalUrlComponent,
- 'stop-component': StopComponent,
- 'rollback-component': RollbackComponent,
- 'terminal-button-component': TerminalButtonComponent,
- 'monitoring-button-component': MonitoringButtonComponent,
- },
-
- props: {
- model: {
- type: Object,
- required: true,
- default: () => ({}),
+ import Timeago from 'timeago.js';
+ import _ from 'underscore';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+ import { humanize } from '~/lib/utils/text_utility';
+ import ActionsComponent from './environment_actions.vue';
+ import ExternalUrlComponent from './environment_external_url.vue';
+ 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.vue';
+ import eventHub from '../event_hub';
+
+ /**
+ * Envrionment Item Component
+ *
+ * Renders a table row for each environment.
+ */
+ const timeagoInstance = new Timeago();
+
+ export default {
+ components: {
+ UserAvatarLink,
+ CommitComponent,
+ ActionsComponent,
+ ExternalUrlComponent,
+ StopComponent,
+ RollbackComponent,
+ TerminalButtonComponent,
+ MonitoringButtonComponent,
},
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
+ directives: {
+ tooltip,
},
- canReadEnvironment: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- /**
- * Verifies if `last_deployment` key exists in the current Envrionment.
- * This key is required to render most of the html - this method works has
- * an helper.
- *
- * @returns {Boolean}
- */
- hasLastDeploymentKey() {
- if (this.model &&
- this.model.last_deployment &&
- !_.isEmpty(this.model.last_deployment)) {
- return true;
- }
- return false;
- },
-
- /**
- * Verifies is the given environment has manual actions.
- * Used to verify if we should render them or nor.
- *
- * @returns {Boolean|Undefined}
- */
- hasManualActions() {
- return this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.manual_actions &&
- this.model.last_deployment.manual_actions.length > 0;
- },
-
- /**
- * Returns the value of the `stop_action?` key provided in the response.
- *
- * @returns {Boolean}
- */
- hasStopAction() {
- return this.model && this.model['stop_action?'];
- },
-
- /**
- * Verifies if the `deployable` key is present in `last_deployment` key.
- * Used to verify whether we should or not render the rollback partial.
- *
- * @returns {Boolean|Undefined}
- */
- canRetry() {
- return this.model &&
- this.hasLastDeploymentKey &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable;
- },
-
- /**
- * Verifies if the date to be shown is present.
- *
- * @returns {Boolean|Undefined}
- */
- canShowDate() {
- return this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable !== undefined;
- },
-
- /**
- * Human readable date.
- *
- * @returns {String}
- */
- createdDate() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.created_at) {
- return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
- }
- return '';
- },
-
- /**
- * Returns the manual actions with the name parsed.
- *
- * @returns {Array.<Object>|Undefined}
- */
- manualActions() {
- if (this.hasManualActions) {
- return this.model.last_deployment.manual_actions.map((action) => {
- const parsedAction = {
- name: humanize(action.name),
- play_path: action.play_path,
- playable: action.playable,
- };
- return parsedAction;
- });
- }
- return [];
- },
-
- /**
- * Builds the string used in the user image alt attribute.
- *
- * @returns {String}
- */
- userImageAltDescription() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.user &&
- this.model.last_deployment.user.username) {
- return `${this.model.last_deployment.user.username}'s avatar'`;
- }
- return '';
- },
-
- /**
- * If provided, returns the commit tag.
- *
- * @returns {String|Undefined}
- */
- commitTag() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.tag) {
- return this.model.last_deployment.tag;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit ref.
- *
- * @returns {Object|Undefined}
- */
- commitRef() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.ref) {
- return this.model.last_deployment.ref;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit url.
- *
- * @returns {String|Undefined}
- */
- commitUrl() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.commit_path) {
- return this.model.last_deployment.commit.commit_path;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit short sha.
- *
- * @returns {String|Undefined}
- */
- commitShortSha() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.short_id) {
- return this.model.last_deployment.commit.short_id;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit title.
- *
- * @returns {String|Undefined}
- */
- commitTitle() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.title) {
- return this.model.last_deployment.commit.title;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit tag.
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.author) {
- return this.model.last_deployment.commit.author;
- }
-
- return undefined;
- },
-
- /**
- * Verifies if the `retry_path` key is present and returns its value.
- *
- * @returns {String|Undefined}
- */
- retryUrl() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.retry_path) {
- return this.model.last_deployment.deployable.retry_path;
- }
- return undefined;
- },
-
- /**
- * Verifies if the `last?` key is present and returns its value.
- *
- * @returns {Boolean|Undefined}
- */
- isLastDeployment() {
- return this.model && this.model.last_deployment &&
- this.model.last_deployment['last?'];
- },
-
- /**
- * Builds the name of the builds needed to display both the name and the id.
- *
- * @returns {String}
- */
- buildName() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable) {
- return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`;
- }
- return '';
+ props: {
+ model: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+
+ canCreateDeployment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ canReadEnvironment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- /**
- * Builds the needed string to show the internal id.
- *
- * @returns {String}
- */
- deploymentInternalId() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.iid) {
- return `#${this.model.last_deployment.iid}`;
- }
- return '';
+ computed: {
+ /**
+ * Verifies if `last_deployment` key exists in the current Envrionment.
+ * This key is required to render most of the html - this method works has
+ * an helper.
+ *
+ * @returns {Boolean}
+ */
+ hasLastDeploymentKey() {
+ if (this.model &&
+ this.model.last_deployment &&
+ !_.isEmpty(this.model.last_deployment)) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Verifies is the given environment has manual actions.
+ * Used to verify if we should render them or nor.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ hasManualActions() {
+ return this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.manual_actions &&
+ this.model.last_deployment.manual_actions.length > 0;
+ },
+
+ /**
+ * Returns the value of the `stop_action?` key provided in the response.
+ *
+ * @returns {Boolean}
+ */
+ hasStopAction() {
+ return this.model && this.model['stop_action?'];
+ },
+
+ /**
+ * Verifies if the `deployable` key is present in `last_deployment` key.
+ * Used to verify whether we should or not render the rollback partial.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ canRetry() {
+ return this.model &&
+ this.hasLastDeploymentKey &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable;
+ },
+
+ /**
+ * Verifies if the date to be shown is present.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ canShowDate() {
+ return this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable !== undefined;
+ },
+
+ /**
+ * Human readable date.
+ *
+ * @returns {String}
+ */
+ createdDate() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.created_at) {
+ return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
+ }
+ return '';
+ },
+
+ /**
+ * Returns the manual actions with the name parsed.
+ *
+ * @returns {Array.<Object>|Undefined}
+ */
+ manualActions() {
+ if (this.hasManualActions) {
+ return this.model.last_deployment.manual_actions.map((action) => {
+ const parsedAction = {
+ name: humanize(action.name),
+ play_path: action.play_path,
+ playable: action.playable,
+ };
+ return parsedAction;
+ });
+ }
+ return [];
+ },
+
+ /**
+ * Builds the string used in the user image alt attribute.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.user &&
+ this.model.last_deployment.user.username) {
+ return `${this.model.last_deployment.user.username}'s avatar'`;
+ }
+ return '';
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTag() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.tag) {
+ return this.model.last_deployment.tag;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit ref.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitRef() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.ref) {
+ return this.model.last_deployment.ref;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit url.
+ *
+ * @returns {String|Undefined}
+ */
+ commitUrl() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.commit_path) {
+ return this.model.last_deployment.commit.commit_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit short sha.
+ *
+ * @returns {String|Undefined}
+ */
+ commitShortSha() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.short_id) {
+ return this.model.last_deployment.commit.short_id;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit title.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTitle() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.title) {
+ return this.model.last_deployment.commit.title;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitAuthor() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.author) {
+ return this.model.last_deployment.commit.author;
+ }
+
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `retry_path` key is present and returns its value.
+ *
+ * @returns {String|Undefined}
+ */
+ retryUrl() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.retry_path) {
+ return this.model.last_deployment.deployable.retry_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `last?` key is present and returns its value.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ isLastDeployment() {
+ return this.model && this.model.last_deployment &&
+ this.model.last_deployment['last?'];
+ },
+
+ /**
+ * Builds the name of the builds needed to display both the name and the id.
+ *
+ * @returns {String}
+ */
+ buildName() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable) {
+ const deployable = this.model.last_deployment.deployable;
+ return `${deployable.name} #${deployable.id}`;
+ }
+ return '';
+ },
+
+ /**
+ * Builds the needed string to show the internal id.
+ *
+ * @returns {String}
+ */
+ deploymentInternalId() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.iid) {
+ return `#${this.model.last_deployment.iid}`;
+ }
+ return '';
+ },
+
+ /**
+ * Verifies if the user object is present under last_deployment object.
+ *
+ * @returns {Boolean}
+ */
+ deploymentHasUser() {
+ return this.model &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user);
+ },
+
+ /**
+ * Returns the user object nested with the last_deployment object.
+ * Used to render the template.
+ *
+ * @returns {Object}
+ */
+ deploymentUser() {
+ if (this.model &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user)) {
+ return this.model.last_deployment.user;
+ }
+ return {};
+ },
+
+ /**
+ * Verifies if the build name column should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderBuildName() {
+ return !this.model.isFolder &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.deployable);
+ },
+
+ /**
+ * Verifies the presence of all the keys needed to render the buil_path.
+ *
+ * @return {String}
+ */
+ buildPath() {
+ if (this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.build_path) {
+ return this.model.last_deployment.deployable.build_path;
+ }
+
+ return '';
+ },
+
+ /**
+ * Verifies the presence of all the keys needed to render the external_url.
+ *
+ * @return {String}
+ */
+ externalURL() {
+ if (this.model && this.model.external_url) {
+ return this.model.external_url;
+ }
+
+ return '';
+ },
+
+ /**
+ * Verifies if deplyment internal ID should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderDeploymentID() {
+ return !this.model.isFolder &&
+ !_.isEmpty(this.model.last_deployment) &&
+ this.model.last_deployment.iid !== undefined;
+ },
+
+ environmentPath() {
+ if (this.model && this.model.environment_path) {
+ return this.model.environment_path;
+ }
+
+ return '';
+ },
+
+ monitoringUrl() {
+ if (this.model && this.model.metrics_path) {
+ return this.model.metrics_path;
+ }
+
+ return '';
+ },
+
+ displayEnvironmentActions() {
+ return this.hasManualActions ||
+ this.externalURL ||
+ this.monitoringUrl ||
+ this.hasStopAction ||
+ this.canRetry;
+ },
},
- /**
- * Verifies if the user object is present under last_deployment object.
- *
- * @returns {Boolean}
- */
- deploymentHasUser() {
- return this.model &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.user);
+ methods: {
+ onClickFolder() {
+ eventHub.$emit('toggleFolder', this.model);
+ },
},
-
- /**
- * Returns the user object nested with the last_deployment object.
- * Used to render the template.
- *
- * @returns {Object}
- */
- deploymentUser() {
- if (this.model &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.user)) {
- return this.model.last_deployment.user;
- }
- return {};
- },
-
- /**
- * Verifies if the build name column should be rendered by verifing
- * if all the information needed is present
- * and if the environment is not a folder.
- *
- * @returns {Boolean}
- */
- shouldRenderBuildName() {
- return !this.model.isFolder &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.deployable);
- },
-
- /**
- * Verifies the presence of all the keys needed to render the buil_path.
- *
- * @return {String}
- */
- buildPath() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.build_path) {
- return this.model.last_deployment.deployable.build_path;
- }
-
- return '';
- },
-
- /**
- * Verifies the presence of all the keys needed to render the external_url.
- *
- * @return {String}
- */
- externalURL() {
- if (this.model && this.model.external_url) {
- return this.model.external_url;
- }
-
- return '';
- },
-
- /**
- * Verifies if deplyment internal ID should be rendered by verifing
- * if all the information needed is present
- * and if the environment is not a folder.
- *
- * @returns {Boolean}
- */
- shouldRenderDeploymentID() {
- return !this.model.isFolder &&
- !_.isEmpty(this.model.last_deployment) &&
- this.model.last_deployment.iid !== undefined;
- },
-
- environmentPath() {
- if (this.model && this.model.environment_path) {
- return this.model.environment_path;
- }
-
- return '';
- },
-
- monitoringUrl() {
- if (this.model && this.model.metrics_path) {
- return this.model.metrics_path;
- }
-
- return '';
- },
-
- displayEnvironmentActions() {
- return this.hasManualActions ||
- this.externalURL ||
- this.monitoringUrl ||
- this.hasStopAction ||
- this.canRetry;
- },
- },
-
- methods: {
- onClickFolder() {
- eventHub.$emit('toggleFolder', this.model);
- },
- },
-};
+ };
</script>
<template>
<div
@@ -427,18 +433,26 @@ export default {
'folder-row': model.isFolder,
}"
role="row">
- <div class="table-section section-10" role="gridcell">
+ <div
+ class="table-section section-10"
+ role="gridcell"
+ >
<div
v-if="!model.isFolder"
class="table-mobile-header"
- role="rowheader">
- {{s__("Environments|Environment")}}
+ role="rowheader"
+ >
+ {{ s__("Environments|Environment") }}
</div>
<a
v-if="!model.isFolder"
class="environment-name flex-truncate-parent table-mobile-content"
:href="environmentPath">
- <span class="flex-truncate-child">{{model.name}}</span>
+ <span
+ class="flex-truncate-child"
+ v-tooltip
+ :title="model.name"
+ >{{ model.name }}</span>
</a>
<span
v-else
@@ -450,32 +464,40 @@ export default {
<i
v-show="model.isOpen"
class="fa fa-caret-down"
- aria-hidden="true" />
+ aria-hidden="true"
+ >
+ </i>
<i
v-show="!model.isOpen"
class="fa fa-caret-right"
- aria-hidden="true"/>
+ aria-hidden="true"
+ >
+ </i>
</span>
<span class="folder-icon">
<i
class="fa fa-folder"
- aria-hidden="true" />
+ aria-hidden="true">
+ </i>
</span>
<span>
- {{model.folderName}}
+ {{ model.folderName }}
</span>
<span class="badge">
- {{model.size}}
+ {{ model.size }}
</span>
</span>
</div>
- <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell">
+ <div
+ class="table-section section-10 deployment-column hidden-xs hidden-sm"
+ role="gridcell"
+ >
<span v-if="shouldRenderDeploymentID">
- {{deploymentInternalId}}
+ {{ deploymentInternalId }}
</span>
<span v-if="!model.isFolder && deploymentHasUser">
@@ -490,22 +512,29 @@ export default {
</span>
</div>
- <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell">
+ <div
+ class="table-section section-15 hidden-xs hidden-sm"
+ role="gridcell"
+ >
<a
v-if="shouldRenderBuildName"
class="build-link flex-truncate-parent"
- :href="buildPath">
- <span class="flex-truncate-child">{{buildName}}</span>
+ :href="buildPath"
+ >
+ <span class="flex-truncate-child">{{ buildName }}</span>
</a>
</div>
<div
v-if="!model.isFolder"
- class="table-section section-25" role="gridcell">
+ class="table-section section-25"
+ role="gridcell"
+ >
<div
role="rowheader"
- class="table-mobile-header">
- {{s__("Environments|Commit")}}
+ class="table-mobile-header"
+ >
+ {{ s__("Environments|Commit") }}
</div>
<div
v-if="hasLastDeploymentKey"
@@ -521,22 +550,24 @@ export default {
<div
v-if="!hasLastDeploymentKey"
class="commit-title table-mobile-content">
- {{s__("Environments|No deployments yet")}}
+ {{ s__("Environments|No deployments yet") }}
</div>
</div>
<div
v-if="!model.isFolder"
- class="table-section section-10" role="gridcell">
+ class="table-section section-10"
+ role="gridcell"
+ >
<div
role="rowheader"
class="table-mobile-header">
- {{s__("Environments|Updated")}}
+ {{ s__("Environments|Updated") }}
</div>
<span
v-if="canShowDate"
class="environment-created-date-timeago table-mobile-content">
- {{createdDate}}
+ {{ createdDate }}
</span>
</div>
@@ -552,33 +583,33 @@ export default {
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
- />
+ />
<external-url-component
v-if="externalURL && canReadEnvironment"
:external-url="externalURL"
- />
+ />
<monitoring-button-component
v-if="monitoringUrl && canReadEnvironment"
:monitoring-url="monitoringUrl"
- />
+ />
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
- />
+ />
<stop-component
v-if="hasStopAction && canCreateDeployment"
:stop-url="model.stop_path"
- />
+ />
<rollback-component
v-if="canRetry && canCreateDeployment"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
- />
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index b45af1a5ebc..081537cf218 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,27 +1,27 @@
<script>
-/**
- * Renders the Monitoring (Metrics) link in environments table.
- */
-import tooltip from '../../vue_shared/directives/tooltip';
+ /**
+ * Renders the Monitoring (Metrics) link in environments table.
+ */
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- monitoringUrl: {
- type: String,
- required: true,
+ export default {
+ directives: {
+ tooltip,
},
- },
- directives: {
- tooltip,
- },
+ props: {
+ monitoringUrl: {
+ type: String,
+ required: true,
+ },
+ },
- computed: {
- title() {
- return 'Monitoring';
+ computed: {
+ title() {
+ return 'Monitoring';
+ },
},
- },
-};
+ };
</script>
<template>
<a
@@ -31,10 +31,12 @@ export default {
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
:title="title"
- :aria-label="title">
+ :aria-label="title"
+ >
<i
class="fa fa-area-chart"
aria-hidden="true"
- />
+ >
+ </i>
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 92a596bfd33..605a88e997e 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -1,57 +1,58 @@
<script>
-/**
- * Renders Rollback or Re deploy button in environments table depending
- * of the provided property `isLastDeployment`.
- *
- * 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: {
- retryUrl: {
- type: String,
- default: '',
+ /**
+ * Renders Rollback or Re deploy button in environments table depending
+ * of the provided property `isLastDeployment`.
+ *
+ * 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 {
+ components: {
+ loadingIcon,
},
- isLastDeployment: {
- type: Boolean,
- default: true,
- },
- },
+ props: {
+ retryUrl: {
+ type: String,
+ default: '',
+ },
- components: {
- loadingIcon,
- },
+ isLastDeployment: {
+ type: Boolean,
+ default: true,
+ },
+ },
- data() {
- return {
- isLoading: false,
- };
- },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
- methods: {
- onClick() {
- this.isLoading = true;
+ methods: {
+ onClick() {
+ this.isLoading = true;
- eventHub.$emit('postAction', this.retryUrl);
+ eventHub.$emit('postAction', this.retryUrl);
+ },
},
- },
-};
+ };
</script>
<template>
<button
type="button"
class="btn hidden-xs hidden-sm"
@click="onClick"
- :disabled="isLoading">
+ :disabled="isLoading"
+ >
<span v-if="isLastDeployment">
- {{s__("Environments|Re-deploy")}}
+ {{ s__("Environments|Re-deploy") }}
</span>
<span v-else>
- {{s__("Environments|Rollback")}}
+ {{ s__("Environments|Rollback") }}
</span>
<loading-icon v-if="isLoading" />
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index 85f11d2071b..dda7429a726 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -1,53 +1,55 @@
<script>
-/**
- * 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';
+ /**
+ * Renders the stop "button" that allows stop an environment.
+ * Used in environments table.
+ */
-export default {
- props: {
- stopUrl: {
- type: String,
- default: '',
+ import $ from 'jquery';
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ components: {
+ loadingIcon,
},
- },
- directives: {
- tooltip,
- },
+ directives: {
+ tooltip,
+ },
- data() {
- return {
- isLoading: false,
- };
- },
+ props: {
+ stopUrl: {
+ type: String,
+ default: '',
+ },
+ },
- components: {
- loadingIcon,
- },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
- computed: {
- title() {
- return 'Stop';
+ computed: {
+ title() {
+ return 'Stop';
+ },
},
- },
- methods: {
- onClick() {
- // eslint-disable-next-line no-alert
- if (confirm('Are you sure you want to stop this environment?')) {
- this.isLoading = true;
+ 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.$el).tooltip('destroy');
- eventHub.$emit('postAction', this.stopUrl);
- }
+ eventHub.$emit('postAction', this.stopUrl);
+ }
+ },
},
- },
-};
+ };
</script>
<template>
<button
@@ -58,10 +60,13 @@ export default {
@click="onClick"
:disabled="isLoading"
:title="title"
- :aria-label="title">
+ :aria-label="title"
+ >
<i
class="fa fa-stop stop-env-icon"
- aria-hidden="true" />
+ aria-hidden="true"
+ >
+ </i>
<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 2037bf618e3..407d5333c0e 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -1,36 +1,36 @@
<script>
-/**
- * Renders a terminal button to open a web terminal.
- * Used in environments table.
- */
-import terminalIconSvg from 'icons/_icon_terminal.svg';
-import tooltip from '../../vue_shared/directives/tooltip';
+ /**
+ * Renders a terminal button to open a web terminal.
+ * Used in environments table.
+ */
+ import terminalIconSvg from 'icons/_icon_terminal.svg';
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- terminalPath: {
- type: String,
- required: false,
- default: '',
+ export default {
+ directives: {
+ tooltip,
},
- },
- directives: {
- tooltip,
- },
+ props: {
+ terminalPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
- data() {
- return {
- terminalIconSvg,
- };
- },
+ data() {
+ return {
+ terminalIconSvg,
+ };
+ },
- computed: {
- title() {
- return 'Terminal';
+ computed: {
+ title() {
+ return 'Terminal';
+ },
},
- },
-};
+ };
</script>
<template>
<a
@@ -40,6 +40,7 @@ export default {
:title="title"
:aria-label="title"
:href="terminalPath"
- v-html="terminalIconSvg">
+ v-html="terminalIconSvg"
+ >
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 2592909734f..c0be72f7401 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -7,6 +7,15 @@
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
export default {
+ components: {
+ emptyState,
+ },
+
+ mixins: [
+ CIPaginationMixin,
+ environmentsMixin,
+ ],
+
props: {
endpoint: {
type: String,
@@ -37,14 +46,6 @@
required: true,
},
},
- components: {
- emptyState,
- },
-
- mixins: [
- CIPaginationMixin,
- environmentsMixin,
- ],
created() {
eventHub.$on('toggleFolder', this.toggleFolder);
@@ -95,15 +96,17 @@
:tabs="tabs"
@onChangeTab="onChangeTab"
scope="environments"
- />
+ />
<div
v-if="canCreateEnvironment && !isLoading"
- class="nav-controls">
+ class="nav-controls"
+ >
<a
:href="newEnvironmentPath"
- class="btn btn-create">
- {{s__("Environments|New environment")}}
+ class="btn btn-create"
+ >
+ {{ s__("Environments|New environment") }}
</a>
</div>
</div>
@@ -116,13 +119,13 @@
:can-read-environment="canReadEnvironment"
@onChangePage="onChangePage"
>
- <empty-state
+ <empty-state
slot="emptyState"
v-if="!isLoading && state.environments.length === 0"
:new-path="newEnvironmentPath"
:help-path="helpPagePath"
:can-create-environment="canCreateEnvironment"
- />
+ />
</container>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index c04da4b81b7..22863e926d4 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -2,8 +2,8 @@
/**
* Render environments table.
*/
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import environmentItem from './environment_item.vue';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
components: {
@@ -30,63 +30,96 @@ export default {
default: false,
},
},
-
methods: {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
},
+ shouldRenderFolderContent(env) {
+ return env.isFolder &&
+ env.isOpen &&
+ env.children &&
+ env.children.length > 0;
+ },
},
};
</script>
<template>
- <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">
- {{s__("Environments|Environment")}}
+ <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"
+ >
+ {{ s__("Environments|Environment") }}
</div>
- <div class="table-section section-10 environments-deploy" role="columnheader">
- {{s__("Environments|Deployment")}}
+ <div
+ class="table-section section-10 environments-deploy"
+ role="columnheader"
+ >
+ {{ s__("Environments|Deployment") }}
</div>
- <div class="table-section section-15 environments-build" role="columnheader">
- {{s__("Environments|Job")}}
+ <div
+ class="table-section section-15 environments-build"
+ role="columnheader"
+ >
+ {{ s__("Environments|Job") }}
</div>
- <div class="table-section section-25 environments-commit" role="columnheader">
- {{s__("Environments|Commit")}}
+ <div
+ class="table-section section-25 environments-commit"
+ role="columnheader"
+ >
+ {{ s__("Environments|Commit") }}
</div>
- <div class="table-section section-10 environments-date" role="columnheader">
- {{s__("Environments|Updated")}}
+ <div
+ class="table-section section-10 environments-date"
+ role="columnheader"
+ >
+ {{ s__("Environments|Updated") }}
</div>
</div>
<template
- v-for="model in environments"
- v-bind:model="model">
+ v-for="(model, i) in environments"
+ :model="model">
<div
is="environment-item"
:model="model"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
- />
+ :key="i"
+ />
- <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
- <div v-if="model.isLoadingFolderContent">
+ <template
+ v-if="shouldRenderFolderContent(model)"
+ >
+ <div
+ v-if="model.isLoadingFolderContent"
+ :key="`loading-item-${i}`">
<loading-icon size="2" />
</div>
<template v-else>
<div
is="environment-item"
- v-for="children in model.children"
+ v-for="(children, index) in model.children"
:model="children"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
- />
+ :key="`env-item-${i}-${index}`"
+ />
- <div>
+ <div :key="`sub-div-${i}`">
<div class="text-center prepend-top-10">
<a
:href="folderUrl(model)"
- class="btn btn-default">
- {{s__("Environments|Show all")}}
+ class="btn btn-default"
+ >
+ {{ s__("Environments|Show all") }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/environments/environments_bundle.js b/app/assets/javascripts/environments/environments_bundle.js
deleted file mode 100644
index 2e0a4001b7c..00000000000
--- a/app/assets/javascripts/environments/environments_bundle.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import Vue from 'vue';
-import environmentsComponent from './components/environments_app.vue';
-import { convertPermissionToBoolean } from '../lib/utils/common_utils';
-import Translate from '../vue_shared/translate';
-
-Vue.use(Translate);
-
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#environments-list-view',
- components: {
- environmentsComponent,
- },
- data() {
- const environmentsData = document.querySelector(this.$options.el).dataset;
-
- return {
- endpoint: environmentsData.environmentsDataEndpoint,
- newEnvironmentPath: environmentsData.newEnvironmentPath,
- helpPagePath: environmentsData.helpPagePath,
- cssContainerClass: environmentsData.cssClass,
- canCreateEnvironment: convertPermissionToBoolean(environmentsData.canCreateEnvironment),
- canCreateDeployment: convertPermissionToBoolean(environmentsData.canCreateDeployment),
- canReadEnvironment: convertPermissionToBoolean(environmentsData.canReadEnvironment),
- };
- },
- render(createElement) {
- return createElement('environments-component', {
- props: {
- endpoint: this.endpoint,
- newEnvironmentPath: this.newEnvironmentPath,
- helpPagePath: this.helpPagePath,
- cssContainerClass: this.cssContainerClass,
- canCreateEnvironment: this.canCreateEnvironment,
- canCreateDeployment: this.canCreateDeployment,
- canReadEnvironment: this.canReadEnvironment,
- },
- });
- },
-}));
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 5d2d14c7682..de0fbdb2e91 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -5,7 +5,7 @@ import Translate from '../../vue_shared/translate';
Vue.use(Translate);
-document.addEventListener('DOMContentLoaded', () => new Vue({
+export default () => new Vue({
el: '#environments-folder-list-view',
components: {
environmentsFolderApp,
@@ -32,4 +32,4 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
},
});
},
-}));
+});
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 27418bad01a..5ef5e347387 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -3,6 +3,10 @@
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
export default {
+ mixins: [
+ environmentsMixin,
+ CIPaginationMixin,
+ ],
props: {
endpoint: {
type: String,
@@ -25,10 +29,6 @@
required: true,
},
},
- mixins: [
- environmentsMixin,
- CIPaginationMixin,
- ],
methods: {
successCallback(resp) {
this.saveData(resp);
@@ -40,17 +40,18 @@
<div :class="cssContainerClass">
<div
class="top-area"
- v-if="!isLoading">
+ v-if="!isLoading"
+ >
<h4 class="js-folder-name environments-folder-name">
- {{s__("Environments|Environments")}} / <b>{{folderName}}</b>
+ {{ s__("Environments|Environments") }} / <b>{{ folderName }}</b>
</h4>
<tabs
:tabs="tabs"
@onChangeTab="onChangeTab"
scope="environments"
- />
+ />
</div>
<container
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
new file mode 100644
index 00000000000..afc4aba6554
--- /dev/null
+++ b/app/assets/javascripts/environments/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import environmentsComponent from './components/environments_app.vue';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+import Translate from '../vue_shared/translate';
+
+Vue.use(Translate);
+
+export default () => new Vue({
+ el: '#environments-list-view',
+ components: {
+ environmentsComponent,
+ },
+ data() {
+ const environmentsData = document.querySelector(this.$options.el).dataset;
+
+ return {
+ endpoint: environmentsData.environmentsDataEndpoint,
+ newEnvironmentPath: environmentsData.newEnvironmentPath,
+ helpPagePath: environmentsData.helpPagePath,
+ cssContainerClass: environmentsData.cssClass,
+ canCreateEnvironment: convertPermissionToBoolean(environmentsData.canCreateEnvironment),
+ canCreateDeployment: convertPermissionToBoolean(environmentsData.canCreateDeployment),
+ canReadEnvironment: convertPermissionToBoolean(environmentsData.canReadEnvironment),
+ };
+ },
+ render(createElement) {
+ return createElement('environments-component', {
+ props: {
+ endpoint: this.endpoint,
+ newEnvironmentPath: this.newEnvironmentPath,
+ helpPagePath: this.helpPagePath,
+ cssContainerClass: this.cssContainerClass,
+ canCreateEnvironment: this.canCreateEnvironment,
+ canCreateDeployment: this.canCreateDeployment,
+ canReadEnvironment: this.canReadEnvironment,
+ },
+ });
+ },
+});
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 7219b076721..34d18d55120 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -1,7 +1,7 @@
/**
* Common code between environmets app and folder view
*/
-
+import _ from 'underscore';
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
import {
diff --git a/app/assets/javascripts/experimental_flags.js b/app/assets/javascripts/experimental_flags.js
index 6ee65ca72f9..1d60847147b 100644
--- a/app/assets/javascripts/experimental_flags.js
+++ b/app/assets/javascripts/experimental_flags.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Cookies from 'js-cookie';
export default () => {
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js
new file mode 100644
index 00000000000..c50ac667c20
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight.js
@@ -0,0 +1,66 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import {
+ getSelector,
+ togglePopover,
+ inserted,
+ mouseenter,
+ mouseleave,
+} from './feature_highlight_helper';
+
+export function setupFeatureHighlightPopover(id, debounceTimeout = 300) {
+ const $selector = $(getSelector(id));
+ const $parent = $selector.parent();
+ const $popoverContent = $parent.siblings('.feature-highlight-popover-content');
+ const hideOnScroll = togglePopover.bind($selector, false);
+ const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout);
+
+ $selector
+ // Setup popover
+ .data('content', $popoverContent.prop('outerHTML'))
+ .popover({
+ html: true,
+ // Override the existing template to add custom CSS classes
+ template: `
+ <div class="popover feature-highlight-popover" role="tooltip">
+ <div class="arrow"></div>
+ <div class="popover-content"></div>
+ </div>
+ `,
+ })
+ .on('mouseenter', mouseenter)
+ .on('mouseleave', debouncedMouseleave)
+ .on('inserted.bs.popover', inserted)
+ .on('show.bs.popover', () => {
+ window.addEventListener('scroll', hideOnScroll);
+ })
+ .on('hide.bs.popover', () => {
+ window.removeEventListener('scroll', hideOnScroll);
+ })
+ // Display feature highlight
+ .removeAttr('disabled');
+}
+
+export function findHighestPriorityFeature() {
+ let priorityFeature;
+
+ const sortedFeatureEls = [].slice.call(document.querySelectorAll('.js-feature-highlight')).sort((a, b) =>
+ (a.dataset.highlightPriority || 0) < (b.dataset.highlightPriority || 0));
+
+ const [priorityFeatureEl] = sortedFeatureEls;
+ if (priorityFeatureEl) {
+ priorityFeature = priorityFeatureEl.dataset.highlight;
+ }
+
+ return priorityFeature;
+}
+
+export function highlightFeatures() {
+ const priorityFeature = findHighestPriorityFeature();
+
+ if (priorityFeature) {
+ setupFeatureHighlightPopover(priorityFeature);
+ }
+
+ return priorityFeature;
+}
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
new file mode 100644
index 00000000000..f480e72961c
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import axios from '../lib/utils/axios_utils';
+import { __ } from '../locale';
+import Flash from '../flash';
+import LazyLoader from '../lazy_loader';
+
+export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`;
+
+export function togglePopover(show) {
+ const isAlreadyShown = this.hasClass('js-popover-show');
+ if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) {
+ return false;
+ }
+ this.popover(show ? 'show' : 'hide');
+ this.toggleClass('disable-animation js-popover-show', show);
+
+ return true;
+}
+
+export function dismiss(highlightId) {
+ axios.post(this.attr('data-dismiss-endpoint'), {
+ feature_name: highlightId,
+ })
+ .catch(() => Flash(__('An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.')));
+
+ togglePopover.call(this, false);
+ this.hide();
+}
+
+export function mouseleave() {
+ if (!$('.popover:hover').length > 0) {
+ const $featureHighlight = $(this);
+ togglePopover.call($featureHighlight, false);
+ }
+}
+
+export function mouseenter() {
+ const $featureHighlight = $(this);
+
+ const showedPopover = togglePopover.call($featureHighlight, true);
+ if (showedPopover) {
+ $('.popover')
+ .on('mouseleave', mouseleave.bind($featureHighlight));
+ }
+}
+
+export function inserted() {
+ const popoverId = this.getAttribute('aria-describedby');
+ const highlightId = this.dataset.highlight;
+ const $popover = $(this);
+ const dismissWrapper = dismiss.bind($popover, highlightId);
+
+ $(`#${popoverId} .dismiss-feature-highlight`)
+ .on('click', dismissWrapper);
+
+ const lazyImg = $(`#${popoverId} .feature-highlight-illustration`)[0];
+ if (lazyImg) {
+ LazyLoader.loadImage(lazyImg);
+ }
+}
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
new file mode 100644
index 00000000000..212643b1e04
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
@@ -0,0 +1,12 @@
+import { highlightFeatures } from './feature_highlight';
+import bp from '../breakpoints';
+
+export default function domContentLoaded() {
+ if (bp.getBreakpointSize() === 'lg') {
+ highlightFeatures();
+ return true;
+ }
+ return false;
+}
+
+document.addEventListener('DOMContentLoaded', domContentLoaded);
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 90020344748..6a4874e1ab8 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -25,7 +25,7 @@ export default {
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') === '';
+ this.userCanCreateNote = $diffFile.closest(DIFF_CONTAINER_SELECTOR).data('canCreateNote') === '';
}
this.isParallelView = Cookies.get('diff_view') === 'parallel';
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 9e91f72b2ea..b17ba3c21db 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -1,4 +1,6 @@
+import $ from 'jquery';
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
/**
* Makes search request for content when user types a value in the search input.
@@ -54,32 +56,26 @@ export default class FilterableList {
this.listFilterElement.removeEventListener('input', this.debounceFilter);
}
- filterResults(queryData) {
+ filterResults(params) {
if (this.isBusy) {
return false;
}
$(this.listHolderElement).fadeTo(250, 0.5);
- return $.ajax({
- url: this.getFilterEndpoint(),
- data: queryData,
- type: 'GET',
- dataType: 'json',
- context: this,
- complete: this.onFilterComplete,
- beforeSend: () => {
- this.isBusy = true;
- },
- success: (response, textStatus, xhr) => {
- this.onFilterSuccess(response, xhr, queryData);
- },
- });
+ this.isBusy = true;
+
+ return axios.get(this.getFilterEndpoint(), {
+ params,
+ }).then((res) => {
+ this.onFilterSuccess(res, params);
+ this.onFilterComplete();
+ }).catch(() => this.onFilterComplete());
}
- onFilterSuccess(response, xhr, queryData) {
- if (response.html) {
- this.listHolderElement.innerHTML = response.html;
+ onFilterSuccess(response, queryData) {
+ if (response.data.html) {
+ this.listHolderElement.innerHTML = response.data.html;
}
// Change url so if user reload a page - search results are saved
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
deleted file mode 100644
index c51d4b056af..00000000000
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import eventHub from '../event_hub';
-
-export default {
- name: 'RecentSearchesDropdownContent',
-
- props: {
- items: {
- 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, this.allowedKeys);
-
- const resultantTokens = tokens.map(token => ({
- prefix: `${token.key}:`,
- suffix: `${token.symbol}${token.value}`,
- }));
-
- return {
- text: item,
- tokens: resultantTokens,
- searchToken,
- };
- });
- },
- hasItems() {
- return this.items.length > 0;
- },
- },
-
- methods: {
- onItemActivated(text) {
- eventHub.$emit('recentSearchesItemSelected', text);
- },
- onRequestClearRecentSearches(e) {
- // Stop the dropdown from closing
- e.stopPropagation();
-
- eventHub.$emit('requestClearRecentSearches');
- },
- },
-
- template: `
- <div>
- <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">
- <button
- type="button"
- class="filtered-search-history-dropdown-item"
- @click="onItemActivated(item.text)">
- <span>
- <span
- v-for="(token, tokenIndex) in item.tokens"
- class="filtered-search-history-dropdown-token">
- <span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
- </span>
- </span>
- <span class="filtered-search-history-dropdown-search-token">
- {{ item.searchToken }}
- </span>
- </button>
- </li>
- <li class="divider"></li>
- <li>
- <button
- type="button"
- class="filtered-search-history-clear-button"
- @click="onRequestClearRecentSearches($event)">
- Clear recent searches
- </button>
- </li>
- </ul>
- <div
- v-else
- class="dropdown-info-note">
- You don't have any recent searches
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
new file mode 100644
index 00000000000..26618af9515
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -0,0 +1,104 @@
+<script>
+import eventHub from '../event_hub';
+import FilteredSearchTokenizer from '../filtered_search_tokenizer';
+
+export default {
+ name: 'RecentSearchesDropdownContent',
+ props: {
+ items: {
+ 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 }
+ = FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
+
+ const resultantTokens = tokens.map(token => ({
+ prefix: `${token.key}:`,
+ suffix: `${token.symbol}${token.value}`,
+ }));
+
+ return {
+ text: item,
+ tokens: resultantTokens,
+ searchToken,
+ };
+ });
+ },
+ hasItems() {
+ return this.items.length > 0;
+ },
+ },
+ methods: {
+ onItemActivated(text) {
+ eventHub.$emit('recentSearchesItemSelected', text);
+ },
+ onRequestClearRecentSearches(e) {
+ // Stop the dropdown from closing
+ e.stopPropagation();
+
+ eventHub.$emit('requestClearRecentSearches');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <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="`processed-items-${index}`"
+ >
+ <button
+ type="button"
+ class="filtered-search-history-dropdown-item"
+ @click="onItemActivated(item.text)">
+ <span>
+ <span
+ class="filtered-search-history-dropdown-token"
+ v-for="(token, index) in item.tokens"
+ :key="`dropdown-token-${index}`"
+ >
+ <span class="name">{{ token.prefix }}</span>
+ <span class="value">{{ token.suffix }}</span>
+ </span>
+ </span>
+ <span class="filtered-search-history-dropdown-search-token">
+ {{ item.searchToken }}
+ </span>
+ </button>
+ </li>
+ <li class="divider"></li>
+ <li>
+ <button
+ type="button"
+ class="filtered-search-history-clear-button"
+ @click="onRequestClearRecentSearches($event)">
+ Clear recent searches
+ </button>
+ </li>
+ </ul>
+ <div
+ v-else
+ class="dropdown-info-note">
+ You don't have any recent searches
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index a6cc079d720..5ddd0e5e690 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,9 +1,10 @@
import Flash from '../flash';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import './filtered_search_dropdown';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
-class DropdownEmoji extends gl.FilteredSearchDropdown {
+export default class DropdownEmoji extends FilteredSearchDropdown {
constructor(options = {}) {
super(options);
this.config = {
@@ -49,7 +50,7 @@ class DropdownEmoji extends gl.FilteredSearchDropdown {
itemClicked(e) {
super.itemClicked(e, (selected) => {
const name = selected.querySelector('.js-data-value').innerText.trim();
- return gl.DropdownUtils.getEscapedText(name);
+ return DropdownUtils.getEscapedText(name);
});
}
@@ -76,6 +77,3 @@ class DropdownEmoji extends gl.FilteredSearchDropdown {
.addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownEmoji = DropdownEmoji;
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 23040cd9eb8..184b34b7b5e 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,14 +1,17 @@
import Filter from '~/droplab/plugins/filter';
-import './filtered_search_dropdown';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
-class DropdownHint extends gl.FilteredSearchDropdown {
+export default class DropdownHint extends FilteredSearchDropdown {
constructor(options = {}) {
const { input, tokenKeys } = options;
super(options);
this.config = {
Filter: {
template: 'hint',
- filterFunction: gl.DropdownUtils.filterHint.bind(null, {
+ filterFunction: DropdownUtils.filterHint.bind(null, {
input,
allowedKeys: tokenKeys.getKeys(),
}),
@@ -45,10 +48,10 @@ class DropdownHint extends gl.FilteredSearchDropdown {
});
if (searchTerms.length > 0) {
- gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
+ FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
}
- gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container);
+ FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container);
}
this.dismissDropdown();
this.dispatchInputEvent();
@@ -73,6 +76,3 @@ class DropdownHint extends gl.FilteredSearchDropdown {
this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownHint = DropdownHint;
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 788fb1dc614..2ffda7e2037 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,9 +1,10 @@
import Flash from '../flash';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import './filtered_search_dropdown';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
-class DropdownNonUser extends gl.FilteredSearchDropdown {
+export default class DropdownNonUser extends FilteredSearchDropdown {
constructor(options = {}) {
const { input, endpoint, symbol, preprocessing } = options;
super(options);
@@ -21,7 +22,7 @@ class DropdownNonUser extends gl.FilteredSearchDropdown {
},
},
Filter: {
- filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
+ filterFunction: DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
template: 'title',
},
};
@@ -30,7 +31,7 @@ class DropdownNonUser extends gl.FilteredSearchDropdown {
itemClicked(e) {
super.itemClicked(e, (selected) => {
const title = selected.querySelector('.js-data-value').innerText.trim();
- return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`;
+ return `${this.symbol}${DropdownUtils.getEscapedText(title)}`;
});
}
@@ -45,6 +46,3 @@ class DropdownNonUser extends gl.FilteredSearchDropdown {
.addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownNonUser = DropdownNonUser;
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index a9e2b65def0..d36f38a70b5 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,9 +1,11 @@
import Flash from '../flash';
import AjaxFilter from '../droplab/plugins/ajax_filter';
-import './filtered_search_dropdown';
+import FilteredSearchDropdown from './filtered_search_dropdown';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
-class DropdownUser extends gl.FilteredSearchDropdown {
+export default class DropdownUser extends FilteredSearchDropdown {
constructor(options = {}) {
const { tokenKeys } = options;
super(options);
@@ -12,7 +14,6 @@ class DropdownUser extends gl.FilteredSearchDropdown {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
- per_page: 20,
active: true,
group_id: this.getGroupId(),
project_id: this.getProjectId(),
@@ -56,8 +57,8 @@ class DropdownUser extends gl.FilteredSearchDropdown {
}
getSearchInput() {
- const query = gl.DropdownUtils.getSearchInput(this.input);
- const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
+ const query = DropdownUtils.getSearchInput(this.input);
+ const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || '';
@@ -78,6 +79,3 @@ class DropdownUser extends gl.FilteredSearchDropdown {
this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
}
}
-
-window.gl = window.gl || {};
-gl.DropdownUser = DropdownUser;
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index cf8a9b0402b..9bc36c1f9b6 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -1,7 +1,10 @@
import _ from 'underscore';
import FilteredSearchContainer from './container';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
-class DropdownUtils {
+export default class DropdownUtils {
static getEscapedText(text) {
let escapedText = text;
const hasSpace = text.indexOf(' ') !== -1;
@@ -24,7 +27,7 @@ class DropdownUtils {
static filterWithSymbol(filterSymbol, input, item) {
const updatedItem = item;
- const searchInput = gl.DropdownUtils.getSearchInput(input);
+ const searchInput = DropdownUtils.getSearchInput(input);
const title = updatedItem.title.toLowerCase();
let value = searchInput.toLowerCase();
@@ -114,9 +117,9 @@ class DropdownUtils {
static filterHint(config, item) {
const { input, allowedKeys } = config;
const updatedItem = item;
- const searchInput = gl.DropdownUtils.getSearchQuery(input);
+ const searchInput = DropdownUtils.getSearchQuery(input);
const { lastToken, tokens } =
- gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
+ FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
@@ -140,7 +143,7 @@ class DropdownUtils {
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
- gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true);
+ FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true);
}
// Return boolean based on whether it was set
@@ -190,7 +193,7 @@ class DropdownUtils {
}
} else if (token.classList.contains('input-token')) {
const { isLastVisualTokenValid } =
- gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const inputValue = input && input.value;
@@ -211,7 +214,7 @@ class DropdownUtils {
static getSearchInput(filteredSearchInput) {
const inputValue = filteredSearchInput.value;
- const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput);
+ const { right } = DropdownUtils.getInputSelectionPosition(filteredSearchInput);
return inputValue.slice(0, right);
}
@@ -252,6 +255,3 @@ class DropdownUtils {
};
}
}
-
-window.gl = window.gl || {};
-gl.DropdownUtils = DropdownUtils;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
deleted file mode 100644
index 6d5dd747224..00000000000
--- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import './dropdown_emoji';
-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 9e9a9ef74be..fb4ae1d17dd 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,6 +1,9 @@
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
-class FilteredSearchDropdown {
+export default class FilteredSearchDropdown {
constructor({ droplab, dropdown, input, filter }) {
this.droplab = droplab;
this.hookId = input && input.id;
@@ -30,11 +33,11 @@ class FilteredSearchDropdown {
const { selected } = e.detail;
if (selected.tagName === 'LI' && selected.innerHTML) {
- const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected);
+ const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected);
if (!dataValueSet) {
const value = getValueFunction(selected);
- gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
+ FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
}
this.resetFilters();
@@ -108,6 +111,9 @@ class FilteredSearchDropdown {
if (hook) {
const data = hook.list.data || [];
+
+ if (!data) return;
+
const results = data.map((o) => {
const updated = o;
updated.droplab_hidden = false;
@@ -117,6 +123,3 @@ class FilteredSearchDropdown {
}
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchDropdown = FilteredSearchDropdown;
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 46c80dfd45e..d7e1de18d09 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,14 +1,33 @@
+import _ from 'underscore';
import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
-
-class FilteredSearchDropdownManager {
- constructor(baseEndpoint = '', tokenizer, page) {
+import FilteredSearchTokenKeys from './filtered_search_token_keys';
+import DropdownUtils from './dropdown_utils';
+import DropdownHint from './dropdown_hint';
+import DropdownEmoji from './dropdown_emoji';
+import DropdownNonUser from './dropdown_non_user';
+import DropdownUser from './dropdown_user';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+
+export default class FilteredSearchDropdownManager {
+ constructor({
+ baseEndpoint = '',
+ tokenizer,
+ page,
+ isGroup,
+ isGroupAncestor,
+ isGroupDecendent,
+ filteredSearchTokenKeys,
+ }) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = tokenizer;
- this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
+ this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
+ this.groupsOnly = isGroup;
+ this.includeAncestorGroups = isGroupAncestor;
+ this.includeDescendantGroups = isGroupDecendent;
this.setupMapping();
@@ -28,57 +47,92 @@ class FilteredSearchDropdownManager {
}
setupMapping() {
- this.mapping = {
+ const supportedTokens = this.filteredSearchTokenKeys.getKeys();
+ const allowedMappings = {
+ hint: {
+ reference: null,
+ gl: DropdownHint,
+ element: this.container.querySelector('#js-dropdown-hint'),
+ },
+ };
+ const availableMappings = {
author: {
reference: null,
- gl: 'DropdownUser',
+ gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
- gl: 'DropdownUser',
+ gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
- gl: 'DropdownNonUser',
+ gl: DropdownNonUser,
extraArguments: {
- endpoint: `${this.baseEndpoint}/milestones.json`,
+ endpoint: this.getMilestoneEndpoint(),
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
- gl: 'DropdownNonUser',
+ gl: DropdownNonUser,
extraArguments: {
- endpoint: `${this.baseEndpoint}/labels.json`,
+ endpoint: this.getLabelsEndpoint(),
symbol: '~',
- preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
+ preprocessing: DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
'my-reaction': {
reference: null,
- gl: 'DropdownEmoji',
+ gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
- hint: {
- reference: null,
- gl: 'DropdownHint',
- element: this.container.querySelector('#js-dropdown-hint'),
- },
};
+
+ supportedTokens.forEach((type) => {
+ if (availableMappings[type]) {
+ allowedMappings[type] = availableMappings[type];
+ }
+ });
+
+ this.mapping = allowedMappings;
+ }
+
+ getMilestoneEndpoint() {
+ const endpoint = `${this.baseEndpoint}/milestones.json`;
+
+ return endpoint;
+ }
+
+ getLabelsEndpoint() {
+ let endpoint = `${this.baseEndpoint}/labels.json?`;
+
+ if (this.groupsOnly) {
+ endpoint = `${endpoint}only_group_labels=true&`;
+ }
+
+ if (this.includeAncestorGroups) {
+ endpoint = `${endpoint}include_ancestor_groups=true&`;
+ }
+
+ if (this.includeDescendantGroups) {
+ endpoint = `${endpoint}include_descendant_groups=true`;
+ }
+
+ return endpoint;
}
static addWordToInput(tokenName, tokenValue = '', clicked = false) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
input.value = '';
if (clicked) {
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ FilteredSearchVisualTokens.moveInputToTheRight();
}
}
@@ -119,9 +173,9 @@ class FilteredSearchDropdownManager {
const extraArguments = mappingKey.extraArguments || {};
const glArguments = Object.assign({}, defaultArguments, extraArguments);
- // Passing glArguments to `new gl[glClass](<arguments>)`
+ // Passing glArguments to `new glClass(<arguments>)`
mappingKey.reference =
- new (Function.prototype.bind.apply(gl[glClass], [null, glArguments]))();
+ new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
}
if (firstLoad) {
@@ -159,7 +213,7 @@ class FilteredSearchDropdownManager {
}
setDropdown() {
- const query = gl.DropdownUtils.getSearchQuery(true);
+ const query = DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } =
this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
@@ -204,6 +258,3 @@ class FilteredSearchDropdownManager {
this.droplab.destroy();
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 2ba85c7da97..cf5ba1e1771 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,21 +1,48 @@
+import _ from 'underscore';
+import {
+ getParameterByName,
+ getUrlParamsArray,
+} from '~/lib/utils/common_utils';
import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import FilteredSearchContainer from './container';
+import FilteredSearchTokenKeys from './filtered_search_token_keys';
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';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import DropdownUtils from './dropdown_utils';
+
+export default class FilteredSearchManager {
+ constructor({
+ page,
+ isGroup = false,
+ isGroupAncestor = true,
+ isGroupDecendent = false,
+ filteredSearchTokenKeys = FilteredSearchTokenKeys,
+ stateFiltersSelector = '.issues-state-filters',
+ }) {
+ this.isGroup = isGroup;
+ this.isGroupAncestor = isGroupAncestor;
+ this.isGroupDecendent = isGroupDecendent;
+ this.states = ['opened', 'closed', 'merged', 'all'];
-class FilteredSearchManager {
- constructor(page) {
this.page = page;
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
- this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
+ this.filteredSearchTokenKeys = filteredSearchTokenKeys;
+ this.stateFiltersSelector = stateFiltersSelector;
+ this.recentsStorageKeyNames = {
+ issues: 'issue-recent-searches',
+ merge_requests: 'merge-request-recent-searches',
+ };
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
@@ -24,11 +51,7 @@ class FilteredSearchManager {
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}`;
+ const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
@@ -56,8 +79,16 @@ class FilteredSearchManager {
});
if (this.filteredSearchInput) {
- this.tokenizer = gl.FilteredSearchTokenizer;
- this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
+ this.tokenizer = FilteredSearchTokenizer;
+ this.dropdownManager = new FilteredSearchDropdownManager({
+ baseEndpoint: this.filteredSearchInput.getAttribute('data-base-endpoint') || '',
+ tokenizer: this.tokenizer,
+ page: this.page,
+ isGroup: this.isGroup,
+ isGroupAncestor: this.isGroupAncestor,
+ isGroupDecendent: this.isGroupDecendent,
+ filteredSearchTokenKeys: this.filteredSearchTokenKeys,
+ });
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
@@ -69,7 +100,6 @@ class FilteredSearchManager {
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
-
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
}
@@ -85,40 +115,33 @@ class FilteredSearchManager {
}
bindStateEvents() {
- this.stateFilters = document.querySelector('.container-fluid .issues-state-filters');
+ this.stateFilters = document.querySelector(`.container-fluid ${this.stateFiltersSelector}`);
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);
- }
+ this.applyToStateFilters((filterEl) => {
+ filterEl.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);
- }
+ this.applyToStateFilters((filterEl) => {
+ filterEl.removeEventListener('click', this.searchStateWrapper);
+ });
}
}
+ applyToStateFilters(callback) {
+ this.stateFilters.querySelectorAll('a[data-state]').forEach((filterEl) => {
+ if (this.states.indexOf(filterEl.dataset.state) > -1) {
+ callback(filterEl);
+ }
+ });
+ }
+
bindEvents() {
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
@@ -127,7 +150,7 @@ class FilteredSearchManager {
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this);
- this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
+ this.checkForBackspaceWrapper = this.checkForBackspace.call(this);
this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
this.editTokenWrapper = this.editToken.bind(this);
@@ -180,22 +203,34 @@ class FilteredSearchManager {
this.unbindStateEvents();
}
- checkForBackspace(e) {
- // 8 = Backspace Key
- // 46 = Delete Key
- if (e.keyCode === 8 || e.keyCode === 46) {
- const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ checkForBackspace() {
+ let backspaceCount = 0;
+
+ // closure for keeping track of the number of backspace keystrokes
+ return (e) => {
+ // 8 = Backspace Key
+ // 46 = Delete Key
+ if (e.keyCode === 8 || e.keyCode === 46) {
+ const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(lastVisualToken);
+ const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
+
+ if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
+ backspaceCount += 1;
+
+ if (backspaceCount === 2) {
+ backspaceCount = 0;
+ this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial();
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ }
+ }
- const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(lastVisualToken);
- const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
- if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
- this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
- gl.FilteredSearchVisualTokens.removeLastTokenPartial();
+ // Reposition dropdown so that it is aligned with cursor
+ this.dropdownManager.updateCurrentDropdownOffset();
+ } else {
+ backspaceCount = 0;
}
-
- // Reposition dropdown so that it is aligned with cursor
- this.dropdownManager.updateCurrentDropdownOffset();
- }
+ };
}
checkForEnter(e) {
@@ -254,7 +289,7 @@ class FilteredSearchManager {
e.stopImmediatePropagation();
const button = e.target.closest('.selectable');
- gl.FilteredSearchVisualTokens.selectToken(button, true);
+ FilteredSearchVisualTokens.selectToken(button, true);
this.removeSelectedToken();
}
}
@@ -266,7 +301,7 @@ class FilteredSearchManager {
const isElementTokensContainer = e.target.classList.contains('tokens-container');
if ((!isElementInFilteredSearch && !isElementInFilterDropdown) || isElementTokensContainer) {
- gl.FilteredSearchVisualTokens.moveInputToTheRight();
+ FilteredSearchVisualTokens.moveInputToTheRight();
this.dropdownManager.resetDropdowns();
}
}
@@ -279,13 +314,13 @@ class FilteredSearchManager {
if (token && canEdit) {
e.preventDefault();
e.stopPropagation();
- gl.FilteredSearchVisualTokens.editToken(token);
+ FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
}
toggleClearSearchButton() {
- const query = gl.DropdownUtils.getSearchQuery();
+ const query = DropdownUtils.getSearchQuery();
const hidden = 'hidden';
const hasHidden = this.clearSearchButton.classList.contains(hidden);
@@ -297,7 +332,7 @@ class FilteredSearchManager {
}
handleInputPlaceholder() {
- const query = gl.DropdownUtils.getSearchQuery();
+ const query = DropdownUtils.getSearchQuery();
const placeholder = 'Search or filter results...';
const currentPlaceholder = this.filteredSearchInput.placeholder;
@@ -317,7 +352,7 @@ class FilteredSearchManager {
}
removeSelectedToken() {
- gl.FilteredSearchVisualTokens.removeSelectedToken();
+ FilteredSearchVisualTokens.removeSelectedToken();
this.handleInputPlaceholder();
this.toggleClearSearchButton();
this.dropdownManager.updateCurrentDropdownOffset();
@@ -337,7 +372,7 @@ class FilteredSearchManager {
let canClearToken = t.classList.contains('js-visual-token');
if (canClearToken) {
- const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(t);
+ const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(t);
canClearToken = this.canEdit && this.canEdit(tokenName, tokenValue);
}
@@ -365,12 +400,12 @@ class FilteredSearchManager {
const { tokens, searchToken }
= this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys());
const { isLastVisualTokenValid }
- = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (isLastVisualTokenValid) {
tokens.forEach((t) => {
input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
- gl.FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`);
+ FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`);
});
const fragments = searchToken.split(':');
@@ -383,10 +418,10 @@ class FilteredSearchManager {
const searchTerms = inputValues.join(' ');
input.value = input.value.replace(searchTerms, '');
- gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
+ FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
}
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenKey);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenKey);
input.value = input.value.replace(`${tokenKey}:`, '');
}
} else {
@@ -394,7 +429,7 @@ class FilteredSearchManager {
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
- gl.FilteredSearchVisualTokens.addFilterVisualToken(searchToken);
+ FilteredSearchVisualTokens.addFilterVisualToken(searchToken);
// Trim the last space as seen in the if statement above
input.value = input.value.replace(searchToken, '').trim();
@@ -410,7 +445,7 @@ class FilteredSearchManager {
saveCurrentSearchQuery() {
// Don't save before we have fetched the already saved searches
this.fetchingRecentSearchesPromise.then(() => {
- const searchQuery = gl.DropdownUtils.getSearchQuery();
+ const searchQuery = DropdownUtils.getSearchQuery();
if (searchQuery.length > 0) {
const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
this.recentSearchesService.save(resultantSearches);
@@ -426,7 +461,7 @@ class FilteredSearchManager {
}
loadSearchParamsFromURL() {
- const urlParams = gl.utils.getUrlParamsArray();
+ const urlParams = getUrlParamsArray();
const params = this.getAllParams(urlParams);
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
@@ -442,7 +477,7 @@ class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey,
condition.value,
canEdit,
@@ -471,7 +506,7 @@ class FilteredSearchManager {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
canEdit,
@@ -482,7 +517,7 @@ class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
@@ -490,7 +525,7 @@ class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
- gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
@@ -522,13 +557,13 @@ class FilteredSearchManager {
search(state = null) {
const paths = [];
- const searchQuery = gl.DropdownUtils.getSearchQuery();
+ const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
- const currentState = state || gl.utils.getParameterByName('state') || 'opened';
+ const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
tokens.forEach((token) => {
@@ -607,6 +642,3 @@ class FilteredSearchManager {
return true;
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchManager = FilteredSearchManager;
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 be595d7df1a..087ef5cd6f2 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -71,7 +71,7 @@ const conditions = [{
value: 'none',
}];
-class FilteredSearchTokenKeys {
+export default class FilteredSearchTokenKeys {
static get() {
return tokenKeys;
}
@@ -121,6 +121,3 @@ class FilteredSearchTokenKeys {
.find(condition => condition.tokenKey === key && condition.value === value) || null;
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index f2e66503e5e..d75610f6d68 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -1,6 +1,6 @@
import './filtered_search_token_keys';
-class FilteredSearchTokenizer {
+export default class FilteredSearchTokenizer {
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)
@@ -50,6 +50,3 @@ class FilteredSearchTokenizer {
};
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchTokenizer = FilteredSearchTokenizer;
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 6139e81fe6d..600024c21c3 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,9 +1,12 @@
-import AjaxCache from '../lib/utils/ajax_cache';
+import _ from 'underscore';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import { objectToQueryString } from '~/lib/utils/common_utils';
import Flash from '../flash';
import FilteredSearchContainer from './container';
import UsersCache from '../lib/utils/users_cache';
+import DropdownUtils from './dropdown_utils';
-class FilteredSearchVisualTokens {
+export default class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
const inputLi = FilteredSearchContainer.container.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling;
@@ -14,6 +17,21 @@ class FilteredSearchVisualTokens {
};
}
+ /**
+ * Returns a computed API endpoint
+ * and query string composed of values from endpointQueryParams
+ * @param {String} endpoint
+ * @param {String} endpointQueryParams
+ */
+ static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
+ if (!endpointQueryParams) {
+ return endpoint;
+ }
+
+ const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
+ return `${endpoint}?${queryString}`;
+ }
+
static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token .selectable.selected');
[].forEach.call(otherTokens, t => t.classList.remove('selected'));
@@ -73,7 +91,7 @@ class FilteredSearchVisualTokens {
let processed = labels;
if (!labels.preprocessed) {
- processed = gl.DropdownUtils.duplicateLabelPreprocessing(labels);
+ processed = DropdownUtils.duplicateLabelPreprocessing(labels);
AjaxCache.override(labelsEndpoint, processed);
processed.preprocessed = true;
}
@@ -84,12 +102,15 @@ class FilteredSearchVisualTokens {
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const baseEndpoint = filteredSearchInput.dataset.baseEndpoint;
- const labelsEndpoint = `${baseEndpoint}/labels.json`;
+ const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
+ `${baseEndpoint}/labels.json`,
+ filteredSearchInput.dataset.endpointQueryParams,
+ );
return AjaxCache.retrieve(labelsEndpoint)
.then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
.then((labels) => {
- const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
+ const matchingLabel = (labels || []).find(label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue);
if (!matchingLabel) {
return;
@@ -258,11 +279,11 @@ class FilteredSearchVisualTokens {
static tokenizeInput() {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const { isLastVisualTokenValid } =
- gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (input.value) {
if (isLastVisualTokenValid) {
- gl.FilteredSearchVisualTokens.addSearchVisualToken(input.value);
+ FilteredSearchVisualTokens.addSearchVisualToken(input.value);
} else {
FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement(input.value);
}
@@ -323,12 +344,12 @@ class FilteredSearchVisualTokens {
if (!tokenContainer.lastElementChild.isEqualNode(inputLi)) {
const { isLastVisualTokenValid } =
- gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!isLastVisualTokenValid) {
- const lastPartial = gl.FilteredSearchVisualTokens.getLastTokenPartial();
- gl.FilteredSearchVisualTokens.removeLastTokenPartial();
- gl.FilteredSearchVisualTokens.addSearchVisualToken(lastPartial);
+ const lastPartial = FilteredSearchVisualTokens.getLastTokenPartial();
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ FilteredSearchVisualTokens.addSearchVisualToken(lastPartial);
}
tokenContainer.removeChild(inputLi);
@@ -336,6 +357,3 @@ class FilteredSearchVisualTokens {
}
}
}
-
-window.gl = window.gl || {};
-gl.FilteredSearchVisualTokens = FilteredSearchVisualTokens;
diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js
index 27e49d4fb96..f9338b82acf 100644
--- a/app/assets/javascripts/filtered_search/recent_searches_root.js
+++ b/app/assets/javascripts/filtered_search/recent_searches_root.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
+import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content.vue';
import eventHub from './event_hub';
class RecentSearchesRoot {
@@ -32,6 +32,9 @@ class RecentSearchesRoot {
const state = this.store.state;
this.vm = new Vue({
el: this.wrapperElement,
+ components: {
+ RecentSearchesDropdownContent,
+ },
data() { return state; },
template: `
<recent-searches-dropdown-content
@@ -40,9 +43,6 @@ class RecentSearchesRoot {
:allowed-keys="allowedKeys"
/>
`,
- components: {
- 'recent-searches-dropdown-content': RecentSearchesDropdownContent,
- },
});
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 44deab9288e..a0af2875ab5 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -10,6 +10,7 @@ const hideFlash = (flashEl, fadeTransition = true) => {
flashEl.addEventListener('transitionend', () => {
flashEl.remove();
+ if (document.body.classList.contains('flash-shown')) document.body.classList.remove('flash-shown');
}, {
once: true,
passive: true,
@@ -64,6 +65,7 @@ const createFlash = function createFlash(
parent = document,
actionConfig = null,
fadeTransition = true,
+ addBodyClass = false,
) {
const flashContainer = parent.querySelector('.flash-container');
@@ -86,6 +88,8 @@ const createFlash = function createFlash(
flashContainer.style.display = 'block';
+ if (addBodyClass) document.body.classList.add('flash-shown');
+
return flashContainer;
};
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
index abb04d77f8f..8b4f3b05ee7 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -118,14 +118,14 @@ export const showSubLevelItems = (el) => {
moveSubItemsToPosition(el, subItems);
};
-export const mouseEnterTopItems = (el) => {
+export const mouseEnterTopItems = (el, timeout = getHideSubItemsInterval()) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (currentOpenMenu) hideMenu(currentOpenMenu);
showSubLevelItems(el);
- }, getHideSubItemsInterval());
+ }, timeout);
};
export const mouseLeaveTopItem = (el) => {
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index d918d80df8d..7e9770a9ea2 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import _ from 'underscore';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
@@ -53,16 +54,17 @@ class GfmAutoComplete {
alias: 'commands',
searchKey: 'search',
skipSpecialCharacterTest: true,
+ skipMarkdownCharacterTest: 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}';
+ let tpl = '<li><span class="name">/${name}</span>';
if (value.aliases.length > 0) {
- tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
+ tpl += ' <small class="aliases">(or /<%- aliases.join(", /") %>)</small>';
}
if (value.params.length > 0) {
- tpl += ' <small><%- params.join(" ") %></small>';
+ tpl += ' <small class="params"><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %></i></small>';
@@ -131,9 +133,8 @@ class GfmAutoComplete {
callbacks: {
...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);
+ const match = regexp.exec(subtext);
return match && match.length ? match[1] : null;
},
@@ -376,15 +377,23 @@ class GfmAutoComplete {
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
},
beforeInsert(value) {
- let resultantValue = value;
+ let withoutAt = value.substring(1);
+ const at = value.charAt();
+
if (value && !this.setting.skipSpecialCharacterTest) {
- const withoutAt = value.substring(1);
- const regex = value.charAt() === '~' ? /\W|^\d+$/ : /\W/;
+ const regex = at === '~' ? /\W|^\d+$/ : /\W/;
if (withoutAt && regex.test(withoutAt)) {
- resultantValue = `${value.charAt()}"${withoutAt}"`;
+ withoutAt = `"${withoutAt}"`;
}
}
- return resultantValue;
+
+ // We can ignore this for quick actions because they are processed
+ // before Markdown.
+ if (!this.setting.skipMarkdownCharacterTest) {
+ withoutAt = withoutAt.replace(/([~\-_*`])/g, '\\$&');
+ }
+
+ return `${at}${withoutAt}`;
},
matcher(flag, subtext) {
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
@@ -461,7 +470,7 @@ class GfmAutoComplete {
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 regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
return regexp.exec(targetSubtext);
}
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 64f258aed64..fa48d7d1915 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,7 +1,10 @@
/* 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 'jquery';
import _ from 'underscore';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import axios from './lib/utils/axios_utils';
import { visitUrl } from './lib/utils/url_utility';
import { isObject } from './lib/utils/type_utility';
@@ -212,25 +215,17 @@ GitLabDropdownRemote = (function() {
};
GitLabDropdownRemote.prototype.fetchData = function() {
- return $.ajax({
- url: this.dataEndpoint,
- dataType: this.options.dataType,
- beforeSend: (function(_this) {
- return function() {
- if (_this.options.beforeSend) {
- return _this.options.beforeSend();
- }
- };
- })(this),
- success: (function(_this) {
- return function(data) {
- if (_this.options.success) {
- return _this.options.success(data);
- }
- };
- })(this)
- });
- // Fetch the data through ajax if the data is a string
+ if (this.options.beforeSend) {
+ this.options.beforeSend();
+ }
+
+ // Fetch the data through ajax if the data is a string
+ return axios.get(this.dataEndpoint)
+ .then(({ data }) => {
+ if (this.options.success) {
+ return this.options.success(data);
+ }
+ });
};
return GitLabDropdownRemote;
@@ -492,7 +487,7 @@ GitLabDropdown = (function() {
$target = $(e.target);
if ($target && !$target.hasClass('dropdown-menu-close') &&
!$target.hasClass('dropdown-menu-close-icon') &&
- !$target.data('is-link')) {
+ !$target.data('isLink')) {
e.stopPropagation();
return false;
} else {
@@ -583,7 +578,7 @@ GitLabDropdown = (function() {
for (var i = 0; i < html.length; i += 1) {
var el = html[i];
- if (el instanceof jQuery) {
+ if (el instanceof $) {
el = el.get(0);
}
@@ -614,7 +609,20 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.renderItem = function(data, group, index) {
- var field, fieldName, html, selected, text, url, value;
+ var field, fieldName, html, selected, text, url, value, rowHidden;
+
+ if (!this.options.renderRow) {
+ value = this.options.id ? this.options.id(data) : data.id;
+
+ if (value) {
+ value = value.toString().replace(/'/g, '\\\'');
+ }
+ }
+
+ // Hide element
+ if (this.options.hideRow && this.options.hideRow(value)) {
+ rowHidden = true;
+ }
if (group == null) {
group = false;
}
@@ -623,6 +631,7 @@ GitLabDropdown = (function() {
index = false;
}
html = document.createElement('li');
+
if (data === 'divider' || data === 'separator') {
html.className = data;
return html;
@@ -638,11 +647,9 @@ GitLabDropdown = (function() {
html = this.options.renderRow.call(this.options, data, this);
} else {
if (!selected) {
- value = this.options.id ? this.options.id(data) : data.id;
fieldName = this.options.fieldName;
if (value) {
- value = value.toString().replace(/'/g, '\\\'');
field = this.dropdown.parent().find(`input[name='${fieldName}'][value='${value}']`);
if (field.length) {
selected = true;
@@ -746,7 +753,7 @@ GitLabDropdown = (function() {
}
if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
- return;
+ return [selectedObject];
}
if (el.hasClass(ACTIVE_CLASS) && value !== 0) {
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index bd63f6f16f0..972b2252acb 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
/**
* This class overrides the browser's validation error bubbles, displaying custom
* error messages for invalid fields instead. To begin validating any form, add the
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index 73bcbd93565..b9c51045b1d 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import GlFieldError from './gl_field_error';
const customValidationFlag = 'gl-field-error-ignore';
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index d0f9e6af0f8..9f5eba353d7 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -1,8 +1,8 @@
-/* global autosize */
-
+import $ from 'jquery';
+import autosize from 'autosize';
import GfmAutoComplete from './gfm_auto_complete';
import dropzoneInput from './dropzone_input';
-import textUtils from './lib/utils/text_markdown';
+import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
export default class GLForm {
constructor(form, enableGFM = false) {
@@ -13,7 +13,7 @@ export default class GLForm {
this.destroy();
// Setup the form
this.setupForm();
- this.form.data('gl-form', this);
+ this.form.data('glForm', this);
}
destroy() {
@@ -22,7 +22,7 @@ export default class GLForm {
if (this.autoComplete) {
this.autoComplete.destroy();
}
- this.form.data('gl-form', null);
+ this.form.data('glForm', null);
}
setupForm() {
@@ -47,7 +47,7 @@ export default class GLForm {
}
// form and textarea event listeners
this.addEventListeners();
- textUtils.init(this.form);
+ addMarkdownListeners(this.form);
// hide discard button
this.form.find('.js-note-discard').hide();
this.form.show();
@@ -86,7 +86,7 @@ export default class GLForm {
clearEventListeners() {
this.textarea.off('focus');
this.textarea.off('blur');
- textUtils.removeListeners(this.form);
+ removeMarkdownListeners(this.form);
}
addEventListeners() {
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index 7ac9dcd1112..502e3569321 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -1,3 +1,9 @@
+import $ from 'jquery';
+import { parseQueryStringIntoObject } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
export default class GpgBadges {
static fetch() {
const badges = $('.js-loading-gpg-badge');
@@ -5,13 +11,13 @@ export default class GpgBadges {
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) => {
+ const params = parseQueryStringIntoObject(form.serialize());
+ return axios.get(form.data('signaturesPath'), { params })
+ .then(({ data }) => {
+ data.signatures.forEach((signature) => {
badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html);
});
- });
+ })
+ .catch(() => flash(__('An error occurred while loading commits')));
}
}
diff --git a/app/assets/javascripts/graphs/graphs_bundle.js b/app/assets/javascripts/graphs/graphs_bundle.js
deleted file mode 100644
index 534bc535bb6..00000000000
--- a/app/assets/javascripts/graphs/graphs_bundle.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import Chart from 'vendor/Chart';
-
-// export to global scope
-window.Chart = Chart;
diff --git a/app/assets/javascripts/graphs/graphs_charts.js b/app/assets/javascripts/graphs/graphs_charts.js
deleted file mode 100644
index ec6eab34989..00000000000
--- a/app/assets/javascripts/graphs/graphs_charts.js
+++ /dev/null
@@ -1,61 +0,0 @@
-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
deleted file mode 100644
index 36bad6db3e1..00000000000
--- a/app/assets/javascripts/graphs/graphs_show.js
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 151a4ce012c..00000000000
--- a/app/assets/javascripts/graphs/stat_graph_contributors.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/* 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 { ContributorsGraph, ContributorsAuthorGraph, ContributorsMasterGraph } from './stat_graph_contributors_graph';
-import ContributorsStatGraphUtil from './stat_graph_contributors_util';
-import { n__, s__, createDateTimeFormat, sprintf } from '../locale';
-
-export default (function() {
- function ContributorsStatGraph() {
- this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
- }
-
- ContributorsStatGraph.prototype.init = function(log) {
- var author_commits, total_commits;
- this.parsed_log = ContributorsStatGraphUtil.parse_log(log);
- this.set_current_field("commits");
- total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
- author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field);
- this.add_master_graph(total_commits);
- this.add_authors_graph(author_commits);
- return this.change_date_header();
- };
-
- ContributorsStatGraph.prototype.add_master_graph = function(total_data) {
- this.master_graph = new ContributorsMasterGraph(total_data);
- return this.master_graph.draw();
- };
-
- ContributorsStatGraph.prototype.add_authors_graph = function(author_data) {
- var limited_author_data;
- this.authors = [];
- limited_author_data = author_data.slice(0, 100);
- return _.each(limited_author_data, (function(_this) {
- return function(d) {
- var author_graph, author_header;
- author_header = _this.create_author_header(d);
- $(".contributors-list").append(author_header);
- _this.authors[d.author_name] = author_graph = new ContributorsAuthorGraph(d.dates);
- return author_graph.draw();
- };
- })(this));
- };
-
- ContributorsStatGraph.prototype.format_author_commit_info = function(author) {
- var commits;
- commits = $('<span/>', {
- "class": 'graph-author-commits-count'
- });
- commits.text(n__('%d commit', '%d commits', author.commits));
- return $('<span/>').append(commits);
- };
-
- ContributorsStatGraph.prototype.create_author_header = function(author) {
- var author_commit_info, author_commit_info_span, author_email, author_name, list_item;
- list_item = $('<li/>', {
- "class": 'person',
- style: 'display: block;'
- });
- author_name = $('<h4>' + author.author_name + '</h4>');
- author_email = $('<p class="graph-author-email">' + author.author_email + '</p>');
- author_commit_info_span = $('<span/>', {
- "class": 'commits'
- });
- author_commit_info = this.format_author_commit_info(author);
- author_commit_info_span.html(author_commit_info);
- list_item.append(author_name);
- list_item.append(author_email);
- list_item.append(author_commit_info_span);
- return list_item;
- };
-
- ContributorsStatGraph.prototype.redraw_master = function() {
- var total_data;
- total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
- this.master_graph.set_data(total_data);
- return this.master_graph.redraw();
- };
-
- ContributorsStatGraph.prototype.redraw_authors = function() {
- var author_commits, x_domain;
- $("ol").html("");
- x_domain = ContributorsGraph.prototype.x_domain;
- author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field, x_domain);
- return _.each(author_commits, (function(_this) {
- return function(d) {
- _this.redraw_author_commit_info(d);
- if (_this.authors[d.author_name] != null) {
- $(_this.authors[d.author_name].list_item).appendTo("ol");
- _this.authors[d.author_name].set_data(d.dates);
- return _this.authors[d.author_name].redraw();
- }
- return '';
- };
- })(this));
- };
-
- ContributorsStatGraph.prototype.set_current_field = function(field) {
- return this.field = field;
- };
-
- ContributorsStatGraph.prototype.change_date_header = function() {
- const x_domain = ContributorsGraph.prototype.x_domain;
- const formattedDateRange = sprintf(
- s__('ContributorsPage|%{startDate} – %{endDate}'),
- {
- startDate: this.dateFormat.format(new Date(x_domain[0])),
- endDate: this.dateFormat.format(new Date(x_domain[1])),
- },
- );
- return $('#date_header').text(formattedDateRange);
- };
-
- ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) {
- var author_commit_info, author_list_item, $author;
- $author = this.authors[author.author_name];
- if ($author != null) {
- author_list_item = $(this.authors[author.author_name].list_item);
- author_commit_info = this.format_author_commit_info(author);
- return author_list_item.find("span").html(author_commit_info);
- }
- return '';
- };
-
- return ContributorsStatGraph;
-})();
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
deleted file mode 100644
index 9a4012232a0..00000000000
--- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js
+++ /dev/null
@@ -1,294 +0,0 @@
-/* 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 { extent, max } from 'd3-array';
-import { select, event as d3Event } from 'd3-selection';
-import { scaleTime, scaleLinear } from 'd3-scale';
-import { axisLeft, axisBottom } from 'd3-axis';
-import { area } from 'd3-shape';
-import { brushX } from 'd3-brush';
-import { timeParse } from 'd3-time-format';
-import { dateTickFormat } from '../lib/utils/tick_formats';
-
-const d3 = { extent, max, select, scaleTime, scaleLinear, axisLeft, axisBottom, area, brushX, timeParse };
-
-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;
-
-export const ContributorsGraph = (function() {
- function ContributorsGraph() {}
-
- ContributorsGraph.prototype.MARGIN = {
- top: 20,
- right: 20,
- bottom: 30,
- left: 50
- };
-
- ContributorsGraph.prototype.x_domain = null;
-
- ContributorsGraph.prototype.y_domain = null;
-
- ContributorsGraph.prototype.dates = [];
-
- ContributorsGraph.set_x_domain = function(data) {
- return ContributorsGraph.prototype.x_domain = data;
- };
-
- ContributorsGraph.set_y_domain = function(data) {
- return ContributorsGraph.prototype.y_domain = [
- 0, d3.max(data, function(d) {
- return d.commits = d.commits || d.additions || d.deletions;
- })
- ];
- };
-
- ContributorsGraph.init_x_domain = function(data) {
- return ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) {
- return d.date;
- });
- };
-
- ContributorsGraph.init_y_domain = function(data) {
- return ContributorsGraph.prototype.y_domain = [
- 0, d3.max(data, function(d) {
- return d.commits = d.commits || d.additions || d.deletions;
- })
- ];
- };
-
- ContributorsGraph.init_domain = function(data) {
- ContributorsGraph.init_x_domain(data);
- return ContributorsGraph.init_y_domain(data);
- };
-
- ContributorsGraph.set_dates = function(data) {
- return ContributorsGraph.prototype.dates = data;
- };
-
- ContributorsGraph.prototype.set_x_domain = function() {
- return this.x.domain(this.x_domain);
- };
-
- ContributorsGraph.prototype.set_y_domain = function() {
- return this.y.domain(this.y_domain);
- };
-
- ContributorsGraph.prototype.set_domain = function() {
- this.set_x_domain();
- return this.set_y_domain();
- };
-
- ContributorsGraph.prototype.create_scale = function(width, height) {
- this.x = d3.scaleTime().range([0, width]).clamp(true);
- return this.y = d3.scaleLinear().range([height, 0]).nice();
- };
-
- ContributorsGraph.prototype.draw_x_axis = function() {
- return this.svg.append("g").attr("class", "x axis").attr("transform", "translate(0, " + this.height + ")").call(this.x_axis);
- };
-
- ContributorsGraph.prototype.draw_y_axis = function() {
- return this.svg.append("g").attr("class", "y axis").call(this.y_axis);
- };
-
- ContributorsGraph.prototype.set_data = function(data) {
- return this.data = data;
- };
-
- return ContributorsGraph;
-})();
-
-export const ContributorsMasterGraph = (function(superClass) {
- extend(ContributorsMasterGraph, superClass);
-
- function ContributorsMasterGraph(data1) {
- const $parentElement = $('#contributors-master');
- const parentPadding = parseFloat($parentElement.css('padding-left')) + parseFloat($parentElement.css('padding-right'));
-
- this.data = data1;
- this.update_content = this.update_content.bind(this);
- this.width = $('.content').width() - parentPadding - (this.MARGIN.left + this.MARGIN.right);
- this.height = 200;
- this.x = null;
- this.y = null;
- this.x_axis = null;
- this.y_axis = null;
- this.area = null;
- this.svg = null;
- this.brush = null;
- this.x_max_domain = null;
- }
-
- ContributorsMasterGraph.prototype.process_dates = function(data) {
- var dates;
- dates = this.get_dates(data);
- this.parse_dates(data);
- return ContributorsGraph.set_dates(dates);
- };
-
- ContributorsMasterGraph.prototype.get_dates = function(data) {
- return _.pluck(data, 'date');
- };
-
- ContributorsMasterGraph.prototype.parse_dates = function(data) {
- var parseDate;
- parseDate = d3.timeParse("%Y-%m-%d");
- return data.forEach(function(d) {
- return d.date = parseDate(d.date);
- });
- };
-
- ContributorsMasterGraph.prototype.create_scale = function() {
- return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height);
- };
-
- ContributorsMasterGraph.prototype.create_axes = function() {
- this.x_axis = d3.axisBottom()
- .scale(this.x)
- .tickFormat(dateTickFormat);
- return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
- };
-
- ContributorsMasterGraph.prototype.create_svg = function() {
- return this.svg = d3.select("#contributors-master").append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "tint-box").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
- };
-
- ContributorsMasterGraph.prototype.create_area = function(x, y) {
- return this.area = d3.area().x(function(d) {
- return x(d.date);
- }).y0(this.height).y1(function(d) {
- d.commits = d.commits || d.additions || d.deletions;
- return y(d.commits);
- });
- };
-
- ContributorsMasterGraph.prototype.create_brush = function() {
- return this.brush = d3.brushX(this.x).extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]).on("end", this.update_content);
- };
-
- ContributorsMasterGraph.prototype.draw_path = function(data) {
- return this.svg.append("path").datum(data).attr("class", "area").attr("d", this.area);
- };
-
- ContributorsMasterGraph.prototype.add_brush = function() {
- return this.svg.append("g").attr("class", "selection").call(this.brush).selectAll("rect").attr("height", this.height);
- };
-
- ContributorsMasterGraph.prototype.update_content = function() {
- // d3Event.selection replaces the function brush.empty() calls
- if (d3Event.selection != null) {
- ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert));
- } else {
- ContributorsGraph.set_x_domain(this.x_max_domain);
- }
- return $("#brush_change").trigger('change');
- };
-
- ContributorsMasterGraph.prototype.draw = function() {
- this.process_dates(this.data);
- this.create_scale();
- this.create_axes();
- ContributorsGraph.init_domain(this.data);
- this.x_max_domain = this.x_domain;
- this.set_domain();
- this.create_area(this.x, this.y);
- this.create_svg();
- this.create_brush();
- this.draw_path(this.data);
- this.draw_x_axis();
- this.draw_y_axis();
- return this.add_brush();
- };
-
- ContributorsMasterGraph.prototype.redraw = function() {
- this.process_dates(this.data);
- ContributorsGraph.set_y_domain(this.data);
- this.set_y_domain();
- this.svg.select("path").datum(this.data);
- this.svg.select("path").attr("d", this.area);
- return this.svg.select(".y.axis").call(this.y_axis);
- };
-
- return ContributorsMasterGraph;
-})(ContributorsGraph);
-
-export const ContributorsAuthorGraph = (function(superClass) {
- extend(ContributorsAuthorGraph, superClass);
-
- function ContributorsAuthorGraph(data1) {
- this.data = data1;
- // Don't split graph size in half for mobile devices.
- if ($(window).width() < 768) {
- this.width = $('.content').width() - 80;
- } else {
- this.width = ($('.content').width() / 2) - 100;
- }
- this.height = 200;
- this.x = null;
- this.y = null;
- this.x_axis = null;
- this.y_axis = null;
- this.area = null;
- this.svg = null;
- this.list_item = null;
- }
-
- ContributorsAuthorGraph.prototype.create_scale = function() {
- return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height);
- };
-
- ContributorsAuthorGraph.prototype.create_axes = function() {
- this.x_axis = d3.axisBottom()
- .scale(this.x)
- .ticks(8)
- .tickFormat(dateTickFormat);
- return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
- };
-
- ContributorsAuthorGraph.prototype.create_area = function(x, y) {
- return this.area = d3.area().x(function(d) {
- var parseDate;
- parseDate = d3.timeParse("%Y-%m-%d");
- return x(parseDate(d));
- }).y0(this.height).y1((function(_this) {
- return function(d) {
- if (_this.data[d] != null) {
- return y(_this.data[d]);
- } else {
- return y(0);
- }
- };
- })(this));
- };
-
- ContributorsAuthorGraph.prototype.create_svg = function() {
- var persons = document.querySelectorAll('.person');
- this.list_item = persons[persons.length - 1];
- return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
- };
-
- ContributorsAuthorGraph.prototype.draw_path = function(data) {
- return this.svg.append("path").datum(data).attr("class", "area-contributor").attr("d", this.area);
- };
-
- ContributorsAuthorGraph.prototype.draw = function() {
- this.create_scale();
- this.create_axes();
- this.set_domain();
- this.create_area(this.x, this.y);
- this.create_svg();
- this.draw_path(this.dates);
- this.draw_x_axis();
- return this.draw_y_axis();
- };
-
- ContributorsAuthorGraph.prototype.redraw = function() {
- this.set_domain();
- this.svg.select("path").datum(this.dates);
- this.svg.select("path").attr("d", this.area);
- this.svg.select(".x.axis").call(this.x_axis);
- return this.svg.select(".y.axis").call(this.y_axis);
- };
-
- return ContributorsAuthorGraph;
-})(ContributorsGraph);
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 7732edde1e7..4365305c168 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default class Group {
constructor() {
this.groupPath = $('#group_path');
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/group_avatar.js
index 2168ff3a8ba..beaac61e887 100644
--- a/app/assets/javascripts/group_avatar.js
+++ b/app/assets/javascripts/group_avatar.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default function groupAvatar() {
$('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() {
const form = $(this).closest('form');
diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js
index befaebb635e..5648cb9a888 100644
--- a/app/assets/javascripts/group_label_subscription.js
+++ b/app/assets/javascripts/group_label_subscription.js
@@ -1,3 +1,8 @@
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+import { __ } from './locale';
+
export default class GroupLabelSubscription {
constructor(container) {
const $container = $(container);
@@ -13,14 +18,12 @@ export default class GroupLabelSubscription {
event.preventDefault();
const url = this.$unsubscribeButtons.attr('data-url');
-
- $.ajax({
- type: 'POST',
- url,
- }).done(() => {
- this.toggleSubscriptionButtons();
- this.$unsubscribeButtons.removeAttr('data-url');
- });
+ axios.post(url)
+ .then(() => {
+ this.toggleSubscriptionButtons();
+ this.$unsubscribeButtons.removeAttr('data-url');
+ })
+ .catch(() => flash(__('There was an error when unsubscribing from this label.')));
}
subscribe(event) {
@@ -31,12 +34,9 @@ export default class GroupLabelSubscription {
this.$unsubscribeButtons.attr('data-url', url);
- $.ajax({
- type: 'POST',
- url,
- }).done(() => {
- this.toggleSubscriptionButtons();
- });
+ axios.post(url)
+ .then(() => this.toggleSubscriptionButtons())
+ .catch(() => flash(__('There was an error when subscribing to this label.')));
}
toggleSubscriptionButtons() {
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 241e026b84c..22eb7bd44c5 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -1,16 +1,21 @@
<script>
/* global Flash */
+import $ from 'jquery';
+import { s__ } from '~/locale';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+
import eventHub from '../event_hub';
-import { getParameterByName } from '../../lib/utils/common_utils';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import { COMMON_STR } from '../constants';
-import { mergeUrlParams } from '../../lib/utils/url_utility';
import groupsComponent from './groups.vue';
export default {
components: {
loadingIcon,
+ DeprecatedModal,
groupsComponent,
},
props: {
@@ -32,6 +37,10 @@ export default {
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
+ showModal: false,
+ groupLeaveConfirmationMessage: '',
+ targetGroup: null,
+ targetParentGroup: null,
};
},
computed: {
@@ -42,23 +51,52 @@ export default {
return this.store.getPaginationInfo();
},
},
+ created() {
+ this.searchEmptyMessage = this.hideProjects
+ ? COMMON_STR.GROUP_SEARCH_EMPTY
+ : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
+
+ eventHub.$on('fetchPage', this.fetchPage);
+ eventHub.$on('toggleChildren', this.toggleChildren);
+ eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal);
+ eventHub.$on('updatePagination', this.updatePagination);
+ eventHub.$on('updateGroups', this.updateGroups);
+ },
+ mounted() {
+ this.fetchAllGroups();
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchPage', this.fetchPage);
+ eventHub.$off('toggleChildren', this.toggleChildren);
+ eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal);
+ eventHub.$off('updatePagination', this.updatePagination);
+ eventHub.$off('updateGroups', this.updateGroups);
+ },
methods: {
- fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
- return this.service.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
- .then((res) => {
- if (updatePagination) {
- this.updatePagination(res.headers);
- }
+ fetchGroups({
+ parentId,
+ page,
+ filterGroupsBy,
+ sortBy,
+ archived,
+ updatePagination,
+ }) {
+ return this.service
+ .getGroups(parentId, page, filterGroupsBy, sortBy, archived)
+ .then(res => {
+ if (updatePagination) {
+ this.updatePagination(res.headers);
+ }
- return res;
- })
- .then(res => res.json())
- .catch(() => {
- this.isLoading = false;
- $.scrollTo(0);
+ return res;
+ })
+ .then(res => res.json())
+ .catch(() => {
+ this.isLoading = false;
+ $.scrollTo(0);
- Flash(COMMON_STR.FAILURE);
- });
+ Flash(COMMON_STR.FAILURE);
+ });
},
fetchAllGroups() {
const page = getParameterByName('page') || null;
@@ -74,7 +112,7 @@ export default {
sortBy,
archived,
updatePagination: true,
- }).then((res) => {
+ }).then(res => {
this.isLoading = false;
this.updateGroups(res, Boolean(filterGroupsBy));
});
@@ -89,14 +127,18 @@ export default {
sortBy,
archived,
updatePagination: true,
- }).then((res) => {
+ }).then(res => {
this.isLoading = false;
$.scrollTo(0);
const currentPath = mergeUrlParams({ page }, window.location.href);
- window.history.replaceState({
- page: currentPath,
- }, document.title, currentPath);
+ window.history.replaceState(
+ {
+ page: currentPath,
+ },
+ document.title,
+ currentPath,
+ );
this.updateGroups(res);
});
@@ -109,11 +151,13 @@ export default {
// eslint-disable-next-line promise/catch-or-return
this.fetchGroups({
parentId: parentGroup.id,
- }).then((res) => {
- this.store.setGroupChildren(parentGroup, res);
- }).catch(() => {
- parentGroup.isChildrenLoading = false;
- });
+ })
+ .then(res => {
+ this.store.setGroupChildren(parentGroup, res);
+ })
+ .catch(() => {
+ parentGroup.isChildrenLoading = false;
+ });
} else {
parentGroup.isOpen = true;
}
@@ -121,23 +165,37 @@ export default {
parentGroup.isOpen = false;
}
},
- leaveGroup(group, parentGroup) {
- const targetGroup = group;
- targetGroup.isBeingRemoved = true;
- this.service.leaveGroup(targetGroup.leavePath)
+ showLeaveGroupModal(group, parentGroup) {
+ this.targetGroup = group;
+ this.targetParentGroup = parentGroup;
+ this.showModal = true;
+ this.groupLeaveConfirmationMessage = s__(
+ `GroupsTree|Are you sure you want to leave the "${
+ group.fullName
+ }" group?`,
+ );
+ },
+ hideLeaveGroupModal() {
+ this.showModal = false;
+ },
+ leaveGroup() {
+ this.showModal = false;
+ this.targetGroup.isBeingRemoved = true;
+ this.service
+ .leaveGroup(this.targetGroup.leavePath)
.then(res => res.json())
- .then((res) => {
+ .then(res => {
$.scrollTo(0);
- this.store.removeGroup(targetGroup, parentGroup);
+ this.store.removeGroup(this.targetGroup, this.targetParentGroup);
Flash(res.notice, 'notice');
})
- .catch((err) => {
+ .catch(err => {
let message = COMMON_STR.FAILURE;
if (err.status === 403) {
message = COMMON_STR.LEAVE_FORBIDDEN;
}
Flash(message);
- targetGroup.isBeingRemoved = false;
+ this.targetGroup.isBeingRemoved = false;
});
},
updatePagination(headers) {
@@ -152,26 +210,6 @@ export default {
}
},
},
- created() {
- this.searchEmptyMessage = this.hideProjects ?
- COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
-
- eventHub.$on('fetchPage', this.fetchPage);
- eventHub.$on('toggleChildren', this.toggleChildren);
- eventHub.$on('leaveGroup', this.leaveGroup);
- eventHub.$on('updatePagination', this.updatePagination);
- eventHub.$on('updateGroups', this.updateGroups);
- },
- mounted() {
- this.fetchAllGroups();
- },
- beforeDestroy() {
- eventHub.$off('fetchPage', this.fetchPage);
- eventHub.$off('toggleChildren', this.toggleChildren);
- eventHub.$off('leaveGroup', this.leaveGroup);
- eventHub.$off('updatePagination', this.updatePagination);
- eventHub.$off('updateGroups', this.updateGroups);
- },
};
</script>
@@ -190,5 +228,14 @@ export default {
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
/>
+ <deprecated-modal
+ v-show="showModal"
+ kind="warning"
+ :primary-button-label="__('Leave')"
+ :title="__('Are you sure?')"
+ :text="groupLeaveConfirmationMessage"
+ @cancel="hideLeaveGroupModal"
+ @submit="leaveGroup"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index e60221fa08d..647c9d0046d 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -20,7 +20,11 @@ export default {
return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
},
moreChildrenStats() {
- return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length);
+ return n__(
+ 'One more item',
+ '%d more items',
+ this.parentGroup.childrenCount - this.parentGroup.children.length,
+ );
},
},
};
@@ -43,8 +47,9 @@ export default {
<i
class="fa fa-external-link"
aria-hidden="true"
- />
- {{moreChildrenStats}}
+ >
+ </i>
+ {{ moreChildrenStats }}
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 6421547bbde..764b130fdb8 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -75,9 +75,10 @@ export default {
:id="groupDomId"
:class="rowClass"
class="group-row"
- >
+ >
<div
- class="group-row-contents">
+ class="group-row-contents"
+ :class="{ 'project-row-contents': !isGroup }">
<item-actions
v-if="isGroup"
:group="group"
@@ -87,7 +88,8 @@ export default {
:item="group"
/>
<div
- class="folder-toggle-wrap">
+ class="folder-toggle-wrap"
+ >
<item-caret
:is-group-open="group.isOpen"
/>
@@ -97,7 +99,7 @@ export default {
/>
</div>
<div
- class="avatar-container s40 hidden-xs"
+ class="avatar-container prepend-top-8 prepend-left-5 s24 hidden-xs"
:class="{ 'content-loading': group.isChildrenLoading }"
>
<a
@@ -106,24 +108,26 @@ export default {
>
<img
v-if="hasAvatar"
- class="avatar s40"
+ class="avatar s24"
:src="group.avatarUrl"
/>
<identicon
v-else
- :entity-id=group.id
+ size-class="s24"
+ :entity-id="group.id"
:entity-name="group.name"
/>
</a>
</div>
<div
- class="title namespace-title">
+ class="title namespace-title"
+ >
<a
v-tooltip
:href="group.relativePath"
:title="group.fullName"
class="no-expand"
- data-placement="top"
+ data-placement="bottom"
>{{
// ending bracket must be by closing tag to prevent
// link hover text-decoration from over-extending
@@ -133,13 +137,14 @@ export default {
v-if="group.permission"
class="user-access-role"
>
- {{group.permission}}
+ {{ group.permission }}
</span>
</div>
<div
v-if="group.description"
class="description">
- {{group.description}}
+ <span v-html="group.description">
+ </span>
</div>
</div>
<group-folder
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 75a2bf34887..adde8c8cdb3 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -1,47 +1,48 @@
<script>
-import tablePagination from '~/vue_shared/components/table_pagination.vue';
-import eventHub from '../event_hub';
-import { getParameterByName } from '../../lib/utils/common_utils';
+ import tablePagination from '~/vue_shared/components/table_pagination.vue';
+ import eventHub from '../event_hub';
+ import { getParameterByName } from '../../lib/utils/common_utils';
-export default {
- components: {
- tablePagination,
- },
- props: {
- groups: {
- type: Array,
- required: true,
+ export default {
+ components: {
+ tablePagination,
},
- pageInfo: {
- type: Object,
- required: true,
+ props: {
+ groups: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ searchEmpty: {
+ type: Boolean,
+ required: true,
+ },
+ searchEmptyMessage: {
+ type: String,
+ required: true,
+ },
},
- searchEmpty: {
- type: Boolean,
- required: true,
+ methods: {
+ change(page) {
+ const filterGroupsParam = getParameterByName('filter_groups');
+ const sortParam = getParameterByName('sort');
+ const archivedParam = getParameterByName('archived');
+ eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
+ },
},
- searchEmptyMessage: {
- type: String,
- required: true,
- },
- },
- methods: {
- change(page) {
- const filterGroupsParam = getParameterByName('filter_groups');
- const sortParam = getParameterByName('sort');
- const archivedParam = getParameterByName('archived');
- eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
- },
- },
-};
+ };
</script>
<template>
<div class="groups-list-tree-container">
<div
v-if="searchEmpty"
- class="has-no-search-results">
- {{searchEmptyMessage}}
+ class="has-no-search-results"
+ >
+ {{ searchEmptyMessage }}
</div>
<group-folder
v-if="!searchEmpty"
@@ -50,7 +51,7 @@ export default {
<table-pagination
v-if="!searchEmpty"
:change="change"
- :pageInfo="pageInfo"
+ :page-info="pageInfo"
/>
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index 58ba5aff7cf..87065b3d6e3 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -1,15 +1,12 @@
<script>
-import { s__ } from '../../locale';
-import tooltip from '../../vue_shared/directives/tooltip';
-import modal from '../../vue_shared/components/modal.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import { COMMON_STR } from '../constants';
-import Icon from '../../vue_shared/components/icon.vue';
export default {
components: {
- Icon,
- modal,
+ icon,
},
directives: {
tooltip,
@@ -25,11 +22,6 @@ export default {
required: true,
},
},
- data() {
- return {
- modalStatus: false,
- };
- },
computed: {
leaveBtnTitle() {
return COMMON_STR.LEAVE_BTN_TITLE;
@@ -37,19 +29,10 @@ export default {
editBtnTitle() {
return COMMON_STR.EDIT_BTN_TITLE;
},
- leaveConfirmationMessage() {
- return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`);
- },
},
methods: {
onLeaveGroup() {
- this.modalStatus = true;
- },
- leaveGroup(leaveConfirmed) {
- this.modalStatus = false;
- if (leaveConfirmed) {
- eventHub.$emit('leaveGroup', this.group, this.parentGroup);
- }
+ eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup);
},
},
};
@@ -64,10 +47,9 @@ export default {
:title="editBtnTitle"
:aria-label="editBtnTitle"
data-container="body"
+ data-placement="bottom"
class="edit-group btn no-expand">
- <icon
- name="settings">
- </icon>
+ <icon name="settings"/>
</a>
<a
v-tooltip
@@ -77,19 +59,9 @@ export default {
:title="leaveBtnTitle"
:aria-label="leaveBtnTitle"
data-container="body"
+ data-placement="bottom"
class="leave-group btn no-expand">
- <i
- class="fa fa-sign-out"
- aria-hidden="true"/>
+ <icon name="leave"/>
</a>
- <modal
- v-show="modalStatus"
- :primary-button-label="__('Leave')"
- kind="warning"
- :title="__('Are you sure?')"
- :text="__('Are you sure you want to leave this group?')"
- :body="leaveConfirmationMessage"
- @submit="leaveGroup"
- />
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
index 959b984816f..2a5bec5e86c 100644
--- a/app/assets/javascripts/groups/components/item_caret.vue
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -1,5 +1,10 @@
<script>
+import icon from '~/vue_shared/components/icon.vue';
+
export default {
+ components: {
+ icon,
+ },
props: {
isGroupOpen: {
type: Boolean,
@@ -9,7 +14,7 @@ export default {
},
computed: {
iconClass() {
- return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
+ return this.isGroupOpen ? 'angle-down' : 'angle-right';
},
},
};
@@ -17,9 +22,9 @@ export default {
<template>
<span class="folder-caret">
- <i
- :class="iconClass"
- class="fa"
- aria-hidden="true"/>
+ <icon
+ :size="12"
+ :name="iconClass"
+ />
</span>
</template>
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 9f8ac138fc3..168b4e4af2c 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -1,98 +1,89 @@
<script>
-import tooltip from '../../vue_shared/directives/tooltip';
-import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants';
+ import icon from '~/vue_shared/components/icon.vue';
+ import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+ import {
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+ } from '../constants';
+ import itemStatsValue from './item_stats_value.vue';
-export default {
- directives: {
- tooltip,
- },
- props: {
- item: {
- type: Object,
- required: true,
+ export default {
+ components: {
+ icon,
+ timeAgoTooltip,
+ itemStatsValue,
},
- },
- computed: {
- visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.item.visibility];
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
},
- visibilityTooltip() {
- if (this.item.type === ITEM_TYPE.GROUP) {
- return GROUP_VISIBILITY_TYPE[this.item.visibility];
- }
- return PROJECT_VISIBILITY_TYPE[this.item.visibility];
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.item.visibility];
+ },
+ visibilityTooltip() {
+ if (this.item.type === ITEM_TYPE.GROUP) {
+ return GROUP_VISIBILITY_TYPE[this.item.visibility];
+ }
+ return PROJECT_VISIBILITY_TYPE[this.item.visibility];
+ },
+ isProject() {
+ return this.item.type === ITEM_TYPE.PROJECT;
+ },
+ isGroup() {
+ return this.item.type === ITEM_TYPE.GROUP;
+ },
},
- isProject() {
- return this.item.type === ITEM_TYPE.PROJECT;
- },
- isGroup() {
- return this.item.type === ITEM_TYPE.GROUP;
- },
- },
-};
+ };
</script>
<template>
<div class="stats">
- <span
- v-tooltip
+ <item-stats-value
v-if="isGroup"
- :title="s__('Subgroups')"
- class="number-subgroups"
- data-placement="top"
- data-container="body">
- <i
- class="fa fa-folder"
- aria-hidden="true"
- />
- {{item.subgroupCount}}
- </span>
- <span
- v-tooltip
+ css-class="number-subgroups"
+ icon-name="folder"
+ :title="__('Subgroups')"
+ :value="item.subgroupCount"
+ />
+ <item-stats-value
v-if="isGroup"
- :title="s__('Projects')"
- class="number-projects"
- data-placement="top"
- data-container="body">
- <i
- class="fa fa-bookmark"
- aria-hidden="true"
- />
- {{item.projectCount}}
- </span>
- <span
- v-tooltip
+ css-class="number-projects"
+ icon-name="bookmark"
+ :title="__('Projects')"
+ :value="item.projectCount"
+ />
+ <item-stats-value
v-if="isGroup"
- :title="s__('Members')"
- class="number-users"
- data-placement="top"
- data-container="body">
- <i
- class="fa fa-users"
- aria-hidden="true"
- />
- {{item.memberCount}}
- </span>
- <span
+ css-class="number-users"
+ icon-name="users"
+ :title="__('Members')"
+ :value="item.memberCount"
+ />
+ <item-stats-value
v-if="isProject"
- class="project-stars">
- <i
- class="fa fa-star"
- aria-hidden="true"
- />
- {{item.starCount}}
- </span>
- <span
- v-tooltip
+ css-class="project-stars"
+ icon-name="star"
+ :value="item.starCount"
+ />
+ <item-stats-value
+ css-class="item-visibility"
+ tooltip-placement="left"
+ :icon-name="visibilityIcon"
:title="visibilityTooltip"
- data-placement="left"
- data-container="body"
- class="item-visibility">
- <i
- :class="visibilityIcon"
- class="fa"
- aria-hidden="true"
+ />
+ <div
+ class="last-updated"
+ v-if="isProject"
+ >
+ <time-ago-tooltip
+ tooltip-placement="bottom"
+ :time="item.updatedAt"
/>
- </span>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/item_stats_value.vue b/app/assets/javascripts/groups/components/item_stats_value.vue
new file mode 100644
index 00000000000..4d86ac8023c
--- /dev/null
+++ b/app/assets/javascripts/groups/components/item_stats_value.vue
@@ -0,0 +1,68 @@
+<script>
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ iconName: {
+ type: String,
+ required: true,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'bottom',
+ },
+ /**
+ * value could either be number or string
+ * as `memberCount` is always passed as string
+ * while `subgroupCount` & `projectCount`
+ * are always number
+ */
+ value: {
+ type: [Number, String],
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isValuePresent() {
+ return this.value !== '';
+ },
+ },
+ };
+</script>
+
+<template>
+ <span
+ v-tooltip
+ data-container="body"
+ :data-placement="tooltipPlacement"
+ :class="cssClass"
+ :title="title"
+ >
+ <icon :name="iconName" />
+ <span
+ v-if="isValuePresent"
+ class="stat-value"
+ >
+ {{ value }}
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
index c02a8ad6d8c..118d94d4937 100644
--- a/app/assets/javascripts/groups/components/item_type_icon.vue
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -1,7 +1,11 @@
<script>
+import icon from '~/vue_shared/components/icon.vue';
import { ITEM_TYPE } from '../constants';
export default {
+ components: {
+ icon,
+ },
props: {
itemType: {
type: String,
@@ -16,9 +20,9 @@ export default {
computed: {
iconClass() {
if (this.itemType === ITEM_TYPE.GROUP) {
- return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder';
+ return this.isGroupOpen ? 'folder-open' : 'folder';
}
- return 'fa-bookmark';
+ return 'bookmark';
},
},
};
@@ -26,9 +30,6 @@ export default {
<template>
<span class="item-type-icon">
- <i
- :class="iconClass"
- class="fa"
- aria-hidden="true"/>
+ <icon :name="iconClass"/>
</span>
</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 6fde41414b3..b8baed682f5 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -29,7 +29,7 @@ export const PROJECT_VISIBILITY_TYPE = {
};
export const VISIBILITY_TYPE_ICON = {
- public: 'fa-globe',
- internal: 'fa-shield',
- private: 'fa-lock',
+ public: 'earth',
+ internal: 'shield',
+ private: 'lock',
};
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
index 2db233b09da..e6db1746487 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -1,6 +1,7 @@
+import $ from 'jquery';
import FilterableList from '~/filterable_list';
import eventHub from './event_hub';
-import { getParameterByName } from '../lib/utils/common_utils';
+import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList {
constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
@@ -94,23 +95,14 @@ export default class GroupFilterableList extends FilterableList {
this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
}
- onFilterSuccess(data, xhr, queryData) {
+ onFilterSuccess(res, queryData) {
const currentPath = this.getPagePath(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'),
- };
-
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
- eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
- eventHub.$emit('updatePagination', paginationData);
+ eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
+ eventHub.$emit('updatePagination', normalizeHeaders(res.headers));
}
}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index 8b850765a1b..57eaac72906 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -10,7 +10,7 @@ import groupItemComponent from './components/group_item.vue';
Vue.use(Translate);
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const el = document.getElementById('js-groups-tree');
// Don't do anything if element doesn't exist (No groups)
@@ -71,4 +71,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+};
diff --git a/app/assets/javascripts/groups/service/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js
index 639410384c2..b79ba291463 100644
--- a/app/assets/javascripts/groups/service/groups_service.js
+++ b/app/assets/javascripts/groups/service/groups_service.js
@@ -1,7 +1,5 @@
import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import '../../vue_shared/vue_resource_interceptor';
export default class GroupsService {
constructor(endpoint) {
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
index a1689f4c5cc..4a7569078a1 100644
--- a/app/assets/javascripts/groups/store/groups_store.js
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -71,7 +71,7 @@ export default class GroupsStore {
id: rawGroupItem.id,
name: rawGroupItem.name,
fullName: rawGroupItem.full_name,
- description: rawGroupItem.description,
+ description: rawGroupItem.markdown_description,
visibility: rawGroupItem.visibility,
avatarUrl: rawGroupItem.avatar_url,
relativePath: rawGroupItem.relative_path,
@@ -91,6 +91,7 @@ export default class GroupsStore {
subgroupCount: rawGroupItem.subgroup_count,
memberCount: rawGroupItem.number_users_with_delimiter,
starCount: rawGroupItem.star_count,
+ updatedAt: rawGroupItem.updated_at,
};
}
diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js
new file mode 100644
index 00000000000..e0eb118ddf7
--- /dev/null
+++ b/app/assets/javascripts/groups/transfer_dropdown.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+
+export default class TransferDropdown {
+ constructor() {
+ this.groupDropdown = $('.js-groups-dropdown');
+ this.parentInput = $('#new_parent_group_id');
+ this.data = this.groupDropdown.data('data');
+ this.init();
+ }
+
+ init() {
+ this.buildDropdown();
+ }
+
+ buildDropdown() {
+ const extraOptions = [{ id: '', text: 'No parent group' }, 'divider'];
+
+ this.groupDropdown.glDropdown({
+ selectable: true,
+ filterable: true,
+ toggleLabel: item => item.text,
+ search: { fields: ['text'] },
+ data: extraOptions.concat(this.data),
+ text: item => item.text,
+ clicked: (options) => {
+ const { e } = options;
+ e.preventDefault();
+ this.assignSelected(options.selectedObj);
+ },
+ });
+ }
+
+ assignSelected(selected) {
+ this.parentInput.val(selected.id);
+ }
+}
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index a69a0bde17b..310f6fe06cf 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -1,13 +1,15 @@
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
import Api from './api';
-import { normalizeCRLFHeaders } from './lib/utils/common_utils';
+import { normalizeHeaders } from './lib/utils/common_utils';
export default function groupsSelect() {
// Needs to be accessible in rspec
window.GROUP_SELECT_PER_PAGE = 20;
$('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
const $select = $(this);
- const allAvailable = $select.data('all-available');
- const skipGroups = $select.data('skip-groups') || [];
+ const allAvailable = $select.data('allAvailable');
+ const skipGroups = $select.data('skipGroups') || [];
$select.select2({
placeholder: 'Search for a group',
multiple: $select.hasClass('multiselect'),
@@ -17,24 +19,23 @@ export default function groupsSelect() {
dataType: 'json',
quietMillis: 250,
transport(params) {
- return $.ajax(params)
- .then((data, status, xhr) => {
- const results = data || [];
-
- const headers = normalizeCRLFHeaders(xhr.getAllResponseHeaders());
+ axios[params.type.toLowerCase()](params.url, {
+ params: params.data,
+ })
+ .then((res) => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
const more = currentPage < totalPages;
- return {
+ params.success({
results,
pagination: {
more,
},
- };
- })
- .then(params.success)
- .fail(params.error);
+ });
+ }).catch(params.error);
},
data(search, page) {
return {
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 33a352e158a..4ae3a714bee 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import { highCountTrim } from '~/lib/utils/text_utility';
/**
diff --git a/app/assets/javascripts/help/help.js b/app/assets/javascripts/help/help.js
index 4a22ebf187d..f5333042bb8 100644
--- a/app/assets/javascripts/help/help.js
+++ b/app/assets/javascripts/help/help.js
@@ -1,6 +1,11 @@
// We will render the icons list here
-if ($('#user-content-gitlab-icons').length > 0) {
- const $iconsHeader = $('#user-content-gitlab-icons');
- const $iconsList = $('<div id="iconsList">ICONS</div>');
- $($iconsList).insertAfter($iconsHeader.parent());
-}
+
+import $ from 'jquery';
+
+export default () => {
+ if ($('#user-content-gitlab-icons').length > 0) {
+ const $iconsHeader = $('#user-content-gitlab-icons');
+ const $iconsList = $('<div id="iconsList">ICONS</div>');
+ $($iconsList).insertAfter($iconsHeader.parent());
+ }
+};
diff --git a/app/assets/javascripts/how_to_merge.js b/app/assets/javascripts/how_to_merge.js
index 19f4a946f73..bb734246584 100644
--- a/app/assets/javascripts/how_to_merge.js
+++ b/app/assets/javascripts/how_to_merge.js
@@ -1,12 +1,15 @@
-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();
- });
-});
+import $ from 'jquery';
+
+export default () => {
+ const modal = $('#modal_merge_info');
+
+ if (modal) {
+ modal.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/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue
new file mode 100644
index 00000000000..037e3efb4ce
--- /dev/null
+++ b/app/assets/javascripts/ide/components/changed_file_icon.vue
@@ -0,0 +1,31 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ changedIcon() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ changedIconClass() {
+ return `multi-${this.changedIcon}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <icon
+ :name="changedIcon"
+ :size="12"
+ :css-classes="`ide-file-changed-icon ${changedIconClass}`"
+ />
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
new file mode 100644
index 00000000000..2cbd982af19
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -0,0 +1,65 @@
+<script>
+ import { mapState } from 'vuex';
+ import { sprintf, __ } from '~/locale';
+ import * as consts from '../../stores/modules/commit/constants';
+ import RadioGroup from './radio_group.vue';
+
+ export default {
+ components: {
+ RadioGroup,
+ },
+ computed: {
+ ...mapState([
+ 'currentBranchId',
+ ]),
+ newMergeRequestHelpText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName} and re-directs to create a new merge request'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ commitToCurrentBranchText() {
+ return sprintf(
+ __('Commit to %{branchName} branch'),
+ { branchName: `<strong>${this.currentBranchId}</strong>` },
+ false,
+ );
+ },
+ commitToNewBranchText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName}'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ },
+ commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
+ commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
+ commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
+ };
+</script>
+
+<template>
+ <div class="append-bottom-15 ide-commit-radios">
+ <radio-group
+ :value="$options.commitToCurrentBranch"
+ :checked="true"
+ >
+ <span
+ v-html="commitToCurrentBranchText"
+ >
+ </span>
+ </radio-group>
+ <radio-group
+ :value="$options.commitToNewBranch"
+ :label="__('Create a new branch')"
+ :show-input="true"
+ :help-text="commitToNewBranchText"
+ />
+ <radio-group
+ :value="$options.commitToNewBranchMR"
+ :label="__('Create a new branch and merge request')"
+ :show-input="true"
+ :help-text="newMergeRequestHelpText"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index 704dff981df..453208f3f19 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -1,6 +1,6 @@
<script>
import { mapState } from 'vuex';
- import icon from '../../../vue_shared/components/icon.vue';
+ import icon from '~/vue_shared/components/icon.vue';
import listItem from './list_item.vue';
import listCollapsed from './list_collapsed.vue';
@@ -26,18 +26,24 @@
'currentBranchId',
'rightPanelCollapsed',
]),
+ isCommitInfoShown() {
+ return this.rightPanelCollapsed || this.fileList.length;
+ },
},
methods: {
toggleCollapsed() {
this.$emit('toggleCollapsed');
},
},
-
};
</script>
<template>
- <div class="multi-file-commit-list">
+ <div
+ :class="{
+ 'multi-file-commit-list': isCommitInfoShown
+ }"
+ >
<list-collapsed
v-if="rightPanelCollapsed"
/>
@@ -55,12 +61,6 @@
/>
</li>
</ul>
- <div
- v-else
- class="help-block prepend-top-0"
- >
- No changes
- </div>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
index 6a0262f271b..15918ac9631 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
- import icon from '../../../vue_shared/components/icon.vue';
+ import icon from '~/vue_shared/components/icon.vue';
export default {
components: {
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 742f746e02f..560cdd941cd 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -1,36 +1,58 @@
<script>
- import icon from '../../../vue_shared/components/icon.vue';
+import { mapActions } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
- export default {
- components: {
- icon,
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
},
- props: {
- file: {
- type: Object,
- required: true,
- },
+ },
+ computed: {
+ iconName() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
},
- computed: {
- iconName() {
- return this.file.tempFile ? 'file-addition' : 'file-modified';
- },
- iconClass() {
- return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
- },
+ iconClass() {
+ return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
},
- };
+ },
+ methods: {
+ ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']),
+ openFileInEditor(file) {
+ return this.openPendingTab(file).then(changeViewer => {
+ if (changeViewer) {
+ this.updateViewer('diff');
+ }
+ });
+ },
+ },
+};
</script>
<template>
<div class="multi-file-commit-list-item">
- <icon
- :name="iconName"
- :size="16"
- :css-classes="iconClass"
- />
- <span class="multi-file-commit-list-path">
- {{ file.path }}
- </span>
+ <button
+ type="button"
+ class="multi-file-commit-list-path"
+ @click="openFileInEditor(file)">
+ <span class="multi-file-commit-list-file-path">
+ <icon
+ :name="iconName"
+ :size="16"
+ :css-classes="iconClass"
+ />{{ file.path }}
+ </span>
+ </button>
+ <button
+ type="button"
+ class="btn btn-blank multi-file-discard-btn"
+ @click="discardFileChanges(file.path)"
+ >
+ Discard
+ </button>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
new file mode 100644
index 00000000000..4310d762c78
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -0,0 +1,94 @@
+<script>
+ import { mapActions, mapState, mapGetters } from 'vuex';
+ import tooltip from '~/vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showInput: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ ...mapState('commit', [
+ 'commitAction',
+ ]),
+ ...mapGetters('commit', [
+ 'newBranchName',
+ ]),
+ },
+ methods: {
+ ...mapActions('commit', [
+ 'updateCommitAction',
+ 'updateBranchName',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label>
+ <input
+ type="radio"
+ name="commit-action"
+ :value="value"
+ @change="updateCommitAction($event.target.value)"
+ :checked="checked"
+ v-once
+ />
+ <span class="prepend-left-10">
+ <template v-if="label">
+ {{ label }}
+ </template>
+ <slot v-else></slot>
+ <span
+ v-if="helpText"
+ v-tooltip
+ class="help-block inline"
+ :title="helpText"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ </span>
+ </label>
+ <div
+ v-if="commitAction === value && showInput"
+ class="ide-commit-new-branch"
+ >
+ <input
+ type="text"
+ class="form-control"
+ :placeholder="newBranchName"
+ @input="updateBranchName($event.target.value)"
+ />
+ </div>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
new file mode 100644
index 00000000000..0c44a755f56
--- /dev/null
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -0,0 +1,130 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { __, sprintf } from '~/locale';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ hasChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ mergeRequestId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ viewer: {
+ type: String,
+ required: true,
+ },
+ showShadow: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ mergeReviewLine() {
+ return sprintf(__('Reviewing (merge request !%{mergeRequestId})'), {
+ mergeRequestId: this.mergeRequestId,
+ });
+ },
+ },
+ methods: {
+ changeMode(mode) {
+ this.$emit('click', mode);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="dropdown"
+ :class="{
+ shadow: showShadow,
+ }"
+ >
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ :class="{
+ 'btn-inverted': hasChanges,
+ }"
+ data-toggle="dropdown"
+ >
+ <template v-if="viewer === 'mrdiff' && mergeRequestId">
+ {{ mergeReviewLine }}
+ </template>
+ <template v-else-if="viewer === 'editor'">
+ {{ __('Editing') }}
+ </template>
+ <template v-else>
+ {{ __('Reviewing') }}
+ </template>
+ <icon
+ name="angle-down"
+ :size="12"
+ css-classes="caret-down"
+ />
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
+ <ul>
+ <template v-if="mergeRequestId">
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('mrdiff')"
+ :class="{
+ 'is-active': viewer === 'mrdiff',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">
+ {{ mergeReviewLine }}
+ </strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('Compare changes with the merge request target branch') }}
+ </span>
+ </a>
+ </li>
+ <li
+ role="separator"
+ class="divider"
+ >
+ </li>
+ </template>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('editor')"
+ :class="{
+ 'is-active': viewer === 'editor',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('View and edit lines') }}
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('diff')"
+ :class="{
+ 'is-active': viewer === 'diff',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('Compare changes with the last commit') }}
+ </span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 7f29a355eca..1c237c0ec97 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -3,34 +3,38 @@ import { mapState, mapGetters } from 'vuex';
import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_bar.vue';
import repoTabs from './repo_tabs.vue';
-import repoFileButtons from './repo_file_buttons.vue';
import ideStatusBar from './ide_status_bar.vue';
-import repoPreview from './repo_preview.vue';
import repoEditor from './repo_editor.vue';
export default {
- computed: {
- ...mapState([
- 'currentBlobView',
- 'selectedFile',
- ]),
- ...mapGetters([
- 'changedFiles',
- 'activeFile',
- ]),
- },
components: {
ideSidebar,
ideContextbar,
repoTabs,
- repoFileButtons,
ideStatusBar,
repoEditor,
- repoPreview,
+ },
+ props: {
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']),
+ ...mapGetters(['activeFile', 'hasChanges']),
},
mounted() {
const returnValue = 'Are you sure you want to lose unsaved changes?';
- window.onbeforeunload = (e) => {
+ window.onbeforeunload = e => {
if (!this.changedFiles.length) return undefined;
Object.assign(e, {
@@ -43,31 +47,62 @@ export default {
</script>
<template>
- <div
+ <div
class="ide-view"
>
- <ide-sidebar/>
+ <ide-sidebar />
<div
class="multi-file-edit-pane"
>
<template
- v-if="activeFile">
- <repo-tabs/>
- <component
+ v-if="activeFile"
+ >
+ <repo-tabs
+ :active-file="activeFile"
+ :files="openFiles"
+ :viewer="viewer"
+ :has-changes="hasChanges"
+ :merge-request-id="currentMergeRequestId"
+ />
+ <repo-editor
class="multi-file-edit-pane-content"
- :is="currentBlobView"
+ :file="activeFile"
/>
- <repo-file-buttons/>
<ide-status-bar
- :file="selectedFile"/>
+ :file="activeFile"
+ />
</template>
<template
- v-else>
- <div class="ide-empty-state">
- <h2 class="clgray">Welcome to the GitLab IDE</h2>
+ v-else
+ >
+ <div
+ v-once
+ class="ide-empty-state"
+ >
+ <div class="row js-empty-state">
+ <div class="col-xs-12">
+ <div class="svg-content svg-250">
+ <img :src="emptyStateSvgPath" />
+ </div>
+ </div>
+ <div class="col-xs-12">
+ <div class="text-content text-center">
+ <h4>
+ Welcome to the GitLab IDE
+ </h4>
+ <p>
+ You can select a file in the left sidebar to begin
+ editing and use the right sidebar to commit your changes.
+ </p>
+ </div>
+ </div>
+ </div>
</div>
</template>
</div>
- <ide-contextbar/>
+ <ide-contextbar
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue
index 5a08718e386..79a83b47994 100644
--- a/app/assets/javascripts/ide/components/ide_context_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_context_bar.vue
@@ -1,66 +1,73 @@
<script>
-import { mapGetters, mapState, mapActions } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import icon from '~/vue_shared/components/icon.vue';
+import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import repoCommitSection from './repo_commit_section.vue';
-import icon from '../../vue_shared/components/icon.vue';
+import ResizablePanel from './resizable_panel.vue';
export default {
components: {
repoCommitSection,
icon,
+ panelResizer,
+ ResizablePanel,
},
- computed: {
- ...mapState([
- 'rightPanelCollapsed',
- ]),
- ...mapGetters([
- 'changedFiles',
- ]),
- currentIcon() {
- return this.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
},
},
+ computed: {
+ ...mapState(['changedFiles', 'rightPanelCollapsed']),
+ ...mapGetters(['currentIcon']),
+ },
methods: {
- ...mapActions([
- 'setPanelCollapsedStatus',
- ]),
- toggleCollapsed() {
- this.setPanelCollapsedStatus({
- side: 'right',
- collapsed: !this.rightPanelCollapsed,
- });
- },
+ ...mapActions(['setPanelCollapsedStatus']),
},
};
</script>
<template>
- <div
- class="multi-file-commit-panel"
- :class="{
- 'is-collapsed': rightPanelCollapsed,
- }"
+ <resizable-panel
+ :collapsible="true"
+ :initial-width="340"
+ side="right"
>
- <div
- class="multi-file-commit-panel-section">
+ <div
+ class="multi-file-commit-panel-section"
+ >
<header
class="multi-file-commit-panel-header"
:class="{
- 'is-collapsed': rightPanelCollapsed,
- }"
- >
+ 'is-collapsed': rightPanelCollapsed,
+ }"
+ >
<div
class="multi-file-commit-panel-header-title"
- v-if="!rightPanelCollapsed">
- <icon
- name="list-bulleted"
- :size="18"
- />
- Staged
+ v-if="!rightPanelCollapsed"
+ >
+ <div
+ v-if="changedFiles.length"
+ >
+ <icon
+ name="list-bulleted"
+ :size="18"
+ />
+ Staged
+ </div>
</div>
<button
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
- @click="toggleCollapsed"
+ @click.stop="setPanelCollapsedStatus({
+ side: 'right',
+ collapsed: !rightPanelCollapsed,
+ })"
>
<icon
:name="currentIcon"
@@ -68,8 +75,10 @@ export default {
/>
</button>
</header>
- <repo-commit-section
- class=""/>
+ <repo-commit-section
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
</div>
- </div>
+ </resizable-panel>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue
new file mode 100644
index 00000000000..c6f6e0d2348
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_external_links.vue
@@ -0,0 +1,43 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ projectUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ goBackUrl() {
+ return document.referrer || this.projectUrl;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav
+ class="ide-external-links"
+ v-once
+ >
+ <p>
+ <a
+ :href="goBackUrl"
+ class="ide-sidebar-link"
+ >
+ <icon
+ :size="16"
+ class="append-right-8"
+ name="go-back"
+ />
+ <span class="ide-external-links-text">
+ {{ s__('Go back') }}
+ </span>
+ </a>
+ </p>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_file_buttons.vue b/app/assets/javascripts/ide/components/ide_file_buttons.vue
new file mode 100644
index 00000000000..a6c6f46a144
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_file_buttons.vue
@@ -0,0 +1,84 @@
+<script>
+import { __ } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showButtons() {
+ return (
+ this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink
+ );
+ },
+ rawDownloadButtonLabel() {
+ return this.file.binary ? __('Download') : __('Raw');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="showButtons"
+ class="pull-right ide-btn-group"
+ >
+ <a
+ v-tooltip
+ v-if="!file.binary"
+ :href="file.blamePath"
+ :title="__('Blame')"
+ class="btn btn-xs btn-transparent blame"
+ >
+ <icon
+ name="blame"
+ :size="16"
+ />
+ </a>
+ <a
+ v-tooltip
+ :href="file.commitsPath"
+ :title="__('History')"
+ class="btn btn-xs btn-transparent history"
+ >
+ <icon
+ name="history"
+ :size="16"
+ />
+ </a>
+ <a
+ v-tooltip
+ :href="file.permalink"
+ :title="__('Permalink')"
+ class="btn btn-xs btn-transparent permalink"
+ >
+ <icon
+ name="link"
+ :size="16"
+ />
+ </a>
+ <a
+ v-tooltip
+ :href="file.rawPath"
+ target="_blank"
+ class="btn btn-xs btn-transparent prepend-left-10 raw"
+ rel="noopener noreferrer"
+ :title="rawDownloadButtonLabel">
+ <icon
+ name="download"
+ :size="16"
+ />
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
index bd3a521ff43..eb2749e6151 100644
--- a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
@@ -1,47 +1,47 @@
<script>
-import repoTree from './ide_repo_tree.vue';
-import icon from '../../vue_shared/components/icon.vue';
-import newDropdown from './new_dropdown/index.vue';
+ import icon from '~/vue_shared/components/icon.vue';
+ import repoTree from './ide_repo_tree.vue';
+ import newDropdown from './new_dropdown/index.vue';
-export default {
- components: {
- repoTree,
- icon,
- newDropdown,
- },
- props: {
- projectId: {
- type: String,
- required: true,
+ export default {
+ components: {
+ repoTree,
+ icon,
+ newDropdown,
},
- branch: {
- type: Object,
- required: true,
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ branch: {
+ type: Object,
+ required: true,
+ },
},
- },
-};
+ };
</script>
<template>
<div class="branch-container">
<div class="branch-header">
- <div class="branch-header-title">
+ <div class="branch-header-title str-truncated ref-name">
<icon
name="branch"
- :size="12">
- </icon>
+ :size="12"
+ />
{{ branch.name }}
</div>
<div class="branch-header-btns">
<new-dropdown
:project-id="projectId"
:branch="branch.name"
- path=""/>
+ path=""
+ />
</div>
</div>
- <div>
- <repo-tree
- :treeId="branch.treeId"/>
- </div>
+ <repo-tree
+ :tree="branch.tree"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue
index 61daba6d176..a6f40286ac1 100644
--- a/app/assets/javascripts/ide/components/ide_project_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_project_tree.vue
@@ -1,11 +1,15 @@
<script>
-import branchesTree from './ide_project_branches_tree.vue';
-import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue';
+import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
+import Identicon from '../../vue_shared/components/identicon.vue';
+import BranchesTree from './ide_project_branches_tree.vue';
+import ExternalLinks from './ide_external_links.vue';
export default {
components: {
- branchesTree,
- projectAvatarImage,
+ BranchesTree,
+ ExternalLinks,
+ ProjectAvatarImage,
+ Identicon,
},
props: {
project: {
@@ -19,29 +23,43 @@ export default {
<template>
<div class="projects-sidebar">
<div class="context-header">
- <a
- :title="project.name"
- :href="project.web_url">
- <div class="avatar-container s40 project-avatar">
+ <a
+ :title="project.name"
+ :href="project.web_url"
+ >
+ <div
+ v-if="project.avatar_url"
+ class="avatar-container s40 project-avatar"
+ >
<project-avatar-image
class="avatar-container project-avatar"
:link-href="project.path"
:img-src="project.avatar_url"
:img-alt="project.name"
:img-size="40"
- />
+ />
</div>
+ <identicon
+ v-else
+ size-class="s40"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ />
<div class="sidebar-context-title">
{{ project.name }}
</div>
</a>
</div>
+ <external-links
+ :project-url="project.web_url"
+ />
<div class="multi-file-commit-panel-inner-scroll">
<branches-tree
- v-for="(branch, index) in project.branches"
+ v-for="branch in project.branches"
:key="branch.name"
:project-id="project.path_with_namespace"
- :branch="branch"/>
+ :branch="branch"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue
index b6b089e6b25..e6af88e04bc 100644
--- a/app/assets/javascripts/ide/components/ide_repo_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue
@@ -1,66 +1,41 @@
<script>
-import { mapState } from 'vuex';
-import RepoPreviousDirectory from './repo_prev_directory.vue';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import RepoFile from './repo_file.vue';
-import RepoLoadingFile from './repo_loading_file.vue';
-import { treeList } from '../stores/utils';
export default {
components: {
- 'repo-previous-directory': RepoPreviousDirectory,
- 'repo-file': RepoFile,
- 'repo-loading-file': RepoLoadingFile,
+ RepoFile,
+ SkeletonLoadingContainer,
},
props: {
- treeId: {
- type: String,
+ tree: {
+ type: Object,
required: true,
},
},
- computed: {
- ...mapState([
- 'loading',
- 'isRoot',
- ]),
- ...mapState({
- projectName(state) {
- return state.project.name;
- },
- }),
- fetchedList() {
- return treeList(this.$store.state, this.treeId);
- },
- hasPreviousDirectory() {
- return !this.isRoot && this.fetchedList.length;
- },
- showLoading() {
- return this.loading;
- },
- },
};
</script>
<template>
-<div>
- <div class="ide-file-list">
- <table class="table">
- <tbody
- v-if="treeId">
- <repo-previous-directory
- v-if="hasPreviousDirectory"
- />
- <repo-loading-file
- v-if="showLoading"
- v-for="n in 5"
- :key="n"
- />
- <repo-file
- v-for="file in fetchedList"
- :key="file.key"
- :file="file"
- />
- </tbody>
- </table>
+ <div
+ class="ide-file-list"
+ >
+ <template v-if="tree.loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <template v-else>
+ <repo-file
+ v-for="file in tree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ />
+ </template>
</div>
-</div>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 535398d98c2..8cf1ccb4fce 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -1,62 +1,51 @@
<script>
-import { mapState, mapActions } from 'vuex';
-import projectTree from './ide_project_tree.vue';
-import icon from '../../vue_shared/components/icon.vue';
+ import { mapState, mapGetters } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+ import projectTree from './ide_project_tree.vue';
+ import ResizablePanel from './resizable_panel.vue';
-export default {
- components: {
- projectTree,
- icon,
- },
- computed: {
- ...mapState([
- 'projects',
- 'leftPanelCollapsed',
- ]),
- currentIcon() {
- return this.leftPanelCollapsed ? 'angle-double-right' : 'angle-double-left';
+ export default {
+ components: {
+ projectTree,
+ icon,
+ panelResizer,
+ skeletonLoadingContainer,
+ ResizablePanel,
},
- },
- methods: {
- ...mapActions([
- 'setPanelCollapsedStatus',
- ]),
- toggleCollapsed() {
- this.setPanelCollapsedStatus({
- side: 'left',
- collapsed: !this.leftPanelCollapsed,
- });
+ computed: {
+ ...mapState([
+ 'loading',
+ ]),
+ ...mapGetters([
+ 'projectsWithTrees',
+ ]),
},
- },
-};
+ };
</script>
<template>
- <div
- class="multi-file-commit-panel"
- :class="{
- 'is-collapsed': leftPanelCollapsed,
- }"
- >
+ <resizable-panel
+ :collapsible="false"
+ :initial-width="290"
+ side="left"
+ >
<div class="multi-file-commit-panel-inner">
+ <template v-if="loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
<project-tree
- v-for="(project, index) in projects"
+ v-for="project in projectsWithTrees"
:key="project.id"
- :project="project"/>
- </div>
- <button
- type="button"
- class="btn btn-transparent left-collapse-btn"
- @click="toggleCollapsed"
- >
- <icon
- :name="currentIcon"
- :size="18"
+ :project="project"
/>
- <span
- v-if="!leftPanelCollapsed"
- class="collapse-text"
- >Collapse sidebar</span>
- </button>
- </div>
+ </div>
+ </resizable-panel>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index a24abadd936..152a5f632ad 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -1,71 +1,60 @@
<script>
-import { mapState } from 'vuex';
-import icon from '../../vue_shared/components/icon.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
-import timeAgoMixin from '../../vue_shared/mixins/timeago';
+import icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import timeAgoMixin from '~/vue_shared/mixins/timeago';
export default {
- props: {
- file: {
- type: Object,
- required: true,
- },
- },
components: {
icon,
},
directives: {
tooltip,
},
- mixins: [
- timeAgoMixin,
- ],
- computed: {
- ...mapState([
- 'selectedFile',
- ]),
+ mixins: [timeAgoMixin],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
},
};
</script>
<template>
- <div
- class="ide-status-bar">
- <div>
+ <div class="ide-status-bar">
+ <div class="ref-name">
<icon
name="branch"
- :size="12">
- </icon>
- {{ selectedFile.branchId }}
+ :size="12"
+ />
+ {{ file.branchId }}
</div>
<div>
- <div
- v-if="selectedFile.lastCommit && selectedFile.lastCommit.id">
+ <div v-if="file.lastCommit && file.lastCommit.id">
Last commit:
<a
v-tooltip
- :title="selectedFile.lastCommit.message"
- :href="selectedFile.lastCommit.url">
- {{ timeFormated(selectedFile.lastCommit.updatedAt) }} by
- {{ selectedFile.lastCommit.author }}
+ :title="file.lastCommit.message"
+ :href="file.lastCommit.url"
+ >
+ {{ timeFormated(file.lastCommit.updatedAt) }} by
+ {{ file.lastCommit.author }}
</a>
- </div>
+ </div>
</div>
- <div
- class="text-right">
- {{ selectedFile.name }}
+ <div class="text-right">
+ {{ file.name }}
</div>
- <div
- class="text-right">
- {{ selectedFile.eol }}
+ <div class="text-right">
+ {{ file.eol }}
</div>
- <div
- class="text-right">
+ <div
+ class="text-right"
+ v-if="!file.binary">
{{ file.editorRow }}:{{ file.editorColumn }}
</div>
- <div
- class="text-right">
- {{ selectedFile.fileLanguage }}
+ <div class="text-right">
+ {{ file.fileLanguage }}
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue
new file mode 100644
index 00000000000..8a440902dfc
--- /dev/null
+++ b/app/assets/javascripts/ide/components/mr_file_icon.vue
@@ -0,0 +1,23 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+};
+</script>
+
+<template>
+ <icon
+ name="git-merge"
+ v-tooltip
+ title="__('Part of merge request changes')"
+ css-classes="ide-file-changed-icon"
+ :size="12"
+ />
+</template>
diff --git a/app/assets/javascripts/ide/components/new_branch_form.vue b/app/assets/javascripts/ide/components/new_branch_form.vue
deleted file mode 100644
index 2119d373d31..00000000000
--- a/app/assets/javascripts/ide/components/new_branch_form.vue
+++ /dev/null
@@ -1,108 +0,0 @@
-<script>
- import { mapState, mapActions } from 'vuex';
- import flash, { hideFlash } from '../../flash';
- import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-
- export default {
- components: {
- loadingIcon,
- },
- data() {
- return {
- branchName: '',
- loading: false,
- };
- },
- computed: {
- ...mapState([
- 'currentBranch',
- ]),
- btnDisabled() {
- return this.loading || this.branchName === '';
- },
- },
- methods: {
- ...mapActions([
- 'createNewBranch',
- ]),
- toggleDropdown() {
- this.$dropdown.dropdown('toggle');
- },
- submitNewBranch() {
- // need to query as the element is appended outside of Vue
- const flashEl = this.$refs.flashContainer.querySelector('.flash-alert');
-
- this.loading = true;
-
- if (flashEl) {
- hideFlash(flashEl, false);
- }
-
- this.createNewBranch(this.branchName)
- .then(() => {
- this.loading = false;
- this.branchName = '';
-
- if (this.dropdownText) {
- this.dropdownText.textContent = this.currentBranchId;
- }
-
- this.toggleDropdown();
- })
- .catch(res => res.json().then((data) => {
- this.loading = false;
- flash(data.message, 'alert', this.$el);
- }));
- },
- },
- created() {
- // Dropdown is outside of Vue instance & is controlled by Bootstrap
- this.$dropdown = $('.git-revision-dropdown');
-
- // text element is outside Vue app
- this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text');
- },
- };
-</script>
-
-<template>
- <div>
- <div
- class="flash-container"
- ref="flashContainer"
- >
- </div>
- <p>
- Create from:
- <code>{{ currentBranch }}</code>
- </p>
- <input
- class="form-control js-new-branch-name"
- type="text"
- placeholder="Name new branch"
- v-model="branchName"
- @keyup.enter.stop.prevent="submitNewBranch"
- />
- <div class="prepend-top-default clearfix">
- <button
- type="button"
- class="btn btn-primary pull-left"
- :disabled="btnDisabled"
- @click.stop.prevent="submitNewBranch"
- >
- <loading-icon
- v-if="loading"
- :inline="true"
- />
- <span>Create</span>
- </button>
- <button
- type="button"
- class="btn btn-default pull-right"
- @click.stop.prevent="toggleDropdown"
- >
- Cancel
- </button>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 6e67e99a70f..769e9b79cad 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -1,9 +1,15 @@
<script>
+ import { mapActions } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
import newModal from './modal.vue';
import upload from './upload.vue';
- import icon from '../../../vue_shared/components/icon.vue';
export default {
+ components: {
+ icon,
+ newModal,
+ upload,
+ },
props: {
branch: {
type: String,
@@ -13,42 +19,46 @@
type: String,
required: true,
},
- parent: {
- type: Object,
- default: null,
- },
- },
- components: {
- icon,
- newModal,
- upload,
},
data() {
return {
openModal: false,
modalType: '',
+ dropdownOpen: false,
};
},
methods: {
+ ...mapActions([
+ 'createTempEntry',
+ ]),
createNewItem(type) {
this.modalType = type;
- this.toggleModalOpen();
+ this.openModal = true;
+ this.dropdownOpen = false;
+ },
+ hideModal() {
+ this.openModal = false;
},
- toggleModalOpen() {
- this.openModal = !this.openModal;
+ openDropdown() {
+ this.dropdownOpen = !this.dropdownOpen;
},
},
};
</script>
<template>
- <div class="repo-new-btn pull-right">
- <div class="dropdown">
+ <div class="ide-new-btn">
+ <div
+ class="dropdown"
+ :class="{
+ open: dropdownOpen,
+ }"
+ >
<button
type="button"
class="btn btn-sm btn-default dropdown-toggle add-to-tree"
- data-toggle="dropdown"
aria-label="Create new file or directory"
+ @click.stop="openDropdown()"
>
<icon
name="plus"
@@ -66,7 +76,7 @@
<a
href="#"
role="button"
- @click.prevent="createNewItem('blob')"
+ @click.stop.prevent="createNewItem('blob')"
>
{{ __('New file') }}
</a>
@@ -75,14 +85,14 @@
<upload
:branch-id="branch"
:path="path"
- :parent="parent"
+ @create="createTempEntry"
/>
</li>
<li>
<a
href="#"
role="button"
- @click.prevent="createNewItem('tree')"
+ @click.stop.prevent="createNewItem('tree')"
>
{{ __('New directory') }}
</a>
@@ -94,8 +104,8 @@
:type="modalType"
:branch-id="branch"
:path="path"
- :parent="parent"
- @toggle="toggleModalOpen"
+ @hide="hideModal"
+ @create="createTempEntry"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index a0650d37690..4b5a50785b6 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,92 +1,79 @@
<script>
- import { mapActions, mapState } from 'vuex';
- import { __ } from '../../../locale';
- import modal from '../../../vue_shared/components/modal.vue';
+import { __ } from '~/locale';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
- export default {
- props: {
- branchId: {
- type: String,
- required: true,
- },
- parent: {
- type: Object,
- default: null,
- },
- type: {
- type: String,
- required: true,
- },
- path: {
- type: String,
- required: true,
- },
+export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ branchId: {
+ type: String,
+ required: true,
},
- data() {
- return {
- entryName: this.path !== '' ? `${this.path}/` : '',
- };
+ type: {
+ type: String,
+ required: true,
},
- components: {
- modal,
+ path: {
+ type: String,
+ required: true,
},
- methods: {
- ...mapActions([
- 'createTempEntry',
- ]),
- createEntryInStore() {
- this.createTempEntry({
- projectId: this.currentProjectId,
- branchId: this.branchId,
- parent: this.parent,
- name: this.entryName.replace(new RegExp(`^${this.path}/`), ''),
- type: this.type,
- });
+ },
+ data() {
+ return {
+ entryName: this.path !== '' ? `${this.path}/` : '',
+ };
+ },
+ computed: {
+ modalTitle() {
+ if (this.type === 'tree') {
+ return __('Create new directory');
+ }
- this.toggleModalOpen();
- },
- toggleModalOpen() {
- this.$emit('toggle');
- },
+ return __('Create new file');
},
- computed: {
- ...mapState([
- 'currentProjectId',
- ]),
- modalTitle() {
- if (this.type === 'tree') {
- return __('Create new directory');
- }
+ buttonLabel() {
+ if (this.type === 'tree') {
+ return __('Create directory');
+ }
- return __('Create new file');
- },
- buttonLabel() {
- if (this.type === 'tree') {
- return __('Create directory');
- }
+ return __('Create file');
+ },
+ formLabelName() {
+ if (this.type === 'tree') {
+ return __('Directory name');
+ }
- return __('Create file');
- },
- formLabelName() {
- if (this.type === 'tree') {
- return __('Directory name');
- }
+ return __('File name');
+ },
+ },
+ mounted() {
+ this.$refs.fieldName.focus();
+ },
+ methods: {
+ createEntryInStore() {
+ this.$emit('create', {
+ branchId: this.branchId,
+ name: this.entryName,
+ type: this.type,
+ });
- return __('File name');
- },
+ this.hideModal();
},
- mounted() {
- this.$refs.fieldName.focus();
+ hideModal() {
+ this.$emit('hide');
},
- };
+ },
+};
</script>
<template>
- <modal
+ <deprecated-modal
:title="modalTitle"
:primary-button-label="buttonLabel"
kind="success"
- @toggle="toggleModalOpen"
+ @cancel="hideModal"
@submit="createEntryInStore"
>
<form
@@ -108,5 +95,5 @@
</div>
</fieldset>
</form>
- </modal>
+ </deprecated-modal>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index 2a2f2a241fc..c165af5ce52 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -1,27 +1,23 @@
<script>
- import { mapActions, mapState } from 'vuex';
-
export default {
props: {
branchId: {
type: String,
required: true,
},
- parent: {
- type: Object,
- default: null,
+ path: {
+ type: String,
+ required: false,
+ default: '',
},
},
- computed: {
- ...mapState([
- 'trees',
- 'currentProjectId',
- ]),
+ mounted() {
+ this.$refs.fileUpload.addEventListener('change', this.openFile);
+ },
+ beforeDestroy() {
+ this.$refs.fileUpload.removeEventListener('change', this.openFile);
},
methods: {
- ...mapActions([
- 'createTempEntry',
- ]),
createFile(target, file, isText) {
const { name } = file;
let { result } = target;
@@ -30,11 +26,9 @@
result = result.split('base64,')[1];
}
- this.createTempEntry({
- name,
- projectId: this.currentProjectId,
+ this.$emit('create', {
+ name: `${(this.path ? `${this.path}/` : '')}${name}`,
branchId: this.branchId,
- parent: this.parent,
type: 'blob',
content: result,
base64: !isText,
@@ -59,12 +53,6 @@
this.$refs.fileUpload.click();
},
},
- mounted() {
- this.$refs.fileUpload.addEventListener('change', this.openFile);
- },
- beforeDestroy() {
- this.$refs.fileUpload.removeEventListener('change', this.openFile);
- },
};
</script>
@@ -73,7 +61,7 @@
<a
href="#"
role="button"
- @click.prevent="startFileUpload"
+ @click.stop.prevent="startFileUpload"
>
{{ __('Upload file') }}
</a>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 470db2c9650..d885ed5e301 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -1,171 +1,172 @@
<script>
-import { mapGetters, mapState, mapActions } from 'vuex';
-import tooltip from '../../vue_shared/directives/tooltip';
-import icon from '../../vue_shared/components/icon.vue';
-import modal from '../../vue_shared/components/modal.vue';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
import commitFilesList from './commit_sidebar/list.vue';
+import * as consts from '../stores/modules/commit/constants';
+import Actions from './commit_sidebar/actions.vue';
export default {
components: {
- modal,
+ DeprecatedModal,
icon,
commitFilesList,
+ Actions,
+ LoadingButton,
},
directives: {
tooltip,
},
- data() {
- return {
- showNewBranchModal: false,
- submitCommitsLoading: false,
- startNewMR: false,
- commitMessage: '',
- };
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
},
computed: {
...mapState([
'currentProjectId',
'currentBranchId',
'rightPanelCollapsed',
- ]),
- ...mapGetters([
+ 'lastCommitMsg',
'changedFiles',
]),
- commitButtonDisabled() {
- return this.commitMessage === '' || this.submitCommitsLoading || !this.changedFiles.length;
- },
- commitMessageCount() {
- return this.commitMessage.length;
+ ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
+ ...mapGetters('commit', [
+ 'commitButtonDisabled',
+ 'discardDraftButtonDisabled',
+ 'branchName',
+ ]),
+ statusSvg() {
+ return this.lastCommitMsg
+ ? this.committedStateSvgPath
+ : this.noChangesStateSvgPath;
},
},
methods: {
- ...mapActions([
- 'checkCommitStatus',
+ ...mapActions(['setPanelCollapsedStatus']),
+ ...mapActions('commit', [
+ 'updateCommitMessage',
+ 'discardDraft',
'commitChanges',
- 'getTreeData',
- 'setPanelCollapsedStatus',
+ 'updateCommitAction',
]),
- makeCommit(newBranch = false) {
- const createNewBranch = newBranch || this.startNewMR;
-
- const payload = {
- branch: createNewBranch ? `${this.currentBranchId}-${new Date().getTime().toString()}` : this.currentBranchId,
- commit_message: this.commitMessage,
- actions: this.changedFiles.map(f => ({
- action: f.tempFile ? 'create' : 'update',
- file_path: f.path,
- content: f.content,
- encoding: f.base64 ? 'base64' : 'text',
- })),
- start_branch: createNewBranch ? this.currentBranchId : undefined,
- };
-
- this.showNewBranchModal = false;
- this.submitCommitsLoading = true;
-
- this.commitChanges({ payload, newMr: this.startNewMR })
- .then(() => {
- this.submitCommitsLoading = false;
- this.$store.dispatch('getTreeData', {
- projectId: this.currentProjectId,
- branch: this.currentBranchId,
- endpoint: `/tree/${this.currentBranchId}`,
- force: true,
- });
- })
- .catch(() => {
- this.submitCommitsLoading = false;
- });
- },
- tryCommit() {
- this.submitCommitsLoading = true;
-
- this.checkCommitStatus()
- .then((branchChanged) => {
- if (branchChanged) {
- this.showNewBranchModal = true;
- } else {
- this.makeCommit();
- }
- })
- .catch(() => {
- this.submitCommitsLoading = false;
- });
- },
toggleCollapsed() {
this.setPanelCollapsedStatus({
side: 'right',
collapsed: !this.rightPanelCollapsed,
});
},
+ forceCreateNewBranch() {
+ return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() =>
+ this.commitChanges(),
+ );
+ },
},
};
</script>
<template>
-<div class="multi-file-commit-panel-section">
- <modal
- v-if="showNewBranchModal"
- :primary-button-label="__('Create new branch')"
- kind="primary"
- :title="__('Branch has changed')"
- :text="__('This branch has changed since you started editing. Would you like to create a new branch?')"
- @toggle="showNewBranchModal = false"
- @submit="makeCommit(true)"
- />
- <commit-files-list
- title="Staged"
- :file-list="changedFiles"
- :collapsed="rightPanelCollapsed"
- @toggleCollapsed="toggleCollapsed"
- />
- <form
- class="form-horizontal multi-file-commit-form"
- @submit.prevent="tryCommit"
- v-if="!rightPanelCollapsed"
+ <div
+ class="multi-file-commit-panel-section"
+ :class="{
+ 'multi-file-commit-empty-state-container': !changedFiles.length
+ }"
>
- <div class="multi-file-commit-fieldset">
- <textarea
- class="form-control multi-file-commit-message"
- name="commit-message"
- v-model="commitMessage"
- placeholder="Commit message"
- >
- </textarea>
- </div>
- <div class="multi-file-commit-fieldset">
- <label
- v-tooltip
- title="Create a new merge request with these changes"
- data-container="body"
- data-placement="top"
+ <deprecated-modal
+ id="ide-create-branch-modal"
+ :primary-button-label="__('Create new branch')"
+ kind="success"
+ :title="__('Branch has changed')"
+ @submit="forceCreateNewBranch"
+ >
+ <template slot="body">
+ {{ __(`This branch has changed since you started editing.
+ Would you like to create a new branch?`) }}
+ </template>
+ </deprecated-modal>
+ <commit-files-list
+ title="Staged"
+ :file-list="changedFiles"
+ :collapsed="rightPanelCollapsed"
+ @toggleCollapsed="toggleCollapsed"
+ />
+ <template
+ v-if="changedFiles.length"
+ >
+ <form
+ class="form-horizontal multi-file-commit-form"
+ @submit.prevent.stop="commitChanges"
+ v-if="!rightPanelCollapsed"
>
- <input
- type="checkbox"
- v-model="startNewMR"
- />
- Merge Request
- </label>
- <button
- type="submit"
- :disabled="commitButtonDisabled"
- class="btn btn-default btn-sm append-right-10 prepend-left-10"
- >
- <i
- v-if="submitCommitsLoading"
- class="js-commit-loading-icon fa fa-spinner fa-spin"
- aria-hidden="true"
- aria-label="loading"
+ <div class="multi-file-commit-fieldset">
+ <textarea
+ class="form-control multi-file-commit-message"
+ name="commit-message"
+ :value="commitMessage"
+ :placeholder="__('Write a commit message...')"
+ @input="updateCommitMessage($event.target.value)"
+ >
+ </textarea>
+ </div>
+ <div class="clearfix prepend-top-15">
+ <actions />
+ <loading-button
+ :loading="submitCommitLoading"
+ :disabled="commitButtonDisabled"
+ container-class="btn btn-success btn-sm pull-left"
+ :label="__('Commit')"
+ @click="commitChanges"
+ />
+ <button
+ v-if="!discardDraftButtonDisabled"
+ type="button"
+ class="btn btn-default btn-sm pull-right"
+ @click="discardDraft"
+ >
+ {{ __('Discard draft') }}
+ </button>
+ </div>
+ </form>
+ </template>
+ <div
+ v-else-if="!rightPanelCollapsed"
+ class="row js-empty-state"
+ >
+ <div class="col-xs-10 col-xs-offset-1">
+ <div class="svg-content svg-80">
+ <img :src="statusSvg" />
+ </div>
+ </div>
+ <div class="col-xs-10 col-xs-offset-1">
+ <div
+ class="text-content text-center"
+ v-if="!lastCommitMsg"
>
- </i>
- Commit
- </button>
- <div
- class="multi-file-commit-message-count"
- >
- {{ commitMessageCount }}
+ <h4>
+ {{ __('No changes') }}
+ </h4>
+ <p>
+ {{ __('Edit files in the editor and commit changes here') }}
+ </p>
+ </div>
+ <div
+ class="text-content text-center"
+ v-else
+ >
+ <h4>
+ {{ __('All changes are committed') }}
+ </h4>
+ <p v-html="lastCommitMsg">
+ </p>
+ </div>
</div>
</div>
- </form>
-</div>
+ </div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_edit_button.vue b/app/assets/javascripts/ide/components/repo_edit_button.vue
deleted file mode 100644
index 37bd9003e96..00000000000
--- a/app/assets/javascripts/ide/components/repo_edit_button.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<script>
-import { mapGetters, mapActions, mapState } from 'vuex';
-import modal from '../../vue_shared/components/modal.vue';
-
-export default {
- components: {
- modal,
- },
- computed: {
- ...mapState([
- 'editMode',
- 'discardPopupOpen',
- ]),
- ...mapGetters([
- 'canEditFile',
- ]),
- buttonLabel() {
- return this.editMode ? this.__('Cancel edit') : this.__('Edit');
- },
- },
- methods: {
- ...mapActions([
- 'toggleEditMode',
- 'closeDiscardPopup',
- ]),
- },
-};
-</script>
-
-<template>
- <div class="editable-mode">
- <button
- v-if="canEditFile"
- class="btn btn-default"
- type="button"
- @click.prevent="toggleEditMode()">
- <i
- v-if="!editMode"
- class="fa fa-pencil"
- aria-hidden="true">
- </i>
- <span>
- {{buttonLabel}}
- </span>
- </button>
- <modal
- v-if="discardPopupOpen"
- class="text-left"
- :primary-button-label="__('Discard changes')"
- kind="warning"
- :title="__('Are you sure?')"
- :text="__('Are you sure you want to discard your changes?')"
- @toggle="closeDiscardPopup"
- @submit="toggleEditMode(true)"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 221be4b9074..6aa44ca2c11 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,11 +1,59 @@
<script>
/* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex';
-import flash from '../../flash';
+import flash from '~/flash';
+import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
+import IdeFileButtons from './ide_file_buttons.vue';
export default {
+ components: {
+ ContentViewer,
+ IdeFileButtons,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']),
+ ...mapGetters(['currentMergeRequest']),
+ shouldHideEditor() {
+ return this.file && this.file.binary && !this.file.raw;
+ },
+ editTabCSS() {
+ return {
+ active: this.file.viewMode === 'edit',
+ };
+ },
+ previewTabCSS() {
+ return {
+ active: this.file.viewMode === 'preview',
+ };
+ },
+ },
+ watch: {
+ file(oldVal, newVal) {
+ // Compare key to allow for files opened in review mode to be cached differently
+ if (newVal.key !== this.file.key) {
+ this.initMonaco();
+ }
+ },
+ rightPanelCollapsed() {
+ this.editor.updateDimensions();
+ },
+ viewer() {
+ this.createEditorInstance();
+ },
+ panelResizing() {
+ if (!this.panelResizing) {
+ this.editor.updateDimensions();
+ }
+ },
+ },
beforeDestroy() {
this.editor.dispose();
},
@@ -26,32 +74,69 @@ export default {
'changeFileContent',
'setFileLanguage',
'setEditorPosition',
+ 'setFileViewMode',
'setFileEOL',
+ 'updateViewer',
+ 'updateDelayViewerUpdated',
]),
initMonaco() {
if (this.shouldHideEditor) return;
this.editor.clearEditor();
- this.getRawFileData(this.activeFile)
+ this.getRawFileData({
+ path: this.file.path,
+ baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '',
+ })
.then(() => {
- this.editor.createInstance(this.$refs.editor);
+ const viewerPromise = this.delayViewerUpdated
+ ? this.updateViewer(this.file.pending ? 'diff' : 'editor')
+ : Promise.resolve();
+
+ return viewerPromise;
+ })
+ .then(() => {
+ this.updateDelayViewerUpdated(false);
+ this.createEditorInstance();
})
- .then(() => this.setupEditor())
- .catch(() => flash('Error setting up monaco. Please try again.'));
+ .catch(err => {
+ flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true);
+ throw err;
+ });
+ },
+ createEditorInstance() {
+ this.editor.dispose();
+
+ this.$nextTick(() => {
+ if (this.viewer === 'editor') {
+ this.editor.createInstance(this.$refs.editor);
+ } else {
+ this.editor.createDiffInstance(this.$refs.editor);
+ }
+
+ this.setupEditor();
+ });
},
setupEditor() {
- if (!this.activeFile) return;
+ if (!this.file || !this.editor.instance) return;
- const model = this.editor.createModel(this.activeFile);
+ this.model = this.editor.createModel(this.file);
- this.editor.attachModel(model);
+ if (this.viewer === 'mrdiff') {
+ this.editor.attachMergeRequestModel(this.model);
+ } else {
+ this.editor.attachModel(this.model);
+ }
- model.onChange((m) => {
- this.changeFileContent({
- file: this.activeFile,
- content: m.getValue(),
- });
+ this.model.onChange(model => {
+ const { file } = model;
+
+ if (file.active) {
+ this.changeFileContent({
+ path: file.path,
+ content: model.getModel().getValue(),
+ });
+ }
});
// Handle Cursor Position
@@ -63,47 +148,21 @@ export default {
});
this.editor.setPosition({
- lineNumber: this.activeFile.editorRow,
- column: this.activeFile.editorColumn,
+ lineNumber: this.file.editorRow,
+ column: this.file.editorColumn,
});
// Handle File Language
this.setFileLanguage({
- fileLanguage: model.language,
+ fileLanguage: this.model.language,
});
// Get File eol
this.setFileEOL({
- eol: model.eol,
+ eol: this.model.eol,
});
},
},
- watch: {
- activeFile(oldVal, newVal) {
- if (newVal && !newVal.active) {
- this.initMonaco();
- }
- },
- leftPanelCollapsed() {
- this.editor.updateDimensions();
- },
- rightPanelCollapsed() {
- this.editor.updateDimensions();
- },
- },
- computed: {
- ...mapGetters([
- 'activeFile',
- 'activeFileExtension',
- ]),
- ...mapState([
- 'leftPanelCollapsed',
- 'rightPanelCollapsed',
- ]),
- shouldHideEditor() {
- return this.activeFile.binary && !this.activeFile.raw;
- },
- },
};
</script>
@@ -112,16 +171,49 @@ export default {
id="ide"
class="blob-viewer-container blob-editor-container"
>
- <div
- v-if="shouldHideEditor"
- v-html="activeFile.html"
- >
+ <div class="ide-mode-tabs clearfix">
+ <ul
+ class="nav-links pull-left"
+ v-if="!shouldHideEditor">
+ <li :class="editTabCSS">
+ <a
+ href="javascript:void(0);"
+ role="button"
+ @click.prevent="setFileViewMode({ file, viewMode: 'edit' })">
+ <template v-if="viewer === 'editor'">
+ {{ __('Edit') }}
+ </template>
+ <template v-else>
+ {{ __('Review') }}
+ </template>
+ </a>
+ </li>
+ <li
+ v-if="file.previewMode"
+ :class="previewTabCSS">
+ <a
+ href="javascript:void(0);"
+ role="button"
+ @click.prevent="setFileViewMode({ file, viewMode:'preview' })">
+ {{ file.previewMode.previewTitle }}
+ </a>
+ </li>
+ </ul>
+ <ide-file-buttons
+ :file="file"
+ />
</div>
<div
- v-show="!shouldHideEditor"
+ v-show="!shouldHideEditor && file.viewMode === 'edit'"
ref="editor"
class="multi-file-editor-holder"
>
</div>
+ <content-viewer
+ v-if="shouldHideEditor || file.viewMode === 'preview'"
+ :content="file.content || file.raw"
+ :path="file.rawPath"
+ :file-size="file.size"
+ :project-path="file.projectId"/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue
index 09ca11531b1..3b5068d4910 100644
--- a/app/assets/javascripts/ide/components/repo_file.vue
+++ b/app/assets/javascripts/ide/components/repo_file.vue
@@ -1,165 +1,127 @@
<script>
- import { mapState } from 'vuex';
- import timeAgoMixin from '../../vue_shared/mixins/timeago';
- import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
- import newDropdown from './new_dropdown/index.vue';
+import { mapActions } from 'vuex';
+import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import fileIcon from '~/vue_shared/components/file_icon.vue';
+import router from '../ide_router';
+import newDropdown from './new_dropdown/index.vue';
+import fileStatusIcon from './repo_file_status_icon.vue';
+import changedFileIcon from './changed_file_icon.vue';
+import mrFileIcon from './mr_file_icon.vue';
- export default {
- mixins: [
- timeAgoMixin,
- ],
- components: {
- skeletonLoadingContainer,
- newDropdown,
+export default {
+ name: 'RepoFile',
+ components: {
+ skeletonLoadingContainer,
+ newDropdown,
+ fileStatusIcon,
+ fileIcon,
+ changedFileIcon,
+ mrFileIcon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
},
- props: {
- file: {
- type: Object,
- required: true,
- },
- showExtraColumns: {
- type: Boolean,
- default: false,
- },
+ level: {
+ type: Number,
+ required: true,
},
- computed: {
- ...mapState([
- 'leftPanelCollapsed',
- ]),
- fileIcon() {
- return {
- 'fa-spinner fa-spin': this.file.loading,
- [this.file.icon]: !this.file.loading,
- 'fa-folder-open': !this.file.loading && this.file.opened,
- };
- },
- isSubmodule() {
- return this.file.type === 'submodule';
- },
- isTree() {
- return this.file.type === 'tree';
- },
- levelIndentation() {
- return {
- marginLeft: `${this.file.level * 16}px`,
- };
- },
- shortId() {
- return this.file.id.substr(0, 8);
- },
- submoduleColSpan() {
- return !this.leftPanelCollapsed && this.isSubmodule ? 3 : 1;
- },
- fileClass() {
- if (this.file.type === 'blob') {
- if (this.file.active) {
- return 'file-open file-active';
- }
- return this.file.opened ? 'file-open' : '';
- }
- return '';
- },
- changedClass() {
- return {
- 'fa-circle unsaved-icon': this.file.changed || this.file.tempFile,
- };
- },
+ },
+ computed: {
+ isTree() {
+ return this.file.type === 'tree';
},
- methods: {
- clickFile(row) {
- // Manual Action if a tree is selected/opened
- if (this.file.type === 'tree' && this.$router.currentRoute.path === `/project${row.url}`) {
- this.$store.dispatch('toggleTreeOpen', {
- endpoint: this.file.url,
- tree: this.file,
- });
- }
- this.$router.push(`/project${row.url}`);
- },
+ isBlob() {
+ return this.file.type === 'blob';
},
- updated() {
- if (this.file.type === 'blob' && this.file.active) {
- this.$el.scrollIntoView();
+ levelIndentation() {
+ return {
+ marginLeft: `${this.level * 16}px`,
+ };
+ },
+ fileClass() {
+ return {
+ 'file-open': this.isBlob && this.file.opened,
+ 'file-active': this.isBlob && this.file.active,
+ folder: this.isTree,
+ 'is-open': this.file.opened,
+ };
+ },
+ },
+ updated() {
+ if (this.file.type === 'blob' && this.file.active) {
+ this.$el.scrollIntoView();
+ }
+ },
+ methods: {
+ ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']),
+ clickFile() {
+ // Manual Action if a tree is selected/opened
+ if (this.isTree && this.$router.currentRoute.path === `/project${this.file.url}`) {
+ this.toggleTreeOpen(this.file.path);
}
+
+ return this.updateDelayViewerUpdated(true).then(() => {
+ router.push(`/project${this.file.url}`);
+ });
},
- };
+ },
+};
</script>
<template>
- <tr
- class="file"
- :class="fileClass"
- @click="clickFile(file)">
- <td
- class="multi-file-table-name"
- :colspan="submoduleColSpan"
+ <div>
+ <div
+ class="file"
+ :class="fileClass"
>
- <i
- class="fa fa-fw file-icon"
- :class="fileIcon"
- :style="levelIndentation"
- aria-hidden="true"
+ <div
+ class="file-name"
+ @click="clickFile"
+ role="button"
>
- </i>
- <a
- class="repo-file-name"
- >
- {{ file.name }}
- </a>
- <new-dropdown
- v-if="isTree"
- :project-id="file.projectId"
- :branch="file.branchId"
- :path="file.path"
- :parent="file"/>
- <i
- class="fa"
- v-if="changedClass"
- :class="changedClass"
- aria-hidden="true"
- >
- </i>
- <template v-if="isSubmodule && file.id">
- @
- <span class="commit-sha">
- <a
- @click.stop
- :href="file.tree_url"
- >
- {{ shortId }}
- </a>
- </span>
- </template>
- </td>
-
- <template v-if="showExtraColumns && !isSubmodule">
- <td class="multi-file-table-col-commit-message hidden-sm hidden-xs">
- <a
- v-if="file.lastCommit.message"
- @click.stop
- :href="file.lastCommit.url"
- >
- {{ file.lastCommit.message }}
- </a>
- <skeleton-loading-container
- v-else
- :small="true"
- />
- </td>
-
- <td class="commit-update hidden-xs text-right">
<span
- v-if="file.lastCommit.updatedAt"
- :title="tooltipTitle(file.lastCommit.updatedAt)"
+ class="ide-file-name str-truncated"
+ :style="levelIndentation"
>
- {{ timeFormated(file.lastCommit.updatedAt) }}
+ <file-icon
+ :file-name="file.name"
+ :loading="file.loading"
+ :folder="isTree"
+ :opened="file.opened"
+ :size="16"
+ />
+ {{ file.name }}
+ <file-status-icon
+ :file="file"
+ />
+ </span>
+ <span class="pull-right">
+ <mr-file-icon
+ v-if="file.mrChange"
+ />
+ <changed-file-icon
+ :file="file"
+ v-if="file.changed || file.tempFile"
+ />
</span>
- <skeleton-loading-container
- v-else
- class="animation-container-right"
- :small="true"
+ <new-dropdown
+ v-if="isTree"
+ :project-id="file.projectId"
+ :branch="file.branchId"
+ :path="file.path"
+ class="pull-right prepend-left-8"
/>
- </td>
+ </div>
+ </div>
+ <template v-if="file.opened">
+ <repo-file
+ v-for="childFile in file.tree"
+ :key="childFile.key"
+ :file="childFile"
+ :level="level + 1"
+ />
</template>
- </tr>
+ </div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue
deleted file mode 100644
index 34f0d51819a..00000000000
--- a/app/assets/javascripts/ide/components/repo_file_buttons.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import { mapGetters } from 'vuex';
-
-export default {
- computed: {
- ...mapGetters([
- 'activeFile',
- ]),
- showButtons() {
- return this.activeFile.rawPath ||
- this.activeFile.blamePath ||
- this.activeFile.commitsPath ||
- this.activeFile.permalink;
- },
- rawDownloadButtonLabel() {
- return this.activeFile.binary ? 'Download' : 'Raw';
- },
- },
-};
-</script>
-
-<template>
- <div
- v-if="showButtons"
- class="multi-file-editor-btn-group"
- >
- <a
- :href="activeFile.rawPath"
- target="_blank"
- class="btn btn-default btn-sm raw"
- rel="noopener noreferrer">
- {{ rawDownloadButtonLabel }}
- </a>
-
- <div
- class="btn-group"
- role="group"
- aria-label="File actions">
- <a
- :href="activeFile.blamePath"
- class="btn btn-default btn-sm blame">
- Blame
- </a>
- <a
- :href="activeFile.commitsPath"
- class="btn btn-default btn-sm history">
- History
- </a>
- <a
- :href="activeFile.permalink"
- class="btn btn-default btn-sm permalink">
- Permalink
- </a>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
new file mode 100644
index 00000000000..97589e116c5
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -0,0 +1,39 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import '~/lib/utils/datetime_utility';
+
+export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ lockTooltip() {
+ return `Locked by ${this.file.file_lock.user.name}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ v-if="file.file_lock"
+ v-tooltip
+ :title="lockTooltip"
+ data-container="body"
+ >
+ <icon
+ name="lock"
+ css-classes="file-status-icon"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue
index 7eb840c7608..79af8c0b0c7 100644
--- a/app/assets/javascripts/ide/components/repo_loading_file.vue
+++ b/app/assets/javascripts/ide/components/repo_loading_file.vue
@@ -1,6 +1,6 @@
<script>
import { mapState } from 'vuex';
- import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
export default {
components: {
@@ -25,15 +25,13 @@
/>
</td>
<template v-if="!leftPanelCollapsed">
- <td
- class="hidden-sm hidden-xs">
+ <td class="hidden-sm hidden-xs">
<skeleton-loading-container
:small="true"
/>
</td>
- <td
- class="hidden-xs">
+ <td class="hidden-xs">
<skeleton-loading-container
class="animation-container-right"
:small="true"
diff --git a/app/assets/javascripts/ide/components/repo_prev_directory.vue b/app/assets/javascripts/ide/components/repo_prev_directory.vue
deleted file mode 100644
index 7cd359ea4ed..00000000000
--- a/app/assets/javascripts/ide/components/repo_prev_directory.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<script>
- import { mapState, mapActions } from 'vuex';
-
- export default {
- computed: {
- ...mapState([
- 'parentTreeUrl',
- 'leftPanelCollapsed',
- ]),
- colSpanCondition() {
- return this.leftPanelCollapsed ? undefined : 3;
- },
- },
- methods: {
- ...mapActions([
- 'getTreeData',
- ]),
- },
- };
-</script>
-
-<template>
- <tr class="file prev-directory">
- <td
- :colspan="colSpanCondition"
- class="table-cell"
- @click.prevent="getTreeData({ endpoint: parentTreeUrl })"
- >
- <a :href="parentTreeUrl">...</a>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/ide/components/repo_preview.vue b/app/assets/javascripts/ide/components/repo_preview.vue
deleted file mode 100644
index 3d1e0297bd5..00000000000
--- a/app/assets/javascripts/ide/components/repo_preview.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import { mapGetters } from 'vuex';
-import LineHighlighter from '../../line_highlighter';
-import syntaxHighlight from '../../syntax_highlight';
-
-export default {
- computed: {
- ...mapGetters([
- 'activeFile',
- ]),
- renderErrorTooLarge() {
- return this.activeFile.renderError === 'too_large';
- },
- },
- methods: {
- highlightFile() {
- syntaxHighlight($(this.$el).find('.file-content'));
- },
- },
- mounted() {
- this.highlightFile();
- this.lineHighlighter = new LineHighlighter({
- fileHolderSelector: '.blob-viewer-container',
- scrollFileHolder: true,
- });
- },
- updated() {
- this.$nextTick(() => {
- this.highlightFile();
- });
- },
-};
-</script>
-
-<template>
-<div>
- <div
- v-if="!activeFile.renderError"
- v-html="activeFile.html"
- class="multi-file-preview-holder"
- >
- </div>
- <div
- v-else-if="activeFile.tempFile"
- class="vertical-center render-error">
- <p class="text-center">
- The source could not be displayed for this temporary file.
- </p>
- </div>
- <div
- v-else-if="renderErrorTooLarge"
- 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.rawPath" download>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 occurred. You can <a :href="activeFile.rawPath" download>download</a> it instead.
- </p>
- </div>
-</div>
-</template>
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 5bd63ac9ec5..304a73ed1ad 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,14 +1,29 @@
<script>
import { mapActions } from 'vuex';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import FileStatusIcon from './repo_file_status_icon.vue';
+import ChangedFileIcon from './changed_file_icon.vue';
+
export default {
+ components: {
+ FileStatusIcon,
+ FileIcon,
+ Icon,
+ ChangedFileIcon,
+ },
props: {
tab: {
type: Object,
required: true,
},
},
-
+ data() {
+ return {
+ tabMouseOver: false,
+ };
+ },
computed: {
closeLabel() {
if (this.tab.changed || this.tab.tempFile) {
@@ -16,21 +31,31 @@ export default {
}
return `Close ${this.tab.name}`;
},
- changedClass() {
- const tabChangedObj = {
- 'fa-times close-icon': !this.tab.changed && !this.tab.tempFile,
- 'fa-circle unsaved-icon': this.tab.changed || this.tab.tempFile,
- };
- return tabChangedObj;
+ showChangedIcon() {
+ return this.tab.changed ? !this.tabMouseOver : false;
},
},
methods: {
- ...mapActions([
- 'closeFile',
- ]),
+ ...mapActions(['closeFile', 'updateDelayViewerUpdated', 'openPendingTab']),
clickFile(tab) {
- this.$router.push(`/project${tab.url}`);
+ this.updateDelayViewerUpdated(true);
+
+ if (tab.pending) {
+ this.openPendingTab(tab);
+ } else {
+ this.$router.push(`/project${tab.url}`);
+ }
+ },
+ mouseOverTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = true;
+ }
+ },
+ mouseOutTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = false;
+ }
},
},
};
@@ -39,31 +64,41 @@ export default {
<template>
<li
@click="clickFile(tab)"
+ @mouseover="mouseOverTab"
+ @mouseout="mouseOutTab"
>
<button
type="button"
class="multi-file-tab-close"
- @click.stop.prevent="closeFile({ file: tab })"
+ @click.stop.prevent="closeFile(tab)"
:aria-label="closeLabel"
- :class="{
- 'modified': tab.changed,
- }"
- :disabled="tab.changed"
>
- <i
- class="fa"
- :class="changedClass"
- aria-hidden="true"
- >
- </i>
+ <icon
+ v-if="!showChangedIcon"
+ name="close"
+ :size="12"
+ />
+ <changed-file-icon
+ v-else
+ :file="tab"
+ />
</button>
<div
class="multi-file-tab"
- :class="{active : tab.active }"
+ :class="{
+ active: tab.active
+ }"
:title="tab.url"
>
+ <file-icon
+ :file-name="tab.name"
+ :size="16"
+ />
{{ tab.name }}
+ <file-status-icon
+ :file="tab"
+ />
</div>
</li>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
index ab0bef4f0ac..7bd646ba9b0 100644
--- a/app/assets/javascripts/ide/components/repo_tabs.vue
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -1,27 +1,82 @@
<script>
- import { mapState } from 'vuex';
- import RepoTab from './repo_tab.vue';
+import { mapActions } from 'vuex';
+import RepoTab from './repo_tab.vue';
+import EditorMode from './editor_mode_dropdown.vue';
+import router from '../ide_router';
- export default {
- components: {
- 'repo-tab': RepoTab,
+export default {
+ components: {
+ RepoTab,
+ EditorMode,
+ },
+ props: {
+ activeFile: {
+ type: Object,
+ required: true,
},
- computed: {
- ...mapState([
- 'openFiles',
- ]),
+ files: {
+ type: Array,
+ required: true,
},
- };
+ viewer: {
+ type: String,
+ required: true,
+ },
+ hasChanges: {
+ type: Boolean,
+ required: true,
+ },
+ mergeRequestId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ showShadow: false,
+ };
+ },
+ updated() {
+ if (!this.$refs.tabsScroller) return;
+
+ this.showShadow = this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth;
+ },
+ methods: {
+ ...mapActions(['updateViewer', 'removePendingTab']),
+ openFileViewer(viewer) {
+ this.updateViewer(viewer);
+
+ if (this.activeFile.pending) {
+ return this.removePendingTab(this.activeFile).then(() => {
+ router.push(`/project${this.activeFile.url}`);
+ });
+ }
+
+ return null;
+ },
+ },
+};
</script>
<template>
- <ul
- class="multi-file-tabs list-unstyled append-bottom-0"
- >
- <repo-tab
- v-for="tab in openFiles"
- :key="tab.id"
- :tab="tab"
+ <div class="multi-file-tabs">
+ <ul
+ class="list-unstyled append-bottom-0"
+ ref="tabsScroller"
+ >
+ <repo-tab
+ v-for="tab in files"
+ :key="tab.key"
+ :tab="tab"
+ />
+ </ul>
+ <editor-mode
+ :viewer="viewer"
+ :show-shadow="showShadow"
+ :has-changes="hasChanges"
+ :merge-request-id="mergeRequestId"
+ @click="openFileViewer"
/>
- </ul>
+ </div>
</template>
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
new file mode 100644
index 00000000000..5ea2a2f6825
--- /dev/null
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -0,0 +1,85 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+
+export default {
+ components: {
+ PanelResizer,
+ },
+ props: {
+ collapsible: {
+ type: Boolean,
+ required: true,
+ },
+ initialWidth: {
+ type: Number,
+ required: true,
+ },
+ minSize: {
+ type: Number,
+ required: false,
+ default: 340,
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ width: this.initialWidth,
+ };
+ },
+ computed: {
+ ...mapState({
+ collapsed(state) {
+ return state[`${this.side}PanelCollapsed`];
+ },
+ }),
+ panelStyle() {
+ if (!this.collapsed) {
+ return {
+ width: `${this.width}px`,
+ };
+ }
+
+ return {};
+ },
+ },
+ methods: {
+ ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']),
+ toggleFullbarCollapsed() {
+ if (this.collapsed && this.collapsible) {
+ this.setPanelCollapsedStatus({
+ side: this.side,
+ collapsed: !this.collapsed,
+ });
+ }
+ },
+ },
+ maxSize: window.innerWidth / 2,
+};
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel"
+ :class="{
+ 'is-collapsed': collapsed && collapsible,
+ }"
+ :style="panelStyle"
+ @click="toggleFullbarCollapsed"
+ >
+ <slot></slot>
+ <panel-resizer
+ :size.sync="width"
+ :enabled="!collapsed"
+ :start-size="initialWidth"
+ :min-size="minSize"
+ :max-size="$options.maxSize"
+ @resize-start="setResizingStatus(true)"
+ @resize-end="setResizingStatus(false)"
+ :side="side === 'right' ? 'left' : 'right'"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/ide/eventhub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index a9cbf8e370f..20983666b4a 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -1,10 +1,7 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
+import flash from '~/flash';
import store from './stores';
-import flash from '../flash';
-import {
- getTreeEntry,
-} from './stores/utils';
Vue.use(VueRouter);
@@ -47,7 +44,7 @@ const router = new VueRouter({
component: EmptyRouterComponent,
},
{
- path: 'mr/:mrid',
+ path: 'merge_requests/:mrid',
component: EmptyRouterComponent,
},
],
@@ -57,42 +54,117 @@ const router = new VueRouter({
router.beforeEach((to, from, next) => {
if (to.params.namespace && to.params.project) {
- store.dispatch('getProjectData', {
- namespace: to.params.namespace,
- projectId: to.params.project,
- })
- .then(() => {
- const fullProjectId = `${to.params.namespace}/${to.params.project}`;
-
- if (to.params.branch) {
- store.dispatch('getBranchData', {
- projectId: fullProjectId,
- branchId: to.params.branch,
- });
-
- store.dispatch('getTreeData', {
- projectId: fullProjectId,
- branch: to.params.branch,
- endpoint: `/tree/${to.params.branch}`,
- })
- .then(() => {
- if (to.params[0]) {
- const treeEntry = getTreeEntry(store, `${to.params.namespace}/${to.params.project}/${to.params.branch}`, to.params[0]);
- if (treeEntry) {
- store.dispatch('handleTreeEntryAction', treeEntry);
- }
- }
- })
- .catch((e) => {
- flash('Error while loading the branch files. Please try again.');
- throw e;
- });
- }
- })
- .catch((e) => {
- flash('Error while loading the project data. Please try again.');
- throw e;
- });
+ store
+ .dispatch('getProjectData', {
+ namespace: to.params.namespace,
+ projectId: to.params.project,
+ })
+ .then(() => {
+ const fullProjectId = `${to.params.namespace}/${to.params.project}`;
+
+ if (to.params.branch) {
+ store.dispatch('getBranchData', {
+ projectId: fullProjectId,
+ branchId: to.params.branch,
+ });
+
+ store
+ .dispatch('getFiles', {
+ projectId: fullProjectId,
+ branchId: to.params.branch,
+ })
+ .then(() => {
+ if (to.params[0]) {
+ const path =
+ to.params[0].slice(-1) === '/' ? to.params[0].slice(0, -1) : to.params[0];
+ const treeEntryKey = Object.keys(store.state.entries).find(
+ key => key === path && !store.state.entries[key].pending,
+ );
+ const treeEntry = store.state.entries[treeEntryKey];
+
+ if (treeEntry) {
+ store.dispatch('handleTreeEntryAction', treeEntry);
+ }
+ }
+ })
+ .catch(e => {
+ flash(
+ 'Error while loading the branch files. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ throw e;
+ });
+ } else if (to.params.mrid) {
+ store.dispatch('updateViewer', 'mrdiff');
+
+ store
+ .dispatch('getMergeRequestData', {
+ projectId: fullProjectId,
+ mergeRequestId: to.params.mrid,
+ })
+ .then(mr => {
+ store.dispatch('getBranchData', {
+ projectId: fullProjectId,
+ branchId: mr.source_branch,
+ });
+
+ return store.dispatch('getFiles', {
+ projectId: fullProjectId,
+ branchId: mr.source_branch,
+ });
+ })
+ .then(() =>
+ store.dispatch('getMergeRequestVersions', {
+ projectId: fullProjectId,
+ mergeRequestId: to.params.mrid,
+ }),
+ )
+ .then(() =>
+ store.dispatch('getMergeRequestChanges', {
+ projectId: fullProjectId,
+ mergeRequestId: to.params.mrid,
+ }),
+ )
+ .then(mrChanges => {
+ mrChanges.changes.forEach((change, ind) => {
+ const changeTreeEntry = store.state.entries[change.new_path];
+
+ if (changeTreeEntry) {
+ store.dispatch('setFileMrChange', {
+ file: changeTreeEntry,
+ mrChange: change,
+ });
+
+ if (ind < 10) {
+ store.dispatch('getFileData', {
+ path: change.new_path,
+ makeFileActive: ind === 0,
+ });
+ }
+ }
+ });
+ })
+ .catch(e => {
+ flash('Error while loading the merge request. Please try again.');
+ throw e;
+ });
+ }
+ })
+ .catch(e => {
+ flash(
+ 'Error while loading the project data. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ throw e;
+ });
}
next();
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index a96bd339f51..cbfb3dc54f2 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -1,12 +1,8 @@
import Vue from 'vue';
-import { mapActions } from 'vuex';
-import { convertPermissionToBoolean } from '../lib/utils/common_utils';
+import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue';
-
import store from './stores';
import router from './ide_router';
-import Translate from '../vue_shared/translate';
-import ContextualSidebar from '../contextual_sidebar';
function initIde(el) {
if (!el) return null;
@@ -18,30 +14,15 @@ function initIde(el) {
components: {
ide,
},
- methods: {
- ...mapActions([
- 'setInitialData',
- ]),
- },
- created() {
- const data = el.dataset;
-
- this.setInitialData({
- endpoints: {
- rootEndpoint: data.url,
- newMergeRequestUrl: data.newMergeRequestUrl,
- rootUrl: data.rootUrl,
+ render(createElement) {
+ return createElement('ide', {
+ props: {
+ emptyStateSvgPath: el.dataset.emptyStateSvgPath,
+ noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
+ committedStateSvgPath: el.dataset.committedStateSvgPath,
},
- canCommit: convertPermissionToBoolean(data.canCommit),
- onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch),
- path: data.currentPath,
- isRoot: convertPermissionToBoolean(data.root),
- isInitialRoot: convertPermissionToBoolean(data.root),
});
},
- render(createElement) {
- return createElement('ide');
- },
});
}
@@ -50,6 +31,3 @@ const ideElement = document.getElementById('ide');
Vue.use(Translate);
initIde(ideElement);
-
-const contextualSidebar = new ContextualSidebar();
-contextualSidebar.bindEvents();
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index 14d9fe4771e..e47adae99ed 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -1,5 +1,6 @@
/* global monaco */
import Disposable from './disposable';
+import eventHub from '../../eventhub';
export default class Model {
constructor(monaco, file) {
@@ -9,19 +10,34 @@ export default class Model {
this.content = file.content !== '' ? file.content : file.raw;
this.disposable.add(
- this.originalModel = this.monaco.editor.createModel(
+ (this.originalModel = this.monaco.editor.createModel(
this.file.raw,
undefined,
- new this.monaco.Uri(null, null, `original/${this.file.path}`),
- ),
- this.model = this.monaco.editor.createModel(
+ new this.monaco.Uri(null, null, `original/${this.file.key}`),
+ )),
+ (this.model = this.monaco.editor.createModel(
this.content,
undefined,
- new this.monaco.Uri(null, null, this.file.path),
- ),
+ new this.monaco.Uri(null, null, this.file.key),
+ )),
);
+ if (this.file.mrChange) {
+ this.disposable.add(
+ (this.baseModel = this.monaco.editor.createModel(
+ this.file.baseRaw,
+ undefined,
+ new this.monaco.Uri(null, null, `target/${this.file.path}`),
+ )),
+ );
+ }
this.events = new Map();
+
+ this.updateContent = this.updateContent.bind(this);
+ this.dispose = this.dispose.bind(this);
+
+ eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose);
+ eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent);
}
get url() {
@@ -37,7 +53,7 @@ export default class Model {
}
get path() {
- return this.file.path;
+ return this.file.key;
}
getModel() {
@@ -48,17 +64,31 @@ export default class Model {
return this.originalModel;
}
+ getBaseModel() {
+ return this.baseModel;
+ }
+
+ setValue(value) {
+ this.getModel().setValue(value);
+ }
+
onChange(cb) {
this.events.set(
this.path,
- this.disposable.add(
- this.model.onDidChangeContent(e => cb(this.model, e)),
- ),
+ this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))),
);
}
+ updateContent(content) {
+ this.getOriginalModel().setValue(content);
+ this.getModel().setValue(content);
+ }
+
dispose() {
this.disposable.dispose();
this.events.clear();
+
+ eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
+ eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent);
}
}
diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js
index fd462252795..0e7b563b5d6 100644
--- a/app/assets/javascripts/ide/lib/common/model_manager.js
+++ b/app/assets/javascripts/ide/lib/common/model_manager.js
@@ -1,3 +1,4 @@
+import eventHub from '../../eventhub';
import Disposable from './disposable';
import Model from './model';
@@ -8,22 +9,37 @@ export default class ModelManager {
this.models = new Map();
}
- hasCachedModel(path) {
- return this.models.has(path);
+ hasCachedModel(key) {
+ return this.models.has(key);
+ }
+
+ getModel(key) {
+ return this.models.get(key);
}
addModel(file) {
- if (this.hasCachedModel(file.path)) {
- return this.models.get(file.path);
+ if (this.hasCachedModel(file.key)) {
+ return this.getModel(file.key);
}
const model = new Model(this.monaco, file);
this.models.set(model.path, model);
this.disposable.add(model);
+ eventHub.$on(
+ `editor.update.model.dispose.${file.key}`,
+ this.removeCachedModel.bind(this, file),
+ );
+
return model;
}
+ removeCachedModel(file) {
+ this.models.delete(file.key);
+
+ eventHub.$off(`editor.update.model.dispose.${file.key}`, this.removeCachedModel);
+ }
+
dispose() {
// dispose of all the models
this.disposable.dispose();
diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js
index 0954b7973c4..42904774747 100644
--- a/app/assets/javascripts/ide/lib/decorations/controller.js
+++ b/app/assets/javascripts/ide/lib/decorations/controller.js
@@ -27,6 +27,8 @@ export default class DecorationsController {
}
decorate(model) {
+ if (!this.editor.instance) return;
+
const decorations = this.getAllDecorationsForModel(model);
const oldDecorations = this.editorDecorations.get(model.url) || [];
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
index dc0b1c95e59..b136545ad11 100644
--- a/app/assets/javascripts/ide/lib/diff/controller.js
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -59,7 +59,8 @@ export default class DirtyDiffController {
decorate({ data }) {
const decorations = data.changes.map(change => getDecorator(change));
- this.decorationsController.addDecorations(data.path, 'dirtyDiff', decorations);
+ const model = this.modelManager.getModel(data.path);
+ this.decorationsController.addDecorations(model, 'dirtyDiff', decorations);
}
dispose() {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 51e202b9348..001737d6ee8 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -1,11 +1,23 @@
+import _ from 'underscore';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
-import editorOptions from './editor_options';
+import editorOptions, { defaultEditorOptions } from './editor_options';
+import gitlabTheme from './themes/gl_theme';
+
+export const clearDomElement = el => {
+ if (!el || !el.firstChild) return;
+
+ while (el.firstChild) {
+ el.removeChild(el.firstChild);
+ }
+};
export default class Editor {
static create(monaco) {
+ if (this.editorInstance) return this.editorInstance;
+
this.editorInstance = new Editor(monaco);
return this.editorInstance;
@@ -17,34 +29,51 @@ export default class Editor {
this.instance = null;
this.dirtyDiffController = null;
this.disposable = new Disposable();
+ this.modelManager = new ModelManager(this.monaco);
+ this.decorationsController = new DecorationsController(this);
- this.disposable.add(
- this.modelManager = new ModelManager(this.monaco),
- this.decorationsController = new DecorationsController(this),
- );
+ this.setupMonacoTheme();
this.debouncedUpdate = _.debounce(() => {
this.updateDimensions();
}, 200);
- window.addEventListener('resize', this.debouncedUpdate, false);
}
createInstance(domElement) {
if (!this.instance) {
+ clearDomElement(domElement);
+
+ this.disposable.add(
+ (this.instance = this.monaco.editor.create(domElement, {
+ ...defaultEditorOptions,
+ })),
+ (this.dirtyDiffController = new DirtyDiffController(
+ this.modelManager,
+ this.decorationsController,
+ )),
+ );
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
+ }
+ }
+
+ createDiffInstance(domElement) {
+ if (!this.instance) {
+ clearDomElement(domElement);
+
this.disposable.add(
- this.instance = this.monaco.editor.create(domElement, {
- model: null,
- readOnly: false,
- contextmenu: true,
- scrollBeyondLastLine: false,
- minimap: {
- enabled: false,
- },
- }),
- this.dirtyDiffController = new DirtyDiffController(
- this.modelManager, this.decorationsController,
- ),
+ (this.instance = this.monaco.editor.createDiffEditor(domElement, {
+ ...defaultEditorOptions,
+ readOnly: true,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ renderLineHighlight: 'none',
+ hideCursorInOverviewRuler: true,
+ renderSideBySide: Editor.renderSideBySide(domElement),
+ })),
);
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
}
}
@@ -53,21 +82,49 @@ export default class Editor {
}
attachModel(model) {
+ if (this.isDiffEditorType) {
+ this.instance.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+
+ return;
+ }
+
this.instance.setModel(model.getModel());
- this.dirtyDiffController.attachModel(model);
+ if (this.dirtyDiffController) this.dirtyDiffController.attachModel(model);
this.currentModel = model;
- this.instance.updateOptions(editorOptions.reduce((acc, obj) => {
- Object.keys(obj).forEach((key) => {
- Object.assign(acc, {
- [key]: obj[key](model),
+ this.instance.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach(key => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
});
- });
- return acc;
- }, {}));
+ return acc;
+ }, {}),
+ );
- this.dirtyDiffController.reDecorate(model);
+ if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model);
+ }
+
+ attachMergeRequestModel(model) {
+ this.instance.setModel({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+
+ this.monaco.editor.createDiffNavigator(this.instance, {
+ alwaysRevealFirst: true,
+ });
+ }
+
+ setupMonacoTheme() {
+ this.monaco.editor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme);
+
+ this.monaco.editor.setTheme('gitlab');
}
clearEditor() {
@@ -77,17 +134,27 @@ export default class Editor {
}
dispose() {
- this.disposable.dispose();
window.removeEventListener('resize', this.debouncedUpdate);
- // dispose main monaco instance
- if (this.instance) {
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ this.disposable.dispose();
+
this.instance = null;
+ } catch (e) {
+ this.instance = null;
+
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
}
}
updateDimensions() {
this.instance.layout();
+ this.updateDiffView();
}
setPosition({ lineNumber, column }) {
@@ -102,8 +169,24 @@ export default class Editor {
}
onPositionChange(cb) {
- this.disposable.add(
- this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)),
- );
+ if (!this.instance.onDidChangeCursorPosition) return;
+
+ this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)));
+ }
+
+ updateDiffView() {
+ if (!this.isDiffEditorType) return;
+
+ this.instance.updateOptions({
+ renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()),
+ });
+ }
+
+ get isDiffEditorType() {
+ return this.instance.getEditorType() === 'vs.editor.IDiffEditor';
+ }
+
+ static renderSideBySide(domElement) {
+ return domElement.offsetWidth >= 700;
}
}
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index 701affc466e..9f895d49f2e 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -1,2 +1,16 @@
-export default [{
-}];
+export const defaultEditorOptions = {
+ model: null,
+ readOnly: false,
+ contextmenu: true,
+ scrollBeyondLastLine: false,
+ minimap: {
+ enabled: false,
+ },
+ wordWrap: 'on',
+};
+
+export default [
+ {
+ readOnly: model => !!model.file.file_lock,
+ },
+];
diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js
new file mode 100644
index 00000000000..2fc96250c7d
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/gl_theme.js
@@ -0,0 +1,14 @@
+export default {
+ themeName: 'gitlab',
+ monacoTheme: {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editorLineNumber.foreground': '#CCCCCC',
+ 'diffEditor.insertedTextBackground': '#ddfbe6',
+ 'diffEditor.removedTextBackground': '#f9d7dc',
+ 'editor.selectionBackground': '#aad6f8',
+ },
+ },
+};
diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js
index af83a1ec0b4..142a220097b 100644
--- a/app/assets/javascripts/ide/monaco_loader.js
+++ b/app/assets/javascripts/ide/monaco_loader.js
@@ -6,6 +6,11 @@ monacoContext.require.config({
},
});
+// ignore CDN config and use local assets path for service worker which cannot be cross-domain
+const relativeRootPath = (gon && gon.relative_url_root) || '';
+const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`;
+window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` };
+
// eslint-disable-next-line no-underscore-dangle
window.__monaco_context__ = monacoContext;
export default monacoContext.require;
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 1fb24e93f2e..a12e637616a 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
-import Api from '../../api';
+import Api from '~/api';
Vue.use(VueResource);
@@ -20,12 +20,35 @@ export default {
return Promise.resolve(file.raw);
}
- return Vue.http.get(file.rawPath, { params: { format: 'json' } })
+ return Vue.http.get(file.rawPath, { params: { format: 'json' } }).then(res => res.text());
+ },
+ getBaseRawFileData(file, sha) {
+ if (file.tempFile) {
+ return Promise.resolve(file.baseRaw);
+ }
+
+ if (file.baseRaw) {
+ return Promise.resolve(file.baseRaw);
+ }
+
+ return Vue.http
+ .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), {
+ params: { format: 'json' },
+ })
.then(res => res.text());
},
getProjectData(namespace, project) {
return Api.project(`${namespace}/${project}`);
},
+ getProjectMergeRequestData(projectId, mergeRequestId) {
+ return Api.mergeRequest(projectId, mergeRequestId);
+ },
+ getProjectMergeRequestChanges(projectId, mergeRequestId) {
+ return Api.mergeRequestChanges(projectId, mergeRequestId);
+ },
+ getProjectMergeRequestVersions(projectId, mergeRequestId) {
+ return Api.mergeRequestVersions(projectId, mergeRequestId);
+ },
getBranchData(projectId, currentBranchId) {
return Api.branchSingle(projectId, currentBranchId);
},
@@ -44,4 +67,12 @@ export default {
},
});
},
+ getFiles(projectUrl, branchId) {
+ const url = `${projectUrl}/files/${branchId}`;
+ return Vue.http.get(url, {
+ params: {
+ format: 'json',
+ },
+ });
+ },
};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index c01046c8c76..c6ba679d99c 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,58 +1,27 @@
import Vue from 'vue';
-import { visitUrl } from '../../lib/utils/url_utility';
-import flash from '../../flash';
-import service from '../services';
+import { visitUrl } from '~/lib/utils/url_utility';
+import flash from '~/flash';
import * as types from './mutation_types';
+import FilesDecoratorWorker from './workers/files_decorator_worker';
export const redirectToUrl = (_, url) => visitUrl(url);
-export const setInitialData = ({ commit }, data) =>
- commit(types.SET_INITIAL_DATA, data);
+export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
-export const closeDiscardPopup = ({ commit }) =>
- commit(types.TOGGLE_DISCARD_POPUP, false);
-
-export const discardAllChanges = ({ commit, getters, dispatch }) => {
- const changedFiles = getters.changedFiles;
-
- changedFiles.forEach((file) => {
- commit(types.DISCARD_FILE_CHANGES, file);
+export const discardAllChanges = ({ state, commit, dispatch }) => {
+ state.changedFiles.forEach(file => {
+ commit(types.DISCARD_FILE_CHANGES, file.path);
if (file.tempFile) {
- dispatch('closeFile', { file, force: true });
+ dispatch('closeFile', file.path);
}
});
-};
-export const closeAllFiles = ({ state, dispatch }) => {
- state.openFiles.forEach(file => dispatch('closeFile', { file }));
+ commit(types.REMOVE_ALL_CHANGES_FILES);
};
-export const toggleEditMode = (
- { state, commit, getters, dispatch },
- force = false,
-) => {
- const changedFiles = getters.changedFiles;
-
- if (changedFiles.length && !force) {
- commit(types.TOGGLE_DISCARD_POPUP, true);
- } else {
- commit(types.TOGGLE_EDIT_MODE);
- commit(types.TOGGLE_DISCARD_POPUP, false);
- dispatch('toggleBlobView');
-
- if (!state.editMode) {
- dispatch('discardAllChanges');
- }
- }
-};
-
-export const toggleBlobView = ({ commit, state }) => {
- if (state.editMode) {
- commit(types.SET_EDIT_MODE);
- } else {
- commit(types.SET_PREVIEW_MODE);
- }
+export const closeAllFiles = ({ state, dispatch }) => {
+ state.openFiles.forEach(file => dispatch('closeFile', file));
};
export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
@@ -63,103 +32,65 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
}
};
-export const checkCommitStatus = ({ state }) =>
- service
- .getBranchData(state.currentProjectId, state.currentBranchId)
- .then((data) => {
- const { id } = data.commit;
- const selectedBranch =
- state.projects[state.currentProjectId].branches[state.currentBranchId];
-
- if (selectedBranch.workingReference !== id) {
- return true;
- }
-
- return false;
- })
- .catch(() => flash('Error checking branch data. Please try again.'));
+export const setResizingStatus = ({ commit }, resizing) => {
+ commit(types.SET_RESIZING_STATUS, resizing);
+};
-export const commitChanges = (
- { commit, state, dispatch, getters },
- { payload, newMr },
+export const createTempEntry = (
+ { state, commit, dispatch },
+ { branchId, name, type, content = '', base64 = false },
) =>
- service
- .commit(state.currentProjectId, payload)
- .then((data) => {
- const { branch } = payload;
- if (!data.short_id) {
- flash(data.message);
- return;
- }
-
- const selectedProject = state.projects[state.currentProjectId];
- const lastCommit = {
- commit_path: `${selectedProject.web_url}/commit/${data.id}`,
- commit: {
- message: data.message,
- authored_date: data.committed_date,
- },
- };
+ new Promise(resolve => {
+ const worker = new FilesDecoratorWorker();
+ const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
+ if (state.entries[name]) {
flash(
- `Your changes have been committed. Commit ${data.short_id} with ${
- data.stats.additions
- } additions, ${data.stats.deletions} deletions.`,
- 'notice',
+ `The name "${name.split('/').pop()}" is already taken in this directory.`,
+ 'alert',
+ document,
+ null,
+ false,
+ true,
);
- if (newMr) {
- dispatch(
- 'redirectToUrl',
- `${
- selectedProject.web_url
- }/merge_requests/new?merge_request%5Bsource_branch%5D=${branch}`,
- );
- } else {
- commit(types.SET_BRANCH_WORKING_REFERENCE, {
- projectId: state.currentProjectId,
- branchId: state.currentBranchId,
- reference: data.id,
- });
-
- getters.changedFiles.forEach((entry) => {
- commit(types.SET_LAST_COMMIT_DATA, {
- entry,
- lastCommit,
- });
- });
-
- dispatch('discardAllChanges');
- dispatch('closeAllFiles');
-
- window.scrollTo(0, 0);
+ resolve();
+
+ return null;
+ }
+
+ worker.addEventListener('message', ({ data }) => {
+ const { file } = data;
+
+ worker.terminate();
+
+ commit(types.CREATE_TMP_ENTRY, {
+ data,
+ projectId: state.currentProjectId,
+ branchId,
+ });
+
+ if (type === 'blob') {
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ commit(types.ADD_FILE_TO_CHANGED, file.path);
+ dispatch('setFileActive', file.path);
}
- })
- .catch(() => flash('Error committing changes. Please try again.'));
-export const createTempEntry = (
- { state, dispatch },
- { projectId, branchId, parent, name, type, content = '', base64 = false },
-) => {
- const selectedParent = parent || state.trees[`${projectId}/${branchId}`];
- if (type === 'tree') {
- dispatch('createTempTree', {
- projectId,
- branchId,
- parent: selectedParent,
- name,
+ resolve(file);
});
- } else if (type === 'blob') {
- dispatch('createTempFile', {
- projectId,
+
+ worker.postMessage({
+ data: [fullName],
+ projectId: state.currentProjectId,
branchId,
- parent: selectedParent,
- name,
+ type,
+ tempFile: true,
base64,
content,
});
- }
-};
+
+ return null;
+ });
export const scrollToTab = () => {
Vue.nextTick(() => {
@@ -173,7 +104,15 @@ export const scrollToTab = () => {
});
};
+export const updateViewer = ({ commit }, viewer) => {
+ commit(types.UPDATE_VIEWER, viewer);
+};
+
+export const updateDelayViewerUpdated = ({ commit }, delay) => {
+ commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
+};
+
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
-export * from './actions/branch';
+export * from './actions/merge_request';
diff --git a/app/assets/javascripts/ide/stores/actions/branch.js b/app/assets/javascripts/ide/stores/actions/branch.js
deleted file mode 100644
index 32bdf7fec22..00000000000
--- a/app/assets/javascripts/ide/stores/actions/branch.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import service from '../../services';
-import flash from '../../../flash';
-import * as types from '../mutation_types';
-
-export const getBranchData = (
- { commit, state, dispatch },
- { projectId, branchId, force = false } = {},
-) => new Promise((resolve, reject) => {
- if ((typeof state.projects[`${projectId}`] === 'undefined' ||
- !state.projects[`${projectId}`].branches[branchId])
- || force) {
- service.getBranchData(`${projectId}`, branchId)
- .then((data) => {
- const { id } = data.commit;
- commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data });
- commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
- resolve(data);
- })
- .catch(() => {
- flash('Error loading branch data. Please try again.');
- reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
- });
- } else {
- resolve(state.projects[`${projectId}`].branches[branchId]);
- }
-});
-
-export const createNewBranch = ({ state, commit }, branch) => service.createBranch(
- state.currentProjectId,
- {
- branch,
- ref: state.currentBranchId,
- },
-)
-.then(res => res.json())
-.then((data) => {
- const branchName = data.name;
- const url = location.href.replace(state.currentBranchId, branchName);
-
- if (this.$router) this.$router.push(url);
-
- commit(types.SET_CURRENT_BRANCH, branchName);
-});
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 0f27d5bf1c3..1a17320a1ea 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -1,131 +1,187 @@
-import { normalizeHeaders } from '../../../lib/utils/common_utils';
-import flash from '../../../flash';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
+import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
-import {
- findEntry,
- setPageTitle,
- createTemp,
- findIndexOfFile,
-} from '../utils';
+import { setPageTitle } from '../utils';
-export const closeFile = ({ commit, state, dispatch }, { file, force = false }) => {
- if ((file.changed || file.tempFile) && !force) return;
-
- const indexOfClosedFile = findIndexOfFile(state.openFiles, file);
+export const closeFile = ({ commit, state, dispatch }, file) => {
+ const path = file.path;
+ const indexOfClosedFile = state.openFiles.findIndex(f => f.key === file.key);
const fileWasActive = file.active;
- commit(types.TOGGLE_FILE_OPEN, file);
- commit(types.SET_FILE_ACTIVE, { file, active: false });
+ if (file.pending) {
+ commit(types.REMOVE_PENDING_TAB, file);
+ } else {
+ commit(types.TOGGLE_FILE_OPEN, path);
+ commit(types.SET_FILE_ACTIVE, { path, active: false });
+ }
if (state.openFiles.length > 0 && fileWasActive) {
const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1;
const nextFileToOpen = state.openFiles[nextIndexToOpen];
- dispatch('setFileActive', nextFileToOpen);
+ if (nextFileToOpen.pending) {
+ dispatch('updateViewer', 'diff');
+ dispatch('openPendingTab', nextFileToOpen);
+ } else {
+ dispatch('updateDelayViewerUpdated', true);
+ router.push(`/project${nextFileToOpen.url}`);
+ }
} else if (!state.openFiles.length) {
router.push(`/project/${file.projectId}/tree/${file.branchId}/`);
}
- dispatch('getLastCommitData');
+ eventHub.$emit(`editor.update.model.dispose.${file.key}`);
};
-export const setFileActive = ({ commit, state, getters, dispatch }, file) => {
+export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
+ const file = state.entries[path];
const currentActiveFile = getters.activeFile;
if (file.active) return;
if (currentActiveFile) {
- commit(types.SET_FILE_ACTIVE, { file: currentActiveFile, active: false });
+ commit(types.SET_FILE_ACTIVE, {
+ path: currentActiveFile.path,
+ active: false,
+ });
}
- commit(types.SET_FILE_ACTIVE, { file, active: true });
+ commit(types.SET_FILE_ACTIVE, { path, active: true });
dispatch('scrollToTab');
- // reset hash for line highlighting
- location.hash = '';
-
commit(types.SET_CURRENT_PROJECT, file.projectId);
commit(types.SET_CURRENT_BRANCH, file.branchId);
};
-export const getFileData = ({ state, commit, dispatch }, file) => {
- commit(types.TOGGLE_LOADING, file);
-
- service.getFileData(file.url)
- .then((res) => {
+export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => {
+ const file = state.entries[path];
+ commit(types.TOGGLE_LOADING, { entry: file });
+ return service
+ .getFileData(file.url)
+ .then(res => {
const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
-
setPageTitle(pageTitle);
return res.json();
})
- .then((data) => {
+ .then(data => {
commit(types.SET_FILE_DATA, { data, file });
- commit(types.TOGGLE_FILE_OPEN, file);
- dispatch('setFileActive', file);
- commit(types.TOGGLE_LOADING, file);
+ commit(types.TOGGLE_FILE_OPEN, path);
+ if (makeFileActive) dispatch('setFileActive', path);
+ commit(types.TOGGLE_LOADING, { entry: file });
})
.catch(() => {
- commit(types.TOGGLE_LOADING, file);
- flash('Error loading file data. Please try again.');
+ commit(types.TOGGLE_LOADING, { entry: file });
+ flash('Error loading file data. Please try again.', 'alert', document, null, false, true);
});
};
-export const getRawFileData = ({ commit, dispatch }, file) => service.getRawFileData(file)
- .then((raw) => {
- commit(types.SET_FILE_RAW_DATA, { file, raw });
- })
- .catch(() => flash('Error loading file content. Please try again.'));
+export const setFileMrChange = ({ state, commit }, { file, mrChange }) => {
+ commit(types.SET_FILE_MERGE_REQUEST_CHANGE, { file, mrChange });
+};
-export const changeFileContent = ({ commit }, { file, content }) => {
- commit(types.UPDATE_FILE_CONTENT, { file, content });
+export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) => {
+ const file = state.entries[path];
+ return new Promise((resolve, reject) => {
+ service
+ .getRawFileData(file)
+ .then(raw => {
+ commit(types.SET_FILE_RAW_DATA, { file, raw });
+ if (file.mrChange && file.mrChange.new_file === false) {
+ service
+ .getBaseRawFileData(file, baseSha)
+ .then(baseRaw => {
+ commit(types.SET_FILE_BASE_RAW_DATA, {
+ file,
+ baseRaw,
+ });
+ resolve(raw);
+ })
+ .catch(e => {
+ reject(e);
+ });
+ } else {
+ resolve(raw);
+ }
+ })
+ .catch(() => {
+ flash('Error loading file content. Please try again.');
+ reject();
+ });
+ });
};
-export const setFileLanguage = ({ state, commit }, { fileLanguage }) => {
- commit(types.SET_FILE_LANGUAGE, { file: state.selectedFile, fileLanguage });
+export const changeFileContent = ({ state, commit }, { path, content }) => {
+ const file = state.entries[path];
+ commit(types.UPDATE_FILE_CONTENT, { path, content });
+
+ const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
+
+ if (file.changed && indexOfChangedFile === -1) {
+ commit(types.ADD_FILE_TO_CHANGED, path);
+ } else if (!file.changed && indexOfChangedFile !== -1) {
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+ }
};
-export const setFileEOL = ({ state, commit }, { eol }) => {
- commit(types.SET_FILE_EOL, { file: state.selectedFile, eol });
+export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage });
+ }
};
-export const setEditorPosition = ({ state, commit }, { editorRow, editorColumn }) => {
- commit(types.SET_FILE_POSITION, { file: state.selectedFile, editorRow, editorColumn });
+export const setFileEOL = ({ getters, commit }, { eol }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_EOL, { file: getters.activeFile, eol });
+ }
};
-export const createTempFile = ({ state, commit, dispatch }, { projectId, branchId, parent, name, content = '', base64 = '' }) => {
- const path = parent.path !== undefined ? parent.path : '';
- // We need to do the replacement otherwise the web_url + file.url duplicate
- const newUrl = `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${name}`;
- const file = createTemp({
- projectId,
- branchId,
- name: name.replace(`${path}/`, ''),
- path,
- type: 'blob',
- level: parent.level !== undefined ? parent.level + 1 : 0,
- changed: true,
- content,
- base64,
- url: newUrl,
- });
+export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_POSITION, {
+ file: getters.activeFile,
+ editorRow,
+ editorColumn,
+ });
+ }
+};
- if (findEntry(parent.tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`);
+export const setFileViewMode = ({ state, commit }, { file, viewMode }) => {
+ commit(types.SET_FILE_VIEWMODE, { file, viewMode });
+};
- commit(types.CREATE_TMP_FILE, {
- parent,
- file,
- });
- commit(types.TOGGLE_FILE_OPEN, file);
- dispatch('setFileActive', file);
+export const discardFileChanges = ({ state, commit }, path) => {
+ const file = state.entries[path];
- if (!state.editMode && !file.base64) {
- dispatch('toggleEditMode', true);
+ commit(types.DISCARD_FILE_CHANGES, path);
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+
+ if (file.tempFile && file.opened) {
+ commit(types.TOGGLE_FILE_OPEN, path);
}
- router.push(`/project${file.url}`);
+ eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw);
+};
+
+export const openPendingTab = ({ commit, getters, dispatch, state }, file) => {
+ if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') {
+ return false;
+ }
+
+ commit(types.ADD_PENDING_TAB, { file });
+
+ dispatch('scrollToTab');
+
+ router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`);
+
+ return true;
+};
+
+export const removePendingTab = ({ commit }, file) => {
+ commit(types.REMOVE_PENDING_TAB, file);
- return Promise.resolve(file);
+ eventHub.$emit(`editor.update.model.dispose.${file.key}`);
};
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
new file mode 100644
index 00000000000..da73034fd7d
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -0,0 +1,84 @@
+import flash from '~/flash';
+import service from '../../services';
+import * as types from '../mutation_types';
+
+export const getMergeRequestData = (
+ { commit, state, dispatch },
+ { projectId, mergeRequestId, force = false } = {},
+) =>
+ new Promise((resolve, reject) => {
+ if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) {
+ service
+ .getProjectMergeRequestData(projectId, mergeRequestId)
+ .then(res => res.data)
+ .then(data => {
+ commit(types.SET_MERGE_REQUEST, {
+ projectPath: projectId,
+ mergeRequestId,
+ mergeRequest: data,
+ });
+ if (!state.currentMergeRequestId) {
+ commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId);
+ }
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading merge request data. Please try again.');
+ reject(new Error(`Merge Request not loaded ${projectId}`));
+ });
+ } else {
+ resolve(state.projects[projectId].mergeRequests[mergeRequestId]);
+ }
+ });
+
+export const getMergeRequestChanges = (
+ { commit, state, dispatch },
+ { projectId, mergeRequestId, force = false } = {},
+) =>
+ new Promise((resolve, reject) => {
+ if (!state.projects[projectId].mergeRequests[mergeRequestId].changes.length || force) {
+ service
+ .getProjectMergeRequestChanges(projectId, mergeRequestId)
+ .then(res => res.data)
+ .then(data => {
+ commit(types.SET_MERGE_REQUEST_CHANGES, {
+ projectPath: projectId,
+ mergeRequestId,
+ changes: data,
+ });
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading merge request changes. Please try again.');
+ reject(new Error(`Merge Request Changes not loaded ${projectId}`));
+ });
+ } else {
+ resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes);
+ }
+ });
+
+export const getMergeRequestVersions = (
+ { commit, state, dispatch },
+ { projectId, mergeRequestId, force = false } = {},
+) =>
+ new Promise((resolve, reject) => {
+ if (!state.projects[projectId].mergeRequests[mergeRequestId].versions.length || force) {
+ service
+ .getProjectMergeRequestVersions(projectId, mergeRequestId)
+ .then(res => res.data)
+ .then(data => {
+ commit(types.SET_MERGE_REQUEST_VERSIONS, {
+ projectPath: projectId,
+ mergeRequestId,
+ versions: data,
+ });
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading merge request versions. Please try again.');
+ reject(new Error(`Merge Request Versions not loaded ${projectId}`));
+ });
+ } else {
+ resolve(state.projects[projectId].mergeRequests[mergeRequestId].versions);
+ }
+ });
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 75e332090cb..b3882cb8d21 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -1,25 +1,49 @@
+import flash from '~/flash';
import service from '../../services';
-import flash from '../../../flash';
import * as types from '../mutation_types';
-// eslint-disable-next-line import/prefer-default-export
export const getProjectData = (
{ commit, state, dispatch },
{ namespace, projectId, force = false } = {},
) => new Promise((resolve, reject) => {
if (!state.projects[`${namespace}/${projectId}`] || force) {
+ commit(types.TOGGLE_LOADING, { entry: state });
service.getProjectData(namespace, projectId)
.then(res => res.data)
.then((data) => {
+ commit(types.TOGGLE_LOADING, { entry: state });
commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data });
if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`);
resolve(data);
})
.catch(() => {
- flash('Error loading project data. Please try again.');
+ flash('Error loading project data. Please try again.', 'alert', document, null, false, true);
reject(new Error(`Project not loaded ${namespace}/${projectId}`));
});
} else {
resolve(state.projects[`${namespace}/${projectId}`]);
}
});
+
+export const getBranchData = (
+ { commit, state, dispatch },
+ { projectId, branchId, force = false } = {},
+) => new Promise((resolve, reject) => {
+ if ((typeof state.projects[`${projectId}`] === 'undefined' ||
+ !state.projects[`${projectId}`].branches[branchId])
+ || force) {
+ service.getBranchData(`${projectId}`, branchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data });
+ commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
+ resolve(data);
+ })
+ .catch(() => {
+ flash('Error loading branch data. Please try again.', 'alert', document, null, false, true);
+ reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
+ });
+ } else {
+ resolve(state.projects[`${projectId}`].branches[branchId]);
+ }
+});
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index 25909400a75..6536be04f0a 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -1,147 +1,42 @@
-import { visitUrl } from '../../../lib/utils/url_utility';
-import { normalizeHeaders } from '../../../lib/utils/common_utils';
-import flash from '../../../flash';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
import service from '../../services';
import * as types from '../mutation_types';
-import router from '../../ide_router';
-import {
- setPageTitle,
- findEntry,
- createTemp,
- createOrMergeEntry,
-} from '../utils';
+import { findEntry } from '../utils';
+import FilesDecoratorWorker from '../workers/files_decorator_worker';
-export const getTreeData = (
- { commit, state, dispatch },
- { endpoint, tree = null, projectId, branch, force = false } = {},
-) => new Promise((resolve, reject) => {
- // We already have the base tree so we resolve immediately
- if (!tree && state.trees[`${projectId}/${branch}`] && !force) {
- resolve();
- } else {
- if (tree) commit(types.TOGGLE_LOADING, tree);
- const selectedProject = state.projects[projectId];
- // We are merging the web_url that we got on the project info with the endpoint
- // we got on the tree entry, as both contain the projectId, we replace it in the tree endpoint
- const completeEndpoint = selectedProject.web_url + (endpoint).replace(projectId, '');
- if (completeEndpoint && (!tree || !tree.tempFile)) {
- service.getTreeData(completeEndpoint)
- .then((res) => {
- const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
-
- setPageTitle(pageTitle);
-
- return res.json();
- })
- .then((data) => {
- if (!state.isInitialRoot) {
- commit(types.SET_ROOT, data.path === '/');
- }
-
- dispatch('updateDirectoryData', { data, tree, projectId, branch });
- const selectedTree = tree || state.trees[`${projectId}/${branch}`];
-
- commit(types.SET_PARENT_TREE_URL, data.parent_tree_url);
- commit(types.SET_LAST_COMMIT_URL, { tree: selectedTree, url: data.last_commit_path });
- if (tree) commit(types.TOGGLE_LOADING, selectedTree);
-
- const prevLastCommitPath = selectedTree.lastCommitPath;
- if (prevLastCommitPath !== null) {
- dispatch('getLastCommitData', selectedTree);
- }
- resolve(data);
- })
- .catch((e) => {
- flash('Error loading tree data. Please try again.');
- if (tree) commit(types.TOGGLE_LOADING, tree);
- reject(e);
- });
- } else {
- resolve();
- }
- }
-});
-
-export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => {
- if (tree.opened) {
- // send empty data to clear the tree
- const data = { trees: [], blobs: [], submodules: [] };
-
- dispatch('updateDirectoryData', { data, tree, projectId: tree.projectId, branchId: tree.branchId });
- } else {
- dispatch('getTreeData', { endpoint, tree, projectId: tree.projectId, branch: tree.branchId });
- }
-
- commit(types.TOGGLE_TREE_OPEN, tree);
+export const toggleTreeOpen = ({ commit, dispatch }, path) => {
+ commit(types.TOGGLE_TREE_OPEN, path);
};
export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
if (row.type === 'tree') {
- dispatch('toggleTreeOpen', {
- endpoint: row.url,
- tree: row,
- });
- } else if (row.type === 'submodule') {
- commit(types.TOGGLE_LOADING, row);
- visitUrl(row.url);
- } else if (row.type === 'blob' && row.opened) {
- dispatch('setFileActive', row);
+ dispatch('toggleTreeOpen', row.path);
+ } else if (row.type === 'blob' && (row.opened || row.changed)) {
+ if (row.changed && !row.opened) {
+ commit(types.TOGGLE_FILE_OPEN, row.path);
+ }
+
+ dispatch('setFileActive', row.path);
} else {
- dispatch('getFileData', row);
+ dispatch('getFileData', { path: row.path });
}
};
-export const createTempTree = (
- { state, commit, dispatch },
- { projectId, branchId, parent, name },
-) => {
- let selectedTree = parent;
- const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/');
-
- dirNames.forEach((dirName) => {
- const foundEntry = findEntry(selectedTree.tree, 'tree', dirName);
-
- if (!foundEntry) {
- const path = selectedTree.path !== undefined ? selectedTree.path : '';
- const tmpEntry = createTemp({
- projectId,
- branchId,
- name: dirName,
- path,
- type: 'tree',
- level: selectedTree.level !== undefined ? selectedTree.level + 1 : 0,
- tree: [],
- url: `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${dirName}`,
- });
-
- commit(types.CREATE_TMP_TREE, {
- parent: selectedTree,
- tmpEntry,
- });
- commit(types.TOGGLE_TREE_OPEN, tmpEntry);
-
- router.push(`/project${tmpEntry.url}`);
-
- selectedTree = tmpEntry;
- } else {
- selectedTree = foundEntry;
- }
- });
-};
-
export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => {
if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return;
- service.getTreeLastCommit(tree.lastCommitPath)
- .then((res) => {
+ service
+ .getTreeLastCommit(tree.lastCommitPath)
+ .then(res => {
const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null;
commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath });
return res.json();
})
- .then((data) => {
- data.forEach((lastCommit) => {
+ .then(data => {
+ data.forEach(lastCommit => {
const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name);
if (entry) {
@@ -151,38 +46,50 @@ export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = s
dispatch('getLastCommitData', tree);
})
- .catch(() => flash('Error fetching log data.'));
+ .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true));
};
-export const updateDirectoryData = (
- { commit, state },
- { data, tree, projectId, branch },
-) => {
- if (!tree) {
- const existingTree = state.trees[`${projectId}/${branch}`];
- if (!existingTree) {
- commit(types.CREATE_TREE, { treePath: `${projectId}/${branch}` });
+export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) =>
+ new Promise((resolve, reject) => {
+ if (!state.trees[`${projectId}/${branchId}`]) {
+ const selectedProject = state.projects[projectId];
+ commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
+
+ service
+ .getFiles(selectedProject.web_url, branchId)
+ .then(res => res.json())
+ .then(data => {
+ const worker = new FilesDecoratorWorker();
+ worker.addEventListener('message', e => {
+ const { entries, treeList } = e.data;
+ const selectedTree = state.trees[`${projectId}/${branchId}`];
+
+ commit(types.SET_ENTRIES, entries);
+ commit(types.SET_DIRECTORY_DATA, {
+ treePath: `${projectId}/${branchId}`,
+ data: treeList,
+ });
+ commit(types.TOGGLE_LOADING, {
+ entry: selectedTree,
+ forceValue: false,
+ });
+
+ worker.terminate();
+
+ resolve();
+ });
+
+ worker.postMessage({
+ data,
+ projectId,
+ branchId,
+ });
+ })
+ .catch(e => {
+ flash('Error loading tree data. Please try again.', 'alert', document, null, false, true);
+ reject(e);
+ });
+ } else {
+ resolve();
}
- }
-
- const selectedTree = tree || state.trees[`${projectId}/${branch}`];
- const level = selectedTree.level !== undefined ? selectedTree.level + 1 : 0;
- const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl;
- const createEntry = (entry, type) => createOrMergeEntry({
- tree: selectedTree,
- projectId: `${projectId}`,
- branchId: branch,
- entry,
- level,
- type,
- parentTreeUrl,
});
-
- const formattedData = [
- ...data.trees.map(t => createEntry(t, 'tree')),
- ...data.submodules.map(m => createEntry(m, 'submodule')),
- ...data.blobs.map(b => createEntry(b, 'blob')),
- ];
-
- commit(types.SET_DIRECTORY_DATA, { tree: selectedTree, data: formattedData });
-};
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 6b51ccff817..a77cdbc13c8 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -1,19 +1,37 @@
-export const changedFiles = state => state.openFiles.filter(file => file.changed);
-
export const activeFile = state => state.openFiles.find(file => file.active) || null;
-export const activeFileExtension = (state) => {
- const file = activeFile(state);
- return file ? `.${file.path.split('.').pop()}` : '';
-};
+export const addedFiles = state => state.changedFiles.filter(f => f.tempFile);
+
+export const modifiedFiles = state => state.changedFiles.filter(f => !f.tempFile);
-export const canEditFile = (state) => {
- const currentActiveFile = activeFile(state);
+export const projectsWithTrees = state =>
+ Object.keys(state.projects).map(projectId => {
+ const project = state.projects[projectId];
- return state.canCommit &&
- (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary);
+ return {
+ ...project,
+ branches: Object.keys(project.branches).map(branchId => {
+ const branch = project.branches[branchId];
+
+ return {
+ ...branch,
+ tree: state.trees[branch.treeId],
+ };
+ }),
+ };
+ });
+
+export const currentMergeRequest = state => {
+ if (state.projects[state.currentProjectId]) {
+ return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId];
+ }
+ return null;
};
-export const addedFiles = state => changedFiles(state).filter(f => f.tempFile);
+// eslint-disable-next-line no-confusing-arrow
+export const currentIcon = state =>
+ state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
+
+export const hasChanges = state => !!state.changedFiles.length;
-export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile);
+export const hasMergeRequest = state => !!state.currentMergeRequestId;
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index 6ac9bfd8189..7c82ce7976b 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -4,6 +4,7 @@ import state from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
+import commitModule from './modules/commit';
Vue.use(Vuex);
@@ -12,4 +13,7 @@ export default new Vuex.Store({
actions,
mutations,
getters,
+ modules: {
+ commit: commitModule,
+ },
});
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
new file mode 100644
index 00000000000..f536ce6344b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -0,0 +1,218 @@
+import $ from 'jquery';
+import { sprintf, __ } from '~/locale';
+import flash from '~/flash';
+import { stripHtml } from '~/lib/utils/text_utility';
+import * as rootTypes from '../../mutation_types';
+import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
+import router from '../../../ide_router';
+import service from '../../../services';
+import * as types from './mutation_types';
+import * as consts from './constants';
+import eventHub from '../../../eventhub';
+
+export const updateCommitMessage = ({ commit }, message) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, message);
+};
+
+export const discardDraft = ({ commit }) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, '');
+};
+
+export const updateCommitAction = ({ commit }, commitAction) => {
+ commit(types.UPDATE_COMMIT_ACTION, commitAction);
+};
+
+export const updateBranchName = ({ commit }, branchName) => {
+ commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
+};
+
+export const setLastCommitMessage = ({ rootState, commit }, data) => {
+ const currentProject = rootState.projects[rootState.currentProjectId];
+ const commitStats = data.stats
+ ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), {
+ additions: data.stats.additions, // eslint-disable-line indent
+ deletions: data.stats.deletions, // eslint-disable-line indent
+ }) // eslint-disable-line indent
+ : '';
+ const commitMsg = sprintf(
+ __('Your changes have been committed. Commit %{commitId} %{commitStats}'),
+ {
+ commitId: `<a href="${currentProject.web_url}/commit/${
+ data.short_id
+ }" class="commit-sha">${data.short_id}</a>`,
+ commitStats,
+ },
+ false,
+ );
+
+ commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true });
+};
+
+export const checkCommitStatus = ({ rootState }) =>
+ service
+ .getBranchData(rootState.currentProjectId, rootState.currentBranchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ const selectedBranch =
+ rootState.projects[rootState.currentProjectId].branches[
+ rootState.currentBranchId
+ ];
+
+ if (selectedBranch.workingReference !== id) {
+ return true;
+ }
+
+ return false;
+ })
+ .catch(() =>
+ flash(
+ __('Error checking branch data. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ ),
+ );
+
+export const updateFilesAfterCommit = (
+ { commit, dispatch, state, rootState, rootGetters },
+ { data, branch },
+) => {
+ const selectedProject = rootState.projects[rootState.currentProjectId];
+ const lastCommit = {
+ commit_path: `${selectedProject.web_url}/commit/${data.id}`,
+ commit: {
+ id: data.id,
+ message: data.message,
+ authored_date: data.committed_date,
+ author_name: data.committer_name,
+ },
+ };
+
+ commit(
+ rootTypes.SET_BRANCH_WORKING_REFERENCE,
+ {
+ projectId: rootState.currentProjectId,
+ branchId: rootState.currentBranchId,
+ reference: data.id,
+ },
+ { root: true },
+ );
+
+ rootState.changedFiles.forEach(entry => {
+ commit(
+ rootTypes.SET_LAST_COMMIT_DATA,
+ {
+ entry,
+ lastCommit,
+ },
+ { root: true },
+ );
+
+ eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
+
+ commit(
+ rootTypes.SET_FILE_RAW_DATA,
+ {
+ file: entry,
+ raw: entry.content,
+ },
+ { root: true },
+ );
+
+ commit(
+ rootTypes.TOGGLE_FILE_CHANGED,
+ {
+ file: entry,
+ changed: false,
+ },
+ { root: true },
+ );
+ });
+
+ commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true });
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
+ router.push(
+ `/project/${rootState.currentProjectId}/blob/${branch}/${
+ rootGetters.activeFile.path
+ }`,
+ );
+ }
+
+ dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH);
+};
+
+export const commitChanges = ({
+ commit,
+ state,
+ getters,
+ dispatch,
+ rootState,
+}) => {
+ const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
+ const payload = createCommitPayload(
+ getters.branchName,
+ newBranch,
+ state,
+ rootState,
+ );
+ const getCommitStatus = newBranch
+ ? Promise.resolve(false)
+ : dispatch('checkCommitStatus');
+
+ commit(types.UPDATE_LOADING, true);
+
+ return getCommitStatus
+ .then(
+ branchChanged =>
+ new Promise(resolve => {
+ if (branchChanged) {
+ // show the modal with a Bootstrap call
+ $('#ide-create-branch-modal').modal('show');
+ } else {
+ resolve();
+ }
+ }),
+ )
+ .then(() => service.commit(rootState.currentProjectId, payload))
+ .then(({ data }) => {
+ commit(types.UPDATE_LOADING, false);
+
+ if (!data.short_id) {
+ flash(data.message, 'alert', document, null, false, true);
+ return;
+ }
+
+ dispatch('setLastCommitMessage', data);
+ dispatch('updateCommitMessage', '');
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
+ dispatch(
+ 'redirectToUrl',
+ createNewMergeRequestUrl(
+ rootState.projects[rootState.currentProjectId].web_url,
+ getters.branchName,
+ rootState.currentBranchId,
+ ),
+ { root: true },
+ );
+ } else {
+ dispatch('updateFilesAfterCommit', {
+ data,
+ branch: getters.branchName,
+ });
+ }
+ })
+ .catch(err => {
+ let errMsg = __('Error committing changes. Please try again.');
+ if (err.response.data && err.response.data.message) {
+ errMsg += ` (${stripHtml(err.response.data.message)})`;
+ }
+ flash(errMsg, 'alert', document, null, false, true);
+ window.dispatchEvent(new Event('resize'));
+
+ commit(types.UPDATE_LOADING, false);
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js
new file mode 100644
index 00000000000..230b0a3d9b5
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js
@@ -0,0 +1,3 @@
+export const COMMIT_TO_CURRENT_BRANCH = '1';
+export const COMMIT_TO_NEW_BRANCH = '2';
+export const COMMIT_TO_NEW_BRANCH_MR = '3';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
new file mode 100644
index 00000000000..f7cdd6adb0c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -0,0 +1,24 @@
+import * as consts from './constants';
+
+export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading;
+
+export const commitButtonDisabled = (state, getters, rootState) =>
+ getters.discardDraftButtonDisabled || !rootState.changedFiles.length;
+
+export const newBranchName = (state, _, rootState) =>
+ `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`;
+
+export const branchName = (state, getters, rootState) => {
+ if (
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH ||
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR
+ ) {
+ if (state.newBranchName === '') {
+ return getters.newBranchName;
+ }
+
+ return state.newBranchName;
+ }
+
+ return rootState.currentBranchId;
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js
new file mode 100644
index 00000000000..3bf65b02847
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/index.js
@@ -0,0 +1,12 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+import * as getters from './getters';
+
+export default {
+ namespaced: true,
+ state: state(),
+ mutations,
+ actions,
+ getters,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
new file mode 100644
index 00000000000..9221f054e9f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
@@ -0,0 +1,4 @@
+export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE';
+export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
+export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
+export const UPDATE_LOADING = 'UPDATE_LOADING';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
new file mode 100644
index 00000000000..797357e3df9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -0,0 +1,24 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.UPDATE_COMMIT_MESSAGE](state, commitMessage) {
+ Object.assign(state, {
+ commitMessage,
+ });
+ },
+ [types.UPDATE_COMMIT_ACTION](state, commitAction) {
+ Object.assign(state, {
+ commitAction,
+ });
+ },
+ [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
+ Object.assign(state, {
+ newBranchName,
+ });
+ },
+ [types.UPDATE_LOADING](state, submitCommitLoading) {
+ Object.assign(state, {
+ submitCommitLoading,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js
new file mode 100644
index 00000000000..8dae50961b0
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ commitMessage: '',
+ commitAction: '1',
+ newBranchName: '',
+ submitCommitLoading: false,
+});
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 4e3c10972ba..e3f504e5ab0 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -1,16 +1,22 @@
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
-export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL';
-export const SET_ROOT = 'SET_ROOT';
export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
+export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
+export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
// Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN';
+// Merge Request Mutation Types
+export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST';
+export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST';
+export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES';
+export const SET_MERGE_REQUEST_VERSIONS = 'SET_MERGE_REQUEST_VERSIONS';
+
// Branch Mutation Types
export const SET_BRANCH = 'SET_BRANCH';
export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
@@ -19,27 +25,31 @@ export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
// Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN';
-export const CREATE_TMP_TREE = 'CREATE_TMP_TREE';
export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL';
export const CREATE_TREE = 'CREATE_TREE';
+export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES';
// File mutation types
export const SET_FILE_DATA = 'SET_FILE_DATA';
export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN';
export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE';
export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA';
+export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA';
export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
export const SET_FILE_POSITION = 'SET_FILE_POSITION';
+export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
export const SET_FILE_EOL = 'SET_FILE_EOL';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
-export const CREATE_TMP_FILE = 'CREATE_TMP_FILE';
-
-// Viewer mutation types
-export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE';
-export const SET_EDIT_MODE = 'SET_EDIT_MODE';
-export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
-export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP';
-
+export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
+export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
+export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED';
export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH';
+export const SET_ENTRIES = 'SET_ENTRIES';
+export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY';
+export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE';
+export const UPDATE_VIEWER = 'UPDATE_VIEWER';
+export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
+export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
+export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 2fed9019cb6..5e5eb831662 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -1,5 +1,6 @@
import * as types from './mutation_types';
import projectMutations from './mutations/project';
+import mergeRequestMutation from './mutations/merge_request';
import fileMutations from './mutations/file';
import treeMutations from './mutations/tree';
import branchMutations from './mutations/branch';
@@ -8,57 +9,94 @@ export default {
[types.SET_INITIAL_DATA](state, data) {
Object.assign(state, data);
},
- [types.SET_PREVIEW_MODE](state) {
- Object.assign(state, {
- currentBlobView: 'repo-preview',
- });
+ [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) {
+ if (entry.path) {
+ Object.assign(state.entries[entry.path], {
+ loading: forceValue !== undefined ? forceValue : !state.entries[entry.path].loading,
+ });
+ } else {
+ Object.assign(entry, {
+ loading: forceValue !== undefined ? forceValue : !entry.loading,
+ });
+ }
},
- [types.SET_EDIT_MODE](state) {
+ [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
Object.assign(state, {
- currentBlobView: 'repo-editor',
+ leftPanelCollapsed: collapsed,
});
},
- [types.TOGGLE_LOADING](state, entry) {
- Object.assign(entry, {
- loading: !entry.loading,
+ [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
+ Object.assign(state, {
+ rightPanelCollapsed: collapsed,
});
},
- [types.TOGGLE_EDIT_MODE](state) {
+ [types.SET_RESIZING_STATUS](state, resizing) {
Object.assign(state, {
- editMode: !state.editMode,
+ panelResizing: resizing,
});
},
- [types.TOGGLE_DISCARD_POPUP](state, discardPopupOpen) {
- Object.assign(state, {
- discardPopupOpen,
+ [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) {
+ Object.assign(entry.lastCommit, {
+ id: lastCommit.commit.id,
+ url: lastCommit.commit_path,
+ message: lastCommit.commit.message,
+ author: lastCommit.commit.author_name,
+ updatedAt: lastCommit.commit.authored_date,
});
},
- [types.SET_ROOT](state, isRoot) {
+ [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) {
Object.assign(state, {
- isRoot,
- isInitialRoot: isRoot,
+ lastCommitMsg,
});
},
- [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
+ [types.SET_ENTRIES](state, entries) {
Object.assign(state, {
- leftPanelCollapsed: collapsed,
+ entries,
});
},
- [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
+ [types.CREATE_TMP_ENTRY](state, { data, projectId, branchId }) {
+ Object.keys(data.entries).reduce((acc, key) => {
+ const entry = data.entries[key];
+ const foundEntry = state.entries[key];
+
+ if (!foundEntry) {
+ Object.assign(state.entries, {
+ [key]: entry,
+ });
+ } else {
+ const tree = entry.tree.filter(
+ f => foundEntry.tree.find(e => e.path === f.path) === undefined,
+ );
+ Object.assign(foundEntry, {
+ tree: foundEntry.tree.concat(tree),
+ });
+ }
+
+ return acc.concat(key);
+ }, []);
+
+ const foundEntry = state.trees[`${projectId}/${branchId}`].tree.find(
+ e => e.path === data.treeList[0].path,
+ );
+
+ if (!foundEntry) {
+ Object.assign(state.trees[`${projectId}/${branchId}`], {
+ tree: state.trees[`${projectId}/${branchId}`].tree.concat(data.treeList),
+ });
+ }
+ },
+ [types.UPDATE_VIEWER](state, viewer) {
Object.assign(state, {
- rightPanelCollapsed: collapsed,
+ viewer,
});
},
- [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) {
- Object.assign(entry.lastCommit, {
- id: lastCommit.commit.id,
- url: lastCommit.commit_path,
- message: lastCommit.commit.message,
- author: lastCommit.commit.author_name,
- updatedAt: lastCommit.commit.authored_date,
+ [types.UPDATE_DELAY_VIEWER_CHANGE](state, delayViewerUpdated) {
+ Object.assign(state, {
+ delayViewerUpdated,
});
},
...projectMutations,
+ ...mergeRequestMutation,
...fileMutations,
...treeMutations,
...branchMutations,
diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js
index 04b9582c5bb..2972ba5e38e 100644
--- a/app/assets/javascripts/ide/stores/mutations/branch.js
+++ b/app/assets/javascripts/ide/stores/mutations/branch.js
@@ -7,16 +7,14 @@ export default {
});
},
[types.SET_BRANCH](state, { projectPath, branchName, branch }) {
- // Add client side properties
- Object.assign(branch, {
- treeId: `${projectPath}/${branchName}`,
- active: true,
- workingReference: '',
- });
-
Object.assign(state.projects[projectPath], {
branches: {
- [branchName]: branch,
+ [branchName]: {
+ ...branch,
+ treeId: `${projectPath}/${branchName}`,
+ active: true,
+ workingReference: '',
+ },
},
});
},
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 5f3655b0092..eeb14b5490c 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -1,74 +1,147 @@
import * as types from '../mutation_types';
-import { findIndexOfFile } from '../utils';
export default {
- [types.SET_FILE_ACTIVE](state, { file, active }) {
- Object.assign(file, {
+ [types.SET_FILE_ACTIVE](state, { path, active }) {
+ Object.assign(state.entries[path], {
active,
});
- Object.assign(state, {
- selectedFile: file,
- });
+ if (active && !state.entries[path].pending) {
+ Object.assign(state, {
+ openFiles: state.openFiles.map(f =>
+ Object.assign(f, { active: f.pending ? false : f.active }),
+ ),
+ });
+ }
},
- [types.TOGGLE_FILE_OPEN](state, file) {
- Object.assign(file, {
- opened: !file.opened,
+ [types.TOGGLE_FILE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
});
- if (file.opened) {
- state.openFiles.push(file);
+ if (state.entries[path].opened) {
+ Object.assign(state, {
+ openFiles: state.openFiles.filter(f => f.path !== path).concat(state.entries[path]),
+ });
} else {
- state.openFiles.splice(findIndexOfFile(state.openFiles, file), 1);
+ const file = state.entries[path];
+
+ Object.assign(state, {
+ openFiles: state.openFiles.filter(f => f.key !== file.key),
+ });
}
},
[types.SET_FILE_DATA](state, { data, file }) {
- Object.assign(file, {
+ Object.assign(state.entries[file.path], {
+ id: data.id,
blamePath: data.blame_path,
commitsPath: data.commits_path,
permalink: data.permalink,
rawPath: data.raw_path,
binary: data.binary,
- html: data.html,
renderError: data.render_error,
+ raw: null,
+ baseRaw: null,
+ html: data.html,
+ size: data.size,
});
},
[types.SET_FILE_RAW_DATA](state, { file, raw }) {
- Object.assign(file, {
+ Object.assign(state.entries[file.path], {
raw,
});
},
- [types.UPDATE_FILE_CONTENT](state, { file, content }) {
- const changed = content !== file.raw;
+ [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) {
+ Object.assign(state.entries[file.path], {
+ baseRaw,
+ });
+ },
+ [types.UPDATE_FILE_CONTENT](state, { path, content }) {
+ const changed = content !== state.entries[path].raw;
- Object.assign(file, {
+ Object.assign(state.entries[path], {
content,
changed,
});
},
[types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) {
- Object.assign(file, {
+ Object.assign(state.entries[file.path], {
fileLanguage,
});
},
[types.SET_FILE_EOL](state, { file, eol }) {
- Object.assign(file, {
+ Object.assign(state.entries[file.path], {
eol,
});
},
[types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
- Object.assign(file, {
+ Object.assign(state.entries[file.path], {
editorRow,
editorColumn,
});
},
- [types.DISCARD_FILE_CHANGES](state, file) {
- Object.assign(file, {
- content: '',
+ [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) {
+ Object.assign(state.entries[file.path], {
+ mrChange,
+ });
+ },
+ [types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
+ Object.assign(state.entries[file.path], {
+ viewMode,
+ });
+ },
+ [types.DISCARD_FILE_CHANGES](state, path) {
+ Object.assign(state.entries[path], {
+ content: state.entries[path].raw,
changed: false,
});
},
- [types.CREATE_TMP_FILE](state, { file, parent }) {
- parent.tree.push(file);
+ [types.ADD_FILE_TO_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.concat(state.entries[path]),
+ });
+ },
+ [types.REMOVE_FILE_FROM_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.filter(f => f.path !== path),
+ });
+ },
+ [types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
+ Object.assign(state.entries[file.path], {
+ changed,
+ });
+ },
+ [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) {
+ const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending);
+ let openFiles = state.openFiles.map(f =>
+ Object.assign(f, { active: f.path === file.path, opened: false }),
+ );
+
+ if (!pendingTab) {
+ const openFile = openFiles.find(f => f.path === file.path);
+
+ openFiles = openFiles.concat(openFile ? null : file).reduce((acc, f) => {
+ if (!f) return acc;
+
+ if (f.path === file.path) {
+ return acc.concat({
+ ...f,
+ active: true,
+ pending: true,
+ opened: true,
+ key: `${keyPrefix}-${f.key}`,
+ });
+ }
+
+ return acc.concat(f);
+ }, []);
+ }
+
+ Object.assign(state, { openFiles });
+ },
+ [types.REMOVE_PENDING_TAB](state, file) {
+ Object.assign(state, {
+ openFiles: state.openFiles.filter(f => f.key !== file.key),
+ });
},
};
diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js
new file mode 100644
index 00000000000..334819fe702
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js
@@ -0,0 +1,33 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_CURRENT_MERGE_REQUEST](state, currentMergeRequestId) {
+ Object.assign(state, {
+ currentMergeRequestId,
+ });
+ },
+ [types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) {
+ Object.assign(state.projects[projectPath], {
+ mergeRequests: {
+ [mergeRequestId]: {
+ ...mergeRequest,
+ active: true,
+ changes: [],
+ versions: [],
+ baseCommitSha: null,
+ },
+ },
+ });
+ },
+ [types.SET_MERGE_REQUEST_CHANGES](state, { projectPath, mergeRequestId, changes }) {
+ Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], {
+ changes,
+ });
+ },
+ [types.SET_MERGE_REQUEST_VERSIONS](state, { projectPath, mergeRequestId, versions }) {
+ Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], {
+ versions,
+ baseCommitSha: versions.length ? versions[0].base_commit_sha : null,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
index 2816562a919..284b39a2c72 100644
--- a/app/assets/javascripts/ide/stores/mutations/project.js
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -11,6 +11,7 @@ export default {
Object.assign(project, {
tree: [],
branches: {},
+ mergeRequests: {},
active: true,
});
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
index 4fe438ab465..7f7e470c9bb 100644
--- a/app/assets/javascripts/ide/stores/mutations/tree.js
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -1,9 +1,9 @@
import * as types from '../mutation_types';
export default {
- [types.TOGGLE_TREE_OPEN](state, tree) {
- Object.assign(tree, {
- opened: !tree.opened,
+ [types.TOGGLE_TREE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
});
},
[types.CREATE_TREE](state, { treePath }) {
@@ -11,18 +11,18 @@ export default {
trees: Object.assign({}, state.trees, {
[treePath]: {
tree: [],
+ loading: true,
},
}),
});
},
- [types.SET_DIRECTORY_DATA](state, { data, tree }) {
- Object.assign(tree, {
- tree: data,
- });
- },
- [types.SET_PARENT_TREE_URL](state, url) {
+ [types.SET_DIRECTORY_DATA](state, { data, treePath }) {
Object.assign(state, {
- parentTreeUrl: url,
+ trees: Object.assign(state.trees, {
+ [treePath]: {
+ tree: data,
+ },
+ }),
});
},
[types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
@@ -30,7 +30,9 @@ export default {
lastCommitPath: url,
});
},
- [types.CREATE_TMP_TREE](state, { parent, tmpEntry }) {
- parent.tree.push(tmpEntry);
+ [types.REMOVE_ALL_CHANGES_FILES](state) {
+ Object.assign(state, {
+ changedFiles: [],
+ });
},
};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 539e382830f..e5cc8814000 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -1,22 +1,20 @@
export default () => ({
- canCommit: false,
currentProjectId: '',
currentBranchId: '',
- currentBlobView: 'repo-editor',
- discardPopupOpen: false,
- editMode: true,
+ currentMergeRequestId: '',
+ changedFiles: [],
endpoints: {},
- isRoot: false,
- isInitialRoot: false,
+ lastCommitMsg: '',
lastCommitPath: '',
loading: false,
- onTopOfBranch: false,
openFiles: [],
- selectedFile: null,
- path: '',
parentTreeUrl: '',
trees: {},
projects: {},
leftPanelCollapsed: false,
- rightPanelCollapsed: true,
+ rightPanelCollapsed: false,
+ panelResizing: false,
+ entries: {},
+ viewer: 'editor',
+ delayViewerUpdated: false,
});
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 29e3ab5d040..05a019de54f 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,5 +1,7 @@
export const dataStructure = () => ({
id: '',
+ // Key will contain a mixture of ID and path
+ // it can also contain a prefix `pending-` for files opened in review mode
key: '',
type: '',
projectId: '',
@@ -7,9 +9,7 @@ export const dataStructure = () => ({
name: '',
url: '',
path: '',
- level: 0,
tempFile: false,
- icon: '',
tree: [],
loading: false,
opened: false,
@@ -23,7 +23,6 @@ export const dataStructure = () => ({
updatedAt: '',
author: '',
},
- tree_url: '',
blamePath: '',
commitsPath: '',
permalink: '',
@@ -39,9 +38,12 @@ export const dataStructure = () => ({
editorColumn: 1,
fileLanguage: '',
eol: '',
+ viewMode: 'edit',
+ previewMode: null,
+ size: 0,
});
-export const decorateData = (entity) => {
+export const decorateData = entity => {
const {
id,
projectId,
@@ -49,8 +51,6 @@ export const decorateData = (entity) => {
type,
url,
name,
- icon,
- tree_url,
path,
renderError,
content = '',
@@ -59,8 +59,10 @@ export const decorateData = (entity) => {
opened = false,
changed = false,
parentTreeUrl = '',
- level = 0,
base64 = false,
+ previewMode,
+ file_lock,
+ html,
} = entity;
return {
@@ -72,11 +74,8 @@ export const decorateData = (entity) => {
type,
name,
url,
- tree_url,
path,
- level,
tempFile,
- icon: `fa-${icon}`,
opened,
active,
parentTreeUrl,
@@ -84,92 +83,52 @@ export const decorateData = (entity) => {
renderError,
content,
base64,
+ previewMode,
+ file_lock,
+ html,
};
};
-/*
- Takes the multi-dimensional tree and returns a flattened array.
- This allows for the table to recursively render the table rows but keeps the data
- structure nested to make it easier to add new files/directories.
-*/
-export const treeList = (state, treeId) => {
- const baseTree = state.trees[treeId];
- if (baseTree) {
- const mapTree = arr => (!arr.tree || !arr.tree.length ?
- [] : _.map(arr.tree, a => [a, mapTree(a)]));
-
- return _.chain(baseTree.tree)
- .map(arr => [arr, mapTree(arr)])
- .flatten()
- .value();
- }
- return [];
-};
-
-export const getTree = state => (namespace, projectId, branch) => state.trees[`${namespace}/${projectId}/${branch}`];
-
-export const getTreeEntry = (store, treeId, path) => {
- const fileList = treeList(store.state, treeId);
- return fileList ? fileList.find(file => file.path === path) : null;
-};
-
-export const findEntry = (tree, type, name) => tree.find(
- f => f.type === type && f.name === name,
-);
+export const findEntry = (tree, type, name, prop = 'name') =>
+ tree.find(f => f.type === type && f[prop] === name);
export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path);
-export const setPageTitle = (title) => {
+export const setPageTitle = title => {
document.title = title;
};
-export const createTemp = ({
- projectId, branchId, name, path, type, level, changed, content, base64, url,
-}) => {
- const treePath = path ? `${path}/${name}` : name;
-
- return decorateData({
- id: new Date().getTime().toString(),
- projectId,
- branchId,
- name,
- type,
- tempFile: true,
- path: treePath,
- icon: type === 'tree' ? 'folder' : 'file-text-o',
- changed,
- content,
- parentTreeUrl: '',
- level,
- base64,
- renderError: base64,
- url,
- });
-};
+export const createCommitPayload = (branch, newBranch, state, rootState) => ({
+ branch,
+ commit_message: state.commitMessage,
+ actions: rootState.changedFiles.map(f => ({
+ action: f.tempFile ? 'create' : 'update',
+ file_path: f.path,
+ content: f.content,
+ encoding: f.base64 ? 'base64' : 'text',
+ })),
+ start_branch: newBranch ? rootState.currentBranchId : undefined,
+});
-export const createOrMergeEntry = ({ tree,
- projectId,
- branchId,
- entry,
- type,
- parentTreeUrl,
- level }) => {
- const found = findEntry(tree.tree || tree, type, entry.name);
+export const createNewMergeRequestUrl = (projectUrl, source, target) =>
+ `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`;
- if (found) {
- return Object.assign({}, found, {
- id: entry.id,
- url: entry.url,
- tempFile: false,
- });
+const sortTreesByTypeAndName = (a, b) => {
+ if (a.type === 'tree' && b.type === 'blob') {
+ return -1;
+ } else if (a.type === 'blob' && b.type === 'tree') {
+ return 1;
}
-
- return decorateData({
- ...entry,
- projectId,
- branchId,
- type,
- parentTreeUrl,
- level,
- });
+ if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
+ if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
+ return 0;
};
+
+export const sortTree = sortedTree =>
+ sortedTree
+ .map(entity =>
+ Object.assign(entity, {
+ tree: entity.tree.length ? sortTree(entity.tree) : [],
+ }),
+ )
+ .sort(sortTreesByTypeAndName);
diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
new file mode 100644
index 00000000000..a1673276900
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
@@ -0,0 +1,90 @@
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
+import { decorateData, sortTree } from '../utils';
+
+self.addEventListener('message', e => {
+ const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
+
+ const treeList = [];
+ let file;
+ const entries = data.reduce((acc, path) => {
+ const pathSplit = path.split('/');
+ const blobName = pathSplit.pop().trim();
+
+ if (pathSplit.length > 0) {
+ pathSplit.reduce((pathAcc, folderName) => {
+ const parentFolder = acc[pathAcc[pathAcc.length - 1]];
+ const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`;
+ const foundEntry = acc[folderPath];
+
+ if (!foundEntry) {
+ const tree = decorateData({
+ projectId,
+ branchId,
+ id: folderPath,
+ name: folderName,
+ path: folderPath,
+ url: `/${projectId}/tree/${branchId}/${folderPath}/`,
+ type: 'tree',
+ parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
+ tempFile,
+ changed: tempFile,
+ opened: tempFile,
+ });
+
+ Object.assign(acc, {
+ [folderPath]: tree,
+ });
+
+ if (parentFolder) {
+ parentFolder.tree.push(tree);
+ } else {
+ treeList.push(tree);
+ }
+
+ pathAcc.push(tree.path);
+ } else {
+ pathAcc.push(foundEntry.path);
+ }
+
+ return pathAcc;
+ }, []);
+ }
+
+ if (blobName !== '') {
+ const fileFolder = acc[pathSplit.join('/')];
+ file = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name: blobName,
+ path,
+ url: `/${projectId}/blob/${branchId}/${path}`,
+ type: 'blob',
+ parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
+ tempFile,
+ changed: tempFile,
+ content,
+ base64,
+ previewMode: viewerInformationForPath(blobName),
+ });
+
+ Object.assign(acc, {
+ [path]: file,
+ });
+
+ if (fileFolder) {
+ fileFolder.tree.push(file);
+ } else {
+ treeList.push(file);
+ }
+ }
+
+ return acc;
+ }, {});
+
+ self.postMessage({
+ entries,
+ treeList: sortTree(treeList),
+ file,
+ });
+});
diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js
index f3af92cf2b0..fab0255c378 100644
--- a/app/assets/javascripts/image_diff/image_diff.js
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import imageDiffHelper from './helpers/index';
import ImageBadge from './image_badge';
import { isImageLoaded } from '../lib/utils/image_utility';
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 1dc70872d92..b469e1e2adc 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -1,7 +1,15 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import { __, sprintf } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
+
class ImporterStatus {
- constructor(jobsUrl, importUrl) {
+ constructor({ jobsUrl, importUrl, ciCdOnly }) {
this.jobsUrl = jobsUrl;
this.importUrl = importUrl;
+ this.ciCdOnly = ciCdOnly;
this.initStatusPage();
this.setAutoUpdate();
}
@@ -9,29 +17,7 @@ class ImporterStatus {
initStatusPage() {
$('.js-add-to-import')
.off('click')
- .on('click', (event) => {
- const $btn = $(event.currentTarget);
- const $tr = $btn.closest('tr');
- const $targetField = $tr.find('.import-target');
- const $namespaceInput = $targetField.find('.js-select-namespace option:selected');
- const id = $tr.attr('id').replace('repo_', '');
- let targetNamespace;
- let newName;
- if ($namespaceInput.length > 0) {
- targetNamespace = $namespaceInput[0].innerHTML;
- newName = $targetField.find('#path').prop('value');
- $targetField.empty().append(`${targetNamespace}/${newName}`);
- }
- $btn.disable().addClass('is-loading');
-
- return $.post(this.importUrl, {
- repo_id: id,
- target_namespace: targetNamespace,
- new_name: newName,
- }, {
- dataType: 'script',
- });
- });
+ .on('click', this.addToImport.bind(this));
$('.js-import-all')
.off('click')
@@ -44,38 +30,97 @@ class ImporterStatus {
});
}
- setAutoUpdate() {
- return setInterval(() => $.get(this.jobsUrl, data => $.each(data, (i, job) => {
- const jobItem = $(`#project_${job.id}`);
- const statusField = jobItem.find('.job-status');
+ addToImport(event) {
+ const $btn = $(event.currentTarget);
+ const $tr = $btn.closest('tr');
+ const $targetField = $tr.find('.import-target');
+ const $namespaceInput = $targetField.find('.js-select-namespace option:selected');
+ const id = $tr.attr('id').replace('repo_', '');
+ let targetNamespace;
+ let newName;
+ if ($namespaceInput.length > 0) {
+ targetNamespace = $namespaceInput[0].innerHTML;
+ newName = $targetField.find('#path').prop('value');
+ $targetField.empty().append(`${targetNamespace}/${newName}`);
+ }
+ $btn.disable().addClass('is-loading');
+
+ return axios.post(this.importUrl, {
+ repo_id: id,
+ target_namespace: targetNamespace,
+ new_name: newName,
+ ci_cd_only: this.ciCdOnly,
+ })
+ .then(({ data }) => {
+ const job = $(`tr#repo_${id}`);
+ job.attr('id', `project_${data.id}`);
- const spinner = '<i class="fa fa-spinner fa-spin"></i>';
+ job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`);
+ $('table.import-jobs tbody').prepend(job);
- switch (job.import_status) {
- case 'finished':
- jobItem.removeClass('active').addClass('success');
- statusField.html('<span><i class="fa fa-check"></i> done</span>');
- break;
- case 'scheduled':
- statusField.html(`${spinner} scheduled`);
- break;
- case 'started':
- statusField.html(`${spinner} started`);
- break;
- default:
- statusField.html(job.import_status);
- break;
- }
- })), 4000);
+ job.addClass('active');
+ const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing');
+ job.find('.import-actions').html(sprintf(
+ _.escape(__('%{loadingIcon} Started')), {
+ loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape(connectingVerb)}"></i>`,
+ },
+ false,
+ ));
+ })
+ .catch(() => flash(__('An error occurred while importing project')));
+ }
+
+ autoUpdate() {
+ return axios.get(this.jobsUrl)
+ .then(({ data = [] }) => {
+ data.forEach((job) => {
+ const jobItem = $(`#project_${job.id}`);
+ const statusField = jobItem.find('.job-status');
+
+ const spinner = '<i class="fa fa-spinner fa-spin"></i>';
+
+ switch (job.import_status) {
+ case 'finished':
+ jobItem.removeClass('active').addClass('success');
+ statusField.html(`<span><i class="fa fa-check"></i> ${__('Done')}</span>`);
+ break;
+ case 'scheduled':
+ statusField.html(`${spinner} ${__('Scheduled')}`);
+ break;
+ case 'started':
+ statusField.html(`${spinner} ${__('Started')}`);
+ break;
+ case 'failed':
+ statusField.html(__('Failed'));
+ break;
+ default:
+ statusField.html(job.import_status);
+ break;
+ }
+ });
+ });
+ }
+
+ setAutoUpdate() {
+ setInterval(this.autoUpdate.bind(this), 4000);
}
}
// eslint-disable-next-line consistent-return
-export default function initImporterStatus() {
+function initImporterStatus() {
const importerStatus = document.querySelector('.js-importer-status');
if (importerStatus) {
const data = importerStatus.dataset;
- return new ImporterStatus(data.jobsImportPath, data.importPath);
+ return new ImporterStatus({
+ jobsUrl: data.jobsImportPath,
+ importUrl: data.importPath,
+ ciCdOnly: convertPermissionToBoolean(data.ciCdOnly),
+ });
}
}
+
+export {
+ initImporterStatus as default,
+ ImporterStatus,
+};
diff --git a/app/assets/javascripts/init_changes_dropdown.js b/app/assets/javascripts/init_changes_dropdown.js
index 1bab7965c19..09cca1dc7d9 100644
--- a/app/assets/javascripts/init_changes_dropdown.js
+++ b/app/assets/javascripts/init_changes_dropdown.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import stickyMonitor from './lib/utils/sticky';
export default (stickyTop) => {
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
index 5d4c1851fe5..e61b37a2d1f 100644
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -1,5 +1,6 @@
/* eslint-disable no-new */
-/* global MilestoneSelect */
+
+import MilestoneSelect from './milestone_select';
import LabelsSelect from './labels_select';
import IssuableContext from './issuable_context';
import Sidebar from './right_sidebar';
diff --git a/app/assets/javascripts/init_labels.js b/app/assets/javascripts/init_labels.js
new file mode 100644
index 00000000000..15da5d5cceb
--- /dev/null
+++ b/app/assets/javascripts/init_labels.js
@@ -0,0 +1,19 @@
+import $ from 'jquery';
+import LabelManager from './label_manager';
+import GroupLabelSubscription from './group_label_subscription';
+import ProjectLabelSubscription from './project_label_subscription';
+
+export default () => {
+ if ($('.prioritized-labels').length) {
+ new LabelManager(); // eslint-disable-line no-new
+ }
+ $('.label-subscription').each((i, el) => {
+ const $el = $(el);
+
+ if ($el.find('.dropdown-group-label').length) {
+ new GroupLabelSubscription($el); // eslint-disable-line no-new
+ } else {
+ new ProjectLabelSubscription($el); // eslint-disable-line no-new
+ }
+ });
+};
diff --git a/app/assets/javascripts/init_legacy_filters.js b/app/assets/javascripts/init_legacy_filters.js
index 2cbb70220d0..b6ff97d1279 100644
--- a/app/assets/javascripts/init_legacy_filters.js
+++ b/app/assets/javascripts/init_legacy_filters.js
@@ -1,9 +1,9 @@
/* eslint-disable no-new */
import LabelsSelect from './labels_select';
-/* global MilestoneSelect */
import subscriptionSelect from './subscription_select';
import UsersSelect from './users_select';
import issueStatusSelect from './issue_status_select';
+import MilestoneSelect from './milestone_select';
export default () => {
new UsersSelect();
diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js
deleted file mode 100644
index 10fe6bac0e8..00000000000
--- a/app/assets/javascripts/integrations/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/* 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
index 32415a8791f..741894b5e6c 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,12 +1,14 @@
-import Flash from '../flash';
+import $ from 'jquery';
+import axios from '../lib/utils/axios_utils';
+import flash from '../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');
+ this.canTestService = this.$form.data('canTest');
+ this.testEndPoint = this.$form.data('testUrl');
// Form Child Elements
this.$serviceToggle = this.$form.find('#service_active');
@@ -95,29 +97,26 @@ export default class IntegrationSettingsForm {
*/
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}`, 'alert', document, {
- 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);
- });
+
+ return axios.put(this.testEndPoint, formData)
+ .then(({ data }) => {
+ if (data.error) {
+ flash(`${data.message} ${data.service_response}`, 'alert', document, {
+ title: 'Save anyway',
+ clickHandler: (e) => {
+ e.preventDefault();
+ this.$form.submit();
+ },
+ });
+ } else {
+ this.$form.submit();
+ }
+
+ this.toggleSubmitBtnState(false);
+ })
+ .catch(() => {
+ flash('Something went wrong on our end.');
+ this.toggleSubmitBtnState(false);
+ });
}
}
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
index 2203a56315e..b2c2de9e5de 100644
--- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
let instanceCount = 0;
class AutoWidthDropdownSelect {
@@ -11,6 +13,14 @@ class AutoWidthDropdownSelect {
const dropdownClass = this.dropdownClass;
this.$selectElement.select2({
dropdownCssClass: dropdownClass,
+ ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
+ });
+
+ return this;
+ }
+
+ static selectOptions(dropdownClass) {
+ return {
dropdownCss() {
let resultantWidth = 'auto';
const $dropdown = $(`.${dropdownClass}`);
@@ -29,9 +39,7 @@ class AutoWidthDropdownSelect {
maxWidth: offsetParentWidth,
};
},
- });
-
- return this;
+ };
}
}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index b124fafec70..e003fb1d127 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,5 +1,8 @@
/* 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 */
+
+import $ from 'jquery';
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
import Flash from './flash';
export default {
@@ -22,15 +25,9 @@ export default {
},
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());
+ axios[this.form.attr('method')](this.form.attr('action'), this.getFormDataAsObject())
+ .then(() => window.location.reload())
+ .catch(() => this.onFormSubmitFailure());
},
onFormSubmitFailure() {
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index bf77b93b643..2307c8e0d85 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -1,8 +1,8 @@
/* eslint-disable class-methods-use-this, no-new */
-/* global MilestoneSelect */
+import $ from 'jquery';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
-import './milestone_select';
+import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select';
import subscriptionSelect from './subscription_select';
import LabelsSelect from './labels_select';
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index da99394ff90..7470d634b99 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Cookies from 'js-cookie';
import bp from './breakpoints';
import UsersSelect from './users_select';
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 57dcaa0e1ac..bb8b3d91e40 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,11 +1,13 @@
/* eslint-disable func-names, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, 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 */
+import $ from 'jquery';
import Pikaday from 'pikaday';
import Autosave from './autosave';
import UsersSelect from './users_select';
import GfmAutoComplete from './gfm_auto_complete';
import ZenMode from './zen_mode';
+import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
export default class IssuableForm {
@@ -46,6 +48,12 @@ export default class IssuableForm {
});
calendar.setDate(parsePikadayDate($issuableDueDate.val()));
}
+
+ this.$targetBranchSelect = $('.js-target-branch-select', this.form);
+
+ if (this.$targetBranchSelect.length) {
+ this.initTargetBranchDropdown();
+ }
}
initAutosave() {
@@ -104,4 +112,37 @@ export default class IssuableForm {
addWip() {
this.titleField.val(`WIP: ${(this.titleField.val())}`);
}
+
+ initTargetBranchDropdown() {
+ this.$targetBranchSelect.select2({
+ ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
+ ajax: {
+ url: this.$targetBranchSelect.data('endpoint'),
+ dataType: 'JSON',
+ quietMillis: 250,
+ data(search) {
+ return {
+ search,
+ };
+ },
+ results(data) {
+ return {
+ // `data` keys are translated so we can't just access them with a string based key
+ results: data[Object.keys(data)[0]].map(name => ({
+ id: name,
+ text: name,
+ })),
+ };
+ },
+ },
+ initSelection(el, callback) {
+ const val = el.val();
+
+ callback({
+ id: val,
+ text: val,
+ });
+ },
+ });
+ }
}
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index c3e0acdff66..06ec4546164 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,3 +1,7 @@
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+import { __ } from './locale';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -20,23 +24,24 @@ export default class IssuableIndex {
}
static resetIncomingEmailToken() {
- $('.incoming-email-token-reset').on('click', (e) => {
+ const $resetToken = $('.incoming-email-token-reset');
+
+ $resetToken.on('click', (e) => {
e.preventDefault();
- $.ajax({
- type: 'PUT',
- url: $('.incoming-email-token-reset').attr('href'),
- dataType: 'json',
- success(response) {
- $('#issuable_email').val(response.new_address).focus();
- },
- beforeSend() {
- $('.incoming-email-token-reset').text('resetting...');
- },
- complete() {
- $('.incoming-email-token-reset').text('reset it');
- },
- });
+ $resetToken.text('resetting...');
+
+ axios.put($resetToken.attr('href'))
+ .then(({ data }) => {
+ $('#issuable_email').val(data.new_address).focus();
+
+ $resetToken.text('reset it');
+ })
+ .catch(() => {
+ flash(__('There was an error when reseting email token.'));
+
+ $resetToken.text('reset it');
+ });
});
}
}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 411c820cc43..5113ac6775d 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,7 +1,9 @@
/* 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 */
-import 'vendor/jquery.waitforimages';
+
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
import { addDelimiter } from './lib/utils/text_utility';
-import Flash from './flash';
+import flash from './flash';
import TaskList from './task_list';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
import IssuablesHelper from './helpers/issuables_helper';
@@ -24,6 +26,51 @@ export default class Issue {
if (Issue.createMrDropdownWrap) {
this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap);
}
+
+ // Listen to state changes in the Vue app
+ document.addEventListener('issuable_vue_app:change', (event) => {
+ this.updateTopState(event.detail.isClosed, event.detail.data);
+ });
+ }
+
+ /**
+ * This method updates the top area of the issue.
+ *
+ * Once the issue state changes, either through a click on the top area (jquery)
+ * or a click on the bottom area (Vue) we need to update the top area.
+ *
+ * @param {Boolean} isClosed
+ * @param {Array} data
+ * @param {String} issueFailMessage
+ */
+ updateTopState(isClosed, data, issueFailMessage = 'Unable to update this issue at this time.') {
+ if ('id' in data) {
+ const isClosedBadge = $('div.status-box-issue-closed');
+ const isOpenBadge = $('div.status-box-open');
+ const projectIssuesCounter = $('.issue_counter');
+
+ isClosedBadge.toggleClass('hidden', !isClosed);
+ isOpenBadge.toggleClass('hidden', isClosed);
+
+ $(document).trigger('issuable:change', isClosed);
+ this.toggleCloseReopenButton(isClosed);
+
+ let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''));
+ numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
+ projectIssuesCounter.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 {
+ flash(issueFailMessage);
+ }
}
initIssueBtnEventListeners() {
@@ -42,41 +89,12 @@ export default class Issue {
this.disableCloseReopenButton($button);
url = $button.attr('href');
- return $.ajax({
- type: 'PUT',
- url: url
- })
- .fail(() => new Flash(issueFailMessage))
- .done((data) => {
- const isClosedBadge = $('div.status-box-issue-closed');
- const isOpenBadge = $('div.status-box-open');
- const projectIssuesCounter = $('.issue_counter');
-
- if ('id' in data) {
- const isClosed = $button.hasClass('btn-close');
- isClosedBadge.toggleClass('hidden', !isClosed);
- isOpenBadge.toggleClass('hidden', isClosed);
-
- $(document).trigger('issuable:change', isClosed);
- this.toggleCloseReopenButton(isClosed);
-
- let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''));
- numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
- projectIssuesCounter.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);
- }
+ return axios.put(url)
+ .then(({ data }) => {
+ const isClosed = $button.hasClass('btn-close');
+ this.updateTopState(isClosed, data);
})
+ .catch(() => flash(issueFailMessage))
.then(() => {
this.disableCloseReopenButton($button, false);
});
@@ -115,24 +133,22 @@ export default class Issue {
static initMergeRequests() {
var $container;
$container = $('#merge-requests');
- return $.getJSON($container.data('url')).fail(function() {
- return new Flash('Failed to load referenced merge requests');
- }).done(function(data) {
- if ('html' in data) {
- return $container.html(data.html);
- }
- });
+ return axios.get($container.data('url'))
+ .then(({ data }) => {
+ if ('html' in data) {
+ $container.html(data.html);
+ }
+ }).catch(() => flash('Failed to load referenced merge requests'));
}
static initRelatedBranches() {
var $container;
$container = $('#related-branches');
- return $.getJSON($container.data('url')).fail(function() {
- return new Flash('Failed to load related branches');
- }).done(function(data) {
- if ('html' in data) {
- return $container.html(data.html);
- }
- });
+ return axios.get($container.data('url'))
+ .then(({ data }) => {
+ if ('html' in data) {
+ $container.html(data.html);
+ }
+ }).catch(() => flash('Failed to load related branches'));
}
}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 952f49d522e..e87a8ed7fea 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,308 +1,324 @@
<script>
-import Visibility from 'visibilityjs';
-import { visitUrl } from '../../lib/utils/url_utility';
-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 recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
+ import Visibility from 'visibilityjs';
+ import { visitUrl } from '../../lib/utils/url_utility';
+ 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 recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
-export default {
- props: {
- endpoint: {
- required: true,
- type: String,
+ export default {
+ components: {
+ descriptionComponent,
+ titleComponent,
+ editedComponent,
+ formComponent,
},
- updateEndpoint: {
- required: true,
- type: String,
- },
- canUpdate: {
- required: true,
- type: Boolean,
- },
- canDestroy: {
- required: true,
- type: Boolean,
- },
- showInlineEditButton: {
- type: Boolean,
- required: false,
- default: true,
- },
- showDeleteButton: {
- type: Boolean,
- required: false,
- default: true,
- },
- enableAutocomplete: {
- type: Boolean,
- required: false,
- default: true,
- },
- 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: () => [],
- },
- markdownPreviewPath: {
- type: String,
- required: true,
- },
- markdownDocsPath: {
- type: String,
- required: true,
- },
- projectPath: {
- type: String,
- required: true,
- },
- projectNamespace: {
- type: String,
- required: true,
- },
- issuableType: {
- type: String,
- required: false,
- default: 'issue',
- },
- canAttachFile: {
- type: Boolean,
- required: false,
- default: true,
+ mixins: [
+ recaptchaModalImplementor,
+ ],
+ props: {
+ endpoint: {
+ required: true,
+ type: String,
+ },
+ updateEndpoint: {
+ required: true,
+ type: String,
+ },
+ canUpdate: {
+ required: true,
+ type: Boolean,
+ },
+ canDestroy: {
+ required: true,
+ type: Boolean,
+ },
+ showInlineEditButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showDeleteButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ 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: () => [],
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ canAttachFile: {
+ type: Boolean,
+ required: false,
+ default: 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,
- });
+ 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;
+ return {
+ store,
+ state: store.state,
+ showForm: false,
+ };
},
- hasUpdated() {
- return !!this.state.updatedAt;
+ computed: {
+ formState() {
+ return this.store.formState;
+ },
+ hasUpdated() {
+ return !!this.state.updatedAt;
+ },
+ issueChanged() {
+ const descriptionChanged =
+ this.initialDescriptionText !== this.store.formState.description;
+ const titleChanged =
+ this.initialTitleText !== this.store.formState.title;
+ return descriptionChanged || titleChanged;
+ },
},
- },
- components: {
- descriptionComponent,
- titleComponent,
- editedComponent,
- formComponent,
- },
-
- mixins: [
- recaptchaModalImplementor,
- ],
+ created() {
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getData',
+ successCallback: res => this.store.updateState(res.data),
+ errorCallback(err) {
+ throw new Error(err);
+ },
+ });
- methods: {
- openForm() {
- if (!this.showForm) {
- this.showForm = true;
- this.store.setFormState({
- title: this.state.titleText,
- description: this.state.descriptionText,
- lockedWarningVisible: false,
- updateLoading: false,
- });
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
}
- },
- closeForm() {
- this.showForm = false;
- },
- updateIssuable() {
- this.service.updateIssuable(this.store.formState)
- .then(res => res.json())
- .then(data => this.checkForSpam(data))
- .then((data) => {
- if (location.pathname !== data.web_url) {
- visitUrl(data.web_url);
- }
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
- return this.service.getData();
- })
- .then(res => res.json())
- .then((data) => {
- this.store.updateState(data);
- eventHub.$emit('close.form');
- })
- .catch((error) => {
- if (error && error.name === 'SpamError') {
- this.openRecaptcha();
- } else {
- eventHub.$emit('close.form');
- window.Flash(`Error updating ${this.issuableType}`);
- }
- });
+ window.addEventListener('beforeunload', this.handleBeforeUnloadEvent);
+
+ 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);
+ window.removeEventListener('beforeunload', this.handleBeforeUnloadEvent);
+ },
+ methods: {
+ handleBeforeUnloadEvent(e) {
+ const event = e;
+ if (this.showForm && this.issueChanged) {
+ event.returnValue = 'Are you sure you want to lose your issue information?';
+ }
+ return undefined;
+ },
- closeRecaptchaModal() {
- this.store.setFormState({
- updateLoading: false,
- });
+ openForm() {
+ if (!this.showForm) {
+ this.showForm = true;
+ this.store.setFormState({
+ title: this.state.titleText,
+ description: this.state.descriptionText,
+ lockedWarningVisible: false,
+ updateLoading: false,
+ });
+ }
+ },
+ closeForm() {
+ this.showForm = false;
+ },
- this.closeRecaptcha();
- },
+ updateIssuable() {
+ return this.service.updateIssuable(this.store.formState)
+ .then(res => res.data)
+ .then(data => this.checkForSpam(data))
+ .then((data) => {
+ if (location.pathname !== data.web_url) {
+ visitUrl(data.web_url);
+ }
- deleteIssuable() {
- this.service.deleteIssuable()
- .then(res => res.json())
- .then((data) => {
- // Stop the poll so we don't get 404's with the issuable not existing
- this.poll.stop();
+ return this.service.getData();
+ })
+ .then(res => res.data)
+ .then((data) => {
+ this.store.updateState(data);
+ eventHub.$emit('close.form');
+ })
+ .catch((error) => {
+ if (error && error.name === 'SpamError') {
+ this.openRecaptcha();
+ } else {
+ eventHub.$emit('close.form');
+ window.Flash(`Error updating ${this.issuableType}`);
+ }
+ });
+ },
- visitUrl(data.web_url);
- })
- .catch(() => {
- eventHub.$emit('close.form');
- window.Flash(`Error deleting ${this.issuableType}`);
+ closeRecaptchaModal() {
+ this.store.setFormState({
+ updateLoading: false,
});
- },
- },
- 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();
- }
+ this.closeRecaptcha();
+ },
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
+ deleteIssuable() {
+ this.service.deleteIssuable()
+ .then(res => res.data)
+ .then((data) => {
+ // Stop the poll so we don't get 404's with the issuable not existing
+ 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);
- },
-};
+ visitUrl(data.web_url);
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ window.Flash(`Error deleting ${this.issuableType}`);
+ });
+ },
+ },
+ };
</script>
<template>
-<div>
- <div v-if="canUpdate && showForm">
- <form-component
- :form-state="formState"
- :can-destroy="canDestroy"
- :issuable-templates="issuableTemplates"
- :markdown-docs-path="markdownDocsPath"
- :markdown-preview-path="markdownPreviewPath"
- :project-path="projectPath"
- :project-namespace="projectNamespace"
- :show-delete-button="showDeleteButton"
- :can-attach-file="canAttachFile"
- :enable-autocomplete="enableAutocomplete"
- />
+ <div>
+ <div v-if="canUpdate && showForm">
+ <form-component
+ :form-state="formState"
+ :can-destroy="canDestroy"
+ :issuable-templates="issuableTemplates"
+ :markdown-docs-path="markdownDocsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace"
+ :show-delete-button="showDeleteButton"
+ :can-attach-file="canAttachFile"
+ :enable-autocomplete="enableAutocomplete"
+ />
- <recaptcha-modal
- v-show="showRecaptcha"
- :html="recaptchaHTML"
- @close="closeRecaptchaModal"
- />
- </div>
- <div v-else>
- <title-component
- :issuable-ref="issuableRef"
- :can-update="canUpdate"
- :title-html="state.titleHtml"
- :title-text="state.titleText"
- :show-inline-edit-button="showInlineEditButton"
- />
- <description-component
- v-if="state.descriptionHtml"
- :can-update="canUpdate"
- :description-html="state.descriptionHtml"
- :description-text="state.descriptionText"
- :updated-at="state.updatedAt"
- :task-status="state.taskStatus"
- :issuable-type="issuableType"
- :update-url="updateEndpoint"
- />
- <edited-component
- v-if="hasUpdated"
- :updated-at="state.updatedAt"
- :updated-by-name="state.updatedByName"
- :updated-by-path="state.updatedByPath"
- />
+ <recaptcha-modal
+ v-show="showRecaptcha"
+ :html="recaptchaHTML"
+ @close="closeRecaptchaModal"
+ />
+ </div>
+ <div v-else>
+ <title-component
+ :issuable-ref="issuableRef"
+ :can-update="canUpdate"
+ :title-html="state.titleHtml"
+ :title-text="state.titleText"
+ :show-inline-edit-button="showInlineEditButton"
+ />
+ <description-component
+ v-if="state.descriptionHtml"
+ :can-update="canUpdate"
+ :description-html="state.descriptionHtml"
+ :description-text="state.descriptionText"
+ :updated-at="state.updatedAt"
+ :task-status="state.taskStatus"
+ :issuable-type="issuableType"
+ :update-url="updateEndpoint"
+ />
+ <edited-component
+ v-if="hasUpdated"
+ :updated-at="state.updatedAt"
+ :updated-by-name="state.updatedByName"
+ :updated-by-path="state.updatedByPath"
+ />
+ </div>
</div>
-</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index c3f2bf130bb..ae577e04a56 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -1,4 +1,5 @@
<script>
+ import $ from 'jquery';
import animateMixin from '../mixins/animate';
import TaskList from '../../task_list';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
@@ -56,7 +57,10 @@
this.updateTaskStatusText();
},
},
-
+ mounted() {
+ this.renderGFM();
+ this.updateTaskStatusText();
+ },
methods: {
renderGFM() {
$(this.$refs['gfm-content']).renderGFM();
@@ -75,6 +79,7 @@
taskListUpdateSuccess(data) {
try {
this.checkForSpam(data);
+ this.closeRecaptcha();
} catch (error) {
if (error && error.name === 'SpamError') this.openRecaptcha();
}
@@ -88,17 +93,17 @@
if (taskRegexMatches) {
$tasks.text(this.taskStatus);
- $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`);
+ $tasksShort.text(
+ `${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ?
+ 's' :
+ ''}`,
+ );
} else {
$tasks.text('');
$tasksShort.text('');
}
},
},
- mounted() {
- this.renderGFM();
- this.updateTaskStatusText();
- },
};
</script>
@@ -108,7 +113,8 @@
class="description"
:class="{
'js-task-list-container': canUpdate
- }">
+ }"
+ >
<div
class="wiki"
:class="{
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
index 992b7064c13..01097b5b35e 100644
--- a/app/assets/javascripts/issue_show/components/edited.vue
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -1,33 +1,33 @@
<script>
-import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+ import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
-export default {
- props: {
- updatedAt: {
- type: String,
- required: false,
- default: '',
+ export default {
+ components: {
+ timeAgoTooltip,
},
- updatedByName: {
- type: String,
- required: false,
- default: '',
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
- updatedByPath: {
- type: String,
- required: false,
- default: '',
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
},
- },
- components: {
- timeAgoTooltip,
- },
- computed: {
- hasUpdatedBy() {
- return this.updatedByName && this.updatedByPath;
- },
- },
-};
+ };
</script>
<template>
@@ -48,7 +48,7 @@ export default {
class="author_link"
:href="updatedByPath"
>
- <span>{{updatedByName}}</span>
+ <span>{{ updatedByName }}</span>
</a>
</span>
</small>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 4e577546551..d9fa2764d65 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -3,6 +3,9 @@
import markdownField from '../../../vue_shared/components/markdown/field.vue';
export default {
+ components: {
+ markdownField,
+ },
mixins: [updateMixin],
props: {
formState: {
@@ -28,9 +31,6 @@
default: true,
},
},
- components: {
- markdownField,
- },
mounted() {
this.$refs.textarea.focus();
},
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index 1ad0e59287e..7db0488e306 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -1,4 +1,5 @@
<script>
+ import $ from 'jquery';
import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
export default {
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 0fa19022336..779705e19ac 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -6,6 +6,13 @@
import descriptionTemplate from './fields/description_template.vue';
export default {
+ components: {
+ lockedWarning,
+ titleField,
+ descriptionField,
+ descriptionTemplate,
+ editActions,
+ },
props: {
canDestroy: {
type: Boolean,
@@ -52,13 +59,6 @@
default: true,
},
},
- components: {
- lockedWarning,
- titleField,
- descriptionField,
- descriptionTemplate,
- editActions,
- },
computed: {
hasIssuableTemplates() {
return this.issuableTemplates.length;
@@ -78,16 +78,19 @@
:form-state="formState"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
- :project-namespace="projectNamespace" />
+ :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" />
+ :issuable-templates="issuableTemplates"
+ />
</div>
</div>
<description-field
@@ -100,6 +103,7 @@
<edit-actions
:form-state="formState"
:can-destroy="canDestroy"
- :show-delete-button="showDeleteButton" />
+ :show-delete-button="showDeleteButton"
+ />
</form>
</template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index b7e6eadd440..aec890a2ff6 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -5,14 +5,10 @@
import { spriteIcon } from '../../lib/utils/common_utils';
export default {
- mixins: [animateMixin],
- data() {
- return {
- preAnimation: false,
- pulseAnimation: false,
- titleEl: document.querySelector('title'),
- };
+ directives: {
+ tooltip,
},
+ mixins: [animateMixin],
props: {
issuableRef: {
type: String,
@@ -37,8 +33,17 @@
default: false,
},
},
- directives: {
- tooltip,
+ data() {
+ return {
+ preAnimation: false,
+ pulseAnimation: false,
+ titleEl: document.querySelector('title'),
+ };
+ },
+ computed: {
+ pencilIcon() {
+ return spriteIcon('pencil', 'link-highlight');
+ },
},
watch: {
titleHtml() {
@@ -46,11 +51,6 @@
this.animateChange();
},
},
- computed: {
- pencilIcon() {
- return spriteIcon('pencil', 'link-highlight');
- },
- },
methods: {
setPageTitle() {
const currentPageTitleScope = this.titleEl.innerText.split('·');
@@ -85,7 +85,7 @@
data-placement="bottom"
data-container="body"
@click="edit"
- >
+ >
</button>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index 6f0fd0b1768..9546eb22c27 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,29 +1,20 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import axios from '../../lib/utils/axios_utils';
export default class Service {
constructor(endpoint) {
- this.endpoint = endpoint;
-
- this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
- realtimeChanges: {
- method: 'GET',
- url: `${this.endpoint}/realtime_changes`,
- },
- });
+ this.endpoint = `${endpoint}.json`;
+ this.realtimeEndpoint = `${endpoint}/realtime_changes`;
}
getData() {
- return this.resource.realtimeChanges();
+ return axios.get(this.realtimeEndpoint);
}
deleteIssuable() {
- return this.resource.delete();
+ return axios.delete(this.endpoint);
}
updateIssuable(data) {
- return this.resource.update(data);
+ return axios.put(this.endpoint, data);
}
}
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index 03546f61d1f..c14803c80e7 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -1,6 +1,8 @@
+import $ from 'jquery';
+
export default function issueStatusSelect() {
$('.js-issue-status').each((i, el) => {
- const fieldName = $(el).data('field-name');
+ const fieldName = $(el).data('fieldName');
return $(el).glDropdown({
selectable: true,
fieldName,
diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js
index 198a7823381..ace45e9dd29 100644
--- a/app/assets/javascripts/job.js
+++ b/app/assets/javascripts/job.js
@@ -1,14 +1,16 @@
+import $ from 'jquery';
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
import { visitUrl } from './lib/utils/url_utility';
import bp from './breakpoints';
-import { bytesToKiB } from './lib/utils/number_utils';
+import { numberToHumanSize } from './lib/utils/number_utils';
import { setCiStatusFavicon } from './lib/utils/common_utils';
-import { timeFor } from './lib/utils/datetime_utility';
export default class Job {
constructor(options) {
this.timeout = null;
this.state = null;
+ this.fetchingStatusFavicon = false;
this.options = options || $('.js-build-options').data();
this.pagePath = this.options.pagePath;
@@ -71,7 +73,6 @@ export default class Job {
.off('resize.build')
.on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
- this.updateArtifactRemoveDate();
this.initAffixTopArea();
this.getBuildTrace();
@@ -96,14 +97,15 @@ export default class Job {
// eslint-disable-next-line class-methods-use-this
canScroll() {
- return this.$document.height() > this.$window.height();
+ return $(document).height() > $(window).height();
}
toggleScroll() {
- const currentPosition = this.$document.scrollTop();
- const scrollHeight = this.$document.height();
+ const $document = $(document);
+ const currentPosition = $document.scrollTop();
+ const scrollHeight = $document.height();
- const windowHeight = this.$window.height();
+ const windowHeight = $(window).height();
if (this.canScroll()) {
if (currentPosition > 0 &&
(scrollHeight - currentPosition !== windowHeight)) {
@@ -127,18 +129,22 @@ export default class Job {
this.toggleDisableButton(this.$scrollBottomBtn, true);
}
}
-
+ // eslint-disable-next-line class-methods-use-this
isScrolledToBottom() {
- const currentPosition = this.$document.scrollTop();
- const scrollHeight = this.$document.height();
+ const $document = $(document);
+
+ const currentPosition = $document.scrollTop();
+ const scrollHeight = $document.height();
+
+ const windowHeight = $(window).height();
- const windowHeight = this.$window.height();
return scrollHeight - currentPosition === windowHeight;
}
// eslint-disable-next-line class-methods-use-this
scrollDown() {
- this.$document.scrollTop(this.$document.height());
+ const $document = $(document);
+ $document.scrollTop($document.height());
}
scrollToBottom() {
@@ -148,7 +154,7 @@ export default class Job {
}
scrollToTop() {
- this.$document.scrollTop(0);
+ $(document).scrollTop(0);
this.hasBeenScrolled = true;
this.toggleScroll();
}
@@ -168,12 +174,23 @@ export default class Job {
}
getBuildTrace() {
- return $.ajax({
- url: `${this.pagePath}/trace.json`,
- data: { state: this.state },
+ return axios.get(`${this.pagePath}/trace.json`, {
+ params: { state: this.state },
})
- .done((log) => {
- setCiStatusFavicon(`${this.pagePath}/status.json`);
+ .then((res) => {
+ const log = res.data;
+
+ if (!this.fetchingStatusFavicon) {
+ this.fetchingStatusFavicon = true;
+
+ setCiStatusFavicon(`${this.pagePath}/status.json`)
+ .then(() => {
+ this.fetchingStatusFavicon = false;
+ })
+ .catch(() => {
+ this.fetchingStatusFavicon = false;
+ });
+ }
if (log.state) {
this.state = log.state;
@@ -193,7 +210,7 @@ export default class Job {
// we need to show a message warning the user about that.
if (this.logBytes < log.total) {
// size is in bytes, we need to calculate KiB
- const size = bytesToKiB(this.logBytes);
+ const size = numberToHumanSize(this.logBytes);
$('.js-truncated-info-size').html(`${size}`);
this.$truncatedInfo.removeClass('hidden');
} else {
@@ -201,7 +218,7 @@ export default class Job {
}
this.isLogComplete = log.complete;
- if (!log.complete) {
+ if (log.complete === false) {
this.timeout = setTimeout(() => {
this.getBuildTrace();
}, 4000);
@@ -214,7 +231,7 @@ export default class Job {
visitUrl(this.pagePath);
}
})
- .fail(() => {
+ .catch(() => {
this.$buildRefreshAnimation.remove();
})
.then(() => {
@@ -256,16 +273,7 @@ export default class Job {
sidebarOnClick() {
if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
}
- // eslint-disable-next-line class-methods-use-this, consistent-return
- updateArtifactRemoveDate() {
- const $date = $('.js-artifacts-remove');
- if ($date.length) {
- const date = $date.text();
- return $date.text(
- timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3'))),
- );
- }
- }
+
// eslint-disable-next-line class-methods-use-this
populateJobs(stage) {
$('.build-job').hide();
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
index 6d671845f8e..357bc9aab17 100644
--- a/app/assets/javascripts/jobs/components/header.vue
+++ b/app/assets/javascripts/jobs/components/header.vue
@@ -3,7 +3,11 @@
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
- name: 'jobHeaderSection',
+ name: 'JobHeaderSection',
+ components: {
+ ciHeader,
+ loadingIcon,
+ },
props: {
job: {
type: Object,
@@ -14,10 +18,6 @@
required: true,
},
},
- components: {
- ciHeader,
- loadingIcon,
- },
data() {
return {
actions: this.getActions(),
@@ -30,6 +30,18 @@
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length;
},
+ /**
+ * When job has not started the key will be `false`
+ * When job started the key will be a string with a date.
+ */
+ jobStarted() {
+ return !this.job.started === false;
+ },
+ },
+ watch: {
+ job() {
+ this.actions = this.getActions();
+ },
},
methods: {
getActions() {
@@ -46,11 +58,6 @@
return actions;
},
},
- watch: {
- job() {
- this.actions = this.getActions();
- },
- },
};
</script>
<template>
@@ -63,11 +70,13 @@
:time="job.created_at"
:user="job.user"
:actions="actions"
- :hasSidebarButton="true"
- />
+ :has-sidebar-button="true"
+ :should-render-triggered-label="jobStarted"
+ />
<loading-icon
v-if="isLoading"
size="2"
- />
+ class="prepend-top-default append-bottom-default"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
index ab2bcd728a8..dfe87d89a39 100644
--- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
@@ -11,11 +11,19 @@
type: String,
required: true,
},
+ helpUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasTitle() {
return this.title.length > 0;
},
+ hasHelpURL() {
+ return this.helpUrl.length > 0;
+ },
},
};
</script>
@@ -23,9 +31,26 @@
<p class="build-detail-row">
<span
v-if="hasTitle"
- class="build-light-text">
- {{title}}:
+ class="build-light-text"
+ >
+ {{ title }}:
+ </span>
+ {{ value }}
+
+ <span
+ v-if="hasHelpURL"
+ class="help-button pull-right"
+ >
+ <a
+ :href="helpUrl"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ ></i>
+ </a>
</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
index d0145fed396..af47056d98f 100644
--- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
@@ -6,6 +6,13 @@
export default {
name: 'SidebarDetailsBlock',
+ components: {
+ detailRow,
+ loadingIcon,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
props: {
job: {
type: Object,
@@ -15,13 +22,11 @@
type: Boolean,
required: true,
},
- },
- mixins: [
- timeagoMixin,
- ],
- components: {
- detailRow,
- loadingIcon,
+ runnerHelpUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
shouldRenderContent() {
@@ -39,6 +44,21 @@
runnerId() {
return `#${this.job.runner.id}`;
},
+ hasTimeout() {
+ return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
+ },
+ timeout() {
+ if (this.job.metadata == null) {
+ return '';
+ }
+
+ let t = this.job.metadata.timeout_human_readable;
+ if (this.job.metadata.timeout_source !== '') {
+ t += ` (from ${this.job.metadata.timeout_source})`;
+ }
+
+ return t;
+ },
renderBlock() {
return this.job.merge_request ||
this.job.duration ||
@@ -58,11 +78,13 @@
<template v-if="shouldRenderContent">
<div
class="block retry-link"
- v-if="job.retry_path || job.new_issue_path">
+ 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">
+ :href="job.new_issue_path"
+ >
New issue
</a>
<a
@@ -70,20 +92,21 @@
class="js-retry-job btn btn-inverted-secondary"
:href="job.retry_path"
data-method="post"
- rel="nofollow">
+ 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">
+ v-if="job.merge_request"
+ >
+ <span class="build-light-text">
Merge Request:
</span>
<a :href="job.merge_request.path">
- !{{job.merge_request.iid}}
+ !{{ job.merge_request.iid }}
</a>
</p>
@@ -92,49 +115,56 @@
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-timeout"
+ v-if="hasTimeout"
+ title="Timeout"
+ :help-url="runnerHelpUrl"
+ :value="timeout"
+ />
<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">
+ v-if="job.tags.length"
+ >
+ <span class="build-light-text">
Tags:
</span>
<span
- v-for="tag in job.tags"
- key="tag"
+ v-for="(tag, i) in job.tags"
+ :key="i"
class="label label-primary">
- {{tag}}
+ {{ tag }}
</span>
</p>
@@ -146,7 +176,8 @@
class="js-cancel-job btn btn-sm btn-default"
:href="job.cancel_path"
data-method="post"
- rel="nofollow">
+ rel="nofollow"
+ >
Cancel
</a>
</div>
@@ -156,6 +187,6 @@
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
index baaf5641200..656676ead91 100644
--- a/app/assets/javascripts/jobs/job_details_bundle.js
+++ b/app/assets/javascripts/jobs/job_details_bundle.js
@@ -3,7 +3,7 @@ import JobMediator from './job_details_mediator';
import jobHeader from './components/header.vue';
import detailsBlock from './components/sidebar_details_block.vue';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
const dataset = document.getElementById('js-job-details-vue').dataset;
const mediator = new JobMediator({ endpoint: dataset.endpoint });
@@ -13,14 +13,14 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line no-new
new Vue({
el: '#js-build-header-vue',
+ components: {
+ jobHeader,
+ },
data() {
return {
mediator,
};
},
- components: {
- jobHeader,
- },
mounted() {
this.mediator.initBuildClass();
},
@@ -38,21 +38,22 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line
new Vue({
el: '#js-details-block-vue',
+ components: {
+ detailsBlock,
+ },
data() {
return {
mediator,
};
},
- components: {
- detailsBlock,
- },
render(createElement) {
return createElement('details-block', {
props: {
isLoading: this.mediator.state.isLoading,
job: this.mediator.store.state.job,
+ runnerHelpUrl: dataset.runnerHelpUrl,
},
});
},
});
-});
+};
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index c929dc98c10..e230dbbd4ac 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -1,7 +1,10 @@
/* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */
-/* global Sortable */
-import Flash from './flash';
+import $ from 'jquery';
+import Sortable from 'vendor/Sortable';
+
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
export default class LabelManager {
constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
@@ -50,11 +53,12 @@ export default class LabelManager {
if (persistState == null) {
persistState = true;
}
- let xhr;
const _this = this;
const url = $label.find('.js-toggle-priority').data('url');
let $target = this.prioritizedLabels;
let $from = this.otherLabels;
+ const rollbackLabelPosition = this.rollbackLabelPosition.bind(this, $label, action);
+
if (action === 'remove') {
$target = this.otherLabels;
$from = this.prioritizedLabels;
@@ -71,40 +75,34 @@ export default class LabelManager {
return;
}
if (action === 'remove') {
- xhr = $.ajax({
- url,
- type: 'DELETE'
- });
+ axios.delete(url)
+ .catch(rollbackLabelPosition);
+
// Restore empty message
if (!$from.find('li').length) {
$from.find('.empty-message').removeClass('hidden');
}
} else {
- xhr = this.savePrioritySort($label, action);
+ this.savePrioritySort($label, action)
+ .catch(rollbackLabelPosition);
}
- return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action));
}
onPrioritySortUpdate() {
- const xhr = this.savePrioritySort();
- return xhr.fail(function() {
- return new Flash(this.errorMessage, 'alert');
- });
+ this.savePrioritySort()
+ .catch(() => flash(this.errorMessage));
}
savePrioritySort() {
- return $.post({
- url: this.prioritizedLabels.data('url'),
- data: {
- label_ids: this.getSortedLabelsIds()
- }
+ return axios.post(this.prioritizedLabels.data('url'), {
+ label_ids: this.getSortedLabelsIds(),
});
}
rollbackLabelPosition($label, originalAction) {
const action = originalAction === 'remove' ? 'add' : 'remove';
this.toggleLabelPriority($label, action, false);
- return new Flash(this.errorMessage, 'alert');
+ flash(this.errorMessage);
}
getSortedLabelsIds() {
diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js
index 7aab13ed9c6..d85ae851706 100644
--- a/app/assets/javascripts/labels.js
+++ b/app/assets/javascripts/labels.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default class Labels {
constructor() {
this.setSuggestedColor = this.setSuggestedColor.bind(this);
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index f7a1c9f1e40..d0050abb8e9 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,10 +1,16 @@
/* 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 'jquery';
import _ from 'underscore';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import DropdownUtils from './filtered_search/dropdown_utils';
import CreateLabelDropdown from './create_label';
+import flash from './flash';
+import ModalStore from './boards/stores/modal_store';
export default class LabelsSelect {
constructor(els, options = {}) {
@@ -18,23 +24,23 @@ export default class LabelsSelect {
}
$els.each(function(i, dropdown) {
- var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
+ var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
$dropdown = $(dropdown);
$dropdownContainer = $dropdown.closest('.labels-filter');
$toggleText = $dropdown.find('.dropdown-toggle-text');
- namespacePath = $dropdown.data('namespace-path');
- projectPath = $dropdown.data('project-path');
+ namespacePath = $dropdown.data('namespacePath');
+ projectPath = $dropdown.data('projectPath');
labelUrl = $dropdown.data('labels');
issueUpdateURL = $dropdown.data('issueUpdate');
selectedLabel = $dropdown.data('selected');
if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(',');
}
- showNo = $dropdown.data('show-no');
- showAny = $dropdown.data('show-any');
+ showNo = $dropdown.data('showNo');
+ showAny = $dropdown.data('showAny');
showMenuAbove = $dropdown.data('showMenuAbove');
- defaultLabel = $dropdown.data('default-label');
- abilityName = $dropdown.data('ability-name');
+ defaultLabel = $dropdown.data('defaultLabel');
+ abilityName = $dropdown.data('abilityName');
$selectbox = $dropdown.closest('.selectbox');
$block = $selectbox.closest('.block');
$form = $dropdown.closest('form, .js-issuable-update');
@@ -42,21 +48,14 @@ export default class LabelsSelect {
$sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
$value = $block.find('.value');
$loading = $block.find('.block-loading').fadeOut();
- fieldName = $dropdown.data('field-name');
+ fieldName = $dropdown.data('fieldName');
useId = $dropdown.is('.js-issuable-form-dropdown, .js-filter-bulk-update, .js-label-sidebar-dropdown');
propertyName = useId ? 'id' : 'title';
initialSelected = $selectbox
- .find('input[name="' + $dropdown.data('field-name') + '"]')
+ .find('input[name="' + $dropdown.data('fieldName') + '"]')
.map(function () {
return this.value;
}).get();
- if (issueUpdateURL != null) {
- issueURLSplit = issueUpdateURL.split('/');
- }
- if (issueUpdateURL) {
- labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
- labelNoneHTMLTemplate = '<span class="no-value">None</span>';
- }
const handleClick = options.handleClick;
$sidebarLabelTooltip.tooltip();
@@ -82,99 +81,99 @@ export default class LabelsSelect {
}
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
- return $.ajax({
- type: 'PUT',
- url: issueUpdateURL,
- dataType: 'JSON',
- data: data
- }).done(function(data) {
- var labelCount, template, labelTooltipTitle, labelTitles;
- $loading.fadeOut();
- $dropdown.trigger('loaded.gl.dropdown');
- $selectbox.hide();
- data.issueURLSplit = issueURLSplit;
- labelCount = 0;
- if (data.labels.length) {
- template = labelHTMLTemplate(data);
- labelCount = data.labels.length;
- }
- else {
- template = labelNoneHTMLTemplate;
- }
- $value.removeAttr('style').html(template);
- $sidebarCollapsedValue.text(labelCount);
+ axios.put(issueUpdateURL, data)
+ .then(({ data }) => {
+ var labelCount, template, labelTooltipTitle, labelTitles;
+ $loading.fadeOut();
+ $dropdown.trigger('loaded.gl.dropdown');
+ $selectbox.hide();
+ data.issueUpdateURL = issueUpdateURL;
+ labelCount = 0;
+ if (data.labels.length && issueUpdateURL) {
+ template = LabelsSelect.getLabelTemplate({
+ labels: data.labels,
+ issueUpdateURL,
+ });
+ labelCount = data.labels.length;
+ }
+ else {
+ template = '<span class="no-value">None</span>';
+ }
+ $value.removeAttr('style').html(template);
+ $sidebarCollapsedValue.text(labelCount);
- if (data.labels.length) {
- labelTitles = data.labels.map(function(label) {
- return label.title;
- });
+ if (data.labels.length) {
+ labelTitles = data.labels.map(function(label) {
+ return label.title;
+ });
- if (labelTitles.length > 5) {
- labelTitles = labelTitles.slice(0, 5);
- labelTitles.push('and ' + (data.labels.length - 5) + ' more');
- }
+ if (labelTitles.length > 5) {
+ labelTitles = labelTitles.slice(0, 5);
+ labelTitles.push('and ' + (data.labels.length - 5) + ' more');
+ }
- labelTooltipTitle = labelTitles.join(', ');
- }
- else {
- labelTooltipTitle = '';
- $sidebarLabelTooltip.tooltip('destroy');
- }
+ labelTooltipTitle = labelTitles.join(', ');
+ }
+ else {
+ labelTooltipTitle = '';
+ $sidebarLabelTooltip.tooltip('destroy');
+ }
- $sidebarLabelTooltip
- .attr('title', labelTooltipTitle)
- .tooltip('fixTitle');
+ $sidebarLabelTooltip
+ .attr('title', labelTooltipTitle)
+ .tooltip('fixTitle');
- $('.has-tooltip', $value).tooltip({
- container: 'body'
- });
- });
+ $('.has-tooltip', $value).tooltip({
+ container: 'body'
+ });
+ })
+ .catch(() => flash(__('Error saving label update.')));
};
$dropdown.glDropdown({
showMenuAbove: showMenuAbove,
data: function(term, callback) {
- return $.ajax({
- url: labelUrl
- }).done(function(data) {
- data = _.chain(data).groupBy(function(label) {
- return label.title;
- }).map(function(label) {
- var color;
- color = _.map(label, function(dup) {
- return dup.color;
- });
- return {
- id: label[0].id,
- title: label[0].title,
- color: color,
- duplicate: color.length > 1
- };
- }).value();
- if ($dropdown.hasClass('js-extra-options')) {
- var extraData = [];
- if (showNo) {
- extraData.unshift({
- id: 0,
- title: 'No Label'
- });
- }
- if (showAny) {
- extraData.unshift({
- isAny: true,
- title: 'Any Label'
+ axios.get(labelUrl)
+ .then((res) => {
+ let data = _.chain(res.data).groupBy(function(label) {
+ return label.title;
+ }).map(function(label) {
+ var color;
+ color = _.map(label, function(dup) {
+ return dup.color;
});
+ return {
+ id: label[0].id,
+ title: label[0].title,
+ color: color,
+ duplicate: color.length > 1
+ };
+ }).value();
+ if ($dropdown.hasClass('js-extra-options')) {
+ var extraData = [];
+ if (showNo) {
+ extraData.unshift({
+ id: 0,
+ title: 'No Label'
+ });
+ }
+ if (showAny) {
+ extraData.unshift({
+ isAny: true,
+ title: 'Any Label'
+ });
+ }
+ if (extraData.length) {
+ extraData.push('divider');
+ data = extraData.concat(data);
+ }
}
- if (extraData.length) {
- extraData.push('divider');
- data = extraData.concat(data);
- }
- }
- callback(data);
- if (showMenuAbove) {
- $dropdown.data('glDropdown').positionMenuAbove();
- }
- });
+ callback(data);
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
+ }
+ })
+ .catch(() => flash(__('Error fetching labels.')));
},
renderRow: function(label, instance) {
var $a, $li, color, colorEl, indeterminate, removesAll, selectedClass, spacing, i, marked, dropdownName, dropdownValue;
@@ -213,7 +212,7 @@ export default class LabelsSelect {
}
}
if (label.duplicate) {
- color = gl.DropdownUtils.duplicateLabelColor(label.color);
+ color = DropdownUtils.duplicateLabelColor(label.color);
}
else {
if (label.color != null) {
@@ -231,7 +230,7 @@ export default class LabelsSelect {
selectedClass.push('label-item');
$a.attr('data-label-id', label.id);
}
- $a.addClass(selectedClass.join(' ')).html(colorEl + " " + label.title);
+ $a.addClass(selectedClass.join(' ')).html(`${colorEl} ${_.escape(label.title)}`);
// Return generated html
return $li.html($a).prop('outerHTML');
},
@@ -242,10 +241,16 @@ export default class LabelsSelect {
filterable: true,
selected: $dropdown.data('selected') || [],
toggleLabel: function(selected, el) {
+ var $dropdownParent = $dropdown.parent();
+ var $dropdownInputField = $dropdownParent.find('.dropdown-input-field');
var isSelected = el !== null ? el.hasClass('is-active') : false;
var title = selected.title;
var selectedLabels = this.selected;
+ if ($dropdownInputField.length && $dropdownInputField.val().length) {
+ $dropdownParent.find('.dropdown-input-clear').trigger('click');
+ }
+
if (selected.id === 0) {
this.selected = [];
return 'No Label';
@@ -268,7 +273,7 @@ export default class LabelsSelect {
return defaultLabel;
}
},
- fieldName: $dropdown.data('field-name'),
+ fieldName: $dropdown.data('fieldName'),
id: function(label) {
if (label.id <= 0) return label.title;
@@ -316,9 +321,9 @@ export default class LabelsSelect {
},
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
- clicked: function(options) {
- const { $el, e, isMarking } = options;
- const label = options.selectedObj;
+ clicked: function (clickEvent) {
+ const { $el, e, isMarking } = clickEvent;
+ const label = clickEvent.selectedObj;
var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => {
@@ -346,7 +351,7 @@ export default class LabelsSelect {
}
if ($dropdown.closest('.add-issues-modal').length) {
- boardsModel = gl.issueBoards.ModalStore.store.filter;
+ boardsModel = ModalStore.store.filter;
}
if (boardsModel) {
@@ -412,6 +417,26 @@ export default class LabelsSelect {
this.bindEvents();
}
+ static getLabelTemplate(tplData) {
+ // We could use ES6 template string here
+ // and properly indent markup for readability
+ // but that also introduces unintended white-space
+ // so best approach is to use traditional way of
+ // concatenation
+ // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
+ const tpl = _.template([
+ '<% _.each(labels, function(label){ %>',
+ '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
+ '<span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
+ '<%- label.title %>',
+ '</span>',
+ '</a>',
+ '<% }); %>',
+ ].join(''));
+
+ return tpl(tplData);
+ }
+
bindEvents() {
return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue);
}
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index ab3cc29146a..e3177188772 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -1,10 +1,11 @@
+import $ from 'jquery';
import ContextualSidebar from './contextual_sidebar';
import initFlyOutNav from './fly_out_nav';
function hideEndFade($scrollingTabs) {
$scrollingTabs.each(function scrollTabsLoop() {
const $this = $(this);
- $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth'));
+ $this.siblings('.fade-right').toggleClass('scrolling', Math.round($this.width()) < $this.prop('scrollWidth'));
});
}
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
index 629d8f44e18..616d8952ada 100644
--- a/app/assets/javascripts/lib/utils/ajax_cache.js
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -1,3 +1,4 @@
+import axios from './axios_utils';
import Cache from './cache';
class AjaxCache extends Cache {
@@ -18,25 +19,18 @@ class AjaxCache extends Cache {
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;
- });
+ pendingRequest = axios.get(endpoint)
+ .then(({ data }) => {
+ this.internalStorage[endpoint] = data;
+ delete this.pendingRequests[endpoint];
+ })
+ .catch((e) => {
+ const error = new Error(`${endpoint}: ${e.message}`);
+ error.textStatus = e.message;
+
+ delete this.pendingRequests[endpoint];
+ throw error;
+ });
this.pendingRequests[endpoint] = pendingRequest;
}
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index 7aeeca3b283..792871e2ecf 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -2,6 +2,8 @@ import axios from 'axios';
import csrf from './csrf';
axios.defaults.headers.common[csrf.headerKey] = csrf.token;
+// Used by Rails to check if it is a valid XHR request
+axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Maintain a global counter for active requests
// see: spec/support/wait_for_requests.rb
@@ -17,6 +19,18 @@ axios.interceptors.response.use((config) => {
window.activeVueResources -= 1;
return config;
+}, (e) => {
+ window.activeVueResources -= 1;
+
+ return Promise.reject(e);
});
export default axios;
+
+/**
+ * @return The adapter that axios uses for dispatching requests. This may be overwritten in tests.
+ *
+ * @see https://github.com/axios/axios/tree/master/lib/adapters
+ * @see https://github.com/ctimmerm/axios-mock-adapter/blob/v1.12.0/src/index.js#L39
+ */
+export const getDefaultAdapter = () => axios.defaults.adapter;
diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
index 0bf2ba6acc2..3873f4528ce 100644
--- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
+++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
/**
* Linked Tabs
*
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index b5328c77b25..9ff2042475b 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -1,4 +1,8 @@
+import $ from 'jquery';
+import Cookies from 'js-cookie';
+import axios from './axios_utils';
import { getLocationHash } from './url_utility';
+import { convertToCamelCase } from './text_utility';
export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index];
@@ -20,23 +24,24 @@ export const getGroupSlug = () => {
return null;
};
-export const isInIssuePage = () => {
- const page = getPagePath(1);
- const action = getPagePath(2);
+export const checkPageAndAction = (page, action) => {
+ const pagePath = getPagePath(1);
+ const actionPath = getPagePath(2);
- return page === 'issues' && action === 'show';
+ return pagePath === page && actionPath === action;
};
-export const ajaxGet = url => $.ajax({
- type: 'GET',
- url,
- dataType: 'script',
-});
-
-export const ajaxPost = (url, data) => $.ajax({
- type: 'POST',
- url,
- data,
+export const isInIssuePage = () => checkPageAndAction('issues', 'show');
+export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
+export const isInEpicPage = () => checkPageAndAction('epics', 'show');
+export const isInNoteablePage = () => isInIssuePage() || isInMRPage();
+export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions');
+
+export const ajaxGet = url => axios.get(url, {
+ params: { format: 'js' },
+ responseType: 'text',
+}).then(({ data }) => {
+ $.globalEval(data);
});
export const rstrip = (val) => {
@@ -136,7 +141,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
-export const scrollToElement = ($el) => {
+export const scrollToElement = (element) => {
+ let $el = element;
+ if (!(element instanceof $)) {
+ $el = $(element);
+ }
const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
@@ -232,7 +241,7 @@ export const nodeMatchesSelector = (node, selector) => {
export const normalizeHeaders = (headers) => {
const upperCaseHeaders = {};
- Object.keys(headers).forEach((e) => {
+ Object.keys(headers || {}).forEach((e) => {
upperCaseHeaders[e.toUpperCase()] = headers[e];
});
@@ -294,6 +303,14 @@ export const parseQueryStringIntoObject = (query = '') => {
}, {});
};
+/**
+ * Converts object with key-value pairs
+ * into query-param string
+ *
+ * @param {Object} params
+ */
+export const objectToQueryString = (params = {}) => Object.keys(params).map(param => `${param}=${params[param]}`).join('&');
+
export const buildUrlWithCurrentLocation = param => (param ? `${window.location.pathname}${param}` : window.location.pathname);
/**
@@ -382,22 +399,16 @@ export const resetFavicon = () => {
}
};
-export const setCiStatusFavicon = (pageUrl) => {
- $.ajax({
- url: pageUrl,
- dataType: 'json',
- success: (data) => {
+export const setCiStatusFavicon = pageUrl =>
+ axios.get(pageUrl)
+ .then(({ data }) => {
if (data && data.favicon) {
setFavicon(data.favicon);
} else {
resetFavicon();
}
- },
- error: () => {
- resetFavicon();
- },
- });
-};
+ })
+ .catch(resetFavicon);
export const spriteIcon = (icon, className = '') => {
const classAttribute = className.length > 0 ? `class="${className}"` : '';
@@ -405,8 +416,38 @@ export const spriteIcon = (icon, className = '') => {
return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
};
+/**
+ * This method takes in object with snake_case property names
+ * and returns new object with camelCase property names
+ *
+ * Reasoning for this method is to ensure consistent property
+ * naming conventions across JS code.
+ */
+export const convertObjectPropsToCamelCase = (obj = {}) => {
+ if (obj === null) {
+ return {};
+ }
+
+ return Object.keys(obj).reduce((acc, prop) => {
+ const result = acc;
+
+ result[convertToCamelCase(prop)] = obj[prop];
+ return acc;
+ }, {});
+};
+
export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
+export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
+ // Click a .js-select-on-focus field, select the contents
+ // Prevent a mouseup event from deselecting the input
+ $(selector).on('focusin', function selectOnFocusCallback() {
+ $(this).select().one('mouseup', (e) => {
+ e.preventDefault();
+ });
+ });
+};
+
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
@@ -417,7 +458,6 @@ window.gl.utils = {
getGroupSlug,
isInIssuePage,
ajaxGet,
- ajaxPost,
rstrip,
updateTooltipTitle,
disableButtonIfEmptyField,
diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js
index 0bdb547d31a..ca9828c4682 100644
--- a/app/assets/javascripts/lib/utils/csrf.js
+++ b/app/assets/javascripts/lib/utils/csrf.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
/*
This module provides easy access to the CSRF token and caches
it for re-use. It also exposes some values commonly used in relation
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 1fa6715180e..c3d94d63c13 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import timeago from 'timeago.js';
import dateFormat from 'vendor/date.format';
import { pluralize } from './text_utility';
@@ -10,6 +11,20 @@ window.timeago = timeago;
window.dateFormat = dateFormat;
/**
+ * Returns i18n month names array.
+ * If `abbreviated` is provided, returns abbreviated
+ * name.
+ *
+ * @param {Boolean} abbreviated
+ */
+const getMonthNames = (abbreviated) => {
+ if (abbreviated) {
+ return [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')];
+ }
+ return [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')];
+};
+
+/**
* Given a date object returns the day of the week in English
* @param {date} date
* @returns {String}
@@ -143,7 +158,6 @@ export const getDayDifference = (a, b) => {
* @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);
@@ -158,7 +172,7 @@ export function timeIntervalInWords(intervalInSeconds) {
return text;
}
-export function dateInWords(date, abbreviated = false) {
+export function dateInWords(date, abbreviated = false, hideYear = false) {
if (!date) return date;
const month = date.getMonth();
@@ -169,9 +183,115 @@ export function dateInWords(date, abbreviated = false) {
const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month];
+ if (hideYear) {
+ return `${monthName} ${date.getDate()}`;
+ }
+
return `${monthName} ${date.getDate()}, ${year}`;
}
+/**
+ * Returns month name based on provided date.
+ *
+ * @param {Date} date
+ * @param {Boolean} abbreviated
+ */
+export const monthInWords = (date, abbreviated = false) => {
+ if (!date) {
+ return '';
+ }
+
+ return getMonthNames(abbreviated)[date.getMonth()];
+};
+
+/**
+ * Returns number of days in a month for provided date.
+ * courtesy: https://stacko(verflow.com/a/1185804/414749
+ *
+ * @param {Date} date
+ */
+export const totalDaysInMonth = (date) => {
+ if (!date) {
+ return 0;
+ }
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
+};
+
+/**
+ * Returns list of Dates referring to Sundays of the month
+ * based on provided date
+ *
+ * @param {Date} date
+ */
+export const getSundays = (date) => {
+ if (!date) {
+ return [];
+ }
+
+ const daysToSunday = ['Saturday', 'Friday', 'Thursday', 'Wednesday', 'Tuesday', 'Monday', 'Sunday'];
+
+ const month = date.getMonth();
+ const year = date.getFullYear();
+ const sundays = [];
+ const dateOfMonth = new Date(year, month, 1);
+
+ while (dateOfMonth.getMonth() === month) {
+ const dayName = getDayName(dateOfMonth);
+ if (dayName === 'Sunday') {
+ sundays.push(new Date(dateOfMonth.getTime()));
+ }
+
+ const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1;
+ dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday);
+ }
+
+ return sundays;
+};
+
+/**
+ * Returns list of Dates representing a timeframe of Months from month of provided date (inclusive)
+ * up to provided length
+ *
+ * For eg;
+ * If current month is January 2018 and `length` provided is `6`
+ * Then this method will return list of Date objects as follows;
+ *
+ * [ October 2017, November 2017, December 2017, January 2018, February 2018, March 2018 ]
+ *
+ * If current month is March 2018 and `length` provided is `3`
+ * Then this method will return list of Date objects as follows;
+ *
+ * [ February 2018, March 2018, April 2018 ]
+ *
+ * @param {Number} length
+ * @param {Date} date
+ */
+export const getTimeframeWindow = (length, date) => {
+ if (!length) {
+ return [];
+ }
+
+ const currentDate = date instanceof Date ? date : new Date();
+ const currentMonthIndex = Math.floor(length / 2);
+ const timeframe = [];
+
+ // Move date object backward to the first month of timeframe
+ currentDate.setDate(1);
+ currentDate.setMonth(currentDate.getMonth() - currentMonthIndex);
+
+ // Iterate and update date for the size of length
+ // and push date reference to timeframe list
+ for (let i = 0; i < length; i += 1) {
+ timeframe.push(new Date(currentDate.getTime()));
+ currentDate.setMonth(currentDate.getMonth() + 1);
+ }
+
+ // Change date of last timeframe item to last date of the month
+ timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1]));
+
+ return timeframe;
+};
+
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index de65ea15a60..914de9de940 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -1,7 +1,12 @@
-/* eslint-disable import/prefer-default-export */
+import $ from 'jquery';
+import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils';
+
+const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
export const addClassIfElementExists = (element, className) => {
if (element) {
element.classList.add(className);
}
};
+
+export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions();
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 625e53ee9de..bb151929431 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -6,4 +6,6 @@ export default {
ABORTED: 0,
NO_CONTENT: 204,
OK: 200,
+ MULTIPLE_CHOICES: 300,
+ BAD_REQUEST: 400,
};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 2dc9cf0cc29..5a16adea4dc 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,26 +1,25 @@
/* eslint-disable import/prefer-default-export, 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 */
+import $ from 'jquery';
+import { insertText } from '~/lib/utils/common_utils';
-const textUtils = {};
-
-textUtils.selectedText = function(text, textarea) {
+function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
-};
+}
-textUtils.lineBefore = function(text, textarea) {
+function lineBefore(text, textarea) {
var split;
split = text.substring(0, textarea.selectionStart).trim().split('\n');
return split[split.length - 1];
-};
+}
-textUtils.lineAfter = function(text, textarea) {
+function lineAfter(text, textarea) {
return text.substring(textarea.selectionEnd).trim().split('\n')[0];
-};
+}
-textUtils.blockTagText = function(text, textArea, blockTag, selected) {
- var lineAfter, lineBefore;
- lineBefore = this.lineBefore(text, textArea);
- lineAfter = this.lineAfter(text, textArea);
- if (lineBefore === blockTag && lineAfter === blockTag) {
+function blockTagText(text, textArea, blockTag, selected) {
+ const before = lineBefore(text, textArea);
+ const after = lineAfter(text, textArea);
+ if (before === blockTag && after === blockTag) {
// To remove the block tag we have to select the line before & after
if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
@@ -30,10 +29,30 @@ textUtils.blockTagText = function(text, textArea, blockTag, selected) {
} else {
return blockTag + "\n" + selected + "\n" + blockTag;
}
-};
+}
+
+function moveCursor(textArea, tag, wrapped, removedLastNewLine) {
+ var pos;
+ if (!textArea.setSelectionRange) {
+ return;
+ }
+ if (textArea.selectionStart === textArea.selectionEnd) {
+ if (wrapped) {
+ pos = textArea.selectionStart - tag.length;
+ } else {
+ pos = textArea.selectionStart;
+ }
+
+ if (removedLastNewLine) {
+ pos -= 1;
+ }
+
+ return textArea.setSelectionRange(pos, pos);
+ }
+}
-textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
- var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
+export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap) {
+ var textToInsert, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
removedLastNewLine = false;
removedFirstNewLine = false;
currentLineEmpty = false;
@@ -65,9 +84,9 @@ textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
- insertText = this.blockTagText(text, textArea, blockTag, selected);
+ textToInsert = blockTagText(text, textArea, blockTag, selected);
} else {
- insertText = selectedSplit.map(function(val) {
+ textToInsert = selectedSplit.map(function(val) {
if (val.indexOf(tag) === 0) {
return "" + (val.replace(tag, ''));
} else {
@@ -76,78 +95,42 @@ textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
}).join('\n');
}
} else {
- insertText = "" + startChar + tag + selected + (wrap ? tag : ' ');
+ textToInsert = "" + startChar + tag + selected + (wrap ? tag : ' ');
}
if (removedFirstNewLine) {
- insertText = '\n' + insertText;
+ textToInsert = '\n' + textToInsert;
}
if (removedLastNewLine) {
- insertText += '\n';
- }
-
- if (document.queryCommandSupported('insertText')) {
- inserted = document.execCommand('insertText', false, insertText);
- }
- if (!inserted) {
- try {
- document.execCommand("ms-beginUndoUnit");
- } catch (error) {}
- textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText);
- try {
- document.execCommand("ms-endUndoUnit");
- } catch (error) {}
+ textToInsert += '\n';
}
- return this.moveCursor(textArea, tag, wrap, removedLastNewLine);
-};
-textUtils.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) {
- var pos;
- if (!textArea.setSelectionRange) {
- return;
- }
- if (textArea.selectionStart === textArea.selectionEnd) {
- if (wrapped) {
- pos = textArea.selectionStart - tag.length;
- } else {
- pos = textArea.selectionStart;
- }
-
- if (removedLastNewLine) {
- pos -= 1;
- }
-
- return textArea.setSelectionRange(pos, pos);
- }
-};
+ insertText(textArea, textToInsert);
+ return moveCursor(textArea, tag, wrap, removedLastNewLine);
+}
-textUtils.updateText = function(textArea, tag, blockTag, wrap) {
+function updateText(textArea, tag, blockTag, wrap) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
- selected = this.selectedText(text, textArea);
+ selected = selectedText(text, textArea);
$textArea.focus();
- return this.insertText(textArea, text, tag, blockTag, selected, wrap);
-};
+ return insertMarkdownText(textArea, text, tag, blockTag, selected, wrap);
+}
-textUtils.init = function(form) {
- var self;
- self = this;
+function replaceRange(s, start, end, substitute) {
+ return s.substring(0, start) + substitute + s.substring(end);
+}
+
+export function addMarkdownListeners(form) {
return $('.js-md', form).off('click').on('click', function() {
- var $this;
- $this = $(this);
- return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
+ const $this = $(this);
+ return updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend'));
});
-};
+}
-textUtils.removeListeners = function(form) {
+export function removeMarkdownListeners(form) {
return $('.js-md', form).off('click');
-};
-
-textUtils.replaceRange = function(s, start, end, substitute) {
- return s.substring(0, start) + substitute + s.substring(end);
-};
-
-export default textUtils;
+}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 9280b7f150c..b54ecd2d543 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -7,7 +7,8 @@
* @param {String} text
* @returns {String}
*/
-export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
+export const addDelimiter = text =>
+ (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
/**
* Returns '99+' for numbers bigger than 99.
@@ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count);
* @param {String} string
* @requires {String}
*/
-export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
+export const humanize = string =>
+ string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
/**
* Adds an 's' to the end of the string when count is bigger than 0
@@ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase();
* @param {Number} maxLength
* @returns {String}
*/
-export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`;
+export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`;
/**
* Capitalizes first character
@@ -64,3 +66,31 @@ export const truncate = (string, maxLength) => `${string.substr(0, (maxLength -
export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`;
}
+
+/**
+ * Replaces all html tags from a string with the given replacement.
+ *
+ * @param {String} string
+ * @param {*} replace
+ * @returns {String}
+ */
+export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, replace);
+
+/**
+ * Converts snake_case string to camelCase
+ *
+ * @param {*} string
+ */
+export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
+
+/**
+ * Converts a sentence to lower case from the second word onwards
+ * e.g. Hello World => Hello world
+ *
+ * @param {*} string
+ */
+export const convertToSentenceCase = string => {
+ const splitWord = string.split(' ').map((word, index) => (index > 0 ? word.toLowerCase() : word));
+
+ return splitWord.join(' ');
+};
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index f1ee9c8f2e5..dd17544b656 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -18,7 +18,7 @@ export function getParameterValues(sParam) {
// @param {String} url
export function mergeUrlParams(params, url) {
let newUrl = Object.keys(params).reduce((acc, paramName) => {
- const paramValue = params[paramName];
+ const paramValue = encodeURIComponent(params[paramName]);
const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`);
if (paramValue === null) {
@@ -51,7 +51,7 @@ export function removeParams(params) {
const url = document.createElement('a');
url.href = window.location.href;
- params.forEach((param) => {
+ params.forEach(param => {
url.search = removeParamQueryString(url.search, param);
});
@@ -83,3 +83,11 @@ export function refreshCurrentPage() {
export function redirectTo(url) {
return window.location.assign(url);
}
+
+export function webIDEUrl(route = undefined) {
+ let returnUrl = `${gon.relative_url_root}/-/ide/`;
+ if (route) {
+ returnUrl += `project${route}`;
+ }
+ return returnUrl;
+}
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
index 88f8a622c00..b01ec6b81a3 100644
--- a/app/assets/javascripts/lib/utils/users_cache.js
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -8,16 +8,16 @@ class UsersCache extends Cache {
}
return Api.users('', { username })
- .then((users) => {
- if (!users.length) {
+ .then(({ data }) => {
+ if (!data.length) {
throw new Error(`User "${username}" could not be found!`);
}
- if (users.length > 1) {
+ if (data.length > 1) {
throw new Error(`Expected username "${username}" to be unique!`);
}
- const user = users[0];
+ const user = data[0];
this.internalStorage[username] = user;
return user;
});
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index fbd381d8ff7..f2323f57455 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -1,5 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-underscore-dangle, no-param-reassign, prefer-template, quotes, comma-dangle, prefer-arrow-callback, consistent-return, one-var, one-var-declaration-per-line, no-else-return, max-len */
+import $ from 'jquery';
+
// LineHighlighter
//
// Handles single- and multi-line selection and highlight for blob views.
@@ -83,7 +85,7 @@ LineHighlighter.prototype.clickHandler = function(event) {
var current, lineNumber, range;
event.preventDefault();
this.clearHighlight();
- lineNumber = $(event.target).closest('a').data('line-number');
+ lineNumber = $(event.target).closest('a').data('lineNumber');
current = this.hashToRange(this._hash);
if (!(current[0] && event.shiftKey)) {
// If there's no current selection, or there is but Shift wasn't held,
diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js
index 3688a57937e..403e216e70f 100644
--- a/app/assets/javascripts/logo.js
+++ b/app/assets/javascripts/logo.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default function initLogoAnimation() {
window.addEventListener('beforeunload', () => {
$('.tanuki-logo').addClass('animate');
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 59bfa482bb0..2c80baba10b 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -1,33 +1,17 @@
-/* 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 ConfirmDangerModal */
+/* eslint-disable import/first */
+/* global $ */
import jQuery from 'jquery';
-import _ from 'underscore';
import Cookies from 'js-cookie';
-import Dropzone from 'dropzone';
-import Sortable from 'vendor/Sortable';
import svg4everybody from 'svg4everybody';
-// libraries with import side-effects
-import 'mousetrap';
-import 'mousetrap/plugins/pause/mousetrap-pause';
-
// expose common libraries as globals (TODO: remove these)
window.jQuery = jQuery;
window.$ = jQuery;
-window._ = _;
-window.Dropzone = Dropzone;
-window.Sortable = Sortable;
-
-// templates
-import './templates/issuable_template_selector';
-import './templates/issuable_template_selectors';
-
-import './commit/image_file';
// lib/utils
-import { handleLocationHash } from './lib/utils/common_utils';
-import { localTimeAgo, renderTimeago } from './lib/utils/datetime_utility';
+import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
+import { localTimeAgo } from './lib/utils/datetime_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// behaviors
@@ -36,31 +20,29 @@ import './behaviors/';
// everything else
import loadAwardsHandler from './awards_handler';
import bp from './breakpoints';
-import './confirm_danger_modal';
import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
import initTodoToggle from './header';
import initImporterStatus from './importer_status';
import initLayoutNav from './layout_nav';
+import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader';
-import './line_highlighter';
import initLogoAnimation from './logo';
import './milestone_select';
-import './preview_markdown';
import './projects_dropdown';
-import './render_gfm';
import initBreadcrumbs from './breadcrumb';
-import './dispatcher';
+import initDispatcher from './dispatcher';
-// eslint-disable-next-line global-require, import/no-commonjs
-if (process.env.NODE_ENV !== 'production') require('./test_utils/');
-
-Dropzone.autoDiscover = false;
+// inject test utilities if necessary
+if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) {
+ $.fx.off = true;
+ import(/* webpackMode: "eager" */ './test_utils/');
+}
svg4everybody();
-document.addEventListener('beforeunload', function () {
+document.addEventListener('beforeunload', () => {
// Unbind scroll events
$(document).off('scroll');
// Close any open tooltips
@@ -77,16 +59,15 @@ window.addEventListener('load', function onLoad() {
gl.lazyLoader = new LazyLoader({
scrollContainer: window,
- observerNode: '#content-body'
+ observerNode: '#content-body',
});
-$(function () {
- var $body = $('body');
- var $document = $(document);
- var $window = $(window);
- var $sidebarGutterToggle = $('.js-sidebar-toggle');
- var bootstrapBreakpoint = bp.getBreakpointSize();
- var fitSidebarForSize;
+document.addEventListener('DOMContentLoaded', () => {
+ const $body = $('body');
+ const $document = $(document);
+ const $window = $(window);
+ const $sidebarGutterToggle = $('.js-sidebar-toggle');
+ let bootstrapBreakpoint = bp.getBreakpointSize();
initBreadcrumbs();
initLayoutNav();
@@ -98,8 +79,8 @@ $(function () {
Cookies.defaults.path = gon.relative_url_root || '/';
// `hashchange` is not triggered when link target is already in window.location
- $body.on('click', 'a[href^="#"]', function() {
- var href = this.getAttribute('href');
+ $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() {
+ const href = this.getAttribute('href');
if (href.substr(1) === getLocationHash()) {
setTimeout(handleLocationHash, 1);
}
@@ -114,155 +95,146 @@ $(function () {
}
// prevent default action for disabled buttons
- $('.btn').click(function(e) {
+ $('.btn').click(function clickDisabledButtonCallback(e) {
if ($(this).hasClass('disabled')) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
- });
- $('.js-select-on-focus').on('focusin', function () {
- return $(this).select().one('mouseup', function (e) {
- return e.preventDefault();
- });
- // Click a .js-select-on-focus field, select the contents
- // Prevent a mouseup event from deselecting the input
+ return true;
});
- $('.remove-row').bind('ajax:success', function () {
+
+ addSelectOnFocusBehaviour('.js-select-on-focus');
+
+ $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
$(this).tooltip('destroy')
.closest('li')
.fadeOut();
});
- $('.js-remove-tr').bind('ajax:before', function () {
- return $(this).hide();
+
+ $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() {
+ $(this).hide();
});
- $('.js-remove-tr').bind('ajax:success', function () {
- return $(this).closest('tr').fadeOut();
+
+ $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
+ $(this).closest('tr').fadeOut();
});
+
+ // Initialize select2 selects
$('select.select2').select2({
width: 'resolve',
- // Initialize select2 selects
- dropdownAutoWidth: true
+ dropdownAutoWidth: true,
});
- $('.js-select2').bind('select2-close', function () {
- return setTimeout((function () {
- $('.select2-container-active').removeClass('select2-container-active');
- return $(':focus').blur();
- }), 1);
+
// Close select2 on escape
+ $('.js-select2').on('select2-close', () => {
+ setTimeout(() => {
+ $('.select2-container-active').removeClass('select2-container-active');
+ $(':focus').blur();
+ }, 1);
});
+
// Initialize tooltips
$.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover';
$body.tooltip({
selector: '.has-tooltip, [data-toggle="tooltip"]',
- placement: function (tip, el) {
+ placement(tip, el) {
return $(el).data('placement') || 'bottom';
- }
+ },
});
+
// Initialize popovers
$body.popover({
selector: '[data-toggle="popover"]',
trigger: 'focus',
// set the viewport to the main content, excluding the navigation bar, so
// the navigation can't overlap the popover
- viewport: '.layout-page'
+ viewport: '.layout-page',
});
- $('.trigger-submit').on('change', function () {
- return $(this).parents('form').submit();
+
// Form submitter
+ $('.trigger-submit').on('change', function triggerSubmitCallback() {
+ $(this).parents('form').submit();
});
+
localTimeAgo($('abbr.timeago, .js-timeago'), true);
+
// Disable form buttons while a form is submitting
- $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) {
- var buttons;
- buttons = $('[type="submit"], .js-disable-on-submit', this);
+ $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) {
+ const $buttons = $('[type="submit"], .js-disable-on-submit', this);
switch (e.type) {
case 'ajax:beforeSend':
case 'submit':
- return buttons.disable();
+ return $buttons.disable();
default:
- return buttons.enable();
+ return $buttons.enable();
}
});
- $(document).ajaxError(function (e, xhrObj) {
- var ref = xhrObj.status;
- if (xhrObj.status === 401) {
- return new Flash('You need to be logged in.', 'alert');
+
+ $(document).ajaxError((e, xhrObj) => {
+ const ref = xhrObj.status;
+
+ if (ref === 401) {
+ Flash('You need to be logged in.');
} else if (ref === 404 || ref === 500) {
- return new Flash('Something went wrong on our end.', 'alert');
+ Flash('Something went wrong on our end.');
}
});
- $('.account-box').hover(function () {
- // Show/Hide the profile menu when hovering the account box
- return $(this).toggleClass('hover');
- });
- $document.on('click', '.diff-content .js-show-suppressed-diff', function () {
- var $container;
- $container = $(this).parent();
- $container.next('table').show();
- return $container.remove();
+
// Commit show suppressed diff
+ $document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() {
+ const $container = $(this).parent();
+ $container.next('table').show();
+ $container.remove();
});
+
$('.navbar-toggle').on('click', () => {
$('.header-content').toggleClass('menu-expanded');
gl.lazyLoader.loadCheck();
});
+
// Show/hide comments on diff
- $body.on('click', '.js-toggle-diff-comments', function (e) {
- var $this = $(this);
- var notesHolders = $this.closest('.diff-file').find('.notes_holder');
+ $body.on('click', '.js-toggle-diff-comments', function toggleDiffCommentsCallback(e) {
+ const $this = $(this);
+ const notesHolders = $this.closest('.diff-file').find('.notes_holder');
+
+ e.preventDefault();
+
$this.toggleClass('active');
+
if ($this.hasClass('active')) {
notesHolders.show().find('.hide, .content').show();
} else {
notesHolders.hide().find('.content').hide();
}
+
$(document).trigger('toggle.comments');
- return e.preventDefault();
- });
- $document.off('click', '.js-confirm-danger');
- $document.on('click', '.js-confirm-danger', function (e) {
- var btn = $(e.target);
- var form = btn.closest('form');
- var text = btn.data('confirm-danger-message');
- e.preventDefault();
- return new ConfirmDangerModal(form, text);
});
- $('input[type="search"]').each(function () {
- var $this = $(this);
- $this.attr('value', $this.val());
- });
- $document.off('keyup', 'input[type="search"]').on('keyup', 'input[type="search"]', function () {
- var $this;
- $this = $(this);
- return $this.attr('value', $this.val());
- });
- $document.off('breakpoint:change').on('breakpoint:change', function (e, breakpoint) {
- var $gutterIcon;
+
+ $document.on('breakpoint:change', (e, breakpoint) => {
if (breakpoint === 'sm' || breakpoint === 'xs') {
- $gutterIcon = $sidebarGutterToggle.find('i');
+ const $gutterIcon = $sidebarGutterToggle.find('i');
if ($gutterIcon.hasClass('fa-angle-double-right')) {
- return $sidebarGutterToggle.trigger('click');
+ $sidebarGutterToggle.trigger('click');
}
}
});
- fitSidebarForSize = function () {
- var oldBootstrapBreakpoint;
- oldBootstrapBreakpoint = bootstrapBreakpoint;
+
+ function fitSidebarForSize() {
+ const oldBootstrapBreakpoint = bootstrapBreakpoint;
bootstrapBreakpoint = bp.getBreakpointSize();
+
if (bootstrapBreakpoint !== oldBootstrapBreakpoint) {
- return $document.trigger('breakpoint:change', [bootstrapBreakpoint]);
+ $document.trigger('breakpoint:change', [bootstrapBreakpoint]);
}
- };
- $window.off('resize.app').on('resize.app', function () {
- return fitSidebarForSize();
- });
- loadAwardsHandler();
+ }
- renderTimeago();
+ $window.on('resize.app', fitSidebarForSize);
- $('form.filter-form').on('submit', function (event) {
+ loadAwardsHandler();
+
+ $('form.filter-form').on('submit', function filterFormSubmitCallback(event) {
const link = document.createElement('a');
link.href = this.action;
@@ -279,4 +251,6 @@ $(function () {
removeFlashClickListener(flashEl);
});
}
+
+ initDispatcher();
});
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index 84e70e35bad..d27922a2099 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Pikaday from 'pikaday';
import { parsePikadayDate, pikadayToString } from './lib/utils/datefix';
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index 52315e969d1..7d0c701fd70 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default class Members {
constructor() {
this.addListeners();
@@ -19,7 +21,7 @@ export default class Members {
isSelectable(selected, $el) {
return !$el.hasClass('is-active');
},
- fieldName: $btn.data('field-name'),
+ fieldName: $btn.data('fieldName'),
id(selected, $el) {
return $el.data('id');
},
@@ -51,7 +53,7 @@ export default class Members {
}
// eslint-disable-next-line class-methods-use-this
getMemberListItems($el) {
- const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('el-id')}`);
+ const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`);
return {
$memberListItem,
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
index 93f8f6ee926..2cb238529aa 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
@@ -2,7 +2,9 @@
/* global ace */
import Vue from 'vue';
-import Flash from '../../flash';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
@@ -49,27 +51,26 @@ import Flash from '../../flash';
loadEditor() {
this.loading = true;
- $.get(this.file.content_path)
- .done((file) => {
+ axios.get(this.file.content_path)
+ .then(({ data }) => {
const content = this.$el.querySelector('pre');
- const fileContent = document.createTextNode(file.content);
+ const fileContent = document.createTextNode(data.content);
content.textContent = fileContent.textContent;
- this.originalContent = file.content;
+ this.originalContent = data.content;
this.fileLoaded = true;
this.editor = ace.edit(content);
this.editor.$blockScrolling = Infinity; // Turn off annoying warning
- this.editor.getSession().setMode(`ace/mode/${file.blob_ace_mode}`);
+ this.editor.getSession().setMode(`ace/mode/${data.blob_ace_mode}`);
this.editor.on('change', () => {
this.saveDiffResolution();
});
this.saveDiffResolution();
+ this.loading = false;
})
- .fail(() => {
- new Flash('Failed to load the file, please try again.');
- })
- .always(() => {
+ .catch(() => {
+ flash(__('An error occurred while loading the file'));
this.loading = false;
});
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
index c012b77e0bf..c68b47c9348 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
@@ -1,4 +1,5 @@
/* eslint-disable no-param-reassign, comma-dangle */
+import axios from '../lib/utils/axios_utils';
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
@@ -10,20 +11,11 @@
}
fetchConflictsData() {
- return $.ajax({
- dataType: 'json',
- url: this.conflictsPath
- });
+ return axios.get(this.conflictsPath);
}
submitResolveConflicts(data) {
- return $.ajax({
- url: this.resolveConflictsPath,
- data: JSON.stringify(data),
- contentType: 'application/json',
- dataType: 'json',
- method: 'POST'
- });
+ return axios.post(this.resolveConflictsPath, data);
}
}
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
index 8be7314ded8..db1d09eb2f2 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
@@ -1,5 +1,6 @@
/* eslint-disable comma-dangle, object-shorthand, no-param-reassign, camelcase, no-nested-ternary, no-continue, max-len */
+import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 94561d6b7c3..4abd5433bb5 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,5 +1,6 @@
/* eslint-disable new-cap, comma-dangle, no-new */
+import $ from 'jquery';
import Vue from 'vue';
import Flash from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
@@ -12,7 +13,7 @@ import './components/inline_conflict_lines';
import './components/parallel_conflict_lines';
import syntaxHighlight from '../syntax_highlight';
-$(() => {
+export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts');
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
@@ -25,12 +26,12 @@ $(() => {
gl.MergeConflictsResolverApp = new Vue({
el: '#conflicts',
- data: mergeConflictsStore.state,
components: {
'diff-file-editor': gl.mergeConflicts.diffFileEditor,
'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines,
'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines
},
+ data: mergeConflictsStore.state,
computed: {
conflictsCountText() { return mergeConflictsStore.getConflictsCountText(); },
readyToCommit() { return mergeConflictsStore.isReadyToCommit(); },
@@ -38,24 +39,23 @@ $(() => {
showDiffViewTypeSwitcher() { return mergeConflictsStore.fileTextTypePresent(); }
},
created() {
- mergeConflictsService
- .fetchConflictsData()
- .done((data) => {
+ mergeConflictsService.fetchConflictsData()
+ .then(({ data }) => {
if (data.type === 'error') {
mergeConflictsStore.setFailedRequest(data.message);
} else {
mergeConflictsStore.setConflictsData(data);
}
- })
- .error(() => {
- mergeConflictsStore.setFailedRequest();
- })
- .always(() => {
+
mergeConflictsStore.setLoadingState(false);
this.$nextTick(() => {
syntaxHighlight($('.js-syntax-highlight'));
});
+ })
+ .catch(() => {
+ mergeConflictsStore.setLoadingState(false);
+ mergeConflictsStore.setFailedRequest();
});
},
methods: {
@@ -82,14 +82,14 @@ $(() => {
mergeConflictsService
.submitResolveConflicts(mergeConflictsStore.getCommitData())
- .done((data) => {
+ .then(({ data }) => {
window.location.href = data.redirect_to;
})
- .error(() => {
+ .catch(() => {
mergeConflictsStore.setSubmitState(false);
new Flash('Failed to save merge conflicts resolutions. Please try again!');
});
}
}
});
-});
+}
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index cb3cdea8111..d8222ebec63 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,6 +1,7 @@
/* 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 */
-import 'vendor/jquery.waitforimages';
+import $ from 'jquery';
+import { __ } from '~/locale';
import TaskList from './task_list';
import MergeRequestTabs from './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper';
@@ -110,22 +111,22 @@ MergeRequest.prototype.initCommitMessageListeners = function() {
});
};
-MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) {
+MergeRequest.setStatusBoxToMerged = function() {
$('.detail-page-header .status-box')
- .removeClass(classToRemove)
- .addClass(classToAdd)
+ .removeClass('status-box-open')
+ .addClass('status-box-mr-merged')
.find('span')
- .text(newStatusText);
+ .text(__('Merged'));
};
-MergeRequest.prototype.decreaseCounter = function(by = 1) {
- const $el = $('.nav-links .js-merge-counter');
+MergeRequest.decreaseCounter = function(by = 1) {
+ const $el = $('.js-merge-counter');
const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
$el.text(addDelimiter(count));
};
-MergeRequest.prototype.hideCloseButton = function() {
+MergeRequest.hideCloseButton = function() {
const el = document.querySelector('.merge-request .js-issuable-actions');
const closeDropdownItem = el.querySelector('li.close-item');
if (closeDropdownItem) {
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index acfc62fe5cb..e77318fef46 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,7 +1,9 @@
/* eslint-disable no-new, class-methods-use-this */
+import $ from 'jquery';
import Cookies from 'js-cookie';
-import Flash from './flash';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import initChangesDropdown from './init_changes_dropdown';
import bp from './breakpoints';
@@ -71,6 +73,7 @@ export default class MergeRequestTabs {
constructor({ action, setUrl, stubLocation } = {}) {
const mergeRequestTabs = document.querySelector('.js-tabs-affix');
const navbar = document.querySelector('.navbar-gitlab');
+ const peek = document.getElementById('peek');
const paddingTop = 16;
this.diffsLoaded = false;
@@ -84,6 +87,10 @@ export default class MergeRequestTabs {
this.showTab = this.showTab.bind(this);
this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0;
+ if (peek) {
+ this.stickyTop += peek.offsetHeight;
+ }
+
if (mergeRequestTabs) {
this.stickyTop += mergeRequestTabs.offsetHeight;
}
@@ -240,19 +247,30 @@ export default class MergeRequestTabs {
return newState;
}
+ getCurrentAction() {
+ return this.currentAction;
+ }
+
loadCommits(source) {
if (this.commitsLoaded) {
return;
}
- this.ajaxGet({
- url: `${source}.json`,
- success: (data) => {
+
+ this.toggleLoading(true);
+
+ axios.get(`${source}.json`)
+ .then(({ data }) => {
document.querySelector('div#commits').innerHTML = data.html;
localTimeAgo($('.js-timeago', 'div#commits'));
this.commitsLoaded = true;
this.scrollToElement('#commits');
- },
- });
+
+ this.toggleLoading(false);
+ })
+ .catch(() => {
+ this.toggleLoading(false);
+ flash('An error occurred while fetching this tab.');
+ });
}
mountPipelinesView() {
@@ -283,9 +301,10 @@ export default class MergeRequestTabs {
// some pages like MergeRequestsController#new has query parameters on that anchor
const urlPathname = parseUrlPathname(source);
- this.ajaxGet({
- url: `${urlPathname}.json${location.search}`,
- success: (data) => {
+ this.toggleLoading(true);
+
+ axios.get(`${urlPathname}.json${location.search}`)
+ .then(({ data }) => {
const $container = $('#diffs');
$container.html(data.html);
@@ -335,8 +354,13 @@ export default class MergeRequestTabs {
// (discussion and diff tabs) and `:target` only applies to the first
anchor.addClass('target');
}
- },
- });
+
+ this.toggleLoading(false);
+ })
+ .catch(() => {
+ this.toggleLoading(false);
+ flash('An error occurred while fetching this tab.');
+ });
}
// Show or hide the loading spinner
@@ -346,19 +370,8 @@ export default class MergeRequestTabs {
$('.mr-loading-status .loading').toggle(status);
}
- ajaxGet(options) {
- const defaults = {
- beforeSend: () => this.toggleLoading(true),
- error: () => new Flash('An error occurred while fetching this tab.', 'alert'),
- complete: () => this.toggleLoading(false),
- dataType: 'json',
- type: 'GET',
- };
- $.ajax($.extend({}, defaults, options));
- }
-
diffViewType() {
- return $('.inline-parallel-buttons a.active').data('view-type');
+ return $('.inline-parallel-buttons a.active').data('viewType');
}
isDiffAction(action) {
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index f76a998bf8c..e6e3a66aa20 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -1,6 +1,6 @@
-/* global Sortable */
-
-import Flash from './flash';
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
export default class Milestone {
constructor() {
@@ -35,15 +35,12 @@ export default class Milestone {
const tabElId = $target.attr('href');
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');
- });
+ axios.get(endpoint)
+ .then(({ data }) => {
+ $(tabElId).html(data.html);
+ $target.addClass('is-loaded');
+ })
+ .catch(() => flash('Error loading milestone tab'));
}
}
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 2e5e818d61d..d0a2b27b0e6 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -1,222 +1,216 @@
-/* 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 */
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, 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 'jquery';
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
import { timeFor } from './lib/utils/datetime_utility';
+import ModalStore from './boards/stores/modal_store';
-(function() {
- this.MilestoneSelect = (function() {
- function MilestoneSelect(currentProject, els, options = {}) {
- var _this, $els;
- if (currentProject != null) {
- _this = this;
- this.currentProject = typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
- }
+export default class MilestoneSelect {
+ constructor(currentProject, els, options = {}) {
+ if (currentProject !== null) {
+ this.currentProject = typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject;
+ }
- $els = $(els);
+ this.init(els, options);
+ }
- if (!els) {
- $els = $('.js-milestone-select');
- }
+ init(els, options) {
+ let $els = $(els);
- $els.each(function(i, dropdown) {
- 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');
- showNo = $dropdown.data('show-no');
- showAny = $dropdown.data('show-any');
- showMenuAbove = $dropdown.data('showMenuAbove');
- showUpcoming = $dropdown.data('show-upcoming');
- 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');
- $block = $selectbox.closest('.block');
- $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>';
- collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- title %> </span>');
- }
- return $dropdown.glDropdown({
- showMenuAbove: showMenuAbove,
- data: function(term, callback) {
- return $.ajax({
- url: milestonesUrl
- }).done(function(data) {
- var extraOptions = [];
- if (showAny) {
- extraOptions.push({
- id: 0,
- name: '',
- title: 'Any Milestone'
- });
- }
- if (showNo) {
- extraOptions.push({
- id: -1,
- name: 'No Milestone',
- title: 'No Milestone'
- });
- }
- if (showUpcoming) {
- extraOptions.push({
- id: -2,
- name: '#upcoming',
- title: 'Upcoming'
- });
- }
- if (showStarted) {
- extraOptions.push({
- id: -3,
- name: '#started',
- title: 'Started'
- });
- }
- if (extraOptions.length) {
- extraOptions.push('divider');
- }
-
- callback(extraOptions.concat(data));
- 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']
- },
- selectable: true,
- toggleLabel: function(selected, el, e) {
- if (selected && 'id' in selected && $(el).hasClass('is-active')) {
- return selected.title;
- } else {
- return defaultLabel;
+ if (!els) {
+ $els = $('.js-milestone-select');
+ }
+
+ $els.each((i, dropdown) => {
+ let collapsedSidebarLabelTemplate, milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault;
+ const $dropdown = $(dropdown);
+ const projectId = $dropdown.data('projectId');
+ const milestonesUrl = $dropdown.data('milestones');
+ const issueUpdateURL = $dropdown.data('issueUpdate');
+ const showNo = $dropdown.data('showNo');
+ const showAny = $dropdown.data('showAny');
+ const showMenuAbove = $dropdown.data('showMenuAbove');
+ const showUpcoming = $dropdown.data('showUpcoming');
+ const showStarted = $dropdown.data('showStarted');
+ const useId = $dropdown.data('useId');
+ const defaultLabel = $dropdown.data('defaultLabel');
+ const defaultNo = $dropdown.data('defaultNo');
+ const issuableId = $dropdown.data('issuableId');
+ const abilityName = $dropdown.data('abilityName');
+ const $selectBox = $dropdown.closest('.selectbox');
+ const $block = $selectBox.closest('.block');
+ const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
+ const $value = $block.find('.value');
+ const $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>';
+ collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- title %> </span>');
+ }
+ return $dropdown.glDropdown({
+ showMenuAbove: showMenuAbove,
+ data: (term, callback) => axios.get(milestonesUrl)
+ .then(({ data }) => {
+ const extraOptions = [];
+ if (showAny) {
+ extraOptions.push({
+ id: 0,
+ name: '',
+ title: 'Any Milestone'
+ });
}
- },
- defaultLabel: defaultLabel,
- fieldName: $dropdown.data('field-name'),
- text: function(milestone) {
- return _.escape(milestone.title);
- },
- id: function(milestone) {
- if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
- return milestone.name;
- } else {
- return milestone.id;
+ if (showNo) {
+ extraOptions.push({
+ id: -1,
+ name: 'No Milestone',
+ title: 'No Milestone'
+ });
+ }
+ if (showUpcoming) {
+ extraOptions.push({
+ id: -2,
+ name: '#upcoming',
+ title: 'Upcoming'
+ });
}
- },
- isSelected: function(milestone) {
- return milestone.name === selectedMilestone;
- },
- hidden: function() {
- $selectbox.hide();
- // 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') || options.handleClick) {
- selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
+ if (showStarted) {
+ extraOptions.push({
+ id: -3,
+ name: '#started',
+ title: 'Started'
+ });
}
- $('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(clickEvent) {
- const { $el, e } = clickEvent;
- let selected = clickEvent.selectedObj;
-
- var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
- if (!selected) return;
-
- if (options.handleClick) {
- e.preventDefault();
- options.handleClick(selected);
- return;
+ if (extraOptions.length) {
+ extraOptions.push('divider');
}
- page = $('body').attr('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;
+ callback(extraOptions.concat(data));
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
}
+ $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active');
+ }),
+ renderRow: milestone => `
+ <li data-milestone-id="${_.escape(milestone.name)}">
+ <a href='#' class='dropdown-menu-milestone-link'>
+ ${_.escape(milestone.title)}
+ </a>
+ </li>
+ `,
+ filterable: true,
+ search: {
+ fields: ['title']
+ },
+ selectable: true,
+ toggleLabel: (selected, el, e) => {
+ if (selected && 'id' in selected && $(el).hasClass('is-active')) {
+ return selected.title;
+ } else {
+ return defaultLabel;
+ }
+ },
+ defaultLabel: defaultLabel,
+ fieldName: $dropdown.data('fieldName'),
+ text: milestone => _.escape(milestone.title),
+ id: (milestone) => {
+ if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
+ return milestone.name;
+ } else {
+ return milestone.id;
+ }
+ },
+ hidden: () => {
+ $selectBox.hide();
+ // display:block overrides the hide-collapse rule
+ return $value.css('display', '');
+ },
+ opened: (e) => {
+ const $el = $(e.currentTarget);
+ if ($dropdown.hasClass('js-issue-board-sidebar') || options.handleClick) {
+ selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
+ }
+ $('a.is-active', $el).removeClass('is-active');
+ $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
+ },
+ vue: $dropdown.hasClass('js-issue-board-sidebar'),
+ clicked: (clickEvent) => {
+ const { $el, e } = clickEvent;
+ let selected = clickEvent.selectedObj;
- if ($dropdown.closest('.add-issues-modal').length) {
- boardsStore = gl.issueBoards.ModalStore.store.filter;
- }
+ let data, boardsStore;
+ if (!selected) return;
- if (boardsStore) {
- boardsStore[$dropdown.data('field-name')] = selected.name;
- e.preventDefault();
- } 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-issue-board-sidebar')) {
- if (selected.id !== -1 && isSelecting) {
- gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
- id: selected.id,
- title: selected.name
- }));
- } else {
- gl.issueBoards.boardStoreIssueDelete('milestone');
- }
-
- $dropdown.trigger('loading.gl.dropdown');
- $loading.removeClass('hidden').fadeIn();
-
- gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
- .then(function () {
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.fadeOut();
- })
- .catch(() => {
- $loading.fadeOut();
- });
+ if (options.handleClick) {
+ e.preventDefault();
+ options.handleClick(selected);
+ return;
+ }
+
+ const page = $('body').attr('data-page');
+ const isIssueIndex = page === 'projects:issues:index';
+ const isMRIndex = (page === page && page === 'projects:merge_requests:index');
+ const 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;
+ }
+
+ if ($dropdown.closest('.add-issues-modal').length) {
+ boardsStore = ModalStore.store.filter;
+ }
+
+ if (boardsStore) {
+ boardsStore[$dropdown.data('fieldName')] = selected.name;
+ e.preventDefault();
+ } 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-issue-board-sidebar')) {
+ if (selected.id !== -1 && isSelecting) {
+ gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
+ id: selected.id,
+ title: selected.name
+ }));
} else {
- selected = $selectbox.find('input[type="hidden"]').val();
- data = {};
- data[abilityName] = {};
- data[abilityName].milestone_id = selected != null ? selected : null;
- $loading.removeClass('hidden').fadeIn();
- $dropdown.trigger('loading.gl.dropdown');
- return $.ajax({
- type: 'PUT',
- url: issueUpdateURL,
- data: data
- }).done(function(data) {
+ gl.issueBoards.boardStoreIssueDelete('milestone');
+ }
+
+ $dropdown.trigger('loading.gl.dropdown');
+ $loading.removeClass('hidden').fadeIn();
+
+ gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
+ .then(() => {
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
- $selectbox.hide();
+ })
+ .catch(() => {
+ $loading.fadeOut();
+ });
+ } else {
+ selected = $selectBox.find('input[type="hidden"]').val();
+ data = {};
+ data[abilityName] = {};
+ data[abilityName].milestone_id = selected != null ? selected : null;
+ $loading.removeClass('hidden').fadeIn();
+ $dropdown.trigger('loading.gl.dropdown');
+ return axios.put(issueUpdateURL, data)
+ .then(({ data }) => {
+ $dropdown.trigger('loaded.gl.dropdown');
+ $loading.fadeOut();
+ $selectBox.hide();
$value.css('display', '');
if (data.milestone != null) {
- data.milestone.full_path = _this.currentProject.full_path;
+ data.milestone.full_path = this.currentProject.full_path;
data.milestone.remaining = timeFor(data.milestone.due_date);
data.milestone.name = data.milestone.title;
$value.html(milestoneLinkTemplate(data.milestone));
@@ -225,13 +219,13 @@ import { timeFor } from './lib/utils/datetime_utility';
$value.html(milestoneLinkNoneTemplate);
return $sidebarCollapsedValue.find('span').text('No');
}
+ })
+ .catch(() => {
+ $loading.fadeOut();
});
- }
}
- });
+ }
});
- }
-
- return MilestoneSelect;
- })();
-}).call(window);
+ });
+ }
+}
diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
index ca3d271663b..01399de4c62 100644
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
@@ -1,5 +1,8 @@
/* eslint-disable no-new */
-import Flash from './flash';
+
+import $ from 'jquery';
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
/**
* In each pipelines table we have a mini pipeline graph for each pipeline.
@@ -78,27 +81,22 @@ export default class MiniPipelineGraph {
const button = e.relatedTarget;
const endpoint = button.dataset.stageEndpoint;
- return $.ajax({
- dataType: 'json',
- type: 'GET',
- url: endpoint,
- beforeSend: () => {
- this.renderBuildsList(button, '');
- this.toggleLoading(button);
- },
- success: (data) => {
+ this.renderBuildsList(button, '');
+ this.toggleLoading(button);
+
+ axios.get(endpoint)
+ .then(({ data }) => {
this.toggleLoading(button);
this.renderBuildsList(button, data.html);
this.stopDropdownClickPropagation();
- },
- error: () => {
+ })
+ .catch(() => {
this.toggleLoading(button);
if ($(button).parent().hasClass('open')) {
$(button).dropdown('toggle');
}
- new Flash('An error occurred while fetching the builds.', 'alert');
- },
- });
+ flash('An error occurred while fetching the builds.', 'alert');
+ });
}
/**
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 8da723ced03..f5572be5fbf 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,111 +1,167 @@
<script>
- import _ from 'underscore';
- import Flash from '../../flash';
- import MonitoringService from '../services/monitoring_service';
- import GraphGroup from './graph_group.vue';
- import Graph from './graph.vue';
- import EmptyState from './empty_state.vue';
- import MonitoringStore from '../stores/monitoring_store';
- import eventHub from '../event_hub';
- import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
+import _ from 'underscore';
+import Flash from '../../flash';
+import MonitoringService from '../services/monitoring_service';
+import GraphGroup from './graph_group.vue';
+import Graph from './graph.vue';
+import EmptyState from './empty_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: convertPermissionToBoolean(metricsData.hasMetrics),
- documentationPath: metricsData.documentationPath,
- settingsPath: metricsData.settingsPath,
- tagsPath: metricsData.tagsPath,
- projectPath: metricsData.projectPath,
- metricsEndpoint: metricsData.additionalMetrics,
- deploymentEndpoint: metricsData.deploymentEndpoint,
- emptyGettingStartedSvgPath: metricsData.emptyGettingStartedSvgPath,
- emptyLoadingSvgPath: metricsData.emptyLoadingSvgPath,
- emptyUnableToConnectSvgPath: metricsData.emptyUnableToConnectSvgPath,
- showEmptyState: true,
- updateAspectRatio: false,
- updatedAspectRatios: 0,
- hoverData: {},
- resizeThrottled: {},
- };
+export default {
+ components: {
+ Graph,
+ GraphGroup,
+ EmptyState,
+ },
+ props: {
+ hasMetrics: {
+ type: Boolean,
+ required: false,
+ default: true,
},
-
- components: {
- Graph,
- GraphGroup,
- EmptyState,
+ showLegend: {
+ type: Boolean,
+ required: false,
+ default: true,
},
-
- methods: {
- getGraphsData() {
- this.state = 'loading';
- Promise.all([
- this.service.getGraphsData()
- .then(data => this.store.storeMetrics(data)),
- this.service.getDeploymentData()
- .then(data => this.store.storeDeploymentData(data))
- .catch(() => new Flash('Error getting deployment information.')),
- ])
- .then(() => { this.showEmptyState = false; })
- .catch(() => { this.state = 'unableToConnect'; });
- },
-
- resize() {
- this.updateAspectRatio = true;
- },
-
- toggleAspectRatio() {
- this.updatedAspectRatios = this.updatedAspectRatios += 1;
- if (this.store.getMetricsCount() === this.updatedAspectRatios) {
- this.updateAspectRatio = !this.updateAspectRatio;
- this.updatedAspectRatios = 0;
- }
- },
-
- hoverChanged(data) {
- this.hoverData = data;
- },
+ showPanels: {
+ type: Boolean,
+ required: false,
+ default: true,
},
-
- created() {
- this.service = new MonitoringService({
- metricsEndpoint: this.metricsEndpoint,
- deploymentEndpoint: this.deploymentEndpoint,
- });
- eventHub.$on('toggleAspectRatio', this.toggleAspectRatio);
- eventHub.$on('hoverChanged', this.hoverChanged);
+ forceSmallGraph: {
+ type: Boolean,
+ required: false,
+ default: false,
},
-
- beforeDestroy() {
- eventHub.$off('toggleAspectRatio', this.toggleAspectRatio);
- eventHub.$off('hoverChanged', this.hoverChanged);
- window.removeEventListener('resize', this.resizeThrottled, false);
+ documentationPath: {
+ type: String,
+ required: true,
},
-
- mounted() {
- this.resizeThrottled = _.throttle(this.resize, 600);
- if (!this.hasMetrics) {
- this.state = 'gettingStarted';
- } else {
- this.getGraphsData();
- window.addEventListener('resize', this.resizeThrottled, false);
+ settingsPath: {
+ type: String,
+ required: true,
+ },
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ tagsPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ metricsEndpoint: {
+ type: String,
+ required: true,
+ },
+ deploymentEndpoint: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ emptyGettingStartedSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyLoadingSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyNoDataSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyUnableToConnectSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ store: new MonitoringStore(),
+ state: 'gettingStarted',
+ showEmptyState: true,
+ updateAspectRatio: false,
+ updatedAspectRatios: 0,
+ hoverData: {},
+ resizeThrottled: {},
+ };
+ },
+ created() {
+ this.service = new MonitoringService({
+ metricsEndpoint: this.metricsEndpoint,
+ deploymentEndpoint: this.deploymentEndpoint,
+ });
+ eventHub.$on('toggleAspectRatio', this.toggleAspectRatio);
+ eventHub.$on('hoverChanged', this.hoverChanged);
+ },
+ beforeDestroy() {
+ eventHub.$off('toggleAspectRatio', this.toggleAspectRatio);
+ eventHub.$off('hoverChanged', this.hoverChanged);
+ 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);
+ }
+ },
+ methods: {
+ getGraphsData() {
+ this.state = 'loading';
+ Promise.all([
+ this.service.getGraphsData().then(data => this.store.storeMetrics(data)),
+ this.service
+ .getDeploymentData()
+ .then(data => this.store.storeDeploymentData(data))
+ .catch(() => new Flash('Error getting deployment information.')),
+ ])
+ .then(() => {
+ if (this.store.groups.length < 1) {
+ this.state = 'noData';
+ return;
+ }
+ this.showEmptyState = false;
+ })
+ .catch(() => {
+ this.state = 'unableToConnect';
+ });
+ },
+ resize() {
+ this.updateAspectRatio = true;
+ },
+ toggleAspectRatio() {
+ this.updatedAspectRatios = this.updatedAspectRatios += 1;
+ if (this.store.getMetricsCount() === this.updatedAspectRatios) {
+ this.updateAspectRatio = !this.updateAspectRatio;
+ this.updatedAspectRatios = 0;
}
},
- };
+ hoverChanged(data) {
+ this.hoverData = data;
+ },
+ },
+};
</script>
<template>
- <div v-if="!showEmptyState" class="prometheus-graphs">
+ <div
+ v-if="!showEmptyState"
+ class="prometheus-graphs"
+ >
<graph-group
v-for="(groupData, index) in store.groups"
:key="index"
:name="groupData.group"
+ :show-panels="showPanels"
>
<graph
v-for="(graphData, index) in groupData.metrics"
@@ -116,6 +172,8 @@
:deployment-data="store.deploymentData"
:project-path="projectPath"
:tags-path="tagsPath"
+ :show-legend="showLegend"
+ :small-graph="forceSmallGraph"
/>
</graph-group>
</div>
@@ -124,8 +182,10 @@
:selected-state="state"
:documentation-path="documentationPath"
:settings-path="settingsPath"
+ :clusters-path="clustersPath"
:empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath"
+ :empty-no-data-svg-path="emptyNoDataSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
/>
</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index a18164482a2..c77f451c2d3 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -1,93 +1,125 @@
<script>
- export default {
- props: {
- documentationPath: {
- type: String,
- required: true,
- },
- settingsPath: {
- type: String,
- required: false,
- default: '',
- },
- selectedState: {
- type: String,
- required: true,
- },
- emptyGettingStartedSvgPath: {
- type: String,
- required: true,
- },
- emptyLoadingSvgPath: {
- type: String,
- required: true,
- },
- emptyUnableToConnectSvgPath: {
- type: String,
- required: true,
- },
+export default {
+ props: {
+ documentationPath: {
+ type: String,
+ required: true,
},
- data() {
- return {
- states: {
- gettingStarted: {
- svgUrl: this.emptyGettingStartedSvgPath,
- 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: {
- svgUrl: this.emptyLoadingSvgPath,
- 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: {
- svgUrl: this.emptyUnableToConnectSvgPath,
- title: 'Unable to connect to Prometheus server',
- description: 'Ensure connectivity is available from the GitLab server to the ',
- buttonText: 'View documentation',
- },
- },
- };
+ settingsPath: {
+ type: String,
+ required: false,
+ default: '',
},
- 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;
+ clustersPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ selectedState: {
+ type: String,
+ required: true,
+ },
+ emptyGettingStartedSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyLoadingSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyNoDataSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyUnableToConnectSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ states: {
+ gettingStarted: {
+ svgUrl: this.emptyGettingStartedSvgPath,
+ 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: 'Install Prometheus on clusters',
+ buttonPath: this.clustersPath,
+ secondaryButtonText: 'Configure existing Prometheus',
+ secondaryButtonPath: this.settingsPath,
+ },
+ loading: {
+ svgUrl: this.emptyLoadingSvgPath,
+ 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',
+ buttonPath: this.documentationPath,
+ },
+ noData: {
+ svgUrl: this.emptyNoDataSvgPath,
+ title: 'No data found',
+ description: `You are connected to the Prometheus server, but there is currently
+ no data to display.`,
+ buttonText: 'Configure Prometheus',
+ buttonPath: this.settingsPath,
+ },
+ unableToConnect: {
+ svgUrl: this.emptyUnableToConnectSvgPath,
+ title: 'Unable to connect to Prometheus server',
+ description: 'Ensure connectivity is available from the GitLab server to the ',
+ buttonText: 'View documentation',
+ buttonPath: this.documentationPath,
+ },
},
+ };
+ },
+ computed: {
+ currentState() {
+ return this.states[this.selectedState];
+ },
+ showButtonDescription() {
+ if (this.selectedState === 'unableToConnect') return true;
+ return false;
},
- };
+ },
+};
</script>
<template>
<div class="prometheus-state">
<div class="state-svg svg-content">
- <img :src="currentState.svgUrl"/>
+ <img :src="currentState.svgUrl" />
</div>
<h4 class="state-title">
- {{currentState.title}}
+ {{ currentState.title }}
</h4>
<p class="state-description">
- {{currentState.description}}
- <a v-if="showButtonDescription" :href="settingsPath">
+ {{ currentState.description }}
+ <a
+ v-if="showButtonDescription"
+ :href="settingsPath"
+ >
Prometheus server
</a>
</p>
<div class="state-button">
- <a class="btn btn-success" :href="buttonPath">
- {{currentState.buttonText}}
+ <a
+ v-if="currentState.buttonPath"
+ class="btn btn-success"
+ :href="currentState.buttonPath"
+ >
+ {{ currentState.buttonText }}
+ </a>
+ </div>
+ <div class="state-button">
+ <a
+ v-if="currentState.secondaryButtonPath"
+ class="btn"
+ :href="currentState.secondaryButtonPath"
+ >
+ {{ currentState.secondaryButtonText }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
index eede04a06cd..f93b1da4f58 100644
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ b/app/assets/javascripts/monitoring/components/graph.vue
@@ -1,292 +1,312 @@
<script>
- import { scaleLinear, scaleTime } from 'd3-scale';
- import { axisLeft, axisBottom } from 'd3-axis';
- import { max, extent } from 'd3-array';
- import { select } from 'd3-selection';
- import GraphLegend from './graph/legend.vue';
- import GraphFlag from './graph/flag.vue';
- import GraphDeployment from './graph/deployment.vue';
- import GraphPath from './graph/path.vue';
- import MonitoringMixin from '../mixins/monitoring_mixins';
- import eventHub from '../event_hub';
- import measurements from '../utils/measurements';
- import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
- import createTimeSeries from '../utils/multiple_time_series';
- import bp from '../../breakpoints';
+import { scaleLinear, scaleTime } from 'd3-scale';
+import { axisLeft, axisBottom } from 'd3-axis';
+import _ from 'underscore';
+import { max, extent } from 'd3-array';
+import { select } from 'd3-selection';
+import GraphAxis from './graph/axis.vue';
+import GraphLegend from './graph/legend.vue';
+import GraphFlag from './graph/flag.vue';
+import GraphDeployment from './graph/deployment.vue';
+import GraphPath from './graph/path.vue';
+import MonitoringMixin from '../mixins/monitoring_mixins';
+import eventHub from '../event_hub';
+import measurements from '../utils/measurements';
+import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
+import createTimeSeries from '../utils/multiple_time_series';
+import bp from '../../breakpoints';
- const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
+const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
- export default {
- props: {
- graphData: {
- type: Object,
- required: true,
- },
- updateAspectRatio: {
- type: Boolean,
- required: true,
- },
- deploymentData: {
- type: Array,
- required: true,
- },
- hoverData: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- projectPath: {
- type: String,
- required: true,
- },
- tagsPath: {
- type: String,
- required: true,
+export default {
+ components: {
+ GraphAxis,
+ GraphFlag,
+ GraphDeployment,
+ GraphPath,
+ GraphLegend,
+ },
+ mixins: [MonitoringMixin],
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ },
+ updateAspectRatio: {
+ type: Boolean,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: true,
+ },
+ hoverData: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ tagsPath: {
+ type: String,
+ required: true,
+ },
+ showLegend: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ smallGraph: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ baseGraphHeight: 450,
+ baseGraphWidth: 600,
+ graphHeight: 450,
+ graphWidth: 600,
+ graphHeightOffset: 120,
+ margin: {},
+ unitOfDisplay: '',
+ yAxisLabel: '',
+ legendTitle: '',
+ reducedDeploymentData: [],
+ measurements: measurements.large,
+ currentData: {
+ time: new Date(),
+ value: 0,
},
+ currentDataIndex: 0,
+ currentXCoordinate: 0,
+ currentFlagPosition: 0,
+ showFlag: false,
+ showFlagContent: false,
+ timeSeries: [],
+ realPixelRatio: 1,
+ };
+ },
+ computed: {
+ outerViewBox() {
+ return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
},
-
- mixins: [MonitoringMixin],
-
- data() {
+ innerViewBox() {
+ return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
+ },
+ axisTransform() {
+ return `translate(70, ${this.graphHeight - 100})`;
+ },
+ paddingBottomRootSvg() {
return {
- baseGraphHeight: 450,
- baseGraphWidth: 600,
- graphHeight: 450,
- graphWidth: 600,
- graphHeightOffset: 120,
- margin: {},
- unitOfDisplay: '',
- yAxisLabel: '',
- legendTitle: '',
- reducedDeploymentData: [],
- measurements: measurements.large,
- currentData: {
- time: new Date(),
- value: 0,
- },
- currentDataIndex: 0,
- currentXCoordinate: 0,
- currentFlagPosition: 0,
- showFlag: false,
- showFlagContent: false,
- showDeployInfo: true,
- timeSeries: [],
+ paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`,
};
},
-
- components: {
- GraphLegend,
- GraphFlag,
- GraphDeployment,
- GraphPath,
+ deploymentFlagData() {
+ return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag);
},
-
- computed: {
- outerViewBox() {
- return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
- },
-
- innerViewBox() {
- if ((this.baseGraphWidth - 150) > 0) {
- return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
- }
- return '0 0 0 0';
- },
-
- axisTransform() {
- return `translate(70, ${this.graphHeight - 100})`;
- },
-
- paddingBottomRootSvg() {
- return {
- paddingBottom: `${(Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth) || 0}%`,
- };
- },
+ },
+ watch: {
+ updateAspectRatio() {
+ if (this.updateAspectRatio) {
+ this.graphHeight = 450;
+ this.graphWidth = 600;
+ this.measurements = measurements.large;
+ this.draw();
+ eventHub.$emit('toggleAspectRatio');
+ }
},
+ hoverData() {
+ this.positionFlag();
+ },
+ },
+ mounted() {
+ this.draw();
+ },
+ methods: {
+ draw() {
+ const breakpointSize = bp.getBreakpointSize();
+ const query = this.graphData.queries[0];
+ this.margin = measurements.large.margin;
+ if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') {
+ this.graphHeight = 300;
+ this.margin = measurements.small.margin;
+ this.measurements = measurements.small;
+ }
+ this.unitOfDisplay = query.unit || '';
+ this.yAxisLabel = this.graphData.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;
+ this.baseGraphHeight = this.graphHeight - 50;
+ this.baseGraphWidth = this.graphWidth;
- methods: {
- draw() {
- const breakpointSize = bp.getBreakpointSize();
- const query = this.graphData.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.unitOfDisplay = query.unit || '';
- this.yAxisLabel = this.graphData.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;
- this.baseGraphHeight = this.graphHeight;
- this.baseGraphWidth = this.graphWidth;
- 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 firstTimeSeries = this.timeSeries[0];
- const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
- const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
- const d0 = firstTimeSeries.values[overlayIndex - 1];
- const d1 = firstTimeSeries.values[overlayIndex];
- if (d0 === undefined || d1 === undefined) return;
- const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
- const hoveredDataIndex = evalTime ? overlayIndex : (overlayIndex - 1);
- const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time;
- const currentDeployXPos = this.mouseOverDeployInfo(point.x);
-
- eventHub.$emit('hoverChanged', {
- hoveredDate,
- currentDeployXPos,
- });
- },
-
- renderAxesPaths() {
- this.timeSeries = createTimeSeries(
- this.graphData.queries,
- this.graphWidth,
- this.graphHeight,
- this.graphHeightOffset,
- );
-
- if (this.timeSeries.length > 3) {
- this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
- }
+ // pixel offsets inside the svg and outside are not 1:1
+ this.realPixelRatio = this.$refs.baseSvg.clientWidth / this.baseGraphWidth;
- const axisXScale = d3.scaleTime()
- .range([0, this.graphWidth - 70]);
- const axisYScale = d3.scaleLinear()
- .range([this.graphHeight - this.graphHeightOffset, 0]);
+ 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 firstTimeSeries = this.timeSeries[0];
+ const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
+ const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
+ const d0 = firstTimeSeries.values[overlayIndex - 1];
+ const d1 = firstTimeSeries.values[overlayIndex];
+ if (d0 === undefined || d1 === undefined) return;
+ const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
+ const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1;
+ const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time;
+ const currentDeployXPos = this.mouseOverDeployInfo(point.x);
- const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
- axisXScale.domain(d3.extent(allValues, d => d.time));
- axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
+ eventHub.$emit('hoverChanged', {
+ hoveredDate,
+ currentDeployXPos,
+ });
+ },
+ renderAxesPaths() {
+ this.timeSeries = createTimeSeries(
+ this.graphData.queries,
+ this.graphWidth,
+ this.graphHeight,
+ this.graphHeightOffset,
+ );
- const xAxis = d3.axisBottom()
- .scale(axisXScale)
- .tickFormat(timeScaleFormat);
+ if (_.findWhere(this.timeSeries, { renderCanary: true })) {
+ this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
+ }
- const yAxis = d3.axisLeft()
- .scale(axisYScale)
- .ticks(measurements.yTicks);
+ const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]);
+ const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]);
- d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis);
+ const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
+ axisXScale.domain(d3.extent(allValues, d => d.time));
+ axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
- 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
- },
- },
+ const xAxis = d3
+ .axisBottom()
+ .scale(axisXScale)
+ .ticks(this.graphWidth / 120)
+ .tickFormat(timeScaleFormat);
- watch: {
- updateAspectRatio() {
- if (this.updateAspectRatio) {
- this.graphHeight = 450;
- this.graphWidth = 600;
- this.measurements = measurements.large;
- this.draw();
- eventHub.$emit('toggleAspectRatio');
- }
- },
+ const yAxis = d3
+ .axisLeft()
+ .scale(axisYScale)
+ .ticks(measurements.yTicks);
- hoverData() {
- this.positionFlag();
- },
- },
+ d3
+ .select(this.$refs.baseSvg)
+ .select('.x-axis')
+ .call(xAxis);
- mounted() {
- this.draw();
+ 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
},
- };
+ },
+};
</script>
<template>
- <div
+ <div
class="prometheus-graph"
@mouseover="showFlagContent = true"
- @mouseleave="showFlagContent = false">
+ @mouseleave="showFlagContent = false"
+ >
<h5 class="text-center graph-title">
- {{graphData.title}}
+ {{ graphData.title }}
</h5>
<div
class="prometheus-svg-container"
- :style="paddingBottomRootSvg">
+ :style="paddingBottomRootSvg"
+ >
<svg
:viewBox="outerViewBox"
- ref="baseSvg">
+ ref="baseSvg"
+ >
<g
class="x-axis"
- :transform="axisTransform">
- </g>
+ :transform="axisTransform"
+ />
<g
class="y-axis"
- transform="translate(70, 20)">
- </g>
- <graph-legend
+ transform="translate(70, 20)"
+ />
+ <graph-axis
:graph-width="graphWidth"
:graph-height="graphHeight"
:margin="margin"
:measurements="measurements"
- :legend-title="legendTitle"
:y-axis-label="yAxisLabel"
- :time-series="timeSeries"
:unit-of-display="unitOfDisplay"
- :current-data-index="currentDataIndex"
/>
<svg
class="graph-data"
:viewBox="innerViewBox"
- ref="graphData">
- <graph-path
- v-for="(path, index) in timeSeries"
- :key="index"
- :generated-line-path="path.linePath"
- :generated-area-path="path.areaPath"
- :line-style="path.lineStyle"
- :line-color="path.lineColor"
- :area-color="path.areaColor"
- />
- <rect
- class="prometheus-graph-overlay"
- :width="(graphWidth - 70)"
- :height="(graphHeight - 100)"
- transform="translate(-5, 20)"
- ref="graphOverlay"
- @mousemove="handleMouseOverGraph($event)">
- </rect>
- <graph-deployment
- :show-deploy-info="showDeployInfo"
- :deployment-data="reducedDeploymentData"
- :graph-width="graphWidth"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- />
- <graph-flag
- v-if="showFlag"
- :current-x-coordinate="currentXCoordinate"
- :current-data="currentData"
- :current-flag-position="currentFlagPosition"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- :show-flag-content="showFlagContent"
- />
+ ref="graphData"
+ >
+ <graph-path
+ v-for="(path, index) in timeSeries"
+ :key="index"
+ :generated-line-path="path.linePath"
+ :generated-area-path="path.areaPath"
+ :line-style="path.lineStyle"
+ :line-color="path.lineColor"
+ :area-color="path.areaColor"
+ />
+ <graph-deployment
+ :deployment-data="reducedDeploymentData"
+ :graph-height="graphHeight"
+ :graph-height-offset="graphHeightOffset"
+ />
+ <rect
+ class="prometheus-graph-overlay"
+ :width="(graphWidth - 70)"
+ :height="(graphHeight - 100)"
+ transform="translate(-5, 20)"
+ ref="graphOverlay"
+ @mousemove="handleMouseOverGraph($event)"
+ />
</svg>
</svg>
+ <graph-flag
+ :real-pixel-ratio="realPixelRatio"
+ :current-x-coordinate="currentXCoordinate"
+ :current-data="currentData"
+ :graph-height="graphHeight"
+ :graph-height-offset="graphHeightOffset"
+ :show-flag-content="showFlagContent"
+ :time-series="timeSeries"
+ :unit-of-display="unitOfDisplay"
+ :current-data-index="currentDataIndex"
+ :legend-title="legendTitle"
+ :deployment-flag-data="deploymentFlagData"
+ />
</div>
+ <graph-legend
+ v-if="showLegend"
+ :legend-title="legendTitle"
+ :time-series="timeSeries"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue
new file mode 100644
index 00000000000..fc4b3689dfd
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/axis.vue
@@ -0,0 +1,142 @@
+<script>
+import { convertToSentenceCase } from '~/lib/utils/text_utility';
+import { s__ } from '~/locale';
+
+export default {
+ props: {
+ graphWidth: {
+ type: Number,
+ required: true,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ margin: {
+ type: Object,
+ required: true,
+ },
+ measurements: {
+ type: Object,
+ required: true,
+ },
+ yAxisLabel: {
+ type: String,
+ required: true,
+ },
+ unitOfDisplay: {
+ 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 +
+ this.measurements.axisLabelLineOffset) /
+ 2 +
+ this.yLabelWidth / 2 || 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
+ );
+ },
+
+ yAxisLabelSentenceCase() {
+ return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`;
+ },
+
+ timeString() {
+ return s__('PrometheusDashboard|Time');
+ },
+ },
+ 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
+ class="label-y-axis-line"
+ stroke="#000000"
+ stroke-width="1"
+ x1="10"
+ y1="0"
+ :x2="10"
+ :y2="yPosition"
+ />
+ <rect
+ class="rect-axis-text"
+ :transform="rectTransform"
+ :width="yLabelWidth"
+ :height="yLabelHeight"
+ />
+ <text
+ class="label-axis-text y-label-text"
+ text-anchor="middle"
+ :transform="textTransform"
+ ref="ylabel"
+ >
+ {{ yAxisLabelSentenceCase }}
+ </text>
+ <rect
+ class="rect-axis-text"
+ :x="xPosition + 60"
+ :y="graphHeight - 80"
+ width="35"
+ height="50"
+ />
+ <text
+ class="label-axis-text x-label-text"
+ :x="xPosition + 60"
+ :y="yPosition"
+ dy=".35em"
+ >
+ {{ timeString }}
+ </text>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue
index 026e2fd0c49..4012191ceb9 100644
--- a/app/assets/javascripts/monitoring/components/graph/deployment.vue
+++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue
@@ -1,196 +1,71 @@
<script>
- import { dateFormatWithName, timeFormat } from '../../utils/date_time_formatters';
- import Icon from '../../../vue_shared/components/icon.vue';
-
- export default {
- props: {
- showDeployInfo: {
- type: Boolean,
- required: true,
- },
- deploymentData: {
- type: Array,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- graphWidth: {
- type: Number,
- required: true,
- },
+export default {
+ props: {
+ deploymentData: {
+ type: Array,
+ required: true,
},
-
- components: {
- Icon,
+ graphHeight: {
+ type: Number,
+ required: true,
},
-
- computed: {
- calculatedHeight() {
- return this.graphHeight - this.graphHeightOffset;
- },
+ graphHeightOffset: {
+ type: Number,
+ required: true,
},
-
- methods: {
- refText(d) {
- return d.tag ? d.ref : d.sha.slice(0, 8);
- },
-
- formatTime(deploymentTime) {
- return timeFormat(deploymentTime);
- },
-
- formatDate(deploymentTime) {
- return dateFormatWithName(deploymentTime);
- },
-
- nameDeploymentClass(deployment) {
- return `deploy-info-${deployment.id}`;
- },
-
- transformDeploymentGroup(deployment) {
- return `translate(${Math.floor(deployment.xPos) + 1}, 20)`;
- },
-
- positionFlag(deployment) {
- let xPosition = 3;
- if (deployment.xPos > (this.graphWidth - 225)) {
- xPosition = -142;
- }
- return xPosition;
- },
-
- svgContainerHeight(tag) {
- let svgHeight = 80;
- if (!tag) {
- svgHeight -= 20;
- }
- return svgHeight;
- },
+ },
+ computed: {
+ calculatedHeight() {
+ return this.graphHeight - this.graphHeightOffset;
},
- };
+ },
+ methods: {
+ transformDeploymentGroup(deployment) {
+ return `translate(${Math.floor(deployment.xPos) - 5}, 20)`;
+ },
+ },
+};
</script>
<template>
- <g
- class="deploy-info"
- v-if="showDeployInfo">
+ <g class="deploy-info">
<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>
+ fill="url(#shadow-gradient)"
+ />
<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="positionFlag(deployment)"
- y="0"
- width="134"
- :height="svgContainerHeight(deployment.tag)">
- <rect
- class="rect-text-metric deploy-info-rect rect-metric"
- x="1"
- y="1"
- rx="2"
- width="132"
- :height="svgContainerHeight(deployment.tag) - 2">
- </rect>
- <text
- class="deploy-info-text text-metric-bold"
- transform="translate(5, 2)">
- Deployed
- </text>
- <!--The date info-->
- <g transform="translate(5, 20)">
- <text class="deploy-info-text">
- {{formatDate(deployment.time)}}
- </text>
- <text
- class="deploy-info-text text-metric-bold"
- x="62">
- {{formatTime(deployment.time)}}
- </text>
- </g>
- <line
- class="divider-line"
- x1="0"
- y1="38"
- x2="132"
- :y2="38"
- stroke="#000">
- </line>
- <!--Commit information-->
- <g transform="translate(5, 40)">
- <icon
- name="commit"
- :width="12"
- :height="12"
- :y="3">
- </icon>
- <a :xlink:href="deployment.commitUrl">
- <text
- class="deploy-info-text deploy-info-text-link"
- transform="translate(20, 2)">
- {{refText(deployment)}}
- </text>
- </a>
- </g>
- <!--Tag information-->
- <g
- transform="translate(5, 55)"
- v-if="deployment.tag">
- <icon
- name="label"
- :width="12"
- :height="12"
- :y="5">
- </icon>
- <a :xlink:href="deployment.tagUrl">
- <text
- class="deploy-info-text deploy-info-text-link"
- transform="translate(20, 2)"
- y="2">
- {{deployment.tag}}
- </text>
- </a>
- </g>
- </svg>
+ stroke="#000"
+ />
</g>
<svg
height="0"
- width="0">
+ width="0"
+ >
<defs>
<linearGradient
- id="shadow-gradient">
+ id="shadow-gradient"
+ >
<stop
offset="0%"
stop-color="#000"
- stop-opacity="0.4">
- </stop>
+ stop-opacity="0.4"
+ />
<stop
offset="100%"
stop-color="#000"
- stop-opacity="0">
- </stop>
+ stop-opacity="0"
+ />
</linearGradient>
</defs>
</svg>
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
index 10fb7ff6803..b8202e25685 100644
--- a/app/assets/javascripts/monitoring/components/graph/flag.vue
+++ b/app/assets/javascripts/monitoring/components/graph/flag.vue
@@ -1,93 +1,175 @@
<script>
- import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
+import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
+import { formatRelevantDigits } from '../../../lib/utils/number_utils';
+import Icon from '../../../vue_shared/components/icon.vue';
+import TrackLine from './track_line.vue';
- export default {
- props: {
- currentXCoordinate: {
- type: Number,
- required: true,
- },
- currentFlagPosition: {
- type: Number,
- required: true,
- },
- currentData: {
- type: Object,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- showFlagContent: {
- type: Boolean,
- required: true,
- },
+export default {
+ components: {
+ Icon,
+ TrackLine,
+ },
+ props: {
+ currentXCoordinate: {
+ type: Number,
+ required: true,
},
+ currentData: {
+ type: Object,
+ required: true,
+ },
+ deploymentFlagData: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ graphHeight: {
+ type: Number,
+ required: true,
+ },
+ graphHeightOffset: {
+ type: Number,
+ required: true,
+ },
+ realPixelRatio: {
+ type: Number,
+ required: true,
+ },
+ showFlagContent: {
+ type: Boolean,
+ required: true,
+ },
+ timeSeries: {
+ type: Array,
+ required: true,
+ },
+ unitOfDisplay: {
+ type: String,
+ required: true,
+ },
+ currentDataIndex: {
+ type: Number,
+ required: true,
+ },
+ legendTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ formatTime() {
+ return this.deploymentFlagData
+ ? timeFormat(this.deploymentFlagData.time)
+ : timeFormat(this.currentData.time);
+ },
+ formatDate() {
+ return this.deploymentFlagData
+ ? dateFormat(this.deploymentFlagData.time)
+ : dateFormat(this.currentData.time);
+ },
+ cursorStyle() {
+ const xCoordinate = this.deploymentFlagData
+ ? this.deploymentFlagData.xPos
+ : this.currentXCoordinate;
+
+ const offsetTop = 20 * this.realPixelRatio;
+ const offsetLeft = (70 + xCoordinate) * this.realPixelRatio;
+ const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio;
- data() {
return {
- circleColorRgb: '#8fbce8',
+ top: `${offsetTop}px`,
+ left: `${offsetLeft}px`,
+ height: `${height}px`,
};
},
-
- computed: {
- formatTime() {
- return timeFormat(this.currentData.time);
- },
-
- formatDate() {
- return dateFormat(this.currentData.time);
- },
-
- calculatedHeight() {
- return this.graphHeight - this.graphHeightOffset;
- },
+ flagOrientation() {
+ if (this.currentXCoordinate * this.realPixelRatio > 120) {
+ return 'left';
+ }
+ return 'right';
+ },
+ },
+ methods: {
+ seriesMetricValue(series) {
+ const index = this.deploymentFlagData
+ ? this.deploymentFlagData.seriesIndex
+ : this.currentDataIndex;
+ const value = series.values[index] && series.values[index].value;
+ if (isNaN(value)) {
+ return '-';
+ }
+ return `${formatRelevantDigits(value)}${this.unitOfDisplay}`;
},
- };
+ seriesMetricLabel(index, series) {
+ if (this.timeSeries.length < 2) {
+ return this.legendTitle;
+ }
+ if (series.metricTag) {
+ return series.metricTag;
+ }
+ return `series ${index + 1}`;
+ },
+ },
+};
</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>
- <svg
+ <div
+ class="prometheus-graph-cursor"
+ :style="cursorStyle"
+ >
+ <div
v-if="showFlagContent"
- 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>
+ class="prometheus-graph-flag popover"
+ :class="flagOrientation"
+ >
+ <div class="arrow"></div>
+ <div class="popover-title">
+ <h5 v-if="deploymentFlagData">
+ Deployed
+ </h5>
+ {{ formatDate }} at
+ <strong>{{ formatTime }}</strong>
+ </div>
+ <div
+ v-if="deploymentFlagData"
+ class="popover-content deploy-meta-content"
+ >
+ <div>
+ <icon
+ name="commit"
+ :size="12"
+ />
+ <a :href="deploymentFlagData.commitUrl">
+ {{ deploymentFlagData.sha.slice(0, 8) }}
+ </a>
+ </div>
+ <div
+ v-if="deploymentFlagData.tag"
+ >
+ <icon
+ name="label"
+ :size="12"
+ />
+ <a :href="deploymentFlagData.tagUrl">
+ {{ deploymentFlagData.ref }}
+ </a>
+ </div>
+ </div>
+ <div class="popover-content">
+ <table class="prometheus-table">
+ <tr
+ v-for="(series, index) in timeSeries"
+ :key="index"
+ >
+ <track-line :track="series"/>
+ <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
+ <td>
+ <strong>{{ seriesMetricValue(series) }}</strong>
+ </td>
+ </tr>
+ </table>
+ </div>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue
index 440b1b12631..da9280cf1f1 100644
--- a/app/assets/javascripts/monitoring/components/graph/legend.vue
+++ b/app/assets/javascripts/monitoring/components/graph/legend.vue
@@ -1,199 +1,72 @@
<script>
- import { formatRelevantDigits } from '../../../lib/utils/number_utils';
+import TrackLine from './track_line.vue';
+import TrackInfo from './track_info.vue';
- export default {
- props: {
- graphWidth: {
- type: Number,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- margin: {
- type: Object,
- required: true,
- },
- measurements: {
- type: Object,
- required: true,
- },
- legendTitle: {
- type: String,
- required: true,
- },
- yAxisLabel: {
- type: String,
- required: true,
- },
- timeSeries: {
- type: Array,
- required: true,
- },
- unitOfDisplay: {
- type: String,
- required: true,
- },
- currentDataIndex: {
- type: Number,
- required: true,
- },
+export default {
+ components: {
+ TrackLine,
+ TrackInfo,
+ },
+ props: {
+ legendTitle: {
+ type: String,
+ required: true,
},
- data() {
+ timeSeries: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ isStable(track) {
return {
- yLabelWidth: 0,
- yLabelHeight: 0,
- seriesXPosition: 0,
- metricUsageXPosition: 0,
+ 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
};
},
- 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;
- },
-
- },
- methods: {
- translateLegendGroup(index) {
- return `translate(0, ${12 * (index)})`;
- },
-
- formatMetricUsage(series) {
- const value = series.values[this.currentDataIndex] &&
- series.values[this.currentDataIndex].value;
- if (isNaN(value)) {
- return '-';
- }
- return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`;
- },
-
- createSeriesString(index, series) {
- if (series.metricTag) {
- return `${series.metricTag} ${this.formatMetricUsage(series)}`;
- }
- return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`;
- },
-
- strokeDashArray(type) {
- if (type === 'dashed') return '6, 3';
- if (type === 'dotted') return '3, 3';
- return null;
- },
- },
- mounted() {
- this.$nextTick(() => {
- const bbox = this.$refs.ylabel.getBBox();
- this.metricUsageXPosition = 0;
- this.seriesXPosition = 0;
- if (this.$refs.legendTitleSvg != null) {
- this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width;
- }
- if (this.$refs.seriesTitleSvg != null) {
- this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width;
- }
- 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>
- <g class="legend-group"
- v-for="(series, index) in timeSeries"
- :key="index"
- :transform="translateLegendGroup(index)">
- <line
- :stroke="series.lineColor"
- :stroke-width="measurements.legends.height"
- :stroke-dasharray="strokeDashArray(series.lineStyle)"
- :x1="measurements.legends.offsetX"
- :x2="measurements.legends.offsetX + measurements.legends.width"
- :y1="graphHeight - measurements.legends.offsetY"
- :y2="graphHeight - measurements.legends.offsetY">
- </line>
- <text
- v-if="timeSeries.length > 1"
- class="legend-metric-title"
- ref="legendTitleSvg"
- x="38"
- :y="graphHeight - 30">
- {{createSeriesString(index, series)}}
- </text>
- <text
- v-else
- class="legend-metric-title"
- ref="legendTitleSvg"
- x="38"
- :y="graphHeight - 30">
- {{legendTitle}} {{formatMetricUsage(series)}}
- </text>
- </g>
- </g>
+ <div class="prometheus-graph-legends prepend-left-10">
+ <table class="prometheus-table">
+ <tr
+ v-for="(series, index) in timeSeries"
+ :key="index"
+ v-if="series.shouldRenderLegend"
+ :class="isStable(series)"
+ >
+ <td>
+ <strong v-if="series.renderCanary">{{ series.trackName }}</strong>
+ </td>
+ <track-line :track="series" />
+ <td
+ class="legend-metric-title"
+ v-if="timeSeries.length > 1">
+ <track-info
+ :track="series"
+ v-if="series.metricTag" />
+ <track-info
+ v-else
+ :track="series">
+ <strong>{{ legendTitle }}</strong> series {{ index + 1 }}
+ </track-info>
+ </td>
+ <td v-else>
+ <track-info :track="series">
+ <strong>{{ legendTitle }}</strong>
+ </track-info>
+ </td>
+ <template v-for="(track, trackIndex) in series.tracksLegend">
+ <track-line
+ :track="track"
+ :key="`track-line-${trackIndex}`"/>
+ <td :key="`track-info-${trackIndex}`">
+ <track-info
+ class="legend-metric-title"
+ :track="track" />
+ </td>
+ </template>
+ </tr>
+ </table>
+ </div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue
index 5e6d409033a..881560124a5 100644
--- a/app/assets/javascripts/monitoring/components/graph/path.vue
+++ b/app/assets/javascripts/monitoring/components/graph/path.vue
@@ -1,35 +1,36 @@
<script>
- export default {
- props: {
- generatedLinePath: {
- type: String,
- required: true,
- },
- generatedAreaPath: {
- type: String,
- required: true,
- },
- lineStyle: {
- type: String,
- required: false,
- },
- lineColor: {
- type: String,
- required: true,
- },
- areaColor: {
- type: String,
- required: true,
- },
+export default {
+ props: {
+ generatedLinePath: {
+ type: String,
+ required: true,
},
- computed: {
- strokeDashArray() {
- if (this.lineStyle === 'dashed') return '3, 1';
- if (this.lineStyle === 'dotted') return '1, 1';
- return null;
- },
+ generatedAreaPath: {
+ type: String,
+ required: true,
},
- };
+ lineStyle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lineColor: {
+ type: String,
+ required: true,
+ },
+ areaColor: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ strokeDashArray() {
+ if (this.lineStyle === 'dashed') return '3, 1';
+ if (this.lineStyle === 'dotted') return '1, 1';
+ return null;
+ },
+ },
+};
</script>
<template>
<g>
@@ -37,8 +38,8 @@
class="metric-area"
:d="generatedAreaPath"
:fill="areaColor"
- transform="translate(-5, 20)">
- </path>
+ transform="translate(-5, 20)"
+ />
<path
class="metric-line"
:d="generatedLinePath"
@@ -46,7 +47,7 @@
fill="none"
stroke-width="1"
:stroke-dasharray="strokeDashArray"
- transform="translate(-5, 20)">
- </path>
+ transform="translate(-5, 20)"
+ />
</g>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue
new file mode 100644
index 00000000000..ec1c2222af9
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/track_info.vue
@@ -0,0 +1,29 @@
+<script>
+import { formatRelevantDigits } from '~/lib/utils/number_utils';
+
+export default {
+ name: 'TrackInfo',
+ props: {
+ track: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ summaryMetrics() {
+ return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits(
+ this.track.max,
+ )}`;
+ },
+ },
+};
+</script>
+<template>
+ <span>
+ <slot>
+ <strong> {{ track.metricTag }} </strong>
+ </slot>
+ {{ summaryMetrics }}
+ </span>
+</template>
+
diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue
new file mode 100644
index 00000000000..79b322e2e42
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/graph/track_line.vue
@@ -0,0 +1,36 @@
+<script>
+export default {
+ name: 'TrackLine',
+ props: {
+ track: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ stylizedLine() {
+ if (this.track.lineStyle === 'dashed') return '6, 3';
+ if (this.track.lineStyle === 'dotted') return '3, 3';
+ return null;
+ },
+ },
+};
+</script>
+<template>
+ <td>
+ <svg
+ width="15"
+ height="6">
+ <line
+ :stroke-dasharray="stylizedLine"
+ :stroke="track.lineColor"
+ stroke-width="4"
+ :x1="0"
+ :x2="15"
+ :y1="2"
+ :y2="2"
+ />
+ </svg>
+ </td>
+</template>
+
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 958f537d31b..a6dbe42a8f0 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -5,17 +5,31 @@ export default {
type: String,
required: true,
},
+ showPanels: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
};
</script>
<template>
- <div class="panel panel-default prometheus-panel">
+ <div
+ v-if="showPanels"
+ class="panel panel-default prometheus-panel"
+ >
<div class="panel-heading">
- <h4>{{name}}</h4>
+ <h4>{{ name }}</h4>
</div>
<div class="panel-body prometheus-graph-group">
- <slot />
+ <slot></slot>
</div>
</div>
+ <div
+ v-else
+ class="prometheus-graph-group"
+ >
+ <slot></slot>
+ </div>
</template>
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
index cbca14ede02..6cc67ba57ee 100644
--- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
+++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
@@ -29,15 +29,18 @@ const mixins = {
time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
if (xPos >= 0) {
+ const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1);
+
deploymentDataArray.push({
id: deployment.id,
time,
sha: deployment.sha,
commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
tag: deployment.tag,
- tagUrl: `${this.tagsPath}/${deployment.tag}`,
+ tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
ref: deployment.ref.name,
xPos,
+ seriesIndex,
showDeploymentFlag: false,
});
}
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 104432ef5de..41270e015d4 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,7 +1,22 @@
import Vue from 'vue';
+import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
import Dashboard from './components/dashboard.vue';
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#prometheus-graphs',
- render: createElement => createElement(Dashboard),
-}));
+export default () => {
+ const el = document.getElementById('prometheus-graphs');
+
+ if (el && el.dataset) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(Dashboard, {
+ props: {
+ ...el.dataset,
+ hasMetrics: convertPermissionToBoolean(el.dataset.hasMetrics),
+ },
+ });
+ },
+ });
+ }
+};
diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js
index e230a06cd8c..6fcca36d2fa 100644
--- a/app/assets/javascripts/monitoring/services/monitoring_service.js
+++ b/app/assets/javascripts/monitoring/services/monitoring_service.js
@@ -40,6 +40,9 @@ export default class MonitoringService {
}
getDeploymentData() {
+ if (!this.deploymentEndpoint) {
+ return Promise.resolve([]);
+ }
return backOffRequest(() => axios.get(this.deploymentEndpoint))
.then(resp => resp.data)
.then((response) => {
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 854636e9a89..535c415cd6d 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -1,7 +1,7 @@
import _ from 'underscore';
function sortMetrics(metrics) {
- return _.chain(metrics).sortBy('weight').sortBy('title').value();
+ return _.chain(metrics).sortBy('title').sortBy('weight').value();
}
function normalizeMetrics(metrics) {
diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
index 48bdec1e030..f3c9acdd93e 100644
--- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js
+++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
@@ -1,10 +1,20 @@
import { timeFormat as time } from 'd3-time-format';
-import { timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear } from 'd3-time';
+import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time';
import { bisector } from 'd3-array';
-const d3 = { time, bisector, timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear };
+const d3 = {
+ time,
+ bisector,
+ timeSecond,
+ timeMinute,
+ timeHour,
+ timeDay,
+ timeWeek,
+ timeMonth,
+ timeYear,
+};
-export const dateFormat = d3.time('%b %-d, %Y');
+export const dateFormat = d3.time('%a, %b %-d');
export const timeFormat = d3.time('%-I:%M%p');
export const dateFormatWithName = d3.time('%a, %b %-d');
export const bisectDate = d3.bisector(d => d.time).left;
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
index 4ce3dad440c..8a93c7e6bae 100644
--- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js
+++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
@@ -1,10 +1,21 @@
import _ from 'underscore';
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, area, curveLinear } from 'd3-shape';
-import { extent, max } from 'd3-array';
+import { extent, max, sum } from 'd3-array';
import { timeMinute } from 'd3-time';
-
-const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute };
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+const d3 = {
+ scaleLinear,
+ scaleTime,
+ line,
+ area,
+ curveLinear,
+ extent,
+ max,
+ timeMinute,
+ sum,
+};
const defaultColorPalette = {
blue: ['#1f78d1', '#8fbce8'],
@@ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
let usedColors = [];
+ let renderCanary = false;
+ const timeSeriesParsed = [];
function pickColor(name) {
let pick;
@@ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
return defaultColorPalette[pick];
}
- return query.result.map((timeSeries, timeSeriesNumber) => {
+ query.result.forEach((timeSeries, timeSeriesNumber) => {
let metricTag = '';
let lineColor = '';
let areaColor = '';
+ let shouldRenderLegend = true;
+ const timeSeriesValues = timeSeries.values.map(d => d.value);
+ const maximumValue = d3.max(timeSeriesValues);
+ const accum = d3.sum(timeSeriesValues);
+ const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable');
+
+ if (trackName === 'Canary') {
+ renderCanary = true;
+ }
- const timeSeriesScaleX = d3.scaleTime()
- .range([0, graphWidth - 70]);
+ const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
- const timeSeriesScaleY = d3.scaleLinear()
- .range([graphHeight - graphHeightOffset, 0]);
+ const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.timeMinute, 60);
@@ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
const defined = d => !isNaN(d.value) && d.value != null;
- const lineFunction = d3.line()
+ const lineFunction = d3
+ .line()
.defined(defined)
.curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
.x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value));
- const areaFunction = d3.area()
+ const areaFunction = d3
+ .area()
.defined(defined)
.curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time))
@@ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
.y1(d => timeSeriesScaleY(d.value));
const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
- const seriesCustomizationData = query.series != null &&
- _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
+ const seriesCustomizationData =
+ query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
if (seriesCustomizationData) {
metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
[lineColor, areaColor] = pickColor(seriesCustomizationData.color);
+ shouldRenderLegend = false;
} else {
- metricTag = timeSeriesMetricLabel || `series ${timeSeriesNumber + 1}`;
+ metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
[lineColor, areaColor] = pickColor();
+ if (timeSeriesParsed.length > 1) {
+ shouldRenderLegend = false;
+ }
}
- if (query.track) {
- metricTag += ` - ${query.track}`;
+ if (!shouldRenderLegend) {
+ if (!timeSeriesParsed[0].tracksLegend) {
+ timeSeriesParsed[0].tracksLegend = [];
+ }
+ timeSeriesParsed[0].tracksLegend.push({
+ max: maximumValue,
+ average: accum / timeSeries.values.length,
+ lineStyle,
+ lineColor,
+ metricTag,
+ });
}
- return {
+ timeSeriesParsed.push({
linePath: lineFunction(timeSeries.values),
areaPath: areaFunction(timeSeries.values),
timeSeriesScaleX,
values: timeSeries.values,
+ max: maximumValue,
+ average: accum / timeSeries.values.length,
lineStyle,
lineColor,
areaColor,
metricTag,
- };
+ trackName,
+ shouldRenderLegend,
+ renderCanary,
+ });
});
+
+ return timeSeriesParsed;
}
export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
- const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat(
- query.result.reduce((allResults, result) => allResults.concat(result.values), []),
- ), []);
+ const allValues = queries.reduce(
+ (allQueryResults, query) =>
+ allQueryResults.concat(
+ query.result.reduce((allResults, result) => allResults.concat(result.values), []),
+ ),
+ [],
+ );
const xDom = d3.extent(allValues, d => d.time);
const yDom = [0, d3.max(allValues.map(d => d.value))];
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
new file mode 100644
index 00000000000..e3c5bf06b3d
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import notesApp from '../notes/components/notes_app.vue';
+import discussionCounter from '../notes/components/discussion_counter.vue';
+import store from '../notes/stores';
+
+export default function initMrNotes() {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-vue-mr-discussions',
+ components: {
+ notesApp,
+ },
+ data() {
+ const notesDataset = document.getElementById('js-vue-mr-discussions')
+ .dataset;
+ const noteableData = JSON.parse(notesDataset.noteableData);
+ noteableData.noteableType = notesDataset.noteableType;
+
+ return {
+ noteableData,
+ currentUserData: JSON.parse(notesDataset.currentUserData),
+ notesData: JSON.parse(notesDataset.notesData),
+ };
+ },
+ render(createElement) {
+ return createElement('notes-app', {
+ props: {
+ noteableData: this.noteableData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ },
+ });
+ },
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-vue-discussion-counter',
+ components: {
+ discussionCounter,
+ },
+ store,
+ render(createElement) {
+ return createElement('discussion-counter');
+ },
+ });
+}
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index aa377327107..c7a8aac79df 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,4 +1,6 @@
/* eslint-disable func-names, space-before-function-paren, no-var, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, max-len */
+
+import $ from 'jquery';
import Api from './api';
import { mergeUrlParams } from './lib/utils/url_utility';
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index 5aad3908eb6..bd007c707f2 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -1,5 +1,9 @@
/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, quotes, comma-dangle, one-var, one-var-declaration-per-line, no-mixed-operators, no-loop-func, no-floating-decimal, consistent-return, no-unused-vars, prefer-template, prefer-arrow-callback, camelcase, max-len */
+import $ from 'jquery';
+import { __ } from '../locale';
+import axios from '../lib/utils/axios_utils';
+import flash from '../flash';
import Raphael from './raphael';
export default (function() {
@@ -26,16 +30,13 @@ export default (function() {
}
BranchGraph.prototype.load = function() {
- return $.ajax({
- url: this.options.url,
- method: "get",
- dataType: "json",
- success: $.proxy(function(data) {
+ axios.get(this.options.url)
+ .then(({ data }) => {
$(".loading", this.element).hide();
this.prepareData(data.days, data.commits);
- return this.buildGraph();
- }, this)
- });
+ this.buildGraph();
+ })
+ .catch(() => __('Error fetching network graph.'));
};
BranchGraph.prototype.prepareData = function(days, commits) {
diff --git a/app/assets/javascripts/network/network.js b/app/assets/javascripts/network/network.js
deleted file mode 100644
index a3fd22aff2a..00000000000
--- a/app/assets/javascripts/network/network.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */
-
-import BranchGraph from './branch_graph';
-
-export default (function() {
- function Network(opts) {
- var vph;
- $("#filter_ref").click(function() {
- return $(this).closest('form').submit();
- });
- this.branch_graph = new BranchGraph($(".network-graph"), opts);
- vph = $(window).height() - 250;
- $('.network-graph').css({
- 'height': vph + 'px'
- });
- }
-
- return Network;
-})();
diff --git a/app/assets/javascripts/network/network_bundle.js b/app/assets/javascripts/network/network_bundle.js
deleted file mode 100644
index 129f1724cb8..00000000000
--- a/app/assets/javascripts/network/network_bundle.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, camelcase, comma-dangle, consistent-return, max-len */
-
-import ShortcutsNetwork from '../shortcuts_network';
-import Network from './network';
-
-$(function() {
- if (!$(".network-graph").length) return;
-
- var network_graph;
- network_graph = new Network({
- url: $(".network-graph").attr('data-url'),
- commit_url: $(".network-graph").attr('data-commit-url'),
- ref: $(".network-graph").attr('data-ref'),
- commit_id: $(".network-graph").attr('data-commit-id')
- });
- return new ShortcutsNetwork(network_graph.branch_graph);
-});
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 77733b67c4d..40c08ee0ace 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,6 @@
/* 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 */
+
+import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
export default class NewBranchForm {
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 82c51a1068c..3d09d24b6ab 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,6 +1,7 @@
<script>
/* global katex */
import marked from 'marked';
+ import sanitize from 'sanitize-html';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
@@ -82,7 +83,12 @@
},
computed: {
markdown() {
- return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
+ return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), {
+ allowedTags: false,
+ allowedAttributes: {
+ '*': ['class'],
+ },
+ });
},
},
};
@@ -91,18 +97,21 @@
<template>
<div class="cell text-cell">
<prompt />
- <div class="markdown" v-html="markdown"></div>
+ <div
+ class="markdown"
+ v-html="markdown">
+ </div>
</div>
</template>
<style>
-.markdown .katex {
- display: block;
- text-align: center;
-}
+ .markdown .katex {
+ display: block;
+ text-align: center;
+ }
-.markdown .inline-katex .katex {
- display: inline;
- text-align: initial;
-}
+ .markdown .inline-katex .katex {
+ display: inline;
+ text-align: initial;
+ }
</style>
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index 2110a9de7ed..0535ee7afa8 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -1,22 +1,35 @@
<script>
-import Prompt from '../prompt.vue';
+ import sanitize from 'sanitize-html';
+ import Prompt from '../prompt.vue';
-export default {
- props: {
- rawCode: {
- type: String,
- required: true,
+ export default {
+ components: {
+ prompt: Prompt,
},
- },
- components: {
- prompt: Prompt,
- },
-};
+ props: {
+ rawCode: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ sanitizedOutput() {
+ return sanitize(this.rawCode, {
+ allowedTags: sanitize.defaults.allowedTags.concat([
+ 'img', 'svg',
+ ]),
+ allowedAttributes: {
+ img: ['src'],
+ },
+ });
+ },
+ },
+ };
</script>
<template>
<div class="output">
<prompt />
- <div v-html="rawCode"></div>
+ <div v-html="sanitizedOutput"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue
index fbb39ea6e2d..67d6c5ad12b 100644
--- a/app/assets/javascripts/notebook/cells/output/image.vue
+++ b/app/assets/javascripts/notebook/cells/output/image.vue
@@ -1,27 +1,26 @@
<script>
-import Prompt from '../prompt.vue';
+ import Prompt from '../prompt.vue';
-export default {
- props: {
- outputType: {
- type: String,
- required: true,
+ export default {
+ components: {
+ prompt: Prompt,
},
- rawCode: {
- type: String,
- required: true,
+ props: {
+ outputType: {
+ type: String,
+ required: true,
+ },
+ rawCode: {
+ type: String,
+ required: true,
+ },
},
- },
- components: {
- prompt: Prompt,
- },
-};
+ };
</script>
<template>
<div class="output">
<prompt />
- <img
- :src="'data:' + outputType + ';base64,' + rawCode" />
+ <img :src="'data:' + outputType + ';base64,' + rawCode" />
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 05af0bf1e8e..91b2269a83a 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -1,83 +1,87 @@
<script>
-import CodeCell from '../code/index.vue';
-import Html from './html.vue';
-import Image from './image.vue';
+ import CodeCell from '../code/index.vue';
+ import Html from './html.vue';
+ import Image from './image.vue';
-export default {
- props: {
- codeCssClass: {
- type: String,
- required: false,
- default: '',
+ export default {
+ components: {
+ 'code-cell': CodeCell,
+ 'html-output': Html,
+ 'image-output': Image,
},
- count: {
- type: Number,
- required: false,
- default: 0,
+ props: {
+ codeCssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ count: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ output: {
+ type: Object,
+ requred: true,
+ default: () => ({}),
+ },
},
- output: {
- type: Object,
- requred: true,
- },
- },
- components: {
- 'code-cell': CodeCell,
- 'html-output': Html,
- 'image-output': Image,
- },
- data() {
- return {
- outputType: '',
- };
- },
- computed: {
- componentName() {
- if (this.output.text) {
- return 'code-cell';
- } else if (this.output.data['image/png']) {
- this.outputType = 'image/png';
-
- return 'image-output';
- } else if (this.output.data['text/html']) {
- this.outputType = 'text/html';
+ computed: {
+ componentName() {
+ if (this.output.text) {
+ return 'code-cell';
+ } else if (this.output.data['image/png']) {
+ return 'image-output';
+ } else if (this.output.data['text/html']) {
+ return 'html-output';
+ } else if (this.output.data['image/svg+xml']) {
+ return 'html-output';
+ }
- return 'html-output';
- } else if (this.output.data['image/svg+xml']) {
- this.outputType = 'image/svg+xml';
-
- return 'html-output';
- }
+ return 'code-cell';
+ },
+ rawCode() {
+ if (this.output.text) {
+ return this.output.text.join('');
+ }
- this.outputType = 'text/plain';
- return 'code-cell';
- },
- rawCode() {
- if (this.output.text) {
- return this.output.text.join('');
- }
+ return this.dataForType(this.outputType);
+ },
+ outputType() {
+ if (this.output.text) {
+ return '';
+ } else if (this.output.data['image/png']) {
+ return 'image/png';
+ } else if (this.output.data['text/html']) {
+ return 'text/html';
+ } else if (this.output.data['image/svg+xml']) {
+ return 'image/svg+xml';
+ }
- return this.dataForType(this.outputType);
+ return 'text/plain';
+ },
},
- },
- methods: {
- dataForType(type) {
- let data = this.output.data[type];
+ methods: {
+ dataForType(type) {
+ let data = this.output.data[type];
- if (typeof data === 'object') {
- data = data.join('');
- }
+ if (typeof data === 'object') {
+ data = data.join('');
+ }
- return data;
+ return data;
+ },
},
- },
-};
+ };
</script>
<template>
- <component :is="componentName"
+ <component
+ :is="componentName"
type="output"
- :outputType="outputType"
+ :output-type="outputType"
:count="count"
:raw-code="rawCode"
- :code-css-class="codeCssClass" />
+ :code-css-class="codeCssClass"
+ />
</template>
diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue
index 039fb99293d..fe1fc37e1dc 100644
--- a/app/assets/javascripts/notebook/cells/prompt.vue
+++ b/app/assets/javascripts/notebook/cells/prompt.vue
@@ -4,10 +4,17 @@
type: {
type: String,
required: false,
+ default: '',
},
count: {
type: Number,
required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ hasKeys() {
+ return this.type !== '' && this.count;
},
},
};
@@ -15,16 +22,16 @@
<template>
<div class="prompt">
- <span v-if="type && count">
+ <span v-if="hasKeys">
{{ type }} [{{ count }}]:
</span>
</div>
</template>
<style scoped>
-.prompt {
- padding: 0 10px;
- min-width: 7em;
- font-family: monospace;
-}
+ .prompt {
+ padding: 0 10px;
+ min-width: 7em;
+ font-family: monospace;
+ }
</style>
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index e88806431af..e2e3b08c77f 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -20,11 +20,6 @@
default: '',
},
},
- methods: {
- cellType(type) {
- return `${type}-cell`;
- },
- },
computed: {
cells() {
if (this.notebook.worksheets) {
@@ -45,6 +40,11 @@
return Object.keys(this.notebook).length;
},
},
+ methods: {
+ cellType(type) {
+ return `${type}-cell`;
+ },
+ },
};
</script>
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index a2b8e6f6495..ac70ddb3ff4 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -16,6 +16,11 @@ import Autosize from 'autosize';
import 'vendor/jquery.caret'; // required by jquery.atwho
import 'vendor/jquery.atwho';
import AjaxCache from '~/lib/utils/ajax_cache';
+import Vue from 'vue';
+import syntaxHighlight from '~/syntax_highlight';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import { __ } from '~/locale';
+import axios from './lib/utils/axios_utils';
import { getLocationHash } from './lib/utils/url_utility';
import Flash from './flash';
import CommentTypeToggle from './comment_type_toggle';
@@ -23,7 +28,13 @@ import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler';
import Autosave from './autosave';
import TaskList from './task_list';
-import { ajaxPost, isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils';
+import {
+ isInViewport,
+ getPagePath,
+ scrollToElement,
+ isMetaKey,
+ hasVueMRDiscussionsCookie,
+} from './lib/utils/common_utils';
import imageDiffHelper from './image_diff/helpers/index';
import { localTimeAgo } from './lib/utils/datetime_utility';
@@ -37,12 +48,28 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export default class Notes {
- static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
+ static initialize(
+ notes_url,
+ note_ids,
+ last_fetched_at,
+ view,
+ enableGFM = true,
+ ) {
if (!this.instance) {
- this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM);
+ this.instance = new Notes(
+ notes_url,
+ note_ids,
+ last_fetched_at,
+ view,
+ enableGFM,
+ );
}
}
+ static getInstance() {
+ return this.instance;
+ }
+
constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this);
@@ -73,10 +100,14 @@ export default class Notes {
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.notesCountBadge ||
+ (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
+ this.$wrapperEl = hasVueMRDiscussionsCookie()
+ ? $(document).find('.diffs')
+ : $(document);
this.cleanBinding();
this.addBinding();
this.setPollingInterval();
@@ -84,15 +115,24 @@ export default class Notes {
this.taskList = new TaskList({
dataType: 'note',
fieldName: 'note',
- selector: '.notes'
+ selector: '.notes',
});
this.collapseLongCommitList();
this.setViewType(view);
// We are in the Merge Requests page so we need another edit form for Changes tab
if (getPagePath(1) === 'merge_requests') {
- $('.note-edit-form').clone()
- .addClass('mr-note-edit-form').insertAfter('.note-edit-form');
+ $('.note-edit-form')
+ .clone()
+ .addClass('mr-note-edit-form')
+ .insertAfter('.note-edit-form');
+ }
+
+ const hash = getLocationHash();
+ const $anchor = hash && document.getElementById(hash);
+
+ if ($anchor) {
+ this.loadLazyDiff({ currentTarget: $anchor });
}
}
@@ -102,74 +142,126 @@ export default class Notes {
addBinding() {
// Edit note link
- $(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
- $(document).on('click', '.note-edit-cancel', this.cancelEdit);
+ this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
+ this.$wrapperEl.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);
+ this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment);
+ this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
+ this.$wrapperEl.on(
+ 'keyup input',
+ '.js-note-text',
+ this.updateTargetButtons,
+ );
// resolve a discussion
- $(document).on('click', '.js-comment-resolve-button', this.postComment);
+ this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
- $(document).on('click', '.js-note-delete', this.removeNote);
+ this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
// delete note attachment
- $(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
+ this.$wrapperEl.on(
+ 'click',
+ '.js-note-attachment-delete',
+ this.removeAttachment,
+ );
// reset main target form when clicking discard
- $(document).on('click', '.js-note-discard', this.resetMainTargetForm);
+ this.$wrapperEl.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);
+ this.$wrapperEl.on(
+ 'change',
+ '.js-note-attachment-input',
+ this.updateFormAttachment,
+ );
// reply to diff/discussion notes
- $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
+ this.$wrapperEl.on(
+ 'click',
+ '.js-discussion-reply-button',
+ this.onReplyToDiscussionNote,
+ );
// add diff note
- $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
+ this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote);
// add diff note for images
- $(document).on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote);
+ this.$wrapperEl.on(
+ 'click',
+ '.js-add-image-diff-note-button',
+ this.onAddImageDiffNote,
+ );
// hide diff note form
- $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
+ this.$wrapperEl.on(
+ 'click',
+ '.js-close-discussion-note-form',
+ this.cancelDiscussionForm,
+ );
// toggle commit list
- $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
+ this.$wrapperEl.on(
+ 'click',
+ '.system-note-commit-list-toggler',
+ this.toggleCommitList,
+ );
+
+ this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff);
// fetch notes when tab becomes visible
- $(document).on('visibilitychange', this.visibilityChange);
+ this.$wrapperEl.on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
- $(document).on('issuable:change', this.refresh);
+ this.$wrapperEl.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);
+ this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote);
+ this.$wrapperEl.on(
+ 'ajax:success',
+ '.js-discussion-note-form',
+ this.addDiscussionNote,
+ );
+ this.$wrapperEl.on(
+ 'ajax:success',
+ '.js-main-target-form',
+ this.resetMainTargetForm,
+ );
+ this.$wrapperEl.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);
+ this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText);
// When the URL fragment/hash has changed, `#note_xxx`
- return $(window).on('hashchange', this.onHashChange);
+ $(window).on('hashchange', this.onHashChange);
+ this.boundGetContent = this.getContent.bind(this);
+ document.addEventListener('refreshLegacyNotes', this.boundGetContent);
}
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('click', '.js-add-image-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');
+ this.$wrapperEl.off('click', '.js-note-edit');
+ this.$wrapperEl.off('click', '.note-edit-cancel');
+ this.$wrapperEl.off('click', '.js-note-delete');
+ this.$wrapperEl.off('click', '.js-note-attachment-delete');
+ this.$wrapperEl.off('click', '.js-discussion-reply-button');
+ this.$wrapperEl.off('click', '.js-add-diff-note-button');
+ this.$wrapperEl.off('click', '.js-add-image-diff-note-button');
+ this.$wrapperEl.off('visibilitychange');
+ this.$wrapperEl.off('keyup input', '.js-note-text');
+ this.$wrapperEl.off('click', '.js-note-target-reopen');
+ this.$wrapperEl.off('click', '.js-note-target-close');
+ this.$wrapperEl.off('click', '.js-note-discard');
+ this.$wrapperEl.off('keydown', '.js-note-text');
+ this.$wrapperEl.off('click', '.js-comment-resolve-button');
+ this.$wrapperEl.off('click', '.system-note-commit-list-toggler');
+ this.$wrapperEl.off('click', '.js-toggle-lazy-diff');
+ this.$wrapperEl.off('ajax:success', '.js-main-target-form');
+ this.$wrapperEl.off('ajax:success', '.js-discussion-note-form');
+ this.$wrapperEl.off('ajax:complete', '.js-main-target-form');
+ document.removeEventListener('refreshLegacyNotes', this.boundGetContent);
$(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 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 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');
@@ -186,7 +278,13 @@ export default class Notes {
}
keydownNoteText(e) {
- var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
+ var $textarea,
+ discussionNoteForm,
+ editNote,
+ myLastNote,
+ myLastNoteEditBtn,
+ newText,
+ originalText;
if (isMetaKey(e)) {
return;
}
@@ -198,7 +296,12 @@ export default class Notes {
if ($textarea.val() !== '') {
return;
}
- myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes'));
+ 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]);
@@ -209,7 +312,9 @@ export default class Notes {
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?')) {
+ if (
+ !confirm('Are you sure you want to cancel creating this comment?')
+ ) {
return;
}
}
@@ -218,10 +323,12 @@ export default class Notes {
}
editNote = $textarea.closest('.note');
if (editNote.length) {
- originalText = $textarea.closest('form').data('original-note');
+ originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!confirm('Are you sure you want to cancel editing this comment?')) {
+ if (
+ !confirm('Are you sure you want to cancel editing this comment?')
+ ) {
return;
}
}
@@ -234,11 +341,14 @@ export default class Notes {
if (Notes.interval) {
clearInterval(Notes.interval);
}
- return Notes.interval = setInterval((function(_this) {
- return function() {
- return _this.refresh();
- };
- })(this), this.pollingInterval);
+ return (Notes.interval = setInterval(
+ (function(_this) {
+ return function() {
+ return _this.refresh();
+ };
+ })(this),
+ this.pollingInterval,
+ ));
}
refresh() {
@@ -251,27 +361,26 @@ export default class Notes {
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;
- };
- })(this));
+
+ axios
+ .get(`${this.notes_url}?html=true`, {
+ headers: {
+ 'X-Last-Fetched-At': this.last_fetched_at,
+ },
+ })
+ .then(({ data }) => {
+ const notes = data.notes;
+ this.last_fetched_at = data.last_fetched_at;
+ this.setPollingInterval(data.notes.length);
+ $.each(notes, (i, note) => this.renderNote(note));
+
+ this.refreshing = false;
+ })
+ .catch(() => {
+ this.refreshing = false;
+ });
}
/**
@@ -287,7 +396,8 @@ export default class Notes {
if (shouldReset == null) {
shouldReset = true;
}
- nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
+ nthInterval =
+ this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
if (shouldReset) {
this.pollingInterval = this.basePollingInterval;
} else if (this.pollingInterval < nthInterval) {
@@ -306,12 +416,17 @@ export default class Notes {
if ('emoji_award' in noteEntity.commands_changes) {
votesBlock = $('.js-awards-block').eq(0);
- loadAwardsHandler().then((awardsHandler) => {
- awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
- awardsHandler.scrollToAwards();
- }).catch(() => {
- // ignore
- });
+ loadAwardsHandler()
+ .then(awardsHandler => {
+ awardsHandler.addAwardToEmojiBar(
+ votesBlock,
+ noteEntity.commands_changes.emoji_award,
+ );
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ // ignore
+ });
}
}
}
@@ -355,12 +470,18 @@ export default class Notes {
}
if (!noteEntity.valid) {
- if (noteEntity.errors.commands_only) {
- if (noteEntity.commands_changes &&
- Object.keys(noteEntity.commands_changes).length > 0) {
+ if (noteEntity.errors && 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.get(0));
+ this.addFlash(
+ noteEntity.errors.commands_only,
+ 'notice',
+ this.parentTimeline.get(0),
+ );
this.refresh();
}
return;
@@ -368,6 +489,10 @@ export default class Notes {
const $note = $notesList.find(`#note_${noteEntity.id}`);
if (Notes.isNewNote(noteEntity, this.note_ids)) {
+ if (hasVueMRDiscussionsCookie()) {
+ return;
+ }
+
this.note_ids.push(noteEntity.id);
if ($notesList.length) {
@@ -378,32 +503,36 @@ export default class Notes {
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)) {
+ } else if (Notes.isUpdatedNote(noteEntity, $note)) {
+ // The server can send the same update multiple times so we need to make sure to only update once per actual update.
const isEditing = $note.hasClass('is-editing');
const initialContent = normalizeNewlines(
- $note.find('.original-note-content').text().trim()
+ $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;
+ const isTextareaUntouched =
+ currentContent === initialContent ||
+ currentContent === sanitizedNoteNote;
if (isEditing && isTextareaUntouched) {
$textarea.val(noteEntity.note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
- }
- else if (isEditing && !isTextareaUntouched) {
+ } else if (isEditing && !isTextareaUntouched) {
this.putConflictEditWarningInPlace(noteEntity, $note);
this.updatedNotesTrackingMap[noteEntity.id] = noteEntity;
- }
- else {
+ } else {
const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
this.setupNewNote($updatedNote);
}
}
+
+ Notes.refreshVueNotes();
}
isParallelView() {
@@ -411,28 +540,41 @@ export default class Notes {
}
/**
- * Render note in discussion area.
- *
- * Note: for rendering inline notes use renderDiscussionNote
+ * Render note in discussion area. To render 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.length || !noteEntity.discussion_line_code) ? form.closest('tr') : $(`#${noteEntity.discussion_line_code}`);
+ form =
+ $form ||
+ $(
+ `.js-discussion-note-form[data-discussion-id="${
+ noteEntity.discussion_id
+ }"]`,
+ );
+ row =
+ form.length || !noteEntity.discussion_line_code
+ ? form.closest('tr')
+ : $(`#${noteEntity.discussion_line_code}`);
if (noteEntity.on_image) {
row = form;
}
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
- diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
+ 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}"]`);
+ discussionContainer = $(
+ `.notes[data-discussion-id="${noteEntity.discussion_id}"]`,
+ );
if (!discussionContainer.length) {
discussionContainer = form.closest('.discussion').find('.notes');
}
@@ -440,31 +582,53 @@ export default class Notes {
if (noteEntity.diff_discussion_html) {
var $discussion = $(noteEntity.diff_discussion_html).renderGFM();
- if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) {
+ if (
+ !this.isParallelView() ||
+ row.hasClass('js-temp-notes-holder') ||
+ noteEntity.on_image
+ ) {
// 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());
+ 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());
}
}
// 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'));
+ if (
+ (page && page.indexOf('projects:merge_request') !== -1) ||
+ !noteEntity.diff_discussion_html
+ ) {
+ if (!hasVueMRDiscussionsCookie()) {
+ Notes.animateAppendNote(
+ noteEntity.discussion_html,
+ $('.main-notes-list'),
+ );
+ }
}
} else {
// append new note to all matching discussions
Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
+ if (
+ typeof gl.diffNotesCompileComponents !== 'undefined' &&
+ noteEntity.discussion_resolvable
+ ) {
gl.diffNotesCompileComponents();
this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
@@ -476,7 +640,8 @@ export default class Notes {
}
getLineHolder(changesDiscussionContainer) {
- return $(changesDiscussionContainer).closest('.notes_holder')
+ return $(changesDiscussionContainer)
+ .closest('.notes_holder')
.prevAll('.line_holder')
.first()
.get(0);
@@ -509,8 +674,14 @@ export default class Notes {
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();
+ 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);
@@ -546,7 +717,10 @@ export default class Notes {
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();
+ form
+ .find('.js-comment-resolve-button')
+ .closest('comment-and-resolve-btn')
+ .remove();
this.parentTimeline = form.parents('.timeline');
if (form.length) {
@@ -600,11 +774,17 @@ export default class Notes {
} 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.get(0));
+ return this.addFlash(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ 'alert',
+ formParentTimeline.get(0),
+ );
}
updateNoteError($parentTimeline) {
- new Flash('Your comment could not be updated! Please check your network connection and try again.');
+ new Flash(
+ 'Your comment could not be updated! Please check your network connection and try again.',
+ );
}
/**
@@ -614,9 +794,9 @@ export default class Notes {
*/
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');
+ var projectPath = $form.data('projectPath');
+ var discussionId = $form.data('discussionId');
+ var mergeRequestId = $form.data('noteableIid');
if (ResolveService != null) {
ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
@@ -639,7 +819,6 @@ export default class Notes {
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
@@ -654,14 +833,16 @@ export default class Notes {
}
checkContentToAllowEditing($el) {
- var initialContent = $el.find('.original-note-content').text().trim();
+ var initialContent = $el
+ .find('.original-note-content')
+ .text()
+ .trim();
var currentContent = $el.find('.js-note-text').val();
var isAllowed = true;
if (currentContent === initialContent) {
this.removeNoteEditForm($el);
- }
- else {
+ } else {
var $buttons = $el.find('.note-form-actions');
var isWidgetVisible = isInViewport($el.get(0));
@@ -723,8 +904,7 @@ export default class Notes {
this.setupNewNote($newNote);
// Now that we have taken care of the update, clear it out
delete this.updatedNotesTrackingMap[noteId];
- }
- else {
+ } else {
$note.find('.js-finish-edit-warning').hide();
this.removeNoteEditForm($note);
}
@@ -735,7 +915,7 @@ export default class Notes {
var selector = this.getEditFormSelector($target);
var $editForm = $(selector);
- $editForm.insertBefore('.notes-form');
+ $editForm.insertBefore('.diffs');
$editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide();
}
@@ -751,12 +931,15 @@ export default class Notes {
}
removeNoteEditForm($note) {
- var form = $note.find('.current-note-edit-form');
+ var form = $note.find('.diffs .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'));
+ return form
+ .find('.js-note-text')
+ .val(form.find('form.edit-note').data('originalNote'));
}
/**
@@ -770,59 +953,69 @@ export default class Notes {
$note = $(e.currentTarget).closest('.note');
noteElId = $note.attr('id');
noteId = $note.attr('data-note-id');
- lineHolder = $(e.currentTarget).closest('.notes[data-discussion-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');
- const discussionId = $('.notes', $notes).data('discussion-id');
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- if (gl.diffNoteApps[noteElId]) {
- gl.diffNoteApps[noteElId].$destroy();
+ $(`.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');
+ const discussionId = $('.notes', $notes).data('discussionId');
+
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ if (gl.diffNoteApps[noteElId]) {
+ gl.diffNoteApps[noteElId].$destroy();
+ }
}
- }
-
- $note.remove();
-
- // 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();
-
- $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
-
- // The notes tr can contain multiple lists of notes, like on the parallel diff
- // notesTr does not exist for image diffs
- if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) {
- const $diffFile = $notes.closest('.diff-file');
- if ($diffFile.length > 0) {
- const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', {
- detail: {
- // badgeNumber's start with 1 and index starts with 0
- badgeNumber: $notes.index() + 1,
- },
- });
- $diffFile[0].dispatchEvent(removeBadgeEvent);
+ $note.remove();
+
+ // 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();
+
+ $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
+
+ // The notes tr can contain multiple lists of notes, like on the parallel diff
+ // notesTr does not exist for image diffs
+ if (
+ notesTr.find('.discussion-notes').length > 1 ||
+ notesTr.length === 0
+ ) {
+ const $diffFile = $notes.closest('.diff-file');
+ if ($diffFile.length > 0) {
+ const removeBadgeEvent = new CustomEvent(
+ 'removeBadge.imageDiff',
+ {
+ detail: {
+ // badgeNumber's start with 1 and index starts with 0
+ badgeNumber: $notes.index() + 1,
+ },
+ },
+ );
+
+ $diffFile[0].dispatchEvent(removeBadgeEvent);
+ }
+
+ $notes.remove();
+ } else if (notesTr.length > 0) {
+ notesTr.remove();
}
-
- $notes.remove();
- } else if (notesTr.length > 0) {
- notesTr.remove();
}
- }
- };
- })(this));
+ };
+ })(this),
+ );
+ Notes.refreshVueNotes();
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
}
@@ -902,7 +1095,12 @@ export default class Notes {
// 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-discard')
+ .show()
+ .removeClass('js-note-discard')
+ .addClass('js-close-discussion-note-form')
+ .text(form.find('.js-close-discussion-note-form').data('cancelText'));
form.find('.js-note-target-close').remove();
form.find('.js-note-new-discussion').remove();
this.setupNoteForm(form);
@@ -938,7 +1136,7 @@ export default class Notes {
this.toggleDiffNote({
target: $link,
lineType: link.dataset.lineType,
- showReplyInput
+ showReplyInput,
});
}
@@ -954,7 +1152,9 @@ export default class Notes {
// Setup comment form
let newForm;
- const $noteContainer = $link.closest('.diff-viewer').find('.note-container');
+ const $noteContainer = $link
+ .closest('.diff-viewer')
+ .find('.note-container');
const $form = $noteContainer.find('> .discussion-form');
if ($form.length === 0) {
@@ -967,13 +1167,17 @@ export default class Notes {
this.setupDiscussionNoteForm($link, newForm);
}
- toggleDiffNote({
- target,
- lineType,
- forceShow,
- showReplyInput = false,
- }) {
- var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
+ 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();
@@ -985,11 +1189,13 @@ export default class Notes {
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>';
+ rowCssToAdd =
+ '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content discussion-notes"></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>';
+ rowCssToAdd =
+ '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content discussion-notes"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content discussion-notes"></div></td></tr>';
}
const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
let notesContent = targetRow.find(notesContentSelector);
@@ -1017,7 +1223,9 @@ export default class Notes {
notesContent = targetRow.find(notesContentSelector);
addForm = true;
} else {
- const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible');
+ const isCurrentlyShown = targetRow
+ .find('.content:not(:empty)')
+ .is(':visible');
const isForced = forceShow === true || forceShow === false;
const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
@@ -1042,13 +1250,14 @@ export default class Notes {
removeDiscussionNoteForm(form) {
var glForm, row;
row = form.closest('tr');
- glForm = form.data('gl-form');
+ glForm = form.data('glForm');
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();
+ .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();
@@ -1089,7 +1298,9 @@ export default class Notes {
var filename, form;
form = $(this).closest('form');
// get only the basename
- filename = $(this).val().replace(/^.*[\\\/]/, '');
+ filename = $(this)
+ .val()
+ .replace(/^.*[\\\/]/, '');
return form.find('.js-attachment-filename').text(filename);
}
@@ -1127,8 +1338,8 @@ export default class Notes {
return discardbtn.show();
}
} else {
- reopentext = reopenbtn.data('original-text');
- closetext = closebtn.data('original-text');
+ reopentext = reopenbtn.data('originalText');
+ closetext = closebtn.data('originalText');
if (reopenbtn.text() !== reopentext) {
reopenbtn.text(reopentext);
}
@@ -1155,18 +1366,22 @@ export default class Notes {
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');
+ var postUrl = $originalContentEl.data('postUrl');
+ var targetId = $originalContentEl.data('targetId');
+ var targetType = $originalContentEl.data('targetType');
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
- $editForm.find('form')
- .attr('action', postUrl)
+ $editForm
+ .find('form')
+ .attr('action', `${postUrl}?html=true`)
.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-note-text')
+ .focus()
+ .val(originalContent);
$editForm.find('.js-md-write-button').trigger('click');
$editForm.find('.referenced-users').hide();
}
@@ -1175,7 +1390,9 @@ export default class Notes {
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">
+ <a href="#note_${
+ noteEntity.id
+ }" target="_blank" rel="noopener noreferrer">
updated comment
</a>
to ensure information is not lost
@@ -1185,14 +1402,79 @@ export default class Notes {
}
updateNotesCount(updateCount) {
- return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
+ return this.notesCountBadge.text(
+ parseInt(this.notesCountBadge.text(), 10) + updateCount,
+ );
+ }
+
+ static renderPlaceholderComponent($container) {
+ const el = $container.find('.js-code-placeholder').get(0);
+ new Vue({
+ // eslint-disable-line no-new
+ el,
+ components: {
+ SkeletonLoadingContainer,
+ },
+ render(createElement) {
+ return createElement('skeleton-loading-container');
+ },
+ });
+ }
+
+ static renderDiffContent($container, data) {
+ const { discussion_html } = data;
+ const lines = $(discussion_html).find('.line_holder');
+ lines.addClass('fade-in');
+ $container.find('tbody').prepend(lines);
+ const fileHolder = $container.find('.file-holder');
+ $container.find('.line-holder-placeholder').remove();
+ syntaxHighlight(fileHolder);
+ }
+
+ static renderDiffError($container) {
+ $container.find('.line_content').html(
+ $(`
+ <div class="nothing-here-block">
+ ${__(
+ 'Unable to load the diff.',
+ )} <a class="js-toggle-lazy-diff" href="javascript:void(0)">Try again</a>?
+ </div>
+ `),
+ );
+ }
+
+ loadLazyDiff(e) {
+ const $container = $(e.currentTarget).closest('.js-toggle-container');
+ Notes.renderPlaceholderComponent($container);
+
+ $container.find('.js-toggle-lazy-diff').removeClass('js-toggle-lazy-diff');
+
+ const tableEl = $container.find('tbody');
+ if (tableEl.length === 0) return;
+
+ const fileHolder = $container.find('.file-holder');
+ const url = fileHolder.data('linesPath');
+
+ axios
+ .get(url)
+ .then(({ data }) => {
+ Notes.renderDiffContent($container, data);
+ })
+ .catch(() => {
+ Notes.renderDiffError($container);
+ });
}
toggleCommitList(e) {
const $element = $(e.currentTarget);
- const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
+ const $closestSystemCommitList = $element.siblings(
+ '.system-note-commit-list',
+ );
- $element.find('.fa').toggleClass('fa-angle-down').toggleClass('fa-angle-up');
+ $element
+ .find('.fa')
+ .toggleClass('fa-angle-down')
+ .toggleClass('fa-angle-up');
$closestSystemCommitList.toggleClass('hide-shade');
}
@@ -1202,11 +1484,17 @@ export default class Notes {
* intrusive.
*/
collapseLongCommitList() {
- const systemNotes = $('#notes-list').find('li.system-note').has('ul');
+ const systemNotes = $('#notes-list')
+ .find('li.system-note')
+ .has('ul');
$.each(systemNotes, function(index, systemNote) {
const $systemNote = $(systemNote);
- const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', '');
+ const headerMessage = $systemNote
+ .find('.note-text')
+ .find('p:first')
+ .text()
+ .replace(':', '');
$systemNote.find('.note-header .system-note-message').html(headerMessage);
@@ -1214,7 +1502,9 @@ export default class Notes {
$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');
+ $systemNote
+ .find('.note-text')
+ .addClass('system-note-commit-list hide-shade');
}
});
}
@@ -1232,14 +1522,10 @@ export default class Notes {
cleanForm($form) {
// Remove JS classes that are not needed here
- $form
- .find('.js-comment-type-dropdown')
- .removeClass('btn-group');
+ $form.find('.js-comment-type-dropdown').removeClass('btn-group');
// Remove dropdown
- $form
- .find('.dropdown-menu')
- .remove();
+ $form.find('.dropdown-menu').remove();
return $form;
}
@@ -1258,7 +1544,11 @@ export default class Notes {
// 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()
+ $note
+ .find('.original-note-content')
+ .first()
+ .text()
+ .trim(),
);
return sanitizedNoteEntityText !== currentNoteText;
}
@@ -1285,6 +1575,10 @@ export default class Notes {
return $updatedNote;
}
+ static refreshVueNotes() {
+ document.dispatchEvent(new CustomEvent('refreshVueNotes'));
+ }
+
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
@@ -1344,7 +1638,14 @@ export default class Notes {
* 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 }) {
+ 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">
@@ -1358,8 +1659,12 @@ export default class Notes {
<div class="note-header">
<div class="note-header-info">
<a href="/${_.escape(currentUsername)}">
- <span class="hidden-xs">${_.escape(currentUsername)}</span>
- <span class="note-headline-light">${_.escape(currentUsername)}</span>
+ <span class="hidden-xs">${_.escape(
+ currentUsername,
+ )}</span>
+ <span class="note-headline-light">${_.escape(
+ currentUsername,
+ )}</span>
</a>
</div>
</div>
@@ -1370,11 +1675,13 @@ export default class Notes {
</div>
</div>
</div>
- </li>`
+ </li>`,
);
$tempNote.find('.hidden-xs').text(_.escape(currentUserFullname));
- $tempNote.find('.note-headline-light').text(`@${_.escape(currentUsername)}`);
+ $tempNote
+ .find('.note-headline-light')
+ .text(`@${_.escape(currentUsername)}`);
return $tempNote;
}
@@ -1390,7 +1697,7 @@ export default class Notes {
<i>${formContent}</i>
</div>
</div>
- </li>`
+ </li>`,
);
return $tempNote;
@@ -1404,7 +1711,7 @@ export default class Notes {
* 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 `ajaxPost`
+ * 5) Perform network request to submit the note using `axios.post`
* a) If request is successfully completed
* 1. Remove placeholder element
* 2. Show submitted Note element
@@ -1420,13 +1727,25 @@ export default class Notes {
// Get Form metadata
const $submitBtn = $(e.target);
+ $submitBtn.prop('disabled', true);
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 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, formContentOriginal } = this.getFormData($form);
+ const isDiscussionResolve = $submitBtn.hasClass(
+ 'js-comment-resolve-button',
+ );
+ const {
+ formData,
+ formContent,
+ formAction,
+ formContentOriginal,
+ } = this.getFormData($form);
let noteUniqueId;
let systemNoteUniqueId;
let hasQuickActions = false;
@@ -1443,7 +1762,6 @@ export default class Notes {
// 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();
}
@@ -1456,23 +1774,30 @@ export default class Notes {
// 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,
- }));
+ $notesContainer.append(
+ this.createPlaceholderNote({
+ formContent: tempFormContent,
+ uniqueId: noteUniqueId,
+ isDiscussionNote,
+ currentUsername: gon.current_username,
+ currentUserFullname: gon.current_user_fullname,
+ currentUserAvatar: gon.current_user_avatar_url,
+ }),
+ );
}
// 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,
- }));
+ $notesContainer.append(
+ this.createPlaceholderSystemNote({
+ formContent: this.getQuickActionDescription(
+ formContent,
+ AjaxCache.get(gl.GfmAutoComplete.dataSources.commands),
+ ),
+ uniqueId: systemNoteUniqueId,
+ }),
+ );
}
// Clear the form textarea
@@ -1484,10 +1809,16 @@ export default class Notes {
}
}
+ $closeBtn.text($closeBtn.data('originalText'));
+
/* eslint-disable promise/catch-or-return */
// Make request to submit comment on server
- ajaxPost(formAction, formData)
- .then((note) => {
+ return axios
+ .post(`${formAction}?html=true`, formData)
+ .then(res => {
+ const note = res.data;
+
+ $submitBtn.prop('disabled', false);
// Submission successful! remove placeholder
$notesContainer.find(`#${noteUniqueId}`).remove();
@@ -1502,7 +1833,9 @@ export default class Notes {
// Reset cached commands list when command is applied
if (hasQuickActions) {
- $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
+ $form
+ .find('textarea.js-note-text')
+ .trigger('clear-commands-cache.atwho');
}
// Clear previous form errors
@@ -1516,9 +1849,9 @@ export default class Notes {
// If comment intends to resolve discussion, do the same.
if (isDiscussionResolve) {
$form
- .attr('data-discussion-id', $submitBtn.data('discussion-id'))
+ .attr('data-discussion-id', $submitBtn.data('discussionId'))
.attr('data-resolve-all', 'true')
- .attr('data-project-path', $submitBtn.data('project-path'));
+ .attr('data-project-path', $submitBtn.data('projectPath'));
}
// Show final note element on UI
@@ -1547,9 +1880,14 @@ export default class Notes {
// append flash-container to the Notes list
if ($notesContainer.length) {
- $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
+ $notesContainer.append(
+ '<div class="flash-container" style="display: none;"></div>',
+ );
}
- } else if (isMainForm) { // Check if this was main thread comment
+
+ Notes.refreshVueNotes();
+ } 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);
@@ -1560,10 +1898,11 @@ export default class Notes {
}
$form.trigger('ajax:success', [note]);
- }).fail(() => {
+ })
+ .catch(() => {
// Submission failed, remove placeholder note and show Flash error message
$notesContainer.find(`#${noteUniqueId}`).remove();
-
+ $submitBtn.prop('disabled', false);
const blurEvent = new CustomEvent('blur.imageDiff', {
detail: e,
});
@@ -1580,7 +1919,9 @@ export default class Notes {
// Show form again on UI on failure
if (isDiscussionForm && $notesContainer.length) {
- const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
+ const replyButton = $notesContainer
+ .parent()
+ .find('.js-discussion-reply-button');
this.replyToDiscussionNote(replyButton[0]);
$form = $notesContainer.parent().find('form');
}
@@ -1589,8 +1930,6 @@ export default class Notes {
this.reenableTargetFormSubmitButton(e);
this.addNoteError($form);
});
-
- return $closeBtn.text($closeBtn.data('original-text'));
}
/**
@@ -1599,7 +1938,7 @@ export default class Notes {
*
* 1) Get Form metadata
* 2) Update note element with new content
- * 3) Perform network request to submit the updated note using `ajaxPost`
+ * 3) Perform network request to submit the updated note using `axios.post`
* a) If request is successfully completed
* 1. Show submitted Note element
* b) If request failed
@@ -1625,17 +1964,24 @@ export default class Notes {
// 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>');
+ $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
- ajaxPost(formAction, formData)
- .then((note) => {
+ axios
+ .post(`${formAction}?html=true`, formData)
+ .then(({ data }) => {
// Submission successful! render final note element
- this.updateNote(note, $editingNote);
+ this.updateNote(data, $editingNote);
})
- .fail(() => {
+ .catch(() => {
// Submission failed, revert back to original note
$noteBodyText.html(_.escape(cachedNoteBodyText));
$editingNote.removeClass('being-posted fade-in');
@@ -1645,7 +1991,7 @@ export default class Notes {
this.updateNoteError();
});
- return $closeBtn.text($closeBtn.data('original-text'));
+ return $closeBtn.text($closeBtn.data('originalText'));
}
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index e594377bc40..648fa6ff804 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,241 +1,315 @@
<script>
- import { mapActions, mapGetters } from 'vuex';
- import _ from 'underscore';
- import Autosize from 'autosize';
- import Flash from '../../flash';
- import Autosave from '../../autosave';
- import TaskList from '../../task_list';
- import * as constants from '../constants';
- import eventHub from '../event_hub';
- import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
- import noteSignedOutWidget from './note_signed_out_widget.vue';
- import discussionLockedWidget from './discussion_locked_widget.vue';
- import markdownField from '../../vue_shared/components/markdown/field.vue';
- import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
- import issuableStateMixin from '../mixins/issuable_state';
+import $ from 'jquery';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import _ from 'underscore';
+import Autosize from 'autosize';
+import { __, sprintf } from '~/locale';
+import Flash from '../../flash';
+import Autosave from '../../autosave';
+import TaskList from '../../task_list';
+import {
+ capitalizeFirstCharacter,
+ convertToCamelCase,
+} from '../../lib/utils/text_utility';
+import * as constants from '../constants';
+import eventHub from '../event_hub';
+import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
+import markdownField from '../../vue_shared/components/markdown/field.vue';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import loadingButton from '../../vue_shared/components/loading_button.vue';
+import noteSignedOutWidget from './note_signed_out_widget.vue';
+import discussionLockedWidget from './discussion_locked_widget.vue';
+import issuableStateMixin from '../mixins/issuable_state';
- export default {
- name: 'commentForm',
- data() {
+export default {
+ name: 'CommentForm',
+ components: {
+ issueWarning,
+ noteSignedOutWidget,
+ discussionLockedWidget,
+ markdownField,
+ userAvatarLink,
+ loadingButton,
+ },
+ mixins: [issuableStateMixin],
+ props: {
+ noteableType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ note: '',
+ noteType: constants.COMMENT,
+ isSubmitting: false,
+ isSubmitButtonDisabled: true,
+ };
+ },
+ computed: {
+ ...mapGetters([
+ 'getCurrentUserLastNote',
+ 'getUserData',
+ 'getNoteableData',
+ 'getNotesData',
+ 'openState',
+ ]),
+ ...mapState(['isToggleStateButtonLoading']),
+ noteableDisplayName() {
+ return this.noteableType.replace(/_/g, ' ');
+ },
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ commentButtonTitle() {
+ return this.noteType === constants.COMMENT
+ ? 'Comment'
+ : 'Start discussion';
+ },
+ isOpen() {
+ return (
+ this.openState === constants.OPENED ||
+ this.openState === constants.REOPENED
+ );
+ },
+ canCreateNote() {
+ return this.getNoteableData.current_user.can_create_note;
+ },
+ issueActionButtonTitle() {
+ const openOrClose = this.isOpen ? 'close' : 'reopen';
+
+ if (this.note.length) {
+ return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), {
+ actionText: this.commentButtonTitle,
+ openOrClose,
+ noteable: this.noteableDisplayName,
+ });
+ }
+
+ return sprintf(__('%{openOrClose} %{noteable}'), {
+ openOrClose: capitalizeFirstCharacter(openOrClose),
+ noteable: this.noteableDisplayName,
+ });
+ },
+ actionButtonClassNames() {
return {
- note: '',
- noteType: constants.COMMENT,
- // Can't use mapGetters,
- // this needs to be in the data object because it belongs to the state
- issueState: this.$store.getters.getNoteableData.state,
- isSubmitting: false,
- isSubmitButtonDisabled: true,
+ 'btn-reopen': !this.isOpen,
+ 'btn-close': this.isOpen,
+ 'js-note-target-close': this.isOpen,
+ 'js-note-target-reopen': !this.isOpen,
};
},
- components: {
- issueWarning,
- noteSignedOutWidget,
- discussionLockedWidget,
- markdownField,
- userAvatarLink,
+ supportQuickActions() {
+ // Disable quick actions support for Epics
+ return this.noteableType !== constants.EPIC_NOTEABLE_TYPE;
+ },
+ markdownDocsPath() {
+ return this.getNotesData.markdownDocsPath;
+ },
+ quickActionsDocsPath() {
+ return this.getNotesData.quickActionsDocsPath;
+ },
+ markdownPreviewPath() {
+ return this.getNoteableData.preview_note_path;
},
- watch: {
- note(newNote) {
- this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
- },
- isSubmitting(newValue) {
- this.setIsSubmitButtonDisabled(this.note, newValue);
- },
+ author() {
+ return this.getUserData;
},
- computed: {
- ...mapGetters([
- 'getCurrentUserLastNote',
- 'getUserData',
- 'getNoteableData',
- 'getNotesData',
- ]),
- isLoggedIn() {
- return this.getUserData.id;
- },
- commentButtonTitle() {
- return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
- },
- isIssueOpen() {
- return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
- },
- canCreateNote() {
- return this.getNoteableData.current_user.can_create_note;
- },
- issueActionButtonTitle() {
- if (this.note.length) {
- const actionText = this.isIssueOpen ? 'close' : 'reopen';
+ canUpdateIssue() {
+ return this.getNoteableData.current_user.can_update;
+ },
+ endpoint() {
+ return this.getNoteableData.create_note_path;
+ },
+ },
+ watch: {
+ note(newNote) {
+ this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
+ },
+ isSubmitting(newValue) {
+ this.setIsSubmitButtonDisabled(this.note, newValue);
+ },
+ },
+ mounted() {
+ // jQuery is needed here because it is a custom event being dispatched with jQuery.
+ $(document).on('issuable:change', (e, isClosed) => {
+ this.toggleIssueLocalState(
+ isClosed ? constants.CLOSED : constants.REOPENED,
+ );
+ });
- return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`;
- }
+ this.initAutoSave();
+ this.initTaskList();
+ },
+ methods: {
+ ...mapActions([
+ 'saveNote',
+ 'stopPolling',
+ 'restartPolling',
+ 'removePlaceholderNotes',
+ 'closeIssue',
+ 'reopenIssue',
+ 'toggleIssueLocalState',
+ 'toggleStateButtonLoading',
+ ]),
+ setIsSubmitButtonDisabled(note, isSubmitting) {
+ if (!_.isEmpty(note) && !isSubmitting) {
+ this.isSubmitButtonDisabled = false;
+ } else {
+ this.isSubmitButtonDisabled = true;
+ }
+ },
+ handleSave(withIssueAction) {
+ this.isSubmitting = true;
- return this.isIssueOpen ? 'Close issue' : 'Reopen issue';
- },
- actionButtonClassNames() {
- return {
- 'btn-reopen': !this.isIssueOpen,
- 'btn-close': this.isIssueOpen,
- 'js-note-target-close': this.isIssueOpen,
- 'js-note-target-reopen': !this.isIssueOpen,
+ if (this.note.length) {
+ const noteData = {
+ endpoint: this.endpoint,
+ flashContainer: this.$el,
+ data: {
+ note: {
+ noteable_type: this.noteableType,
+ noteable_id: this.getNoteableData.id,
+ note: this.note,
+ },
+ },
};
- },
- markdownDocsPath() {
- return this.getNotesData.markdownDocsPath;
- },
- quickActionsDocsPath() {
- return this.getNotesData.quickActionsDocsPath;
- },
- markdownPreviewPath() {
- return this.getNoteableData.preview_note_path;
- },
- author() {
- return this.getUserData;
- },
- canUpdateIssue() {
- return this.getNoteableData.current_user.can_update;
- },
- endpoint() {
- return this.getNoteableData.create_note_path;
- },
- },
- methods: {
- ...mapActions([
- 'saveNote',
- 'stopPolling',
- 'restartPolling',
- 'removePlaceholderNotes',
- ]),
- setIsSubmitButtonDisabled(note, isSubmitting) {
- if (!_.isEmpty(note) && !isSubmitting) {
- this.isSubmitButtonDisabled = false;
- } else {
- this.isSubmitButtonDisabled = true;
+
+ if (this.noteType === constants.DISCUSSION) {
+ noteData.data.note.type = constants.DISCUSSION_NOTE;
}
- },
- handleSave(withIssueAction) {
- if (this.note.length) {
- const noteData = {
- endpoint: this.endpoint,
- flashContainer: this.$el,
- data: {
- note: {
- noteable_type: constants.NOTEABLE_TYPE,
- noteable_id: this.getNoteableData.id,
- note: this.note,
- },
- },
- };
- if (this.noteType === constants.DISCUSSION) {
- noteData.data.note.type = constants.DISCUSSION_NOTE;
- }
- this.isSubmitting = true;
- this.note = ''; // Empty textarea while being requested. Repopulate in catch
- this.resizeTextarea();
- this.stopPolling();
+ this.note = ''; // Empty textarea while being requested. Repopulate in catch
+ this.resizeTextarea();
+ this.stopPolling();
- this.saveNote(noteData)
- .then((res) => {
- this.isSubmitting = false;
- this.restartPolling();
+ this.saveNote(noteData)
+ .then(res => {
+ this.enableButton();
+ this.restartPolling();
- if (res.errors) {
- if (res.errors.commands_only) {
- this.discard();
- } else {
- Flash(
- 'Something went wrong while adding your comment. Please try again.',
- 'alert',
- this.$refs.commentForm,
- );
- }
- } else {
+ if (res.errors) {
+ if (res.errors.commands_only) {
this.discard();
+ } else {
+ Flash(
+ 'Something went wrong while adding your comment. Please try again.',
+ 'alert',
+ this.$refs.commentForm,
+ );
}
+ } else {
+ this.discard();
+ }
- if (withIssueAction) {
- this.toggleIssueState();
- }
- })
- .catch(() => {
- this.isSubmitting = false;
- this.discard(false);
- const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
- Flash(msg, 'alert', this.$el);
- this.note = noteData.data.note.note; // Restore textarea content.
- this.removePlaceholderNotes();
- });
- } else {
- this.toggleIssueState();
- }
- },
- toggleIssueState() {
- this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED;
-
- // This is out of scope for the Notes Vue component.
- // It was the shortest path to update the issue state and relevant places.
- const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close';
- $(`.js-btn-issue-action.${btnClass}:visible`).trigger('click');
- },
- discard(shouldClear = true) {
- // `blur` is needed to clear slash commands autocomplete cache if event fired.
- // `focus` is needed to remain cursor in the textarea.
- this.$refs.textarea.blur();
- this.$refs.textarea.focus();
+ if (withIssueAction) {
+ this.toggleIssueState();
+ }
+ })
+ .catch(() => {
+ this.enableButton();
+ this.discard(false);
+ const msg = `Your comment could not be submitted!
+Please check your network connection and try again.`;
+ Flash(msg, 'alert', this.$el);
+ this.note = noteData.data.note.note; // Restore textarea content.
+ this.removePlaceholderNotes();
+ });
+ } else {
+ this.toggleIssueState();
+ }
+ },
+ enableButton() {
+ this.isSubmitting = false;
+ },
+ toggleIssueState() {
+ if (this.isOpen) {
+ this.closeIssue()
+ .then(() => this.enableButton())
+ .catch(() => {
+ this.enableButton();
+ this.toggleStateButtonLoading(false);
+ Flash(
+ sprintf(
+ __(
+ 'Something went wrong while closing the %{issuable}. Please try again later',
+ ),
+ { issuable: this.noteableDisplayName },
+ ),
+ );
+ });
+ } else {
+ this.reopenIssue()
+ .then(() => this.enableButton())
+ .catch(() => {
+ this.enableButton();
+ this.toggleStateButtonLoading(false);
+ Flash(
+ sprintf(
+ __(
+ 'Something went wrong while reopening the %{issuable}. Please try again later',
+ ),
+ { issuable: this.noteableDisplayName },
+ ),
+ );
+ });
+ }
+ },
+ discard(shouldClear = true) {
+ // `blur` is needed to clear slash commands autocomplete cache if event fired.
+ // `focus` is needed to remain cursor in the textarea.
+ this.$refs.textarea.blur();
+ this.$refs.textarea.focus();
- if (shouldClear) {
- this.note = '';
- this.resizeTextarea();
- this.$refs.markdownField.previewMarkdown = false;
- }
+ if (shouldClear) {
+ this.note = '';
+ this.resizeTextarea();
+ this.$refs.markdownField.previewMarkdown = false;
+ }
- // reset autostave
- this.autosave.reset();
- },
- setNoteType(type) {
- this.noteType = type;
- },
- editCurrentUserLastNote() {
- if (this.note === '') {
- const lastNote = this.getCurrentUserLastNote;
+ this.autosave.reset();
+ },
+ setNoteType(type) {
+ this.noteType = type;
+ },
+ editCurrentUserLastNote() {
+ if (this.note === '') {
+ const lastNote = this.getCurrentUserLastNote;
- if (lastNote) {
- eventHub.$emit('enterEditMode', {
- noteId: lastNote.id,
- });
- }
- }
- },
- initAutoSave() {
- if (this.isLoggedIn) {
- this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getNoteableData.id], 'issue');
+ if (lastNote) {
+ eventHub.$emit('enterEditMode', {
+ noteId: lastNote.id,
+ });
}
- },
- initTaskList() {
- return new TaskList({
- dataType: 'note',
- fieldName: 'note',
- selector: '.notes',
- });
- },
- resizeTextarea() {
- this.$nextTick(() => {
- Autosize.update(this.$refs.textarea);
- });
- },
+ }
},
- mixins: [
- issuableStateMixin,
- ],
- mounted() {
- // jQuery is needed here because it is a custom event being dispatched with jQuery.
- $(document).on('issuable:change', (e, isClosed) => {
- this.issueState = isClosed ? constants.CLOSED : constants.REOPENED;
- });
+ initAutoSave() {
+ if (this.isLoggedIn) {
+ const noteableType = capitalizeFirstCharacter(
+ convertToCamelCase(this.noteableType),
+ );
- this.initAutoSave();
- this.initTaskList();
+ this.autosave = new Autosave($(this.$refs.textarea), [
+ 'Note',
+ noteableType,
+ this.getNoteableData.id,
+ ]);
+ }
+ },
+ initTaskList() {
+ return new TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes',
+ });
+ },
+ resizeTextarea() {
+ this.$nextTick(() => {
+ Autosize.update(this.$refs.textarea);
+ });
},
- };
+ },
+};
</script>
<template>
@@ -258,12 +332,12 @@
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
- />
+ />
</div>
<div class="timeline-content timeline-content-form">
<form
ref="commentForm"
- class="new-note js-quick-submit common-note-form gfm-form js-main-target-form"
+ class="new-note common-note-form gfm-form js-main-target-form"
>
<div class="error-alert"></div>
@@ -283,8 +357,9 @@
<textarea
id="note-body"
name="note[note]"
- class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea"
- data-supports-quick-actions="true"
+ class="note-textarea js-vue-comment-form
+js-gfm-input js-autosize markdown-area js-vue-textarea"
+ :data-supports-quick-actions="supportQuickActions"
aria-label="Description"
v-model="note"
ref="textarea"
@@ -292,17 +367,20 @@
:disabled="isSubmitting"
placeholder="Write a comment or drag your files here..."
@keydown.up="editCurrentUserLastNote()"
- @keydown.meta.enter="handleSave()">
+ @keydown.meta.enter="handleSave()"
+ @keydown.ctrl.enter="handleSave()">
</textarea>
</markdown-field>
<div class="note-form-actions">
- <div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown">
+ <div
+ class="pull-left btn-group
+append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown">
<button
@click.prevent="handleSave()"
:disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit">
- {{commentButtonTitle}}
+ {{ __(commentButtonTitle) }}
</button>
<button
:disabled="isSubmitButtonDisabled"
@@ -330,7 +408,7 @@
<div class="description">
<strong>Comment</strong>
<p>
- Add a general comment to this issue.
+ Add a general comment to this {{ noteableDisplayName }}.
</p>
</div>
</button>
@@ -344,7 +422,7 @@
<i
aria-hidden="true"
class="fa fa-check icon">
- </i>
+ </i>
<div class="description">
<strong>Start discussion</strong>
<p>
@@ -355,15 +433,19 @@
</li>
</ul>
</div>
- <button
- type="button"
- @click="handleSave(true)"
+
+ <loading-button
v-if="canUpdateIssue"
- :class="actionButtonClassNames"
- :disabled="isSubmitting"
- class="btn btn-comment btn-comment-and-close js-action-button">
- {{issueActionButtonTitle}}
- </button>
+ :loading="isToggleStateButtonLoading"
+ @click="handleSave(true)"
+ :container-class="[
+ actionButtonClassNames,
+ 'btn btn-comment btn-comment-and-close js-action-button'
+ ]"
+ :disabled="isToggleStateButtonLoading || isSubmitting"
+ :label="issueActionButtonTitle"
+ />
+
<button
type="button"
v-if="note.length"
diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue
new file mode 100644
index 00000000000..94d9dc69964
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_file_header.vue
@@ -0,0 +1,94 @@
+<script>
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ ClipboardButton,
+ Icon,
+ },
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ titleTag() {
+ return this.diffFile.discussionPath ? 'a' : 'span';
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="file-header-content">
+ <div
+ v-if="diffFile.submodule"
+ >
+ <span>
+ <icon name="archive" />
+ <strong
+ v-html="diffFile.submoduleLink"
+ class="file-title-name"
+ ></strong>
+ <clipboard-button
+ title="Copy file path to clipboard"
+ :text="diffFile.submoduleLink"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </span>
+ </div>
+ <template v-else>
+ <component
+ ref="titleWrapper"
+ :is="titleTag"
+ :href="diffFile.discussionPath"
+ >
+ <span v-html="diffFile.blobIcon"></span>
+ <span v-if="diffFile.renamedFile">
+ <strong
+ class="file-title-name has-tooltip"
+ :title="diffFile.oldPath"
+ data-container="body"
+ >
+ {{ diffFile.oldPath }}
+ </strong>
+ &rarr;
+ <strong
+ class="file-title-name has-tooltip"
+ :title="diffFile.newPath"
+ data-container="body"
+ >
+ {{ diffFile.newPath }}
+ </strong>
+ </span>
+
+ <strong
+ v-else
+ class="file-title-name has-tooltip"
+ :title="diffFile.oldPath"
+ data-container="body"
+ >
+ {{ diffFile.filePath }}
+ <span v-if="diffFile.deletedFile">
+ deleted
+ </span>
+ </strong>
+ </component>
+
+ <clipboard-button
+ title="Copy file path to clipboard"
+ :text="diffFile.filePath"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+
+ <small
+ v-if="diffFile.modeChanged"
+ ref="fileMode"
+ >
+ {{ diffFile.aMode }} → {{ diffFile.bMode }}
+ </small>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
new file mode 100644
index 00000000000..ee01ec85bbb
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -0,0 +1,101 @@
+<script>
+import $ from 'jquery';
+import syntaxHighlight from '~/syntax_highlight';
+import imageDiffHelper from '~/image_diff/helpers/index';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import DiffFileHeader from './diff_file_header.vue';
+
+export default {
+ components: {
+ DiffFileHeader,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isImageDiff() {
+ return !this.diffFile.text;
+ },
+ diffFileClass() {
+ const { text } = this.diffFile;
+ return text ? 'text-file' : 'js-image-file';
+ },
+ diffRows() {
+ return $(this.discussion.truncatedDiffLines);
+ },
+ diffFile() {
+ return convertObjectPropsToCamelCase(this.discussion.diffFile);
+ },
+ imageDiffHtml() {
+ return this.discussion.imageDiffHtml;
+ },
+ },
+ mounted() {
+ if (this.isImageDiff) {
+ const canCreateNote = false;
+ const renderCommentBadge = true;
+ imageDiffHelper.initImageDiff(
+ this.$refs.fileHolder,
+ canCreateNote,
+ renderCommentBadge,
+ );
+ } else {
+ const fileHolder = $(this.$refs.fileHolder);
+ this.$nextTick(() => {
+ syntaxHighlight(fileHolder);
+ });
+ }
+ },
+ methods: {
+ rowTag(html) {
+ return html.outerHTML ? 'tr' : 'template';
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="fileHolder"
+ class="diff-file file-holder"
+ :class="diffFileClass"
+ >
+ <div class="js-file-title file-title file-title-flex-parent">
+ <diff-file-header
+ :diff-file="diffFile"
+ />
+ </div>
+ <div
+ v-if="diffFile.text"
+ class="diff-content code js-syntax-highlight"
+ >
+ <table>
+ <component
+ :is="rowTag(html)"
+ :class="html.className"
+ v-for="(html, index) in diffRows"
+ v-html="html.outerHTML"
+ :key="index"
+ />
+ <tr class="notes_holder">
+ <td
+ class="notes_line"
+ colspan="2"
+ ></td>
+ <td class="notes_content">
+ <slot></slot>
+ </td>
+ </tr>
+ </table>
+ </div>
+ <div
+ v-else
+ >
+ <div v-html="imageDiffHtml"></div>
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
new file mode 100644
index 00000000000..d492d1cd001
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -0,0 +1,121 @@
+<script>
+import { mapGetters } from 'vuex';
+import resolveSvg from 'icons/_icon_resolve_discussion.svg';
+import resolvedSvg from 'icons/_icon_status_success_solid.svg';
+import mrIssueSvg from 'icons/_icon_mr_issue.svg';
+import nextDiscussionSvg from 'icons/_next_discussion.svg';
+import { pluralize } from '../../lib/utils/text_utility';
+import { scrollToElement } from '../../lib/utils/common_utils';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ computed: {
+ ...mapGetters([
+ 'getUserData',
+ 'getNoteableData',
+ 'discussionCount',
+ 'unresolvedDiscussions',
+ 'resolvedDiscussionCount',
+ ]),
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ hasNextButton() {
+ return this.isLoggedIn && !this.allResolved;
+ },
+ countText() {
+ return pluralize('discussion', this.discussionCount);
+ },
+ allResolved() {
+ return this.resolvedDiscussionCount === this.discussionCount;
+ },
+ resolveAllDiscussionsIssuePath() {
+ return this.getNoteableData.create_issue_to_resolve_discussions_path;
+ },
+ firstUnresolvedDiscussionId() {
+ const item = this.unresolvedDiscussions[0] || {};
+
+ return item.id;
+ },
+ },
+ created() {
+ this.resolveSvg = resolveSvg;
+ this.resolvedSvg = resolvedSvg;
+ this.mrIssueSvg = mrIssueSvg;
+ this.nextDiscussionSvg = nextDiscussionSvg;
+ },
+ methods: {
+ jumpToFirstDiscussion() {
+ const el = document.querySelector(
+ `[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`,
+ );
+ const activeTab = window.mrTabs.currentAction;
+
+ if (activeTab === 'commits' || activeTab === 'pipelines') {
+ window.mrTabs.activateTab('show');
+ }
+
+ if (el) {
+ scrollToElement(el);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="line-resolve-all-container prepend-top-10">
+ <div>
+ <div
+ v-if="discussionCount > 0"
+ :class="{ 'has-next-btn': hasNextButton }"
+ class="line-resolve-all">
+ <span
+ :class="{ 'is-active': allResolved }"
+ class="line-resolve-btn is-disabled"
+ type="button">
+ <span
+ v-if="allResolved"
+ v-html="resolvedSvg"
+ ></span>
+ <span
+ v-else
+ v-html="resolveSvg"
+ ></span>
+ </span>
+ <span class=".line-resolve-text">
+ {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
+ </span>
+ </div>
+ <div
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ class="btn-group"
+ role="group">
+ <a
+ :href="resolveAllDiscussionsIssuePath"
+ v-tooltip
+ title="Resolve all discussions in new issue"
+ data-container="body"
+ class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
+ <span v-html="mrIssueSvg"></span>
+ </a>
+ </div>
+ <div
+ v-if="isLoggedIn && !allResolved"
+ class="btn-group"
+ role="group">
+ <button
+ @click="jumpToFirstDiscussion"
+ v-tooltip
+ title="Jump to first unresolved discussion"
+ data-container="body"
+ class="btn btn-default discussion-next-btn">
+ <span v-html="nextDiscussionSvg"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
index e6f7ee56ff3..13283b187d1 100644
--- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -1,15 +1,13 @@
<script>
- import Icon from '~/vue_shared/components/icon.vue';
- import Issuable from '~/vue_shared/mixins/issuable';
+import Icon from '~/vue_shared/components/icon.vue';
+import Issuable from '~/vue_shared/mixins/issuable';
- export default {
- mixins: [
- Issuable,
- ],
- components: {
- Icon,
- },
- };
+export default {
+ components: {
+ Icon,
+ },
+ mixins: [Issuable],
+};
</script>
<template>
@@ -18,9 +16,11 @@
<icon
name="lock"
:size="16"
- class="icon">
- </icon>
- <span>This {{ issuableDisplayName }} is locked. Only <b>project members</b> can comment.</span>
- </span>
+ class="icon"
+ />
+ <span>
+ This {{ issuableDisplayName }} is locked. Only <b>project members</b> can comment.
+ </span>
+ </span>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 7fb45ed4d4b..a7e2d857013 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,92 +1,153 @@
<script>
- import { mapGetters } from 'vuex';
- import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
- import emojiSmile from 'icons/_emoji_smile.svg';
- import emojiSmiley from 'icons/_emoji_smiley.svg';
- import editSvg from 'icons/_icon_pencil.svg';
- import ellipsisSvg from 'icons/_ellipsis_v.svg';
- import loadingIcon from '~/vue_shared/components/loading_icon.vue';
- import tooltip from '~/vue_shared/directives/tooltip';
+import { mapGetters } from 'vuex';
+import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
+import emojiSmile from 'icons/_emoji_smile.svg';
+import emojiSmiley from 'icons/_emoji_smiley.svg';
+import editSvg from 'icons/_icon_pencil.svg';
+import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
+import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
+import ellipsisSvg from 'icons/_ellipsis_v.svg';
+import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
- export default {
- name: 'noteActions',
- props: {
- authorId: {
- type: Number,
- required: true,
- },
- noteId: {
- type: Number,
- required: true,
- },
- accessLevel: {
- type: String,
- required: false,
- default: '',
- },
- reportAbusePath: {
- type: String,
- required: true,
- },
- canEdit: {
- type: Boolean,
- required: true,
- },
- canDelete: {
- type: Boolean,
- required: true,
- },
- canReportAsAbuse: {
- type: Boolean,
- required: true,
- },
- },
- directives: {
- tooltip,
- },
- components: {
- loadingIcon,
- },
- computed: {
- ...mapGetters([
- 'getUserDataByProp',
- ]),
- shouldShowActionsDropdown() {
- return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
- },
- canAddAwardEmoji() {
- return this.currentUserId;
- },
- isAuthoredByCurrentUser() {
- return this.authorId === this.currentUserId;
- },
- currentUserId() {
- return this.getUserDataByProp('id');
- },
- },
- methods: {
- onEdit() {
- this.$emit('handleEdit');
- },
- onDelete() {
- this.$emit('handleDelete');
- },
- },
- created() {
- this.emojiSmiling = emojiSmiling;
- this.emojiSmile = emojiSmile;
- this.emojiSmiley = emojiSmiley;
- this.editSvg = editSvg;
- this.ellipsisSvg = ellipsisSvg;
- },
- };
+export default {
+ name: 'NoteActions',
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ },
+ props: {
+ authorId: {
+ type: Number,
+ required: true,
+ },
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ accessLevel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ reportAbusePath: {
+ type: String,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ canDelete: {
+ type: Boolean,
+ required: true,
+ },
+ resolvable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isResolved: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isResolving: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ resolvedBy: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ canReportAsAbuse: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters(['getUserDataByProp']),
+ shouldShowActionsDropdown() {
+ return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
+ },
+ canAddAwardEmoji() {
+ return this.currentUserId;
+ },
+ isAuthoredByCurrentUser() {
+ return this.authorId === this.currentUserId;
+ },
+ currentUserId() {
+ return this.getUserDataByProp('id');
+ },
+ resolveButtonTitle() {
+ let title = 'Mark as resolved';
+
+ if (this.resolvedBy) {
+ title = `Resolved by ${this.resolvedBy.name}`;
+ }
+
+ return title;
+ },
+ },
+ created() {
+ this.emojiSmiling = emojiSmiling;
+ this.emojiSmile = emojiSmile;
+ this.emojiSmiley = emojiSmiley;
+ this.editSvg = editSvg;
+ this.ellipsisSvg = ellipsisSvg;
+ this.resolveDiscussionSvg = resolveDiscussionSvg;
+ this.resolvedDiscussionSvg = resolvedDiscussionSvg;
+ },
+ methods: {
+ onEdit() {
+ this.$emit('handleEdit');
+ },
+ onDelete() {
+ this.$emit('handleDelete');
+ },
+ onResolve() {
+ this.$emit('handleResolve');
+ },
+ },
+};
</script>
<template>
<div class="note-actions">
<span
v-if="accessLevel"
- class="note-role user-access-role">{{accessLevel}}</span>
+ class="note-role user-access-role">
+ {{ accessLevel }}
+ </span>
+ <div
+ v-if="resolvable"
+ class="note-actions-item">
+ <button
+ v-tooltip
+ @click="onResolve"
+ :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
+ :title="resolveButtonTitle"
+ :aria-label="resolveButtonTitle"
+ type="button"
+ class="line-resolve-btn note-action-button">
+ <template v-if="!isResolving">
+ <div
+ v-if="isResolved"
+ v-html="resolvedDiscussionSvg"></div>
+ <div
+ v-else
+ v-html="resolveDiscussionSvg"></div>
+ </template>
+ <loading-icon
+ v-else
+ :inline="true"
+ />
+ </button>
+ </div>
<div
v-if="canAddAwardEmoji"
class="note-actions-item">
@@ -98,20 +159,21 @@
data-placement="bottom"
data-container="body"
href="#"
- title="Add reaction">
- <loading-icon :inline="true" />
- <span
- v-html="emojiSmiling"
- class="link-highlight award-control-icon-neutral">
- </span>
- <span
- v-html="emojiSmiley"
- class="link-highlight award-control-icon-positive">
- </span>
- <span
- v-html="emojiSmile"
- class="link-highlight award-control-icon-super-positive">
- </span>
+ title="Add reaction"
+ >
+ <loading-icon :inline="true" />
+ <span
+ v-html="emojiSmiling"
+ class="link-highlight award-control-icon-neutral">
+ </span>
+ <span
+ v-html="emojiSmiley"
+ class="link-highlight award-control-icon-positive">
+ </span>
+ <span
+ v-html="emojiSmile"
+ class="link-highlight award-control-icon-super-positive">
+ </span>
</a>
</div>
<div
@@ -125,9 +187,10 @@
class="note-action-button js-note-edit btn btn-transparent"
data-container="body"
data-placement="bottom">
- <span
- v-html="editSvg"
- class="link-highlight"></span>
+ <span
+ v-html="editSvg"
+ class="link-highlight">
+ </span>
</button>
</div>
<div
@@ -141,9 +204,10 @@
data-toggle="dropdown"
data-container="body"
data-placement="bottom">
- <span
- class="icon"
- v-html="ellipsisSvg"></span>
+ <span
+ class="icon"
+ v-html="ellipsisSvg">
+ </span>
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue
index cd9571a4002..34ecbd00c63 100644
--- a/app/assets/javascripts/notes/components/note_attachment.vue
+++ b/app/assets/javascripts/notes/components/note_attachment.vue
@@ -1,13 +1,13 @@
<script>
- export default {
- name: 'noteAttachment',
- props: {
- attachment: {
- type: Object,
- required: true,
- },
+export default {
+ name: 'NoteAttachment',
+ props: {
+ attachment: {
+ type: Object,
+ required: true,
},
- };
+ },
+};
</script>
<template>
@@ -19,7 +19,8 @@
rel="noopener noreferrer">
<img
:src="attachment.url"
- class="note-image-attach" />
+ class="note-image-attach"
+ />
</a>
<div class="attachment">
<a
@@ -29,8 +30,9 @@
rel="noopener noreferrer">
<i
class="fa fa-paperclip"
- aria-hidden="true"></i>
- {{attachment.filename}}
+ aria-hidden="true">
+ </i>
+ {{ attachment.filename }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index c3a340139e7..6cb8229e268 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,179 +1,192 @@
<script>
- import { mapActions, mapGetters } from 'vuex';
- import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
- import emojiSmile from 'icons/_emoji_smile.svg';
- import emojiSmiley from 'icons/_emoji_smiley.svg';
- import Flash from '../../flash';
- import { glEmojiTag } from '../../emoji';
- import tooltip from '../../vue_shared/directives/tooltip';
-
- export default {
- props: {
- awards: {
- type: Array,
- required: true,
- },
- toggleAwardPath: {
- type: String,
- required: true,
- },
- noteAuthorId: {
- type: Number,
- required: true,
- },
- noteId: {
- type: Number,
- required: true,
- },
+import { mapActions, mapGetters } from 'vuex';
+import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
+import emojiSmile from 'icons/_emoji_smile.svg';
+import emojiSmiley from 'icons/_emoji_smiley.svg';
+import Flash from '../../flash';
+import { glEmojiTag } from '../../emoji';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ awards: {
+ type: Array,
+ required: true,
},
- directives: {
- tooltip,
+ toggleAwardPath: {
+ type: String,
+ required: true,
},
- computed: {
- ...mapGetters([
- 'getUserData',
- ]),
- // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
- // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
- // This method will group emojis by their name as an Object. See below.
- // {
- // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
- // bar: [ { name: bar, user: user1 } ]
- // }
- // We need to do this otherwise we will render the same emoji over and over again.
- groupedAwards() {
- const awards = this.awards.reduce((acc, award) => {
- if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
- acc[award.name].push(award);
- } else {
- Object.assign(acc, { [award.name]: [award] });
- }
-
- return acc;
- }, {});
-
- const orderedAwards = {};
- const { thumbsdown, thumbsup } = awards;
- // Always show thumbsup and thumbsdown first
- if (thumbsup) {
- orderedAwards.thumbsup = thumbsup;
- delete awards.thumbsup;
- }
- if (thumbsdown) {
- orderedAwards.thumbsdown = thumbsdown;
- delete awards.thumbsdown;
- }
-
- return Object.assign({}, orderedAwards, awards);
- },
- isAuthoredByMe() {
- return this.noteAuthorId === this.getUserData.id;
- },
- isLoggedIn() {
- return this.getUserData.id;
- },
+ noteAuthorId: {
+ type: Number,
+ required: true,
},
- methods: {
- ...mapActions([
- 'toggleAwardRequest',
- ]),
- getAwardHTML(name) {
- return glEmojiTag(name);
- },
- getAwardClassBindings(awardList, awardName) {
- return {
- active: this.hasReactionByCurrentUser(awardList),
- disabled: !this.canInteractWithEmoji(awardList, awardName),
- };
- },
- canInteractWithEmoji(awardList, awardName) {
- let isAllowed = true;
- const restrictedEmojis = ['thumbsup', 'thumbsdown'];
-
- // Users can not add :+1: and :-1: to their own notes
- if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) {
- isAllowed = false;
- }
-
- return this.getUserData.id && isAllowed;
- },
- hasReactionByCurrentUser(awardList) {
- return awardList.filter(award => award.user.id === this.getUserData.id).length;
- },
- awardTitle(awardsList) {
- const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
- const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
- let awardList = awardsList;
-
- // Filter myself from list if I am awarded.
- if (hasReactionByCurrentUser) {
- awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
- }
-
- // Get only 9-10 usernames to show in tooltip text.
- const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
-
- // Get the remaining list to use in `and x more` text.
- const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
-
- // Add myself to the begining of the list so title will start with You.
- if (hasReactionByCurrentUser) {
- namesToShow.unshift('You');
- }
-
- let title = '';
-
- // We have 10+ awarded user, join them with comma and add `and x more`.
- if (remainingAwardList.length) {
- title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`;
- } else if (namesToShow.length > 1) {
- // Join all names with comma but not the last one, it will be added with and text.
- title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
- // If we have more than 2 users we need an extra comma before and text.
- title += namesToShow.length > 2 ? ',' : '';
- title += ` and ${namesToShow.slice(-1)}`; // Append and text
- } else { // We have only 2 users so join them with and.
- title = namesToShow.join(' and ');
- }
-
- return title;
- },
- handleAward(awardName) {
- if (!this.isLoggedIn) {
- return;
- }
-
- let parsedName;
-
- // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
- switch (awardName) {
- case '100':
- parsedName = 100;
- break;
- case '1234':
- parsedName = 1234;
- break;
- default:
- parsedName = awardName;
- break;
+ noteId: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters(['getUserData']),
+ // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
+ // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
+ // This method will group emojis by their name as an Object. See below.
+ // {
+ // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
+ // bar: [ { name: bar, user: user1 } ]
+ // }
+ // We need to do this otherwise we will render the same emoji over and over again.
+ groupedAwards() {
+ const awards = this.awards.reduce((acc, award) => {
+ if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
+ acc[award.name].push(award);
+ } else {
+ Object.assign(acc, { [award.name]: [award] });
}
- const data = {
- endpoint: this.toggleAwardPath,
- noteId: this.noteId,
- awardName: parsedName,
- };
-
- this.toggleAwardRequest(data)
- .catch(() => Flash('Something went wrong on our end.'));
- },
+ return acc;
+ }, {});
+
+ const orderedAwards = {};
+ const { thumbsdown, thumbsup } = awards;
+ // Always show thumbsup and thumbsdown first
+ if (thumbsup) {
+ orderedAwards.thumbsup = thumbsup;
+ delete awards.thumbsup;
+ }
+ if (thumbsdown) {
+ orderedAwards.thumbsdown = thumbsdown;
+ delete awards.thumbsdown;
+ }
+
+ return Object.assign({}, orderedAwards, awards);
+ },
+ isAuthoredByMe() {
+ return this.noteAuthorId === this.getUserData.id;
+ },
+ isLoggedIn() {
+ return this.getUserData.id;
+ },
+ },
+ created() {
+ this.emojiSmiling = emojiSmiling;
+ this.emojiSmile = emojiSmile;
+ this.emojiSmiley = emojiSmiley;
+ },
+ methods: {
+ ...mapActions(['toggleAwardRequest']),
+ getAwardHTML(name) {
+ return glEmojiTag(name);
+ },
+ getAwardClassBindings(awardList, awardName) {
+ return {
+ active: this.hasReactionByCurrentUser(awardList),
+ disabled: !this.canInteractWithEmoji(awardList, awardName),
+ };
+ },
+ canInteractWithEmoji(awardList, awardName) {
+ let isAllowed = true;
+ const restrictedEmojis = ['thumbsup', 'thumbsdown'];
+
+ // Users can not add :+1: and :-1: to their own notes
+ if (
+ this.getUserData.id === this.noteAuthorId &&
+ restrictedEmojis.indexOf(awardName) > -1
+ ) {
+ isAllowed = false;
+ }
+
+ return this.getUserData.id && isAllowed;
+ },
+ hasReactionByCurrentUser(awardList) {
+ return awardList.filter(award => award.user.id === this.getUserData.id)
+ .length;
+ },
+ awardTitle(awardsList) {
+ const hasReactionByCurrentUser = this.hasReactionByCurrentUser(
+ awardsList,
+ );
+ const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
+ let awardList = awardsList;
+
+ // Filter myself from list if I am awarded.
+ if (hasReactionByCurrentUser) {
+ awardList = awardList.filter(
+ award => award.user.id !== this.getUserData.id,
+ );
+ }
+
+ // Get only 9-10 usernames to show in tooltip text.
+ const namesToShow = awardList
+ .slice(0, TOOLTIP_NAME_COUNT)
+ .map(award => award.user.name);
+
+ // Get the remaining list to use in `and x more` text.
+ const remainingAwardList = awardList.slice(
+ TOOLTIP_NAME_COUNT,
+ awardList.length,
+ );
+
+ // Add myself to the begining of the list so title will start with You.
+ if (hasReactionByCurrentUser) {
+ namesToShow.unshift('You');
+ }
+
+ let title = '';
+
+ // We have 10+ awarded user, join them with comma and add `and x more`.
+ if (remainingAwardList.length) {
+ title = `${namesToShow.join(', ')}, and ${
+ remainingAwardList.length
+ } more.`;
+ } else if (namesToShow.length > 1) {
+ // Join all names with comma but not the last one, it will be added with and text.
+ title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
+ // If we have more than 2 users we need an extra comma before and text.
+ title += namesToShow.length > 2 ? ',' : '';
+ title += ` and ${namesToShow.slice(-1)}`; // Append and text
+ } else {
+ // We have only 2 users so join them with and.
+ title = namesToShow.join(' and ');
+ }
+
+ return title;
},
- created() {
- this.emojiSmiling = emojiSmiling;
- this.emojiSmile = emojiSmile;
- this.emojiSmiley = emojiSmiley;
+ handleAward(awardName) {
+ if (!this.isLoggedIn) {
+ return;
+ }
+
+ let parsedName;
+
+ // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
+ switch (awardName) {
+ case '100':
+ parsedName = 100;
+ break;
+ case '1234':
+ parsedName = 1234;
+ break;
+ default:
+ parsedName = awardName;
+ break;
+ }
+
+ const data = {
+ endpoint: this.toggleAwardPath,
+ noteId: this.noteId,
+ awardName: parsedName,
+ };
+
+ this.toggleAwardRequest(data).catch(() =>
+ Flash('Something went wrong on our end.'),
+ );
},
- };
+ },
+};
</script>
<template>
@@ -191,7 +204,7 @@
type="button">
<span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter">
- {{awardList.length}}
+ {{ awardList.length }}
</span>
</button>
<div
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index ac4e1ffe53a..069f94c5845 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,82 +1,81 @@
<script>
- import noteEditedText from './note_edited_text.vue';
- import noteAwardsList from './note_awards_list.vue';
- import noteAttachment from './note_attachment.vue';
- import noteForm from './note_form.vue';
- import TaskList from '../../task_list';
- import autosave from '../mixins/autosave';
+import $ from 'jquery';
+import noteEditedText from './note_edited_text.vue';
+import noteAwardsList from './note_awards_list.vue';
+import noteAttachment from './note_attachment.vue';
+import noteForm from './note_form.vue';
+import TaskList from '../../task_list';
+import autosave from '../mixins/autosave';
- export default {
- props: {
- note: {
- type: Object,
- required: true,
- },
- canEdit: {
- type: Boolean,
- required: true,
- },
- isEditing: {
- type: Boolean,
- required: false,
- default: false,
- },
+export default {
+ components: {
+ noteEditedText,
+ noteAwardsList,
+ noteAttachment,
+ noteForm,
+ },
+ mixins: [autosave],
+ props: {
+ note: {
+ type: Object,
+ required: true,
},
- mixins: [
- autosave,
- ],
- components: {
- noteEditedText,
- noteAwardsList,
- noteAttachment,
- noteForm,
+ canEdit: {
+ type: Boolean,
+ required: true,
},
- computed: {
- noteBody() {
- return this.note.note;
- },
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- methods: {
- renderGFM() {
- $(this.$refs['note-body']).renderGFM();
- },
- initTaskList() {
- if (this.canEdit) {
- this.taskList = new TaskList({
- dataType: 'note',
- fieldName: 'note',
- selector: '.notes',
- });
- }
- },
- handleFormUpdate(note, parentElement, callback) {
- this.$emit('handleFormUpdate', note, parentElement, callback);
- },
- formCancelHandler(shouldConfirm, isDirty) {
- this.$emit('cancelFormEdition', shouldConfirm, isDirty);
- },
+ },
+ computed: {
+ noteBody() {
+ return this.note.note;
},
- mounted() {
- this.renderGFM();
- this.initTaskList();
+ },
+ mounted() {
+ this.renderGFM();
+ this.initTaskList();
- if (this.isEditing) {
- this.initAutoSave();
+ if (this.isEditing) {
+ this.initAutoSave(this.note.noteable_type);
+ }
+ },
+ updated() {
+ this.initTaskList();
+ this.renderGFM();
+
+ if (this.isEditing) {
+ if (!this.autosave) {
+ this.initAutoSave(this.note.noteable_type);
+ } else {
+ this.setAutoSave();
}
+ }
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs['note-body']).renderGFM();
},
- updated() {
- this.initTaskList();
- this.renderGFM();
-
- if (this.isEditing) {
- if (!this.autosave) {
- this.initAutoSave();
- } else {
- this.setAutoSave();
- }
+ initTaskList() {
+ if (this.canEdit) {
+ this.taskList = new TaskList({
+ dataType: 'note',
+ fieldName: 'note',
+ selector: '.notes',
+ });
}
},
- };
+ handleFormUpdate(note, parentElement, callback) {
+ this.$emit('handleFormUpdate', note, parentElement, callback);
+ },
+ formCancelHandler(shouldConfirm, isDirty) {
+ this.$emit('cancelFormEdition', shouldConfirm, isDirty);
+ },
+ },
+};
</script>
<template>
@@ -95,7 +94,7 @@
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
- />
+ />
<textarea
v-if="canEdit"
v-model="note.note"
@@ -106,17 +105,17 @@
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
action-text="Edited"
- />
+ />
<note-awards-list
v-if="note.award_emoji.length"
:note-id="note.id"
:note-author-id="note.author.id"
:awards="note.award_emoji"
:toggle-award-path="note.toggle_award_path"
- />
+ />
<note-attachment
v-if="note.attachment"
:attachment="note.attachment"
- />
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 49e09f0ecc5..4ddca918495 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -1,46 +1,47 @@
<script>
- import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
- export default {
- name: 'editedNoteText',
- props: {
- actionText: {
- type: String,
- required: true,
- },
- editedAt: {
- type: String,
- required: true,
- },
- editedBy: {
- type: Object,
- required: false,
- },
- className: {
- type: String,
- required: false,
- default: 'edited-text',
- },
+export default {
+ name: 'EditedNoteText',
+ components: {
+ timeAgoTooltip,
+ },
+ props: {
+ actionText: {
+ type: String,
+ required: true,
},
- components: {
- timeAgoTooltip,
+ editedAt: {
+ type: String,
+ required: true,
},
- };
+ editedBy: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ className: {
+ type: String,
+ required: false,
+ default: 'edited-text',
+ },
+ },
+};
</script>
<template>
<div :class="className">
- {{actionText}}
+ {{ actionText }}
<time-ago-tooltip
:time="editedAt"
tooltip-placement="bottom"
- />
+ />
<template v-if="editedBy">
by
<a
:href="editedBy.path"
class="js-vue-author author_link">
- {{editedBy.name}}
+ {{ editedBy.name }}
</a>
</template>
</div>
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 4d527cb6643..c59a2e7a406 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,119 +1,142 @@
<script>
- import { mapGetters } from 'vuex';
- import eventHub from '../event_hub';
- import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
- import markdownField from '../../vue_shared/components/markdown/field.vue';
- import issuableStateMixin from '../mixins/issuable_state';
+import { mapGetters, mapActions } from 'vuex';
+import eventHub from '../event_hub';
+import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
+import markdownField from '../../vue_shared/components/markdown/field.vue';
+import issuableStateMixin from '../mixins/issuable_state';
+import resolvable from '../mixins/resolvable';
- export default {
- name: 'issueNoteForm',
- props: {
- noteBody: {
- type: String,
- required: false,
- default: '',
- },
- noteId: {
- type: Number,
- required: false,
- },
- saveButtonTitle: {
- type: String,
- required: false,
- default: 'Save comment',
- },
- discussion: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- isEditing: {
- type: Boolean,
- required: true,
- },
+export default {
+ name: 'IssueNoteForm',
+ components: {
+ issueWarning,
+ markdownField,
+ },
+ mixins: [issuableStateMixin, resolvable],
+ props: {
+ noteBody: {
+ type: String,
+ required: false,
+ default: '',
},
- data() {
- return {
- note: this.noteBody,
- conflictWhileEditing: false,
- isSubmitting: false,
- };
+ noteId: {
+ type: Number,
+ required: false,
+ default: 0,
},
- components: {
- issueWarning,
- markdownField,
+ saveButtonTitle: {
+ type: String,
+ required: false,
+ default: 'Save comment',
},
- computed: {
- ...mapGetters([
- 'getDiscussionLastNote',
- 'getNoteableData',
- 'getNoteableDataByProp',
- 'getNotesDataByProp',
- 'getUserDataByProp',
- ]),
- noteHash() {
- return `#note_${this.noteId}`;
- },
- markdownPreviewPath() {
- return this.getNoteableDataByProp('preview_note_path');
- },
- markdownDocsPath() {
- return this.getNotesDataByProp('markdownDocsPath');
- },
- quickActionsDocsPath() {
- return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
- },
- currentUserId() {
- return this.getUserDataByProp('id');
- },
- isDisabled() {
- return !this.note.length || this.isSubmitting;
- },
+ note: {
+ type: Object,
+ required: false,
+ default: () => ({}),
},
- methods: {
- handleUpdate() {
- this.isSubmitting = true;
+ isEditing: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ updatedNoteBody: this.noteBody,
+ conflictWhileEditing: false,
+ isSubmitting: false,
+ isResolving: false,
+ resolveAsThread: true,
+ };
+ },
+ computed: {
+ ...mapGetters([
+ 'getDiscussionLastNote',
+ 'getNoteableData',
+ 'getNoteableDataByProp',
+ 'getNotesDataByProp',
+ 'getUserDataByProp',
+ ]),
+ noteHash() {
+ return `#note_${this.noteId}`;
+ },
+ markdownPreviewPath() {
+ return this.getNoteableDataByProp('preview_note_path');
+ },
+ markdownDocsPath() {
+ return this.getNotesDataByProp('markdownDocsPath');
+ },
+ quickActionsDocsPath() {
+ return !this.isEditing
+ ? this.getNotesDataByProp('quickActionsDocsPath')
+ : undefined;
+ },
+ currentUserId() {
+ return this.getUserDataByProp('id');
+ },
+ isDisabled() {
+ return !this.updatedNoteBody.length || this.isSubmitting;
+ },
+ },
+ watch: {
+ noteBody() {
+ if (this.updatedNoteBody === this.noteBody) {
+ this.updatedNoteBody = this.noteBody;
+ } else {
+ this.conflictWhileEditing = true;
+ }
+ },
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ methods: {
+ ...mapActions(['toggleResolveNote']),
+ handleUpdate(shouldResolve) {
+ const beforeSubmitDiscussionState = this.discussionResolved;
+ this.isSubmitting = true;
- this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
+ this.$emit(
+ 'handleFormUpdate',
+ this.updatedNoteBody,
+ this.$refs.editNoteForm,
+ () => {
this.isSubmitting = false;
- });
- },
- editMyLastNote() {
- if (this.note === '') {
- const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
- if (lastNoteInDiscussion) {
- eventHub.$emit('enterEditMode', {
- noteId: lastNoteInDiscussion.id,
- });
+ if (shouldResolve) {
+ this.resolveHandler(beforeSubmitDiscussionState);
}
- }
- },
- cancelHandler(shouldConfirm = false) {
- // Sends information about confirm message and if the textarea has changed
- this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
- },
+ },
+ );
},
- mixins: [
- issuableStateMixin,
- ],
- mounted() {
- this.$refs.textarea.focus();
- },
- watch: {
- noteBody() {
- if (this.note === this.noteBody) {
- this.note = this.noteBody;
- } else {
- this.conflictWhileEditing = true;
+ editMyLastNote() {
+ if (this.updatedNoteBody === '') {
+ const lastNoteInDiscussion = this.getDiscussionLastNote(
+ this.updatedNoteBody,
+ );
+
+ if (lastNoteInDiscussion) {
+ eventHub.$emit('enterEditMode', {
+ noteId: lastNoteInDiscussion.id,
+ });
}
- },
+ }
},
- };
+ cancelHandler(shouldConfirm = false) {
+ // Sends information about confirm message and if the textarea has changed
+ this.$emit(
+ 'cancelFormEdition',
+ shouldConfirm,
+ this.noteBody !== this.updatedNoteBody,
+ );
+ },
+ },
+};
</script>
<template>
- <div ref="editNoteForm" class="note-edit-form current-note-edit-form">
+ <div
+ ref="editNoteForm"
+ class="note-edit-form current-note-edit-form">
<div
v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger">
@@ -121,12 +144,13 @@
<a
:href="noteHash"
target="_blank"
- rel="noopener noreferrer">updated comment</a>
- to ensure information is not lost.
+ rel="noopener noreferrer">
+ updated comment
+ </a>
+ to ensure information is not lost.
</div>
<div class="flash-container timeline-content"></div>
- <form
- class="edit-note common-note-form js-quick-submit gfm-form">
+ <form class="edit-note common-note-form js-quick-submit gfm-form">
<issue-warning
v-if="hasWarning(getNoteableData)"
@@ -142,14 +166,16 @@
<textarea
id="note_note"
name="note[note]"
- class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
+ class="note-textarea js-gfm-input
+js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing"
aria-label="Description"
- v-model="note"
+ v-model="updatedNoteBody"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.meta.enter="handleUpdate()"
+ @keydown.ctrl.enter="handleUpdate()"
@keydown.up="editMyLastNote()"
@keydown.esc="cancelHandler(true)">
</textarea>
@@ -160,7 +186,14 @@
@click="handleUpdate()"
:disabled="isDisabled"
class="js-vue-issue-save btn btn-save">
- {{saveButtonTitle}}
+ {{ saveButtonTitle }}
+ </button>
+ <button
+ v-if="note.resolvable"
+ @click.prevent="handleUpdate(true)"
+ class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
+ >
+ {{ resolveButtonTitle }}
</button>
<button
@click="cancelHandler()"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 63aa3d777d0..c3d1ef1fcc6 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,82 +1,77 @@
<script>
- import { mapActions } from 'vuex';
- import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+import { mapActions } from 'vuex';
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
- export default {
- props: {
- author: {
- type: Object,
- required: true,
- },
- createdAt: {
- type: String,
- required: true,
- },
- actionText: {
- type: String,
- required: false,
- default: '',
- },
- actionTextHtml: {
- type: String,
- required: false,
- default: '',
- },
- noteId: {
- type: Number,
- required: true,
- },
- includeToggle: {
- type: Boolean,
- required: false,
- default: false,
- },
+export default {
+ components: {
+ timeAgoTooltip,
+ },
+ props: {
+ author: {
+ type: Object,
+ required: true,
},
- data() {
- return {
- isExpanded: true,
- };
+ createdAt: {
+ type: String,
+ required: true,
},
- components: {
- timeAgoTooltip,
+ actionText: {
+ type: String,
+ required: false,
+ default: '',
},
- computed: {
- toggleChevronClass() {
- return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
- },
- noteTimestampLink() {
- return `#note_${this.noteId}`;
- },
+ actionTextHtml: {
+ type: String,
+ required: false,
+ default: '',
},
- methods: {
- ...mapActions([
- 'setTargetNoteHash',
- ]),
- handleToggle() {
- this.isExpanded = !this.isExpanded;
- this.$emit('toggleHandler');
- },
- updateTargetNoteHash() {
- this.setTargetNoteHash(this.noteTimestampLink);
- },
+ noteId: {
+ type: Number,
+ required: true,
},
- };
+ includeToggle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ computed: {
+ toggleChevronClass() {
+ return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
+ },
+ noteTimestampLink() {
+ return `#note_${this.noteId}`;
+ },
+ },
+ methods: {
+ ...mapActions(['setTargetNoteHash']),
+ handleToggle() {
+ this.$emit('toggleHandler');
+ },
+ updateTargetNoteHash() {
+ this.setTargetNoteHash(this.noteTimestampLink);
+ },
+ },
+};
</script>
<template>
<div class="note-header-info">
<a :href="author.path">
- <span class="note-header-author-name">
- {{author.name}}
- </span>
+ <span class="note-header-author-name">{{ author.name }}</span>
<span class="note-headline-light">
- @{{author.username}}
+ @{{ author.username }}
</span>
</a>
<span class="note-headline-light">
<span class="note-headline-meta">
<template v-if="actionText">
- {{actionText}}
+ {{ actionText }}
</template>
<span
v-if="actionTextHtml"
@@ -90,12 +85,13 @@
<time-ago-tooltip
:time="createdAt"
tooltip-placement="bottom"
- />
+ />
</a>
<i
class="fa fa-spinner fa-spin editing-spinner"
aria-label="Comment is being updated"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</span>
</span>
@@ -106,12 +102,12 @@
@click="handleToggle"
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button">
- <i
- :class="toggleChevronClass"
- class="fa"
- aria-hidden="true">
- </i>
- Toggle discussion
+ <i
+ :class="toggleChevronClass"
+ class="fa"
+ aria-hidden="true">
+ </i>
+ Toggle discussion
</button>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
index 45d3c2de355..91f7c269757 100644
--- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue
+++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
@@ -1,19 +1,17 @@
<script>
- import { mapGetters } from 'vuex';
+import { mapGetters } from 'vuex';
- export default {
- computed: {
- ...mapGetters([
- 'getNotesDataByProp',
- ]),
- registerLink() {
- return this.getNotesDataByProp('registerPath');
- },
- signInLink() {
- return this.getNotesDataByProp('newSessionPath');
- },
+export default {
+ computed: {
+ ...mapGetters(['getNotesDataByProp']),
+ registerLink() {
+ return this.getNotesDataByProp('registerPath');
},
- };
+ signInLink() {
+ return this.getNotesDataByProp('newSessionPath');
+ },
+ },
+};
</script>
<template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 11e8f805635..476b15aca4a 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,162 +1,216 @@
<script>
- import { mapActions, mapGetters } from 'vuex';
- import Flash from '../../flash';
- import { SYSTEM_NOTE } from '../constants';
- import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
- import noteableNote from './noteable_note.vue';
- import noteHeader from './note_header.vue';
- import noteSignedOutWidget from './note_signed_out_widget.vue';
- import noteEditedText from './note_edited_text.vue';
- import noteForm from './note_form.vue';
- import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
- import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
- import autosave from '../mixins/autosave';
+import { mapActions, mapGetters } from 'vuex';
+import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
+import nextDiscussionsSvg from 'icons/_next_discussion.svg';
+import Flash from '../../flash';
+import { SYSTEM_NOTE } from '../constants';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import noteableNote from './noteable_note.vue';
+import noteHeader from './note_header.vue';
+import noteSignedOutWidget from './note_signed_out_widget.vue';
+import noteEditedText from './note_edited_text.vue';
+import noteForm from './note_form.vue';
+import diffWithNote from './diff_with_note.vue';
+import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
+import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import autosave from '../mixins/autosave';
+import noteable from '../mixins/noteable';
+import resolvable from '../mixins/resolvable';
+import tooltip from '../../vue_shared/directives/tooltip';
+import { scrollToElement } from '../../lib/utils/common_utils';
- export default {
- props: {
- note: {
- type: Object,
- required: true,
- },
- },
- data() {
+export default {
+ components: {
+ noteableNote,
+ diffWithNote,
+ userAvatarLink,
+ noteHeader,
+ noteSignedOutWidget,
+ noteEditedText,
+ noteForm,
+ placeholderNote,
+ placeholderSystemNote,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [autosave, noteable, resolvable],
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isReplying: false,
+ isResolving: false,
+ resolveAsThread: true,
+ };
+ },
+ computed: {
+ ...mapGetters([
+ 'getNoteableData',
+ 'discussionCount',
+ 'resolvedDiscussionCount',
+ 'unresolvedDiscussions',
+ ]),
+ discussion() {
return {
- isReplying: false,
+ ...this.note.notes[0],
+ truncatedDiffLines: this.note.truncated_diff_lines,
+ diffFile: this.note.diff_file,
+ diffDiscussion: this.note.diff_discussion,
+ imageDiffHtml: this.note.image_diff_html,
};
},
- components: {
- noteableNote,
- userAvatarLink,
- noteHeader,
- noteSignedOutWidget,
- noteEditedText,
- noteForm,
- placeholderNote,
- placeholderSystemNote,
- },
- mixins: [
- autosave,
- ],
- computed: {
- ...mapGetters([
- 'getNoteableData',
- ]),
- discussion() {
- return this.note.notes[0];
- },
- author() {
- return this.discussion.author;
- },
- canReply() {
- return this.getNoteableData.current_user.can_create_note;
- },
- newNotePath() {
- return this.getNoteableData.create_note_path;
- },
- lastUpdatedBy() {
- const { notes } = this.note;
+ author() {
+ return this.discussion.author;
+ },
+ canReply() {
+ return this.getNoteableData.current_user.can_create_note;
+ },
+ newNotePath() {
+ return this.getNoteableData.create_note_path;
+ },
+ lastUpdatedBy() {
+ const { notes } = this.note;
- if (notes.length > 1) {
- return notes[notes.length - 1].author;
- }
+ if (notes.length > 1) {
+ return notes[notes.length - 1].author;
+ }
- return null;
- },
- lastUpdatedAt() {
- const { notes } = this.note;
+ return null;
+ },
+ lastUpdatedAt() {
+ const { notes } = this.note;
- if (notes.length > 1) {
- return notes[notes.length - 1].created_at;
- }
+ if (notes.length > 1) {
+ return notes[notes.length - 1].created_at;
+ }
- return null;
- },
- },
- methods: {
- ...mapActions([
- 'saveNote',
- 'toggleDiscussion',
- 'removePlaceholderNotes',
- ]),
- componentName(note) {
- if (note.isPlaceholderNote) {
- if (note.placeholderType === SYSTEM_NOTE) {
- return placeholderSystemNote;
- }
- return placeholderNote;
+ return null;
+ },
+ hasUnresolvedDiscussion() {
+ return this.unresolvedDiscussions.length > 0;
+ },
+ wrapperComponent() {
+ return this.discussion.diffDiscussion && this.discussion.diffFile
+ ? diffWithNote
+ : 'div';
+ },
+ wrapperClass() {
+ return this.isDiffDiscussion ? '' : 'panel panel-default';
+ },
+ },
+ mounted() {
+ if (this.isReplying) {
+ this.initAutoSave(this.discussion.noteable_type);
+ }
+ },
+ updated() {
+ if (this.isReplying) {
+ if (!this.autosave) {
+ this.initAutoSave(this.discussion.noteable_type);
+ } else {
+ this.setAutoSave();
+ }
+ }
+ },
+ created() {
+ this.resolveDiscussionsSvg = resolveDiscussionsSvg;
+ this.nextDiscussionsSvg = nextDiscussionsSvg;
+ },
+ methods: {
+ ...mapActions([
+ 'saveNote',
+ 'toggleDiscussion',
+ 'removePlaceholderNotes',
+ 'toggleResolveNote',
+ ]),
+ componentName(note) {
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === SYSTEM_NOTE) {
+ return placeholderSystemNote;
}
+ return placeholderNote;
+ }
- return noteableNote;
- },
- componentData(note) {
- return note.isPlaceholderNote ? note.notes[0] : note;
- },
- toggleDiscussionHandler() {
- this.toggleDiscussion({ discussionId: this.note.id });
- },
- showReplyForm() {
- this.isReplying = true;
- },
- cancelReplyForm(shouldConfirm) {
- if (shouldConfirm && this.$refs.noteForm.isDirty) {
- // eslint-disable-next-line no-alert
- if (!confirm('Are you sure you want to cancel creating this comment?')) {
- return;
- }
+ return noteableNote;
+ },
+ componentData(note) {
+ return note.isPlaceholderNote ? this.note.notes[0] : note;
+ },
+ toggleDiscussionHandler() {
+ this.toggleDiscussion({ discussionId: this.note.id });
+ },
+ showReplyForm() {
+ this.isReplying = true;
+ },
+ cancelReplyForm(shouldConfirm) {
+ if (shouldConfirm && this.$refs.noteForm.isDirty) {
+ const msg = 'Are you sure you want to cancel creating this comment?';
+
+ // eslint-disable-next-line no-alert
+ if (!confirm(msg)) {
+ return;
}
+ }
- this.resetAutoSave();
- this.isReplying = false;
- },
- saveReply(noteText, form, callback) {
- const replyData = {
- endpoint: this.newNotePath,
- flashContainer: this.$el,
- data: {
- in_reply_to_discussion_id: this.note.reply_id,
- target_type: 'issue',
- target_id: this.discussion.noteable_id,
- note: { note: noteText },
- },
- };
- this.isReplying = false;
+ this.resetAutoSave();
+ this.isReplying = false;
+ },
+ saveReply(noteText, form, callback) {
+ const replyData = {
+ endpoint: this.newNotePath,
+ flashContainer: this.$el,
+ data: {
+ in_reply_to_discussion_id: this.note.reply_id,
+ target_type: this.noteableType,
+ target_id: this.discussion.noteable_id,
+ note: { note: noteText },
+ },
+ };
+ this.isReplying = false;
- this.saveNote(replyData)
- .then(() => {
- this.resetAutoSave();
- callback();
- })
- .catch((err) => {
- this.removePlaceholderNotes();
- this.isReplying = true;
- this.$nextTick(() => {
- const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
- Flash(msg, 'alert', this.$el);
- this.$refs.noteForm.note = noteText;
- callback(err);
- });
+ this.saveNote(replyData)
+ .then(() => {
+ this.resetAutoSave();
+ callback();
+ })
+ .catch(err => {
+ this.removePlaceholderNotes();
+ this.isReplying = true;
+ this.$nextTick(() => {
+ const msg = `Your comment could not be submitted!
+Please check your network connection and try again.`;
+ Flash(msg, 'alert', this.$el);
+ this.$refs.noteForm.note = noteText;
+ callback(err);
});
- },
- },
- mounted() {
- if (this.isReplying) {
- this.initAutoSave();
- }
+ });
},
- updated() {
- if (this.isReplying) {
- if (!this.autosave) {
- this.initAutoSave();
- } else {
- this.setAutoSave();
+ jumpToDiscussion() {
+ const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
+ const index = unresolvedIds.indexOf(this.note.id);
+
+ if (index >= 0 && index !== unresolvedIds.length) {
+ const nextId = unresolvedIds[index + 1];
+ const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
+
+ if (el) {
+ scrollToElement(el);
}
}
},
- };
+ },
+};
</script>
<template>
- <li class="note note-discussion timeline-entry">
+ <li
+ :data-discussion-id="note.id"
+ class="note note-discussion timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
@@ -164,7 +218,7 @@
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
- />
+ />
</div>
<div class="timeline-content">
<div class="discussion">
@@ -174,6 +228,7 @@
:created-at="discussion.created_at"
:note-id="discussion.id"
:include-toggle="true"
+ :expanded="note.expanded"
@toggleHandler="toggleDiscussionHandler"
action-text="started a discussion"
class="discussion"
@@ -184,13 +239,16 @@
:edited-by="lastUpdatedBy"
action-text="Last updated"
class-name="discussion-headline-light js-discussion-headline"
- />
- </div>
+ />
</div>
<div
v-if="note.expanded"
class="discussion-body">
- <div class="panel panel-default">
+ <component
+ :is="wrapperComponent"
+ :discussion="discussion"
+ :class="wrapperClass"
+ >
<div class="discussion-notes">
<ul class="notes">
<component
@@ -198,30 +256,88 @@
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
- />
+ />
</ul>
- <div
- :class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder">
- <button
- v-if="canReply && !isReplying"
- @click="showReplyForm"
- type="button"
- class="js-vue-discussion-reply btn btn-text-field"
- title="Add a reply">Reply...</button>
+ <div class="discussion-reply-holder">
+ <template v-if="!isReplying && canReply">
+ <div
+ class="btn-group-justified discussion-with-resolve-btn"
+ role="group">
+ <div
+ class="btn-group"
+ role="group">
+ <button
+ @click="showReplyForm"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
+ title="Add a reply">Reply...</button>
+ </div>
+ <div
+ v-if="note.resolvable"
+ class="btn-group"
+ role="group">
+ <button
+ @click="resolveHandler()"
+ type="button"
+ class="btn btn-default"
+ >
+ <i
+ v-if="isResolving"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin"
+ ></i>
+ {{ resolveButtonTitle }}
+ </button>
+ </div>
+ <div
+ v-if="note.resolvable"
+ class="btn-group discussion-actions"
+ role="group"
+ >
+ <div
+ v-if="!discussionResolved"
+ class="btn-group"
+ role="group">
+ <a
+ :href="note.resolve_with_issue_path"
+ v-tooltip
+ class="new-issue-for-discussion btn
+ btn-default discussion-create-issue-btn"
+ title="Resolve this discussion in a new issue"
+ data-container="body"
+ >
+ <span v-html="resolveDiscussionsSvg"></span>
+ </a>
+ </div>
+ <div
+ v-if="hasUnresolvedDiscussion"
+ class="btn-group"
+ role="group">
+ <button
+ @click="jumpToDiscussion"
+ v-tooltip
+ class="btn btn-default discussion-next-btn"
+ title="Jump to next unresolved discussion"
+ data-container="body"
+ >
+ <span v-html="nextDiscussionsSvg"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </template>
<note-form
v-if="isReplying"
save-button-title="Comment"
- :discussion="note"
+ :note="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
- ref="noteForm"
- />
+ ref="noteForm" />
<note-signed-out-widget v-if="!canReply" />
</div>
</div>
- </div>
+ </component>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 9186d6ff64a..3554027d2b4 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -1,140 +1,152 @@
<script>
- import { mapGetters, mapActions } from 'vuex';
- import { escape } from 'underscore';
- import Flash from '../../flash';
- import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
- import noteHeader from './note_header.vue';
- import noteActions from './note_actions.vue';
- import noteBody from './note_body.vue';
- import eventHub from '../event_hub';
+import $ from 'jquery';
+import { mapGetters, mapActions } from 'vuex';
+import { escape } from 'underscore';
+import Flash from '../../flash';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import noteHeader from './note_header.vue';
+import noteActions from './note_actions.vue';
+import noteBody from './note_body.vue';
+import eventHub from '../event_hub';
+import noteable from '../mixins/noteable';
+import resolvable from '../mixins/resolvable';
- export default {
- props: {
- note: {
- type: Object,
- required: true,
- },
+export default {
+ components: {
+ userAvatarLink,
+ noteHeader,
+ noteActions,
+ noteBody,
+ },
+ mixins: [noteable, resolvable],
+ props: {
+ note: {
+ type: Object,
+ required: true,
},
- data() {
+ },
+ data() {
+ return {
+ isEditing: false,
+ isDeleting: false,
+ isRequesting: false,
+ isResolving: false,
+ };
+ },
+ computed: {
+ ...mapGetters(['targetNoteHash', 'getUserData']),
+ author() {
+ return this.note.author;
+ },
+ classNameBindings() {
return {
- isEditing: false,
- isDeleting: false,
- isRequesting: false,
+ 'is-editing': this.isEditing && !this.isRequesting,
+ 'is-requesting being-posted': this.isRequesting,
+ 'disabled-content': this.isDeleting,
+ target: this.targetNoteHash === this.noteAnchorId,
};
},
- components: {
- userAvatarLink,
- noteHeader,
- noteActions,
- noteBody,
+ canReportAsAbuse() {
+ return (
+ this.note.report_abuse_path && this.author.id !== this.getUserData.id
+ );
},
- computed: {
- ...mapGetters([
- 'targetNoteHash',
- 'getUserData',
- ]),
- author() {
- return this.note.author;
- },
- classNameBindings() {
- return {
- 'is-editing': this.isEditing && !this.isRequesting,
- 'is-requesting being-posted': this.isRequesting,
- 'disabled-content': this.isDeleting,
- target: this.targetNoteHash === this.noteAnchorId,
- };
- },
- canReportAsAbuse() {
- return this.note.report_abuse_path && this.author.id !== this.getUserData.id;
- },
- noteAnchorId() {
- return `note_${this.note.id}`;
- },
+ noteAnchorId() {
+ return `note_${this.note.id}`;
},
- methods: {
- ...mapActions([
- 'deleteNote',
- 'updateNote',
- 'scrollToNoteIfNeeded',
- ]),
- editHandler() {
+ },
+
+ created() {
+ eventHub.$on('enterEditMode', ({ noteId }) => {
+ if (noteId === this.note.id) {
this.isEditing = true;
- },
- deleteHandler() {
- // eslint-disable-next-line no-alert
- if (confirm('Are you sure you want to delete this comment?')) {
- this.isDeleting = true;
+ this.scrollToNoteIfNeeded($(this.$el));
+ }
+ });
+ },
- this.deleteNote(this.note)
- .then(() => {
- this.isDeleting = false;
- })
- .catch(() => {
- Flash('Something went wrong while deleting your note. Please try again.');
- this.isDeleting = false;
- });
- }
- },
- formUpdateHandler(noteText, parentElement, callback) {
- const data = {
- endpoint: this.note.path,
- note: {
- target_type: 'issue',
- target_id: this.note.noteable_id,
- note: { note: noteText },
- },
- };
- this.isRequesting = true;
- this.oldContent = this.note.note_html;
- this.note.note_html = escape(noteText);
+ methods: {
+ ...mapActions([
+ 'deleteNote',
+ 'updateNote',
+ 'toggleResolveNote',
+ 'scrollToNoteIfNeeded',
+ ]),
+ editHandler() {
+ this.isEditing = true;
+ },
+ deleteHandler() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Are you sure you want to delete this comment?')) {
+ this.isDeleting = true;
- this.updateNote(data)
+ this.deleteNote(this.note)
.then(() => {
- this.isEditing = false;
- this.isRequesting = false;
- $(this.$refs.noteBody.$el).renderGFM();
- this.$refs.noteBody.resetAutoSave();
- callback();
+ this.isDeleting = false;
})
.catch(() => {
- this.isRequesting = false;
- this.isEditing = true;
- this.$nextTick(() => {
- const msg = 'Something went wrong while editing your comment. Please try again.';
- Flash(msg, 'alert', this.$el);
- this.recoverNoteContent(noteText);
- callback();
- });
+ Flash(
+ 'Something went wrong while deleting your note. Please try again.',
+ );
+ this.isDeleting = false;
});
- },
- formCancelHandler(shouldConfirm, isDirty) {
- if (shouldConfirm && isDirty) {
- // eslint-disable-next-line no-alert
- if (!confirm('Are you sure you want to cancel editing this comment?')) return;
- }
- this.$refs.noteBody.resetAutoSave();
- if (this.oldContent) {
- this.note.note_html = this.oldContent;
- this.oldContent = null;
- }
- this.isEditing = false;
- },
- recoverNoteContent(noteText) {
- // we need to do this to prevent noteForm inconsistent content warning
- // this is something we intentionally do so we need to recover the content
- this.note.note = noteText;
- this.$refs.noteBody.$refs.noteForm.note = noteText;
- },
+ }
},
- created() {
- eventHub.$on('enterEditMode', ({ noteId }) => {
- if (noteId === this.note.id) {
+ formUpdateHandler(noteText, parentElement, callback) {
+ const data = {
+ endpoint: this.note.path,
+ note: {
+ target_type: this.noteableType,
+ target_id: this.note.noteable_id,
+ note: { note: noteText },
+ },
+ };
+ this.isRequesting = true;
+ this.oldContent = this.note.note_html;
+ this.note.note_html = escape(noteText);
+
+ this.updateNote(data)
+ .then(() => {
+ this.isEditing = false;
+ this.isRequesting = false;
+ this.oldContent = null;
+ $(this.$refs.noteBody.$el).renderGFM();
+ this.$refs.noteBody.resetAutoSave();
+ callback();
+ })
+ .catch(() => {
+ this.isRequesting = false;
this.isEditing = true;
- this.scrollToNoteIfNeeded($(this.$el));
- }
- });
+ this.$nextTick(() => {
+ const msg =
+ 'Something went wrong while editing your comment. Please try again.';
+ Flash(msg, 'alert', this.$el);
+ this.recoverNoteContent(noteText);
+ callback();
+ });
+ });
+ },
+ formCancelHandler(shouldConfirm, isDirty) {
+ if (shouldConfirm && isDirty) {
+ // eslint-disable-next-line no-alert
+ if (!confirm('Are you sure you want to cancel editing this comment?'))
+ return;
+ }
+ this.$refs.noteBody.resetAutoSave();
+ if (this.oldContent) {
+ this.note.note_html = this.oldContent;
+ this.oldContent = null;
+ }
+ this.isEditing = false;
+ },
+ recoverNoteContent(noteText) {
+ // we need to do this to prevent noteForm inconsistent content warning
+ // this is something we intentionally do so we need to recover the content
+ this.note.note = noteText;
+ this.$refs.noteBody.$refs.noteForm.note.note = noteText;
},
- };
+ },
+};
</script>
<template>
@@ -150,7 +162,7 @@
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
- />
+ />
</div>
<div class="timeline-content">
<div class="note-header">
@@ -159,7 +171,7 @@
:created-at="note.created_at"
:note-id="note.id"
action-text="commented"
- />
+ />
<note-actions
:author-id="author.id"
:note-id="note.id"
@@ -168,9 +180,14 @@
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path"
+ :resolvable="note.resolvable"
+ :is-resolved="note.resolved"
+ :is-resolving="isResolving"
+ :resolved-by="note.resolved_by"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
- />
+ @handleResolve="resolveHandler"
+ />
</div>
<note-body
:note="note"
@@ -179,7 +196,7 @@
@handleFormUpdate="formUpdateHandler"
@cancelFormEdition="formCancelHandler"
ref="noteBody"
- />
+ />
</div>
</div>
</li>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index c4cae4b3b6f..ebfc827ac57 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,152 +1,175 @@
<script>
- import { mapGetters, mapActions } from 'vuex';
- import { getLocationHash } from '../../lib/utils/url_utility';
- import Flash from '../../flash';
- import store from '../stores/';
- import * as constants from '../constants';
- import noteableNote from './noteable_note.vue';
- import noteableDiscussion from './noteable_discussion.vue';
- import systemNote from '../../vue_shared/components/notes/system_note.vue';
- import commentForm from './comment_form.vue';
- import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
- import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
- import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import $ from 'jquery';
+import { mapGetters, mapActions } from 'vuex';
+import { getLocationHash } from '../../lib/utils/url_utility';
+import Flash from '../../flash';
+import store from '../stores/';
+import * as constants from '../constants';
+import noteableNote from './noteable_note.vue';
+import noteableDiscussion from './noteable_discussion.vue';
+import systemNote from '../../vue_shared/components/notes/system_note.vue';
+import commentForm from './comment_form.vue';
+import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
+import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
- export default {
- name: 'notesApp',
- props: {
- noteableData: {
- type: Object,
- required: true,
- },
- notesData: {
- type: Object,
- required: true,
- },
- userData: {
- type: Object,
- required: false,
- default: {},
- },
+export default {
+ name: 'NotesApp',
+ components: {
+ noteableNote,
+ noteableDiscussion,
+ systemNote,
+ commentForm,
+ loadingIcon,
+ placeholderNote,
+ placeholderSystemNote,
+ },
+ props: {
+ noteableData: {
+ type: Object,
+ required: true,
},
- store,
- data() {
- return {
- isLoading: true,
- };
+ notesData: {
+ type: Object,
+ required: true,
},
- components: {
- noteableNote,
- noteableDiscussion,
- systemNote,
- commentForm,
- loadingIcon,
- placeholderNote,
- placeholderSystemNote,
+ userData: {
+ type: Object,
+ required: false,
+ default: () => ({}),
},
- computed: {
- ...mapGetters([
- 'notes',
- 'getNotesDataByProp',
- ]),
+ },
+ store,
+ data() {
+ return {
+ isLoading: true,
+ };
+ },
+ computed: {
+ ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']),
+ noteableType() {
+ return this.noteableData.noteableType;
},
- methods: {
- ...mapActions({
- actionFetchNotes: 'fetchNotes',
- poll: 'poll',
- actionToggleAward: 'toggleAward',
- scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
- setNotesData: 'setNotesData',
- setNoteableData: 'setNoteableData',
- setUserData: 'setUserData',
- setLastFetchedAt: 'setLastFetchedAt',
- setTargetNoteHash: 'setTargetNoteHash',
- }),
- getComponentName(note) {
- if (note.isPlaceholderNote) {
- if (note.placeholderType === constants.SYSTEM_NOTE) {
- return placeholderSystemNote;
- }
- return placeholderNote;
- } else if (note.individual_note) {
- return note.notes[0].system ? systemNote : noteableNote;
- }
+ allNotes() {
+ if (this.isLoading) {
+ const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0;
- return noteableDiscussion;
- },
- getComponentData(note) {
- return note.individual_note ? note.notes[0] : note;
- },
- fetchNotes() {
- return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath'))
- .then(() => this.initPolling())
- .then(() => {
- this.isLoading = false;
- })
- .then(() => this.$nextTick())
- .then(() => this.checkLocationHash())
- .catch(() => {
- this.isLoading = false;
- Flash('Something went wrong while fetching comments. Please try again.');
- });
- },
- initPolling() {
- this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
+ return new Array(totalNotes).fill({
+ isSkeletonNote: true,
+ });
+ }
+ return this.notes;
+ },
+ },
+ created() {
+ this.setNotesData(this.notesData);
+ this.setNoteableData(this.noteableData);
+ this.setUserData(this.userData);
+ },
+ mounted() {
+ this.fetchNotes();
- this.poll();
- },
- checkLocationHash() {
- const hash = getLocationHash();
- const element = document.getElementById(hash);
+ const parentElement = this.$el.parentElement;
- if (hash && element) {
- this.setTargetNoteHash(hash);
- this.scrollToNoteIfNeeded($(element));
+ if (
+ parentElement &&
+ parentElement.classList.contains('js-vue-notes-event')
+ ) {
+ parentElement.addEventListener('toggleAward', event => {
+ const { awardName, noteId } = event.detail;
+ this.actionToggleAward({ awardName, noteId });
+ });
+ }
+ document.addEventListener('refreshVueNotes', this.fetchNotes);
+ },
+ beforeDestroy() {
+ document.removeEventListener('refreshVueNotes', this.fetchNotes);
+ },
+ methods: {
+ ...mapActions({
+ actionFetchNotes: 'fetchNotes',
+ poll: 'poll',
+ actionToggleAward: 'toggleAward',
+ scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
+ setNotesData: 'setNotesData',
+ setNoteableData: 'setNoteableData',
+ setUserData: 'setUserData',
+ setLastFetchedAt: 'setLastFetchedAt',
+ setTargetNoteHash: 'setTargetNoteHash',
+ }),
+ getComponentName(note) {
+ if (note.isSkeletonNote) {
+ return skeletonLoadingContainer;
+ }
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === constants.SYSTEM_NOTE) {
+ return placeholderSystemNote;
}
- },
+ return placeholderNote;
+ } else if (note.individual_note) {
+ return note.notes[0].system ? systemNote : noteableNote;
+ }
+
+ return noteableDiscussion;
},
- created() {
- this.setNotesData(this.notesData);
- this.setNoteableData(this.noteableData);
- this.setUserData(this.userData);
+ getComponentData(note) {
+ return note.individual_note ? note.notes[0] : note;
},
- mounted() {
- this.fetchNotes();
+ fetchNotes() {
+ return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath'))
+ .then(() => this.initPolling())
+ .then(() => {
+ this.isLoading = false;
+ })
+ .then(() => this.$nextTick())
+ .then(() => this.checkLocationHash())
+ .catch(() => {
+ this.isLoading = false;
+ Flash(
+ 'Something went wrong while fetching comments. Please try again.',
+ );
+ });
+ },
+ initPolling() {
+ if (this.isPollingInitialized) {
+ return;
+ }
- const parentElement = this.$el.parentElement;
+ this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
- if (parentElement &&
- parentElement.classList.contains('js-vue-notes-event')) {
- parentElement.addEventListener('toggleAward', (event) => {
- const { awardName, noteId } = event.detail;
- this.actionToggleAward({ awardName, noteId });
- });
+ this.poll();
+ this.isPollingInitialized = true;
+ },
+ checkLocationHash() {
+ const hash = getLocationHash();
+ const element = document.getElementById(hash);
+
+ if (hash && element) {
+ this.setTargetNoteHash(hash);
+ this.scrollToNoteIfNeeded($(element));
}
},
- };
+ },
+};
</script>
<template>
<div id="notes">
- <div
- v-if="isLoading"
- class="js-loading loading">
- <loading-icon />
- </div>
-
<ul
- v-if="!isLoading"
id="notes-list"
class="notes main-notes-list timeline">
<component
- v-for="note in notes"
+ v-for="note in allNotes"
:is="getComponentName(note)"
:note="getComponentData(note)"
:key="note.id"
- />
+ />
</ul>
- <comment-form />
+ <comment-form
+ :noteable-type="noteableType"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index a6961063c01..c4de4826eda 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -1,4 +1,5 @@
export const DISCUSSION_NOTE = 'DiscussionNote';
+export const DIFF_NOTE = 'DiffNote';
export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
@@ -8,4 +9,14 @@ export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
-export const NOTEABLE_TYPE = 'Issue';
+export const ISSUE_NOTEABLE_TYPE = 'issue';
+export const EPIC_NOTEABLE_TYPE = 'epic';
+export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
+export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
+export const RESOLVE_NOTE_METHOD_NAME = 'post';
+
+export const NOTEABLE_TYPE_MAPPING = {
+ Issue: ISSUE_NOTEABLE_TYPE,
+ MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE,
+ Epic: EPIC_NOTEABLE_TYPE,
+};
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index d250dd8d25b..e4121f151db 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,43 +1,46 @@
import Vue from 'vue';
import notesApp from './components/notes_app.vue';
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: '#js-vue-notes',
- components: {
- notesApp,
- },
- data() {
- const notesDataset = document.getElementById('js-vue-notes').dataset;
- const parsedUserData = JSON.parse(notesDataset.currentUserData);
- const currentUserData = parsedUserData ? {
- id: parsedUserData.id,
- name: parsedUserData.name,
- username: parsedUserData.username,
- avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
- path: parsedUserData.path,
- } : {};
+document.addEventListener(
+ 'DOMContentLoaded',
+ () =>
+ new Vue({
+ el: '#js-vue-notes',
+ components: {
+ notesApp,
+ },
+ data() {
+ const notesDataset = document.getElementById('js-vue-notes').dataset;
+ const parsedUserData = JSON.parse(notesDataset.currentUserData);
+ const noteableData = JSON.parse(notesDataset.noteableData);
+ let currentUserData = {};
+
+ noteableData.noteableType = notesDataset.noteableType;
+
+ if (parsedUserData) {
+ currentUserData = {
+ id: parsedUserData.id,
+ name: parsedUserData.name,
+ username: parsedUserData.username,
+ avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
+ path: parsedUserData.path,
+ };
+ }
- return {
- noteableData: JSON.parse(notesDataset.noteableData),
- currentUserData,
- notesData: {
- lastFetchedAt: notesDataset.lastFetchedAt,
- discussionsPath: notesDataset.discussionsPath,
- newSessionPath: notesDataset.newSessionPath,
- registerPath: notesDataset.registerPath,
- notesPath: notesDataset.notesPath,
- markdownDocsPath: notesDataset.markdownDocsPath,
- quickActionsDocsPath: notesDataset.quickActionsDocsPath,
+ return {
+ noteableData,
+ currentUserData,
+ notesData: JSON.parse(notesDataset.notesData),
+ };
},
- };
- },
- render(createElement) {
- return createElement('notes-app', {
- props: {
- noteableData: this.noteableData,
- notesData: this.notesData,
- userData: this.currentUserData,
+ render(createElement) {
+ return createElement('notes-app', {
+ props: {
+ noteableData: this.noteableData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ },
+ });
},
- });
- },
-}));
+ }),
+);
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
index a008171beda..3dff715905f 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -1,9 +1,15 @@
+import $ from 'jquery';
import Autosave from '../../autosave';
+import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default {
methods: {
- initAutoSave() {
- this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
+ initAutoSave(noteableType) {
+ this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [
+ 'Note',
+ capitalizeFirstCharacter(noteableType),
+ this.note.id,
+ ]);
},
resetAutoSave() {
this.autosave.reset();
diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js
new file mode 100644
index 00000000000..b68543d71c8
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/noteable.js
@@ -0,0 +1,15 @@
+import * as constants from '../constants';
+
+export default {
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ noteableType() {
+ return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type];
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
new file mode 100644
index 00000000000..f79049b85f6
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -0,0 +1,55 @@
+import Flash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ discussionResolved() {
+ const { notes, resolved } = this.note;
+
+ if (notes) {
+ // Decide resolved state using store. Only valid for discussions.
+ return notes.every(note => note.resolved && !note.system);
+ }
+
+ return resolved;
+ },
+ resolveButtonTitle() {
+ if (this.updatedNoteBody) {
+ if (this.discussionResolved) {
+ return __('Comment and unresolve discussion');
+ }
+
+ return __('Comment and resolve discussion');
+ }
+ return this.discussionResolved
+ ? __('Unresolve discussion')
+ : __('Resolve discussion');
+ },
+ },
+ methods: {
+ resolveHandler(resolvedState = false) {
+ this.isResolving = true;
+ const endpoint = this.note.resolve_path || `${this.note.path}/resolve`;
+ const isResolved = this.discussionResolved || resolvedState;
+ const discussion = this.resolveAsThread;
+
+ this.toggleResolveNote({ endpoint, isResolved, discussion })
+ .then(() => {
+ this.isResolving = false;
+ })
+ .catch(() => {
+ this.isResolving = false;
+ const msg = __(
+ 'Something went wrong while resolving this discussion. Please try again.',
+ );
+ Flash(msg, 'alert', this.$el);
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
index b51b0cb2013..7c623aac6ed 100644
--- a/app/assets/javascripts/notes/services/notes_service.js
+++ b/app/assets/javascripts/notes/services/notes_service.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
+import * as constants from '../constants';
Vue.use(VueResource);
@@ -19,11 +20,20 @@ export default {
createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
+ toggleResolveNote(endpoint, isResolved) {
+ const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants;
+ const method = isResolved
+ ? UNRESOLVE_NOTE_METHOD_NAME
+ : RESOLVE_NOTE_METHOD_NAME;
+
+ return Vue.http[method](endpoint);
+ },
poll(data = {}) {
- const { endpoint, lastFetchedAt } = data;
+ const endpoint = data.notesData.notesPath;
+ const lastFetchedAt = data.lastFetchedAt;
const options = {
headers: {
- 'X-Last-Fetched-At': lastFetchedAt,
+ 'X-Last-Fetched-At': lastFetchedAt ? `${lastFetchedAt}` : undefined,
},
};
@@ -32,4 +42,7 @@ export default {
toggleAward(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
+ toggleIssueState(endpoint, data) {
+ return Vue.http.put(endpoint, data);
+ },
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 085b18642ba..244a6980b5a 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Visibility from 'visibilityjs';
import Flash from '../../flash';
import Poll from '../../lib/utils/poll';
@@ -11,56 +12,127 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils';
let eTagPoll;
-export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
-export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data);
-export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
-export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
-export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data);
-export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data);
-export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
-
-export const fetchNotes = ({ commit }, path) => service
- .fetchNotes(path)
- .then(res => res.json())
- .then((res) => {
- commit(types.SET_INITIAL_NOTES, res);
- });
+export const setNotesData = ({ commit }, data) =>
+ commit(types.SET_NOTES_DATA, data);
+export const setNoteableData = ({ commit }, data) =>
+ commit(types.SET_NOTEABLE_DATA, data);
+export const setUserData = ({ commit }, data) =>
+ commit(types.SET_USER_DATA, data);
+export const setLastFetchedAt = ({ commit }, data) =>
+ commit(types.SET_LAST_FETCHED_AT, data);
+export const setInitialNotes = ({ commit }, data) =>
+ commit(types.SET_INITIAL_NOTES, data);
+export const setTargetNoteHash = ({ commit }, data) =>
+ commit(types.SET_TARGET_NOTE_HASH, data);
+export const toggleDiscussion = ({ commit }, data) =>
+ commit(types.TOGGLE_DISCUSSION, data);
+
+export const fetchNotes = ({ commit }, path) =>
+ service
+ .fetchNotes(path)
+ .then(res => res.json())
+ .then(res => {
+ commit(types.SET_INITIAL_NOTES, res);
+ });
-export const deleteNote = ({ commit }, note) => service
- .deleteNote(note.path)
- .then(() => {
+export const deleteNote = ({ commit }, note) =>
+ service.deleteNote(note.path).then(() => {
commit(types.DELETE_NOTE, note);
});
-export const updateNote = ({ commit }, { endpoint, note }) => service
- .updateNote(endpoint, note)
- .then(res => res.json())
- .then((res) => {
- commit(types.UPDATE_NOTE, res);
- });
+export const updateNote = ({ commit }, { endpoint, note }) =>
+ service
+ .updateNote(endpoint, note)
+ .then(res => res.json())
+ .then(res => {
+ commit(types.UPDATE_NOTE, res);
+ });
-export const replyToDiscussion = ({ commit }, { endpoint, data }) => service
- .replyToDiscussion(endpoint, data)
- .then(res => res.json())
- .then((res) => {
- commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
+export const replyToDiscussion = ({ commit }, { endpoint, data }) =>
+ service
+ .replyToDiscussion(endpoint, data)
+ .then(res => res.json())
+ .then(res => {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
- return res;
- });
+ return res;
+ });
-export const createNewNote = ({ commit }, { endpoint, data }) => service
- .createNewNote(endpoint, data)
- .then(res => res.json())
- .then((res) => {
- if (!res.errors) {
- commit(types.ADD_NEW_NOTE, res);
- }
- return res;
- });
+export const createNewNote = ({ commit }, { endpoint, data }) =>
+ service
+ .createNewNote(endpoint, data)
+ .then(res => res.json())
+ .then(res => {
+ if (!res.errors) {
+ commit(types.ADD_NEW_NOTE, res);
+ }
+ return res;
+ });
export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES);
+export const toggleResolveNote = (
+ { commit },
+ { endpoint, isResolved, discussion },
+) =>
+ service
+ .toggleResolveNote(endpoint, isResolved)
+ .then(res => res.json())
+ .then(res => {
+ const mutationType = discussion
+ ? types.UPDATE_DISCUSSION
+ : types.UPDATE_NOTE;
+
+ commit(mutationType, res);
+ });
+
+export const closeIssue = ({ commit, dispatch, state }) => {
+ dispatch('toggleStateButtonLoading', true);
+ return service
+ .toggleIssueState(state.notesData.closePath)
+ .then(res => res.json())
+ .then(data => {
+ commit(types.CLOSE_ISSUE);
+ dispatch('emitStateChangedEvent', data);
+ dispatch('toggleStateButtonLoading', false);
+ });
+};
+
+export const reopenIssue = ({ commit, dispatch, state }) => {
+ dispatch('toggleStateButtonLoading', true);
+ return service
+ .toggleIssueState(state.notesData.reopenPath)
+ .then(res => res.json())
+ .then(data => {
+ commit(types.REOPEN_ISSUE);
+ dispatch('emitStateChangedEvent', data);
+ dispatch('toggleStateButtonLoading', false);
+ });
+};
+
+export const toggleStateButtonLoading = ({ commit }, value) =>
+ commit(types.TOGGLE_STATE_BUTTON_LOADING, value);
+
+export const emitStateChangedEvent = ({ commit, getters }, data) => {
+ const event = new CustomEvent('issuable_vue_app:change', {
+ detail: {
+ data,
+ isClosed: getters.openState === constants.CLOSED,
+ },
+ });
+
+ document.dispatchEvent(event);
+};
+
+export const toggleIssueLocalState = ({ commit }, newState) => {
+ if (newState === constants.CLOSED) {
+ commit(types.CLOSE_ISSUE);
+ } else if (newState === constants.REOPENED) {
+ commit(types.REOPEN_ISSUE);
+ }
+};
+
export const saveNote = ({ commit, dispatch }, noteData) => {
const { note } = noteData.data.note;
let placeholderText = note;
@@ -90,59 +162,70 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
});
}
- return dispatch(methodToDispatch, noteData)
- .then((res) => {
- const { errors } = res;
- const commandsChanges = res.commands_changes;
+ return dispatch(methodToDispatch, noteData).then(res => {
+ const { errors } = res;
+ const commandsChanges = res.commands_changes;
- if (hasQuickActions && errors && Object.keys(errors).length) {
- eTagPoll.makeRequest();
+ if (hasQuickActions && errors && Object.keys(errors).length) {
+ eTagPoll.makeRequest();
- $('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash('Commands applied', 'notice', noteData.flashContainer);
- }
-
- if (commandsChanges) {
- if (commandsChanges.emoji_award) {
- const votesBlock = $('.js-awards-block').eq(0);
-
- loadAwardsHandler()
- .then((awardsHandler) => {
- awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
- awardsHandler.scrollToAwards();
- })
- .catch(() => {
- Flash(
- 'Something went wrong while adding your award. Please try again.',
- 'alert',
- noteData.flashContainer,
- );
- });
- }
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+ Flash('Commands applied', 'notice', noteData.flashContainer);
+ }
- if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
- sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
- }
+ if (commandsChanges) {
+ if (commandsChanges.emoji_award) {
+ const votesBlock = $('.js-awards-block').eq(0);
+
+ loadAwardsHandler()
+ .then(awardsHandler => {
+ awardsHandler.addAwardToEmojiBar(
+ votesBlock,
+ commandsChanges.emoji_award,
+ );
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ Flash(
+ 'Something went wrong while adding your award. Please try again.',
+ 'alert',
+ noteData.flashContainer,
+ );
+ });
}
- if (errors && errors.commands_only) {
- Flash(errors.commands_only, 'notice', noteData.flashContainer);
+ if (
+ commandsChanges.spend_time != null ||
+ commandsChanges.time_estimate != null
+ ) {
+ sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
}
- commit(types.REMOVE_PLACEHOLDER_NOTES);
+ }
- return res;
- });
+ if (errors && errors.commands_only) {
+ Flash(errors.commands_only, 'notice', noteData.flashContainer);
+ }
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
+
+ return res;
+ });
};
const pollSuccessCallBack = (resp, commit, state, getters) => {
if (resp.notes && resp.notes.length) {
const { notesById } = getters;
- resp.notes.forEach((note) => {
+ resp.notes.forEach(note => {
if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note);
- } else if (note.type === constants.DISCUSSION_NOTE) {
- const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
+ } else if (
+ note.type === constants.DISCUSSION_NOTE ||
+ note.type === constants.DIFF_NOTE
+ ) {
+ const discussion = utils.findNoteObjectById(
+ state.notes,
+ note.discussion_id,
+ );
if (discussion) {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
@@ -155,27 +238,28 @@ const pollSuccessCallBack = (resp, commit, state, getters) => {
});
}
- commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt);
+ commit(types.SET_LAST_FETCHED_AT, resp.last_fetched_at);
return resp;
};
export const poll = ({ commit, state, getters }) => {
- const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
-
eTagPoll = new Poll({
resource: service,
method: 'poll',
- data: requestData,
- successCallback: resp => resp.json()
- .then(data => pollSuccessCallBack(data, commit, state, getters)),
- errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
+ data: state,
+ successCallback: resp =>
+ resp
+ .json()
+ .then(data => pollSuccessCallBack(data, commit, state, getters)),
+ errorCallback: () =>
+ Flash('Something went wrong while fetching latest comments.'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
- service.poll(requestData);
+ service.poll(state);
}
Visibility.change(() => {
@@ -196,15 +280,22 @@ export const restartPolling = () => {
};
export const fetchData = ({ commit, state, getters }) => {
- const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
+ const requestData = {
+ endpoint: state.notesData.notesPath,
+ lastFetchedAt: state.lastFetchedAt,
+ };
- service.poll(requestData)
+ service
+ .poll(requestData)
.then(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters))
.catch(() => Flash('Something went wrong while fetching latest comments.'));
};
-export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => {
+export const toggleAward = (
+ { commit, state, getters, dispatch },
+ { awardName, noteId },
+) => {
commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index e18b277119e..f89591a54d6 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -8,24 +8,63 @@ export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+export const openState = state => state.noteableData.state;
export const getUserData = state => state.userData || {};
-export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
+export const getUserDataByProp = state => prop =>
+ state.userData && state.userData[prop];
-export const notesById = state => state.notes.reduce((acc, note) => {
- note.notes.every(n => Object.assign(acc, { [n.id]: n }));
- return acc;
-}, {});
+export const notesById = state =>
+ state.notes.reduce((acc, note) => {
+ note.notes.every(n => Object.assign(acc, { [n.id]: n }));
+ return acc;
+ }, {});
const reverseNotes = array => array.slice(0).reverse();
-const isLastNote = (note, state) => !note.system &&
- state.userData && note.author &&
+const isLastNote = (note, state) =>
+ !note.system &&
+ state.userData &&
+ note.author &&
note.author.id === state.userData.id;
-export const getCurrentUserLastNote = state => _.flatten(
- reverseNotes(state.notes)
- .map(note => reverseNotes(note.notes)),
+export const getCurrentUserLastNote = state =>
+ _.flatten(
+ reverseNotes(state.notes).map(note => reverseNotes(note.notes)),
).find(el => isLastNote(el, state));
-export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
- .find(el => isLastNote(el, state));
+export const getDiscussionLastNote = state => discussion =>
+ reverseNotes(discussion.notes).find(el => isLastNote(el, state));
+
+export const discussionCount = state => {
+ const discussions = state.notes.filter(n => !n.individual_note);
+
+ return discussions.length;
+};
+
+export const unresolvedDiscussions = (state, getters) => {
+ const resolvedMap = getters.resolvedDiscussionsById;
+
+ return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]);
+};
+
+export const resolvedDiscussionsById = state => {
+ const map = {};
+
+ state.notes.forEach(n => {
+ if (n.notes) {
+ const resolved = n.notes.every(note => note.resolved && !note.system);
+
+ if (resolved) {
+ map[n.id] = n;
+ }
+ }
+ });
+
+ return map;
+};
+
+export const resolvedDiscussionCount = (state, getters) => {
+ const resolvedMap = getters.resolvedDiscussionsById;
+
+ return Object.keys(resolvedMap).length;
+};
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
index 488a9ca38d3..9ed19bf171e 100644
--- a/app/assets/javascripts/notes/stores/index.js
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -12,6 +12,9 @@ export default new Vuex.Store({
targetNoteHash: null,
lastFetchedAt: null,
+ // View layer
+ isToggleStateButtonLoading: false,
+
// holds endpoints and permissions provided through haml
notesData: {},
userData: {},
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index d520c197407..b455e23ecde 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -12,3 +12,9 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE';
+export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
+
+// Issue
+export const CLOSE_ISSUE = 'CLOSE_ISSUE';
+export const REOPEN_ISSUE = 'REOPEN_ISSUE';
+export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 20f81a430c2..c8edc06349f 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -1,22 +1,32 @@
import * as utils from './utils';
import * as types from './mutation_types';
import * as constants from '../constants';
+import { isInMRPage } from '../../lib/utils/common_utils';
export default {
[types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note;
const [exists] = state.notes.filter(n => n.id === note.discussion_id);
+ const isDiscussion = type === constants.DISCUSSION_NOTE;
if (!exists) {
const noteData = {
expanded: true,
id: discussion_id,
- individual_note: !(type === constants.DISCUSSION_NOTE),
+ individual_note: !isDiscussion,
notes: [note],
reply_id: discussion_id,
};
+ if (isDiscussion && isInMRPage()) {
+ noteData.resolvable = note.resolvable;
+ noteData.resolved = false;
+ noteData.resolve_path = note.resolve_path;
+ noteData.resolve_with_issue_path = note.resolve_with_issue_path;
+ }
+
state.notes.push(noteData);
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}
},
@@ -25,6 +35,7 @@ export default {
if (noteObj) {
noteObj.notes.push(note);
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}
},
@@ -41,6 +52,8 @@ export default {
state.notes.splice(state.notes.indexOf(noteObj), 1);
}
}
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.REMOVE_PLACEHOLDER_NOTES](state) {
@@ -50,13 +63,15 @@ export default {
const note = notes[i];
const children = note.notes;
- if (children.length && !note.individual_note) { // remove placeholder from discussions
+ if (children.length && !note.individual_note) {
+ // remove placeholder from discussions
for (let j = children.length - 1; j >= 0; j -= 1) {
if (children[j].isPlaceholderNote) {
children.splice(j, 1);
}
}
- } else if (note.isPlaceholderNote) { // remove placeholders from state root
+ } else if (note.isPlaceholderNote) {
+ // remove placeholders from state root
notes.splice(i, 1);
}
}
@@ -76,16 +91,22 @@ export default {
[types.SET_INITIAL_NOTES](state, notesData) {
const notes = [];
- notesData.forEach((note) => {
+ notesData.forEach(note => {
// To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) {
- note.notes.forEach((n) => {
- const nn = Object.assign({}, note);
- nn.notes = [n]; // override notes array to only have one item to mimick individual_note
- notes.push(nn);
+ note.notes.forEach(n => {
+ notes.push({
+ ...note,
+ notes: [n], // override notes array to only have one item to mimick individual_note
+ });
});
} else {
- notes.push(note);
+ const oldNote = utils.findNoteObjectById(state.notes, note.id);
+
+ notes.push({
+ ...note,
+ expanded: oldNote ? oldNote.expanded : note.expanded,
+ });
}
});
@@ -109,7 +130,9 @@ export default {
notesArr.push({
individual_note: true,
isPlaceholderNote: true,
- placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE,
+ placeholderType: data.isSystemNote
+ ? constants.SYSTEM_NOTE
+ : constants.NOTE,
notes: [
{
body: data.noteBody,
@@ -122,18 +145,24 @@ export default {
const { awardName, note } = data;
const { id, name, username } = state.userData;
- const hasEmojiAwardedByCurrentUser = note.award_emoji
- .filter(emoji => emoji.name === data.awardName && emoji.user.id === id);
+ const hasEmojiAwardedByCurrentUser = note.award_emoji.filter(
+ emoji => emoji.name === data.awardName && emoji.user.id === id,
+ );
if (hasEmojiAwardedByCurrentUser.length) {
// If current user has awarded this emoji, remove it.
- note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1);
+ note.award_emoji.splice(
+ note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]),
+ 1,
+ );
} else {
note.award_emoji.push({
name: awardName,
user: { id, name, username },
});
}
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
@@ -151,5 +180,35 @@ export default {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
}
+
+ // document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ },
+
+ [types.UPDATE_DISCUSSION](state, noteData) {
+ const note = noteData;
+ let index = 0;
+
+ state.notes.forEach((n, i) => {
+ if (n.id === note.id) {
+ index = i;
+ }
+ });
+
+ note.expanded = true; // override expand flag to prevent collapse
+ state.notes.splice(index, 1, note);
+
+ document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
+ },
+
+ [types.CLOSE_ISSUE](state) {
+ Object.assign(state.noteableData, { state: constants.CLOSED });
+ },
+
+ [types.REOPEN_ISSUE](state) {
+ Object.assign(state.noteableData, { state: constants.REOPENED });
+ },
+
+ [types.TOGGLE_STATE_BUTTON_LOADING](state, value) {
+ Object.assign(state, { isToggleStateButtonLoading: value });
},
};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index 6074115e855..a0e096ebfaf 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -2,13 +2,15 @@ import AjaxCache from '~/lib/utils/ajax_cache';
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
-export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
+export const findNoteObjectById = (notes, id) =>
+ notes.filter(n => n.id === id)[0];
-export const getQuickActionText = (note) => {
+export const getQuickActionText = note => {
let text = 'Applying command';
- const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
+ const quickActions =
+ AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
- const executedCommands = quickActions.filter((command) => {
+ const executedCommands = quickActions.filter(command => {
const commandRegex = new RegExp(`/${command.name}`);
return commandRegex.test(note);
});
@@ -27,5 +29,5 @@ export const getQuickActionText = (note) => {
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
-export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
-
+export const stripQuickActions = note =>
+ note.replace(REGEX_QUICK_ACTIONS, '').trim();
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index 9570d1c00aa..8ff8bb446ad 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -1,13 +1,14 @@
+import $ from 'jquery';
import Flash from './flash';
export default function notificationsDropdown() {
$(document).on('click', '.update-notification', function updateNotificationCallback(e) {
e.preventDefault();
- if ($(this).is('.is-active') && $(this).data('notification-level') === 'custom') {
+ if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') {
return;
}
- const notificationLevel = $(this).data('notification-level');
+ const notificationLevel = $(this).data('notificationLevel');
const form = $(this).parents('.notification-form:first');
form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner');
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 4534360d577..9e6cf67dff0 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -1,3 +1,8 @@
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+
export default class NotificationsForm {
constructor() {
this.toggleCheckbox = this.toggleCheckbox.bind(this);
@@ -27,24 +32,20 @@ export default class NotificationsForm {
saveEvent($checkbox, $parent) {
const form = $parent.parents('form:first');
- return $.ajax({
- url: form.attr('action'),
- method: form.attr('method'),
- dataType: 'json',
- data: form.serialize(),
- beforeSend: () => {
- this.showCheckboxLoadingSpinner($parent);
- },
- }).done((data) => {
- $checkbox.enable();
- if (data.saved) {
- $parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done');
- setTimeout(() => {
- $parent.removeClass('is-loading')
- .find('.custom-notification-event-loading')
- .toggleClass('fa-spin fa-spinner fa-check is-done');
- }, 2000);
- }
- });
+ this.showCheckboxLoadingSpinner($parent);
+
+ axios[form.attr('method')](form.attr('action'), form.serialize())
+ .then(({ data }) => {
+ $checkbox.enable();
+ if (data.saved) {
+ $parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done');
+ setTimeout(() => {
+ $parent.removeClass('is-loading')
+ .find('.custom-notification-event-loading')
+ .toggleClass('fa-spin fa-spinner fa-check is-done');
+ }, 2000);
+ }
+ })
+ .catch(() => flash(__('There was an error saving your notification settings.')));
}
}
diff --git a/app/assets/javascripts/oauth_remember_me.js b/app/assets/javascripts/oauth_remember_me.js
deleted file mode 100644
index ffc2dd6bbca..00000000000
--- a/app/assets/javascripts/oauth_remember_me.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * 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 6552a88b606..86a43b66cc8 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,4 +1,6 @@
+import $ from 'jquery';
import { getParameterByName } from '~/lib/utils/common_utils';
+import axios from './lib/utils/axios_utils';
import { removeParams } from './lib/utils/url_utility';
const ENDLESS_SCROLL_BOTTOM_PX = 400;
@@ -22,24 +24,22 @@ export default {
getOld() {
this.loading.show();
- $.ajax({
- type: 'GET',
- url: this.url,
- data: `limit=${this.limit}&offset=${this.offset}`,
- dataType: 'json',
- error: () => this.loading.hide(),
- success: (data) => {
- this.append(data.count, this.prepareData(data.html));
- this.callback();
-
- // keep loading until we've filled the viewport height
- if (!this.disable && !this.isScrollable()) {
- this.getOld();
- } else {
- this.loading.hide();
- }
+ axios.get(this.url, {
+ params: {
+ limit: this.limit,
+ offset: this.offset,
},
- });
+ }).then(({ data }) => {
+ this.append(data.count, this.prepareData(data.html));
+ this.callback();
+
+ // keep loading until we've filled the viewport height
+ if (!this.disable && !this.isScrollable()) {
+ this.getOld();
+ } else {
+ this.loading.hide();
+ }
+ }).catch(() => this.loading.hide());
},
append(count, html) {
@@ -57,7 +57,7 @@ export default {
},
initLoadMore() {
- $(document).unbind('scroll');
+ $(document).off('scroll');
$(document).endlessScroll({
bottomPixels: ENDLESS_SCROLL_BOTTOM_PX,
fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS,
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
new file mode 100644
index 00000000000..15e737fff05
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js
@@ -0,0 +1,37 @@
+import $ from 'jquery';
+import { truncate } from '../../../lib/utils/text_utility';
+
+const MAX_MESSAGE_LENGTH = 500;
+const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
+
+export default class AbuseReports {
+ constructor() {
+ $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
+ $(document)
+ .off('click', MESSAGE_CELL_SELECTOR)
+ .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
+ }
+
+ truncateLongMessage() {
+ const $messageCellElement = $(this);
+ const reportMessage = $messageCellElement.text();
+ if (reportMessage.length > MAX_MESSAGE_LENGTH) {
+ $messageCellElement.data('originalMessage', reportMessage);
+ $messageCellElement.data('messageTruncated', 'true');
+ $messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH));
+ }
+ }
+
+ toggleMessageTruncation() {
+ const $messageCellElement = $(this);
+ const originalMessage = $messageCellElement.data('originalMessage');
+ if (!originalMessage) return;
+ if ($messageCellElement.data('messageTruncated') === 'true') {
+ $messageCellElement.data('messageTruncated', 'false');
+ $messageCellElement.text(originalMessage);
+ } else {
+ $messageCellElement.data('messageTruncated', 'true');
+ $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
new file mode 100644
index 00000000000..d76b1f174fc
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -0,0 +1,3 @@
+import AbuseReports from './abuse_reports';
+
+document.addEventListener('DOMContentLoaded', () => new AbuseReports());
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
new file mode 100644
index 00000000000..91f154b7ecd
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import { refreshCurrentPage } from '../../lib/utils/url_utility';
+
+function showBlacklistType() {
+ if ($('input[name="blacklist_type"]:checked').val() === 'file') {
+ $('.blacklist-file').show();
+ $('.blacklist-raw').hide();
+ } else {
+ $('.blacklist-file').hide();
+ $('.blacklist-raw').show();
+ }
+}
+
+export default function adminInit() {
+ const modal = $('.change-owner-holder');
+
+ $('input#user_force_random_password').on('change', function randomPasswordClick() {
+ const $elems = $('#user_password, #user_password_confirmation');
+ if ($(this).attr('checked')) {
+ $elems.val('').prop('disabled', true);
+ } else {
+ $elems.prop('disabled', false);
+ }
+ });
+
+ $('body').on('click', '.js-toggle-colors-link', (e) => {
+ e.preventDefault();
+ $('.js-toggle-colors-container').toggle();
+ });
+
+ $('.log-tabs a').on('click', function logTabsClick(e) {
+ e.preventDefault();
+ $(this).tab('show');
+ });
+
+ $('.log-bottom').on('click', (e) => {
+ e.preventDefault();
+ const $visibleLog = $('.file-content:visible');
+ $visibleLog.animate({
+ scrollTop: $visibleLog.find('ol').height(),
+ }, 'fast');
+ });
+
+ $('.change-owner-link').on('click', function changeOwnerLinkClick(e) {
+ e.preventDefault();
+ $(this).hide();
+ modal.show();
+ });
+
+ $('.change-owner-cancel-link').on('click', (e) => {
+ e.preventDefault();
+ modal.hide();
+ $('.change-owner-link').show();
+ });
+
+ $('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
+
+ $("input[name='blacklist_type']").on('click', showBlacklistType);
+ showBlacklistType();
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
new file mode 100644
index 00000000000..48d75f5443b
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -0,0 +1,6 @@
+import initSettingsPanels from '~/settings_panels';
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize expandable settings panels
+ initSettingsPanels();
+});
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
new file mode 100644
index 00000000000..e7ceccb6f47
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+export default () => {
+ $('input#broadcast_message_color').on('input', function onMessageColorInput() {
+ const previewColor = $(this).val();
+ $('div.broadcast-message-preview').css('background-color', previewColor);
+ });
+
+ $('input#broadcast_message_font').on('input', function onMessageFontInput() {
+ const previewColor = $(this).val();
+ $('div.broadcast-message-preview').css('color', previewColor);
+ });
+
+ const previewPath = $('textarea#broadcast_message_message').data('previewPath');
+
+ $('textarea#broadcast_message_message').on('input', _.debounce(function onMessageInput() {
+ const message = $(this).val();
+ if (message === '') {
+ $('.js-broadcast-message-preview').text('Your message here');
+ } else {
+ axios.post(previewPath, {
+ broadcast_message: {
+ message,
+ },
+ })
+ .then(({ data }) => {
+ $('.js-broadcast-message-preview').html(data.message);
+ })
+ .catch(() => flash(__('An error occurred while rendering preview broadcast message')));
+ }
+ }, 250));
+};
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
new file mode 100644
index 00000000000..d6cc6a850eb
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
@@ -0,0 +1,3 @@
+import initBroadcastMessagesForm from './broadcast_message';
+
+document.addEventListener('DOMContentLoaded', initBroadcastMessagesForm);
diff --git a/app/assets/javascripts/pages/admin/cohorts/index.js b/app/assets/javascripts/pages/admin/cohorts/index.js
new file mode 100644
index 00000000000..2d5020dbef4
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/cohorts/index.js
@@ -0,0 +1,3 @@
+import initUsagePing from './usage_ping';
+
+document.addEventListener('DOMContentLoaded', initUsagePing);
diff --git a/app/assets/javascripts/pages/admin/cohorts/usage_ping.js b/app/assets/javascripts/pages/admin/cohorts/usage_ping.js
new file mode 100644
index 00000000000..914a9661c27
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/cohorts/usage_ping.js
@@ -0,0 +1,13 @@
+import axios from '../../../lib/utils/axios_utils';
+import { __ } from '../../../locale';
+import flash from '../../../flash';
+
+export default function UsagePing() {
+ const el = document.querySelector('.usage-data');
+
+ axios.get(el.dataset.endpoint, {
+ responseType: 'text',
+ }).then(({ data }) => {
+ el.innerHTML = data;
+ }).catch(() => flash(__('Error fetching usage ping data.')));
+}
diff --git a/app/assets/javascripts/pages/admin/conversational_development_index/show/index.js b/app/assets/javascripts/pages/admin/conversational_development_index/show/index.js
new file mode 100644
index 00000000000..c1056537f90
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/conversational_development_index/show/index.js
@@ -0,0 +1,3 @@
+import UserCallout from '~/user_callout';
+
+document.addEventListener('DOMContentLoaded', () => new UserCallout());
diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js
new file mode 100644
index 00000000000..d3d125a1859
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/groups/edit/index.js
@@ -0,0 +1,3 @@
+import groupAvatar from '~/group_avatar';
+
+document.addEventListener('DOMContentLoaded', groupAvatar);
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
new file mode 100644
index 00000000000..21f1ce222ac
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -0,0 +1,9 @@
+import BindInOut from '../../../../behaviors/bind_in_out';
+import Group from '../../../../group';
+import groupAvatar from '../../../../group_avatar';
+
+document.addEventListener('DOMContentLoaded', () => {
+ BindInOut.initAll();
+ new Group(); // eslint-disable-line no-new
+ groupAvatar();
+});
diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js
new file mode 100644
index 00000000000..b0cdad627a6
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/groups/show/index.js
@@ -0,0 +1,3 @@
+import UsersSelect from '../../../../users_select';
+
+document.addEventListener('DOMContentLoaded', () => new UsersSelect());
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
new file mode 100644
index 00000000000..78a5c4c27be
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -0,0 +1,3 @@
+import DueDateSelectors from '~/due_date_select';
+
+document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js
new file mode 100644
index 00000000000..e50b61f09e2
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/index.js
@@ -0,0 +1,3 @@
+import initAdmin from './admin';
+
+document.addEventListener('DOMContentLoaded', initAdmin);
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
new file mode 100644
index 00000000000..ba1d8e4d8db
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
@@ -0,0 +1,49 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+ import createFlash from '~/flash';
+ import GlModal from '~/vue_shared/components/gl_modal.vue';
+ import { redirectTo } from '~/lib/utils/url_utility';
+ import { s__ } from '~/locale';
+
+ export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return s__('AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running.');
+ },
+ },
+ methods: {
+ onSubmit() {
+ return axios.post(this.url)
+ .then((response) => {
+ // follow the rediect to refresh the page
+ redirectTo(response.request.responseURL);
+ })
+ .catch((error) => {
+ createFlash(s__('AdminArea|Stopping jobs failed'));
+ throw error;
+ });
+ },
+ },
+ };
+</script>
+
+<template>
+ <gl-modal
+ id="stop-jobs-modal"
+ :header-title-text="s__('AdminArea|Stop all jobs?')"
+ footer-primary-button-variant="danger"
+ :footer-primary-button-text="s__('AdminArea|Stop jobs')"
+ @submit="onSubmit"
+ >
+ {{ text }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
new file mode 100644
index 00000000000..5a4f8c6e745
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import stopJobsModal from './components/stop_jobs_modal.vue';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const stopJobsButton = document.getElementById('stop-jobs-button');
+ if (stopJobsButton) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#stop-jobs-modal',
+ components: {
+ stopJobsModal,
+ },
+ mounted() {
+ stopJobsButton.classList.remove('disabled');
+ },
+ render(createElement) {
+ return createElement('stop-jobs-modal', {
+ props: {
+ url: stopJobsButton.dataset.url,
+ },
+ });
+ },
+ });
+ }
+});
diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js
new file mode 100644
index 00000000000..5de1d4d6344
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/labels/edit/index.js
@@ -0,0 +1,3 @@
+import Labels from '../../../../labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js
new file mode 100644
index 00000000000..5de1d4d6344
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/labels/new/index.js
@@ -0,0 +1,3 @@
+import Labels from '../../../../labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
new file mode 100644
index 00000000000..31c96eb87af
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -0,0 +1,9 @@
+import ProjectsList from '../../../projects_list';
+import NamespaceSelect from '../../../namespace_select';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectsList(); // eslint-disable-line no-new
+
+ document.querySelectorAll('.js-namespace-select')
+ .forEach(dropdown => new NamespaceSelect({ dropdown }));
+});
diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
new file mode 100644
index 00000000000..343c65edb37
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -0,0 +1,125 @@
+<script>
+ import _ from 'underscore';
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { s__, sprintf } from '~/locale';
+
+ export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ deleteProjectUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ csrfToken: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ enteredProjectName: '',
+ };
+ },
+ computed: {
+ title() {
+ return sprintf(s__('AdminProjects|Delete Project %{projectName}?'),
+ {
+ projectName: `'${_.escape(this.projectName)}'`,
+ },
+ false,
+ );
+ },
+ text() {
+ return sprintf(s__(`AdminProjects|
+ You’re about to permanently delete the project %{projectName}, its repository,
+ and all related resources including issues, merge requests, etc.. Once you confirm and press
+ %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`),
+ {
+ projectName: `<strong>${_.escape(this.projectName)}</strong>`,
+ strong_start: '<strong>',
+ strong_end: '</strong>',
+ },
+ false,
+ );
+ },
+ confirmationTextLabel() {
+ return sprintf(s__('AdminUsers|To confirm, type %{projectName}'),
+ {
+ projectName: `<code>${_.escape(this.projectName)}</code>`,
+ },
+ false,
+ );
+ },
+ primaryButtonLabel() {
+ return s__('AdminProjects|Delete project');
+ },
+ canSubmit() {
+ return this.enteredProjectName === this.projectName;
+ },
+ },
+ methods: {
+ onCancel() {
+ this.enteredProjectName = '';
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ this.enteredProjectName = '';
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ id="delete-project-modal"
+ :title="title"
+ :text="text"
+ kind="danger"
+ :primary-button-label="primaryButtonLabel"
+ :submit-disabled="!canSubmit"
+ @submit="onSubmit"
+ @cancel="onCancel"
+ >
+ <template
+ slot="body"
+ slot-scope="props"
+ >
+ <p v-html="props.text"></p>
+ <p v-html="confirmationTextLabel"></p>
+ <form
+ ref="form"
+ :action="deleteProjectUrl"
+ method="post"
+ >
+ <input
+ ref="method"
+ type="hidden"
+ name="_method"
+ value="delete"
+ />
+ <input
+ type="hidden"
+ name="authenticity_token"
+ :value="csrfToken"
+ />
+ <input
+ name="projectName"
+ class="form-control"
+ type="text"
+ v-model="enteredProjectName"
+ aria-labelledby="input-label"
+ autocomplete="off"
+ />
+ </form>
+ </template>
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js
new file mode 100644
index 00000000000..ddbefec87b6
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/index/index.js
@@ -0,0 +1,38 @@
+import $ from 'jquery';
+import Vue from 'vue';
+
+import Translate from '~/vue_shared/translate';
+import csrf from '~/lib/utils/csrf';
+
+import deleteProjectModal from './components/delete_project_modal.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ Vue.use(Translate);
+
+ const deleteProjectModalEl = document.getElementById('delete-project-modal');
+
+ const deleteModal = new Vue({
+ el: deleteProjectModalEl,
+ data: {
+ deleteProjectUrl: '',
+ projectName: '',
+ },
+ render(createElement) {
+ return createElement(deleteProjectModal, {
+ props: {
+ deleteProjectUrl: this.deleteProjectUrl,
+ projectName: this.projectName,
+ csrfToken: csrf.token,
+ },
+ });
+ },
+ });
+
+ $(document).on('shown.bs.modal', (event) => {
+ if (event.relatedTarget.classList.contains('delete-project-button')) {
+ const buttonProps = event.relatedTarget.dataset;
+ deleteModal.deleteProjectUrl = buttonProps.deleteProjectUrl;
+ deleteModal.projectName = buttonProps.projectName;
+ }
+ });
+});
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
new file mode 100644
index 00000000000..0e3ac636661
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -0,0 +1,174 @@
+<script>
+ import _ from 'underscore';
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { s__, sprintf } from '~/locale';
+
+ export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ deleteUserUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ blockUserUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ deleteContributions: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ csrfToken: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ enteredUsername: '',
+ };
+ },
+ computed: {
+ title() {
+ const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?');
+ const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?');
+
+ return sprintf(
+ this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle, {
+ username: `'${_.escape(this.username)}'`,
+ }, false);
+ },
+ text() {
+ const keepContributionsText = s__(`AdminArea|
+ You are about to permanently delete the user %{username}.
+ This will delete all of the issues, merge requests, and groups linked to them.
+ To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
+ Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
+
+ const deleteContributionsText = s__(`AdminArea|
+ You are about to permanently delete the user %{username}.
+ Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user".
+ To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
+ Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
+
+ return sprintf(this.deleteContributions ? deleteContributionsText : keepContributionsText,
+ {
+ username: `<strong>${_.escape(this.username)}</strong>`,
+ strong_start: '<strong>',
+ strong_end: '</strong>',
+ },
+ false,
+ );
+ },
+ confirmationTextLabel() {
+ return sprintf(s__('AdminUsers|To confirm, type %{username}'),
+ {
+ username: `<code>${_.escape(this.username)}</code>`,
+ },
+ false,
+ );
+ },
+ primaryButtonLabel() {
+ const keepContributionsLabel = s__('AdminUsers|Delete user');
+ const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions');
+
+ return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel;
+ },
+ secondaryButtonLabel() {
+ return s__('AdminUsers|Block user');
+ },
+ canSubmit() {
+ return this.enteredUsername === this.username;
+ },
+ },
+ methods: {
+ onCancel() {
+ this.enteredUsername = '';
+ },
+ onSecondaryAction() {
+ const form = this.$refs.form;
+
+ form.action = this.blockUserUrl;
+ this.$refs.method.value = 'put';
+
+ form.submit();
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ this.enteredUsername = '';
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ id="delete-user-modal"
+ :title="title"
+ :text="text"
+ kind="danger"
+ :primary-button-label="primaryButtonLabel"
+ :secondary-button-label="secondaryButtonLabel"
+ :submit-disabled="!canSubmit"
+ @submit="onSubmit"
+ @cancel="onCancel"
+ >
+ <template
+ slot="body"
+ slot-scope="props"
+ >
+ <p v-html="props.text"></p>
+ <p v-html="confirmationTextLabel"></p>
+ <form
+ ref="form"
+ :action="deleteUserUrl"
+ method="post"
+ >
+ <input
+ ref="method"
+ type="hidden"
+ name="_method"
+ value="delete"
+ />
+ <input
+ type="hidden"
+ name="authenticity_token"
+ :value="csrfToken"
+ />
+ <input
+ type="text"
+ name="username"
+ class="form-control"
+ v-model="enteredUsername"
+ aria-labelledby="input-label"
+ autocomplete="off"
+ />
+ </form>
+ </template>
+ <template
+ slot="secondary-button"
+ slot-scope="props"
+ >
+ <button
+ type="button"
+ class="btn js-secondary-button btn-warning"
+ :disabled="!canSubmit"
+ @click="onSecondaryAction"
+ data-dismiss="modal"
+ >
+ {{ secondaryButtonLabel }}
+ </button>
+ </template>
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
new file mode 100644
index 00000000000..06599c3fd5f
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -0,0 +1,44 @@
+import $ from 'jquery';
+import Vue from 'vue';
+
+import Translate from '~/vue_shared/translate';
+import csrf from '~/lib/utils/csrf';
+
+import deleteUserModal from './components/delete_user_modal.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+ Vue.use(Translate);
+
+ const deleteUserModalEl = document.getElementById('delete-user-modal');
+
+ const deleteModal = new Vue({
+ el: deleteUserModalEl,
+ data: {
+ deleteUserUrl: '',
+ blockUserUrl: '',
+ deleteContributions: '',
+ username: '',
+ },
+ render(createElement) {
+ return createElement(deleteUserModal, {
+ props: {
+ deleteUserUrl: this.deleteUserUrl,
+ blockUserUrl: this.blockUserUrl,
+ deleteContributions: this.deleteContributions,
+ username: this.username,
+ csrfToken: csrf.token,
+ },
+ });
+ },
+ });
+
+ $(document).on('shown.bs.modal', (event) => {
+ if (event.relatedTarget.classList.contains('delete-user-button')) {
+ const buttonProps = event.relatedTarget.dataset;
+ deleteModal.deleteUserUrl = buttonProps.deleteUserUrl;
+ deleteModal.blockUserUrl = buttonProps.blockUserUrl;
+ deleteModal.deleteContributions = event.relatedTarget.hasAttribute('data-delete-contributions');
+ deleteModal.username = buttonProps.username;
+ }
+ });
+});
diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js
new file mode 100644
index 00000000000..328b6541636
--- /dev/null
+++ b/app/assets/javascripts/pages/constants.js
@@ -0,0 +1,6 @@
+/* eslint-disable import/prefer-default-export */
+
+export const FILTERED_SEARCH = {
+ MERGE_REQUESTS: 'merge_requests',
+ ISSUES: 'issues',
+};
diff --git a/app/assets/javascripts/pages/dashboard/activity/index.js b/app/assets/javascripts/pages/dashboard/activity/index.js
new file mode 100644
index 00000000000..1b887cad496
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/activity/index.js
@@ -0,0 +1,3 @@
+import Activities from '~/activities';
+
+document.addEventListener('DOMContentLoaded', () => new Activities());
diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js
new file mode 100644
index 00000000000..79987642796
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js
@@ -0,0 +1,3 @@
+import initGroupsList from '~/groups';
+
+document.addEventListener('DOMContentLoaded', initGroupsList);
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
new file mode 100644
index 00000000000..c4901dd1cb6
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -0,0 +1,7 @@
+import projectSelect from '~/project_select';
+import initLegacyFilters from '~/init_legacy_filters';
+
+document.addEventListener('DOMContentLoaded', () => {
+ projectSelect();
+ initLegacyFilters();
+});
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
new file mode 100644
index 00000000000..c4901dd1cb6
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -0,0 +1,7 @@
+import projectSelect from '~/project_select';
+import initLegacyFilters from '~/init_legacy_filters';
+
+document.addEventListener('DOMContentLoaded', () => {
+ projectSelect();
+ initLegacyFilters();
+});
diff --git a/app/assets/javascripts/pages/dashboard/milestones/index/index.js b/app/assets/javascripts/pages/dashboard/milestones/index/index.js
new file mode 100644
index 00000000000..38ddebe30d9
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/milestones/index/index.js
@@ -0,0 +1,3 @@
+import projectSelect from '~/project_select';
+
+document.addEventListener('DOMContentLoaded', projectSelect);
diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
new file mode 100644
index 00000000000..397149aaa9e
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
@@ -0,0 +1,9 @@
+import Milestone from '~/milestone';
+import Sidebar from '~/right_sidebar';
+import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Milestone(); // eslint-disable-line no-new
+ new Sidebar(); // eslint-disable-line no-new
+ new MountMilestoneSidebar(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js
new file mode 100644
index 00000000000..0c585e162cb
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/projects/index.js
@@ -0,0 +1,3 @@
+import ProjectsList from '~/projects_list';
+
+document.addEventListener('DOMContentLoaded', () => new ProjectsList());
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/index.js b/app/assets/javascripts/pages/dashboard/todos/index/index.js
new file mode 100644
index 00000000000..9d2c2f2994f
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/todos/index/index.js
@@ -0,0 +1,3 @@
+import Todos from './todos';
+
+document.addEventListener('DOMContentLoaded', () => new Todos());
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
new file mode 100644
index 00000000000..c334eaa90f8
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -0,0 +1,151 @@
+/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
+
+import $ from 'jquery';
+import { visitUrl } from '~/lib/utils/url_utility';
+import UsersSelect from '~/users_select';
+import { isMetaClick } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import flash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+
+export default class Todos {
+ constructor() {
+ this.initFilters();
+ this.bindEvents();
+ this.todo_ids = [];
+
+ this.cleanupWrapper = this.cleanup.bind(this);
+ document.addEventListener('beforeunload', this.cleanupWrapper);
+ }
+
+ cleanup() {
+ this.unbindEvents();
+ document.removeEventListener('beforeunload', this.cleanupWrapper);
+ }
+
+ unbindEvents() {
+ $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper);
+ $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper);
+ $('.todo').off('click', this.goToTodoUrl);
+ }
+
+ bindEvents() {
+ this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this);
+ this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this);
+
+ $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper);
+ $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper);
+ $('.todo').on('click', this.goToTodoUrl);
+ }
+
+ initFilters() {
+ this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
+ this.initFilterDropdown($('.js-type-search'), 'type');
+ this.initFilterDropdown($('.js-action-search'), 'action_id');
+
+ return new UsersSelect();
+ }
+
+ initFilterDropdown($dropdown, fieldName, searchFields) {
+ $dropdown.glDropdown({
+ fieldName,
+ selectable: true,
+ filterable: searchFields ? true : false,
+ search: { fields: searchFields },
+ data: $dropdown.data('data'),
+ clicked: () => $dropdown.closest('form.filter-form').submit(),
+ });
+ }
+
+ updateRowStateClicked(e) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const target = e.target;
+ target.setAttribute('disabled', true);
+ target.classList.add('disabled');
+
+ axios[target.dataset.method](target.dataset.href)
+ .then(({ data }) => {
+ this.updateRowState(target);
+ this.updateBadges(data);
+ }).catch(() => flash(__('Error updating todo status.')));
+ }
+
+ updateRowState(target) {
+ const row = target.closest('li');
+ const restoreBtn = row.querySelector('.js-undo-todo');
+ const doneBtn = row.querySelector('.js-done-todo');
+
+ target.classList.add('hidden');
+ target.removeAttribute('disabled');
+ target.classList.remove('disabled');
+
+ if (target === doneBtn) {
+ row.classList.add('done-reversible');
+ restoreBtn.classList.remove('hidden');
+ } else if (target === restoreBtn) {
+ row.classList.remove('done-reversible');
+ doneBtn.classList.remove('hidden');
+ } else {
+ row.parentNode.removeChild(row);
+ }
+ }
+
+ updateAllStateClicked(e) {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const target = e.currentTarget;
+ target.setAttribute('disabled', true);
+ target.classList.add('disabled');
+
+ axios[target.dataset.method](target.dataset.href, {
+ ids: this.todo_ids,
+ }).then(({ data }) => {
+ this.updateAllState(target, data);
+ this.updateBadges(data);
+ }).catch(() => flash(__('Error updating status for all todos.')));
+ }
+
+ updateAllState(target, data) {
+ const markAllDoneBtn = document.querySelector('.js-todos-mark-all');
+ const undoAllBtn = document.querySelector('.js-todos-undo-all');
+ const todoListContainer = document.querySelector('.js-todos-list-container');
+ const nothingHereContainer = document.querySelector('.js-nothing-here-container');
+
+ target.removeAttribute('disabled');
+ target.classList.remove('disabled');
+
+ this.todo_ids = (target === markAllDoneBtn) ? data.updated_ids : [];
+ undoAllBtn.classList.toggle('hidden');
+ markAllDoneBtn.classList.toggle('hidden');
+ todoListContainer.classList.toggle('hidden');
+ nothingHereContainer.classList.toggle('hidden');
+ }
+
+ updateBadges(data) {
+ $(document).trigger('todo:toggle', data.count);
+ document.querySelector('.todos-pending .badge').innerHTML = data.count;
+ document.querySelector('.todos-done .badge').innerHTML = data.done_count;
+ }
+
+ goToTodoUrl(e) {
+ const todoLink = this.dataset.url;
+
+ if (!todoLink || e.target.tagName === 'A' || e.target.tagName === 'IMG') {
+ return;
+ }
+
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (isMetaClick(e)) {
+ const windowTarget = '_blank';
+
+ window.open(todoLink, windowTarget);
+ } else {
+ visitUrl(todoLink);
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js
new file mode 100644
index 00000000000..3c7edbdd7c7
--- /dev/null
+++ b/app/assets/javascripts/pages/explore/groups/index.js
@@ -0,0 +1,16 @@
+import GroupsList from '~/groups_list';
+import Landing from '~/landing';
+import initGroupsList from '../../../groups';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new GroupsList(); // eslint-disable-line no-new
+ initGroupsList();
+ const landingElement = document.querySelector('.js-explore-groups-landing');
+ if (!landingElement) return;
+ const exploreGroupsLanding = new Landing(
+ landingElement,
+ landingElement.querySelector('.dismiss-button'),
+ 'explore_groups_landing_dismissed',
+ );
+ exploreGroupsLanding.toggle();
+});
diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js
new file mode 100644
index 00000000000..0c585e162cb
--- /dev/null
+++ b/app/assets/javascripts/pages/explore/projects/index.js
@@ -0,0 +1,3 @@
+import ProjectsList from '~/projects_list';
+
+document.addEventListener('DOMContentLoaded', () => new ProjectsList());
diff --git a/app/assets/javascripts/pages/groups/activity/index.js b/app/assets/javascripts/pages/groups/activity/index.js
new file mode 100644
index 00000000000..1b887cad496
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/activity/index.js
@@ -0,0 +1,3 @@
+import Activities from '~/activities';
+
+document.addEventListener('DOMContentLoaded', () => new Activities());
diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js
new file mode 100644
index 00000000000..5cfe8723204
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/boards/index.js
@@ -0,0 +1,9 @@
+import UsersSelect from '~/users_select';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import initBoards from '~/boards';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsersSelect(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ initBoards();
+});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
new file mode 100644
index 00000000000..bb91ac84ffb
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -0,0 +1,9 @@
+import groupAvatar from '~/group_avatar';
+import TransferDropdown from '~/groups/transfer_dropdown';
+import initConfirmDangerModal from '~/confirm_danger_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ groupAvatar();
+ new TransferDropdown(); // eslint-disable-line no-new
+ initConfirmDangerModal();
+});
diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js
new file mode 100644
index 00000000000..c22a164cd4e
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/group_members/index/index.js
@@ -0,0 +1,11 @@
+/* eslint-disable no-new */
+
+import memberExpirationDate from '~/member_expiration_date';
+import Members from '~/members';
+import UsersSelect from '~/users_select';
+
+document.addEventListener('DOMContentLoaded', () => {
+ memberExpirationDate();
+ new Members();
+ new UsersSelect();
+});
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
new file mode 100644
index 00000000000..914f804fdd3
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -0,0 +1,11 @@
+import projectSelect from '~/project_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ isGroupDecendent: true,
+ });
+ projectSelect();
+});
diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/labels/edit/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js
new file mode 100644
index 00000000000..6e45de2a724
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/labels/index/index.js
@@ -0,0 +1,3 @@
+import initLabels from '~/init_labels';
+
+document.addEventListener('DOMContentLoaded', initLabels);
diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/labels/new/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
new file mode 100644
index 00000000000..1600faa3611
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -0,0 +1,11 @@
+import projectSelect from '~/project_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.MERGE_REQUESTS,
+ isGroupDecendent: true,
+ });
+ projectSelect();
+});
diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js
new file mode 100644
index 00000000000..ddd10fe5062
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm(false));
diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js
new file mode 100644
index 00000000000..ddd10fe5062
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/milestones/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm(false));
diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
new file mode 100644
index 00000000000..88f40b5278e
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -0,0 +1,3 @@
+import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
+
+document.addEventListener('DOMContentLoaded', initMilestonesShow);
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
new file mode 100644
index 00000000000..b2f275dc5ea
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -0,0 +1,9 @@
+import BindInOut from '~/behaviors/bind_in_out';
+import Group from '~/group';
+import groupAvatar from '~/group_avatar';
+
+document.addEventListener('DOMContentLoaded', () => {
+ BindInOut.initAll();
+ new Group(); // eslint-disable-line no-new
+ groupAvatar();
+});
diff --git a/app/assets/javascripts/pages/groups/settings/badges/index.js b/app/assets/javascripts/pages/groups/settings/badges/index.js
new file mode 100644
index 00000000000..74e96ee4a8f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/badges/index.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { GROUP_BADGE } from '~/badges/constants';
+import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountBadgeSettings(GROUP_BADGE);
+});
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
new file mode 100644
index 00000000000..04a0d8117cc
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -0,0 +1,12 @@
+import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const variableListEl = document.querySelector('.js-ci-variable-list-section');
+ // eslint-disable-next-line no-new
+ new AjaxVariableList({
+ container: variableListEl,
+ saveButton: variableListEl.querySelector('.js-secret-variables-save-button'),
+ errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
+ saveEndpoint: variableListEl.dataset.saveEndpoint,
+ });
+});
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
new file mode 100644
index 00000000000..d7b35d2b26b
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -0,0 +1,22 @@
+/* eslint-disable no-new */
+
+import NewGroupChild from '~/groups/new_group_child';
+import notificationsDropdown from '~/notifications_dropdown';
+import NotificationsForm from '~/notifications_form';
+import ProjectsList from '~/projects_list';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import initGroupsList from '~/groups';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
+ new ShortcutsNavigation();
+ new NotificationsForm();
+ notificationsDropdown();
+ new ProjectsList();
+
+ if (newGroupChildWrapper) {
+ new NewGroupChild(newGroupChildWrapper);
+ }
+
+ initGroupsList();
+});
diff --git a/app/assets/javascripts/pages/help/index/index.js b/app/assets/javascripts/pages/help/index/index.js
new file mode 100644
index 00000000000..1bafe564a37
--- /dev/null
+++ b/app/assets/javascripts/pages/help/index/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import VersionCheckImage from '~/version_check_image';
+import docs from '~/docs/docs_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ docs();
+ VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
+});
diff --git a/app/assets/javascripts/pages/help/show/index.js b/app/assets/javascripts/pages/help/show/index.js
new file mode 100644
index 00000000000..ec426a850b6
--- /dev/null
+++ b/app/assets/javascripts/pages/help/show/index.js
@@ -0,0 +1,3 @@
+import initHelp from '~/help/help';
+
+document.addEventListener('DOMContentLoaded', initHelp);
diff --git a/app/assets/javascripts/pages/help/ui/index.js b/app/assets/javascripts/pages/help/ui/index.js
new file mode 100644
index 00000000000..709ca2f3828
--- /dev/null
+++ b/app/assets/javascripts/pages/help/ui/index.js
@@ -0,0 +1,3 @@
+import initUIKit from '~/ui_development_kit';
+
+document.addEventListener('DOMContentLoaded', initUIKit);
diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js
new file mode 100644
index 00000000000..68d4c1f049f
--- /dev/null
+++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js
@@ -0,0 +1,3 @@
+import UsersSelect from '~/users_select';
+
+document.addEventListener('DOMContentLoaded', () => new UsersSelect());
diff --git a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js
new file mode 100644
index 00000000000..bb86f72b95b
--- /dev/null
+++ b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js
@@ -0,0 +1,3 @@
+import initGitLabImportProject from '~/projects/project_import_gitlab_project';
+
+document.addEventListener('DOMContentLoaded', initGitLabImportProject);
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
new file mode 100644
index 00000000000..16f792d635a
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
@@ -0,0 +1,110 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+
+ import Flash from '~/flash';
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { n__, s__, sprintf } from '~/locale';
+ import { redirectTo } from '~/lib/utils/url_utility';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ DeprecatedModal,
+ },
+ props: {
+ issueCount: {
+ type: Number,
+ required: true,
+ },
+ mergeRequestCount: {
+ type: Number,
+ required: true,
+ },
+ milestoneId: {
+ type: Number,
+ required: true,
+ },
+ milestoneTitle: {
+ type: String,
+ required: true,
+ },
+ milestoneUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', { milestoneTitle: this.milestoneTitle });
+
+ if (this.issueCount === 0 && this.mergeRequestCount === 0) {
+ return sprintf(
+ s__(`Milestones|
+You’re about to permanently delete the milestone %{milestoneTitle} from this project.
+%{milestoneTitle} is not currently used in any issues or merge requests.`),
+ {
+ milestoneTitle,
+ },
+ false,
+ );
+ }
+
+ return sprintf(
+ s__(`Milestones|
+You’re about to permanently delete the milestone %{milestoneTitle} from this project and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}.
+Once deleted, it cannot be undone or recovered.`),
+ {
+ milestoneTitle,
+ issuesWithCount: n__('%d issue', '%d issues', this.issueCount),
+ mergeRequestsWithCount: n__('%d merge request', '%d merge requests', this.mergeRequestCount),
+ },
+ false,
+ );
+ },
+ title() {
+ return sprintf(s__('Milestones|Delete milestone %{milestoneTitle}?'), { milestoneTitle: this.milestoneTitle });
+ },
+ },
+ methods: {
+ onSubmit() {
+ eventHub.$emit('deleteMilestoneModal.requestStarted', this.milestoneUrl);
+
+ return axios.delete(this.milestoneUrl)
+ .then((response) => {
+ eventHub.$emit('deleteMilestoneModal.requestFinished', { milestoneUrl: this.milestoneUrl, successful: true });
+
+ // follow the rediect to milestones overview page
+ redirectTo(response.request.responseURL);
+ })
+ .catch((error) => {
+ eventHub.$emit('deleteMilestoneModal.requestFinished', { milestoneUrl: this.milestoneUrl, successful: false });
+
+ if (error.response && error.response.status === 404) {
+ Flash(sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { milestoneTitle: this.milestoneTitle }));
+ } else {
+ Flash(sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), { milestoneTitle: this.milestoneTitle }));
+ }
+ throw error;
+ });
+ },
+ },
+ };
+</script>
+
+<template>
+ <deprecated-modal
+ id="delete-milestone-modal"
+ :title="title"
+ :text="text"
+ kind="danger"
+ :primary-button-label="s__('Milestones|Delete milestone')"
+ @submit="onSubmit">
+
+ <template
+ slot="body"
+ slot-scope="props">
+ <p v-html="props.text"></p>
+ </template>
+
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
new file mode 100644
index 00000000000..2bda2aeb3a1
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -0,0 +1,68 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+ import createFlash from '~/flash';
+ import GlModal from '~/vue_shared/components/gl_modal.vue';
+ import { s__, sprintf } from '~/locale';
+ import { visitUrl } from '~/lib/utils/url_utility';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ milestoneTitle: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ return sprintf(s__('Milestones|Promote %{milestoneTitle} to group milestone?'), { milestoneTitle: this.milestoneTitle });
+ },
+ text() {
+ return sprintf(s__(`Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}.
+ Existing project milestones with the same title will be merged.
+ This action cannot be reversed.`), { milestoneTitle: this.milestoneTitle, groupName: this.groupName });
+ },
+ },
+ methods: {
+ onSubmit() {
+ eventHub.$emit('promoteMilestoneModal.requestStarted', this.url);
+ return axios.post(this.url, { params: { format: 'json' } })
+ .then((response) => {
+ eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: true });
+ visitUrl(response.data.url);
+ })
+ .catch((error) => {
+ eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: false });
+ createFlash(error);
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <gl-modal
+ id="promote-milestone-modal"
+ footer-primary-button-variant="warning"
+ :footer-primary-button-text="s__('Milestones|Promote Milestone')"
+ @submit="onSubmit"
+ >
+ <template
+ slot="title"
+ >
+ {{ title }}
+ </template>
+ {{ text }}
+ </gl-modal>
+</template>
+
diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
new file mode 100644
index 00000000000..d51b5c221e3
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import deleteMilestoneModal from './components/delete_milestone_modal.vue';
+import eventHub from './event_hub';
+
+export default () => {
+ Vue.use(Translate);
+
+ const onRequestFinished = ({ milestoneUrl, successful }) => {
+ const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`);
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+
+ button.querySelector('.js-loading-icon').classList.add('hidden');
+ };
+
+ const onRequestStarted = (milestoneUrl) => {
+ const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`);
+ button.setAttribute('disabled', '');
+ button.querySelector('.js-loading-icon').classList.remove('hidden');
+ eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished);
+ };
+
+ const onDeleteButtonClick = (event) => {
+ const button = event.currentTarget;
+ const modalProps = {
+ milestoneId: parseInt(button.dataset.milestoneId, 10),
+ milestoneTitle: button.dataset.milestoneTitle,
+ milestoneUrl: button.dataset.milestoneUrl,
+ issueCount: parseInt(button.dataset.milestoneIssueCount, 10),
+ mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10),
+ };
+ eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted);
+ eventHub.$emit('deleteMilestoneModal.props', modalProps);
+ };
+
+ const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
+ deleteMilestoneButtons.forEach((button) => {
+ button.addEventListener('click', onDeleteButtonClick);
+ });
+
+ eventHub.$once('deleteMilestoneModal.mounted', () => {
+ deleteMilestoneButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ });
+ });
+
+ return new Vue({
+ el: '#delete-milestone-modal',
+ components: {
+ deleteMilestoneModal,
+ },
+ data() {
+ return {
+ modalProps: {
+ milestoneId: -1,
+ milestoneTitle: '',
+ milestoneUrl: '',
+ issueCount: -1,
+ mergeRequestCount: -1,
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('deleteMilestoneModal.props', this.setModalProps);
+ eventHub.$emit('deleteMilestoneModal.mounted');
+ },
+ beforeDestroy() {
+ eventHub.$off('deleteMilestoneModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement(deleteMilestoneModal, {
+ props: this.modalProps,
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/pages/milestones/shared/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js
new file mode 100644
index 00000000000..dabfe32848b
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/index.js
@@ -0,0 +1,7 @@
+import initDeleteMilestoneModal from './delete_milestone_modal_init';
+import initPromoteMilestoneModal from './promote_milestone_modal_init';
+
+export default () => {
+ initDeleteMilestoneModal();
+ initPromoteMilestoneModal();
+};
diff --git a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
new file mode 100644
index 00000000000..b2a896a3265
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
@@ -0,0 +1,11 @@
+/* eslint-disable no-new */
+
+import Milestone from '~/milestone';
+import Sidebar from '~/right_sidebar';
+import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
+
+export default () => {
+ new Milestone();
+ new Sidebar();
+ new MountMilestoneSidebar();
+};
diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js
new file mode 100644
index 00000000000..8e79341e96a
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
+import eventHub from './event_hub';
+
+Vue.use(Translate);
+
+export default () => {
+ const onRequestFinished = ({ milestoneUrl, successful }) => {
+ const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`);
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+ };
+
+ const onRequestStarted = (milestoneUrl) => {
+ const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`);
+ button.setAttribute('disabled', '');
+ eventHub.$once('promoteMilestoneModal.requestFinished', onRequestFinished);
+ };
+
+ const onDeleteButtonClick = (event) => {
+ const button = event.currentTarget;
+ const modalProps = {
+ milestoneTitle: button.dataset.milestoneTitle,
+ url: button.dataset.url,
+ groupName: button.dataset.groupName,
+ };
+ eventHub.$once('promoteMilestoneModal.requestStarted', onRequestStarted);
+ eventHub.$emit('promoteMilestoneModal.props', modalProps);
+ };
+
+ const promoteMilestoneButtons = document.querySelectorAll('.js-promote-project-milestone-button');
+ promoteMilestoneButtons.forEach((button) => {
+ button.addEventListener('click', onDeleteButtonClick);
+ });
+
+ eventHub.$once('promoteMilestoneModal.mounted', () => {
+ promoteMilestoneButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ });
+ });
+
+ const promoteMilestoneModal = document.getElementById('promote-milestone-modal');
+ let promoteMilestoneComponent;
+
+ if (promoteMilestoneModal) {
+ promoteMilestoneComponent = new Vue({
+ el: promoteMilestoneModal,
+ components: {
+ PromoteMilestoneModal,
+ },
+ data() {
+ return {
+ modalProps: {
+ milestoneTitle: '',
+ groupName: '',
+ url: '',
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('promoteMilestoneModal.props', this.setModalProps);
+ eventHub.$emit('promoteMilestoneModal.mounted');
+ },
+ beforeDestroy() {
+ eventHub.$off('promoteMilestoneModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement('promote-milestone-modal', {
+ props: this.modalProps,
+ });
+ },
+ });
+ }
+
+ return promoteMilestoneComponent;
+};
diff --git a/app/assets/javascripts/pages/omniauth_callbacks/index.js b/app/assets/javascripts/pages/omniauth_callbacks/index.js
new file mode 100644
index 00000000000..c2c069d1ca8
--- /dev/null
+++ b/app/assets/javascripts/pages/omniauth_callbacks/index.js
@@ -0,0 +1,3 @@
+import initU2F from '../../shared/sessions/u2f';
+
+document.addEventListener('DOMContentLoaded', initU2F);
diff --git a/app/assets/javascripts/pages/profiles/accounts/show/index.js b/app/assets/javascripts/pages/profiles/accounts/show/index.js
new file mode 100644
index 00000000000..96c3d725780
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/accounts/show/index.js
@@ -0,0 +1,3 @@
+import initProfileAccount from '~/profile/account';
+
+document.addEventListener('DOMContentLoaded', initProfileAccount);
diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js
new file mode 100644
index 00000000000..04e50963699
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import '~/profile/gl_crop';
+import Profile from '~/profile/profile';
+
+document.addEventListener('DOMContentLoaded', () => {
+ $(document).on('input.ssh_key', '#key_key', function () { // eslint-disable-line func-names
+ const $title = $('#key_title');
+ const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/);
+
+ // Extract the SSH Key title from its comment
+ if (comment && comment.length > 1) {
+ $title.val(comment[1]).change();
+ }
+ });
+
+ new Profile(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/profiles/index/index.js b/app/assets/javascripts/pages/profiles/index/index.js
new file mode 100644
index 00000000000..9bd430f4f11
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/index/index.js
@@ -0,0 +1,7 @@
+import NotificationsForm from '../../../notifications_form';
+import notificationsDropdown from '../../../notifications_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new NotificationsForm(); // eslint-disable-line no-new
+ notificationsDropdown();
+});
diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js
new file mode 100644
index 00000000000..2e24a10fa5c
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js
@@ -0,0 +1,7 @@
+import NotificationsForm from '../../../../notifications_form';
+import notificationsDropdown from '../../../../notifications_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new NotificationsForm(); // eslint-disable-line no-new
+ notificationsDropdown();
+});
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
new file mode 100644
index 00000000000..78a5c4c27be
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -0,0 +1,3 @@
+import DueDateSelectors from '~/due_date_select';
+
+document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
new file mode 100644
index 00000000000..fbdef329ab2
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -0,0 +1,15 @@
+import $ from 'jquery';
+import U2FRegister from '~/u2f/register';
+
+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/pages/projects/activity/index.js b/app/assets/javascripts/pages/projects/activity/index.js
new file mode 100644
index 00000000000..5543ad82428
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/activity/index.js
@@ -0,0 +1,7 @@
+import Activities from '~/activities';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Activities(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/artifacts/browse/index.js b/app/assets/javascripts/pages/projects/artifacts/browse/index.js
new file mode 100644
index 00000000000..ea7458fe9b8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/artifacts/browse/index.js
@@ -0,0 +1,7 @@
+import BuildArtifacts from '~/build_artifacts';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new BuildArtifacts(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/artifacts/file/index.js b/app/assets/javascripts/pages/projects/artifacts/file/index.js
new file mode 100644
index 00000000000..8484e5e9848
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/artifacts/file/index.js
@@ -0,0 +1,7 @@
+import BlobViewer from '~/blob/viewer/index';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/blame/show/index.js b/app/assets/javascripts/pages/projects/blame/show/index.js
new file mode 100644
index 00000000000..80d0bff92fa
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blame/show/index.js
@@ -0,0 +1,3 @@
+import initBlob from '~/pages/projects/init_blob';
+
+document.addEventListener('DOMContentLoaded', initBlob);
diff --git a/app/assets/javascripts/pages/projects/blob/edit/index.js b/app/assets/javascripts/pages/projects/blob/edit/index.js
new file mode 100644
index 00000000000..189053f3ed7
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blob/edit/index.js
@@ -0,0 +1,3 @@
+import initBlobBundle from '~/blob_edit/blob_bundle';
+
+document.addEventListener('DOMContentLoaded', initBlobBundle);
diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js
new file mode 100644
index 00000000000..189053f3ed7
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blob/new/index.js
@@ -0,0 +1,3 @@
+import initBlobBundle from '~/blob_edit/blob_bundle';
+
+document.addEventListener('DOMContentLoaded', initBlobBundle);
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
new file mode 100644
index 00000000000..85c6862d629
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import BlobViewer from '~/blob/viewer/index';
+import initBlob from '~/pages/projects/init_blob';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new BlobViewer(); // eslint-disable-line no-new
+ initBlob();
+
+ const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
+ const statusLink = document.querySelector('.commit-actions .ci-status-link');
+ if (statusLink) {
+ statusLink.remove();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: CommitPipelineStatusEl,
+ components: {
+ commitPipelineStatus,
+ },
+ render(createElement) {
+ return createElement('commit-pipeline-status', {
+ props: {
+ endpoint: CommitPipelineStatusEl.dataset.endpoint,
+ },
+ });
+ },
+ });
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js
new file mode 100644
index 00000000000..5cfe8723204
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/boards/index.js
@@ -0,0 +1,9 @@
+import UsersSelect from '~/users_select';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import initBoards from '~/boards';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsersSelect(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ initBoards();
+});
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
new file mode 100644
index 00000000000..8fa266a37ce
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -0,0 +1,7 @@
+import AjaxLoadingSpinner from '~/ajax_loading_spinner';
+import DeleteModal from '~/branches/branches_delete_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ AjaxLoadingSpinner.init();
+ new DeleteModal(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js
new file mode 100644
index 00000000000..a9658fd1eb4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/branches/new/index.js
@@ -0,0 +1,6 @@
+import $ from 'jquery';
+import NewBranchForm from '~/new_branch_form';
+
+document.addEventListener('DOMContentLoaded', () => (
+ new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML))
+));
diff --git a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js
new file mode 100644
index 00000000000..9ab73be80a0
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js
@@ -0,0 +1,19 @@
+export default class CILintEditor {
+ constructor() {
+ this.editor = window.ace.edit('ci-editor');
+ this.textarea = document.querySelector('#content');
+ this.clearYml = document.querySelector('.clear-yml');
+
+ this.editor.getSession().setMode('ace/mode/yaml');
+ this.editor.on('input', () => {
+ const content = this.editor.getSession().getValue();
+ this.textarea.value = content;
+ });
+
+ this.clearYml.addEventListener('click', this.clear.bind(this));
+ }
+
+ clear() {
+ this.editor.setValue('');
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/ci/lints/new/index.js b/app/assets/javascripts/pages/projects/ci/lints/new/index.js
new file mode 100644
index 00000000000..8e8a843da0b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ci/lints/new/index.js
@@ -0,0 +1,3 @@
+import CILintEditor from '../ci_lint_editor';
+
+document.addEventListener('DOMContentLoaded', () => new CILintEditor());
diff --git a/app/assets/javascripts/pages/projects/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js
new file mode 100644
index 00000000000..8e8a843da0b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js
@@ -0,0 +1,3 @@
+import CILintEditor from '../ci_lint_editor';
+
+document.addEventListener('DOMContentLoaded', () => new CILintEditor());
diff --git a/app/assets/javascripts/pages/projects/clusters/destroy/index.js b/app/assets/javascripts/pages/projects/clusters/destroy/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/destroy/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
new file mode 100644
index 00000000000..e4b8baede58
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -0,0 +1,5 @@
+import ClustersIndex from '~/clusters/clusters_index';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersIndex(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/show/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/update/index.js b/app/assets/javascripts/pages/projects/clusters/update/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/update/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
new file mode 100644
index 00000000000..8cc3cb0a57c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -0,0 +1,11 @@
+import $ from 'jquery';
+import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import initPipelines from '~/commit/pipelines/pipelines_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+ $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
+ initPipelines();
+});
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
new file mode 100644
index 00000000000..2e23cce11ce
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -0,0 +1,26 @@
+/* eslint-disable no-new */
+
+import $ from 'jquery';
+import Diff from '~/diff';
+import ZenMode from '~/zen_mode';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import initNotes from '~/init_notes';
+import initChangesDropdown from '~/init_changes_dropdown';
+import initDiffNotes from '~/diff_notes/diff_notes_bundle';
+import { fetchCommitMergeRequests } from '~/commit_merge_requests';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Diff();
+ new ZenMode();
+ new ShortcutsNavigation();
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+ initNotes();
+ const stickyBarPaddingTop = 16;
+ initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - stickyBarPaddingTop);
+ $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
+ fetchCommitMergeRequests();
+ initDiffNotes();
+});
diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js
new file mode 100644
index 00000000000..3682020579b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/commits/show/index.js
@@ -0,0 +1,9 @@
+import CommitsList from '~/commits';
+import GpgBadges from '~/gpg_badges';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ GpgBadges.fetch();
+});
diff --git a/app/assets/javascripts/pages/projects/compare/index.js b/app/assets/javascripts/pages/projects/compare/index.js
new file mode 100644
index 00000000000..d1c78bd61db
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/compare/index.js
@@ -0,0 +1,3 @@
+import initCompareAutocomplete from '~/compare_autocomplete';
+
+document.addEventListener('DOMContentLoaded', initCompareAutocomplete);
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
new file mode 100644
index 00000000000..2b4fd3c47c0
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -0,0 +1,8 @@
+import Diff from '~/diff';
+import initChangesDropdown from '~/init_changes_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Diff(); // eslint-disable-line no-new
+ const paddingTop = 16;
+ initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
+});
diff --git a/app/assets/javascripts/pages/projects/constants.js b/app/assets/javascripts/pages/projects/constants.js
new file mode 100644
index 00000000000..9efbf7cd36e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/constants.js
@@ -0,0 +1,6 @@
+/* eslint-disable import/prefer-default-export */
+
+export const ISSUABLE_INDEX = {
+ MERGE_REQUEST: 'merge_request_',
+ ISSUE: 'issue_',
+};
diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
new file mode 100644
index 00000000000..df58e9dd072
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
@@ -0,0 +1,3 @@
+import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
+
+document.addEventListener('DOMContentLoaded', initCycleAnalytics);
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
new file mode 100644
index 00000000000..be37df36be8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -0,0 +1,16 @@
+import initSettingsPanels from '~/settings_panels';
+import setupProjectEdit from '~/project_edit';
+import initConfirmDangerModal from '~/confirm_danger_modal';
+import ProjectNew from '../shared/project_new';
+import projectAvatar from '../shared/project_avatar';
+import initProjectPermissionsSettings from '../shared/permissions';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectNew(); // eslint-disable-line no-new
+ setupProjectEdit();
+ // Initialize expandable settings panels
+ initSettingsPanels();
+ projectAvatar();
+ initProjectPermissionsSettings();
+ initConfirmDangerModal();
+});
diff --git a/app/assets/javascripts/pages/projects/environments/folder/index.js b/app/assets/javascripts/pages/projects/environments/folder/index.js
new file mode 100644
index 00000000000..5feaf944038
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/folder/index.js
@@ -0,0 +1,3 @@
+import initEnvironmentsFolderBundle from '~/environments/folder/environments_folder_bundle';
+
+document.addEventListener('DOMContentLoaded', initEnvironmentsFolderBundle);
diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js
new file mode 100644
index 00000000000..ace8af00ece
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/index/index.js
@@ -0,0 +1,3 @@
+import initEnviroments from '~/environments/';
+
+document.addEventListener('DOMContentLoaded', initEnviroments);
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
new file mode 100644
index 00000000000..0b644780ad4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js
@@ -0,0 +1,3 @@
+import monitoringBundle from '~/monitoring/monitoring_bundle';
+
+document.addEventListener('DOMContentLoaded', monitoringBundle);
diff --git a/app/assets/javascripts/pages/projects/environments/terminal/index.js b/app/assets/javascripts/pages/projects/environments/terminal/index.js
new file mode 100644
index 00000000000..7129e24cee1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/environments/terminal/index.js
@@ -0,0 +1,3 @@
+import initTerminal from '~/terminal/';
+
+document.addEventListener('DOMContentLoaded', initTerminal);
diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js
new file mode 100644
index 00000000000..24630c2aa05
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/find_file/show/index.js
@@ -0,0 +1,13 @@
+import $ from 'jquery';
+import ProjectFindFile from '~/project_find_file';
+import ShortcutsFindFile from '~/shortcuts_find_file';
+
+document.addEventListener('DOMContentLoaded', () => {
+ 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); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
new file mode 100644
index 00000000000..d80e27e9156
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -0,0 +1,3 @@
+import ProjectFork from '~/project_fork';
+
+document.addEventListener('DOMContentLoaded', () => new ProjectFork());
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
new file mode 100644
index 00000000000..80159a82bd4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -0,0 +1,62 @@
+import $ from 'jquery';
+import Chart from 'chart.js';
+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/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js
new file mode 100644
index 00000000000..71f629fbc13
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/show/index.js
@@ -0,0 +1,24 @@
+import $ from 'jquery';
+import flash from '~/flash';
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import ContributorsStatGraph from './stat_graph_contributors';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath;
+
+ axios.get(url)
+ .then(({ data }) => {
+ const graph = new ContributorsStatGraph();
+ graph.init(data);
+
+ $('#brush_change').change(() => {
+ graph.change_date_header();
+ graph.redraw_authors();
+ });
+
+ $('.stat-graph').fadeIn();
+ $('.loading-graph').hide();
+ })
+ .catch(() => flash(__('Error fetching contributors data.')));
+});
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
new file mode 100644
index 00000000000..653e2502d01
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
@@ -0,0 +1,126 @@
+/* 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 'jquery';
+import _ from 'underscore';
+import { n__, s__, createDateTimeFormat, sprintf } from '~/locale';
+import { ContributorsGraph, ContributorsAuthorGraph, ContributorsMasterGraph } from './stat_graph_contributors_graph';
+import ContributorsStatGraphUtil from './stat_graph_contributors_util';
+
+export default (function() {
+ function ContributorsStatGraph() {
+ this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
+ }
+
+ ContributorsStatGraph.prototype.init = function(log) {
+ var author_commits, total_commits;
+ this.parsed_log = ContributorsStatGraphUtil.parse_log(log);
+ this.set_current_field("commits");
+ total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
+ author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field);
+ this.add_master_graph(total_commits);
+ this.add_authors_graph(author_commits);
+ return this.change_date_header();
+ };
+
+ ContributorsStatGraph.prototype.add_master_graph = function(total_data) {
+ this.master_graph = new ContributorsMasterGraph(total_data);
+ return this.master_graph.draw();
+ };
+
+ ContributorsStatGraph.prototype.add_authors_graph = function(author_data) {
+ var limited_author_data;
+ this.authors = [];
+ limited_author_data = author_data.slice(0, 100);
+ return _.each(limited_author_data, (function(_this) {
+ return function(d) {
+ var author_graph, author_header;
+ author_header = _this.create_author_header(d);
+ $(".contributors-list").append(author_header);
+ _this.authors[d.author_name] = author_graph = new ContributorsAuthorGraph(d.dates);
+ return author_graph.draw();
+ };
+ })(this));
+ };
+
+ ContributorsStatGraph.prototype.format_author_commit_info = function(author) {
+ var commits;
+ commits = $('<span/>', {
+ "class": 'graph-author-commits-count'
+ });
+ commits.text(n__('%d commit', '%d commits', author.commits));
+ return $('<span/>').append(commits);
+ };
+
+ ContributorsStatGraph.prototype.create_author_header = function(author) {
+ var author_commit_info, author_commit_info_span, author_email, author_name, list_item;
+ list_item = $('<li/>', {
+ "class": 'person',
+ style: 'display: block;'
+ });
+ author_name = $('<h4>' + author.author_name + '</h4>');
+ author_email = $('<p class="graph-author-email">' + author.author_email + '</p>');
+ author_commit_info_span = $('<span/>', {
+ "class": 'commits'
+ });
+ author_commit_info = this.format_author_commit_info(author);
+ author_commit_info_span.html(author_commit_info);
+ list_item.append(author_name);
+ list_item.append(author_email);
+ list_item.append(author_commit_info_span);
+ return list_item;
+ };
+
+ ContributorsStatGraph.prototype.redraw_master = function() {
+ var total_data;
+ total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
+ this.master_graph.set_data(total_data);
+ return this.master_graph.redraw();
+ };
+
+ ContributorsStatGraph.prototype.redraw_authors = function() {
+ var author_commits, x_domain;
+ $("ol").html("");
+ x_domain = ContributorsGraph.prototype.x_domain;
+ author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field, x_domain);
+ return _.each(author_commits, (function(_this) {
+ return function(d) {
+ _this.redraw_author_commit_info(d);
+ if (_this.authors[d.author_name] != null) {
+ $(_this.authors[d.author_name].list_item).appendTo("ol");
+ _this.authors[d.author_name].set_data(d.dates);
+ return _this.authors[d.author_name].redraw();
+ }
+ return '';
+ };
+ })(this));
+ };
+
+ ContributorsStatGraph.prototype.set_current_field = function(field) {
+ return this.field = field;
+ };
+
+ ContributorsStatGraph.prototype.change_date_header = function() {
+ const x_domain = ContributorsGraph.prototype.x_domain;
+ const formattedDateRange = sprintf(
+ s__('ContributorsPage|%{startDate} – %{endDate}'),
+ {
+ startDate: this.dateFormat.format(new Date(x_domain[0])),
+ endDate: this.dateFormat.format(new Date(x_domain[1])),
+ },
+ );
+ return $('#date_header').text(formattedDateRange);
+ };
+
+ ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) {
+ var author_commit_info, author_list_item, $author;
+ $author = this.authors[author.author_name];
+ if ($author != null) {
+ author_list_item = $(this.authors[author.author_name].list_item);
+ author_commit_info = this.format_author_commit_info(author);
+ return author_list_item.find("span").html(author_commit_info);
+ }
+ return '';
+ };
+
+ return ContributorsStatGraph;
+})();
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
new file mode 100644
index 00000000000..a99ce0f1c36
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
@@ -0,0 +1,296 @@
+/* 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 'jquery';
+import _ from 'underscore';
+import { extent, max } from 'd3-array';
+import { select, event as d3Event } from 'd3-selection';
+import { scaleTime, scaleLinear } from 'd3-scale';
+import { axisLeft, axisBottom } from 'd3-axis';
+import { area } from 'd3-shape';
+import { brushX } from 'd3-brush';
+import { timeParse } from 'd3-time-format';
+import { dateTickFormat } from '~/lib/utils/tick_formats';
+
+const d3 = { extent, max, select, scaleTime, scaleLinear, axisLeft, axisBottom, area, brushX, timeParse };
+
+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;
+
+export const ContributorsGraph = (function() {
+ function ContributorsGraph() {}
+
+ ContributorsGraph.prototype.MARGIN = {
+ top: 20,
+ right: 20,
+ bottom: 30,
+ left: 50
+ };
+
+ ContributorsGraph.prototype.x_domain = null;
+
+ ContributorsGraph.prototype.y_domain = null;
+
+ ContributorsGraph.prototype.dates = [];
+
+ ContributorsGraph.set_x_domain = function(data) {
+ return ContributorsGraph.prototype.x_domain = data;
+ };
+
+ ContributorsGraph.set_y_domain = function(data) {
+ return ContributorsGraph.prototype.y_domain = [
+ 0, d3.max(data, function(d) {
+ return d.commits = d.commits || d.additions || d.deletions;
+ })
+ ];
+ };
+
+ ContributorsGraph.init_x_domain = function(data) {
+ return ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) {
+ return d.date;
+ });
+ };
+
+ ContributorsGraph.init_y_domain = function(data) {
+ return ContributorsGraph.prototype.y_domain = [
+ 0, d3.max(data, function(d) {
+ return d.commits = d.commits || d.additions || d.deletions;
+ })
+ ];
+ };
+
+ ContributorsGraph.init_domain = function(data) {
+ ContributorsGraph.init_x_domain(data);
+ return ContributorsGraph.init_y_domain(data);
+ };
+
+ ContributorsGraph.set_dates = function(data) {
+ return ContributorsGraph.prototype.dates = data;
+ };
+
+ ContributorsGraph.prototype.set_x_domain = function() {
+ return this.x.domain(this.x_domain);
+ };
+
+ ContributorsGraph.prototype.set_y_domain = function() {
+ return this.y.domain(this.y_domain);
+ };
+
+ ContributorsGraph.prototype.set_domain = function() {
+ this.set_x_domain();
+ return this.set_y_domain();
+ };
+
+ ContributorsGraph.prototype.create_scale = function(width, height) {
+ this.x = d3.scaleTime().range([0, width]).clamp(true);
+ return this.y = d3.scaleLinear().range([height, 0]).nice();
+ };
+
+ ContributorsGraph.prototype.draw_x_axis = function() {
+ return this.svg.append("g").attr("class", "x axis").attr("transform", "translate(0, " + this.height + ")").call(this.x_axis);
+ };
+
+ ContributorsGraph.prototype.draw_y_axis = function() {
+ return this.svg.append("g").attr("class", "y axis").call(this.y_axis);
+ };
+
+ ContributorsGraph.prototype.set_data = function(data) {
+ return this.data = data;
+ };
+
+ return ContributorsGraph;
+})();
+
+export const ContributorsMasterGraph = (function(superClass) {
+ extend(ContributorsMasterGraph, superClass);
+
+ function ContributorsMasterGraph(data1) {
+ const $parentElement = $('#contributors-master');
+ const parentPadding = parseFloat($parentElement.css('padding-left')) + parseFloat($parentElement.css('padding-right'));
+
+ this.data = data1;
+ this.update_content = this.update_content.bind(this);
+ this.width = $('.content').width() - parentPadding - (this.MARGIN.left + this.MARGIN.right);
+ this.height = 200;
+ this.x = null;
+ this.y = null;
+ this.x_axis = null;
+ this.y_axis = null;
+ this.area = null;
+ this.svg = null;
+ this.brush = null;
+ this.x_max_domain = null;
+ }
+
+ ContributorsMasterGraph.prototype.process_dates = function(data) {
+ var dates;
+ dates = this.get_dates(data);
+ this.parse_dates(data);
+ return ContributorsGraph.set_dates(dates);
+ };
+
+ ContributorsMasterGraph.prototype.get_dates = function(data) {
+ return _.pluck(data, 'date');
+ };
+
+ ContributorsMasterGraph.prototype.parse_dates = function(data) {
+ var parseDate;
+ parseDate = d3.timeParse("%Y-%m-%d");
+ return data.forEach(function(d) {
+ return d.date = parseDate(d.date);
+ });
+ };
+
+ ContributorsMasterGraph.prototype.create_scale = function() {
+ return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height);
+ };
+
+ ContributorsMasterGraph.prototype.create_axes = function() {
+ this.x_axis = d3.axisBottom()
+ .scale(this.x)
+ .tickFormat(dateTickFormat);
+ return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
+ };
+
+ ContributorsMasterGraph.prototype.create_svg = function() {
+ return this.svg = d3.select("#contributors-master").append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "tint-box").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
+ };
+
+ ContributorsMasterGraph.prototype.create_area = function(x, y) {
+ return this.area = d3.area().x(function(d) {
+ return x(d.date);
+ }).y0(this.height).y1(function(d) {
+ d.commits = d.commits || d.additions || d.deletions;
+ return y(d.commits);
+ });
+ };
+
+ ContributorsMasterGraph.prototype.create_brush = function() {
+ return this.brush = d3.brushX(this.x).extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]).on("end", this.update_content);
+ };
+
+ ContributorsMasterGraph.prototype.draw_path = function(data) {
+ return this.svg.append("path").datum(data).attr("class", "area").attr("d", this.area);
+ };
+
+ ContributorsMasterGraph.prototype.add_brush = function() {
+ return this.svg.append("g").attr("class", "selection").call(this.brush).selectAll("rect").attr("height", this.height);
+ };
+
+ ContributorsMasterGraph.prototype.update_content = function() {
+ // d3Event.selection replaces the function brush.empty() calls
+ if (d3Event.selection != null) {
+ ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert));
+ } else {
+ ContributorsGraph.set_x_domain(this.x_max_domain);
+ }
+ return $("#brush_change").trigger('change');
+ };
+
+ ContributorsMasterGraph.prototype.draw = function() {
+ this.process_dates(this.data);
+ this.create_scale();
+ this.create_axes();
+ ContributorsGraph.init_domain(this.data);
+ this.x_max_domain = this.x_domain;
+ this.set_domain();
+ this.create_area(this.x, this.y);
+ this.create_svg();
+ this.create_brush();
+ this.draw_path(this.data);
+ this.draw_x_axis();
+ this.draw_y_axis();
+ return this.add_brush();
+ };
+
+ ContributorsMasterGraph.prototype.redraw = function() {
+ this.process_dates(this.data);
+ ContributorsGraph.set_y_domain(this.data);
+ this.set_y_domain();
+ this.svg.select("path").datum(this.data);
+ this.svg.select("path").attr("d", this.area);
+ return this.svg.select(".y.axis").call(this.y_axis);
+ };
+
+ return ContributorsMasterGraph;
+})(ContributorsGraph);
+
+export const ContributorsAuthorGraph = (function(superClass) {
+ extend(ContributorsAuthorGraph, superClass);
+
+ function ContributorsAuthorGraph(data1) {
+ this.data = data1;
+ // Don't split graph size in half for mobile devices.
+ if ($(window).width() < 768) {
+ this.width = $('.content').width() - 80;
+ } else {
+ this.width = ($('.content').width() / 2) - 100;
+ }
+ this.height = 200;
+ this.x = null;
+ this.y = null;
+ this.x_axis = null;
+ this.y_axis = null;
+ this.area = null;
+ this.svg = null;
+ this.list_item = null;
+ }
+
+ ContributorsAuthorGraph.prototype.create_scale = function() {
+ return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height);
+ };
+
+ ContributorsAuthorGraph.prototype.create_axes = function() {
+ this.x_axis = d3.axisBottom()
+ .scale(this.x)
+ .ticks(8)
+ .tickFormat(dateTickFormat);
+ return this.y_axis = d3.axisLeft().scale(this.y).ticks(5);
+ };
+
+ ContributorsAuthorGraph.prototype.create_area = function(x, y) {
+ return this.area = d3.area().x(function(d) {
+ var parseDate;
+ parseDate = d3.timeParse("%Y-%m-%d");
+ return x(parseDate(d));
+ }).y0(this.height).y1((function(_this) {
+ return function(d) {
+ if (_this.data[d] != null) {
+ return y(_this.data[d]);
+ } else {
+ return y(0);
+ }
+ };
+ })(this));
+ };
+
+ ContributorsAuthorGraph.prototype.create_svg = function() {
+ var persons = document.querySelectorAll('.person');
+ this.list_item = persons[persons.length - 1];
+ return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")");
+ };
+
+ ContributorsAuthorGraph.prototype.draw_path = function(data) {
+ return this.svg.append("path").datum(data).attr("class", "area-contributor").attr("d", this.area);
+ };
+
+ ContributorsAuthorGraph.prototype.draw = function() {
+ this.create_scale();
+ this.create_axes();
+ this.set_domain();
+ this.create_area(this.x, this.y);
+ this.create_svg();
+ this.draw_path(this.dates);
+ this.draw_x_axis();
+ return this.draw_y_axis();
+ };
+
+ ContributorsAuthorGraph.prototype.redraw = function() {
+ this.set_domain();
+ this.svg.select("path").datum(this.dates);
+ this.svg.select("path").attr("d", this.area);
+ this.svg.select(".x.axis").call(this.x_axis);
+ return this.svg.select(".y.axis").call(this.y_axis);
+ };
+
+ return ContributorsAuthorGraph;
+})(ContributorsGraph);
diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
index 77135ad1f0e..77135ad1f0e 100644
--- a/app/assets/javascripts/graphs/stat_graph_contributors_util.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
diff --git a/app/assets/javascripts/pages/projects/imports/show/index.js b/app/assets/javascripts/pages/projects/imports/show/index.js
new file mode 100644
index 00000000000..d5f92baf054
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/imports/show/index.js
@@ -0,0 +1,5 @@
+import ProjectImport from '~/project_import';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectImport(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
new file mode 100644
index 00000000000..de1e13de7e9
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -0,0 +1,7 @@
+import Project from './project';
+import ShortcutsNavigation from '../../shortcuts_navigation';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Project(); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
new file mode 100644
index 00000000000..82143fa875a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -0,0 +1,36 @@
+import LineHighlighter from '~/line_highlighter';
+import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import ShortcutsBlob from '~/shortcuts_blob';
+import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
+import initBlobBundle from '~/blob_edit/blob_bundle';
+
+export default () => {
+ new LineHighlighter(); // eslint-disable-line no-new
+
+ new BlobLinePermalinkUpdater( // eslint-disable-line no-new
+ document.querySelector('#blob-content-holder'),
+ '.diff-line-num[data-line-number]',
+ document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
+ );
+
+ const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
+ const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
+
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+
+ new ShortcutsBlob({ // eslint-disable-line no-new
+ skipResetBindings: true,
+ fileBlobPermalinkUrl,
+ });
+
+ new BlobForkSuggestion({ // eslint-disable-line no-new
+ openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'),
+ forkButtons: document.querySelectorAll('.js-fork-suggestion-button'),
+ cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'),
+ suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'),
+ actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'),
+ }).init();
+
+ initBlobBundle();
+};
diff --git a/app/assets/javascripts/pages/projects/init_form.js b/app/assets/javascripts/pages/projects/init_form.js
new file mode 100644
index 00000000000..0b6c5c1d30b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/init_form.js
@@ -0,0 +1,7 @@
+import ZenMode from '~/zen_mode';
+import GLForm from '~/gl_form';
+
+export default function ($formEl) {
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($formEl, true); // eslint-disable-line no-new
+}
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
new file mode 100644
index 00000000000..ffc84dc106b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
new file mode 100644
index 00000000000..14fddbc9a05
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -0,0 +1,18 @@
+/* eslint-disable no-new */
+
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import IssuableForm from '~/issuable_form';
+import LabelsSelect from '~/labels_select';
+import MilestoneSelect from '~/milestone_select';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
+
+export default () => {
+ new ShortcutsNavigation();
+ new GLForm($('.issue-form'), true);
+ new IssuableForm($('.issue-form'));
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssuableTemplateSelectors();
+};
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
new file mode 100644
index 00000000000..70fdb0ef40d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -0,0 +1,18 @@
+/* eslint-disable no-new */
+
+import IssuableIndex from '~/issuable_index';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import UsersSelect from '~/users_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ });
+ new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+
+ new ShortcutsNavigation();
+ new UsersSelect();
+});
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
new file mode 100644
index 00000000000..ffc84dc106b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
new file mode 100644
index 00000000000..500fbd27340
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -0,0 +1,13 @@
+import initIssuableSidebar from '~/init_issuable_sidebar';
+import Issue from '~/issue';
+import ShortcutsIssuable from '~/shortcuts_issuable';
+import ZenMode from '~/zen_mode';
+import '~/notes/index';
+import '~/issue_show/index';
+
+export default function () {
+ new Issue(); // eslint-disable-line no-new
+ new ShortcutsIssuable(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+ initIssuableSidebar();
+}
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
new file mode 100644
index 00000000000..7968dfd7a12
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -0,0 +1,7 @@
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initShow from '../show';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initShow();
+ initSidebarBundle();
+});
diff --git a/app/assets/javascripts/pages/projects/jobs/show/index.js b/app/assets/javascripts/pages/projects/jobs/show/index.js
new file mode 100644
index 00000000000..3626f3ffec6
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/jobs/show/index.js
@@ -0,0 +1,3 @@
+import initJobDetails from '~/jobs/job_details_bundle';
+
+document.addEventListener('DOMContentLoaded', initJobDetails);
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
new file mode 100644
index 00000000000..ad6df51bb7a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -0,0 +1,88 @@
+<script>
+ import _ from 'underscore';
+ import axios from '~/lib/utils/axios_utils';
+ import createFlash from '~/flash';
+ import GlModal from '~/vue_shared/components/gl_modal.vue';
+ import { s__, sprintf } from '~/locale';
+ import { visitUrl } from '~/lib/utils/url_utility';
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ labelTitle: {
+ type: String,
+ required: true,
+ },
+ labelColor: {
+ type: String,
+ required: true,
+ },
+ labelTextColor: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return sprintf(s__(`Labels|Promoting %{labelTitle} will make it available for all projects inside %{groupName}.
+ Existing project labels with the same title will be merged. This action cannot be reversed.`), {
+ labelTitle: this.labelTitle,
+ groupName: this.groupName,
+ });
+ },
+ title() {
+ const label = `<span
+ class="label color-label"
+ style="background-color: ${this.labelColor}; color: ${this.labelTextColor};"
+ >${_.escape(this.labelTitle)}</span>`;
+
+ return sprintf(s__('Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>'), {
+ labelTitle: label,
+ }, false);
+ },
+ },
+ methods: {
+ onSubmit() {
+ eventHub.$emit('promoteLabelModal.requestStarted', this.url);
+ return axios.post(this.url, { params: { format: 'json' } })
+ .then((response) => {
+ eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: true });
+ visitUrl(response.data.url);
+ })
+ .catch((error) => {
+ eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: false });
+ createFlash(error);
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <gl-modal
+ id="promote-label-modal"
+ footer-primary-button-variant="warning"
+ :footer-primary-button-text="s__('Labels|Promote Label')"
+ @submit="onSubmit"
+ >
+ <div
+ slot="title"
+ class="modal-title-with-label"
+ v-html="title"
+ >
+ {{ title }}
+ </div>
+
+ {{ text }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/pages/projects/labels/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js
new file mode 100644
index 00000000000..03cfef61311
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/index/index.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import initLabels from '~/init_labels';
+import eventHub from '../event_hub';
+import PromoteLabelModal from '../components/promote_label_modal.vue';
+
+Vue.use(Translate);
+
+const initLabelIndex = () => {
+ initLabels();
+
+ const onRequestFinished = ({ labelUrl, successful }) => {
+ const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`);
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+ };
+
+ const onRequestStarted = (labelUrl) => {
+ const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`);
+ button.setAttribute('disabled', '');
+ eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished);
+ };
+
+ const onDeleteButtonClick = (event) => {
+ const button = event.currentTarget;
+ const modalProps = {
+ labelTitle: button.dataset.labelTitle,
+ labelColor: button.dataset.labelColor,
+ labelTextColor: button.dataset.labelTextColor,
+ url: button.dataset.url,
+ groupName: button.dataset.groupName,
+ };
+ eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted);
+ eventHub.$emit('promoteLabelModal.props', modalProps);
+ };
+
+ const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button');
+ promoteLabelButtons.forEach((button) => {
+ button.addEventListener('click', onDeleteButtonClick);
+ });
+
+ eventHub.$once('promoteLabelModal.mounted', () => {
+ promoteLabelButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ });
+ });
+
+ const promoteLabelModal = document.getElementById('promote-label-modal');
+ let promoteLabelModalComponent;
+
+ if (promoteLabelModal) {
+ promoteLabelModalComponent = new Vue({
+ el: promoteLabelModal,
+ components: {
+ PromoteLabelModal,
+ },
+ data() {
+ return {
+ modalProps: {
+ labelTitle: '',
+ labelColor: '',
+ labelTextColor: '',
+ url: '',
+ groupName: '',
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('promoteLabelModal.props', this.setModalProps);
+ eventHub.$emit('promoteLabelModal.mounted');
+ },
+ beforeDestroy() {
+ eventHub.$off('promoteLabelModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement('promote-label-modal', {
+ props: this.modalProps,
+ });
+ },
+ });
+ }
+
+ return promoteLabelModalComponent;
+};
+
+document.addEventListener('DOMContentLoaded', initLabelIndex);
diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js
new file mode 100644
index 00000000000..fa81ad914ba
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/labels/new/index.js
@@ -0,0 +1,3 @@
+import Labels from '~/labels';
+
+document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
new file mode 100644
index 00000000000..28641104c58
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
@@ -0,0 +1,7 @@
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSidebarBundle();
+ initMergeConflicts();
+});
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/index.js
new file mode 100644
index 00000000000..febfecebbd2
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/index.js
@@ -0,0 +1,3 @@
+import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
+
+document.addEventListener('DOMContentLoaded', initMergeRequest);
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
new file mode 100644
index 00000000000..6c9afddefac
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -0,0 +1,20 @@
+import Compare from '~/compare';
+import MergeRequest from '~/merge_request';
+import initPipelines from '~/commit/pipelines/pipelines_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
+ if (mrNewCompareNode) {
+ new Compare({ // eslint-disable-line no-new
+ targetProjectUrl: mrNewCompareNode.dataset.targetProjectUrl,
+ sourceBranchUrl: mrNewCompareNode.dataset.sourceBranchUrl,
+ targetBranchUrl: mrNewCompareNode.dataset.targetBranchUrl,
+ });
+ } else {
+ const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit');
+ new MergeRequest({ // eslint-disable-line no-new
+ action: mrNewSubmitNode.dataset.mrSubmitAction,
+ });
+ initPipelines();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
new file mode 100644
index 00000000000..febfecebbd2
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -0,0 +1,3 @@
+import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
+
+document.addEventListener('DOMContentLoaded', initMergeRequest);
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
new file mode 100644
index 00000000000..a7aa616319f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -0,0 +1,15 @@
+import IssuableIndex from '~/issuable_index';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import UsersSelect from '~/users_select';
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import { FILTERED_SEARCH } from '~/pages/constants';
+import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initFilteredSearch({
+ page: FILTERED_SEARCH.MERGE_REQUESTS,
+ });
+ new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new UsersSelect(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
new file mode 100644
index 00000000000..406fc32f9a2
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -0,0 +1,20 @@
+/* eslint-disable no-new */
+
+import $ from 'jquery';
+import Diff from '~/diff';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import GLForm from '~/gl_form';
+import IssuableForm from '~/issuable_form';
+import LabelsSelect from '~/labels_select';
+import MilestoneSelect from '~/milestone_select';
+import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
+
+export default () => {
+ new Diff();
+ new ShortcutsNavigation();
+ new GLForm($('.merge-request-form'), true);
+ new IssuableForm($('.merge-request-form'));
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssuableTemplateSelectors();
+};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
new file mode 100644
index 00000000000..28d8761b502
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -0,0 +1,32 @@
+import MergeRequest from '~/merge_request';
+import ZenMode from '~/zen_mode';
+import initNotes from '~/init_notes';
+import initIssuableSidebar from '~/init_issuable_sidebar';
+import initDiffNotes from '~/diff_notes/diff_notes_bundle';
+import ShortcutsIssuable from '~/shortcuts_issuable';
+import Diff from '~/diff';
+import { handleLocationHash } from '~/lib/utils/common_utils';
+import howToMerge from '~/how_to_merge';
+import initPipelines from '~/commit/pipelines/pipelines_bundle';
+import initWidget from '../../../vue_merge_request_widget';
+
+export default function () {
+ new Diff(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+
+ initIssuableSidebar();
+ initNotes();
+ initDiffNotes();
+ initPipelines();
+
+ const mrShowNode = document.querySelector('.merge-request');
+
+ window.mergeRequest = new MergeRequest({
+ action: mrShowNode.dataset.mrAction,
+ });
+
+ new ShortcutsIssuable(true); // eslint-disable-line no-new
+ handleLocationHash();
+ howToMerge();
+ initWidget();
+}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
new file mode 100644
index 00000000000..e5b2827b50c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -0,0 +1,13 @@
+import { hasVueMRDiscussionsCookie } from '~/lib/utils/common_utils';
+import initMrNotes from '~/mr_notes';
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initShow from '../init_merge_request_show';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initShow();
+ initSidebarBundle();
+
+ if (hasVueMRDiscussionsCookie()) {
+ initMrNotes();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js
new file mode 100644
index 00000000000..9a4ebf9890d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm());
diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js
new file mode 100644
index 00000000000..38789365a67
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/index/index.js
@@ -0,0 +1,3 @@
+import milestones from '~/pages/milestones/shared';
+
+document.addEventListener('DOMContentLoaded', milestones);
diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js
new file mode 100644
index 00000000000..9a4ebf9890d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../../../../shared/milestones/form';
+
+document.addEventListener('DOMContentLoaded', () => initForm());
diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js
new file mode 100644
index 00000000000..84a52421598
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/show/index.js
@@ -0,0 +1,7 @@
+import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
+import milestones from '~/pages/milestones/shared';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initMilestonesShow();
+ milestones();
+});
diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js
new file mode 100644
index 00000000000..aa50dd4bb25
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/network/network.js
@@ -0,0 +1,20 @@
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */
+
+import $ from 'jquery';
+import BranchGraph from '../../../network/branch_graph';
+
+export default (function() {
+ function Network(opts) {
+ var vph;
+ $("#filter_ref").click(function() {
+ return $(this).closest('form').submit();
+ });
+ this.branch_graph = new BranchGraph($(".network-graph"), opts);
+ vph = $(window).height() - 250;
+ $('.network-graph').css({
+ 'height': vph + 'px'
+ });
+ }
+
+ return Network;
+})();
diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js
new file mode 100644
index 00000000000..a0b14fed10f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/network/show/index.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import ShortcutsNetwork from '../../../../shortcuts_network';
+import Network from '../network';
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (!$('.network-graph').length) return;
+
+ const networkGraph = new Network({
+ url: $('.network-graph').attr('data-url'),
+ commit_url: $('.network-graph').attr('data-commit-url'),
+ ref: $('.network-graph').attr('data-ref'),
+ commit_id: $('.network-graph').attr('data-commit-id'),
+ });
+
+ // eslint-disable-next-line no-new
+ new ShortcutsNetwork(networkGraph.branch_graph);
+});
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
new file mode 100644
index 00000000000..ea6fd961393
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -0,0 +1,9 @@
+import ProjectNew from '../shared/project_new';
+import initProjectVisibilitySelector from '../../../project_visibility';
+import initProjectNew from '../../../projects/project_new';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectNew(); // eslint-disable-line no-new
+ initProjectVisibilitySelector();
+ initProjectNew.bindEvents();
+});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
new file mode 100644
index 00000000000..544360dcd51
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import PipelineSchedulesCallout from '../shared/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/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
new file mode 100644
index 00000000000..2d18fa2044b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -0,0 +1,160 @@
+<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);
+ },
+ },
+ watch: {
+ cronInterval() {
+ // updates field validation state when model changes, as
+ // glFieldError only updates on input.
+ this.$nextTick(() => {
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ });
+ },
+ },
+ created() {
+ if (this.intervalIsPreset) {
+ this.enableCustomInput = false;
+ }
+ },
+ 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} `;
+ }
+ },
+ },
+ };
+</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/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
new file mode 100644
index 00000000000..77508e62cef
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -0,0 +1,67 @@
+<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',
+ };
+ },
+ created() {
+ this.illustrationSvg = illustrationSvg;
+ },
+ methods: {
+ dismissCallout() {
+ this.calloutDismissed = true;
+ Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 });
+ },
+ },
+ };
+</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/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
new file mode 100644
index 00000000000..4ef0d11dd36
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js
@@ -0,0 +1,54 @@
+import $ from 'jquery';
+
+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/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
new file mode 100644
index 00000000000..95b57d5e048
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
@@ -0,0 +1,68 @@
+/* eslint-disable class-methods-use-this */
+
+import $ from 'jquery';
+
+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/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
index 26d1ff97b3e..26d1ff97b3e 100644
--- a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
new file mode 100644
index 00000000000..c3ac54733a3
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -0,0 +1,50 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import Translate from '../../../../vue_shared/translate';
+import GlFieldErrors from '../../../../gl_field_errors';
+import intervalPatternInput from './components/interval_pattern_input.vue';
+import TimezoneDropdown from './components/timezone_dropdown';
+import TargetBranchDropdown from './components/target_branch_dropdown';
+import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_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,
+ },
+ });
+ },
+ });
+}
+
+export default () => {
+ /* 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 GlFieldErrors(formElement);
+
+ setupNativeFormVariableList({
+ container: $('.js-ci-variable-list-section'),
+ formField: 'schedule',
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
new file mode 100644
index 00000000000..d65be6bc69e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
@@ -0,0 +1,3 @@
+import initForm from '../shared/init_form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipelines/builds/index.js b/app/assets/javascripts/pages/projects/pipelines/builds/index.js
new file mode 100644
index 00000000000..7a57e417b41
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/builds/index.js
@@ -0,0 +1,7 @@
+import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
+import initPipelines from '../init_pipelines';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initPipelines();
+ initPipelineDetails();
+});
diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
new file mode 100644
index 00000000000..07b6992eba1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
@@ -0,0 +1,57 @@
+import $ from 'jquery';
+import Chart from 'chart.js';
+
+const options = {
+ scaleOverlay: true,
+ responsive: true,
+ maintainAspectRatio: false,
+};
+
+const buildChart = (chartScope) => {
+ const data = {
+ labels: chartScope.labels,
+ datasets: [{
+ fillColor: '#707070',
+ strokeColor: '#707070',
+ pointColor: '#707070',
+ pointStrokeColor: '#EEE',
+ data: chartScope.totalValues,
+ },
+ {
+ fillColor: '#1aaa55',
+ strokeColor: '#1aaa55',
+ pointColor: '#1aaa55',
+ pointStrokeColor: '#fff',
+ data: chartScope.successValues,
+ },
+ ],
+ };
+ const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d');
+
+ new Chart(ctx).Line(data, options);
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML);
+ const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML);
+ const data = {
+ labels: chartTimesData.labels,
+ datasets: [{
+ fillColor: 'rgba(220,220,220,0.5)',
+ strokeColor: 'rgba(220,220,220,1)',
+ barStrokeWidth: 1,
+ barValueSpacing: 1,
+ barDatasetSpacing: 1,
+ data: chartTimesData.values,
+ }],
+ };
+
+ if (window.innerWidth < 768) {
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ options.scaleFontSize = 8;
+ }
+
+ new Chart($('#build_timesChart').get(0).getContext('2d')).Bar(data, options);
+
+ chartsData.forEach(scope => buildChart(scope));
+});
diff --git a/app/assets/javascripts/pages/projects/pipelines/failures/index.js b/app/assets/javascripts/pages/projects/pipelines/failures/index.js
new file mode 100644
index 00000000000..fbe9824c34b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/failures/index.js
@@ -0,0 +1,3 @@
+import initPipelines from '../init_pipelines';
+
+document.addEventListener('DOMContentLoaded', initPipelines);
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
new file mode 100644
index 00000000000..a84e2790680
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import PipelinesStore from '../../../../pipelines/stores/pipelines_store';
+import pipelinesComponent from '../../../../pipelines/components/pipelines.vue';
+import Translate from '../../../../vue_shared/translate';
+import { convertPermissionToBoolean } from '../../../../lib/utils/common_utils';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => new Vue({
+ el: '#pipelines-list-vue',
+ components: {
+ pipelinesComponent,
+ },
+ data() {
+ return {
+ store: new PipelinesStore(),
+ };
+ },
+ created() {
+ this.dataset = document.querySelector(this.$options.el).dataset;
+ },
+ render(createElement) {
+ return createElement('pipelines-component', {
+ props: {
+ store: this.store,
+ endpoint: this.dataset.endpoint,
+ helpPagePath: this.dataset.helpPagePath,
+ emptyStateSvgPath: this.dataset.emptyStateSvgPath,
+ errorStateSvgPath: this.dataset.errorStateSvgPath,
+ noPipelinesSvgPath: this.dataset.noPipelinesSvgPath,
+ autoDevopsPath: this.dataset.helpAutoDevopsPath,
+ newPipelinePath: this.dataset.newPipelinePath,
+ canCreatePipeline: convertPermissionToBoolean(this.dataset.canCreatePipeline),
+ hasGitlabCi: convertPermissionToBoolean(this.dataset.hasGitlabCi),
+ ciLintPath: this.dataset.ciLintPath,
+ resetCachePath: this.dataset.resetCachePath,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js
new file mode 100644
index 00000000000..94dfeb96e8c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js
@@ -0,0 +1,16 @@
+import Pipelines from '~/pipelines';
+
+export default () => {
+ const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
+ const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
+
+ new Pipelines({ // eslint-disable-line no-new
+ initTabs: true,
+ pipelineStatusUrl,
+ tabsOptions: {
+ action: controllerAction,
+ defaultAction: 'pipelines',
+ parentEl: '.pipelines-tabs',
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js
new file mode 100644
index 00000000000..9aa8945e268
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js
@@ -0,0 +1,6 @@
+import $ from 'jquery';
+import NewBranchForm from '~/new_branch_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js
new file mode 100644
index 00000000000..7a57e417b41
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js
@@ -0,0 +1,7 @@
+import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
+import initPipelines from '../init_pipelines';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initPipelines();
+ initPipelineDetails();
+});
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
new file mode 100644
index 00000000000..c1e3425ec75
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -0,0 +1,135 @@
+/* eslint-disable func-names, space-before-function-paren, no-var, consistent-return, no-new, prefer-arrow-callback, no-return-assign, one-var, one-var-declaration-per-line, object-shorthand, no-else-return, newline-per-chained-call, no-shadow, vars-on-top, prefer-template, max-len */
+
+import $ from 'jquery';
+import Cookies from 'js-cookie';
+import { __ } from '~/locale';
+import { visitUrl } from '~/lib/utils/url_utility';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import projectSelect from '../../project_select';
+
+export default class Project {
+ constructor() {
+ 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');
+ const activeText = $this.find('.dropdown-menu-inner-title').text();
+
+ e.preventDefault();
+
+ $('.is-active', $cloneOptions).not($this).removeClass('is-active');
+ $this.toggleClass('is-active');
+ $projectCloneField.val(url);
+ $cloneBtnText.text(activeText);
+
+ return $('.clone').text(url);
+ });
+ // Ref switcher
+ Project.initRefSwitcher();
+ $('.project-refs-select').on('change', function() {
+ return $(this).parents('form').submit();
+ });
+ $('.hide-no-ssh-message').on('click', function(e) {
+ Cookies.set('hide_no_ssh_message', 'false');
+ $(this).parents('.no-ssh-key-message').remove();
+ return e.preventDefault();
+ });
+ $('.hide-no-password-message').on('click', function(e) {
+ Cookies.set('hide_no_password_message', 'false');
+ $(this).parents('.no-password-message').remove();
+ return e.preventDefault();
+ });
+ Project.projectSelectDropdown();
+ }
+
+ static projectSelectDropdown() {
+ projectSelect();
+ $('.project-item-select').on('click', e => Project.changeProject($(e.currentTarget).val()));
+ }
+
+ static changeProject(url) {
+ return window.location = url;
+ }
+
+ static initRefSwitcher() {
+ var refListItem = document.createElement('li');
+ var refLink = document.createElement('a');
+
+ refLink.href = '#';
+
+ return $('.js-project-refs-dropdown').each(function() {
+ var $dropdown, selected;
+ $dropdown = $(this);
+ selected = $dropdown.data('selected');
+ return $dropdown.glDropdown({
+ data(term, callback) {
+ axios.get($dropdown.data('refsUrl'), {
+ params: {
+ ref: $dropdown.data('ref'),
+ search: term,
+ },
+ })
+ .then(({ data }) => callback(data))
+ .catch(() => flash(__('An error occurred while getting projects')));
+ },
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ filterByText: true,
+ inputFieldName: $dropdown.data('inputFieldName'),
+ fieldName: $dropdown.data('fieldName'),
+ renderRow: function(ref) {
+ var li = refListItem.cloneNode(false);
+
+ if (ref.header != null) {
+ li.className = 'dropdown-header';
+ li.textContent = ref.header;
+ } else {
+ var link = refLink.cloneNode(false);
+
+ if (ref === selected) {
+ link.className = 'is-active';
+ }
+
+ link.textContent = ref;
+ link.dataset.ref = ref;
+
+ li.appendChild(link);
+ }
+
+ return li;
+ },
+ id: function(obj, $el) {
+ return $el.attr('data-ref');
+ },
+ toggleLabel: function(obj, $el) {
+ return $el.text().trim();
+ },
+ 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 ? '?' : '&';
+ if (shouldVisit) {
+ visitUrl(`${action}${divider}${$form.serialize()}`);
+ }
+ }
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
new file mode 100644
index 00000000000..adbe744290a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -0,0 +1,12 @@
+import memberExpirationDate from '../../../member_expiration_date';
+import UsersSelect from '../../../users_select';
+import groupsSelect from '../../../groups_select';
+import Members from '../../../members';
+
+document.addEventListener('DOMContentLoaded', () => {
+ memberExpirationDate('.js-access-expiration-date-groups');
+ groupsSelect();
+ memberExpirationDate();
+ new Members(); // eslint-disable-line no-new
+ new UsersSelect(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js
new file mode 100644
index 00000000000..35564754ee0
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js
@@ -0,0 +1,3 @@
+import initRegistryImages from '~/registry/index';
+
+document.addEventListener('DOMContentLoaded', initRegistryImages);
diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js
new file mode 100644
index 00000000000..c70271b09c4
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/releases/edit/index.js
@@ -0,0 +1,4 @@
+import $ from 'jquery';
+import initForm from '~/pages/projects/init_form';
+
+document.addEventListener('DOMContentLoaded', () => initForm($('.release-form')));
diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js
new file mode 100644
index 00000000000..ba4b271f09e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/services/edit/index.js
@@ -0,0 +1,13 @@
+import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
+ const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ integrationSettingsForm.init();
+
+ if (prometheusSettingsWrapper) {
+ const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ prometheusMetrics.loadActiveMetrics();
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/settings/badges/index/index.js b/app/assets/javascripts/pages/projects/settings/badges/index/index.js
new file mode 100644
index 00000000000..30469550866
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/badges/index/index.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { PROJECT_BADGE } from '~/badges/constants';
+import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountBadgeSettings(PROJECT_BADGE);
+});
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
new file mode 100644
index 00000000000..6c2a785c0af
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -0,0 +1,25 @@
+import initSettingsPanels from '~/settings_panels';
+import SecretValues from '~/behaviors/secret_values';
+import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize expandable settings panels
+ initSettingsPanels();
+
+ const runnerToken = document.querySelector('.js-secret-runner-token');
+ if (runnerToken) {
+ const runnerTokenSecretValue = new SecretValues({
+ container: runnerToken,
+ });
+ runnerTokenSecretValue.init();
+ }
+
+ const variableListEl = document.querySelector('.js-ci-variable-list-section');
+ // eslint-disable-next-line no-new
+ new AjaxVariableList({
+ container: variableListEl,
+ saveButton: variableListEl.querySelector('.js-secret-variables-save-button'),
+ errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
+ saveEndpoint: variableListEl.dataset.saveEndpoint,
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
new file mode 100644
index 00000000000..ffc84dc106b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
@@ -0,0 +1,3 @@
+import initForm from '../form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js
new file mode 100644
index 00000000000..a5c17ab322c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/repository/form.js
@@ -0,0 +1,19 @@
+/* eslint-disable no-new */
+
+import ProtectedTagCreate from '~/protected_tags/protected_tag_create';
+import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list';
+import initSettingsPanels from '~/settings_panels';
+import initDeployKeys from '~/deploy_keys';
+import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
+import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list';
+import DueDateSelectors from '~/due_date_select';
+
+export default () => {
+ new ProtectedTagCreate();
+ new ProtectedTagEditList();
+ initDeployKeys();
+ initSettingsPanels();
+ new ProtectedBranchCreate(); // eslint-disable-line no-new
+ new ProtectedBranchEditList(); // eslint-disable-line no-new
+ new DueDateSelectors();
+};
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
new file mode 100644
index 00000000000..ffc84dc106b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -0,0 +1,3 @@
+import initForm from '../form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
new file mode 100644
index 00000000000..9b13b2a524f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -0,0 +1,111 @@
+<script>
+ import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue';
+
+ export default {
+ components: {
+ projectFeatureToggle,
+ },
+
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ options: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ value: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ disabledInput: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ featureEnabled() {
+ return this.value !== 0;
+ },
+
+ displayOptions() {
+ if (this.featureEnabled) {
+ return this.options;
+ }
+ return [
+ [0, 'Enable feature to choose access level'],
+ ];
+ },
+
+ displaySelectInput() {
+ return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2;
+ },
+ },
+
+ methods: {
+ toggleFeature(featureEnabled) {
+ if (featureEnabled === false || this.options.length < 1) {
+ this.$emit('change', 0);
+ } else {
+ const [firstOptionValue] = this.options[this.options.length - 1];
+ this.$emit('change', firstOptionValue);
+ }
+ },
+
+ selectOption(e) {
+ this.$emit('change', Number(e.target.value));
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="project-feature-controls"
+ :data-for="name"
+ >
+ <input
+ v-if="name"
+ type="hidden"
+ :name="name"
+ :value="value"
+ />
+ <project-feature-toggle
+ :value="featureEnabled"
+ @change="toggleFeature"
+ :disabled-input="disabledInput"
+ />
+ <div class="select-wrapper">
+ <select
+ class="form-control project-repo-select select-control"
+ @change="selectOption"
+ :disabled="displaySelectInput"
+ >
+ <option
+ v-for="[optionValue, optionName] in displayOptions"
+ :key="optionValue"
+ :value="optionValue"
+ :selected="optionValue === value"
+ >
+ {{ optionName }}
+ </option>
+ </select>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down"
+ >
+ </i>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
new file mode 100644
index 00000000000..25a88f846eb
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
@@ -0,0 +1,51 @@
+<script>
+ export default {
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="project-feature-row">
+ <label
+ v-if="label"
+ class="label-light"
+ >
+ {{ label }}
+ <a
+ v-if="helpPath"
+ :href="helpPath"
+ target="_blank"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-question-circle"
+ >
+ </i>
+ </a>
+ </label>
+ <span
+ v-if="helpText"
+ class="help-block"
+ >
+ {{ helpText }}
+ </span>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
new file mode 100644
index 00000000000..755a34b7348
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -0,0 +1,328 @@
+<script>
+ import projectFeatureSetting from './project_feature_setting.vue';
+ import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue';
+ import projectSettingRow from './project_setting_row.vue';
+ import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
+ import { toggleHiddenClassBySelector } from '../external';
+
+ export default {
+ components: {
+ projectFeatureSetting,
+ projectFeatureToggle,
+ projectSettingRow,
+ },
+
+ props: {
+ currentSettings: {
+ type: Object,
+ required: true,
+ },
+ canChangeVisibilityLevel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowedVisibilityOptions: {
+ type: Array,
+ required: false,
+ default: () => [0, 10, 20],
+ },
+ lfsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ registryAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ visibilityHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lfsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ registryHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ data() {
+ const defaults = {
+ visibilityOptions,
+ visibilityLevel: visibilityOptions.PUBLIC,
+ issuesAccessLevel: 20,
+ repositoryAccessLevel: 20,
+ mergeRequestsAccessLevel: 20,
+ buildsAccessLevel: 20,
+ wikiAccessLevel: 20,
+ snippetsAccessLevel: 20,
+ containerRegistryEnabled: true,
+ lfsEnabled: true,
+ requestAccessEnabled: true,
+ highlightChangesClass: false,
+ };
+
+ return { ...defaults, ...this.currentSettings };
+ },
+
+ computed: {
+ featureAccessLevelOptions() {
+ const options = [
+ [10, 'Only Project Members'],
+ ];
+ if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+ options.push([20, 'Everyone With Access']);
+ }
+ return options;
+ },
+
+ repoFeatureAccessLevelOptions() {
+ return this.featureAccessLevelOptions.filter(
+ ([value]) => value <= this.repositoryAccessLevel,
+ );
+ },
+
+ repositoryEnabled() {
+ return this.repositoryAccessLevel > 0;
+ },
+
+ visibilityLevelDescription() {
+ return visibilityLevelDescriptions[this.visibilityLevel];
+ },
+ },
+
+ watch: {
+ visibilityLevel(value, oldValue) {
+ if (value === visibilityOptions.PRIVATE) {
+ // when private, features are restricted to "only team members"
+ this.issuesAccessLevel = Math.min(10, this.issuesAccessLevel);
+ this.repositoryAccessLevel = Math.min(10, this.repositoryAccessLevel);
+ this.mergeRequestsAccessLevel = Math.min(10, this.mergeRequestsAccessLevel);
+ this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel);
+ this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel);
+ this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel);
+ this.highlightChanges();
+ } else if (oldValue === visibilityOptions.PRIVATE) {
+ // if changing away from private, make enabled features more permissive
+ if (this.issuesAccessLevel > 0) this.issuesAccessLevel = 20;
+ if (this.repositoryAccessLevel > 0) this.repositoryAccessLevel = 20;
+ if (this.mergeRequestsAccessLevel > 0) this.mergeRequestsAccessLevel = 20;
+ if (this.buildsAccessLevel > 0) this.buildsAccessLevel = 20;
+ if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20;
+ if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20;
+ this.highlightChanges();
+ }
+ },
+
+ repositoryAccessLevel(value, oldValue) {
+ if (value < oldValue) {
+ // sub-features cannot have more premissive access level
+ this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value);
+ this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value);
+
+ if (value === 0) {
+ this.containerRegistryEnabled = false;
+ this.lfsEnabled = false;
+ }
+ } else if (oldValue === 0) {
+ this.mergeRequestsAccessLevel = value;
+ this.buildsAccessLevel = value;
+ this.containerRegistryEnabled = true;
+ this.lfsEnabled = true;
+ }
+ },
+
+ issuesAccessLevel(value, oldValue) {
+ if (value === 0) toggleHiddenClassBySelector('.issues-feature', true);
+ else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false);
+ },
+
+ mergeRequestsAccessLevel(value, oldValue) {
+ if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true);
+ else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false);
+ },
+
+ buildsAccessLevel(value, oldValue) {
+ if (value === 0) toggleHiddenClassBySelector('.builds-feature', true);
+ else if (oldValue === 0) toggleHiddenClassBySelector('.builds-feature', false);
+ },
+ },
+
+ methods: {
+ highlightChanges() {
+ this.highlightChangesClass = true;
+ this.$nextTick(() => {
+ this.highlightChangesClass = false;
+ });
+ },
+
+ visibilityAllowed(option) {
+ return this.allowedVisibilityOptions.includes(option);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <div class="project-visibility-setting">
+ <project-setting-row
+ label="Project visibility"
+ :help-path="visibilityHelpPath"
+ >
+ <div class="project-feature-controls">
+ <div class="select-wrapper">
+ <select
+ name="project[visibility_level]"
+ v-model="visibilityLevel"
+ class="form-control select-control"
+ :disabled="!canChangeVisibilityLevel"
+ >
+ <option
+ :value="visibilityOptions.PRIVATE"
+ :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ >
+ Private
+ </option>
+ <option
+ :value="visibilityOptions.INTERNAL"
+ :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
+ >
+ Internal
+ </option>
+ <option
+ :value="visibilityOptions.PUBLIC"
+ :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+ >
+ Public
+ </option>
+ </select>
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-chevron-down"
+ >
+ </i>
+ </div>
+ </div>
+ <span class="help-block">{{ visibilityLevelDescription }}</span>
+ <label
+ v-if="visibilityLevel !== visibilityOptions.PRIVATE"
+ class="request-access"
+ >
+ <input
+ type="hidden"
+ name="project[request_access_enabled]"
+ :value="requestAccessEnabled"
+ />
+ <input
+ type="checkbox"
+ v-model="requestAccessEnabled"
+ />
+ Allow users to request access
+ </label>
+ </project-setting-row>
+ </div>
+ <div
+ class="project-feature-settings"
+ :class="{ 'highlight-changes': highlightChangesClass }"
+ >
+ <project-setting-row
+ label="Issues"
+ help-text="Lightweight issue tracking system for this project"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][issues_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="issuesAccessLevel"
+ />
+ </project-setting-row>
+ <project-setting-row
+ label="Repository"
+ help-text="View and edit files in this project"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][repository_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="repositoryAccessLevel"
+ />
+ </project-setting-row>
+ <div class="project-feature-setting-group">
+ <project-setting-row
+ label="Merge requests"
+ help-text="Submit changes to be merged upstream"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][merge_requests_access_level]"
+ :options="repoFeatureAccessLevelOptions"
+ v-model="mergeRequestsAccessLevel"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ <project-setting-row
+ label="Pipelines"
+ help-text="Build, test, and deploy your changes"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][builds_access_level]"
+ :options="repoFeatureAccessLevelOptions"
+ v-model="buildsAccessLevel"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ <project-setting-row
+ v-if="registryAvailable"
+ label="Container registry"
+ :help-path="registryHelpPath"
+ help-text="Every project can have its own space to store its Docker images"
+ >
+ <project-feature-toggle
+ name="project[container_registry_enabled]"
+ v-model="containerRegistryEnabled"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ <project-setting-row
+ v-if="lfsAvailable"
+ label="Git Large File Storage"
+ :help-path="lfsHelpPath"
+ help-text="Manages large files such as audio, video, and graphics files"
+ >
+ <project-feature-toggle
+ name="project[lfs_enabled]"
+ v-model="lfsEnabled"
+ :disabled-input="!repositoryEnabled"
+ />
+ </project-setting-row>
+ </div>
+ <project-setting-row
+ label="Wiki"
+ help-text="Pages for project documentation"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][wiki_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="wikiAccessLevel"
+ />
+ </project-setting-row>
+ <project-setting-row
+ label="Snippets"
+ help-text="Share code pastes with others out of Git repository"
+ >
+ <project-feature-setting
+ name="project[project_feature_attributes][snippets_access_level]"
+ :options="featureAccessLevelOptions"
+ v-model="snippetsAccessLevel"
+ />
+ </project-setting-row>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index ce47562f259..ce47562f259 100644
--- a/app/assets/javascripts/projects/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
diff --git a/app/assets/javascripts/projects/permissions/external.js b/app/assets/javascripts/pages/projects/shared/permissions/external.js
index 460af4a2111..460af4a2111 100644
--- a/app/assets/javascripts/projects/permissions/external.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/external.js
diff --git a/app/assets/javascripts/projects/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js
index dbde8dda634..dbde8dda634 100644
--- a/app/assets/javascripts/projects/permissions/index.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js
diff --git a/app/assets/javascripts/pages/projects/shared/project_avatar.js b/app/assets/javascripts/pages/projects/shared/project_avatar.js
new file mode 100644
index 00000000000..447877752fe
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/project_avatar.js
@@ -0,0 +1,15 @@
+import $ from 'jquery';
+
+export default function projectAvatar() {
+ $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() {
+ const form = $(this).closest('form');
+ return form.find('.js-project-avatar-input').click();
+ });
+
+ $('.js-project-avatar-input').bind('change', function onClickAvatarInput() {
+ const form = $(this).closest('form');
+ // eslint-disable-next-line no-useless-escape
+ const filename = $(this).val().replace(/^.*[\\\/]/, '');
+ return form.find('.js-avatar-filename').text(filename);
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/shared/project_new.js b/app/assets/javascripts/pages/projects/shared/project_new.js
new file mode 100644
index 00000000000..56d5574aa2f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/project_new.js
@@ -0,0 +1,152 @@
+/* eslint-disable func-names, no-var, no-underscore-dangle, prefer-template, prefer-arrow-callback*/
+
+import $ from 'jquery';
+import VisibilitySelect from '../../../visibility_select';
+
+function highlightChanges($elm) {
+ $elm.addClass('highlight-changes');
+ setTimeout(() => $elm.removeClass('highlight-changes'), 10);
+}
+
+export default class ProjectNew {
+ constructor() {
+ 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', () => {
+ $('.project-edit-container').hide();
+ return $('.save-project-loader').show();
+ });
+
+ this.initVisibilitySelect();
+
+ this.toggleSettings();
+ this.toggleSettingsOnclick();
+ this.toggleRepoVisibility();
+ }
+
+ initVisibilitySelect() {
+ const visibilityContainer = document.querySelector('.js-visibility-select');
+ if (!visibilityContainer) return;
+ 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;
+ }
+ });
+ }
+
+ toggleSettings() {
+ this.$selects.each(function () {
+ var $select = $(this);
+ var className = $select.data('field')
+ .replace(/_/g, '-')
+ .replace('access-level', 'feature');
+ ProjectNew._showOrHide($select, '.' + className);
+ });
+ }
+
+ toggleSettingsOnclick() {
+ this.$selects.on('change', this.toggleSettings);
+ }
+
+ static _showOrHide(checkElement, container) {
+ const $container = $(container);
+
+ if ($(checkElement).val() !== '0') {
+ return $container.show();
+ }
+ return $container.hide();
+ }
+
+ toggleRepoVisibility() {
+ 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()
+ .hide();
+
+ $repoAccessLevel
+ .off('change')
+ .on('change', function () {
+ var selectedVal = parseInt($repoAccessLevel.val(), 10);
+
+ this.$repoSelects.each(function () {
+ var $this = $(this);
+ var repoSelectVal = parseInt($this.val(), 10);
+
+ $this.find('option').enable();
+
+ if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
+ $this.val(selectedVal).trigger('change');
+ highlightChanges($this);
+ }
+
+ $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/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
new file mode 100644
index 00000000000..3b0f0f960b8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import initBlob from '~/blob_edit/blob_bundle';
+import ShortcutsNavigation from '~/shortcuts_navigation';
+import NotificationsForm from '~/notifications_form';
+import UserCallout from '~/user_callout';
+import TreeView from '~/tree';
+import BlobViewer from '~/blob/viewer/index';
+import Activities from '~/activities';
+import { ajaxGet } from '~/lib/utils/common_utils';
+import Star from '../../../star';
+import notificationsDropdown from '../../../notifications_dropdown';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Star(); // eslint-disable-line no-new
+ notificationsDropdown();
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new NotificationsForm(); // eslint-disable-line no-new
+ new UserCallout({ // eslint-disable-line no-new
+ setCalloutPerProject: true,
+ className: 'js-autodevops-banner',
+ });
+
+ // Project show page loads different overview content based on user preferences
+ const treeSlider = document.querySelector('#tree-slider');
+ if (treeSlider) {
+ new TreeView(); // eslint-disable-line no-new
+ initBlob();
+ }
+
+ if (document.querySelector('.blob-viewer')) {
+ new BlobViewer(); // eslint-disable-line no-new
+ }
+
+ if (document.querySelector('.project-show-activity')) {
+ new Activities(); // eslint-disable-line no-new
+ }
+
+ $(treeSlider).waitForImages(() => {
+ ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/snippets/edit/index.js b/app/assets/javascripts/pages/projects/snippets/edit/index.js
new file mode 100644
index 00000000000..53606acc508
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/edit/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import initSnippet from '~/snippet/snippet_bundle';
+import initForm from '~/pages/projects/init_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ initForm($('.snippet-form'));
+});
diff --git a/app/assets/javascripts/pages/projects/snippets/new/index.js b/app/assets/javascripts/pages/projects/snippets/new/index.js
new file mode 100644
index 00000000000..53606acc508
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/new/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import initSnippet from '~/snippet/snippet_bundle';
+import initForm from '~/pages/projects/init_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ initForm($('.snippet-form'));
+});
diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js
new file mode 100644
index 00000000000..a134599cb04
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/snippets/show/index.js
@@ -0,0 +1,11 @@
+import initNotes from '~/init_notes';
+import ZenMode from '~/zen_mode';
+import LineHighlighter from '../../../../line_highlighter';
+import BlobViewer from '../../../../blob/viewer';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new LineHighlighter(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ initNotes();
+ new ZenMode(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js
new file mode 100644
index 00000000000..8d0edf7e06c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/new/index.js
@@ -0,0 +1,10 @@
+import $ from 'jquery';
+import RefSelectDropdown from '../../../../ref_select_dropdown';
+import ZenMode from '../../../../zen_mode';
+import GLForm from '../../../../gl_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.tag-form'), true); // eslint-disable-line no-new
+ new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
new file mode 100644
index 00000000000..7ad082a5e61
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -0,0 +1,39 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import initBlob from '~/blob_edit/blob_bundle';
+import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import TreeView from '../../../../tree';
+import ShortcutsNavigation from '../../../../shortcuts_navigation';
+import BlobViewer from '../../../../blob/viewer';
+import NewCommitForm from '../../../../new_commit_form';
+import { ajaxGet } from '../../../../lib/utils/common_utils';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ShortcutsNavigation(); // eslint-disable-line no-new
+ new TreeView(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
+ $('#tree-slider').waitForImages(() =>
+ ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath));
+
+ initBlob();
+ const commitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
+ const statusLink = document.querySelector('.commit-actions .ci-status-link');
+ if (statusLink != null) {
+ statusLink.remove();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: commitPipelineStatusEl,
+ components: {
+ commitPipelineStatus,
+ },
+ render(createElement) {
+ return createElement('commit-pipeline-status', {
+ props: {
+ endpoint: commitPipelineStatusEl.dataset.endpoint,
+ },
+ });
+ },
+ });
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js
new file mode 100644
index 00000000000..ec01c66ffda
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/index.js
@@ -0,0 +1,12 @@
+import $ from 'jquery';
+import Wikis from './wikis';
+import ShortcutsWiki from '../../../shortcuts_wiki';
+import ZenMode from '../../../zen_mode';
+import GLForm from '../../../gl_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new Wikis(); // eslint-disable-line no-new
+ new ShortcutsWiki(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.wiki-form'), true); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
new file mode 100644
index 00000000000..34a12ef76a1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -0,0 +1,60 @@
+import bp from '../../../breakpoints';
+import { slugify } from '../../../lib/utils/text_utility';
+
+export default class Wikis {
+ constructor() {
+ this.sidebarEl = document.querySelector('.js-wiki-sidebar');
+ this.sidebarExpanded = false;
+
+ 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));
+ }
+
+ window.addEventListener('resize', () => this.renderSidebar());
+ this.renderSidebar();
+ }
+
+ handleNewWikiSubmit(e) {
+ if (!this.newWikiForm) return;
+
+ const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
+ const slug = slugify(slugInput.value);
+
+ if (slug.length > 0) {
+ const wikisPath = slugInput.getAttribute('data-wikis-path');
+ window.location.href = `${wikisPath}/${slug}`;
+ e.preventDefault();
+ }
+ }
+
+ handleToggleSidebar(e) {
+ e.preventDefault();
+ this.sidebarExpanded = !this.sidebarExpanded;
+ this.renderSidebar();
+ }
+
+ 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');
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js
new file mode 100644
index 00000000000..7fdf4ee0bf3
--- /dev/null
+++ b/app/assets/javascripts/pages/search/init_filtered_search.js
@@ -0,0 +1,23 @@
+import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
+
+export default ({
+ page,
+ filteredSearchTokenKeys,
+ isGroup,
+ isGroupAncestor,
+ isGroupDecendent,
+ stateFiltersSelector,
+}) => {
+ const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search');
+ if (filteredSearchEnabled) {
+ const filteredSearchManager = new FilteredSearchManager({
+ page,
+ isGroup,
+ isGroupAncestor,
+ isGroupDecendent,
+ filteredSearchTokenKeys,
+ stateFiltersSelector,
+ });
+ filteredSearchManager.setup();
+ }
+};
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
new file mode 100644
index 00000000000..85aaaa2c9da
--- /dev/null
+++ b/app/assets/javascripts/pages/search/show/index.js
@@ -0,0 +1,3 @@
+import Search from './search';
+
+document.addEventListener('DOMContentLoaded', () => new Search());
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
new file mode 100644
index 00000000000..2e1fe78b3fa
--- /dev/null
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -0,0 +1,116 @@
+import $ from 'jquery';
+import Flash from '~/flash';
+import Api from '~/api';
+
+export default class Search {
+ constructor() {
+ const $groupDropdown = $('.js-search-group-dropdown');
+ const $projectDropdown = $('.js-search-project-dropdown');
+
+ this.searchInput = '.js-search-input';
+ this.searchClear = '.js-search-clear';
+
+ this.groupId = $groupDropdown.data('groupId');
+ this.eventListeners();
+
+ $groupDropdown.glDropdown({
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ fieldName: 'group_id',
+ search: {
+ fields: ['full_name'],
+ },
+ data(term, callback) {
+ return Api.groups(term, {}, (data) => {
+ data.unshift({
+ full_name: 'Any',
+ });
+ data.splice(1, 0, 'divider');
+ return callback(data);
+ });
+ },
+ id(obj) {
+ return obj.id;
+ },
+ text(obj) {
+ return obj.full_name;
+ },
+ toggleLabel(obj) {
+ return `${($groupDropdown.data('defaultLabel'))} ${obj.full_name}`;
+ },
+ clicked: () => Search.submitSearch(),
+ });
+
+ $projectDropdown.glDropdown({
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ fieldName: 'project_id',
+ search: {
+ fields: ['name'],
+ },
+ 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(obj) {
+ return obj.id;
+ },
+ text(obj) {
+ return obj.name_with_namespace;
+ },
+ toggleLabel(obj) {
+ return `${($projectDropdown.data('defaultLabel'))} ${obj.name_with_namespace}`;
+ },
+ clicked: () => Search.submitSearch(),
+ });
+ }
+
+ eventListeners() {
+ $(document)
+ .off('keyup', this.searchInput)
+ .on('keyup', this.searchInput, this.searchKeyUp);
+ $(document)
+ .off('click', this.searchClear)
+ .on('click', this.searchClear, this.clearSearchField.bind(this));
+ }
+
+ static submitSearch() {
+ return $('.js-search-form').submit();
+ }
+
+ searchKeyUp() {
+ const $input = $(this);
+ if ($input.val() === '') {
+ $('.js-search-clear').addClass('hidden');
+ } else {
+ $('.js-search-clear').removeClass('hidden');
+ }
+ }
+
+ clearSearchField() {
+ return $(this.searchInput).val('').trigger('keyup').focus();
+ }
+
+ getProjectsData(term) {
+ return new Promise((resolve) => {
+ if (this.groupId) {
+ Api.groupProjects(this.groupId, term, resolve);
+ } else {
+ Api.projects(term, {
+ order_by: 'id',
+ }, resolve);
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/sessions/index.js b/app/assets/javascripts/pages/sessions/index.js
new file mode 100644
index 00000000000..c2c069d1ca8
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/index.js
@@ -0,0 +1,3 @@
+import initU2F from '../../shared/sessions/u2f';
+
+document.addEventListener('DOMContentLoaded', initU2F);
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
new file mode 100644
index 00000000000..80a7114f94d
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -0,0 +1,12 @@
+import $ from 'jquery';
+import UsernameValidator from './username_validator';
+import SigninTabsMemoizer from './signin_tabs_memoizer';
+import OAuthRememberMe from './oauth_remember_me';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsernameValidator(); // eslint-disable-line no-new
+ new SigninTabsMemoizer(); // eslint-disable-line no-new
+ new OAuthRememberMe({ // eslint-disable-line no-new
+ container: $('.omniauth-container'),
+ }).bindEvents();
+});
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
new file mode 100644
index 00000000000..53030045292
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -0,0 +1,34 @@
+import $ from 'jquery';
+
+/**
+ * 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/pages/sessions/new/signin_tabs_memoizer.js b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
new file mode 100644
index 00000000000..08f0afdcce3
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
@@ -0,0 +1,55 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+
+/**
+ * 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();
+ // sets selected tab if given as hash tag
+ if (window.location.hash) {
+ this.saveData(window.location.hash);
+ }
+
+ this.bootstrap();
+ }
+
+ 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);
+ }
+ });
+ }
+
+ this.showTab();
+ }
+
+ showTab() {
+ const anchorName = this.readData();
+ if (anchorName) {
+ const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`);
+ if (tab) {
+ tab.click();
+ }
+ }
+ }
+
+ 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/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
new file mode 100644
index 00000000000..825de01b5a2
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -0,0 +1,134 @@
+/* eslint-disable comma-dangle, consistent-return, class-methods-use-this, arrow-parens, no-param-reassign, max-len */
+
+import $ from 'jquery';
+import _ from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+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) {
+ return debounceTimeout(username);
+ }
+
+ this.renderState();
+ });
+
+ // Override generic field validation
+ this.inputElement.on('invalid', this.interceptInvalid.bind(this));
+ }
+
+ renderState() {
+ // Clear all state
+ this.clearFieldValidationState();
+
+ if (this.state.valid && this.state.available) {
+ return this.setSuccessState();
+ }
+
+ if (this.state.empty) {
+ return this.clearFieldValidationState();
+ }
+
+ if (this.state.pending) {
+ return this.setPendingState();
+ }
+
+ if (!this.state.available) {
+ return this.setUnavailableState();
+ }
+
+ if (!this.state.valid) {
+ return this.setInvalidState();
+ }
+ }
+
+ interceptInvalid(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ validateUsername(username) {
+ if (this.state.valid) {
+ this.state.pending = true;
+ this.state.available = false;
+ this.renderState();
+ axios.get(`${gon.relative_url_root}/users/${username}/exists`)
+ .then(({ data }) => this.setAvailabilityState(data.exists))
+ .catch(() => flash(__('An error occurred while validating username')));
+ }
+ }
+
+ setAvailabilityState(usernameTaken) {
+ if (usernameTaken) {
+ this.state.valid = false;
+ this.state.available = false;
+ } else {
+ this.state.available = true;
+ }
+ this.state.pending = false;
+ this.renderState();
+ }
+
+ 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();
+ }
+ }
+
+ setInvalidState() {
+ const $inputErrorMessage = $(invalidMessageSelector);
+ this.inputElement.addClass(invalidInputClass).removeClass(successInputClass);
+ $inputErrorMessage.show();
+ }
+}
diff --git a/app/assets/javascripts/pages/shared/mount_badge_settings.js b/app/assets/javascripts/pages/shared/mount_badge_settings.js
new file mode 100644
index 00000000000..1397c0834ff
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/mount_badge_settings.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import BadgeSettings from '~/badges/components/badge_settings.vue';
+import store from '~/badges/store';
+
+export default kind => {
+ const badgeSettingsElement = document.getElementById('badge-settings');
+
+ store.dispatch('loadBadges', {
+ kind,
+ apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl,
+ docsUrl: badgeSettingsElement.dataset.docsUrl,
+ });
+
+ return new Vue({
+ el: badgeSettingsElement,
+ store,
+ components: {
+ BadgeSettings,
+ },
+ render(createElement) {
+ return createElement(BadgeSettings);
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/snippets/edit/index.js b/app/assets/javascripts/pages/snippets/edit/index.js
new file mode 100644
index 00000000000..d86e1632ae5
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/edit/index.js
@@ -0,0 +1,7 @@
+import initSnippet from '~/snippet/snippet_bundle';
+import form from '../form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ form();
+});
diff --git a/app/assets/javascripts/pages/snippets/form.js b/app/assets/javascripts/pages/snippets/form.js
new file mode 100644
index 00000000000..72d05da1069
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/form.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import ZenMode from '~/zen_mode';
+
+export default () => {
+ new GLForm($('.snippet-form'), false); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+};
diff --git a/app/assets/javascripts/pages/snippets/new/index.js b/app/assets/javascripts/pages/snippets/new/index.js
new file mode 100644
index 00000000000..d86e1632ae5
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/new/index.js
@@ -0,0 +1,7 @@
+import initSnippet from '~/snippet/snippet_bundle';
+import form from '../form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initSnippet();
+ form();
+});
diff --git a/app/assets/javascripts/pages/snippets/show/index.js b/app/assets/javascripts/pages/snippets/show/index.js
new file mode 100644
index 00000000000..f548b9fad65
--- /dev/null
+++ b/app/assets/javascripts/pages/snippets/show/index.js
@@ -0,0 +1,11 @@
+import LineHighlighter from '../../../line_highlighter';
+import BlobViewer from '../../../blob/viewer';
+import ZenMode from '../../../zen_mode';
+import initNotes from '../../../init_notes';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new LineHighlighter(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ initNotes();
+ new ZenMode(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
new file mode 100644
index 00000000000..8ce938c958b
--- /dev/null
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -0,0 +1,243 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import { scaleLinear, scaleThreshold } from 'd3-scale';
+import { select } from 'd3-selection';
+import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+const d3 = { select, scaleLinear, scaleThreshold };
+
+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 = 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.scaleLinear().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 = 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 = 6;
+ }
+
+ 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.scaleThreshold().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('-');
+
+ $('.user-calendar-activities').html(LOADING_HTML);
+
+ axios.get(this.calendarActivitiesPath, {
+ params: {
+ date,
+ },
+ responseType: 'text',
+ })
+ .then(({ data }) => $('.user-calendar-activities').html(data))
+ .catch(() => flash(__('An error occurred while retrieving calendar activity')));
+ } else {
+ this.currentSelectedDate = '';
+ $('.user-calendar-activities').html('');
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
new file mode 100644
index 00000000000..6b1626b0161
--- /dev/null
+++ b/app/assets/javascripts/pages/users/index.js
@@ -0,0 +1,28 @@
+import $ from 'jquery';
+import UserCallout from '~/user_callout';
+import Cookies from 'js-cookie';
+import UserTabs from './user_tabs';
+
+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();
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const page = $('body').attr('data-page');
+ const action = page.split(':')[1];
+ initUserProfile(action);
+ new UserCallout(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js
deleted file mode 100644
index f18f98b4e9a..00000000000
--- a/app/assets/javascripts/pages/users/show/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import UserCallout from '~/user_callout';
-
-export default () => new UserCallout();
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
new file mode 100644
index 00000000000..124bc2ba710
--- /dev/null
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -0,0 +1,200 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import Activities from '~/activities';
+import { localTimeAgo } from '~/lib/utils/datetime_utility';
+import { __ } from '~/locale';
+import flash from '~/flash';
+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) {
+ this.toggleLoading(true);
+
+ return axios.get(endpoint)
+ .then(({ data }) => {
+ const tabSelector = `div#${action}`;
+ this.$parentEl.find(tabSelector).html(data.html);
+ this.loaded[action] = true;
+ localTimeAgo($('.js-timeago', tabSelector));
+
+ this.toggleLoading(false);
+ })
+ .catch(() => {
+ this.toggleLoading(false);
+ });
+ }
+
+ 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)}`;
+ }
+
+ axios.get(calendarPath)
+ .then(({ data }) => {
+ $calendarWrap.html(CALENDAR_TEMPLATE);
+ $calendarWrap.find('.calendar-hint').append(`(Timezone: ${utcFormatted})`);
+
+ // eslint-disable-next-line no-new
+ new ActivityCalendar('.js-contrib-calendar', data, calendarActivitiesPath, utcOffset);
+ })
+ .catch(() => flash(__('There was an error loading users activity calendar.')));
+
+ // eslint-disable-next-line no-new
+ new 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/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index c8a2f778ee8..00f32d9de78 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -5,6 +5,7 @@
import page from './page/index.vue';
export default {
+ components: { page },
props: {
pdf: {
type: [String, Uint8Array],
@@ -17,8 +18,6 @@
pages: [],
};
},
- components: { page },
- watch: { pdf: 'load' },
computed: {
document() {
return typeof this.pdf === 'string' ? this.pdf : { data: this.pdf };
@@ -27,6 +26,11 @@
return this.pdf && this.pdf.length > 0;
},
},
+ watch: { pdf: 'load' },
+ mounted() {
+ pdfjsLib.PDFJS.workerSrc = workerSrc;
+ if (this.hasPDF) this.load();
+ },
methods: {
load() {
this.pages = [];
@@ -47,20 +51,20 @@
return Promise.all(pagePromises);
},
},
- mounted() {
- pdfjsLib.PDFJS.workerSrc = workerSrc;
- if (this.hasPDF) this.load();
- },
};
</script>
<template>
- <div class="pdf-viewer" v-if="hasPDF">
- <page v-for="(page, index) in pages"
+ <div
+ class="pdf-viewer"
+ v-if="hasPDF">
+ <page
+ v-for="(page, index) in pages"
:key="index"
:v-if="!loading"
:page="page"
- :number="index + 1" />
+ :number="index + 1"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue
index be38f7cc129..fcba819beba 100644
--- a/app/assets/javascripts/pdf/page/index.vue
+++ b/app/assets/javascripts/pdf/page/index.vue
@@ -45,24 +45,26 @@
<canvas
class="pdf-page"
ref="canvas"
- :data-page="number" />
+ :data-page="number"
+ >
+ </canvas>
</template>
<style>
-.pdf-page {
- margin: 8px auto 0 auto;
- border-top: 1px #ddd solid;
- border-bottom: 1px #ddd solid;
- width: 100%;
-}
+ .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:first-child {
+ margin-top: 0px;
+ border-top: 0px;
+ }
-.pdf-page:last-child {
- margin-bottom: 0px;
- border-bottom: 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
deleted file mode 100644
index 0562a681c4b..00000000000
--- a/app/assets/javascripts/performance_bar.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import 'vendor/peek';
-import 'vendor/peek.performance_bar';
-import { getParameterValues } from './lib/utils/url_utility';
-
-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 = 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/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
new file mode 100644
index 00000000000..db8a0055acd
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -0,0 +1,93 @@
+<script>
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ metric: {
+ type: String,
+ required: true,
+ },
+ header: {
+ type: String,
+ required: true,
+ },
+ details: {
+ type: String,
+ required: true,
+ },
+ keys: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ metricDetails() {
+ return this.currentRequest.details[this.metric];
+ },
+ detailsList() {
+ return this.metricDetails[this.details];
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`peek-view-${metric}`"
+ class="view"
+ v-if="currentRequest.details"
+ >
+ <button
+ :data-target="`#modal-peek-${metric}-details`"
+ class="btn-blank btn-link bold"
+ type="button"
+ data-toggle="modal"
+ >
+ {{ metricDetails.duration }}
+ /
+ {{ metricDetails.calls }}
+ </button>
+ <gl-modal
+ :id="`modal-peek-${metric}-details`"
+ :header-title-text="header"
+ class="performance-bar-modal"
+ >
+ <table
+ class="table"
+ >
+ <template v-if="detailsList.length">
+ <tr
+ v-for="(item, index) in detailsList"
+ :key="index"
+ >
+ <td><strong>{{ item.duration }}ms</strong></td>
+ <td
+ v-for="key in keys"
+ :key="key"
+ class="break-word"
+ >
+ {{ item[key] }}
+ </td>
+ </tr>
+ </template>
+ <template v-else>
+ <tr>
+ <td>
+ No {{ header.toLowerCase() }} for this request.
+ </td>
+ </tr>
+ </template>
+ </table>
+
+ <div slot="footer">
+ </div>
+ </gl-modal>
+ {{ metric }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
new file mode 100644
index 00000000000..2fd1715ee79
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -0,0 +1,191 @@
+<script>
+import $ from 'jquery';
+
+import PerformanceBarService from '../services/performance_bar_service';
+import detailedMetric from './detailed_metric.vue';
+import requestSelector from './request_selector.vue';
+import simpleMetric from './simple_metric.vue';
+import upstreamPerformanceBar from './upstream_performance_bar.vue';
+
+import Flash from '../../flash';
+
+export default {
+ components: {
+ detailedMetric,
+ requestSelector,
+ simpleMetric,
+ upstreamPerformanceBar,
+ },
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ },
+ env: {
+ type: String,
+ required: true,
+ },
+ requestId: {
+ type: String,
+ required: true,
+ },
+ peekUrl: {
+ type: String,
+ required: true,
+ },
+ profileUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ detailedMetrics: [
+ { metric: 'pg', header: 'SQL queries', details: 'queries', keys: ['sql'] },
+ {
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ },
+ ],
+ simpleMetrics: ['redis', 'sidekiq'],
+ data() {
+ return { currentRequestId: '' };
+ },
+ computed: {
+ requests() {
+ return this.store.requestsWithDetails();
+ },
+ currentRequest: {
+ get() {
+ return this.store.findRequest(this.currentRequestId);
+ },
+ set(requestId) {
+ this.currentRequestId = requestId;
+ },
+ },
+ initialRequest() {
+ return this.currentRequestId === this.requestId;
+ },
+ lineProfileModal() {
+ return $('#modal-peek-line-profile');
+ },
+ },
+ mounted() {
+ this.interceptor = PerformanceBarService.registerInterceptor(
+ this.peekUrl,
+ this.loadRequestDetails,
+ );
+
+ this.loadRequestDetails(this.requestId, window.location.href);
+ this.currentRequest = this.requestId;
+
+ if (this.lineProfileModal.length) {
+ this.lineProfileModal.modal('toggle');
+ }
+ },
+ beforeDestroy() {
+ PerformanceBarService.removeInterceptor(this.interceptor);
+ },
+ methods: {
+ loadRequestDetails(requestId, requestUrl) {
+ if (!this.store.canTrackRequest(requestUrl)) {
+ return;
+ }
+
+ this.store.addRequest(requestId, requestUrl);
+
+ PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
+ .then(res => {
+ this.store.addRequestDetails(requestId, res.data.data);
+ })
+ .catch(() =>
+ Flash(`Error getting performance bar results for ${requestId}`),
+ );
+ },
+ changeCurrentRequest(newRequestId) {
+ this.currentRequest = newRequestId;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ id="js-peek"
+ :class="env"
+ >
+ <div
+ v-if="currentRequest"
+ class="container-fluid container-limited"
+ >
+ <div
+ id="peek-view-host"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="current-host"
+ >
+ {{ currentRequest.details.host.hostname }}
+ </span>
+ </div>
+ <upstream-performance-bar
+ v-if="initialRequest && currentRequest.details"
+ />
+ <detailed-metric
+ v-for="metric in $options.detailedMetrics"
+ :key="metric.metric"
+ :current-request="currentRequest"
+ :metric="metric.metric"
+ :header="metric.header"
+ :details="metric.details"
+ :keys="metric.keys"
+ />
+ <div
+ v-if="initialRequest"
+ id="peek-view-rblineprof"
+ class="view"
+ >
+ <button
+ v-if="lineProfileModal.length"
+ class="btn-link btn-blank"
+ data-toggle="modal"
+ data-target="#modal-peek-line-profile"
+ >
+ profile
+ </button>
+ <a
+ v-else
+ :href="profileUrl"
+ >
+ profile
+ </a>
+ </div>
+ <simple-metric
+ v-for="metric in $options.simpleMetrics"
+ :current-request="currentRequest"
+ :key="metric"
+ :metric="metric"
+ />
+ <div
+ id="peek-view-gc"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="bold"
+ >
+ <span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span>ms
+ /
+ <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span>
+ gc
+ </span>
+ </div>
+ <request-selector
+ v-if="currentRequest"
+ :current-request="currentRequest"
+ :requests="requests"
+ @change-current-request="changeCurrentRequest"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
new file mode 100644
index 00000000000..3ed07a4a47d
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -0,0 +1,52 @@
+<script>
+export default {
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ requests: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentRequestId: this.currentRequest.id,
+ };
+ },
+ watch: {
+ currentRequestId(newRequestId) {
+ this.$emit('change-current-request', newRequestId);
+ },
+ },
+ methods: {
+ truncatedUrl(requestUrl) {
+ const components = requestUrl.replace(/\/$/, '').split('/');
+ let truncated = components[components.length - 1];
+
+ if (truncated.match(/^\d+$/)) {
+ truncated = `${components[components.length - 2]}/${truncated}`;
+ }
+
+ return truncated;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ id="peek-request-selector"
+ class="pull-right"
+ >
+ <select v-model="currentRequestId">
+ <option
+ v-for="request in requests"
+ :key="request.id"
+ :value="request.id"
+ >
+ {{ truncatedUrl(request.url) }}
+ </option>
+ </select>
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue
new file mode 100644
index 00000000000..b654bc66249
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/simple_metric.vue
@@ -0,0 +1,30 @@
+<script>
+export default {
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ metric: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`peek-view-${metric}`"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="bold"
+ >
+ {{ currentRequest.details[metric].duration }}
+ /
+ {{ currentRequest.details[metric].calls }}
+ </span>
+ {{ metric }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
new file mode 100644
index 00000000000..2b5915f381f
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
@@ -0,0 +1,20 @@
+<script>
+export default {
+ mounted() {
+ const upstreamPerformanceBar = document
+ .getElementById('peek-view-performance-bar')
+ .cloneNode(true);
+
+ upstreamPerformanceBar.classList.remove('hidden');
+
+ this.$refs.wrapper.appendChild(upstreamPerformanceBar);
+ },
+};
+</script>
+<template>
+ <div
+ id="peek-view-performance-bar-vue"
+ class="view"
+ ref="wrapper"
+ ></div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
new file mode 100644
index 00000000000..a0ddf36a672
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -0,0 +1,37 @@
+import 'vendor/peek.performance_bar';
+
+import Vue from 'vue';
+import performanceBarApp from './components/performance_bar_app.vue';
+import PerformanceBarStore from './stores/performance_bar_store';
+
+export default ({ container }) =>
+ new Vue({
+ el: container,
+ components: {
+ performanceBarApp,
+ },
+ data() {
+ const performanceBarData = document.querySelector(this.$options.el)
+ .dataset;
+ const store = new PerformanceBarStore();
+
+ return {
+ store,
+ env: performanceBarData.env,
+ requestId: performanceBarData.requestId,
+ peekUrl: performanceBarData.peekUrl,
+ profileUrl: performanceBarData.profileUrl,
+ };
+ },
+ render(createElement) {
+ return createElement('performance-bar-app', {
+ props: {
+ store: this.store,
+ env: this.env,
+ requestId: this.requestId,
+ peekUrl: this.peekUrl,
+ profileUrl: this.profileUrl,
+ },
+ });
+ },
+ });
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
new file mode 100644
index 00000000000..3ebfaa87a4e
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import _ from 'underscore';
+import axios from '../../lib/utils/axios_utils';
+
+let vueResourceInterceptor;
+
+export default class PerformanceBarService {
+ static fetchRequestDetails(peekUrl, requestId) {
+ return axios.get(peekUrl, { params: { request_id: requestId } });
+ }
+
+ static registerInterceptor(peekUrl, callback) {
+ vueResourceInterceptor = (request, next) => {
+ next(response => {
+ const requestId = response.headers['x-request-id'];
+ const requestUrl = response.url;
+
+ if (requestUrl !== peekUrl && requestId) {
+ callback(requestId, requestUrl);
+ }
+ });
+ };
+
+ Vue.http.interceptors.push(vueResourceInterceptor);
+
+ return axios.interceptors.response.use(response => {
+ const requestId = response.headers['x-request-id'];
+ const requestUrl = response.config.url;
+
+ if (requestUrl !== peekUrl && requestId) {
+ callback(requestId, requestUrl);
+ }
+
+ return response;
+ });
+ }
+
+ static removeInterceptor(interceptor) {
+ axios.interceptors.response.eject(interceptor);
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors,
+ vueResourceInterceptor,
+ );
+ }
+}
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
new file mode 100644
index 00000000000..c6b2f55243c
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -0,0 +1,39 @@
+export default class PerformanceBarStore {
+ constructor() {
+ this.requests = [];
+ }
+
+ addRequest(requestId, requestUrl, requestDetails) {
+ if (!this.findRequest(requestId)) {
+ this.requests.push({
+ id: requestId,
+ url: requestUrl,
+ details: requestDetails,
+ });
+ }
+
+ return this.requests;
+ }
+
+ findRequest(requestId) {
+ return this.requests.find(request => request.id === requestId);
+ }
+
+ addRequestDetails(requestId, requestDetails) {
+ const request = this.findRequest(requestId);
+
+ request.details = requestDetails;
+
+ return request;
+ }
+
+ requestsWithDetails() {
+ return this.requests.filter(request => request.details);
+ }
+
+ canTrackRequest(requestUrl) {
+ return (
+ this.requests.filter(request => request.url === requestUrl).length < 2
+ );
+ }
+}
diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue
deleted file mode 100644
index b5d85299cf8..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<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
deleted file mode 100644
index 6e0bc2d697a..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<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
deleted file mode 100644
index 0c3926d76b5..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js
+++ /dev/null
@@ -1,52 +0,0 @@
-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
deleted file mode 100644
index 95ed9c7dc21..00000000000
--- a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js
+++ /dev/null
@@ -1,66 +0,0 @@
-/* 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/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
deleted file mode 100644
index f1cf6e92ef5..00000000000
--- a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import Vue from 'vue';
-import Translate from '../vue_shared/translate';
-import GlFieldErrors from '../gl_field_errors';
-import intervalPatternInput from './components/interval_pattern_input.vue';
-import TimezoneDropdown from './components/timezone_dropdown';
-import TargetBranchDropdown from './components/target_branch_dropdown';
-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 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
deleted file mode 100644
index a6c945e22b0..00000000000
--- a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index 9e0e5cacb11..00000000000
--- a/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { convertPermissionToBoolean } from '../lib/utils/common_utils';
-
-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 = 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/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue
index 16cc0761fc1..0cdffbde05b 100644
--- a/app/assets/javascripts/pipelines/components/async_button.vue
+++ b/app/assets/javascripts/pipelines/components/async_button.vue
@@ -1,67 +1,79 @@
<script>
-/* eslint-disable no-new, no-alert */
+ /* eslint-disable no-alert */
-import eventHub from '../event_hub';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
-export default {
- props: {
- endpoint: {
- type: String,
- required: true,
+ export default {
+ directives: {
+ tooltip,
},
- title: {
- type: String,
- required: true,
+ components: {
+ loadingIcon,
+ icon,
},
- icon: {
- type: String,
- required: true,
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ cssClass: {
+ type: String,
+ required: true,
+ },
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
},
- cssClass: {
- type: String,
- required: true,
+ data() {
+ return {
+ isLoading: false,
+ };
},
- confirmActionMessage: {
- type: String,
- required: false,
+ computed: {
+ buttonClass() {
+ return `btn ${this.cssClass}`;
+ },
},
- },
- directives: {
- tooltip,
- },
- components: {
- loadingIcon,
- },
- data() {
- return {
- isLoading: false,
- };
- },
- computed: {
- iconClass() {
- return `fa fa-${this.icon}`;
+ created() {
+ // We're using eventHub to listen to the modal here instead of
+ // using props because it would would make the parent components
+ // much more complex to keep track of the loading state of each button
+ eventHub.$on('postAction', this.setLoading);
},
- buttonClass() {
- return `btn ${this.cssClass}`;
+ beforeDestroy() {
+ eventHub.$off('postAction', this.setLoading);
},
- },
- methods: {
- onClick() {
- if (this.confirmActionMessage && confirm(this.confirmActionMessage)) {
- this.makeRequest();
- } else if (!this.confirmActionMessage) {
- this.makeRequest();
- }
+ methods: {
+ onClick() {
+ eventHub.$emit('openConfirmationModal', {
+ pipelineId: this.pipelineId,
+ endpoint: this.endpoint,
+ type: this.type,
+ });
+ },
+ setLoading(endpoint) {
+ if (endpoint === this.endpoint) {
+ this.isLoading = true;
+ }
+ },
},
- makeRequest() {
- this.isLoading = true;
-
- eventHub.$emit('postAction', this.endpoint);
- },
- },
-};
+ };
</script>
<template>
@@ -75,10 +87,9 @@ export default {
data-container="body"
data-placement="top"
:disabled="isLoading">
- <i
- :class="iconClass"
- aria-hidden="true">
- </i>
+ <icon
+ :name="icon"
+ />
<loading-icon v-if="isLoading" />
</button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/blank_state.vue
new file mode 100644
index 00000000000..8d3d6223d7b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/blank_state.vue
@@ -0,0 +1,32 @@
+<script>
+ export default {
+ name: 'PipelinesSvgState',
+ props: {
+ svgPath: {
+ type: String,
+ required: true,
+ },
+
+ message: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="row empty-state">
+ <div class="col-xs-12">
+ <div class="svg-content">
+ <img :src="svgPath" />
+ </div>
+ </div>
+
+ <div class="col-xs-12 text-center">
+ <div class="text-content">
+ <h4>{{ message }}</h4>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue
index 78322f30685..10ac8c08bed 100644
--- a/app/assets/javascripts/pipelines/components/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/empty_state.vue
@@ -1,5 +1,6 @@
<script>
export default {
+ name: 'PipelinesEmptyState',
props: {
helpPagePath: {
type: String,
@@ -9,6 +10,10 @@
type: String,
required: true,
},
+ canSetCi: {
+ type: Boolean,
+ required: true,
+ },
},
};
</script>
@@ -22,20 +27,36 @@
<div class="col-xs-12">
<div class="text-content">
- <h4 class="text-center">
- {{ s__("Pipelines|Build with confidence") }}
- </h4>
- <p>
- {{ s__("Pipelines|Continous Integration can help catch bugs by running your tests automatically, while Continuous Deployment can help you deliver code to your product environment.") }}
- </p>
- <div class="text-center">
- <a
- :href="helpPagePath"
- class="btn btn-info"
+
+ <template v-if="canSetCi">
+ <h4 class="text-center">
+ {{ s__('Pipelines|Build with confidence') }}
+ </h4>
+
+ <p>
+ {{ s__(`Pipelines|Continous Integration can help
+ catch bugs by running your tests automatically,
+ while Continuous Deployment can help you deliver
+ code to your product environment.`) }}
+ </p>
+
+ <div class="text-center">
+ <a
+ :href="helpPagePath"
+ class="btn btn-primary js-get-started-pipelines"
>
- {{ s__("Pipelines|Get started with Pipelines") }}
- </a>
- </div>
+ {{ s__('Pipelines|Get started with Pipelines') }}
+ </a>
+ </div>
+ </template>
+
+ <p
+ v-else
+ class="text-center"
+ >
+ {{ s__('Pipelines|This project is not currently set up to run pipelines.') }}
+ </p>
+
</div>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/error_state.vue b/app/assets/javascripts/pipelines/components/error_state.vue
deleted file mode 100644
index 012853b201d..00000000000
--- a/app/assets/javascripts/pipelines/components/error_state.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-export default {
- props: {
- errorStateSvgPath: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="row empty-state js-pipelines-error-state">
- <div class="col-xs-12">
- <div class="svg-content">
- <img :src="errorStateSvgPath"/>
- </div>
- </div>
-
- <div class="col-xs-12 text-center">
- <div class="text-content">
- <h4>The API failed to fetch the pipelines.</h4>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 19d8e1f49cf..e99d949801f 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -1,59 +1,72 @@
<script>
- import tooltip from '../../../vue_shared/directives/tooltip';
- import icon from '../../../vue_shared/components/icon.vue';
- import { dasherize } from '../../../lib/utils/text_utility';
- /**
- * 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,
- },
+import $ from 'jquery';
+import tooltip from '../../../vue_shared/directives/tooltip';
+import Icon from '../../../vue_shared/components/icon.vue';
+import { dasherize } from '../../../lib/utils/text_utility';
+import eventHub from '../../event_hub';
+/**
+ * Renders either a cancel, retry or play icon pointing to the given path.
+ */
+export default {
+ components: {
+ Icon,
+ },
- link: {
- type: String,
- required: true,
- },
+ directives: {
+ tooltip,
+ },
- actionMethod: {
- type: String,
- required: true,
- },
+ props: {
+ tooltipText: {
+ type: String,
+ required: true,
+ },
- actionIcon: {
- type: String,
- required: true,
- },
+ link: {
+ type: String,
+ required: true,
},
- components: {
- icon,
+ actionIcon: {
+ type: String,
+ required: true,
},
- directives: {
- tooltip,
+ buttonDisabled: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ cssClass() {
+ const actionIconDash = dasherize(this.actionIcon);
+ return `${actionIconDash} js-icon-${actionIconDash}`;
+ },
+ isDisabled() {
+ return this.buttonDisabled === this.link;
},
+ },
- computed: {
- cssClass() {
- const actionIconDash = dasherize(this.actionIcon);
- return `${actionIconDash} js-icon-${actionIconDash}`;
- },
+ methods: {
+ onClickAction() {
+ $(this.$el).tooltip('hide');
+ eventHub.$emit('graphAction', this.link);
},
- };
+ },
+};
</script>
<template>
- <a
+ <button
+ type="button"
+ @click="onClickAction"
v-tooltip
- :data-method="actionMethod"
:title="tooltipText"
- :href="link"
- class="ci-action-icon-container ci-action-icon-wrapper"
+ class="btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper"
:class="cssClass"
- data-container="body">
- <icon :name="actionIcon"/>
- </a>
+ data-container="body"
+ :disabled="isDisabled"
+ >
+ <icon :name="actionIcon" />
+ </button>
</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
index 1c0944d45fc..7c4fd65e36f 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue
@@ -7,6 +7,13 @@
* TODO: Remove UJS from here and use an async request instead.
*/
export default {
+ components: {
+ icon,
+ },
+
+ directives: {
+ tooltip,
+ },
props: {
tooltipText: {
type: String,
@@ -28,14 +35,6 @@
required: true,
},
},
-
- components: {
- icon,
- },
-
- directives: {
- tooltip,
- },
};
</script>
<template>
@@ -47,7 +46,8 @@
rel="nofollow"
class="ci-action-icon-wrapper js-ci-status-icon"
data-container="body"
- aria-label="Job's action">
- <icon :name="actionIcon"/>
+ aria-label="Job's action"
+ >
+ <icon :name="actionIcon" />
</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
index 7006d05e7b2..be213c2ee78 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -1,4 +1,5 @@
<script>
+ import $ from 'jquery';
import jobNameComponent from './job_name_component.vue';
import jobComponent from './job_component.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
@@ -27,13 +28,6 @@
* }
*/
export default {
- props: {
- job: {
- type: Object,
- required: true,
- },
- },
-
directives: {
tooltip,
},
@@ -43,12 +37,23 @@
jobNameComponent,
},
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+
computed: {
tooltipText() {
return `${this.job.name} - ${this.job.status.label}`;
},
},
+ mounted() {
+ this.stopDropdownClickPropagation();
+ },
+
methods: {
/**
* When the user right clicks or cmd/ctrl + click in the job name
@@ -59,16 +64,13 @@
* target the click event of this component.
*/
stopDropdownClickPropagation() {
- $(this.$el.querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
+ $(this.$el
+ .querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
.on('click', (e) => {
e.stopPropagation();
});
},
},
-
- mounted() {
- this.stopDropdownClickPropagation();
- },
};
</script>
<template>
@@ -83,22 +85,25 @@
<job-name-component
:name="job.name"
- :status="job.status" />
+ :status="job.status"
+ />
<span class="dropdown-counter-badge">
- {{job.size}}
+ {{ 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">
+ <li
+ v-for="(item, i) in job.jobs"
+ :key="i">
<job-component
:job="item"
:is-dropdown="true"
css-class-job-name="mini-pipeline-graph-dropdown-item"
- />
+ />
</li>
</ul>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 66bc1d1979c..ac9ce7e47d6 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,64 +1,68 @@
<script>
- import loadingIcon from '~/vue_shared/components/loading_icon.vue';
- import '~/flash';
- import stageColumnComponent from './stage_column_component.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import StageColumnComponent from './stage_column_component.vue';
- export default {
- props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
- },
+export default {
+ components: {
+ StageColumnComponent,
+ LoadingIcon,
+ },
- components: {
- stageColumnComponent,
- loadingIcon,
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ actionDisabled: {
+ type: String,
+ required: false,
+ default: null,
},
+ },
- computed: {
- graph() {
- return this.pipeline.details && this.pipeline.details.stages;
- },
+ computed: {
+ graph() {
+ return this.pipeline.details && this.pipeline.details.stages;
},
+ },
- methods: {
- capitalizeStageName(name) {
- return name.charAt(0).toUpperCase() + name.slice(1);
- },
+ methods: {
+ capitalizeStageName(name) {
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ },
- isFirstColumn(index) {
- return index === 0;
- },
+ isFirstColumn(index) {
+ return index === 0;
+ },
- stageConnectorClass(index, stage) {
- let className;
+ 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';
- }
+ // 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;
- },
+ return className;
},
- };
+ },
+};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
- <div class="pipeline-visualization pipeline-graph">
+ <div class="pipeline-visualization pipeline-graph pipeline-tab-content">
<div class="text-center">
<loading-icon
v-if="isLoading"
size="3"
- />
+ />
</div>
<ul
@@ -70,7 +74,9 @@
:jobs="stage.groups"
:key="stage.name"
:stage-connector-class="stageConnectorClass(index, stage)"
- :is-first-column="isFirstColumn(index)"/>
+ :is-first-column="isFirstColumn(index)"
+ :action-disabled="actionDisabled"
+ />
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue
index b01c799643c..c6e5ae6df41 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue
@@ -1,96 +1,102 @@
<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": "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,
- },
+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",
+ * "tooltip": "passed",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+export default {
+ components: {
+ ActionComponent,
+ DropdownActionComponent,
+ JobNameComponent,
+ },
+
+ directives: {
+ tooltip,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
},
- components: {
- actionComponent,
- dropdownActionComponent,
- jobNameComponent,
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
},
- directives: {
- tooltip,
+ isDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- computed: {
- status() {
- return this.job && this.job.status ? this.job.status : {};
- },
-
- tooltipText() {
- const textBuilder = [];
-
- if (this.job.name) {
- textBuilder.push(this.job.name);
- }
-
- if (this.job.name && this.status.label) {
- textBuilder.push('-');
- }
-
- if (this.status.label) {
- textBuilder.push(`${this.job.status.label}`);
- }
-
- return textBuilder.join(' ');
- },
-
- /**
- * 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;
- },
+ actionDisabled: {
+ type: String,
+ required: false,
+ default: null,
},
- };
+ },
+
+ computed: {
+ status() {
+ return this.job && this.job.status ? this.job.status : {};
+ },
+
+ tooltipText() {
+ const textBuilder = [];
+
+ if (this.job.name) {
+ textBuilder.push(this.job.name);
+ }
+
+ if (this.job.name && this.status.tooltip) {
+ textBuilder.push('-');
+ }
+
+ if (this.status.tooltip) {
+ textBuilder.push(`${this.job.status.tooltip}`);
+ }
+
+ return textBuilder.join(' ');
+ },
+
+ /**
+ * 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 class="ci-job-component">
@@ -101,13 +107,14 @@
:title="tooltipText"
:class="cssClassJobName"
data-container="body"
+ data-html="true"
class="js-pipeline-graph-job-link"
- >
+ >
<job-name-component
:name="job.name"
:status="job.status"
- />
+ />
</a>
<div
@@ -116,13 +123,14 @@
class="js-job-component-tooltip"
:title="tooltipText"
:class="cssClassJobName"
+ data-html="true"
data-container="body"
- >
+ >
<job-name-component
:name="job.name"
:status="job.status"
- />
+ />
</div>
<action-component
@@ -130,8 +138,8 @@
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
- :action-method="status.action.method"
- />
+ :button-disabled="actionDisabled"
+ />
<dropdown-action-component
v-if="hasAction && isDropdown"
@@ -139,6 +147,6 @@
:link="status.action.path"
:action-icon="status.action.icon"
:action-method="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
index f46d21bd6d7..14f4964a406 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -8,6 +8,9 @@
* - Dropdown badge components
*/
export default {
+ components: {
+ ciIcon,
+ },
props: {
name: {
type: String,
@@ -19,19 +22,14 @@
required: true,
},
},
-
- components: {
- ciIcon,
- },
};
</script>
<template>
<span class="ci-job-name-component">
- <ci-icon
- :status="status" />
+ <ci-icon :status="status" />
<span class="ci-status-text">
- {{name}}
+ {{ 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
index 9b1bbb0906f..f6e6569e15b 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,8 +1,12 @@
<script>
-import jobComponent from './job_component.vue';
-import dropdownJobComponent from './dropdown_job_component.vue';
+import JobComponent from './job_component.vue';
+import DropdownJobComponent from './dropdown_job_component.vue';
export default {
+ components: {
+ JobComponent,
+ DropdownJobComponent,
+ },
props: {
title: {
type: String,
@@ -25,11 +29,11 @@ export default {
required: false,
default: '',
},
- },
-
- components: {
- jobComponent,
- dropdownJobComponent,
+ actionDisabled: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
methods: {
@@ -52,7 +56,7 @@ export default {
class="stage-column"
:class="stageConnectorClass">
<div class="stage-name">
- {{title}}
+ {{ title }}
</div>
<div class="builds-container">
<ul>
@@ -61,7 +65,8 @@ export default {
:key="job.id"
class="build"
:class="buildConnnectorClass(index)"
- :id="jobId(job)">
+ :id="jobId(job)"
+ >
<div class="curve"></div>
@@ -69,12 +74,13 @@ export default {
v-if="job.size === 1"
:job="job"
css-class-job-name="build-content"
- />
+ :action-disabled="actionDisabled"
+ />
<dropdown-job-component
v-if="job.size > 1"
:job="job"
- />
+ />
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 2a1ecac3707..e08c2092680 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,82 +1,81 @@
<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';
+ 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,
+ export default {
+ name: 'PipelineHeaderSection',
+ components: {
+ ciHeader,
+ loadingIcon,
},
- isLoading: {
- type: Boolean,
- required: true,
+ 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;
+ data() {
+ return {
+ actions: this.getActions(),
+ };
},
- shouldRenderContent() {
- return !this.isLoading && Object.keys(this.pipeline).length;
+
+ 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);
+ watch: {
+ pipeline() {
+ this.actions = this.getActions();
+ },
+ },
- this.$set(this.actions[index], 'isLoading', true);
+ methods: {
+ postAction(action) {
+ const index = this.actions.indexOf(action);
- eventHub.$emit('headerPostAction', action);
- },
+ this.$set(this.actions[index], 'isLoading', true);
- getActions() {
- const actions = [];
+ eventHub.$emit('headerPostAction', action);
+ },
- 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,
- });
- }
+ getActions() {
+ const actions = [];
- 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,
- });
- }
+ 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,
+ });
+ }
- return actions;
- },
- },
+ 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,
+ });
+ }
- watch: {
- pipeline() {
- this.actions = this.getActions();
+ return actions;
+ },
},
- },
-};
+ };
</script>
<template>
<div class="pipeline-header-container">
@@ -89,9 +88,11 @@ export default {
:user="pipeline.user"
:actions="actions"
@actionClicked="postAction"
- />
+ />
<loading-icon
v-if="isLoading"
- size="2"/>
+ size="2"
+ class="prepend-top-default append-bottom-default"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue
index 632fc167f2b..eba5678e3e5 100644
--- a/app/assets/javascripts/pipelines/components/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/nav_controls.vue
@@ -1,54 +1,67 @@
<script>
-export default {
- name: 'PipelineNavControls',
- props: {
- newPipelinePath: {
- type: String,
- required: true,
- },
+ import LoadingButton from '../../vue_shared/components/loading_button.vue';
- hasCiEnabled: {
- type: Boolean,
- required: true,
+ export default {
+ name: 'PipelineNavControls',
+ components: {
+ LoadingButton,
},
+ props: {
+ newPipelinePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- helpPagePath: {
- type: String,
- required: true,
- },
+ resetCachePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- ciLintPath: {
- type: String,
- required: true,
- },
+ ciLintPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
- canCreatePipeline: {
- type: Boolean,
- required: true,
+ isResetCacheButtonLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- },
-};
+ methods: {
+ onClickResetCache() {
+ this.$emit('resetRunnersCache', this.resetCachePath);
+ },
+ },
+ };
</script>
<template>
<div class="nav-controls">
<a
- v-if="canCreatePipeline"
+ v-if="newPipelinePath"
:href="newPipelinePath"
- class="btn btn-create">
- Run Pipeline
+ class="btn btn-create js-run-pipeline"
+ >
+ {{ s__('Pipelines|Run Pipeline') }}
</a>
- <a
- v-if="!hasCiEnabled"
- :href="helpPagePath"
- class="btn btn-info">
- Get started with Pipelines
- </a>
+ <loading-button
+ v-if="resetCachePath"
+ @click="onClickResetCache"
+ :loading="isResetCacheButtonLoading"
+ class="btn btn-default js-clear-cache"
+ :label="s__('Pipelines|Clear Runner Caches')"
+ />
<a
+ v-if="ciLintPath"
:href="ciLintPath"
- class="btn btn-default">
- CI Lint
+ class="btn btn-default js-ci-lint"
+ >
+ {{ s__('Pipelines|CI Lint') }}
</a>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 9da0aac50a1..ceb4d9ca604 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -4,6 +4,13 @@
import popover from '../../vue_shared/directives/popover';
export default {
+ components: {
+ userAvatarLink,
+ },
+ directives: {
+ tooltip,
+ popover,
+ },
props: {
pipeline: {
type: Object,
@@ -14,13 +21,6 @@
required: true,
},
},
- components: {
- userAvatarLink,
- },
- directives: {
- tooltip,
- popover,
- },
computed: {
user() {
return this.pipeline.user;
@@ -30,8 +30,16 @@
html: true,
trigger: 'focus',
placement: 'top',
- title: '<div class="autodevops-title">This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b></div>',
- content: `<a class="autodevops-link" href="${this.autoDevopsHelpPath}" target="_blank" rel="noopener noreferrer nofollow">Learn more about Auto DevOps</a>`,
+ title: `<div class="autodevops-title">
+ This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>
+ </div>`,
+ content: `<a
+ class="autodevops-link"
+ href="${this.autoDevopsHelpPath}"
+ target="_blank"
+ rel="noopener noreferrer nofollow">
+ Learn more about Auto DevOps
+ </a>`,
};
},
},
@@ -42,7 +50,7 @@
<a
:href="pipeline.path"
class="js-pipeline-url-link">
- <span class="pipeline-id">#{{pipeline.id}}</span>
+ <span class="pipeline-id">#{{ pipeline.id }}</span>
</a>
<span>by</span>
<user-avatar-link
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index fe1f3b4246a..e0a7284124d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -1,18 +1,28 @@
<script>
import _ from 'underscore';
+ import { __, sprintf, s__ } from '../../locale';
+ import createFlash from '../../flash';
import PipelinesService from '../services/pipelines_service';
import pipelinesMixin from '../mixins/pipelines';
- import tablePagination from '../../vue_shared/components/table_pagination.vue';
- import navigationTabs from '../../vue_shared/components/navigation_tabs.vue';
- import navigationControls from './nav_controls.vue';
+ import TablePagination from '../../vue_shared/components/table_pagination.vue';
+ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue';
+ import NavigationControls from './nav_controls.vue';
import {
- convertPermissionToBoolean,
getParameterByName,
parseQueryStringIntoObject,
} from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
export default {
+ components: {
+ TablePagination,
+ NavigationTabs,
+ NavigationControls,
+ },
+ mixins: [
+ pipelinesMixin,
+ CIPaginationMixin,
+ ],
props: {
store: {
type: Object,
@@ -27,119 +37,187 @@
required: false,
default: 'root',
},
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ errorStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ noPipelinesSvgPath: {
+ type: String,
+ required: true,
+ },
+ autoDevopsPath: {
+ type: String,
+ required: true,
+ },
+ hasGitlabCi: {
+ type: Boolean,
+ required: true,
+ },
+ canCreatePipeline: {
+ type: Boolean,
+ required: true,
+ },
+ ciLintPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ resetCachePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ newPipelinePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
- components: {
- tablePagination,
- navigationTabs,
- navigationControls,
- },
- mixins: [
- pipelinesMixin,
- CIPaginationMixin,
- ],
data() {
- const pipelinesData = document.querySelector('#pipelines-list-vue').dataset;
-
return {
- endpoint: pipelinesData.endpoint,
- helpPagePath: pipelinesData.helpPagePath,
- emptyStateSvgPath: pipelinesData.emptyStateSvgPath,
- errorStateSvgPath: pipelinesData.errorStateSvgPath,
- autoDevopsPath: pipelinesData.helpAutoDevopsPath,
- newPipelinePath: pipelinesData.newPipelinePath,
- canCreatePipeline: pipelinesData.canCreatePipeline,
- hasCi: pipelinesData.hasCi,
- ciLintPath: pipelinesData.ciLintPath,
+ // Start with loading state to avoid a glitch when the empty state will be rendered
+ isLoading: true,
state: this.store.state,
scope: getParameterByName('scope') || 'all',
page: getParameterByName('page') || '1',
requestData: {},
+ isResetCacheButtonLoading: false,
};
},
- computed: {
- canCreatePipelineParsed() {
- return convertPermissionToBoolean(this.canCreatePipeline);
- },
+ stateMap: {
+ // with tabs
+ loading: 'loading',
+ tableList: 'tableList',
+ error: 'error',
+ emptyTab: 'emptyTab',
+ // without tabs
+ emptyState: 'emptyState',
+ },
+ scopes: {
+ all: 'all',
+ pending: 'pending',
+ running: 'running',
+ finished: 'finished',
+ branches: 'branches',
+ tags: 'tags',
+ },
+ computed: {
/**
- * 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);
+ * `hasGitlabCi` handles both internal and external CI.
+ * The order on which the checks are made in this method is
+ * important to guarantee we handle all the corner cases.
+ */
+ stateToRender() {
+ const { stateMap } = this.$options;
+
+ if (this.isLoading) {
+ return stateMap.loading;
+ }
+
+ if (this.hasError) {
+ return stateMap.error;
+ }
+
+ if (this.state.pipelines.length) {
+ return stateMap.tableList;
+ }
+
+ if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) {
+ return stateMap.emptyTab;
+ }
+
+ return stateMap.emptyState;
},
/**
- * When a specific scope does not have pipelines we render a message.
- *
- * @return {Boolean}
+ * Tabs are rendered in all states except empty state.
+ * They are not rendered before the first request to avoid a flicker on first load.
*/
- shouldRenderNoPipelinesMessage() {
- return !this.isLoading &&
- !this.hasError &&
- !this.state.pipelines.length &&
- this.scope !== 'all' &&
- this.scope !== null;
+ shouldRenderTabs() {
+ const { stateMap } = this.$options;
+ return this.hasMadeRequest &&
+ [
+ stateMap.loading,
+ stateMap.tableList,
+ stateMap.error,
+ stateMap.emptyTab,
+ ].includes(this.stateToRender);
},
- shouldRenderTable() {
- return !this.hasError &&
- !this.isLoading && this.state.pipelines.length;
+ shouldRenderButtons() {
+ return (this.newPipelinePath ||
+ this.resetCachePath ||
+ this.ciLintPath) && this.shouldRenderTabs;
},
- /**
- * 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;
+
+ emptyTabMessage() {
+ const { scopes } = this.$options;
+ const possibleScopes = [scopes.pending, scopes.running, scopes.finished];
+
+ if (possibleScopes.includes(this.scope)) {
+ return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), {
+ scope: this.scope,
+ });
+ }
+
+ return s__('Pipelines|There are currently no pipelines.');
},
tabs() {
const { count } = this.state;
+ const { scopes } = this.$options;
+
return [
{
- name: 'All',
- scope: 'all',
+ name: __('All'),
+ scope: scopes.all,
count: count.all,
isActive: this.scope === 'all',
},
{
- name: 'Pending',
- scope: 'pending',
+ name: __('Pending'),
+ scope: scopes.pending,
count: count.pending,
isActive: this.scope === 'pending',
},
{
- name: 'Running',
- scope: 'running',
+ name: __('Running'),
+ scope: scopes.running,
count: count.running,
isActive: this.scope === 'running',
},
{
- name: 'Finished',
- scope: 'finished',
+ name: __('Finished'),
+ scope: scopes.finished,
count: count.finished,
isActive: this.scope === 'finished',
},
{
- name: 'Branches',
- scope: 'branches',
+ name: __('Branches'),
+ scope: scopes.branches,
isActive: this.scope === 'branches',
},
{
- name: 'Tags',
- scope: 'tags',
+ name: __('Tags'),
+ scope: scopes.tags,
isActive: this.scope === 'tags',
},
];
@@ -186,7 +264,24 @@
this.errorCallback();
// restart polling
- this.poll.restart();
+ this.poll.restart({ data: this.requestData });
+ });
+ },
+
+ handleResetRunnersCache(endpoint) {
+ this.isResetCacheButtonLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => {
+ this.isResetCacheButtonLoading = false;
+ createFlash(
+ s__('Pipelines|Project cache successfully reset.'),
+ 'notice',
+ );
+ })
+ .catch(() => {
+ this.isResetCacheButtonLoading = false;
+ createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.'));
});
},
},
@@ -196,80 +291,87 @@
<div class="pipelines-container">
<div
class="top-area scrolling-tabs-container inner-page-scroll-tabs"
- v-if="!shouldRenderEmptyState">
+ v-if="shouldRenderTabs || shouldRenderButtons"
+ >
<div class="fade-left">
<i
class="fa fa-angle-left"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</div>
<div class="fade-right">
<i
class="fa fa-angle-right"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</div>
<navigation-tabs
+ v-if="shouldRenderTabs"
:tabs="tabs"
@onChangeTab="onChangeTab"
scope="pipelines"
- />
+ />
<navigation-controls
+ v-if="shouldRenderButtons"
:new-pipeline-path="newPipelinePath"
- :has-ci-enabled="hasCiEnabled"
- :help-page-path="helpPagePath"
+ :reset-cache-path="resetCachePath"
:ci-lint-path="ciLintPath"
- :can-create-pipeline="canCreatePipelineParsed "
- />
+ @resetRunnersCache="handleResetRunnersCache"
+ :is-reset-cache-button-loading="isResetCacheButtonLoading"
+ />
</div>
<div class="content-list pipelines">
<loading-icon
- label="Loading Pipelines"
+ v-if="stateToRender === $options.stateMap.loading"
+ :label="s__('Pipelines|Loading Pipelines')"
size="3"
- v-if="isLoading"
class="prepend-top-20"
- />
+ />
<empty-state
- v-if="shouldRenderEmptyState"
+ v-else-if="stateToRender === $options.stateMap.emptyState"
:help-page-path="helpPagePath"
:empty-state-svg-path="emptyStateSvgPath"
- />
+ :can-set-ci="canCreatePipeline"
+ />
- <error-state
- v-if="shouldRenderErrorState"
- :error-state-svg-path="errorStateSvgPath"
- />
+ <svg-blank-state
+ v-else-if="stateToRender === $options.stateMap.error"
+ :svg-path="errorStateSvgPath"
+ :message="s__(`Pipelines|There was an error fetching the pipelines.
+ Try again in a few moments or contact your support team.`)"
+ />
- <div
- class="blank-state-row"
- v-if="shouldRenderNoPipelinesMessage">
- <div class="blank-state-center">
- <h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2>
- </div>
- </div>
+ <svg-blank-state
+ v-else-if="stateToRender === $options.stateMap.emptyTab"
+ :svg-path="noPipelinesSvgPath"
+ :message="emptyTabMessage"
+ />
<div
class="table-holder"
- v-if="shouldRenderTable">
+ v-else-if="stateToRender === $options.stateMap.tableList"
+ >
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsPath"
:view-type="viewType"
- />
+ />
</div>
<table-pagination
v-if="shouldRenderPagination"
:change="onChangePage"
:page-info="state.pageInfo"
- />
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
index f3c0aca17ba..3297af7bde4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
@@ -1,25 +1,25 @@
<script>
- import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import icon from '../../vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
- props: {
- actions: {
- type: Array,
- required: true,
- },
- },
directives: {
tooltip,
},
components: {
loadingIcon,
+ icon,
+ },
+ props: {
+ actions: {
+ type: Array,
+ required: true,
+ },
},
data() {
return {
- playIconSvg,
isLoading: false,
};
},
@@ -50,8 +50,12 @@
data-toggle="dropdown"
data-placement="top"
aria-label="Manual job"
- :disabled="isLoading">
- <span v-html="playIconSvg"></span>
+ :disabled="isLoading"
+ >
+ <icon
+ name="play"
+ class="icon-play"
+ />
<i
class="fa fa-caret-down"
aria-hidden="true">
@@ -60,14 +64,18 @@
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="action in actions">
+ <li
+ v-for="(action, i) in actions"
+ :key="i"
+ >
<button
type="button"
class="js-pipeline-action-link no-btn btn"
@click="onClickAction(action.path)"
:class="{ disabled: isActionDisabled(action) }"
- :disabled="isActionDisabled(action)">
- {{action.name}}
+ :disabled="isActionDisabled(action)"
+ >
+ {{ action.name }}
</button>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
index 751a20991af..1b9e0f917a4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
@@ -1,45 +1,52 @@
<script>
import tooltip from '../../vue_shared/directives/tooltip';
+ import icon from '../../vue_shared/components/icon.vue';
export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ icon,
+ },
props: {
artifacts: {
type: Array,
required: true,
},
},
- directives: {
- tooltip,
- },
};
</script>
<template>
<div
class="btn-group"
- role="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>
+ aria-label="Artifacts"
+ >
+ <icon name="download" />
<i
class="fa fa-caret-down"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
- <li v-for="artifact in artifacts">
+ <li
+ v-for="(artifact, i) in artifacts"
+ :key="i">
<a
rel="nofollow"
download
- :href="artifact.path">
- Download {{artifact.name}} artifacts
+ :href="artifact.path"
+ >
+ Download {{ artifact.name }} artifacts
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue
index 16a705cbaff..714aed1333e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue
@@ -1,5 +1,8 @@
<script>
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { s__, sprintf } from '~/locale';
import pipelinesTableRowComponent from './pipelines_table_row.vue';
+ import eventHub from '../event_hub';
/**
* Pipelines Table Component.
@@ -7,6 +10,10 @@
* Given an array of objects, renders a table.
*/
export default {
+ components: {
+ pipelinesTableRowComponent,
+ DeprecatedModal,
+ },
props: {
pipelines: {
type: Array,
@@ -26,8 +33,51 @@
required: true,
},
},
- components: {
- pipelinesTableRowComponent,
+ data() {
+ return {
+ pipelineId: '',
+ endpoint: '',
+ type: '',
+ };
+ },
+ computed: {
+ modalTitle() {
+ return this.type === 'stop' ?
+ sprintf(s__('Pipeline|Stop pipeline #%{pipelineId}?'), {
+ pipelineId: `'${this.pipelineId}'`,
+ }, false) :
+ sprintf(s__('Pipeline|Retry pipeline #%{pipelineId}?'), {
+ pipelineId: `'${this.pipelineId}'`,
+ }, false);
+ },
+ modalText() {
+ return this.type === 'stop' ?
+ sprintf(s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), {
+ pipelineId: `<strong>#${this.pipelineId}</strong>`,
+ }, false) :
+ sprintf(s__('Pipeline|You’re about to retry pipeline %{pipelineId}.'), {
+ pipelineId: `<strong>#${this.pipelineId}</strong>`,
+ }, false);
+ },
+ primaryButtonLabel() {
+ return this.type === 'stop' ? s__('Pipeline|Stop pipeline') : s__('Pipeline|Retry pipeline');
+ },
+ },
+ created() {
+ eventHub.$on('openConfirmationModal', this.setModalData);
+ },
+ beforeDestroy() {
+ eventHub.$off('openConfirmationModal', this.setModalData);
+ },
+ methods: {
+ setModalData(data) {
+ this.pipelineId = data.pipelineId;
+ this.endpoint = data.endpoint;
+ this.type = data.type;
+ },
+ onSubmit() {
+ eventHub.$emit('postAction', this.endpoint);
+ },
},
};
</script>
@@ -35,25 +85,30 @@
<div class="ci-table">
<div
class="gl-responsive-table-row table-row-header"
- role="row">
+ role="row"
+ >
<div
class="table-section section-10 js-pipeline-status pipeline-status"
- role="rowheader">
+ role="rowheader"
+ >
Status
</div>
<div
class="table-section section-15 js-pipeline-info pipeline-info"
- role="rowheader">
+ role="rowheader"
+ >
Pipeline
</div>
<div
- class="table-section section-25 js-pipeline-commit pipeline-commit"
- role="rowheader">
+ class="table-section section-20 js-pipeline-commit pipeline-commit"
+ role="rowheader"
+ >
Commit
</div>
<div
- class="table-section section-15 js-pipeline-stages pipeline-stages"
- role="rowheader">
+ class="table-section section-20 js-pipeline-stages pipeline-stages"
+ role="rowheader"
+ >
Stages
</div>
</div>
@@ -65,5 +120,20 @@
:auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
/>
+ <deprecated-modal
+ id="confirmation-modal"
+ :title="modalTitle"
+ :text="modalText"
+ kind="danger"
+ :primary-button-label="primaryButtonLabel"
+ @submit="onSubmit"
+ >
+ <template
+ slot="body"
+ slot-scope="props"
+ >
+ <p v-html="props.text"></p>
+ </template>
+ </deprecated-modal>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index 33fbce993b2..4cbd67e0372 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -1,244 +1,246 @@
<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';
+ /* 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,
+ /**
+ * Pipeline table row.
+ *
+ * Given the received object renders a table row in the pipelines' table.
+ */
+ export default {
+ components: {
+ asyncButtonComponent,
+ pipelinesActionsComponent,
+ pipelinesArtifactsComponent,
+ commitComponent,
+ pipelineStage,
+ pipelineUrl,
+ ciBadge,
+ pipelinesTimeago,
},
- updateGraphDropdown: {
- type: Boolean,
- required: false,
- default: false,
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ updateGraphDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ autoDevopsHelpPath: {
+ type: String,
+ required: true,
+ },
+ viewType: {
+ type: String,
+ required: true,
+ },
},
- autoDevopsHelpPath: {
- type: String,
- required: true,
- },
- viewType: {
- type: String,
- required: true,
- },
- },
- 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;
+ 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;
- }
+ 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;
+ // 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, {
+ // 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,
+ };
}
- // 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;
- },
+ 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 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;
- }, {});
- }
+ /**
+ * 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;
- },
+ 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 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 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;
- },
+ /**
+ * 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;
- }
+ /**
+ * Timeago components expects a number
+ *
+ * @return {type} description
+ */
+ pipelineDuration() {
+ if (this.pipeline.details && this.pipeline.details.duration) {
+ return this.pipeline.details.duration;
+ }
- return 0;
- },
+ 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;
- }
+ /**
+ * Timeago component expects a String.
+ *
+ * @return {String}
+ */
+ pipelineFinishedAt() {
+ if (this.pipeline.details && this.pipeline.details.finished_at) {
+ return this.pipeline.details.finished_at;
+ }
- return '';
- },
+ return '';
+ },
- pipelineStatus() {
- if (this.pipeline.details && this.pipeline.details.status) {
- return this.pipeline.details.status;
- }
- 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;
- },
+ displayPipelineActions() {
+ return this.pipeline.flags.retryable ||
+ this.pipeline.flags.cancelable ||
+ this.pipeline.details.manual_actions.length ||
+ this.pipeline.details.artifacts.length;
+ },
- isChildView() {
- return this.viewType === 'child';
+ isChildView() {
+ return this.viewType === 'child';
+ },
},
- },
-};
+ };
</script>
<template>
<div class="commit gl-responsive-table-row">
<div class="table-section section-10 commit-link">
- <div class="table-mobile-header"
- role="rowheader">
+ <div
+ class="table-mobile-header"
+ role="rowheader"
+ >
Status
</div>
<div class="table-mobile-content">
<ci-badge
:status="pipelineStatus"
:show-text="!isChildView"
- />
+ />
</div>
</div>
<pipeline-url
:pipeline="pipeline"
:auto-devops-help-path="autoDevopsHelpPath"
- />
+ />
- <div class="table-section section-25">
+ <div class="table-section section-20">
<div
class="table-mobile-header"
role="rowheader">
@@ -253,32 +255,35 @@ export default {
:title="commitTitle"
:author="commitAuthor"
:show-branch="!isChildView"
- />
+ />
</div>
</div>
- <div class="table-section section-wrap section-15 stage-cell">
+ <div class="table-section section-wrap section-20 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"
+ <template v-if="pipeline.details.stages.length > 0">
+ <div
+ class="stage-container dropdown js-mini-pipeline-graph"
+ v-for="(stage, index) in pipeline.details.stages"
+ :key="index">
+ <pipeline-stage
+ :stage="stage"
+ :update-dropdown="updateGraphDropdown"
/>
- </div>
+ </div>
+ </template>
</div>
</div>
<pipelines-timeago
:duration="pipelineDuration"
:finished-time="pipelineFinishedAt"
- />
+ />
<div
v-if="displayPipelineActions"
@@ -287,13 +292,13 @@ export default {
<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"
@@ -301,16 +306,23 @@ export default {
css-class="js-pipelines-retry-button btn-default btn-retry"
title="Retry"
icon="repeat"
- />
+ :pipeline-id="pipeline.id"
+ data-toggle="modal"
+ data-target="#confirmation-modal"
+ type="retry"
+ />
<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?"
- />
+ title="Stop"
+ icon="close"
+ :pipeline-id="pipeline.id"
+ data-toggle="modal"
+ data-target="#confirmation-modal"
+ type="stop"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index ac9d9c901ca..8bc7a1f20b2 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -1,133 +1,134 @@
<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
- */
-
-import Flash from '../../flash';
-import icon from '../../vue_shared/components/icon.vue';
-import loadingIcon from '../../vue_shared/components/loading_icon.vue';
-import tooltip from '../../vue_shared/directives/tooltip';
-
-export default {
- props: {
- stage: {
- type: Object,
- required: true,
+ import $ from 'jquery';
+
+ /**
+ * 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
+ */
+
+ import Flash from '../../flash';
+ import icon from '../../vue_shared/components/icon.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ components: {
+ loadingIcon,
+ icon,
},
- updateDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- directives: {
- tooltip,
- },
-
- data() {
- return {
- isLoading: false,
- dropdownContent: '',
- };
- },
-
- components: {
- loadingIcon,
- icon,
- },
-
- updated() {
- if (this.dropdownContent.length > 0) {
- this.stopDropdownClickPropagation();
- }
- },
-
- watch: {
- updateDropdown() {
- if (this.updateDropdown &&
- this.isDropdownOpen() &&
- !this.isLoading) {
- this.fetchJobs();
- }
+ directives: {
+ tooltip,
},
- },
- methods: {
- onClickStage() {
- if (!this.isDropdownOpen()) {
- this.isLoading = true;
- this.fetchJobs();
- }
+ props: {
+ stage: {
+ type: Object,
+ required: true,
+ },
+
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- 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;
- });
+ data() {
+ return {
+ isLoading: false,
+ dropdownContent: '',
+ };
},
- /**
- * 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: {
+ dropdownClass() {
+ return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
+ },
- closeDropdown() {
- if (this.isDropdownOpen()) {
- $(this.$refs.dropdown).dropdown('toggle');
- }
- },
+ triggerButtonClass() {
+ return `ci-status-icon-${this.stage.status.group}`;
+ },
- isDropdownOpen() {
- return this.$el.classList.contains('open');
+ borderlessIcon() {
+ return `${this.stage.status.icon}_borderless`;
+ },
},
- },
- computed: {
- dropdownClass() {
- return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown &&
+ this.isDropdownOpen() &&
+ !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
},
- triggerButtonClass() {
- return `ci-status-icon-${this.stage.status.group}`;
+ updated() {
+ if (this.dropdownContent.length > 0) {
+ this.stopDropdownClickPropagation();
+ }
},
- borderlessIcon() {
- return `${this.stage.status.icon}_borderless`;
+ 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');
+ },
},
- },
-};
+ };
</script>
<template>
@@ -143,36 +144,41 @@ export default {
type="button"
id="stageDropdown"
aria-haspopup="true"
- aria-expanded="false">
+ aria-expanded="false"
+ >
<span
aria-hidden="true"
- :aria-label="stage.title">
- <icon
- :name="borderlessIcon"/>
+ :aria-label="stage.title"
+ >
+ <icon :name="borderlessIcon" />
</span>
<i
class="fa fa-caret-down"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</button>
<ul
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
- aria-labelledby="stageDropdown">
+ aria-labelledby="stageDropdown"
+ >
<li
:class="dropdownClass"
- class="js-builds-dropdown-list scrollable-menu">
+ class="js-builds-dropdown-list scrollable-menu"
+ >
<loading-icon v-if="isLoading"/>
<ul
v-else
- v-html="dropdownContent">
+ v-html="dropdownContent"
+ >
</ul>
</li>
</ul>
</div>
-</script>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/time_ago.vue
index 037684b4e72..cd54d26c9d3 100644
--- a/app/assets/javascripts/pipelines/components/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/time_ago.vue
@@ -5,6 +5,12 @@
import timeagoMixin from '../../vue_shared/mixins/timeago';
export default {
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
props: {
finishedTime: {
type: String,
@@ -15,12 +21,6 @@
required: true,
},
},
- mixins: [
- timeagoMixin,
- ],
- directives: {
- tooltip,
- },
data() {
return {
iconTimerSvg,
@@ -60,26 +60,29 @@
<div class="table-section section-15 pipelines-time-ago">
<div
class="table-mobile-header"
- role="rowheader">
+ role="rowheader"
+ >
Duration
</div>
<div class="table-mobile-content">
<p
class="duration"
- v-if="hasDuration">
- <span
- v-html="iconTimerSvg">
+ v-if="hasDuration"
+ >
+ <span v-html="iconTimerSvg">
</span>
- {{durationFormated}}
+ {{ durationFormated }}
</p>
<p
class="finished-at hidden-xs hidden-sm"
- v-if="hasFinishedTime">
+ v-if="hasFinishedTime"
+ >
<i
class="fa fa-calendar"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
<time
@@ -87,9 +90,9 @@
data-placement="top"
data-container="body"
:title="tooltipTitle(finishedTime)">
- {{timeFormated(finishedTime)}}
+ {{ timeFormated(finishedTime) }}
</time>
</p>
</div>
</div>
-</script>
+</template>
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 50bdf80c3e3..522a4277bd7 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -1,23 +1,19 @@
import Visibility from 'visibilityjs';
+import { __ } from '../../locale';
import Flash from '../../flash';
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 EmptyState from '../components/empty_state.vue';
+import SvgBlankState from '../components/blank_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;
- },
+ PipelinesTableComponent,
+ SvgBlankState,
+ EmptyState,
+ LoadingIcon,
},
data() {
return {
@@ -55,12 +51,10 @@ export default {
}
});
- eventHub.$on('refreshPipelines', this.fetchPipelines);
eventHub.$on('postAction', this.postAction);
},
beforeDestroy() {
- eventHub.$off('refreshPipelines');
- eventHub.$on('postAction', this.postAction);
+ eventHub.$off('postAction', this.postAction);
},
destroyed() {
this.poll.stop();
@@ -85,6 +79,7 @@ export default {
this.hasError = true;
this.isLoading = false;
this.updateGraphDropdown = false;
+ this.hasMadeRequest = true;
},
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
@@ -95,8 +90,8 @@ export default {
},
postAction(endpoint) {
this.service.postAction(endpoint)
- .then(() => eventHub.$emit('refreshPipelines'))
- .catch(() => new Flash('An error occurred while making the request.'));
+ .then(() => this.fetchPipelines())
+ .catch(() => Flash(__('An error occurred while making the request.')));
},
},
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 206023d4ddb..900eb7855f4 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,11 +1,15 @@
import Vue from 'vue';
-import Flash from '../flash';
-import PipelinesMediator from './pipeline_details_mediatior';
+import Flash from '~/flash';
+import Translate from '~/vue_shared/translate';
+import { __ } from '~/locale';
+import PipelinesMediator from './pipeline_details_mediator';
import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
-document.addEventListener('DOMContentLoaded', () => {
+Vue.use(Translate);
+
+export default () => {
const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
@@ -15,19 +19,42 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line
new Vue({
el: '#js-pipeline-graph-vue',
+ components: {
+ pipelineGraph,
+ },
data() {
return {
mediator,
+ actionDisabled: null,
};
},
- components: {
- pipelineGraph,
+ created() {
+ eventHub.$on('graphAction', this.postAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('graphAction', this.postAction);
+ },
+ methods: {
+ postAction(action) {
+ this.actionDisabled = action;
+
+ this.mediator.service.postAction(action)
+ .then(() => {
+ this.mediator.refreshPipeline();
+ this.actionDisabled = null;
+ })
+ .catch(() => {
+ this.actionDisabled = null;
+ Flash(__('An error occurred while making the request.'));
+ });
+ },
},
render(createElement) {
return createElement('pipeline-graph', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
+ actionDisabled: this.actionDisabled,
},
});
},
@@ -36,14 +63,14 @@ document.addEventListener('DOMContentLoaded', () => {
// eslint-disable-next-line
new Vue({
el: '#js-pipeline-header-vue',
+ components: {
+ pipelineHeader,
+ },
data() {
return {
mediator,
};
},
- components: {
- pipelineHeader,
- },
created() {
eventHub.$on('headerPostAction', this.postAction);
},
@@ -54,7 +81,7 @@ document.addEventListener('DOMContentLoaded', () => {
postAction(action) {
this.mediator.service.postAction(action.path)
.then(() => this.mediator.refreshPipeline())
- .catch(() => new Flash('An error occurred while making the request.'));
+ .catch(() => Flash(__('An error occurred while making the request.')));
},
},
render(createElement) {
@@ -66,4 +93,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
-});
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
deleted file mode 100644
index 823ccd849f4..00000000000
--- a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import Visibility from 'visibilityjs';
-import Flash from '../flash';
-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/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
new file mode 100644
index 00000000000..621969cd622
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -0,0 +1,62 @@
+import Visibility from 'visibilityjs';
+import Flash from '../flash';
+import Poll from '../lib/utils/poll';
+import { __ } from '../locale';
+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;
+ Flash(__('An error occurred while fetching the pipeline.'));
+ }
+
+ refreshPipeline() {
+ this.poll.stop();
+
+ return this.service.getPipeline()
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback())
+ .finally(() => this.poll.restart());
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipelines_bundle.js b/app/assets/javascripts/pipelines/pipelines_bundle.js
deleted file mode 100644
index 3e4b6eeb5bf..00000000000
--- a/app/assets/javascripts/pipelines/pipelines_bundle.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import Vue from 'vue';
-import PipelinesStore from './stores/pipelines_store';
-import pipelinesComponent from './components/pipelines.vue';
-import Translate from '../vue_shared/translate';
-
-Vue.use(Translate);
-
-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
deleted file mode 100644
index 001faf4be33..00000000000
--- a/app/assets/javascripts/pipelines/pipelines_charts.js
+++ /dev/null
@@ -1,38 +0,0 @@
-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
deleted file mode 100644
index b5e7a0e53d9..00000000000
--- a/app/assets/javascripts/pipelines/pipelines_times.js
+++ /dev/null
@@ -1,27 +0,0 @@
-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/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index e2285494e62..47736fc5f42 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import Vue from 'vue';
import VueResource from 'vue-resource';
+import '../../vue_shared/vue_resource_interceptor';
Vue.use(VueResource);
diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js
index ffaafb3ee9e..246a265ef2b 100644
--- a/app/assets/javascripts/preview_markdown.js
+++ b/app/assets/javascripts/preview_markdown.js
@@ -1,200 +1,199 @@
/* eslint-disable func-names, no-var, object-shorthand, comma-dangle, prefer-arrow-callback */
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
// MarkdownPreview
//
// 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;
- var lastTextareaHeight = null;
- var markdownPreview;
- var previewButtonSelector;
- var writeButtonSelector;
-
- window.MarkdownPreview = (function () {
- function MarkdownPreview() {}
-
- // 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(this.emptyMessage);
- this.hideReferencedUsers($form);
+var lastTextareaPreviewed;
+var lastTextareaHeight = null;
+var markdownPreview;
+var previewButtonSelector;
+var writeButtonSelector;
+
+function MarkdownPreview() {}
+
+// 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(this.emptyMessage);
+ this.hideReferencedUsers($form);
+ } else {
+ preview.addClass('md-preview-loading').text('Loading...');
+ this.fetchMarkdownPreview(mdText, url, (function (response) {
+ var body;
+ if (response.body.length > 0) {
+ body = response.body;
} else {
- preview.addClass('md-preview-loading').text('Loading...');
- 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, url, success) {
- if (!url) {
- return;
- }
- if (text === this.ajaxCache.text) {
- success(this.ajaxCache.response);
- return;
- }
- $.ajax({
- type: 'POST',
- url: url,
- data: {
- text: text
- },
- dataType: 'json',
- success: (function (response) {
- this.ajaxCache = {
- text: text,
- response: response
- };
- success(response);
- }).bind(this)
- });
- };
-
- MarkdownPreview.prototype.hideReferencedUsers = function ($form) {
- $form.find('.referenced-users').hide();
- };
-
- MarkdownPreview.prototype.renderReferencedUsers = function (users, $form) {
- var referencedUsers;
- referencedUsers = $form.find('.referenced-users');
- if (referencedUsers.length) {
- if (users.length >= this.referenceThreshold) {
- referencedUsers.show();
- referencedUsers.find('.js-referenced-users-count').text(users.length);
- } else {
- referencedUsers.hide();
- }
+ body = this.emptyMessage;
}
- };
- MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
- $form.find('.referenced-commands').hide();
- };
+ preview.removeClass('md-preview-loading').html(body);
+ preview.renderGFM();
+ this.renderReferencedUsers(response.references.users, $form);
- 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();
+ if (response.references.commands) {
+ this.renderReferencedCommands(response.references.commands, $form);
}
+ }).bind(this));
+ }
+};
+
+MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
+ if (!url) {
+ return;
+ }
+ if (text === this.ajaxCache.text) {
+ success(this.ajaxCache.response);
+ return;
+ }
+ axios.post(url, {
+ text,
+ })
+ .then(({ data }) => {
+ this.ajaxCache = {
+ text: text,
+ response: data,
};
-
- return MarkdownPreview;
- }());
-
- markdownPreview = new window.MarkdownPreview();
- previewButtonSelector = '.js-md-preview-button';
- writeButtonSelector = '.js-md-write-button';
- lastTextareaPreviewed = null;
- const markdownToolbar = $('.md-header-toolbar');
-
- $.fn.setupMarkdownPreview = function () {
- var $form = $(this);
- $form.find('textarea.markdown-area').on('input', function () {
- markdownPreview.hideReferencedUsers($form);
- });
- };
-
- $(document).on('markdown-preview:show', function (e, $form) {
- if (!$form) {
- return;
+ success(data);
+ })
+ .catch(() => flash(__('An error occurred while fetching markdown preview')));
+};
+
+MarkdownPreview.prototype.hideReferencedUsers = function ($form) {
+ $form.find('.referenced-users').hide();
+};
+
+MarkdownPreview.prototype.renderReferencedUsers = function (users, $form) {
+ var referencedUsers;
+ referencedUsers = $form.find('.referenced-users');
+ if (referencedUsers.length) {
+ if (users.length >= this.referenceThreshold) {
+ referencedUsers.show();
+ referencedUsers.find('.js-referenced-users-count').text(users.length);
+ } else {
+ referencedUsers.hide();
}
-
- lastTextareaPreviewed = $form.find('textarea.markdown-area');
- lastTextareaHeight = lastTextareaPreviewed.height();
-
- // toggle tabs
- $form.find(writeButtonSelector).parent().removeClass('active');
- $form.find(previewButtonSelector).parent().addClass('active');
-
- // toggle content
- $form.find('.md-write-holder').hide();
- $form.find('.md-preview-holder').show();
- markdownToolbar.removeClass('active');
- markdownPreview.showPreview($form);
- });
-
- $(document).on('markdown-preview:hide', function (e, $form) {
- if (!$form) {
- return;
- }
- lastTextareaPreviewed = null;
-
- if (lastTextareaHeight) {
- $form.find('textarea.markdown-area').height(lastTextareaHeight);
- }
-
- // toggle tabs
- $form.find(writeButtonSelector).parent().addClass('active');
- $form.find(previewButtonSelector).parent().removeClass('active');
-
- // toggle content
- $form.find('.md-write-holder').show();
- $form.find('textarea.markdown-area').focus();
- $form.find('.md-preview-holder').hide();
- markdownToolbar.addClass('active');
-
- markdownPreview.hideReferencedCommands($form);
- });
-
- $(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
- var $target;
- $target = $(keyboardEvent.target);
- if ($target.is('textarea.markdown-area')) {
- $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]);
- keyboardEvent.preventDefault();
- } else if (lastTextareaPreviewed) {
- $target = lastTextareaPreviewed;
- $(document).triggerHandler('markdown-preview:hide', [$target.closest('form')]);
- keyboardEvent.preventDefault();
- }
- });
-
- $(document).on('click', previewButtonSelector, function (e) {
- var $form;
- e.preventDefault();
- $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:show', [$form]);
+ }
+};
+
+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();
+ }
+};
+
+markdownPreview = new MarkdownPreview();
+
+previewButtonSelector = '.js-md-preview-button';
+writeButtonSelector = '.js-md-write-button';
+lastTextareaPreviewed = null;
+const markdownToolbar = $('.md-header-toolbar');
+
+$.fn.setupMarkdownPreview = function () {
+ var $form = $(this);
+ $form.find('textarea.markdown-area').on('input', function () {
+ markdownPreview.hideReferencedUsers($form);
});
+};
+
+$(document).on('markdown-preview:show', function (e, $form) {
+ if (!$form) {
+ return;
+ }
+
+ lastTextareaPreviewed = $form.find('textarea.markdown-area');
+ lastTextareaHeight = lastTextareaPreviewed.height();
+
+ // toggle tabs
+ $form.find(writeButtonSelector).parent().removeClass('active');
+ $form.find(previewButtonSelector).parent().addClass('active');
+
+ // toggle content
+ $form.find('.md-write-holder').hide();
+ $form.find('.md-preview-holder').show();
+ markdownToolbar.removeClass('active');
+ markdownPreview.showPreview($form);
+});
+
+$(document).on('markdown-preview:hide', function (e, $form) {
+ if (!$form) {
+ return;
+ }
+ lastTextareaPreviewed = null;
- $(document).on('click', writeButtonSelector, function (e) {
- var $form;
- e.preventDefault();
- $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:hide', [$form]);
- });
-}());
+ if (lastTextareaHeight) {
+ $form.find('textarea.markdown-area').height(lastTextareaHeight);
+ }
+
+ // toggle tabs
+ $form.find(writeButtonSelector).parent().addClass('active');
+ $form.find(previewButtonSelector).parent().removeClass('active');
+
+ // toggle content
+ $form.find('.md-write-holder').show();
+ $form.find('textarea.markdown-area').focus();
+ $form.find('.md-preview-holder').hide();
+ markdownToolbar.addClass('active');
+
+ markdownPreview.hideReferencedCommands($form);
+});
+
+$(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
+ var $target;
+ $target = $(keyboardEvent.target);
+ if ($target.is('textarea.markdown-area')) {
+ $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]);
+ keyboardEvent.preventDefault();
+ } else if (lastTextareaPreviewed) {
+ $target = lastTextareaPreviewed;
+ $(document).triggerHandler('markdown-preview:hide', [$target.closest('form')]);
+ keyboardEvent.preventDefault();
+ }
+});
+
+$(document).on('click', previewButtonSelector, function (e) {
+ var $form;
+ e.preventDefault();
+ $form = $(this).closest('form');
+ $(document).triggerHandler('markdown-preview:show', [$form]);
+});
+
+$(document).on('click', writeButtonSelector, function (e) {
+ var $form;
+ e.preventDefault();
+ $form = $(this).closest('form');
+ $(document).triggerHandler('markdown-preview:hide', [$form]);
+});
+
+export default MarkdownPreview;
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
index 78be6b6e884..f50002afbf2 100644
--- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -1,9 +1,12 @@
<script>
- import modal from '../../../vue_shared/components/modal.vue';
- import { __, s__, sprintf } from '../../../locale';
- import csrf from '../../../lib/utils/csrf';
+ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+ import { __, s__, sprintf } from '~/locale';
+ import csrf from '~/lib/utils/csrf';
export default {
+ components: {
+ DeprecatedModal,
+ },
props: {
actionUrl: {
type: String,
@@ -22,12 +25,8 @@
return {
enteredPassword: '',
enteredUsername: '',
- isOpen: false,
};
},
- components: {
- modal,
- },
computed: {
csrfToken() {
return csrf.token;
@@ -69,78 +68,68 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`),
return this.enteredUsername === this.username;
},
- onSubmit(status) {
- if (status) {
- if (!this.canSubmit()) {
- return;
- }
-
- this.$refs.form.submit();
- }
-
- this.toggleOpen(false);
- },
- toggleOpen(isOpen) {
- this.isOpen = isOpen;
+ onSubmit() {
+ this.$refs.form.submit();
},
},
};
</script>
<template>
- <div>
- <modal
- v-if="isOpen"
- :title="s__('Profiles|Delete your account?')"
- :text="text"
- :kind="`danger ${!canSubmit() && 'disabled'}`"
- :primary-button-label="s__('Profiles|Delete account')"
- @toggle="toggleOpen"
- @submit="onSubmit">
-
- <template slot="body" slot-scope="props">
- <p v-html="props.text"></p>
+ <deprecated-modal
+ id="delete-account-modal"
+ :title="s__('Profiles|Delete your account?')"
+ :text="text"
+ kind="danger"
+ :primary-button-label="s__('Profiles|Delete account')"
+ @submit="onSubmit"
+ :submit-disabled="!canSubmit()">
- <form
- ref="form"
- :action="actionUrl"
- method="post">
+ <template
+ slot="body"
+ slot-scope="props">
+ <p v-html="props.text"></p>
- <input
- type="hidden"
- name="_method"
- value="delete" />
- <input
- type="hidden"
- name="authenticity_token"
- :value="csrfToken" />
+ <form
+ ref="form"
+ :action="actionUrl"
+ method="post">
- <p id="input-label" v-html="inputLabel"></p>
+ <input
+ type="hidden"
+ name="_method"
+ value="delete"
+ />
+ <input
+ type="hidden"
+ name="authenticity_token"
+ :value="csrfToken"
+ />
- <input
- v-if="confirmWithPassword"
- name="password"
- class="form-control"
- type="password"
- v-model="enteredPassword"
- aria-labelledby="input-label" />
- <input
- v-else
- name="username"
- class="form-control"
- type="text"
- v-model="enteredUsername"
- aria-labelledby="input-label" />
- </form>
- </template>
+ <p
+ id="input-label"
+ v-html="inputLabel"
+ >
+ </p>
- </modal>
+ <input
+ v-if="confirmWithPassword"
+ name="password"
+ class="form-control"
+ type="password"
+ v-model="enteredPassword"
+ aria-labelledby="input-label"
+ />
+ <input
+ v-else
+ name="username"
+ class="form-control"
+ type="text"
+ v-model="enteredUsername"
+ aria-labelledby="input-label"
+ />
+ </form>
+ </template>
- <button
- type="button"
- class="btn btn-danger"
- @click="toggleOpen(true)">
- {{ s__('Profiles|Delete account') }}
- </button>
- </div>
+ </deprecated-modal>
</template>
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
new file mode 100644
index 00000000000..e5de3f69b01
--- /dev/null
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -0,0 +1,121 @@
+<script>
+import _ from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+import { s__, sprintf } from '~/locale';
+import Flash from '~/flash';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ actionUrl: {
+ type: String,
+ required: true,
+ },
+ rootUrl: {
+ type: String,
+ required: true,
+ },
+ initialUsername: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isRequestPending: false,
+ username: this.initialUsername,
+ newUsername: this.initialUsername,
+ };
+ },
+ computed: {
+ path() {
+ return sprintf(s__('Profiles|Current path: %{path}'), {
+ path: `${this.rootUrl}${this.username}`,
+ });
+ },
+ modalText() {
+ return sprintf(
+ s__(`Profiles|
+You are going to change the username %{currentUsernameBold} to %{newUsernameBold}.
+Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group.
+Please update your Git repository remotes as soon as possible.`),
+ {
+ currentUsernameBold: `<strong>${_.escape(this.username)}</strong>`,
+ newUsernameBold: `<strong>${_.escape(this.newUsername)}</strong>`,
+ currentUsername: _.escape(this.username),
+ newUsername: _.escape(this.newUsername),
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ onConfirm() {
+ this.isRequestPending = true;
+ const username = this.newUsername;
+ const putData = {
+ user: {
+ username,
+ },
+ };
+
+ return axios
+ .put(this.actionUrl, putData)
+ .then(result => {
+ Flash(result.data.message, 'notice');
+ this.username = username;
+ this.isRequestPending = false;
+ })
+ .catch(error => {
+ Flash(error.response.data.message);
+ this.isRequestPending = false;
+ throw error;
+ });
+ },
+ },
+ modalId: 'username-change-confirmation-modal',
+ inputId: 'username-change-input',
+ buttonText: s__('Profiles|Update username'),
+};
+</script>
+<template>
+ <div>
+ <div class="form-group">
+ <label :for="$options.inputId">{{ s__('Profiles|Path') }}</label>
+ <div class="input-group">
+ <div class="input-group-addon">{{ rootUrl }}</div>
+ <input
+ :id="$options.inputId"
+ class="form-control"
+ required="required"
+ v-model="newUsername"
+ :disabled="isRequestPending"
+ />
+ </div>
+ <p class="help-block">
+ {{ path }}
+ </p>
+ </div>
+ <button
+ :data-target="`#${$options.modalId}`"
+ class="btn btn-warning"
+ type="button"
+ data-toggle="modal"
+ :disabled="isRequestPending || newUsername === username"
+ >
+ {{ $options.buttonText }}
+ </button>
+ <gl-modal
+ :id="$options.modalId"
+ :header-title-text="s__('Profiles|Change username') + '?'"
+ footer-primary-button-variant="warning"
+ :footer-primary-button-text="$options.buttonText"
+ @submit="onConfirm"
+ >
+ <span v-html="modalText"></span>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
index 635056e0eeb..59c13e1a042 100644
--- a/app/assets/javascripts/profile/account/index.js
+++ b/app/assets/javascripts/profile/account/index.js
@@ -1,21 +1,44 @@
import Vue from 'vue';
-
+import Translate from '~/vue_shared/translate';
+import UpdateUsername from './components/update_username.vue';
import deleteAccountModal from './components/delete_account_modal.vue';
-const deleteAccountModalEl = document.getElementById('delete-account-modal');
-// eslint-disable-next-line no-new
-new Vue({
- el: deleteAccountModalEl,
- components: {
- deleteAccountModal,
- },
- render(createElement) {
- return createElement('delete-account-modal', {
- props: {
- actionUrl: deleteAccountModalEl.dataset.actionUrl,
- confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword,
- username: deleteAccountModalEl.dataset.username,
- },
- });
- },
-});
+export default () => {
+ Vue.use(Translate);
+
+ const updateUsernameElement = document.getElementById('update-username');
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: updateUsernameElement,
+ components: {
+ UpdateUsername,
+ },
+ render(createElement) {
+ return createElement('update-username', {
+ props: { ...updateUsernameElement.dataset },
+ });
+ },
+ });
+
+ const deleteAccountButton = document.getElementById('delete-account-button');
+ const deleteAccountModalEl = document.getElementById('delete-account-modal');
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: deleteAccountModalEl,
+ components: {
+ deleteAccountModal,
+ },
+ mounted() {
+ deleteAccountButton.classList.remove('disabled');
+ },
+ render(createElement) {
+ return createElement('delete-account-modal', {
+ props: {
+ actionUrl: deleteAccountModalEl.dataset.actionUrl,
+ confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword,
+ username: deleteAccountModalEl.dataset.username,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 4bdda611cfc..8f93156cdd1 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -1,5 +1,6 @@
/* 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 $ from 'jquery';
import 'cropper';
import _ from 'underscore';
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 0dc02f012e4..0af34657d72 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,101 +1,84 @@
/* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */
-import Flash from '../flash';
-import { getPagePath } from '../lib/utils/common_utils';
-((global) => {
- class Profile {
- constructor({ form } = {}) {
- this.onSubmitForm = this.onSubmitForm.bind(this);
- this.form = form || $('.edit-user');
- this.bindEvents();
- this.initAvatarGlCrop();
- }
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import flash from '../flash';
- initAvatarGlCrop() {
- const cropOpts = {
- filename: '.js-avatar-filename',
- previewImage: '.avatar-image .avatar',
- modalCrop: '.modal-profile-crop',
- pickImageEl: '.js-choose-user-avatar-button',
- uploadImageBtn: '.js-upload-user-avatar',
- modalCropImg: '.modal-profile-crop-image'
- };
- this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
- }
+export default class Profile {
+ constructor({ form } = {}) {
+ this.onSubmitForm = this.onSubmitForm.bind(this);
+ this.form = form || $('.edit-user');
+ this.setRepoRadio();
+ this.bindEvents();
+ this.initAvatarGlCrop();
+ }
- bindEvents() {
- $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
- $('#user_notification_email').on('change', this.submitForm);
- $('#user_notified_of_own_activity').on('change', this.submitForm);
- $('.update-username').on('ajax:before', this.beforeUpdateUsername);
- $('.update-username').on('ajax:complete', this.afterUpdateUsername);
- $('.update-notifications').on('ajax:success', this.onUpdateNotifs);
- this.form.on('submit', this.onSubmitForm);
- }
+ initAvatarGlCrop() {
+ const cropOpts = {
+ filename: '.js-avatar-filename',
+ previewImage: '.avatar-image .avatar',
+ modalCrop: '.modal-profile-crop',
+ pickImageEl: '.js-choose-user-avatar-button',
+ uploadImageBtn: '.js-upload-user-avatar',
+ modalCropImg: '.modal-profile-crop-image',
+ };
+ this.avatarGlCrop = $('.js-user-avatar-input')
+ .glCrop(cropOpts)
+ .data('glcrop');
+ }
- submitForm() {
- return $(this).parents('form').submit();
- }
+ bindEvents() {
+ $('.js-preferences-form').on(
+ 'change.preference',
+ 'input[type=radio]',
+ this.submitForm,
+ );
+ $('#user_notification_email').on('change', this.submitForm);
+ $('#user_notified_of_own_activity').on('change', this.submitForm);
+ this.form.on('submit', this.onSubmitForm);
+ }
- onSubmitForm(e) {
- e.preventDefault();
- return this.saveForm();
- }
+ submitForm() {
+ return $(this)
+ .parents('form')
+ .submit();
+ }
- beforeUpdateUsername() {
- $('.loading-username', this).removeClass('hidden');
- }
+ onSubmitForm(e) {
+ e.preventDefault();
+ return this.saveForm();
+ }
- afterUpdateUsername() {
- $('.loading-username', this).addClass('hidden');
- $('button[type=submit]', this).enable();
- }
+ saveForm() {
+ const self = this;
+ const formData = new FormData(this.form[0]);
+ const avatarBlob = this.avatarGlCrop.getBlob();
- onUpdateNotifs(e, data) {
- return data.saved ?
- new Flash("Notification settings saved", "notice") :
- new Flash("Failed to save new settings", "alert");
+ if (avatarBlob != null) {
+ formData.append('user[avatar]', avatarBlob, 'avatar.png');
}
- saveForm() {
- const self = this;
- const formData = new FormData(this.form[0]);
- const avatarBlob = this.avatarGlCrop.getBlob();
-
- if (avatarBlob != null) {
- formData.append('user[avatar]', avatarBlob, 'avatar.png');
- }
-
- return $.ajax({
- url: this.form.attr('action'),
- type: this.form.attr('method'),
- data: formData,
- dataType: "json",
- processData: false,
- contentType: false,
- success: response => new Flash(response.message, 'notice'),
- error: jqXHR => new Flash(jqXHR.responseJSON.message, 'alert'),
- complete: () => {
- window.scrollTo(0, 0);
- // Enable submit button after requests ends
- return self.form.find(':input[disabled]').enable();
- }
- });
- }
+ axios({
+ method: this.form.attr('method'),
+ url: this.form.attr('action'),
+ data: formData,
+ })
+ .then(({ data }) => flash(data.message, 'notice'))
+ .then(() => {
+ window.scrollTo(0, 0);
+ // Enable submit button after requests ends
+ self.form.find(':input[disabled]').enable();
+ })
+ .catch(error => flash(error.message));
}
- $(function() {
- $(document).on('input.ssh_key', '#key_key', function() {
- const $title = $('#key_title');
- const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/);
-
- // Extract the SSH Key title from its comment
- if (comment && comment.length > 1) {
- return $title.val(comment[1]).change();
- }
- });
- if (getPagePath() === 'profiles') {
- return new Profile();
+ setRepoRadio() {
+ const multiEditRadios = $('input[name="user[multi_file]"]');
+ if (this.newRepoActivated || this.newRepoActivated === 'true') {
+ multiEditRadios.filter('[value=on]').prop('checked', true);
+ } else {
+ multiEditRadios.filter('[value=off]').prop('checked', true);
}
- });
-})(window.gl || (window.gl = {}));
+ }
+}
diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js
deleted file mode 100644
index ff35a9bcb83..00000000000
--- a/app/assets/javascripts/profile/profile_bundle.js
+++ /dev/null
@@ -1,2 +0,0 @@
-import './gl_crop';
-import './profile';
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
deleted file mode 100644
index d4f26b81f30..00000000000
--- a/app/assets/javascripts/project.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, consistent-return, no-new, prefer-arrow-callback, no-return-assign, one-var, one-var-declaration-per-line, object-shorthand, no-else-return, newline-per-chained-call, no-shadow, vars-on-top, prefer-template, max-len */
-
-import Cookies from 'js-cookie';
-import { visitUrl } from './lib/utils/url_utility';
-import projectSelect from './project_select';
-
-export default class Project {
- constructor() {
- 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');
- const activeText = $this.find('.dropdown-menu-inner-title').text();
-
- e.preventDefault();
-
- $('.is-active', $cloneOptions).not($this).removeClass('is-active');
- $this.toggleClass('is-active');
- $projectCloneField.val(url);
- $cloneBtnText.text(activeText);
-
- return $('.clone').text(url);
- });
- // Ref switcher
- Project.initRefSwitcher();
- $('.project-refs-select').on('change', function() {
- return $(this).parents('form').submit();
- });
- $('.hide-no-ssh-message').on('click', function(e) {
- Cookies.set('hide_no_ssh_message', 'false');
- $(this).parents('.no-ssh-key-message').remove();
- return e.preventDefault();
- });
- $('.hide-no-password-message').on('click', function(e) {
- Cookies.set('hide_no_password_message', 'false');
- $(this).parents('.no-password-message').remove();
- return e.preventDefault();
- });
- Project.projectSelectDropdown();
- }
-
- static projectSelectDropdown () {
- projectSelect();
- $('.project-item-select').on('click', e => Project.changeProject($(e.currentTarget).val()));
- }
-
- static changeProject(url) {
- return window.location = url;
- }
-
- static initRefSwitcher() {
- var refListItem = document.createElement('li');
- var refLink = document.createElement('a');
-
- refLink.href = '#';
-
- return $('.js-project-refs-dropdown').each(function() {
- var $dropdown, selected;
- $dropdown = $(this);
- selected = $dropdown.data('selected');
- return $dropdown.glDropdown({
- data: function(term, callback) {
- return $.ajax({
- url: $dropdown.data('refs-url'),
- data: {
- ref: $dropdown.data('ref'),
- search: term,
- },
- dataType: 'json',
- }).done(function(refs) {
- return callback(refs);
- });
- },
- selectable: true,
- 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);
-
- if (ref.header != null) {
- li.className = 'dropdown-header';
- li.textContent = ref.header;
- } else {
- var link = refLink.cloneNode(false);
-
- if (ref === selected) {
- link.className = 'is-active';
- }
-
- link.textContent = ref;
- link.dataset.ref = ref;
-
- li.appendChild(link);
- }
-
- return li;
- },
- id: function(obj, $el) {
- return $el.attr('data-ref');
- },
- toggleLabel: function(obj, $el) {
- return $el.text().trim();
- },
- 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 ? '?' : '&';
- if (shouldVisit) {
- visitUrl(`${action}${divider}${$form.serialize()}`);
- }
- }
- },
- });
- });
- }
-}
diff --git a/app/assets/javascripts/project_avatar.js b/app/assets/javascripts/project_avatar.js
deleted file mode 100644
index 56627aa155c..00000000000
--- a/app/assets/javascripts/project_avatar.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export default function projectAvatar() {
- $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() {
- const form = $(this).closest('form');
- return form.find('.js-project-avatar-input').click();
- });
-
- $('.js-project-avatar-input').bind('change', function onClickAvatarInput() {
- const form = $(this).closest('form');
- // eslint-disable-next-line no-useless-escape
- const filename = $(this).val().replace(/^.*[\\\/]/, '');
- return form.find('.js-avatar-filename').text(filename);
- });
-}
diff --git a/app/assets/javascripts/project_edit.js b/app/assets/javascripts/project_edit.js
index 7572fec15e0..47bf2226781 100644
--- a/app/assets/javascripts/project_edit.js
+++ b/app/assets/javascripts/project_edit.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default function setupProjectEdit() {
const $transferForm = $('.js-project-transfer-form');
const $selectNamespace = $transferForm.find('select.select2');
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index 0da32b4a3cc..4c4acd487f8 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -1,6 +1,10 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, consistent-return, one-var, one-var-declaration-per-line, no-cond-assign, max-len, object-shorthand, no-param-reassign, comma-dangle, prefer-template, no-unused-vars, no-return-assign */
+import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { __ } from '~/locale';
// highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> )
const highlighter = function(element, text, matches) {
@@ -70,24 +74,19 @@ export default class ProjectFindFile {
// find file
}
- // files pathes load
+ // files pathes load
load(url) {
- return $.ajax({
- url: url,
- method: "get",
- dataType: "json",
- success: (function(_this) {
- return function(data) {
- _this.element.find(".loading").hide();
- _this.filePaths = data;
- _this.findFile();
- return _this.element.find(".files-slider tr.tree-item").eq(0).addClass("selected").focus();
- };
- })(this)
- });
+ axios.get(url)
+ .then(({ data }) => {
+ this.element.find('.loading').hide();
+ this.filePaths = data;
+ this.findFile();
+ this.element.find('.files-slider tr.tree-item').eq(0).addClass('selected').focus();
+ })
+ .catch(() => flash(__('An error occurred while loading filenames')));
}
- // render result
+ // render result
renderList(filePaths, searchText) {
var blobItemUrl, filePath, html, i, j, len, matches, results;
this.element.find(".tree-table > tbody").empty();
diff --git a/app/assets/javascripts/project_fork.js b/app/assets/javascripts/project_fork.js
index 65d46fa9a73..6fedd94a6a9 100644
--- a/app/assets/javascripts/project_fork.js
+++ b/app/assets/javascripts/project_fork.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default () => {
$('.js-fork-thumbnail').on('click', function forkThumbnailClicked() {
if ($(this).hasClass('disabled')) return false;
diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js
index b65521b278f..f31beb4dc78 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/project_label_subscription.js
@@ -1,3 +1,8 @@
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import flash from './flash';
+
export default class ProjectLabelSubscription {
constructor(container) {
this.$container = $(container);
@@ -17,10 +22,7 @@ export default class ProjectLabelSubscription {
$btn.addClass('disabled');
$span.toggleClass('hidden');
- $.ajax({
- type: 'POST',
- url,
- }).done(() => {
+ axios.post(url).then(() => {
let newStatus;
let newAction;
@@ -45,6 +47,6 @@ export default class ProjectLabelSubscription {
return button;
});
- });
+ }).catch(() => flash(__('There was an error subscribing to this label.')));
}
}
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
deleted file mode 100644
index ca548d011b6..00000000000
--- a/app/assets/javascripts/project_new.js
+++ /dev/null
@@ -1,151 +0,0 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, prefer-template, prefer-arrow-callback*/
-
-import VisibilitySelect from './visibility_select';
-
-function highlightChanges($elm) {
- $elm.addClass('highlight-changes');
- setTimeout(() => $elm.removeClass('highlight-changes'), 10);
-}
-
-export default class ProjectNew {
- constructor() {
- 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', () => {
- $('.project-edit-container').hide();
- return $('.save-project-loader').show();
- });
-
- this.initVisibilitySelect();
-
- this.toggleSettings();
- this.toggleSettingsOnclick();
- this.toggleRepoVisibility();
- }
-
- initVisibilitySelect() {
- const visibilityContainer = document.querySelector('.js-visibility-select');
- if (!visibilityContainer) return;
- 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;
- }
- });
- }
-
- toggleSettings() {
- this.$selects.each(function () {
- var $select = $(this);
- var className = $select.data('field')
- .replace(/_/g, '-')
- .replace('access-level', 'feature');
- ProjectNew._showOrHide($select, '.' + className);
- });
- }
-
- toggleSettingsOnclick() {
- this.$selects.on('change', this.toggleSettings);
- }
-
- static _showOrHide(checkElement, container) {
- const $container = $(container);
-
- if ($(checkElement).val() !== '0') {
- return $container.show();
- }
- return $container.hide();
- }
-
- toggleRepoVisibility() {
- 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()
- .hide();
-
- $repoAccessLevel
- .off('change')
- .on('change', function () {
- var selectedVal = parseInt($repoAccessLevel.val(), 10);
-
- this.$repoSelects.each(function () {
- var $this = $(this);
- var repoSelectVal = parseInt($this.val(), 10);
-
- $this.find('option').enable();
-
- if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
- $this.val(selectedVal).trigger('change');
- highlightChanges($this);
- }
-
- $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 07a49d1506c..cb2e6855d1d 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,17 +1,19 @@
/* 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 */
+
+import $ from 'jquery';
import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
export default function projectSelect() {
$('.ajax-project-select').each(function(i, select) {
var placeholder;
- const simpleFilter = $(select).data('simple-filter') || false;
- this.groupId = $(select).data('group-id');
- this.includeGroups = $(select).data('include-groups');
- this.allProjects = $(select).data('all-projects') || false;
- this.orderBy = $(select).data('order-by') || 'id';
- this.withIssuesEnabled = $(select).data('with-issues-enabled');
- this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled');
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ this.groupId = $(select).data('groupId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
placeholder = "Search for project";
if (this.includeGroups) {
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
index 99cea683d9a..9b404896e86 100644
--- a/app/assets/javascripts/project_select_combo_button.js
+++ b/app/assets/javascripts/project_select_combo_button.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import AccessorUtilities from './lib/utils/accessor';
export default class ProjectSelectComboButton {
diff --git a/app/assets/javascripts/project_visibility.js b/app/assets/javascripts/project_visibility.js
index c3f5e8cb907..7c95c71e239 100644
--- a/app/assets/javascripts/project_visibility.js
+++ b/app/assets/javascripts/project_visibility.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
function setVisibilityOptions(namespaceSelector) {
if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) {
return;
diff --git a/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue b/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue
deleted file mode 100644
index 8fce4c63872..00000000000
--- a/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-<script>
-import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
-
-export default {
- props: {
- name: {
- type: String,
- required: false,
- default: '',
- },
- options: {
- type: Array,
- required: false,
- default: () => [],
- },
- value: {
- type: Number,
- required: false,
- default: 0,
- },
- disabledInput: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- components: {
- projectFeatureToggle,
- },
-
- computed: {
- featureEnabled() {
- return this.value !== 0;
- },
-
- displayOptions() {
- if (this.featureEnabled) {
- return this.options;
- }
- return [
- [0, 'Enable feature to choose access level'],
- ];
- },
-
- displaySelectInput() {
- return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2;
- },
- },
-
- model: {
- prop: 'value',
- event: 'change',
- },
-
- methods: {
- toggleFeature(featureEnabled) {
- if (featureEnabled === false || this.options.length < 1) {
- this.$emit('change', 0);
- } else {
- const [firstOptionValue] = this.options[this.options.length - 1];
- this.$emit('change', firstOptionValue);
- }
- },
-
- selectOption(e) {
- this.$emit('change', Number(e.target.value));
- },
- },
-};
-</script>
-
-<template>
- <div class="project-feature-controls" :data-for="name">
- <input
- v-if="name"
- type="hidden"
- :name="name"
- :value="value"
- />
- <project-feature-toggle
- :value="featureEnabled"
- @change="toggleFeature"
- :disabledInput="disabledInput"
- />
- <div class="select-wrapper">
- <select
- class="form-control project-repo-select select-control"
- @change="selectOption"
- :disabled="displaySelectInput"
- >
- <option
- v-for="[optionValue, optionName] in displayOptions"
- :key="optionValue"
- :value="optionValue"
- :selected="optionValue === value"
- >
- {{optionName}}
- </option>
- </select>
- <i aria-hidden="true" class="fa fa-chevron-down"></i>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/projects/permissions/components/project_setting_row.vue b/app/assets/javascripts/projects/permissions/components/project_setting_row.vue
deleted file mode 100644
index 6140d74fea8..00000000000
--- a/app/assets/javascripts/projects/permissions/components/project_setting_row.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<script>
-export default {
- props: {
- label: {
- type: String,
- required: false,
- default: null,
- },
- helpPath: {
- type: String,
- required: false,
- default: null,
- },
- helpText: {
- type: String,
- required: false,
- default: null,
- },
- },
-};
-</script>
-
-<template>
- <div class="project-feature-row">
- <label v-if="label" class="label-light">
- {{label}}
- <a v-if="helpPath" :href="helpPath" target="_blank">
- <i aria-hidden="true" data-hidden="true" class="fa fa-question-circle"></i>
- </a>
- </label>
- <span v-if="helpText" class="help-block">
- {{helpText}}
- </span>
- <slot />
- </div>
-</template>
diff --git a/app/assets/javascripts/projects/permissions/components/settings_panel.vue b/app/assets/javascripts/projects/permissions/components/settings_panel.vue
deleted file mode 100644
index 639429baf26..00000000000
--- a/app/assets/javascripts/projects/permissions/components/settings_panel.vue
+++ /dev/null
@@ -1,312 +0,0 @@
-<script>
-import projectFeatureSetting from './project_feature_setting.vue';
-import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
-import projectSettingRow from './project_setting_row.vue';
-import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
-import { toggleHiddenClassBySelector } from '../external';
-
-export default {
- props: {
- currentSettings: {
- type: Object,
- required: true,
- },
- canChangeVisibilityLevel: {
- type: Boolean,
- required: false,
- default: false,
- },
- allowedVisibilityOptions: {
- type: Array,
- required: false,
- default: () => [0, 10, 20],
- },
- lfsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- registryAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- visibilityHelpPath: {
- type: String,
- required: false,
- },
- lfsHelpPath: {
- type: String,
- required: false,
- },
- registryHelpPath: {
- type: String,
- required: false,
- },
- },
-
- data() {
- const defaults = {
- visibilityOptions,
- visibilityLevel: visibilityOptions.PUBLIC,
- issuesAccessLevel: 20,
- repositoryAccessLevel: 20,
- mergeRequestsAccessLevel: 20,
- buildsAccessLevel: 20,
- wikiAccessLevel: 20,
- snippetsAccessLevel: 20,
- containerRegistryEnabled: true,
- lfsEnabled: true,
- requestAccessEnabled: true,
- highlightChangesClass: false,
- };
-
- return { ...defaults, ...this.currentSettings };
- },
-
- components: {
- projectFeatureSetting,
- projectFeatureToggle,
- projectSettingRow,
- },
-
- computed: {
- featureAccessLevelOptions() {
- const options = [
- [10, 'Only Project Members'],
- ];
- if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
- options.push([20, 'Everyone With Access']);
- }
- return options;
- },
-
- repoFeatureAccessLevelOptions() {
- return this.featureAccessLevelOptions.filter(
- ([value]) => value <= this.repositoryAccessLevel,
- );
- },
-
- repositoryEnabled() {
- return this.repositoryAccessLevel > 0;
- },
-
- visibilityLevelDescription() {
- return visibilityLevelDescriptions[this.visibilityLevel];
- },
- },
-
- methods: {
- highlightChanges() {
- this.highlightChangesClass = true;
- this.$nextTick(() => {
- this.highlightChangesClass = false;
- });
- },
-
- visibilityAllowed(option) {
- return this.allowedVisibilityOptions.includes(option);
- },
- },
-
- watch: {
- visibilityLevel(value, oldValue) {
- if (value === visibilityOptions.PRIVATE) {
- // when private, features are restricted to "only team members"
- this.issuesAccessLevel = Math.min(10, this.issuesAccessLevel);
- this.repositoryAccessLevel = Math.min(10, this.repositoryAccessLevel);
- this.mergeRequestsAccessLevel = Math.min(10, this.mergeRequestsAccessLevel);
- this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel);
- this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel);
- this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel);
- this.highlightChanges();
- } else if (oldValue === visibilityOptions.PRIVATE) {
- // if changing away from private, make enabled features more permissive
- if (this.issuesAccessLevel > 0) this.issuesAccessLevel = 20;
- if (this.repositoryAccessLevel > 0) this.repositoryAccessLevel = 20;
- if (this.mergeRequestsAccessLevel > 0) this.mergeRequestsAccessLevel = 20;
- if (this.buildsAccessLevel > 0) this.buildsAccessLevel = 20;
- if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20;
- if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20;
- this.highlightChanges();
- }
- },
-
- repositoryAccessLevel(value, oldValue) {
- if (value < oldValue) {
- // sub-features cannot have more premissive access level
- this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value);
- this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value);
-
- if (value === 0) {
- this.containerRegistryEnabled = false;
- this.lfsEnabled = false;
- }
- } else if (oldValue === 0) {
- this.mergeRequestsAccessLevel = value;
- this.buildsAccessLevel = value;
- this.containerRegistryEnabled = true;
- this.lfsEnabled = true;
- }
- },
-
- issuesAccessLevel(value, oldValue) {
- if (value === 0) toggleHiddenClassBySelector('.issues-feature', true);
- else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false);
- },
-
- mergeRequestsAccessLevel(value, oldValue) {
- if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true);
- else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false);
- },
-
- buildsAccessLevel(value, oldValue) {
- if (value === 0) toggleHiddenClassBySelector('.builds-feature', true);
- else if (oldValue === 0) toggleHiddenClassBySelector('.builds-feature', false);
- },
- },
-};
-
-</script>
-
-<template>
- <div>
- <div class="project-visibility-setting">
- <project-setting-row
- label="Project visibility"
- :help-path="visibilityHelpPath"
- >
- <div class="project-feature-controls">
- <div class="select-wrapper">
- <select
- name="project[visibility_level]"
- v-model="visibilityLevel"
- class="form-control select-control"
- :disabled="!canChangeVisibilityLevel"
- >
- <option
- :value="visibilityOptions.PRIVATE"
- :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
- >
- Private
- </option>
- <option
- :value="visibilityOptions.INTERNAL"
- :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
- >
- Internal
- </option>
- <option
- :value="visibilityOptions.PUBLIC"
- :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
- >
- Public
- </option>
- </select>
- <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
- </div>
- </div>
- <span class="help-block">{{ visibilityLevelDescription }}</span>
- <label v-if="visibilityLevel !== visibilityOptions.PUBLIC" class="request-access">
- <input
- type="hidden"
- name="project[request_access_enabled]"
- :value="requestAccessEnabled"
- />
- <input type="checkbox" v-model="requestAccessEnabled" />
- Allow users to request access
- </label>
- </project-setting-row>
- </div>
- <div class="project-feature-settings" :class="{ 'highlight-changes': highlightChangesClass }">
- <project-setting-row
- label="Issues"
- help-text="Lightweight issue tracking system for this project"
- >
- <project-feature-setting
- name="project[project_feature_attributes][issues_access_level]"
- :options="featureAccessLevelOptions"
- v-model="issuesAccessLevel"
- />
- </project-setting-row>
- <project-setting-row
- label="Repository"
- help-text="View and edit files in this project"
- >
- <project-feature-setting
- name="project[project_feature_attributes][repository_access_level]"
- :options="featureAccessLevelOptions"
- v-model="repositoryAccessLevel"
- />
- </project-setting-row>
- <div class="project-feature-setting-group">
- <project-setting-row
- label="Merge requests"
- help-text="Submit changes to be merged upstream"
- >
- <project-feature-setting
- name="project[project_feature_attributes][merge_requests_access_level]"
- :options="repoFeatureAccessLevelOptions"
- v-model="mergeRequestsAccessLevel"
- :disabledInput="!repositoryEnabled"
- />
- </project-setting-row>
- <project-setting-row
- label="Pipelines"
- help-text="Build, test, and deploy your changes"
- >
- <project-feature-setting
- name="project[project_feature_attributes][builds_access_level]"
- :options="repoFeatureAccessLevelOptions"
- v-model="buildsAccessLevel"
- :disabledInput="!repositoryEnabled"
- />
- </project-setting-row>
- <project-setting-row
- v-if="registryAvailable"
- label="Container registry"
- :help-path="registryHelpPath"
- help-text="Every project can have its own space to store its Docker images"
- >
- <project-feature-toggle
- name="project[container_registry_enabled]"
- v-model="containerRegistryEnabled"
- :disabledInput="!repositoryEnabled"
- />
- </project-setting-row>
- <project-setting-row
- v-if="lfsAvailable"
- label="Git Large File Storage"
- :help-path="lfsHelpPath"
- help-text="Manages large files such as audio, video, and graphics files"
- >
- <project-feature-toggle
- name="project[lfs_enabled]"
- v-model="lfsEnabled"
- :disabledInput="!repositoryEnabled"
- />
- </project-setting-row>
- </div>
- <project-setting-row
- label="Wiki"
- help-text="Pages for project documentation"
- >
- <project-feature-setting
- name="project[project_feature_attributes][wiki_access_level]"
- :options="featureAccessLevelOptions"
- v-model="wikiAccessLevel"
- />
- </project-setting-row>
- <project-setting-row
- label="Snippets"
- help-text="Share code pastes with others out of Git repository"
- >
- <project-feature-setting
- name="project[project_feature_attributes][snippets_access_level]"
- :options="featureAccessLevelOptions"
- v-model="snippetsAccessLevel"
- />
- </project-setting-row>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js
index cec6f0dd5a3..4e20fce1460 100644
--- a/app/assets/javascripts/projects/project_import_gitlab_project.js
+++ b/app/assets/javascripts/projects/project_import_gitlab_project.js
@@ -1,14 +1,9 @@
+import $ from 'jquery';
import { getParameterValues } from '../lib/utils/url_utility';
-const bindEvents = () => {
+export default () => {
const path = 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
index 3ecc0c2a6e5..93603dfc14d 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,10 @@
+import $ from 'jquery';
+import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
+
let hasUserDefinedProjectPath = false;
-const deriveProjectPathFromUrl = ($projectImportUrl, $projectPath) => {
+const deriveProjectPathFromUrl = ($projectImportUrl) => {
+ const $currentProjectPath = $projectImportUrl.parents('.toggle-import-form').find('#project_path');
if (hasUserDefinedProjectPath) {
return;
}
@@ -21,7 +25,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl, $projectPath) => {
// extract everything after the last slash
const pathMatch = /\/([^/]+)$/.exec(importUrl);
if (pathMatch) {
- $projectPath.val(pathMatch[1]);
+ $currentProjectPath.val(pathMatch[1]);
}
};
@@ -35,6 +39,7 @@ const bindEvents = () => {
const $changeTemplateBtn = $('.change-template');
const $selectedIcon = $('.selected-icon svg');
const $templateProjectNameInput = $('#template-project-name #project_path');
+ const $pushNewProjectTipTrigger = $('.push-new-project-tip');
if ($newProjectForm.length !== 1) {
return;
@@ -54,6 +59,34 @@ const bindEvents = () => {
$('.btn_import_gitlab_project').attr('href', `${importHref}?namespace_id=${$('#project_namespace_id').val()}&path=${$projectPath.val()}`);
});
+ if ($pushNewProjectTipTrigger) {
+ $pushNewProjectTipTrigger
+ .removeAttr('rel')
+ .removeAttr('target')
+ .on('click', (e) => { e.preventDefault(); })
+ .popover({
+ title: $pushNewProjectTipTrigger.data('title'),
+ placement: 'auto bottom',
+ html: 'true',
+ content: $('.push-new-project-tip-template').html(),
+ })
+ .on('shown.bs.popover', () => {
+ $(document).on('click.popover touchstart.popover', (event) => {
+ if ($(event.target).closest('.popover').length === 0) {
+ $pushNewProjectTipTrigger.trigger('click');
+ }
+ });
+
+ const target = $(`#${$pushNewProjectTipTrigger.attr('aria-describedby')}`).find('.js-select-on-focus');
+ addSelectOnFocusBehaviour(target);
+
+ target.focus();
+ })
+ .on('hide.bs.popover', () => {
+ $(document).off('click.popover touchstart.popover');
+ });
+ }
+
function chooseTemplate() {
$('.template-option').hide();
$projectFieldsForm.addClass('selected');
@@ -96,11 +129,9 @@ const bindEvents = () => {
hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
});
- $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl, $projectPath));
+ $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
};
-document.addEventListener('DOMContentLoaded', bindEvents);
-
export default {
bindEvents,
deriveProjectPathFromUrl,
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
new file mode 100644
index 00000000000..63f20a0041d
--- /dev/null
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -0,0 +1,120 @@
+<script>
+ import Visibility from 'visibilityjs';
+ import ciIcon from '~/vue_shared/components/ci_icon.vue';
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import Poll from '~/lib/utils/poll';
+ import Flash from '~/flash';
+ import { s__, sprintf } from '~/locale';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import CommitPipelineService from '../services/commit_pipeline_service';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ ciIcon,
+ loadingIcon,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ /* This prop can be used to replace some of the `render_commit_status`
+ used across GitLab, this way we could use this vue component and add a
+ realtime status where it makes sense
+ realtime: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }, */
+ },
+ data() {
+ return {
+ ciStatus: {},
+ isLoading: true,
+ };
+ },
+ computed: {
+ statusTitle() {
+ return sprintf(s__('Commits|Commit: %{commitText}'), { commitText: this.ciStatus.text });
+ },
+ },
+ mounted() {
+ this.service = new CommitPipelineService(this.endpoint);
+ this.initPolling();
+ },
+ methods: {
+ successCallback(res) {
+ const pipelines = res.data.pipelines;
+ if (pipelines.length > 0) {
+ // The pipeline entity always keeps the latest pipeline info on the `details.status`
+ this.ciStatus = pipelines[0].details.status;
+ }
+ this.isLoading = false;
+ },
+ errorCallback() {
+ this.ciStatus = {
+ text: 'not found',
+ icon: 'status_notfound',
+ group: 'notfound',
+ };
+ this.isLoading = false;
+ Flash(s__('Something went wrong on our end'));
+ },
+ initPolling() {
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'fetchData',
+ successCallback: response => this.successCallback(response),
+ errorCallback: this.errorCallback,
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ this.poll.makeRequest();
+ } else {
+ this.fetchPipelineCommitData();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ },
+ fetchPipelineCommitData() {
+ this.service.fetchData()
+ .then(this.successCallback)
+ .catch(this.errorCallback);
+ },
+ },
+ destroy() {
+ this.poll.stop();
+ },
+ };
+</script>
+<template>
+ <div>
+ <loading-icon
+ label="Loading pipeline status"
+ size="3"
+ v-if="isLoading"
+ />
+ <a
+ v-else
+ :href="ciStatus.details_path"
+ >
+ <ci-icon
+ v-tooltip
+ :title="statusTitle"
+ :aria-label="statusTitle"
+ data-container="body"
+ :status="ciStatus"
+ />
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/tree/services/commit_pipeline_service.js b/app/assets/javascripts/projects/tree/services/commit_pipeline_service.js
new file mode 100644
index 00000000000..4b4189bc2de
--- /dev/null
+++ b/app/assets/javascripts/projects/tree/services/commit_pipeline_service.js
@@ -0,0 +1,11 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default class CommitPipelineService {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ fetchData() {
+ return axios.get(this.endpoint);
+ }
+}
diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue
index 7606605be32..34a60dd574b 100644
--- a/app/assets/javascripts/projects_dropdown/components/app.vue
+++ b/app/assets/javascripts/projects_dropdown/components/app.vue
@@ -47,6 +47,22 @@ export default {
return this.store.getSearchedProjects();
},
},
+ created() {
+ if (this.currentProject.id) {
+ this.logCurrentProjectAccess();
+ }
+
+ eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$on('searchProjects', this.fetchSearchedProjects);
+ eventHub.$on('searchCleared', this.handleSearchClear);
+ eventHub.$on('searchFailed', this.handleSearchFailure);
+ },
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$off('searchProjects', this.fetchSearchedProjects);
+ eventHub.$off('searchCleared', this.handleSearchClear);
+ eventHub.$off('searchFailed', this.handleSearchFailure);
+ },
methods: {
toggleFrequentProjectsList(state) {
this.isLoadingProjects = !state;
@@ -108,22 +124,6 @@ export default {
this.toggleSearchProjectsList(true);
},
},
- created() {
- if (this.currentProject.id) {
- this.logCurrentProjectAccess();
- }
-
- eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
- eventHub.$on('searchProjects', this.fetchSearchedProjects);
- eventHub.$on('searchCleared', this.handleSearchClear);
- eventHub.$on('searchFailed', this.handleSearchFailure);
- },
- beforeDestroy() {
- eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
- eventHub.$off('searchProjects', this.fetchSearchedProjects);
- eventHub.$off('searchCleared', this.handleSearchClear);
- eventHub.$off('searchFailed', this.handleSearchFailure);
- },
};
</script>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
index 093554cd0bc..246dbeaaded 100644
--- a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
@@ -1,32 +1,32 @@
<script>
-import { s__ } from '../../locale';
-import projectsListItem from './projects_list_item.vue';
+ import { s__ } from '../../locale';
+ import projectsListItem from './projects_list_item.vue';
-export default {
- components: {
- projectsListItem,
- },
- props: {
- projects: {
- type: Array,
- required: true,
+ export default {
+ components: {
+ projectsListItem,
},
- localStorageFailed: {
- type: Boolean,
- required: true,
+ props: {
+ projects: {
+ type: Array,
+ required: true,
+ },
+ localStorageFailed: {
+ type: Boolean,
+ required: true,
+ },
},
- },
- computed: {
- isListEmpty() {
- return this.projects.length === 0;
+ computed: {
+ isListEmpty() {
+ return this.projects.length === 0;
+ },
+ listEmptyMessage() {
+ return this.localStorageFailed ?
+ s__('ProjectsDropdown|This feature requires browser localStorage support') :
+ s__('ProjectsDropdown|Projects you visit often will appear here');
+ },
},
- listEmptyMessage() {
- return this.localStorageFailed ?
- s__('ProjectsDropdown|This feature requires browser localStorage support') :
- s__('ProjectsDropdown|Projects you visit often will appear here');
- },
- },
-};
+ };
</script>
<template>
@@ -40,7 +40,7 @@ export default {
class="section-empty"
v-if="isListEmpty"
>
- {{listEmptyMessage}}
+ {{ listEmptyMessage }}
</li>
<projects-list-item
v-else
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
index d482a7025de..759cdd1ded9 100644
--- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
@@ -1,76 +1,77 @@
<script>
-import identicon from '../../vue_shared/components/identicon.vue';
+ /* eslint-disable vue/require-default-prop, vue/require-prop-types */
+ import identicon from '../../vue_shared/components/identicon.vue';
-export default {
- components: {
- identicon,
- },
- props: {
- matcher: {
- type: String,
- required: false,
+ export default {
+ components: {
+ identicon,
},
- projectId: {
- type: Number,
- required: true,
- },
- projectName: {
- type: String,
- required: true,
- },
- namespace: {
- type: String,
- required: true,
- },
- webUrl: {
- type: String,
- required: true,
- },
- avatarUrl: {
- required: true,
- validator(value) {
- return value === null || typeof value === 'string';
+ props: {
+ matcher: {
+ type: String,
+ required: false,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: true,
+ },
+ webUrl: {
+ type: String,
+ required: true,
+ },
+ avatarUrl: {
+ required: true,
+ validator(value) {
+ return value === null || typeof value === 'string';
+ },
},
},
- },
- computed: {
- hasAvatar() {
- return this.avatarUrl !== null;
- },
- highlightedProjectName() {
- if (this.matcher) {
- const matcherRegEx = new RegExp(this.matcher, 'gi');
- const matches = this.projectName.match(matcherRegEx);
+ computed: {
+ hasAvatar() {
+ return this.avatarUrl !== null;
+ },
+ highlightedProjectName() {
+ if (this.matcher) {
+ const matcherRegEx = new RegExp(this.matcher, 'gi');
+ const matches = this.projectName.match(matcherRegEx);
- if (matches && matches.length > 0) {
- return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
+ if (matches && matches.length > 0) {
+ return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
+ }
}
- }
- return this.projectName;
- },
- /**
- * Smartly truncates project namespace by doing two things;
- * 1. Only include Group names in path by removing project name
- * 2. Only include first and last group names in the path
- * when namespace has more than 2 groups present
- *
- * First part (removal of project name from namespace) can be
- * done from backend but doing so involves migration of
- * existing project namespaces which is not wise thing to do.
- */
- truncatedNamespace() {
- const namespaceArr = this.namespace.split(' / ');
- namespaceArr.splice(-1, 1);
- let namespace = namespaceArr.join(' / ');
+ return this.projectName;
+ },
+ /**
+ * Smartly truncates project namespace by doing two things;
+ * 1. Only include Group names in path by removing project name
+ * 2. Only include first and last group names in the path
+ * when namespace has more than 2 groups present
+ *
+ * First part (removal of project name from namespace) can be
+ * done from backend but doing so involves migration of
+ * existing project namespaces which is not wise thing to do.
+ */
+ truncatedNamespace() {
+ const namespaceArr = this.namespace.split(' / ');
+ namespaceArr.splice(-1, 1);
+ let namespace = namespaceArr.join(' / ');
- if (namespaceArr.length > 2) {
- namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
- }
+ if (namespaceArr.length > 2) {
+ namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
+ }
- return namespace;
+ return namespace;
+ },
},
- },
-};
+ };
</script>
<template>
@@ -92,7 +93,7 @@ export default {
<identicon
v-else
size-class="s32"
- :entity-id=projectId
+ :entity-id="projectId"
:entity-name="projectName"
/>
</div>
@@ -108,7 +109,7 @@ export default {
<div
class="project-namespace"
:title="namespace"
- >{{truncatedNamespace}}</div>
+ >{{ truncatedNamespace }}</div>
</div>
</a>
</li>
diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue
index 53bc76d0f2d..0c46ed184be 100644
--- a/app/assets/javascripts/projects_dropdown/components/search.vue
+++ b/app/assets/javascripts/projects_dropdown/components/search.vue
@@ -1,47 +1,47 @@
<script>
-import _ from 'underscore';
-import eventHub from '../event_hub';
+ import _ from 'underscore';
+ import eventHub from '../event_hub';
-export default {
- data() {
- return {
- searchQuery: '',
- };
- },
- watch: {
- searchQuery() {
- this.handleInput();
+ export default {
+ data() {
+ return {
+ searchQuery: '',
+ };
},
- },
- methods: {
- setFocus() {
- this.$refs.search.focus();
+ watch: {
+ searchQuery() {
+ this.handleInput();
+ },
},
- emitSearchEvents() {
- if (this.searchQuery) {
- eventHub.$emit('searchProjects', this.searchQuery);
- } else {
- eventHub.$emit('searchCleared');
- }
+ mounted() {
+ eventHub.$on('dropdownOpen', this.setFocus);
},
- /**
- * Callback function within _.debounce is intentionally
- * kept as ES5 `function() {}` instead of ES6 `() => {}`
- * as it otherwise messes up function context
- * and component reference is no longer accessible via `this`
- */
- // eslint-disable-next-line func-names
- handleInput: _.debounce(function () {
- this.emitSearchEvents();
- }, 500),
- },
- mounted() {
- eventHub.$on('dropdownOpen', this.setFocus);
- },
- beforeDestroy() {
- eventHub.$off('dropdownOpen', this.setFocus);
- },
-};
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.setFocus);
+ },
+ methods: {
+ setFocus() {
+ this.$refs.search.focus();
+ },
+ emitSearchEvents() {
+ if (this.searchQuery) {
+ eventHub.$emit('searchProjects', this.searchQuery);
+ } else {
+ eventHub.$emit('searchCleared');
+ }
+ },
+ /**
+ * Callback function within _.debounce is intentionally
+ * kept as ES5 `function() {}` instead of ES6 `() => {}`
+ * as it otherwise messes up function context
+ * and component reference is no longer accessible via `this`
+ */
+ // eslint-disable-next-line func-names
+ handleInput: _.debounce(function () {
+ this.emitSearchEvents();
+ }, 500),
+ },
+ };
</script>
<template>
@@ -59,6 +59,7 @@ export default {
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
- />
+ >
+ </i>
</div>
</template>
diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js
index 2660da3c558..e1ca70c51a6 100644
--- a/app/assets/javascripts/projects_dropdown/index.js
+++ b/app/assets/javascripts/projects_dropdown/index.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Vue from 'vue';
import Translate from '../vue_shared/translate';
@@ -19,11 +20,8 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
- $(navEl).on('show.bs.dropdown', (e) => {
- const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
- dropdownEl.one('transitionend', () => {
- eventHub.$emit('dropdownOpen');
- });
+ $(navEl).on('shown.bs.dropdown', () => {
+ eventHub.$emit('dropdownOpen');
});
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js
index 9cbd8f21f2a..7231f520933 100644
--- a/app/assets/javascripts/projects_dropdown/service/projects_service.js
+++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import Vue from 'vue';
import VueResource from 'vue-resource';
diff --git a/app/assets/javascripts/prometheus_metrics/index.js b/app/assets/javascripts/prometheus_metrics/index.js
deleted file mode 100644
index a0c43c5abe1..00000000000
--- a/app/assets/javascripts/prometheus_metrics/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-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
index 55c93923cc8..0a60f4845b2 100644
--- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
+++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
@@ -1,3 +1,7 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import { s__, n__, sprintf } from '~/locale';
+import axios from '../lib/utils/axios_utils';
import PANEL_STATE from './constants';
import { backOff } from '../lib/utils/common_utils';
@@ -18,7 +22,8 @@ export default class PrometheusMetrics {
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.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics');
+ this.helpMetricsPath = this.$monitoredMetricsPanel.data('metrics-help-path');
this.$panelToggle.on('click', e => this.handlePanelToggle(e));
}
@@ -58,57 +63,69 @@ export default class PrometheusMetrics {
populateActiveMetrics(metrics) {
let totalMonitoredMetrics = 0;
let totalMissingEnvVarMetrics = 0;
+ let totalExporters = 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;
+ if (metric.active_metrics > 0) {
+ totalExporters += 1;
+ this.$monitoredMetricsList.append(`<li>${_.escape(metric.group)}<span class="badge">${_.escape(metric.active_metrics)}</span></li>`);
+ totalMonitoredMetrics += metric.active_metrics;
+ if (metric.metrics_missing_requirements > 0) {
+ this.$missingEnvVarMetricsList.append(`<li>${_.escape(metric.group)}</li>`);
+ totalMissingEnvVarMetrics += 1;
+ }
}
});
- this.$monitoredMetricsCount.text(totalMonitoredMetrics);
- this.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+ if (totalMonitoredMetrics === 0) {
+ const emptyCommonMetricsText = sprintf(s__('PrometheusService|<p class="text-tertiary">No <a href="%{docsUrl}">common metrics</a> were found</p>'), {
+ docsUrl: this.helpMetricsPath,
+ }, false);
+ this.$monitoredMetricsEmpty.empty();
+ this.$monitoredMetricsEmpty.append(emptyCommonMetricsText);
+ this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+ } else {
+ const metricsCountText = sprintf(s__('PrometheusService|%{exporters} with %{metrics} were found'), {
+ exporters: n__('%d exporter', '%d exporters', totalExporters),
+ metrics: n__('%d metric', '%d metrics', totalMonitoredMetrics),
+ });
+ this.$monitoredMetricsCount.text(metricsCountText);
+ this.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
- if (totalMissingEnvVarMetrics > 0) {
- this.$missingEnvVarPanel.removeClass('hidden');
- this.$missingEnvVarPanel.find('.flash-container').off('click');
- this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics);
+ if (totalMissingEnvVarMetrics > 0) {
+ this.$missingEnvVarPanel.removeClass('hidden');
+ this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics);
+ }
}
}
loadActiveMetrics() {
this.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
backOff((next, stop) => {
- $.ajax({
- url: this.activeMetricsEndpoint,
- dataType: 'json',
- global: false,
- })
- .done((res) => {
- if (res && res.success) {
- stop(res);
+ axios.get(this.activeMetricsEndpoint)
+ .then(({ data }) => {
+ if (data && data.success) {
+ stop(data);
} else {
this.backOffRequestCounter = this.backOffRequestCounter += 1;
if (this.backOffRequestCounter < 3) {
next();
} else {
- stop(res);
+ stop(data);
}
}
})
- .fail(stop);
+ .catch(stop);
})
- .then((res) => {
- if (res && res.data && res.data.length) {
- this.populateActiveMetrics(res.data);
- } else {
+ .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);
- }
- })
- .catch(() => {
- this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
- });
+ });
}
}
diff --git a/app/assets/javascripts/protected_branches/index.js b/app/assets/javascripts/protected_branches/index.js
deleted file mode 100644
index c9e7af127d2..00000000000
--- a/app/assets/javascripts/protected_branches/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-/* 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 38b1406a99f..40a873833e1 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -9,8 +9,8 @@ export default class ProtectedBranchAccessDropdown {
$dropdown.glDropdown({
data,
selectable: true,
- inputId: $dropdown.data('input-id'),
- fieldName: $dropdown.data('field-name'),
+ inputId: $dropdown.data('inputId'),
+ fieldName: $dropdown.data('fieldName'),
toggleLabel(item, $el) {
if ($el.is('.is-active')) {
return item.text;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 0a9fdb074e5..7c61c070a35 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,6 +1,7 @@
+import $ from 'jquery';
import _ from 'underscore';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
-import ProtectedBranchDropdown from './protected_branch_dropdown';
+import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor';
const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults';
@@ -35,10 +36,12 @@ export default class ProtectedBranchCreate {
onSelect: this.onSelectCallback,
});
- // Protected branch dropdown
- this.protectedBranchDropdown = new ProtectedBranchDropdown({
+ this.createItemDropdown = new CreateItemDropdown({
$dropdown: $protectedBranchDropdown,
+ defaultToggleLabel: 'Protected Branch',
+ fieldName: 'protected_branch[name]',
onSelect: this.onSelectCallback,
+ getData: ProtectedBranchCreate.getProtectedBranches,
});
this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown'));
@@ -57,7 +60,11 @@ export default class ProtectedBranchCreate {
);
this.savePreviousSelection($allowedToMergeInput.val(), $allowedToPushInput.val());
- this.$form.find('input[type="submit"]').attr('disabled', completedForm);
+ this.$form.find('input[type="submit"]').prop('disabled', completedForm);
+ }
+
+ static getProtectedBranches(term, callback) {
+ callback(gon.open_branches);
}
loadPreviousSelection(mergeDropdown, pushDropdown) {
diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
deleted file mode 100644
index 678882a8d2c..00000000000
--- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import _ from 'underscore';
-
-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;
- this.$dropdownContainer = this.$dropdown.parent();
- this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedBranch = this.$dropdownContainer.find('.js-create-new-protected-branch');
-
- this.buildDropdown();
- this.bindEvents();
-
- // Hide footer
- this.toggleFooter(true);
- }
-
- buildDropdown() {
- this.$dropdown.glDropdown({
- data: this.getProtectedBranches.bind(this),
- filterable: true,
- remote: false,
- search: {
- fields: ['title'],
- },
- selectable: true,
- toggleLabel(selected) {
- return (selected && 'id' in selected) ? selected.title : 'Protected Branch';
- },
- fieldName: 'protected_branch[name]',
- text(protectedBranch) {
- return _.escape(protectedBranch.title);
- },
- id(protectedBranch) {
- return _.escape(protectedBranch.id);
- },
- onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (options) => {
- options.e.preventDefault();
- this.onSelect();
- },
- });
- }
-
- bindEvents() {
- this.$protectedBranch.on('click', this.onClickCreateWildcard.bind(this));
- }
-
- onClickCreateWildcard(e) {
- e.preventDefault();
-
- // Refresh the dropdown's data, which ends up calling `getProtectedBranches`
- this.$dropdown.data('glDropdown').remote.execute();
- this.$dropdown.data('glDropdown').selectRowAtIndex();
- }
-
- getProtectedBranches(term, callback) {
- if (this.selectedBranch) {
- callback(gon.open_branches.concat(this.selectedBranch));
- } else {
- callback(gon.open_branches);
- }
- }
-
- toggleCreateNewButton(branchName) {
- if (branchName) {
- this.selectedBranch = {
- title: branchName,
- id: branchName,
- text: branchName,
- };
-
- this.$dropdownContainer
- .find('.js-create-new-protected-branch code')
- .text(branchName);
- }
-
- this.toggleFooter(!branchName);
- }
-
- 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 632625da8e7..54560d08ad7 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,5 +1,5 @@
-/* eslint-disable no-new */
-import Flash from '../flash';
+import flash from '../flash';
+import axios from '../lib/utils/axios_utils';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
export default class ProtectedBranchEdit {
@@ -38,29 +38,25 @@ export default class ProtectedBranchEdit {
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(),
- }],
- },
+ axios.patch(this.$wrap.data('url'), {
+ protected_branch: {
+ merge_access_levels_attributes: [{
+ id: this.$allowedToMergeDropdown.data('accessLevelId'),
+ access_level: $allowedToMergeInput.val(),
+ }],
+ push_access_levels_attributes: [{
+ id: this.$allowedToPushDropdown.data('accessLevelId'),
+ access_level: $allowedToPushInput.val(),
+ }],
},
- error() {
- new Flash('Failed to update branch!', 'alert', document.querySelector('.js-protected-branches-list'));
- },
- }).always(() => {
+ }).then(() => {
+ this.$allowedToMergeDropdown.enable();
+ this.$allowedToPushDropdown.enable();
+ }).catch(() => {
this.$allowedToMergeDropdown.enable();
this.$allowedToPushDropdown.enable();
+
+ flash('Failed to update branch!', 'alert', document.querySelector('.js-protected-branches-list'));
});
}
}
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 b40d3827c30..10253c0febc 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js
@@ -1,5 +1,6 @@
/* eslint-disable no-new */
+import $ from 'jquery';
import ProtectedBranchEdit from './protected_branch_edit';
export default class ProtectedBranchEditList {
diff --git a/app/assets/javascripts/protected_tags/index.js b/app/assets/javascripts/protected_tags/index.js
deleted file mode 100644
index b1618e24e49..00000000000
--- a/app/assets/javascripts/protected_tags/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-/* 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 d4c9a91a74a..b803da798d5 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -9,8 +9,8 @@ export default class ProtectedTagAccessDropdown {
this.options.$dropdown.glDropdown({
data: this.options.data,
selectable: true,
- inputId: this.options.$dropdown.data('input-id'),
- fieldName: this.options.$dropdown.data('field-name'),
+ inputId: this.options.$dropdown.data('inputId'),
+ fieldName: this.options.$dropdown.data('fieldName'),
toggleLabel(item, $el) {
if ($el.is('.is-active')) {
return item.text;
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
index 91bd140bd12..2f8116df0d2 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -1,5 +1,6 @@
+import $ from 'jquery';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
-import ProtectedTagDropdown from './protected_tag_dropdown';
+import CreateItemDropdown from '../create_item_dropdown';
export default class ProtectedTagCreate {
constructor() {
@@ -24,9 +25,12 @@ export default class ProtectedTagCreate {
$allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0);
// Protected tag dropdown
- this.protectedTagDropdown = new ProtectedTagDropdown({
+ this.createItemDropdown = new CreateItemDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'),
+ defaultToggleLabel: 'Protected Tag',
+ fieldName: 'protected_tag[name]',
onSelect: this.onSelectCallback,
+ getData: ProtectedTagCreate.getProtectedTags,
});
}
@@ -36,6 +40,10 @@ export default class ProtectedTagCreate {
const $tagInput = this.$form.find('input[name="protected_tag[name]"]');
const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
- this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length));
+ this.$form.find('input[type="submit"]').prop('disabled', !($tagInput.val() && $allowedToCreateInput.length));
+ }
+
+ static getProtectedTags(term, callback) {
+ callback(gon.open_tags);
}
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
deleted file mode 100644
index a0224213aa0..00000000000
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import _ from 'underscore';
-
-export default class ProtectedTagDropdown {
- /**
- * @param {Object} options containing
- * `$dropdown` target element
- * `onSelect` event callback
- * $dropdown must be an element created using `dropdown_tag()` rails helper
- */
- constructor(options) {
- this.onSelect = options.onSelect;
- this.$dropdown = options.$dropdown;
- this.$dropdownContainer = this.$dropdown.parent();
- this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
-
- this.buildDropdown();
- this.bindEvents();
-
- // Hide footer
- this.toggleFooter(true);
- }
-
- buildDropdown() {
- this.$dropdown.glDropdown({
- data: this.getProtectedTags.bind(this),
- filterable: true,
- remote: false,
- search: {
- fields: ['title'],
- },
- selectable: true,
- toggleLabel(selected) {
- return (selected && 'id' in selected) ? selected.title : 'Protected Tag';
- },
- fieldName: 'protected_tag[name]',
- text(protectedTag) {
- return _.escape(protectedTag.title);
- },
- id(protectedTag) {
- return _.escape(protectedTag.id);
- },
- onFilter: this.toggleCreateNewButton.bind(this),
- clicked: (options) => {
- options.e.preventDefault();
- this.onSelect();
- },
- });
- }
-
- bindEvents() {
- this.$protectedTag.on('click', this.onClickCreateWildcard.bind(this));
- }
-
- onClickCreateWildcard(e) {
- this.$dropdown.data('glDropdown').remote.execute();
- this.$dropdown.data('glDropdown').selectRowAtIndex();
- e.preventDefault();
- }
-
- getProtectedTags(term, callback) {
- if (this.selectedTag) {
- callback(gon.open_tags.concat(this.selectedTag));
- } else {
- callback(gon.open_tags);
- }
- }
-
- toggleCreateNewButton(tagName) {
- if (tagName) {
- this.selectedTag = {
- title: tagName,
- id: tagName,
- text: tagName,
- };
-
- this.$dropdownContainer
- .find('.js-create-new-protected-tag code')
- .text(tagName);
- }
-
- this.toggleFooter(!tagName);
- }
-
- toggleFooter(toggleState) {
- this.$dropdownFooter.toggleClass('hidden', toggleState);
- }
-}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
index dad0ad25b65..8687b2a4044 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -1,5 +1,5 @@
-/* eslint-disable no-new */
-import Flash from '../flash';
+import flash from '../flash';
+import axios from '../lib/utils/axios_utils';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
export default class ProtectedTagEdit {
@@ -28,24 +28,19 @@ export default class ProtectedTagEdit {
this.$allowedToCreateDropdownButton.disable();
- $.ajax({
- type: 'POST',
- url: this.$wrap.data('url'),
- dataType: 'json',
- data: {
- _method: 'PATCH',
- protected_tag: {
- create_access_levels_attributes: [{
- id: this.$allowedToCreateDropdownButton.data('access-level-id'),
- access_level: $allowedToCreateInput.val(),
- }],
- },
+ axios.patch(this.$wrap.data('url'), {
+ protected_tag: {
+ create_access_levels_attributes: [{
+ id: this.$allowedToCreateDropdownButton.data('accessLevelId'),
+ access_level: $allowedToCreateInput.val(),
+ }],
},
- error() {
- new Flash('Failed to update tag!', 'alert', document.querySelector('.js-protected-tags-list'));
- },
- }).always(() => {
+ }).then(() => {
+ this.$allowedToCreateDropdownButton.enable();
+ }).catch(() => {
this.$allowedToCreateDropdownButton.enable();
+
+ flash('Failed to update tag!', 'alert', document.querySelector('.js-protected-tags-list'));
});
}
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
index bd9fc872266..b35bf4d4606 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
@@ -1,5 +1,6 @@
/* eslint-disable no-new */
+import $ from 'jquery';
import ProtectedTagEdit from './protected_tag_edit';
export default class ProtectedTagEditList {
diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
index 65e4101352c..95c5cf7b345 100644
--- a/app/assets/javascripts/ref_select_dropdown.js
+++ b/app/assets/javascripts/ref_select_dropdown.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
class RefSelectDropdown {
constructor($dropdownButton, availableRefs) {
const availableRefsValue = availableRefs || JSON.parse(document.getElementById('availableRefs').innerHTML);
@@ -6,7 +8,7 @@ class RefSelectDropdown {
filterable: true,
filterByText: true,
remote: false,
- fieldName: $dropdownButton.data('field-name'),
+ fieldName: $dropdownButton.data('fieldName'),
filterInput: 'input[type="search"]',
selectable: true,
isSelectable(branch, $el) {
@@ -24,7 +26,7 @@ class RefSelectDropdown {
});
const $dropdownContainer = $dropdownButton.closest('.dropdown');
- const $fieldInput = $(`input[name="${$dropdownButton.data('field-name')}"]`, $dropdownContainer);
+ const $fieldInput = $(`input[name="${$dropdownButton.data('fieldName')}"]`, $dropdownContainer);
const $filterInput = $('input[type="search"]', $dropdownContainer);
$filterInput.on('keyup', (e) => {
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index 2d8ca443ea7..ea0f7199a70 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -1,14 +1,17 @@
<script>
- /* globals Flash */
import { mapGetters, mapActions } from 'vuex';
- import '../../flash';
+ import Flash from '../../flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import store from '../stores';
import collapsibleContainer from './collapsible_container.vue';
import { errorMessages, errorMessagesTypes } from '../constants';
export default {
- name: 'registryListApp',
+ name: 'RegistryListApp',
+ components: {
+ collapsibleContainer,
+ loadingIcon,
+ },
props: {
endpoint: {
type: String,
@@ -16,22 +19,12 @@
},
},
store,
- components: {
- collapsibleContainer,
- loadingIcon,
- },
computed: {
...mapGetters([
'isLoading',
'repos',
]),
},
- methods: {
- ...mapActions([
- 'setMainEndpoint',
- 'fetchRepos',
- ]),
- },
created() {
this.setMainEndpoint(this.endpoint);
},
@@ -39,6 +32,12 @@
this.fetchRepos()
.catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS]));
},
+ methods: {
+ ...mapActions([
+ 'setMainEndpoint',
+ 'fetchRepos',
+ ]),
+ },
};
</script>
<template>
@@ -46,17 +45,18 @@
<loading-icon
v-if="isLoading"
size="3"
- />
+ />
<collapsible-container
v-else-if="!isLoading && repos.length"
v-for="(item, index) in repos"
:key="index"
:repo="item"
- />
+ />
<p v-else-if="!isLoading && !repos.length">
- {{__("No container images stored for this project. Add one by following the instructions above.")}}
+ {{ __(`No container images stored for this project.
+Add one by following the instructions above.`) }}
</p>
</div>
</template>
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index ac1c3ec253c..a03180e80e6 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -1,7 +1,6 @@
<script>
- /* globals Flash */
import { mapActions } from 'vuex';
- import '../../flash';
+ import Flash from '../../flash';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
@@ -9,13 +8,7 @@
import { errorMessages, errorMessagesTypes } from '../constants';
export default {
- name: 'collapsibeContainerRegisty',
- props: {
- repo: {
- type: Object,
- required: true,
- },
- },
+ name: 'CollapsibeContainerRegisty',
components: {
clipboardButton,
loadingIcon,
@@ -24,6 +17,12 @@
directives: {
tooltip,
},
+ props: {
+ repo: {
+ type: Object,
+ required: true,
+ },
+ },
data() {
return {
isOpen: false,
@@ -65,28 +64,30 @@
<template>
<div class="container-image">
- <div
- class="container-image-head">
+ <div class="container-image-head">
<button
type="button"
@click="toggleRepo"
- class="js-toggle-repo btn-link">
+ class="js-toggle-repo btn-link"
+ >
<i
class="fa"
:class="{
'fa-chevron-right': !isOpen,
'fa-chevron-up': isOpen,
}"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
- {{repo.name}}
+ {{ repo.name }}
</button>
<clipboard-button
v-if="repo.location"
:text="clipboardText"
:title="repo.location"
- />
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
<div class="controls hidden-xs pull-right">
<button
@@ -96,35 +97,38 @@
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
v-tooltip
- @click="handleDeleteRepository">
+ @click="handleDeleteRepository"
+ >
<i
class="fa fa-trash"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</button>
</div>
-
</div>
<loading-icon
v-if="repo.isLoading"
class="append-bottom-20"
size="2"
- />
+ />
<div
v-else-if="!repo.isLoading && isOpen"
- class="container-image-tags">
+ class="container-image-tags"
+ >
<table-registry
v-if="repo.list.length"
:repo="repo"
- />
+ />
<div
v-else
- class="nothing-here-block">
- {{s__("ContainerRegistry|No tags in Container Registry for this container image.")}}
+ class="nothing-here-block"
+ >
+ {{ s__("ContainerRegistry|No tags in Container Registry for this container image.") }}
</div>
</div>
</div>
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index 14d43e135fe..ee4eb3581f3 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -1,8 +1,7 @@
<script>
- /* globals Flash */
import { mapActions } from 'vuex';
import { n__ } from '../../locale';
- import '../../flash';
+ import Flash from '../../flash';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import tablePagination from '../../vue_shared/components/table_pagination.vue';
import tooltip from '../../vue_shared/directives/tooltip';
@@ -11,21 +10,21 @@
import { numberToHumanSize } from '../../lib/utils/number_utils';
export default {
- props: {
- repo: {
- type: Object,
- required: true,
- },
- },
components: {
clipboardButton,
tablePagination,
},
+ directives: {
+ tooltip,
+ },
mixins: [
timeagoMixin,
],
- directives: {
- tooltip,
+ props: {
+ repo: {
+ type: Object,
+ required: true,
+ },
},
computed: {
shouldRenderPagination() {
@@ -68,75 +67,79 @@
};
</script>
<template>
-<div>
- <table class="table tags">
- <thead>
- <tr>
- <th>{{s__('ContainerRegistry|Tag')}}</th>
- <th>{{s__('ContainerRegistry|Tag ID')}}</th>
- <th>{{s__("ContainerRegistry|Size")}}</th>
- <th>{{s__("ContainerRegistry|Created")}}</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- <tr
- v-for="(item, i) in repo.list"
- :key="i">
- <td>
+ <div>
+ <table class="table tags">
+ <thead>
+ <tr>
+ <th>{{ s__('ContainerRegistry|Tag') }}</th>
+ <th>{{ s__('ContainerRegistry|Tag ID') }}</th>
+ <th>{{ s__("ContainerRegistry|Size") }}</th>
+ <th>{{ s__("ContainerRegistry|Created") }}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="(item, i) in repo.list"
+ :key="i">
+ <td>
- {{item.tag}}
+ {{ item.tag }}
- <clipboard-button
- v-if="item.location"
- :title="item.location"
- :text="clipboardText(item.location)"
+ <clipboard-button
+ v-if="item.location"
+ :title="item.location"
+ :text="clipboardText(item.location)"
+ css-class="btn-default btn-transparent btn-clipboard"
/>
- </td>
- <td>
- <span
- v-tooltip
- :title="item.revision"
- data-placement="bottom">
- {{item.shortRevision}}
+ </td>
+ <td>
+ <span
+ v-tooltip
+ :title="item.revision"
+ data-placement="bottom"
+ >
+ {{ item.shortRevision }}
</span>
- </td>
- <td>
- {{formatSize(item.size)}}
- <template v-if="item.size && item.layers">
- &middot;
- </template>
- {{layers(item)}}
- </td>
+ </td>
+ <td>
+ {{ formatSize(item.size) }}
+ <template v-if="item.size && item.layers">
+ &middot;
+ </template>
+ {{ layers(item) }}
+ </td>
- <td>
- {{timeFormated(item.createdAt)}}
- </td>
+ <td>
+ {{ timeFormated(item.createdAt) }}
+ </td>
- <td class="content">
- <button
- v-if="item.canDelete"
- type="button"
- class="js-delete-registry btn btn-danger hidden-xs pull-right"
- :title="s__('ContainerRegistry|Remove tag')"
- :aria-label="s__('ContainerRegistry|Remove tag')"
- data-container="body"
- v-tooltip
- @click="handleDeleteRegistry(item)">
- <i
- class="fa fa-trash"
- aria-hidden="true">
- </i>
- </button>
- </td>
- </tr>
- </tbody>
- </table>
+ <td class="content">
+ <button
+ v-if="item.canDelete"
+ type="button"
+ class="js-delete-registry btn btn-danger hidden-xs pull-right"
+ :title="s__('ContainerRegistry|Remove tag')"
+ :aria-label="s__('ContainerRegistry|Remove tag')"
+ data-container="body"
+ v-tooltip
+ @click="handleDeleteRegistry(item)"
+ >
+ <i
+ class="fa fa-trash"
+ aria-hidden="true"
+ >
+ </i>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
- <table-pagination
- v-if="shouldRenderPagination"
- :change="onPageChange"
- :page-info="repo.pagination"
+ <table-pagination
+ v-if="shouldRenderPagination"
+ :change="onPageChange"
+ :page-info="repo.pagination"
/>
-</div>
+ </div>
</template>
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js
index d8edff73f72..6fb125192b2 100644
--- a/app/assets/javascripts/registry/index.js
+++ b/app/assets/javascripts/registry/index.js
@@ -4,7 +4,7 @@ import Translate from '../vue_shared/translate';
Vue.use(Translate);
-document.addEventListener('DOMContentLoaded', () => new Vue({
+export default () => new Vue({
el: '#js-vue-registry-images',
components: {
registryApp,
@@ -22,4 +22,4 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
},
});
},
-}));
+});
diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js
deleted file mode 100644
index 5482c55f8bb..00000000000
--- a/app/assets/javascripts/render_gfm.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import renderMath from './render_math';
-import renderMermaid from './render_mermaid';
-import syntaxHighlight from './syntax_highlight';
-// Render Gitlab flavoured Markdown
-//
-// Delegates to syntax highlight and render math & mermaid diagrams.
-//
-$.fn.renderGFM = function renderGFM() {
- syntaxHighlight(this.find('.js-syntax-highlight'));
- renderMath(this.find('.js-render-math'));
- renderMermaid(this.find('.js-render-mermaid'));
- return this;
-};
-
-$(() => $('body').renderGFM());
diff --git a/app/assets/javascripts/render_math.js b/app/assets/javascripts/render_math.js
deleted file mode 100644
index a759992cd54..00000000000
--- a/app/assets/javascripts/render_math.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/* global katex */
-
-// Renders math using KaTeX in any element with the
-// `js-render-math` class
-//
-// ### Example Markup
-//
-// <code class="js-render-math"></div>
-//
- // Only load once
-let katexLoaded = false;
-
-// Loop over all math elements and render math
-function renderWithKaTeX(elements) {
- elements.each(function katexElementsLoop() {
- const mathNode = $('<span></span>');
- const $this = $(this);
-
- const display = $this.attr('data-math-style') === 'display';
- try {
- katex.render($this.text(), mathNode.get(0), { displayMode: display });
- mathNode.insertAfter($this);
- $this.remove();
- } catch (err) {
- throw err;
- }
- });
-}
-
-export default function renderMath($els) {
- if (!$els.length) return;
-
- if (katexLoaded) {
- renderWithKaTeX($els);
- } else {
- $.get(gon.katex_css_url, () => {
- const css = $('<link>', {
- rel: 'stylesheet',
- type: 'text/css',
- href: gon.katex_css_url,
- });
- css.appendTo('head');
-
- // Load KaTeX js
- $.getScript(gon.katex_js_url, () => {
- katexLoaded = true;
- renderWithKaTeX($els); // Run KaTeX
- });
- });
- }
-}
diff --git a/app/assets/javascripts/render_mermaid.js b/app/assets/javascripts/render_mermaid.js
deleted file mode 100644
index 41942c04a4e..00000000000
--- a/app/assets/javascripts/render_mermaid.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// Renders diagrams and flowcharts from text using Mermaid in any element with the
-// `js-render-mermaid` class.
-//
-// Example markup:
-//
-// <pre class="js-render-mermaid">
-// graph TD;
-// A-- > B;
-// A-- > C;
-// B-- > D;
-// C-- > D;
-// </pre>
-//
-
-import Flash from './flash';
-
-export default function renderMermaid($els) {
- if (!$els.length) return;
-
- import(/* webpackChunkName: 'mermaid' */ 'blackst0ne-mermaid').then((mermaid) => {
- mermaid.initialize({
- loadOnStart: false,
- theme: 'neutral',
- });
-
- $els.each((i, el) => {
- mermaid.init(undefined, el);
- });
- }).catch((err) => {
- Flash(`Can't load mermaid module: ${err}`);
- });
-}
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index b830fcf7e80..2088a49590a 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,7 +1,10 @@
/* 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 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
+import flash from './flash';
+import axios from './lib/utils/axios_utils';
function Sidebar(currentUser) {
this.toggleTodo = this.toggleTodo.bind(this);
@@ -62,7 +65,7 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
Sidebar.prototype.toggleTodo = function(e) {
var $btnText, $this, $todoLoading, ajaxType, url;
$this = $(e.currentTarget);
- ajaxType = $this.attr('data-delete-path') ? 'DELETE' : 'POST';
+ ajaxType = $this.attr('data-delete-path') ? 'delete' : 'post';
if ($this.attr('data-delete-path')) {
url = "" + ($this.attr('data-delete-path'));
} else {
@@ -71,25 +74,14 @@ Sidebar.prototype.toggleTodo = function(e) {
$this.tooltip('hide');
- return $.ajax({
- url: url,
- type: ajaxType,
- dataType: 'json',
- data: {
- issuable_id: $this.data('issuable-id'),
- issuable_type: $this.data('issuable-type')
- },
- beforeSend: (function(_this) {
- return function() {
- $('.js-issuable-todo').disable()
- .addClass('is-loading');
- };
- })(this)
- }).done((function(_this) {
- return function(data) {
- return _this.todoUpdateDone(data);
- };
- })(this));
+ $('.js-issuable-todo').disable().addClass('is-loading');
+
+ axios[ajaxType](url, {
+ issuable_id: $this.data('issuableId'),
+ issuable_type: $this.data('issuableType'),
+ }).then(({ data }) => {
+ this.todoUpdateDone(data);
+ }).catch(() => flash(`There was an error ${ajaxType === 'post' ? 'adding a' : 'deleting the'} todo.`));
};
Sidebar.prototype.todoUpdateDone = function(data) {
@@ -105,18 +97,18 @@ Sidebar.prototype.todoUpdateDone = function(data) {
$el.removeClass('is-loading')
.enable()
- .attr('aria-label', $el.data(`${attrPrefix}-text`))
+ .attr('aria-label', $el.data(`${attrPrefix}Text`))
.attr('data-delete-path', deletePath)
- .attr('title', $el.data(`${attrPrefix}-text`));
+ .attr('title', $el.data(`${attrPrefix}Text`));
if ($el.hasClass('has-tooltip')) {
$el.tooltip('fixTitle');
}
- if ($el.data(`${attrPrefix}-icon`)) {
- $elText.html($el.data(`${attrPrefix}-icon`));
+ if ($el.data(`${attrPrefix}Icon`)) {
+ $elText.html($el.data(`${attrPrefix}Icon`));
} else {
- $elText.text($el.data(`${attrPrefix}-text`));
+ $elText.text($el.data(`${attrPrefix}Text`));
}
});
};
diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js
deleted file mode 100644
index 363322af47a..00000000000
--- a/app/assets/javascripts/search.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import Flash from './flash';
-import Api from './api';
-
-export default class Search {
- constructor() {
- const $groupDropdown = $('.js-search-group-dropdown');
- const $projectDropdown = $('.js-search-project-dropdown');
-
- this.searchInput = '.js-search-input';
- this.searchClear = '.js-search-clear';
-
- this.groupId = $groupDropdown.data('group-id');
- this.eventListeners();
-
- $groupDropdown.glDropdown({
- selectable: true,
- filterable: true,
- fieldName: 'group_id',
- search: {
- fields: ['full_name'],
- },
- data(term, callback) {
- return Api.groups(term, {}, (data) => {
- data.unshift({
- full_name: 'Any',
- });
- data.splice(1, 0, 'divider');
- return callback(data);
- });
- },
- id(obj) {
- return obj.id;
- },
- text(obj) {
- return obj.full_name;
- },
- toggleLabel(obj) {
- return `${($groupDropdown.data('default-label'))} ${obj.full_name}`;
- },
- clicked: () => Search.submitSearch(),
- });
-
- $projectDropdown.glDropdown({
- selectable: true,
- filterable: true,
- fieldName: 'project_id',
- search: {
- fields: ['name'],
- },
- 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(obj) {
- return obj.id;
- },
- text(obj) {
- return obj.name_with_namespace;
- },
- toggleLabel(obj) {
- return `${($projectDropdown.data('default-label'))} ${obj.name_with_namespace}`;
- },
- clicked: () => Search.submitSearch(),
- });
- }
-
- eventListeners() {
- $(document)
- .off('keyup', this.searchInput)
- .on('keyup', this.searchInput, this.searchKeyUp);
- $(document)
- .off('click', this.searchClear)
- .on('click', this.searchClear, this.clearSearchField.bind(this));
- }
-
- static submitSearch() {
- return $('.js-search-form').submit();
- }
-
- searchKeyUp() {
- const $input = $(this);
- if ($input.val() === '') {
- $('.js-search-clear').addClass('hidden');
- } else {
- $('.js-search-clear').removeClass('hidden');
- }
- }
-
- clearSearchField() {
- return $(this.searchInput).val('').trigger('keyup').focus();
- }
-
- getProjectsData(term) {
- return new Promise((resolve) => {
- if (this.groupId) {
- Api.groupProjects(this.groupId, term, resolve);
- } else {
- Api.projects(term, {
- order_by: 'id',
- }, resolve);
- }
- });
- }
-}
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 98b524f7e3f..2da022fde63 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,4 +1,8 @@
/* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, no-cond-assign, consistent-return, object-shorthand, prefer-arrow-callback, func-names, space-before-function-paren, prefer-template, quotes, class-methods-use-this, no-sequences, wrap-iife, no-lonely-if, no-else-return, no-param-reassign, vars-on-top, max-len */
+
+import $ from 'jquery';
+import axios from './lib/utils/axios_utils';
+import DropdownUtils from './filtered_search/dropdown_utils';
import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils';
/**
@@ -24,32 +28,32 @@ function setSearchOptions() {
if ($projectOptionsDataEl.length) {
gl.projectOptions = gl.projectOptions || {};
- var projectPath = $projectOptionsDataEl.data('project-path');
+ var projectPath = $projectOptionsDataEl.data('projectPath');
gl.projectOptions[projectPath] = {
name: $projectOptionsDataEl.data('name'),
- issuesPath: $projectOptionsDataEl.data('issues-path'),
- issuesDisabled: $projectOptionsDataEl.data('issues-disabled'),
- mrPath: $projectOptionsDataEl.data('mr-path'),
+ issuesPath: $projectOptionsDataEl.data('issuesPath'),
+ issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'),
+ mrPath: $projectOptionsDataEl.data('mrPath'),
};
}
if ($groupOptionsDataEl.length) {
gl.groupOptions = gl.groupOptions || {};
- var groupPath = $groupOptionsDataEl.data('group-path');
+ var groupPath = $groupOptionsDataEl.data('groupPath');
gl.groupOptions[groupPath] = {
name: $groupOptionsDataEl.data('name'),
- issuesPath: $groupOptionsDataEl.data('issues-path'),
- mrPath: $groupOptionsDataEl.data('mr-path'),
+ issuesPath: $groupOptionsDataEl.data('issuesPath'),
+ mrPath: $groupOptionsDataEl.data('mrPath'),
};
}
if ($dashboardOptionsDataEl.length) {
gl.dashboardOptions = {
- issuesPath: $dashboardOptionsDataEl.data('issues-path'),
- mrPath: $dashboardOptionsDataEl.data('mr-path'),
+ issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
+ mrPath: $dashboardOptionsDataEl.data('mrPath'),
};
}
}
@@ -60,9 +64,9 @@ export default class SearchAutocomplete {
this.bindEventContext();
this.wrap = wrap || $('.search');
this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
- this.autocompletePath = autocompletePath || this.optsEl.data('autocomplete-path');
- this.projectId = projectId || (this.optsEl.data('autocomplete-project-id') || '');
- this.projectRef = projectRef || (this.optsEl.data('autocomplete-project-ref') || '');
+ this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
+ this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || '');
+ this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || '');
this.dropdown = this.wrap.find('.dropdown');
this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
this.dropdownContent = this.dropdown.find('.dropdown-content');
@@ -146,23 +150,25 @@ export default class SearchAutocomplete {
this.loadingSuggestions = true;
- return $.get(this.autocompletePath, {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term: term,
- }, (response) => {
- var firstCategory, i, lastCategory, len, suggestion;
+ return axios.get(this.autocompletePath, {
+ params: {
+ project_id: this.projectId,
+ project_ref: this.projectRef,
+ term: term,
+ },
+ }).then((response) => {
// Hide dropdown menu if no suggestions returns
- if (!response.length) {
+ if (!response.data.length) {
this.disableAutocomplete();
return;
}
const data = [];
// List results
- firstCategory = true;
- for (i = 0, len = response.length; i < len; i += 1) {
- suggestion = response[i];
+ let firstCategory = true;
+ let lastCategory;
+ for (let i = 0, len = response.data.length; i < len; i += 1) {
+ const suggestion = response.data[i];
// Add group header before list each group
if (lastCategory !== suggestion.category) {
if (!firstCategory) {
@@ -177,7 +183,7 @@ export default class SearchAutocomplete {
lastCategory = suggestion.category;
}
data.push({
- id: (suggestion.category.toLowerCase()) + "-" + suggestion.id,
+ id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
category: suggestion.category,
text: suggestion.label,
url: suggestion.url,
@@ -187,13 +193,17 @@ export default class SearchAutocomplete {
if (data.length) {
data.push('separator');
data.push({
- text: "Result name contains \"" + term + "\"",
- url: "/search?search=" + term + "&project_id=" + (this.projectInputEl.val()) + "&group_id=" + (this.groupInputEl.val()),
+ text: `Result name contains "${term}"`,
+ url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
});
}
- return callback(data);
- })
- .always(() => { this.loadingSuggestions = false; });
+
+ callback(data);
+
+ this.loadingSuggestions = false;
+ }).catch(() => {
+ this.loadingSuggestions = false;
+ });
}
getCategoryContents() {
@@ -223,21 +233,21 @@ export default class SearchAutocomplete {
const issueItems = [
{
text: 'Issues assigned to me',
- url: `${issuesPath}/?assignee_username=${userName}`,
+ url: `${issuesPath}/?assignee_id=${userId}`,
},
{
text: "Issues I've created",
- url: `${issuesPath}/?author_username=${userName}`,
+ url: `${issuesPath}/?author_id=${userId}`,
},
];
const mergeRequestItems = [
{
text: 'Merge requests assigned to me',
- url: `${mrPath}/?assignee_username=${userName}`,
+ url: `${mrPath}/?assignee_id=${userId}`,
},
{
text: "Merge requests I've created",
- url: `${mrPath}/?author_username=${userName}`,
+ url: `${mrPath}/?author_id=${userId}`,
},
];
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
index d34a21b37e1..eecde4550f9 100644
--- a/app/assets/javascripts/settings_panels.js
+++ b/app/assets/javascripts/settings_panels.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
function expandSection($section) {
$section.find('.js-settings-toggle').text('Collapse');
$section.find('.settings-content').off('scroll.expandSection').scrollTop(0);
@@ -42,7 +44,7 @@ export default function initSettingsPanels() {
if (location.hash) {
const $target = $(location.hash);
- if ($target.length && $target.hasClass('.settings')) {
+ if ($target.length && $target.hasClass('settings')) {
expandSection($target);
}
}
diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js
new file mode 100644
index 00000000000..2f974d6ff9d
--- /dev/null
+++ b/app/assets/javascripts/shared/milestones/form.js
@@ -0,0 +1,10 @@
+import $ from 'jquery';
+import ZenMode from '../../zen_mode';
+import DueDateSelectors from '../../due_date_select';
+import GLForm from '../../gl_form';
+
+export default (initGFM = true) => {
+ new ZenMode(); // eslint-disable-line no-new
+ new DueDateSelectors(); // eslint-disable-line no-new
+ new GLForm($('.milestone-form'), initGFM); // eslint-disable-line no-new
+};
diff --git a/app/assets/javascripts/shared/sessions/u2f.js b/app/assets/javascripts/shared/sessions/u2f.js
new file mode 100644
index 00000000000..6ae9faf1dde
--- /dev/null
+++ b/app/assets/javascripts/shared/sessions/u2f.js
@@ -0,0 +1,17 @@
+import $ from 'jquery';
+import U2FAuthenticate from '../../u2f/authenticate';
+
+export default () => {
+ if (!gon.u2f) return;
+
+ const u2fAuthenticate = new U2FAuthenticate(
+ $('#js-authenticate-u2f'),
+ '#js-login-u2f-form',
+ gon.u2f,
+ document.querySelector('#js-login-2fa-device'),
+ document.querySelector('.js-2fa-form'),
+ );
+ u2fAuthenticate.start();
+ // needed in rspec
+ gl.u2fAuthenticate = u2fAuthenticate;
+};
diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js
index d2f0d7410da..e31e067033f 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -1,5 +1,7 @@
+import $ from 'jquery';
import Cookies from 'js-cookie';
import Mousetrap from 'mousetrap';
+import axios from './lib/utils/axios_utils';
import { refreshCurrentPage, visitUrl } from './lib/utils/url_utility';
import findAndFollowLink from './shortcuts_dashboard_navigation';
@@ -13,12 +15,10 @@ Mousetrap.stopCallback = (e, element, combo) => {
};
export default class Shortcuts {
- constructor(skipResetBindings) {
+ constructor() {
this.onToggleHelp = this.onToggleHelp.bind(this);
this.enabledHelp = [];
- if (!skipResetBindings) {
- Mousetrap.reset();
- }
+
Mousetrap.bind('?', this.onToggleHelp);
Mousetrap.bind('s', Shortcuts.focusSearch);
Mousetrap.bind('f', this.focusFilter.bind(this));
@@ -62,7 +62,7 @@ export default class Shortcuts {
e.preventDefault();
const performanceBarCookieName = 'perf_bar_enabled';
if (Cookies.get(performanceBarCookieName) === 'true') {
- Cookies.remove(performanceBarCookieName, { path: '/' });
+ Cookies.set(performanceBarCookieName, 'false', { path: '/' });
} else {
Cookies.set(performanceBarCookieName, 'true', { path: '/' });
}
@@ -87,21 +87,21 @@ export default class Shortcuts {
$modal.modal('toggle');
}
- $.ajax({
- url: gon.shortcuts_path,
- dataType: 'script',
- success() {
- if (location && location.length > 0) {
- const results = [];
- for (let i = 0, len = location.length; i < len; i += 1) {
- results.push($(location[i]).show());
- }
- return results;
+ return axios.get(gon.shortcuts_path, {
+ responseType: 'text',
+ }).then(({ data }) => {
+ $.globalEval(data);
+
+ if (location && location.length > 0) {
+ const results = [];
+ for (let i = 0, len = location.length; i < len; i += 1) {
+ results.push($(location[i]).show());
}
+ return results;
+ }
- $('.hidden-shortcut').show();
- return $('.js-more-help-button').remove();
- },
+ $('.hidden-shortcut').show();
+ return $('.js-more-help-button').remove();
});
}
diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js
index cf309be4f6f..908b9cab93d 100644
--- a/app/assets/javascripts/shortcuts_blob.js
+++ b/app/assets/javascripts/shortcuts_blob.js
@@ -1,4 +1,4 @@
-/* global Mousetrap */
+import Mousetrap from 'mousetrap';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
import Shortcuts from './shortcuts';
diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js
index 81286c0010c..1e246a56b85 100644
--- a/app/assets/javascripts/shortcuts_find_file.js
+++ b/app/assets/javascripts/shortcuts_find_file.js
@@ -1,5 +1,4 @@
-/* global Mousetrap */
-
+import Mousetrap from 'mousetrap';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsFindFile extends ShortcutsNavigation {
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index 292e3d6a657..193788f754f 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -1,23 +1,21 @@
-/* global Mousetrap */
-
+import $ from 'jquery';
+import Mousetrap from 'mousetrap';
import _ from 'underscore';
-import 'mousetrap';
import Sidebar from './right_sidebar';
-import ShortcutsNavigation from './shortcuts_navigation';
-import { CopyAsGFM } from './behaviors/copy_as_gfm';
+import Shortcuts from './shortcuts';
+import { CopyAsGFM } from './behaviors/markdown/copy_as_gfm';
-export default class ShortcutsIssuable extends ShortcutsNavigation {
+export default class ShortcutsIssuable extends Shortcuts {
constructor(isMergeRequest) {
super();
this.$replyField = isMergeRequest ? $('.js-main-target-form #note_note') : $('.js-main-target-form .js-vue-comment-form');
- this.editBtn = document.querySelector('.js-issuable-edit');
Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee'));
Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone'));
Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
Mousetrap.bind('r', this.replyWithSelectedText.bind(this));
- Mousetrap.bind('e', this.editIssue.bind(this));
+ Mousetrap.bind('e', ShortcutsIssuable.editIssue);
if (isMergeRequest) {
this.enabledHelp.push('.hidden-shortcut.merge_requests');
@@ -60,10 +58,10 @@ export default class ShortcutsIssuable extends ShortcutsNavigation {
return false;
}
- editIssue() {
+ static editIssue() {
// Need to click the element as on issues, editing is inline
// on merge request, editing is on a different page
- this.editBtn.click();
+ document.querySelector('.js-issuable-edit').click();
return false;
}
diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js
index b4562701a3e..a4d10850471 100644
--- a/app/assets/javascripts/shortcuts_navigation.js
+++ b/app/assets/javascripts/shortcuts_navigation.js
@@ -1,5 +1,4 @@
-/* global Mousetrap */
-
+import Mousetrap from 'mousetrap';
import findAndFollowLink from './shortcuts_dashboard_navigation';
import Shortcuts from './shortcuts';
diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js
index 21823085ac4..a88c280fa3b 100644
--- a/app/assets/javascripts/shortcuts_network.js
+++ b/app/assets/javascripts/shortcuts_network.js
@@ -1,4 +1,4 @@
-/* global Mousetrap */
+import Mousetrap from 'mousetrap';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsNetwork extends ShortcutsNavigation {
diff --git a/app/assets/javascripts/shortcuts_wiki.js b/app/assets/javascripts/shortcuts_wiki.js
index 59b967dbe09..41865dcf4ba 100644
--- a/app/assets/javascripts/shortcuts_wiki.js
+++ b/app/assets/javascripts/shortcuts_wiki.js
@@ -1,16 +1,14 @@
-/* eslint-disable class-methods-use-this */
-/* global Mousetrap */
-
+import Mousetrap from 'mousetrap';
import ShortcutsNavigation from './shortcuts_navigation';
import findAndFollowLink from './shortcuts_dashboard_navigation';
export default class ShortcutsWiki extends ShortcutsNavigation {
constructor() {
super();
- Mousetrap.bind('e', this.editWiki);
+ Mousetrap.bind('e', ShortcutsWiki.editWiki);
}
- editWiki() {
+ static editWiki() {
findAndFollowLink('.js-wiki-edit');
}
}
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
deleted file mode 100644
index 77f070d48cc..00000000000
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
+++ /dev/null
@@ -1,59 +0,0 @@
-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="js-sidebar-dropdown-toggle 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/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
new file mode 100644
index 00000000000..5eeb2a41bae
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -0,0 +1,64 @@
+<script>
+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';
+ },
+ },
+};
+</script>
+<template>
+ <div class="title hide-collapsed">
+ {{ assigneeTitle }}
+ <i
+ v-if="loading"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ >
+
+ </i>
+ <a
+ v-if="editable"
+ class="js-sidebar-dropdown-toggle 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"
+ >
+ </i>
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js
deleted file mode 100644
index 7e5feac622c..00000000000
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.js
+++ /dev/null
@@ -1,224 +0,0 @@
-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/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
new file mode 100644
index 00000000000..1e7f46454bf
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -0,0 +1,232 @@
+<script>
+export default {
+ name: 'Assignees',
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ defaultRenderCount: 5,
+ defaultMaxCounter: 99,
+ showLess: 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 || gon.default_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;
+ },
+ },
+};
+</script>
+
+<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"
+ >
+ </i>
+ <button
+ type="button"
+ class="btn-link"
+ v-for="(user, index) in users"
+ v-if="shouldRenderCollapsedAssignee(index)"
+ :key="user.id"
+ >
+ <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)"
+ :key="user.id"
+ >
+ <a
+ class="user-link has-tooltip"
+ data-container="body"
+ 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>
+</template>
+
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
deleted file mode 100644
index 9e47039d920..00000000000
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import Flash from '../../../flash';
-import AssigneeTitle from './assignee_title';
-import Assignees from './assignees';
-import Store from '../../stores/sidebar_store';
-import eventHub from '../../event_hub';
-
-export default {
- name: 'SidebarAssignees',
- data() {
- return {
- store: new Store(),
- loading: false,
- };
- },
- props: {
- mediator: {
- type: Object,
- required: true,
- },
- field: {
- type: String,
- required: true,
- },
- signedIn: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- 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);
- },
- 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/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
new file mode 100644
index 00000000000..3c6b9c27814
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -0,0 +1,95 @@
+<script>
+import Flash from '../../../flash';
+import AssigneeTitle from './assignee_title.vue';
+import Assignees from './assignees.vue';
+import Store from '../../stores/sidebar_store';
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'SidebarAssignees',
+ components: {
+ AssigneeTitle,
+ Assignees,
+ },
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ field: {
+ type: String,
+ required: true,
+ },
+ signedIn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ store: new Store(),
+ loading: false,
+ };
+ },
+ 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);
+ },
+ 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');
+ });
+ },
+ },
+};
+</script>
+
+<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>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index 6ee4d487c0b..ceb02309959 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -2,6 +2,8 @@
import Flash from '../../../flash';
import editForm from './edit_form.vue';
import Icon from '../../../vue_shared/components/icon.vue';
+import { __ } from '../../../locale';
+import eventHub from '../../event_hub';
export default {
components: {
@@ -32,14 +34,27 @@ export default {
return this.isConfidential ? 'eye-slash' : 'eye';
},
},
+ created() {
+ eventHub.$on('closeConfidentialityForm', this.toggleForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('closeConfidentialityForm', this.toggleForm);
+ },
methods: {
toggleForm() {
this.edit = !this.edit;
},
updateConfidentialAttribute(confidential) {
- this.service.update('issue', { confidential })
+ this.service
+ .update('issue', { confidential })
.then(() => location.reload())
- .catch(() => new Flash('Something went wrong trying to change the confidentiality of this issue'));
+ .catch(() => {
+ Flash(
+ __(
+ 'Something went wrong trying to change the confidentiality of this issue',
+ ),
+ );
+ });
},
},
};
@@ -47,48 +62,53 @@ export default {
<template>
<div class="block issuable-sidebar-item confidentiality">
- <div class="sidebar-collapsed-icon">
+ <div
+ class="sidebar-collapsed-icon"
+ @click="toggleForm"
+ >
<icon
:name="confidentialityIcon"
- :size="16"
- aria-hidden="true">
- </icon>
+ aria-hidden="true"
+ />
</div>
<div class="title hide-collapsed">
- Confidentiality
+ {{ __('Confidentiality') }}
<a
v-if="isEditable"
class="pull-right confidential-edit"
href="#"
@click.prevent="toggleForm"
>
- Edit
+ {{ __('Edit') }}
</a>
</div>
<div class="value sidebar-item-value hide-collapsed">
<editForm
v-if="edit"
- :toggle-form="toggleForm"
:is-confidential="isConfidential"
:update-confidential-attribute="updateConfidentialAttribute"
/>
- <div v-if="!isConfidential" class="no-value sidebar-item-value">
+ <div
+ v-if="!isConfidential"
+ class="no-value sidebar-item-value">
<icon
name="eye"
:size="16"
aria-hidden="true"
- class="sidebar-item-icon inline">
- </icon>
- Not confidential
+ class="sidebar-item-icon inline"
+ />
+ {{ __('Not confidential') }}
</div>
- <div v-else class="value sidebar-item-value hide-collapsed">
+ <div
+ v-else
+ class="value sidebar-item-value hide-collapsed">
<icon
name="eye-slash"
:size="16"
aria-hidden="true"
- class="sidebar-item-icon inline is-active">
- </icon>
- This issue is confidential
+ class="sidebar-item-icon inline is-active"
+ />
+ {{ __('This issue is confidential') }}
</div>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
index dd17b5abd46..3783f71a848 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
@@ -1,24 +1,32 @@
<script>
import editFormButtons from './edit_form_buttons.vue';
+import { s__ } from '../../../locale';
export default {
+ components: {
+ editFormButtons,
+ },
props: {
isConfidential: {
required: true,
type: Boolean,
},
- toggleForm: {
- required: true,
- type: Function,
- },
updateConfidentialAttribute: {
required: true,
type: Function,
},
},
-
- components: {
- editFormButtons,
+ computed: {
+ confidentialityOnWarning() {
+ return s__(
+ 'confidentiality|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.',
+ );
+ },
+ confidentialityOffWarning() {
+ return s__(
+ 'confidentiality|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.',
+ );
+ },
},
};
</script>
@@ -27,19 +35,16 @@ export default {
<div class="dropdown open">
<div class="dropdown-menu sidebar-item-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
+ v-if="!isConfidential"
+ v-html="confidentialityOnWarning">
</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
+ v-else
+ v-html="confidentialityOffWarning">
</p>
<edit-form-buttons
:is-confidential="isConfidential"
- :toggle-form="toggleForm"
:update-confidential-attribute="updateConfidentialAttribute"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
index 7ed0619ee6b..38b1ddbfd5b 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -1,14 +1,13 @@
<script>
+import $ from 'jquery';
+import eventHub from '../../event_hub';
+
export default {
props: {
isConfidential: {
required: true,
type: Boolean,
},
- toggleForm: {
- required: true,
- type: Function,
- },
updateConfidentialAttribute: {
required: true,
type: Function,
@@ -22,6 +21,16 @@ export default {
return !this.isConfidential;
},
},
+ methods: {
+ closeForm() {
+ eventHub.$emit('closeConfidentialityForm');
+ $(this.$el).trigger('hidden.gl.dropdown');
+ },
+ submitForm() {
+ this.closeForm();
+ this.updateConfidentialAttribute(this.updateConfidentialBool);
+ },
+ },
};
</script>
@@ -30,14 +39,14 @@ export default {
<button
type="button"
class="btn btn-default append-right-10"
- @click="toggleForm"
+ @click="closeForm"
>
- Cancel
+ {{ __('Cancel') }}
</button>
<button
type="button"
class="btn btn-close"
- @click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
+ @click.prevent="submitForm"
>
{{ toggleButtonText }}
</button>
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
index 242e826d471..e1e4715826a 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
@@ -1,31 +1,41 @@
<script>
import editFormButtons from './edit_form_buttons.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
+import { __, sprintf } from '../../../locale';
export default {
+ components: {
+ editFormButtons,
+ },
+ mixins: [issuableMixin],
props: {
isLocked: {
required: true,
type: Boolean,
},
- toggleForm: {
- required: true,
- type: Function,
- },
-
updateLockedAttribute: {
required: true,
type: Function,
},
},
-
- mixins: [
- issuableMixin,
- ],
-
- components: {
- editFormButtons,
+ computed: {
+ lockWarning() {
+ return sprintf(
+ __(
+ 'Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.',
+ ),
+ { issuableDisplayName: this.issuableDisplayName },
+ );
+ },
+ unlockWarning() {
+ return sprintf(
+ __(
+ 'Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.',
+ ),
+ { issuableDisplayName: this.issuableDisplayName },
+ );
+ },
},
};
</script>
@@ -33,22 +43,20 @@ export default {
<template>
<div class="dropdown open">
<div class="dropdown-menu sidebar-item-warning-message">
- <p class="text" v-if="isLocked">
- Unlock this {{ issuableDisplayName }}?
- <strong>Everyone</strong>
- will be able to comment.
+ <p
+ class="text"
+ v-if="isLocked"
+ v-html="unlockWarning">
</p>
- <p class="text" v-else>
- Lock this {{ issuableDisplayName }}?
- Only
- <strong>project members</strong>
- will be able to comment.
+ <p
+ class="text"
+ v-else
+ v-html="lockWarning">
</p>
<edit-form-buttons
:is-locked="isLocked"
- :toggle-form="toggleForm"
:update-locked-attribute="updateLockedAttribute"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index c3a553a7605..5e7b8f9698f 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -1,4 +1,7 @@
<script>
+import $ from 'jquery';
+import eventHub from '../../event_hub';
+
export default {
props: {
isLocked: {
@@ -6,11 +9,6 @@ export default {
type: Boolean,
},
- toggleForm: {
- required: true,
- type: Function,
- },
-
updateLockedAttribute: {
required: true,
type: Function,
@@ -26,6 +24,17 @@ export default {
return !this.isLocked;
},
},
+
+ methods: {
+ closeForm() {
+ eventHub.$emit('closeLockForm');
+ $(this.$el).trigger('hidden.gl.dropdown');
+ },
+ submitForm() {
+ this.closeForm();
+ this.updateLockedAttribute(this.toggleLock);
+ },
+ },
};
</script>
@@ -34,7 +43,7 @@ export default {
<button
type="button"
class="btn btn-default append-right-10"
- @click="toggleForm"
+ @click="closeForm"
>
{{ __('Cancel') }}
</button>
@@ -42,7 +51,7 @@ export default {
<button
type="button"
class="btn btn-close"
- @click.prevent="updateLockedAttribute(toggleLock)"
+ @click.prevent="submitForm"
>
{{ buttonText }}
</button>
diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
index 04c3a96bf74..e4893451af3 100644
--- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
@@ -1,10 +1,17 @@
<script>
-/* global Flash */
+import Flash from '~/flash';
import editForm from './edit_form.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
import Icon from '../../../vue_shared/components/icon.vue';
+import eventHub from '../../event_hub';
export default {
+ components: {
+ editForm,
+ Icon,
+ },
+ mixins: [issuableMixin],
+
props: {
isLocked: {
required: true,
@@ -20,20 +27,15 @@ export default {
required: true,
type: Object,
validator(mediatorObject) {
- return mediatorObject.service && mediatorObject.service.update && mediatorObject.store;
+ return (
+ mediatorObject.service &&
+ mediatorObject.service.update &&
+ mediatorObject.store
+ );
},
},
},
- mixins: [
- issuableMixin,
- ],
-
- components: {
- editForm,
- Icon,
- },
-
computed: {
lockIcon() {
return this.isLocked ? 'lock' : 'lock-open';
@@ -44,17 +46,35 @@ export default {
},
},
+ created() {
+ eventHub.$on('closeLockForm', this.toggleForm);
+ },
+
+ beforeDestroy() {
+ eventHub.$off('closeLockForm', this.toggleForm);
+ },
+
methods: {
toggleForm() {
- this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
+ this.mediator.store.isLockDialogOpen = !this.mediator.store
+ .isLockDialogOpen;
},
updateLockedAttribute(locked) {
- this.mediator.service.update(this.issuableType, {
- discussion_locked: locked,
- })
- .then(() => location.reload())
- .catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName}`)));
+ this.mediator.service
+ .update(this.issuableType, {
+ discussion_locked: locked,
+ })
+ .then(() => location.reload())
+ .catch(() =>
+ Flash(
+ this.__(
+ `Something went wrong trying to change the locked state of this ${
+ this.issuableDisplayName
+ }`,
+ ),
+ ),
+ );
},
},
};
@@ -62,20 +82,22 @@ export default {
<template>
<div class="block issuable-sidebar-item lock">
- <div class="sidebar-collapsed-icon">
+ <div
+ class="sidebar-collapsed-icon"
+ @click="toggleForm"
+ >
<icon
:name="lockIcon"
- :size="16"
aria-hidden="true"
- class="sidebar-item-icon is-active">
- </icon>
+ class="sidebar-item-icon is-active"
+ />
</div>
<div class="title hide-collapsed">
- Lock {{ issuableDisplayName }}
+ {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }}
<button
v-if="isEditable"
- class="pull-right lock-edit btn btn-blank"
+ class="pull-right lock-edit"
type="button"
@click.prevent="toggleForm"
>
@@ -86,7 +108,6 @@ export default {
<div class="value sidebar-item-value hide-collapsed">
<edit-form
v-if="isLockDialogOpen"
- :toggle-form="toggleForm"
:is-locked="isLocked"
:update-locked-attribute="updateLockedAttribute"
:issuable-type="issuableType"
@@ -100,8 +121,8 @@ export default {
name="lock"
:size="16"
aria-hidden="true"
- class="sidebar-item-icon inline is-active">
- </icon>
+ class="sidebar-item-icon inline is-active"
+ />
{{ __('Locked') }}
</div>
@@ -113,8 +134,8 @@ export default {
name="lock-open"
:size="16"
aria-hidden="true"
- class="sidebar-item-icon inline">
- </icon>
+ class="sidebar-item-icon inline"
+ />
{{ __('Unlocked') }}
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index b8510a6ce3a..006a6d2905d 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,73 +1,73 @@
<script>
-import { __, n__, sprintf } from '../../../locale';
-import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
-import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue';
+ import { __, n__, sprintf } from '../../../locale';
+ import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+ import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue';
-export default {
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
+ export default {
+ components: {
+ loadingIcon,
+ userAvatarImage,
},
- participants: {
- type: Array,
- required: false,
- default: () => [],
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ participants: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ numberOfLessParticipants: {
+ type: Number,
+ required: false,
+ default: 7,
+ },
},
- numberOfLessParticipants: {
- type: Number,
- required: false,
- default: 7,
+ data() {
+ return {
+ isShowingMoreParticipants: false,
+ };
},
- },
- data() {
- return {
- isShowingMoreParticipants: false,
- };
- },
- components: {
- loadingIcon,
- userAvatarImage,
- },
- computed: {
- lessParticipants() {
- return this.participants.slice(0, this.numberOfLessParticipants);
- },
- visibleParticipants() {
- return this.isShowingMoreParticipants ? this.participants : this.lessParticipants;
- },
- hasMoreParticipants() {
- return this.participants.length > this.numberOfLessParticipants;
- },
- toggleLabel() {
- let label = '';
- if (this.isShowingMoreParticipants) {
- label = __('- show less');
- } else {
- label = sprintf(__('+ %{moreCount} more'), {
- moreCount: this.participants.length - this.numberOfLessParticipants,
- });
- }
+ computed: {
+ lessParticipants() {
+ return this.participants.slice(0, this.numberOfLessParticipants);
+ },
+ visibleParticipants() {
+ return this.isShowingMoreParticipants ? this.participants : this.lessParticipants;
+ },
+ hasMoreParticipants() {
+ return this.participants.length > this.numberOfLessParticipants;
+ },
+ toggleLabel() {
+ let label = '';
+ if (this.isShowingMoreParticipants) {
+ label = __('- show less');
+ } else {
+ label = sprintf(__('+ %{moreCount} more'), {
+ moreCount: this.participants.length - this.numberOfLessParticipants,
+ });
+ }
- return label;
- },
- participantLabel() {
- return sprintf(
- n__('%{count} participant', '%{count} participants', this.participants.length),
- { count: this.loading ? '' : this.participantCount },
- );
- },
- participantCount() {
- return this.participants.length;
+ return label;
+ },
+ participantLabel() {
+ return sprintf(
+ n__('%{count} participant', '%{count} participants', this.participants.length),
+ { count: this.loading ? '' : this.participantCount },
+ );
+ },
+ participantCount() {
+ return this.participants.length;
+ },
},
- },
- methods: {
- toggleMoreParticipants() {
- this.isShowingMoreParticipants = !this.isShowingMoreParticipants;
+ methods: {
+ toggleMoreParticipants() {
+ this.isShowingMoreParticipants = !this.isShowingMoreParticipants;
+ },
},
- },
-};
+ };
</script>
<template>
@@ -75,14 +75,17 @@ export default {
<div class="sidebar-collapsed-icon">
<i
class="fa fa-users"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
<loading-icon
v-if="loading"
- class="js-participants-collapsed-loading-icon" />
+ class="js-participants-collapsed-loading-icon"
+ />
<span
v-else
- class="js-participants-collapsed-count">
+ class="js-participants-collapsed-count"
+ >
{{ participantCount }}
</span>
</div>
@@ -90,34 +93,40 @@ export default {
<loading-icon
v-if="loading"
:inline="true"
- class="js-participants-expanded-loading-icon" />
+ class="js-participants-expanded-loading-icon"
+ />
{{ participantLabel }}
</div>
<div class="participants-list hide-collapsed">
<div
v-for="participant in visibleParticipants"
:key="participant.id"
- class="participants-author js-participants-author">
+ class="participants-author js-participants-author"
+ >
<a
class="author_link"
- :href="participant.web_url">
+ :href="participant.web_url"
+ >
<user-avatar-image
:lazy="true"
:img-src="participant.avatar_url"
css-classes="avatar-inline"
:size="24"
:tooltip-text="participant.name"
- tooltip-placement="bottom" />
+ tooltip-placement="bottom"
+ />
</a>
</div>
</div>
<div
v-if="hasMoreParticipants"
- class="participants-more hide-collapsed">
+ class="participants-more hide-collapsed"
+ >
<button
type="button"
class="btn-transparent btn-blank js-toggle-participants-button"
- @click="toggleMoreParticipants">
+ @click="toggleMoreParticipants"
+ >
{{ toggleLabel }}
</button>
</div>
diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue
index 6fcd2f95309..5c1ead1a8ac 100644
--- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue
@@ -1,23 +1,23 @@
<script>
-import Store from '../../stores/sidebar_store';
-import participants from './participants.vue';
+ import Store from '../../stores/sidebar_store';
+ import participants from './participants.vue';
-export default {
- data() {
- return {
- store: new Store(),
- };
- },
- props: {
- mediator: {
- type: Object,
- required: true,
+ export default {
+ components: {
+ participants,
},
- },
- components: {
- participants,
- },
-};
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ store: new Store(),
+ };
+ },
+ };
</script>
<template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
index f4bae1d3dd5..3e8cc7a6630 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
@@ -6,10 +6,8 @@ import { __ } from '../../../locale';
import subscriptions from './subscriptions.vue';
export default {
- data() {
- return {
- store: new Store(),
- };
+ components: {
+ subscriptions,
},
props: {
mediator: {
@@ -17,10 +15,17 @@ export default {
required: true,
},
},
- components: {
- subscriptions,
+ data() {
+ return {
+ store: new Store(),
+ };
+ },
+ created() {
+ eventHub.$on('toggleSubscription', this.onToggleSubscription);
+ },
+ beforeDestroy() {
+ eventHub.$off('toggleSubscription', this.onToggleSubscription);
},
-
methods: {
onToggleSubscription() {
this.mediator.toggleSubscription()
@@ -29,14 +34,6 @@ export default {
});
},
},
-
- created() {
- eventHub.$on('toggleSubscription', this.onToggleSubscription);
- },
-
- beforeDestroy() {
- eventHub.$off('toggleSubscription', this.onToggleSubscription);
- },
};
</script>
@@ -44,6 +41,7 @@ export default {
<div class="block subscriptions">
<subscriptions
:loading="store.isFetching.subscriptions"
- :subscribed="store.subscribed" />
+ :subscribed="store.subscribed"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 940e1764f3d..d69d100a26c 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -1,64 +1,85 @@
<script>
-import { __ } from '../../../locale';
-import eventHub from '../../event_hub';
-import loadingButton from '../../../vue_shared/components/loading_button.vue';
+ import { __ } from '~/locale';
+ import icon from '~/vue_shared/components/icon.vue';
+ import toggleButton from '~/vue_shared/components/toggle_button.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import eventHub from '../../event_hub';
-export default {
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
+ const ICON_ON = 'notifications';
+ const ICON_OFF = 'notifications-off';
+ const LABEL_ON = __('Notifications on');
+ const LABEL_OFF = __('Notifications off');
+
+ export default {
+ directives: {
+ tooltip,
},
- subscribed: {
- type: Boolean,
- required: false,
+ components: {
+ icon,
+ toggleButton,
},
- id: {
- type: Number,
- required: false,
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ subscribed: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ id: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
- },
- components: {
- loadingButton,
- },
- computed: {
- buttonLabel() {
- let label;
- if (this.subscribed === false) {
- label = __('Subscribe');
- } else if (this.subscribed === true) {
- label = __('Unsubscribe');
- }
-
- return label;
+ computed: {
+ showLoadingState() {
+ return this.subscribed === null;
+ },
+ notificationIcon() {
+ return this.subscribed ? ICON_ON : ICON_OFF;
+ },
+ notificationTooltip() {
+ return this.subscribed ? LABEL_ON : LABEL_OFF;
+ },
},
- },
- methods: {
- toggleSubscription() {
- eventHub.$emit('toggleSubscription', this.id);
+ methods: {
+ toggleSubscription() {
+ eventHub.$emit('toggleSubscription', this.id);
+ },
},
- },
-};
+ };
</script>
<template>
<div>
<div class="sidebar-collapsed-icon">
- <i
- class="fa fa-rss"
- aria-hidden="true">
- </i>
+ <span
+ v-tooltip
+ :title="notificationTooltip"
+ data-container="body"
+ data-placement="left"
+ >
+ <icon
+ :name="notificationIcon"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon is-active"
+ />
+ </span>
</div>
<span class="issuable-header-text hide-collapsed pull-left">
{{ __('Notifications') }}
</span>
- <loading-button
- ref="loadingButton"
- class="btn btn-default pull-right hide-collapsed js-issuable-subscribe-button"
- :loading="loading"
- :label="buttonLabel"
- @click="toggleSubscription"
+ <toggle-button
+ ref="toggleButton"
+ class="pull-right hide-collapsed js-issuable-subscribe-button"
+ :is-loading="showLoadingState"
+ :value="subscribed"
+ @change="toggleSubscription"
/>
</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
deleted file mode 100644
index a9fbc7f1a2f..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import stopwatchSvg from 'icons/_icon_stopwatch.svg';
-import { abbreviateTime } from '../../../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 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/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
new file mode 100644
index 00000000000..3b86f1145d1
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -0,0 +1,102 @@
+<script>
+ import icon from '../../../vue_shared/components/icon.vue';
+ import { abbreviateTime } from '../../../lib/utils/pretty_time';
+
+ export default {
+ name: 'TimeTrackingCollapsedState',
+ components: {
+ icon,
+ },
+ 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 abbreviateTime(timeStr);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="sidebar-collapsed-icon">
+ <icon name="timer" />
+ <div class="time-tracking-collapsed-summary">
+ <div :class="divClass">
+ <span :class="spanClass">
+ {{ text }}
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
deleted file mode 100644
index fd0d4570d68..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { parseSeconds, stringifyTime } from '../../../lib/utils/pretty_time';
-
-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: {
- parsedTimeRemaining() {
- const diffSeconds = this.timeEstimate - this.timeSpent;
- return parseSeconds(diffSeconds);
- },
- timeRemainingHumanReadable() {
- return stringifyTime(this.parsedTimeRemaining);
- },
- 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';
- },
- },
- 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/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
new file mode 100644
index 00000000000..82c4562f9a9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -0,0 +1,93 @@
+<script>
+import { parseSeconds, stringifyTime } from '../../../lib/utils/pretty_time';
+
+export default {
+ name: 'TimeTrackingComparisonPane',
+ props: {
+ timeSpent: {
+ type: Number,
+ required: true,
+ },
+ timeEstimate: {
+ type: Number,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ parsedTimeRemaining() {
+ const diffSeconds = this.timeEstimate - this.timeSpent;
+ return parseSeconds(diffSeconds);
+ },
+ timeRemainingHumanReadable() {
+ return stringifyTime(this.parsedTimeRemaining);
+ },
+ 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';
+ },
+ },
+};
+</script>
+
+<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">
+ {{ s__('TimeTracking|Spent') }}
+ </span>
+ <span class="compare-value spent">
+ {{ timeSpentHumanReadable }}
+ </span>
+ </div>
+ <div class="compare-display estimated pull-right">
+ <span class="compare-label">
+ {{ s__('TimeTrackingEstimated|Est') }}
+ </span>
+ <span class="compare-value">
+ {{ timeEstimateHumanReadable }}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
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
index ad1b9179db0..2d324c71379 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
@@ -9,7 +9,7 @@ export default {
template: `
<div class="time-tracking-estimate-only-pane">
<span class="bold">
- Estimated:
+ {{ s__('TimeTracking|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
index 142ad437509..19f74ad3c6d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
@@ -1,3 +1,5 @@
+import { sprintf, s__ } from '../../../locale';
+
export default {
name: 'time-tracking-help-state',
props: {
@@ -10,33 +12,39 @@ export default {
href() {
return `${this.rootPath}help/workflow/time_tracking.md`;
},
+ estimateText() {
+ return sprintf(
+ s__('estimateCommand|%{slash_command} will update the estimated time with the latest command.'), {
+ slash_command: '<code>/estimate</code>',
+ }, false,
+ );
+ },
+ spendText() {
+ return sprintf(
+ s__('spendCommand|%{slash_command} will update the sum of the time spent.'), {
+ slash_command: '<code>/spend</code>',
+ }, false,
+ );
+ },
},
template: `
<div class="time-tracking-help-state">
<div class="time-tracking-info">
<h4>
- Track time with quick actions
+ {{ __('Track time with quick actions') }}
</h4>
<p>
- Quick actions can be used in the issues description and comment boxes.
+ {{ __('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 v-html="estimateText">
</p>
- <p>
- <code>
- /spend
- </code>
- will update the sum of the time spent.
+ <p v-html="spendText">
</p>
<a
class="btn btn-default learn-more-button"
:href="href"
>
- Learn more
+ {{ __('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
index d1dd1dcdd27..38da76c6771 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
@@ -3,7 +3,7 @@ export default {
template: `
<div class="time-tracking-no-tracking-pane">
<span class="no-value">
- No estimate or time spent
+ {{ __('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
index d32fe4abc7d..5626cccc022 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -1,8 +1,9 @@
+import $ from 'jquery';
import _ from 'underscore';
import '~/smart_interval';
-import timeTracker from './time_tracker';
+import IssuableTimeTracker from './time_tracker.vue';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
@@ -16,7 +17,7 @@ export default {
};
},
components: {
- 'issuable-time-tracker': timeTracker,
+ IssuableTimeTracker,
},
methods: {
listenForQuickActions() {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
deleted file mode 100644
index ed0d71a4f79..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
+++ /dev/null
@@ -1,163 +0,0 @@
-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/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
new file mode 100644
index 00000000000..1c641c73ea3
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -0,0 +1,168 @@
+<script>
+import timeTrackingHelpState from './help_state';
+import TimeTrackingCollapsedState from './collapsed_state.vue';
+import timeTrackingSpentOnlyPane from './spent_only_pane';
+import timeTrackingNoTrackingPane from './no_tracking_pane';
+import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import TimeTrackingComparisonPane from './comparison_pane.vue';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'IssuableTimeTracker',
+ components: {
+ TimeTrackingCollapsedState,
+ 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
+ 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
+ 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ TimeTrackingComparisonPane,
+ 'time-tracking-help-state': timeTrackingHelpState,
+ },
+ 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,
+ };
+ },
+ 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;
+ },
+ },
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
+ 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;
+ },
+ },
+};
+</script>
+
+<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"
+ >
+ </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-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"
+ :root-path="rootPath"
+ />
+ </transition>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 977dd83a7ea..1eadebc7004 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
function isValidProjectId(id) {
return id > 0;
}
@@ -50,7 +52,7 @@ class SidebarMoveIssue {
const selectedProjectId = options.isMarking ? project.id : 0;
this.mediator.setMoveToProjectId(selectedProjectId);
- this.$confirmButton.attr('disabled', !isValidProjectId(selectedProjectId));
+ this.$confirmButton.prop('disabled', !isValidProjectId(selectedProjectId));
},
});
}
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
new file mode 100644
index 00000000000..b15ad0e5586
--- /dev/null
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import timeTracker from './components/time_tracking/time_tracker.vue';
+
+export default class SidebarMilestone {
+ constructor() {
+ const el = document.getElementById('issuable-time-tracker');
+
+ if (!el) return;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ timeTracker,
+ },
+ render: createElement => createElement('timeTracker', {
+ props: {
+ time_estimate: parseInt(el.dataset.timeEstimate, 10),
+ time_spent: parseInt(el.dataset.timeSpent, 10),
+ human_time_estimate: el.dataset.humanTimeEstimate,
+ human_time_spent: el.dataset.humanTimeSpent,
+ rootPath: '/',
+ },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 56cc78ca0ca..9f5d852260e 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,6 +1,7 @@
+import $ from 'jquery';
import Vue from 'vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
-import SidebarAssignees from './components/assignees/sidebar_assignees';
+import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 04c39d7b6b5..377846db70e 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -1,13 +1,9 @@
import Mediator from './sidebar_mediator';
import { mountSidebar, getSidebarOptions } from './mount_sidebar';
-function domContentLoaded() {
+export default () => {
const mediator = new Mediator(getSidebarOptions());
mediator.fetch();
mountSidebar(mediator);
-}
-
-document.addEventListener('DOMContentLoaded', domContentLoaded);
-
-export default domContentLoaded;
+};
diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js
deleted file mode 100644
index 20255398047..00000000000
--- a/app/assets/javascripts/signin_tabs_memoizer.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* eslint no-param-reassign: ["error", { "props": false }]*/
-/* eslint no-new: "off" */
-import AccessorUtilities from './lib/utils/accessor';
-
-/**
- * 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.bootstrap();
- }
-
- 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);
- }
- });
- }
-
- this.showTab();
- }
-
- showTab() {
- const anchorName = this.readData();
- if (anchorName) {
- const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`);
- if (tab) {
- tab.click();
- }
- }
- }
-
- 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 95e51bc4e7a..1afff0dba38 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,5 +1,9 @@
/* 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 */
+import $ from 'jquery';
+import { __ } from './locale';
+import axios from './lib/utils/axios_utils';
+import createFlash from './flash';
import FilesCommentButton from './files_comment_button';
import imageDiffHelper from './image_diff/helpers/index';
import syntaxHighlight from './syntax_highlight';
@@ -15,7 +19,7 @@ export default class SingleFileDiff {
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.diffForPath = this.content.find('[data-diff-for-path]').data('diffForPath');
this.isOpen = !this.diffForPath;
if (this.diffForPath) {
this.collapsedContent = this.content;
@@ -60,30 +64,33 @@ export default class SingleFileDiff {
getContentHTML(cb) {
this.collapsedContent.hide();
this.loadingContent.show();
- $.get(this.diffForPath, (function(_this) {
- return function(data) {
- _this.loadingContent.hide();
+
+ axios.get(this.diffForPath)
+ .then(({ data }) => {
+ this.loadingContent.hide();
if (data.html) {
- _this.content = $(data.html);
- syntaxHighlight(_this.content);
+ this.content = $(data.html);
+ syntaxHighlight(this.content);
} else {
- _this.hasError = true;
- _this.content = $(ERROR_HTML);
+ this.hasError = true;
+ this.content = $(ERROR_HTML);
}
- _this.collapsedContent.after(_this.content);
+ this.collapsedContent.after(this.content);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
- const $file = $(_this.file);
+ const $file = $(this.file);
FilesCommentButton.init($file);
const canCreateNote = $file.closest('.files').is('[data-can-create-note]');
imageDiffHelper.initImageDiff($file[0], canCreateNote);
if (cb) cb();
- };
- })(this));
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while retrieving diff'));
+ });
}
}
diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js
index 8e931995fc6..77ab7c964e6 100644
--- a/app/assets/javascripts/smart_interval.js
+++ b/app/assets/javascripts/smart_interval.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
/**
* Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
* and controllable by a public API.
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
index a98403f4cf2..dcee17453b8 100644
--- a/app/assets/javascripts/snippet/snippet_bundle.js
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -1,12 +1,11 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, max-len */
/* global ace */
-(function() {
- $(function() {
- var editor = ace.edit("editor");
+import $ from 'jquery';
- $(".snippet-form-holder form").on('submit', function() {
- $(".snippet-file-content").val(editor.getValue());
- });
+export default () => {
+ const editor = ace.edit('editor');
+
+ $('.snippet-form-holder form').on('submit', () => {
+ $('.snippet-file-content').val(editor.getValue());
});
-}).call(window);
+};
diff --git a/app/assets/javascripts/sortable/sortable_config.js b/app/assets/javascripts/sortable/sortable_config.js
new file mode 100644
index 00000000000..43ef5d66422
--- /dev/null
+++ b/app/assets/javascripts/sortable/sortable_config.js
@@ -0,0 +1,7 @@
+export default {
+ animation: 200,
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ fallbackOnBody: true,
+ ghostClass: 'is-ghost',
+};
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index d5606e153f6..f5a7fdae5d7 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -1,17 +1,21 @@
+import $ from 'jquery';
import Flash from './flash';
import { __, s__ } from './locale';
import { spriteIcon } from './lib/utils/common_utils';
+import axios from './lib/utils/axios_utils';
export default class Star {
constructor() {
- $('.project-home-panel .toggle-star')
- .on('ajax:success', function handleSuccess(e, data) {
- const $this = $(this);
- const $starSpan = $this.find('span');
- const $startIcon = $this.find('svg');
+ $('.project-home-panel .toggle-star').on('click', function toggleStarClickCallback() {
+ const $this = $(this);
+ const $starSpan = $this.find('span');
+ const $startIcon = $this.find('svg');
- function toggleStar(isStarred) {
+ axios.post($this.data('endpoint'))
+ .then(({ data }) => {
+ const isStarred = $starSpan.hasClass('starred');
$this.parent().find('.star-count').text(data.star_count);
+
if (isStarred) {
$starSpan.removeClass('starred').text(s__('StarProject|Star'));
$startIcon.remove();
@@ -21,12 +25,8 @@ export default class Star {
$startIcon.remove();
$this.prepend(spriteIcon('star'));
}
- }
-
- toggleStar($starSpan.hasClass('starred'));
- })
- .on('ajax:error', () => {
- Flash('Star toggle failed. Try again later.', 'alert');
- });
+ })
+ .catch(() => Flash('Star toggle failed. Try again later.'));
+ });
}
}
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index 1ab4c2229ca..ebe1c6dd02d 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -1,6 +1,8 @@
+import $ from 'jquery';
+
export default function subscriptionSelect() {
$('.js-subscription-event').each((i, element) => {
- const fieldName = $(element).data('field-name');
+ const fieldName = $(element).data('fieldName');
return $(element).glDropdown({
selectable: true,
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 62bdef76c55..f52990ba232 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -1,5 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-else-return, prefer-arrow-callback, max-len */
+import $ from 'jquery';
+
// Syntax Highlighter
//
// Applies a syntax highlighting color scheme CSS class to any element with the
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index dcbec40c79e..48782e63b9b 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,4 +1,6 @@
+import $ from 'jquery';
import 'deckar01-task_list';
+import axios from './lib/utils/axios_utils';
import Flash from './flash';
export default class TaskList {
@@ -7,11 +9,11 @@ export default class TaskList {
this.dataType = options.dataType;
this.fieldName = options.fieldName;
this.onSuccess = options.onSuccess || (() => {});
- this.onError = function showFlash(response) {
+ this.onError = function showFlash(e) {
let errorMessages = '';
- if (response.responseJSON) {
- errorMessages = response.responseJSON.errors.join(' ');
+ if (e.response.data && typeof e.response.data === 'object') {
+ errorMessages = e.response.data.errors.join(' ');
}
return new Flash(errorMessages || 'Update failed', 'alert');
@@ -38,12 +40,9 @@ export default class TaskList {
patchData[this.dataType] = {
[this.fieldName]: $target.val(),
};
- return $.ajax({
- type: 'PATCH',
- url: $target.data('update-url') || $('form.js-issuable-update').attr('action'),
- data: patchData,
- success: this.onSuccess,
- error: this.onError,
- });
+
+ return axios.patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData)
+ .then(({ data }) => this.onSuccess(data))
+ .catch(err => this.onError(err));
}
}
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index 8e167f5bf08..6fea03af46a 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -1,14 +1,15 @@
/* eslint-disable no-useless-return, max-len */
+import $ from 'jquery';
import Api from '../api';
import TemplateSelector from '../blob/template_selector';
export default class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
super(...args);
- this.projectPath = this.dropdown.data('project-path');
- this.namespacePath = this.dropdown.data('namespace-path');
- this.issuableType = this.$dropdownContainer.data('issuable-type');
+ this.projectPath = this.dropdown.data('projectPath');
+ this.namespacePath = this.dropdown.data('namespacePath');
+ this.issuableType = this.$dropdownContainer.data('issuableType');
this.titleInput = $(`#${this.issuableType}_title`);
const initialQuery = {
@@ -32,8 +33,8 @@ export default class IssuableTemplateSelector extends TemplateSelector {
this.startLoadingSpinner();
Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => {
this.currentTemplate = currentTemplate;
- if (err) return; // Error handled by global AJAX error handler
this.stopLoadingSpinner();
+ if (err) return; // Error handled by global AJAX error handler
this.setInputValueToTemplateContent();
});
return;
diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/templates/issuable_template_selectors.js
index 66d868c5839..50e58ec5c46 100644
--- a/app/assets/javascripts/templates/issuable_template_selectors.js
+++ b/app/assets/javascripts/templates/issuable_template_selectors.js
@@ -1,4 +1,6 @@
/* eslint-disable no-new, class-methods-use-this */
+
+import $ from 'jquery';
import IssuableTemplateSelector from './issuable_template_selector';
export default class IssuableTemplateSelectors {
diff --git a/app/assets/javascripts/terminal/index.js b/app/assets/javascripts/terminal/index.js
new file mode 100644
index 00000000000..1a75e072c4e
--- /dev/null
+++ b/app/assets/javascripts/terminal/index.js
@@ -0,0 +1,9 @@
+import 'vendor/xterm/encoding-indexes';
+import 'vendor/xterm/encoding';
+import Terminal from 'vendor/xterm/xterm';
+import 'vendor/xterm/fit';
+import './terminal';
+
+window.Terminal = Terminal;
+
+export default () => new gl.Terminal({ selector: '#terminal' });
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index 6b9422b1816..caffcddf3b0 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -1,13 +1,21 @@
/* global Terminal */
+import $ from 'jquery';
+
(() => {
class GLTerminal {
constructor(options) {
this.options = options || {};
- this.options.cursorBlink = options.cursorBlink || true;
- this.options.screenKeys = options.screenKeys || true;
+ if (!Object.prototype.hasOwnProperty.call(this.options, 'cursorBlink')) {
+ this.options.cursorBlink = true;
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(this.options, 'screenKeys')) {
+ this.options.screenKeys = true;
+ }
+
this.container = document.querySelector(options.selector);
this.setSocketUrl();
diff --git a/app/assets/javascripts/terminal/terminal_bundle.js b/app/assets/javascripts/terminal/terminal_bundle.js
deleted file mode 100644
index 134522ef961..00000000000
--- a/app/assets/javascripts/terminal/terminal_bundle.js
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index c4c7918a68f..00000000000
--- a/app/assets/javascripts/test.js
+++ /dev/null
@@ -1 +0,0 @@
-$.fx.off = true;
diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js
deleted file mode 100644
index 748caecf153..00000000000
--- a/app/assets/javascripts/todos.js
+++ /dev/null
@@ -1,156 +0,0 @@
-/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */
-import { visitUrl } from './lib/utils/url_utility';
-import UsersSelect from './users_select';
-import { isMetaClick } from './lib/utils/common_utils';
-
-export default class Todos {
- constructor() {
- this.initFilters();
- this.bindEvents();
- this.todo_ids = [];
-
- this.cleanupWrapper = this.cleanup.bind(this);
- document.addEventListener('beforeunload', this.cleanupWrapper);
- }
-
- cleanup() {
- this.unbindEvents();
- document.removeEventListener('beforeunload', this.cleanupWrapper);
- }
-
- unbindEvents() {
- $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper);
- $('.todo').off('click', this.goToTodoUrl);
- }
-
- bindEvents() {
- this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this);
- this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this);
-
- $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper);
- $('.todo').on('click', this.goToTodoUrl);
- }
-
- initFilters() {
- this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
- this.initFilterDropdown($('.js-type-search'), 'type');
- this.initFilterDropdown($('.js-action-search'), 'action_id');
-
- return new UsersSelect();
- }
-
- initFilterDropdown($dropdown, fieldName, searchFields) {
- $dropdown.glDropdown({
- fieldName,
- selectable: true,
- filterable: searchFields ? true : false,
- search: { fields: searchFields },
- data: $dropdown.data('data'),
- clicked: () => $dropdown.closest('form.filter-form').submit(),
- });
- }
-
- updateRowStateClicked(e) {
- e.stopPropagation();
- e.preventDefault();
-
- const target = e.target;
- target.setAttribute('disabled', true);
- target.classList.add('disabled');
- $.ajax({
- type: 'POST',
- url: target.dataset.href,
- dataType: 'json',
- data: {
- '_method': target.dataset.method,
- },
- success: (data) => {
- this.updateRowState(target);
- return this.updateBadges(data);
- },
- });
- }
-
- updateRowState(target) {
- const row = target.closest('li');
- const restoreBtn = row.querySelector('.js-undo-todo');
- const doneBtn = row.querySelector('.js-done-todo');
-
- target.classList.add('hidden');
- target.removeAttribute('disabled');
- target.classList.remove('disabled');
-
- if (target === doneBtn) {
- row.classList.add('done-reversible');
- restoreBtn.classList.remove('hidden');
- } else if (target === restoreBtn) {
- row.classList.remove('done-reversible');
- doneBtn.classList.remove('hidden');
- } else {
- row.parentNode.removeChild(row);
- }
- }
-
- updateAllStateClicked(e) {
- e.stopPropagation();
- e.preventDefault();
-
- const target = e.currentTarget;
- const requestData = { '_method': target.dataset.method, ids: this.todo_ids };
- target.setAttribute('disabled', true);
- target.classList.add('disabled');
- $.ajax({
- type: 'POST',
- url: target.dataset.href,
- dataType: 'json',
- data: requestData,
- success: (data) => {
- this.updateAllState(target, data);
- return this.updateBadges(data);
- },
- });
- }
-
- updateAllState(target, data) {
- const markAllDoneBtn = document.querySelector('.js-todos-mark-all');
- const undoAllBtn = document.querySelector('.js-todos-undo-all');
- const todoListContainer = document.querySelector('.js-todos-list-container');
- const nothingHereContainer = document.querySelector('.js-nothing-here-container');
-
- target.removeAttribute('disabled');
- target.classList.remove('disabled');
-
- this.todo_ids = (target === markAllDoneBtn) ? data.updated_ids : [];
- undoAllBtn.classList.toggle('hidden');
- markAllDoneBtn.classList.toggle('hidden');
- todoListContainer.classList.toggle('hidden');
- nothingHereContainer.classList.toggle('hidden');
- }
-
- updateBadges(data) {
- $(document).trigger('todo:toggle', data.count);
- document.querySelector('.todos-pending .badge').innerHTML = data.count;
- document.querySelector('.todos-done .badge').innerHTML = data.done_count;
- }
-
- goToTodoUrl(e) {
- const todoLink = this.dataset.url;
-
- if (!todoLink || e.target.tagName === 'A' || e.target.tagName === 'IMG') {
- return;
- }
-
- e.stopPropagation();
- e.preventDefault();
-
- if (isMetaClick(e)) {
- const windowTarget = '_blank';
-
- window.open(todoLink, windowTarget);
- } else {
- visitUrl(todoLink);
- }
- }
-}
diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js
new file mode 100644
index 00000000000..199b14458ed
--- /dev/null
+++ b/app/assets/javascripts/toggle_buttons.js
@@ -0,0 +1,61 @@
+import $ from 'jquery';
+import Flash from './flash';
+import { __ } from './locale';
+import { convertPermissionToBoolean } from './lib/utils/common_utils';
+
+/*
+ example HAML:
+ ```
+ %button.js-project-feature-toggle.project-feature-toggle{ type: "button",
+ class: "#{'is-checked' if enabled?}",
+ 'aria-label': _('Toggle Kubernetes Cluster') }
+ %input{ type: "hidden", class: 'js-project-feature-toggle-input', value: enabled? }
+ ```
+*/
+
+function updateToggle(toggle, isOn) {
+ toggle.classList.toggle('is-checked', isOn);
+}
+
+function onToggleClicked(toggle, input, clickCallback) {
+ const previousIsOn = convertPermissionToBoolean(input.value);
+
+ // Visually change the toggle and start loading
+ updateToggle(toggle, !previousIsOn);
+ toggle.setAttribute('disabled', true);
+ toggle.classList.toggle('is-loading', true);
+
+ Promise.resolve(clickCallback(!previousIsOn, toggle))
+ .then(() => {
+ // Actually change the input value
+ input.setAttribute('value', !previousIsOn);
+ })
+ .catch(() => {
+ // Revert the visuals if something goes wrong
+ updateToggle(toggle, previousIsOn);
+ })
+ .then(() => {
+ // Remove the loading indicator in any case
+ toggle.removeAttribute('disabled');
+ toggle.classList.toggle('is-loading', false);
+
+ $(input).trigger('trigger-change');
+ })
+ .catch(() => {
+ Flash(__('Something went wrong when toggling the button'));
+ });
+}
+
+export default function setupToggleButtons(container, clickCallback = () => {}) {
+ const toggles = container.querySelectorAll('.js-project-feature-toggle');
+
+ toggles.forEach((toggle) => {
+ const input = toggle.querySelector('.js-project-feature-toggle-input');
+ const isOn = convertPermissionToBoolean(input.value);
+
+ // Get the visible toggle in sync with the hidden input
+ updateToggle(toggle, isOn);
+
+ toggle.addEventListener('click', onToggleClicked.bind(null, toggle, input, clickCallback));
+ });
+}
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 1a0b2c0415b..afbb958d058 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,4 +1,6 @@
/* 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 */
+
+import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
export default class TreeView {
diff --git a/app/assets/javascripts/two_factor_auth.js b/app/assets/javascripts/two_factor_auth.js
deleted file mode 100644
index e3414d9afff..00000000000
--- a/app/assets/javascripts/two_factor_auth.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import U2FRegister from './u2f/register';
-
-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 a3cc04e35fe..96af6d2fcca 100644
--- a/app/assets/javascripts/u2f/authenticate.js
+++ b/app/assets/javascripts/u2f/authenticate.js
@@ -1,7 +1,6 @@
-/* eslint-disable func-names, wrap-iife */
-/* global u2f */
+import $ from 'jquery';
import _ from 'underscore';
-import isU2FSupported from './util';
+import importU2FLibrary from './util';
import U2FError from './error';
// Authenticate U2F (universal 2nd factor) devices for users to authenticate with.
@@ -10,6 +9,7 @@ import U2FError from './error';
// State Flow #2: setup -> in_progress -> error -> setup
export default class U2FAuthenticate {
constructor(container, form, u2fParams, fallbackButton, fallbackUI) {
+ this.u2fUtils = null;
this.container = container;
this.renderNotSupported = this.renderNotSupported.bind(this);
this.renderAuthenticated = this.renderAuthenticated.bind(this);
@@ -50,22 +50,23 @@ export default class U2FAuthenticate {
}
start() {
- if (isU2FSupported()) {
- return this.renderInProgress();
- }
- return this.renderNotSupported();
+ return importU2FLibrary()
+ .then((utils) => {
+ this.u2fUtils = utils;
+ this.renderInProgress();
+ })
+ .catch(() => this.renderNotSupported());
}
authenticate() {
- return u2f.sign(this.appId, this.challenge, this.signRequests, (function (_this) {
- return function (response) {
+ return this.u2fUtils.sign(this.appId, this.challenge, this.signRequests,
+ (response) => {
if (response.errorCode) {
const error = new U2FError(response.errorCode, 'authenticate');
- return _this.renderError(error);
+ return this.renderError(error);
}
- return _this.renderAuthenticated(JSON.stringify(response));
- };
- })(this), 10);
+ return this.renderAuthenticated(JSON.stringify(response));
+ }, 10);
}
renderTemplate(name, params) {
diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js
index cc3f02e75f6..01e259a741d 100644
--- a/app/assets/javascripts/u2f/register.js
+++ b/app/assets/javascripts/u2f/register.js
@@ -1,8 +1,6 @@
-/* eslint-disable func-names, wrap-iife */
-/* global u2f */
-
+import $ from 'jquery';
import _ from 'underscore';
-import isU2FSupported from './util';
+import importU2FLibrary from './util';
import U2FError from './error';
// Register U2F (universal 2nd factor) devices for users to authenticate with.
@@ -11,6 +9,7 @@ import U2FError from './error';
// State Flow #2: setup -> in_progress -> error -> setup
export default class U2FRegister {
constructor(container, u2fParams) {
+ this.u2fUtils = null;
this.container = container;
this.renderNotSupported = this.renderNotSupported.bind(this);
this.renderRegistered = this.renderRegistered.bind(this);
@@ -34,22 +33,23 @@ export default class U2FRegister {
}
start() {
- if (isU2FSupported()) {
- return this.renderSetup();
- }
- return this.renderNotSupported();
+ return importU2FLibrary()
+ .then((utils) => {
+ this.u2fUtils = utils;
+ this.renderSetup();
+ })
+ .catch(() => this.renderNotSupported());
}
register() {
- return u2f.register(this.appId, this.registerRequests, this.signRequests, (function (_this) {
- return function (response) {
+ return this.u2fUtils.register(this.appId, this.registerRequests, this.signRequests,
+ (response) => {
if (response.errorCode) {
const error = new U2FError(response.errorCode, 'register');
- return _this.renderError(error);
+ return this.renderError(error);
}
- return _this.renderRegistered(JSON.stringify(response));
- };
- })(this), 10);
+ return this.renderRegistered(JSON.stringify(response));
+ }, 10);
}
renderTemplate(name, params) {
diff --git a/app/assets/javascripts/u2f/util.js b/app/assets/javascripts/u2f/util.js
index 9771ff935c2..5778f00332d 100644
--- a/app/assets/javascripts/u2f/util.js
+++ b/app/assets/javascripts/u2f/util.js
@@ -1,3 +1,41 @@
-export default function isU2FSupported() {
- return window.u2f;
+function isOpera(userAgent) {
+ return userAgent.indexOf('Opera') >= 0 || userAgent.indexOf('OPR') >= 0;
+}
+
+function getOperaVersion(userAgent) {
+ const match = userAgent.match(/OPR[^0-9]*([0-9]+)[^0-9]+/);
+ return match ? parseInt(match[1], 10) : false;
+}
+
+function isChrome(userAgent) {
+ return userAgent.indexOf('Chrom') >= 0 && !isOpera(userAgent);
+}
+
+function getChromeVersion(userAgent) {
+ const match = userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
+ return match ? parseInt(match[1], 10) : false;
+}
+
+export function canInjectU2fApi(userAgent) {
+ const isSupportedChrome = isChrome(userAgent) && getChromeVersion(userAgent) >= 41;
+ const isSupportedOpera = isOpera(userAgent) && getOperaVersion(userAgent) >= 40;
+ const isMobile = (
+ userAgent.indexOf('droid') >= 0 ||
+ userAgent.indexOf('CriOS') >= 0 ||
+ /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent)
+ );
+ return (isSupportedChrome || isSupportedOpera) && !isMobile;
+}
+
+export default function importU2FLibrary() {
+ if (window.u2f) {
+ return Promise.resolve(window.u2f);
+ }
+
+ const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
+ if (canInjectU2fApi(userAgent) || (gon && gon.test_env)) {
+ return import(/* webpackMode: "eager" */ 'vendor/u2f').then(() => window.u2f);
+ }
+
+ return Promise.reject();
}
diff --git a/app/assets/javascripts/ui_development_kit.js b/app/assets/javascripts/ui_development_kit.js
index f503076715c..9b242ea779d 100644
--- a/app/assets/javascripts/ui_development_kit.js
+++ b/app/assets/javascripts/ui_development_kit.js
@@ -1,6 +1,7 @@
+import $ from 'jquery';
import Api from './api';
-document.addEventListener('DOMContentLoaded', () => {
+export default () => {
$('#js-project-dropdown').glDropdown({
data: (term, callback) => {
Api.projects(term, {
@@ -19,4 +20,4 @@ document.addEventListener('DOMContentLoaded', () => {
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
deleted file mode 100644
index 2389056bd02..00000000000
--- a/app/assets/javascripts/usage_ping.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export default function UsagePing() {
- const usageDataUrl = $('.usage-data').data('endpoint');
-
- $.ajax({
- type: 'GET',
- url: usageDataUrl,
- dataType: 'html',
- success(html) {
- $('.usage-data').html(html);
- },
- });
-}
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index a45b22f3084..97d5cf96bcb 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Cookies from 'js-cookie';
export default class UserCallout {
@@ -22,7 +23,7 @@ export default class UserCallout {
const $currentTarget = $(e.currentTarget);
if (this.options.setCalloutPerProject) {
- Cookies.set(this.cookieName, 'true', { expires: 365, path: this.userCalloutBody.data('project-path') });
+ Cookies.set(this.cookieName, 'true', { expires: 365, path: this.userCalloutBody.data('projectPath') });
} else {
Cookies.set(this.cookieName, 'true', { expires: 365 });
}
diff --git a/app/assets/javascripts/username_validator.js b/app/assets/javascripts/username_validator.js
deleted file mode 100644
index bb34d5d2008..00000000000
--- a/app/assets/javascripts/username_validator.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/* eslint-disable comma-dangle, consistent-return, class-methods-use-this, arrow-parens, no-param-reassign, max-len */
-
-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) {
- return debounceTimeout(username);
- }
-
- this.renderState();
- });
-
- // Override generic field validation
- this.inputElement.on('invalid', this.interceptInvalid.bind(this));
- }
-
- renderState() {
- // Clear all state
- this.clearFieldValidationState();
-
- if (this.state.valid && this.state.available) {
- return this.setSuccessState();
- }
-
- if (this.state.empty) {
- return this.clearFieldValidationState();
- }
-
- if (this.state.pending) {
- return this.setPendingState();
- }
-
- if (!this.state.available) {
- return this.setUnavailableState();
- }
-
- if (!this.state.valid) {
- return this.setInvalidState();
- }
- }
-
- interceptInvalid(event) {
- event.preventDefault();
- event.stopPropagation();
- }
-
- 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)
- });
- }
- }
-
- setAvailabilityState(usernameTaken) {
- if (usernameTaken) {
- this.state.valid = false;
- this.state.available = false;
- } else {
- this.state.available = true;
- }
- this.state.pending = false;
- this.renderState();
- }
-
- 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();
- }
- }
-
- 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
deleted file mode 100644
index 0581239d5a5..00000000000
--- a/app/assets/javascripts/users/activity_calendar.js
+++ /dev/null
@@ -1,237 +0,0 @@
-import _ from 'underscore';
-import { scaleLinear, scaleThreshold } from 'd3-scale';
-import { select } from 'd3-selection';
-import { getDayName, getDayDifference } from '../lib/utils/datetime_utility';
-
-const d3 = { select, scaleLinear, scaleThreshold };
-
-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 = 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.scaleLinear().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 = 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.scaleThreshold().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/index.js b/app/assets/javascripts/users/index.js
deleted file mode 100644
index 9fd8452a2b6..00000000000
--- a/app/assets/javascripts/users/index.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Cookies from 'js-cookie';
-import UserTabs from './user_tabs';
-
-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();
- });
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- const page = $('body').attr('data-page');
- const action = page.split(':')[1];
- initUserProfile(action);
-});
diff --git a/app/assets/javascripts/users/user_tabs.js b/app/assets/javascripts/users/user_tabs.js
deleted file mode 100644
index 992baa9a1ef..00000000000
--- a/app/assets/javascripts/users/user_tabs.js
+++ /dev/null
@@ -1,196 +0,0 @@
-import Activities from '../activities';
-import ActivityCalendar from './activity_calendar';
-import { localTimeAgo } from '../lib/utils/datetime_utility';
-
-/**
- * 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;
- 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 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_select.js b/app/assets/javascripts/users_select.js
index 759cc9925f4..520a0b3f424 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,7 +1,11 @@
/* 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 emitSidebarEvent */
+
+import $ from 'jquery';
import _ from 'underscore';
+import axios from './lib/utils/axios_utils';
+import ModalStore from './boards/stores/modal_store';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -33,23 +37,22 @@ function UsersSelect(currentUser, els, options = {}) {
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');
+ options.projectId = $dropdown.data('projectId');
+ options.groupId = $dropdown.data('groupId');
+ options.showCurrentUser = $dropdown.data('currentUser');
+ options.todoFilter = $dropdown.data('todoFilter');
+ options.todoStateFilter = $dropdown.data('todoStateFilter');
+ showNullUser = $dropdown.data('nullUser');
+ defaultNullUser = $dropdown.data('nullUserDefault');
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');
+ showAnyUser = $dropdown.data('anyUser');
+ firstUser = $dropdown.data('firstUser');
+ options.authorId = $dropdown.data('authorId');
+ defaultLabel = $dropdown.data('defaultLabel');
issueURL = $dropdown.data('issueUpdate');
$selectbox = $dropdown.closest('.selectbox');
$block = $selectbox.closest('.block');
- abilityName = $dropdown.data('ability-name');
+ abilityName = $dropdown.data('abilityName');
$value = $block.find('.value');
$collapsedSidebar = $block.find('.sidebar-collapsed-user');
$loading = $block.find('.block-loading').fadeOut();
@@ -62,7 +65,7 @@ function UsersSelect(currentUser, els, options = {}) {
const assignYourself = function () {
const unassignedSelected = $dropdown.closest('.selectbox')
- .find(`input[name='${$dropdown.data('field-name')}'][value=0]`);
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
if (unassignedSelected) {
unassignedSelected.remove();
@@ -71,7 +74,7 @@ function UsersSelect(currentUser, els, options = {}) {
// Save current selected user to the DOM
const input = document.createElement('input');
input.type = 'hidden';
- input.name = $dropdown.data('field-name');
+ input.name = $dropdown.data('fieldName');
const currentUserInfo = $dropdown.data('currentUserInfo');
@@ -95,7 +98,7 @@ function UsersSelect(currentUser, els, options = {}) {
const getSelectedUserInputs = function() {
return $selectbox
- .find(`input[name="${$dropdown.data('field-name')}"]`);
+ .find(`input[name="${$dropdown.data('fieldName')}"]`);
};
const getSelected = function() {
@@ -105,14 +108,14 @@ function UsersSelect(currentUser, els, options = {}) {
};
const checkMaxSelect = function() {
- const maxSelect = $dropdown.data('max-select');
+ const maxSelect = $dropdown.data('maxSelect');
if (maxSelect) {
const selected = getSelected();
if (selected.length > maxSelect) {
const firstSelectedId = selected[0];
const firstSelected = $dropdown.closest('.selectbox')
- .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`);
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
firstSelected.remove();
emitSidebarEvent('sidebar.removeAssignee', {
@@ -157,7 +160,7 @@ function UsersSelect(currentUser, els, options = {}) {
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')}"]`);
+ const $input = $(`input[name="${$dropdown.data('fieldName')}"]`);
$input.val(gon.current_user_id);
selectedId = $input.val();
$dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
@@ -177,32 +180,28 @@ function UsersSelect(currentUser, els, options = {}) {
$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', _.escape(user.name)).tooltip('fixTitle');
- return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
- });
+ return axios.put(issueURL, data)
+ .then(({ 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', _.escape(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> <% } %>');
@@ -296,10 +295,10 @@ function UsersSelect(currentUser, els, options = {}) {
const selected = getSelected().filter(i => i !== 0);
if (selected.length > 0) {
- if ($dropdown.data('dropdown-header')) {
+ if ($dropdown.data('dropdownHeader')) {
showDivider += 1;
users.splice(showDivider, 0, {
- header: $dropdown.data('dropdown-header'),
+ header: $dropdown.data('dropdownHeader'),
});
}
@@ -330,7 +329,7 @@ function UsersSelect(currentUser, els, options = {}) {
fields: ['name', 'username']
},
selectable: true,
- fieldName: $dropdown.data('field-name'),
+ fieldName: $dropdown.data('fieldName'),
toggleLabel: function(selected, el, glDropdown) {
const inputValue = glDropdown.filterInput.val();
@@ -365,7 +364,7 @@ function UsersSelect(currentUser, els, options = {}) {
emitSidebarEvent('sidebar.saveAssignees');
}
- if (!$dropdown.data('always-show-selectbox')) {
+ if (!$dropdown.data('alwaysShowSelectbox')) {
$selectbox.hide();
// Recalculate where .value is because vue might have changed it
@@ -376,7 +375,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
- inputMeta: $dropdown.data('input-meta'),
+ inputMeta: $dropdown.data('inputMeta'),
clicked: function(options) {
const { $el, e, isMarking } = options;
const user = options.selectedObj;
@@ -384,7 +383,7 @@ function UsersSelect(currentUser, els, options = {}) {
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]");
+ .find("input[name='" + ($dropdown.data('fieldName')) + "'][value!=0]");
// Enables support for limiting the number of users selected
// Automatically removes the first on the list if more users are selected
@@ -403,7 +402,7 @@ function UsersSelect(currentUser, els, options = {}) {
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown.closest('.selectbox')
- .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
+ .find("input[name='" + ($dropdown.data('fieldName')) + "'][value=0]");
if (unassignedSelected) {
unassignedSelected.remove();
@@ -411,7 +410,7 @@ function UsersSelect(currentUser, els, options = {}) {
} else {
if (previouslySelected.length === 0) {
// Select unassigned because there is no more selected users
- this.addInput($dropdown.data('field-name'), 0, {});
+ this.addInput($dropdown.data('fieldName'), 0, {});
}
// User unselected
@@ -443,7 +442,7 @@ function UsersSelect(currentUser, els, options = {}) {
return;
}
if ($el.closest('.add-issues-modal').length) {
- gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
+ ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
} else if (handleClick) {
e.preventDefault();
handleClick(user, isMarking);
@@ -452,15 +451,15 @@ function UsersSelect(currentUser, els, options = {}) {
} 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();
+ selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('fieldName')) + "']").val();
return assignTo(selected);
}
// 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')) {
+ if ($dropdown.data('maxSelect') &&
+ getSelected().length === $dropdown.data('maxSelect')) {
// Close the dropdown
$dropdown.dropdown('toggle');
}
@@ -472,7 +471,7 @@ function UsersSelect(currentUser, els, options = {}) {
const $el = $(e.currentTarget);
const selected = getSelected();
if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
- this.addInput($dropdown.data('field-name'), 0, {});
+ this.addInput($dropdown.data('fieldName'), 0, {});
}
$el.find('.is-active').removeClass('is-active');
@@ -488,11 +487,11 @@ function UsersSelect(currentUser, els, options = {}) {
highlightSelected(selectedId);
}
},
- updateLabel: $dropdown.data('dropdown-title'),
+ updateLabel: $dropdown.data('dropdownTitle'),
renderRow: function(user) {
var avatar, img, listClosingTags, listWithName, listWithUserName, username;
username = user.username ? "@" + user.username : "";
- avatar = user.avatar_url ? user.avatar_url : false;
+ avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
let selected = false;
@@ -513,9 +512,7 @@ function UsersSelect(currentUser, els, options = {}) {
if (user.beforeDivider != null) {
`<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(user.name)}</a></li>`;
} else {
- if (avatar) {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
- }
+ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
}
return `
@@ -538,16 +535,15 @@ function UsersSelect(currentUser, els, options = {}) {
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');
+ options.projectId = $(select).data('projectId');
+ options.groupId = $(select).data('groupId');
+ options.showCurrentUser = $(select).data('currentUser');
+ options.authorId = $(select).data('authorId');
+ options.skipUsers = $(select).data('skipUsers');
+ showNullUser = $(select).data('nullUser');
+ showAnyUser = $(select).data('anyUser');
+ showEmailUser = $(select).data('emailUser');
+ firstUser = $(select).data('firstUser');
return $(select).select2({
placeholder: "Search for a user",
multiple: $(select).hasClass('multiselect'),
@@ -663,39 +659,32 @@ UsersSelect.prototype.user = function(user_id, callback) {
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 axios.get(url)
+ .then(({ data }) => {
+ callback(data);
+ });
};
// 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);
- });
+ const url = this.buildUrl(this.usersPath);
+ const params = {
+ search: query,
+ 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,
+ author_id: options.authorId || null,
+ skip_users: options.skipUsers || null
+ };
+ return axios.get(url, { params })
+ .then(({ data }) => {
+ callback(data);
+ });
};
UsersSelect.prototype.buildUrl = function(url) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
new file mode 100644
index 00000000000..7bef2e97349
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -0,0 +1,144 @@
+<script>
+import timeagoMixin from '../../vue_shared/mixins/timeago';
+import tooltip from '../../vue_shared/directives/tooltip';
+import LoadingButton from '../../vue_shared/components/loading_button.vue';
+import { visitUrl } from '../../lib/utils/url_utility';
+import createFlash from '../../flash';
+import MemoryUsage from './memory_usage.vue';
+import StatusIcon from './mr_widget_status_icon.vue';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'Deployment',
+ components: {
+ LoadingButton,
+ MemoryUsage,
+ StatusIcon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
+ props: {
+ deployment: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isStopping: false,
+ };
+ },
+ computed: {
+ deployTimeago() {
+ return this.timeFormated(this.deployment.deployed_at);
+ },
+ hasExternalUrls() {
+ return !!(this.deployment.external_url && this.deployment.external_url_formatted);
+ },
+ hasDeploymentTime() {
+ return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted);
+ },
+ hasDeploymentMeta() {
+ return !!(this.deployment.url && this.deployment.name);
+ },
+ hasMetrics() {
+ return !!(this.deployment.metrics_url);
+ },
+ },
+ methods: {
+ stopEnvironment() {
+ const msg = 'Are you sure you want to stop this environment?';
+ const isConfirmed = confirm(msg); // eslint-disable-line
+
+ if (isConfirmed) {
+ this.isStopping = true;
+
+ MRWidgetService.stopEnvironment(this.deployment.stop_url)
+ .then(res => res.data)
+ .then((data) => {
+ if (data.redirect_url) {
+ visitUrl(data.redirect_url);
+ }
+
+ this.isStopping = false;
+ })
+ .catch(() => {
+ createFlash('Something went wrong while stopping this environment. Please try again.');
+ this.isStopping = false;
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-heading deploy-heading">
+ <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">
+ <div class="deploy-body">
+ <template v-if="hasDeploymentMeta">
+ <span>
+ Deployed to
+ </span>
+ <a
+ :href="deployment.url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="deploy-link js-deploy-meta"
+ >
+ {{ deployment.name }}
+ </a>
+ </template>
+ <template v-if="hasExternalUrls">
+ <span>
+ on
+ </span>
+ <a
+ :href="deployment.external_url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="deploy-link js-deploy-url"
+ >
+ <i
+ class="fa fa-external-link"
+ aria-hidden="true"
+ >
+ </i>
+ {{ deployment.external_url_formatted }}
+ </a>
+ </template>
+ <span
+ v-if="hasDeploymentTime"
+ v-tooltip
+ :title="deployment.deployed_at_formatted"
+ class="js-deploy-time"
+ >
+ {{ deployTimeago }}
+ </span>
+ <loading-button
+ v-if="deployment.stop_url"
+ container-class="btn btn-default btn-xs prepend-left-default"
+ label="Stop environment"
+ :loading="isStopping"
+ @click="stopEnvironment"
+ />
+ </div>
+ <memory-usage
+ v-if="hasMetrics"
+ :metrics-url="deployment.metrics_url"
+ :metrics-monitoring-url="deployment.metrics_monitoring_url"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
new file mode 100644
index 00000000000..95c8b0a4c55
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
@@ -0,0 +1,170 @@
+<script>
+import { sprintf, s__ } from '~/locale';
+import statusCodes from '../../lib/utils/http_status';
+import { bytesToMiB } from '../../lib/utils/number_utils';
+import { backOff } from '../../lib/utils/common_utils';
+import MemoryGraph from '../../vue_shared/components/memory_graph.vue';
+import MRWidgetService from '../services/mr_widget_service';
+
+export default {
+ name: 'MemoryUsage',
+ components: {
+ MemoryGraph,
+ },
+ 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,
+ };
+ },
+ 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;
+ },
+ memoryChangeMessage() {
+ const messageProps = {
+ memoryFrom: this.memoryFrom,
+ memoryTo: this.memoryTo,
+ metricsLinkStart: `<a href="${this.metricsMonitoringUrl}">`,
+ metricsLinkEnd: '</a>',
+ emphasisStart: '<b>',
+ emphasisEnd: '</b>',
+ };
+ const memoryTo = Number(this.memoryTo);
+ const memoryFrom = Number(this.memoryFrom);
+ let memoryUsageMsg = '';
+
+ if (memoryTo > memoryFrom) {
+ memoryUsageMsg = sprintf(s__('mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB'), messageProps, false);
+ } else if (memoryTo < memoryFrom) {
+ memoryUsageMsg = sprintf(s__('mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB'), messageProps, false);
+ } else {
+ memoryUsageMsg = sprintf(s__('mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB'), messageProps, false);
+ }
+
+ return memoryUsageMsg;
+ },
+ },
+ mounted() {
+ this.loadingMetrics = true;
+ this.loadMetrics();
+ },
+ 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() {
+ 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.data;
+ })
+ .then((data) => {
+ this.computeGraphData(data.metrics, data.deployment_time);
+ return data;
+ })
+ .catch(() => {
+ this.loadFailed = true;
+ this.loadingMetrics = false;
+ });
+ },
+ },
+};
+</script>
+
+<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">
+ </i>{{ s__('mrWidget|Loading deployment statistics') }}
+ </p>
+ <p
+ v-if="shouldShowMemoryGraph"
+ class="usage-info js-usage-info">
+ {{ memoryChangeMessage }}
+ </p>
+ <p
+ v-if="shouldShowLoadFailure"
+ class="usage-info js-usage-info usage-info-failed">
+ {{ s__('mrWidget|Failed to load deployment statistics') }}
+ </p>
+ <p
+ v-if="shouldShowMetricsUnavailable"
+ class="usage-info js-usage-info usage-info-unavailable">
+ {{ s__('mrWidget|Deployment statistics are not available currently') }}
+ </p>
+ <memory-graph
+ v-if="shouldShowMemoryGraph"
+ :metrics="memoryMetrics"
+ :deployment-time="deploymentTime"
+ height="25"
+ width="100"
+ />
+ </div>
+</template>
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
deleted file mode 100644
index 982b5e8e373..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js
+++ /dev/null
@@ -1,28 +0,0 @@
-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.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
new file mode 100644
index 00000000000..cb6e9858736
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
@@ -0,0 +1,53 @@
+<script>
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ name: 'MRWidgetAuthor',
+ directives: {
+ tooltip,
+ },
+ props: {
+ author: {
+ type: Object,
+ required: true,
+ },
+ showAuthorName: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showAuthorTooltip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ authorUrl() {
+ return this.author.webUrl || this.author.web_url;
+ },
+ avatarUrl() {
+ return this.author.avatarUrl || this.author.avatar_url;
+ },
+ },
+ };
+</script>
+<template>
+ <a
+ :href="authorUrl"
+ class="author-link inline"
+ :v-tooltip="showAuthorTooltip"
+ :title="author.name"
+ >
+ <img
+ :src="avatarUrl"
+ class="avatar avatar-inline s16"
+ />
+ <span
+ class="author"
+ v-if="showAuthorName"
+ >
+ {{ author.name }}
+ </span>
+ </a>
+</template>
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
deleted file mode 100644
index 6d2ed5fda64..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js
+++ /dev/null
@@ -1,27 +0,0 @@
-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_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
new file mode 100644
index 00000000000..8f1fd809a81
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
@@ -0,0 +1,42 @@
+<script>
+ import mrWidgetAuthor from './mr_widget_author.vue';
+
+ export default {
+ name: 'MRWidgetAuthorTime',
+ components: {
+ mrWidgetAuthor,
+ },
+ props: {
+ actionText: {
+ type: String,
+ required: true,
+ },
+ author: {
+ type: Object,
+ required: true,
+ },
+ dateTitle: {
+ type: String,
+ required: true,
+ },
+ dateReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+<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>
+</template>
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
deleted file mode 100644
index ee1a45cc754..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import { getTimeago } from '~/lib/utils/datetime_utility';
-import { visitUrl } from '../../lib/utils/url_utility';
-import Flash from '../../flash';
-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 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) {
- 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
deleted file mode 100644
index 13e4cb5717e..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import tooltip from '../../vue_shared/directives/tooltip';
-import { pluralize } from '../../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 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-sm inline">
- Check out branch
- </a>
- <span class="dropdown prepend-left-10">
- <a
- class="btn btn-sm inline dropdown-toggle"
- data-toggle="dropdown"
- aria-label="Download as"
- role="button">
- <i
- class="fa fa-download"
- aria-hidden="true">
- </i>
- <i
- class="fa fa-caret-down"
- aria-hidden="true">
- </i>
- </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_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
new file mode 100644
index 00000000000..18ee4c62bf1
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -0,0 +1,157 @@
+<script>
+import tooltip from '~/vue_shared/directives/tooltip';
+import { n__ } from '~/locale';
+import { webIDEUrl } from '~/lib/utils/url_utility';
+import icon from '~/vue_shared/components/icon.vue';
+import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ name: 'MRWidgetHeader',
+ directives: {
+ tooltip,
+ },
+ components: {
+ icon,
+ clipboardButton,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ shouldShowCommitsBehindText() {
+ return this.mr.divergedCommitsCount > 0;
+ },
+ commitsText() {
+ return n__('%d commit behind', '%d commits behind', 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}\``,
+ });
+ },
+ isSourceBranchLong() {
+ return this.isBranchTitleLong(this.mr.sourceBranch);
+ },
+ isTargetBranchLong() {
+ return this.isBranchTitleLong(this.mr.targetBranch);
+ },
+ webIdePath() {
+ return webIDEUrl(this.mr.statusPath.replace('.json', ''));
+ },
+ },
+ methods: {
+ isBranchTitleLong(branchTitle) {
+ return branchTitle.length > 32;
+ },
+ },
+};
+</script>
+<template>
+ <div class="mr-source-target">
+ <div class="normal">
+ <strong>
+ {{ s__("mrWidget|Request to merge") }}
+ <span
+ class="label-branch js-source-branch"
+ :class="{ 'label-truncated': isSourceBranchLong }"
+ :title="isSourceBranchLong ? mr.sourceBranch : ''"
+ data-placement="bottom"
+ :v-tooltip="isSourceBranchLong"
+ v-html="mr.sourceBranchLink"
+ >
+ </span>
+
+ <clipboard-button
+ :text="branchNameClipboardData"
+ :title="__('Copy branch name to clipboard')"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+
+ {{ s__("mrWidget|into") }}
+
+ <span
+ class="label-branch"
+ :v-tooltip="isTargetBranchLong"
+ :class="{ 'label-truncatedtooltip': isTargetBranchLong }"
+ :title="isTargetBranchLong ? mr.targetBranch : ''"
+ data-placement="bottom"
+ >
+ <a
+ :href="mr.targetBranchTreePath"
+ class="js-target-branch"
+ >
+ {{ mr.targetBranch }}
+ </a>
+ </span>
+ </strong>
+ <span
+ v-if="shouldShowCommitsBehindText"
+ class="diverged-commits-count"
+ >
+ (<a :href="mr.targetBranchPath">{{ commitsText }}</a>)
+ </span>
+ </div>
+
+ <div v-if="mr.isOpen">
+ <a
+ v-if="!mr.sourceBranchRemoved"
+ :href="webIdePath"
+ class="btn btn-sm btn-default inline js-web-ide"
+ >
+ {{ s__("mrWidget|Web IDE") }}
+ </a>
+ <button
+ data-target="#modal_merge_info"
+ data-toggle="modal"
+ :disabled="mr.sourceBranchRemoved"
+ class="btn btn-sm btn-default inline js-check-out-branch"
+ type="button"
+ >
+ {{ s__("mrWidget|Check out branch") }}
+ </button>
+ <span class="dropdown prepend-left-10">
+ <button
+ type="button"
+ class="btn btn-sm inline dropdown-toggle"
+ data-toggle="dropdown"
+ aria-label="Download as"
+ aria-haspopup="true"
+ aria-expanded="false"
+ >
+ <icon name="download" />
+ <i
+ class="fa fa-caret-down"
+ aria-hidden="true">
+ </i>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-align-right">
+ <li>
+ <a
+ class="js-download-email-patches"
+ :href="mr.emailPatchesPath"
+ download
+ >
+ {{ s__("mrWidget|Email patches") }}
+ </a>
+ </li>
+ <li>
+ <a
+ class="js-download-plain-diff"
+ :href="mr.plainDiffPath"
+ download
+ >
+ {{ s__("mrWidget|Plain diff") }}
+ </a>
+ </li>
+ </ul>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue
new file mode 100644
index 00000000000..f0298f732ea
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue
@@ -0,0 +1,20 @@
+<script>
+ export default {
+ name: 'MRWidgetMaintainerEdit',
+ props: {
+ maintainerEditAllowed: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ };
+</script>
+
+<template>
+ <section class="mr-info-list mr-links">
+ <p v-if="maintainerEditAllowed">
+ {{ s__("mrWidget|Allows edits from maintainers") }}
+ </p>
+ </section>
+</template>
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
deleted file mode 100644
index a8c686e5065..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js
+++ /dev/null
@@ -1,153 +0,0 @@
-import statusCodes from '../../lib/utils/http_status';
-import { bytesToMiB } from '../../lib/utils/number_utils';
-import { backOff } from '../../lib/utils/common_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() {
- 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
deleted file mode 100644
index 1d9f9863dd9..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js
+++ /dev/null
@@ -1,23 +0,0 @@
-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_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
new file mode 100644
index 00000000000..62b61e1f41f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
@@ -0,0 +1,41 @@
+<script>
+ import { sprintf, s__ } from '~/locale';
+
+ export default {
+ name: 'MRWidgetMergeHelp',
+ props: {
+ missingBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ missingBranchInfo() {
+ return sprintf(
+ s__('mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the'),
+ { branch: this.missingBranch },
+ );
+ },
+ },
+ };
+</script>
+<template>
+ <section class="mr-widget-help">
+ <template v-if="missingBranch">
+ {{ missingBranchInfo }}
+ </template>
+ <template v-else>
+ {{ s__("mrWidget|You can merge this merge request manually using the") }}
+ </template>
+
+ <button
+ type="button"
+ class="btn-link btn-blank js-open-modal-help"
+ data-toggle="modal"
+ data-target="#modal_merge_info"
+ >
+ {{ s__("mrWidget|command line") }}
+ </button>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index dbc65462377..48dff8c4916 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -1,55 +1,61 @@
<script>
- import pipelineStage from '../../pipelines/components/stage.vue';
- import ciIcon from '../../vue_shared/components/ci_icon.vue';
- import icon from '../../vue_shared/components/icon.vue';
+/* eslint-disable vue/require-default-prop */
+import PipelineStage from '~/pipelines/components/stage.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
- export default {
- name: 'MRWidgetPipeline',
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- // This prop needs to be camelCase, html attributes are case insensive
- // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case
- hasCi: {
- type: Boolean,
- required: false,
- },
- ciStatus: {
- type: String,
- required: false,
- },
+export default {
+ name: 'MRWidgetPipeline',
+ components: {
+ PipelineStage,
+ CiIcon,
+ Icon,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
},
- components: {
- pipelineStage,
- ciIcon,
- icon,
+ // This prop needs to be camelCase, html attributes are case insensive
+ // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case
+ hasCi: {
+ type: Boolean,
+ required: false,
},
- computed: {
- hasPipeline() {
- return this.pipeline && Object.keys(this.pipeline).length > 0;
- },
- hasCIError() {
- return this.hasCi && !this.ciStatus;
- },
- status() {
- return this.pipeline.details &&
- this.pipeline.details.status ? this.pipeline.details.status : {};
- },
- hasStages() {
- return this.pipeline.details &&
- this.pipeline.details.stages &&
- this.pipeline.details.stages.length;
- },
+ ciStatus: {
+ type: String,
+ required: false,
},
- };
+ },
+ computed: {
+ hasPipeline() {
+ return this.pipeline && Object.keys(this.pipeline).length > 0;
+ },
+ hasCIError() {
+ return this.hasCi && !this.ciStatus;
+ },
+ status() {
+ return this.pipeline.details && this.pipeline.details.status
+ ? this.pipeline.details.status
+ : {};
+ },
+ hasStages() {
+ return (
+ this.pipeline.details && this.pipeline.details.stages && this.pipeline.details.stages.length
+ );
+ },
+ hasCommitInfo() {
+ return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
+ },
+ },
+};
</script>
<template>
<div
v-if="hasPipeline || hasCIError"
- class="mr-widget-heading">
+ 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">
@@ -62,7 +68,8 @@
<template v-else-if="hasPipeline">
<a
class="append-right-10"
- :href="this.status.details_path">
+ :href="status.details_path"
+ >
<ci-icon :status="status" />
</a>
@@ -70,33 +77,41 @@
Pipeline
<a
:href="pipeline.path"
- class="pipeline-id">
- #{{pipeline.id}}
+ class="pipeline-id"
+ >
+ #{{ pipeline.id }}
</a>
- {{pipeline.details.status.label}} for
+ {{ pipeline.details.status.label }}
- <a
- :href="pipeline.commit.commit_path"
- class="commit-sha js-commit-link">
- {{pipeline.commit.short_id}}</a>.
+ <template v-if="hasCommitInfo">
+ for
+
+ <a
+ :href="pipeline.commit.commit_path"
+ class="commit-sha js-commit-link"
+ >
+ {{ pipeline.commit.short_id }}</a>.
+ </template>
<span class="mr-widget-pipeline-graph">
- <span class="stage-cell">
+ <span
+ class="stage-cell"
+ v-if="hasStages"
+ >
<div
- v-if="hasStages"
v-for="(stage, i) in pipeline.details.stages"
:key="i"
- class="stage-container dropdown js-mini-pipeline-graph">
+ class="stage-container dropdown js-mini-pipeline-graph"
+ >
<pipeline-stage :stage="stage" />
</div>
</span>
</span>
<template v-if="pipeline.coverage">
- Coverage {{pipeline.coverage}}%
+ Coverage {{ pipeline.coverage }}%
</template>
-
</div>
</template>
</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
deleted file mode 100644
index 563267ad044..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js
+++ /dev/null
@@ -1,37 +0,0 @@
-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_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
new file mode 100644
index 00000000000..88d0fcd70f5
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -0,0 +1,43 @@
+<script>
+ import { s__ } from '~/locale';
+
+ export default {
+ name: 'MRWidgetRelatedLinks',
+ props: {
+ relatedLinks: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ state: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ closesText() {
+ if (this.state === 'merged') {
+ return s__('mrWidget|Closed');
+ }
+ if (this.state === 'closed') {
+ return s__('mrWidget|Did not close');
+ }
+ return s__('mrWidget|Closes');
+ },
+ },
+ };
+</script>
+<template>
+ <section class="mr-info-list mr-links">
+ <p v-if="relatedLinks.closing">
+ {{ closesText }} <span v-html="relatedLinks.closing"></span>
+ </p>
+ <p v-if="relatedLinks.mentioned">
+ {{ s__("mrWidget|Mentions") }} <span v-html="relatedLinks.mentioned"></span>
+ </p>
+ <p v-if="relatedLinks.assignToMe">
+ <span v-html="relatedLinks.assignToMe"></span>
+ </p>
+ </section>
+</template>
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
deleted file mode 100644
index eeb990908f6..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.js
+++ /dev/null
@@ -1,36 +0,0 @@
-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: `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="js-disabled-merge-button btn btn-success btn-sm"
- disabled="true">
- Merge
- </button>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
new file mode 100644
index 00000000000..1fdc3218671
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -0,0 +1,57 @@
+<script>
+ import ciIcon from '../../vue_shared/components/ci_icon.vue';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+ export default {
+ components: {
+ ciIcon,
+ loadingIcon,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ showDisabledButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.status === 'loading';
+ },
+ statusObj() {
+ return {
+ group: this.status,
+ icon: `status_${this.status}`,
+ };
+ },
+ },
+ };
+</script>
+<template>
+ <div class="space-children flex-container-block append-right-10">
+ <div
+ v-if="isLoading"
+ class="mr-widget-icon"
+ >
+ <loading-icon />
+ </div>
+
+ <ci-icon
+ v-else
+ :status="statusObj"
+ />
+
+ <button
+ v-if="showDisabledButton"
+ type="button"
+ class="js-disabled-merge-button btn btn-success btn-sm"
+ disabled="true"
+ >
+ {{ s__("mrWidget|Merge") }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
new file mode 100644
index 00000000000..460437ceeff
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
@@ -0,0 +1,34 @@
+<script>
+ import tooltip from '../../vue_shared/directives/tooltip';
+ import { __ } from '../../locale';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ created() {
+ this.removesBranchText = __('<strong>Removes</strong> source branch');
+ this.tooltipTitle = __('A user with write access to the source branch selected this option');
+ },
+ };
+</script>
+
+<template>
+ <p
+ v-once
+ class="mr-info-list mr-links source-branch-removal-status append-bottom-0"
+ >
+ <span
+ class="status-text"
+ v-html="removesBranchText"
+ >
+ </span>
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ :title="tooltipTitle"
+ :aria-label="tooltipTitle"
+ >
+ </i>
+ </p>
+</template>
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
deleted file mode 100644
index b4e4a6aa161..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js
+++ /dev/null
@@ -1,26 +0,0 @@
-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-sm"
- 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_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
new file mode 100644
index 00000000000..cfbd44d41b2
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -0,0 +1,31 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetArchived',
+ components: {
+ statusIcon,
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <div class="space-children">
+ <status-icon
+ status="warning"
+ />
+ <button
+ type="button"
+ class="btn btn-success btn-sm"
+ disabled="true"
+ >
+ {{ s__("mrWidget|Merge") }}
+ </button>
+ </div>
+ <div class="media-body">
+ <span class="bold">
+ {{ s__("mrWidget|This project is archived, write access has been disabled") }}
+ </span>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 5648208f7b1..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js
+++ /dev/null
@@ -1,47 +0,0 @@
-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_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
new file mode 100644
index 00000000000..ebaf2b972eb
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -0,0 +1,55 @@
+<script>
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import eventHub from '../../event_hub';
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetAutoMergeFailed',
+ components: {
+ statusIcon,
+ loadingIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isRefreshing: false,
+ };
+ },
+ methods: {
+ refreshWidget() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isRefreshing = false;
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon status="warning" />
+ <div class="media-body space-children">
+ <span class="bold">
+ <template v-if="mr.mergeError">{{ mr.mergeError }}.</template>
+ {{ s__("mrWidget|This merge request failed to be merged automatically") }}
+ </span>
+ <button
+ @click="refreshWidget"
+ :disabled="isRefreshing"
+ type="button"
+ class="btn btn-xs btn-default"
+ >
+ <loading-icon
+ v-if="isRefreshing"
+ :inline="true"
+ />
+ {{ s__("mrWidget|Refresh") }}
+ </button>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 09561694939..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetChecking',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="loading" :show-disabled-button="true" />
- <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_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
new file mode 100644
index 00000000000..caeaac75b45
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -0,0 +1,23 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetChecking',
+ components: {
+ statusIcon,
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="loading"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ {{ s__("mrWidget|Checking ability to merge automatically") }}
+ </span>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index b25cc3443ef..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js
+++ /dev/null
@@ -1,35 +0,0 @@
-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.closedEvent.author"
- :dateTitle="mr.closedEvent.updatedAt"
- :dateReadable="mr.closedEvent.formattedUpdatedAt"
- />
- <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_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
new file mode 100644
index 00000000000..68b691fc914
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -0,0 +1,48 @@
+<script>
+ import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetClosed',
+ components: {
+ mrWidgetAuthorTime,
+ statusIcon,
+ },
+ props: {
+ /* TODO: This is providing all store and service down when it
+ only needs metrics and targetBranch */
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ />
+ <div class="media-body">
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Closed by')"
+ :author="mr.metrics.closedBy"
+ :date-title="mr.metrics.closedAt"
+ :date-readable="mr.metrics.readableClosedAt"
+ />
+
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes were not merged into") }}
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch"
+ >
+ {{ mr.targetBranch }}
+ </a>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 5d468a085cb..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js
+++ /dev/null
@@ -1,47 +0,0 @@
-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"
- :show-disabled-button="true" />
- <div class="media-body space-children">
- <span
- v-if="mr.shouldBeRebased"
- class="bold">
- Fast-forward merge is not possible.
- To merge this request, first rebase locally.
- </span>
- <template v-else>
- <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="js-resolve-conflicts-button btn btn-default btn-xs">
- Resolve conflicts
- </a>
- <a
- v-if="mr.canMerge"
- class="js-merge-locally-button btn btn-default btn-xs"
- data-toggle="modal"
- href="#modal_merge_info">
- Merge locally
- </a>
- </template>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
new file mode 100644
index 00000000000..dad4b0fe49d
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -0,0 +1,61 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetConflicts',
+ components: {
+ statusIcon,
+ },
+ props: {
+ /* TODO: This is providing all store and service down when it
+ only needs a few props */
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+
+ <div class="media-body space-children">
+ <span
+ v-if="mr.shouldBeRebased"
+ class="bold"
+ >
+ {{ s__(`mrWidget|Fast-forward merge is not possible.
+To merge this request, first rebase locally.`) }}
+ </span>
+ <template v-else>
+ <span class="bold">
+ {{ s__("mrWidget|There are merge conflicts") }}<span v-if="!mr.canMerge">.</span>
+ <span v-if="!mr.canMerge">
+ {{ s__(`mrWidget|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="js-resolve-conflicts-button btn btn-default btn-xs"
+ >
+ {{ s__("mrWidget|Resolve conflicts") }}
+ </a>
+ <button
+ v-if="mr.canMerge"
+ class="js-merge-locally-button btn btn-default btn-xs"
+ data-toggle="modal"
+ data-target="#modal_merge_info"
+ >
+ {{ s__("mrWidget|Merge locally") }}
+ </button>
+ </template>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index c25d6c359bb..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js
+++ /dev/null
@@ -1,78 +0,0 @@
-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" :show-disabled-button="true" />
- <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_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
new file mode 100644
index 00000000000..602b68ea572
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -0,0 +1,105 @@
+<script>
+ import { n__ } from '~/locale';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import eventHub from '../../event_hub';
+
+ export default {
+ name: 'MRWidgetFailedToMerge',
+
+ components: {
+ statusIcon,
+ },
+
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+
+ data() {
+ return {
+ timer: 10,
+ isRefreshing: false,
+ };
+ },
+
+ computed: {
+ timerText() {
+ return n__(
+ 'Refreshing in a second to show the updated status...',
+ 'Refreshing in %d seconds to show the updated status...',
+ this.timer,
+ );
+ },
+ },
+
+ mounted() {
+ setInterval(() => {
+ this.updateTimer();
+ }, 1000);
+ },
+
+ created() {
+ eventHub.$emit('DisablePolling');
+ },
+
+ methods: {
+ refresh() {
+ this.isRefreshing = true;
+ eventHub.$emit('MRWidgetUpdateRequested');
+ eventHub.$emit('EnablePolling');
+ },
+ updateTimer() {
+ this.timer = this.timer - 1;
+
+ if (this.timer === 0) {
+ this.refresh();
+ }
+ },
+ },
+
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <template v-if="isRefreshing">
+ <status-icon status="loading" />
+ <span class="media-body bold js-refresh-label">
+ {{ s__("mrWidget|Refreshing now") }}
+ </span>
+ </template>
+ <template v-else>
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ <span
+ class="has-error-message"
+ v-if="mr.mergeError"
+ >
+ {{ mr.mergeError }}.
+ </span>
+ <span v-else>
+ {{ s__("mrWidget|Merge failed.") }}
+ </span>
+ <span
+ :class="{ 'has-custom-error': mr.mergeError }"
+ >
+ {{ timerText }}
+ </span>
+ </span>
+ <button
+ @click="refresh"
+ class="btn btn-default btn-xs js-refresh-button"
+ type="button"
+ >
+ {{ s__("mrWidget|Refresh now") }}
+ </button>
+ </div>
+ </template>
+ </div>
+</template>
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
deleted file mode 100644
index 43b2d238f65..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import Flash from '../../../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 class="flex-container-block">
- <span class="append-right-10">
- Set by
- <mr-widget-author :author="mr.setToMWPSBy" />
- to be merged automatically when the pipeline succeeds
- </span>
- <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
- class="flex-container-block"
- >
- <span class="append-right-10">
- The source branch will not be removed
- </span>
- <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_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
new file mode 100644
index 00000000000..7ff7fc7988a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
@@ -0,0 +1,147 @@
+<script>
+ import Flash from '../../../flash';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import mrWidgetAuthor from '../../components/mr_widget_author.vue';
+ import eventHub from '../../event_hub';
+
+ export default {
+ name: 'MRWidgetMergeWhenPipelineSucceeds',
+ components: {
+ mrWidgetAuthor,
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ service: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ 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.data)
+ .then((data) => {
+ eventHub.$emit('UpdateWidgetData', data);
+ })
+ .catch(() => {
+ this.isCancellingAutoMerge = false;
+ Flash('Something went wrong. Please try again.');
+ });
+ },
+ removeSourceBranch() {
+ const options = {
+ sha: this.mr.sha,
+ merge_when_pipeline_succeeds: true,
+ should_remove_source_branch: true,
+ };
+
+ this.isRemovingSourceBranch = true;
+ this.service.merge(options)
+ .then(res => res.data)
+ .then((data) => {
+ if (data.status === 'merge_when_pipeline_succeeds') {
+ eventHub.$emit('MRWidgetUpdateRequested');
+ }
+ })
+ .catch(() => {
+ this.isRemovingSourceBranch = false;
+ Flash('Something went wrong. Please try again.');
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <div class="media-body">
+ <h4 class="flex-container-block">
+ <span class="append-right-10">
+ {{ s__("mrWidget|Set by") }}
+ <mr-widget-author :author="mr.setToMWPSBy" />
+ {{ s__("mrWidget|to be merged automatically when the pipeline succeeds") }}
+ </span>
+ <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"
+ >
+ </i>
+ {{ s__("mrWidget|Cancel automatic merge") }}
+ </a>
+ </h4>
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes will be merged into") }}
+ <a
+ :href="mr.targetBranchPath"
+ class="label-branch"
+ >
+ {{ mr.targetBranch }}
+ </a>
+ </p>
+ <p v-if="mr.shouldRemoveSourceBranch">
+ {{ s__("mrWidget|The source branch will be removed") }}
+ </p>
+ <p
+ v-else
+ class="flex-container-block"
+ >
+ <span class="append-right-10">
+ {{ s__("mrWidget|The source branch will not be removed") }}
+ </span>
+ <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"
+ >
+ </i>
+ {{ s__("mrWidget|Remove source branch") }}
+ </a>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 2dfd87ed904..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import Flash from '../../../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.mergedEvent.author"
- :date-title="mr.mergedEvent.updatedAt"
- :date-readable="mr.mergedEvent.formattedUpdatedAt" />
- <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_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
new file mode 100644
index 00000000000..c1618bc6ea0
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -0,0 +1,192 @@
+<script>
+ import Flash from '~/flash';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import loadingIcon from '~/vue_shared/components/loading_icon.vue';
+ import { s__, __ } from '~/locale';
+ import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import eventHub from '../../event_hub';
+
+ export default {
+ name: 'MRWidgetMerged',
+ directives: {
+ tooltip,
+ },
+ components: {
+ mrWidgetAuthorTime,
+ loadingIcon,
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ service: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ };
+ },
+ 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;
+ },
+ revertTitle() {
+ return s__('mrWidget|Revert this merge request in a new merge request');
+ },
+ cherryPickTitle() {
+ return s__('mrWidget|Cherry-pick this merge request in a new merge request');
+ },
+ revertLabel() {
+ return s__('mrWidget|Revert');
+ },
+ cherryPickLabel() {
+ return s__('mrWidget|Cherry-pick');
+ },
+ },
+ methods: {
+ removeSourceBranch() {
+ this.isMakingRequest = true;
+
+ this.service.removeSourceBranch()
+ .then(res => res.data)
+ .then((data) => {
+ if (data.message === 'Branch was removed') {
+ eventHub.$emit('MRWidgetUpdateRequested', () => {
+ this.isMakingRequest = false;
+ });
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ Flash(__('Something went wrong. Please try again.'));
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <div class="media-body">
+ <div class="space-children">
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Merged by')"
+ :author="mr.metrics.mergedBy"
+ :date-title="mr.metrics.mergedAt"
+ :date-readable="mr.metrics.readableMergedAt"
+ />
+ <a
+ v-if="mr.canRevertInCurrentMR"
+ v-tooltip
+ class="btn btn-close btn-xs"
+ href="#modal-revert-commit"
+ data-toggle="modal"
+ data-container="body"
+ :title="revertTitle"
+ >
+ {{ revertLabel }}
+ </a>
+ <a
+ v-else-if="mr.revertInForkPath"
+ v-tooltip
+ class="btn btn-close btn-xs"
+ data-method="post"
+ :href="mr.revertInForkPath"
+ :title="revertTitle"
+ >
+ {{ revertLabel }}
+ </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="cherryPickTitle"
+ >
+ {{ cherryPickLabel }}
+ </a>
+ <a
+ v-else-if="mr.cherryPickInForkPath"
+ v-tooltip
+ class="btn btn-default btn-xs"
+ data-method="post"
+ :href="mr.cherryPickInForkPath"
+ :title="cherryPickTitle"
+ >
+ {{ cherryPickLabel }}
+ </a>
+ </div>
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes were merged into") }}
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
+ </span>
+ </p>
+ <p v-if="mr.sourceBranchRemoved">
+ {{ s__("mrWidget|The source branch has been removed") }}
+ </p>
+ <p
+ v-if="shouldShowRemoveSourceBranch"
+ class="space-children"
+ >
+ <span>{{ s__("mrWidget|You can remove source branch now") }}</span>
+ <button
+ @click="removeSourceBranch"
+ :disabled="isMakingRequest"
+ type="button"
+ class="btn btn-xs btn-default js-remove-branch-button"
+ >
+ {{ s__("mrWidget|Remove Source Branch") }}
+ </button>
+ </p>
+ <p v-if="shouldShowSourceBranchRemoving">
+ <loading-icon :inline="true" />
+ <span>
+ {{ s__("mrWidget|The source branch is being removed") }}
+ </span>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index f6d1a4feeb2..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js
+++ /dev/null
@@ -1,29 +0,0 @@
-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_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
new file mode 100644
index 00000000000..953ddf40a51
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -0,0 +1,35 @@
+<script>
+ import statusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetMerging',
+ components: {
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body mr-state-locked media">
+ <status-icon status="loading" />
+ <div class="media-body">
+ <h4>
+ {{ s__("mrWidget|This merge request is in the process of being merged") }}
+ </h4>
+ <section class="mr-info-list">
+ <p>
+ {{ s__("mrWidget|The changes will be merged into") }}
+ <span class="label-branch">
+ <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
+ </span>
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 1bc0b7e0819..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js
+++ /dev/null
@@ -1,43 +0,0 @@
-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" :show-disabled-button="true" />
- <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_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
new file mode 100644
index 00000000000..718c0e4b3c6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -0,0 +1,62 @@
+<script>
+ import { sprintf, s__ } from '~/locale';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue';
+
+ export default {
+ name: 'MRWidgetMissingBranch',
+ directives: {
+ tooltip,
+ },
+ components: {
+ mrWidgetMergeHelp,
+ statusIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ missingBranchName() {
+ return this.mr.sourceBranchRemoved ? 'source' : 'target';
+ },
+ missingBranchNameMessage() {
+ return sprintf(s__('mrWidget| Please restore it or use a different %{missingBranchName} branch'), {
+ missingBranchName: this.missingBranchName,
+ });
+ },
+ message() {
+ return sprintf(s__('mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line'), {
+ missingBranchName: this.missingBranchName,
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+
+ <div class="media-body space-children">
+ <span class="bold js-branch-text">
+ <span class="capitalize">
+ {{ missingBranchName }}
+ </span> {{ s__("mrWidget|branch does not exist.") }}
+ {{ missingBranchNameMessage }}
+ <i
+ v-tooltip
+ class="fa fa-question-circle"
+ :title="message"
+ :aria-label="message"
+ >
+ </i>
+ </span>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index 00047718201..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetNotAllowed',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="success" :show-disabled-button="true" />
- <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_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
new file mode 100644
index 00000000000..e4af50b09f8
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
@@ -0,0 +1,25 @@
+<script>
+ import StatusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetNotAllowed',
+ components: {
+ StatusIcon,
+ },
+ };
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="success"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ {{ s__(`mrWidget|Ready to be merged automatically.
+Ask someone with write access to this repository to merge this request`) }}
+ </span>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index ebfd6765934..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js
+++ /dev/null
@@ -1,44 +0,0 @@
-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
deleted file mode 100644
index 1cedf86e811..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetPipelineBlocked',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="failed" :show-disabled-button="true" />
- <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_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
new file mode 100644
index 00000000000..6d7cc03f7ad
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
@@ -0,0 +1,24 @@
+<script>
+ import StatusIcon from '../mr_widget_status_icon.vue';
+
+ export default {
+ name: 'MRWidgetPipelineBlocked',
+ components: {
+ StatusIcon,
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ {{ s__(`mrWidget|Pipeline blocked.
+The pipeline for this merge request requires a manual action to proceed`) }}
+ </span>
+ </div>
+ </div>
+</template>
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
index 6853ba4b9f8..4d9a2ca530f 100644
--- 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
@@ -1,4 +1,4 @@
-import statusIcon from '../mr_widget_status_icon';
+import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetPipelineBlocked',
@@ -7,7 +7,7 @@ export default {
},
template: `
<div class="mr-widget-body media">
- <status-icon status="failed" :show-disabled-button="true" />
+ <status-icon status="warning" :show-disabled-button="true" />
<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
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
index be37dd87de9..3c781ccddc8 100644
--- 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
@@ -1,8 +1,9 @@
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import simplePoll from '~/lib/utils/simple_poll';
+import MergeRequest from '../../../merge_request';
import Flash from '../../../flash';
-import statusIcon from '../mr_widget_status_icon';
+import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
export default {
@@ -68,7 +69,7 @@ export default {
},
iconClass() {
if (this.status === 'failed' || !this.commitMessage.length || !this.mr.isMergeAllowed || this.mr.preventMerge) {
- return 'failed';
+ return 'warning';
}
return 'success';
},
@@ -92,7 +93,7 @@ export default {
|| this.mr.preventMerge);
},
isRemoveSourceBranchButtonDisabled() {
- return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch;
+ return this.isMergeButtonDisabled;
},
shouldShowSquashBeforeMerge() {
const { commitsCount, enableSquashBeforeMerge } = this.mr;
@@ -135,16 +136,16 @@ export default {
this.isMakingRequest = true;
this.service.merge(options)
- .then(res => res.json())
- .then((res) => {
- const hasError = res.status === 'failed' || res.status === 'hook_validation_error';
+ .then(res => res.data)
+ .then((data) => {
+ const hasError = data.status === 'failed' || data.status === 'hook_validation_error';
- if (res.status === 'merge_when_pipeline_succeeds') {
+ if (data.status === 'merge_when_pipeline_succeeds') {
eventHub.$emit('MRWidgetUpdateRequested');
- } else if (res.status === 'success') {
+ } else if (data.status === 'success') {
this.initiateMergePolling();
} else if (hasError) {
- eventHub.$emit('FailedToMerge', res.merge_error);
+ eventHub.$emit('FailedToMerge', data.merge_error);
}
})
.catch(() => {
@@ -159,26 +160,24 @@ export default {
},
handleMergePolling(continuePolling, stopPolling) {
this.service.poll()
- .then(res => res.json())
- .then((res) => {
- if (res.state === 'merged') {
+ .then(res => res.data)
+ .then((data) => {
+ if (data.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.hideCloseButton();
- window.mergeRequest.decreaseCounter();
- }
+ MergeRequest.setStatusBoxToMerged();
+ MergeRequest.hideCloseButton();
+ 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) {
+ if (this.removeSourceBranch && data.source_branch_exists) {
this.initiateRemoveSourceBranchPolling();
}
- } else if (res.merge_error) {
- eventHub.$emit('FailedToMerge', res.merge_error);
+ } else if (data.merge_error) {
+ eventHub.$emit('FailedToMerge', data.merge_error);
stopPolling();
} else {
// MR is not merged yet, continue polling until the state becomes 'merged'
@@ -199,11 +198,11 @@ export default {
},
handleRemoveBranchPolling(continuePolling, stopPolling) {
this.service.poll()
- .then(res => res.json())
- .then((res) => {
+ .then(res => res.data)
+ .then((data) => {
// 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) {
+ if (data.source_branch_exists) {
continuePolling();
} else {
// Branch is removed. Update widget, stop polling and hide the spinner
@@ -228,7 +227,8 @@ export default {
@click="handleMergeButtonClick()"
:disabled="isMergeButtonDisabled"
:class="mergeButtonClass"
- type="button">
+ type="button"
+ class="qa-merge-button">
<i
v-if="isMakingRequest"
class="fa fa-spinner fa-spin"
@@ -282,7 +282,7 @@ export default {
</span>
<div class="media-body-wrap space-children">
<template v-if="shouldShowMergeControls()">
- <label>
+ <label v-if="mr.canRemoveSourceBranch">
<input
id="remove-source-branch-input"
v-model="removeSourceBranch"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
new file mode 100644
index 00000000000..143fd328d88
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -0,0 +1,138 @@
+<script>
+ import simplePoll from '../../../lib/utils/simple_poll';
+ import eventHub from '../../event_hub';
+ import statusIcon from '../mr_widget_status_icon.vue';
+ import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
+ import Flash from '../../../flash';
+
+ export default {
+ name: 'MRWidgetRebase',
+ components: {
+ statusIcon,
+ loadingIcon,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isMakingRequest: false,
+ rebasingError: null,
+ };
+ },
+ computed: {
+ status() {
+ if (this.mr.rebaseInProgress || this.isMakingRequest) {
+ return 'loading';
+ }
+ if (!this.mr.canPushToSourceBranch && !this.mr.rebaseInProgress) {
+ return 'warning';
+ }
+ return 'success';
+ },
+ showDisabledButton() {
+ return ['failed', 'loading'].includes(this.status);
+ },
+ },
+ methods: {
+ rebase() {
+ this.isMakingRequest = true;
+ this.rebasingError = null;
+
+ this.service.rebase()
+ .then(() => {
+ simplePoll(this.checkRebaseStatus);
+ })
+ .catch((error) => {
+ this.rebasingError = error.merge_error;
+ this.isMakingRequest = false;
+ Flash('Something went wrong. Please try again.');
+ });
+ },
+ checkRebaseStatus(continuePolling, stopPolling) {
+ this.service.poll()
+ .then(res => res.data)
+ .then((res) => {
+ if (res.rebase_in_progress) {
+ continuePolling();
+ } else {
+ this.isMakingRequest = false;
+
+ if (res.merge_error && res.merge_error.length) {
+ this.rebasingError = res.merge_error;
+ Flash('Something went wrong. Please try again.');
+ }
+
+ eventHub.$emit('MRWidgetUpdateRequested');
+ stopPolling();
+ }
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ Flash('Something went wrong. Please try again.');
+ stopPolling();
+ });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ :status="status"
+ :show-disabled-button="showDisabledButton"
+ />
+
+ <div class="rebase-state-find-class-convention media media-body space-children">
+ <template v-if="mr.rebaseInProgress || isMakingRequest">
+ <span class="bold">
+ Rebase in progress
+ </span>
+ </template>
+ <template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch">
+ <span class="bold">
+ Fast-forward merge is not possible.
+ Rebase the source branch onto
+ <span class="label-branch">{{ mr.targetBranch }}</span>
+ to allow this merge request to be merged.
+ </span>
+ </template>
+ <template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest">
+ <div
+ class="accept-merge-holder clearfix
+js-toggle-container accept-action media space-children"
+ >
+ <button
+ type="button"
+ class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button"
+ :disabled="isMakingRequest"
+ @click="rebase"
+ >
+ <loading-icon v-if="isMakingRequest" />
+ Rebase
+ </button>
+ <span
+ v-if="!rebasingError"
+ class="bold"
+ >
+ Fast-forward merge is not possible.
+ Rebase the source branch onto the target branch or merge target
+ branch into source branch to allow this merge request to be merged.
+ </span>
+ <span
+ v-else
+ class="bold danger">
+ {{ rebasingError }}
+ </span>
+ </div>
+ </template>
+ </div>
+ </div>
+</template>
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
deleted file mode 100644
index af19cf6ab87..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import statusIcon from '../mr_widget_status_icon';
-
-export default {
- name: 'MRWidgetSHAMismatch',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="failed" :show-disabled-button="true" />
- <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_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
deleted file mode 100644
index a119ecbbdfe..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js
+++ /dev/null
@@ -1,27 +0,0 @@
-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" :show-disabled-button="true" />
- <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
index 4f83350e07c..44e1a616a19 100644
--- 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
@@ -1,4 +1,5 @@
-import statusIcon from '../mr_widget_status_icon';
+import $ from 'jquery';
+import statusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
@@ -23,9 +24,9 @@ export default {
removeWIP() {
this.isMakingRequest = true;
this.service.removeWIP()
- .then(res => res.json())
- .then((res) => {
- eventHub.$emit('UpdateWidgetData', res);
+ .then(res => res.data)
+ .then((data) => {
+ eventHub.$emit('UpdateWidgetData', data);
new window.Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
$('.merge-request .detail-page-description .title').text(this.mr.title);
})
@@ -37,7 +38,7 @@ export default {
},
template: `
<div class="mr-widget-body media">
- <status-icon status="failed" :show-disabled-button="Boolean(mr.removeWIPPath)" />
+ <status-icon status="warning" :show-disabled-button="Boolean(mr.removeWIPPath)" />
<div class="media-body space-children">
<span class="bold">
This is a Work in Progress
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
new file mode 100644
index 00000000000..3d9161f6926
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -0,0 +1,47 @@
+<script>
+import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+
+export default {
+ name: 'MRWidgetNothingToMerge',
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return { emptyStateSVG };
+ },
+};
+</script>
+
+<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>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
new file mode 100644
index 00000000000..7cc07401911
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -0,0 +1,25 @@
+<script>
+import statusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ name: 'ShaMismatch',
+ components: {
+ statusIcon,
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ {{ s__(`mrWidget|The source branch HEAD has recently changed.
+Please reload the page and review the changes before merging`) }}
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
new file mode 100644
index 00000000000..9ade6a91747
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -0,0 +1,33 @@
+<script>
+import statusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ name: 'UnresolvedDiscussions',
+ components: {
+ statusIcon,
+ },
+ props: {
+ mr: { type: Object, required: true },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <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>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index 5bd8b99420a..ed15fc6ab0f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -11,34 +11,38 @@
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 WidgetHeader } from './components/mr_widget_header.vue';
+export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue';
export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue';
-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 Deployment } from './components/deployment.vue';
+export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue';
+export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue';
+export { default as MergedState } from './components/states/mr_widget_merged.vue';
+export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge.vue';
+export { default as ClosedState } from './components/states/mr_widget_closed.vue';
+export { default as MergingState } from './components/states/mr_widget_merging.vue';
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 ArchivedState } from './components/states/mr_widget_archived.vue';
+export { default as ConflictsState } from './components/states/mr_widget_conflicts.vue';
+export { default as NothingToMergeState } from './components/states/nothing_to_merge.vue';
+export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue';
+export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue';
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 ShaMismatchState } from './components/states/sha_mismatch.vue';
+export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue';
+export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue';
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 MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.vue';
+export { default as RebaseState } from './components/states/mr_widget_rebase.vue';
+export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed.vue';
+export { default as CheckingState } from './components/states/mr_widget_checking.vue';
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';
+export { default as SourceBranchRemovalStatus } from './components/source_branch_removal_status.vue';
+
+export { default as mrWidgetOptions } from './mr_widget_options';
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 43ef468c303..69a9132a2da 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -2,8 +2,11 @@ import {
Vue,
mrWidgetOptions,
} from './dependencies';
+import Translate from '../vue_shared/translate';
-document.addEventListener('DOMContentLoaded', () => {
+Vue.use(Translate);
+
+export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
const vm = new Vue(mrWidgetOptions);
@@ -11,4 +14,4 @@ document.addEventListener('DOMContentLoaded', () => {
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
index 8a9129c385b..0be5d9e5a55 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
@@ -1,15 +1,17 @@
-import Project from '~/project';
+import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
import Flash from '../flash';
import {
WidgetHeader,
WidgetMergeHelp,
WidgetPipeline,
- WidgetDeployment,
+ Deployment,
+ WidgetMaintainerEdit,
WidgetRelatedLinks,
MergedState,
ClosedState,
MergingState,
+ RebaseState,
WipState,
ArchivedState,
ConflictsState,
@@ -17,7 +19,7 @@ import {
MissingBranchState,
NotAllowedState,
ReadyToMergeState,
- SHAMismatchState,
+ ShaMismatchState,
UnresolvedDiscussionsState,
PipelineBlockedState,
PipelineFailedState,
@@ -31,6 +33,7 @@ import {
stateMaps,
SquashBeforeMerge,
notify,
+ SourceBranchRemovalStatus,
} from './dependencies';
import { setFavicon } from '../lib/utils/common_utils';
@@ -64,8 +67,9 @@ export default {
shouldRenderRelatedLinks() {
return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState;
},
- shouldRenderDeployments() {
- return this.mr.deployments.length;
+ shouldRenderSourceBranchRemovalStatus() {
+ return !this.mr.canRemoveSourceBranch && this.mr.shouldRemoveSourceBranch &&
+ (!this.mr.isNothingToMergeState && !this.mr.isMergedState);
},
},
methods: {
@@ -79,24 +83,23 @@ export default {
ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
statusPath: store.statusPath,
mergeActionsContentPath: store.mergeActionsContentPath,
+ rebasePath: store.rebasePath,
};
return new MRWidgetService(endpoints);
},
checkStatus(cb) {
return this.service.checkStatus()
- .then(res => res.json())
- .then((res) => {
- this.handleNotification(res);
- this.mr.setData(res);
+ .then(res => res.data)
+ .then((data) => {
+ this.handleNotification(data);
+ this.mr.setData(data);
this.setFaviconHelper();
if (cb) {
- cb.call(null, res);
+ cb.call(null, data);
}
})
- .catch(() => {
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
+ .catch(() => new Flash('Something went wrong. Please try again.'));
},
initPolling() {
this.pollingInterval = new SmartInterval({
@@ -124,10 +127,10 @@ export default {
},
fetchDeployments() {
return this.service.fetchDeployments()
- .then(res => res.json())
- .then((res) => {
- if (res.length) {
- this.mr.deployments = res;
+ .then(res => res.data)
+ .then((data) => {
+ if (data.length) {
+ this.mr.deployments = data;
}
})
.catch(() => {
@@ -137,19 +140,18 @@ export default {
fetchActionsContent() {
this.service.fetchMergeActionsContent()
.then((res) => {
- if (res.body) {
+ if (res.data) {
const el = document.createElement('div');
- el.innerHTML = res.body;
+ el.innerHTML = res.data;
document.body.appendChild(el);
Project.initRefSwitcher();
}
})
- .catch(() => {
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
+ .catch(() => new Flash('Something went wrong. Please try again.'));
},
handleNotification(data) {
if (data.ci_status === this.mr.ciStatus) return;
+ if (!data.pipeline) return;
const label = data.pipeline.details.status.label;
const title = `Pipeline ${label}`;
@@ -211,7 +213,8 @@ export default {
'mr-widget-header': WidgetHeader,
'mr-widget-merge-help': WidgetMergeHelp,
'mr-widget-pipeline': WidgetPipeline,
- 'mr-widget-deployment': WidgetDeployment,
+ Deployment,
+ 'mr-widget-maintainer-edit': WidgetMaintainerEdit,
'mr-widget-related-links': WidgetRelatedLinks,
'mr-widget-merged': MergedState,
'mr-widget-closed': ClosedState,
@@ -224,7 +227,7 @@ export default {
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
'mr-widget-ready-to-merge': ReadyToMergeState,
- 'mr-widget-sha-mismatch': SHAMismatchState,
+ 'mr-widget-sha-mismatch': ShaMismatchState,
'mr-widget-squash-before-merge': SquashBeforeMerge,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
@@ -232,6 +235,8 @@ export default {
'mr-widget-pipeline-failed': PipelineFailedState,
'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
'mr-widget-auto-merge-failed': AutoMergeFailed,
+ 'mr-widget-rebase': RebaseState,
+ SourceBranchRemovalStatus,
},
template: `
<div class="mr-state-widget prepend-top-default">
@@ -242,19 +247,25 @@ export default {
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
/>
- <mr-widget-deployment
- v-if="shouldRenderDeployments"
- :mr="mr"
- :service="service" />
+ <deployment
+ v-for="deployment in mr.deployments"
+ :key="deployment.id"
+ :deployment="deployment"
+ />
<div class="mr-widget-section">
<component
:is="componentName"
:mr="mr"
:service="service" />
+ <mr-widget-maintainer-edit
+ :maintainerEditAllowed="mr.maintainerEditAllowed" />
<mr-widget-related-links
v-if="shouldRenderRelatedLinks"
:state="mr.state"
:related-links="mr.relatedLinks" />
+ <source-branch-removal-status
+ v-if="shouldRenderSourceBranchRemovalStatus"
+ />
</div>
<div
class="mr-widget-footer"
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
index 5fa838baba3..fecbfec2214 100644
--- 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
@@ -1,57 +1,51 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-
-Vue.use(VueResource);
+import axios from '../../lib/utils/axios_utils';
export default class MRWidgetService {
constructor(endpoints) {
- this.mergeResource = Vue.resource(endpoints.mergePath);
- this.mergeCheckResource = Vue.resource(`${endpoints.statusPath}?serializer=widget`);
- 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}?serializer=basic`);
- this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath);
+ this.endpoints = endpoints;
}
merge(data) {
- return this.mergeResource.save(data);
+ return axios.post(this.endpoints.mergePath, data);
}
cancelAutomaticMerge() {
- return this.cancelAutoMergeResource.save();
+ return axios.post(this.endpoints.cancelAutoMergePath);
}
removeWIP() {
- return this.removeWIPResource.save();
+ return axios.post(this.endpoints.removeWIPPath);
}
removeSourceBranch() {
- return this.removeSourceBranchResource.delete();
+ return axios.delete(this.endpoints.sourceBranchPath);
}
fetchDeployments() {
- return this.deploymentsResource.get();
+ return axios.get(this.endpoints.ciEnvironmentsStatusPath);
}
poll() {
- return this.pollResource.get();
+ return axios.get(`${this.endpoints.statusPath}?serializer=basic`);
}
checkStatus() {
- return this.mergeCheckResource.get();
+ return axios.get(`${this.endpoints.statusPath}?serializer=widget`);
}
fetchMergeActionsContent() {
- return this.mergeActionsContentResource.get();
+ return axios.get(this.endpoints.mergeActionsContentPath);
+ }
+
+ rebase() {
+ return axios.post(this.endpoints.rebasePath);
}
static stopEnvironment(url) {
- return Vue.http.post(url);
+ return axios.post(url);
}
static fetchMetrics(metricsUrl) {
- return Vue.http.get(`${metricsUrl}.json`);
+ return axios.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
index 2bace3311c8..f7f0c1b6cb7 100644
--- 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
@@ -25,6 +25,8 @@ export default function deviseState(data) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
} else if (!this.canMerge) {
return stateKey.notAllowedToMerge;
+ } else if (this.shouldBeRebased) {
+ return stateKey.rebase;
} else if (this.canBeMerged) {
return stateKey.readyToMerge;
}
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
index 93d31a2a684..a47ca9fae86 100644
--- 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
@@ -4,7 +4,6 @@ import { stateKey } from './state_maps';
import { formatDate } from '../../lib/utils/datetime_utility';
export default class MergeRequestStore {
-
constructor(data) {
this.sha = data.diff_head_sha;
this.gitlabLogo = data.gitlabLogo;
@@ -26,6 +25,7 @@ export default class MergeRequestStore {
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
this.deployments = this.deployments || data.deployments || [];
+ this.initRebase(data);
if (data.issues_links) {
const links = data.issues_links;
@@ -39,9 +39,8 @@ export default class MergeRequestStore {
}
this.updatedAt = data.updated_at;
- this.mergedEvent = MergeRequestStore.getEventObject(data.merge_event);
- this.closedEvent = MergeRequestStore.getEventObject(data.closed_event);
- this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} });
+ this.metrics = MergeRequestStore.buildMetrics(data.metrics);
+ this.setToMWPSBy = MergeRequestStore.formatUserObject(data.merge_user || {});
this.mergeUserId = data.merge_user_id;
this.currentUserId = gon.current_user_id;
this.sourceBranchPath = data.source_branch_path;
@@ -77,6 +76,7 @@ export default class MergeRequestStore {
this.canBeMerged = data.can_be_merged || false;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
+ this.maintainerEditAllowed = data.allow_maintainer_to_push;
// Cherry-pick and Revert actions related
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
@@ -125,43 +125,52 @@ export default class MergeRequestStore {
return this.state === stateKey.nothingToMerge;
}
- static getEventObject(event) {
- return {
- author: MergeRequestStore.getAuthorObject(event),
- updatedAt: formatDate(MergeRequestStore.getEventUpdatedAtDate(event)),
- formattedUpdatedAt: MergeRequestStore.getEventDate(event),
- };
+ get isMergedState() {
+ return this.state === stateKey.merged;
}
- static getAuthorObject(event) {
- if (!event) {
+ initRebase(data) {
+ this.canPushToSourceBranch = data.can_push_to_source_branch;
+ this.rebaseInProgress = data.rebase_in_progress;
+ this.approvalsLeft = !data.approved;
+ this.rebasePath = data.rebase_path;
+ }
+
+ static buildMetrics(metrics) {
+ if (!metrics) {
return {};
}
return {
- name: event.author.name || '',
- username: event.author.username || '',
- webUrl: event.author.web_url || '',
- avatarUrl: event.author.avatar_url || '',
+ mergedBy: MergeRequestStore.formatUserObject(metrics.merged_by),
+ closedBy: MergeRequestStore.formatUserObject(metrics.closed_by),
+ mergedAt: formatDate(metrics.merged_at),
+ closedAt: formatDate(metrics.closed_at),
+ readableMergedAt: MergeRequestStore.getReadableDate(metrics.merged_at),
+ readableClosedAt: MergeRequestStore.getReadableDate(metrics.closed_at),
};
}
- static getEventUpdatedAtDate(event) {
- if (!event) {
- return '';
+ static formatUserObject(user) {
+ if (!user) {
+ return {};
}
- return event.updated_at;
+ return {
+ name: user.name || '',
+ username: user.username || '',
+ webUrl: user.web_url || '',
+ avatarUrl: user.avatar_url || '',
+ };
}
- static getEventDate(event) {
- const timeagoInstance = new Timeago();
-
- if (!event) {
+ static getReadableDate(date) {
+ if (!date) {
return '';
}
- return timeagoInstance.format(MergeRequestStore.getEventUpdatedAtDate(event));
- }
+ const timeagoInstance = new Timeago();
+ return timeagoInstance.format(date);
+ }
}
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
index de980c175fb..e080ce5c229 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -16,7 +16,8 @@ const stateToComponentMap = {
mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds',
failedToMerge: 'mr-widget-failed-to-merge',
autoMergeFailed: 'mr-widget-auto-merge-failed',
- shaMismatch: 'mr-widget-sha-mismatch',
+ shaMismatch: 'sha-mismatch',
+ rebase: 'mr-widget-rebase',
};
const statesToShowHelpWidget = [
@@ -29,6 +30,7 @@ const statesToShowHelpWidget = [
'pipelineFailed',
'pipelineBlocked',
'autoMergeFailed',
+ 'rebase',
];
export const stateKey = {
@@ -46,6 +48,8 @@ export const stateKey = {
mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds',
notAllowedToMerge: 'notAllowedToMerge',
readyToMerge: 'readyToMerge',
+ rebase: 'rebase',
+ merged: 'merged',
};
export default {
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index fc795936abf..5324d5dc797 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -23,6 +23,12 @@
*/
export default {
+ components: {
+ ciIcon,
+ },
+ directives: {
+ tooltip,
+ },
props: {
status: {
type: Object,
@@ -34,12 +40,6 @@
default: true,
},
},
- components: {
- ciIcon,
- },
- directives: {
- tooltip,
- },
computed: {
cssClass() {
const className = this.status.group;
@@ -53,11 +53,12 @@
:href="status.details_path"
:class="cssClass"
v-tooltip
- :title="!showText ? status.text : ''">
+ :title="!showText ? status.text : ''"
+ >
<ci-icon :status="status" />
<template v-if="showText">
- {{status.text}}
+ {{ status.text }}
</template>
</a>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 2a018f38366..8fea746f4de 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -23,6 +23,9 @@
* - Jobs show view sidebar
*/
export default {
+ components: {
+ icon,
+ },
props: {
status: {
type: Object,
@@ -30,10 +33,6 @@
},
},
- components: {
- icon,
- },
-
computed: {
cssClass() {
const status = this.status.group;
@@ -43,9 +42,7 @@
};
</script>
<template>
- <span
- :class="cssClass">
- <icon
- :name="status.icon"/>
+ <span :class="cssClass">
+ <icon :name="status.icon" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 3a7143c450e..cab126a7eca 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -2,9 +2,13 @@
/**
* Falls back to the code used in `copy_to_clipboard.js`
*/
+ import tooltip from '../directives/tooltip';
export default {
- name: 'clipboardButton',
+ name: 'ClipboardButton',
+ directives: {
+ tooltip,
+ },
props: {
text: {
type: String,
@@ -14,6 +18,21 @@
type: String,
required: true,
},
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ tooltipContainer: {
+ type: [String, Boolean],
+ required: false,
+ default: false,
+ },
+ cssClass: {
+ type: String,
+ required: false,
+ default: 'btn-default',
+ },
},
};
</script>
@@ -21,12 +40,18 @@
<template>
<button
type="button"
- class="btn btn-transparent btn-clipboard"
- :data-title="title"
- :data-clipboard-text="text">
- <i
- aria-hidden="true"
- class="fa fa-clipboard">
- </i>
+ class="btn"
+ :class="cssClass"
+ :title="title"
+ :data-clipboard-text="text"
+ v-tooltip
+ :data-container="tooltipContainer"
+ :data-placement="tooltipPlacement"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-clipboard"
+ >
+ </i>
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 52814de8b2d..97789636787 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -2,13 +2,21 @@
import commitIconSvg from 'icons/_icon_commit.svg';
import userAvatarLink from './user_avatar/user_avatar_link.vue';
import tooltip from '../directives/tooltip';
+ import icon from '../../vue_shared/components/icon.vue';
export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ userAvatarLink,
+ icon,
+ },
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.
+ * if false will render a svg sprite fork icon
*/
tag: {
type: Boolean,
@@ -102,12 +110,6 @@
this.author.username ? `${this.author.username}'s avatar` : null;
},
},
- directives: {
- tooltip,
- },
- components: {
- userAvatarLink,
- },
created() {
this.commitIconSvg = commitIconSvg;
},
@@ -116,44 +118,47 @@
<template>
<div class="branch-commit">
<template v-if="hasCommitRef && showBranch">
- <div
- class="icon-container hidden-xs">
+ <div class="icon-container">
<i
v-if="tag"
class="fa fa-tag"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
- <i
+ <icon
v-if="!tag"
- class="fa fa-code-fork"
- aria-hidden="true">
- </i>
+ name="fork"
+ />
</div>
<a
- class="ref-name hidden-xs"
+ class="ref-name"
:href="commitRef.ref_url"
v-tooltip
data-container="body"
- :title="commitRef.name">
- {{commitRef.name}}
+ :title="commitRef.name"
+ >
+ {{ commitRef.name }}
</a>
</template>
<div
v-html="commitIconSvg"
- class="commit-icon js-commit-icon">
+ class="commit-icon js-commit-icon"
+ >
</div>
<a
class="commit-sha"
- :href="commitUrl">
- {{shortSha}}
+ :href="commitUrl"
+ >
+ {{ shortSha }}
</a>
<div class="commit-title flex-truncate-parent">
<span
v-if="title"
- class="flex-truncate-child">
+ class="flex-truncate-child"
+ >
<user-avatar-link
v-if="hasAuthor"
class="avatar-image-container"
@@ -164,8 +169,9 @@
/>
<a
class="commit-row-message"
- :href="commitUrl">
- {{title}}
+ :href="commitUrl"
+ >
+ {{ title }}
</a>
</span>
<span v-else>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
new file mode 100644
index 00000000000..4155e1bab9c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -0,0 +1,58 @@
+<script>
+import { viewerInformationForPath } from './lib/viewer_utils';
+import MarkdownViewer from './viewers/markdown_viewer.vue';
+import ImageViewer from './viewers/image_viewer.vue';
+import DownloadViewer from './viewers/download_viewer.vue';
+
+export default {
+ props: {
+ content: {
+ type: String,
+ default: '',
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ projectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ viewer() {
+ if (!this.path) return null;
+
+ const previewInfo = viewerInformationForPath(this.path);
+ if (!previewInfo) return DownloadViewer;
+
+ switch (previewInfo.id) {
+ case 'markdown':
+ return MarkdownViewer;
+ case 'image':
+ return ImageViewer;
+ default:
+ return DownloadViewer;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="preview-container">
+ <component
+ :is="viewer"
+ :path="path"
+ :file-size="fileSize"
+ :project-path="projectPath"
+ :content="content"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
new file mode 100644
index 00000000000..f01a51da0b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
@@ -0,0 +1,32 @@
+const viewers = {
+ image: {
+ id: 'image',
+ },
+ markdown: {
+ id: 'markdown',
+ previewTitle: 'Preview Markdown',
+ },
+};
+
+const fileNameViewers = {};
+const fileExtensionViewers = {
+ jpg: 'image',
+ jpeg: 'image',
+ gif: 'image',
+ png: 'image',
+ bmp: 'image',
+ ico: 'image',
+ md: 'markdown',
+ markdown: 'markdown',
+};
+
+export function viewerInformationForPath(path) {
+ if (!path) return null;
+ const name = path.split('/').pop();
+ const viewerName =
+ fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || '';
+
+ return viewers[viewerName];
+}
+
+export default viewers;
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
new file mode 100644
index 00000000000..395a71acccf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -0,0 +1,52 @@
+<script>
+import Icon from '../../icon.vue';
+import { numberToHumanSize } from '../../../../lib/utils/number_utils';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ fileSizeReadable() {
+ return numberToHumanSize(this.fileSize);
+ },
+ fileName() {
+ return this.path.split('/').pop();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="file-container">
+ <div class="file-content">
+ <p class="prepend-top-10 file-info">
+ {{ fileName }} ({{ fileSizeReadable }})
+ </p>
+ <a
+ :href="path"
+ class="btn btn-default"
+ rel="nofollow"
+ download
+ target="_blank">
+ <icon
+ name="download"
+ css-classes="pull-left append-right-8"
+ :size="16"
+ />
+ {{ __('Download') }}
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
new file mode 100644
index 00000000000..a5999f909ca
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
@@ -0,0 +1,68 @@
+<script>
+import { numberToHumanSize } from '../../../../lib/utils/number_utils';
+
+export default {
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ data() {
+ return {
+ width: 0,
+ height: 0,
+ isZoomable: false,
+ isZoomed: false,
+ };
+ },
+ computed: {
+ fileSizeReadable() {
+ return numberToHumanSize(this.fileSize);
+ },
+ },
+ methods: {
+ onImgLoad() {
+ const contentImg = this.$refs.contentImg;
+ this.isZoomable =
+ contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height;
+
+ this.width = contentImg.naturalWidth;
+ this.height = contentImg.naturalHeight;
+ },
+ onImgClick() {
+ if (this.isZoomable) this.isZoomed = !this.isZoomed;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="file-container">
+ <div class="file-content image_file">
+ <img
+ ref="contentImg"
+ :class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }"
+ :src="path"
+ :alt="path"
+ @load="onImgLoad"
+ @click="onImgClick"/>
+ <p class="file-info prepend-top-10">
+ <template v-if="fileSize>0">
+ {{ fileSizeReadable }}
+ </template>
+ <template v-if="fileSize>0 && width && height">
+ -
+ </template>
+ <template v-if="width && height">
+ {{ width }} x {{ height }}
+ </template>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
new file mode 100644
index 00000000000..09e0094054d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -0,0 +1,90 @@
+<script>
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import $ from 'jquery';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+
+const CancelToken = axios.CancelToken;
+let axiosSource;
+
+export default {
+ components: {
+ SkeletonLoadingContainer,
+ },
+ props: {
+ content: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ previewContent: null,
+ isLoading: false,
+ };
+ },
+ watch: {
+ content() {
+ this.previewContent = null;
+ },
+ },
+ created() {
+ axiosSource = CancelToken.source();
+ this.fetchMarkdownPreview();
+ },
+ updated() {
+ this.fetchMarkdownPreview();
+ },
+ destroyed() {
+ if (this.isLoading) axiosSource.cancel('Cancelling Preview');
+ },
+ methods: {
+ fetchMarkdownPreview() {
+ if (this.content && this.previewContent === null) {
+ this.isLoading = true;
+ const postBody = {
+ text: this.content,
+ };
+ const postOptions = {
+ cancelToken: axiosSource.token,
+ };
+
+ axios
+ .post(
+ `${gon.relative_url_root}/${this.projectPath}/preview_markdown`,
+ postBody,
+ postOptions,
+ )
+ .then(({ data }) => {
+ this.previewContent = data.body;
+ this.isLoading = false;
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
+ })
+ .catch(() => {
+ this.previewContent = __('An error occurred while fetching markdown preview');
+ this.isLoading = false;
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="markdown-preview"
+ class="md md-previewer">
+ <skeleton-loading-container v-if="isLoading" />
+ <div
+ v-else
+ v-html="previewContent">
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
new file mode 100644
index 00000000000..dcf1489b37c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
@@ -0,0 +1,173 @@
+<script>
+ /* eslint-disable vue/require-default-prop */
+ export default {
+ name: 'DeprecatedModal', // use GlModal instead
+
+ props: {
+ id: {
+ type: String,
+ required: false,
+ },
+ title: {
+ type: String,
+ required: false,
+ },
+ text: {
+ type: String,
+ required: false,
+ },
+ hideFooter: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ kind: {
+ type: String,
+ required: false,
+ default: 'primary',
+ },
+ modalDialogClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ closeKind: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
+ closeButtonLabel: {
+ type: String,
+ required: false,
+ default: 'Cancel',
+ },
+ primaryButtonLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ secondaryButtonLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ submitDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ btnKindClass() {
+ return {
+ [`btn-${this.kind}`]: true,
+ };
+ },
+ btnCancelKindClass() {
+ return {
+ [`btn-${this.closeKind}`]: true,
+ };
+ },
+ },
+
+ methods: {
+ emitCancel(event) {
+ this.$emit('cancel', event);
+ },
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="modal-open">
+ <div
+ :id="id"
+ class="modal"
+ :class="id ? '' : 'show'"
+ role="dialog"
+ tabindex="-1"
+ >
+ <div
+ :class="modalDialogClass"
+ class="modal-dialog"
+ role="document"
+ >
+ <div class="modal-content">
+ <div class="modal-header">
+ <slot name="header">
+ <h4 class="modal-title pull-left">
+ {{ title }}
+ </h4>
+ <button
+ type="button"
+ class="close pull-right"
+ @click="emitCancel($event)"
+ data-dismiss="modal"
+ aria-label="Close"
+ >
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </slot>
+ </div>
+ <div class="modal-body">
+ <slot
+ name="body"
+ :text="text"
+ >
+ <p>{{ text }}</p>
+ </slot>
+ </div>
+ <div
+ class="modal-footer"
+ v-if="!hideFooter"
+ >
+ <button
+ type="button"
+ class="btn"
+ :class="btnCancelKindClass"
+ @click="emitCancel($event)"
+ data-dismiss="modal"
+ >
+ {{ closeButtonLabel }}
+ </button>
+
+ <slot
+ v-if="secondaryButtonLabel"
+ name="secondary-button"
+ >
+ <button
+ v-if="secondaryButtonLabel"
+ type="button"
+ class="btn"
+ data-dismiss="modal"
+ >
+ {{ secondaryButtonLabel }}
+ </button>
+ </slot>
+
+ <button
+ v-if="primaryButtonLabel"
+ type="button"
+ class="btn js-primary-button"
+ :disabled="submitDisabled"
+ :class="btnKindClass"
+ @click="emitSubmit($event)"
+ data-dismiss="modal"
+ >
+ {{ primaryButtonLabel }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="!id"
+ class="modal-backdrop fade in"
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue
new file mode 100644
index 00000000000..c943c8d98a4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/expand_button.vue
@@ -0,0 +1,46 @@
+<script>
+ import { __ } from '~/locale';
+ /**
+ * Port of detail_behavior expand button.
+ *
+ * @example
+ * <expand-button>
+ * <template slot="expanded">
+ * Text goes here.
+ * </template>
+ * </expand-button>
+ */
+ export default {
+ name: 'ExpandButton',
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+ computed: {
+ ariaLabel() {
+ return __('Click to expand text');
+ },
+ },
+ methods: {
+ onClick() {
+ this.isCollapsed = !this.isCollapsed;
+ },
+ },
+ };
+</script>
+<template>
+ <span>
+ <button
+ type="button"
+ v-show="isCollapsed"
+ class="text-expander btn-blank"
+ :aria-label="ariaLabel"
+ @click="onClick">
+ ...
+ </button>
+ <span v-if="!isCollapsed">
+ <slot name="expanded"></slot>
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
new file mode 100644
index 00000000000..ee1c3498748
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -0,0 +1,91 @@
+<script>
+ import getIconForFile from './file_icon/file_icon_map';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+
+ /* This is a re-usable vue component for rendering a svg sprite
+ icon
+
+ Sample configuration:
+
+ <file-icon
+ name="retry"
+ :size="32"
+ css-classes="top"
+ />
+
+ */
+ export default {
+ components: {
+ loadingIcon,
+ icon,
+ },
+ props: {
+ fileName: {
+ type: String,
+ required: true,
+ },
+
+ folder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ opened: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ size: {
+ type: Number,
+ required: false,
+ default: 16,
+ },
+
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ spriteHref() {
+ const iconName = getIconForFile(this.fileName) || 'file';
+ return `${gon.sprite_file_icons}#${iconName}`;
+ },
+ folderIconName() {
+ return this.opened ? 'folder-open' : 'folder';
+ },
+ iconSizeClass() {
+ return this.size ? `s${this.size}` : '';
+ },
+ },
+ };
+</script>
+<template>
+ <span>
+ <svg
+ :class="[iconSizeClass, cssClasses]"
+ v-if="!loading && !folder"
+ >
+ <use v-bind="{ 'xlink:href':spriteHref }" />
+ </svg>
+ <icon
+ v-if="!loading && folder"
+ :name="folderIconName"
+ :size="size"
+ />
+ <loading-icon
+ v-if="loading"
+ :inline="true"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
new file mode 100644
index 00000000000..9ffbaae3ea5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -0,0 +1,589 @@
+const fileExtensionIcons = {
+ html: 'html',
+ htm: 'html',
+ html_vm: 'html',
+ asp: 'html',
+ jade: 'pug',
+ pug: 'pug',
+ md: 'markdown',
+ 'md.rendered': 'markdown',
+ markdown: 'markdown',
+ 'markdown.rendered': 'markdown',
+ rst: 'markdown',
+ blink: 'blink',
+ css: 'css',
+ scss: 'sass',
+ sass: 'sass',
+ less: 'less',
+ json: 'json',
+ yaml: 'yaml',
+ 'YAML-tmLanguage': 'yaml',
+ yml: 'yaml',
+ xml: 'xml',
+ plist: 'xml',
+ xsd: 'xml',
+ dtd: 'xml',
+ xsl: 'xml',
+ xslt: 'xml',
+ resx: 'xml',
+ iml: 'xml',
+ xquery: 'xml',
+ tmLanguage: 'xml',
+ manifest: 'xml',
+ project: 'xml',
+ png: 'image',
+ jpeg: 'image',
+ jpg: 'image',
+ gif: 'image',
+ svg: 'image',
+ ico: 'image',
+ tif: 'image',
+ tiff: 'image',
+ psd: 'image',
+ psb: 'image',
+ ami: 'image',
+ apx: 'image',
+ bmp: 'image',
+ bpg: 'image',
+ brk: 'image',
+ cur: 'image',
+ dds: 'image',
+ dng: 'image',
+ exr: 'image',
+ fpx: 'image',
+ gbr: 'image',
+ img: 'image',
+ jbig2: 'image',
+ jb2: 'image',
+ jng: 'image',
+ jxr: 'image',
+ pbm: 'image',
+ pgf: 'image',
+ pic: 'image',
+ raw: 'image',
+ webp: 'image',
+ js: 'javascript',
+ ejs: 'javascript',
+ esx: 'javascript',
+ jsx: 'react',
+ tsx: 'react',
+ ini: 'settings',
+ dlc: 'settings',
+ dll: 'settings',
+ config: 'settings',
+ conf: 'settings',
+ properties: 'settings',
+ prop: 'settings',
+ settings: 'settings',
+ option: 'settings',
+ props: 'settings',
+ toml: 'settings',
+ prefs: 'settings',
+ 'sln.dotsettings': 'settings',
+ 'sln.dotsettings.user': 'settings',
+ ts: 'typescript',
+ 'd.ts': 'typescript-def',
+ marko: 'markojs',
+ pdf: 'pdf',
+ xlsx: 'table',
+ xls: 'table',
+ csv: 'table',
+ tsv: 'table',
+ vscodeignore: 'vscode',
+ vsixmanifest: 'vscode',
+ vsix: 'vscode',
+ 'code-workplace': 'vscode',
+ suo: 'visualstudio',
+ sln: 'visualstudio',
+ csproj: 'visualstudio',
+ vb: 'visualstudio',
+ pdb: 'database',
+ sql: 'database',
+ pks: 'database',
+ pkb: 'database',
+ accdb: 'database',
+ mdb: 'database',
+ sqlite: 'database',
+ cs: 'csharp',
+ zip: 'zip',
+ tar: 'zip',
+ gz: 'zip',
+ xz: 'zip',
+ bzip2: 'zip',
+ gzip: 'zip',
+ '7z': 'zip',
+ rar: 'zip',
+ tgz: 'zip',
+ exe: 'exe',
+ msi: 'exe',
+ java: 'java',
+ jar: 'java',
+ jsp: 'java',
+ c: 'c',
+ m: 'c',
+ h: 'h',
+ cc: 'cpp',
+ cpp: 'cpp',
+ mm: 'cpp',
+ cxx: 'cpp',
+ hpp: 'hpp',
+ go: 'go',
+ py: 'python',
+ url: 'url',
+ sh: 'console',
+ ksh: 'console',
+ csh: 'console',
+ tcsh: 'console',
+ zsh: 'console',
+ bash: 'console',
+ bat: 'console',
+ cmd: 'console',
+ ps1: 'powershell',
+ psm1: 'powershell',
+ psd1: 'powershell',
+ ps1xml: 'powershell',
+ psc1: 'powershell',
+ pssc: 'powershell',
+ gradle: 'gradle',
+ doc: 'word',
+ docx: 'word',
+ rtf: 'word',
+ cer: 'certificate',
+ cert: 'certificate',
+ crt: 'certificate',
+ pub: 'key',
+ key: 'key',
+ pem: 'key',
+ asc: 'key',
+ gpg: 'key',
+ woff: 'font',
+ woff2: 'font',
+ ttf: 'font',
+ eot: 'font',
+ suit: 'font',
+ otf: 'font',
+ bmap: 'font',
+ fnt: 'font',
+ odttf: 'font',
+ ttc: 'font',
+ font: 'font',
+ fonts: 'font',
+ sui: 'font',
+ ntf: 'font',
+ mrf: 'font',
+ lib: 'lib',
+ bib: 'lib',
+ rb: 'ruby',
+ erb: 'ruby',
+ fs: 'fsharp',
+ fsx: 'fsharp',
+ fsi: 'fsharp',
+ fsproj: 'fsharp',
+ swift: 'swift',
+ ino: 'arduino',
+ dockerignore: 'docker',
+ dockerfile: 'docker',
+ tex: 'tex',
+ cls: 'tex',
+ sty: 'tex',
+ pptx: 'powerpoint',
+ ppt: 'powerpoint',
+ pptm: 'powerpoint',
+ potx: 'powerpoint',
+ pot: 'powerpoint',
+ potm: 'powerpoint',
+ ppsx: 'powerpoint',
+ ppsm: 'powerpoint',
+ pps: 'powerpoint',
+ ppam: 'powerpoint',
+ ppa: 'powerpoint',
+ webm: 'movie',
+ mkv: 'movie',
+ flv: 'movie',
+ vob: 'movie',
+ ogv: 'movie',
+ ogg: 'movie',
+ gifv: 'movie',
+ avi: 'movie',
+ mov: 'movie',
+ qt: 'movie',
+ wmv: 'movie',
+ yuv: 'movie',
+ rm: 'movie',
+ rmvb: 'movie',
+ mp4: 'movie',
+ m4v: 'movie',
+ mpg: 'movie',
+ mp2: 'movie',
+ mpeg: 'movie',
+ mpe: 'movie',
+ mpv: 'movie',
+ m2v: 'movie',
+ vdi: 'virtual',
+ vbox: 'virtual',
+ 'vbox-prev': 'virtual',
+ ics: 'email',
+ mp3: 'music',
+ flac: 'music',
+ m4a: 'music',
+ wma: 'music',
+ aiff: 'music',
+ coffee: 'coffee',
+ txt: 'document',
+ graphql: 'graphql',
+ rs: 'rust',
+ raml: 'raml',
+ xaml: 'xaml',
+ hs: 'haskell',
+ kt: 'kotlin',
+ kts: 'kotlin',
+ patch: 'git',
+ lua: 'lua',
+ clj: 'clojure',
+ cljs: 'clojure',
+ groovy: 'groovy',
+ r: 'r',
+ rmd: 'r',
+ dart: 'dart',
+ as: 'actionscript',
+ mxml: 'mxml',
+ ahk: 'autohotkey',
+ swf: 'flash',
+ swc: 'swc',
+ cmake: 'cmake',
+ asm: 'assembly',
+ a51: 'assembly',
+ inc: 'assembly',
+ nasm: 'assembly',
+ s: 'assembly',
+ ms: 'assembly',
+ agc: 'assembly',
+ ags: 'assembly',
+ aea: 'assembly',
+ argus: 'assembly',
+ mitigus: 'assembly',
+ binsource: 'assembly',
+ vue: 'vue',
+ ml: 'ocaml',
+ mli: 'ocaml',
+ cmx: 'ocaml',
+ 'js.map': 'javascript-map',
+ 'css.map': 'css-map',
+ lock: 'lock',
+ hbs: 'handlebars',
+ mustache: 'handlebars',
+ pl: 'perl',
+ pm: 'perl',
+ hx: 'haxe',
+ 'spec.ts': 'test-ts',
+ 'test.ts': 'test-ts',
+ 'ts.snap': 'test-ts',
+ 'spec.tsx': 'test-jsx',
+ 'test.tsx': 'test-jsx',
+ 'tsx.snap': 'test-jsx',
+ 'spec.jsx': 'test-jsx',
+ 'test.jsx': 'test-jsx',
+ 'jsx.snap': 'test-jsx',
+ 'spec.js': 'test-js',
+ 'test.js': 'test-js',
+ 'js.snap': 'test-js',
+ 'routing.ts': 'angular-routing',
+ 'routing.js': 'angular-routing',
+ 'module.ts': 'angular',
+ 'module.js': 'angular',
+ 'ng-template': 'angular',
+ 'component.ts': 'angular-component',
+ 'component.js': 'angular-component',
+ 'guard.ts': 'angular-guard',
+ 'guard.js': 'angular-guard',
+ 'service.ts': 'angular-service',
+ 'service.js': 'angular-service',
+ 'pipe.ts': 'angular-pipe',
+ 'pipe.js': 'angular-pipe',
+ 'filter.js': 'angular-pipe',
+ 'directive.ts': 'angular-directive',
+ 'directive.js': 'angular-directive',
+ 'resolver.ts': 'angular-resolver',
+ 'resolver.js': 'angular-resolver',
+ pp: 'puppet',
+ ex: 'elixir',
+ exs: 'elixir',
+ ls: 'livescript',
+ erl: 'erlang',
+ twig: 'twig',
+ jl: 'julia',
+ elm: 'elm',
+ pure: 'purescript',
+ tpl: 'smarty',
+ styl: 'stylus',
+ re: 'reason',
+ rei: 'reason',
+ cmj: 'bucklescript',
+ merlin: 'merlin',
+ v: 'verilog',
+ vhd: 'verilog',
+ sv: 'verilog',
+ svh: 'verilog',
+ nb: 'mathematica',
+ wl: 'wolframlanguage',
+ wls: 'wolframlanguage',
+ njk: 'nunjucks',
+ nunjucks: 'nunjucks',
+ robot: 'robot',
+ sol: 'solidity',
+ au3: 'autoit',
+ haml: 'haml',
+ yang: 'yang',
+ tf: 'terraform',
+ 'tf.json': 'terraform',
+ tfvars: 'terraform',
+ tfstate: 'terraform',
+ 'blade.php': 'laravel',
+ 'inky.php': 'laravel',
+ applescript: 'applescript',
+ cake: 'cake',
+ feature: 'cucumber',
+ nim: 'nim',
+ nimble: 'nim',
+ apib: 'apiblueprint',
+ apiblueprint: 'apiblueprint',
+ tag: 'riot',
+ vfl: 'vfl',
+ kl: 'kl',
+ pcss: 'postcss',
+ sss: 'postcss',
+ todo: 'todo',
+ cfml: 'coldfusion',
+ cfc: 'coldfusion',
+ lucee: 'coldfusion',
+ cabal: 'cabal',
+ nix: 'nix',
+ slim: 'slim',
+ http: 'http',
+ rest: 'http',
+ rql: 'restql',
+ restql: 'restql',
+ kv: 'kivy',
+ graphcool: 'graphcool',
+ sbt: 'sbt',
+ 'reducer.ts': 'ngrx-reducer',
+ 'rootReducer.ts': 'ngrx-reducer',
+ 'state.ts': 'ngrx-state',
+ 'actions.ts': 'ngrx-actions',
+ 'effects.ts': 'ngrx-effects',
+ cr: 'crystal',
+ 'drone.yml': 'drone',
+ cu: 'cuda',
+ cuh: 'cuda',
+ log: 'log',
+};
+
+const fileNameIcons = {
+ '.jscsrc': 'json',
+ '.jshintrc': 'json',
+ 'tsconfig.json': 'json',
+ 'tslint.json': 'json',
+ 'composer.lock': 'json',
+ '.jsbeautifyrc': 'json',
+ '.esformatter': 'json',
+ 'cdp.pid': 'json',
+ '.htaccess': 'xml',
+ '.jshintignore': 'settings',
+ '.buildignore': 'settings',
+ makefile: 'settings',
+ '.mrconfig': 'settings',
+ '.yardopts': 'settings',
+ 'gradle.properties': 'gradle',
+ gradlew: 'gradle',
+ 'gradle-wrapper.properties': 'gradle',
+ license: 'certificate',
+ 'license.md': 'certificate',
+ 'license.md.rendered': 'certificate',
+ 'license.txt': 'certificate',
+ licence: 'certificate',
+ 'licence.md': 'certificate',
+ 'licence.md.rendered': 'certificate',
+ 'licence.txt': 'certificate',
+ dockerfile: 'docker',
+ 'docker-compose.yml': 'docker',
+ '.mailmap': 'email',
+ '.gitignore': 'git',
+ '.gitconfig': 'git',
+ '.gitattributes': 'git',
+ '.gitmodules': 'git',
+ '.gitkeep': 'git',
+ 'git-history': 'git',
+ '.Rhistory': 'r',
+ 'cmakelists.txt': 'cmake',
+ 'cmakecache.txt': 'cmake',
+ 'angular-cli.json': 'angular',
+ '.angular-cli.json': 'angular',
+ '.vfl': 'vfl',
+ '.kl': 'kl',
+ 'postcss.config.js': 'postcss',
+ '.postcssrc.js': 'postcss',
+ 'project.graphcool': 'graphcool',
+ 'webpack.js': 'webpack',
+ 'webpack.ts': 'webpack',
+ 'webpack.base.js': 'webpack',
+ 'webpack.base.ts': 'webpack',
+ 'webpack.config.js': 'webpack',
+ 'webpack.config.ts': 'webpack',
+ 'webpack.common.js': 'webpack',
+ 'webpack.common.ts': 'webpack',
+ 'webpack.config.common.js': 'webpack',
+ 'webpack.config.common.ts': 'webpack',
+ 'webpack.config.common.babel.js': 'webpack',
+ 'webpack.config.common.babel.ts': 'webpack',
+ 'webpack.dev.js': 'webpack',
+ 'webpack.dev.ts': 'webpack',
+ 'webpack.config.dev.js': 'webpack',
+ 'webpack.config.dev.ts': 'webpack',
+ 'webpack.config.dev.babel.js': 'webpack',
+ 'webpack.config.dev.babel.ts': 'webpack',
+ 'webpack.prod.js': 'webpack',
+ 'webpack.prod.ts': 'webpack',
+ 'webpack.server.js': 'webpack',
+ 'webpack.server.ts': 'webpack',
+ 'webpack.client.js': 'webpack',
+ 'webpack.client.ts': 'webpack',
+ 'webpack.config.server.js': 'webpack',
+ 'webpack.config.server.ts': 'webpack',
+ 'webpack.config.client.js': 'webpack',
+ 'webpack.config.client.ts': 'webpack',
+ 'webpack.config.production.babel.js': 'webpack',
+ 'webpack.config.production.babel.ts': 'webpack',
+ 'webpack.config.prod.babel.js': 'webpack',
+ 'webpack.config.prod.babel.ts': 'webpack',
+ 'webpack.config.prod.js': 'webpack',
+ 'webpack.config.prod.ts': 'webpack',
+ 'webpack.config.production.js': 'webpack',
+ 'webpack.config.production.ts': 'webpack',
+ 'webpack.config.staging.js': 'webpack',
+ 'webpack.config.staging.ts': 'webpack',
+ 'webpack.config.babel.js': 'webpack',
+ 'webpack.config.babel.ts': 'webpack',
+ 'webpack.config.base.babel.js': 'webpack',
+ 'webpack.config.base.babel.ts': 'webpack',
+ 'webpack.config.base.js': 'webpack',
+ 'webpack.config.base.ts': 'webpack',
+ 'webpack.config.staging.babel.js': 'webpack',
+ 'webpack.config.staging.babel.ts': 'webpack',
+ 'webpack.config.coffee': 'webpack',
+ 'webpack.config.test.js': 'webpack',
+ 'webpack.config.test.ts': 'webpack',
+ 'webpack.config.vendor.js': 'webpack',
+ 'webpack.config.vendor.ts': 'webpack',
+ 'webpack.config.vendor.production.js': 'webpack',
+ 'webpack.config.vendor.production.ts': 'webpack',
+ 'webpack.test.js': 'webpack',
+ 'webpack.test.ts': 'webpack',
+ 'webpack.dist.js': 'webpack',
+ 'webpack.dist.ts': 'webpack',
+ 'webpackfile.js': 'webpack',
+ 'webpackfile.ts': 'webpack',
+ 'ionic.config.json': 'ionic',
+ '.io-config.json': 'ionic',
+ 'gulpfile.js': 'gulp',
+ 'gulpfile.ts': 'gulp',
+ 'gulpfile.babel.js': 'gulp',
+ 'package.json': 'nodejs',
+ 'package-lock.json': 'nodejs',
+ '.nvmrc': 'nodejs',
+ '.npmignore': 'npm',
+ '.npmrc': 'npm',
+ '.yarnrc': 'yarn',
+ 'yarn.lock': 'yarn',
+ '.yarnclean': 'yarn',
+ '.yarn-integrity': 'yarn',
+ 'yarn-error.log': 'yarn',
+ 'androidmanifest.xml': 'android',
+ '.env': 'tune',
+ '.env.example': 'tune',
+ '.babelrc': 'babel',
+ 'contributing.md': 'contributing',
+ 'contributing.md.rendered': 'contributing',
+ 'readme.md': 'readme',
+ 'readme.md.rendered': 'readme',
+ changelog: 'changelog',
+ 'changelog.md': 'changelog',
+ 'changelog.md.rendered': 'changelog',
+ CREDITS: 'credits',
+ 'credits.txt': 'credits',
+ 'credits.md': 'credits',
+ 'credits.md.rendered': 'credits',
+ '.flowconfig': 'flow',
+ 'favicon.ico': 'favicon',
+ 'karma.conf.js': 'karma',
+ 'karma.conf.ts': 'karma',
+ 'karma.conf.coffee': 'karma',
+ 'karma.config.js': 'karma',
+ 'karma.config.ts': 'karma',
+ 'karma-main.js': 'karma',
+ 'karma-main.ts': 'karma',
+ '.bithoundrc': 'bithound',
+ 'appveyor.yml': 'appveyor',
+ '.travis.yml': 'travis',
+ 'protractor.conf.js': 'protractor',
+ 'protractor.conf.ts': 'protractor',
+ 'protractor.conf.coffee': 'protractor',
+ 'protractor.config.js': 'protractor',
+ 'protractor.config.ts': 'protractor',
+ 'fuse.js': 'fusebox',
+ procfile: 'heroku',
+ '.editorconfig': 'editorconfig',
+ '.gitlab-ci.yml': 'gitlab',
+ '.bowerrc': 'bower',
+ 'bower.json': 'bower',
+ '.eslintrc.js': 'eslint',
+ '.eslintrc.yaml': 'eslint',
+ '.eslintrc.yml': 'eslint',
+ '.eslintrc.json': 'eslint',
+ '.eslintrc': 'eslint',
+ '.eslintignore': 'eslint',
+ 'code_of_conduct.md': 'conduct',
+ 'code_of_conduct.md.rendered': 'conduct',
+ '.watchmanconfig': 'watchman',
+ 'aurelia.json': 'aurelia',
+ 'mocha.opts': 'mocha',
+ jenkinsfile: 'jenkins',
+ 'firebase.json': 'firebase',
+ '.firebaserc': 'firebase',
+ 'rollup.config.js': 'rollup',
+ 'rollup.config.ts': 'rollup',
+ 'rollup-config.js': 'rollup',
+ 'rollup-config.ts': 'rollup',
+ 'rollup.config.prod.js': 'rollup',
+ 'rollup.config.prod.ts': 'rollup',
+ 'rollup.config.dev.js': 'rollup',
+ 'rollup.config.dev.ts': 'rollup',
+ 'rollup.config.prod.vendor.js': 'rollup',
+ 'rollup.config.prod.vendor.ts': 'rollup',
+ '.hhconfig': 'hack',
+ '.stylelintrc': 'stylelint',
+ 'stylelint.config.js': 'stylelint',
+ '.stylelintrc.json': 'stylelint',
+ '.stylelintrc.yaml': 'stylelint',
+ '.stylelintrc.yml': 'stylelint',
+ '.stylelintrc.js': 'stylelint',
+ '.stylelintignore': 'stylelint',
+ '.codeclimate.yml': 'code-climate',
+ '.prettierrc': 'prettier',
+ 'prettier.config.js': 'prettier',
+ '.prettierrc.js': 'prettier',
+ '.prettierrc.json': 'prettier',
+ '.prettierrc.yaml': 'prettier',
+ '.prettierrc.yml': 'prettier',
+ 'nodemon.json': 'nodemon',
+ '.sonarrc': 'sonar',
+ browserslist: 'browserlist',
+ '.browserslistrc': 'browserlist',
+ '.snyk': 'snyk',
+ '.drone.yml': 'drone',
+};
+
+export default function getIconForFile(name) {
+ return fileNameIcons[name] ||
+ fileExtensionIcons[name ? name.split('.').pop() : ''] ||
+ '';
+}
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
new file mode 100644
index 00000000000..f28e5e2715d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue
@@ -0,0 +1,101 @@
+<script>
+const buttonVariants = ['danger', 'primary', 'success', 'warning'];
+
+export default {
+ name: 'GlModal',
+
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ headerTitleText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ footerPrimaryButtonVariant: {
+ type: String,
+ required: false,
+ default: 'primary',
+ validator: value => buttonVariants.includes(value),
+ },
+ footerPrimaryButtonText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ methods: {
+ emitCancel(event) {
+ this.$emit('cancel', event);
+ },
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :id="id"
+ class="modal fade"
+ tabindex="-1"
+ role="dialog"
+ >
+ <div
+ class="modal-dialog"
+ role="document"
+ >
+ <div class="modal-content">
+ <div class="modal-header">
+ <slot name="header">
+ <button
+ type="button"
+ class="close js-modal-close-action"
+ data-dismiss="modal"
+ :aria-label="s__('Modal|Close')"
+ @click="emitCancel($event)"
+ >
+ <span aria-hidden="true">&times;</span>
+ </button>
+ <h4 class="modal-title">
+ <slot name="title">
+ {{ headerTitleText }}
+ </slot>
+ </h4>
+ </slot>
+ </div>
+
+ <div class="modal-body">
+ <slot></slot>
+ </div>
+
+ <div class="modal-footer">
+ <slot name="footer">
+ <button
+ type="button"
+ class="btn js-modal-cancel-action"
+ data-dismiss="modal"
+ @click="emitCancel($event)"
+ >
+ {{ s__('Modal|Cancel') }}
+ </button>
+ <button
+ type="button"
+ class="btn js-modal-primary-action"
+ :class="`btn-${footerPrimaryButtonVariant}`"
+ data-dismiss="modal"
+ @click="emitSubmit($event)"
+ >
+ {{ footerPrimaryButtonText }}
+ </button>
+ </slot>
+ </div>
+ </div>
+ </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
index d305bd6acdc..a0cd0cbd200 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,75 +1,78 @@
<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,
+ 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 {
+ components: {
+ ciIconBadge,
+ loadingIcon,
+ timeagoTooltip,
+ userAvatarImage,
},
- time: {
- type: String,
- required: true,
+ directives: {
+ tooltip,
},
- user: {
- type: Object,
- required: false,
- 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,
+ },
+ shouldRenderTriggeredLabel: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
- 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`;
+ computed: {
+ userAvatarAltText() {
+ return `${this.user.name}'s avatar`;
+ },
},
- },
- methods: {
- onClickAction(action) {
- this.$emit('actionClicked', action);
+ methods: {
+ onClickAction(action) {
+ this.$emit('actionClicked', action);
+ },
},
- },
-};
+ };
</script>
<template>
@@ -79,10 +82,15 @@ export default {
<ci-icon-badge :status="status" />
<strong>
- {{itemName}} #{{itemId}}
+ {{ itemName }} #{{ itemId }}
</strong>
- triggered
+ <template v-if="shouldRenderTriggeredLabel">
+ triggered
+ </template>
+ <template v-else>
+ created
+ </template>
<timeago-tooltip :time="time" />
@@ -93,30 +101,35 @@ export default {
v-tooltip
:href="user.path"
:title="user.email"
- class="js-user-link commit-committer-link">
+ 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}}
+ {{ user.name }}
</a>
</template>
</section>
<section
class="header-action-buttons"
- v-if="actions.length">
+ v-if="actions.length"
+ >
<template
- v-for="action in actions">
+ v-for="(action, i) in actions"
+ >
<a
v-if="action.type === 'link'"
:href="action.path"
- :class="action.cssClass">
- {{action.label}}
+ :class="action.cssClass"
+ :key="i"
+ >
+ {{ action.label }}
</a>
<a
@@ -124,8 +137,10 @@ export default {
:href="action.path"
data-method="post"
rel="nofollow"
- :class="action.cssClass">
- {{action.label}}
+ :class="action.cssClass"
+ :key="i"
+ >
+ {{ action.label }}
</a>
<button
@@ -133,25 +148,31 @@ export default {
@click="onClickAction(action)"
:disabled="action.isLoading"
:class="action.cssClass"
- type="button">
- {{action.label}}
+ type="button"
+ :key="i"
+ >
+ {{ action.label }}
<i
v-show="action.isLoading"
class="fa fa-spin fa-spinner"
- aria-hidden="true">
+ 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"
+ 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">
+ id="toggleSidebar"
+ >
<i
class="fa fa-angle-double-left"
aria-hidden="true"
- aria-labelledby="toggleSidebar">
+ aria-labelledby="toggleSidebar"
+ >
</i>
</button>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index 365229ea274..6a2e05000e1 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -1,17 +1,17 @@
<script>
-/* This is a re-usable vue component for rendering a svg sprite
- icon
+ /* This is a re-usable vue component for rendering a svg sprite
+ icon
- Sample configuration:
+ Sample configuration:
- <icon
- name="retry"
- :size="32"
- css-classes="top"
- />
+ <icon
+ name="retry"
+ :size="32"
+ css-classes="top"
+ />
-*/
+ */
// only allow classes in images.scss e.g. s12
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
@@ -80,7 +80,6 @@
:height="height"
:x="x"
:y="y">
- <use
- v-bind="{'xlink:href':spriteHref}"/>
+ <use v-bind="{ 'xlink:href':spriteHref }" />
</svg>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
index 7cf2e029cf6..0a30f467b08 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -46,6 +46,6 @@ export default {
class="avatar identicon"
:class="sizeClass"
:style="identiconStyles">
- {{identiconTitle}}
+ {{ identiconTitle }}
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
index 564fc5029af..3d39b3ab173 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
@@ -1,25 +1,22 @@
<script>
- import Icon from '../../../vue_shared/components/icon.vue';
+ import icon from '../../../vue_shared/components/icon.vue';
export default {
+ components: {
+ icon,
+ },
props: {
isLocked: {
type: Boolean,
default: false,
required: false,
},
-
isConfidential: {
type: Boolean,
default: false,
required: false,
},
},
-
- components: {
- Icon,
- },
-
computed: {
warningIcon() {
if (this.isConfidential) return 'eye-slash';
@@ -27,7 +24,6 @@
return '';
},
-
isLockedAndConfidential() {
return this.isConfidential && this.isLocked;
},
@@ -37,16 +33,17 @@
<template>
<div class="issuable-note-warning">
<icon
- :name="warningIcon"
- :size="16"
- class="icon inline"
- aria-hidden="true"
- v-if="!isLockedAndConfidential">
- </icon>
+ :name="warningIcon"
+ :size="16"
+ class="icon inline"
+ aria-hidden="true"
+ v-if="!isLockedAndConfidential"
+ />
<span v-if="isLockedAndConfidential">
{{ __('This issue is confidential and locked.') }}
- {{ __('People without permission will never get a notification and won\'t be able to comment.') }}
+ {{ __(`People without permission will never
+get a notification and won't be able to comment.`) }}
</span>
<span v-else-if="isConfidential">
diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue
index 247943f83e6..e832d94d32f 100644
--- a/app/assets/javascripts/vue_shared/components/loading_button.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_button.vue
@@ -1,55 +1,55 @@
<script>
+ /* eslint-disable vue/require-default-prop */
+ /* This is a re-usable vue component for rendering a button
+ that will probably be sending off ajax requests and need
+ to show the loading status by setting the `loading` option.
+ This can also be used for initial page load when you don't
+ know the action of the button yet by setting
+ `loading: true, label: undefined`.
-/* This is a re-usable vue component for rendering a button
- that will probably be sending off ajax requests and need
- to show the loading status by setting the `loading` option.
- This can also be used for initial page load when you don't
- know the action of the button yet by setting
- `loading: true, label: undefined`.
+ Sample configuration:
- Sample configuration:
+ <loading-button
+ :loading="true"
+ :label="Hello"
+ @click="..."
+ />
- <loading-button
- :loading="true"
- :label="Hello"
- @click="..."
- />
+ */
-*/
+ import loadingIcon from './loading_icon.vue';
-import loadingIcon from './loading_icon.vue';
-
-export default {
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- label: {
- type: String,
- required: false,
+ export default {
+ components: {
+ loadingIcon,
},
- containerClass: {
- type: String,
- required: false,
- default: 'btn btn-align-content',
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: false,
+ },
+ containerClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: 'btn btn-align-content',
+ },
},
- },
- components: {
- loadingIcon,
- },
- methods: {
- onClick(e) {
- this.$emit('click', e);
+ methods: {
+ onClick(e) {
+ this.$emit('click', e);
+ },
},
- },
-};
+ };
</script>
<template>
@@ -59,23 +59,23 @@ export default {
:class="containerClass"
:disabled="loading || disabled"
>
- <transition name="fade">
- <loading-icon
- v-if="loading"
- :inline="true"
- class="js-loading-button-icon"
- :class="{
- 'append-right-5': label
- }"
- />
- </transition>
- <transition name="fade">
- <span
- v-if="label"
- class="js-loading-button-label"
- >
- {{ label }}
- </span>
- </transition>
+ <transition name="fade">
+ <loading-icon
+ v-if="loading"
+ :inline="true"
+ class="js-loading-button-icon"
+ :class="{
+ 'append-right-5': label
+ }"
+ />
+ </transition>
+ <transition name="fade">
+ <span
+ v-if="label"
+ class="js-loading-button-label"
+ >
+ {{ label }}
+ </span>
+ </transition>
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue
index 15581d5c2a0..12a75e016d7 100644
--- a/app/assets/javascripts/vue_shared/components/loading_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue
@@ -32,13 +32,14 @@
</script>
<template>
<component
- :is="this.rootElementType"
- class="text-center">
+ :is="rootElementType"
+ class="loading-container text-center">
<i
class="fa fa-spin fa-spinner"
:class="cssClass"
aria-hidden="true"
- :aria-label="label">
+ :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
index 15e3d713448..12c7d125062 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,4 +1,5 @@
<script>
+ import $ from 'jquery';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
@@ -6,6 +7,11 @@
import icon from '../icon.vue';
export default {
+ components: {
+ markdownHeader,
+ markdownToolbar,
+ icon,
+ },
props: {
markdownPreviewPath: {
type: String,
@@ -24,6 +30,7 @@
quickActionsDocsPath: {
type: String,
required: false,
+ default: '',
},
canAttachFile: {
type: Boolean,
@@ -45,17 +52,24 @@
previewMarkdown: false,
};
},
- components: {
- markdownHeader,
- markdownToolbar,
- icon,
- },
computed: {
shouldShowReferencedUsers() {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
},
+ mounted() {
+ /*
+ GLForm class handles all the toolbar buttons
+ */
+ return new GLForm($(this.$refs['gl-form']), this.enableAutocomplete);
+ },
+ beforeDestroy() {
+ const glForm = $(this.$refs['gl-form']).data('glForm');
+ if (glForm) {
+ glForm.destroy();
+ }
+ },
methods: {
showPreviewTab() {
if (this.previewMarkdown) return;
@@ -98,18 +112,6 @@
});
},
},
- mounted() {
- /*
- GLForm class handles all the toolbar buttons
- */
- return new GLForm($(this.$refs['gl-form']), this.enableAutocomplete);
- },
- beforeDestroy() {
- const glForm = $(this.$refs['gl-form']).data('gl-form');
- if (glForm) {
- glForm.destroy();
- }
- },
};
</script>
@@ -121,34 +123,39 @@
<markdown-header
:preview-markdown="previewMarkdown"
@preview-markdown="showPreviewTab"
- @write-markdown="showWriteTab" />
+ @write-markdown="showWriteTab"
+ />
<div
class="md-write-holder"
- v-show="!previewMarkdown">
+ 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">
+ aria-label="Enter zen mode"
+ >
<icon
name="screen-normal"
- :size="32">
- </icon>
+ :size="32"
+ />
</a>
<markdown-toolbar
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:can-attach-file="canAttachFile"
- />
+ />
</div>
</div>
<div
class="md md-preview-holder md-preview"
- v-show="previewMarkdown">
+ v-show="previewMarkdown"
+ >
<div
ref="markdown-preview"
- v-html="markdownPreview">
+ v-html="markdownPreview"
+ >
</div>
<span v-if="markdownPreviewLoading">
Loading...
@@ -158,23 +165,27 @@
<div
v-if="referencedCommands"
v-html="referencedCommands"
- class="referenced-commands"></div>
+ class="referenced-commands"
+ >
+ </div>
<div
v-if="shouldShowReferencedUsers"
- class="referenced-users">
- <span>
- <i
- class="fa fa-exclamation-triangle"
- aria-hidden="true">
- </i>
- You are about to add
- <strong>
- <span class="js-referenced-users-count">
- {{referencedUsers.length}}
- </span>
- </strong> people to the discussion. Proceed with caution.
- </span>
- </div>
+ class="referenced-users"
+ >
+ <span>
+ <i
+ class="fa fa-exclamation-triangle"
+ aria-hidden="true"
+ >
+ </i>
+ You are about to add
+ <strong>
+ <span class="js-referenced-users-count">
+ {{ referencedUsers.length }}
+ </span>
+ </strong> people to the discussion. Proceed with caution.
+ </span>
+ </div>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 36d2d1dc164..d91fe3cf0c5 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,21 +1,30 @@
<script>
+ import $ from 'jquery';
import tooltip from '../../directives/tooltip';
import toolbarButton from './toolbar_button.vue';
import icon from '../icon.vue';
export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ toolbarButton,
+ icon,
+ },
props: {
previewMarkdown: {
type: Boolean,
required: true,
},
},
- directives: {
- tooltip,
+ mounted() {
+ $(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
+ $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab);
},
- components: {
- toolbarButton,
- icon,
+ beforeDestroy() {
+ $(document).off('markdown-preview:show.vue', this.previewMarkdownTab);
+ $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab);
},
methods: {
isMarkdownForm(form) {
@@ -36,14 +45,6 @@
this.$emit('write-markdown');
},
},
- mounted() {
- $(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
- $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab);
- },
- beforeDestroy() {
- $(document).off('markdown-preview:show.vue', this.previewMarkdownTab);
- $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab);
- },
};
</script>
@@ -52,62 +53,74 @@
<ul class="nav-links clearfix">
<li
class="md-header-tab"
- :class="{ active: !previewMarkdown }">
+ :class="{ active: !previewMarkdown }"
+ >
<a
class="js-write-link"
href="#md-write-holder"
tabindex="-1"
- @click.prevent="writeMarkdownTab($event)">
+ @click.prevent="writeMarkdownTab($event)"
+ >
Write
</a>
</li>
<li
class="md-header-tab"
- :class="{ active: previewMarkdown }">
+ :class="{ active: previewMarkdown }"
+ >
<a
class="js-preview-link"
href="#md-preview-holder"
tabindex="-1"
- @click.prevent="previewMarkdownTab($event)">
+ @click.prevent="previewMarkdownTab($event)"
+ >
Preview
</a>
</li>
<li
class="md-header-toolbar"
- :class="{ active: !previewMarkdown }">
+ :class="{ active: !previewMarkdown }"
+ >
<toolbar-button
tag="**"
button-title="Add bold text"
- icon="bold" />
+ icon="bold"
+ />
<toolbar-button
tag="*"
button-title="Add italic text"
- icon="italic" />
+ icon="italic"
+ />
<toolbar-button
tag="> "
:prepend="true"
button-title="Insert a quote"
- icon="quote" />
+ icon="quote"
+ />
<toolbar-button
tag="`"
tag-block="```"
button-title="Insert code"
- icon="code" />
+ icon="code"
+ />
<toolbar-button
tag="* "
:prepend="true"
button-title="Add a bullet list"
- icon="list-bulleted" />
+ icon="list-bulleted"
+ />
<toolbar-button
tag="1. "
:prepend="true"
button-title="Add a numbered list"
- icon="list-numbered" />
+ icon="list-numbered"
+ />
<toolbar-button
tag="* [ ] "
:prepend="true"
button-title="Add a task list"
- icon="task-done" />
+ icon="task-done"
+ />
<button
v-tooltip
aria-label="Go full screen"
@@ -115,10 +128,11 @@
data-container="body"
tabindex="-1"
title="Go full screen"
- type="button">
+ type="button"
+ >
<icon
- name="screen-full">
- </icon>
+ name="screen-full"
+ />
</button>
</li>
</ul>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index ea2509d2839..c0ee88bbf72 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -8,6 +8,7 @@
quickActionsDocsPath: {
type: String,
required: false,
+ default: '',
},
canAttachFile: {
type: Boolean,
@@ -15,32 +16,40 @@
default: true,
},
},
+ computed: {
+ hasQuickActionsDocsPath() {
+ return this.quickActionsDocsPath !== '';
+ },
+ },
};
</script>
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
- <template v-if="!quickActionsDocsPath && markdownDocsPath">
+ <template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
<a
:href="markdownDocsPath"
target="_blank"
- tabindex="-1">
+ tabindex="-1"
+ >
Markdown is supported
</a>
</template>
- <template v-if="quickActionsDocsPath && markdownDocsPath">
- <a
+ <template v-if="hasQuickActionsDocsPath && markdownDocsPath">
+ <a
:href="markdownDocsPath"
target="_blank"
- tabindex="-1">
+ tabindex="-1"
+ >
Markdown
</a>
and
- <a
+ <a
:href="quickActionsDocsPath"
target="_blank"
- tabindex="-1">
+ tabindex="-1"
+ >
quick actions
</a>
are supported
@@ -53,46 +62,58 @@
<span class="uploading-progress-container hide">
<i
class="fa fa-file-image-o toolbar-button-icon"
- aria-hidden="true"></i>
+ aria-hidden="true"
+ >
+ </i>
<span class="attaching-file-message"></span>
<span class="uploading-progress">0%</span>
<span class="uploading-spinner">
<i
class="fa fa-spinner fa-spin toolbar-button-icon"
- aria-hidden="true"></i>
+ aria-hidden="true"
+ >
+ </i>
</span>
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
<i
class="fa fa-file-image-o toolbar-button-icon"
- aria-hidden="true"></i>
+ aria-hidden="true"
+ >
+ </i>
</span>
<span class="uploading-error-message"></span>
<button
class="retry-uploading-link"
- type="button">
- Try again
+ type="button"
+ >
+ Try again
</button>
or
<button
class="attach-new-file markdown-selector"
- type="button">
+ type="button"
+ >
attach a new file
</button>
</span>
<button
class="markdown-selector button-attach-file"
tabindex="-1"
- type="button">
+ type="button"
+ >
<i
class="fa fa-file-image-o toolbar-button-icon"
- aria-hidden="true"></i>
+ aria-hidden="true"
+ >
+ </i>
Attach a file
</button>
<button
class="btn btn-default btn-xs hide button-cancel-uploading-files"
- type="button">
+ type="button"
+ >
Cancel
</button>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index e3e41f8f0ca..2d2d69ebeb2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -3,6 +3,12 @@
import icon from '../icon.vue';
export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
props: {
buttonTitle: {
type: String,
@@ -27,12 +33,6 @@
default: false,
},
},
- components: {
- icon,
- },
- directives: {
- tooltip,
- },
};
</script>
@@ -47,9 +47,10 @@
:data-md-block="tagBlock"
:data-md-prepend="prepend"
:title="buttonTitle"
- :aria-label="buttonTitle">
+ :aria-label="buttonTitle"
+ >
<icon
- :name="icon">
- </icon>
+ :name="icon"
+ />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js
deleted file mode 100644
index f37ef1a5ca3..00000000000
--- a/app/assets/javascripts/vue_shared/components/memory_graph.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import { getTimeago } from '../../lib/utils/datetime_utility';
-
-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 = 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/memory_graph.vue b/app/assets/javascripts/vue_shared/components/memory_graph.vue
new file mode 100644
index 00000000000..b07f6b07afe
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/memory_graph.vue
@@ -0,0 +1,133 @@
+<script>
+import { getTimeago } from '../../lib/utils/datetime_utility';
+
+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 = getTimeago().format(this.deploymentTime * 1000);
+ return `Deployed ${deployedSince}`;
+ },
+ },
+ mounted() {
+ this.renderGraph(this.deploymentTime, this.metrics);
+ },
+ 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;
+ },
+ },
+};
+</script>
+
+<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>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/modal.vue b/app/assets/javascripts/vue_shared/components/modal.vue
deleted file mode 100644
index 55f466b7b41..00000000000
--- a/app/assets/javascripts/vue_shared/components/modal.vue
+++ /dev/null
@@ -1,131 +0,0 @@
-<script>
-export default {
- name: 'modal',
-
- props: {
- title: {
- type: String,
- required: false,
- },
- text: {
- type: String,
- required: false,
- },
- hideFooter: {
- type: Boolean,
- required: false,
- default: false,
- },
- kind: {
- type: String,
- required: false,
- default: 'primary',
- },
- modalDialogClass: {
- type: String,
- required: false,
- default: '',
- },
- closeKind: {
- type: String,
- required: false,
- default: 'default',
- },
- closeButtonLabel: {
- type: String,
- required: false,
- default: 'Cancel',
- },
- primaryButtonLabel: {
- type: String,
- required: false,
- default: '',
- },
- submitDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- btnKindClass() {
- return {
- [`btn-${this.kind}`]: true,
- };
- },
- btnCancelKindClass() {
- return {
- [`btn-${this.closeKind}`]: true,
- };
- },
- },
-
- methods: {
- close() {
- this.$emit('toggle', false);
- },
- emitSubmit(status) {
- this.$emit('submit', status);
- },
- },
-};
-</script>
-
-<template>
-<div class="modal-open">
- <div
- class="modal show"
- role="dialog"
- tabindex="-1"
- >
- <div
- :class="modalDialogClass"
- class="modal-dialog"
- role="document"
- >
- <div class="modal-content">
- <div class="modal-header">
- <slot name="header">
- <h4 class="modal-title pull-left">
- {{this.title}}
- </h4>
- <button
- type="button"
- class="close pull-right"
- @click="close"
- aria-label="Close"
- >
- <span aria-hidden="true">&times;</span>
- </button>
- </slot>
- </div>
- <div class="modal-body">
- <slot name="body" :text="text">
- <p>{{this.text}}</p>
- </slot>
- </div>
- <div class="modal-footer" v-if="!hideFooter">
- <button
- type="button"
- class="btn pull-left"
- :class="btnCancelKindClass"
- @click="close">
- {{ closeButtonLabel }}
- </button>
- <button
- v-if="primaryButtonLabel"
- type="button"
- class="btn pull-right js-primary-button"
- :disabled="submitDisabled"
- :class="btnKindClass"
- @click="emitSubmit(true)">
- {{ primaryButtonLabel }}
- </button>
- </div>
- </div>
- </div>
- </div>
- <div class="modal-backdrop fade in" />
-</div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
index a2ddd565170..b33a0101dbf 100644
--- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -1,4 +1,6 @@
<script>
+ import $ from 'jquery';
+
/**
* Given an array of tabs, renders non linked bootstrap tabs.
* When a tab is clicked it will trigger an event and provide the clicked scope.
@@ -45,31 +47,30 @@
this.$emit('onChangeTab', tab.scope);
},
},
-};
+ };
</script>
<template>
- <ul class="nav-links scrolling-tabs">
+ <ul class="nav-links scrolling-tabs separator">
<li
v-for="(tab, i) in tabs"
:key="i"
:class="{
active: tab.isActive,
}"
- >
+ >
<a
role="button"
@click="onTabClick(tab)"
:class="`js-${scope}-tab-${tab.scope}`"
- >
+ >
{{ tab.name }}
<span
v-if="shouldRenderBadge(tab.count)"
class="badge"
- >
- {{tab.count}}
+ >
+ {{ tab.count }}
</span>
-
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index e467ca56704..50b1508691b 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -20,16 +20,16 @@
import userAvatarLink from '../user_avatar/user_avatar_link.vue';
export default {
- name: 'placeholderNote',
+ name: 'PlaceholderNote',
+ components: {
+ userAvatarLink,
+ },
props: {
note: {
type: Object,
required: true,
},
},
- components: {
- userAvatarLink,
- },
computed: {
...mapGetters([
'getUserData',
@@ -46,7 +46,7 @@
:link-href="getUserData.path"
:img-src="getUserData.avatar_url"
:img-size="40"
- />
+ />
</div>
<div
:class="{ discussion: !note.individual_note }"
@@ -54,14 +54,14 @@
<div class="note-header">
<div class="note-header-info">
<a :href="getUserData.path">
- <span class="hidden-xs">{{getUserData.name}}</span>
- <span class="note-headline-light">@{{getUserData.username}}</span>
+ <span class="hidden-xs">{{ getUserData.name }}</span>
+ <span class="note-headline-light">@{{ getUserData.username }}</span>
</a>
</div>
</div>
<div class="note-body">
<div class="note-text">
- <p>{{note.body}}</p>
+ <p>{{ note.body }}</p>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
index d805fea8006..95e2b38e292 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_system_note.vue
@@ -8,7 +8,7 @@
* />
*/
export default {
- name: 'placeholderSystemNote',
+ name: 'PlaceholderSystemNote',
props: {
note: {
type: Object,
@@ -20,10 +20,10 @@
<template>
<li class="note system-note timeline-entry being-posted fade-in-half">
- <div class="timeline-entry-inner">
- <div class="timeline-content">
- <em>{{note.body}}</em>
- </div>
- </div>
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <em>{{ note.body }}</em>
+ </div>
+ </div>
</li>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
new file mode 100644
index 00000000000..80e3db52cb0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -0,0 +1,24 @@
+<template>
+ <li class="timeline-entry note">
+ <div class="timeline-entry-inner">
+ <div class="timeline-icon">
+ </div>
+ <div class="timeline-content">
+ <div class="note-header"></div>
+ <div class="note-body">
+ <skeleton-loading-container />
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
+
+<script>
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+
+ export default {
+ components: {
+ skeletonLoadingContainer,
+ },
+ };
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 2248699c399..aac10f84087 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -21,16 +21,16 @@
import { spriteIcon } from '../../../lib/utils/common_utils';
export default {
- name: 'systemNote',
+ name: 'SystemNote',
+ components: {
+ noteHeader,
+ },
props: {
note: {
type: Object,
required: true,
},
},
- components: {
- noteHeader,
- },
computed: {
...mapGetters([
'targetNoteHash',
diff --git a/app/assets/javascripts/vue_shared/components/panel_resizer.vue b/app/assets/javascripts/vue_shared/components/panel_resizer.vue
new file mode 100644
index 00000000000..abbe9a22717
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/panel_resizer.vue
@@ -0,0 +1,91 @@
+<script>
+ export default {
+ props: {
+ startSize: {
+ type: Number,
+ required: true,
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ minSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ maxSize: {
+ type: Number,
+ required: false,
+ default: Number.MAX_VALUE,
+ },
+ enabled: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ size: this.startSize,
+ };
+ },
+ computed: {
+ className() {
+ return `drag${this.side}`;
+ },
+ cursorStyle() {
+ if (this.enabled) {
+ return { cursor: 'ew-resize' };
+ }
+ return {};
+ },
+ },
+ methods: {
+ resetSize(e) {
+ e.preventDefault();
+ this.size = this.startSize;
+ this.$emit('update:size', this.size);
+ },
+ startDrag(e) {
+ if (this.enabled) {
+ e.preventDefault();
+ this.startPos = e.clientX;
+ this.currentStartSize = this.size;
+ document.addEventListener('mousemove', this.drag);
+ document.addEventListener('mouseup', this.endDrag, { once: true });
+ this.$emit('resize-start', this.size);
+ }
+ },
+ drag(e) {
+ e.preventDefault();
+ let moved = e.clientX - this.startPos;
+ if (this.side === 'left') moved = -moved;
+ let newSize = this.currentStartSize + moved;
+ if (newSize < this.minSize) {
+ newSize = this.minSize;
+ } else if (newSize > this.maxSize) {
+ newSize = this.maxSize;
+ }
+ this.size = newSize;
+
+ this.$emit('update:size', newSize);
+ },
+ endDrag(e) {
+ e.preventDefault();
+ document.removeEventListener('mousemove', this.drag);
+ this.$emit('resize-end', this.size);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dragHandle"
+ :class="className"
+ :style="cursorStyle"
+ @mousedown="startDrag"
+ @dblclick="resetSize"
+ ></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
index d8d974a2ff7..bfeece12077 100644
--- a/app/assets/javascripts/vue_shared/components/pikaday.vue
+++ b/app/assets/javascripts/vue_shared/components/pikaday.vue
@@ -3,7 +3,7 @@
import { parsePikadayDate, pikadayToString } from '../../lib/utils/datefix';
export default {
- name: 'datePicker',
+ name: 'DatePicker',
props: {
label: {
type: String,
@@ -13,22 +13,17 @@
selectedDate: {
type: Date,
required: false,
+ default: null,
},
minDate: {
type: Date,
required: false,
+ default: null,
},
maxDate: {
type: Date,
required: false,
- },
- },
- methods: {
- selected(dateText) {
- this.$emit('newDateSelected', this.calendar.toString(dateText));
- },
- toggled() {
- this.$emit('hidePicker');
+ default: null,
},
},
mounted() {
@@ -53,6 +48,14 @@
beforeDestroy() {
this.calendar.destroy();
},
+ methods: {
+ selected(dateText) {
+ this.$emit('newDateSelected', this.calendar.toString(dateText));
+ },
+ toggled() {
+ this.$emit('hidePicker');
+ },
+ },
};
</script>
@@ -66,7 +69,7 @@
@click="toggled"
>
<span class="dropdown-toggle-text">
- {{label}}
+ {{ label }}
</span>
<i
class="fa fa-chevron-down"
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
index dce23bd65f6..279cc1de5bb 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
@@ -1,85 +1,85 @@
<script>
-/* This is a re-usable vue component for rendering a project avatar that
- does not need to link to the project's profile. The image and an optional
- tooltip can be configured by props passed to this component.
+ /* This is a re-usable vue component for rendering a project avatar that
+ does not need to link to the project's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
- Sample configuration:
+ Sample configuration:
- <project-avatar-image
- :lazy="true"
- :img-src="projectAvatarSrc"
- :img-alt="tooltipText"
- :tooltip-text="tooltipText"
- tooltip-placement="top"
- />
+ <project-avatar-image
+ :lazy="true"
+ :img-src="projectAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
-*/
+ */
-import defaultAvatarUrl from 'images/no_avatar.png';
-import { placeholderImage } from '../../../lazy_loader';
-import tooltip from '../../directives/tooltip';
+ import defaultAvatarUrl from 'images/no_avatar.png';
+ import { placeholderImage } from '../../../lazy_loader';
+ import tooltip from '../../directives/tooltip';
-export default {
- name: 'ProjectAvatarImage',
- props: {
- lazy: {
- type: Boolean,
- required: false,
- default: false,
- },
- imgSrc: {
- type: String,
- required: false,
- default: defaultAvatarUrl,
- },
- cssClasses: {
- type: String,
- required: false,
- default: '',
- },
- imgAlt: {
- type: String,
- required: false,
- default: 'project avatar',
- },
- size: {
- type: Number,
- required: false,
- default: 20,
- },
- tooltipText: {
- type: String,
- required: false,
- default: '',
- },
- tooltipPlacement: {
- type: String,
- required: false,
- default: 'top',
- },
- },
- directives: {
- tooltip,
- },
- computed: {
- // API response sends null when gravatar is disabled and
- // we provide an empty string when we use it inside project avatar link.
- // In both cases we should render the defaultAvatarUrl
- sanitizedSource() {
- return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- },
- resultantSrcAttribute() {
- return this.lazy ? placeholderImage : this.sanitizedSource;
+ export default {
+ name: 'ProjectAvatarImage',
+ directives: {
+ tooltip,
},
- tooltipContainer() {
- return this.tooltipText ? 'body' : null;
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: 'project avatar',
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
},
- avatarSizeClass() {
- return `s${this.size}`;
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside project avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ tooltipContainer() {
+ return this.tooltipText ? 'body' : null;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
},
- },
-};
+ };
</script>
<template>
@@ -87,7 +87,7 @@ export default {
v-tooltip
class="avatar"
:class="{
- lazy,
+ lazy: lazy,
[avatarSizeClass]: true,
[cssClasses]: true
}"
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
index 8053c65d498..21ffdc1dc86 100644
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
@@ -1,85 +1,86 @@
<script>
-import modal from './modal.vue';
+ import DeprecatedModal from './deprecated_modal.vue';
-export default {
- name: 'recaptcha-modal',
+ export default {
+ name: 'RecaptchaModal',
- props: {
- html: {
- type: String,
- required: false,
- default: '',
+ components: {
+ DeprecatedModal,
},
- },
- data() {
- return {
- script: {},
- scriptSrc: 'https://www.google.com/recaptcha/api.js',
- };
- },
+ props: {
+ html: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
- components: {
- modal,
- },
+ data() {
+ return {
+ script: {},
+ scriptSrc: 'https://www.google.com/recaptcha/api.js',
+ };
+ },
- methods: {
- appendRecaptchaScript() {
- this.removeRecaptchaScript();
+ watch: {
+ html() {
+ this.appendRecaptchaScript();
+ },
+ },
- const script = document.createElement('script');
- script.src = this.scriptSrc;
- script.classList.add('js-recaptcha-script');
- script.async = true;
- script.defer = true;
+ mounted() {
+ window.recaptchaDialogCallback = this.submit.bind(this);
+ },
- this.script = script;
+ methods: {
+ appendRecaptchaScript() {
+ this.removeRecaptchaScript();
- document.body.appendChild(script);
- },
+ const script = document.createElement('script');
+ script.src = this.scriptSrc;
+ script.classList.add('js-recaptcha-script');
+ script.async = true;
+ script.defer = true;
- removeRecaptchaScript() {
- if (this.script instanceof Element) this.script.remove();
- },
+ this.script = script;
- close() {
- this.removeRecaptchaScript();
- this.$emit('close');
- },
+ document.body.appendChild(script);
+ },
- submit() {
- this.$el.querySelector('form').submit();
- },
- },
+ removeRecaptchaScript() {
+ if (this.script instanceof Element) this.script.remove();
+ },
- watch: {
- html() {
- this.appendRecaptchaScript();
- },
- },
+ close() {
+ this.removeRecaptchaScript();
+ this.$emit('close');
+ },
- mounted() {
- window.recaptchaDialogCallback = this.submit.bind(this);
- },
-};
+ submit() {
+ this.$el.querySelector('form').submit();
+ },
+ },
+ };
</script>
<template>
-<modal
- kind="warning"
- class="recaptcha-modal js-recaptcha-modal"
- :hide-footer="true"
- :title="__('Please solve the reCAPTCHA')"
- @toggle="close"
->
- <div slot="body">
- <p>
- {{__('We want to be sure it is you, please confirm you are not a robot.')}}
- </p>
- <div
- ref="recaptcha"
- v-html="html"
- ></div>
- </div>
-</modal>
+ <deprecated-modal
+ kind="warning"
+ class="recaptcha-modal js-recaptcha-modal"
+ :hide-footer="true"
+ :title="__('Please solve the reCAPTCHA')"
+ @cancel="close"
+ >
+ <div slot="body">
+ <p>
+ {{ __('We want to be sure it is you, please confirm you are not a robot.') }}
+ </p>
+ <div
+ ref="recaptcha"
+ v-html="html"
+ >
+ </div>
+ </div>
+ </deprecated-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
index a88e1310131..7f1eb6bcec4 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
@@ -1,6 +1,6 @@
<script>
export default {
- name: 'collapsedCalendarIcon',
+ name: 'CollapsedCalendarIcon',
props: {
containerClass: {
type: String,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
index 9ede5553bc5..dac438a702d 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
@@ -4,7 +4,11 @@
import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
export default {
- name: 'sidebarCollapsedGroupedDatePicker',
+ name: 'SidebarCollapsedGroupedDatePicker',
+ components: {
+ toggleSidebar,
+ collapsedCalendarIcon,
+ },
props: {
collapsed: {
type: Boolean,
@@ -19,10 +23,12 @@
minDate: {
type: Date,
required: false,
+ default: null,
},
maxDate: {
type: Date,
required: false,
+ default: null,
},
disableClickableIcons: {
type: Boolean,
@@ -30,10 +36,6 @@
default: false,
},
},
- components: {
- toggleSidebar,
- collapsedCalendarIcon,
- },
computed: {
hasMinAndMaxDates() {
return this.minDate && this.maxDate;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
index 9c3413377a3..3fcacd156c5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
@@ -6,8 +6,19 @@
import { dateInWords } from '../../../lib/utils/datetime_utility';
export default {
- name: 'sidebarDatePicker',
+ name: 'SidebarDatePicker',
+ components: {
+ datePicker,
+ toggleSidebar,
+ loadingIcon,
+ collapsedCalendarIcon,
+ },
props: {
+ blockClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
collapsed: {
type: Boolean,
required: false,
@@ -36,14 +47,17 @@
selectedDate: {
type: Date,
required: false,
+ default: null,
},
minDate: {
type: Date,
required: false,
+ default: null,
},
maxDate: {
type: Date,
required: false,
+ default: null,
},
},
data() {
@@ -51,12 +65,6 @@
editing: false,
};
},
- components: {
- datePicker,
- toggleSidebar,
- loadingIcon,
- collapsedCalendarIcon,
- },
computed: {
selectedAndEditable() {
return this.selectedDate && this.editable;
@@ -88,7 +96,10 @@
</script>
<template>
- <div class="block">
+ <div
+ class="block"
+ :class="blockClass"
+ >
<div class="issuable-sidebar-header">
<toggle-sidebar
:collapsed="collapsed"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
new file mode 100644
index 00000000000..5ede53d8d01
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -0,0 +1,173 @@
+<script>
+import { __ } from '~/locale';
+import LabelsSelect from '~/labels_select';
+import LoadingIcon from '../../loading_icon.vue';
+
+import DropdownTitle from './dropdown_title.vue';
+import DropdownValue from './dropdown_value.vue';
+import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
+import DropdownButton from './dropdown_button.vue';
+import DropdownHiddenInput from './dropdown_hidden_input.vue';
+import DropdownHeader from './dropdown_header.vue';
+import DropdownSearchInput from './dropdown_search_input.vue';
+import DropdownFooter from './dropdown_footer.vue';
+import DropdownCreateLabel from './dropdown_create_label.vue';
+
+export default {
+ components: {
+ LoadingIcon,
+ DropdownTitle,
+ DropdownValue,
+ DropdownValueCollapsed,
+ DropdownButton,
+ DropdownHiddenInput,
+ DropdownHeader,
+ DropdownSearchInput,
+ DropdownFooter,
+ DropdownCreateLabel,
+ },
+ props: {
+ showCreate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isProject: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ abilityName: {
+ type: String,
+ required: true,
+ },
+ context: {
+ type: Object,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsPath: {
+ type: String,
+ required: true,
+ },
+ labelsWebUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelFilterBasePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ hiddenInputName() {
+ return this.showCreate ? `${this.abilityName}[label_names][]` : 'label_id[]';
+ },
+ createLabelTitle() {
+ if (this.isProject) {
+ return __('Create project label');
+ }
+
+ return __('Create group label');
+ },
+ manageLabelsTitle() {
+ if (this.isProject) {
+ return __('Manage project labels');
+ }
+
+ return __('Manage group labels');
+ },
+ },
+ mounted() {
+ this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
+ handleClick: this.handleClick,
+ });
+ },
+ methods: {
+ handleClick(label) {
+ this.$emit('onLabelClick', label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block labels js-labels-block">
+ <dropdown-value-collapsed
+ v-if="showCreate"
+ :labels="context.labels"
+ />
+ <dropdown-title
+ :can-edit="canEdit"
+ />
+ <dropdown-value
+ :labels="context.labels"
+ :label-filter-base-path="labelFilterBasePath"
+ >
+ <slot></slot>
+ </dropdown-value>
+ <div
+ v-if="canEdit"
+ class="selectbox js-selectbox"
+ style="display: none;"
+ >
+ <dropdown-hidden-input
+ v-for="label in context.labels"
+ :key="label.id"
+ :name="hiddenInputName"
+ :label="label"
+ />
+ <div class="dropdown">
+ <dropdown-button
+ :ability-name="abilityName"
+ :field-name="hiddenInputName"
+ :update-path="updatePath"
+ :labels-path="labelsPath"
+ :namespace="namespace"
+ :labels="context.labels"
+ :show-extra-options="!showCreate"
+ />
+ <div
+ class="dropdown-menu dropdown-select dropdown-menu-paging
+dropdown-menu-labels dropdown-menu-selectable"
+ >
+ <div class="dropdown-page-one">
+ <dropdown-header v-if="showCreate" />
+ <dropdown-search-input/>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-loading">
+ <loading-icon />
+ </div>
+ <dropdown-footer
+ v-if="showCreate"
+ :labels-web-url="labelsWebUrl"
+ :create-label-title="createLabelTitle"
+ :manage-labels-title="manageLabelsTitle"
+ />
+ </div>
+ <dropdown-create-label
+ v-if="showCreate"
+ :is-project="isProject"
+ :header-title="createLabelTitle"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
new file mode 100644
index 00000000000..47497c1de98
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -0,0 +1,78 @@
+<script>
+import { __, s__, sprintf } from '~/locale';
+
+export default {
+ props: {
+ abilityName: {
+ type: String,
+ required: true,
+ },
+ fieldName: {
+ type: String,
+ required: true,
+ },
+ updatePath: {
+ type: String,
+ required: true,
+ },
+ labelsPath: {
+ type: String,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: true,
+ },
+ labels: {
+ type: Array,
+ required: true,
+ },
+ showExtraOptions: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ dropdownToggleText() {
+ if (this.labels.length === 0) {
+ return __('Label');
+ }
+
+ if (this.labels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: this.labels[0].title,
+ remainingLabelCount: this.labels.length - 1,
+ });
+ }
+
+ return this.labels[0].title;
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ type="button"
+ ref="dropdownButton"
+ class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
+ data-toggle="dropdown"
+ :class="{ 'js-extra-options': showExtraOptions }"
+ :data-ability-name="abilityName"
+ :data-field-name="fieldName"
+ :data-issue-update="updatePath"
+ :data-labels="labelsPath"
+ :data-namespace-path="namespace"
+ :data-show-any="showExtraOptions"
+ >
+ <span class="dropdown-toggle-text">
+ {{ dropdownToggleText }}
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
new file mode 100644
index 00000000000..34a07f33a23
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
@@ -0,0 +1,93 @@
+<script>
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ headerTitle: {
+ type: String,
+ required: false,
+ default: () => __('Create new label'),
+ },
+ },
+ created() {
+ this.suggestedColors = gon.suggested_label_colors;
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown-page-two dropdown-new-label">
+ <div class="dropdown-title">
+ <button
+ type="button"
+ class="dropdown-title-button dropdown-menu-back"
+ :aria-label="__('Go back')"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-arrow-left"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+ {{ headerTitle }}
+ <button
+ type="button"
+ class="dropdown-title-button dropdown-menu-close"
+ :aria-label="__('Close')"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-content">
+ <div class="dropdown-labels-error js-label-error"></div>
+ <input
+ id="new_label_name"
+ type="text"
+ class="default-dropdown-input"
+ :placeholder="__('Name new label')"
+ />
+ <div class="suggest-colors suggest-colors-dropdown">
+ <a
+ v-for="(color, index) in suggestedColors"
+ href="#"
+ :key="index"
+ :data-color="color"
+ :style="{
+ backgroundColor: color,
+ }"
+ >
+ &nbsp;
+ </a>
+ </div>
+ <div class="dropdown-label-color-input">
+ <div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div>
+ <input
+ id="new_label_color"
+ type="text"
+ class="default-dropdown-input"
+ :placeholder="__('Assign custom color like #FF0000')"
+ />
+ </div>
+ <div class="clearfix">
+ <button
+ type="button"
+ class="btn btn-primary pull-left js-new-label-btn disabled"
+ >
+ {{ __('Create') }}
+ </button>
+ <button
+ type="button"
+ class="btn btn-default pull-right js-cancel-label-btn"
+ >
+ {{ __('Cancel') }}
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
new file mode 100644
index 00000000000..5f61e9fbe80
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
@@ -0,0 +1,46 @@
+<script>
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ labelsWebUrl: {
+ type: String,
+ required: true,
+ },
+ createLabelTitle: {
+ type: String,
+ required: false,
+ default: () => __('Create new label'),
+ },
+ manageLabelsTitle: {
+ type: String,
+ required: false,
+ default: () => __('Manage labels'),
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a
+ href="#"
+ class="dropdown-toggle-page"
+ >
+ {{ createLabelTitle }}
+ </a>
+ </li>
+ <li>
+ <a
+ data-is-link="true"
+ class="dropdown-external-link"
+ :href="labelsWebUrl"
+ >
+ {{ manageLabelsTitle }}
+ </a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
new file mode 100644
index 00000000000..7664acdf19c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
@@ -0,0 +1,21 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="dropdown-title">
+ <span>{{ __('Assign labels') }}</span>
+ <button
+ type="button"
+ class="dropdown-title-button dropdown-menu-close"
+ :aria-label="__('Close')"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon"
+ data-hidden="true"
+ >
+ </i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue
new file mode 100644
index 00000000000..1832c3c1757
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue
@@ -0,0 +1,22 @@
+<script>
+export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <input
+ type="hidden"
+ :name="name"
+ :value="label.id"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
new file mode 100644
index 00000000000..ae633460c95
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
@@ -0,0 +1,27 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="dropdown-input">
+ <input
+ autocomplete="off"
+ class="dropdown-input-field"
+ type="search"
+ :placeholder="__('Search')"
+ />
+ <i
+ aria-hidden="true"
+ class="fa fa-search dropdown-input-search"
+ data-hidden="true"
+ >
+ </i>
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
+ data-hidden="true"
+ role="button"
+ >
+ </i>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
new file mode 100644
index 00000000000..7da82e90e29
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
@@ -0,0 +1,30 @@
+<script>
+export default {
+ props: {
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="title hide-collapsed append-bottom-10">
+ {{ __('Labels') }}
+ <template v-if="canEdit">
+ <i
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ data-hidden="true"
+ >
+ </i>
+ <button
+ type="button"
+ class="edit-link btn btn-blank pull-right js-sidebar-dropdown-toggle"
+ >
+ {{ __('Edit') }}
+ </button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
new file mode 100644
index 00000000000..69d588eb25d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
@@ -0,0 +1,63 @@
+<script>
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ labelFilterBasePath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isEmpty() {
+ return this.labels.length === 0;
+ },
+ },
+ methods: {
+ labelFilterUrl(label) {
+ return `${this.labelFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
+ },
+ labelStyle(label) {
+ return {
+ color: label.textColor,
+ backgroundColor: label.color,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="hide-collapsed value issuable-show-labels js-value">
+ <span
+ v-if="isEmpty"
+ class="text-secondary"
+ >
+ <slot>{{ __('None') }}</slot>
+ </span>
+ <a
+ v-else
+ v-for="label in labels"
+ :key="label.id"
+ :href="labelFilterUrl(label)"
+ >
+ <span
+ v-tooltip
+ class="label color-label"
+ data-placement="bottom"
+ data-container="body"
+ :style="labelStyle(label)"
+ :title="label.description"
+ >
+ {{ label.title }}
+ </span>
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
new file mode 100644
index 00000000000..5cf728fe050
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
@@ -0,0 +1,48 @@
+<script>
+import { s__, sprintf } from '~/locale';
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ labelsList() {
+ const labelsString = this.labels.slice(0, 5).map(label => label.title).join(', ');
+
+ if (this.labels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.labels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-tooltip
+ class="sidebar-collapsed-icon"
+ data-placement="left"
+ data-container="body"
+ :title="labelsList"
+ >
+ <i
+ aria-hidden="true"
+ data-hidden="true"
+ class="fa fa-tags"
+ >
+ </i>
+ <span>{{ labels.length }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index 5ae76adad71..8211d425b1f 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -1,6 +1,6 @@
<script>
export default {
- name: 'toggleSidebar',
+ name: 'ToggleSidebar',
props: {
collapsed: {
type: Boolean,
@@ -24,7 +24,11 @@
<i
aria-label="toggle collapse"
class="fa"
- :class="{ 'fa-angle-double-right': !collapsed, 'fa-angle-double-left': collapsed }"
- ></i>
+ :class="{
+ 'fa-angle-double-right': !collapsed,
+ 'fa-angle-double-left': collapsed
+ }"
+ >
+ </i>
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
new file mode 100644
index 00000000000..86f06c8d266
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -0,0 +1,127 @@
+<script>
+import tooltip from '~/vue_shared/directives/tooltip';
+
+export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ cssClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ successLabel: {
+ type: String,
+ required: true,
+ },
+ failureLabel: {
+ type: String,
+ required: true,
+ },
+ neutralLabel: {
+ type: String,
+ required: true,
+ },
+ successCount: {
+ type: Number,
+ required: true,
+ },
+ failureCount: {
+ type: Number,
+ required: true,
+ },
+ totalCount: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ neutralCount() {
+ return this.totalCount - this.successCount - this.failureCount;
+ },
+ successPercent() {
+ return this.getPercent(this.successCount);
+ },
+ successBarStyle() {
+ return this.barStyle(this.successPercent);
+ },
+ successTooltip() {
+ return this.getTooltip(this.successLabel, this.successCount);
+ },
+ failurePercent() {
+ return this.getPercent(this.failureCount);
+ },
+ failureBarStyle() {
+ return this.barStyle(this.failurePercent);
+ },
+ failureTooltip() {
+ return this.getTooltip(this.failureLabel, this.failureCount);
+ },
+ neutralPercent() {
+ return this.getPercent(this.neutralCount);
+ },
+ neutralBarStyle() {
+ return this.barStyle(this.neutralPercent);
+ },
+ neutralTooltip() {
+ return this.getTooltip(this.neutralLabel, this.neutralCount);
+ },
+ },
+ methods: {
+ getPercent(count) {
+ return Math.ceil((count / this.totalCount) * 100);
+ },
+ barStyle(percent) {
+ return `width: ${percent}%;`;
+ },
+ getTooltip(label, count) {
+ return `${label}: ${count}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="stacked-progress-bar"
+ :class="cssClass"
+ >
+ <span
+ v-if="!totalCount"
+ class="status-unavailable"
+ >
+ {{ __("Not available") }}
+ </span>
+ <span
+ v-tooltip
+ v-if="successPercent"
+ class="status-green"
+ data-placement="bottom"
+ :title="successTooltip"
+ :style="successBarStyle"
+ >
+ {{ successPercent }}%
+ </span>
+ <span
+ v-tooltip
+ v-if="neutralPercent"
+ class="status-neutral"
+ data-placement="bottom"
+ :title="neutralTooltip"
+ :style="neutralBarStyle"
+ >
+ {{ neutralPercent }}%
+ </span>
+ <span
+ v-tooltip
+ v-if="failurePercent"
+ class="status-red"
+ data-placement="bottom"
+ :title="failureTooltip"
+ :style="failureBarStyle"
+ >
+ {{ failurePercent }}%
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue
index 33096b53cf8..22fc5757447 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -1,132 +1,131 @@
<script>
-import { s__ } from '../../locale';
-
-const PAGINATION_UI_BUTTON_LIMIT = 4;
-const UI_LIMIT = 6;
-const SPREAD = '...';
-const PREV = s__('Pagination|Prev');
-const NEXT = s__('Pagination|Next');
-const FIRST = s__('Pagination|« First');
-const LAST = s__('Pagination|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}`);
+ import { s__ } from '../../locale';
+
+ const PAGINATION_UI_BUTTON_LIMIT = 4;
+ const UI_LIMIT = 6;
+ const SPREAD = '...';
+ const PREV = s__('Pagination|Prev');
+ const NEXT = s__('Pagination|Next');
+ const FIRST = s__('Pagination|« First');
+ const LAST = s__('Pagination|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,
},
- */
- 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;
+ /**
+ 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,
+ },
},
- 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;
+ 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;
+ },
+ showPagination() {
+ return this.pageInfo.totalPages > 1;
+ },
},
- showPagination() {
- return this.pageInfo.totalPages > 1;
+ methods: {
+ changePage(text, isDisabled) {
+ if (isDisabled) return;
+
+ 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;
+ }
+ },
},
- },
-};
+ };
</script>
<template>
<div
@@ -135,7 +134,8 @@ export default {
>
<ul class="pagination clearfix">
<li
- v-for="item in getItems"
+ v-for="(item, index) in getItems"
+ :key="index"
:class="{
page: item.page,
'js-previous-button': item.prev,
@@ -145,8 +145,11 @@ export default {
separator: item.separator,
active: item.active,
disabled: item.disabled
- }">
- <a @click.prevent="changePage($event)">{{item.title}}</a>
+ }"
+ >
+ <a @click.prevent="changePage(item.title, item.disabled)">
+ {{ item.title }}
+ </a>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 3ff7f6e2c4e..bec4e7c99b6 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -8,6 +8,12 @@ import '../../lib/utils/datetime_utility';
*/
export default {
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeagoMixin,
+ ],
props: {
time: {
type: String,
@@ -26,14 +32,6 @@ export default {
default: '',
},
},
-
- mixins: [
- timeagoMixin,
- ],
-
- directives: {
- tooltip,
- },
};
</script>
<template>
@@ -43,6 +41,6 @@ export default {
:title="tooltipTitle(time)"
:data-placement="tooltipPlacement"
data-container="body">
- {{timeFormated(time)}}
+ {{ timeFormated(time) }}
</time>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
index 4277d9281a0..09031d3ffa1 100644
--- a/app/assets/javascripts/vue_shared/components/toggle_button.vue
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -9,15 +9,26 @@
const LABEL_OFF = s__('ToggleButton|Toggle Status: OFF');
export default {
+ components: {
+ icon,
+ loadingIcon,
+ },
+
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+
props: {
name: {
type: String,
required: false,
- default: '',
+ default: null,
},
value: {
type: Boolean,
- required: true,
+ required: false,
+ default: null,
},
disabledInput: {
type: Boolean,
@@ -31,16 +42,6 @@
},
},
- components: {
- icon,
- loadingIcon,
- },
-
- model: {
- prop: 'value',
- event: 'change',
- },
-
computed: {
toggleIcon() {
return this.value ? ICON_ON : ICON_OFF;
@@ -61,6 +62,7 @@
<template>
<label class="toggle-wrapper">
<input
+ v-if="name"
type="hidden"
:name="name"
:value="value"
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index 1ac61a3c39b..cc9cc46bb4c 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -22,6 +22,9 @@ import tooltip from '../../directives/tooltip';
export default {
name: 'UserAvatarImage',
+ directives: {
+ tooltip,
+ },
props: {
lazy: {
type: Boolean,
@@ -59,9 +62,6 @@ export default {
default: 'top',
},
},
- directives: {
- tooltip,
- },
computed: {
// API response sends null when gravatar is disabled and
// we provide an empty string when we use it inside user avatar link.
@@ -87,7 +87,7 @@ export default {
v-tooltip
class="avatar"
:class="{
- lazy,
+ lazy: lazy,
[avatarSizeClass]: true,
[cssClasses]: true
}"
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
index dc32e783258..6955d164def 100644
--- 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
@@ -26,6 +26,9 @@ export default {
components: {
userAvatarImage,
},
+ directives: {
+ tooltip,
+ },
props: {
linkHref: {
type: String,
@@ -76,9 +79,6 @@ export default {
return this.shouldShowUsername ? '' : this.tooltipText;
},
},
- directives: {
- tooltip,
- },
};
</script>
@@ -98,6 +98,6 @@ export default {
v-tooltip
:title="tooltipText"
:tooltip-placement="tooltipPlacement"
- >{{username}}</span>
+ >{{ username }}</span>
</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
index d2ff2ac006e..ef3b16edf5f 100644
--- 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
@@ -39,7 +39,7 @@ export default {
:class="avatarSizeClass"
:height="size"
:width="size"
- v-html="svg">
- </svg>
+ v-html="svg"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/directives/popover.js b/app/assets/javascripts/vue_shared/directives/popover.js
index 05fa563cbd0..eb35294906b 100644
--- a/app/assets/javascripts/vue_shared/directives/popover.js
+++ b/app/assets/javascripts/vue_shared/directives/popover.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
/**
* Helper to user bootstrap popover in vue.js.
* Follow docs for html attributes: https://getbootstrap.com/docs/3.3/javascript/#static-popover
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
index dc896cf5c7d..b7f7e9fec15 100644
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ b/app/assets/javascripts/vue_shared/directives/tooltip.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+
export default {
bind(el) {
$(el).tooltip();
diff --git a/app/assets/javascripts/vue_shared/models/label.js b/app/assets/javascripts/vue_shared/models/label.js
new file mode 100644
index 00000000000..70b9efe0c68
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/models/label.js
@@ -0,0 +1,13 @@
+class ListLabel {
+ constructor(obj) {
+ this.id = obj.id;
+ this.title = obj.title;
+ this.type = obj.type;
+ this.color = obj.color;
+ this.textColor = obj.text_color;
+ this.description = obj.description;
+ this.priority = (obj.priority !== null) ? obj.priority : Infinity;
+ }
+}
+
+window.ListLabel = ListLabel;
diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js
deleted file mode 100644
index 7a865587444..00000000000
--- a/app/assets/javascripts/wikis.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import bp from './breakpoints';
-import { slugify } from './lib/utils/text_utility';
-
-export default class Wikis {
- constructor() {
- this.sidebarEl = document.querySelector('.js-wiki-sidebar');
- this.sidebarExpanded = false;
-
- 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));
- }
-
- window.addEventListener('resize', () => this.renderSidebar());
- this.renderSidebar();
- }
-
- handleNewWikiSubmit(e) {
- if (!this.newWikiForm) return;
-
- const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
- const slug = slugify(slugInput.value);
-
- if (slug.length > 0) {
- const wikisPath = slugInput.getAttribute('data-wikis-path');
- window.location.href = `${wikisPath}/${slug}`;
- e.preventDefault();
- }
- }
-
- handleToggleSidebar(e) {
- e.preventDefault();
- this.sidebarExpanded = !this.sidebarExpanded;
- this.renderSidebar();
- }
-
- 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');
- }
- }
-}
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 06a86f3b94a..f68a4f28714 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,16 +1,18 @@
/* 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
//
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
+import $ from 'jquery';
import 'vendor/jquery.scrollTo';
import Dropzone from 'dropzone';
-import 'mousetrap';
+import Mousetrap from 'mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
+Dropzone.autoDiscover = false;
+
//
// ### Events
//
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 43b16d3cf7d..2fccfa4011c 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -21,7 +21,7 @@
@import "framework/flash";
@import "framework/forms";
@import "framework/gfm";
-@import "framework/gitlab-theme";
+@import "framework/gitlab_theme";
@import "framework/header";
@import "framework/highlight";
@import "framework/issue_box";
@@ -35,10 +35,10 @@
@import "framework/pagination";
@import "framework/panels";
@import "framework/popup";
-@import "framework/secondary-navigation-elements";
+@import "framework/secondary_navigation_elements";
@import "framework/selects";
@import "framework/sidebar";
-@import "framework/contextual-sidebar";
+@import "framework/contextual_sidebar";
@import "framework/tables";
@import "framework/notes";
@import "framework/tabs";
@@ -49,13 +49,16 @@
@import "framework/zen";
@import "framework/blank";
@import "framework/wells";
-@import "framework/page-header";
+@import "framework/page_header";
@import "framework/awards";
@import "framework/images";
-@import "framework/broadcast-messages";
+@import "framework/broadcast_messages";
@import "framework/emojis";
-@import "framework/emoji-sprites";
+@import "framework/emoji_sprites";
@import "framework/icons";
@import "framework/snippets";
@import "framework/memory_graph";
@import "framework/responsive_tables";
+@import "framework/stacked_progress_bar";
+@import "framework/ci_variable_list";
+@import "framework/feature_highlight";
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index 26db2386879..077d0424093 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -71,7 +71,7 @@
vertical-align: top;
&.s16 { font-size: 12px; line-height: 1.33; }
- &.s24 { font-size: 14px; line-height: 1.8; }
+ &.s24 { font-size: 13px; line-height: 1.8; }
&.s26 { font-size: 20px; line-height: 1.33; }
&.s32 { font-size: 20px; line-height: 30px; }
&.s40 { font-size: 16px; line-height: 38px; }
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index e0d2ed80de5..a538b5a2946 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -174,12 +174,13 @@
&.user-authored {
cursor: default;
- opacity: 0.65;
+ background-color: $gray-light;
+ border-color: $theme-gray-200;
+ color: $gl-text-color-disabled;
- &:hover,
- &:active {
- background-color: $white-light;
- border-color: $border-color;
+ gl-emoji {
+ opacity: 0.4;
+ filter: grayscale(100%);
}
}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 91976ca1f56..c5c7afe25be 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -10,7 +10,6 @@
color: $gl-text-color;
font-weight: $gl-font-weight-normal;
font-size: 14px;
- line-height: 36px;
&.diff-collapsed {
padding: 5px;
diff --git a/app/assets/stylesheets/framework/broadcast-messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index 9b54fb94cdc..9b54fb94cdc 100644
--- a/app/assets/stylesheets/framework/broadcast-messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index fcc420923f9..6b89387ab5f 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -176,6 +176,11 @@
&.btn-remove {
@include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
}
+
+ &.btn-primary,
+ &.btn-info {
+ @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
+ }
}
&.btn-gray {
@@ -220,14 +225,6 @@
@include btn-with-margin;
}
- &.disabled {
- pointer-events: auto !important;
- }
-
- &[disabled] {
- pointer-events: none !important;
- }
-
.fa-caret-down,
.fa-chevron-down {
margin-left: 5px;
@@ -447,6 +444,44 @@
}
}
+.btn-missing {
+ color: $notes-light-color;
+ border: 1px dashed $border-gray-normal-dashed;
+ border-radius: $border-radius-default;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $notes-light-color;
+ background-color: $white-normal;
+ }
+}
+
.btn-svg svg {
@include btn-svg;
}
+
+// All disabled buttons, regardless of color, type, etc
+%disabled {
+ background-color: $gray-light !important;
+ border-color: $theme-gray-200 !important;
+ color: $gl-text-color-disabled !important;
+ opacity: 1 !important;
+ cursor: default !important;
+
+ i {
+ color: $gl-text-color-disabled !important;
+ }
+}
+
+.btn.disabled,
+.btn[disabled],
+fieldset[disabled] .btn,
+.dropdown-toggle[disabled],
+[disabled].dropdown-menu-toggle {
+ @extend %disabled;
+
+ &:hover {
+ @extend %disabled;
+ }
+}
diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss
new file mode 100644
index 00000000000..5fe835dd8f9
--- /dev/null
+++ b/app/assets/stylesheets/framework/ci_variable_list.scss
@@ -0,0 +1,99 @@
+.ci-variable-list {
+ margin-left: 0;
+ margin-bottom: 0;
+ padding-left: 0;
+ list-style: none;
+ clear: both;
+}
+
+.ci-variable-row {
+ display: flex;
+ align-items: flex-start;
+
+ @media (max-width: $screen-xs-max) {
+ align-items: flex-end;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: $gl-btn-padding;
+
+ @media (max-width: $screen-xs-max) {
+ margin-bottom: 3 * $gl-btn-padding;
+ }
+ }
+
+ &:last-child {
+ .ci-variable-body-item:last-child {
+ margin-right: $ci-variable-remove-button-width;
+
+ @media (max-width: $screen-xs-max) {
+ margin-right: 0;
+ }
+ }
+
+ .ci-variable-row-remove-button {
+ display: none;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .ci-variable-row-body {
+ margin-right: $ci-variable-remove-button-width;
+ }
+ }
+ }
+}
+
+.ci-variable-row-body {
+ display: flex;
+ align-items: flex-start;
+ width: 100%;
+
+ @media (max-width: $screen-xs-max) {
+ display: block;
+ }
+}
+
+.ci-variable-body-item {
+ flex: 1;
+
+ &:not(:last-child) {
+ margin-right: $gl-btn-padding;
+
+ @media (max-width: $screen-xs-max) {
+ margin-right: 0;
+ margin-bottom: $gl-btn-padding;
+ }
+ }
+}
+
+.ci-variable-protected-item {
+ flex: 0 1 auto;
+ display: flex;
+ align-items: center;
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+
+.ci-variable-row-remove-button {
+ @include transition(color);
+ flex-shrink: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: $ci-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;
+ }
+
+ &[disabled] {
+ color: $gl-text-color-disabled;
+ }
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 73524d5cf60..d0dda50a835 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -14,6 +14,10 @@
color: $gl-text-color-secondary;
}
+.text-tertiary {
+ color: $gl-text-color-tertiary;
+}
+
.text-primary,
.text-primary:hover {
color: $brand-primary;
@@ -442,6 +446,10 @@ img.emoji {
opacity: .5;
}
+.break-word {
+ word-wrap: break-word;
+}
+
/** COMMON CLASSES **/
.prepend-top-0 { margin-top: 0; }
.prepend-top-5 { margin-top: 5px; }
@@ -449,9 +457,11 @@ img.emoji {
.prepend-top-10 { margin-top: 10px; }
.prepend-top-15 { margin-top: 15px; }
.prepend-top-default { margin-top: $gl-padding !important; }
+.prepend-top-16 { margin-top: 16px; }
.prepend-top-20 { margin-top: 20px; }
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
+.prepend-left-8 { margin-left: 8px; }
.prepend-left-10 { margin-left: 10px; }
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss
deleted file mode 100644
index 5da06b90113..00000000000
--- a/app/assets/stylesheets/framework/contextual-sidebar.scss
+++ /dev/null
@@ -1,474 +0,0 @@
-.page-with-contextual-sidebar {
- transition: padding-left $sidebar-transition-duration;
-
- @media (min-width: $screen-md-min) {
- padding-left: $contextual-sidebar-collapsed-width;
- }
-
- @media (min-width: $screen-lg-min) {
- padding-left: $contextual-sidebar-width;
- }
-
- .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: $contextual-sidebar-collapsed-width;
- }
-}
-
-.context-header {
- position: relative;
- margin-right: 2px;
-
- a {
- transition: padding $sidebar-transition-duration;
- font-weight: $gl-font-weight-bold;
- display: flex;
- align-items: center;
- padding: 10px 16px 10px 10px;
- color: $gl-text-color;
- }
-
- &:hover,
- a:hover {
- background-color: $link-hover-background;
- color: $gl-text-color;
- }
-
- .avatar-container {
- flex: 0 0 40px;
- background-color: $white-light;
- }
-
- .sidebar-context-title {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-}
-
-.settings-avatar {
- background-color: $white-light;
-
- svg {
- fill: $gl-text-color-secondary;
- margin: auto;
- }
-}
-
-.nav-sidebar {
- transition: width $sidebar-transition-duration, left $sidebar-transition-duration;
- position: fixed;
- z-index: 400;
- width: $contextual-sidebar-width;
- top: $header-height;
- bottom: 0;
- left: 0;
- background-color: $gray-light;
- box-shadow: inset -2px 0 0 $border-color;
- transform: translate3d(0, 0, 0);
-
- &:not(.sidebar-collapsed-desktop) {
- @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) {
- box-shadow: inset -2px 0 0 $border-color,
- 2px 1px 3px $dropdown-shadow-color;
- }
- }
-
- &.sidebar-collapsed-desktop {
- width: $contextual-sidebar-collapsed-width;
-
- .nav-sidebar-inner-scroll {
- overflow-x: hidden;
- }
-
- .badge:not(.fly-out-badge),
- .sidebar-context-title,
- .nav-item-name {
- display: none;
- }
-
- .sidebar-top-level-items > li > a {
- min-height: 44px;
- }
-
- .fly-out-top-item {
- display: block;
- }
-
- .avatar-container {
- margin-right: 0;
- }
- }
-
- &.sidebar-expanded-mobile {
- left: 0;
- }
-
- a {
- text-decoration: none;
- }
-
- ul {
- padding-left: 0;
- list-style: none;
- }
-
- li {
- white-space: nowrap;
-
- a {
- transition: padding $sidebar-transition-duration;
- display: flex;
- align-items: center;
- padding: 12px 15px;
- color: $gl-text-color-secondary;
- }
-
- .nav-item-name {
- flex: 1;
- }
-
- &.active {
- > a {
- font-weight: $gl-font-weight-bold;
- }
- }
- }
-
- @media (max-width: $screen-xs-max) {
- left: (-$contextual-sidebar-width);
- }
-
- .nav-icon-container {
- display: flex;
- margin-right: 8px;
- }
-
- .fly-out-top-item {
- display: none;
- }
-
- svg {
- height: 16px;
- width: 16px;
- }
-}
-
-.nav-sidebar-inner-scroll {
- height: 100%;
- width: 100%;
- overflow: auto;
-
- @media (min-width: $screen-sm-min) {
- overflow: hidden;
- }
-}
-
-.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: $link-active-background;
- color: $gl-text-color;
- }
- }
-
- &.active {
- a {
- &,
- &:hover,
- &:focus {
- background: $link-active-background;
- }
- }
- }
- }
-}
-
-.sidebar-top-level-items {
- margin-bottom: 60px;
-
- > li {
- > a {
- @media (min-width: $screen-sm-min) {
- margin-right: 2px;
- }
-
- &:hover {
- color: $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: 0;
- min-width: 150px;
- margin-top: -1px;
- padding: 4px 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;
- }
-
- .divider {
- height: 1px;
- margin: 4px -1px;
- padding: 0;
- background-color: $dropdown-divider-color;
- }
-
- > .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: $gl-text-color-secondary;
- }
-
- &.active {
- background: $link-active-background;
-
- > a {
- margin-left: 4px;
- // Subtract width of left border on active element
- padding-left: 11px;
- }
-
- .badge {
- font-weight: $gl-font-weight-bold;
- }
-
- .sidebar-sub-level-items:not(.is-fly-out-only) {
- display: block;
- }
- }
-
- &.active > a:hover,
- &.is-over > a {
- background-color: $link-hover-background;
- }
- }
-}
-
-
-// Collapsed nav
-
-.toggle-sidebar-button,
-.close-nav-button {
- width: $contextual-sidebar-width - 2px;
- transition: width $sidebar-transition-duration;
- position: fixed;
- bottom: 0;
- padding: $gl-padding;
- background-color: $gray-light;
- border: 0;
- border-top: 2px solid $border-color;
- color: $gl-text-color-secondary;
- display: flex;
- align-items: center;
- line-height: 1;
-
- svg {
- margin-right: 8px;
- }
-
- .icon-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;
- }
-}
-
-.collapse-text {
- white-space: nowrap;
- overflow: hidden;
-}
-
-.sidebar-collapsed-desktop {
- .context-header {
- height: 60px;
- width: $contextual-sidebar-collapsed-width;
-
- a {
- padding: 10px 4px;
- }
- }
-
- .sidebar-top-level-items > li {
- &.active a {
- padding-left: 12px;
- }
-
- .sidebar-sub-level-items {
- &:not(.flyout-list) {
- display: none;
- }
- }
- }
-
- .nav-icon-container {
- margin-right: 0;
- }
-
- .toggle-sidebar-button {
- padding: 16px;
- width: $contextual-sidebar-collapsed-width - 2px;
-
- .collapse-text,
- .icon-angle-double-left {
- display: none;
- }
-
- .icon-angle-double-right {
- display: block;
- margin: 0;
- }
- }
-}
-
-.fly-out-top-item {
- > a {
- display: flex;
- }
-
- .fly-out-badge {
- margin-left: 8px;
- }
-}
-
-.fly-out-top-item-name {
- flex: 1;
-}
-
-// Mobile nav
-
-.close-nav-button {
- display: none;
-}
-
-.toggle-mobile-nav {
- display: none;
- background-color: transparent;
- border: 0;
- padding: 6px 16px;
- margin: 0 0 0 -15px;
- height: 46px;
-
- i {
- font-size: 20px;
- color: $gl-text-color-secondary;
- }
-
- @media (max-width: $screen-xs-max) {
- display: flex;
- align-items: center;
-
- i {
- font-size: 18px;
- }
- }
-
- @media (max-width: $screen-xs-max) {
- + .breadcrumbs-links {
- padding-left: $gl-padding;
- border-left: 1px solid $gl-text-color-quaternary;
- }
- }
-}
-
-@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: calc(100vh - 180px);
- }
-}
-
-.with-performance-bar .boards-list {
- height: calc(100vh - #{$header-height} - #{$performance-bar-height});
-}
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
new file mode 100644
index 00000000000..e2d97d0298f
--- /dev/null
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -0,0 +1,457 @@
+.page-with-contextual-sidebar {
+ transition: padding-left $sidebar-transition-duration;
+
+ @media (min-width: $screen-md-min) {
+ padding-left: $contextual-sidebar-collapsed-width;
+ }
+
+ @media (min-width: $screen-lg-min) {
+ padding-left: $contextual-sidebar-width;
+ }
+
+ .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: $contextual-sidebar-collapsed-width;
+ }
+}
+
+.context-header {
+ position: relative;
+ margin-right: 2px;
+ width: $contextual-sidebar-width;
+
+ a {
+ transition: padding $sidebar-transition-duration;
+ font-weight: $gl-font-weight-bold;
+ display: flex;
+ align-items: center;
+ padding: 10px 16px 10px 10px;
+ color: $gl-text-color;
+ }
+
+ &:hover,
+ a:hover {
+ background-color: $link-hover-background;
+ color: $gl-text-color;
+ }
+
+ .avatar-container {
+ flex: 0 0 40px;
+ background-color: $white-light;
+ }
+
+ .sidebar-context-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.settings-avatar {
+ background-color: $white-light;
+
+ svg {
+ fill: $gl-text-color-secondary;
+ margin: auto;
+ }
+}
+
+.nav-sidebar {
+ transition: width $sidebar-transition-duration,
+ left $sidebar-transition-duration;
+ position: fixed;
+ z-index: 400;
+ width: $contextual-sidebar-width;
+ top: $header-height;
+ bottom: 0;
+ left: 0;
+ background-color: $gray-light;
+ box-shadow: inset -2px 0 0 $border-color;
+ transform: translate3d(0, 0, 0);
+
+ &:not(.sidebar-collapsed-desktop) {
+ @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) {
+ box-shadow: inset -2px 0 0 $border-color,
+ 2px 1px 3px $dropdown-shadow-color;
+ }
+ }
+
+ &.sidebar-collapsed-desktop {
+ width: $contextual-sidebar-collapsed-width;
+
+ .nav-sidebar-inner-scroll {
+ overflow-x: hidden;
+ }
+
+ .badge:not(.fly-out-badge),
+ .sidebar-context-title,
+ .nav-item-name {
+ display: none;
+ }
+
+ .sidebar-top-level-items > li > a {
+ min-height: 44px;
+ }
+
+ .fly-out-top-item {
+ display: block;
+ }
+
+ .avatar-container {
+ margin-right: 0;
+ }
+ }
+
+ &.sidebar-expanded-mobile {
+ left: 0;
+ }
+
+ a {
+ text-decoration: none;
+ }
+
+ ul {
+ padding-left: 0;
+ list-style: none;
+ }
+
+ li {
+ white-space: nowrap;
+
+ a {
+ transition: padding $sidebar-transition-duration;
+ display: flex;
+ align-items: center;
+ padding: 12px 15px;
+ color: $gl-text-color-secondary;
+ }
+
+ .nav-item-name {
+ flex: 1;
+ }
+
+ &.active {
+ > a {
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ left: (-$contextual-sidebar-width);
+ }
+
+ .nav-icon-container {
+ display: flex;
+ margin-right: 8px;
+ }
+
+ .fly-out-top-item {
+ display: none;
+ }
+
+ svg {
+ height: 16px;
+ width: 16px;
+ }
+}
+
+.nav-sidebar-inner-scroll {
+ height: 100%;
+ width: 100%;
+ overflow: auto;
+
+ @media (min-width: $screen-sm-min) {
+ overflow: hidden;
+ }
+}
+
+.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: $link-active-background;
+ color: $gl-text-color;
+ }
+ }
+
+ &.active {
+ a {
+ &,
+ &:hover,
+ &:focus {
+ background: $link-active-background;
+ }
+ }
+ }
+ }
+}
+
+.sidebar-top-level-items {
+ margin-bottom: 60px;
+
+ > li {
+ > a {
+ @media (min-width: $screen-sm-min) {
+ margin-right: 2px;
+ }
+
+ &:hover {
+ color: $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: 0;
+ min-width: 150px;
+ margin-top: -1px;
+ padding: 4px 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;
+ }
+
+ .divider {
+ height: 1px;
+ margin: 4px -1px;
+ padding: 0;
+ background-color: $dropdown-divider-color;
+ }
+
+ > .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: $gl-text-color-secondary;
+ }
+
+ &.active {
+ background: $link-active-background;
+
+ > a {
+ margin-left: 4px;
+ // Subtract width of left border on active element
+ padding-left: 11px;
+ }
+
+ .badge {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .sidebar-sub-level-items:not(.is-fly-out-only) {
+ display: block;
+ }
+ }
+
+ &.active > a:hover,
+ &.is-over > a {
+ background-color: $link-hover-background;
+ }
+ }
+}
+
+// Collapsed nav
+
+.toggle-sidebar-button,
+.close-nav-button {
+ width: $contextual-sidebar-width - 2px;
+ transition: width $sidebar-transition-duration;
+ position: fixed;
+ bottom: 0;
+ padding: $gl-padding;
+ background-color: $gray-light;
+ border: 0;
+ border-top: 2px solid $border-color;
+ color: $gl-text-color-secondary;
+ display: flex;
+ align-items: center;
+ line-height: 1;
+
+ svg {
+ margin-right: 8px;
+ }
+
+ .icon-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;
+ }
+}
+
+.collapse-text {
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.sidebar-collapsed-desktop {
+ .context-header {
+ height: 60px;
+ width: $contextual-sidebar-collapsed-width;
+
+ a {
+ padding: 10px 4px;
+ }
+ }
+
+ .sidebar-top-level-items > li {
+ .sidebar-sub-level-items {
+ &:not(.flyout-list) {
+ display: none;
+ }
+ }
+ }
+
+ .nav-icon-container {
+ margin-right: 0;
+ }
+
+ .toggle-sidebar-button {
+ padding: 16px;
+ width: $contextual-sidebar-collapsed-width - 2px;
+
+ .collapse-text,
+ .icon-angle-double-left {
+ display: none;
+ }
+
+ .icon-angle-double-right {
+ display: block;
+ margin: 0;
+ }
+ }
+}
+
+.fly-out-top-item {
+ > a {
+ display: flex;
+ }
+
+ .fly-out-badge {
+ margin-left: 8px;
+ }
+}
+
+.fly-out-top-item-name {
+ flex: 1;
+}
+
+// Mobile nav
+
+.close-nav-button {
+ display: none;
+}
+
+.toggle-mobile-nav {
+ display: none;
+ background-color: transparent;
+ border: 0;
+ padding: 6px 16px;
+ margin: 0 0 0 -15px;
+ height: 46px;
+
+ i {
+ font-size: 20px;
+ color: $gl-text-color-secondary;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: flex;
+ align-items: center;
+
+ i {
+ font-size: 18px;
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ + .breadcrumbs-links {
+ padding-left: $gl-padding;
+ border-left: 1px solid $gl-text-color-quaternary;
+ }
+ }
+}
+
+@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;
+ }
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index bc907a390d8..cc74cb72795 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -28,7 +28,9 @@
.dropdown-menu,
.dropdown-menu-nav {
@include set-visible;
- min-height: 40px;
+ min-height: $dropdown-min-height;
+ max-height: $dropdown-max-height;
+ overflow-y: auto;
@media (max-width: $screen-xs-max) {
width: 100%;
@@ -61,11 +63,6 @@
border-radius: $border-radius-base;
white-space: nowrap;
- &[disabled] {
- opacity: .65;
- cursor: not-allowed;
- }
-
&.no-outline {
outline: 0;
}
@@ -275,7 +272,7 @@
.divider {
height: 1px;
- margin: 6px 0;
+ margin: #{$grid-size / 2} 0;
padding: 0;
background-color: $dropdown-divider-color;
@@ -504,10 +501,8 @@
-moz-osx-font-smoothing: grayscale;
}
- &.dropdown-menu-user-link {
- &::before {
- top: 50%;
- }
+ &.dropdown-menu-user-link::before {
+ top: 50%;
}
}
@@ -627,7 +622,7 @@
}
.dropdown-content {
- max-height: $dropdown-max-height;
+ max-height: 252px;
overflow-y: auto;
}
@@ -664,6 +659,16 @@
}
}
+.dropdown-create-new-item-button {
+ @include dropdown-link;
+
+ width: 100%;
+ background-color: transparent;
+ border: 0;
+ text-align: left;
+ text-overflow: ellipsis;
+}
+
.dropdown-loading {
position: absolute;
top: 0;
@@ -694,6 +699,31 @@
border-radius: $border-radius-base;
}
+.git-revision-dropdown {
+ .dropdown-content {
+ max-height: 215px;
+ }
+}
+
+.sidebar-move-issue-dropdown {
+ .dropdown-content {
+ max-height: 160px;
+ }
+}
+
+.dropdown-menu-author {
+ .dropdown-content {
+ max-height: 215px;
+ }
+}
+
+.dropdown-menu-labels {
+ .dropdown-content {
+ max-height: 128px;
+ }
+}
+
+
.dropdown-menu-due-date {
.dropdown-content {
max-height: 230px;
@@ -729,10 +759,6 @@
}
}
-.droplab-item-ignore {
- pointer-events: none;
-}
-
.pika-single.animate-picker.is-bound,
.pika-single.animate-picker.is-bound.is-hidden {
/*
diff --git a/app/assets/stylesheets/framework/emoji-sprites.scss b/app/assets/stylesheets/framework/emoji_sprites.scss
index 0174e17b660..0174e17b660 100644
--- a/app/assets/stylesheets/framework/emoji-sprites.scss
+++ b/app/assets/stylesheets/framework/emoji_sprites.scss
diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss
new file mode 100644
index 00000000000..4f26cd015e4
--- /dev/null
+++ b/app/assets/stylesheets/framework/feature_highlight.scss
@@ -0,0 +1,103 @@
+.feature-highlight {
+ position: relative;
+ margin-left: $gl-padding;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ width: 8px;
+ height: 8px;
+ background-color: $blue-500;
+ border-radius: 50%;
+ box-shadow: 0 0 0 rgba($blue-500, 0.4);
+ animation: pulse-highlight 2s infinite;
+ }
+
+ &:hover::before,
+ &.disable-animation::before {
+ animation: none;
+ }
+
+ &[disabled]::before {
+ display: none;
+ }
+}
+
+.is-showing-fly-out {
+ .feature-highlight {
+ display: none;
+ }
+}
+
+.feature-highlight-popover-content {
+ display: none;
+
+ hr {
+ margin: $gl-padding * 0.5 0;
+ }
+
+ .btn-link {
+ svg {
+ @include btn-svg;
+
+ path {
+ fill: currentColor;
+ }
+ }
+ }
+
+ .feature-highlight-illustration {
+ width: 100%;
+ height: 100px;
+ padding-top: 12px;
+ padding-bottom: 12px;
+
+ background-color: $indigo-50;
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ border-bottom: 1px solid darken($gray-normal, 8%);
+ }
+}
+
+.popover .feature-highlight-popover-content {
+ display: block;
+}
+
+.feature-highlight-popover {
+ width: 240px;
+ padding: 0;
+ border: 1px solid $dropdown-border-color;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ &.right > .arrow {
+ border-right-color: $dropdown-border-color;
+ }
+
+ .popover-content {
+ padding: 0;
+ }
+}
+
+.feature-highlight-popover-sub-content {
+ padding: 9px 14px;
+}
+
+@include keyframes(pulse-highlight) {
+ 0% {
+ box-shadow: 0 0 0 0 rgba($blue-200, 0.4);
+ }
+
+ 70% {
+ box-shadow: 0 0 0 10px transparent;
+ }
+
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 1588036aeae..d835d49d8b2 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -18,14 +18,9 @@
margin: $gl-padding 0;
&.limited-width-container .file-content {
- max-width: $limited-layout-width-sm;
+ max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
-
- @media (min-width: $screen-md-min) {
- padding-top: 64px;
- padding-bottom: 64px;
- }
}
}
@@ -128,7 +123,11 @@
}
&.wiki {
- padding: 30px $gl-padding;
+ padding: $gl-padding;
+
+ @media (min-width: $screen-md-min) {
+ padding: $gl-padding * 2;
+ }
}
&.blob-no-preview {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 2d7465401f1..621a4adc0cb 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -260,7 +260,7 @@
}
.filtered-search-input-dropdown-menu {
- max-height: 260px;
+ max-height: $dropdown-max-height;
max-width: 280px;
overflow: auto;
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index 88ce119ee3a..cb2f71b0033 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -12,6 +12,12 @@
margin: 0;
}
+ .flash-warning {
+ @extend .alert;
+ @extend .alert-warning;
+ margin: 0;
+ }
+
.flash-alert {
@extend .alert;
@extend .alert-danger;
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index be96c8ee964..2c30311b1c1 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -167,7 +167,7 @@ label {
.input-group {
.select2-container {
display: table-cell;
- width: 200px !important;
+ max-width: 180px;
}
.input-group-addon {
@@ -182,6 +182,7 @@ label {
.help-block {
margin-bottom: 0;
+ margin-top: #{$grid-size / 2};
}
.gl-field-error {
diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss
index 5621505996d..e378e84ca1b 100644
--- a/app/assets/stylesheets/framework/gfm.scss
+++ b/app/assets/stylesheets/framework/gfm.scss
@@ -16,3 +16,31 @@
background-color: $user-mention-bg-hover;
}
}
+
+.gfm-color_chip {
+ display: inline-block;
+ margin: 0 0 2px 4px;
+ vertical-align: middle;
+ border-radius: 3px;
+
+ $chip-size: 0.9em;
+ $bg-size: $chip-size / 0.9;
+ $bg-pos: $bg-size / 2;
+
+ width: $chip-size;
+ height: $chip-size;
+ background: $white-light;
+ background-image: linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%),
+ linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%);
+ background-size: $bg-size $bg-size;
+ background-position: 0 0, $bg-pos $bg-pos;
+
+ > span {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ margin-bottom: 2px;
+ border-radius: 3px;
+ border: 1px solid $black-transparent;
+ }
+}
diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss
deleted file mode 100644
index db36e27fa74..00000000000
--- a/app/assets/stylesheets/framework/gitlab-theme.scss
+++ /dev/null
@@ -1,274 +0,0 @@
-/**
- * Styles the GitLab application with a specific color theme
- */
-
-@mixin gitlab-theme($color-100, $color-200, $color-500, $color-700, $color-800, $color-900, $color-alternate) {
- // Header
-
- .navbar-gitlab {
- background-color: $color-900;
-
- .navbar-collapse {
- color: $color-200;
- }
-
- .container-fluid {
- .navbar-toggle {
- border-left: 1px solid lighten($color-700, 10%);
- }
- }
-
- .navbar-sub-nav,
- .navbar-nav {
- > li {
- > a:hover,
- > a:focus {
- background-color: rgba($color-200, .2);
- }
-
- &.active > a,
- &.dropdown.open > a {
- color: $color-900;
- background-color: $color-alternate;
- }
-
- &.line-separator {
- border-left: 1px solid rgba($color-200, .2);
- }
- }
- }
-
- .navbar-sub-nav {
- color: $color-200;
- }
-
- .nav {
- > li {
- color: $color-200;
-
- > a {
- &.header-user-dropdown-toggle {
- .header-user-avatar {
- border-color: $color-200;
- }
- }
-
- &:hover,
- &:focus {
- @media (min-width: $screen-sm-min) {
- background-color: rgba($color-200, .2);
- }
-
- svg {
- fill: currentColor;
- }
- }
- }
-
- &.active > a,
- &.dropdown.open > a {
- color: $color-900;
- background-color: $color-alternate;
-
- &:hover {
- svg {
- fill: $color-900;
- }
- }
- }
-
- .impersonated-user,
- .impersonated-user:hover {
- svg {
- fill: $color-900;
- }
- }
- }
- }
- }
-
- .navbar .title {
- > a {
- &:hover,
- &:focus {
- background-color: rgba($color-200, .2);
- }
- }
- }
-
- .search {
- form {
- background-color: rgba($color-200, .2);
-
- &:hover {
- background-color: rgba($color-200, .3);
- }
- }
-
- .location-badge {
- color: $color-100;
- background-color: rgba($color-200, .1);
- border-right: 1px solid $color-800;
- }
-
- .search-input::placeholder {
- color: rgba($color-200, .8);
- }
-
- .search-input-wrap {
- .search-icon,
- .clear-icon {
- fill: rgba($color-200, .8);
- }
- }
-
- &.search-active {
- form {
- background-color: $white-light;
- }
-
- .location-badge {
- color: $gl-text-color;
- }
-
- .search-input-wrap {
- .search-icon {
- fill: rgba($color-200, .8);
- }
- }
- }
- }
-
- .btn-sign-in {
- background-color: $color-100;
- color: $color-900;
- }
-
-
- // Sidebar
- .nav-sidebar li.active {
- box-shadow: inset 4px 0 0 $color-700;
-
- > a {
- color: $color-800;
- }
-
- svg {
- fill: $color-800;
- }
- }
-
- .sidebar-top-level-items > li.active .badge {
- color: $color-800;
- }
-
- .nav-links li.active a {
- border-bottom-color: $color-500;
-
- .badge {
- font-weight: $gl-font-weight-bold;
- }
- }
-}
-
-
-body {
- &.ui_indigo {
- @include gitlab-theme($indigo-100, $indigo-200, $indigo-500, $indigo-700, $indigo-800, $indigo-900, $white-light);
- }
-
- &.ui_dark {
- @include gitlab-theme($theme-gray-100, $theme-gray-200, $theme-gray-500, $theme-gray-700, $theme-gray-800, $theme-gray-900, $white-light);
- }
-
- &.ui_blue {
- @include gitlab-theme($theme-blue-100, $theme-blue-200, $theme-blue-500, $theme-blue-700, $theme-blue-800, $theme-blue-900, $white-light);
- }
-
- &.ui_green {
- @include gitlab-theme($theme-green-100, $theme-green-200, $theme-green-500, $theme-green-700, $theme-green-800, $theme-green-900, $white-light);
- }
-
- &.ui_light {
- @include gitlab-theme($theme-gray-900, $theme-gray-700, $theme-gray-800, $theme-gray-700, $theme-gray-700, $theme-gray-100, $theme-gray-700);
-
- .navbar-gitlab {
- background-color: $theme-gray-100;
- box-shadow: 0 1px 0 0 $border-color;
-
- .logo-text svg {
- fill: $theme-gray-900;
- }
-
- .navbar-sub-nav,
- .navbar-nav {
- > li {
- > a:hover,
- > a:focus {
- color: $theme-gray-900;
- }
-
- &.active > a,
- &.active > a:hover {
- color: $white-light;
- }
- }
- }
-
- .container-fluid {
- .navbar-toggle,
- .navbar-toggle:hover {
- color: $theme-gray-700;
- border-left: 1px solid $theme-gray-200;
- }
- }
- }
-
- .search {
- form {
- background-color: $white-light;
- box-shadow: inset 0 0 0 1px $border-color;
-
- &:hover {
- background-color: $white-light;
- box-shadow: inset 0 0 0 1px $blue-200;
-
- .location-badge {
- box-shadow: inset 0 0 0 1px $blue-200;
- }
- }
- }
-
- .search-input-wrap {
- .search-icon {
- fill: $theme-gray-200;
- }
-
- .search-input {
- color: $gl-text-color;
- }
- }
-
- .location-badge {
- color: $theme-gray-700;
- box-shadow: inset 0 0 0 1px $border-color;
- background-color: $nav-badge-bg;
- border-right: 0;
- }
- }
-
- .nav-sidebar li.active {
- > a {
- color: $theme-gray-900;
- }
-
- svg {
- fill: $theme-gray-900;
- }
- }
-
- .sidebar-top-level-items > li.active .badge {
- color: $theme-gray-900;
- }
- }
-}
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
new file mode 100644
index 00000000000..05cb0196ced
--- /dev/null
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -0,0 +1,351 @@
+/**
+ * Styles the GitLab application with a specific color theme
+ */
+
+@mixin gitlab-theme(
+ $color-100,
+ $color-200,
+ $color-500,
+ $color-700,
+ $color-800,
+ $color-900,
+ $color-alternate
+) {
+ // Header
+
+ .navbar-gitlab {
+ background-color: $color-900;
+
+ .navbar-collapse {
+ color: $color-200;
+ }
+
+ .container-fluid {
+ .navbar-toggle {
+ border-left: 1px solid lighten($color-700, 10%);
+ }
+ }
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ > li {
+ > a:hover,
+ > a:focus {
+ background-color: rgba($color-200, 0.2);
+ }
+
+ &.active > a,
+ &.dropdown.open > a {
+ color: $color-900;
+ background-color: $color-alternate;
+ }
+
+ &.line-separator {
+ border-left: 1px solid rgba($color-200, 0.2);
+ }
+ }
+ }
+
+ .navbar-sub-nav {
+ color: $color-200;
+ }
+
+ .nav {
+ > li {
+ color: $color-200;
+
+ > a {
+ &.header-user-dropdown-toggle {
+ .header-user-avatar {
+ border-color: $color-200;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ @media (min-width: $screen-sm-min) {
+ background-color: rgba($color-200, 0.2);
+ }
+
+ svg {
+ fill: currentColor;
+ }
+ }
+ }
+
+ &.active > a,
+ &.dropdown.open > a {
+ color: $color-900;
+ background-color: $color-alternate;
+
+ &:hover {
+ svg {
+ fill: $color-900;
+ }
+ }
+ }
+
+ .impersonated-user,
+ .impersonated-user:hover {
+ svg {
+ fill: $color-900;
+ }
+ }
+ }
+ }
+ }
+
+ .navbar .title {
+ > a {
+ &:hover,
+ &:focus {
+ background-color: rgba($color-200, 0.2);
+ }
+ }
+ }
+
+ .search {
+ form {
+ background-color: rgba($color-200, 0.2);
+
+ &:hover {
+ background-color: rgba($color-200, 0.3);
+ }
+ }
+
+ .location-badge {
+ color: $color-100;
+ background-color: rgba($color-200, 0.1);
+ border-right: 1px solid $color-800;
+ }
+
+ .search-input::placeholder {
+ color: rgba($color-200, 0.8);
+ }
+
+ .search-input-wrap {
+ .search-icon,
+ .clear-icon {
+ fill: rgba($color-200, 0.8);
+ }
+ }
+
+ &.search-active {
+ form {
+ background-color: $white-light;
+ }
+
+ .location-badge {
+ color: $gl-text-color;
+ }
+
+ .search-input-wrap {
+ .search-icon {
+ fill: rgba($color-200, 0.8);
+ }
+ }
+ }
+ }
+
+ .btn-sign-in {
+ background-color: $color-100;
+ color: $color-900;
+ }
+
+ // Sidebar
+ .nav-sidebar li.active {
+ box-shadow: inset 4px 0 0 $color-700;
+
+ > a {
+ color: $color-800;
+ }
+
+ svg {
+ fill: $color-800;
+ }
+ }
+
+ .sidebar-top-level-items > li.active .badge {
+ color: $color-800;
+ }
+
+ .nav-links li.active a {
+ border-bottom-color: $color-500;
+
+ .badge {
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+
+ // Web IDE
+ .ide-sidebar-link {
+ color: $color-200;
+ background-color: $color-700;
+
+ &:hover,
+ &:focus {
+ background-color: $color-500;
+ }
+
+ &:active {
+ background: $color-800;
+ }
+ }
+
+ .branch-container {
+ border-left-color: $color-700;
+ }
+
+ .branch-header-title {
+ color: $color-700;
+ }
+
+ .ide-file-list .file.file-active {
+ color: $color-700;
+ }
+}
+
+body {
+ &.ui_indigo {
+ @include gitlab-theme(
+ $indigo-100,
+ $indigo-200,
+ $indigo-500,
+ $indigo-700,
+ $indigo-800,
+ $indigo-900,
+ $white-light
+ );
+ }
+
+ &.ui_dark {
+ @include gitlab-theme(
+ $theme-gray-100,
+ $theme-gray-200,
+ $theme-gray-500,
+ $theme-gray-700,
+ $theme-gray-800,
+ $theme-gray-900,
+ $white-light
+ );
+ }
+
+ &.ui_blue {
+ @include gitlab-theme(
+ $theme-blue-100,
+ $theme-blue-200,
+ $theme-blue-500,
+ $theme-blue-700,
+ $theme-blue-800,
+ $theme-blue-900,
+ $white-light
+ );
+ }
+
+ &.ui_green {
+ @include gitlab-theme(
+ $theme-green-100,
+ $theme-green-200,
+ $theme-green-500,
+ $theme-green-700,
+ $theme-green-800,
+ $theme-green-900,
+ $white-light
+ );
+ }
+
+ &.ui_light {
+ @include gitlab-theme(
+ $theme-gray-900,
+ $theme-gray-700,
+ $theme-gray-800,
+ $theme-gray-700,
+ $theme-gray-700,
+ $theme-gray-100,
+ $theme-gray-700
+ );
+
+ .navbar-gitlab {
+ background-color: $theme-gray-100;
+ box-shadow: 0 1px 0 0 $border-color;
+
+ .logo-text svg {
+ fill: $theme-gray-900;
+ }
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ > li {
+ > a:hover,
+ > a:focus {
+ color: $theme-gray-900;
+ }
+
+ &.active > a,
+ &.active > a:hover {
+ color: $white-light;
+ }
+ }
+ }
+
+ .container-fluid {
+ .navbar-toggle,
+ .navbar-toggle:hover {
+ color: $theme-gray-700;
+ border-left: 1px solid $theme-gray-200;
+ }
+ }
+ }
+
+ .search {
+ form {
+ background-color: $white-light;
+ box-shadow: inset 0 0 0 1px $border-color;
+
+ &:hover {
+ background-color: $white-light;
+ box-shadow: inset 0 0 0 1px $blue-200;
+
+ .location-badge {
+ box-shadow: inset 0 0 0 1px $blue-200;
+ }
+ }
+ }
+
+ .search-input-wrap {
+ .search-icon {
+ fill: $theme-gray-200;
+ }
+
+ .search-input {
+ color: $gl-text-color;
+ }
+ }
+
+ .location-badge {
+ color: $theme-gray-700;
+ box-shadow: inset 0 0 0 1px $border-color;
+ background-color: $nav-badge-bg;
+ border-right: 0;
+ }
+ }
+
+ .nav-sidebar li.active {
+ > a {
+ color: $theme-gray-900;
+ }
+
+ svg {
+ fill: $theme-gray-900;
+ }
+ }
+
+ .sidebar-top-level-items > li.active .badge {
+ color: $theme-gray-900;
+ }
+
+ .ide-sidebar-link {
+ color: $white-light;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 29714e348a0..0136af76a13 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -1,60 +1,24 @@
.navbar-gitlab {
- &.navbar-gitlab {
- padding: 0 16px;
- z-index: 1000;
- margin-bottom: 0;
- min-height: $header-height;
- border: 0;
- border-bottom: 1px solid $border-color;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- border-radius: 0;
-
- .logo-text {
- line-height: initial;
-
- svg {
- width: 55px;
- height: 14px;
- margin: 0;
- fill: $white-light;
- }
- }
-
- .container-fluid {
- padding: 0;
-
- .user-counter {
- svg {
- margin-right: 3px;
- }
- }
-
- .navbar-toggle {
- right: -10px;
- border-radius: 0;
- min-width: 45px;
- padding: 0;
- margin-right: -7px;
- font-size: 14px;
- text-align: center;
- color: currentColor;
-
- &:hover,
- &:focus,
- &.active {
- color: currentColor;
- background-color: transparent;
- }
-
- .more-icon,
- .close-icon {
- fill: $white-light;
- margin: auto;
- }
- }
+ padding: 0 16px;
+ z-index: 1000;
+ margin-bottom: 0;
+ min-height: $header-height;
+ border: 0;
+ border-bottom: 1px solid $border-color;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ border-radius: 0;
+
+ .logo-text {
+ line-height: initial;
+
+ svg {
+ width: 55px;
+ height: 14px;
+ margin: 0;
+ fill: $white-light;
}
}
@@ -104,7 +68,10 @@
img {
height: 28px;
- margin-right: 8px;
+
+ + .logo-text {
+ margin-left: 8px;
+ }
}
&.wrap {
@@ -181,6 +148,37 @@
}
.container-fluid {
+ padding: 0;
+
+ .user-counter {
+ svg {
+ margin-right: 3px;
+ }
+ }
+
+ .navbar-toggle {
+ right: -10px;
+ border-radius: 0;
+ min-width: 45px;
+ padding: 0;
+ margin-right: -7px;
+ font-size: 14px;
+ text-align: center;
+ color: currentColor;
+
+ &:hover,
+ &:focus,
+ &.active {
+ color: currentColor;
+ background-color: transparent;
+ }
+
+ .more-icon,
+ .close-icon {
+ fill: $white-light;
+ margin: auto;
+ }
+ }
.navbar-nav {
@media (max-width: $screen-xs-max) {
@@ -300,6 +298,8 @@
.projects-dropdown-menu {
padding: 0;
+ overflow-y: initial;
+ max-height: initial;
}
.dropdown-chevron {
@@ -332,7 +332,7 @@
.breadcrumbs {
display: -webkit-flex;
display: flex;
- min-height: 48px;
+ min-height: $breadcrumb-min-height;
color: $gl-text-color;
}
@@ -461,7 +461,7 @@
padding: 0 5px;
line-height: 12px;
border-radius: 7px;
- box-shadow: 0 1px 0 rgba($gl-header-color, .2);
+ box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
&.issues-count {
background-color: $green-500;
@@ -516,7 +516,7 @@
.header-user {
.dropdown-menu-nav {
width: auto;
- min-width: 140px;
+ min-width: 160px;
margin-top: 4px;
color: $gl-text-color;
left: auto;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index e2084e8f85f..30314f3d6cb 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -26,15 +26,15 @@
}
.ci-status-icon-canceled,
-.ci-status-icon-disabled,
-.ci-status-icon-not-found {
+.ci-status-icon-disabled {
svg {
fill: $gl-text-color;
}
}
.ci-status-icon-created,
-.ci-status-icon-skipped {
+.ci-status-icon-skipped,
+.ci-status-icon-notfound {
svg {
fill: $gray-darkest;
}
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index aa2d30a3cef..62a0fba3da3 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -20,10 +20,13 @@
width: 100%;
}
- &.svg-250 {
- img,
- svg {
- width: 250px;
+ $image-widths: 80 130 250 306 394 430;
+ @each $width in $image-widths {
+ &.svg-#{$width} {
+ img,
+ svg {
+ width: #{$width + 'px'};
+ }
}
}
}
@@ -36,12 +39,35 @@
svg {
fill: currentColor;
- &.s8 { @include svg-size(8px); }
- &.s12 { @include svg-size(12px); }
- &.s16 { @include svg-size(16px); }
- &.s18 { @include svg-size(18px); }
- &.s24 { @include svg-size(24px); }
- &.s32 { @include svg-size(32px); }
- &.s48 { @include svg-size(48px); }
- &.s72 { @include svg-size(72px); }
+ &.s8 {
+ @include svg-size(8px);
+ }
+
+ &.s12 {
+ @include svg-size(12px);
+ }
+
+ &.s16 {
+ @include svg-size(16px);
+ }
+
+ &.s18 {
+ @include svg-size(18px);
+ }
+
+ &.s24 {
+ @include svg-size(24px);
+ }
+
+ &.s32 {
+ @include svg-size(32px);
+ }
+
+ &.s48 {
+ @include svg-size(48px);
+ }
+
+ &.s72 {
+ @include svg-size(72px);
+ }
}
diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss
index 1d8bd26cf1a..d8c57a0e2d9 100644
--- a/app/assets/stylesheets/framework/issue_box.scss
+++ b/app/assets/stylesheets/framework/issue_box.scss
@@ -24,15 +24,13 @@
font-size: $gl-font-size;
line-height: 25px;
+ &.status-box-closed,
&.status-box-mr-closed {
background-color: $gl-danger;
}
- &.status-box-issue-closed {
- background-color: $gl-primary;
- }
-
- &.status-box-merged {
+ &.status-box-issue-closed,
+ &.status-box-mr-merged {
background-color: $gl-primary;
}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 3f0268541a4..d107422e517 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -1,10 +1,16 @@
html {
overflow-y: scroll;
- &.touch .tooltip { display: none !important; }
+ &.touch .tooltip {
+ display: none !important;
+ }
}
body {
+ // Improves readability for dyslexic users; supported only in Chrome/Safari so far
+ // scss-lint:disable PropertySpelling
+ text-decoration-skip: ink;
+ // scss-lint:enable PropertySpelling
&.navless {
background-color: $white-light !important;
}
@@ -106,10 +112,6 @@ body {
}
}
-.layout-page > .content-wrapper {
- min-height: calc(100vh - #{$header-height});
-}
-
.with-performance-bar .layout-page {
margin-top: $header-height + $performance-bar-height;
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index f79a71221c4..f1a8a46dda4 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -12,6 +12,7 @@
padding: 10px 15px;
min-height: 20px;
border-bottom: 1px solid $list-border;
+ word-wrap: break-word;
&::after {
content: " ";
@@ -23,6 +24,10 @@
color: $list-text-disabled-color;
}
+ &:not(.ui-sort-disabled):hover {
+ background: $row-hover;
+ }
+
&.unstyled {
&:hover {
background: none;
@@ -33,14 +38,15 @@
background-color: $list-warning-row-bg;
border-color: $list-warning-row-border;
color: $list-warning-row-color;
- }
- &.smoke { background-color: $gray-light; }
+ &:hover {
+ background: $list-warning-row-bg;
+ }
- &:not(.ui-sort-disabled):hover {
- background: $row-hover;
}
+ &.smoke { background-color: $gray-light; }
+
&:last-child {
border-bottom: 0;
@@ -125,10 +131,8 @@ ul.content-list {
}
.description {
- p {
- @include str-truncated;
- margin-bottom: 0;
- }
+ @include str-truncated;
+ color: $gl-text-color-secondary;
}
.controls {
@@ -256,8 +260,6 @@ ul.controls {
}
.author_link {
- display: inline-block;
-
.avatar-inline {
margin-left: 0;
margin-right: 0;
@@ -314,7 +316,7 @@ ul.indent-list {
border: 2px solid $white-normal;
&.identicon {
- line-height: 30px;
+ line-height: 15px;
}
}
}
@@ -348,14 +350,19 @@ ul.indent-list {
.folder-caret {
width: 15px;
+
+ svg {
+ margin-bottom: 2px;
+ }
}
.item-type-icon {
+ margin-top: 2px;
width: 20px;
}
> .group-row:not(.has-children) {
- .folder-caret .fa {
+ .folder-caret {
opacity: 0;
}
}
@@ -438,12 +445,61 @@ ul.indent-list {
.avatar-container > a {
width: 100%;
+ text-decoration: none;
}
&.has-more-items {
display: block;
padding: 20px 10px;
}
+
+ .stats {
+ position: relative;
+ line-height: 46px;
+
+ > span {
+ display: inline-flex;
+ align-items: center;
+ height: 16px;
+ min-width: 30px;
+ }
+
+ > span:last-child {
+ margin-right: 0;
+ }
+
+ .stat-value {
+ margin: 2px 0 0 5px;
+ }
+ }
+
+ .controls {
+ margin-left: 5px;
+
+ > .btn {
+ margin-right: $btn-xs-side-margin;
+ }
+ }
+ }
+
+ .project-row-contents .stats {
+ line-height: inherit;
+
+ > span:first-child {
+ margin-left: 25px;
+ }
+
+ .item-visibility {
+ margin-right: 0;
+ }
+
+ .last-updated {
+ position: absolute;
+ right: 12px;
+ min-width: 250px;
+ text-align: right;
+ color: $gl-text-color-secondary;
+ }
}
}
@@ -455,12 +511,12 @@ ul.indent-list {
ul.group-list-tree {
li.group-row {
- &.has-description .title {
- line-height: inherit;
+ > .group-row-contents .title {
+ line-height: $list-text-height;
}
- &:not(.has-description) .title {
- line-height: $list-text-height;
+ &.has-description > .group-row-contents .title {
+ line-height: inherit;
}
}
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 6b07ffdbd61..938f5f49c09 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -192,6 +192,17 @@
overflow-y: auto;
overflow-x: hidden;
+ .name,
+ small.aliases,
+ small.params {
+ float: left;
+ }
+
+ small.aliases,
+ small.params {
+ padding: 2px 5px;
+ }
+
small.description {
float: right;
padding: 3px 5px;
@@ -209,6 +220,7 @@
}
ul > li {
+ @include clearfix;
white-space: nowrap;
}
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index a12f28efce6..8604e753c18 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -63,10 +63,6 @@
}
}
- .project-stats {
- display: none;
- }
-
.group-buttons {
display: none;
}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 1be66d0ab21..eb789cc64b0 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -1,6 +1,21 @@
.modal-header {
+ background-color: $modal-body-bg;
padding: #{3 * $grid-size} #{2 * $grid-size};
+ .page-title,
+ .modal-title {
+ .modal-title-with-label span {
+ vertical-align: middle;
+ display: inline-block;
+ }
+
+ .color-label {
+ font-size: $gl-font-size;
+ padding: $gl-vert-padding $label-padding-modal;
+ vertical-align: middle;
+ }
+ }
+
.page-title {
margin-top: 0;
}
@@ -8,8 +23,11 @@
.modal-body {
background-color: $modal-body-bg;
+ line-height: $line-height-base;
+ min-height: $modal-body-height;
position: relative;
padding: #{3 * $grid-size} #{2 * $grid-size};
+ text-align: left;
.form-actions {
margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size};
@@ -20,6 +38,30 @@
}
}
+.modal-footer {
+ display: flex;
+ flex-direction: row;
+
+ .btn + .btn {
+ margin-left: $grid-size;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ flex-direction: column;
+
+ .btn + .btn {
+ margin-left: 0;
+ margin-top: $grid-size;
+ }
+ }
+
+ @media (min-width: $screen-sm-min) {
+ .btn:first-of-type {
+ margin-left: auto;
+ }
+ }
+}
+
body.modal-open {
overflow: hidden;
}
@@ -32,12 +74,6 @@ body.modal-open {
}
}
-@media (min-width: $screen-md-min) {
- .modal-dialog {
- width: 860px;
- }
-}
-
@media (min-width: $screen-lg-min) {
.modal-full {
width: 98%;
diff --git a/app/assets/stylesheets/framework/page-header.scss b/app/assets/stylesheets/framework/page_header.scss
index 0c879f40930..0c879f40930 100644
--- a/app/assets/stylesheets/framework/page-header.scss
+++ b/app/assets/stylesheets/framework/page_header.scss
diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
index 7829d722560..34fccf6f0a4 100644
--- a/app/assets/stylesheets/framework/responsive_tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -39,7 +39,7 @@
.table-section {
white-space: nowrap;
- $section-widths: 10 15 20 25 30 40 100;
+ $section-widths: 10 15 20 25 30 40 50 100;
@each $width in $section-widths {
&.section-#{$width} {
flex: 0 0 #{$width + '%'};
diff --git a/app/assets/stylesheets/framework/secondary-navigation-elements.scss b/app/assets/stylesheets/framework/secondary-navigation-elements.scss
deleted file mode 100644
index 5f67126bafa..00000000000
--- a/app/assets/stylesheets/framework/secondary-navigation-elements.scss
+++ /dev/null
@@ -1,376 +0,0 @@
-// For tabbed navigation links, scrolling tabs, etc. For all top/main navigation,
-// please check nav.scss
-.nav-links {
- display: flex;
- padding: 0;
- margin: 0;
- list-style: none;
- height: auto;
- border-bottom: 1px solid $border-color;
-
- li {
- display: flex;
-
- a {
- padding: $gl-btn-padding;
- padding-bottom: 11px;
- font-size: 14px;
- line-height: 28px;
- color: $gl-text-color-secondary;
- border-bottom: 2px solid transparent;
- white-space: nowrap;
-
- &:hover,
- &:active,
- &:focus {
- text-decoration: none;
- color: $black;
- border-bottom: 2px solid $gray-darkest;
-
- .badge {
- color: $black;
- }
- }
- }
-
- &.active a {
- color: $black;
- font-weight: $gl-font-weight-bold;
-
- .badge {
- color: $black;
- }
- }
- }
-}
-
-.top-area {
- @include clearfix;
- border-bottom: 1px solid $border-color;
-
- .nav-text {
- padding-top: 16px;
- padding-bottom: 11px;
- display: inline-block;
- line-height: 28px;
- white-space: normal;
-
- /* Small devices (phones, tablets, 768px and lower) */
- @media (max-width: $screen-xs-max) {
- width: 100%;
- }
- }
-
- .nav-links {
- margin-bottom: 0;
- border-bottom: 0;
- float: left;
-
- &.wide {
- width: 100%;
- display: block;
- }
-
- &.scrolling-tabs {
- float: left;
- }
-
- li a {
- padding: 16px 15px 11px;
- }
-
- /* Small devices (phones, tablets, 768px and lower) */
- @media (max-width: $screen-xs-max) {
- width: 100%;
- }
- }
-
- .nav-controls {
- display: inline-block;
- float: right;
- text-align: right;
- padding: 11px 0;
- margin-bottom: 0;
-
- > .btn,
- > .btn-container,
- > .dropdown,
- > input,
- > form {
- margin-right: $gl-padding-top;
- display: inline-block;
- vertical-align: top;
-
- &:last-child {
- margin-right: 0;
- float: right;
- }
- }
-
- > .btn-grouped {
- float: none;
- }
-
- .icon-label {
- display: none;
- }
-
- input {
- display: inline-block;
- position: relative;
-
- /* Medium devices (desktops, 992px and up) */
- @media (min-width: $screen-md-min) { width: 200px; }
-
- /* Large devices (large desktops, 1200px and up) */
- @media (min-width: $screen-lg-min) { width: 250px; }
-
- &.input-short {
- /* Medium devices (desktops, 992px and up) */
- @media (min-width: $screen-md-min) { width: 170px; }
-
- /* Large devices (large desktops, 1200px and up) */
- @media (min-width: $screen-lg-min) { width: 210px; }
- }
- }
-
- @media (max-width: $screen-xs-max) {
- padding-bottom: 0;
- width: 100%;
-
- .btn,
- form,
- .dropdown,
- .dropdown-toggle,
- .dropdown-menu-toggle,
- .form-control {
- margin: 0 0 10px;
- display: block;
- width: 100%;
- }
-
- form {
- display: block;
- height: auto;
- margin-bottom: 14px;
-
- input {
- width: 100%;
- margin: 0 0 10px;
- }
- }
-
- .input-short {
- width: 100%;
- }
-
- .icon-label {
- display: inline-block;
- }
-
- // Applies on /dashboard/issues
- .project-item-select-holder {
- margin: 0;
- }
- }
- }
-
- &.adjust {
- .nav-text,
- .nav-controls {
- width: auto;
-
- @media (max-width: $screen-xs-max) {
- width: 100%;
- }
- }
- }
-
- &.multi-line {
- .nav-text {
- line-height: 20px;
- }
-
- .nav-controls {
- padding: 17px 0;
- }
- }
-
- pre {
- width: 100%;
- }
-
- @media (max-width: $screen-xs-max) {
- flex-flow: row wrap;
-
- .nav-controls {
- $controls-margin: $btn-xs-side-margin - 2px;
- flex: 0 0 100%;
-
- &.controls-flex {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
- justify-content: center;
- padding: 0 0 $gl-padding-top;
- }
-
- .controls-item,
- .controls-item-full,
- .controls-item:last-child {
- flex: 1 1 35%;
- display: block;
- width: 100%;
- margin: $controls-margin;
-
- .btn,
- .dropdown {
- margin: 0;
- }
- }
-
- .controls-item-full {
- flex: 1 1 100%;
- }
- }
- }
-}
-
-.scrolling-tabs-container {
- position: relative;
-
- .merge-request-tabs-container & {
- overflow: hidden;
- }
-
- .nav-links {
- @include scrolling-links();
- }
-
- .fade-right {
- @include fade(left, $gray-light);
- right: -5px;
-
- .fa {
- right: -7px;
- }
- }
-
- .fade-left {
- @include fade(right, $gray-light);
- left: -5px;
- text-align: center;
-
- .fa {
- left: -7px;
- }
- }
-}
-
-.inner-page-scroll-tabs {
- position: relative;
-
- .fade-right {
- @include fade(left, $white-light);
- right: 0;
- text-align: right;
-
- .fa {
- right: 5px;
- }
- }
-
- .fade-left {
- @include fade(right, $white-light);
- left: 0;
- text-align: left;
-
- .fa {
- left: 5px;
- }
- }
-
- .fade-right,
- .fade-left {
- top: 16px;
- bottom: auto;
- }
-
- &.is-smaller {
- .fade-right,
- .fade-left {
- top: 11px;
- }
- }
-}
-
-.nav-block {
- position: relative;
-
- .nav-links {
- @include scrolling-links();
-
- .fade-right {
- @include fade(left, $white-light);
- right: -5px;
-
- .fa {
- right: -7px;
- }
- }
-
- .fade-left {
- @include fade(right, $white-light);
- left: -5px;
-
- .fa {
- left: -7px;
- }
- }
- }
-
- &.activities {
- border-bottom: 1px solid $border-color;
-
- .nav-links {
- border-bottom: 0;
- }
- }
-}
-
-.project-item-select-holder.btn-group {
- display: flex;
- max-width: 350px;
- overflow: hidden;
- float: right;
-
- .new-project-item-link {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .new-project-item-select-button {
- width: 32px;
- }
-}
-
-.empty-state .project-item-select-holder.btn-group {
- float: none;
- display: inline-block;
-
- .btn {
- // overrides styles applied to plain `.empty-state .btn`
- margin: 10px 0;
- max-width: 300px;
- width: auto;
-
- @media(max-width: $screen-xs-max) {
- max-width: 250px;
- }
- }
-}
-
-.new-project-item-select-button .fa-caret-down {
- margin-left: 2px;
-}
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
new file mode 100644
index 00000000000..17c31d6b184
--- /dev/null
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -0,0 +1,379 @@
+// For tabbed navigation links, scrolling tabs, etc. For all top/main navigation,
+// please check nav.scss
+.nav-links {
+ display: flex;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ height: auto;
+ border-bottom: 1px solid $border-color;
+
+ li {
+ display: flex;
+
+ a {
+ padding: $gl-btn-padding;
+ padding-bottom: 11px;
+ font-size: 14px;
+ line-height: 28px;
+ color: $gl-text-color-secondary;
+ border-bottom: 2px solid transparent;
+ white-space: nowrap;
+
+ &:hover,
+ &:active,
+ &:focus {
+ text-decoration: none;
+ color: $black;
+ border-bottom: 2px solid $gray-darkest;
+
+ .badge {
+ color: $black;
+ }
+ }
+ }
+
+ &.active a {
+ color: $black;
+ font-weight: $gl-font-weight-bold;
+
+ .badge {
+ color: $black;
+ }
+ }
+ }
+}
+
+.top-area {
+ @include clearfix;
+ border-bottom: 1px solid $border-color;
+
+ .nav-text {
+ padding-top: 16px;
+ padding-bottom: 11px;
+ display: inline-block;
+ line-height: 28px;
+ white-space: normal;
+
+ /* Small devices (phones, tablets, 768px and lower) */
+ @media (max-width: $screen-xs-max) {
+ width: 100%;
+ }
+ }
+
+ .nav-links {
+ margin-bottom: 0;
+ border-bottom: 0;
+ float: left;
+
+ &.wide {
+ width: 100%;
+ display: block;
+ }
+
+ &.scrolling-tabs {
+ float: left;
+ }
+
+ li a {
+ padding: 16px 15px 11px;
+ }
+
+ /* Small devices (phones, tablets, 768px and lower) */
+ @media (max-width: $screen-xs-max) {
+ width: 100%;
+
+ &.mobile-separator {
+ border-bottom: 1px solid $border-color;
+ }
+ }
+ }
+
+ .nav-controls {
+ display: inline-block;
+ float: right;
+ text-align: right;
+ padding: 11px 0;
+ margin-bottom: 0;
+
+ > .btn,
+ > .btn-container,
+ > .dropdown,
+ > input,
+ > form {
+ margin-right: $gl-padding-top;
+ display: inline-block;
+ vertical-align: top;
+
+ &:last-child {
+ margin-right: 0;
+ float: right;
+ }
+ }
+
+ > .btn-grouped {
+ float: none;
+ }
+
+ .icon-label {
+ display: none;
+ }
+
+ input {
+ display: inline-block;
+ position: relative;
+
+ /* Medium devices (desktops, 992px and up) */
+ @media (min-width: $screen-md-min) { width: 200px; }
+
+ /* Large devices (large desktops, 1200px and up) */
+ @media (min-width: $screen-lg-min) { width: 250px; }
+
+ &.input-short {
+ /* Medium devices (desktops, 992px and up) */
+ @media (min-width: $screen-md-min) { width: 170px; }
+
+ /* Large devices (large desktops, 1200px and up) */
+ @media (min-width: $screen-lg-min) { width: 210px; }
+ }
+ }
+
+ @media (max-width: $screen-xs-max) {
+ padding-bottom: 0;
+ width: 100%;
+
+ .btn,
+ form,
+ .dropdown,
+ .dropdown-toggle,
+ .dropdown-menu-toggle,
+ .form-control {
+ margin: 0 0 10px;
+ display: block;
+ width: 100%;
+ }
+
+ form {
+ display: block;
+ height: auto;
+ margin-bottom: 14px;
+
+ input {
+ width: 100%;
+ margin: 0 0 10px;
+ }
+ }
+
+ .input-short {
+ width: 100%;
+ }
+
+ .icon-label {
+ display: inline-block;
+ }
+
+ .project-item-select-holder {
+ margin: 0;
+ width: 100%;
+ }
+ }
+ }
+
+ &.adjust {
+ .nav-text,
+ .nav-controls {
+ width: auto;
+
+ @media (max-width: $screen-xs-max) {
+ width: 100%;
+ }
+ }
+ }
+
+ &.multi-line {
+ .nav-text {
+ line-height: 20px;
+ }
+
+ .nav-controls {
+ padding: 17px 0;
+ }
+ }
+
+ pre {
+ width: 100%;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ flex-flow: row wrap;
+
+ .nav-controls {
+ $controls-margin: $btn-xs-side-margin - 2px;
+ flex: 0 0 100%;
+
+ &.controls-flex {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0 $gl-padding-top;
+ }
+
+ .controls-item,
+ .controls-item-full,
+ .controls-item:last-child {
+ flex: 1 1 35%;
+ display: block;
+ width: 100%;
+ margin: $controls-margin;
+
+ .btn,
+ .dropdown {
+ margin: 0;
+ }
+ }
+
+ .controls-item-full {
+ flex: 1 1 100%;
+ }
+ }
+ }
+}
+
+.scrolling-tabs-container {
+ position: relative;
+
+ .merge-request-tabs-container & {
+ overflow: hidden;
+ }
+
+ .nav-links {
+ @include scrolling-links();
+ }
+
+ .fade-right {
+ @include fade(left, $gray-light);
+ right: -5px;
+
+ .fa {
+ right: -7px;
+ }
+ }
+
+ .fade-left {
+ @include fade(right, $gray-light);
+ left: -5px;
+ text-align: center;
+
+ .fa {
+ left: -7px;
+ }
+ }
+}
+
+.inner-page-scroll-tabs {
+ position: relative;
+
+ .fade-right {
+ @include fade(left, $white-light);
+ right: 0;
+ text-align: right;
+
+ .fa {
+ right: 5px;
+ }
+ }
+
+ .fade-left {
+ @include fade(right, $white-light);
+ left: 0;
+ text-align: left;
+
+ .fa {
+ left: 5px;
+ }
+ }
+
+ .fade-right,
+ .fade-left {
+ top: 16px;
+ bottom: auto;
+ }
+
+ &.is-smaller {
+ .fade-right,
+ .fade-left {
+ top: 11px;
+ }
+ }
+}
+
+.nav-block {
+ position: relative;
+
+ .nav-links {
+ @include scrolling-links();
+
+ .fade-right {
+ @include fade(left, $white-light);
+ right: -5px;
+
+ .fa {
+ right: -7px;
+ }
+ }
+
+ .fade-left {
+ @include fade(right, $white-light);
+ left: -5px;
+
+ .fa {
+ left: -7px;
+ }
+ }
+ }
+
+ &.activities {
+ border-bottom: 1px solid $border-color;
+
+ .nav-links {
+ border-bottom: 0;
+ }
+ }
+}
+
+.project-item-select-holder.btn-group {
+ display: flex;
+ overflow: hidden;
+ float: right;
+
+ .new-project-item-link {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .new-project-item-select-button {
+ width: 32px;
+ }
+}
+
+.empty-state .project-item-select-holder.btn-group {
+ float: none;
+ display: inline-block;
+
+ .btn {
+ // overrides styles applied to plain `.empty-state .btn`
+ margin: 10px 0;
+ max-width: 300px;
+ width: auto;
+
+ @media(max-width: $screen-xs-max) {
+ max-width: 250px;
+ }
+ }
+}
+
+.new-project-item-select-button .fa-caret-down {
+ margin-left: 2px;
+}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index dbee7073975..b40dcf93969 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -8,7 +8,7 @@
.select2-choice {
background: $white-light;
border-color: $input-border;
- height: 35px;
+ height: 34px;
padding: $gl-vert-padding $gl-input-padding;
font-size: $gl-font-size;
line-height: 1.42857143;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index d61809cb0a4..798f248dad4 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -3,7 +3,6 @@
transition: padding $sidebar-transition-duration;
.container-fluid {
- background: $white-light;
padding: 0 $gl-padding;
&.container-blank {
@@ -89,7 +88,6 @@
.right-sidebar {
border-left: 1px solid $border-color;
- height: calc(100% - #{$header-height});
}
.with-performance-bar .right-sidebar.affix {
@@ -153,3 +151,4 @@
.sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px;
}
+
diff --git a/app/assets/stylesheets/framework/stacked_progress_bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss
new file mode 100644
index 00000000000..528ba53a48b
--- /dev/null
+++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss
@@ -0,0 +1,54 @@
+.stacked-progress-bar {
+ display: flex;
+ height: 16px;
+ border-radius: 10px;
+ overflow: hidden;
+ background-color: $theme-gray-100;
+
+ .status-unavailable,
+ .status-green,
+ .status-neutral,
+ .status-red, {
+ height: 100%;
+ min-width: 30px;
+ padding: 0 5px;
+ font-size: $tooltip-font-size;
+ font-weight: normal;
+ color: $white-light;
+ line-height: 16px;
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+
+ .status-unavailable {
+ padding: 0 10px;
+ color: $theme-gray-700;
+ }
+
+ .status-green {
+ background-color: $green-500;
+
+ &:hover {
+ background-color: $green-600;
+ }
+ }
+
+ .status-neutral {
+ background-color: $theme-gray-200;
+ color: $gl-gray-dark;
+
+ &:hover {
+ background-color: $theme-gray-300;
+ }
+ }
+
+ .status-red {
+ background-color: $red-500;
+
+ &:hover {
+ background-color: $red-600;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
index a23131e0818..d04e555769b 100644
--- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
+++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss
@@ -194,6 +194,6 @@ $modal-body-bg: $white-light;
//** Modal footer border color
// $modal-footer-border-color: $modal-header-border-color
-// $modal-lg: 900px
-// $modal-md: 600px
+$modal-lg: 860px;
+$modal-md: 540px;
// $modal-sm: 300px
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 11c1aeea871..9e1371648ed 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -178,6 +178,10 @@
font-weight: inherit;
}
+ dd {
+ margin-left: $gl-padding;
+ }
+
ul,
ol {
padding: 0;
@@ -285,6 +289,11 @@ body {
&:last-child {
margin-bottom: 0;
}
+
+ &.with-button {
+ line-height: 34px;
+ }
+
}
.page-title-empty {
@@ -292,7 +301,7 @@ body {
line-height: 1.3;
font-size: 1.25em;
font-weight: $gl-font-weight-bold;
- margin: 12px 7px;
+ margin: 12px 0;
}
h1,
@@ -329,6 +338,10 @@ a > code {
font-family: $monospace_font;
}
+.weight-normal {
+ font-weight: $gl-font-weight-normal;
+}
+
.commit-sha,
.ref-name {
@extend .monospace;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1d6c7a5c472..8ee1bb03d55 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -5,9 +5,9 @@ $grid-size: 8px;
$gutter_collapsed_width: 62px;
$gutter_width: 290px;
$gutter_inner_width: 250px;
-$sidebar-transition-duration: .3s;
+$sidebar-transition-duration: 0.3s;
$sidebar-breakpoint: 1024px;
-$default-transition-duration: .15s;
+$default-transition-duration: 0.15s;
$contextual-sidebar-width: 220px;
$contextual-sidebar-collapsed-width: 50px;
@@ -129,7 +129,6 @@ $theme-green-800: #145d33;
$theme-green-900: #0d4524;
$theme-green-950: #072d16;
-
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
$almost-black: #242424;
@@ -163,7 +162,8 @@ $gl-text-color-secondary: #707070;
$gl-text-color-tertiary: #949494;
$gl-text-color-quaternary: #d6d6d6;
$gl-text-color-inverted: rgba(255, 255, 255, 1);
-$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85);
+$gl-text-color-secondary-inverted: rgba(255, 255, 255, 0.85);
+$gl-text-color-disabled: #919191;
$gl-text-green: $green-600;
$gl-text-green-hover: $green-700;
$gl-text-red: $red-500;
@@ -213,8 +213,9 @@ $tooltip-font-size: 12px;
* Padding
*/
$gl-padding: 16px;
+$gl-padding-8: 8px;
+$gl-padding-4: 4px;
$gl-col-padding: 15px;
-$gl-btn-padding: 10px;
$gl-input-padding: 10px;
$gl-vert-padding: 6px;
$gl-padding-top: 10px;
@@ -258,6 +259,9 @@ $general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
$highlight-changes-color: rgb(235, 255, 232);
$performance-bar-height: 35px;
+$flash-height: 52px;
+$context-header-height: 60px;
+$breadcrumb-min-height: 48px;
/*
* Common component specific colors
@@ -292,7 +296,7 @@ $tanuki-yellow: #fca326;
*/
$gl-primary: $blue-500;
$gl-success: $green-500;
-$gl-success-focus: rgba($gl-success, .4);
+$gl-success-focus: rgba($gl-success, 0.4);
$gl-info: $blue-500;
$gl-warning: $orange-500;
$gl-danger: $red-500;
@@ -327,27 +331,31 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%);
/*
* Fonts
*/
-$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";
+$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';
/*
* Dropdowns
*/
$dropdown-width: 300px;
-$dropdown-max-height: 215px;
+$dropdown-min-height: 40px;
+$dropdown-max-height: 312px;
$dropdown-vertical-offset: 4px;
$dropdown-link-color: #555;
$dropdown-link-hover-bg: $row-hover;
-$dropdown-empty-row-bg: rgba(#000, .04);
+$dropdown-empty-row-bg: rgba(#000, 0.04);
$dropdown-border-color: $border-color;
-$dropdown-shadow-color: rgba(#000, .1);
-$dropdown-divider-color: rgba(#000, .1);
+$dropdown-shadow-color: rgba(#000, 0.1);
+$dropdown-divider-color: rgba(#000, 0.1);
$dropdown-title-btn-color: #bfbfbf;
$dropdown-input-color: #555;
$dropdown-input-fa-color: #c7c7c7;
$dropdown-input-focus-border: $focus-border-color;
-$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, .4);
-$dropdown-loading-bg: rgba(#fff, .6);
+$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, 0.4);
+$dropdown-loading-bg: rgba(#fff, 0.6);
$dropdown-chevron-size: 10px;
$dropdown-toggle-active-border-color: darken($border-color, 14%);
$dropdown-item-hover-bg: $gray-darker;
@@ -362,9 +370,9 @@ $dropdown-hover-color: $blue-400;
/*
* Contextual Sidebar
*/
-$link-active-background: rgba(0, 0, 0, .04);
-$link-hover-background: rgba(0, 0, 0, .06);
-$inactive-badge-background: rgba(0, 0, 0, .08);
+$link-active-background: rgba(0, 0, 0, 0.04);
+$link-hover-background: rgba(0, 0, 0, 0.06);
+$inactive-badge-background: rgba(0, 0, 0, 0.08);
/*
* Buttons
@@ -372,6 +380,10 @@ $inactive-badge-background: rgba(0, 0, 0, .08);
$btn-active-gray: #ececec;
$btn-active-gray-light: e4e7ed;
$btn-white-active: #848484;
+$gl-btn-padding: 10px;
+$gl-btn-line-height: 16px;
+$gl-btn-vert-padding: 8px;
+$gl-btn-horz-padding: 12px;
/*
* Badges
@@ -388,14 +400,14 @@ $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, 0.175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
/*
* Search Box
*/
-$search-input-border-color: rgba($blue-400, .8);
+$search-input-border-color: rgba($blue-400, 0.8);
$search-input-focus-shadow-color: $dropdown-input-focus-shadow;
$search-input-width: 220px;
$location-badge-active-bg: $blue-500;
@@ -420,7 +432,7 @@ $zen-control-color: #555;
* Calendar
*/
$calendar-hover-bg: #ecf3fe;
-$calendar-border-color: rgba(#000, .1);
+$calendar-border-color: rgba(#000, 0.1);
$calendar-user-contrib-text: #959494;
/*
@@ -443,6 +455,17 @@ $ci-skipped-color: #888;
*/
$issue-boards-font-size: 14px;
$issue-boards-card-shadow: rgba(186, 186, 186, 0.5);
+/*
+ The following heights are used in boards.scss and are used for calculation of the board height.
+ They probably should be derived in a smarter way.
+*/
+$issue-boards-filter-height: 68px;
+$issue-boards-breadcrumbs-height-xs: 63px;
+$issue-board-list-difference-xs: $header-height +
+ $issue-boards-breadcrumbs-height-xs;
+$issue-board-list-difference-sm: $header-height + $breadcrumb-min-height;
+$issue-board-list-difference-md: $issue-board-list-difference-sm +
+ $issue-boards-filter-height;
/*
* Avatar
@@ -553,17 +576,19 @@ $jq-ui-default-color: #777;
/*
* Label
*/
+$label-font-size: 12px;
$label-padding: 7px;
+$label-padding-modal: 10px;
$label-gray-bg: #f8fafc;
$label-inverse-bg: #333;
-$label-remove-border: rgba(0, 0, 0, .1);
+$label-remove-border: rgba(0, 0, 0, 0.1);
$label-border-radius: 100px;
/*
* Animation
*/
$fade-in-duration: 200ms;
-$fade-mask-transition-duration: .1s;
+$fade-mask-transition-duration: 0.1s;
$fade-mask-transition-curve: ease-in-out;
/*
@@ -631,7 +656,6 @@ $stat-graph-selection-stroke: #333;
$select2-drop-shadow1: rgba(76, 86, 103, 0.247059);
$select2-drop-shadow2: rgba(31, 37, 50, 0.317647);
-
/*
* Todo
*/
@@ -664,10 +688,9 @@ $pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
/*
-Pipeline Schedules
+CI variable lists
*/
-$pipeline-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
-
+$ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
/*
Filtered Search
@@ -695,7 +718,14 @@ 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%);
+$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
@@ -706,8 +736,8 @@ $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);
+$perf-bar-bucket-box-shadow-from: rgba($white-light, 0.2);
+$perf-bar-bucket-box-shadow-to: rgba($black, 0.25);
/*
Issuable warning
@@ -727,3 +757,18 @@ Popup
$popup-triangle-size: 15px;
$popup-triangle-border-size: 1px;
$popup-box-shadow-color: rgba(90, 90, 90, 0.05);
+
+/*
+Multi file editor
+*/
+$border-color-settings: #e1e1e1;
+
+/*
+Modals
+*/
+$modal-body-height: 134px;
+
+/*
+Prometheus
+*/
+$prometheus-table-row-highlight-color: $theme-gray-100;
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 735fc4babd7..2f3a80daa90 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -16,6 +16,10 @@
.commit-sha,
.commit-info {
margin-left: 4px;
+
+ .fork-svg {
+ margin-right: 4px;
+ }
}
.ref-name {
@@ -79,7 +83,7 @@
}
.limit-icon {
- margin: 0 8px;
+ margin: 0 4px;
}
.limit-message {
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 2803144ef1d..318d3ddaece 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -1,4 +1,4 @@
-@import "./issues/issue_count_badge";
+@import './issues/issue_count_badge';
[v-cloak] {
display: none;
@@ -31,8 +31,12 @@
.dropdown-menu-issues-board-new {
width: 320px;
+ .open & {
+ max-height: 400px;
+ }
+
.dropdown-content {
- max-height: 150px;
+ max-height: 162px;
}
}
@@ -72,22 +76,37 @@
}
.boards-list {
- height: calc(100vh - 105px);
+ height: calc(100vh - #{$issue-board-list-difference-xs});
width: 100%;
- padding-top: 25px;
- padding-bottom: 25px;
- padding-right: ($gl-padding / 2);
- padding-left: ($gl-padding / 2);
+ padding: $gl-padding ($gl-padding / 2);
overflow-x: scroll;
white-space: nowrap;
+ min-height: 200px;
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- height: calc(100vh - 90px);
+ height: calc(100vh - #{$issue-board-list-difference-sm});
}
@media (min-width: $screen-md-min) {
- height: calc(100vh - 160px);
- min-height: 475px;
+ height: calc(100vh - #{$issue-board-list-difference-md});
+ }
+
+ .with-performance-bar & {
+ height: calc(
+ 100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height}
+ );
+
+ @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
+ height: calc(
+ 100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height}
+ );
+ }
+
+ @media (min-width: $screen-md-min) {
+ height: calc(
+ 100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height}
+ );
+ }
}
}
@@ -454,7 +473,7 @@
&.boards-sidebar-slide-enter-active,
&.boards-sidebar-slide-leave-active {
transition: width $sidebar-transition-duration,
- padding $sidebar-transition-duration;
+ padding $sidebar-transition-duration;
}
&.boards-sidebar-slide-enter,
@@ -473,7 +492,7 @@
right: 0;
bottom: 0;
left: 0;
- background-color: rgba($black, .3);
+ background-color: rgba($black, 0.3);
z-index: 9999;
}
@@ -490,7 +509,7 @@
padding: 25px 15px 0;
background-color: $white-light;
border-radius: $border-radius-default;
- box-shadow: 0 2px 12px rgba($black, .5);
+ box-shadow: 0 2px 12px rgba($black, 0.5);
.empty-state {
display: -webkit-flex;
@@ -568,7 +587,7 @@
.card {
border: 1px solid $border-gray-dark;
- box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, .3);
+ box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, 0.3);
cursor: pointer;
}
}
diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss
index 3e2fa8ca88d..49fe50977f5 100644
--- a/app/assets/stylesheets/pages/branches.scss
+++ b/app/assets/stylesheets/pages/branches.scss
@@ -1,6 +1,17 @@
+.content-list > .branch-item,
+.branch-title {
+ display: flex;
+ align-items: center;
+}
+
+.branch-info {
+ flex: auto;
+ min-width: 0;
+ overflow: hidden;
+}
+
.divergence-graph {
- padding: 12px 12px 0 0;
- float: right;
+ padding: 0 6px;
.graph-side {
position: relative;
@@ -53,3 +64,9 @@
background-color: $divergence-graph-separator-bg;
}
}
+
+.divergence-graph,
+.branch-item .controls {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 98d460339cd..7a6352e45f1 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -391,7 +391,7 @@
}
&:hover {
- background-color: $row-hover;
+ background-color: $dropdown-item-hover-bg;
}
.icon-retry {
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 88d44131d5b..7b8ee026357 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -6,7 +6,7 @@
.cluster-applications-table {
// Wait for the Vue to kick-in and render the applications block
- min-height: 302px;
+ min-height: 400px;
}
.clusters-dropdown-menu {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 0cba223c6a6..86cdda0359e 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -107,7 +107,6 @@
}
}
-
.commits-compare-switch {
float: left;
margin-right: 9px;
@@ -179,7 +178,7 @@
.commit-detail {
display: flex;
justify-content: space-between;
- align-items: flex-start;
+ align-items: center;
flex-grow: 1;
.merge-request-branches & {
@@ -194,36 +193,69 @@
.commit-actions {
@media (min-width: $screen-sm-min) {
- font-size: 0;
+ .fa-spinner {
+ font-size: 12px;
+ }
}
.ci-status-link {
- display: inline-block;
- position: relative;
- top: 1px;
+ display: inline-flex;
}
- .btn-clipboard,
- .btn-transparent {
- padding-left: 0;
- padding-right: 0;
+ > .ci-status-link,
+ > .btn,
+ > .commit-sha-group {
+ margin-left: $gl-padding-8;
}
+}
+.commit-sha-group {
+ display: inline-flex;
+
+ .label,
.btn {
- &:not(:first-child) {
- margin-left: $gl-padding;
- }
+ padding: $gl-vert-padding $gl-btn-padding;
+ border: 1px $border-color solid;
+ font-size: $gl-font-size;
+ line-height: $line-height-base;
+ border-radius: 0;
+ display: flex;
+ align-items: center;
+ }
+
+ .label-monospace {
+ @extend .monospace;
+ user-select: text;
+ color: $gl-text-color;
+ background-color: $gray-light;
}
- .commit-sha {
- font-size: 14px;
- font-weight: $gl-font-weight-bold;
+ .btn svg {
+ top: auto;
+ fill: $gl-text-color-secondary;
+ }
+
+ .fa-clipboard {
+ color: $gl-text-color-secondary;
+ }
+
+ :first-child {
+ border-bottom-left-radius: $border-radius-default;
+ border-top-left-radius: $border-radius-default;
+ }
+
+ :not(:first-child) {
+ border-left: 0;
+ }
+
+ :last-child {
+ border-bottom-right-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
}
}
.commit,
.generic_commit_status {
-
a,
button {
color: $gl-text-color;
@@ -296,10 +328,8 @@
}
}
-
.gpg-status-box {
padding: 2px 10px;
- margin-right: $gl-padding;
&:empty {
display: none;
@@ -310,8 +340,8 @@
}
&.invalid {
- @include status-color($gray-dark, $gray, $common-gray-dark);
- border-color: $common-gray-light;
+ @include status-color($gray-dark, $gray, $gray-darkest);
+ border-color: $gray-darkest;
}
}
@@ -335,8 +365,8 @@
&.invalid {
svg {
- border: 1px solid $common-gray-light;
- fill: $common-gray-light;
+ border: 1px solid $gray-darkest;
+ fill: $gray-darkest;
}
}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 3b35beb7695..cfef6476d4d 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -117,47 +117,6 @@
top: $gl-padding-top;
}
- .content-list {
- li {
- padding: 18px $gl-padding $gl-padding;
-
- .container-fluid {
- padding: 0;
- }
- }
-
- .title-col {
- p {
- margin: 0;
-
- &.title {
- line-height: 19px;
- font-size: 14px;
- font-weight: $gl-font-weight-bold;
- color: $gl-text-color;
- }
-
- &.text {
- color: $layout-link-gray;
-
- &.value-col {
- color: $gl-text-color;
- }
- }
- }
- }
-
- .value-col {
- text-align: right;
-
- span {
- position: relative;
- vertical-align: middle;
- top: 3px;
- }
- }
- }
-
.fa-spinner {
font-size: 28px;
position: relative;
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 60b07537799..679f783b1b6 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -652,14 +652,18 @@
}
.diff-changed-file-name,
- .diff-changed-file-path {
+ .diff-changed-blank-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+ .diff-changed-blank-file-name {
+ color: $gl-text-color-tertiary;
+ font-style: italic;
+ }
+
.diff-changed-file-path {
- direction: rtl;
color: $gl-text-color-tertiary;
}
@@ -809,6 +813,7 @@
}
.discussion-notes {
+ padding: 0 $gl-padding $gl-padding;
min-height: 35px;
&:first-child {
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index f4882305c57..3a300086fa3 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -121,6 +121,10 @@
width: 100%;
text-align: left;
}
+
+ .environment-child-row {
+ padding-left: 20px;
+ }
}
}
@@ -205,7 +209,7 @@
}
.prometheus-state {
- max-width: 430px;
+ max-width: 460px;
margin: 10px auto;
text-align: center;
@@ -213,6 +217,10 @@
max-width: 80vw;
margin: 0 auto;
}
+
+ .state-button {
+ padding: $gl-padding / 2;
+ }
}
.environments-actions {
@@ -248,6 +256,78 @@
}
}
+.prometheus-graph-cursor {
+ position: absolute;
+ background: $theme-gray-600;
+ width: 1px;
+}
+
+.prometheus-graph-flag {
+ display: block;
+ min-width: 160px;
+
+ h5 {
+ padding: 0;
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.2;
+ }
+
+ .deploy-meta-content {
+ border-bottom: 1px solid $white-dark;
+
+ svg {
+ height: 15px;
+ vertical-align: bottom;
+ }
+ }
+
+ &.popover {
+ &.left {
+ left: auto;
+ right: 0;
+ margin-right: 10px;
+ }
+
+ &.right {
+ left: 0;
+ right: auto;
+ margin-left: 10px;
+ }
+
+ > .arrow {
+ top: 40px;
+ }
+
+ > .popover-title,
+ > .popover-content {
+ padding: 5px 8px;
+ font-size: 12px;
+ white-space: nowrap;
+ }
+ }
+}
+
+.prometheus-table {
+ border-collapse: collapse;
+ padding: 0;
+ margin: 0;
+
+ td {
+ vertical-align: middle;
+
+ + td {
+ padding-left: 5px;
+ vertical-align: top;
+ }
+ }
+
+ .legend-metric-title {
+ font-size: 12px;
+ vertical-align: middle;
+ }
+}
+
.prometheus-svg-container {
position: relative;
height: 0;
@@ -255,8 +335,7 @@
padding: 0;
padding-bottom: 100%;
- .text-metric-usage,
- .legend-metric-title {
+ .text-metric-usage {
fill: $black;
font-weight: $gl-font-weight-normal;
font-size: 12px;
@@ -294,14 +373,11 @@
}
> text {
- font-size: 12px;
+ fill: $theme-gray-600;
+ font-size: 10px;
}
}
- .text-metric-title {
- font-size: 12px;
- }
-
.y-label-text,
.x-label-text {
fill: $gray-darkest;
@@ -338,3 +414,7 @@
}
}
}
+
+.prometheus-table-row-highlight {
+ background-color: $prometheus-table-row-highlight-color;
+}
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index eea8b7dd193..d9267f5cdf3 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -72,7 +72,6 @@
.label {
color: $gl-text-color;
- font-size: inherit;
}
iframe.twitter-share-button {
@@ -159,22 +158,18 @@
}
}
-
/*
* Last push widget
*/
.event-last-push {
- overflow: auto;
width: 100%;
+ display: flex;
+ align-items: center;
.event-last-push-text {
@include str-truncated(100%);
- padding: 4px 0;
font-size: 13px;
- float: left;
- margin-right: -150px;
- padding-right: 150px;
- line-height: 20px;
+ margin-right: $gl-padding;
}
}
@@ -182,6 +177,12 @@
.event-item {
padding-left: 0;
+ &.event-inline {
+ .event-title {
+ line-height: 20px;
+ }
+ }
+
.event-title {
white-space: normal;
overflow: visible;
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index f9a761e85fe..6ee8b33bd39 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -224,3 +224,16 @@
border-radius: $label-border-radius;
font-weight: $gl-font-weight-normal;
}
+
+.js-groups-dropdown {
+ width: 100%;
+}
+
+.dropdown-group-transfer {
+ bottom: 100%;
+ top: initial;
+
+ .dropdown-content {
+ overflow-y: unset;
+ }
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index e1637618ab2..2c0ed976301 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -102,12 +102,12 @@
.issuable-show-labels {
a {
- margin-right: 5px;
margin-bottom: 5px;
+ margin-right: 5px;
display: inline-block;
.color-label {
- padding: 6px 10px;
+ padding: 4px $grid-size;
border-radius: $label-border-radius;
}
@@ -117,6 +117,12 @@
}
&.has-labels {
+ // this font size is a fix to
+ // prevent unintended spacing between labels
+ // which shows up when rendering markup has white-space
+ // characters present.
+ // see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3
+ font-size: 0;
margin-bottom: -5px;
}
}
@@ -131,12 +137,22 @@
z-index: 200;
overflow: hidden;
- a:not(.btn-retry),
- .btn-link {
+ a:not(.btn) {
color: inherit;
+
+ &:hover {
+ color: $gl-link-hover-color;
+
+ .avatar {
+ border-color: rgba($avatar-border, .2);
+ }
+
+ }
+
}
.btn-link {
+ color: inherit;
outline: none;
}
@@ -162,10 +178,6 @@
border: 0;
}
- span {
- display: inline-block;
- }
-
.select2-container span {
margin-top: 0;
}
@@ -201,11 +213,18 @@
margin-left: 0;
}
+ a.edit-link:not([href]):hover {
+ color: rgba($avatar-border, .2);
+ }
+
+ .lock-edit, // uses same style, different js behaviour
.edit-link {
+ @extend .btn-blank;
color: $gl-text-color;
- &:not([href]):hover {
- color: rgba($avatar-border, .2);
+ &:hover {
+ text-decoration: underline;
+ color: $gl-link-hover-color;
}
}
}
@@ -303,7 +322,6 @@
.gutter-toggle {
margin-top: 7px;
border-left: 1px solid $border-gray-normal;
- padding-left: 0;
text-align: center;
}
@@ -478,16 +496,6 @@
}
}
- a:not(.btn-retry) {
- &:hover {
- color: $md-link-color;
-
- .avatar {
- border-color: rgba($avatar-border, .2);
- }
- }
- }
-
.dropdown-menu-toggle {
width: 100%;
padding-top: 6px;
@@ -495,15 +503,25 @@
.dropdown-menu {
width: 100%;
+
+ /*
+ * Overwrite hover style for dropdown items, so that they are not blue
+ * This should be removed during dev of https://gitlab.com/gitlab-org/gitlab-ce/issues/44040
+ */
+ li a {
+ &:hover,
+ &:active,
+ &:focus,
+ &.is-focused {
+ @include dropdown-item-hover;
+ }
+ }
+
}
}
.with-performance-bar .right-sidebar {
top: $header-height + $performance-bar-height;
-
- .issuable-sidebar {
- height: calc(100% - #{$performance-bar-height});
- }
}
.sidebar-move-issue-confirmation-button {
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index c48e58af691..b9390450477 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -13,10 +13,20 @@
display: inline-block;
}
+ .issuable-meta {
+ .author_link {
+ display: inline-block;
+ }
+
+ .issuable-comments {
+ height: 18px;
+ }
+ }
+
.icon-merge-request-unmerged {
height: 13px;
margin-bottom: 3px;
- }
+ }
}
}
@@ -181,11 +191,6 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap {
- .branch-message,
- .ref-message {
- display: none;
- }
-
.ref::selection {
color: $placeholder-text-color;
}
@@ -216,6 +221,17 @@ ul.related-merge-requests > li {
transform: translateY(0);
display: none;
margin-top: 4px;
+
+ // override dropdown item styles
+ .btn.btn-success {
+ @include btn-default;
+ @include btn-green;
+
+ border-style: solid;
+ border-width: 1px;
+ line-height: $line-height-base;
+ width: auto;
+ }
}
.create-merge-request-dropdown-toggle {
@@ -225,66 +241,6 @@ ul.related-merge-requests > li {
margin-left: 0;
}
}
-
- .droplab-item-ignore {
- pointer-events: auto;
- }
-
- .create-item {
- cursor: pointer;
- margin: 0 1px;
-
- &:hover,
- &:focus {
- background-color: $dropdown-item-hover-bg;
- color: $gl-text-color;
- }
- }
-
- li.divider {
- margin: 8px 10px;
- }
-
- li:not(.divider) {
- padding: 8px 9px;
-
- &:last-child {
- padding-bottom: 8px;
- }
-
- &.droplab-item-selected {
- .icon-container {
- i {
- visibility: visible;
- }
- }
-
- .description {
- display: block;
- }
- }
-
- &.droplab-item-ignore {
- padding-top: 8px;
- }
-
- .icon-container {
- float: left;
-
- i {
- visibility: hidden;
- }
- }
-
- .description {
- padding-left: 22px;
- }
-
- input,
- span {
- margin: 4px 0 0;
- }
- }
}
.discussion-reply-holder .note-edit-form {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index e8cd8a4905c..b0852adb459 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -26,9 +26,15 @@
}
}
+.dropdown-menu-labels {
+ .dropdown-content {
+ max-height: 135px;
+ }
+}
+
.dropdown-new-label {
.dropdown-content {
- max-height: 260px;
+ max-height: 136px;
}
}
@@ -58,13 +64,13 @@
@media (min-width: $screen-sm-min) {
width: 200px;
+ margin-left: $gl-padding * 2;
margin-bottom: 0;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
- vertical-align: middle;
max-width: 100%;
}
}
@@ -79,32 +85,42 @@
width: 100px;
margin-left: 10px;
margin-bottom: 0;
- vertical-align: middle;
+ vertical-align: top;
}
}
.label-description {
display: block;
margin-bottom: 10px;
- margin-left: 50px;
+
+ .description-text {
+ margin-bottom: $gl-padding;
+ }
+
+ a {
+ color: $blue-600;
+ }
@media (min-width: $screen-sm-min) {
display: inline-block;
- width: 30%;
+ max-width: 50%;
margin-left: 10px;
margin-bottom: 0;
- vertical-align: middle;
+ vertical-align: top;
}
}
.label {
- padding: 8px 9px 9px;
- font-size: 14px;
+ padding: 4px $grid-size;
+ font-size: $label-font-size;
+ position: relative;
+ top: ($grid-size / 2);
}
}
.color-label {
- padding: 3px $label-padding;
+ padding: 0 $grid-size;
+ line-height: 16px;
border-radius: $label-border-radius;
}
@@ -116,6 +132,12 @@
}
.manage-labels-list {
+ @media(min-width: $screen-md-min) {
+ &.content-list li {
+ padding: $gl-padding 0;
+ }
+ }
+
> li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
cursor: move;
@@ -133,8 +155,6 @@
}
.btn-action {
- color: $gl-text-color;
-
.fa {
font-size: 18px;
vertical-align: middle;
@@ -155,10 +175,18 @@
float: right;
}
}
+
+ @media (max-width: $screen-xs-max) {
+ .dropdown-menu {
+ min-width: 100%;
+ }
+ }
}
.draggable-handler {
display: inline-block;
+ vertical-align: top;
+ margin: 5px 0;
opacity: 0;
transition: opacity .3s;
color: $gray-darkest;
@@ -188,7 +216,7 @@
.toggle-priority {
display: inline-block;
- vertical-align: middle;
+ vertical-align: top;
button {
border-color: transparent;
@@ -255,6 +283,11 @@
}
.label-subscribe-button {
+ @media(min-width: $screen-md-min) {
+ min-width: 105px;
+ margin-left: $gl-padding;
+ }
+
.label-subscribe-button-icon {
&[disabled] {
opacity: 0.5;
@@ -278,10 +311,11 @@
}
.label-link {
- display: inline-block;
+ display: inline-flex;
vertical-align: top;
.label {
vertical-align: inherit;
+ font-size: $label-font-size;
}
}
diff --git a/app/assets/stylesheets/pages/lint.scss b/app/assets/stylesheets/pages/lint.scss
deleted file mode 100644
index 68b6c5ecbd4..00000000000
--- a/app/assets/stylesheets/pages/lint.scss
+++ /dev/null
@@ -1,21 +0,0 @@
-.ci-body {
- .incorrect-syntax {
- font-size: 18px;
- color: $lint-incorrect-color;
- }
-
- .correct-syntax {
- font-size: 18px;
- color: $lint-correct-color;
- }
-}
-
-.ci-linter {
- .ci-editor {
- height: 400px;
- }
-
- .ci-template pre {
- white-space: pre-wrap;
- }
-}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index e75a35d78ad..4692d0fb873 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -718,8 +718,47 @@
}
.mr-memory-usage {
+ width: 100%;
+
p.usage-info-loading .usage-info-load-spinner {
margin-right: 10px;
font-size: 16px;
}
}
+
+.fork-sprite {
+ margin-right: -5px;
+}
+
+.deploy-heading {
+ .media-body {
+ min-width: 0;
+ }
+}
+
+.deploy-body {
+ display: flex;
+ flex-wrap: wrap;
+
+ @media (min-width: $screen-xs) {
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ }
+
+ > *:not(:last-child) {
+ margin-right: .3em;
+ }
+}
+
+.deploy-link {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 100px;
+ max-width: 150px;
+
+ @media (min-width: $screen-xs) {
+ min-width: 0;
+ max-width: 100%;
+ }
+}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index ae8fa45a2d7..e5afa8fffcb 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -115,6 +115,10 @@
display: block;
margin-top: 7px;
+ .issue-link {
+ display: inline-block;
+ }
+
.issuable-number {
color: $gl-text-color-secondary;
margin-right: 5px;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 6d4ccd53e12..8720f821ce9 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -173,11 +173,7 @@
}
.discussion-form {
- background-color: $white-light;
-}
-
-.discussion-form-container {
- padding: $gl-padding-top $gl-padding $gl-padding;
+ padding-top: $gl-padding-top;
}
.discussion-notes .disabled-comment {
@@ -237,12 +233,7 @@
.discussion-body,
.diff-file {
.discussion-reply-holder {
- background-color: $white-light;
- padding: 10px 16px;
-
- &.is-replying {
- padding-bottom: $gl-padding;
- }
+ padding-top: $gl-padding;
}
}
@@ -391,11 +382,17 @@
.dropdown-toggle {
float: right;
- .toggle-icon {
+ i {
color: $white-light;
padding-right: 2px;
margin-top: 2px;
}
+
+ &[disabled] {
+ i {
+ color: $gl-text-color-disabled;
+ }
+ }
}
.dropdown-menu {
@@ -404,7 +401,6 @@
width: 298px;
}
-
@media (max-width: $screen-xs-max) {
display: flex;
width: 100%;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 26e6e8688b6..9d9cbecc958 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -16,7 +16,7 @@ ul.notes {
.note-created-ago,
.note-updated-at {
- white-space: nowrap;
+ white-space: normal;
}
.discussion-body {
@@ -47,7 +47,7 @@ ul.notes {
}
.timeline-entry-inner {
- padding: $gl-padding $gl-btn-padding;
+ padding: $gl-padding 0;
border-bottom: 1px solid $white-normal;
}
@@ -94,12 +94,6 @@ ul.notes {
}
}
- &.note-discussion {
- .timeline-entry-inner {
- padding: $gl-padding 10px;
- }
- }
-
.editing-spinner {
display: none;
}
@@ -140,12 +134,6 @@ ul.notes {
@include bulleted-list;
word-wrap: break-word;
- ul.task-list {
- ul:not(.task-list) {
- padding-left: 1.3em;
- }
- }
-
table {
@include markdown-table;
}
@@ -358,6 +346,8 @@ ul.notes {
}
.discussion-notes {
+ background-color: $white-light;
+
&:not(:first-child) {
border-top: 1px solid $white-normal;
margin-top: 20px;
@@ -369,10 +359,6 @@ ul.notes {
}
}
- .notes {
- background-color: $white-light;
- }
-
a code {
top: 0;
margin-right: 0;
@@ -653,8 +639,6 @@ ul.notes {
border-bottom: 1px solid $white-normal;
.timeline-entry-inner {
- padding-left: $gl-padding;
- padding-right: $gl-padding;
border-bottom: 0;
}
}
@@ -723,7 +707,7 @@ ul.notes {
.line-resolve-all {
vertical-align: middle;
display: inline-block;
- padding: 5px 10px 6px;
+ padding: 6px 10px;
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-default;
diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss
new file mode 100644
index 00000000000..fb42dee66d2
--- /dev/null
+++ b/app/assets/stylesheets/pages/pages.scss
@@ -0,0 +1,60 @@
+.pages-domain-list {
+ &-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+
+ .domain-status {
+ display: inline-flex;
+ left: $gl-padding;
+ position: absolute;
+ }
+
+ .domain-name {
+ flex-grow: 1;
+ }
+
+ }
+
+ &.has-verification-status > li {
+ padding-left: 3 * $gl-padding;
+ }
+
+}
+
+.status-badge {
+
+ display: inline-flex;
+ margin-bottom: $gl-padding-8;
+
+ // Most of the following settings "stolen" from btn-sm
+ // Border radius is overwritten for both
+ .label,
+ .btn {
+ padding: $gl-padding-4 $gl-padding-8;
+ font-size: $gl-font-size;
+ line-height: $gl-btn-line-height;
+ border-radius: 0;
+ display: flex;
+ align-items: center;
+ }
+
+ .btn svg {
+ top: auto;
+ }
+
+ :first-child {
+ border-bottom-left-radius: $border-radius-default;
+ border-top-left-radius: $border-radius-default;
+ }
+
+ :not(:first-child) {
+ border-left: 0;
+ }
+
+ :last-child {
+ border-bottom-right-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
+ }
+
+}
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss
index 7e2297c283f..bc7fa8a26d9 100644
--- a/app/assets/stylesheets/pages/pipeline_schedules.scss
+++ b/app/assets/stylesheets/pages/pipeline_schedules.scss
@@ -39,6 +39,10 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ }
}
.next-run-cell {
@@ -74,84 +78,3 @@
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 9805fc4f882..8d5eb2e8c5a 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -48,7 +48,7 @@
}
.dropdown-menu {
- max-height: 250px;
+ max-height: $dropdown-max-height;
overflow-y: auto;
}
@@ -69,13 +69,6 @@
border-color: $border-white-normal;
}
}
-
- .btn {
- .icon-play {
- height: 13px;
- width: 12px;
- }
- }
}
.btn .text-center {
@@ -128,7 +121,7 @@
.ref-name {
font-weight: $gl-font-weight-bold;
- max-width: 120px;
+ max-width: 100px;
overflow: hidden;
display: inline-block;
white-space: nowrap;
@@ -143,6 +136,12 @@
fill: $gl-text-color-secondary;
}
+ .sprite {
+ width: 12px;
+ height: 12px;
+ fill: $gl-text-color;
+ }
+
.fa {
font-size: 12px;
color: $gl-text-color;
@@ -229,7 +228,7 @@
.stage-cell {
&.table-section {
@media (min-width: $screen-md-min) {
- min-width: 148px;
+ min-width: 160px; /* Hack alert: Without this the mini graph pipeline won't work properly*/
margin-right: -4px;
}
}
@@ -321,14 +320,17 @@
}
}
-// Pipeline graph
-.pipeline-graph {
+.pipeline-tab-content {
width: 100%;
background-color: $gray-light;
padding: $gl-padding;
+ overflow: auto;
+}
+
+// Pipeline graph
+.pipeline-graph {
white-space: nowrap;
transition: max-height 0.3s, padding 0.3s;
- overflow: auto;
.stage-column-list,
.builds-container > ul {
@@ -493,17 +495,17 @@
svg {
fill: $gl-text-color-secondary;
position: relative;
- left: 5px;
- top: 2px;
- width: 18px;
- height: 18px;
+ left: 1px;
+ top: -1px;
+ width: 16px;
+ height: 16px;
}
&.play {
svg {
- width: #{$ci-action-icon-size - 8};
- height: #{$ci-action-icon-size - 8};
- left: 8px;
+ width: 16px;
+ height: 16px;
+ left: 3px;
}
}
}
@@ -704,8 +706,8 @@ button.mini-pipeline-graph-dropdown-toggle {
// dropdown content for big and mini pipeline
.big-pipeline-graph-dropdown-menu,
.mini-pipeline-graph-dropdown-menu {
- width: 195px;
- max-width: 195px;
+ width: 240px;
+ max-width: 240px;
.scrollable-menu {
padding: 0;
@@ -748,7 +750,7 @@ button.mini-pipeline-graph-dropdown-toggle {
height: #{$ci-action-icon-size - 6};
left: -3px;
position: relative;
- top: -2px;
+ top: -1px;
&.icon-action-stop,
&.icon-action-cancel {
@@ -792,7 +794,6 @@ button.mini-pipeline-graph-dropdown-toggle {
// link to the build
.mini-pipeline-graph-dropdown-item {
- padding: 3px 7px 4px;
align-items: center;
clear: both;
display: flex;
@@ -930,13 +931,11 @@ button.mini-pipeline-graph-dropdown-toggle {
*/
&.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;
}
}
}
@@ -987,3 +986,11 @@ button.mini-pipeline-graph-dropdown-toggle {
font-weight: $gl-font-weight-normal;
line-height: 1.5;
}
+
+.legend-all {
+ color: $gl-text-color-secondary;
+}
+
+.legend-success {
+ color: $green-500;
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index ac745019319..b199f9876d3 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -210,13 +210,8 @@
}
.created-personal-access-token-container {
- #created-personal-access-token {
- width: 90%;
- display: inline;
- }
-
.btn-clipboard {
- margin-left: 5px;
+ border: 1px solid $border-color;
}
}
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index c197494b152..68d40b56133 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -20,6 +20,22 @@
}
}
+.multi-file-editor-options {
+ label {
+ margin-right: 20px;
+ text-align: center;
+ }
+
+ .preview {
+ font-size: 0;
+
+ img {
+ border: 1px solid $border-color-settings;
+ border-radius: 4px;
+ }
+ }
+}
+
.application-theme {
label {
margin-right: 20px;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 6f4c678c4b8..790e91e4431 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -9,7 +9,6 @@
.new_project,
.edit-project,
.import-project {
-
.help-block {
margin-bottom: 10px;
}
@@ -18,18 +17,25 @@
border-radius: $border-radius-base;
}
- .input-group > div {
+ .input-group {
+ display: flex;
- &:last-child {
- padding-right: 0;
+ .select2-container {
+ display: unset;
+ max-width: unset;
+ width: unset !important;
+ flex-grow: 1;
+ }
+
+ > div {
+ &:last-child {
+ padding-right: 0;
+ }
}
}
@media (max-width: $screen-xs-max) {
.input-group > div {
-
- margin-bottom: 14px;
-
&:last-child {
margin-bottom: 0;
}
@@ -41,17 +47,24 @@
}
.input-group-addon {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: unset;
+ width: unset;
+ max-width: 50%;
+ text-align: left;
&.static-namespace {
height: 35px;
border-radius: 3px;
border: 1px solid $border-color;
+ max-width: 100%;
+ flex-grow: 1;
}
+ .select2 a,
+ .btn-default {
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
+ border-radius: 0 $border-radius-base $border-radius-base 0;
}
}
}
@@ -290,7 +303,7 @@
font-size: 13px;
font-weight: $gl-font-weight-bold;
line-height: 13px;
- letter-spacing: .4px;
+ letter-spacing: 0.4px;
padding: 6px 14px;
text-align: center;
vertical-align: middle;
@@ -322,13 +335,6 @@
}
}
-.project-repo-buttons {
- .project-action-button .dropdown-menu {
- max-height: 250px;
- overflow-y: auto;
- }
-}
-
.split-one {
display: inline-table;
margin-right: 12px;
@@ -450,7 +456,7 @@ a.deploy-project-label {
text-decoration: none;
&.disabled {
- opacity: .3;
+ opacity: 0.3;
cursor: not-allowed;
}
}
@@ -607,26 +613,26 @@ a.deploy-project-label {
}
.first-column {
- @media(min-width: $screen-xs-min) {
+ @media (min-width: $screen-xs-min) {
max-width: 50%;
padding-right: 30px;
}
- @media(max-width: $screen-xs-max) {
+ @media (max-width: $screen-xs-max) {
max-width: 100%;
width: 100%;
}
}
.second-column {
- @media(min-width: $screen-xs-min) {
+ @media (min-width: $screen-xs-min) {
width: 50%;
flex: 1;
padding-left: 30px;
position: relative;
}
- @media(max-width: $screen-xs-max) {
+ @media (max-width: $screen-xs-max) {
max-width: 100%;
width: 100%;
padding-left: 0;
@@ -639,7 +645,7 @@ a.deploy-project-label {
}
&::before {
- content: "OR";
+ content: 'OR';
position: absolute;
left: -10px;
top: 50%;
@@ -663,7 +669,7 @@ a.deploy-project-label {
}
&::after {
- content: "";
+ content: '';
position: absolute;
background-color: $border-color;
bottom: 0;
@@ -685,6 +691,9 @@ a.deploy-project-label {
}
}
+.project-empty-note-panel {
+ border-bottom: 1px solid $border-color;
+}
.project-stats {
font-size: 0;
@@ -693,11 +702,13 @@ a.deploy-project-label {
border-bottom: 1px solid $border-color;
.nav {
- padding-top: 12px;
- padding-bottom: 12px;
+ margin-top: $gl-padding-8;
+ margin-bottom: $gl-padding-8;
> li {
display: inline-block;
+ margin-top: $gl-padding-4;
+ margin-bottom: $gl-padding-4;
&:not(:last-child) {
margin-right: $gl-padding;
@@ -711,36 +722,32 @@ a.deploy-project-label {
float: right;
}
}
+ }
- > a {
- padding: 0;
- background-color: transparent;
- font-size: 14px;
- line-height: 29px;
- color: $notes-light-color;
+ .stat-text,
+ .stat-link {
+ padding: $gl-btn-vert-padding 0;
+ background-color: transparent;
+ font-size: $gl-font-size;
+ line-height: $gl-btn-line-height;
+ color: $notes-light-color;
+ }
- &:hover,
- &:focus {
- color: $gl-text-color;
- text-decoration: underline;
- }
+ .stat-link {
+ &:hover,
+ &:focus {
+ color: $gl-text-color;
+ text-decoration: underline;
}
}
- }
-
- li.missing {
- border: 1px dashed $border-gray-normal-dashed;
- border-radius: $border-radius-default;
- a {
- padding-left: 10px;
- padding-right: 10px;
- color: $notes-light-color;
- display: block;
+ .btn {
+ padding: $gl-btn-vert-padding $gl-btn-horz-padding;
+ line-height: $gl-btn-line-height;
}
- &:hover {
- background-color: $gray-normal;
+ .btn-missing {
+ @extend .btn-missing;
}
}
}
@@ -750,7 +757,7 @@ pre.light-well {
}
.git-empty {
- margin: 0 7px 7px;
+ margin-bottom: 7px;
h5 {
color: $gl-text-color;
@@ -902,15 +909,10 @@ pre.light-well {
}
}
-.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;
+.project-tip-command {
+ > .input-group-btn:first-child {
+ width: auto;
+ }
}
.protected-branches-list,
@@ -932,10 +934,7 @@ pre.light-well {
border-right: solid 1px transparent;
}
}
-}
-.protected-tags-list,
-.protected-branches-list {
.dropdown-menu-toggle {
width: 100%;
max-width: 300px;
@@ -1122,3 +1121,33 @@ pre.light-well {
padding-top: $gl-padding;
padding-bottom: 37px;
}
+
+.project-ci-body {
+ .incorrect-syntax {
+ font-size: 18px;
+ color: $lint-incorrect-color;
+ }
+
+ .correct-syntax {
+ font-size: 18px;
+ color: $lint-correct-color;
+ }
+}
+
+.project-ci-linter {
+ .ci-editor {
+ height: 400px;
+ }
+
+ .ci-template pre {
+ white-space: pre-wrap;
+ }
+}
+
+.project-badge {
+ opacity: 0.9;
+
+ &:hover {
+ opacity: 1;
+ }
+}
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index da3c2d7fa5d..a414deb8921 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -16,16 +16,10 @@
display: inline-block;
}
-@media (min-width: $screen-md-min) {
- .blob-viewer[data-type="rich"] {
- margin: 20px;
- }
-}
-
.ide-view {
display: flex;
height: calc(100vh - #{$header-height});
- color: $almost-black;
+ margin-top: 0;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
@@ -34,10 +28,11 @@
max-width: 250px;
}
}
-}
-.with-performance-bar .ide-view {
- height: calc(100vh - #{$header-height});
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ }
}
.ide-file-list {
@@ -47,34 +42,52 @@
cursor: pointer;
&.file-open {
- background: $white-normal;
+ background: $link-active-background;
+ }
+
+ &.file-active {
+ font-weight: $gl-font-weight-bold;
}
- .repo-file-name {
+ .ide-file-name {
+ flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
+ max-width: inherit;
+
+ svg {
+ vertical-align: middle;
+ margin-right: 2px;
+ }
+
+ .loading-container {
+ margin-right: 4px;
+ display: inline-block;
+ }
}
- .unsaved-icon {
- color: $indigo-700;
- float: right;
- font-size: smaller;
- line-height: 20px;
+ .ide-file-changed-icon {
+ margin-left: auto;
}
- .repo-new-btn {
+ .ide-new-btn {
display: none;
- margin-top: -4px;
margin-bottom: -4px;
+ margin-right: -8px;
}
- &:hover {
- .repo-new-btn {
+ &:hover,
+ &:focus {
+ background: $link-active-background;
+
+ .ide-new-btn {
display: block;
}
+ }
- .unsaved-icon {
- display: none;
+ &.folder {
+ svg {
+ fill: $gl-text-color-secondary;
}
}
}
@@ -89,15 +102,24 @@
}
}
-.multi-file-table-name,
-.multi-file-table-col-commit-message {
+.file-name,
+.file-col-commit-message {
+ display: flex;
overflow: visible;
- max-width: 0;
padding: 6px 12px;
}
-.multi-file-table-name {
- width: 350px;
+.multi-file-loading-container {
+ margin-top: 10px;
+ padding: 10px;
+
+ .animation-container {
+ background: $gray-light;
+
+ div {
+ background: $gray-light;
+ }
+ }
}
.multi-file-table-col-commit-message {
@@ -115,13 +137,35 @@
.multi-file-tabs {
display: flex;
- overflow-x: auto;
background-color: $white-normal;
box-shadow: inset 0 -1px $white-dark;
- > li {
+ > ul {
+ display: flex;
+ overflow-x: auto;
+ }
+
+ li {
position: relative;
}
+
+ .dropdown {
+ display: flex;
+ margin-left: auto;
+ margin-bottom: 1px;
+ padding: 0 $grid-size;
+ border-left: 1px solid $white-dark;
+ background-color: $white-light;
+
+ &.shadow {
+ box-shadow: 0 0 10px $dropdown-shadow-color;
+ }
+
+ .btn {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
}
.multi-file-tab {
@@ -132,6 +176,10 @@
border-bottom: 1px solid $white-dark;
cursor: pointer;
+ svg {
+ vertical-align: middle;
+ }
+
&.active {
background-color: $white-light;
border-bottom-color: $white-light;
@@ -142,20 +190,32 @@
position: absolute;
right: 8px;
top: 50%;
+ width: 16px;
+ height: 16px;
padding: 0;
background: none;
border: 0;
- font-size: $gl-font-size;
- color: $gray-darkest;
+ border-radius: $border-radius-default;
+ color: $theme-gray-900;
transform: translateY(-50%);
- &:not(.modified):hover,
- &:not(.modified):focus {
- color: $hint-color;
+ svg {
+ position: relative;
+ top: -1px;
}
- &.modified {
- color: $indigo-700;
+ &:hover {
+ background-color: $theme-gray-200;
+ }
+
+ &:focus {
+ background-color: $blue-500;
+ color: $white-light;
+ outline: 0;
+
+ svg {
+ fill: currentColor;
+ }
}
}
@@ -174,20 +234,147 @@
.vertical-center {
min-height: auto;
}
+
+ .monaco-editor .lines-content .cigr {
+ display: none;
+ }
+
+ .monaco-diff-editor.vs {
+ .editor.modified {
+ box-shadow: none;
+ }
+
+ .diagonal-fill {
+ display: none !important;
+ }
+
+ .diffOverview {
+ background-color: $white-light;
+ border-left: 1px solid $white-dark;
+ cursor: ns-resize;
+ }
+
+ .diffViewport {
+ display: none;
+ }
+
+ .char-insert {
+ background-color: $line-added-dark;
+ }
+
+ .char-delete {
+ background-color: $line-removed-dark;
+ }
+
+ .line-numbers {
+ color: $black-transparent;
+ }
+
+ .view-overlays {
+ .line-insert {
+ background-color: $line-added;
+ }
+
+ .line-delete {
+ background-color: $line-removed;
+ }
+ }
+
+ .margin {
+ background-color: $gray-light;
+ border-right: 1px solid $white-normal;
+
+ .line-insert {
+ border-right: 1px solid $line-added-dark;
+ }
+
+ .line-delete {
+ border-right: 1px solid $line-removed-dark;
+ }
+ }
+
+ .margin-view-overlays .insert-sign,
+ .margin-view-overlays .delete-sign {
+ opacity: 0.4;
+ }
+
+ .cursors-layer {
+ display: none;
+ }
+ }
}
.multi-file-editor-holder {
height: 100%;
}
-.multi-file-editor-btn-group {
- padding: $gl-bar-padding $gl-padding;
- border-top: 1px solid $white-dark;
+.preview-container {
+ height: 100%;
+ overflow: auto;
+
+ .file-container {
+ background-color: $gray-darker;
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+
+ text-align: center;
+
+ .file-content {
+ padding: $gl-padding;
+ max-width: 100%;
+ max-height: 100%;
+
+ img {
+ max-width: 90%;
+ max-height: 90%;
+ }
+
+ .isZoomable {
+ cursor: pointer;
+ cursor: zoom-in;
+
+ &.isZoomed {
+ cursor: pointer;
+ cursor: zoom-out;
+ max-width: none;
+ max-height: none;
+ margin-right: $gl-padding;
+ }
+ }
+ }
+
+ .file-info {
+ font-size: $label-font-size;
+ color: $diff-image-info-color;
+ }
+ }
+
+ .md-previewer {
+ padding: $gl-padding;
+ }
+}
+
+.ide-mode-tabs {
border-bottom: 1px solid $white-dark;
- background: $white-light;
+
+ .nav-links {
+ border-bottom: 0;
+
+ li a {
+ padding: $gl-padding-8 $gl-padding;
+ line-height: $gl-btn-line-height;
+ }
+ }
+}
+
+.ide-btn-group {
+ padding: $gl-padding-4 $gl-vert-padding;
}
.ide-status-bar {
+ border-top: 1px solid $white-dark;
padding: $gl-bar-padding $gl-padding;
background: $white-light;
display: flex;
@@ -232,16 +419,21 @@
.multi-file-commit-panel {
display: flex;
+ position: relative;
flex-direction: column;
- height: 100%;
- width: 290px;
+ width: 340px;
padding: 0;
background-color: $gray-light;
- border-left: 1px solid $white-dark;
+ padding-right: 3px;
.projects-sidebar {
display: flex;
flex-direction: column;
+
+ .context-header {
+ width: auto;
+ margin-right: 0;
+ }
}
.multi-file-commit-panel-inner {
@@ -276,7 +468,7 @@
}
.branch-container {
- border-left: 4px solid $indigo-700;
+ border-left: 4px solid;
margin-bottom: $gl-bar-padding;
}
@@ -288,7 +480,6 @@
.branch-header-title {
flex: 1;
padding: $grid-size $gl-padding;
- color: $indigo-700;
font-weight: $gl-font-weight-bold;
svg {
@@ -325,12 +516,19 @@
display: flex;
flex-direction: column;
flex: 1;
+ max-height: 100%;
+ overflow: auto;
+}
+
+.multi-file-commit-empty-state-container {
+ align-items: center;
+ justify-content: center;
}
.multi-file-commit-panel-header {
display: flex;
align-items: center;
- margin-bottom: 12px;
+ margin-bottom: 0;
border-bottom: 1px solid $white-dark;
padding: $gl-btn-padding 0;
@@ -353,7 +551,7 @@
.multi-file-commit-panel-header-title {
display: flex;
flex: 1;
- padding: $gl-btn-padding;
+ padding: 0 $gl-btn-padding;
svg {
margin-right: $gl-btn-padding;
@@ -367,12 +565,34 @@
.multi-file-commit-list {
flex: 1;
overflow: auto;
- padding: $gl-padding;
+ padding: $gl-padding 0;
+ min-height: 60px;
}
.multi-file-commit-list-item {
display: flex;
+ padding: 0;
align-items: center;
+
+ .multi-file-discard-btn {
+ display: none;
+ margin-left: auto;
+ color: $gl-link-color;
+ padding: 0 2px;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &:hover {
+ background: $white-normal;
+
+ .multi-file-discard-btn {
+ display: block;
+ }
+ }
}
.multi-file-addition {
@@ -391,29 +611,58 @@
margin-left: auto;
margin-right: auto;
}
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ margin-left: 3px;
+ }
}
.multi-file-commit-list-path {
+ padding: $grid-size / 2;
+ padding-left: $gl-padding;
+ background: none;
+ border: 0;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+
+ svg {
+ min-width: 16px;
+ vertical-align: middle;
+ display: inline-block;
+ }
+
+ &:hover,
+ &:focus {
+ outline: 0;
+ }
+}
+
+.multi-file-commit-list-file-path {
@include str-truncated(100%);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ text-decoration: none;
+ }
}
.multi-file-commit-form {
padding: $gl-padding;
border-top: 1px solid $white-dark;
-}
-
-.multi-file-commit-fieldset {
- display: flex;
- align-items: center;
- padding-bottom: 12px;
.btn {
- flex: 1;
+ font-size: $gl-font-size;
}
}
.multi-file-commit-message.form-control {
- height: 80px;
+ height: 160px;
resize: none;
}
@@ -445,7 +694,7 @@
top: 0;
width: 100px;
height: 1px;
- background-color: rgba($red-500, .5);
+ background-color: rgba($red-500, 0.5);
}
}
}
@@ -464,7 +713,7 @@
justify-content: center;
}
-.repo-new-btn {
+.ide-new-btn {
.dropdown-toggle svg {
margin-top: -2px;
margin-bottom: 2px;
@@ -482,7 +731,110 @@
}
}
-.ide-flash-container.flash-container {
- margin-top: $header-height;
- margin-bottom: 0;
+.ide {
+ overflow: hidden;
+
+ &.nav-only {
+ padding-top: $header-height;
+
+ .with-performance-bar & {
+ padding-top: $header-height + $performance-bar-height;
+ }
+
+ .flash-container {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+
+ .alert-wrapper .flash-container .flash-alert:last-child,
+ .alert-wrapper .flash-container .flash-notice:last-child {
+ margin-bottom: 0;
+ }
+
+ .content-wrapper {
+ margin-top: 0;
+ padding-bottom: 0;
+ }
+
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $flash-height});
+ }
+ }
+
+ .projects-sidebar {
+ .multi-file-commit-panel-inner-scroll {
+ flex: 1;
+ }
+ }
+ }
+}
+
+.with-performance-bar .ide.nav-only {
+ .flash-container {
+ margin-top: 0;
+ }
+
+ .content-wrapper {
+ margin-top: 0;
+ padding-bottom: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $performance-bar-height});
+ }
+
+ &.flash-shown {
+ .ide-view {
+ height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height});
+ }
+ }
+}
+
+.dragHandle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background-color: $white-dark;
+
+ &.dragright {
+ right: 0;
+ }
+
+ &.dragleft {
+ left: 0;
+ }
+}
+
+.ide-commit-radios {
+ label {
+ font-weight: normal;
+ }
+
+ .help-block {
+ margin-top: 0;
+ line-height: 0;
+ }
+}
+
+.ide-commit-new-branch {
+ margin-left: 25px;
+}
+
+.ide-external-links {
+ p {
+ margin: 0;
+ }
+}
+
+.ide-sidebar-link {
+ padding: $gl-padding-8 $gl-padding;
+ display: flex;
+ align-items: center;
+ font-weight: $gl-font-weight-bold;
}
diff --git a/app/assets/stylesheets/pages/repo.scss.orig b/app/assets/stylesheets/pages/repo.scss.orig
new file mode 100644
index 00000000000..57b995adb64
--- /dev/null
+++ b/app/assets/stylesheets/pages/repo.scss.orig
@@ -0,0 +1,786 @@
+.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;
+}
+
+.ide-view {
+ display: flex;
+ height: calc(100vh - #{$header-height});
+ margin-top: 40px;
+ color: $almost-black;
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+
+ &.is-collapsed {
+ .ide-file-list {
+ max-width: 250px;
+ }
+ }
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ }
+}
+
+.ide-file-list {
+ flex: 1;
+
+ .file {
+ cursor: pointer;
+
+ &.file-open {
+ background: $white-normal;
+ }
+
+ .ide-file-name {
+ flex: 1;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ margin-right: 2px;
+ }
+
+ .loading-container {
+ margin-right: 4px;
+ display: inline-block;
+ }
+ }
+
+ .ide-file-changed-icon {
+ margin-left: auto;
+ }
+
+ .ide-new-btn {
+ display: none;
+ margin-bottom: -4px;
+ margin-right: -8px;
+ }
+
+ &:hover {
+ .ide-new-btn {
+ display: block;
+ }
+ }
+
+ &.folder {
+ svg {
+ fill: $gl-text-color-secondary;
+ }
+ }
+ }
+
+ a {
+ color: $gl-text-color;
+ }
+
+ th {
+ position: sticky;
+ top: 0;
+ }
+}
+
+.file-name,
+.file-col-commit-message {
+ display: flex;
+ overflow: visible;
+ padding: 6px 12px;
+}
+
+.multi-file-loading-container {
+ margin-top: 10px;
+ padding: 10px;
+
+ .animation-container {
+ background: $gray-light;
+
+ div {
+ background: $gray-light;
+ }
+ }
+}
+
+.multi-file-table-col-commit-message {
+ white-space: nowrap;
+ width: 50%;
+}
+
+.multi-file-edit-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ border-left: 1px solid $white-dark;
+ overflow: hidden;
+}
+
+.multi-file-tabs {
+ display: flex;
+ background-color: $white-normal;
+ box-shadow: inset 0 -1px $white-dark;
+
+ > ul {
+ display: flex;
+ overflow-x: auto;
+ }
+
+ li {
+ position: relative;
+ }
+
+ .dropdown {
+ display: flex;
+ margin-left: auto;
+ margin-bottom: 1px;
+ padding: 0 $grid-size;
+ border-left: 1px solid $white-dark;
+ background-color: $white-light;
+
+ &.shadow {
+ box-shadow: 0 0 10px $dropdown-shadow-color;
+ }
+
+ .btn {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+}
+
+.multi-file-tab {
+ @include str-truncated(150px);
+ padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding;
+ background-color: $gray-normal;
+ border-right: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ cursor: pointer;
+
+ svg {
+ vertical-align: middle;
+ }
+
+ &.active {
+ background-color: $white-light;
+ border-bottom-color: $white-light;
+ }
+}
+
+.multi-file-tab-close {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ background: none;
+ border: 0;
+ border-radius: $border-radius-default;
+ color: $theme-gray-900;
+ transform: translateY(-50%);
+
+ svg {
+ position: relative;
+ top: -1px;
+ }
+
+ &:hover {
+ background-color: $theme-gray-200;
+ }
+
+ &:focus {
+ background-color: $blue-500;
+ color: $white-light;
+ outline: 0;
+
+ svg {
+ fill: currentColor;
+ }
+ }
+}
+
+.multi-file-edit-pane-content {
+ flex: 1;
+ height: 0;
+}
+
+.blob-editor-container {
+ flex: 1;
+ height: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ .vertical-center {
+ min-height: auto;
+ }
+
+ .monaco-editor .lines-content .cigr {
+ display: none;
+ }
+
+ .monaco-diff-editor.vs {
+ .editor.modified {
+ box-shadow: none;
+ }
+
+ .diagonal-fill {
+ display: none !important;
+ }
+
+ .diffOverview {
+ background-color: $white-light;
+ border-left: 1px solid $white-dark;
+ cursor: ns-resize;
+ }
+
+ .diffViewport {
+ display: none;
+ }
+
+ .char-insert {
+ background-color: $line-added-dark;
+ }
+
+ .char-delete {
+ background-color: $line-removed-dark;
+ }
+
+ .line-numbers {
+ color: $black-transparent;
+ }
+
+ .view-overlays {
+ .line-insert {
+ background-color: $line-added;
+ }
+
+ .line-delete {
+ background-color: $line-removed;
+ }
+ }
+
+ .margin {
+ background-color: $gray-light;
+ border-right: 1px solid $white-normal;
+
+ .line-insert {
+ border-right: 1px solid $line-added-dark;
+ }
+
+ .line-delete {
+ border-right: 1px solid $line-removed-dark;
+ }
+ }
+
+ .margin-view-overlays .insert-sign,
+ .margin-view-overlays .delete-sign {
+ opacity: 0.4;
+ }
+
+ .cursors-layer {
+ display: none;
+ }
+ }
+}
+
+.multi-file-editor-holder {
+ height: 100%;
+}
+
+.multi-file-editor-btn-group {
+ padding: $gl-bar-padding $gl-padding;
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ background: $white-light;
+}
+
+.ide-status-bar {
+ padding: $gl-bar-padding $gl-padding;
+ background: $white-light;
+ display: flex;
+ justify-content: space-between;
+
+ svg {
+ vertical-align: middle;
+ }
+}
+
+// Not great, but this is to deal with our current output
+.multi-file-preview-holder {
+ height: 100%;
+ overflow: scroll;
+
+ .file-content.code {
+ display: flex;
+
+ i {
+ margin-left: -10px;
+ }
+ }
+
+ .line-numbers {
+ min-width: 50px;
+ }
+
+ .file-content,
+ .line-numbers,
+ .blob-content,
+ .code {
+ min-height: 100%;
+ }
+}
+
+.file-content.blob-no-preview {
+ a {
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+.multi-file-commit-panel {
+ display: flex;
+ position: relative;
+ flex-direction: column;
+ width: 340px;
+ padding: 0;
+ background-color: $gray-light;
+ padding-right: 3px;
+
+ .projects-sidebar {
+ display: flex;
+ flex-direction: column;
+
+ .context-header {
+ width: auto;
+ margin-right: 0;
+ }
+ }
+
+ .multi-file-commit-panel-inner {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ }
+
+ .multi-file-commit-panel-inner-scroll {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: auto;
+ }
+
+ &.is-collapsed {
+ width: 60px;
+
+ .multi-file-commit-list {
+ padding-top: $gl-padding;
+ overflow: hidden;
+ }
+
+ .multi-file-context-bar-icon {
+ align-items: center;
+
+ svg {
+ float: none;
+ margin: 0;
+ }
+ }
+ }
+
+ .branch-container {
+ border-left: 4px solid $indigo-700;
+ margin-bottom: $gl-bar-padding;
+ }
+
+ .branch-header {
+ background: $white-dark;
+ display: flex;
+ }
+
+ .branch-header-title {
+ flex: 1;
+ padding: $grid-size $gl-padding;
+ color: $indigo-700;
+ font-weight: $gl-font-weight-bold;
+
+ svg {
+ vertical-align: middle;
+ }
+ }
+
+ .branch-header-btns {
+ padding: $gl-vert-padding $gl-padding;
+ }
+
+ .left-collapse-btn {
+ display: none;
+ background: $gray-light;
+ text-align: left;
+ border-top: 1px solid $white-dark;
+
+ svg {
+ vertical-align: middle;
+ }
+ }
+}
+
+.multi-file-context-bar-icon {
+ padding: 10px;
+
+ svg {
+ margin-right: 10px;
+ float: left;
+ }
+}
+
+.multi-file-commit-panel-section {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.multi-file-commit-empty-state-container {
+ align-items: center;
+ justify-content: center;
+}
+
+.multi-file-commit-panel-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ border-bottom: 1px solid $white-dark;
+ padding: $gl-btn-padding 0;
+
+ &.is-collapsed {
+ border-bottom: 1px solid $white-dark;
+
+ svg {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .multi-file-commit-panel-collapse-btn {
+ margin-right: auto;
+ margin-left: auto;
+ border-left: 0;
+ }
+ }
+}
+
+.multi-file-commit-panel-header-title {
+ display: flex;
+ flex: 1;
+ padding: 0 $gl-btn-padding;
+
+ svg {
+ margin-right: $gl-btn-padding;
+ }
+}
+
+.multi-file-commit-panel-collapse-btn {
+ border-left: 1px solid $white-dark;
+}
+
+.multi-file-commit-list {
+ flex: 1;
+ overflow: auto;
+ padding: $gl-padding 0;
+ min-height: 60px;
+}
+
+.multi-file-commit-list-item {
+ display: flex;
+ padding: 0;
+ align-items: center;
+
+ .multi-file-discard-btn {
+ display: none;
+ margin-left: auto;
+ color: $gl-link-color;
+ padding: 0 2px;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &:hover {
+ background: $white-normal;
+
+ .multi-file-discard-btn {
+ display: block;
+ }
+ }
+}
+
+.multi-file-addition {
+ fill: $green-500;
+}
+
+.multi-file-modified {
+ fill: $orange-500;
+}
+
+.multi-file-commit-list-collapsed {
+ display: flex;
+ flex-direction: column;
+
+ > svg {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ margin-left: 3px;
+ }
+}
+
+.multi-file-commit-list-path {
+ padding: $grid-size / 2;
+ padding-left: $gl-padding;
+ background: none;
+ border: 0;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+
+ svg {
+ min-width: 16px;
+ vertical-align: middle;
+ display: inline-block;
+ }
+
+ &:hover,
+ &:focus {
+ outline: 0;
+ }
+}
+
+.multi-file-commit-list-file-path {
+ @include str-truncated(100%);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ text-decoration: none;
+ }
+}
+
+.multi-file-commit-form {
+ padding: $gl-padding;
+ border-top: 1px solid $white-dark;
+
+ .btn {
+ font-size: $gl-font-size;
+ }
+}
+
+.multi-file-commit-message.form-control {
+ height: 160px;
+ resize: none;
+}
+
+.dirty-diff {
+ // !important need to override monaco inline style
+ width: 4px !important;
+ left: 0 !important;
+
+ &-modified {
+ background-color: $blue-500;
+ }
+
+ &-added {
+ background-color: $green-600;
+ }
+
+ &-removed {
+ height: 0 !important;
+ width: 0 !important;
+ bottom: -2px;
+ border-style: solid;
+ border-width: 5px;
+ border-color: transparent transparent transparent $red-500;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 1px;
+ background-color: rgba($red-500, 0.5);
+ }
+ }
+}
+
+.ide-loading {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
+
+.ide-empty-state {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
+
+.ide-new-btn {
+ .dropdown-toggle svg {
+ margin-top: -2px;
+ margin-bottom: 2px;
+ }
+
+ .dropdown-menu {
+ left: auto;
+ right: 0;
+
+ label {
+ font-weight: $gl-font-weight-normal;
+ padding: 5px 8px;
+ margin-bottom: 0;
+ }
+ }
+}
+
+.ide {
+ overflow: hidden;
+
+ &.nav-only {
+ .flash-container {
+ margin-top: $header-height;
+ margin-bottom: 0;
+ }
+
+ .alert-wrapper .flash-container .flash-alert:last-child,
+ .alert-wrapper .flash-container .flash-notice:last-child {
+ margin-bottom: 0;
+ }
+
+ .content-wrapper {
+ margin-top: $header-height;
+ padding-bottom: 0;
+ }
+
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $flash-height});
+ }
+ }
+
+ .projects-sidebar {
+ .multi-file-commit-panel-inner-scroll {
+ flex: 1;
+ }
+ }
+ }
+}
+
+.with-performance-bar .ide.nav-only {
+ .flash-container {
+ margin-top: #{$header-height + $performance-bar-height};
+ }
+
+ .content-wrapper {
+ margin-top: #{$header-height + $performance-bar-height};
+ padding-bottom: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $performance-bar-height});
+ }
+
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(
+ 100vh - #{$header-height + $performance-bar-height + $flash-height}
+ );
+ }
+ }
+}
+
+.dragHandle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background-color: $white-dark;
+
+ &.dragright {
+ right: 0;
+ }
+
+ &.dragleft {
+ left: 0;
+ }
+}
+
+.ide-commit-radios {
+ label {
+ font-weight: normal;
+ }
+
+ .help-block {
+ margin-top: 0;
+ line-height: 0;
+ }
+}
+
+.ide-commit-new-branch {
+ margin-left: 25px;
+}
+
+.ide-external-links {
+ p {
+ margin: 0;
+ }
+}
+
+.ide-sidebar-link {
+ padding: $gl-padding-8 $gl-padding;
+ background: $indigo-700;
+ color: $white-light;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+
+ &:focus,
+ &:hover {
+ color: $white-light;
+ text-decoration: underline;
+ background: $indigo-500;
+ }
+
+ &:active {
+ background: $indigo-800;
+ }
+}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index c9363188505..dbde0720993 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -112,7 +112,7 @@ input[type="checkbox"]:hover {
}
.dropdown-content {
- max-height: 350px;
+ max-height: 302px;
}
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 5d630c7d61e..c410049bc0b 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -135,6 +135,17 @@
padding-top: 0;
}
+.integration-settings-form {
+ .well {
+ padding: $gl-padding / 2;
+ box-shadow: none;
+ }
+
+ .svg-container {
+ max-width: 150px;
+ }
+}
+
.token-token-container {
#impersonation-token-token {
width: 80%;
@@ -194,7 +205,8 @@
}
.badge {
- font-size: inherit;
+ font-size: 12px;
+ line-height: 12px;
}
.panel-heading .badge-count {
@@ -268,3 +280,27 @@
margin: 0 0 5px 17px;
}
}
+
+.deprecated-service {
+ cursor: default;
+}
+
+.personal-access-tokens-never-expires-label {
+ color: $note-disabled-comment-color;
+}
+
+.created-deploy-token-container {
+ .deploy-token-field {
+ width: 90%;
+ display: inline;
+ }
+
+ .btn-clipboard {
+ margin-left: 5px;
+ }
+
+ .deploy-token-help-block {
+ display: block;
+ margin-bottom: 0;
+ }
+}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index a79772ea37b..4b9824fab0c 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -128,7 +128,6 @@
.label {
color: $gl-text-color;
- font-size: inherit;
}
p {
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index d8fec583121..9a0ec936979 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -6,6 +6,14 @@
}
}
+.wiki-form {
+ .edit-wiki-page-slug-tip {
+ display: inline-block;
+ max-width: 100%;
+ margin-top: 5px;
+ }
+}
+
.title .edit-wiki-header {
width: 780px;
margin-left: auto;
@@ -172,6 +180,11 @@ ul.wiki-pages-list.content-list {
}
}
+.wiki-holder {
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
.wiki {
table {
@include markdown-table;
diff --git a/app/assets/stylesheets/pages/xterm.scss b/app/assets/stylesheets/pages/xterm.scss
index c7297a34ad8..7d40c61da26 100644
--- a/app/assets/stylesheets/pages/xterm.scss
+++ b/app/assets/stylesheets/pages/xterm.scss
@@ -3,22 +3,21 @@
// see also: https://gist.github.com/jasonm23/2868981
$black: #000;
- $red: #cd0000;
- $green: #00cd00;
- $yellow: #cdcd00;
- $blue: #00e; // according to wikipedia, this is the xterm standard
- //$blue: #1e90ff; // this is used by all the terminals I tried (when configured with the xterm color profile)
- $magenta: #cd00cd;
- $cyan: #00cdcd;
- $white: #e5e5e5;
+ $red: #ea1010;
+ $green: #009900;
+ $yellow: #999900;
+ $blue: #0073e6;
+ $magenta: #d411d4;
+ $cyan: #009999;
+ $white: #ccc;
$l-black: #373b41;
- $l-red: #c66;
- $l-green: #b5bd68;
- $l-yellow: #f0c674;
- $l-blue: #81a2be;
- $l-magenta: #b294bb;
- $l-cyan: #8abeb7;
- $l-white: $gray-darkest;
+ $l-red: #ff6161;
+ $l-green: #00d600;
+ $l-yellow: #bdbd00;
+ $l-blue: #5797ff;
+ $l-magenta: #d96dd9;
+ $l-cyan: #00bdbd;
+ $l-white: #fff;
/*
* xterm colors
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 6e539e39ca1..45ae94abaff 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -1,8 +1,8 @@
-@import "framework/variables";
-@import "peek/views/performance_bar";
-@import "peek/views/rblineprof";
+@import 'framework/variables';
+@import 'peek/views/performance_bar';
+@import 'peek/views/rblineprof';
-#peek {
+#js-peek {
position: fixed;
left: 0;
top: 0;
@@ -15,26 +15,36 @@
line-height: $performance-bar-height;
color: $perf-bar-text;
+ select {
+ width: 200px;
+ }
+
&.disabled {
display: none;
}
&.production {
background-color: $perf-bar-production;
+
+ select {
+ background: $perf-bar-production;
+ }
}
&.staging {
background-color: $perf-bar-staging;
+
+ select {
+ background: $perf-bar-staging;
+ }
}
&.development {
background-color: $perf-bar-development;
- }
- .wrapper {
- width: 80%;
- height: $performance-bar-height;
- margin: 0 auto;
+ select {
+ background: $perf-bar-development;
+ }
}
// UI Elements
@@ -42,11 +52,12 @@
background: $perf-bar-bucket-bg;
display: inline-block;
padding: 4px 6px;
- font-family: Consolas, "Liberation Mono", Courier, monospace;
+ 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;
+ box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from,
+ inset 0 1px 2px $perf-bar-bucket-box-shadow-to;
.hidden {
display: none;
@@ -94,6 +105,16 @@
max-width: 10000px !important;
}
}
+
+ .performance-bar-modal {
+ .modal-footer {
+ display: none;
+ }
+
+ .modal-dialog {
+ width: 860px;
+ }
+ }
}
#modal-peek-pg-queries-content {
diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index dd0b38970bd..ea302f17d16 100644
--- a/app/controllers/admin/appearances_controller.rb
+++ b/app/controllers/admin/appearances_controller.rb
@@ -50,9 +50,19 @@ class Admin::AppearancesController < Admin::ApplicationController
# Only allow a trusted parameter "white list" through.
def appearance_params
- params.require(:appearance).permit(
- :title, :description, :logo, :logo_cache, :header_logo, :header_logo_cache,
- :new_project_guidelines, :updated_by
- )
+ params.require(:appearance).permit(allowed_appearance_params)
+ end
+
+ def allowed_appearance_params
+ %i[
+ title
+ description
+ logo
+ logo_cache
+ header_logo
+ header_logo_cache
+ new_project_guidelines
+ updated_by
+ ]
end
end
diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb
index c27f2ee3c09..a4648b33cfa 100644
--- a/app/controllers/admin/application_controller.rb
+++ b/app/controllers/admin/application_controller.rb
@@ -3,23 +3,9 @@
# Automatically sets the layout and ensures an administrator is logged in
class Admin::ApplicationController < ApplicationController
before_action :authenticate_admin!
- before_action :display_read_only_information
layout 'admin'
def authenticate_admin!
render_404 unless current_user.admin?
end
-
- def display_read_only_information
- return unless Gitlab::Database.read_only?
-
- flash.now[:notice] = read_only_message
- end
-
- private
-
- # Overridden in EE
- def read_only_message
- _('You are on a read-only GitLab instance.')
- end
end
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index c49b6459452..a9109a1d4d0 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -1,4 +1,6 @@
class Admin::BroadcastMessagesController < Admin::ApplicationController
+ include BroadcastMessagesHelper
+
before_action :finder, only: [:edit, :update, :destroy]
def index
@@ -37,7 +39,8 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
end
def preview
- @broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ broadcast_message = BroadcastMessage.new(broadcast_message_params)
+ render json: { message: render_broadcast_message(broadcast_message) }
end
protected
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index 9b77c554908..10d9d1b5345 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -1,6 +1,6 @@
class Admin::CohortsController < Admin::ApplicationController
def index
- if current_application_settings.usage_ping_enabled
+ if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
CohortsService.new.execute
end
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index a7ab481519d..b0c4c31cffc 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -50,10 +50,10 @@ class Admin::DeployKeysController < Admin::ApplicationController
end
def create_params
- params.require(:deploy_key).permit(:key, :title, :can_push)
+ params.require(:deploy_key).permit(:key, :title)
end
def update_params
- params.require(:deploy_key).permit(:title, :can_push)
+ params.require(:deploy_key).permit(:title)
end
end
diff --git a/app/controllers/admin/gitaly_servers_controller.rb b/app/controllers/admin/gitaly_servers_controller.rb
new file mode 100644
index 00000000000..11c4dfe3d8d
--- /dev/null
+++ b/app/controllers/admin/gitaly_servers_controller.rb
@@ -0,0 +1,5 @@
+class Admin::GitalyServersController < Admin::ApplicationController
+ def index
+ @gitaly_servers = Gitaly::Server.all
+ end
+end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index a94726887d9..001f6520093 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -5,7 +5,7 @@ class Admin::GroupsController < Admin::ApplicationController
def index
@groups = Group.with_statistics.with_route
- @groups = @groups.sort(@sort = params[:sort])
+ @groups = @groups.sort_by_attribute(@sort = params[:sort])
@groups = @groups.search(params[:name]) if params[:name].present?
@groups = @groups.page(params[:page])
end
@@ -48,7 +48,7 @@ class Admin::GroupsController < Admin::ApplicationController
def members_update
member_params = params.permit(:user_ids, :access_level, :expires_at)
- result = Members::CreateService.new(@group, current_user, member_params.merge(limit: -1)).execute
+ result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group)
if result[:status] == :success
redirect_to [:admin, @group], notice: 'Users were successfully added.'
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 77e3c95d197..2b47819303e 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -59,11 +59,9 @@ class Admin::HooksController < Admin::ApplicationController
def hook_params
params.require(:hook).permit(
:enable_ssl_verification,
- :push_events,
- :tag_push_events,
- :repository_update_events,
:token,
- :url
+ :url,
+ *SystemHook.triggers.values
)
end
end
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index 7a2c7234a1e..a7b562b1d8e 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -9,7 +9,6 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
@impersonation_token = finder.build(impersonation_token_params)
if @impersonation_token.save
- flash[:impersonation_token] = @impersonation_token.token
redirect_to admin_user_impersonation_tokens_path, notice: "A new impersonation token has been created."
else
set_index_vars
diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb
index 5162273ef8a..ae7a7f6279c 100644
--- a/app/controllers/admin/jobs_controller.rb
+++ b/app/controllers/admin/jobs_controller.rb
@@ -20,6 +20,6 @@ class Admin::JobsController < Admin::ApplicationController
def cancel_all
Ci::Build.running_or_pending.each(&:cancel)
- redirect_to admin_jobs_path
+ redirect_to admin_jobs_path, status: 303
end
end
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 38b808cdc31..4b01904f2a1 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -65,6 +65,7 @@ class Admin::RunnersController < Admin::ApplicationController
else
Project.all
end
+
@projects = @projects.where.not(id: runner.projects.select(:id)) if runner.projects.any?
@projects = @projects.page(params[:page]).per(30)
end
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index 4c3d336b3af..a7025b62ad7 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -1,6 +1,7 @@
class Admin::ServicesController < Admin::ApplicationController
include ServiceParams
+ before_action :whitelist_query_limiting, only: [:index]
before_action :service, only: [:edit, :update]
def index
@@ -37,4 +38,8 @@ class Admin::ServicesController < Admin::ApplicationController
def service
@service ||= Service.where(id: params[:id], template: true).first
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42430')
+ end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 156a8e2c515..bfeb5a2d097 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -4,7 +4,7 @@ class Admin::UsersController < Admin::ApplicationController
def index
@users = User.order_name_asc.filter(params[:filter])
@users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
- @users = @users.sort(@sort = params[:sort])
+ @users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index ee21d81f23e..24651dd392c 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -2,7 +2,6 @@ require 'gon'
require 'fogbugz'
class ApplicationController < ActionController::Base
- include Gitlab::CurrentSettings
include Gitlab::GonHelper
include GitlabRoutingHelper
include PageLayoutHelper
@@ -28,7 +27,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
- helper_method :can?, :current_application_settings
+ helper_method :can?
helper_method :import_sources_enabled?, :github_import_enabled?, :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?
rescue_from Encoding::CompatibilityError do |exception|
@@ -120,17 +119,22 @@ class ApplicationController < ActionController::Base
end
def after_sign_out_path_for(resource)
- current_application_settings.after_sign_out_path.presence || new_user_session_path
+ Gitlab::CurrentSettings.after_sign_out_path.presence || new_user_session_path
end
def can?(object, action, subject = :global)
Ability.allowed?(object, action, subject)
end
- def access_denied!
+ def access_denied!(message = nil)
respond_to do |format|
- format.json { head :not_found }
- format.any { render "errors/access_denied", layout: "errors", status: 404 }
+ format.any { head :not_found }
+ format.html do
+ render "errors/access_denied",
+ layout: "errors",
+ status: 404,
+ locals: { message: message }
+ end
end
end
@@ -147,6 +151,8 @@ class ApplicationController < ActionController::Base
format.html do
render file: Rails.root.join("public", "404"), layout: false, status: "404"
end
+ # Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file
+ format.js { render json: '', status: :not_found, content_type: 'application/json' }
format.any { head :not_found }
end
end
@@ -185,7 +191,7 @@ class ApplicationController < ActionController::Base
return unless signed_in? && session[:service_tickets]
valid = session[:service_tickets].all? do |provider, ticket|
- Gitlab::OAuth::Session.valid?(provider, ticket)
+ Gitlab::Auth::OAuth::Session.valid?(provider, ticket)
end
unless valid
@@ -209,7 +215,7 @@ class ApplicationController < ActionController::Base
if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease
- unless Gitlab::LDAP::Access.allowed?(current_user)
+ unless Gitlab::Auth::LDAP::Access.allowed?(current_user)
sign_out current_user
flash[:alert] = "Access denied for your LDAP account."
redirect_to new_user_session_path
@@ -223,10 +229,6 @@ class ApplicationController < ActionController::Base
@event_filter ||= EventFilter.new(filters)
end
- def gitlab_ldap_access(&block)
- Gitlab::LDAP::Access.open { |access| yield(access) }
- end
-
# JSON for infinite scroll via Pager object
def pager_json(partial, count, locals = {})
html = render_to_string(
@@ -266,51 +268,51 @@ class ApplicationController < ActionController::Base
end
def import_sources_enabled?
- !current_application_settings.import_sources.empty?
+ !Gitlab::CurrentSettings.import_sources.empty?
end
def github_import_enabled?
- current_application_settings.import_sources.include?('github')
+ Gitlab::CurrentSettings.import_sources.include?('github')
end
def gitea_import_enabled?
- current_application_settings.import_sources.include?('gitea')
+ Gitlab::CurrentSettings.import_sources.include?('gitea')
end
def github_import_configured?
- Gitlab::OAuth::Provider.enabled?(:github)
+ Gitlab::Auth::OAuth::Provider.enabled?(:github)
end
def gitlab_import_enabled?
- request.host != 'gitlab.com' && current_application_settings.import_sources.include?('gitlab')
+ request.host != 'gitlab.com' && Gitlab::CurrentSettings.import_sources.include?('gitlab')
end
def gitlab_import_configured?
- Gitlab::OAuth::Provider.enabled?(:gitlab)
+ Gitlab::Auth::OAuth::Provider.enabled?(:gitlab)
end
def bitbucket_import_enabled?
- current_application_settings.import_sources.include?('bitbucket')
+ Gitlab::CurrentSettings.import_sources.include?('bitbucket')
end
def bitbucket_import_configured?
- Gitlab::OAuth::Provider.enabled?(:bitbucket)
+ Gitlab::Auth::OAuth::Provider.enabled?(:bitbucket)
end
def google_code_import_enabled?
- current_application_settings.import_sources.include?('google_code')
+ Gitlab::CurrentSettings.import_sources.include?('google_code')
end
def fogbugz_import_enabled?
- current_application_settings.import_sources.include?('fogbugz')
+ Gitlab::CurrentSettings.import_sources.include?('fogbugz')
end
def git_import_enabled?
- current_application_settings.import_sources.include?('git')
+ Gitlab::CurrentSettings.import_sources.include?('git')
end
def gitlab_project_import_enabled?
- current_application_settings.import_sources.include?('gitlab_project')
+ Gitlab::CurrentSettings.import_sources.include?('gitlab_project')
end
# U2F (universal 2nd factor) devices need a unique identifier for the application
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index f8049b20b9f..7d7ff217e5d 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -1,7 +1,11 @@
module Boards
class IssuesController < Boards::ApplicationController
include BoardsResponses
+ include ControllerWithCrossProjectAccessCheck
+ requires_cross_project_access if: -> { board&.group_board? }
+
+ before_action :whitelist_query_limiting, only: [:index, :update]
before_action :authorize_read_issue, only: [:index]
before_action :authorize_create_issue, only: [:create]
before_action :authorize_update_issue, only: [:update]
@@ -54,7 +58,7 @@ module Boards
end
def issue
- @issue ||= issues_finder.execute.find(params[:id])
+ @issue ||= issues_finder.find(params[:id])
end
def filter_params
@@ -63,11 +67,19 @@ module Boards
end
def issues_finder
- IssuesFinder.new(current_user, project_id: board_parent.id)
+ if board.group_board?
+ IssuesFinder.new(current_user, group_id: board_parent.id)
+ else
+ IssuesFinder.new(current_user, project_id: board_parent.id)
+ end
end
def project
- board_parent
+ @project ||= if board.group_board?
+ Project.find(issue_params[:project_id])
+ else
+ board_parent
+ end
end
def move_params
@@ -84,7 +96,8 @@ module Boards
resource.as_json(
only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position],
labels: true,
- sidebar_endpoints: true,
+ issue_endpoints: true,
+ include_full_project_path: board.group_board?,
include: {
project: { only: [:id, :path] },
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
@@ -92,5 +105,10 @@ module Boards
}
)
end
+
+ def whitelist_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42439
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42428')
+ end
end
end
diff --git a/app/controllers/ci/lints_controller.rb b/app/controllers/ci/lints_controller.rb
index be667687c18..738a6a5173e 100644
--- a/app/controllers/ci/lints_controller.rb
+++ b/app/controllers/ci/lints_controller.rb
@@ -4,23 +4,5 @@ module Ci
def show
end
-
- def create
- @content = params[:content]
- @error = Gitlab::Ci::YamlProcessor.validation_message(@content)
- @status = @error.blank?
-
- if @error.blank?
- @config_processor = Gitlab::Ci::YamlProcessor.new(@content)
- @stages = @config_processor.stages
- @builds = @config_processor.builds
- @jobs = @config_processor.jobs
- end
- rescue
- @error = 'Undefined error'
- @status = false
- ensure
- render :show
- end
end
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index db8c362f125..2fdf346ef44 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -10,7 +10,7 @@ module AuthenticatesWithTwoFactor
# This action comes from DeviseController, but because we call `sign_in`
# manually, not skipping this action would cause a "You are already signed
# in." error message to be shown upon successful login.
- skip_before_action :require_no_authentication, only: [:create]
+ skip_before_action :require_no_authentication, only: [:create], raise: false
end
# Store the user's ID in the session for later retrieval and render the
@@ -56,6 +56,7 @@ module AuthenticatesWithTwoFactor
session.delete(:otp_user_id)
remember_me(user) if user_params[:remember_me] == '1'
+ user.save!
sign_in(user)
else
user.increment_failed_attempts!
diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb
index a145049dc7d..da830ec2cb1 100644
--- a/app/controllers/concerns/boards_responses.rb
+++ b/app/controllers/concerns/boards_responses.rb
@@ -1,10 +1,46 @@
module BoardsResponses
+ include Gitlab::Utils::StrongMemoize
+
+ def board_params
+ params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
+ end
+
+ def parent
+ strong_memoize(:parent) do
+ group? ? group : project
+ end
+ end
+
+ def boards_path
+ if group?
+ group_boards_path(parent)
+ else
+ project_boards_path(parent)
+ end
+ end
+
+ def board_path(board)
+ if group?
+ group_board_path(parent, board)
+ else
+ project_board_path(parent, board)
+ end
+ end
+
+ def group?
+ instance_variable_defined?(:@group)
+ end
+
def authorize_read_list
- authorize_action_for!(board.parent, :read_list)
+ ability = board.group_board? ? :read_group : :read_list
+
+ authorize_action_for!(board.parent, ability)
end
def authorize_read_issue
- authorize_action_for!(board.parent, :read_issue)
+ ability = board.group_board? ? :read_group : :read_issue
+
+ authorize_action_for!(board.parent, ability)
end
def authorize_update_issue
@@ -31,6 +67,10 @@ module BoardsResponses
respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
+ def serialize_as_json(resource)
+ resource.as_json(only: [:id])
+ end
+
def respond_with(resource)
respond_to do |format|
format.html
diff --git a/app/controllers/concerns/controller_with_cross_project_access_check.rb b/app/controllers/concerns/controller_with_cross_project_access_check.rb
new file mode 100644
index 00000000000..a45c3384578
--- /dev/null
+++ b/app/controllers/concerns/controller_with_cross_project_access_check.rb
@@ -0,0 +1,24 @@
+module ControllerWithCrossProjectAccessCheck
+ extend ActiveSupport::Concern
+
+ included do
+ extend Gitlab::CrossProjectAccess::ClassMethods
+ before_action :cross_project_check
+ end
+
+ def cross_project_check
+ if Gitlab::CrossProjectAccess.find_check(self)&.should_run?(self)
+ authorize_cross_project_page!
+ end
+ end
+
+ def authorize_cross_project_page!
+ return if can?(current_user, :read_cross_project)
+
+ rejection_message = _(
+ "This page is unavailable because you are not allowed to read information "\
+ "across multiple projects."
+ )
+ access_denied!(rejection_message)
+ end
+end
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index 6f4fdcdaa4f..b26a76d2b62 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -4,7 +4,7 @@ module CreatesCommit
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil)
- if can?(current_user, :push_code, @project)
+ if user_access(@project).can_push_to_branch?(branch_name_or_ref)
@project_to_commit_into = @project
@branch_name ||= @ref
else
@@ -50,7 +50,7 @@ module CreatesCommit
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def authorize_edit_tree!
- return if can_collaborate_with_project?
+ return if can_collaborate_with_project?(project, ref: branch_name_or_ref)
access_denied!
end
@@ -123,4 +123,8 @@ module CreatesCommit
params[:create_merge_request].present? &&
(different_project? || @start_branch != @branch_name) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
+
+ def branch_name_or_ref
+ @branch_name || @ref # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 688e8bd4a37..997af4ab9e9 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -20,13 +20,13 @@ module EnforcesTwoFactorAuthentication
end
def two_factor_authentication_required?
- current_application_settings.require_two_factor_authentication? ||
+ Gitlab::CurrentSettings.require_two_factor_authentication? ||
current_user.try(:require_two_factor_authentication_from_group?)
end
def two_factor_authentication_reason(global: -> {}, group: -> {})
if two_factor_authentication_required?
- if current_application_settings.require_two_factor_authentication?
+ if Gitlab::CurrentSettings.require_two_factor_authentication?
global.call
else
groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc)
@@ -36,7 +36,7 @@ module EnforcesTwoFactorAuthentication
end
def two_factor_grace_period
- periods = [current_application_settings.two_factor_grace_period]
+ periods = [Gitlab::CurrentSettings.two_factor_grace_period]
periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?)
periods.min
end
diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb
index b10147835f3..56770a17406 100644
--- a/app/controllers/concerns/group_tree.rb
+++ b/app/controllers/concerns/group_tree.rb
@@ -2,14 +2,19 @@ module GroupTree
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def render_group_tree(groups)
@groups = if params[:filter].present?
- Gitlab::GroupHierarchy.new(groups.search(params[:filter]))
+ # We find the ancestors by ID of the search results here.
+ # Otherwise the ancestors would also have filters applied,
+ # which would cause them not to be preloaded.
+ group_ids = groups.search(params[:filter]).select(:id)
+ Gitlab::GroupHierarchy.new(Group.where(id: group_ids))
.base_and_ancestors
else
# Only show root groups if no parent-id is given
groups.where(parent_id: params[:parent_id])
end
+
@groups = @groups.with_selects_for_list(archived: params[:archived])
- .sort(@sort = params[:sort])
+ .sort_by_attribute(@sort = params[:sort])
.page(params[:page])
respond_to do |format|
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 74a4f437dc8..0379f76fc3d 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -45,7 +45,7 @@ module IssuableActions
}
if issuable.edited?
- response[:updated_at] = issuable.updated_at
+ response[:updated_at] = issuable.last_edited_at.to_time.iso8601
response[:updated_by_name] = issuable.last_edited_by.name
response[:updated_by_path] = user_path(issuable.last_edited_by)
end
@@ -77,8 +77,26 @@ module IssuableActions
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
end
+ def discussions
+ notes = issuable.notes
+ .inc_relations_for_view
+ .includes(:noteable)
+ .fresh
+
+ notes = prepare_notes_for_rendering(notes)
+ notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
+
+ discussions = Discussion.build_collection(notes, issuable)
+
+ render json: discussion_serializer.represent(discussions, context: self)
+ end
+
private
+ def discussion_serializer
+ DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user, note_entity: ProjectNoteEntity)
+ end
+
def recaptcha_check_if_spammable(should_redirect = true, &block)
return yield unless issuable.is_a? Spammable
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index b25e753a5ad..4114ca6bf7c 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -12,14 +12,12 @@ module IssuableCollections
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def set_issuables_index
- @issuables = issuables_collection
- @issuables = @issuables.page(params[:page])
- @issuable_meta_data = issuable_meta_data(@issuables, collection_type)
- @total_pages = issuable_page_count
+ @issuables = issuables_collection
+ set_pagination
return if redirect_out_of_range(@total_pages)
- if params[:label_name].present?
+ if params[:label_name].present? && @project
labels_params = { project_id: @project.id, title: params[:label_name] }
@labels = LabelsFinder.new(current_user, labels_params).execute
end
@@ -35,14 +33,26 @@ module IssuableCollections
@users.push(author) if author
end
end
+
+ def set_pagination
+ return if pagination_disabled?
+
+ @issuables = @issuables.page(params[:page])
+ @issuable_meta_data = issuable_meta_data(@issuables, collection_type)
+ @total_pages = issuable_page_count
+ end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
+ def pagination_disabled?
+ false
+ end
+
def issuables_collection
finder.execute.preload(preload_for_collection)
end
def redirect_out_of_range(total_pages)
- return false if total_pages.zero?
+ return false if total_pages.nil? || total_pages.zero?
out_of_range = @issuables.current_page > total_pages # rubocop:disable Gitlab/ModuleWithInstanceVariables
@@ -84,6 +94,7 @@ module IssuableCollections
@filter_params[:project_id] = @project.id
elsif @group
@filter_params[:group_id] = @group.id
+ @filter_params[:include_subgroups] = true
else
# TODO: this filter ignore issues/mr created in public or
# internal repos where you are not a member. Enable this filter
@@ -92,7 +103,7 @@ module IssuableCollections
# @filter_params[:authorized_only] = true
end
- @filter_params.permit(IssuableFinder::VALID_PARAMS)
+ @filter_params.permit(finder_type.valid_params)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
@@ -135,7 +146,7 @@ module IssuableCollections
def finder
strong_memoize(:finder) do
- issuable_finder_for(@finder_type) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ issuable_finder_for(finder_type)
end
end
diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb
index d4cccbe6442..3b11a373368 100644
--- a/app/controllers/concerns/issues_action.rb
+++ b/app/controllers/concerns/issues_action.rb
@@ -4,9 +4,6 @@ module IssuesAction
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def issues
- @finder_type = IssuesFinder
- @label = finder.labels.first
-
@issues = issuables_collection
.non_archived
.page(params[:page])
@@ -19,4 +16,11 @@ module IssuesAction
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ private
+
+ def finder_type
+ (super if defined?(super)) ||
+ (IssuesFinder if action_name == 'issues')
+ end
end
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 4311f9d4db9..5e4e8a87153 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -10,6 +10,8 @@
module LfsRequest
extend ActiveSupport::Concern
+ CONTENT_TYPE = 'application/vnd.git-lfs+json'.freeze
+
included do
before_action :require_lfs_enabled!
before_action :lfs_check_access!
@@ -50,7 +52,7 @@ module LfsRequest
message: 'Access forbidden. Check your access level.',
documentation_url: help_url
},
- content_type: "application/vnd.git-lfs+json",
+ content_type: CONTENT_TYPE,
status: 403
)
end
@@ -61,7 +63,7 @@ module LfsRequest
message: 'Not found.',
documentation_url: help_url
},
- content_type: "application/vnd.git-lfs+json",
+ content_type: CONTENT_TYPE,
status: 404
)
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index c6b1e443de6..7a6a00b8e13 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -3,20 +3,31 @@ module MembershipActions
def create
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
+ result = Members::CreateService.new(current_user, create_params).execute(membershipable)
if result[:status] == :success
- redirect_to redirect_url, notice: 'Users were successfully added.'
+ redirect_to members_page_url, notice: 'Users were successfully added.'
else
- redirect_to redirect_url, alert: result[:message]
+ redirect_to members_page_url, alert: result[:message]
+ end
+ end
+
+ def update
+ update_params = params.require(root_params_key).permit(:access_level, :expires_at)
+ member = membershipable.members_and_requesters.find(params[:id])
+ member = Members::UpdateService
+ .new(current_user, update_params)
+ .execute(member)
+ .present(current_user: current_user)
+
+ respond_to do |format|
+ format.js { render 'shared/members/update', locals: { member: member } }
end
end
def destroy
- Members::DestroyService.new(membershipable, current_user, params)
- .execute(:all)
+ member = membershipable.members_and_requesters.find(params[:id])
+ Members::DestroyService.new(current_user).execute(member)
respond_to do |format|
format.html do
@@ -36,14 +47,17 @@ module MembershipActions
end
def approve_access_request
- Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute
+ access_requester = membershipable.requesters.find(params[:id])
+ Members::ApproveAccessRequestService
+ .new(current_user, params)
+ .execute(access_requester)
redirect_to members_page_url
end
def leave
- member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id)
- .execute(:all)
+ member = membershipable.members_and_requesters.find_by!(user_id: current_user.id)
+ Members::DestroyService.new(current_user).execute(member)
notice =
if member.request?
@@ -62,17 +76,43 @@ module MembershipActions
end
end
+ def resend_invite
+ member = membershipable.members.find(params[:id])
+
+ if member.invite?
+ member.resend_invite
+
+ redirect_to members_page_url, notice: 'The invitation was successfully resent.'
+ else
+ redirect_to members_page_url, alert: 'The invitation has already been accepted.'
+ end
+ end
+
protected
def membershipable
raise NotImplementedError
end
+ def root_params_key
+ case membershipable
+ when Namespace
+ :group_member
+ when Project
+ :project_member
+ else
+ raise "Unknown membershipable type: #{membershipable}!"
+ end
+ end
+
def members_page_url
- if membershipable.is_a?(Project)
+ case membershipable
+ when Namespace
+ polymorphic_url([membershipable, :members])
+ when Project
project_project_members_path(membershipable)
else
- polymorphic_url([membershipable, :members])
+ raise "Unknown membershipable type: #{membershipable}!"
end
end
diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb
index 4d44df3bba9..b70db99b157 100644
--- a/app/controllers/concerns/merge_requests_action.rb
+++ b/app/controllers/concerns/merge_requests_action.rb
@@ -4,9 +4,6 @@ module MergeRequestsAction
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def merge_requests
- @finder_type = MergeRequestsFinder
- @label = finder.labels.first
-
@merge_requests = issuables_collection.page(params[:page])
@issuable_meta_data = issuable_meta_data(@merge_requests, collection_type)
@@ -15,6 +12,11 @@ module MergeRequestsAction
private
+ def finder_type
+ (super if defined?(super)) ||
+ (MergeRequestsFinder if action_name == 'merge_requests')
+ end
+
def filter_params
super.merge(non_archived: true)
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index e82a5650935..839cac3687c 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -22,7 +22,7 @@ module NotesActions
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
notes_json[:notes] =
- if noteable.discussions_rendered_on_frontend?
+ if use_note_serializer?
note_serializer.represent(notes)
else
notes.map { |note| note_json(note) }
@@ -95,7 +95,7 @@ module NotesActions
if note.persisted?
attrs[:valid] = true
- if noteable.discussions_rendered_on_frontend?
+ if use_note_serializer?
attrs.merge!(note_serializer.represent(note))
else
attrs.merge!(
@@ -212,7 +212,7 @@ module NotesActions
end
def note_serializer
- NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
+ ProjectNoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
end
def note_project
@@ -233,4 +233,14 @@ module NotesActions
the_project
end
end
+
+ def use_note_serializer?
+ return false if params['html']
+
+ if noteable.is_a?(MergeRequest)
+ cookies[:vue_mr_discussions] == 'true'
+ else
+ noteable.discussions_rendered_on_frontend?
+ end
+ end
end
diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
index 0218ac83441..88d1b34bb06 100644
--- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
+++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
@@ -1,8 +1,6 @@
module RequiresWhitelistedMonitoringClient
extend ActiveSupport::Concern
- include Gitlab::CurrentSettings
-
included do
before_action :validate_ip_whitelisted_or_valid_token!
end
@@ -26,7 +24,7 @@ module RequiresWhitelistedMonitoringClient
token.present? &&
ActiveSupport::SecurityUtils.variable_size_secure_compare(
token,
- current_application_settings.health_check_access_token
+ Gitlab::CurrentSettings.health_check_access_token
)
end
diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
index 4199da9cdf5..0931bdf4c04 100644
--- a/app/controllers/concerns/routable_actions.rb
+++ b/app/controllers/concerns/routable_actions.rb
@@ -3,16 +3,20 @@ module RoutableActions
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
+ handle_not_found_or_authorized(routable)
nil
end
end
+ # This is overridden in gitlab-ee.
+ def handle_not_found_or_authorized(_routable)
+ route_not_found
+ end
+
def routable_authorized?(routable, extra_authorization_proc)
action = :"read_#{routable.class.to_s.underscore}"
return false unless can?(current_user, action, routable)
@@ -32,6 +36,7 @@ module RoutableActions
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
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
new file mode 100644
index 00000000000..55011c89886
--- /dev/null
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -0,0 +1,17 @@
+module SendFileUpload
+ def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, disposition: 'attachment')
+ if attachment
+ redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}" }
+ send_params.merge!(filename: attachment, disposition: disposition)
+ end
+
+ if file_upload.file_storage?
+ send_file file_upload.path, send_params
+ elsif file_upload.class.proxy_download_enabled?
+ headers.store(*Gitlab::Workhorse.send_url(file_upload.url(**redirect_params)))
+ head :ok
+ else
+ redirect_to file_upload.url(**redirect_params)
+ end
+ end
+end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 3d61458c064..c1acb50b76c 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -32,6 +32,7 @@ module ServiceParams
:issues_events,
:issues_url,
:jira_issue_transition_id,
+ :manual_configuration,
:merge_requests_events,
:mock_service_url,
:namespace,
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index a6fb1f40001..b9b9b6e4e88 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -1,5 +1,8 @@
module UploadsActions
include Gitlab::Utils::StrongMemoize
+ include SendFileUpload
+
+ UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo).freeze
def create
link_to_file = UploadService.new(model, params[:file], uploader_class).execute
@@ -17,34 +20,79 @@ module UploadsActions
end
end
+ # This should either
+ # - send the file directly
+ # - or redirect to its URL
+ #
def show
- return render_404 unless uploader.exists?
-
- disposition = uploader.image_or_video? ? 'inline' : 'attachment'
+ return render_404 unless uploader&.exists?
expires_in 0.seconds, must_revalidate: true, private: true
- send_file uploader.file.path, disposition: disposition
+ disposition = uploader.image_or_video? ? 'inline' : 'attachment'
+
+ send_upload(uploader, attachment: uploader.filename, disposition: disposition)
end
private
+ def uploader_class
+ raise NotImplementedError
+ end
+
+ def upload_mount
+ mounted_as = params[:mounted_as]
+ mounted_as if UPLOAD_MOUNTS.include?(mounted_as)
+ end
+
+ def uploader_mounted?
+ upload_model_class < CarrierWave::Mount::Extension && !upload_mount.nil?
+ end
+
def uploader
strong_memoize(:uploader) do
- return if show_model.nil?
+ if uploader_mounted?
+ model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend
+ else
+ build_uploader_from_upload || build_uploader_from_params
+ end
+ end
+ end
- file_uploader = FileUploader.new(show_model, params[:secret])
- file_uploader.retrieve_from_store!(params[:filename])
+ def build_uploader_from_upload
+ return unless uploader = build_uploader
- file_uploader
- end
+ upload_paths = uploader.upload_paths(params[:filename])
+ upload = Upload.find_by(uploader: uploader_class.to_s, path: upload_paths)
+ upload&.build_uploader
+ end
+
+ def build_uploader_from_params
+ return unless uploader = build_uploader
+
+ uploader.retrieve_from_store!(params[:filename])
+ uploader
+ end
+
+ def build_uploader
+ return unless params[:secret] && params[:filename]
+
+ uploader = uploader_class.new(model, secret: params[:secret])
+
+ return unless uploader.model_valid?
+
+ uploader
end
def image_or_video?
uploader && uploader.exists? && uploader.image_or_video?
end
- def uploader_class
- FileUploader
+ def find_model
+ nil
+ end
+
+ def model
+ strong_memoize(:model) { find_model }
end
end
diff --git a/app/controllers/concerns/with_performance_bar.rb b/app/controllers/concerns/with_performance_bar.rb
index 230bbe4b1aa..6a8b1a4de7b 100644
--- a/app/controllers/concerns/with_performance_bar.rb
+++ b/app/controllers/concerns/with_performance_bar.rb
@@ -6,13 +6,22 @@ module WithPerformanceBar
end
def peek_enabled?
- return true if Rails.env.development?
return false unless Gitlab::PerformanceBar.enabled?(current_user)
if RequestStore.active?
- RequestStore.fetch(:peek_enabled) { cookies[:perf_bar_enabled].present? }
+ RequestStore.fetch(:peek_enabled) { cookie_or_default_value }
else
- cookies[:perf_bar_enabled].present?
+ cookie_or_default_value
+ end
+ end
+
+ private
+
+ def cookie_or_default_value
+ if cookies[:perf_bar_enabled].present?
+ cookies[:perf_bar_enabled] == 'true'
+ else
+ cookies[:perf_bar_enabled] = 'true' if Rails.env.development?
end
end
end
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index bc0948cd3fb..6d9c38d9581 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -17,7 +17,7 @@ class ConfirmationsController < Devise::ConfirmationsController
else
Gitlab::AppLogger.info("Email Confirmed: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip}")
flash[:notice] += " Please sign in."
- new_session_path(:user)
+ new_session_path(:user, anchor: 'login-pane')
end
end
diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb
index 9d3d1c23c28..9fb5c525425 100644
--- a/app/controllers/dashboard/application_controller.rb
+++ b/app/controllers/dashboard/application_controller.rb
@@ -1,6 +1,10 @@
class Dashboard::ApplicationController < ApplicationController
+ include ControllerWithCrossProjectAccessCheck
+
layout 'dashboard'
+ requires_cross_project_access
+
private
def projects
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index 025769f512a..79f563bef86 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -1,6 +1,8 @@
class Dashboard::GroupsController < Dashboard::ApplicationController
include GroupTree
+ skip_cross_project_access_check :index
+
def index
groups = GroupsFinder.new(current_user, all_available: false).execute
render_group_tree(groups)
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index de9f8f9224a..4d4ac025f8c 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -4,6 +4,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
before_action :set_non_archived_param
before_action :default_sorting
+ skip_cross_project_access_check :index, :starred
def index
@projects = load_projects(params.merge(non_public: true)).page(params[:page])
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index 8dd91264451..0ba97e4fd59 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -1,4 +1,6 @@
class Dashboard::SnippetsController < Dashboard::ApplicationController
+ skip_cross_project_access_check :index
+
def index
@snippets = SnippetsFinder.new(
current_user,
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 280ed93faf8..68d328fa797 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -2,9 +2,17 @@ class DashboardController < Dashboard::ApplicationController
include IssuesAction
include MergeRequestsAction
+ FILTER_PARAMS = [
+ :author_id,
+ :assignee_id,
+ :milestone_title,
+ :label_name
+ ].freeze
+
before_action :event_filter, only: :activity
before_action :projects, only: [:issues, :merge_requests]
before_action :set_show_full_reference, only: [:issues, :merge_requests]
+ before_action :check_filters_presence!, only: [:issues, :merge_requests]
respond_to :html
@@ -39,4 +47,15 @@ class DashboardController < Dashboard::ApplicationController
def set_show_full_reference
@show_full_reference = true
end
+
+ def check_filters_presence!
+ @no_filters_set = FILTER_PARAMS.none? { |k| params.key?(k) }
+
+ return unless @no_filters_set
+
+ respond_to do |format|
+ format.html
+ format.atom { head :bad_request }
+ end
+ end
end
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index 96ce686c989..9f3bb60b4cc 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -1,10 +1,12 @@
class Groups::ApplicationController < ApplicationController
include RoutableActions
+ include ControllerWithCrossProjectAccessCheck
layout 'group'
skip_before_action :authenticate_user!
before_action :group
+ requires_cross_project_access
private
@@ -16,10 +18,6 @@ class Groups::ApplicationController < ApplicationController
@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
diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb
index 735915abdaa..cc5ba5878f8 100644
--- a/app/controllers/groups/avatars_controller.rb
+++ b/app/controllers/groups/avatars_controller.rb
@@ -1,6 +1,8 @@
class Groups::AvatarsController < Groups::ApplicationController
before_action :authorize_admin_group!
+ skip_cross_project_access_check :destroy
+
def destroy
@group.remove_avatar!
@group.save
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
new file mode 100644
index 00000000000..7c2016f0326
--- /dev/null
+++ b/app/controllers/groups/boards_controller.rb
@@ -0,0 +1,27 @@
+class Groups::BoardsController < Groups::ApplicationController
+ include BoardsResponses
+
+ before_action :assign_endpoint_vars
+
+ def index
+ @boards = Boards::ListService.new(group, current_user).execute
+
+ respond_with_boards
+ end
+
+ def show
+ @board = group.boards.find(params[:id])
+
+ respond_with_board
+ end
+
+ def assign_endpoint_vars
+ @boards_endpoint = group_boards_url(group)
+ @namespace_path = group.to_param
+ @labels_endpoint = group_labels_url(group)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(only: [:id])
+ end
+end
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
index b474f5d15ee..0e8125d6113 100644
--- a/app/controllers/groups/children_controller.rb
+++ b/app/controllers/groups/children_controller.rb
@@ -1,6 +1,7 @@
module Groups
class ChildrenController < Groups::ApplicationController
before_action :group
+ skip_cross_project_access_check :index
def index
parent = if params[:parent_id].present?
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 21e77431176..134b0dfc0db 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -6,6 +6,10 @@ class Groups::GroupMembersController < Groups::ApplicationController
# Authorize
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access]
+ skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
+ :approve_access_request, :leave, :resend_invite,
+ :override
+
def index
@sort = params[:sort].presence || sort_value_name
@project = @group.projects.find(params[:project_id]) if params[:project_id]
@@ -13,7 +17,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
@members = GroupMembersFinder.new(@group).execute
@members = @members.non_invite unless can?(current_user, :admin_group, @group)
@members = @members.search(params[:search]) if params[:search].present?
- @members = @members.sort(@sort)
+ @members = @members.sort_by_attribute(@sort)
@members = @members.page(params[:page]).per(50)
@members = present_members(@members.includes(:user))
@@ -23,35 +27,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
@group_member = @group.group_members.new
end
- def update
- @group_member = @group.members_and_requesters.find(params[:id])
- .present(current_user: current_user)
-
- return render_403 unless can?(current_user, :update_group_member, @group_member)
-
- @group_member.update_attributes(member_params)
- end
-
- def resend_invite
- redirect_path = group_group_members_path(@group)
-
- @group_member = @group.group_members.find(params[:id])
-
- if @group_member.invite?
- @group_member.resend_invite
-
- redirect_to redirect_path, notice: 'The invitation was successfully resent.'
- else
- redirect_to redirect_path, alert: 'The invitation has already been accepted.'
- end
- end
-
- protected
-
- def member_params
- params.require(:group_member).permit(:access_level, :user_id, :expires_at)
- end
-
# MembershipActions concern
alias_method :membershipable, :group
end
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index dda59262483..58be330f466 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -14,7 +14,14 @@ class Groups::LabelsController < Groups::ApplicationController
end
format.json do
- available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute
+ available_labels = LabelsFinder.new(
+ current_user,
+ group_id: @group.id,
+ only_group_labels: params[:only_group_labels],
+ include_ancestor_groups: params[:include_ancestor_groups],
+ include_descendant_groups: params[:include_descendant_groups]
+ ).execute
+
render json: LabelSerializer.new.represent_appearance(available_labels)
end
end
@@ -28,10 +35,18 @@ class Groups::LabelsController < Groups::ApplicationController
def create
@label = Labels::CreateService.new(label_params).execute(group: group)
- if @label.valid?
- redirect_to group_labels_path(@group)
- else
- render :new
+ respond_to do |format|
+ format.html do
+ if @label.valid?
+ redirect_to group_labels_path(@group)
+ else
+ render :new
+ end
+ end
+
+ format.json do
+ render json: LabelSerializer.new.represent_appearance(@label)
+ end
end
end
@@ -54,7 +69,7 @@ class Groups::LabelsController < Groups::ApplicationController
respond_to do |format|
format.html do
- redirect_to group_labels_path(@group), status: 302, notice: 'Label was removed'
+ redirect_to group_labels_path(@group), status: 302, notice: "#{@label.name} deleted permanently"
end
format.js
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index f013d21275e..5903689dc62 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
format.json do
- render json: milestones.map { |m| m.for_display.slice(:title, :name) }
+ render json: milestones.map { |m| m.for_display.slice(:id, :title, :name) }
end
end
end
@@ -75,8 +75,6 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def milestones
- search_params = params.merge(group_ids: group.id)
-
milestones = MilestonesFinder.new(search_params).execute
legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
@@ -94,4 +92,8 @@ class Groups::MilestonesController < Groups::ApplicationController
render_404 unless @milestone
end
+
+ def search_params
+ params.permit(:state).merge(group_ids: group.id)
+ end
end
diff --git a/app/controllers/groups/settings/badges_controller.rb b/app/controllers/groups/settings/badges_controller.rb
new file mode 100644
index 00000000000..edb334a3d88
--- /dev/null
+++ b/app/controllers/groups/settings/badges_controller.rb
@@ -0,0 +1,13 @@
+module Groups
+ module Settings
+ class BadgesController < Groups::ApplicationController
+ include GrapeRouteHelpers::NamedRouteMatcher
+
+ before_action :authorize_admin_group!
+
+ def index
+ @badge_api_endpoint = api_v4_groups_badges_path(id: @group.id)
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 0142ad8278c..4bf6a2a3ad1 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -1,6 +1,7 @@
module Groups
module Settings
class CiCdController < Groups::ApplicationController
+ skip_cross_project_access_check :show
before_action :authorize_admin_pipeline!
def show
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
index e6bd9806401..f1578f75e88 100644
--- a/app/controllers/groups/uploads_controller.rb
+++ b/app/controllers/groups/uploads_controller.rb
@@ -7,29 +7,23 @@ class Groups::UploadsController < Groups::ApplicationController
private
- def show_model
- strong_memoize(:show_model) do
- group_id = params[:group_id]
-
- Group.find_by_full_path(group_id)
- end
+ def upload_model_class
+ Group
end
- def authorize_upload_file!
- render_404 unless can?(current_user, :upload_file, group)
+ def uploader_class
+ NamespaceFileUploader
end
- def uploader
- strong_memoize(:uploader) do
- file_uploader = uploader_class.new(show_model, params[:secret])
- file_uploader.retrieve_from_store!(params[:filename])
- file_uploader
- end
- end
+ def find_model
+ return @group if @group
- def uploader_class
- NamespaceFileUploader
+ group_id = params[:group_id]
+
+ Group.find_by_full_path(group_id)
end
- alias_method :model, :group
+ def authorize_upload_file!
+ render_404 unless can?(current_user, :upload_file, group)
+ end
end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 10038ff3ad9..6142e75b4c1 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -1,60 +1,45 @@
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
+ skip_cross_project_access_check :show, :update
def show
+ respond_to do |format|
+ format.json do
+ render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
+ end
+ end
end
def update
- if variable.update(variable_params)
- redirect_to group_variables_path(group),
- notice: 'Variable was successfully updated.'
+ if @group.update(group_variables_params)
+ respond_to do |format|
+ format.json { return render_group_variables }
+ end
else
- render "show"
+ respond_to do |format|
+ format.json { render_error }
+ end
end
end
- def create
- @variable = group.variables.create(variable_params)
- .present(current_user: current_user)
+ private
- if @variable.persisted?
- redirect_to group_settings_ci_cd_path(group),
- notice: 'Variable was successfully created.'
- else
- render "show"
- end
+ def render_group_variables
+ render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) }
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
+ def render_error
+ render status: :bad_request, json: @group.errors.full_messages
end
- private
-
- def variable_params
- params.require(:variable).permit(*variable_params_attributes)
+ def group_variables_params
+ params.permit(variables_attributes: [*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)
+ %i[id key secret_value protected _destroy]
end
def authorize_admin_build!
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index eb53a522f90..283c3e5f1e0 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -10,15 +10,20 @@ class GroupsController < Groups::ApplicationController
before_action :group, except: [:index, :new, :create]
# Authorize
- before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects]
+ before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects, :transfer]
before_action :authorize_create_group!, only: [:new]
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]
+ skip_cross_project_access_check :index, :new, :create, :edit, :update,
+ :destroy, :projects
+ # When loading show as an atom feed, we render events that could leak cross
+ # project information
+ skip_cross_project_access_check :show, if: -> { request.format.html? }
+
layout :determine_layout
def index
@@ -94,6 +99,19 @@ class GroupsController < Groups::ApplicationController
redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion."
end
+ def transfer
+ parent_group = Group.find_by(id: params[:new_parent_group_id])
+ service = ::Groups::TransferService.new(@group, current_user)
+
+ if service.execute(parent_group)
+ flash[:notice] = "Group '#{@group.name}' was successfully transferred."
+ redirect_to group_path(@group)
+ else
+ flash.now[:alert] = service.error
+ render :edit
+ end
+ end
+
protected
def authorize_create_group!
@@ -118,10 +136,10 @@ class GroupsController < Groups::ApplicationController
end
def group_params
- params.require(:group).permit(group_params_ce)
+ params.require(:group).permit(group_params_attributes)
end
- def group_params_ce
+ def group_params_attributes
[
:avatar,
:description,
@@ -150,7 +168,6 @@ class GroupsController < Groups::ApplicationController
@projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user)
.execute
.includes(:namespace)
- .page(params[:page])
@events = EventCollection
.new(@projects, offset: params[:offset].to_i, filter: event_filter)
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index a931b456a93..16abf7bab7e 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -8,7 +8,8 @@ class HealthController < ActionController::Base
Gitlab::HealthChecks::Redis::CacheCheck,
Gitlab::HealthChecks::Redis::QueuesCheck,
Gitlab::HealthChecks::Redis::SharedStateCheck,
- Gitlab::HealthChecks::FsShardsCheck
+ Gitlab::HealthChecks::FsShardsCheck,
+ Gitlab::HealthChecks::GitalyCheck
].freeze
def readiness
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index 38f379dbf4f..a394521698c 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -5,7 +5,7 @@ class HelpController < ApplicationController
# Taken from Jekyll
# https://github.com/jekyll/jekyll/blob/3.5-stable/lib/jekyll/document.rb#L13
- YAML_FRONT_MATTER_REGEXP = %r!\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)!m
+ YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
def index
# Remove YAML frontmatter so that it doesn't look weird
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 9de0297ecfd..c84fc2d305d 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -2,26 +2,16 @@ class Import::BaseController < ApplicationController
private
def find_or_create_namespace(names, owner)
- return current_user.namespace if names == owner
- return current_user.namespace unless current_user.can_create_group?
-
names = params[:target_namespace].presence || names
- full_path_namespace = Namespace.find_by_full_path(names)
- return full_path_namespace if full_path_namespace
+ return current_user.namespace if names == owner
+
+ group = Groups::NestedCreateService.new(current_user, group_path: names).execute
- names.split('/').inject(nil) do |parent, name|
- begin
- namespace = Group.create!(name: name,
- path: name,
- owner: current_user,
- parent: parent)
- namespace.add_owner(current_user)
+ group.errors.any? ? current_user.namespace : group
+ rescue => e
+ Gitlab::AppLogger.error(e)
- namespace
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
- Namespace.where(parent: parent).find_by_path_or_name(name)
- end
- end
+ current_user.namespace
end
end
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 5ad1e116e4e..61d81ad8a71 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -37,24 +37,30 @@ class Import::BitbucketController < Import::BaseController
def create
bitbucket_client = Bitbucket::Client.new(credentials)
- @repo_id = params[:repo_id].to_s
- name = @repo_id.gsub('___', '/')
+ repo_id = params[:repo_id].to_s
+ name = repo_id.gsub('___', '/')
repo = bitbucket_client.repo(name)
- @project_name = params[:new_name].presence || repo.name
+ project_name = params[:new_name].presence || repo.name
repo_owner = repo.owner
repo_owner = current_user.username if repo_owner == bitbucket_client.user.username
namespace_path = params[:new_namespace].presence || repo_owner
+ target_namespace = find_or_create_namespace(namespace_path, current_user)
- @target_namespace = find_or_create_namespace(namespace_path, current_user)
-
- if current_user.can?(:create_projects, @target_namespace)
+ if current_user.can?(:create_projects, target_namespace)
# The token in a session can be expired, we need to get most recent one because
# Bitbucket::Connection class refreshes it.
session[:bitbucket_token] = bitbucket_client.connection.token
- @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, credentials).execute
+
+ project = Gitlab::BitbucketImport::ProjectCreator.new(repo, project_name, target_namespace, current_user, credentials).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
else
- render 'unauthorized'
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
end
end
@@ -65,7 +71,7 @@ class Import::BitbucketController < Import::BaseController
end
def provider
- Gitlab::OAuth::Provider.config_for('bitbucket')
+ Gitlab::Auth::OAuth::Provider.config_for('bitbucket')
end
def options
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 5df6bd34185..669eb31a995 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -58,17 +58,17 @@ class Import::FogbugzController < Import::BaseController
end
def create
- @repo_id = params[:repo_id]
- repo = client.repo(@repo_id)
+ repo = client.repo(params[:repo_id])
fb_session = { uri: session[:fogbugz_uri], token: session[:fogbugz_token] }
- @target_namespace = current_user.namespace
- @project_name = repo.name
-
- namespace = @target_namespace
-
umap = session[:fogbugz_user_map] || client.user_map
- @project = Gitlab::FogbugzImport::ProjectCreator.new(repo, fb_session, namespace, current_user, umap).execute
+ project = Gitlab::FogbugzImport::ProjectCreator.new(repo, fb_session, current_user.namespace, current_user, umap).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
end
private
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index b8ba7921613..eb7d5fca367 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -36,16 +36,23 @@ class Import::GithubController < Import::BaseController
end
def create
- @repo_id = params[:repo_id].to_i
- repo = client.repo(@repo_id)
- @project_name = params[:new_name].presence || repo.name
+ repo = client.repo(params[:repo_id].to_i)
+ project_name = params[:new_name].presence || repo.name
namespace_path = params[:target_namespace].presence || current_user.namespace_path
- @target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path)
-
- if can?(current_user, :create_projects, @target_namespace)
- @project = Gitlab::LegacyGithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params, type: provider).execute
+ target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path)
+
+ if can?(current_user, :create_projects, target_namespace)
+ project = Gitlab::LegacyGithubImport::ProjectCreator
+ .new(repo, project_name, target_namespace, current_user, access_params, type: provider)
+ .execute(extra_project_attrs)
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
else
- render 'unauthorized'
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
end
end
@@ -68,15 +75,15 @@ class Import::GithubController < Import::BaseController
end
def new_import_url
- public_send("new_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend
+ public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def status_import_url
- public_send("status_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend
+ public_send("status_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def callback_import_url
- public_send("callback_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend
+ public_send("callback_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def provider_unauthorized
@@ -111,4 +118,12 @@ class Import::GithubController < Import::BaseController
def client_options
{}
end
+
+ def extra_project_attrs
+ {}
+ end
+
+ def extra_import_params
+ {}
+ end
end
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index 407154e59a0..18f1d20f5a9 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -24,15 +24,19 @@ class Import::GitlabController < Import::BaseController
end
def create
- @repo_id = params[:repo_id].to_i
- repo = client.project(@repo_id)
- @project_name = repo['name']
- @target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username'])
+ repo = client.project(params[:repo_id].to_i)
+ target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username'])
- if current_user.can?(:create_projects, @target_namespace)
- @project = Gitlab::GitlabImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute
+ if current_user.can?(:create_projects, target_namespace)
+ project = Gitlab::GitlabImport::ProjectCreator.new(repo, target_namespace, current_user, access_params).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
else
- render 'unauthorized'
+ render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 567957ba2cb..f22df992fe9 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -1,4 +1,5 @@
class Import::GitlabProjectsController < Import::BaseController
+ before_action :whitelist_query_limiting, only: [:create]
before_action :verify_gitlab_project_import_enabled
def new
@@ -40,4 +41,8 @@ class Import::GitlabProjectsController < Import::BaseController
:path, :namespace_id, :file
)
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42437')
+ end
end
diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb
index 7d7f13ce5d5..baa19fb383d 100644
--- a/app/controllers/import/google_code_controller.rb
+++ b/app/controllers/import/google_code_controller.rb
@@ -85,16 +85,16 @@ class Import::GoogleCodeController < Import::BaseController
end
def create
- @repo_id = params[:repo_id]
- repo = client.repo(@repo_id)
- @target_namespace = current_user.namespace
- @project_name = repo.name
-
- namespace = @target_namespace
-
+ repo = client.repo(params[:repo_id])
user_map = session[:google_code_user_map]
- @project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, current_user, user_map).execute
+ project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, current_user.namespace, current_user, user_map).execute
+
+ if project.persisted?
+ render json: ProjectSerializer.new.represent(project)
+ else
+ render json: { errors: project.errors.full_messages }, status: :unprocessable_entity
+ end
end
private
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 04b29aa2384..025d8270b7c 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -51,7 +51,7 @@ class InvitesController < ApplicationController
return if current_user
notice = "To accept this invitation, sign in"
- notice << " or create an account" if current_application_settings.allow_signup?
+ notice << " or create an account" if Gitlab::CurrentSettings.allow_signup?
notice << "."
store_location_for :user, request.fullpath
@@ -62,7 +62,7 @@ class InvitesController < ApplicationController
case source
when Project
project = member.source
- label = "project #{project.name_with_namespace}"
+ label = "project #{project.full_name}"
path = project_path(project)
when Group
group = member.source
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 7d6fe6a0232..67057b5b126 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -25,8 +25,7 @@ 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)
- if @authentication_result.failed? ||
- (@authentication_result.actor.present? && !@authentication_result.actor.is_a?(User))
+ if @authentication_result.failed?
render_unauthorized
end
end
diff --git a/app/controllers/koding_controller.rb b/app/controllers/koding_controller.rb
index 6b1e64ce819..745abf3c0f5 100644
--- a/app/controllers/koding_controller.rb
+++ b/app/controllers/koding_controller.rb
@@ -10,6 +10,6 @@ class KodingController < ApplicationController
private
def check_integration!
- render_404 unless current_application_settings.koding_enabled?
+ render_404 unless Gitlab::CurrentSettings.koding_enabled?
end
end
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index d81ad135198..33b682d2859 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -12,6 +12,7 @@ class MetricsController < ActionController::Base
)
"# Metrics are disabled, see: #{help_page}\n"
end
+
render text: response, content_type: 'text/plain; version=0.0.4'
end
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 2443f529c7b..a1fe02dc852 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -1,6 +1,6 @@
class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
- include Gitlab::CurrentSettings
include Gitlab::GonHelper
+ include Gitlab::Allowable
include PageLayoutHelper
include OauthApplications
@@ -9,6 +9,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
before_action :add_gon_variables
before_action :load_scopes, only: [:index, :create, :edit]
+ helper_method :can?
+
layout 'profile'
def index
@@ -31,7 +33,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
private
def verify_user_oauth_applications_enabled
- return if current_application_settings.user_oauth_applications?
+ return if Gitlab::CurrentSettings.user_oauth_applications?
redirect_to profile_path
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index e3c18cba1dd..5e6676ea513 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -10,14 +10,26 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
- if Gitlab::LDAP::Config.enabled?
- Gitlab::LDAP::Config.available_servers.each do |server|
+ if Gitlab::Auth::LDAP::Config.enabled?
+ Gitlab::Auth::LDAP::Config.available_servers.each do |server|
define_method server['provider_name'] do
ldap
end
end
end
+ # Extend the standard implementation to also increment
+ # the number of failed sign in attempts
+ def failure
+ if params[:username].present? && AuthHelper.form_based_provider?(failed_strategy.name)
+ user = User.by_login(params[:username])
+
+ user&.increment_failed_attempts!
+ end
+
+ super
+ end
+
# Extend the standard message generation to accept our custom exception
def failure_message
exception = env["omniauth.error"]
@@ -31,7 +43,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# We only find ourselves here
# if the authentication to LDAP was successful.
def ldap
- ldap_user = Gitlab::LDAP::User.new(oauth)
+ ldap_user = Gitlab::Auth::LDAP::User.new(oauth)
ldap_user.save if ldap_user.changed? # will also save new users
@user = ldap_user.gl_user
@@ -62,13 +74,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
redirect_to after_sign_in_path_for(current_user)
end
else
- saml_user = Gitlab::Saml::User.new(oauth)
+ saml_user = Gitlab::Auth::Saml::User.new(oauth)
saml_user.save if saml_user.changed?
@user = saml_user.gl_user
continue_login_process
end
- rescue Gitlab::OAuth::SignupDisabledError
+ rescue Gitlab::Auth::OAuth::User::SignupDisabledError
handle_signup_error
end
@@ -83,6 +95,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if ticket
handle_service_ticket oauth['provider'], ticket
end
+
handle_omniauth
end
@@ -90,9 +103,18 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if params['sid']
handle_service_ticket oauth['provider'], params['sid']
end
+
handle_omniauth
end
+ def auth0
+ if oauth['uid'].blank?
+ fail_auth0_login
+ else
+ handle_omniauth
+ end
+ end
+
private
def handle_omniauth
@@ -104,18 +126,20 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
log_audit_event(current_user, with: oauth['provider'])
redirect_to profile_account_path, notice: 'Authentication method updated'
else
- oauth_user = Gitlab::OAuth::User.new(oauth)
+ oauth_user = Gitlab::Auth::OAuth::User.new(oauth)
oauth_user.save
@user = oauth_user.gl_user
continue_login_process
end
- rescue Gitlab::OAuth::SignupDisabledError
+ rescue Gitlab::Auth::OAuth::User::SigninDisabledForProviderError
+ handle_disabled_provider
+ rescue Gitlab::Auth::OAuth::User::SignupDisabledError
handle_signup_error
end
def handle_service_ticket(provider, ticket)
- Gitlab::OAuth::Session.create provider, ticket
+ Gitlab::Auth::OAuth::Session.create provider, ticket
session[:service_tickets] ||= {}
session[:service_tickets][provider] = ticket
end
@@ -124,6 +148,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# Only allow properly saved users to login.
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)
@@ -137,10 +162,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
def handle_signup_error
- label = Gitlab::OAuth::Provider.label_for(oauth['provider'])
+ label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
message = "Signing in using your #{label} account without a pre-existing GitLab account is not allowed."
- if current_application_settings.allow_signup?
+ if Gitlab::CurrentSettings.allow_signup?
message << " Create a GitLab account first, and then connect it to your #{label} account."
end
@@ -165,6 +190,19 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
redirect_to new_user_session_path
end
+ def fail_auth0_login
+ flash[:alert] = 'Wrong extern UID provided. Make sure Auth0 is configured correctly.'
+
+ redirect_to new_user_session_path
+ end
+
+ def handle_disabled_provider
+ label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
+ flash[:alert] = "Signing in using #{label} has been disabled"
+
+ redirect_to new_user_session_path
+ end
+
def log_audit_event(user, options = {})
AuditEventService.new(user, user, options)
.for_authentication.security_event
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index 68a52f40342..331583c49e6 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -1,5 +1,5 @@
class PasswordsController < Devise::PasswordsController
- include Gitlab::CurrentSettings
+ skip_before_action :require_no_authentication, only: [:edit, :update]
before_action :resource_from_email, only: [:create]
before_action :check_password_authentication_available, only: [:create]
@@ -44,7 +44,7 @@ class PasswordsController < Devise::PasswordsController
if resource
return if resource.allow_password_authentication?
else
- return if current_application_settings.password_authentication_enabled?
+ return if Gitlab::CurrentSettings.password_authentication_enabled?
end
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index fa72f67c77e..b8ccc6e3c99 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -1,5 +1,6 @@
class Profiles::PasswordsController < Profiles::ApplicationController
skip_before_action :check_password_expiration, only: [:new, :create]
+ skip_before_action :check_two_factor_requirement, only: [:new, :create]
before_action :set_user
before_action :authorize_change_password!
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index dbf61a17724..ac71f72e624 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -51,15 +51,21 @@ class ProfilesController < Profiles::ApplicationController
end
def update_username
- result = Users::UpdateService.new(current_user, user: @user, username: user_params[:username]).execute
+ result = Users::UpdateService.new(current_user, user: @user, username: username_param).execute
- options = if result[:status] == :success
- { notice: "Username successfully changed" }
- else
- { alert: "Username change failed - #{result[:message]}" }
- end
+ respond_to do |format|
+ if result[:status] == :success
+ message = s_("Profiles|Username successfully changed")
+
+ format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) }
+ format.json { render json: { message: message }, status: :ok }
+ else
+ message = s_("Profiles|Username change failed - %{message}") % { message: result[:message] }
- redirect_back_or_default(default: { action: 'show' }, options: options)
+ format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: message }) }
+ format.json { render json: { message: message }, status: :unprocessable_entity }
+ end
+ end
end
private
@@ -72,6 +78,10 @@ class ProfilesController < Profiles::ApplicationController
return render_404 unless @user.can_change_username?
end
+ def username_param
+ @username_param ||= user_params.require(:username)
+ end
+
def user_params
@user_params ||= params.require(:user).permit(
:avatar,
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 9e79852e378..6d9b42a2c04 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -6,7 +6,7 @@ class Projects::ApplicationController < ApplicationController
before_action :repository
layout 'project'
- helper_method :repository, :can_collaborate_with_project?
+ helper_method :repository, :can_collaborate_with_project?, :user_access
private
@@ -31,11 +31,12 @@ class Projects::ApplicationController < ApplicationController
@repository ||= project.repository
end
- def can_collaborate_with_project?(project = nil)
+ def can_collaborate_with_project?(project = nil, ref: nil)
project ||= @project
can?(current_user, :push_code, project) ||
- (current_user && current_user.already_forked?(project))
+ (current_user && current_user.already_forked?(project)) ||
+ user_access(project).can_push_to_branch?(ref)
end
def authorize_action!(action)
@@ -86,4 +87,13 @@ class Projects::ApplicationController < ApplicationController
def require_pages_enabled!
not_found unless @project.pages_available?
end
+
+ def check_issues_available!
+ return render_404 unless @project.feature_available?(:issues, current_user)
+ end
+
+ def user_access(project)
+ @user_access ||= {}
+ @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project)
+ end
end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 0837451cc49..abc283d7aa9 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -1,6 +1,7 @@
class Projects::ArtifactsController < Projects::ApplicationController
include ExtractsPath
include RendersBlob
+ include SendFileUpload
layout 'project'
before_action :authorize_read_build!
@@ -10,11 +11,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
before_action :entry, only: [:file]
def download
- if artifacts_file.file_storage?
- send_file artifacts_file.path, disposition: 'attachment'
- else
- redirect_to artifacts_file.url
- end
+ send_upload(artifacts_file, attachment: artifacts_file.filename)
end
def browse
@@ -45,8 +42,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def raw
- path = Gitlab::Ci::Build::Artifacts::Path
- .new(params[:path])
+ path = Gitlab::Ci::Build::Artifacts::Path.new(params[:path])
send_artifacts_entry(build, path)
end
@@ -75,7 +71,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
def validate_artifacts!
- render_404 unless build && build.artifacts?
+ render_404 unless build&.artifacts?
end
def build
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index 45c66b63ea5..992c8ea6992 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -34,9 +34,9 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
def target
case params[:type]&.downcase
when 'issue'
- IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
+ IssuesFinder.new(current_user, project_id: @project.id).find_by(iid: params[:type_id])
when 'mergerequest'
- MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
+ MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:type_id])
when 'commit'
@project.commit(params[:type_id])
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index d838b8dc29e..0c1c286a0a4 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -9,8 +9,12 @@ class Projects::BlobController < Projects::ApplicationController
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
- before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
+
+ # We need to assign the blob vars before `authorize_edit_tree!` so we can
+ # validate access to a specific ref.
before_action :assign_blob_vars
+ before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
+
before_action :commit, except: [:new, :create]
before_action :blob, except: [:new, :create]
before_action :require_branch_head, only: [:edit, :update]
@@ -38,7 +42,7 @@ class Projects::BlobController < Projects::ApplicationController
end
format.json do
- page_title @blob.path, @ref, @project.name_with_namespace
+ page_title @blob.path, @ref, @project.full_name
show_json
end
@@ -46,7 +50,7 @@ class Projects::BlobController < Projects::ApplicationController
end
def edit
- if can_collaborate_with_project?
+ if can_collaborate_with_project?(project, ref: @ref)
blob.load_all_data!
else
redirect_to action: 'show'
@@ -133,7 +137,7 @@ class Projects::BlobController < Projects::ApplicationController
end
def after_edit_path
- from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:from_merge_request_iid])
+ from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:from_merge_request_iid])
if from_merge_request && @branch_name == @ref
diffs_project_merge_request_path(from_merge_request.target_project, from_merge_request) +
"##{hexdigest(@path)}"
@@ -150,6 +154,7 @@ class Projects::BlobController < Projects::ApplicationController
if params[:file].present?
params[:file_name] = params[:file].original_filename
end
+
File.join(@path, params[:file_name])
elsif params[:file_path].present?
params[:file_path]
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index e36105ddc11..949e54ff819 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -2,6 +2,7 @@ class Projects::BoardsController < Projects::ApplicationController
include BoardsResponses
include IssuableCollections
+ before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 56df9991fda..b7b36f770f5 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -7,28 +7,36 @@ class Projects::BranchesController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_push_code!, only: [:new, :create, :destroy, :destroy_all_merged]
- def index
- @sort = params[:sort].presence || sort_value_recently_updated
- @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute
- @branches = Kaminari.paginate_array(@branches).page(params[:page])
+ # Support legacy URLs
+ before_action :redirect_for_legacy_index_sort_or_search, only: [:index]
+ def index
respond_to do |format|
format.html do
+ @sort = params[:sort].presence || sort_value_recently_updated
+ @mode = params[:state].presence || 'overview'
+ @overview_max_branches = 5
+
+ # Fetch branches for the specified mode
+ fetch_branches_by_mode
+
@refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name))
- @merged_branch_names =
- repository.merged_branch_names(@branches.map(&:name))
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37429
+ @merged_branch_names = repository.merged_branch_names(@branches.map(&:name))
+
+ # n+1: https://gitlab.com/gitlab-org/gitaly/issues/992
Gitlab::GitalyClient.allow_n_plus_1_calls do
@max_commits = @branches.reduce(0) do |memo, branch|
diverging_commit_counts = repository.diverging_commit_counts(branch)
[memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max
end
-
- render
end
+
+ render
end
format.json do
- render json: @branches.map(&:name)
+ branches = BranchesFinder.new(@repository, params).execute
+ branches = Kaminari.paginate_array(branches).page(params[:page])
+ render json: branches.map(&:name)
end
end
end
@@ -46,14 +54,16 @@ class Projects::BranchesController < Projects::ApplicationController
result = CreateBranchService.new(project, current_user)
.execute(branch_name, ref)
- if params[:issue_iid]
+ success = (result[:status] == :success)
+
+ if params[:issue_iid] && success
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
respond_to do |format|
format.html do
- if result[:status] == :success
+ if success
if redirect_to_autodeploy
redirect_to url_to_autodeploy_setup(project, branch_name),
notice: view_context.autodeploy_flash_notice(branch_name)
@@ -67,7 +77,7 @@ class Projects::BranchesController < Projects::ApplicationController
end
format.json do
- if result[:status] == :success
+ if success
render json: { name: branch_name, url: project_tree_url(@project, branch_name) }
else
render json: result[:messsage], status: :unprocessable_entity
@@ -121,4 +131,27 @@ class Projects::BranchesController < Projects::ApplicationController
context: 'autodeploy'
)
end
+
+ def redirect_for_legacy_index_sort_or_search
+ # Normalize a legacy URL with redirect
+ if request.format != :json && !params[:state].presence && [:sort, :search, :page].any? { |key| params[key].presence }
+ redirect_to project_branches_filtered_path(@project, state: 'all'), notice: 'Update your bookmarked URLs as filtered/sorted branches URL has been changed.'
+ end
+ end
+
+ def fetch_branches_by_mode
+ if @mode == 'overview'
+ # overview mode
+ @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?)
+ # Here we get one more branch to indicate if there are more data we're not showing
+ @active_branches = @active_branches.first(@overview_max_branches + 1)
+ @stale_branches = @stale_branches.first(@overview_max_branches + 1)
+ @branches = @active_branches + @stale_branches
+ else
+ # active/stale/all view mode
+ @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute
+ @branches = @branches.select { |b| b.state.to_s == @mode } if %w[active stale].include?(@mode)
+ @branches = Kaminari.paginate_array(@branches).page(params[:page])
+ end
+ end
end
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
new file mode 100644
index 00000000000..a2185572a20
--- /dev/null
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -0,0 +1,27 @@
+class Projects::Ci::LintsController < Projects::ApplicationController
+ before_action :authorize_create_pipeline!
+
+ def show
+ end
+
+ def create
+ @content = params[:content]
+ @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options)
+ @status = @error.blank?
+
+ if @error.blank?
+ @config_processor = Gitlab::Ci::YamlProcessor.new(@content, yaml_processor_options)
+ @stages = @config_processor.stages
+ @builds = @config_processor.builds
+ @jobs = @config_processor.jobs
+ end
+
+ render :show
+ end
+
+ private
+
+ def yaml_processor_options
+ { project: @project, sha: project.repository.commit.sha }
+ end
+end
diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb
index d3b9d8a9bbc..6b0b22f8e73 100644
--- a/app/controllers/projects/clusters/gcp_controller.rb
+++ b/app/controllers/projects/clusters/gcp_controller.rb
@@ -1,7 +1,9 @@
class Projects::Clusters::GcpController < Projects::ApplicationController
before_action :authorize_read_cluster!
before_action :authorize_google_api, except: [:login]
+ before_action :authorize_google_project_billing, only: [:new, :create]
before_action :authorize_create_cluster!, only: [:new, :create]
+ before_action :verify_billing, only: [:create]
def login
begin
@@ -35,6 +37,21 @@ class Projects::Clusters::GcpController < Projects::ApplicationController
private
+ def verify_billing
+ case google_project_billing_status
+ when nil
+ flash.now[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
+ when false
+ flash.now[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" }
+ when true
+ return
+ end
+
+ @cluster = ::Clusters::Cluster.new(create_params)
+
+ render :new
+ end
+
def create_params
params.require(:cluster).permit(
:enabled,
@@ -58,6 +75,15 @@ class Projects::Clusters::GcpController < Projects::ApplicationController
end
end
+ def authorize_google_project_billing
+ redis_token_key = CheckGcpProjectBillingWorker.store_session_token(token_in_session)
+ CheckGcpProjectBillingWorker.perform_async(redis_token_key)
+ end
+
+ def google_project_billing_status
+ CheckGcpProjectBillingWorker.get_billing_state(token_in_session)
+ end
+
def token_in_session
@token_in_session ||=
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
index 1dc7f1b3a7f..aeaba3a0acf 100644
--- a/app/controllers/projects/clusters_controller.rb
+++ b/app/controllers/projects/clusters_controller.rb
@@ -4,6 +4,7 @@ class Projects::ClustersController < Projects::ApplicationController
before_action :authorize_create_cluster!, only: [:new]
before_action :authorize_update_cluster!, only: [:update]
before_action :authorize_admin_cluster!, only: [:destroy]
+ before_action :update_applications_status, only: [:status]
STATUS_POLLING_INTERVAL = 10_000
@@ -41,7 +42,7 @@ class Projects::ClustersController < Projects::ApplicationController
head :no_content
end
format.html do
- flash[:notice] = "Cluster was successfully updated."
+ flash[:notice] = _('Kubernetes cluster was successfully updated.')
redirect_to project_cluster_path(project, cluster)
end
end
@@ -55,10 +56,10 @@ class Projects::ClustersController < Projects::ApplicationController
def destroy
if cluster.destroy
- flash[:notice] = "Cluster integration was successfully removed."
+ flash[:notice] = _('Kubernetes cluster integration was successfully removed.')
redirect_to project_clusters_path(project), status: 302
else
- flash[:notice] = "Cluster integration was not removed."
+ flash[:notice] = _('Kubernetes cluster integration was not removed.')
render :show
end
end
@@ -114,4 +115,8 @@ class Projects::ClustersController < Projects::ApplicationController
def authorize_admin_cluster!
access_denied! unless can?(current_user, :admin_cluster, cluster)
end
+
+ def update_applications_status
+ @cluster.applications.each(&:schedule_status_update)
+ end
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 2e7344b1cad..effb484ef0f 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -12,7 +12,7 @@ class Projects::CommitController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_read_pipeline!, only: [:pipelines]
before_action :commit
- before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines]
+ before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines, :merge_requests]
before_action :define_note_vars, only: [:show, :diff_for_path]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
@@ -52,6 +52,18 @@ class Projects::CommitController < Projects::ApplicationController
end
end
+ def merge_requests
+ @merge_requests = @commit.merge_requests.map do |mr|
+ { iid: mr.iid, path: merge_request_path(mr), title: mr.title }
+ end
+
+ respond_to do |format|
+ format.json do
+ render json: @merge_requests.to_json
+ end
+ end
+ end
+
def branches
# branch_names_contains/tag_names_contains can take a long time when there are thousands of
# branches/tags - each `git branch --contains xxx` request can consume a cpu core.
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 026708169f4..7b7cb52d7ed 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -4,6 +4,7 @@ class Projects::CommitsController < Projects::ApplicationController
include ExtractsPath
include RendersCommits
+ before_action :whitelist_query_limiting
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
@@ -59,4 +60,8 @@ class Projects::CommitsController < Projects::ApplicationController
@commits = @commits.with_pipeline_status
@commits = prepare_commits_for_rendering(@commits)
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42330')
+ end
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 3cb4eb23981..2b0c2ca97c0 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -17,10 +17,8 @@ class Projects::CompareController < Projects::ApplicationController
def show
apply_diff_view_cookie!
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37430
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- render
- end
+
+ render
end
def diff_for_path
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 88ac3ad046b..d1b8fd80c4e 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -3,6 +3,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
+ before_action :whitelist_query_limiting, only: [:show]
before_action :authorize_read_cycle_analytics!
def show
@@ -31,4 +32,8 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
permissions: @cycle_analytics.permissions(user: current_user)
}
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42671')
+ end
end
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index cf8829ba95b..f43ef2e5f2f 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -24,9 +24,10 @@ class Projects::DeployKeysController < Projects::ApplicationController
def create
@key = DeployKeys::CreateService.new(current_user, create_params).execute
- unless @key.valid? && @project.deploy_keys << @key
+ unless @key.valid?
flash[:alert] = @key.errors.full_messages.join(', ').html_safe
end
+
redirect_to_repository_settings(@project)
end
@@ -70,11 +71,14 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create_params
- params.require(:deploy_key).permit(:key, :title, :can_push)
+ create_params = params.require(:deploy_key)
+ .permit(:key, :title, deploy_keys_projects_attributes: [:can_push])
+ create_params.dig(:deploy_keys_projects_attributes, '0')&.merge!(project_id: @project.id)
+ create_params
end
def update_params
- params.require(:deploy_key).permit(:title, :can_push)
+ params.require(:deploy_key).permit(:title, deploy_keys_projects_attributes: [:id, :can_push])
end
def authorize_update_deploy_key!
diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb
new file mode 100644
index 00000000000..2f91b8f36de
--- /dev/null
+++ b/app/controllers/projects/deploy_tokens_controller.rb
@@ -0,0 +1,10 @@
+class Projects::DeployTokensController < Projects::ApplicationController
+ before_action :authorize_admin_project!
+
+ def revoke
+ @token = @project.deploy_tokens.find(params[:id])
+ @token.revoke!
+
+ redirect_to project_settings_repository_path(project)
+ end
+end
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index 1a418d0f15a..b68cdc39cb8 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -24,7 +24,7 @@ class Projects::DeploymentsController < Projects::ApplicationController
end
def additional_metrics
- return render_404 unless deployment.has_additional_metrics?
+ return render_404 unless deployment.has_metrics?
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 2e6ab7903b8..8e86af43fee 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -1,28 +1,60 @@
class Projects::DiscussionsController < Projects::ApplicationController
+ include NotesHelper
+ include RendersNotes
+
before_action :check_merge_requests_available!
before_action :merge_request
- before_action :discussion
- before_action :authorize_resolve_discussion!
+ before_action :discussion, only: [:resolve, :unresolve]
+ before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve]
def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
- render json: {
- resolved_by: discussion.resolved_by.try(:name),
- discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
- }
+ render_discussion
end
def unresolve
discussion.unresolve!
+ render_discussion
+ end
+
+ def show
render json: {
- discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
+ discussion_html: view_to_html_string('discussions/_diff_with_notes', discussion: discussion, expanded: true)
}
end
private
+ def render_discussion
+ if serialize_notes?
+ # TODO - It is not needed to serialize notes when resolving
+ # or unresolving discussions. We should remove this behavior
+ # passing a parameter to DiscussionEntity to return an empty array
+ # for notes.
+ # Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853
+ prepare_notes_for_rendering(discussion.notes, merge_request)
+ render_json_with_discussions_serializer
+ else
+ render_json_with_html
+ end
+ end
+
+ def render_json_with_discussions_serializer
+ render json:
+ DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity)
+ .represent(discussion, context: self)
+ end
+
+ # Legacy method used to render discussions notes when not using Vue on views.
+ def render_json_with_html
+ render json: {
+ resolved_by: discussion.resolved_by.try(:name),
+ discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
+ }
+ end
+
def merge_request
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
end
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 68978f8fdd1..f43bba18d81 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -2,6 +2,7 @@ class Projects::ForksController < Projects::ApplicationController
include ContinueParams
# Authorize
+ before_action :whitelist_query_limiting, only: [:create]
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authenticate_user!, only: [:new, :create]
@@ -54,4 +55,8 @@ class Projects::ForksController < Projects::ApplicationController
render :error
end
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42335')
+ end
end
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index dd5e66f60e3..07249fe3182 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -7,6 +7,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
attr_reader :authentication_result, :redirected_path
delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
+ delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
alias_method :user, :actor
alias_method :authenticated_user, :actor
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 71ae60cb8cd..1dcf837f78e 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -5,6 +5,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
+ rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
@@ -55,8 +56,15 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render plain: exception.message, status: :not_found
end
+ def render_422(exception)
+ render plain: exception.message, status: :unprocessable_entity
+ end
+
def access
- @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities, redirected_path: redirected_path)
+ @access ||= access_klass.new(access_actor, project,
+ 'http', authentication_abilities: authentication_abilities,
+ namespace_path: params[:namespace_id], project_path: project_path,
+ redirected_path: redirected_path, auth_result_type: auth_result_type)
end
def access_actor
@@ -68,12 +76,17 @@ class Projects::GitHttpController < Projects::GitHttpClientController
# Use the magic string '_any' to indicate we do not know what the
# changes are. This is also what gitlab-shell does.
access.check(git_command, '_any')
+ @project ||= access.project
end
def access_klass
@access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
end
+ def project_path
+ @project_path ||= params[:project_id].sub(/\.git$/, '')
+ end
+
def log_user_activity
Users::ActivityService.new(user, 'pull').execute
end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 85d35900c71..dd7aa1a67b9 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -21,6 +21,7 @@ class Projects::HooksController < Projects::ApplicationController
@hooks = @project.hooks.select(&:persisted?)
flash[:alert] = @hook.errors.full_messages.join.html_safe
end
+
redirect_to project_settings_integrations_path(@project)
end
@@ -63,18 +64,10 @@ class Projects::HooksController < Projects::ApplicationController
def hook_params
params.require(:hook).permit(
- :job_events,
- :pipeline_events,
:enable_ssl_verification,
- :issues_events,
- :confidential_issues_events,
- :merge_requests_events,
- :note_events,
- :push_events,
- :tag_push_events,
:token,
:url,
- :wiki_page_events
+ *ProjectHook.triggers.values
)
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index d7a3441a245..b14939c4216 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -8,6 +8,7 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:new]
+ before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
before_action :issue, except: [:index, :new, :create, :bulk_update]
before_action :set_issuables_index, only: [:index]
@@ -59,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue)
end
- def discussions
- notes = @issue.notes
- .inc_relations_for_view
- .includes(:noteable)
- .fresh
-
- notes = prepare_notes_for_rendering(notes)
- notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
-
- discussions = Discussion.build_collection(notes, @issue)
-
- render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
- end
-
def create
create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -121,8 +108,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
def referenced_merge_requests
- @merge_requests = @issue.referenced_merge_requests(current_user)
- @closed_by_merge_requests = @issue.closed_by_merge_requests(current_user)
+ @merge_requests, @closed_by_merge_requests = ::Issues::FetchReferencedMergeRequestsService.new(project, current_user).execute(issue)
respond_to do |format|
format.json do
@@ -194,10 +180,6 @@ class Projects::IssuesController < Projects::ApplicationController
render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user)
end
- def check_issues_available!
- return render_404 unless @project.feature_available?(:issues, current_user)
- end
-
def render_issue_json
if @issue.valid?
render json: serializer.represent(@issue)
@@ -247,8 +229,16 @@ class Projects::IssuesController < Projects::ApplicationController
Issues::UpdateService.new(project, current_user, update_params)
end
- def set_issuables_index
- @finder_type = IssuesFinder
- super
+ def finder_type
+ IssuesFinder
+ end
+
+ def whitelist_query_limiting
+ # Also see the following issues:
+ #
+ # 1. https://gitlab.com/gitlab-org/gitlab-ce/issues/42423
+ # 2. https://gitlab.com/gitlab-org/gitlab-ce/issues/42424
+ # 3. https://gitlab.com/gitlab-org/gitlab-ce/issues/42426
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42422')
end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 4865ec3dfe5..dd12d30a085 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -1,6 +1,7 @@
class Projects::JobsController < Projects::ApplicationController
- before_action :build, except: [:index, :cancel_all]
+ include SendFileUpload
+ before_action :build, except: [:index, :cancel_all]
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
@@ -29,7 +30,7 @@ class Projects::JobsController < Projects::ApplicationController
:project,
:tags
])
- @builds = @builds.page(params[:page]).per(30)
+ @builds = @builds.page(params[:page]).per(30).without_count
end
def cancel_all
@@ -43,8 +44,11 @@ class Projects::JobsController < Projects::ApplicationController
end
def show
- @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
- @builds = @builds.where("id not in (?)", @build.id)
+ @builds = @project.pipelines
+ .find_by_sha(@build.sha)
+ .builds
+ .order('id DESC')
+ .present(current_user: current_user)
@pipeline = @build.pipeline
respond_to do |format|
@@ -117,11 +121,17 @@ class Projects::JobsController < Projects::ApplicationController
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
+ if trace_artifact_file
+ send_upload(trace_artifact_file,
+ send_params: raw_send_params,
+ redirect_params: raw_redirect_params)
+ else
+ build.trace.read do |stream|
+ if stream.file?
+ send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
+ else
+ send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log'
+ end
end
end
end
@@ -136,9 +146,21 @@ class Projects::JobsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :erase_build, build)
end
+ def raw_send_params
+ { type: 'text/plain; charset=utf-8', disposition: 'inline' }
+ end
+
+ def raw_redirect_params
+ { query: { 'response-content-type' => 'text/plain; charset=utf-8', 'response-content-disposition' => 'inline' } }
+ end
+
+ def trace_artifact_file
+ @trace_artifact_file ||= build.job_artifacts_trace&.file
+ end
+
def build
@build ||= project.builds.find(params[:id])
- .present(current_user: current_user)
+ .present(current_user: current_user)
end
def build_path(build)
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index e0f4710175f..91016f6494e 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -112,12 +112,14 @@ class Projects::LabelsController < Projects::ApplicationController
begin
return render_404 unless promote_service.execute(@label)
+ flash[:notice] = "#{@label.title} promoted to <a href=\"#{group_labels_path(@project.group)}\">group label</a>.".html_safe
respond_to do |format|
format.html do
- redirect_to(project_labels_path(@project),
- notice: 'Label was promoted to a Group Label')
+ redirect_to(project_labels_path(@project), status: 303)
+ end
+ format.json do
+ render json: { url: project_labels_path(@project) }
end
- format.js
end
rescue ActiveRecord::RecordInvalid => e
Gitlab::AppLogger.error "Failed to promote label \"#{@label.title}\" to group label"
@@ -148,7 +150,8 @@ class Projects::LabelsController < Projects::ApplicationController
end
def find_labels
- @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute
+ @available_labels ||=
+ LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: params[:include_ancestor_groups]).execute
end
def authorize_admin_labels!
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index 536f908d2c5..ee4ed674110 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -41,7 +41,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
def existing_oids
@existing_oids ||= begin
- storage_project.lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid)
+ project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid)
end
end
@@ -98,7 +98,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
json: {
message: lfs_read_only_message
},
- content_type: 'application/vnd.git-lfs+json',
+ content_type: LfsRequest::CONTENT_TYPE,
status: 403
)
end
diff --git a/app/controllers/projects/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb
new file mode 100644
index 00000000000..3fff0fd69ae
--- /dev/null
+++ b/app/controllers/projects/lfs_locks_api_controller.rb
@@ -0,0 +1,70 @@
+class Projects::LfsLocksApiController < Projects::GitHttpClientController
+ include LfsRequest
+
+ def create
+ @result = Lfs::LockFileService.new(project, user, params).execute
+
+ render_json(@result[:lock])
+ end
+
+ def unlock
+ @result = Lfs::UnlockFileService.new(project, user, params).execute
+
+ render_json(@result[:lock])
+ end
+
+ def index
+ @result = Lfs::LocksFinderService.new(project, user, params).execute
+
+ render_json(@result[:locks])
+ end
+
+ def verify
+ @result = Lfs::LocksFinderService.new(project, user, {}).execute
+
+ ours, theirs = split_by_owner(@result[:locks])
+
+ render_json({ ours: ours, theirs: theirs }, false)
+ end
+
+ private
+
+ def render_json(data, process = true)
+ render json: build_payload(data, process),
+ content_type: LfsRequest::CONTENT_TYPE,
+ status: @result[:http_status]
+ end
+
+ def build_payload(data, process)
+ data = LfsFileLockSerializer.new.represent(data) if process
+
+ return data if @result[:status] == :success
+
+ # When the locking failed due to an existent Lock, the existent record
+ # is returned in `@result[:lock]`
+ error_payload(@result[:message], @result[:lock] ? data : {})
+ end
+
+ def error_payload(message, custom_attrs = {})
+ custom_attrs.merge({
+ message: message,
+ documentation_url: help_url
+ })
+ end
+
+ def split_by_owner(locks)
+ groups = locks.partition { |lock| lock.user_id == user.id }
+
+ groups.map! do |records|
+ LfsFileLockSerializer.new.represent(records, root: false)
+ end
+ end
+
+ def download_request?
+ params[:action] == 'index'
+ end
+
+ def upload_request?
+ %w(create unlock verify).include?(params[:action])
+ end
+end
diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb
index 293869345bd..ebde0df1f7b 100644
--- a/app/controllers/projects/lfs_storage_controller.rb
+++ b/app/controllers/projects/lfs_storage_controller.rb
@@ -1,6 +1,7 @@
class Projects::LfsStorageController < Projects::GitHttpClientController
include LfsRequest
include WorkhorseRequest
+ include SendFileUpload
skip_before_action :verify_workhorse_api!, only: [:download, :upload_finalize]
@@ -11,25 +12,30 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
return
end
- send_file lfs_object.file.path, content_type: "application/octet-stream"
+ send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" })
end
def upload_authorize
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.lfs_upload_ok(oid, size)
+
+ authorized = LfsObjectUploader.workhorse_authorize
+ authorized.merge!(LfsOid: oid, LfsSize: size)
+
+ render json: authorized
end
def upload_finalize
- unless tmp_filename
- render_lfs_forbidden
- return
- end
-
- if store_file(oid, size, tmp_filename)
+ if store_file!(oid, size)
head 200
else
render plain: 'Unprocessable entity', status: 422
end
+ rescue ActiveRecord::RecordInvalid
+ render_lfs_forbidden
+ rescue UploadedFile::InvalidPathError
+ render_lfs_forbidden
+ rescue ObjectStorage::RemoteStoreError
+ render_lfs_forbidden
end
private
@@ -50,38 +56,29 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
params[:size].to_i
end
- def tmp_filename
- name = request.headers['X-Gitlab-Lfs-Tmp']
- return if name.include?('/')
- return unless oid.present? && name.start_with?(oid)
-
- name
- end
+ def store_file!(oid, size)
+ object = LfsObject.find_by(oid: oid, size: size)
+ unless object&.file&.exists?
+ object = create_file!(oid, size)
+ end
- def store_file(oid, size, tmp_file)
- # Define tmp_file_path early because we use it in "ensure"
- tmp_file_path = File.join("#{Gitlab.config.lfs.storage_path}/tmp/upload", tmp_file)
+ return unless object
- object = LfsObject.find_or_create_by(oid: oid, size: size)
- file_exists = object.file.exists? || move_tmp_file_to_storage(object, tmp_file_path)
- file_exists && link_to_project(object)
- ensure
- FileUtils.rm_f(tmp_file_path)
+ link_to_project!(object)
end
- def move_tmp_file_to_storage(object, path)
- File.open(path) do |f|
- object.file = f
- end
+ def create_file!(oid, size)
+ uploaded_file = UploadedFile.from_params(
+ params, :file, LfsObjectUploader.workhorse_local_upload_path)
+ return unless uploaded_file
- object.file.store!
- object.save
+ LfsObject.create!(oid: oid, size: size, file: uploaded_file)
end
- def link_to_project(object)
+ def link_to_project!(object)
if object && !object.projects.exists?(storage_project.id)
object.projects << storage_project
- object.save
+ object.save!
end
end
end
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index 793ae03fb88..67d4ea2ca8f 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -15,6 +15,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
def merge_request_params_attributes
[
+ :allow_maintainer_to_push,
:assignee_id,
:description,
:force_remove_source_branch,
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index dc524b790a0..a90030a8312 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -4,6 +4,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
include RendersCommits
skip_before_action :merge_request
+ before_action :whitelist_query_limiting, only: [:create]
before_action :authorize_create_merge_request!
before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
before_action :build_merge_request, except: [:create]
@@ -43,11 +44,8 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
end
def diffs
- @diffs = if @merge_request.can_be_created
- @merge_request.diffs(diff_options)
- else
- []
- end
+ @diffs = @merge_request.diffs(diff_options) if @merge_request.can_be_created
+
@diff_notes_disabled = true
@environment = @merge_request.environments_for(current_user).last
@@ -77,7 +75,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def branch_to
@target_project = selected_target_project
- if params[:ref].present?
+ if @target_project && params[:ref].present?
@ref = params[:ref]
@commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
@@ -87,7 +85,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def update_branches
@target_project = selected_target_project
- @target_branches = @target_project.repository.branch_names
+ @target_branches = @target_project ? @target_project.repository.branch_names : []
render layout: false
end
@@ -123,9 +121,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
@project
elsif params[:target_project_id].present?
MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: @project)
- .execute.find(params[:target_project_id])
+ .find_by(id: params[:target_project_id])
else
@project.forked_from_project
end
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42384')
+ end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 6b59c8461a3..54e7d81de6a 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -7,9 +7,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include IssuableCollections
skip_before_action :merge_request, only: [:index, :bulk_update]
+ before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
+ before_action :check_user_can_push_to_source_branch!, only: [:rebase]
def index
@merge_requests = @issuables
@@ -48,10 +50,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
set_pipeline_variables
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37432
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- render
- end
+ render
end
format.json do
@@ -188,7 +187,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
begin
@merge_request.environments_for(current_user).map do |environment|
project = environment.project
- deployment = environment.first_deployment_for(@merge_request.diff_head_commit)
+ deployment = environment.first_deployment_for(@merge_request.diff_head_sha)
stop_url =
if environment.stop_action? && can?(current_user, :create_deployment, environment)
@@ -223,6 +222,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
render json: environments
end
+ def rebase
+ RebaseWorker.perform_async(@merge_request.id, current_user.id)
+
+ render nothing: true, status: 200
+ end
+
protected
alias_method :subscribable_resource, :merge_request
@@ -318,8 +323,22 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@target_branches = @merge_request.target_project.repository.branch_names
end
- def set_issuables_index
- @finder_type = MergeRequestsFinder
- super
+ def finder_type
+ MergeRequestsFinder
+ end
+
+ def check_user_can_push_to_source_branch!
+ return access_denied! unless @merge_request.source_branch_exists?
+
+ access_check = ::Gitlab::UserAccess
+ .new(current_user, project: @merge_request.source_project)
+ .can_push_to_branch?(@merge_request.source_branch)
+
+ access_denied! unless access_check
+ end
+
+ def whitelist_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42441
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42438')
end
end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 980bbf699b6..c5a044541f1 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -14,7 +14,7 @@ class Projects::MilestonesController < Projects::ApplicationController
def index
@sort = params[:sort] || 'due_date_asc'
- @milestones = milestones.sort(@sort)
+ @milestones = milestones.sort_by_attribute(@sort)
respond_to do |format|
format.html do
@@ -42,6 +42,10 @@ class Projects::MilestonesController < Projects::ApplicationController
def show
@project_namespace = @project.namespace.becomes(Namespace)
+
+ respond_to do |format|
+ format.html
+ end
end
def create
@@ -71,8 +75,16 @@ class Projects::MilestonesController < Projects::ApplicationController
def promote
promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone)
- flash[:notice] = "Milestone has been promoted to group milestone."
- redirect_to group_milestone_path(project.group, promoted_milestone.iid)
+
+ flash[:notice] = "#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, promoted_milestone.iid)}\">group milestone</a>.".html_safe
+ respond_to do |format|
+ format.html do
+ redirect_to project_milestones_path(project)
+ end
+ format.json do
+ render json: { url: project_milestones_path(project) }
+ end
+ end
rescue Milestones::PromoteService::PromoteMilestoneError => error
redirect_to milestone, alert: error.message
end
@@ -83,7 +95,7 @@ class Projects::MilestonesController < Projects::ApplicationController
Milestones::DestroyService.new(project, current_user).execute(milestone)
respond_to do |format|
- format.html { redirect_to namespace_project_milestones_path, status: 302 }
+ format.html { redirect_to namespace_project_milestones_path, status: 303 }
format.js { head :ok }
end
end
@@ -92,12 +104,6 @@ class Projects::MilestonesController < Projects::ApplicationController
def milestones
@milestones ||= begin
- if @project.group && can?(current_user, :read_group, @project.group)
- group = @project.group
- end
-
- search_params = params.merge(project_ids: @project.id, group_ids: group&.id)
-
MilestonesFinder.new(search_params).execute
end
end
@@ -113,4 +119,12 @@ class Projects::MilestonesController < Projects::ApplicationController
def milestone_params
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
+
+ def search_params
+ if @project.group && can?(current_user, :read_group, @project.group)
+ group = @project.group
+ end
+
+ params.permit(:state).merge(project_ids: @project.id, group_ids: group&.id)
+ end
end
diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb
index fb68dd771a1..35fec229db7 100644
--- a/app/controllers/projects/network_controller.rb
+++ b/app/controllers/projects/network_controller.rb
@@ -2,31 +2,29 @@ class Projects::NetworkController < Projects::ApplicationController
include ExtractsPath
include ApplicationHelper
+ before_action :whitelist_query_limiting
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
before_action :assign_commit
def show
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37602
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- @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
- if @options[:extended_sha1] && !@commit
- flash.now[:alert] = "Git revision '#{@options[:extended_sha1]}' does not exist."
- end
- end
+ @url = project_network_path(@project, @ref, @options.merge(format: :json))
+ @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s")
- format.json do
- @graph = Network::Graph.new(project, @ref, @commit, @options[:filter_ref])
+ respond_to do |format|
+ format.html do
+ if @options[:extended_sha1] && !@commit
+ flash.now[:alert] = "Git revision '#{@options[:extended_sha1]}' does not exist."
end
end
- render
+ format.json do
+ @graph = Network::Graph.new(project, @ref, @commit, @options[:filter_ref])
+ end
end
+
+ render
end
def assign_commit
@@ -35,4 +33,8 @@ class Projects::NetworkController < Projects::ApplicationController
@options[:extended_sha1] = params[:extended_sha1]
@commit = @repo.commit(@options[:extended_sha1])
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42333')
+ end
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 5940fae8dd0..dd41b9648e8 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -1,7 +1,9 @@
class Projects::NotesController < Projects::ApplicationController
include NotesActions
+ include NotesHelper
include ToggleAwardEmoji
+ before_action :whitelist_query_limiting, only: [:create]
before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
@@ -37,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion
- render json: {
- resolved_by: note.resolved_by.try(:name),
- discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
- }
+ if serialize_notes?
+ render_json_with_notes_serializer
+ else
+ render json: {
+ resolved_by: note.resolved_by.try(:name),
+ discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
+ }
+ end
end
def unresolve
@@ -50,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion
- render json: {
- discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
- }
+ if serialize_notes?
+ render_json_with_notes_serializer
+ else
+ render json: {
+ discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
+ }
+ end
end
private
+ def render_json_with_notes_serializer
+ Notes::RenderService.new(current_user).execute([note], project)
+
+ render json: note_serializer.represent(note)
+ end
+
def note
@note ||= @project.notes.find(params[:id])
end
+
alias_method :awardable, :note
def finder_params
@@ -79,4 +96,8 @@ class Projects::NotesController < Projects::ApplicationController
access_denied! unless can?(current_user, :create_note, noteable)
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42383')
+ end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index d421b1a8eb5..cae6e2c40b8 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -21,4 +21,26 @@ class Projects::PagesController < Projects::ApplicationController
end
end
end
+
+ def update
+ result = Projects::UpdateService.new(@project, current_user, project_params).execute
+
+ respond_to do |format|
+ format.html do
+ if result[:status] == :success
+ flash[:notice] = 'Your changes have been saved'
+ else
+ flash[:alert] = 'Something went wrong on our end'
+ end
+
+ redirect_to project_pages_path(@project)
+ end
+ end
+ end
+
+ private
+
+ def project_params
+ params.require(:project).permit(:pages_https_only)
+ end
end
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 15e77d854dc..4856be61e88 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show]
- before_action :domain, only: [:show, :destroy]
+ before_action :domain, except: [:new, :create]
def show
end
@@ -12,16 +12,41 @@ class Projects::PagesDomainsController < Projects::ApplicationController
@domain = @project.pages_domains.new
end
+ def verify
+ result = VerifyPagesDomainService.new(@domain).execute
+
+ if result[:status] == :success
+ flash[:notice] = 'Successfully verified domain ownership'
+ else
+ flash[:alert] = 'Failed to verify domain ownership'
+ end
+
+ redirect_to project_pages_domain_path(@project, @domain)
+ end
+
+ def edit
+ end
+
def create
- @domain = @project.pages_domains.create(pages_domain_params)
+ @domain = @project.pages_domains.create(create_params)
if @domain.valid?
- redirect_to project_pages_path(@project)
+ redirect_to project_pages_domain_path(@project, @domain)
else
render 'new'
end
end
+ def update
+ if @domain.update(update_params)
+ redirect_to project_pages_path(@project),
+ status: 302,
+ notice: 'Domain was updated'
+ else
+ render 'edit'
+ end
+ end
+
def destroy
@domain.destroy
@@ -37,15 +62,15 @@ class Projects::PagesDomainsController < Projects::ApplicationController
private
- def pages_domain_params
- params.require(:pages_domain).permit(
- :certificate,
- :key,
- :domain
- )
+ def create_params
+ params.require(:pages_domain).permit(:key, :certificate, :domain)
+ end
+
+ def update_params
+ params.require(:pages_domain).permit(:key, :certificate)
end
def domain
- @domain ||= @project.pages_domains.find_by(domain: params[:id].to_s)
+ @domain ||= @project.pages_domains.find_by!(domain: params[:id].to_s)
end
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index b478e7b5e05..fa258f3d9af 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -92,7 +92,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def schedule_params
params.require(:schedule)
.permit(:description, :cron, :cron_timezone, :ref, :active,
- variables_attributes: [:id, :key, :value, :_destroy] )
+ variables_attributes: [:id, :key, :secret_value, :_destroy] )
end
def authorize_play_pipeline_schedule!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index e146d0d3cd5..78d109cf33e 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -1,4 +1,5 @@
class Projects::PipelinesController < Projects::ApplicationController
+ before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :commit, only: [:show, :builds, :failures]
before_action :authorize_read_pipeline!
@@ -166,4 +167,9 @@ class Projects::PipelinesController < Projects::ApplicationController
def commit
@commit ||= @pipeline.commit
end
+
+ def whitelist_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42343
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42339')
+ end
end
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index 06ce7328fb5..73c613b26f3 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -4,32 +4,4 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
def show
redirect_to project_settings_ci_cd_path(@project, params: params)
end
-
- def update
- Projects::UpdateService.new(project, current_user, update_params).tap do |service|
- if service.execute
- flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
-
- if service.run_auto_devops_pipeline?
- CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
- flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
- end
-
- redirect_to project_settings_ci_cd_path(@project)
- else
- render 'show'
- end
- end
- end
-
- private
-
- 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, :ci_config_path,
- auto_devops_attributes: [:id, :domain, :enabled]
- )
- end
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index d7372beb9d3..cfa5e72af64 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -21,34 +21,11 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
end
- @project_members = present_members(@project_members.sort(@sort).page(params[:page]))
+ @project_members = present_members(@project_members.sort_by_attribute(@sort).page(params[:page]))
@requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user))
@project_member = @project.project_members.new
end
- def update
- @project_member = @project.members_and_requesters.find(params[:id])
- .present(current_user: current_user)
-
- return render_403 unless can?(current_user, :update_project_member, @project_member)
-
- @project_member.update_attributes(member_params)
- end
-
- def resend_invite
- redirect_path = project_project_members_path(@project)
-
- @project_member = @project.project_members.find(params[:id])
-
- if @project_member.invite?
- @project_member.resend_invite
-
- redirect_to redirect_path, notice: 'The invitation was successfully resent.'
- else
- redirect_to redirect_path, alert: 'The invitation has already been accepted.'
- end
- end
-
def import
@projects = current_user.authorized_projects.order_id_desc
end
@@ -67,12 +44,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
notice: notice)
end
- protected
-
- def member_params
- params.require(:project_member).permit(:user_id, :access_level, :expires_at)
- end
-
# MembershipActions concern
alias_method :membershipable, :project
end
diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb
new file mode 100644
index 00000000000..1dd886409a5
--- /dev/null
+++ b/app/controllers/projects/prometheus/metrics_controller.rb
@@ -0,0 +1,32 @@
+module Projects
+ module Prometheus
+ class MetricsController < Projects::ApplicationController
+ before_action :authorize_admin_project!
+ before_action :require_prometheus_metrics!
+
+ def active_common
+ respond_to do |format|
+ format.json do
+ matched_metrics = prometheus_adapter.query(:matched_metrics) || {}
+
+ if matched_metrics.any?
+ render json: matched_metrics
+ else
+ head :no_content
+ end
+ end
+ end
+ end
+
+ private
+
+ def prometheus_adapter
+ @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter
+ end
+
+ def require_prometheus_metrics!
+ render_404 unless prometheus_adapter.can_query?
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/prometheus_controller.rb b/app/controllers/projects/prometheus_controller.rb
deleted file mode 100644
index 507468d7102..00000000000
--- a/app/controllers/projects/prometheus_controller.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-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 d1719f12072..64954ac9a42 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -5,12 +5,8 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
@project.repository.branches
end
- def create_service_class
- ::ProtectedBranches::CreateService
- end
-
- def update_service_class
- ::ProtectedBranches::UpdateService
+ def service_namespace
+ ::ProtectedBranches
end
def load_protected_ref
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index b51bdf7aa78..9e757a8d25f 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -37,7 +37,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
end
def destroy
- @protected_ref.destroy
+ destroy_service_class.new(@project, current_user).execute(@protected_ref)
respond_to do |format|
format.html { redirect_to_repository_settings(@project) }
@@ -47,6 +47,18 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
protected
+ def create_service_class
+ service_namespace::CreateService
+ end
+
+ def update_service_class
+ service_namespace::UpdateService
+ end
+
+ def destroy_service_class
+ service_namespace::DestroyService
+ end
+
def access_level_attributes
%i(access_level id)
end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
index a5dbd7e46ae..198c938ff35 100644
--- a/app/controllers/projects/protected_tags_controller.rb
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -5,12 +5,8 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
@project.repository.tags
end
- def create_service_class
- ::ProtectedTags::CreateService
- end
-
- def update_service_class
- ::ProtectedTags::UpdateService
+ def service_namespace
+ ::ProtectedTags
end
def load_protected_ref
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index a02cc477e08..9bc774b7636 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -2,6 +2,7 @@
class Projects::RawController < Projects::ApplicationController
include ExtractsPath
include BlobHelper
+ include SendFileUpload
before_action :require_non_empty_project
before_action :assign_ref_vars
@@ -31,7 +32,7 @@ class Projects::RawController < Projects::ApplicationController
lfs_object = find_lfs_object
if lfs_object && lfs_object.project_allowed_access?(@project)
- send_file lfs_object.file.path, filename: @blob.name, disposition: 'attachment'
+ send_upload(lfs_object.file, attachment: @blob.name)
else
render_404
end
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 2376f469213..48a09e1ddb8 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -25,7 +25,7 @@ class Projects::RefsController < Projects::ApplicationController
when "graphs_commits"
commits_project_graph_path(@project, @id)
when "badges"
- project_pipelines_settings_path(@project, ref: @id)
+ project_settings_ci_cd_path(@project, ref: @id)
else
project_commits_path(@project, @id)
end
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index d5af0341d18..937b0e39cbd 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -1,6 +1,9 @@
class Projects::RepositoriesController < Projects::ApplicationController
+ include ExtractsPath
+
# Authorize
before_action :require_non_empty_project, except: :create
+ before_action :assign_archive_vars, only: :archive
before_action :authorize_download_code!
before_action :authorize_admin_project!, only: :create
@@ -11,9 +14,26 @@ class Projects::RepositoriesController < Projects::ApplicationController
end
def archive
- send_git_archive @repository, ref: params[:ref], format: params[:format]
+ append_sha = params[:append_sha]
+
+ if @ref
+ shortname = "#{@project.path}-#{@ref.tr('/', '-')}"
+ append_sha = false if @filename == shortname
+ end
+
+ send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha
rescue => ex
logger.error("#{self.class.name}: #{ex}")
return git_not_found!
end
+
+ def assign_archive_vars
+ @id = params[:id]
+
+ return unless @id
+
+ @ref, @filename = extract_ref(@id)
+ rescue InvalidPathError
+ render_404
+ end
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 9f9773575a5..c950d0f7001 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -29,17 +29,17 @@ class Projects::RunnersController < Projects::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to runner_path(@runner), notice: 'Runner was successfully updated.'
+ redirect_to runners_path(@project), notice: 'Runner was successfully updated.'
else
- redirect_to runner_path(@runner), alert: 'Runner was not updated.'
+ redirect_to runners_path(@project), alert: 'Runner was not updated.'
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to runner_path(@runner), notice: 'Runner was successfully updated.'
+ redirect_to runners_path(@project), notice: 'Runner was successfully updated.'
else
- redirect_to runner_path(@runner), alert: 'Runner was not updated.'
+ redirect_to runners_path(@project), alert: 'Runner was not updated.'
end
end
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index daa5c88aae0..a5ea9ff7ed7 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -3,7 +3,8 @@ class Projects::ServicesController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project!
- before_action :service, only: [:edit, :update, :test]
+ before_action :ensure_service_enabled
+ before_action :service
respond_to :html
@@ -23,26 +24,32 @@ class Projects::ServicesController < Projects::ApplicationController
end
def test
- message = {}
+ if @service.can_test?
+ render json: service_test_response, status: :ok
+ else
+ render json: {}, status: :not_found
+ end
+ end
- if @service.can_test? && @service.update_attributes(service_params[:service])
+ private
+
+ def service_test_response
+ if @service.update_attributes(service_params[:service])
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 }
+ if outcome[:success]
+ {}
+ else
+ { error: true, message: 'Test failed.', service_response: outcome[:result].to_s }
end
-
- status = :ok
else
- status = :not_found
+ { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(',') }
end
-
- render json: message, status: status
+ rescue Gitlab::HTTP::BlockedUrlError => e
+ { error: true, message: 'Test failed.', service_response: e.message }
end
- private
-
def success_message
if @service.active?
"#{@service.title} activated."
@@ -54,4 +61,8 @@ class Projects::ServicesController < Projects::ApplicationController
def service
@service ||= @project.find_or_initialize_service(params[:id])
end
+
+ def ensure_service_enabled
+ render_404 unless service
+ end
end
diff --git a/app/controllers/projects/settings/badges_controller.rb b/app/controllers/projects/settings/badges_controller.rb
new file mode 100644
index 00000000000..f7b70dd4b7b
--- /dev/null
+++ b/app/controllers/projects/settings/badges_controller.rb
@@ -0,0 +1,13 @@
+module Projects
+ module Settings
+ class BadgesController < Projects::ApplicationController
+ include GrapeRouteHelpers::NamedRouteMatcher
+
+ before_action :authorize_admin_project!
+
+ def index
+ @badge_api_endpoint = api_v4_projects_badges_path(id: @project.id)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index b029b31f9af..d80ef8113aa 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -2,8 +2,62 @@ module Projects
module Settings
class CiCdController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
+ before_action :define_variables
def show
+ end
+
+ def update
+ Projects::UpdateService.new(project, current_user, update_params).tap do |service|
+ result = service.execute
+ if result[:status] == :success
+ flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
+
+ run_autodevops_pipeline(service)
+
+ redirect_to project_settings_ci_cd_path(@project)
+ else
+ render 'show'
+ end
+ end
+ end
+
+ def reset_cache
+ if ResetProjectCacheService.new(@project, current_user).execute
+ respond_to do |format|
+ format.json { head :ok }
+ end
+ else
+ respond_to do |format|
+ format.json { head :bad_request }
+ end
+ end
+ end
+
+ private
+
+ def update_params
+ params.require(:project).permit(
+ :runners_token, :builds_enabled, :build_allow_git_fetch,
+ :build_timeout_human_readable, :build_coverage_regex, :public_builds,
+ :auto_cancel_pending_pipelines, :ci_config_path,
+ auto_devops_attributes: [:id, :domain, :enabled]
+ )
+ end
+
+ def run_autodevops_pipeline(service)
+ return unless service.run_auto_devops_pipeline?
+
+ if @project.empty_repo?
+ flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch."
+ return
+ end
+
+ CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
+ flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
+ end
+
+ def define_variables
define_runners_variables
define_secret_variables
define_triggers_variables
@@ -11,18 +65,16 @@ module Projects
define_auto_devops_variables
end
- private
-
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)
- @shared_runners = Ci::Runner.shared.active
+ @shared_runners = ::Ci::Runner.shared.active
@shared_runners_count = @shared_runners.count(:all)
end
def define_secret_variables
- @variable = Ci::Variable.new(project: project)
+ @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) }
@@ -30,7 +82,7 @@ module Projects
def define_triggers_variables
@triggers = @project.triggers
- @trigger = Ci::Trigger.new
+ @trigger = ::Ci::Trigger.new
end
def define_badges_variables
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index d06d18c498b..f17056f13e0 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -4,18 +4,40 @@ module Projects
before_action :authorize_admin_project!
def show
- @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
+ render_show
+ end
- define_protected_refs
+ def create_deploy_token
+ @new_deploy_token = DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute
+
+ if @new_deploy_token.persisted?
+ flash.now[:notice] = s_('DeployTokens|Your new project deploy token has been created.')
+ end
+
+ render_show
end
private
+ def render_show
+ @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
+ @deploy_tokens = @project.deploy_tokens.active
+
+ define_deploy_token
+ define_protected_refs
+
+ render 'show'
+ end
+
def define_protected_refs
@protected_branches = @project.protected_branches.order(:name).page(params[:page])
@protected_tags = @project.protected_tags.order(:name).page(params[:page])
@protected_branch = @project.protected_branches.new
@protected_tag = @project.protected_tags.new
+
+ @protected_branches_count = @protected_branches.reduce(0) { |sum, branch| sum + branch.matching(@project.repository.branches).size }
+ @protected_tags_count = @protected_tags.reduce(0) { |sum, tag| sum + tag.matching(@project.repository.tags).size }
+
load_gon_index
end
@@ -47,6 +69,14 @@ module Projects
gon.push(protectable_branches_for_dropdown)
gon.push(access_levels_options)
end
+
+ def define_deploy_token
+ @new_deploy_token ||= DeployToken.new
+ end
+
+ def deploy_token_params
+ params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry)
+ end
end
end
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index f752a46f828..ee9b5458282 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -36,7 +36,7 @@ class Projects::TreeController < Projects::ApplicationController
end
format.json do
- page_title @path.presence || _("Files"), @ref, @project.name_with_namespace
+ page_title @path.presence || _("Files"), @ref, @project.full_name
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
Gitlab::GitalyClient.allow_n_plus_1_calls do
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 4685bbe80b4..f5cf089ad98 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -1,6 +1,7 @@
class Projects::UploadsController < Projects::ApplicationController
include UploadsActions
+ # These will kick you out if you don't have access.
skip_before_action :project, :repository,
if: -> { action_name == 'show' && image_or_video? }
@@ -8,14 +9,20 @@ class Projects::UploadsController < Projects::ApplicationController
private
- def show_model
- strong_memoize(:show_model) do
- namespace = params[:namespace_id]
- id = params[:project_id]
+ def upload_model_class
+ Project
+ end
- Project.find_by_full_path("#{namespace}/#{id}")
- end
+ def uploader_class
+ FileUploader
end
- alias_method :model, :project
+ def find_model
+ return @project if @project
+
+ namespace = params[:namespace_id]
+ id = params[:project_id]
+
+ Project.find_by_full_path("#{namespace}/#{id}")
+ end
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index 6a825137564..517d0b026c2 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -1,60 +1,41 @@
class Projects::VariablesController < Projects::ApplicationController
- before_action :variable, only: [:show, :update, :destroy]
before_action :authorize_admin_build!
- layout 'project_settings'
-
- def index
- redirect_to project_settings_ci_cd_path(@project)
- end
-
def show
+ respond_to do |format|
+ format.json do
+ render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
+ end
+ end
end
def update
- if variable.update(variable_params)
- redirect_to project_variables_path(project),
- notice: 'Variable was successfully updated.'
+ if @project.update(variables_params)
+ respond_to do |format|
+ format.json { return render_variables }
+ end
else
- render "show"
+ respond_to do |format|
+ format.json { render_error }
+ end
end
end
- def create
- @variable = project.variables.create(variable_params)
- .present(current_user: current_user)
+ private
- if @variable.persisted?
- redirect_to project_settings_ci_cd_path(project),
- notice: 'Variable was successfully created.'
- else
- render "show"
- end
+ def render_variables
+ render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) }
end
- def destroy
- 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
+ def render_error
+ render status: :bad_request, json: @project.errors.full_messages
end
- private
-
- def variable_params
- params.require(:variable).permit(*variable_params_attributes)
+ def variables_params
+ params.permit(variables_attributes: [*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)
+ %i[id key secret_value protected _destroy]
end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 292e4158f8b..c4930d3d18d 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -54,8 +54,8 @@ class Projects::WikisController < Projects::ApplicationController
else
render 'edit'
end
- rescue WikiPage::PageChangedError
- @conflict = true
+ rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e
+ @error = e
render 'edit'
end
@@ -76,9 +76,9 @@ class Projects::WikisController < Projects::ApplicationController
@page = @project_wiki.find_page(params[:id])
if @page
- @page_versions = Kaminari.paginate_array(@page.versions(page: params[:page]),
+ @page_versions = Kaminari.paginate_array(@page.versions(page: params[:page].to_i),
total_count: @page.count_versions)
- .page(params[:page])
+ .page(params[:page])
else
redirect_to(
project_wiki_path(@project, :home),
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 6f609348402..37f14230196 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -3,6 +3,7 @@ class ProjectsController < Projects::ApplicationController
include ExtractsPath
include PreviewMarkdown
+ before_action :whitelist_query_limiting, only: [:create]
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
before_action :redirect_git_extension, only: [:show]
before_action :project, except: [:index, :new, :create]
@@ -40,11 +41,11 @@ class ProjectsController < Projects::ApplicationController
cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) }
redirect_to(
- project_path(@project),
+ project_path(@project, custom_import_params),
notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
)
else
- render 'new'
+ render 'new', locals: { active_tab: active_new_project_tab }
end
end
@@ -102,7 +103,7 @@ class ProjectsController < Projects::ApplicationController
def show
if @project.import_in_progress?
- redirect_to project_import_path(@project)
+ redirect_to project_import_path(@project, custom_import_params)
return
end
@@ -113,6 +114,8 @@ class ProjectsController < Projects::ApplicationController
respond_to do |format|
format.html do
@notification_setting = current_user.notification_settings_for(@project) if current_user
+ @project = @project.present(current_user: current_user)
+
render_landing_page
end
@@ -127,7 +130,7 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_project, @project)
::Projects::DestroyService.new(@project, current_user, {}).async_execute
- flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.name_with_namespace }
+ flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name }
redirect_to dashboard_projects_path, status: 302
rescue Projects::DestroyService::DestroyError => ex
@@ -203,6 +206,7 @@ class ProjectsController < Projects::ApplicationController
else
flash[:alert] = _("Project export could not be deleted.")
end
+
redirect_to(edit_project_path(@project))
end
@@ -277,7 +281,6 @@ class ProjectsController < Projects::ApplicationController
@project_wiki = @project.wiki
@wiki_home = @project_wiki.find_page('home', params[:version_id])
elsif @project.feature_available?(:issues, current_user)
- @finder_type = IssuesFinder
@issues = issuables_collection.page(params[:page])
@collection_type = 'Issue'
@issuable_meta_data = issuable_meta_data(@issues, @collection_type)
@@ -287,6 +290,10 @@ class ProjectsController < Projects::ApplicationController
end
end
+ def finder_type
+ IssuesFinder
+ end
+
def determine_layout
if [:new, :create].include?(action_name.to_sym)
'application'
@@ -317,7 +324,7 @@ class ProjectsController < Projects::ApplicationController
:avatar,
:build_allow_git_fetch,
:build_coverage_regex,
- :build_timeout_in_minutes,
+ :build_timeout_human_readable,
:resolve_outdated_diff_discussions,
:container_registry_enabled,
:default_branch,
@@ -352,8 +359,16 @@ class ProjectsController < Projects::ApplicationController
]
end
+ def custom_import_params
+ {}
+ end
+
+ def active_new_project_tab
+ project_params[:import_url].present? ? 'import' : 'blank'
+ end
+
def repo_exists?
- project.repository_exists? && !project.empty_repo? && project.repo
+ project.repository_exists? && !project.empty_repo?
rescue Gitlab::Git::Repository::NoRepository
project.repository.expire_exists_cache
@@ -393,7 +408,7 @@ class ProjectsController < Projects::ApplicationController
end
def project_export_enabled
- render_404 unless current_application_settings.project_export_enabled?
+ render_404 unless Gitlab::CurrentSettings.project_export_enabled?
end
def redirect_git_extension
@@ -402,6 +417,10 @@ class ProjectsController < Projects::ApplicationController
# to
# localhost/group/project
#
- redirect_to request.original_url.sub(/\.git\/?\Z/, '') if params[:format] == 'git'
+ redirect_to request.original_url.sub(%r{\.git/?\Z}, '') if params[:format] == 'git'
+ end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440')
end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index d9142311b6f..1848c806c41 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -1,6 +1,8 @@
class RegistrationsController < Devise::RegistrationsController
include Recaptcha::Verify
+ before_action :whitelist_query_limiting, only: [:destroy]
+
def new
redirect_to(new_user_session_path)
end
@@ -83,4 +85,8 @@ class RegistrationsController < Devise::RegistrationsController
def devise_mapping
@devise_mapping ||= Devise.mappings[:user]
end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42380')
+ end
end
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index 19e38993038..651b82f04f4 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -23,7 +23,7 @@ class RootController < Dashboard::ProjectsController
def redirect_unlogged_user
if redirect_to_home_page_url?
- redirect_to(current_application_settings.home_page_url)
+ redirect_to(Gitlab::CurrentSettings.home_page_url)
else
redirect_to(new_user_session_path)
end
@@ -42,15 +42,19 @@ class RootController < Dashboard::ProjectsController
redirect_to(dashboard_groups_path)
when 'todos'
redirect_to(dashboard_todos_path)
+ when 'issues'
+ redirect_to(issues_dashboard_path(assignee_id: current_user.id))
+ when 'merge_requests'
+ redirect_to(merge_requests_dashboard_path(assignee_id: current_user.id))
end
end
def redirect_to_home_page_url?
# If user is not signed-in and tries to access root_path - redirect him to landing page
# Don't redirect to the default URL to prevent endless redirections
- return false unless current_application_settings.home_page_url.present?
+ return false unless Gitlab::CurrentSettings.home_page_url.present?
- home_page_url = current_application_settings.home_page_url.chomp('/')
+ home_page_url = Gitlab::CurrentSettings.home_page_url.chomp('/')
root_urls = [Gitlab.config.gitlab['url'].chomp('/'), root_url.chomp('/')]
root_urls.exclude?(home_page_url)
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index fbad9ba7db8..983f888b8ec 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -1,9 +1,14 @@
class SearchController < ApplicationController
- skip_before_action :authenticate_user!
-
+ include ControllerWithCrossProjectAccessCheck
include SearchHelper
include RendersCommits
+ skip_before_action :authenticate_user!
+ requires_cross_project_access if: -> do
+ search_term_present = params[:search].present? || params[:term].present?
+ search_term_present && !params[:project_id].present?
+ end
+
layout 'search'
def show
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index d79108c88fb..f3a4aa849c7 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -16,7 +16,7 @@ class SessionsController < Devise::SessionsController
def new
set_minimum_password_length
- @ldap_servers = Gitlab::LDAP::Config.available_servers
+ @ldap_servers = Gitlab::Auth::LDAP::Config.available_servers
super
end
@@ -28,6 +28,7 @@ class SessionsController < Devise::SessionsController
resource.update_attributes(reset_password_token: nil,
reset_password_sent_at: nil)
end
+
# hide the signed-in notification
flash[:notice] = nil
log_audit_event(current_user, resource, with: authentication_method)
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 16a74f82d3f..3d227b0a955 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -1,19 +1,34 @@
class UploadsController < ApplicationController
include UploadsActions
+ UnknownUploadModelError = Class.new(StandardError)
+
+ MODEL_CLASSES = {
+ "user" => User,
+ "project" => Project,
+ "note" => Note,
+ "group" => Group,
+ "appearance" => Appearance,
+ "personal_snippet" => PersonalSnippet,
+ nil => PersonalSnippet
+ }.freeze
+
+ rescue_from UnknownUploadModelError, with: :render_404
+
skip_before_action :authenticate_user!
+ before_action :upload_mount_satisfied?
before_action :find_model
before_action :authorize_access!, only: [:show]
before_action :authorize_create_access!, only: [:create]
- private
+ def uploader_class
+ PersonalFileUploader
+ end
def find_model
return nil unless params[:id]
- return render_404 unless upload_model && upload_mount
-
- @model = upload_model.find(params[:id])
+ upload_model_class.find(params[:id])
end
def authorize_access!
@@ -53,55 +68,17 @@ class UploadsController < ApplicationController
end
end
- def upload_model
- upload_models = {
- "user" => User,
- "project" => Project,
- "note" => Note,
- "group" => Group,
- "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
+ def upload_model_class
+ MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError)
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
+ def upload_model_class_has_mounts?
+ upload_model_class < CarrierWave::Mount::Extension
end
- def uploader_class
- PersonalFileUploader
- end
+ def upload_mount_satisfied?
+ return true unless upload_model_class_has_mounts?
- def model
- @model ||= find_model
+ upload_model_class.uploader_options.has_key?(upload_mount)
end
end
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
new file mode 100644
index 00000000000..18cde4a7b1a
--- /dev/null
+++ b/app/controllers/user_callouts_controller.rb
@@ -0,0 +1,23 @@
+class UserCalloutsController < ApplicationController
+ def create
+ if ensure_callout.persisted?
+ respond_to do |format|
+ format.json { head :ok }
+ end
+ else
+ respond_to do |format|
+ format.json { head :bad_request }
+ end
+ end
+ end
+
+ private
+
+ def ensure_callout
+ current_user.callouts.find_or_create_by(feature_name: UserCallout.feature_names[feature_name])
+ end
+
+ def feature_name
+ params.require(:feature_name)
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 575ec5c20f0..956df4a0a16 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,6 +1,15 @@
class UsersController < ApplicationController
include RoutableActions
include RendersMemberAccess
+ include ControllerWithCrossProjectAccessCheck
+
+ requires_cross_project_access show: false,
+ groups: false,
+ projects: false,
+ contributed: false,
+ snippets: true,
+ calendar: false,
+ calendar_activities: true
skip_before_action :authenticate_user!
before_action :user, except: [:exists]
@@ -103,12 +112,7 @@ class UsersController < ApplicationController
end
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 = UserRecentEventsFinder.new(current_user, user, params).execute
Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
@@ -141,10 +145,6 @@ class UsersController < ApplicationController
).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
diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb
index d6bcd939522..53b77f5fed9 100644
--- a/app/finders/admin/projects_finder.rb
+++ b/app/finders/admin/projects_finder.rb
@@ -16,8 +16,8 @@ class Admin::ProjectsFinder
items = by_archived(items)
items = by_personal(items)
items = by_name(items)
- items = sort(items)
- items.includes(:namespace).order("namespaces.path, projects.name ASC").page(params[:page])
+ items = items.includes(namespace: [:owner])
+ sort(items).page(params[:page])
end
private
@@ -62,6 +62,6 @@ class Admin::ProjectsFinder
def sort(items)
sort = params.fetch(:sort) { 'latest_activity_desc' }
- items.sort(sort)
+ items.sort_by_attribute(sort)
end
end
diff --git a/app/finders/autocomplete_users_finder.rb b/app/finders/autocomplete_users_finder.rb
index c3f5358b577..e8a03947f59 100644
--- a/app/finders/autocomplete_users_finder.rb
+++ b/app/finders/autocomplete_users_finder.rb
@@ -1,6 +1,12 @@
class AutocompleteUsersFinder
+ # The number of users to display in the results is hardcoded to 20, and
+ # pagination is not supported. This ensures that performance remains
+ # consistent and removes the need for implementing keyset pagination to ensure
+ # good performance.
+ LIMIT = 20
+
attr_reader :current_user, :project, :group, :search, :skip_users,
- :page, :per_page, :author_id, :params
+ :author_id, :params
def initialize(params:, current_user:, project:, group:)
@current_user = current_user
@@ -8,8 +14,6 @@ class AutocompleteUsersFinder
@group = group
@search = params[:search]
@skip_users = params[:skip_users]
- @page = params[:page]
- @per_page = params[:per_page]
@author_id = params[:author_id]
@params = params
end
@@ -20,7 +24,7 @@ class AutocompleteUsersFinder
items = items.reorder(:name)
items = items.search(search) if search.present?
items = items.where.not(id: skip_users) if skip_users.present?
- items = items.page(page).per(per_page)
+ items = items.limit(LIMIT)
if params[:todo_filter].present? && current_user
items = items.todo_authors(current_user.id, params[:todo_state_filter])
@@ -52,9 +56,13 @@ class AutocompleteUsersFinder
end
def users_from_project
- user_ids = project.team.users.pluck(:id)
- user_ids << author_id if author_id.present?
+ if author_id.present?
+ union = Gitlab::SQL::Union
+ .new([project.authorized_users, User.where(id: author_id)])
- User.where(id: user_ids)
+ User.from("(#{union.to_sql}) #{User.table_name}")
+ else
+ project.authorized_users
+ end
end
end
diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb
index 852eac3647d..8bb1366867c 100644
--- a/app/finders/branches_finder.rb
+++ b/app/finders/branches_finder.rb
@@ -1,5 +1,5 @@
class BranchesFinder
- def initialize(repository, params)
+ def initialize(repository, params = {})
@repository = repository
@params = params
end
diff --git a/app/finders/concerns/finder_methods.rb b/app/finders/concerns/finder_methods.rb
new file mode 100644
index 00000000000..2e905fa5750
--- /dev/null
+++ b/app/finders/concerns/finder_methods.rb
@@ -0,0 +1,51 @@
+module FinderMethods
+ def find_by!(*args)
+ raise_not_found_unless_authorized execute.find_by!(*args)
+ end
+
+ def find_by(*args)
+ if_authorized execute.find_by(*args)
+ end
+
+ def find(*args)
+ raise_not_found_unless_authorized model.find(*args)
+ end
+
+ private
+
+ def raise_not_found_unless_authorized(result)
+ result = if_authorized(result)
+
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{model}") unless result
+
+ result
+ end
+
+ def if_authorized(result)
+ # Return the result if the finder does not perform authorization checks.
+ # this is currently the case in the `MilestoneFinder`
+ return result unless respond_to?(:current_user)
+
+ if can_read_object?(result)
+ result
+ else
+ nil
+ end
+ end
+
+ def can_read_object?(object)
+ # When there's no policy, we'll allow the read, this is for example the case
+ # for Todos
+ return true unless DeclarativePolicy.has_policy?(object)
+
+ model_name = object&.model_name || model.model_name
+
+ Ability.allowed?(current_user, :"read_#{model_name.singular}", object)
+ end
+
+ # This fetches the model from the `ActiveRecord::Relation` but does not
+ # actually execute the query.
+ def model
+ execute.model
+ end
+end
diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb
new file mode 100644
index 00000000000..92bf98d7cd2
--- /dev/null
+++ b/app/finders/concerns/finder_with_cross_project_access.rb
@@ -0,0 +1,70 @@
+# Module to prepend into finders to specify wether or not the finder requires
+# cross project access
+#
+# This module depends on the finder implementing the following methods:
+#
+# - `#execute` should return an `ActiveRecord::Relation`
+# - `#current_user` the user that requires access (or nil)
+module FinderWithCrossProjectAccess
+ extend ActiveSupport::Concern
+ extend ::Gitlab::Utils::Override
+
+ prepended do
+ extend Gitlab::CrossProjectAccess::ClassMethods
+ end
+
+ override :execute
+ def execute(*args)
+ check = Gitlab::CrossProjectAccess.find_check(self)
+ original = super
+
+ return original unless check
+ return original if should_skip_cross_project_check || can_read_cross_project?
+
+ if check.should_run?(self)
+ original.model.none
+ else
+ original
+ end
+ end
+
+ # We can skip the cross project check for finding indivitual records.
+ # this would be handled by the `can?(:read_*, result)` call in `FinderMethods`
+ # itself.
+ override :find_by!
+ def find_by!(*args)
+ skip_cross_project_check { super }
+ end
+
+ override :find_by
+ def find_by(*args)
+ skip_cross_project_check { super }
+ end
+
+ override :find
+ def find(*args)
+ skip_cross_project_check { super }
+ end
+
+ private
+
+ attr_accessor :should_skip_cross_project_check
+
+ def skip_cross_project_check
+ self.should_skip_cross_project_check = true
+
+ yield
+ ensure
+ # The find could raise an `ActiveRecord::RecordNotFound`, after which we
+ # still want to re-enable the check.
+ self.should_skip_cross_project_check = false
+ end
+
+ def can_read_cross_project?
+ Ability.allowed?(current_user, :read_cross_project)
+ end
+
+ def can_read_project?(project)
+ Ability.allowed?(current_user, :read_project, project)
+ end
+end
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 46ecbaba73a..8676925a540 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -1,6 +1,10 @@
class EventsFinder
+ prepend FinderMethods
+ prepend FinderWithCrossProjectAccess
attr_reader :source, :params, :current_user
+ requires_cross_project_access unless: -> { source.is_a?(Project) }
+
# Used to filter Events
#
# Arguments:
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 1a5f6063437..e72fd8eb3a5 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -27,12 +27,16 @@ class GroupDescendantsFinder
end
def execute
- # The children array might be extended with the ancestors of projects when
- # filtering. In that case, take the maximum so the array does not get limited
- # Otherwise, allow paginating through all results
+ # The children array might be extended with the ancestors of projects and
+ # subgroups when filtering. In that case, take the maximum so the array does
+ # not get limited otherwise, allow paginating through all results.
#
all_required_elements = children
- all_required_elements |= ancestors_for_projects if params[:filter]
+ if params[:filter]
+ all_required_elements |= ancestors_of_filtered_subgroups
+ all_required_elements |= ancestors_of_filtered_projects
+ end
+
total_count = [all_required_elements.size, paginator.total_count].max
Kaminari.paginate_array(all_required_elements, total_count: total_count)
@@ -49,8 +53,11 @@ class GroupDescendantsFinder
end
def paginator
- @paginator ||= Gitlab::MultiCollectionPaginator.new(subgroups, projects,
- per_page: params[:per_page])
+ @paginator ||= Gitlab::MultiCollectionPaginator.new(
+ subgroups,
+ projects.with_route,
+ per_page: params[:per_page]
+ )
end
def direct_child_groups
@@ -63,6 +70,7 @@ class GroupDescendantsFinder
groups_table = Group.arel_table
visible_to_user = groups_table[:visibility_level]
.in(Gitlab::VisibilityLevel.levels_for_user(current_user))
+
if current_user
authorized_groups = GroupsFinder.new(current_user,
all_available: false)
@@ -93,15 +101,21 @@ class GroupDescendantsFinder
#
# So when searching 'project', on the 'subgroup' page we want to preload
# 'nested-group' but not 'subgroup' or 'root'
- def ancestors_for_groups(base_for_ancestors)
- Gitlab::GroupHierarchy.new(base_for_ancestors)
+ def ancestors_of_groups(base_for_ancestors)
+ group_ids = base_for_ancestors.except(:select, :sort).select(:id)
+ Gitlab::GroupHierarchy.new(Group.where(id: group_ids))
.base_and_ancestors(upto: parent_group.id)
end
- def ancestors_for_projects
+ def ancestors_of_filtered_projects
projects_to_load_ancestors_of = projects.where.not(namespace: parent_group)
groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id))
- ancestors_for_groups(groups_to_load_ancestors_of)
+ ancestors_of_groups(groups_to_load_ancestors_of)
+ .with_selects_for_list(archived: params[:archived])
+ end
+
+ def ancestors_of_filtered_subgroups
+ ancestors_of_groups(subgroups)
.with_selects_for_list(archived: params[:archived])
end
@@ -111,16 +125,19 @@ class GroupDescendantsFinder
# When filtering subgroups, we want to find all matches withing the tree of
# descendants to show to the user
groups = if params[:filter]
- ancestors_for_groups(subgroups_matching_filter)
+ subgroups_matching_filter
else
direct_child_groups
end
+
groups.with_selects_for_list(archived: params[:archived]).order_by(sort)
end
def direct_child_projects
- GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
- .execute
+ GroupProjectsFinder.new(group: parent_group,
+ current_user: current_user,
+ options: { only_owned: true },
+ params: params).execute
end
# Finds all projects nested under `parent_group` or any of its descendant
@@ -140,6 +157,7 @@ class GroupDescendantsFinder
else
direct_child_projects
end
+
projects.with_route.order_by(sort)
end
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index 6e8733bb49c..f73cf8adb4d 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -34,6 +34,7 @@ class GroupProjectsFinder < ProjectsFinder
else
collection_without_user
end
+
union(projects)
end
@@ -86,8 +87,17 @@ class GroupProjectsFinder < ProjectsFinder
options.fetch(:only_shared, false)
end
+ # subgroups are supported only for owned projects not for shared
+ def include_subgroups?
+ options.fetch(:include_subgroups, false)
+ end
+
def owned_projects
- group.projects
+ if include_subgroups?
+ Project.where(namespace_id: group.self_and_descendants.select(:id))
+ else
+ group.projects
+ end
end
def shared_projects
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index b46ec5e5350..61c72aa22a8 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -19,37 +19,52 @@
# non_archived: boolean
# iids: integer[]
# my_reaction_emoji: string
+# created_after: datetime
+# created_before: datetime
+# updated_after: datetime
+# updated_before: datetime
#
class IssuableFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
include CreatedAtFilter
- NONE = '0'.freeze
+ requires_cross_project_access unless: -> { project? }
- SCALAR_PARAMS = %i[
- assignee_id
- assignee_username
- author_id
- author_username
- authorized_only
- due_date
- group_id
- iids
- label_name
- milestone_title
- my_reaction_emoji
- non_archived
- project_id
- scope
- search
- sort
- state
- ].freeze
- ARRAY_PARAMS = { label_name: [], iids: [], assignee_username: [] }.freeze
-
- VALID_PARAMS = (SCALAR_PARAMS + [ARRAY_PARAMS]).freeze
+ NONE = '0'.freeze
attr_accessor :current_user, :params
+ def self.scalar_params
+ @scalar_params ||= %i[
+ assignee_id
+ assignee_username
+ author_id
+ author_username
+ authorized_only
+ group_id
+ iids
+ label_name
+ milestone_title
+ my_reaction_emoji
+ non_archived
+ project_id
+ scope
+ search
+ sort
+ state
+ include_subgroups
+ ]
+ end
+
+ def self.array_params
+ @array_params ||= { label_name: [], iids: [], assignee_username: [] }
+ end
+
+ def self.valid_params
+ @valid_params ||= scalar_params + [array_params]
+ end
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params
@@ -57,31 +72,28 @@ class IssuableFinder
def execute
items = init_collection
+ items = filter_items(items)
+
+ # Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far
+ items = by_project(items)
+
+ sort(items)
+ end
+
+ def filter_items(items)
items = by_scope(items)
items = by_created_at(items)
+ items = by_updated_at(items)
items = by_state(items)
items = by_group(items)
items = by_search(items)
items = by_assignee(items)
items = by_author(items)
- items = by_due_date(items)
items = by_non_archived(items)
items = by_iids(items)
items = by_milestone(items)
items = by_label(items)
- items = by_my_reaction_emoji(items)
-
- # Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far
- items = by_project(items)
- sort(items)
- end
-
- def find(*params)
- execute.find(*params)
- end
-
- def find_by(*params)
- execute.find_by(*params)
+ by_my_reaction_emoji(items)
end
def row_count
@@ -113,10 +125,6 @@ class IssuableFinder
counts
end
- def find_by!(*params)
- execute.find_by!(*params)
- end
-
def group
return @group if defined?(@group)
@@ -148,7 +156,8 @@ class IssuableFinder
if current_user && params[:authorized_only].presence && !current_user_related?
current_user.authorized_projects
elsif group
- GroupProjectsFinder.new(group: group, current_user: current_user).execute
+ finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
+ GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
else
ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids(items)).execute
end
@@ -279,6 +288,13 @@ class IssuableFinder
end
end
+ def by_updated_at(items)
+ items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
+ items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
+
+ items
+ end
+
def by_state(items)
case params[:state].to_s
when 'closed'
@@ -321,7 +337,7 @@ class IssuableFinder
def sort(items)
# Ensure we always have an explicit sort order (instead of inheriting
# multiple orders when combining ActiveRecord::Relation objects).
- params[:sort] ? items.sort(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc)
+ params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc)
end
def by_assignee(items)
@@ -374,19 +390,14 @@ class IssuableFinder
end
def by_label(items)
- if labels?
+ return items unless labels?
+
+ items =
if filter_by_no_label?
- items = items.without_label
+ items.without_label
else
- items = items.with_label(label_names, params[:sort])
- items_projects = projects(items)
-
- if items_projects
- label_ids = LabelsFinder.new(current_user, project_ids: items_projects).execute(skip_authorization: true).select(:id)
- items = items.where(labels: { id: label_ids })
- end
+ items.with_label(label_names, params[:sort])
end
- end
items
end
@@ -399,42 +410,6 @@ class IssuableFinder
items
end
- def by_due_date(items)
- if due_date?
- if filter_by_no_due_date?
- items = items.without_due_date
- elsif filter_by_overdue?
- items = items.due_before(Date.today)
- elsif filter_by_due_this_week?
- items = items.due_between(Date.today.beginning_of_week, Date.today.end_of_week)
- elsif filter_by_due_this_month?
- items = items.due_between(Date.today.beginning_of_month, Date.today.end_of_month)
- end
- end
-
- items
- end
-
- def filter_by_no_due_date?
- due_date? && params[:due_date] == Issue::NoDueDate.name
- end
-
- def filter_by_overdue?
- due_date? && params[:due_date] == Issue::Overdue.name
- end
-
- def filter_by_due_this_week?
- due_date? && params[:due_date] == Issue::DueThisWeek.name
- end
-
- def filter_by_due_this_month?
- due_date? && params[:due_date] == Issue::DueThisMonth.name
- end
-
- def due_date?
- params[:due_date].present? && klass.column_names.include?('due_date')
- end
-
def label_names
if labels?
params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index d2275139c42..2a27ff0e386 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -15,12 +15,22 @@
# label_name: string
# sort: string
# my_reaction_emoji: string
+# public_only: boolean
+# due_date: date or '0', '', 'overdue', 'week', or 'month'
+# created_after: datetime
+# created_before: datetime
+# updated_after: datetime
+# updated_before: datetime
#
class IssuesFinder < IssuableFinder
CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
+ def self.scalar_params
+ @scalar_params ||= super + [:due_date]
+ end
+
def klass
- Issue
+ Issue.includes(:author)
end
def with_confidentiality_access_check
@@ -40,7 +50,55 @@ class IssuesFinder < IssuableFinder
private
def init_collection
- with_confidentiality_access_check
+ if public_only?
+ Issue.public_only
+ else
+ with_confidentiality_access_check
+ end
+ end
+
+ def public_only?
+ params.fetch(:public_only, false)
+ end
+
+ def filter_items(items)
+ by_due_date(super)
+ end
+
+ def by_due_date(items)
+ if due_date?
+ if filter_by_no_due_date?
+ items = items.without_due_date
+ elsif filter_by_overdue?
+ items = items.due_before(Date.today)
+ elsif filter_by_due_this_week?
+ items = items.due_between(Date.today.beginning_of_week, Date.today.end_of_week)
+ elsif filter_by_due_this_month?
+ items = items.due_between(Date.today.beginning_of_month, Date.today.end_of_month)
+ end
+ end
+
+ items
+ end
+
+ def filter_by_no_due_date?
+ due_date? && params[:due_date] == Issue::NoDueDate.name
+ end
+
+ def filter_by_overdue?
+ due_date? && params[:due_date] == Issue::Overdue.name
+ end
+
+ def filter_by_due_this_week?
+ due_date? && params[:due_date] == Issue::DueThisWeek.name
+ end
+
+ def filter_by_due_this_month?
+ due_date? && params[:due_date] == Issue::DueThisMonth.name
+ end
+
+ def due_date?
+ params[:due_date].present?
end
def user_can_see_all_confidential_issues?
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index ce432ddbfe6..afd1f824b32 100644
--- a/app/finders/labels_finder.rb
+++ b/app/finders/labels_finder.rb
@@ -1,4 +1,10 @@
class LabelsFinder < UnionFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+ include Gitlab::Utils::StrongMemoize
+
+ requires_cross_project_access unless: -> { project? }
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params
@@ -22,9 +28,10 @@ class LabelsFinder < UnionFinder
if project
if project.group.present?
labels_table = Label.arel_table
+ group_ids = group_ids_for(project.group)
label_ids << Label.where(
- labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].eq(project.group.id)).or(
+ labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].in(group_ids)).or(
labels_table[:type].eq('ProjectLabel').and(labels_table[:project_id].eq(project.id))
)
)
@@ -33,8 +40,13 @@ class LabelsFinder < UnionFinder
end
end
else
+ if group?
+ group = Group.find(params[:group_id])
+ label_ids << Label.where(group_id: group_ids_for(group))
+ end
+
label_ids << Label.where(group_id: projects.group_ids)
- label_ids << Label.where(project_id: projects.select(:id))
+ label_ids << Label.where(project_id: projects.select(:id)) unless only_group_labels?
end
label_ids
@@ -51,6 +63,33 @@ class LabelsFinder < UnionFinder
items.where(title: title)
end
+ # Gets redacted array of group ids
+ # which can include the ancestors and descendants of the requested group.
+ def group_ids_for(group)
+ strong_memoize(:group_ids) do
+ groups = groups_to_include(group)
+
+ groups_user_can_read_labels(groups).map(&:id)
+ end
+ end
+
+ def groups_to_include(group)
+ groups = [group]
+
+ groups += group.ancestors if include_ancestor_groups?
+ groups += group.descendants if include_descendant_groups?
+
+ groups
+ end
+
+ def include_ancestor_groups?
+ params[:include_ancestor_groups]
+ end
+
+ def include_descendant_groups?
+ params[:include_descendant_groups]
+ end
+
def group?
params[:group_id].present?
end
@@ -60,7 +99,11 @@ class LabelsFinder < UnionFinder
end
def projects?
- params[:project_ids].present?
+ params[:project_ids]
+ end
+
+ def only_group_labels?
+ params[:only_group_labels]
end
def title
@@ -96,9 +139,15 @@ class LabelsFinder < UnionFinder
@projects
end
- def authorized_to_read_labels?(project)
+ def authorized_to_read_labels?(label_parent)
return true if skip_authorization
- Ability.allowed?(current_user, :read_label, project)
+ Ability.allowed?(current_user, :read_label, label_parent)
+ end
+
+ def groups_user_can_read_labels(groups)
+ DeclarativePolicy.user_scope do
+ groups.select { |group| authorized_to_read_labels?(group) }
+ end
end
end
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index af24045886e..4734d97b8c7 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -10,26 +10,59 @@ class MembersFinder
def execute
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
- wheres = ["members.id IN (#{project_members.select(:id).to_sql})"]
if group
- # We need `.where.not(user_id: nil)` here otherwise when a group has an
- # invitee, it would make the following query return 0 rows since a NULL
- # user_id would be present in the subquery
- # See http://stackoverflow.com/questions/129077/not-in-clause-and-null-values
- non_null_user_ids = project_members.where.not(user_id: nil).select(:user_id)
-
group_members = GroupMembersFinder.new(group).execute
- group_members = group_members.where.not(user_id: non_null_user_ids)
- group_members = group_members.non_invite unless can?(current_user, :admin_group, group)
+ group_members = group_members.non_invite
- wheres << "members.id IN (#{group_members.select(:id).to_sql})"
- end
+ union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false)
- Member.where(wheres.join(' OR '))
+ sql = distinct_on(union)
+
+ Member.includes(:user).from("(#{sql}) AS #{Member.table_name}")
+ else
+ project_members
+ end
end
def can?(*args)
Ability.allowed?(*args)
end
+
+ private
+
+ def distinct_on(union)
+ # We're interested in a list of members without duplicates by user_id.
+ # We prefer project members over group members, project members should go first.
+ if Gitlab::Database.postgresql?
+ <<~SQL
+ SELECT DISTINCT ON (user_id, invite_email) member_union.*
+ FROM (#{union.to_sql}) AS member_union
+ ORDER BY user_id,
+ invite_email,
+ CASE
+ WHEN type = 'ProjectMember' THEN 1
+ WHEN type = 'GroupMember' THEN 2
+ ELSE 3
+ END
+ SQL
+ else
+ # Older versions of MySQL do not support window functions (and DISTINCT ON is postgres-specific).
+ <<~SQL
+ SELECT t1.*
+ FROM (#{union.to_sql}) AS t1
+ JOIN (
+ SELECT
+ COALESCE(user_id, -1) AS user_id,
+ COALESCE(invite_email, 'NULL') AS invite_email,
+ MIN(CASE WHEN type = 'ProjectMember' THEN 1 WHEN type = 'GroupMember' THEN 2 ELSE 3 END) AS type_number
+ FROM
+ (#{union.to_sql}) AS t3
+ GROUP BY COALESCE(user_id, -1), COALESCE(invite_email, 'NULL')
+ ) AS t2 ON COALESCE(t1.user_id, -1) = t2.user_id
+ AND COALESCE(t1.invite_email, 'NULL') = t2.invite_email
+ AND CASE WHEN t1.type = 'ProjectMember' THEN 1 WHEN t1.type = 'GroupMember' THEN 2 ELSE 3 END = t2.type_number
+ SQL
+ end
+ end
end
diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb
index 189eb3847eb..f358938344e 100644
--- a/app/finders/merge_request_target_project_finder.rb
+++ b/app/finders/merge_request_target_project_finder.rb
@@ -1,4 +1,6 @@
class MergeRequestTargetProjectFinder
+ include FinderMethods
+
attr_reader :current_user, :source_project
def initialize(current_user: nil, source_project:)
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index d0687d28c21..64dc1e6af0f 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -17,14 +17,46 @@
# sort: string
# non_archived: boolean
# my_reaction_emoji: string
+# source_branch: string
+# target_branch: string
+# created_after: datetime
+# created_before: datetime
+# updated_after: datetime
+# updated_before: datetime
#
class MergeRequestsFinder < IssuableFinder
def klass
MergeRequest
end
+ def filter_items(_items)
+ items = by_source_branch(super)
+
+ by_target_branch(items)
+ end
+
private
+ def source_branch
+ @source_branch ||= params[:source_branch].presence
+ end
+
+ def by_source_branch(items)
+ return items unless source_branch
+
+ items.where(source_branch: source_branch)
+ end
+
+ def target_branch
+ @target_branch ||= params[:target_branch].presence
+ end
+
+ def by_target_branch(items)
+ return items unless target_branch
+
+ items.where(target_branch: target_branch)
+ end
+
def item_project_ids(items)
items&.reorder(nil)&.select(:target_project_id)
end
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 0a5a0ea2f35..f5d2b9f253a 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -8,6 +8,8 @@
# state - filters by state.
class MilestonesFinder
+ include FinderMethods
+
attr_reader :params, :project_ids, :group_ids
def initialize(params = {})
@@ -46,11 +48,7 @@ class MilestonesFinder
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
+ order_statement = Gitlab::Database.nulls_last_order('due_date', 'ASC')
+ items.reorder(order_statement).order('title ASC')
end
end
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 12157818bcd..35f4ff2f62f 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -48,16 +48,28 @@ class NotesFinder
def init_collection
if target
notes_on_target
+ elsif target_type
+ notes_of_target_type
else
notes_of_any_type
end
end
+ def notes_of_target_type
+ notes = notes_for_type(target_type)
+
+ search(notes)
+ end
+
+ def target_type
+ @params[:target_type]
+ end
+
def notes_of_any_type
types = %w(commit issue merge_request snippet)
note_relations = types.map { |t| notes_for_type(t) }
note_relations.map! { |notes| search(notes) }
- UnionFinder.new.find_union(note_relations, Note)
+ UnionFinder.new.find_union(note_relations, Note.includes(:author))
end
def noteables_for_type(noteable_type)
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 005612ededc..c7d6bc6cfdc 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -124,7 +124,7 @@ class ProjectsFinder < UnionFinder
end
def sort(items)
- params[:sort].present? ? items.sort(params[:sort]) : items.order_id_desc
+ params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.order_id_desc
end
def by_archived(projects)
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index c04f61de79c..d498a2d6d11 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -1,14 +1,30 @@
+# Snippets Finder
+#
+# Used to filter Snippets collections by a set of params
+#
+# Arguments.
+#
+# current_user - The current user, nil also can be used.
+# params:
+# visibility (integer) - Individual snippet visibility: Public(20), internal(10) or private(0).
+# project (Project) - Project related.
+# author (User) - Author related.
+#
+# params are optional
class SnippetsFinder < UnionFinder
- attr_accessor :current_user, :params
+ include Gitlab::Allowable
+ include FinderMethods
+
+ attr_accessor :current_user, :project, :params
def initialize(current_user, params = {})
@current_user = current_user
@params = params
+ @project = params[:project]
end
def execute
items = init_collection
- items = by_project(items)
items = by_author(items)
items = by_visibility(items)
@@ -18,25 +34,74 @@ class SnippetsFinder < UnionFinder
private
def init_collection
- items = Snippet.all
+ if project.present?
+ authorized_snippets_from_project
+ else
+ authorized_snippets
+ end
+ end
- accessible(items)
+ def authorized_snippets_from_project
+ if can?(current_user, :read_project_snippet, project)
+ if project.team.member?(current_user)
+ project.snippets
+ else
+ project.snippets.public_to_user(current_user)
+ end
+ else
+ Snippet.none
+ end
end
- def accessible(items)
- segments = []
- segments << items.public_to_user(current_user)
- segments << authorized_to_user(items) if current_user
+ def authorized_snippets
+ Snippet.where(feature_available_projects.or(not_project_related))
+ .public_or_visible_to_user(current_user)
+ end
+
+ # Returns a collection of projects that is either public or visible to the
+ # logged in user.
+ #
+ # A caller must pass in a block to modify individual parts of
+ # the query, e.g. to apply .with_feature_available_for_user on top of it.
+ # This is useful for performance as we can stick those additional filters
+ # at the bottom of e.g. the UNION.
+ def projects_for_user
+ return yield(Project.public_to_user) unless current_user
+
+ # If the current_user is allowed to see all projects,
+ # we can shortcut and just return.
+ return yield(Project.all) if current_user.full_private_access?
+
+ authorized_projects = yield(Project.where('EXISTS (?)', current_user.authorizations_for_projects))
+
+ levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
+ visible_projects = yield(Project.where(visibility_level: levels))
- find_union(segments, Snippet)
+ # We use a UNION here instead of OR clauses since this results in better
+ # performance.
+ union = Gitlab::SQL::Union.new([authorized_projects.select('projects.id'), visible_projects.select('projects.id')])
+
+ Project.from("(#{union.to_sql}) AS #{Project.table_name}")
+ end
+
+ def feature_available_projects
+ # Don't return any project related snippets if the user cannot read cross project
+ return table[:id].eq(nil) unless Ability.allowed?(current_user, :read_cross_project)
+
+ projects = projects_for_user do |part|
+ part.with_feature_available_for_user(:snippets, current_user)
+ end.select(:id)
+
+ arel_query = Arel::Nodes::SqlLiteral.new(projects.to_sql)
+ table[:project_id].in(arel_query)
end
- 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))
+ def not_project_related
+ table[:project_id].eq(nil)
+ end
+
+ def table
+ Snippet.arel_table
end
def by_visibility(items)
@@ -53,12 +118,6 @@ class SnippetsFinder < UnionFinder
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 visibility_from_scope
case params[:scope].to_s
when 'are_private'
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index 3502bf08971..09e2c586f2a 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -13,6 +13,11 @@
#
class TodosFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+
+ requires_cross_project_access unless: -> { project? }
+
NONE = '0'.freeze
attr_accessor :current_user, :params
@@ -105,10 +110,6 @@ class TodosFinder
ids
end
- def projects(items)
- ProjectsFinder.new(current_user: current_user, project_ids_relation: project_ids(items)).execute
- end
-
def type?
type.present? && %w(Issue MergeRequest).include?(type)
end
@@ -118,7 +119,7 @@ class TodosFinder
end
def sort(items)
- params[:sort] ? items.sort(params[:sort]) : items.order_id_desc
+ params[:sort] ? items.sort_by_attribute(params[:sort]) : items.order_id_desc
end
def by_action(items)
@@ -147,13 +148,12 @@ class TodosFinder
def by_project(items)
if project?
- items = items.where(project: project)
+ items.where(project: project)
else
- item_projects = projects(items)
- items = items.merge(item_projects).joins(:project)
- end
+ projects = Project.public_or_visible_to_user(current_user)
- items
+ items.joins(:project).merge(projects)
+ end
end
def by_state(items)
diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb
new file mode 100644
index 00000000000..65d6e019746
--- /dev/null
+++ b/app/finders/user_recent_events_finder.rb
@@ -0,0 +1,64 @@
+# Get user activity feed for projects common for a user and a logged in user
+#
+# - current_user: The user viewing the events
+# - user: The user for which to load the events
+# - params:
+# - offset: The page of events to return
+class UserRecentEventsFinder
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+
+ requires_cross_project_access
+
+ attr_reader :current_user, :target_user, :params
+
+ LIMIT = 20
+
+ def initialize(current_user, target_user, params = {})
+ @current_user = current_user
+ @target_user = target_user
+ @params = params
+ end
+
+ def execute
+ recent_events(params[:offset] || 0)
+ .joins(:project)
+ .with_associations
+ .limit_recent(LIMIT, params[:offset])
+ end
+
+ private
+
+ def recent_events(offset)
+ sql = <<~SQL
+ (#{projects}) AS projects_for_join
+ JOIN (#{target_events.to_sql}) AS #{Event.table_name}
+ ON #{Event.table_name}.project_id = projects_for_join.id
+ SQL
+
+ # Workaround for https://github.com/rails/rails/issues/24193
+ Event.from([Arel.sql(sql)])
+ end
+
+ def target_events
+ Event.where(author: target_user)
+ end
+
+ def projects
+ # Compile a list of projects `current_user` interacted with
+ # and `target_user` is allowed to see.
+
+ authorized = target_user
+ .project_interactions
+ .joins(:project_authorizations)
+ .where(project_authorizations: { user: current_user })
+ .select(:id)
+
+ visible = target_user
+ .project_interactions
+ .where(visibility_level: [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC])
+ .select(:id)
+
+ Gitlab::SQL::Union.new([authorized, visible]).to_sql
+ end
+end
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index c037de33c22..f48db024e3f 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -1,27 +1,27 @@
module AppearancesHelper
def brand_title
- brand_item&.title.presence || 'GitLab Community Edition'
+ current_appearance&.title.presence || 'GitLab Community Edition'
end
def brand_image
- image_tag(brand_item.logo) if brand_item&.logo?
+ image_tag(current_appearance.logo) if current_appearance&.logo?
end
def brand_text
- markdown_field(brand_item, :description)
+ markdown_field(current_appearance, :description)
end
def brand_new_project_guidelines
- markdown_field(brand_item, :new_project_guidelines)
+ markdown_field(current_appearance, :new_project_guidelines)
end
- def brand_item
+ def current_appearance
@appearance ||= Appearance.current
end
def brand_header_logo
- if brand_item&.header_logo?
- image_tag brand_item.header_logo
+ if current_appearance&.header_logo?
+ image_tag current_appearance.header_logo
else
render 'shared/logo.svg'
end
@@ -29,7 +29,7 @@ module AppearancesHelper
# Skip the 'GitLab' type logo when custom brand logo is set
def brand_header_logo_type
- unless brand_item&.header_logo?
+ unless current_appearance&.header_logo?
render 'shared/logo_type.svg'
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index d13407a06c8..228c8d2e8f9 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -34,7 +34,7 @@ module ApplicationHelper
def project_icon(project_id, options = {})
project =
- if project_id.is_a?(Project)
+ if project_id.respond_to?(:avatar_url)
project_id
else
Project.find_by_full_path(project_id)
@@ -68,18 +68,32 @@ module ApplicationHelper
end
end
- 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
- else
- User.find_by_any_email(user_or_email.try(:downcase))
- end
+ # Takes both user and email and returns the avatar_icon by
+ # user (preferred) or email.
+ def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true)
+ if user
+ avatar_icon_for_user(user, size, scale, only_path: only_path)
+ elsif email
+ avatar_icon_for_email(email, size, scale, only_path: only_path)
+ else
+ default_avatar
+ end
+ end
+ def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
+ user = User.find_by_any_email(email.try(:downcase))
+ if user
+ avatar_icon_for_user(user, size, scale, only_path: only_path)
+ else
+ gravatar_icon(email, size, scale)
+ end
+ end
+
+ def avatar_icon_for_user(user = nil, size = nil, scale = 2, only_path: true)
if user
user.avatar_url(size: size, only_path: only_path) || default_avatar
else
- gravatar_icon(user_or_email, size, scale)
+ gravatar_icon(nil, size, scale)
end
end
@@ -89,7 +103,7 @@ module ApplicationHelper
end
def default_avatar
- 'no_avatar.png'
+ asset_path('no_avatar.png')
end
def last_commit(project)
@@ -214,9 +228,7 @@ module ApplicationHelper
scope: params[:scope],
milestone_title: params[:milestone_title],
assignee_id: params[:assignee_id],
- assignee_username: params[:assignee_username],
author_id: params[:author_id],
- author_username: params[:author_username],
search: params[:search],
label_name: params[:label_name]
}
@@ -271,6 +283,10 @@ module ApplicationHelper
class_names
end
+ # EE feature: System header and footer, unavailable in CE
+ def system_message_class
+ end
+
# Returns active css class when condition returns true
# otherwise returns nil.
#
@@ -286,7 +302,7 @@ module ApplicationHelper
def linkedin_url(user)
name = user.linkedin
- if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/}
+ if name =~ %r{\Ahttps?://(www\.)?linkedin\.com/in/}
name
else
"https://www.linkedin.com/in/#{name}"
@@ -295,10 +311,10 @@ module ApplicationHelper
def twitter_url(user)
name = user.twitter
- if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/}
+ if name =~ %r{\Ahttps?://(www\.)?twitter\.com/}
name
else
- "https://www.twitter.com/#{name}"
+ "https://twitter.com/#{name}"
end
end
@@ -306,11 +322,14 @@ module ApplicationHelper
cookies["sidebar_collapsed"] == "true"
end
- def show_new_ide?
- cookies["new_repo"] == "true" && body_data_page != 'projects:show'
- end
-
def locale_path
asset_path("locale/#{Gitlab::I18n.locale}/app.js")
end
+
+ # Overridden in EE
+ def read_only_message
+ return unless Gitlab::Database.read_only?
+
+ _('You are on a read-only GitLab instance.')
+ end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index b12ea760668..b3b080e6dcf 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -1,25 +1,23 @@
module ApplicationSettingsHelper
extend self
- include Gitlab::CurrentSettings
-
delegate :allow_signup?,
:gravatar_enabled?,
:password_authentication_enabled_for_web?,
:akismet_enabled?,
:koding_enabled?,
- to: :current_application_settings
+ to: :'Gitlab::CurrentSettings.current_application_settings'
def user_oauth_applications?
- current_application_settings.user_oauth_applications
+ Gitlab::CurrentSettings.user_oauth_applications
end
def allowed_protocols_present?
- current_application_settings.enabled_git_access_protocol.present?
+ Gitlab::CurrentSettings.enabled_git_access_protocol.present?
end
def enabled_protocol
- case current_application_settings.enabled_git_access_protocol
+ case Gitlab::CurrentSettings.enabled_git_access_protocol
when 'http'
gitlab_config.protocol
when 'ssh'
@@ -57,7 +55,7 @@ module ApplicationSettingsHelper
# toggle button effect.
def import_sources_checkboxes(help_block_id)
Gitlab::ImportSources.options.map do |name, source|
- checked = current_application_settings.import_sources.include?(source)
+ checked = Gitlab::CurrentSettings.import_sources.include?(source)
css_class = checked ? 'active' : ''
checkbox_name = 'application_setting[import_sources][]'
@@ -72,14 +70,14 @@ module ApplicationSettingsHelper
def oauth_providers_checkboxes
button_based_providers.map do |source|
- disabled = current_application_settings.disabled_oauth_sign_in_sources.include?(source.to_s)
+ disabled = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources.include?(source.to_s)
css_class = 'btn'
css_class << ' active' unless disabled
checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]'
label_tag(checkbox_name, class: css_class) do
check_box_tag(checkbox_name, source, !disabled,
- autocomplete: 'off') + Gitlab::OAuth::Provider.label_for(source)
+ autocomplete: 'off') + Gitlab::Auth::OAuth::Provider.label_for(source)
end
end
end
@@ -96,12 +94,12 @@ module ApplicationSettingsHelper
]
end
- def repository_storages_options_for_select
+ def repository_storages_options_for_select(selected)
options = Gitlab.config.repositories.storages.map do |name, storage|
- ["#{name} - #{storage['path']}", name]
+ ["#{name} - #{storage['gitaly_address']}", name]
end
- options_for_select(options, @application_setting.repository_storages)
+ options_for_select(options, selected)
end
def sidekiq_queue_options_for_select
@@ -146,7 +144,9 @@ module ApplicationSettingsHelper
:after_sign_up_text,
:akismet_api_key,
:akismet_enabled,
+ :authorized_keys_enabled,
:auto_devops_enabled,
+ :auto_devops_domain,
:circuitbreaker_access_retries,
:circuitbreaker_check_interval,
:circuitbreaker_failure_count_threshold,
@@ -199,7 +199,9 @@ module ApplicationSettingsHelper
:metrics_port,
:metrics_sample_interval,
:metrics_timeout,
+ :pages_domain_verification_enabled,
:password_authentication_enabled_for_web,
+ :password_authentication_enabled_for_git,
:performance_bar_allowed_group_id,
:performance_bar_enabled,
:plantuml_enabled,
@@ -243,7 +245,8 @@ module ApplicationSettingsHelper
:usage_ping_enabled,
:user_default_external,
:user_oauth_applications,
- :version_check_enabled
+ :version_check_enabled,
+ :allow_local_requests_from_hooks_and_services
]
end
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 66dc0b1e6f7..c109954f3a3 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -1,11 +1,9 @@
module AuthHelper
- include Gitlab::CurrentSettings
-
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
FORM_BASED_PROVIDERS = [/\Aldap/, 'crowd'].freeze
def ldap_enabled?
- Gitlab::LDAP::Config.enabled?
+ Gitlab::Auth::LDAP::Config.enabled?
end
def omniauth_enabled?
@@ -17,11 +15,11 @@ module AuthHelper
end
def auth_providers
- Gitlab::OAuth::Provider.providers
+ Gitlab::Auth::OAuth::Provider.providers
end
def label_for_provider(name)
- Gitlab::OAuth::Provider.label_for(name)
+ Gitlab::Auth::OAuth::Provider.label_for(name)
end
def form_based_provider?(name)
@@ -41,7 +39,7 @@ module AuthHelper
end
def enabled_button_based_providers
- disabled_providers = current_application_settings.disabled_oauth_sign_in_sources || []
+ disabled_providers = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources || []
button_based_providers.map(&:to_s) - disabled_providers
end
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index f4310ca2f06..16451993e93 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -9,21 +9,28 @@ module AutoDevopsHelper
end
def auto_devops_warning_message(project)
- missing_domain = !project.auto_devops&.has_domain?
- missing_service = !project.deployment_platform&.active?
-
- if missing_service
+ if missing_auto_devops_service?(project)
params = {
- kubernetes: link_to('Kubernetes service', edit_project_service_path(project, 'kubernetes'))
+ kubernetes: link_to('Kubernetes cluster', project_clusters_path(project))
}
- if missing_domain
- _('Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly.') % params
+ if missing_auto_devops_domain?(project)
+ _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params
else
- _('Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly.') % params
+ _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params
end
- elsif missing_domain
+ elsif missing_auto_devops_domain?(project)
_('Auto Review Apps and Auto Deploy need a domain name to work correctly.')
end
end
+
+ private
+
+ def missing_auto_devops_domain?(project)
+ !(project.auto_devops || project.build_auto_devops)&.has_domain?
+ end
+
+ def missing_auto_devops_service?(project)
+ !project.deployment_platform&.active?
+ end
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index be11d453898..21b6c0a8ad5 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -8,10 +8,22 @@ module AvatarsHelper
}))
end
+ def user_avatar_url_for(options = {})
+ if options[:url]
+ options[:url]
+ elsif options[:user]
+ avatar_icon_for_user(options[:user], options[:size])
+ else
+ avatar_icon_for_email(options[:user_email], options[:size])
+ end
+ end
+
def user_avatar_without_link(options = {})
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
- avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size)
+
+ avatar_url = user_avatar_url_for(options.merge(size: avatar_size))
+
has_tooltip = options[:has_tooltip].nil? ? true : options[:has_tooltip]
data_attributes = options[:data] || {}
css_class = %W[avatar s#{avatar_size}].push(*options[:css_class])
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 3c2ee2cb5bc..2b440e4d584 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -1,6 +1,8 @@
module BlobHelper
def highlight(blob_name, blob_content, repository: nil, plain: false)
+ plain ||= blob_content.length > Blob::MAXIMUM_TEXT_HIGHLIGHT_SIZE
highlighted = Gitlab::Highlight.highlight(blob_name, blob_content, plain: plain, repository: repository)
+
raw %(<pre class="code highlight"><code>#{highlighted}</code></pre>)
end
@@ -10,75 +12,39 @@ module BlobHelper
def edit_blob_path(project = @project, ref = @ref, path = @path, options = {})
project_edit_blob_path(project,
- tree_join(ref, path),
- options[:link_opts])
- end
-
- def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
- blob = options.delete(:blob)
- blob ||= project.repository.blob_at(ref, path) rescue nil
-
- return unless blob && blob.readable_text?
-
- common_classes = "btn js-edit-blob #{options[:extra_class]}"
-
- if !on_top_of_branch?(project, ref)
- button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
- # This condition applies to anonymous or users who can edit directly
- elsif !current_user || (current_user && can_modify_blob?(blob, project, ref))
- link_to 'Edit', edit_blob_path(project, ref, path, options), class: "#{common_classes} btn-sm"
- elsif current_user && can?(current_user, :fork_project, project)
- continue_params = {
- to: edit_blob_path(project, ref, path, options),
- 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)
-
- button_tag 'Edit',
- class: "#{common_classes} js-edit-blob-link-fork-toggler",
- data: { action: 'edit', fork_path: fork_path }
- end
+ tree_join(ref, path),
+ options[:link_opts])
end
def ide_edit_path(project = @project, ref = @ref, path = @path, options = {})
"#{ide_path}/project#{edit_blob_path(project, ref, path, options)}"
end
- def ide_edit_text
- "#{_('Multi Edit')} <span class='label label-primary'>#{_('Beta')}</span>".html_safe
- end
-
- def ide_blob_link(project = @project, ref = @ref, path = @path, options = {})
- return unless show_new_ide?
+ def edit_blob_button(project = @project, ref = @ref, path = @path, options = {})
+ return unless blob = readable_blob(options, path, project, ref)
- blob = options.delete(:blob)
- blob ||= project.repository.blob_at(ref, path) rescue nil
+ common_classes = "btn js-edit-blob #{options[:extra_class]}"
- return unless blob && blob.readable_text?
+ edit_button_tag(blob,
+ common_classes,
+ _('Edit'),
+ edit_blob_path(project, ref, path, options),
+ project,
+ ref)
+ end
- common_classes = "btn js-edit-ide #{options[:extra_class]}"
+ def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
+ return unless blob = readable_blob(options, path, project, ref)
- if !on_top_of_branch?(project, ref)
- button_tag ide_edit_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' }
- # This condition applies to anonymous or users who can edit directly
- elsif current_user && can_modify_blob?(blob, project, ref)
- link_to ide_edit_text, ide_edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
- elsif current_user && can?(current_user, :fork_project, project)
- continue_params = {
- to: ide_edit_path(project, ref, path, options),
- 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)
-
- button_tag ide_edit_text,
- class: common_classes,
- data: { fork_path: fork_path }
- end
+ edit_button_tag(blob,
+ 'btn btn-default',
+ _('Web IDE'),
+ ide_edit_path(project, ref, path, options),
+ project,
+ ref)
end
- def modify_file_link(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
+ def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
return unless current_user
blob = project.repository.blob_at(ref, path) rescue nil
@@ -94,21 +60,12 @@ module BlobHelper
elsif can_modify_blob?(blob, project, ref)
button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal'
elsif can?(current_user, :fork_project, project)
- continue_params = {
- to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to #{action} this 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)
-
- button_tag label,
- class: "#{common_classes} js-edit-blob-link-fork-toggler",
- data: { action: action, fork_path: fork_path }
+ edit_fork_button_tag(common_classes, project, label, edit_modify_file_fork_params(action), action)
end
end
def replace_blob_link(project = @project, ref = @ref, path = @path)
- modify_file_link(
+ modify_file_button(
project,
ref,
path,
@@ -120,7 +77,7 @@ module BlobHelper
end
def delete_blob_link(project = @project, ref = @ref, path = @path)
- modify_file_link(
+ modify_file_button(
project,
ref,
path,
@@ -269,7 +226,7 @@ module BlobHelper
return if blob.empty?
if blob.raw_binary? || blob.stored_externally?
- icon = icon('download')
+ icon = sprite_icon('download')
title = 'Download'
else
icon = icon('file-code-o')
@@ -330,4 +287,55 @@ module BlobHelper
options
end
+
+ def readable_blob(options, path, project, ref)
+ blob = options.delete(:blob)
+ blob ||= project.repository.blob_at(ref, path) rescue nil
+
+ blob if blob&.readable_text?
+ end
+
+ def edit_blob_fork_params(path)
+ {
+ to: path,
+ notice: edit_in_new_fork_notice,
+ notice_now: edit_in_new_fork_notice_now
+ }
+ end
+
+ def edit_modify_file_fork_params(action)
+ {
+ to: request.fullpath,
+ notice: edit_in_new_fork_notice_action(action),
+ notice_now: edit_in_new_fork_notice_now
+ }
+ end
+
+ def edit_fork_button_tag(common_classes, project, label, params, action = 'edit')
+ fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: params)
+
+ button_tag label,
+ class: "#{common_classes} js-edit-blob-link-fork-toggler",
+ data: { action: action, fork_path: fork_path }
+ end
+
+ def edit_disabled_button_tag(button_text, common_classes)
+ button_tag(button_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' })
+ end
+
+ def edit_link_tag(link_text, edit_path, common_classes)
+ link_to link_text, edit_path, class: "#{common_classes} btn-sm"
+ end
+
+ def edit_button_tag(blob, common_classes, text, edit_path, project, ref)
+ if !on_top_of_branch?(project, ref)
+ edit_disabled_button_tag(text, common_classes)
+ # This condition only applies to users who are logged in
+ # Web IDE (Beta) requires the user to have this feature enabled
+ elsif !current_user || (current_user && can_modify_blob?(blob, project, ref))
+ edit_link_tag(text, edit_path, common_classes)
+ elsif current_user && can?(current_user, :fork_project, project)
+ edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path))
+ end
+ end
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 12b3d9bac1a..af878bcf9a0 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -17,23 +17,35 @@ module BoardsHelper
end
def build_issue_link_base
- project_issues_path(@project)
+ if board.group_board?
+ "#{group_path(@board.group)}/:project_path/issues"
+ else
+ project_issues_path(@project)
+ end
end
def board_base_url
- project_boards_path(@project)
+ if board.group_board?
+ group_boards_url(@group)
+ else
+ project_boards_path(@project)
+ end
end
def multiple_boards_available?
- current_board_parent.multiple_issue_boards_available?(current_user)
+ current_board_parent.multiple_issue_boards_available?
end
def current_board_path(board)
- @current_board_path ||= project_board_path(current_board_parent, board)
+ @current_board_path ||= if board.group_board?
+ group_board_path(current_board_parent, board)
+ else
+ project_board_path(current_board_parent, board)
+ end
end
def current_board_parent
- @current_board_parent ||= @project
+ @current_board_parent ||= @group || @project
end
def can_admin_issue?
@@ -41,13 +53,16 @@ module BoardsHelper
end
def board_list_data
+ include_descendant_groups = @group&.present?
+
{
toggle: "dropdown",
- list_labels_path: labels_filter_path(true),
- labels: labels_filter_path(true),
+ list_labels_path: labels_filter_path(true, include_ancestor_groups: true),
+ labels: labels_filter_path(true, include_descendant_groups: include_descendant_groups),
labels_endpoint: @labels_endpoint,
namespace_path: @namespace_path,
- project_path: @project&.try(:path)
+ project_path: @project&.path,
+ group_path: @group&.path
}
end
@@ -59,7 +74,8 @@ module BoardsHelper
field_name: 'issue[assignee_ids][]',
first_user: current_user&.username,
current_user: 'true',
- project_id: @project&.try(:id),
+ project_id: @project&.id,
+ group_id: @group&.id,
null_user: 'true',
multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'],
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index 686437fc99a..07b1fc3d7cf 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -1,21 +1,4 @@
module BranchesHelper
- def filter_branches_path(options = {})
- exist_opts = {
- search: params[:search],
- sort: params[:sort]
- }
-
- options = exist_opts.merge(options)
-
- project_branches_path(@project, @id, options)
- end
-
- def can_push_branch?(project, branch_name)
- return false unless project.repository.branch_exists?(branch_name)
-
- ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch_name)
- end
-
def project_branches
options_for_select(@project.repository.branch_names, @project.default_branch)
end
@@ -23,4 +6,12 @@ module BranchesHelper
def protected_branch?(project, branch)
ProtectedBranch.protected?(project, branch.name)
end
+
+ def diverging_count_label(count)
+ if count >= Repository::MAX_DIVERGING_COUNT
+ "#{Repository::MAX_DIVERGING_COUNT - 1}+"
+ else
+ count.to_s
+ end
+ end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 2d304f7eb91..7cc56de24e4 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -63,7 +63,7 @@ module CommitsHelper
# Returns a link formatted as a commit branch link
def commit_branch_link(url, text)
link_to(url, class: 'label label-gray ref-name branch-link') do
- icon('code-fork', class: 'append-right-5') + "#{text}"
+ sprite_icon('fork', size: 16, css_class: 'fork-svg') + "#{text}"
end
end
@@ -93,25 +93,18 @@ module CommitsHelper
return unless current_controller?(:commits)
if @path.blank?
- return link_to(
- _("Browse Files"),
- project_tree_path(project, commit),
- class: "btn btn-default"
- )
+ url = project_tree_path(project, commit)
+ tooltip = _("Browse Files")
elsif @repo.blob_at(commit.id, @path)
- return link_to(
- _("Browse File"),
- project_blob_path(project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
+ url = project_blob_path(project, tree_join(commit.id, @path))
+ tooltip = _("Browse File")
elsif @path.present?
- return link_to(
- _("Browse Directory"),
- project_tree_path(project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
+ url = project_tree_path(project, tree_join(commit.id, @path))
+ tooltip = _("Browse Directory")
+ end
+
+ link_to url, class: "btn btn-default has-tooltip", title: tooltip, data: { container: "body" } do
+ sprite_icon('folder-open')
end
end
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index c25b54eadc6..19aa55a8d49 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -6,4 +6,28 @@ module DashboardHelper
def assigned_mrs_dashboard_path
merge_requests_dashboard_path(assignee_id: current_user.id)
end
+
+ def dashboard_nav_links
+ @dashboard_nav_links ||= get_dashboard_nav_links
+ end
+
+ def dashboard_nav_link?(link)
+ dashboard_nav_links.include?(link)
+ end
+
+ def any_dashboard_nav_link?(links)
+ links.any? { |link| dashboard_nav_link?(link) }
+ end
+
+ private
+
+ def get_dashboard_nav_links
+ links = [:projects, :groups, :snippets]
+
+ if can?(current_user, :read_cross_project)
+ links += [:activity, :milestones]
+ end
+
+ links
+ end
end
diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb
new file mode 100644
index 00000000000..bd921322476
--- /dev/null
+++ b/app/helpers/deploy_tokens_helper.rb
@@ -0,0 +1,12 @@
+module DeployTokensHelper
+ def expand_deploy_tokens_section?(deploy_token)
+ deploy_token.persisted? ||
+ deploy_token.errors.present? ||
+ Rails.env.test?
+ end
+
+ def container_registry_enabled?(project)
+ Gitlab.config.registry.enabled &&
+ can?(current_user, :read_container_image, project)
+ end
+end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 1ce487e6592..b5ca39711bc 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -160,7 +160,7 @@ module DiffHelper
end
def diff_file_changed_icon(diff_file)
- if diff_file.deleted_file? || diff_file.renamed_file?
+ if diff_file.deleted_file?
"file-deletion"
elsif diff_file.new_file?
"file-addition"
@@ -226,4 +226,12 @@ module DiffHelper
diffs.overflow?
end
+
+ def diff_file_path_text(diff_file, max: 60)
+ path = diff_file.new_path
+
+ return path unless path.size > max && max > 3
+
+ "...#{path[-(max - 3)..-1]}"
+ end
end
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 878bc9b5c9c..c86a26ac30f 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -54,9 +54,9 @@ module EmailsHelper
end
def header_logo
- if brand_item && brand_item.header_logo?
+ if current_appearance&.header_logo?
image_tag(
- brand_item.header_logo,
+ current_appearance.header_logo,
style: 'height: 50px'
)
else
@@ -80,4 +80,20 @@ module EmailsHelper
'text-align:center'
].join(';')
end
+
+ # "You are receiving this email because #{reason}"
+ def notification_reason_text(reason)
+ string = case reason
+ when NotificationReason::OWN_ACTIVITY
+ 'of your activity'
+ when NotificationReason::ASSIGNED
+ 'you have been assigned an item'
+ when NotificationReason::MENTIONED
+ 'you have been mentioned'
+ else
+ 'of your account'
+ end
+
+ "#{string} on #{Gitlab.config.gitlab.host}"
+ end
end
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index b981a1e8242..f062a91a166 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -25,8 +25,24 @@ module ExploreHelper
controller.class.name.split("::").first == "Explore"
end
+ def explore_nav_links
+ @explore_nav_links ||= get_explore_nav_links
+ end
+
+ def explore_nav_link?(link)
+ explore_nav_links.include?(link)
+ end
+
+ def any_explore_nav_link?(links)
+ links.any? { |link| explore_nav_link?(link) }
+ end
+
private
+ def get_explore_nav_links
+ [:projects, :groups, :snippets]
+ end
+
def request_path_with_options(options = {})
request.path + "?#{options.to_param}"
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index e26ce6da030..905e2002592 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -27,7 +27,7 @@ module FormHelper
first_user: current_user&.username,
null_user: true,
current_user: true,
- project_id: @project.id,
+ project_id: @project&.id,
field_name: 'issue[assignee_ids][]',
default_label: 'Unassigned',
'max-select': 1,
diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb
index 6d303ba857d..1022070ab6f 100644
--- a/app/helpers/graph_helper.rb
+++ b/app/helpers/graph_helper.rb
@@ -1,10 +1,6 @@
module GraphHelper
- def get_refs(repo, commit)
- 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/') }
- refs << commit_refs.join(' ')
+ def refs(repo, commit)
+ refs = commit.ref_names(repo).join(' ')
# append note count
notes_count = @graph.notes[commit.id]
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 676c1d1988b..95fea2f18d1 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -1,4 +1,16 @@
module GroupsHelper
+ def group_nav_link_paths
+ %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
+ end
+
+ def group_sidebar_links
+ @group_sidebar_links ||= get_group_sidebar_links
+ end
+
+ def group_sidebar_link?(link)
+ group_sidebar_links.include?(link)
+ end
+
def can_change_group_visibility_level?(group)
can?(current_user, :change_visibility_level, group)
end
@@ -7,6 +19,20 @@ module GroupsHelper
can?(current_user, :change_share_with_group_lock, group)
end
+ def group_issues_count(state:)
+ IssuesFinder
+ .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true)
+ .execute
+ .count
+ end
+
+ def group_merge_requests_count(state:)
+ MergeRequestsFinder
+ .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true)
+ .execute
+ .count
+ end
+
def group_icon(group, options = {})
img_path = group_icon_url(group, options)
image_tag img_path, options
@@ -65,10 +91,6 @@ module GroupsHelper
end
end
- 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 }
@@ -88,8 +110,35 @@ module GroupsHelper
end
end
+ def parent_group_options(current_group)
+ groups = current_user.owned_groups.sort_by(&:human_name).map do |group|
+ { id: group.id, text: group.human_name }
+ end
+
+ groups.delete_if { |group| group[:id] == current_group.id }
+ groups.to_json
+ end
+
+ def supports_nested_groups?
+ Group.supports_nested_groups?
+ end
+
private
+ def get_group_sidebar_links
+ links = [:overview, :group_members]
+
+ if can?(current_user, :read_cross_project)
+ links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests]
+ end
+
+ if can?(current_user, :admin_group, @group)
+ links << :settings
+ end
+
+ links
+ end
+
def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false)
link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do
output =
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index c6a83f21ceb..c5522ff7a69 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -30,6 +30,13 @@ module IconsHelper
ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
end
+ def sprite_file_icons_path
+ # SVG Sprites currently don't work across domains, so in the case of a CDN
+ # we have to set the current path deliberately to prevent addition of asset_host
+ sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
+ ActionController::Base.helpers.image_path('file_icons.svg', host: sprite_base_url)
+ end
+
def sprite_icon(icon_name, size: nil, css_class: nil)
css_classes = size ? "s#{size}" : ""
css_classes << " #{css_class}" unless css_class.blank?
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index a18ebfb6030..4664b1728c4 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -1,29 +1,94 @@
module ImportHelper
+ include ::Gitlab::Utils::StrongMemoize
+
+ def has_ci_cd_only_params?
+ false
+ end
+
def import_project_target(owner, name)
namespace = current_user.can_create_group? ? owner : current_user.namespace_path
"#{namespace}/#{name}"
end
- def provider_project_link(provider, path_with_namespace)
- url = __send__("#{provider}_project_url", path_with_namespace) # rubocop:disable GitlabSecurity/PublicSend
+ def provider_project_link(provider, full_path)
+ url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend
+
+ link_to full_path, url, target: '_blank', rel: 'noopener noreferrer'
+ end
+
+ def import_will_timeout_message(_ci_cd_only)
+ timeout = time_interval_in_words(Gitlab.config.gitlab_shell.git_timeout)
+ _('The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination.') % { timeout: timeout }
+ end
+
+ def import_svn_message(_ci_cd_only)
+ svn_link = link_to _('this document'), help_page_path('user/project/import/svn')
+ _('To import an SVN repository, check out %{svn_link}.').html_safe % { svn_link: svn_link }
+ end
+
+ def import_in_progress_title
+ if @project.forked?
+ _('Forking in progress')
+ else
+ _('Import in progress')
+ end
+ end
+
+ def import_wait_and_refresh_message
+ _('Please wait while we import the repository for you. Refresh at will.')
+ end
+
+ def import_github_title
+ _('Import repositories from GitHub')
+ end
+
+ def import_github_authorize_message
+ _('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:')
+ end
+
+ def import_github_personal_access_token_message
+ personal_access_token_link = link_to _('Personal Access Token'), 'https://github.com/settings/tokens'
+
+ if github_import_configured?
+ _('Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link }
+ else
+ _('To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link }
+ end
+ end
+
+ def import_configure_github_admin_message
+ github_integration_link = link_to 'GitHub integration', help_page_path('integration/github')
+
+ if current_user.admin?
+ _('Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
+ else
+ _('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
+ end
+ end
+
+ def import_githubish_choose_repository_message
+ _('Choose which repositories you want to import.')
+ end
- link_to path_with_namespace, url, target: '_blank', rel: 'noopener noreferrer'
+ def import_all_githubish_repositories_button_label
+ _('Import all repositories')
end
private
- def github_project_url(path_with_namespace)
- "#{github_root_url}/#{path_with_namespace}"
+ def github_project_url(full_path)
+ URI.join(github_root_url, full_path).to_s
end
def github_root_url
- return @github_url if defined?(@github_url)
+ strong_memoize(:github_url) do
+ provider = Gitlab::Auth::OAuth::Provider.config_for('github')
- provider = Gitlab.config.omniauth.providers.find { |p| p.name == 'github' }
- @github_url = provider.fetch('url', 'https://github.com') if provider
+ provider&.dig('url').presence || 'https://github.com'
+ end
end
- def gitea_project_url(path_with_namespace)
- "#{@gitea_host_url.sub(%r{/+\z}, '')}/#{path_with_namespace}"
+ def gitea_project_url(full_path)
+ URI.join(@gitea_host_url, full_path).to_s
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 2668cf78afe..06c3e569c84 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -99,7 +99,7 @@ module IssuablesHelper
project = Project.find_by(id: project_id)
if project
- project.name_with_namespace
+ project.full_name
else
default_label
end
@@ -159,16 +159,18 @@ module IssuablesHelper
label_names.join(', ')
end
- def issuables_state_counter_text(issuable_type, state)
+ def issuables_state_counter_text(issuable_type, state, display_count)
titles = {
opened: "Open"
}
state_title = titles[state] || state.to_s.humanize
- count = issuables_count_for_state(issuable_type, state)
-
html = content_tag(:span, state_title)
- html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
+
+ if display_count
+ count = issuables_count_for_state(issuable_type, state)
+ html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
+ end
html.html_safe
end
@@ -191,24 +193,10 @@ module IssuablesHelper
end
end
- def issuable_filter_params
- [
- :search,
- :author_id,
- :assignee_id,
- :milestone_title,
- :label_name
- ]
- end
-
def issuable_reference(issuable)
@show_full_reference ? issuable.to_reference(full: true) : issuable.to_reference(@group || @project)
end
- def issuable_filter_present?
- issuable_filter_params.any? { |k| params.key?(k) }
- end
-
def issuable_initial_data(issuable)
data = {
endpoint: issuable_path(issuable),
@@ -234,14 +222,14 @@ module IssuablesHelper
data.merge!(updated_at_by(issuable))
- data.to_json
+ data
end
def updated_at_by(issuable)
return {} unless issuable.edited?
{
- updatedAt: issuable.updated_at.to_time.iso8601,
+ updatedAt: issuable.last_edited_at.to_time.iso8601,
updatedBy: {
name: issuable.last_edited_by.name,
path: user_path(issuable.last_edited_by)
@@ -304,6 +292,12 @@ module IssuablesHelper
issuable.model_name.human.downcase
end
+ def selected_labels
+ Array(params[:label_name]).map do |label_name|
+ Label.new(title: label_name)
+ end
+ end
+
private
def sidebar_gutter_collapsed?
@@ -371,4 +365,11 @@ module IssuablesHelper
def parent
@project || @group
end
+
+ def issuable_milestone_tooltip_title(issuable)
+ if issuable.milestone
+ milestone_tooltip = milestone_tooltip_title(issuable.milestone)
+ _('Milestone') + (milestone_tooltip ? ': ' + milestone_tooltip : '')
+ end
+ end
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 0f110bd25c5..0f25d401406 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -47,32 +47,11 @@ module IssuesHelper
end
end
- def milestone_options(object)
- milestones = object.project.milestones.active.reorder(due_date: :asc, title: :asc).to_a
- milestones.unshift(object.milestone) if object.milestone.present? && object.milestone.closed?
- milestones.unshift(Milestone::None)
-
- options_from_collection_for_select(milestones, 'id', 'title', object.milestone_id)
- end
-
- def project_options(issuable, current_user, ability: :read_project)
- projects = current_user.authorized_projects.order_id_desc
- projects = projects.select do |project|
- current_user.can?(ability, project)
- end
-
- no_project = OpenStruct.new(id: 0, name_with_namespace: 'No project')
- projects.unshift(no_project)
- projects.delete(issuable.project)
-
- options_from_collection_for_select(projects, :id, :name_with_namespace)
- end
-
def status_box_class(item)
if item.try(:expired?)
'status-box-expired'
elsif item.try(:merged?)
- 'status-box-merged'
+ 'status-box-mr-merged'
elsif item.closed?
'status-box-mr-closed'
elsif item.try(:upcoming?)
diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb
index d5e77c7e271..cd4075b340d 100644
--- a/app/helpers/javascript_helper.rb
+++ b/app/helpers/javascript_helper.rb
@@ -2,9 +2,4 @@ module JavascriptHelper
def page_specific_javascript_tag(js)
javascript_include_tag asset_path(js)
end
-
- # deprecated; use webpack_bundle_tag directly instead
- def page_specific_javascript_bundle_tag(bundle)
- webpack_bundle_tag(bundle)
- end
end
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index c1c19062c91..c4a6a1e4bb3 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -1,4 +1,5 @@
module LabelsHelper
+ extend self
include ActionView::Helpers::TagHelper
def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil)
@@ -128,13 +129,17 @@ module LabelsHelper
end
end
- def labels_filter_path(only_group_labels = false)
+ def labels_filter_path(only_group_labels = false, include_ancestor_groups: true, include_descendant_groups: false)
project = @target_project || @project
+ options = {}
+ options[:include_ancestor_groups] = include_ancestor_groups if include_ancestor_groups
+ options[:include_descendant_groups] = include_descendant_groups if include_descendant_groups
+
if project
- project_labels_path(project, :json)
+ project_labels_path(project, :json, options)
elsif @group
- options = { only_group_labels: only_group_labels } if only_group_labels
+ options[:only_group_labels] = only_group_labels if only_group_labels
group_labels_path(@group, :json, options)
else
dashboard_labels_path(:json)
@@ -173,6 +178,39 @@ module LabelsHelper
end
end
+ def create_label_title(subject)
+ case subject
+ when Group
+ _('Create group label')
+ when Project
+ _('Create project label')
+ else
+ _('Create new label')
+ end
+ end
+
+ def manage_labels_title(subject)
+ case subject
+ when Group
+ _('Manage group labels')
+ when Project
+ _('Manage project labels')
+ else
+ _('Manage labels')
+ end
+ end
+
+ def view_labels_title(subject)
+ case subject
+ when Group
+ _('View group labels')
+ when Project
+ _('View project labels')
+ else
+ _('View labels')
+ end
+ end
+
# Required for Banzai::Filter::LabelReferenceFilter
module_function :render_colored_label, :text_color_for_bg, :escape_once
end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index f78d41a0448..2fe1927a189 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -203,6 +203,7 @@ module MarkupHelper
node.content = node.content.truncate(num_remaining)
truncated = true
end
+
content_length += node.content.length
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index ce57422f45d..fb4fe1c40b7 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -125,6 +125,19 @@ module MergeRequestsHelper
link_to(url[merge_request.project, merge_request], data: data_attrs, &block)
end
+ def allow_maintainer_push_unavailable_reason(merge_request)
+ return if merge_request.can_allow_maintainer_to_push?(current_user)
+
+ minimum_visibility = [merge_request.target_project.visibility_level,
+ merge_request.source_project.visibility_level].min
+
+ if minimum_visibility < Gitlab::VisibilityLevel::INTERNAL
+ _('Not available for private projects')
+ elsif ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch)
+ _('Not available for protected branches')
+ end
+ end
+
def merge_params_ee(merge_request)
{}
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index b78d3072186..9be93fa69ae 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -31,9 +31,9 @@ module NamespacesHelper
def namespace_icon(namespace, size = 40)
if namespace.is_a?(Group)
- group_icon(namespace)
+ group_icon_url(namespace)
else
- avatar_icon(namespace.owner.email, size)
+ avatar_icon_for_user(namespace.owner, size)
end
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 8ada746b244..56c88e6eab0 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -1,4 +1,12 @@
module NavHelper
+ def header_links
+ @header_links ||= get_header_links
+ end
+
+ def header_link?(link)
+ header_links.include?(link)
+ end
+
def page_with_sidebar_class
class_name = page_gutter_class
class_name << 'page-with-contextual-sidebar' if defined?(@left_sidebar) && @left_sidebar
@@ -12,6 +20,7 @@ module NavHelper
current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
current_path?('milestones#show')
+
if cookies[:collapsed_gutter] == 'true'
%w[page-gutter right-sidebar-collapsed]
else
@@ -37,4 +46,28 @@ module NavHelper
class_names
end
+
+ private
+
+ def get_header_links
+ links = if current_user
+ [:user_dropdown]
+ else
+ [:sign_in]
+ end
+
+ if can?(current_user, :read_cross_project)
+ links += [:issues, :merge_requests, :todos] if current_user.present?
+ end
+
+ if @project&.persisted? || can?(current_user, :read_cross_project)
+ links << :search
+ end
+
+ if session[:impersonator_id]
+ links << :admin_impersonation
+ end
+
+ links
+ end
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index c219aa3d6a9..27ed48fdbc7 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -11,7 +11,7 @@ module NotesHelper
end
def note_supports_quick_actions?(note)
- Notes::QuickActionsService.supported?(note, current_user)
+ Notes::QuickActionsService.supported?(note)
end
def noteable_json(noteable)
@@ -151,7 +151,38 @@ module NotesHelper
}
end
+ def discussions_path(issuable)
+ if issuable.is_a?(Issue)
+ discussions_project_issue_path(@project, issuable, format: :json)
+ else
+ discussions_project_merge_request_path(@project, issuable, format: :json)
+ end
+ end
+
+ def notes_data(issuable)
+ {
+ discussionsPath: discussions_path(issuable),
+ registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
+ newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
+ markdownDocsPath: help_page_path('user/markdown'),
+ quickActionsDocsPath: help_page_path('user/project/quick_actions'),
+ closePath: close_issuable_path(issuable),
+ reopenPath: reopen_issuable_path(issuable),
+ notesPath: notes_url,
+ totalNotes: issuable.discussions.length,
+ lastFetchedAt: Time.now.to_i
+ }.to_json
+ end
+
def discussion_resolved_intro(discussion)
discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
end
+
+ def has_vue_discussions_cookie?
+ cookies[:vue_mr_discussions] == 'true'
+ end
+
+ def serialize_notes?
+ has_vue_discussions_cookie? && !params['html']
+ end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 18b9bf214a3..a8397b03d63 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -39,7 +39,10 @@ module PageLayoutHelper
end
def favicon
- Rails.env.development? ? 'favicon-blue.ico' : 'favicon.ico'
+ return 'favicon-yellow.ico' if Gitlab::Utils.to_boolean(ENV['CANARY'])
+ return 'favicon-blue.ico' if Rails.env.development?
+
+ 'favicon.ico'
end
def page_image
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index aaee6eaeedd..fb523cb865b 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -9,12 +9,14 @@ module PreferencesHelper
# Maps `dashboard` values to more user-friendly option text
DASHBOARD_CHOICES = {
- projects: 'Your Projects (default)',
- stars: 'Starred Projects',
- project_activity: "Your Projects' Activity",
- starred_project_activity: "Starred Projects' Activity",
- groups: "Your Groups",
- todos: "Your Todos"
+ projects: _("Your Projects (default)"),
+ stars: _("Starred Projects"),
+ project_activity: _("Your Projects' Activity"),
+ starred_project_activity: _("Starred Projects' Activity"),
+ groups: _("Your Groups"),
+ todos: _("Your Todos"),
+ issues: _("Assigned Issues"),
+ merge_requests: _("Assigned Merge Requests")
}.with_indifferent_access.freeze
# Returns an Array usable by a select field for more user-friendly option text
@@ -48,30 +50,4 @@ module PreferencesHelper
def user_color_scheme
Gitlab::ColorSchemes.for_user(current_user).css_class
end
-
- def default_project_view
- return anonymous_project_view unless current_user
-
- user_view = current_user.project_view
-
- if can?(current_user, :download_code, @project)
- user_view
- elsif user_view == "activity"
- "activity"
- elsif can?(current_user, :read_wiki, @project)
- "wiki"
- elsif @project.feature_available?(:issues, current_user)
- "projects/issues/issues"
- else
- "customize_workflow"
- end
- end
-
- def anonymous_project_view
- if !@project.empty_repo? && can?(current_user, :download_code, @project)
- 'files'
- else
- 'activity'
- end
- end
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 5a4fda0724c..e7aa92e6e5c 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -3,7 +3,7 @@ module ProfilesHelper
user_synced_attributes_metadata = current_user.user_synced_attributes_metadata
if user_synced_attributes_metadata&.synced?(attribute)
if user_synced_attributes_metadata.provider
- Gitlab::OAuth::Provider.label_for(user_synced_attributes_metadata.provider)
+ Gitlab::Auth::OAuth::Provider.label_for(user_synced_attributes_metadata.provider)
else
'LDAP'
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 4a6b22b5ff6..15f48e43a28 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -1,6 +1,4 @@
module ProjectsHelper
- include Gitlab::CurrentSettings
-
def link_to_project(project)
link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
@@ -21,7 +19,7 @@ module ProjectsHelper
classes = %W[avatar avatar-inline s#{opts[:size]}]
classes << opts[:avatar_class] if opts[:avatar_class]
- avatar = avatar_icon(author, opts[:size])
+ avatar = avatar_icon_for_user(author, opts[:size])
src = opts[:lazy_load] ? nil : avatar
image_tag(src, width: opts[:size], class: classes, alt: '', "data-src" => avatar)
@@ -99,13 +97,13 @@ module ProjectsHelper
end
def remove_project_message(project)
- _("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 }
+ _("You are going to remove %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?") %
+ { project_full_name: project.full_name }
end
def transfer_project_message(project)
- _("You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?") %
- { project_name_with_namespace: project.name_with_namespace }
+ _("You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?") %
+ { project_full_name: project.full_name }
end
def remove_fork_project_message(project)
@@ -155,11 +153,6 @@ module ProjectsHelper
end
end
- def license_short_name(project)
- license = project.repository.license
- license&.nickname || license&.name || 'LICENSE'
- end
-
def last_push_event
current_user&.recent_push(@project)
end
@@ -214,7 +207,8 @@ module ProjectsHelper
project.cache_key,
controller.controller_name,
controller.action_name,
- current_application_settings.cache_key,
+ Gitlab::CurrentSettings.cache_key,
+ "cross-project:#{can?(current_user, :read_cross_project)}",
'v2.5'
]
@@ -267,6 +261,17 @@ module ProjectsHelper
!!(params[:personal] || params[:name] || any_projects?(projects))
end
+ def push_to_create_project_command(user = current_user)
+ repository_url =
+ if Gitlab::CurrentSettings.current_application_settings.enabled_git_access_protocol == 'http'
+ user_url(user)
+ else
+ Gitlab.config.gitlab_shell.ssh_path_prefix + user.username
+ end
+
+ "git push --set-upstream #{repository_url}/$(git rev-parse --show-toplevel | xargs basename).git $(git rev-parse --abbrev-ref HEAD)"
+ end
+
private
def repo_children_classes(field)
@@ -298,6 +303,10 @@ module ProjectsHelper
nav_tabs << :pipelines
end
+ if project.external_issue_tracker
+ nav_tabs << :external_issue_tracker
+ end
+
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
nav_tabs << tab
@@ -388,55 +397,6 @@ module ProjectsHelper
end
end
- def add_special_file_path(project, file_name:, commit_message: nil, branch_name: nil, context: nil)
- commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name.downcase }
- project_new_blob_path(
- project,
- project.default_branch || 'master',
- file_name: file_name,
- commit_message: commit_message,
- branch_name: branch_name,
- context: context
- )
- end
-
- def add_koding_stack_path(project)
- project_new_blob_path(
- project,
- project.default_branch || 'master',
- file_name: '.koding.yml',
- commit_message: "Add Koding stack script",
- content: <<-CONTENT.strip_heredoc
- provider:
- aws:
- access_key: '${var.aws_access_key}'
- secret_key: '${var.aws_secret_key}'
- resource:
- aws_instance:
- #{project.path}-vm:
- instance_type: t2.nano
- user_data: |-
-
- # Created by GitLab UI for :>
-
- echo _KD_NOTIFY_@Installing Base packages...@
-
- apt-get update -y
- apt-get install git -y
-
- echo _KD_NOTIFY_@Cloning #{project.name}...@
-
- export KODING_USER=${var.koding_user_username}
- export REPO_URL=#{root_url}${var.koding_queryString_repo}.git
- export BRANCH=${var.koding_queryString_branch}
-
- sudo -i -u $KODING_USER git clone $REPO_URL -b $BRANCH
-
- echo _KD_NOTIFY_@#{project.name} cloned.@
- CONTENT
- )
- end
-
def koding_project_url(project = nil, branch = nil, sha = nil)
if project
import_path = "/Home/Stacks/import"
@@ -447,40 +407,10 @@ module ProjectsHelper
path = "#{import_path}?repo=#{repo}&branch=#{branch}&sha=#{sha}"
- return URI.join(current_application_settings.koding_url, path).to_s
- end
-
- current_application_settings.koding_url
- end
-
- def contribution_guide_path(project)
- if project && contribution_guide = project.repository.contribution_guide
- project_blob_path(
- project,
- tree_join(project.default_branch,
- contribution_guide.name)
- )
+ return URI.join(Gitlab::CurrentSettings.koding_url, path).to_s
end
- end
-
- def readme_path(project)
- filename_path(project, :readme)
- end
-
- def changelog_path(project)
- filename_path(project, :changelog)
- end
- def license_path(project)
- filename_path(project, :license_blob)
- end
-
- def version_path(project)
- filename_path(project, :version)
- end
-
- def ci_configuration_path(project)
- filename_path(project, :gitlab_ci_yml)
+ Gitlab::CurrentSettings.koding_url
end
def project_wiki_path_with_version(proj, page, version, is_newest)
@@ -508,15 +438,6 @@ module ProjectsHelper
@ref || @repository.try(:root_ref)
end
- def filename_path(project, filename)
- if project && blob = project.repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend
- project_blob_path(
- project,
- tree_join(project.default_branch, blob.name)
- )
- end
- end
-
def sanitize_repo_path(project, message)
return '' unless message.present?
@@ -559,7 +480,7 @@ module ProjectsHelper
def restricted_levels
return [] if current_user.admin?
- current_application_settings.restricted_visibility_levels || []
+ Gitlab::CurrentSettings.restricted_visibility_levels || []
end
def project_permissions_settings(project)
@@ -606,4 +527,26 @@ module ProjectsHelper
project_find_file_path(@project, ref)
end
+
+ def can_show_last_commit_in_list?(project)
+ can?(current_user, :read_cross_project) && project.commit
+ end
+
+ def pages_https_only_disabled?
+ !@project.pages_domains.all?(&:https?)
+ end
+
+ def pages_https_only_title
+ return unless pages_https_only_disabled?
+
+ "You must enable HTTPS for all your domains first"
+ end
+
+ def pages_https_only_label_class
+ if pages_https_only_disabled?
+ "list-label disabled"
+ else
+ "list-label"
+ end
+ end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 0f9ac958f95..761c1252fc8 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -110,7 +110,7 @@ module SearchHelper
category: "Projects",
id: p.id,
value: "#{search_result_sanitize(p.name)}",
- label: "#{search_result_sanitize(p.name_with_namespace)}",
+ label: "#{search_result_sanitize(p.full_name)}",
url: project_path(p)
}
end
@@ -170,4 +170,8 @@ module SearchHelper
# Truncato's filtered_tags and filtered_attributes are not quite the same
sanitize(html, tags: %w(a p ol ul li pre code))
end
+
+ def limited_count(count, limit = 1000)
+ count > limit ? "#{limit}+" : count
+ end
end
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index 1a4f1431bdc..6cefcde558a 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -73,7 +73,6 @@ module SelectsHelper
email_user: opts[:email_user] || false,
first_user: opts[:first_user] && current_user ? current_user.username : false,
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
}
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 3707bb5ba36..f872990122e 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -7,9 +7,11 @@ module ServicesHelper
"Event will be triggered when a new tag is pushed to the repository"
when "note", "note_events"
"Event will be triggered when someone adds a comment"
+ when "confidential_note", "confidential_note_events"
+ "Event will be triggered when someone adds a comment on a confidential issue"
when "issue", "issue_events"
"Event will be triggered when an issue is created/updated/closed"
- when "confidential_issue", "confidential_issue_events"
+ when "confidential_issue", "confidential_issues_events"
"Event will be triggered when a confidential issue is created/updated/closed"
when "merge_request", "merge_request_events"
"Event will be triggered when a merge request is created/updated/merged"
@@ -27,5 +29,16 @@ module ServicesHelper
"#{event}_events"
end
+ def service_save_button(service)
+ button_tag(class: 'btn btn-save', type: 'submit', disabled: service.deprecated?) do
+ icon('spinner spin', class: 'hidden js-btn-spinner') +
+ content_tag(:span, 'Save changes', class: 'js-btn-label')
+ end
+ end
+
+ def disable_fields_service?(service)
+ !current_controller?("admin/services") && service.deprecated?
+ end
+
extend self
end
diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb
index 55f4da0ef85..50aeb7f4b82 100644
--- a/app/helpers/sidekiq_helper.rb
+++ b/app/helpers/sidekiq_helper.rb
@@ -1,12 +1,12 @@
module SidekiqHelper
- SIDEKIQ_PS_REGEXP = /\A
+ SIDEKIQ_PS_REGEXP = %r{\A
(?<pid>\d+)\s+
(?<cpu>[\d\.,]+)\s+
(?<mem>[\d\.,]+)\s+
- (?<state>[DIEKNRSTVWXZNLpsl\+<>\/\d]+)\s+
+ (?<state>[DIEKNRSTVWXZNLpsl\+<>/\d]+)\s+
(?<start>.+?)\s+
(?<command>(?:ruby\d+:\s+)?sidekiq.*\].*)
- \z/x
+ \z}x
def parse_sidekiq_ps(line)
match = line.strip.match(SIDEKIQ_PS_REGEXP)
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index b447d4952e7..00e7e4230b9 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -89,6 +89,7 @@ module SnippetsHelper
snippet_chunk = [lined_content[line_number]]
snippet_start_line = line_number
end
+
last_line = line_number
end
# Add final chunk to chunk array
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index 40d69e30188..9151543dfdc 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -11,7 +11,7 @@ module SubmoduleHelper
url = File.join(Gitlab.config.gitlab.url, @project.full_path)
end
- if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
+ if url =~ %r{([^/:]+)/([^/]+(?:\.git)?)\Z}
namespace, project = $1, $2
gitlab_hosts = [Gitlab.config.gitlab.url,
Gitlab.config.gitlab_shell.ssh_path_prefix]
@@ -23,7 +23,7 @@ module SubmoduleHelper
end
end
- namespace.sub!(/\A\//, '')
+ namespace.sub!(%r{\A/}, '')
project.rstrip!
project.sub!(/\.git\z/, '')
@@ -47,24 +47,25 @@ module SubmoduleHelper
protected
def github_dot_com_url?(url)
- url =~ /github\.com[\/:][^\/]+\/[^\/]+\Z/
+ url =~ %r{github\.com[/:][^/]+/[^/]+\Z}
end
def gitlab_dot_com_url?(url)
- url =~ /gitlab\.com[\/:][^\/]+\/[^\/]+\Z/
+ url =~ %r{gitlab\.com[/:][^/]+/[^/]+\Z}
end
def self_url?(url, namespace, project)
url_no_dotgit = url.chomp('.git')
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.new.url_to_repo([namespace, '/', project].join(''))
end
def relative_self_url?(url)
# (./)?(../repo.git) || (./)?(../../project/repo.git) )
- url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*(\.git)?\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*(\.git)?\z/
+ url =~ %r{\A((\./)?(\.\./))(?!(\.\.)|(.*/)).*(\.git)?\z} || url =~ %r{\A((\./)?(\.\./){2})(?!(\.\.))([^/]*)/(?!(\.\.)|(.*/)).*(\.git)?\z}
end
def standard_links(host, namespace, project, commit)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 2a7aa299e83..f7620e0b6b8 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -30,6 +30,7 @@ module TodosHelper
else
todo.target_reference
end
+
link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title
end
@@ -53,8 +54,16 @@ module TodosHelper
def todo_target_state_pill(todo)
return unless show_todo_state?(todo)
+ type =
+ case todo.target
+ when MergeRequest
+ 'mr'
+ when Issue
+ 'issue'
+ end
+
content_tag(:span, nil, class: 'target-status') do
- content_tag(:span, nil, class: "status-box status-box-#{todo.target.state.dasherize}") do
+ content_tag(:span, nil, class: "status-box status-box-#{type}-#{todo.target.state.dasherize}") do
todo.target.state.capitalize
end
end
@@ -105,7 +114,7 @@ module TodosHelper
projects = current_user.authorized_projects.sorted_by_activity.non_archived.with_route
projects = projects.map do |project|
- { id: project.id, text: project.name_with_namespace }
+ { id: project.id, text: project.full_name }
end
projects.unshift({ id: '', text: 'Any Project' }).to_json
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 5b2ea38a03d..5e7c20ef51e 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -49,13 +49,13 @@ module TreeHelper
return false unless on_top_of_branch?(project, ref)
- can_collaborate_with_project?(project)
+ can_collaborate_with_project?(project, ref: ref)
end
def tree_edit_branch(project = @project, ref = @ref)
return unless can_edit_tree?(project, ref)
- if can_push_branch?(project, ref)
+ if user_access(project).can_push_to_branch?(ref)
ref
else
project = tree_edit_project(project)
@@ -81,8 +81,21 @@ module TreeHelper
" A fork of this project has been created that you can make changes in, so you can submit a merge request."
end
+ def edit_in_new_fork_notice_action(action)
+ edit_in_new_fork_notice + " Try to #{action} this file again."
+ end
+
def commit_in_fork_help
- "A new branch will be created in your fork and a new merge request will be started."
+ _("A new branch will be created in your fork and a new merge request will be started.")
+ end
+
+ def commit_in_single_accessible_branch
+ branch_name = html_escape(selected_branch)
+
+ message = _("Your changes can be committed to %{branch_name} because a merge "\
+ "request is open.") % { branch_name: "<strong>#{branch_name}</strong>" }
+
+ message.html_safe
end
def path_breadcrumbs(max_links = 6)
@@ -110,7 +123,7 @@ module TreeHelper
# returns the relative path of the first subdir that doesn't have only one directory descendant
def flatten_tree(root_path, tree)
- return tree.flat_path.sub(/\A#{root_path}\//, '') if tree.flat_path.present?
+ return tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') if tree.flat_path.present?
subtree = Gitlab::Git::Tree.where(@repository, @commit.id, tree.path)
if subtree.count == 1 && subtree.first.dir?
@@ -119,4 +132,8 @@ module TreeHelper
return tree.name
end
end
+
+ def selected_branch
+ @branch_name || tree_edit_branch
+ end
end
diff --git a/app/helpers/u2f_helper.rb b/app/helpers/u2f_helper.rb
deleted file mode 100644
index 81bfe5d4eeb..00000000000
--- a/app/helpers/u2f_helper.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-module U2fHelper
- def inject_u2f_api?
- ((browser.chrome? && browser.version.to_i >= 41) || (browser.opera? && browser.version.to_i >= 40)) && !browser.device.mobile?
- end
-end
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
new file mode 100644
index 00000000000..36abfaf19a5
--- /dev/null
+++ b/app/helpers/user_callouts_helper.rb
@@ -0,0 +1,14 @@
+module UserCalloutsHelper
+ GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'.freeze
+
+ def show_gke_cluster_integration_callout?(project)
+ can?(current_user, :create_cluster, project) &&
+ !user_dismissed?(GKE_CLUSTER_INTEGRATION)
+ end
+
+ private
+
+ def user_dismissed?(feature_name)
+ current_user&.callouts&.find_by(feature_name: UserCallout.feature_names[feature_name])
+ end
+end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index b5f54d3e154..01af68088df 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -14,4 +14,18 @@ module UsersHelper
content_tag(:strong) { user.unconfirmed_email } + h('.') +
content_tag(:p) { confirmation_link }
end
+
+ def profile_tabs
+ @profile_tabs ||= get_profile_tabs
+ end
+
+ def profile_tab?(tab)
+ profile_tabs.include?(tab)
+ end
+
+ private
+
+ def get_profile_tabs
+ [:activity, :groups, :contributed, :projects, :snippets]
+ end
end
diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb
index 456598b4c28..c20753ece72 100644
--- a/app/helpers/version_check_helper.rb
+++ b/app/helpers/version_check_helper.rb
@@ -1,6 +1,6 @@
module VersionCheckHelper
def version_status_badge
- if Rails.env.production? && current_application_settings.version_check_enabled
+ if Rails.env.production? && Gitlab::CurrentSettings.version_check_enabled
image_url = VersionCheck.new.url
image_tag image_url, class: 'js-version-status-badge'
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index c3d5628f241..e395cda03d3 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -151,12 +151,12 @@ module VisibilityLevelHelper
def restricted_visibility_levels(show_all = false)
return [] if current_user.admin? && !show_all
- current_application_settings.restricted_visibility_levels || []
+ Gitlab::CurrentSettings.restricted_visibility_levels || []
end
delegate :default_project_visibility,
:default_group_visibility,
- to: :current_application_settings
+ to: :'Gitlab::CurrentSettings.current_application_settings'
def disallowed_visibility_level?(form_model, level)
return false unless form_model.respond_to?(:visibility_level_allowed?)
diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb
index 33453dd178f..8bcced70d63 100644
--- a/app/helpers/webpack_helper.rb
+++ b/app/helpers/webpack_helper.rb
@@ -1,12 +1,37 @@
require 'webpack/rails/manifest'
module WebpackHelper
- def webpack_bundle_tag(bundle)
- javascript_include_tag(*gitlab_webpack_asset_paths(bundle))
+ def webpack_bundle_tag(bundle, force_same_domain: false)
+ javascript_include_tag(*gitlab_webpack_asset_paths(bundle, force_same_domain: force_same_domain))
+ end
+
+ def webpack_controller_bundle_tags
+ bundles = []
+
+ action = case controller.action_name
+ when 'create' then 'new'
+ when 'update' then 'edit'
+ else controller.action_name
+ end
+
+ route = [*controller.controller_path.split('/'), action].compact
+
+ until route.empty?
+ begin
+ asset_paths = gitlab_webpack_asset_paths("pages.#{route.join('.')}", extension: 'js')
+ bundles.unshift(*asset_paths)
+ rescue Webpack::Rails::Manifest::EntryPointMissingError
+ # no bundle exists for this path
+ end
+
+ route.pop
+ end
+
+ javascript_include_tag(*bundles)
end
# override webpack-rails gem helper until changes can make it upstream
- def gitlab_webpack_asset_paths(source, extension: nil)
+ def gitlab_webpack_asset_paths(source, extension: nil, force_same_domain: false)
return "" unless source.present?
paths = Webpack::Rails::Manifest.asset_paths(source)
@@ -14,9 +39,11 @@ module WebpackHelper
paths.select! { |p| p.ends_with? ".#{extension}" }
end
- force_host = webpack_public_host
- if force_host
- paths.map! { |p| "#{force_host}#{p}" }
+ unless force_same_domain
+ force_host = webpack_public_host
+ if force_host
+ paths.map! { |p| "#{force_host}#{p}" }
+ end
end
paths
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 815fab9e061..41f9eedd4bd 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -21,4 +21,22 @@ module WikiHelper
add_to_breadcrumb_dropdown link_to(WikiPage.unhyphenize(dir_or_page).capitalize, project_wiki_path(@project, current_slug)), location: :after
end
end
+
+ def wiki_page_errors(error)
+ return unless error
+
+ content_tag(:div, class: 'alert alert-danger') do
+ case error
+ when WikiPage::PageChangedError
+ page_link = link_to s_("WikiPageConflictMessage|the page"), project_wiki_path(@project, @page), target: "_blank"
+ concat(
+ (s_("WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs.") % { page_link: page_link }).html_safe
+ )
+ when WikiPage::PageRenameError
+ s_("WikiEdit|There is already a page with the same title in that path.")
+ else
+ error.message
+ end
+ end
+ end
end
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index 88f374be1e5..9f78b80c71d 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -24,8 +24,8 @@ module WorkhorseHelper
end
# Archive a Git repository and send it through Workhorse
- def send_git_archive(repository, ref:, format:)
- headers.store(*Gitlab::Workhorse.send_git_archive(repository, ref: ref, format: format))
+ def send_git_archive(repository, **kwargs)
+ headers.store(*Gitlab::Workhorse.send_git_archive(repository, **kwargs))
head :ok
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index d0ce827a595..fe5f68ba3d5 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -1,13 +1,11 @@
class AbuseReportMailer < BaseMailer
- include Gitlab::CurrentSettings
-
def notify(abuse_report_id)
return unless deliverable?
@abuse_report = AbuseReport.find(abuse_report_id)
mail(
- to: current_application_settings.admin_notification_email,
+ to: Gitlab::CurrentSettings.admin_notification_email,
subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse"
)
end
@@ -15,6 +13,6 @@ class AbuseReportMailer < BaseMailer
private
def deliverable?
- current_application_settings.admin_notification_email.present?
+ Gitlab::CurrentSettings.admin_notification_email.present?
end
end
diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb
index 8e99db444d6..654468bc7fe 100644
--- a/app/mailers/base_mailer.rb
+++ b/app/mailers/base_mailer.rb
@@ -1,13 +1,11 @@
class BaseMailer < ActionMailer::Base
- include Gitlab::CurrentSettings
-
around_action :render_with_default_locale
helper ApplicationHelper
helper MarkupHelper
attr_accessor :current_user
- helper_method :current_user, :can?, :current_application_settings
+ helper_method :current_user, :can?
default from: proc { default_sender_address.format }
default reply_to: proc { default_reply_to_address.format }
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 64ca2d2eacf..b33131becd3 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -1,54 +1,54 @@
module Emails
module Issues
- def new_issue_email(recipient_id, issue_id)
+ def new_issue_email(recipient_id, issue_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id))
+ mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason))
end
- def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id)
+ def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id)
+ def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_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))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def closed_issue_email(recipient_id, issue_id, updated_by_user_id)
+ def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id)
+ def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@label_names = label_names
@labels_url = project_labels_url(@project)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id)
+ def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@issue_status = status
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
- def issue_moved_email(recipient, issue, new_issue, updated_by_user)
+ def issue_moved_email(recipient, issue, new_issue, updated_by_user, reason = nil)
setup_issue_mail(issue.id, recipient.id)
@new_issue = new_issue
@new_project = new_issue.project
- mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id))
+ mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
end
private
@@ -61,11 +61,12 @@ module Emails
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
- def issue_thread_options(sender_id, recipient_id)
+ def issue_thread_options(sender_id, recipient_id, reason)
{
from: sender(sender_id),
to: recipient(recipient_id),
- subject: subject("#{@issue.title} (##{@issue.iid})")
+ subject: subject("#{@issue.title} (##{@issue.iid})"),
+ 'X-GitLab-NotificationReason' => reason
}
end
end
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index d76c61c369f..75cf56a51f2 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -7,18 +7,11 @@ module Emails
helper_method :member_source, :member
end
- def member_access_requested_email(member_source_type, member_id)
+ def member_access_requested_email(member_source_type, member_id, recipient_notification_email)
@member_source_type = member_source_type
@member_id = member_id
- 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.pluck(:notification_email)
- end
-
- mail(to: admins,
+ mail(to: recipient_notification_email,
subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}"))
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 3626f8ce416..b3f2aeb08ca 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -1,57 +1,66 @@
module Emails
module MergeRequests
- def new_merge_request_email(recipient_id, merge_request_id)
+ def new_merge_request_email(recipient_id, merge_request_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id))
+ mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason))
end
- def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
+ def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id)
+ def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], existing_commits: [])
+ setup_merge_request_mail(merge_request_id, recipient_id)
+ @new_commits = new_commits
+ @existing_commits = existing_commits
+ @updated_by_user = User.find(updated_by_user_id)
+
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ end
+
+ def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id)
+ def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@label_names = label_names
@labels_url = project_labels_url(@project)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
+ def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
+ def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id)
+ def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@mr_status = status
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id)
+ def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@resolved_by = User.find(resolved_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id))
+ mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id, reason))
end
private
@@ -64,11 +73,12 @@ module Emails
@sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
end
- def merge_request_thread_options(sender_id, recipient_id)
+ def merge_request_thread_options(sender_id, recipient_id, reason = nil)
{
from: sender(sender_id),
to: recipient(recipient_id),
- subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})")
+ subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"),
+ 'X-GitLab-NotificationReason' => reason
}
end
end
diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb
new file mode 100644
index 00000000000..0027dfdc36b
--- /dev/null
+++ b/app/mailers/emails/pages_domains.rb
@@ -0,0 +1,43 @@
+module Emails
+ module PagesDomains
+ def pages_domain_enabled_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled")
+ )
+ end
+
+ def pages_domain_disabled_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled")
+ )
+ end
+
+ def pages_domain_verification_succeeded_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'")
+ )
+ end
+
+ def pages_domain_verification_failed_email(domain, recipient)
+ @domain = domain
+ @project = domain.project
+
+ mail(
+ to: recipient.notification_email,
+ subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'")
+ )
+ end
+ end
+end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index ec886e993c3..e4212775956 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -5,6 +5,7 @@ class Notify < BaseMailer
include Emails::Issues
include Emails::MergeRequests
include Emails::Notes
+ include Emails::PagesDomains
include Emails::Projects
include Emails::Profile
include Emails::Pipelines
@@ -112,9 +113,11 @@ class Notify < BaseMailer
headers["X-GitLab-#{model.class.name}-ID"] = model.id
headers['X-GitLab-Reply-Key'] = reply_key
+ @reason = headers['X-GitLab-NotificationReason']
+
if Gitlab::IncomingEmail.enabled? && @sent_notification
address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key))
- address.display_name = @project.name_with_namespace
+ address.display_name = @project.full_name
headers['Reply-To'] = address
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 0b6bcbde5d9..6dae49f38dc 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -22,12 +22,30 @@ 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)
+ # filters - A hash of abilities and filters to apply if the user lacks this
+ # ability
+ def issues_readable_by_user(issues, user = nil, filters: {})
+ issues = apply_filters_if_needed(issues, user, filters)
+
DeclarativePolicy.user_scope do
issues.select { |issue| issue.visible_to_user?(user) }
end
end
+ # Returns an Array of MergeRequests that can be read by the given user.
+ #
+ # merge_requests - MRs out of which to collect mr's readable by the user.
+ # user - The User for which to check the merge_requests
+ # filters - A hash of abilities and filters to apply if the user lacks this
+ # ability
+ def merge_requests_readable_by_user(merge_requests, user = nil, filters: {})
+ merge_requests = apply_filters_if_needed(merge_requests, user, filters)
+
+ DeclarativePolicy.user_scope do
+ merge_requests.select { |mr| allowed?(user, :read_merge_request, mr) }
+ end
+ end
+
def can_edit_note?(user, note)
allowed?(user, :edit_note, note)
end
@@ -53,5 +71,15 @@ class Ability
cache = RequestStore.active? ? RequestStore : {}
DeclarativePolicy.policy_for(user, subject, cache: cache)
end
+
+ private
+
+ def apply_filters_if_needed(elements, user, filters)
+ filters.each do |ability, filter|
+ elements = filter.call(elements) unless allowed?(user, ability)
+ end
+
+ elements
+ end
end
end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index 76cfe28742a..fb66dd0b766 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -1,5 +1,7 @@
class Appearance < ActiveRecord::Base
include CacheMarkdownField
+ include AfterCommitQueue
+ include ObjectStorage::BackgroundMove
cache_markdown_field :description
cache_markdown_field :new_project_guidelines
@@ -11,9 +13,10 @@ class Appearance < ActiveRecord::Base
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
+
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- CACHE_KEY = 'current_appearance'.freeze
+ CACHE_KEY = "current_appearance:#{Gitlab::VERSION}".freeze
after_commit :flush_redis_cache
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 253e213af81..862933bf127 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -117,6 +117,11 @@ class ApplicationSetting < ActiveRecord::Base
validates :repository_storages, presence: true
validate :check_repository_storages
+ validates :auto_devops_domain,
+ allow_blank: true,
+ hostname: { allow_numeric_hostname: true, require_valid_tld: true },
+ if: :auto_devops_enabled?
+
validates :enabled_git_access_protocol,
inclusion: { in: %w(ssh http), allow_blank: true, allow_nil: true }
@@ -261,6 +266,7 @@ class ApplicationSetting < ActiveRecord::Base
{
after_sign_up_text: nil,
akismet_enabled: false,
+ authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
container_registry_token_expire_delay: 5,
default_artifacts_expire_in: '30 days',
default_branch_protection: Settings.gitlab['default_branch_protection'],
@@ -324,7 +330,8 @@ class ApplicationSetting < ActiveRecord::Base
usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
gitaly_timeout_fast: 10,
gitaly_timeout_medium: 30,
- gitaly_timeout_default: 55
+ gitaly_timeout_default: 55,
+ allow_local_requests_from_hooks_and_services: false
}
end
@@ -341,15 +348,15 @@ class ApplicationSetting < ActiveRecord::Base
end
def home_page_url_column_exists?
- ActiveRecord::Base.connection.column_exists?(:application_settings, :home_page_url)
+ ::Gitlab::Database.cached_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)
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url)
end
def sidekiq_throttling_column_exists?
- ActiveRecord::Base.connection.column_exists?(:application_settings, :sidekiq_throttling_enabled)
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :sidekiq_throttling_enabled)
end
def domain_whitelist_raw
@@ -417,6 +424,7 @@ class ApplicationSetting < ActiveRecord::Base
super(group_full_path)
Gitlab::PerformanceBar.expire_allowed_user_ids_cache
end
+
return
end
diff --git a/app/models/badge.rb b/app/models/badge.rb
new file mode 100644
index 00000000000..f7e10c2ebfc
--- /dev/null
+++ b/app/models/badge.rb
@@ -0,0 +1,51 @@
+class Badge < ActiveRecord::Base
+ # This structure sets the placeholders that the urls
+ # can have. This hash also sets which action to ask when
+ # the placeholder is found.
+ PLACEHOLDERS = {
+ 'project_path' => :full_path,
+ 'project_id' => :id,
+ 'default_branch' => :default_branch,
+ 'commit_sha' => ->(project) { project.commit&.sha }
+ }.freeze
+
+ # This regex is built dynamically using the keys from the PLACEHOLDER struct.
+ # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
+ # This regex will build the new PLACEHOLDER_REGEX with the new information
+ PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze
+
+ default_scope { order_created_at_asc }
+
+ scope :order_created_at_asc, -> { reorder(created_at: :asc) }
+
+ validates :link_url, :image_url, url_placeholder: { protocols: %w(http https), placeholder_regex: PLACEHOLDERS_REGEX }
+ validates :type, presence: true
+
+ def rendered_link_url(project = nil)
+ build_rendered_url(link_url, project)
+ end
+
+ def rendered_image_url(project = nil)
+ build_rendered_url(image_url, project)
+ end
+
+ private
+
+ def build_rendered_url(url, project = nil)
+ return url unless valid? && project
+
+ Gitlab::StringPlaceholderReplacer.replace_string_placeholders(url, PLACEHOLDERS_REGEX) do |arg|
+ replace_placeholder_action(PLACEHOLDERS[arg], project)
+ end
+ end
+
+ # The action param represents the :symbol or Proc to call in order
+ # to retrieve the return value from the project.
+ # This method checks if it is a Proc and use the call method, and if it is
+ # a symbol just send the action
+ def replace_placeholder_action(action, project)
+ return unless project
+
+ action.is_a?(Proc) ? action.call(project) : project.public_send(action) # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/models/badges/group_badge.rb b/app/models/badges/group_badge.rb
new file mode 100644
index 00000000000..f4b2bdecdcc
--- /dev/null
+++ b/app/models/badges/group_badge.rb
@@ -0,0 +1,5 @@
+class GroupBadge < Badge
+ belongs_to :group
+
+ validates :group, presence: true
+end
diff --git a/app/models/badges/project_badge.rb b/app/models/badges/project_badge.rb
new file mode 100644
index 00000000000..3945b376052
--- /dev/null
+++ b/app/models/badges/project_badge.rb
@@ -0,0 +1,15 @@
+class ProjectBadge < Badge
+ belongs_to :project
+
+ validates :project, presence: true
+
+ def rendered_link_url(project = nil)
+ project ||= self.project
+ super
+ end
+
+ def rendered_image_url(project = nil)
+ project ||= self.project
+ super
+ end
+end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 19ad110db58..71c974b4c09 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -160,7 +160,7 @@ class Blob < SimpleDelegator
if stored_externally?
if rich_viewer
rich_viewer.binary?
- elsif Linguist::Language.find_by_filename(name).any?
+ elsif Linguist::Language.find_by_extension(name).any?
false
elsif _mime_type
_mime_type.binary?
diff --git a/app/models/board.rb b/app/models/board.rb
index 5bb7d3d3722..3cede6fc99a 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,20 +1,22 @@
class Board < ActiveRecord::Base
+ belongs_to :group
belongs_to :project
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
validates :project, presence: true, if: :project_needed?
+ validates :group, presence: true, unless: :project
def project_needed?
- true
+ !group
end
def parent
- project
+ @parent ||= group || project
end
def group_board?
- false
+ group_id.present?
end
def backlog_list
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index f321db75eeb..fbd0f123341 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -1,4 +1,6 @@
class ChatName < ActiveRecord::Base
+ LAST_USED_AT_INTERVAL = 1.hour
+
belongs_to :service
belongs_to :user
@@ -9,4 +11,23 @@ class ChatName < ActiveRecord::Base
validates :user_id, uniqueness: { scope: [:service_id] }
validates :chat_id, uniqueness: { scope: [:service_id, :team_id] }
+
+ # Updates the "last_used_timestamp" but only if it wasn't already updated
+ # recently.
+ #
+ # The throttling this method uses is put in place to ensure that high chat
+ # traffic doesn't result in many UPDATE queries being performed.
+ def update_last_used_at
+ return unless update_last_used_at?
+
+ obtained = Gitlab::ExclusiveLease
+ .new("chat_name/last_used_at/#{id}", timeout: LAST_USED_AT_INTERVAL.to_i)
+ .try_obtain
+
+ touch(:last_used_at) if obtained
+ end
+
+ def update_last_used_at?
+ last_used_at.nil? || last_used_at > LAST_USED_AT_INTERVAL.ago
+ end
end
diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb
index ec56cc53aea..760f01f225b 100644
--- a/app/models/ci/artifact_blob.rb
+++ b/app/models/ci/artifact_blob.rb
@@ -36,16 +36,15 @@ module Ci
def external_url(project, job)
return unless external_link?(job)
- full_path_parts = project.full_path_components
- top_level_group = full_path_parts.shift
+ url_project_path = project.full_path.partition('/').last
artifact_path = [
- '-', *full_path_parts, '-',
+ '-', url_project_path, '-',
'jobs', job.id,
'artifacts', path
].join('/')
- "#{pages_config.protocol}://#{top_level_group}.#{pages_config.host}/#{artifact_path}"
+ "#{project.pages_group_url}/#{artifact_path}"
end
def external_link?(job)
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 83fe23606d1..4aa65bf4273 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -3,11 +3,14 @@ module Ci
prepend ArtifactMigratable
include TokenAuthenticatable
include AfterCommitQueue
+ include ObjectStorage::BackgroundMove
include Presentable
include Importable
+ include Gitlab::Utils::StrongMemoize
MissingDependenciesError = Class.new(StandardError)
+ belongs_to :project, inverse_of: :builds
belongs_to :runner
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
@@ -20,13 +23,20 @@ module Ci
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :job_artifacts_archive, -> { where(file_type: Ci::JobArtifact.file_types[:archive]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
has_one :job_artifacts_metadata, -> { where(file_type: Ci::JobArtifact.file_types[:metadata]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
+ has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
- # The "environment" field for builds is a String, and is the unexpanded name
+ has_one :metadata, class_name: 'Ci::BuildMetadata'
+ delegate :timeout, to: :metadata, prefix: true, allow_nil: true
+
+ ##
+ # The "environment" field for builds is a String, and is the unexpanded name!
+ #
def persisted_environment
- @persisted_environment ||= Environment.find_by(
- name: expanded_environment_name,
- project: project
- )
+ return unless has_environment?
+
+ strong_memoize(:persisted_environment) do
+ Environment.find_by(name: expanded_environment_name, project: project)
+ end
end
serialize :options # rubocop:disable Cop/ActiveRecordSerialize
@@ -39,12 +49,13 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
- scope :with_artifacts, ->() do
+ scope :with_artifacts_archive, ->() do
where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)',
- '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id'))
+ '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
end
- 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 :with_artifacts_stored_locally, -> { with_artifacts_archive.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) }
+ scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
+ scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) }
scope :ref_protected, -> { where(protected: true) }
@@ -79,7 +90,8 @@ module Ci
before_save :ensure_token
before_destroy { unscoped_project }
- after_create do |build|
+ before_create :ensure_metadata
+ after_create unless: :importing? do |build|
run_after_commit { BuildHooksWorker.perform_async(build.id) }
end
@@ -138,13 +150,25 @@ module Ci
next if build.retries_max.zero?
if build.retries_count < build.retries_max
- Ci::Build.retry(build, build.user)
+ begin
+ Ci::Build.retry(build, build.user)
+ rescue Gitlab::Access::AccessDeniedError => ex
+ Rails.logger.error "Unable to auto-retry job #{build.id}: #{ex}"
+ end
end
end
before_transition any => [:running] do |build|
build.validates_dependencies! unless Feature.enabled?('ci_disable_validates_dependencies')
end
+
+ before_transition pending: :running do |build|
+ build.ensure_metadata.update_timeout_state
+ end
+ end
+
+ def ensure_metadata
+ metadata || build_metadata(project: project)
end
def detailed_status(current_user)
@@ -192,7 +216,11 @@ module Ci
end
def expanded_environment_name
- ExpandVariables.expand(environment, simple_variables) if environment
+ return unless has_environment?
+
+ strong_memoize(:expanded_environment_name) do
+ ExpandVariables.expand(environment, simple_variables)
+ end
end
def has_environment?
@@ -223,10 +251,6 @@ module Ci
latest_builds.where('stage_idx < ?', stage_idx)
end
- def timeout
- project.build_timeout
- end
-
def triggered_by?(current_user)
user == current_user
end
@@ -242,31 +266,52 @@ module Ci
Gitlab::Utils.slugify(ref.to_s)
end
- # Variables whose value does not depend on environment
+ ##
+ # Variables in the environment name scope.
+ #
+ def scoped_variables(environment: expanded_environment_name)
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.concat(predefined_variables)
+ variables.concat(project.predefined_variables)
+ variables.concat(pipeline.predefined_variables)
+ variables.concat(runner.predefined_variables) if runner
+ variables.concat(project.deployment_variables(environment: environment)) if environment
+ variables.concat(yaml_variables)
+ variables.concat(user_variables)
+ variables.concat(secret_group_variables)
+ variables.concat(secret_project_variables(environment: environment))
+ variables.concat(trigger_request.user_variables) if trigger_request
+ variables.concat(pipeline.variables)
+ variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule
+ end
+ end
+
+ ##
+ # Variables that do not depend on the environment name.
+ #
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
- variables += runner.predefined_variables if runner
- variables += project.container_registry_variables
- variables += project.deployment_variables if has_environment?
- variables += project.auto_devops_variables
- variables += yaml_variables
- variables += user_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 += pipeline.variables.map(&:to_runner_variable)
- variables += pipeline.pipeline_schedule.job_variables if pipeline.pipeline_schedule
- variables += persisted_environment_variables if environment
-
- variables
+ strong_memoize(:simple_variables) do
+ scoped_variables(environment: nil).to_runner_variables
+ end
+ end
+
+ ##
+ # All variables, including persisted environment variables.
+ #
+ def variables
+ Gitlab::Ci::Variables::Collection.new
+ .concat(persisted_variables)
+ .concat(scoped_variables)
+ .concat(persisted_environment_variables)
+ .to_runner_variables
+ end
+
+ ##
+ # Regular Ruby hash of scoped variables, without duplicates that are
+ # possible to be present in an array of hashes returned from `variables`.
+ #
+ def scoped_variables_hash
+ scoped_variables.to_hash
end
def features
@@ -291,7 +336,7 @@ module Ci
def repo_url
auth = "gitlab-ci-token:#{ensure_token!}@"
- project.http_url_to_repo.sub(/^https?:\/\//) do |prefix|
+ project.http_url_to_repo.sub(%r{^https?://}) do |prefix|
prefix + auth
end
end
@@ -326,8 +371,7 @@ module Ci
end
def erase_old_trace!
- write_attribute(:trace, nil)
- save
+ update_column(:trace, nil)
end
def needs_touch?
@@ -360,13 +404,19 @@ module Ci
project.running_or_pending_build_count(force: true)
end
+ def browsable_artifacts?
+ artifacts_metadata?
+ end
+
def artifacts_metadata_entry(path, **options)
- metadata = Gitlab::Ci::Build::Artifacts::Metadata.new(
- artifacts_metadata.path,
- path,
- **options)
+ artifacts_metadata.use_file do |metadata_path|
+ metadata = Gitlab::Ci::Build::Artifacts::Metadata.new(
+ metadata_path,
+ path,
+ **options)
- metadata.to_entry
+ metadata.to_entry
+ end
end
def erase_artifacts!
@@ -428,19 +478,24 @@ module Ci
end
def user_variables
- return [] if user.blank?
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ return variables if user.blank?
+
+ variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s)
+ variables.append(key: 'GITLAB_USER_EMAIL', value: user.email)
+ variables.append(key: 'GITLAB_USER_LOGIN', value: user.username)
+ variables.append(key: 'GITLAB_USER_NAME', value: user.name)
+ end
+ end
+
+ def secret_group_variables
+ return [] unless project.group
- [
- { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true },
- { key: 'GITLAB_USER_EMAIL', value: user.email, public: true },
- { key: 'GITLAB_USER_LOGIN', value: user.username, public: true },
- { key: 'GITLAB_USER_NAME', value: user.name, public: true }
- ]
+ project.group.secret_variables_for(ref, project)
end
- def secret_variables(environment: persisted_environment)
+ def secret_project_variables(environment: persisted_environment)
project.secret_variables_for(ref: ref, environment: environment)
- .map(&:to_runner_variable)
end
def steps
@@ -461,7 +516,14 @@ module Ci
end
def cache
- [options[:cache]]
+ cache = options[:cache]
+
+ if cache && project.jobs_cache_index
+ cache = cache.merge(
+ key: "#{cache[:key]}-#{project.jobs_cache_index}")
+ end
+
+ [cache]
end
def credentials
@@ -530,60 +592,66 @@ module Ci
CI_REGISTRY_USER = 'gitlab-ci-token'.freeze
- def predefined_variables
- variables = [
- { key: 'CI', value: 'true', public: true },
- { key: 'GITLAB_CI', value: 'true', public: true },
- { key: 'CI_SERVER_NAME', value: 'GitLab', public: true },
- { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true },
- { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true },
- { key: 'CI_JOB_ID', value: id.to_s, public: true },
- { key: 'CI_JOB_NAME', value: name, public: true },
- { key: 'CI_JOB_STAGE', value: stage, public: true },
- { key: 'CI_JOB_TOKEN', value: token, public: false },
- { key: 'CI_COMMIT_SHA', value: sha, public: true },
- { key: 'CI_COMMIT_REF_NAME', value: ref, public: true },
- { key: 'CI_COMMIT_REF_SLUG', value: ref_slug, public: true },
- { key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER, public: true },
- { key: 'CI_REGISTRY_PASSWORD', value: token, public: false },
- { key: 'CI_REPOSITORY_URL', value: repo_url, public: false }
- ]
-
- variables << { key: "CI_COMMIT_TAG", value: ref, public: true } if tag?
- variables << { key: "CI_PIPELINE_TRIGGERED", value: 'true', public: true } if trigger_request
- variables << { key: "CI_JOB_MANUAL", value: 'true', public: true } if action?
- variables.concat(legacy_variables)
+ def persisted_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ return variables unless persisted?
+
+ variables
+ .append(key: 'CI_JOB_ID', value: id.to_s)
+ .append(key: 'CI_JOB_TOKEN', value: token, public: false)
+ .append(key: 'CI_BUILD_ID', value: id.to_s)
+ .append(key: 'CI_BUILD_TOKEN', value: token, public: false)
+ .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER)
+ .append(key: 'CI_REGISTRY_PASSWORD', value: token, public: false)
+ .append(key: 'CI_REPOSITORY_URL', value: repo_url, public: false)
+ end
end
- def persisted_environment_variables
- return [] unless persisted_environment
+ def predefined_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI', value: 'true')
+ variables.append(key: 'GITLAB_CI', value: 'true')
+ variables.append(key: 'GITLAB_FEATURES', value: project.namespace.features.join(','))
+ variables.append(key: 'CI_SERVER_NAME', value: 'GitLab')
+ variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
+ variables.append(key: 'CI_SERVER_REVISION', value: Gitlab::REVISION)
+ variables.append(key: 'CI_JOB_NAME', value: name)
+ variables.append(key: 'CI_JOB_STAGE', value: stage)
+ variables.append(key: 'CI_COMMIT_SHA', value: sha)
+ variables.append(key: 'CI_COMMIT_REF_NAME', value: ref)
+ variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug)
+ variables.append(key: "CI_COMMIT_TAG", value: ref) if tag?
+ variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request
+ variables.append(key: "CI_JOB_MANUAL", value: 'true') if action?
+ variables.concat(legacy_variables)
+ end
+ end
- variables = persisted_environment.predefined_variables
+ def legacy_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_BUILD_REF', value: sha)
+ variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha)
+ variables.append(key: 'CI_BUILD_REF_NAME', value: ref)
+ variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug)
+ variables.append(key: 'CI_BUILD_NAME', value: name)
+ variables.append(key: 'CI_BUILD_STAGE', value: stage)
+ variables.append(key: "CI_BUILD_TAG", value: ref) if tag?
+ variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request
+ variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action?
+ end
+ end
- # 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
+ def persisted_environment_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ return variables unless persisted? && persisted_environment.present?
- variables
- end
+ variables.concat(persisted_environment.predefined_variables)
- def legacy_variables
- variables = [
- { key: 'CI_BUILD_ID', value: id.to_s, public: true },
- { key: 'CI_BUILD_TOKEN', value: token, public: false },
- { key: 'CI_BUILD_REF', value: sha, public: true },
- { key: 'CI_BUILD_BEFORE_SHA', value: before_sha, public: true },
- { key: 'CI_BUILD_REF_NAME', value: ref, public: true },
- { key: 'CI_BUILD_REF_SLUG', value: ref_slug, public: true },
- { key: 'CI_BUILD_NAME', value: name, public: true },
- { key: 'CI_BUILD_STAGE', value: stage, public: true }
- ]
-
- variables << { key: "CI_BUILD_TAG", value: ref, public: true } if tag?
- variables << { key: "CI_BUILD_TRIGGERED", value: 'true', public: true } if trigger_request
- variables << { key: "CI_BUILD_MANUAL", value: 'true', public: true } if action?
- 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.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url
+ end
end
def environment_url
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
new file mode 100644
index 00000000000..96762f8845c
--- /dev/null
+++ b/app/models/ci/build_metadata.rb
@@ -0,0 +1,35 @@
+module Ci
+ # The purpose of this class is to store Build related data that can be disposed.
+ # Data that should be persisted forever, should be stored with Ci::Build model.
+ class BuildMetadata < ActiveRecord::Base
+ extend Gitlab::Ci::Model
+ include Presentable
+ include ChronicDurationAttribute
+
+ self.table_name = 'ci_builds_metadata'
+
+ belongs_to :build, class_name: 'Ci::Build'
+ belongs_to :project
+
+ validates :build, presence: true
+ validates :project, presence: true
+
+ chronic_duration_attr_reader :timeout_human_readable, :timeout
+
+ enum timeout_source: {
+ unknown_timeout_source: 1,
+ project_timeout_source: 2,
+ runner_timeout_source: 3
+ }
+
+ def update_timeout_state
+ return unless build.runner.present?
+
+ project_timeout = project&.build_timeout
+ timeout = [project_timeout, build.runner.maximum_timeout].compact.min
+ timeout_source = timeout < project_timeout ? :runner_timeout_source : :project_timeout_source
+
+ update(timeout: timeout, timeout_source: timeout_source)
+ end
+ end
+end
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index afeae69ba39..62d768cc6cf 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -6,7 +6,12 @@ module Ci
belongs_to :group
- validates :key, uniqueness: { scope: :group_id }
+ alias_attribute :secret_value, :value
+
+ validates :key, uniqueness: {
+ scope: :group_id,
+ message: "(%{value}) has already been taken"
+ }
scope :unprotected, -> { where(protected: false) }
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 84fc6863567..fbb95fe16df 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -1,23 +1,39 @@
module Ci
class JobArtifact < ActiveRecord::Base
+ include AfterCommitQueue
+ include ObjectStorage::BackgroundMove
extend Gitlab::Ci::Model
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
+ before_save :update_file_store
before_save :set_size, if: :file_changed?
+ scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
+
mount_uploader :file, JobArtifactUploader
+ delegate :exists?, :open, to: :file
+
enum file_type: {
archive: 1,
- metadata: 2
+ metadata: 2,
+ trace: 3
}
+ def update_file_store
+ self.file_store = file.object_store
+ end
+
def self.artifacts_size_for(project)
self.where(project: project).sum(:size)
end
+ def local_store?
+ [nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store)
+ end
+
def set_size
self.size = file.size
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index d4690da3be6..434b9b64c65 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -6,15 +6,16 @@ module Ci
include AfterCommitQueue
include Presentable
include Gitlab::OptimisticLocking
+ include Gitlab::Utils::StrongMemoize
- belongs_to :project
+ belongs_to :project, inverse_of: :pipelines
belongs_to :user
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
has_many :stages
- has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
- has_many :builds, foreign_key: :commit_id
+ has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
@@ -361,21 +362,23 @@ module Ci
def stage_seeds
return [] unless config_processor
- @stage_seeds ||= config_processor.stage_seeds(self)
+ strong_memoize(:stage_seeds) do
+ seeds = config_processor.stages_attributes.map do |attributes|
+ Gitlab::Ci::Pipeline::Seed::Stage.new(self, attributes)
+ end
+
+ seeds.select(&:included?)
+ end
end
def seeds_size
- @seeds_size ||= stage_seeds.sum(&:size)
+ stage_seeds.sum(&:size)
end
def has_kubernetes_active?
project.deployment_platform&.active?
end
- def has_stage_seeds?
- stage_seeds.any?
- end
-
def has_warnings?
builds.latest.failed_but_allowed.any?
end
@@ -388,13 +391,16 @@ module Ci
end
end
+ ##
+ # TODO, setting yaml_errors should be moved to the pipeline creation chain.
+ #
def config_processor
return unless ci_yaml_file
return @config_processor if defined?(@config_processor)
@config_processor ||= begin
Gitlab::Ci::YamlProcessor.new(ci_yaml_file)
- rescue Gitlab::Ci::YamlProcessor::ValidationError, Psych::SyntaxError => e
+ rescue Gitlab::Ci::YamlProcessor::ValidationError => e
self.yaml_errors = e.message
nil
rescue
@@ -472,12 +478,19 @@ module Ci
end
end
+ def protected_ref?
+ strong_memoize(:protected_ref) { project.protected_for?(ref) }
+ end
+
+ def legacy_trigger
+ strong_memoize(:legacy_trigger) { trigger_requests.first }
+ end
+
def predefined_variables
- [
- { key: 'CI_PIPELINE_ID', value: id.to_s, public: true },
- { key: 'CI_CONFIG_PATH', value: ci_yaml_file_path, public: true },
- { key: 'CI_PIPELINE_SOURCE', value: source.to_s, public: true }
- ]
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_PIPELINE_ID', value: id.to_s)
+ .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path)
+ .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s)
end
def queued_duration
@@ -514,7 +527,7 @@ module Ci
# We purposely cast the builds to an Array here. Because we always use the
# rows if there are more than 0 this prevents us from having to run two
# queries: one to get the count and one to get the rows.
- @latest_builds_with_artifacts ||= builds.latest.with_artifacts.to_a
+ @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a
end
private
@@ -524,7 +537,7 @@ module Ci
return unless sha
project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path)
- rescue GRPC::NotFound, Rugged::ReferenceError, GRPC::Internal
+ rescue GRPC::NotFound, GRPC::Internal
nil
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 10ead6b6d3b..b6abc3d7681 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -2,8 +2,9 @@ module Ci
class PipelineSchedule < ActiveRecord::Base
extend Gitlab::Ci::Model
include Importable
+ include IgnorableColumn
- acts_as_paranoid
+ ignore_column :deleted_at
belongs_to :project
belongs_to :owner, class_name: 'User'
diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb
index af989fb14b4..03df4e3e638 100644
--- a/app/models/ci/pipeline_schedule_variable.rb
+++ b/app/models/ci/pipeline_schedule_variable.rb
@@ -5,6 +5,8 @@ module Ci
belongs_to :pipeline_schedule
+ alias_attribute :secret_value, :value
+
validates :key, uniqueness: { scope: :pipeline_schedule_id }
end
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index dcbb397fb78..ee0d8df8eb7 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -2,15 +2,18 @@ module Ci
class Runner < ActiveRecord::Base
extend Gitlab::Ci::Model
include Gitlab::SQL::Pattern
+ include RedisCacheable
+ include ChronicDurationAttribute
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
ONLINE_CONTACT_TIMEOUT = 1.hour
+ UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes
AVAILABLE_SCOPES = %w[specific shared active paused online].freeze
- FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze
+ FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
has_many :builds
has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :projects, through: :runner_projects
+ has_many :projects, -> { auto_include(false) }, through: :runner_projects
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
@@ -47,6 +50,14 @@ module Ci
ref_protected: 1
}
+ cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address
+
+ chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout
+
+ validates :maximum_timeout, allow_nil: true,
+ numericality: { greater_than_or_equal_to: 600,
+ message: 'needs to be at least 10 minutes' }
+
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
@@ -128,11 +139,10 @@ module Ci
end
def predefined_variables
- [
- { key: 'CI_RUNNER_ID', value: id.to_s, public: true },
- { key: 'CI_RUNNER_DESCRIPTION', value: description, public: true },
- { key: 'CI_RUNNER_TAGS', value: tag_list.to_s, public: true }
- ]
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_RUNNER_ID', value: id.to_s)
+ .append(key: 'CI_RUNNER_DESCRIPTION', value: description)
+ .append(key: 'CI_RUNNER_TAGS', value: tag_list.to_s)
end
def tick_runner_queue
@@ -152,6 +162,18 @@ module Ci
ensure_runner_queue_value == value if value.present?
end
+ def update_cached_info(values)
+ values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {}
+ values[:contacted_at] = Time.now
+
+ cache_attributes(values)
+
+ if persist_cached_data?
+ self.assign_attributes(values)
+ self.save if self.changed?
+ end
+ end
+
private
def cleanup_runner_queue
@@ -164,6 +186,17 @@ module Ci
"runner:build_queue:#{self.token}"
end
+ def persist_cached_data?
+ # Use a random threshold to prevent beating DB updates.
+ # It generates a distribution between [40m, 80m].
+
+ contacted_at_max_age = UPDATE_DB_RUNNER_INFO_EVERY + Random.rand(UPDATE_DB_RUNNER_INFO_EVERY)
+
+ real_contacted_at = read_attribute(:contacted_at)
+ real_contacted_at.nil? ||
+ (Time.now - real_contacted_at) >= contacted_at_max_age
+ end
+
def tag_constraints
unless has_tags? || run_untagged?
errors.add(:tags_list,
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index b5290bcaf53..aa065e33739 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -1,8 +1,9 @@
module Ci
class Trigger < ActiveRecord::Base
extend Gitlab::Ci::Model
+ include IgnorableColumn
- acts_as_paranoid
+ ignore_column :deleted_at
belongs_to :project
belongs_to :owner, class_name: "User"
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 67d3ec81b6f..452cb910bca 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -6,7 +6,12 @@ module Ci
belongs_to :project
- validates :key, uniqueness: { scope: [:project_id, :environment_scope] }
+ alias_attribute :secret_value, :value
+
+ validates :key, uniqueness: {
+ scope: [:project_id, :environment_scope],
+ message: "(%{value}) has already been taken"
+ }
scope :unprotected, -> { where(protected: false) }
end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index c7949d11ef8..58de3448577 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -3,32 +3,19 @@ module Clusters
class Helm < ActiveRecord::Base
self.table_name = 'clusters_applications_helm'
+ include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
- belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
-
default_value_for :version, Gitlab::Kubernetes::Helm::HELM_VERSION
- validates :cluster, presence: true
-
- after_initialize :set_initial_status
-
- def self.application_name
- self.to_s.demodulize.underscore
- end
-
def set_initial_status
return unless not_installable?
self.status = 'installable' if cluster&.platform_kubernetes_active?
end
- def name
- self.class.application_name
- end
-
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(name, true)
+ Gitlab::Kubernetes::Helm::InitCommand.new(name)
end
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 44bd979741e..27fc3b85465 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -3,33 +3,27 @@ module Clusters
class Ingress < ActiveRecord::Base
self.table_name = 'clusters_applications_ingress'
+ include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
-
- belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
-
- validates :cluster, presence: true
+ include ::Clusters::Concerns::ApplicationData
+ include AfterCommitQueue
default_value_for :ingress_type, :nginx
default_value_for :version, :nginx
- after_initialize :set_initial_status
-
enum ingress_type: {
nginx: 1
}
- def self.application_name
- self.to_s.demodulize.underscore
- end
-
- def set_initial_status
- return unless not_installable?
+ FETCH_IP_ADDRESS_DELAY = 30.seconds
- self.status = 'installable' if cluster&.application_helm_installed?
- end
-
- def name
- self.class.application_name
+ state_machine :status do
+ before_transition any => [:installed] do |application|
+ application.run_after_commit do
+ ClusterWaitForIngressIpAddressWorker.perform_in(
+ FETCH_IP_ADDRESS_DELAY, application.name, application.id)
+ end
+ end
end
def chart
@@ -37,7 +31,18 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(name, false, chart)
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name,
+ chart: chart,
+ values: values
+ )
+ end
+
+ def schedule_status_update
+ return unless installed?
+ return if external_ip
+
+ ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
end
end
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
new file mode 100644
index 00000000000..7b25d8c4089
--- /dev/null
+++ b/app/models/clusters/applications/prometheus.rb
@@ -0,0 +1,61 @@
+module Clusters
+ module Applications
+ class Prometheus < ActiveRecord::Base
+ include PrometheusAdapter
+
+ VERSION = "2.0.0".freeze
+
+ self.table_name = 'clusters_applications_prometheus'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+ include ::Clusters::Concerns::ApplicationData
+
+ default_value_for :version, VERSION
+
+ state_machine :status do
+ after_transition any => [:installed] do |application|
+ application.cluster.projects.each do |project|
+ project.find_or_initialize_service('prometheus').update(active: true)
+ end
+ end
+ end
+
+ def chart
+ 'stable/prometheus'
+ end
+
+ def service_name
+ 'prometheus-prometheus-server'
+ end
+
+ def service_port
+ 80
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name,
+ chart: chart,
+ values: values
+ )
+ end
+
+ def prometheus_client
+ return unless kube_client
+
+ proxy_url = kube_client.proxy_url('service', service_name, service_port, Gitlab::Kubernetes::Helm::NAMESPACE)
+
+ # ensures headers containing auth data are appended to original k8s client options
+ options = kube_client.rest_client.options.merge(headers: kube_client.headers)
+ RestClient::Resource.new(proxy_url, options)
+ end
+
+ private
+
+ def kube_client
+ cluster&.kubeclient
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
new file mode 100644
index 00000000000..16efe90fa27
--- /dev/null
+++ b/app/models/clusters/applications/runner.rb
@@ -0,0 +1,69 @@
+module Clusters
+ module Applications
+ class Runner < ActiveRecord::Base
+ VERSION = '0.1.13'.freeze
+
+ self.table_name = 'clusters_applications_runners'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+ include ::Clusters::Concerns::ApplicationData
+
+ belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id
+ delegate :project, to: :cluster
+
+ default_value_for :version, VERSION
+
+ def chart
+ "#{name}/gitlab-runner"
+ end
+
+ def repository
+ 'https://charts.gitlab.io'
+ end
+
+ def values
+ content_values.to_yaml
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name,
+ chart: chart,
+ values: values,
+ repository: repository
+ )
+ end
+
+ private
+
+ def ensure_runner
+ runner || create_and_assign_runner
+ end
+
+ def create_and_assign_runner
+ transaction do
+ project.runners.create!(name: 'kubernetes-cluster', tag_list: %w(kubernetes cluster)).tap do |runner|
+ update!(runner_id: runner.id)
+ end
+ end
+ end
+
+ def gitlab_url
+ Gitlab::Routing.url_helpers.root_url(only_path: false)
+ end
+
+ def specification
+ {
+ "gitlabUrl" => gitlab_url,
+ "runnerToken" => ensure_runner.token,
+ "runners" => { "privileged" => privileged }
+ }
+ end
+
+ def content_values
+ YAML.load_file(chart_values_file).deep_merge!(specification)
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 55419189282..e4a06f3f976 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -6,13 +6,16 @@ module Clusters
APPLICATIONS = {
Applications::Helm.application_name => Applications::Helm,
- Applications::Ingress.application_name => Applications::Ingress
+ Applications::Ingress.application_name => Applications::Ingress,
+ Applications::Prometheus.application_name => Applications::Prometheus,
+ Applications::Runner.application_name => Applications::Runner
}.freeze
+ DEFAULT_ENVIRONMENT = '*'.freeze
belongs_to :user
has_many :cluster_projects, class_name: 'Clusters::Project'
- has_many :projects, through: :cluster_projects, class_name: '::Project'
+ has_many :projects, -> { auto_include(false) }, through: :cluster_projects, class_name: '::Project'
# we force autosave to happen when we save `Cluster` model
has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
@@ -21,6 +24,8 @@ module Clusters
has_one :application_helm, class_name: 'Clusters::Applications::Helm'
has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
+ has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus'
+ has_one :application_runner, class_name: 'Clusters::Applications::Runner'
accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true
@@ -46,6 +51,11 @@ module Clusters
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
+ scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) }
+ scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) }
+ scope :gcp_installed, -> { gcp_provided.includes(:provider_gcp).where(cluster_providers_gcp: { status: ::Clusters::Providers::Gcp.state_machines[:status].states[:created].value }) }
+
+ scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
def status_name
if provider
@@ -62,7 +72,9 @@ module Clusters
def applications
[
application_helm || build_application_helm,
- application_ingress || build_application_ingress
+ application_ingress || build_application_ingress,
+ application_prometheus || build_application_prometheus,
+ application_runner || build_application_runner
]
end
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
new file mode 100644
index 00000000000..623b836c0ed
--- /dev/null
+++ b/app/models/clusters/concerns/application_core.rb
@@ -0,0 +1,34 @@
+module Clusters
+ module Concerns
+ module ApplicationCore
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
+
+ validates :cluster, presence: true
+
+ after_initialize :set_initial_status
+
+ def set_initial_status
+ return unless not_installable?
+
+ self.status = 'installable' if cluster&.application_helm_installed?
+ end
+
+ def self.application_name
+ self.to_s.demodulize.underscore
+ end
+
+ def name
+ self.class.application_name
+ end
+
+ def schedule_status_update
+ # Override if you need extra data synchronized
+ # from K8s after installation
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
new file mode 100644
index 00000000000..96ac757e99e
--- /dev/null
+++ b/app/models/clusters/concerns/application_data.rb
@@ -0,0 +1,23 @@
+module Clusters
+ module Concerns
+ module ApplicationData
+ extend ActiveSupport::Concern
+
+ included do
+ def repository
+ nil
+ end
+
+ def values
+ File.read(chart_values_file)
+ end
+
+ private
+
+ def chart_values_file
+ "#{Rails.root}/vendor/#{name}/values.yaml"
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 7b7c8eac773..8f3eb75bfa9 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -4,6 +4,8 @@ module Clusters
extend ActiveSupport::Concern
included do
+ scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) }
+
state_machine :status, initial: :not_installable do
state :not_installable, value: -2
state :errored, value: -1
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 9160a169452..ba6552f238f 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -1,7 +1,6 @@
module Clusters
module Platforms
class Kubernetes < ActiveRecord::Base
- include Gitlab::CurrentSettings
include Gitlab::Kubernetes
include ReactiveCaching
@@ -57,19 +56,19 @@ module Clusters
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: actual_namespace, public: true },
- { key: 'KUBECONFIG', value: config, public: false, file: true }
- ]
-
- if ca_pem.present?
- variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true }
- variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true }
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables
+ .append(key: 'KUBE_URL', value: api_url)
+ .append(key: 'KUBE_TOKEN', value: token, public: false)
+ .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
+ .append(key: 'KUBECONFIG', value: config, public: false, file: true)
+
+ if ca_pem.present?
+ variables
+ .append(key: 'KUBE_CA_PEM', value: ca_pem)
+ .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true)
+ end
end
-
- variables
end
# Constructs a list of terminals from the reactive cache
@@ -135,7 +134,7 @@ module Clusters
kubeclient = build_kubeclient!
kubeclient.get_pods(namespace: actual_namespace).as_json
- rescue KubeException => err
+ rescue Kubeclient::HttpError => err
raise err unless err.error_code == 404
[]
@@ -169,7 +168,7 @@ module Clusters
{
token: token,
ca_pem: ca_pem,
- max_session_time: current_application_settings.terminal_max_session_time
+ max_session_time: Gitlab::CurrentSettings.terminal_max_session_time
}
end
@@ -181,7 +180,7 @@ module Clusters
return unless managed?
if api_url_changed? || token_changed? || ca_pem_changed?
- errors.add(:base, "cannot modify managed cluster")
+ errors.add(:base, _('Cannot modify managed Kubernetes cluster'))
return false
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 2be07ca7d3c..de860df4b9c 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -9,6 +9,7 @@ class Commit
include Mentionable
include Referable
include StaticModel
+ include ::Gitlab::Utils::StrongMemoize
attr_mentionable :safe_message, pipeline: :single_line
@@ -19,6 +20,7 @@ class Commit
attr_accessor :project, :author
attr_accessor :redacted_description_html
attr_accessor :redacted_title_html
+ attr_reader :gpg_commit
DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
@@ -28,9 +30,12 @@ class Commit
MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH
COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze
+ # Used by GFM to match and present link extensions on node texts and hrefs.
+ LINK_EXTENSION_PATTERN = /(patch)/.freeze
def banzai_render_context(field)
- context = { pipeline: :single_line, project: self.project }
+ pipeline = field == :description ? :commit_description : :single_line
+ context = { pipeline: pipeline, project: self.project }
context[:author] = self.author if self.author
context
@@ -110,12 +115,17 @@ class Commit
@raw = raw_commit
@project = project
@statuses = {}
+ @gpg_commit = Gitlab::Gpg::Commit.new(self) if project
end
def id
raw.id
end
+ def project_id
+ project.id
+ end
+
def ==(other)
other.is_a?(self.class) && raw == other.raw
end
@@ -135,7 +145,8 @@ class Commit
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})/)
+ @link_reference_pattern ||=
+ super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/)
end
def to_reference(from = nil, full: false)
@@ -168,7 +179,7 @@ class Commit
if safe_message.blank?
no_commit_message
else
- safe_message.split("\n", 2).first
+ safe_message.split(/[\r\n]/, 2).first
end
end
@@ -219,11 +230,13 @@ class Commit
end
def parents
- @parents ||= parent_ids.map { |id| project.commit(id) }
+ @parents ||= parent_ids.map { |oid| Commit.lazy(project, oid) }
end
def parent
- @parent ||= project.commit(self.parent_id) if self.parent_id
+ strong_memoize(:parent) do
+ project.commit_by(oid: self.parent_id) if self.parent_id
+ end
end
def notes
@@ -238,6 +251,10 @@ class Commit
notes.includes(:author)
end
+ def merge_requests
+ @merge_requests ||= project.merge_requests.by_commit_sha(sha)
+ end
+
def method_missing(method, *args, &block)
@raw.__send__(method, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end
@@ -342,10 +359,11 @@ class Commit
@merged_merge_request_hash[current_user]
end
- def has_been_reverted?(current_user, noteable = self)
+ def has_been_reverted?(current_user, notes_association = nil)
ext = all_references(current_user)
+ notes_association ||= notes_with_associations
- noteable.notes_with_associations.system.each do |note|
+ notes_association.system.each do |note|
note.all_references(current_user, extractor: ext)
end
@@ -367,19 +385,19 @@ class Commit
# uri_type('doc/README.md') # => :blob
# uri_type('doc/logo.png') # => :raw
# uri_type('doc/api') # => :tree
- # uri_type('not/found') # => :nil
+ # uri_type('not/found') # => nil
#
# Returns a symbol
def uri_type(path)
- entry = @raw.tree.path(path)
+ entry = @raw.tree_entry(path)
+ return unless entry
+
if entry[:type] == :blob
blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project)
blob.image? || blob.video? ? :raw : :blob
else
entry[:type]
end
- rescue Rugged::TreeError
- nil
end
def raw_diffs(*args)
@@ -408,6 +426,10 @@ class Commit
!!(title =~ WIP_REGEX)
end
+ def merged_merge_request?(user)
+ !!merged_merge_request(user)
+ end
+
private
def commit_reference(from, referable_commit_id, full: false)
@@ -436,15 +458,7 @@ class Commit
changes
end
- def merged_merge_request?(user)
- !!merged_merge_request(user)
- end
-
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.new(self)
- end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index c0263c0b4e2..3469d5d795c 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -141,7 +141,7 @@ class CommitStatus < ActiveRecord::Base
end
def group_name
- name.to_s.gsub(/\d+[\s:\/\\]+\d+\s*/, '').strip
+ name.to_s.gsub(%r{\d+[\s:/\\]+\d+\s*}, '').strip
end
def failed_but_allowed?
diff --git a/app/models/compare.rb b/app/models/compare.rb
index 3a8bbcb1acd..feb4b89c781 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -1,4 +1,6 @@
class Compare
+ include Gitlab::Utils::StrongMemoize
+
delegate :same, :head, :base, to: :@compare
attr_reader :project
@@ -11,9 +13,10 @@ class Compare
end
end
- def initialize(compare, project, straight: false)
+ def initialize(compare, project, base_sha: nil, straight: false)
@compare = compare
@project = project
+ @base_sha = base_sha
@straight = straight
end
@@ -22,40 +25,36 @@ class Compare
end
def start_commit
- return @start_commit if defined?(@start_commit)
+ strong_memoize(:start_commit) do
+ commit = @compare.base
- commit = @compare.base
- @start_commit = commit ? ::Commit.new(commit, project) : nil
+ ::Commit.new(commit, project) if commit
+ end
end
def head_commit
- return @head_commit if defined?(@head_commit)
+ strong_memoize(:head_commit) do
+ commit = @compare.head
- commit = @compare.head
- @head_commit = commit ? ::Commit.new(commit, project) : nil
+ ::Commit.new(commit, project) if commit
+ end
end
alias_method :commit, :head_commit
- def base_commit
- return @base_commit if defined?(@base_commit)
-
- @base_commit = if start_commit && head_commit
- project.merge_base_commit(start_commit.id, head_commit.id)
- else
- nil
- end
- end
-
def start_commit_sha
- start_commit.try(:sha)
+ start_commit&.sha
end
def base_commit_sha
- base_commit.try(:sha)
+ strong_memoize(:base_commit) do
+ next unless start_commit && head_commit
+
+ @base_sha || project.merge_base_commit(start_commit.id, head_commit.id)&.sha
+ end
end
def head_commit_sha
- commit.try(:sha)
+ commit&.sha
end
def raw_diffs(*args)
diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb
index 62bc6b809f4..d502e7e54c6 100644
--- a/app/models/concerns/access_requestable.rb
+++ b/app/models/concerns/access_requestable.rb
@@ -8,6 +8,6 @@ module AccessRequestable
extend ActiveSupport::Concern
def request_access(user)
- Members::RequestAccessService.new(self, user).execute
+ Members::RequestAccessService.new(user).execute(self)
end
end
diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb
index 0460439e9e6..ff52ca64459 100644
--- a/app/models/concerns/artifact_migratable.rb
+++ b/app/models/concerns/artifact_migratable.rb
@@ -39,7 +39,6 @@ module ArtifactMigratable
end
def artifacts_size
- read_attribute(:artifacts_size).to_i +
- job_artifacts_archive&.size.to_i + job_artifacts_metadata&.size.to_i
+ read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i
end
end
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
new file mode 100644
index 00000000000..4b66725a3e6
--- /dev/null
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -0,0 +1,46 @@
+# Include atomic internal id generation scheme for a model
+#
+# This allows us to atomically generate internal ids that are
+# unique within a given scope.
+#
+# For example, let's generate internal ids for Issue per Project:
+# ```
+# class Issue < ActiveRecord::Base
+# has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) }
+# end
+# ```
+#
+# This generates unique internal ids per project for newly created issues.
+# The generated internal id is saved in the `iid` attribute of `Issue`.
+#
+# This concern uses InternalId records to facilitate atomicity.
+# In the absence of a record for the given scope, one will be created automatically.
+# In this situation, the `init` block is called to calculate the initial value.
+# In the example above, we calculate the maximum `iid` of all issues
+# within the given project.
+#
+# Note that a model may have more than one internal id associated with possibly
+# different scopes.
+module AtomicInternalId
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName
+ before_validation(on: :create) do
+ if read_attribute(column).blank?
+ scope_attrs = { scope => association(scope).reader }
+ usage = self.class.table_name.to_sym
+
+ new_iid = InternalId.generate_next(self, scope_attrs, usage, init)
+ write_attribute(column, new_iid)
+ end
+ end
+
+ validates column, presence: true, numericality: true
+ end
+ end
+
+ def to_param
+ iid.to_s
+ end
+end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 10659030910..7677891b9ce 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -1,6 +1,31 @@
module Avatarable
extend ActiveSupport::Concern
+ included do
+ prepend ShadowMethods
+ include ObjectStorage::BackgroundMove
+
+ validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
+ validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
+
+ mount_uploader :avatar, AvatarUploader
+ end
+
+ module ShadowMethods
+ 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(only_path: args.fetch(:only_path, true)) || super
+ end
+ end
+
+ def avatar_type
+ unless self.avatar.image?
+ errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::IMAGE_EXT.join(', ')}"
+ end
+ end
+
def avatar_path(only_path: true)
return unless self[:avatar].present?
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 90ad644ce34..4ae5dd8c677 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 = 2
+ CACHE_VERSION = 3
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb
new file mode 100644
index 00000000000..593a9b3d71d
--- /dev/null
+++ b/app/models/concerns/chronic_duration_attribute.rb
@@ -0,0 +1,39 @@
+module ChronicDurationAttribute
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def chronic_duration_attr_reader(virtual_attribute, source_attribute)
+ define_method(virtual_attribute) do
+ chronic_duration_attributes[virtual_attribute] || output_chronic_duration_attribute(source_attribute)
+ end
+ end
+
+ def chronic_duration_attr_writer(virtual_attribute, source_attribute, parameters = {})
+ chronic_duration_attr_reader(virtual_attribute, source_attribute)
+
+ define_method("#{virtual_attribute}=") do |value|
+ chronic_duration_attributes[virtual_attribute] = value.presence || parameters[:default].presence.to_s
+
+ begin
+ new_value = value.present? ? ChronicDuration.parse(value).to_i : parameters[:default].presence
+ assign_attributes(source_attribute => new_value)
+ rescue ChronicDuration::DurationParseError
+ # ignore error as it will be caught by validation
+ end
+ end
+
+ validates virtual_attribute, allow_nil: true, duration: true
+ end
+
+ alias_method :chronic_duration_attr, :chronic_duration_attr_writer
+ end
+
+ def chronic_duration_attributes
+ @chronic_duration_attributes ||= {}
+ end
+
+ def output_chronic_duration_attribute(source_attribute)
+ value = attributes[source_attribute.to_s]
+ ChronicDuration.output(value, format: :short) if value
+ end
+end
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
new file mode 100644
index 00000000000..52851b3d0b2
--- /dev/null
+++ b/app/models/concerns/deployment_platform.rb
@@ -0,0 +1,57 @@
+module DeploymentPlatform
+ # EE would override this and utilize environment argument
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def deployment_platform(environment: nil)
+ @deployment_platform ||= {}
+
+ @deployment_platform[environment] ||= find_deployment_platform(environment)
+ end
+
+ private
+
+ def find_deployment_platform(environment)
+ find_cluster_platform_kubernetes(environment: environment) ||
+ find_kubernetes_service_integration ||
+ build_cluster_and_deployment_platform
+ end
+
+ # EE would override this and utilize environment argument
+ def find_cluster_platform_kubernetes(environment: nil)
+ clusters.enabled.default_environment
+ .last&.platform_kubernetes
+ end
+
+ def find_kubernetes_service_integration
+ services.deployment.reorder(nil).find_by(active: true)
+ end
+
+ def build_cluster_and_deployment_platform
+ return unless kubernetes_service_template
+
+ cluster = ::Clusters::Cluster.create(cluster_attributes_from_service_template)
+ cluster.platform_kubernetes if cluster.persisted?
+ end
+
+ def kubernetes_service_template
+ @kubernetes_service_template ||= KubernetesService.active.find_by_template
+ end
+
+ def cluster_attributes_from_service_template
+ {
+ name: 'kubernetes-template',
+ projects: [self],
+ provider_type: :user,
+ platform_type: :kubernetes,
+ platform_kubernetes_attributes: platform_kubernetes_attributes_from_service_template
+ }
+ end
+
+ def platform_kubernetes_attributes_from_service_template
+ {
+ api_url: kubernetes_service_template.api_url,
+ ca_pem: kubernetes_service_template.ca_pem,
+ token: kubernetes_service_template.token,
+ namespace: kubernetes_service_template.namespace
+ }
+ end
+end
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index 4b4d519f3df..8b3c55387b3 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -9,7 +9,6 @@ module DiscussionOnDiff
:original_line_code,
:diff_file,
:diff_line,
- :for_line?,
:active?,
:created_at_diff?,
@@ -38,18 +37,19 @@ module DiscussionOnDiff
# Returns an array of at most 16 highlighted lines above a diff note
def truncated_diff_lines(highlight: true)
+ return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote)
+
lines = highlight ? highlighted_diff_lines : diff_lines
+
+ initial_line_index = [diff_line.index - NUMBER_OF_TRUNCATED_DIFF_LINES + 1, 0].max
+
prev_lines = []
- lines.each do |line|
+ lines[initial_line_index..diff_line.index].each do |line|
if line.meta?
prev_lines.clear
else
prev_lines << line
-
- break if for_line?(line)
-
- prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES
end
end
diff --git a/app/models/concerns/internal_id.rb b/app/models/concerns/internal_id.rb
deleted file mode 100644
index a3d0ac8d862..00000000000
--- a/app/models/concerns/internal_id.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module InternalId
- extend ActiveSupport::Concern
-
- included do
- validate :set_iid, on: :create
- validates :iid, presence: true, numericality: true
- end
-
- def set_iid
- if iid.blank?
- 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)
-
- self.iid = max_iid.to_i + 1
- end
- end
-
- def to_param
- iid.to_s
- end
-end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 5ca4a7086cb..d9416352f9c 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -19,6 +19,7 @@ module Issuable
include AfterCommitQueue
include Sortable
include CreatedAtFilterable
+ include UpdatedAtFilterable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
@@ -47,7 +48,7 @@ module Issuable
end
has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :labels, through: :label_links
+ has_many :labels, -> { auto_include(false) }, through: :label_links
has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :metrics
@@ -96,7 +97,7 @@ module Issuable
strip_attributes :title
- after_save :record_metrics, unless: :imported?
+ after_save :ensure_metrics, unless: :imported?
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
@@ -136,7 +137,7 @@ module Issuable
fuzzy_search(query, [:title, :description])
end
- def sort(method, excluded_labels: [])
+ def sort_by_attribute(method, excluded_labels: [])
sorted =
case method.to_s
when 'downvotes_desc' then order_downvotes_desc
@@ -222,6 +223,10 @@ module Issuable
def to_ability_name
model_name.singular
end
+
+ def parent_class
+ ::Project
+ end
end
def today?
@@ -314,6 +319,7 @@ module Issuable
includes = []
includes << :author unless notes.authors_loaded?
includes << :award_emoji unless notes.award_emojis_loaded?
+
if includes.any?
notes.includes(includes)
else
@@ -335,11 +341,6 @@ module Issuable
false
end
- def record_metrics
- metrics = self.metrics || create_metrics
- metrics.record!
- end
-
##
# Override in issuable specialization
#
@@ -347,6 +348,10 @@ module Issuable
false
end
+ def ensure_metrics
+ self.metrics || create_metrics
+ end
+
##
# Overriden in MergeRequest
#
diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb
index dcb3b2b5ff3..935e9d10133 100644
--- a/app/models/concerns/loaded_in_group_list.rb
+++ b/app/models/concerns/loaded_in_group_list.rb
@@ -25,6 +25,7 @@ module LoadedInGroupList
base_count = projects.project(Arel.star.count.as('preloaded_project_count'))
.where(projects[:namespace_id].eq(namespaces[:id]))
+
if archived == 'only'
base_count.where(projects[:archived].eq(true))
elsif Gitlab::Utils.to_boolean(archived)
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index fd6703831e4..5130ecec472 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -45,11 +45,11 @@ module Milestoneish
end
def sorted_issues(user)
- issues_visible_to_user(user).preload_associations.sort('label_priority')
+ issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority')
end
def sorted_merge_requests
- merge_requests.sort('label_priority')
+ merge_requests.sort_by_attribute('label_priority')
end
def upcoming?
@@ -94,6 +94,14 @@ module Milestoneish
Gitlab::TimeTrackingFormatter.output(total_issue_time_spent)
end
+ def total_issue_time_estimate
+ @total_issue_time_estimate ||= issues.sum(:time_estimate)
+ end
+
+ def human_total_issue_time_estimate
+ Gitlab::TimeTrackingFormatter.output(total_issue_time_estimate)
+ end
+
private
def count_issues_by_state(user)
diff --git a/app/models/concerns/nonatomic_internal_id.rb b/app/models/concerns/nonatomic_internal_id.rb
new file mode 100644
index 00000000000..9d0c9b8512f
--- /dev/null
+++ b/app/models/concerns/nonatomic_internal_id.rb
@@ -0,0 +1,22 @@
+module NonatomicInternalId
+ extend ActiveSupport::Concern
+
+ included do
+ validate :set_iid, on: :create
+ validates :iid, presence: true, numericality: true
+ end
+
+ def set_iid
+ if iid.blank?
+ parent = project || group
+ records = parent.public_send(self.class.name.tableize) # rubocop:disable GitlabSecurity/PublicSend
+ max_iid = records.maximum(:iid)
+
+ self.iid = max_iid.to_i + 1
+ end
+ end
+
+ def to_param
+ iid.to_s
+ end
+end
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index f734952fa6c..510b8868462 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -14,10 +14,6 @@ module NoteOnDiff
raise NotImplementedError
end
- def for_line?(line)
- raise NotImplementedError
- end
-
def original_line_code
raise NotImplementedError
end
diff --git a/app/models/concerns/presentable.rb b/app/models/concerns/presentable.rb
index 7b33b837004..bc4fbd19a02 100644
--- a/app/models/concerns/presentable.rb
+++ b/app/models/concerns/presentable.rb
@@ -1,4 +1,12 @@
module Presentable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def present(attributes)
+ all.map { |klass_object| klass_object.present(attributes) }
+ end
+ end
+
def present(**attributes)
Gitlab::View::Presenter::Factory
.new(self, attributes)
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index cb59b4da3d7..b3fec99c816 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -2,6 +2,7 @@
#
# After migrating issues_enabled merge_requests_enabled builds_enabled snippets_enabled and wiki_enabled
# fields to a new table "project_features", support for the old fields is still needed in the API.
+require 'gitlab/utils'
module ProjectFeaturesCompatibility
extend ActiveSupport::Concern
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
new file mode 100644
index 00000000000..18cbbd871a1
--- /dev/null
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -0,0 +1,48 @@
+module PrometheusAdapter
+ extend ActiveSupport::Concern
+
+ included do
+ include ReactiveCaching
+
+ self.reactive_cache_key = ->(adapter) { [adapter.class.model_name.singular, adapter.id] }
+ self.reactive_cache_lease_timeout = 30.seconds
+ self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_lifetime = 1.minute
+
+ def prometheus_client
+ raise NotImplementedError
+ end
+
+ def prometheus_client_wrapper
+ Gitlab::PrometheusClient.new(prometheus_client)
+ end
+
+ def can_query?
+ prometheus_client.present?
+ end
+
+ def query(query_name, *args)
+ return unless can_query?
+
+ query_class = Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query")
+
+ args.map!(&:id)
+
+ with_reactive_cache(query_class.name, *args, &query_class.method(:transform_reactive_result))
+ end
+
+ # Cache metrics for specific environment
+ def calculate_reactive_cache(query_class_name, *args)
+ return unless prometheus_client
+
+ data = Kernel.const_get(query_class_name).new(prometheus_client_wrapper).query(*args)
+ {
+ success: true,
+ data: data,
+ last_update: Time.now.utc
+ }
+ rescue Gitlab::PrometheusClient::Error => err
+ { success: false, result: err.message }
+ end
+ end
+end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index 80c9f7d4eb4..bfda5b1678b 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -35,6 +35,7 @@ module ProtectedRefAccess
def check_access(user)
return true if user.admin?
- project.team.max_member_access(user.id) >= access_level
+ user.can?(:push_code, project) &&
+ project.team.max_member_access(user.id) >= access_level
end
end
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
new file mode 100644
index 00000000000..b889f4202dc
--- /dev/null
+++ b/app/models/concerns/redis_cacheable.rb
@@ -0,0 +1,41 @@
+module RedisCacheable
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ CACHED_ATTRIBUTES_EXPIRY_TIME = 24.hours
+
+ class_methods do
+ def cached_attr_reader(*attributes)
+ attributes.each do |attribute|
+ define_method("#{attribute}") do
+ cached_attribute(attribute) || read_attribute(attribute)
+ end
+ end
+ end
+ end
+
+ def cached_attribute(attribute)
+ (cached_attributes || {})[attribute]
+ end
+
+ def cache_attributes(values)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME)
+ end
+ end
+
+ private
+
+ def cache_attribute_key
+ "cache:#{self.class.name}:#{self.id}:attributes"
+ end
+
+ def cached_attributes
+ strong_memoize(:cached_attributes) do
+ Gitlab::Redis::SharedState.with do |redis|
+ data = redis.get(cache_attribute_key)
+ JSON.parse(data, symbolize_names: true) if data
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 835f26aa57b..afacdb8cb12 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -10,12 +10,12 @@ module RelativePositioning
after_save :save_positionable_neighbours
end
- def project_ids
- [project.id]
+ def min_relative_position
+ self.class.in_parents(parent_ids).minimum(:relative_position)
end
def max_relative_position
- self.class.in_projects(project_ids).maximum(:relative_position)
+ self.class.in_parents(parent_ids).maximum(:relative_position)
end
def prev_relative_position
@@ -23,7 +23,7 @@ module RelativePositioning
if self.relative_position
prev_pos = self.class
- .in_projects(project_ids)
+ .in_parents(parent_ids)
.where('relative_position < ?', self.relative_position)
.maximum(:relative_position)
end
@@ -36,7 +36,7 @@ module RelativePositioning
if self.relative_position
next_pos = self.class
- .in_projects(project_ids)
+ .in_parents(parent_ids)
.where('relative_position > ?', self.relative_position)
.minimum(:relative_position)
end
@@ -63,7 +63,7 @@ module RelativePositioning
pos_after = before.next_relative_position
if before.shift_after?
- issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_after)
+ issue_to_move = self.class.in_parents(parent_ids).find_by!(relative_position: pos_after)
issue_to_move.move_after
@positionable_neighbours = [issue_to_move] # rubocop:disable Gitlab/ModuleWithInstanceVariables
@@ -78,7 +78,7 @@ module RelativePositioning
pos_before = after.prev_relative_position
if after.shift_before?
- issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_before)
+ issue_to_move = self.class.in_parents(parent_ids).find_by!(relative_position: pos_before)
issue_to_move.move_before
@positionable_neighbours = [issue_to_move] # rubocop:disable Gitlab/ModuleWithInstanceVariables
@@ -92,6 +92,10 @@ module RelativePositioning
self.relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION)
end
+ def move_to_start
+ self.relative_position = position_between(min_relative_position || START_POSITION, MIN_POSITION)
+ end
+
# Indicates if there is an issue that should be shifted to free the place
def shift_after?
next_pos = next_relative_position
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index b6c7b6735b9..399abb67c9d 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -1,5 +1,6 @@
module ResolvableDiscussion
extend ActiveSupport::Concern
+ include ::Gitlab::Utils::StrongMemoize
included do
# A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized.
@@ -31,27 +32,37 @@ module ResolvableDiscussion
end
def resolvable?
- @resolvable ||= potentially_resolvable? && notes.any?(&:resolvable?)
+ strong_memoize(:resolvable) do
+ potentially_resolvable? && notes.any?(&:resolvable?)
+ end
end
def resolved?
- @resolved ||= resolvable? && notes.none?(&:to_be_resolved?)
+ strong_memoize(:resolved) do
+ resolvable? && notes.none?(&:to_be_resolved?)
+ end
end
def first_note
- @first_note ||= notes.first
+ strong_memoize(:first_note) do
+ notes.first
+ end
end
def first_note_to_resolve
return unless resolvable?
- @first_note_to_resolve ||= notes.find(&:to_be_resolved?) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ strong_memoize(:first_note_to_resolve) do
+ notes.find(&:to_be_resolved?)
+ end
end
def last_resolved_note
return unless resolved?
- @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ strong_memoize(:last_resolved_note) do
+ resolved_notes.sort_by(&:resolved_at).last
+ end
end
def resolved_notes
@@ -91,10 +102,10 @@ module ResolvableDiscussion
yield(notes_relation)
# Set the notes array to the updated notes
- @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @notes = notes_relation.fresh.auto_include(false).to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
- self.class.memoized_values.each do |var|
- instance_variable_set(:"@#{var}", nil)
+ self.class.memoized_values.each do |name|
+ clear_memoization(name)
end
end
end
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index 5c1cce98ad4..dfd7d94450b 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -7,11 +7,12 @@ module Routable
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
scope :with_route, -> { includes(:route) }
+ after_validation :set_path_errors
+
before_validation do
if full_path_changed? || full_name_changed?
prepare_route
@@ -125,6 +126,11 @@ module Routable
private
+ def set_path_errors
+ route_path_errors = self.errors.delete(:"route.path")
+ self.errors[:path].concat(route_path_errors) if route_path_errors
+ end
+
def uncached_full_path
if route && route.path.present?
@full_path ||= route.path # rubocop:disable Gitlab/ModuleWithInstanceVariables
diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb
index 67ecf470f7e..703a72c355c 100644
--- a/app/models/concerns/sha_attribute.rb
+++ b/app/models/concerns/sha_attribute.rb
@@ -3,6 +3,7 @@ module ShaAttribute
module ClassMethods
def sha_attribute(name)
+ return if ENV['STATIC_VERIFICATION']
return unless table_exists?
column = columns.find { |c| c.name == name.to_s }
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index b3020484738..f05e606995d 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -7,25 +7,24 @@ module Storage
raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry')
end
- expires_full_path_cache
+ parent_was = if parent_changed? && parent_id_was.present?
+ Namespace.find(parent_id_was) # raise NotFound early if needed
+ 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)
+ expires_full_path_cache
- 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}"
+ move_repositories
- # 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
+ if parent_changed?
+ former_parent_full_path = parent_was&.full_path
+ parent_full_path = parent&.full_path
+ Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
+ Gitlab::PagesTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
+ else
+ Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path)
+ Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path)
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
@@ -34,6 +33,8 @@ module Storage
# So we basically we mute exceptions in next actions
begin
send_update_instructions
+ write_projects_repository_config
+
true
rescue
# Returning false does not rollback after_* transaction but gives
@@ -51,6 +52,26 @@ module Storage
private
+ def move_repositories
+ # 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)
+
+ # Ensure new directory exists before moving it (if there's a parent)
+ gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent
+
+ 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
+ end
+
def old_repository_storage_paths
@old_repository_storage_paths ||= repository_storage_paths
end
@@ -85,20 +106,10 @@ module Storage
remove_exports!
end
- def remove_exports!
- Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
- end
+ def remove_legacy_exports!
+ legacy_export_path = File.join(Gitlab::ImportExport.storage_path, full_path_was)
- 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
+ FileUtils.rm_rf(legacy_export_path)
end
end
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index d07041c2fdf..ccd6f0e0a7d 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -9,13 +9,13 @@ require 'task_list/filter'
module Taskable
COMPLETED = 'completed'.freeze
INCOMPLETE = 'incomplete'.freeze
- ITEM_PATTERN = /
+ ITEM_PATTERN = %r{
^
\s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list
\s+ # whitespace prefix has to be always presented for a list item
(\[\s\]|\[[xX]\]) # checkbox
(\s.+) # followed by whitespace and some text.
- /x
+ }x
def self.get_tasks(content)
content.to_s.scan(ITEM_PATTERN).map do |checkbox, label|
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
new file mode 100644
index 00000000000..ec0ed3b795a
--- /dev/null
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -0,0 +1,40 @@
+module TriggerableHooks
+ AVAILABLE_TRIGGERS = {
+ repository_update_hooks: :repository_update_events,
+ 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
+
+ extend ActiveSupport::Concern
+
+ class_methods do
+ attr_reader :triggerable_hooks
+
+ attr_reader :triggers
+
+ def hooks_for(trigger)
+ callable_scopes = triggers.keys + [:all]
+ return none unless callable_scopes.include?(trigger)
+
+ public_send(trigger) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ private
+
+ def triggerable_hooks(hooks)
+ triggers = AVAILABLE_TRIGGERS.slice(*hooks)
+ @triggers = triggers
+
+ triggers.each do |trigger, event|
+ scope trigger, -> { where(event => true) }
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/updated_at_filterable.rb b/app/models/concerns/updated_at_filterable.rb
new file mode 100644
index 00000000000..edb423b7828
--- /dev/null
+++ b/app/models/concerns/updated_at_filterable.rb
@@ -0,0 +1,12 @@
+module UpdatedAtFilterable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :updated_before, ->(date) { where(scoped_table[:updated_at].lteq(date)) }
+ scope :updated_after, ->(date) { where(scoped_table[:updated_at].gteq(date)) }
+
+ def self.scoped_table
+ arel_table.alias(table_name)
+ end
+ end
+end
diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb
index d2e626c22e8..b34d1382d43 100644
--- a/app/models/cycle_analytics.rb
+++ b/app/models/cycle_analytics.rb
@@ -6,6 +6,12 @@ class CycleAnalytics
@options = options
end
+ def all_medians_per_stage
+ STAGES.each_with_object({}) do |stage_name, medians_per_stage|
+ medians_per_stage[stage_name] = self[stage_name].median
+ end
+ end
+
def summary
@summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(@project,
from: @options[:from],
diff --git a/app/models/cycle_analytics/summary.rb b/app/models/cycle_analytics/summary.rb
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/models/cycle_analytics/summary.rb
+++ /dev/null
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index eae5eee4fee..858b7ef533e 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -1,10 +1,16 @@
class DeployKey < Key
- has_many :deploy_keys_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :projects, through: :deploy_keys_projects
+ include IgnorableColumn
+
+ has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :projects, -> { auto_include(false) }, through: :deploy_keys_projects
scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) }
scope :are_public, -> { where(public: true) }
+ ignore_column :can_push
+
+ accepts_nested_attributes_for :deploy_keys_projects
+
def private?
!public?
end
@@ -21,11 +27,23 @@ class DeployKey < Key
self.private?
end
+ def user
+ super || User.ghost
+ end
+
def has_access_to?(project)
- projects.include?(project)
+ deploy_keys_project_for(project).present?
end
def can_push_to?(project)
- can_push? && has_access_to?(project)
+ !!deploy_keys_project_for(project)&.can_push?
+ end
+
+ def deploy_keys_project_for(project)
+ deploy_keys_projects.find_by(project: project)
+ end
+
+ def projects_with_write_access
+ Project.preload(:route).where(id: deploy_keys_projects.with_write_access.select(:project_id))
end
end
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index b37b9bfbdac..6eef12c4373 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -1,8 +1,14 @@
class DeployKeysProject < ActiveRecord::Base
belongs_to :project
- belongs_to :deploy_key
+ belongs_to :deploy_key, inverse_of: :deploy_keys_projects
- validates :deploy_key_id, presence: true
+ scope :without_project_deleted, -> { joins(:project).where(projects: { pending_delete: false }) }
+ scope :in_project, ->(project) { where(project: project) }
+ scope :with_write_access, -> { where(can_push: true) }
+
+ accepts_nested_attributes_for :deploy_key
+
+ validates :deploy_key, presence: true
validates :deploy_key_id, uniqueness: { scope: [:project_id], message: "already exists in project" }
validates :project_id, presence: true
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
new file mode 100644
index 00000000000..b47b2ff4c3f
--- /dev/null
+++ b/app/models/deploy_token.rb
@@ -0,0 +1,61 @@
+class DeployToken < ActiveRecord::Base
+ include Expirable
+ include TokenAuthenticatable
+ add_authentication_token_field :token
+
+ AVAILABLE_SCOPES = %i(read_repository read_registry).freeze
+
+ default_value_for(:expires_at) { Forever.date }
+
+ has_many :project_deploy_tokens, inverse_of: :deploy_token
+ has_many :projects, -> { auto_include(false) }, through: :project_deploy_tokens
+
+ validate :ensure_at_least_one_scope
+ before_save :ensure_token
+
+ accepts_nested_attributes_for :project_deploy_tokens
+
+ scope :active, -> { where("revoked = false AND expires_at >= NOW()") }
+
+ def revoke!
+ update!(revoked: true)
+ end
+
+ def active?
+ !revoked
+ end
+
+ def scopes
+ AVAILABLE_SCOPES.select { |token_scope| read_attribute(token_scope) }
+ end
+
+ def username
+ "gitlab+deploy-token-#{id}"
+ end
+
+ def has_access_to?(requested_project)
+ project == requested_project
+ end
+
+ # This is temporal. Currently we limit DeployToken
+ # to a single project, later we're going to extend
+ # that to be for multiple projects and namespaces.
+ def project
+ projects.first
+ end
+
+ def expires_at
+ expires_at = read_attribute(:expires_at)
+ expires_at != Forever.date ? expires_at : nil
+ end
+
+ def expires_at=(value)
+ write_attribute(:expires_at, value.presence || Forever.date)
+ end
+
+ private
+
+ def ensure_at_least_one_scope
+ errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry
+ end
+end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 7bcded5b5e1..e18ea8bfea4 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -1,8 +1,8 @@
class Deployment < ActiveRecord::Base
- include InternalId
+ include NonatomicInternalId
- belongs_to :project, required: true, validate: true
- belongs_to :environment, required: true, validate: true
+ belongs_to :project, required: true
+ belongs_to :environment, required: true
belongs_to :user
belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
@@ -45,14 +45,7 @@ class Deployment < ActiveRecord::Base
def includes_commit?(commit)
return false unless commit
- # Before 8.10, deployments didn't have keep-around refs. Any deployment
- # created before then could have a `sha` referring to a commit that no
- # longer exists in the repository, so just ignore those.
- begin
- project.repository.ancestor?(commit.id, sha)
- rescue Rugged::OdbError
- false
- end
+ project.repository.ancestor?(commit.id, sha)
end
def update_merge_request_metrics!
@@ -105,28 +98,29 @@ class Deployment < ActiveRecord::Base
end
def has_metrics?
- project.monitoring_service.present?
+ prometheus_adapter&.can_query?
end
def metrics
return {} unless has_metrics?
- project.monitoring_service.deployment_metrics(self)
- end
-
- def has_additional_metrics?
- project.prometheus_service.present?
+ metrics = prometheus_adapter.query(:deployment, self)
+ metrics&.merge(deployment_time: created_at.to_i) || {}
end
def additional_metrics
- return {} unless project.prometheus_service.present?
+ return {} unless has_metrics?
- metrics = project.prometheus_service.additional_deployment_metrics(self)
+ metrics = prometheus_adapter.query(:additional_metrics_deployment, self)
metrics&.merge(deployment_time: created_at.to_i) || {}
end
private
+ def prometheus_adapter
+ environment.prometheus_adapter
+ end
+
def ref_path
File.join(environment.ref_path, 'deployments', iid.to_s)
end
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index d67b16584a4..bd6af622bfb 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -23,8 +23,13 @@ class DiffDiscussion < Discussion
def merge_request_version_params
return unless for_merge_request?
+ version_params = get_params
+
+ return version_params unless on_merge_request_commit? && commit_id
+
+ version_params ||= {}
version_params.tap do |params|
- params[:commit_id] = commit_id if on_merge_request_commit?
+ params[:commit_id] = commit_id
end
end
@@ -37,7 +42,7 @@ class DiffDiscussion < Discussion
private
- def version_params
+ def get_params
return {} if active?
noteable.version_params_for(position.diff_refs)
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index b53d44cda95..15122cbc693 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -21,7 +21,7 @@ class DiffNote < Note
before_validation :set_original_position, on: :create
before_validation :update_position, on: :create, if: :on_text?
- before_validation :set_line_code
+ before_validation :set_line_code, if: :on_text?
after_save :keep_around_commits
def discussion_class(*)
@@ -61,10 +61,6 @@ class DiffNote < Note
@diff_line ||= diff_file&.line_for_position(self.original_position)
end
- def for_line?(line)
- diff_file.position(line) == self.original_position
- end
-
def original_line_code
return unless on_text?
diff --git a/app/models/environment.rb b/app/models/environment.rb
index bf69b4c50f0..fddb269af4b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -4,7 +4,7 @@ class Environment < ActiveRecord::Base
NUMBERS = '0'..'9'
SUFFIX_CHARS = LETTERS.to_a + NUMBERS.to_a
- belongs_to :project, required: true, validate: true
+ belongs_to :project, required: true
has_many :deployments, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -65,10 +65,9 @@ class Environment < ActiveRecord::Base
end
def predefined_variables
- [
- { key: 'CI_ENVIRONMENT_NAME', value: name, public: true },
- { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true }
- ]
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_ENVIRONMENT_NAME', value: name)
+ .append(key: 'CI_ENVIRONMENT_SLUG', value: slug)
end
def recently_updated_on_branch?(ref)
@@ -99,8 +98,8 @@ class Environment < ActiveRecord::Base
folder_name == "production"
end
- def first_deployment_for(commit)
- ref = project.repository.ref_name_for_sha(ref_path, commit.sha)
+ def first_deployment_for(commit_sha)
+ ref = project.repository.ref_name_for_sha(ref_path, commit_sha)
return nil unless ref
@@ -115,7 +114,7 @@ class Environment < ActiveRecord::Base
def formatted_external_url
return nil unless external_url
- external_url.gsub(/\A.*?:\/\//, '')
+ external_url.gsub(%r{\A.*?://}, '')
end
def stop_action?
@@ -146,21 +145,19 @@ class Environment < ActiveRecord::Base
end
def has_metrics?
- project.monitoring_service.present? && available? && last_deployment.present?
+ prometheus_adapter&.can_query? && available? && last_deployment.present?
end
def metrics
- project.monitoring_service.environment_metrics(self) if has_metrics?
+ prometheus_adapter.query(:environment, self) if has_metrics?
end
- def has_additional_metrics?
- project.prometheus_service.present? && available? && last_deployment.present?
+ def additional_metrics
+ prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics?
end
- def additional_metrics
- if has_additional_metrics?
- project.prometheus_service.additional_environment_metrics(self)
- end
+ def prometheus_adapter
+ @prometheus_adapter ||= Prometheus::AdapterService.new(project, deployment_platform).prometheus_adapter
end
def slug
@@ -226,6 +223,10 @@ class Environment < ActiveRecord::Base
self.environment_type || self.name
end
+ def deployment_platform
+ project.deployment_platform(environment: self.name)
+ 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 6053594fab5..3805f6cf857 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -48,12 +48,24 @@ class Event < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :project
- belongs_to :target, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+
+ belongs_to :target, -> {
+ # If the association for "target" defines an "author" association we want to
+ # eager-load this so Banzai & friends don't end up performing N+1 queries to
+ # get the authors of notes, issues, etc. (likewise for "noteable").
+ incs = %i(author noteable).select do |a|
+ reflections['events'].active_record.reflect_on_association(a)
+ end
+
+ incs.reduce(self) { |obj, a| obj.includes(a) }
+ }, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+
has_one :push_event_payload
# Callbacks
after_create :reset_project_activity
after_create :set_last_repository_updated_at, if: :push?
+ after_create :track_user_interacted_projects
# Scopes
scope :recent, -> { reorder(id: :desc) }
@@ -72,7 +84,7 @@ class Event < ActiveRecord::Base
# 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(:push_event_payload, target: :author)
+ .preload(:target, :push_event_payload)
end
scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) }
@@ -85,10 +97,6 @@ class Event < ActiveRecord::Base
self.inheritance_column = 'action'
- # "data" will be removed in 10.0 but it may be possible that JOINs happen that
- # include this column, hence we're ignoring it as well.
- ignore_column :data
-
class << self
def model_name
ActiveModel::Name.new(self, nil, 'event')
@@ -151,7 +159,7 @@ class Event < ActiveRecord::Base
def project_name
if project
- project.name_with_namespace
+ project.full_name
else
"(deleted project)"
end
@@ -382,4 +390,11 @@ class Event < ActiveRecord::Base
Project.unscoped.where(id: project_id)
.update_all(last_repository_updated_at: created_at)
end
+
+ def track_user_interacted_projects
+ # Note the call to .available? is due to earlier migrations
+ # that would otherwise conflict with the call to .track
+ # (because the table does not exist yet).
+ UserInteractedProject.track(self) if UserInteractedProject.available?
+ end
end
diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb
index 2aaba2e4c90..282fd7edcb7 100644
--- a/app/models/external_issue.rb
+++ b/app/models/external_issue.rb
@@ -39,7 +39,7 @@ class ExternalIssue
end
def to_reference(_from = nil, full: nil)
- id
+ reference_link_text
end
def reference_link_text(from = nil)
diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb
index 7f1728e8c77..aad3509b895 100644
--- a/app/models/fork_network.rb
+++ b/app/models/fork_network.rb
@@ -1,7 +1,7 @@
class ForkNetwork < ActiveRecord::Base
belongs_to :root_project, class_name: 'Project'
has_many :fork_network_members
- has_many :projects, through: :fork_network_members
+ has_many :projects, -> { auto_include(false) }, through: :fork_network_members
after_create :add_root_as_member, if: :root_project
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index c0864769314..dc2f6817190 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -44,10 +44,10 @@ class GlobalMilestone
def self.group_milestones_states_count(group)
return STATE_COUNT_HASH unless group
- params = { group_ids: [group.id], state: 'all', order: nil }
+ params = { group_ids: [group.id], state: 'all' }
relation = MilestonesFinder.new(params).execute
- grouped_by_state = relation.group(:state).count
+ grouped_by_state = relation.reorder(nil).group(:state).count
{
opened: grouped_by_state['active'] || 0,
@@ -60,10 +60,10 @@ class GlobalMilestone
def self.legacy_group_milestone_states_count(projects)
return STATE_COUNT_HASH unless projects
- params = { project_ids: projects.map(&:id), state: 'all', order: nil }
+ params = { project_ids: projects.map(&:id), state: 'all' }
relation = MilestonesFinder.new(params).execute
- project_milestones_by_state_and_title = relation.group(:state, :title).count
+ project_milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count
opened = count_by_state(project_milestones_by_state_and_title, 'active')
closed = count_by_state(project_milestones_by_state_and_title, 'closed')
diff --git a/app/models/group.rb b/app/models/group.rb
index fddace03387..202988d743d 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -12,9 +12,9 @@ class Group < Namespace
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 :users, -> { auto_include(false) }, through: :group_members
has_many :owners,
- -> { where(members: { access_level: Gitlab::Access::OWNER }) },
+ -> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) },
through: :group_members,
source: :user
@@ -23,24 +23,26 @@ class Group < Namespace
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 :shared_projects, -> { auto_include(false) }, through: :project_group_links, source: :project
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'
has_many :custom_attributes, class_name: 'GroupCustomAttribute'
- validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+
+ has_many :boards
+ has_many :badges, class_name: 'GroupBadge'
+
+ accepts_nested_attributes_for :variables, allow_destroy: true
+
validate :visibility_level_allowed_by_projects
validate :visibility_level_allowed_by_sub_groups
validate :visibility_level_allowed_by_parent
-
- validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
+ validates :variables, variable_duplicates: true
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 # rubocop:disable Cop/ActiveRecordDependent
-
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
@@ -51,7 +53,7 @@ class Group < Namespace
Gitlab::Database.postgresql?
end
- def sort(method)
+ def sort_by_attribute(method)
if method == 'storage_size_desc'
# storage_size is a virtual column so we need to
# pass a string to avoid AR adding the table name
@@ -116,12 +118,6 @@ class Group < Namespace
visibility_level_allowed_by_sub_groups?(level)
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?
return false unless Gitlab.config.lfs.enabled
return Gitlab.config.lfs.enabled if self[:lfs_enabled].nil?
@@ -193,12 +189,6 @@ class Group < Namespace
owners.include?(user) && owners.size == 1
end
- def avatar_type
- unless self.avatar.image?
- self.errors.add :avatar, "only images allowed"
- end
- end
-
def post_create_hook
Gitlab::AppLogger.info("Group \"#{name}\" was created")
@@ -234,13 +224,13 @@ class Group < Namespace
end
GroupMember
- .active_without_invites
+ .active_without_invites_and_requests
.where(source_id: source_ids)
end
def members_with_descendants
GroupMember
- .active_without_invites
+ .active_without_invites_and_requests
.where(source_id: self_and_descendants.reorder(nil).select(:id))
end
@@ -284,12 +274,6 @@ class Group < Namespace
list_of_ids.reverse.map { |group| variables[group.id] }.compact.flatten
end
- def full_path_was
- return path_was unless has_parent?
-
- "#{parent.full_path}/#{path_was}"
- end
-
def group_member(user)
if group_members.loaded?
group_members.find { |gm| gm.user_id == user.id }
@@ -302,6 +286,10 @@ class Group < Namespace
false
end
+ def refresh_project_authorizations
+ refresh_members_authorized_projects(blocking: false)
+ end
+
private
def update_two_factor_requirement
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index a8c424a6614..ec072882cc9 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -1,19 +1,18 @@
class ProjectHook < WebHook
- 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
+ include TriggerableHooks
- TRIGGERS.each do |trigger, event|
- scope trigger, -> { where(event => true) }
- end
+ triggerable_hooks [
+ :push_hooks,
+ :tag_push_hooks,
+ :issue_hooks,
+ :confidential_issue_hooks,
+ :note_hooks,
+ :confidential_note_hooks,
+ :merge_request_hooks,
+ :job_hooks,
+ :pipeline_hooks,
+ :wiki_page_hooks
+ ]
belongs_to :project
validates :project, presence: true
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index 180c479c41b..0528266e5b3 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -1,14 +1,14 @@
class SystemHook < WebHook
- TRIGGERS = {
- repository_update_hooks: :repository_update_events,
- push_hooks: :push_events,
- tag_push_hooks: :tag_push_events
- }.freeze
+ include TriggerableHooks
- TRIGGERS.each do |trigger, event|
- scope trigger, -> { where(event => true) }
- end
+ triggerable_hooks [
+ :repository_update_hooks,
+ :push_hooks,
+ :tag_push_hooks,
+ :merge_request_hooks
+ ]
default_value_for :push_events, false
default_value_for :repository_update_events, true
+ default_value_for :merge_requests_events, false
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 5a70e114f56..27729deeac9 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -4,6 +4,7 @@ class WebHook < ActiveRecord::Base
has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :url, presence: true, url: true
+ validates :token, format: { without: /\n/ }
def execute(data, hook_name)
WebHookService.new(self, data, hook_name).execute
diff --git a/app/models/identity.rb b/app/models/identity.rb
index b3fa7d8176a..1011b9f1109 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -9,6 +9,7 @@ class Identity < ActiveRecord::Base
validates :user_id, uniqueness: { scope: :provider }
before_save :ensure_normalized_extern_uid, if: :extern_uid_changed?
+ after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider?
scope :with_provider, ->(provider) { where(provider: provider) }
scope :with_extern_uid, ->(provider, extern_uid) do
@@ -16,12 +17,12 @@ class Identity < ActiveRecord::Base
end
def ldap?
- Gitlab::OAuth::Provider.ldap_provider?(provider)
+ Gitlab::Auth::OAuth::Provider.ldap_provider?(provider)
end
def self.normalize_uid(provider, uid)
- if Gitlab::OAuth::Provider.ldap_provider?(provider)
- Gitlab::LDAP::Person.normalize_dn(uid)
+ if Gitlab::Auth::OAuth::Provider.ldap_provider?(provider)
+ Gitlab::Auth::LDAP::Person.normalize_dn(uid)
else
uid.to_s
end
@@ -34,4 +35,12 @@ class Identity < ActiveRecord::Base
self.extern_uid = Identity.normalize_uid(self.provider, self.extern_uid)
end
+
+ def user_synced_attributes_metadata_from_provider?
+ user.user_synced_attributes_metadata&.provider == provider
+ end
+
+ def clear_user_synced_attributes
+ user.user_synced_attributes_metadata&.destroy
+ end
end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
new file mode 100644
index 00000000000..cbec735c2dd
--- /dev/null
+++ b/app/models/internal_id.rb
@@ -0,0 +1,125 @@
+# An InternalId is a strictly monotone sequence of integers
+# generated for a given scope and usage.
+#
+# For example, issues use their project to scope internal ids:
+# In that sense, scope is "project" and usage is "issues".
+# Generated internal ids for an issue are unique per project.
+#
+# See InternalId#usage enum for available usages.
+#
+# In order to leverage InternalId for other usages, the idea is to
+# * Add `usage` value to enum
+# * (Optionally) add columns to `internal_ids` if needed for scope.
+class InternalId < ActiveRecord::Base
+ belongs_to :project
+
+ enum usage: { issues: 0 }
+
+ validates :usage, presence: true
+
+ REQUIRED_SCHEMA_VERSION = 20180305095250
+
+ # Increments #last_value and saves the record
+ #
+ # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
+ # As such, the increment is atomic and safe to be called concurrently.
+ def increment_and_save!
+ lock!
+ self.last_value = (last_value || 0) + 1
+ save!
+ last_value
+ end
+
+ class << self
+ def generate_next(subject, scope, usage, init)
+ # Shortcut if `internal_ids` table is not available (yet)
+ # This can be the case in other (unrelated) migration specs
+ return (init.call(subject) || 0) + 1 unless available?
+
+ InternalIdGenerator.new(subject, scope, usage, init).generate
+ end
+
+ def available?
+ @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization
+ end
+
+ # Flushes cached information about schema
+ def reset_column_information
+ @available_flag = nil
+ super
+ end
+ end
+
+ class InternalIdGenerator
+ # Generate next internal id for a given scope and usage.
+ #
+ # For currently supported usages, see #usage enum.
+ #
+ # The method implements a locking scheme that has the following properties:
+ # 1) Generated sequence of internal ids is unique per (scope and usage)
+ # 2) The method is thread-safe and may be used in concurrent threads/processes.
+ # 3) The generated sequence is gapless.
+ # 4) In the absence of a record in the internal_ids table, one will be created
+ # and last_value will be calculated on the fly.
+ #
+ # subject: The instance we're generating an internal id for. Gets passed to init if called.
+ # scope: Attributes that define the scope for id generation.
+ # usage: Symbol to define the usage of the internal id, see InternalId.usages
+ # init: Block that gets called to initialize InternalId record if not present
+ # Make sure to not throw exceptions in the absence of records (if this is expected).
+ attr_reader :subject, :scope, :init, :scope_attrs, :usage
+
+ def initialize(subject, scope, usage, init)
+ @subject = subject
+ @scope = scope
+ @init = init
+ @usage = usage
+
+ raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
+
+ unless InternalId.usages.has_key?(usage.to_s)
+ raise ArgumentError, "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages"
+ end
+ end
+
+ # Generates next internal id and returns it
+ def generate
+ subject.transaction do
+ # Create a record in internal_ids if one does not yet exist
+ # and increment its last value
+ #
+ # Note this will acquire a ROW SHARE lock on the InternalId record
+ (lookup || create_record).increment_and_save!
+ end
+ end
+
+ private
+
+ # Retrieve InternalId record for (project, usage) combination, if it exists
+ def lookup
+ InternalId.find_by(**scope, usage: usage_value)
+ end
+
+ def usage_value
+ @usage_value ||= InternalId.usages[usage.to_s]
+ end
+
+ # Create InternalId record for (scope, usage) combination, if it doesn't exist
+ #
+ # We blindly insert without synchronization. If another process
+ # was faster in doing this, we'll realize once we hit the unique key constraint
+ # violation. We can safely roll-back the nested transaction and perform
+ # a lookup instead to retrieve the record.
+ def create_record
+ subject.transaction(requires_new: true) do
+ InternalId.create!(
+ **scope,
+ usage: usage_value,
+ last_value: init.call(subject) || 0
+ )
+ end
+ rescue ActiveRecord::RecordNotUnique
+ lookup
+ end
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index dc64888b6fc..c1ffe6512ea 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -1,7 +1,7 @@
require 'carrierwave/orm/activerecord'
class Issue < ActiveRecord::Base
- include InternalId
+ include AtomicInternalId
include Issuable
include Noteable
include Referable
@@ -12,7 +12,7 @@ class Issue < ActiveRecord::Base
include ThrottledTouch
include IgnorableColumn
- ignore_column :assignee_id, :branch_name
+ ignore_column :assignee_id, :branch_name, :deleted_at
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -23,6 +23,9 @@ class Issue < ActiveRecord::Base
belongs_to :project
belongs_to :moved_to, class_name: 'Issue'
+ belongs_to :closed_by, class_name: 'User'
+
+ has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) }
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -31,10 +34,12 @@ class Issue < ActiveRecord::Base
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees
- has_many :assignees, class_name: "User", through: :issue_assignees
+ has_many :assignees, -> { auto_include(false) }, class_name: "User", through: :issue_assignees
validates :project, presence: true
+ alias_attribute :parent_ids, :project_id
+
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
@@ -74,9 +79,16 @@ class Issue < ActiveRecord::Base
before_transition any => :closed do |issue|
issue.closed_at = Time.zone.now
end
+
+ before_transition closed: :opened do |issue|
+ issue.closed_at = nil
+ issue.closed_by = nil
+ end
end
- acts_as_paranoid
+ class << self
+ alias_method :in_parents, :in_projects
+ end
def self.reference_prefix
'#'
@@ -104,7 +116,7 @@ class Issue < ActiveRecord::Base
'project_id'
end
- def self.sort(method, excluded_labels: [])
+ def self.sort_by_attribute(method, excluded_labels: [])
case method.to_s
when 'due_date' then order_due_date_asc
when 'due_date_asc' then order_due_date_asc
@@ -155,7 +167,18 @@ class Issue < ActiveRecord::Base
object.all_references(current_user, extractor: ext)
end
- ext.merge_requests.sort_by(&:iid)
+ merge_requests = ext.merge_requests.sort_by(&:iid)
+
+ cross_project_filter = -> (merge_requests) do
+ merge_requests.select { |mr| mr.target_project == project }
+ end
+
+ Ability.merge_requests_readable_by_user(
+ merge_requests, current_user,
+ filters: {
+ read_cross_project: cross_project_filter
+ }
+ )
end
# All branches containing the current issue's ID, except for
@@ -249,11 +272,17 @@ class Issue < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
- if options.key?(:sidebar_endpoints) && project
+ if options.key?(:issue_endpoints) && project
url_helper = Gitlab::Routing.url_helpers
- json.merge!(issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'),
- toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self))
+ issue_reference = options[:include_full_project_path] ? to_reference(full: true) : to_reference
+
+ json.merge!(
+ reference_path: issue_reference,
+ real_path: url_helper.project_issue_path(project, self),
+ issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'),
+ toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self)
+ )
end
if options.key?(:labels)
@@ -276,6 +305,11 @@ class Issue < ActiveRecord::Base
private
+ def ensure_metrics
+ super
+ metrics.record!
+ end
+
# Returns `true` if the given User can read the current Issue.
#
# This method duplicates the same check of issue_policy.rb
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
index 06d760b6a89..326b9eb7ad5 100644
--- a/app/models/issue_assignee.rb
+++ b/app/models/issue_assignee.rb
@@ -1,6 +1,4 @@
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/key.rb b/app/models/key.rb
index a3f8a5d6dc7..ae5769c0627 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,7 +1,6 @@
require 'digest/md5'
class Key < ActiveRecord::Base
- include Gitlab::CurrentSettings
include AfterCommitQueue
include Sortable
@@ -34,9 +33,8 @@ class Key < ActiveRecord::Base
after_destroy :refresh_user_cache
def key=(value)
- value&.delete!("\n\r")
- value.strip! unless value.blank?
- write_attribute(:key, value)
+ write_attribute(:key, value.present? ? Gitlab::SSHPublicKey.sanitize(value) : nil)
+
@public_key = nil
end
@@ -98,13 +96,13 @@ class Key < ActiveRecord::Base
def generate_fingerprint
self.fingerprint = nil
- return unless self.key.present?
+ return unless public_key.valid?
self.fingerprint = public_key.fingerprint
end
def key_meets_restrictions
- restriction = current_application_settings.key_restriction_for(public_key.type)
+ restriction = Gitlab::CurrentSettings.key_restriction_for(public_key.type)
if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE
errors.add(:key, forbidden_key_type_message)
@@ -115,7 +113,7 @@ class Key < ActiveRecord::Base
def forbidden_key_type_message
allowed_types =
- current_application_settings
+ Gitlab::CurrentSettings
.allowed_key_types
.map(&:upcase)
.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
diff --git a/app/models/label.rb b/app/models/label.rb
index b5bfa6ea2dd..f3496884cff 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -18,8 +18,8 @@ class Label < ActiveRecord::Base
has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :priorities, class_name: 'LabelPriority'
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'
+ has_many :issues, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'Issue'
+ has_many :merge_requests, -> { auto_include(false) }, through: :label_links, source: :target, source_type: 'MergeRequest'
before_validation :strip_whitespace_from_title_and_color
@@ -35,6 +35,7 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) }
scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
+ scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
def self.prioritized(project)
@@ -132,6 +133,7 @@ class Label < ActiveRecord::Base
else
priorities.find_by(project: project)
end
+
priority.try(:priority)
end
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index c36be956ff0..d90cafd14b4 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -38,11 +38,7 @@ class LegacyDiffNote < Note
end
def diff_line
- @diff_line ||= diff_file.line_for_line_code(self.line_code) if diff_file
- end
-
- def for_line?(line)
- line.discussable? && diff_file.line_code(line) == self.line_code
+ @diff_line ||= diff_file&.line_for_line_code(self.line_code)
end
def original_line_code
diff --git a/app/models/lfs_file_lock.rb b/app/models/lfs_file_lock.rb
new file mode 100644
index 00000000000..50bb6ca382d
--- /dev/null
+++ b/app/models/lfs_file_lock.rb
@@ -0,0 +1,12 @@
+class LfsFileLock < ActiveRecord::Base
+ belongs_to :project
+ belongs_to :user
+
+ validates :project_id, :user_id, :path, presence: true
+
+ def can_be_unlocked_by?(current_user, forced = false)
+ return true if current_user.id == user_id
+
+ forced && current_user.can?(:admin_project, project)
+ end
+end
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index fc586fa216e..ed95613ee59 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -1,18 +1,37 @@
class LfsObject < ActiveRecord::Base
+ include AfterCommitQueue
+ include ObjectStorage::BackgroundMove
+
has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :projects, through: :lfs_objects_projects
+ has_many :projects, -> { auto_include(false) }, through: :lfs_objects_projects
+
+ scope :with_files_stored_locally, -> { where(file_store: [nil, LfsObjectUploader::Store::LOCAL]) }
validates :oid, presence: true, uniqueness: true
mount_uploader :file, LfsObjectUploader
+ before_save :update_file_store
+
+ def update_file_store
+ self.file_store = file.object_store
+ end
+
def project_allowed_access?(project)
projects.exists?(project.lfs_storage_project.id)
end
+ def local_store?
+ [nil, LfsObjectUploader::Store::LOCAL].include?(self.file_store)
+ end
+
def self.destroy_unreferenced
joins("LEFT JOIN lfs_objects_projects ON lfs_objects_projects.lfs_object_id = #{table_name}.id")
.where(lfs_objects_projects: { id: nil })
.destroy_all
end
+
+ def self.calculate_oid(path)
+ Digest::SHA256.file(path).hexdigest
+ end
end
diff --git a/app/models/member.rb b/app/models/member.rb
index c47145667b5..eac4a22a03f 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -52,10 +52,10 @@ class Member < ActiveRecord::Base
end
# Like active, but without invites. For when a User is required.
- scope :active_without_invites, -> do
+ scope :active_without_invites_and_requests, -> do
left_join_users
.where(users: { state: 'active' })
- .where(requested_at: nil)
+ .non_request
.reorder(nil)
end
@@ -85,6 +85,7 @@ class Member < ActiveRecord::Base
after_create :create_notification_setting, unless: [:pending?, :importing?]
after_create :post_create_hook, unless: [:pending?, :importing?]
after_update :post_update_hook, unless: [:pending?, :importing?]
+ after_destroy :destroy_notification_setting
after_destroy :post_destroy_hook, unless: :pending?
after_commit :refresh_member_authorized_projects
@@ -95,7 +96,7 @@ class Member < ActiveRecord::Base
joins(:user).merge(User.search(query))
end
- def sort(method)
+ def sort_by_attribute(method)
case method.to_s
when 'access_level_asc' then reorder(access_level: :asc)
when 'access_level_desc' then reorder(access_level: :desc)
@@ -128,7 +129,7 @@ class Member < ActiveRecord::Base
find_by(invite_token: invite_token)
end
- def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil)
+ def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil, ldap: false)
# `user` can be either a User object, User ID or an email to be invited
member = retrieve_member(source, user, existing_members)
access_level = retrieve_access_level(access_level)
@@ -143,11 +144,13 @@ class Member < ActiveRecord::Base
if member.request?
::Members::ApproveAccessRequestService.new(
- source,
current_user,
- id: member.id,
access_level: access_level
- ).execute
+ ).execute(
+ member,
+ skip_authorization: ldap,
+ skip_log_audit_event: ldap
+ )
else
member.save
end
@@ -313,8 +316,12 @@ class Member < ActiveRecord::Base
user.notification_settings.find_or_create_for(source)
end
+ def destroy_notification_setting
+ notification_setting&.destroy
+ end
+
def notification_setting
- @notification_setting ||= user.notification_settings_for(source)
+ @notification_setting ||= user&.notification_settings_for(source)
end
def notifiable?(type, opts = {})
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index b6f1dd272cd..1c7ed4a96df 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -13,8 +13,6 @@ class ProjectMember < Member
scope :in_project, ->(project) { where(source_id: project.id) }
- before_destroy :delete_member_todos
-
class << self
# Add users to projects with passed access option
#
@@ -93,10 +91,6 @@ class ProjectMember < Member
private
- def delete_member_todos
- user.todos.where(project_id: source_id).destroy_all if user
- end
-
def send_invite
notification_service.invite_project_member(self, @raw_invite_token)
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index c39789b047d..91d8be5559b 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1,5 +1,5 @@
class MergeRequest < ActiveRecord::Base
- include InternalId
+ include NonatomicInternalId
include Issuable
include Noteable
include Referable
@@ -11,7 +11,8 @@ class MergeRequest < ActiveRecord::Base
include Gitlab::Utils::StrongMemoize
ignore_column :locked_at,
- :ref_fetched
+ :ref_fetched,
+ :deleted_at
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
@@ -139,7 +140,9 @@ class MergeRequest < ActiveRecord::Base
scope :merged, -> { with_state(:merged) }
scope :closed_and_merged, -> { with_states(:closed, :merged) }
scope :from_source_branches, ->(branches) { where(source_branch: branches) }
-
+ scope :by_commit_sha, ->(sha) do
+ where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil)
+ end
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
scope :assigned, -> { where("assignee_id IS NOT NULL") }
@@ -150,12 +153,19 @@ class MergeRequest < ActiveRecord::Base
after_save :keep_around_commit
- acts_as_paranoid
-
def self.reference_prefix
'!'
end
+ def rebase_in_progress?
+ strong_memoize(:rebase_in_progress) do
+ # The source project can be deleted
+ next false unless source_project
+
+ source_project.repository.rebase_in_progress?(id)
+ end
+ end
+
# Use this method whenever you need to make sure the head_pipeline is synced with the
# branch head commit, for example checking if a merge request can be merged.
# For more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/40004
@@ -365,15 +375,27 @@ class MergeRequest < ActiveRecord::Base
end
def diff_start_sha
- diff_start_commit.try(:sha)
+ if persisted?
+ merge_request_diff.start_commit_sha
+ else
+ target_branch_head.try(:sha)
+ end
end
def diff_base_sha
- diff_base_commit.try(:sha)
+ if persisted?
+ merge_request_diff.base_commit_sha
+ else
+ branch_merge_base_commit.try(:sha)
+ end
end
def diff_head_sha
- diff_head_commit.try(:sha)
+ if persisted?
+ merge_request_diff.head_commit_sha
+ else
+ source_branch_head.try(:sha)
+ end
end
# When importing a pull request from GitHub, the old and new branches may no
@@ -514,18 +536,25 @@ class MergeRequest < ActiveRecord::Base
merge_request_diff(true)
end
+ def viewable_diffs
+ @viewable_diffs ||= merge_request_diffs.viewable.to_a
+ end
+
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
- 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
+ matcher =
+ if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs)
+ {
+ 'start_commit_sha' => diff_refs_or_sha.start_sha,
+ 'head_commit_sha' => diff_refs_or_sha.head_sha,
+ 'base_commit_sha' => diff_refs_or_sha.base_sha
+ }
+ else
+ { 'head_commit_sha' => diff_refs_or_sha }
+ end
- @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha]
+ viewable_diffs.find do |diff|
+ diff.attributes.slice(*matcher.keys) == matcher
+ end
end
def version_params_for(diff_refs)
@@ -557,9 +586,10 @@ class MergeRequest < ActiveRecord::Base
return unless open?
old_diff_refs = self.diff_refs
+ new_diff = create_merge_request_diff
+
+ MergeRequests::MergeRequestDiffCacheService.new.execute(self, new_diff)
- create_merge_request_diff
- MergeRequests::MergeRequestDiffCacheService.new.execute(self)
new_diff_refs = self.diff_refs
update_diff_discussion_positions(
@@ -607,15 +637,15 @@ class MergeRequest < ActiveRecord::Base
check_if_can_be_merged
- can_be_merged?
+ can_be_merged? && !should_be_rebased?
end
- def mergeable_state?(skip_ci_check: false)
+ def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
return false unless open?
return false if work_in_progress?
return false if broken?
return false unless skip_ci_check || mergeable_ci_state?
- return false unless mergeable_discussions_state?
+ return false unless skip_discussions_check || mergeable_discussions_state?
true
end
@@ -636,7 +666,7 @@ class MergeRequest < ActiveRecord::Base
!ProtectedBranch.protected?(source_project, source_branch) &&
!source_project.root_ref?(source_branch) &&
Ability.allowed?(current_user, :push_code, source_project) &&
- diff_head_commit == source_branch_head
+ diff_head_sha == source_branch_head.try(:sha)
end
def should_remove_source_branch?
@@ -787,6 +817,7 @@ class MergeRequest < ActiveRecord::Base
if !include_description && closes_issues_references.present?
message << "Closes #{closes_issues_references.to_sentence}"
end
+
message << "#{description}" if include_description && description.present?
message << "See merge request #{to_reference(full: true)}"
@@ -842,7 +873,7 @@ class MergeRequest < ActiveRecord::Base
def can_be_merged_by?(user)
access = ::Gitlab::UserAccess.new(user, project: project)
- access.can_push_to_branch?(target_branch) || access.can_merge_to_branch?(target_branch)
+ access.can_update_branch?(target_branch)
end
def can_be_merged_via_command_line_by?(user)
@@ -975,7 +1006,22 @@ class MergeRequest < ActiveRecord::Base
end
def can_be_reverted?(current_user)
- merge_commit && !merge_commit.has_been_reverted?(current_user, self)
+ return false unless merge_commit
+
+ merged_at = metrics&.merged_at
+ notes_association = notes_with_associations
+
+ if merged_at
+ # It is not guaranteed that Note#created_at will be strictly later than
+ # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this
+ # comparison, as will a HA environment if clocks are not *precisely*
+ # synchronized. Add a minute's leeway to compensate for both possibilities
+ cutoff = merged_at - 1.minute
+
+ notes_association = notes_association.where('created_at >= ?', cutoff)
+ end
+
+ !merge_commit.has_been_reverted?(current_user, notes_association)
end
def can_be_cherry_picked?
@@ -1049,4 +1095,22 @@ class MergeRequest < ActiveRecord::Base
project.merge_requests.merged.where(author_id: author_id).empty?
end
+
+ def allow_maintainer_to_push
+ maintainer_push_possible? && super
+ end
+
+ alias_method :allow_maintainer_to_push?, :allow_maintainer_to_push
+
+ def maintainer_push_possible?
+ source_project.present? && for_fork? &&
+ target_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE &&
+ source_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE &&
+ !ProtectedBranch.protected?(source_project, source_branch)
+ end
+
+ def can_allow_maintainer_to_push?(user)
+ maintainer_push_possible? &&
+ Ability.allowed?(user, :push_code, source_project)
+ end
end
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index cdc408738be..9e660eccd86 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -1,12 +1,6 @@
class MergeRequest::Metrics < ActiveRecord::Base
belongs_to :merge_request
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
-
- def record!
- if merge_request.merged? && self.merged_at.blank?
- self.merged_at = Time.now
- end
-
- self.save
- end
+ belongs_to :latest_closed_by, class_name: 'User'
+ belongs_to :merged_by, class_name: 'User'
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index e35de9b97ee..c1c27ccf3e5 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -28,6 +28,9 @@ class MergeRequestDiff < ActiveRecord::Base
end
scope :viewable, -> { without_state(:empty) }
+ scope :by_commit_sha, ->(sha) do
+ joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
+ end
scope :recent, -> { order(id: :desc).limit(100) }
@@ -49,6 +52,7 @@ class MergeRequestDiff < ActiveRecord::Base
ensure_commit_shas
save_commits
save_diffs
+ save
keep_around_commits
end
@@ -56,7 +60,6 @@ class MergeRequestDiff < ActiveRecord::Base
self.start_commit_sha ||= merge_request.target_branch_sha
self.head_commit_sha ||= merge_request.source_branch_sha
self.base_commit_sha ||= find_base_sha
- save
end
# Override head_commit_sha to keep compatibility with merge request diff
@@ -195,7 +198,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commits_count
- merge_request_diff_commits.size
+ super || merge_request_diff_commits.size
end
private
@@ -264,13 +267,16 @@ class MergeRequestDiff < ActiveRecord::Base
new_attributes[:state] = :overflow if diff_collection.overflow?
end
- update(new_attributes)
+ assign_attributes(new_attributes)
end
def save_commits
MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse)
- merge_request_diff_commits.reload
+ # merge_request_diff_commits.reload is preferred way to reload associated
+ # objects but it returns cached result for some reason in this case
+ commits = merge_request_diff_commits(true)
+ self.commits_count = commits.size
end
def repository
@@ -284,7 +290,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def keep_around_commits
- [repository, merge_request.source_project.repository].each do |repo|
+ [repository, merge_request.source_project.repository].uniq.each do |repo|
repo.keep_around(start_commit_sha)
repo.keep_around(head_commit_sha)
repo.keep_around(base_commit_sha)
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index b75387e236e..1c2e57bb01f 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -17,7 +17,7 @@ class MergeRequestDiffCommit < ActiveRecord::Base
commit_hash.merge(
merge_request_diff_id: merge_request_diff_id,
relative_order: index,
- sha: sha_attribute.type_cast_for_database(sha),
+ sha: sha_attribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize
authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date])
)
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 77c19380e66..8e33bab81c1 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -8,7 +8,7 @@ class Milestone < ActiveRecord::Base
Started = MilestoneStruct.new('Started', '#started', -3)
include CacheMarkdownField
- include InternalId
+ include NonatomicInternalId
include Sortable
include Referable
include StripAttribute
@@ -22,7 +22,7 @@ class Milestone < ActiveRecord::Base
belongs_to :group
has_many :issues
- has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
+ has_many :labels, -> { distinct.reorder('labels.title').auto_include(false) }, through: :issues
has_many :merge_requests
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -34,8 +34,8 @@ class Milestone < ActiveRecord::Base
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?
+ 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?
where(conditions.reduce(:or))
end
@@ -138,7 +138,7 @@ class Milestone < ActiveRecord::Base
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq
end
- def self.sort(method)
+ def self.sort_by_attribute(method)
case method.to_s
when 'due_date_asc'
reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC'))
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 0ff169d4531..2b63aa33222 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -1,15 +1,15 @@
class Namespace < ActiveRecord::Base
- 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
include Gitlab::SQL::Pattern
+ include IgnorableColumn
+
+ ignore_column :deleted_at
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -20,6 +20,9 @@ class Namespace < ActiveRecord::Base
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics
+
+ # This should _not_ be `inverse_of: :namespace`, because that would also set
+ # `user.namespace` when this user creates a group with themselves as `owner`.
belongs_to :owner, class_name: "User"
belongs_to :parent, class_name: "Namespace"
@@ -29,7 +32,6 @@ class Namespace < ActiveRecord::Base
validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
validates :name,
presence: true,
- uniqueness: { scope: :parent_id },
length: { maximum: 255 },
namespace_name: true
@@ -40,7 +42,6 @@ class Namespace < ActiveRecord::Base
namespace_path: true
validate :nesting_level_allowed
- validate :allowed_path_by_redirects
delegate :name, to: :owner, allow_nil: true, prefix: true
@@ -52,7 +53,7 @@ class Namespace < ActiveRecord::Base
# Legacy Storage specific hooks
- after_update :move_dir, if: :path_changed?
+ after_update :move_dir, if: :path_or_parent_changed?
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
@@ -221,14 +222,46 @@ class Namespace < ActiveRecord::Base
has_parent?
end
- 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
+ # Overridden on EE module
+ def multiple_issue_boards_available?
+ false
+ end
+
+ def full_path_was
+ if parent_id_was.nil?
+ path_was
+ else
+ previous_parent = Group.find_by(id: parent_id_was)
+ previous_parent.full_path + '/' + path_was
+ end
+ end
+
+ # Exports belonging to projects with legacy storage are placed in a common
+ # subdirectory of the namespace, so a simple `rm -rf` is sufficient to remove
+ # them.
+ #
+ # Exports of projects using hashed storage are placed in a location defined
+ # only by the project ID, so each must be removed individually.
+ def remove_exports!
+ remove_legacy_exports!
+
+ all_projects.with_storage_feature(:repository).find_each(&:remove_exports)
+ end
+
+ def features
+ []
+ end
+
+ def refresh_project_authorizations
+ owner.refresh_authorized_projects
end
private
+ def path_or_parent_changed?
+ path_changed? || parent_changed?
+ end
+
def refresh_access_of_projects_invited_groups
Group
.joins(project_group_links: :project)
@@ -259,13 +292,10 @@ class Namespace < ActiveRecord::Base
.update_all(share_with_group_lock: true)
end
- def allowed_path_by_redirects
- return if path.nil?
-
- errors.add(:path, "#{path} has been taken before. Please use another one") if namespace_previously_created_with_same_path?
- end
-
- def namespace_previously_created_with_same_path?
- RedirectRoute.permanent.exists?(path: path)
+ def write_projects_repository_config
+ all_projects.find_each do |project|
+ project.expires_full_path_cache # we need to clear cache to validate renames correctly
+ project.write_repository_config
+ end
end
end
diff --git a/app/models/network/commit.rb b/app/models/network/commit.rb
index 9357e55b419..22d48c9e661 100644
--- a/app/models/network/commit.rb
+++ b/app/models/network/commit.rb
@@ -24,12 +24,7 @@ module Network
end
def parents(map)
- @commit.parents.map do |p|
- if map.include?(p.id)
- map[p.id]
- end
- end
- .compact
+ map.values_at(*@commit.parent_ids).compact
end
end
end
diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb
index aec7b01e23a..1e0d1f9edcb 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -61,11 +61,8 @@ module Network
@reserved[i] = []
end
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37436
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- commits_sort_by_ref.each do |commit|
- place_chain(commit)
- end
+ commits_sort_by_ref.each do |commit|
+ place_chain(commit)
end
# find parent spaces for not overlap lines
@@ -224,6 +221,7 @@ module Network
space_base = parents.first.space
end
end
+
space_base
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 184fbd5f5ae..e426f84832b 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -3,7 +3,6 @@
# A note of this type is never resolvable.
class Note < ActiveRecord::Base
extend ActiveModel::Naming
- include Gitlab::CurrentSettings
include Participable
include Mentionable
include Awardable
@@ -61,7 +60,7 @@ class Note < ActiveRecord::Base
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
- has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :todos
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
@@ -82,12 +81,13 @@ class Note < ActiveRecord::Base
validates :author, presence: true
validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
- validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note|
+ validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note|
unless note.noteable.try(:project) == note.project
errors.add(:project, 'does not match noteable project')
end
end
+ # @deprecated attachments are handler by the MarkdownUploader
mount_uploader :attachment, AttachmentUploader
# Scopes
@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base
def find_discussion(discussion_id)
notes = where(discussion_id: discussion_id).fresh.to_a
+
return if notes.empty?
Discussion.build(notes)
@@ -195,7 +196,7 @@ class Note < ActiveRecord::Base
end
def max_attachment_size
- current_application_settings.max_attachment_size.megabytes.to_i
+ Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
end
def hook_attrs
@@ -227,7 +228,7 @@ class Note < ActiveRecord::Base
end
def skip_project_check?
- for_personal_snippet?
+ !for_project_noteable?
end
def commit
@@ -267,6 +268,10 @@ class Note < ActiveRecord::Base
self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
end
+ def confidential?
+ noteable.try(:confidential?)
+ end
+
def editable?
!system?
end
@@ -307,6 +312,15 @@ class Note < ActiveRecord::Base
self.noteable.supports_discussions? && !part_of_discussion?
end
+ def can_create_todo?
+ # Skip system notes, and notes on project snippet
+ !system? && !for_snippet?
+ end
+
+ def can_create_notification?
+ true
+ end
+
def discussion_class(noteable = nil)
# When commit notes are rendered on an MR's Discussion page, they are
# displayed in one discussion instead of individually.
@@ -373,12 +387,15 @@ class Note < ActiveRecord::Base
def expire_etag_cache
return unless noteable&.discussions_rendered_on_frontend?
- key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
+ Gitlab::EtagCaching::Store.new.touch(etag_key)
+ end
+
+ def etag_key
+ Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: noteable_type.underscore,
target_id: noteable_id
)
- Gitlab::EtagCaching::Store.new.touch(key)
end
def touch(*args)
diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb
new file mode 100644
index 00000000000..c3965565022
--- /dev/null
+++ b/app/models/notification_reason.rb
@@ -0,0 +1,19 @@
+# Holds reasons for a notification to have been sent as well as a priority list to select which reason to use
+# above the rest
+class NotificationReason
+ OWN_ACTIVITY = 'own_activity'.freeze
+ ASSIGNED = 'assigned'.freeze
+ MENTIONED = 'mentioned'.freeze
+
+ # Priority list for selecting which reason to return in the notification
+ REASON_PRIORITY = [
+ OWN_ACTIVITY,
+ ASSIGNED,
+ MENTIONED
+ ].freeze
+
+ # returns the priority of a reason as an integer
+ def self.priority(reason)
+ REASON_PRIORITY.index(reason) || REASON_PRIORITY.length + 1
+ end
+end
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index 183e098d819..b3ffad00a07 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -1,26 +1,19 @@
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
- )
+ attr_reader :user, :type, :reason
+ def initialize(user, type, **opts)
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
+ @custom_action = opts[:custom_action]
+ @acting_user = opts[:acting_user]
+ @target = opts[:target]
+ @project = opts[:project] || default_project
+ @group = opts[:group] || @project&.group
@user = user
@type = type
- @skip_read_ability = skip_read_ability
+ @reason = opts[:reason]
+ @skip_read_ability = opts[:skip_read_ability]
end
def notification_setting
@@ -42,7 +35,8 @@ class NotificationRecipient
# check this last because it's expensive
# nobody should receive notifications if they've specifically unsubscribed
- return false if unsubscribed?
+ # except if they were mentioned.
+ return false if @type != :mention && unsubscribed?
true
end
@@ -54,7 +48,7 @@ class NotificationRecipient
when :custom
custom_enabled? || %i[participating mention].include?(@type)
when :watch, :participating
- !excluded_watcher_action?
+ !action_excluded?
when :mention
@type == :mention
else
@@ -76,9 +70,15 @@ class NotificationRecipient
def own_activity?
return false unless @acting_user
- return false if @acting_user.notified_of_own_activity?
- user == @acting_user
+ if user == @acting_user
+ # if activity was generated by the same user, change reason to :own_activity
+ @reason = NotificationReason::OWN_ACTIVITY
+ # If the user wants to be notified, we must return `false`
+ !@acting_user.notified_of_own_activity?
+ else
+ false
+ end
end
def has_access?
@@ -86,6 +86,7 @@ class NotificationRecipient
return false unless user.can?(:receive_notifications)
return true if @skip_read_ability
+ return false if @target && !user.can?(:read_cross_project)
return false if @project && !user.can?(:read_project, @project)
return true unless read_ability
@@ -95,13 +96,22 @@ class NotificationRecipient
end
end
+ def action_excluded?
+ excluded_watcher_action? || excluded_participating_action?
+ end
+
def excluded_watcher_action?
- return false unless @custom_action
- return false if notification_level == :custom
+ return false unless @custom_action && notification_level == :watch
NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action)
end
+ def excluded_participating_action?
+ return false unless @custom_action && notification_level == :participating
+
+ NotificationSetting::EXCLUDED_PARTICIPATING_EVENTS.include?(@custom_action)
+ end
+
private
def read_ability
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 245f8dddcf9..f6d9b0215fc 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -33,6 +33,7 @@ class NotificationSetting < ActiveRecord::Base
:close_issue,
:reassign_issue,
:new_merge_request,
+ :push_to_merge_request,
:reopen_merge_request,
:close_merge_request,
:reassign_merge_request,
@@ -41,10 +42,14 @@ class NotificationSetting < ActiveRecord::Base
:success_pipeline
].freeze
- EXCLUDED_WATCHER_EVENTS = [
+ EXCLUDED_PARTICIPATING_EVENTS = [
:success_pipeline
].freeze
+ EXCLUDED_WATCHER_EVENTS = [
+ :push_to_merge_request
+ ].push(*EXCLUDED_PARTICIPATING_EVENTS).freeze
+
def self.find_or_create_for(source)
setting = find_or_initialize_by(source: source)
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 8de42ff9d2e..2e478a24778 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,10 +1,16 @@
class PagesDomain < ActiveRecord::Base
+ VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze
+ VERIFICATION_THRESHOLD = 3.days.freeze
+
belongs_to :project
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
+ validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' }, if: ->(domain) { domain.project&.pages_https_only? }
+ validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? }
+ validates :key, presence: { message: 'must be present if HTTPS-only is enabled' }, if: ->(domain) { domain.project&.pages_https_only? }
+ validates :key, certificate_key: true, if: ->(domain) { domain.key.present? }
+ validates :verification_code, presence: true, allow_blank: false
validate :validate_pages_domain
validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? }
@@ -16,10 +22,36 @@ class PagesDomain < ActiveRecord::Base
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
+ after_initialize :set_verification_code
after_create :update_daemon
- after_save :update_daemon
+ after_update :update_daemon, if: :pages_config_changed?
after_destroy :update_daemon
+ scope :enabled, -> { where('enabled_until >= ?', Time.now ) }
+ scope :needs_verification, -> do
+ verified_at = arel_table[:verified_at]
+ enabled_until = arel_table[:enabled_until]
+ threshold = Time.now + VERIFICATION_THRESHOLD
+
+ where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
+ end
+
+ def verified?
+ !!verified_at
+ end
+
+ def unverified?
+ !verified?
+ end
+
+ def enabled?
+ !Gitlab::CurrentSettings.pages_domain_verification_enabled? || enabled_until.present?
+ end
+
+ def https?
+ certificate.present?
+ end
+
def to_param
domain
end
@@ -27,7 +59,7 @@ class PagesDomain < ActiveRecord::Base
def url
return unless domain
- if certificate
+ if certificate.present?
"https://#{domain}"
else
"http://#{domain}"
@@ -84,12 +116,49 @@ class PagesDomain < ActiveRecord::Base
@certificate_text ||= x509.try(:to_text)
end
+ # Verification codes may be TXT records for domain or verification_domain, to
+ # support the use of CNAME records on domain.
+ def verification_domain
+ return unless domain.present?
+
+ "_#{VERIFICATION_KEY}.#{domain}"
+ end
+
+ def keyed_verification_code
+ return unless verification_code.present?
+
+ "#{VERIFICATION_KEY}=#{verification_code}"
+ end
+
private
+ def set_verification_code
+ return if self.verification_code.present?
+
+ self.verification_code = SecureRandom.hex(16)
+ end
+
def update_daemon
::Projects::UpdatePagesConfigurationService.new(project).execute
end
+ def pages_config_changed?
+ project_id_changed? ||
+ domain_changed? ||
+ certificate_changed? ||
+ key_changed? ||
+ became_enabled? ||
+ became_disabled?
+ end
+
+ def became_enabled?
+ enabled_until.present? && !enabled_until_was.present?
+ end
+
+ def became_disabled?
+ !enabled_until.present? && enabled_until_was.present?
+ end
+
def validate_matching_key
unless has_matching_key?
self.errors.add(:key, "doesn't match the certificate")
diff --git a/app/models/project.rb b/app/models/project.rb
index 3440c01b356..ffd78d3ab70 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -4,7 +4,6 @@ class Project < ActiveRecord::Base
include Gitlab::ConfigHelper
include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
- include Gitlab::CurrentSettings
include AccessRequestable
include Avatarable
include CacheMarkdownField
@@ -16,12 +15,15 @@ class Project < ActiveRecord::Base
include ValidAttribute
include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
+ include Presentable
include Routable
include GroupDescendant
include Gitlab::SQL::Pattern
+ include DeploymentPlatform
+ include ::Gitlab::Utils::StrongMemoize
+ include ChronicDurationAttribute
extend Gitlab::ConfigHelper
- extend Gitlab::CurrentSettings
BoardLimitExceeded = Class.new(StandardError)
@@ -37,6 +39,9 @@ class Project < ActiveRecord::Base
attachments: 2
}.freeze
+ # Valids ports to import from
+ VALID_IMPORT_PORTS = [22, 80, 443].freeze
+
cache_markdown_field :description, pipeline: :description
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
@@ -49,8 +54,8 @@ class Project < ActiveRecord::Base
default_value_for :visibility_level, gitlab_config_features.visibility_level
default_value_for :resolve_outdated_diff_discussions, false
default_value_for :container_registry_enabled, gitlab_config_features.container_registry
- default_value_for(:repository_storage) { current_application_settings.pick_repository_storage }
- default_value_for(:shared_runners_enabled) { current_application_settings.shared_runners_enabled }
+ default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage }
+ default_value_for(:shared_runners_enabled) { Gitlab::CurrentSettings.shared_runners_enabled }
default_value_for :issues_enabled, gitlab_config_features.issues
default_value_for :merge_requests_enabled, gitlab_config_features.merge_requests
default_value_for :builds_enabled, gitlab_config_features.builds
@@ -69,6 +74,7 @@ class Project < ActiveRecord::Base
before_destroy :remove_private_deploy_keys
after_destroy -> { run_after_commit { remove_pages } }
+ after_destroy :remove_exports
after_validation :check_pending_delete
@@ -132,11 +138,11 @@ class Project < ActiveRecord::Base
has_one :packagist_service
# TODO: replace these relations with the fork network versions
- has_one :forked_project_link, foreign_key: "forked_to_project_id"
- has_one :forked_from_project, through: :forked_project_link
+ has_one :forked_project_link, foreign_key: "forked_to_project_id"
+ has_one :forked_from_project, -> { auto_include(false) }, 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
+ has_many :forks, -> { auto_include(false) }, through: :forked_project_links, source: :forked_to_project
# TODO: replace these relations with the fork network versions
has_one :root_of_fork_network,
@@ -144,10 +150,11 @@ class Project < ActiveRecord::Base
inverse_of: :root_project,
class_name: 'ForkNetwork'
has_one :fork_network_member
- has_one :fork_network, through: :fork_network_member
+ has_one :fork_network, -> { auto_include(false) }, through: :fork_network_member
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id'
+ has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest'
has_many :issues
has_many :labels, class_name: 'ProjectLabel'
has_many :services
@@ -160,36 +167,39 @@ class Project < ActiveRecord::Base
has_many :protected_tags
has_many :project_authorizations
- has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
+ has_many :authorized_users, -> { auto_include(false) }, through: :project_authorizations, source: :user, class_name: 'User'
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 :users, -> { auto_include(false) }, through: :project_members
has_many :requesters, -> { where.not(requested_at: nil) },
as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :members_and_requesters, as: :source, class_name: 'ProjectMember'
has_many :deploy_keys_projects
- has_many :deploy_keys, through: :deploy_keys_projects
+ has_many :deploy_keys, -> { auto_include(false) }, through: :deploy_keys_projects
has_many :users_star_projects
- has_many :starrers, through: :users_star_projects, source: :user
+ has_many :starrers, -> { auto_include(false) }, through: :users_star_projects, source: :user
has_many :releases
has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :lfs_objects, through: :lfs_objects_projects
+ has_many :lfs_objects, -> { auto_include(false) }, through: :lfs_objects_projects
+ has_many :lfs_file_locks
has_many :project_group_links
- has_many :invited_groups, through: :project_group_links, source: :group
+ has_many :invited_groups, -> { auto_include(false) }, through: :project_group_links, source: :group
has_many :pages_domains
has_many :todos
has_many :notification_settings, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :internal_ids
+
has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
has_one :project_feature, inverse_of: :project
has_one :statistics, class_name: 'ProjectStatistics'
has_one :cluster_project, class_name: 'Clusters::Project'
- has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
+ has_many :clusters, -> { auto_include(false) }, through: :cluster_project, class_name: 'Clusters::Cluster'
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy
@@ -197,27 +207,31 @@ class Project < ActiveRecord::Base
has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :commit_statuses
- has_many :pipelines, class_name: 'Ci::Pipeline'
+ has_many :pipelines, class_name: 'Ci::Pipeline', inverse_of: :project
# 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 :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
has_many :runner_projects, class_name: 'Ci::RunnerProject'
- has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
+ has_many :runners, -> { auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
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 :project_deploy_tokens
+ has_many :deploy_tokens, -> { auto_include(false) }, through: :project_deploy_tokens
- has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
+ has_many :active_runners, -> { active.auto_include(false) }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
+ has_many :project_badges, class_name: 'ProjectBadge'
+
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :import_data
@@ -226,13 +240,13 @@ class Project < ActiveRecord::Base
delegate :name, to: :owner, allow_nil: true, 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
+ delegate :add_guest, :add_reporter, :add_developer, :add_master, :add_role, to: :team
# Validations
validates :creator, presence: true, on: :create
validates :description, length: { maximum: 2000 }, allow_blank: true
validates :ci_config_path,
- format: { without: /(\.{2}|\A\/)/,
+ format: { without: %r{(\.{2}|\A/)},
message: 'cannot include leading slash or directory traversal.' },
length: { maximum: 255 },
allow_blank: true
@@ -244,8 +258,7 @@ class Project < ActiveRecord::Base
validates :path,
presence: true,
project_path: true,
- length: { maximum: 255 },
- uniqueness: { scope: :namespace_id }
+ length: { maximum: 255 }
validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id }
@@ -254,17 +267,15 @@ class Project < ActiveRecord::Base
validates :star_count, numericality: { greater_than_or_equal_to: 0 }
validate :check_limit, on: :create
validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? }
- validate :avatar_type,
- if: ->(project) { project.avatar.present? && project.avatar_changed? }
- validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
validate :visibility_level_allowed_by_group
validate :visibility_level_allowed_as_fork
validate :check_wiki_path_conflict
+ validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
validates :repository_storage,
presence: true,
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
+ validates :variables, variable_duplicates: { scope: :environment_scope }
- mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Scopes
@@ -275,7 +286,8 @@ class Project < ActiveRecord::Base
scope :without_storage_feature, ->(feature) { where('storage_version < :version OR storage_version IS NULL', version: HASHED_STORAGE_FEATURES[feature]) }
scope :with_unmigrated_storage, -> { where('storage_version < :version OR storage_version IS NULL', version: LATEST_STORAGE_VERSION) }
- scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) }
+ # last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push
+ scope :sorted_by_activity, -> { reorder("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC") }
scope :sorted_by_stars, -> { reorder('projects.star_count DESC') }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
@@ -287,7 +299,6 @@ class Project < ActiveRecord::Base
scope :non_archived, -> { where(archived: false) }
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
scope :with_push, -> { joins(:events).where('events.action = ?', Event::PUSHED) }
-
scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') }
scope :with_statistics, -> { includes(:statistics) }
scope :with_shared_runners, -> { where(shared_runners_enabled: true) }
@@ -312,22 +323,24 @@ class Project < ActiveRecord::Base
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
+ scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
+ chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600
+
+ validates :build_timeout, allow_nil: true,
+ numericality: { greater_than_or_equal_to: 600,
+ message: 'needs to be at least 10 minutes' }
+
# 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)
+ where('EXISTS (?) OR projects.visibility_level IN (?)',
+ user.authorizations_for_projects,
+ Gitlab::VisibilityLevel.levels_for_user(user))
else
public_to_user
end
@@ -348,14 +361,11 @@ class Project < ActiveRecord::Base
elsif user
column = ProjectFeature.quoted_access_level_column(feature)
- authorized = user.project_authorizations.select(1)
- .where('project_authorizations.project_id = projects.id')
-
with_project_feature
.where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))",
visible,
ProjectFeature::PRIVATE,
- authorized)
+ user.authorizations_for_projects)
else
with_feature_access_level(feature, visible)
end
@@ -435,7 +445,7 @@ class Project < ActiveRecord::Base
Gitlab::VisibilityLevel.options
end
- def sort(method)
+ def sort_by_attribute(method)
case method.to_s
when 'storage_size_desc'
# storage_size is a joined column so we need to
@@ -488,14 +498,14 @@ class Project < ActiveRecord::Base
def auto_devops_enabled?
if auto_devops&.enabled.nil?
- current_application_settings.auto_devops_enabled?
+ Gitlab::CurrentSettings.auto_devops_enabled?
else
auto_devops.enabled?
end
end
def has_auto_devops_implicitly_disabled?
- auto_devops&.enabled.nil? && !current_application_settings.auto_devops_enabled?
+ auto_devops&.enabled.nil? && !Gitlab::CurrentSettings.auto_devops_enabled?
end
def empty_repo?
@@ -503,7 +513,7 @@ class Project < ActiveRecord::Base
end
def repository_storage_path
- Gitlab.config.repositories.storages[repository_storage].try(:[], 'path')
+ Gitlab.config.repositories.storages[repository_storage]&.legacy_disk_path
end
def team
@@ -514,10 +524,13 @@ class Project < ActiveRecord::Base
@repository ||= Repository.new(full_path, self, disk_path: disk_path)
end
- def reload_repository!
+ def cleanup
+ @repository&.cleanup
@repository = nil
end
+ alias_method :reload_repository!, :cleanup
+
def container_registry_url
if Gitlab.config.registry.enabled
"#{Gitlab.config.registry.host_port}/#{full_path.downcase}"
@@ -544,7 +557,7 @@ class Project < ActiveRecord::Base
latest_pipeline = pipelines.latest_successful_for(ref)
if latest_pipeline
- latest_pipeline.builds.latest.with_artifacts
+ latest_pipeline.builds.latest.with_artifacts_archive
else
builds.none
end
@@ -562,9 +575,10 @@ 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.disk_path)
+ RepositoryForkWorker.perform_async(id)
+ elsif gitlab_project_import?
+ # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab-ce/issues/26189 is solved.
+ RepositoryImportWorker.set(retry: false).perform_async(self.id)
else
RepositoryImportWorker.perform_async(self.id)
end
@@ -625,13 +639,14 @@ class Project < ActiveRecord::Base
end
def create_or_update_import_data(data: nil, credentials: nil)
- return unless import_url.present? && valid_import_url?
+ return if data.nil? && credentials.nil?
project_import_data = import_data || build_import_data
if data
project_import_data.data ||= {}
project_import_data.data = project_import_data.data.merge(data)
end
+
if credentials
project_import_data.credentials ||= {}
project_import_data.credentials = project_import_data.credentials.merge(credentials)
@@ -639,7 +654,7 @@ class Project < ActiveRecord::Base
end
def import?
- external_import? || forked? || gitlab_project_import?
+ external_import? || forked? || gitlab_project_import? || bare_repository_import?
end
def no_import?
@@ -679,6 +694,10 @@ class Project < ActiveRecord::Base
Gitlab::UrlSanitizer.new(import_url).masked_url
end
+ def bare_repository_import?
+ import_type == 'bare_repository'
+ end
+
def gitlab_project_import?
import_type == 'gitlab_project'
end
@@ -726,6 +745,26 @@ class Project < ActiveRecord::Base
end
end
+ def pages_https_only
+ return false unless Gitlab.config.pages.external_https
+
+ super
+ end
+
+ def pages_https_only?
+ return false unless Gitlab.config.pages.external_https
+
+ super
+ end
+
+ def validate_pages_https_only
+ return unless pages_https_only?
+
+ unless pages_domains.all?(&:https?)
+ errors.add(:pages_https_only, "cannot be enabled unless all domains have TLS certificates")
+ end
+ end
+
def to_param
if persisted? && errors.include?(:path)
path_was
@@ -774,7 +813,7 @@ class Project < ActiveRecord::Base
end
def last_activity_date
- last_repository_updated_at || last_activity_at || updated_at
+ [last_activity_at, last_repository_updated_at, updated_at].compact.max
end
def project_id
@@ -900,12 +939,6 @@ class Project < ActiveRecord::Base
@ci_service ||= ci_services.reorder(nil).find_by(active: true)
end
- # TODO: This will be extended for multiple enviroment clusters
- def deployment_platform
- @deployment_platform ||= clusters.find_by(enabled: true)&.platform_kubernetes
- @deployment_platform ||= services.where(category: :deployment).reorder(nil).find_by(active: true)
- end
-
def monitoring_services
services.where(category: :monitoring)
end
@@ -918,20 +951,12 @@ class Project < ActiveRecord::Base
issues_tracker.to_param == 'jira'
end
- def avatar_type
- unless self.avatar.image?
- self.errors.add :avatar, 'only images allowed'
- end
- end
-
def avatar_in_git
repository.avatar
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)
+ Gitlab::Routing.url_helpers.project_avatar_url(self) if avatar_in_git
end
# For compatibility with old code
@@ -951,7 +976,9 @@ class Project < ActiveRecord::Base
def send_move_instructions(old_path_with_namespace)
# New project path needs to be committed to the DB or notification will
# retrieve stale information
- run_after_commit { NotificationService.new.project_was_moved(self, old_path_with_namespace) }
+ run_after_commit do
+ NotificationService.new.project_was_moved(self, old_path_with_namespace)
+ end
end
def owner
@@ -963,15 +990,21 @@ class Project < ActiveRecord::Base
end
def execute_hooks(data, hooks_scope = :push_hooks)
- hooks.public_send(hooks_scope).each do |hook| # rubocop:disable GitlabSecurity/PublicSend
- hook.async_execute(data, hooks_scope.to_s)
+ run_after_commit_or_now do
+ hooks.hooks_for(hooks_scope).each do |hook|
+ hook.async_execute(data, hooks_scope.to_s)
+ end
+
+ SystemHooksService.new.execute_hooks(data, hooks_scope)
end
end
def execute_services(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
- services.public_send(hooks_scope).each do |service| # rubocop:disable GitlabSecurity/PublicSend
- service.async_execute(data)
+ run_after_commit_or_now do
+ services.public_send(hooks_scope).each do |service| # rubocop:disable GitlabSecurity/PublicSend
+ service.async_execute(data)
+ end
end
end
@@ -982,18 +1015,18 @@ class Project < ActiveRecord::Base
false
end
- def repo
- repository.rugged
- end
-
def url_to_repo
gitlab_shell.url_to_repo(full_path)
end
def repo_exists?
- @repo_exists ||= repository.exists?
- rescue
- @repo_exists = false
+ strong_memoize(:repo_exists) do
+ begin
+ repository.exists?
+ rescue
+ false
+ end
+ end
end
def root_ref?(branch)
@@ -1009,6 +1042,9 @@ class Project < ActiveRecord::Base
end
def user_can_push_to_empty_repo?(user)
+ return false unless empty_repo?
+ return false unless Ability.allowed?(user, :push_code, self)
+
!ProtectedBranch.default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
end
@@ -1022,6 +1058,8 @@ class Project < ActiveRecord::Base
end
def fork_source
+ return nil unless forked?
+
forked_from_project || fork_network&.root_project
end
@@ -1037,6 +1075,16 @@ class Project < ActiveRecord::Base
end
end
+ # This will return all `lfs_objects` that are accessible to the project.
+ # So this might be `self.lfs_objects` if the project is not part of a fork
+ # network, or it is the base of the fork network.
+ #
+ # TODO: refactor this to get the correct lfs objects when implementing
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/39769
+ def all_lfs_objects
+ lfs_storage_project.lfs_objects
+ end
+
def personal?
!group
end
@@ -1078,7 +1126,7 @@ class Project < ActiveRecord::Base
# Forked import is handled asynchronously
return if forked? && !force
- if gitlab_shell.add_repository(repository_storage, disk_path)
+ if gitlab_shell.create_repository(repository_storage, disk_path)
repository.after_create
true
else
@@ -1148,7 +1196,7 @@ class Project < ActiveRecord::Base
def change_head(branch)
if repository.branch_exists?(branch)
repository.before_change_head
- repository.write_ref('HEAD', "refs/heads/#{branch}")
+ repository.raw_repository.write_ref('HEAD', "refs/heads/#{branch}", shell: false)
repository.copy_gitattributes(branch)
repository.after_change_head
reload_default_branch
@@ -1270,14 +1318,6 @@ class Project < ActiveRecord::Base
self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token)
end
- def build_timeout_in_minutes
- build_timeout / 60
- end
-
- def build_timeout_in_minutes=(value)
- self.build_timeout = value.to_i * 60
- end
-
def open_issues_count
Projects::OpenIssuesCountService.new(self).count
end
@@ -1315,20 +1355,19 @@ class Project < ActiveRecord::Base
Dir.exist?(public_pages_path)
end
- def pages_url
- subdomain, _, url_path = full_path.partition('/')
-
- # The hostname always needs to be in downcased
- # All web servers convert hostname to lowercase
- host = "#{subdomain}.#{Settings.pages.host}".downcase
-
+ def pages_group_url
# The host in URL always needs to be downcased
- url = Gitlab.config.pages.url.sub(/^https?:\/\//) do |prefix|
- "#{prefix}#{subdomain}."
+ Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix|
+ "#{prefix}#{pages_subdomain}."
end.downcase
+ end
+
+ def pages_url
+ url = pages_group_url
+ url_path = full_path.partition('/').last
# If the project path is the same as host, we serve it as group page
- return url if host == url_path
+ return url if url == "#{Settings.pages.protocol}://#{url_path}"
"#{url}/#{url_path}"
end
@@ -1410,6 +1449,8 @@ class Project < ActiveRecord::Base
end
def after_rename_repo
+ write_repository_config
+
path_before_change = previous_changes['path'].first
# We need to check if project had been rolled out to move resource to hashed storage or not and decide
@@ -1422,8 +1463,20 @@ class Project < ActiveRecord::Base
Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path)
end
+ def write_repository_config(gl_full_path: full_path)
+ # We'd need to keep track of project full path otherwise directory tree
+ # created with hashed storage enabled cannot be usefully imported using
+ # the import rake task.
+ repository.raw_repository.write_config(full_path: gl_full_path)
+ rescue Gitlab::Git::Repository::NoRepository => e
+ Rails.logger.error("Error writing to .git/config for project #{full_path} (#{id}): #{e.message}.")
+ nil
+ end
+
def rename_repo_notify!
- send_move_instructions(full_path_was)
+ # When we import a project overwriting the original project, there
+ # is a move operation. In that case we don't want to send the instructions.
+ send_move_instructions(full_path_was) unless started?
expires_full_path_cache
self.old_path_with_namespace = full_path_was
@@ -1437,6 +1490,8 @@ class Project < ActiveRecord::Base
import_finish
remove_import_jid
update_project_counter_caches
+ after_create_default_branch
+ refresh_markdown_cache!
end
def update_project_counter_caches
@@ -1450,6 +1505,27 @@ class Project < ActiveRecord::Base
end
end
+ def after_create_default_branch
+ return unless default_branch
+
+ # Ensure HEAD points to the default branch in case it is not master
+ change_head(default_branch)
+
+ if Gitlab::CurrentSettings.default_branch_protection != Gitlab::Access::PROTECTION_NONE && !ProtectedBranch.protected?(self, default_branch)
+ params = {
+ name: default_branch,
+ push_access_levels_attributes: [{
+ access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
+ }],
+ merge_access_levels_attributes: [{
+ access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
+ }]
+ }
+
+ ProtectedBranches::CreateService.new(self, creator, params).execute(skip_authorization: true)
+ end
+ end
+
def remove_import_jid
return unless import_jid
@@ -1480,8 +1556,8 @@ class Project < ActiveRecord::Base
@errors = original_errors
end
- def add_export_job(current_user:)
- job_id = ProjectExportWorker.perform_async(current_user.id, self.id)
+ def add_export_job(current_user:, after_export_strategy: nil, params: {})
+ job_id = ProjectExportWorker.perform_async(current_user.id, self.id, after_export_strategy, params)
if job_id
Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}"
@@ -1490,17 +1566,50 @@ class Project < ActiveRecord::Base
end
end
+ def import_export_shared
+ @import_export_shared ||= Gitlab::ImportExport::Shared.new(self)
+ end
+
def export_path
- File.join(Gitlab::ImportExport.storage_path, disk_path)
+ return nil unless namespace.present? || hashed_storage?(:repository)
+
+ import_export_shared.archive_path
end
def export_project_path
Dir.glob("#{export_path}/*export.tar.gz").max_by { |f| File.ctime(f) }
end
+ def export_status
+ if export_in_progress?
+ :started
+ elsif after_export_in_progress?
+ :after_export_action
+ elsif export_project_path
+ :finished
+ else
+ :none
+ end
+ end
+
+ def export_in_progress?
+ import_export_shared.active_export_count > 0
+ end
+
+ def after_export_in_progress?
+ import_export_shared.after_export_in_progress?
+ end
+
def remove_exports
- _, status = Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete))
- status.zero?
+ return nil unless export_path.present?
+
+ FileUtils.rm_rf(export_path)
+ end
+
+ def remove_exported_project_file
+ return unless export_project_path.present?
+
+ FileUtils.rm_f(export_project_path)
end
def full_path_slug
@@ -1512,29 +1621,30 @@ class Project < ActiveRecord::Base
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: 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 },
- { key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level), public: true }
- ]
+ visibility = Gitlab::VisibilityLevel.string_level(visibility_level)
+
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_PROJECT_ID', value: id.to_s)
+ .append(key: 'CI_PROJECT_NAME', value: path)
+ .append(key: 'CI_PROJECT_PATH', value: full_path)
+ .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug)
+ .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path)
+ .append(key: 'CI_PROJECT_URL', value: web_url)
+ .append(key: 'CI_PROJECT_VISIBILITY', value: visibility)
+ .concat(container_registry_variables)
+ .concat(auto_devops_variables)
end
def container_registry_variables
- return [] unless Gitlab.config.registry.enabled
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ return variables unless Gitlab.config.registry.enabled
- variables = [
- { key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port, public: true }
- ]
+ variables.append(key: 'CI_REGISTRY', value: Gitlab.config.registry.host_port)
- if container_registry_enabled?
- variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true }
+ if container_registry_enabled?
+ variables.append(key: 'CI_REGISTRY_IMAGE', value: container_registry_url)
+ end
end
-
- variables
end
def secret_variables_for(ref:, environment: nil)
@@ -1547,20 +1657,21 @@ class Project < ActiveRecord::Base
end
def protected_for?(ref)
- ProtectedBranch.protected?(self, ref) ||
+ if repository.branch_exists?(ref)
+ ProtectedBranch.protected?(self, ref)
+ elsif repository.tag_exists?(ref)
ProtectedTag.protected?(self, ref)
+ end
end
- def deployment_variables
- return [] unless deployment_platform
-
- deployment_platform.predefined_variables
+ def deployment_variables(environment: nil)
+ deployment_platform(environment: environment)&.predefined_variables || []
end
def auto_devops_variables
return [] unless auto_devops_enabled?
- auto_devops&.variables || []
+ (auto_devops || build_auto_devops)&.predefined_variables
end
def append_or_update_attribute(name, value)
@@ -1622,8 +1733,9 @@ class Project < ActiveRecord::Base
end
end
- def multiple_issue_boards_available?(user)
- feature_available?(:multiple_issue_boards, user)
+ # Overridden on EE module
+ def multiple_issue_boards_available?
+ false
end
def issue_board_milestone_available?(user = nil)
@@ -1725,6 +1837,44 @@ class Project < ActiveRecord::Base
.set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
end
+ def badges
+ return project_badges unless group
+
+ group_badges_rel = GroupBadge.where(group: group.self_and_ancestors)
+
+ union = Gitlab::SQL::Union.new([project_badges.select(:id),
+ group_badges_rel.select(:id)])
+
+ Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
+ end
+
+ def merge_requests_allowing_push_to_user(user)
+ return MergeRequest.none unless user
+
+ developer_access_exists = user.project_authorizations
+ .where('access_level >= ? ', Gitlab::Access::DEVELOPER)
+ .where('project_authorizations.project_id = merge_requests.target_project_id')
+ .limit(1)
+ .select(1)
+ source_of_merge_requests.opened
+ .where(allow_maintainer_to_push: true)
+ .where('EXISTS (?)', developer_access_exists)
+ end
+
+ def branch_allows_maintainer_push?(user, branch_name)
+ return false unless user
+
+ cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push"
+
+ memoized_results = strong_memoize(:branch_allows_maintainer_push) do
+ Hash.new do |result, cache_key|
+ result[cache_key] = fetch_branch_allows_maintainer_push?(user, branch_name)
+ end
+ end
+
+ memoized_results[cache_key]
+ end
+
private
def storage
@@ -1737,7 +1887,7 @@ class Project < ActiveRecord::Base
end
def use_hashed_storage
- if self.new_record? && current_application_settings.hashed_storage_enabled
+ if self.new_record? && Gitlab::CurrentSettings.hashed_storage_enabled
self.storage_version = LATEST_STORAGE_VERSION
end
end
@@ -1847,4 +1997,22 @@ class Project < ActiveRecord::Base
raise ex
end
+
+ def fetch_branch_allows_maintainer_push?(user, branch_name)
+ check_access = -> do
+ merge_request = source_of_merge_requests.opened
+ .where(allow_maintainer_to_push: true)
+ .find_by(source_branch: branch_name)
+
+ merge_request&.can_be_merged_by?(user)
+ end
+
+ if RequestStore.active?
+ RequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_maintainer_push") do
+ check_access.call
+ end
+ else
+ check_access.call
+ end
+ end
end
diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb
index 9a52edbff8e..ed6c1eddbc1 100644
--- a/app/models/project_auto_devops.rb
+++ b/app/models/project_auto_devops.rb
@@ -6,13 +6,20 @@ class ProjectAutoDevops < ActiveRecord::Base
validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true }
+ def instance_domain
+ Gitlab::CurrentSettings.auto_devops_domain
+ end
+
def has_domain?
- domain.present?
+ domain.present? || instance_domain.present?
end
- def variables
- variables = []
- variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain, public: true } if domain.present?
- variables
+ def predefined_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ if has_domain?
+ variables.append(key: 'AUTO_DEVOPS_DOMAIN',
+ value: domain.presence || instance_domain)
+ end
+ end
end
end
diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb
new file mode 100644
index 00000000000..ab4482f0c0b
--- /dev/null
+++ b/app/models/project_deploy_token.rb
@@ -0,0 +1,8 @@
+class ProjectDeployToken < ActiveRecord::Base
+ belongs_to :project
+ belongs_to :deploy_token, inverse_of: :project_deploy_tokens
+
+ validates :deploy_token, presence: true
+ validates :project, presence: true
+ validates :deploy_token_id, uniqueness: { scope: [:project_id] }
+end
diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb
index 9ce2d1153a7..4f289e6e215 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -68,7 +68,7 @@ http://app.asana.com/-/account_api'
end
user = data[:user_name]
- project_name = project.name_with_namespace
+ project_name = project.full_name
data[:commits].each do |commit|
push_msg = "#{user} pushed to branch #{branch} of #{project_name} ( #{commit[:url]} ):"
@@ -84,7 +84,7 @@ http://app.asana.com/-/account_api'
# - fix/ed/es/ing
# - close/s/d
# - closing
- issue_finder = /(fix\w*|clos[ei]\w*+)?\W*(?:https:\/\/app\.asana\.com\/\d+\/\d+\/(\d+)|#(\d+))/i
+ issue_finder = %r{(fix\w*|clos[ei]\w*+)?\W*(?:https://app\.asana\.com/\d+/\d+/(\d+)|#(\d+))}i
message.scan(issue_finder).each do |tuple|
# tuple will be
diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb
index ae6af732ed4..4234b8044e5 100644
--- a/app/models/project_services/assembla_service.rb
+++ b/app/models/project_services/assembla_service.rb
@@ -1,6 +1,4 @@
class AssemblaService < Service
- include HTTParty
-
prop_accessor :token, :subdomain
validates :token, presence: true, if: :activated?
@@ -31,6 +29,6 @@ class AssemblaService < Service
return unless supported_events.include?(data[:object_kind])
url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}"
- AssemblaService.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' })
+ Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' })
end
end
diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb
index 42939ea0ec8..54e4b3278db 100644
--- a/app/models/project_services/bamboo_service.rb
+++ b/app/models/project_services/bamboo_service.rb
@@ -117,14 +117,14 @@ class BambooService < CiService
url = build_url(path)
if username.blank? && password.blank?
- HTTParty.get(url, verify: false)
+ Gitlab::HTTP.get(url, verify: false)
else
url << '&os_authType=basic'
- HTTParty.get(url, verify: false,
- basic_auth: {
- username: username,
- password: password
- })
+ Gitlab::HTTP.get(url, verify: false,
+ basic_auth: {
+ username: username,
+ password: password
+ })
end
end
end
diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb
index fc30f6e3365..d2aaff8817a 100644
--- a/app/models/project_services/buildkite_service.rb
+++ b/app/models/project_services/buildkite_service.rb
@@ -71,7 +71,7 @@ class BuildkiteService < CiService
end
def calculate_reactive_cache(sha, ref)
- response = HTTParty.get(commit_status_path(sha), verify: false)
+ response = Gitlab::HTTP.get(commit_status_path(sha), verify: false)
status =
if response.code == 200 && response['status']
diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb
index c3f5b310619..cb4af73807b 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -1,6 +1,4 @@
class CampfireService < Service
- include HTTParty
-
prop_accessor :token, :subdomain, :room
validates :token, presence: true, if: :activated?
@@ -31,7 +29,6 @@ class CampfireService < Service
def execute(data)
return unless supported_events.include?(data[:object_kind])
- self.class.base_uri base_uri
message = build_message(data)
speak(self.room, message, auth)
end
@@ -69,14 +66,14 @@ class CampfireService < Service
}
}
}
- res = self.class.post(path, auth.merge(body))
+ res = Gitlab::HTTP.post(path, base_uri: base_uri, **auth.merge(body))
res.code == 201 ? res : nil
end
# 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 = Gitlab::HTTP.get("/rooms.json", base_uri: base_uri, **auth)
res.code == 200 ? res["rooms"] : []
end
@@ -86,7 +83,7 @@ class CampfireService < Service
after = push[:after]
message = ""
- message << "[#{project.name_with_namespace}] "
+ message << "[#{project.full_name}] "
message << "#{push[:user_name]} "
if Gitlab::Git.blank_ref?(before)
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index 818cfb01b14..7591ab4f478 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -21,8 +21,16 @@ class ChatNotificationService < Service
end
end
+ def confidential_issue_channel
+ properties['confidential_issue_channel'].presence || properties['issue_channel']
+ end
+
+ def confidential_note_channel
+ properties['confidential_note_channel'].presence || properties['note_channel']
+ end
+
def self.supported_events
- %w[push issue confidential_issue merge_request note tag_push
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page]
end
@@ -55,7 +63,9 @@ class ChatNotificationService < Service
return false unless message
- channel_name = get_channel_field(object_kind).presence || channel
+ event_type = data[:event_type] || object_kind
+
+ channel_name = get_channel_field(event_type).presence || channel
opts = {}
opts[:channel] = channel_name if channel_name
@@ -99,7 +109,7 @@ class ChatNotificationService < Service
def get_message(object_kind, data)
case object_kind
when "push", "tag_push"
- ChatMessage::PushMessage.new(data)
+ ChatMessage::PushMessage.new(data) if notify_for_ref?(data)
when "issue"
ChatMessage::IssueMessage.new(data) unless update?(data)
when "merge_request"
@@ -129,7 +139,7 @@ class ChatNotificationService < Service
end
def project_name
- project.name_with_namespace.gsub(/\s/, '')
+ project.full_name.gsub(/\s/, '')
end
def project_url
@@ -145,10 +155,16 @@ class ChatNotificationService < Service
end
def notify_for_ref?(data)
- return true if data[:object_attributes][:tag]
+ return true if data.dig(:object_attributes, :tag)
return true unless notify_only_default_branch?
- data[:object_attributes][:ref] == project.default_branch
+ ref = if data[:ref]
+ Gitlab::Git.ref_name(data[:ref])
+ else
+ data.dig(:object_attributes, :ref)
+ end
+
+ ref == project.default_branch
end
def notify_for_pipeline?(data)
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index c93f1632652..71b10fc6bc1 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -49,7 +49,7 @@ class DroneCiService < CiService
end
def calculate_reactive_cache(sha, ref)
- response = HTTParty.get(commit_status_path(sha, ref), verify: enable_ssl_verification)
+ response = Gitlab::HTTP.get(commit_status_path(sha, ref), verify: enable_ssl_verification)
status =
if response.code == 200 && response['status']
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index 1a236e232f9..b604d860a87 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -2,7 +2,7 @@ class EmailsOnPushService < Service
boolean_accessor :send_from_committer_email
boolean_accessor :disable_diffs
prop_accessor :recipients
- validates :recipients, presence: true, if: :activated?
+ validates :recipients, presence: true, if: :valid_recipients?
def title
'Emails on push'
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index 720ad61162e..1553f169827 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -1,6 +1,4 @@
class ExternalWikiService < Service
- include HTTParty
-
prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, url: true, if: :activated?
@@ -24,7 +22,7 @@ class ExternalWikiService < Service
end
def execute(_data)
- @response = HTTParty.get(properties['external_wiki_url'], verify: true) rescue nil
+ @response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) rescue nil
if @response != 200
nil
end
diff --git a/app/models/project_services/gemnasium_service.rb b/app/models/project_services/gemnasium_service.rb
index 017a9b2df6e..26cbfd784ad 100644
--- a/app/models/project_services/gemnasium_service.rb
+++ b/app/models/project_services/gemnasium_service.rb
@@ -36,7 +36,7 @@ class GemnasiumService < Service
after: data[:after],
token: token,
api_key: api_key,
- repo: project.repository.path_to_repo
+ repo: project.repository.path_to_repo # Gitaly: fixed by https://gitlab.com/gitlab-org/security-products/gemnasium-migration/issues/9
)
end
end
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 768f0a7472e..dce878e485f 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -46,7 +46,7 @@ class HipchatService < Service
end
def self.supported_events
- %w(push issue confidential_issue merge_request note tag_push pipeline)
+ %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline)
end
def execute(data)
@@ -110,6 +110,7 @@ class HipchatService < Service
message = ""
message << "#{push[:user_name]} "
+
if Gitlab::Git.blank_ref?(before)
message << "pushed new #{ref_type} <a href=\""\
"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"\
@@ -119,7 +120,7 @@ class HipchatService < Service
else
message << "pushed to #{ref_type} <a href=\""\
"#{project.web_url}/commits/#{CGI.escape(ref)}\">#{ref}</a> "
- message << "of <a href=\"#{project.web_url}\">#{project.name_with_namespace.gsub!(/\s/, '')}</a> "
+ message << "of <a href=\"#{project.web_url}\">#{project.full_name.gsub!(/\s/, '')}</a> "
message << "(<a href=\"#{project.web_url}/compare/#{before}...#{after}\">Compare changes</a>)"
push[:commits].take(MAX_COMMITS).each do |commit|
@@ -273,7 +274,7 @@ class HipchatService < Service
end
def project_name
- project.name_with_namespace.gsub(/\s/, '')
+ project.full_name.gsub(/\s/, '')
end
def project_url
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index 19357f90810..27bdf708c80 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -4,7 +4,7 @@ class IrkerService < Service
prop_accessor :server_host, :server_port, :default_irc_uri
prop_accessor :recipients, :channels
boolean_accessor :colorize_messages
- validates :recipients, presence: true, if: :activated?
+ validates :recipients, presence: true, if: :valid_recipients?
before_validation :get_channels
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index 31984c5d7ed..df6dcd90985 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -10,9 +10,9 @@ class IssueTrackerService < Service
# 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+)}
+ /(\b[A-Z][A-Z0-9_]+-)(?<issue>\d+)/
else
- %r{(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)}
+ /(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)/
end
end
@@ -77,13 +77,13 @@ class IssueTrackerService < Service
result = false
begin
- response = HTTParty.head(self.project_url, verify: true)
+ response = Gitlab::HTTP.head(self.project_url, verify: true)
if response
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, OpenSSL::SSL::SSLError => error
+ rescue Gitlab::HTTP::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 2be35b6ea9d..ed4bbfb6cfc 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -1,5 +1,7 @@
class JiraService < IssueTrackerService
include Gitlab::Routing
+ include ApplicationHelper
+ include ActionView::Helpers::AssetUrlHelper
validates :url, url: true, presence: true, if: :activated?
validates :api_url, url: true, allow_blank: true
@@ -10,16 +12,17 @@ class JiraService < IssueTrackerService
before_update :reset_password
- # This is confusing, but JiraService does not really support these events.
- # The values here are required to display correct options in the service
- # configuration screen.
+ alias_method :project_url, :url
+
+ # When these are false GitLab does not create cross reference
+ # comments on JIRA except when an issue gets transitioned.
def self.supported_events
%w(commit merge_request)
end
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def self.reference_pattern(only_long: true)
- @reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
+ @reference_pattern ||= /(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)/
end
def initialize_properties
@@ -43,7 +46,7 @@ class JiraService < IssueTrackerService
username: self.username,
password: self.password,
site: URI.join(url, '/').to_s,
- context_path: url.path,
+ context_path: url.path.chomp('/'),
auth_type: :basic,
read_timeout: 120,
use_cookies: true,
@@ -157,11 +160,6 @@ class JiraService < IssueTrackerService
add_comment(data, jira_issue)
end
- # reason why service cannot be tested
- def disabled_title
- "Please fill in Password and Username."
- end
-
def test(_)
result = test_settings
success = result.present?
@@ -266,7 +264,9 @@ class JiraService < IssueTrackerService
url: url,
title: title,
status: status,
- icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' }
+ icon: {
+ title: 'GitLab', url16x16: asset_url('favicon.ico', host: gitlab_config.url)
+ }
}
}
end
@@ -317,4 +317,13 @@ class JiraService < IssueTrackerService
url_changed?
end
+
+ def self.event_description(event)
+ case event
+ when "merge_request", "merge_request_events"
+ "JIRA comments will be created when an issue gets referenced in a merge request."
+ when "commit", "commit_events"
+ "JIRA comments will be created when an issue gets referenced in a commit."
+ end
+ end
end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index b82567ce2b3..20fed432e55 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -4,7 +4,6 @@
# After we've migrated data, we'll remove KubernetesService. This would happen in a few months.
# If you're modyfiyng this class, please note that you should update the same change in Clusters::Platforms::Kubernetes.
class KubernetesService < DeploymentService
- include Gitlab::CurrentSettings
include Gitlab::Kubernetes
include ReactiveCaching
@@ -31,6 +30,7 @@ class KubernetesService < DeploymentService
before_validation :enforce_namespace_to_lower_case
+ validate :deprecation_validation, unless: :template?
validates :namespace,
allow_blank: true,
length: 1..63,
@@ -105,19 +105,19 @@ class KubernetesService < DeploymentService
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: actual_namespace, public: true },
- { key: 'KUBECONFIG', value: config, public: false, file: true }
- ]
-
- if ca_pem.present?
- variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true }
- variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true }
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables
+ .append(key: 'KUBE_URL', value: api_url)
+ .append(key: 'KUBE_TOKEN', value: token, public: false)
+ .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
+ .append(key: 'KUBECONFIG', value: config, public: false, file: true)
+
+ if ca_pem.present?
+ variables
+ .append(key: 'KUBE_CA_PEM', value: ca_pem)
+ .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true)
+ end
end
-
- variables
end
# Constructs a list of terminals from the reactive cache
@@ -145,6 +145,18 @@ class KubernetesService < DeploymentService
@kubeclient ||= build_kubeclient!
end
+ def deprecated?
+ !active
+ end
+
+ def deprecation_message
+ content = _("Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page") % {
+ deprecated_message_content: deprecated_message_content,
+ url: Gitlab::Routing.url_helpers.project_clusters_path(project)
+ }
+ content.html_safe
+ end
+
TEMPLATE_PLACEHOLDER = 'Kubernetes namespace'.freeze
private
@@ -185,7 +197,7 @@ class KubernetesService < DeploymentService
kubeclient = build_kubeclient!
kubeclient.get_pods(namespace: actual_namespace).as_json
- rescue KubeException => err
+ rescue Kubeclient::HttpError => err
raise err unless err.error_code == 404
[]
@@ -219,11 +231,27 @@ class KubernetesService < DeploymentService
{
token: token,
ca_pem: ca_pem,
- max_session_time: current_application_settings.terminal_max_session_time
+ max_session_time: Gitlab::CurrentSettings.terminal_max_session_time
}
end
def enforce_namespace_to_lower_case
self.namespace = self.namespace&.downcase
end
+
+ def deprecation_validation
+ return if active_changed?(from: true, to: false)
+
+ if deprecated?
+ errors[:base] << deprecation_message
+ end
+ end
+
+ def deprecated_message_content
+ if active?
+ _("Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure")
+ else
+ _("Fields on this page are now uneditable, you can configure")
+ end
+ end
end
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
index 4d2037286a2..227d430083d 100644
--- a/app/models/project_services/mattermost_slash_commands_service.rb
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -37,7 +37,7 @@ class MattermostSlashCommandsService < SlashCommandsService
private
def command(params)
- pretty_project_name = project.name_with_namespace
+ pretty_project_name = project.full_name
params.merge(
auto_complete: true,
diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb
index 72ddf9a4be3..2221459c90b 100644
--- a/app/models/project_services/mock_ci_service.rb
+++ b/app/models/project_services/mock_ci_service.rb
@@ -52,7 +52,7 @@ class MockCiService < CiService
#
#
def commit_status(sha, ref)
- response = HTTParty.get(commit_status_path(sha), verify: false)
+ response = Gitlab::HTTP.get(commit_status_path(sha), verify: false)
read_commit_status(response)
rescue Errno::ECONNREFUSED
:error
diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb
index ee9cd78327a..9af68b4e821 100644
--- a/app/models/project_services/monitoring_service.rb
+++ b/app/models/project_services/monitoring_service.rb
@@ -9,11 +9,11 @@ class MonitoringService < Service
%w()
end
- def environment_metrics(environment)
+ def can_query?
raise NotImplementedError
end
- def deployment_metrics(deployment)
+ def query(_, *_)
raise NotImplementedError
end
end
diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb
index f68a0c1a3c3..ba62a5b7ac0 100644
--- a/app/models/project_services/packagist_service.rb
+++ b/app/models/project_services/packagist_service.rb
@@ -1,6 +1,4 @@
class PackagistService < Service
- include HTTParty
-
prop_accessor :username, :token, :server
validates :username, presence: true, if: :activated?
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index 6a3118a11b8..4cf149ac044 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -1,7 +1,7 @@
class PipelinesEmailService < Service
prop_accessor :recipients
boolean_accessor :notify_only_broken_pipelines
- validates :recipients, presence: true, if: :activated?
+ validates :recipients, presence: true, if: :valid_recipients?
def initialize_properties
self.properties ||= { notify_only_broken_pipelines: true }
@@ -39,10 +39,6 @@ class PipelinesEmailService < Service
project.pipelines.any?
end
- def disabled_title
- 'Please setup a pipeline on your repository.'
- end
-
def test_data(project, user)
data = Gitlab::DataBuilder::Pipeline.build(project.pipelines.last)
data[:user] = user.hook_attrs
diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb
index f9dfa2e91c3..3476e7d2283 100644
--- a/app/models/project_services/pivotaltracker_service.rb
+++ b/app/models/project_services/pivotaltracker_service.rb
@@ -1,6 +1,4 @@
class PivotaltrackerService < Service
- include HTTParty
-
API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'.freeze
prop_accessor :token, :restrict_to_branch
@@ -52,7 +50,7 @@ class PivotaltrackerService < Service
'message' => commit[:message]
}
}
- PivotaltrackerService.post(
+ Gitlab::HTTP.post(
API_ENDPOINT,
body: message.to_json,
headers: {
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index fa7b3f2bcaf..dcaeb65dc32 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -1,17 +1,16 @@
class PrometheusService < MonitoringService
- include ReactiveService
-
- self.reactive_cache_lease_timeout = 30.seconds
- self.reactive_cache_refresh_interval = 30.seconds
- self.reactive_cache_lifetime = 1.minute
+ include PrometheusAdapter
# Access to prometheus is directly through the API
prop_accessor :api_url
+ boolean_accessor :manual_configuration
- with_options presence: true, if: :activated? do
+ with_options presence: true, if: :manual_configuration? do
validates :api_url, url: true
end
+ before_save :synchronize_service_state
+
after_save :clear_reactive_cache!
def initialize_properties
@@ -20,12 +19,20 @@ class PrometheusService < MonitoringService
end
end
+ def show_active_box?
+ false
+ end
+
+ def editable?
+ manual_configuration? || !prometheus_installed?
+ end
+
def title
'Prometheus'
end
def description
- s_('PrometheusService|Prometheus monitoring')
+ s_('PrometheusService|Time-series monitoring service')
end
def self.to_param
@@ -33,8 +40,16 @@ class PrometheusService < MonitoringService
end
def fields
+ return [] unless editable?
+
[
{
+ type: 'checkbox',
+ name: 'manual_configuration',
+ title: s_('PrometheusService|Active'),
+ required: true
+ },
+ {
type: 'text',
name: 'api_url',
title: 'API URL',
@@ -47,56 +62,29 @@ class PrometheusService < MonitoringService
# Check we can connect to the Prometheus API
def test(*args)
- client.ping
+ Gitlab::PrometheusClient.new(prometheus_client).ping
{ success: true, result: 'Checked API endpoint' }
- rescue Gitlab::PrometheusError => err
+ rescue Gitlab::PrometheusClient::Error => err
{ success: false, result: err }
end
- def environment_metrics(environment)
- with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &method(:rename_data_to_metrics))
+ def prometheus_client
+ RestClient::Resource.new(api_url) if api_url && manual_configuration? && active?
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 prometheus_installed?
+ return false if template?
+ return false unless project
- 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(query_class_name, *args)
- return unless active? && project && !project.pending_delete?
-
- data = Kernel.const_get(query_class_name).new(client).query(*args)
- {
- success: true,
- data: data,
- last_update: Time.now.utc
- }
- rescue Gitlab::PrometheusError => err
- { success: false, result: err.message }
- end
-
- def client
- @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url)
+ project.clusters.enabled.any? { |cluster| cluster.application_prometheus&.installed? }
end
private
- def rename_data_to_metrics(metrics)
- metrics[:metrics] = metrics.delete :data
- metrics
+ def synchronize_service_state
+ self.active = prometheus_installed? || manual_configuration?
+
+ true
end
end
diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb
index aa7bd4c3c84..8777a44b72f 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -1,6 +1,5 @@
class PushoverService < Service
- include HTTParty
- base_uri 'https://api.pushover.net/1'
+ BASE_URI = 'https://api.pushover.net/1'.freeze
prop_accessor :api_key, :user_key, :device, :priority, :sound
validates :api_key, :user_key, :priority, presence: true, if: :activated?
@@ -88,10 +87,10 @@ class PushoverService < Service
user: user_key,
device: device,
priority: priority,
- title: "#{project.name_with_namespace}",
+ title: "#{project.full_name}",
message: message,
url: data[:project][:web_url],
- url_title: "See project #{project.name_with_namespace}"
+ url_title: "See project #{project.full_name}"
}
# Sound parameter MUST NOT be sent to API if not selected
@@ -99,6 +98,6 @@ class PushoverService < Service
pushover_data[:sound] = sound
end
- PushoverService.post('/messages.json', body: pushover_data)
+ Gitlab::HTTP.post('/messages.json', base_uri: BASE_URI, body: pushover_data)
end
end
diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb
index eb4da68bb7e..37ea45109ae 100644
--- a/app/models/project_services/slash_commands_service.rb
+++ b/app/models/project_services/slash_commands_service.rb
@@ -30,10 +30,10 @@ class SlashCommandsService < Service
def trigger(params)
return unless valid_token?(params[:token])
- user = find_chat_user(params)
+ chat_user = find_chat_user(params)
- if user
- Gitlab::SlashCommands::Command.new(project, user, params).execute
+ if chat_user&.user
+ Gitlab::SlashCommands::Command.new(project, chat_user, params).execute
else
url = authorize_chat_name_url(params)
Gitlab::SlashCommands::Presenters::Access.new(url).authorize
diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb
index cbe137452bd..145313b8e71 100644
--- a/app/models/project_services/teamcity_service.rb
+++ b/app/models/project_services/teamcity_service.rb
@@ -83,7 +83,7 @@ class TeamcityService < CiService
branch = Gitlab::Git.ref_name(data[:ref])
- HTTParty.post(
+ Gitlab::HTTP.post(
build_url('httpAuth/app/rest/buildQueue'),
body: "<build branchName=\"#{branch}\">"\
"<buildType id=\"#{build_type}\"/>"\
@@ -134,10 +134,10 @@ class TeamcityService < CiService
end
def get_path(path)
- HTTParty.get(build_url(path), verify: false,
- basic_auth: {
- username: username,
- password: password
- })
+ Gitlab::HTTP.get(build_url(path), verify: false,
+ basic_auth: {
+ username: username,
+ password: password
+ })
end
end
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 17b9d2cf7b4..87a4350f022 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -37,7 +37,7 @@ class ProjectStatistics < ActiveRecord::Base
def update_build_artifacts_size
self.build_artifacts_size =
project.builds.sum(:artifacts_size) +
- Ci::JobArtifact.artifacts_size_for(self)
+ Ci::JobArtifact.artifacts_size_for(self.project)
end
def update_storage_size
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index c679758973a..33280eda0b9 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -7,36 +7,24 @@ class ProjectTeam
@project = project
end
- # Shortcut to add users
- #
- # Use:
- # @team << [@user, :master]
- # @team << [@users, :master]
- #
- def <<(args)
- users, access, current_user = *args
-
- if users.respond_to?(:each)
- add_users(users, access, current_user: current_user)
- else
- add_user(users, access, current_user: current_user)
- end
- end
-
def add_guest(user, current_user: nil)
- self << [user, :guest, current_user]
+ add_user(user, :guest, current_user: current_user)
end
def add_reporter(user, current_user: nil)
- self << [user, :reporter, current_user]
+ add_user(user, :reporter, current_user: current_user)
end
def add_developer(user, current_user: nil)
- self << [user, :developer, current_user]
+ add_user(user, :developer, current_user: current_user)
end
def add_master(user, current_user: nil)
- self << [user, :master, current_user]
+ add_user(user, :master, current_user: current_user)
+ end
+
+ def add_role(user, role, current_user: nil)
+ send(:"add_#{role}", user, current_user: current_user) # rubocop:disable GitlabSecurity/PublicSend
end
def find_member(user_id)
@@ -97,6 +85,15 @@ class ProjectTeam
@masters ||= fetch_members(Gitlab::Access::MASTER)
end
+ def owners
+ @owners ||=
+ if group
+ group.owners
+ else
+ [project.owner]
+ end
+ end
+
def import(source_project, current_user = nil)
target_project = project
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index a0af749a93f..52e067cb44c 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -119,12 +119,22 @@ class ProjectWiki
end
def delete_page(page, message = nil)
+ return unless page
+
wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
update_project_activity
end
+ def page_formatted_data(page)
+ page_title, page_dir = page_title_and_dir(page.title)
+
+ wiki.page_formatted_data(title: page_title, dir: page_dir, version: page.version)
+ end
+
def page_title_and_dir(title)
+ return unless title
+
title_array = title.split("/")
title = title_array.pop
[title, title_array.join("/")]
@@ -159,7 +169,7 @@ class ProjectWiki
private
def create_repo!(raw_repository)
- gitlab_shell.add_repository(project.repository_storage, disk_path)
+ gitlab_shell.create_repository(project.repository_storage, disk_path)
raise CouldNotCreateWikiError unless raw_repository.exists?
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index d28fed11ca8..609780c5587 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,8 +2,6 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
- extend Gitlab::CurrentSettings
-
protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
@@ -16,7 +14,7 @@ class ProtectedBranch < ActiveRecord::Base
end
def self.default_branch_protected?
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
+ Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
+ Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end
end
diff --git a/app/models/push_event.rb b/app/models/push_event.rb
index 83ce9014094..90c085c888e 100644
--- a/app/models/push_event.rb
+++ b/app/models/push_event.rb
@@ -46,10 +46,11 @@ class PushEvent < Event
# Returns PushEvent instances for which no merge requests have been created.
def self.without_existing_merge_requests
- existing_mrs = MergeRequest.except(:order)
+ existing_mrs = MergeRequest.except(:order, :where)
.select(1)
.where('merge_requests.source_project_id = events.project_id')
.where('merge_requests.source_branch = push_event_payloads.ref')
+ .where(state: :opened)
# For reasons unknown the use of #eager_load will result in the
# "push_event_payload" association not being set. Because of this we're
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index 20532527346..31de204d824 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -17,32 +17,4 @@ class RedirectRoute < ActiveRecord::Base
where(wheres, path, "#{sanitize_sql_like(path)}/%")
end
-
- scope :permanent, -> do
- if column_permanent_exists?
- where(permanent: true)
- else
- none
- end
- end
-
- scope :temporary, -> do
- if column_permanent_exists?
- where(permanent: [false, nil])
- else
- all
- end
- end
-
- default_value_for :permanent, false
-
- def permanent=(value)
- if self.class.column_permanent_exists?
- super
- end
- end
-
- def self.column_permanent_exists?
- ActiveRecord::Base.connection.column_exists?(:redirect_routes, :permanent)
- end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index a34f5e5439b..fd1afafe4df 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -4,6 +4,7 @@ class Repository
REF_MERGE_REQUEST = 'merge-requests'.freeze
REF_KEEP_AROUND = 'keep-around'.freeze
REF_ENVIRONMENTS = 'environments'.freeze
+ MAX_DIVERGING_COUNT = 1000
RESERVED_REFS_NAMES = %W[
heads
@@ -15,10 +16,12 @@ class Repository
].freeze
include Gitlab::ShellAdapter
+ include Gitlab::RepositoryCacheAdapter
attr_accessor :full_path, :disk_path, :project, :is_wiki
delegate :ref_name_for_sha, to: :raw_repository
+ delegate :bundle_to_disk, :create_from_bundle, to: :raw_repository
CreateTreeError = Class.new(StandardError)
@@ -33,7 +36,7 @@ class Repository
CACHED_METHODS = %i(size commit_count rendered_readme contribution_guide
changelog license_blob license_key gitignore koding_yml
gitlab_ci_yml branch_names tag_names branch_count
- tag_count avatar exists? empty? root_ref has_visible_content?
+ tag_count avatar exists? root_ref has_visible_content?
issue_template_names merge_request_template_names).freeze
# Methods that use cache_method but only memoize the value
@@ -55,22 +58,6 @@ class Repository
merge_request_template: :merge_request_template_names
}.freeze
- # Wraps around the given method and caches its output in Redis and an instance
- # variable.
- #
- # This only works for methods that do not take any arguments.
- def self.cache_method(name, fallback: nil, memoize_only: false)
- original = :"_uncached_#{name}"
-
- alias_method(original, name)
-
- define_method(name) do
- cache_method_output(name, fallback: fallback, memoize_only: memoize_only) do
- __send__(original) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
- end
-
def initialize(full_path, project, disk_path: nil, is_wiki: false)
@full_path = full_path
@disk_path = disk_path || full_path
@@ -91,6 +78,10 @@ class Repository
alias_method :raw, :raw_repository
+ def cleanup
+ @raw_repository&.cleanup
+ end
+
# Return absolute path to repository
def path_to_repo
@path_to_repo ||= File.expand_path(
@@ -129,7 +120,7 @@ class Repository
end
end
- def commits(ref, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil)
+ def commits(ref = nil, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil, all: nil)
options = {
repo: raw_repository,
ref: ref,
@@ -139,7 +130,8 @@ class Repository
after: after,
before: before,
follow: Array(path).length == 1,
- skip_merges: skip_merges
+ skip_merges: skip_merges,
+ all: all
}
commits = Gitlab::Git::Commit.where(options)
@@ -154,34 +146,27 @@ class Repository
commits
end
+ # Returns a list of commits that are not present in any reference
+ def new_commits(newrev)
+ refs = ::Gitlab::Git::RevList.new(raw, newrev: newrev).new_refs
+
+ refs.map { |sha| commit(sha.strip) }
+ 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
- raw_repository.gitaly_migrate(:commits_by_message) do |is_enabled|
- commits =
- 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
-
- CommitCollection.new(project, commits, ref)
+ commits = raw_repository.find_commits_by_message(query, ref, path, limit, offset).map do |c|
+ commit(c)
end
+ CommitCollection.new(project, commits, ref)
end
def find_branch(name, fresh_repo: true)
- # Since the Repository object may have in-memory index changes, invalidating the memoized Repository object may
- # cause unintended side effects. Because finding a branch is a read-only operation, we can safely instantiate
- # a new repo here to ensure a consistent state to avoid a libgit2 bug where concurrent access (e.g. via git gc)
- # may cause the branch to "disappear" erroneously or have the wrong SHA.
- #
- # See: https://github.com/libgit2/libgit2/issues/1534 and https://gitlab.com/gitlab-org/gitlab-ce/issues/15392
- raw_repo = fresh_repo ? initialize_raw_repository : raw_repository
-
- raw_repo.find_branch(name)
+ raw_repository.find_branch(name, fresh_repo)
end
def find_tag(name)
@@ -249,40 +234,31 @@ class Repository
# branches or tags, but we want to keep some of these commits around, for
# example if they have comments or CI builds.
def keep_around(sha)
- return unless sha && commit_by(oid: sha)
+ return unless sha.present? && commit_by(oid: sha)
return if kept_around?(sha)
# This will still fail if the file is corrupted (e.g. 0 bytes)
- begin
- write_ref(keep_around_ref_name(sha), sha)
- rescue Rugged::ReferenceError => ex
- Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}"
- rescue Rugged::OSError => ex
- raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/
-
- Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}"
- end
+ raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false)
+ rescue Gitlab::Git::CommandError => ex
+ Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}"
end
def kept_around?(sha)
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.commit(root_ref).id
+ @root_ref_hash ||= raw_repository.commit(root_ref).id
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_ahead = raw_repository
- .count_commits_between(root_ref_hash, branch.dereferenced_target.sha)
+ number_commits_behind, number_commits_ahead =
+ raw_repository.count_commits_between(
+ @root_ref_hash,
+ branch.dereferenced_target.sha,
+ left_right: true,
+ max_count: MAX_DIVERGING_COUNT)
{ behind: number_commits_behind, ahead: number_commits_ahead }
end
@@ -307,17 +283,6 @@ class Repository
expire_method_caches(CACHED_METHODS)
end
- # Expires the caches of a specific set of methods
- def expire_method_caches(methods)
- methods.each do |key|
- cache.expire(key)
-
- ivar = cache_instance_variable_name(key)
-
- remove_instance_variable(ivar) if instance_variable_defined?(ivar)
- end
- end
-
def expire_avatar_cache
expire_method_caches(%i(avatar))
end
@@ -365,7 +330,7 @@ class Repository
def expire_emptiness_caches
return unless empty?
- expire_method_caches(%i(empty? has_visible_content?))
+ expire_method_caches(%i(has_visible_content?))
end
def lookup_cache
@@ -498,12 +463,8 @@ class Repository
end
def root_ref
- if raw_repository
- raw_repository.root_ref
- else
- # When the repo does not exist we raise this error so no data is cached.
- raise Rugged::ReferenceError
- end
+ # When the repo does not exist, or there is no root ref, we raise this error so no data is cached.
+ raw_repository&.root_ref or raise Gitlab::Git::Repository::NoRepository # rubocop:disable Style/AndOr
end
cache_method :root_ref
@@ -515,12 +476,14 @@ class Repository
end
cache_method :exists?
+ # We don't need to cache the output of this method because both exists? and
+ # has_visible_content? are already memoized and cached. There's no guarantee
+ # that the values are expired and loaded atomically.
def empty?
return true unless exists?
!has_visible_content?
end
- cache_method :empty?
# The size of this repository in megabytes.
def size
@@ -536,11 +499,7 @@ class Repository
def commit_count_for_ref(ref)
return 0 unless exists?
- begin
- cache.fetch(:"commit_count_#{ref}") { raw_repository.commit_count(ref) }
- rescue Rugged::ReferenceError
- 0
- end
+ cache.fetch(:"commit_count_#{ref}") { raw_repository.commit_count(ref) }
end
delegate :branch_names, to: :raw_repository
@@ -603,7 +562,7 @@ class Repository
def license_key
return unless exists?
- Licensee.license(path).try(:key)
+ raw_repository.license_short_name
end
cache_method :license_key
@@ -664,26 +623,15 @@ class Repository
end
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
+ commit = raw_repository.last_commit_for_path(sha, path)
+ ::Commit.new(commit, @project) if commit
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
- 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
+ last_commit_for_path(sha, path)&.id
end
end
@@ -745,29 +693,12 @@ class Repository
Commit.order_by(collection: commits, order_by: order_by, sort: sort)
end
- def refs_contains_sha(ref_type, sha)
- args = %W(#{ref_type} --contains #{sha})
- names = run_git(args).first
-
- if names.respond_to?(:split)
- names = names.split("\n").map(&:strip)
-
- names.each do |name|
- name.slice! '* '
- end
-
- names
- else
- []
- end
- end
-
def branch_names_contains(sha)
- refs_contains_sha('branch', sha)
+ raw_repository.branch_names_contains_sha(sha)
end
def tag_names_contains(sha)
- refs_contains_sha('tag', sha)
+ raw_repository.tag_names_contains_sha(sha)
end
def local_branches
@@ -781,34 +712,30 @@ class Repository
end
def create_dir(user, path, **options)
- options[:user] = user
options[:actions] = [{ action: :create_dir, file_path: path }]
- multi_action(**options)
+ multi_action(user, **options)
end
def create_file(user, path, content, **options)
- options[:user] = user
options[:actions] = [{ action: :create, file_path: path, content: content }]
- multi_action(**options)
+ multi_action(user, **options)
end
def update_file(user, path, content, **options)
previous_path = options.delete(:previous_path)
action = previous_path && previous_path != path ? :move : :update
- options[:user] = user
options[:actions] = [{ action: action, file_path: path, previous_path: previous_path, content: content }]
- multi_action(**options)
+ multi_action(user, **options)
end
def delete_file(user, path, **options)
- options[:user] = user
options[:actions] = [{ action: :delete, file_path: path }]
- multi_action(**options)
+ multi_action(user, **options)
end
def with_cache_hooks
@@ -822,70 +749,14 @@ class Repository
result.newrev
end
- def with_branch(user, *args)
- with_cache_hooks do
- Gitlab::Git::OperationService.new(user, raw_repository).with_branch(*args) do |start_commit|
- yield start_commit
- end
- end
- end
-
- # rubocop:disable Metrics/ParameterLists
- def multi_action(
- user:, branch_name:, message:, actions:,
- author_email: nil, author_name: nil,
- start_branch_name: nil, start_project: project)
+ def multi_action(user, **options)
+ start_project = options.delete(:start_project)
- with_branch(
- user,
- branch_name,
- start_branch_name: start_branch_name,
- start_repository: start_project.repository.raw_repository) do |start_commit|
-
- index = Gitlab::Git::Index.new(raw_repository)
-
- if start_commit
- 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) # rubocop:disable GitlabSecurity/PublicSend
- end
-
- options = {
- tree: index.write_tree,
- message: message,
- parents: parents
- }
- options.merge!(get_committer_and_author(user, email: author_email, name: author_name))
-
- create_commit(options)
+ if start_project
+ options[:start_repository] = start_project.repository.raw_repository
end
- end
- # rubocop:enable Metrics/ParameterLists
-
- def get_committer_and_author(user, email: nil, name: nil)
- committer = user_to_committer(user)
- author = Gitlab::Git.committer_hash(email: email, name: name) || committer
- {
- author: author,
- committer: committer
- }
- end
-
- def can_be_merged?(source_sha, target_branch)
- our_commit = rugged.branches[target_branch].target
- their_commit = rugged.lookup(source_sha)
-
- if our_commit && their_commit
- !rugged.merge_commits(our_commit, their_commit).conflicts?
- else
- false
- end
+ with_cache_hooks { raw.multi_action(user, **options) }
end
def merge(user, source_sha, merge_request, message)
@@ -942,78 +813,52 @@ class Repository
branch = Gitlab::Git::Branch.find(self, branch_or_name)
if branch
- @root_ref_sha ||= commit(root_ref).sha
- same_head = branch.target == @root_ref_sha
- merged = ancestor?(branch.target, @root_ref_sha)
+ same_head = branch.target == root_ref_sha
+ merged = ancestor?(branch.target, root_ref_sha)
!same_head && merged
else
nil
end
end
- delegate :merged_branch_names, to: :raw_repository
+ def root_ref_sha
+ @root_ref_sha ||= commit(root_ref).sha
+ end
+
+ delegate :merged_branch_names, :can_be_merged?, to: :raw_repository
def merge_base(first_commit_id, second_commit_id)
first_commit_id = commit(first_commit_id).try(:id) || first_commit_id
second_commit_id = commit(second_commit_id).try(:id) || second_commit_id
raw_repository.merge_base(first_commit_id, second_commit_id)
- rescue Rugged::ReferenceError
- nil
end
def ancestor?(ancestor_id, descendant_id)
return false if ancestor_id.nil? || descendant_id.nil?
- Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
- if is_enabled
- raw_repository.ancestor?(ancestor_id, descendant_id)
- else
- rugged_is_ancestor?(ancestor_id, descendant_id)
- end
- end
- end
-
- def search_files_by_content(query, ref)
- return [] if empty? || query.blank?
-
- offset = 2
- 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? || query.blank?
-
- args = %W(ls-tree --full-tree -r #{ref || root_ref} --name-status | #{Regexp.escape(query)})
-
- run_git(args).first.lines.map(&:strip)
+ raw_repository.ancestor?(ancestor_id, descendant_id)
end
- def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil)
+ def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true)
unless remote_name
remote_name = "tmp-#{SecureRandom.hex}"
tmp_remote_name = true
end
add_remote(remote_name, url, mirror_refmap: refmap)
- fetch_remote(remote_name, forced: forced)
+ fetch_remote(remote_name, forced: forced, prune: prune)
ensure
remove_remote(remote_name) if tmp_remote_name
end
- def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false)
- gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags)
+ def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false, prune: true)
+ gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune)
end
def fetch_source_branch!(source_repository, source_branch, local_ref)
raw_repository.fetch_source_branch!(source_repository.raw_repository, source_branch, local_ref)
end
- def remote_exists?(name)
- raw_repository.remote_exists?(name)
- end
-
def compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:)
raw_repository.compare_source_branch(target_branch_name, source_repository.raw_repository, source_branch_name, straight: straight)
end
@@ -1027,6 +872,18 @@ class Repository
raw_repository.ls_files(actual_ref)
end
+ def search_files_by_content(query, ref)
+ return [] if empty? || query.blank?
+
+ raw_repository.search_files_by_content(query, ref)
+ end
+
+ def search_files_by_name(query, ref)
+ return [] if empty?
+
+ raw_repository.search_files_by_name(query, ref)
+ end
+
def copy_gitattributes(ref)
actual_ref = ref || root_ref
begin
@@ -1037,48 +894,6 @@ class Repository
end
end
- # Caches the supplied block both in a cache and in an instance variable.
- #
- # The cache key and instance variable are named the same way as the value of
- # the `key` argument.
- #
- # This method will return `nil` if the corresponding instance variable is also
- # set to `nil`. This ensures we don't keep yielding the block when it returns
- # `nil`.
- #
- # key - The name of the key to cache the data in.
- # fallback - A value to fall back to in the event of a Git error.
- def cache_method_output(key, fallback: nil, memoize_only: false, &block)
- ivar = cache_instance_variable_name(key)
-
- if instance_variable_defined?(ivar)
- instance_variable_get(ivar)
- else
- # If the repository doesn't exist and a fallback was specified we return
- # that value inmediately. This saves us Rugged/gRPC invocations.
- return fallback unless fallback.nil? || exists?
-
- begin
- value =
- if memoize_only
- yield
- else
- cache.fetch(key, &block)
- end
- instance_variable_set(ivar, value)
- rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository
- # Even if the above `#exists?` check passes these errors might still
- # occur (for example because of a non-existing HEAD). We want to
- # gracefully handle this and not cache anything
- fallback
- end
- end
- end
-
- def cache_instance_variable_name(key)
- :"@#{key.to_s.tr('?!', '')}"
- end
-
def file_on_head(type)
if head = tree(:head)
head.blobs.find do |blob|
@@ -1103,6 +918,13 @@ class Repository
@project.repository_storage_path
end
+ def rebase(user, merge_request)
+ raw.rebase(user, merge_request.id, branch: merge_request.source_branch,
+ branch_sha: merge_request.source_branch_sha,
+ remote_repository: merge_request.target_project.repository.raw,
+ remote_branch: merge_request.target_branch)
+ end
+
private
# TODO Generice finder, later split this on finders by Ref or Oid
@@ -1126,8 +948,7 @@ class Repository
end
def cache
- # TODO: should we use UUIDs here? We could move repositories without clearing this cache
- @cache ||= RepositoryCache.new(full_path, @project.id)
+ @cache ||= Gitlab::RepositoryCache.new(self)
end
def tags_sorted_by_committed_date
@@ -1153,43 +974,7 @@ class Repository
Gitlab::Metrics.add_event(event, { path: full_path }.merge(tags))
end
- def last_commit_for_path_by_gitaly(sha, path)
- c = raw_repository.gitaly_commit_client.last_commit_for_path(sha, path)
- commit_by(oid: c)
- end
-
- def last_commit_for_path_by_rugged(sha, path)
- sha = last_commit_id_for_path_by_shelling_out(sha, path)
- commit_by(oid: sha)
- end
-
- def last_commit_id_for_path_by_shelling_out(sha, path)
- args = %W(rev-list --max-count=1 #{sha} -- #{path})
- raw_repository.run_git_with_timeout(args, Gitlab::Git::Popen::FAST_GIT_PROCESS_TIMEOUT).first.strip
- end
-
def initialize_raw_repository
Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki))
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 7ba3ec06041..2d609920051 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,4 +1,6 @@
class Route < ActiveRecord::Base
+ include CaseSensitivity
+
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
@@ -8,8 +10,7 @@ class Route < ActiveRecord::Base
presence: true,
uniqueness: { case_sensitive: false }
- validate :ensure_permanent_paths
-
+ before_validation :delete_conflicting_orphaned_routes
after_create :delete_conflicting_redirects
after_update :delete_conflicting_redirects, if: :path_changed?
after_update :create_redirect_for_old_path
@@ -42,7 +43,7 @@ class Route < ActiveRecord::Base
# 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, permanent: permanent_redirect?) if attributes[:path]
+ route.create_redirect(old_path) if attributes[:path]
end
end
end
@@ -52,30 +53,25 @@ class Route < ActiveRecord::Base
end
def conflicting_redirects
- RedirectRoute.temporary.matching_path_and_descendants(path)
+ RedirectRoute.matching_path_and_descendants(path)
end
- def create_redirect(path, permanent: false)
- RedirectRoute.create(source: source, path: path, permanent: permanent)
+ def create_redirect(path)
+ RedirectRoute.create(source: source, path: path)
end
private
def create_redirect_for_old_path
- create_redirect(path_was, permanent: permanent_redirect?) if path_changed?
- end
-
- def permanent_redirect?
- source_type != "Project"
+ create_redirect(path_was) if path_changed?
end
- def ensure_permanent_paths
- return if path.nil?
-
- errors.add(:path, "#{path} has been taken before. Please use another one") if conflicting_redirect_exists?
- end
+ def delete_conflicting_orphaned_routes
+ conflicting = self.class.iwhere(path: path)
+ conflicting_orphaned_routes = conflicting.select do |route|
+ route.source.nil?
+ end
- def conflicting_redirect_exists?
- RedirectRoute.permanent.matching_path_and_descendants(path).exists?
+ conflicting_orphaned_routes.each(&:destroy)
end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index 3c4f1885dd0..f7e3f7590ad 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -2,6 +2,8 @@
# and implement a set of methods
class Service < ActiveRecord::Base
include Sortable
+ include Importable
+
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
default_value_for :active, false
@@ -12,6 +14,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 :confidential_note_events, true
default_value_for :job_events, true
default_value_for :pipeline_events, true
default_value_for :wiki_page_events, true
@@ -40,10 +43,12 @@ 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 :confidential_note_hooks, -> { where(confidential_note_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 }
+ scope :deployment, -> { where(category: 'deployment') }
default_value_for :category, 'common'
@@ -117,10 +122,26 @@ class Service < ActiveRecord::Base
nil
end
+ def api_field_names
+ fields.map { |field| field[:name] }
+ .reject { |field_name| field_name =~ /(password|token|key)/ }
+ end
+
def global_fields
fields
end
+ def configurable_events
+ events = self.class.supported_events
+
+ # No need to disable individual triggers when there is only one
+ if events.count == 1
+ []
+ else
+ events
+ end
+ end
+
def supported_events
self.class.supported_events
end
@@ -143,19 +164,16 @@ class Service < ActiveRecord::Base
true
end
- # reason why service cannot be tested
- def disabled_title
- "Please setup a project repository."
- end
-
# Provide convenient accessor methods
# for each serialized property.
# Also keep track of updated properties in a similar way as ActiveModel::Dirty
def self.prop_accessor(*args)
args.each do |arg|
class_eval %{
- def #{arg}
- properties['#{arg}']
+ unless method_defined?(arg)
+ def #{arg}
+ properties['#{arg}']
+ end
end
def #{arg}=(value)
@@ -188,7 +206,11 @@ class Service < ActiveRecord::Base
args.each do |arg|
class_eval %{
def #{arg}?
- ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(#{arg})
+ if Gitlab.rails5?
+ !ActiveModel::Type::Boolean::FALSE_VALUES.include?(#{arg})
+ else
+ ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(#{arg})
+ end
end
}
end
@@ -249,6 +271,7 @@ class Service < ActiveRecord::Base
teamcity
microsoft_teams
]
+
if Rails.env.development?
service_names += %w[mock_ci mock_deployment mock_monitoring]
end
@@ -258,11 +281,24 @@ class Service < ActiveRecord::Base
def self.build_from_template(project_id, template)
service = template.dup
+ service.active = false unless service.valid?
service.template = false
service.project_id = project_id
service
end
+ def deprecated?
+ false
+ end
+
+ def deprecation_message
+ nil
+ end
+
+ def self.find_by_template
+ find_by(template: true)
+ end
+
private
def cache_project_has_external_issue_tracker
@@ -276,4 +312,31 @@ class Service < ActiveRecord::Base
project.cache_has_external_wiki
end
end
+
+ def self.event_description(event)
+ case event
+ when "push", "push_events"
+ "Event will be triggered by a push to the repository"
+ when "tag_push", "tag_push_events"
+ "Event will be triggered when a new tag is pushed to the repository"
+ when "note", "note_events"
+ "Event will be triggered when someone adds a comment"
+ when "issue", "issue_events"
+ "Event will be triggered when an issue is created/updated/closed"
+ when "confidential_issue", "confidential_issue_events"
+ "Event will be triggered when a confidential issue is created/updated/closed"
+ when "merge_request", "merge_request_events"
+ "Event will be triggered when a merge request is created/updated/merged"
+ when "pipeline", "pipeline_events"
+ "Event will be triggered when a pipeline status changes"
+ when "wiki_page", "wiki_page_events"
+ "Event will be triggered when a wiki page is created/updated"
+ when "commit", "commit_events"
+ "Event will be triggered when a commit is created/updated"
+ end
+ end
+
+ def valid_recipients?
+ activated? && !importing?
+ end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 05a16f11b59..644120453cf 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -11,8 +11,6 @@ class Snippet < ActiveRecord::Base
include Editable
include Gitlab::SQL::Pattern
- extend Gitlab::CurrentSettings
-
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
cache_markdown_field :content
@@ -28,7 +26,7 @@ class Snippet < ActiveRecord::Base
default_content_html_invalidator || file_name_changed?
end
- default_value_for(:visibility_level) { current_application_settings.default_snippet_visibility }
+ default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_snippet_visibility }
belongs_to :author, class_name: 'User'
belongs_to :project
@@ -76,6 +74,27 @@ class Snippet < ActiveRecord::Base
@link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/)
end
+ # Returns a collection of snippets that are either public or visible to the
+ # logged in user.
+ #
+ # This method does not verify the user actually has the access to the project
+ # the snippet is in, so it should be only used on a relation that's already scoped
+ # for project access
+ def self.public_or_visible_to_user(user = nil)
+ if user
+ authorized = user
+ .project_authorizations
+ .select(1)
+ .where('project_authorizations.project_id = snippets.project_id')
+
+ levels = Gitlab::VisibilityLevel.levels_for_user(user)
+
+ where('EXISTS (?) OR snippets.visibility_level IN (?) or snippets.author_id = (?)', authorized, levels, user.id)
+ else
+ public_to_user
+ end
+ end
+
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{id}"
@@ -149,5 +168,9 @@ class Snippet < ActiveRecord::Base
def search_code(query)
fuzzy_search(query, [:content])
end
+
+ def parent_class
+ ::Project
+ end
end
end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 7af54b2beb2..aad2c1dac4e 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -22,17 +22,16 @@ class Todo < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
- belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
+ belongs_to :target, -> { auto_include(false) }, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :action, :project, :target_type, :user, presence: true
+ validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
- default_scope { reorder(id: :desc) }
-
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
@@ -51,11 +50,15 @@ class Todo < ActiveRecord::Base
# Priority sorting isn't displayed in the dropdown, because we don't show
# milestones, but still show something if the user has a URL with that
# selected.
- def sort(method)
- case method.to_s
- when 'priority', 'label_priority' then order_by_labels_priority
- else order_by(method)
- end
+ def sort_by_attribute(method)
+ sorted =
+ case method.to_s
+ when 'priority', 'label_priority' then order_by_labels_priority
+ else order_by(method)
+ end
+
+ # Break ties with the ID column for pagination
+ sorted.order(id: :desc)
end
# Order by priority depending on which issue/merge request the Todo belongs to
diff --git a/app/models/tree.rb b/app/models/tree.rb
index c89b8eca9be..4c1856b67a8 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -9,10 +9,9 @@ class Tree
@repository = repository
@sha = sha
@path = path
- @recursive = recursive
git_repo = @repository.raw_repository
- @entries = get_entries(git_repo, @sha, @path, recursive: @recursive)
+ @entries = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive)
end
def readme
@@ -58,21 +57,4 @@ class Tree
def sorted_entries
trees + blobs + submodules
end
-
- private
-
- def get_entries(git_repo, sha, path, recursive: false)
- current_path_entries = Gitlab::Git::Tree.where(git_repo, sha, path)
- ordered_entries = []
-
- current_path_entries.each do |entry|
- ordered_entries << entry
-
- if recursive && entry.dir?
- ordered_entries.concat(get_entries(git_repo, sha, entry.path, recursive: true))
- end
- end
-
- ordered_entries
- end
end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index f194d7bdb80..cf71a7b76fc 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -9,44 +9,69 @@ class Upload < ActiveRecord::Base
validates :model, presence: true
validates :uploader, presence: true
- before_save :calculate_checksum, if: :foreground_checksum?
- after_commit :schedule_checksum, unless: :foreground_checksum?
+ scope :with_files_stored_locally, -> { where(store: [nil, ObjectStorage::Store::LOCAL]) }
- def self.remove_path(path)
- where(path: path).destroy_all
- end
+ before_save :calculate_checksum!, if: :foreground_checksummable?
+ after_commit :schedule_checksum, if: :checksummable?
- def self.record(uploader)
- remove_path(uploader.relative_path)
+ # as the FileUploader is not mounted, the default CarrierWave ActiveRecord
+ # hooks are not executed and the file will not be deleted
+ after_destroy :delete_file!, if: -> { uploader_class <= FileUploader }
- create(
- size: uploader.file.size,
- path: uploader.relative_path,
- model: uploader.model,
- uploader: uploader.class.to_s
- )
+ def self.hexdigest(path)
+ Digest::SHA256.file(path).hexdigest
end
def absolute_path
+ raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local?
return path unless relative_path?
uploader_class.absolute_path(self)
end
- def calculate_checksum
- return unless exist?
+ def calculate_checksum!
+ self.checksum = nil
+ return unless checksummable?
self.checksum = Digest::SHA256.file(absolute_path).hexdigest
end
+ def build_uploader(mounted_as = nil)
+ uploader_class.new(model, mounted_as || mount_point).tap do |uploader|
+ uploader.upload = self
+ uploader.retrieve_from_store!(identifier)
+ end
+ end
+
def exist?
File.exist?(absolute_path)
end
+ def uploader_context
+ {
+ identifier: identifier,
+ secret: secret
+ }.compact
+ end
+
+ def local?
+ return true if store.nil?
+
+ store == ObjectStorage::Store::LOCAL
+ end
+
private
- def foreground_checksum?
- size <= CHECKSUM_THRESHOLD
+ def delete_file!
+ build_uploader.remove!
+ end
+
+ def checksummable?
+ checksum.nil? && local? && exist?
+ end
+
+ def foreground_checksummable?
+ checksummable? && size <= CHECKSUM_THRESHOLD
end
def schedule_checksum
@@ -60,4 +85,12 @@ class Upload < ActiveRecord::Base
def uploader_class
Object.const_get(uploader)
end
+
+ def identifier
+ File.basename(path)
+ end
+
+ def mount_point
+ super&.to_sym
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index b52f17cd6a8..d5c5c0964c5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,10 +2,8 @@ require 'carrierwave/orm/activerecord'
class User < ActiveRecord::Base
extend Gitlab::ConfigHelper
- extend Gitlab::CurrentSettings
include Gitlab::ConfigHelper
- include Gitlab::CurrentSettings
include Gitlab::SQL::Pattern
include AfterCommitQueue
include Avatarable
@@ -30,7 +28,7 @@ class User < ActiveRecord::Base
add_authentication_token_field :rss_token
default_value_for :admin, false
- default_value_for(:external) { current_application_settings.user_default_external }
+ default_value_for(:external) { Gitlab::CurrentSettings.user_default_external }
default_value_for :can_create_group, gitlab_config.default_can_create_group
default_value_for :can_create_team, false
default_value_for :hide_no_ssh_key, false
@@ -53,11 +51,16 @@ class User < ActiveRecord::Base
serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
- :validatable, :omniauthable, :confirmable, :registerable
+ :validatable, :omniauthable, :confirmable, :registerable
+
+ BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
+ "administrator if you think this is an error.".freeze
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
def update_tracked_fields!(request)
+ return if Gitlab::Database.read_only?
+
update_tracked_fields(request)
lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i)
@@ -76,14 +79,11 @@ class User < ActiveRecord::Base
#
# Namespace for personal projects
- has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, autosave: true # rubocop:disable Cop/ActiveRecordDependent
+ has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, 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 # rubocop:disable Cop/ActiveRecordDependent
- has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :keys, -> { where(type: ['Key', nil]) }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :gpg_keys
has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -94,22 +94,25 @@ class User < ActiveRecord::Base
has_one :user_synced_attributes_metadata, autosave: true
# Groups
- 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
+ has_many :members
+ has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember'
+ has_many :groups, -> { auto_include(false) }, through: :group_members
+ has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }).auto_include(false) }, through: :group_members, source: :group
+ has_many :masters_groups, -> { where(members: { access_level: Gitlab::Access::MASTER }).auto_include(false) }, through: :group_members, source: :group
# 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 # rubocop:disable Cop/ActiveRecordDependent
- has_many :projects, through: :project_members
- has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
+ has_many :groups_projects, -> { auto_include(false) }, through: :groups, source: :projects
+ has_many :personal_projects, -> { auto_include(false) }, through: :namespace, source: :projects
+ has_many :project_members, -> { where(requested_at: nil) }
+ has_many :projects, -> { auto_include(false) }, through: :project_members
+ has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :starred_projects, through: :users_star_projects, source: :project
+ has_many :starred_projects, -> { auto_include(false) }, through: :users_star_projects, source: :project
has_many :project_authorizations
- has_many :authorized_projects, through: :project_authorizations, source: :project
+ has_many :authorized_projects, -> { auto_include(false) }, through: :project_authorizations, source: :project
+
+ has_many :user_interacted_projects
+ has_many :project_interactions, -> { auto_include(false) }, through: :user_interacted_projects, source: :project, class_name: 'Project'
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
@@ -117,23 +120,24 @@ class User < ActiveRecord::Base
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 # 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 :todos
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_issues, -> { auto_include(false) }, 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
has_many :custom_attributes, class_name: 'UserCustomAttribute'
+ has_many :callouts, class_name: 'UserCallout'
+ has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
#
# Validations
@@ -148,29 +152,28 @@ class User < ActiveRecord::Base
validates :projects_limit,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
- validates :username,
- user_path: true,
- presence: true,
- uniqueness: { case_sensitive: false }
+ validates :username, presence: true
- validate :namespace_uniq, if: :username_changed?
+ validates :namespace, presence: true
validate :namespace_move_dir_allowed, if: :username_changed?
- validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_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: :email_changed?
+ before_save :set_notification_email, if: :email_changed? # in case validation is skipped
before_validation :set_public_email, if: :public_email_changed?
+ before_save :set_public_email, if: :public_email_changed? # in case validation is skipped
before_save :ensure_incoming_email_token
before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? }
before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) }
before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? }
- after_save :ensure_namespace_correct
+ before_validation :ensure_namespace_correct
+ before_save :ensure_namespace_correct # in case validation is skipped
+ after_validation :set_username_errors
after_update :username_changed_hook, if: :username_changed?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
@@ -184,7 +187,7 @@ class User < ActiveRecord::Base
# User's Dashboard preference
# Note: When adding an option, it MUST go on the end of the array.
- enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos]
+ enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos, :issues, :merge_requests]
# User's Project preference
# Note: When adding an option, it MUST go on the end of the array.
@@ -217,15 +220,11 @@ class User < ActiveRecord::Base
end
def inactive_message
- "Your account has been blocked. Please contact your GitLab " \
- "administrator if you think this is an error."
+ BLOCKED_MESSAGE
end
end
end
- mount_uploader :avatar, AvatarUploader
- has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
-
# Scopes
scope :admins, -> { where(admin: true) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
@@ -233,8 +232,8 @@ class User < ActiveRecord::Base
scope :active, -> { with_state(:active).non_internal }
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')) }
+ scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
+ scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
def self.with_two_factor
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id")
@@ -254,13 +253,13 @@ class User < ActiveRecord::Base
def find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup
if login = conditions.delete(:login)
- where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase)
+ where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase.strip)
else
find_by(conditions)
end
end
- def sort(method)
+ def sort_by_attribute(method)
order_method = method || 'id_desc'
case order_method.to_s
@@ -316,6 +315,8 @@ class User < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
+ return none if query.blank?
+
query = query.downcase
order = <<~SQL
@@ -328,8 +329,8 @@ class User < ActiveRecord::Base
SQL
where(
- fuzzy_arel_match(:name, query)
- .or(fuzzy_arel_match(:username, query))
+ fuzzy_arel_match(:name, query, lower_exact_match: true)
+ .or(fuzzy_arel_match(:username, query, lower_exact_match: true))
.or(arel_table[:email].eq(query))
).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
end
@@ -339,6 +340,8 @@ class User < ActiveRecord::Base
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
def search_with_secondary_emails(query)
+ return none if query.blank?
+
query = query.downcase
email_table = Email.arel_table
@@ -408,7 +411,6 @@ class User < ActiveRecord::Base
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
@@ -430,7 +432,7 @@ class User < ActiveRecord::Base
end
def self.non_internal
- where(Hash[internal_attributes.zip([[false, nil]] * internal_attributes.size)])
+ where(internal_attributes.map { |attr| "#{attr} IS NOT TRUE" }.join(" AND "))
end
#
@@ -504,29 +506,12 @@ class User < ActiveRecord::Base
end
end
- def namespace_uniq
- # Return early if username already failed the first uniqueness validation
- return if errors.key?(:username) &&
- errors[:username].include?('has already been taken')
-
- existing_namespace = Namespace.by_path(username)
- if existing_namespace && existing_namespace != namespace
- errors.add(:username, 'has already been taken')
- end
- end
-
def 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"
- end
- end
-
def unique_email
if !emails.exists?(email: email) && Email.exists?(email: email)
errors.add(:email, 'has already been taken')
@@ -569,7 +554,7 @@ class User < ActiveRecord::Base
gpg_keys.each(&:update_invalid_gpg_signatures)
end
- # Returns the groups a user has access to
+ # Returns the groups a user has access to, either through a membership or a project authorization
def authorized_groups
union = Gitlab::SQL::Union
.new([groups.select(:id), authorized_projects.select(:namespace_id)])
@@ -577,6 +562,11 @@ class User < ActiveRecord::Base
Group.where("namespaces.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
end
+ # Returns the groups a user is a member of, either directly or through a parent group
+ def membership_groups
+ Gitlab::GroupHierarchy.new(groups).base_and_descendants
+ end
+
# Returns a relation of groups the user has access to, including their parent
# and child groups (recursively).
def all_expanded_groups
@@ -612,6 +602,15 @@ class User < ActiveRecord::Base
authorized_projects(min_access_level).exists?({ id: project.id })
end
+ # Typically used in conjunction with projects table to get projects
+ # a user has been given access to.
+ #
+ # Example use:
+ # `Project.where('EXISTS(?)', user.authorizations_for_projects)`
+ def authorizations_for_projects
+ project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
+ end
+
# Returns the projects this user has reporter (or greater) access to, limited
# to at most the given projects.
#
@@ -623,9 +622,7 @@ class User < ActiveRecord::Base
end
def owned_projects
- @owned_projects ||=
- Project.where('namespace_id IN (?) OR namespace_id = ?',
- owned_groups.select(:id), namespace.id).joins(:namespace)
+ @owned_projects ||= Project.from("(#{owned_projects_union.to_sql}) AS projects")
end
# Returns projects which user can admin issues on (for example to move an issue to that project).
@@ -664,11 +661,11 @@ class User < ActiveRecord::Base
end
def allow_password_authentication_for_web?
- current_application_settings.password_authentication_enabled_for_web? && !ldap_user?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_web? && !ldap_user?
end
def allow_password_authentication_for_git?
- current_application_settings.password_authentication_enabled_for_git? && !ldap_user?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user?
end
def can_change_username?
@@ -703,10 +700,6 @@ class User < ActiveRecord::Base
projects_limit - personal_projects_count
end
- def personal_projects_count
- @personal_projects_count ||= personal_projects.count
- end
-
def recent_push(project = nil)
service = Users::LastPushEventService.new(self)
@@ -739,7 +732,7 @@ class User < ActiveRecord::Base
def ldap_user?
if identities.loaded?
- identities.find { |identity| Gitlab::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? }
+ identities.find { |identity| Gitlab::Auth::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? }
else
identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"])
end
@@ -794,12 +787,9 @@ class User < ActiveRecord::Base
# `User.select(:id)` raises
# `ActiveModel::MissingAttributeError: missing attribute: projects_limit`
# without this safeguard!
- return unless has_attribute?(:projects_limit)
+ return unless has_attribute?(:projects_limit) && projects_limit.nil?
- connection_default_value_defined = new_record? && !projects_limit_changed?
- return unless projects_limit.nil? || connection_default_value_defined
-
- self.projects_limit = current_application_settings.default_projects_limit
+ self.projects_limit = Gitlab::CurrentSettings.default_projects_limit
end
def requires_ldap_check?
@@ -839,13 +829,13 @@ class User < ActiveRecord::Base
end
def full_website_url
- return "http://#{website_url}" if website_url !~ /\Ahttps?:\/\//
+ return "http://#{website_url}" if website_url !~ %r{\Ahttps?://}
website_url
end
def short_website_url
- website_url.sub(/\Ahttps?:\/\//, '')
+ website_url.sub(%r{\Ahttps?://}, '')
end
def all_ssh_keys
@@ -857,9 +847,7 @@ class User < ActiveRecord::Base
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)
+ GravatarService.new.execute(email, size, scale, username: username)
end
def primary_email_verified?
@@ -894,19 +882,18 @@ class User < ActiveRecord::Base
end
def ensure_namespace_correct
- # Ensure user has namespace
- create_namespace!(path: username, name: username) unless namespace
-
- if username_changed?
- unless namespace.update_attributes(path: username, name: username)
- namespace.errors.each do |attribute, message|
- self.errors.add(:"namespace_#{attribute}", message)
- end
- raise ActiveRecord::RecordInvalid.new(namespace)
- end
+ if namespace
+ namespace.path = namespace.name = username if username_changed?
+ else
+ build_namespace(path: username, name: username)
end
end
+ def set_username_errors
+ namespace_path_errors = self.errors.delete(:"namespace.path")
+ self.errors[:username].concat(namespace_path_errors) if namespace_path_errors
+ end
+
def username_changed_hook
system_hook_service.execute_hooks_for(self, :rename)
end
@@ -1006,7 +993,7 @@ 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})") # rubocop:disable GitlabSecurity/SqlInjection
+ .where(project: authorized_projects(Gitlab::Access::MASTER))
.select(:runner_id)
Ci::Runner.specific.where(id: runner_ids)
end
@@ -1043,14 +1030,35 @@ class User < ActiveRecord::Base
end
end
- def update_cache_counts
- assigned_open_merge_requests_count(force: true)
- assigned_open_issues_count(force: true)
+ def todos_done_count(force: false)
+ Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: 20.minutes) do
+ TodosFinder.new(self, state: :done).execute.count
+ end
+ end
+
+ def todos_pending_count(force: false)
+ Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do
+ TodosFinder.new(self, state: :pending).execute.count
+ end
+ end
+
+ def personal_projects_count(force: false)
+ Rails.cache.fetch(['users', id, 'personal_projects_count'], force: force, expires_in: 24.hours, raw: true) do
+ personal_projects.count
+ end.to_i
+ end
+
+ def update_todos_count_cache
+ todos_done_count(force: true)
+ todos_pending_count(force: true)
end
def invalidate_cache_counts
invalidate_issue_cache_counts
invalidate_merge_request_cache_counts
+ invalidate_todos_done_count
+ invalidate_todos_pending_count
+ invalidate_personal_projects_count
end
def invalidate_issue_cache_counts
@@ -1061,21 +1069,16 @@ class User < ActiveRecord::Base
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, expires_in: 20.minutes) do
- TodosFinder.new(self, state: :done).execute.count
- end
+ def invalidate_todos_done_count
+ Rails.cache.delete(['users', id, 'todos_done_count'])
end
- def todos_pending_count(force: false)
- Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do
- TodosFinder.new(self, state: :pending).execute.count
- end
+ def invalidate_todos_pending_count
+ Rails.cache.delete(['users', id, 'todos_pending_count'])
end
- def update_todos_count_cache
- todos_done_count(force: true)
- todos_pending_count(force: true)
+ def invalidate_personal_projects_count
+ Rails.cache.delete(['users', id, 'personal_projects_count'])
end
# This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth
@@ -1192,13 +1195,13 @@ class User < ActiveRecord::Base
private
- def ci_projects_union
- scope = { access_level: [Gitlab::Access::MASTER, Gitlab::Access::OWNER] }
- groups = groups_projects.where(members: scope)
- other = projects.where(members: scope)
-
- Gitlab::SQL::Union.new([personal_projects.select(:id), groups.select(:id),
- other.select(:id)])
+ def owned_projects_union
+ Gitlab::SQL::Union.new([
+ Project.where(namespace: namespace),
+ Project.joins(:project_authorizations)
+ .where("projects.namespace_id <> ?", namespace.id)
+ .where(project_authorizations: { user_id: id, access_level: Gitlab::Access::OWNER })
+ ], remove_duplicates: false)
end
# Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
@@ -1224,7 +1227,7 @@ class User < ActiveRecord::Base
else
# Only revert these back to the default if they weren't specifically changed in this update.
self.can_create_group = gitlab_config.default_can_create_group unless can_create_group_changed?
- self.projects_limit = current_application_settings.default_projects_limit unless projects_limit_changed?
+ self.projects_limit = Gitlab::CurrentSettings.default_projects_limit unless projects_limit_changed?
end
end
@@ -1232,15 +1235,15 @@ class User < ActiveRecord::Base
valid = true
error = nil
- if current_application_settings.domain_blacklist_enabled?
- blocked_domains = current_application_settings.domain_blacklist
+ if Gitlab::CurrentSettings.domain_blacklist_enabled?
+ blocked_domains = Gitlab::CurrentSettings.domain_blacklist
if domain_matches?(blocked_domains, email)
error = 'is not from an allowed domain.'
valid = false
end
end
- allowed_domains = current_application_settings.domain_whitelist
+ allowed_domains = Gitlab::CurrentSettings.domain_whitelist
unless allowed_domains.blank?
if domain_matches?(allowed_domains, email)
valid = true
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
new file mode 100644
index 00000000000..e4b69382626
--- /dev/null
+++ b/app/models/user_callout.rb
@@ -0,0 +1,13 @@
+class UserCallout < ActiveRecord::Base
+ belongs_to :user
+
+ enum feature_name: {
+ gke_cluster_integration: 1
+ }
+
+ validates :user, presence: true
+ validates :feature_name,
+ presence: true,
+ uniqueness: { scope: :user_id },
+ inclusion: { in: UserCallout.feature_names.keys }
+end
diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb
new file mode 100644
index 00000000000..dd55a6acb79
--- /dev/null
+++ b/app/models/user_interacted_project.rb
@@ -0,0 +1,59 @@
+class UserInteractedProject < ActiveRecord::Base
+ belongs_to :user
+ belongs_to :project
+
+ validates :project_id, presence: true
+ validates :user_id, presence: true
+
+ CACHE_EXPIRY_TIME = 1.day
+
+ # Schema version required for this model
+ REQUIRED_SCHEMA_VERSION = 20180223120443
+
+ class << self
+ def track(event)
+ # For events without a project, we simply don't care.
+ # An example of this is the creation of a snippet (which
+ # is not related to any project).
+ return unless event.project_id
+
+ attributes = {
+ project_id: event.project_id,
+ user_id: event.author_id
+ }
+
+ cached_exists?(attributes) do
+ transaction(requires_new: true) do
+ begin
+ where(attributes).select(1).first || create!(attributes)
+ true # not caching the whole record here for now
+ rescue ActiveRecord::RecordNotUnique
+ # Note, above queries are not atomic and prone
+ # to race conditions (similar like #find_or_create!).
+ # In the case where we hit this, the record we want
+ # already exists - shortcut and return.
+ true
+ end
+ end
+ end
+ end
+
+ # Check if we can safely call .track (table exists)
+ def available?
+ @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization
+ end
+
+ # Flushes cached information about schema
+ def reset_column_information
+ @available_flag = nil
+ super
+ end
+
+ private
+
+ def cached_exists?(project_id:, user_id:, &block)
+ cache_key = "user_interacted_projects:#{project_id}:#{user_id}"
+ Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRY_TIME, &block)
+ end
+ end
+end
diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb
index 548b99b69d9..688432a9d67 100644
--- a/app/models/user_synced_attributes_metadata.rb
+++ b/app/models/user_synced_attributes_metadata.rb
@@ -26,6 +26,6 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base
private
def sync_profile_from_provider?
- Gitlab::OAuth::Provider.sync_profile_from_provider?(provider)
+ Gitlab::Auth::OAuth::Provider.sync_profile_from_provider?(provider)
end
end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index bdfef677ef3..0f5536415f7 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -1,5 +1,6 @@
class WikiPage
PageChangedError = Class.new(StandardError)
+ PageRenameError = Class.new(StandardError)
include ActiveModel::Validations
include ActiveModel::Conversion
@@ -102,12 +103,15 @@ class WikiPage
# The hierarchy of the directory this page is contained in.
def directory
- wiki.page_title_and_dir(slug).last
+ wiki.page_title_and_dir(slug)&.last.to_s
end
# The processed/formatted content of this page.
def formatted_content
- @attributes[:formatted_content] ||= @page&.formatted_data
+ # Assuming @page exists, nil formatted_data means we didn't load it
+ # before hand (i.e. page was fetched by Gitaly), so we fetch it separately.
+ # If the page was fetched by Gollum, formatted_data would've been a String.
+ @attributes[:formatted_content] ||= @page&.formatted_data || @wiki.page_formatted_data(@page)
end
# The markup format for the page.
@@ -174,7 +178,7 @@ class WikiPage
# Creates a new Wiki Page.
#
# attr - Hash of attributes to set on the new page.
- # :title - The title for the new page.
+ # :title - The title (optionally including dir) for the new page.
# :content - The raw markup content.
# :format - Optional symbol representing the
# content format. Can be any type
@@ -186,7 +190,7 @@ class WikiPage
# Returns the String SHA1 of the newly created page
# or False if the save was unsuccessful.
def create(attrs = {})
- @attributes.merge!(attrs)
+ update_attributes(attrs)
save(page_details: title) do
wiki.create_page(title, content, format, message)
@@ -201,24 +205,29 @@ class WikiPage
# 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
+ # :title - The Title (optionally including dir) to replace existing title
#
# Returns the String SHA1 of the newly created page
# or False if the save was unsuccessful.
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.")
+ raise PageChangedError
end
- attrs.slice!(:content, :format, :message, :title)
- @attributes.merge!(attrs)
- page_details =
- if title.present? && @page.title != title
- title
- else
- @page.url_path
+ update_attributes(attrs)
+
+ if title_changed?
+ page_details = title
+
+ if wiki.find_page(page_details).present?
+ @attributes[:title] = @page.url_path
+ raise PageRenameError
end
+ else
+ page_details = @page.url_path
+ end
save(page_details: page_details) do
wiki.update_page(
@@ -252,8 +261,44 @@ class WikiPage
page.version.to_s
end
+ def title_changed?
+ title.present? && self.class.unhyphenize(@page.url_path) != title
+ end
+
private
+ # Process and format the title based on the user input.
+ def process_title(title)
+ return if title.blank?
+
+ title = deep_title_squish(title)
+ current_dirname = File.dirname(title)
+
+ if @page.present?
+ return title[1..-1] if current_dirname == '/'
+ return File.join([directory.presence, title].compact) if current_dirname == '.'
+ end
+
+ title
+ end
+
+ # This method squishes all the filename
+ # i.e: ' foo / bar / page_name' => 'foo/bar/page_name'
+ def deep_title_squish(title)
+ components = title.split(File::SEPARATOR).map(&:squish)
+
+ File.join(components)
+ end
+
+ # Updates the current @attributes hash by merging a hash of params
+ def update_attributes(attrs)
+ attrs[:title] = process_title(attrs[:title]) if attrs[:title].present?
+
+ attrs.slice!(:content, :format, :message, :title)
+
+ @attributes.merge!(attrs)
+ end
+
def set_attributes
attributes[:slug] = @page.url_path
attributes[:title] = @page.title
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 8fa7b2753c7..603218aa6df 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -15,4 +15,7 @@ class BasePolicy < DeclarativePolicy::Base
condition(:restricted_public_level, scope: :global) do
Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
+
+ # This is prevented in some cases in `gitlab-ee`
+ rule { default }.enable :read_cross_project
end
diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb
index abcf536b2f7..dc7a4aed577 100644
--- a/app/policies/ci/pipeline_schedule_policy.rb
+++ b/app/policies/ci/pipeline_schedule_policy.rb
@@ -10,6 +10,10 @@ module Ci
can?(:developer_access) && pipeline_schedule.owned_by?(@user)
end
+ condition(:non_owner_of_schedule) do
+ !pipeline_schedule.owned_by?(@user)
+ end
+
rule { can?(:developer_access) }.policy do
enable :play_pipeline_schedule
end
@@ -19,6 +23,10 @@ module Ci
enable :admin_pipeline_schedule
end
+ rule { can?(:master_access) & non_owner_of_schedule }.policy do
+ enable :take_ownership_pipeline_schedule
+ end
+
rule { protected_ref }.prevent :play_pipeline_schedule
end
end
diff --git a/app/policies/deploy_token_policy.rb b/app/policies/deploy_token_policy.rb
new file mode 100644
index 00000000000..7aa9106e8b1
--- /dev/null
+++ b/app/policies/deploy_token_policy.rb
@@ -0,0 +1,11 @@
+class DeployTokenPolicy < BasePolicy
+ with_options scope: :subject, score: 0
+ condition(:master) { @subject.project.team.master?(@user) }
+
+ rule { anonymous }.prevent_all
+
+ rule { master }.policy do
+ enable :create_deploy_token
+ enable :update_deploy_token
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index d2d45e402b0..c9cb730c4e9 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -28,12 +28,18 @@ class GroupPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:request_access_enabled) { @subject.request_access_enabled }
- rule { public_group } .enable :read_group
+ rule { public_group }.policy do
+ enable :read_group
+ enable :read_list
+ enable :read_label
+ end
+
rule { logged_in_viewable }.enable :read_group
rule { guest }.policy do
enable :read_group
enable :upload_file
+ enable :read_label
end
rule { admin } .enable :read_group
@@ -42,7 +48,12 @@ class GroupPolicy < BasePolicy
rule { has_access }.enable :read_namespace
rule { developer }.enable :admin_milestones
- rule { reporter }.enable :admin_label
+
+ rule { reporter }.policy do
+ enable :admin_label
+ enable :admin_list
+ enable :admin_issue
+ end
rule { master }.policy do
enable :create_projects
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index f0aa16d2ecf..e86d1c8f98e 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -2,7 +2,6 @@ class IssuablePolicy < BasePolicy
delegate { @subject.project }
condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? }
-
condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) }
desc "User is the assignee or author"
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index bd2d417b2a8..263c6e3039c 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -13,6 +13,7 @@ class IssuePolicy < IssuablePolicy
rule { confidential & ~can_read_confidential }.policy do
prevent :read_issue
+ prevent :read_issue_iid
prevent :update_issue
prevent :admin_issue
end
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index bc3afc626fb..c3fe857f8a2 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -1,3 +1,2 @@
class MergeRequestPolicy < IssuablePolicy
- # pass
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index f599eab42f2..21bb0934dee 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -61,6 +61,27 @@ class ProjectPolicy < BasePolicy
desc "Project has request access enabled"
condition(:request_access_enabled, scope: :subject) { project.request_access_enabled }
+ desc "Has merge requests allowing pushes to user"
+ condition(:has_merge_requests_allowing_pushes, scope: :subject) do
+ project.merge_requests_allowing_push_to_user(user).any?
+ end
+
+ # We aren't checking `:read_issue` or `:read_merge_request` in this case
+ # because it could be possible for a user to see an issuable-iid
+ # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be
+ # allowed to read the actual issue after a more expensive `:read_issue`
+ # check. These checks are intended to be used alongside
+ # `:read_project_for_iids`.
+ #
+ # `:read_issue` & `:read_issue_iid` could diverge in gitlab-ee.
+ condition(:issues_visible_to_user, score: 4) do
+ @subject.feature_available?(:issues, @user)
+ end
+
+ condition(:merge_requests_visible_to_user, score: 4) do
+ @subject.feature_available?(:merge_requests, @user)
+ end
+
features = %w[
merge_requests
issues
@@ -76,12 +97,17 @@ class ProjectPolicy < BasePolicy
condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) }
end
+ # `:read_project` may be prevented in EE, but `:read_project_for_iids` should
+ # not.
+ rule { guest | admin }.enable :read_project_for_iids
+
rule { guest }.enable :guest_access
rule { reporter }.enable :reporter_access
rule { developer }.enable :developer_access
rule { master }.enable :master_access
+ rule { owner | admin }.enable :owner_access
- rule { owner | admin }.policy do
+ rule { can?(:owner_access) }.policy do
enable :guest_access
enable :reporter_access
enable :developer_access
@@ -98,11 +124,6 @@ class ProjectPolicy < BasePolicy
enable :remove_pages
end
- rule { owner | reporter }.policy do
- enable :build_download_code
- enable :build_read_container_image
- end
-
rule { can?(:guest_access) }.policy do
enable :read_project
enable :read_board
@@ -119,9 +140,13 @@ class ProjectPolicy < BasePolicy
enable :create_note
enable :upload_file
enable :read_cycle_analytics
- enable :read_project_snippet
end
+ # These abilities are not allowed to admins that are not members of the project,
+ # that's why they are defined separately.
+ rule { guest & can?(:download_code) }.enable :build_download_code
+ rule { guest & can?(:read_container_image) }.enable :build_read_container_image
+
rule { can?(:reporter_access) }.policy do
enable :download_code
enable :download_wiki_code
@@ -141,12 +166,20 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
end
+ # We define `:public_user_access` separately because there are cases in gitlab-ee
+ # where we enable or prevent it based on other coditions.
rule { (~anonymous & public_project) | internal_access }.policy do
enable :public_user_access
+ enable :read_project_for_iids
end
rule { can?(:public_user_access) }.policy do
+ enable :public_access
enable :guest_access
+
+ enable :fork_project
+ enable :build_download_code
+ enable :build_read_container_image
enable :request_access
end
@@ -197,14 +230,6 @@ class ProjectPolicy < BasePolicy
enable :create_cluster
end
- rule { can?(:public_user_access) }.policy do
- enable :public_access
-
- enable :fork_project
- enable :build_download_code
- enable :build_read_container_image
- end
-
rule { archived }.policy do
prevent :create_merge_request
prevent :push_code
@@ -241,7 +266,6 @@ class ProjectPolicy < BasePolicy
rule { repository_disabled }.policy do
prevent :push_code
- prevent :push_code_to_protected_branches
prevent :download_code
prevent :fork_project
prevent :read_commit_status
@@ -252,7 +276,11 @@ class ProjectPolicy < BasePolicy
end
rule { anonymous & ~public_project }.prevent_all
- rule { public_project }.enable(:public_access)
+
+ rule { public_project }.policy do
+ enable :public_access
+ enable :read_project_for_iids
+ end
rule { can?(:public_access) }.policy do
enable :read_project
@@ -293,6 +321,23 @@ class ProjectPolicy < BasePolicy
prevent :read_issue
end
+ # These rules are included to allow maintainers of projects to push to certain
+ # to run pipelines for the branches they have access to.
+ rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do
+ enable :create_build
+ enable :update_build
+ enable :create_pipeline
+ enable :update_pipeline
+ end
+
+ rule do
+ (can?(:read_project_for_iids) & issues_visible_to_user) | can?(:read_issue)
+ end.enable :read_issue_iid
+
+ rule do
+ (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request)
+ end.enable :read_merge_request_iid
+
private
def team_member?
diff --git a/app/policies/protected_branch_policy.rb b/app/policies/protected_branch_policy.rb
new file mode 100644
index 00000000000..1a7faa4db40
--- /dev/null
+++ b/app/policies/protected_branch_policy.rb
@@ -0,0 +1,9 @@
+class ProtectedBranchPolicy < BasePolicy
+ delegate { @subject.project }
+
+ rule { can?(:admin_project) }.policy do
+ enable :create_protected_branch
+ enable :update_protected_branch
+ enable :destroy_protected_branch
+ end
+end
diff --git a/app/presenters/ci/build_metadata_presenter.rb b/app/presenters/ci/build_metadata_presenter.rb
new file mode 100644
index 00000000000..5048f967ea8
--- /dev/null
+++ b/app/presenters/ci/build_metadata_presenter.rb
@@ -0,0 +1,18 @@
+module Ci
+ class BuildMetadataPresenter < Gitlab::View::Presenter::Delegated
+ TIMEOUT_SOURCES = {
+ unknown_timeout_source: nil,
+ project_timeout_source: 'project',
+ runner_timeout_source: 'runner'
+ }.freeze
+
+ presents :metadata
+
+ def timeout_source
+ return unless metadata.timeout_source?
+
+ TIMEOUT_SOURCES[metadata.timeout_source.to_sym] ||
+ metadata.timeout_source
+ end
+ end
+end
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 255475e1fe6..9afebda19be 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -15,6 +15,8 @@ module Ci
def status_title
if auto_canceled?
"Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
+ else
+ tooltip_for_badge
end
end
@@ -28,5 +30,19 @@ module Ci
trigger_request.user_variables
end
end
+
+ def tooltip_message
+ "#{subject.name} - #{detailed_status.status_tooltip}"
+ end
+
+ private
+
+ def tooltip_for_badge
+ detailed_status.badge_tooltip.capitalize
+ end
+
+ def detailed_status
+ @detailed_status ||= subject.detailed_status(user)
+ end
end
end
diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb
index 81fea106a5c..98d68bc7a83 100644
--- a/app/presenters/ci/group_variable_presenter.rb
+++ b/app/presenters/ci/group_variable_presenter.rb
@@ -7,19 +7,15 @@ module Ci
end
def form_path
- if variable.persisted?
- group_variable_path(group, variable)
- else
- group_variables_path(group)
- end
+ group_settings_ci_cd_path(group)
end
def edit_path
- group_variable_path(group, variable)
+ group_variables_path(group)
end
def delete_path
- group_variable_path(group, variable)
+ group_variables_path(group)
end
end
end
diff --git a/app/presenters/ci/variable_presenter.rb b/app/presenters/ci/variable_presenter.rb
index 5d7998393a6..96159f88c59 100644
--- a/app/presenters/ci/variable_presenter.rb
+++ b/app/presenters/ci/variable_presenter.rb
@@ -7,19 +7,15 @@ module Ci
end
def form_path
- if variable.persisted?
- project_variable_path(project, variable)
- else
- project_variables_path(project)
- end
+ project_settings_ci_cd_path(project)
end
def edit_path
- project_variable_path(project, variable)
+ project_variables_path(project)
end
def delete_path
- project_variable_path(project, variable)
+ project_variables_path(project)
end
end
end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index ab4c87c0169..9f3f2637183 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -3,6 +3,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
include GitlabRoutingHelper
include MarkupHelper
include TreeHelper
+ include Gitlab::Utils::StrongMemoize
presents :merge_request
@@ -43,7 +44,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def revert_in_fork_path
- if user_can_fork_project? && can_be_reverted?(current_user)
+ if user_can_fork_project? && cached_can_be_reverted?
continue_params = {
to: merge_request_path(merge_request),
notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.",
@@ -76,6 +77,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
+ def rebase_path
+ if !rebase_in_progress? && should_be_rebased? && can_push_to_source_branch?
+ rebase_project_merge_request_path(project, merge_request)
+ end
+ end
+
def target_branch_tree_path
if target_branch_exists?
project_tree_path(project, target_branch)
@@ -145,15 +152,29 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def can_revert_on_current_merge_request?
- user_can_collaborate_with_project? && can_be_reverted?(current_user)
+ user_can_collaborate_with_project? && cached_can_be_reverted?
end
def can_cherry_pick_on_current_merge_request?
user_can_collaborate_with_project? && can_be_cherry_picked?
end
+ def can_push_to_source_branch?
+ return false unless source_branch_exists?
+
+ !!::Gitlab::UserAccess
+ .new(current_user, project: source_project)
+ .can_push_to_branch?(source_branch)
+ end
+
private
+ def cached_can_be_reverted?
+ strong_memoize(:can_be_reverted) do
+ can_be_reverted?(current_user)
+ end
+ end
+
def conflicts
@conflicts ||= MergeRequests::Conflicts::ListService.new(merge_request)
end
@@ -176,7 +197,8 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def user_can_collaborate_with_project?
can?(current_user, :push_code, project) ||
- (current_user && current_user.already_forked?(project))
+ (current_user && current_user.already_forked?(project)) ||
+ can_push_to_source_branch?
end
def user_can_fork_project?
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
new file mode 100644
index 00000000000..484ac64580d
--- /dev/null
+++ b/app/presenters/project_presenter.rb
@@ -0,0 +1,338 @@
+class ProjectPresenter < Gitlab::View::Presenter::Delegated
+ include ActionView::Helpers::NumberHelper
+ include ActionView::Helpers::UrlHelper
+ include GitlabRoutingHelper
+ include StorageHelper
+ include TreeHelper
+ include Gitlab::Utils::StrongMemoize
+
+ presents :project
+
+ def statistics_anchors(show_auto_devops_callout:)
+ [
+ files_anchor_data,
+ commits_anchor_data,
+ branches_anchor_data,
+ tags_anchor_data,
+ readme_anchor_data,
+ changelog_anchor_data,
+ license_anchor_data,
+ contribution_guide_anchor_data,
+ gitlab_ci_anchor_data,
+ autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
+ kubernetes_cluster_anchor_data
+ ].compact.select { |item| item.enabled }
+ end
+
+ def statistics_buttons(show_auto_devops_callout:)
+ [
+ changelog_anchor_data,
+ license_anchor_data,
+ contribution_guide_anchor_data,
+ autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
+ kubernetes_cluster_anchor_data,
+ gitlab_ci_anchor_data,
+ koding_anchor_data
+ ].compact.reject { |item| item.enabled }
+ end
+
+ def empty_repo_statistics_anchors
+ [
+ autodevops_anchor_data,
+ kubernetes_cluster_anchor_data
+ ].compact.select { |item| item.enabled }
+ end
+
+ def empty_repo_statistics_buttons
+ [
+ new_file_anchor_data,
+ readme_anchor_data,
+ license_anchor_data,
+ autodevops_anchor_data,
+ kubernetes_cluster_anchor_data
+ ].compact.reject { |item| item.enabled }
+ end
+
+ def default_view
+ return anonymous_project_view unless current_user
+
+ user_view = current_user.project_view
+
+ if can?(current_user, :download_code, project)
+ user_view
+ elsif user_view == "activity"
+ "activity"
+ elsif can?(current_user, :read_wiki, project)
+ "wiki"
+ elsif feature_available?(:issues, current_user)
+ "projects/issues/issues"
+ else
+ "customize_workflow"
+ end
+ end
+
+ def readme_path
+ filename_path(:readme)
+ end
+
+ def changelog_path
+ filename_path(:changelog)
+ end
+
+ def license_path
+ filename_path(:license_blob)
+ end
+
+ def ci_configuration_path
+ filename_path(:gitlab_ci_yml)
+ end
+
+ def contribution_guide_path
+ if project && contribution_guide = repository.contribution_guide
+ project_blob_path(
+ project,
+ tree_join(project.default_branch,
+ contribution_guide.name)
+ )
+ end
+ end
+
+ def add_license_path
+ add_special_file_path(file_name: 'LICENSE')
+ end
+
+ def add_changelog_path
+ add_special_file_path(file_name: 'CHANGELOG')
+ end
+
+ def add_contribution_guide_path
+ add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add contribution guide')
+ end
+
+ def add_ci_yml_path
+ add_special_file_path(file_name: '.gitlab-ci.yml')
+ end
+
+ def add_readme_path
+ add_special_file_path(file_name: 'README.md')
+ end
+
+ def add_koding_stack_path
+ project_new_blob_path(
+ project,
+ default_branch || 'master',
+ file_name: '.koding.yml',
+ commit_message: "Add Koding stack script",
+ content: <<-CONTENT.strip_heredoc
+ provider:
+ aws:
+ access_key: '${var.aws_access_key}'
+ secret_key: '${var.aws_secret_key}'
+ resource:
+ aws_instance:
+ #{project.path}-vm:
+ instance_type: t2.nano
+ user_data: |-
+
+ # Created by GitLab UI for :>
+
+ echo _KD_NOTIFY_@Installing Base packages...@
+
+ apt-get update -y
+ apt-get install git -y
+
+ echo _KD_NOTIFY_@Cloning #{project.name}...@
+
+ export KODING_USER=${var.koding_user_username}
+ export REPO_URL=#{root_url}${var.koding_queryString_repo}.git
+ export BRANCH=${var.koding_queryString_branch}
+
+ sudo -i -u $KODING_USER git clone $REPO_URL -b $BRANCH
+
+ echo _KD_NOTIFY_@#{project.name} cloned.@
+ CONTENT
+ )
+ end
+
+ def license_short_name
+ license = repository.license
+ license&.nickname || license&.name || 'LICENSE'
+ end
+
+ def can_current_user_push_code?
+ strong_memoize(:can_current_user_push_code) do
+ if empty_repo?
+ can?(current_user, :push_code, project)
+ else
+ can_current_user_push_to_branch?(default_branch)
+ end
+ end
+ end
+
+ def can_current_user_push_to_branch?(branch)
+ return false unless repository.branch_exists?(branch)
+
+ ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch)
+ end
+
+ def files_anchor_data
+ OpenStruct.new(enabled: true,
+ label: _('Files (%{human_size})') % { human_size: storage_counter(statistics.total_repository_size) },
+ link: project_tree_path(project))
+ end
+
+ def commits_anchor_data
+ OpenStruct.new(enabled: true,
+ label: n_('Commit (%{commit_count})', 'Commits (%{commit_count})', statistics.commit_count) % { commit_count: number_with_delimiter(statistics.commit_count) },
+ link: project_commits_path(project, repository.root_ref))
+ end
+
+ def branches_anchor_data
+ OpenStruct.new(enabled: true,
+ label: n_('Branch (%{branch_count})', 'Branches (%{branch_count})', repository.branch_count) % { branch_count: number_with_delimiter(repository.branch_count) },
+ link: project_branches_path(project))
+ end
+
+ def tags_anchor_data
+ OpenStruct.new(enabled: true,
+ label: n_('Tag (%{tag_count})', 'Tags (%{tag_count})', repository.tag_count) % { tag_count: number_with_delimiter(repository.tag_count) },
+ link: project_tags_path(project))
+ end
+
+ def new_file_anchor_data
+ if current_user && can_current_user_push_code?
+ OpenStruct.new(enabled: false,
+ label: _('New file'),
+ link: project_new_blob_path(project, default_branch || 'master'),
+ class_modifier: 'new')
+ end
+ end
+
+ def readme_anchor_data
+ if current_user && can_current_user_push_code? && repository.readme.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add Readme'),
+ link: add_readme_path)
+ elsif repository.readme.present?
+ OpenStruct.new(enabled: true,
+ label: _('Readme'),
+ link: default_view != 'readme' ? readme_path : '#readme')
+ end
+ end
+
+ def changelog_anchor_data
+ if current_user && can_current_user_push_code? && repository.changelog.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add Changelog'),
+ link: add_changelog_path)
+ elsif repository.changelog.present?
+ OpenStruct.new(enabled: true,
+ label: _('Changelog'),
+ link: changelog_path)
+ end
+ end
+
+ def license_anchor_data
+ if current_user && can_current_user_push_code? && repository.license_blob.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add License'),
+ link: add_license_path)
+ elsif repository.license_blob.present?
+ OpenStruct.new(enabled: true,
+ label: license_short_name,
+ link: license_path)
+ end
+ end
+
+ def contribution_guide_anchor_data
+ if current_user && can_current_user_push_code? && repository.contribution_guide.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Add Contribution guide'),
+ link: add_contribution_guide_path)
+ elsif repository.contribution_guide.present?
+ OpenStruct.new(enabled: true,
+ label: _('Contribution guide'),
+ link: contribution_guide_path)
+ end
+ end
+
+ def autodevops_anchor_data(show_auto_devops_callout: false)
+ if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout
+ OpenStruct.new(enabled: auto_devops_enabled?,
+ label: auto_devops_enabled? ? _('Auto DevOps enabled') : _('Enable Auto DevOps'),
+ link: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings'))
+ elsif auto_devops_enabled?
+ OpenStruct.new(enabled: true,
+ label: _('Auto DevOps enabled'),
+ link: nil)
+ end
+ end
+
+ def kubernetes_cluster_anchor_data
+ if current_user && can?(current_user, :create_cluster, project)
+ cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project)
+
+ if clusters.empty?
+ cluster_link = new_project_cluster_path(project)
+ end
+
+ OpenStruct.new(enabled: !clusters.empty?,
+ label: clusters.empty? ? _('Add Kubernetes cluster') : _('Kubernetes configured'),
+ link: cluster_link)
+ end
+ end
+
+ def gitlab_ci_anchor_data
+ if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled?
+ OpenStruct.new(enabled: false,
+ label: _('Set up CI/CD'),
+ link: add_ci_yml_path)
+ elsif repository.gitlab_ci_yml.present?
+ OpenStruct.new(enabled: true,
+ label: _('CI/CD configuration'),
+ link: ci_configuration_path)
+ end
+ end
+
+ def koding_anchor_data
+ if current_user && can_current_user_push_code? && koding_enabled? && repository.koding_yml.blank?
+ OpenStruct.new(enabled: false,
+ label: _('Set up Koding'),
+ link: add_koding_stack_path)
+ end
+ end
+
+ private
+
+ def filename_path(filename)
+ if blob = repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend
+ project_blob_path(
+ project,
+ tree_join(default_branch, blob.name)
+ )
+ end
+ end
+
+ def anonymous_project_view
+ if !project.empty_repo? && can?(current_user, :download_code, project)
+ 'files'
+ else
+ 'activity'
+ end
+ end
+
+ def add_special_file_path(file_name:, commit_message: nil, branch_name: nil)
+ commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name }
+ project_new_blob_path(
+ project,
+ project.default_branch || 'master',
+ file_name: file_name,
+ commit_message: commit_message,
+ branch_name: branch_name
+ )
+ end
+
+ def koding_enabled?
+ Gitlab::CurrentSettings.koding_enabled?
+ end
+end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 229311eb6ee..c226586fba5 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -7,7 +7,7 @@ module Projects
delegate :size, to: :available_public_keys, prefix: true
def new_key
- @key ||= DeployKey.new
+ @key ||= DeployKey.new.tap { |dk| dk.deploy_keys_projects.build }
end
def enabled_keys
diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb
index 564612202b5..3e355a13e06 100644
--- a/app/serializers/analytics_stage_entity.rb
+++ b/app/serializers/analytics_stage_entity.rb
@@ -7,6 +7,7 @@ class AnalyticsStageEntity < Grape::Entity
expose :description
expose :median, as: :value do |stage|
- stage.median && !stage.median.zero? ? distance_of_time_in_words(stage.median) : nil
+ # median returns a BatchLoader instance which we first have to unwrap by using to_i
+ !stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 69d46f5ec14..ca4480fe2b1 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -5,6 +5,8 @@ class BuildDetailsEntity < JobEntity
expose :runner, using: RunnerEntity
expose :pipeline, using: PipelineEntity
+ expose :metadata, using: BuildMetadataEntity
+
expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity
expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build|
erase_project_job_path(project, build)
diff --git a/app/serializers/build_metadata_entity.rb b/app/serializers/build_metadata_entity.rb
new file mode 100644
index 00000000000..f16f3badffa
--- /dev/null
+++ b/app/serializers/build_metadata_entity.rb
@@ -0,0 +1,6 @@
+class BuildMetadataEntity < Grape::Entity
+ expose :timeout_human_readable
+ expose :timeout_source do |metadata|
+ metadata.present.timeout_source
+ end
+end
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 3f9a275ad08..b22a0b666ef 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -2,4 +2,5 @@ class ClusterApplicationEntity < Grape::Entity
expose :name
expose :status_name, as: :status
expose :status_reason
+ expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
end
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index c75431a79ae..2678f99510c 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -3,19 +3,20 @@ class DeployKeyEntity < Grape::Entity
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) }
+ expose :deploy_keys_projects, using: DeployKeysProjectEntity do |deploy_key|
+ deploy_key.deploy_keys_projects
+ .without_project_deleted
+ .select { |deploy_key_project| Ability.allowed?(options[:user], :read_project, deploy_key_project.project) }
end
expose :can_edit
private
def can_edit
- options[:user].can?(:update_deploy_key, object)
+ Ability.allowed?(options[:user], :update_deploy_key, object)
end
end
diff --git a/app/serializers/deploy_keys_project_entity.rb b/app/serializers/deploy_keys_project_entity.rb
new file mode 100644
index 00000000000..568ef5ab75e
--- /dev/null
+++ b/app/serializers/deploy_keys_project_entity.rb
@@ -0,0 +1,4 @@
+class DeployKeysProjectEntity < Grape::Entity
+ expose :can_push
+ expose :project, using: ProjectEntity
+end
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
new file mode 100644
index 00000000000..6e68d275047
--- /dev/null
+++ b/app/serializers/diff_file_entity.rb
@@ -0,0 +1,41 @@
+class DiffFileEntity < Grape::Entity
+ include DiffHelper
+ include SubmoduleHelper
+ include BlobHelper
+ include IconsHelper
+ include ActionView::Helpers::TagHelper
+
+ expose :submodule?, as: :submodule
+
+ expose :submodule_link do |diff_file|
+ submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first
+ end
+
+ expose :blob_path do |diff_file|
+ diff_file.blob.path
+ end
+
+ expose :blob_icon do |diff_file|
+ blob_icon(diff_file.b_mode, diff_file.file_path)
+ end
+
+ expose :file_path
+ expose :deleted_file?, as: :deleted_file
+ expose :renamed_file?, as: :renamed_file
+ expose :old_path
+ expose :new_path
+ expose :mode_changed?, as: :mode_changed
+ expose :a_mode
+ expose :b_mode
+ expose :text?, as: :text
+
+ expose :old_path_html do |diff_file|
+ old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
+ old_path
+ end
+
+ expose :new_path_html do |diff_file|
+ _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
+ new_path
+ end
+end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index 0a92e3f8167..718fb35e62d 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -4,7 +4,47 @@ class DiscussionEntity < Grape::Entity
expose :id, :reply_id
expose :expanded?, as: :expanded
- expose :notes, using: NoteEntity
+ expose :notes do |discussion, opts|
+ request.note_entity.represent(discussion.notes, opts)
+ end
expose :individual_note?, as: :individual_note
+ expose :resolvable?, as: :resolvable
+ expose :resolved?, as: :resolved
+ expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
+ resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
+ end
+ expose :resolve_with_issue_path, if: -> (d, _) { d.resolvable? } do |discussion|
+ new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
+ end
+
+ expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file }
+
+ expose :diff_discussion?, as: :diff_discussion
+
+ expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion|
+ options[:context].render_to_string(
+ partial: "projects/diffs/line",
+ collection: discussion.truncated_diff_lines,
+ as: :line,
+ locals: { diff_file: discussion.diff_file,
+ discussion_expanded: true,
+ plain: true },
+ layout: false,
+ formats: [:html]
+ )
+ end
+
+ expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion|
+ diff_file = discussion.diff_file
+ partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff'
+ options[:context].render_to_string(
+ partial: "projects/diffs/#{partial}",
+ locals: { diff_file: diff_file,
+ position: discussion.position.to_json,
+ click_to_comment: false },
+ layout: false,
+ formats: [:html]
+ )
+ end
end
diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb
deleted file mode 100644
index 935d67a4f37..00000000000
--- a/app/serializers/event_entity.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-class EventEntity < Grape::Entity
- expose :author, using: UserEntity
- expose :updated_at
-end
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
index 37240bfb0b1..15ec0f89bb2 100644
--- a/app/serializers/group_child_entity.rb
+++ b/app/serializers/group_child_entity.rb
@@ -1,6 +1,7 @@
class GroupChildEntity < Grape::Entity
include ActionView::Helpers::NumberHelper
include RequestAwareEntity
+ include MarkupHelper
expose :id, :name, :description, :visibility, :full_name,
:created_at, :updated_at, :avatar_url
@@ -10,9 +11,7 @@ class GroupChildEntity < Grape::Entity
end
expose :can_edit do |instance|
- return false unless request.respond_to?(:current_user)
-
- can?(request.current_user, "admin_#{type}", instance)
+ can_edit?
end
expose :edit_path do |instance|
@@ -59,6 +58,10 @@ class GroupChildEntity < Grape::Entity
number_with_delimiter(instance.member_count)
end
+ expose :markdown_description do |instance|
+ markdown_description
+ end
+
private
def membership
@@ -74,4 +77,21 @@ class GroupChildEntity < Grape::Entity
def type
object.class.name.downcase
end
+
+ def markdown_description
+ markdown_field(object, :description)
+ end
+
+ def can_edit?
+ return false unless request.respond_to?(:current_user)
+
+ if project?
+ # Avoid checking rights for each project, as it might be expensive if the
+ # user cannot read cross project.
+ can?(request.current_user, :read_cross_project) &&
+ can?(request.current_user, :admin_project, object)
+ else
+ can?(request.current_user, :admin_group, object)
+ end
+ end
end
diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb
new file mode 100644
index 00000000000..62cf0b21e1e
--- /dev/null
+++ b/app/serializers/group_variable_entity.rb
@@ -0,0 +1,7 @@
+class GroupVariableEntity < Grape::Entity
+ expose :id
+ expose :key
+ expose :value
+
+ expose :protected?, as: :protected
+end
diff --git a/app/serializers/group_variable_serializer.rb b/app/serializers/group_variable_serializer.rb
new file mode 100644
index 00000000000..8f8205924aa
--- /dev/null
+++ b/app/serializers/group_variable_serializer.rb
@@ -0,0 +1,3 @@
+class GroupVariableSerializer < BaseSerializer
+ entity GroupVariableEntity
+end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index 0bdd4d7a272..b5e2334b6e3 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -6,7 +6,6 @@ class IssueEntity < IssuableEntity
expose :updated_by_id
expose :created_at
expose :updated_at
- expose :deleted_at
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
expose :lock_version
diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb
index 72e56a2c77f..523b522d449 100644
--- a/app/serializers/job_entity.rb
+++ b/app/serializers/job_entity.rb
@@ -4,6 +4,8 @@ class JobEntity < Grape::Entity
expose :id
expose :name
+ expose :started?, as: :started
+
expose :build_path do |build|
build.target_url || path_to(:namespace_project_job, build)
end
diff --git a/app/serializers/lfs_file_lock_entity.rb b/app/serializers/lfs_file_lock_entity.rb
new file mode 100644
index 00000000000..264a77adc3f
--- /dev/null
+++ b/app/serializers/lfs_file_lock_entity.rb
@@ -0,0 +1,11 @@
+class LfsFileLockEntity < Grape::Entity
+ root 'locks', 'lock'
+
+ expose :path
+ expose(:id) { |entity| entity.id.to_s }
+ expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_s(:iso8601) }
+
+ expose :owner do
+ expose(:name) { |entity| entity.user&.name }
+ end
+end
diff --git a/app/serializers/lfs_file_lock_serializer.rb b/app/serializers/lfs_file_lock_serializer.rb
new file mode 100644
index 00000000000..ba8fb1a461d
--- /dev/null
+++ b/app/serializers/lfs_file_lock_serializer.rb
@@ -0,0 +1,3 @@
+class LfsFileLockSerializer < BaseSerializer
+ entity LfsFileLockEntity
+end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index d54a6516aed..e4aec977f01 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -4,4 +4,5 @@ class MergeRequestBasicEntity < IssuableSidebarEntity
expose :merge_error
expose :state
expose :source_branch_exists?, as: :source_branch_exists
+ expose :rebase_in_progress?, as: :rebase_in_progress
end
diff --git a/app/serializers/merge_request_metrics_entity.rb b/app/serializers/merge_request_metrics_entity.rb
new file mode 100644
index 00000000000..3548107ac16
--- /dev/null
+++ b/app/serializers/merge_request_metrics_entity.rb
@@ -0,0 +1,6 @@
+class MergeRequestMetricsEntity < Grape::Entity
+ expose :latest_closed_at, as: :closed_at
+ expose :merged_at
+ expose :latest_closed_by, as: :closed_by, using: UserEntity
+ expose :merged_by, using: UserEntity
+end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index f8e59b2ffd7..4a812e39ee1 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -11,22 +11,36 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :source_project_id
expose :target_branch
expose :target_project_id
+ expose :allow_maintainer_to_push
expose :should_be_rebased?, as: :should_be_rebased
expose :ff_only_enabled do |merge_request|
merge_request.project.merge_requests_ff_only_enabled
end
- # Events
- expose :merge_event, using: EventEntity
- expose :closed_event, using: EventEntity
+ expose :metrics do |merge_request|
+ metrics = build_metrics(merge_request)
+
+ MergeRequestMetricsEntity.new(metrics).as_json
+ end
+
+ expose :rebase_commit_sha
+ expose :rebase_in_progress?, as: :rebase_in_progress
+
+ expose :can_push_to_source_branch do |merge_request|
+ presenter(merge_request).can_push_to_source_branch?
+ end
+
+ expose :rebase_path do |merge_request|
+ presenter(merge_request).rebase_path
+ end
# User entities
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
+ merge_request.diff_head_sha.presence
end
expose :merge_commit_message
@@ -36,7 +50,18 @@ class MergeRequestWidgetEntity < IssuableEntity
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 :mergeable_discussions_state?, as: :mergeable_discussions_state do |merge_request|
+ # This avoids calling MergeRequest#mergeable_discussions_state without
+ # considering the state of the MR first. If a MR isn't mergeable, we can
+ # safely short-circuit it.
+ if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true)
+ merge_request.mergeable_discussions_state?
+ else
+ false
+ end
+ end
+
expose :branch_missing?, as: :branch_missing
expose :commits_count
expose :cannot_be_merged?, as: :has_conflicts
@@ -92,6 +117,14 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :can_cherry_pick_on_current_merge_request do |merge_request|
presenter(merge_request).can_cherry_pick_on_current_merge_request?
end
+
+ expose :can_create_note do |issue|
+ can?(request.current_user, :create_note, issue.project)
+ end
+
+ expose :can_update do |issue|
+ can?(request.current_user, :update_issue, issue)
+ end
end
# Paths
@@ -105,8 +138,8 @@ class MergeRequestWidgetEntity < IssuableEntity
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)
+ if presenter(merge_request).can_push_to_source_branch?
+ project_new_blob_path(merge_request.source_project, merge_request.source_branch)
end
end
@@ -166,6 +199,10 @@ class MergeRequestWidgetEntity < IssuableEntity
end
end
+ expose :create_note_path do |merge_request|
+ project_notes_path(merge_request.project, target_type: 'merge_request', target_id: merge_request.id)
+ end
+
expose :commit_change_content_path do |merge_request|
commit_change_content_project_merge_request_path(merge_request.project, merge_request)
end
@@ -178,4 +215,27 @@ class MergeRequestWidgetEntity < IssuableEntity
@presenters ||= {}
@presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user)
end
+
+ # Once SchedulePopulateMergeRequestMetricsWithEventsData fully runs,
+ # we can remove this method and just serialize MergeRequest#metrics
+ # instead. See https://gitlab.com/gitlab-org/gitlab-ce/issues/41587
+ def build_metrics(merge_request)
+ # There's no need to query and serialize metrics data for merge requests that are not
+ # merged or closed.
+ return unless merge_request.merged? || merge_request.closed?
+ return merge_request.metrics if merge_request.merged? && merge_request.metrics&.merged_by_id
+ return merge_request.metrics if merge_request.closed? && merge_request.metrics&.latest_closed_by_id
+
+ build_metrics_from_events(merge_request)
+ end
+
+ def build_metrics_from_events(merge_request)
+ closed_event = merge_request.closed_event
+ merge_event = merge_request.merge_event
+
+ MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at,
+ latest_closed_by: closed_event&.author,
+ merged_at: merge_event&.updated_at,
+ merged_by: merge_event&.author)
+ end
end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index 7d50e0ff10d..c964aa9c99b 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -5,10 +5,6 @@ class NoteEntity < API::Entities::Note
expose :author, using: NoteUserEntity
- expose :human_access do |note|
- note.project.team.human_max_access(note.author_id)
- end
-
unexpose :note, as: :body
expose :note
@@ -23,6 +19,10 @@ class NoteEntity < API::Entities::Note
end
end
+ expose :resolved?, as: :resolved
+ expose :resolvable?, as: :resolvable
+ expose :resolved_by, using: NoteUserEntity
+
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
SystemNoteHelper.system_note_icon_name(note)
end
@@ -33,28 +33,10 @@ class NoteEntity < API::Entities::Note
expose :emoji_awardable?, as: :emoji_awardable
expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
- expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
- if note.for_personal_snippet?
- toggle_award_emoji_snippet_note_path(note.noteable, note)
- else
- toggle_award_emoji_project_note_path(note.project, note.id)
- end
- end
expose :report_abuse_path do |note|
new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
end
- expose :path do |note|
- if note.for_personal_snippet?
- snippet_note_path(note.noteable, note)
- else
- project_note_path(note.project, note)
- end
- end
-
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
- expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
- delete_attachment_project_note_path(note.project, note)
- end
end
diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb
deleted file mode 100644
index 2afe40d7a34..00000000000
--- a/app/serializers/note_serializer.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-class NoteSerializer < BaseSerializer
- entity NoteEntity
-end
diff --git a/app/serializers/project_note_entity.rb b/app/serializers/project_note_entity.rb
new file mode 100644
index 00000000000..e541bfbee8d
--- /dev/null
+++ b/app/serializers/project_note_entity.rb
@@ -0,0 +1,25 @@
+class ProjectNoteEntity < NoteEntity
+ expose :human_access do |note|
+ note.project.team.human_max_access(note.author_id)
+ end
+
+ expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
+ toggle_award_emoji_project_note_path(note.project, note.id)
+ end
+
+ expose :path do |note|
+ project_note_path(note.project, note)
+ end
+
+ expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
+ resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
+ end
+
+ expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
+ new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
+ end
+
+ expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
+ delete_attachment_project_note_path(note.project, note)
+ end
+end
diff --git a/app/serializers/project_note_serializer.rb b/app/serializers/project_note_serializer.rb
new file mode 100644
index 00000000000..763ad0bdb3f
--- /dev/null
+++ b/app/serializers/project_note_serializer.rb
@@ -0,0 +1,3 @@
+class ProjectNoteSerializer < BaseSerializer
+ entity ProjectNoteEntity
+end
diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb
new file mode 100644
index 00000000000..74de1e79a8f
--- /dev/null
+++ b/app/serializers/project_serializer.rb
@@ -0,0 +1,3 @@
+class ProjectSerializer < BaseSerializer
+ entity ProjectEntity
+end
diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb
index 3e40ecf1c1c..8e8bda2f9df 100644
--- a/app/serializers/status_entity.rb
+++ b/app/serializers/status_entity.rb
@@ -2,13 +2,19 @@ class StatusEntity < Grape::Entity
include RequestAwareEntity
expose :icon, :text, :label, :group
-
+ expose :status_tooltip, as: :tooltip
expose :has_details?, as: :has_details
expose :details_path
expose :favicon do |status|
- dir = 'ci_favicons'
- dir = File.join(dir, 'dev') if Rails.env.development?
+ dir =
+ if Gitlab::Utils.to_boolean(ENV['CANARY'])
+ File.join('ci_favicons', 'canary')
+ elsif Rails.env.development?
+ File.join('ci_favicons', 'dev')
+ else
+ 'ci_favicons'
+ end
ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico"))
end
diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb
new file mode 100644
index 00000000000..d576745c073
--- /dev/null
+++ b/app/serializers/variable_entity.rb
@@ -0,0 +1,7 @@
+class VariableEntity < Grape::Entity
+ expose :id
+ expose :key
+ expose :value
+
+ expose :protected?, as: :protected
+end
diff --git a/app/serializers/variable_serializer.rb b/app/serializers/variable_serializer.rb
new file mode 100644
index 00000000000..32ae82ab51c
--- /dev/null
+++ b/app/serializers/variable_serializer.rb
@@ -0,0 +1,3 @@
+class VariableSerializer < BaseSerializer
+ entity VariableEntity
+end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
index aa6f0e841c9..0521393dd27 100644
--- a/app/services/akismet_service.rb
+++ b/app/services/akismet_service.rb
@@ -1,6 +1,4 @@
class AkismetService
- include Gitlab::CurrentSettings
-
attr_accessor :owner, :text, :options
def initialize(owner, text, options = {})
@@ -41,12 +39,12 @@ class AkismetService
private
def akismet_client
- @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key,
+ @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key,
Gitlab.config.gitlab.url)
end
def akismet_enabled?
- current_application_settings.akismet_enabled
+ Gitlab::CurrentSettings.akismet_enabled
end
def submit(type)
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index f40cd2b06c8..8f050072f74 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -1,7 +1,5 @@
module Auth
class ContainerRegistryAuthenticationService < BaseService
- extend Gitlab::CurrentSettings
-
AUDIENCE = 'container_registry'.freeze
def execute(authentication_abilities:)
@@ -32,7 +30,7 @@ module Auth
end
def self.token_expire_at
- Time.now + current_application_settings.container_registry_token_expire_delay.minutes
+ Time.now + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes
end
private
@@ -111,7 +109,7 @@ module Auth
case requested_action
when 'pull'
- build_can_pull?(requested_project) || user_can_pull?(requested_project)
+ build_can_pull?(requested_project) || user_can_pull?(requested_project) || deploy_token_can_pull?(requested_project)
when 'push'
build_can_push?(requested_project) || user_can_push?(requested_project)
when '*'
@@ -125,22 +123,33 @@ module Auth
Gitlab.config.registry
end
+ def can_user?(ability, project)
+ user = current_user.is_a?(User) ? current_user : nil
+ can?(user, ability, project)
+ end
+
def build_can_pull?(requested_project)
# Build can:
# 1. pull from its own project (for ex. a build)
# 2. read images from dependent projects if creator of build is a team member
has_authentication_ability?(:build_read_container_image) &&
- (requested_project == project || can?(current_user, :build_read_container_image, requested_project))
+ (requested_project == project || can_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)
+ can_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)
+ can_user?(:read_container_image, requested_project)
+ end
+
+ def deploy_token_can_pull?(requested_project)
+ has_authentication_ability?(:read_container_image) &&
+ current_user.is_a?(DeployToken) &&
+ current_user.has_access_to?(requested_project)
end
##
@@ -156,7 +165,7 @@ module Auth
def user_can_push?(requested_project)
has_authentication_ability?(:create_container_image) &&
- can?(current_user, :create_container_image, requested_project)
+ can_user?(:create_container_image, requested_project)
end
def error(code, status:, message: '')
diff --git a/app/services/badges/base_service.rb b/app/services/badges/base_service.rb
new file mode 100644
index 00000000000..4f87426bd38
--- /dev/null
+++ b/app/services/badges/base_service.rb
@@ -0,0 +1,11 @@
+module Badges
+ class BaseService
+ protected
+
+ attr_accessor :params
+
+ def initialize(params = {})
+ @params = params.dup
+ end
+ end
+end
diff --git a/app/services/badges/build_service.rb b/app/services/badges/build_service.rb
new file mode 100644
index 00000000000..6267e571838
--- /dev/null
+++ b/app/services/badges/build_service.rb
@@ -0,0 +1,12 @@
+module Badges
+ class BuildService < Badges::BaseService
+ # returns the created badge
+ def execute(source)
+ if source.is_a?(Group)
+ GroupBadge.new(params.merge(group: source))
+ else
+ ProjectBadge.new(params.merge(project: source))
+ end
+ end
+ end
+end
diff --git a/app/services/badges/create_service.rb b/app/services/badges/create_service.rb
new file mode 100644
index 00000000000..aafb87f7dcd
--- /dev/null
+++ b/app/services/badges/create_service.rb
@@ -0,0 +1,10 @@
+module Badges
+ class CreateService < Badges::BaseService
+ # returns the created badge
+ def execute(source)
+ badge = Badges::BuildService.new(params).execute(source)
+
+ badge.tap { |b| b.save }
+ end
+ end
+end
diff --git a/app/services/badges/update_service.rb b/app/services/badges/update_service.rb
new file mode 100644
index 00000000000..7ca84b5df31
--- /dev/null
+++ b/app/services/badges/update_service.rb
@@ -0,0 +1,12 @@
+module Badges
+ class UpdateService < Badges::BaseService
+ # returns the updated badge
+ def execute(badge)
+ if params.present?
+ badge.update_attributes(params)
+ end
+
+ badge
+ end
+ end
+end
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index a0cb00dba58..6883ba36c71 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -1,6 +1,5 @@
class BaseService
include Gitlab::Allowable
- include Gitlab::CurrentSettings
attr_accessor :project, :current_user, :params
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 6078fe38064..ac70a99c2c5 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -35,18 +35,27 @@ module Boards
def filter_params
set_parent
set_state
+ set_scope
params
end
def set_parent
- params[:project_id] = parent.id
+ if parent.is_a?(Group)
+ params[:group_id] = parent.id
+ else
+ params[:project_id] = parent.id
+ end
end
def set_state
params[:state] = list && list.closed? ? 'closed' : 'opened'
end
+ def set_scope
+ params[:include_subgroups] = board.group_board?
+ end
+
def board_label_ids
@board_label_ids ||= board.lists.movable.pluck(:label_id)
end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index 797d6df7c1a..3ceab209f3f 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -42,7 +42,10 @@ module Boards
)
end
- attrs[:move_between_ids] = move_between_ids if move_between_ids
+ if move_between_ids
+ attrs[:move_between_ids] = move_between_ids
+ attrs[:board_group_id] = board.group&.id
+ end
attrs
end
@@ -60,8 +63,10 @@ module Boards
label_ids =
if moving_to_list.movable?
moving_from_list.label_id
+ elsif board.group_board?
+ ::Label.on_group_boards(parent.id).pluck(:label_id)
else
- Label.on_project_boards(parent.id).pluck(:label_id)
+ ::Label.on_project_boards(parent.id).pluck(:label_id)
end
Array(label_ids).compact
diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb
index 6d0dd0a9f99..9269b8d2620 100644
--- a/app/services/boards/list_service.rb
+++ b/app/services/boards/list_service.rb
@@ -2,11 +2,15 @@ module Boards
class ListService < Boards::BaseService
def execute
create_board! if parent.boards.empty?
- parent.boards
+ boards
end
private
+ def boards
+ parent.boards
+ end
+
def create_board!
Boards::CreateService.new(parent, current_user).execute
end
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
index 183556a1d6b..02f1c709374 100644
--- a/app/services/boards/lists/create_service.rb
+++ b/app/services/boards/lists/create_service.rb
@@ -12,7 +12,15 @@ module Boards
private
def available_labels_for(board)
- LabelsFinder.new(current_user, project_id: parent.id).execute
+ options = { include_ancestor_groups: true }
+
+ if board.group_board?
+ options.merge!(group_id: parent.id, only_group_labels: true)
+ else
+ options[:project_id] = parent.id
+ end
+
+ LabelsFinder.new(current_user, options).execute
end
def next_position(board)
diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb
index 4f5c5567b42..d458b814183 100644
--- a/app/services/chat_names/find_user_service.rb
+++ b/app/services/chat_names/find_user_service.rb
@@ -9,8 +9,8 @@ module ChatNames
chat_name = find_chat_name
return unless chat_name
- chat_name.touch(:last_used_at)
- chat_name.user
+ chat_name.update_last_used_at
+ chat_name
end
private
diff --git a/app/services/check_gcp_project_billing_service.rb b/app/services/check_gcp_project_billing_service.rb
new file mode 100644
index 00000000000..ea82b61b279
--- /dev/null
+++ b/app/services/check_gcp_project_billing_service.rb
@@ -0,0 +1,11 @@
+class CheckGcpProjectBillingService
+ def execute(token)
+ client = GoogleApi::CloudPlatform::Client.new(token, nil)
+ client.projects_list.select do |project|
+ begin
+ client.projects_get_billing_info(project.project_id).billing_enabled
+ rescue
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index c8b112132b3..6ce86983287 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -7,6 +7,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Validate::Repository,
Gitlab::Ci::Pipeline::Chain::Validate::Config,
Gitlab::Ci::Pipeline::Chain::Skip,
+ Gitlab::Ci::Pipeline::Chain::Populate,
Gitlab::Ci::Pipeline::Chain::Create].freeze
def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block)
@@ -65,7 +66,7 @@ module Ci
project.pipelines
.where(ref: pipeline.ref)
.where.not(id: pipeline.id)
- .where.not(sha: project.repository.sha_from_ref(pipeline.ref))
+ .where.not(sha: project.commit(pipeline.ref).try(:id))
.created_or_pending
end
@@ -81,7 +82,7 @@ module Ci
end
def related_merge_requests
- MergeRequest.opened.where(source_project: pipeline.project, source_branch: pipeline.ref)
+ pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref)
end
end
end
diff --git a/app/services/ci/create_pipeline_stages_service.rb b/app/services/ci/create_pipeline_stages_service.rb
deleted file mode 100644
index f2c175adee6..00000000000
--- a/app/services/ci/create_pipeline_stages_service.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-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/ensure_stage_service.rb b/app/services/ci/ensure_stage_service.rb
index dc2f49e8db1..87f19b333de 100644
--- a/app/services/ci/ensure_stage_service.rb
+++ b/app/services/ci/ensure_stage_service.rb
@@ -7,6 +7,8 @@ module Ci
# stage.
#
class EnsureStageService < BaseService
+ EnsureStageError = Class.new(StandardError)
+
def execute(build)
@build = build
@@ -22,8 +24,16 @@ module Ci
private
- def ensure_stage
+ def ensure_stage(attempts: 2)
find_stage || create_stage
+ rescue ActiveRecord::RecordNotUnique
+ retry if (attempts -= 1) > 0
+
+ raise EnsureStageError, <<~EOS
+ We failed to find or create a unique pipeline stage after 2 retries.
+ This should never happen and is most likely the result of a bug in
+ the database load balancing code.
+ EOS
end
def find_stage
diff --git a/app/services/ci/fetch_kubernetes_token_service.rb b/app/services/ci/fetch_kubernetes_token_service.rb
index e73c6ad6780..bca883ec0a0 100644
--- a/app/services/ci/fetch_kubernetes_token_service.rb
+++ b/app/services/ci/fetch_kubernetes_token_service.rb
@@ -32,7 +32,7 @@ module Ci
kubeclient = build_kubeclient!
kubeclient.get_secrets.as_json
- rescue KubeException => err
+ rescue Kubeclient::HttpError => err
raise err unless err.error_code == 404
[]
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index a9813d774bb..85533a1cbdb 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -16,8 +16,8 @@ module Ci
pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: params[:ref])
.execute(:trigger, ignore_skip_ci: true) do |pipeline|
- pipeline.trigger_requests.create!(trigger: trigger)
- create_pipeline_variables!(pipeline)
+ pipeline.trigger_requests.build(trigger: trigger)
+ pipeline.variables.build(variables)
end
if pipeline.persisted?
@@ -33,14 +33,10 @@ module Ci
end
end
- def create_pipeline_variables!(pipeline)
- return unless params[:variables]
-
- variables = params[:variables].map do |key, value|
+ def variables
+ params[:variables].to_h.map do |key, value|
{ key: key, value: value }
end
-
- pipeline.variables.create!(variables)
end
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index f832b79ef21..d46dcff34a1 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -2,10 +2,11 @@ module Ci
# This class responsible for assigning
# proper pending build to runner on runner API request
class RegisterJobService
- include Gitlab::CurrentSettings
-
attr_reader :runner
+ JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30].freeze
+ JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze
+
Result = Struct.new(:build, :valid?)
def initialize(runner)
@@ -106,10 +107,22 @@ module Ci
end
def register_success(job)
- job_queue_duration_seconds.observe({ shared_runner: @runner.shared? }, Time.now - job.created_at)
+ labels = { shared_runner: runner.shared?,
+ jobs_running_for_project: jobs_running_for_project(job) }
+
+ job_queue_duration_seconds.observe(labels, Time.now - job.queued_at)
attempt_counter.increment
end
+ def jobs_running_for_project(job)
+ return '+Inf' unless runner.shared?
+
+ # excluding currently started job
+ running_jobs_count = job.project.builds.running.where(runner: Ci::Runner.shared)
+ .limit(JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET + 1).count - 1
+ running_jobs_count < JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET ? running_jobs_count : "#{JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET}+"
+ 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
@@ -119,7 +132,7 @@ module Ci
end
def job_queue_duration_seconds
- @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time')
+ @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time', {}, JOB_QUEUE_DURATION_SECONDS_BUCKETS)
end
end
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index c552193e66b..6128b2a8fbb 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -1,7 +1,7 @@
module Ci
class RetryBuildService < ::BaseService
CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
- allow_failure stage_id stage stage_idx trigger_request
+ allow_failure stage stage_id stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list protected].freeze
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
index 9a4ce31cb39..cba1b920f7c 100644
--- a/app/services/clusters/applications/base_helm_service.rb
+++ b/app/services/clusters/applications/base_helm_service.rb
@@ -18,7 +18,7 @@ module Clusters
end
def helm_api
- @helm_api ||= Gitlab::Kubernetes::Helm.new(kubeclient)
+ @helm_api ||= Gitlab::Kubernetes::Helm::Api.new(kubeclient)
end
def install_command
diff --git a/app/services/clusters/applications/check_ingress_ip_address_service.rb b/app/services/clusters/applications/check_ingress_ip_address_service.rb
new file mode 100644
index 00000000000..e572b1e5d99
--- /dev/null
+++ b/app/services/clusters/applications/check_ingress_ip_address_service.rb
@@ -0,0 +1,36 @@
+module Clusters
+ module Applications
+ class CheckIngressIpAddressService < BaseHelmService
+ include Gitlab::Utils::StrongMemoize
+
+ Error = Class.new(StandardError)
+
+ LEASE_TIMEOUT = 15.seconds.to_i
+
+ def execute
+ return if app.external_ip
+ return unless try_obtain_lease
+
+ app.update!(external_ip: ingress_ip) if ingress_ip
+ end
+
+ private
+
+ def try_obtain_lease
+ Gitlab::ExclusiveLease
+ .new("check_ingress_ip_address_service:#{app.id}", timeout: LEASE_TIMEOUT)
+ .try_obtain
+ end
+
+ def ingress_ip
+ service.status.loadBalancer.ingress&.first&.ip
+ end
+
+ def service
+ strong_memoize(:ingress_service) do
+ kubeclient.get_service('ingress-nginx-ingress-controller', Gitlab::Kubernetes::Helm::NAMESPACE)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
index bde090eaeec..90393e951a4 100644
--- a/app/services/clusters/applications/check_installation_progress_service.rb
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -12,7 +12,7 @@ module Clusters
else
check_timeout
end
- rescue KubeException => ke
+ rescue Kubeclient::HttpError => ke
app.make_errored!("Kubernetes error: #{ke.message}") unless app.errored?
end
diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb
index 8ceeec687cd..4c25a09814b 100644
--- a/app/services/clusters/applications/install_service.rb
+++ b/app/services/clusters/applications/install_service.rb
@@ -10,7 +10,7 @@ module Clusters
ClusterWaitForAppInstallationWorker.perform_in(
ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
- rescue KubeException => ke
+ rescue Kubeclient::HttpError => ke
app.make_errored!("Kubernetes error: #{ke.message}")
rescue StandardError
app.make_errored!("Can't start installation process")
diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
index 0471b0f17a2..418888e3293 100644
--- a/app/services/clusters/create_service.rb
+++ b/app/services/clusters/create_service.rb
@@ -5,7 +5,7 @@ module Clusters
def execute(access_token = nil)
@access_token = access_token
- raise ArgumentError.new('Instance does not support multiple clusters') unless can_create_cluster?
+ raise ArgumentError.new(_('Instance does not support multiple Kubernetes clusters')) unless can_create_cluster?
create_cluster.tap do |cluster|
ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted?
diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb
index cea56f4e849..15ab2d54404 100644
--- a/app/services/clusters/gcp/finalize_creation_service.rb
+++ b/app/services/clusters/gcp/finalize_creation_service.rb
@@ -30,10 +30,10 @@ module Clusters
ca_cert: Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate),
username: gke_cluster.master_auth.username,
password: gke_cluster.master_auth.password,
- token: request_kuberenetes_token)
+ token: request_kubernetes_token)
end
- def request_kuberenetes_token
+ def request_kubernetes_token
Ci::FetchKubernetesTokenService.new(
'https://' + gke_cluster.endpoint,
Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate),
diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb
index bc33756f27c..f994aacd086 100644
--- a/app/services/clusters/gcp/verify_provision_status_service.rb
+++ b/app/services/clusters/gcp/verify_provision_status_service.rb
@@ -28,7 +28,7 @@ module Clusters
if elapsed_time_from_creation(operation) < TIMEOUT
WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, provider.cluster_id)
else
- provider.make_errored!("Cluster creation time exceeds timeout; #{TIMEOUT}")
+ provider.make_errored!(_('Kubernetes cluster creation time exceeds timeout; %{timeout}') % { timeout: TIMEOUT })
end
end
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index 1db91c3c90c..2a69a205629 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -10,9 +10,14 @@ class CompareService
@start_ref_name = new_start_ref_name
end
- def execute(target_project, target_ref, straight: false)
+ def execute(target_project, target_ref, base_sha: nil, straight: false)
raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight)
- Compare.new(raw_compare, target_project, straight: straight) if raw_compare
+ return unless raw_compare
+
+ Compare.new(raw_compare,
+ target_project,
+ base_sha: base_sha,
+ straight: straight)
end
end
diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb
index 63b85c3de7d..88dfb7a4a90 100644
--- a/app/services/create_deployment_service.rb
+++ b/app/services/create_deployment_service.rb
@@ -16,6 +16,7 @@ class CreateDeploymentService
ActiveRecord::Base.transaction do
environment.external_url = expanded_environment_url if
expanded_environment_url
+
environment.fire_state_event(action)
return unless environment.save
diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb
index cb235a85daf..c98d1e3c540 100644
--- a/app/services/delete_merged_branches_service.rb
+++ b/app/services/delete_merged_branches_service.rb
@@ -6,18 +6,14 @@ class DeleteMergedBranchesService < BaseService
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project)
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37438
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- branches = project.repository.branch_names
- 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| ProtectedBranch.protected?(project, branch) }
+ branches = project.repository.merged_branch_names
+ # Prevent deletion of branches relevant to open merge requests
+ branches -= merge_request_branch_names
+ # Prevent deletion of protected branches
+ branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) }
- branches.each do |branch|
- DeleteBranchService.new(project, current_user).execute(branch)
- end
+ branches.each do |branch|
+ DeleteBranchService.new(project, current_user).execute(branch)
end
end
diff --git a/app/services/deploy_tokens/create_service.rb b/app/services/deploy_tokens/create_service.rb
new file mode 100644
index 00000000000..52f545947af
--- /dev/null
+++ b/app/services/deploy_tokens/create_service.rb
@@ -0,0 +1,7 @@
+module DeployTokens
+ class CreateService < BaseService
+ def execute
+ @project.deploy_tokens.create(params)
+ end
+ end
+end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 6328d567a07..44dc90b3462 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -103,6 +103,6 @@ class EventCreateService
author_id: current_user.id
)
- Event.create(attributes)
+ Event.create!(attributes)
end
end
diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb
index 00a8dcf0934..a954564946b 100644
--- a/app/services/files/create_service.rb
+++ b/app/services/files/create_service.rb
@@ -1,10 +1,20 @@
module Files
class CreateService < Files::BaseService
def create_commit!
+ transformer = Lfs::FileTransformer.new(project, @branch_name)
+
+ result = transformer.new_file(@file_path, @file_content)
+
+ create_transformed_commit(result.content)
+ end
+
+ private
+
+ def create_transformed_commit(content_or_lfs_pointer)
repository.create_file(
current_user,
@file_path,
- @file_content,
+ content_or_lfs_pointer,
message: @commit_message,
branch_name: @branch_name,
author_email: @author_email,
diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb
index 98a3e83c130..13a1dee4173 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -3,33 +3,46 @@ module Files
UPDATE_FILE_ACTIONS = %w(update move delete).freeze
def create_commit!
+ transformer = Lfs::FileTransformer.new(project, @branch_name)
+
+ actions = actions_after_lfs_transformation(transformer, params[:actions])
+
+ commit_actions!(actions)
+ end
+
+ private
+
+ def actions_after_lfs_transformation(transformer, actions)
+ actions.map do |action|
+ if action[:action] == 'create'
+ result = transformer.new_file(action[:file_path], action[:content], encoding: action[:encoding])
+ action[:content] = result.content
+ action[:encoding] = result.encoding
+ end
+
+ action
+ end
+ end
+
+ def commit_actions!(actions)
repository.multi_action(
- user: current_user,
+ current_user,
message: @commit_message,
branch_name: @branch_name,
- actions: params[:actions],
+ actions: actions,
author_email: @author_email,
author_name: @author_name,
start_project: @start_project,
start_branch_name: @start_branch
)
+ rescue ArgumentError => e
+ raise_error(e)
end
- private
-
def validate!
super
- params[:actions].each do |action|
- validate_action!(action)
- validate_file_status!(action)
- end
- end
-
- def validate_action!(action)
- unless Gitlab::Git::Index::ACTIONS.include?(action[:action].to_s)
- raise_error("Unknown action '#{action[:action]}'")
- end
+ params[:actions].each { |action| validate_file_status!(action) }
end
def validate_file_status!(action)
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index bb61136e33b..c037141fcde 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -1,6 +1,5 @@
class GitPushService < BaseService
attr_accessor :push_data, :push_commits
- include Gitlab::CurrentSettings
include Gitlab::Access
# The N most recent commits to process in a single push payload.
@@ -154,24 +153,7 @@ class GitPushService < BaseService
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)
-
- # Set protection on the default branch if configured
- if current_application_settings.default_branch_protection != PROTECTION_NONE && !ProtectedBranch.protected?(@project, @project.default_branch)
-
- params = {
- name: @project.default_branch,
- push_access_levels_attributes: [{
- access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }],
- merge_access_levels_attributes: [{
- access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }]
- }
-
- ProtectedBranches::CreateService.new(@project, current_user, params).execute
- end
+ @project.after_create_default_branch
end
def build_push_data
diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb
index e77e08aa380..c6e52c3bb91 100644
--- a/app/services/gravatar_service.rb
+++ b/app/services/gravatar_service.rb
@@ -1,8 +1,6 @@
class GravatarService
- include Gitlab::CurrentSettings
-
def execute(email, size = nil, scale = 2, username: nil)
- return unless current_application_settings.gravatar_enabled?
+ return unless Gitlab::CurrentSettings.gravatar_enabled?
identifier = email.presence || username.presence
return unless identifier
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index e3f9d9ee95d..58e88688dfa 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -1,7 +1,6 @@
module Groups
class DestroyService < Groups::BaseService
def async_execute
- 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
@@ -23,7 +22,7 @@ module Groups
group.chat_team&.remove_mattermost_team(current_user)
- group.really_destroy!
+ group.destroy
end
end
end
diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb
index d6f08fc3cce..5c337a9faa5 100644
--- a/app/services/groups/nested_create_service.rb
+++ b/app/services/groups/nested_create_service.rb
@@ -11,8 +11,8 @@ module Groups
def execute
return nil unless group_path
- if group = Group.find_by_full_path(group_path)
- return group
+ if namespace = namespace_or_group(group_path)
+ return namespace
end
if group_path.include?('/') && !Group.supports_nested_groups?
@@ -40,10 +40,14 @@ module Groups
)
new_params[:visibility_level] ||= Gitlab::CurrentSettings.current_application_settings.default_group_visibility
- last_group = Group.find_by_full_path(partial_path) || Groups::CreateService.new(current_user, new_params).execute
+ last_group = namespace_or_group(partial_path) || Groups::CreateService.new(current_user, new_params).execute
end
last_group
end
+
+ def namespace_or_group(group_path)
+ Namespace.find_by_full_path(group_path)
+ end
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
new file mode 100644
index 00000000000..e591c820cff
--- /dev/null
+++ b/app/services/groups/transfer_service.rb
@@ -0,0 +1,96 @@
+module Groups
+ class TransferService < Groups::BaseService
+ ERROR_MESSAGES = {
+ database_not_supported: 'Database is not supported.',
+ namespace_with_same_path: 'The parent group already has a subgroup with the same path.',
+ group_is_already_root: 'Group is already a root group.',
+ same_parent_as_current: 'Group is already associated to the parent group.',
+ invalid_policies: "You don't have enough permissions."
+ }.freeze
+
+ TransferError = Class.new(StandardError)
+
+ attr_reader :error
+
+ def initialize(group, user, params = {})
+ super
+ @error = nil
+ end
+
+ def execute(new_parent_group)
+ @new_parent_group = new_parent_group
+ ensure_allowed_transfer
+ proceed_to_transfer
+
+ rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e
+ @group.errors.clear
+ @error = "Transfer failed: " + e.message
+ false
+ end
+
+ private
+
+ def proceed_to_transfer
+ Group.transaction do
+ update_group_attributes
+ end
+ end
+
+ def ensure_allowed_transfer
+ raise_transfer_error(:group_is_already_root) if group_is_already_root?
+ raise_transfer_error(:database_not_supported) unless Group.supports_nested_groups?
+ raise_transfer_error(:same_parent_as_current) if same_parent?
+ raise_transfer_error(:invalid_policies) unless valid_policies?
+ raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
+ end
+
+ def group_is_already_root?
+ !@new_parent_group && !@group.has_parent?
+ end
+
+ def same_parent?
+ @new_parent_group && @new_parent_group.id == @group.parent_id
+ end
+
+ def valid_policies?
+ return false unless can?(current_user, :admin_group, @group)
+
+ if @new_parent_group
+ can?(current_user, :create_subgroup, @new_parent_group)
+ else
+ can?(current_user, :create_group)
+ end
+ end
+
+ def namespace_with_same_path?
+ Namespace.exists?(path: @group.path, parent: @new_parent_group)
+ end
+
+ def update_group_attributes
+ if @new_parent_group && @new_parent_group.visibility_level < @group.visibility_level
+ update_children_and_projects_visibility
+ @group.visibility_level = @new_parent_group.visibility_level
+ end
+
+ @group.parent = @new_parent_group
+ @group.save!
+ end
+
+ def update_children_and_projects_visibility
+ descendants = @group.descendants.where("visibility_level > ?", @new_parent_group.visibility_level)
+
+ Group
+ .where(id: descendants.select(:id))
+ .update_all(visibility_level: @new_parent_group.visibility_level)
+
+ @group
+ .all_projects
+ .where("visibility_level > ?", @new_parent_group.visibility_level)
+ .update_all(visibility_level: @new_parent_group.visibility_level)
+ end
+
+ def raise_transfer_error(message)
+ raise TransferError, ERROR_MESSAGES[message]
+ end
+ end
+end
diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb
index 7197a426a72..0b1a33518c6 100644
--- a/app/services/issuable/destroy_service.rb
+++ b/app/services/issuable/destroy_service.rb
@@ -4,6 +4,7 @@ module Issuable
TodoService.new.destroy_target(issuable) do |issuable|
if issuable.destroy
issuable.update_project_counter_caches
+ issuable.assignees.each(&:invalidate_cache_counts)
end
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index e7463e6e25c..1f67e3ecf9d 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -51,9 +51,10 @@ class IssuableBaseService < BaseService
return unless milestone_id
params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
+ group_ids = project.group&.self_and_ancestors&.pluck(:id)
milestone =
- Milestone.for_projects_and_groups([project.id], [project.group&.id]).find_by_id(milestone_id)
+ Milestone.for_projects_and_groups([project.id], group_ids).find_by_id(milestone_id)
params[:milestone_id] = '' unless milestone
end
@@ -77,8 +78,12 @@ class IssuableBaseService < BaseService
return unless labels
params[:label_ids] = labels.split(",").map do |label_name|
- service = Labels::FindOrCreateService.new(current_user, project, title: label_name.strip)
- label = service.execute
+ label = Labels::FindOrCreateService.new(
+ current_user,
+ parent,
+ title: label_name.strip,
+ available_labels: available_labels
+ ).execute
label.try(:id)
end.compact
@@ -102,7 +107,11 @@ class IssuableBaseService < BaseService
end
def available_labels
- LabelsFinder.new(current_user, project_id: @project.id).execute
+ @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: true).execute
+ end
+
+ def handle_quick_actions_on_create(issuable)
+ merge_quick_actions_into_params!(issuable)
end
def merge_quick_actions_into_params!(issuable)
@@ -127,7 +136,7 @@ class IssuableBaseService < BaseService
end
def create(issuable)
- merge_quick_actions_into_params!(issuable)
+ handle_quick_actions_on_create(issuable)
filter_params(issuable)
params.delete(:state_event)
@@ -247,7 +256,7 @@ class IssuableBaseService < BaseService
when 'add'
todo_service.mark_todo(issuable, current_user)
when 'done'
- todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
+ todo = TodosFinder.new(current_user).find_by(target: issuable)
todo_service.mark_todos_as_done_by_ids(todo, current_user) if todo
end
end
@@ -303,4 +312,8 @@ class IssuableBaseService < BaseService
def update_project_counter_caches?(issuable)
issuable.state_changed?
end
+
+ def parent
+ project
+ end
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index 0c5cf2c62ad..fee5bc38f7b 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -23,6 +23,7 @@ module Issues
end
if project.issues_enabled? && issue.close
+ issue.update(closed_by: current_user)
event_service.close_issue(issue, current_user)
create_note(issue, commit) if system_note
notification_service.close_issue(issue, current_user) if notifications
diff --git a/app/services/issues/fetch_referenced_merge_requests_service.rb b/app/services/issues/fetch_referenced_merge_requests_service.rb
new file mode 100644
index 00000000000..39c8ded9df4
--- /dev/null
+++ b/app/services/issues/fetch_referenced_merge_requests_service.rb
@@ -0,0 +1,12 @@
+module Issues
+ class FetchReferencedMergeRequestsService < Issues::BaseService
+ def execute(issue)
+ referenced_merge_requests = issue.referenced_merge_requests(current_user)
+ referenced_merge_requests = Gitlab::IssuableSorter.sort(project, referenced_merge_requests) { |i| i.iid.to_s }
+ closed_by_merge_requests = issue.closed_by_merge_requests(current_user)
+ closed_by_merge_requests = Gitlab::IssuableSorter.sort(project, closed_by_merge_requests) { |i| i.iid.to_s }
+
+ [referenced_merge_requests, closed_by_merge_requests]
+ end
+ end
+end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 29def25719d..7140890d201 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -19,19 +19,10 @@ module Issues
# on rewriting notes (unfolding references)
#
ActiveRecord::Base.transaction do
- # New issue tasks
- #
@new_issue = create_new_issue
- rewrite_notes
- rewrite_award_emoji
- add_note_moved_from
-
- # Old issue tasks
- #
- add_note_moved_to
- close_issue
- mark_as_moved
+ update_new_issue
+ update_old_issue
end
notify_participants
@@ -41,11 +32,24 @@ module Issues
private
+ def update_new_issue
+ rewrite_notes
+ rewrite_issue_award_emoji
+ add_note_moved_from
+ end
+
+ def update_old_issue
+ add_note_moved_to
+ close_issue
+ mark_as_moved
+ end
+
def create_new_issue
new_params = { id: nil, iid: nil, label_ids: cloneable_label_ids,
milestone_id: cloneable_milestone_id,
project: @new_project, author: @old_issue.author,
- description: rewrite_content(@old_issue.description) }
+ description: rewrite_content(@old_issue.description),
+ assignee_ids: @old_issue.assignee_ids }
new_params = @old_issue.serializable_hash.symbolize_keys.merge(new_params)
CreateService.new(@new_project, @current_user, new_params).execute
@@ -76,7 +80,7 @@ module Issues
end
def rewrite_notes
- @old_issue.notes.find_each do |note|
+ @old_issue.notes_with_associations.find_each do |note|
new_note = note.dup
new_params = { project: @new_project, noteable: @new_issue,
note: rewrite_content(new_note.note),
@@ -84,13 +88,19 @@ module Issues
updated_at: note.updated_at }
new_note.update(new_params)
+
+ rewrite_award_emoji(note, new_note)
end
end
- def rewrite_award_emoji
- @old_issue.award_emoji.each do |award|
+ def rewrite_issue_award_emoji
+ rewrite_award_emoji(@old_issue, @new_issue)
+ end
+
+ def rewrite_award_emoji(old_awardable, new_awardable)
+ old_awardable.award_emoji.each do |award|
new_award = award.dup
- new_award.awardable = @new_issue
+ new_award.awardable = new_awardable
new_award.save
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index d7aa7e2347e..1374f10c586 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -55,9 +55,10 @@ module Issues
return unless params[:move_between_ids]
after_id, before_id = params.delete(:move_between_ids)
+ board_group_id = params.delete(:board_group_id)
- issue_before = get_issue_if_allowed(issue.project, before_id) if before_id
- issue_after = get_issue_if_allowed(issue.project, after_id) if after_id
+ issue_before = get_issue_if_allowed(before_id, board_group_id)
+ issue_after = get_issue_if_allowed(after_id, board_group_id)
issue.move_between(issue_before, issue_after)
end
@@ -84,8 +85,16 @@ module Issues
private
- def get_issue_if_allowed(project, id)
- issue = project.issues.find(id)
+ def get_issue_if_allowed(id, board_group_id = nil)
+ return unless id
+
+ issue =
+ if board_group_id
+ IssuesFinder.new(current_user, group_id: board_group_id, include_subgroups: true).find_by(id: id)
+ else
+ project.issues.find(id)
+ end
+
issue if can?(current_user, :update_issue, issue)
end
diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb
index 940c8b333d3..079f611b3f3 100644
--- a/app/services/labels/find_or_create_service.rb
+++ b/app/services/labels/find_or_create_service.rb
@@ -1,8 +1,9 @@
module Labels
class FindOrCreateService
- def initialize(current_user, project, params = {})
+ def initialize(current_user, parent, params = {})
@current_user = current_user
- @project = project
+ @parent = parent
+ @available_labels = params.delete(:available_labels)
@params = params.dup.with_indifferent_access
end
@@ -13,12 +14,13 @@ module Labels
private
- attr_reader :current_user, :project, :params, :skip_authorization
+ attr_reader :current_user, :parent, :params, :skip_authorization
def available_labels
@available_labels ||= LabelsFinder.new(
current_user,
- project_id: project.id
+ "#{parent_type}_id".to_sym => parent.id,
+ only_group_labels: parent_is_group?
).execute(skip_authorization: skip_authorization)
end
@@ -27,8 +29,8 @@ module Labels
def find_or_create_label
new_label = available_labels.find_by(title: title)
- if new_label.nil? && (skip_authorization || Ability.allowed?(current_user, :admin_label, project))
- new_label = Labels::CreateService.new(params).execute(project: project)
+ if new_label.nil? && (skip_authorization || Ability.allowed?(current_user, :admin_label, parent))
+ new_label = Labels::CreateService.new(params).execute(parent_type.to_sym => parent)
end
new_label
@@ -37,5 +39,13 @@ module Labels
def title
params[:title] || params[:name]
end
+
+ def parent_type
+ parent.model_name.param_key
+ end
+
+ def parent_is_group?
+ parent_type == "group"
+ end
end
end
diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb
index 997d247be46..74a85e5c9f0 100644
--- a/app/services/labels/promote_service.rb
+++ b/app/services/labels/promote_service.rb
@@ -13,6 +13,7 @@ module Labels
update_issuables(new_label, batched_ids)
update_issue_board_lists(new_label, batched_ids)
update_priorities(new_label, batched_ids)
+ subscribe_users(new_label, batched_ids)
# Order is important, project labels need to be last
update_project_labels(batched_ids)
end
@@ -26,6 +27,15 @@ module Labels
private
+ def subscribe_users(new_label, label_ids)
+ # users can be subscribed to multiple labels that will be merged into the group one
+ # we want to keep only one subscription / user
+ ids_to_update = Subscription.where(subscribable_id: label_ids, subscribable_type: 'Label')
+ .group(:user_id)
+ .pluck('MAX(id)')
+ Subscription.where(id: ids_to_update).update_all(subscribable_id: new_label.id)
+ end
+
def label_ids_for_merge(new_label)
LabelsFinder
.new(current_user, title: new_label.title, group_id: project.group.id)
@@ -53,7 +63,7 @@ module Labels
end
def update_project_labels(label_ids)
- Label.where(id: label_ids).delete_all
+ Label.where(id: label_ids).destroy_all
end
def clone_label_to_group_label(label)
diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb
new file mode 100644
index 00000000000..69281ee3137
--- /dev/null
+++ b/app/services/lfs/file_transformer.rb
@@ -0,0 +1,66 @@
+module Lfs
+ # Usage: Calling `new_file` check to see if a file should be in LFS and
+ # return a transformed result with `content` and `encoding` to commit.
+ #
+ # For LFS an LfsObject linked to the project is stored and an LFS
+ # pointer returned. If the file isn't in LFS the untransformed content
+ # is returned to save in the commit.
+ #
+ # transformer = Lfs::FileTransformer.new(project, @branch_name)
+ # content_or_lfs_pointer = transformer.new_file(file_path, content).content
+ # create_transformed_commit(content_or_lfs_pointer)
+ #
+ class FileTransformer
+ attr_reader :project, :branch_name
+
+ delegate :repository, to: :project
+
+ def initialize(project, branch_name)
+ @project = project
+ @branch_name = branch_name
+ end
+
+ def new_file(file_path, file_content, encoding: nil)
+ if project.lfs_enabled? && lfs_file?(file_path)
+ file_content = Base64.decode64(file_content) if encoding == 'base64'
+ lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content)
+ lfs_object = create_lfs_object!(lfs_pointer_file, file_content)
+
+ link_lfs_object!(lfs_object)
+
+ Result.new(content: lfs_pointer_file.pointer, encoding: 'text')
+ else
+ Result.new(content: file_content, encoding: encoding)
+ end
+ end
+
+ class Result
+ attr_reader :content, :encoding
+
+ def initialize(content:, encoding:)
+ @content = content
+ @encoding = encoding
+ end
+ end
+
+ private
+
+ def lfs_file?(file_path)
+ cached_attributes.attributes(file_path)['filter'] == 'lfs'
+ end
+
+ def cached_attributes
+ @cached_attributes ||= Gitlab::Git::AttributesAtRefParser.new(repository, branch_name)
+ end
+
+ def create_lfs_object!(lfs_pointer_file, file_content)
+ LfsObject.find_or_create_by(oid: lfs_pointer_file.sha256, size: lfs_pointer_file.size) do |lfs_object|
+ lfs_object.file = CarrierWaveStringFile.new(file_content)
+ end
+ end
+
+ def link_lfs_object!(lfs_object)
+ project.lfs_objects << lfs_object
+ end
+ end
+end
diff --git a/app/services/lfs/lock_file_service.rb b/app/services/lfs/lock_file_service.rb
new file mode 100644
index 00000000000..bbe10f84ef4
--- /dev/null
+++ b/app/services/lfs/lock_file_service.rb
@@ -0,0 +1,39 @@
+module Lfs
+ class LockFileService < BaseService
+ def execute
+ unless can?(current_user, :push_code, project)
+ raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions'
+ end
+
+ create_lock!
+ rescue ActiveRecord::RecordNotUnique
+ error('already locked', 409, current_lock)
+ rescue Gitlab::GitAccess::UnauthorizedError => ex
+ error(ex.message, 403)
+ rescue => ex
+ error(ex.message, 500)
+ end
+
+ private
+
+ def current_lock
+ project.lfs_file_locks.find_by(path: params[:path])
+ end
+
+ def create_lock!
+ lock = project.lfs_file_locks.create!(user: current_user,
+ path: params[:path])
+
+ success(http_status: 201, lock: lock)
+ end
+
+ def error(message, http_status, lock = nil)
+ {
+ status: :error,
+ message: message,
+ http_status: http_status,
+ lock: lock
+ }
+ end
+ end
+end
diff --git a/app/services/lfs/locks_finder_service.rb b/app/services/lfs/locks_finder_service.rb
new file mode 100644
index 00000000000..13c6cc6f81c
--- /dev/null
+++ b/app/services/lfs/locks_finder_service.rb
@@ -0,0 +1,17 @@
+module Lfs
+ class LocksFinderService < BaseService
+ def execute
+ success(locks: find_locks)
+ rescue => ex
+ error(ex.message, 500)
+ end
+
+ private
+
+ def find_locks
+ options = params.slice(:id, :path).compact.symbolize_keys
+
+ project.lfs_file_locks.where(options)
+ end
+ end
+end
diff --git a/app/services/lfs/unlock_file_service.rb b/app/services/lfs/unlock_file_service.rb
new file mode 100644
index 00000000000..6c93dc69bb0
--- /dev/null
+++ b/app/services/lfs/unlock_file_service.rb
@@ -0,0 +1,43 @@
+module Lfs
+ class UnlockFileService < BaseService
+ def execute
+ unless can?(current_user, :push_code, project)
+ raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions'
+ end
+
+ unlock_file
+ rescue Gitlab::GitAccess::UnauthorizedError => ex
+ error(ex.message, 403)
+ rescue ActiveRecord::RecordNotFound
+ error('Lock not found', 404)
+ rescue => ex
+ error(ex.message, 500)
+ end
+
+ private
+
+ def unlock_file
+ forced = params[:force] == true
+
+ if lock.can_be_unlocked_by?(current_user, forced)
+ lock.destroy!
+
+ success(lock: lock, http_status: :ok)
+ elsif forced
+ error('You must have master access to force delete a lock', 403)
+ else
+ error("#{lock.path} is locked by GitLab User #{lock.user_id}", 403)
+ end
+ end
+
+ def lock
+ return @lock if defined?(@lock)
+
+ @lock = if params[:id].present?
+ project.lfs_file_locks.find(params[:id])
+ elsif params[:path].present?
+ project.lfs_file_locks.find_by!(path: params[:path])
+ end
+ end
+ end
+end
diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb
index 2a2bb0cae5b..6be08b590bc 100644
--- a/app/services/members/approve_access_request_service.rb
+++ b/app/services/members/approve_access_request_service.rb
@@ -1,51 +1,20 @@
module Members
- class ApproveAccessRequestService < BaseService
- include MembersHelper
-
- attr_accessor :source
-
- # source - The source object that respond to `#requesters` (i.g. project or group)
- # current_user - The user that performs the access request approval
- # params - A hash of parameters
- # :user_id - User ID used to retrieve the access requester
- # :id - Member ID used to retrieve the access requester
- # :access_level - Optional access level set when the request is accepted
- def initialize(source, current_user, params = {})
- @source = source
- @current_user = current_user
- @params = params.slice(:user_id, :id, :access_level)
- end
-
- # opts - A hash of options
- # :force - Bypass permission check: current_user can be nil in that case
- def execute(opts = {})
- condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] }
- access_requester = source.requesters.find_by!(condition)
-
- raise Gitlab::Access::AccessDeniedError unless can_update_access_requester?(access_requester, opts)
+ class ApproveAccessRequestService < Members::BaseService
+ def execute(access_requester, skip_authorization: false, skip_log_audit_event: false)
+ raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_update_access_requester?(access_requester)
access_requester.access_level = params[:access_level] if params[:access_level]
access_requester.accept_request
+ after_execute(member: access_requester, skip_log_audit_event: skip_log_audit_event)
+
access_requester
end
private
- def can_update_access_requester?(access_requester, opts = {})
- access_requester && (
- opts[:force] ||
- can?(current_user, update_member_permission(access_requester), access_requester)
- )
- end
-
- def update_member_permission(member)
- case member
- when GroupMember
- :update_group_member
- when ProjectMember
- :update_project_member
- end
+ def can_update_access_requester?(access_requester)
+ can?(current_user, update_member_permission(access_requester), access_requester)
end
end
end
diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb
deleted file mode 100644
index de3a252d6c6..00000000000
--- a/app/services/members/authorized_destroy_service.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-module Members
- class AuthorizedDestroyService < BaseService
- attr_accessor :member, :user
-
- def initialize(member, user = nil)
- @member, @user = member, user
- end
-
- def execute
- return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
-
- Member.transaction do
- unassign_issues_and_merge_requests(member) unless member.invite?
-
- member.destroy
- end
-
- if member.request? && member.user != user
- notification_service.decline_access_request(member)
- end
-
- member
- end
-
- private
-
- def unassign_issues_and_merge_requests(member)
- if member.is_a?(GroupMember)
- 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
-
- # 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)
- end
-
- member.user.invalidate_cache_counts
- end
- end
-end
diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb
new file mode 100644
index 00000000000..74556fb20cf
--- /dev/null
+++ b/app/services/members/base_service.rb
@@ -0,0 +1,49 @@
+module Members
+ class BaseService < ::BaseService
+ # current_user - The user that performs the action
+ # params - A hash of parameters
+ def initialize(current_user = nil, params = {})
+ @current_user = current_user
+ @params = params
+ end
+
+ def after_execute(args)
+ # overriden in EE::Members modules
+ end
+
+ private
+
+ def update_member_permission(member)
+ case member
+ when GroupMember
+ :update_group_member
+ when ProjectMember
+ :update_project_member
+ else
+ raise "Unknown member type: #{member}!"
+ end
+ end
+
+ def override_member_permission(member)
+ case member
+ when GroupMember
+ :override_group_member
+ when ProjectMember
+ :override_project_member
+ else
+ raise "Unknown member type: #{member}!"
+ end
+ end
+
+ def action_member_permission(action, member)
+ case action
+ when :update
+ update_member_permission(member)
+ when :override
+ override_member_permission(member)
+ else
+ raise "Unknown action '#{action}' on #{member}!"
+ end
+ end
+ end
+end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 26906ae7167..bc6a9405aac 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -1,15 +1,8 @@
module Members
- class CreateService < BaseService
+ class CreateService < Members::BaseService
DEFAULT_LIMIT = 100
- def initialize(source, current_user, params = {})
- @source = source
- @current_user = current_user
- @params = params
- @error = nil
- end
-
- def execute
+ def execute(source)
return error('No users specified.') if params[:user_ids].blank?
user_ids = params[:user_ids].split(',').uniq
@@ -17,13 +10,15 @@ module Members
return error("Too many users specified (limit is #{user_limit})") if
user_limit && user_ids.size > user_limit
- @source.add_users(
+ members = source.add_users(
user_ids,
params[:access_level],
expires_at: params[:expires_at],
current_user: current_user
)
+ members.each { |member| after_execute(member: member) }
+
success
end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 05b93ac8fdb..5b51e1982f1 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -1,42 +1,27 @@
module Members
- class DestroyService < BaseService
- include MembersHelper
+ class DestroyService < Members::BaseService
+ def execute(member, skip_authorization: false)
+ raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member)
- attr_accessor :source
+ return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
- ALLOWED_SCOPES = %i[members requesters all].freeze
+ member.destroy
- def initialize(source, current_user, params = {})
- @source = source
- @current_user = current_user
- @params = params
- end
-
- def execute(scope = :members)
- raise "scope :#{scope} is not allowed!" unless ALLOWED_SCOPES.include?(scope)
+ member.user&.invalidate_cache_counts
- member = find_member!(scope)
+ if member.request? && member.user != current_user
+ notification_service.decline_access_request(member)
+ end
- raise Gitlab::Access::AccessDeniedError unless can_destroy_member?(member)
+ after_execute(member: member)
- AuthorizedDestroyService.new(member, current_user).execute
+ member
end
private
- def find_member!(scope)
- condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] }
- case scope
- when :all
- source.members.find_by(condition) ||
- source.requesters.find_by!(condition)
- else
- source.public_send(scope).find_by!(condition) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
-
def can_destroy_member?(member)
- member && can?(current_user, destroy_member_permission(member), member)
+ can?(current_user, destroy_member_permission(member), member)
end
def destroy_member_permission(member)
@@ -45,6 +30,8 @@ module Members
:destroy_group_member
when ProjectMember
:destroy_project_member
+ else
+ raise "Unknown member type: #{member}!"
end
end
end
diff --git a/app/services/members/request_access_service.rb b/app/services/members/request_access_service.rb
index 2614153d900..24293b30005 100644
--- a/app/services/members/request_access_service.rb
+++ b/app/services/members/request_access_service.rb
@@ -1,13 +1,6 @@
module Members
- class RequestAccessService < BaseService
- attr_accessor :source
-
- def initialize(source, current_user)
- @source = source
- @current_user = current_user
- end
-
- def execute
+ class RequestAccessService < Members::BaseService
+ def execute(source)
raise Gitlab::Access::AccessDeniedError unless can_request_access?(source)
source.members.create(
@@ -19,7 +12,7 @@ module Members
private
def can_request_access?(source)
- source && can?(current_user, :request_access, source)
+ can?(current_user, :request_access, source)
end
end
end
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
new file mode 100644
index 00000000000..48b3d59f7bd
--- /dev/null
+++ b/app/services/members/update_service.rb
@@ -0,0 +1,16 @@
+module Members
+ class UpdateService < Members::BaseService
+ # returns the updated member
+ def execute(member, permission: :update)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member)
+
+ old_access_level = member.human_access
+
+ if member.update_attributes(params)
+ after_execute(action: permission, old_access_level: old_access_level, member: member)
+ end
+
+ member
+ end
+ end
+end
diff --git a/app/services/merge_request_metrics_service.rb b/app/services/merge_request_metrics_service.rb
new file mode 100644
index 00000000000..9248de14a53
--- /dev/null
+++ b/app/services/merge_request_metrics_service.rb
@@ -0,0 +1,19 @@
+class MergeRequestMetricsService
+ delegate :update!, to: :@merge_request_metrics
+
+ def initialize(merge_request_metrics)
+ @merge_request_metrics = merge_request_metrics
+ end
+
+ def merge(event)
+ update!(merged_by_id: event.author_id, merged_at: event.created_at)
+ end
+
+ def close(event)
+ update!(latest_closed_by_id: event.author_id, latest_closed_at: event.created_at)
+ end
+
+ def reopen
+ update!(latest_closed_by_id: nil, latest_closed_at: nil)
+ end
+end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 6b32d65a74b..231ab76fde4 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -24,6 +24,29 @@ module MergeRequests
private
+ def handle_wip_event(merge_request)
+ if wip_event = params.delete(:wip_event)
+ # We update the title that is provided in the params or we use the mr title
+ title = params[:title] || merge_request.title
+ params[:title] = case wip_event
+ when 'wip' then MergeRequest.wip_title(title)
+ when 'unwip' then MergeRequest.wipless_title(title)
+ end
+ end
+ end
+
+ def filter_params(merge_request)
+ super
+
+ unless merge_request.can_allow_maintainer_to_push?(current_user)
+ params.delete(:allow_maintainer_to_push)
+ end
+ end
+
+ def merge_request_metrics_service(merge_request)
+ MergeRequestMetricsService.new(merge_request.metrics)
+ end
+
def create_assignee_note(merge_request)
SystemNoteService.change_assignee(
merge_request, merge_request.project, current_user, merge_request.assignee)
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index 9622a5c5462..a98bbdf74dd 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -1,9 +1,12 @@
module MergeRequests
class BuildService < MergeRequests::BaseService
+ include Gitlab::Utils::StrongMemoize
+
def execute
- @issue_iid = params.delete(:issue_iid)
+ @params_issue_iid = params.delete(:issue_iid)
self.merge_request = MergeRequest.new(params)
+ merge_request.author = current_user
merge_request.compare_commits = []
merge_request.source_project = find_source_project
merge_request.target_project = find_target_project
@@ -123,7 +126,7 @@ module MergeRequests
#
def assign_title_and_description
assign_title_and_description_from_single_commit
- assign_title_from_issue
+ assign_title_from_issue if target_project.issues_enabled? || target_project.external_issue_tracker
merge_request.title ||= source_branch.titleize.humanize
merge_request.title = wip_title if compare_commits.empty?
@@ -132,9 +135,9 @@ module MergeRequests
end
def append_closes_description
- return unless issue_iid
+ return unless issue&.to_reference.present?
- closes_issue = "Closes ##{issue_iid}"
+ closes_issue = "Closes #{issue.to_reference}"
if description.present?
merge_request.description += closes_issue.prepend("\n\n")
@@ -156,15 +159,27 @@ module MergeRequests
def assign_title_from_issue
return unless issue
- merge_request.title =
- case issue
- when Issue then "Resolve \"#{issue.title}\""
- when ExternalIssue then "Resolve #{issue.title}"
- end
+ merge_request.title = "Resolve \"#{issue.title}\"" if issue.is_a?(Issue)
+
+ return if merge_request.title.present?
+
+ if issue_iid.present?
+ merge_request.title = "Resolve #{issue.to_reference}"
+ branch_title = source_branch.downcase.remove(issue_iid.downcase).titleize.humanize
+ merge_request.title += " \"#{branch_title}\"" if branch_title.present?
+ end
end
def issue_iid
- @issue_iid ||= source_branch.match(/\A(\d+)-/).try(:[], 1)
+ strong_memoize(:issue_iid) do
+ @params_issue_iid || begin
+ id = if target_project.external_issue_tracker
+ source_branch.match(target_project.external_issue_reference_pattern).try(:[], 0)
+ end
+
+ id || source_branch.match(/\A(\d+)-/).try(:[], 1)
+ end
+ end
end
def issue
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index 40213c99014..f727ec002e7 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -8,7 +8,7 @@ module MergeRequests
merge_request.allow_broken = true
if merge_request.close
- event_service.close_mr(merge_request, current_user)
+ create_event(merge_request)
create_note(merge_request)
notification_service.close_mr(merge_request, current_user)
todo_service.close_merge_request(merge_request, current_user)
@@ -19,5 +19,16 @@ module MergeRequests
merge_request
end
+
+ private
+
+ def create_event(merge_request)
+ # Making sure MergeRequest::Metrics updates are in sync with
+ # Event creation.
+ Event.transaction do
+ close_event = event_service.close_mr(merge_request, current_user)
+ merge_request_metrics_service(merge_request).close(close_event)
+ end
+ end
end
end
diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb
index 0f677a996f7..72cbc49adb2 100644
--- a/app/services/merge_requests/conflicts/list_service.rb
+++ b/app/services/merge_requests/conflicts/list_service.rb
@@ -17,15 +17,7 @@ module MergeRequests
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::Git::Conflict::Parser::UnresolvableError, Gitlab::Git::Conflict::Resolver::ConflictSideMissing
- @conflicts_can_be_resolved_in_ui = false
- end
+ @conflicts_can_be_resolved_in_ui = conflicts.can_be_resolved_in_ui?
end
def conflicts
diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb
index 89dab1dd028..cf687b71d16 100644
--- a/app/services/merge_requests/create_from_issue_service.rb
+++ b/app/services/merge_requests/create_from_issue_service.rb
@@ -54,6 +54,7 @@ module MergeRequests
source_project_id: project.id,
source_branch: branch_name,
target_project_id: project.id,
+ target_branch: ref,
milestone_id: issue.milestone_id
}
end
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 49cf534dc0d..c57a2445341 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -1,22 +1,15 @@
module MergeRequests
class CreateService < MergeRequests::BaseService
def execute
- # @project is used to determine whether the user can set the merge request's
- # assignee, milestone and labels. Whether they can depends on their
- # permissions on the target project.
- source_project = @project
- @project = Project.find(params[:target_project_id]) if params[:target_project_id]
+ set_projects!
merge_request = MergeRequest.new
merge_request.target_project = @project
- merge_request.source_project = source_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)
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37439
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- create(merge_request)
- end
+ create(merge_request)
end
def before_create(merge_request)
@@ -41,6 +34,12 @@ module MergeRequests
super
end
+ # Override from IssuableBaseService
+ def handle_quick_actions_on_create(merge_request)
+ super
+ handle_wip_event(merge_request)
+ end
+
private
def update_merge_requests_head_pipeline(merge_request)
@@ -58,5 +57,25 @@ module MergeRequests
pipelines.order(id: :desc).first
end
+
+ def set_projects!
+ # @project is used to determine whether the user can set the merge request's
+ # assignee, milestone and labels. Whether they can depends on their
+ # permissions on the target project.
+ @source_project = @project
+ @project = Project.find(params[:target_project_id]) if params[:target_project_id]
+
+ # make sure that source/target project ids are not in
+ # params so it can't be overridden later when updating attributes
+ # from params when applying quick actions
+ params.delete(:source_project_id)
+ params.delete(:target_project_id)
+
+ unless can?(current_user, :read_project, @source_project) &&
+ can?(current_user, :read_project, @project)
+
+ raise Gitlab::Access::AccessDeniedError
+ end
+ end
end
end
diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb
index 2945a7fd4e4..10aa9ae609c 100644
--- a/app/services/merge_requests/merge_request_diff_cache_service.rb
+++ b/app/services/merge_requests/merge_request_diff_cache_service.rb
@@ -1,8 +1,17 @@
module MergeRequests
class MergeRequestDiffCacheService
- def execute(merge_request)
+ def execute(merge_request, new_diff)
# Executing the iteration we cache all the highlighted diff information
merge_request.diffs.diff_files.to_a
+
+ # Remove cache for all diffs on this MR. Do not use the association on the
+ # model, as that will interfere with other actions happening when
+ # reloading the diff.
+ MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff|
+ next if merge_request_diff == new_diff
+
+ merge_request_diff.diffs.clear_cache!
+ end
end
end
end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index b1d6bac4d4a..c78e78afcd1 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -9,7 +9,7 @@ module MergeRequests
close_issues(merge_request)
todo_service.merge_merge_request(merge_request, current_user)
merge_request.mark_as_merged
- create_merge_event(merge_request, current_user)
+ create_event(merge_request)
create_note(merge_request)
notification_service.merge_mr(merge_request, current_user)
execute_hooks(merge_request, 'merge')
@@ -34,5 +34,14 @@ module MergeRequests
def create_merge_event(merge_request, current_user)
EventCreateService.new.merge_mr(merge_request, current_user)
end
+
+ def create_event(merge_request)
+ # Making sure MergeRequest::Metrics updates are in sync with
+ # Event creation.
+ Event.transaction do
+ merge_event = create_merge_event(merge_request, current_user)
+ merge_request_metrics_service(merge_request).merge(merge_event)
+ end
+ end
end
end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
new file mode 100644
index 00000000000..c0083cd6afd
--- /dev/null
+++ b/app/services/merge_requests/rebase_service.rb
@@ -0,0 +1,32 @@
+module MergeRequests
+ class RebaseService < MergeRequests::WorkingCopyBaseService
+ REBASE_ERROR = 'Rebase failed. Please rebase locally'.freeze
+
+ def execute(merge_request)
+ @merge_request = merge_request
+
+ if rebase
+ success
+ else
+ error(REBASE_ERROR)
+ end
+ end
+
+ def rebase
+ if merge_request.rebase_in_progress?
+ log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
+ return false
+ end
+
+ rebase_sha = repository.rebase(current_user, merge_request)
+
+ merge_request.update_attributes(rebase_commit_sha: rebase_sha)
+
+ true
+ rescue => e
+ log_error(REBASE_ERROR, save_message_on_model: true)
+ log_error(e.message)
+ false
+ end
+ end
+end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 9f05535d4d4..1fb1796b56c 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -9,7 +9,8 @@ module MergeRequests
Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits))
# Be sure to close outstanding MRs before reloading them to avoid generating an
# empty diff during a manual merge
- close_merge_requests
+ close_upon_missing_source_branch_ref
+ post_merge_manually_merged
reload_merge_requests
reset_merge_when_pipeline_succeeds
mark_pending_todos_done
@@ -20,7 +21,7 @@ module MergeRequests
comment_mr_branch_presence_changed
end
- comment_mr_with_commits
+ notify_about_push
mark_mr_as_wip_from_commits
execute_mr_web_hooks
@@ -29,11 +30,22 @@ module MergeRequests
private
+ def close_upon_missing_source_branch_ref
+ # MergeRequest#reload_diff ignores not opened MRs. This means it won't
+ # create an `empty` diff for `closed` MRs without a source branch, keeping
+ # the latest diff state as the last _valid_ one.
+ merge_requests_for_source_branch.reject(&:source_branch_exists?).each do |mr|
+ MergeRequests::CloseService
+ .new(mr.target_project, @current_user)
+ .execute(mr)
+ end
+ end
+
# Collect open merge requests that target same branch we push into
# and close if push to master include last commit from merge request
# We need this to close(as merged) merge requests that were merged into
# target branch manually
- def close_merge_requests
+ def post_merge_manually_merged
commit_ids = @commits.map(&:id)
merge_requests = @project.merge_requests.preload(:latest_merge_request_diff).opened.where(target_branch: @branch_name).to_a
merge_requests = merge_requests.select(&:diff_head_commit)
@@ -78,6 +90,10 @@ module MergeRequests
merge_request.mark_as_unchecked
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
+
+ # Upcoming method calls need the refreshed version of
+ # @source_merge_requests diffs (for MergeRequest#commit_shas for instance).
+ merge_requests_for_source_branch(reload: true)
end
def reset_merge_when_pipeline_succeeds
@@ -125,8 +141,8 @@ module MergeRequests
end
end
- # Add comment about pushing new commits to merge requests
- def comment_mr_with_commits
+ # Add comment about pushing new commits to merge requests and send nofitication emails
+ def notify_about_push
return unless @commits.present?
merge_requests_for_source_branch.each do |merge_request|
@@ -139,6 +155,8 @@ module MergeRequests
SystemNoteService.add_commits(merge_request, merge_request.project,
@current_user, new_commits,
existing_commits, @oldrev)
+
+ notification_service.push_to_merge_request(merge_request, @current_user, new_commits: new_commits, existing_commits: existing_commits)
end
end
@@ -183,7 +201,8 @@ module MergeRequests
merge_requests.uniq.select(&:source_project)
end
- def merge_requests_for_source_branch
+ def merge_requests_for_source_branch(reload: false)
+ @source_merge_requests = nil if reload
@source_merge_requests ||= merge_requests_for(@branch_name)
end
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index c599a90f9fe..120677a7149 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -4,7 +4,7 @@ module MergeRequests
return merge_request unless can?(current_user, :update_merge_request, merge_request)
if merge_request.reopen
- event_service.reopen_mr(merge_request, current_user)
+ create_event(merge_request)
create_note(merge_request, 'reopened')
notification_service.reopen_mr(merge_request, current_user)
execute_hooks(merge_request, 'reopen')
@@ -16,5 +16,16 @@ module MergeRequests
merge_request
end
+
+ private
+
+ def create_event(merge_request)
+ # Making sure MergeRequest::Metrics updates are in sync with
+ # Event creation.
+ Event.transaction do
+ event_service.reopen_mr(merge_request, current_user)
+ merge_request_metrics_service(merge_request).reopen
+ end
+ end
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index c153872c874..8a40ad88182 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -98,17 +98,6 @@ module MergeRequests
private
- def handle_wip_event(merge_request)
- if wip_event = params.delete(:wip_event)
- # We update the title that is provided in the params or we use the mr title
- title = params[:title] || merge_request.title
- params[:title] = case wip_event
- when 'wip' then MergeRequest.wip_title(title)
- when 'unwip' then MergeRequest.wipless_title(title)
- end
- end
- end
-
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
diff --git a/app/services/merge_requests/working_copy_base_service.rb b/app/services/merge_requests/working_copy_base_service.rb
new file mode 100644
index 00000000000..186e05bf966
--- /dev/null
+++ b/app/services/merge_requests/working_copy_base_service.rb
@@ -0,0 +1,24 @@
+module MergeRequests
+ class WorkingCopyBaseService < MergeRequests::BaseService
+ attr_reader :merge_request
+
+ def source_project
+ @source_project ||= merge_request.source_project
+ end
+
+ def target_project
+ @target_project ||= merge_request.target_project
+ end
+
+ def log_error(message, save_message_on_model: false)
+ Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}")
+
+ merge_request.update(merge_error: message) if save_message_on_model
+ end
+
+ # Don't try to print expensive instance variables.
+ def inspect
+ "#<#{self.class} #{merge_request.to_reference(full: true)}>"
+ end
+ end
+end
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index abf25bb778b..77e7b8a5ea7 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -26,14 +26,19 @@ module Notes
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)
+ return nil unless noteable_without_project?(noteable)
discussion
end
end
+
+ def noteable_without_project?(noteable)
+ return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable)
+
+ false
+ end
end
end
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index 6a10e172483..199b8028dbc 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -23,9 +23,13 @@ module Notes
end
def execute_note_hooks
+ return unless @note.project
+
note_data = hook_data
- @note.project.execute_hooks(note_data, :note_hooks)
- @note.project.execute_services(note_data, :note_hooks)
+ hooks_scope = @note.confidential? ? :confidential_note_hooks : :note_hooks
+
+ @note.project.execute_hooks(note_data, hooks_scope)
+ @note.project.execute_services(note_data, hooks_scope)
end
end
end
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index a8d0cc15527..0a33d5f3f3d 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -9,14 +9,12 @@ module Notes
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)
+ def self.supported?(note)
+ !!noteable_update_service(note)
end
def supported?(note)
- self.class.supported?(note, current_user)
+ self.class.supported?(note)
end
def extract_commands(note, options = {})
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index 3eb8cfcca9b..b82d9c64296 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -11,11 +11,11 @@ module NotificationRecipientService
end
def self.build_recipients(*a)
- Builder::Default.new(*a).recipient_users
+ Builder::Default.new(*a).notification_recipients
end
def self.build_new_note_recipients(*a)
- Builder::NewNote.new(*a).recipient_users
+ Builder::NewNote.new(*a).notification_recipients
end
module Builder
@@ -49,25 +49,23 @@ module NotificationRecipientService
@recipients ||= []
end
- def <<(pair)
- users, type = pair
-
+ def add_recipients(users, type, reason)
if users.is_a?(ActiveRecord::Relation)
users = users.includes(:notification_settings)
end
- users = Array(users)
- users.compact!
- recipients.concat(users.map { |u| make_recipient(u, type) })
+ users = Array(users).compact
+ recipients.concat(users.map { |u| make_recipient(u, type, reason) })
end
def user_scope
User.includes(:notification_settings)
end
- def make_recipient(user, type)
+ def make_recipient(user, type, reason)
NotificationRecipient.new(
user, type,
+ reason: reason,
project: project,
custom_action: custom_action,
target: target,
@@ -75,14 +73,13 @@ module NotificationRecipientService
)
end
- def recipient_users
- @recipient_users ||=
+ def notification_recipients
+ @notification_recipients ||=
begin
build!
filter!
- users = recipients.map(&:user)
- users.uniq!
- users.freeze
+ recipients = self.recipients.sort_by { |r| NotificationReason.priority(r.reason) }.uniq(&:user)
+ recipients.freeze
end
end
@@ -95,13 +92,13 @@ module NotificationRecipientService
def add_participants(user)
return unless target.respond_to?(:participants)
- self << [target.participants(user), :participating]
+ add_recipients(target.participants(user), :participating, nil)
end
def add_mentions(user, target:)
return unless target.respond_to?(:mentioned_users)
- self << [target.mentioned_users(user), :mention]
+ add_recipients(target.mentioned_users(user), :mention, NotificationReason::MENTIONED)
end
# Get project/group users with CUSTOM notification level
@@ -119,11 +116,11 @@ module NotificationRecipientService
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)
- self << [user_scope.where(id: user_ids), :watch]
+ add_recipients(user_scope.where(id: user_ids), :watch, nil)
end
def add_project_watchers
- self << [project_watchers, :watch]
+ add_recipients(project_watchers, :watch, nil)
end
# Get project users with WATCH notification level
@@ -144,7 +141,7 @@ module NotificationRecipientService
def add_subscribed_users
return unless target.respond_to? :subscribers
- self << [target.subscribers(project), :subscription]
+ add_recipients(target.subscribers(project), :subscription, nil)
end
def user_ids_notifiable_on(resource, notification_level = nil)
@@ -195,7 +192,7 @@ module NotificationRecipientService
return unless target.respond_to? :labels
(labels || target.labels).each do |label|
- self << [label.subscribers(project), :subscription]
+ add_recipients(label.subscribers(project), :subscription, nil)
end
end
end
@@ -222,12 +219,12 @@ module NotificationRecipientService
# 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]
+ add_recipients(previous_assignee, :mention, nil)
+ add_recipients(target.assignee, :mention, NotificationReason::ASSIGNED)
when :reassign_issue
previous_assignees = Array(previous_assignee)
- self << [previous_assignees, :mention]
- self << [target.assignees, :mention]
+ add_recipients(previous_assignees, :mention, nil)
+ add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED)
end
add_subscribed_users
@@ -238,6 +235,12 @@ module NotificationRecipientService
# receive them, too.
add_mentions(current_user, target: target)
+ # Add the assigned users, if any
+ assignees = custom_action == :new_issue ? target.assignees : target.assignee
+ # We use the `:participating` notification level in order to match existing legacy behavior as captured
+ # in existing specs (notification_service_spec.rb ~ line 507)
+ add_recipients(assignees, :participating, NotificationReason::ASSIGNED) if assignees
+
add_labels_subscribers
end
end
@@ -276,7 +279,7 @@ module NotificationRecipientService
add_participants(note.author)
add_mentions(note.author, target: note)
- unless note.for_personal_snippet?
+ if note.for_project_noteable?
# Merge project watchers
add_project_watchers
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index be3b4b2ba07..f94c76cf3ac 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -85,10 +85,11 @@ class NotificationService
recipients.each do |recipient|
mailer.send(
:reassigned_issue_email,
- recipient.id,
+ recipient.user.id,
issue.id,
previous_assignee_ids,
- current_user.id
+ current_user.id,
+ recipient.reason
).deliver_later
end
end
@@ -112,6 +113,16 @@ class NotificationService
new_resource_email(merge_request, :new_merge_request_email)
end
+ def push_to_merge_request(merge_request, current_user, new_commits: [], existing_commits: [])
+ new_commits = new_commits.map { |c| { short_id: c.short_id, title: c.title } }
+ existing_commits = existing_commits.map { |c| { short_id: c.short_id, title: c.title } }
+ recipients = NotificationRecipientService.build_recipients(merge_request, current_user, action: "push_to")
+
+ recipients.each do |recipient|
+ mailer.send(:push_to_merge_request_email, recipient.user.id, merge_request.id, current_user.id, recipient.reason, new_commits: new_commits, existing_commits: existing_commits).deliver_later
+ end
+ end
+
# When merge request text is updated, we should send an email to:
#
# * newly mentioned project team members with notification level higher than Participating
@@ -176,7 +187,7 @@ class NotificationService
action: "resolve_all_discussions")
recipients.each do |recipient|
- mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
+ mailer.resolved_all_discussions_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later
end
end
@@ -199,7 +210,7 @@ class NotificationService
recipients = NotificationRecipientService.build_new_note_recipients(note)
recipients.each do |recipient|
- mailer.send(notify_method, recipient.id, note.id).deliver_later
+ mailer.send(notify_method, recipient.user.id, note.id).deliver_later
end
end
@@ -207,7 +218,12 @@ class NotificationService
def new_access_request(member)
return true unless member.notifiable?(:subscription)
- mailer.member_access_requested_email(member.real_source_type, member.id).deliver_later
+ recipients = member.source.members.active_without_invites_and_requests.owners_and_masters
+ if fallback_to_group_owners_masters?(recipients, member)
+ recipients = member.source.group.members.active_without_invites_and_requests.owners_and_masters
+ end
+
+ recipients.each { |recipient| deliver_access_request_email(recipient, member) }
end
def decline_access_request(member)
@@ -299,7 +315,7 @@ class NotificationService
recipients = NotificationRecipientService.build_recipients(issue, current_user, action: 'moved')
recipients.map do |recipient|
- email = mailer.issue_moved_email(recipient, issue, new_issue, current_user)
+ email = mailer.issue_moved_email(recipient.user, issue, new_issue, current_user, recipient.reason)
email.deliver_later
email
end
@@ -333,22 +349,46 @@ class NotificationService
end
end
+ def pages_domain_verification_succeeded(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_verification_succeeded_email(domain, user).deliver_later
+ end
+ end
+
+ def pages_domain_verification_failed(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_verification_failed_email(domain, user).deliver_later
+ end
+ end
+
+ def pages_domain_enabled(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_enabled_email(domain, user).deliver_later
+ end
+ end
+
+ def pages_domain_disabled(domain)
+ recipients_for_pages_domain(domain).each do |user|
+ mailer.pages_domain_disabled_email(domain, user).deliver_later
+ end
+ end
+
protected
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
+ mailer.send(method, recipient.user.id, target.id, recipient.reason).deliver_later
end
end
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 = recipients.select {|r| new_mentioned_users.include?(r.user) }
recipients.each do |recipient|
- mailer.send(method, recipient.id, target.id, current_user.id).deliver_later
+ mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later
end
end
@@ -363,7 +403,7 @@ class NotificationService
)
recipients.each do |recipient|
- mailer.send(method, recipient.id, target.id, current_user.id).deliver_later
+ mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later
end
end
@@ -381,10 +421,11 @@ class NotificationService
recipients.each do |recipient|
mailer.send(
method,
- recipient.id,
+ recipient.user.id,
target.id,
previous_assignee_id,
- current_user.id
+ current_user.id,
+ recipient.reason
).deliver_later
end
end
@@ -408,7 +449,7 @@ class NotificationService
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
+ mailer.send(method, recipient.user.id, target.id, status, current_user.id, recipient.reason).deliver_later
end
end
@@ -426,6 +467,14 @@ class NotificationService
private
+ def recipients_for_pages_domain(domain)
+ project = domain.project
+
+ return [] unless project
+
+ notifiable_users(project.team.masters, :watch, target: project)
+ end
+
def notifiable?(*args)
NotificationRecipientService.notifiable?(*args)
end
@@ -433,4 +482,14 @@ class NotificationService
def notifiable_users(*args)
NotificationRecipientService.notifiable_users(*args)
end
+
+ def deliver_access_request_email(recipient, member)
+ mailer.member_access_requested_email(member.real_source_type, member.id, recipient.user.notification_email).deliver_later
+ end
+
+ def fallback_to_group_owners_masters?(recipients, member)
+ return false if recipients.present?
+
+ member.source.respond_to?(:group) && member.source.group
+ end
end
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 1ae2c40872a..346971138b1 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -21,7 +21,8 @@ module Projects
end
def labels(target = nil)
- labels = LabelsFinder.new(current_user, project_id: project.id).execute.select([:color, :title])
+ labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true)
+ .execute.select([:color, :title])
return labels unless target&.respond_to?(:labels)
@@ -50,16 +51,7 @@ module Projects
return [] unless noteable&.is_a?(Issuable)
- opts = {
- project: project,
- issuable: noteable,
- current_user: current_user
- }
- QuickActions::InterpretService.command_definitions.map do |definition|
- next unless definition.available?(opts)
-
- definition.to_h(opts)
- end.compact
+ QuickActions::InterpretService.new(project, current_user).available_commands(noteable)
end
end
end
diff --git a/app/services/projects/base_move_relations_service.rb b/app/services/projects/base_move_relations_service.rb
new file mode 100644
index 00000000000..e8fd3ef57e5
--- /dev/null
+++ b/app/services/projects/base_move_relations_service.rb
@@ -0,0 +1,22 @@
+module Projects
+ class BaseMoveRelationsService < BaseService
+ attr_reader :source_project
+ def execute(source_project, remove_remaining_elements: true)
+ return if source_project.blank?
+
+ @source_project = source_project
+
+ true
+ end
+
+ private
+
+ def prepare_relation(relation, id_param = :id)
+ if Gitlab::Database.postgresql?
+ relation
+ else
+ relation.model.where("#{id_param}": relation.pluck(id_param))
+ end
+ end
+ end
+end
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
index 87d9ed7a0e6..a549cfbabea 100644
--- a/app/services/projects/create_from_template_service.rb
+++ b/app/services/projects/create_from_template_service.rb
@@ -5,11 +5,15 @@ module Projects
end
def execute
- params[:file] = Gitlab::ProjectTemplate.find(params[:template_name]).file
+ template_name = params.delete(:template_name)
+ file = Gitlab::ProjectTemplate.find(template_name).file
+
+ params[:file] = file
+
+ GitlabProjectsImportService.new(current_user, params).execute
- GitlabProjectsImportService.new(@current_user, @params).execute
ensure
- params[:file]&.close
+ file&.close
end
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 71533da31b1..d361d070993 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -56,11 +56,7 @@ module Projects
after_create_actions if @project.persisted?
- if @project.errors.empty?
- @project.import_schedule if @project.import?
- else
- fail(error: @project.errors.full_messages.join(', '))
- end
+ import_schedule
@project
rescue ActiveRecord::RecordInvalid => e
@@ -89,19 +85,19 @@ module Projects
end
def after_create_actions
- log_info("#{@project.owner.name} created a new project \"#{@project.name_with_namespace}\"")
+ log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"")
unless @project.gitlab_project_import?
+ @project.write_repository_config
@project.create_wiki unless skip_wiki?
- create_services_from_active_templates(@project)
-
- @project.create_labels
end
event_service.create_project(@project, current_user)
system_hook_service.execute_hooks_for(@project, :create)
setup_authorizations
+
+ current_user.invalidate_personal_projects_count
end
# Refresh the current user's authorizations inline (so they can access the
@@ -124,21 +120,29 @@ module Projects
Project.transaction do
@project.create_or_update_import_data(data: import_data[:data], credentials: import_data[:credentials]) if import_data
- if @project.save && !@project.import?
- raise 'Failed to create repository' unless @project.create_repository
+ if @project.save
+ unless @project.gitlab_project_import?
+ create_services_from_active_templates(@project)
+ @project.create_labels
+ end
+
+ unless @project.import?
+ raise 'Failed to create repository' unless @project.create_repository
+ end
end
end
end
def fail(error:)
message = "Unable to save project. Error: #{error}"
- message << "Project ID: #{@project.id}" if @project && @project.id
+ log_message = message.dup
- Rails.logger.error(message)
+ log_message << " Project ID: #{@project.id}" if @project&.id
+ Rails.logger.error(log_message)
- if @project && @project.import?
+ if @project
@project.errors.add(:base, message)
- @project.mark_import_as_failed(message)
+ @project.mark_import_as_failed(message) if @project.import?
end
@project
@@ -164,5 +168,15 @@ module Projects
@project.path = @project.name.dup.parameterize
end
end
+
+ private
+
+ def import_schedule
+ if @project.errors.empty?
+ @project.import_schedule if @project.import? && !@project.bare_repository_import?
+ else
+ fail(error: @project.errors.full_messages.join(', '))
+ end
+ end
end
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 81972df9b3c..aa14206db3b 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -34,6 +34,8 @@ module Projects
system_hook_service.execute_hooks_for(project, :destroy)
log_info("Project \"#{project.full_path}\" was removed")
+ current_user.invalidate_personal_projects_count
+
true
rescue => error
attempt_rollback(project, error.message)
@@ -44,6 +46,20 @@ module Projects
raise
end
+ def attempt_repositories_rollback
+ return unless @project
+
+ flush_caches(@project)
+
+ unless mv_repository(removal_path(repo_path), repo_path)
+ raise_error('Failed to restore project repository. Please contact the administrator.')
+ end
+
+ unless mv_repository(removal_path(wiki_path), wiki_path)
+ raise_error('Failed to restore wiki repository. Please contact the administrator.')
+ end
+ end
+
private
def repo_path
@@ -68,12 +84,9 @@ module Projects
# Skip repository removal. We use this flag when remove user or group
return true if params[:skip_repo] == true
- # There is a possibility project does not have repository or wiki
- return true unless gitlab_shell.exists?(project.repository_storage_path, path + '.git')
-
new_path = removal_path(path)
- if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path)
+ if mv_repository(path, new_path)
log_info("Repository \"#{path}\" moved to \"#{new_path}\"")
project.run_after_commit do
@@ -85,10 +98,21 @@ module Projects
end
end
+ def mv_repository(from_path, to_path)
+ # There is a possibility project does not have repository or wiki
+ return true unless gitlab_shell.exists?(project.repository_storage_path, from_path + '.git')
+
+ gitlab_shell.mv_repository(project.repository_storage_path, from_path, to_path)
+ end
+
def attempt_rollback(project, message)
return unless project
- project.update_attributes(delete_error: message, pending_delete: false)
+ # It's possible that the project was destroyed, but some after_commit
+ # hook failed and caused us to end up here. A destroyed model will be a frozen hash,
+ # which cannot be altered.
+ project.update_attributes(delete_error: message, pending_delete: false) unless project.destroyed?
+
log_error("Deletion failed on #{project.full_path} with the following message: #{message}")
end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 03be7039b2a..348eb0bf8d8 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -26,7 +26,7 @@ module Projects
name: @project.name,
path: @project.path,
shared_runners_enabled: @project.shared_runners_enabled,
- namespace_id: @params[:namespace].try(:id) || current_user.namespace.id
+ namespace_id: target_namespace.id
}
if @project.avatar.present? && @project.avatar.image?
@@ -74,14 +74,14 @@ module Projects
Projects::ForksCountService.new(@project).refresh_cache
end
+ def target_namespace
+ @target_namespace ||= @params[:namespace] || current_user.namespace
+ end
+
def allowed_visibility_level
- project_level = @project.visibility_level
+ target_level = [@project.visibility_level, target_namespace.visibility_level].min
- if Gitlab::VisibilityLevel.non_restricted_level?(project_level)
- project_level
- else
- Gitlab::VisibilityLevel.highest_allowed_level
- end
+ Gitlab::VisibilityLevel.closest_allowed_level(target_level)
end
end
end
diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb
index 4ca6414b73b..a16268f4fd2 100644
--- a/app/services/projects/gitlab_projects_import_service.rb
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -5,18 +5,30 @@ module Projects
class GitlabProjectsImportService
attr_reader :current_user, :params
- def initialize(user, params)
- @current_user, @params = user, params.dup
+ def initialize(user, import_params, override_params = nil)
+ @current_user, @params, @override_params = user, import_params.dup, override_params
end
def execute
FileUtils.mkdir_p(File.dirname(import_upload_path))
+
+ file = params.delete(:file)
FileUtils.copy_entry(file.path, import_upload_path)
- Gitlab::ImportExport::ProjectCreator.new(params[:namespace_id],
- current_user,
- import_upload_path,
- params[:path]).execute
+ @overwrite = params.delete(:overwrite)
+ data = {}
+ data[:override_params] = @override_params if @override_params
+
+ if overwrite_project?
+ data[:original_path] = params[:path]
+ params[:path] += "-#{tmp_filename}"
+ end
+
+ params[:import_type] = 'gitlab_project'
+ params[:import_source] = import_upload_path
+ params[:import_data] = { data: data } if data.present?
+
+ ::Projects::CreateService.new(current_user, params).execute
end
private
@@ -26,11 +38,19 @@ module Projects
end
def tmp_filename
- "#{SecureRandom.hex}_#{params[:path]}"
+ SecureRandom.hex
+ end
+
+ def overwrite_project?
+ @overwrite && project_with_same_full_path?
+ end
+
+ def project_with_same_full_path?
+ Project.find_by_full_path("#{current_namespace.full_path}/#{params[:path]}").present?
end
- def file
- params[:file]
+ def current_namespace
+ @current_namespace ||= Namespace.find_by(id: params[:namespace_id])
end
end
end
diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb
index f8aaec8a9c0..bc897d891d5 100644
--- a/app/services/projects/hashed_storage/migrate_attachments_service.rb
+++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb
@@ -14,9 +14,9 @@ module Projects
@old_path = project.full_path
@new_path = project.disk_path
- origin = FileUploader.dynamic_path_segment(project)
+ origin = FileUploader.absolute_base_dir(project)
project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments]
- target = FileUploader.dynamic_path_segment(project)
+ target = FileUploader.absolute_base_dir(project)
result = move_folder!(origin, target)
project.save!
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
index 7212e7524ab..67178de75de 100644
--- a/app/services/projects/hashed_storage/migrate_repository_service.rb
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -27,7 +27,9 @@ module Projects
result &&= move_repository("#{@old_wiki_disk_path}", "#{@new_disk_path}.wiki")
end
- unless result
+ if result
+ project.write_repository_config
+ else
rollback_folder_move
project.storage_version = nil
end
diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb
index dcef8b66215..120d57a188d 100644
--- a/app/services/projects/housekeeping_service.rb
+++ b/app/services/projects/housekeeping_service.rb
@@ -7,8 +7,6 @@
#
module Projects
class HousekeepingService < BaseService
- include Gitlab::CurrentSettings
-
# Timeout set to 24h
LEASE_TIMEOUT = 86400
@@ -83,19 +81,19 @@ module Projects
end
def housekeeping_enabled?
- current_application_settings.housekeeping_enabled
+ Gitlab::CurrentSettings.housekeeping_enabled
end
def gc_period
- current_application_settings.housekeeping_gc_period
+ Gitlab::CurrentSettings.housekeeping_gc_period
end
def full_repack_period
- current_application_settings.housekeeping_full_repack_period
+ Gitlab::CurrentSettings.housekeeping_full_repack_period
end
def repack_period
- current_application_settings.housekeeping_incremental_repack_period
+ Gitlab::CurrentSettings.housekeeping_incremental_repack_period
end
end
end
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index fe4e8ea10bf..7bf0b90b491 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -1,22 +1,36 @@
module Projects
module ImportExport
class ExportService < BaseService
- def execute(_options = {})
- @shared = Gitlab::ImportExport::Shared.new(relative_path: File.join(project.disk_path, 'work'))
- save_all
+ def execute(after_export_strategy = nil, options = {})
+ @shared = project.import_export_shared
+
+ save_all!
+ execute_after_export_action(after_export_strategy)
end
private
- def save_all
- if [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save)
+ def execute_after_export_action(after_export_strategy)
+ return unless after_export_strategy
+
+ unless after_export_strategy.execute(current_user, project)
+ cleanup_and_notify_error
+ end
+ end
+
+ def save_all!
+ if save_services
Gitlab::ImportExport::Saver.save(project: project, shared: @shared)
notify_success
else
- cleanup_and_notify
+ cleanup_and_notify_error!
end
end
+ def save_services
+ [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver].all?(&:save)
+ end
+
def version_saver
Gitlab::ImportExport::VersionSaver.new(shared: @shared)
end
@@ -26,7 +40,7 @@ module Projects
end
def project_tree_saver
- Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared)
+ Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared, params: @params)
end
def uploads_saver
@@ -41,19 +55,26 @@ module Projects
Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared)
end
- def cleanup_and_notify
+ def lfs_saver
+ Gitlab::ImportExport::LfsSaver.new(project: project, shared: @shared)
+ end
+
+ def cleanup_and_notify_error
Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{@shared.errors.join(', ')}")
FileUtils.rm_rf(@shared.export_path)
notify_error
+ end
+
+ def cleanup_and_notify_error!
+ cleanup_and_notify_error
+
raise Gitlab::ImportExport::Error.new(@shared.errors.join(', '))
end
def notify_success
Rails.logger.info("Import/Export - Project #{project.name} with ID: #{project.id} successfully exported")
-
- notification_service.project_exported(@project, @current_user)
end
def notify_error
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index f2d676af5c3..bdd9598f85a 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -28,7 +28,11 @@ module Projects
def add_repository_to_project
if project.external_import? && !unknown_url?
- raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url)
+ begin
+ Gitlab::UrlBlocker.validate!(project.import_url, valid_ports: Project::VALID_IMPORT_PORTS)
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ raise Error, "Blocked import URL: #{e.message}"
+ end
end
# We should skip the repository for a GitHub import or GitLab project import,
@@ -57,7 +61,7 @@ module Projects
project.ensure_repository
project.repository.fetch_as_mirror(project.import_url, refmap: refmap)
else
- gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, project.import_url)
+ gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url)
end
rescue Gitlab::Shell::Error, Gitlab::Git::RepositoryMirroring::RemoteError => e
# Expire cache to prevent scenarios such as:
diff --git a/app/services/projects/move_access_service.rb b/app/services/projects/move_access_service.rb
new file mode 100644
index 00000000000..3af3a22d486
--- /dev/null
+++ b/app/services/projects/move_access_service.rb
@@ -0,0 +1,25 @@
+module Projects
+ class MoveAccessService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ @project.with_transaction_returning_status do
+ if @project.namespace != source_project.namespace
+ @project.run_after_commit do
+ source_project.namespace.refresh_project_authorizations
+ self.namespace.refresh_project_authorizations
+ end
+ end
+
+ ::Projects::MoveProjectMembersService.new(@project, @current_user)
+ .execute(source_project, remove_remaining_elements: remove_remaining_elements)
+ ::Projects::MoveProjectGroupLinksService.new(@project, @current_user)
+ .execute(source_project, remove_remaining_elements: remove_remaining_elements)
+ ::Projects::MoveProjectAuthorizationsService.new(@project, @current_user)
+ .execute(source_project, remove_remaining_elements: remove_remaining_elements)
+
+ success
+ end
+ end
+ end
+end
diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb
new file mode 100644
index 00000000000..dde420655b0
--- /dev/null
+++ b/app/services/projects/move_deploy_keys_projects_service.rb
@@ -0,0 +1,31 @@
+module Projects
+ class MoveDeployKeysProjectsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_deploy_keys_projects
+ remove_remaining_deploy_keys_projects if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_deploy_keys_projects
+ prepare_relation(non_existent_deploy_keys_projects)
+ .update_all(project_id: @project.id)
+ end
+
+ def non_existent_deploy_keys_projects
+ source_project.deploy_keys_projects
+ .joins(:deploy_key)
+ .where.not(keys: { fingerprint: @project.deploy_keys.select(:fingerprint) })
+ end
+
+ def remove_remaining_deploy_keys_projects
+ source_project.deploy_keys_projects.destroy_all
+ end
+ end
+end
diff --git a/app/services/projects/move_forks_service.rb b/app/services/projects/move_forks_service.rb
new file mode 100644
index 00000000000..d2901ea1457
--- /dev/null
+++ b/app/services/projects/move_forks_service.rb
@@ -0,0 +1,42 @@
+module Projects
+ class MoveForksService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super && source_project.fork_network
+
+ Project.transaction(requires_new: true) do
+ move_forked_project_links
+ move_fork_network_members
+ update_root_project
+ refresh_forks_count
+
+ success
+ end
+ end
+
+ private
+
+ def move_forked_project_links
+ # Update ancestor
+ ForkedProjectLink.where(forked_to_project: source_project)
+ .update_all(forked_to_project_id: @project.id)
+
+ # Update the descendants
+ ForkedProjectLink.where(forked_from_project: source_project)
+ .update_all(forked_from_project_id: @project.id)
+ end
+
+ def move_fork_network_members
+ ForkNetworkMember.where(project: source_project).update_all(project_id: @project.id)
+ ForkNetworkMember.where(forked_from_project: source_project).update_all(forked_from_project_id: @project.id)
+ end
+
+ def update_root_project
+ # Update root network project
+ ForkNetwork.where(root_project: source_project).update_all(root_project_id: @project.id)
+ end
+
+ def refresh_forks_count
+ Projects::ForksCountService.new(@project).refresh_cache
+ end
+ end
+end
diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb
new file mode 100644
index 00000000000..298da5f1a82
--- /dev/null
+++ b/app/services/projects/move_lfs_objects_projects_service.rb
@@ -0,0 +1,29 @@
+module Projects
+ class MoveLfsObjectsProjectsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_lfs_objects_projects
+ remove_remaining_lfs_objects_project if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_lfs_objects_projects
+ prepare_relation(non_existent_lfs_objects_projects)
+ .update_all(project_id: @project.lfs_storage_project.id)
+ end
+
+ def remove_remaining_lfs_objects_project
+ source_project.lfs_objects_projects.destroy_all
+ end
+
+ def non_existent_lfs_objects_projects
+ source_project.lfs_objects_projects.where.not(lfs_object: @project.lfs_objects)
+ end
+ end
+end
diff --git a/app/services/projects/move_notification_settings_service.rb b/app/services/projects/move_notification_settings_service.rb
new file mode 100644
index 00000000000..f7be461a5da
--- /dev/null
+++ b/app/services/projects/move_notification_settings_service.rb
@@ -0,0 +1,38 @@
+module Projects
+ class MoveNotificationSettingsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_notification_settings
+ remove_remaining_notification_settings if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_notification_settings
+ prepare_relation(non_existent_notifications)
+ .update_all(source_id: @project.id)
+ end
+
+ # Remove remaining notification settings from source_project
+ def remove_remaining_notification_settings
+ source_project.notification_settings.destroy_all
+ end
+
+ # Get users of current notification_settings
+ def users_in_target_project
+ @project.notification_settings.select(:user_id)
+ end
+
+ # Look for notification_settings in source_project that are not in the target project
+ def non_existent_notifications
+ source_project.notification_settings
+ .select(:id)
+ .where.not(user_id: users_in_target_project)
+ end
+ end
+end
diff --git a/app/services/projects/move_project_authorizations_service.rb b/app/services/projects/move_project_authorizations_service.rb
new file mode 100644
index 00000000000..5ef12fc49e5
--- /dev/null
+++ b/app/services/projects/move_project_authorizations_service.rb
@@ -0,0 +1,40 @@
+# NOTE: This service cannot be used directly because it is part of a
+# a bigger process. Instead, use the service MoveAccessService which moves
+# project memberships, project group links, authorizations and refreshes
+# the authorizations if neccessary
+module Projects
+ class MoveProjectAuthorizationsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_project_authorizations
+
+ remove_remaining_authorizations if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_project_authorizations
+ prepare_relation(non_existent_authorization, :user_id)
+ .update_all(project_id: @project.id)
+ end
+
+ def remove_remaining_authorizations
+ # I think because the Project Authorization table does not have a primary key
+ # it brings a lot a problems/bugs. First, Rails raises PG::SyntaxException if we use
+ # destroy_all instead of delete_all.
+ source_project.project_authorizations.delete_all(:delete_all)
+ end
+
+ # Look for authorizations in source_project that are not in the target project
+ def non_existent_authorization
+ source_project.project_authorizations
+ .select(:user_id)
+ .where.not(user: @project.authorized_users)
+ end
+ end
+end
diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb
new file mode 100644
index 00000000000..dbeffd7dae9
--- /dev/null
+++ b/app/services/projects/move_project_group_links_service.rb
@@ -0,0 +1,40 @@
+# NOTE: This service cannot be used directly because it is part of a
+# a bigger process. Instead, use the service MoveAccessService which moves
+# project memberships, project group links, authorizations and refreshes
+# the authorizations if neccessary
+module Projects
+ class MoveProjectGroupLinksService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_group_links
+ remove_remaining_project_group_links if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_group_links
+ prepare_relation(non_existent_group_links)
+ .update_all(project_id: @project.id)
+ end
+
+ # Remove remaining project group links from source_project
+ def remove_remaining_project_group_links
+ source_project.reload.project_group_links.destroy_all
+ end
+
+ def group_links_in_target_project
+ @project.project_group_links.select(:group_id)
+ end
+
+ # Look for groups in source_project that are not in the target project
+ def non_existent_group_links
+ source_project.project_group_links
+ .where.not(group_id: group_links_in_target_project)
+ end
+ end
+end
diff --git a/app/services/projects/move_project_members_service.rb b/app/services/projects/move_project_members_service.rb
new file mode 100644
index 00000000000..22a5f0a3fe6
--- /dev/null
+++ b/app/services/projects/move_project_members_service.rb
@@ -0,0 +1,40 @@
+# NOTE: This service cannot be used directly because it is part of a
+# a bigger process. Instead, use the service MoveAccessService which moves
+# project memberships, project group links, authorizations and refreshes
+# the authorizations if neccessary
+module Projects
+ class MoveProjectMembersService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ Project.transaction(requires_new: true) do
+ move_project_members
+ remove_remaining_members if remove_remaining_elements
+
+ success
+ end
+ end
+
+ private
+
+ def move_project_members
+ prepare_relation(non_existent_members).update_all(source_id: @project.id)
+ end
+
+ def remove_remaining_members
+ # Remove remaining members and authorizations from source_project
+ source_project.project_members.destroy_all
+ end
+
+ def project_members_in_target_project
+ @project.project_members.select(:user_id)
+ end
+
+ # Look for members in source_project that are not in the target project
+ def non_existent_members
+ source_project.members
+ .select(:id)
+ .where.not(user_id: @project.project_members.select(:user_id))
+ end
+ end
+end
diff --git a/app/services/projects/move_users_star_projects_service.rb b/app/services/projects/move_users_star_projects_service.rb
new file mode 100644
index 00000000000..079fd5b9685
--- /dev/null
+++ b/app/services/projects/move_users_star_projects_service.rb
@@ -0,0 +1,20 @@
+module Projects
+ class MoveUsersStarProjectsService < BaseMoveRelationsService
+ def execute(source_project, remove_remaining_elements: true)
+ return unless super
+
+ user_stars = source_project.users_star_projects
+
+ return unless user_stars.any?
+
+ Project.transaction(requires_new: true) do
+ user_stars.update_all(project_id: @project.id)
+
+ Project.reset_counters @project.id, :users_star_projects
+ Project.reset_counters source_project.id, :users_star_projects
+
+ success
+ end
+ end
+ end
+end
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
new file mode 100644
index 00000000000..ce94f147aa9
--- /dev/null
+++ b/app/services/projects/overwrite_project_service.rb
@@ -0,0 +1,69 @@
+module Projects
+ class OverwriteProjectService < BaseService
+ def execute(source_project)
+ return unless source_project && source_project.namespace == @project.namespace
+
+ Project.transaction do
+ move_before_destroy_relationships(source_project)
+ destroy_old_project(source_project)
+ rename_project(source_project.name, source_project.path)
+
+ @project
+ end
+ # Projects::DestroyService can raise Exceptions, but we don't want
+ # to pass that kind of exception to the caller. Instead, we change it
+ # for a StandardError exception
+ rescue Exception => e # rubocop:disable Lint/RescueException
+ attempt_restore_repositories(source_project)
+
+ if e.class == Exception
+ raise StandardError, e.message
+ else
+ raise
+ end
+ end
+
+ private
+
+ def move_before_destroy_relationships(source_project)
+ options = { remove_remaining_elements: false }
+
+ ::Projects::MoveUsersStarProjectsService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveAccessService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveDeployKeysProjectsService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveNotificationSettingsService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveForksService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveLfsObjectsProjectsService.new(@project, @current_user).execute(source_project, options)
+ add_source_project_to_fork_network(source_project)
+ end
+
+ def destroy_old_project(source_project)
+ # Delete previous project (synchronously) and unlink relations
+ ::Projects::DestroyService.new(source_project, @current_user).execute
+ end
+
+ def rename_project(name, path)
+ # Update de project's name and path to the original name/path
+ ::Projects::UpdateService.new(@project,
+ @current_user,
+ { name: name, path: path })
+ .execute
+ end
+
+ def attempt_restore_repositories(project)
+ ::Projects::DestroyService.new(project, @current_user).attempt_repositories_rollback
+ end
+
+ def add_source_project_to_fork_network(source_project)
+ return unless @project.fork_network
+
+ # Because he have moved all references in the fork network from the source_project
+ # we won't be able to query the database (only through its cached data),
+ # for its former relationships. That's why we're adding it to the network
+ # as a fork of the target project
+ ForkNetworkMember.create!(fork_network: @project.fork_network,
+ project: source_project,
+ forked_from_project: @project)
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index e5cd6fcdfe3..5a23f0f0a62 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -24,6 +24,8 @@ module Projects
transfer(project)
+ current_user.invalidate_personal_projects_count
+
true
rescue Projects::TransferService::TransferError => ex
project.reload
@@ -75,6 +77,8 @@ module Projects
project.old_path_with_namespace = @old_path
project.expires_full_path_cache
+ write_repository_config(@new_path)
+
execute_system_hooks
end
rescue Exception # rubocop:disable Lint/RescueException
@@ -98,6 +102,10 @@ module Projects
project.save!
end
+ def write_repository_config(full_path)
+ project.write_repository_config(gl_full_path: full_path)
+ end
+
def refresh_permissions
# This ensures we only schedule 1 job for every user that has access to
# the namespaces.
@@ -110,6 +118,7 @@ module Projects
def rollback_side_effects
rollback_folder_move
update_namespace_and_visibility(@old_namespace)
+ write_repository_config(@old_path)
end
def rollback_folder_move
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index cacb74b1205..25017c5cbe3 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -18,20 +18,30 @@ module Projects
def pages_config
{
- domains: pages_domains_config
+ domains: pages_domains_config,
+ https_only: project.pages_https_only?
}
end
def pages_domains_config
- project.pages_domains.map do |domain|
+ enabled_pages_domains.map do |domain|
{
domain: domain.domain,
certificate: domain.certificate,
- key: domain.key
+ key: domain.key,
+ https_only: project.pages_https_only? && domain.https?
}
end
end
+ def enabled_pages_domains
+ if Gitlab::CurrentSettings.pages_domain_verification_enabled?
+ project.pages_domains.enabled
+ else
+ project.pages_domains
+ end
+ end
+
def reload_daemon
# GitLab Pages daemon constantly watches for modification time of `pages.path`
# It reloads configuration when `pages.path` is modified
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index a773222bf17..de77f6bf585 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -1,6 +1,7 @@
module Projects
class UpdatePagesService < BaseService
- include Gitlab::CurrentSettings
+ InvaildStateError = Class.new(StandardError)
+ FailedToExtractError = Class.new(StandardError)
BLOCK_SIZE = 32.kilobytes
MAX_SIZE = 1.terabyte
@@ -13,13 +14,15 @@ module Projects
end
def execute
+ register_attempt
+
# Create status notifying the deployment of pages
@status = create_status
@status.enqueue!
@status.run!
- raise 'missing pages artifacts' unless build.artifacts?
- raise 'pages are outdated' unless latest?
+ raise InvaildStateError, 'missing pages artifacts' unless build.artifacts?
+ raise InvaildStateError, 'pages are outdated' unless latest?
# Create temporary directory in which we will extract the artifacts
FileUtils.mkdir_p(tmp_path)
@@ -28,32 +31,34 @@ module Projects
# Check if we did extract public directory
archive_public_path = File.join(archive_path, 'public')
- raise 'pages miss the public folder' unless Dir.exist?(archive_public_path)
- raise 'pages are outdated' unless latest?
+ raise InvaildStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
+ raise InvaildStateError, 'pages are outdated' unless latest?
deploy_page!(archive_public_path)
success
end
- rescue => e
- register_failure
+ rescue InvaildStateError => e
error(e.message)
- ensure
- register_attempt
- build.erase_artifacts! unless build.has_expiring_artifacts?
+ rescue => e
+ error(e.message, false)
+ raise e
end
private
def success
@status.success
+ delete_artifact!
super
end
- def error(message, http_status = nil)
+ def error(message, allow_delete_artifact = true)
+ register_failure
log_error("Projects::UpdatePagesService: #{message}")
@status.allow_failure = !latest?
@status.description = message
@status.drop(:script_failure)
+ delete_artifact! if allow_delete_artifact
super
end
@@ -69,31 +74,21 @@ module Projects
end
def extract_archive!(temp_path)
- if artifacts.ends_with?('.tar.gz') || artifacts.ends_with?('.tgz')
- extract_tar_archive!(temp_path)
- elsif artifacts.ends_with?('.zip')
+ if artifacts.ends_with?('.zip')
extract_zip_archive!(temp_path)
else
- raise 'unsupported artifacts format'
+ raise InvaildStateError, 'unsupported artifacts format'
end
end
- def extract_tar_archive!(temp_path)
- results = Open3.pipeline(%W(gunzip -c #{artifacts}),
- %W(dd bs=#{BLOCK_SIZE} count=#{blocks}),
- %W(tar -x -C #{temp_path} #{SITE_PATH}),
- err: '/dev/null')
- raise 'pages failed to extract' unless results.compact.all?(&:success?)
- end
-
def extract_zip_archive!(temp_path)
- raise 'missing artifacts metadata' unless build.artifacts_metadata?
+ raise InvaildStateError, 'missing artifacts metadata' unless build.artifacts_metadata?
# Calculate page size after extract
public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true)
if public_entry.total_size > max_size
- raise "artifacts for pages are too large: #{public_entry.total_size}"
+ raise InvaildStateError, "artifacts for pages are too large: #{public_entry.total_size}"
end
# Requires UnZip at least 6.00 Info-ZIP.
@@ -101,8 +96,10 @@ module Projects
# -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 -qq -n #{artifacts} #{site_path} -d #{temp_path}))
- raise 'pages failed to extract'
+ build.artifacts_file.use_file do |artifacts_path|
+ unless system(*%W(unzip -n #{artifacts_path} #{site_path} -d #{temp_path}))
+ raise FailedToExtractError, 'pages failed to extract'
+ end
end
end
@@ -134,7 +131,7 @@ module Projects
end
def max_size
- max_pages_size = current_application_settings.max_pages_size.megabytes
+ max_pages_size = Gitlab::CurrentSettings.max_pages_size.megabytes
return MAX_SIZE if max_pages_size.zero?
@@ -165,8 +162,16 @@ module Projects
build.artifacts_file.path
end
+ def delete_artifact!
+ build.reload # Reload stable object to prevent erase artifacts with old state
+ build.erase_artifacts! unless build.has_expiring_artifacts?
+ end
+
def latest_sha
project.commit(build.ref).try(:sha).to_s
+ ensure
+ # Close any file descriptors that were opened and free libgit2 buffers
+ project.cleanup
end
def sha
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index ff4c73c886e..679f4a9cb62 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -15,6 +15,8 @@ module Projects
return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end
+ ensure_wiki_exists if enabling_wiki?
+
if project.update_attributes(params.except(:default_branch))
if project.previous_changes.include?('path')
project.rename_repo
@@ -22,6 +24,8 @@ module Projects
system_hook_service.execute_hooks_for(project, :update)
end
+ update_pages_config if changing_pages_https_only?
+
success
else
model_errors = project.errors.full_messages.to_sentence
@@ -34,7 +38,7 @@ module Projects
def run_auto_devops_pipeline?
return false if project.repository.gitlab_ci_yml || !project.auto_devops.previous_changes.include?('enabled')
- project.auto_devops.enabled? || (project.auto_devops.enabled.nil? && current_application_settings.auto_devops_enabled?)
+ project.auto_devops.enabled? || (project.auto_devops.enabled.nil? && Gitlab::CurrentSettings.auto_devops_enabled?)
end
private
@@ -52,5 +56,26 @@ module Projects
project.repository.exists? &&
new_branch && new_branch != project.default_branch
end
+
+ def enabling_wiki?
+ return false if @project.wiki_enabled?
+
+ params.dig(:project_feature_attributes, :wiki_access_level).to_i > ProjectFeature::DISABLED
+ end
+
+ def ensure_wiki_exists
+ ProjectWiki.new(project, project.owner).wiki
+ rescue ProjectWiki::CouldNotCreateWikiError
+ log_error("Could not create wiki for #{project.full_name}")
+ Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki')
+ end
+
+ def update_pages_config
+ Projects::UpdatePagesConfigurationService.new(project).execute
+ end
+
+ def changing_pages_https_only?
+ project.previous_changes.include?(:pages_https_only)
+ end
end
end
diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb
new file mode 100644
index 00000000000..4504d2ccfe6
--- /dev/null
+++ b/app/services/prometheus/adapter_service.rb
@@ -0,0 +1,36 @@
+module Prometheus
+ class AdapterService
+ def initialize(project, deployment_platform = nil)
+ @project = project
+
+ @deployment_platform = if deployment_platform
+ deployment_platform
+ else
+ project.deployment_platform
+ end
+ end
+
+ attr_reader :deployment_platform, :project
+
+ def prometheus_adapter
+ @prometheus_adapter ||= if service_prometheus_adapter.can_query?
+ service_prometheus_adapter
+ else
+ cluster_prometheus_adapter
+ end
+ end
+
+ def service_prometheus_adapter
+ project.find_or_initialize_service('prometheus')
+ end
+
+ def cluster_prometheus_adapter
+ return unless deployment_platform.respond_to?(:cluster)
+
+ cluster = deployment_platform.cluster
+ return unless cluster.application_prometheus&.installed?
+
+ cluster.application_prometheus
+ end
+ end
+end
diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb
index a84e335340d..9d947f73af1 100644
--- a/app/services/protected_branches/create_service.rb
+++ b/app/services/protected_branches/create_service.rb
@@ -1,11 +1,20 @@
module ProtectedBranches
class CreateService < BaseService
- attr_reader :protected_branch
+ def execute(skip_authorization: false)
+ raise Gitlab::Access::AccessDeniedError unless skip_authorization || authorized?
- def execute
- raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
+ protected_branch.save
+ protected_branch
+ end
+
+ def authorized?
+ can?(current_user, :create_protected_branch, protected_branch)
+ end
+
+ private
- project.protected_branches.create(params)
+ def protected_branch
+ @protected_branch ||= project.protected_branches.new(params)
end
end
end
diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb
new file mode 100644
index 00000000000..8172c896e76
--- /dev/null
+++ b/app/services/protected_branches/destroy_service.rb
@@ -0,0 +1,9 @@
+module ProtectedBranches
+ class DestroyService < BaseService
+ def execute(protected_branch)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_protected_branch, protected_branch)
+
+ protected_branch.destroy
+ end
+ end
+end
diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb
index 4b3337a5c9d..95e46645374 100644
--- a/app/services/protected_branches/update_service.rb
+++ b/app/services/protected_branches/update_service.rb
@@ -1,7 +1,7 @@
module ProtectedBranches
class UpdateService < BaseService
def execute(protected_branch)
- raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :update_protected_branch, protected_branch)
protected_branch.update(params)
protected_branch
diff --git a/app/services/protected_tags/destroy_service.rb b/app/services/protected_tags/destroy_service.rb
new file mode 100644
index 00000000000..c868d7ad8e6
--- /dev/null
+++ b/app/services/protected_tags/destroy_service.rb
@@ -0,0 +1,7 @@
+module ProtectedTags
+ class DestroyService < BaseService
+ def execute(protected_tag)
+ protected_tag.destroy
+ end
+ end
+end
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index 669c1ba0a22..6cc51b6ee1b 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -7,6 +7,18 @@ module QuickActions
SHRUG = '¯\\_(ツ)_/¯'.freeze
TABLEFLIP = '(╯°□°)╯︵ â”»â”â”»'.freeze
+ # Takes an issuable and returns an array of all the available commands
+ # represented with .to_h
+ def available_commands(issuable)
+ @issuable = issuable
+
+ self.class.command_definitions.map do |definition|
+ next unless definition.available?(self)
+
+ definition.to_h(self)
+ end.compact
+ end
+
# 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)
@@ -15,8 +27,8 @@ module QuickActions
@issuable = issuable
@updates = {}
- content, commands = extractor.extract_commands(content, context)
- extract_updates(commands, context)
+ content, commands = extractor.extract_commands(content)
+ extract_updates(commands)
[content, @updates]
end
@@ -28,8 +40,8 @@ module QuickActions
@issuable = issuable
- content, commands = extractor.extract_commands(content, context)
- commands = explain_commands(commands, context)
+ content, commands = extractor.extract_commands(content)
+ commands = explain_commands(commands)
[content, commands]
end
@@ -157,11 +169,11 @@ module QuickActions
params '%"milestone"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
- project.milestones.active.any?
+ find_milestones(project, state: 'active').any?
end
parse_params do |milestone_param|
extract_references(milestone_param, :milestone).first ||
- project.milestones.find_by(title: milestone_param.strip)
+ find_milestones(project, title: milestone_param.strip).first
end
command :milestone do |milestone|
@updates[:milestone_id] = milestone.id if milestone
@@ -188,7 +200,7 @@ module QuickActions
end
params '~label1 ~"label 2"'
condition do
- available_labels = LabelsFinder.new(current_user, project_id: project.id).execute
+ available_labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true).execute
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
available_labels.any?
@@ -335,9 +347,9 @@ module QuickActions
"#{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)
+ issuable.respond_to?(:work_in_progress?) &&
+ # Allow it to mark as WIP on MR creation page _or_ through MR notes.
+ (issuable.new_record? || current_user.can?(:"update_#{issuable.to_ability_name}", issuable))
end
command :wip do
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
@@ -544,9 +556,13 @@ module QuickActions
users
end
+ def find_milestones(project, params = {})
+ MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute
+ end
+
def find_labels(labels_param)
extract_references(labels_param, :label) |
- LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
+ LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split, include_ancestor_groups: true).execute
end
def find_label_references(labels_param)
@@ -557,38 +573,30 @@ module QuickActions
find_labels(labels_param).map(&:id)
end
- def explain_commands(commands, opts)
+ def explain_commands(commands)
commands.map do |name, arg|
definition = self.class.definition_by_name(name)
next unless definition
- definition.explain(self, opts, arg)
+ definition.explain(self, arg)
end.compact
end
- def extract_updates(commands, opts)
+ def extract_updates(commands)
commands.each do |name, arg|
definition = self.class.definition_by_name(name)
next unless definition
- definition.execute(self, opts, arg)
+ definition.execute(self, 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/reset_project_cache_service.rb b/app/services/reset_project_cache_service.rb
new file mode 100644
index 00000000000..a162a6eedb9
--- /dev/null
+++ b/app/services/reset_project_cache_service.rb
@@ -0,0 +1,5 @@
+class ResetProjectCacheService < BaseService
+ def execute
+ @project.increment!(:jobs_cache_index)
+ end
+end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 14171bce782..ac029fad7ea 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -11,21 +11,20 @@ class SubmitUsagePingService
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?
+ return false unless Gitlab::CurrentSettings.usage_ping_enabled?
- response = HTTParty.post(
+ response = Gitlab::HTTP.post(
URL,
body: Gitlab::UsageData.to_json(force_refresh: true),
+ allow_local_requests: true,
headers: { 'Content-type' => 'application/json' }
)
store_metrics(response)
true
- rescue HTTParty::Error => e
+ rescue Gitlab::HTTP::Error => e
Rails.logger.info "Unable to contact GitLab, Inc.: #{e}"
false
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index 690918b4a00..ba7946fd23c 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -8,9 +8,11 @@ class SystemHooksService
end
def execute_hooks(data, hooks_scope = :all)
- SystemHook.public_send(hooks_scope).find_each do |hook| # rubocop:disable GitlabSecurity/PublicSend
+ SystemHook.hooks_for(hooks_scope).find_each do |hook|
hook.async_execute(data, 'system_hooks')
end
+
+ Gitlab::Plugin.execute_all_async(data)
end
private
@@ -18,8 +20,8 @@ class SystemHooksService
def build_event_data(model, event)
data = {
event_name: build_event_name(model, event),
- created_at: model.created_at.xmlschema,
- updated_at: model.updated_at.xmlschema
+ created_at: model.created_at&.xmlschema,
+ updated_at: model.updated_at&.xmlschema
}
case model
@@ -41,8 +43,11 @@ class SystemHooksService
when User
data.merge!(user_data(model))
- if event == :rename
+ case event
+ when :rename
data[:old_username] = model.username_was
+ when :failed_login
+ data[:state] = model.state
end
when ProjectMember
data.merge!(project_member_data(model))
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 30a5aab13bf..00bf5434b7f 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -22,8 +22,7 @@ module SystemNoteService
commits_text = "#{total_count} commit".pluralize(total_count)
body = "added #{commits_text}\n\n"
- body << existing_commit_summary(noteable, existing_commits, oldrev)
- body << new_commit_summary(new_commits).join("\n")
+ body << commits_list(noteable, new_commits, existing_commits, oldrev)
body << "\n\n[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})"
create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count))
@@ -68,21 +67,14 @@ module SystemNoteService
#
# 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
+ 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?
+
+ body = text_parts.join(' and ')
create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
end
@@ -437,7 +429,7 @@ module SystemNoteService
def cross_reference(noteable, mentioner, author)
return if cross_reference_disallowed?(noteable, mentioner)
- gfm_reference = mentioner.gfm_reference(noteable.project)
+ gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
body = cross_reference_note_content(gfm_reference)
if noteable.is_a?(ExternalIssue)
@@ -488,7 +480,7 @@ module SystemNoteService
# Returns an Array of Strings
def new_commit_summary(new_commits)
new_commits.collect do |commit|
- "* #{commit.short_id} - #{escape_html(commit.title)}"
+ content_tag('li', "#{commit.short_id} - #{commit.title}")
end
end
@@ -590,7 +582,7 @@ module SystemNoteService
text = "#{cross_reference_note_prefix}%#{mentioner.to_reference(nil)}"
notes.where('(note LIKE ? OR note LIKE ?)', text, text.capitalize)
else
- gfm_reference = mentioner.gfm_reference(noteable.project)
+ gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
text = cross_reference_note_content(gfm_reference)
notes.where(note: [text, text.capitalize])
end
@@ -611,6 +603,16 @@ module SystemNoteService
"#{cross_reference_note_prefix}#{gfm_reference}"
end
+ # Builds a list of existing and new commits according to existing_commits and
+ # new_commits methods.
+ # Returns a String wrapped in `ul` and `li` tags.
+ def commits_list(noteable, new_commits, existing_commits, oldrev)
+ existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev)
+ new_commit_summary = new_commit_summary(new_commits).join
+
+ content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe)
+ end
+
# Build a single line summarizing existing commits being added in a merge
# request
#
@@ -647,11 +649,8 @@ module SystemNoteService
branch = noteable.target_branch
branch = "#{noteable.target_project_namespace}:#{branch}" if noteable.for_fork?
- "* #{commit_ids} - #{commits_text} from branch `#{branch}`\n"
- end
-
- def escape_html(text)
- Rack::Utils.escape_html(text)
+ branch_name = content_tag('code', branch)
+ content_tag('li', "#{commit_ids} - #{commits_text} from branch #{branch_name}".html_safe)
end
def url_helpers
@@ -668,4 +667,8 @@ module SystemNoteService
start_sha: oldrev
)
end
+
+ def content_tag(*args)
+ ActionController::Base.helpers.content_tag(*args)
+ end
end
diff --git a/app/services/test_hooks/base_service.rb b/app/services/test_hooks/base_service.rb
index 20d90504bd2..e9aefb1fb75 100644
--- a/app/services/test_hooks/base_service.rb
+++ b/app/services/test_hooks/base_service.rb
@@ -9,7 +9,7 @@ module TestHooks
end
def execute
- trigger_key = hook.class::TRIGGERS.key(trigger.to_sym)
+ trigger_key = hook.class.triggers.key(trigger.to_sym)
trigger_data_method = "#{trigger}_data"
if trigger_key.nil? || !self.respond_to?(trigger_data_method, true)
diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb
index 67552edefc9..9016c77b7f0 100644
--- a/app/services/test_hooks/system_service.rb
+++ b/app/services/test_hooks/system_service.rb
@@ -13,5 +13,12 @@ module TestHooks
def repository_update_events_data
Gitlab::DataBuilder::Repository.sample_data
end
+
+ def merge_requests_events_data
+ merge_request = MergeRequest.of_projects(current_user.projects.select(:id)).first
+ throw(:validation_error, 'Ensure one of your projects has merge requests.') unless merge_request.present?
+
+ merge_request.to_hook_data(current_user)
+ end
end
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index c2ca404b179..ffd48e842c2 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -241,8 +241,7 @@ class TodoService
end
def handle_note(note, author, skip_users = [])
- # Skip system notes, and notes on project snippet
- return if note.system? || note.for_snippet?
+ return unless note.can_create_todo?
project = note.project
target = note.noteable
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
index 76700dfcdee..d5a9b344905 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -1,6 +1,4 @@
class UploadService
- include Gitlab::CurrentSettings
-
def initialize(model, file, uploader_class = FileUploader)
@model, @file, @uploader_class = model, file, uploader_class
end
@@ -17,6 +15,6 @@ class UploadService
private
def max_attachment_size
- current_application_settings.max_attachment_size.megabytes.to_i
+ Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
end
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 61f1568f366..4fb6d221909 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -1,7 +1,5 @@
module Users
class BuildService < BaseService
- include Gitlab::CurrentSettings
-
def initialize(current_user, params = {})
@current_user = current_user
@params = params.dup
@@ -34,7 +32,7 @@ module Users
private
def can_create_user?
- (current_user.nil? && current_application_settings.allow_signup?) || current_user&.admin?
+ (current_user.nil? && Gitlab::CurrentSettings.allow_signup?) || current_user&.admin?
end
# Allowed params for creating a user (admins only)
@@ -102,7 +100,7 @@ module Users
end
def skip_user_confirmation_email_from_setting
- !current_application_settings.send_user_confirmation_email
+ !Gitlab::CurrentSettings.send_user_confirmation_email
end
end
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 8e20de8dfa5..06b604dad4d 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -31,6 +31,11 @@ module Users
return user
end
+ # Calling all before/after_destroy hooks for the user because
+ # there is no dependent: destroy in the relationship. And the removal
+ # is done by a foreign_key. Otherwise they won't be called
+ user.members.find_each { |member| member.run_callbacks(:destroy) }
+
user.solo_owned_groups.each do |group|
Groups::DestroyService.new(group, current_user).execute
end
@@ -44,11 +49,13 @@ module Users
::Projects::DestroyService.new(project, current_user, skip_repo: project.legacy_storage?).execute
end
+ yield(user) if block_given?
+
MigrateToGhostUserService.new(user).execute unless options[:hard_delete]
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
user_data = user.destroy
- namespace.really_destroy!
+ namespace.destroy
user_data
end
diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb
new file mode 100644
index 00000000000..13cb53dee01
--- /dev/null
+++ b/app/services/verify_pages_domain_service.rb
@@ -0,0 +1,111 @@
+require 'resolv'
+
+class VerifyPagesDomainService < BaseService
+ # The maximum number of seconds to be spent on each DNS lookup
+ RESOLVER_TIMEOUT_SECONDS = 15
+
+ # How long verification lasts for
+ VERIFICATION_PERIOD = 7.days
+
+ attr_reader :domain
+
+ def initialize(domain)
+ @domain = domain
+ end
+
+ def execute
+ return error("No verification code set for #{domain.domain}") unless domain.verification_code.present?
+
+ if !verification_enabled? || dns_record_present?
+ verify_domain!
+ elsif expired?
+ disable_domain!
+ else
+ unverify_domain!
+ end
+ end
+
+ private
+
+ def verify_domain!
+ was_disabled = !domain.enabled?
+ was_unverified = domain.unverified?
+
+ # Prevent any pre-existing grace period from being truncated
+ reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max
+
+ domain.assign_attributes(verified_at: Time.now, enabled_until: reverify)
+ domain.save!(validate: false)
+
+ if was_disabled
+ notify(:enabled)
+ elsif was_unverified
+ notify(:verification_succeeded)
+ end
+
+ success
+ end
+
+ def unverify_domain!
+ if domain.verified?
+ domain.assign_attributes(verified_at: nil)
+ domain.save!(validate: false)
+
+ notify(:verification_failed)
+ end
+
+ error("Couldn't verify #{domain.domain}")
+ end
+
+ def disable_domain!
+ domain.assign_attributes(verified_at: nil, enabled_until: nil)
+ domain.save!(validate: false)
+
+ notify(:disabled)
+
+ error("Couldn't verify #{domain.domain}. It is now disabled.")
+ end
+
+ # A domain is only expired until `disable!` has been called
+ def expired?
+ domain.enabled_until && domain.enabled_until < Time.now
+ end
+
+ def dns_record_present?
+ Resolv::DNS.open do |resolver|
+ resolver.timeouts = RESOLVER_TIMEOUT_SECONDS
+
+ check(domain.domain, resolver) || check(domain.verification_domain, resolver)
+ end
+ end
+
+ def check(domain_name, resolver)
+ records = parse(txt_records(domain_name, resolver))
+
+ records.any? do |record|
+ record == domain.keyed_verification_code || record == domain.verification_code
+ end
+ rescue => err
+ log_error("Failed to check TXT records on #{domain_name} for #{domain.domain}: #{err}")
+ false
+ end
+
+ def txt_records(domain_name, resolver)
+ resolver.getresources(domain_name, Resolv::DNS::Resource::IN::TXT)
+ end
+
+ def parse(records)
+ records.flat_map(&:strings).flat_map(&:split)
+ end
+
+ def verification_enabled?
+ Gitlab::CurrentSettings.pages_domain_verification_enabled?
+ end
+
+ def notify(type)
+ return unless verification_enabled?
+
+ Gitlab::AppLogger.info("Pages domain '#{domain.domain}' changed state to '#{type}'")
+ notification_service.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 6ebc7c89500..809ce1303d8 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -3,23 +3,20 @@ class WebHookService
attr_reader :body, :headers, :code
def initialize
- @headers = HTTParty::Response::Headers.new({})
+ @headers = Gitlab::HTTP::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
+ attr_accessor :hook, :data, :hook_name, :request_options
def initialize(hook, data, hook_name)
@hook = hook
@data = data
@hook_name = hook_name.to_s
+ @request_options = { timeout: Gitlab.config.gitlab.webhook_timeout }
+ @request_options.merge!(allow_local_requests: true) if @hook.is_a?(SystemHook)
end
def execute
@@ -73,11 +70,12 @@ class WebHookService
end
def make_request(url, basic_auth = false)
- self.class.post(url,
+ Gitlab::HTTP.post(url,
body: data.to_json,
headers: build_headers(hook_name),
verify: hook.enable_ssl_verification,
- basic_auth: basic_auth)
+ basic_auth: basic_auth,
+ **request_options)
end
def make_request_with_auth
@@ -113,7 +111,7 @@ class WebHookService
'Content-Type' => 'application/json',
'X-Gitlab-Event' => hook_name.singularize.titleize
}.tap do |hash|
- hash['X-Gitlab-Token'] = hook.token if hook.token.present?
+ hash['X-Gitlab-Token'] = Gitlab::Utils.remove_line_breaks(hook.token) if hook.token.present?
end
end
end
diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb
index 109eb2fea0b..cd819dc9bff 100644
--- a/app/uploaders/attachment_uploader.rb
+++ b/app/uploaders/attachment_uploader.rb
@@ -1,10 +1,12 @@
class AttachmentUploader < GitlabUploader
- include RecordsUploads
+ include RecordsUploads::Concern
+ include ObjectStorage::Concern
+ prepend ObjectStorage::Extension::RecordsUploads
include UploaderHelper
- storage :file
+ private
- def store_dir
- "#{base_dir}/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
+ def dynamic_segment
+ File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index cbb79376d5f..5848e6c6994 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -1,20 +1,13 @@
class AvatarUploader < GitlabUploader
- include RecordsUploads
include UploaderHelper
-
- storage :file
-
- def store_dir
- "#{base_dir}/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
- end
+ include RecordsUploads::Concern
+ include ObjectStorage::Concern
+ prepend ObjectStorage::Extension::RecordsUploads
def exists?
model.avatar.file && model.avatar.file.present?
end
- # We set move_to_store and move_to_cache to 'false' to prevent stealing
- # the avatar file from a project when forking it.
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/26158
def move_to_store
false
end
@@ -22,4 +15,10 @@ class AvatarUploader < GitlabUploader
def move_to_cache
false
end
+
+ private
+
+ def dynamic_segment
+ File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
+ end
end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
index 00c2888d224..bd7736ad74e 100644
--- a/app/uploaders/file_mover.rb
+++ b/app/uploaders/file_mover.rb
@@ -10,7 +10,11 @@ class FileMover
def execute
move
- uploader.record_upload if update_markdown
+
+ if update_markdown
+ uploader.record_upload
+ uploader.schedule_background_upload
+ end
end
private
@@ -21,13 +25,11 @@ class FileMover
end
def update_markdown
- updated_text = model.read_attribute(update_field).gsub(temp_file_uploader.to_markdown, uploader.to_markdown)
+ updated_text = model.read_attribute(update_field)
+ .gsub(temp_file_uploader.markdown_link, uploader.markdown_link)
model.update_attribute(update_field, updated_text)
-
- true
rescue
revert
-
false
end
@@ -48,11 +50,11 @@ class FileMover
end
def uploader
- @uploader ||= PersonalFileUploader.new(model, secret)
+ @uploader ||= PersonalFileUploader.new(model, secret: secret)
end
def temp_file_uploader
- @temp_file_uploader ||= PersonalFileUploader.new(nil, secret)
+ @temp_file_uploader ||= PersonalFileUploader.new(nil, secret: secret)
end
def revert
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index 0b591e3bbbb..133fdf6684d 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -1,23 +1,47 @@
+# This class breaks the actual CarrierWave concept.
+# Every uploader should use a base_dir that is model agnostic so we can build
+# back URLs from base_dir-relative paths saved in the `Upload` model.
+#
+# As the `.base_dir` is model dependent and **not** saved in the upload model (see #upload_path)
+# there is no way to build back the correct file path without the model, which defies
+# CarrierWave way of storing files.
+#
class FileUploader < GitlabUploader
- include RecordsUploads
include UploaderHelper
+ include RecordsUploads::Concern
+ include ObjectStorage::Concern
+ prepend ObjectStorage::Extension::RecordsUploads
MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}
+ DYNAMIC_PATH_PATTERN = %r{(?<secret>\h{32})/(?<identifier>.*)}
- storage :file
+ after :remove, :prune_store_dir
- def self.absolute_path(upload_record)
+ # FileUploader do not run in a model transaction, so we can simply
+ # enqueue a job after the :store hook.
+ after :store, :schedule_background_upload
+
+ def self.root
+ File.join(options.storage_path, 'uploads')
+ end
+
+ def self.absolute_path(upload)
File.join(
- self.dynamic_path_segment(upload_record.model),
- upload_record.path
+ absolute_base_dir(upload.model),
+ upload.path # already contain the dynamic_segment, see #upload_path
)
end
- # Not using `GitlabUploader.base_dir` because all project namespaces are in
- # the `public/uploads` dir.
- #
- def self.base_dir
- root_dir
+ def self.base_dir(model, store = Store::LOCAL)
+ decorated_model = model
+ decorated_model = Storage::HashedProject.new(model) if store == Store::REMOTE
+
+ model_path_segment(decorated_model)
+ end
+
+ # used in migrations and import/exports
+ def self.absolute_base_dir(model)
+ File.join(root, base_dir(model))
end
# Returns the part of `store_dir` that can change based on the model's current
@@ -29,63 +53,125 @@ class FileUploader < GitlabUploader
# model - Object that responds to `full_path` and `disk_path`
#
# Returns a String without a trailing slash
- def self.dynamic_path_segment(model)
- if model.hashed_storage?(:attachments)
- dynamic_path_builder(model.disk_path)
+ def self.model_path_segment(model)
+ case model
+ when Storage::HashedProject then model.disk_path
else
- dynamic_path_builder(model.full_path)
+ model.hashed_storage?(:attachments) ? model.disk_path : model.full_path
end
end
- # Auxiliary method to build dynamic path segment when not using a project model
- #
- # Prefer to use the `.dynamic_path_segment` as it includes Hashed Storage specific logic
- def self.dynamic_path_builder(path)
- File.join(CarrierWave.root, base_dir, path)
+ def self.generate_secret
+ SecureRandom.hex
+ end
+
+ def upload_paths(filename)
+ [
+ File.join(secret, filename),
+ File.join(base_dir(Store::REMOTE), secret, filename)
+ ]
end
attr_accessor :model
- attr_reader :secret
- def initialize(model, secret = nil)
+ def initialize(model, mounted_as = nil, **uploader_context)
+ super(model, nil, **uploader_context)
+
@model = model
- @secret = secret || generate_secret
+ apply_context!(uploader_context)
end
- def store_dir
- File.join(dynamic_path_segment, @secret)
+ # enforce the usage of Hashed storage when storing to
+ # remote store as the FileMover doesn't support OS
+ def base_dir(store = nil)
+ self.class.base_dir(@model, store || object_store)
end
- def relative_path
- self.file.path.sub("#{dynamic_path_segment}/", '')
+ # we don't need to know the actual path, an uploader instance should be
+ # able to yield the file content on demand, so we should build the digest
+ def absolute_path
+ self.class.absolute_path(@upload)
end
- def to_markdown
- to_h[:markdown]
+ def upload_path
+ if file_storage?
+ # Legacy path relative to project.full_path
+ File.join(dynamic_segment, identifier)
+ else
+ File.join(store_dir, identifier)
+ end
end
- def to_h
- filename = image_or_video? ? self.file.basename : self.file.filename
- escaped_filename = filename.gsub("]", "\\]")
+ def store_dirs
+ {
+ Store::LOCAL => File.join(base_dir, dynamic_segment),
+ Store::REMOTE => File.join(base_dir(ObjectStorage::Store::REMOTE), dynamic_segment)
+ }
+ end
- markdown = "[#{escaped_filename}](#{secure_url})"
+ def markdown_link
+ markdown = "[#{markdown_name}](#{secure_url})"
markdown.prepend("!") if image_or_video? || dangerous?
+ markdown
+ end
+ def to_h
{
- alt: filename,
+ alt: markdown_name,
url: secure_url,
- markdown: markdown
+ markdown: markdown_link
}
end
+ def filename
+ self.file.filename
+ end
+
+ def upload=(value)
+ super
+
+ return unless value
+ return if apply_context!(value.uploader_context)
+
+ # fallback to the regex based extraction
+ if matches = DYNAMIC_PATH_PATTERN.match(value.path)
+ @secret = matches[:secret]
+ @identifier = matches[:identifier]
+ end
+ end
+
+ def secret
+ @secret ||= self.class.generate_secret
+ end
+
private
- def dynamic_path_segment
- self.class.dynamic_path_segment(model)
+ def apply_context!(uploader_context)
+ @secret, @identifier = uploader_context.values_at(:secret, :identifier)
+
+ !!(@secret && @identifier)
end
- def generate_secret
- SecureRandom.hex
+ def build_upload
+ super.tap do |upload|
+ upload.secret = secret
+ end
+ end
+
+ def prune_store_dir
+ storage.delete_dir!(store_dir) # only remove when empty
+ end
+
+ def markdown_name
+ (image_or_video? ? File.basename(filename, File.extname(filename)) : filename).gsub("]", "\\]")
+ end
+
+ def identifier
+ @identifier ||= filename
+ end
+
+ def dynamic_segment
+ secret
end
def secure_url
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index 7f72b3ce471..f12f0466a1d 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -1,72 +1,83 @@
class GitlabUploader < CarrierWave::Uploader::Base
- def self.absolute_path(upload_record)
- File.join(CarrierWave.root, upload_record.path)
- end
+ class_attribute :options
- def self.root_dir
- 'uploads'
- end
+ class << self
+ # DSL setter
+ def storage_options(options)
+ self.options = options
+ end
- # 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?
+ def root
+ options.storage_path
+ end
- File.join(root_dir, '-', 'system')
- end
+ # represent the directory namespacing at the class level
+ def base_dir
+ options.fetch('base_dir', '')
+ end
- def self.file_storage?
- self.storage == CarrierWave::Storage::File
+ def file_storage?
+ storage == CarrierWave::Storage::File
+ end
+
+ def absolute_path(upload_record)
+ File.join(root, upload_record.path)
+ end
end
+ storage_options Gitlab.config.uploads
+
delegate :base_dir, :file_storage?, to: :class
+ def initialize(model, mounted_as = nil, **uploader_context)
+ super(model, mounted_as)
+ end
+
def file_cache_storage?
cache_storage.is_a?(CarrierWave::Storage::File)
end
- # Reduce disk IO
def move_to_cache
- true
+ file_storage?
end
- # Reduce disk IO
def move_to_store
- true
- end
-
- # Designed to be overridden by child uploaders that have a dynamic path
- # segment -- that is, a path that changes based on mutable attributes of its
- # associated model
- #
- # For example, `FileUploader` builds the storage path based on the associated
- # project model's `path_with_namespace` value, which can change when the
- # project or its containing namespace is moved or renamed.
- def relative_path
- self.file.path.sub("#{root}/", '')
+ file_storage?
end
def exists?
file.present?
end
- # Override this if you don't want to save files by default to the Rails.root directory
+ def cache_dir
+ File.join(root, base_dir, 'tmp/cache')
+ end
+
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
+ File.join(root, base_dir, 'tmp/work')
end
def filename
super || file&.filename
end
+ def model_valid?
+ !!model
+ end
+
private
+ # Designed to be overridden by child uploaders that have a dynamic path
+ # segment -- that is, a path that changes based on mutable attributes of its
+ # associated model
+ #
+ # For example, `FileUploader` builds the storage path based on the associated
+ # project model's `path_with_namespace` value, which can change when the
+ # project or its containing namespace is moved or renamed.
+ def dynamic_segment
+ raise(NotImplementedError)
+ end
+
# 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
@@ -74,6 +85,6 @@ class GitlabUploader < CarrierWave::Uploader::Base
# 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)
+ File.join(work_dir, cache_id, version_name.to_s, for_file)
end
end
diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb
index 15dfb5a5763..dd86753479d 100644
--- a/app/uploaders/job_artifact_uploader.rb
+++ b/app/uploaders/job_artifact_uploader.rb
@@ -1,13 +1,10 @@
class JobArtifactUploader < GitlabUploader
- storage :file
+ extend Workhorse::UploadPath
+ include ObjectStorage::Concern
- def self.local_store_path
- Gitlab.config.artifacts.path
- end
+ ObjectNotReadyError = Class.new(StandardError)
- def self.artifacts_upload_path
- File.join(self.local_store_path, 'tmp/uploads/')
- end
+ storage_options Gitlab.config.artifacts
def size
return super if model.size.nil?
@@ -16,24 +13,22 @@ class JobArtifactUploader < GitlabUploader
end
def store_dir
- default_local_path
+ dynamic_segment
end
- def cache_dir
- File.join(self.class.local_store_path, 'tmp/cache')
- end
-
- def work_dir
- File.join(self.class.local_store_path, 'tmp/work')
+ def open
+ if file_storage?
+ File.open(path, "rb") if path
+ else
+ ::Gitlab::Ci::Trace::HttpIO.new(url, size) if url
+ end
end
private
- def default_local_path
- File.join(self.class.local_store_path, default_path)
- end
+ def dynamic_segment
+ raise ObjectNotReadyError, 'JobArtifact is not ready' unless model.id
- def default_path
creation_date = model.created_at.utc.strftime('%Y_%m_%d')
File.join(disk_hash[0..1], disk_hash[2..3], disk_hash,
diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb
index 4f7f8a63108..efb7893d153 100644
--- a/app/uploaders/legacy_artifact_uploader.rb
+++ b/app/uploaders/legacy_artifact_uploader.rb
@@ -1,33 +1,20 @@
class LegacyArtifactUploader < GitlabUploader
- storage :file
+ extend Workhorse::UploadPath
+ include ObjectStorage::Concern
- def self.local_store_path
- Gitlab.config.artifacts.path
- end
+ ObjectNotReadyError = Class.new(StandardError)
- def self.artifacts_upload_path
- File.join(self.local_store_path, 'tmp/uploads/')
- end
+ storage_options Gitlab.config.artifacts
def store_dir
- default_local_path
- end
-
- def cache_dir
- File.join(self.class.local_store_path, 'tmp/cache')
- end
-
- def work_dir
- File.join(self.class.local_store_path, 'tmp/work')
+ dynamic_segment
end
private
- def default_local_path
- File.join(self.class.local_store_path, default_path)
- end
+ def dynamic_segment
+ raise ObjectNotReadyError, 'Build is not ready' unless model.id
- def default_path
File.join(model.created_at.utc.strftime('%Y_%m'), model.project_id.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index d11ebf0f9ca..eb521a22ebc 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -1,19 +1,20 @@
class LfsObjectUploader < GitlabUploader
- storage :file
+ extend Workhorse::UploadPath
+ include ObjectStorage::Concern
- def store_dir
- "#{Gitlab.config.lfs.storage_path}/#{model.oid[0, 2]}/#{model.oid[2, 2]}"
- end
-
- def cache_dir
- "#{Gitlab.config.lfs.storage_path}/tmp/cache"
- end
+ storage_options Gitlab.config.lfs
def filename
model.oid[4..-1]
end
- def work_dir
- File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work')
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.oid[0, 2], model.oid[2, 2])
end
end
diff --git a/app/uploaders/namespace_file_uploader.rb b/app/uploaders/namespace_file_uploader.rb
index 672126e9ec2..1085ecb1700 100644
--- a/app/uploaders/namespace_file_uploader.rb
+++ b/app/uploaders/namespace_file_uploader.rb
@@ -1,15 +1,26 @@
class NamespaceFileUploader < FileUploader
- def self.base_dir
- File.join(root_dir, '-', 'system', 'namespace')
+ # Re-Override
+ def self.root
+ options.storage_path
end
- def self.dynamic_path_segment(model)
- dynamic_path_builder(model.id.to_s)
+ def self.base_dir(model, _store = nil)
+ File.join(options.base_dir, 'namespace', model_path_segment(model))
end
- private
+ def self.model_path_segment(model)
+ File.join(model.id.to_s)
+ end
+
+ # Re-Override
+ def store_dir
+ store_dirs[object_store]
+ end
- def secure_url
- File.join('/uploads', @secret, file.filename)
+ def store_dirs
+ {
+ Store::LOCAL => File.join(base_dir, dynamic_segment),
+ Store::REMOTE => File.join('namespace', self.class.model_path_segment(model), dynamic_segment)
+ }
end
end
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
new file mode 100644
index 00000000000..bd258e04d3f
--- /dev/null
+++ b/app/uploaders/object_storage.rb
@@ -0,0 +1,432 @@
+require 'fog/aws'
+require 'carrierwave/storage/fog'
+
+#
+# This concern should add object storage support
+# to the GitlabUploader class
+#
+module ObjectStorage
+ RemoteStoreError = Class.new(StandardError)
+ UnknownStoreError = Class.new(StandardError)
+ ObjectStorageUnavailable = Class.new(StandardError)
+
+ DIRECT_UPLOAD_TIMEOUT = 4.hours
+ TMP_UPLOAD_PATH = 'tmp/upload'.freeze
+
+ module Store
+ LOCAL = 1
+ REMOTE = 2
+ end
+
+ module Extension
+ # this extension is the glue between the ObjectStorage::Concern and RecordsUploads::Concern
+ module RecordsUploads
+ extend ActiveSupport::Concern
+
+ def prepended(base)
+ raise "#{base} must include ObjectStorage::Concern to use extensions." unless base < Concern
+
+ base.include(RecordsUploads::Concern)
+ end
+
+ def retrieve_from_store!(identifier)
+ paths = store_dirs.map { |store, path| File.join(path, identifier) }
+
+ unless current_upload_satisfies?(paths, model)
+ # the upload we already have isn't right, find the correct one
+ self.upload = uploads.find_by(model: model, path: paths)
+ end
+
+ super
+ end
+
+ def build_upload
+ super.tap do |upload|
+ upload.store = object_store
+ end
+ end
+
+ def upload=(upload)
+ return unless upload
+
+ self.object_store = upload.store
+ super
+ end
+
+ def schedule_background_upload(*args)
+ return unless schedule_background_upload?
+ return unless upload
+
+ ObjectStorage::BackgroundMoveWorker.perform_async(self.class.name,
+ upload.class.to_s,
+ mounted_as,
+ upload.id)
+ end
+
+ private
+
+ def current_upload_satisfies?(paths, model)
+ return false unless upload
+ return false unless model
+
+ paths.include?(upload.path) &&
+ upload.model_id == model.id &&
+ upload.model_type == model.class.base_class.sti_name
+ end
+ end
+ end
+
+ # Add support for automatic background uploading after the file is stored.
+ #
+ module BackgroundMove
+ extend ActiveSupport::Concern
+
+ def background_upload(mount_points = [])
+ return unless mount_points.any?
+
+ run_after_commit do
+ mount_points.each { |mount| send(mount).schedule_background_upload } # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ def changed_mounts
+ self.class.uploaders.select do |mount, uploader_class|
+ mounted_as = uploader_class.serialization_column(self.class, mount)
+ uploader = send(:"#{mounted_as}") # rubocop:disable GitlabSecurity/PublicSend
+
+ next unless uploader
+ next unless uploader.exists?
+ next unless send(:"#{mounted_as}_changed?") # rubocop:disable GitlabSecurity/PublicSend
+
+ mount
+ end.keys
+ end
+
+ included do
+ after_save on: [:create, :update] do
+ background_upload(changed_mounts)
+ end
+ end
+ end
+
+ module Concern
+ extend ActiveSupport::Concern
+
+ included do |base|
+ base.include(ObjectStorage)
+
+ after :migrate, :delete_migrated_file
+ end
+
+ class_methods do
+ def object_store_options
+ options.object_store
+ end
+
+ def object_store_enabled?
+ object_store_options.enabled
+ end
+
+ def direct_upload_enabled?
+ object_store_options&.direct_upload
+ end
+
+ def background_upload_enabled?
+ object_store_options.background_upload
+ end
+
+ def proxy_download_enabled?
+ object_store_options.proxy_download
+ end
+
+ def direct_download_enabled?
+ !proxy_download_enabled?
+ end
+
+ def object_store_credentials
+ object_store_options.connection.to_hash.deep_symbolize_keys
+ end
+
+ def remote_store_path
+ object_store_options.remote_directory
+ end
+
+ def serialization_column(model_class, mount_point)
+ model_class.uploader_options.dig(mount_point, :mount_on) || mount_point
+ end
+
+ def workhorse_authorize
+ {
+ RemoteObject: workhorse_remote_upload_options,
+ TempPath: workhorse_local_upload_path
+ }.compact
+ end
+
+ def workhorse_local_upload_path
+ File.join(self.root, TMP_UPLOAD_PATH)
+ end
+
+ def workhorse_remote_upload_options
+ return unless self.object_store_enabled?
+ return unless self.direct_upload_enabled?
+
+ id = [CarrierWave.generate_cache_id, SecureRandom.hex].join('-')
+ upload_path = File.join(TMP_UPLOAD_PATH, id)
+ connection = ::Fog::Storage.new(self.object_store_credentials)
+ expire_at = Time.now + DIRECT_UPLOAD_TIMEOUT
+ options = { 'Content-Type' => 'application/octet-stream' }
+
+ {
+ ID: id,
+ GetURL: connection.get_object_url(remote_store_path, upload_path, expire_at),
+ DeleteURL: connection.delete_object_url(remote_store_path, upload_path, expire_at),
+ StoreURL: connection.put_object_url(remote_store_path, upload_path, expire_at, options)
+ }
+ end
+
+ def default_object_store
+ if self.object_store_enabled? && self.direct_upload_enabled?
+ Store::REMOTE
+ else
+ Store::LOCAL
+ end
+ end
+ end
+
+ # allow to configure and overwrite the filename
+ def filename
+ @filename || super || file&.filename # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def filename=(filename)
+ @filename = filename # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def file_storage?
+ storage.is_a?(CarrierWave::Storage::File)
+ end
+
+ def file_cache_storage?
+ cache_storage.is_a?(CarrierWave::Storage::File)
+ end
+
+ def object_store
+ @object_store ||= model.try(store_serialization_column) || self.class.default_object_store
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def object_store=(value)
+ @object_store = value || self.class.default_object_store
+ @storage = storage_for(object_store)
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ # Return true if the current file is part or the model (i.e. is mounted in the model)
+ #
+ def persist_object_store?
+ model.respond_to?(:"#{store_serialization_column}=")
+ end
+
+ # Save the current @object_store to the model <mounted_as>_store column
+ def persist_object_store!
+ return unless persist_object_store?
+
+ updated = model.update_column(store_serialization_column, object_store)
+ raise 'Failed to update object store' unless updated
+ end
+
+ def use_file(&blk)
+ with_exclusive_lease do
+ unsafe_use_file(&blk)
+ end
+ end
+
+ #
+ # Move the file to another store
+ #
+ # new_store: Enum (Store::LOCAL, Store::REMOTE)
+ #
+ def migrate!(new_store)
+ with_exclusive_lease do
+ unsafe_migrate!(new_store)
+ end
+ end
+
+ def schedule_background_upload(*args)
+ return unless schedule_background_upload?
+
+ ObjectStorage::BackgroundMoveWorker.perform_async(self.class.name,
+ model.class.name,
+ mounted_as,
+ model.id)
+ end
+
+ def fog_directory
+ self.class.remote_store_path
+ end
+
+ def fog_credentials
+ self.class.object_store_credentials
+ end
+
+ def fog_public
+ false
+ end
+
+ def delete_migrated_file(migrated_file)
+ migrated_file.delete if exists?
+ end
+
+ def exists?
+ file.present?
+ end
+
+ def store_dir(store = nil)
+ store_dirs[store || object_store]
+ end
+
+ def store_dirs
+ {
+ Store::LOCAL => File.join(base_dir, dynamic_segment),
+ Store::REMOTE => File.join(dynamic_segment)
+ }
+ end
+
+ def cache!(new_file = sanitized_file)
+ # We intercept ::UploadedFile which might be stored on remote storage
+ # We use that for "accelerated" uploads, where we store result on remote storage
+ if new_file.is_a?(::UploadedFile) && new_file.remote_id
+ return cache_remote_file!(new_file.remote_id, new_file.original_filename)
+ end
+
+ super
+ end
+
+ private
+
+ def schedule_background_upload?
+ self.class.object_store_enabled? &&
+ self.class.background_upload_enabled? &&
+ self.file_storage?
+ end
+
+ def cache_remote_file!(remote_object_id, original_filename)
+ file_path = File.join(TMP_UPLOAD_PATH, remote_object_id)
+ file_path = Pathname.new(file_path).cleanpath.to_s
+ raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(TMP_UPLOAD_PATH + '/')
+
+ # TODO:
+ # This should be changed to make use of `tmp/cache` mechanism
+ # instead of using custom upload directory,
+ # using tmp/cache makes this implementation way easier than it is today
+ CarrierWave::Storage::Fog::File.new(self, storage_for(Store::REMOTE), file_path).tap do |file|
+ raise RemoteStoreError, 'Missing file' unless file.exists?
+
+ # Remote stored file, we force to store on remote storage
+ self.object_store = Store::REMOTE
+
+ # TODO:
+ # We store file internally and force it to be considered as `cached`
+ # This makes CarrierWave to store file in permament location (copy/delete)
+ # once this object is saved, but not sooner
+ @cache_id = "force-to-use-cache" # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @file = file # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @filename = original_filename # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+ end
+
+ # this is a hack around CarrierWave. The #migrate method needs to be
+ # able to force the current file to the migrated file upon success.
+ def file=(file)
+ @file = file # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def serialization_column
+ self.class.serialization_column(model.class, mounted_as)
+ end
+
+ # Returns the column where the 'store' is saved
+ # defaults to 'store'
+ def store_serialization_column
+ [serialization_column, 'store'].compact.join('_').to_sym
+ end
+
+ def storage
+ @storage ||= storage_for(object_store)
+ end
+
+ def storage_for(store)
+ case store
+ when Store::REMOTE
+ raise 'Object Storage is not enabled' unless self.class.object_store_enabled?
+
+ CarrierWave::Storage::Fog.new(self)
+ when Store::LOCAL
+ CarrierWave::Storage::File.new(self)
+ else
+ raise UnknownStoreError
+ end
+ end
+
+ def exclusive_lease_key
+ "object_storage_migrate:#{model.class}:#{model.id}"
+ end
+
+ def with_exclusive_lease
+ uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain
+ raise 'exclusive lease already taken' unless uuid
+
+ yield uuid
+ ensure
+ Gitlab::ExclusiveLease.cancel(exclusive_lease_key, uuid)
+ end
+
+ #
+ # Move the file to another store
+ #
+ # new_store: Enum (Store::LOCAL, Store::REMOTE)
+ #
+ def unsafe_migrate!(new_store)
+ return unless object_store != new_store
+ return unless file
+
+ new_file = nil
+ file_to_delete = file
+ from_object_store = object_store
+ self.object_store = new_store # changes the storage and file
+
+ cache_stored_file! if file_storage?
+
+ with_callbacks(:migrate, file_to_delete) do
+ with_callbacks(:store, file_to_delete) do # for #store_versions!
+ new_file = storage.store!(file)
+ persist_object_store!
+ self.file = new_file
+ end
+ end
+
+ file
+ rescue => e
+ # in case of failure delete new file
+ new_file.delete unless new_file.nil?
+ # revert back to the old file
+ self.object_store = from_object_store
+ self.file = file_to_delete
+ raise e
+ end
+ end
+
+ def unsafe_use_file
+ if file_storage?
+ return yield path
+ end
+
+ begin
+ cache_stored_file!
+ yield cache_path
+ ensure
+ FileUtils.rm_f(cache_path)
+ cache_storage.delete_dir!(cache_path(nil))
+ end
+ end
+end
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
index 3298ad104ec..e3898b07730 100644
--- a/app/uploaders/personal_file_uploader.rb
+++ b/app/uploaders/personal_file_uploader.rb
@@ -1,23 +1,46 @@
class PersonalFileUploader < FileUploader
- def self.dynamic_path_segment(model)
- File.join(CarrierWave.root, model_path(model))
+ # Re-Override
+ def self.root
+ options.storage_path
end
- def self.base_dir
- File.join(root_dir, '-', 'system')
+ def self.base_dir(model, _store = nil)
+ File.join(options.base_dir, model_path_segment(model))
end
- private
+ def self.model_path_segment(model)
+ return 'temp/' unless model
- def secure_url
- File.join(self.class.model_path(model), secret, file.filename)
+ File.join(model.class.to_s.underscore, model.id.to_s)
+ end
+
+ def object_store
+ return Store::LOCAL unless model
+
+ super
+ end
+
+ # model_path_segment does not require a model to be passed, so we can always
+ # generate a path, even when there's no model.
+ def model_valid?
+ true
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
+ # Revert-Override
+ def store_dir
+ store_dirs[object_store]
+ end
+
+ def store_dirs
+ {
+ Store::LOCAL => File.join(base_dir, dynamic_segment),
+ Store::REMOTE => File.join(self.class.model_path_segment(model), dynamic_segment)
+ }
+ end
+
+ private
+
+ def secure_url
+ File.join('/', base_dir, secret, file.filename)
end
end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index feb4f04d7b7..89c74a78835 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -1,35 +1,61 @@
module RecordsUploads
- extend ActiveSupport::Concern
+ module Concern
+ extend ActiveSupport::Concern
- included do
- after :store, :record_upload
- before :remove, :destroy_upload
- end
+ attr_accessor :upload
- # After storing an attachment, create a corresponding Upload record
- #
- # NOTE: We're ignoring the argument passed to this callback because we want
- # the `SanitizedFile` object from `CarrierWave::Uploader::Base#file`, not the
- # `Tempfile` object the callback gets.
- #
- # Called `after :store`
- def record_upload(_tempfile = nil)
- return unless model
- return unless file_storage?
- return unless file.exists?
-
- Upload.record(self)
- end
+ included do
+ after :store, :record_upload
+ before :remove, :destroy_upload
+ end
+
+ # After storing an attachment, create a corresponding Upload record
+ #
+ # NOTE: We're ignoring the argument passed to this callback because we want
+ # the `SanitizedFile` object from `CarrierWave::Uploader::Base#file`, not the
+ # `Tempfile` object the callback gets.
+ #
+ # Called `after :store`
+ def record_upload(_tempfile = nil)
+ return unless model
+ return unless file && file.exists?
+
+ Upload.transaction do
+ uploads.where(path: upload_path).delete_all
+ upload.destroy! if upload
+
+ self.upload = build_upload.tap(&:save!)
+ end
+ end
+
+ def upload_path
+ File.join(store_dir, filename.to_s)
+ end
+
+ private
+
+ def uploads
+ Upload.order(id: :desc).where(uploader: self.class.to_s)
+ end
- private
+ def build_upload
+ Upload.new(
+ uploader: self.class.to_s,
+ size: file.size,
+ path: upload_path,
+ model: model,
+ mount_point: mounted_as
+ )
+ end
- # Before removing an attachment, destroy any Upload records at the same path
- #
- # Called `before :remove`
- def destroy_upload(*args)
- return unless file_storage?
- return unless file
+ # Before removing an attachment, destroy any Upload records at the same path
+ #
+ # Called `before :remove`
+ def destroy_upload(*args)
+ return unless file && file.exists?
- Upload.remove_path(relative_path)
+ self.upload = nil
+ uploads.where(path: upload_path).delete_all
+ end
end
end
diff --git a/app/uploaders/uploader_helper.rb b/app/uploaders/uploader_helper.rb
index 7635c20ab3a..fd446d31092 100644
--- a/app/uploaders/uploader_helper.rb
+++ b/app/uploaders/uploader_helper.rb
@@ -32,14 +32,7 @@ module UploaderHelper
def extension_match?(extensions)
return false unless file
- extension =
- if file.respond_to?(:extension)
- file.extension
- else
- # Not all CarrierWave storages respond to :extension
- File.extname(file.path).delete('.')
- end
-
+ extension = file.try(:extension) || File.extname(file.path).delete('.')
extensions.include?(extension.downcase)
end
end
diff --git a/app/uploaders/workhorse.rb b/app/uploaders/workhorse.rb
new file mode 100644
index 00000000000..782032cf516
--- /dev/null
+++ b/app/uploaders/workhorse.rb
@@ -0,0 +1,7 @@
+module Workhorse
+ module UploadPath
+ def workhorse_upload_path
+ File.join(root, base_dir, 'tmp/uploads')
+ end
+ end
+end
diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb
index adbccb65a84..e43b66cbe3a 100644
--- a/app/validators/abstract_path_validator.rb
+++ b/app/validators/abstract_path_validator.rb
@@ -13,10 +13,6 @@ class AbstractPathValidator < ActiveModel::EachValidator
raise NotImplementedError
end
- def self.full_path(record, value)
- value
- end
-
def self.valid_path?(path)
encode!(path)
"#{path}/" =~ path_regex
@@ -28,7 +24,7 @@ class AbstractPathValidator < ActiveModel::EachValidator
return
end
- full_path = self.class.full_path(record, value)
+ full_path = record.build_full_path
return unless full_path
unless self.class.valid_path?(full_path)
diff --git a/app/validators/certificate_fingerprint_validator.rb b/app/validators/certificate_fingerprint_validator.rb
new file mode 100644
index 00000000000..17df756183a
--- /dev/null
+++ b/app/validators/certificate_fingerprint_validator.rb
@@ -0,0 +1,9 @@
+class CertificateFingerprintValidator < ActiveModel::EachValidator
+ FINGERPRINT_PATTERN = /\A([a-zA-Z0-9]{2}[\s\-:]?){16,}\z/.freeze
+
+ def validate_each(record, attribute, value)
+ unless value.try(:match, FINGERPRINT_PATTERN)
+ record.errors.add(attribute, "must be a hash containing only letters, numbers, spaces, : and -")
+ end
+ end
+end
diff --git a/app/validators/certificate_validator.rb b/app/validators/certificate_validator.rb
index 5239e70a326..b0c9a1b92a4 100644
--- a/app/validators/certificate_validator.rb
+++ b/app/validators/certificate_validator.rb
@@ -16,8 +16,6 @@ class CertificateValidator < ActiveModel::EachValidator
private
def valid_certificate_pem?(value)
- return false unless value
-
OpenSSL::X509::Certificate.new(value).present?
rescue OpenSSL::X509::CertificateError
false
diff --git a/app/validators/importable_url_validator.rb b/app/validators/importable_url_validator.rb
index 37a314adee6..612d3c71913 100644
--- a/app/validators/importable_url_validator.rb
+++ b/app/validators/importable_url_validator.rb
@@ -4,8 +4,8 @@
# protect against Server-side Request Forgery (SSRF).
class ImportableUrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
- if Gitlab::UrlBlocker.blocked_url?(value)
- record.errors.add(attribute, "imports are not allowed from that URL")
- end
+ Gitlab::UrlBlocker.validate!(value, valid_ports: Project::VALID_IMPORT_PORTS)
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ record.errors.add(attribute, "is blocked: #{e.message}")
end
end
diff --git a/app/validators/namespace_path_validator.rb b/app/validators/namespace_path_validator.rb
index 4a0aa64ae0c..7b0ae4db5d4 100644
--- a/app/validators/namespace_path_validator.rb
+++ b/app/validators/namespace_path_validator.rb
@@ -12,8 +12,4 @@ class NamespacePathValidator < AbstractPathValidator
def self.format_error_message
Gitlab::PathRegex.namespace_format_message
end
-
- def self.full_path(record, value)
- record.build_full_path
- end
end
diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb
index 829b596ad3c..424fd77a6a3 100644
--- a/app/validators/project_path_validator.rb
+++ b/app/validators/project_path_validator.rb
@@ -12,8 +12,4 @@ class ProjectPathValidator < AbstractPathValidator
def self.format_error_message
Gitlab::PathRegex.project_path_format_message
end
-
- def self.full_path(record, value)
- record.build_full_path
- end
end
diff --git a/app/validators/top_level_group_validator.rb b/app/validators/top_level_group_validator.rb
new file mode 100644
index 00000000000..7e2e735e0cf
--- /dev/null
+++ b/app/validators/top_level_group_validator.rb
@@ -0,0 +1,7 @@
+class TopLevelGroupValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ if value&.subgroup?
+ record.errors.add(attribute, "must be a top level Group")
+ end
+ end
+end
diff --git a/app/validators/url_placeholder_validator.rb b/app/validators/url_placeholder_validator.rb
new file mode 100644
index 00000000000..dd681218b6b
--- /dev/null
+++ b/app/validators/url_placeholder_validator.rb
@@ -0,0 +1,32 @@
+# UrlValidator
+#
+# Custom validator for URLs.
+#
+# By default, only URLs for the HTTP(S) protocols will be considered valid.
+# Provide a `:protocols` option to configure accepted protocols.
+#
+# Also, this validator can help you validate urls with placeholders inside.
+# Usually, if you have a url like 'http://www.example.com/%{project_path}' the
+# URI parser will reject that URL format. Provide a `:placeholder_regex` option
+# to configure accepted placeholders.
+#
+# Example:
+#
+# class User < ActiveRecord::Base
+# validates :personal_url, url: true
+#
+# validates :ftp_url, url: { protocols: %w(ftp) }
+#
+# validates :git_url, url: { protocols: %w(http https ssh git) }
+#
+# validates :placeholder_url, url: { placeholder_regex: /(project_path|project_id|default_branch)/ }
+# end
+#
+class UrlPlaceholderValidator < UrlValidator
+ def validate_each(record, attribute, value)
+ placeholder_regex = self.options[:placeholder_regex]
+ value = value.gsub(/%{#{placeholder_regex}}/, 'foo') if placeholder_regex && value
+
+ super(record, attribute, value)
+ end
+end
diff --git a/app/validators/user_path_validator.rb b/app/validators/user_path_validator.rb
deleted file mode 100644
index adf02901802..00000000000
--- a/app/validators/user_path_validator.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-class UserPathValidator < AbstractPathValidator
- extend Gitlab::EncodingHelper
-
- def self.path_regex
- Gitlab::PathRegex.root_namespace_path_regex
- end
-
- def self.format_regex
- Gitlab::PathRegex.namespace_format_regex
- end
-
- def self.format_error_message
- Gitlab::PathRegex.namespace_format_message
- end
-end
diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/variable_duplicates_validator.rb
index 8a9d8892e9b..72660be6c43 100644
--- a/app/validators/variable_duplicates_validator.rb
+++ b/app/validators/variable_duplicates_validator.rb
@@ -1,13 +1,30 @@
# VariableDuplicatesValidator
#
-# This validtor is designed for especially the following condition
+# This validator 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)
+ return if record.errors.include?(:"#{attribute}.key")
+
+ if options[:scope]
+ scoped = value.group_by do |variable|
+ Array(options[:scope]).map { |attr| variable.send(attr) } # rubocop:disable GitlabSecurity/PublicSend
+ end
+ scoped.each_value { |scope| validate_duplicates(record, attribute, scope) }
+ else
+ validate_duplicates(record, attribute, value)
+ end
+ end
+
+ private
+
+ def validate_duplicates(record, attribute, values)
+ duplicates = values.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first)
if duplicates.any?
- record.errors.add(attribute, "Duplicate variables: #{duplicates.join(", ")}")
+ error_message = "have duplicate values (#{duplicates.join(", ")})"
+ error_message += " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend
+ record.errors.add(attribute, error_message)
end
end
end
diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml
new file mode 100644
index 00000000000..bb3fa26a33e
--- /dev/null
+++ b/app/views/admin/application_settings/_abuse.html.haml
@@ -0,0 +1,12 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :admin_notification_email, 'Abuse reports notification email', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :admin_notification_email, class: 'form-control'
+ .help-block
+ Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
new file mode 100644
index 00000000000..dd86c9ed2eb
--- /dev/null
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -0,0 +1,39 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :gravatar_enabled do
+ = f.check_box :gravatar_enabled
+ Gravatar enabled
+ .form-group
+ = f.label :default_projects_limit, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :default_projects_limit, class: 'form-control'
+ .form-group
+ = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :max_attachment_size, class: 'form-control'
+ .form-group
+ = f.label :session_expire_delay, 'Session duration (minutes)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :session_expire_delay, class: 'form-control'
+ %span.help-block#session_expire_delay_help_block GitLab restart is required to apply changes
+ .form-group
+ = f.label :user_oauth_applications, 'User OAuth applications', class: 'control-label col-sm-2'
+ .col-sm-10
+ .checkbox
+ = f.label :user_oauth_applications do
+ = f.check_box :user_oauth_applications
+ Allow users to register any application to use GitLab as an OAuth provider
+ .form-group
+ = f.label :user_default_external, 'New users set to external', class: 'control-label col-sm-2'
+ .col-sm-10
+ .checkbox
+ = f.label :user_default_external do
+ = f.check_box :user_default_external
+ Newly registered users will by default be external
+
+ = f.submit 'Save changes', class: 'btn btn-success'
diff --git a/app/views/admin/application_settings/_background_jobs.html.haml b/app/views/admin/application_settings/_background_jobs.html.haml
new file mode 100644
index 00000000000..8198a822a10
--- /dev/null
+++ b/app/views/admin/application_settings/_background_jobs.html.haml
@@ -0,0 +1,30 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ %p
+ These settings require a
+ = link_to 'restart', help_page_path('administration/restart_gitlab')
+ to take effect.
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :sidekiq_throttling_enabled do
+ = f.check_box :sidekiq_throttling_enabled
+ Enable Sidekiq Job Throttling
+ .help-block
+ Limit the amount of resources slow running jobs are assigned.
+ .form-group
+ = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' }
+ .help-block
+ Choose which queues you wish to throttle.
+ .form-group
+ = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01'
+ .help-block
+ The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
new file mode 100644
index 00000000000..b4d2a789df0
--- /dev/null
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -0,0 +1,47 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :auto_devops_enabled do
+ = f.check_box :auto_devops_enabled
+ Enabled Auto DevOps (Beta) for projects by default
+ .help-block
+ It will automatically build, test, and deploy applications based on a predefined CI/CD configuration
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md')
+ .form-group
+ = f.label :auto_devops_domain, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com'
+ .help-block
+ = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.")
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :shared_runners_enabled do
+ = f.check_box :shared_runners_enabled
+ Enable shared runners for new projects
+ .form-group
+ = f.label :shared_runners_text, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_area :shared_runners_text, class: 'form-control', rows: 4
+ .help-block Markdown enabled
+ .form-group
+ = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :max_artifacts_size, class: 'form-control'
+ .help-block
+ Set the maximum file size for each job's artifacts
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
+ .form-group
+ = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :default_artifacts_expire_in, class: 'form-control'
+ .help-block
+ Set the default expiration time for each job's artifacts.
+ 0 for unlimited.
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
new file mode 100644
index 00000000000..6c89f1c4e98
--- /dev/null
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -0,0 +1,26 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :email_author_in_body do
+ = f.check_box :email_author_in_body
+ Include author name in notification email body
+ .help-block
+ Some email servers do not support overriding the email sender name.
+ Enable this option to include the name of the author of the issue,
+ merge request or comment in the email body instead.
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :html_emails_enabled do
+ = f.check_box :html_emails_enabled
+ Enable HTML emails
+ .help-block
+ By default GitLab sends emails in HTML and plain text formats so mail
+ clients can choose what format to use. Disable this option if you only
+ want to send emails in plain text format.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
deleted file mode 100644
index 3e2dbb07a6c..00000000000
--- a/app/views/admin/application_settings/_form.html.haml
+++ /dev/null
@@ -1,829 +0,0 @@
-= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
- = form_errors(@application_setting)
-
- %fieldset
- %legend Visibility and Access Controls
- .form-group
- = f.label :default_branch_protection, class: 'control-label col-sm-2'
- .col-sm-10
- = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control'
- .form-group.visibility-level-setting
- = f.label :default_project_visibility, class: 'control-label col-sm-2'
- .col-sm-10
- = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new)
- .form-group.visibility-level-setting
- = f.label :default_snippet_visibility, class: 'control-label col-sm-2'
- .col-sm-10
- = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new)
- .form-group.visibility-level-setting
- = f.label :default_group_visibility, class: 'control-label col-sm-2'
- .col-sm-10
- = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new)
- .form-group
- = f.label :restricted_visibility_levels, class: 'control-label col-sm-2'
- .col-sm-10
- - 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
- Selected levels cannot be used by non-admin users for projects or snippets.
- If the public level is restricted, user profiles are only visible to logged in users.
- .form-group
- = f.label :import_sources, class: 'control-label col-sm-2'
- .col-sm-10
- - import_sources_checkboxes('import-sources-help').each do |source|
- .checkbox= source
- %span.help-block#import-sources-help
- Enabled sources for code import during project creation. OmniAuth must be configured for GitHub
- = link_to "(?)", help_page_path("integration/github")
- , Bitbucket
- = link_to "(?)", help_page_path("integration/bitbucket")
- and GitLab.com
- = link_to "(?)", help_page_path("integration/gitlab")
-
- .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
-
- .form-group
- %label.control-label.col-sm-2 Enabled Git access protocols
- .col-sm-10
- = 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.
-
- - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
- - field_name = :"#{type}_key_restriction"
- .form-group
- = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2'
- .col-sm-10
- = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
-
- %fieldset
- %legend Account and Limit Settings
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :gravatar_enabled do
- = f.check_box :gravatar_enabled
- Gravatar enabled
- .form-group
- = f.label :default_projects_limit, class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :default_projects_limit, class: 'form-control'
- .form-group
- = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :max_attachment_size, class: 'form-control'
- .form-group
- = f.label :session_expire_delay, 'Session duration (minutes)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :session_expire_delay, class: 'form-control'
- %span.help-block#session_expire_delay_help_block GitLab restart is required to apply changes
- .form-group
- = f.label :user_oauth_applications, 'User OAuth applications', class: 'control-label col-sm-2'
- .col-sm-10
- .checkbox
- = f.label :user_oauth_applications do
- = f.check_box :user_oauth_applications
- Allow users to register any application to use GitLab as an OAuth provider
- .form-group
- = f.label :user_default_external, 'New users set to external', class: 'control-label col-sm-2'
- .col-sm-10
- .checkbox
- = f.label :user_default_external do
- = f.check_box :user_default_external
- Newly registered users will by default be external
-
- %fieldset
- %legend Sign-up Restrictions
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :signup_enabled do
- = f.check_box :signup_enabled
- Sign-up enabled
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :send_user_confirmation_email do
- = f.check_box :send_user_confirmation_email
- Send confirmation email on sign-up
- .form-group
- = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
- .help-block ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com
- .form-group
- = f.label :domain_blacklist_enabled, 'Domain Blacklist', class: 'control-label col-sm-2'
- .col-sm-10
- .checkbox
- = f.label :domain_blacklist_enabled do
- = f.check_box :domain_blacklist_enabled
- Enable domain blacklist for sign ups
- .form-group
- .col-sm-offset-2.col-sm-10
- .radio
- = label_tag :blacklist_type_file do
- = radio_button_tag :blacklist_type, :file
- .option-title
- Upload blacklist file
- .radio
- = label_tag :blacklist_type_raw do
- = radio_button_tag :blacklist_type, :raw, @application_setting.domain_blacklist.present? || @application_setting.domain_blacklist.blank?
- .option-title
- Enter blacklist manually
- .form-group.blacklist-file
- = f.label :domain_blacklist_file, 'Blacklist file', class: 'control-label col-sm-2'
- .col-sm-10
- = f.file_field :domain_blacklist_file, class: 'form-control', accept: '.txt,.conf'
- .help-block Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries.
- .form-group.blacklist-raw
- = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
- .help-block 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
-
- .form-group
- = f.label :after_sign_up_text, class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_area :after_sign_up_text, class: 'form-control', rows: 4
- .help-block Markdown enabled
-
- %fieldset
- %legend Sign-in Restrictions
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :password_authentication_enabled_for_web do
- = f.check_box :password_authentication_enabled_for_web
- Password authentication enabled for web interface
- .help-block
- When disabled, an external authentication provider must be used.
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :password_authentication_enabled_for_git do
- = f.check_box :password_authentication_enabled_for_git
- Password authentication enabled for Git over HTTP(S)
- .help-block
- When disabled, a Personal Access Token
- - if Gitlab::LDAP::Config.enabled?
- or LDAP password
- must be used to authenticate.
- - 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'
- .col-sm-10
- .btn-group{ data: { toggle: 'buttons' } }
- - oauth_providers_checkboxes.each do |source|
- = source
- .form-group
- = f.label :two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
- .col-sm-10
- .checkbox
- = f.label :require_two_factor_authentication do
- = f.check_box :require_two_factor_authentication
- Require all users to setup Two-factor authentication
- .form-group
- = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0'
- .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
- .form-group
- = f.label :home_page_url, 'Home page URL', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block'
- %span.help-block#home_help_block We will redirect non-logged in users to this page
- .form-group
- = f.label :after_sign_out_path, class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block'
- %span.help-block#after_sign_out_path_help_block We will redirect users to this page after they sign out
- .form-group
- = f.label :sign_in_text, class: 'control-label col-sm-2'
- .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
- .form-group
- = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :max_pages_size, class: 'form-control'
- .help-block 0 for unlimited
-
- %fieldset
- %legend Continuous Integration and Deployment
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :auto_devops_enabled do
- = f.check_box :auto_devops_enabled
- Enabled Auto DevOps (Beta) for projects by default
- .help-block
- It will automatically build, test, and deploy applications based on a predefined CI/CD configuration
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md')
-
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :shared_runners_enabled do
- = f.check_box :shared_runners_enabled
- Enable shared runners for new projects
- .form-group
- = f.label :shared_runners_text, class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_area :shared_runners_text, class: 'form-control', rows: 4
- .help-block Markdown enabled
- .form-group
- = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :max_artifacts_size, class: 'form-control'
- .help-block
- Set the maximum file size for each job's artifacts
- = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
- .form-group
- = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :default_artifacts_expire_in, class: 'form-control'
- .help-block
- Set the default expiration time for each job's artifacts.
- 0 for unlimited.
- = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
-
- - if Gitlab.config.registry.enabled
- %fieldset
- %legend Container Registry
- .form-group
- = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :container_registry_token_expire_delay, class: 'form-control'
-
- %fieldset
- %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
- = link_to 'restart', help_page_path('administration/restart_gitlab')
- to take effect.
- = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction')
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :metrics_enabled do
- = f.check_box :metrics_enabled
- Enable InfluxDB Metrics
- .form-group
- = f.label :metrics_host, 'InfluxDB host', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com'
- .form-group
- = f.label :metrics_port, 'InfluxDB port', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :metrics_port, class: 'form-control', placeholder: '8089'
- .help-block
- The UDP port to use for connecting to InfluxDB. InfluxDB requires that
- your server configuration specifies a database to store data in when
- sending messages to this port, without it metrics data will not be
- saved.
- .form-group
- = f.label :metrics_pool_size, 'Connection pool size', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :metrics_pool_size, class: 'form-control'
- .help-block
- The amount of InfluxDB connections to open. Connections are opened
- lazily. Users using multi-threaded application servers should ensure
- enough connections are available (at minimum the amount of application
- server threads).
- .form-group
- = f.label :metrics_timeout, 'Connection timeout', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :metrics_timeout, class: 'form-control'
- .help-block
- The amount of seconds after which an InfluxDB connection will time
- out.
- .form-group
- = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :metrics_method_call_threshold, class: 'form-control'
- .help-block
- A method call is only tracked when it takes longer to complete than
- the given amount of milliseconds.
- .form-group
- = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :metrics_sample_interval, class: 'form-control'
- .help-block
- The sampling interval in seconds. Sampled data includes memory usage,
- retained Ruby objects, file descriptors and so on.
- .form-group
- = f.label :metrics_packet_size, 'Metrics per packet', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :metrics_packet_size, class: 'form-control'
- .help-block
- The amount of points to store in a single UDP packet. More points
- 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
- = link_to 'restart', help_page_path('administration/restart_gitlab')
- to take effect.
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :sidekiq_throttling_enabled do
- = f.check_box :sidekiq_throttling_enabled
- Enable Sidekiq Job Throttling
- .help-block
- Limit the amount of resources slow running jobs are assigned.
- .form-group
- = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'control-label col-sm-2'
- .col-sm-10
- = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' }
- .help-block
- Choose which queues you wish to throttle.
- .form-group
- = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01'
- .help-block
- The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive.
-
- %fieldset
- %legend Spam and Anti-bot Protection
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :recaptcha_enabled do
- = f.check_box :recaptcha_enabled
- Enable reCAPTCHA
- %span.help-block#recaptcha_help_block Helps prevent bots from creating accounts
-
- .form-group
- = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :recaptcha_site_key, class: 'form-control'
- .help-block
- Generate site and private keys at
- %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha
-
- .form-group
- = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :recaptcha_private_key, class: 'form-control'
-
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :akismet_enabled do
- = f.check_box :akismet_enabled
- Enable Akismet
- %span.help-block#akismet_help_block Helps prevent bots from creating issues
-
- .form-group
- = f.label :akismet_api_key, 'Akismet API Key', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :akismet_api_key, class: 'form-control'
- .help-block
- Generate API key at
- %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com
-
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :unique_ips_limit_enabled do
- = f.check_box :unique_ips_limit_enabled
- Limit sign in from multiple ips
- %span.help-block#unique_ip_help_block
- Helps prevent malicious users hide their activity
-
- .form-group
- = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :unique_ips_limit_per_user, class: 'form-control'
- .help-block
- Maximum number of unique IPs per user
-
- .form-group
- = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :unique_ips_limit_time_window, class: 'form-control'
- .help-block
- How many seconds an IP will be counted towards the limit
-
- %fieldset
- %legend Abuse reports
- .form-group
- = f.label :admin_notification_email, 'Abuse reports notification email', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :admin_notification_email, class: 'form-control'
- .help-block
- Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.
-
- %fieldset
- %legend Error Reporting and Logging
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :sentry_enabled do
- = 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
-
- .form-group
- = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2'
- .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
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :hashed_storage_enabled do
- = f.check_box :hashed_storage_enabled
- Create new projects using hashed storage paths
- .help-block
- Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents
- repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance.
- %em (EXPERIMENTAL)
- .form-group
- = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2'
- .col-sm-10
- = f.select :repository_storages, repository_storages_options_for_select, {include_hidden: false}, multiple: true, class: 'form-control'
- .help-block
- Manage repository storage paths. Learn more in the
- = succeed "." do
- = link_to "repository storages documentation", help_page_path("administration/repository_storages")
-
- %fieldset
- %legend Git Storage Circuitbreaker settings
- .form-group
- = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :circuitbreaker_check_interval, class: 'form-control'
- .help-block
- = circuitbreaker_check_interval_help_text
- .form-group
- = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :circuitbreaker_access_retries, class: 'form-control'
- .help-block
- = circuitbreaker_access_retries_help_text
- .form-group
- = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
- .help-block
- = circuitbreaker_storage_timeout_help_text
- .form-group
- = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control'
- .help-block
- = circuitbreaker_failure_count_help_text
- .form-group
- = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control'
- .help-block
- = circuitbreaker_failure_reset_time_help_text
-
- %fieldset
- %legend Repository Checks
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :repository_checks_enabled do
- = f.check_box :repository_checks_enabled
- Enable Repository Checks
- .help-block
- GitLab will periodically run
- %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck'
- in all project and wiki repositories to look for silent disk corruption issues.
- .form-group
- .col-sm-offset-2.col-sm-10
- = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove"
- .help-block
- If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
-
- - if koding_enabled?
- %fieldset
- %legend Koding
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :koding_enabled do
- = f.check_box :koding_enabled
- Enable Koding
- .help-block
- Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again.
- .form-group
- = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
- .help-block
- Koding has integration enabled out of the box for the
- %strong gitlab
- team, and you need to provide that team's URL here. Learn more in the
- = succeed "." do
- = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
-
- %fieldset
- %legend PlantUML
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :plantuml_enabled do
- = f.check_box :plantuml_enabled
- Enable PlantUML
- .form-group
- = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080'
- .help-block
- Allow rendering of
- = link_to "PlantUML", "http://plantuml.com"
- diagrams in Asciidoc documents using an external PlantUML service.
-
- %fieldset
- %legend#usage-statistics Usage statistics
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :version_check_enabled do
- = f.check_box :version_check_enabled
- Version check enabled
- .help-block
- 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, disabled: !can_be_configured
- Usage ping enabled
- = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping")
- .help-block
- - 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
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :email_author_in_body do
- = f.check_box :email_author_in_body
- Include author name in notification email body
- .help-block
- Some email servers do not support overriding the email sender name.
- Enable this option to include the name of the author of the issue,
- merge request or comment in the email body instead.
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :html_emails_enabled do
- = f.check_box :html_emails_enabled
- Enable HTML emails
- .help-block
- By default GitLab sends emails in HTML and plain text formats so mail
- clients can choose what format to use. Disable this option if you only
- want to send emails in plain text format.
- %fieldset
- %legend Automatic Git repository housekeeping
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :housekeeping_enabled do
- = f.check_box :housekeeping_enabled
- Enable automatic repository housekeeping (git repack, git gc)
- .help-block
- If you keep automatic housekeeping disabled for a long time Git
- repository access on your GitLab server will become slower and your
- repositories will use more disk space. We recommend to always leave
- this enabled.
- .checkbox
- = f.label :housekeeping_bitmaps_enabled do
- = f.check_box :housekeeping_bitmaps_enabled
- Enable Git pack file bitmap creation
- .help-block
- Creating pack file bitmaps makes housekeeping take a little longer but
- bitmaps should accelerate 'git clone' performance.
- .form-group
- = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :housekeeping_incremental_repack_period, class: 'form-control'
- .help-block
- Number of Git pushes after which an incremental 'git repack' is run.
- .form-group
- = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :housekeeping_full_repack_period, class: 'form-control'
- .help-block
- Number of Git pushes after which a full 'git repack' is run.
- .form-group
- = f.label :housekeeping_gc_period, 'Git GC period', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :housekeeping_gc_period, class: 'form-control'
- .help-block
- Number of Git pushes after which 'git gc' is run.
-
- %fieldset
- %legend Gitaly Timeouts
- .form-group
- = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :gitaly_timeout_default, class: 'form-control'
- .help-block
- Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
- for git fetch/push operations or Sidekiq jobs.
- .form-group
- = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :gitaly_timeout_fast, class: 'form-control'
- .help-block
- Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast.
- If they exceed this threshold, there may be a problem with a storage shard and 'failing fast'
- can help maintain the stability of the GitLab instance.
- .form-group
- = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :gitaly_timeout_medium, class: 'form-control'
- .help-block
- Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
-
- %fieldset
- %legend Web terminal
- .form-group
- = f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :terminal_max_session_time, class: 'form-control'
- .help-block
- Maximum time for web terminal websocket connection (in seconds).
- 0 for unlimited.
-
- %fieldset
- %legend Real-time features
- .form-group
- = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :polling_interval_multiplier, class: 'form-control'
- .help-block
- Change this value to influence how frequently the GitLab UI polls for updates.
- If you set the value to 2 all polling intervals are multiplied
- by 2, which means that polling happens half as frequently.
- The multiplier can also have a decimal value.
- The default value (1) is a reasonable choice for the majority of GitLab
- installations. Set to 0 to completely disable polling.
- = link_to icon('question-circle'), help_page_path('administration/polling')
-
- %fieldset
- %legend User and IP Rate Limits
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :throttle_unauthenticated_enabled do
- = f.check_box :throttle_unauthenticated_enabled
- Enable unauthenticated request rate limit
- %span.help-block
- Helps reduce request volume (e.g. from crawlers or abusive bots)
- .form-group
- = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control'
- .form-group
- = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control'
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :throttle_authenticated_api_enabled do
- = f.check_box :throttle_authenticated_api_enabled
- Enable authenticated API request rate limit
- %span.help-block
- Helps reduce request volume (e.g. from crawlers or abusive bots)
- .form-group
- = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control'
- .form-group
- = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control'
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :throttle_authenticated_web_enabled do
- = f.check_box :throttle_authenticated_web_enabled
- Enable authenticated web request rate limit
- %span.help-block
- Helps reduce request volume (e.g. from crawlers or abusive bots)
- .form-group
- = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control'
- .form-group
- = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
- .col-sm-10
- = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control'
-
- .form-actions
- = f.submit 'Save', class: 'btn btn-save'
diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml
new file mode 100644
index 00000000000..4acc5b3a0c5
--- /dev/null
+++ b/app/views/admin/application_settings/_gitaly.html.haml
@@ -0,0 +1,27 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_default, class: 'form-control'
+ .help-block
+ Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
+ for git fetch/push operations or Sidekiq jobs.
+ .form-group
+ = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_fast, class: 'form-control'
+ .help-block
+ Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast.
+ If they exceed this threshold, there may be a problem with a storage shard and 'failing fast'
+ can help maintain the stability of the GitLab instance.
+ .form-group
+ = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_medium, class: 'form-control'
+ .help-block
+ Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
new file mode 100644
index 00000000000..3bc101ddf04
--- /dev/null
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -0,0 +1,22 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .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
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_influx.html.haml b/app/views/admin/application_settings/_influx.html.haml
new file mode 100644
index 00000000000..a173fd38a9c
--- /dev/null
+++ b/app/views/admin/application_settings/_influx.html.haml
@@ -0,0 +1,68 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ %p
+ Setup InfluxDB to measure a wide variety of statistics like the time spent
+ in running SQL queries. These settings require a
+ = link_to 'restart', help_page_path('administration/restart_gitlab')
+ to take effect.
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction')
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :metrics_enabled do
+ = f.check_box :metrics_enabled
+ Enable InfluxDB Metrics
+ .form-group
+ = f.label :metrics_host, 'InfluxDB host', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com'
+ .form-group
+ = f.label :metrics_port, 'InfluxDB port', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :metrics_port, class: 'form-control', placeholder: '8089'
+ .help-block
+ The UDP port to use for connecting to InfluxDB. InfluxDB requires that
+ your server configuration specifies a database to store data in when
+ sending messages to this port, without it metrics data will not be
+ saved.
+ .form-group
+ = f.label :metrics_pool_size, 'Connection pool size', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :metrics_pool_size, class: 'form-control'
+ .help-block
+ The amount of InfluxDB connections to open. Connections are opened
+ lazily. Users using multi-threaded application servers should ensure
+ enough connections are available (at minimum the amount of application
+ server threads).
+ .form-group
+ = f.label :metrics_timeout, 'Connection timeout', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :metrics_timeout, class: 'form-control'
+ .help-block
+ The amount of seconds after which an InfluxDB connection will time
+ out.
+ .form-group
+ = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :metrics_method_call_threshold, class: 'form-control'
+ .help-block
+ A method call is only tracked when it takes longer to complete than
+ the given amount of milliseconds.
+ .form-group
+ = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :metrics_sample_interval, class: 'form-control'
+ .help-block
+ The sampling interval in seconds. Sampled data includes memory usage,
+ retained Ruby objects, file descriptors and so on.
+ .form-group
+ = f.label :metrics_packet_size, 'Metrics per packet', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :metrics_packet_size, class: 'form-control'
+ .help-block
+ The amount of points to store in a single UDP packet. More points
+ results in fewer but larger UDP packets being sent.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml
new file mode 100644
index 00000000000..b83ffc375d9
--- /dev/null
+++ b/app/views/admin/application_settings/_ip_limits.html.haml
@@ -0,0 +1,54 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :throttle_unauthenticated_enabled do
+ = f.check_box :throttle_unauthenticated_enabled
+ Enable unauthenticated request rate limit
+ %span.help-block
+ Helps reduce request volume (e.g. from crawlers or abusive bots)
+ .form-group
+ = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control'
+ .form-group
+ = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control'
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :throttle_authenticated_api_enabled do
+ = f.check_box :throttle_authenticated_api_enabled
+ Enable authenticated API request rate limit
+ %span.help-block
+ Helps reduce request volume (e.g. from crawlers or abusive bots)
+ .form-group
+ = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control'
+ .form-group
+ = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control'
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :throttle_authenticated_web_enabled do
+ = f.check_box :throttle_authenticated_web_enabled
+ Enable authenticated web request rate limit
+ %span.help-block
+ Helps reduce request volume (e.g. from crawlers or abusive bots)
+ .form-group
+ = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control'
+ .form-group
+ = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_koding.html.haml b/app/views/admin/application_settings/_koding.html.haml
new file mode 100644
index 00000000000..17358cf775b
--- /dev/null
+++ b/app/views/admin/application_settings/_koding.html.haml
@@ -0,0 +1,24 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :koding_enabled do
+ = f.check_box :koding_enabled
+ Enable Koding
+ .help-block
+ Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again.
+ .form-group
+ = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
+ .help-block
+ Koding has integration enabled out of the box for the
+ %strong gitlab
+ team, and you need to provide that team's URL here. Learn more in the
+ = succeed "." do
+ = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml
new file mode 100644
index 00000000000..44a11ddc120
--- /dev/null
+++ b/app/views/admin/application_settings/_logging.html.haml
@@ -0,0 +1,36 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :sentry_enabled do
+ = 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
+
+ .form-group
+ = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2'
+ .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'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
new file mode 100644
index 00000000000..d10f609006d
--- /dev/null
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -0,0 +1,12 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :allow_local_requests_from_hooks_and_services do
+ = f.check_box :allow_local_requests_from_hooks_and_services
+ Allow requests to the local network from hooks and services
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
new file mode 100644
index 00000000000..b28ecf9a039
--- /dev/null
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -0,0 +1,22 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :max_pages_size, class: 'form-control'
+ .help-block 0 for unlimited
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :pages_domain_verification_enabled do
+ = f.check_box :pages_domain_verification_enabled
+ Require users to prove ownership of custom domains
+ .help-block
+ Domain verification is an essential security measure for public GitLab
+ sites. Users are required to demonstrate they control a domain before
+ it is enabled
+ = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
new file mode 100644
index 00000000000..01d5a31aa9f
--- /dev/null
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -0,0 +1,19 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :authorized_keys_enabled do
+ = f.check_box :authorized_keys_enabled
+ Write to "authorized_keys" file
+ .help-block
+ By default, we write to the "authorized_keys" file to support Git
+ over SSH without additional configuration. GitLab can be optimized
+ to authenticate SSH keys via the database file. Only uncheck this
+ if you have configured your OpenSSH server to use the
+ AuthorizedKeysCommand. Click on the help icon for more details.
+ = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup')
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
new file mode 100644
index 00000000000..5344f030c97
--- /dev/null
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -0,0 +1,16 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .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
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
new file mode 100644
index 00000000000..56764b3fb81
--- /dev/null
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -0,0 +1,20 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :plantuml_enabled do
+ = f.check_box :plantuml_enabled
+ Enable PlantUML
+ .form-group
+ = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080'
+ .help-block
+ Allow rendering of
+ = link_to "PlantUML", "http://plantuml.com"
+ diagrams in Asciidoc documents using an external PlantUML service.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml
new file mode 100644
index 00000000000..48745db2991
--- /dev/null
+++ b/app/views/admin/application_settings/_prometheus.html.haml
@@ -0,0 +1,28 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ %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')
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml
new file mode 100644
index 00000000000..0a53a75119e
--- /dev/null
+++ b/app/views/admin/application_settings/_realtime.html.haml
@@ -0,0 +1,19 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :polling_interval_multiplier, class: 'form-control'
+ .help-block
+ Change this value to influence how frequently the GitLab UI polls for updates.
+ If you set the value to 2 all polling intervals are multiplied
+ by 2, which means that polling happens half as frequently.
+ The multiplier can also have a decimal value.
+ The default value (1) is a reasonable choice for the majority of GitLab
+ installations. Set to 0 to completely disable polling.
+ = link_to icon('question-circle'), help_page_path('administration/polling')
+
+ = f.submit 'Save changes', class: "btn btn-success"
+
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
new file mode 100644
index 00000000000..3451ef62458
--- /dev/null
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -0,0 +1,10 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :container_registry_token_expire_delay, class: 'form-control'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
new file mode 100644
index 00000000000..f33769b23c2
--- /dev/null
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -0,0 +1,62 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .sub-section
+ %h4 Repository checks
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :repository_checks_enabled do
+ = f.check_box :repository_checks_enabled
+ Enable Repository Checks
+ .help-block
+ GitLab will periodically run
+ %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck'
+ in all project and wiki repositories to look for silent disk corruption issues.
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove"
+ .help-block
+ If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
+
+ .sub-section
+ %h4 Housekeeping
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :housekeeping_enabled do
+ = f.check_box :housekeeping_enabled
+ Enable automatic repository housekeeping (git repack, git gc)
+ .help-block
+ If you keep automatic housekeeping disabled for a long time Git
+ repository access on your GitLab server will become slower and your
+ repositories will use more disk space. We recommend to always leave
+ this enabled.
+ .checkbox
+ = f.label :housekeeping_bitmaps_enabled do
+ = f.check_box :housekeeping_bitmaps_enabled
+ Enable Git pack file bitmap creation
+ .help-block
+ Creating pack file bitmaps makes housekeeping take a little longer but
+ bitmaps should accelerate 'git clone' performance.
+ .form-group
+ = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :housekeeping_incremental_repack_period, class: 'form-control'
+ .help-block
+ Number of Git pushes after which an incremental 'git repack' is run.
+ .form-group
+ = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :housekeeping_full_repack_period, class: 'form-control'
+ .help-block
+ Number of Git pushes after which a full 'git repack' is run.
+ .form-group
+ = f.label :housekeeping_gc_period, 'Git GC period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :housekeeping_gc_period, class: 'form-control'
+ .help-block
+ Number of Git pushes after which 'git gc' is run.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
new file mode 100644
index 00000000000..ac31977e1a9
--- /dev/null
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -0,0 +1,58 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .sub-section
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :hashed_storage_enabled do
+ = f.check_box :hashed_storage_enabled
+ Create new projects using hashed storage paths
+ .help-block
+ Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents
+ repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance.
+ %em (EXPERIMENTAL)
+ .form-group
+ = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages),
+ {include_hidden: false}, multiple: true, class: 'form-control'
+ .help-block
+ Manage repository storage paths. Learn more in the
+ = succeed "." do
+ = link_to "repository storages documentation", help_page_path("administration/repository_storages")
+ .sub-section
+ %h4 Circuit breaker
+ .form-group
+ = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_check_interval, class: 'form-control'
+ .help-block
+ = circuitbreaker_check_interval_help_text
+ .form-group
+ = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_access_retries, class: 'form-control'
+ .help-block
+ = circuitbreaker_access_retries_help_text
+ .form-group
+ = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
+ .help-block
+ = circuitbreaker_storage_timeout_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_count_help_text
+ .form-group
+ = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control'
+ .help-block
+ = circuitbreaker_failure_reset_time_help_text
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
new file mode 100644
index 00000000000..864e64b5fa9
--- /dev/null
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -0,0 +1,59 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :password_authentication_enabled_for_web do
+ = f.check_box :password_authentication_enabled_for_web
+ Password authentication enabled for web interface
+ .help-block
+ When disabled, an external authentication provider must be used.
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :password_authentication_enabled_for_git do
+ = f.check_box :password_authentication_enabled_for_git
+ Password authentication enabled for Git over HTTP(S)
+ .help-block
+ When disabled, a Personal Access Token
+ - if Gitlab::Auth::LDAP::Config.enabled?
+ or LDAP password
+ must be used to authenticate.
+ - 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'
+ .col-sm-10
+ .btn-group{ data: { toggle: 'buttons' } }
+ - oauth_providers_checkboxes.each do |source|
+ = source
+ .form-group
+ = f.label :two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
+ .col-sm-10
+ .checkbox
+ = f.label :require_two_factor_authentication do
+ = f.check_box :require_two_factor_authentication
+ Require all users to setup Two-factor authentication
+ .form-group
+ = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0'
+ .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
+ .form-group
+ = f.label :home_page_url, 'Home page URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block'
+ %span.help-block#home_help_block We will redirect non-logged in users to this page
+ .form-group
+ = f.label :after_sign_out_path, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block'
+ %span.help-block#after_sign_out_path_help_block We will redirect users to this page after they sign out
+ .form-group
+ = f.label :sign_in_text, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_area :sign_in_text, class: 'form-control', rows: 4
+ .help-block Markdown enabled
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
new file mode 100644
index 00000000000..85f311dd894
--- /dev/null
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -0,0 +1,58 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :signup_enabled do
+ = f.check_box :signup_enabled
+ Sign-up enabled
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :send_user_confirmation_email do
+ = f.check_box :send_user_confirmation_email
+ Send confirmation email on sign-up
+ .form-group
+ = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
+ .help-block ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com
+ .form-group
+ = f.label :domain_blacklist_enabled, 'Domain Blacklist', class: 'control-label col-sm-2'
+ .col-sm-10
+ .checkbox
+ = f.label :domain_blacklist_enabled do
+ = f.check_box :domain_blacklist_enabled
+ Enable domain blacklist for sign ups
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .radio
+ = label_tag :blacklist_type_file do
+ = radio_button_tag :blacklist_type, :file
+ .option-title
+ Upload blacklist file
+ .radio
+ = label_tag :blacklist_type_raw do
+ = radio_button_tag :blacklist_type, :raw, @application_setting.domain_blacklist.present? || @application_setting.domain_blacklist.blank?
+ .option-title
+ Enter blacklist manually
+ .form-group.blacklist-file
+ = f.label :domain_blacklist_file, 'Blacklist file', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.file_field :domain_blacklist_file, class: 'form-control', accept: '.txt,.conf'
+ .help-block Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries.
+ .form-group.blacklist-raw
+ = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
+ .help-block 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
+
+ .form-group
+ = f.label :after_sign_up_text, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_area :after_sign_up_text, class: 'form-control', rows: 4
+ .help-block Markdown enabled
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
new file mode 100644
index 00000000000..25e89097dfe
--- /dev/null
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -0,0 +1,65 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :recaptcha_enabled do
+ = f.check_box :recaptcha_enabled
+ Enable reCAPTCHA
+ %span.help-block#recaptcha_help_block Helps prevent bots from creating accounts
+
+ .form-group
+ = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :recaptcha_site_key, class: 'form-control'
+ .help-block
+ Generate site and private keys at
+ %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha
+
+ .form-group
+ = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :recaptcha_private_key, class: 'form-control'
+
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :akismet_enabled do
+ = f.check_box :akismet_enabled
+ Enable Akismet
+ %span.help-block#akismet_help_block Helps prevent bots from creating issues
+
+ .form-group
+ = f.label :akismet_api_key, 'Akismet API Key', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :akismet_api_key, class: 'form-control'
+ .help-block
+ Generate API key at
+ %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com
+
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :unique_ips_limit_enabled do
+ = f.check_box :unique_ips_limit_enabled
+ Limit sign in from multiple ips
+ %span.help-block#unique_ip_help_block
+ Helps prevent malicious users hide their activity
+
+ .form-group
+ = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :unique_ips_limit_per_user, class: 'form-control'
+ .help-block
+ Maximum number of unique IPs per user
+
+ .form-group
+ = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :unique_ips_limit_time_window, class: 'form-control'
+ .help-block
+ How many seconds an IP will be counted towards the limit
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml
new file mode 100644
index 00000000000..36d8838803f
--- /dev/null
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -0,0 +1,13 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :terminal_max_session_time, class: 'form-control'
+ .help-block
+ Maximum time for web terminal websocket connection (in seconds).
+ 0 for unlimited.
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
new file mode 100644
index 00000000000..7684e2cfdd1
--- /dev/null
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -0,0 +1,37 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :version_check_enabled do
+ = f.check_box :version_check_enabled
+ Enable version check
+ .help-block
+ GitLab will inform you if a new version is available.
+ = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")
+ about what information is shared with GitLab Inc.
+ .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, disabled: !can_be_configured
+ Enable usage ping
+ .help-block
+ - if can_be_configured
+ To help improve GitLab and its user experience, GitLab will
+ periodically collect usage information.
+ = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping")
+ about what information is shared with GitLab Inc. Visit
+ = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping')
+ to see the JSON payload sent.
+ - 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')
+
+ = f.submit 'Save changes', class: "btn btn-success"
+
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
new file mode 100644
index 00000000000..cbc779548f6
--- /dev/null
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -0,0 +1,66 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :default_branch_protection, class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control'
+ .form-group.visibility-level-setting
+ = f.label :default_project_visibility, class: 'control-label col-sm-2'
+ .col-sm-10
+ = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new)
+ .form-group.visibility-level-setting
+ = f.label :default_snippet_visibility, class: 'control-label col-sm-2'
+ .col-sm-10
+ = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new)
+ .form-group.visibility-level-setting
+ = f.label :default_group_visibility, class: 'control-label col-sm-2'
+ .col-sm-10
+ = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new)
+ .form-group
+ = f.label :restricted_visibility_levels, class: 'control-label col-sm-2'
+ .col-sm-10
+ - 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
+ Selected levels cannot be used by non-admin users for projects or snippets.
+ If the public level is restricted, user profiles are only visible to logged in users.
+ .form-group
+ = f.label :import_sources, class: 'control-label col-sm-2'
+ .col-sm-10
+ - import_sources_checkboxes('import-sources-help').each do |source|
+ .checkbox= source
+ %span.help-block#import-sources-help
+ Enabled sources for code import during project creation. OmniAuth must be configured for GitHub
+ = link_to "(?)", help_page_path("integration/github")
+ , Bitbucket
+ = link_to "(?)", help_page_path("integration/bitbucket")
+ and GitLab.com
+ = link_to "(?)", help_page_path("integration/gitlab")
+
+ .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
+
+ .form-group
+ %label.control-label.col-sm-2 Enabled Git access protocols
+ .col-sm-10
+ = 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.
+
+ - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
+ - field_name = :"#{type}_key_restriction"
+ .form-group
+ = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index ecc46d86afe..caaa93aa1e2 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -1,5 +1,304 @@
+- breadcrumb_title "Settings"
- page_title "Settings"
+- @content_class = "limit-container-width" unless fluid_layout
+- expanded = Rails.env.test?
-%h3.page-title Settings
-%hr
-= render 'form'
+%section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Visibility and access controls')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Set default and restrict visibility levels. Configure import sources and git access protocol.')
+ .settings-content
+ = render 'visibility_and_access'
+
+%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Account and limit settings')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Session expiration, projects limit and attachment size.')
+ .settings-content
+ = render 'account_and_limit'
+
+%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Sign-up restrictions')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Configure the way a user creates a new account.')
+ .settings-content
+ = render 'signup'
+
+%section.settings.as-signin.no-animate#js-signin-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Sign-in restrictions')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Set requirements for a user to sign-in. Enable mandatory two-factor authentication.')
+ .settings-content
+ = render 'signin'
+
+%section.settings.as-help-page.no-animate#js-help-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Help page')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Help page text and support page url.')
+ .settings-content
+ = render 'help_page'
+
+%section.settings.as-pages.no-animate#js-pages-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Pages')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Size and domain settings for static websites')
+ .settings-content
+ = render 'pages'
+
+%section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Continuous Integration and Deployment')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Auto DevOps, runners and job artifacts')
+ .settings-content
+ = render 'ci_cd'
+
+%section.settings.as-influx.no-animate#js-influx-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Metrics - Influx')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Enable and configure InfluxDB metrics.')
+ .settings-content
+ = render 'influx'
+
+%section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Metrics - Prometheus')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Enable and configure Prometheus metrics.')
+ .settings-content
+ = render 'prometheus'
+
+%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Profiling - Performance bar')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Enable the Performance Bar for a given group.')
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar')
+ .settings-content
+ = render 'performance_bar'
+
+%section.settings.as-background.no-animate#js-background-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Background jobs')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Configure Sidekiq job throttling.')
+ .settings-content
+ = render 'background_jobs'
+
+%section.settings.as-spam.no-animate#js-spam-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Spam and Anti-bot Protection')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Enable reCAPTCHA or Akismet and set IP limits.')
+ .settings-content
+ = render 'spam'
+
+%section.settings.as-abuse.no-animate#js-abuse-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Abuse reports')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Set notification email for abuse reports.')
+ .settings-content
+ = render 'abuse'
+
+%section.settings.as-logging.no-animate#js-logging-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Error Reporting and Logging')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Enable Sentry for error reporting and logging.')
+ .settings-content
+ = render 'logging'
+
+%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Repository storage')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Configure storage path and circuit breaker settings.')
+ .settings-content
+ = render 'repository_storage'
+
+%section.settings.as-repository-check.no-animate#js-repository-check-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Repository maintenance')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Configure automatic git checks and housekeeping on repositories.')
+ .settings-content
+ = render 'repository_check'
+
+- if Gitlab.config.registry.enabled
+ %section.settings.as-registry.no-animate#js-registry-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Container Registry')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Various container registry settings.')
+ .settings-content
+ = render 'registry'
+
+- if koding_enabled?
+ %section.settings.as-koding.no-animate#js-koding-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Koding')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Online IDE integration settings.')
+ .settings-content
+ = render 'koding'
+
+%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('PlantUML')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Allow rendering of PlantUML diagrams in Asciidoc documents.')
+ .settings-content
+ = render 'plantuml'
+
+%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded) }
+ .settings-header#usage-statistics
+ %h4
+ = _('Usage statistics')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Enable or disable version check and usage ping.')
+ .settings-content
+ = render 'usage'
+
+%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Email')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Various email settings.')
+ .settings-content
+ = render 'email'
+
+%section.settings.as-gitaly.no-animate#js-gitaly-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Gitaly')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Configure Gitaly timeouts.')
+ .settings-content
+ = render 'gitaly'
+
+%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Web terminal')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Set max session time for web terminal.')
+ .settings-content
+ = render 'terminal'
+
+%section.settings.as-realtime.no-animate#js-realtime-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Real-time features')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Change this value to influence how frequently the GitLab UI polls for updates.')
+ .settings-content
+ = render 'realtime'
+
+%section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Performance optimization')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Various settings that affect GitLab performance.')
+ .settings-content
+ = render 'performance'
+
+%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('User and IP Rate Limits')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Configure limits for web and API requests.')
+ .settings-content
+ = render 'ip_limits'
+
+%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Outbound requests')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Allow requests to the local network from hooks and services.')
+ .settings-content
+ = render 'outbound'
diff --git a/app/views/admin/broadcast_messages/preview.js.haml b/app/views/admin/broadcast_messages/preview.js.haml
deleted file mode 100644
index c72e59640d7..00000000000
--- a/app/views/admin/broadcast_messages/preview.js.haml
+++ /dev/null
@@ -1 +0,0 @@
-$('.js-broadcast-message-preview').html("#{j(render_broadcast_message(@broadcast_message))}");
diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml
index 30dd87f0463..ed40e7b4d00 100644
--- a/app/views/admin/conversational_development_index/show.html.haml
+++ b/app/views/admin/conversational_development_index/show.html.haml
@@ -6,7 +6,7 @@
= render 'callout'
.prepend-top-default
- - if !current_application_settings.usage_ping_enabled
+ - if !Gitlab::CurrentSettings.usage_ping_enabled
= render 'disabled'
- elsif @metric.blank?
= render 'no_data'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index a24516355bf..05c41082882 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -4,6 +4,34 @@
%div{ class: container_class }
.admin-dashboard.prepend-top-default
.row
+ .col-sm-4
+ .info-well.dark-well
+ .well-segment.well-centered
+ = link_to admin_projects_path do
+ %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
+ .info-well.dark-well
+ .well-segment.well-centered
+ = link_to admin_users_path do
+ %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
+ .info-well.dark-well
+ .well-segment.well-centered
+ = link_to admin_groups_path do
+ %h3.text-center
+ Groups:
+ = number_with_delimiter(Group.count)
+ %hr
+ = link_to 'New group', new_admin_group_path, class: "btn btn-new"
+ .row
.col-md-4
.info-well
.well-segment.admin-well.admin-well-statistics
@@ -91,7 +119,7 @@
.well-segment.admin-well
%h4
Components
- - if current_application_settings.version_check_enabled
+ - if Gitlab::CurrentSettings.version_check_enabled
.pull-right
= version_status_badge
%p
@@ -110,20 +138,12 @@
GitLab API
%span.pull-right
= API::API::version
- %p
- Gitaly
- %span.pull-right
- = Gitlab::GitalyClient.expected_server_version
- if Gitlab.config.pages.enabled
%p
GitLab Pages
%span.pull-right
= Gitlab::Pages::VERSION
%p
- Git
- %span.pull-right
- = Gitlab::Git.version
- %p
Ruby
%span.pull-right
#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}
@@ -135,34 +155,8 @@
= Gitlab::Database.adapter_name
%span.pull-right
= Gitlab::Database.version
- .row
- .col-sm-4
- .info-well.dark-well
- .well-segment.well-centered
- = link_to admin_projects_path do
- %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
- .info-well.dark-well
- .well-segment.well-centered
- = link_to admin_users_path do
- %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
- .info-well.dark-well
- .well-segment.well-centered
- = link_to admin_groups_path do
- %h3.text-center
- Groups:
- = number_with_delimiter(Group.count)
- %hr
- = link_to 'New group', new_admin_group_path, class: "btn btn-new"
+ %p
+ = link_to "Gitaly Servers", admin_gitaly_servers_path
.row
.col-md-4
.info-well
@@ -170,7 +164,7 @@
%h4 Latest projects
- @projects.each do |project|
%p
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
+ = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated-60'
%span.light.pull-right
#{time_ago_with_tooltip(project.created_at)}
.col-md-4
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 92370034baa..1420163fd5a 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -12,7 +12,7 @@
%tr
%th.col-sm-2 Title
%th.col-sm-4 Fingerprint
- %th.col-sm-2 Write access allowed
+ %th.col-sm-2 Projects with write access
%th.col-sm-2 Added at
%th.col-sm-2
%tbody
@@ -23,10 +23,8 @@
%td
%code.key-fingerprint= deploy_key.fingerprint
%td
- - if deploy_key.can_push?
- Yes
- - else
- No
+ - deploy_key.projects_with_write_access.each do |project|
+ = link_to project.full_name, admin_project_path(project), class: 'label deploy-project-label'
%td
%span.cgray
added #{time_ago_with_tooltip(deploy_key.created_at)}
diff --git a/app/views/admin/gitaly_servers/index.html.haml b/app/views/admin/gitaly_servers/index.html.haml
new file mode 100644
index 00000000000..231f94dc95d
--- /dev/null
+++ b/app/views/admin/gitaly_servers/index.html.haml
@@ -0,0 +1,31 @@
+- breadcrumb_title _("Gitaly Servers")
+
+%h3.page-title= _("Gitaly Servers")
+%hr
+.gitaly_servers
+ - if @gitaly_servers.any?
+ .table-holder
+ %table.table.responsive-table
+ %thead.hidden-sm.hidden-xs
+ %tr
+ %th= _("Storage")
+ %th= n_("Gitaly|Address")
+ %th= _("Server version")
+ %th= _("Git version")
+ %th= _("Up to date")
+ - @gitaly_servers.each do |server|
+ %tr
+ %td
+ = server.storage
+ %td
+ = server.address
+ %td
+ = server.server_version
+ %td
+ = server.git_binary_version
+ %td
+ = boolean_to_icon(server.up_to_date?)
+ - else
+ .empty-state
+ .text-center
+ %h4= _("No connection could be made to a Gitaly Server, please check your logs!")
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 2545cecc721..324f3c0a22f 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -68,7 +68,7 @@
- @projects.each do |project|
%li
%strong
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project]
+ = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project]
%span.badge
= storage_counter(project.statistics.storage_size)
%span.pull-right.light
@@ -86,7 +86,7 @@
- @group.shared_projects.sort_by(&:name).each do |project|
%li
%strong
- = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project]
+ = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project]
%span.badge
= storage_counter(project.statistics.storage_size)
%span.pull-right.light
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 10a3bed0a4f..e31fb58b205 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -8,7 +8,7 @@
.pull-left
%p
#{ s_('HealthCheck|Access token is') }
- %code#health-check-token= current_application_settings.health_check_access_token
+ %code#health-check-token= Gitlab::CurrentSettings.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',
@@ -18,11 +18,11 @@
= link_to s_('More information is available|here'), help_page_path('user/admin_area/monitoring/health_check')
%ul
%li
- %code= readiness_url(token: current_application_settings.health_check_access_token)
+ %code= readiness_url(token: Gitlab::CurrentSettings.health_check_access_token)
%li
- %code= liveness_url(token: current_application_settings.health_check_access_token)
+ %code= liveness_url(token: Gitlab::CurrentSettings.health_check_access_token)
%li
- %code= metrics_url(token: current_application_settings.health_check_access_token)
+ %code= metrics_url(token: Gitlab::CurrentSettings.health_check_access_token)
%hr
.panel.panel-default
diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index 645005c6deb..a6324a97fd5 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -1,21 +1,20 @@
= form_errors(hook)
.form-group
- = form.label :url, 'URL', class: 'control-label'
- .col-sm-10
- = form.text_field :url, class: 'form-control'
+ = form.label :url, 'URL', class: 'label-light'
+ = 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.label :token, 'Secret Token', class: 'label-light'
+ = 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.
+ = form.label :url, 'Trigger', class: 'label-light'
+ %ul.list-unstyled
+ %li
+ .help-block
+ 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'
@@ -24,24 +23,30 @@
%strong Repository update events
%p.light
This URL will be triggered when repository is updated
- %div
+ %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 for each branch updated to the repository
- %div
+ %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 :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
.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
+ = 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/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index efb15ccc8df..629b1a9940f 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -13,7 +13,7 @@
= 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
+ = 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
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index b6e1df5f3ac..d9e2ce5e74c 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -1,33 +1,35 @@
- page_title 'System Hooks'
-%h3.page-title
- System hooks
+.row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ = page_title
+ %p
+ #{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.
-%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.
+ .col-lg-8.append-bottom-default
+ = form_for @hook, as: :hook, url: admin_hooks_path do |f|
+ = render partial: 'form', locals: { form: f, hook: @hook }
+ = f.submit 'Add system hook', class: 'btn btn-create'
-%hr
+ %hr
-= form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f|
- = render partial: 'form', locals: { form: f, hook: @hook }
- .form-actions
- = f.submit 'Add system hook', class: 'btn btn-create'
-%hr
+ - if @hooks.any?
+ .panel.panel-default
+ .panel-heading
+ System hooks (#{@hooks.count})
+ %ul.content-list
+ - @hooks.each do |hook|
+ %li
+ .controls
+ = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm'
+ = 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
+ - 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'}
-- if @hooks.any?
- .panel.panel-default
- .panel-heading
- System hooks (#{@hooks.count})
- %ul.content-list
- - @hooks.each do |hook|
- %li
- .controls
- = render 'shared/web_hooks/test_button', triggers: SystemHook::TRIGGERS, hook: hook, button_class: 'btn-sm'
- = 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
- - 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'}
+= render 'shared/plugins/index'
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index 112a201fafa..5381b854f5c 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -4,7 +4,7 @@
.form-group
= f.label :provider, class: 'control-label'
.col-sm-10
- - values = Gitlab::OAuth::Provider.providers.map { |name| ["#{Gitlab::OAuth::Provider.label_for(name)} (#{name})", name] }
+ - values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] }
= f.select :provider, values, { allow_blank: false }, class: 'form-control'
.form-group
= f.label :extern_uid, "Identifier", class: 'control-label'
diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml
index 8c658905bd6..ef5a3f1d969 100644
--- a/app/views/admin/identities/_identity.html.haml
+++ b/app/views/admin/identities/_identity.html.haml
@@ -1,6 +1,6 @@
%tr
%td
- #{Gitlab::OAuth::Provider.label_for(identity.provider)} (#{identity.provider})
+ #{Gitlab::Auth::OAuth::Provider.label_for(identity.provider)} (#{identity.provider})
%td
= identity.extern_uid
%td
diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml
index 7066ed12b95..4e3e2f7a475 100644
--- a/app/views/admin/jobs/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -7,9 +7,13 @@
- 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
+ - if @all_builds.running_or_pending.any?
+ #stop-jobs-modal
+ .nav-controls
+ %button#stop-jobs-button.btn.btn-danger{ data: { toggle: 'modal',
+ target: '#stop-jobs-modal',
+ url: cancel_all_admin_jobs_path } }
+ = s_('AdminArea|Stop all jobs')
.row-content-block.second-block
#{(@scope || 'all').capitalize} jobs
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index c69c2761189..b5d7b889504 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -5,7 +5,12 @@
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
= 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"
+ %button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal',
+ target: '#delete-project-modal',
+ delete_project_url: project_path(project),
+ project_name: project.name }, type: 'button' }
+ = s_('AdminProjects|Delete')
+
.stats
%span.badge
= storage_counter(project.statistics.storage_size)
@@ -31,3 +36,5 @@
= paginate @projects, theme: 'gitlab'
- else
.nothing-here-block No projects found
+
+ #delete-project-modal
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 42f92079d85..c47b8a88f56 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -1,8 +1,8 @@
- add_to_breadcrumbs "Projects", admin_projects_path
-- breadcrumb_title @project.name_with_namespace
-- page_title @project.name_with_namespace, "Projects"
+- breadcrumb_title @project.full_name
+- page_title @project.full_name, "Projects"
%h3.page-title
- Project: #{@project.name_with_namespace}
+ Project: #{@project.full_name}
= link_to edit_project_path(@project), class: "btn btn-nr pull-right" do
%i.fa.fa-pencil-square-o
Edit
@@ -62,12 +62,16 @@
= link_to @project.ssh_url_to_repo, project_path(@project)
- if @project.repository.exists?
%li
- %span.light fs:
+ %span.light Gitaly storage name:
%strong
- = @project.repository.path_to_repo
+ = @project.repository.storage
+ %li
+ %span.light Gitaly relative path:
+ %strong
+ = @project.repository.relative_path
%li
- %span.light Storage:
+ %span.light Storage used:
%strong= storage_counter(@project.statistics.storage_size)
(
= storage_counter(@project.statistics.repository_size)
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index 140688b52d3..e1cee584929 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -17,6 +17,8 @@
%td
= runner.version
%td
+ = runner.ip_address
+ %td
- if runner.shared?
n/a
- else
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 4f60be698e9..9f13dbbbd82 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -35,9 +35,8 @@
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' }
+ = render partial: 'ci/runner/how_to_setup_shared_runner',
+ locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token }
.append-bottom-20.clearfix
.pull-left
@@ -61,6 +60,7 @@
%th Runner token
%th Description
%th Version
+ %th IP Address
%th Projects
%th Jobs
%th Tags
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 6d8fad0eb8d..37269862de6 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -9,6 +9,10 @@
%span.runner-state.runner-state-specific
Specific
+- add_to_breadcrumbs _("Runners"), admin_runners_path
+- breadcrumb_title "##{@runner.id}"
+- @no_container = true
+
- if @runner.shared?
.bs-callout.bs-callout-success
%h4 This Runner will process jobs from ALL UNASSIGNED projects
@@ -39,7 +43,7 @@
%tr.alert-info
%td
%strong
- = project.name_with_namespace
+ = project.full_name
%td
.pull-right
= link_to 'Disable', [:admin, project.namespace.becomes(Namespace), project, runner_project], method: :delete, class: 'btn btn-danger btn-xs'
@@ -61,7 +65,7 @@
- @projects.each do |project|
%tr
%td
- = project.name_with_namespace
+ = project.full_name
%td
.pull-right
= form_for [:admin, project.namespace.becomes(Namespace), project, project.runner_projects.new] do |f|
@@ -95,7 +99,7 @@
%td.status
- if project
- = project.name_with_namespace
+ = project.full_name
%td.build-link
- if project
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index ca6e43e091c..bbfeceff5b9 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -1,6 +1,6 @@
%li.flex-row
.user-avatar
- = image_tag avatar_icon(user), class: "avatar", alt: ''
+ = image_tag avatar_icon_for_user(user), class: "avatar", alt: ''
.row-main-content
.user-name.row-title.str-truncated-100
= link_to user.name, [:admin, user]
@@ -38,12 +38,19 @@
%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
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(user),
+ block_user_url: block_admin_user_path(user),
+ username: user.name,
+ delete_contributions: 'false' }, type: 'button' }
+ = s_('AdminUsers|Delete user')
+
%li
- = 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
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(user, hard_delete: true),
+ block_user_url: block_admin_user_path(user),
+ username: user.name,
+ delete_contributions: 'true' }, type: 'button' }
+ = s_('AdminUsers|Delete user and contributions')
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 38ce1564eff..0ef4b71f4fe 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -76,3 +76,6 @@
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
+
+#delete-user-modal
+
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index 4a440f3f6d4..96835ee9af5 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -29,12 +29,12 @@
.panel.panel-default
.panel-heading Joined projects (#{@joined_projects.count})
%ul.well-list
- - @joined_projects.sort_by(&:name_with_namespace).each do |project|
+ - @joined_projects.sort_by(&:full_name).each do |project|
- member = project.team.find_member(@user.id)
%li.project_member
.list-item-name
= link_to admin_project_path(project), class: dom_class(project) do
- = project.name_with_namespace
+ = project.full_name
- if member
.pull-right
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 63c5a15de1c..ec3be869797 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -10,7 +10,7 @@
= @user.name
%ul.well-list
%li
- = image_tag avatar_icon(@user, 60), class: "avatar s60"
+ = image_tag avatar_icon_for_user(@user, 60), class: "avatar s60"
%li
%span.light Profile page:
%strong
@@ -172,13 +172,19 @@
.panel.panel-danger
.panel-heading
- Remove user
+ = s_('AdminUsers|Delete user')
.panel-body
- if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
%p Deleting a user has the following effects:
= render 'users/deletion_guidance', user: @user
%br
- = 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"
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(@user),
+ block_user_url: block_admin_user_path(@user),
+ username: @user.name,
+ delete_contributions: 'false' }, type: 'button' }
+ = s_('AdminUsers|Delete user')
- else
- if @user.solo_owned_groups.present?
%p
@@ -192,7 +198,7 @@
.panel.panel-danger
.panel-heading
- Remove user and contributions
+ = s_('AdminUsers|Delete user and contributions')
.panel-body
- if can?(current_user, :destroy_user, @user)
%p
@@ -204,7 +210,15 @@
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"
+ %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
+ target: '#delete-user-modal',
+ delete_user_url: admin_user_path(@user, hard_delete: true),
+ block_user_url: block_admin_user_path(@user),
+ username: @user.name,
+ delete_contributions: 'true' }, type: 'button' }
+ = s_('AdminUsers|Delete user and contributions')
- else
%p
You don't have access to delete this user.
+
+ #delete-user-modal
diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml
index e6408f35201..22f149d1caa 100644
--- a/app/views/ci/lints/show.html.haml
+++ b/app/views/ci/lints/show.html.haml
@@ -1,25 +1,9 @@
-- page_title "CI Lint"
-- page_description "Validate your GitLab CI configuration file"
-- content_for :library_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
-
-%h2 Check your .gitlab-ci.yml
-
-.ci-linter
- .row
- = form_tag ci_lint_path, method: :post do
- .form-group
- .col-sm-12
- .file-holder
- .js-file-title.file-title.clearfix
- Content of .gitlab-ci.yml
- #ci-editor.ci-editor= @content
- = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true)
- .col-sm-12
- .pull-left.prepend-top-10
- = submit_tag('Validate', class: 'btn btn-success submit-yml')
-
- .row.prepend-top-20
- .col-sm-12
- .results.ci-template
- = render partial: 'create' if defined?(@status)
+.row.empty-state
+ .col-xs-12
+ .svg-content
+ = image_tag 'illustrations/feature_moved.svg'
+ .col-xs-12
+ .text-content.text-center
+ %h4= _("GitLab CI Linter has been moved")
+ %p
+ = _("To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button.")
diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml
index b75dab0acc5..37fb8fbab26 100644
--- a/app/views/ci/runner/_how_to_setup_runner.html.haml
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -1,16 +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")
+.append-bottom-10
+ %h4= _("Setup a #{type} Runner manually")
- %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!")
+%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#coordinator_address= 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/runner/_how_to_setup_shared_runner.html.haml b/app/views/ci/runner/_how_to_setup_shared_runner.html.haml
new file mode 100644
index 00000000000..2a190cb9250
--- /dev/null
+++ b/app/views/ci/runner/_how_to_setup_shared_runner.html.haml
@@ -0,0 +1,3 @@
+.bs-callout.help-callout
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: registration_token, type: 'shared' }
diff --git a/app/views/ci/runner/_how_to_setup_specific_runner.html.haml b/app/views/ci/runner/_how_to_setup_specific_runner.html.haml
new file mode 100644
index 00000000000..e765a353fe4
--- /dev/null
+++ b/app/views/ci/runner/_how_to_setup_specific_runner.html.haml
@@ -0,0 +1,26 @@
+.bs-callout.help-callout
+ .append-bottom-10
+ %h4= _('Setup a specific Runner automatically')
+
+ %p
+ - link_to_help_page = link_to(_('Learn more about Kubernetes'),
+ help_page_path('user/project/clusters/index'),
+ target: '_blank',
+ rel: 'noopener noreferrer')
+
+ = _('You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page }
+
+ %ol
+ %li
+ = _('Click the button below to begin the install process by navigating to the Kubernetes page')
+ %li
+ = _('Select an existing Kubernetes cluster or create a new one')
+ %li
+ = _('From the Kubernetes cluster details view, install Runner from the applications list')
+
+ = link_to _('Install Runner on Kubernetes'),
+ project_clusters_path(@project),
+ class: 'btn btn-info'
+ %hr
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: registration_token, type: 'specific' }
diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml
index 35a3563dff1..5114387984b 100644
--- a/app/views/ci/status/_badge.html.haml
+++ b/app/views/ci/status/_badge.html.haml
@@ -4,10 +4,10 @@
- css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}"
- if link && status.has_details?
- = link_to status.details_path, class: css_classes, title: title do
+ = link_to status.details_path, class: css_classes, title: title, data: { html: title.present? } do
= sprite_icon(status.icon)
= status.text
- else
- %span{ class: css_classes, title: title }
+ %span{ class: css_classes, title: title, data: { html: title.present? } }
= sprite_icon(status.icon)
= status.text
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index c5b4439e273..db2040110fa 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -3,14 +3,15 @@
- subject = local_assigns.fetch(:subject)
- status = subject.detailed_status(current_user)
- klass = "ci-status-icon ci-status-icon-#{status.group}"
-- tooltip = "#{subject.name} - #{status.label}"
+- tooltip = "#{subject.name} - #{status.status_tooltip}"
- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
+ = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, html: true, container: 'body' } do
%span{ class: klass }= sprite_icon(status.icon)
%span.ci-build-text= subject.name
+
- else
- .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
+ .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', html: true, title: tooltip, container: 'body' } }
%span{ class: klass }= sprite_icon(status.icon)
%span.ci-build-text= subject.name
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index fbfe3e56588..d355e7799df 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -1,3 +1 @@
-%p.append-bottom-default
- Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags.
- You can use variables for passwords, secret keys, or whatever you want.
+= _('Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want.')
diff --git a/app/views/ci/variables/_form.html.haml b/app/views/ci/variables/_form.html.haml
deleted file mode 100644
index eebd0955c80..00000000000
--- a/app/views/ci/variables/_form.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-= 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
index 6e399fc7392..e402801a776 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -1,16 +1,20 @@
-.row.prepend-top-default.append-bottom-default
- .col-lg-12
- %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
- .js-secret-variable-table
- = render "ci/variables/table"
- %button.btn.btn-info.js-secret-value-reveal-button{ data: { secret_reveal_status: 'false' } }
+- save_endpoint = local_assigns.fetch(:save_endpoint, nil)
+
+.row
+ .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } }
+ .hide.alert.alert-danger.js-ci-variable-error-box
+
+ %ul.ci-variable-list
+ - @variables.each.each do |variable|
+ = render 'ci/variables/variable_row', form_field: 'variables', variable: variable
+ = render 'ci/variables/variable_row', form_field: 'variables'
+ .prepend-top-20
+ %button.btn.btn-success.js-secret-variables-save-button{ type: 'button' }
+ %span.hide.js-secret-variables-save-loading-icon
+ = icon('spinner spin')
+ = _('Save variables')
+ %button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
+ - if @variables.size == 0
+ = n_('Hide value', 'Hide values', @variables.size)
+ - else
= n_('Reveal value', 'Reveal values', @variables.size)
diff --git a/app/views/ci/variables/_show.html.haml b/app/views/ci/variables/_show.html.haml
deleted file mode 100644
index 6d75ae96124..00000000000
--- a/app/views/ci/variables/_show.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- page_title "Variables"
-
-.row.prepend-top-default.append-bottom-default
- .col-lg-3
- = render "ci/variables/content"
- .col-lg-9
- %h4.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
deleted file mode 100644
index 2298930d0c7..00000000000
--- a/app/views/ci/variables/_table.html.haml
+++ /dev/null
@@ -1,32 +0,0 @@
-.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
- %span.js-secret-value-placeholder
- = '*' * 6
- %span.hide.js-secret-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/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
new file mode 100644
index 00000000000..440623b34f5
--- /dev/null
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -0,0 +1,47 @@
+- form_field = local_assigns.fetch(:form_field, nil)
+- variable = local_assigns.fetch(:variable, nil)
+- only_key_value = local_assigns.fetch(:only_key_value, false)
+
+- id = variable&.id
+- key = variable&.key
+- value = variable&.value
+- is_protected = variable && !only_key_value ? variable.protected : false
+
+- id_input_name = "#{form_field}[variables_attributes][][id]"
+- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
+- key_input_name = "#{form_field}[variables_attributes][][key]"
+- value_input_name = "#{form_field}[variables_attributes][][secret_value]"
+- protected_input_name = "#{form_field}[variables_attributes][][protected]"
+
+%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
+ .ci-variable-row-body
+ %input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id }
+ %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
+ %input.js-ci-variable-input-key.ci-variable-body-item.form-control{ type: "text",
+ name: key_input_name,
+ value: key,
+ placeholder: s_('CiVariables|Input variable key') }
+ .ci-variable-body-item
+ .form-control.js-secret-value-placeholder{ class: ('hide' unless id) }
+ = '*' * 20
+ %textarea.js-ci-variable-input-value.js-secret-value.form-control{ class: ('hide' if id),
+ rows: 1,
+ name: value_input_name,
+ placeholder: s_('CiVariables|Input variable value') }
+ = value
+ - unless only_key_value
+ .ci-variable-body-item.ci-variable-protected-item
+ .append-right-default
+ = s_("CiVariable|Protected")
+ %button{ type: 'button',
+ class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_protected}",
+ "aria-label": s_("CiVariable|Toggle protected") }
+ %input{ type: "hidden",
+ class: 'js-ci-variable-input-protected js-project-feature-toggle-input',
+ name: protected_input_name,
+ value: is_protected }
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
+ %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
+ = icon('minus-circle')
diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml
index ecdf76ef5c5..7a3f3667ac1 100644
--- a/app/views/dashboard/_activity_head.html.haml
+++ b/app/views/dashboard/_activity_head.html.haml
@@ -2,7 +2,7 @@
%ul.nav-links
%li{ class: active_when(params[:filter].nil?) }>
= link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
- Your Projects
+ Your projects
%li{ class: active_when(params[:filter] == 'starred') }>
= link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
- Starred Projects
+ Starred projects
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index cebdbab4e74..617c20b9635 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,5 +1,5 @@
.top-area
- %ul.nav-links
+ %ul.nav-links.mobile-separator
= nav_link(page: dashboard_groups_path) do
= link_to dashboard_groups_path, title: _("Your groups") do
Your groups
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 9038c4fbebd..449a2ce625e 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -4,7 +4,7 @@
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
- %ul.nav-links.scrolling-tabs
+ %ul.nav-links.scrolling-tabs.mobile-separator
= nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
Your projects
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 7330f4cb523..a9488df07bd 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -2,10 +2,10 @@
%ul.nav-links
= nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do
= link_to dashboard_snippets_path, title: 'Your snippets', data: {placement: 'right'} do
- Your Snippets
+ Your snippets
= nav_link(page: explore_snippets_path) do
= link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do
- Explore Snippets
+ Explore snippets
- if current_user
.nav-controls.hidden-xs
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index ad35d05c29a..31d4b3da4f1 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -7,10 +7,8 @@
- page_title "Activity"
- header_title "Activity", activity_dashboard_path
-.hidden-xs
- = render "projects/last_push"
-
%div{ class: container_class }
+ = render "projects/last_push"
= render 'dashboard/activity_head'
%section.activities
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 601b6a8b1a7..db856ef7d7b 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,2 +1,4 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 25bf08c6c12..50f39f93283 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -3,9 +3,6 @@
- header_title "Groups", dashboard_groups_path
= render 'dashboard/groups_head'
-= webpack_bundle_tag 'common_vue'
-= webpack_bundle_tag 'groups'
-
- if params[:filter].blank? && @groups.empty?
= render 'shared/groups/empty_state'
- else
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index 42941acc508..bb472b4c900 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -1,15 +1,19 @@
- @hide_top_links = true
-- page_title "Issues"
-- header_title "Issues", issues_dashboard_path(assignee_id: current_user.id)
+- page_title _("Issues")
+- @breadcrumb_link = 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")
.top-area
- = render 'shared/issuable/nav', type: :issues
+ = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set
.nav-controls
- = link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do
+ = link_to params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do
= icon('rss')
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues
= render 'shared/issuable/filter', type: :issues
-= render 'shared/issues'
+
+- if current_user && @no_filters_set
+ = render 'shared/dashboard/no_filter_selected'
+- else
+ = render 'shared/issues'
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 53cd1130299..61aae31be60 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,11 +1,15 @@
- @hide_top_links = true
-- page_title "Merge Requests"
-- header_title "Merge Requests", merge_requests_dashboard_path(assignee_id: current_user.id)
+- page_title _("Merge Requests")
+- @breadcrumb_link = merge_requests_dashboard_path(assignee_id: current_user.id)
.top-area
- = render 'shared/issuable/nav', type: :merge_requests
+ = render 'shared/issuable/nav', type: :merge_requests, display_count: !@no_filters_set
.nav-controls
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests', type: :merge_requests
= render 'shared/issuable/filter', type: :merge_requests
-= render 'shared/merge_requests'
+
+- if current_user && @no_filters_set
+ = render 'shared/dashboard/no_filter_selected'
+- else
+ = render 'shared/merge_requests'
diff --git a/app/views/dashboard/projects/_nav.html.haml b/app/views/dashboard/projects/_nav.html.haml
index 3701e1c0578..97f854cc5f0 100644
--- a/app/views/dashboard/projects/_nav.html.haml
+++ b/app/views/dashboard/projects/_nav.html.haml
@@ -1,5 +1,5 @@
-.top-area
- %ul.nav-links
+.nav-block
+ %ul.nav-links.mobile-separator
= nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do
= link_to s_('DashboardProjects|All'), dashboard_projects_path
= nav_link(html_options: { class: ("active" if params[:personal].present?) }) do
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 57a4da353fe..deed774a4a5 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -7,9 +7,8 @@
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
-= render "projects/last_push"
-
%div{ class: container_class }
+ = render "projects/last_push"
- if show_projects?(@projects, params)
= render 'dashboard/projects_head'
= render 'nav'
diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml
index 14f9f8cd70a..b1efe59aadc 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -4,9 +4,8 @@
- page_title "Starred Projects"
- header_title "Projects", dashboard_projects_path
-= render "projects/last_push"
-
%div{ class: container_class }
+ = render "projects/last_push"
= render 'dashboard/projects_head'
- if params[:filter_projects] || any_projects?(@projects)
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 20ca6ec969a..664966989db 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -4,7 +4,7 @@
- if current_user.todos.any?
.top-area
- %ul.nav-links
+ %ul.nav-links.mobile-separator
%li.todos-pending{ class: active_when(params[:state].blank? || params[:state] == 'pending') }>
= link_to todos_filter_path(state: 'pending') do
%span
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index fb70d158096..79826a364db 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -4,9 +4,9 @@
%p.lead.append-bottom-20
Please check your email to confirm your account
%hr
-- if current_application_settings.after_sign_up_text.present?
+- if Gitlab::CurrentSettings.after_sign_up_text.present?
.well-confirmation.text-center
- = markdown_field(current_application_settings, :after_sign_up_text)
+ = markdown_field(Gitlab::CurrentSettings, :after_sign_up_text)
%p.text-center
No confirmation email received? Please check your spam folder or
.append-bottom-20.prepend-top-20.text-center
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index a039756c7e2..6e54b9b5645 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,7 +1,3 @@
-- if inject_u2f_api?
- - content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('u2f')
-
%div
= render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication'
.login-box
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index 6087f4a0b37..5ddb3ece1cb 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -8,7 +8,7 @@
.login-body
= render 'devise/sessions/new_ldap', server: server
- if password_authentication_enabled_for_web?
- .login-box.tab-pane{ id: 'ldap-standard', role: 'tabpanel' }
+ .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index 94f19ccd44c..270191f9452 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -7,7 +7,7 @@
= link_to server['label'], "##{server['provider_name']}", 'data-toggle' => 'tab'
- if password_authentication_enabled_for_web?
%li
- = link_to 'Standard', '#ldap-standard', 'data-toggle' => 'tab'
+ = link_to 'Standard', '#login-pane', 'data-toggle' => 'tab'
- if allow_signup?
%li
= link_to 'Register', '#register-pane', 'data-toggle' => 'tab'
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index f9bfc01f213..8680ec2e298 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -2,8 +2,12 @@
- blob = discussion.blob
- discussions = { discussion.original_line_code => [discussion] }
- diff_file_class = diff_file.text? ? 'text-file' : 'js-image-file'
+- diff_data = {}
+- expanded = discussion.expanded? || local_assigns.fetch(:expanded, nil)
+- unless expanded
+ - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) }
-.diff-file.file-holder{ class: diff_file_class }
+.diff-file.file-holder{ class: diff_file_class, data: diff_data }
.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
@@ -11,17 +15,24 @@
- if diff_file.text?
.diff-content.code.js-syntax-highlight
%table
- = render partial: "projects/diffs/line",
- collection: discussion.truncated_diff_lines,
- as: :line,
- locals: { diff_file: diff_file,
- discussions: discussions,
- discussion_expanded: true,
- plain: true }
+ - if expanded
+ - discussions = { discussion.original_line_code => [discussion] }
+ = render partial: "projects/diffs/line",
+ collection: discussion.truncated_diff_lines,
+ as: :line,
+ locals: { diff_file: diff_file,
+ discussions: discussions,
+ discussion_expanded: true,
+ plain: true }
+ - else
+ %tr.line_holder.line-holder-placeholder
+ %td.old_line.diff-line-num
+ %td.new_line.diff-line-num
+ %td.line_content
+ .js-code-placeholder
+ = render "discussions/diff_discussion", discussions: [discussion], expanded: true
- else
- partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff'
-
= render partial: "projects/diffs/#{partial}", locals: { diff_file: diff_file, position: discussion.position.to_json, click_to_comment: false }
-
.note-container
= render partial: "discussions/notes", locals: { discussion: discussion, show_toggle: false, show_image_comment_badge: true, disable_collapse_class: true }
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 205320ed87c..e9589213f80 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -3,12 +3,12 @@
.timeline-entry-inner
.timeline-icon
= link_to user_path(discussion.author) do
- = image_tag avatar_icon(discussion.author), class: "avatar s40"
+ = image_tag avatar_icon_for_user(discussion.author), class: "avatar s40"
.timeline-content
.discussion.js-toggle-container{ data: { discussion_id: discussion.id } }
.discussion-header
.discussion-actions
- %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" }
+ %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) }
- if expanded
= icon("chevron-up")
- else
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 72eab964766..6364f0be4a3 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -1,3 +1,5 @@
+- add_to_breadcrumbs "Applications", oauth_applications_path
+- breadcrumb_title @application.name
- page_title @application.name, "Applications"
- @content_class = "limit-container-width" unless fluid_layout
diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml
index 6693e6f90e8..af518b5b583 100644
--- a/app/views/email_rejection_mailer/rejection.text.haml
+++ b/app/views/email_rejection_mailer/rejection.text.haml
@@ -1,4 +1,3 @@
Unfortunately, your email message to GitLab could not be processed.
-
-
+\
= @reason
diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml
index a97cbd4d4b3..bf540439c79 100644
--- a/app/views/errors/access_denied.html.haml
+++ b/app/views/errors/access_denied.html.haml
@@ -1,3 +1,5 @@
+- message = local_assigns.fetch(:message)
+
- content_for(:title, 'Access Denied')
%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') }
%h1
@@ -5,5 +7,9 @@
.container
%h3 Access Denied
%hr
- %p You are not allowed to access this page.
- %p Read more about project permissions #{link_to "here", help_page_path("user/permissions"), class: "vlink"}
+ - if message
+ %p
+ = message
+ - else
+ %p You are not allowed to access this page.
+ %p Read more about project permissions #{link_to "here", help_page_path("user/permissions"), class: "vlink"}
diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder
index 38741fe6662..d56234e6c1a 100644
--- a/app/views/events/_event.atom.builder
+++ b/app/views/events/_event.atom.builder
@@ -10,7 +10,7 @@ xml.entry do
# eager-loaded. This allows us to re-use the user object's Email address,
# instead of having to run additional queries to figure out what Email to use
# for the avatar.
- xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author))
+ xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(event.author))
xml.author do
xml.username event.author_username
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index 91149498248..ff57b39e947 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,2 +1,4 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index 86abdf547cc..efa8b2706da 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,9 +2,6 @@
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
-= webpack_bundle_tag 'common_vue'
-= webpack_bundle_tag 'groups'
-
- if current_user
= render 'dashboard/groups_head'
- else
diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml
index 3afb6b2f849..742b40784d3 100644
--- a/app/views/groups/_children.html.haml
+++ b/app/views/groups/_children.html.haml
@@ -1,5 +1,4 @@
-= webpack_bundle_tag 'common_vue'
-= webpack_bundle_tag 'groups'
-
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/groups/boards/index.html.haml b/app/views/groups/boards/index.html.haml
new file mode 100644
index 00000000000..bb56769bd3f
--- /dev/null
+++ b/app/views/groups/boards/index.html.haml
@@ -0,0 +1 @@
+= render "shared/boards/show", board: @boards.first
diff --git a/app/views/groups/boards/show.html.haml b/app/views/groups/boards/show.html.haml
new file mode 100644
index 00000000000..92838fa4b11
--- /dev/null
+++ b/app/views/groups/boards/show.html.haml
@@ -0,0 +1 @@
+= render "shared/boards/show", board: @board, group: true
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 16038ef2f79..86cd0759a2c 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -13,13 +13,13 @@
= group_icon(@group, alt: '', class: 'avatar group-avatar s160')
%p.light
- if @group.avatar?
- You can change your group avatar here
+ You can change the group avatar here
- else
You can upload a group avatar here
= render 'shared/choose_group_avatar_button', f: f
- if @group.avatar?
%hr
- = link_to 'Remove avatar', group_avatar_path(@group.to_param), data: { confirm: "Group avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar"
+ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _("Avatar will be removed. Are you sure?")}, method: :delete, class: "btn btn-danger btn-inverted"
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
@@ -57,4 +57,20 @@
.form-actions
= button_to 'Remove group', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_group_message(@group) }
+- if supports_nested_groups?
+ .panel.panel-warning
+ .panel-heading Transfer group
+ .panel-body
+ = form_for @group, url: transfer_group_path(@group), method: :put do |f|
+ .form-group
+ = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: "Search groups", data: { data: parent_group_options(@group) } })
+ = hidden_field_tag 'new_parent_group_id'
+
+ %ul
+ %li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}.
+ %li You can only transfer the group to a group you manage.
+ %li You will need to update your local repositories to point to the new location.
+ %li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.
+ = f.submit 'Transfer group', class: "btn btn-warning"
+
= render 'shared/confirm_modal', phrase: @group.path
diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml
deleted file mode 100644
index 9d05bff6c4e..00000000000
--- a/app/views/groups/group_members/update.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-:plain
- var $listItem = $('#{escape_javascript(render('shared/members/member', member: @group_member))}');
- $("##{dom_id(@group_member)} .list-item-name").replaceWith($listItem.find('.list-item-name'));
- gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(@group_member)}"));
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 00909982d59..36df03302e8 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -1,13 +1,10 @@
- page_title "Issues"
-- group_issues_exists = group_issues(@group).exists?
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues")
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
-
-- if group_issues_exists
+- if group_issues_count(state: 'all').zero?
+ = render 'shared/empty_states/issues', project_select_button: true
+- else
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
@@ -20,5 +17,3 @@
= render 'shared/issuable/search_bar', type: :issues
= render 'shared/issues'
-- else
- = render 'shared/empty_states/issues', project_select_button: true
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index d10efdad53b..ac7e12fcd0b 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -1,8 +1,10 @@
- page_title 'Labels'
+- issuables = ['issues', 'merge requests']
+
.top-area.adjust
.nav-text
- Labels can be applied to issues and merge requests. Group labels are available for any project within the group.
+ = _("Labels can be applied to %{features}. Group labels are available for any project within the group.") % { features: issuables.to_sentence }
.nav-controls
- if can?(current_user, :admin_label, @group)
@@ -16,4 +18,4 @@
= paginate @labels, theme: 'gitlab'
- else
.nothing-here-block
- No labels created yet.
+ = _("No labels created yet.")
diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml
index ae240490bbd..538c353cf2d 100644
--- a/app/views/groups/labels/new.html.haml
+++ b/app/views/groups/labels/new.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title "Labels"
- page_title 'New Label'
-- header_title group_title(@group, 'Labels', group_labels_path(@group))
%h3.page-title
New Label
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index 694292aa7c1..4ccd16f3e11 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -1,10 +1,6 @@
- page_title "Merge Requests"
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
-
-- if @group_merge_requests.empty?
+- if group_merge_requests_count(state: 'all').zero?
= render 'shared/empty_states/merge_requests', project_select_button: true
- else
.top-area
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 8d2bc810a7d..ef181b425bc 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -14,7 +14,7 @@
.list-item-name
%span{ class: visibility_level_color(project.visibility_level) }
= visibility_level_icon(project.visibility_level)
- %strong= link_to project.name_with_namespace, project
+ %strong= link_to project.full_name, project
.pull-right
- if project.archived
%span.label.label-warning archived
diff --git a/app/views/groups/settings/badges/index.html.haml b/app/views/groups/settings/badges/index.html.haml
new file mode 100644
index 00000000000..c7afb25d0f8
--- /dev/null
+++ b/app/views/groups/settings/badges/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Project Badges')
+- page_title _('Project Badges')
+
+= render 'shared/badges/badge_settings'
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 472da2a6a72..dd82922ec55 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -1,4 +1,11 @@
- breadcrumb_title "CI / CD Settings"
- page_title "CI / CD"
-= render 'ci/variables/index'
+%h4
+ = _('Secret variables')
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer'
+
+%p
+ = render "ci/variables/content"
+
+= render 'ci/variables/index', save_endpoint: group_variables_path
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 7f9486d08d9..8e1dea4afc1 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,5 +1,5 @@
- @no_container = true
-- breadcrumb_title "Details"
+- breadcrumb_title _("Details")
- can_create_subgroups = can?(current_user, :create_subgroup, @group)
= content_for :meta_tags do
diff --git a/app/views/groups/variables/show.html.haml b/app/views/groups/variables/show.html.haml
deleted file mode 100644
index df533952b76..00000000000
--- a/app/views/groups/variables/show.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'ci/variables/show'
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index b8692009225..bf2725dc328 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -1,19 +1,18 @@
-= webpack_bundle_tag 'docs'
-
%div
-- if current_application_settings.help_page_text.present?
- = markdown_field(current_application_settings, :help_page_text)
+- if Gitlab::CurrentSettings.help_page_text.present?
+ = markdown_field(Gitlab::CurrentSettings.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= link_to Gitlab::REVISION, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', 'gitlab-ce', Gitlab::REVISION)
- = version_status_badge
+%h1
+ GitLab
+ Community Edition
+ - if user_signed_in?
+ %span= Gitlab::VERSION
+ %small= link_to Gitlab::REVISION, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', 'gitlab-ce', Gitlab::REVISION)
+ = version_status_badge
+ %hr
+- unless Gitlab::CurrentSettings.help_page_hide_commercial_content?
%p.slead
GitLab is open source software to collaborate on code.
%br
@@ -45,6 +44,6 @@
%li
%button.btn-blank.btn-link.js-trigger-shortcut{ type: 'button' }
Use shortcuts
- - unless current_application_settings.help_page_hide_commercial_content?
+ - unless Gitlab::CurrentSettings.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 d6789baea28..c07c148a12a 100644
--- a/app/views/help/show.html.haml
+++ b/app/views/help/show.html.haml
@@ -1,5 +1,3 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'help'
- page_title @path.split("/").reverse.map(&:humanize)
.documentation.wiki.prepend-top-default
= markdown @markdown
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 445f0dffbcc..ce09b44fbb2 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -1,7 +1,5 @@
- 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
@@ -68,7 +66,7 @@
.example
.cover-block
.avatar-holder
- = image_tag avatar_icon('admin@example.com', 90), class: "avatar s90", alt: ''
+ = image_tag avatar_icon_for_email('admin@example.com', 90), class: "avatar s90", alt: ''
.cover-title
John Smith
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
index 8368e7a4563..e0e8fe548d0 100644
--- a/app/views/ide/index.html.haml
+++ b/app/views/ide/index.html.haml
@@ -1,12 +1,12 @@
+- @body_class = 'ide'
- page_title 'IDE'
- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'ide'
+ = webpack_bundle_tag 'ide', force_same_domain: true
-.ide-flash-container.flash-container
-
-#ide.ide-loading
+#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
+ "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
+ "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } }
.text-center
= icon('spinner spin 2x')
- %h2.clgray= _('IDE Loading ...')
+ %h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index e9a04e6c122..638c8b5a672 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -2,11 +2,11 @@
- provider_title = Gitlab::ImportSources.title(provider)
%p.light
- Select projects you want to import.
+ = import_githubish_choose_repository_message
%hr
%p
= button_tag class: "btn btn-import btn-success js-import-all" do
- Import all projects
+ = import_all_githubish_repositories_button_label
= icon("spinner spin", class: "loading-icon")
.table-responsive
@@ -16,9 +16,9 @@
%colgroup.import-jobs-status-col
%thead
%tr
- %th From #{provider_title}
- %th To GitLab
- %th Status
+ %th= _('From %{provider_title}') % { provider_title: provider_title }
+ %th= _('To GitLab')
+ %th= _('Status')
%tbody
- @already_added_projects.each do |project|
%tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
@@ -30,10 +30,12 @@
- if project.import_status == 'finished'
%span
%i.fa.fa-check
- done
+ = _('Done')
- elsif project.import_status == 'started'
%i.fa.fa-spinner.fa-spin
- started
+ = _('Started')
+ - elsif project.import_status == 'failed'
+ = _('Failed')
- else
= project.human_import_status_name
@@ -55,7 +57,9 @@
= text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
%td.import-actions.job-status
= button_tag class: "btn btn-import js-add-to-import" do
- Import
+ = has_ci_cd_only_params? ? _('Connect') : _('Import')
= icon("spinner spin", class: "loading-icon")
-.js-importer-status{ data: { jobs_import_path: "#{url_for([:jobs, :import, provider])}", import_path: "#{url_for([:import, provider])}" } }
+.js-importer-status{ data: { jobs_import_path: "#{url_for([:jobs, :import, provider])}",
+ import_path: "#{url_for([:import, provider])}",
+ ci_cd_only: "#{has_ci_cd_only_params?}" } }
diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml
deleted file mode 100644
index 4dc3a4a0acf..00000000000
--- a/app/views/import/base/create.js.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-- if @project.persisted?
- :plain
- job = $("tr#repo_#{@repo_id}")
- job.attr("id", "project_#{@project.id}")
- target_field = job.find(".import-target")
- target_field.empty()
- 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(h(@project.errors.full_messages.join(',')))}")
diff --git a/app/views/import/base/unauthorized.js.haml b/app/views/import/base/unauthorized.js.haml
deleted file mode 100644
index ada5f99f4e2..00000000000
--- a/app/views/import/base/unauthorized.js.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-:plain
- tr = $("tr#repo_#{@repo_id}")
- target_field = tr.find(".import-target")
- import_button = tr.find(".btn-import")
- origin_target = target_field.text()
- project_name = "#{@project_name}"
- origin_namespace = "#{@target_namespace.full_path}"
- target_field.empty()
- target_field.append("<p class='alert alert-danger'>This namespace has already been taken! Please choose another one.</p>")
- target_field.append("<input type='text' name='target_namespace' />")
- target_field.append("/" + project_name)
- target_field.data("project_name", project_name)
- target_field.find('input').prop("value", origin_namespace)
- import_button.enable().removeClass('is-loading')
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 9c2da3a3eec..c63cf2b31cb 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -1,43 +1,28 @@
-- page_title "GitHub Import"
+- title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import')
+- page_title title
+- breadcrumb_title title
- header_title "Projects", root_path
%h3.page-title
- = icon 'github', text: 'Import Projects from GitHub'
+ = icon 'github', text: import_github_title
- if github_import_configured?
%p
- To import a GitHub project, you first need to authorize GitLab to access
- the list of your GitHub repositories:
+ = import_github_authorize_message
- = link_to 'List your GitHub repositories', status_import_github_path, class: 'btn btn-success'
+ = link_to _('List your GitHub repositories'), status_import_github_path(ci_cd_only: params[:ci_cd_only]), class: 'btn btn-success'
%hr
%p
- - if github_import_configured?
- Alternatively,
- - else
- To import a GitHub project,
- you can use a
- = succeed '.' do
- = link_to 'Personal Access Token', 'https://github.com/settings/tokens'
- When you create your Personal Access Token,
- you will need to select the <code>repo</code> scope, so we can display a
- list of your public and private repositories which are available for import.
+ = import_github_personal_access_token_message
= form_tag personal_access_token_import_github_path, method: :post, class: 'form-inline' do
.form-group
- = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: "Personal Access Token", size: 40
- = submit_tag 'List your GitHub repositories', class: 'btn btn-success'
+ = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('Personal Access Token'), size: 40
+ = submit_tag _('List your GitHub repositories'), class: 'btn btn-success'
- unless github_import_configured?
%hr
%p
- Note:
- - if current_user.admin?
- As an administrator you may like to configure
- - else
- Consider asking your GitLab administrator to configure
- = link_to 'GitHub integration', help_page_path("integration/github")
- which will allow login via GitHub and allow importing projects without
- generating a Personal Access Token.
+ = import_configure_github_admin_message
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index 0fe578a0036..b00b972d9c9 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -1,6 +1,8 @@
-- page_title "GitHub Import"
+- title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import')
+- page_title title
+- breadcrumb_title title
- header_title "Projects", root_path
%h3.page-title
- = icon 'github', text: 'Import Projects from GitHub'
+ = icon 'github', text: import_github_title
= render 'import/githubish_status', provider: 'github'
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index 5d68e1e2156..dec85368d10 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -1,7 +1,5 @@
- 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')
@@ -15,13 +13,13 @@
.form-group
.input-group
- if current_user.can_select_namespace?
- .input-group-addon
+ .input-group-addon.has-tooltip{ title: root_url }
= 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
- else
- .input-group-addon.static-namespace
- #{root_url}#{current_user.username}/
+ .input-group-addon.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
+ #{user_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'
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index ad6213b4efd..c2bb1216c5f 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, project_url(project)
+ = link_to project.full_name, 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 0c113c08526..21cf6d0dd65 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -3,7 +3,7 @@ xml.entry do
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))
+ xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(issue.author))
xml.author do
xml.name issue.author_name
diff --git a/app/views/koding/index.html.haml b/app/views/koding/index.html.haml
index 04e2d4b63e6..bb7f9ba7ae4 100644
--- a/app/views/koding/index.html.haml
+++ b/app/views/koding/index.html.haml
@@ -3,4 +3,4 @@
= icon('circle', class: 'cgreen')
Integration is active for
= link_to koding_project_url, target: '_blank', rel: 'noopener noreferrer' do
- #{current_application_settings.koding_url}
+ #{Gitlab::CurrentSettings.koding_url}
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 1597621fa78..b981b5fdafa 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -41,13 +41,13 @@
= webpack_bundle_tag "webpack_runtime"
= webpack_bundle_tag "common"
= 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?
+ = webpack_bundle_tag "raven" if Gitlab::CurrentSettings.clientside_sentry_enabled
- if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts
+ = webpack_controller_bundle_tags
+
= yield :project_javascripts
= csrf_meta_tags
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
index b50537438a9..ddc1cdb24b5 100644
--- a/app/views/layouts/_mailer.html.haml
+++ b/app/views/layouts/_mailer.html.haml
@@ -67,12 +67,8 @@
%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
+ - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;")
+ - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;")
+ = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
= yield :additional_footer
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index eba9cd253bb..f67a8878c80 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -1,11 +1,12 @@
.layout-page{ class: page_with_sidebar_class }
- if defined?(nav) && nav
= render "layouts/nav/sidebar/#{nav}"
- .content-wrapper
+ .content-wrapper{ class: "#{@content_wrapper_class}" }
= render 'shared/outdated_browser'
.mobile-overlay
.alert-wrapper
= render "layouts/broadcast"
+ = render 'layouts/header/read_only_banner'
= yield :flash_message
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 691d2528022..6513b719199 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,7 +1,7 @@
!!! 5
-%html.devise-layout-html
+%html.devise-layout-html{ class: system_message_class }
= render "layouts/head"
- %body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } }
+ %body.ui_indigo.login-page.application.navless{ data: { page: body_data_page } }
.page-wrap
= render "layouts/header/empty"
.login-page-broadcast
@@ -15,8 +15,8 @@
.col-sm-7.brand-holder.pull-left
%h1
= brand_title
- = brand_image
- - if brand_item&.description?
+ = brand_image
+ - if current_appearance&.description?
= brand_text
- else
%h3 Open source software to collaborate on code
@@ -26,8 +26,8 @@
Perform code reviews and enhance collaboration with merge requests.
Each project can also have an issue tracker and a wiki.
- - if current_application_settings.sign_in_text.present?
- = markdown_field(current_application_settings, :sign_in_text)
+ - if Gitlab::CurrentSettings.sign_in_text.present?
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
%hr.footer-fixed
.container.footer-container
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index ed6731bde95..adf90cb7667 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -1,7 +1,7 @@
!!! 5
-%html{ lang: "en" }
+%html{ lang: "en", class: system_message_class }
= render "layouts/head"
- %body.ui_charcoal.login-page.application.navless
+ %body.ui_indigo.login-page.application.navless
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 99e7f3b568d..e6238c0dddb 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,4 +1,4 @@
-%header.navbar.navbar-gitlab
+%header.navbar.navbar-gitlab.qa-navbar
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
@@ -6,8 +6,10 @@
%h1.title
= link_to root_path, title: 'Dashboard', id: 'logo' do
= brand_header_logo
- %span.logo-text.hidden-xs
- = brand_header_logo_type
+ - logo_text = brand_header_logo_type
+ - if logo_text.present?
+ %span.logo-text.hidden-xs
+ = logo_text
- if current_user
= render "layouts/nav/dashboard"
@@ -18,32 +20,37 @@
%ul.nav.navbar-nav
- if current_user
= render 'layouts/header/new_dropdown'
- %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
- = sprite_icon('search', size: 16)
- - if current_user
+ - if header_link?(:search)
+ %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
+ = sprite_icon('search', size: 16)
+
+ - if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
= link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('issues', size: 16)
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
+ - if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('git-merge', size: 16)
- 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)
+ - if header_link?(:todos)
= nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do
= link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('todo-done', size: 16)
%span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
+ - if header_link?(:user_dropdown)
%li.header-user.dropdown
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
- = image_tag avatar_icon(current_user, 23), width: 23, height: 23, class: "header-user-avatar"
+ = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar"
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
@@ -62,11 +69,11 @@
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, class: "sign-out-link"
- - if session[:impersonator_id]
- %li.impersonation
- = link_to admin_impersonation_path, class: 'impersonation-btn', method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
- = icon('user-secret')
- - else
+ - if header_link?(:admin_impersonation)
+ %li.impersonation
+ = link_to admin_impersonation_path, class: 'impersonation-btn', method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
+ = icon('user-secret')
+ - if header_link?(:sign_in)
%li
%div
= link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in'
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 088f2785092..eb32f393310 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -1,5 +1,5 @@
%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
+ = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
= sprite_icon('plus-square', size: 16)
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
diff --git a/app/views/layouts/header/_read_only_banner.html.haml b/app/views/layouts/header/_read_only_banner.html.haml
new file mode 100644
index 00000000000..f3d563c362f
--- /dev/null
+++ b/app/views/layouts/header/_read_only_banner.html.haml
@@ -0,0 +1,7 @@
+- message = read_only_message
+- if message
+ .flash-container.flash-container-page
+ .flash-notice
+ %div{ class: (container_class) }
+ %span
+ = message
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index e0d8d9cb402..f773bd0832d 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -1,59 +1,76 @@
%ul.list-unstyled.navbar-sub-nav
- = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects" }) do
- %a{ href: "#", data: { toggle: "dropdown" } }
- Projects
- = sprite_icon('angle-down', css_class: 'caret-down')
- .dropdown-menu.projects-dropdown-menu
- = render "layouts/nav/projects_dropdown/show"
-
- = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do
- = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
- Groups
-
- = nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do
- = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
- Activity
-
- = nav_link(controller: 'dashboard/milestones', html_options: { class: "visible-lg" }) do
- = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
- Milestones
-
- = nav_link(controller: 'dashboard/snippets', html_options: { class: "visible-lg" }) do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
- Snippets
-
- %li.header-more.dropdown.hidden-lg
- %a{ href: "#", data: { toggle: "dropdown" } }
- More
- = sprite_icon('angle-down', css_class: 'caret-down')
- .dropdown-menu
- %ul
- = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "visible-xs" }) do
- = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
- Groups
-
- = nav_link(path: 'dashboard#activity') 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
+ - if dashboard_nav_link?(:projects)
+ = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do
+ %a{ href: "#", data: { toggle: "dropdown" } }
+ Projects
+ = sprite_icon('angle-down', css_class: 'caret-down')
+ .dropdown-menu.projects-dropdown-menu
+ = render "layouts/nav/projects_dropdown/show"
+
+ - if dashboard_nav_link?(:groups)
+ = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do
+ = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do
+ Groups
+
+ - if dashboard_nav_link?(:activity)
+ = nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do
+ = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
+ Activity
+
+ - if dashboard_nav_link?(:milestones)
+ = nav_link(controller: 'dashboard/milestones', html_options: { class: "visible-lg" }) do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
+ Milestones
+
+ - if dashboard_nav_link?(:snippets)
+ = nav_link(controller: 'dashboard/snippets', html_options: { class: "visible-lg" }) do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
+ Snippets
+
+ - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
+ %li.header-more.dropdown.hidden-lg
+ %a{ href: "#", data: { toggle: "dropdown" } }
+ More
+ = sprite_icon('angle-down', css_class: 'caret-down')
+ .dropdown-menu
+ %ul
+ - if dashboard_nav_link?(:groups)
+ = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "visible-xs" }) do
+ = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
+ Groups
+
+ - if dashboard_nav_link?(:activity)
+ = nav_link(path: 'dashboard#activity') do
+ = link_to activity_dashboard_path, title: 'Activity' do
+ Activity
+
+ - if dashboard_nav_link?(:milestones)
+ = nav_link(controller: 'dashboard/milestones') do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
+ Milestones
+
+ - if dashboard_nav_link?(:snippets)
+ = nav_link(controller: 'dashboard/snippets') do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
+ Snippets
-# Shortcut to Dashboard > Projects
- %li.hidden
- = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
- Projects
+ - if dashboard_nav_link?(:projects)
+ %li.hidden
+ = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ Projects
+
+ - if current_controller?('ide')
+ %li.line-separator.hidden-xs
+ = nav_link(controller: 'ide') do
+ = link_to '#', class: 'dashboard-shortcuts-web-ide', title: 'Web IDE' do
+ Web IDE
- if current_user.admin? || Gitlab::Sherlock.enabled?
%li.line-separator.hidden-xs
- if current_user.admin?
= nav_link(controller: 'admin/dashboard') do
- = link_to admin_root_path, class: 'admin-icon', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin', size: 18)
- if Gitlab::Sherlock.enabled?
%li
diff --git a/app/views/layouts/nav/_explore.html.haml b/app/views/layouts/nav/_explore.html.haml
index cd1c39f3226..50bde9d1754 100644
--- a/app/views/layouts/nav/_explore.html.haml
+++ b/app/views/layouts/nav/_explore.html.haml
@@ -1,12 +1,15 @@
%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
- = nav_link(controller: :snippets) do
- = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
- Snippets
+ - if explore_nav_link?(:projects)
+ = 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
+ - if explore_nav_link?(:groups)
+ = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
+ = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
+ Groups
+ - if explore_nav_link?(:snippets)
+ = nav_link(controller: :snippets) do
+ = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
+ Snippets
%li
= link_to "Help", help_path, title: 'About GitLab CE'
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
index 32a24c101fc..5809d6f7fea 100644
--- a/app/views/layouts/nav/projects_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -1,9 +1,9 @@
-- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted?
+- project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted?
.projects-dropdown-container
- .project-dropdown-sidebar
+ .project-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do
- = link_to dashboard_projects_path do
+ = link_to dashboard_projects_path, class: 'qa-your-projects-link' do
= _('Your projects')
= nav_link(path: 'projects#starred') do
= link_to starred_dashboard_projects_path do
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index cb8db306b56..dd086f70641 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -130,7 +130,7 @@
%span.badge.count= number_with_delimiter(AbuseReport.count(:all))
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :abuse_reports, html_options: { class: "fly-out-top-item" } ) do
- = link_to admin_broadcast_messages_path do
+ = link_to admin_abuse_reports_path do
%strong.fly-out-top-item-name
#{ _('Abuse Reports') }
%span.badge.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(AbuseReport.count(:all))
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 0c27b09f7b1..517d9aa3d99 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,5 +1,6 @@
-- issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
-- merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
+- issues_count = group_issues_count(state: 'opened')
+- merge_requests_count = group_merge_requests_count(state: 'opened')
+- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show']
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
@@ -10,92 +11,108 @@
.sidebar-context-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) do
- .nav-icon-container
- = sprite_icon('project')
- %span.nav-item-name
- Overview
-
- %ul.sidebar-sub-level-items
- = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
- = link_to group_path(@group) do
- %strong.fly-out-top-item-name
- #{ _('Overview') }
- %li.divider.fly-out-top-item
- = 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) do
- .nav-icon-container
- = sprite_icon('issues')
- %span.nav-item-name
- Issues
- %span.badge.count= number_with_delimiter(issues.count)
-
- %ul.sidebar-sub-level-items
- = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index'], html_options: { class: "fly-out-top-item" } ) do
- = link_to issues_group_path(@group) do
- %strong.fly-out-top-item-name
- #{ _('Issues') }
- %span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues.count)
- %li.divider.fly-out-top-item
- = 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) do
- .nav-icon-container
- = sprite_icon('git-merge')
- %span.nav-item-name
- Merge Requests
- %span.badge.count= number_with_delimiter(merge_requests.count)
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: 'groups#merge_requests', html_options: { class: "fly-out-top-item" } ) do
- = link_to merge_requests_group_path(@group) do
- %strong.fly-out-top-item-name
- #{ _('Merge Requests') }
- %span.badge.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests.count)
- = nav_link(path: 'group_members#index') do
- = link_to group_group_members_path(@group) do
- .nav-icon-container
- = sprite_icon('users')
- %span.nav-item-name
- Members
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do
- = link_to group_group_members_path(@group) do
- %strong.fly-out-top-item-name
- #{ _('Members') }
- - if current_user && can?(current_user, :admin_group, @group)
- = nav_link(path: %w[groups#projects groups#edit ci_cd#show]) do
+ - if group_sidebar_link?(:overview)
+ = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups', 'analytics#show'], html_options: { class: 'home' }) do
+ = link_to group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('project')
+ %span.nav-item-name
+ Overview
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
+ = link_to group_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Overview') }
+ %li.divider.fly-out-top-item
+ = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do
+ = link_to group_path(@group), title: 'Group details' do
+ %span
+ Details
+
+ - if group_sidebar_link?(:activity)
+ = nav_link(path: 'groups#activity') do
+ = link_to activity_group_path(@group), title: 'Activity' do
+ %span
+ Activity
+
+ - if group_sidebar_link?(:issues)
+ = nav_link(path: issues_sub_menu_items) do
+ = link_to issues_group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('issues')
+ %span.nav-item-name
+ Issues
+ %span.badge.count= number_with_delimiter(issues_count)
+
+ %ul.sidebar-sub-level-items
+ = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index'], html_options: { class: "fly-out-top-item" } ) do
+ = link_to issues_group_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Issues') }
+ %span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count)
+
+ %li.divider.fly-out-top-item
+ = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
+ = link_to issues_group_path(@group), title: 'List' do
+ %span
+ List
+
+ - if group_sidebar_link?(:boards)
+ = nav_link(path: ['boards#index', 'boards#show']) do
+ = link_to group_boards_path(@group), title: boards_link_text do
+ %span
+ = boards_link_text
+
+ - if group_sidebar_link?(:labels)
+ = nav_link(path: 'labels#index') do
+ = link_to group_labels_path(@group), title: 'Labels' do
+ %span
+ Labels
+
+ - if group_sidebar_link?(:milestones)
+ = nav_link(path: 'milestones#index') do
+ = link_to group_milestones_path(@group), title: 'Milestones' do
+ %span
+ Milestones
+
+ - if group_sidebar_link?(:merge_requests)
+ = nav_link(path: 'groups#merge_requests') do
+ = link_to merge_requests_group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('git-merge')
+ %span.nav-item-name
+ Merge Requests
+ %span.badge.count= number_with_delimiter(merge_requests_count)
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'groups#merge_requests', html_options: { class: "fly-out-top-item" } ) do
+ = link_to merge_requests_group_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Merge Requests') }
+ %span.badge.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests_count)
+
+ - if group_sidebar_link?(:group_members)
+ = nav_link(path: 'group_members#index') do
+ = link_to group_group_members_path(@group) do
+ .nav-icon-container
+ = sprite_icon('users')
+ %span.nav-item-name
+ Members
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do
+ = link_to group_group_members_path(@group) do
+ %strong.fly-out-top-item-name
+ #{ _('Members') }
+
+ - if group_sidebar_link?(:settings)
+ = nav_link(path: group_nav_link_paths) do
= link_to edit_group_path(@group) do
.nav-icon-container
= sprite_icon('settings')
%span.nav-item-name
Settings
%ul.sidebar-sub-level-items
- = nav_link(path: %w[groups#projects groups#edit ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
= link_to edit_group_path(@group) do
%strong.fly-out-top-item-name
#{ _('Settings') }
@@ -105,6 +122,12 @@
%span
General
+ = nav_link(controller: :badges) do
+ = link_to group_settings_badges_path(@group), title: _('Project Badges') do
+ %span
+ = _('Project Badges')
+
+
= nav_link(path: 'groups#projects') do
= link_to projects_group_path(@group), title: 'Projects' do
%span
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index a5a62a0695f..c878fcf2808 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -28,7 +28,7 @@
= link_to profile_account_path do
%strong.fly-out-top-item-name
#{ _('Account') }
- - if current_application_settings.user_oauth_applications?
+ - if Gitlab::CurrentSettings.user_oauth_applications?
= nav_link(controller: 'oauth/applications') do
= link_to applications_profile_path do
.nav-icon-container
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index be39f577ba7..93f674b9d3c 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -80,14 +80,6 @@
= 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), class: 'shortcuts-container-registry' do
- .nav-icon-container
- = sprite_icon('disk')
- %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), class: 'shortcuts-issues' do
@@ -127,6 +119,19 @@
= link_to project_milestones_path(@project), title: 'Milestones' do
%span
Milestones
+ - if project_nav_tab? :external_issue_tracker
+ = nav_link do
+ - issue_tracker = @project.external_issue_tracker
+ = link_to issue_tracker.issue_tracker_path, class: 'shortcuts-external_tracker' do
+ .nav-icon-container
+ = sprite_icon('issue-external')
+ %span.nav-item-name
+ = issue_tracker.title
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: "fly-out-top-item" } ) do
+ = link_to issue_tracker.issue_tracker_path do
+ %strong.fly-out-top-item-name
+ = issue_tracker.title
- if project_nav_tab? :merge_requests
= nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
@@ -184,10 +189,33 @@
Environments
- if project_nav_tab? :clusters
+ - show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do
- = link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do
+ = link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-cluster' do
%span
- Clusters
+ = _('Kubernetes')
+ - if show_cluster_hint
+ .feature-highlight.js-feature-highlight{ disabled: true,
+ data: { trigger: 'manual',
+ container: 'body',
+ toggle: 'popover',
+ placement: 'right',
+ highlight: UserCalloutsHelper::GKE_CLUSTER_INTEGRATION,
+ highlight_priority: UserCallout.feature_names[:GKE_CLUSTER_INTEGRATION],
+ dismiss_endpoint: user_callouts_path } }
+ - if show_cluster_hint
+ .feature-highlight-popover-content
+ = image_tag 'illustrations/cluster_popover.svg', class: 'feature-highlight-illustration'
+ .feature-highlight-popover-sub-content
+ %p= _('Allows you to add and manage Kubernetes clusters.')
+ %p
+ = _('Protip:')
+ = link_to 'Auto DevOps', help_page_path('topics/autodevops/index.md')
+ %span= _('uses Kubernetes clusters to deploy your code!')
+ %hr
+ %button.btn.btn-create.btn-xs.dismiss-feature-highlight{ type: 'button' }
+ %span= _("Got it!")
+ = sprite_icon('thumb-up')
- if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
= nav_link(path: 'pipelines#charts') do
@@ -195,6 +223,14 @@
%span
Charts
+ - if project_nav_tab? :container_registry
+ = nav_link(controller: %w[projects/registry/repositories]) do
+ = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do
+ .nav-icon-container
+ = sprite_icon('disk')
+ %span.nav-item-name
+ Registry
+
- if project_nav_tab? :wiki
= nav_link(controller: :wikis) do
= link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do
@@ -222,17 +258,17 @@
#{ _('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
+ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show]) do
= link_to edit_project_path(@project), class: 'shortcuts-tree' do
.nav-icon-container
= sprite_icon('settings')
- %span.nav-item-name
+ %span.nav-item-name.qa-settings-item
Settings
%ul.sidebar-sub-level-items
- can_edit = can?(current_user, :admin_project, @project)
- if can_edit
- = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show], html_options: { class: "fly-out-top-item" } ) do
= link_to edit_project_path(@project) do
%strong.fly-out-top-item-name
#{ _('Settings') }
@@ -246,6 +282,11 @@
%span
Members
- if can_edit
+ = nav_link(controller: :badges) do
+ = link_to project_settings_badges_path(@project), title: _('Badges') do
+ %span
+ = _('Badges')
+ - if can_edit
= nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
= link_to project_settings_integrations_path(@project), title: 'Integrations' do
%span
@@ -299,9 +340,10 @@
Charts
-# Shortcut to Issues > New Issue
- %li.hidden
- = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
- Create a new issue
+ - if project_nav_tab?(:issues)
+ %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
@@ -316,5 +358,6 @@
Commits
-# Shortcut to issue boards
- %li.hidden
- = link_to 'Issue Boards', project_boards_path(@project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
+ - if project_nav_tab?(:issues)
+ %li.hidden
+ = link_to 'Issue Boards', project_boards_path(@project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
diff --git a/app/views/layouts/nav_only.html.haml b/app/views/layouts/nav_only.html.haml
index 6fa4b39dc10..0811211f7b2 100644
--- a/app/views/layouts/nav_only.html.haml
+++ b/app/views/layouts/nav_only.html.haml
@@ -1,7 +1,7 @@
!!! 5
%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
- %body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page } }
+ %body{ class: "#{user_application_theme} #{@body_class} nav-only", data: { page: body_data_page } }
= render 'peek/bar'
= render "layouts/header/default"
= render 'shared/outdated_browser'
@@ -10,4 +10,5 @@
= render "layouts/broadcast"
= yield :flash_message
= render "layouts/flash"
- = yield
+ .content{ id: "content-body" }
+ = yield
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index 40bf45cece7..ab8b1271212 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -20,7 +20,7 @@
#{link_to "View it on GitLab", @target_url}.
%br
-# Don't link the host in the line below, one link in the email is easier to quickly click than two.
- You're receiving this email because of your account on #{Gitlab.config.gitlab.host}.
+ You're receiving this email because #{notification_reason_text(@reason)}.
If you'd like to receive fewer emails, you can
- if @labels_url
adjust your #{link_to 'label subscriptions', @labels_url}.
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
index b4ce02eead8..de48f548a1b 100644
--- a/app/views/layouts/notify.text.erb
+++ b/app/views/layouts/notify.text.erb
@@ -9,4 +9,4 @@
<% end -%>
<% end -%>
-You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>.
+<%= "You're receiving this email because #{notification_reason_text(@reason)}." %>
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 6b847fb4b7c..6b51483810e 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -1,4 +1,4 @@
-- page_title @project.name_with_namespace
+- page_title @project.full_name
- page_description @project.description unless page_description
- header_title project_title(@project) unless header_title
- nav "project"
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index 3e36da31ea3..94bd6f96dbc 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -22,7 +22,7 @@
- else
commented on a #{link_to 'discussion', @target_url}
-- elsif current_application_settings.email_author_in_body
+- elsif Gitlab::CurrentSettings.email_author_in_body
%p.details
#{link_to @note.author_name, user_url(@note.author)} commented:
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index cb2e7fab6d5..c319cb55e87 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -12,7 +12,7 @@
<%= ":" -%>
-<% elsif current_application_settings.email_author_in_body -%>
+<% elsif Gitlab::CurrentSettings.email_author_in_body -%>
<%= "#{@note.author_name} commented:" -%>
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index eb5157ccac9..e6cdaf85c0d 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -1,4 +1,4 @@
-- if current_application_settings.email_author_in_body
+- if Gitlab::CurrentSettings.email_author_in_body
%p.details
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 951c96bdb9c..0a9adc6f243 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -1,4 +1,4 @@
-- if current_application_settings.email_author_in_body
+- if Gitlab::CurrentSettings.email_author_in_body
%p.details
#{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request:
diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml
index 00e1b5faae3..db4424a01f9 100644
--- a/app/views/notify/new_user_email.html.haml
+++ b/app/views/notify/new_user_email.html.haml
@@ -1,7 +1,7 @@
%p
Hi #{@user['name']}!
%p
- - if current_application_settings.allow_signup?
+ - if Gitlab::CurrentSettings.allow_signup?
Your account has been created successfully.
- else
The Administrator created an account for you. Now you are a member of the company GitLab application.
diff --git a/app/views/notify/pages_domain_disabled_email.html.haml b/app/views/notify/pages_domain_disabled_email.html.haml
new file mode 100644
index 00000000000..34ce4238a12
--- /dev/null
+++ b/app/views/notify/pages_domain_disabled_email.html.haml
@@ -0,0 +1,15 @@
+%p
+ Following a verification check, your GitLab Pages custom domain has been
+ %strong disabled.
+ This means that your content is no longer visible at #{link_to @domain.url, @domain.url}
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ If this domain has been disabled in error, please follow
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ to verify and re-enable your domain.
+%p
+ If you no longer wish to use this domain with GitLab Pages, please remove it
+ from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_disabled_email.text.haml b/app/views/notify/pages_domain_disabled_email.text.haml
new file mode 100644
index 00000000000..4e81b054b1f
--- /dev/null
+++ b/app/views/notify/pages_domain_disabled_email.text.haml
@@ -0,0 +1,13 @@
+Following a verification check, your GitLab Pages custom domain has been
+**disabled**. This means that your content is no longer visible at #{@domain.url}
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+If this domain has been disabled in error, please follow these instructions
+to verify and re-enable your domain:
+
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+
+If you no longer wish to use this domain with GitLab Pages, please remove it
+from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_enabled_email.html.haml b/app/views/notify/pages_domain_enabled_email.html.haml
new file mode 100644
index 00000000000..db09e503f65
--- /dev/null
+++ b/app/views/notify/pages_domain_enabled_email.html.haml
@@ -0,0 +1,11 @@
+%p
+ Following a verification check, your GitLab Pages custom domain has been
+ enabled. You should now be able to view your content at #{link_to @domain.url, @domain.url}
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ Please visit
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ for more information about custom domain verification.
diff --git a/app/views/notify/pages_domain_enabled_email.text.haml b/app/views/notify/pages_domain_enabled_email.text.haml
new file mode 100644
index 00000000000..1ed1dbb8315
--- /dev/null
+++ b/app/views/notify/pages_domain_enabled_email.text.haml
@@ -0,0 +1,9 @@
+Following a verification check, your GitLab Pages custom domain has been
+enabled. You should now be able to view your content at #{@domain.url}
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+Please visit
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+for more information about custom domain verification.
diff --git a/app/views/notify/pages_domain_verification_failed_email.html.haml b/app/views/notify/pages_domain_verification_failed_email.html.haml
new file mode 100644
index 00000000000..0bb0eb09fd5
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_failed_email.html.haml
@@ -0,0 +1,17 @@
+%p
+ Verification has failed for one of your GitLab Pages custom domains!
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ Unless you take action, it will be disabled on
+ %strong= @domain.enabled_until.strftime('%F %T.')
+ Until then, you can view your content at #{link_to @domain.url, @domain.url}
+%p
+ Please visit
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ for more information about custom domain verification.
+%p
+ If you no longer wish to use this domain with GitLab Pages, please remove it
+ from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_verification_failed_email.text.haml b/app/views/notify/pages_domain_verification_failed_email.text.haml
new file mode 100644
index 00000000000..c14e0e0c24d
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_failed_email.text.haml
@@ -0,0 +1,14 @@
+Verification has failed for one of your GitLab Pages custom domains!
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+Unless you take action, it will be disabled on *#{@domain.enabled_until.strftime('%F %T')}*.
+Until then, you can view your content at #{@domain.url}
+
+Please visit
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+for more information about custom domain verification.
+
+If you no longer wish to use this domain with GitLab Pages, please remove it
+from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/pages_domain_verification_succeeded_email.html.haml b/app/views/notify/pages_domain_verification_succeeded_email.html.haml
new file mode 100644
index 00000000000..2ead3187b10
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_succeeded_email.html.haml
@@ -0,0 +1,13 @@
+%p
+ One of your GitLab Pages custom domains has been successfully verified!
+%p
+ Project: #{link_to @project.human_name, project_url(@project)}
+%p
+ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
+%p
+ This is a notification. No action is required on your part. You can view your
+ content at #{link_to @domain.url, @domain.url}
+%p
+ Please visit
+ = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ for more information about custom domain verification.
diff --git a/app/views/notify/pages_domain_verification_succeeded_email.text.haml b/app/views/notify/pages_domain_verification_succeeded_email.text.haml
new file mode 100644
index 00000000000..e7cdbdee420
--- /dev/null
+++ b/app/views/notify/pages_domain_verification_succeeded_email.text.haml
@@ -0,0 +1,10 @@
+One of your GitLab Pages custom domains has been successfully verified!
+
+Project: #{@project.human_name} (#{project_url(@project)})
+Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
+
+No action is required on your part. You can view your content at #{@domain.url}
+
+Please visit
+= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+for more information about custom domain verification.
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index 8eb3f2d5192..38dab104eb5 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, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.author, commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.committer, commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
+ %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml
index bae37292d62..7b06e8afa0b 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, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.author, commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.author
%a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" }
@@ -76,7 +76,7 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
- %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
+ %img.avatar{ height: "24", src: avatar_icon_for(commit.committer, commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- if commit.committer
%a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" }
@@ -100,7 +100,7 @@
triggered by
- if @pipeline.user
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
+ %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
diff --git a/app/views/notify/project_was_exported_email.html.haml b/app/views/notify/project_was_exported_email.html.haml
index f0ba7827cef..71c62f6be4e 100644
--- a/app/views/notify/project_was_exported_email.html.haml
+++ b/app/views/notify/project_was_exported_email.html.haml
@@ -3,6 +3,6 @@
%p
The project export can be downloaded from:
= link_to download_export_project_url(@project), rel: 'nofollow', download: '' do
- = @project.name_with_namespace + " export"
+ = @project.full_name + " export"
%p
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 c476a39b661..1b6b1a81665 100644
--- a/app/views/notify/project_was_moved_email.html.haml
+++ b/app/views/notify/project_was_moved_email.html.haml
@@ -3,7 +3,7 @@
%p
The project is now located under
= link_to project_url(@project) do
- = @project.name_with_namespace
+ = @project.full_name
%p
To update the remote url in your local repository run (for ssh):
%p{ style: "background: #f5f5f5; padding:10px; border:1px solid #ddd" }
diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml
new file mode 100644
index 00000000000..67744ec1cee
--- /dev/null
+++ b/app/views/notify/push_to_merge_request_email.html.haml
@@ -0,0 +1,26 @@
+%h3
+ = @updated_by_user.name
+ pushed new commits to merge request
+ = link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request))
+
+- if @existing_commits.any?
+ - count = @existing_commits.size
+ %ul
+ %li
+ - if count == 1
+ - commit_id = @existing_commits.first[:short_id]
+ = link_to(commit_id, project_commit_url(@merge_request.target_project, commit_id))
+ - else
+ = link_to(project_compare_url(@merge_request.target_project, from: @existing_commits.first[:short_id], to: @existing_commits.last[:short_id])) do
+ #{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}
+ = precede '&nbsp;- ' do
+ - commits_text = "#{count} commit".pluralize(count)
+ #{commits_text} from branch `#{@merge_request.target_branch}`
+
+- if @new_commits.any?
+ %ul
+ - @new_commits.each do |commit|
+ %li
+ = link_to(commit[:short_id], project_commit_url(@merge_request.target_project, commit[:short_id]))
+ = precede ' - ' do
+ #{commit[:title]}
diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml
new file mode 100644
index 00000000000..95759d127e2
--- /dev/null
+++ b/app/views/notify/push_to_merge_request_email.text.haml
@@ -0,0 +1,13 @@
+#{@updated_by_user.name} pushed new commits to merge request #{@merge_request.to_reference}
+\
+#{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))}
+\
+- if @existing_commits.any?
+ - count = @existing_commits.size
+ - commits_id = count == 1 ? @existing_commits.first[:short_id] : "#{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]}"
+ - commits_text = "#{count} commit".pluralize(count)
+
+ * #{commits_id} - #{commits_text} from branch `#{@merge_request.target_branch}`
+\
+- @new_commits.each do |commit|
+ * #{commit[:short_id]} - #{raw commit[:title]}
diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml
new file mode 100644
index 00000000000..b4d86e1601c
--- /dev/null
+++ b/app/views/peek/_bar.html.haml
@@ -0,0 +1,12 @@
+- return unless peek_enabled?
+
+#js-peek{ data: { env: Peek.env,
+ request_id: Peek.request_id,
+ peek_url: peek_routes.results_url,
+ profile_url: url_for(params.merge(lineprofiler: 'true')) },
+ class: Peek.env }
+
+#peek-view-performance-bar.hidden
+ = render_server_response_time
+ %span#serverstats
+ %ul.performance-bar
diff --git a/app/views/peek/views/_gc.html.haml b/app/views/peek/views/_gc.html.haml
new file mode 100644
index 00000000000..9fc83e56ee7
--- /dev/null
+++ b/app/views/peek/views/_gc.html.haml
@@ -0,0 +1,7 @@
+- local_assigns.fetch(:view)
+
+%span.bold
+ %span{ title: 'Invoke Time', data: { defer_to: "#{view.defer_key}-gc_time" } }...
+ \/
+ %span{ title: 'Invoke Count', data: { defer_to: "#{view.defer_key}-invokes" } }...
+gc
diff --git a/app/views/peek/views/_gitaly.html.haml b/app/views/peek/views/_gitaly.html.haml
deleted file mode 100644
index a7d040d6821..00000000000
--- a/app/views/peek/views/_gitaly.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-- local_assigns.fetch(:view)
-
-%strong
- %span{ data: { defer_to: "#{view.defer_key}-duration" } } ...
- \/
- %span{ data: { defer_to: "#{view.defer_key}-calls" } } ...
- Gitaly
diff --git a/app/views/peek/views/_host.html.haml b/app/views/peek/views/_host.html.haml
deleted file mode 100644
index 40769b5c6f6..00000000000
--- a/app/views/peek/views/_host.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-%span.current-host
- = truncate(view.hostname)
diff --git a/app/views/peek/views/_mysql2.html.haml b/app/views/peek/views/_mysql2.html.haml
deleted file mode 100644
index ac811a10ef5..00000000000
--- a/app/views/peek/views/_mysql2.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- 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
deleted file mode 100644
index ee94c2f3274..00000000000
--- a/app/views/peek/views/_pg.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- 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
deleted file mode 100644
index 6c037930ca9..00000000000
--- a/app/views/peek/views/_rblineprof.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-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/_redis.html.haml b/app/views/peek/views/_redis.html.haml
new file mode 100644
index 00000000000..f7fba6c95fc
--- /dev/null
+++ b/app/views/peek/views/_redis.html.haml
@@ -0,0 +1,7 @@
+- local_assigns.fetch(:view)
+
+%span.bold
+ %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
+ \/
+ %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
+redis
diff --git a/app/views/peek/views/_sidekiq.html.haml b/app/views/peek/views/_sidekiq.html.haml
new file mode 100644
index 00000000000..7efbc05890d
--- /dev/null
+++ b/app/views/peek/views/_sidekiq.html.haml
@@ -0,0 +1,7 @@
+- local_assigns.fetch(:view)
+
+%span.bold
+ %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
+ \/
+ %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
+sidekiq
diff --git a/app/views/peek/views/_sql.html.haml b/app/views/peek/views/_sql.html.haml
deleted file mode 100644
index dd8b524064f..00000000000
--- a/app/views/peek/views/_sql.html.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-%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/_head.html.haml b/app/views/profiles/_head.html.haml
deleted file mode 100644
index 83ae9129807..00000000000
--- a/app/views/profiles/_head.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('profile')
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index ced58dffcdc..9c95b6281ba 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -1,6 +1,5 @@
- page_title "Account"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
- if current_user.ldap_user?
.alert.alert-info
@@ -17,10 +16,6 @@
Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'}
- if current_user.two_factor_enabled?
= link_to 'Manage two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-info'
- = link_to 'Disable', profile_two_factor_auth_path,
- method: :delete,
- data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." },
- class: 'btn btn-danger'
- else
.append-bottom-10
= link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success'
@@ -62,20 +57,8 @@
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank'
.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"
- .input-group
- .input-group-addon
- = root_url
- = f.text_field :username, required: true, class: 'form-control'
- .help-block
- Current path:
- #{root_url}#{current_user.username}
- .prepend-top-default
- = f.button class: "btn btn-warning", type: "submit" do
- = icon "spinner spin", class: "hidden loading-username"
- Update username
+ - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) }
+ #update-username{ data: data }
%hr
.row.prepend-top-default
@@ -88,11 +71,13 @@
= s_('Profiles|Deleting an account has the following effects:')
= render 'users/deletion_guidance', user: current_user
+ %button#delete-account-button.btn.btn-danger.disabled{ data: { toggle: 'modal',
+ target: '#delete-account-modal' } }
+ = s_('Profiles|Delete account')
+
#delete-account-modal{ data: { action_url: user_registration_path,
confirm_with_password: ('true' if current_user.confirm_deletion_with_password?),
username: current_user.username } }
- %button.btn.btn-danger.disabled
- = s_('Profiles|Delete account')
- else
- if @user.solo_owned_groups.present?
%p
@@ -104,6 +89,3 @@
%p
= s_("Profiles|You don't have access to delete this user.")
.append-bottom-default
-
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('account')
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index 1a392e29e2a..a924369050b 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,10 +1,9 @@
- page_title "Authentication log"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h3.prepend-top-0
+ %h4.prepend-top-0
= page_title
%p
This is a security log of important events involving your account.
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index fe1cf802971..c7094800fb2 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -4,7 +4,7 @@
%td
%strong
- if can?(current_user, :read_project, project)
- = link_to project.name_with_namespace, project_path(project)
+ = link_to project.full_name, project_path(project)
- else
.light N/A
%td
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 8f7121afe02..4b6e419af50 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,6 +1,5 @@
- page_title 'Chat'
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index df1df4f5d72..e3c2bd1150e 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,6 +1,5 @@
- page_title "Emails"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 8dbb8aef31b..1d2e41cb437 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -1,13 +1,13 @@
- page_title "GPG Keys"
-= render 'profiles/head'
+- @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
GPG keys allow you to verify signed commits.
- .col-lg-9
+ .col-lg-8
%h5.prepend-top-0
Add a GPG key
%p.profile-settings-content
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 5f7b41cf30e..1e206def7ee 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,6 +1,5 @@
- page_title "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
@@ -13,7 +12,9 @@
Add an SSH key
%p.profile-settings-content
Before you can add an SSH key you need to
- = link_to "generate it.", help_page_path("ssh/README")
+ = link_to "generate one", help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair')
+ or use an
+ = link_to "existing key.", help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair')
= render 'form'
%hr
%h5
diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml
index 172c0450381..28be6172219 100644
--- a/app/views/profiles/keys/show.html.haml
+++ b/app/views/profiles/keys/show.html.haml
@@ -1,4 +1,5 @@
+- add_to_breadcrumbs "SSH Keys", profile_keys_path
+- breadcrumb_title @key.title
- 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 e98fdfc7a3d..8f099aa6dd7 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,6 +1,5 @@
- page_title "Notifications"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
%div
- if @user.errors.any?
@@ -10,16 +9,16 @@
%li= msg
= hidden_field_tag :notification_type, 'global'
- .row
+ .row.prepend-top-default
.col-lg-4.profile-settings-sidebar
- %h4
+ %h4.prepend-top-0
= 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-8
- %h5
+ %h5.prepend-top-0
Global notification settings
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index f445e5a2417..9b87a7aaca8 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -2,8 +2,6 @@
- page_title "Personal Access Tokens"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
-
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
@@ -20,8 +18,10 @@
%h5.prepend-top-0
Your New Personal Access Token
.form-group
- = text_field_tag 'created-personal-access-token', @new_personal_access_token, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block"
- = clipboard_button(text: @new_personal_access_token, title: "Copy personal access token to clipboard", placement: "left")
+ .input-group
+ = text_field_tag 'created-personal-access-token', @new_personal_access_token, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block"
+ %span.input-group-btn
+ = clipboard_button(text: @new_personal_access_token, title: "Copy personal access token to clipboard", placement: "left", class: "btn-default btn-clipboard")
%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.
%hr
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 66d1d1e8d44..6aefd97bb96 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,6 +1,5 @@
- 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-4.application-theme
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 79f334176a5..e497eab32e0 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title "Edit Profile"
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
= form_errors(@user)
@@ -13,27 +12,24 @@
- 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, 'https://' + 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}
+ or change it at #{link_to Gitlab.config.gravatar.host, 'https://' + 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'
- %h5.prepend-top-0
- Upload new avatar
+ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
+ = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160'
+ %h5.prepend-top-0= _("Upload new avatar")
.prepend-top-5.append-bottom-10
- %a.btn.js-choose-user-avatar-button
- Browse file...
- %span.avatar-file-name.prepend-left-default.js-avatar-filename No file chosen
+ %button.btn.js-choose-user-avatar-button{ type: 'button' }= _("Choose file...")
+ %span.avatar-file-name.prepend-left-default.js-avatar-filename= _("No file chosen")
= f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
- .help-block
- The maximum file size allowed is 200KB.
+ .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-danger btn-inverted'
%hr
.row
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 0b03276efcc..1bd10018b40 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,14 +1,7 @@
- page_title 'Two-Factor Authentication', 'Account'
-- add_to_breadcrumbs("Account", profile_account_path)
+- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout
-= render 'profiles/head'
-
-- content_for :page_specific_javascripts do
- - if inject_u2f_api?
- = page_specific_javascript_bundle_tag('u2f')
- = page_specific_javascript_bundle_tag('two_factor_auth')
-
.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
@@ -18,7 +11,12 @@
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."
+ %p
+ You've already enabled two-factor authentication using mobile authenticator applications. In order to register a different device, you must first disable two-factor authentication.
+ = link_to 'Disable two-factor authentication', profile_two_factor_auth_path,
+ method: :delete,
+ data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." },
+ class: 'btn btn-danger'
- else
%p
Download the Google Authenticator application from App Store or Google Play Store and scan this code.
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index b55dc3dce5c..b387e38c1a6 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -3,6 +3,4 @@
= link_to 'Cancel', cancel_path,
class: 'btn btn-cancel', data: {confirm: leave_edit_message}
- - unless can?(current_user, :push_code, @project)
- .inline.prepend-left-10
- = commit_in_fork_help
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index c5b1897c492..1e7d9444986 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -1,4 +1,4 @@
-- return unless current_application_settings.project_export_enabled?
+- return unless Gitlab::CurrentSettings.project_export_enabled?
- project = local_assigns.fetch(:project)
- expanded = Rails.env.test?
@@ -7,7 +7,7 @@
.settings-header
%h4
Export project
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= 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.
@@ -21,16 +21,16 @@
%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
+ %li LFS objects
%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.
+ Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.
- if project.export_project_path
= link_to 'Download export', download_export_project_path(project),
rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 1d644dda177..043057e79ee 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -4,7 +4,7 @@
.limit-container-width{ class: container_class }
.avatar-container.s70.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile')
- %h1.project-title
+ %h1.project-title.qa-project-name
= @project.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false)
@@ -23,6 +23,15 @@
- deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
= deleted_message % { project_name: fork_source_name(@project) }
+ .project-badges.prepend-top-default.append-bottom-default
+ - @project.badges.each do |badge|
+ %a.append-right-8{ href: badge.rendered_link_url(@project),
+ target: '_blank',
+ rel: 'noopener noreferrer' }>
+ %img.project-badge{ src: badge.rendered_image_url(@project),
+ 'aria-hidden': true,
+ alt: '' }>
+
.project-repo-buttons
.count-buttons
= render 'projects/buttons/star'
diff --git a/app/views/projects/_issuable_by_email.html.haml b/app/views/projects/_issuable_by_email.html.haml
index 749e273b2e2..c137e38ed50 100644
--- a/app/views/projects/_issuable_by_email.html.haml
+++ b/app/views/projects/_issuable_by_email.html.haml
@@ -18,7 +18,14 @@
.email-modal-input-group.input-group
= text_field_tag :issuable_email, email, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-btn
- = clipboard_button(target: '#issuable_email')
+ = clipboard_button(target: '#issuable_email', class: 'btn btn-clipboard btn-transparent hidden-xs')
+ = mail_to email, class: 'btn btn-clipboard btn-transparent',
+ subject: _("Enter the #{name} title"),
+ body: _("Enter the #{name} description"),
+ title: _('Send email'),
+ data: { toggle: 'tooltip', placement: 'bottom' } do
+ = sprite_icon('mail')
+
%p
= render 'by_email_description'
%p
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 56eecece54c..6a1035d2dc7 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -13,6 +13,6 @@
#{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
+ .flex-right
+ = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn btn-info btn-sm qa-create-merge-request" do
#{ _('Create merge request') }
diff --git a/app/views/projects/_merge_request_fast_forward_settings.html.haml b/app/views/projects/_merge_request_fast_forward_settings.html.haml
index 9d357293a2f..f455522d17c 100644
--- a/app/views/projects/_merge_request_fast_forward_settings.html.haml
+++ b/app/views/projects/_merge_request_fast_forward_settings.html.haml
@@ -3,11 +3,11 @@
.radio
= label_tag :project_merge_method_ff do
- = form.radio_button :merge_method, :ff, class: "js-merge-method-radio"
+ = form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff"
%strong Fast-forward merge
%br
%span.descr
No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.
%br
%span.descr
- When fast-forward merge is not possible, the user must first rebase locally.
+ When fast-forward merge is not possible, the user is given the option to rebase.
diff --git a/app/views/projects/_merge_request_rebase_settings.html.haml b/app/views/projects/_merge_request_rebase_settings.html.haml
index c52e09573a6..54e0b73d24c 100644
--- a/app/views/projects/_merge_request_rebase_settings.html.haml
+++ b/app/views/projects/_merge_request_rebase_settings.html.haml
@@ -10,4 +10,4 @@
This way you could make sure that if this merge request would build, after merging to target branch it would also build.
%br
%span.descr
- When fast-forward merge is not possible, the user must first rebase locally.
+ When fast-forward merge is not possible, the user is given the option to rebase.
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index a78a8e5d628..241bc3dbca0 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -1,18 +1,20 @@
- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility
+- ci_cd_only = local_assigns.fetch(:ci_cd_only, false)
.row{ id: project_name_id }
+ = f.hidden_field :ci_cd_only, value: ci_cd_only
.form-group.project-path.col-sm-6
= f.label :namespace_id, class: 'label-light' do
%span
Project path
.input-group
- if current_user.can_select_namespace?
- .input-group-addon
+ .input-group-addon.has-tooltip{ title: root_url }
= root_url
- = f.select :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}
+ = f.select :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), {}, { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1}
- else
- .input-group-addon.static-namespace
+ .input-group-addon.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
#{user_url(current_user.username)}/
= f.hidden_field :namespace_id, value: current_user.namespace_id
.form-group.project-path.col-sm-6
@@ -34,7 +36,7 @@
.form-group.visibility-level-setting
= f.label :visibility_level, class: 'label-light' do
Visibility Level
- = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer'
= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false
= f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4
diff --git a/app/views/projects/_new_project_push_tip.html.haml b/app/views/projects/_new_project_push_tip.html.haml
new file mode 100644
index 00000000000..9bc69211d12
--- /dev/null
+++ b/app/views/projects/_new_project_push_tip.html.haml
@@ -0,0 +1,11 @@
+.push-to-create-popover
+ %p
+ = label_tag(:push_to_create_tip, _("Private projects can be created in your personal namespace with:"), class: "weight-normal")
+
+ %p.input-group.project-tip-command
+ %span.input-group-btn
+ = text_field_tag :push_to_create_tip, push_to_create_project_command, class: "js-select-on-focus form-control monospace", readonly: true, aria: { label: _("Push project from command line") }
+ %span.input-group-btn
+ = clipboard_button(text: push_to_create_project_command, title: _("Copy command to clipboard"), placement: "right")
+ %p
+ = link_to("What does this command do?", help_page_path("gitlab-basics/create-project", anchor: "push-to-create-a-new-project"), target: "_blank")
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
index 32901d30b96..705338c083e 100644
--- a/app/views/projects/_readme.html.haml
+++ b/app/views/projects/_readme.html.haml
@@ -9,15 +9,15 @@
- else
.row-content-block.second-block.center
- %h3.page-title
+ %h4
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.
+ GitLab will render it here instead of this message.
%p
- We recommend you to
- = link_to "add a README", add_special_file_path(@project, file_name: 'README.md')
- file to the repository and GitLab will render it here instead of this message.
+ = link_to "Add Readme", @project.add_readme_path, class: 'btn btn-new'
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
new file mode 100644
index 00000000000..a115b65938b
--- /dev/null
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -0,0 +1,8 @@
+- anchors = local_assigns.fetch(:anchors, [])
+
+- return unless anchors.any?
+%ul.nav
+ - anchors.each do |anchor|
+ %li
+ = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.enabled ? 'stat-link' : "btn btn-#{anchor.class_modifier || 'missing'}" do
+ %span.stat-text= anchor.label
diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml
index d0ab39033cf..b28a375e956 100644
--- a/app/views/projects/activity.html.haml
+++ b/app/views/projects/activity.html.haml
@@ -2,6 +2,7 @@
- page_title _("Activity")
-= render 'projects/last_push'
+%div{ class: container_class }
+ = render 'projects/last_push'
= render 'projects/activity'
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index fe02cbcbf95..7ff7466e561 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -18,7 +18,7 @@
.tree-controls
= link_to download_project_job_artifacts_path(@project, @build),
rel: 'nofollow', download: '', class: 'btn btn-default download' do
- = icon('download')
+ = sprite_icon('download')
Download artifacts archive
.tree-content-holder
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 7777f55ddd7..c9fa90acd11 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -3,7 +3,7 @@
.file-holder-bottom-radius.file-holder.file.append-bottom-default
.js-file-title.file-title.clearfix{ data: { current_action: action } }
.editor-ref
- = icon('code-fork')
+ = sprite_icon('fork', size: 12)
= ref
%span.editor-file-name
- if current_action?(:edit) || current_action?(:update)
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 2a77dedd9a2..1b150ec3e5c 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -11,8 +11,8 @@
= view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }<
- = edit_blob_link
- = ide_blob_link
+ = edit_blob_button
+ = ide_edit_button
- if current_user
= replace_blob_link
= delete_blob_link
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index 03ab1bb59e4..48ff66900be 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -1,5 +1,5 @@
#modal-create-new-dir.modal
- .modal-dialog
+ .modal-dialog.modal-lg
.modal-content
.modal-header
%a.close{ href: "#", "data-dismiss" => "modal" } ×
@@ -17,6 +17,4 @@
= 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
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index 05b7dfe2872..182d02376bf 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -1,8 +1,8 @@
#modal-upload-blob.modal
- .modal-dialog
+ .modal-dialog.modal-lg
.modal-content
.modal-header
- %a.close{ href: "#", "data-dismiss" => "modal" } ×
+ %a.close{ href: "#", "data-dismiss" => "modal" } &times;
%h3.page-title= title
.modal-body
= form_tag form_path, method: method, class: 'js-quick-submit js-upload-blob-form form-horizontal', data: { method: method } do
@@ -24,9 +24,4 @@
= button_title
= 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
-
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('blob')
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml
index cc85e5de40f..3124443b4e4 100644
--- a/app/views/projects/blob/_viewer.html.haml
+++ b/app/views/projects/blob/_viewer.html.haml
@@ -1,9 +1,10 @@
- hidden = local_assigns.fetch(:hidden, false)
- render_error = viewer.render_error
+- rich_type = viewer.type == :rich ? viewer.partial_name : nil
- 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) }
+.blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url }, class: ('hidden' if hidden) }
- if render_error
= render 'projects/blob/render_error', viewer: viewer
- elsif load_async
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 626cbc9e41d..9d90251ab66 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -3,7 +3,6 @@
- page_title "Edit", @blob.path, @ref
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
- = page_specific_javascript_bundle_tag('blob')
%div{ class: container_class }
- if @conflict
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index a4263774dfd..fa091d8f6ef 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -2,7 +2,6 @@
- page_title "New File", @path.presence, @ref
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
- = page_specific_javascript_bundle_tag('blob')
.editor-title-row
%h3.page-title.blob-new-page-title
New file
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 4d358052d43..efb8175398b 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -3,12 +3,9 @@
- page_title @blob.path, @ref
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'blob'
-
-= render 'projects/last_push'
-
%div{ class: container_class }
+ = render 'projects/last_push'
+
#tree-holder.tree-holder
= render 'blob', blob: @blob
diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml
index 1e7c461f02e..b20106e8c3a 100644
--- a/app/views/projects/blob/viewers/_balsamiq.html.haml
+++ b/app/views/projects/blob/viewers/_balsamiq.html.haml
@@ -1,4 +1 @@
-- 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/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml
index 253566c43be..f9b1da05a00 100644
--- a/app/views/projects/blob/viewers/_download.html.haml
+++ b/app/views/projects/blob/viewers/_download.html.haml
@@ -2,6 +2,6 @@
.center.render-error.vertical-center
= link_to blob_raw_path do
%h1.light
- = icon('download')
+ = sprite_icon('download')
%h4
Download (#{number_to_human_size(viewer.blob.raw_size)})
diff --git a/app/views/projects/blob/viewers/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml
index 8a41bc53004..eb4ca1b9816 100644
--- a/app/views/projects/blob/viewers/_notebook.html.haml
+++ b/app/views/projects/blob/viewers/_notebook.html.haml
@@ -1,5 +1 @@
-- 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
index ec2b18bd4ab..95d837a57dc 100644
--- a/app/views/projects/blob/viewers/_pdf.html.haml
+++ b/app/views/projects/blob/viewers/_pdf.html.haml
@@ -1,5 +1 @@
-- 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/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml
index 775e4584f77..b4b6492b92f 100644
--- a/app/views/projects/blob/viewers/_sketch.html.haml
+++ b/app/views/projects/blob/viewers/_sketch.html.haml
@@ -1,7 +1,3 @@
-- 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
index 6578d826ace..55dd8cba7fe 100644
--- a/app/views/projects/blob/viewers/_stl.html.haml
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -1,6 +1,3 @@
-- 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')
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 573050e597d..883dfb3e6c8 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -5,80 +5,82 @@
- number_commits_behind = diverging_commit_counts[:behind]
- number_commits_ahead = diverging_commit_counts[:ahead]
- 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 project_tree_path(@project, branch.name), class: 'item-title str-truncated ref-name' do
- = icon('code-fork', class: 'append-right-5') + "#{branch.name}"
- &nbsp;
- - if branch.name == @repository.root_ref
- %span.label.label-primary default
- - elsif merged
- %span.label.label-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
- = s_('Branches|merged')
+%li{ class: "branch-item js-branch-#{branch.name}" }
+ .branch-info
+ .branch-title
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name' do
+ = sprite_icon('fork', size: 12)
+ = branch.name
+ &nbsp;
+ - if branch.name == @repository.root_ref
+ %span.label.label-primary default
+ - elsif merged
+ %span.label.label-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
+ = s_('Branches|merged')
- - if protected_branch?(@project, branch)
- %span.label.label-success
- = s_('Branches|protected')
- .controls.hidden-xs<
- - if merge_project && create_mr_button?(@repository.root_ref, branch.name)
- = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do
- = _('Merge request')
+ - if protected_branch?(@project, branch)
+ %span.label.label-success
+ = s_('Branches|protected')
- - if branch.name != @repository.root_ref
- = 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: s_('Branches|Compare') do
- = s_('Branches|Compare')
+ .block-truncated
+ - if commit
+ = render 'projects/branches/commit', commit: commit, project: @project
+ - else
+ = s_('Branches|Cant find HEAD commit for this branch')
- = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name]
+ - if branch.name != @repository.root_ref
+ .divergence-graph{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind),
+ default_branch: @repository.root_ref,
+ number_commits_ahead: diverging_count_label(number_commits_ahead) } }
+ .graph-side
+ .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" }
+ %span.count.count-behind= diverging_count_label(number_commits_behind)
+ .graph-separator
+ .graph-side
+ .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" }
+ %span.count.count-ahead= diverging_count_label(number_commits_ahead)
- - if can?(current_user, :push_code, @project)
- - if branch.name == @project.repository.root_ref
- %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
- disabled: true,
- title: s_('Branches|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: s_('Branches|Delete protected branch'),
- data: { toggle: "modal",
- target: "#modal-delete-branch",
- delete_path: project_branch_path(@project, branch.name),
- branch_name: branch.name,
- is_merged: ("true" if merged) } }
- = icon("trash-o")
- - else
- %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
- disabled: true,
- title: s_('Branches|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: s_('Branches|Delete branch'),
- method: :delete,
- data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } },
- remote: true,
- 'aria-label' => s_('Branches|Delete branch') do
- = icon("trash-o")
+ .controls.hidden-xs<
+ - if merge_project && create_mr_button?(@repository.root_ref, branch.name)
+ = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do
+ = _('Merge request')
- if branch.name != @repository.root_ref
- .divergence-graph{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: number_commits_behind,
- default_branch: @repository.root_ref,
- number_commits_ahead: number_commits_ahead } }
- .graph-side
- .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" }
- %span.count.count-behind= number_commits_behind
- .graph-separator
- .graph-side
- .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" }
- %span.count.count-ahead= number_commits_ahead
+ = 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: s_('Branches|Compare') do
+ = s_('Branches|Compare')
+ = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name]
- - if commit
- = render 'projects/branches/commit', commit: commit, project: @project
- - else
- %p
- = s_('Branches|Cant find HEAD commit for this branch')
+ - if can?(current_user, :push_code, @project)
+ - if branch.name == @project.repository.root_ref
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: s_('Branches|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: s_('Branches|Delete protected branch'),
+ data: { toggle: "modal",
+ target: "#modal-delete-branch",
+ delete_path: project_branch_path(@project, branch.name),
+ branch_name: branch.name,
+ is_merged: ("true" if merged) } }
+ = icon("trash-o")
+ - else
+ %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled",
+ disabled: true,
+ title: s_('Branches|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: s_('Branches|Delete branch'),
+ method: :delete,
+ data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } },
+ remote: true,
+ 'aria-label' => s_('Branches|Delete branch') do
+ = icon("trash-o")
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
new file mode 100644
index 00000000000..12e5a8e8d69
--- /dev/null
+++ b/app/views/projects/branches/_panel.html.haml
@@ -0,0 +1,19 @@
+- branches = local_assigns.fetch(:branches)
+- state = local_assigns.fetch(:state)
+- panel_title = local_assigns.fetch(:panel_title)
+- show_more_text = local_assigns.fetch(:show_more_text)
+- project = local_assigns.fetch(:project)
+- overview_max_branches = local_assigns.fetch(:overview_max_branches)
+
+- return unless branches.any?
+
+.panel.panel-default.prepend-top-10
+ .panel-heading
+ %h4.panel-title
+ = panel_title
+ %ul.content-list.all-branches
+ - branches.first(overview_max_branches).each do |branch|
+ = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch)
+ - if branches.size > overview_max_branches
+ .panel-footer.text-center
+ = link_to show_more_text, project_branches_filtered_path(project, state: state), id: "state-#{state}", data: { state: state }
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index fb770764364..5dcc72d8263 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -3,26 +3,35 @@
%div{ class: container_class }
.top-area.adjust
- - if can?(current_user, :admin_project, @project)
- .nav-text
- - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
- = s_('Branches|Protected branches can be managed in %{project_settings_link}').html_safe % { project_settings_link: project_settings_link }
+ %ul.nav-links.issues-state-filters
+ %li{ class: active_when(@mode == 'overview') }>
+ = link_to s_('Branches|Overview'), project_branches_path(@project), title: s_('Branches|Show overview of the branches')
+
+ %li{ class: active_when(@mode == 'active') }>
+ = link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), title: s_('Branches|Show active branches')
+
+ %li{ class: active_when(@mode == 'stale') }>
+ = link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), title: s_('Branches|Show stale branches')
+
+ %li{ class: active_when(!%w[overview active stale].include?(@mode)) }>
+ = link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), title: s_('Branches|Show all branches')
.nav-controls
- = form_tag(filter_branches_path, method: :get) do
+ = form_tag(project_branches_filtered_path(@project, state: 'all'), method: :get) do
= search_field_tag :search, params[:search], { placeholder: s_('Branches|Filter by branch name'), id: 'branch-search', class: 'form-control search-text-input input-short', spellcheck: false }
- .dropdown.inline>
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light
- = branches_sort_options_hash[@sort]
- = icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
- %li.dropdown-header
- = s_('Branches|Sort by')
- - branches_sort_options_hash.each do |value, title|
- %li
- = link_to title, filter_branches_path(sort: value), class: ("is-active" if @sort == value)
+ - unless @mode == 'overview'
+ .dropdown.inline>
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %span.light
+ = branches_sort_options_hash[@sort]
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = s_('Branches|Sort by')
+ - branches_sort_options_hash.each do |value, title|
+ %li
+ = link_to title, project_branches_filtered_path(@project, state: 'all', search: params[:search], sort: value), class: ("is-active" if @sort == value)
- if can? current_user, :push_code, @project
= link_to project_merged_branches_path(@project),
@@ -35,7 +44,17 @@
= link_to new_project_branch_path(@project), class: 'btn btn-create' do
= s_('Branches|New branch')
- - if @branches.any?
+ - if can?(current_user, :admin_project, @project)
+ - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
+ .row-content-block
+ %h5
+ = s_('Branches|Protected branches can be managed in %{project_settings_link}.').html_safe % { project_settings_link: project_settings_link }
+
+ - if @mode == 'overview' && (@active_branches.any? || @stale_branches.any?)
+ = render "projects/branches/panel", branches: @active_branches, state: 'active', panel_title: s_('Branches|Active branches'), show_more_text: s_('Branches|Show more active branches'), project: @project, overview_max_branches: @overview_max_branches
+ = render "projects/branches/panel", branches: @stale_branches, state: 'stale', panel_title: s_('Branches|Stale branches'), show_more_text: s_('Branches|Show more stale branches'), project: @project, overview_max_branches: @overview_max_branches
+
+ - elsif @branches.any?
%ul.content-list.all-branches
- @branches.each do |branch|
= render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name)
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index e9d8fc75142..c7fc5a98ca8 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -28,4 +28,5 @@
.form-actions
= button_tag 'Create branch', class: 'btn btn-create', tabindex: 3
= link_to 'Cancel', project_branches_path(@project), class: 'btn btn-cancel'
+-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index fa6f6d0b588..f49f6e630d2 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -1,25 +1,26 @@
- pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) }
- if !project.empty_repo? && can?(current_user, :download_code, project)
+ - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}"
.project-action-button.dropdown.inline>
%button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') }
- = icon('download')
+ = sprite_icon('download')
= icon("caret-down")
%span.sr-only= _('Select Archive Format')
%ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' }
%li.dropdown-header
#{ _('Source code') }
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'zip'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do
%span= _('Download zip')
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'tar.gz'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do
%span= _('Download tar.gz')
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'tar.bz2'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do
%span= _('Download tar.bz2')
%li
- = link_to archive_project_repository_path(project, ref: ref, format: 'tar'), rel: 'nofollow', download: '' do
+ = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do
%span= _('Download tar')
- if pipeline && pipeline.latest_builds_with_artifacts.any?
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index 2589c53beae..18e948ce35a 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -6,46 +6,33 @@
%ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown
- can_create_issue = can?(current_user, :create_issue, @project)
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- - can_create_snippet = can?(current_user, :create_snippet, @project)
+ - can_create_project_snippet = can?(current_user, :create_project_snippet, @project)
+
+ - if can_create_issue || merge_project || can_create_project_snippet
+ %li.dropdown-header= _('This project')
- if can_create_issue
- %li
- = link_to new_project_issue_path(@project) do
- #{ _('New issue') }
+ %li= link_to _('New issue'), new_project_issue_path(@project)
- if merge_project
- %li
- = link_to project_new_merge_request_path(merge_project) do
- #{ _('New merge request') }
+ %li= link_to _('New merge request'), project_new_merge_request_path(merge_project)
- - if can_create_snippet
- %li
- = link_to new_project_snippet_path(@project) do
- #{ _('New snippet') }
+ - if can_create_project_snippet
+ %li= link_to _('New snippet'), new_project_snippet_path(@project)
- - if can_create_issue || merge_project || can_create_snippet
- %li.divider
+ - if can?(current_user, :push_code, @project)
+ %li.dropdown-header= _('This repository')
- if can?(current_user, :push_code, @project)
- %li
- = link_to project_new_blob_path(@project, @project.default_branch || 'master') do
- #{ _('New file') }
- %li
- = link_to new_project_branch_path(@project) do
- #{ _('New branch') }
- %li
- = link_to new_project_tag_path(@project) do
- #{ _('New tag') }
+ %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master')
+ - unless @project.empty_repo?
+ %li= link_to _('New branch'), new_project_branch_path(@project)
+ %li= link_to _('New tag'), new_project_tag_path(@project)
- elsif current_user && current_user.already_forked?(@project)
- %li
- = link_to project_new_blob_path(@project, @project.default_branch || 'master') do
- #{ _('New file') }
+ %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master')
- elsif can?(current_user, :fork_project, @project)
- %li
- - 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 = project_forks_path(@project, namespace_key: current_user.namespace.id,
- continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('New file') }
+ - 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 = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ %li= link_to _('New file'), fork_path, method: :post
diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml
index de2d61d4aa3..e665ca61da8 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)
+- if koding_enabled? && current_user && @repository.koding_yml && @project.can_current_user_push_code?
= link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank', rel: 'noopener noreferrer' do
_('Run in IDE (Koding)')
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 0a54c736761..d8b4266143e 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -1,5 +1,5 @@
- if current_user
- = link_to toggle_star_project_path(@project), { class: 'btn star-btn toggle-star', method: :post, remote: true } do
+ %button.btn.btn-default.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
- if current_user.starred?(@project)
= sprite_icon('star')
%span.starred= _('Unstar')
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 86510b8ab93..9126476e79e 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -22,7 +22,7 @@
- if ref
- if job.ref
.icon-container
- = job.tag? ? icon('tag') : icon('code-fork')
+ = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name"
- else
.light none
@@ -63,7 +63,7 @@
- if admin
%td
- if job.project
- = link_to job.project.name_with_namespace, admin_project_path(job.project)
+ = link_to job.project.full_name, admin_project_path(job.project)
%td
- if job.try(:runner)
= runner_link(job.runner)
@@ -96,7 +96,7 @@
.pull-right
- if can?(current_user, :read_build, job) && job.artifacts?
= link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
- = icon('download')
+ = sprite_icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
= link_to cancel_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
diff --git a/app/views/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml
index 30bf1384b22..30bf1384b22 100644
--- a/app/views/ci/lints/_create.html.haml
+++ b/app/views/projects/ci/lints/_create.html.haml
diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml
new file mode 100644
index 00000000000..6ca8152183d
--- /dev/null
+++ b/app/views/projects/ci/lints/show.html.haml
@@ -0,0 +1,27 @@
+- page_title "CI Lint"
+- page_description "Validate your GitLab CI configuration file"
+- content_for :library_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+
+%h2 Check your .gitlab-ci.yml
+
+.project-ci-linter
+ .row
+ = form_tag project_ci_lint_path(@project), method: :post do
+ .form-group
+ .col-sm-12
+ .file-holder
+ .js-file-title.file-title.clearfix
+ Content of .gitlab-ci.yml
+ #ci-editor.ci-editor= @content
+ = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true)
+ .col-sm-12
+ .pull-left.prepend-top-10
+ = submit_tag('Validate', class: 'btn btn-success submit-yml')
+ .pull-right.prepend-top-10
+ = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml')
+
+ .row.prepend-top-20
+ .col-sm-12
+ .results.project-ci-template
+ = render partial: 'create' if defined?(@status)
diff --git a/app/views/projects/clusters/_advanced_settings.html.haml b/app/views/projects/clusters/_advanced_settings.html.haml
index 7032b892029..14979bee714 100644
--- a/app/views/projects/clusters/_advanced_settings.html.haml
+++ b/app/views/projects/clusters/_advanced_settings.html.haml
@@ -5,11 +5,11 @@
= s_('ClusterIntegration|Google Kubernetes Engine')
%p
- link_gke = link_to(s_('ClusterIntegration|Google Kubernetes Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+ = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
.well.form-group
%label.text-danger
- = s_('ClusterIntegration|Remove cluster integration')
+ = s_('ClusterIntegration|Remove Kubernetes cluster integration')
%p
- = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your cluster on Google Kubernetes Engine.')
- = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Kubernetes Engine"})
+ = s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.")
+ = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: s_("ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster.")})
diff --git a/app/views/projects/clusters/_banner.html.haml b/app/views/projects/clusters/_banner.html.haml
index 76a66fb92a2..f18caa3f4ac 100644
--- a/app/views/projects/clusters/_banner.html.haml
+++ b/app/views/projects/clusters/_banner.html.haml
@@ -1,21 +1,14 @@
-%h4= s_('ClusterIntegration|Enable cluster integration')
-.settings-content
+%h4= s_('ClusterIntegration|Kubernetes cluster integration')
+.settings-content
.hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' }
- = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine')
+ = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine')
%p.js-error-reason
.hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' }
- = s_('ClusterIntegration|Cluster is being created on Google Kubernetes Engine...')
+ = s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...')
.hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' }
- = s_('ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine. Refresh the page to see cluster\'s details')
+ = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details")
- %p
- - if @cluster.enabled?
- - if can?(current_user, :update_cluster, @cluster)
- = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
- - else
- = s_('ClusterIntegration|Cluster integration is enabled for this project.')
- - else
- = s_('ClusterIntegration|Cluster integration is disabled for this project.')
+ %p= s_('ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab')
diff --git a/app/views/projects/clusters/_cluster.html.haml b/app/views/projects/clusters/_cluster.html.haml
index ad696daa259..2d7f7c6b1fb 100644
--- a/app/views/projects/clusters/_cluster.html.haml
+++ b/app/views/projects/clusters/_cluster.html.haml
@@ -1,10 +1,10 @@
.gl-responsive-table-row
.table-section.section-30
- .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Cluster")
+ .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
.table-mobile-content
= link_to cluster.name, namespace_project_cluster_path(@project.namespace, @project, cluster)
.table-section.section-30
- .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment pattern")
+ .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment scope")
.table-mobile-content= cluster.environment_scope
.table-section.section-30
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Project namespace")
@@ -12,11 +12,12 @@
.table-section.section-10
.table-mobile-header{ role: "rowheader" }
.table-mobile-content
- %button{ type: "button",
- class: "js-toggle-cluster-list project-feature-toggle #{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}",
- "aria-label": s_("ClusterIntegration|Toggle Cluster"),
+ %button.js-project-feature-toggle.project-feature-toggle{ type: "button",
+ class: "#{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}",
+ "aria-label": s_("ClusterIntegration|Toggle Kubernetes Cluster"),
disabled: !cluster.can_toggle_cluster?,
data: { endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } }
+ %input.js-project-feature-toggle-input{ type: "hidden", value: cluster.enabled? }
= icon("spinner spin", class: "loading-icon")
%span.toggle-icon
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
diff --git a/app/views/projects/clusters/_dropdown.html.haml b/app/views/projects/clusters/_dropdown.html.haml
index e36dd900f8d..d55a9c60b64 100644
--- a/app/views/projects/clusters/_dropdown.html.haml
+++ b/app/views/projects/clusters/_dropdown.html.haml
@@ -1,4 +1,4 @@
-%h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up cluster integration')
+%h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration')
.dropdown.clusters-dropdown
%button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false }
@@ -7,6 +7,6 @@
= icon('chevron-down')
%ul.dropdown-menu.clusters-dropdown-menu.dropdown-menu-full-width
%li
- = link_to(s_('ClusterIntegration|Create cluster on Google Kubernetes Engine'), gcp_new_namespace_project_clusters_path(@project.namespace, @project))
+ = link_to(s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine'), gcp_new_namespace_project_clusters_path(@project.namespace, @project))
%li
- = link_to(s_('ClusterIntegration|Add an existing cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project))
+ = link_to(s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project))
diff --git a/app/views/projects/clusters/_empty_state.html.haml b/app/views/projects/clusters/_empty_state.html.haml
index b525f4efc83..112dde66ff7 100644
--- a/app/views/projects/clusters/_empty_state.html.haml
+++ b/app/views/projects/clusters/_empty_state.html.haml
@@ -3,10 +3,9 @@
.svg-content= image_tag 'illustrations/clusters_empty.svg'
.col-xs-12
.text-content
- %h4.text-center= s_('ClusterIntegration|Integrate cluster automation')
- - link_to_help_page = link_to(s_('ClusterIntegration|Learn more about Clusters'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
- %p= s_('ClusterIntegration|Clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page}
+ %h4.text-center= s_('ClusterIntegration|Integrate Kubernetes cluster automation')
+ - link_to_help_page = link_to(_('Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ %p= s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page}
.text-center
- = link_to s_('ClusterIntegration|Add cluster'), new_project_cluster_path(@project), class: 'btn btn-success'
-
+ = link_to s_('ClusterIntegration|Add Kubernetes cluster'), new_project_cluster_path(@project), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/_enabled.html.haml b/app/views/projects/clusters/_enabled.html.haml
deleted file mode 100644
index 547b3c8446f..00000000000
--- a/app/views/projects/clusters/_enabled.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field|
- = form_errors(@cluster)
- .form-group.append-bottom-20
- %label.append-bottom-10
- = field.hidden_field :enabled, { class: 'js-toggle-input'}
-
- %button{ type: 'button',
- class: "js-toggle-cluster project-feature-toggle #{'is-checked' unless !@cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
- "aria-label": s_("ClusterIntegration|Toggle Cluster"),
- disabled: !can?(current_user, :update_cluster, @cluster) }
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
-
- - if can?(current_user, :update_cluster, @cluster)
- .form-group
- = field.submit _('Save'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/projects/clusters/_integration_form.html.haml
new file mode 100644
index 00000000000..db97203a2aa
--- /dev/null
+++ b/app/views/projects/clusters/_integration_form.html.haml
@@ -0,0 +1,38 @@
+= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field|
+ = form_errors(@cluster)
+ .form-group.append-bottom-20
+ %h5= s_('ClusterIntegration|Integration status')
+ %p
+ - if @cluster.enabled?
+ - if can?(current_user, :update_cluster, @cluster)
+ = s_('ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab\'s connection to it.')
+ - else
+ = s_('ClusterIntegration|Kubernetes cluster integration is enabled for this project.')
+ - else
+ = s_('ClusterIntegration|Kubernetes cluster integration is disabled for this project.')
+ %label.append-bottom-10.js-cluster-enable-toggle-area
+ %button{ type: 'button',
+ class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
+ "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"),
+ disabled: !can?(current_user, :update_cluster, @cluster) }
+ = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
+
+ .form-group
+ %h5= s_('ClusterIntegration|Security')
+ %p
+ = s_("ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application.")
+ = link_to s_("ClusterIntegration|Learn more about security configuration"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications')
+
+ .form-group
+ %h5= s_('ClusterIntegration|Environment scope')
+ %p
+ = s_("ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster.")
+ = link_to s_("ClusterIntegration|Learn more about environments"), help_page_path('ci/environments')
+ = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
+
+ - if can?(current_user, :update_cluster, @cluster)
+ .form-group
+ = field.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/_sidebar.html.haml b/app/views/projects/clusters/_sidebar.html.haml
index 761879db32b..73cd7c50922 100644
--- a/app/views/projects/clusters/_sidebar.html.haml
+++ b/app/views/projects/clusters/_sidebar.html.haml
@@ -1,7 +1,7 @@
%h4.prepend-top-0
- = s_('ClusterIntegration|Cluster integration')
+ = s_('ClusterIntegration|Kubernetes cluster integration')
%p
- = s_('ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
+ = s_('ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
%p
- - link = link_to(s_('ClusterIntegration|cluster'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ - link = link_to(_('Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Learn more about %{link_to_documentation}').html_safe % { link_to_documentation: link }
diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml
index e384b60d8d9..5739a57dcfe 100644
--- a/app/views/projects/clusters/gcp/_form.html.haml
+++ b/app/views/projects/clusters/gcp/_form.html.haml
@@ -1,12 +1,12 @@
%p
- link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Read our %{link_to_help_page} on cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
+ = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
= form_for @cluster, html: { class: 'prepend-top-20' }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field|
= form_errors(@cluster)
.form-group
- = field.label :name, s_('ClusterIntegration|Cluster name')
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name')
+ = field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
+ = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
.form-group
= field.label :environment_scope, s_('ClusterIntegration|Environment scope')
= field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
@@ -32,4 +32,4 @@
= provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4'
.form-group
- = field.submit s_('ClusterIntegration|Create cluster'), class: 'btn btn-success'
+ = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/gcp/_header.html.haml b/app/views/projects/clusters/gcp/_header.html.haml
index f23d5b80e4f..fa989943492 100644
--- a/app/views/projects/clusters/gcp/_header.html.haml
+++ b/app/views/projects/clusters/gcp/_header.html.haml
@@ -1,14 +1,14 @@
%h4.prepend-top-20
- = s_('ClusterIntegration|Enter the details for your cluster')
+ = s_('ClusterIntegration|Enter the details for your Kubernetes cluster')
%p
= s_('ClusterIntegration|Please make sure that your Google account meets the following requirements:')
%ul
%li
- - link_to_kubernetes_engine = link_to(s_('ClusterIntegration|access to Google Kubernetes Engine'), 'https://console.cloud.google.com', target: '_blank', rel: 'noopener noreferrer')
+ - link_to_kubernetes_engine = link_to(s_('ClusterIntegration|access to Google Kubernetes Engine'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Your account must have %{link_to_kubernetes_engine}').html_safe % { link_to_kubernetes_engine: link_to_kubernetes_engine }
%li
- - link_to_requirements = link_to(s_('ClusterIntegration|meets the requirements'), 'https://cloud.google.com/kubernetes-engine/docs/quickstart', target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters').html_safe % { link_to_requirements: link_to_requirements }
+ - link_to_requirements = link_to(s_('ClusterIntegration|meets the requirements'), 'https://cloud.google.com/kubernetes-engine/docs/quickstart?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters').html_safe % { link_to_requirements: link_to_requirements }
%li
- - link_to_container_project = link_to(s_('ClusterIntegration|Google Kubernetes Engine project'), target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|This account must have permissions to create a cluster in the %{link_to_container_project} specified below').html_safe % { link_to_container_project: link_to_container_project }
+ - link_to_container_project = link_to(s_('ClusterIntegration|Google Kubernetes Engine project'), 'https://console.cloud.google.com/home/dashboard?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below').html_safe % { link_to_container_project: link_to_container_project }
diff --git a/app/views/projects/clusters/gcp/_show.html.haml b/app/views/projects/clusters/gcp/_show.html.haml
index bde85aed341..78cd687ef93 100644
--- a/app/views/projects/clusters/gcp/_show.html.haml
+++ b/app/views/projects/clusters/gcp/_show.html.haml
@@ -1,18 +1,14 @@
.form-group
%label.append-bottom-10{ for: 'cluster-name' }
- = s_('ClusterIntegration|Cluster name')
+ = s_('ClusterIntegration|Kubernetes cluster name')
.input-group
%input.form-control.cluster-name.js-select-on-focus{ value: @cluster.name, readonly: true }
%span.input-group-btn
- = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy cluster name'), class: 'btn-default')
+ = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'btn-default')
= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field|
= form_errors(@cluster)
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope')
- = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
-
= field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field|
.form-group
= platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL')
diff --git a/app/views/projects/clusters/gcp/login.html.haml b/app/views/projects/clusters/gcp/login.html.haml
index e97ce01893a..dada51f39da 100644
--- a/app/views/projects/clusters/gcp/login.html.haml
+++ b/app/views/projects/clusters/gcp/login.html.haml
@@ -1,17 +1,19 @@
-- breadcrumb_title "Cluster"
+- breadcrumb_title 'Kubernetes'
- page_title _("Login")
.row.prepend-top-default
.col-sm-4
= render 'projects/clusters/sidebar'
.col-sm-8
- = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create cluster on Google Kubernetes Engine')
+ = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine')
= render 'header'
.row
.col-sm-8.col-sm-offset-4.signin-with-google
- if @authorize_url
= link_to @authorize_url do
= image_tag('auth_buttons/signin_with_google.png', width: '191px')
+ = _('or')
+ = link_to('create a new Google account', 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral', target: '_blank', rel: 'noopener noreferrer')
- else
- link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer')
= s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link }
diff --git a/app/views/projects/clusters/gcp/new.html.haml b/app/views/projects/clusters/gcp/new.html.haml
index 8d92fb1e320..ea78d66d883 100644
--- a/app/views/projects/clusters/gcp/new.html.haml
+++ b/app/views/projects/clusters/gcp/new.html.haml
@@ -1,10 +1,10 @@
-- breadcrumb_title "Cluster"
-- page_title _("New Cluster")
+- breadcrumb_title 'Kubernetes'
+- page_title _("New Kubernetes Cluster")
.row.prepend-top-default
.col-sm-4
= render 'projects/clusters/sidebar'
.col-sm-8
- = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create cluster on Google Kubernetes Engine')
+ = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine')
= render 'header'
= render 'form'
diff --git a/app/views/projects/clusters/index.html.haml b/app/views/projects/clusters/index.html.haml
index bec512be91c..17b244f4bf7 100644
--- a/app/views/projects/clusters/index.html.haml
+++ b/app/views/projects/clusters/index.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "Clusters"
-- page_title "Clusters"
+- breadcrumb_title 'Kubernetes'
+- page_title "Kubernetes Clusters"
.clusters-container
- if @clusters.empty?
@@ -7,13 +7,13 @@
- else
.top-area.adjust
.nav-text
- = s_("ClusterIntegration|Clusters can be used to deploy applications and to provide Review Apps for this project")
+ = s_("ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project")
.ci-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
.table-section.section-30{ role: "rowheader" }
- = s_("ClusterIntegration|Cluster")
+ = s_("ClusterIntegration|Kubernetes cluster")
.table-section.section-30{ role: "rowheader" }
- = s_("ClusterIntegration|Environment pattern")
+ = s_("ClusterIntegration|Environment scope")
.table-section.section-30{ role: "rowheader" }
= s_("ClusterIntegration|Project namespace")
.table-section.section-10{ role: "rowheader" }
diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml
index ddd13f8ea96..ebb7d247125 100644
--- a/app/views/projects/clusters/new.html.haml
+++ b/app/views/projects/clusters/new.html.haml
@@ -1,13 +1,13 @@
-- breadcrumb_title "Cluster"
-- page_title _("Cluster")
+- breadcrumb_title 'Kubernetes'
+- page_title _("Kubernetes Cluster")
.row.prepend-top-default
.col-sm-4
= render 'sidebar'
.col-sm-8
- %h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up cluster integration')
+ %h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration')
- %p= s_('ClusterIntegration|Create a new cluster on Google Kubernetes Engine right from GitLab')
+ %p= s_('ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab')
= link_to s_('ClusterIntegration|Create on GKE'), gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20'
%p= s_('ClusterIntegration|Enter the details for an existing Kubernetes cluster')
- = link_to s_('ClusterIntegration|Add an existing cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20'
+ = link_to s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20'
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
index fe6dacf1f0d..4c510293204 100644
--- a/app/views/projects/clusters/show.html.haml
+++ b/app/views/projects/clusters/show.html.haml
@@ -1,7 +1,7 @@
- @content_class = "limit-container-width" unless fluid_layout
-- add_to_breadcrumbs "Clusters", project_clusters_path(@project)
-- breadcrumb_title @cluster.id
-- page_title _("Cluster")
+- add_to_breadcrumbs "Kubernetes Clusters", project_clusters_path(@project)
+- breadcrumb_title @cluster.name
+- page_title _("Kubernetes Cluster")
- expanded = Rails.env.test?
@@ -9,26 +9,31 @@
.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
install_helm_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :helm),
install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress),
+ install_prometheus_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :prometheus),
+ install_runner_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :runner),
toggle_status: @cluster.enabled? ? 'true': 'false',
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason,
- help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications') } }
+ help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
+ ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'),
+ ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'),
+ manage_prometheus_path: edit_project_service_path(@cluster.project, 'prometheus') } }
.js-cluster-application-notice
.flash-container
- %section.settings.no-animate.expanded
+ %section.settings.no-animate.expanded#cluster-integration
= render 'banner'
- = render 'enabled'
+ = render 'integration_form'
.cluster-applications-table#js-cluster-applications
%section.settings#js-cluster-details{ class: ('expanded' if expanded) }
.settings-header
- %h4= s_('ClusterIntegration|Cluster details')
- %button.btn.js-settings-toggle
+ %h4= s_('ClusterIntegration|Kubernetes cluster details')
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
- %p= s_('ClusterIntegration|See and edit the details for your cluster')
+ %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster')
.settings-content
- if @cluster.managed?
= render 'projects/clusters/gcp/show'
@@ -38,8 +43,8 @@
%section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) }
.settings-header
%h4= _('Advanced settings')
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
- %p= s_('ClusterIntegration|Manage cluster integration on your GitLab project')
+ %p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration")
.settings-content
= render 'advanced_settings'
diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml
index babfca0c567..2e92524ce8f 100644
--- a/app/views/projects/clusters/user/_form.html.haml
+++ b/app/views/projects/clusters/user/_form.html.haml
@@ -1,8 +1,8 @@
= form_for @cluster, url: user_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field|
= form_errors(@cluster)
.form-group
- = field.label :name, s_('ClusterIntegration|Cluster name')
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name')
+ = field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
+ = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
.form-group
= field.label :environment_scope, s_('ClusterIntegration|Environment scope')
= field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
@@ -25,4 +25,4 @@
= platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
.form-group
- = field.submit s_('ClusterIntegration|Add cluster'), class: 'btn btn-success'
+ = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success'
diff --git a/app/views/projects/clusters/user/_header.html.haml b/app/views/projects/clusters/user/_header.html.haml
index 06ac210a06d..37f6a788518 100644
--- a/app/views/projects/clusters/user/_header.html.haml
+++ b/app/views/projects/clusters/user/_header.html.haml
@@ -1,5 +1,5 @@
%h4.prepend-top-20
- = s_('ClusterIntegration|Enter the details for your cluster')
+ = s_('ClusterIntegration|Enter the details for your Kubernetes cluster')
%p
- - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Please enter access information for your cluster. If you need help, you can read our %{link_to_help_page} on clusters').html_safe % { link_to_help_page: link_to_help_page }
+ - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index', anchor: 'adding-an-existing-kubernetes-cluster'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes').html_safe % { link_to_help_page: link_to_help_page }
diff --git a/app/views/projects/clusters/user/_show.html.haml b/app/views/projects/clusters/user/_show.html.haml
index 89595bca007..ebbf7e775c7 100644
--- a/app/views/projects/clusters/user/_show.html.haml
+++ b/app/views/projects/clusters/user/_show.html.haml
@@ -1,12 +1,8 @@
= form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field|
= form_errors(@cluster)
.form-group
- = field.label :name, s_('ClusterIntegration|Cluster name')
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name')
-
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope')
- = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope')
+ = field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
+ = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
= field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field|
.form-group
diff --git a/app/views/projects/clusters/user/new.html.haml b/app/views/projects/clusters/user/new.html.haml
index 68f38f83453..7fb75cd9cc7 100644
--- a/app/views/projects/clusters/user/new.html.haml
+++ b/app/views/projects/clusters/user/new.html.haml
@@ -1,11 +1,11 @@
-- breadcrumb_title "Cluster"
-- page_title _("New Cluster")
+- breadcrumb_title 'Kubernetes'
+- page_title _("New Kubernetes cluster")
.row.prepend-top-default
.col-sm-4
= render 'projects/clusters/sidebar'
.col-sm-8
- = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Add an existing cluster')
+ = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Add an existing Kubernetes cluster')
= render 'header'
.prepend-top-20
= render 'form'
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index d0a380516f9..21e4664d4e4 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -4,6 +4,7 @@
- branch_label = s_('ChangeTypeActionLabel|Revert in branch')
- revert_merge_request = _('Revert this merge request')
- revert_commit = _('Revert this commit')
+ - description = s_('ChangeTypeAction|This will create a new commit in order to revert the existing changes.')
- title = commit.merged_merge_request(current_user) ? revert_merge_request : revert_commit
- when 'cherry-pick'
- label = s_('ChangeTypeAction|Cherry-pick')
@@ -17,6 +18,8 @@
%a.close{ href: "#", "data-dismiss" => "modal" } ×
%h3.page-title= title
.modal-body
+ - if description
+ %p.append-bottom-20= description
= 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'
@@ -32,6 +35,4 @@
= submit_tag label, 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
+ = render 'shared/projects/edit_information'
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 09934c09865..74c5317428c 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -49,10 +49,10 @@
.commit-box{ data: { project_path: project_path(@project) } }
%h3.commit-title
- = markdown(@commit.title, pipeline: :single_line, author: @commit.author)
+ = markdown_field(@commit, :title)
- if @commit.description.present?
%pre.commit-description
- = preserve(markdown(@commit.description, pipeline: :single_line, author: @commit.author))
+ = preserve(markdown_field(@commit, :description))
.info-well
.well-segment.branch-info
@@ -64,6 +64,12 @@
.commit-info.branches
%i.fa.fa-spinner.fa-spin
+ .well-segment.merge-request-info
+ .icon-container
+ = custom_icon('mr_bold')
+ %span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) }
+ = icon('spinner spin')
+
- if @commit.last_pipeline
- last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info
diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml
index 84a52d49487..a264f3517c4 100644
--- a/app/views/projects/commit/_limit_exceeded_message.html.haml
+++ b/app/views/projects/commit/_limit_exceeded_message.html.haml
@@ -1,7 +1,7 @@
.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: "Project has too many #{label_for_message} to search"} }
.limit-icon
- if objects == :branch
- = icon('code-fork')
+ = sprite_icon('fork', size: 12)
- else
= icon('tag')
.limit-message
diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml
index 80eca96f7ce..d7bf2dc0cb6 100644
--- a/app/views/projects/commit/_other_user_signature_badge.html.haml
+++ b/app/views/projects/commit/_other_user_signature_badge.html.haml
@@ -1,6 +1,6 @@
- title = capture do
This commit was signed with a different user's verified signature.
-- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless', show_user: true }
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
index 49b0b314e1d..68b35072f26 100644
--- a/app/views/projects/commit/_pipelines_list.haml
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -6,7 +6,3 @@
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
} }
-
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('commit_pipelines')
diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
index e737de48e22..22ffd66ff8e 100644
--- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
+++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
@@ -2,6 +2,6 @@
This commit was signed with a verified signature, but the committer email
is <strong>not verified</strong> to belong to the same user.
-- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'icon_status_notfound_borderless', show_user: true }
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index 44aa8002f12..aac020b42c5 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -10,7 +10,7 @@
- title = capture do
.gpg-popover-status
.gpg-popover-icon{ class: css_class }
- = render "shared/icons/#{icon}.svg"
+ = sprite_icon(icon)
%div
= title
diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml
index 1af58027b83..00e1efe0582 100644
--- a/app/views/projects/commit/_unverified_signature_badge.html.haml
+++ b/app/views/projects/commit/_unverified_signature_badge.html.haml
@@ -1,6 +1,6 @@
- title = capture do
This commit was signed with an <strong>unverified</strong> signature.
-- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless' }
+- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless' }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml
index 423beba2120..31408806be7 100644
--- a/app/views/projects/commit/_verified_signature_badge.html.haml
+++ b/app/views/projects/commit/_verified_signature_badge.html.haml
@@ -2,6 +2,6 @@
This commit was signed with a <strong>verified</strong> signature and the
committer email is verified to belong to the same user.
-- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'icon_status_success_borderless', show_user: true }
+- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'status_success_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 2890e9d2b65..abb292f8f27 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -6,9 +6,6 @@
- @content_class = limited_container_width
- page_title "#{@commit.title} (#{@commit.short_id})", "Commits"
- page_description @commit.description
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('diff_notes')
.container-fluid{ class: [limited_container_width, container_class] }
= render "commit_box"
diff --git a/app/views/projects/commits/_commit.atom.builder b/app/views/projects/commits/_commit.atom.builder
index d806acdda13..640b5ecf99e 100644
--- a/app/views/projects/commits/_commit.atom.builder
+++ b/app/views/projects/commits/_commit.atom.builder
@@ -1,14 +1,14 @@
xml.entry do
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.title truncate(commit.title, length: 80, escape: false)
xml.updated commit.committed_date.xmlschema
- xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(commit.author_email))
+ xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_email(commit.author_email))
xml.author do |author|
xml.name commit.author_name
xml.email commit.author_email
end
- xml.summary markdown(commit.description, pipeline: :single_line)
+ xml.summary markdown_field(commit, :description), type: 'html'
end
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index d66066a6d0b..163432c9263 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -6,7 +6,7 @@
- link = commit_path(project, commit, merge_request: merge_request)
- cache_key = [project.full_path,
commit.id,
- current_application_settings,
+ Gitlab::CurrentSettings.current_application_settings,
@path.presence,
current_controller?(:commits),
merge_request&.iid,
@@ -20,9 +20,12 @@
.avatar-cell.hidden-xs
= author_avatar(commit, size: 36)
- .commit-detail
+ .commit-detail.flex-list
.commit-content
- = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title")
+ - if view_details && merge_request
+ = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title"
+ - else
+ = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title")
%span.commit-row-message.visible-xs-inline
&middot;
= commit.short_id
@@ -51,9 +54,10 @@
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
- = link_to commit.short_id, link, class: "commit-sha btn btn-transparent btn-link"
- = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"))
- = link_to_browse_code(project, commit)
+ .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id) } }
- - if view_details && merge_request
- = link_to "View details", project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "btn btn-default"
+ .commit-sha-group
+ .label.label-monospace
+ = commit.short_id
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body")
+ = link_to_browse_code(project, commit)
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index a518fced2b4..d0c8a699608 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -5,22 +5,24 @@
= link_to icon('exchange'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn btn-white', title: 'Swap revisions'
.form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
.input-group.inline-input-group
- %span.input-group-addon Source
+ %span.input-group-addon
+ = s_("CompareBranches|Source")
= hidden_field_tag :to, params[:to]
= 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'
+ .dropdown-toggle-text.str-truncated= params[:to] || _("Select branch/tag")
= render 'shared/ref_dropdown'
.compare-ellipsis.inline ...
.form-group.dropdown.compare-form-group.from.js-compare-from-dropdown
.input-group.inline-input-group
- %span.input-group-addon Target
+ %span.input-group-addon
+ = s_("CompareBranches|Target")
= hidden_field_tag :from, params[:from]
= 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'
+ .dropdown-toggle-text.str-truncated= params[:from] || _("Select branch/tag")
= render 'shared/ref_dropdown'
&nbsp;
- = button_tag "Compare", class: "btn btn-create commits-compare-btn"
+ = button_tag s_("CompareBranches|Compare"), class: "btn btn-create commits-compare-btn"
- if @merge_request.present?
- = link_to "View open merge request", project_merge_request_path(@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'
+ = link_to _("Create merge request"), create_mr_path, class: 'prepend-left-10 btn'
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index 3ad0166e9cd..14c64b3534a 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -3,22 +3,16 @@
- page_title "Compare"
%div{ class: container_class }
+ %h3.page-title
+ = _("Compare Git revisions")
.sub-header-block
- Compare Git revisions.
- %br
- Choose a branch/tag (e.g.
- = succeed ')' do
+ - example_master = capture do
%code.ref-name master
- or enter a commit SHA (e.g.
- = succeed ')' do
+ - example_sha = capture do
%code.ref-name 4eedf23
- to see what's changed or to create a merge request.
+ = (_("Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.") % { master: example_master, sha: example_sha }).html_safe
%br
- Changes are shown as if the
- %b source
- revision was being merged into the
- %b target
- revision.
+ = (_("Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision.")).html_safe
.prepend-top-20
= render "form"
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index f87f1d476f5..8da55664878 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,5 +1,5 @@
- @no_container = true
-- add_to_breadcrumbs "Compare Revisions", project_compare_index_path(@project)
+- add_to_breadcrumbs _("Compare Revisions"), project_compare_index_path(@project)
- page_title "#{params[:from]}...#{params[:to]}"
%div{ class: container_class }
@@ -13,12 +13,13 @@
.light-well
.center
%h4
- There isn't anything to compare.
+ = s_("CompareBranches|There isn't anything to compare.")
%p.slead
- if params[:to] == params[:from]
- %span.ref-name= params[:from]
- and
- %span.ref-name= params[:to]
- are the same.
+ - source_branch = capture do
+ %span.ref-name= params[:from]
+ - target_branch = capture do
+ %span.ref-name= params[:to]
+ = (s_("CompareBranches|%{source_branch} and %{target_branch} are the same.") % { source_branch: source_branch, target_branch: target_branch }).html_safe
- else
- You'll need to use different branch names to get a valid comparison.
+ = _("You'll need to use different branch names to get a valid comparison.")
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 71d30da14a9..5041f322612 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,8 +1,5 @@
- @no_container = true
- page_title "Cycle Analytics"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('cycle_analytics')
#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/projects/deploy_keys/_form.html.haml
index edaa3a1119e..c363180d0db 100644
--- a/app/views/projects/deploy_keys/_form.html.haml
+++ b/app/views/projects/deploy_keys/_form.html.haml
@@ -10,13 +10,15 @@
%p.light.append-bottom-0
Paste a machine public key here. Read more about how to generate it
= link_to "here", help_page_path("ssh/README")
- .form-group
- .checkbox
- = f.label :can_push do
- = f.check_box :can_push
- %strong Write access allowed
- .form-group
- %p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
+
+ = f.fields_for :deploy_keys_projects do |deploy_keys_project_form|
+ .form-group
+ .checkbox
+ = deploy_keys_project_form.label :can_push do
+ = deploy_keys_project_form.check_box :can_push
+ %strong Write access allowed
+ .form-group
+ %p.light.append-bottom-0
+ Allow this key to push to repository as well? (Default only allows pull access.)
= f.submit "Add key", class: "btn-create btn"
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index e75ae87e771..7dd8dc28e5b 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -3,7 +3,7 @@
.settings-header
%h4
Deploy Keys
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' }
= 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.
diff --git a/app/views/projects/deploy_tokens/_form.html.haml b/app/views/projects/deploy_tokens/_form.html.haml
new file mode 100644
index 00000000000..f8db30df7b4
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_form.html.haml
@@ -0,0 +1,29 @@
+%p.profile-settings-content
+ = s_("DeployTokens|Pick a name for the application, and we'll give you a unique deploy token.")
+
+= form_for token, url: create_deploy_token_namespace_project_settings_repository_path(project.namespace, project), method: :post do |f|
+ = form_errors(token)
+
+ .form-group
+ = f.label :name, class: 'label-light'
+ = f.text_field :name, class: 'form-control', required: true
+
+ .form-group
+ = f.label :expires_at, class: 'label-light'
+ = f.text_field :expires_at, class: 'datepicker form-control', value: f.object.expires_at
+
+ .form-group
+ = f.label :scopes, class: 'label-light'
+ %fieldset
+ = f.check_box :read_repository
+ = label_tag ("deploy_token_read_repository"), 'read_repository'
+ %span= s_('DeployTokens|Allows read-only access to the repository')
+
+ - if container_registry_enabled?(project)
+ %fieldset
+ = f.check_box :read_registry
+ = label_tag ("deploy_token_read_registry"), 'read_registry'
+ %span= s_('DeployTokens|Allows read-only access to the registry images')
+
+ .prepend-top-default
+ = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success'
diff --git a/app/views/projects/deploy_tokens/_index.html.haml b/app/views/projects/deploy_tokens/_index.html.haml
new file mode 100644
index 00000000000..50e5950ced4
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_index.html.haml
@@ -0,0 +1,18 @@
+- expanded = expand_deploy_tokens_section?(@new_deploy_token)
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4= s_('DeployTokens|Deploy Tokens')
+ %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = s_('DeployTokens|Deploy tokens allow read-only access to your repository and registry images.')
+ .settings-content
+ - if @new_deploy_token.persisted?
+ = render 'projects/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token
+ - else
+ %h5.prepend-top-0
+ = s_('DeployTokens|Add a deploy token')
+ = render 'projects/deploy_tokens/form', project: @project, token: @new_deploy_token, presenter: @deploy_tokens
+ %hr
+ = render 'projects/deploy_tokens/table', project: @project, active_tokens: @deploy_tokens
diff --git a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml
new file mode 100644
index 00000000000..1e715681e59
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml
@@ -0,0 +1,14 @@
+.created-deploy-token-container
+ %h5.prepend-top-0
+ = s_('DeployTokens|Your New Deploy Token')
+
+ .form-group
+ = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus'
+ = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left')
+ %span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.")
+
+ .form-group
+ = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus'
+ = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left')
+ %span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.")
+%hr
diff --git a/app/views/projects/deploy_tokens/_revoke_modal.html.haml b/app/views/projects/deploy_tokens/_revoke_modal.html.haml
new file mode 100644
index 00000000000..085964fe22e
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_revoke_modal.html.haml
@@ -0,0 +1,17 @@
+.modal{ id: "revoke-modal-#{token.id}" }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h4.modal-title.pull-left
+ = s_('DeployTokens|Revoke')
+ %b #{token.name}?
+ %button.close{ 'aria-label' => _('Close'), 'data-dismiss' => 'modal', type: 'button' }
+ %span{ 'aria-hidden' => 'true' } &times;
+ .modal-body
+ %p
+ = s_('DeployTokens|You are about to revoke')
+ %b #{token.name}.
+ = s_('DeployTokens|This action cannot be undone.')
+ .modal-footer
+ %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel')
+ = link_to s_('DeployTokens|Revoke %{name}') % { name: token.name }, revoke_project_deploy_token_path(project, token), method: :put, class: 'btn btn-danger'
diff --git a/app/views/projects/deploy_tokens/_table.html.haml b/app/views/projects/deploy_tokens/_table.html.haml
new file mode 100644
index 00000000000..5013a9b250d
--- /dev/null
+++ b/app/views/projects/deploy_tokens/_table.html.haml
@@ -0,0 +1,31 @@
+%h5= s_("DeployTokens|Active Deploy Tokens (%{active_tokens})") % { active_tokens: active_tokens.length }
+
+- if active_tokens.present?
+ .table-responsive.deploy-tokens
+ %table.table
+ %thead
+ %tr
+ %th= s_('DeployTokens|Name')
+ %th= s_('DeployTokens|Username')
+ %th= s_('DeployTokens|Created')
+ %th= s_('DeployTokens|Expires')
+ %th= s_('DeployTokens|Scopes')
+ %th
+ %tbody
+ - active_tokens.each do |token|
+ %tr
+ %td= token.name
+ %td= token.username
+ %td= token.created_at.to_date.to_s(:medium)
+ %td
+ - if token.expires?
+ %span{ class: ('text-warning' if token.expires_soon?) }
+ In #{distance_of_time_in_words_to_now(token.expires_at)}
+ - else
+ %span.token-never-expires-label Never
+ %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>"
+ %td= link_to s_('DeployTokens|Revoke'), "#", class: "btn btn-danger pull-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"}
+ = render 'projects/deploy_tokens/revoke_modal', token: token, project: project
+- else
+ .settings-message.text-center
+ = s_('DeployTokens|This project has no active Deploy Tokens.')
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 014486be868..c7ac687e4a6 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -2,7 +2,7 @@
.branch-commit
- if deployment.ref
%span.icon-container
- = deployment.tag? ? icon('tag') : icon('code-fork')
+ = deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
.icon-container.commit-icon
= custom_icon("icon_commit")
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index adc4dcbed33..47bfcb21cf4 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -11,13 +11,13 @@
- unless diff_file.submodule?
- blob = diff_file.blob
.file-actions.hidden-xs
- - if blob.readable_text?
+ - 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')
\
- if editable_diff?(diff_file)
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
- = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
+ = edit_blob_button(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
blob: blob, link_opts: link_opts)
- if image_diff && image_replaced
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index 73c316472e3..dbeddf6689a 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -35,3 +35,6 @@
- if diff_file.mode_changed?
%small
#{diff_file.a_mode} → #{diff_file.b_mode}
+
+ - if diff_file.stored_externally? && diff_file.external_storage == :lfs
+ %span.label.label-lfs.append-right-5 LFS
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index dd473ebe580..6fd6018dea3 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -7,9 +7,9 @@
= icon("caret-down", class: "prepend-left-5")
%span.diff-stats-additions-deletions-expanded#diff-stats
with
- %strong.cgreen #{sum_added_lines} additions
+ %strong.cgreen= pluralize(sum_added_lines, 'addition')
and
- %strong.cred #{sum_removed_lines} deletions
+ %strong.cred= pluralize(sum_removed_lines, 'deletion')
.diff-stats-additions-deletions-collapsed.pull-right.hidden-xs.hidden-sm{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
%strong.cgreen<
+#{sum_added_lines}
@@ -24,8 +24,13 @@
%a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path }
= sprite_icon(diff_file_changed_icon(diff_file), size: 16, css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon append-right-8")
%span.diff-changed-file-content.append-right-8
- %strong.diff-changed-file-name= diff_file.blob.name
- %span.diff-changed-file-path.prepend-top-5= diff_file.new_path
+ - if diff_file.blob&.name
+ %strong.diff-changed-file-name
+ = diff_file.blob.name
+ - else
+ %strong.diff-changed-blank-file-name
+ = s_('Diffs|No file name available')
+ %span.diff-changed-file-path.prepend-top-5= diff_file_path_text(diff_file)
%span.diff-changed-stats
%span.cgreen<
+#{diff_file.added_lines}
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 71206f3a386..99eeb9551e3 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -8,7 +8,7 @@
.settings-header
%h4
General project settings
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Update your project name, description, avatar, and other general settings.
@@ -42,35 +42,35 @@
= f.text_field :tag_list, value: @project.tag_list.sort.join(', '), maxlength: 2000, class: "form-control"
%p.help-block Separate tags with commas.
%fieldset.features
- %h5.prepend-top-0
- Project avatar
+ %h5.prepend-top-0= _("Project avatar")
.form-group
- if @project.avatar?
- .avatar-container.s160
+ .avatar-container.s160.append-bottom-15
= project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160')
- %p.light
- - if @project.avatar_in_git
- Project avatar in repository: #{ @project.avatar_in_git }
- %a.choose-btn.btn.js-choose-project-avatar-button
- Browse file...
- %span.file_name.prepend-left-default.js-avatar-filename No file chosen
- = f.file_field :avatar, class: "js-project-avatar-input hidden"
- .help-block The maximum file size allowed is 200KB.
+ - if @project.avatar_in_git
+ %p.light
+ = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
+ .prepend-top-5.append-bottom-10
+ %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...")
+ %span.file_name.prepend-left-default.js-avatar-filename= _("No file chosen")
+ = f.file_field :avatar, class: "js-project-avatar-input hidden"
+ .help-block= _("The maximum file size allowed is 200KB.")
- if @project.avatar?
%hr
- = 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"
+ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted"
+ = f.submit 'Save changes', class: "btn btn-success js-btn-save-general-project-settings"
%section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Permissions
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Enable or disable certain project features and choose access levels.
.settings-content
= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
+ -# haml-lint:disable InlineJavaScript
%script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project)
.js-project-permissions-form
= f.submit 'Save changes', class: "btn btn-save"
@@ -79,14 +79,14 @@
.settings-header
%h4
Merge request settings
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Customize your merge request restrictions.
.settings-content
= 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"
+ = f.submit 'Save changes', class: "btn btn-save qa-save-merge-request-changes"
= render 'export', project: @project
@@ -94,7 +94,7 @@
.settings-header
%h4
Advanced settings
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project.
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 58e89a481a9..b15fe514a08 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,41 +1,45 @@
- @no_container = true
-- breadcrumb_title "Details"
+- breadcrumb_title _("Details")
= render partial: 'flash_messages', locals: { project: @project }
= render "home_panel"
-.row-content-block.second-block.center
- %h3.page-title
- The repository for this project is empty
- - if can?(current_user, :push_code, @project)
- %p
- If you already have files you can push them using command line instructions below.
- %p
- Otherwise you can start with adding a
- = succeed ',' do
- = link_to "README", add_special_file_path(@project, file_name: 'README.md')
- a
- = succeed ',' do
- = link_to "LICENSE", add_special_file_path(@project, file_name: 'LICENSE')
- or a
- = link_to '.gitignore', add_special_file_path(@project, file_name: '.gitignore')
- to this project.
- %p
- You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected.
+.project-empty-note-panel
+ %div{ class: [container_class, ("limit-container-width-sm" unless fluid_layout)] }
+ .prepend-top-20
+ %h4
+ = _('The repository for this project is empty')
+
+ - if @project.can_current_user_push_code?
+ %p
+ - link_to_cli = link_to _('command line instructions'), '#repo-command-line-instructions'
+ = _('If you already have files you can push them using the %{link_to_cli} below.').html_safe % { link_to_cli: link_to_cli }
+ %p
+ %em
+ - link_to_protected_branches = link_to _('Learn more about protected branches'), help_page_path('user/project/protected_branches')
+ = _('Note that the master branch is automatically protected. %{link_to_protected_branches}').html_safe % { link_to_protected_branches: link_to_protected_branches }
- - if show_auto_devops_callout?(@project)
+ %hr
%p
- - link = link_to(s_('AutoDevOps|Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'))
- = s_('AutoDevOps|You can activate %{link_to_settings} for this project.').html_safe % { link_to_settings: link }
+ - link_to_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'))
+ - link_to_add_kubernetes_cluster = link_to(s_('AutoDevOps|add a Kubernetes cluster'), new_project_cluster_path(@project))
+ = s_('AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}.').html_safe % { link_to_auto_devops_settings: link_to_auto_devops_settings, link_to_add_kubernetes_cluster: link_to_add_kubernetes_cluster }
+
+ %hr
%p
- = s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
+ = _('Otherwise it is recommended you start with one of the options below.')
+ .prepend-top-20
+
+%nav.project-stats{ class: container_class }
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
- if can?(current_user, :push_code, @project)
- %div{ class: container_class }
+ %div{ class: [container_class, ("limit-container-width-sm" unless fluid_layout)] }
.prepend-top-20
.empty_wrapper
- %h3.page-title-empty
+ %h3#repo-command-line-instructions.page-title-empty
Command line instructions
.git-empty
%fieldset
@@ -79,4 +83,4 @@
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to 'Remove project', [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right"
+ = link_to 'Remove project', [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove pull-right"
diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml
index d9c9f0ed546..1ac7dab6775 100644
--- a/app/views/projects/environments/folder.html.haml
+++ b/app/views/projects/environments/folder.html.haml
@@ -1,10 +1,6 @@
- @no_container = true
- page_title "Environments"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag("environments_folder")
-
#environments-folder-list-view{ data: { endpoint: folder_project_environments_path(@project, @folder, format: :json),
"folder-name" => @folder,
"can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 88f1348da47..7ebe617766f 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -2,10 +2,6 @@
- page_title "Environments"
- 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")
-
#environments-list-view{ data: { environments_data: environments_list_data,
"can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
index ad94113fffd..d6f0b230b58 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -1,9 +1,5 @@
- @no_container = true
- page_title "Metrics for environment", @environment.name
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'common_d3'
- = webpack_bundle_tag 'monitoring'
.prometheus-container{ class: container_class }
.top-area
@@ -14,11 +10,14 @@
= link_to @environment.name, environment_path(@environment)
#prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'),
+ "clusters-path": project_clusters_path(@project),
"documentation-path": help_page_path('administration/monitoring/prometheus/index.md'),
"empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'),
+ "empty-no-data-svg-path": image_path('illustrations/monitoring/no_data.svg'),
"empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'),
- "additional-metrics": additional_metrics_project_environment_path(@project, @environment, format: :json),
+ "metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json),
+ "deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json),
"project-path": project_path(@project),
"tags-path": project_tags_path(@project),
- "has-metrics": "#{@environment.has_metrics?}", deployment_endpoint: project_environment_deployments_path(@project, @environment, format: :json) } }
+ "has-metrics": "#{@environment.has_metrics?}" } }
diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml
index a073a164f11..6ec4ff56552 100644
--- a/app/views/projects/environments/terminal.html.haml
+++ b/app/views/projects/environments/terminal.html.haml
@@ -3,7 +3,6 @@
- content_for :page_specific_javascripts do
= stylesheet_link_tag "xterm/xterm"
- = page_specific_javascript_bundle_tag("terminal")
%div{ class: container_class }
.top-area
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index d365bcd4ecc..e8a89b8c6fc 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -2,7 +2,7 @@
- if @forked_project && !@forked_project.saved?
.alert.alert-danger.alert-block
%h4
- %i.fa.fa-code-fork
+ = sprite_icon('fork', size: 16)
Fork Error!
%p
You tried to fork
@@ -21,5 +21,4 @@
%p
= 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 111cbcda266..21a4702a2a9 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -31,11 +31,11 @@
- 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 btn-new' do
- = custom_icon('icon_fork')
+ = sprite_icon('fork', size: 12)
%span Fork
- else
= link_to new_project_fork_path(@project), title: "Fork project", class: 'btn btn-new' do
- = custom_icon('icon_fork')
+ = sprite_icon('fork', size: 12)
%span Fork
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 b98dc09534f..620fd1906ba 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
@@ -19,7 +19,7 @@
- if ref
- if generic_commit_status.ref
.icon-container
- = generic_commit_status.tags.any? ? icon('tag') : icon('code-fork')
+ = generic_commit_status.tags.any? ? icon('tag') : sprite_icon('fork', size: 10)
= link_to generic_commit_status.ref, project_commits_path(generic_commit_status.project, generic_commit_status.ref)
- else
.light none
@@ -53,7 +53,7 @@
- if admin
%td
- if generic_commit_status.project
- = link_to generic_commit_status.project.name_with_namespace, admin_project_path(generic_commit_status.project)
+ = link_to generic_commit_status.project.full_name, 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 ffb9238a65a..14c47a5d91c 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -1,13 +1,9 @@
- @no_container = true
- page_title "Charts"
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_d3')
- = webpack_bundle_tag('graphs')
- = webpack_bundle_tag('graphs_charts')
.repo-charts{ class: container_class }
%h4.sub-header
- Programming languages used in this repository
+ = _("Programming languages used in this repository")
.row
.col-md-4
@@ -30,9 +26,11 @@
.row.tree-ref-header
.col-md-6
%h4
- Commit statistics for
- %strong= @ref
- #{@commits_graph.start_date.strftime('%b %d')} - #{@commits_graph.end_date.strftime('%b %d')}
+ - start_time = capture do
+ #{@commits_graph.start_date.strftime('%b %d')}
+ - end_time = capture do
+ #{@commits_graph.end_date.strftime('%b %d')}
+ = (_("Commit statistics for %{ref} %{start_time} - %{end_time}") % { ref: "<strong>#{@ref}</strong>", start_time: start_time, end_time: end_time }).html_safe
.col-md-6
.tree-ref-container
@@ -45,34 +43,38 @@
.col-md-6
%ul.commit-stats
%li
- Total:
- %strong #{@commits_graph.commits.size} commits
+ - total = capture do
+ #{@commits_graph.commits.size}
+ = (_("Total: %{total}") % { total: "<strong>#{total} commits</strong>" }).html_safe
%li
- Average per day:
- %strong #{@commits_graph.commit_per_day} commits
+ - average = capture do
+ #{@commits_graph.commit_per_day}
+ = (_("Average per day: %{average}") % { average: "<strong>#{average} commits</strong>" }).html_safe
%li
- Authors:
- %strong= @commits_graph.authors
+ - authors = capture do
+ #{@commits_graph.authors}
+ = (_("Authors: %{authors}") % { authors: "<strong>#{authors}</strong>" }).html_safe
.col-md-6
%div
%p.slead
- Commits per day of month
+ = _("Commits per day of month")
%canvas#month-chart
.row
.col-md-6
.col-md-6
%div
%p.slead
- Commits per weekday
+ = _("Commits per weekday")
%canvas#weekday-chart
.row
.col-md-6
.col-md-6
%div
%p.slead
- Commits per day hour (UTC)
+ = _("Commits per day hour (UTC)")
%canvas#hour-chart
+-# haml-lint:disable InlineJavaScript
%script#projectChartData{ type: "application/json" }
- projectChartData = {};
- projectChartData['hour'] = @commits_per_time
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index cce16bc58b3..c81ee6874e3 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -1,9 +1,5 @@
- @no_container = true
- page_title _('Contributors')
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('common_d3')
- = webpack_bundle_tag('graphs')
- = webpack_bundle_tag('graphs_show')
.js-graphs-show{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) }
.sub-header-block
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index b1219f019d7..dcc1f0e3fbe 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -12,7 +12,7 @@
= 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
+ = 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
diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml
index 8c490773a56..3b0c828ccd1 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -1,12 +1,11 @@
-- page_title @project.forked? ? "Forking in progress" : "Import in progress"
+- page_title import_in_progress_title
+
.save-project-loader
.center
%h2
%i.fa.fa-spinner.fa-spin
- - if @project.forked?
- Forking in progress.
- - else
- Import in progress.
- - if @project.external_import?
+ = import_in_progress_title
+ - if !has_ci_cd_only_params? && @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.
+ %p
+ = import_wait_and_refresh_message
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 9779c1985d5..816f2fa816d 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -6,12 +6,7 @@
= 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'
%section.js-vue-notes-event
- #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json),
- register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
- new_session_path: new_session_path(:user, redirect_to_referer: 'yes'),
- markdown_docs_path: help_page_path('user/markdown'),
- quick_actions_docs_path: help_page_path('user/project/quick_actions'),
- notes_path: notes_url,
- last_fetched_at: Time.now.to_i,
- noteable_data: serialize_issuable(@issue),
- current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
+ #js-vue-notes{ data: { notes_data: notes_data(@issue),
+ noteable_data: serialize_issuable(@issue),
+ noteable_type: 'issue',
+ current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 64c648f201b..0c58dd60e2c 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -7,7 +7,9 @@
.issue-main-info
.issue-title.title
%span.issue-title-text
- = confidential_icon(issue)
+ - if issue.confidential?
+ %span.has-tooltip{ title: _('Confidential') }
+ = confidential_icon(issue)
= link_to issue.title, issue_path(issue)
- if issue.tasks?
%span.task-status.hidden-xs
@@ -24,11 +26,11 @@
- if issue.milestone
%span.issuable-milestone.hidden-xs
&nbsp;
- = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_title(issue.milestone) } do
+ = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(issue) } do
= icon('clock-o')
= issue.milestone.title
- if issue.due_date
- %span.issuable-due-date.hidden-xs{ class: "#{'cred' if issue.overdue?}" }
+ %span.issuable-due-date.hidden-xs.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') }
&nbsp;
= icon('calendar')
= issue.due_date.to_s(:medium)
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
index 5f97d31f610..5c36d2202a6 100644
--- a/app/views/projects/issues/_merge_requests.html.haml
+++ b/app/views/projects/issues/_merge_requests.html.haml
@@ -18,7 +18,7 @@
- unless @issue.project.id == merge_request.target_project.id
in
- project = merge_request.target_project
- = link_to project.name_with_namespace, project_path(project)
+ = link_to project.full_name, project_path(project)
- if merge_request.merged?
%span.merge-request-status.prepend-left-10.merged
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 331d62cf247..36e24037214 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -15,36 +15,39 @@
%span.text
Checking branch availability…
.btn-group.available.hide
- %button.btn.js-create-merge-request.btn-default{ type: 'button', data: { action: data_action } }
+ %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } }
= value
- %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-default.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } }
+ %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } }
= icon('caret-down')
- %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } }
- - if can_create_merge_request
- %li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } }
- .menu-item.droplab-item-ignore-hiding
- .icon-container.droplab-item-ignore-hiding= icon('check')
- .description.droplab-item-ignore-hiding Create merge request and branch
-
- %li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } }
- .menu-item.droplab-item-ignore-hiding
- .icon-container.droplab-item-ignore-hiding= icon('check')
- .description.droplab-item-ignore-hiding Create branch
- %li.divider
-
- %li.droplab-item-ignore
- Branch name
- %input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
- %span.js-branch-message.branch-message.droplab-item-ignore
-
- %li.droplab-item-ignore
- Source (branch or tag)
- %input.js-ref.ref.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } }
- %span.js-ref-message.ref-message.droplab-item-ignore
-
- %li.droplab-item-ignore
- %button.btn.btn-success.js-create-target.droplab-item-ignore{ type: 'button', data: { action: 'create-mr' } }
- Create merge request
-
+ .droplab-dropdown
+ %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } }
+ - if can_create_merge_request
+ %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: _('Create merge request') } }
+ .menu-item
+ = icon('check', class: 'icon')
+ = _('Create merge request and branch')
+
+ %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
+ .menu-item
+ = icon('check', class: 'icon')
+ = _('Create branch')
+ %li.divider.droplab-item-ignore
+
+ %li.droplab-item-ignore.prepend-left-8.append-right-8.prepend-top-16
+ .form-group
+ %label{ for: 'new-branch-name' }
+ = _('Branch name')
+ %input#new-branch-name.js-branch-name.form-control{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
+ %span.js-branch-message.help-block
+
+ .form-group
+ %label{ for: 'source-name' }
+ = _('Source (branch or tag)')
+ %input#source-name.js-ref.ref.form-control{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } }
+ %span.js-ref-message.help-block
+
+ .form-group
+ %button.btn.btn-success.js-create-target{ type: 'button', data: { action: 'create-mr' } }
+ = _('Create merge request')
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 193111b4cee..c427a9eedc2 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -4,10 +4,6 @@
- page_title "Issues"
- new_issue_email = @project.new_issuable_address(current_user, 'issue')
-- content_for :page_specific_javascripts do
- = 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")
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 1f28d8acff6..ec7e87219f5 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -5,10 +5,6 @@
- page_description @issue.description
- page_card_attributes @issue.card_attributes
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'notes'
-
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
@@ -59,7 +55,8 @@
.issue-details.issuable-details
.detail-page-description.content-block
- %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue)
+ -# haml-lint:disable InlineJavaScript
+ %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json
#js-issuable-app
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
@@ -77,7 +74,7 @@
.content-block.emoji-block
.row
- .col-sm-8.js-issue-note-awards
+ .col-sm-8.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential?
@@ -86,6 +83,3 @@
= render 'projects/issues/discussion'
= render 'shared/issuable/sidebar', issuable: @issue
-
-= page_specific_javascript_bundle_tag('common_vue')
-= page_specific_javascript_bundle_tag('issue_show')
diff --git a/app/views/projects/jobs/_empty_state.html.haml b/app/views/projects/jobs/_empty_state.html.haml
new file mode 100644
index 00000000000..311934d9c33
--- /dev/null
+++ b/app/views/projects/jobs/_empty_state.html.haml
@@ -0,0 +1,18 @@
+- illustration = local_assigns.fetch(:illustration)
+- illustration_size = local_assigns.fetch(:illustration_size)
+- title = local_assigns.fetch(:title)
+- content = local_assigns.fetch(:content, nil)
+- action = local_assigns.fetch(:action, nil)
+
+.row.empty-state
+ .col-xs-12
+ .svg-content{ class: illustration_size }
+ = image_tag illustration
+ .col-xs-12
+ .text-content
+ %h4.text-center= title
+ - if content
+ %p= content
+ - if action
+ .text-center
+ = action
diff --git a/app/views/projects/jobs/_empty_states.html.haml b/app/views/projects/jobs/_empty_states.html.haml
new file mode 100644
index 00000000000..e5198d047df
--- /dev/null
+++ b/app/views/projects/jobs/_empty_states.html.haml
@@ -0,0 +1,9 @@
+- detailed_status = @build.detailed_status(current_user)
+- illustration = detailed_status.illustration
+
+= render 'empty_state',
+ illustration: illustration[:image],
+ illustration_size: illustration[:size],
+ title: illustration[:title],
+ content: illustration[:content],
+ action: detailed_status.has_action? ? link_to(detailed_status.action_button_title, detailed_status.action_path, method: detailed_status.action_method, class: 'btn btn-primary', title: detailed_status.action_button_title) : nil
diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index a71333497e6..0b57ebedebd 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -1,5 +1,3 @@
-- 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
@@ -24,7 +22,7 @@
- elsif @build.has_expiring_artifacts?
%p.build-detail-row
The artifacts will be removed in
- %span.js-artifacts-remove= @build.artifacts_expire_at
+ %span= time_ago_in_words @build.artifacts_expire_at
- if @build.artifacts?
.btn-group.btn-group-justified{ role: :group }
@@ -35,7 +33,7 @@
= link_to download_project_job_artifacts_path(@project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do
Download
- - if @build.artifacts_metadata?
+ - if @build.browsable_artifacts?
= link_to browse_project_job_artifacts_path(@project, @build), class: 'btn btn-sm btn-default' do
Browse
@@ -91,7 +89,8 @@
- 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
+ - tooltip = build.tooltip_message
+ = link_to(project_job_path(@project, build), data: { toggle: 'tooltip', html: true, title: tooltip, container: 'body' }) do
= sprite_icon('arrow-right', size:16, css_class: 'icon-arrow-right')
%span{ class: "ci-status-icon-#{build.status}" }
= ci_icon_for_status(build.status)
@@ -101,5 +100,4 @@
- else
= build.id
- if build.retried?
- %span.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
- = sprite_icon('retry', size:16, css_class: 'icon-retry')
+ = sprite_icon('retry', size:16, css_class: 'icon-retry')
diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index 82806f022ee..d124d3ebfc1 100644
--- a/app/views/projects/jobs/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
@@ -22,4 +22,4 @@
= render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin }
- = paginate builds, theme: 'gitlab'
+ = paginate_collection(builds)
diff --git a/app/views/projects/jobs/_user.html.haml b/app/views/projects/jobs/_user.html.haml
index 83f299da651..461d503f95d 100644
--- a/app/views/projects/jobs/_user.html.haml
+++ b/app/views/projects/jobs/_user.html.haml
@@ -1,7 +1,7 @@
by
%a{ href: user_path(@build.user) }
%span.hidden-xs
- = image_tag avatar_icon(@build.user, 24), class: "avatar s24"
+ = image_tag avatar_icon_for_user(@build.user, 24), class: "avatar s24"
%strong{ data: { toggle: 'tooltip', placement: 'top', title: @build.user.to_reference } }
= @build.user.name
%strong.visible-xs-inline= @build.user.to_reference
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 1d0aaa47b60..8beb4ffef45 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -55,47 +55,45 @@
- 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-xs.pull-left.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
+ - if @build.has_trace?
+ .build-trace-container.prepend-top-default
+ .top-bar.js-top-bar
+ .js-truncated-info.truncated-info.hidden-xs.pull-left.hidden<
+ Showing last
+ %span.js-truncated-info-size.truncated-info-size><
+ of log -
+ %a.js-raw-link.raw-link{ href: raw_project_job_path(@project, @build) }>< Complete Raw
- .controllers.pull-right
- - 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')
+ .controllers.pull-right
+ - 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 @build.erasable? && can?(current_user, :erase_build, @build)
- = 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')
+ - if @build.erasable? && can?(current_user, :erase_build, @build)
+ = 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
+ %pre.build-trace#build-trace
+ %code.bash.js-build-output
+ .build-loader-animation.js-build-refresh
+ - else
+ = render "empty_states"
-
- = render "sidebar"
+ = render "sidebar", builds: @builds
.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')
+#js-job-details-vue{ data: { endpoint: project_job_path(@project, @build, format: :json), runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner') } }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 80e4dce1a80..9c78bade254 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -4,6 +4,7 @@
- can_admin_label = can?(current_user, :admin_label, @project)
- if @labels.exists? || @prioritized_labels.exists?
+ #promote-label-modal
%div{ class: container_class }
.top-area.adjust
.nav-text
diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml
index 917ec7fdbda..54a661040ea 100644
--- a/app/views/projects/merge_requests/_how_to_merge.html.haml
+++ b/app/views/projects/merge_requests/_how_to_merge.html.haml
@@ -1,6 +1,3 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('how_to_merge')
-
#modal_merge_info.modal
.modal-dialog
.modal-content
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 2b5e8711b0a..a94267deeb2 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -23,14 +23,14 @@
- 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), data: { html: 1, toggle: 'tooltip', title: milestone_tooltip_title(merge_request.milestone) } do
+ = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 1, toggle: 'tooltip', title: issuable_milestone_tooltip_title(merge_request) } do
= icon('clock-o')
= merge_request.milestone.title
- if merge_request.target_project.default_branch != merge_request.target_branch
- %span.project-ref-path
+ %span.project-ref-path.has-tooltip{ title: _('Target branch') }
&nbsp;
= link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do
- = icon('code-fork')
+ = sprite_icon('fork', size: 12, css_class: 'fork-sprite')
= merge_request.target_branch
- if merge_request.labels.any?
&nbsp;
@@ -51,11 +51,11 @@
= render_pipeline_status(merge_request.head_pipeline)
- if merge_request.open? && merge_request.broken?
%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
+ = link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') 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")
+ = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: _('Assigned to :name'))
= render 'shared/issuable_meta_data', issuable: merge_request
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
index 454bc359b6b..a6e2565a485 100644
--- a/app/views/projects/merge_requests/conflicts.html.haml
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -1,7 +1,5 @@
- 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"
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index 454bc359b6b..a6e2565a485 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,7 +1,5 @@
- 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"
diff --git a/app/views/projects/merge_requests/creations/_diffs.html.haml b/app/views/projects/merge_requests/creations/_diffs.html.haml
index 627fc4e9671..5b70e894b39 100644
--- a/app/views/projects/merge_requests/creations/_diffs.html.haml
+++ b/app/views/projects/merge_requests/creations/_diffs.html.haml
@@ -1 +1,5 @@
-= render "projects/diffs/diffs", diffs: @diffs, environment: @environment, show_whitespace_toggle: false
+- if @merge_request.can_be_created
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, show_whitespace_toggle: false
+- else
+ .nothing-here-block
+ This merge request cannot be created.
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 4b5fa28078a..376ac377562 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -15,7 +15,7 @@
= f.hidden_field :source_project_id
= f.hidden_field :source_branch
= f.hidden_field :target_project_id
- = f.hidden_field :target_branch
+ = f.hidden_field :target_branch, id: ''
.mr-compare.merge-request.js-merge-request-new-submit{ 'data-mr-submit-action': "#{j params[:tab].presence || 'new'}" }
- if @commits.empty?
diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml
index 60c91024b23..986ba5ae02d 100644
--- a/app/views/projects/merge_requests/diffs/_diffs.html.haml
+++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml
@@ -4,14 +4,17 @@
= render 'projects/merge_requests/diffs/commit_widget'
- if @merge_request_diff&.empty?
- .nothing-here-block
- = image_tag 'illustrations/merge_request_changes_empty.svg'
- = succeed '.' do
- No changes between
- %span.ref-name= @merge_request.source_branch
- and
- %span.ref-name= @merge_request.target_branch
- %p= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-save'
+ .row.empty-state.nothing-here-block
+ .col-xs-12
+ .svg-content= image_tag 'illustrations/merge_request_changes_empty.svg'
+ .col-xs-12
+ .text-content.text-center
+ %p
+ No changes between
+ %span.ref-name= @merge_request.source_branch
+ and
+ %span.ref-name= @merge_request.target_branch
+ .text-center= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-save'
- else
- diff_viewable = @merge_request_diff ? @merge_request_diff.collected? || @merge_request_diff.overflow? : true
- if diff_viewable
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 2ded7484151..b2c0d9e1cfa 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -6,11 +6,8 @@
- page_title "Merge Requests"
- new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request')
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
-
-= render 'projects/last_push'
+%div{ class: container_class }
+ = render 'projects/last_push'
- if @project.merge_requests.exists?
%div{ class: container_class }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 8740c6895df..15a0e4d7ef5 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -1,12 +1,10 @@
+- @gfm_form = true
- @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference
- 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'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
= render "projects/merge_requests/mr_title"
@@ -24,10 +22,7 @@
#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
+ .content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
@@ -55,28 +50,38 @@
= tab_link_for @merge_request, :diffs 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 }" }
- %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
- = render 'shared/icons/icon_status_success_solid.svg'
- %template{ 'v-else' => '' }
- = render 'shared/icons/icon_resolve_discussion.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"
+
+ - if has_vue_discussions_cookie?
+ #js-vue-discussion-counter
+ - else
+ #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 }" }
+ %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
+ = render 'shared/icons/icon_status_success_solid.svg'
+ %template{ 'v-else' => '' }
+ = render 'shared/icons/icon_resolve_discussion.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
+ %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
+ .issuable-discussion.js-vue-notes-event
= render "projects/merge_requests/discussion"
+ - if has_vue_discussions_cookie?
+ #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
+ noteable_data: serialize_issuable(@merge_request),
+ noteable_type: 'merge_request',
+ current_user_data: UserSerializer.new.represent(current_user).to_json} }
#commits.commits.tab-pane
-# This tab is always loaded via AJAX
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index fcbf7cb802b..5b0197ed58c 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -12,6 +12,9 @@
New milestone
.milestones
+ #delete-milestone-modal
+ #promote-milestone-modal
+
%ul.content-list
= render @milestones
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 9fc297ab7f6..5ec219fdf00 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -27,16 +27,34 @@
Edit
- if @project.group
- = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do
- Promote
+ %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal',
+ target: '#promote-milestone-modal',
+ milestone_title: @milestone.title,
+ group_name: @project.group.name,
+ url: promote_project_milestone_path(@milestone.project, @milestone),
+ container: 'body' },
+ disabled: true,
+ type: 'button' }
+ = _('Promote')
+ #promote-milestone-modal
- if @milestone.active?
= 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', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
- = link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do
- Delete
+ %button.js-delete-milestone-button.btn.btn-grouped.btn-danger{ data: { toggle: 'modal',
+ target: '#delete-milestone-modal',
+ milestone_id: @milestone.id,
+ milestone_title: markdown_field(@milestone, :title),
+ milestone_url: project_milestone_path(@project, @milestone),
+ milestone_issue_count: @milestone.issues.count,
+ milestone_merge_request_count: @milestone.merge_requests.count },
+ disabled: true }
+ = _('Delete')
+ = icon('spin spinner', class: 'js-loading-icon hidden' )
+
+ #delete-milestone-modal
%a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" }
= icon('angle-double-left')
diff --git a/app/views/projects/network/_head.html.haml b/app/views/projects/network/_head.html.haml
index 8f6805268d5..f08526f485e 100644
--- a/app/views/projects/network/_head.html.haml
+++ b/app/views/projects/network/_head.html.haml
@@ -6,4 +6,4 @@
= render partial: 'shared/ref_switcher', locals: {destination: 'graph'}
.oneline
- You can move around the graph by using the arrow keys.
+ = _("You can move around the graph by using the arrow keys.")
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index 8a19497c55b..4b7be9a223f 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -1,20 +1,18 @@
- breadcrumb_title "Graph"
- page_title "Graph", @ref
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('network')
= render "head"
%div{ class: container_class }
.project-network
.controls
= 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'
+ = 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')
.inline.prepend-left-20
.checkbox.light
= label_tag :filter_ref do
= check_box_tag :filter_ref, 1, @options[:filter_ref]
- %span Begin with the selected commit
+ %span= _("Begin with the selected commit")
- if @commit
.network-graph{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } }
diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb
index 122e84b41b2..a0e82e891ff 100644
--- a/app/views/projects/network/show.json.erb
+++ b/app/views/projects/network/show.json.erb
@@ -9,11 +9,11 @@
author: {
name: c.author_name,
email: c.author_email,
- icon: image_path(avatar_icon(c.author_email, 20))
+ icon: image_path(avatar_icon_for_email(c.author_email, 20))
},
time: c.time,
space: c.spaces.first,
- refs: get_refs(@graph.repo, c),
+ refs: refs(@graph.repo, c),
id: c.sha,
date: c.date,
message: c.message,
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 2f56630c22e..b66e0559603 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -4,8 +4,7 @@
- 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'
+- active_tab = local_assigns.fetch(:active_tab, 'blank')
.project-edit-container
.project-edit-errors
@@ -13,45 +12,53 @@
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
- New project
+ = _('New project')
%p
- A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), #{link_to 'among other things', help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'}.
+ - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'
+ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link }
%p
- All features are enabled when you create a project, but you can disable the ones you don’t need in the project settings.
+ = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.')
.md
= brand_new_project_guidelines
+ %p
+ %strong= _("Tip:")
+ = _("You can also create a project from the command line.")
+ %a.push-new-project-tip{ data: { title: _("Push to create a project") }, href: help_page_path('gitlab-basics/create-project', anchor: 'push-to-create-a-new-project'), target: "_blank", rel: "noopener noreferrer" }
+ = _("Show command")
+ %template.push-new-project-tip-template= render partial: "new_project_push_tip"
+
.col-lg-9.js-toggle-container
%ul.nav-links.gitlab-tabs{ role: 'tablist' }
- %li.active{ role: 'presentation' }
+ %li{ class: active_when(active_tab == 'blank'), role: 'presentation' }
%a{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab' }, role: 'tab' }
%span.hidden-xs Blank project
%span.visible-xs Blank
- %li{ role: 'presentation' }
+ %li{ class: active_when(active_tab == 'template'), role: 'presentation' }
%a{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab' }, role: 'tab' }
%span.hidden-xs Create from template
%span.visible-xs Template
- %li{ role: 'presentation' }
+ %li{ class: active_when(active_tab == 'import'), role: 'presentation' }
%a{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab' }, role: 'tab' }
%span.hidden-xs Import project
%span.visible-xs Import
.tab-content.gitlab-tab-content
- .tab-pane.active{ id: 'blank-project-pane', role: 'tabpanel' }
+ .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
= render 'new_project_fields', f: f, project_name_id: "blank-project-name"
- .tab-pane.no-padding{ id: 'create-from-template-pane', role: 'tabpanel' }
+ .tab-pane.no-padding{ id: 'create-from-template-pane', class: active_when(active_tab == 'template'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
.project-template
.form-group
%div
= render 'project_templates', f: f
- .tab-pane.import-project-pane{ id: 'import-project-pane', role: 'tabpanel' }
+ .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
- if import_sources_enabled?
.project-import.row
- .col-sm-12
+ .col-lg-12
.form-group.import-btn-container.clearfix
= f.label :visibility_level, class: 'label-light' do #the label here seems wrong
Import project from
@@ -62,7 +69,7 @@
= icon('gitlab', text: 'GitLab export')
%div
- if github_import_enabled?
- = link_to new_import_github_path, class: 'btn import_github' do
+ = link_to new_import_github_path, class: 'btn js-import-github' do
= icon('github', text: 'GitHub')
%div
- if bitbucket_import_enabled?
@@ -91,10 +98,10 @@
Gitea
%div
- if git_import_enabled?
- %button.btn.js-toggle-button.import_git{ type: "button" }
+ %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } }
= icon('git', text: 'Repo by URL')
.col-lg-12
- .js-toggle-content.hide.toggle-import-form
+ .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
%hr
= render "shared/import_form", f: f
= render 'new_project_fields', f: f, project_name_id: "import-url-name"
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index ba5845877e5..14d880028c7 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,3 +1,5 @@
+- breadcrumb_title _("Details")
+
%h2
%i.fa.fa-warning
#{ _('No repository') }
@@ -10,7 +12,7 @@
.no-repo-actions
= link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do
- #{ _('Create empty bare repository') }
+ #{ _('Create empty repository') }
%strong.prepend-left-10.append-right-10 or
@@ -19,4 +21,4 @@
- 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-inverted btn-remove pull-right"
diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml
new file mode 100644
index 00000000000..6a3ffce949f
--- /dev/null
+++ b/app/views/projects/pages/_https_only.html.haml
@@ -0,0 +1,10 @@
+= form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f|
+ = f.check_box :pages_https_only, class: 'pull-left', disabled: pages_https_only_disabled?
+
+ .prepend-left-20
+ = f.label :pages_https_only, class: pages_https_only_label_class do
+ %strong Force domains with SSL certificates to use HTTPS
+
+ - unless pages_https_only_disabled?
+ .prepend-top-10
+ = f.submit 'Save', class: 'btn btn-success'
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index a85cda407af..27bbe52a714 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -1,17 +1,29 @@
+- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
+
- if can?(current_user, :update_pages, @project) && @domains.any?
.panel.panel-default
.panel-heading
Domains (#{@domains.count})
- %ul.well-list
+ %ul.well-list.pages-domain-list{ class: ("has-verification-status" if verification_enabled) }
- @domains.each do |domain|
- %li
- .pull-right
+ %li.pages-domain-list-item.unstyled
+ - if verification_enabled
+ - tooltip, status = domain.unverified? ? [_('Unverified'), 'failed'] : [_('Verified'), 'success']
+ .domain-status.ci-status-icon.has-tooltip{ class: "ci-status-icon-#{status}", title: tooltip }
+ = sprite_icon("status_#{status}", size: 16 )
+ .domain-name
+ = link_to domain.url do
+ = domain.url
+ = icon('external-link')
+ - if domain.subject
+ %p
+ %span.label.label-gray Certificate: #{domain.subject}
+ - if domain.expired?
+ %span.label.label-danger Expired
+ %div
= 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
- - if domain.subject
- %span.label.label-gray Certificate: #{domain.subject}
- - if domain.expired?
- %span.label.label-danger Expired
+ - if verification_enabled && domain.unverified?
+ %li.warning-row
+ #{domain.domain} is not verified. To learn how to verify ownership, visit your
+ #{link_to 'domain details', project_pages_domain_path(@project, domain)}.
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 04e647c0dc6..6adaea799b2 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,11 +1,10 @@
- page_title 'Pages'
-%h3.page_title
+%h3.page-title.with-button
Pages
- if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
= link_to new_project_pages_domain_path(@project), class: 'btn btn-new pull-right', title: 'New Domain' do
- %i.fa.fa-plus
New Domain
%p.light
@@ -13,6 +12,9 @@
Combined with the power of GitLab CI and the help of GitLab Runner
you can deploy static pages for your individual projects, your user or your group.
+- if Gitlab.config.pages.external_https
+ = render 'https_only'
+
%hr.clearfix
= render 'access'
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index ca1b41b140a..d81b07832bb 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -1,34 +1,30 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
- - if @domain.errors.any?
- #error_explanation
- .alert.alert-danger
- - @domain.errors.full_messages.each do |msg|
- %p= msg
+- if @domain.errors.any?
+ #error_explanation
+ .alert.alert-danger
+ - @domain.errors.full_messages.each do |msg|
+ %p= msg
+.form-group
+ = f.label :domain, class: 'control-label' do
+ Domain
+ .col-sm-10
+ = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted?
+
+- if Gitlab.config.pages.external_https
.form-group
- = f.label :domain, class: 'control-label' do
- Domain
+ = f.label :certificate, class: 'control-label' do
+ Certificate (PEM)
.col-sm-10
- = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control'
-
- - if Gitlab.config.pages.external_https
- .form-group
- = f.label :certificate, class: 'control-label' do
- Certificate (PEM)
- .col-sm-10
- = f.text_area :certificate, rows: 5, class: 'form-control'
- %span.help-inline Upload a certificate for your domain with all intermediates
-
- .form-group
- = f.label :key, class: 'control-label' do
- Key (PEM)
- .col-sm-10
- = f.text_area :key, rows: 5, class: 'form-control'
- %span.help-inline Upload a private key for your certificate
- - else
- .nothing-here-block
- Support for custom certificates is disabled.
- Ask your system's administrator to enable it.
+ = f.text_area :certificate, rows: 5, class: 'form-control'
+ %span.help-inline Upload a certificate for your domain with all intermediates
- .form-actions
- = f.submit 'Create New Domain', class: "btn btn-save"
+ .form-group
+ = f.label :key, class: 'control-label' do
+ Key (PEM)
+ .col-sm-10
+ = f.text_area :key, rows: 5, class: 'form-control'
+ %span.help-inline Upload a private key for your certificate
+- else
+ .nothing-here-block
+ Support for custom certificates is disabled.
+ Ask your system's administrator to enable it.
diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml
new file mode 100644
index 00000000000..6c404990492
--- /dev/null
+++ b/app/views/projects/pages_domains/edit.html.haml
@@ -0,0 +1,11 @@
+- add_to_breadcrumbs "Pages", project_pages_path(@project)
+- breadcrumb_title @domain.domain
+- page_title @domain.domain
+%h3.page-title
+ = @domain.domain
+%hr.clearfix
+%div
+ = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
+ = render 'form', { f: f }
+ .form-actions
+ = f.submit 'Save Changes', class: "btn btn-save"
diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml
index e1477c71d06..269df803a2b 100644
--- a/app/views/projects/pages_domains/new.html.haml
+++ b/app/views/projects/pages_domains/new.html.haml
@@ -1,6 +1,12 @@
+- add_to_breadcrumbs "Pages", project_pages_path(@project)
- page_title 'New Pages Domain'
-%h3.page_title
+%h3.page-title
New Pages Domain
%hr.clearfix
%div
- = render 'form'
+ = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
+ = render 'form', { f: f }
+ .form-actions
+ = f.submit 'Create New Domain', class: "btn btn-save"
+ .pull-right
+ = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index 876cac0dacb..44d66f3b2d0 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -1,6 +1,18 @@
+- add_to_breadcrumbs "Pages", project_pages_path(@project)
+- breadcrumb_title @domain.domain
- page_title "#{@domain.domain}", 'Pages Domains'
+- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}."
-%h3.page-title
+- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
+
+- if verification_enabled && @domain.unverified?
+ = content_for :flash_message do
+ .alert.alert-warning
+ .container-fluid.container-limited
+ This domain is not verified. You will need to verify ownership before access is enabled.
+
+%h3.page-title.with-button
+ = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right'
Pages Domain
.table-holder
@@ -9,15 +21,42 @@
%td
Domain
%td
- = link_to @domain.domain, @domain.url
+ = link_to @domain.url do
+ = @domain.url
+ = icon('external-link')
%tr
%td
DNS
%td
- %p
- To access the domain create a new DNS record:
- %pre
- #{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}.
+ .input-group
+ = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true
+ .input-group-btn
+ = clipboard_button(target: '#domain_dns', class: 'btn-default hidden-xs')
+ %p.help-block
+ To access this domain create a new DNS record
+
+ - if verification_enabled
+ - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}"
+ %tr
+ %td
+ Verification status
+ %td
+ = form_tag verify_project_pages_domain_path(@project, @domain) do
+ .status-badge
+ - text, status = @domain.unverified? ? [_('Unverified'), 'label-danger'] : [_('Verified'), 'label-success']
+ .label{ class: status }
+ = text
+ %button.btn.has-tooltip{ type: "submit", data: { container: 'body' }, title: _("Retry verification") }
+ = sprite_icon('redo')
+ .input-group
+ = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
+ .input-group-btn
+ = clipboard_button(target: '#domain_verification', class: 'btn-default hidden-xs')
+ %p.help-block
+ - help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ To #{link_to 'verify ownership', help_link} of your domain,
+ add the above key to a TXT record within to your DNS configuration.
+
%tr
%td
Certificate
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 857ae00d0ab..160e325996a 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -1,7 +1,3 @@
-- 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
@@ -22,14 +18,20 @@
= 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
+ .form-group.js-ci-variable-list-section
.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'
+ %ul.ci-variable-list
+ - @schedule.variables.each do |variable|
+ = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable, only_key_value: true
+ = render 'ci/variables/variable_row', form_field: 'schedule', only_key_value: true
+ - if @schedule.variables.size > 0
+ %button.btn.btn-info.btn-inverted.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } }
+ - if @schedule.variables.size == 0
+ = n_('Hide value', 'Hide values', @schedule.variables.size)
+ - else
+ = n_('Reveal value', 'Reveal values', @schedule.variables.size)
.form-group
.col-md-9
= f.label :active, s_('PipelineSchedules|Activated'), class: 'label-light'
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index f8c4005a9e0..55d0e8bb7f9 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -3,7 +3,7 @@
%td
= pipeline_schedule.description
%td.branch-name-cell
- = icon('code-fork')
+ = sprite_icon('fork', size: 12)
- if pipeline_schedule.ref.present?
= link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name"
%td
@@ -21,7 +21,7 @@
= s_("PipelineSchedules|Inactive")
%td
- if pipeline_schedule.owner
- = image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20"
+ = image_tag avatar_icon_for_user(pipeline_schedule.owner, 20), class: "avatar s20"
= link_to user_path(pipeline_schedule.owner) do
= pipeline_schedule.owner&.name
%td
@@ -29,9 +29,10 @@
- if can?(current_user, :play_pipeline_schedule, pipeline_schedule)
= link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do
= icon('play')
- - if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
+ - if can?(current_user, :take_ownership_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)
diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml
index 7fcb624a9dd..8996c1b3e38 100644
--- a/app/views/projects/pipeline_schedules/_tabs.html.haml
+++ b/app/views/projects/pipeline_schedules/_tabs.html.haml
@@ -1,4 +1,4 @@
-%ul.nav-links
+%ul.nav-links.mobile-separator
%li{ class: active_when(scope.nil?) }>
= link_to schedule_path_proc.call(nil) do
= s_("PipelineSchedules|All")
diff --git a/app/views/projects/pipeline_schedules/_variable_row.html.haml b/app/views/projects/pipeline_schedules/_variable_row.html.haml
deleted file mode 100644
index 564cb5d1ca9..00000000000
--- a/app/views/projects/pipeline_schedules/_variable_row.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- 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/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 4fbdd1dd5e4..bcb6dddba1a 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -1,9 +1,5 @@
- 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")
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 398a1c46746..852143ecb2a 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -1,18 +1,18 @@
- failed_builds = @pipeline.statuses.latest.failed
.tabs-holder
- %ul.pipelines-tabs.nav-links.no-top.no-bottom
+ %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator
%li.js-pipeline-tab-link
- = link_to project_pipeline_path(@project, @pipeline), data: { target: 'div#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
- Pipeline
+ = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
+ = _("Pipeline")
%li.js-builds-tab-link
- = link_to builds_project_pipeline_path(@project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
- Jobs
+ = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
+ = _("Jobs")
%span.badge.js-builds-counter= pipeline.total_size
- 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
+ = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
+ = _("Failed Jobs")
%span.badge.js-failures-counter= failed_builds.count
.tab-content
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index ba55bc23add..a86cb14960a 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,9 +1,6 @@
- @no_container = true
- breadcrumb_title "CI / CD Charts"
- page_title _("Charts"), _("Pipelines")
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_d3')
- = page_specific_javascript_bundle_tag('graphs')
%div{ class: container_class }
.sub-header-block
diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml
index a5dbd1b1532..c23fe6ff170 100644
--- a/app/views/projects/pipelines/charts/_pipeline_times.haml
+++ b/app/views/projects/pipelines/charts/_pipeline_times.haml
@@ -1,10 +1,8 @@
-- 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 }
+-# haml-lint:disable InlineJavaScript
%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
index 7a100843f5e..14b3d47a9c2 100644
--- a/app/views/projects/pipelines/charts/_pipelines.haml
+++ b/app/views/projects/pipelines/charts/_pipelines.haml
@@ -1,14 +1,11 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('pipelines_charts')
-
%h4= _("Pipelines charts")
%p
&nbsp;
- %span.cgreen
+ %span.legend-success
= icon("circle")
= s_("Pipeline|success")
&nbsp;
- %span.cgray
+ %span.legend-all
= icon("circle")
= s_("Pipeline|all")
@@ -29,6 +26,7 @@
= _("Pipelines for last year")
%canvas#yearChart.padded{ height: 250 }
+-# haml-lint:disable InlineJavaScript
%script#pipelinesChartsData{ type: "application/json" }
- chartData = []
- [:week, :month, :year].each do |scope|
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index b2e71cff6ce..c0ee81fe28d 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -7,10 +7,9 @@
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
- "new-pipeline-path" => new_project_pipeline_path(@project),
+ "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'),
"can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
- "has-ci" => @repository.gitlab_ci_yml,
- "ci-lint-path" => ci_lint_path } }
-
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('pipelines')
+ "new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project),
+ "ci-lint-path" => can?(current_user, :create_pipeline, @project) && project_ci_lint_path(@project),
+ "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) ,
+ "has-gitlab-ci" => (@project.has_ci? && @project.builds_enabled?).to_s } }
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 4ad37d0e882..877101b05ca 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -20,4 +20,5 @@
= f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3
= link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-cancel'
+-# haml-lint:disable InlineJavaScript
%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 2174154b207..a7d7c923957 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -10,7 +10,3 @@
= 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/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
deleted file mode 100644
index c5f9f5aa15b..00000000000
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ /dev/null
@@ -1,160 +0,0 @@
-.row.prepend-top-default
- .col-lg-12
- = form_for @project, url: project_pipelines_settings_path(@project) do |f|
- %fieldset.builds-feature
- .form-group
- %h5 Auto DevOps (Beta)
- %p
- Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.
- = link_to 'Learn more about Auto DevOps', help_page_path('topics/autodevops/index.md')
- - message = auto_devops_warning_message(@project)
- - if message
- %p.settings-message.text-center
- = message.html_safe
- = f.fields_for :auto_devops_attributes, @auto_devops do |form|
- .radio
- = form.label :enabled_true do
- = form.radio_button :enabled, 'true'
- %strong Enable Auto DevOps
- %br
- %span.descr
- The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project.
-
- .radio
- = form.label :enabled_false do
- = form.radio_button :enabled, 'false'
- %strong Disable Auto DevOps
- %br
- %span.descr
- An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
-
- .radio
- = form.label :enabled_ do
- = form.radio_button :enabled, ''
- %strong Instance default (#{current_application_settings.auto_devops_enabled? ? 'enabled' : 'disabled'})
- %br
- %span.descr
- Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific <code>.gitlab-ci.yml</code>.
- %p
- You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.
- = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
-
- %hr
- .form-group.append-bottom-default.js-secret-runner-token
- = f.label :runners_token, "Runner token", class: 'label-light'
- .form-control.js-secret-value-placeholder
- = '*' * 20
- = f.text_field :runners_token, class: "form-control hide js-secret-value", placeholder: 'xEeFCaDAB89'
- %p.help-block The secure token used by the Runner to checkout the project
- %button.btn.btn-info.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: 'false' } }
- = _('Reveal value')
-
- %hr
- .form-group
- %h5.prepend-top-0
- Git strategy for pipelines
- %p
- Choose between <code>clone</code> or <code>fetch</code> to get the recent application code
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank'
- .radio
- = f.label :build_allow_git_fetch_false do
- = f.radio_button :build_allow_git_fetch, 'false'
- %strong git clone
- %br
- %span.descr
- Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job
- .radio
- = f.label :build_allow_git_fetch_true do
- = f.radio_button :build_allow_git_fetch, 'true'
- %strong git fetch
- %br
- %span.descr
- Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)
-
- %hr
- .form-group
- = 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
- = 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
- %strong Public pipelines
- .help-block
- Allow public access to pipelines and job details, including output logs and artifacts
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank'
- .bs-callout.bs-callout-info
- %p If enabled:
- %ul
- %li
- For public projects, anyone can view pipelines and access job details (output logs and artifacts)
- %li
- For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)
- %li
- For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)
- %p
- If disabled, the access level will depend on the user's
- permissions in the project.
-
- %hr
- .form-group
- .checkbox
- = f.label :auto_cancel_pending_pipelines do
- = f.check_box :auto_cancel_pending_pipelines, {}, 'enabled', 'disabled'
- %strong Auto-cancel redundant, pending pipelines
- .help-block
- New pipelines will cancel older, pending pipelines on the same branch
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank'
-
- %hr
- .form-group
- = f.label :build_coverage_regex, "Test coverage parsing", class: 'label-light'
- .input-group
- %span.input-group-addon /
- = f.text_field :build_coverage_regex, class: 'form-control', placeholder: 'Regular expression'
- %span.input-group-addon /
- %p.help-block
- A regular expression that will be used to find the test coverage
- output in the job trace. Leave blank to disable
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank'
- .bs-callout.bs-callout-info
- %p Below are examples of regex for existing tools:
- %ul
- %li
- Simplecov (Ruby) -
- %code \(\d+.\d+\%\) covered
- %li
- pytest-cov (Python) -
- %code \d+\%\s*$
- %li
- phpunit --coverage-text --colors=never (PHP) -
- %code ^\s*Lines:\s*\d+.\d+\%
- %li
- gcovr (C/C++) -
- %code ^TOTAL.*\s+(\d+\%)$
- %li
- tap --coverage-report=text-summary (NodeJS) -
- %code ^Statements\s*:\s*([^%]+)
- %li
- excoveralls (Elixir) -
- %code \[TOTAL\]\s+(\d+\.\d+)%
-
- = f.submit 'Save changes', class: "btn btn-save"
-
-%hr
-
-.row.prepend-top-default
- = render partial: 'projects/pipelines_settings/badge', collection: @badges
diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml
deleted file mode 100644
index d15f4310ff5..00000000000
--- a/app/views/projects/project_members/update.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-:plain
- var $listItem = $('#{escape_javascript(render('shared/members/member', member: @project_member))}');
- $("##{dom_id(@project_member)} .list-item-name").replaceWith($listItem.find('.list-item-name'));
- gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(@project_member)}"));
diff --git a/app/views/projects/protected_branches/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml
index 2f30fe33a97..2b0a502fe4d 100644
--- a/app/views/projects/protected_branches/_index.html.haml
+++ b/app/views/projects/protected_branches/_index.html.haml
@@ -1,6 +1,3 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('protected_branches')
-
- content_for :create_protected_branch do
= render 'projects/protected_branches/create_protected_branch'
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
index 2a0704bc7af..a09c13176c3 100644
--- a/app/views/projects/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -2,7 +2,7 @@
- if @protected_branches.empty?
.panel-heading
%h3.panel-title
- Protected branch (#{@protected_branches.size})
+ Protected branch (#{@protected_branches_count})
%p.settings-message.text-center
There are currently no protected branches, protect a branch with the form above.
- else
@@ -16,7 +16,7 @@
%col
%thead
%tr
- %th Protected branch (#{@protected_branches.size})
+ %th Protected branch (#{@protected_branches_count})
%th Last commit
%th Allowed to merge
%th Allowed to push
diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml
index 6e9c473494e..74435236808 100644
--- a/app/views/projects/protected_branches/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- %button{ class: "create-new-protected-branch-button js-create-new-protected-branch", title: "New Protected Branch" }
+ %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", 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
index e662b877fbb..55d87c35a80 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -4,7 +4,7 @@
.settings-header
%h4
Protected Branches
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Keep stable branches secure and force developers to use merge requests.
diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml
index 955220562a6..6b284fda35c 100644
--- a/app/views/projects/protected_tags/_index.html.haml
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -1,6 +1,3 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('protected_tags')
-
- content_for :create_protected_tag do
= render 'projects/protected_tags/create_protected_tag'
diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml
index 9b6923210f7..f0d7dcccd36 100644
--- a/app/views/projects/protected_tags/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml
@@ -10,6 +10,6 @@
%ul.dropdown-footer-list
%li
- %button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" }
+ %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", 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
index 24baf1cfc89..c33723d8072 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -4,7 +4,7 @@
.settings-header
%h4
Protected Tags
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Limit access to creating and updating tags.
diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml
index 3f42ae58438..02908e16dc5 100644
--- a/app/views/projects/protected_tags/shared/_tags_list.html.haml
+++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml
@@ -2,7 +2,7 @@
- if @protected_tags.empty?
.panel-heading
%h3.panel-title
- Protected tag (#{@protected_tags.size})
+ Protected tag (#{@protected_tags_count})
%p.settings-message.text-center
There are currently no protected tags, protect a tag with the form above.
- else
@@ -17,7 +17,7 @@
%col
%thead
%tr
- %th Protected tag (#{@protected_tags.size})
+ %th Protected tag (#{@protected_tags_count})
%th Last commit
%th Allowed to create
- if can_admin_project
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 36ea5e013e4..2c80f7c3fa3 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -14,9 +14,6 @@
.col-lg-12
#js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } }
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('registry_list')
-
.row.prepend-top-10
.col-lg-12
.panel.panel-default
@@ -32,6 +29,10 @@
docker login #{Gitlab.config.registry.host_port}
%br
%p
+ - deploy_token = link_to(_('deploy token'), help_page_path('user/projects/deploy_tokens/index', anchor: 'read-container-registry-images'), target: '_blank')
+ = s_('ContainerRegistry|You can also %{deploy_token} for read-only access to the registry images.').html_safe % { deploy_token: deploy_token }
+ %br
+ %p
= s_('ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe }
%pre
:plain
diff --git a/app/views/projects/repositories/_feed.html.haml b/app/views/projects/repositories/_feed.html.haml
index 170f9e259df..87895a15239 100644
--- a/app/views/projects/repositories/_feed.html.haml
+++ b/app/views/projects/repositories/_feed.html.haml
@@ -11,7 +11,7 @@
%div
= link_to project_commits_path(@project, commit.id) do
%code= commit.short_id
- = image_tag avatar_icon(commit.author_email), class: "", width: 16, alt: ''
+ = image_tag avatar_icon_for_email(commit.author_email), class: "", width: 16, alt: ''
= markdown(truncate(commit.title, length: 40), pipeline: :single_line, author: commit.author)
%td
%span.pull-right.cgray
diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml
index e660fce652f..6a681736b6f 100644
--- a/app/views/projects/runners/_form.html.haml
+++ b/app/views/projects/runners/_form.html.haml
@@ -30,11 +30,22 @@
.col-sm-10
= f.text_field :token, class: 'form-control', readonly: true
.form-group
+ = label_tag :ip_address, class: 'control-label' do
+ IP Address
+ .col-sm-10
+ = f.text_field :ip_address, class: 'form-control', readonly: true
+ .form-group
= label_tag :description, class: 'control-label' do
Description
.col-sm-10
= f.text_field :description, class: 'form-control'
.form-group
+ = label_tag :maximum_timeout_human_readable, class: 'control-label' do
+ Maximum job timeout
+ .col-sm-10
+ = f.text_field :maximum_timeout_human_readable, class: 'form-control'
+ .help-block This timeout will take precedence when lower than Project-defined timeout
+ .form-group
= label_tag :tag_list, class: 'control-label' do
Tags
.col-sm-10
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 25d862ab4de..6376496ee1a 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -17,6 +17,10 @@
.pull-right
- if @project_runners.include?(runner)
+ - if runner.active?
+ = link_to 'Pause', pause_project_runner_path(@project, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: "Are you sure?" }
+ - else
+ = link_to 'Resume', resume_project_runner_path(@project, runner), method: :post, class: 'btn btn-success btn-sm'
- if runner.belongs_to_one_project?
= link_to 'Remove Runner', runner_path(runner), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
- else
diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml
index a4e820628f3..4fd4ca355a8 100644
--- a/app/views/projects/runners/_shared_runners.html.haml
+++ b/app/views/projects/runners/_shared_runners.html.haml
@@ -1,15 +1,15 @@
%h3 Shared Runners
-.bs-callout.bs-callout-warning.shared-runners-description
- - if current_application_settings.shared_runners_text.present?
- = markdown_field(current_application_settings, :shared_runners_text)
+.bs-callout.shared-runners-description
+ - if Gitlab::CurrentSettings.shared_runners_text.present?
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text)
- else
GitLab Shared Runners execute code of different projects on the same Runner
unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is
on GitLab.com).
%hr
- if @project.shared_runners_enabled?
- = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-warning', method: :post do
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
Disable shared Runners
- else
= link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
@@ -23,6 +23,3 @@
%h4.underlined-title Available shared Runners : #{@shared_runners_count}
%ul.bordered-list.available-shared-runners
= render partial: 'projects/runners/runner', collection: @shared_runners, as: :runner
- - if @shared_runners_count > 10
- .light
- and #{@shared_runners_count - 10} more...
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index 28ccbf7eb15..f0813e56b71 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -1,8 +1,7 @@
%h3 Specific Runners
-= render partial: 'ci/runner/how_to_setup_runner',
- locals: { registration_token: @project.runners_token,
- type: 'specific' }
+= render partial: 'ci/runner/how_to_setup_specific_runner',
+ locals: { registration_token: @project.runners_token }
- if @project_runners.any?
%h4.underlined-title Runners activated for this project
diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml
index dfab04aa1fb..f33e7e25b68 100644
--- a/app/views/projects/runners/show.html.haml
+++ b/app/views/projects/runners/show.html.haml
@@ -41,6 +41,9 @@
%td Version
%td= @runner.version
%tr
+ %td IP Address
+ %td= @runner.ip_address
+ %tr
%td Revision
%td= @runner.revision
%tr
@@ -53,6 +56,9 @@
%td Description
%td= @runner.description
%tr
+ %td Maximum job timeout
+ %td= @runner.maximum_timeout_human_readable
+ %tr
%td Last contact
%td
- if @runner.contacted_at
diff --git a/app/views/projects/services/_deprecated_message.html.haml b/app/views/projects/services/_deprecated_message.html.haml
new file mode 100644
index 00000000000..fea9506a4bb
--- /dev/null
+++ b/app/views/projects/services/_deprecated_message.html.haml
@@ -0,0 +1,3 @@
+.flash-container.flash-container-page
+ .flash-alert.deprecated-service
+ %span= @service.deprecation_message
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index c0b1c62e8ef..684b082efbb 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -1,6 +1,3 @@
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag('integrations')
-
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
@@ -8,21 +5,16 @@
= boolean_to_icon @service.activated?
%p= @service.description
+
+ - if @service.respond_to?(:detailed_description)
+ %p= @service.detailed_description
.col-lg-9
- = 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|
+ = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal integration-settings-form 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
- 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
+ = service_save_button(@service)
&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'
- if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true)
diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml
index 25770df1c90..df1fd583670 100644
--- a/app/views/projects/services/edit.html.haml
+++ b/app/views/projects/services/edit.html.haml
@@ -2,4 +2,6 @@
- page_title @service.title, "Services"
- add_to_breadcrumbs("Settings", edit_project_path(@project))
+= render 'deprecated_message' if @service.deprecation_message
+
= 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 5dbcbf7eba6..2ab0227126a 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
@@ -1,4 +1,4 @@
-- run_actions_text = "Perform common operations on GitLab project: #{@project.name_with_namespace}"
+- run_actions_text = "Perform common operations on GitLab project: #{@project.full_name}"
%p To setup this service:
%ul.list-unstyled.indent-list
@@ -20,7 +20,7 @@
.form-group
= label_tag :display_name, 'Display name', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
- = text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly'
+ = text_field_tag :display_name, "GitLab / #{@project.full_name}", class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
= clipboard_button(target: '#display_name')
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml
new file mode 100644
index 00000000000..2cc2a6b2b5b
--- /dev/null
+++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml
@@ -0,0 +1,26 @@
+%h4
+ = s_('PrometheusService|Auto configuration')
+
+- if service.manual_configuration?
+ .well
+ = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below')
+- else
+ .container-fluid
+ .row
+ - if service.prometheus_installed?
+ .col-sm-2
+ .svg-container
+ = image_tag 'illustrations/monitoring/getting_started.svg'
+ .col-sm-10
+ %p.text-success.prepend-top-default
+ = s_('PrometheusService|Prometheus is being automatically managed on your clusters')
+ = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn'
+ - else
+ .col-sm-2
+ = image_tag 'illustrations/monitoring/loading.svg'
+ .col-sm-10
+ %p.prepend-top-default
+ = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments')
+ = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success'
+
+%hr
diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml
new file mode 100644
index 00000000000..88acb824ba7
--- /dev/null
+++ b/app/views/projects/services/prometheus/_help.html.haml
@@ -0,0 +1,9 @@
+- if @project
+ = render 'projects/services/prometheus/configuration_banner', project: @project, service: @service
+
+%h4.append-bottom-default
+ = s_('PrometheusService|Manual configuration')
+
+- unless @service.editable?
+ .well
+ = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters')
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
index b0cb5ce5e8f..43e6a173108 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -1,6 +1,3 @@
-- 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
@@ -10,21 +7,19 @@
= link_to s_('PrometheusService|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.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } }
.panel-heading
%h3.panel-title
- = s_('PrometheusService|Monitored')
+ = s_('PrometheusService|Common metrics')
%span.badge.js-monitored-count 0
.panel-body
- .loading-metrics.text-center.js-loading-metrics
- = icon('spinner spin 3x', class: 'metrics-load-spinner')
- %p
+ .loading-metrics.js-loading-metrics
+ %p.prepend-top-10.prepend-left-10
+ = icon('spinner spin', class: 'metrics-load-spinner')
= s_('PrometheusService|Finding and configuring metrics...')
- .empty-metrics.text-center.hidden.js-empty-metrics
- = custom_icon('icon_empty_metrics')
- %p
- = s_('PrometheusService|No metrics are being monitored. To start monitoring, deploy to an environment.')
- = link_to s_('PrometheusService|View environments'), project_environments_path(@project), class: 'btn btn-success'
+ .empty-metrics.hidden.js-empty-metrics
+ %p.text-tertiary.prepend-top-10.prepend-left-10
+ = s_('PrometheusService|Waiting for your first deployment to an environment to find common metrics')
%ul.list-unstyled.metrics-list.hidden.js-metrics-list
.panel.panel-default.hidden.js-panel-missing-env-vars
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 c31c95608c6..d592a5e4663 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -1,4 +1,4 @@
-- pretty_name = defined?(@project) ? @project.name_with_namespace : 'namespace / path'
+- pretty_name = defined?(@project) ? @project.full_name : 'namespace / path'
- run_actions_text = "Perform common operations on GitLab project: #{pretty_name}"
.well
diff --git a/app/views/projects/settings/badges/index.html.haml b/app/views/projects/settings/badges/index.html.haml
new file mode 100644
index 00000000000..b68ed70de89
--- /dev/null
+++ b/app/views/projects/settings/badges/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Badges')
+- page_title _('Badges')
+
+= render 'shared/badges/badge_settings'
diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml
index e8028059487..e8028059487 100644
--- a/app/views/projects/pipelines_settings/_badge.html.haml
+++ b/app/views/projects/settings/ci_cd/_badge.html.haml
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
new file mode 100644
index 00000000000..20868f9ba5d
--- /dev/null
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -0,0 +1,164 @@
+.row.prepend-top-default
+ .col-lg-12
+ = form_for @project, url: project_settings_ci_cd_path(@project) do |f|
+ = form_errors(@project)
+ %fieldset.builds-feature
+ .form-group
+ %h5 Auto DevOps (Beta)
+ %p
+ Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.
+ = link_to 'Learn more about Auto DevOps', help_page_path('topics/autodevops/index.md')
+ - message = auto_devops_warning_message(@project)
+ - if message
+ %p.settings-message.text-center
+ = message.html_safe
+ = f.fields_for :auto_devops_attributes, @auto_devops do |form|
+ .radio
+ = form.label :enabled_true do
+ = form.radio_button :enabled, 'true'
+ %strong Enable Auto DevOps
+ %br
+ %span.descr
+ The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project.
+
+ .radio
+ = form.label :enabled_false do
+ = form.radio_button :enabled, 'false'
+ %strong Disable Auto DevOps
+ %br
+ %span.descr
+ An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
+
+ .radio
+ = form.label :enabled_ do
+ = form.radio_button :enabled, ''
+ %strong Instance default (#{Gitlab::CurrentSettings.auto_devops_enabled? ? 'enabled' : 'disabled'})
+ %br
+ %span.descr
+ Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific <code>.gitlab-ci.yml</code>.
+ %p
+ You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.
+ = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
+
+ %hr
+ .form-group.append-bottom-default.js-secret-runner-token
+ = f.label :runners_token, "Runner token", class: 'label-light'
+ .form-control.js-secret-value-placeholder
+ = '*' * 20
+ = f.text_field :runners_token, class: "form-control hide js-secret-value", placeholder: 'xEeFCaDAB89'
+ %p.help-block The secure token used by the Runner to checkout the project
+ %button.btn.btn-info.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: 'false' } }
+ = _('Reveal value')
+
+ %hr
+ .form-group
+ %h5.prepend-top-0
+ Git strategy for pipelines
+ %p
+ Choose between <code>clone</code> or <code>fetch</code> to get the recent application code
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank'
+ .radio
+ = f.label :build_allow_git_fetch_false do
+ = f.radio_button :build_allow_git_fetch, 'false'
+ %strong git clone
+ %br
+ %span.descr
+ Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job
+ .radio
+ = f.label :build_allow_git_fetch_true do
+ = f.radio_button :build_allow_git_fetch, 'true'
+ %strong git fetch
+ %br
+ %span.descr
+ Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)
+
+ %hr
+ .form-group
+ = f.label :build_timeout_human_readable, 'Timeout', class: 'label-light'
+ = f.text_field :build_timeout_human_readable, class: 'form-control'
+ %p.help-block
+ Per job. 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
+ %strong Public pipelines
+ .help-block
+ Allow public access to pipelines and job details, including output logs and artifacts
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank'
+ .bs-callout.bs-callout-info
+ %p If enabled:
+ %ul
+ %li
+ For public projects, anyone can view pipelines and access job details (output logs and artifacts)
+ %li
+ For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)
+ %li
+ For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)
+ %p
+ If disabled, the access level will depend on the user's
+ permissions in the project.
+
+ %hr
+ .form-group
+ .checkbox
+ = f.label :auto_cancel_pending_pipelines do
+ = f.check_box :auto_cancel_pending_pipelines, {}, 'enabled', 'disabled'
+ %strong Auto-cancel redundant, pending pipelines
+ .help-block
+ New pipelines will cancel older, pending pipelines on the same branch
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank'
+
+ %hr
+ .form-group
+ = f.label :build_coverage_regex, "Test coverage parsing", class: 'label-light'
+ .input-group
+ %span.input-group-addon /
+ = f.text_field :build_coverage_regex, class: 'form-control', placeholder: 'Regular expression'
+ %span.input-group-addon /
+ %p.help-block
+ A regular expression that will be used to find the test coverage
+ output in the job trace. Leave blank to disable
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank'
+ .bs-callout.bs-callout-info
+ %p Below are examples of regex for existing tools:
+ %ul
+ %li
+ Simplecov (Ruby) -
+ %code \(\d+.\d+\%\) covered
+ %li
+ pytest-cov (Python) -
+ %code \d+\%\s*$
+ %li
+ phpunit --coverage-text --colors=never (PHP) -
+ %code ^\s*Lines:\s*\d+.\d+\%
+ %li
+ gcovr (C/C++) -
+ %code ^TOTAL.*\s+(\d+\%)$
+ %li
+ tap --coverage-report=text-summary (NodeJS) -
+ %code ^Statements\s*:\s*([^%]+)
+ %li
+ excoveralls (Elixir) -
+ %code \[TOTAL\]\s+(\d+\.\d+)%
+ %li
+ JaCoCo (Java/Kotlin)
+ %code Total.*?([0-9]{1,3})%
+
+ = f.submit 'Save changes', class: "btn btn-save"
+
+%hr
+
+.row.prepend-top-default
+ = render partial: 'badge', collection: @badges
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 664a4554692..09268c9943b 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -3,23 +3,24 @@
- page_title "CI / CD"
- expanded = Rails.env.test?
+- general_expanded = @project.errors.empty? ? expanded : true
-%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if expanded) }
+%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) }
.settings-header
%h4
General pipelines settings
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Update your CI/CD configuration, like job timeout or Auto DevOps.
.settings-content
- = render 'projects/pipelines_settings/show'
+ = render 'form'
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Runners settings
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Register and see your runners for this project.
@@ -29,20 +30,20 @@
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
- Secret variables
- = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank'
- %button.btn.js-settings-toggle
+ = _('Secret variables')
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer'
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
- %p
+ %p.append-bottom-0
= render "ci/variables/content"
.settings-content
- = render 'ci/variables/index'
+ = render 'ci/variables/index', save_endpoint: project_variables_path(@project)
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Pipeline triggers
- %button.btn.js-settings-toggle
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will
diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml
index 82516cb4bcf..cd003107d66 100644
--- a/app/views/projects/settings/integrations/_project_hook.html.haml
+++ b/app/views/projects/settings/integrations/_project_hook.html.haml
@@ -3,14 +3,14 @@
.col-md-8.col-lg-7
%strong.light-header= hook.url
%div
- - ProjectHook::TRIGGERS.each_value do |event|
+ - 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 '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-sm'
+ = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: hook, button_class: 'btn-sm'
= 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/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 517d51993d2..f57590a908f 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -2,10 +2,6 @@
- page_title "Repository"
- @content_class = "limit-container-width" unless fluid_layout
-- 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
@@ -13,3 +9,4 @@
= render "projects/protected_branches/index"
= render "projects/protected_tags/index"
= render @deploy_keys
+= render "projects/deploy_tokens/index"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 7a68aa16aa4..94331a16abd 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,76 +1,23 @@
- @no_container = true
-- breadcrumb_title "Details"
+- breadcrumb_title _("Details")
- @content_class = "limit-container-width" unless fluid_layout
+- show_auto_devops_callout = show_auto_devops_callout?(@project)
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
= render partial: 'flash_messages', locals: { project: @project }
-= render "projects/last_push"
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
+ = render "projects/last_push"
+
= render "home_panel"
- if can?(current_user, :download_code, @project)
%nav.project-stats{ class: container_class }
- %ul.nav
- %li
- = link_to project_tree_path(@project) do
- #{_('Files')} (#{storage_counter(@project.statistics.total_repository_size)})
- %li
- = 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 project_branches_path(@project) do
- #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)})
- %li
- = link_to project_tags_path(@project) do
- #{n_('Tag', 'Tags', @repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)})
-
- - if @repository.readme
- %li
- = link_to _('Readme'),
- default_project_view != 'readme' ? readme_path(@project) : '#readme'
-
- - if @repository.changelog
- %li
- = link_to _('Changelog'), changelog_path(@project)
-
- - if @repository.license_blob
- %li
- = link_to license_short_name(@project), license_path(@project)
-
- - if @repository.contribution_guide
- %li
- = link_to _('Contribution guide'), contribution_guide_path(@project)
-
- - if @repository.gitlab_ci_yml
- %li
- = link_to _('CI configuration'), ci_configuration_path(@project)
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
+ = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- - 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') }
- - unless @repository.license_blob
- %li.missing
- = link_to add_special_file_path(@project, file_name: 'LICENSE') do
- #{ _('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') }
- - unless @repository.gitlab_ci_yml
- %li.missing
- = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do
- #{ _('Set up CI') }
- - if koding_enabled? && @repository.koding_yml.blank?
- %li.missing
- = link_to _('Set up Koding'), add_koding_stack_path(@project)
- - if @repository.gitlab_ci_yml.blank? && @project.deployment_platform.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') }
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- if @project.archived?
@@ -79,7 +26,7 @@
= icon("exclamation-triangle fw")
#{ _('Archived project! Repository is read-only') }
- - view_path = default_project_view
+ - view_path = @project.default_view
- if show_auto_devops_callout?(@project)
= render 'shared/auto_devops_callout'
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 467f19b4c56..3d5f92f9aaa 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -24,7 +24,7 @@
.wiki
= markdown_field(release, :description)
- .row-fixed-content.controls
+ .row-fixed-content.controls.flex-row
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :push_code, @project)
@@ -32,5 +32,5 @@
= icon("pencil")
- if can?(current_user, :admin_project, @project)
- = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
+ = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, 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 da364b58e36..10415d011d6 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -1,7 +1,6 @@
- @no_container = true
- @sort ||= sort_value_recently_updated
- page_title s_('TagsPage|Tags')
-- add_to_breadcrumbs("Repository", project_tree_path(@project))
.flex-list{ class: container_class }
.top-area.adjust
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 6e105a5521a..1827a3d323c 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -43,4 +43,5 @@
.form-actions
= button_tag s_('TagsPage|Create tag'), class: 'btn btn-create'
= link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel'
+-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index d1ecef39475..5ef5e9c09a2 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -24,6 +24,8 @@
.add-to-tree-dropdown
%ul.dropdown-menu
- if can_edit_tree?
+ %li.dropdown-header
+ #{ _('This directory') }
%li
= link_to project_new_blob_path(@project, @id) do
#{ _('New file') }
@@ -60,6 +62,8 @@
#{ _('New directory') }
%li.divider
+ %li.dropdown-header
+ #{ _('This repository') }
%li
= link_to new_project_branch_path(@project) do
#{ _('New branch') }
@@ -68,13 +72,12 @@
#{ _('New tag') }
.tree-controls
- - if show_new_ide?
- = succeed " " do
- = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do
- = ide_edit_text
-
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
= render 'projects/find_file_link'
+ = succeed " " do
+ = link_to ide_edit_path(@project, @id, ""), class: 'btn btn-default' do
+ = _('Web IDE')
+
= render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml
index 2c05ebe52ae..1a353953838 100644
--- a/app/views/projects/update.js.haml
+++ b/app/views/projects/update.js.haml
@@ -6,4 +6,4 @@
$(".project-edit-errors").html("#{escape_javascript(render('errors'))}");
$('.save-project-loader').hide();
$('.project-edit-container').show();
- $('.edit-project .btn-save').enable();
+ $('.edit-project .js-btn-save-general-project-settings').enable();
diff --git a/app/views/projects/variables/show.html.haml b/app/views/projects/variables/show.html.haml
deleted file mode 100644
index df533952b76..00000000000
--- a/app/views/projects/variables/show.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'ci/variables/show'
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index 4e265bf733a..d285251d06f 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -9,7 +9,13 @@
.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
+ .col-sm-12
+ = f.text_field :title, class: 'form-control', value: @page.title
+ - if @page.persisted?
+ %span.edit-wiki-page-slug-tip
+ = icon('lightbulb-o')
+ = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
+ = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), target: '_blank'
.form-group
.col-sm-12= f.label :format, class: 'control-label-full-width'
.col-sm-12
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml
index 0d77e5bd16d..9d3d4072027 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/projects/wikis/edit.html.haml
@@ -1,10 +1,7 @@
- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout
- page_title _("Edit"), @page.title.capitalize, _("Wiki")
-- if @conflict
- .alert.alert-danger
- - page_link = link_to s_("WikiPageConflictMessage|the page"), project_wiki_path(@project, @page), target: "_blank"
- = (s_("WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs.") % { page_link: page_link }).html_safe
+= wiki_page_errors(@error)
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 314d8e9cb25..7d43fd61081 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -14,25 +14,25 @@
= link_to search_filter_path(scope: 'issues') do
Issues
%span.badge
- = @search_results.issues_count
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_notes_count)
- if project_search_tabs?(:wiki)
%li{ class: active_when(@scope == 'wiki_blobs') }
= link_to search_filter_path(scope: 'wiki_blobs') do
@@ -57,25 +57,24 @@
Titles and Filenames
%span.badge
= @search_results.snippet_titles_count
-
- else
%li{ class: active_when(@scope == 'projects') }
= link_to search_filter_path(scope: 'projects') do
Projects
%span.badge
- = @search_results.projects_count
+ = limited_count(@search_results.limited_projects_count)
%li{ class: active_when(@scope == 'issues') }
= link_to search_filter_path(scope: 'issues') do
Issues
%span.badge
- = @search_results.issues_count
+ = limited_count(@search_results.limited_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
+ = limited_count(@search_results.limited_merge_requests_count)
%li{ class: active_when(@scope == 'milestones') }
= link_to search_filter_path(scope: 'milestones') do
Milestones
%span.badge
- = @search_results.milestones_count
+ = limited_count(@search_results.limited_milestones_count)
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index e43796e9654..e4902d368e7 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -22,7 +22,7 @@
%span.dropdown-toggle-text
Project:
- if @project.present?
- = @project.name_with_namespace
+ = @project.full_name
- else
Any
= icon("chevron-down")
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 02133d09cdf..ab56f48ba4d 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -2,10 +2,11 @@
= render partial: "search/results/empty"
- else
.row-content-block
- = search_entries_info(@search_objects, @scope, @search_term)
+ - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
+ = search_entries_info(@search_objects, @scope, @search_term)
- unless @show_snippets
- if @project
- in project #{link_to @project.name_with_namespace, [@project.namespace.becomes(Namespace), @project]}
+ in project #{link_to @project.full_name, [@project.namespace.becomes(Namespace), @project]}
- elsif @group
in group #{link_to @group.name, @group}
@@ -22,4 +23,4 @@
= render partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
- = paginate(@search_objects, theme: 'gitlab')
+ = paginate_collection(@search_objects)
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index b4bc8982c05..b7a27ef6be2 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -10,4 +10,4 @@
.description.term
= search_md_sanitize(issue, :description)
%span.light
- #{issue.project.name_with_namespace}
+ #{issue.project.full_name}
diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml
index 1a5499e4d58..8b0fd74f680 100644
--- a/app/views/search/results/_merge_request.html.haml
+++ b/app/views/search/results/_merge_request.html.haml
@@ -11,4 +11,4 @@
.description.term
= search_md_sanitize(merge_request, :description)
%span.light
- #{merge_request.project.name_with_namespace}
+ #{merge_request.project.full_name}
diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml
index a7e178dfa71..e4ab7b0541f 100644
--- a/app/views/search/results/_note.html.haml
+++ b/app/views/search/results/_note.html.haml
@@ -7,7 +7,7 @@
%i.fa.fa-comment
= link_to_member(project, note.author, avatar: false)
commented on
- = link_to project.name_with_namespace, project
+ = link_to project.full_name, project
&middot;
- if note.for_commit?
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index c4a5131c1a7..57a0b64bfd5 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -7,7 +7,7 @@
= snippet.title
by
= link_to user_snippets_path(snippet.author) do
- = image_tag avatar_icon(snippet.author), class: "avatar avatar-inline s16", alt: ''
+ = image_tag avatar_icon_for_user(snippet.author), class: "avatar avatar-inline s16", alt: ''
= snippet.author_name
%span.light= time_ago_with_tooltip(snippet.created_at)
%h4.snippet-title
diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml
index aef825691e0..d46c4d11e51 100644
--- a/app/views/search/results/_snippet_title.html.haml
+++ b/app/views/search/results/_snippet_title.html.haml
@@ -11,13 +11,13 @@
%small.pull-right.cgray
- if snippet_title.project_id?
- = link_to snippet_title.project.name_with_namespace, project_path(snippet_title.project)
+ = link_to snippet_title.project.full_name, project_path(snippet_title.project)
.snippet-info
= snippet_title.to_reference
%span
by
= link_to user_snippets_path(snippet_title.author) do
- = image_tag avatar_icon(snippet_title.author), class: "avatar avatar-inline s16", alt: ''
+ = image_tag avatar_icon_for_user(snippet_title.author), class: "avatar avatar-inline s16", alt: ''
= snippet_title.author_name
%span.light= time_ago_with_tooltip(snippet_title.created_at)
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index de52fd00157..7d3e243495f 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -1,7 +1,7 @@
- noteable = @sent_notification.noteable
- noteable_type = @sent_notification.noteable_type.titleize.downcase
- noteable_text = %(#{noteable.title} (#{noteable.to_reference}))
-- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.name_with_namespace
+- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.full_name
%h3.page-title
Unsubscribe from #{noteable_type}
diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml
index 94295970acf..75c65520350 100644
--- a/app/views/shared/_choose_group_avatar_button.html.haml
+++ b/app/views/shared/_choose_group_avatar_button.html.haml
@@ -1,7 +1,4 @@
-%button.choose-btn.btn.btn-sm.js-choose-group-avatar-button{ type: 'button' }
- %i.fa.fa-paperclip
- %span Choose File ...
-&nbsp;
-%span.file_name.js-avatar-filename File name...
-= f.file_field :avatar, class: 'js-group-avatar-input hidden'
-.light The maximum file size allowed is 200KB.
+%button.btn.js-choose-group-avatar-button{ type: 'button' }= _("Choose File ...")
+%span.file_name.js-avatar-filename= _("No file chosen")
+= f.file_field :avatar, class: "js-group-avatar-input hidden"
+.help-block= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 1cba4fc6c41..687cd4d1532 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -7,7 +7,7 @@
%span
= enabled_project_button(project, enabled_protocol)
- else
- %a#clone-dropdown.btn.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.btn.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span
= default_clone_protocol.upcase
= icon('caret-down')
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
new file mode 100644
index 00000000000..01effefc34d
--- /dev/null
+++ b/app/views/shared/_delete_label_modal.html.haml
@@ -0,0 +1,20 @@
+.modal{ id: "modal-delete-label-#{label.id}", tabindex: -1 }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %button.close{ data: {dismiss: 'modal' } } &times;
+ %h3.page-title Delete #{render_colored_label(label, tooltip: false)} ?
+
+ .modal-body
+ %p
+ %strong= label.name
+ %span will be permanently deleted from #{label.is_a?(ProjectLabel)? label.project.name : label.group.name}. This cannot be undone.
+
+ .modal-footer
+ %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel
+
+ = link_to 'Delete label',
+ destroy_label_path(label),
+ title: 'Delete',
+ method: :delete,
+ class: 'btn btn-remove'
diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml
index 151aad306a0..e7fa7477e0c 100644
--- a/app/views/shared/_event_filter.html.haml
+++ b/app/views/shared/_event_filter.html.haml
@@ -1,11 +1,14 @@
-%ul.nav-links.event-filter.scrolling-tabs
- = event_filter_link EventFilter.all, _('All'), s_('EventFilterBy|Filter by all')
- - if event_filter_visible(:repository)
- = event_filter_link EventFilter.push, _('Push events'), s_('EventFilterBy|Filter by push events')
- - if event_filter_visible(:merge_requests)
- = event_filter_link EventFilter.merged, _('Merge events'), s_('EventFilterBy|Filter by merge events')
- - if event_filter_visible(:issues)
- = event_filter_link EventFilter.issue, _('Issue events'), s_('EventFilterBy|Filter by issue events')
- - if comments_visible?
- = event_filter_link EventFilter.comments, _('Comments'), s_('EventFilterBy|Filter by comments')
- = event_filter_link EventFilter.team, _('Team'), s_('EventFilterBy|Filter by team')
+.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
+ .fade-left= icon('angle-left')
+ .fade-right= icon('angle-right')
+ %ul.nav-links.event-filter.scrolling-tabs
+ = event_filter_link EventFilter.all, _('All'), s_('EventFilterBy|Filter by all')
+ - if event_filter_visible(:repository)
+ = event_filter_link EventFilter.push, _('Push events'), s_('EventFilterBy|Filter by push events')
+ - if event_filter_visible(:merge_requests)
+ = event_filter_link EventFilter.merged, _('Merge events'), s_('EventFilterBy|Filter by merge events')
+ - if event_filter_visible(:issues)
+ = event_filter_link EventFilter.issue, _('Issue events'), s_('EventFilterBy|Filter by issue events')
+ - if comments_visible?
+ = event_filter_link EventFilter.comments, _('Comments'), s_('EventFilterBy|Filter by comments')
+ = event_filter_link EventFilter.team, _('Team'), s_('EventFilterBy|Filter by team')
diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml
index 795447a9ca6..aea0a8fd8e0 100644
--- a/app/views/shared/_field.html.haml
+++ b/app/views/shared/_field.html.haml
@@ -7,6 +7,7 @@
- choices = field[:choices]
- default_choice = field[:default_choice]
- help = field[:help]
+- disabled = disable_fields_service?(@service)
.form-group
- if type == "password" && value.present?
@@ -15,14 +16,14 @@
= form.label name, title, class: "control-label"
.col-sm-10
- if type == 'text'
- = form.text_field name, class: "form-control", placeholder: placeholder, required: required
+ = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled
- elsif type == 'textarea'
- = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required
+ = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required, disabled: disabled
- elsif type == 'checkbox'
- = form.check_box name
+ = form.check_box name, disabled: disabled
- elsif type == 'select'
- = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control" }
+ = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control", disabled: disabled}
- elsif type == 'password'
- = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && :required
+ = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && required, disabled: disabled
- 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 d0b9e891b82..cb21f90696f 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -1,5 +1,3 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('group')
- parent = @group.parent
- group_path = root_url
- group_path << parent.full_path + '/' if parent
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 736afa085e8..3806ead6c87 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -1,17 +1,19 @@
+- ci_cd_only = local_assigns.fetch(:ci_cd_only, false)
+
.form-group.import-url-data
= f.label :import_url, class: 'label-light' do
- %span Git repository URL
+ %span
+ = _('Git repository URL')
- = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git'
+ = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', required: true
.well.prepend-top-20
%ul
%li
- The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.
+ = _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe
%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>.
+ = _('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>.').html_safe
%li
- The import will time out after #{time_interval_in_words(Gitlab.config.gitlab_shell.git_timeout)}.
- For repositories that take longer, use a clone/push combination.
+ = import_will_timeout_message(ci_cd_only)
%li
- To migrate an SVN repository, check out #{link_to "this document", help_page_path('user/project/import/svn')}.
+ = import_svn_message(ci_cd_only)
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 435acbc634c..430d9a9dd76 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.issuable-mr.hidden-xs
+ %li.issuable-mr.hidden-xs.has-tooltip{ title: _('Related merge requests') }
= image_tag('icon-merge-request-unmerged.svg', class: 'icon-merge-request-unmerged')
= issuable_mr
- if upvotes > 0
- %li.issuable-upvotes.hidden-xs
+ %li.issuable-upvotes.hidden-xs.has-tooltip{ title: _('Upvotes') }
= icon('thumbs-up')
= upvotes
- if downvotes > 0
- %li.issuable-downvotes.hidden-xs
+ %li.issuable-downvotes.hidden-xs.has-tooltip{ title: _('Downvotes') }
= icon('thumbs-down')
= downvotes
%li.issuable-comments.hidden-xs
- = link_to issuable_url, class: ('no-comments' if note_count.zero?) do
+ = link_to issuable_url, class: ['has-tooltip', ('no-comments' if note_count.zero?)], title: _('Comments') do
= icon('comments')
= note_count
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 81d07074325..56403907844 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -5,10 +5,10 @@
- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
-%li{ id: label_css_id, data: { id: label.id } }
+%li.label-list-item{ 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
+ .visible-xs.visible-sm-inline-block.dropdown
%button.btn.btn-default.label-options-toggle{ type: 'button', data: { toggle: "dropdown" } }
Options
= icon('caret-down')
@@ -46,14 +46,28 @@
data: {confirm: 'Remove this label? Are you sure?'},
class: 'text-danger'
- .pull-right.hidden-xs.hidden-sm.hidden-md
- - if show_label_merge_requests_link
- = link_to_label(label, subject: subject, type: :merge_request, css_class: 'btn btn-transparent btn-action btn-link') do
- view merge requests
- - if show_label_issues_link
- = link_to_label(label, subject: subject, css_class: 'btn btn-transparent btn-action btn-link') do
- view open issues
-
+ .pull-right.hidden-xs.hidden-sm
+ - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
+ %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'),
+ disabled: true,
+ type: 'button',
+ data: { url: promote_project_label_path(label.project, label),
+ label_title: label.title,
+ label_color: label.color,
+ label_text_color: label.text_color,
+ group_name: label.project.group.name,
+ target: '#promote-label-modal',
+ container: 'body',
+ toggle: 'modal' } }
+ = sprite_icon('level-up')
+ - if can?(current_user, :admin_label, label)
+ = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do
+ %span.sr-only Edit
+ = sprite_icon('pencil')
+ %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } }
+ = link_to "#", title: "Delete", class: 'btn btn-transparent btn-action remove-row', data: { toggle: "tooltip" } do
+ %span.sr-only Delete
+ = sprite_icon('remove')
- if current_user
.label-subscription.inline
- if can_subscribe_to_label_in_different_levels?(label)
@@ -76,14 +90,4 @@
%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_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)
- = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do
- %span.sr-only Edit
- = icon('pencil-square-o')
- = 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')
+= render 'shared/delete_label_modal', label: label
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 7f58298c60f..bd4f191502e 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -1,3 +1,7 @@
+- subject = local_assigns[:subject]
+- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
+- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
+
%span.label-row
- if can?(current_user, :admin_label, @project)
.draggable-handler
@@ -13,6 +17,14 @@
- if defined?(@project) && @project.group.present?
%span.label-type
= label.model_name.human.titleize
- - if label.description
- %span.label-description
- = markdown_field(label, :description)
+
+ %span.label-description
+ - if label.description.present?
+ .description-text
+ = markdown_field(label, :description)
+ .hidden-xs.hidden-sm
+ - if show_label_issues_link
+ = link_to_label(label, subject: subject) { 'Issues' }
+ - if show_label_merge_requests_link
+ &middot;
+ = link_to_label(label, subject: subject, type: :merge_request) { 'Merge requests' }
diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml
index db2ac1e1d12..034b76b978f 100644
--- a/app/views/shared/_milestones_filter.html.haml
+++ b/app/views/shared/_milestones_filter.html.haml
@@ -1,4 +1,4 @@
-%ul.nav-links
+%ul.nav-links.mobile-separator
%li{ class: milestone_class_for_state(params[:state], 'opened', true) }>
= link_to milestones_filter_path(state: 'opened') do
Open
diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml
index 0a4a24ae807..9221fd1e025 100644
--- a/app/views/shared/_new_commit_form.html.haml
+++ b/app/views/shared/_new_commit_form.html.haml
@@ -1,3 +1,6 @@
+- project = @project.present(current_user: current_user)
+- branch_name = selected_branch
+
= render 'shared/commit_message_container', placeholder: placeholder
- if @project.empty_repo?
@@ -7,12 +10,14 @@
.form-group.branch
= label_tag 'branch_name', _('Target Branch'), class: 'control-label'
.col-sm-10
- = text_field_tag 'branch_name', @branch_name || tree_edit_branch, required: true, class: "form-control js-branch-name ref-name"
+ = text_field_tag 'branch_name', branch_name, required: true, class: "form-control js-branch-name ref-name"
.js-create-merge-request-container
= render 'shared/new_merge_request_checkbox'
+ - elsif project.can_current_user_push_to_branch?(branch_name)
+ = hidden_field_tag 'branch_name', branch_name
- else
- = hidden_field_tag 'branch_name', @branch_name || tree_edit_branch
+ = hidden_field_tag 'branch_name', branch_name
= hidden_field_tag 'create_merge_request', 1
= hidden_field_tag 'original_branch', @ref, class: 'js-original-branch'
diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml
index 0e816870f15..a0ba1afc284 100644
--- a/app/views/shared/_recaptcha_form.html.haml
+++ b/app/views/shared/_recaptcha_form.html.haml
@@ -1,15 +1,16 @@
- resource_name = spammable.class.model_name.singular
- humanized_resource_name = spammable.class.model_name.human.downcase
- script = local_assigns.fetch(:script, true)
+- method = params[:action] == 'create' ? :post : :put
- has_submit = local_assigns.fetch(:has_submit, true)
-= form_for resource_name, method: :post, html: { class: 'recaptcha-form js-recaptcha-form' } do |f|
+= form_for resource_name, method: method, html: { class: 'recaptcha-form js-recaptcha-form' } do |f|
.recaptcha
- params[resource_name].each do |field, value|
= hidden_field(resource_name, field, value: value)
= hidden_field_tag(:spam_log_id, spammable.spam_log.id)
= hidden_field_tag(:recaptcha_verification, true)
- = recaptcha_tags script: script, callback: 'recaptchaDialogCallback'
+ = recaptcha_tags script: script, callback: 'recaptchaDialogCallback' unless Rails.env.test?
-# Yields a block with given extra params.
= yield
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index 479bd2cdb38..4c8c92d722a 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -1,6 +1,5 @@
- show_create = local_assigns.fetch(:show_create, false)
-- show_new_branch_form = show_new_ide? && show_create && can?(current_user, :push_code, @project)
- dropdown_toggle_text = @ref || @project.default_branch
= form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do
= hidden_field_tag :destination, destination
@@ -16,14 +15,3 @@
= dropdown_filter _("Search branches and tags")
= dropdown_content
= dropdown_loading
- - if show_new_branch_form
- = dropdown_footer do
- %ul.dropdown-footer-list
- %li
- %a.dropdown-toggle-page{ href: "#" }
- Create new branch
- - if show_new_branch_form
- .dropdown-page-two
- = dropdown_title("Create new branch", options: { back: true })
- = dropdown_content do
- .js-new-branch-dropdown
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 7ca14ac93cc..a41aaed66a3 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -11,14 +11,14 @@
.form-group
= form.label :active, "Active", class: "control-label"
.col-sm-10
- = form.check_box :active
+ = form.check_box :active, disabled: disable_fields_service?(@service)
- - if @service.supported_events.present?
+ - if @service.configurable_events.present?
.form-group
= form.label :url, "Trigger", class: 'control-label'
.col-sm-10
- - @service.supported_events.each do |event|
+ - @service.configurable_events.each do |event|
%div
= form.check_box service_event_field_name(event), class: 'pull-left'
.prepend-left-20
@@ -33,7 +33,7 @@
= form.text_field field[:name], class: "form-control", placeholder: field[:placeholder]
%p.light
- = service_event_description(event)
+ = @service.class.event_description(event)
- @service.global_fields.each do |field|
- type = field[:type]
diff --git a/app/views/shared/badges/_badge_settings.html.haml b/app/views/shared/badges/_badge_settings.html.haml
new file mode 100644
index 00000000000..b7c250d3b1c
--- /dev/null
+++ b/app/views/shared/badges/_badge_settings.html.haml
@@ -0,0 +1,4 @@
+#badge-settings{ data: { api_endpoint_url: @badge_api_endpoint,
+ docs_url: help_page_path('user/project/badges')} }
+ .text-center.prepend-top-default
+ = icon('spinner spin 2x')
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index ee8ad8e3999..dac60094686 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -1,3 +1,5 @@
+- board = local_assigns.fetch(:board, nil)
+- group = local_assigns.fetch(:group, false)
- @no_breadcrumb_container = true
- @no_container = true
- @content_class = "issue-boards-content"
@@ -5,10 +7,8 @@
- page_title "Boards"
- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_vue'
- = webpack_bundle_tag 'filtered_search'
- = webpack_bundle_tag 'boards'
+ -# haml-lint:disable InlineJavaScript
%script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
@@ -27,8 +27,8 @@
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
":board-id" => "boardId",
- ":key" => "_uid" }
- = render "shared/boards/components/sidebar"
+ ":key" => "list.id" }
+ = render "shared/boards/components/sidebar", group: group
- if @project
%board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index c687e66fd43..149bf8da4b9 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -4,7 +4,7 @@
%header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
%h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' }
%i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
- ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }",
+ ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" }
%span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"",
@@ -42,6 +42,7 @@
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
+ ":groupId" => ((current_board_parent.id if @group) || 'null'),
"ref" => "board-list" }
- if can?(current_user, :admin_list, current_board_parent)
%board-blank-state{ "v-if" => 'list.id == "blank"' }
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
index 8e5e32e9f16..b385cc3f962 100644
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -22,6 +22,6 @@
= render "shared/boards/components/sidebar/labels"
= render "shared/boards/components/sidebar/notifications"
%remove-btn{ ":issue" => "issue",
- ":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'",
+ ":issue-update" => "issue.sidebarInfoEndpoint",
":list" => "list",
"v-if" => "canRemove" }
diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml
index 3d2e8471a60..1374da9d82c 100644
--- a/app/views/shared/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml
@@ -21,8 +21,7 @@
.dropdown
- 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: board_sidebar_user_data,
- ":data-issuable-id" => "issue.iid",
- ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ ":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml
index db794d6f855..d13b998e6f4 100644
--- a/app/views/shared/boards/components/sidebar/_due_date.html.haml
+++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml
@@ -22,8 +22,7 @@
":value" => "issue.dueDate" }
.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" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" } }
%span.dropdown-toggle-text Due date
= icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index dfc0f9be321..87e6b52f46e 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -26,8 +26,7 @@
project_id: @project&.try(:id),
labels: labels_filter_path(false),
namespace_path: @namespace_path,
- project_path: @project.try(:path) },
- ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ project_path: @project.try(:path) } }
%span.dropdown-toggle-text
Label
= icon('chevron-down')
diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml
index d09c7c218e0..f51c4a97f2b 100644
--- a/app/views/shared/boards/components/sidebar/_milestone.html.haml
+++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml
@@ -18,8 +18,7 @@
.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]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" },
":data-selected" => "milestoneTitle",
- ":data-issuable-id" => "issue.iid",
- ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
+ ":data-issuable-id" => "issue.iid" }
Milestone
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml
index 639f28cc210..0b003125912 100644
--- a/app/views/shared/builds/_tabs.html.haml
+++ b/app/views/shared/builds/_tabs.html.haml
@@ -1,4 +1,4 @@
-%ul.nav-links
+%ul.nav-links.mobile-separator
%li{ class: active_when(scope.nil?) }>
= link_to build_path_proc.call(nil) do
All
diff --git a/app/views/shared/dashboard/_no_filter_selected.html.haml b/app/views/shared/dashboard/_no_filter_selected.html.haml
new file mode 100644
index 00000000000..b2e6967f6aa
--- /dev/null
+++ b/app/views/shared/dashboard/_no_filter_selected.html.haml
@@ -0,0 +1,8 @@
+.row.empty-state.text-center
+ .col-xs-12
+ .svg-130.prepend-top-default
+ = image_tag 'illustrations/issue-dashboard_results-without-filter.svg'
+ .col-xs-12
+ .text-content
+ %h4
+ = _("Please select at least one filter to see results")
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index e6075c3ae3a..87c2965bb21 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -1,5 +1,6 @@
- form = local_assigns.fetch(:form)
- deploy_key = local_assigns.fetch(:deploy_key)
+- deploy_keys_project = deploy_key.deploy_keys_project_for(@project)
= form_errors(deploy_key)
@@ -20,11 +21,13 @@
.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.)
+- if deploy_keys_project.present?
+ = form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form|
+ .form-group
+ .control-label
+ .col-sm-10
+ = deploy_keys_project_form.label :can_push do
+ = deploy_keys_project_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/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index f65bb6a29e6..38e9899ca4b 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -15,7 +15,7 @@
= render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: form, attr: :description,
- classes: 'note-textarea',
+ classes: 'note-textarea qa-issuable-form-description',
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
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 1a259b679c7..8607be9cd06 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -23,11 +23,11 @@
- if show_archive_options
%li.divider
%li.js-filter-archived-projects
- = link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do
+ = link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
Hide archived projects
%li.js-filter-archived-projects
- = link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
+ = link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
Show archived projects
%li.js-filter-archived-projects
- = link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+ = link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
Show archived projects only
diff --git a/app/views/shared/icons/_icon_status_notfound_borderless.svg b/app/views/shared/icons/_icon_status_notfound_borderless.svg
deleted file mode 100644
index e58bd264ef8..00000000000
--- a/app/views/shared/icons/_icon_status_notfound_borderless.svg
+++ /dev/null
@@ -1 +0,0 @@
-<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_success_borderless.svg b/app/views/shared/icons/_icon_status_success_borderless.svg
deleted file mode 100644
index 8ee5be7ab78..00000000000
--- a/app/views/shared/icons/_icon_status_success_borderless.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M11.4583333,12.375 L8.70008808,12.375 C8.45889044,12.375 8.25,12.5826293 8.25,12.8387529 L8.25,14.2029137 C8.25,14.4551799 8.4515113,14.6666667 8.70008808,14.6666667 L12.9619841,14.6666667 C13.3891296,14.6666667 13.75,14.3193051 13.75,13.8908129 L13.75,13.2899463 L13.75,6.42552703 C13.75,6.16226705 13.5423707,5.95833333 13.2862471,5.95833333 L11.9220863,5.95833333 C11.6698201,5.95833333 11.4583333,6.16750307 11.4583333,6.42552703 L11.4583333,12.375 Z" id="Combined-Shape" transform="translate(11.000000, 10.312500) rotate(-315.000000) translate(-11.000000, -10.312500) "></path></svg>
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 8442d7ff4a2..1bd5b4164b1 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -22,14 +22,11 @@
= render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true
.filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", selected: finder.labels.select(:title).uniq, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
+ = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
- - if issuable_filter_present?
- .filter-item.inline.reset-filters
- %a{ href: page_filter_path(without: issuable_filter_params) } Reset filters
-
- .pull-right
- = render 'shared/sort_dropdown'
+ - unless @no_filters_set
+ .pull-right
+ = render 'shared/sort_dropdown'
- has_labels = @labels && @labels.any?
.row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index bb02dfa0d3a..4c8f03f1498 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -33,6 +33,8 @@
= render 'shared/issuable/form/merge_params', issuable: issuable
+= render 'shared/issuable/form/contribution', issuable: issuable, form: form
+
- if @merge_request_to_resolve_discussions_of
.form-group
.col-sm-10.col-sm-offset-2
@@ -65,11 +67,11 @@
%span.append-right-10
- if issuable.new_record?
- = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'btn btn-create'
+ = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'btn btn-create qa-issuable-create-button'
- else
= form.submit 'Save changes', class: 'btn btn-save'
- - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = contribution_guide_path(issuable.project))
+ - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path)
.inline.prepend-top-10
Please review the
%strong= link_to('contribution guidelines', guide_url)
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index 0a692d9653f..91aa329eb93 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -1,17 +1,18 @@
+- subject = @project || @group
.dropdown-page-two.dropdown-new-label
- = dropdown_title("Create new label", options: { back: true })
+ = dropdown_title(create_label_title(subject), 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" }
+ %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
.suggest-colors.suggest-colors-dropdown
- suggested_colors.each do |color|
= link_to '#', style: "background-color: #{color}", data: { color: color } do
&nbsp
.dropdown-label-color-input
.dropdown-label-color-preview.js-dropdown-label-color-preview
- %input#new_label_color.default-dropdown-input{ type: "text", placeholder: "Assign custom color like #FF0000" }
+ %input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') }
.clearfix
%button.btn.btn-primary.pull-left.js-new-label-btn{ type: "button" }
- Create
+ = _('Create')
%button.btn.btn-default.pull-right.js-cancel-label-btn{ type: "button" }
- Cancel
+ = _('Cancel')
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index ad031e6af80..2bd922bca2b 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -1,14 +1,15 @@
-- title = local_assigns.fetch(:title, 'Assign labels')
+- title = local_assigns.fetch(:title, _('Assign labels'))
- show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
- show_boards_content = local_assigns.fetch(:show_boards_content, false)
+- subject = @project || @group
.dropdown-page-one
= dropdown_title(title)
- if show_boards_content
.issue-board-dropdown-content
%p
- Create lists from labels. Issues with that label appear in that list.
+ = _('Create lists from labels. Issues with that label appear in that list.')
= dropdown_filter(filter_placeholder)
= dropdown_content
- if current_board_parent && show_footer
@@ -17,11 +18,11 @@
- if can?(current_user, :admin_label, current_board_parent)
%li
%a.dropdown-toggle-page{ href: "#" }
- Create new label
+ = create_label_title(subject)
%li
= link_to labels_path, :"data-is-link" => true do
- if show_create && can?(current_user, :admin_label, current_board_parent)
- Manage labels
+ = manage_labels_title(subject)
- else
- View labels
+ = view_labels_title(subject)
= dropdown_loading
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index 6d8a4668cec..a5f40ea934b 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -1,22 +1,23 @@
- type = local_assigns.fetch(:type, :issues)
- page_context_word = type.to_s.humanize(capitalize: false)
+- display_count = local_assigns.fetch(:display_count, :true)
-%ul.nav-links.issues-state-filters
+%ul.nav-links.issues-state-filters.mobile-separator
%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.", data: { state: 'opened' } do
- #{issuables_state_counter_text(type, :opened)}
+ #{issuables_state_counter_text(type, :opened, display_count)}
- 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.', data: { state: 'merged' } do
- #{issuables_state_counter_text(type, :merged)}
+ #{issuables_state_counter_text(type, :merged, display_count)}
%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.', data: { state: 'closed' } do
- #{issuables_state_counter_text(type, :closed)}
+ #{issuables_state_counter_text(type, :closed, display_count)}
- else
%li{ class: active_when(params[:state] == 'closed') }>
= 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)}
+ #{issuables_state_counter_text(type, :closed, display_count)}
- = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all)
+ = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count)
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index fabb17c7340..fc6f71ef60f 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -112,6 +112,7 @@
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
- #js-add-issues-btn.prepend-left-10
+ - if @project
+ #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index e0009a35b9f..975b9cb4729 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,7 +1,4 @@
- todo = issuable_todo(issuable)
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('common_vue')
- = page_specific_javascript_bundle_tag('sidebar')
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
@@ -9,7 +6,7 @@
.block.issuable-sidebar-header
- if current_user
%span.issuable-header-text.hide-collapsed.pull-left
- Todo
+ = _('Todo')
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
= sidebar_gutter_toggle_icon
- if current_user
@@ -29,26 +26,27 @@
%span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } }
= issuable.milestone.title
- else
- None
+ = _('None')
.title.hide-collapsed
- Milestone
+ = _('Milestone')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if issuable.milestone
= link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_title(issuable.milestone), data: { container: "body", html: 1 }
- else
- %span.no-value None
+ %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: 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 }})
+ = 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
// Fallback while content is loading
.title.hide-collapsed
- Time tracking
+ = _('Time tracking')
= icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date)
.block.due_date
@@ -57,34 +55,36 @@
%span.js-due-date-sidebar-value
= issuable.due_date.try(:to_s, :medium) || 'None'
.title.hide-collapsed
- Due date
+ = _('Due date')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
- = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if issuable.due_date
%span.bold= issuable.due_date.to_s(:medium)
- else
- %span.no-value No due date
+ %span.no-value
+ = _('No due date')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
%span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) }
\-
%a.js-remove-due-date{ href: "#", role: "button" }
- remove due date
+ = _('remove due date')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.selectbox.hide-collapsed
= f.hidden_field :due_date, value: issuable.due_date.try(:strftime, 'yy-mm-dd')
.dropdown
%button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable) } }
- %span.dropdown-toggle-text Due date
+ %span.dropdown-toggle-text
+ = _('Due date')
= icon('chevron-down', 'aria-hidden': 'true')
.dropdown-menu.dropdown-menu-due-date
- = dropdown_title('Due date')
+ = dropdown_title(_('Due date'))
= dropdown_content do
.js-due-date-calendar
- - if @labels && @labels.any?
+ - if @labels
- selected_labels = issuable.labels
.block.labels
.sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body" } }
@@ -92,21 +92,22 @@
%span
= selected_labels.size
.title.hide-collapsed
- Labels
+ = _('Labels')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any?
- selected_labels.each do |label|
= link_to_label(label, subject: issuable.project, type: issuable.to_ability_name)
- else
- %span.no-value None
+ %span.no-value
+ = _('None')
.selectbox.hide-collapsed
- 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(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (project_labels_path(@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: (labels_filter_path(false) 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')
@@ -116,10 +117,12 @@
= render partial: "shared/issuable/label_page_create"
- if issuable.has_attribute?(:confidential)
+ -# haml-lint:disable InlineJavaScript
%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
- if issuable.has_attribute?(:discussion_locked)
+ -# haml-lint:disable InlineJavaScript
%script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe
#js-lock-entry-point
@@ -131,29 +134,30 @@
- project_ref = cross_project_reference(@project, issuable)
.block.project-reference
.sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
+ = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left")
.cross-project-reference.hide-collapsed
%span
- Reference:
+ = _('Reference:')
%cite{ title: project_ref }
= project_ref
- = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
+ = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left")
- if current_user && issuable.can_move?(current_user)
.block.js-sidebar-move-issue-block
- .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' }
+ .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: _('Move issue') }
= custom_icon('icon_arrow_right')
.dropdown.sidebar-move-issue-dropdown.hide-collapsed
%button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
data: { toggle: 'dropdown' } }
- Move issue
+ = _('Move issue')
.dropdown-menu.dropdown-menu-selectable
- = dropdown_title('Move issue')
- = dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search')
+ = dropdown_title(_('Move issue'))
+ = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search')
= dropdown_content
= dropdown_loading
= dropdown_footer add_content_class: true do
%button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true }
- Move
+ = _('Move')
= icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
+ -# haml-lint:disable InlineJavaScript
%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
index 58782fa5f58..304df38a096 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,7 +1,7 @@
- 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
+ = _('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) }
@@ -10,35 +10,35 @@
- else
= icon('user', 'aria-hidden': 'true')
.title.hide-collapsed
- Assignee
+ = _('Assignee')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
- if !signed_in
- %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
+ %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' }
+ %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
+ = _('No assignee')
- if can_edit_issuable
\-
%a.js-assign-yourself{ href: '#' }
- assign yourself
+ = _('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'
+ - 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?
diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml
index 574e2958ae8..b77e104c072 100644
--- a/app/views/shared/issuable/_sidebar_todo.html.haml
+++ b/app/views/shared/issuable/_sidebar_todo.html.haml
@@ -1,11 +1,11 @@
- is_collapsed = local_assigns.fetch(:is_collapsed, false)
-- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : 'Mark done'
-- todo_content = is_collapsed ? icon('plus-square') : 'Add todo'
+- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark done')
+- todo_content = is_collapsed ? icon('plus-square') : _('Add todo')
%button.issuable-todo-btn.js-issuable-todo{ type: 'button',
class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn pull-right'),
- title: (todo.nil? ? 'Add todo' : 'Mark done'),
- 'aria-label' => (todo.nil? ? 'Add todo' : 'Mark done'),
+ title: (todo.nil? ? _('Add todo') : _('Mark done')),
+ 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark done')),
data: issuable_todo_button_data(issuable, todo, is_collapsed) }
%span.issuable-todo-inner.js-issuable-todo-inner<
- if todo
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 203d2adc8db..9a589387255 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -15,11 +15,10 @@
= form.label :target_branch, class: 'control-label'
.col-sm-10.target-branch-select-dropdown-container
.issuable-form-select-holder
- = form.select(:target_branch, issuable.target_branches,
- { include_blank: true },
+ = form.hidden_field(:target_branch,
{ class: 'target_branch js-target-branch-select ref-name',
disabled: issuable.new_record?,
- data: { placeholder: "Select branch" }})
+ data: { placeholder: "Select branch", endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }})
- if issuable.new_record?
&nbsp;
= link_to 'Change branches', mr_change_branches_path(issuable)
diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml
new file mode 100644
index 00000000000..de508278d7c
--- /dev/null
+++ b/app/views/shared/issuable/form/_contribution.html.haml
@@ -0,0 +1,20 @@
+- issuable = local_assigns.fetch(:issuable)
+- form = local_assigns.fetch(:form)
+
+- return unless issuable.is_a?(MergeRequest)
+- return unless issuable.for_fork?
+- return unless can?(current_user, :push_code, issuable.source_project)
+
+%hr
+
+.form-group
+ .control-label
+ = _('Contribution')
+ .col-sm-10
+ .checkbox
+ = form.label :allow_maintainer_to_push do
+ = form.check_box :allow_maintainer_to_push, disabled: !issuable.can_allow_maintainer_to_push?(current_user)
+ = _('Allow edits from maintainers.')
+ = link_to 'About this feature', help_page_path('user/project/merge_requests/maintainer_access')
+ .help-block
+ = allow_maintainer_push_unavailable_reason(issuable)
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 64826d41d60..e81639f35ea 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -6,7 +6,7 @@
%div{ class: div_class }
= form.text_field :title, required: true, maxlength: 255, autofocus: true,
- autocomplete: 'off', class: 'form-control pad'
+ autocomplete: 'off', class: 'form-control pad qa-issuable-form-title'
- if issuable.respond_to?(:work_in_progress?)
%p.help-block
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 71878e93255..ba57d922c6d 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -8,7 +8,7 @@
%li.member{ class: dom_class(member), id: dom_id(member) }
%span.list-item-name
- if user
- = image_tag avatar_icon(user, 40), class: "avatar s40", alt: ''
+ = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: ''
.user-info
= link_to user.name, user_path(user), class: 'member'
%span.cgray= user.to_reference
@@ -36,7 +36,7 @@
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: ''
+ = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40", alt: ''
.user-info
.member= member.invite_email
.cgray
diff --git a/app/views/shared/members/update.js.haml b/app/views/shared/members/update.js.haml
new file mode 100644
index 00000000000..55050bd8a15
--- /dev/null
+++ b/app/views/shared/members/update.js.haml
@@ -0,0 +1,6 @@
+- member = local_assigns.fetch(:member)
+
+:plain
+ var $listItem = $('#{escape_javascript(render('shared/members/member', member: member))}');
+ $("##{dom_id(member)} .list-item-name").replaceWith($listItem.find('.list-item-name'));
+ gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(member)}"));
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index 14395bcc661..eba64daaadc 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -12,12 +12,12 @@
- if show_project_name
%strong #{project.name} &middot;
- elsif show_full_project_name
- %strong #{project.name_with_namespace} &middot;
+ %strong #{project.full_name} &middot;
- if issuable.is_a?(Issue)
= confidential_icon(issuable)
= link_to issuable.title, issuable_url_args, title: issuable.title
.issuable-detail
- = link_to [namespace, project, issuable] do
+ = link_to [namespace, project, issuable], class: 'issue-link' do
%span.issuable-number= issuable.to_reference
- labels.each do |label|
@@ -28,4 +28,4 @@
- assignees.each do |assignee|
= link_to polymorphic_path(issuable_type_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(assignee, 16), class: "avatar s16", alt: '')
+ - image_tag(avatar_icon_for_user(assignee, 16), class: "avatar s16", alt: '')
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 7ba8f9d4313..ac494814f55 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -27,7 +27,7 @@
- milestone.milestones.each do |milestone|
= link_to milestone_path(milestone) do
%span.label.label-gray
- = dashboard ? milestone.project.name_with_namespace : milestone.project.name
+ = dashboard ? milestone.project.full_name : milestone.project.name
- if @group
.col-sm-6.milestone-actions
- if can?(current_user, :admin_milestones, @group)
@@ -51,11 +51,26 @@
\
- if @project.group
- = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do
- Promote
+ %button.js-promote-project-milestone-button.btn.btn-xs.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'),
+ disabled: true,
+ type: 'button',
+ data: { url: promote_project_milestone_path(milestone.project, milestone),
+ milestone_title: milestone.title,
+ group_name: @project.group.name,
+ target: '#promote-milestone-modal',
+ container: 'body',
+ toggle: 'modal' } }
+ = _('Promote')
= 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
-
+ %button.js-delete-milestone-button.btn.btn-xs.btn-grouped.btn-danger{ data: { toggle: 'modal',
+ target: '#delete-milestone-modal',
+ milestone_id: milestone.id,
+ milestone_title: markdown_field(milestone, :title),
+ milestone_url: project_milestone_path(milestone.project, milestone),
+ milestone_issue_count: milestone.issues.count,
+ milestone_merge_request_count: milestone.merge_requests.count },
+ disabled: true }
+ = _('Delete')
+ = icon('spin spinner', class: 'js-loading-icon hidden' )
diff --git a/app/views/shared/milestones/_participants_tab.html.haml b/app/views/shared/milestones/_participants_tab.html.haml
index 1615871385e..fe83040c168 100644
--- a/app/views/shared/milestones/_participants_tab.html.haml
+++ b/app/views/shared/milestones/_participants_tab.html.haml
@@ -2,7 +2,7 @@
- users.each do |user|
%li
= link_to user, title: user.name, class: "darken" do
- = image_tag avatar_icon(user, 32), class: "avatar s32"
+ = image_tag avatar_icon_for_user(user, 32), class: "avatar s32"
%strong= truncate(user.name, length: 40)
%div
%small.cgray= user.username
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 4f51455c26e..a942ebc328b 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -85,21 +85,12 @@
Closed:
= milestone.issues_visible_to_user(current_user).closed.count
- .block.time_spent
- .sidebar-collapsed-icon
- = custom_icon('icon_hourglass')
- %span.collapsed-milestone-total-time-spent
- - if milestone.human_total_issue_time_spent
- = milestone.human_total_issue_time_spent
- - else
- = _("None")
- .title.hide-collapsed
- = _("Total issue time spent")
- .value.hide-collapsed
- - if milestone.human_total_issue_time_spent
- %span.bold= milestone.human_total_issue_time_spent
- - else
- %span.no-value= _("No time spent")
+ .block
+ #issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate, time_spent: @milestone.total_issue_time_spent, human_time_estimate: @milestone.human_total_issue_time_estimate, human_time_spent: @milestone.human_total_issue_time_spent } }
+ // Fallback while content is loading
+ .title.hide-collapsed
+ = _('Time tracking')
+ = icon('spinner spin')
.block.merge-requests
.sidebar-collapsed-icon
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index fd0760d83a5..f302299eb24 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -1,4 +1,5 @@
-- page_title milestone.title, "Milestones"
+- page_title @milestone.title
+- @breadcrumb_link = dashboard_milestone_path(milestone.safe_title, title: milestone.title)
- group = local_assigns[:group]
@@ -17,7 +18,7 @@
Milestone #{milestone.title}
- if milestone.due_date || milestone.start_date
%span.creator
- &middot;
+ &nbsp;&middot;
= milestone_date_range(milestone)
- if group
.pull-right
@@ -56,7 +57,7 @@
- milestone.milestones.each do |ms|
%tr
%td
- - project_name = group ? ms.project.name : ms.project.name_with_namespace
+ - project_name = group ? ms.project.name : ms.project.full_name
= link_to project_name, project_milestone_path(ms.project, ms)
%td
= ms.issues_visible_to_user(current_user).opened.count
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 71c0d740bc8..725bf916592 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -24,21 +24,20 @@
-# DiffNote
= f.hidden_field :position
- .discussion-form-container
- = 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
+ = 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/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 98e0161f7d1..bf359774ead 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -16,7 +16,7 @@
= icon_for_system_note(note)
- else
%a.image-diff-avatar-link{ href: user_path(note.author) }
- = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
+ = image_tag avatar_icon_for_user(note.author), alt: '', class: 'avatar s40'
- if note.is_a?(DiffNote) && note.on_image?
- if show_image_comment_badge && note_counter == 0
-# Only show this for the first comment in the discussion
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index e11f778adf5..1db7c4e67cf 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -1,20 +1,21 @@
- issuable = @issue || @merge_request
- discussion_locked = issuable&.discussion_locked?
-%ul#notes-list.notes.main-notes-list.timeline
- = render "shared/notes/notes"
+- unless has_vue_discussions_cookie?
+ %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
+ %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) }
%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'
+ = image_tag avatar_icon_for_user(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
@@ -34,4 +35,5 @@
is locked. Only
%b project members
can comment.
+-# haml-lint:disable InlineJavaScript
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/app/views/shared/plugins/_index.html.haml b/app/views/shared/plugins/_index.html.haml
new file mode 100644
index 00000000000..fc643c3ecc2
--- /dev/null
+++ b/app/views/shared/plugins/_index.html.haml
@@ -0,0 +1,23 @@
+- plugins = Gitlab::Plugin.files
+
+.row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ Plugins
+ %p
+ #{link_to 'Plugins', help_page_path('administration/plugins')} are similar to
+ system hooks but are executed as files instead of sending data to a URL.
+
+ .col-lg-8.append-bottom-default
+ - if plugins.any?
+ .panel.panel-default
+ .panel-heading
+ Plugins (#{plugins.count})
+ %ul.content-list
+ - plugins.each do |file|
+ %li
+ .monospace
+ = File.basename(file)
+ - else
+ %p.light-well.text-center
+ No plugins found.
diff --git a/app/views/shared/projects/_edit_information.html.haml b/app/views/shared/projects/_edit_information.html.haml
new file mode 100644
index 00000000000..ec9dc8f62c2
--- /dev/null
+++ b/app/views/shared/projects/_edit_information.html.haml
@@ -0,0 +1,6 @@
+- unless can?(current_user, :push_code, @project)
+ .inline.prepend-left-10
+ - if @project.branch_allows_maintainer_push?(current_user, selected_branch)
+ = commit_in_single_accessible_branch
+ - else
+ = commit_in_fork_help
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 003f5fa52eb..0687f6d961d 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -6,7 +6,7 @@
- user = local_assigns[:user]
- access = user&.max_member_access_for_project(project.id) unless user.nil?
- css_class = '' unless local_assigns[:css_class]
-- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
+- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
- 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.last_activity_date)
@@ -17,7 +17,7 @@
.avatar-container.s40
= link_to project_path(project), class: dom_class(project) do
- if project.creator && use_creator_avatar
- = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:''
+ = image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
.project-details
@@ -47,12 +47,12 @@
.prepend-top-0
- if project.archived
%span.prepend-left-10.label.label-warning archived
- - if project.pipeline_status.has_status?
+ - if can?(current_user, :read_cross_project) && project.pipeline_status.has_status?
%span.prepend-left-10
= render_project_pipeline_status(project.pipeline_status)
- if forks
%span.prepend-left-10
- = icon('code-fork')
+ = sprite_icon('fork', size: 12)
= number_with_delimiter(project.forks_count)
- if stars
%span.prepend-left-10
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 43322978749..c75c882a693 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,6 +1,5 @@
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
- = 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 common-note-form" } do |f|
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 7388f20a9fd..3acec88c2e3 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -1,7 +1,7 @@
- link_project = local_assigns.fetch(:link_project, false)
%li.snippet-row
- = image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: ''
+ = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 hidden-xs", alt: ''
.title
= link_to reliable_snippet_path(snippet) do
@@ -31,7 +31,7 @@
%span.hidden-xs
in
= link_to project_path(snippet.project) do
- = snippet.project.name_with_namespace
+ = snippet.project.full_name
.pull-right.snippet-updated-at
%span updated #{time_ago_with_tooltip(snippet.updated_at, placement: 'bottom')}
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 1f0e7629fb4..d36ca032558 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -33,6 +33,13 @@
%p.light
This URL will be triggered when someone adds a comment
%li
+ = form.check_box :confidential_note_events, class: 'pull-left'
+ .prepend-left-20
+ = form.label :confidential_note_events, class: 'list-label' do
+ %strong Confidential Comments
+ %p.light
+ This URL will be triggered when someone adds a comment on a confidential issue
+ %li
= form.check_box :issues_events, class: 'pull-left'
.prepend-left-20
= form.label :issues_events, class: 'list-label' do
@@ -50,7 +57,7 @@
= 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
+ %strong Merge request events
%p.light
This URL will be triggered when a merge request is created/updated/merged
%li
diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml
index 8b6a98a054a..65aa4fbc757 100644
--- a/app/views/snippets/_snippets_scope_menu.html.haml
+++ b/app/views/snippets/_snippets_scope_menu.html.haml
@@ -1,7 +1,7 @@
- subject = local_assigns.fetch(:subject, current_user)
- include_private = local_assigns.fetch(:include_private, false)
-.nav-links.snippet-scope-menu
+.nav-links.snippet-scope-menu.mobile-separator
%li{ class: active_when(params[:scope].nil?) }
= link_to subject_snippets_path(subject) do
All
diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml
index f878bece2fa..7eb221620ad 100644
--- a/app/views/u2f/_authenticate.html.haml
+++ b/app/views/u2f/_authenticate.html.haml
@@ -1,6 +1,7 @@
#js-authenticate-u2f
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' } Sign in via 2FA code
+-# haml-lint:disable InlineJavaScript
%script#js-authenticate-u2f-not-supported{ type: "text/template" }
%p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).
diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml
index 79e8f8d0e89..cc0e93c0755 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -1,5 +1,6 @@
#js-register-u2f
+-# haml-lint:disable InlineJavaScript
%script#js-register-u2f-not-supported{ type: "text/template" }
%p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 4f4e81c705f..4bf01ecb48c 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -4,9 +4,6 @@
- page_description @user.bio
- header_title @user.name, user_path(@user)
- @no_container = true
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'common_d3'
- = webpack_bundle_tag 'users'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity")
@@ -35,8 +32,8 @@
.profile-header
.avatar-holder
- = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
- = image_tag avatar_icon(@user, 90), class: "avatar s90", alt: ''
+ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
+ = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
.user-info
.cover-title
@@ -58,15 +55,15 @@
= icon('skype')
- unless @user.linkedin.blank?
.profile-link-holder.middle-dot-divider
- = link_to linkedin_url(@user), title: "LinkedIn" do
+ = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
= icon('linkedin-square')
- unless @user.twitter.blank?
.profile-link-holder.middle-dot-divider
- = link_to twitter_url(@user), title: "Twitter" do
+ = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
= icon('twitter-square')
- unless @user.website_url.blank?
.profile-link-holder.middle-dot-divider
- = link_to @user.short_website_url, @user.full_website_url, class: 'text-link'
+ = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'noopener noreferrer nofollow'
- unless @user.location.blank?
.profile-link-holder.middle-dot-divider
= icon('map-marker')
@@ -85,47 +82,58 @@
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
%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
- %li.js-groups-tab
- = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
- Groups
- %li.js-contributed-tab
- = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
- Contributed projects
- %li.js-projects-tab
- = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
- Personal projects
- %li.js-snippets-tab
- = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
- Snippets
+ - if profile_tab?(:activity)
+ %li.js-activity-tab
+ = link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
+ Activity
+ - if profile_tab?(:groups)
+ %li.js-groups-tab
+ = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
+ Groups
+ - if profile_tab?(:contributed)
+ %li.js-contributed-tab
+ = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
+ Contributed projects
+ - if profile_tab?(:projects)
+ %li.js-projects-tab
+ = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
+ Personal projects
+ - if profile_tab?(:snippets)
+ %li.js-snippets-tab
+ = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
+ Snippets
%div{ class: container_class }
.tab-content
- #activity.tab-pane
- .row-content-block.calender-block.white.second-block.hidden-xs
- .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
+ - if profile_tab?(:activity)
+ #activity.tab-pane
+ .row-content-block.calender-block.white.second-block.hidden-xs
+ .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
- %h4.prepend-top-20
- Most Recent Activity
- .content_list{ data: { href: user_path } }
- = spinner
+ - if can?(current_user, :read_cross_project)
+ %h4.prepend-top-20
+ Most Recent Activity
+ .content_list{ data: { href: user_path } }
+ = spinner
- #groups.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:groups)
+ #groups.tab-pane
+ -# This tab is always loaded via AJAX
- #contributed.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:contributed)
+ #contributed.tab-pane
+ -# This tab is always loaded via AJAX
- #projects.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:projects)
+ #projects.tab-pane
+ -# This tab is always loaded via AJAX
- #snippets.tab-pane
- -# This tab is always loaded via AJAX
+ - if profile_tab?(:snippets)
+ #snippets.tab-pane
+ -# This tab is always loaded via AJAX
.loading-status
= spinner
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 268b7028fd9..9a11cdb121e 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -3,6 +3,7 @@
- cronjob:expire_build_artifacts
- cronjob:gitlab_usage_ping
- cronjob:import_export_project_cleanup
+- cronjob:pages_domain_verification_cron
- cronjob:pipeline_schedule
- cronjob:prune_old_events
- cronjob:remove_expired_group_links
@@ -22,6 +23,8 @@
- gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:wait_for_cluster_creation
+- gcp_cluster:check_gcp_project_billing
+- gcp_cluster:cluster_wait_for_ingress_ip_address
- github_import_advance_stage
- github_importer:github_import_import_diff_note
@@ -36,15 +39,19 @@
- github_importer:github_import_stage_import_pull_requests
- github_importer:github_import_stage_import_repository
+- object_storage_upload
+- object_storage:object_storage_background_move
+- object_storage:object_storage_migrate_uploads
+
- pipeline_cache:expire_job_cache
- pipeline_cache:expire_pipeline_cache
- pipeline_creation:create_pipeline
- pipeline_creation:run_pipeline_schedule
+- pipeline_background:archive_trace
- pipeline_default:build_coverage
- pipeline_default:build_trace_sections
- pipeline_default:pipeline_metrics
- pipeline_default:pipeline_notification
-- pipeline_default:update_head_pipeline_for_merge_request
- pipeline_hooks:build_hooks
- pipeline_hooks:pipeline_hooks
- pipeline_processing:build_finished
@@ -54,6 +61,7 @@
- pipeline_processing:pipeline_success
- pipeline_processing:pipeline_update
- pipeline_processing:stage_update
+- pipeline_processing:update_head_pipeline_for_merge_request
- repository_check:repository_check_clear
- repository_check:repository_check_single_repository
@@ -80,6 +88,8 @@
- new_merge_request
- new_note
- pages
+- pages_domain_verification
+- plugin
- post_receive
- process_commit
- project_cache
@@ -89,6 +99,7 @@
- project_service
- propagate_service_template
- reactive_caching
+- rebase
- repository_fork
- repository_import
- storage_migrator
diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb
new file mode 100644
index 00000000000..dea7425ad88
--- /dev/null
+++ b/app/workers/archive_trace_worker.rb
@@ -0,0 +1,10 @@
+class ArchiveTraceWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ def perform(job_id)
+ Ci::Build.find_by(id: job_id).try do |job|
+ job.trace.archive!
+ end
+ end
+end
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index 09559e3b696..8fe3619f6ee 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -1,42 +1,18 @@
class AuthorizedProjectsWorker
include ApplicationWorker
+ prepend WaitableWorker
- # Schedules multiple jobs and waits for them to be completed.
- def self.bulk_perform_and_wait(args_list)
- # Short-circuit: it's more efficient to do small numbers of jobs inline
- return bulk_perform_inline(args_list) if args_list.size <= 3
-
- waiter = Gitlab::JobWaiter.new(args_list.size)
-
- # Point all the bulk jobs at the same JobWaiter. Converts, [[1], [2], [3]]
- # into [[1, "key"], [2, "key"], [3, "key"]]
- waiting_args_list = args_list.map { |args| [*args, waiter.key] }
- bulk_perform_async(waiting_args_list)
-
- waiter.wait
- end
-
- # Performs multiple jobs directly. Failed jobs will be put into sidekiq so
- # they can benefit from retries
- def self.bulk_perform_inline(args_list)
- failed = []
-
- args_list.each do |args|
- begin
- new.perform(*args)
- rescue
- failed << args
- end
+ # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the
+ # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231
+ # for more details.
+ if Rails.env.test?
+ def self.bulk_perform_and_wait(args_list, timeout: 10)
end
-
- bulk_perform_async(failed) if failed.present?
end
- def perform(user_id, notify_key = nil)
+ def perform(user_id)
user = User.find_by(id: user_id)
user&.refresh_authorized_projects
- ensure
- Gitlab::JobWaiter.notify(notify_key, jid) if notify_key
end
end
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index aeb3bc019b9..376703f6319 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -1,10 +1,53 @@
class BackgroundMigrationWorker
include ApplicationWorker
+ # The minimum amount of time between processing two jobs of the same migration
+ # class.
+ #
+ # This interval is set to 5 minutes so autovacuuming and other maintenance
+ # related tasks have plenty of time to clean up after a migration has been
+ # performed.
+ MIN_INTERVAL = 5.minutes.to_i
+
# Performs the background migration.
#
# See Gitlab::BackgroundMigration.perform for more information.
+ #
+ # class_name - The class name of the background migration to run.
+ # arguments - The arguments to pass to the migration class.
def perform(class_name, arguments = [])
- Gitlab::BackgroundMigration.perform(class_name, arguments)
+ should_perform, ttl = perform_and_ttl(class_name)
+
+ if should_perform
+ Gitlab::BackgroundMigration.perform(class_name, arguments)
+ else
+ # If the lease could not be obtained this means either another process is
+ # running a migration of this class or we ran one recently. In this case
+ # we'll reschedule the job in such a way that it is picked up again around
+ # the time the lease expires.
+ self.class.perform_in(ttl || MIN_INTERVAL, class_name, arguments)
+ end
+ end
+
+ def perform_and_ttl(class_name)
+ if always_perform?
+ # In test environments `perform_in` will run right away. This can then
+ # lead to stack level errors in the above `#perform`. To work around this
+ # we'll just perform the migration right away in the test environment.
+ [true, nil]
+ else
+ lease = lease_for(class_name)
+
+ [lease.try_obtain, lease.ttl]
+ end
+ end
+
+ def lease_for(class_name)
+ Gitlab::ExclusiveLease
+ .new("#{self.class.name}:#{class_name}", timeout: MIN_INTERVAL)
+ end
+
+ def always_perform?
+ Rails.env.test?
end
end
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index 97d80305bec..46f1ac09915 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -6,9 +6,13 @@ class BuildFinishedWorker
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
- BuildTraceSectionsWorker.perform_async(build.id)
+ # We execute that in sync as this access the files in order to access local file, and reduce IO
+ BuildTraceSectionsWorker.new.perform(build.id)
BuildCoverageWorker.new.perform(build.id)
- BuildHooksWorker.new.perform(build.id)
+
+ # We execute that async as this are two indepentent operations that can be executed after TraceSections and Coverage
+ BuildHooksWorker.perform_async(build.id)
+ ArchiveTraceWorker.perform_async(build.id)
end
end
end
diff --git a/app/workers/check_gcp_project_billing_worker.rb b/app/workers/check_gcp_project_billing_worker.rb
new file mode 100644
index 00000000000..363f81590ab
--- /dev/null
+++ b/app/workers/check_gcp_project_billing_worker.rb
@@ -0,0 +1,92 @@
+require 'securerandom'
+
+class CheckGcpProjectBillingWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ LEASE_TIMEOUT = 3.seconds.to_i
+ SESSION_KEY_TIMEOUT = 5.minutes
+ BILLING_TIMEOUT = 1.hour
+ BILLING_CHANGED_LABELS = { state_transition: nil }.freeze
+
+ def self.get_session_token(token_key)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.get(get_redis_session_key(token_key))
+ end
+ end
+
+ def self.store_session_token(token)
+ generate_token_key.tap do |token_key|
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(get_redis_session_key(token_key), token, ex: SESSION_KEY_TIMEOUT)
+ end
+ end
+ end
+
+ def self.get_billing_state(token)
+ Gitlab::Redis::SharedState.with do |redis|
+ value = redis.get(redis_shared_state_key_for(token))
+ ActiveRecord::Type::Boolean.new.type_cast_from_user(value)
+ end
+ end
+
+ def perform(token_key)
+ return unless token_key
+
+ token = self.class.get_session_token(token_key)
+ return unless token
+ return unless try_obtain_lease_for(token)
+
+ billing_enabled_state = !CheckGcpProjectBillingService.new.execute(token).empty?
+ update_billing_change_counter(self.class.get_billing_state(token), billing_enabled_state)
+ self.class.set_billing_state(token, billing_enabled_state)
+ end
+
+ private
+
+ def self.generate_token_key
+ SecureRandom.uuid
+ end
+
+ def self.get_redis_session_key(token_key)
+ "gitlab:gcp:session:#{token_key}"
+ end
+
+ def self.redis_shared_state_key_for(token)
+ "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled"
+ end
+
+ def self.set_billing_state(token, value)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(redis_shared_state_key_for(token), value, ex: BILLING_TIMEOUT)
+ end
+ end
+
+ def try_obtain_lease_for(token)
+ Gitlab::ExclusiveLease
+ .new("check_gcp_project_billing_worker:#{token.hash}", timeout: LEASE_TIMEOUT)
+ .try_obtain
+ end
+
+ def billing_changed_counter
+ @billing_changed_counter ||= Gitlab::Metrics.counter(
+ :gcp_billing_change_count,
+ "Counts the number of times a GCP project changed billing_enabled state from false to true",
+ BILLING_CHANGED_LABELS
+ )
+ end
+
+ def state_transition(previous_state, current_state)
+ if previous_state.nil? && !current_state
+ 'no_billing'
+ elsif previous_state.nil? && current_state
+ 'with_billing'
+ elsif !previous_state && current_state
+ 'billing_configured'
+ end
+ end
+
+ def update_billing_change_counter(previous_state, current_state)
+ billing_changed_counter.increment(state_transition: state_transition(previous_state, current_state))
+ end
+end
diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
new file mode 100644
index 00000000000..8ba5951750c
--- /dev/null
+++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb
@@ -0,0 +1,11 @@
+class ClusterWaitForIngressIpAddressWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::CheckIngressIpAddressService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index 9a9fbaad653..100d86e38c8 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -22,7 +22,7 @@ module Gitlab
importer_class.new(object, project, client).execute
- counter.increment(project: project.path_with_namespace)
+ counter.increment(project: project.full_path)
end
def counter
diff --git a/app/workers/concerns/object_storage_queue.rb b/app/workers/concerns/object_storage_queue.rb
new file mode 100644
index 00000000000..a80f473a6d4
--- /dev/null
+++ b/app/workers/concerns/object_storage_queue.rb
@@ -0,0 +1,8 @@
+# Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers.
+module ObjectStorageQueue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :object_storage
+ end
+end
diff --git a/app/workers/concerns/pipeline_background_queue.rb b/app/workers/concerns/pipeline_background_queue.rb
new file mode 100644
index 00000000000..8bf43de6b26
--- /dev/null
+++ b/app/workers/concerns/pipeline_background_queue.rb
@@ -0,0 +1,10 @@
+##
+# Concern for setting Sidekiq settings for the low priority CI pipeline workers.
+#
+module PipelineBackgroundQueue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :pipeline_background
+ end
+end
diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb
index 10b971344f7..ef23990ad97 100644
--- a/app/workers/concerns/project_import_options.rb
+++ b/app/workers/concerns/project_import_options.rb
@@ -1,9 +1,9 @@
module ProjectImportOptions
extend ActiveSupport::Concern
- included do
- IMPORT_RETRY_COUNT = 5
+ IMPORT_RETRY_COUNT = 5
+ included do
sidekiq_options retry: IMPORT_RETRY_COUNT, status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION
# We only want to mark the project as failed once we exhausted all retries
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
new file mode 100644
index 00000000000..48ebe862248
--- /dev/null
+++ b/app/workers/concerns/waitable_worker.rb
@@ -0,0 +1,44 @@
+module WaitableWorker
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ # Schedules multiple jobs and waits for them to be completed.
+ def bulk_perform_and_wait(args_list, timeout: 10)
+ # Short-circuit: it's more efficient to do small numbers of jobs inline
+ return bulk_perform_inline(args_list) if args_list.size <= 3
+
+ waiter = Gitlab::JobWaiter.new(args_list.size)
+
+ # Point all the bulk jobs at the same JobWaiter. Converts, [[1], [2], [3]]
+ # into [[1, "key"], [2, "key"], [3, "key"]]
+ waiting_args_list = args_list.map { |args| [*args, waiter.key] }
+ bulk_perform_async(waiting_args_list)
+
+ waiter.wait(timeout)
+ end
+
+ # Performs multiple jobs directly. Failed jobs will be put into sidekiq so
+ # they can benefit from retries
+ def bulk_perform_inline(args_list)
+ failed = []
+
+ args_list.each do |args|
+ begin
+ new.perform(*args)
+ rescue
+ failed << args
+ end
+ end
+
+ bulk_perform_async(failed) if failed.present?
+ end
+ end
+
+ def perform(*args)
+ notify_key = args.pop if Gitlab::JobWaiter.key?(args.last)
+
+ super(*args)
+ ensure
+ Gitlab::JobWaiter.notify(notify_key, jid) if notify_key
+ end
+end
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index 21da27973fe..2a4d65b5cb3 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -66,7 +66,7 @@ class EmailsOnPushWorker
# These are input errors and won't be corrected even if Sidekiq retries
rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e
- logger.info("Failed to send e-mail for project '#{project.name_with_namespace}' to #{recipient}: #{e}")
+ logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}")
end
end
ensure
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index 8e26275669e..be4203bc7ad 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -1,6 +1,5 @@
class GitGarbageCollectWorker
include ApplicationWorker
- include Gitlab::CurrentSettings
sidekiq_options retry: false
@@ -29,22 +28,27 @@ class GitGarbageCollectWorker
task = task.to_sym
cmd = command(task)
- repo_path = project.repository.path_to_repo
- description = "'#{cmd.join(' ')}' in #{repo_path}"
-
- Gitlab::GitLogger.info(description)
gitaly_migrate(GITALY_MIGRATED_TASKS[task]) do |is_enabled|
if is_enabled
gitaly_call(task, project.repository.raw_repository)
else
+ repo_path = project.repository.path_to_repo
+ description = "'#{cmd.join(' ')}' in #{repo_path}"
+ Gitlab::GitLogger.info(description)
+
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
+
+ # In case pack files are deleted, release libgit2 cache and open file
+ # descriptors ASAP instead of waiting for Ruby garbage collection
+ project.cleanup
ensure
cancel_lease(lease_key, lease_uuid) if lease_key.present? && lease_uuid.present?
end
@@ -102,7 +106,7 @@ class GitGarbageCollectWorker
end
def bitmaps_enabled?
- current_application_settings.housekeeping_bitmaps_enabled
+ Gitlab::CurrentSettings.housekeeping_bitmaps_enabled
end
def git(write_bitmaps:)
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
index 073d6608082..a779e631516 100644
--- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -16,7 +16,7 @@ module Gitlab
def report_import_time(project)
duration = Time.zone.now - project.created_at
- path = project.path_with_namespace
+ path = project.full_path
histogram.observe({ project: path }, duration)
counter.increment
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index f577b310b20..509bd09dc2e 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -4,7 +4,7 @@ class GroupDestroyWorker
def perform(group_id, user_id)
begin
- group = Group.with_deleted.find(group_id)
+ group = Group.find(group_id)
rescue ActiveRecord::RecordNotFound
return
end
diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb
index 67c54fbf10e..b925741934a 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -5,7 +5,7 @@ class NewNoteWorker
# old `NewNoteWorker` jobs (can remove later)
def perform(note_id, _params = {})
if note = Note.find_by(id: note_id)
- NotificationService.new.new_note(note)
+ NotificationService.new.new_note(note) if note.can_create_notification?
Notes::PostProcessService.new(note).execute
else
Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb
new file mode 100644
index 00000000000..9c4d72e0ecf
--- /dev/null
+++ b/app/workers/object_storage/background_move_worker.rb
@@ -0,0 +1,29 @@
+module ObjectStorage
+ class BackgroundMoveWorker
+ include ApplicationWorker
+ include ObjectStorageQueue
+
+ sidekiq_options retry: 5
+
+ def perform(uploader_class_name, subject_class_name, file_field, subject_id)
+ uploader_class = uploader_class_name.constantize
+ subject_class = subject_class_name.constantize
+
+ return unless uploader_class < ObjectStorage::Concern
+ return unless uploader_class.object_store_enabled?
+ return unless uploader_class.background_upload_enabled?
+
+ subject = subject_class.find(subject_id)
+ uploader = build_uploader(subject, file_field&.to_sym)
+ uploader.migrate!(ObjectStorage::Store::REMOTE)
+ end
+
+ def build_uploader(subject, mount_point)
+ case subject
+ when Upload then subject.build_uploader(mount_point)
+ else
+ subject.send(mount_point) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
new file mode 100644
index 00000000000..a6b2c251254
--- /dev/null
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -0,0 +1,214 @@
+# frozen_string_literal: true
+# rubocop:disable Metrics/LineLength
+# rubocop:disable Style/Documentation
+
+module ObjectStorage
+ class MigrateUploadsWorker
+ include ApplicationWorker
+ include ObjectStorageQueue
+
+ SanityCheckError = Class.new(StandardError)
+
+ class Upload < ActiveRecord::Base
+ # Upper limit for foreground checksum processing
+ CHECKSUM_THRESHOLD = 100.megabytes
+
+ belongs_to :model, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+
+ validates :size, presence: true
+ validates :path, presence: true
+ validates :model, presence: true
+ validates :uploader, presence: true
+
+ before_save :calculate_checksum!, if: :foreground_checksummable?
+ after_commit :schedule_checksum, if: :checksummable?
+
+ scope :stored_locally, -> { where(store: [nil, ObjectStorage::Store::LOCAL]) }
+ scope :stored_remotely, -> { where(store: ObjectStorage::Store::REMOTE) }
+
+ def self.hexdigest(path)
+ Digest::SHA256.file(path).hexdigest
+ end
+
+ def absolute_path
+ raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local?
+ return path unless relative_path?
+
+ uploader_class.absolute_path(self)
+ end
+
+ def calculate_checksum!
+ self.checksum = nil
+ return unless checksummable?
+
+ self.checksum = self.class.hexdigest(absolute_path)
+ end
+
+ def build_uploader(mounted_as = nil)
+ uploader_class.new(model, mounted_as).tap do |uploader|
+ uploader.upload = self
+ uploader.retrieve_from_store!(identifier)
+ end
+ end
+
+ def exist?
+ File.exist?(absolute_path)
+ end
+
+ def local?
+ return true if store.nil?
+
+ store == ObjectStorage::Store::LOCAL
+ end
+
+ private
+
+ def checksummable?
+ checksum.nil? && local? && exist?
+ end
+
+ def foreground_checksummable?
+ checksummable? && size <= CHECKSUM_THRESHOLD
+ end
+
+ def schedule_checksum
+ UploadChecksumWorker.perform_async(id)
+ end
+
+ def relative_path?
+ !path.start_with?('/')
+ end
+
+ def identifier
+ File.basename(path)
+ end
+
+ def uploader_class
+ Object.const_get(uploader)
+ end
+ end
+
+ class MigrationResult
+ attr_reader :upload
+ attr_accessor :error
+
+ def initialize(upload, error = nil)
+ @upload, @error = upload, error
+ end
+
+ def success?
+ error.nil?
+ end
+
+ def to_s
+ success? ? "Migration successful." : "Error while migrating #{upload.id}: #{error.message}"
+ end
+ end
+
+ module Report
+ class MigrationFailures < StandardError
+ attr_reader :errors
+
+ def initialize(errors)
+ @errors = errors
+ end
+
+ def message
+ errors.map(&:message).join("\n")
+ end
+ end
+
+ def report!(results)
+ success, failures = results.partition(&:success?)
+
+ Rails.logger.info header(success, failures)
+ Rails.logger.warn failures(failures)
+
+ raise MigrationFailures.new(failures.map(&:error)) if failures.any?
+ end
+
+ def header(success, failures)
+ "Migrated #{success.count}/#{success.count + failures.count} files."
+ end
+
+ def failures(failures)
+ failures.map { |f| "\t#{f}" }.join('\n')
+ end
+ end
+
+ include Report
+
+ def self.enqueue!(uploads, model_class, mounted_as, to_store)
+ sanity_check!(uploads, model_class, mounted_as)
+
+ perform_async(uploads.ids, model_class.to_s, mounted_as, to_store)
+ end
+
+ # We need to be sure all the uploads are for the same uploader and model type
+ # and that the mount point exists if provided.
+ #
+ def self.sanity_check!(uploads, model_class, mounted_as)
+ upload = uploads.first
+ uploader_class = upload.uploader.constantize
+ uploader_types = uploads.map(&:uploader).uniq
+ model_types = uploads.map(&:model_type).uniq
+ model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class
+
+ raise(SanityCheckError, "Multiple uploaders found: #{uploader_types}") unless uploader_types.count == 1
+ raise(SanityCheckError, "Multiple model types found: #{model_types}") unless model_types.count == 1
+ raise(SanityCheckError, "Mount point #{mounted_as} not found in #{model_class}.") unless model_has_mount
+ end
+
+ def perform(*args)
+ args_check!(args)
+
+ (ids, model_type, mounted_as, to_store) = args
+
+ @model_class = model_type.constantize
+ @mounted_as = mounted_as&.to_sym
+ @to_store = to_store
+
+ uploads = Upload.preload(:model).where(id: ids)
+
+ sanity_check!(uploads)
+ results = migrate(uploads)
+
+ report!(results)
+ rescue SanityCheckError => e
+ # do not retry: the job is insane
+ Rails.logger.warn "#{self.class}: Sanity check error (#{e.message})"
+ end
+
+ def sanity_check!(uploads)
+ self.class.sanity_check!(uploads, @model_class, @mounted_as)
+ end
+
+ def args_check!(args)
+ return if args.count == 4
+
+ case args.count
+ when 3 then raise SanityCheckError, "Job is missing the `model_type` argument."
+ else
+ raise SanityCheckError, "Job has wrong arguments format."
+ end
+ end
+
+ def build_uploaders(uploads)
+ uploads.map { |upload| upload.build_uploader(@mounted_as) }
+ end
+
+ def migrate(uploads)
+ build_uploaders(uploads).map(&method(:process_uploader))
+ end
+
+ def process_uploader(uploader)
+ MigrationResult.new(uploader.upload).tap do |result|
+ begin
+ uploader.migrate!(@to_store)
+ rescue => e
+ result.error = e
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/object_storage_upload_worker.rb b/app/workers/object_storage_upload_worker.rb
new file mode 100644
index 00000000000..5c80f34069c
--- /dev/null
+++ b/app/workers/object_storage_upload_worker.rb
@@ -0,0 +1,21 @@
+# @Deprecated - remove once the `object_storage_upload` queue is empty
+# The queue has been renamed `object_storage:object_storage_background_upload`
+#
+class ObjectStorageUploadWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: 5
+
+ def perform(uploader_class_name, subject_class_name, file_field, subject_id)
+ uploader_class = uploader_class_name.constantize
+ subject_class = subject_class_name.constantize
+
+ return unless uploader_class < ObjectStorage::Concern
+ return unless uploader_class.object_store_enabled?
+ return unless uploader_class.background_upload_enabled?
+
+ subject = subject_class.find(subject_id)
+ uploader = subject.public_send(file_field) # rubocop:disable GitlabSecurity/PublicSend
+ uploader.migrate!(ObjectStorage::Store::REMOTE)
+ end
+end
diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb
new file mode 100644
index 00000000000..a3ff4bd2101
--- /dev/null
+++ b/app/workers/pages_domain_verification_cron_worker.rb
@@ -0,0 +1,10 @@
+class PagesDomainVerificationCronWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ def perform
+ PagesDomain.needs_verification.find_each do |domain|
+ PagesDomainVerificationWorker.perform_async(domain.id)
+ end
+ end
+end
diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb
new file mode 100644
index 00000000000..2e93489113c
--- /dev/null
+++ b/app/workers/pages_domain_verification_worker.rb
@@ -0,0 +1,11 @@
+class PagesDomainVerificationWorker
+ include ApplicationWorker
+
+ def perform(domain_id)
+ domain = PagesDomain.find_by(id: domain_id)
+
+ return unless domain
+
+ VerifyPagesDomainService.new(domain).execute
+ end
+end
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index 3ec81d040b4..66a0ff83bef 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -1,7 +1,7 @@
class PagesWorker
include ApplicationWorker
- sidekiq_options retry: false
+ sidekiq_options retry: 3
def perform(action, *arg)
send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
@@ -13,6 +13,7 @@ class PagesWorker
if result[:status] == :success
result = Projects::UpdatePagesConfigurationService.new(build.project).execute
end
+
result
end
diff --git a/app/workers/plugin_worker.rb b/app/workers/plugin_worker.rb
new file mode 100644
index 00000000000..bfcc683d99a
--- /dev/null
+++ b/app/workers/plugin_worker.rb
@@ -0,0 +1,15 @@
+class PluginWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: false
+
+ def perform(file_name, data)
+ success, message = Gitlab::Plugin.execute(file_name, data)
+
+ unless success
+ Gitlab::PluginLogger.error("Plugin Error => #{file_name}: #{message}")
+ end
+
+ true
+ end
+end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index f2b2c4428d3..3909dbf7d7f 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -55,7 +55,7 @@ class PostReceive
end
def process_wiki_changes(post_received)
- # Nothing defined here yet.
+ post_received.project.touch(:last_activity_at, :last_repository_updated_at)
end
def log(message)
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 52eebe475ec..201e7f332b4 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -23,27 +23,24 @@ class ProcessCommitWorker
return unless user
commit = build_commit(project, commit_hash)
-
author = commit.author || user
process_commit_message(project, commit, user, author, default)
-
update_issue_metrics(commit, author)
end
def process_commit_message(project, commit, user, author, default = false)
- closed_issues = default ? commit.closes_issues(user) : []
-
- unless closed_issues.empty?
- close_issues(project, user, author, commit, closed_issues)
- end
+ # Ignore closing references from GitLab-generated commit messages.
+ find_closing_issues = default && !commit.merged_merge_request?(user)
+ closed_issues = find_closing_issues ? commit.closes_issues(user) : []
+ close_issues(project, user, author, commit, closed_issues) if closed_issues.any?
commit.create_cross_references!(author, closed_issues)
end
def close_issues(project, user, author, commit, issues)
# We don't want to run permission related queries for every single issue,
- # therefor we use IssueCollection here and skip the authorization check in
+ # therefore 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)
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index f19bcbf946a..a993b4b2680 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -18,6 +18,8 @@ class ProjectCacheWorker
update_statistics(project, statistics.map(&:to_sym))
project.repository.refresh_method_caches(files.map(&:to_sym))
+
+ project.cleanup
end
def update_statistics(project, statistics = [])
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index c100852374a..c3d84bb0b93 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -4,10 +4,19 @@ class ProjectExportWorker
sidekiq_options retry: 3
- def perform(current_user_id, project_id)
+ def perform(current_user_id, project_id, after_export_strategy = {}, params = {})
current_user = User.find(current_user_id)
project = Project.find(project_id)
+ after_export = build!(after_export_strategy)
- ::Projects::ImportExport::ExportService.new(project, current_user).execute
+ ::Projects::ImportExport::ExportService.new(project, current_user, params).execute(after_export)
+ end
+
+ private
+
+ def build!(after_export_strategy)
+ strategy_klass = after_export_strategy&.delete('klass')
+
+ Gitlab::ImportExport::AfterExportStrategyBuilder.build!(strategy_klass, after_export_strategy)
end
end
diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb
new file mode 100644
index 00000000000..090987778a2
--- /dev/null
+++ b/app/workers/rebase_worker.rb
@@ -0,0 +1,12 @@
+class RebaseWorker
+ include ApplicationWorker
+
+ def perform(merge_request_id, current_user_id)
+ current_user = User.find(current_user_id)
+ merge_request = MergeRequest.find(merge_request_id)
+
+ MergeRequests::RebaseService
+ .new(merge_request.source_project, current_user)
+ .execute(merge_request)
+ end
+end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index d80b3b15840..68960f72bf6 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -5,7 +5,7 @@ class RemoveExpiredMembersWorker
def perform
Member.expired.find_each do |member|
begin
- Members::AuthorizedDestroyService.new(member).execute
+ Members::DestroyService.new.execute(member, skip_authorization: true)
rescue => ex
logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
end
diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb
index 4e3c691e8da..116bc185b38 100644
--- a/app/workers/repository_check/single_repository_worker.rb
+++ b/app/workers/repository_check/single_repository_worker.rb
@@ -20,10 +20,7 @@ module RepositoryCheck
# Historically some projects never had their wiki repos initialized;
# this happens on project creation now. Let's initialize an empty repo
# if it is not already there.
- begin
- project.create_wiki
- rescue Rugged::RepositoryError
- end
+ project.create_wiki
git_fsck(project.wiki.repository)
else
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index d1c57b82681..51fad4faf36 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -4,27 +4,47 @@ class RepositoryForkWorker
include ProjectStartImport
include ProjectImportOptions
- def perform(project_id, forked_from_repository_storage_path, source_disk_path)
- project = Project.find(project_id)
+ def perform(*args)
+ target_project_id = args.shift
+ target_project = Project.find(target_project_id)
- return unless start_fork(project)
+ # By v10.8, we should've drained the queue of all jobs using the old arguments.
+ # We can remove the else clause if we're no longer logging the message in that clause.
+ # See https://gitlab.com/gitlab-org/gitaly/issues/1110
+ if args.empty?
+ source_project = target_project.forked_from_project
+ return target_project.mark_import_as_failed('Source project cannot be found.') unless source_project
- Gitlab::Metrics.add_event(:fork_repository,
- source_path: source_disk_path,
- target_path: project.disk_path)
+ fork_repository(target_project, source_project.repository_storage, source_project.disk_path)
+ else
+ Rails.logger.info("Project #{target_project.id} is being forked using old-style arguments.")
- result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_disk_path,
- project.repository_storage_path, project.disk_path)
- raise "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result
+ source_repository_storage_path, source_disk_path = *args
- project.repository.after_import
- raise "Project #{project_id} had an invalid repository after fork" unless project.valid_repo?
+ source_repository_storage_name = Gitlab.config.repositories.storages.find do |_, info|
+ info.legacy_disk_path == source_repository_storage_path
+ end&.first || raise("no shard found for path '#{source_repository_storage_path}'")
- project.import_finish
+ fork_repository(target_project, source_repository_storage_name, source_disk_path)
+ end
end
private
+ def fork_repository(target_project, source_repository_storage_name, source_disk_path)
+ return unless start_fork(target_project)
+
+ Gitlab::Metrics.add_event(:fork_repository,
+ source_path: source_disk_path,
+ target_path: target_project.disk_path)
+
+ result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path,
+ target_project.repository_storage, target_project.disk_path)
+ raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result
+
+ target_project.after_import
+ end
+
def start_fork(project)
return true if start(project)
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 31e2798c36b..d79b5ee5346 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -20,7 +20,11 @@ class RepositoryImportWorker
# to those importers to mark the import process as complete.
return if service.async?
- raise result[:message] if result[:status] == :error
+ if result[:status] == :error
+ fail_import(project, result[:message]) if project.gitlab_project_import?
+
+ raise result[:message]
+ end
project.after_import
end
@@ -33,4 +37,8 @@ class RepositoryImportWorker
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_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index e0e6d1418de..fbb14efc525 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -16,43 +16,41 @@ class StuckImportJobsWorker
private
def mark_projects_without_jid_as_failed!
- started_projects_without_jid.each do |project|
+ enqueued_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
+ jids_and_ids = enqueued_projects_with_jid.pluck(:import_jid, :id).to_h
- 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_and_ids.keys)
+ return unless completed_jids.any?
- # Find the jobs that aren't currently running or that exceeded the threshold.
- completed_jids = Gitlab::SidekiqStatus.completed_jids(jids).to_set
+ completed_project_ids = jids_and_ids.values_at(*completed_jids)
- if completed_jids.any?
- 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
+ # We select the projects again, because they may have transitioned from
+ # scheduled/started to finished/failed while we were looking up their Sidekiq status.
+ completed_projects = enqueued_projects_with_jid.where(id: completed_project_ids)
- Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_jids.to_a.join(', ')}")
- end
- end
+ Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_projects.map(&:import_jid).join(', ')}")
- completed_jids_count
+ completed_projects.each do |project|
+ project.mark_import_as_failed(error_message)
+ end.count
end
- def started_projects
- Project.with_import_status(:started)
+ def enqueued_projects
+ Project.with_import_status(:scheduled, :started)
end
- def started_projects_with_jid
- started_projects.where.not(import_jid: nil)
+ def enqueued_projects_with_jid
+ enqueued_projects.where.not(import_jid: nil)
end
- def started_projects_without_jid
- started_projects.where(import_jid: nil)
+ def enqueued_projects_without_jid
+ enqueued_projects.where(import_jid: nil)
end
def error_message
diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb
index f09d89aa170..76f84ff920f 100644
--- a/app/workers/update_head_pipeline_for_merge_request_worker.rb
+++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb
@@ -2,6 +2,8 @@ class UpdateHeadPipelineForMergeRequestWorker
include ApplicationWorker
include PipelineQueue
+ queue_namespace :pipeline_processing
+
def perform(merge_request_id)
merge_request = MergeRequest.find(merge_request_id)
pipeline = Ci::Pipeline.where(project: merge_request.source_project, ref: merge_request.source_branch).last
diff --git a/app/workers/upload_checksum_worker.rb b/app/workers/upload_checksum_worker.rb
index 9222760c031..65d40336f18 100644
--- a/app/workers/upload_checksum_worker.rb
+++ b/app/workers/upload_checksum_worker.rb
@@ -3,7 +3,7 @@ class UploadChecksumWorker
def perform(upload_id)
upload = Upload.find(upload_id)
- upload.calculate_checksum
+ upload.calculate_checksum!
upload.save!
rescue ActiveRecord::RecordNotFound
Rails.logger.error("UploadChecksumWorker: couldn't find upload #{upload_id}, skipping")
diff --git a/bin/changelog b/bin/changelog
index efe25032ba1..9b60f53ce40 100755
--- a/bin/changelog
+++ b/bin/changelog
@@ -214,9 +214,10 @@ class ChangelogEntry
end
def unreleased_path
- File.join('changelogs', 'unreleased').tap do |path|
- path << '-ee' if ee?
- end
+ path = File.join('changelogs', 'unreleased')
+ path = File.join('ee', path) if ee?
+
+ path
end
def ee?
diff --git a/bin/profile-url b/bin/profile-url
new file mode 100755
index 00000000000..d8d09641624
--- /dev/null
+++ b/bin/profile-url
@@ -0,0 +1,57 @@
+#!/usr/bin/env ruby
+require 'optparse'
+
+options = {}
+
+opt_parser = OptionParser.new do |opt|
+ opt.banner = <<DOCSTRING
+Profile a URL on this GitLab instance.
+
+Usage:
+ #{__FILE__} url --output=<profile-html> --sql=<sql-log> [--user=<user>] [--post=<post-data>]
+
+Example:
+ #{__FILE__} /dashboard/issues --output=dashboard-profile.html --sql=dashboard.log --user=root
+DOCSTRING
+ opt.separator ''
+ opt.separator 'Options:'
+
+ opt.on('-o', '--output=/tmp/profile.html', 'profile output filename') do |output|
+ options[:profile_output] = output
+ end
+
+ opt.on('-s', '--sql=/tmp/profile_sql.txt', 'SQL output filename') do |sql|
+ options[:sql_output] = sql
+ end
+
+ opt.on('-u', '--user=root', 'User to authenticate as') do |username|
+ options[:username] = username
+ end
+
+ opt.on('-p', "--post='user=john&pass=test'", 'Send HTTP POST data') do |post_data|
+ options[:post_data] = post_data
+ end
+end
+
+opt_parser.parse!
+options[:url] = ARGV[0]
+
+if options[:url].nil? ||
+ options[:profile_output].nil? ||
+ options[:sql_output].nil?
+ puts opt_parser
+ exit
+end
+
+require File.expand_path('../config/environment', File.dirname(__FILE__))
+
+result = Gitlab::Profiler.profile(options[:url],
+ logger: Logger.new(options[:sql_output]),
+ post_data: options[:post_data],
+ user: User.find_by_username(options[:username]),
+ private_token: ENV['PRIVATE_TOKEN'])
+
+printer = RubyProf::CallStackPrinter.new(result)
+file = File.open(options[:profile_output], 'w')
+printer.print(file)
+file.close
diff --git a/bin/rails b/bin/rails
index 0138d79b751..228f812ccaf 100755
--- a/bin/rails
+++ b/bin/rails
@@ -1,9 +1,14 @@
#!/usr/bin/env ruby
-begin
- load File.expand_path('../spring', __FILE__)
-rescue LoadError => e
- raise unless e.message.include?('spring')
+
+# Remove this block when upgraded to rails 5.0.
+unless %w[1 true].include?(ENV["RAILS5"])
+ begin
+ load File.expand_path('../spring', __FILE__)
+ rescue LoadError => e
+ raise unless e.message.include?('spring')
+ end
end
-APP_PATH = File.expand_path('../../config/application', __FILE__)
+
+APP_PATH = File.expand_path('../config/application', __dir__)
require_relative '../config/boot'
require 'rails/commands'
diff --git a/bin/rake b/bin/rake
index d87d5f57810..b52a8321f1a 100755
--- a/bin/rake
+++ b/bin/rake
@@ -1,9 +1,14 @@
#!/usr/bin/env ruby
-begin
- load File.expand_path('../spring', __FILE__)
-rescue LoadError => e
- raise unless e.message.include?('spring')
+
+# Remove this block when upgraded to rails 5.0.
+unless %w[1 true].include?(ENV["RAILS5"])
+ begin
+ load File.expand_path('../spring', __FILE__)
+ rescue LoadError => e
+ raise unless e.message.include?('spring')
+ end
end
+
require_relative '../config/boot'
require 'rake'
Rake.application.run
diff --git a/bin/rspec b/bin/rspec
index 6e6709219af..26583242051 100755
--- a/bin/rspec
+++ b/bin/rspec
@@ -1,4 +1,10 @@
#!/usr/bin/env ruby
+
+# Remove these two lines below when upgraded to rails 5.0.
+# Allow run `rspec` command as `RAILS5=1 rspec ...` instead of `BUNDLE_GEMFILE=Gemfile.rails5 rspec ...`
+gemfile = %w[1 true].include?(ENV["RAILS5"]) ? "Gemfile.rails5" : "Gemfile"
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../#{gemfile}", __dir__)
+
begin
load File.expand_path('../spring', __FILE__)
rescue LoadError => e
diff --git a/bin/setup b/bin/setup
index 6cb2d7f1e3a..c60c1267e06 100755
--- a/bin/setup
+++ b/bin/setup
@@ -1,29 +1,61 @@
#!/usr/bin/env ruby
-require 'pathname'
+
+def rails5?
+ %w[1 true].include?(ENV["RAILS5"])
+end
+
+require "pathname"
# path to your application root.
-APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
+APP_ROOT = Pathname.new File.expand_path("../../", __FILE__)
+
+if rails5?
+ def system!(*args)
+ system(*args) || abort("\n== Command #{args} failed ==")
+ end
+end
Dir.chdir APP_ROOT do
# This script is a starting point to setup your application.
# Add necessary setup steps to this file:
puts "== Installing dependencies =="
- system "gem install bundler --conservative"
- system "bundle check || bundle install"
+
+ if rails5?
+ system! "gem install bundler --conservative"
+ system("bundle check") || system!("bundle install")
+ else
+ system "gem install bundler --conservative"
+ system "bundle check || bundle install"
+ end
# puts "\n== Copying sample files =="
# unless File.exist?("config/database.yml")
- # system "cp config/database.yml.sample config/database.yml"
+ # cp "config/database.yml.sample", "config/database.yml"
# end
puts "\n== Preparing database =="
- system "bin/rake db:reset"
+
+ if rails5?
+ system! "bin/rails db:setup"
+ else
+ system "bin/rake db:reset"
+ end
puts "\n== Removing old logs and tempfiles =="
- system "rm -f log/*"
- system "rm -rf tmp/cache"
+
+ if rails5?
+ system! "bin/rails log:clear tmp:clear"
+ else
+ system "rm -f log/*"
+ system "rm -rf tmp/cache"
+ end
puts "\n== Restarting application server =="
- system "touch tmp/restart.txt"
+
+ if rails5?
+ system! "bin/rails restart"
+ else
+ system "touch tmp/restart.txt"
+ end
end
diff --git a/bin/update b/bin/update
new file mode 100755
index 00000000000..a8e4462f203
--- /dev/null
+++ b/bin/update
@@ -0,0 +1,29 @@
+#!/usr/bin/env ruby
+require 'pathname'
+require 'fileutils'
+include FileUtils
+
+# path to your application root.
+APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
+
+def system!(*args)
+ system(*args) || abort("\n== Command #{args} failed ==")
+end
+
+chdir APP_ROOT do
+ # This script is a way to update your development environment automatically.
+ # Add necessary update steps to this file.
+
+ puts '== Installing dependencies =='
+ system! 'gem install bundler --conservative'
+ system('bundle check') || system!('bundle install')
+
+ puts "\n== Updating database =="
+ system! 'bin/rails db:migrate'
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! 'bin/rails log:clear tmp:clear'
+
+ puts "\n== Restarting application server =="
+ system! 'bin/rails restart'
+end
diff --git a/bin/upgrade.rb b/bin/upgrade.rb
deleted file mode 100755
index a5caecf8526..00000000000
--- a/bin/upgrade.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-require_relative "../lib/gitlab/upgrader"
-
-Gitlab::Upgrader.new.execute
diff --git a/changelogs/archive.md b/changelogs/archive.md
index c68ab694d39..fe461a6ac5e 100644
--- a/changelogs/archive.md
+++ b/changelogs/archive.md
@@ -1,3 +1,3251 @@
+## 8.15.8 (2017-03-19)
+
+- Only show public emails in atom feeds.
+- To protect against Server-side Request Forgery project import URLs are now prohibited against localhost or the server IP except for the assigned instance URL and port. Imports are also prohibited from ports below 1024 with the exception of ports 22, 80, and 443.
+
+## 8.15.7 (2017-02-15)
+
+- No changes.
+
+## 8.15.6 (2017-02-14)
+
+- Patch Asciidocs rendering to block XSS.
+- Fix XSS vulnerability in SVG attachments.
+- 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
+- Speed up group milestone index by passing group_id to IssuesFinder. !8363
+- Don't instrument 405 Grape calls. !8445
+- Update the gitlab-markup gem to the version 1.5.1. !8509
+- Updated Turbolinks to mitigate potential XSS attacks.
+- Re-order update steps in the 8.14 -> 8.15 upgrade guide.
+- Re-add Google Cloud Storage as a backup strategy.
+
+## 8.15.3 (2017-01-06)
+
+- Rename wiki_events to wiki_page_events in project hooks API to avoid errors. !8425
+- Rename projects wth reserved names. !8234
+- Cache project authorizations even when user has access to zero projects. !8327
+- Fix a minor grammar error in merge request widget. !8337
+- Fix unclear closing issue behaviour on Merge Request show page. !8345 (Gabriel Gizotti)
+- fix border in login session tabs. !8346
+- Copy, don't move uploaded avatar files. !8396
+- Increases width of mini-pipeline-graph dropdown to prevent wrong position on chrome on ubuntu. !8399
+- Removes invalid html and unneed CSS to prevent shaking in the pipelines tab. !8411
+- Gitlab::LDAP::Person uses LDAP attributes configuration. !8418
+- Fix 500 errors when creating a user with identity via API. !8442
+- Whitelist next project names: assets, profile, public. !8470
+- Fixed regression of note-headline-light where it was always placed on 2 lines, even on wide viewports.
+- Fix 500 error when visit group from admin area if group name contains dot.
+- Fix cross-project references copy to include the project reference.
+- Fix 500 error renaming group.
+- Fixed GFM dropdown not showing on new lines.
+
+## 8.15.2 (2016-12-27)
+
+- Fix finding the latest pipeline. !8301
+- Fix mr list timestamp alignment. !8271
+- Fix discussion overlap text in regular screens. !8273
+- Fixes mini-pipeline-graph dropdown animation and stage position in chrome, firefox and safari. !8282
+- Fix line breaking in nodes of the pipeline graph in firefox. !8292
+- Fixes confendential warning text alignment. !8293
+- Hide Scroll Top button for failed build page. !8295
+- Fix finding the latest pipeline. !8301
+- Disable PostgreSQL statement timeouts when removing unneeded services. !8322
+- Fix timeout when MR contains large files marked as binary by .gitattributes.
+- Rename "autodeploy" to "auto deploy".
+- Fixed GFM autocomplete error when no data exists.
+- Fixed resolve discussion note button color.
+
+## 8.15.1 (2016-12-23)
+
+- Push payloads schedule at most 100 commits, instead of all commits.
+- Fix Mattermost command creation by specifying username.
+- Do not override incoming webhook for mattermost and slack.
+- Adds background color for disabled state to merge when succeeds dropdown. !8222
+- Standardises font-size for titles in Issues, Merge Requests and Merge Request widget. !8235
+- Fix Pipeline builds list blank on MR. !8255
+- Do not show retried builds in pipeline stage dropdown. !8260
+
+## 8.15.0 (2016-12-22)
+
+- Whitelist next project names: notes, services.
+- Use Grape's new Route methods.
+- Fixed issue boards scrolling with a lot of lists & issues.
+- Remove unnecessary sentences for status codes in the API documentation. (Luis Alonso Chavez Armendariz)
+- Allow unauthenticated access to Repositories Files API GET endpoints.
+- Add note to the invite page when the logged in user email is not the same as the invitation.
+- Don't accidentally mark unsafe diff lines as HTML safe.
+- Add git diff context to notifications of new notes on merge requests. (Heidi Hoopes)
+- Shows group members in project members list.
+- Gem update: Update grape to 0.18.0. (Robert Schilling)
+- API: Expose merge status for branch API. (Robert Schilling)
+- Displays milestone remaining days only when it's present.
+- API: Expose committer details for commits. (Robert Schilling)
+- API: Ability to set 'should_remove_source_branch' on merge requests. (Robert Schilling)
+- Fix project import label priorities error.
+- Fix Import/Export merge requests error while importing.
+- Refactor Bitbucket importer to use BitBucket API Version 2.
+- Fix Import/Export duplicated builds error.
+- Ci::Builds have same ref as Ci::Pipeline in dev fixtures. (twonegatives)
+- For single line git commit messages, the close quote should be on the same line as the open quote.
+- Use authorized projects in ProjectTeam.
+- Destroy a user's session when they delete their own account.
+- Edit help text to clarify annotated tag creation. (Liz Lam)
+- Fixed file template dropdown for the "New File" editor for smaller/zoomed screens.
+- Fix Route#rename_children behavior.
+- Add nested groups support on data level.
+- Allow projects with 'dashboard' as path.
+- Disabled emoji buttons when user is not logged in.
+- Remove unused and void services from the database.
+- Add issue search slash command.
+- Accept issue new as command to create an issue.
+- Non members cannot create labels through the API.
+- API: expose pipeline coverage.
+- Validate state param when filtering issuables.
+- Username exists check respects relative root path.
+- Bump Git version requirement to 2.8.4.
+- Updates the font weight of button styles because of the change to system fonts.
+- Update API spec files to describe the correct class. (Livier)
+- Fixed timeago re-rendering every timeago.
+- Enable ColorVariable in scss-lint. (Sam Rose)
+- Various small emoji positioning adjustments.
+- Add shortcuts for adding users to a project team with a specific role. (Nikolay Ponomarev and Dino M)
+- Additional rounded label fixes.
+- Remove unnecessary database indices.
+- 24726 Remove Across GitLab from side navigation.
+- Changed cursor icon to pointer when mousing over stages on the Cycle Analytics pages. (Ryan Harris)
+- Add focus state to dropdown items.
+- Fixes Environments displaying incorrect date since 8.14 upgrade.
+- Improve bulk assignment for issuables.
+- Stop supporting Google and Azure as backup strategies.
+- Fix broken README.md UX guide link.
+- Allow public access to some Tag API endpoints.
+- Encode input when migrating ProcessCommitWorker jobs to prevent migration errors.
+- Adjust the width of project avatars to fix alignment within their container. (Ryan Harris)
+- Sentence cased the nav tab headers on the project dashboard page. (Ryan Harris)
+- Adds hoverstates for collapsed Issue/Merge Request sidebar.
+- Make CI badge hitboxes match parent.
+- Add a starting date to milestones.
+- Adjusted margins for Build Status and Coverage Report rows to match those of the CI/CD Pipeline row. (Ryan Harris)
+- Updated members dropdowns.
+- Move all action buttons to project header.
+- Replace issue access checks with use of IssuableFinder.
+- Fix missing Note access checks by moving Note#search to updated NoteFinder.
+- Centered Accept Merge Request button within MR widget and added padding for viewports smaller than 768px. (Ryan Harris)
+- Fix missing access checks on issue lookup using IssuableFinder.
+- Added top margin to Build status page header for mobile views. (Ryan Harris)
+- Fixes "ActionView::Template::Error: undefined method `text?` for nil:NilClass" on MR pages.
+- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
+- Replace MR access checks with use of MergeRequestsFinder.
+- Fix information disclosure in `Projects::BlobController#update`.
+- Allow branch names with dots on API endpoint.
+- Changed Housekeeping button on project settings page to default styling. (Ryan Harris)
+- Ensure issuable state changes only fire webhooks once.
+- Fix bad selection on dropdown menu for tags filter. (Luis Alonso Chavez Armendariz)
+- Fix title case to sentence case. (Luis Alonso Chavez Armendariz)
+- Fix appearance in error pages. (Luis Alonso Chavez Armendariz)
+- Create mattermost service.
+- 25617 Fix placeholder color of todo filters.
+- Made the padding on the plus button in the breadcrumb menu even. (Ryan Harris)
+- Allow to delete tag release note.
+- Ensure nil User-Agent doesn't break the CI API.
+- Replace Rack::Multipart with GitLab-Workhorse based solution. !5867
+- Add scopes for personal access tokens and OAuth tokens. !5951
+- API: Endpoint to expose personal snippets as /snippets. !6373 (Bernard Guyzmo Pratz)
+- New `gitlab:workhorse:install` rake task. !6574
+- Filter protocol-relative URLs in ExternalLinkFilter. Fixes issue #22742. !6635 (Makoto Scott-Hinkle)
+- Add support for setting the GitLab Runners Registration Token during initial database seeding. !6642
+- Guests can read builds when public. !6842
+- Made comment autocomplete more performant and removed some loading bugs. !6856
+- Add GitLab host to 2FA QR code and manual info. !6941
+- Add sorting functionality for group/project members. !7032
+- Rename Merge When Build Succeeds to Merge When Pipeline Succeeds. !7135
+- Resolve all discussions in a merge request by creating an issue collecting them. !7180 (Bob Van Landuyt)
+- Add Human Readable format for rake backup. !7188 (David Gerő)
+- post_receive: accept any user email from last commit. !7225 (Elan Ruusamäe)
+- Add support for Dockerfile templates. !7247
+- Add shorthand support to gitlab markdown references. !7255 (Oswaldo Ferreira)
+- Display error code for U2F errors. !7305 (winniehell)
+- Fix wrong tab selected when loggin fails and multiple login tabs exists. !7314 (Jacopo Beschi @jacopo-beschi)
+- Clean up common_utils.js. !7318 (winniehell)
+- Show commit status from latest pipeline. !7333
+- Remove the help text under the sidebar subscribe button and style it inline. !7389
+- Update wiki page design. !7429
+- Add nested groups support to the routing. !7459
+- Changed eslint airbnb config to the base airbnb config and corrected eslintrc plugins and envs. !7470 (Luke "Jared" Bennett)
+- Fix cancelling created or external pipelines. !7508
+- Allow admins to stop impersonating users without e-mail addresses. !7550 (Oren Kanner)
+- Remove unnecessary self from user model. !7551 (Semyon Pupkov)
+- Homogenize filter and sort dropdown look'n'feel. !7583 (David Wagner)
+- Create dynamic fixture for build_spec. !7589 (winniehell)
+- Moved Leave Project and Leave Group buttons to access_request_buttons from the settings dropdown. !7600
+- Remove unnecessary require_relative calls from service classes. !7601 (Semyon Pupkov)
+- Simplify copy on "Create a new list" dropdown in Issue Boards. !7605 (Victor Rodrigues)
+- Refactor create service spec. !7609 (Semyon Pupkov)
+- Shows unconfirmed email status in profile. !7611
+- The admin user projects view now has a clickable group link. !7620 (James Gregory)
+- Prevent DOM ID collisions resulting from user-generated content anchors. !7631
+- Replace static fixture for abuse_reports_spec. !7644 (winniehell)
+- Define common helper for describe pagination params in api. !7646 (Semyon Pupkov)
+- Move abuse report spinach test to rspec. !7659 (Semyon Pupkov)
+- Replace static fixture for awards_handler_spec. !7661 (winniehell)
+- API: Add ability to unshare a project from a group. !7662 (Robert Schilling)
+- Replace references to MergeRequestDiff#commits with st_commits when we care only about the number of commits. !7668
+- Add issue events filter and make all really show all events. !7673 (Oxan van Leeuwen)
+- Replace static fixture for notes_spec. !7683 (winniehell)
+- Replace static fixture for shortcuts_issuable_spec. !7685 (winniehell)
+- Replace static fixture for zen_mode_spec. !7686 (winniehell)
+- Replace static fixture for right_sidebar_spec. !7687 (winniehell)
+- Add online terminal support for Kubernetes. !7690
+- Move admin abuse report spinach test to rspec. !7691 (Semyon Pupkov)
+- Move admin spam spinach test to Rspec. !7708 (Semyon Pupkov)
+- Make API::Helpers find a project with only one query. !7714
+- Create builds in transaction to avoid empty pipelines. !7742
+- Render SVG images in diffs and notes. !7747 (andrebsguedes)
+- Add setting to enable/disable HTML emails. !7749
+- Use SmartInterval for MR widget and improve visibilitychange functionality. !7762
+- Resolve "Remove Builds tab from Merge Requests and Commits". !7763
+- Moved new projects button below new group button on the welcome screen. !7770
+- fix display hook error message. !7775 (basyura)
+- Refactor issuable_filters_present to reduce duplications. !7776 (Semyon Pupkov)
+- Redirect to sign-in page when unauthenticated user tries to create a snippet. !7786
+- Fix Archived project merge requests add to group's Merge Requests. !7790 (Jacopo Beschi @jacopo-beschi)
+- Update generic/external build status to match normal build status template. !7811
+- Enable AsciiDoctor admonition icons. !7812 (Horacio Sanson)
+- Do not raise error in AutocompleteController#users when not authorized. !7817 (Semyon Pupkov)
+- fix: 24982- Remove'Signed in successfully' message After this change the sign-in-success flash message will not be shown. !7837 (jnoortheen)
+- Fix Latest deployment link is broken. !7839
+- Don't display prompt to add SSH keys if SSH protocol is disabled. !7840 (Andrew Smith (EspadaV8))
+- Allow unauthenticated access to some Project API GET endpoints. !7843
+- Refactor presenters ChatCommands. !7846
+- Improve help message for issue create slash command. !7850
+- change text around timestamps to make it clear which timestamp is displayed. !7860 (BM5k)
+- Improve Build Log scrolling experience. !7895
+- Change ref property to commitRef in vue commit component. !7901
+- Prevent user creating issue or MR without signing in for a group. !7902
+- Provides a sensible default message when adding a README to a project. !7903
+- Bump ruby version to 2.3.3. !7904
+- Fix comments activity tab visibility condition. !7913 (Rydkin Maxim)
+- Remove unnecessary target branch link from MR page in case of deleted target branch. !7916 (Rydkin Maxim)
+- Add image controls to MR diffs. !7919
+- Remove wrong '.builds-feature' class from the MR settings fieldset. !7930
+- Resolve "Manual actions on pipeline graph". !7931
+- Avoid escaping relative links in Markdown twice. !7940 (winniehell)
+- Move admin hooks spinach to rspec. !7942 (Semyon Pupkov)
+- Move admin logs spinach test to rspec. !7945 (Semyon Pupkov)
+- fix: removed signed_out notification. !7958 (jnoortheen)
+- Accept environment variables from the `pre-receive` script. !7967
+- Do not reload diff for merge request made from fork when target branch in fork is updated. !7973
+- Fixes left align issue for long system notes. !7982
+- Add a slug to environments. !7983
+- Fix lookup of project by unknown ref when caching is enabled. !7988
+- Resolve "Provide SVG as a prop instead of hiding and copy them in environments table". !7992
+- Introduce deployment services, starting with a KubernetesService. !7994
+- Adds tests for custom event polyfill. !7996
+- Allow all alphanumeric characters in file names. !8002 (winniehell)
+- Added support for math rendering, using KaTeX, in Markdown and asciidoc. !8003 (Munken)
+- Remove unnecessary commits order message. !8004
+- API: Memoize the current_user so that sudo can work properly. !8017
+- group authors in contribution graph with case insensitive email handle comparison. !8021
+- Move admin active tab spinach tests to rspec. !8037 (Semyon Pupkov)
+- Add Authentiq as Oauth provider. !8038 (Alexandros Keramidas)
+- API: Ability to cherry pick a commit. !8047 (Robert Schilling)
+- Fix Slack pipeline message from pipelines made by API. !8059
+- API: Simple representation of group's projects. !8060 (Robert Schilling)
+- Prevent overflow with vertical scroll when we have space to show content. !8061
+- Allow to auto-configure Mattermost. !8070
+- Introduce $CI_BUILD_REF_SLUG. !8072
+- Added go back anchor on error pages. !8087
+- Convert CI YAML variables keys into strings. !8088
+- Adds Direct link from pipeline list to builds. !8097
+- Cache last commit id for path. !8098 (Hiroyuki Sato)
+- Pass variables from deployment project services to CI runner. !8107
+- New Gitea importer. !8116
+- Introduce "Set up autodeploy" button to help configure GitLab CI for deployment. !8135
+- Prevent enviroment table to overflow when name has underscores. !8142
+- Fix missing service error importing from EE to CE. !8144
+- Milestoneish SQL performance partially improved and memoized. !8146
+- Allow unauthenticated access to Repositories API GET endpoints. !8148
+- fix colors and margins for adjacent alert banners. !8151
+- Hides new issue button for non loggedin user. !8175
+- Fix N+1 queries on milestone show pages. !8185
+- Rename groups with .git in the end of the path. !8199
+- Whitelist next project names: help, ci, admin, search. !8227
+- Adds back CSS for progress-bars. !8237
+
+## 8.14.10 (2017-02-15)
+
+- No changes.
+
+## 8.14.9 (2017-02-14)
+
+- Patch Asciidocs rendering to block XSS.
+- Fix XSS vulnerability in SVG attachments.
+- 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.14.8 (2017-01-25)
+
+- Accept environment variables from the `pre-receive` script. !7967
+- Milestoneish SQL performance partially improved and memoized. !8146
+- Fix N+1 queries on milestone show pages. !8185
+- 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
+- Updated Turbolinks to mitigate potential XSS attacks.
+
+## 8.14.5 (2016-12-14)
+
+- Moved Leave Project and Leave Group buttons to access_request_buttons from the settings dropdown. !7600
+- fix display hook error message. !7775 (basyura)
+- Remove wrong '.builds-feature' class from the MR settings fieldset. !7930
+- Avoid escaping relative links in Markdown twice. !7940 (winniehell)
+- API: Memoize the current_user so that sudo can work properly. !8017
+- Displays milestone remaining days only when it's present.
+- Allow branch names with dots on API endpoint.
+- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
+- Shows group members in project members list.
+- Encode input when migrating ProcessCommitWorker jobs to prevent migration errors.
+- Fixed timeago re-rendering every timeago.
+- Fix missing Note access checks by moving Note#search to updated NoteFinder.
+
+## 8.14.4 (2016-12-08)
+
+- Fix diff view permalink highlighting. !7090
+- Fix pipeline author for Slack and use pipeline id for pipeline link. !7506
+- Fix compatibility with Internet Explorer 11 for merge requests. !7525 (Steffen Rauh)
+- Reenables /user API request to return private-token if user is admin and request is made with sudo. !7615
+- Fix Cicking on tabs on pipeline page should set URL. !7709
+- Authorize users into imported GitLab project.
+- Destroy a user's session when they delete their own account.
+- Don't accidentally mark unsafe diff lines as HTML safe.
+- Replace MR access checks with use of MergeRequestsFinder.
+- Remove visible content caching.
+
+## 8.14.3 (2016-12-02)
+
+- Pass commit data to ProcessCommitWorker to reduce Git overhead. !7744
+- Speed up issuable dashboards.
+- Don't change relative URLs to absolute URLs in the Help page.
+- Fixes "ActionView::Template::Error: undefined method `text?` for nil:NilClass" on MR pages.
+- Fix branch validation for GitHub PR where repo/fork was renamed/deleted.
+- Validate state param when filtering issuables.
+
+## 8.14.2 (2016-12-01)
+
+- Remove caching of events data. !6578
+- Rephrase some system notes to be compatible with new system note style. !7692
+- Pass tag SHA to post-receive hook when tag is created via UI. !7700
+- Prevent error when submitting a merge request and pipeline is not defined. !7707
+- Fixes system note style in commit discussion. !7721
+- Use a Redis lease for updating authorized projects. !7733
+- Refactor JiraService by moving code out of JiraService#execute method. !7756
+- Update GitLab Workhorse to v1.0.1. !7759
+- Fix pipelines info being hidden in merge request widget. !7808
+- Fixed commit timeago not rendering after initial page.
+- Fix for error thrown in cycle analytics events if build has not started.
+- Fixed issue boards issue sorting when dragging issue into list.
+- Allow access to the wiki with git when repository feature disabled.
+- Fixed timeago not rendering when resolving a discussion.
+- Update Sidekiq-cron to fix compatibility issues with Sidekiq 4.2.1.
+- Timeout creating and viewing merge request for binary file.
+- Gracefully recover from Redis connection failures in Sidekiq initializer.
+
+## 8.14.1 (2016-11-28)
+
+- Fix deselecting calendar days on contribution graph. !6453 (ClemMakesApps)
+- Update grape entity to 0.6.0. !7491
+- If Build running change accept merge request when build succeeds button from orange to blue. !7577
+- Changed import sources buttons to checkboxes. !7598 (Luke "Jared" Bennett)
+- Last minute CI Style tweaks for 8.14. !7643
+- Fix exceptions when loading build trace. !7658
+- Fix wrong template rendered when CI/CD settings aren't update successfully. !7665
+- fixes last_deployment call environment is nil. !7671
+- Sort builds by name within pipeline graph. !7681
+- Correctly determine mergeability of MR with no discussions.
+- Sidekiq stats in the admin area will now show correctly on different platforms. (blackst0ne)
+- Fixed issue boards dragging card removing random issues.
+- Fix information disclosure in `Projects::BlobController#update`.
+- Fix missing access checks on issue lookup using IssuableFinder.
+- Replace issue access checks with use of IssuableFinder.
+- Non members cannot create labels through the API.
+- Fix cycle analytics plan stage when commits are missing.
+
+## 8.14.0 (2016-11-22)
+
+- Use separate email-token for incoming email and revert back the inactive feature. !5914
+- API: allow recursive tree request. !6088 (Rebeca Mendez)
+- Replace jQuery.timeago with timeago.js. !6274 (ClemMakesApps)
+- Add CI notifications. Who triggered a pipeline would receive an email after the pipeline is succeeded or failed. Users could also update notification settings accordingly. !6342
+- Add button to delete all merged branches. !6449 (Toon Claes)
+- Finer-grained Git gargage collection. !6588
+- Introduce better credential and error checking to `rake gitlab:ldap:check`. !6601
+- Centralize LDAP config/filter logic. !6606
+- Make system notes less intrusive. !6755
+- Process commits using a dedicated Sidekiq worker. !6802
+- Show random messages when the To Do list is empty. !6818 (Josep Llaneras)
+- Precalculate user's authorized projects in database. !6839
+- Fix record not found error on NewNoteWorker processing. !6863 (Oswaldo Ferreira)
+- Show avatars in mention dropdown. !6865
+- Fix expanding a collapsed diff when converting a symlink to a regular file. !6953
+- Defer saving project services to the database if there are no user changes. !6958
+- Omniauth auto link LDAP user falls back to find by DN when user cannot be found by UID. !7002
+- Display "folders" for environments. !7015
+- Make it possible to trigger builds from webhooks. !7022 (Dmitry Poray)
+- Fix showing pipeline status for a given commit from correct branch. !7034
+- Add link to build pipeline within individual build pages. !7082
+- Add api endpoint `/groups/owned`. !7103 (Borja Aparicio)
+- Add query param to filter users by external & blocked type. !7109 (Yatish Mehta)
+- Issues atom feed url reflect filters on dashboard. !7114 (Lucas Deschamps)
+- Add setting to only allow merge requests to be merged when all discussions are resolved. !7125 (Rodolfo Arruda)
+- Remove an extra leading space from diff paste data. !7133 (Hiroyuki Sato)
+- Fix trace patching feature - update the updated_at value. !7146
+- Fix 404 on network page when entering non-existent git revision. !7172 (Hiroyuki Sato)
+- Rewrite git blame spinach feature tests to rspec feature tests. !7197 (Lisanne Fellinger)
+- Add api endpoint for creating a pipeline. !7209 (Ido Leibovich)
+- Allow users to subscribe to group labels. !7215
+- Reduce API calls needed when importing issues and pull requests from GitHub. !7241 (Andrew Smith (EspadaV8))
+- Only skip group when it's actually a group in the "Share with group" select. !7262
+- Introduce round-robin project creation to spread load over multiple shards. !7266
+- Ensure merge request's "remove branch" accessors return booleans. !7267
+- Fix no "Register" tab if ldap auth is enabled (#24038). !7274 (Luc Didry)
+- Expose label IDs in API. !7275 (Rares Sfirlogea)
+- Fix invalid filename validation on eslint. !7281
+- API: Ability to retrieve version information. !7286 (Robert Schilling)
+- Added ability to throttle Sidekiq Jobs. !7292
+- Set default Sidekiq retries to 3. !7294
+- Fix double event and ajax request call on MR page. !7298 (YarNayar)
+- Unify anchor link format for MR diff files. !7298 (YarNayar)
+- Require projects before creating milestone. !7301 (gfyoung)
+- Fix error when using invalid branch name when creating a new pipeline. !7324
+- Return 400 when creating a system hook fails. !7350 (Robert Schilling)
+- Auto-close environment when branch is deleted. !7355
+- Rework cache invalidation so only changed data is refreshed. !7360
+- Navigation bar issuables counters reflects dashboard issuables counters. !7368 (Lucas Deschamps)
+- Fix cache for commit status in commits list to respect branches. !7372
+- fixes 500 error on project show when user is not logged in and project is still empty. !7376
+- Removed gray button styling from todo buttons in sidebars. !7387
+- Fix project records with invalid visibility_level values. !7391
+- Use 'Forking in progress' title when appropriate. !7394 (Philip Karpiak)
+- Fix error links in help index page. !7396 (Fu Xu)
+- Add support for reply-by-email when the email only contains HTML. !7397
+- [Fix] Extra divider issue in dropdown. !7398
+- Project download buttons always show. !7405 (Philip Karpiak)
+- Give search-input correct padding-right value. !7407 (Philip Karpiak)
+- Remove additional padding on right-aligned items in MR widget. !7411 (Didem Acet)
+- Fix issue causing Labels not to appear in sidebar on MR page. !7416 (Alex Sanford)
+- Allow mail_room idle_timeout option to be configurable. !7423
+- Fix misaligned buttons on admin builds page. !7424 (Didem Acet)
+- Disable "Request Access" functionality by default for new projects and groups. !7425
+- fix shibboleth misconfigurations resulting in authentication bypass. !7428
+- Added Mattermost slash command. !7438
+- Allow to connect Chat account with GitLab. !7450
+- Make New Group form respect default visibility application setting. !7454 (Jacopo Beschi @jacopo-beschi)
+- Fix Error 500 when creating a merge request that contains an image that was deleted and added. !7457
+- Fix labels API by adding missing current_user parameter. !7458 (Francesco Coda Zabetta)
+- Changed restricted visibility admin buttons to checkboxes. !7463
+- Send credentials (currently for registry only) with build data to GitLab Runner. !7474
+- Fix POST /internal/allowed to cope with gitlab-shell v4.0.0 project paths. !7480
+- Adds es6-promise Polyfill. !7482
+- Added colored labels to related MR list. !7486 (Didem Acet)
+- Use setter for key instead AR callback. !7488 (Semyon Pupkov)
+- Limit labels returned for a specific project as an administrator. !7496
+- Change slack notification comment link. !7498 (Herbert Kagumba)
+- Allow registering users whose username contains dots. !7500 (Timothy Andrew)
+- Fix race condition during group deletion and remove stale records present due to this bug. !7528 (Timothy Andrew)
+- Check all namespaces on validation of new username. !7537
+- Pass correct tag target to post-receive hook when creating tag via UI. !7556
+- Add help message for configuring Mattermost slash commands. !7558
+- Fix typo in Build page JavaScript. !7563 (winniehell)
+- Make job script a required configuration entry. !7566
+- Fix errors happening when source branch of merge request is removed and then restored. !7568
+- Fix a wrong "The build for this merge request failed" message. !7579
+- Fix Margins look weird in Project page with pinned sidebar in project stats bar. !7580
+- Fix regression causing bad error message to appear on Merge Request form. !7599 (Alex Sanford)
+- Fix activity page endless scroll on large viewports. !7608
+- Fix 404 on some group pages when name contains dot. !7614
+- Do not create a new TODO when failed build is allowed to fail. !7618
+- Add deployment command to ChatOps. !7619
+- Fix 500 error when group name ends with git. !7630
+- Fix undefined error in CI linter. !7650
+- Show events per stage on Cycle Analytics page. !23449
+- Add JIRA remotelinks and prevent duplicated closing messages.
+- Fixed issue boards counter border when unauthorized.
+- Add placeholder for the example text for custom hex color on label creation popup. (Luis Alonso Chavez Armendariz)
+- Add an index for project_id in project_import_data to improve performance.
+- Fix broken commits search.
+- Assignee dropdown now searches author of issue or merge request.
+- Clicking "force remove source branch" label now toggles the checkbox again.
+- More aggressively preload on merge request and issue index pages.
+- Fix broken link to observatory cli on Frontend Dev Guide. (Sam Rose)
+- Fixing the issue of the project fork url giving 500 when not signed instead of being redirected to sign in page. (Cagdas Gerede)
+- Fix: Guest sees some repository details and gets 404.
+- Add logging for rack attack events to production.log.
+- Add environment info to builds page.
+- Allow commit note to be visible if repo is visible.
+- Bump omniauth-gitlab to 1.0.2 to fix incompatibility with omniauth-oauth2.
+- Redesign pipelines page.
+- Faster search inside Project.
+- Search for a filename in a project.
+- Allow sorting groups in the API.
+- Fix: Todos Filter Shows All Users.
+- Use the Gitlab Workhorse HTTP header in the admin dashboard. (Chris Wright)
+- Fixed multiple requests sent when opening dropdowns.
+- Added permissions per stage to cycle analytics endpoint.
+- Fix project Visibility Level selector not using default values.
+- Add events per stage to cycle analytics.
+- Allow to test JIRA service settings without having a repository.
+- Fix JIRA references for project snippets.
+- Allow enabling and disabling commit and MR events for JIRA.
+- simplify url generation. (Jarka Kadlecova)
+- Show correct environment log in admin/logs (@duk3luk3 !7191)
+- Fix Milestone dropdown not stay selected for `Upcoming` and `No Milestone` option !7117
+- Diff collapse won't shift when collapsing.
+- Backups do not fail anymore when using tar on annex and custom_hooks only. !5814
+- Adds user project membership expired event to clarify why user was removed (Callum Dryden)
+- Trim leading and trailing whitespace on project_path (Linus Thiel)
+- Prevent award emoji via notes for issues/MRs authored by user (barthc)
+- Adds support for the `token` attribute in project hooks API (Gauvain Pocentek)
+- Change auto selection behaviour of emoji and slash commands to be more UX/Type friendly (Yann Gravrand)
+- Adds an optional path parameter to the Commits API to filter commits by path (Luis HGO)
+- Fix Markdown styling inside reference links (Jan Zdráhal)
+- Create new issue board list after creating a new label
+- Fix extra space on Build sidebar on Firefox !7060
+- Fail gracefully when creating merge request with non-existing branch (alexsanford)
+- Fix mobile layout issues in admin user overview page !7087
+- Fix HipChat notifications rendering (airatshigapov, eisnerd)
+- Removed unneeded "Builds" and "Environments" link from project titles
+- Remove 'Edit' button from wiki edit view !7143 (Hiroyuki Sato)
+- Cleaned up global namespace JS !19661 (Jose Ivan Vargas)
+- Refactor Jira service to use jira-ruby gem
+- Improved todos empty state
+- Add hover to trash icon in notes !7008 (blackst0ne)
+- Hides project activity tabs when features are disabled
+- Only show one error message for an invalid email !5905 (lycoperdon)
+- Added guide describing how to upgrade PostgreSQL using Slony
+- Fix sidekiq stats in admin area (blackst0ne)
+- Added label description as tooltip to issue board list title
+- Created cycle analytics bundle JavaScript file
+- Make the milestone page more responsive (yury-n)
+- Hides container registry when repository is disabled
+- API: Fix booleans not recognized as such when using the `to_boolean` helper
+- Removed delete branch tooltip !6954
+- Stop unauthorized users dragging on milestone page (blackst0ne)
+- Restore issue boards welcome message when a project is created !6899
+- Check that JavaScript file names match convention !7238 (winniehell)
+- Do not show tooltip for active element !7105 (winniehell)
+- Escape ref and path for relative links !6050 (winniehell)
+- Fixed link typo on /help/ui to Alerts section. !6915 (Sam Rose)
+- Fix broken issue/merge request links in JIRA comments. !6143 (Brian Kintz)
+- Fix filtering of milestones with quotes in title (airatshigapov)
+- Fix issue boards dragging bug in Safari
+- Refactor less readable existance checking code from CoffeeScript !6289 (jlogandavison)
+- Update mail_room and enable sentinel support to Reply By Email (!7101)
+- Add task completion status in Issues and Merge Requests tabs: "X of Y tasks completed" (!6527, @gmesalazar)
+- Simpler arguments passed to named_route on toggle_award_url helper method
+- Fix typo in framework css class. !7086 (Daniel Voogsgerd)
+- New issue board list dropdown stays open after adding a new list
+- Fix: Backup restore doesn't clear cache
+- Optimize Event queries by removing default order
+- Add new icon for skipped builds
+- Show created icon in pipeline mini-graph
+- Remove duplicate links from sidebar
+- API: Fix project deploy keys 400 and 500 errors when adding an existing key. !6784 (Joshua Welsh)
+- Add Rake task to create/repair GitLab Shell hooks symlinks !5634
+- Add job for removal of unreferenced LFS objects from both the database and the filesystem (Frank Groeneveld)
+- Replace jquery.cookie plugin with js.cookie !7085
+- Use MergeRequestsClosingIssues cache data on Issue#closed_by_merge_requests method
+- Fix Sign in page 'Forgot your password?' link overlaps on medium-large screens
+- Show full status link on MR & commit pipelines
+- Fix documents and comments on Build API `scope`
+- Initialize Sidekiq with the list of queues used by GitLab
+- Refactor email, use setter method instead AR callbacks for email attribute (Semyon Pupkov)
+- Shortened merge request modal to let clipboard button not overlap
+- Adds JavaScript validation for group path editing field
+- In all filterable drop downs, put input field in focus only after load is complete (Ido @leibo)
+- Improve search query parameter naming in /admin/users !7115 (YarNayar)
+- Fix table pagination to be responsive
+- Fix applying GitHub-imported labels when importing job is interrupted
+- Allow to search for user by secondary email address in the admin interface(/admin/users) !7115 (YarNayar)
+- Updated commit SHA styling on the branches page.
+- 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
+- Updated Turbolinks to mitigate potential XSS attacks.
+
+## 8.13.10 (2016-12-14)
+
+- API: Memoize the current_user so that sudo can work properly. !8017
+- Filter `authentication_token`, `incoming_email_token` and `runners_token` parameters.
+- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
+- Fix missing Note access checks by moving Note#search to updated NoteFinder.
+
+## 8.13.9 (2016-12-08)
+
+- Reenables /user API request to return private-token if user is admin and request is made with sudo. !7615
+- Replace MR access checks with use of MergeRequestsFinder.
+
+## 8.13.8 (2016-12-02)
+
+- Pass tag SHA to post-receive hook when tag is created via UI. !7700
+- Validate state param when filtering issuables.
+
+## 8.13.7 (2016-11-28)
+
+- fixes 500 error on project show when user is not logged in and project is still empty. !7376
+- Update grape entity to 0.6.0. !7491
+- Fix information disclosure in `Projects::BlobController#update`.
+- Fix missing access checks on issue lookup using IssuableFinder.
+- Replace issue access checks with use of IssuableFinder.
+- Non members cannot create labels through the API.
+
+## 8.13.6 (2016-11-17)
+
+- Omniauth auto link LDAP user falls back to find by DN when user cannot be found by UID. !7002
+- Fix Milestone dropdown not stay selected for `Upcoming` and `No Milestone` option. !7117
+- Fix relative links in Markdown wiki when displayed in "Project" tab. !7218
+- Fix no "Register" tab if ldap auth is enabled (#24038). !7274 (Luc Didry)
+- Fix cache for commit status in commits list to respect branches. !7372
+- Fix issue causing Labels not to appear in sidebar on MR page. !7416 (Alex Sanford)
+- Limit labels returned for a specific project as an administrator. !7496
+- Clicking "force remove source branch" label now toggles the checkbox again.
+- Allow commit note to be visible if repo is visible.
+- Fix project Visibility Level selector not using default values.
+
+## 8.13.5 (2016-11-08)
+
+- Restore unauthenticated access to public container registries
+- Fix showing pipeline status for a given commit from correct branch. !7034
+- Only skip group when it's actually a group in the "Share with group" select. !7262
+- Introduce round-robin project creation to spread load over multiple shards. !7266
+- Ensure merge request's "remove branch" accessors return booleans. !7267
+- Ensure external users are not able to clone disabled repositories.
+- Fix XSS issue in Markdown autolinker.
+- Respect event visibility in Gitlab::ContributionsCalendar.
+- Honour issue and merge request visibility in their respective finders.
+- Disable reference Markdown for unavailable features.
+- Fix lightweight tags not processed correctly by GitTagPushService. !6532
+- Allow owners to fetch source code in CI builds. !6943
+- Return conflict error in label API when title is taken by group label. !7014
+- Reduce the overhead to calculate number of open/closed issues and merge requests within the group or project. !7123
+- Fix builds tab visibility. !7178
+- Fix project features default values. !7181
+
+## 8.13.4
+
+- Pulled due to packaging error.
+
+## 8.13.3 (2016-11-02)
+
+- Removes any symlinks before importing a project export file. CVE-2016-9086
+- Fixed Import/Export foreign key issue to do with project members.
+- Changed build dropdown list length to be 6,5 builds long in the pipeline graph
+
+## 8.13.2 (2016-10-31)
+
+- Fix encoding issues on pipeline commits. !6832
+- Use Hash rocket syntax to fix cycle analytics under Ruby 2.1. !6977
+- Modify GitHub importer to be retryable. !7003
+- Fix refs dropdown selection with special characters. !7061
+- Fix horizontal padding for highlight blocks. !7062
+- Pass user instance to `Labels::FindOrCreateService` or `skip_authorization: true`. !7093
+- Fix builds dropdown overlapping bug. !7124
+- Fix applying labels for GitHub-imported MRs. !7139
+- Fix importing MR comments from GitHub. !7139
+- Fix project member access for group links. !7144
+- API: Fix booleans not recognized as such when using the `to_boolean` helper. !7149
+- Fix and improve `Sortable.highest_label_priority`. !7165
+- Fixed sticky merge request tabs when sidebar is pinned. !7167
+- Only remove right connector of first build of last stage. !7179
+
+## 8.13.1 (2016-10-25)
+
+- Fix branch protection API. !6215
+- Fix hidden pipeline graph on commit and MR page. !6895
+- Fix Cycle analytics not showing correct data when filtering by date. !6906
+- Ensure custom provider tab labels don't break layout. !6993
+- Fix issue boards user link when in subdirectory. !7018
+- Refactor and add new environment functionality to CI yaml reference. !7026
+- Fix typo in project settings that prevents users from enabling container registry. !7037
+- Fix events order in `users/:id/events` endpoint. !7039
+- Remove extra line for empty issue description. !7045
+- Don't append issue/MR templates to any existing text. !7050
+- Fix error in generating labels. !7055
+- Stop clearing the database cache on `rake cache:clear`. !7056
+- Only show register tab if signup enabled. !7058
+- Fix lightweight tags not processed correctly by GitTagPushService
+- Expire and build repository cache after project import. !7064
+- Fix bug where labels would be assigned to issues that were moved. !7065
+- Fix reply-by-email not working due to queue name mismatch. !7068
+- Fix 404 for group pages when GitLab setup uses relative url. !7071
+- Fix `User#to_reference`. !7088
+- Reduce overhead of `LabelFinder` by avoiding `#presence` call. !7094
+- Fix unauthorized users dragging on issue boards. !7096
+- Only schedule `ProjectCacheWorker` jobs when needed. !7099
+
+## 8.13.0 (2016-10-22)
+
+- Fix save button on project pipeline settings page. (!6955)
+- All Sidekiq workers now use their own queue
+- Avoid race condition when asynchronously removing expired artifacts. (!6881)
+- Improve Merge When Build Succeeds triggers and execute on pipeline success. (!6675)
+- Respond with 404 Not Found for non-existent tags (Linus Thiel)
+- Truncate long labels with ellipsis in labels page
+- Improve tabbing usability for sign in page (ClemMakesApps)
+- Enforce TrailingSemicolon and EmptyLineBetweenBlocks in scss-lint
+- Adding members no longer silently fails when there is extra whitespace
+- Update runner version only when updating contacted_at
+- Add link from system note to compare with previous version
+- Use gitlab-shell v3.6.6
+- Ignore references to internal issues when using external issues tracker
+- Ability to resolve merge request conflicts with editor !6374
+- Add `/projects/visible` API endpoint (Ben Boeckel)
+- Fix centering of custom header logos (Ashley Dumaine)
+- Keep around commits only pipeline creation as pipeline data doesn't change over time
+- Update duration at the end of pipeline
+- ExpireBuildArtifactsWorker query builds table without ordering enqueuing one job per build to cleanup
+- Add group level labels. (!6425)
+- Add an example for testing a phoenix application with Gitlab CI in the docs (Manthan Mallikarjun)
+- Cancelled pipelines could be retried. !6927
+- Updating verbiage on git basics to be more intuitive
+- Fix project_feature record not generated on project creation
+- Clarify documentation for Runners API (Gennady Trafimenkov)
+- Use optimistic locking for pipelines and builds
+- The instrumentation for Banzai::Renderer has been restored
+- Change user & group landing page routing from /u/:username to /:username
+- Added documentation for .gitattributes files
+- Move Pipeline Metrics to separate worker
+- AbstractReferenceFilter caches project_refs on RequestStore when active
+- Replaced the check sign to arrow in the show build view. !6501
+- Add a /wip slash command to toggle the Work In Progress status of a merge request. !6259 (tbalthazar)
+- ProjectCacheWorker updates caches at most once per 15 minutes per project
+- Fix Error 500 when viewing old merge requests with bad diff data
+- Create a new /templates namespace for the /licenses, /gitignores and /gitlab_ci_ymls API endpoints. !5717 (tbalthazar)
+- Fix viewing merged MRs when the source project has been removed !6991
+- Speed-up group milestones show page
+- Fix inconsistent options dropdown caret on mobile viewports (ClemMakesApps)
+- Extract project#update_merge_requests and SystemHooks to its own worker from GitPushService
+- Fix discussion thread from emails for merge requests. !7010
+- Don't include archived projects when creating group milestones. !4940 (Jeroen Jacobs)
+- Add tag shortcut from the Commit page. !6543
+- Keep refs for each deployment
+- Close open tooltips on page navigation (Linus Thiel)
+- Allow browsing branches that end with '.atom'
+- Log LDAP lookup errors and don't swallow unrelated exceptions. !6103 (Markus Koller)
+- Replace unique keyframes mixin with keyframe mixin with specific names (ClemMakesApps)
+- Add more tests for calendar contribution (ClemMakesApps)
+- Update Gitlab Shell to fix some problems with moving projects between storages
+- Cache rendered markdown in the database, rather than Redis
+- Add todo toggle event (ClemMakesApps)
+- Avoid database queries on Banzai::ReferenceParser::BaseParser for nodes without references
+- Simplify Mentionable concern instance methods
+- API: Ability to retrieve version information (Robert Schilling)
+- Fix permission for setting an issue's due date
+- API: Multi-file commit !6096 (mahcsig)
+- Unicode emoji are now converted to images
+- Revert "Label list shows all issues (opened or closed) with that label"
+- Expose expires_at field when sharing project on API
+- Fix VueJS template tags being rendered in code comments
+- Added copy file path button to merge request diff files
+- Fix issue with page scrolling to top when closing or pinning sidebar (lukehowell)
+- Add Issue Board API support (andrebsguedes)
+- Allow the Koding integration to be configured through the API
+- Add new issue button to each list on Issues Board
+- Execute specific named route method from toggle_award_url helper method
+- Added soft wrap button to repository file/blob editor
+- Update namespace validation to forbid reserved names (.git and .atom) (Will Starms)
+- Show the time ago a merge request was deployed to an environment
+- Add RTL support to markdown renderer (Ebrahim Byagowi)
+- Add word-wrap to issue title on issue and milestone boards (ClemMakesApps)
+- Fix todos page mobile viewport layout (ClemMakesApps)
+- Make issues search less finicky
+- Fix inconsistent highlighting of already selected activity nav-links (ClemMakesApps)
+- Remove redundant mixins (ClemMakesApps)
+- Added 'Download' button to the Snippets page (Justin DiPierro)
+- Add visibility level to project repository
+- Fix robots.txt disallowing access to groups starting with "s" (Matt Harrison)
+- Close open merge request without source project (Katarzyna Kobierska Ula Budziszewska)
+- Fix showing commits from source project for merge request !6658
+- Fix that manual jobs would no longer block jobs in the next stage. !6604
+- Add configurable email subject suffix (Fu Xu)
+- Use defined colour for a language when available !6748 (nilsding)
+- Added tooltip to fork count on project show page. (Justin DiPierro)
+- Use a ConnectionPool for Rails.cache on Sidekiq servers
+- Replace `alias_method_chain` with `Module#prepend`
+- Enable GitLab Import/Export for non-admin users.
+- Preserve label filters when sorting !6136 (Joseph Frazier)
+- MergeRequest#new form load diff asynchronously
+- Only update issuable labels if they have been changed
+- Take filters in account in issuable counters. !6496
+- Use custom Ruby images to test builds (registry.dev.gitlab.org/gitlab/gitlab-build-images:*)
+- Replace static issue fixtures by script !6059 (winniehell)
+- Append issue template to existing description !6149 (Joseph Frazier)
+- Trending projects now only show public projects and the list of projects is cached for a day
+- Memoize Gitlab Shell's secret token (!6599, Justin DiPierro)
+- Revoke button in Applications Settings underlines on hover.
+- Use higher size on Gitlab::Redis connection pool on Sidekiq servers
+- Add missing values to linter !6276 (Katarzyna Kobierska Ula Budziszewska)
+- Revert avoid touching file system on Build#artifacts?
+- Stop using a Redis lease when updating the project activity timestamp whenever a new event is created
+- Add disabled delete button to protected branches (ClemMakesApps)
+- Add broadcast messages and alerts below sub-nav
+- Better empty state for Groups view
+- API: New /users/:id/events endpoint
+- Update ruby-prof to 0.16.2. !6026 (Elan Ruusamäe)
+- Replace bootstrap caret with fontawesome caret (ClemMakesApps)
+- Fix unnecessary escaping of reserved HTML characters in milestone title. !6533
+- Add organization field to user profile
+- Change user pages routing from /u/:username/PATH to /users/:username/PATH. Old routes will redirect to the new ones for the time being.
+- Fix enter key when navigating search site search dropdown. !6643 (Brennan Roberts)
+- Fix deploy status responsiveness error !6633
+- Make searching for commits case insensitive
+- Fix resolved discussion display in side-by-side diff view !6575
+- Optimize GitHub importing for speed and memory
+- API: expose pipeline data in builds API (!6502, Guilherme Salazar)
+- Notify the Merger about merge after successful build (Dimitris Karakasilis)
+- Reduce queries needed to find users using their SSH keys when pushing commits
+- Prevent rendering the link to all when the author has no access (Katarzyna Kobierska Ula Budziszewska)
+- Fix broken repository 500 errors in project list
+- Fix the diff in the merge request view when converting a symlink to a regular file
+- Fix Pipeline list commit column width should be adjusted
+- Close todos when accepting merge requests via the API !6486 (tonygambone)
+- Ability to batch assign issues relating to a merge request to the author. !5725 (jamedjo)
+- Changed Slack service user referencing from full name to username (Sebastian Poxhofer)
+- Retouch environments list and deployments list
+- Add multiple command support for all label related slash commands !6780 (barthc)
+- Add Container Registry on/off status to Admin Area !6638 (the-undefined)
+- Add Nofollow for uppercased scheme in external urls !6820 (the-undefined)
+- Allow empty merge requests !6384 (Artem Sidorenko)
+- Grouped pipeline dropdown is a scrollable container
+- Cleanup Ci::ApplicationController. !6757 (Takuya Noguchi)
+- Fixes padding in all clipboard icons that have .btn class
+- Fix a typo in doc/api/labels.md
+- Fix double-escaping in activities tab (Alexandre Maia)
+- API: all unknown routing will be handled with 404 Not Found
+- Add docs for request profiling
+- Delete dynamic environments
+- Fix buggy iOS tooltip layering behavior.
+- Make guests unable to view MRs on private projects
+- Fix broken Project API docs (Takuya Noguchi)
+- Migrate invalid project members (owner -> master)
+
+## 8.12.12 (2016-12-08)
+
+- Replace MR access checks with use of MergeRequestsFinder
+- Reenables /user API request to return private-token if user is admin and request is made with sudo
+
+## 8.12.11 (2016-12-02)
+
+- No changes
+
+## 8.12.10 (2016-11-28)
+
+- Fix information disclosure in `Projects::BlobController#update`
+- Fix missing access checks on issue lookup using IssuableFinder
+- Replace issue access checks with use of IssuableFinder
+
+## 8.12.9 (2016-11-07)
+
+- Fix XSS issue in Markdown autolinker
+
+## 8.12.8 (2016-11-02)
+
+- Removes any symlinks before importing a project export file. CVE-2016-9086
+- Fixed Import/Export foreign key issue to do with project members.
+
+## 8.12.7
+
+ - Prevent running `GfmAutocomplete` setup for each diff note. !6569
+ - Fix long commit messages overflow viewport in file tree. !6573
+ - Use `gitlab-markup` gem instead of `github-markup` to fix `.rst` file rendering. !6659
+ - Prevent flash alert text from being obscured when container is fluid. !6694
+ - Fix due date being displayed as `NaN` in Safari. !6797
+ - Fix JS bug with select2 because of missing `data-field` attribute in select box. !6812
+ - Do not alter `force_remove_source_branch` options on MergeRequest unless specified. !6817
+ - Fix GFM autocomplete setup being called several times. !6840
+ - Handle case where deployment ref no longer exists. !6855
+
+## 8.12.6
+
+ - Update mailroom to 0.8.1 in Gemfile.lock !6814
+
+## 8.12.5
+
+ - Switch from request to env in ::API::Helpers. !6615
+ - Update the mail_room gem to 0.8.1 to fix a race condition with the mailbox watching thread. !6714
+ - Improve issue load time performance by avoiding ORDER BY in find_by call. !6724
+ - Add a new gitlab:users:clear_all_authentication_tokens task. !6745
+ - Don't send Private-Token (API authentication) headers to Sentry
+ - Share projects via the API only with groups the authenticated user can access
+
+## 8.12.4
+
+ - Fix "Copy to clipboard" tooltip to say "Copied!" when clipboard button is clicked. !6294 (lukehowell)
+ - Fix padding in build sidebar. !6506
+ - Changed compare dropdowns to dropdowns with isolated search input. !6550
+ - Fix race condition on LFS Token. !6592
+ - Fix type mismatch bug when closing Jira issue. !6619
+ - Fix lint-doc error. !6623
+ - Skip wiki creation when GitHub project has wiki enabled. !6665
+ - Fix issues importing services via Import/Export. !6667
+ - Restrict failed login attempts for users with 2FA enabled. !6668
+ - Fix failed project deletion when feature visibility set to private. !6688
+ - Prevent claiming associated model IDs via import.
+ - Set GitLab project exported file permissions to owner only
+ - Improve the way merge request versions are compared with each other
+
+## 8.12.3
+
+ - Update Gitlab Shell to support low IO priority for storage moves
+
+## 8.12.2
+
+ - Fix Import/Export not recognising correctly the imported services.
+ - Fix snippets pagination
+ - Fix "Create project" button layout when visibility options are restricted
+ - Fix List-Unsubscribe header in emails
+ - Fix IssuesController#show degradation including project on loaded notes
+ - Fix an issue with the "Commits" section of the cycle analytics summary. !6513
+ - Fix errors importing project feature and milestone models using GitLab project import
+ - Make JWT messages Docker-compatible
+ - Fix duplicate branch entry in the merge request version compare dropdown
+ - Respect the fork_project permission when forking projects
+ - Only update issuable labels if they have been changed
+ - Fix bug where 'Search results' repeated many times when a search in the emoji search form is cleared (Xavier Bick) (@zeiv)
+ - Fix resolve discussion buttons endpoint path
+ - Refactor remnants of CoffeeScript destructured opts and super !6261
+
+## 8.12.1
+
+ - Fix a memory leak in HTML::Pipeline::SanitizationFilter::WHITELIST
+ - Fix issue with search filter labels not displaying
+
+## 8.12.0 (2016-09-22)
+
+ - Removes inconsistency regarding tagging immediatelly as merged once you create a new branch. !6408
+ - Update the rouge gem to 2.0.6, which adds highlighting support for JSX, Prometheus, and others. !6251
+ - Only check :can_resolve permission if the note is resolvable
+ - Bump fog-aws to v0.11.0 to support ap-south-1 region
+ - Add ability to fork to a specific namespace using API. (ritave)
+ - Allow to set request_access_enabled for groups and projects
+ - Cleanup misalignments in Issue list view !6206
+ - Only create a protected branch upon a push to a new branch if a rule for that branch doesn't exist
+ - Add Pipelines for Commit
+ - Prune events older than 12 months. (ritave)
+ - Prepend blank line to `Closes` message on merge request linked to issue (lukehowell)
+ - Fix issues/merge-request templates dropdown for forked projects
+ - Filter tags by name !6121
+ - Update gitlab shell secret file also when it is empty. !3774 (glensc)
+ - Give project selection dropdowns responsive width, make non-wrapping.
+ - Fix note form hint showing slash commands supported for commits.
+ - Make push events have equal vertical spacing.
+ - API: Ensure invitees are not returned in Members API.
+ - Preserve applied filters on issues search.
+ - Add two-factor recovery endpoint to internal API !5510
+ - Pass the "Remember me" value to the U2F authentication form
+ - Display stages in valid order in stages dropdown on build page
+ - Only update projects.last_activity_at once per hour when creating a new event
+ - Cycle analytics (first iteration) !5986
+ - Remove vendor prefixes for linear-gradient CSS (ClemMakesApps)
+ - Move pushes_since_gc from the database to Redis
+ - Limit number of shown environments on Merge Request: show only environments for target_branch, source_branch and tags
+ - Add font color contrast to external label in admin area (ClemMakesApps)
+ - Fix find file navigation links (ClemMakesApps)
+ - Change logo animation to CSS (ClemMakesApps)
+ - Instructions for enabling Git packfile bitmaps !6104
+ - Use Search::GlobalService.new in the `GET /projects/search/:query` endpoint
+ - Fix long comments in diffs messing with table width
+ - Add spec covering 'Gitlab::Git::committer_hash' !6433 (dandunckelman)
+ - Fix pagination on user snippets page
+ - Honor "fixed layout" preference in more places !6422
+ - Run CI builds with the permissions of users !5735
+ - Fix sorting of issues in API
+ - Fix download artifacts button links !6407
+ - Sort project variables by key. !6275 (Diego Souza)
+ - Ensure specs on sorting of issues in API are deterministic on MySQL
+ - Added ability to use predefined CI variables for environment name
+ - Added ability to specify URL in environment configuration in gitlab-ci.yml
+ - Escape search term before passing it to Regexp.new !6241 (winniehell)
+ - Fix pinned sidebar behavior in smaller viewports !6169
+ - Fix file permissions change when updating a file on the Gitlab UI !5979
+ - Added horizontal padding on build page sidebar on code coverage block. !6196 (Vitaly Baev)
+ - Change merge_error column from string to text type
+ - Fix issue with search filter labels not displaying
+ - Reduce contributions calendar data payload (ClemMakesApps)
+ - Show all pipelines for merge requests even from discarded commits !6414
+ - Replace contributions calendar timezone payload with dates (ClemMakesApps)
+ - Changed MR widget build status to pipeline status !6335
+ - Add `web_url` field to issue, merge request, and snippet API objects (Ben Boeckel)
+ - Enable pipeline events by default !6278
+ - Add pipeline email service !6019
+ - Move parsing of sidekiq ps into helper !6245 (pascalbetz)
+ - Added go to issue boards keyboard shortcut
+ - Expose `sha` and `merge_commit_sha` in merge request API (Ben Boeckel)
+ - Emoji can be awarded on Snippets !4456
+ - Set path for all JavaScript cookies to honor GitLab's subdirectory setting !5627 (Mike Greiling)
+ - Fix blame table layout width
+ - Spec testing if issue authors can read issues on private projects
+ - Fix bug where pagination is still displayed despite all todos marked as done (ClemMakesApps)
+ - Request only the LDAP attributes we need !6187
+ - Center build stage columns in pipeline overview (ClemMakesApps)
+ - Fix bug with tooltip not hiding on discussion toggle button
+ - Rename behaviour to behavior in bug issue template for consistency (ClemMakesApps)
+ - Fix bug stopping issue description being scrollable after selecting issue template
+ - Remove suggested colors hover underline (ClemMakesApps)
+ - Fix jump to discussion button being displayed on commit notes
+ - Shorten task status phrase (ClemMakesApps)
+ - Fix project visibility level fields on settings
+ - Add hover color to emoji icon (ClemMakesApps)
+ - Increase ci_builds artifacts_size column to 8-byte integer to allow larger files
+ - Add textarea autoresize after comment (ClemMakesApps)
+ - Do not write SSH public key 'comments' to authorized_keys !6381
+ - Add due date to issue todos
+ - Refresh todos count cache when an Issue/MR is deleted
+ - Fix branches page dropdown sort alignment (ClemMakesApps)
+ - Hides merge request button on branches page is user doesn't have permissions
+ - Add white background for no readme container (ClemMakesApps)
+ - API: Expose issue confidentiality flag. (Robert Schilling)
+ - Fix markdown anchor icon interaction (ClemMakesApps)
+ - Test migration paths from 8.5 until current release !4874
+ - Replace animateEmoji timeout with eventListener (ClemMakesApps)
+ - Show badges in Milestone tabs. !5946 (Dan Rowden)
+ - Optimistic locking for Issues and Merge Requests (title and description overriding prevention)
+ - Require confirmation when not logged in for unsubscribe links !6223 (Maximiliano Perez Coto)
+ - Add `wiki_page_events` to project hook APIs (Ben Boeckel)
+ - Remove Gitorious import
+ - Loads GFM autocomplete source only when required
+ - Fix issue with slash commands not loading on new issue page
+ - Fix inconsistent background color for filter input field (ClemMakesApps)
+ - Remove prefixes from transition CSS property (ClemMakesApps)
+ - Add Sentry logging to API calls
+ - Add BroadcastMessage API
+ - Merge request tabs are fixed when scrolling page
+ - Use 'git update-ref' for safer web commits !6130
+ - Sort pipelines requested through the API
+ - Automatically expand hidden discussions when accessed by a permalink !5585 (Mike Greiling)
+ - Fix issue boards loading on large screens
+ - Change pipeline duration to be jobs running time instead of simple wall time from start to end !6084
+ - Show queued time when showing a pipeline !6084
+ - Remove unused mixins (ClemMakesApps)
+ - Fix issue board label filtering appending already filtered labels
+ - Add search to all issue board lists
+ - Scroll active tab into view on mobile
+ - Fix groups sort dropdown alignment (ClemMakesApps)
+ - Add horizontal scrolling to all sub-navs on mobile viewports (ClemMakesApps)
+ - Use JavaScript tooltips for mentions !5301 (winniehell)
+ - Add hover state to todos !5361 (winniehell)
+ - Fix icon alignment of star and fork buttons !5451 (winniehell)
+ - Fix alignment of icon buttons !5887 (winniehell)
+ - Added Ubuntu 16.04 support for packager.io (JonTheNiceGuy)
+ - Fix markdown help references (ClemMakesApps)
+ - Add last commit time to repo view (ClemMakesApps)
+ - Fix accessibility and visibility of project list dropdown button !6140
+ - Fix missing flash messages on service edit page (airatshigapov)
+ - Added project-specific enable/disable setting for LFS !5997
+ - Added group-specific enable/disable setting for LFS !6164
+ - Add optional 'author' param when making commits. !5822 (dandunckelman)
+ - Don't expose a user's token in the `/api/v3/user` API (!6047)
+ - Remove redundant js-timeago-pending from user activity log (ClemMakesApps)
+ - Ability to manage project issues, snippets, wiki, merge requests and builds access level
+ - Remove inconsistent font weight for sidebar's labels (ClemMakesApps)
+ - Align add button on repository view (ClemMakesApps)
+ - Fix contributions calendar month label truncation (ClemMakesApps)
+ - Import release note descriptions from GitHub (EspadaV8)
+ - Added tests for diff notes
+ - Add pipeline events to Slack integration !5525
+ - Add a button to download latest successful artifacts for branches and tags !5142
+ - Remove redundant pipeline tooltips (ClemMakesApps)
+ - Expire commit info views after one day, instead of two weeks, to allow for user email updates
+ - Add delimiter to project stars and forks count (ClemMakesApps)
+ - Fix badge count alignment (ClemMakesApps)
+ - Remove green outline from `New branch unavailable` button on issue page !5858 (winniehell)
+ - Fix repo title alignment (ClemMakesApps)
+ - Change update interval of contacted_at
+ - Add LFS support to SSH !6043
+ - Fix branch title trailing space on hover (ClemMakesApps)
+ - Don't include 'Created By' tag line when importing from GitHub if there is a linked GitLab account (EspadaV8)
+ - Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison)
+ - Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison)
+ - Order award emoji tooltips in order they were added (EspadaV8)
+ - Fix spacing and vertical alignment on build status icon on commits page (ClemMakesApps)
+ - Update merge_requests.md with a simpler way to check out a merge request. !5944
+ - Fix button missing type (ClemMakesApps)
+ - Gitlab::Checks is now instrumented
+ - Move to project dropdown with infinite scroll for better performance
+ - Fix leaking of submit buttons outside the width of a main container !18731 (originally by @pavelloz)
+ - Load branches asynchronously in Cherry Pick and Revert dialogs.
+ - Convert datetime coffeescript spec to ES6 (ClemMakesApps)
+ - Add merge request versions !5467
+ - Change using size to use count and caching it for number of group members. !5935
+ - Replace play icon font with svg (ClemMakesApps)
+ - Added 'only_allow_merge_if_build_succeeds' project setting in the API. !5930 (Duck)
+ - Reduce number of database queries on builds tab
+ - Wrap text in commit message containers
+ - Capitalize mentioned issue timeline notes (ClemMakesApps)
+ - Fix inconsistent checkbox alignment (ClemMakesApps)
+ - Use the default branch for displaying the project icon instead of master !5792 (Hannes Rosenögger)
+ - Adds response mime type to transaction metric action when it's not HTML
+ - Fix hover leading space bug in pipeline graph !5980
+ - Avoid conflict with admin labels when importing GitHub labels
+ - User can edit closed MR with deleted fork (Katarzyna Kobierska Ula Budziszewska) !5496
+ - Fix repository page ui issues
+ - Avoid protected branches checks when verifying access without branch name
+ - Add information about user and manual build start to runner as variables !6201 (Sergey Gnuskov)
+ - Fixed invisible scroll controls on build page on iPhone
+ - Fix error on raw build trace download for old builds stored in database !4822
+ - Refactor the triggers page and documentation !6217
+ - Show values of CI trigger variables only when clicked (Katarzyna Kobierska Ula Budziszewska)
+ - Use default clone protocol on "check out, review, and merge locally" help page URL
+ - Let the user choose a namespace and name on GitHub imports
+ - API for Ci Lint !5953 (Katarzyna Kobierska Urszula Budziszewska)
+ - Allow bulk update merge requests from merge requests index page
+ - Ensure validation messages are shown within the milestone form
+ - Add notification_settings API calls !5632 (mahcsig)
+ - Remove duplication between project builds and admin builds view !5680 (Katarzyna Kobierska Ula Budziszewska)
+ - Fix URLs with anchors in wiki !6300 (houqp)
+ - Deleting source project with existing fork link will close all related merge requests !6177 (Katarzyna Kobierska Ula Budziszeska)
+ - Return 204 instead of 404 for /ci/api/v1/builds/register.json if no builds are scheduled for a runner !6225
+ - Fix Gitlab::Popen.popen thread-safety issue
+ - Add specs to removing project (Katarzyna Kobierska Ula Budziszewska)
+ - Clean environment variables when running git hooks
+ - Fix Import/Export issues importing protected branches and some specific models
+ - Fix non-master branch readme display in tree view
+ - Add UX improvements for merge request version diffs
+
+## 8.11.11 (2016-11-07)
+
+- Fix XSS issue in Markdown autolinker
+
+## 8.11.10 (2016-11-02)
+
+- Removes any symlinks before importing a project export file. CVE-2016-9086
+
+## 8.11.9
+
+ - Don't send Private-Token (API authentication) headers to Sentry
+ - Share projects via the API only with groups the authenticated user can access
+
+## 8.11.8
+
+ - Respect the fork_project permission when forking projects
+ - Set a restrictive CORS policy on the API for credentialed requests
+ - API: disable rails session auth for non-GET/HEAD requests
+ - Escape HTML nodes in builds commands in CI linter
+
+## 8.11.7
+
+ - Avoid conflict with admin labels when importing GitHub labels. !6158
+ - Restores `fieldName` to allow only string values in `gl_dropdown.js`. !6234
+ - Allow the Rails cookie to be used for API authentication.
+ - Login/Register UX upgrade !6328
+
+## 8.11.6
+
+ - Fix unnecessary horizontal scroll area in pipeline visualizations. !6005
+ - Make merge conflict file size limit 200 KB, to match the docs. !6052
+ - Fix an error where we were unable to create a CommitStatus for running state. !6107
+ - Optimize discussion notes resolving and unresolving. !6141
+ - Fix GitLab import button. !6167
+ - Restore SSH Key title auto-population behavior. !6186
+ - Fix DB schema to match latest migration. !6256
+ - Exclude some pending or inactivated rows in Member scopes.
+
+## 8.11.5
+
+ - Optimize branch lookups and force a repository reload for Repository#find_branch. !6087
+ - Fix member expiration date picker after update. !6184
+ - Fix suggested colors options for new labels in the admin area. !6138
+ - Optimize discussion notes resolving and unresolving
+ - Fix GitLab import button
+ - Fix confidential issues being exposed as public using gitlab.com export
+ - Remove gitorious from import_sources. !6180
+ - Scope webhooks/services that will run for confidential issues
+ - Remove gitorious from import_sources
+ - Fix confidential issues being exposed as public using gitlab.com export
+ - Use oj gem for faster JSON processing
+
+## 8.11.4
+
+ - Fix resolving conflicts on forks. !6082
+ - Fix diff commenting on merge requests created prior to 8.10. !6029
+ - Fix pipelines tab layout regression. !5952
+ - Fix "Wiki" link not appearing in navigation for projects with external wiki. !6057
+ - Do not enforce using hash with hidden key in CI configuration. !6079
+ - Fix hover leading space bug in pipeline graph !5980
+ - Fix sorting issues by "last updated" doesn't work after import from GitHub
+ - GitHub importer use default project visibility for non-private projects
+ - Creating an issue through our API now emails label subscribers !5720
+ - Block concurrent updates for Pipeline
+ - Don't create groups for unallowed users when importing projects
+ - Fix issue boards leak private label names and descriptions
+ - Fix broken gitlab:backup:restore because of bad permissions on repo storage !6098 (Dirk Hörner)
+ - Remove gitorious. !5866
+ - Allow compare merge request versions
+
+## 8.11.3
+
+ - Allow system info page to handle case where info is unavailable
+ - Label list shows all issues (opened or closed) with that label
+ - Don't show resolve conflicts link before MR status is updated
+ - Fix IE11 fork button bug !5982
+ - Don't prevent viewing the MR when git refs for conflicts can't be found on disk
+ - Fix external issue tracker "Issues" link leading to 404s
+ - Don't try to show merge conflict resolution info if a merge conflict contains non-UTF-8 characters
+ - Automatically expand hidden discussions when accessed by a permalink !5585 (Mike Greiling)
+ - Issues filters reset button
+
+## 8.11.2
+
+ - Show "Create Merge Request" widget for push events to fork projects on the source project. !5978
+ - Use gitlab-workhorse 0.7.11 !5983
+ - Does not halt the GitHub import process when an error occurs. !5763
+ - Fix file links on project page when default view is Files !5933
+ - Fixed enter key in search input not working !5888
+
+## 8.11.1
+
+ - Pulled due to packaging error.
+
+## 8.11.0 (2016-08-22)
+
+ - Use test coverage value from the latest successful pipeline in badge. !5862
+ - Add test coverage report badge. !5708
+ - Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar)
+ - Add Koding (online IDE) integration
+ - Ability to specify branches for Pivotal Tracker integration (Egor Lynko)
+ - Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres)
+ - Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres)
+ - Fix adding line comments on the initial commit to a repo !5900
+ - Fix the title of the toggle dropdown button. !5515 (herminiotorres)
+ - Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
+ - Update to Ruby 2.3.1. !4948
+ - Add Issues Board !5548
+ - Allow resolving merge conflicts in the UI !5479
+ - Improve diff performance by eliminating redundant checks for text blobs
+ - Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
+ - Convert switch icon into icon font (ClemMakesApps)
+ - API: Endpoints for enabling and disabling deploy keys
+ - API: List access requests, request access, approve, and deny access requests to a project or a group. !4833
+ - Use long options for curl examples in documentation !5703 (winniehell)
+ - Added tooltip listing label names to the labels value in the collapsed issuable sidebar
+ - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
+ - GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository
+ - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell)
+ - Allow naming U2F devices !5833
+ - Ignore URLs starting with // in Markdown links !5677 (winniehell)
+ - Fix CI status icon link underline (ClemMakesApps)
+ - The Repository class is now instrumented
+ - Fix commit mention font inconsistency (ClemMakesApps)
+ - Do not escape URI when extracting path !5878 (winniehell)
+ - Fix filter label tooltip HTML rendering (ClemMakesApps)
+ - Cache the commit author in RequestStore to avoid extra lookups in PostReceive
+ - Expand commit message width in repo view (ClemMakesApps)
+ - Cache highlighted diff lines for merge requests
+ - Pre-create all builds for a Pipeline when the new Pipeline is created !5295
+ - Allow merge request diff notes and discussions to be explicitly marked as resolved
+ - API: Add deployment endpoints
+ - API: Add Play endpoint on Builds
+ - Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
+ - Show wall clock time when showing a pipeline. !5734
+ - Show member roles to all users on members page
+ - Project.visible_to_user is instrumented again
+ - Fix awardable button mutuality loading spinners (ClemMakesApps)
+ - Sort todos by date and priority
+ - Add support for using RequestStore within Sidekiq tasks via SIDEKIQ_REQUEST_STORE env variable
+ - Optimize maximum user access level lookup in loading of notes
+ - Send notification emails to users newly mentioned in issue and MR edits !5800
+ - Add "No one can push" as an option for protected branches. !5081
+ - Improve performance of AutolinkFilter#text_parse by using XPath
+ - Add experimental Redis Sentinel support !1877
+ - Rendering of SVGs as blobs is now limited to SVGs with a size smaller or equal to 2MB
+ - Fix branches page dropdown sort initial state (ClemMakesApps)
+ - Environments have an url to link to
+ - Various redundant database indexes have been removed
+ - Update `timeago` plugin to use multiple string/locale settings
+ - Remove unused images (ClemMakesApps)
+ - Get issue and merge request description templates from repositories
+ - Enforce 2FA restrictions on API authentication endpoints !5820
+ - Limit git rev-list output count to one in forced push check
+ - Show deployment status on merge requests with external URLs
+ - Clean up unused routes (Josef Strzibny)
+ - Fix issue on empty project to allow developers to only push to protected branches if given permission
+ - API: Add enpoints for pipelines
+ - Add green outline to New Branch button. !5447 (winniehell)
+ - Optimize generating of cache keys for issues and notes
+ - Fix repository push email formatting in Outlook
+ - Improve performance of syntax highlighting Markdown code blocks
+ - Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects
+ - Remove delay when hitting "Reply..." button on page with a lot of discussions
+ - Retrieve rendered HTML from cache in one request
+ - Fix renaming repository when name contains invalid chararacters under project settings
+ - Upgrade Grape from 0.13.0 to 0.15.0. !4601
+ - Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries
+ - Fix devise deprecation warnings.
+ - Check for 2FA when using Git over HTTP and only allow PersonalAccessTokens as password in that case !5764
+ - Update version_sorter and use new interface for faster tag sorting
+ - Optimize checking if a user has read access to a list of issues !5370
+ - Store all DB secrets in secrets.yml, under descriptive names !5274
+ - Fix syntax highlighting in file editor
+ - Support slash commands in issue and merge request descriptions as well as comments. !5021
+ - Nokogiri's various parsing methods are now instrumented
+ - Add archived badge to project list !5798
+ - Add simple identifier to public SSH keys (muteor)
+ - Admin page now references docs instead of a specific file !5600 (AnAverageHuman)
+ - Fix filter input alignment (ClemMakesApps)
+ - Include old revision in merge request update hooks (Ben Boeckel)
+ - Add build event color in HipChat messages (David Eisner)
+ - Make fork counter always clickable. !5463 (winniehell)
+ - Document that webhook secret token is sent in X-Gitlab-Token HTTP header !5664 (lycoperdon)
+ - Gitlab::Highlight is now instrumented
+ - All created issues, API or WebUI, can be submitted to Akismet for spam check !5333
+ - Allow users to import cross-repository pull requests from GitHub
+ - The overhead of instrumented method calls has been reduced
+ - Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le)
+ - Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
+ - Add pipeline events hook
+ - Bump gitlab_git to speedup DiffCollection iterations
+ - Rewrite description of a blocked user in admin settings. (Elias Werberich)
+ - Make branches sortable without push permission !5462 (winniehell)
+ - Check for Ci::Build artifacts at database level on pipeline partial
+ - Convert image diff background image to CSS (ClemMakesApps)
+ - Remove unnecessary index_projects_on_builds_enabled index from the projects table
+ - Make "New issue" button in Issue page less obtrusive !5457 (winniehell)
+ - Gitlab::Metrics.current_transaction needs to be public for RailsQueueDuration
+ - Fix search for notes which belongs to deleted objects
+ - Allow Akismet to be trained by submitting issues as spam or ham !5538
+ - Add GitLab Workhorse version to admin dashboard (Katarzyna Kobierska Ula Budziszewska)
+ - Allow branch names ending with .json for graph and network page !5579 (winniehell)
+ - Add the `sprockets-es6` gem
+ - Improve OAuth2 client documentation (muteor)
+ - Fix diff comments inverted toggle bug (ClemMakesApps)
+ - Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska)
+ - Profile requests when a header is passed
+ - Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab.
+ - Speedup DiffNote#active? on discussions, preloading noteables and avoid touching git repository to return diff_refs when possible
+ - Add commit stats in commit api. !5517 (dixpac)
+ - Add CI configuration button on project page
+ - Fix merge request new view not changing code view rendering style
+ - edit_blob_link will use blob passed onto the options parameter
+ - Make error pages responsive (Takuya Noguchi)
+ - The performance of the project dropdown used for moving issues has been improved
+ - Fix skip_repo parameter being ignored when destroying a namespace
+ - Add all builds into stage/job dropdowns on builds page
+ - Change requests_profiles resource constraint to catch virtually any file
+ - Bump gitlab_git to lazy load compare commits
+ - Reduce number of queries made for merge_requests/:id/diffs
+ - Add the option to set the expiration date for the project membership when giving a user access to a project. !5599 (Adam Niedzielski)
+ - Sensible state specific default sort order for issues and merge requests !5453 (tomb0y)
+ - Fix bug where destroying a namespace would not always destroy projects
+ - Fix RequestProfiler::Middleware error when code is reloaded in development
+ - Allow horizontal scrolling of code blocks in issue body
+ - Catch what warden might throw when profiling requests to re-throw it
+ - Avoid commit lookup on diff_helper passing existing local variable to the helper method
+ - Add description to new_issue email and new_merge_request_email in text/plain content type. !5663 (dixpac)
+ - Speed up and reduce memory usage of Commit#repo_changes, Repository#expire_avatar_cache and IrkerWorker
+ - Add unfold links for Side-by-Side view. !5415 (Tim Masliuchenko)
+ - Adds support for pending invitation project members importing projects
+ - Add pipeline visualization/graph on pipeline page
+ - Update devise initializer to turn on changed password notification emails. !5648 (tombell)
+ - Avoid to show the original password field when password is automatically set. !5712 (duduribeiro)
+ - Fix importing GitLab projects with an invalid MR source project
+ - Sort folders with submodules in Files view !5521
+ - Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0
+ - Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska)
+ - Add pipelines tab to merge requests
+ - Fix notification_service argument error of declined invitation emails
+ - Fix a memory leak caused by Banzai::Filter::SanitizationFilter
+ - Speed up todos queries by limiting the projects set we join with
+ - Ensure file editing in UI does not overwrite commited changes without warning user
+ - Eliminate unneeded calls to Repository#blob_at when listing commits with no path
+ - Update gitlab_git gem to 10.4.7
+ - Simplify SQL queries of marking a todo as done
+
+## 8.10.13 (2016-11-02)
+
+- Removes any symlinks before importing a project export file. CVE-2016-9086
+
+## 8.10.12
+
+ - Don't send Private-Token (API authentication) headers to Sentry
+ - Share projects via the API only with groups the authenticated user can access
+
+## 8.10.11
+
+ - Respect the fork_project permission when forking projects
+ - Set a restrictive CORS policy on the API for credentialed requests
+ - API: disable rails session auth for non-GET/HEAD requests
+ - Escape HTML nodes in builds commands in CI linter
+
+## 8.10.10
+
+ - Allow the Rails cookie to be used for API authentication.
+
+## 8.10.9
+
+ - Exclude some pending or inactivated rows in Member scopes
+
+## 8.10.8
+
+ - Fix information disclosure in issue boards.
+ - Fix privilege escalation in project import.
+
+## 8.10.7
+
+ - Upgrade Hamlit to 2.6.1. !5873
+ - Upgrade Doorkeeper to 4.2.0. !5881
+
+## 8.10.6
+
+ - Upgrade Rails to 4.2.7.1 for security fixes. !5781
+ - Restore "Largest repository" sort option on Admin > Projects page. !5797
+ - Fix privilege escalation via project export.
+ - Require administrator privileges to perform a project import.
+
+## 8.10.5
+
+ - Add a data migration to fix some missing timestamps in the members table. !5670
+ - Revert the "Defend against 'Host' header injection" change in the source NGINX templates. !5706
+ - Cache project count for 5 minutes to reduce DB load. !5746 & !5754
+
+## 8.10.4
+
+ - Don't close referenced upstream issues from a forked project.
+ - Fixes issue with dropdowns `enter` key not working correctly. !5544
+ - Fix Import/Export project import not working in HA mode. !5618
+ - Fix Import/Export error checking versions. !5638
+
+## 8.10.3
+
+ - Fix Import/Export issue importing milestones and labels not associated properly. !5426
+ - Fix timing problems running imports on production. !5523
+ - Add a log message when a project is scheduled for destruction for debugging. !5540
+ - Fix hooks missing on imported GitLab projects. !5549
+ - Properly abort a merge when merge conflicts occur. !5569
+ - Fix importer for GitHub Pull Requests when a branch was removed. !5573
+ - Ignore invalid IPs in X-Forwarded-For when trusted proxies are configured. !5584
+ - Trim extra displayed carriage returns in diffs and files with CRLFs. !5588
+ - Fix label already exist error message in the right sidebar.
+
+## 8.10.2
+
+ - User can now search branches by name. !5144
+ - Page is now properly rendered after committing the first file and creating the first branch. !5399
+ - Add branch or tag icon to ref in builds page. !5434
+ - Fix backup restore. !5459
+ - Use project ID in repository cache to prevent stale data from persisting across projects. !5460
+ - Fix issue with autocomplete search not working with enter key. !5466
+ - Add iid to MR API response. !5468
+ - Disable MySQL foreign key checks before dropping all tables. !5472
+ - Ensure relative paths for video are rewritten as we do for images. !5474
+ - Ensure current user can retry a build before showing the 'Retry' button. !5476
+ - Add ENV variable to skip repository storages validations. !5478
+ - Added `*.js.es6 gitlab-language=javascript` to `.gitattributes`. !5486
+ - Don't show comment button in gutter of diffs on MR discussion tab. !5493
+ - Rescue Rugged::OSError (lock exists) when creating references. !5497
+ - Fix expand all diffs button in compare view. !5500
+ - Show release notes in tags list. !5503
+ - Fix a bug where forking a project from a repository storage to another would fail. !5509
+ - Fix missing schema update for `20160722221922`. !5512
+ - Update `gitlab-shell` version to 3.2.1 in the 8.9->8.10 update guide. !5516
+
+## 8.10.1
+
+ - Refactor repository storages documentation. !5428
+ - Gracefully handle case when keep-around references are corrupted or exist already. !5430
+ - Add detailed info on storage path mountpoints. !5437
+ - Fix Error 500 when creating Wiki pages with hyphens or spaces. !5444
+ - Fix bug where replies to commit notes displayed in the MR discussion tab wouldn't show up on the commit page. !5446
+ - Ignore invalid trusted proxies in X-Forwarded-For header. !5454
+ - Add links to the real markdown.md file for all GFM examples. !5458
+
+## 8.10.0 (2016-07-22)
+
+ - Fix profile activity heatmap to show correct day name (eanplatter)
+ - Speed up ExternalWikiHelper#get_project_wiki_path
+ - Expose {should,force}_remove_source_branch (Ben Boeckel)
+ - Add the functionality to be able to rename a file. !5049
+ - Disable PostgreSQL statement timeout during migrations
+ - Fix projects dropdown loading performance with a simplified api cal. !5113
+ - Fix commit builds API, return all builds for all pipelines for given commit. !4849
+ - Replace Haml with Hamlit to make view rendering faster. !3666
+ - Refresh the branch cache after `git gc` runs
+ - Allow to disable request access button on projects/groups
+ - Refactor repository paths handling to allow multiple git mount points
+ - Optimize system note visibility checking by memoizing the visible reference count. !5070
+ - Add Application Setting to configure default Repository Path for new projects
+ - Delete award emoji when deleting a user
+ - Remove pinTo from Flash and make inline flash messages look nicer. !4854 (winniehell)
+ - Add an API for downloading latest successful build from a particular branch or tag. !5347
+ - Avoid data-integrity issue when cleaning up repository archive cache.
+ - Add link to profile to commit avatar. !5163 (winniehell)
+ - Wrap code blocks on Activies and Todos page. !4783 (winniehell)
+ - Align flash messages with left side of page content. !4959 (winniehell)
+ - Display tooltip for "Copy to Clipboard" button. !5164 (winniehell)
+ - Use default cursor for table header of project files. !5165 (winniehell)
+ - Store when and yaml variables in builds table
+ - Display last commit of deleted branch in push events. !4699 (winniehell)
+ - Escape file extension when parsing search results. !5141 (winniehell)
+ - Add "passing with warnings" to the merge request pipeline possible statuses, this happens when builds that allow failures have failed. !5004
+ - Add image border in Markdown preview. !5162 (winniehell)
+ - Apply the trusted_proxies config to the rack request object for use with rack_attack
+ - Added the ability to block sign ups using a domain blacklist. !5259
+ - Upgrade to Rails 4.2.7. !5236
+ - Extend exposed environment variables for CI builds
+ - Deprecate APIs "projects/:id/keys/...". Use "projects/:id/deploy_keys/..." instead
+ - Add API "deploy_keys" for admins to get all deploy keys
+ - Allow to pull code with deploy key from public projects
+ - Use limit parameter rather than hardcoded value in `ldap:check` rake task (Mike Ricketts)
+ - Add Sidekiq queue duration to transaction metrics.
+ - Add a new column `artifacts_size` to table `ci_builds`. !4964
+ - Let Workhorse serve format-patch diffs
+ - Display tooltip for mentioned users and groups. !5261 (winniehell)
+ - Allow build email service to be tested
+ - Added day name to contribution calendar tooltips
+ - Refactor user authorization check for a single project to avoid querying all user projects
+ - Make images fit to the size of the viewport. !4810
+ - Fix check for New Branch button on Issue page. !4630 (winniehell)
+ - Fix GFM autocomplete not working on wiki pages
+ - Fixed enter key not triggering click on first row when searching in a dropdown
+ - Updated dropdowns in issuable form to use new GitLab dropdown style
+ - Make images fit to the size of the viewport !4810
+ - Fix check for New Branch button on Issue page !4630 (winniehell)
+ - Fix MR-auto-close text added to description. !4836
+ - Support U2F devices in Firefox. !5177
+ - Fix issue, preventing users w/o push access to sort tags. !5105 (redetection)
+ - Add Spring EmojiOne updates.
+ - Added Rake task for tracking deployments. !5320
+ - Fix fetching LFS objects for private CI projects
+ - Add the new 2016 Emoji! Adds 72 new emoji including bacon, facepalm, and selfie. !5237
+ - Add syntax for multiline blockquote using `>>>` fence. !3954
+ - Fix viewing notification settings when a project is pending deletion
+ - Updated compare dropdown menus to use GL dropdown
+ - Redirects back to issue after clicking login link
+ - Eager load award emoji on notes
+ - Allow to define manual actions/builds on Pipelines and Environments
+ - Fix pagination when sorting by columns with lots of ties (like priority)
+ - The Markdown reference parsers now re-use query results to prevent running the same queries multiple times. !5020
+ - Updated project header design
+ - Issuable collapsed assignee tooltip is now the users name
+ - Fix compare view not changing code view rendering style
+ - Exclude email check from the standard health check
+ - Updated layout for Projects, Groups, Users on Admin area. !4424
+ - Fix changing issue state columns in milestone view
+ - Update health_check gem to version 2.1.0
+ - Add notification settings dropdown for groups
+ - Render inline diffs for multiple changed lines following eachother
+ - Wildcards for protected branches. !4665
+ - Allow importing from Github using Personal Access Tokens. (Eric K Idema)
+ - API: Expose `due_date` for issues (Robert Schilling)
+ - API: Todos. !3188 (Robert Schilling)
+ - API: Expose shared groups for projects and shared projects for groups. !5050 (Robert Schilling)
+ - API: Expose `developers_can_push` and `developers_can_merge` for branches. !5208 (Robert Schilling)
+ - Add "Enabled Git access protocols" to Application Settings
+ - Diffs will create button/diff form on demand no on server side
+ - Reduce size of HTML used by diff comment forms
+ - Protected branches have a "Developers can Merge" setting. !4892 (original implementation by Mathias Vestergaard)
+ - Fix user creation with stronger minimum password requirements. !4054 (nathan-pmt)
+ - Only show New Snippet button to users that can create snippets.
+ - PipelinesFinder uses git cache data
+ - Track a user who created a pipeline
+ - Actually render old and new sections of parallel diff next to each other
+ - Throttle the update of `project.pushes_since_gc` to 1 minute.
+ - Allow expanding and collapsing files in diff view. !4990
+ - Collapse large diffs by default (!4990)
+ - Fix mentioned users list on diff notes
+ - Add support for inline videos in GitLab Flavored Markdown. !5215 (original implementation by Eric Hayes)
+ - Fix creation of deployment on build that is retried, redeployed or rollback
+ - Don't parse Rinku returned value to DocFragment when it didn't change the original html string.
+ - Check for conflicts with existing Project's wiki path when creating a new project.
+ - Show last push widget in upstream after push to fork
+ - Fix stage status shown for pipelines
+ - Cache todos pending/done dashboard query counts.
+ - Don't instantiate a git tree on Projects show default view
+ - Bump Rinku to 2.0.0
+ - Remove unused front-end variable -> default_issues_tracker
+ - ObjectRenderer retrieve renderer content using Rails.cache.read_multi
+ - Better caching of git calls on ProjectsController#show.
+ - Avoid to retrieve MR closes_issues as much as possible.
+ - Hide project name in project activities. !5068 (winniehell)
+ - Add API endpoint for a group issues. !4520 (mahcsig)
+ - Add Bugzilla integration. !4930 (iamtjg)
+ - Fix new snippet style bug (elliotec)
+ - Instrument Rinku usage
+ - Be explicit to define merge request discussion variables
+ - Use cache for todos counter calling TodoService
+ - Metrics for Rouge::Plugins::Redcarpet and Rouge::Formatters::HTMLGitlab
+ - RailsCache metris now includes fetch_hit/fetch_miss and read_hit/read_miss info.
+ - Allow [ci skip] to be in any case and allow [skip ci]. !4785 (simon_w)
+ - Made project list visibility icon fixed width
+ - Set import_url validation to be more strict
+ - Memoize MR merged/closed events retrieval
+ - Don't render discussion notes when requesting diff tab through AJAX
+ - Add basic system information like memory and disk usage to the admin panel
+ - Don't garbage collect commits that have related DB records like comments
+ - Allow to setup event by channel on slack service
+ - More descriptive message for git hooks and file locks
+ - Aliases of award emoji should be stored as original name. !5060 (dixpac)
+ - Handle custom Git hook result in GitLab UI
+ - Allow to access Container Registry for Public and Internal projects
+ - Allow '?', or '&' for label names
+ - Support redirected blobs for Container Registry integration
+ - Fix importer for GitHub Pull Requests when a branch was reused across Pull Requests
+ - Add date when user joined the team on the member page
+ - Fix 404 redirect after validation fails importing a GitLab project
+ - Added setting to set new users by default as external. !4545 (Dravere)
+ - Add min value for project limit field on user's form. !3622 (jastkand)
+ - Reset project pushes_since_gc when we enqueue the git gc call
+ - Add reminder to not paste private SSH keys. !4399 (Ingo Blechschmidt)
+ - Collapsed diffs lines/size don't acumulate to overflow diffs.
+ - Remove duplicate `description` field in `MergeRequest` entities (Ben Boeckel)
+ - Style of import project buttons were fixed in the new project page. !5183 (rdemirbay)
+ - Fix GitHub client requests when rate limit is disabled
+ - Optimistic locking for Issues and Merge Requests (Title and description overriding prevention)
+ - Redesign Builds and Pipelines pages
+ - Change status color and icon for running builds
+ - Fix commenting issue in side by side diff view for unchanged lines
+ - Fix markdown rendering for: consecutive labels references, label references that begin with a digit or contains `.`
+ - Project export filename now includes the project and namespace path
+ - Fix last update timestamp on issues not preserved on gitlab.com and project imports
+ - Fix issues importing projects from EE to CE
+ - Fix creating group with space in group path
+ - Improve cron_jobs loading error messages. !5318 / !5360
+ - Prevent toggling sidebar when clipboard icon clicked
+ - Create Todos for Issue author when assign or mention himself (Katarzyna Kobierska)
+ - Limit the number of retries on error to 3 for exporting projects
+ - Allow empty repositories on project import/export
+ - Render only commit message title in builds (Katarzyna Kobierska Ula Budziszewska)
+ - Allow bulk (un)subscription from issues in issue index
+ - Fix MR diff encoding issues exporting GitLab projects
+ - Move builds settings out of project settings and rename Pipelines
+ - Add builds badge to Pipelines settings page
+ - Export and import avatar as part of project import/export
+ - Fix migration corrupting import data for old version upgrades
+ - Show tooltip on GitLab export link in new project page
+ - Fix import_data wrongly saved as a result of an invalid import_url !5206
+
+## 8.9.11
+
+ - Respect the fork_project permission when forking projects
+ - Set a restrictive CORS policy on the API for credentialed requests
+ - API: disable rails session auth for non-GET/HEAD requests
+ - Escape HTML nodes in builds commands in CI linter
+
+## 8.9.10
+
+ - Allow the Rails cookie to be used for API authentication.
+
+## 8.9.9
+
+ - Exclude some pending or inactivated rows in Member scopes
+
+## 8.9.8
+
+ - Upgrade Doorkeeper to 4.2.0. !5881
+
+## 8.9.7
+
+ - Upgrade Rails to 4.2.7.1 for security fixes. !5781
+ - Require administrator privileges to perform a project import.
+
+## 8.9.6
+
+ - Fix importing of events under notes for GitLab projects. !5154
+ - Fix log statements in import/export. !5129
+ - Fix commit avatar alignment in compare view. !5128
+ - Fix broken migration in MySQL. !5005
+ - Overwrite Host and X-Forwarded-Host headers in NGINX !5213
+ - Keeps issue number when importing from Gitlab.com
+ - Add Pending tab for Builds (Katarzyna Kobierska, Urszula Budziszewska)
+
+## 8.9.5
+
+ - Add more debug info to import/export and memory killer. !5108
+ - Fixed avatar alignment in new MR view. !5095
+ - Fix diff comments not showing up in activity feed. !5069
+ - Add index on both Award Emoji user and name. !5061
+ - Downgrade to Redis 3.2.2 due to massive memory leak with Sidekiq. !5056
+ - Re-enable import button when import process fails due to namespace already being taken. !5053
+ - Fix snippets comments not displayed. !5045
+ - Fix emoji paths in relative root configurations. !5027
+ - Fix issues importing events in Import/Export. !4987
+ - Fixed 'use shortcuts' button on docs. !4979
+ - Admin should be able to turn shared runners into specific ones. !4961
+ - Update RedCloth to 4.3.2 for CVE-2012-6684. !4929 (Takuya Noguchi)
+ - Improve the request / withdraw access button. !4860
+
+## 8.9.4
+
+ - Fix privilege escalation issue with OAuth external users.
+ - Ensure references to private repos aren't shown to logged-out users.
+ - Fixed search field blur not removing focus. !4704
+ - Resolve "Sub nav isn't showing on file view". !4890
+ - Fixes middle click and double request when navigating through the file browser. !4891
+ - Fixed URL on label button when filtering. !4897
+ - Fixed commit avatar alignment. !4933
+ - Do not show build retry link when build is active. !4967
+ - Fix restore Rake task warning message output. !4980
+ - Handle external issues in IssueReferenceFilter. !4988
+ - Expiry date on pinned nav cookie. !5009
+ - Updated breakpoint for sidebar pinning. !5019
+
+## 8.9.3
+
+ - Fix encrypted data backwards compatibility after upgrading attr_encrypted gem. !4963
+ - Fix rendering of commit notes. !4953
+ - Resolve "Pin should show up at 1280px min". !4947
+ - Switched mobile button icons to ellipsis and angle. !4944
+ - Correctly returns todo ID after creating todo. !4941
+ - Better debugging for memory killer middleware. !4936
+ - Remove duplicate new page btn from edit wiki. !4904
+ - Use clock_gettime for all performance timestamps. !4899
+ - Use memorized tags array when searching tags by name. !4859
+ - Fixed avatar alignment in new MR view. !4901
+ - Removed fade when filtering results. !4932
+ - Fix missing avatar on system notes. !4954
+ - Reduce overhead and optimize ProjectTeam#max_member_access performance. !4973
+ - Use update_columns to bypass all the dirty code on active_record. !4985
+ - Fix restore Rake task warning message output !4980
+
+## 8.9.2
+
+ - Fix visibility of snippets when searching.
+ - Fix an information disclosure when requesting access to a group containing private projects.
+ - Update omniauth-saml to 1.6.0 !4951
+
+## 8.9.1
+
+ - Refactor labels documentation. !3347
+ - Eager load award emoji on notes. !4628
+ - Fix some CI wording in documentation. !4660
+ - Document `GIT_STRATEGY` and `GIT_DEPTH`. !4720
+ - Add documentation for the export & import features. !4732
+ - Add some docs for Docker Registry configuration. !4738
+ - Ensure we don't send the "access request declined" email to access requesters on project deletion. !4744
+ - Display group/project access requesters separately in the admin area. !4798
+ - Add documentation and examples for configuring cloud storage for registry images. !4812
+ - Clarifies documentation about artifact expiry. !4831
+ - Fix the Network graph links. !4832
+ - Fix MR-auto-close text added to description. !4836
+ - Add documentation for award emoji now that comments can be awarded with emojis. !4839
+ - Fix typo in export failure email. !4847
+ - Fix header vertical centering. !4170
+ - Fix subsequent SAML sign ins. !4718
+ - Set button label when picking an option from status dropdown. !4771
+ - Prevent invalid URLs from raising exceptions in WikiLink Filter. !4775
+ - Handle external issues in IssueReferenceFilter. !4789
+ - Support for rendering/redacting multiple documents. !4828
+ - Update Todos documentation and screenshots to include new functionality. !4840
+ - Hide nav arrows by default. !4843
+ - Added bottom padding to label color suggestion link. !4845
+ - Use jQuery objects in ref dropdown. !4850
+ - Fix GitLab project import issues related to notes and builds. !4855
+ - Restrict header logo to 36px so it doesn't overflow. !4861
+ - Fix unwanted label unassignment. !4863
+ - Fix mobile Safari bug where horizontal nav arrows would flicker on scroll. !4869
+ - Restore old behavior around diff notes to outdated discussions. !4870
+ - Fix merge requests project settings help link anchor. !4873
+ - Fix 404 when accessing pipelines as guest user on public projects. !4881
+ - Remove width restriction for logo on sign-in page. !4888
+ - Bump gitlab_git to 10.2.3 to fix false truncated warnings with ISO-8559 files. !4884
+ - Apply selected value as label. !4886
+ - Change Retry to Re-deploy on Deployments page
+ - Fix temp file being deleted after the request while importing a GitLab project. !4894
+ - Fix pagination when sorting by columns with lots of ties (like priority)
+ - Implement Subresource Integrity for CSS and JavaScript assets. This prevents malicious assets from loading in the case of a CDN compromise.
+ - Fix user creation with stronger minimum password requirements !4054 (nathan-pmt)
+ - Fix a wrong MR status when merge_when_build_succeeds & project.only_allow_merge_if_build_succeeds are true. !4912
+ - Add SMTP as default delivery method to match gitlab-org/omnibus-gitlab!826. !4915
+ - Remove duplicate 'New Page' button on edit wiki page
+
+## 8.9.0 (2016-06-22)
+
+ - Fix group visibility form layout in application settings
+ - Fix builds API response not including commit data
+ - Fix error when CI job variables key specified but not defined
+ - Fix pipeline status when there are no builds in pipeline
+ - Fix Error 500 when using closes_issues API with an external issue tracker
+ - Add more information into RSS feed for issues (Alexander Matyushentsev)
+ - Bulk assign/unassign labels to issues.
+ - Ability to prioritize labels !4009 / !3205 (Thijs Wouters)
+ - Show Star and Fork buttons on mobile.
+ - Performance improvements on RelativeLinkFilter
+ - Fix endless redirections when accessing user OAuth applications when they are disabled
+ - Allow enabling wiki page events from Webhook management UI
+ - Bump rouge to 1.11.0
+ - Fix issue with arrow keys not working in search autocomplete dropdown
+ - Fix an issue where note polling stopped working if a window was in the
+ background during a refresh.
+ - Pre-processing Markdown now only happens when needed
+ - Make EmailsOnPushWorker use Sidekiq mailers queue
+ - Redesign all Devise emails. !4297
+ - Don't show 'Leave Project' to group members
+ - Fix wiki page events' webhook to point to the wiki repository
+ - Add a border around images to differentiate them from the background.
+ - Don't show tags for revert and cherry-pick operations
+ - Show image ID on registry page
+ - Fix issue todo not remove when leave project !4150 (Long Nguyen)
+ - Allow customisable text on the 'nearly there' page after a user signs up
+ - Bump recaptcha gem to 3.0.0 to remove deprecated stoken support
+ - Fix SVG sanitizer to allow more elements
+ - Allow forking projects with restricted visibility level
+ - Added descriptions to notification settings dropdown
+ - Improve note validation to prevent errors when creating invalid note via API
+ - Reduce number of fog gem dependencies
+ - Add number of merge requests for a given milestone to the milestones view.
+ - Implement a fair usage of shared runners
+ - Remove project notification settings associated with deleted projects
+ - Fix 404 page when viewing TODOs that contain milestones or labels in different projects
+ - Add a metric for the number of new Redis connections created by a transaction
+ - Fix Error 500 when viewing a blob with binary characters after the 1024-byte mark
+ - Redesign navigation for project pages
+ - Fix images in sign-up confirmation email
+ - Added shortcut 'y' for copying a files content hash URL #14470
+ - Fix groups API to list only user's accessible projects
+ - Fix horizontal scrollbar for long commit message.
+ - GitLab Performance Monitoring now tracks the total method execution time and call count per method
+ - Add Environments and Deployments
+ - Redesign account and email confirmation emails
+ - Don't fail builds for projects that are deleted
+ - Support Docker Registry manifest v1
+ - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix
+ - Bump nokogiri to 1.6.8
+ - Use gitlab-shell v3.0.0
+ - Fixed alignment of download dropdown in merge requests
+ - Upgrade to jQuery 2
+ - Adds selected branch name to the dropdown toggle
+ - Add API endpoint for Sidekiq Metrics !4653
+ - Refactoring Award Emoji with API support for Issues and MergeRequests
+ - Use Knapsack to evenly distribute tests across multiple nodes
+ - Add `sha` parameter to MR merge API, to ensure only reviewed changes are merged
+ - Don't allow MRs to be merged when commits were added since the last review / page load
+ - Add DB index on users.state
+ - Limit email on push diff size to 30 files / 150 KB
+ - Add rake task 'gitlab:db:configure' for conditionally seeding or migrating the database
+ - Changed the Slack build message to use the singular duration if necessary (Aran Koning)
+ - Fix race condition on merge when build succeeds
+ - Added shortcut to focus filter search fields and added documentation #18120
+ - Links from a wiki page to other wiki pages should be rewritten as expected
+ - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos)
+ - Added navigation shortcuts to the project pipelines, milestones, builds and forks page. !4393
+ - Fix issues filter when ordering by milestone
+ - Disable SAML account unlink feature
+ - Added artifacts:when to .gitlab-ci.yml - this requires GitLab Runner 1.3
+ - Bamboo Service: Fix missing credentials & URL handling when base URL contains a path (Benjamin Schmid)
+ - TeamCity Service: Fix URL handling when base URL contains a path
+ - Todos will display target state if issuable target is 'Closed' or 'Merged'
+ - Validate only and except regexp
+ - Fix bug when sorting issues by milestone due date and filtering by two or more labels
+ - POST to API /projects/:id/runners/:runner_id would give 409 if the runner was already enabled for this project
+ - Add support for using Yubikeys (U2F) for two-factor authentication
+ - Link to blank group icon doesn't throw a 404 anymore
+ - Remove 'main language' feature
+ - Toggle whitespace button now available for compare branches diffs #17881
+ - Pipelines can be canceled only when there are running builds
+ - Allow authentication using personal access tokens
+ - Use downcased path to container repository as this is expected path by Docker
+ - Allow to use CI token to fetch LFS objects
+ - Custom notification settings
+ - Projects pending deletion will render a 404 page
+ - Measure queue duration between gitlab-workhorse and Rails
+ - Added Gfm autocomplete for labels
+ - Added edit note 'up' shortcut documentation to the help panel and docs screenshot #18114
+ - Make Omniauth providers specs to not modify global configuration
+ - Remove unused JiraIssue class and replace references with ExternalIssue. !4659 (Ilan Shamir)
+ - Make authentication service for Container Registry to be compatible with < Docker 1.11
+ - Make it possible to lock a runner from being enabled for other projects
+ - Add Application Setting to configure Container Registry token expire delay (default 5min)
+ - Cache assigned issue and merge request counts in sidebar nav
+ - Use Knapsack only in CI environment
+ - Updated project creation page to match new UI #2542
+ - Cache project build count in sidebar nav
+ - Add milestone expire date to the right sidebar
+ - Manually mark a issue or merge request as a todo
+ - Fix markdown_spec to use before instead of before(:all) to properly cleanup database after testing
+ - Reduce number of queries needed to render issue labels in the sidebar
+ - Improve error handling importing projects
+ - Remove duplicated notification settings
+ - Put project Files and Commits tabs under Code tab
+ - Decouple global notification level from user model
+ - Replace Colorize with Rainbow for coloring console output in Rake tasks.
+ - Add workhorse controller and API helpers
+ - An indicator is now displayed at the top of the comment field for confidential issues.
+ - Show categorised search queries in the search autocomplete
+ - RepositoryCheck::SingleRepositoryWorker public and private methods are now instrumented
+ - Dropdown for `.gitlab-ci.yml` templates
+ - Improve issuables APIs performance when accessing notes !4471
+ - Add sorting dropdown to tags page !4423
+ - External links now open in a new tab
+ - Prevent default actions of disabled buttons and links
+ - Markdown editor now correctly resets the input value on edit cancellation !4175
+ - Toggling a task list item in a issue/mr description does not creates a Todo for mentions
+ - Improved UX of date pickers on issue & milestone forms
+ - Cache on the database if a project has an active external issue tracker.
+ - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav
+ - GitLab project import and export functionality
+ - All classes in the Banzai::ReferenceParser namespace are now instrumented
+ - Remove deprecated issues_tracker and issues_tracker_id from project model
+ - Allow users to create confidential issues in private projects
+ - Measure CPU time for instrumented methods
+ - Instrument private methods and private instance methods by default instead just public methods
+ - Only show notes through JSON on confidential issues that the user has access to
+ - Updated the allocations Gem to version 1.0.5
+ - The background sampler now ignores classes without names
+ - Update design for `Close` buttons
+ - New custom icons for navigation
+ - Horizontally scrolling navigation on project, group, and profile settings pages
+ - Hide global side navigation by default
+ - Fix project Star/Unstar project button tooltip
+ - Remove tanuki logo from side navigation; center on top nav
+ - Include user relationships when retrieving award_emoji
+ - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed
+ - Set inverse_of for Project/Service association to reduce the number of queries
+ - Update tanuki logo highlight/loading colors
+ - Remove explicit Gitlab::Metrics.action assignments, are already automatic.
+ - Use Git cached counters for branches and tags on project page
+ - Cache participable participants in an instance variable.
+ - Filter parameters for request_uri value on instrumented transactions.
+ - Remove duplicated keys add UNIQUE index to keys fingerprint column
+ - ExtractsPath get ref_names from repository cache, if not there access git.
+ - Show a flash warning about the error detail of XHR requests which failed with status code 404 and 500
+ - Cache user todo counts from TodoService
+ - Ensure Todos counters doesn't count Todos for projects pending delete
+ - Add left/right arrows horizontal navigation
+ - Add tooltip to pin/unpin navbar
+ - Add new sub nav style to Wiki and Graphs sub navigation
+
+## 8.8.9
+
+ - Upgrade Doorkeeper to 4.2.0. !5881
+
+## 8.8.8
+
+ - Upgrade Rails to 4.2.7.1 for security fixes. !5781
+
+## 8.8.7
+
+ - Fix privilege escalation issue with OAuth external users.
+ - Ensure references to private repos aren't shown to logged-out users.
+
+## 8.8.6
+
+ - Fix visibility of snippets when searching.
+ - Update omniauth-saml to 1.6.0 !4951
+
+## 8.8.5
+
+ - Import GitHub repositories respecting the API rate limit !4166
+ - Fix todos page throwing errors when you have a project pending deletion !4300
+ - Disable Webhooks before proceeding with the GitHub import !4470
+ - Fix importer for GitHub comments on diff !4488
+ - Adjust the SAML control flow to allow LDAP identities to be added to an existing SAML user !4498
+ - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace !4541
+ - Prevent unauthorized access for projects build traces
+ - Forbid scripting for wiki files
+ - Only show notes through JSON on confidential issues that the user has access to
+ - Banzai::Filter::UploadLinkFilter use XPath instead CSS expressions
+ - Banzai::Filter::ExternalLinkFilter use XPath instead CSS expressions
+
+## 8.8.4
+
+ - Fix LDAP-based login for users with 2FA enabled. !4493
+ - Added descriptions to notification settings dropdown
+ - Due date can be removed from milestones
+
+## 8.8.3
+
+ - Fix 404 page when viewing TODOs that contain milestones or labels in different projects. !4312
+ - Fixed JS error when trying to remove discussion form. !4303
+ - Fixed issue with button color when no CI enabled. !4287
+ - Fixed potential issue with 2 CI status polling events happening. !3869
+ - Improve design of Pipeline view. !4230
+ - Fix gitlab importer failing to import new projects due to missing credentials. !4301
+ - Fix import URL migration not rescuing with the correct Error. !4321
+ - Fix health check access token changing due to old application settings being used. !4332
+ - Make authentication service for Container Registry to be compatible with Docker versions before 1.11. !4363
+ - Add Application Setting to configure Container Registry token expire delay (default 5 min). !4364
+ - Pass the "Remember me" value to the 2FA token form. !4369
+ - Fix incorrect links on pipeline page when merge request created from fork. !4376
+ - Use downcased path to container repository as this is expected path by Docker. !4420
+ - Fix wiki project clone address error (chujinjin). !4429
+ - Fix serious performance bug with rendering Markdown with InlineDiffFilter. !4392
+ - Fix missing number on generated ordered list element. !4437
+ - Prevent disclosure of notes on confidential issues in search results.
+
+## 8.8.2
+
+ - Added remove due date button. !4209
+ - Fix Error 500 when accessing application settings due to nil disabled OAuth sign-in sources. !4242
+ - Fix Error 500 in CI charts by gracefully handling commits with no durations. !4245
+ - Fix table UI on CI builds page. !4249
+ - Fix backups if registry is disabled. !4263
+ - Fixed issue with merge button color. !4211
+ - Fixed issue with enter key selecting wrong option in dropdown. !4210
+ - When creating a .gitignore file a dropdown with templates will be provided. !4075
+ - Fix concurrent request when updating build log in browser. !4183
+
+## 8.8.1
+
+ - Add documentation for the "Health Check" feature
+ - Allow anonymous users to access a public project's pipelines !4233
+ - Fix MySQL compatibility in zero downtime migrations helpers
+ - Fix the CI login to Container Registry (the gitlab-ci-token user)
+
+## 8.8.0 (2016-05-22)
+
+ - Implement GFM references for milestones (Alejandro Rodríguez)
+ - Snippets tab under user profile. !4001 (Long Nguyen)
+ - Fix error when using link to uploads in global snippets
+ - Fix Error 500 when attempting to retrieve project license when HEAD points to non-existent ref
+ - Assign labels and milestone to target project when moving issue. !3934 (Long Nguyen)
+ - Use a case-insensitive comparison in sanitizing URI schemes
+ - Toggle sign-up confirmation emails in application settings
+ - Make it possible to prevent tagged runner from picking untagged jobs
+ - Added `InlineDiffFilter` to the markdown parser. (Adam Butler)
+ - Added inline diff styling for `change_title` system notes. (Adam Butler)
+ - Project#open_branches has been cleaned up and no longer loads entire records into memory.
+ - Escape HTML in commit titles in system note messages
+ - Improve design of Pipeline View
+ - Fix scope used when accessing container registry
+ - Fix creation of Ci::Commit object which can lead to pending, failed in some scenarios
+ - Improve multiple branch push performance by memoizing permission checking
+ - Log to application.log when an admin starts and stops impersonating a user
+ - Changing the confidentiality of an issue now creates a new system note (Alex Moore-Niemi)
+ - Updated gitlab_git to 10.1.0
+ - GitAccess#protected_tag? no longer loads all tags just to check if a single one exists
+ - Reduce delay in destroying a project from 1-minute to immediately
+ - Make build status canceled if any of the jobs was canceled and none failed
+ - Upgrade Sidekiq to 4.1.2
+ - Added /health_check endpoint for checking service status
+ - Make 'upcoming' filter for milestones work better across projects
+ - Sanitize repo paths in new project error message
+ - Bump mail_room to 0.7.0 to fix stuck IDLE connections
+ - Remove future dates from contribution calendar graph.
+ - Support e-mail notifications for comments on project snippets
+ - Fix API leak of notes of unauthorized issues, snippets and merge requests
+ - Use ActionDispatch Remote IP for Akismet checking
+ - Fix error when visiting commit builds page before build was updated
+ - Add 'l' shortcut to open Label dropdown on issuables and 'i' to create new issue on a project
+ - Update SVG sanitizer to conform to SVG 1.1
+ - Speed up push emails with multiple recipients by only generating the email once
+ - Updated search UI
+ - Added authentication service for Container Registry
+ - Display informative message when new milestone is created
+ - Sanitize milestones and labels titles
+ - Support multi-line tag messages. !3833 (Calin Seciu)
+ - Force users to reset their password after an admin changes it
+ - Allow "NEWS" and "CHANGES" as alternative names for CHANGELOG. !3768 (Connor Shea)
+ - Added button to toggle whitespaces changes on diff view
+ - Backport GitHub Enterprise import support from EE
+ - Create tags using Rugged for performance reasons. !3745
+ - Allow guests to set notification level in projects
+ - API: Expose Issue#user_notes_count. !3126 (Anton Popov)
+ - Don't show forks button when user can't view forks
+ - Fix atom feed links and rendering
+ - Files over 5MB can only be viewed in their raw form, files over 1MB without highlighting !3718
+ - Add support for supressing text diffs using .gitattributes on the default branch (Matt Oakes)
+ - Add eager load paths to help prevent dependency load issues in Sidekiq workers. !3724
+ - Added multiple colors for labels in dropdowns when dups happen.
+ - Show commits in the same order as `git log`
+ - Improve description for the Two-factor Authentication sign-in screen. (Connor Shea)
+ - API support for the 'since' and 'until' operators on commit requests (Paco Guzman)
+ - Fix Gravatar hint in user profile when Gravatar is disabled. !3988 (Artem Sidorenko)
+ - Expire repository exists? and has_visible_content? caches after a push if necessary
+ - Fix unintentional filtering bug in Issue/MR sorted by milestone due (Takuya Noguchi)
+ - Fix adding a todo for private group members (Ahmad Sherif)
+ - Bump ace-rails-ap gem version from 2.0.1 to 4.0.2 which upgrades Ace Editor from 1.1.2 to 1.2.3
+ - Total method execution timings are no longer tracked
+ - Allow Admins to remove the Login with buttons for OAuth services and still be able to import !4034. (Andrei Gliga)
+ - Add API endpoints for un/subscribing from/to a label. !4051 (Ahmad Sherif)
+ - Hide left sidebar on phone screens to give more space for content
+ - Redesign navigation for profile and group pages
+ - Add counter metrics for rails cache
+ - Import pull requests from GitHub where the source or target branches were removed
+ - All Grape API helpers are now instrumented
+ - Improve Issue formatting for the Slack Service (Jeroen van Baarsen)
+ - Fixed advice on invalid permissions on upload path !2948 (Ludovic Perrine)
+ - Allows MR authors to have the source branch removed when merging the MR. !2801 (Jeroen Jacobs)
+ - When creating a .gitignore file a dropdown with templates will be provided
+ - Shows the issue/MR list search/filter form and corrects the mobile styling for guest users. #17562
+
+## 8.7.9
+
+ - Fix privilege escalation issue with OAuth external users.
+ - Ensure references to private repos aren't shown to logged-out users.
+
+## 8.7.8
+
+ - Fix visibility of snippets when searching.
+ - Update omniauth-saml to 1.6.0 !4951
+
+## 8.7.7
+
+ - Fix import by `Any Git URL` broken if the URL contains a space
+ - Prevent unauthorized access to other projects build traces
+ - Forbid scripting for wiki files
+ - Only show notes through JSON on confidential issues that the user has access to
+
+## 8.7.6
+
+ - Fix links on wiki pages for relative url setups. !4131 (Artem Sidorenko)
+ - Fix import from GitLab.com to a private instance failure. !4181
+ - Fix external imports not finding the import data. !4106
+ - Fix notification delay when changing status of an issue
+ - Bump Workhorse to 0.7.5 so it can serve raw diffs
+
+## 8.7.5
+
+ - Fix relative links in wiki pages. !4050
+ - Fix always showing build notification message when switching between merge requests !4086
+ - Fix an issue when filtering merge requests with more than one label. !3886
+ - Fix short note for the default scope on build page (Takuya Noguchi)
+
+## 8.7.4
+
+ - Links for Redmine issue references are generated correctly again !4048 (Benedikt Huss)
+ - Fix setting trusted proxies !3970
+ - Fix BitBucket importer bug when throwing exceptions !3941
+ - Use sign out path only if not empty !3989
+ - Running rake gitlab:db:drop_tables now drops tables with cascade !4020
+ - Running rake gitlab:db:drop_tables uses "IF EXISTS" as a precaution !4100
+ - Use a case-insensitive comparison in sanitizing URI schemes
+
+## 8.7.3
+
+ - Emails, Gitlab::Email::Message, Gitlab::Diff, and Premailer::Adapter::Nokogiri are now instrumented
+ - Merge request widget displays TeamCity build state and code coverage correctly again.
+ - Fix the line code when importing PR review comments from GitHub. !4010
+ - Wikis are now initialized on legacy projects when checking repositories
+ - Remove animate.css in favor of a smaller subset of animations. !3937 (Connor Shea)
+
+## 8.7.2
+
+ - The "New Branch" button is now loaded asynchronously
+ - Fix error 500 when trying to create a wiki page
+ - Updated spacing between notification label and button
+ - Label titles in filters are now escaped properly
+
+## 8.7.1
+
+ - Throttle the update of `project.last_activity_at` to 1 minute. !3848
+ - Fix .gitlab-ci.yml parsing issue when hidde job is a template without script definition. !3849
+ - Fix license detection to detect all license files, not only known licenses. !3878
+ - Use the `can?` helper instead of `current_user.can?`. !3882
+ - Prevent users from deleting Webhooks via API they do not own
+ - Fix Error 500 due to stale cache when projects are renamed or transferred
+ - Update width of search box to fix Safari bug. !3900 (Jedidiah)
+ - Use the `can?` helper instead of `current_user.can?`
+
+## 8.7.0 (2016-04-22)
+
+ - Gitlab::GitAccess and Gitlab::GitAccessWiki are now instrumented
+ - Fix vulnerability that made it possible to gain access to private labels and milestones
+ - The number of InfluxDB points stored per UDP packet can now be configured
+ - Fix error when cross-project label reference used with non-existent project
+ - Transactions for /internal/allowed now have an "action" tag set
+ - Method instrumentation now uses Module#prepend instead of aliasing methods
+ - Repository.clean_old_archives is now instrumented
+ - Add support for environment variables on a job level in CI configuration file
+ - SQL query counts are now tracked per transaction
+ - The Projects::HousekeepingService class has extra instrumentation
+ - All service classes (those residing in app/services) are now instrumented
+ - Developers can now add custom tags to transactions
+ - Loading of an issue's referenced merge requests and related branches is now done asynchronously
+ - Enable gzip for assets, makes the page size significantly smaller. !3544 / !3632 (Connor Shea)
+ - Add support to cherry-pick any commit into any branch in the web interface (Minqi Pan)
+ - Project switcher uses new dropdown styling
+ - Load award emoji images separately unless opening the full picker. Saves several hundred KBs of data for most pages. (Connor Shea)
+ - Do not include award_emojis in issue and merge_request comment_count !3610 (Lucas Charles)
+ - Restrict user profiles when public visibility level is restricted.
+ - Add ability set due date to issues, sort and filter issues by due date (Mehmet Beydogan)
+ - All images in discussions and wikis now link to their source files !3464 (Connor Shea).
+ - Return status code 303 after a branch DELETE operation to avoid project deletion (Stan Hu)
+ - Add setting for customizing the list of trusted proxies !3524
+ - Allow projects to be transfered to a lower visibility level group
+ - Fix `signed_in_ip` being set to 127.0.0.1 when using a reverse proxy !3524
+ - Improved Markdown rendering performance !3389
+ - Make shared runners text in box configurable
+ - Don't attempt to look up an avatar in repo if repo directory does not exist (Stan Hu)
+ - API: Ability to subscribe and unsubscribe from issues and merge requests (Robert Schilling)
+ - Expose project badges in project settings
+ - Make /profile/keys/new redirect to /profile/keys for back-compat. !3717
+ - Preserve time notes/comments have been updated at when moving issue
+ - Make HTTP(s) label consistent on clone bar (Stan Hu)
+ - Add support for `after_script`, requires Runner 1.2 (Kamil Trzciński)
+ - Expose label description in API (Mariusz Jachimowicz)
+ - API: Ability to update a group (Robert Schilling)
+ - API: Ability to move issues (Robert Schilling)
+ - Fix Error 500 after renaming a project path (Stan Hu)
+ - Fix a bug whith trailing slash in teamcity_url (Charles May)
+ - Allow back dating on issues when created or updated through the API
+ - Allow back dating on issue notes when created through the API
+ - Propose license template when creating a new LICENSE file
+ - API: Expose /licenses and /licenses/:key
+ - Fix avatar stretching by providing a cropping feature
+ - API: Expose `subscribed` for issues and merge requests (Robert Schilling)
+ - Allow SAML to handle external users based on user's information !3530
+ - Allow Omniauth providers to be marked as `external` !3657
+ - Add endpoints to archive or unarchive a project !3372
+ - Fix a bug whith trailing slash in bamboo_url
+ - Add links to CI setup documentation from project settings and builds pages
+ - Display project members page to all members
+ - Handle nil descriptions in Slack issue messages (Stan Hu)
+ - Add automated repository integrity checks (OFF by default)
+ - API: Expose open_issues_count, closed_issues_count, open_merge_requests_count for labels (Robert Schilling)
+ - API: Ability to star and unstar a project (Robert Schilling)
+ - Add default scope to projects to exclude projects pending deletion
+ - Allow to close merge requests which source projects(forks) are deleted.
+ - Ensure empty recipients are rejected in BuildsEmailService
+ - Use rugged to change HEAD in Project#change_head (P.S.V.R)
+ - API: Ability to filter milestones by state `active` and `closed` (Robert Schilling)
+ - API: Fix milestone filtering by `iid` (Robert Schilling)
+ - Make before_script and after_script overridable on per-job (Kamil Trzciński)
+ - API: Delete notes of issues, snippets, and merge requests (Robert Schilling)
+ - Implement 'Groups View' as an option for dashboard preferences !3379 (Elias W.)
+ - Better errors handling when creating milestones inside groups
+ - Fix high CPU usage when PostReceive receives refs/merge-requests/<id>
+ - Hide `Create a group` help block when creating a new project in a group
+ - Implement 'TODOs View' as an option for dashboard preferences !3379 (Elias W.)
+ - Allow issues and merge requests to be assigned to the author !2765
+ - Make Ci::Commit to group only similar builds and make it stateful (ref, tag)
+ - Gracefully handle notes on deleted commits in merge requests (Stan Hu)
+ - Decouple membership and notifications
+ - Fix creation of merge requests for orphaned branches (Stan Hu)
+ - API: Ability to retrieve a single tag (Robert Schilling)
+ - While signing up, don't persist the user password across form redisplays
+ - Fall back to `In-Reply-To` and `References` headers when sub-addressing is not available (David Padilla)
+ - Remove "Congratulations!" tweet button on newly-created project. (Connor Shea)
+ - Fix admin/projects when using visibility levels on search (PotHix)
+ - Build status notifications
+ - Update email confirmation interface
+ - API: Expose user location (Robert Schilling)
+ - API: Do not leak group existence via return code (Robert Schilling)
+ - ClosingIssueExtractor regex now also works with colons. e.g. "Fixes: #1234" !3591
+ - Update number of Todos in the sidebar when it's marked as "Done". !3600
+ - Sanitize branch names created for confidential issues
+ - API: Expose 'updated_at' for issue, snippet, and merge request notes (Robert Schilling)
+ - API: User can leave a project through the API when not master or owner. !3613
+ - Fix repository cache invalidation issue when project is recreated with an empty repo (Stan Hu)
+ - Fix: Allow empty recipients list for builds emails service when pushed is added (Frank Groeneveld)
+ - Improved markdown forms
+ - Diff design updates (colors, button styles, etc)
+ - Copying and pasting a diff no longer pastes the line numbers or +/-
+ - Add null check to formData when updating profile content to fix Firefox bug
+ - Disable spellcheck and autocorrect for username field in admin page
+ - Delete tags using Rugged for performance reasons (Robert Schilling)
+ - Add Slack notifications when Wiki is edited (Sebastian Klier)
+ - Diffs load at the correct point when linking from from number
+ - Selected diff rows highlight
+ - Fix emoji categories in the emoji picker
+ - API: Properly display annotated tags for GET /projects/:id/repository/tags (Robert Schilling)
+ - Add encrypted credentials for imported projects and migrate old ones
+ - Properly format all merge request references with ! rather than # !3740 (Ben Bodenmiller)
+ - Author and participants are displayed first on users autocompletion
+ - Show number sign on external issue reference text (Florent Baldino)
+ - Updated print style for issues
+ - Use GitHub Issue/PR number as iid to keep references
+ - Import GitHub labels
+ - Add option to filter by "Owned projects" on dashboard page
+ - Import GitHub milestones
+ - Execute system web hooks on push to the project
+ - Allow enable/disable push events for system hooks
+ - Fix GitHub project's link in the import page when provider has a custom URL
+ - Add RAW build trace output and button on build page
+ - Add incremental build trace update into CI API
+
+## 8.6.9
+
+ - Prevent unauthorized access to other projects build traces
+ - Forbid scripting for wiki files
+ - Only show notes through JSON on confidential issues that the user has access to
+
+## 8.6.8
+
+ - Prevent privilege escalation via "impersonate" feature
+ - Prevent privilege escalation via notes API
+ - Prevent privilege escalation via project webhook API
+ - Prevent XSS via Git branch and tag names
+ - Prevent XSS via custom issue tracker URL
+ - Prevent XSS via `window.opener`
+ - Prevent XSS via label drop-down
+ - Prevent information disclosure via milestone API
+ - Prevent information disclosure via snippet API
+ - Prevent information disclosure via project labels
+ - Prevent information disclosure via new merge request page
+
+## 8.6.7
+
+ - Fix persistent XSS vulnerability in `commit_person_link` helper
+ - Fix persistent XSS vulnerability in Label and Milestone dropdowns
+ - Fix vulnerability that made it possible to enumerate private projects belonging to group
+
+## 8.6.6
+
+ - Expire the exists cache before deletion to ensure project dir actually exists (Stan Hu). !3413
+ - Fix error on language detection when repository has no HEAD (e.g., master branch) (Jeroen Bobbeldijk). !3654
+ - Fix revoking of authorized OAuth applications (Connor Shea). !3690
+ - Fix error on language detection when repository has no HEAD (e.g., master branch). !3654 (Jeroen Bobbeldijk)
+ - Issuable header is consistent between issues and merge requests
+ - Improved spacing in issuable header on mobile
+
+## 8.6.5
+
+ - Fix importing from GitHub Enterprise. !3529
+ - Perform the language detection after updating merge requests in `GitPushService`, leading to faster visual feedback for the end-user. !3533
+ - Check permissions when user attempts to import members from another project. !3535
+ - Only update repository language if it is not set to improve performance. !3556
+ - Return status code 303 after a branch DELETE operation to avoid project deletion (Stan Hu). !3583
+ - Unblock user when active_directory is disabled and it can be found !3550
+ - Fix a 2FA authentication spoofing vulnerability.
+
+## 8.6.4
+
+ - Don't attempt to fetch any tags from a forked repo (Stan Hu)
+ - Redesign the Labels page
+
+## 8.6.3
+
+ - Mentions on confidential issues doesn't create todos for non-members. !3374
+ - Destroy related todos when an Issue/MR is deleted. !3376
+ - Fix error 500 when target is nil on todo list. !3376
+ - Fix copying uploads when moving issue to another project. !3382
+ - Ensuring Merge Request API returns boolean values for work_in_progress (Abhi Rao). !3432
+ - Fix raw/rendered diff producing different results on merge requests. !3450
+ - Fix commit comment alignment (Stan Hu). !3466
+ - Fix Error 500 when searching for a comment in a project snippet. !3468
+ - Allow temporary email as notification email. !3477
+ - Fix issue with dropdowns not selecting values. !3478
+ - Update gitlab-shell version and doc to 2.6.12. gitlab-org/gitlab-ee!280
+
+## 8.6.2
+
+ - Fix dropdown alignment. !3298
+ - Fix issuable sidebar overlaps on tablet. !3299
+ - Make dropdowns pixel perfect. !3337
+ - Fix order of steps to prevent PostgreSQL errors when running migration. !3355
+ - Fix bold text in issuable sidebar. !3358
+ - Fix error with anonymous token in applications settings. !3362
+ - Fix the milestone 'upcoming' filter. !3364 + !3368
+ - Fix comments on confidential issues showing up in activity feed to non-members. !3375
+ - Fix `NoMethodError` when visiting CI root path at `/ci`. !3377
+ - Add a tooltip to new branch button in issue page. !3380
+ - Fix an issue hiding the password form when signed-in with a linked account. !3381
+ - Add links to CI setup documentation from project settings and builds pages. !3384
+ - Fix an issue with width of project select dropdown. !3386
+ - Remove redundant `require`s from Banzai files. !3391
+ - Fix error 500 with cancel button on issuable edit form. !3392 + !3417
+ - Fix background when editing a highlighted note. !3423
+ - Remove tabstop from the WIP toggle links. !3426
+ - Ensure private project snippets are not viewable by unauthorized people.
+ - Gracefully handle notes on deleted commits in merge requests (Stan Hu). !3402
+ - Fixed issue with notification settings not saving. !3452
+
+## 8.6.1
+
+ - Add option to reload the schema before restoring a database backup. !2807
+ - Display navigation controls on mobile. !3214
+ - Fixed bug where participants would not work correctly on merge requests. !3329
+ - Fix sorting issues by votes on the groups issues page results in SQL errors. !3333
+ - Restrict notifications for confidential issues. !3334
+ - Do not allow to move issue if it has not been persisted. !3340
+ - Add a confirmation step before deleting an issuable. !3341
+ - Fixes issue with signin button overflowing on mobile. !3342
+ - Auto collapses the navigation sidebar when resizing. !3343
+ - Fix build dependencies, when the dependency is a string. !3344
+ - Shows error messages when trying to create label in dropdown menu. !3345
+ - Fixes issue with assign milestone not loading milestone list. !3346
+ - Fix an issue causing the Dashboard/Milestones page to be blank. !3348
+
+## 8.6.0 (2016-03-22)
+
+ - Add ability to move issue to another project
+ - Prevent tokens in the import URL to be showed by the UI
+ - Fix bug where wrong commit ID was being used in a merge request diff to show old image (Stan Hu)
+ - Add confidential issues
+ - Bump gitlab_git to 9.0.3 (Stan Hu)
+ - Fix diff image view modes (2-up, swipe, onion skin) not working (Stan Hu)
+ - Support Golang subpackage fetching (Stan Hu)
+ - Bump Capybara gem to 2.6.2 (Stan Hu)
+ - New branch button appears on issues where applicable
+ - Contributions to forked projects are included in calendar
+ - Improve the formatting for the user page bio (Connor Shea)
+ - Easily (un)mark merge request as WIP using link
+ - Use specialized system notes when MR is (un)marked as WIP
+ - Removed the default password from the initial admin account created during
+ setup. A password can be provided during setup (see installation docs), or
+ GitLab will ask the user to create a new one upon first visit.
+ - Fix issue when pushing to projects ending in .wiki
+ - Properly display YAML front matter in Markdown
+ - Add support for wiki with UTF-8 page names (Hiroyuki Sato)
+ - Fix wiki search results point to raw source (Hiroyuki Sato)
+ - Don't load all of GitLab in mail_room
+ - Add information about `image` and `services` field at `job` level in the `.gitlab-ci.yml` documentation (Pat Turner)
+ - HTTP error pages work independently from location and config (Artem Sidorenko)
+ - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set
+ - Memoize @group in Admin::GroupsController (Yatish Mehta)
+ - Indicate how much an MR diverged from the target branch (Pierre de La Morinerie)
+ - Added omniauth-auth0 Gem (Daniel Carraro)
+ - Add label description in tooltip to labels in issue index and sidebar
+ - Strip leading and trailing spaces in URL validator (evuez)
+ - Add "last_sign_in_at" and "confirmed_at" to GET /users/* API endpoints for admins (evuez)
+ - Return empty array instead of 404 when commit has no statuses in commit status API
+ - Decrease the font size and the padding of the `.anchor` icons used in the README (Roberto Dip)
+ - Rewrite logo to simplify SVG code (Sean Lang)
+ - Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach)
+ - Ignore jobs that start with `.` (hidden jobs)
+ - Hide builds from project's settings when the feature is disabled
+ - Allow to pass name of created artifacts archive in `.gitlab-ci.yml`
+ - Refactor and greatly improve search performance
+ - Add support for cross-project label references
+ - Ensure "new SSH key" email do not ends up as dead Sidekiq jobs
+ - Update documentation to reflect Guest role not being enforced on internal projects
+ - Allow search for logged out users
+ - Allow to define on which builds the current one depends on
+ - Allow user subscription to a label: get notified for issues/merge requests related to that label (Timothy Andrew)
+ - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio)
+ - Don't show Issues/MRs from archived projects in Groups view
+ - Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs
+ - Fix empty source_sha on Merge Request when there is no diff (Pierre de La Morinerie)
+ - Increase the notes polling timeout over time (Roberto Dip)
+ - Add shortcut to toggle markdown preview (Florent Baldino)
+ - Show labels in dashboard and group milestone views
+ - Fix an issue when the target branch of a MR had been deleted
+ - Add main language of a project in the list of projects (Tiago Botelho)
+ - Add #upcoming filter to Milestone filter (Tiago Botelho)
+ - Add ability to show archived projects on dashboard, explore and group pages
+ - Remove fork link closes all merge requests opened on source project (Florent Baldino)
+ - Move group activity to separate page
+ - Create external users which are excluded of internal and private projects unless access was explicitly granted
+ - Continue parameters are checked to ensure redirection goes to the same instance
+ - User deletion is now done in the background so the request can not time out
+ - Canceled builds are now ignored in compound build status if marked as `allowed to fail`
+ - Trigger a todo for mentions on commits page
+ - Let project owners and admins soft delete issues and merge requests
+
+## 8.5.13
+
+ - Prevent unauthorized access to other projects build traces
+ - Forbid scripting for wiki files
+
+## 8.5.12
+
+ - Prevent privilege escalation via "impersonate" feature
+ - Prevent privilege escalation via notes API
+ - Prevent privilege escalation via project webhook API
+ - Prevent XSS via Git branch and tag names
+ - Prevent XSS via custom issue tracker URL
+ - Prevent XSS via `window.opener`
+ - Prevent information disclosure via snippet API
+ - Prevent information disclosure via project labels
+ - Prevent information disclosure via new merge request page
+
+## 8.5.11
+
+ - Fix persistent XSS vulnerability in `commit_person_link` helper
+
+## 8.5.10
+
+ - Fix a 2FA authentication spoofing vulnerability.
+
+## 8.5.9
+
+ - Don't attempt to fetch any tags from a forked repo (Stan Hu).
+
+## 8.5.8
+
+ - Bump Git version requirement to 2.7.4
+
+## 8.5.7
+
+ - Bump Git version requirement to 2.7.3
+
+## 8.5.6
+
+ - Obtain a lease before querying LDAP
+
+## 8.5.5
+
+ - Ensure removing a project removes associated Todo entries
+ - Prevent a 500 error in Todos when author was removed
+ - Fix pagination for filtered dashboard and explore pages
+ - Fix "Show all" link behavior
+
+## 8.5.4
+
+ - Do not cache requests for badges (including builds badge)
+
+## 8.5.3
+
+ - Flush repository caches before renaming projects
+ - Sort starred projects on dashboard based on last activity by default
+ - Show commit message in JIRA mention comment
+ - Makes issue page and merge request page usable on mobile browsers.
+ - Improved UI for profile settings
+
+## 8.5.2
+
+ - Fix sidebar overlapping content when screen width was below 1200px
+ - Don't repeat labels listed on Labels tab
+ - Bring the "branded appearance" feature from EE to CE
+ - Fix error 500 when commenting on a commit
+ - Show days remaining instead of elapsed time for Milestone
+ - Fix broken icons on installations with relative URL (Artem Sidorenko)
+ - Fix issue where tag list wasn't refreshed after deleting a tag
+ - Fix import from gitlab.com (KazSawada)
+ - Improve implementation to check read access to forks and add pagination
+ - Don't show any "2FA required" message if it's not actually required
+ - Fix help keyboard shortcut on relative URL setups (Artem Sidorenko)
+ - Update Rails to 4.2.5.2
+ - Fix permissions for deprecated CI build status badge
+ - Don't show "Welcome to GitLab" when the search didn't return any projects
+ - Add Todos documentation
+
+## 8.5.1
+
+ - Fix group projects styles
+ - Show Crowd login tab when sign in is disabled and Crowd is enabled (Peter Hudec)
+ - Fix a set of small UI glitches in project, profile, and wiki pages
+ - Restrict permissions on public/uploads
+ - Fix the merge request side-by-side view after loading diff results
+ - Fix the look of tooltip for the "Revert" button
+ - Add when the Builds & Runners API changes got introduced
+ - Fix error 500 on some merged merge requests
+ - Fix an issue causing the content of the issuable sidebar to disappear
+ - Fix error 500 when trying to mark an already done todo as "done"
+ - Fix an issue where MRs weren't sortable
+ - Issues can now be dragged & dropped into empty milestone lists. This is also
+ possible with MRs
+ - Changed padding & background color for highlighted notes
+ - Re-add the newrelic_rpm gem which was removed without any deprecation or warning (Stan Hu)
+ - Update sentry-raven gem to 0.15.6
+ - Add build coverage in project's builds page (Steffen Köhler)
+ - Changed # to ! for merge requests in activity view
+
+## 8.5.0 (2016-02-22)
+
+ - Fix duplicate "me" in tooltip of the "thumbsup" awards Emoji (Stan Hu)
+ - Cache various Repository methods to improve performance
+ - Fix duplicated branch creation/deletion Webhooks/service notifications when using Web UI (Stan Hu)
+ - Ensure rake tasks that don't need a DB connection can be run without one
+ - Update New Relic gem to 3.14.1.311 (Stan Hu)
+ - Add "visibility" flag to GET /projects api endpoint
+ - Add an option to supply root email through an environmental variable (Koichiro Mikami)
+ - Ignore binary files in code search to prevent Error 500 (Stan Hu)
+ - Render sanitized SVG images (Stan Hu)
+ - Support download access by PRIVATE-TOKEN header (Stan Hu)
+ - Upgrade gitlab_git to 7.2.23 to fix commit message mentions in first branch push
+ - Add option to include the sender name in body of Notify email (Jason Lee)
+ - New UI for pagination
+ - Don't prevent sign out when 2FA enforcement is enabled and user hasn't yet
+ set it up
+ - API: Added "merge_requests/:merge_request_id/closes_issues" (Gal Schlezinger)
+ - Fix diff comments loaded by AJAX to load comment with diff in discussion tab
+ - Fix relative links in other markup formats (Ben Boeckel)
+ - Whitelist raw "abbr" elements when parsing Markdown (Benedict Etzel)
+ - Fix label links for a merge request pointing to issues list
+ - Don't vendor minified JS
+ - Increase project import timeout to 15 minutes
+ - Be more permissive with email address validation: it only has to contain a single '@'
+ - Display 404 error on group not found
+ - Track project import failure
+ - Support Two-factor Authentication for LDAP users
+ - Display database type and version in Administration dashboard
+ - Allow limited Markdown in Broadcast Messages
+ - Fix visibility level text in admin area (Zeger-Jan van de Weg)
+ - Warn admin during OAuth of granting admin rights (Zeger-Jan van de Weg)
+ - Update the ExternalIssue regex pattern (Blake Hitchcock)
+ - Remember user's inline/side-by-side diff view preference in a cookie (Kirill Katsnelson)
+ - Optimized performance of finding issues to be closed by a merge request
+ - Add `avatar_url`, `description`, `git_ssh_url`, `git_http_url`, `path_with_namespace`
+ and `default_branch` in `project` in push, issue, merge-request and note webhooks data (Kirill Zaitsev)
+ - Deprecate the `ssh_url` in favor of `git_ssh_url` and `http_url` in favor of `git_http_url`
+ in `project` for push, issue, merge-request and note webhooks data (Kirill Zaitsev)
+ - Deprecate the `repository` key in push, issue, merge-request and note webhooks data, use `project` instead (Kirill Zaitsev)
+ - API: Expose MergeRequest#merge_status (Andrei Dziahel)
+ - Revert "Add IP check against DNSBLs at account sign-up"
+ - Actually use the `skip_merges` option in Repository#commits (Tony Chu)
+ - Fix API to keep request parameters in Link header (Michael Potthoff)
+ - Deprecate API "merge_request/:merge_request_id/comments". Use "merge_requests/:merge_request_id/notes" instead
+ - Deprecate API "merge_request/:merge_request_id/...". Use "merge_requests/:merge_request_id/..." instead
+ - Prevent parse error when name of project ends with .atom and prevent path issues
+ - Discover branches for commit statuses ref-less when doing merge when succeeded
+ - Mark inline difference between old and new paths when a file is renamed
+ - Support Akismet spam checking for creation of issues via API (Stan Hu)
+ - API: Allow to set or update a merge-request's milestone (Kirill Skachkov)
+ - Improve UI consistency between projects and groups lists
+ - Add sort dropdown to dashboard projects page
+ - Fixed logo animation on Safari (Roman Rott)
+ - Fix Merge When Succeeded when multiple stages
+ - Hide remove source branch button when the MR is merged but new commits are pushed (Zeger-Jan van de Weg)
+ - In seach autocomplete show only groups and projects you are member of
+ - Don't process cross-reference notes from forks
+ - Fix: init.d script not working on OS X
+ - Faster snippet search
+ - Added API to download build artifacts
+ - Title for milestones should be unique (Zeger-Jan van de Weg)
+ - Validate correctness of maximum attachment size application setting
+ - Replaces "Create merge request" link with one to the "Merge Request" when one exists
+ - Fix CI builds badge, add a new link to builds badge, deprecate the old one
+ - Fix broken link to project in build notification emails
+ - Ability to see and sort on vote count from Issues and MR lists
+ - Fix builds scheduler when first build in stage was allowed to fail
+ - User project limit is reached notice is hidden if the projects limit is zero
+ - Add API support for managing runners and project's runners
+ - Allow SAML users to login with no previous account without having to allow
+ all Omniauth providers to do so.
+ - Allow existing users to auto link their SAML credentials by logging in via SAML
+ - Make it possible to erase a build (trace, artifacts) using UI and API
+ - Ability to revert changes from a Merge Request or Commit
+ - Emoji comment on diffs are not award emoji
+ - Add label description (Nuttanart Pornprasitsakul)
+ - Show label row when filtering issues or merge requests by label (Nuttanart Pornprasitsakul)
+ - Add Todos
+
+## 8.4.11
+
+ - Prevent unauthorized access to other projects build traces
+ - Forbid scripting for wiki files
+
+## 8.4.10
+
+ - Prevent privilege escalation via "impersonate" feature
+ - Prevent privilege escalation via notes API
+ - Prevent privilege escalation via project webhook API
+ - Prevent XSS via Git branch and tag names
+ - Prevent XSS via custom issue tracker URL
+ - Prevent XSS via `window.opener`
+ - Prevent information disclosure via snippet API
+ - Prevent information disclosure via project labels
+ - Prevent information disclosure via new merge request page
+
+## 8.4.9
+
+ - Fix persistent XSS vulnerability in `commit_person_link` helper
+
+## 8.4.8
+
+ - Fix a 2FA authentication spoofing vulnerability.
+
+## 8.4.7
+
+ - Don't attempt to fetch any tags from a forked repo (Stan Hu).
+
+## 8.4.6
+
+ - Bump Git version requirement to 2.7.4
+
+## 8.4.5
+
+ - No CE-specific changes
+
+## 8.4.4
+
+ - Update omniauth-saml gem to 1.4.2
+ - Prevent long-running backup tasks from timing out the database connection
+ - Add a Project setting to allow guests to view build logs (defaults to true)
+ - Sort project milestones by due date including issue editor (Oliver Rogers / Orih)
+
+## 8.4.3
+
+ - Increase lfs_objects size column to 8-byte integer to allow files larger
+ than 2.1GB
+ - Correctly highlight MR diff when MR has merge conflicts
+ - Fix highlighting in blame view
+ - Update sentry-raven gem to prevent "Not a git repository" console output
+ when running certain commands
+ - Add instrumentation to additional Gitlab::Git and Rugged methods for
+ performance monitoring
+ - Allow autosize textareas to also be manually resized
+
+## 8.4.2
+
+ - Bump required gitlab-workhorse version to bring in a fix for missing
+ artifacts in the build artifacts browser
+ - Get rid of those ugly borders on the file tree view
+ - Fix updating the runner information when asking for builds
+ - Bump gitlab_git version to 7.2.24 in order to bring in a performance
+ improvement when checking if a repository was empty
+ - Add instrumentation for Gitlab::Git::Repository instance methods so we can
+ track them in Performance Monitoring.
+ - Increase contrast between highlighted code comments and inline diff marker
+ - Fix method undefined when using external commit status in builds
+ - Fix highlighting in blame view.
+
+## 8.4.1
+
+ - Apply security updates for Rails (4.2.5.1), rails-html-sanitizer (1.0.3),
+ and Nokogiri (1.6.7.2)
+ - Fix redirect loop during import
+ - Fix diff highlighting for all syntax themes
+ - Delete project and associations in a background worker
+
+## 8.4.0 (2016-01-22)
+
+ - Allow LDAP users to change their email if it was not set by the LDAP server
+ - Ensure Gravatar host looks like an actual host
+ - Consider re-assign as a mention from a notification point of view
+ - Add pagination headers to already paginated API resources
+ - Properly generate diff of orphan commits, like the first commit in a repository
+ - Improve the consistency of commit titles, branch names, tag names, issue/MR titles, on their respective project pages
+ - Autocomplete data is now always loaded, instead of when focusing a comment text area
+ - Improved performance of finding issues for an entire group
+ - Added custom application performance measuring system powered by InfluxDB
+ - Add syntax highlighting to diffs
+ - Gracefully handle invalid UTF-8 sequences in Markdown links (Stan Hu)
+ - Bump fog to 1.36.0 (Stan Hu)
+ - Add user's last used IP addresses to admin page (Stan Hu)
+ - Add housekeeping function to project settings page
+ - The default GitLab logo now acts as a loading indicator
+ - Fix caching issue where build status was not updating in project dashboard (Stan Hu)
+ - Accept 2xx status codes for successful Webhook triggers (Stan Hu)
+ - Fix missing date of month in network graph when commits span a month (Stan Hu)
+ - Expire view caches when application settings change (e.g. Gravatar disabled) (Stan Hu)
+ - Don't notify users twice if they are both project watchers and subscribers (Stan Hu)
+ - Remove gray background from layout in UI
+ - Fix signup for OAuth providers that don't provide a name
+ - Implement new UI for group page
+ - Implement search inside emoji picker
+ - Let the CI runner know about builds that this build depends on
+ - Add API support for looking up a user by username (Stan Hu)
+ - Add project permissions to all project API endpoints (Stan Hu)
+ - Link to milestone in "Milestone changed" system note
+ - Only allow group/project members to mention `@all`
+ - Expose Git's version in the admin area (Trey Davis)
+ - Add "Frequently used" category to emoji picker
+ - Add CAS support (tduehr)
+ - Add link to merge request on build detail page
+ - Fix: Problem with projects ending with .keys (Jose Corcuera)
+ - Revert back upvote and downvote button to the issue and MR pages
+ - Swap position of Assignee and Author selector on Issuables (Zeger-Jan van de Weg)
+ - Add system hook messages for project rename and transfer (Steve Norman)
+ - Fix version check image in Safari
+ - Show 'All' tab by default in the builds page
+ - Add Open Graph and Twitter Card data to all pages
+ - Fix API project lookups when querying with a namespace with dots (Stan Hu)
+ - Enable forcing Two-factor authentication sitewide, with optional grace period
+ - Import GitHub Pull Requests into GitLab
+ - Change single user API endpoint to return more detailed data (Michael Potthoff)
+ - Update version check images to use SVG
+ - Validate README format before displaying
+ - Enable Microsoft Azure OAuth2 support (Janis Meybohm)
+ - Properly set task-list class on single item task lists
+ - Add file finder feature in tree view (Kyungchul Shin)
+ - Ajax filter by message for commits page
+ - API: Add support for deleting a tag via the API (Robert Schilling)
+ - Allow subsequent validations in CI Linter
+ - Show referenced MRs & Issues only when the current viewer can access them
+ - Fix Encoding::CompatibilityError bug when markdown content has some complex URL (Jason Lee)
+ - Add API support for managing project's builds
+ - Add API support for managing project's build triggers
+ - Add API support for managing project's build variables
+ - Allow broadcast messages to be edited
+ - Autosize Markdown textareas
+ - Import GitHub wiki into GitLab
+ - Add reporters ability to download and browse build artifacts (Andrew Johnson)
+ - Autofill referring url in message box when reporting user abuse.
+ - Remove leading comma on award emoji when the user is the first to award the emoji (Zeger-Jan van de Weg)
+ - Add build artifacts browser
+ - Improve UX in builds artifacts browser
+ - Increase default size of `data` column in `events` table when using MySQL
+ - Expose button to CI Lint tool on project builds page
+ - Fix: Creator should be added as a master of the project on creation
+ - Added X-GitLab-... headers to emails from CI and Email On Push services (Anton Baklanov)
+ - Add IP check against DNSBLs at account sign-up
+ - Added cache:key to .gitlab-ci.yml allowing to fine tune the caching
+
+## 8.3.10
+
+ - Prevent unauthorized access to other projects build traces
+ - Forbid scripting for wiki files
+
+## 8.3.9
+
+ - Prevent privilege escalation via "impersonate" feature
+ - Prevent privilege escalation via notes API
+ - Prevent privilege escalation via project webhook API
+ - Prevent XSS via custom issue tracker URL
+ - Prevent XSS via `window.opener`
+ - Prevent information disclosure via project labels
+ - Prevent information disclosure via new merge request page
+
+## 8.3.8
+
+ - Fix persistent XSS vulnerability in `commit_person_link` helper
+
+## 8.3.7
+
+ - Fix a 2FA authentication spoofing vulnerability.
+
+## 8.3.6
+
+ - Don't attempt to fetch any tags from a forked repo (Stan Hu).
+
+## 8.3.5
+
+ - Bump Git version requirement to 2.7.4
+
+## 8.3.4
+
+ - Use gitlab-workhorse 0.5.4 (fixes API routing bug)
+
+## 8.3.3
+
+ - Preserve CE behavior with JIRA integration by only calling API if URL is set
+ - Fix duplicated branch creation/deletion events when using Web UI (Stan Hu)
+ - Add configurable LDAP server query timeout
+ - Get "Merge when build succeeds" to work when commits were pushed to MR target branch while builds were running
+ - Suppress e-mails on failed builds if allow_failure is set (Stan Hu)
+ - Fix project transfer e-mail sending incorrect paths in e-mail notification (Stan Hu)
+ - Better support for referencing and closing issues in Asana service (Mike Wyatt)
+ - Enable "Add key" button when user fills in a proper key (Stan Hu)
+ - Fix error in processing reply-by-email messages (Jason Lee)
+ - Fix Error 500 when visiting build page of project with nil runners_token (Stan Hu)
+ - Use WOFF versions of SourceSansPro fonts
+ - Fix regression when builds were not generated for tags created through web/api interface
+ - Fix: maintain milestone filter between Open and Closed tabs (Greg Smethells)
+ - Fix missing artifacts and build traces for build created before 8.3
+
+## 8.3.2
+
+ - Disable --follow in `git log` to avoid loading duplicate commit data in infinite scroll (Stan Hu)
+ - Add support for Google reCAPTCHA in user registration
+
+## 8.3.1
+
+ - Fix Error 500 when global milestones have slashes (Stan Hu)
+ - Fix Error 500 when doing a search in dashboard before visiting any project (Stan Hu)
+ - Fix LDAP identity and user retrieval when special characters are used
+ - Move Sidekiq-cron configuration to gitlab.yml
+
+## 8.3.0 (2015-12-22)
+
+ - Bump rack-attack to 4.3.1 for security fix (Stan Hu)
+ - API support for starred projects for authorized user (Zeger-Jan van de Weg)
+ - Add open_issues_count to project API (Stan Hu)
+ - Expand character set of usernames created by Omniauth (Corey Hinshaw)
+ - Add button to automatically merge a merge request when the build succeeds (Zeger-Jan van de Weg)
+ - Add unsubscribe link in the email footer (Zeger-Jan van de Weg)
+ - Provide better diagnostic message upon project creation errors (Stan Hu)
+ - Bump devise to 3.5.3 to fix reset token expiring after account creation (Stan Hu)
+ - Remove api credentials from link to build_page
+ - Deprecate GitLabCiService making it to always be inactive
+ - Bump gollum-lib to 4.1.0 (Stan Hu)
+ - Fix broken group avatar upload under "New group" (Stan Hu)
+ - Update project repositorize size and commit count during import:repos task (Stan Hu)
+ - Fix API setting of 'public' attribute to false will make a project private (Stan Hu)
+ - Handle and report SSL errors in Webhook test (Stan Hu)
+ - Bump Redis requirement to 2.8 for Sidekiq 4 (Stan Hu)
+ - Fix: Assignee selector is empty when 'Unassigned' is selected (Jose Corcuera)
+ - WIP identifier on merge requests no longer requires trailing space
+ - Add rake tasks for git repository maintainance (Zeger-Jan van de Weg)
+ - Fix 500 error when update group member permission
+ - Fix: As an admin, cannot add oneself as a member to a group/project
+ - Trim leading and trailing whitespace of milestone and issueable titles (Jose Corcuera)
+ - Recognize issue/MR/snippet/commit links as references
+ - Backport JIRA features from EE to CE
+ - Add ignore whitespace change option to commit view
+ - Fire update hook from GitLab
+ - Allow account unlock via email
+ - Style warning about mentioning many people in a comment
+ - Fix: sort milestones by due date once again (Greg Smethells)
+ - Migrate all CI::Services and CI::WebHooks to Services and WebHooks
+ - Don't show project fork event as "imported"
+ - Add API endpoint to fetch merge request commits list
+ - Don't create CI status for refs that doesn't have .gitlab-ci.yml, even if the builds are enabled
+ - Expose events API with comment information and author info
+ - Fix: Ensure "Remove Source Branch" button is not shown when branch is being deleted. #3583
+ - Run custom Git hooks when branch is created or deleted.
+ - Fix bug when simultaneously accepting multiple MRs results in MRs that are of "merged" status, but not merged to the target branch
+ - Add languages page to graphs
+ - Block LDAP user when they are no longer found in the LDAP server
+ - Improve wording on project visibility levels (Zeger-Jan van de Weg)
+ - Fix editing notes on a merge request diff
+ - Automatically select default clone protocol based on user preferences (Eirik Lygre)
+ - Make Network page as sub tab of Commits
+ - Add copy-to-clipboard button for Snippets
+ - Add indication to merge request list item that MR cannot be merged automatically
+ - Default target branch to patch-n when editing file in protected branch
+ - Add Builds tab to merge request detail page
+ - Allow milestones, issues and MRs to be created from dashboard and group indexes
+ - Use new style for wiki
+ - Use new style for milestone detail page
+ - Fix sidebar tooltips when collapsed
+ - Prevent possible XSS attack with award-emoji
+ - Upgraded Sidekiq to 4.x
+ - Accept COPYING,COPYING.lesser, and licence as license file (Zeger-Jan van de Weg)
+ - Fix emoji aliases problem
+ - Fix award-emojis Flash alert's width
+ - Fix deleting notes on a merge request diff
+ - Display referenced merge request statuses in the issue description (Greg Smethells)
+ - Implement new sidebar for issue and merge request pages
+ - Emoji picker improvements
+ - Suppress warning about missing `.gitlab-ci.yml` if builds are disabled
+ - Do not show build status unless builds are enabled and `.gitlab-ci.yml` is present
+ - Persist runners registration token in database
+ - Fix online editor should not remove newlines at the end of the file
+ - Expose Git's version in the admin area
+ - Show "New Merge Request" buttons on canonical repos when you have a fork (Josh Frye)
+
+## 8.2.6
+
+ - Prevent unauthorized access to other projects build traces
+ - Forbid scripting for wiki files
+
+## 8.2.5
+
+ - Prevent privilege escalation via "impersonate" feature
+ - Prevent privilege escalation via notes API
+ - Prevent privilege escalation via project webhook API
+ - Prevent XSS via `window.opener`
+ - Prevent information disclosure via project labels
+ - Prevent information disclosure via new merge request page
+
+## 8.2.4
+
+ - Bump Git version requirement to 2.7.4
+
+## 8.2.3
+
+ - Fix application settings cache not expiring after changes (Stan Hu)
+ - Fix Error 500s when creating global milestones with Unicode characters (Stan Hu)
+ - Update documentation for "Guest" permissions
+ - Properly convert Emoji-only comments into Award Emojis
+ - Enable devise paranoid mode to prevent user enumeration attack
+ - Webhook payload has an added, modified and removed properties for each commit
+ - Fix 500 error when creating a merge request that removes a submodule
+
+## 8.2.2
+
+ - Fix 404 in redirection after removing a project (Stan Hu)
+ - Ensure cached application settings are refreshed at startup (Stan Hu)
+ - Fix Error 500 when viewing user's personal projects from admin page (Stan Hu)
+ - Fix: Raw private snippets access workflow
+ - Prevent "413 Request entity too large" errors when pushing large files with LFS
+ - Fix invalid links within projects dashboard header
+ - Make current user the first user in assignee dropdown in issues detail page (Stan Hu)
+ - Fix: duplicate email notifications on issue comments
+
+## 8.2.1
+
+ - Forcefully update builds that didn't want to update with state machine
+ - Fix: saving GitLabCiService as Admin Template
+
+## 8.2.0 (2015-11-22)
+
+ - Improved performance of finding projects and groups in various places
+ - Improved performance of rendering user profile pages and Atom feeds
+ - Expose build artifacts path as config option
+ - Fix grouping of contributors by email in graph.
+ - Improved performance of finding issues with/without labels
+ - Fix Drone CI service template not saving properly (Stan Hu)
+ - Fix avatars not showing in Atom feeds and project issues when Gravatar disabled (Stan Hu)
+ - Added a GitLab specific profiling tool called "Sherlock" (see GitLab CE merge request #1749)
+ - Upgrade gitlab_git to 7.2.20 and rugged to 0.23.3 (Stan Hu)
+ - Improved performance of finding users by one of their Email addresses
+ - Add allow_failure field to commit status API (Stan Hu)
+ - Commits without .gitlab-ci.yml are marked as skipped
+ - Save detailed error when YAML syntax is invalid
+ - Since GitLab CI is enabled by default, remove enabling it by pushing .gitlab-ci.yml
+ - Added build artifacts
+ - Improved performance of replacing references in comments
+ - Show last project commit to default branch on project home page
+ - Highlight comment based on anchor in URL
+ - Adds ability to remove the forked relationship from project settings screen. (Han Loong Liauw)
+ - Improved performance of sorting milestone issues
+ - Allow users to select the Files view as default project view (Cristian Bica)
+ - Show "Empty Repository Page" for repository without branches (Artem V. Navrotskiy)
+ - Fix: Inability to reply to code comments in the MR view, if the MR comes from a fork
+ - Use git follow flag for commits page when retrieve history for file or directory
+ - Show merge request CI status on merge requests index page
+ - Send build name and stage in CI notification e-mail
+ - Extend yml syntax for only and except to support specifying repository path
+ - Enable shared runners to all new projects
+ - Bump GitLab-Workhorse to 0.4.1
+ - Allow to define cache in `.gitlab-ci.yml`
+ - Fix: 500 error returned if destroy request without HTTP referer (Kazuki Shimizu)
+ - Remove deprecated CI events from project settings page
+ - Use issue editor as cross reference comment author when issue is edited with a new mention.
+ - Add graphs of commits ahead and behind default branch (Jeff Stubler)
+ - Improve personal snippet access workflow (Douglas Alexandre)
+ - [API] Add ability to fetch the commit ID of the last commit that actually touched a file
+ - Fix omniauth documentation setting for omnibus configuration (Jon Cairns)
+ - Add "New file" link to dropdown on project page
+ - Include commit logs in project search
+ - Add "added", "modified" and "removed" properties to commit object in webhook
+ - Rename "Back to" links to "Go to" because its not always a case it point to place user come from
+ - Allow groups to appear in the search results if the group owner allows it
+ - Add email notification to former assignee upon unassignment (Adam Lieskovský)
+ - New design for project graphs page
+ - Remove deprecated dumped yaml file generated from previous job definitions
+ - Show specific runners from projects where user is master or owner
+ - MR target branch is now visible on a list view when it is different from project's default one
+ - Improve Continuous Integration graphs page
+ - Make color of "Accept Merge Request" button consistent with current build status
+ - Add ignore white space option in merge request diff and commit and compare view
+ - Ability to add release notes (markdown text and attachments) to git tags (aka Releases)
+ - Relative links from a repositories README.md now link to the default branch
+ - Fix trailing whitespace issue in merge request/issue title
+ - Fix bug when milestone/label filter was empty for dashboard issues page
+ - Add ability to create milestone in group projects from single form
+ - Add option to create merge request when editing/creating a file (Dirceu Tiegs)
+ - Prevent the last owner of a group from being able to delete themselves by 'adding' themselves as a master (James Lopez)
+ - Add Award Emoji to issue and merge request pages
+
+## 8.1.4
+
+ - Fix bug where manually merged branches in a MR would end up with an empty diff (Stan Hu)
+ - Prevent redirect loop when home_page_url is set to the root URL
+ - Fix incoming email config defaults
+ - Remove CSS property preventing hard tabs from rendering in Chromium 45 (Stan Hu)
+
+## 8.1.3
+
+ - Force update refs/merge-requests/X/head upon a push to the source branch of a merge request (Stan Hu)
+ - Spread out runner contacted_at updates
+ - Use issue editor as cross reference comment author when issue is edited with a new mention
+ - Add Facebook authentication
+
+## 8.1.2
+
+ - Fix cloning Wiki repositories via HTTP (Stan Hu)
+ - Add migration to remove satellites directory
+ - Fix specific runners visibility
+ - Fix 500 when editing CI service
+ - Require CI jobs to be named
+ - Fix CSS for runner status
+ - Fix CI badge
+ - Allow developer to manage builds
+
+## 8.1.1
+
+ - Removed, see 8.1.2
+
+## 8.1.0 (2015-10-22)
+
+ - Ensure MySQL CI limits DB migrations occur after the fields have been created (Stan Hu)
+ - Fix duplicate repositories in GitHub import page (Stan Hu)
+ - Redirect to a default path if HTTP_REFERER is not set (Stan Hu)
+ - Adds ability to create directories using the web editor (Ben Ford)
+ - Cleanup stuck CI builds
+ - Send an email to admin email when a user is reported for spam (Jonathan Rochkind)
+ - Show notifications button when user is member of group rather than project (Grzegorz Bizon)
+ - Fix bug preventing mentioned issued from being closed when MR is merged using fast-forward merge.
+ - Fix nonatomic database update potentially causing project star counts to go negative (Stan Hu)
+ - Don't show "Add README" link in an empty repository if user doesn't have access to push (Stan Hu)
+ - Fix error preventing displaying of commit data for a directory with a leading dot (Stan Hu)
+ - Speed up load times of issue detail pages by roughly 1.5x
+ - Fix CI rendering regressions
+ - If a merge request is to close an issue, show this on the issue page (Zeger-Jan van de Weg)
+ - Add a system note and update relevant merge requests when a branch is deleted or re-added (Stan Hu)
+ - Make diff file view easier to use on mobile screens (Stan Hu)
+ - Improved performance of finding users by username or Email address
+ - Fix bug where merge request comments created by API would not trigger notifications (Stan Hu)
+ - Add support for creating directories from Files page (Stan Hu)
+ - Allow removing of project without confirmation when JavaScript is disabled (Stan Hu)
+ - Support filtering by "Any" milestone or issue and fix "No Milestone" and "No Label" filters (Stan Hu)
+ - Improved performance of the trending projects page
+ - Remove CI migration task
+ - Improved performance of finding projects by their namespace
+ - Add assignee data to Issuables' hook_data (Bram Daams)
+ - Fix bug where transferring a project would result in stale commit links (Stan Hu)
+ - Fix build trace updating
+ - Include full path of source and target branch names in New Merge Request page (Stan Hu)
+ - Add user preference to view activities as default dashboard (Stan Hu)
+ - Add option to admin area to sign in as a specific user (Pavel Forkert)
+ - Show CI status on all pages where commits list is rendered
+ - Automatically enable CI when push .gitlab-ci.yml file to repository
+ - Move CI charts to project graphs area
+ - Fix cases where Markdown did not render links in activity feed (Stan Hu)
+ - Add first and last to pagination (Zeger-Jan van de Weg)
+ - Added Commit Status API
+ - Added Builds View
+ - Added when to .gitlab-ci.yml
+ - Show CI status on commit page
+ - Added CI_BUILD_TAG, _STAGE, _NAME and _TRIGGERED to CI builds
+ - Show CI status on Your projects page and Starred projects page
+ - Remove "Continuous Integration" page from dashboard
+ - Add notes and SSL verification entries to hook APIs (Ben Boeckel)
+ - Fix grammar in admin area "labels" .nothing-here-block when no labels exist.
+ - Move CI runners page to project settings area
+ - Move CI variables page to project settings area
+ - Move CI triggers page to project settings area
+ - Move CI project settings page to CE project settings area
+ - Fix bug when removed file was not appearing in merge request diff
+ - Show warning when build cannot be served by any of the available CI runners
+ - Note the original location of a moved project when notifying users of the move
+ - Improve error message when merging fails
+ - Add support of multibyte characters in LDAP UID (Roman Petrov)
+ - Show additions/deletions stats on merge request diff
+ - Remove footer text in emails (Zeger-Jan van de Weg)
+ - Ensure code blocks are properly highlighted after a note is updated
+ - Fix wrong access level badge on MR comments
+ - Hide password in the service settings form
+ - Move CI webhooks page to project settings area
+ - Fix User Identities API. It now allows you to properly create or update user's identities.
+ - Add user preference to change layout width (Peter Göbel)
+ - Use commit status in merge request widget as preferred source of CI status
+ - Integrate CI commit and build pages into project pages
+ - Move CI services page to project settings area
+ - Add "Quick Submit" behavior to input fields throughout the application. Use
+ Cmd+Enter on Mac and Ctrl+Enter on Windows/Linux.
+ - Fix position of hamburger in header for smaller screens (Han Loong Liauw)
+ - Fix bug where Emojis in Markdown would truncate remaining text (Sakata Sinji)
+ - Persist filters when sorting on admin user page (Jerry Lukins)
+ - Update style of snippets pages (Han Loong Liauw)
+ - Allow dashboard and group issues/MRs to be filtered by label
+ - Add spellcheck=false to certain input fields
+ - Invalidate stored service password if the endpoint URL is changed
+ - Project names are not fully shown if group name is too big, even on group page view
+ - Apply new design for Files page
+ - Add "New Page" button to Wiki Pages tab (Stan Hu)
+ - Only render 404 page from /public
+ - Hide passwords from services API (Alex Lossent)
+ - Fix: Images cannot show when projects' path was changed
+ - Let gitlab-git-http-server generate and serve 'git archive' downloads
+ - Optimize query when filtering on issuables (Zeger-Jan van de Weg)
+ - Fix padding of outdated discussion item.
+ - Animate the logo on hover
+
+## 8.0.5
+
+ - Correct lookup-by-email for LDAP logins
+ - Fix loading spinner sometimes not being hidden on Merge Request tab switches
+
+## 8.0.4
+
+ - Fix Message-ID header to be RFC 2111-compliant to prevent e-mails being dropped (Stan Hu)
+ - Fix referrals for :back and relative URL installs
+ - Fix anchors to comments in diffs
+ - Remove CI token from build traces
+ - Fix "Assign All" button on Runner admin page
+ - Fix search in Files
+ - Add full project namespace to payload of system webhooks (Ricardo Band)
+
+## 8.0.3
+
+ - Fix URL shown in Slack notifications
+ - Fix bug where projects would appear to be stuck in the forked import state (Stan Hu)
+ - Fix Error 500 in creating merge requests with > 1000 diffs (Stan Hu)
+ - Add work_in_progress key to MR webhooks (Ben Boeckel)
+
+## 8.0.2
+
+ - Fix default avatar not rendering in network graph (Stan Hu)
+ - Skip check_initd_configured_correctly on omnibus installs
+ - Prevent double-prefixing of help page paths
+ - Clarify confirmation text on user deletion
+ - Make commit graphs responsive to window width changes (Stan Hu)
+ - Fix top margin for sign-in button on public pages
+ - Fix LDAP attribute mapping
+ - Remove git refs used internally by GitLab from network graph (Stan Hu)
+ - Use standard Markdown font in Markdown preview instead of fixed-width font (Stan Hu)
+ - Fix Reply by email for non-UTF-8 messages.
+ - Add option to use StartTLS with Reply by email IMAP server.
+ - Allow AWS S3 Server-Side Encryption with Amazon S3-Managed Keys for backups (Paul Beattie)
+
+## 8.0.1
+
+ - Improve CI migration procedure and documentation
+
+## 8.0.0 (2015-09-22)
+
+ - Fix Markdown links not showing up in dashboard activity feed (Stan Hu)
+ - Remove milestones from merge requests when milestones are deleted (Stan Hu)
+ - Fix HTML link that was improperly escaped in new user e-mail (Stan Hu)
+ - Fix broken sort in merge request API (Stan Hu)
+ - Bump rouge to 1.10.1 to remove warning noise and fix other syntax highlighting bugs (Stan Hu)
+ - Gracefully handle errors in syntax highlighting by leaving the block unformatted (Stan Hu)
+ - Add "replace" and "upload" functionalities to allow user replace existing file and upload new file into current repository
+ - Fix URL construction for merge requests, issues, notes, and commits for relative URL config (Stan Hu)
+ - Fix emoji URLs in Markdown when relative_url_root is used (Stan Hu)
+ - Omit filename in Content-Disposition header in raw file download to avoid RFC 6266 encoding issues (Stan HU)
+ - Fix broken Wiki Page History (Stan Hu)
+ - Import forked repositories asynchronously to prevent large repositories from timing out (Stan Hu)
+ - Prevent anchors from being hidden by header (Stan Hu)
+ - Fix bug where only the first 15 Bitbucket issues would be imported (Stan Hu)
+ - Sort issues by creation date in Bitbucket importer (Stan Hu)
+ - Prevent too many redirects upon login when home page URL is set to external_url (Stan Hu)
+ - Improve dropdown positioning on the project home page (Hannes Rosenögger)
+ - Upgrade browser gem to 1.0.0 to avoid warning in IE11 compatibilty mode (Stan Hu)
+ - Remove user OAuth tokens from the database and request new tokens each session (Stan Hu)
+ - Restrict users API endpoints to use integer IDs (Stan Hu)
+ - Only show recent push event if the branch still exists or a recent merge request has not been created (Stan Hu)
+ - Remove satellites
+ - Better performance for web editor (switched from satellites to rugged)
+ - Faster merge
+ - Ability to fetch merge requests from refs/merge-requests/:id
+ - Allow displaying of archived projects in the admin interface (Artem Sidorenko)
+ - Allow configuration of import sources for new projects (Artem Sidorenko)
+ - Search for comments should be case insensetive
+ - Create cross-reference for closing references on commits pushed to non-default branches (Maël Valais)
+ - Ability to search milestones
+ - Gracefully handle SMTP user input errors (e.g. incorrect email addresses) to prevent Sidekiq retries (Stan Hu)
+ - Move dashboard activity to separate page (for your projects and starred projects)
+ - Improve performance of git blame
+ - Limit content width to 1200px for most of pages to improve readability on big screens
+ - Fix 500 error when submit project snippet without body
+ - Improve search page usability
+ - Bring more UI consistency in way how projects, snippets and groups lists are rendered
+ - Make all profiles and group public
+ - Fixed login failure when extern_uid changes (Joel Koglin)
+ - Don't notify users without access to the project when they are (accidentally) mentioned in a note.
+ - Retrieving oauth token with LDAP credentials
+ - Load Application settings from running database unless env var USE_DB=false
+ - Added Drone CI integration (Kirill Zaitsev)
+ - Allow developers to retry builds
+ - Hide advanced project options for non-admin users
+ - Fail builds if no .gitlab-ci.yml is found
+ - Refactored service API and added automatically service docs generator (Kirill Zaitsev)
+ - Added web_url key project hook_attrs (Kirill Zaitsev)
+ - Add ability to get user information by ID of an SSH key via the API
+ - Fix bug which IE cannot show image at markdown when the image is raw file of gitlab
+ - Add support for Crowd
+ - Global Labels that are available to all projects
+ - Fix highlighting of deleted lines in diffs.
+ - Project notification level can be set on the project page itself
+ - Added service API endpoint to retrieve service parameters (Petheő Bence)
+ - Add FogBugz project import (Jared Szechy)
+ - Sort users autocomplete lists by user (Allister Antosik)
+ - Webhook for issue now contains repository field (Jungkook Park)
+ - Add ability to add custom text to the help page (Jeroen van Baarsen)
+ - Add pg_schema to backup config
+ - Fix references to target project issues in Merge Requests markdown preview and textareas (Francesco Levorato)
+ - Redirect from incorrectly cased group or project path to correct one (Francesco Levorato)
+ - Removed API calls from CE to CI
+
## 7.14.3
- No changes
diff --git a/changelogs/unreleased/13634-broadcast-message.yml b/changelogs/unreleased/13634-broadcast-message.yml
deleted file mode 100644
index 26c4c133443..00000000000
--- a/changelogs/unreleased/13634-broadcast-message.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix broadcast message not showing up on login page
-merge_request: 15578
-author:
-type: fixed
diff --git a/changelogs/unreleased/13695-order-contributors-in-api.yml b/changelogs/unreleased/13695-order-contributors-in-api.yml
deleted file mode 100644
index 26bf8650a4a..00000000000
--- a/changelogs/unreleased/13695-order-contributors-in-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Adds ordering to projects contributors in API
-merge_request: 15469
-author: Jacopo Beschi @jacopo-beschi
-type: added
diff --git a/changelogs/unreleased/15588-fix-empty-dropdown-on-create-new-pipeline-in-case-of-validation-errors.yml b/changelogs/unreleased/15588-fix-empty-dropdown-on-create-new-pipeline-in-case-of-validation-errors.yml
deleted file mode 100644
index a4934c8896d..00000000000
--- a/changelogs/unreleased/15588-fix-empty-dropdown-on-create-new-pipeline-in-case-of-validation-errors.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Initializes the branches dropdown when the 'Start new pipeline' failed due to validation errors
-merge_request: 15588
-author: Christiaan Van den Poel
-type: fixed
diff --git a/changelogs/unreleased/15774-fix-39233-500-in-merge-request.yml b/changelogs/unreleased/15774-fix-39233-500-in-merge-request.yml
deleted file mode 100644
index 1179b3f20e6..00000000000
--- a/changelogs/unreleased/15774-fix-39233-500-in-merge-request.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: 'fix #39233 - 500 in merge request'
-merge_request: 15774
-author: Martin Nowak
-type: fixed
diff --git a/changelogs/unreleased/15832-fix-access-level-update-for-requesters.yml b/changelogs/unreleased/15832-fix-access-level-update-for-requesters.yml
deleted file mode 100644
index 9d6c958cb3e..00000000000
--- a/changelogs/unreleased/15832-fix-access-level-update-for-requesters.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix error that was preventing users to change the access level of access requests for Groups or Projects
-merge_request: 15832
-author:
-type: fixed
diff --git a/changelogs/unreleased/15922-validate-file-status-when-commiting-multiple-files.yml b/changelogs/unreleased/15922-validate-file-status-when-commiting-multiple-files.yml
deleted file mode 100644
index db2bd6e692b..00000000000
--- a/changelogs/unreleased/15922-validate-file-status-when-commiting-multiple-files.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: 'Validate file status when commiting multiple files'
-merge_request: 15922
-author:
-type: added
diff --git a/changelogs/unreleased/15955-improve-search-query.yml b/changelogs/unreleased/15955-improve-search-query.yml
deleted file mode 100644
index 80cb8af617f..00000000000
--- a/changelogs/unreleased/15955-improve-search-query.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Improve search query for merge requests.
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/17516-nested-restore-changelog.yml b/changelogs/unreleased/17516-nested-restore-changelog.yml
new file mode 100644
index 00000000000..89753f45457
--- /dev/null
+++ b/changelogs/unreleased/17516-nested-restore-changelog.yml
@@ -0,0 +1,5 @@
+---
+title: Enable restore rake task to handle nested storage directories
+merge_request: 17516
+author: Balasankar C
+type: fixed
diff --git a/changelogs/unreleased/17939-osw-patch-support-gfm.yml b/changelogs/unreleased/17939-osw-patch-support-gfm.yml
new file mode 100644
index 00000000000..576581e25d6
--- /dev/null
+++ b/changelogs/unreleased/17939-osw-patch-support-gfm.yml
@@ -0,0 +1,5 @@
+---
+title: Add support for patch link extension for commit links on GitLab Flavored Markdown
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/18040-rubocop-line-break-after-guard-clause.yml b/changelogs/unreleased/18040-rubocop-line-break-after-guard-clause.yml
deleted file mode 100644
index e3c7ffc8046..00000000000
--- a/changelogs/unreleased/18040-rubocop-line-break-after-guard-clause.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Adds Rubocop rule for line break after guard clause
-merge_request: 15188
-author: Jacopo Beschi @jacopo-beschi
-type: added
diff --git a/changelogs/unreleased/20394-protected-branches-wildcard.yml b/changelogs/unreleased/20394-protected-branches-wildcard.yml
new file mode 100644
index 00000000000..3fa8ee4f69f
--- /dev/null
+++ b/changelogs/unreleased/20394-protected-branches-wildcard.yml
@@ -0,0 +1,5 @@
+---
+title: Include matching branches and tags in protected branches / tags count
+merge_request:
+author: Jan Beckmann
+type: fixed
diff --git a/changelogs/unreleased/21143-customize-branch-name-when-using-create-branch-in-an-issue.yml b/changelogs/unreleased/21143-customize-branch-name-when-using-create-branch-in-an-issue.yml
deleted file mode 100644
index d4c209aaf2b..00000000000
--- a/changelogs/unreleased/21143-customize-branch-name-when-using-create-branch-in-an-issue.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add an ability to use a custom branch name on creation from issues
-merge_request: 13884
-author: Vitaliy @blackst0ne Klachkov
-type: added
diff --git a/changelogs/unreleased/22680-unlabel-slash-command-limit-autocomplete-to-applied-labels.yml b/changelogs/unreleased/22680-unlabel-slash-command-limit-autocomplete-to-applied-labels.yml
deleted file mode 100644
index 6d7f8655282..00000000000
--- a/changelogs/unreleased/22680-unlabel-slash-command-limit-autocomplete-to-applied-labels.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Limit autocomplete menu to applied labels
-merge_request: 11110
-author: Vitaliy @blackst0ne Klachkov
-type: added
diff --git a/changelogs/unreleased/23460-send-email-when-pushing-more-commits-to-the-merge-request.yml b/changelogs/unreleased/23460-send-email-when-pushing-more-commits-to-the-merge-request.yml
new file mode 100644
index 00000000000..a62137ea2c9
--- /dev/null
+++ b/changelogs/unreleased/23460-send-email-when-pushing-more-commits-to-the-merge-request.yml
@@ -0,0 +1,5 @@
+---
+title: Send notification emails when push to a merge request
+merge_request: 7610
+author: YarNayar
+type: feature
diff --git a/changelogs/unreleased/25317-prioritize-author-date-over-commit.yml b/changelogs/unreleased/25317-prioritize-author-date-over-commit.yml
deleted file mode 100644
index a5f6d316a7d..00000000000
--- a/changelogs/unreleased/25317-prioritize-author-date-over-commit.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Show authored date rather than committed date on the commit list
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/27210-add-cancel-btn-to-new-page-domain.yml b/changelogs/unreleased/27210-add-cancel-btn-to-new-page-domain.yml
new file mode 100644
index 00000000000..d96f7e54c8d
--- /dev/null
+++ b/changelogs/unreleased/27210-add-cancel-btn-to-new-page-domain.yml
@@ -0,0 +1,5 @@
+---
+title: Adds cancel btn to new pages domain page
+merge_request: 18026
+author: Jacopo Beschi @jacopo-beschi
+type: added
diff --git a/changelogs/unreleased/28004-consider-refactoring-member-view-by-using-presenter.yml b/changelogs/unreleased/28004-consider-refactoring-member-view-by-using-presenter.yml
deleted file mode 100644
index 0e91d4ae403..00000000000
--- a/changelogs/unreleased/28004-consider-refactoring-member-view-by-using-presenter.yml
+++ /dev/null
@@ -1,4 +0,0 @@
----
-title: Refactor member view using a Presenter
-merge_request: 9645
-author: TM Lee
diff --git a/changelogs/unreleased/28377-add-edit-button-to-mobile-file-view.yml b/changelogs/unreleased/28377-add-edit-button-to-mobile-file-view.yml
deleted file mode 100644
index b6646379b8d..00000000000
--- a/changelogs/unreleased/28377-add-edit-button-to-mobile-file-view.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add edit button to mobile file view
-merge_request: 15199
-author: Travis Miller
-type: added
diff --git a/changelogs/unreleased/29483-no-feedback-when-checking-on-checklist-if-potential-spam-was-detected.yml b/changelogs/unreleased/29483-no-feedback-when-checking-on-checklist-if-potential-spam-was-detected.yml
deleted file mode 100644
index 6bfcc5e70de..00000000000
--- a/changelogs/unreleased/29483-no-feedback-when-checking-on-checklist-if-potential-spam-was-detected.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add recaptcha modal to issue updates detected as spam
-merge_request: 15408
-author:
-type: fixed
diff --git a/changelogs/unreleased/31114-internal-ids-are-not-atomic.yml b/changelogs/unreleased/31114-internal-ids-are-not-atomic.yml
new file mode 100644
index 00000000000..bc1955bc66f
--- /dev/null
+++ b/changelogs/unreleased/31114-internal-ids-are-not-atomic.yml
@@ -0,0 +1,5 @@
+---
+title: Atomic generation of internal ids for issues.
+merge_request: 17580
+author:
+type: other
diff --git a/changelogs/unreleased/31591-project-deploy-tokens-to-allow-permanent-access.yml b/changelogs/unreleased/31591-project-deploy-tokens-to-allow-permanent-access.yml
new file mode 100644
index 00000000000..5546d26d0fb
--- /dev/null
+++ b/changelogs/unreleased/31591-project-deploy-tokens-to-allow-permanent-access.yml
@@ -0,0 +1,5 @@
+---
+title: Create Deploy Tokens to allow permanent access to repository and registry
+merge_request: 17894
+author:
+type: added
diff --git a/changelogs/unreleased/32057_support_ordering_project_notes_in_notes_api.yml b/changelogs/unreleased/32057_support_ordering_project_notes_in_notes_api.yml
deleted file mode 100644
index cd19d24e035..00000000000
--- a/changelogs/unreleased/32057_support_ordering_project_notes_in_notes_api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: added support for ordering and sorting in notes api
-merge_request: 15342
-author: haseebeqx
-type: added
diff --git a/changelogs/unreleased/32098-pipelines-navigation.yml b/changelogs/unreleased/32098-pipelines-navigation.yml
deleted file mode 100644
index 925c92b6be8..00000000000
--- a/changelogs/unreleased/32098-pipelines-navigation.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Stop reloading the page when using pagination and tabs - use API calls - in
- Pipelines table
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/32617-fix-template-selector-menu-visibility.yml b/changelogs/unreleased/32617-fix-template-selector-menu-visibility.yml
new file mode 100644
index 00000000000..c73be5a901e
--- /dev/null
+++ b/changelogs/unreleased/32617-fix-template-selector-menu-visibility.yml
@@ -0,0 +1,6 @@
+---
+title: Fix template selector menu visibility when toggling preview mode in file edit
+ view
+merge_request: 18118
+author: Fabian Schneider
+type: fixed
diff --git a/changelogs/unreleased/32878-merge-request-from-email.yml b/changelogs/unreleased/32878-merge-request-from-email.yml
deleted file mode 100644
index 2df148d5a81..00000000000
--- a/changelogs/unreleased/32878-merge-request-from-email.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Allow creation of merge request from email
-merge_request: 13817
-author: janp
-type: added
diff --git a/changelogs/unreleased/33028-event-tag-links.yml b/changelogs/unreleased/33028-event-tag-links.yml
deleted file mode 100644
index 1d674200dcd..00000000000
--- a/changelogs/unreleased/33028-event-tag-links.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix tags in the Activity tab not being clickable
-merge_request: 15996
-author: Mario de la Ossa
-type: fixed
diff --git a/changelogs/unreleased/33338-internationalization-support-for-prometheus-service-configuration.yml b/changelogs/unreleased/33338-internationalization-support-for-prometheus-service-configuration.yml
deleted file mode 100644
index d61bbf2e355..00000000000
--- a/changelogs/unreleased/33338-internationalization-support-for-prometheus-service-configuration.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add internationalization support for the prometheus integration
-merge_request: 33338
-author:
-type: other
diff --git a/changelogs/unreleased/33609-hide-pagination.yml b/changelogs/unreleased/33609-hide-pagination.yml
deleted file mode 100644
index 3586b091cb1..00000000000
--- a/changelogs/unreleased/33609-hide-pagination.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Disable Vue pagination when only one page of content is available
-merge_request: 15999
-author: Mario de la Ossa
-type: fixed
diff --git a/changelogs/unreleased/33803-drop-json-support-in-project-milestone.yml b/changelogs/unreleased/33803-drop-json-support-in-project-milestone.yml
new file mode 100644
index 00000000000..0382ede4565
--- /dev/null
+++ b/changelogs/unreleased/33803-drop-json-support-in-project-milestone.yml
@@ -0,0 +1,5 @@
+---
+title: Drop JSON response in Project Milestone along with avoiding error
+merge_request: 17977
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/33926-update-issuable-icons.yml b/changelogs/unreleased/33926-update-issuable-icons.yml
deleted file mode 100644
index 87076dde545..00000000000
--- a/changelogs/unreleased/33926-update-issuable-icons.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Update issuable status icons
-merge_request: 15898
-author:
-type: changed
diff --git a/changelogs/unreleased/34600-performance-wiki-pages.yml b/changelogs/unreleased/34600-performance-wiki-pages.yml
deleted file mode 100644
index 541ae8f8e60..00000000000
--- a/changelogs/unreleased/34600-performance-wiki-pages.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Performance issues when loading large number of wiki pages
-merge_request: 15276
-author:
-type: performance
diff --git a/changelogs/unreleased/34604-fix-generated-url-for-external-repository.yml b/changelogs/unreleased/34604-fix-generated-url-for-external-repository.yml
new file mode 100644
index 00000000000..c4b5f59b724
--- /dev/null
+++ b/changelogs/unreleased/34604-fix-generated-url-for-external-repository.yml
@@ -0,0 +1,5 @@
+---
+title: Fix generated URL when listing repoitories for import
+merge_request: 17692
+author:
+type: fixed
diff --git a/changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml b/changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml
deleted file mode 100644
index 31450287caf..00000000000
--- a/changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Allow git pull/push on group/user/project redirects
-merge_request: 15670
-author:
-type: added
diff --git a/changelogs/unreleased/35475-lazy-diff.yml b/changelogs/unreleased/35475-lazy-diff.yml
new file mode 100644
index 00000000000..bafa66ebe39
--- /dev/null
+++ b/changelogs/unreleased/35475-lazy-diff.yml
@@ -0,0 +1,5 @@
+---
+title: lazy load diffs on merge request discussions
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/35616-move-k8-to-cluster-page.yml b/changelogs/unreleased/35616-move-k8-to-cluster-page.yml
deleted file mode 100644
index 032a39608ce..00000000000
--- a/changelogs/unreleased/35616-move-k8-to-cluster-page.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Create a new form to add Existing Kubernetes Cluster
-merge_request: 14805
-author:
-type: added
diff --git a/changelogs/unreleased/35724-animate-sidebar.yml b/changelogs/unreleased/35724-animate-sidebar.yml
deleted file mode 100644
index 5d0b46a23c8..00000000000
--- a/changelogs/unreleased/35724-animate-sidebar.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Animate contextual sidebar on collapse/expand
-merge_request:
-author:
-type: changed
diff --git a/changelogs/unreleased/36020-private-npm-modules.yml b/changelogs/unreleased/36020-private-npm-modules.yml
deleted file mode 100644
index 5c2585a602e..00000000000
--- a/changelogs/unreleased/36020-private-npm-modules.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Do not generate NPM links for private NPM modules in blob view
-merge_request: 16002
-author: Mario de la Ossa
-type: added
diff --git a/changelogs/unreleased/36400-trigger-job.yml b/changelogs/unreleased/36400-trigger-job.yml
deleted file mode 100644
index 27243813dc8..00000000000
--- a/changelogs/unreleased/36400-trigger-job.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Prevent 500 error when inspecting job after trigger was removed
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/36958-enable-ordering-projects-subgroups-by-name.yml b/changelogs/unreleased/36958-enable-ordering-projects-subgroups-by-name.yml
deleted file mode 100644
index 8348e3e8ceb..00000000000
--- a/changelogs/unreleased/36958-enable-ordering-projects-subgroups-by-name.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Enable ordering of groups and their children by name
-merge_request:
-author:
-type: added
diff --git a/changelogs/unreleased/37354-pipelines-update.yml b/changelogs/unreleased/37354-pipelines-update.yml
deleted file mode 100644
index 2b6ddfe95ed..00000000000
--- a/changelogs/unreleased/37354-pipelines-update.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make sure head pippeline always corresponds with the head sha of an MR
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/38019-hide-runner-token.yml b/changelogs/unreleased/38019-hide-runner-token.yml
deleted file mode 100644
index 11ae0a685ef..00000000000
--- a/changelogs/unreleased/38019-hide-runner-token.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Hide runner token in CI/CD settings page
-merge_request:
-author:
-type: added
diff --git a/changelogs/unreleased/38032-deploy-markers-should-be-more-verbose.yml b/changelogs/unreleased/38032-deploy-markers-should-be-more-verbose.yml
deleted file mode 100644
index a1f28b3ba0f..00000000000
--- a/changelogs/unreleased/38032-deploy-markers-should-be-more-verbose.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Changed the deploy markers on the prometheus dashboard to be more verbose
-merge_request: 38032
-author:
-type: changed
diff --git a/changelogs/unreleased/38075_allow_refernce_integer_labels.yml b/changelogs/unreleased/38075_allow_refernce_integer_labels.yml
deleted file mode 100644
index b5342d4adf8..00000000000
--- a/changelogs/unreleased/38075_allow_refernce_integer_labels.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix errors when selecting numeric-only labels in the labels autocomplete selector
-merge_request: 14607
-author: haseebeqx
-type: fixed
diff --git a/changelogs/unreleased/38145_ux_issues_in_system_info_page.yml b/changelogs/unreleased/38145_ux_issues_in_system_info_page.yml
deleted file mode 100644
index d2358750518..00000000000
--- a/changelogs/unreleased/38145_ux_issues_in_system_info_page.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixes the wording of headers in system info page
-merge_request: 15802
-author: Gilbert Roulot
-type: fixed
diff --git a/changelogs/unreleased/38167-ui-bug-when-creating-new-branch.yml b/changelogs/unreleased/38167-ui-bug-when-creating-new-branch.yml
new file mode 100644
index 00000000000..cec06bf2dfe
--- /dev/null
+++ b/changelogs/unreleased/38167-ui-bug-when-creating-new-branch.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed bug in dropdown selector when selecting the same selection again
+merge_request: 14631
+author: bitsapien
+type: fixed
diff --git a/changelogs/unreleased/38239-update-toggle-design.yml b/changelogs/unreleased/38239-update-toggle-design.yml
deleted file mode 100644
index 4d9034e8515..00000000000
--- a/changelogs/unreleased/38239-update-toggle-design.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Update feature toggle design to use icons and make it i18n friendly
-merge_request: 15904
-author:
-type: changed
diff --git a/changelogs/unreleased/38318-search-merge-requests-with-api.yml b/changelogs/unreleased/38318-search-merge-requests-with-api.yml
deleted file mode 100644
index d8b2f1f25c8..00000000000
--- a/changelogs/unreleased/38318-search-merge-requests-with-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add optional search param for Merge Requests API
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/38393-Milestone-duration-error-message-is-not-accurate-enough.yml b/changelogs/unreleased/38393-Milestone-duration-error-message-is-not-accurate-enough.yml
deleted file mode 100644
index c73cf8bf60b..00000000000
--- a/changelogs/unreleased/38393-Milestone-duration-error-message-is-not-accurate-enough.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Changed validation error message on wrong milestone dates
-merge_request:
-author: Xurxo Méndez Pérez
-type: fixed
diff --git a/changelogs/unreleased/38541-cancel-alignment.yml b/changelogs/unreleased/38541-cancel-alignment.yml
deleted file mode 100644
index c6d5136dd57..00000000000
--- a/changelogs/unreleased/38541-cancel-alignment.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: fix button alignment on MWPS component
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/38822-oauth-search-case-insensitive.yml b/changelogs/unreleased/38822-oauth-search-case-insensitive.yml
deleted file mode 100644
index d84360b4c5c..00000000000
--- a/changelogs/unreleased/38822-oauth-search-case-insensitive.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: OAuth identity lookups case-insensitive
-merge_request: 15312
-author:
-type: fixed
diff --git a/changelogs/unreleased/38862-email-notifications-not-sent-as-expected.yml b/changelogs/unreleased/38862-email-notifications-not-sent-as-expected.yml
deleted file mode 100644
index 6b1b309ab14..00000000000
--- a/changelogs/unreleased/38862-email-notifications-not-sent-as-expected.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Fix sending notification emails to users with the mention level set who were
- mentioned in an issue or merge request description
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/38869-templates.yml b/changelogs/unreleased/38869-templates.yml
deleted file mode 100644
index 957b5f27bd0..00000000000
--- a/changelogs/unreleased/38869-templates.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Remove template selector from global namespace
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/38877-disable-autocomplete-in-filtered-search.yml b/changelogs/unreleased/38877-disable-autocomplete-in-filtered-search.yml
deleted file mode 100644
index 07439a860ec..00000000000
--- a/changelogs/unreleased/38877-disable-autocomplete-in-filtered-search.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Disables autocomplete in filtered searc
-merge_request: 15477
-author: Jacopo Beschi @jacopo-beschi
-type: added
diff --git a/changelogs/unreleased/38962-automatically-run-a-pipeline-when-auto-devops-is-turned-on-in-project-settings.yml b/changelogs/unreleased/38962-automatically-run-a-pipeline-when-auto-devops-is-turned-on-in-project-settings.yml
deleted file mode 100644
index a4d703bc69f..00000000000
--- a/changelogs/unreleased/38962-automatically-run-a-pipeline-when-auto-devops-is-turned-on-in-project-settings.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add the option to automatically run a pipeline after updating AutoDevOps settings
-merge_request: 15380
-author:
-type: changed
diff --git a/changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml b/changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml
deleted file mode 100644
index ce238a2c79f..00000000000
--- a/changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Only mark import and fork jobs as failed once all Sidekiq retries get exhausted
-merge_request: 15844
-author:
-type: changed
diff --git a/changelogs/unreleased/39298-list-of-avatars-2.yml b/changelogs/unreleased/39298-list-of-avatars-2.yml
deleted file mode 100644
index e2095561c0e..00000000000
--- a/changelogs/unreleased/39298-list-of-avatars-2.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: List of avatars should never show +1
-merge_request: 15972
-author: Jacopo Beschi @jacopo-beschi
-type: added
diff --git a/changelogs/unreleased/39335-add-time-spend-to-milestones.yml b/changelogs/unreleased/39335-add-time-spend-to-milestones.yml
deleted file mode 100644
index 41a43418cbf..00000000000
--- a/changelogs/unreleased/39335-add-time-spend-to-milestones.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add total time spent to milestones
-merge_request: 15116
-author: George Andrinopoulos
-type: added
diff --git a/changelogs/unreleased/39364-in-issue-board-url-doesn-t-take-in-account-hostname-settings.yml b/changelogs/unreleased/39364-in-issue-board-url-doesn-t-take-in-account-hostname-settings.yml
deleted file mode 100644
index 9793c6a8e9c..00000000000
--- a/changelogs/unreleased/39364-in-issue-board-url-doesn-t-take-in-account-hostname-settings.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Change boards page boards_data absolute urls to paths
-merge_request: 15703
-author:
-type: fixed
diff --git a/changelogs/unreleased/39367-fix-new-email-session-path.yml b/changelogs/unreleased/39367-fix-new-email-session-path.yml
deleted file mode 100644
index 73485d9d1a9..00000000000
--- a/changelogs/unreleased/39367-fix-new-email-session-path.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Confirming email with invalid token should no longer generate an error
-merge_request: 15726
-author:
-type: fixed
diff --git a/changelogs/unreleased/39436-pages-api-administrative.yml b/changelogs/unreleased/39436-pages-api-administrative.yml
deleted file mode 100644
index f38bbbd479c..00000000000
--- a/changelogs/unreleased/39436-pages-api-administrative.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add administrative endpoint to list all pages domains
-merge_request: 15160
-author: Travis Miller
-type: added
diff --git a/changelogs/unreleased/39455-clone-dropdown-should-not-have-a-tooltip.yml b/changelogs/unreleased/39455-clone-dropdown-should-not-have-a-tooltip.yml
deleted file mode 100644
index cb522cb7611..00000000000
--- a/changelogs/unreleased/39455-clone-dropdown-should-not-have-a-tooltip.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Removed tooltip from clone dropdown
-merge_request: 15334
-author:
-type: other
diff --git a/changelogs/unreleased/39497-inline-edit-issue-on-mobile.yml b/changelogs/unreleased/39497-inline-edit-issue-on-mobile.yml
deleted file mode 100644
index fc7c024f95a..00000000000
--- a/changelogs/unreleased/39497-inline-edit-issue-on-mobile.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add inline editing to issues on mobile
-merge_request: 15438
-author:
-type: changed
diff --git a/changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml b/changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml
new file mode 100644
index 00000000000..30a8dc63983
--- /dev/null
+++ b/changelogs/unreleased/39584-nesting-depth-5-framework-dropdowns.yml
@@ -0,0 +1,5 @@
+---
+title: Apply NestingDepth (level 5) (framework/dropdowns.scss)
+merge_request: 17820
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/39601-create-issuable-destroy-service.yml b/changelogs/unreleased/39601-create-issuable-destroy-service.yml
deleted file mode 100644
index b0463f02eba..00000000000
--- a/changelogs/unreleased/39601-create-issuable-destroy-service.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Create issuable destroy service
-merge_request: 15604
-author: George Andrinopoulos
-type: other
diff --git a/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml b/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml
deleted file mode 100644
index 056afe43010..00000000000
--- a/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Move update_project_counter_caches? out of issue and merge request
-merge_request: 15300
-author: George Andrinopoulos
-type: other
diff --git a/changelogs/unreleased/39608-comment-on-image-discussions-tab-alignment.yml b/changelogs/unreleased/39608-comment-on-image-discussions-tab-alignment.yml
deleted file mode 100644
index 5021fe88caf..00000000000
--- a/changelogs/unreleased/39608-comment-on-image-discussions-tab-alignment.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Update comment on image cursor and icons
-merge_request: 15760
-author:
-type: fixed
diff --git a/changelogs/unreleased/39720-group-milestone-sorting.yml b/changelogs/unreleased/39720-group-milestone-sorting.yml
deleted file mode 100644
index 15ef87fa567..00000000000
--- a/changelogs/unreleased/39720-group-milestone-sorting.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add dropdown sort to group milestones
-merge_request: 15230
-author: George Andrinopoulos
-type: added
diff --git a/changelogs/unreleased/39727-add-axios-to-common.yml b/changelogs/unreleased/39727-add-axios-to-common.yml
deleted file mode 100644
index 688757d2486..00000000000
--- a/changelogs/unreleased/39727-add-axios-to-common.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add axios to common file
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/39821-fix-commits-list-with-multi-file-editor.yml b/changelogs/unreleased/39821-fix-commits-list-with-multi-file-editor.yml
deleted file mode 100644
index 8b27c43d15b..00000000000
--- a/changelogs/unreleased/39821-fix-commits-list-with-multi-file-editor.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix commits page throwing 500 when the multi-file editor was enabled
-merge_request: 15502
-author:
-type: fixed
diff --git a/changelogs/unreleased/39827-fix-projects-dropdown-overflow.yml b/changelogs/unreleased/39827-fix-projects-dropdown-overflow.yml
deleted file mode 100644
index ebd7b582e8a..00000000000
--- a/changelogs/unreleased/39827-fix-projects-dropdown-overflow.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix item name and namespace text overflow in Projects dropdown
-merge_request: 15451
-author:
-type: fixed
diff --git a/changelogs/unreleased/39869_show_closed_status_of_links_to_issues_on_wiki_pages.yml b/changelogs/unreleased/39869_show_closed_status_of_links_to_issues_on_wiki_pages.yml
deleted file mode 100644
index cd31ed463a2..00000000000
--- a/changelogs/unreleased/39869_show_closed_status_of_links_to_issues_on_wiki_pages.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: show status of gitlab reference links in wiki
-merge_request: 15694
-author: haseebeqx
-type: added
diff --git a/changelogs/unreleased/39880-merge-method-api.yml b/changelogs/unreleased/39880-merge-method-api.yml
new file mode 100644
index 00000000000..dd44a752c4f
--- /dev/null
+++ b/changelogs/unreleased/39880-merge-method-api.yml
@@ -0,0 +1,5 @@
+---
+title: 'API: Add parameter merge_method to projects'
+merge_request: 18031
+author: Jan Beckmann
+type: added
diff --git a/changelogs/unreleased/39884-fix-pipeline-transition-with-single-manual-action.yml b/changelogs/unreleased/39884-fix-pipeline-transition-with-single-manual-action.yml
deleted file mode 100644
index 580b97241e7..00000000000
--- a/changelogs/unreleased/39884-fix-pipeline-transition-with-single-manual-action.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Fix pipeline status transition for single manual job. This would also fix pipeline
- duration becuse it is depending on status transition
-merge_request: 15251
-author:
-type: fixed
diff --git a/changelogs/unreleased/39895-cant-set-mattermost-username-channel-from-api.yml b/changelogs/unreleased/39895-cant-set-mattermost-username-channel-from-api.yml
deleted file mode 100644
index 358c007387e..00000000000
--- a/changelogs/unreleased/39895-cant-set-mattermost-username-channel-from-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix acceptance of username for Mattermost service update
-merge_request: 15275
-author:
-type: fixed
diff --git a/changelogs/unreleased/39977-gitlab-shell-default-timeout.yml b/changelogs/unreleased/39977-gitlab-shell-default-timeout.yml
deleted file mode 100644
index b7a974fd8d9..00000000000
--- a/changelogs/unreleased/39977-gitlab-shell-default-timeout.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Set the default gitlab-shell timeout to 3 hours
-merge_request: 15292
-author:
-type: fixed
diff --git a/changelogs/unreleased/40016-log-header.yml b/changelogs/unreleased/40016-log-header.yml
deleted file mode 100644
index f52c2d2a0d5..00000000000
--- a/changelogs/unreleased/40016-log-header.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Hide log size for mobile screens
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/40031-include-assset_sync-gem.yml b/changelogs/unreleased/40031-include-assset_sync-gem.yml
deleted file mode 100644
index 93ce565b32c..00000000000
--- a/changelogs/unreleased/40031-include-assset_sync-gem.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add assets_sync gem to Gemfile
-merge_request: 15734
-author:
-type: added
diff --git a/changelogs/unreleased/40040-decouple-multi-file-editor-from-file-list.yml b/changelogs/unreleased/40040-decouple-multi-file-editor-from-file-list.yml
deleted file mode 100644
index e2fade2bfd9..00000000000
--- a/changelogs/unreleased/40040-decouple-multi-file-editor-from-file-list.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Adds the multi file editor as a new beta feature
-merge_request: 15430
-author:
-type: feature
diff --git a/changelogs/unreleased/40063-markdown-editor-improvements.yml b/changelogs/unreleased/40063-markdown-editor-improvements.yml
deleted file mode 100644
index fa2f09408b4..00000000000
--- a/changelogs/unreleased/40063-markdown-editor-improvements.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Hide markdown toolbar in preview mode
-merge_request:
-author:
-type: changed
diff --git a/changelogs/unreleased/40122-only-one-note-webhook-is-triggered-when-a-comment-with-time-spent-is-added.yml b/changelogs/unreleased/40122-only-one-note-webhook-is-triggered-when-a-comment-with-time-spent-is-added.yml
deleted file mode 100644
index a2ae2059c47..00000000000
--- a/changelogs/unreleased/40122-only-one-note-webhook-is-triggered-when-a-comment-with-time-spent-is-added.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add total_time_spent to the `changes` hash in issuable Webhook payloads
-merge_request: 15381
-author:
-type: changed
diff --git a/changelogs/unreleased/40146_fix_special_charecter_search_in_filenames.yml b/changelogs/unreleased/40146_fix_special_charecter_search_in_filenames.yml
deleted file mode 100644
index 00f7dd7c0f0..00000000000
--- a/changelogs/unreleased/40146_fix_special_charecter_search_in_filenames.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix search results when a filename would contain a special character.
-merge_request: 15606
-author: haseebeqx
-type: fixed
diff --git a/changelogs/unreleased/40161-extra-margin-on-svg-logo-in-header.yml b/changelogs/unreleased/40161-extra-margin-on-svg-logo-in-header.yml
deleted file mode 100644
index fdaa90f0d5d..00000000000
--- a/changelogs/unreleased/40161-extra-margin-on-svg-logo-in-header.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Remove extra margin from wordmark in header
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/40286-hide-full-namespace-groups-tree.yml b/changelogs/unreleased/40286-hide-full-namespace-groups-tree.yml
deleted file mode 100644
index cae02d0c2f6..00000000000
--- a/changelogs/unreleased/40286-hide-full-namespace-groups-tree.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Show only group name by default and put full namespace in tooltip in Groups
- tree
-merge_request: 15650
-author:
-type: changed
diff --git a/changelogs/unreleased/40290-remove-rake-gitlab-sidekiq-drop-post-receive.yml b/changelogs/unreleased/40290-remove-rake-gitlab-sidekiq-drop-post-receive.yml
deleted file mode 100644
index 9c308321a19..00000000000
--- a/changelogs/unreleased/40290-remove-rake-gitlab-sidekiq-drop-post-receive.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Removed unused rake task, 'rake gitlab:sidekiq:drop_post_receive'
-merge_request: 15493
-author:
-type: fixed
diff --git a/changelogs/unreleased/40373-fix-issue-note-submit-disabled-on-paste.yml b/changelogs/unreleased/40373-fix-issue-note-submit-disabled-on-paste.yml
deleted file mode 100644
index e683e60397e..00000000000
--- a/changelogs/unreleased/40373-fix-issue-note-submit-disabled-on-paste.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Fix Issue comment submit button being disabled when pasting content from another
- GFM note
-merge_request: 15530
-author:
-type: fixed
diff --git a/changelogs/unreleased/40481-bump-jquery-to-2-2-4.yml b/changelogs/unreleased/40481-bump-jquery-to-2-2-4.yml
deleted file mode 100644
index e275c65e8c8..00000000000
--- a/changelogs/unreleased/40481-bump-jquery-to-2-2-4.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Upgrade jQuery to 2.2.4
-merge_request: 15570
-author: Takuya Noguchi
-type: security
diff --git a/changelogs/unreleased/40508-snippets-zen-mode.yml b/changelogs/unreleased/40508-snippets-zen-mode.yml
deleted file mode 100644
index c205218575b..00000000000
--- a/changelogs/unreleased/40508-snippets-zen-mode.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Init zen mode in snippets pages
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/40509_sorting_tags_api.yml b/changelogs/unreleased/40509_sorting_tags_api.yml
deleted file mode 100644
index 38b198d0fe3..00000000000
--- a/changelogs/unreleased/40509_sorting_tags_api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: add support for sorting in tags api
-merge_request: 15772
-author: haseebeqx
-type: added
diff --git a/changelogs/unreleased/40530-merge-request-generates-wrong-diff-when-branch-and-tag-have-the-same-name.yml b/changelogs/unreleased/40530-merge-request-generates-wrong-diff-when-branch-and-tag-have-the-same-name.yml
deleted file mode 100644
index e9fae6fe0d7..00000000000
--- a/changelogs/unreleased/40530-merge-request-generates-wrong-diff-when-branch-and-tag-have-the-same-name.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix merge requests where the source or target branch name matches a tag name
-merge_request: 15591
-author:
-type: fixed
diff --git a/changelogs/unreleased/40561-environment-scope-value-is-not-trimmed.yml b/changelogs/unreleased/40561-environment-scope-value-is-not-trimmed.yml
deleted file mode 100644
index e0e3ddbdaa8..00000000000
--- a/changelogs/unreleased/40561-environment-scope-value-is-not-trimmed.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Strip leading & trailing whitespaces in CI/CD secret variable keys
-merge_request: 15615
-author:
-type: fixed
diff --git a/changelogs/unreleased/40568-bump-seed-fu-to-2-3-7.yml b/changelogs/unreleased/40568-bump-seed-fu-to-2-3-7.yml
deleted file mode 100644
index 708269d5c83..00000000000
--- a/changelogs/unreleased/40568-bump-seed-fu-to-2-3-7.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Upgrade seed-fu to 2.3.7
-merge_request: 15607
-author: Takuya Noguchi
-type: other
diff --git a/changelogs/unreleased/40573-rename-gke-as-kubernetes-engine.yml b/changelogs/unreleased/40573-rename-gke-as-kubernetes-engine.yml
deleted file mode 100644
index afbb869bdbb..00000000000
--- a/changelogs/unreleased/40573-rename-gke-as-kubernetes-engine.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Rename GKE as Kubernetes Engine
-merge_request: 15608
-author: Takuya Noguchi
-type: other
diff --git a/changelogs/unreleased/40770-doc-elasticsearch.yml b/changelogs/unreleased/40770-doc-elasticsearch.yml
deleted file mode 100644
index b770fb7548e..00000000000
--- a/changelogs/unreleased/40770-doc-elasticsearch.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix typo in docs about Elasticsearch
-merge_request: 15699
-author: Takuya Noguchi
-type: other
diff --git a/changelogs/unreleased/40781-os-to-ce.yml b/changelogs/unreleased/40781-os-to-ce.yml
new file mode 100644
index 00000000000..4a364292c60
--- /dev/null
+++ b/changelogs/unreleased/40781-os-to-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Add object storage support for LFS objects, CI artifacts, and uploads.
+merge_request: 17358
+author:
+type: added
diff --git a/changelogs/unreleased/4080-align-retry-btn.yml b/changelogs/unreleased/4080-align-retry-btn.yml
deleted file mode 100644
index c7d3997839c..00000000000
--- a/changelogs/unreleased/4080-align-retry-btn.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Align retry button with job title with new grid size
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/40871-todo-notification-count-shows-notification-without-having-a-todo.yml b/changelogs/unreleased/40871-todo-notification-count-shows-notification-without-having-a-todo.yml
deleted file mode 100644
index ee196629def..00000000000
--- a/changelogs/unreleased/40871-todo-notification-count-shows-notification-without-having-a-todo.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Reset todo counters when the target is deleted
-merge_request: 15807
-author:
-type: fixed
diff --git a/changelogs/unreleased/40895-fix-frequent-projects-stale-path.yml b/changelogs/unreleased/40895-fix-frequent-projects-stale-path.yml
deleted file mode 100644
index 485133b46a7..00000000000
--- a/changelogs/unreleased/40895-fix-frequent-projects-stale-path.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Use relative URL for projects to avoid storing domains
-merge_request: 15876
-author:
-type: fixed
diff --git a/changelogs/unreleased/41016-import-gitlab-shell-projects.yml b/changelogs/unreleased/41016-import-gitlab-shell-projects.yml
deleted file mode 100644
index 47a9e9c3eec..00000000000
--- a/changelogs/unreleased/41016-import-gitlab-shell-projects.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Import some code and functionality from gitlab-shell to improve subprocess
- handling
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/41224-pipeline-icons.yml b/changelogs/unreleased/41224-pipeline-icons.yml
new file mode 100644
index 00000000000..3fe05448d1c
--- /dev/null
+++ b/changelogs/unreleased/41224-pipeline-icons.yml
@@ -0,0 +1,5 @@
+---
+title: Increase dropdown width in pipeline graph & center action icon
+merge_request: 18089
+author:
+type: fixed
diff --git a/changelogs/unreleased/41758-after-changing-username-url-still-redirects-to-old-route.yml b/changelogs/unreleased/41758-after-changing-username-url-still-redirects-to-old-route.yml
new file mode 100644
index 00000000000..36e79ea1ed4
--- /dev/null
+++ b/changelogs/unreleased/41758-after-changing-username-url-still-redirects-to-old-route.yml
@@ -0,0 +1,5 @@
+---
+title: Added confirmation modal for changing username
+merge_request: 17405
+author:
+type: added
diff --git a/changelogs/unreleased/41902-add-api-option-to-overwrite-project-description-on-project-export.yml b/changelogs/unreleased/41902-add-api-option-to-overwrite-project-description-on-project-export.yml
new file mode 100644
index 00000000000..60a649f22c9
--- /dev/null
+++ b/changelogs/unreleased/41902-add-api-option-to-overwrite-project-description-on-project-export.yml
@@ -0,0 +1,5 @@
+---
+title: Adds the option to the project export API to override the project description and display GitLab export description once imported
+merge_request: 17744
+author:
+type: added
diff --git a/changelogs/unreleased/41967_issue_api_closed_by_info.yml b/changelogs/unreleased/41967_issue_api_closed_by_info.yml
new file mode 100644
index 00000000000..436574c3638
--- /dev/null
+++ b/changelogs/unreleased/41967_issue_api_closed_by_info.yml
@@ -0,0 +1,5 @@
+---
+title: adds closed by informations in issue api
+merge_request: 17042
+author: haseebeqx
+type: added
diff --git a/changelogs/unreleased/41981-allow-group-owner-to-enable-runners-from-subgroups.yml b/changelogs/unreleased/41981-allow-group-owner-to-enable-runners-from-subgroups.yml
new file mode 100644
index 00000000000..30481e7af84
--- /dev/null
+++ b/changelogs/unreleased/41981-allow-group-owner-to-enable-runners-from-subgroups.yml
@@ -0,0 +1,5 @@
+---
+title: 'Allow group owner to enable runners from subgroups (#41981)'
+merge_request: 18009
+author:
+type: fixed
diff --git a/changelogs/unreleased/42028-xss-diffs.yml b/changelogs/unreleased/42028-xss-diffs.yml
new file mode 100644
index 00000000000..a05f9d3c78d
--- /dev/null
+++ b/changelogs/unreleased/42028-xss-diffs.yml
@@ -0,0 +1,5 @@
+---
+title: Fix XSS on diff view stored on filenames
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml b/changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml
new file mode 100644
index 00000000000..f7758734a6f
--- /dev/null
+++ b/changelogs/unreleased/42037-long-instance-names-group-names-covers-namespace-dropdown.yml
@@ -0,0 +1,5 @@
+---
+title: Long instance urls do not overflow anymore during project creation
+merge_request: 17717
+author:
+type: fixed
diff --git a/changelogs/unreleased/42448-change-commit-row-actions-and-sha-design-for-project-commit-list.yml b/changelogs/unreleased/42448-change-commit-row-actions-and-sha-design-for-project-commit-list.yml
new file mode 100644
index 00000000000..77d1ebf69df
--- /dev/null
+++ b/changelogs/unreleased/42448-change-commit-row-actions-and-sha-design-for-project-commit-list.yml
@@ -0,0 +1,6 @@
+---
+title: Improved visual styles and consistency for commit hash and possible actions
+ across commit lists
+merge_request: 17406
+author:
+type: changed
diff --git a/changelogs/unreleased/42568-pipeline-empty-state.yml b/changelogs/unreleased/42568-pipeline-empty-state.yml
new file mode 100644
index 00000000000..d36edcf1b37
--- /dev/null
+++ b/changelogs/unreleased/42568-pipeline-empty-state.yml
@@ -0,0 +1,5 @@
+---
+title: Improve empty state for canceled job
+merge_request: 17646
+author:
+type: fixed
diff --git a/changelogs/unreleased/42579-fix-sidebar-dropdown-hover-style.yml b/changelogs/unreleased/42579-fix-sidebar-dropdown-hover-style.yml
new file mode 100644
index 00000000000..c0a247dc895
--- /dev/null
+++ b/changelogs/unreleased/42579-fix-sidebar-dropdown-hover-style.yml
@@ -0,0 +1,5 @@
+---
+title: Fix hover style of dropdown items in the right sidebar
+merge_request: 17519
+author:
+type: fixed
diff --git a/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml b/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml
new file mode 100644
index 00000000000..0e892a51bc5
--- /dev/null
+++ b/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Firefox stealing formatting characters on issue notes
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/43098-controller-projects-issuescontroller-show-executes-more-than-100-sql-queries.yml b/changelogs/unreleased/43098-controller-projects-issuescontroller-show-executes-more-than-100-sql-queries.yml
new file mode 100644
index 00000000000..686258460e0
--- /dev/null
+++ b/changelogs/unreleased/43098-controller-projects-issuescontroller-show-executes-more-than-100-sql-queries.yml
@@ -0,0 +1,5 @@
+---
+title: Improve performance of loading issues with lots of references to merge requests
+merge_request: 17986
+author:
+type: performance
diff --git a/changelogs/unreleased/43215-update-design-for-verifying-domains.yml b/changelogs/unreleased/43215-update-design-for-verifying-domains.yml
new file mode 100644
index 00000000000..8326540f7b2
--- /dev/null
+++ b/changelogs/unreleased/43215-update-design-for-verifying-domains.yml
@@ -0,0 +1,5 @@
+---
+title: Polish design for verifying domains
+merge_request: 17767
+author:
+type: changed
diff --git a/changelogs/unreleased/43246-checkfilter.yml b/changelogs/unreleased/43246-checkfilter.yml
new file mode 100644
index 00000000000..e6c0e716213
--- /dev/null
+++ b/changelogs/unreleased/43246-checkfilter.yml
@@ -0,0 +1,6 @@
+---
+title: Require at least one filter when listing issues or merge requests on dashboard
+ page
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml b/changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml
new file mode 100644
index 00000000000..de1cee6e436
--- /dev/null
+++ b/changelogs/unreleased/43316-controller-parameters-handling-sensitive-information-should-use-a-more-specific-name.yml
@@ -0,0 +1,5 @@
+---
+title: Use specific names for filtered CI variable controller parameters
+merge_request: 17796
+author:
+type: other
diff --git a/changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml b/changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml
new file mode 100644
index 00000000000..889fd008bad
--- /dev/null
+++ b/changelogs/unreleased/43482-enabling-auto-devops-on-an-empty-project-gives-you-wrong-information.yml
@@ -0,0 +1,5 @@
+---
+title: Add empty repo check before running AutoDevOps pipeline
+merge_request: 17605
+author:
+type: changed
diff --git a/changelogs/unreleased/43512-add-support-for-omniauth-jwt-provider.yml b/changelogs/unreleased/43512-add-support-for-omniauth-jwt-provider.yml
new file mode 100644
index 00000000000..039d3de7168
--- /dev/null
+++ b/changelogs/unreleased/43512-add-support-for-omniauth-jwt-provider.yml
@@ -0,0 +1,5 @@
+---
+title: Adds support for OmniAuth JWT provider
+merge_request: 17774
+author:
+type: added
diff --git a/changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml b/changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml
new file mode 100644
index 00000000000..f30fea3c4a7
--- /dev/null
+++ b/changelogs/unreleased/43525-limit-number-of-failed-logins-using-ldap.yml
@@ -0,0 +1,5 @@
+---
+title: Limit the number of failed logins when using LDAP for authentication
+merge_request: 43525
+author:
+type: added
diff --git a/changelogs/unreleased/43552-user-owned-projects-query-performance-improvement.yml b/changelogs/unreleased/43552-user-owned-projects-query-performance-improvement.yml
new file mode 100644
index 00000000000..39f92c281ad
--- /dev/null
+++ b/changelogs/unreleased/43552-user-owned-projects-query-performance-improvement.yml
@@ -0,0 +1,5 @@
+---
+title: Improves the performance of projects list page
+merge_request: 17934
+author:
+type: performance
diff --git a/changelogs/unreleased/43603-ci-lint-support.yml b/changelogs/unreleased/43603-ci-lint-support.yml
new file mode 100644
index 00000000000..8e4a92c0287
--- /dev/null
+++ b/changelogs/unreleased/43603-ci-lint-support.yml
@@ -0,0 +1,5 @@
+---
+title: Move ci/lint under project's namespace
+merge_request: 17729
+author:
+type: added
diff --git a/changelogs/unreleased/43702-update-label-dropdown-wording.yml b/changelogs/unreleased/43702-update-label-dropdown-wording.yml
new file mode 100644
index 00000000000..97100ec89de
--- /dev/null
+++ b/changelogs/unreleased/43702-update-label-dropdown-wording.yml
@@ -0,0 +1,5 @@
+---
+title: Update wording to specify create/manage project vs group labels in labels dropdown
+merge_request: 17640
+author:
+type: changed
diff --git a/changelogs/unreleased/43717-breadcrumb-on-admin-runner-page.yml b/changelogs/unreleased/43717-breadcrumb-on-admin-runner-page.yml
new file mode 100644
index 00000000000..3aec71d5ac4
--- /dev/null
+++ b/changelogs/unreleased/43717-breadcrumb-on-admin-runner-page.yml
@@ -0,0 +1,5 @@
+---
+title: Set breadcrumb for admin/runners/show
+merge_request: 17431
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/43720-update-fe-webpack-docs.yml b/changelogs/unreleased/43720-update-fe-webpack-docs.yml
new file mode 100644
index 00000000000..9e461eaaec8
--- /dev/null
+++ b/changelogs/unreleased/43720-update-fe-webpack-docs.yml
@@ -0,0 +1,6 @@
+---
+title: Update documentation to reflect current minimum required versions of node and
+ yarn
+merge_request: 17706
+author:
+type: other
diff --git a/changelogs/unreleased/43745-store-metadata-checksum-for-artifacts.yml b/changelogs/unreleased/43745-store-metadata-checksum-for-artifacts.yml
new file mode 100644
index 00000000000..6283e797930
--- /dev/null
+++ b/changelogs/unreleased/43745-store-metadata-checksum-for-artifacts.yml
@@ -0,0 +1,5 @@
+---
+title: Store sha256 checksum of artifact metadata
+merge_request: 18149
+author:
+type: added
diff --git a/changelogs/unreleased/43771-improve-avatar-error-message.yml b/changelogs/unreleased/43771-improve-avatar-error-message.yml
new file mode 100644
index 00000000000..1fae10f4d1f
--- /dev/null
+++ b/changelogs/unreleased/43771-improve-avatar-error-message.yml
@@ -0,0 +1,5 @@
+---
+title: Change avatar error message to include allowed file formats
+merge_request: 17747
+author: Fabian Schneider
+type: changed
diff --git a/changelogs/unreleased/43786-on-the-issuable-list-add-tooltips-to-icons.yml b/changelogs/unreleased/43786-on-the-issuable-list-add-tooltips-to-icons.yml
new file mode 100644
index 00000000000..19b633daace
--- /dev/null
+++ b/changelogs/unreleased/43786-on-the-issuable-list-add-tooltips-to-icons.yml
@@ -0,0 +1,5 @@
+---
+title: Add tooltips to icons in lists of issues and merge requests
+merge_request: 17700
+author:
+type: changed
diff --git a/changelogs/unreleased/43794-fix-domain-verification-validation-errors.yml b/changelogs/unreleased/43794-fix-domain-verification-validation-errors.yml
new file mode 100644
index 00000000000..861820c7538
--- /dev/null
+++ b/changelogs/unreleased/43794-fix-domain-verification-validation-errors.yml
@@ -0,0 +1,5 @@
+---
+title: Avoid validation errors when running the Pages domain verification service
+merge_request: 17992
+author:
+type: fixed
diff --git a/changelogs/unreleased/43805-list-gitaly-calls-and-arguments-in-the-performance-bar.yml b/changelogs/unreleased/43805-list-gitaly-calls-and-arguments-in-the-performance-bar.yml
new file mode 100644
index 00000000000..4c63e69f0bb
--- /dev/null
+++ b/changelogs/unreleased/43805-list-gitaly-calls-and-arguments-in-the-performance-bar.yml
@@ -0,0 +1,5 @@
+---
+title: Add Gitaly call details to performance bar
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/43806-update-ruby-saml-to-1-7-2.yml b/changelogs/unreleased/43806-update-ruby-saml-to-1-7-2.yml
new file mode 100644
index 00000000000..7335d313510
--- /dev/null
+++ b/changelogs/unreleased/43806-update-ruby-saml-to-1-7-2.yml
@@ -0,0 +1,5 @@
+---
+title: Update ruby-saml to 1.7.2 and omniauth-saml to 1.10.0
+merge_request: 17734
+author: Takuya Noguchi
+type: security
diff --git a/changelogs/unreleased/43933-always-notify-mentions.yml b/changelogs/unreleased/43933-always-notify-mentions.yml
new file mode 100644
index 00000000000..7b494d38541
--- /dev/null
+++ b/changelogs/unreleased/43933-always-notify-mentions.yml
@@ -0,0 +1,6 @@
+---
+title: Send @mention notifications even if a user has explicitly unsubscribed from
+ item
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/43949-verify-job-artifacts.yml b/changelogs/unreleased/43949-verify-job-artifacts.yml
new file mode 100644
index 00000000000..45e1916ae17
--- /dev/null
+++ b/changelogs/unreleased/43949-verify-job-artifacts.yml
@@ -0,0 +1,5 @@
+---
+title: Implement foreground verification of CI artifacts
+merge_request: 17578
+author:
+type: added
diff --git a/changelogs/unreleased/43976-fix-access-token-clipboard-button-style.yml b/changelogs/unreleased/43976-fix-access-token-clipboard-button-style.yml
new file mode 100644
index 00000000000..b341d5dfa7f
--- /dev/null
+++ b/changelogs/unreleased/43976-fix-access-token-clipboard-button-style.yml
@@ -0,0 +1,5 @@
+---
+title: Fix personal access token clipboard button style
+merge_request: 17978
+author: Fabian Schneider
+type: fixed
diff --git a/changelogs/unreleased/44022-singular-1-diff.yml b/changelogs/unreleased/44022-singular-1-diff.yml
new file mode 100644
index 00000000000..f4942925a73
--- /dev/null
+++ b/changelogs/unreleased/44022-singular-1-diff.yml
@@ -0,0 +1,5 @@
+---
+title: Use singular in the diff stats if only one line has been changed
+merge_request: 17697
+author: Jan Beckmann
+type: fixed
diff --git a/changelogs/unreleased/44139-fix-issue-boards-dup-keys.yml b/changelogs/unreleased/44139-fix-issue-boards-dup-keys.yml
new file mode 100644
index 00000000000..dd5f2f06d6c
--- /dev/null
+++ b/changelogs/unreleased/44139-fix-issue-boards-dup-keys.yml
@@ -0,0 +1,6 @@
+---
+title: Use object ID to prevent duplicate keys Vue warning on Issue Boards page during
+ development
+merge_request: 17682
+author:
+type: other
diff --git a/changelogs/unreleased/44160-update-foreman-to-0-84-0.yml b/changelogs/unreleased/44160-update-foreman-to-0-84-0.yml
new file mode 100644
index 00000000000..990d188eb78
--- /dev/null
+++ b/changelogs/unreleased/44160-update-foreman-to-0-84-0.yml
@@ -0,0 +1,5 @@
+---
+title: Update foreman from 0.78.0 to 0.84.0
+merge_request: 17690
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/44191-reduce-redis-usage-from-merge-request-diffs-caching.yml b/changelogs/unreleased/44191-reduce-redis-usage-from-merge-request-diffs-caching.yml
new file mode 100644
index 00000000000..8fdca6eec83
--- /dev/null
+++ b/changelogs/unreleased/44191-reduce-redis-usage-from-merge-request-diffs-caching.yml
@@ -0,0 +1,5 @@
+---
+title: Stop caching highlighted diffs in Redis unnecessarily
+merge_request: 17746
+author:
+type: performance
diff --git a/changelogs/unreleased/44218-add-internationalization-support-for-the-prometheus-merge-request-widget.yml b/changelogs/unreleased/44218-add-internationalization-support-for-the-prometheus-merge-request-widget.yml
new file mode 100644
index 00000000000..12c73281998
--- /dev/null
+++ b/changelogs/unreleased/44218-add-internationalization-support-for-the-prometheus-merge-request-widget.yml
@@ -0,0 +1,5 @@
+---
+title: Added i18n support for the prometheus memory widget
+merge_request: 17753
+author:
+type: other
diff --git a/changelogs/unreleased/44224-remove-gl.yml b/changelogs/unreleased/44224-remove-gl.yml
new file mode 100644
index 00000000000..1c792883f09
--- /dev/null
+++ b/changelogs/unreleased/44224-remove-gl.yml
@@ -0,0 +1,5 @@
+---
+title: Removes modal boards store and mixins from global scope
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/44235-update-knapsack-to-1-16-0.yml b/changelogs/unreleased/44235-update-knapsack-to-1-16-0.yml
new file mode 100644
index 00000000000..265d36b763f
--- /dev/null
+++ b/changelogs/unreleased/44235-update-knapsack-to-1-16-0.yml
@@ -0,0 +1,5 @@
+---
+title: Update knapsack to 1.16.0
+merge_request: 17735
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml b/changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml
new file mode 100644
index 00000000000..934860b95fe
--- /dev/null
+++ b/changelogs/unreleased/44257-viewing-a-particular-commit-gives-500-error-error-undefined-method-binary.yml
@@ -0,0 +1,5 @@
+---
+title: Fix viewing diffs on old merge requests
+merge_request: 17805
+author:
+type: fixed
diff --git a/changelogs/unreleased/44269-show-failure-reason-on-upgrade-tooltip-of-jobs.yml b/changelogs/unreleased/44269-show-failure-reason-on-upgrade-tooltip-of-jobs.yml
new file mode 100644
index 00000000000..b3ae8ca7340
--- /dev/null
+++ b/changelogs/unreleased/44269-show-failure-reason-on-upgrade-tooltip-of-jobs.yml
@@ -0,0 +1,5 @@
+---
+title: Display error message on job's tooltip if this one fails
+merge_request: 17782
+author:
+type: added
diff --git a/changelogs/unreleased/44280-fix-code-search.yml b/changelogs/unreleased/44280-fix-code-search.yml
new file mode 100644
index 00000000000..07f3abb224c
--- /dev/null
+++ b/changelogs/unreleased/44280-fix-code-search.yml
@@ -0,0 +1,5 @@
+---
+title: Fix search results stripping last endline when parsing the results
+merge_request: 17777
+author: Jasper Maes
+type: fixed
diff --git a/changelogs/unreleased/44291-usage-ping-for-kubernetes-integration.yml b/changelogs/unreleased/44291-usage-ping-for-kubernetes-integration.yml
new file mode 100644
index 00000000000..b5c12d8f40e
--- /dev/null
+++ b/changelogs/unreleased/44291-usage-ping-for-kubernetes-integration.yml
@@ -0,0 +1,5 @@
+---
+title: Add additional cluster usage metrics to usage ping.
+merge_request: 17922
+author:
+type: changed
diff --git a/changelogs/unreleased/44296-commit-path.yml b/changelogs/unreleased/44296-commit-path.yml
new file mode 100644
index 00000000000..b658178f8dc
--- /dev/null
+++ b/changelogs/unreleased/44296-commit-path.yml
@@ -0,0 +1,6 @@
+---
+title: Verifiy if pipeline has commit idetails and render information in MR widget
+ when branch is deleted
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml b/changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml
new file mode 100644
index 00000000000..dd8c0b19d5f
--- /dev/null
+++ b/changelogs/unreleased/44382-ui-breakdown-for-create-merge-request.yml
@@ -0,0 +1,5 @@
+---
+title: Fix UI breakdown for Create merge request button
+merge_request: 17821
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/44383-cleanup-framework-header.yml b/changelogs/unreleased/44383-cleanup-framework-header.yml
new file mode 100644
index 00000000000..ef9be9f48de
--- /dev/null
+++ b/changelogs/unreleased/44383-cleanup-framework-header.yml
@@ -0,0 +1,5 @@
+---
+title: Clean up selectors in framework/header.scss
+merge_request: 17822
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml b/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml
new file mode 100644
index 00000000000..79c470ea4e1
--- /dev/null
+++ b/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml
@@ -0,0 +1,5 @@
+---
+title: Unify format for nested non-task lists
+merge_request: 17823
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/44386-better-ux-for-long-name-branches.yml b/changelogs/unreleased/44386-better-ux-for-long-name-branches.yml
new file mode 100644
index 00000000000..16712486f0f
--- /dev/null
+++ b/changelogs/unreleased/44386-better-ux-for-long-name-branches.yml
@@ -0,0 +1,5 @@
+---
+title: UX re-design branch items with flexbox
+merge_request: 17832
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml b/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml
new file mode 100644
index 00000000000..c21d02d4d87
--- /dev/null
+++ b/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml
@@ -0,0 +1,5 @@
+---
+title: Update rack-protection to 2.0.1
+merge_request: 17835
+author: Takuya Noguchi
+type: security
diff --git a/changelogs/unreleased/44389-always-allow-http-for-ci-git-operations.yml b/changelogs/unreleased/44389-always-allow-http-for-ci-git-operations.yml
new file mode 100644
index 00000000000..2e5a0302ee6
--- /dev/null
+++ b/changelogs/unreleased/44389-always-allow-http-for-ci-git-operations.yml
@@ -0,0 +1,5 @@
+---
+title: Allow HTTP(s) when git request is made by GitLab CI
+merge_request: 18021
+author:
+type: changed
diff --git a/changelogs/unreleased/44392-resolve-projects-creation-silently-failing-on-after-create-error.yml b/changelogs/unreleased/44392-resolve-projects-creation-silently-failing-on-after-create-error.yml
new file mode 100644
index 00000000000..3bbd5a05b98
--- /dev/null
+++ b/changelogs/unreleased/44392-resolve-projects-creation-silently-failing-on-after-create-error.yml
@@ -0,0 +1,5 @@
+---
+title: Project creation will now raise an error if a service template is invalid
+merge_request: 18013
+author:
+type: fixed
diff --git a/changelogs/unreleased/44425-use-gitlab_environment.yml b/changelogs/unreleased/44425-use-gitlab_environment.yml
new file mode 100644
index 00000000000..a774143d5f5
--- /dev/null
+++ b/changelogs/unreleased/44425-use-gitlab_environment.yml
@@ -0,0 +1,5 @@
+---
+title: Fix `gitlab-rake gitlab:two_factor:disable_for_all_users`
+merge_request: 18154
+author:
+type: fixed
diff --git a/changelogs/unreleased/44508-fix-fork-namespace-images.yml b/changelogs/unreleased/44508-fix-fork-namespace-images.yml
new file mode 100644
index 00000000000..63b4b9a5e56
--- /dev/null
+++ b/changelogs/unreleased/44508-fix-fork-namespace-images.yml
@@ -0,0 +1,5 @@
+---
+title: Fix bug rendering group icons when forking
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/44649-reference-parsing-conflicting-with-auto-linking.yml b/changelogs/unreleased/44649-reference-parsing-conflicting-with-auto-linking.yml
new file mode 100644
index 00000000000..a64b0efa1ed
--- /dev/null
+++ b/changelogs/unreleased/44649-reference-parsing-conflicting-with-auto-linking.yml
@@ -0,0 +1,5 @@
+---
+title: Fix autolinking URLs containing ampersands
+merge_request: 18045
+author:
+type: fixed
diff --git a/changelogs/unreleased/44657-reuse-root_ref_hash-on-branches.yml b/changelogs/unreleased/44657-reuse-root_ref_hash-on-branches.yml
new file mode 100644
index 00000000000..4f21aadd86b
--- /dev/null
+++ b/changelogs/unreleased/44657-reuse-root_ref_hash-on-branches.yml
@@ -0,0 +1,5 @@
+---
+title: Reuse root_ref_hash for performance on Branches
+merge_request: 17998
+author: Takuya Noguchi
+type: performance
diff --git a/changelogs/unreleased/44665-fix-db-trace-stream-by-raw-access.yml b/changelogs/unreleased/44665-fix-db-trace-stream-by-raw-access.yml
new file mode 100644
index 00000000000..4166d4fe320
--- /dev/null
+++ b/changelogs/unreleased/44665-fix-db-trace-stream-by-raw-access.yml
@@ -0,0 +1,5 @@
+---
+title: Fix `JobsController#raw` endpoint can not read traces in database
+merge_request: 18101
+author:
+type: fixed
diff --git a/changelogs/unreleased/44712-update-asciidoctor-from-1-5-3-to-1-5-6-2.yml b/changelogs/unreleased/44712-update-asciidoctor-from-1-5-3-to-1-5-6-2.yml
new file mode 100644
index 00000000000..bdfed89d2ea
--- /dev/null
+++ b/changelogs/unreleased/44712-update-asciidoctor-from-1-5-3-to-1-5-6-2.yml
@@ -0,0 +1,5 @@
+---
+title: Update asciidoctor-plantuml to 0.0.8
+merge_request: 18022
+author: Takuya Noguchi
+type: performance
diff --git a/changelogs/unreleased/44717-no-resolve-issue.yml b/changelogs/unreleased/44717-no-resolve-issue.yml
new file mode 100644
index 00000000000..ce23f4e6e9f
--- /dev/null
+++ b/changelogs/unreleased/44717-no-resolve-issue.yml
@@ -0,0 +1,5 @@
+---
+title: Don't show Jump to Discussion button on Issues
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/44774-migrate-upload-task-fails-for-upload-with-store-nil.yml b/changelogs/unreleased/44774-migrate-upload-task-fails-for-upload-with-store-nil.yml
new file mode 100644
index 00000000000..372f4293964
--- /dev/null
+++ b/changelogs/unreleased/44774-migrate-upload-task-fails-for-upload-with-store-nil.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed gitlab:uploads:migrate task ignoring some uploads.
+merge_request: 18082
+author:
+type: fixed
diff --git a/changelogs/unreleased/44776-fix-upload-migrate-fails-for-group.yml b/changelogs/unreleased/44776-fix-upload-migrate-fails-for-group.yml
new file mode 100644
index 00000000000..6094fcd0b3e
--- /dev/null
+++ b/changelogs/unreleased/44776-fix-upload-migrate-fails-for-group.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed gitlab:uploads:migrate task failing for Groups' avatar.
+merge_request: 18088
+author:
+type: fixed
diff --git a/changelogs/unreleased/44870-remove-extra-space-around-comment-form-on-merge-requests.yml b/changelogs/unreleased/44870-remove-extra-space-around-comment-form-on-merge-requests.yml
new file mode 100644
index 00000000000..53e4ebdb996
--- /dev/null
+++ b/changelogs/unreleased/44870-remove-extra-space-around-comment-form-on-merge-requests.yml
@@ -0,0 +1,5 @@
+---
+title: Refactor and tweak margin for note forms on Issuable
+merge_request: 18120
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/44878-update-brakeman-3-6-1-to-4-2-1.yml b/changelogs/unreleased/44878-update-brakeman-3-6-1-to-4-2-1.yml
new file mode 100644
index 00000000000..f5710cf4f7f
--- /dev/null
+++ b/changelogs/unreleased/44878-update-brakeman-3-6-1-to-4-2-1.yml
@@ -0,0 +1,5 @@
+---
+title: Update brakeman 3.6.1 to 4.2.1
+merge_request: 18122
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/44902-remove-rake-test-ci.yml b/changelogs/unreleased/44902-remove-rake-test-ci.yml
new file mode 100644
index 00000000000..459de1c2ca3
--- /dev/null
+++ b/changelogs/unreleased/44902-remove-rake-test-ci.yml
@@ -0,0 +1,5 @@
+---
+title: Remove test_ci rake task
+merge_request: 18139
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/45070-prometheus-integration-via-kubernetes-is-broken.yml b/changelogs/unreleased/45070-prometheus-integration-via-kubernetes-is-broken.yml
new file mode 100644
index 00000000000..046785deb3f
--- /dev/null
+++ b/changelogs/unreleased/45070-prometheus-integration-via-kubernetes-is-broken.yml
@@ -0,0 +1,5 @@
+---
+title: Work around Prometheus Helm chart name changes to fix integration
+merge_request: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18206/
+author: joshlambert
+type: fixed
diff --git a/changelogs/unreleased/Link_to_project_labels_page.yml b/changelogs/unreleased/Link_to_project_labels_page.yml
new file mode 100644
index 00000000000..7bdeec423fc
--- /dev/null
+++ b/changelogs/unreleased/Link_to_project_labels_page.yml
@@ -0,0 +1,5 @@
+---
+title: Always display Labels section in issuable sidebar, even when the project has no labels
+merge_request: 18081
+author: Branka Martinovic
+type: fixed
diff --git a/changelogs/unreleased/ab-37462-cache-personal-projects-count.yml b/changelogs/unreleased/ab-37462-cache-personal-projects-count.yml
new file mode 100644
index 00000000000..55069b1f2d2
--- /dev/null
+++ b/changelogs/unreleased/ab-37462-cache-personal-projects-count.yml
@@ -0,0 +1,5 @@
+---
+title: Cache personal projects count.
+merge_request: 18197
+author:
+type: performance
diff --git a/changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml b/changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml
new file mode 100644
index 00000000000..502c1176d2d
--- /dev/null
+++ b/changelogs/unreleased/ab-43150-users-controller-show-query-limit.yml
@@ -0,0 +1,5 @@
+---
+title: Remove N+1 query for Noteable association.
+merge_request: 17956
+author:
+type: performance
diff --git a/changelogs/unreleased/ab-44467-remove-index.yml b/changelogs/unreleased/ab-44467-remove-index.yml
new file mode 100644
index 00000000000..fb772ce85d5
--- /dev/null
+++ b/changelogs/unreleased/ab-44467-remove-index.yml
@@ -0,0 +1,5 @@
+---
+title: Remove unused index from events table.
+merge_request: 18014
+author:
+type: other
diff --git a/changelogs/unreleased/ac-fix-use_file-race.yml b/changelogs/unreleased/ac-fix-use_file-race.yml
new file mode 100644
index 00000000000..f1315d5d50e
--- /dev/null
+++ b/changelogs/unreleased/ac-fix-use_file-race.yml
@@ -0,0 +1,5 @@
+---
+title: Fix data race between ObjectStorage background_upload and Pages publishing
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/ac-lfs-direct-upload-ee-to-ce.yml b/changelogs/unreleased/ac-lfs-direct-upload-ee-to-ce.yml
new file mode 100644
index 00000000000..4db7f76e0af
--- /dev/null
+++ b/changelogs/unreleased/ac-lfs-direct-upload-ee-to-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Port direct upload of LFS artifacts from EE
+merge_request: 17752
+author:
+type: added
diff --git a/changelogs/unreleased/ac-pages-port.yml b/changelogs/unreleased/ac-pages-port.yml
new file mode 100644
index 00000000000..4f7257b4798
--- /dev/null
+++ b/changelogs/unreleased/ac-pages-port.yml
@@ -0,0 +1,5 @@
+---
+title: Add missing port to artifact links
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml b/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml
new file mode 100644
index 00000000000..3b057373e7d
--- /dev/null
+++ b/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml
@@ -0,0 +1,5 @@
+---
+title: Add slash command for moving issues
+merge_request:
+author: Adam Pahlevi
+type: added
diff --git a/changelogs/unreleased/add-canary-favicon.yml b/changelogs/unreleased/add-canary-favicon.yml
new file mode 100644
index 00000000000..1af6572588d
--- /dev/null
+++ b/changelogs/unreleased/add-canary-favicon.yml
@@ -0,0 +1,5 @@
+---
+title: Add yellow favicon when `CANARY=true` to differientate canary environment
+merge_request: 12477
+author:
+type: changed
diff --git a/changelogs/unreleased/add-cpu-mem-totals.yml b/changelogs/unreleased/add-cpu-mem-totals.yml
new file mode 100644
index 00000000000..bc8babab731
--- /dev/null
+++ b/changelogs/unreleased/add-cpu-mem-totals.yml
@@ -0,0 +1,5 @@
+---
+title: Add Total CPU/Memory consumption metrics for Kubernetes
+merge_request: 17731
+author:
+type: added
diff --git a/changelogs/unreleased/add-milestone-path-to-dashboard-milestones-breadcrumb-link.yml b/changelogs/unreleased/add-milestone-path-to-dashboard-milestones-breadcrumb-link.yml
new file mode 100644
index 00000000000..015bee99170
--- /dev/null
+++ b/changelogs/unreleased/add-milestone-path-to-dashboard-milestones-breadcrumb-link.yml
@@ -0,0 +1,5 @@
+---
+title: Update dashboard milestones breadcrumb link
+merge_request: 17933
+author: George Tsiolis
+type: fixed
diff --git a/changelogs/unreleased/add-per-runner-job-timeout.yml b/changelogs/unreleased/add-per-runner-job-timeout.yml
new file mode 100644
index 00000000000..336b4d15ddf
--- /dev/null
+++ b/changelogs/unreleased/add-per-runner-job-timeout.yml
@@ -0,0 +1,5 @@
+---
+title: Add per-runner configured job timeout
+merge_request: 17221
+author:
+type: added
diff --git a/changelogs/unreleased/add-query-counts-to-profiler-output.yml b/changelogs/unreleased/add-query-counts-to-profiler-output.yml
new file mode 100644
index 00000000000..8a90b1cbeb0
--- /dev/null
+++ b/changelogs/unreleased/add-query-counts-to-profiler-output.yml
@@ -0,0 +1,5 @@
+---
+title: Add query counts to profiler output
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/add-tcp-check-rake-task.yml b/changelogs/unreleased/add-tcp-check-rake-task.yml
deleted file mode 100644
index a7c04bd0d55..00000000000
--- a/changelogs/unreleased/add-tcp-check-rake-task.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add a gitlab:tcp_check rake task
-merge_request: 15759
-author:
-type: added
diff --git a/changelogs/unreleased/add_project_ci_config_path_leading_slash_validation.yml b/changelogs/unreleased/add_project_ci_config_path_leading_slash_validation.yml
deleted file mode 100644
index 1c96bd66ed4..00000000000
--- a/changelogs/unreleased/add_project_ci_config_path_leading_slash_validation.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Prefer ci_config_path validation for leading slashes instead of sanitizing
- the input
-merge_request: 15672
-author: Christiaan Van den Poel
-type: other
diff --git a/changelogs/unreleased/admin-welcome-new-group-link.yml b/changelogs/unreleased/admin-welcome-new-group-link.yml
deleted file mode 100644
index 9c939a0a3ad..00000000000
--- a/changelogs/unreleased/admin-welcome-new-group-link.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed admin welcome screen new group path
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/ajax-requests-in-performance-bar.yml b/changelogs/unreleased/ajax-requests-in-performance-bar.yml
new file mode 100644
index 00000000000..88cc3678c2b
--- /dev/null
+++ b/changelogs/unreleased/ajax-requests-in-performance-bar.yml
@@ -0,0 +1,5 @@
+---
+title: Allow viewing timings for AJAX requests in the performance bar
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/an-gitaly-timeouts.yml b/changelogs/unreleased/an-gitaly-timeouts.yml
deleted file mode 100644
index e18d82b2704..00000000000
--- a/changelogs/unreleased/an-gitaly-timeouts.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add timeouts for Gitaly calls
-merge_request: 15047
-author:
-type: performance
diff --git a/changelogs/unreleased/anchor-issue-references.yml b/changelogs/unreleased/anchor-issue-references.yml
deleted file mode 100644
index 78896427417..00000000000
--- a/changelogs/unreleased/anchor-issue-references.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Fix false positive issue references in merge requests caused by header anchor
- links.
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/blackst0ne-bump-html-pipeline-gem.yml b/changelogs/unreleased/blackst0ne-bump-html-pipeline-gem.yml
new file mode 100644
index 00000000000..9885c8853cc
--- /dev/null
+++ b/changelogs/unreleased/blackst0ne-bump-html-pipeline-gem.yml
@@ -0,0 +1,5 @@
+---
+title: Bump html-pipeline to 2.7.1
+merge_request: 18132
+author: "@blackst0ne"
+type: other
diff --git a/changelogs/unreleased/blackst0ne-rails5-update-state_machines-activerecord-gem.yml b/changelogs/unreleased/blackst0ne-rails5-update-state_machines-activerecord-gem.yml
new file mode 100644
index 00000000000..a9c6fcbf428
--- /dev/null
+++ b/changelogs/unreleased/blackst0ne-rails5-update-state_machines-activerecord-gem.yml
@@ -0,0 +1,5 @@
+---
+title: Bump `state_machines-activerecord` to 0.5.1
+merge_request: 17924
+author: blackst0ne
+type: other
diff --git a/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-issues-feature.yml b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-issues-feature.yml
new file mode 100644
index 00000000000..7defdc0a28f
--- /dev/null
+++ b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-issues-feature.yml
@@ -0,0 +1,5 @@
+---
+title: Replace the spinach test with an rspec analog
+merge_request: 17950
+author: blackst0ne
+type: other
diff --git a/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-labels-feature.yml b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-labels-feature.yml
new file mode 100644
index 00000000000..4e1bb15f150
--- /dev/null
+++ b/changelogs/unreleased/blackst0ne-replace-spinach-project-issues-labels-feature.yml
@@ -0,0 +1,5 @@
+---
+title: Replace the `project/issues/labels.feature` spinach test with an rspec analog
+merge_request: 18126
+author: blackst0ne
+type: other
diff --git a/changelogs/unreleased/brand_header_change.yml b/changelogs/unreleased/brand_header_change.yml
deleted file mode 100644
index 6ea6e8192a4..00000000000
--- a/changelogs/unreleased/brand_header_change.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: When a custom header logo is present, don't show GitLab type logo
-merge_request:
-author:
-type: changed
diff --git a/changelogs/unreleased/bvl-circuitbreaker-process.yml b/changelogs/unreleased/bvl-circuitbreaker-process.yml
deleted file mode 100644
index 595dd13f724..00000000000
--- a/changelogs/unreleased/bvl-circuitbreaker-process.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Monitor NFS shards for circuitbreaker in a separate process
-merge_request: 15426
-author:
-type: changed
diff --git a/changelogs/unreleased/bvl-delete-empty-fork-networks.yml b/changelogs/unreleased/bvl-delete-empty-fork-networks.yml
deleted file mode 100644
index 3bbb4cf6e3c..00000000000
--- a/changelogs/unreleased/bvl-delete-empty-fork-networks.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Clean up empty fork networks
-merge_request: 15373
-author:
-type: other
diff --git a/changelogs/unreleased/bvl-export-import-lfs.yml b/changelogs/unreleased/bvl-export-import-lfs.yml
new file mode 100644
index 00000000000..dd1f499c3a3
--- /dev/null
+++ b/changelogs/unreleased/bvl-export-import-lfs.yml
@@ -0,0 +1,5 @@
+---
+title: Support LFS objects when importing/exporting GitLab project archives
+merge_request: 18115
+author:
+type: added
diff --git a/changelogs/unreleased/bvl-fix-unlinking-with-lfs-objects.yml b/changelogs/unreleased/bvl-fix-unlinking-with-lfs-objects.yml
deleted file mode 100644
index 058d686e74c..00000000000
--- a/changelogs/unreleased/bvl-fix-unlinking-with-lfs-objects.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Don't link LFS objects to a project when unlinking forks when they were already
- linked
-merge_request: 16006
-author:
-type: fixed
diff --git a/changelogs/unreleased/bvl-import-zip-multiple-assignees.yml b/changelogs/unreleased/bvl-import-zip-multiple-assignees.yml
new file mode 100644
index 00000000000..86bd5faf8ed
--- /dev/null
+++ b/changelogs/unreleased/bvl-import-zip-multiple-assignees.yml
@@ -0,0 +1,5 @@
+---
+title: Fix importing multiple assignees from GitLab export
+merge_request: 17718
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-limit-fork-queries-on-project-show.yml b/changelogs/unreleased/bvl-limit-fork-queries-on-project-show.yml
deleted file mode 100644
index 299d9bf6b9c..00000000000
--- a/changelogs/unreleased/bvl-limit-fork-queries-on-project-show.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Reduce requests for project forks on show page of projects that have forks
-merge_request: 15663
-author:
-type: performance
diff --git a/changelogs/unreleased/bvl-no-permanent-redirect.yml b/changelogs/unreleased/bvl-no-permanent-redirect.yml
new file mode 100644
index 00000000000..c34a3789b58
--- /dev/null
+++ b/changelogs/unreleased/bvl-no-permanent-redirect.yml
@@ -0,0 +1,5 @@
+---
+title: Don't create permanent redirect routes
+merge_request: 17521
+author:
+type: changed
diff --git a/changelogs/unreleased/bvl-override-import-params.yml b/changelogs/unreleased/bvl-override-import-params.yml
new file mode 100644
index 00000000000..18cfef873df
--- /dev/null
+++ b/changelogs/unreleased/bvl-override-import-params.yml
@@ -0,0 +1,5 @@
+---
+title: Allow overriding params on project import through API
+merge_request: 18086
+author:
+type: added
diff --git a/changelogs/unreleased/bvl-subgroup-in-dropdowns.yml b/changelogs/unreleased/bvl-subgroup-in-dropdowns.yml
deleted file mode 100644
index 1114d429dec..00000000000
--- a/changelogs/unreleased/bvl-subgroup-in-dropdowns.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make sure a user can add projects to subgroups they have access to
-merge_request: 15294
-author:
-type: fixed
diff --git a/changelogs/unreleased/ci-pipeline-commit-lookup.yml b/changelogs/unreleased/ci-pipeline-commit-lookup.yml
new file mode 100644
index 00000000000..b2a1e4c2163
--- /dev/null
+++ b/changelogs/unreleased/ci-pipeline-commit-lookup.yml
@@ -0,0 +1,5 @@
+---
+title: Use porcelain commit lookup method on CI::CreatePipelineService
+merge_request: 17911
+author:
+type: fixed
diff --git a/changelogs/unreleased/cleanup-issues-schema.yml b/changelogs/unreleased/cleanup-issues-schema.yml
deleted file mode 100644
index 9f5fb0bdf82..00000000000
--- a/changelogs/unreleased/cleanup-issues-schema.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Clean up schema of the "issues" table
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/commit-title-wrapping.yml b/changelogs/unreleased/commit-title-wrapping.yml
deleted file mode 100644
index 65c28b82b8b..00000000000
--- a/changelogs/unreleased/commit-title-wrapping.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed long commit links not wrapping correctly
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/da-gitaly-calculate-repository-checksum.yml b/changelogs/unreleased/da-gitaly-calculate-repository-checksum.yml
new file mode 100644
index 00000000000..de09f87a7c9
--- /dev/null
+++ b/changelogs/unreleased/da-gitaly-calculate-repository-checksum.yml
@@ -0,0 +1,5 @@
+---
+title: Repository checksum calculation is handled by Gitaly when feature is enabled
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/dashboard-view-user-choices-issues-merge-requests.yml b/changelogs/unreleased/dashboard-view-user-choices-issues-merge-requests.yml
new file mode 100644
index 00000000000..92a03070d78
--- /dev/null
+++ b/changelogs/unreleased/dashboard-view-user-choices-issues-merge-requests.yml
@@ -0,0 +1,5 @@
+---
+title: Add 'Assigned Issues' and 'Assigned Merge Requests' as dashboard view choices for users
+merge_request: 17860
+author: Elias Werberich
+type: added
diff --git a/changelogs/unreleased/deploy-keys-loading-icon.yml b/changelogs/unreleased/deploy-keys-loading-icon.yml
deleted file mode 100644
index e3cb5bc6924..00000000000
--- a/changelogs/unreleased/deploy-keys-loading-icon.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed deploy keys remove button loading state not resetting
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/direct-upload-of-uploads.yml b/changelogs/unreleased/direct-upload-of-uploads.yml
new file mode 100644
index 00000000000..7900fa5f58d
--- /dev/null
+++ b/changelogs/unreleased/direct-upload-of-uploads.yml
@@ -0,0 +1,5 @@
+---
+title: Allow to store uploads by default on Object Storage
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml b/changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml
deleted file mode 100644
index 1f8b42ea21f..00000000000
--- a/changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make diff notes created on a commit in a merge request to persist a rebase.
-merge_request: 12148
-author:
-type: added
diff --git a/changelogs/unreleased/dm-deploy-keys-default-user.yml b/changelogs/unreleased/dm-deploy-keys-default-user.yml
new file mode 100644
index 00000000000..b82d67d028c
--- /dev/null
+++ b/changelogs/unreleased/dm-deploy-keys-default-user.yml
@@ -0,0 +1,5 @@
+---
+title: Ensure hooks run when a deploy key without a user pushes
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/dm-flatten-tree-plus-chars.yml b/changelogs/unreleased/dm-flatten-tree-plus-chars.yml
new file mode 100644
index 00000000000..23f1b30d8fa
--- /dev/null
+++ b/changelogs/unreleased/dm-flatten-tree-plus-chars.yml
@@ -0,0 +1,5 @@
+---
+title: Fix links to subdirectories of a directory with a plus character in its path
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/dm-image-blob-diff-full-url.yml b/changelogs/unreleased/dm-image-blob-diff-full-url.yml
deleted file mode 100644
index db44a5a16b5..00000000000
--- a/changelogs/unreleased/dm-image-blob-diff-full-url.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Use app host instead of asset host when rendering image blob or diff
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/dm-internal-user-namespace.yml b/changelogs/unreleased/dm-internal-user-namespace.yml
new file mode 100644
index 00000000000..8517c116795
--- /dev/null
+++ b/changelogs/unreleased/dm-internal-user-namespace.yml
@@ -0,0 +1,5 @@
+---
+title: Ensure internal users (ghost, support bot) get assigned a namespace
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/dm-ldap-email-readonly.yml b/changelogs/unreleased/dm-ldap-email-readonly.yml
deleted file mode 100644
index 744b21f901e..00000000000
--- a/changelogs/unreleased/dm-ldap-email-readonly.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make sure user email is read only when synced with LDAP
-merge_request: 15915
-author:
-type: fixed
diff --git a/changelogs/unreleased/dm-refs-contains-sha-encoding.yml b/changelogs/unreleased/dm-refs-contains-sha-encoding.yml
new file mode 100644
index 00000000000..cdd9ead5a65
--- /dev/null
+++ b/changelogs/unreleased/dm-refs-contains-sha-encoding.yml
@@ -0,0 +1,5 @@
+---
+title: Fix listing commit branch/tags that contain special characters
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/dm-search-pattern.yml b/changelogs/unreleased/dm-search-pattern.yml
deleted file mode 100644
index 1670d8c4b9a..00000000000
--- a/changelogs/unreleased/dm-search-pattern.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Use fuzzy search with minimum length of 3 characters where appropriate
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/docs-add-why-do-i-get-signed-out-authentication-section.yml b/changelogs/unreleased/docs-add-why-do-i-get-signed-out-authentication-section.yml
deleted file mode 100644
index bc245880ed0..00000000000
--- a/changelogs/unreleased/docs-add-why-do-i-get-signed-out-authentication-section.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add docs for why you might be signed out when using the Remember me token
-merge_request: 15756
-author:
-type: other
diff --git a/changelogs/unreleased/dz-improve-app-settings-2.yml b/changelogs/unreleased/dz-improve-app-settings-2.yml
new file mode 100644
index 00000000000..ebe571decb8
--- /dev/null
+++ b/changelogs/unreleased/dz-improve-app-settings-2.yml
@@ -0,0 +1,5 @@
+---
+title: Redesign application settings to match project settings
+merge_request: 18019
+author:
+type: changed
diff --git a/changelogs/unreleased/enable-scss-lint-unnecessary-mantissa.yml b/changelogs/unreleased/enable-scss-lint-unnecessary-mantissa.yml
deleted file mode 100644
index 1049e94f312..00000000000
--- a/changelogs/unreleased/enable-scss-lint-unnecessary-mantissa.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Enable UnnecessaryMantissa in scss-lint
-merge_request: 15255
-author: Takuya Noguchi
-type: other
diff --git a/changelogs/unreleased/escape-autocomplete-values-for-markdown.yml b/changelogs/unreleased/escape-autocomplete-values-for-markdown.yml
new file mode 100644
index 00000000000..eea9da4c579
--- /dev/null
+++ b/changelogs/unreleased/escape-autocomplete-values-for-markdown.yml
@@ -0,0 +1,5 @@
+---
+title: Escape Markdown characters properly when using autocomplete
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/expose-commits-mr-api.yml b/changelogs/unreleased/expose-commits-mr-api.yml
new file mode 100644
index 00000000000..77ea2f27431
--- /dev/null
+++ b/changelogs/unreleased/expose-commits-mr-api.yml
@@ -0,0 +1,5 @@
+---
+title: Allow merge requests related to a commit to be found via API
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/feature-40842-provide-oracles-webgate-cookies-to-jira-requests.yml b/changelogs/unreleased/feature-40842-provide-oracles-webgate-cookies-to-jira-requests.yml
deleted file mode 100644
index d5ff5bc4627..00000000000
--- a/changelogs/unreleased/feature-40842-provide-oracles-webgate-cookies-to-jira-requests.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Provide additional cookies to JIRA service requests to allow Oracle WebGates
- Basic Auth
-merge_request:
-author: Stanislaw Wozniak
-type: changed
diff --git a/changelogs/unreleased/feature-add_target_to_tags.yml b/changelogs/unreleased/feature-add_target_to_tags.yml
new file mode 100644
index 00000000000..75816005e1f
--- /dev/null
+++ b/changelogs/unreleased/feature-add_target_to_tags.yml
@@ -0,0 +1,5 @@
+---
+title: Expose the target commit ID through the tag API
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/feature-custom-text-for-new-projects.yml b/changelogs/unreleased/feature-custom-text-for-new-projects.yml
deleted file mode 100644
index 905d76dab33..00000000000
--- a/changelogs/unreleased/feature-custom-text-for-new-projects.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add custom brand text on new project pages
-merge_request: 15541
-author: Markus Koller
-type: changed
diff --git a/changelogs/unreleased/feature-disable-password-authentication.yml b/changelogs/unreleased/feature-disable-password-authentication.yml
deleted file mode 100644
index 999203f12ce..00000000000
--- a/changelogs/unreleased/feature-disable-password-authentication.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Allow password authentication to be disabled entirely
-merge_request: 15223
-author: Markus Koller
-type: changed
diff --git a/changelogs/unreleased/feature-gb-variables-expressions-in-only-except.yml b/changelogs/unreleased/feature-gb-variables-expressions-in-only-except.yml
new file mode 100644
index 00000000000..84977ce11c8
--- /dev/null
+++ b/changelogs/unreleased/feature-gb-variables-expressions-in-only-except.yml
@@ -0,0 +1,5 @@
+---
+title: Add support for pipeline variables expressions in only/except
+merge_request: 17316
+author:
+type: added
diff --git a/changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml b/changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml
deleted file mode 100644
index ab85b8ee515..00000000000
--- a/changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fail jobs if its dependency is missing
-merge_request: 14009
-author:
-type: fixed
diff --git a/changelogs/unreleased/feature_add_mermaid.yml b/changelogs/unreleased/feature_add_mermaid.yml
deleted file mode 100644
index caeb5d3470d..00000000000
--- a/changelogs/unreleased/feature_add_mermaid.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: 'Add support of Mermaid (generation of diagrams and flowcharts from text)'
-merge_request: 15107
-author: Vitaliy @blackst0ne Klachkov
-type: added
diff --git a/changelogs/unreleased/feature_detect_co_authored_commits.yml b/changelogs/unreleased/feature_detect_co_authored_commits.yml
new file mode 100644
index 00000000000..7b1269ed982
--- /dev/null
+++ b/changelogs/unreleased/feature_detect_co_authored_commits.yml
@@ -0,0 +1,6 @@
+---
+title: Detect commit message trailers and link users properly to their accounts
+ on Gitlab
+merge_request: 17919
+author: cousine
+type: added
diff --git a/changelogs/unreleased/fix-40798-namespace-forking.yml b/changelogs/unreleased/fix-40798-namespace-forking.yml
new file mode 100644
index 00000000000..095235725f8
--- /dev/null
+++ b/changelogs/unreleased/fix-40798-namespace-forking.yml
@@ -0,0 +1,5 @@
+---
+title: Fix forking to subgroup via API when namespace is given by name
+merge_request: 17815
+author: Jan Beckmann
+type: fixed
diff --git a/changelogs/unreleased/fix-42459---in-branch.yml b/changelogs/unreleased/fix-42459---in-branch.yml
new file mode 100644
index 00000000000..26cc2046206
--- /dev/null
+++ b/changelogs/unreleased/fix-42459---in-branch.yml
@@ -0,0 +1,5 @@
+---
+title: Fix relative uri when "#" is in branch name
+merge_request:
+author: Jan
+type: fixed
diff --git a/changelogs/unreleased/fix-500-error-when-mr-ref-is-not-yet-fetched.yml b/changelogs/unreleased/fix-500-error-when-mr-ref-is-not-yet-fetched.yml
new file mode 100644
index 00000000000..e21554f091a
--- /dev/null
+++ b/changelogs/unreleased/fix-500-error-when-mr-ref-is-not-yet-fetched.yml
@@ -0,0 +1,6 @@
+---
+title: Fix 500 error when a merge request from a fork has conflicts and has not yet
+ been updated
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-auth0-unsafe-login.yml b/changelogs/unreleased/fix-auth0-unsafe-login.yml
new file mode 100644
index 00000000000..01c6ea69dcc
--- /dev/null
+++ b/changelogs/unreleased/fix-auth0-unsafe-login.yml
@@ -0,0 +1,5 @@
+---
+title: Fix GitLab Auth0 integration signing in the wrong user
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/fix-create-mr-from-issue-with-template.yml b/changelogs/unreleased/fix-create-mr-from-issue-with-template.yml
deleted file mode 100644
index 8668aa18669..00000000000
--- a/changelogs/unreleased/fix-create-mr-from-issue-with-template.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Execute quick actions (if present) when creating MR from issue
-merge_request: 15810
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix-dashboard-sorting.yml b/changelogs/unreleased/fix-dashboard-sorting.yml
new file mode 100644
index 00000000000..2ba13a93fa9
--- /dev/null
+++ b/changelogs/unreleased/fix-dashboard-sorting.yml
@@ -0,0 +1,5 @@
+---
+title: Prioritize weight over title when sorting charts
+merge_request: 18233
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-docs-help-shortcut.yml b/changelogs/unreleased/fix-docs-help-shortcut.yml
deleted file mode 100644
index 8c172e44160..00000000000
--- a/changelogs/unreleased/fix-docs-help-shortcut.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix shortcut links on help page
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix-emoji-popup.yml b/changelogs/unreleased/fix-emoji-popup.yml
new file mode 100644
index 00000000000..c81d061a5d7
--- /dev/null
+++ b/changelogs/unreleased/fix-emoji-popup.yml
@@ -0,0 +1,5 @@
+---
+title: Hide emoji popup after multiple spaces
+merge_request:
+author: Jan Beckmann
+type: fixed
diff --git a/changelogs/unreleased/fix-event-target-author-preloading.yml b/changelogs/unreleased/fix-event-target-author-preloading.yml
deleted file mode 100644
index c6154cc0835..00000000000
--- a/changelogs/unreleased/fix-event-target-author-preloading.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix N+1 query when displaying events
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/fix-filter-by-my-reaction.yml b/changelogs/unreleased/fix-filter-by-my-reaction.yml
deleted file mode 100644
index 8bf91ddf893..00000000000
--- a/changelogs/unreleased/fix-filter-by-my-reaction.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix filter by my reaction is not working
-merge_request: 15345
-author: Hiroyuki Sato
-type: fixed
diff --git a/changelogs/unreleased/fix-gb-fix-background-pipeline-stages-migration.yml b/changelogs/unreleased/fix-gb-fix-background-pipeline-stages-migration.yml
new file mode 100644
index 00000000000..63948f0c196
--- /dev/null
+++ b/changelogs/unreleased/fix-gb-fix-background-pipeline-stages-migration.yml
@@ -0,0 +1,5 @@
+---
+title: Fix exceptions raised when migrating pipeline stages in the background
+merge_request: 18076
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-mattermost-delete-team.yml b/changelogs/unreleased/fix-mattermost-delete-team.yml
new file mode 100644
index 00000000000..d14ae023114
--- /dev/null
+++ b/changelogs/unreleased/fix-mattermost-delete-team.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed group deletion linked to Mattermost
+merge_request: 16209
+author: Julien Millau
+type: fixed
diff --git a/changelogs/unreleased/fix-n-plus-one-when-getting-notification-settings-for-recipients.yml b/changelogs/unreleased/fix-n-plus-one-when-getting-notification-settings-for-recipients.yml
new file mode 100644
index 00000000000..837bfed19b7
--- /dev/null
+++ b/changelogs/unreleased/fix-n-plus-one-when-getting-notification-settings-for-recipients.yml
@@ -0,0 +1,5 @@
+---
+title: Add Goldiloader to fix N+1 issues when calculating email recipients
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/fix-new-project-guidelines-styling.yml b/changelogs/unreleased/fix-new-project-guidelines-styling.yml
deleted file mode 100644
index a97f5c485d4..00000000000
--- a/changelogs/unreleased/fix-new-project-guidelines-styling.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Use Markdown styling for new project guidelines
-merge_request: 15785
-author: Markus Koller
-type: fixed
diff --git a/changelogs/unreleased/fix-onion-skin-reenter.yml b/changelogs/unreleased/fix-onion-skin-reenter.yml
deleted file mode 100644
index 66b12c037b0..00000000000
--- a/changelogs/unreleased/fix-onion-skin-reenter.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix onion-skin re-entering state
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix-projects-no-repository-placeholder.yml b/changelogs/unreleased/fix-projects-no-repository-placeholder.yml
new file mode 100644
index 00000000000..3d11c897020
--- /dev/null
+++ b/changelogs/unreleased/fix-projects-no-repository-placeholder.yml
@@ -0,0 +1,5 @@
+---
+title: Update no repository placeholder
+merge_request: 17964
+author: George Tsiolis
+type: fixed
diff --git a/changelogs/unreleased/fix-protected-branches-descriptions.yml b/changelogs/unreleased/fix-protected-branches-descriptions.yml
deleted file mode 100644
index 8e233d9defd..00000000000
--- a/changelogs/unreleased/fix-protected-branches-descriptions.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Clarify wording of protected branch settings for the default branch
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/fix-sm-31771-do-not-allow-jobs-to-be-erased-new.yml b/changelogs/unreleased/fix-sm-31771-do-not-allow-jobs-to-be-erased-new.yml
deleted file mode 100644
index 198116f34aa..00000000000
--- a/changelogs/unreleased/fix-sm-31771-do-not-allow-jobs-to-be-erased-new.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Only owner or master can erase jobs
-merge_request: 15216
-author:
-type: changed
diff --git a/changelogs/unreleased/fix-sm-37991-avoid-deactivation-when-pipeline-schedules-execute-a-commit-includes-ci-skip.yml b/changelogs/unreleased/fix-sm-37991-avoid-deactivation-when-pipeline-schedules-execute-a-commit-includes-ci-skip.yml
deleted file mode 100644
index 4e525c875ca..00000000000
--- a/changelogs/unreleased/fix-sm-37991-avoid-deactivation-when-pipeline-schedules-execute-a-commit-includes-ci-skip.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Avoid deactivation when pipeline schedules execute a branch includes `[ci skip]`
- comment
-merge_request: 15405
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix-todos-last-page.yml b/changelogs/unreleased/fix-todos-last-page.yml
deleted file mode 100644
index efcdbb75e6e..00000000000
--- a/changelogs/unreleased/fix-todos-last-page.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix access to the final page of todos
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix_build_count_in_pipeline_success_maild.yml b/changelogs/unreleased/fix_build_count_in_pipeline_success_maild.yml
deleted file mode 100644
index c39bba62271..00000000000
--- a/changelogs/unreleased/fix_build_count_in_pipeline_success_maild.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: fix build count in pipeline success mail
-merge_request: 15827
-author: Christiaan Van den Poel
-type: fixed
diff --git a/changelogs/unreleased/fj-15329-services-callbacks-ssrf.yml b/changelogs/unreleased/fj-15329-services-callbacks-ssrf.yml
new file mode 100644
index 00000000000..7fa6f6a5874
--- /dev/null
+++ b/changelogs/unreleased/fj-15329-services-callbacks-ssrf.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed some SSRF vulnerabilities in services, hooks and integrations
+merge_request: 2337
+author:
+type: security
diff --git a/changelogs/unreleased/fj-174-better-ldap-connection-handling.yml b/changelogs/unreleased/fj-174-better-ldap-connection-handling.yml
new file mode 100644
index 00000000000..be0b83505fb
--- /dev/null
+++ b/changelogs/unreleased/fj-174-better-ldap-connection-handling.yml
@@ -0,0 +1,5 @@
+---
+title: Add better LDAP connection handling
+merge_request: 18039
+author:
+type: fixed
diff --git a/changelogs/unreleased/fj-40279-normalize-ldap-dn-api.yml b/changelogs/unreleased/fj-40279-normalize-ldap-dn-api.yml
deleted file mode 100644
index 3fd8b0eb988..00000000000
--- a/changelogs/unreleased/fj-40279-normalize-ldap-dn-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Normalizing Identity extern_uid when saving the record
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fj-40407-missing-order-paginate.yml b/changelogs/unreleased/fj-40407-missing-order-paginate.yml
deleted file mode 100644
index 27471dc2c52..00000000000
--- a/changelogs/unreleased/fj-40407-missing-order-paginate.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Added default order to UsersFinder
-merge_request: 15679
-author:
-type: fixed
diff --git a/changelogs/unreleased/fj-40752-forks-api-not-using-services.yml b/changelogs/unreleased/fj-40752-forks-api-not-using-services.yml
deleted file mode 100644
index cd7b87596e6..00000000000
--- a/changelogs/unreleased/fj-40752-forks-api-not-using-services.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Using appropiate services in the API for managing forks
-merge_request: 15709
-author:
-type: fixed
diff --git a/changelogs/unreleased/fj-41900-import-endpoint-with-overwrite-support.yml b/changelogs/unreleased/fj-41900-import-endpoint-with-overwrite-support.yml
new file mode 100644
index 00000000000..0553cc684ce
--- /dev/null
+++ b/changelogs/unreleased/fj-41900-import-endpoint-with-overwrite-support.yml
@@ -0,0 +1,5 @@
+---
+title: Extend API for importing a project export with overwrite support
+merge_request: 17883
+author:
+type: added
diff --git a/changelogs/unreleased/fj-42685-extend-project-export-endpoint.yml b/changelogs/unreleased/fj-42685-extend-project-export-endpoint.yml
new file mode 100644
index 00000000000..a06499d821a
--- /dev/null
+++ b/changelogs/unreleased/fj-42685-extend-project-export-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Extend API for exporting a project with direct upload URL
+merge_request: 17686
+author:
+type: added
diff --git a/changelogs/unreleased/fl-fix-annoying-actions.yml b/changelogs/unreleased/fl-fix-annoying-actions.yml
new file mode 100644
index 00000000000..fe17f9a8978
--- /dev/null
+++ b/changelogs/unreleased/fl-fix-annoying-actions.yml
@@ -0,0 +1,5 @@
+---
+title: Stop redirecting the page in pipeline main actions
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fl-upgrade-svg.yml b/changelogs/unreleased/fl-upgrade-svg.yml
deleted file mode 100644
index caf73881d0f..00000000000
--- a/changelogs/unreleased/fl-upgrade-svg.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Update svg external depencency
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/group-new-miletone-breadcrumb.yml b/changelogs/unreleased/group-new-miletone-breadcrumb.yml
deleted file mode 100644
index b82c5b604e8..00000000000
--- a/changelogs/unreleased/group-new-miletone-breadcrumb.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed new group milestone breadcrumbs
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/hashed-storage-attachments-migration-path.yml b/changelogs/unreleased/hashed-storage-attachments-migration-path.yml
deleted file mode 100644
index 32535437046..00000000000
--- a/changelogs/unreleased/hashed-storage-attachments-migration-path.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Hashed Storage migration script now supports migrating project attachments
-merge_request: 15352
-author:
-type: added
diff --git a/changelogs/unreleased/ide-file-row-hover-style.yml b/changelogs/unreleased/ide-file-row-hover-style.yml
new file mode 100644
index 00000000000..158379a5aef
--- /dev/null
+++ b/changelogs/unreleased/ide-file-row-hover-style.yml
@@ -0,0 +1,5 @@
+---
+title: Added hover background color to IDE file list rows
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/ide-folder-button-path.yml b/changelogs/unreleased/ide-folder-button-path.yml
new file mode 100644
index 00000000000..84a122fab75
--- /dev/null
+++ b/changelogs/unreleased/ide-folder-button-path.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed IDE button opening the wrong URL in tree list
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/ide-project-avatar-identicon.yml b/changelogs/unreleased/ide-project-avatar-identicon.yml
new file mode 100644
index 00000000000..2b8b00018a8
--- /dev/null
+++ b/changelogs/unreleased/ide-project-avatar-identicon.yml
@@ -0,0 +1,5 @@
+---
+title: Make project avatar in IDE consistent with the rest of GitLab
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/improve-jobs-queuing-time-metric.yml b/changelogs/unreleased/improve-jobs-queuing-time-metric.yml
new file mode 100644
index 00000000000..cee8b8523fd
--- /dev/null
+++ b/changelogs/unreleased/improve-jobs-queuing-time-metric.yml
@@ -0,0 +1,5 @@
+---
+title: Partition job_queue_duration_seconds with jobs_running_for_project
+merge_request: 17730
+author:
+type: changed
diff --git a/changelogs/unreleased/improved-changes-dropdown.yml b/changelogs/unreleased/improved-changes-dropdown.yml
deleted file mode 100644
index f305cbe573b..00000000000
--- a/changelogs/unreleased/improved-changes-dropdown.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Improved diff changed files dropdown design
-merge_request:
-author:
-type: changed
diff --git a/changelogs/unreleased/increase-unicorn-memory-killer-limits.yml b/changelogs/unreleased/increase-unicorn-memory-killer-limits.yml
new file mode 100644
index 00000000000..6d7d2df4f4a
--- /dev/null
+++ b/changelogs/unreleased/increase-unicorn-memory-killer-limits.yml
@@ -0,0 +1,5 @@
+---
+title: Increase the memory limits used in the unicorn killer
+merge_request: 17948
+author:
+type: other
diff --git a/changelogs/unreleased/issue-description-field-typo.yml b/changelogs/unreleased/issue-description-field-typo.yml
deleted file mode 100644
index 9c4c179876d..00000000000
--- a/changelogs/unreleased/issue-description-field-typo.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed typo for issue description field declaration
-merge_request:
-author: Marcus Amargi
-type: fixed
diff --git a/changelogs/unreleased/issue_25542.yml b/changelogs/unreleased/issue_25542.yml
new file mode 100644
index 00000000000..eba491f7e2a
--- /dev/null
+++ b/changelogs/unreleased/issue_25542.yml
@@ -0,0 +1,5 @@
+---
+title: Improve JIRA event descriptions
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/issue_39238.yml b/changelogs/unreleased/issue_39238.yml
deleted file mode 100644
index 75a4969ca9e..00000000000
--- a/changelogs/unreleased/issue_39238.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix image diff notification email from showing wrong content
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/issue_40915.yml b/changelogs/unreleased/issue_40915.yml
new file mode 100644
index 00000000000..2b6d98e69a6
--- /dev/null
+++ b/changelogs/unreleased/issue_40915.yml
@@ -0,0 +1,5 @@
+---
+title: Allow assigning and filtering issuables by ancestor group labels
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/issue_42443.yml b/changelogs/unreleased/issue_42443.yml
new file mode 100644
index 00000000000..954fbd98a4b
--- /dev/null
+++ b/changelogs/unreleased/issue_42443.yml
@@ -0,0 +1,5 @@
+---
+title: Include subgroup issues when searching for group issues using the API
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/issue_44270.yml b/changelogs/unreleased/issue_44270.yml
new file mode 100644
index 00000000000..6234162be30
--- /dev/null
+++ b/changelogs/unreleased/issue_44270.yml
@@ -0,0 +1,5 @@
+---
+title: Show issues of subgroups in group-level issue board
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/issue_44551.yml b/changelogs/unreleased/issue_44551.yml
new file mode 100644
index 00000000000..d5265667b00
--- /dev/null
+++ b/changelogs/unreleased/issue_44551.yml
@@ -0,0 +1,5 @@
+---
+title: Fix 404 in group boards when moving issue between lists
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/jej-commit-api-tracks-lfs.yml b/changelogs/unreleased/jej-commit-api-tracks-lfs.yml
new file mode 100644
index 00000000000..8284abf9f28
--- /dev/null
+++ b/changelogs/unreleased/jej-commit-api-tracks-lfs.yml
@@ -0,0 +1,5 @@
+---
+title: Create commit API and Web IDE obey LFS filters
+merge_request: 16718
+author:
+type: fixed
diff --git a/changelogs/unreleased/jej-mattermost-notification-confidentiality.yml b/changelogs/unreleased/jej-mattermost-notification-confidentiality.yml
new file mode 100644
index 00000000000..d5219b5d019
--- /dev/null
+++ b/changelogs/unreleased/jej-mattermost-notification-confidentiality.yml
@@ -0,0 +1,5 @@
+---
+title: Adds confidential notes channel for Slack/Mattermost
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/jivl-change-copy-text-promote-milestones-labels.yml b/changelogs/unreleased/jivl-change-copy-text-promote-milestones-labels.yml
new file mode 100644
index 00000000000..fb3095552d3
--- /dev/null
+++ b/changelogs/unreleased/jivl-change-copy-text-promote-milestones-labels.yml
@@ -0,0 +1,5 @@
+---
+title: Correct copy text for the promote milestone and label modals
+merge_request: 17726
+author:
+type: fixed
diff --git a/changelogs/unreleased/jivl-realtime-update-adding-file.yml b/changelogs/unreleased/jivl-realtime-update-adding-file.yml
new file mode 100644
index 00000000000..df1bdb1648d
--- /dev/null
+++ b/changelogs/unreleased/jivl-realtime-update-adding-file.yml
@@ -0,0 +1,5 @@
+---
+title: Add realtime pipeline status for adding/viewing files
+merge_request: 17705
+author:
+type: other
diff --git a/changelogs/unreleased/jivl-summary-statistics-prometheus-dashboard.yml b/changelogs/unreleased/jivl-summary-statistics-prometheus-dashboard.yml
new file mode 100644
index 00000000000..c5cdbcf7b40
--- /dev/null
+++ b/changelogs/unreleased/jivl-summary-statistics-prometheus-dashboard.yml
@@ -0,0 +1,5 @@
+---
+title: Add average and maximum summary statistics to the prometheus dashboard
+merge_request: 17921
+author:
+type: changed
diff --git a/changelogs/unreleased/jprovazn-issueref.yml b/changelogs/unreleased/jprovazn-issueref.yml
new file mode 100644
index 00000000000..ee19cac7b19
--- /dev/null
+++ b/changelogs/unreleased/jprovazn-issueref.yml
@@ -0,0 +1,6 @@
+---
+title: Display state indicator for issuable references in non-project scope (e.g.
+ when referencing issuables from group scope).
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/jramsay-38830-tarball.yml b/changelogs/unreleased/jramsay-38830-tarball.yml
new file mode 100644
index 00000000000..6d40c305614
--- /dev/null
+++ b/changelogs/unreleased/jramsay-38830-tarball.yml
@@ -0,0 +1,5 @@
+---
+title: Add alternate archive route for simplified packaging
+merge_request: 17225
+author:
+type: added
diff --git a/changelogs/unreleased/lfs-badge.yml b/changelogs/unreleased/lfs-badge.yml
deleted file mode 100644
index e4ed4d6741f..00000000000
--- a/changelogs/unreleased/lfs-badge.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Added badge to tree & blob views to indicate LFS tracked files
-merge_request:
-author:
-type: added
diff --git a/changelogs/unreleased/merge-request-lock-icon-size-fix.yml b/changelogs/unreleased/merge-request-lock-icon-size-fix.yml
deleted file mode 100644
index 09c059a3011..00000000000
--- a/changelogs/unreleased/merge-request-lock-icon-size-fix.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed merge request lock icon size
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/merge-request-widget-source-branch-improvements.yml b/changelogs/unreleased/merge-request-widget-source-branch-improvements.yml
new file mode 100644
index 00000000000..942eb6062fd
--- /dev/null
+++ b/changelogs/unreleased/merge-request-widget-source-branch-improvements.yml
@@ -0,0 +1,6 @@
+---
+title: Fixes remove source branch checkbox being visible when user cannot remove the
+ branch
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/merge-requests-schema-cleanup.yml b/changelogs/unreleased/merge-requests-schema-cleanup.yml
deleted file mode 100644
index ccce9b1436c..00000000000
--- a/changelogs/unreleased/merge-requests-schema-cleanup.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Clean up schema of the "merge_requests" table
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/mk-add-old-attachments-to-uploads-table.yml b/changelogs/unreleased/mk-add-old-attachments-to-uploads-table.yml
deleted file mode 100644
index 499543ef883..00000000000
--- a/changelogs/unreleased/mk-add-old-attachments-to-uploads-table.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add untracked files to uploads table
-merge_request: 15270
-author:
-type: other
diff --git a/changelogs/unreleased/mk-add-user-rate-limits.yml b/changelogs/unreleased/mk-add-user-rate-limits.yml
deleted file mode 100644
index 512757da5fc..00000000000
--- a/changelogs/unreleased/mk-add-user-rate-limits.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Add anonymous rate limit per IP, and authenticated (web or API) rate limits
- per user
-merge_request: 14708
-author:
-type: added
diff --git a/changelogs/unreleased/mk-fix-schema-dump-of-untracked-files-for-uploads.yml b/changelogs/unreleased/mk-fix-schema-dump-of-untracked-files-for-uploads.yml
deleted file mode 100644
index 2691e85320c..00000000000
--- a/changelogs/unreleased/mk-fix-schema-dump-of-untracked-files-for-uploads.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix error during schema dump.
-merge_request: 15866
-author:
-type: fixed
diff --git a/changelogs/unreleased/move-board-blank-state-vue-component.yml b/changelogs/unreleased/move-board-blank-state-vue-component.yml
new file mode 100644
index 00000000000..0a278a8c009
--- /dev/null
+++ b/changelogs/unreleased/move-board-blank-state-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Move BoardBlankState vue component
+merge_request: 17666
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/move-email-footer-info-to-single-line.yml b/changelogs/unreleased/move-email-footer-info-to-single-line.yml
new file mode 100644
index 00000000000..87ed5638056
--- /dev/null
+++ b/changelogs/unreleased/move-email-footer-info-to-single-line.yml
@@ -0,0 +1,5 @@
+---
+title: Move email footer info to a single line
+merge_request: 17916
+author:
+type: changed
diff --git a/changelogs/unreleased/move-registry-after-cicd-project-nav-sidebar.yml b/changelogs/unreleased/move-registry-after-cicd-project-nav-sidebar.yml
new file mode 100644
index 00000000000..03a6fd42228
--- /dev/null
+++ b/changelogs/unreleased/move-registry-after-cicd-project-nav-sidebar.yml
@@ -0,0 +1,5 @@
+---
+ title: Move 'Registry' after 'CI/CD' in project navigation sidebar
+ merge_request: 18018
+ author: Elias Werberich
+ type: changed
diff --git a/changelogs/unreleased/multiple-clusters-single-list.yml b/changelogs/unreleased/multiple-clusters-single-list.yml
deleted file mode 100644
index 55743f3c00e..00000000000
--- a/changelogs/unreleased/multiple-clusters-single-list.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Present multiple clusters in a single list instead of a tabbed view
-merge_request: 15669
-author:
-type: changed
diff --git a/changelogs/unreleased/optimize-issues-avoid-noop-empty-cache-updates2.yml b/changelogs/unreleased/optimize-issues-avoid-noop-empty-cache-updates2.yml
deleted file mode 100644
index e0c3136be69..00000000000
--- a/changelogs/unreleased/optimize-issues-avoid-noop-empty-cache-updates2.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Treat empty markdown and html strings as valid cached text, not missing cache
- that needs to be updated
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/optimize-projects-for-imported-projects.yml b/changelogs/unreleased/optimize-projects-for-imported-projects.yml
deleted file mode 100644
index 13186fa36d5..00000000000
--- a/changelogs/unreleased/optimize-projects-for-imported-projects.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: check the import_status field before doing SQL operations to check the import
- url
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/optional-api-delimiter.yml b/changelogs/unreleased/optional-api-delimiter.yml
new file mode 100644
index 00000000000..0bcd0787306
--- /dev/null
+++ b/changelogs/unreleased/optional-api-delimiter.yml
@@ -0,0 +1,5 @@
+---
+title: Make /-/ delimiter optional for search endpoints
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/osw-41401-render-mr-commit-sha-instead-diffs.yml b/changelogs/unreleased/osw-41401-render-mr-commit-sha-instead-diffs.yml
new file mode 100644
index 00000000000..44973641325
--- /dev/null
+++ b/changelogs/unreleased/osw-41401-render-mr-commit-sha-instead-diffs.yml
@@ -0,0 +1,5 @@
+---
+title: Render MR commit SHA instead "diffs" when viable
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/osw-44295-adjust-authorization-for-discussions-show.yml b/changelogs/unreleased/osw-44295-adjust-authorization-for-discussions-show.yml
new file mode 100644
index 00000000000..978c5468bb1
--- /dev/null
+++ b/changelogs/unreleased/osw-44295-adjust-authorization-for-discussions-show.yml
@@ -0,0 +1,5 @@
+---
+title: Adjust 404's for LegacyDiffNote discussion rendering
+merge_request: 18201
+author:
+type: fixed
diff --git a/changelogs/unreleased/osw-isolate-mr-widget-exposed-attributes.yml b/changelogs/unreleased/osw-isolate-mr-widget-exposed-attributes.yml
deleted file mode 100644
index 6b05713d1a1..00000000000
--- a/changelogs/unreleased/osw-isolate-mr-widget-exposed-attributes.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Stop sending milestone and labels data over the wire for MR widget requests
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/outdated-browser-position-fix.yml b/changelogs/unreleased/outdated-browser-position-fix.yml
deleted file mode 100644
index 801e45a28b3..00000000000
--- a/changelogs/unreleased/outdated-browser-position-fix.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed outdated browser flash positioning
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/pages_force_https.yml b/changelogs/unreleased/pages_force_https.yml
new file mode 100644
index 00000000000..da7e29087f3
--- /dev/null
+++ b/changelogs/unreleased/pages_force_https.yml
@@ -0,0 +1,5 @@
+---
+title: Add HTTPS-only pages
+merge_request: 16273
+author: rfwatson
+type: added
diff --git a/changelogs/unreleased/patch-24.yml b/changelogs/unreleased/patch-24.yml
deleted file mode 100644
index a670eb3ab56..00000000000
--- a/changelogs/unreleased/patch-24.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix graph notes number duplication.
-merge_request: 15696
-author: Vladislav Kaverin
-type: fixed
diff --git a/changelogs/unreleased/pawel-reduce_cardinality_of_prometheus_metrics.yml b/changelogs/unreleased/pawel-reduce_cardinality_of_prometheus_metrics.yml
deleted file mode 100644
index 0cee0b634d6..00000000000
--- a/changelogs/unreleased/pawel-reduce_cardinality_of_prometheus_metrics.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Reduce the number of buckets in gitlab_cache_operation_duration_seconds metric
-merge_request: 15881
-author:
-type: changed
diff --git a/changelogs/unreleased/pawel-update_prometheus_gem_to_well_tested_version.yml b/changelogs/unreleased/pawel-update_prometheus_gem_to_well_tested_version.yml
deleted file mode 100644
index a4133ae5cec..00000000000
--- a/changelogs/unreleased/pawel-update_prometheus_gem_to_well_tested_version.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Reenable Prometheus metrics, add more control over Prometheus method instrumentation
-merge_request: 15558
-author:
-type: fixed
diff --git a/changelogs/unreleased/perform-sql-matching-of-tags.yml b/changelogs/unreleased/perform-sql-matching-of-tags.yml
deleted file mode 100644
index 39f8a867a4d..00000000000
--- a/changelogs/unreleased/perform-sql-matching-of-tags.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Perform SQL matching of Build&Runner tags to greatly speed-up job picking
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/poc-upload-hashing-path.yml b/changelogs/unreleased/poc-upload-hashing-path.yml
new file mode 100644
index 00000000000..7970405bea1
--- /dev/null
+++ b/changelogs/unreleased/poc-upload-hashing-path.yml
@@ -0,0 +1,5 @@
+---
+title: File uploads in remote storage now support project renaming.
+merge_request: 4597
+author:
+type: fixed
diff --git a/changelogs/unreleased/reduce-query-count-for-mergerequestscontroller-show.yml b/changelogs/unreleased/reduce-query-count-for-mergerequestscontroller-show.yml
new file mode 100644
index 00000000000..1f793fe5e7c
--- /dev/null
+++ b/changelogs/unreleased/reduce-query-count-for-mergerequestscontroller-show.yml
@@ -0,0 +1,5 @@
+---
+title: Reduce number of queries when viewing a merge request
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/refactor-move-assignee-title-vue-component.yml b/changelogs/unreleased/refactor-move-assignee-title-vue-component.yml
new file mode 100644
index 00000000000..f6521339c39
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-assignee-title-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Move AssigneeTitle vue component
+merge_request: 17397
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/refactor-move-mr-widget-memory-usage-and-graph-components.yml b/changelogs/unreleased/refactor-move-mr-widget-memory-usage-and-graph-components.yml
new file mode 100644
index 00000000000..96e63343963
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-mr-widget-memory-usage-and-graph-components.yml
@@ -0,0 +1,5 @@
+---
+title: Move MemoryGraph and MemoryUsage vue components
+merge_request: 17533
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/refactor-move-mr-widget-nothing-to-merge-vue-component.yml b/changelogs/unreleased/refactor-move-mr-widget-nothing-to-merge-vue-component.yml
new file mode 100644
index 00000000000..dc8ff95dc27
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-mr-widget-nothing-to-merge-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Move NothingToMerge vue component
+merge_request: 17544
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml b/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml
new file mode 100644
index 00000000000..ac41fe23d3d
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Move ShaMismatch vue component
+merge_request: 17546
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/refactor-move-mr-widget-unresolved-discussions-vue-component.yml b/changelogs/unreleased/refactor-move-mr-widget-unresolved-discussions-vue-component.yml
new file mode 100644
index 00000000000..a31f1f372a8
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-mr-widget-unresolved-discussions-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Move UnresolvedDiscussions vue component
+merge_request: 17538
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/refactor-move-time-tracking-comparison-pane-vue-component.yml b/changelogs/unreleased/refactor-move-time-tracking-comparison-pane-vue-component.yml
new file mode 100644
index 00000000000..88a4b8ec8c1
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-time-tracking-comparison-pane-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Move TimeTrackingComparisonPane vue component
+merge_request: 17931
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/refactor-move-time-tracking-vue-components.yml b/changelogs/unreleased/refactor-move-time-tracking-vue-components.yml
new file mode 100644
index 00000000000..8151655250a
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-time-tracking-vue-components.yml
@@ -0,0 +1,5 @@
+---
+title: Move TimeTrackingCollapsedState vue component
+merge_request: 17399
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/remove-incorrect-guidance.yml b/changelogs/unreleased/remove-incorrect-guidance.yml
deleted file mode 100644
index eeb5745698f..00000000000
--- a/changelogs/unreleased/remove-incorrect-guidance.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Removed incorrect guidance stating blocked users will be removed from groups
- and project as members
-merge_request: 15947
-author: CesarApodaca
-type: fixed
diff --git a/changelogs/unreleased/remove-links-mr-empty-state.yml b/changelogs/unreleased/remove-links-mr-empty-state.yml
deleted file mode 100644
index c666bc2c81d..00000000000
--- a/changelogs/unreleased/remove-links-mr-empty-state.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Remove related links in MR widget when empty state
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/remove-pages-tar-support.yml b/changelogs/unreleased/remove-pages-tar-support.yml
new file mode 100644
index 00000000000..73448687912
--- /dev/null
+++ b/changelogs/unreleased/remove-pages-tar-support.yml
@@ -0,0 +1,5 @@
+---
+title: Remove support for legacy tar.gz pages artifacts
+merge_request: 18090
+author:
+type: deprecated
diff --git a/changelogs/unreleased/remove-tabindexes-from-tag-form.yml b/changelogs/unreleased/remove-tabindexes-from-tag-form.yml
deleted file mode 100644
index a15bf2a7a4f..00000000000
--- a/changelogs/unreleased/remove-tabindexes-from-tag-form.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: removed tabindexes from tag form
-merge_request:
-author: Marcus Amargi
-type: changed
diff --git a/changelogs/unreleased/sh-add-cleanup-rpc-gitaly.yml b/changelogs/unreleased/sh-add-cleanup-rpc-gitaly.yml
new file mode 100644
index 00000000000..81b48fc255b
--- /dev/null
+++ b/changelogs/unreleased/sh-add-cleanup-rpc-gitaly.yml
@@ -0,0 +1,5 @@
+---
+title: Automatically cleanup stale worktrees and lock files upon a push
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml b/changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml
deleted file mode 100644
index 6d06f695f10..00000000000
--- a/changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add button to run scheduled pipeline immediately
-merge_request:
-author:
-type: added
diff --git a/changelogs/unreleased/sh-appearance-cache-key-version.yml b/changelogs/unreleased/sh-appearance-cache-key-version.yml
new file mode 100644
index 00000000000..c17a3dc36cd
--- /dev/null
+++ b/changelogs/unreleased/sh-appearance-cache-key-version.yml
@@ -0,0 +1,5 @@
+---
+title: Use the GitLab version as part of the appearances cache key
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-cleanup-pages-worker.yml b/changelogs/unreleased/sh-cleanup-pages-worker.yml
new file mode 100644
index 00000000000..c26e1342dd2
--- /dev/null
+++ b/changelogs/unreleased/sh-cleanup-pages-worker.yml
@@ -0,0 +1,5 @@
+---
+title: Free open file descriptors and libgit2 buffers in UpdatePagesService
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/sh-gitlab-sidekiq-logger.yml b/changelogs/unreleased/sh-gitlab-sidekiq-logger.yml
new file mode 100644
index 00000000000..f68d45d2f38
--- /dev/null
+++ b/changelogs/unreleased/sh-gitlab-sidekiq-logger.yml
@@ -0,0 +1,5 @@
+---
+title: Add support for Sidekiq JSON logging
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/sh-move-sidekiq-exporter-logs.yml b/changelogs/unreleased/sh-move-sidekiq-exporter-logs.yml
new file mode 100644
index 00000000000..1990f4f6124
--- /dev/null
+++ b/changelogs/unreleased/sh-move-sidekiq-exporter-logs.yml
@@ -0,0 +1,5 @@
+---
+title: Move Sidekiq exporter logs to log/sidekiq_exporter.log
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/sh-remove-allocation-tracking-influxdb.yml b/changelogs/unreleased/sh-remove-allocation-tracking-influxdb.yml
deleted file mode 100644
index b98573df303..00000000000
--- a/changelogs/unreleased/sh-remove-allocation-tracking-influxdb.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Remove allocation tracking code from InfluxDB sampler for performance
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/show-inline-edit-btn.yml b/changelogs/unreleased/show-inline-edit-btn.yml
deleted file mode 100644
index 8cfe9b7d75a..00000000000
--- a/changelogs/unreleased/show-inline-edit-btn.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Move edit button to second row on issue page (and change it to a pencil icon)
-merge_request:
-author:
-type: changed
diff --git a/changelogs/unreleased/skip_confirmation_user_API.yml b/changelogs/unreleased/skip_confirmation_user_API.yml
deleted file mode 100644
index 144ccd69e68..00000000000
--- a/changelogs/unreleased/skip_confirmation_user_API.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-title: Add email confirmation parameters for user creation and update via API
-merge_request:
-author: Daniel Juarez
-type: added
-
-
diff --git a/changelogs/unreleased/sophie-h-gitlab-ce-patch-15.yml b/changelogs/unreleased/sophie-h-gitlab-ce-patch-15.yml
deleted file mode 100644
index b5e3210c737..00000000000
--- a/changelogs/unreleased/sophie-h-gitlab-ce-patch-15.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Hide link to issues/MRs from labels list if issues/MRs are disabled.
-merge_request: 15863
-author: Sophie Herold
-type: fixed
diff --git a/changelogs/unreleased/tc-correct-email-in-reply-to.yml b/changelogs/unreleased/tc-correct-email-in-reply-to.yml
deleted file mode 100644
index 1c8043f6a5c..00000000000
--- a/changelogs/unreleased/tc-correct-email-in-reply-to.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make mail notifications of discussion notes In-Reply-To of each other
-merge_request: 14289
-author:
-type: changed
diff --git a/changelogs/unreleased/tc-re-add-read-only-banner.yml b/changelogs/unreleased/tc-re-add-read-only-banner.yml
new file mode 100644
index 00000000000..35bcd7e184e
--- /dev/null
+++ b/changelogs/unreleased/tc-re-add-read-only-banner.yml
@@ -0,0 +1,5 @@
+---
+title: Add read-only banner to all pages
+merge_request: 17798
+author:
+type: fixed
diff --git a/changelogs/unreleased/text-utils.yml b/changelogs/unreleased/text-utils.yml
deleted file mode 100644
index b95bb82fe01..00000000000
--- a/changelogs/unreleased/text-utils.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Export text utils functions as es6 module and add tests
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/throttle-touching-of-objects.yml b/changelogs/unreleased/throttle-touching-of-objects.yml
deleted file mode 100644
index 0a57bea7c83..00000000000
--- a/changelogs/unreleased/throttle-touching-of-objects.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Throttle the number of UPDATEs triggered by touch
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/tm-feature-list-runners-jobs-api.yml b/changelogs/unreleased/tm-feature-list-runners-jobs-api.yml
deleted file mode 100644
index d75a2b68c30..00000000000
--- a/changelogs/unreleased/tm-feature-list-runners-jobs-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: New API endpoint - list jobs for a specified runner
-merge_request: 15432
-author:
-type: added
diff --git a/changelogs/unreleased/tm-feature-namespace-by-id-api.yml b/changelogs/unreleased/tm-feature-namespace-by-id-api.yml
deleted file mode 100644
index bc4a8949d28..00000000000
--- a/changelogs/unreleased/tm-feature-namespace-by-id-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add new API endpoint - get a namespace by ID
-merge_request: 15442
-author:
-type: added
diff --git a/changelogs/unreleased/ui-mr-counter-cache.yml b/changelogs/unreleased/ui-mr-counter-cache.yml
new file mode 100644
index 00000000000..5e241bfe93f
--- /dev/null
+++ b/changelogs/unreleased/ui-mr-counter-cache.yml
@@ -0,0 +1,5 @@
+---
+title: Deleting a MR you are assigned to should decrements counter
+merge_request: 17951
+author: m b
+type: fixed
diff --git a/changelogs/unreleased/update-emoji-digests-with-latest-from-gemojione.yml b/changelogs/unreleased/update-emoji-digests-with-latest-from-gemojione.yml
deleted file mode 100644
index e509a8df6bc..00000000000
--- a/changelogs/unreleased/update-emoji-digests-with-latest-from-gemojione.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: 'Update emojis. Add :gay_pride_flag: and :speech_left:. Remove extraneous comma
- in :cartwheel_tone4:'
-merge_request:
-author:
-type: changed
diff --git a/changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml b/changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml
new file mode 100644
index 00000000000..c76495ec959
--- /dev/null
+++ b/changelogs/unreleased/update-gitlab-ci-yml-services-docs.yml
@@ -0,0 +1,5 @@
+---
+title: Update CI services documnetation
+merge_request: 17749
+author:
+type: other
diff --git a/changelogs/unreleased/update-merge-worker-metrics.yml b/changelogs/unreleased/update-merge-worker-metrics.yml
deleted file mode 100644
index c733675926a..00000000000
--- a/changelogs/unreleased/update-merge-worker-metrics.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add performance logging to UpdateMergeRequestsWorker.
-merge_request: 15360
-author:
-type: performance
diff --git a/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml b/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml
new file mode 100644
index 00000000000..9c13bfbaf6f
--- /dev/null
+++ b/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml
@@ -0,0 +1,5 @@
+---
+title: Update spec import path for vue mount component helper
+merge_request: 17880
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/update-unresolved-discussions-vue-component.yml b/changelogs/unreleased/update-unresolved-discussions-vue-component.yml
new file mode 100644
index 00000000000..246eaaae2bd
--- /dev/null
+++ b/changelogs/unreleased/update-unresolved-discussions-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Add i18n and update specs for ShaMismatch vue component
+merge_request: 17870
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/update_mr_changes_empty_page.yml b/changelogs/unreleased/update_mr_changes_empty_page.yml
deleted file mode 100644
index bae73c21e8f..00000000000
--- a/changelogs/unreleased/update_mr_changes_empty_page.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Update empty state page of merge request 'changes' tab
-merge_request: 15611
-author: Vitaliy @blackst0ne Klachkov
-type: added
diff --git a/changelogs/unreleased/use-chronic-duration-attribute-for-project-build-timeout.yml b/changelogs/unreleased/use-chronic-duration-attribute-for-project-build-timeout.yml
new file mode 100644
index 00000000000..675d347b64c
--- /dev/null
+++ b/changelogs/unreleased/use-chronic-duration-attribute-for-project-build-timeout.yml
@@ -0,0 +1,5 @@
+---
+title: Use human readable value build_timeout in Project
+merge_request: 17386
+author:
+type: changed
diff --git a/changelogs/unreleased/use-count_commits-directly.yml b/changelogs/unreleased/use-count_commits-directly.yml
deleted file mode 100644
index 549e0744ea4..00000000000
--- a/changelogs/unreleased/use-count_commits-directly.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Improve the performance for counting commits
-merge_request: 15628
-author:
-type: performance
diff --git a/changelogs/unreleased/use-merge-requests-diff-id-column.yml b/changelogs/unreleased/use-merge-requests-diff-id-column.yml
deleted file mode 100644
index da4106ec8cf..00000000000
--- a/changelogs/unreleased/use-merge-requests-diff-id-column.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make finding most recent merge request diffs more efficient
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/user-agent-gke-api.yml b/changelogs/unreleased/user-agent-gke-api.yml
deleted file mode 100644
index 1abdbadd53b..00000000000
--- a/changelogs/unreleased/user-agent-gke-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Use custom user agent header in all GCP API requests.
-merge_request: 15705
-author:
-type: changed
diff --git a/changelogs/unreleased/winh-41174-projects-groups-badges-ui.yml b/changelogs/unreleased/winh-41174-projects-groups-badges-ui.yml
new file mode 100644
index 00000000000..14114eca2b2
--- /dev/null
+++ b/changelogs/unreleased/winh-41174-projects-groups-badges-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Projects and groups badges settings UI
+merge_request: 17114
+author:
+type: added
diff --git a/changelogs/unreleased/winh-deprecate-old-modal.yml b/changelogs/unreleased/winh-deprecate-old-modal.yml
new file mode 100644
index 00000000000..4fae1fafbea
--- /dev/null
+++ b/changelogs/unreleased/winh-deprecate-old-modal.yml
@@ -0,0 +1,5 @@
+---
+title: Rename modal.vue to deprecated_modal.vue
+merge_request: 17438
+author:
+type: other
diff --git a/changelogs/unreleased/winh-subgroups-api.yml b/changelogs/unreleased/winh-subgroups-api.yml
deleted file mode 100644
index c49e3621e9c..00000000000
--- a/changelogs/unreleased/winh-subgroups-api.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Add /groups/:id/subgroups endpoint to API
-merge_request: 15142
-author: marbemac
-type: added
diff --git a/changelogs/unreleased/winh-translate-contributors-page-dates.yml b/changelogs/unreleased/winh-translate-contributors-page-dates.yml
deleted file mode 100644
index 74801bbd86e..00000000000
--- a/changelogs/unreleased/winh-translate-contributors-page-dates.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Translate date ranges on contributors page
-merge_request: 15846
-author:
-type: changed
diff --git a/changelogs/unreleased/workhorse-gitaly-mandatory.yml b/changelogs/unreleased/workhorse-gitaly-mandatory.yml
new file mode 100644
index 00000000000..77b62302e86
--- /dev/null
+++ b/changelogs/unreleased/workhorse-gitaly-mandatory.yml
@@ -0,0 +1,5 @@
+---
+title: Make all workhorse gitaly calls opt-out, take 2
+merge_request: 18043
+author:
+type: other
diff --git a/changelogs/unreleased/zj-bump-gitaly.yml b/changelogs/unreleased/zj-bump-gitaly.yml
new file mode 100644
index 00000000000..eb28bed70e4
--- /dev/null
+++ b/changelogs/unreleased/zj-bump-gitaly.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade Gitaly to upgrade its charlock_holmes
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/zj-commit-show-n-1.yml b/changelogs/unreleased/zj-commit-show-n-1.yml
deleted file mode 100644
index e536434f74a..00000000000
--- a/changelogs/unreleased/zj-commit-show-n-1.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fetch blobs in bulk when generating diffs
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/zj-empty-repo-importer.yml b/changelogs/unreleased/zj-empty-repo-importer.yml
deleted file mode 100644
index 71d50af9a04..00000000000
--- a/changelogs/unreleased/zj-empty-repo-importer.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix GitHub importer using removed interface
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/zj-feature-gate-remove-http-api.yml b/changelogs/unreleased/zj-feature-gate-remove-http-api.yml
new file mode 100644
index 00000000000..2095f60146c
--- /dev/null
+++ b/changelogs/unreleased/zj-feature-gate-remove-http-api.yml
@@ -0,0 +1,5 @@
+---
+title: Allow feature gates to be removed through the API
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/zj-find-license-opt-out.yml b/changelogs/unreleased/zj-find-license-opt-out.yml
new file mode 100644
index 00000000000..be2656601a9
--- /dev/null
+++ b/changelogs/unreleased/zj-find-license-opt-out.yml
@@ -0,0 +1,5 @@
+---
+title: Detect repository license on Gitaly by default
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/zj-memoization-mr-commits.yml b/changelogs/unreleased/zj-memoization-mr-commits.yml
deleted file mode 100644
index 59dfc6d6049..00000000000
--- a/changelogs/unreleased/zj-memoization-mr-commits.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Cache commits for MergeRequest diffs
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/zj-opt-out-delete-refs.yml b/changelogs/unreleased/zj-opt-out-delete-refs.yml
new file mode 100644
index 00000000000..b02a45eee17
--- /dev/null
+++ b/changelogs/unreleased/zj-opt-out-delete-refs.yml
@@ -0,0 +1,5 @@
+---
+title: Bulk deleting refs is handled by Gitaly by default
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/zj-opt-out-list-commits-by-oid.yml b/changelogs/unreleased/zj-opt-out-list-commits-by-oid.yml
new file mode 100644
index 00000000000..3871293ee04
--- /dev/null
+++ b/changelogs/unreleased/zj-opt-out-list-commits-by-oid.yml
@@ -0,0 +1,5 @@
+---
+title: ListCommitsByOid is executed by Gitaly by default
+merge_request:
+author:
+type: performance
diff --git a/changelogs/unreleased/zj-remote-repo-exists.yml b/changelogs/unreleased/zj-remote-repo-exists.yml
new file mode 100644
index 00000000000..f024b83159b
--- /dev/null
+++ b/changelogs/unreleased/zj-remote-repo-exists.yml
@@ -0,0 +1,5 @@
+---
+title: Test if remote repository exists when importing wikis
+merge_request:
+author:
+type: fixed
diff --git a/config.ru b/config.ru
index 065ce59932f..405d01863ac 100644
--- a/config.ru
+++ b/config.ru
@@ -7,8 +7,8 @@ if defined?(Unicorn)
# Unicorn self-process killer
require 'unicorn/worker_killer'
- min = (ENV['GITLAB_UNICORN_MEMORY_MIN'] || 300 * 1 << 20).to_i
- max = (ENV['GITLAB_UNICORN_MEMORY_MAX'] || 350 * 1 << 20).to_i
+ min = (ENV['GITLAB_UNICORN_MEMORY_MIN'] || 400 * 1 << 20).to_i
+ max = (ENV['GITLAB_UNICORN_MEMORY_MAX'] || 650 * 1 << 20).to_i
# Max memory size (RSS) per worker
use Unicorn::WorkerKiller::Oom, min, max
@@ -17,6 +17,12 @@ end
require ::File.expand_path('../config/environment', __FILE__)
+warmup do |app|
+ client = Rack::MockRequest.new(app)
+ client.get('/')
+end
+
map ENV['RAILS_RELATIVE_URL_ROOT'] || "/" do
+ use Gitlab::Middleware::ReleaseEnv
run Gitlab::Application
end
diff --git a/config/application.rb b/config/application.rb
index 1110199b888..13501d4bdb5 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -6,10 +6,12 @@ Bundler.require(:default, Rails.env)
module Gitlab
class Application < Rails::Application
+ require_dependency Rails.root.join('lib/gitlab/redis/wrapper')
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')
+ require_dependency Rails.root.join('lib/gitlab/current_settings')
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
@@ -24,6 +26,7 @@ module Gitlab
# 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/badges
#{config.root}/app/models/hooks
#{config.root}/app/models/members
#{config.root}/app/models/project_services
@@ -61,6 +64,7 @@ module Gitlab
# - Any parameter containing `secret`
# - Two-factor tokens (:otp_attempt)
# - Repo/Project Import URLs (:import_url)
+ # - Build traces (:trace)
# - Build variables (:variables)
# - GitLab Pages SSL cert/key info (:certificate, :encrypted_key)
# - Webhook URLs (:hook)
@@ -75,6 +79,7 @@ module Gitlab
key
otp_attempt
sentry_dsn
+ trace
variables
)
@@ -96,22 +101,26 @@ module Gitlab
# Enable the asset pipeline
config.assets.enabled = true
+
# Support legacy unicode file named img emojis, `1F939.png`
config.assets.paths << Gemojione.images_path
- config.assets.paths << "vendor/assets/fonts"
- config.assets.precompile << "*.png"
+ config.assets.paths << "#{config.root}/vendor/assets/fonts"
+
config.assets.precompile << "print.css"
config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.css"
- 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 << "locale/**/app.js"
+ # Import gitlab-svgs directly from vendored directory
+ config.assets.paths << "#{config.root}/node_modules/@gitlab-org/gitlab-svgs/dist"
+ config.assets.precompile << "icons.svg"
+ config.assets.precompile << "icons.json"
+ config.assets.precompile << "illustrations/*.svg"
+
# Version of your assets, change this if you want to expire all your assets
config.assets.version = '1.0'
@@ -149,6 +158,7 @@ module Gitlab
caching_config_hash[:pool_size] = Sidekiq.options[:concurrency] + 5
caching_config_hash[:pool_timeout] = 1
end
+
config.cache_store = :redis_store, caching_config_hash
config.active_record.raise_in_transactional_callbacks = true
@@ -160,7 +170,7 @@ module Gitlab
ENV['GIT_TERMINAL_PROMPT'] = '0'
# Gitlab Read-only middleware support
- config.middleware.insert_after ActionDispatch::Flash, 'Gitlab::Middleware::ReadOnly'
+ config.middleware.insert_after ActionDispatch::Flash, '::Gitlab::Middleware::ReadOnly'
config.generators do |g|
g.factory_bot false
@@ -188,4 +198,10 @@ module Gitlab
Gitlab::Routing.add_helpers(MilestonesRoutingHelper)
end
end
+
+ # This method is used for smooth upgrading from the current Rails 4.x to Rails 5.0.
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/14286
+ def self.rails5?
+ ENV["RAILS5"].in?(%w[1 true])
+ end
end
diff --git a/config/boot.rb b/config/boot.rb
index f2830ae3166..84f390f3228 100644
--- a/config/boot.rb
+++ b/config/boot.rb
@@ -1,6 +1,11 @@
-require 'rubygems'
+def rails5?
+ %w[1 true].include?(ENV["RAILS5"])
+end
-# Set up gems listed in the Gemfile.
-ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
+require 'rubygems' unless rails5?
+
+gemfile = rails5? ? "Gemfile.rails5" : "Gemfile"
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../#{gemfile}", __dir__)
+# Set up gems listed in the Gemfile.
require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index 60df92a44fc..6616b85129e 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -503,3 +503,34 @@
:versions:
- 1.0.9
:when: 2017-11-16 13:02:06.765282000 Z
+- - :license
+ - JSONStream
+ - MIT
+ - :who: Tim Zallmann
+ :why: https://github.com/dominictarr/JSONStream/blob/master/LICENSE.MIT
+ :versions: []
+ :when: 2018-01-17 22:46:12.367554000 Z
+- - :approve
+ - uws
+ - :who: Tim Zallmann
+ :why: zlib license + Development Lib + https://github.com/uNetworking/uWebSockets/blob/master/LICENSE
+ :versions: []
+ :when: 2018-01-17 23:46:12.367554000 Z
+- - :approve
+ - atob
+ - :who: Mike Greiling
+ :why: https://github.com/node-browser-compat/atob/blob/master/LICENSE
+ :versions: []
+ :when: 2018-02-20 19:42:08.409887000 Z
+- - :approve
+ - cyclist
+ - :who: Mike Greiling
+ :why: https://github.com/mafintosh/cyclist/blob/master/LICENSE
+ :versions: []
+ :when: 2018-02-20 21:37:43.774978000 Z
+- - :approve
+ - bitsyntax
+ - :who: Mike Greiling
+ :why: https://github.com/squaremo/bitsyntax-js/blob/master/LICENSE-MIT
+ :versions: []
+ :when: 2018-02-20 22:20:25.958123000 Z
diff --git a/config/environment.rb b/config/environment.rb
index df3006d349c..487a4564b47 100644
--- a/config/environment.rb
+++ b/config/environment.rb
@@ -1,5 +1,11 @@
# Load the rails application
-require File.expand_path('../application', __FILE__)
+
+# Remove this condition when upgraded to rails 5.0.
+if %w[1 true].include?(ENV["RAILS5"])
+ require_relative 'application'
+else
+ require File.expand_path('../application', __FILE__)
+end
# Initialize the rails application
Rails.application.initialize!
diff --git a/config/environments/production.rb b/config/environments/production.rb
index c5cbfcf64cf..9941987929c 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -9,7 +9,11 @@ Rails.application.configure do
config.action_controller.perform_caching = true
# Disable Rails's static asset server (Apache or nginx will already do this)
- config.serve_static_files = false
+ if Gitlab.rails5?
+ config.public_file_server.enabled = false
+ else
+ config.serve_static_files = false
+ end
# Compress JavaScripts and CSS.
config.assets.js_compressor = :uglifier
diff --git a/config/environments/test.rb b/config/environments/test.rb
index d09e51e766a..1849c984351 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -18,7 +18,13 @@ Rails.application.configure do
# Configure static asset server for tests with Cache-Control for performance
config.assets.compile = false if ENV['CI']
- config.serve_static_files = true
+
+ if Gitlab.rails5?
+ config.public_file_server.enabled = true
+ else
+ config.serve_static_files = true
+ end
+
config.static_cache_control = "public, max-age=3600"
# Show full error reports and disable caching
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index f2f05b3eeb2..8c39a1f2aa9 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -145,12 +145,57 @@ production: &base
enabled: true
# The location where build artifacts are stored (default: shared/artifacts).
# path: shared/artifacts
+ # object_store:
+ # enabled: false
+ # remote_directory: artifacts # The bucket name
+ # background_upload: false # Temporary option to limit automatic upload (Default: true)
+ # proxy_download: false # Passthrough all downloads via GitLab instead of using Redirects to Object Storage
+ # connection:
+ # provider: AWS # Only AWS supported at the moment
+ # aws_access_key_id: AWS_ACCESS_KEY_ID
+ # aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ # region: us-east-1
## Git LFS
lfs:
enabled: true
# The location where LFS objects are stored (default: shared/lfs-objects).
# storage_path: shared/lfs-objects
+ object_store:
+ enabled: false
+ remote_directory: lfs-objects # Bucket name
+ # direct_upload: false # Use Object Storage directly for uploads instead of background uploads if enabled (Default: false)
+ # background_upload: false # Temporary option to limit automatic upload (Default: true)
+ # proxy_download: false # Passthrough all downloads via GitLab instead of using Redirects to Object Storage
+ connection:
+ provider: AWS
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
+ # Use the following options to configure an AWS compatible host
+ # host: 'localhost' # default: s3.amazonaws.com
+ # endpoint: 'http://127.0.0.1:9000' # default: nil
+ # path_style: true # Use 'host/bucket_name/object' instead of 'bucket_name.host/object'
+
+ ## Uploads (attachments, avatars, etc...)
+ uploads:
+ # The location where uploads objects are stored (default: public/).
+ # storage_path: public/
+ # base_dir: uploads/-/system
+ object_store:
+ enabled: false
+ # remote_directory: uploads # Bucket name
+ # direct_upload: false # Use Object Storage directly for uploads instead of background uploads if enabled (Default: false)
+ # background_upload: false # Temporary option to limit automatic upload (Default: true)
+ # proxy_download: false # Passthrough all downloads via GitLab instead of using Redirects to Object Storage
+ connection:
+ provider: AWS
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
+ # host: 'localhost' # default: s3.amazonaws.com
+ # endpoint: 'http://127.0.0.1:9000' # default: nil
+ # path_style: true # Use 'host/bucket_name/object' instead of 'bucket_name.host/object'
## GitLab Pages
pages:
@@ -175,14 +220,20 @@ production: &base
host: 'https://mattermost.example.com'
## Gravatar
- ## For Libravatar see: http://doc.gitlab.com/ce/customization/libravatar.html
+ ## If using gravatar.com, there's nothing to change here. For Libravatar
+ ## you'll need to provide the custom URLs. For more information,
+ ## see: https://docs.gitlab.com/ee/customization/libravatar.html
gravatar:
- # gravatar urls: possible placeholders: %{hash} %{size} %{email} %{username}
- # plain_url: "http://..." # default: http://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon
+ # Gravatar/Libravatar URLs: possible placeholders: %{hash} %{size} %{email} %{username}
+ # plain_url: "http://..." # default: https://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon
# ssl_url: "https://..." # default: https://secure.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon
+ ## Sidekiq
+ sidekiq:
+ log_format: default # (json is also supported)
+
## Auxiliary jobs
- # Periodically executed jobs, to self-heal Gitlab, do external synchronizations, etc.
+ # Periodically executed jobs, to self-heal GitLab, do external synchronizations, etc.
# Please read here for more information: https://github.com/ondrejbartas/sidekiq-cron#adding-cron-job
cron_jobs:
# Flag stuck CI jobs as failed
@@ -206,6 +257,10 @@ production: &base
repository_archive_cache_worker:
cron: "0 * * * *"
+ # Verify custom GitLab Pages domains
+ pages_domain_verification_cron_worker:
+ cron: "*/15 * * * *"
+
registry:
# enabled: true
# host: registry.example.com
@@ -362,6 +417,9 @@ production: &base
first_name: 'givenName'
last_name: 'sn'
+ # If lowercase_usernames is enabled, GitLab will lower case the username.
+ lowercase_usernames: false
+
# GitLab EE only: add more LDAP servers
# Choose an ID made of a-z and 0-9 . This ID will be stored in the database
# so that GitLab can remember which LDAP server a user belongs to.
@@ -466,7 +524,17 @@ production: &base
# - { name: 'twitter',
# app_id: 'YOUR_APP_ID',
# app_secret: 'YOUR_APP_SECRET' }
- #
+ # - { name: 'jwt',
+ # app_secret: 'YOUR_APP_SECRET',
+ # args: {
+ # algorithm: 'HS256',
+ # uid_claim: 'email',
+ # required_claims: ["name", "email"],
+ # info_map: { name: "name", email: "email" },
+ # auth_url: 'https://example.com/',
+ # valid_within: nil,
+ # }
+ # }
# - { name: 'saml',
# label: 'Our SAML Provider',
# groups_attribute: 'Groups',
@@ -640,6 +708,39 @@ test:
enabled: true
lfs:
enabled: false
+ # The location where LFS objects are stored (default: shared/lfs-objects).
+ # storage_path: shared/lfs-objects
+ object_store:
+ enabled: false
+ remote_directory: lfs-objects # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
+ artifacts:
+ path: tmp/tests/artifacts
+ enabled: true
+ # The location where build artifacts are stored (default: shared/artifacts).
+ # path: shared/artifacts
+ object_store:
+ enabled: false
+ remote_directory: artifacts # The bucket name
+ background_upload: false
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
+ uploads:
+ storage_path: tmp/tests/public
+ object_store:
+ enabled: false
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
gitlab:
host: localhost
port: 80
@@ -650,8 +751,6 @@ test:
# user: YOUR_USERNAME
pages:
path: tmp/tests/pages
- artifacts:
- path: tmp/tests/artifacts
repositories:
storages:
default:
@@ -716,6 +815,17 @@ test:
- { name: 'twitter',
app_id: 'YOUR_APP_ID',
app_secret: 'YOUR_APP_SECRET' }
+ - { name: 'jwt',
+ app_secret: 'YOUR_APP_SECRET',
+ args: {
+ algorithm: 'HS256',
+ uid_claim: 'email',
+ required_claims: ["name", "email"],
+ info_map: { name: "name", email: "email" },
+ auth_url: 'https://example.com/',
+ valid_within: nil,
+ }
+ }
- { name: 'auth0',
args: {
client_id: 'YOUR_AUTH0_CLIENT_ID',
diff --git a/config/initializers/0_as_concern.rb b/config/initializers/0_as_concern.rb
new file mode 100644
index 00000000000..40232bd6252
--- /dev/null
+++ b/config/initializers/0_as_concern.rb
@@ -0,0 +1,25 @@
+# This module is based on: https://gist.github.com/bcardarella/5735987
+
+module Prependable
+ def prepend_features(base)
+ if base.instance_variable_defined?(:@_dependencies)
+ base.instance_variable_get(:@_dependencies) << self
+ false
+ else
+ return false if base < self
+
+ super
+ base.singleton_class.send(:prepend, const_get('ClassMethods')) if const_defined?(:ClassMethods)
+ @_dependencies.each { |dep| base.send(:prepend, dep) } # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+ end
+end
+
+module ActiveSupport
+ module Concern
+ prepend Prependable
+
+ alias_method :prepended, :included
+ end
+end
diff --git a/config/initializers/0_post_deployment_migrations.rb b/config/initializers/0_post_deployment_migrations.rb
index 0068a03d214..3d81b869b52 100644
--- a/config/initializers/0_post_deployment_migrations.rb
+++ b/config/initializers/0_post_deployment_migrations.rb
@@ -2,11 +2,13 @@
# before other initializers as Rails may otherwise memoize a list of migrations
# excluding the post deployment migrations.
unless ENV['SKIP_POST_DEPLOYMENT_MIGRATIONS']
- path = Rails.root.join('db', 'post_migrate').to_s
+ Rails.application.config.paths['db'].each do |db_path|
+ path = Rails.root.join(db_path, 'post_migrate').to_s
- Rails.application.config.paths['db/migrate'] << path
+ Rails.application.config.paths['db/migrate'] << path
- # Rails memoizes migrations at certain points where it won't read the above
- # path just yet. As such we must also update the following list of paths.
- ActiveRecord::Migrator.migrations_paths << path
+ # Rails memoizes migrations at certain points where it won't read the above
+ # path just yet. As such we must also update the following list of paths.
+ ActiveRecord::Migrator.migrations_paths << path
+ end
end
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index f10f0cdf42c..acf7754abe6 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -68,6 +68,7 @@ class Settings < Settingslogic
end
values.delete_if { |value| value.nil? }
end
+
values
end
@@ -78,6 +79,7 @@ class Settings < Settingslogic
if current.is_a? String
value = modul.const_get(current.upcase) rescue default
end
+
value
end
@@ -108,7 +110,7 @@ class Settings < Settingslogic
url = "http://#{url}" unless url.start_with?('http')
# Get rid of the path so that we don't even have to encode it
- url_without_path = url.sub(%r{(https?://[^\/]+)/?.*}, '\1')
+ url_without_path = url.sub(%r{(https?://[^/]+)/?.*}, '\1')
URI.parse(url_without_path).host
end
@@ -149,6 +151,7 @@ if Settings.ldap['enabled'] || Rails.env.test?
server['allow_username_or_email_login'] = false if server['allow_username_or_email_login'].nil?
server['active_directory'] = true if server['active_directory'].nil?
server['attributes'] = {} if server['attributes'].nil?
+ server['lowercase_usernames'] = false if server['lowercase_usernames'].nil?
server['provider_name'] ||= "ldap#{key}".downcase
server['provider_class'] = OmniAuth::Utils.camelize(server['provider_name'])
@@ -259,7 +262,7 @@ Settings.gitlab['signup_enabled'] ||= true if Settings.gitlab['signup_enabled'].
Settings.gitlab['signin_enabled'] ||= true if Settings.gitlab['signin_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)|[Ii]mplement(?:s|ed|ing)?)(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)' if Settings.gitlab['issue_closing_pattern'].nil?
+Settings.gitlab['issue_closing_pattern'] = '((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing)|[Ii]mplement(?:s|ed|ing)?)(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?: *,? +and +| *, *)?)|([A-Z][A-Z0-9_]+-\d+))+)' if Settings.gitlab['issue_closing_pattern'].nil?
Settings.gitlab['default_projects_features'] ||= {}
Settings.gitlab['webhook_timeout'] ||= 10
Settings.gitlab['max_attachment_size'] ||= 10
@@ -298,8 +301,18 @@ Settings.incoming_email['enabled'] = false if Settings.incoming_email['enabled']
#
Settings['artifacts'] ||= Settingslogic.new({})
Settings.artifacts['enabled'] = true if Settings.artifacts['enabled'].nil?
-Settings.artifacts['path'] = Settings.absolute(Settings.artifacts['path'] || File.join(Settings.shared['path'], "artifacts"))
-Settings.artifacts['max_size'] ||= 100 # in megabytes
+Settings.artifacts['storage_path'] = Settings.absolute(Settings.artifacts.values_at('path', 'storage_path').compact.first || File.join(Settings.shared['path'], "artifacts"))
+# Settings.artifact['path'] is deprecated, use `storage_path` instead
+Settings.artifacts['path'] = Settings.artifacts['storage_path']
+Settings.artifacts['max_size'] ||= 100 # in megabytes
+Settings.artifacts['object_store'] ||= Settingslogic.new({})
+Settings.artifacts['object_store']['enabled'] = false if Settings.artifacts['object_store']['enabled'].nil?
+Settings.artifacts['object_store']['remote_directory'] ||= nil
+Settings.artifacts['object_store']['direct_upload'] = false if Settings.artifacts['object_store']['direct_upload'].nil?
+Settings.artifacts['object_store']['background_upload'] = true if Settings.artifacts['object_store']['background_upload'].nil?
+Settings.artifacts['object_store']['proxy_download'] = false if Settings.artifacts['object_store']['proxy_download'].nil?
+# Convert upload connection settings to use string keys, to make Fog happy
+Settings.artifacts['object_store']['connection']&.deep_stringify_keys!
#
# Registry
@@ -335,6 +348,29 @@ Settings.pages['artifacts_server'] ||= Settings.pages['enabled'] if Settings.pa
Settings['lfs'] ||= Settingslogic.new({})
Settings.lfs['enabled'] = true if Settings.lfs['enabled'].nil?
Settings.lfs['storage_path'] = Settings.absolute(Settings.lfs['storage_path'] || File.join(Settings.shared['path'], "lfs-objects"))
+Settings.lfs['object_store'] ||= Settingslogic.new({})
+Settings.lfs['object_store']['enabled'] = false if Settings.lfs['object_store']['enabled'].nil?
+Settings.lfs['object_store']['remote_directory'] ||= nil
+Settings.lfs['object_store']['direct_upload'] = false if Settings.lfs['object_store']['direct_upload'].nil?
+Settings.lfs['object_store']['background_upload'] = true if Settings.lfs['object_store']['background_upload'].nil?
+Settings.lfs['object_store']['proxy_download'] = false if Settings.lfs['object_store']['proxy_download'].nil?
+# Convert upload connection settings to use string keys, to make Fog happy
+Settings.lfs['object_store']['connection']&.deep_stringify_keys!
+
+#
+# Uploads
+#
+Settings['uploads'] ||= Settingslogic.new({})
+Settings.uploads['storage_path'] = Settings.absolute(Settings.uploads['storage_path'] || 'public')
+Settings.uploads['base_dir'] = Settings.uploads['base_dir'] || 'uploads/-/system'
+Settings.uploads['object_store'] ||= Settingslogic.new({})
+Settings.uploads['object_store']['enabled'] = false if Settings.uploads['object_store']['enabled'].nil?
+Settings.uploads['object_store']['remote_directory'] ||= 'uploads'
+Settings.uploads['object_store']['direct_upload'] = false if Settings.uploads['object_store']['direct_upload'].nil?
+Settings.uploads['object_store']['background_upload'] = true if Settings.uploads['object_store']['background_upload'].nil?
+Settings.uploads['object_store']['proxy_download'] = false if Settings.uploads['object_store']['proxy_download'].nil?
+# Convert upload connection settings to use string keys, to make Fog happy
+Settings.uploads['object_store']['connection']&.deep_stringify_keys!
#
# Mattermost
@@ -348,7 +384,7 @@ Settings.mattermost['host'] = nil unless Settings.mattermost.enabled
#
Settings['gravatar'] ||= Settingslogic.new({})
Settings.gravatar['enabled'] = true if Settings.gravatar['enabled'].nil?
-Settings.gravatar['plain_url'] ||= 'http://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon'
+Settings.gravatar['plain_url'] ||= 'https://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon'
Settings.gravatar['ssl_url'] ||= 'https://secure.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon'
Settings.gravatar['host'] = Settings.host_without_www(Settings.gravatar['plain_url'])
@@ -415,6 +451,16 @@ 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'
+Settings.cron_jobs['pages_domain_verification_cron_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['pages_domain_verification_cron_worker']['cron'] ||= '*/15 * * * *'
+Settings.cron_jobs['pages_domain_verification_cron_worker']['job_class'] = 'PagesDomainVerificationCronWorker'
+
+#
+# Sidekiq
+#
+Settings['sidekiq'] ||= Settingslogic.new({})
+Settings['sidekiq']['log_format'] ||= 'default'
+
#
# GitLab Shell
#
@@ -451,12 +497,7 @@ unless Settings.repositories.storages['default']
end
Settings.repositories.storages.each do |key, storage|
- storage = Settingslogic.new(storage)
-
- # Expand relative paths
- storage['path'] = Settings.absolute(storage['path'])
-
- Settings.repositories.storages[key] = storage
+ Settings.repositories.storages[key] = Gitlab::GitalyClient::StorageSettings.new(storage)
end
#
@@ -467,10 +508,10 @@ end
# repository_downloads_path value.
#
repositories_storages = Settings.repositories.storages.values
-repository_downloads_path = Settings.gitlab['repository_downloads_path'].to_s.gsub(/\/$/, '')
+repository_downloads_path = Settings.gitlab['repository_downloads_path'].to_s.gsub(%r{/$}, '')
repository_downloads_full_path = File.expand_path(repository_downloads_path, Settings.gitlab['user_home'])
-if repository_downloads_path.blank? || repositories_storages.any? { |rs| [repository_downloads_path, repository_downloads_full_path].include?(rs['path'].gsub(/\/$/, '')) }
+if repository_downloads_path.blank? || repositories_storages.any? { |rs| [repository_downloads_path, repository_downloads_full_path].include?(rs.legacy_disk_path.gsub(%r{/$}, '')) }
Settings.gitlab['repository_downloads_path'] = File.join(Settings.shared['path'], 'cache/archive')
end
diff --git a/config/initializers/6_validations.rb b/config/initializers/6_validations.rb
index f8e67ce04c9..d92cdb97766 100644
--- a/config/initializers/6_validations.rb
+++ b/config/initializers/6_validations.rb
@@ -5,7 +5,7 @@ end
def find_parent_path(name, path)
parent = Pathname.new(path).realpath.parent
Gitlab.config.repositories.storages.detect do |n, rs|
- name != n && Pathname.new(rs['path']).realpath == parent
+ name != n && Pathname.new(rs.legacy_disk_path).realpath == parent
end
rescue Errno::EIO, Errno::ENOENT => e
warning = "WARNING: couldn't verify #{path} (#{name}). "\
@@ -33,7 +33,7 @@ def validate_storages_config
"If you're using the Gitlab Development Kit, you can update your configuration running `gdk reconfigure`.\n"
end
- if !repository_storage.is_a?(Hash) || repository_storage['path'].nil?
+ if !repository_storage.is_a?(Gitlab::GitalyClient::StorageSettings) || repository_storage.legacy_disk_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
@@ -50,7 +50,7 @@ end
def validate_storages_paths
Gitlab.config.repositories.storages.each do |name, repository_storage|
- parent_name, _parent_path = find_parent_path(name, repository_storage['path'])
+ parent_name, _parent_path = find_parent_path(name, repository_storage.legacy_disk_path)
if parent_name
storage_validation_error("#{name} is a nested path of #{parent_name}. Nested paths are not supported for repository storages")
end
diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb
index 45b39b2a38d..7cdf49159b4 100644
--- a/config/initializers/8_metrics.rb
+++ b/config/initializers/8_metrics.rb
@@ -94,6 +94,7 @@ def instrument_classes(instrumentation)
instrumentation.instrument_instance_methods(RepositoryCheck::SingleRepositoryWorker)
+ instrumentation.instrument_instance_methods(Rouge::Plugins::CommonMark)
instrumentation.instrument_instance_methods(Rouge::Plugins::Redcarpet)
instrumentation.instrument_instance_methods(Rouge::Formatters::HTMLGitlab)
diff --git a/config/initializers/active_record_array_type_casting.rb b/config/initializers/active_record_array_type_casting.rb
index d94d592add6..a149e048ee2 100644
--- a/config/initializers/active_record_array_type_casting.rb
+++ b/config/initializers/active_record_array_type_casting.rb
@@ -1,20 +1,23 @@
-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
+# Remove this initializer when upgraded to Rails 5.0
+unless Gitlab.rails5?
+ 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)
+ super(attribute, value)
+ end
end
- end
- prepend TypeCasting
+ prepend TypeCasting
+ end
end
end
end
diff --git a/config/initializers/active_record_data_types.rb b/config/initializers/active_record_data_types.rb
index fef591c397d..fda13d0c4cb 100644
--- a/config/initializers/active_record_data_types.rb
+++ b/config/initializers/active_record_data_types.rb
@@ -54,7 +54,7 @@ elsif Gitlab::Database.mysql?
def initialize_type_map(mapping)
super mapping
- mapping.register_type(%r(timestamp)i) do |sql_type|
+ mapping.register_type(/timestamp/i) do |sql_type|
precision = extract_precision(sql_type)
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlDateTimeWithTimeZone.new(precision: precision)
end
@@ -79,3 +79,8 @@ elsif Gitlab::Database.mysql?
NATIVE_DATABASE_TYPES[:datetime_with_timezone] = { name: 'timestamp' }
end
end
+
+# Ensure `datetime_with_timezone` columns are correctly written to schema.rb
+if (ActiveRecord::Base.connection.active? rescue false)
+ ActiveRecord::Base.connection.send :reload_type_map
+end
diff --git a/config/initializers/active_record_locking.rb b/config/initializers/active_record_locking.rb
index 150aaa2a8c2..3e7111fd063 100644
--- a/config/initializers/active_record_locking.rb
+++ b/config/initializers/active_record_locking.rb
@@ -1,73 +1,77 @@
# 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
+# Remove this entire initializer when we are at rails 5.0.
+# This file fixes the bug (see below) which has been fixed in the upstream.
+unless Gitlab.rails5?
+ # 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?
+ 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
+ lock_col = self.class.locking_column
- previous_lock_value = send(lock_col).to_i # rubocop:disable GitlabSecurity/PublicSend
+ 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
+ # This line is added as a patch
+ previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0
- increment_lock
+ increment_lock
- attribute_names += [lock_col]
- attribute_names.uniq!
+ attribute_names += [lock_col]
+ attribute_names.uniq!
- begin
- relation = self.class.unscoped
+ 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
- )
+ 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
+ unless affected_rows == 1
+ raise ActiveRecord::StaleObjectError.new(self, "update")
+ end
- affected_rows
+ affected_rows
- # If something went wrong, revert the version.
- rescue Exception
- send(lock_col + '=', previous_lock_value) # rubocop:disable GitlabSecurity/PublicSend
- raise
+ # If something went wrong, revert the version.
+ rescue Exception
+ send(lock_col + '=', previous_lock_value) # rubocop:disable GitlabSecurity/PublicSend
+ raise
+ end
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?
+ # 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]))
+ column_name = self.class.locking_column
+ super.where(self.class.arel_table[column_name].eq(self[column_name]))
+ end
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
+ # 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/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb
new file mode 100644
index 00000000000..a65f8aecf9e
--- /dev/null
+++ b/config/initializers/application_controller_renderer.rb
@@ -0,0 +1,12 @@
+# Remove this `if` condition when upgraded to rails 5.0.
+# The body must be kept.
+if Gitlab.rails5?
+ # Be sure to restart your server when you modify this file.
+
+ # ActiveSupport::Reloader.to_prepare do
+ # ApplicationController.renderer.defaults.merge!(
+ # http_host: 'example.org',
+ # https: false
+ # )
+ # end
+end
diff --git a/config/initializers/ar5_batching.rb b/config/initializers/ar5_batching.rb
index 6ebaf8834d2..874455ce5af 100644
--- a/config/initializers/ar5_batching.rb
+++ b/config/initializers/ar5_batching.rb
@@ -1,41 +1,39 @@
-# Port ActiveRecord::Relation#in_batches from ActiveRecord 5.
-# https://github.com/rails/rails/blob/ac027338e4a165273607dccee49a3d38bc836794/activerecord/lib/active_record/relation/batches.rb#L184
-# TODO: this can be removed once we're using AR5.
-raise "Vendored ActiveRecord 5 code! Delete #{__FILE__}!" if ActiveRecord::VERSION::MAJOR >= 5
-
-module ActiveRecord
- module Batches
- # Differences from upstream: enumerator support was removed, and custom
- # order/limit clauses are ignored without a warning.
- def in_batches(of: 1000, start: nil, finish: nil, load: false)
- raise "Must provide a block" unless block_given?
-
- relation = self.reorder(batch_order).limit(of)
- relation = relation.where(arel_table[primary_key].gteq(start)) if start
- relation = relation.where(arel_table[primary_key].lteq(finish)) if finish
- batch_relation = relation
-
- loop do
- if load
- records = batch_relation.records
- ids = records.map(&:id)
- yielded_relation = self.where(primary_key => ids)
- yielded_relation.load_records(records)
- else
- ids = batch_relation.pluck(primary_key)
- yielded_relation = self.where(primary_key => ids)
+# Remove this file when upgraded to rails 5.0.
+unless Gitlab.rails5?
+ module ActiveRecord
+ module Batches
+ # Differences from upstream: enumerator support was removed, and custom
+ # order/limit clauses are ignored without a warning.
+ def in_batches(of: 1000, start: nil, finish: nil, load: false)
+ raise "Must provide a block" unless block_given?
+
+ relation = self.reorder(batch_order).limit(of)
+ relation = relation.where(arel_table[primary_key].gteq(start)) if start
+ relation = relation.where(arel_table[primary_key].lteq(finish)) if finish
+ batch_relation = relation
+
+ loop do
+ if load
+ records = batch_relation.records
+ ids = records.map(&:id)
+ yielded_relation = self.where(primary_key => ids)
+ yielded_relation.load_records(records)
+ else
+ ids = batch_relation.pluck(primary_key)
+ yielded_relation = self.where(primary_key => ids)
+ end
+
+ break if ids.empty?
+
+ primary_key_offset = ids.last
+ raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset
+
+ yield yielded_relation
+
+ break if ids.length < of
+
+ batch_relation = relation.where(arel_table[primary_key].gt(primary_key_offset))
end
-
- break if ids.empty?
-
- primary_key_offset = ids.last
- raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset
-
- yield yielded_relation
-
- break if ids.length < of
-
- batch_relation = relation.where(arel_table[primary_key].gt(primary_key_offset))
end
end
end
diff --git a/config/initializers/ar5_pg_10_support.rb b/config/initializers/ar5_pg_10_support.rb
new file mode 100644
index 00000000000..40548290ce8
--- /dev/null
+++ b/config/initializers/ar5_pg_10_support.rb
@@ -0,0 +1,58 @@
+# Remove this file when upgraded to rails 5.0.
+if !Gitlab.rails5? && Gitlab::Database.postgresql?
+ require 'active_record/connection_adapters/postgresql_adapter'
+ require 'active_record/connection_adapters/postgresql/schema_statements'
+
+ #
+ # Monkey-patch the refused Rails 4.2 patch at https://github.com/rails/rails/pull/31330
+ #
+ # Updates sequence logic to support PostgreSQL 10.
+ #
+ # rubocop:disable all
+ module ActiveRecord
+ module ConnectionAdapters
+
+ # We need #postgresql_version to be public as in ActiveRecord 5 for seed_fu
+ # to work. In ActiveRecord 4, it is protected.
+ # https://github.com/mbleigh/seed-fu/issues/123
+ class PostgreSQLAdapter
+ public :postgresql_version
+ end
+
+ module PostgreSQL
+ module SchemaStatements
+ # Resets the sequence of a table's primary key to the maximum value.
+ def reset_pk_sequence!(table, pk = nil, sequence = nil) #:nodoc:
+ unless pk and sequence
+ default_pk, default_sequence = pk_and_sequence_for(table)
+
+ pk ||= default_pk
+ sequence ||= default_sequence
+ end
+
+ if @logger && pk && !sequence
+ @logger.warn "#{table} has primary key #{pk} with no default sequence"
+ end
+
+ if pk && sequence
+ quoted_sequence = quote_table_name(sequence)
+ max_pk = select_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}")
+ if max_pk.nil?
+ if postgresql_version >= 100000
+ minvalue = select_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass")
+ else
+ minvalue = select_value("SELECT min_value FROM #{quoted_sequence}")
+ end
+ end
+
+ select_value <<-end_sql, 'SCHEMA'
+ SELECT setval(#{quote(quoted_sequence)}, #{max_pk ? max_pk : minvalue}, #{max_pk ? true : false})
+ end_sql
+ end
+ end
+ end
+ end
+ end
+ end
+ # rubocop:enable all
+end
diff --git a/config/initializers/ar_native_database_types.rb b/config/initializers/ar_native_database_types.rb
new file mode 100644
index 00000000000..3522b1db536
--- /dev/null
+++ b/config/initializers/ar_native_database_types.rb
@@ -0,0 +1,11 @@
+require 'active_record/connection_adapters/abstract_mysql_adapter'
+
+module ActiveRecord
+ module ConnectionAdapters
+ class AbstractMysqlAdapter
+ NATIVE_DATABASE_TYPES.merge!(
+ bigserial: { name: 'bigint(20) auto_increment PRIMARY KEY' }
+ )
+ end
+ end
+end
diff --git a/config/initializers/artifacts_direct_upload_support.rb b/config/initializers/artifacts_direct_upload_support.rb
new file mode 100644
index 00000000000..d2bc35ea613
--- /dev/null
+++ b/config/initializers/artifacts_direct_upload_support.rb
@@ -0,0 +1,7 @@
+artifacts_object_store = Gitlab.config.artifacts.object_store
+
+if artifacts_object_store.enabled &&
+ artifacts_object_store.direct_upload &&
+ artifacts_object_store.connection&.provider.to_s != 'Google'
+ raise "Only 'Google' is supported as a object storage provider when 'direct_upload' of artifacts is used"
+end
diff --git a/config/initializers/asset_sync.rb b/config/initializers/asset_sync.rb
index db8500f6231..7f3934853fa 100644
--- a/config/initializers/asset_sync.rb
+++ b/config/initializers/asset_sync.rb
@@ -14,8 +14,8 @@ AssetSync.configure do |config|
config.fog_directory = ENV['FOG_DIRECTORY'] if ENV.has_key?('FOG_DIRECTORY')
config.fog_region = ENV['FOG_REGION'] if ENV.has_key?('FOG_REGION')
- config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID'] if ENV.has_key?('AWS_ACCESS_KEY_ID')
- config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY'] if ENV.has_key?('AWS_SECRET_ACCESS_KEY')
+ config.aws_access_key_id = ENV['ASSETS_AWS_ACCESS_KEY_ID'] if ENV.has_key?('ASSETS_AWS_ACCESS_KEY_ID')
+ config.aws_secret_access_key = ENV['ASSETS_AWS_SECRET_ACCESS_KEY'] if ENV.has_key?('ASSETS_AWS_SECRET_ACCESS_KEY')
config.aws_reduced_redundancy = ENV['AWS_REDUCED_REDUNDANCY'] == true if ENV.has_key?('AWS_REDUCED_REDUNDANCY')
config.rackspace_username = ENV['RACKSPACE_USERNAME'] if ENV.has_key?('RACKSPACE_USERNAME')
diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb
index 59385cdf379..58941aae1b0 100644
--- a/config/initializers/backtrace_silencers.rb
+++ b/config/initializers/backtrace_silencers.rb
@@ -1,7 +1,2 @@
-# Be sure to restart your server when you modify this file.
-
-# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
-# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
-
-# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
-# Rails.backtrace_cleaner.remove_silencers!
+Rails.backtrace_cleaner.remove_silencers!
+Rails.backtrace_cleaner.add_silencer { |line| line !~ Gitlab::APP_DIRS_PATTERN }
diff --git a/config/initializers/carrierwave.rb b/config/initializers/carrierwave.rb
index cd7df44351a..5cde6cbb0ff 100644
--- a/config/initializers/carrierwave.rb
+++ b/config/initializers/carrierwave.rb
@@ -28,16 +28,4 @@ if File.exist?(aws_file)
# when fog_public is false and provider is AWS or Google, defaults to 600
config.fog_authenticated_url_expiration = 1 << 29
end
-
- # Mocking Fog requests, based on: https://github.com/carrierwaveuploader/carrierwave/wiki/How-to%3A-Test-Fog-based-uploaders
- if Rails.env.test?
- Fog.mock!
- connection = ::Fog::Storage.new(
- aws_access_key_id: AWS_CONFIG['access_key_id'],
- aws_secret_access_key: AWS_CONFIG['secret_access_key'],
- provider: 'AWS',
- region: AWS_CONFIG['region']
- )
- connection.directories.create(key: AWS_CONFIG['bucket'])
- end
end
diff --git a/config/initializers/date_time_formats.rb b/config/initializers/date_time_formats.rb
index 57568203cab..1939ced512d 100644
--- a/config/initializers/date_time_formats.rb
+++ b/config/initializers/date_time_formats.rb
@@ -2,8 +2,10 @@
# :medium - Nov 10, 2007
# :long - November 10, 2007
Date::DATE_FORMATS[:medium] = '%b %-d, %Y'
+Date::DATE_FORMATS[:csv] = '%Y-%m-%d'
# :short - 18 Jan 06:10
# :medium - Jan 18, 2007 6:10am
# :long - January 18, 2007 06:10
Time::DATE_FORMATS[:medium] = '%b %-d, %Y %-I:%M%P'
+Time::DATE_FORMATS[:csv] = '%Y-%m-%d %H:%M:%S'
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 051ef93b205..362b9cc9a88 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -212,55 +212,12 @@ Devise.setup do |config|
# manager.default_strategies(scope: :user).unshift :some_external_strategy
# end
- if Gitlab::LDAP::Config.enabled?
- Gitlab::LDAP::Config.providers.each do |provider|
- ldap_config = Gitlab::LDAP::Config.new(provider)
+ if Gitlab::Auth::LDAP::Config.enabled?
+ Gitlab::Auth::LDAP::Config.providers.each do |provider|
+ ldap_config = Gitlab::Auth::LDAP::Config.new(provider)
config.omniauth(provider, ldap_config.omniauth_options)
end
end
- Gitlab.config.omniauth.providers.each do |provider|
- provider_arguments = []
-
- %w[app_id app_secret].each do |argument|
- provider_arguments << provider[argument] if provider[argument]
- end
-
- case provider['args']
- when Array
- # An Array from the configuration will be expanded.
- provider_arguments.concat provider['args']
- when Hash
- # Add procs for handling SLO
- if provider['name'] == 'cas3'
- provider['args'][:on_single_sign_out] = lambda do |request|
- ticket = request.params[:session_index]
- raise "Service Ticket not found." unless Gitlab::OAuth::Session.valid?(:cas3, ticket)
-
- Gitlab::OAuth::Session.destroy(:cas3, ticket)
- true
- end
- end
- if provider['name'] == 'authentiq'
- provider['args'][:remote_sign_out_handler] = lambda do |request|
- authentiq_session = request.params['sid']
- if Gitlab::OAuth::Session.valid?(:authentiq, authentiq_session)
- Gitlab::OAuth::Session.destroy(:authentiq, authentiq_session)
- true
- else
- false
- end
- end
- end
-
- if provider['name'] == 'shibboleth'
- provider['args'][:fail_with_empty_uid] = true
- end
-
- # A Hash from the configuration will be passed as is.
- provider_arguments << provider['args'].symbolize_keys
- end
-
- config.omniauth provider['name'].to_sym, *provider_arguments
- end
+ Gitlab::OmniauthInitializer.new(config).execute(Gitlab.config.omniauth.providers)
end
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index b89f0419b91..2079d3acb72 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -103,4 +103,6 @@ Doorkeeper.configure do
# Some applications require dynamic query parameters on their request_uri
# set to true if you want this to be allowed
# wildcard_redirect_uri false
+
+ base_controller 'ApplicationController'
end
diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb
index af174def047..98e1f6e830f 100644
--- a/config/initializers/doorkeeper_openid_connect.rb
+++ b/config/initializers/doorkeeper_openid_connect.rb
@@ -31,6 +31,7 @@ Doorkeeper::OpenidConnect.configure do
o.claim(:website) { |user| user.full_website_url if user.website_url? }
o.claim(:profile) { |user| Gitlab::Routing.url_helpers.user_url user }
o.claim(:picture) { |user| user.avatar_url(only_path: false) }
+ o.claim(:groups) { |user| user.membership_groups.map(&:full_path) }
end
end
end
diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb
index cc9167d29b9..c60ad535fd5 100644
--- a/config/initializers/flipper.rb
+++ b/config/initializers/flipper.rb
@@ -8,7 +8,7 @@ Flipper.configure do |config|
cached_adapter = Flipper::Adapters::ActiveSupportCacheStore.new(
adapter,
Rails.cache,
- expires_in: 10.seconds)
+ expires_in: 1.hour)
Flipper.new(cached_adapter)
end
diff --git a/config/initializers/fog_google_https_private_urls.rb b/config/initializers/fog_google_https_private_urls.rb
new file mode 100644
index 00000000000..f92e623a5d2
--- /dev/null
+++ b/config/initializers/fog_google_https_private_urls.rb
@@ -0,0 +1,20 @@
+#
+# Monkey patching the https support for private urls
+# See https://gitlab.com/gitlab-org/gitlab-ee/issues/4879
+#
+module Fog
+ module Storage
+ class GoogleXML
+ class File < Fog::Model
+ module MonkeyPatch
+ def url(expires)
+ requires :key
+ collection.get_https_url(key, expires)
+ end
+ end
+
+ prepend MonkeyPatch
+ end
+ end
+ end
+end
diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb
index bedd57ede04..4cf1d455eb4 100644
--- a/config/initializers/forbid_sidekiq_in_transactions.rb
+++ b/config/initializers/forbid_sidekiq_in_transactions.rb
@@ -1,5 +1,7 @@
module Sidekiq
module Worker
+ EnqueueFromTransactionError = Class.new(StandardError)
+
mattr_accessor :skip_transaction_check
self.skip_transaction_check = false
@@ -12,17 +14,30 @@ module Sidekiq
end
module ClassMethods
- module NoSchedulingFromTransactions
+ module NoEnqueueingFromTransactions
%i(perform_async perform_at perform_in).each do |name|
define_method(name) do |*args|
if !Sidekiq::Worker.skip_transaction_check && AfterCommitQueue.inside_transaction?
- raise <<-MSG.strip_heredoc
+ begin
+ raise Sidekiq::Worker::EnqueueFromTransactionError, <<~MSG
`#{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
+ MSG
+ rescue Sidekiq::Worker::EnqueueFromTransactionError => e
+ if Rails.env.production?
+ Rails.logger.error(e.message)
+
+ if Gitlab::Sentry.enabled?
+ Gitlab::Sentry.context
+ Raven.capture_exception(e)
+ end
+ else
+ raise
+ end
+ end
end
super(*args)
@@ -30,7 +45,7 @@ module Sidekiq
end
end
- prepend NoSchedulingFromTransactions
+ prepend NoEnqueueingFromTransactions
end
end
end
diff --git a/config/initializers/gollum.rb b/config/initializers/gollum.rb
index f1066f83dd9..6dfaceb8427 100644
--- a/config/initializers/gollum.rb
+++ b/config/initializers/gollum.rb
@@ -35,6 +35,108 @@ module Gollum
[]
end
end
+
+ # Remove if https://github.com/gollum/gollum-lib/pull/292 has been merged
+ def update_page(page, name, format, data, commit = {})
+ name = name ? ::File.basename(name) : page.name
+ format ||= page.format
+ dir = ::File.dirname(page.path)
+ dir = '' if dir == '.'
+ filename = (rename = page.name != name) ? Gollum::Page.cname(name) : page.filename_stripped
+
+ multi_commit = !!commit[:committer]
+ committer = multi_commit ? commit[:committer] : Committer.new(self, commit)
+
+ if !rename && page.format == format
+ committer.add(page.path, normalize(data))
+ else
+ committer.delete(page.path)
+ committer.add_to_index(dir, filename, format, data)
+ end
+
+ committer.after_commit do |index, _sha|
+ @access.refresh
+ index.update_working_dir(dir, page.filename_stripped, page.format)
+ index.update_working_dir(dir, filename, format)
+ end
+
+ multi_commit ? committer : committer.commit
+ end
+
+ # Remove if https://github.com/gollum/gollum-lib/pull/292 has been merged
+ def rename_page(page, rename, commit = {})
+ return false if page.nil?
+ return false if rename.nil? || rename.empty?
+
+ (target_dir, target_name) = ::File.split(rename)
+ (source_dir, source_name) = ::File.split(page.path)
+ source_name = page.filename_stripped
+
+ # File.split gives us relative paths with ".", commiter.add_to_index doesn't like that.
+ target_dir = '' if target_dir == '.'
+ source_dir = '' if source_dir == '.'
+ target_dir = target_dir.gsub(/^\//, '') # rubocop:disable Style/RegexpLiteral
+
+ # if the rename is a NOOP, abort
+ if source_dir == target_dir && source_name == target_name
+ return false
+ end
+
+ multi_commit = !!commit[:committer]
+ committer = multi_commit ? commit[:committer] : Committer.new(self, commit)
+
+ # This piece only works for multi_commit
+ # If we are in a commit batch and one of the previous operations
+ # has updated the page, any information we ask to the page can be outdated.
+ # Therefore, we should ask first to the current committer tree to see if
+ # there is any updated change.
+ raw_data = raw_data_in_committer(committer, source_dir, page.filename) ||
+ raw_data_in_committer(committer, source_dir, "#{target_name}.#{Page.format_to_ext(page.format)}") ||
+ page.raw_data
+
+ committer.delete(page.path)
+ committer.add_to_index(target_dir, target_name, page.format, raw_data)
+
+ committer.after_commit do |index, _sha|
+ @access.refresh
+ index.update_working_dir(source_dir, source_name, page.format)
+ index.update_working_dir(target_dir, target_name, page.format)
+ end
+
+ multi_commit ? committer : committer.commit
+ end
+
+ # Remove if https://github.com/gollum/gollum-lib/pull/292 has been merged
+ def raw_data_in_committer(committer, dir, filename)
+ data = nil
+
+ [*dir.split(::File::SEPARATOR), filename].each do |key|
+ data = data ? data[key] : committer.tree[key]
+ break unless data
+ end
+
+ data
+ end
+ end
+
+ module Git
+ class Git
+ def tree_entry(commit, path)
+ pathname = Pathname.new(path)
+ tmp_entry = nil
+
+ pathname.each_filename do |dir|
+ tmp_entry = if tmp_entry.nil?
+ commit.tree[dir]
+ else
+ @repo.lookup(tmp_entry[:oid])[dir]
+ end
+
+ return nil unless tmp_entry
+ end
+ tmp_entry
+ end
+ end
end
end
diff --git a/config/initializers/grape_route_helpers_fix.rb b/config/initializers/grape_route_helpers_fix.rb
index d3cf9e453d0..612cca3dfbd 100644
--- a/config/initializers/grape_route_helpers_fix.rb
+++ b/config/initializers/grape_route_helpers_fix.rb
@@ -1,5 +1,21 @@
if defined?(GrapeRouteHelpers)
module GrapeRouteHelpers
+ module AllRoutes
+ # Bringing in PR https://github.com/reprah/grape-route-helpers/pull/21 due to abandonment.
+ #
+ # Without the following fix, when two helper methods are the same, but have different arguments
+ # (for example: api_v1_cats_owners_path(id: 1) vs api_v1_cats_owners_path(id: 1, owner_id: 2))
+ # if the helper method with the least number of arguments is defined first (because the route was defined first)
+ # then it will shadow the longer route.
+ #
+ # The fix is to sort descending by amount of arguments
+ def decorated_routes
+ @decorated_routes ||= all_routes
+ .map { |r| DecoratedRoute.new(r) }
+ .sort_by { |r| -r.dynamic_path_segments.count }
+ end
+ end
+
class DecoratedRoute
# GrapeRouteHelpers gem tries to parse the versions
# from a string, not supporting Grape `version` array definition.
diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb
index 8560d24526f..49fdd23064c 100644
--- a/config/initializers/lograge.rb
+++ b/config/initializers/lograge.rb
@@ -1,3 +1,21 @@
+# Monkey patch lograge until https://github.com/roidrage/lograge/pull/241 is released
+module Lograge
+ class RequestLogSubscriber < ActiveSupport::LogSubscriber
+ def strip_query_string(path)
+ index = path.index('?')
+ index ? path[0, index] : path
+ end
+
+ def extract_location
+ location = Thread.current[:lograge_location]
+ return {} unless location
+
+ Thread.current[:lograge_location] = nil
+ { location: strip_query_string(location) }
+ end
+ end
+end
+
# Only use Lograge for Rails
unless Sidekiq.server?
filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log")
@@ -12,9 +30,14 @@ unless Sidekiq.server?
config.lograge.logger = ActiveSupport::Logger.new(filename)
# Add request parameters to log output
config.lograge.custom_options = lambda do |event|
+ params = event.payload[:params]
+ .except(*%w(controller action format))
+ .each_pair
+ .map { |k, v| { key: k, value: v } }
+
payload = {
time: event.time.utc.iso8601(3),
- params: event.payload[:params].except(*%w(controller action format)),
+ params: params,
remote_ip: event.payload[:remote_ip],
user_id: event.payload[:user_id],
username: event.payload[:username]
diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb
index 5e3e4c966cb..e9326653cbe 100644
--- a/config/initializers/mime_types.rb
+++ b/config/initializers/mime_types.rb
@@ -14,4 +14,4 @@ Mime::Type.register "video/webm", :webm
Mime::Type.register "video/ogg", :ogv
Mime::Type.unregister :json
-Mime::Type.register 'application/json', :json, %w(application/vnd.git-lfs+json application/json)
+Mime::Type.register 'application/json', :json, [LfsRequest::CONTENT_TYPE, 'application/json']
diff --git a/config/initializers/new_framework_defaults.rb b/config/initializers/new_framework_defaults.rb
new file mode 100644
index 00000000000..2d130bc0bf8
--- /dev/null
+++ b/config/initializers/new_framework_defaults.rb
@@ -0,0 +1,29 @@
+# Remove this `if` condition when upgraded to rails 5.0.
+# The body must be kept.
+if Gitlab.rails5?
+ # Be sure to restart your server when you modify this file.
+ #
+ # This file contains migration options to ease your Rails 5.0 upgrade.
+ #
+ # Once upgraded flip defaults one by one to migrate to the new default.
+ #
+ # Read the Guide for Upgrading Ruby on Rails for more info on each option.
+
+ Rails.application.config.action_controller.raise_on_unfiltered_parameters = true
+
+ # Enable per-form CSRF tokens. Previous versions had false.
+ Rails.application.config.action_controller.per_form_csrf_tokens = false
+
+ # Enable origin-checking CSRF mitigation. Previous versions had false.
+ Rails.application.config.action_controller.forgery_protection_origin_check = false
+
+ # Make Ruby 2.4 preserve the timezone of the receiver when calling `to_time`.
+ # Previous versions had false.
+ ActiveSupport.to_time_preserves_timezone = false
+
+ # Require `belongs_to` associations by default. Previous versions had false.
+ Rails.application.config.active_record.belongs_to_required_by_default = false
+
+ # Do not halt callback chains when a callback returns false. Previous versions had true.
+ ActiveSupport.halt_callback_chains_on_return_false = true
+end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index e9e1f1c4e9b..00baea08613 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -1,6 +1,6 @@
-if Gitlab::LDAP::Config.enabled?
+if Gitlab::Auth::LDAP::Config.enabled?
module OmniAuth::Strategies
- Gitlab::LDAP::Config.available_servers.each do |server|
+ Gitlab::Auth::LDAP::Config.available_servers.each do |server|
# do not redeclare LDAP
next if server['provider_name'] == 'ldap'
diff --git a/config/initializers/peek.rb b/config/initializers/peek.rb
index 1cff355346c..ba04a2bf5fa 100644
--- a/config/initializers/peek.rb
+++ b/config/initializers/peek.rb
@@ -2,23 +2,27 @@ Rails.application.config.peek.adapter = :redis, { client: ::Redis.new(Gitlab::Re
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
+elsif Gitlab::Database.postgresql?
require 'peek-pg'
PEEK_DB_CLIENT = ::PG::Connection
PEEK_DB_VIEW = Peek::Views::PG
+else
+ raise "Unsupported database adapter for peek!"
end
+
Peek.into PEEK_DB_VIEW
+Peek.into Peek::Views::Gitaly
+Peek.into Peek::Views::Rblineprof
Peek.into Peek::Views::Redis
Peek.into Peek::Views::Sidekiq
-Peek.into Peek::Views::Rblineprof
Peek.into Peek::Views::GC
-Peek.into Peek::Views::Gitaly
-# rubocop:disable Style/ClassAndModuleCamelCase
+# rubocop:disable Naming/ClassAndModuleCamelCase
class PEEK_DB_CLIENT
class << self
attr_accessor :query_details
diff --git a/config/initializers/query_limiting.rb b/config/initializers/query_limiting.rb
new file mode 100644
index 00000000000..66864d1898e
--- /dev/null
+++ b/config/initializers/query_limiting.rb
@@ -0,0 +1,9 @@
+if Gitlab::QueryLimiting.enable?
+ require_dependency 'gitlab/query_limiting/active_support_subscriber'
+ require_dependency 'gitlab/query_limiting/transaction'
+ require_dependency 'gitlab/query_limiting/middleware'
+
+ Gitlab::Application.configure do |config|
+ config.middleware.use(Gitlab::QueryLimiting::Middleware)
+ end
+end
diff --git a/config/initializers/rack_attack_global.rb b/config/initializers/rack_attack_global.rb
index 9453df2ec5a..a90516eee7d 100644
--- a/config/initializers/rack_attack_global.rb
+++ b/config/initializers/rack_attack_global.rb
@@ -26,6 +26,7 @@ class Rack::Attack
throttle('throttle_unauthenticated', Gitlab::Throttle.unauthenticated_options) do |req|
Gitlab::Throttle.settings.throttle_unauthenticated_enabled &&
req.unauthenticated? &&
+ !req.api_internal_request? &&
req.ip
end
@@ -54,6 +55,10 @@ class Rack::Attack
path.start_with?('/api')
end
+ def api_internal_request?
+ path =~ %r{^/api/v\d+/internal/}
+ end
+
def web_request?
!api_request?
end
diff --git a/config/initializers/rugged_use_gitlab_git_attributes.rb b/config/initializers/rugged_use_gitlab_git_attributes.rb
deleted file mode 100644
index 1cfb3bcb4bd..00000000000
--- a/config/initializers/rugged_use_gitlab_git_attributes.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# 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.attributes(name)
- end
-
- def attributes
- @attributes ||= Gitlab::Git::Attributes.new(path)
- end
- end
-
- prepend UseGitlabGitAttributes
- end
-end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 0f164e628f9..f6803eb0b5a 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -5,16 +5,23 @@ 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 }
+enable_json_logs = Gitlab.config.sidekiq.log_format == 'json'
+
Sidekiq.configure_server do |config|
config.redis = queues_config_hash
config.server_middleware do |chain|
- chain.add Gitlab::SidekiqMiddleware::ArgumentsLogger if ENV['SIDEKIQ_LOG_ARGUMENTS']
- chain.add Gitlab::SidekiqMiddleware::MemoryKiller if ENV['SIDEKIQ_MEMORY_KILLER_MAX_RSS']
+ chain.add Gitlab::SidekiqMiddleware::ArgumentsLogger if ENV['SIDEKIQ_LOG_ARGUMENTS'] && !enable_json_logs
+ chain.add Gitlab::SidekiqMiddleware::Shutdown
chain.add Gitlab::SidekiqMiddleware::RequestStoreMiddleware unless ENV['SIDEKIQ_REQUEST_STORE'] == '0'
chain.add Gitlab::SidekiqStatus::ServerMiddleware
end
+ if enable_json_logs
+ Sidekiq.logger.formatter = Gitlab::SidekiqLogging::JSONFormatter.new
+ config.options[:job_logger] = Gitlab::SidekiqLogging::StructuredLogger
+ end
+
config.client_middleware do |chain|
chain.add Gitlab::SidekiqStatus::ClientMiddleware
end
diff --git a/config/initializers/warden.rb b/config/initializers/warden.rb
index 3d83fb92d56..ee034d21eae 100644
--- a/config/initializers/warden.rb
+++ b/config/initializers/warden.rb
@@ -2,4 +2,8 @@ Rails.application.configure do |config|
Warden::Manager.after_set_user do |user, auth, opts|
Gitlab::Auth::UniqueIpsLimiter.limit_user!(user)
end
+
+ Warden::Manager.before_failure do |env, opts|
+ Gitlab::Auth::BlockedUserTracker.log_if_user_blocked(env)
+ end
end
diff --git a/config/karma.config.js b/config/karma.config.js
index 609c4780ccc..3be4d5220f4 100644
--- a/config/karma.config.js
+++ b/config/karma.config.js
@@ -5,7 +5,7 @@ var ROOT_PATH = path.resolve(__dirname, '..');
// remove problematic plugins
if (webpackConfig.plugins) {
- webpackConfig.plugins = webpackConfig.plugins.filter(function (plugin) {
+ webpackConfig.plugins = webpackConfig.plugins.filter(function(plugin) {
return !(
plugin instanceof webpack.optimize.CommonsChunkPlugin ||
plugin instanceof webpack.optimize.ModuleConcatenationPlugin ||
@@ -26,6 +26,8 @@ webpackConfig.devtool = 'cheap-inline-source-map';
// Karma configuration
module.exports = function(config) {
+ process.env.TZ = 'Etc/UTC';
+
var progressReporter = process.env.CI ? 'mocha' : 'progress';
var karmaConfig = {
@@ -40,12 +42,12 @@ module.exports = function(config) {
// 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 },
- { pattern: 'spec/javascripts/fixtures/**/*@(.json|.html|.html.raw)', included: false },
+ { pattern: 'spec/javascripts/fixtures/**/*@(.json|.html|.html.raw|.png)', included: false },
],
preprocessors: {
'spec/javascripts/**/*.js': ['webpack', 'sourcemap'],
@@ -61,7 +63,7 @@ module.exports = function(config) {
reports: ['html', 'text-summary'],
dir: 'coverage-javascript/',
subdir: '.',
- fixWebpackSourcePaths: true
+ fixWebpackSourcePaths: true,
};
karmaConfig.browserNoActivityTimeout = 60000; // 60 seconds
}
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
index b1c71095d4f..889111282ef 100644
--- a/config/locales/doorkeeper.en.yml
+++ b/config/locales/doorkeeper.en.yml
@@ -68,7 +68,7 @@ en:
read_user:
Read-only access to the user's profile information, like username, public email and full name
openid:
- The ability to authenticate using GitLab, and read-only access to the user's profile information
+ The ability to authenticate using GitLab, and read-only access to the user's profile information and group memberships
sudo:
Access to the Sudo feature, to perform API actions as any user in the system (only available for admins)
flash:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8932db138d9..795e5d4e6bc 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2,6 +2,18 @@
# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
en:
+ hello: "Hello world"
+ activerecord:
+ attributes:
+ issue_link:
+ source: Source issue
+ target: Target issue
+ errors:
+ 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"
diff --git a/config/prometheus/additional_metrics.yml b/config/prometheus/additional_metrics.yml
index 601a86490d4..10ca612b246 100644
--- a/config/prometheus/additional_metrics.yml
+++ b/config/prometheus/additional_metrics.yml
@@ -26,7 +26,7 @@
weight: 1
queries:
- query_range: 'avg(nginx_upstream_response_msecs_avg{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"})'
- label: Average
+ label: Pod average
unit: ms
- title: "HTTP Error Rate"
y_label: "HTTP 500 Errors / Sec"
@@ -139,21 +139,39 @@
- group: System metrics (Kubernetes)
priority: 5
metrics:
- - title: "Memory Usage"
- y_label: "Memory Usage (MB)"
+ - title: "Memory Usage (Total)"
+ y_label: "Total Memory Used"
required_metrics:
- container_memory_usage_bytes
- weight: 1
+ weight: 4
queries:
- - query_range: '(sum(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) without (job))) / count(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) without (job)) /1024/1024'
- label: Average
+ - query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024'
+ label: Total
+ unit: GB
+ - title: "Core Usage (Total)"
+ y_label: "Total Cores"
+ required_metrics:
+ - container_cpu_usage_seconds_total
+ weight: 3
+ queries:
+ - query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)'
+ label: Total
+ unit: "cores"
+ - title: "Memory Usage (Pod average)"
+ y_label: "Memory Used per Pod"
+ required_metrics:
+ - container_memory_usage_bytes
+ weight: 2
+ queries:
+ - query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024'
+ label: Pod average
unit: MB
- - title: "CPU Utilization"
- y_label: "CPU Utilization (%)"
+ - title: "Core Usage (Pod average)"
+ y_label: "Cores per Pod"
required_metrics:
- container_cpu_usage_seconds_total
weight: 1
queries:
- - query_range: 'sum(avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}"}[2m])) without (job)) * 100'
- label: Average
- unit: "%" \ No newline at end of file
+ - query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (pod_name))'
+ label: Pod average
+ unit: "cores" \ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index f162043dd5e..52726f94753 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -43,10 +43,8 @@ Rails.application.routes.draw do
get 'liveness' => 'health#liveness'
get 'readiness' => 'health#readiness'
post 'storage_check' => 'health#storage_check'
- get 'ide' => 'ide#index'
- get 'ide/*vueroute' => 'ide#index', format: false
resources :metrics, only: [:index]
- mount Peek::Railtie => '/peek'
+ mount Peek::Railtie => '/peek', as: 'peek_routes'
# Boards resources shared between group and projects
resources :boards, only: [] do
@@ -60,6 +58,12 @@ Rails.application.routes.draw do
resources :issues, module: :boards, only: [:index, :update]
end
+
+ # UserCallouts
+ resources :user_callouts, only: [:create]
+
+ get 'ide' => 'ide#index'
+ get 'ide/*vueroute' => 'ide#index', format: false
end
# Koding route
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index e22fb440abc..3cca1210e39 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -1,5 +1,5 @@
namespace :admin do
- resources :users, constraints: { id: /[a-zA-Z.\/0-9_\-]+/ } do
+ resources :users, constraints: { id: %r{[a-zA-Z./0-9_\-]+} } do
resources :keys, only: [:show, :destroy]
resources :identities, except: [:show]
resources :impersonation_tokens, only: [:index, :create] do
@@ -24,6 +24,8 @@ namespace :admin do
resource :impersonation, only: :destroy
resources :abuse_reports, only: [:index, :destroy]
+ resources :gitaly_servers, only: [:index]
+
resources :spam_logs, only: [:index, :destroy] do
member do
post :mark_as_ham
diff --git a/config/routes/ci.rb b/config/routes/ci.rb
index 60c1724bc05..ebd321ed097 100644
--- a/config/routes/ci.rb
+++ b/config/routes/ci.rb
@@ -1,5 +1,5 @@
namespace :ci do
- resource :lint, only: [:show, :create]
+ resource :lint, only: :show
root to: redirect('')
end
diff --git a/config/routes/git_http.rb b/config/routes/git_http.rb
index a53c94326d4..ec5c68f81df 100644
--- a/config/routes/git_http.rb
+++ b/config/routes/git_http.rb
@@ -16,6 +16,13 @@ scope(path: '*namespace_id/:project_id',
get '/*oid', action: :deprecated
end
+ scope(path: 'info/lfs') do
+ resources :lfs_locks, controller: :lfs_locks_api, path: 'locks' do
+ post :unlock, on: :member
+ post :verify, on: :collection
+ end
+ end
+
# GitLab LFS object storage
scope(path: 'gitlab-lfs/objects/*oid', controller: :lfs_storage, constraints: { oid: /[a-f0-9]{64}/ }) do
get '/', action: :download
@@ -33,7 +40,7 @@ scope(path: '*namespace_id/:project_id',
# /info/refs?service=git-receive-pack, but nothing else.
#
git_http_handshake = lambda do |request|
- ProjectUrlConstrainer.new.matches?(request) &&
+ ::Constraints::ProjectUrlConstrainer.new.matches?(request) &&
(request.query_string.blank? ||
request.query_string.match(/\Aservice=git-(upload|receive)-pack\z/))
end
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 976837a246d..170508e893d 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -1,10 +1,8 @@
-require 'constraints/group_url_constrainer'
-
resources :groups, only: [:index, :new, :create] do
post :preview_markdown
end
-constraints(GroupUrlConstrainer.new) do
+constraints(::Constraints::GroupUrlConstrainer.new) do
scope(path: 'groups/*id',
controller: :groups,
constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do
@@ -14,6 +12,7 @@ constraints(GroupUrlConstrainer.new) do
get :merge_requests, as: :merge_requests_group
get :projects, as: :projects_group
get :activity, as: :activity_group
+ put :transfer, as: :transfer_group
end
get '/', action: :show, as: :group_canonical
@@ -25,9 +24,10 @@ constraints(GroupUrlConstrainer.new) do
constraints: { group_id: Gitlab::PathRegex.full_namespace_route_regex }) do
namespace :settings do
resource :ci_cd, only: [:show], controller: 'ci_cd'
+ resources :badges, only: [:index]
end
- resources :variables, only: [:index, :show, :update, :create, :destroy]
+ resource :variables, only: [:show, :update]
resources :children, only: [:index]
@@ -35,7 +35,7 @@ constraints(GroupUrlConstrainer.new) do
post :toggle_subscription, on: :member
end
- resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :edit, :update, :new, :create] do
+ resources :milestones, constraints: { id: %r{[^/]+} }, only: [:index, :show, :edit, :update, :new, :create] do
member do
get :merge_requests
get :participants
@@ -52,9 +52,12 @@ constraints(GroupUrlConstrainer.new) do
resources :uploads, only: [:create] do
collection do
- get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ }
+ get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} }
end
end
+
+ # On CE only index and show actions are needed
+ resources :boards, only: [:index, :show]
end
scope(path: '*id',
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 239b5480321..2a1bcb8cde2 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -1,10 +1,8 @@
-require 'constraints/project_url_constrainer'
-
resources :projects, only: [:index, :new, :create]
draw :git_http
-constraints(ProjectUrlConstrainer.new) do
+constraints(::Constraints::ProjectUrlConstrainer.new) 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
@@ -40,7 +38,7 @@ constraints(ProjectUrlConstrainer.new) do
#
# Templates
#
- get '/templates/:template_type/:key' => 'templates#show', as: :template
+ get '/templates/:template_type/:key' => 'templates#show', as: :template, constraints: { key: %r{[^/]+} }
resource :avatar, only: [:show, :destroy]
resources :commit, only: [:show], constraints: { id: /\h{7,40}/ } do
@@ -50,11 +48,16 @@ constraints(ProjectUrlConstrainer.new) do
post :revert
post :cherry_pick
get :diff_for_path
+ get :merge_requests
end
end
- resource :pages, only: [:show, :destroy] do
- resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: /[^\/]+/ }
+ resource :pages, only: [:show, :update, :destroy] do
+ resources :domains, except: :index, controller: 'pages_domains', constraints: { id: %r{[^/]+} } do
+ member do
+ post :verify
+ end
+ end
end
resources :snippets, concerns: :awardable, constraints: { id: /\d+/ } do
@@ -64,7 +67,7 @@ constraints(ProjectUrlConstrainer.new) do
end
end
- resources :services, constraints: { id: /[^\/]+/ }, only: [:index, :edit, :update] do
+ resources :services, constraints: { id: %r{[^/]+} }, only: [:edit, :update] do
member do
put :test
end
@@ -73,7 +76,9 @@ constraints(ProjectUrlConstrainer.new) do
resource :mattermost, only: [:new, :create]
namespace :prometheus do
- get :active_metrics
+ resources :metrics, constraints: { id: %r{[^\/]+} }, only: [] do
+ get :active_common, on: :collection
+ end
end
resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do
@@ -83,6 +88,12 @@ constraints(ProjectUrlConstrainer.new) do
end
end
+ resources :deploy_tokens, constraints: { id: /\d+/ }, only: [] do
+ member do
+ put :revoke
+ end
+ end
+
resources :forks, only: [:index, :new, :create]
resource :import, only: [:new, :create, :show]
@@ -96,6 +107,8 @@ constraints(ProjectUrlConstrainer.new) do
post :toggle_subscription
post :remove_wip
post :assign_related_issues
+ get :discussions, format: :json
+ post :rebase
scope constraints: { format: nil }, action: :show do
get :commits, defaults: { tab: 'commits' }
@@ -123,7 +136,7 @@ constraints(ProjectUrlConstrainer.new) do
post :bulk_update
end
- resources :discussions, only: [], constraints: { id: /\h{40}/ } do
+ resources :discussions, only: [:show], constraints: { id: /\h{40}/ } do
member do
post :resolve
delete :resolve, action: :unresolve
@@ -154,7 +167,8 @@ constraints(ProjectUrlConstrainer.new) do
end
end
- resources :variables, only: [:index, :show, :update, :create, :destroy]
+ resource :variables, only: [:show, :update]
+
resources :triggers, only: [:index, :create, :edit, :update, :destroy] do
member do
post :take_ownership
@@ -241,6 +255,8 @@ constraints(ProjectUrlConstrainer.new) do
end
scope '-' do
+ get 'archive/*id', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'repositories#archive', as: 'archive'
+
resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do
collection do
post :cancel_all
@@ -272,6 +288,10 @@ constraints(ProjectUrlConstrainer.new) do
post :keep
end
end
+
+ namespace :ci do
+ resource :lint, only: [:show, :create]
+ end
end
draw :legacy_builds
@@ -344,7 +364,7 @@ constraints(ProjectUrlConstrainer.new) do
end
end
- resources :project_members, except: [:show, :new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do
+ resources :project_members, except: [:show, :new, :edit], constraints: { id: %r{[a-zA-Z./0-9_\-#%+]+} }, concerns: :access_requestable do
collection do
delete :leave
@@ -371,20 +391,21 @@ constraints(ProjectUrlConstrainer.new) do
get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes'
- resources :boards, only: [:index, :show, :create, :update, :destroy]
+ # On CE only index and show are needed
+ resources :boards, only: [:index, :show]
resources :todos, only: [:create]
resources :uploads, only: [:create] do
collection do
- get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ }
+ get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} }
end
end
resources :runners, only: [:index, :edit, :update, :destroy, :show] do
member do
- get :resume
- get :pause
+ post :resume
+ post :pause
end
collection do
@@ -407,9 +428,14 @@ constraints(ProjectUrlConstrainer.new) do
end
namespace :settings do
get :members, to: redirect("%{namespace_id}/%{project_id}/project_members")
- resource :ci_cd, only: [:show], controller: 'ci_cd'
+ resource :ci_cd, only: [:show, :update], controller: 'ci_cd' do
+ post :reset_cache
+ end
resource :integrations, only: [:show]
- resource :repository, only: [:show], controller: :repository
+ resource :repository, only: [:show], controller: :repository do
+ post :create_deploy_token, path: 'deploy_token/create'
+ end
+ resources :badges, only: [:index]
end
# Since both wiki and repository routing contains wildcard characters
diff --git a/config/routes/repository.rb b/config/routes/repository.rb
index 9ffdebbcff1..9e506a1a43a 100644
--- a/config/routes/repository.rb
+++ b/config/routes/repository.rb
@@ -2,10 +2,11 @@
resource :repository, only: [:create] do
member do
- 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'
+ get 'archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex }, as: 'archive_alternative', defaults: { append_sha: true }
+
+ # deprecated since GitLab 10.7
+ get ':id/archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+/ }, action: 'archive', as: 'archive_deprecated', defaults: { append_sha: true }
end
end
@@ -49,6 +50,7 @@ scope format: false do
end
end
+ get '/branches/:state', to: 'branches#index', as: :branches_filtered, constraints: { state: /active|stale|all/ }
resources :branches, only: [:index, :new, :create, :destroy]
delete :merged_branches, controller: 'branches', action: :destroy_all_merged
resources :tags, only: [:index, :show, :new, :create, :destroy] do
diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb
index d7bca8310e4..6370645bcb9 100644
--- a/config/routes/uploads.rb
+++ b/config/routes/uploads.rb
@@ -2,17 +2,17 @@ scope path: :uploads do
# Note attachments and User/Group/Project avatars
get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
- constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ }
+ constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: %r{[^/]+} }
# 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: /[^\/]+/ }
+ constraints: { model: /personal_snippet/, id: /\d+/, filename: %r{[^/]+} }
# show temporary uploads
get '-/system/temp/:secret/:filename',
to: 'uploads#show',
- constraints: { filename: /[^\/]+/ }
+ constraints: { filename: %r{[^/]+} }
# Appearance
get "-/system/:model/:mounted_as/:id/:filename",
@@ -22,7 +22,7 @@ scope path: :uploads do
# Project markdown uploads
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: /[^\/]+/ }
+ constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: %r{[^/]+} }
# create uploads for models, snippets (notes) available for now
post ':model',
@@ -34,4 +34,4 @@ end
# Redirect old note attachments path to new uploads path.
get "files/note/:id/:filename",
to: redirect("uploads/note/attachment/%{id}/%{filename}"),
- constraints: { filename: /[^\/]+/ }
+ constraints: { filename: %r{[^/]+} }
diff --git a/config/routes/user.rb b/config/routes/user.rb
index 733a3f6ce9a..57fb37530bb 100644
--- a/config/routes/user.rb
+++ b/config/routes/user.rb
@@ -1,5 +1,3 @@
-require 'constraints/user_url_constrainer'
-
devise_for :users, controllers: { omniauth_callbacks: :omniauth_callbacks,
registrations: :registrations,
passwords: :passwords,
@@ -35,7 +33,7 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get '/u/:username/contributed', to: redirect('users/%{username}/contributed')
end
-constraints(UserUrlConstrainer.new) do
+constraints(::Constraints::UserUrlConstrainer.new) do
# Get all keys of user
get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 31a38f2b508..c811034b29d 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -67,3 +67,8 @@
- [gcp_cluster, 1]
- [project_migrate_hashed_storage, 1]
- [storage_migrator, 1]
+ - [pages_domain_verification, 1]
+ - [object_storage_upload, 1]
+ - [object_storage, 1]
+ - [plugin, 1]
+ - [pipeline_background, 1]
diff --git a/config/spring.rb b/config/spring.rb
new file mode 100644
index 00000000000..c9119b40c08
--- /dev/null
+++ b/config/spring.rb
@@ -0,0 +1,6 @@
+%w(
+ .ruby-version
+ .rbenv-vars
+ tmp/restart.txt
+ tmp/caching-dev.txt
+).each { |path| Spring.watch(path) }
diff --git a/config/svg.config.js b/config/svg.config.js
deleted file mode 100644
index bb27f0caeef..00000000000
--- a/config/svg.config.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/* eslint-disable no-commonjs */
-const path = require('path');
-const fs = require('fs');
-
-const sourcePath = path.join('node_modules', '@gitlab-org/gitlab-svgs', 'dist');
-const sourcePathIllustrations = path.join('node_modules', '@gitlab-org/gitlab-svgs', 'dist', 'illustrations');
-const destPath = path.normalize(path.join('app', 'assets', 'images'));
-
-// Actual Task copying the 2 files + all illustrations
-copyFileSync(path.join(sourcePath, 'icons.svg'), destPath);
-copyFileSync(path.join(sourcePath, 'icons.json'), destPath);
-copyFolderRecursiveSync(sourcePathIllustrations, destPath);
-
-// Helper Functions
-function copyFileSync(source, target) {
- var targetFile = target;
- //if target is a directory a new file with the same name will be created
- if (fs.existsSync(target)) {
- if (fs.lstatSync(target).isDirectory()) {
- targetFile = path.join(target, path.basename(source));
- }
- }
- console.log(`Copy SVG File : ${targetFile}`);
- fs.writeFileSync(targetFile, fs.readFileSync(source));
-}
-
-function copyFolderRecursiveSync(source, target) {
- var files = [];
-
- //check if folder needs to be created or integrated
- var targetFolder = path.join(target, path.basename(source));
- if (!fs.existsSync(targetFolder)) {
- fs.mkdirSync(targetFolder);
- }
-
- //copy
- if (fs.lstatSync(source).isDirectory()) {
- files = fs.readdirSync(source);
- files.forEach(function (file) {
- var curSource = path.join(source, file);
- if (fs.lstatSync(curSource).isDirectory()) {
- copyFolderRecursiveSync(curSource, targetFolder);
- } else {
- copyFileSync(curSource, targetFolder);
- }
- });
- }
-}
diff --git a/config/unicorn.rb.example.development b/config/unicorn.rb.example.development
index 3cd00d53a15..0df028648d1 100644
--- a/config/unicorn.rb.example.development
+++ b/config/unicorn.rb.example.development
@@ -1,2 +1,15 @@
worker_processes 2
timeout 60
+
+before_fork do |server, worker|
+ if /darwin/ =~ RUBY_PLATFORM
+ require 'fiddle'
+
+ # Dynamically load Foundation.framework, ~implicitly~ initialising
+ # the Objective-C runtime before any forking happens in Unicorn
+ #
+ # From https://bugs.ruby-lang.org/issues/14009
+ Fiddle.dlopen '/System/Library/Frameworks/Foundation.framework/Foundation'
+ end
+end
+
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 1218b0ef208..39e9fbbd530 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -1,93 +1,60 @@
-'use strict';
-
-var fs = require('fs');
-var path = require('path');
-var webpack = require('webpack');
-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.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"
- },
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const glob = require('glob');
+const webpack = require('webpack');
+const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin;
+const CopyWebpackPlugin = require('copy-webpack-plugin');
+const CompressionPlugin = require('compression-webpack-plugin');
+const NameAllModulesPlugin = require('name-all-modules-plugin');
+const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
+const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
+
+const ROOT_PATH = path.resolve(__dirname, '..');
+const IS_PRODUCTION = process.env.NODE_ENV === 'production';
+const IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1;
+const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost';
+const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808;
+const DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false';
+const WEBPACK_REPORT = process.env.WEBPACK_REPORT;
+const NO_COMPRESSION = process.env.NO_COMPRESSION;
+
+let autoEntriesCount = 0;
+let watchAutoEntries = [];
+
+function generateEntries() {
+ // generate automatic entry points
+ const autoEntries = {};
+ const pageEntries = glob.sync('pages/**/index.js', {
+ cwd: path.join(ROOT_PATH, 'app/assets/javascripts'),
+ });
+ watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')];
+
+ function generateAutoEntries(path, prefix = '.') {
+ const chunkPath = path.replace(/\/index\.js$/, '');
+ const chunkName = chunkPath.replace(/\//g, '.');
+ autoEntries[chunkName] = `${prefix}/${path}`;
+ }
+
+ pageEntries.forEach(path => generateAutoEntries(path));
+
+ autoEntriesCount = Object.keys(autoEntries).length;
+
+ const manualEntries = {
+ common: './commons/index.js',
+ main: './main.js',
+ raven: './raven/index.js',
+ webpack_runtime: './webpack.js',
+ ide: './ide/index.js',
+ };
+
+ return Object.assign(manualEntries, autoEntries);
+}
+
+const config = {
context: path.join(ROOT_PATH, 'app/assets/javascripts'),
- entry: {
- account: './profile/account/index.js',
- balsamiq_viewer: './blob/balsamiq_viewer.js',
- blob: './blob_edit/blob_bundle.js',
- boards: './boards/boards_bundle.js',
- common: './commons/index.js',
- common_vue: './vue_shared/vue_resource_interceptor.js',
- cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
- commit_pipelines: './commit/pipelines/pipelines_bundle.js',
- deploy_keys: './deploy_keys/index.js',
- docs: './docs/docs_bundle.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',
- help: './help/help.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',
- monitoring: './monitoring/monitoring_bundle.js',
- network: './network/network_bundle.js',
- notebook_viewer: './blob/notebook_viewer.js',
- notes: './notes/index.js',
- pdf_viewer: './blob/pdf_viewer.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',
- 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',
- registry_list: './registry/index.js',
- ide: './ide/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'],
- ui_development_kit: './ui_development_kit.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',
- users: './users/index.js',
- performance_bar: './performance_bar.js',
- webpack_runtime: './webpack.js',
- },
+
+ entry: generateEntries,
output: {
path: path.join(ROOT_PATH, 'public/assets/webpack'),
@@ -119,7 +86,12 @@ var config = {
{
test: /\_worker\.js$/,
use: [
- { loader: 'worker-loader' },
+ {
+ loader: 'worker-loader',
+ options: {
+ inline: true,
+ },
+ },
{ loader: 'babel-loader' },
],
},
@@ -129,7 +101,28 @@ var config = {
loader: 'file-loader',
options: {
name: '[name].[hash].[ext]',
- }
+ },
+ },
+ {
+ test: /katex.min.css$/,
+ include: /node_modules\/katex\/dist/,
+ use: [
+ { loader: 'style-loader' },
+ {
+ loader: 'css-loader',
+ options: {
+ name: '[name].[hash].[ext]',
+ },
+ },
+ ],
+ },
+ {
+ test: /\.(eot|ttf|woff|woff2)$/,
+ include: /node_modules\/katex\/dist\/fonts/,
+ loader: 'file-loader',
+ options: {
+ name: '[name].[hash].[ext]',
+ },
},
{
test: /monaco-editor\/\w+\/vs\/loader\.js$/,
@@ -137,10 +130,11 @@ var config = {
{ loader: 'exports-loader', options: 'l.global' },
{ loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' },
],
- }
+ },
],
noParse: [/monaco-editor\/\w+\/vs\//],
+ strictExportPresence: true,
},
plugins: [
@@ -149,15 +143,15 @@ var config = {
new StatsWriterPlugin({
filename: 'manifest.json',
transform: function(data, opts) {
- var stats = opts.compiler.getStats().toJson({
+ const stats = opts.compiler.getStats().toJson({
chunkModules: false,
source: false,
chunks: false,
modules: false,
- assets: true
+ assets: true,
});
return JSON.stringify(stats, null, 2);
- }
+ },
}),
// prevent pikaday from including moment.js
@@ -174,67 +168,42 @@ var config = {
new NameAllModulesPlugin(),
// assign deterministic chunk ids
- new webpack.NamedChunksPlugin((chunk) => {
+ new webpack.NamedChunksPlugin(chunk => {
if (chunk.name) {
return chunk.name;
}
- return chunk.mapModules((m) => {
+
+ const moduleNames = [];
+
+ function collectModuleNames(m) {
+ // handle ConcatenatedModule which does not have resource nor context set
+ if (m.modules) {
+ m.modules.forEach(collectModuleNames);
+ return;
+ }
+
const pagesBase = path.join(ROOT_PATH, 'app/assets/javascripts/pages');
+
if (m.resource.indexOf(pagesBase) === 0) {
- return path.relative(pagesBase, m.resource)
- .replace(/\/index\.[a-z]+$/, '')
- .replace(/\//g, '__');
+ moduleNames.push(
+ path
+ .relative(pagesBase, m.resource)
+ .replace(/\/index\.[a-z]+$/, '')
+ .replace(/\//g, '__')
+ );
+ } else {
+ moduleNames.push(path.relative(m.context, m.resource));
}
- return path.relative(m.context, m.resource);
- }).join('_');
- }),
+ }
- // create cacheable common library bundle for all vue chunks
- new webpack.optimize.CommonsChunkPlugin({
- name: 'common_vue',
- chunks: [
- 'boards',
- 'commit_pipelines',
- 'cycle_analytics',
- 'deploy_keys',
- 'diff_notes',
- 'environments',
- 'environments_folder',
- 'filtered_search',
- 'groups',
- 'issue_show',
- 'job_details',
- 'merge_conflicts',
- 'monitoring',
- 'notebook_viewer',
- 'notes',
- 'pdf_viewer',
- 'pipelines',
- 'pipelines_details',
- 'registry_list',
- 'ide',
- 'schedule_form',
- 'schedules_index',
- 'sidebar',
- 'vue_merge_request_widget',
- ],
- minChunks: function(module, count) {
- return module.resource && (/vue_shared/).test(module.resource);
- },
- }),
+ chunk.forEachModule(collectModuleNames);
- // create cacheable common library bundle for all d3 chunks
- new webpack.optimize.CommonsChunkPlugin({
- name: 'common_d3',
- chunks: [
- 'graphs',
- 'graphs_show',
- 'monitoring',
- 'users',
- ],
- minChunks: function (module, count) {
- return module.resource && /d3-/.test(module.resource);
- },
+ const hash = crypto
+ .createHash('sha256')
+ .update(moduleNames.join('_'))
+ .digest('hex');
+
+ return `${moduleNames[0]}-${hash.substr(0, 6)}`;
}),
// create cacheable common library bundles
@@ -242,13 +211,13 @@ var config = {
names: ['main', '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`),
+ 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) && !/typescript/i.test(path)) {
@@ -261,24 +230,30 @@ var config = {
);
}
return content;
- }
- }
+ },
+ },
]),
],
resolve: {
extensions: ['.js'],
alias: {
- '~': path.join(ROOT_PATH, 'app/assets/javascripts'),
- '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',
- }
- }
-}
+ '~': path.join(ROOT_PATH, 'app/assets/javascripts'),
+ 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',
+ spec: path.join(ROOT_PATH, 'spec/javascripts'),
+ },
+ },
+
+ // sqljs requires fs
+ node: {
+ fs: 'empty',
+ },
+};
if (IS_PRODUCTION) {
config.devtool = 'source-map';
@@ -286,13 +261,14 @@ if (IS_PRODUCTION) {
new webpack.NoEmitOnErrorsPlugin(),
new webpack.LoaderOptionsPlugin({
minimize: true,
- debug: false
+ debug: false,
}),
+ new webpack.optimize.ModuleConcatenationPlugin(),
new webpack.optimize.UglifyJsPlugin({
- sourceMap: true
+ sourceMap: true,
}),
new webpack.DefinePlugin({
- 'process.env': { NODE_ENV: JSON.stringify('production') }
+ 'process.env': { NODE_ENV: JSON.stringify('production') },
})
);
@@ -311,11 +287,30 @@ if (IS_DEV_SERVER) {
headers: { 'Access-Control-Allow-Origin': '*' },
stats: 'errors-only',
hot: DEV_SERVER_LIVERELOAD,
- inline: DEV_SERVER_LIVERELOAD
+ inline: DEV_SERVER_LIVERELOAD,
};
config.plugins.push(
// watch node_modules for changes if we encounter a missing module compile error
- new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules'))
+ new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules')),
+
+ // watch for changes to our automatic entry point modules
+ {
+ apply(compiler) {
+ compiler.plugin('emit', (compilation, callback) => {
+ compilation.contextDependencies = [
+ ...compilation.contextDependencies,
+ ...watchAutoEntries,
+ ];
+
+ // report our auto-generated bundle count
+ console.log(
+ `${autoEntriesCount} entries from '/pages' automatically added to webpack output.`
+ );
+
+ callback();
+ });
+ },
+ }
);
if (DEV_SERVER_LIVERELOAD) {
config.plugins.push(new webpack.HotModuleReplacementPlugin());
diff --git a/db/fixtures/development/01_admin.rb b/db/fixtures/development/01_admin.rb
index 6f241f6fa4a..dfb50c195c1 100644
--- a/db/fixtures/development/01_admin.rb
+++ b/db/fixtures/development/01_admin.rb
@@ -9,7 +9,6 @@ Gitlab::Seeder.quiet do
s.username = 'root'
s.password = '5iveL!fe'
s.admin = true
- s.projects_limit = 100
s.confirmed_at = DateTime.now
end
end
diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb
index 1f8f5cfc82b..213c8bca639 100644
--- a/db/fixtures/development/04_project.rb
+++ b/db/fixtures/development/04_project.rb
@@ -63,7 +63,8 @@ Sidekiq::Testing.inline! do
namespace_id: group.id,
name: project_path.titleize,
description: FFaker::Lorem.sentence,
- visibility_level: Gitlab::VisibilityLevel.values.sample
+ visibility_level: Gitlab::VisibilityLevel.values.sample,
+ skip_disk_validation: true
}
project = Projects::CreateService.new(User.first, params).execute
diff --git a/db/fixtures/development/06_teams.rb b/db/fixtures/development/06_teams.rb
index 86e0a38aae1..b218f4e71fd 100644
--- a/db/fixtures/development/06_teams.rb
+++ b/db/fixtures/development/06_teams.rb
@@ -14,7 +14,7 @@ Sidekiq::Testing.inline! do
Project.all.each do |project|
User.all.sample(4).each do |user|
- if project.team << [user, Gitlab::Access.values.sample]
+ if project.add_role(user, Gitlab::Access.sym_options.keys.sample)
print '.'
else
print 'F'
diff --git a/db/fixtures/development/22_labeled_issues_seed.rb b/db/fixtures/development/22_labeled_issues_seed.rb
new file mode 100644
index 00000000000..3730e9c7958
--- /dev/null
+++ b/db/fixtures/development/22_labeled_issues_seed.rb
@@ -0,0 +1,103 @@
+# Creates a project with labeled issues for an user.
+# Run this single seed file using: rake db:seed_fu FILTER=labeled USER_ID=74.
+# If an USER_ID is not provided it will use the last created user.
+require './spec/support/sidekiq'
+
+class Gitlab::Seeder::LabeledIssues
+ include ::Gitlab::Utils
+
+ def initialize(user)
+ @user = user
+ end
+
+ def seed!
+ Sidekiq::Testing.inline! do
+ group = create_group
+
+ create_projects(group)
+ create_labels(group)
+ create_issues(group)
+ end
+
+ print '.'
+ end
+
+ private
+
+ def create_group
+ group_name = "group_of_#{@user.username}_#{SecureRandom.hex(4)}"
+
+ group_params = {
+ name: group_name,
+ path: group_name,
+ description: FFaker::Lorem.sentence
+ }
+
+ Groups::CreateService.new(@user, group_params).execute
+ end
+
+ def create_projects(group)
+ 5.times do
+ project_name = "project_#{SecureRandom.hex(6)}"
+
+ params = {
+ namespace_id: group.id,
+ name: project_name,
+ description: FFaker::Lorem.sentence,
+ visibility_level: Gitlab::VisibilityLevel.values.sample
+ }
+
+ Projects::CreateService.new(@user, params).execute
+ end
+ end
+
+ def create_labels(group)
+ group.projects.each do |project|
+ 5.times do
+ label_title = FFaker::Vehicle.model
+ Labels::CreateService.new(title: label_title, color: "#69D100").execute(project: project)
+ end
+ end
+
+ 10.times do
+ label_title = FFaker::Product.brand
+ Labels::CreateService.new(title: label_title).execute(group: group)
+ end
+ end
+
+ def create_issues(group)
+ # Get only group labels
+ group_labels =
+ LabelsFinder.new(@user, group_id: group.id).execute.where.not(group_id: nil)
+
+ group.projects.each do |project|
+ label_ids = project.labels.pluck(:id).sample(5)
+ label_ids.push(*group.labels.sample(4))
+
+ 20.times do
+ issue_params = {
+ title: FFaker::Lorem.sentence(6),
+ description: FFaker::Lorem.sentence,
+ state: 'opened',
+ label_ids: label_ids
+
+ }
+
+ Issues::CreateService.new(project, @user, issue_params).execute if project.project_feature.present?
+ end
+ end
+ end
+end
+
+Gitlab::Seeder.quiet do
+ user_id = ENV['USER_ID']
+
+ user =
+ if user_id.present?
+ User.find(user_id)
+ else
+ User.last
+ end
+
+ Gitlab::Seeder::LabeledIssues.new(user).seed!
+end
diff --git a/db/migrate/20160301174731_add_fingerprint_index.rb b/db/migrate/20160301174731_add_fingerprint_index.rb
new file mode 100644
index 00000000000..f2c3d1ba1ea
--- /dev/null
+++ b/db/migrate/20160301174731_add_fingerprint_index.rb
@@ -0,0 +1,17 @@
+# rubocop:disable all
+class AddFingerprintIndex < ActiveRecord::Migration
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ # https://gitlab.com/gitlab-org/gitlab-ee/issues/764
+ def change
+ args = [:keys, :fingerprint]
+
+ if Gitlab::Database.postgresql?
+ args << { algorithm: :concurrently }
+ end
+
+ add_index(*args) unless index_exists?(:keys, :fingerprint)
+ end
+end
diff --git a/db/migrate/20160621123729_add_rebase_commit_sha_to_merge_requests.rb b/db/migrate/20160621123729_add_rebase_commit_sha_to_merge_requests.rb
new file mode 100644
index 00000000000..1222dc640a8
--- /dev/null
+++ b/db/migrate/20160621123729_add_rebase_commit_sha_to_merge_requests.rb
@@ -0,0 +1,22 @@
+# This migration is a duplicate of 20171230123729_add_rebase_commit_sha_to_merge_requests_ce.rb
+#
+# We backported this feature from EE using the same migration, but with a new
+# timestamp, which caused an error when the backport was then to be merged back
+# into EE.
+#
+# See discussion at https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/3932
+class AddRebaseCommitShaToMergeRequests < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ unless column_exists?(:merge_requests, :rebase_commit_sha)
+ add_column :merge_requests, :rebase_commit_sha, :string
+ end
+ end
+
+ def down
+ if column_exists?(:merge_requests, :rebase_commit_sha)
+ remove_column :merge_requests, :rebase_commit_sha
+ end
+ end
+end
diff --git a/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb b/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb
index bcdae272209..a96ea7d9db4 100644
--- a/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb
+++ b/db/migrate/20161124141322_migrate_process_commit_worker_jobs.rb
@@ -12,7 +12,7 @@ class MigrateProcessCommitWorkerJobs < ActiveRecord::Migration
end
def repository_storage_path
- Gitlab.config.repositories.storages[repository_storage]['path']
+ Gitlab.config.repositories.storages[repository_storage].legacy_disk_path
end
def repository_path
diff --git a/db/migrate/20161220141214_remove_dot_git_from_group_names.rb b/db/migrate/20161220141214_remove_dot_git_from_group_names.rb
index 8fb1f9d5e73..bddc234db25 100644
--- a/db/migrate/20161220141214_remove_dot_git_from_group_names.rb
+++ b/db/migrate/20161220141214_remove_dot_git_from_group_names.rb
@@ -60,7 +60,7 @@ class RemoveDotGitFromGroupNames < ActiveRecord::Migration
def move_namespace(group_id, path_was, path)
repository_storage_paths = select_all("SELECT distinct(repository_storage) FROM projects WHERE namespace_id = #{group_id}").map do |row|
- Gitlab.config.repositories.storages[row['repository_storage']]['path']
+ Gitlab.config.repositories.storages[row['repository_storage']].legacy_disk_path
end.compact
# Move the namespace directory in all storages paths used by member projects
diff --git a/db/migrate/20161226122833_remove_dot_git_from_usernames.rb b/db/migrate/20161226122833_remove_dot_git_from_usernames.rb
index 61dcc8c54f5..7c28d934c29 100644
--- a/db/migrate/20161226122833_remove_dot_git_from_usernames.rb
+++ b/db/migrate/20161226122833_remove_dot_git_from_usernames.rb
@@ -71,7 +71,7 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration
route_exists = route_exists?(path)
Gitlab.config.repositories.storages.each_value do |storage|
- if route_exists || path_exists?(path, storage['path'])
+ if route_exists || path_exists?(path, storage.legacy_disk_path)
counter += 1
path = "#{base}#{counter}"
@@ -84,7 +84,7 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration
def move_namespace(namespace_id, path_was, path)
repository_storage_paths = select_all("SELECT distinct(repository_storage) FROM projects WHERE namespace_id = #{namespace_id}").map do |row|
- Gitlab.config.repositories.storages[row['repository_storage']]['path']
+ Gitlab.config.repositories.storages[row['repository_storage']].legacy_disk_path
end.compact
# Move the namespace directory in all storages paths used by member projects
diff --git a/db/migrate/20170425112128_create_pipeline_schedules_table.rb b/db/migrate/20170425112128_create_pipeline_schedules_table.rb
index 57df47f5f42..4f9c56a1ad8 100644
--- a/db/migrate/20170425112128_create_pipeline_schedules_table.rb
+++ b/db/migrate/20170425112128_create_pipeline_schedules_table.rb
@@ -17,7 +17,7 @@ class CreatePipelineSchedulesTable < ActiveRecord::Migration
t.boolean :active, default: true
t.datetime :deleted_at
- t.timestamps
+ t.timestamps null: true
end
add_index(:ci_pipeline_schedules, :project_id)
diff --git a/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb b/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb
index af6d10b5158..1199073ed3a 100644
--- a/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb
+++ b/db/migrate/20170530130129_project_foreign_keys_with_cascading_deletes.rb
@@ -154,7 +154,7 @@ class ProjectForeignKeysWithCascadingDeletes < ActiveRecord::Migration
end
def add_foreign_key_if_not_exists(source, target, column:)
- return if foreign_key_exists?(source, column)
+ return if foreign_key_exists?(source, target, column: column)
add_concurrent_foreign_key(source, target, column: column)
end
@@ -175,12 +175,6 @@ class ProjectForeignKeysWithCascadingDeletes < ActiveRecord::Migration
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.
diff --git a/db/migrate/20170531180233_add_authorized_keys_enabled_to_application_settings.rb b/db/migrate/20170531180233_add_authorized_keys_enabled_to_application_settings.rb
new file mode 100644
index 00000000000..1d86a531eb3
--- /dev/null
+++ b/db/migrate/20170531180233_add_authorized_keys_enabled_to_application_settings.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 AddAuthorizedKeysEnabledToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default :application_settings, :authorized_keys_enabled, :boolean, default: true, allow_null: false
+ end
+
+ def down
+ remove_column :application_settings, :authorized_keys_enabled
+ end
+end
diff --git a/db/migrate/20170601163708_add_artifacts_store_to_ci_build.rb b/db/migrate/20170601163708_add_artifacts_store_to_ci_build.rb
new file mode 100644
index 00000000000..e82109190a7
--- /dev/null
+++ b/db/migrate/20170601163708_add_artifacts_store_to_ci_build.rb
@@ -0,0 +1,10 @@
+class AddArtifactsStoreToCiBuild < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column(:ci_builds, :artifacts_file_store, :integer)
+ add_column(:ci_builds, :artifacts_metadata_store, :integer)
+ 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
index 68b947583d3..a89d348b127 100644
--- a/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb
+++ b/db/migrate/20170703102400_add_stage_id_foreign_key_to_builds.rb
@@ -10,13 +10,13 @@ class AddStageIdForeignKeyToBuilds < ActiveRecord::Migration
add_concurrent_index(:ci_builds, :stage_id)
end
- unless foreign_key_exists?(:ci_builds, :stage_id)
+ unless foreign_key_exists?(:ci_builds, :ci_stages, column: :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)
+ if foreign_key_exists?(:ci_builds, column: :stage_id)
remove_foreign_key(:ci_builds, column: :stage_id)
end
@@ -24,12 +24,4 @@ class AddStageIdForeignKeyToBuilds < ActiveRecord::Migration
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/20170713104829_add_foreign_key_to_merge_requests.rb b/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb
index c25d4fd5986..c409915ceed 100644
--- a/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb
+++ b/db/migrate/20170713104829_add_foreign_key_to_merge_requests.rb
@@ -23,23 +23,15 @@ class AddForeignKeyToMergeRequests < ActiveRecord::Migration
merge_requests.update_all(head_pipeline_id: nil)
end
- unless foreign_key_exists?(:merge_requests, :head_pipeline_id)
+ unless foreign_key_exists?(:merge_requests, column: :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)
+ if foreign_key_exists?(:merge_requests, column: :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/20170825015534_add_file_store_to_lfs_objects.rb b/db/migrate/20170825015534_add_file_store_to_lfs_objects.rb
new file mode 100644
index 00000000000..41bb031014f
--- /dev/null
+++ b/db/migrate/20170825015534_add_file_store_to_lfs_objects.rb
@@ -0,0 +1,31 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddFileStoreToLfsObjects < 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 change
+ add_column(:lfs_objects, :file_store, :integer)
+ end
+end
diff --git a/db/migrate/20170827123848_add_index_on_merge_request_diff_commit_sha.rb b/db/migrate/20170827123848_add_index_on_merge_request_diff_commit_sha.rb
new file mode 100644
index 00000000000..1b360b231a8
--- /dev/null
+++ b/db/migrate/20170827123848_add_index_on_merge_request_diff_commit_sha.rb
@@ -0,0 +1,17 @@
+# rubocop:disable RemoveIndex
+
+class AddIndexOnMergeRequestDiffCommitSha < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :merge_request_diff_commits, :sha, length: Gitlab::Database.mysql? ? 20 : nil
+ end
+
+ def down
+ remove_index :merge_request_diff_commits, :sha if index_exists? :merge_request_diff_commits, :sha
+ end
+end
diff --git a/db/migrate/20170918072949_add_file_store_job_artifacts.rb b/db/migrate/20170918072949_add_file_store_job_artifacts.rb
new file mode 100644
index 00000000000..b1f1bea6deb
--- /dev/null
+++ b/db/migrate/20170918072949_add_file_store_job_artifacts.rb
@@ -0,0 +1,10 @@
+class AddFileStoreJobArtifacts < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+ DOWNTIME = false
+
+ def change
+ add_column(:ci_job_artifacts, :file_store, :integer)
+ end
+end
diff --git a/db/migrate/20170928124105_create_fork_networks.rb b/db/migrate/20170928124105_create_fork_networks.rb
index ca906b953a3..89e5b871967 100644
--- a/db/migrate/20170928124105_create_fork_networks.rb
+++ b/db/migrate/20170928124105_create_fork_networks.rb
@@ -23,6 +23,7 @@ class CreateForkNetworks < ActiveRecord::Migration
if foreign_keys_for(:fork_networks, :root_project_id).any?
remove_foreign_key :fork_networks, column: :root_project_id
end
+
drop_table :fork_networks
end
end
diff --git a/db/migrate/20170928133643_create_fork_network_members.rb b/db/migrate/20170928133643_create_fork_network_members.rb
index 836f023efdc..8c7d9ba859a 100644
--- a/db/migrate/20170928133643_create_fork_network_members.rb
+++ b/db/migrate/20170928133643_create_fork_network_members.rb
@@ -21,6 +21,7 @@ class CreateForkNetworkMembers < ActiveRecord::Migration
if foreign_keys_for(:fork_network_members, :forked_from_project_id).any?
remove_foreign_key :fork_network_members, column: :forked_from_project_id
end
+
drop_table :fork_network_members
end
end
diff --git a/db/migrate/20170929131201_populate_fork_networks.rb b/db/migrate/20170929131201_populate_fork_networks.rb
index 1214962770f..ddbf27e1852 100644
--- a/db/migrate/20170929131201_populate_fork_networks.rb
+++ b/db/migrate/20170929131201_populate_fork_networks.rb
@@ -6,22 +6,8 @@ class PopulateForkNetworks < ActiveRecord::Migration
DOWNTIME = false
- MIGRATION = 'PopulateForkNetworksRange'.freeze
- BATCH_SIZE = 100
- DELAY_INTERVAL = 15.seconds
-
- disable_ddl_transaction!
-
- class ForkedProjectLink < ActiveRecord::Base
- include EachBatch
-
- self.table_name = 'forked_project_links'
- end
-
def up
- say 'Populating the `fork_networks` based on existing `forked_project_links`'
-
- queue_background_migration_jobs_by_range_at_intervals(ForkedProjectLink, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE)
+ say 'Fork networks will be populated in 20171205190711 - RescheduleForkNetworkCreationCaller'
end
def down
diff --git a/db/migrate/20171019141859_fix_dev_timezone_schema.rb b/db/migrate/20171019141859_fix_dev_timezone_schema.rb
new file mode 100644
index 00000000000..fb7c17dd747
--- /dev/null
+++ b/db/migrate/20171019141859_fix_dev_timezone_schema.rb
@@ -0,0 +1,25 @@
+class FixDevTimezoneSchema < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # The this migrations tries to help solve unwanted changes to `schema.rb`
+ # while developing GitLab. Installations created before we started using
+ # `datetime_with_timezone` are likely to face this problem. Updating those
+ # columns to the new type should help fix this.
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ TIMEZONE_TABLES = %i(appearances ci_group_variables ci_pipeline_schedule_variables events gpg_keys gpg_signatures project_auto_devops)
+
+ def up
+ return unless Rails.env.development? || Rails.env.test?
+
+ TIMEZONE_TABLES.each do |table|
+ change_column table, :created_at, :datetime_with_timezone
+ change_column table, :updated_at, :datetime_with_timezone
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/migrate/20171106151218_issues_moved_to_id_foreign_key.rb b/db/migrate/20171106151218_issues_moved_to_id_foreign_key.rb
index 8d2ceb8cc18..6395462384b 100644
--- a/db/migrate/20171106151218_issues_moved_to_id_foreign_key.rb
+++ b/db/migrate/20171106151218_issues_moved_to_id_foreign_key.rb
@@ -15,8 +15,20 @@ class IssuesMovedToIdForeignKey < ActiveRecord::Migration
self.table_name = 'issues'
def self.with_orphaned_moved_to_issues
- where('NOT EXISTS (SELECT true FROM issues WHERE issues.id = issues.moved_to_id)')
- .where('moved_to_id IS NOT NULL')
+ if Gitlab::Database.postgresql?
+ # Be careful to use a second table here for comparison otherwise we'll null
+ # out all rows that don't have id == moved.to_id!
+ where('NOT EXISTS (SELECT true FROM issues B WHERE issues.moved_to_id = B.id)')
+ .where('moved_to_id IS NOT NULL')
+ else
+ # MySQL doesn't allow modification of the same table in a subquery,
+ # and using a temporary table isn't automatically guaranteed to work
+ # due to the MySQL query optimizer. See
+ # https://dev.mysql.com/doc/refman/5.7/en/update.html for more
+ # details.
+ joins('LEFT JOIN issues AS b ON issues.moved_to_id = b.id')
+ .where('issues.moved_to_id IS NOT NULL AND b.id IS NULL')
+ end
end
end
diff --git a/db/migrate/20171127151038_add_events_related_columns_to_merge_request_metrics.rb b/db/migrate/20171127151038_add_events_related_columns_to_merge_request_metrics.rb
new file mode 100644
index 00000000000..18af697cf88
--- /dev/null
+++ b/db/migrate/20171127151038_add_events_related_columns_to_merge_request_metrics.rb
@@ -0,0 +1,37 @@
+class AddEventsRelatedColumnsToMergeRequestMetrics < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ change_table :merge_request_metrics do |t|
+ t.references :merged_by, references: :users
+ t.references :latest_closed_by, references: :users
+ end
+
+ add_column :merge_request_metrics, :latest_closed_at, :datetime_with_timezone
+
+ add_concurrent_foreign_key :merge_request_metrics, :users,
+ column: :merged_by_id,
+ on_delete: :nullify
+
+ add_concurrent_foreign_key :merge_request_metrics, :users,
+ column: :latest_closed_by_id,
+ on_delete: :nullify
+ end
+
+ def down
+ if foreign_keys_for(:merge_request_metrics, :merged_by_id).any?
+ remove_foreign_key :merge_request_metrics, column: :merged_by_id
+ end
+
+ if foreign_keys_for(:merge_request_metrics, :latest_closed_by_id).any?
+ remove_foreign_key :merge_request_metrics, column: :latest_closed_by_id
+ end
+
+ remove_columns :merge_request_metrics,
+ :merged_by_id, :latest_closed_by_id, :latest_closed_at
+ end
+end
diff --git a/db/migrate/20171207185153_add_merge_request_state_index.rb b/db/migrate/20171207185153_add_merge_request_state_index.rb
new file mode 100644
index 00000000000..72f846c5c38
--- /dev/null
+++ b/db/migrate/20171207185153_add_merge_request_state_index.rb
@@ -0,0 +1,18 @@
+class AddMergeRequestStateIndex < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :merge_requests, [:source_project_id, :source_branch],
+ where: "state = 'opened'",
+ name: 'index_merge_requests_on_source_project_and_branch_state_opened'
+ end
+
+ def down
+ remove_concurrent_index_by_name :merge_requests,
+ 'index_merge_requests_on_source_project_and_branch_state_opened'
+ end
+end
diff --git a/db/migrate/20171211145425_add_can_push_to_deploy_keys_projects.rb b/db/migrate/20171211145425_add_can_push_to_deploy_keys_projects.rb
new file mode 100644
index 00000000000..5dc723db9f9
--- /dev/null
+++ b/db/migrate/20171211145425_add_can_push_to_deploy_keys_projects.rb
@@ -0,0 +1,15 @@
+class AddCanPushToDeployKeysProjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default :deploy_keys_projects, :can_push, :boolean, default: false, allow_null: false
+ end
+
+ def down
+ remove_column :deploy_keys_projects, :can_push
+ end
+end
diff --git a/db/migrate/20171212203433_create_clusters_applications_prometheus.rb b/db/migrate/20171212203433_create_clusters_applications_prometheus.rb
new file mode 100644
index 00000000000..dc2531d2691
--- /dev/null
+++ b/db/migrate/20171212203433_create_clusters_applications_prometheus.rb
@@ -0,0 +1,18 @@
+class CreateClustersApplicationsPrometheus < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :clusters_applications_prometheus do |t|
+ t.references :cluster, null: false, unique: true, foreign_key: { on_delete: :cascade }
+
+ t.integer :status, null: false
+ t.string :version, null: false
+
+ t.text :status_reason
+
+ t.timestamps_with_timezone null: false
+ end
+ end
+end
diff --git a/db/migrate/20171214144320_add_store_column_to_uploads.rb b/db/migrate/20171214144320_add_store_column_to_uploads.rb
new file mode 100644
index 00000000000..e35798e2c41
--- /dev/null
+++ b/db/migrate/20171214144320_add_store_column_to_uploads.rb
@@ -0,0 +1,12 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddStoreColumnToUploads < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column(:uploads, :store, :integer)
+ end
+end
diff --git a/db/migrate/20171215113714_populate_can_push_from_deploy_keys_projects.rb b/db/migrate/20171215113714_populate_can_push_from_deploy_keys_projects.rb
new file mode 100644
index 00000000000..680855af945
--- /dev/null
+++ b/db/migrate/20171215113714_populate_can_push_from_deploy_keys_projects.rb
@@ -0,0 +1,64 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class PopulateCanPushFromDeployKeysProjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+ DATABASE_NAME = Gitlab::Database.database_name
+
+ disable_ddl_transaction!
+
+ class DeploysKeyProject < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'deploy_keys_projects'
+ end
+
+ def up
+ DeploysKeyProject.each_batch(of: 10_000) do |batch|
+ start_id, end_id = batch.pluck('MIN(id), MAX(id)').first
+
+ if Gitlab::Database.mysql?
+ execute <<-EOF.strip_heredoc
+ UPDATE deploy_keys_projects, #{DATABASE_NAME}.keys
+ SET deploy_keys_projects.can_push = #{DATABASE_NAME}.keys.can_push
+ WHERE deploy_keys_projects.deploy_key_id = #{DATABASE_NAME}.keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ else
+ execute <<-EOF.strip_heredoc
+ UPDATE deploy_keys_projects
+ SET can_push = keys.can_push
+ FROM keys
+ WHERE deploy_key_id = keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ end
+ end
+ end
+
+ def down
+ DeploysKeyProject.each_batch(of: 10_000) do |batch|
+ start_id, end_id = batch.pluck('MIN(id), MAX(id)').first
+
+ if Gitlab::Database.mysql?
+ execute <<-EOF.strip_heredoc
+ UPDATE deploy_keys_projects, #{DATABASE_NAME}.keys
+ SET #{DATABASE_NAME}.keys.can_push = deploy_keys_projects.can_push
+ WHERE deploy_keys_projects.deploy_key_id = #{DATABASE_NAME}.keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ else
+ execute <<-EOF.strip_heredoc
+ UPDATE keys
+ SET can_push = deploy_keys_projects.can_push
+ FROM deploy_keys_projects
+ WHERE deploy_keys_projects.deploy_key_id = keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ end
+ end
+ end
+end
diff --git a/db/migrate/20171216111734_clean_up_for_members.rb b/db/migrate/20171216111734_clean_up_for_members.rb
new file mode 100644
index 00000000000..22e0997dce6
--- /dev/null
+++ b/db/migrate/20171216111734_clean_up_for_members.rb
@@ -0,0 +1,31 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanUpForMembers < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Member < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'members'
+ end
+
+ def up
+ condition = <<~EOF.squish
+ invite_token IS NULL AND
+ NOT EXISTS (SELECT 1 FROM users WHERE users.id = members.user_id)
+ EOF
+
+ Member.each_batch(of: 10_000) do |batch|
+ batch.where(condition).delete_all
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/migrate/20171216112339_add_foreign_key_for_members.rb b/db/migrate/20171216112339_add_foreign_key_for_members.rb
new file mode 100644
index 00000000000..be17769be6a
--- /dev/null
+++ b/db/migrate/20171216112339_add_foreign_key_for_members.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 AddForeignKeyForMembers < 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_foreign_key(:members,
+ :users,
+ column: :user_id)
+ end
+
+ def down
+ remove_foreign_key(:members, column: :user_id)
+ end
+end
diff --git a/db/migrate/20171220191323_add_index_on_namespaces_lower_name.rb b/db/migrate/20171220191323_add_index_on_namespaces_lower_name.rb
new file mode 100644
index 00000000000..d1a039ed551
--- /dev/null
+++ b/db/migrate/20171220191323_add_index_on_namespaces_lower_name.rb
@@ -0,0 +1,32 @@
+class AddIndexOnNamespacesLowerName < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+ DOWNTIME = false
+ INDEX_NAME = 'index_on_namespaces_lower_name'
+
+ disable_ddl_transaction!
+
+ def up
+ return unless Gitlab::Database.postgresql?
+
+ disable_statement_timeout
+
+ if Gitlab::Database.version.to_f >= 9.5
+ # Allow us to hot-patch the index manually ahead of the migration
+ execute "CREATE INDEX CONCURRENTLY IF NOT EXISTS #{INDEX_NAME} ON namespaces (lower(name));"
+ else
+ execute "CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON namespaces (lower(name));"
+ end
+ end
+
+ def down
+ return unless Gitlab::Database.postgresql?
+
+ disable_statement_timeout
+
+ if Gitlab::Database.version.to_f >= 9.2
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME};"
+ else
+ execute "DROP INDEX IF EXISTS #{INDEX_NAME};"
+ end
+ end
+end
diff --git a/db/migrate/20171222115326_add_confidential_note_events_to_web_hooks.rb b/db/migrate/20171222115326_add_confidential_note_events_to_web_hooks.rb
new file mode 100644
index 00000000000..900a6386922
--- /dev/null
+++ b/db/migrate/20171222115326_add_confidential_note_events_to_web_hooks.rb
@@ -0,0 +1,15 @@
+class AddConfidentialNoteEventsToWebHooks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column :web_hooks, :confidential_note_events, :boolean
+ end
+
+ def down
+ remove_column :web_hooks, :confidential_note_events
+ end
+end
diff --git a/db/migrate/20171222183504_add_jobs_cache_index_to_project.rb b/db/migrate/20171222183504_add_jobs_cache_index_to_project.rb
new file mode 100644
index 00000000000..607e9d027d7
--- /dev/null
+++ b/db/migrate/20171222183504_add_jobs_cache_index_to_project.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 AddJobsCacheIndexToProject < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ add_column :projects, :jobs_cache_index, :integer
+ end
+end
diff --git a/db/migrate/20171229225929_change_user_project_limit_not_null_and_remove_default.rb b/db/migrate/20171229225929_change_user_project_limit_not_null_and_remove_default.rb
new file mode 100644
index 00000000000..54fbbcf1a0d
--- /dev/null
+++ b/db/migrate/20171229225929_change_user_project_limit_not_null_and_remove_default.rb
@@ -0,0 +1,38 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class ChangeUserProjectLimitNotNullAndRemoveDefault < 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
+ # Set Users#projects_limit to NOT NULL and remove the default value
+ change_column_null :users, :projects_limit, false
+ change_column_default :users, :projects_limit, nil
+ end
+
+ def down
+ change_column_null :users, :projects_limit, true
+ change_column_default :users, :projects_limit, 10
+ end
+end
diff --git a/db/migrate/20171230123729_add_rebase_commit_sha_to_merge_requests_ce.rb b/db/migrate/20171230123729_add_rebase_commit_sha_to_merge_requests_ce.rb
new file mode 100644
index 00000000000..94a7c1019d8
--- /dev/null
+++ b/db/migrate/20171230123729_add_rebase_commit_sha_to_merge_requests_ce.rb
@@ -0,0 +1,15 @@
+class AddRebaseCommitShaToMergeRequestsCe < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ unless column_exists?(:merge_requests, :rebase_commit_sha)
+ add_column :merge_requests, :rebase_commit_sha, :string
+ end
+ end
+
+ def down
+ if column_exists?(:merge_requests, :rebase_commit_sha)
+ remove_column :merge_requests, :rebase_commit_sha
+ end
+ end
+end
diff --git a/db/migrate/20180102220145_add_pages_https_only_to_projects.rb b/db/migrate/20180102220145_add_pages_https_only_to_projects.rb
new file mode 100644
index 00000000000..ef6bc6896c0
--- /dev/null
+++ b/db/migrate/20180102220145_add_pages_https_only_to_projects.rb
@@ -0,0 +1,9 @@
+class AddPagesHttpsOnlyToProjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :projects, :pages_https_only, :boolean
+ end
+end
diff --git a/db/migrate/20180103123548_add_confidential_note_events_to_services.rb b/db/migrate/20180103123548_add_confidential_note_events_to_services.rb
new file mode 100644
index 00000000000..b54ad88df43
--- /dev/null
+++ b/db/migrate/20180103123548_add_confidential_note_events_to_services.rb
@@ -0,0 +1,16 @@
+class AddConfidentialNoteEventsToServices < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column :services, :confidential_note_events, :boolean
+ change_column_default :services, :confidential_note_events, true
+ end
+
+ def down
+ remove_column :services, :confidential_note_events
+ end
+end
diff --git a/db/migrate/20180105212544_add_commits_count_to_merge_request_diff.rb b/db/migrate/20180105212544_add_commits_count_to_merge_request_diff.rb
new file mode 100644
index 00000000000..f942b4c062e
--- /dev/null
+++ b/db/migrate/20180105212544_add_commits_count_to_merge_request_diff.rb
@@ -0,0 +1,29 @@
+class AddCommitsCountToMergeRequestDiff < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ MIGRATION = 'AddMergeRequestDiffCommitsCount'.freeze
+ BATCH_SIZE = 5000
+ DELAY_INTERVAL = 5.minutes.to_i
+
+ class MergeRequestDiff < ActiveRecord::Base
+ self.table_name = 'merge_request_diffs'
+
+ include ::EachBatch
+ end
+
+ disable_ddl_transaction!
+
+ def up
+ add_column :merge_request_diffs, :commits_count, :integer
+
+ say 'Populating the MergeRequestDiff `commits_count`'
+
+ queue_background_migration_jobs_by_range_at_intervals(MergeRequestDiff, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE)
+ end
+
+ def down
+ remove_column :merge_request_diffs, :commits_count
+ end
+end
diff --git a/db/migrate/20180109183319_change_default_value_for_pages_https_only.rb b/db/migrate/20180109183319_change_default_value_for_pages_https_only.rb
new file mode 100644
index 00000000000..c242e1b0d24
--- /dev/null
+++ b/db/migrate/20180109183319_change_default_value_for_pages_https_only.rb
@@ -0,0 +1,13 @@
+class ChangeDefaultValueForPagesHttpsOnly < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ change_column_default :projects, :pages_https_only, true
+ end
+
+ def down
+ change_column_default :projects, :pages_https_only, nil
+ end
+end
diff --git a/db/migrate/20180113220114_rework_redirect_routes_indexes.rb b/db/migrate/20180113220114_rework_redirect_routes_indexes.rb
new file mode 100644
index 00000000000..ab9971be074
--- /dev/null
+++ b/db/migrate/20180113220114_rework_redirect_routes_indexes.rb
@@ -0,0 +1,68 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class ReworkRedirectRoutesIndexes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ INDEX_NAME_UNIQUE = "index_redirect_routes_on_path_unique_text_pattern_ops"
+
+ INDEX_NAME_PERM = "index_redirect_routes_on_path_text_pattern_ops_where_permanent"
+ INDEX_NAME_TEMP = "index_redirect_routes_on_path_text_pattern_ops_where_temporary"
+
+ OLD_INDEX_NAME_PATH_TPOPS = "index_redirect_routes_on_path_text_pattern_ops"
+ OLD_INDEX_NAME_PATH_LOWER = "index_on_redirect_routes_lower_path"
+
+ def up
+ disable_statement_timeout
+
+ # this is a plain btree on a single boolean column. It'll never be
+ # selective enough to be valuable. This class is called by
+ # setup_postgresql.rake so it needs to be able to handle this
+ # index not existing.
+ if index_exists?(:redirect_routes, :permanent)
+ remove_concurrent_index(:redirect_routes, :permanent)
+ end
+
+ # If we're on MySQL then the existing index on path is ok. But on
+ # Postgres we need to clean things up:
+ return unless Gitlab::Database.postgresql?
+
+ if_not_exists = Gitlab::Database.version.to_f >= 9.5 ? "IF NOT EXISTS" : ""
+
+ # Unique index on lower(path) across both types of redirect_routes:
+ execute("CREATE UNIQUE INDEX CONCURRENTLY #{if_not_exists} #{INDEX_NAME_UNIQUE} ON redirect_routes (lower(path) varchar_pattern_ops);")
+
+ # Make two indexes on path -- one for permanent and one for temporary routes:
+ execute("CREATE INDEX CONCURRENTLY #{if_not_exists} #{INDEX_NAME_PERM} ON redirect_routes (lower(path) varchar_pattern_ops) where (permanent);")
+ execute("CREATE INDEX CONCURRENTLY #{if_not_exists} #{INDEX_NAME_TEMP} ON redirect_routes (lower(path) varchar_pattern_ops) where (not permanent or permanent is null) ;")
+
+ # Remove the old indexes:
+
+ # This one needed to be on lower(path) but wasn't so it's replaced with the two above
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{OLD_INDEX_NAME_PATH_TPOPS};"
+
+ # This one isn't needed because we only ever do = and LIKE on this
+ # column so the varchar_pattern_ops index is sufficient
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{OLD_INDEX_NAME_PATH_LOWER};"
+ end
+
+ def down
+ disable_statement_timeout
+
+ add_concurrent_index(:redirect_routes, :permanent)
+
+ return unless Gitlab::Database.postgresql?
+
+ execute("CREATE INDEX CONCURRENTLY #{OLD_INDEX_NAME_PATH_TPOPS} ON redirect_routes (path varchar_pattern_ops);")
+ execute("CREATE INDEX CONCURRENTLY #{OLD_INDEX_NAME_PATH_LOWER} ON redirect_routes (LOWER(path));")
+
+ execute("DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME_UNIQUE};")
+ execute("DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME_PERM};")
+ execute("DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME_TEMP};")
+ end
+end
diff --git a/db/migrate/20180115201419_add_index_updated_at_to_issues.rb b/db/migrate/20180115201419_add_index_updated_at_to_issues.rb
new file mode 100644
index 00000000000..a5a48fc97be
--- /dev/null
+++ b/db/migrate/20180115201419_add_index_updated_at_to_issues.rb
@@ -0,0 +1,15 @@
+class AddIndexUpdatedAtToIssues < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :issues, :updated_at
+ end
+
+ def down
+ remove_concurrent_index :issues, :updated_at
+ end
+end
diff --git a/db/migrate/20180116193854_create_lfs_file_locks.rb b/db/migrate/20180116193854_create_lfs_file_locks.rb
new file mode 100644
index 00000000000..23b0c90484b
--- /dev/null
+++ b/db/migrate/20180116193854_create_lfs_file_locks.rb
@@ -0,0 +1,30 @@
+class CreateLfsFileLocks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :lfs_file_locks do |t|
+ t.references :project, null: false, foreign_key: { on_delete: :cascade }
+ t.references :user, null: false, index: true, foreign_key: { on_delete: :cascade }
+ t.datetime :created_at, null: false
+ t.string :path, limit: 511
+ end
+
+ add_index :lfs_file_locks, [:project_id, :path], unique: true
+ end
+
+ def down
+ if foreign_keys_for(:lfs_file_locks, :project_id).any?
+ remove_foreign_key :lfs_file_locks, column: :project_id
+ end
+
+ if index_exists?(:lfs_file_locks, [:project_id, :path])
+ remove_concurrent_index :lfs_file_locks, [:project_id, :path]
+ end
+
+ drop_table :lfs_file_locks
+ end
+end
diff --git a/db/migrate/20180119135717_add_uploader_index_to_uploads.rb b/db/migrate/20180119135717_add_uploader_index_to_uploads.rb
new file mode 100644
index 00000000000..a678c3d049f
--- /dev/null
+++ b/db/migrate/20180119135717_add_uploader_index_to_uploads.rb
@@ -0,0 +1,20 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddUploaderIndexToUploads < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index :uploads, :path
+ add_concurrent_index :uploads, [:uploader, :path], using: :btree
+ end
+
+ def down
+ remove_concurrent_index :uploads, [:uploader, :path]
+ add_concurrent_index :uploads, :path, using: :btree
+ end
+end
diff --git a/db/migrate/20180119160751_optimize_ci_job_artifacts.rb b/db/migrate/20180119160751_optimize_ci_job_artifacts.rb
new file mode 100644
index 00000000000..9b4340ed7b7
--- /dev/null
+++ b/db/migrate/20180119160751_optimize_ci_job_artifacts.rb
@@ -0,0 +1,23 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class OptimizeCiJobArtifacts < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # job_id is just here to be a covering index for index only scans
+ # since we'll almost always be joining against ci_builds on job_id
+ add_concurrent_index(:ci_job_artifacts, [:expire_at, :job_id])
+ add_concurrent_index(:ci_builds, [:artifacts_expire_at], where: "artifacts_file <> ''")
+ end
+
+ def down
+ remove_concurrent_index(:ci_job_artifacts, [:expire_at, :job_id])
+ remove_concurrent_index(:ci_builds, [:artifacts_expire_at], where: "artifacts_file <> ''")
+ end
+end
diff --git a/db/migrate/20180122162010_add_auto_devops_domain_to_application_settings.rb b/db/migrate/20180122162010_add_auto_devops_domain_to_application_settings.rb
new file mode 100644
index 00000000000..7e16cb83087
--- /dev/null
+++ b/db/migrate/20180122162010_add_auto_devops_domain_to_application_settings.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 AddAutoDevopsDomainToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :auto_devops_domain, :string
+ end
+end
diff --git a/db/migrate/20180125214301_create_user_callouts.rb b/db/migrate/20180125214301_create_user_callouts.rb
new file mode 100644
index 00000000000..856eff36ae0
--- /dev/null
+++ b/db/migrate/20180125214301_create_user_callouts.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 CreateUserCallouts < ActiveRecord::Migration
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ create_table :user_callouts do |t|
+ t.integer :feature_name, null: false
+ t.references :user, index: true, foreign_key: { on_delete: :cascade }, null: false
+ end
+
+ add_index :user_callouts, [:user_id, :feature_name], unique: true
+ end
+end
diff --git a/db/migrate/20180129193323_add_uploads_builder_context.rb b/db/migrate/20180129193323_add_uploads_builder_context.rb
new file mode 100644
index 00000000000..b3909a770ca
--- /dev/null
+++ b/db/migrate/20180129193323_add_uploads_builder_context.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 AddUploadsBuilderContext < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ add_column :uploads, :mount_point, :string
+ add_column :uploads, :secret, :string
+ end
+end
diff --git a/db/migrate/20180201102129_add_unique_constraint_to_trending_projects_project_id.rb b/db/migrate/20180201102129_add_unique_constraint_to_trending_projects_project_id.rb
new file mode 100644
index 00000000000..02e53b8fa8a
--- /dev/null
+++ b/db/migrate/20180201102129_add_unique_constraint_to_trending_projects_project_id.rb
@@ -0,0 +1,19 @@
+class AddUniqueConstraintToTrendingProjectsProjectId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :trending_projects, :project_id, unique: true, name: 'index_trending_projects_on_project_id_unique'
+ remove_concurrent_index_by_name :trending_projects, 'index_trending_projects_on_project_id'
+ rename_index :trending_projects, 'index_trending_projects_on_project_id_unique', 'index_trending_projects_on_project_id'
+ end
+
+ def down
+ rename_index :trending_projects, 'index_trending_projects_on_project_id', 'index_trending_projects_on_project_id_old'
+ add_concurrent_index :trending_projects, :project_id
+ remove_concurrent_index_by_name :trending_projects, 'index_trending_projects_on_project_id_old'
+ end
+end
diff --git a/db/migrate/20180201110056_add_foreign_keys_to_todos.rb b/db/migrate/20180201110056_add_foreign_keys_to_todos.rb
new file mode 100644
index 00000000000..b7c40f8c01a
--- /dev/null
+++ b/db/migrate/20180201110056_add_foreign_keys_to_todos.rb
@@ -0,0 +1,38 @@
+class AddForeignKeysToTodos < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ class Todo < ActiveRecord::Base
+ self.table_name = 'todos'
+ include EachBatch
+ end
+
+ BATCH_SIZE = 1000
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ Todo.where('NOT EXISTS ( SELECT true FROM users WHERE id=todos.user_id )').each_batch(of: BATCH_SIZE) do |batch|
+ batch.delete_all
+ end
+
+ Todo.where('NOT EXISTS ( SELECT true FROM users WHERE id=todos.author_id )').each_batch(of: BATCH_SIZE) do |batch|
+ batch.delete_all
+ end
+
+ Todo.where('note_id IS NOT NULL AND NOT EXISTS ( SELECT true FROM notes WHERE id=todos.note_id )').each_batch(of: BATCH_SIZE) do |batch|
+ batch.delete_all
+ end
+
+ add_concurrent_foreign_key :todos, :users, column: :user_id, on_delete: :cascade
+ add_concurrent_foreign_key :todos, :users, column: :author_id, on_delete: :cascade
+ add_concurrent_foreign_key :todos, :notes, column: :note_id, on_delete: :cascade
+ end
+
+ def down
+ remove_foreign_key :todos, :users
+ remove_foreign_key :todos, column: :author_id
+ remove_foreign_key :todos, :notes
+ end
+end
diff --git a/db/migrate/20180201145907_migrate_remaining_issues_closed_at.rb b/db/migrate/20180201145907_migrate_remaining_issues_closed_at.rb
new file mode 100644
index 00000000000..5a36dec6a9a
--- /dev/null
+++ b/db/migrate/20180201145907_migrate_remaining_issues_closed_at.rb
@@ -0,0 +1,55 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class MigrateRemainingIssuesClosedAt < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Issue < ActiveRecord::Base
+ self.table_name = 'issues'
+ include EachBatch
+ end
+
+ def up
+ Gitlab::BackgroundMigration.steal('CopyColumn')
+ Gitlab::BackgroundMigration.steal('CleanupConcurrentTypeChange')
+
+ if migrate_column_type?
+ if closed_at_for_type_change_exists?
+ migrate_remaining_rows
+ else
+ # Due to some EE merge problems some environments may not have the
+ # "closed_at_for_type_change" column. If this is the case we have no
+ # other option than to migrate the data _right now_.
+ change_column_type_concurrently(:issues, :closed_at, :datetime_with_timezone)
+ cleanup_concurrent_column_type_change(:issues, :closed_at)
+ end
+ end
+ end
+
+ def down
+ # Previous migrations already revert the changes made here.
+ end
+
+ def migrate_remaining_rows
+ Issue.where('closed_at_for_type_change IS NULL AND closed_at IS NOT NULL').each_batch do |batch|
+ batch.update_all('closed_at_for_type_change = closed_at')
+ end
+
+ cleanup_concurrent_column_type_change(:issues, :closed_at)
+ end
+
+ def migrate_column_type?
+ # Some environments may have already executed the previous version of this
+ # migration, thus we don't need to migrate those environments again.
+ column_for('issues', 'closed_at').type == :datetime # rubocop:disable Migration/Datetime
+ end
+
+ def closed_at_for_type_change_exists?
+ columns('issues').any? { |col| col.name == 'closed_at_for_type_change' }
+ end
+end
diff --git a/db/migrate/20180206200543_reset_events_primary_key_sequence.rb b/db/migrate/20180206200543_reset_events_primary_key_sequence.rb
new file mode 100644
index 00000000000..eb5c4a6a1e7
--- /dev/null
+++ b/db/migrate/20180206200543_reset_events_primary_key_sequence.rb
@@ -0,0 +1,35 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class ResetEventsPrimaryKeySequence < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ class Event < ActiveRecord::Base
+ self.table_name = 'events'
+ end
+
+ def up
+ if Gitlab::Database.postgresql?
+ reset_primary_key_for_postgresql
+ else
+ reset_primary_key_for_mysql
+ end
+ end
+
+ def down
+ # No-op
+ end
+
+ def reset_primary_key_for_postgresql
+ reset_pk_sequence!(Event.table_name)
+ end
+
+ def reset_primary_key_for_mysql
+ amount = Event.pluck('COALESCE(MAX(id), 1)').first
+
+ execute "ALTER TABLE #{Event.table_name} AUTO_INCREMENT = #{amount}"
+ end
+end
diff --git a/db/migrate/20180208183958_schedule_populate_untracked_uploads_if_needed.rb b/db/migrate/20180208183958_schedule_populate_untracked_uploads_if_needed.rb
new file mode 100644
index 00000000000..e46e793d9d2
--- /dev/null
+++ b/db/migrate/20180208183958_schedule_populate_untracked_uploads_if_needed.rb
@@ -0,0 +1,47 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class SchedulePopulateUntrackedUploadsIfNeeded < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ FOLLOW_UP_MIGRATION = 'PopulateUntrackedUploads'.freeze
+
+ class UntrackedFile < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'untracked_files_for_uploads'
+ end
+
+ def up
+ if table_exists?(:untracked_files_for_uploads)
+ process_or_remove_table
+ end
+ end
+
+ def down
+ # nothing
+ end
+
+ private
+
+ def process_or_remove_table
+ if UntrackedFile.all.empty?
+ drop_temp_table
+ else
+ schedule_populate_untracked_uploads_jobs
+ end
+ end
+
+ def drop_temp_table
+ drop_table(:untracked_files_for_uploads, if_exists: true)
+ end
+
+ def schedule_populate_untracked_uploads_jobs
+ say "Scheduling #{FOLLOW_UP_MIGRATION} background migration jobs since there are rows in untracked_files_for_uploads."
+
+ bulk_queue_background_migration_jobs_by_range(
+ UntrackedFile, FOLLOW_UP_MIGRATION)
+ end
+end
diff --git a/db/migrate/20180209165249_add_closed_by_to_issues.rb b/db/migrate/20180209165249_add_closed_by_to_issues.rb
new file mode 100644
index 00000000000..e251afd7b49
--- /dev/null
+++ b/db/migrate/20180209165249_add_closed_by_to_issues.rb
@@ -0,0 +1,20 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddClosedByToIssues < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def up
+ add_column :issues, :closed_by_id, :integer
+ add_concurrent_foreign_key :issues, :users, column: :closed_by_id, on_delete: :nullify
+ end
+
+ def down
+ remove_foreign_key :issues, column: :closed_by_id
+ remove_column :issues, :closed_by_id
+ end
+end
diff --git a/db/migrate/20180212030105_add_external_ip_to_clusters_applications_ingress.rb b/db/migrate/20180212030105_add_external_ip_to_clusters_applications_ingress.rb
new file mode 100644
index 00000000000..dbe09a43aa7
--- /dev/null
+++ b/db/migrate/20180212030105_add_external_ip_to_clusters_applications_ingress.rb
@@ -0,0 +1,9 @@
+class AddExternalIpToClustersApplicationsIngress < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :clusters_applications_ingress, :external_ip, :string
+ end
+end
diff --git a/db/migrate/20180213131630_add_partial_index_to_projects_for_index_only_scans.rb b/db/migrate/20180213131630_add_partial_index_to_projects_for_index_only_scans.rb
new file mode 100644
index 00000000000..cedf2510dda
--- /dev/null
+++ b/db/migrate/20180213131630_add_partial_index_to_projects_for_index_only_scans.rb
@@ -0,0 +1,21 @@
+class AddPartialIndexToProjectsForIndexOnlyScans < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_projects_on_id_partial_for_visibility'
+
+ disable_ddl_transaction!
+
+ # Adds a partial index to leverage index-only scans when looking up project ids
+ def up
+ unless index_exists?(:projects, :id, name: INDEX_NAME)
+ add_concurrent_index :projects, :id, name: INDEX_NAME, unique: true, where: 'visibility_level IN (10,20)'
+ end
+ end
+
+ def down
+ if index_exists?(:projects, :id, name: INDEX_NAME)
+ remove_concurrent_index_by_name :projects, INDEX_NAME
+ end
+ end
+end
diff --git a/db/migrate/20180214093516_create_badges.rb b/db/migrate/20180214093516_create_badges.rb
new file mode 100644
index 00000000000..6559f834484
--- /dev/null
+++ b/db/migrate/20180214093516_create_badges.rb
@@ -0,0 +1,17 @@
+class CreateBadges < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :badges do |t|
+ t.string :link_url, null: false
+ t.string :image_url, null: false
+ t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: true
+ t.integer :group_id, index: true, null: true
+ t.string :type, null: false
+
+ t.timestamps_with_timezone null: false
+ end
+
+ add_foreign_key :badges, :namespaces, column: :group_id, on_delete: :cascade
+ end
+end
diff --git a/db/migrate/20180214155405_create_clusters_applications_runners.rb b/db/migrate/20180214155405_create_clusters_applications_runners.rb
new file mode 100644
index 00000000000..fc4c0881338
--- /dev/null
+++ b/db/migrate/20180214155405_create_clusters_applications_runners.rb
@@ -0,0 +1,32 @@
+class CreateClustersApplicationsRunners < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :clusters_applications_runners do |t|
+ t.references :cluster, null: false, foreign_key: { on_delete: :cascade }
+ t.references :runner, references: :ci_runners
+ t.index :runner_id
+ t.index :cluster_id, unique: true
+ t.integer :status, null: false
+ t.timestamps_with_timezone null: false
+ t.string :version, null: false
+ t.text :status_reason
+ end
+
+ add_concurrent_foreign_key :clusters_applications_runners, :ci_runners,
+ column: :runner_id,
+ on_delete: :nullify
+ end
+
+ def down
+ if foreign_keys_for(:clusters_applications_runners, :runner_id).any?
+ remove_foreign_key :clusters_applications_runners, column: :runner_id
+ end
+
+ drop_table :clusters_applications_runners
+ end
+end
diff --git a/db/migrate/20180215181245_users_name_lower_index.rb b/db/migrate/20180215181245_users_name_lower_index.rb
new file mode 100644
index 00000000000..d3f68cb7d45
--- /dev/null
+++ b/db/migrate/20180215181245_users_name_lower_index.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 UsersNameLowerIndex < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+ INDEX_NAME = 'index_on_users_name_lower'
+
+ disable_ddl_transaction!
+
+ def up
+ return unless Gitlab::Database.postgresql?
+
+ # On GitLab.com this produces an index with a size of roughly 60 MB.
+ execute "CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON users (LOWER(name))"
+ end
+
+ def down
+ return unless Gitlab::Database.postgresql?
+
+ if supports_drop_index_concurrently?
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}"
+ else
+ execute "DROP INDEX IF EXISTS #{INDEX_NAME}"
+ end
+ end
+end
diff --git a/db/migrate/20180216120000_add_pages_domain_verification.rb b/db/migrate/20180216120000_add_pages_domain_verification.rb
new file mode 100644
index 00000000000..8b7cae92285
--- /dev/null
+++ b/db/migrate/20180216120000_add_pages_domain_verification.rb
@@ -0,0 +1,8 @@
+class AddPagesDomainVerification < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :pages_domains, :verified_at, :datetime_with_timezone
+ add_column :pages_domains, :verification_code, :string
+ end
+end
diff --git a/db/migrate/20180216120010_add_pages_domain_verified_at_index.rb b/db/migrate/20180216120010_add_pages_domain_verified_at_index.rb
new file mode 100644
index 00000000000..825dfb52dce
--- /dev/null
+++ b/db/migrate/20180216120010_add_pages_domain_verified_at_index.rb
@@ -0,0 +1,15 @@
+class AddPagesDomainVerifiedAtIndex < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :pages_domains, :verified_at
+ end
+
+ def down
+ remove_concurrent_index :pages_domains, :verified_at
+ end
+end
diff --git a/db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb b/db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb
new file mode 100644
index 00000000000..06d458028b3
--- /dev/null
+++ b/db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb
@@ -0,0 +1,7 @@
+class AllowDomainVerificationToBeDisabled < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :pages_domain_verification_enabled, :boolean, default: true, null: false
+ end
+end
diff --git a/db/migrate/20180216120030_add_pages_domain_enabled_until.rb b/db/migrate/20180216120030_add_pages_domain_enabled_until.rb
new file mode 100644
index 00000000000..b40653044dd
--- /dev/null
+++ b/db/migrate/20180216120030_add_pages_domain_enabled_until.rb
@@ -0,0 +1,7 @@
+class AddPagesDomainEnabledUntil < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :pages_domains, :enabled_until, :datetime_with_timezone
+ end
+end
diff --git a/db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb b/db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb
new file mode 100644
index 00000000000..00f6e4979da
--- /dev/null
+++ b/db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb
@@ -0,0 +1,17 @@
+class AddPagesDomainEnabledUntilIndex < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :pages_domains, [:project_id, :enabled_until]
+ add_concurrent_index :pages_domains, [:verified_at, :enabled_until]
+ end
+
+ def down
+ remove_concurrent_index :pages_domains, [:verified_at, :enabled_until]
+ remove_concurrent_index :pages_domains, [:project_id, :enabled_until]
+ end
+end
diff --git a/db/migrate/20180216120050_pages_domains_verification_grace_period.rb b/db/migrate/20180216120050_pages_domains_verification_grace_period.rb
new file mode 100644
index 00000000000..d7f8634b536
--- /dev/null
+++ b/db/migrate/20180216120050_pages_domains_verification_grace_period.rb
@@ -0,0 +1,26 @@
+class PagesDomainsVerificationGracePeriod < ActiveRecord::Migration
+ DOWNTIME = false
+
+ class PagesDomain < ActiveRecord::Base
+ include EachBatch
+ end
+
+ # Allow this migration to resume if it fails partway through
+ disable_ddl_transaction!
+
+ def up
+ now = Time.now
+ grace = now + 30.days
+
+ PagesDomain.each_batch do |relation|
+ relation.update_all(verified_at: now, enabled_until: grace)
+
+ # Sleep 2 minutes between batches to not overload the DB with dead tuples
+ sleep(2.minutes) unless relation.reorder(:id).last == PagesDomain.reorder(:id).last
+ end
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/migrate/20180219153455_add_maximum_timeout_to_ci_runners.rb b/db/migrate/20180219153455_add_maximum_timeout_to_ci_runners.rb
new file mode 100644
index 00000000000..072e696a43e
--- /dev/null
+++ b/db/migrate/20180219153455_add_maximum_timeout_to_ci_runners.rb
@@ -0,0 +1,9 @@
+class AddMaximumTimeoutToCiRunners < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_runners, :maximum_timeout, :integer
+ end
+end
diff --git a/db/migrate/20180221151752_add_allow_maintainer_to_push_to_merge_requests.rb b/db/migrate/20180221151752_add_allow_maintainer_to_push_to_merge_requests.rb
new file mode 100644
index 00000000000..81acfbc3655
--- /dev/null
+++ b/db/migrate/20180221151752_add_allow_maintainer_to_push_to_merge_requests.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 AddAllowMaintainerToPushToMergeRequests < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column :merge_requests, :allow_maintainer_to_push, :boolean
+ end
+
+ def down
+ remove_column :merge_requests, :allow_maintainer_to_push
+ end
+end
diff --git a/db/migrate/20180222043024_add_ip_address_to_runner.rb b/db/migrate/20180222043024_add_ip_address_to_runner.rb
new file mode 100644
index 00000000000..bf00560b5a8
--- /dev/null
+++ b/db/migrate/20180222043024_add_ip_address_to_runner.rb
@@ -0,0 +1,9 @@
+class AddIpAddressToRunner < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :ci_runners, :ip_address, :string
+ end
+end
diff --git a/db/migrate/20180223120443_create_user_interacted_projects_table.rb b/db/migrate/20180223120443_create_user_interacted_projects_table.rb
new file mode 100644
index 00000000000..8da8cf68088
--- /dev/null
+++ b/db/migrate/20180223120443_create_user_interacted_projects_table.rb
@@ -0,0 +1,20 @@
+class CreateUserInteractedProjectsTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ INDEX_NAME = 'user_interacted_projects_non_unique_index'
+
+ def up
+ create_table :user_interacted_projects, id: false do |t|
+ t.references :user, null: false
+ t.references :project, null: false
+ end
+
+ add_index :user_interacted_projects, [:project_id, :user_id], name: INDEX_NAME
+ end
+
+ def down
+ drop_table :user_interacted_projects
+ end
+end
diff --git a/db/migrate/20180223144945_add_allow_local_requests_from_hooks_and_services_to_application_settings.rb b/db/migrate/20180223144945_add_allow_local_requests_from_hooks_and_services_to_application_settings.rb
new file mode 100644
index 00000000000..c994a54698b
--- /dev/null
+++ b/db/migrate/20180223144945_add_allow_local_requests_from_hooks_and_services_to_application_settings.rb
@@ -0,0 +1,18 @@
+class AddAllowLocalRequestsFromHooksAndServicesToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:application_settings, :allow_local_requests_from_hooks_and_services,
+ :boolean,
+ default: false,
+ allow_null: false)
+ end
+
+ def down
+ remove_column(:application_settings, :allow_local_requests_from_hooks_and_services)
+ end
+end
diff --git a/db/migrate/20180226050030_add_checksum_to_ci_job_artifacts.rb b/db/migrate/20180226050030_add_checksum_to_ci_job_artifacts.rb
new file mode 100644
index 00000000000..54e6e35449e
--- /dev/null
+++ b/db/migrate/20180226050030_add_checksum_to_ci_job_artifacts.rb
@@ -0,0 +1,7 @@
+class AddChecksumToCiJobArtifacts < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :ci_job_artifacts, :file_sha256, :binary
+ end
+end
diff --git a/db/migrate/20180227182112_add_group_id_to_boards_ce.rb b/db/migrate/20180227182112_add_group_id_to_boards_ce.rb
new file mode 100644
index 00000000000..f54dd8d7687
--- /dev/null
+++ b/db/migrate/20180227182112_add_group_id_to_boards_ce.rb
@@ -0,0 +1,34 @@
+class AddGroupIdToBoardsCe < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ return if group_id_exists?
+
+ add_column :boards, :group_id, :integer
+ add_foreign_key :boards, :namespaces, column: :group_id, on_delete: :cascade
+ add_concurrent_index :boards, :group_id
+
+ change_column_null :boards, :project_id, true
+ end
+
+ def down
+ return unless group_id_exists?
+
+ remove_foreign_key :boards, column: :group_id
+ remove_index :boards, :group_id if index_exists? :boards, :group_id
+ remove_column :boards, :group_id
+
+ execute "DELETE from boards WHERE project_id IS NULL"
+ change_column_null :boards, :project_id, false
+ end
+
+ private
+
+ def group_id_exists?
+ column_exists?(:boards, :group_id)
+ end
+end
diff --git a/db/migrate/20180301010859_create_ci_builds_metadata_table.rb b/db/migrate/20180301010859_create_ci_builds_metadata_table.rb
new file mode 100644
index 00000000000..ce737444092
--- /dev/null
+++ b/db/migrate/20180301010859_create_ci_builds_metadata_table.rb
@@ -0,0 +1,20 @@
+class CreateCiBuildsMetadataTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :ci_builds_metadata do |t|
+ t.integer :build_id, null: false
+ t.integer :project_id, null: false
+ t.integer :timeout
+ t.integer :timeout_source, null: false, default: 1
+
+ t.foreign_key :ci_builds, column: :build_id, on_delete: :cascade
+ t.foreign_key :projects, column: :project_id, on_delete: :cascade
+
+ t.index :build_id, unique: true
+ t.index :project_id
+ end
+ end
+end
diff --git a/db/migrate/20180302152117_ensure_foreign_keys_on_clusters_applications.rb b/db/migrate/20180302152117_ensure_foreign_keys_on_clusters_applications.rb
new file mode 100644
index 00000000000..8298979e96a
--- /dev/null
+++ b/db/migrate/20180302152117_ensure_foreign_keys_on_clusters_applications.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 EnsureForeignKeysOnClustersApplications < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ existing = Clusters::Cluster
+ .joins(:application_ingress)
+ .where('clusters.id = clusters_applications_ingress.cluster_id')
+
+ Clusters::Applications::Ingress.where('NOT EXISTS (?)', existing).in_batches do |batch|
+ batch.delete_all
+ end
+
+ unless foreign_keys_for(:clusters_applications_ingress, :cluster_id).any?
+ add_concurrent_foreign_key :clusters_applications_ingress, :clusters,
+ column: :cluster_id,
+ on_delete: :cascade
+ end
+
+ existing = Clusters::Cluster
+ .joins(:application_prometheus)
+ .where('clusters.id = clusters_applications_prometheus.cluster_id')
+
+ Clusters::Applications::Ingress.where('NOT EXISTS (?)', existing).in_batches do |batch|
+ batch.delete_all
+ end
+
+ unless foreign_keys_for(:clusters_applications_prometheus, :cluster_id).any?
+ add_concurrent_foreign_key :clusters_applications_prometheus, :clusters,
+ column: :cluster_id,
+ on_delete: :cascade
+ end
+ end
+
+ def down
+ if foreign_keys_for(:clusters_applications_ingress, :cluster_id).any?
+ remove_foreign_key :clusters_applications_ingress, column: :cluster_id
+ end
+
+ if foreign_keys_for(:clusters_applications_prometheus, :cluster_id).any?
+ remove_foreign_key :clusters_applications_prometheus, column: :cluster_id
+ end
+ end
+end
diff --git a/db/migrate/20180305095250_create_internal_ids_table.rb b/db/migrate/20180305095250_create_internal_ids_table.rb
new file mode 100644
index 00000000000..432086fe98b
--- /dev/null
+++ b/db/migrate/20180305095250_create_internal_ids_table.rb
@@ -0,0 +1,15 @@
+class CreateInternalIdsTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :internal_ids, id: :bigserial do |t|
+ t.references :project, null: false, foreign_key: { on_delete: :cascade }
+ t.integer :usage, null: false
+ t.integer :last_value, null: false
+
+ t.index [:usage, :project_id], unique: true
+ end
+ end
+end
diff --git a/db/migrate/20180305144721_add_privileged_to_runner.rb b/db/migrate/20180305144721_add_privileged_to_runner.rb
new file mode 100644
index 00000000000..32e73dba8d5
--- /dev/null
+++ b/db/migrate/20180305144721_add_privileged_to_runner.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 AddPrivilegedToRunner < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default :clusters_applications_runners, :privileged, :boolean, default: true, allow_null: false
+ end
+
+ def down
+ remove_column :clusters_applications_runners, :privileged
+ end
+end
diff --git a/db/migrate/20180306134842_add_missing_indexes_acts_as_taggable_on_engine.rb b/db/migrate/20180306134842_add_missing_indexes_acts_as_taggable_on_engine.rb
new file mode 100644
index 00000000000..06e402adcd7
--- /dev/null
+++ b/db/migrate/20180306134842_add_missing_indexes_acts_as_taggable_on_engine.rb
@@ -0,0 +1,21 @@
+# This migration comes from acts_as_taggable_on_engine (originally 6)
+#
+# It has been modified to handle no-downtime GitLab migrations. Several
+# indexes have been removed since they are not needed for GitLab.
+class AddMissingIndexesActsAsTaggableOnEngine < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :taggings, :tag_id unless index_exists? :taggings, :tag_id
+ add_concurrent_index :taggings, [:taggable_id, :taggable_type] unless index_exists? :taggings, [:taggable_id, :taggable_type]
+ end
+
+ def down
+ remove_concurrent_index :taggings, :tag_id
+ remove_concurrent_index :taggings, [:taggable_id, :taggable_type]
+ end
+end
diff --git a/db/migrate/20180308052825_add_section_name_id_index_on_ci_build_trace_sections.rb b/db/migrate/20180308052825_add_section_name_id_index_on_ci_build_trace_sections.rb
new file mode 100644
index 00000000000..b616cc2fd30
--- /dev/null
+++ b/db/migrate/20180308052825_add_section_name_id_index_on_ci_build_trace_sections.rb
@@ -0,0 +1,23 @@
+class AddSectionNameIdIndexOnCiBuildTraceSections < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+ INDEX_NAME = 'index_ci_build_trace_sections_on_section_name_id'
+
+ disable_ddl_transaction!
+
+ def up
+ # MySQL may already have this as a foreign key
+ unless index_exists?(:ci_build_trace_sections, :section_name_id, name: INDEX_NAME)
+ add_concurrent_index :ci_build_trace_sections, :section_name_id, name: INDEX_NAME
+ 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_build_trace_sections, :section_name_id, name: INDEX_NAME
+ end
+ end
+end
diff --git a/db/migrate/20180309121820_reschedule_commits_count_for_merge_request_diff.rb b/db/migrate/20180309121820_reschedule_commits_count_for_merge_request_diff.rb
new file mode 100644
index 00000000000..990759104b0
--- /dev/null
+++ b/db/migrate/20180309121820_reschedule_commits_count_for_merge_request_diff.rb
@@ -0,0 +1,30 @@
+class RescheduleCommitsCountForMergeRequestDiff < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ MIGRATION = 'AddMergeRequestDiffCommitsCount'.freeze
+ BATCH_SIZE = 5000
+ DELAY_INTERVAL = 5.minutes.to_i
+
+ class MergeRequestDiff < ActiveRecord::Base
+ self.table_name = 'merge_request_diffs'
+
+ include ::EachBatch
+ end
+
+ disable_ddl_transaction!
+
+ def up
+ say 'Populating the MergeRequestDiff `commits_count` (reschedule)'
+
+ execute("SET statement_timeout TO '60s'") if Gitlab::Database.postgresql?
+
+ MergeRequestDiff.where(commits_count: nil).each_batch(of: BATCH_SIZE) do |relation, index|
+ start_id, end_id = relation.pluck('MIN(id), MAX(id)').first
+ delay = index * DELAY_INTERVAL
+
+ BackgroundMigrationWorker.perform_in(delay, MIGRATION, [start_id, end_id])
+ end
+ end
+end
diff --git a/db/migrate/20180309160427_add_partial_indexes_on_todos.rb b/db/migrate/20180309160427_add_partial_indexes_on_todos.rb
new file mode 100644
index 00000000000..18a5c69df1b
--- /dev/null
+++ b/db/migrate/20180309160427_add_partial_indexes_on_todos.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 AddPartialIndexesOnTodos < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ INDEX_NAME_PENDING="index_todos_on_user_id_and_id_pending"
+ INDEX_NAME_DONE="index_todos_on_user_id_and_id_done"
+
+ def up
+ unless index_exists?(:todos, [:user_id, :id], name: INDEX_NAME_PENDING)
+ add_concurrent_index(:todos, [:user_id, :id], where: "state='pending'", name: INDEX_NAME_PENDING)
+ end
+ unless index_exists?(:todos, [:user_id, :id], name: INDEX_NAME_DONE)
+ add_concurrent_index(:todos, [:user_id, :id], where: "state='done'", name: INDEX_NAME_DONE)
+ end
+ end
+
+ def down
+ remove_concurrent_index(:todos, [:user_id, :id], where: "state='pending'", name: INDEX_NAME_PENDING)
+ remove_concurrent_index(:todos, [:user_id, :id], where: "state='done'", name: INDEX_NAME_DONE)
+ end
+end
diff --git a/db/migrate/20180319190020_create_deploy_tokens.rb b/db/migrate/20180319190020_create_deploy_tokens.rb
new file mode 100644
index 00000000000..d129459ea0a
--- /dev/null
+++ b/db/migrate/20180319190020_create_deploy_tokens.rb
@@ -0,0 +1,19 @@
+class CreateDeployTokens < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :deploy_tokens do |t|
+ t.boolean :revoked, default: false
+ t.boolean :read_repository, null: false, default: false
+ t.boolean :read_registry, null: false, default: false
+
+ t.datetime_with_timezone :expires_at, null: false
+ t.datetime_with_timezone :created_at, null: false
+
+ t.string :name, null: false
+ t.string :token, index: { unique: true }, null: false
+
+ t.index [:token, :expires_at, :id], where: "(revoked IS FALSE)"
+ end
+ end
+end
diff --git a/db/migrate/20180320182229_add_indexes_for_user_activity_queries.rb b/db/migrate/20180320182229_add_indexes_for_user_activity_queries.rb
new file mode 100644
index 00000000000..824bbb3ac05
--- /dev/null
+++ b/db/migrate/20180320182229_add_indexes_for_user_activity_queries.rb
@@ -0,0 +1,40 @@
+class AddIndexesForUserActivityQueries < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :events, [:author_id, :project_id] unless index_exists?(:events, [:author_id, :project_id])
+ add_concurrent_index :user_interacted_projects, :user_id unless index_exists?(:user_interacted_projects, :user_id)
+ end
+
+ def down
+ remove_concurrent_index :events, [:author_id, :project_id] if index_exists?(:events, [:author_id, :project_id])
+
+ patch_foreign_keys do
+ remove_concurrent_index :user_interacted_projects, :user_id if index_exists?(:user_interacted_projects, :user_id)
+ end
+ end
+
+ private
+
+ def patch_foreign_keys
+ return yield if Gitlab::Database.postgresql?
+
+ # MySQL doesn't like to remove the index with a foreign key using it.
+ remove_foreign_key :user_interacted_projects, :users if fk_exists?(:user_interacted_projects, :user_id)
+
+ yield
+
+ # Let's re-add the foreign key using the existing index on (user_id, project_id)
+ add_concurrent_foreign_key :user_interacted_projects, :users, column: :user_id unless fk_exists?(:user_interacted_projects, :user_id)
+ end
+
+ def fk_exists?(table, column)
+ foreign_keys(table).any? do |key|
+ key.options[:column] == column.to_s
+ end
+ end
+end
diff --git a/db/migrate/20180323150945_add_push_to_merge_request_to_notification_settings.rb b/db/migrate/20180323150945_add_push_to_merge_request_to_notification_settings.rb
new file mode 100644
index 00000000000..12b8875d8dc
--- /dev/null
+++ b/db/migrate/20180323150945_add_push_to_merge_request_to_notification_settings.rb
@@ -0,0 +1,9 @@
+class AddPushToMergeRequestToNotificationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :notification_settings, :push_to_merge_request, :boolean
+ end
+end
diff --git a/db/migrate/20180327101207_remove_index_from_events_table.rb b/db/migrate/20180327101207_remove_index_from_events_table.rb
new file mode 100644
index 00000000000..172441da65b
--- /dev/null
+++ b/db/migrate/20180327101207_remove_index_from_events_table.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 RemoveIndexFromEventsTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index :events, :author_id
+ end
+
+ def down
+ add_concurrent_index :events, :author_id
+ end
+end
diff --git a/db/migrate/20180405142733_create_project_deploy_tokens.rb b/db/migrate/20180405142733_create_project_deploy_tokens.rb
new file mode 100644
index 00000000000..9d8f89243a8
--- /dev/null
+++ b/db/migrate/20180405142733_create_project_deploy_tokens.rb
@@ -0,0 +1,16 @@
+class CreateProjectDeployTokens < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :project_deploy_tokens do |t|
+ t.integer :project_id, null: false
+ t.integer :deploy_token_id, null: false
+ t.datetime_with_timezone :created_at, null: false
+
+ t.foreign_key :deploy_tokens, column: :deploy_token_id, on_delete: :cascade
+ t.foreign_key :projects, column: :project_id, on_delete: :cascade
+
+ t.index [:project_id, :deploy_token_id], unique: true
+ end
+ 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
index da0fcda87a6..17ad7de065d 100644
--- a/db/post_migrate/20170518200835_rename_users_with_renamed_namespace.rb
+++ b/db/post_migrate/20170518200835_rename_users_with_renamed_namespace.rb
@@ -31,6 +31,7 @@ class RenameUsersWithRenamedNamespace < ActiveRecord::Migration
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}"
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
index 26b99b61424..c48f1c938d0 100644
--- a/db/post_migrate/20170717111152_cleanup_move_system_upload_folder_symlink.rb
+++ b/db/post_migrate/20170717111152_cleanup_move_system_upload_folder_symlink.rb
@@ -20,7 +20,7 @@ class CleanupMoveSystemUploadFolderSymlink < ActiveRecord::Migration
def down
if File.directory?(new_directory)
say "Symlinking #{old_directory} -> #{new_directory}"
- FileUtils.ln_s(new_directory, old_directory)
+ FileUtils.ln_s(new_directory, old_directory) unless File.exist?(old_directory)
else
say "#{new_directory} doesn't exist, skipping."
end
diff --git a/db/post_migrate/20170907170235_delete_conflicting_redirect_routes.rb b/db/post_migrate/20170907170235_delete_conflicting_redirect_routes.rb
index 3e84b295be4..033019c398e 100644
--- a/db/post_migrate/20170907170235_delete_conflicting_redirect_routes.rb
+++ b/db/post_migrate/20170907170235_delete_conflicting_redirect_routes.rb
@@ -2,36 +2,12 @@
# for more information on how to write migrations for GitLab.
class DeleteConflictingRedirectRoutes < ActiveRecord::Migration
- include Gitlab::Database::MigrationHelpers
-
- DOWNTIME = false
- MIGRATION = 'DeleteConflictingRedirectRoutesRange'.freeze
- BATCH_SIZE = 200 # At 200, I expect under 20s per batch, which is under our query timeout of 60s.
- DELAY_INTERVAL = 12.seconds
-
- disable_ddl_transaction!
-
- class Route < ActiveRecord::Base
- include EachBatch
-
- self.table_name = 'routes'
- end
-
def up
- say opening_message
-
- queue_background_migration_jobs_by_range_at_intervals(Route, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE)
+ # No-op.
+ # See https://gitlab.com/gitlab-com/infrastructure/issues/3460#note_53223252
end
def down
# nothing
end
-
- def opening_message
- <<~MSG
- Clean up redirect routes that conflict with regular routes.
- See initial bug fix:
- https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13357
- MSG
- end
end
diff --git a/db/post_migrate/20171124104327_migrate_kubernetes_service_to_new_clusters_architectures.rb b/db/post_migrate/20171124104327_migrate_kubernetes_service_to_new_clusters_architectures.rb
new file mode 100644
index 00000000000..11b581e4b57
--- /dev/null
+++ b/db/post_migrate/20171124104327_migrate_kubernetes_service_to_new_clusters_architectures.rb
@@ -0,0 +1,151 @@
+class MigrateKubernetesServiceToNewClustersArchitectures < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ DEFAULT_KUBERNETES_SERVICE_CLUSTER_NAME = 'KubernetesService'.freeze
+
+ disable_ddl_transaction!
+
+ class Project < ActiveRecord::Base
+ self.table_name = 'projects'
+
+ has_many :cluster_projects, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::ClustersProject'
+ has_many :clusters, through: :cluster_projects, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Cluster'
+ has_many :services, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Service'
+ has_one :kubernetes_service, -> { where(category: 'deployment', type: 'KubernetesService') }, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Service', inverse_of: :project, foreign_key: :project_id
+ end
+
+ class Cluster < ActiveRecord::Base
+ self.table_name = 'clusters'
+
+ has_many :cluster_projects, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::ClustersProject'
+ has_many :projects, through: :cluster_projects, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Project'
+ has_one :platform_kubernetes, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::PlatformsKubernetes'
+
+ accepts_nested_attributes_for :platform_kubernetes
+
+ enum platform_type: {
+ kubernetes: 1
+ }
+
+ enum provider_type: {
+ user: 0,
+ gcp: 1
+ }
+ end
+
+ class ClustersProject < ActiveRecord::Base
+ self.table_name = 'cluster_projects'
+
+ belongs_to :cluster, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Cluster'
+ belongs_to :project, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Project'
+ end
+
+ class PlatformsKubernetes < ActiveRecord::Base
+ self.table_name = 'cluster_platforms_kubernetes'
+
+ belongs_to :cluster, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Cluster'
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+ end
+
+ class Service < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'services'
+ self.inheritance_column = :_type_disabled # Disable STI, otherwise KubernetesModel will be looked up
+
+ belongs_to :project, class_name: 'MigrateKubernetesServiceToNewClustersArchitectures::Project', foreign_key: :project_id
+
+ scope :unmanaged_kubernetes_service, -> do
+ joins('LEFT JOIN projects ON projects.id = services.project_id')
+ .joins('LEFT JOIN cluster_projects ON cluster_projects.project_id = projects.id')
+ .joins('LEFT JOIN cluster_platforms_kubernetes ON cluster_platforms_kubernetes.cluster_id = cluster_projects.cluster_id')
+ .where(category: 'deployment', type: 'KubernetesService', template: false)
+ .where("services.properties LIKE '%api_url%'")
+ .where("(services.properties NOT LIKE CONCAT('%', cluster_platforms_kubernetes.api_url, '%')) OR cluster_platforms_kubernetes.api_url IS NULL")
+ .group(:id)
+ .order(id: :asc)
+ end
+
+ scope :kubernetes_service_without_template, -> do
+ where(category: 'deployment', type: 'KubernetesService', template: false)
+ end
+
+ def api_url
+ parsed_properties['api_url']
+ end
+
+ def ca_pem
+ parsed_properties['ca_pem']
+ end
+
+ def namespace
+ parsed_properties['namespace']
+ end
+
+ def token
+ parsed_properties['token']
+ end
+
+ private
+
+ def parsed_properties
+ @parsed_properties ||= JSON.parse(self.properties)
+ end
+ end
+
+ def find_dedicated_environement_scope(project)
+ environment_scopes = project.clusters.map(&:environment_scope)
+
+ return '*' if environment_scopes.exclude?('*') # KubernetesService should be added as a default cluster (environment_scope: '*') at first place
+ return 'migrated/*' if environment_scopes.exclude?('migrated/*') # If it's conflicted, the KubernetesService added as a migrated cluster
+
+ unique_iid = 0
+
+ # If it's still conflicted, finding an unique environment scope incrementaly
+ loop do
+ candidate = "migrated#{unique_iid}/*"
+ return candidate if environment_scopes.exclude?(candidate)
+
+ unique_iid += 1
+ end
+ end
+
+ def up
+ ActiveRecord::Base.transaction do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service
+ .unmanaged_kubernetes_service.find_each(batch_size: 1) do |kubernetes_service|
+ MigrateKubernetesServiceToNewClustersArchitectures::Cluster.create(
+ enabled: kubernetes_service.active,
+ user_id: nil, # KubernetesService doesn't have
+ name: DEFAULT_KUBERNETES_SERVICE_CLUSTER_NAME,
+ provider_type: MigrateKubernetesServiceToNewClustersArchitectures::Cluster.provider_types[:user],
+ platform_type: MigrateKubernetesServiceToNewClustersArchitectures::Cluster.platform_types[:kubernetes],
+ projects: [kubernetes_service.project],
+ environment_scope: find_dedicated_environement_scope(kubernetes_service.project),
+ platform_kubernetes_attributes: {
+ api_url: kubernetes_service.api_url,
+ ca_cert: kubernetes_service.ca_pem,
+ namespace: kubernetes_service.namespace,
+ username: nil, # KubernetesService doesn't have
+ encrypted_password: nil, # KubernetesService doesn't have
+ encrypted_password_iv: nil, # KubernetesService doesn't have
+ token: kubernetes_service.token # encrypted_token and encrypted_token_iv
+ } )
+ end
+ end
+
+ MigrateKubernetesServiceToNewClustersArchitectures::Service
+ .kubernetes_service_without_template.each_batch(of: 100) do |kubernetes_service|
+ kubernetes_service.update_all(active: false)
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20171124150326_reschedule_fork_network_creation.rb b/db/post_migrate/20171124150326_reschedule_fork_network_creation.rb
index 05430efe1f6..26f917d5a1e 100644
--- a/db/post_migrate/20171124150326_reschedule_fork_network_creation.rb
+++ b/db/post_migrate/20171124150326_reschedule_fork_network_creation.rb
@@ -3,22 +3,8 @@ class RescheduleForkNetworkCreation < ActiveRecord::Migration
DOWNTIME = false
- MIGRATION = 'PopulateForkNetworksRange'.freeze
- BATCH_SIZE = 100
- DELAY_INTERVAL = 15.seconds
-
- disable_ddl_transaction!
-
- class ForkedProjectLink < ActiveRecord::Base
- include EachBatch
-
- self.table_name = 'forked_project_links'
- end
-
def up
- say 'Populating the `fork_networks` based on existing `forked_project_links`'
-
- queue_background_migration_jobs_by_range_at_intervals(ForkedProjectLink, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE)
+ say 'Fork networks will be populated in 20171205190711 - RescheduleForkNetworkCreationCaller'
end
def down
diff --git a/db/post_migrate/20171128214150_schedule_populate_merge_request_metrics_with_events_data.rb b/db/post_migrate/20171128214150_schedule_populate_merge_request_metrics_with_events_data.rb
new file mode 100644
index 00000000000..fce1829c982
--- /dev/null
+++ b/db/post_migrate/20171128214150_schedule_populate_merge_request_metrics_with_events_data.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+# rubocop:disable GitlabSecurity/SqlInjection
+
+class SchedulePopulateMergeRequestMetricsWithEventsData < ActiveRecord::Migration
+ DOWNTIME = false
+ BATCH_SIZE = 10_000
+ MIGRATION = 'PopulateMergeRequestMetricsWithEventsData'
+
+ disable_ddl_transaction!
+
+ class MergeRequest < ActiveRecord::Base
+ self.table_name = 'merge_requests'
+
+ include ::EachBatch
+ end
+
+ def up
+ say 'Scheduling `PopulateMergeRequestMetricsWithEventsData` jobs'
+ # It will update around 4_000_000 records in batches of 10_000 merge
+ # requests (running between 10 minutes) and should take around 66 hours to complete.
+ # Apparently, production PostgreSQL is able to vacuum 10k-20k dead_tuples by
+ # minute, and at maximum, each of these jobs should UPDATE 20k records.
+ #
+ # More information about the updates in `PopulateMergeRequestMetricsWithEventsData` class.
+ #
+ MergeRequest.all.each_batch(of: BATCH_SIZE) do |relation, index|
+ range = relation.pluck('MIN(id)', 'MAX(id)').first
+
+ BackgroundMigrationWorker.perform_in(index * 10.minutes, MIGRATION, range)
+ end
+ end
+
+ def down
+ execute "update merge_request_metrics set latest_closed_at = null"
+ execute "update merge_request_metrics set latest_closed_by_id = null"
+ execute "update merge_request_metrics set merged_by_id = null"
+ end
+end
diff --git a/db/post_migrate/20171207150300_remove_project_labels_group_id_copy.rb b/db/post_migrate/20171207150300_remove_project_labels_group_id_copy.rb
new file mode 100644
index 00000000000..2f339172eeb
--- /dev/null
+++ b/db/post_migrate/20171207150300_remove_project_labels_group_id_copy.rb
@@ -0,0 +1,21 @@
+# Copy of 20180202111106 - this one should run before 20171207150343 to fix issues related to
+# the removal of groups with labels.
+
+class RemoveProjectLabelsGroupIdCopy < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # rubocop:disable Migration/UpdateColumnInBatches
+ update_column_in_batches(:labels, :group_id, nil) do |table, query|
+ query.where(table[:type].eq('ProjectLabel').and(table[:group_id].not_eq(nil)))
+ end
+ # rubocop:enable Migration/UpdateColumnInBatches
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20171207150343_remove_soft_removed_objects.rb b/db/post_migrate/20171207150343_remove_soft_removed_objects.rb
new file mode 100644
index 00000000000..3e2dedfdd6a
--- /dev/null
+++ b/db/post_migrate/20171207150343_remove_soft_removed_objects.rb
@@ -0,0 +1,208 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveSoftRemovedObjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ module SoftRemoved
+ extend ActiveSupport::Concern
+
+ included do
+ scope :soft_removed, -> { where('deleted_at IS NOT NULL') }
+ end
+ end
+
+ class User < ActiveRecord::Base
+ self.table_name = 'users'
+
+ include EachBatch
+ end
+
+ class Issue < ActiveRecord::Base
+ self.table_name = 'issues'
+
+ include EachBatch
+ include SoftRemoved
+ end
+
+ class MergeRequest < ActiveRecord::Base
+ self.table_name = 'merge_requests'
+
+ include EachBatch
+ include SoftRemoved
+ end
+
+ class Namespace < ActiveRecord::Base
+ self.table_name = 'namespaces'
+
+ include EachBatch
+ include SoftRemoved
+
+ scope :soft_removed_personal, -> { soft_removed.where(type: nil) }
+ scope :soft_removed_group, -> { soft_removed.where(type: 'Group') }
+ end
+
+ class Route < ActiveRecord::Base
+ self.table_name = 'routes'
+
+ include EachBatch
+ include SoftRemoved
+ end
+
+ class Project < ActiveRecord::Base
+ self.table_name = 'projects'
+
+ include EachBatch
+ include SoftRemoved
+ end
+
+ class CiPipelineSchedule < ActiveRecord::Base
+ self.table_name = 'ci_pipeline_schedules'
+
+ include EachBatch
+ include SoftRemoved
+ end
+
+ class CiTrigger < ActiveRecord::Base
+ self.table_name = 'ci_triggers'
+
+ include EachBatch
+ include SoftRemoved
+ end
+
+ MODELS = [Issue, MergeRequest, CiPipelineSchedule, CiTrigger].freeze
+
+ def up
+ disable_statement_timeout
+
+ remove_personal_routes
+ remove_personal_namespaces
+ remove_group_namespaces
+ remove_simple_soft_removed_rows
+ end
+
+ def down
+ # The data removed by this migration can't be restored in an automated way.
+ end
+
+ def remove_simple_soft_removed_rows
+ create_temporary_indexes
+
+ MODELS.each do |model|
+ say_with_time("Removing soft removed rows from #{model.table_name}") do
+ model.soft_removed.each_batch do |batch, index|
+ batch.delete_all
+ end
+ end
+ end
+ ensure
+ remove_temporary_indexes
+ end
+
+ def create_temporary_indexes
+ MODELS.each do |model|
+ index_name = temporary_index_name_for(model)
+
+ # Without this index the removal process can take a very long time. For
+ # example, getting the next ID of a batch for the `issues` table in
+ # staging would take between 15 and 20 seconds.
+ next if temporary_index_exists?(model)
+
+ say_with_time("Creating temporary index #{index_name}") do
+ add_concurrent_index(
+ model.table_name,
+ [:deleted_at, :id],
+ name: index_name,
+ where: 'deleted_at IS NOT NULL'
+ )
+ end
+ end
+ end
+
+ def remove_temporary_indexes
+ MODELS.each do |model|
+ index_name = temporary_index_name_for(model)
+
+ next unless temporary_index_exists?(model)
+
+ say_with_time("Removing temporary index #{index_name}") do
+ remove_concurrent_index_by_name(model.table_name, index_name)
+ end
+ end
+ end
+
+ def temporary_index_name_for(model)
+ "index_on_#{model.table_name}_tmp"
+ end
+
+ def temporary_index_exists?(model)
+ index_name = temporary_index_name_for(model)
+
+ index_exists?(model.table_name, [:deleted_at, :id], name: index_name)
+ end
+
+ def remove_personal_namespaces
+ # Some personal namespaces are left behind in case of GitLab.com. In these
+ # cases the associated data such as the projects and users has already been
+ # removed.
+ Namespace.soft_removed_personal.each_batch do |batch|
+ batch.delete_all
+ end
+ end
+
+ def remove_group_namespaces
+ admin_id = id_for_admin_user
+
+ unless admin_id
+ say 'Not scheduling soft removed groups for removal as no admin user ' \
+ 'could be found. You will need to remove any such groups manually.'
+
+ return
+ end
+
+ # Left over groups can't be easily removed because we may also need to
+ # remove memberships, repositories, and other associated data. As a result
+ # we'll just schedule a Sidekiq job to remove these.
+ #
+ # As of January 5th, 2018 there are 36 groups that will be removed using
+ # this code.
+ Namespace.select(:id).soft_removed_group.each_batch(of: 10) do |batch, index|
+ batch.each do |ns|
+ schedule_group_removal(index * 5.minutes, ns.id, admin_id)
+ end
+ end
+ end
+
+ def schedule_group_removal(delay, group_id, user_id)
+ if migrate_inline?
+ GroupDestroyWorker.new.perform(group_id, user_id)
+ else
+ GroupDestroyWorker.perform_in(delay, group_id, user_id)
+ end
+ end
+
+ def remove_personal_routes
+ namespaces = Namespace.select(1)
+ .soft_removed
+ .where('namespaces.type IS NULL')
+ .where('routes.source_type = ?', 'Namespace')
+ .where('routes.source_id = namespaces.id')
+
+ Route.where('EXISTS (?)', namespaces).each_batch do |batch|
+ batch.delete_all
+ end
+ end
+
+ def id_for_admin_user
+ User.where(admin: true).limit(1).pluck(:id).first
+ end
+
+ def migrate_inline?
+ Rails.env.test? || Rails.env.development?
+ end
+end
diff --git a/db/post_migrate/20171207150344_remove_deleted_at_columns.rb b/db/post_migrate/20171207150344_remove_deleted_at_columns.rb
new file mode 100644
index 00000000000..154d7a1b926
--- /dev/null
+++ b/db/post_migrate/20171207150344_remove_deleted_at_columns.rb
@@ -0,0 +1,31 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveDeletedAtColumns < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ TABLES = %i[issues merge_requests namespaces ci_pipeline_schedules ci_triggers].freeze
+ COLUMN = :deleted_at
+
+ def up
+ TABLES.each do |table|
+ remove_column(table, COLUMN) if column_exists?(table, COLUMN)
+ end
+ end
+
+ def down
+ TABLES.each do |table|
+ unless column_exists?(table, COLUMN)
+ add_column(table, COLUMN, :datetime_with_timezone)
+ end
+
+ unless index_exists?(table, COLUMN)
+ add_concurrent_index(table, COLUMN)
+ end
+ end
+ end
+end
diff --git a/db/post_migrate/20171215121205_post_populate_can_push_from_deploy_keys_projects.rb b/db/post_migrate/20171215121205_post_populate_can_push_from_deploy_keys_projects.rb
new file mode 100644
index 00000000000..3a5850df3db
--- /dev/null
+++ b/db/post_migrate/20171215121205_post_populate_can_push_from_deploy_keys_projects.rb
@@ -0,0 +1,63 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class PostPopulateCanPushFromDeployKeysProjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ DATABASE_NAME = Gitlab::Database.database_name
+
+ disable_ddl_transaction!
+
+ class DeploysKeyProject < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'deploy_keys_projects'
+ end
+
+ def up
+ DeploysKeyProject.each_batch(of: 10_000) do |batch|
+ start_id, end_id = batch.pluck('MIN(id), MAX(id)').first
+
+ if Gitlab::Database.mysql?
+ execute <<-EOF.strip_heredoc
+ UPDATE deploy_keys_projects, #{DATABASE_NAME}.keys
+ SET deploy_keys_projects.can_push = #{DATABASE_NAME}.keys.can_push
+ WHERE deploy_keys_projects.deploy_key_id = #{DATABASE_NAME}.keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ else
+ execute <<-EOF.strip_heredoc
+ UPDATE deploy_keys_projects
+ SET can_push = keys.can_push
+ FROM keys
+ WHERE deploy_key_id = keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ end
+ end
+ end
+
+ def down
+ DeploysKeyProject.each_batch(of: 10_000) do |batch|
+ start_id, end_id = batch.pluck('MIN(id), MAX(id)').first
+
+ if Gitlab::Database.mysql?
+ execute <<-EOF.strip_heredoc
+ UPDATE deploy_keys_projects, #{DATABASE_NAME}.keys
+ SET #{DATABASE_NAME}.keys.can_push = deploy_keys_projects.can_push
+ WHERE deploy_keys_projects.deploy_key_id = #{DATABASE_NAME}.keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ else
+ execute <<-EOF.strip_heredoc
+ UPDATE keys
+ SET can_push = deploy_keys_projects.can_push
+ FROM deploy_keys_projects
+ WHERE deploy_keys_projects.deploy_key_id = keys.id
+ AND deploy_keys_projects.id BETWEEN #{start_id} AND #{end_id}
+ EOF
+ end
+ end
+ end
+end
diff --git a/db/post_migrate/20171215121259_remove_can_push_from_keys.rb b/db/post_migrate/20171215121259_remove_can_push_from_keys.rb
new file mode 100644
index 00000000000..0599811d986
--- /dev/null
+++ b/db/post_migrate/20171215121259_remove_can_push_from_keys.rb
@@ -0,0 +1,17 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class RemoveCanPushFromKeys < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ def up
+ remove_column :keys, :can_push
+ end
+
+ def down
+ add_column_with_default :keys, :can_push, :boolean, default: false, allow_null: false
+ end
+end
diff --git a/db/post_migrate/20171221140220_schedule_issues_closed_at_type_change.rb b/db/post_migrate/20171221140220_schedule_issues_closed_at_type_change.rb
new file mode 100644
index 00000000000..eeecc7b1de0
--- /dev/null
+++ b/db/post_migrate/20171221140220_schedule_issues_closed_at_type_change.rb
@@ -0,0 +1,45 @@
+# 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 ScheduleIssuesClosedAtTypeChange < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Issue < ActiveRecord::Base
+ self.table_name = 'issues'
+ include EachBatch
+
+ def self.to_migrate
+ where('closed_at IS NOT NULL')
+ end
+ end
+
+ def up
+ return unless migrate_column_type?
+
+ change_column_type_using_background_migration(
+ Issue.to_migrate,
+ :closed_at,
+ :datetime_with_timezone
+ )
+ end
+
+ def down
+ return if migrate_column_type?
+
+ change_column_type_using_background_migration(
+ Issue.to_migrate,
+ :closed_at,
+ :datetime
+ )
+ end
+
+ def migrate_column_type?
+ # Some environments may have already executed the previous version of this
+ # migration, thus we don't need to migrate those environments again.
+ column_for('issues', 'closed_at').type == :datetime
+ end
+end
diff --git a/db/post_migrate/20180104131052_schedule_set_confidential_note_events_on_webhooks.rb b/db/post_migrate/20180104131052_schedule_set_confidential_note_events_on_webhooks.rb
new file mode 100644
index 00000000000..fa51ac83619
--- /dev/null
+++ b/db/post_migrate/20180104131052_schedule_set_confidential_note_events_on_webhooks.rb
@@ -0,0 +1,23 @@
+class ScheduleSetConfidentialNoteEventsOnWebhooks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ BATCH_SIZE = 1_000
+ INTERVAL = 5.minutes
+
+ disable_ddl_transaction!
+
+ def up
+ migration = Gitlab::BackgroundMigration::SetConfidentialNoteEventsOnWebhooks
+ migration_name = migration.to_s.demodulize
+ relation = migration::WebHook.hooks_to_update
+
+ queue_background_migration_jobs_by_range_at_intervals(relation,
+ migration_name,
+ INTERVAL,
+ batch_size: BATCH_SIZE)
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20180119121225_remove_redundant_pipeline_stages.rb b/db/post_migrate/20180119121225_remove_redundant_pipeline_stages.rb
new file mode 100644
index 00000000000..61ea85eb2a7
--- /dev/null
+++ b/db/post_migrate/20180119121225_remove_redundant_pipeline_stages.rb
@@ -0,0 +1,66 @@
+class RemoveRedundantPipelineStages < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up(attempts: 100)
+ remove_redundant_pipeline_stages!
+ remove_outdated_index!
+ add_unique_index!
+ rescue ActiveRecord::RecordNotUnique
+ retry if (attempts -= 1) > 0
+
+ raise StandardError, <<~EOS
+ Failed to add an unique index to ci_stages, despite retrying the
+ migration 100 times.
+
+ See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/16580.
+ EOS
+ end
+
+ def down
+ remove_concurrent_index :ci_stages, [:pipeline_id, :name], unique: true
+ add_concurrent_index :ci_stages, [:pipeline_id, :name]
+ end
+
+ private
+
+ def remove_outdated_index!
+ return unless index_exists?(:ci_stages, [:pipeline_id, :name])
+
+ remove_concurrent_index :ci_stages, [:pipeline_id, :name]
+ end
+
+ def add_unique_index!
+ add_concurrent_index :ci_stages, [:pipeline_id, :name], unique: true
+ end
+
+ def remove_redundant_pipeline_stages!
+ disable_statement_timeout
+
+ redundant_stages_ids = <<~SQL
+ SELECT id FROM ci_stages WHERE (pipeline_id, name) IN (
+ SELECT pipeline_id, name FROM ci_stages
+ GROUP BY pipeline_id, name HAVING COUNT(*) > 1
+ )
+ SQL
+
+ execute <<~SQL
+ UPDATE ci_builds SET stage_id = NULL WHERE stage_id IN (#{redundant_stages_ids})
+ SQL
+
+ if Gitlab::Database.postgresql?
+ execute <<~SQL
+ DELETE FROM ci_stages WHERE id IN (#{redundant_stages_ids})
+ SQL
+ else # We can't modify a table we are selecting from on MySQL
+ execute <<~SQL
+ DELETE a FROM ci_stages AS a, ci_stages AS b
+ WHERE a.pipeline_id = b.pipeline_id AND a.name = b.name
+ AND a.id <> b.id
+ SQL
+ end
+ end
+end
diff --git a/db/post_migrate/20180122154930_schedule_set_confidential_note_events_on_services.rb b/db/post_migrate/20180122154930_schedule_set_confidential_note_events_on_services.rb
new file mode 100644
index 00000000000..a3ff9f1719e
--- /dev/null
+++ b/db/post_migrate/20180122154930_schedule_set_confidential_note_events_on_services.rb
@@ -0,0 +1,23 @@
+class ScheduleSetConfidentialNoteEventsOnServices < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ BATCH_SIZE = 1_000
+ INTERVAL = 20.minutes
+
+ disable_ddl_transaction!
+
+ def up
+ migration = Gitlab::BackgroundMigration::SetConfidentialNoteEventsOnServices
+ migration_name = migration.to_s.demodulize
+ relation = migration::Service.services_to_update
+
+ queue_background_migration_jobs_by_range_at_intervals(relation,
+ migration_name,
+ INTERVAL,
+ batch_size: BATCH_SIZE)
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20180202111106_remove_project_labels_group_id.rb b/db/post_migrate/20180202111106_remove_project_labels_group_id.rb
new file mode 100644
index 00000000000..db7fd0d167d
--- /dev/null
+++ b/db/post_migrate/20180202111106_remove_project_labels_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 RemoveProjectLabelsGroupId < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ update_column_in_batches(:labels, :group_id, nil) do |table, query|
+ query.where(table[:type].eq('ProjectLabel').and(table[:group_id].not_eq(nil)))
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20180204200836_change_author_id_to_not_null_in_todos.rb b/db/post_migrate/20180204200836_change_author_id_to_not_null_in_todos.rb
new file mode 100644
index 00000000000..92c32feebf7
--- /dev/null
+++ b/db/post_migrate/20180204200836_change_author_id_to_not_null_in_todos.rb
@@ -0,0 +1,26 @@
+class ChangeAuthorIdToNotNullInTodos < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ class Todo < ActiveRecord::Base
+ self.table_name = 'todos'
+ include EachBatch
+ end
+
+ BATCH_SIZE = 1000
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ Todo.where(author_id: nil).each_batch(of: BATCH_SIZE) do |batch|
+ batch.delete_all
+ end
+
+ change_column_null :todos, :author_id, false
+ end
+
+ def down
+ change_column_null :todos, :author_id, true
+ end
+end
diff --git a/db/post_migrate/20180212101828_add_tmp_partial_null_index_to_builds.rb b/db/post_migrate/20180212101828_add_tmp_partial_null_index_to_builds.rb
new file mode 100644
index 00000000000..e55e2e6f888
--- /dev/null
+++ b/db/post_migrate/20180212101828_add_tmp_partial_null_index_to_builds.rb
@@ -0,0 +1,14 @@
+class AddTmpPartialNullIndexToBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index(:ci_builds, :id, where: 'stage_id IS NULL',
+ name: 'tmp_id_partial_null_index')
+ end
+
+ def down
+ remove_concurrent_index_by_name(:ci_builds, 'tmp_id_partial_null_index')
+ end
+end
diff --git a/db/post_migrate/20180212101928_schedule_build_stage_migration.rb b/db/post_migrate/20180212101928_schedule_build_stage_migration.rb
new file mode 100644
index 00000000000..0f61fa81832
--- /dev/null
+++ b/db/post_migrate/20180212101928_schedule_build_stage_migration.rb
@@ -0,0 +1,14 @@
+class ScheduleBuildStageMigration < ActiveRecord::Migration
+ ##
+ # This migration has been rescheduled to run again, see
+ # `20180405101928_reschedule_builds_stages_migration.rb`
+ #
+
+ def up
+ # noop
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/post_migrate/20180212102028_remove_tmp_partial_null_index_from_builds.rb b/db/post_migrate/20180212102028_remove_tmp_partial_null_index_from_builds.rb
new file mode 100644
index 00000000000..ed7b1fc72f4
--- /dev/null
+++ b/db/post_migrate/20180212102028_remove_tmp_partial_null_index_from_builds.rb
@@ -0,0 +1,14 @@
+class RemoveTmpPartialNullIndexFromBuilds < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index_by_name(:ci_builds, 'tmp_id_partial_null_index')
+ end
+
+ def down
+ add_concurrent_index(:ci_builds, :id, where: 'stage_id IS NULL',
+ name: 'tmp_id_partial_null_index')
+ end
+end
diff --git a/db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb b/db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb
new file mode 100644
index 00000000000..d423673d2a5
--- /dev/null
+++ b/db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb
@@ -0,0 +1,41 @@
+class FillPagesDomainVerificationCode < ActiveRecord::Migration
+ DOWNTIME = false
+
+ class PagesDomain < ActiveRecord::Base
+ include EachBatch
+ end
+
+ # Allow this migration to resume if it fails partway through
+ disable_ddl_transaction!
+
+ def up
+ PagesDomain.where(verification_code: [nil, '']).each_batch do |relation|
+ connection.execute(set_codes_sql(relation))
+
+ # Sleep 2 minutes between batches to not overload the DB with dead tuples
+ sleep(2.minutes) unless relation.reorder(:id).last == PagesDomain.reorder(:id).last
+ end
+
+ change_column_null(:pages_domains, :verification_code, false)
+ end
+
+ def down
+ change_column_null(:pages_domains, :verification_code, true)
+ end
+
+ private
+
+ def set_codes_sql(relation)
+ ids = relation.pluck(:id)
+ whens = ids.map { |id| "WHEN #{id} THEN '#{SecureRandom.hex(16)}'" }
+
+ <<~SQL
+ UPDATE pages_domains
+ SET verification_code =
+ CASE id
+ #{whens.join("\n")}
+ END
+ WHERE id IN(#{ids.join(',')})
+ SQL
+ end
+end
diff --git a/db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb b/db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb
new file mode 100644
index 00000000000..bf9bf4e660f
--- /dev/null
+++ b/db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb
@@ -0,0 +1,16 @@
+class EnqueueVerifyPagesDomainWorkers < ActiveRecord::Migration
+ class PagesDomain < ActiveRecord::Base
+ include EachBatch
+ end
+
+ def up
+ PagesDomain.each_batch do |relation|
+ ids = relation.pluck(:id).map { |id| [id] }
+ PagesDomainVerificationWorker.bulk_perform_async(ids)
+ end
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/post_migrate/20180220150310_remove_empty_extern_uid_auth0_identities.rb b/db/post_migrate/20180220150310_remove_empty_extern_uid_auth0_identities.rb
new file mode 100644
index 00000000000..2d5a8617169
--- /dev/null
+++ b/db/post_migrate/20180220150310_remove_empty_extern_uid_auth0_identities.rb
@@ -0,0 +1,25 @@
+class RemoveEmptyExternUidAuth0Identities < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Identity < ActiveRecord::Base
+ self.table_name = 'identities'
+ include EachBatch
+ end
+
+ def up
+ broken_auth0_identities.each_batch do |identity|
+ identity.delete_all
+ end
+ end
+
+ def broken_auth0_identities
+ Identity.where(provider: 'auth0', extern_uid: [nil, ''])
+ end
+
+ def down
+ end
+end
diff --git a/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb b/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb
new file mode 100644
index 00000000000..9addd36dca6
--- /dev/null
+++ b/db/post_migrate/20180223124427_build_user_interacted_projects_table.rb
@@ -0,0 +1,170 @@
+require_relative '../migrate/20180223120443_create_user_interacted_projects_table.rb'
+# rubocop:disable AddIndex
+# rubocop:disable AddConcurrentForeignKey
+class BuildUserInteractedProjectsTable < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ UNIQUE_INDEX_NAME = 'index_user_interacted_projects_on_project_id_and_user_id'
+
+ disable_ddl_transaction!
+
+ def up
+ if Gitlab::Database.postgresql?
+ PostgresStrategy.new
+ else
+ MysqlStrategy.new
+ end.up
+
+ if index_exists_by_name?(:user_interacted_projects, CreateUserInteractedProjectsTable::INDEX_NAME)
+ remove_concurrent_index_by_name :user_interacted_projects, CreateUserInteractedProjectsTable::INDEX_NAME
+ end
+ end
+
+ def down
+ execute "TRUNCATE user_interacted_projects"
+
+ if foreign_key_exists?(:user_interacted_projects, :users)
+ remove_foreign_key :user_interacted_projects, :users
+ end
+
+ if foreign_key_exists?(:user_interacted_projects, :projects)
+ remove_foreign_key :user_interacted_projects, :projects
+ end
+
+ if index_exists_by_name?(:user_interacted_projects, UNIQUE_INDEX_NAME)
+ remove_concurrent_index_by_name :user_interacted_projects, UNIQUE_INDEX_NAME
+ end
+
+ unless index_exists_by_name?(:user_interacted_projects, CreateUserInteractedProjectsTable::INDEX_NAME)
+ add_concurrent_index :user_interacted_projects, [:project_id, :user_id], name: CreateUserInteractedProjectsTable::INDEX_NAME
+ end
+ end
+
+ private
+
+ class PostgresStrategy < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ BATCH_SIZE = 100_000
+ SLEEP_TIME = 5
+
+ def up
+ with_index(:events, [:author_id, :project_id], name: 'events_user_interactions_temp', where: 'project_id IS NOT NULL') do
+ insert_missing_records
+
+ # Do this once without lock to speed up the second invocation
+ remove_duplicates
+ with_table_lock(:user_interacted_projects) do
+ remove_duplicates
+ create_unique_index
+ end
+
+ remove_without_project
+ with_table_lock(:user_interacted_projects, :projects) do
+ remove_without_project
+ create_fk :user_interacted_projects, :projects, :project_id
+ end
+
+ remove_without_user
+ with_table_lock(:user_interacted_projects, :users) do
+ remove_without_user
+ create_fk :user_interacted_projects, :users, :user_id
+ end
+ end
+
+ execute "ANALYZE user_interacted_projects"
+ end
+
+ private
+ def insert_missing_records
+ iteration = 0
+ records = 0
+ begin
+ Rails.logger.info "Building user_interacted_projects table, batch ##{iteration}"
+ result = execute <<~SQL
+ INSERT INTO user_interacted_projects (user_id, project_id)
+ SELECT e.user_id, e.project_id
+ FROM (SELECT DISTINCT author_id AS user_id, project_id FROM events WHERE project_id IS NOT NULL) AS e
+ LEFT JOIN user_interacted_projects ucp USING (user_id, project_id)
+ WHERE ucp.user_id IS NULL
+ LIMIT #{BATCH_SIZE}
+ SQL
+ iteration += 1
+ records += result.cmd_tuples
+ Rails.logger.info "Building user_interacted_projects table, batch ##{iteration} complete, created #{records} overall"
+ Kernel.sleep(SLEEP_TIME) if result.cmd_tuples > 0
+ end while result.cmd_tuples > 0
+ end
+
+ def remove_duplicates
+ execute <<~SQL
+ WITH numbered AS (select ctid, ROW_NUMBER() OVER (PARTITION BY (user_id, project_id)) as row_number, user_id, project_id from user_interacted_projects)
+ DELETE FROM user_interacted_projects WHERE ctid IN (SELECT ctid FROM numbered WHERE row_number > 1);
+ SQL
+ end
+
+ def remove_without_project
+ execute "DELETE FROM user_interacted_projects WHERE NOT EXISTS (SELECT 1 FROM projects WHERE id = user_interacted_projects.project_id)"
+ end
+
+ def remove_without_user
+ execute "DELETE FROM user_interacted_projects WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = user_interacted_projects.user_id)"
+ end
+
+ def create_fk(table, target, column)
+ return if foreign_key_exists?(table, target, column: column)
+
+ add_foreign_key table, target, column: column, on_delete: :cascade
+ end
+
+ def create_unique_index
+ return if index_exists_by_name?(:user_interacted_projects, UNIQUE_INDEX_NAME)
+
+ add_index :user_interacted_projects, [:project_id, :user_id], unique: true, name: UNIQUE_INDEX_NAME
+ end
+
+ # Protect table against concurrent data changes while still allowing reads
+ def with_table_lock(*tables)
+ ActiveRecord::Base.connection.transaction do
+ execute "LOCK TABLE #{tables.join(", ")} IN SHARE MODE"
+ yield
+ end
+ end
+
+ def with_index(*args)
+ add_concurrent_index(*args) unless index_exists?(*args)
+ yield
+ ensure
+ remove_concurrent_index(*args) if index_exists?(*args)
+ end
+ end
+
+ class MysqlStrategy < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ def up
+ execute <<~SQL
+ INSERT INTO user_interacted_projects (user_id, project_id)
+ SELECT e.user_id, e.project_id
+ FROM (SELECT DISTINCT author_id AS user_id, project_id FROM events WHERE project_id IS NOT NULL) AS e
+ LEFT JOIN user_interacted_projects ucp USING (user_id, project_id)
+ WHERE ucp.user_id IS NULL
+ SQL
+
+ unless index_exists?(:user_interacted_projects, [:project_id, :user_id])
+ add_concurrent_index :user_interacted_projects, [:project_id, :user_id], unique: true, name: UNIQUE_INDEX_NAME
+ end
+
+ unless foreign_key_exists?(:user_interacted_projects, :users, column: :user_id)
+ add_concurrent_foreign_key :user_interacted_projects, :users, column: :user_id, on_delete: :cascade
+ end
+
+ unless foreign_key_exists?(:user_interacted_projects, :projects, column: :project_id)
+ add_concurrent_foreign_key :user_interacted_projects, :projects, column: :project_id, on_delete: :cascade
+ end
+ end
+ end
+end
diff --git a/db/post_migrate/20180301084653_change_project_namespace_id_not_null.rb b/db/post_migrate/20180301084653_change_project_namespace_id_not_null.rb
new file mode 100644
index 00000000000..0342372cbed
--- /dev/null
+++ b/db/post_migrate/20180301084653_change_project_namespace_id_not_null.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 ChangeProjectNamespaceIdNotNull < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ class Project < ActiveRecord::Base
+ self.table_name = 'projects'
+ include EachBatch
+ end
+
+ BATCH_SIZE = 1000
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ Project.where(namespace_id: nil).each_batch(of: BATCH_SIZE) do |batch|
+ batch.delete_all
+ end
+
+ change_column_null :projects, :namespace_id, false
+ end
+
+ def down
+ change_column_null :projects, :namespace_id, true
+ end
+end
diff --git a/db/post_migrate/20180305100050_remove_permanent_from_redirect_routes.rb b/db/post_migrate/20180305100050_remove_permanent_from_redirect_routes.rb
new file mode 100644
index 00000000000..db5165dbe70
--- /dev/null
+++ b/db/post_migrate/20180305100050_remove_permanent_from_redirect_routes.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 RemovePermanentFromRedirectRoutes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ INDEX_NAME_PERM = "index_redirect_routes_on_path_text_pattern_ops_where_permanent"
+ INDEX_NAME_TEMP = "index_redirect_routes_on_path_text_pattern_ops_where_temporary"
+
+ def up
+ # These indexes were created on Postgres only in:
+ # ReworkRedirectRoutesIndexes:
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/16211
+ if Gitlab::Database.postgresql?
+ disable_statement_timeout
+
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME_PERM};"
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME_TEMP};"
+ end
+
+ remove_column(:redirect_routes, :permanent)
+ end
+
+ def down
+ add_column(:redirect_routes, :permanent, :boolean)
+
+ if Gitlab::Database.postgresql?
+ disable_statement_timeout
+
+ execute("CREATE INDEX CONCURRENTLY #{INDEX_NAME_PERM} ON redirect_routes (lower(path) varchar_pattern_ops) where (permanent);")
+ execute("CREATE INDEX CONCURRENTLY #{INDEX_NAME_TEMP} ON redirect_routes (lower(path) varchar_pattern_ops) where (not permanent or permanent is null) ;")
+ end
+ end
+end
diff --git a/db/post_migrate/20180306074045_migrate_create_trace_artifact_sidekiq_queue.rb b/db/post_migrate/20180306074045_migrate_create_trace_artifact_sidekiq_queue.rb
new file mode 100644
index 00000000000..0af1c3bc0a5
--- /dev/null
+++ b/db/post_migrate/20180306074045_migrate_create_trace_artifact_sidekiq_queue.rb
@@ -0,0 +1,13 @@
+class MigrateCreateTraceArtifactSidekiqQueue < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ sidekiq_queue_migrate 'pipeline_default:create_trace_artifact', to: 'pipeline_background:archive_trace'
+ end
+
+ def down
+ sidekiq_queue_migrate 'pipeline_background:archive_trace', to: 'pipeline_default:create_trace_artifact'
+ end
+end
diff --git a/db/post_migrate/20180306164012_add_path_index_to_redirect_routes.rb b/db/post_migrate/20180306164012_add_path_index_to_redirect_routes.rb
new file mode 100644
index 00000000000..d6fb4f06695
--- /dev/null
+++ b/db/post_migrate/20180306164012_add_path_index_to_redirect_routes.rb
@@ -0,0 +1,38 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddPathIndexToRedirectRoutes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_redirect_routes_on_path_unique_text_pattern_ops'
+
+ # Indexing on LOWER(path) varchar_pattern_ops speeds up the LIKE query in
+ # RedirectRoute.matching_path_and_descendants
+ #
+ # This same index is also added in the `ReworkRedirectRoutesIndexes` so this
+ # is a no-op in most cases. But this migration is also called from the
+ # `setup_postgresql.rake` task when setting up a new database, in which case
+ # we want to create the index.
+ def up
+ return unless Gitlab::Database.postgresql?
+
+ disable_statement_timeout
+
+ unless index_exists_by_name?(:redirect_routes, INDEX_NAME)
+ execute("CREATE UNIQUE INDEX CONCURRENTLY #{INDEX_NAME} ON redirect_routes (lower(path) varchar_pattern_ops);")
+ end
+ end
+
+ def down
+ # Do nothing in the DOWN. Since the index above is originally created in the
+ # `ReworkRedirectRoutesIndexes`. This migration wouldn't have actually
+ # created any new index.
+ #
+ # This migration is only here to be called form `setup_postgresql.rake` so
+ # any newly created database would have this index.
+ end
+end
diff --git a/db/post_migrate/20180307012445_migrate_update_head_pipeline_for_merge_request_sidekiq_queue.rb b/db/post_migrate/20180307012445_migrate_update_head_pipeline_for_merge_request_sidekiq_queue.rb
new file mode 100644
index 00000000000..9728df6d409
--- /dev/null
+++ b/db/post_migrate/20180307012445_migrate_update_head_pipeline_for_merge_request_sidekiq_queue.rb
@@ -0,0 +1,15 @@
+class MigrateUpdateHeadPipelineForMergeRequestSidekiqQueue < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ sidekiq_queue_migrate 'pipeline_default:update_head_pipeline_for_merge_request',
+ to: 'pipeline_processing:update_head_pipeline_for_merge_request'
+ end
+
+ def down
+ sidekiq_queue_migrate 'pipeline_processing:update_head_pipeline_for_merge_request',
+ to: 'pipeline_default:update_head_pipeline_for_merge_request'
+ end
+end
diff --git a/db/post_migrate/20180405101928_reschedule_builds_stages_migration.rb b/db/post_migrate/20180405101928_reschedule_builds_stages_migration.rb
new file mode 100644
index 00000000000..e19387bce1e
--- /dev/null
+++ b/db/post_migrate/20180405101928_reschedule_builds_stages_migration.rb
@@ -0,0 +1,33 @@
+class RescheduleBuildsStagesMigration < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ ##
+ # Rescheduled `20180212101928_schedule_build_stage_migration.rb`
+ #
+
+ DOWNTIME = false
+ MIGRATION = 'MigrateBuildStage'.freeze
+ BATCH_SIZE = 500
+
+ disable_ddl_transaction!
+
+ class Build < ActiveRecord::Base
+ include EachBatch
+ self.table_name = 'ci_builds'
+ end
+
+ def up
+ disable_statement_timeout
+
+ Build.where('stage_id IS NULL').tap do |relation|
+ queue_background_migration_jobs_by_range_at_intervals(relation,
+ MIGRATION,
+ 5.minutes,
+ batch_size: BATCH_SIZE)
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2b20628bd53..fd75b176318 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: 20171219121201) do
+ActiveRecord::Schema.define(version: 20180405142733) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -154,6 +154,10 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "gitaly_timeout_default", default: 55, null: false
t.integer "gitaly_timeout_medium", default: 30, null: false
t.integer "gitaly_timeout_fast", default: 10, null: false
+ t.boolean "authorized_keys_enabled", default: true, null: false
+ t.string "auto_devops_domain"
+ t.boolean "pages_domain_verification_enabled", default: true, null: false
+ t.boolean "allow_local_requests_from_hooks_and_services", default: false, null: false
end
create_table "audit_events", force: :cascade do |t|
@@ -180,12 +184,27 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "award_emoji", ["awardable_type", "awardable_id"], name: "index_award_emoji_on_awardable_type_and_awardable_id", using: :btree
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
+ create_table "badges", force: :cascade do |t|
+ t.string "link_url", null: false
+ t.string "image_url", null: false
+ t.integer "project_id"
+ t.integer "group_id"
+ t.string "type", null: false
+ t.datetime_with_timezone "created_at", null: false
+ t.datetime_with_timezone "updated_at", null: false
+ end
+
+ add_index "badges", ["group_id"], name: "index_badges_on_group_id", using: :btree
+ add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree
+
create_table "boards", force: :cascade do |t|
- t.integer "project_id", null: false
+ t.integer "project_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.integer "group_id"
end
+ add_index "boards", ["group_id"], name: "index_boards_on_group_id", using: :btree
add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
create_table "broadcast_messages", force: :cascade do |t|
@@ -246,6 +265,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "ci_build_trace_sections", ["build_id", "section_name_id"], name: "index_ci_build_trace_sections_on_build_id_and_section_name_id", unique: true, using: :btree
add_index "ci_build_trace_sections", ["project_id"], name: "index_ci_build_trace_sections_on_project_id", using: :btree
+ add_index "ci_build_trace_sections", ["section_name_id"], name: "index_ci_build_trace_sections_on_section_name_id", using: :btree
create_table "ci_builds", force: :cascade do |t|
t.string "status"
@@ -287,10 +307,13 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "auto_canceled_by_id"
t.boolean "retried"
t.integer "stage_id"
+ t.integer "artifacts_file_store"
+ t.integer "artifacts_metadata_store"
t.boolean "protected"
t.integer "failure_reason"
end
+ add_index "ci_builds", ["artifacts_expire_at"], name: "index_ci_builds_on_artifacts_expire_at", where: "(artifacts_file <> ''::text)", using: :btree
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
@@ -306,6 +329,16 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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_builds_metadata", force: :cascade do |t|
+ t.integer "build_id", null: false
+ t.integer "project_id", null: false
+ t.integer "timeout"
+ t.integer "timeout_source", default: 1, null: false
+ end
+
+ add_index "ci_builds_metadata", ["build_id"], name: "index_ci_builds_metadata_on_build_id", unique: true, using: :btree
+ add_index "ci_builds_metadata", ["project_id"], name: "index_ci_builds_metadata_on_project_id", using: :btree
+
create_table "ci_group_variables", force: :cascade do |t|
t.string "key", null: false
t.text "value"
@@ -324,13 +357,16 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "project_id", null: false
t.integer "job_id", null: false
t.integer "file_type", null: false
+ t.integer "file_store"
t.integer "size", limit: 8
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.datetime_with_timezone "expire_at"
t.string "file"
+ t.binary "file_sha256"
end
+ add_index "ci_job_artifacts", ["expire_at", "job_id"], name: "index_ci_job_artifacts_on_expire_at_and_job_id", using: :btree
add_index "ci_job_artifacts", ["job_id", "file_type"], name: "index_ci_job_artifacts_on_job_id_and_file_type", unique: true, using: :btree
add_index "ci_job_artifacts", ["project_id"], name: "index_ci_job_artifacts_on_project_id", using: :btree
@@ -356,7 +392,6 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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
@@ -433,6 +468,8 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.boolean "run_untagged", default: true, null: false
t.boolean "locked", default: false, null: false
t.integer "access_level", default: 0, null: false
+ t.string "ip_address"
+ t.integer "maximum_timeout"
end
add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree
@@ -450,7 +487,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "lock_version"
end
- add_index "ci_stages", ["pipeline_id", "name"], name: "index_ci_stages_on_pipeline_id_and_name", using: :btree
+ add_index "ci_stages", ["pipeline_id", "name"], name: "index_ci_stages_on_pipeline_id_and_name", unique: true, 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
@@ -466,7 +503,6 @@ ActiveRecord::Schema.define(version: 20171219121201) do
create_table "ci_triggers", force: :cascade do |t|
t.string "token"
- t.datetime "deleted_at"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "project_id"
@@ -566,8 +602,32 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.string "version", null: false
t.string "cluster_ip"
t.text "status_reason"
+ t.string "external_ip"
+ end
+
+ create_table "clusters_applications_prometheus", force: :cascade do |t|
+ t.integer "cluster_id", null: false
+ t.integer "status", null: false
+ t.string "version", null: false
+ t.text "status_reason"
+ t.datetime_with_timezone "created_at", null: false
+ t.datetime_with_timezone "updated_at", null: false
end
+ create_table "clusters_applications_runners", force: :cascade do |t|
+ t.integer "cluster_id", null: false
+ t.integer "runner_id"
+ t.integer "status", null: false
+ t.datetime_with_timezone "created_at", null: false
+ t.datetime_with_timezone "updated_at", null: false
+ t.string "version", null: false
+ t.text "status_reason"
+ t.boolean "privileged", default: true, null: false
+ end
+
+ add_index "clusters_applications_runners", ["cluster_id"], name: "index_clusters_applications_runners_on_cluster_id", unique: true, using: :btree
+ add_index "clusters_applications_runners", ["runner_id"], name: "index_clusters_applications_runners_on_runner_id", using: :btree
+
create_table "container_repositories", force: :cascade do |t|
t.integer "project_id", null: false
t.string "name", null: false
@@ -618,10 +678,24 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "project_id", null: false
t.datetime "created_at"
t.datetime "updated_at"
+ t.boolean "can_push", default: false, null: false
end
add_index "deploy_keys_projects", ["project_id"], name: "index_deploy_keys_projects_on_project_id", using: :btree
+ create_table "deploy_tokens", force: :cascade do |t|
+ t.boolean "revoked", default: false
+ t.boolean "read_repository", default: false, null: false
+ t.boolean "read_registry", default: false, null: false
+ t.datetime_with_timezone "expires_at", null: false
+ t.datetime_with_timezone "created_at", null: false
+ t.string "name", null: false
+ t.string "token", null: false
+ end
+
+ add_index "deploy_tokens", ["token", "expires_at", "id"], name: "index_deploy_tokens_on_token_and_expires_at_and_id", where: "(revoked IS FALSE)", using: :btree
+ add_index "deploy_tokens", ["token"], name: "index_deploy_tokens_on_token", unique: true, using: :btree
+
create_table "deployments", force: :cascade do |t|
t.integer "iid", null: false
t.integer "project_id", null: false
@@ -648,8 +722,8 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.datetime "created_at"
t.datetime "updated_at"
t.string "confirmation_token"
- t.datetime "confirmed_at"
- t.datetime "confirmation_sent_at"
+ t.datetime_with_timezone "confirmed_at"
+ t.datetime_with_timezone "confirmation_sent_at"
end
add_index "emails", ["confirmation_token"], name: "index_emails_on_confirmation_token", unique: true, using: :btree
@@ -681,7 +755,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
end
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", ["author_id", "project_id"], name: "index_events_on_author_id_and_project_id", using: :btree
add_index "events", ["project_id", "id"], name: "index_events_on_project_id_and_id", using: :btree
add_index "events", ["target_type", "target_id"], name: "index_events_on_target_type_and_target_id", using: :btree
@@ -820,6 +894,14 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree
+ create_table "internal_ids", id: :bigserial, force: :cascade do |t|
+ t.integer "project_id", null: false
+ t.integer "usage", null: false
+ t.integer "last_value", null: false
+ end
+
+ add_index "internal_ids", ["usage", "project_id"], name: "index_internal_ids_on_usage_and_project_id", unique: true, using: :btree
+
create_table "issue_assignees", id: false, force: :cascade do |t|
t.integer "user_id", null: false
t.integer "issue_id", null: false
@@ -851,7 +933,6 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "iid"
t.integer "updated_by_id"
t.boolean "confidential", default: false, null: false
- t.datetime "deleted_at"
t.date "due_date"
t.integer "moved_to_id"
t.integer "lock_version"
@@ -864,11 +945,11 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "last_edited_by_id"
t.boolean "discussion_locked"
t.datetime_with_timezone "closed_at"
+ t.integer "closed_by_id"
end
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", ["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", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree
add_index "issues", ["moved_to_id"], name: "index_issues_on_moved_to_id", where: "(moved_to_id IS NOT NULL)", using: :btree
@@ -879,6 +960,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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"}
+ add_index "issues", ["updated_at"], name: "index_issues_on_updated_at", using: :btree
add_index "issues", ["updated_by_id"], name: "index_issues_on_updated_by_id", where: "(updated_by_id IS NOT NULL)", using: :btree
create_table "keys", force: :cascade do |t|
@@ -890,7 +972,6 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.string "type"
t.string "fingerprint"
t.boolean "public", default: false, null: false
- t.boolean "can_push", default: false, null: false
t.datetime "last_used_at"
end
@@ -939,12 +1020,23 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "labels", ["title"], name: "index_labels_on_title", using: :btree
add_index "labels", ["type", "project_id"], name: "index_labels_on_type_and_project_id", using: :btree
+ create_table "lfs_file_locks", force: :cascade do |t|
+ t.integer "project_id", null: false
+ t.integer "user_id", null: false
+ t.datetime "created_at", null: false
+ t.string "path", limit: 511
+ end
+
+ add_index "lfs_file_locks", ["project_id", "path"], name: "index_lfs_file_locks_on_project_id_and_path", unique: true, using: :btree
+ add_index "lfs_file_locks", ["user_id"], name: "index_lfs_file_locks_on_user_id", using: :btree
+
create_table "lfs_objects", force: :cascade do |t|
t.string "oid", null: false
t.integer "size", limit: 8, null: false
t.datetime "created_at"
t.datetime "updated_at"
t.string "file"
+ t.integer "file_store"
end
add_index "lfs_objects", ["oid"], name: "index_lfs_objects_on_oid", unique: true, using: :btree
@@ -1007,6 +1099,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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
+ add_index "merge_request_diff_commits", ["sha"], name: "index_merge_request_diff_commits_on_sha", using: :btree
create_table "merge_request_diff_files", id: false, force: :cascade do |t|
t.integer "merge_request_diff_id", null: false
@@ -1034,6 +1127,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.string "real_size"
t.string "head_commit_sha"
t.string "start_commit_sha"
+ t.integer "commits_count"
end
add_index "merge_request_diffs", ["merge_request_id", "id"], name: "index_merge_request_diffs_on_merge_request_id_and_id", using: :btree
@@ -1047,6 +1141,9 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "pipeline_id"
+ t.integer "merged_by_id"
+ t.integer "latest_closed_by_id"
+ t.datetime_with_timezone "latest_closed_at"
end
add_index "merge_request_metrics", ["first_deployed_to_production_at"], name: "index_merge_request_metrics_on_first_deployed_to_production_at", using: :btree
@@ -1074,7 +1171,6 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.boolean "merge_when_pipeline_succeeds", default: false, null: false
t.integer "merge_user_id"
t.string "merge_commit_sha"
- t.datetime "deleted_at"
t.string "in_progress_merge_commit_sha"
t.integer "lock_version"
t.text "title_html"
@@ -1087,18 +1183,20 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.string "merge_jid"
t.boolean "discussion_locked"
t.integer "latest_merge_request_diff_id"
+ t.string "rebase_commit_sha"
+ t.boolean "allow_maintainer_to_push"
end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
add_index "merge_requests", ["author_id"], name: "index_merge_requests_on_author_id", using: :btree
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", ["latest_merge_request_diff_id"], name: "index_merge_requests_on_latest_merge_request_diff_id", using: :btree
add_index "merge_requests", ["merge_user_id"], name: "index_merge_requests_on_merge_user_id", where: "(merge_user_id IS NOT NULL)", 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", "source_branch"], name: "index_merge_requests_on_source_project_and_branch_state_opened", where: "((state)::text = 'opened'::text)", using: :btree
add_index "merge_requests", ["source_project_id", "source_branch"], name: "index_merge_requests_on_source_project_id_and_source_branch", using: :btree
add_index "merge_requests", ["target_branch"], name: "index_merge_requests_on_target_branch", using: :btree
add_index "merge_requests", ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid", unique: true, using: :btree
@@ -1152,7 +1250,6 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.boolean "share_with_group_lock", default: false
t.integer "visibility_level", default: 20, null: false
t.boolean "request_access_enabled", default: false, null: false
- t.datetime "deleted_at"
t.text "description_html"
t.boolean "lfs_enabled"
t.integer "parent_id"
@@ -1162,7 +1259,6 @@ ActiveRecord::Schema.define(version: 20171219121201) do
end
add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree
- add_index "namespaces", ["deleted_at"], name: "index_namespaces_on_deleted_at", using: :btree
add_index "namespaces", ["name", "parent_id"], name: "index_namespaces_on_name_and_parent_id", unique: true, using: :btree
add_index "namespaces", ["name"], name: "index_namespaces_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"}
add_index "namespaces", ["owner_id"], name: "index_namespaces_on_owner_id", using: :btree
@@ -1228,6 +1324,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.boolean "merge_merge_request"
t.boolean "failed_pipeline"
t.boolean "success_pipeline"
+ t.boolean "push_to_merge_request"
end
add_index "notification_settings", ["source_id", "source_type"], name: "index_notification_settings_on_source_id_and_source_type", using: :btree
@@ -1290,10 +1387,16 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.string "encrypted_key_iv"
t.string "encrypted_key_salt"
t.string "domain"
+ t.datetime_with_timezone "verified_at"
+ t.string "verification_code", null: false
+ t.datetime_with_timezone "enabled_until"
end
add_index "pages_domains", ["domain"], name: "index_pages_domains_on_domain", unique: true, using: :btree
+ add_index "pages_domains", ["project_id", "enabled_until"], name: "index_pages_domains_on_project_id_and_enabled_until", using: :btree
add_index "pages_domains", ["project_id"], name: "index_pages_domains_on_project_id", using: :btree
+ add_index "pages_domains", ["verified_at", "enabled_until"], name: "index_pages_domains_on_verified_at_and_enabled_until", using: :btree
+ add_index "pages_domains", ["verified_at"], name: "index_pages_domains_on_verified_at", using: :btree
create_table "personal_access_tokens", force: :cascade do |t|
t.integer "user_id", null: false
@@ -1340,6 +1443,14 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "project_custom_attributes", ["key", "value"], name: "index_project_custom_attributes_on_key_and_value", using: :btree
add_index "project_custom_attributes", ["project_id", "key"], name: "index_project_custom_attributes_on_project_id_and_key", unique: true, using: :btree
+ create_table "project_deploy_tokens", force: :cascade do |t|
+ t.integer "project_id", null: false
+ t.integer "deploy_token_id", null: false
+ t.datetime_with_timezone "created_at", null: false
+ end
+
+ add_index "project_deploy_tokens", ["project_id", "deploy_token_id"], name: "index_project_deploy_tokens_on_project_id_and_deploy_token_id", unique: true, using: :btree
+
create_table "project_features", force: :cascade do |t|
t.integer "project_id"
t.integer "merge_requests_access_level"
@@ -1396,7 +1507,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.datetime "created_at"
t.datetime "updated_at"
t.integer "creator_id"
- t.integer "namespace_id"
+ t.integer "namespace_id", null: false
t.datetime "last_activity_at"
t.string "import_url"
t.integer "visibility_level", default: 0, null: false
@@ -1438,12 +1549,15 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.boolean "repository_read_only"
t.boolean "merge_requests_ff_only_enabled", default: false
t.boolean "merge_requests_rebase_enabled", default: false, null: false
+ t.integer "jobs_cache_index"
+ t.boolean "pages_https_only", default: true
end
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
add_index "projects", ["created_at"], name: "index_projects_on_created_at", using: :btree
add_index "projects", ["creator_id"], name: "index_projects_on_creator_id", using: :btree
add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
+ add_index "projects", ["id"], name: "index_projects_on_id_partial_for_visibility", unique: true, where: "(visibility_level = ANY (ARRAY[10, 20]))", using: :btree
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
@@ -1524,12 +1638,9 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.string "path", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.boolean "permanent"
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", ["permanent"], name: "index_redirect_routes_on_permanent", using: :btree
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|
@@ -1594,6 +1705,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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
+ t.boolean "confidential_note_events", default: true
end
add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree
@@ -1670,7 +1782,9 @@ ActiveRecord::Schema.define(version: 20171219121201) do
end
add_index "taggings", ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true, using: :btree
+ add_index "taggings", ["tag_id"], name: "index_taggings_on_tag_id", using: :btree
add_index "taggings", ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context", using: :btree
+ add_index "taggings", ["taggable_id", "taggable_type"], name: "index_taggings_on_taggable_id_and_taggable_type", using: :btree
create_table "tags", force: :cascade do |t|
t.string "name"
@@ -1698,7 +1812,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.integer "project_id", null: false
t.integer "target_id"
t.string "target_type", null: false
- t.integer "author_id"
+ t.integer "author_id", null: false
t.integer "action", null: false
t.string "state", null: false
t.datetime "created_at"
@@ -1712,13 +1826,15 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree
add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree
add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree
+ add_index "todos", ["user_id", "id"], name: "index_todos_on_user_id_and_id_done", where: "((state)::text = 'done'::text)", using: :btree
+ add_index "todos", ["user_id", "id"], name: "index_todos_on_user_id_and_id_pending", where: "((state)::text = 'pending'::text)", using: :btree
add_index "todos", ["user_id"], name: "index_todos_on_user_id", using: :btree
create_table "trending_projects", force: :cascade do |t|
t.integer "project_id", null: false
end
- add_index "trending_projects", ["project_id"], name: "index_trending_projects_on_project_id", using: :btree
+ add_index "trending_projects", ["project_id"], name: "index_trending_projects_on_project_id", unique: true, using: :btree
create_table "u2f_registrations", force: :cascade do |t|
t.text "certificate"
@@ -1742,11 +1858,14 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.string "model_type"
t.string "uploader", null: false
t.datetime "created_at", null: false
+ t.string "mount_point"
+ t.string "secret"
+ t.integer "store"
end
add_index "uploads", ["checksum"], name: "index_uploads_on_checksum", using: :btree
add_index "uploads", ["model_id", "model_type"], name: "index_uploads_on_model_id_and_model_type", using: :btree
- add_index "uploads", ["path"], name: "index_uploads_on_path", using: :btree
+ add_index "uploads", ["uploader", "path"], name: "index_uploads_on_uploader_and_path", using: :btree
create_table "user_agent_details", force: :cascade do |t|
t.string "user_agent", null: false
@@ -1760,9 +1879,17 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "user_agent_details", ["subject_id", "subject_type"], name: "index_user_agent_details_on_subject_id_and_subject_type", using: :btree
+ create_table "user_callouts", force: :cascade do |t|
+ t.integer "feature_name", null: false
+ t.integer "user_id", null: false
+ end
+
+ add_index "user_callouts", ["user_id", "feature_name"], name: "index_user_callouts_on_user_id_and_feature_name", unique: true, using: :btree
+ add_index "user_callouts", ["user_id"], name: "index_user_callouts_on_user_id", using: :btree
+
create_table "user_custom_attributes", force: :cascade do |t|
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
+ t.datetime_with_timezone "created_at", null: false
+ t.datetime_with_timezone "updated_at", null: false
t.integer "user_id", null: false
t.string "key", null: false
t.string "value", null: false
@@ -1771,6 +1898,14 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_index "user_custom_attributes", ["key", "value"], name: "index_user_custom_attributes_on_key_and_value", using: :btree
add_index "user_custom_attributes", ["user_id", "key"], name: "index_user_custom_attributes_on_user_id_and_key", unique: true, using: :btree
+ create_table "user_interacted_projects", id: false, force: :cascade do |t|
+ t.integer "user_id", null: false
+ t.integer "project_id", null: false
+ end
+
+ add_index "user_interacted_projects", ["project_id", "user_id"], name: "index_user_interacted_projects_on_project_id_and_user_id", unique: true, using: :btree
+ add_index "user_interacted_projects", ["user_id"], name: "index_user_interacted_projects_on_user_id", using: :btree
+
create_table "user_synced_attributes_metadata", force: :cascade do |t|
t.boolean "name_synced", default: false
t.boolean "email_synced", default: false
@@ -1796,7 +1931,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
t.datetime "updated_at"
t.string "name"
t.boolean "admin", default: false, null: false
- t.integer "projects_limit", default: 10
+ t.integer "projects_limit", null: false
t.string "skype", default: "", null: false
t.string "linkedin", default: "", null: false
t.string "twitter", default: "", null: false
@@ -1909,11 +2044,15 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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
+ t.boolean "confidential_note_events"
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 "badges", "namespaces", column: "group_id", on_delete: :cascade
+ add_foreign_key "badges", "projects", on_delete: :cascade
+ add_foreign_key "boards", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade
@@ -1923,6 +2062,8 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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_builds_metadata", "ci_builds", column: "build_id", on_delete: :cascade
+ add_foreign_key "ci_builds_metadata", "projects", on_delete: :cascade
add_foreign_key "ci_group_variables", "namespaces", column: "group_id", name: "fk_33ae4d58d8", on_delete: :cascade
add_foreign_key "ci_job_artifacts", "ci_builds", column: "job_id", on_delete: :cascade
add_foreign_key "ci_job_artifacts", "projects", on_delete: :cascade
@@ -1946,6 +2087,10 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_foreign_key "cluster_providers_gcp", "clusters", on_delete: :cascade
add_foreign_key "clusters", "users", on_delete: :nullify
add_foreign_key "clusters_applications_helm", "clusters", on_delete: :cascade
+ add_foreign_key "clusters_applications_ingress", "clusters", name: "fk_753a7b41c1", on_delete: :cascade
+ add_foreign_key "clusters_applications_prometheus", "clusters", name: "fk_557e773639", on_delete: :cascade
+ add_foreign_key "clusters_applications_runners", "ci_runners", column: "runner_id", name: "fk_02de2ded36", on_delete: :nullify
+ add_foreign_key "clusters_applications_runners", "clusters", 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
@@ -1966,6 +2111,7 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify
add_foreign_key "gpg_signatures", "projects", on_delete: :cascade
add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade
+ add_foreign_key "internal_ids", "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
@@ -1973,18 +2119,24 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_foreign_key "issues", "milestones", name: "fk_96b1dd429c", on_delete: :nullify
add_foreign_key "issues", "projects", name: "fk_899c8f3231", on_delete: :cascade
add_foreign_key "issues", "users", column: "author_id", name: "fk_05f1e72feb", on_delete: :nullify
+ add_foreign_key "issues", "users", column: "closed_by_id", name: "fk_c63cbf6c25", on_delete: :nullify
add_foreign_key "issues", "users", column: "updated_by_id", name: "fk_ffed080f01", on_delete: :nullify
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 "labels", "projects", name: "fk_7de4989a69", on_delete: :cascade
+ add_foreign_key "lfs_file_locks", "projects", on_delete: :cascade
+ add_foreign_key "lfs_file_locks", "users", 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 "members", "users", name: "fk_2e88fb7ce9", 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_request_metrics", "users", column: "latest_closed_by_id", name: "fk_ae440388cc", on_delete: :nullify
+ add_foreign_key "merge_request_metrics", "users", column: "merged_by_id", name: "fk_7f28d925f3", on_delete: :nullify
add_foreign_key "merge_requests", "ci_pipelines", column: "head_pipeline_id", name: "fk_fd82eae0b9", on_delete: :nullify
add_foreign_key "merge_requests", "merge_request_diffs", column: "latest_merge_request_diff_id", name: "fk_06067f5644", on_delete: :nullify
add_foreign_key "merge_requests", "milestones", name: "fk_6a5165a692", on_delete: :nullify
@@ -2006,6 +2158,8 @@ ActiveRecord::Schema.define(version: 20171219121201) do
add_foreign_key "project_authorizations", "users", on_delete: :cascade
add_foreign_key "project_auto_devops", "projects", on_delete: :cascade
add_foreign_key "project_custom_attributes", "projects", on_delete: :cascade
+ add_foreign_key "project_deploy_tokens", "deploy_tokens", on_delete: :cascade
+ add_foreign_key "project_deploy_tokens", "projects", 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
@@ -2025,10 +2179,16 @@ ActiveRecord::Schema.define(version: 20171219121201) do
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", "notes", name: "fk_91d1f47b13", on_delete: :cascade
add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade
+ add_foreign_key "todos", "users", column: "author_id", name: "fk_ccf0373936", on_delete: :cascade
+ add_foreign_key "todos", "users", name: "fk_d94154aa95", on_delete: :cascade
add_foreign_key "trending_projects", "projects", on_delete: :cascade
add_foreign_key "u2f_registrations", "users"
+ add_foreign_key "user_callouts", "users", on_delete: :cascade
add_foreign_key "user_custom_attributes", "users", on_delete: :cascade
+ add_foreign_key "user_interacted_projects", "projects", name: "fk_722ceba4f7", on_delete: :cascade
+ add_foreign_key "user_interacted_projects", "users", name: "fk_0894651f08", on_delete: :cascade
add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade
add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade
add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade
diff --git a/doc/README.md b/doc/README.md
index 11d52001440..604f7244a34 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -1,5 +1,4 @@
---
-toc: false
comments: false
---
@@ -8,51 +7,66 @@ comments: false
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 offers the most scalable Git-based fully integrated platform for
+software development, with flexible products and subscriptions.
+To understand what features you have access to, check the [GitLab subscriptions](#gitlab-subscriptions) below.
-- **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)**, **GitLab Enterprise Edition Premium (EEP)**, and **GitLab Enterprise Edition Ultimate (EEU)**.
-- **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).
+**Shortcuts to GitLab's most visited docs:**
-> **GitLab EE** contains all features available in **GitLab CE**,
-plus premium features available in each version: **Enterprise Edition Starter**
-(**EES**), **Enterprise Edition Premium** (**EEP**), and **Enterprise Edition Ultimate**
-(**EEU**). Everything available in **EES** is also available in **EEP**. Every feature
-available in **EEP** is also available in **EEU**.
-
-----
-
-Shortcuts to GitLab's most visited docs:
-
-| [GitLab CI/CD](ci/README.md) | Other |
+| General documentation | GitLab CI/CD docs |
| :----- | :----- |
-| [Quick start guide](ci/quick_start/README.md) | [API](api/README.md) |
-| [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) | [GitLab CI/CD](ci/README.md) |
+| [Administrator documentation](administration/index.md) | [GitLab CI/CD quick start guide](ci/quick_start/README.md) |
+| [Contributor documentation](#contributor-documentation) | [Configuring `.gitlab-ci.yml`](ci/yaml/README.md) |
+| [Getting started with GitLab](#getting-started-with-gitlab) | [Using Docker images](ci/docker/using_docker_images.md) |
+| [API](api/README.md) | [Auto DevOps](topics/autodevops/index.md) |
+| [SSH authentication](ssh/README.md) | [Kubernetes integration](user/project/clusters/index.md)|
+| [GitLab Pages](user/project/pages/index.md) | [GitLab Container Registry](user/project/container_registry.md) |
+
+## Complete DevOps with GitLab
+
+GitLab is the first single application for software development, security,
+and operations that enables Concurrent DevOps, making the software lifecycle
+three times faster and radically improving the speed of business. GitLab
+provides solutions for all the stages of the DevOps lifecycle:
+[plan](#plan), [create](#create), [verify](#verify), [package](#package),
+[release](#release), [configure](#configure), [monitor](#monitor).
+
+![DevOps Lifecycle](img/devops_lifecycle.png)
+
+### Plan
+
+Whether you use Waterfall, Agile, or Conversational Development,
+GitLab streamlines your collaborative workflows. Visualize, prioritize,
+coordinate, and track your progress your way with GitLab’s flexible project
+management tools.
+
+- Chat operations
+ - [Mattermost slash commands](user/project/integrations/mattermost_slash_commands.md)
+ - [Slack slash commands](user/project/integrations/slack_slash_commands.md)
+- [Discussions](user/discussions/index.md): Threads, comments, and resolvable discussions in issues, commits, and merge requests.
+- [Issues](user/project/issues/index.md)
+- [Project 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.
+- [Milestones](user/project/milestones/index.md): Organize issues and merge requests into a cohesive group, optionally setting a due date.
+- [Todos](workflow/todos.md): A chronological list of to-dos that are waiting for your input, all in a simple dashboard.
+- [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 documentation](user/index.md)
-- [Administrator documentation](administration/index.md)
-- [Contributor documentation](#contributor-documentation)
+#### Migrate and import your projects from other platforms
-## Getting started with 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 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 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.
-- [Auto DevOps](topics/autodevops/index.md)
+### Create
-### User account
+Consolidate source code into a single [DVCS](https://en.wikipedia.org/wiki/Distributed_version_control)
+that’s easily managed and controlled without disrupting your workflow.
+GitLab’s git repositories come complete with branching tools and access
+controls, providing a scalable, single source of truth for collaborating
+on projects and code.
-- [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
+#### Projects and groups
- [Projects](user/project/index.md):
- [Project settings](user/project/settings/index.md)
@@ -67,7 +81,7 @@ Shortcuts to GitLab's most visited docs:
- [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
+#### Repositories
Manage your [repositories](user/project/repository/index.md) from the UI (user interface):
@@ -85,73 +99,142 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i
- [Commits](user/project/repository/index.md#commits)
- [Signing commits](user/project/repository/gpg_signed_commits/index.md): use GPG to sign your commits.
-### Issues and Merge Requests (MRs)
+#### Integrations
+
+- [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
+
+#### Automation
+
+- [API](api/README.md): Automate GitLab via a simple and powerful API.
+- [GitLab Webhooks](user/project/integrations/webhooks.md): Let GitLab notify you when new code has been pushed to your project.
+
+### Verify
+
+Spot errors sooner and shorten feedback cycles with built-in code review, code testing,
+Code Quality, and Review Apps. Customize your approval workflow controls, automatically
+test the quality of your code, and spin up a staging environment for every code change.
+GitLab Continuous Integration is the most popular next generation testing system that
+auto scales to run your tests faster.
-- [Discussions](user/discussions/index.md): Threads, comments, and resolvable discussions in issues, commits, and merge requests.
-- [Issues](user/project/issues/index.md)
-- [Project 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)
- [Work In Progress Merge Requests](user/project/merge_requests/work_in_progress_merge_requests.md)
- [Merge Request discussion resolution](user/discussions/index.md#moving-a-single-discussion-to-a-new-issue): Resolve discussions, move discussions in a merge request to an issue, only allow merge requests to be merged if all discussions are resolved.
- [Checkout merge requests locally](user/project/merge_requests/index.md#checkout-merge-requests-locally)
- [Cherry-pick](user/project/merge_requests/cherry_pick_changes.md)
-- [Milestones](user/project/milestones/index.md): Organize issues and merge requests into a cohesive group, optionally setting a due date.
-- [Todos](workflow/todos.md): A chronological list of to-dos that are waiting for your input, all in a simple dashboard.
+- [Review Apps](ci/review_apps/index.md): Preview changes to your app right from a merge request.
-### Git and GitLab
+### Package
-- [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.
+GitLab Container Registry gives you the enhanced security and access controls of
+custom Docker images without 3rd party add-ons. Easily upload and download images
+from GitLab CI/CD with full Git repository management integration.
-### Migrate and import your projects from other platforms
+- [GitLab CI/CD](ci/README.md): Explore the features and capabilities of Continuous Integration, Continuous Delivery, and Continuous Deployment with GitLab.
+- [GitLab Container Registry](user/project/container_registry.md): Learn how to use GitLab's built-in Container Registry.
-- [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.
+### Release
+
+Spend less time configuring your tools, and more time creating. Whether you’re
+deploying to one server or thousands, build, test, and release your code
+confidently and securely with GitLab’s built-in Continuous Delivery and Deployment.
+
+- [GitLab Pages](user/project/pages/index.md): Build, test, and deploy a static site directly from GitLab.
+- [Auto Deploy](topics/autodevops/index.md#auto-deploy): Configure GitLab CI for the deployment of your application.
+- [Environments and deployments](ci/environments.md): With environments, you can control the continuous deployment of your software within GitLab.
+
+### Configure
-### Continuous Integration, Delivery, and Deployment
+Automate your entire workflow from build to deploy and monitoring with GitLab
+Auto Devops. Best practice templates get you started with minimal to zero
+configuration. Then customize everything from buildpacks to CI/CD.
+
+- [Auto DevOps](topics/autodevops/index.md)
+
+### Monitor
+
+Measure how long it takes to go from planning to monitoring and ensure your
+applications are always responsive and available. GitLab collects and displays
+performance metrics for deployed apps using Prometheus so you can know in an
+instant how code changes impact your production environment.
-- [GitLab CI](ci/README.md): Explore the features and capabilities of Continuous Integration, Continuous Delivery, and Continuous Deployment with GitLab.
- - [Auto Deploy](ci/autodeploy/index.md): Configure GitLab CI for the deployment of your application.
- - [Review Apps](ci/review_apps/index.md): Preview changes to your app right from a merge request.
- [GitLab Cycle Analytics](user/project/cycle_analytics.md): Cycle Analytics measures the time it takes to go from an [idea to production](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab) for each project you have.
-- [GitLab Container Registry](user/project/container_registry.md): Learn how to use GitLab's built-in Container Registry.
+- [GitLab Performance Monitoring](administration/monitoring/performance/index.md)
-### Automation
+## Getting started with GitLab
-- [API](api/README.md): Automate GitLab via a simple and powerful API.
-- [GitLab Webhooks](user/project/integrations/webhooks.md): Let GitLab notify you when new code has been pushed to your project.
+- [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).
-### Integrations
+### User account
-- [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
+- [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.
-----
+### Git and GitLab
+
+- [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.
## Administrator documentation
[Administration documentation](administration/index.md) applies to admin users of GitLab
-self-hosted instances:
-
-- GitLab Community Edition
-- GitLab [Enterprise Editions](https://about.gitlab.com/gitlab-ee/)
- - Enterprise Edition Starter (EES)
- - Enterprise Edition Premium (EEP)
- - Enterprise Edition Ultimate (EEU)
+self-hosted instances.
Learn how to install, configure, update, upgrade, integrate, and maintain your own instance.
Regular users don't have access to GitLab administration tools and settings.
## Contributor documentation
-GitLab Community Edition is [opensource](https://gitlab.com/gitlab-org/gitlab-ce/)
-and Enterprise Editions are [opencore](https://gitlab.com/gitlab-org/gitlab-ee/).
+GitLab Community Edition is [open source](https://gitlab.com/gitlab-org/gitlab-ce/)
+and GitLab Enterprise Edition is [open-core](https://gitlab.com/gitlab-org/gitlab-ee/).
Learn how to contribute to GitLab:
- [Development](development/README.md): All styleguides and explanations how to contribute.
- [Legal](legal/README.md): Contributor license agreements.
- [Writing documentation](development/writing_documentation.md): Contributing to GitLab Docs.
+
+## GitLab subscriptions
+
+You have two options to use GitLab:
+
+- GitLab self-hosted: Install, administer, and maintain your own GitLab instance.
+- GitLab.com: GitLab's SaaS offering. You don't need to install anything to use GitLab.com,
+you only need to [sign up](https://gitlab.com/users/sign_in) and start using GitLab
+straight away.
+
+### GitLab self-hosted
+
+With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Core, Starter, Premium, and Ultimate.
+
+Every feature available in Core is also available in Starter, Premium, and Ultimate.
+Starter features are also available in Premium and Ultimate, and Premium features are also
+available in Ultimate.
+
+### GitLab.com
+
+GitLab.com is hosted, managed, and administered by GitLab, Inc., with
+[free and paid subscriptions](https://about.gitlab.com/gitlab-com/) for individuals
+and teams: Free, Bronze, Silver, and Gold.
+
+GitLab.com subscriptions grants access
+to the same features available in GitLab self-hosted, **expect
+[administration](administration/index.md) tools and settings**:
+
+- GitLab.com Free includes the same features available in Core
+- GitLab.com Bronze includes the same features available in GitLab Starter
+- GitLab.com Silver includes the same features available in GitLab Premium
+- GitLab.com Gold includes the same features available in GitLab Ultimate
+
+For supporting the open source community and encouraging the development of
+open source projects, GitLab grants access to **Gold** features
+for all GitLab.com **public** projects, regardless of the subscription.
+
+To know more about GitLab subscriptions and licensing, please refer to the
+[GitLab Product Marketing Handbook](https://about.gitlab.com/handbook/marketing/product-marketing/#tiers).
diff --git a/doc/administration/auth/crowd.md b/doc/administration/auth/crowd.md
index 2c289c67a6d..6db74958d5a 100644
--- a/doc/administration/auth/crowd.md
+++ b/doc/administration/auth/crowd.md
@@ -66,3 +66,15 @@ On the sign in page there should now be a Crowd tab in the sign in form.
[reconfigure]: ../restart_gitlab.md#omnibus-gitlab-reconfigure
[restart]: ../restart_gitlab.md#installations-from-source
+
+## Troubleshooting
+
+If you see an error message like the one below when you sign in after Crowd authentication is configured, you may want to consult the Crowd administrator for the Crowd log file to know the exact cause:
+
+```
+could not authorize you from Crowd because invalid credentials
+```
+
+Please make sure the Crowd users who need to login to GitLab are authorized to [the application](#configure-a-new-crowd-application) in the step of **Authorisation**. This could be verified by try "Authentication test" for Crowd as of 2.11.
+
+![Example Crowd application authorisation configuration](img/crowd_application_authorisation.png) \ No newline at end of file
diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png b/doc/administration/auth/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png
index 11ce324f938..11ce324f938 100644
--- a/doc/articles/how_to_configure_ldap_gitlab_ce/img/gitlab_ou.png
+++ b/doc/administration/auth/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/administration/auth/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif
index a6727a3d85f..a6727a3d85f 100644
--- a/doc/articles/how_to_configure_ldap_gitlab_ce/img/ldap_ou.gif
+++ b/doc/administration/auth/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/administration/auth/how_to_configure_ldap_gitlab_ce/img/user_auth.gif
index 36e6085259f..36e6085259f 100644
--- a/doc/articles/how_to_configure_ldap_gitlab_ce/img/user_auth.gif
+++ b/doc/administration/auth/how_to_configure_ldap_gitlab_ce/img/user_auth.gif
Binary files differ
diff --git a/doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md b/doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md
new file mode 100644
index 00000000000..aa5e9513290
--- /dev/null
+++ b/doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md
@@ -0,0 +1,270 @@
+---
+author: Chris Wilson
+author_gitlab: MrChrisW
+level: intermediary
+article_type: admin guide
+date: 2017-05-03
+---
+
+# How to configure LDAP with GitLab CE
+
+## 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](../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/administration/auth/how_to_configure_ldap_gitlab_ee/) for an overview.
diff --git a/doc/administration/auth/img/crowd_application_authorisation.png b/doc/administration/auth/img/crowd_application_authorisation.png
new file mode 100644
index 00000000000..70339891b34
--- /dev/null
+++ b/doc/administration/auth/img/crowd_application_authorisation.png
Binary files differ
diff --git a/doc/administration/auth/jwt.md b/doc/administration/auth/jwt.md
new file mode 100644
index 00000000000..b51e705ab52
--- /dev/null
+++ b/doc/administration/auth/jwt.md
@@ -0,0 +1,72 @@
+# JWT OmniAuth provider
+
+To enable the JWT OmniAuth provider, you must register your application with JWT.
+JWT will provide you with a secret key for you to use.
+
+1. On your GitLab server, open the configuration file.
+
+ For Omnibus GitLab:
+
+ ```sh
+ sudo editor /etc/gitlab/gitlab.rb
+ ```
+
+ For installations from source:
+
+ ```sh
+ cd /home/git/gitlab
+ sudo -u git -H editor config/gitlab.yml
+ ```
+
+1. See [Initial OmniAuth Configuration](../../integration/omniauth.md#initial-omniauth-configuration) for initial settings.
+1. Add the provider configuration.
+
+ For Omnibus GitLab:
+
+ ```ruby
+ gitlab_rails['omniauth_providers'] = [
+ { name: 'jwt',
+ app_secret: 'YOUR_APP_SECRET',
+ args: {
+ algorithm: 'HS256',
+ uid_claim: 'email',
+ required_claims: ["name", "email"],
+ info_maps: { name: "name", email: "email" },
+ auth_url: 'https://example.com/',
+ valid_within: nil,
+ }
+ }
+ ]
+ ```
+
+ For installation from source:
+
+ ```
+ - { name: 'jwt',
+ app_secret: 'YOUR_APP_SECRET',
+ args: {
+ algorithm: 'HS256',
+ uid_claim: 'email',
+ required_claims: ["name", "email"],
+ info_map: { name: "name", email: "email" },
+ auth_url: 'https://example.com/',
+ valid_within: nil,
+ }
+ }
+ ```
+
+ NOTE: **Note:** For more information on each configuration option refer to
+ the [OmniAuth JWT usage documentation](https://github.com/mbleigh/omniauth-jwt#usage).
+
+1. Change `YOUR_APP_SECRET` to the client secret and set `auth_url` to your redirect URL.
+1. Save the configuration file.
+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 JWT icon below the regular sign in form.
+Click the icon to begin the authentication process. JWT will ask the user to
+sign in and authorize the GitLab application. If everything goes well, the user
+will be redirected to GitLab and will be signed in.
+
+[reconfigure GitLab]: ../restart_gitlab.md#omnibus-gitlab-reconfigure
+[restart GitLab]: ../restart_gitlab.md#installations-from-source
diff --git a/doc/administration/auth/ldap.md b/doc/administration/auth/ldap.md
index 6b5a0f139c5..63fbb24bac1 100644
--- a/doc/administration/auth/ldap.md
+++ b/doc/administration/auth/ldap.md
@@ -38,6 +38,9 @@ in the application settings.
## Configuration
+For a complete guide on configuring LDAP with GitLab Community Edition, please check
+the admin guide [How to configure LDAP with GitLab CE](how_to_configure_ldap_gitlab_ce/index.md).
+
To enable LDAP integration you need to add your LDAP server settings in
`/etc/gitlab/gitlab.rb` or `/home/git/gitlab/config/gitlab.yml`.
@@ -178,6 +181,10 @@ main: # 'main' is the GitLab 'provider ID' of this LDAP server
first_name: 'givenName'
last_name: 'sn'
+ # If lowercase_usernames is enabled, GitLab will lower case the username.
+ lowercase_usernames: false
+
+
## EE only
# Base where we can search for groups
@@ -287,6 +294,41 @@ 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.
+## Enabling LDAP username lowercase
+
+Some LDAP servers, depending on their configurations, can return uppercase usernames. This can lead to several confusing issues like, for example, creating links or namespaces with uppercase names.
+
+GitLab can automatically lowercase usernames provided by the LDAP server by enabling
+the configuration option `lowercase_usernames`. By default, this configuration option is `false`.
+
+**Omnibus configuration**
+
+1. Edit `/etc/gitlab/gitlab.rb`:
+
+ ```ruby
+ gitlab_rails['ldap_servers'] = YAML.load <<-EOS
+ main:
+ # snip...
+ lowercase_usernames: true
+ EOS
+ ```
+
+2. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
+
+**Source configuration**
+
+1. Edit `config/gitlab.yaml`:
+
+ ```yaml
+ production:
+ ldap:
+ servers:
+ main:
+ # snip...
+ lowercase_usernames: true
+ ```
+2. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
+
## Encryption
### TLS Server Authentication
diff --git a/doc/administration/auth/okta.md b/doc/administration/auth/okta.md
index cb42b7743c5..664657650d4 100644
--- a/doc/administration/auth/okta.md
+++ b/doc/administration/auth/okta.md
@@ -144,7 +144,7 @@ Now that the Okta app is configured, it's time to enable it in GitLab.
1. [Reconfigure][reconf] or [restart] GitLab for Omnibus and installations
from source respectively for the changes to take effect.
-You might want to try this out on a incognito browser window.
+You might want to try this out on an incognito browser window.
## Configuring groups
diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md
index 57e54815b68..2441ff85783 100644
--- a/doc/administration/container_registry.md
+++ b/doc/administration/container_registry.md
@@ -483,7 +483,7 @@ You can use GitLab as an auth endpoint and use a non-bundled Container Registry.
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
+ When using a non-bundled Container Registry, you will need to supply a
custom certificate key. To do that, add the following to
`/etc/gitlab/gitlab.rb`
diff --git a/doc/administration/custom_hooks.md b/doc/administration/custom_hooks.md
index 4d35b20d0c3..960970aea30 100644
--- a/doc/administration/custom_hooks.md
+++ b/doc/administration/custom_hooks.md
@@ -4,8 +4,9 @@
**Note:** Custom Git hooks must be configured on the filesystem of the GitLab
server. Only GitLab server administrators will be able to complete these tasks.
Please explore [webhooks] as an option if you do not
-have filesystem access. For a user configurable Git hook interface, please see
-[GitLab Enterprise Edition Git Hooks](http://docs.gitlab.com/ee/git_hooks/git_hooks.html).
+have filesystem access. For a user configurable Git hook interface, see
+[Push Rules](https://docs.gitlab.com/ee/push_rules/push_rules.html),
+available in GitLab Enterprise Edition.
Git natively supports hooks that are executed on different actions.
Examples of server-side git hooks include pre-receive, post-receive, and update.
diff --git a/doc/administration/environment_variables.md b/doc/administration/environment_variables.md
index 9bcd13a52f7..e6c8f59549f 100644
--- a/doc/administration/environment_variables.md
+++ b/doc/administration/environment_variables.md
@@ -13,7 +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_CDN_HOST` | string | Sets the base URL 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`
diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md
index e3b10119090..d9a61aea6ef 100644
--- a/doc/administration/gitaly/index.md
+++ b/doc/administration/gitaly/index.md
@@ -2,7 +2,9 @@
[Gitaly](https://gitlab.com/gitlab-org/gitaly) (introduced in GitLab
9.0) is a service that provides high-level RPC access to Git
-repositories. Gitaly is a mandatory component in GitLab 9.4 and newer.
+repositories. Gitaly was optional when it was first introduced in
+GitLab, but since GitLab 9.4 it is a mandatory component of the
+application.
GitLab components that access Git repositories (gitlab-rails,
gitlab-shell, gitlab-workhorse) act as clients to Gitaly. End users do
@@ -184,14 +186,20 @@ Gitaly logs on your Gitaly server (`sudo gitlab-ctl tail gitaly` or
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
+## Disabling or enabling the Gitaly service in a cluster environment
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`:
+> 'Disabling Gitaly' only makes sense when you run GitLab in a custom
+cluster configuration, where different services run on different
+machines. Disabling Gitaly on all machines in the cluster is not a
+valid configuration.
+
+If you are setting up a GitLab cluster where Gitaly does not need to
+run on all machines, you can disable the Gitaly service in your
+Omnibus installation, add the following line to `/etc/gitlab/gitlab.rb`:
```ruby
gitaly['enable'] = false
@@ -200,11 +208,13 @@ gitaly['enable'] = false
When you run `gitlab-ctl reconfigure` the Gitaly service will be
disabled.
-To disable the Gitaly service in an installation from source, add the
-following to `/etc/default/gitlab`:
+To disable the Gitaly service in a GitLab cluster where you installed
+GitLab from source, add the following to `/etc/default/gitlab` on the
+machine where you want to disable Gitaly.
```shell
gitaly_enabled=false
```
-When you run `service gitlab restart` Gitaly will be disabled.
+When you run `service gitlab restart` Gitaly will be disabled on this
+particular machine.
diff --git a/doc/administration/high_availability/gitlab.md b/doc/administration/high_availability/gitlab.md
index b85a166089d..e201848791c 100644
--- a/doc/administration/high_availability/gitlab.md
+++ b/doc/administration/high_availability/gitlab.md
@@ -25,11 +25,11 @@ for each GitLab application server in your environment.
options. Here is an example snippet to add to `/etc/fstab`:
```
- 10.1.0.1:/var/opt/gitlab/.ssh /var/opt/gitlab/.ssh nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nobootwait,lookupcache=positive 0 2
- 10.1.0.1:/var/opt/gitlab/gitlab-rails/uploads /var/opt/gitlab/gitlab-rails/uploads nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nobootwait,lookupcache=positive 0 2
- 10.1.0.1:/var/opt/gitlab/gitlab-rails/shared /var/opt/gitlab/gitlab-rails/shared nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nobootwait,lookupcache=positive 0 2
- 10.1.0.1:/var/opt/gitlab/gitlab-ci/builds /var/opt/gitlab/gitlab-ci/builds nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nobootwait,lookupcache=positive 0 2
- 10.1.1.1:/var/opt/gitlab/git-data /var/opt/gitlab/git-data nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nobootwait,lookupcache=positive 0 2
+ 10.1.0.1:/var/opt/gitlab/.ssh /var/opt/gitlab/.ssh nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2
+ 10.1.0.1:/var/opt/gitlab/gitlab-rails/uploads /var/opt/gitlab/gitlab-rails/uploads nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2
+ 10.1.0.1:/var/opt/gitlab/gitlab-rails/shared /var/opt/gitlab/gitlab-rails/shared nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2
+ 10.1.0.1:/var/opt/gitlab/gitlab-ci/builds /var/opt/gitlab/gitlab-ci/builds nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2
+ 10.1.0.1:/var/opt/gitlab/git-data /var/opt/gitlab/git-data nfs defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2
```
1. Create the shared directories. These may be different depending on your NFS
diff --git a/doc/administration/high_availability/nfs.md b/doc/administration/high_availability/nfs.md
index e09ccaba08c..d8928a7fe4c 100644
--- a/doc/administration/high_availability/nfs.md
+++ b/doc/administration/high_availability/nfs.md
@@ -32,7 +32,9 @@ options:
## AWS Elastic File System
-GitLab does not recommend using AWS Elastic File System (EFS).
+GitLab strongly recommends against using AWS Elastic File System (EFS).
+Our support team will not be able to assist on performance issues related to
+file system access.
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
diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md
index 0e92f7c5a34..fd2677996b1 100644
--- a/doc/administration/high_availability/redis.md
+++ b/doc/administration/high_availability/redis.md
@@ -154,7 +154,7 @@ who will take all the decisions to restore the service availability by:
- Reconfigure the old **Master** and demote to **Slave** when it comes back online
You must have at least `3` Redis Sentinel servers, and they need to
-be each in a independent machine (that are believed to fail independently),
+be each in an independent machine (that are believed to fail independently),
ideally in different geographical areas.
You can configure them in the same machines where you've configured the other
diff --git a/doc/administration/img/circuitbreaker_config.png b/doc/administration/img/circuitbreaker_config.png
index e811d173634..693b2ee9c69 100644
--- a/doc/administration/img/circuitbreaker_config.png
+++ b/doc/administration/img/circuitbreaker_config.png
Binary files differ
diff --git a/doc/administration/img/repository_storages_admin_ui.png b/doc/administration/img/repository_storages_admin_ui.png
index 3e76c5b282c..036e708cdac 100644
--- a/doc/administration/img/repository_storages_admin_ui.png
+++ b/doc/administration/img/repository_storages_admin_ui.png
Binary files differ
diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md
new file mode 100644
index 00000000000..27a3710632d
--- /dev/null
+++ b/doc/administration/incoming_email.md
@@ -0,0 +1,332 @@
+# Incoming email
+
+GitLab has several features based on receiving incoming emails:
+
+- [Reply by Email](reply_by_email.md): allow GitLab users to comment on issues
+ and merge requests by replying to notification emails.
+- [New issue by email](../user/project/issues/create_new_issue.md#new-issue-via-email):
+ allow GitLab users to create a new issue by sending an email to a
+ user-specific email address.
+- [New merge request by email](../user/project/merge_requests/index.md#create-new-merge-requests-by-email):
+ allow GitLab users to create a new merge request by sending an email to a
+ user-specific email address.
+
+## Requirements
+
+Handling incoming emails requires an [IMAP]-enabled email account. GitLab
+requires one of the following three strategies:
+
+- Email sub-addressing
+- Dedicated email address
+- Catch-all mailbox
+
+Let's walk through each of these options.
+
+**If your provider or server supports email sub-addressing, we recommend using it.
+Most features (other than reply by email) only work with sub-addressing.**
+
+[IMAP]: https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol
+
+### Email sub-addressing
+
+[Sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) is
+a feature where any email to `user+some_arbitrary_tag@example.com` will end up
+in the mailbox for `user@example.com`, and is supported by providers such as
+Gmail, Google Apps, Yahoo! Mail, Outlook.com and iCloud, as well as the
+[Postfix mail server] which you can run on-premises.
+
+[Postfix mail server]: reply_by_email_postfix_setup.md
+
+### Dedicated email address
+
+This solution is really simple to set up: you just have to create an email
+address dedicated to receive your users' replies to GitLab notifications.
+
+### Catch-all mailbox
+
+A [catch-all mailbox](https://en.wikipedia.org/wiki/Catch-all) for a domain will
+"catch all" the emails addressed to the domain that do not exist in the mail
+server.
+
+GitLab can be set up to allow users to comment on issues and merge requests by
+replying to notification emails.
+
+## Set it up
+
+If you want to use Gmail / Google Apps for incoming emails, make sure you have
+[IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018)
+and [allowed less secure apps to access the account](https://support.google.com/accounts/answer/6010255)
+or [turn-on 2-step validation](https://support.google.com/accounts/answer/185839)
+and use [an application password](https://support.google.com/mail/answer/185833).
+
+To set up a basic Postfix mail server with IMAP access on Ubuntu, follow the
+[Postfix setup documentation](reply_by_email_postfix_setup.md).
+
+### Security Concerns
+
+**WARNING:** Be careful when choosing the domain used for receiving incoming
+email.
+
+For the sake of example, suppose your top-level company domain is `hooli.com`.
+All employees in your company have an email address at that domain via Google
+Apps, and your company's private Slack instance requires a valid `@hooli.com`
+email address in order to sign up.
+
+If you also host a public-facing GitLab instance at `hooli.com` and set your
+incoming email domain to `hooli.com`, an attacker could abuse the "Create new
+issue by email" or
+"[Create new merge request by email](../user/project/merge_requests/index.md#create-new-merge-requests-by-email)"
+features by using a project's unique address as the email when signing up for
+Slack, which would send a confirmation email, which would create a new issue or
+merge request on the project owned by the attacker, allowing them to click the
+confirmation link and validate their account on your company's private Slack
+instance.
+
+We recommend receiving incoming email on a subdomain, such as
+`incoming.hooli.com`, and ensuring that you do not employ any services that
+authenticate solely based on access to an email domain such as `*.hooli.com.`
+Alternatively, use a dedicated domain for GitLab email communications such as
+`hooli-gitlab.com`.
+
+See GitLab issue [#30366](https://gitlab.com/gitlab-org/gitlab-ce/issues/30366)
+for a real-world example of this exploit.
+
+### Omnibus package installations
+
+1. Find the `incoming_email` section in `/etc/gitlab/gitlab.rb`, enable the
+ feature and fill in the details for your specific IMAP server and email account:
+
+ Configuration for Postfix mail server, assumes mailbox
+ incoming@gitlab.example.com
+
+ ```ruby
+ gitlab_rails['incoming_email_enabled'] = true
+
+ # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
+ # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
+ gitlab_rails['incoming_email_address'] = "incoming+%{key}@gitlab.example.com"
+
+ # Email account username
+ # With third party providers, this is usually the full email address.
+ # With self-hosted email servers, this is usually the user part of the email address.
+ gitlab_rails['incoming_email_email'] = "incoming"
+ # Email account password
+ gitlab_rails['incoming_email_password'] = "[REDACTED]"
+
+ # IMAP server host
+ gitlab_rails['incoming_email_host'] = "gitlab.example.com"
+ # IMAP server port
+ gitlab_rails['incoming_email_port'] = 143
+ # Whether the IMAP server uses SSL
+ gitlab_rails['incoming_email_ssl'] = false
+ # Whether the IMAP server uses StartTLS
+ gitlab_rails['incoming_email_start_tls'] = false
+
+ # The mailbox where incoming mail will end up. Usually "inbox".
+ gitlab_rails['incoming_email_mailbox_name'] = "inbox"
+ # The IDLE command timeout.
+ gitlab_rails['incoming_email_idle_timeout'] = 60
+ ```
+
+ Configuration for Gmail / Google Apps, assumes mailbox
+ gitlab-incoming@gmail.com
+
+ ```ruby
+ gitlab_rails['incoming_email_enabled'] = true
+
+ # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
+ # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
+ gitlab_rails['incoming_email_address'] = "gitlab-incoming+%{key}@gmail.com"
+
+ # Email account username
+ # With third party providers, this is usually the full email address.
+ # With self-hosted email servers, this is usually the user part of the email address.
+ gitlab_rails['incoming_email_email'] = "gitlab-incoming@gmail.com"
+ # Email account password
+ gitlab_rails['incoming_email_password'] = "[REDACTED]"
+
+ # IMAP server host
+ gitlab_rails['incoming_email_host'] = "imap.gmail.com"
+ # IMAP server port
+ gitlab_rails['incoming_email_port'] = 993
+ # Whether the IMAP server uses SSL
+ gitlab_rails['incoming_email_ssl'] = true
+ # Whether the IMAP server uses StartTLS
+ gitlab_rails['incoming_email_start_tls'] = false
+
+ # The mailbox where incoming mail will end up. Usually "inbox".
+ gitlab_rails['incoming_email_mailbox_name'] = "inbox"
+ # The IDLE command timeout.
+ gitlab_rails['incoming_email_idle_timeout'] = 60
+ ```
+
+ Configuration for Microsoft Exchange mail server w/ IMAP enabled, assumes
+ mailbox incoming@exchange.example.com
+
+ ```ruby
+ gitlab_rails['incoming_email_enabled'] = true
+
+ # The email address replies are sent to - Exchange does not support sub-addressing so %{key} is not used here
+ gitlab_rails['incoming_email_address'] = "incoming@exchange.example.com"
+
+ # Email account username
+ # Typically this is the userPrincipalName (UPN)
+ gitlab_rails['incoming_email_email'] = "incoming@ad-domain.example.com"
+ # Email account password
+ gitlab_rails['incoming_email_password'] = "[REDACTED]"
+
+ # IMAP server host
+ gitlab_rails['incoming_email_host'] = "exchange.example.com"
+ # IMAP server port
+ gitlab_rails['incoming_email_port'] = 993
+ # Whether the IMAP server uses SSL
+ gitlab_rails['incoming_email_ssl'] = true
+ ```
+
+1. Reconfigure GitLab for the changes to take effect:
+
+ ```sh
+ sudo gitlab-ctl reconfigure
+ sudo gitlab-ctl restart
+ ```
+
+1. Verify that everything is configured correctly:
+
+ ```sh
+ sudo gitlab-rake gitlab:incoming_email:check
+ ```
+
+1. Reply by email should now be working.
+
+### Installations from source
+
+1. Go to the GitLab installation directory:
+
+ ```sh
+ cd /home/git/gitlab
+ ```
+
+1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature
+ and fill in the details for your specific IMAP server and email account:
+
+ ```sh
+ sudo editor config/gitlab.yml
+ ```
+
+ Configuration for Postfix mail server, assumes mailbox
+ incoming@gitlab.example.com
+
+ ```yaml
+ incoming_email:
+ enabled: true
+
+ # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
+ # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
+ address: "incoming+%{key}@gitlab.example.com"
+
+ # Email account username
+ # With third party providers, this is usually the full email address.
+ # With self-hosted email servers, this is usually the user part of the email address.
+ user: "incoming"
+ # Email account password
+ password: "[REDACTED]"
+
+ # IMAP server host
+ host: "gitlab.example.com"
+ # IMAP server port
+ port: 143
+ # Whether the IMAP server uses SSL
+ ssl: false
+ # Whether the IMAP server uses StartTLS
+ start_tls: false
+
+ # The mailbox where incoming mail will end up. Usually "inbox".
+ mailbox: "inbox"
+ # The IDLE command timeout.
+ idle_timeout: 60
+ ```
+
+ Configuration for Gmail / Google Apps, assumes mailbox
+ gitlab-incoming@gmail.com
+
+ ```yaml
+ incoming_email:
+ enabled: true
+
+ # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
+ # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
+ address: "gitlab-incoming+%{key}@gmail.com"
+
+ # Email account username
+ # With third party providers, this is usually the full email address.
+ # With self-hosted email servers, this is usually the user part of the email address.
+ user: "gitlab-incoming@gmail.com"
+ # Email account password
+ password: "[REDACTED]"
+
+ # IMAP server host
+ host: "imap.gmail.com"
+ # IMAP server port
+ port: 993
+ # Whether the IMAP server uses SSL
+ ssl: true
+ # Whether the IMAP server uses StartTLS
+ start_tls: false
+
+ # The mailbox where incoming mail will end up. Usually "inbox".
+ mailbox: "inbox"
+ # The IDLE command timeout.
+ idle_timeout: 60
+ ```
+
+ Configuration for Microsoft Exchange mail server w/ IMAP enabled, assumes
+ mailbox incoming@exchange.example.com
+
+ ```yaml
+ incoming_email:
+ enabled: true
+
+ # The email address replies are sent to - Exchange does not support sub-addressing so %{key} is not used here
+ address: "incoming@exchange.example.com"
+
+ # Email account username
+ # Typically this is the userPrincipalName (UPN)
+ user: "incoming@ad-domain.example.com"
+ # Email account password
+ password: "[REDACTED]"
+
+ # IMAP server host
+ host: "exchange.example.com"
+ # IMAP server port
+ port: 993
+ # Whether the IMAP server uses SSL
+ ssl: true
+ # Whether the IMAP server uses StartTLS
+ start_tls: false
+
+ # The mailbox where incoming mail will end up. Usually "inbox".
+ mailbox: "inbox"
+ # The IDLE command timeout.
+ idle_timeout: 60
+ ```
+
+1. Enable `mail_room` in the init script at `/etc/default/gitlab`:
+
+ ```sh
+ sudo mkdir -p /etc/default
+ echo 'mail_room_enabled=true' | sudo tee -a /etc/default/gitlab
+ ```
+
+1. Restart GitLab:
+
+ ```sh
+ sudo service gitlab restart
+ ```
+
+1. Verify that everything is configured correctly:
+
+ ```sh
+ sudo -u git -H bundle exec rake gitlab:incoming_email:check RAILS_ENV=production
+ ```
+
+1. Reply by email should now be working.
diff --git a/doc/administration/index.md b/doc/administration/index.md
index 0b199eecefd..0906821d6a3 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -1,9 +1,19 @@
# Administrator documentation
Learn how to administer your GitLab instance (Community Edition and
-[Enterprise Editions](https://about.gitlab.com/gitlab-ee/)).
+Enterprise Edition).
Regular users don't have access to GitLab administration tools and settings.
+GitLab has two product distributions: the open source
+[GitLab Community Edition (CE)](https://gitlab.com/gitlab-org/gitlab-ce),
+and the open core [GitLab Enterprise Edition (EE)](https://gitlab.com/gitlab-org/gitlab-ee),
+available through [different subscriptions](https://about.gitlab.com/products/).
+
+You can [install GitLab CE or GitLab EE](https://about.gitlab.com/installation/ce-or-ee/),
+but the features you'll have access to depend on the subscription you choose
+(Core, Starter, Premium, or Ultimate). GitLab Community Edition installations
+only have access to Core features.
+
GitLab.com is administered by GitLab, Inc., therefore, only GitLab team members have
access to its admin configurations. If you're a GitLab.com user, please check the
[user documentation](../user/index.html).
@@ -29,6 +39,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [GitLab Pages configuration for GitLab source installations](pages/source.md): Enable and configure GitLab Pages on
[source installations](../install/installation.md#installation-from-source).
- [Environment variables](environment_variables.md): Supported environment variables that can be used to override their defaults values in order to configure GitLab.
+- [Plugins](plugins.md): With custom plugins, GitLab administrators can introduce custom integrations without modifying GitLab's source code.
#### Customizing GitLab's appearance
@@ -69,11 +80,18 @@ created in snippets, wikis, and repos.
- [Sign-up restrictions](../user/admin_area/settings/sign_up_restrictions.md): block email addresses of specific domains, or whitelist only specific domains.
- [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 (SSH, HTTP, HTTPS).
- [Authentication/Authorization](../topics/authentication/index.md#gitlab-administrators): Enforce 2FA, configure external authentication with LDAP, SAML, CAS and additional Omniauth providers.
-- [Reply by email](reply_by_email.md): Allow users to comment on issues and merge requests by replying to notification emails.
- - [Postfix for Reply by email](reply_by_email_postfix_setup.md): Set up a basic Postfix mail
-server with IMAP authentication on Ubuntu, to be used with Reply by email.
+- [Incoming email](incoming_email.md): Configure incoming emails to allow
+ users to [reply by email], create [issues by email] and
+ [merge requests by email], and to enable [Service Desk].
+ - [Postfix for incoming email](reply_by_email_postfix_setup.md): Set up a
+ basic Postfix mail server with IMAP authentication on Ubuntu for incoming
+ emails.
- [User Cohorts](../user/admin_area/user_cohorts.md): Display the monthly cohorts of new users and their activities over time.
+[reply by email]: reply_by_email.md
+[issues by email]: ../user/project/issues/create_new_issue.md#new-issue-via-email
+[merge requests by email]: ../user/project/merge_requests/index.md#create-new-merge-requests-by-email
+
## Project settings
- [Container Registry](container_registry.md): Configure Container Registry with GitLab.
@@ -93,6 +111,7 @@ server with IMAP authentication on Ubuntu, to be used with Reply by email.
- [Enable/disable GitLab CI/CD](../ci/enable_or_disable_ci.md#site-wide-admin-setting): Enable or disable GitLab CI/CD for your instance.
- [GitLab CI/CD admin settings](../user/admin_area/settings/continuous_integration.md): Define max artifacts size and expiration time.
- [Job artifacts](job_artifacts.md): Enable, disable, and configure job artifacts (a set of files and directories which are outputted by a job when it completes successfully).
+- [Job traces](job_traces.md): Information about the job traces (logs).
- [Artifacts size and expiration](../user/admin_area/settings/continuous_integration.md#maximum-artifacts-size): Define maximum artifacts limits and expiration date.
- [Register Shared and specific Runners](../ci/runners/README.md#registering-a-shared-runner): Learn how to register and configure Shared and specific Runners to your own instance.
- [Shared Runners pipelines quota](../user/admin_area/settings/continuous_integration.md#shared-runners-pipeline-minutes-quota): Limit the usage of pipeline minutes for Shared Runners.
diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md
index 93c3642a1f1..d978d1dca53 100644
--- a/doc/administration/integration/plantuml.md
+++ b/doc/administration/integration/plantuml.md
@@ -9,7 +9,19 @@ created in snippets, wikis, and repos.
## PlantUML Server
Before you can enable PlantUML in GitLab; you need to set up your own PlantUML
-server that will generate the diagrams. Installing and configuring your
+server that will generate the diagrams.
+
+### Docker
+
+With Docker, you can just run a container like this:
+
+`docker run -d --name plantuml -p 8080:8080 plantuml/plantuml-server:tomcat`
+
+The **PlantUML URL** will be the hostname of the server running the container.
+
+### Debian/Ubuntu
+
+Installing and configuring your
own PlantUML server is easy in Debian/Ubuntu distributions using Tomcat.
First you need to create a `plantuml.war` file from the source code:
@@ -58,30 +70,32 @@ our AsciiDoc snippets, wikis and repos using delimited blocks:
- **Markdown**
- ```plantuml
- Bob -> Alice : hello
- Alice -> Bob : Go Away
- ```
+ <pre>
+ ```plantuml
+ Bob -> Alice : hello
+ Alice -> Bob : Go Away
+ ```
+ </pre>
- **AsciiDoc**
- ```
+ <pre>
[plantuml, format="png", id="myDiagram", width="200px"]
--
Bob->Alice : hello
Alice -> Bob : Go Away
--
- ```
+ </pre>
- **reStructuredText**
- ```
+ <pre>
.. plantuml::
:caption: Caption with **bold** and *italic*
Bob -> Alice: hello
Alice -> Bob: Go Away
- ```
+ </pre>
You can also use the `uml::` directive for compatibility with [sphinxcontrib-plantuml](https://pypi.python.org/pypi/sphinxcontrib-plantuml), but please note that we currently only support the `caption` option.
diff --git a/doc/administration/issue_closing_pattern.md b/doc/administration/issue_closing_pattern.md
index 28e1fd4e12e..466bb1f851e 100644
--- a/doc/administration/issue_closing_pattern.md
+++ b/doc/administration/issue_closing_pattern.md
@@ -24,11 +24,11 @@ Because Rubular doesn't understand `%{issue_ref}`, you can replace this by
**For Omnibus installations**
1. Open `/etc/gitlab/gitlab.rb` with your editor.
-1. Change the value of `gitlab_rails['issue_closing_pattern']` to a regular
+1. Change the value of `gitlab_rails['gitlab_issue_closing_pattern']` to a regular
expression of your liking:
```ruby
- gitlab_rails['issue_closing_pattern'] = "((?:[Cc]los(?:e[sd]|ing)|[Ff]ix(?:e[sd]|ing)?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?))+)"
+ gitlab_rails['gitlab_issue_closing_pattern'] = "((?:[Cc]los(?:e[sd]|ing)|[Ff]ix(?:e[sd]|ing)?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?))+)"
```
1. [Reconfigure] GitLab for the changes to take effect.
diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md
index 33f8a69c249..896cb93e5ed 100644
--- a/doc/administration/job_artifacts.md
+++ b/doc/administration/job_artifacts.md
@@ -87,10 +87,125 @@ _The artifacts are stored by default in
### Using object storage
-In [GitLab Enterprise Edition Premium][eep] you can use an object storage like
-AWS S3 to store the artifacts.
+>**Notes:**
+- [Introduced][ee-1762] in [GitLab Premium][eep] 9.4.
+- Since version 9.5, artifacts are [browsable], when object storage is enabled.
+ 9.4 lacks this feature.
+> Available in [GitLab Premium](https://about.gitlab.com/products/) and
+[GitLab.com Silver](https://about.gitlab.com/gitlab-com/).
+> Since version 10.6, available in [GitLab CE](https://about.gitlab.com/products/)
+
+If you don't want to use the local disk where GitLab is installed to store the
+artifacts, you can use an object storage like AWS S3 instead.
+This configuration relies on valid AWS credentials to be configured already.
+Use an [Object storage option][os] like AWS S3 to store job artifacts.
+
+### Object Storage Settings
+
+For source installations the following settings are nested under `artifacts:` and then `object_store:`. On omnibus installs they are prefixed by `artifacts_object_store_`.
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `enabled` | Enable/disable object storage | `false` |
+| `remote_directory` | The bucket name where Artfacts will be stored| |
+| `direct_upload` | Set to true to enable direct upload of Artifacts without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. Currently only `Google` provider is supported | `false` |
+| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` |
+| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` |
+| `connection` | Various connection options described below | |
+
+#### S3 compatible connection settings
+
+The connection settings match those provided by [Fog](https://github.com/fog), and are as follows:
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `provider` | Always `AWS` for compatible hosts | AWS |
+| `aws_access_key_id` | AWS credentials, or compatible | |
+| `aws_secret_access_key` | AWS credentials, or compatible | |
+| `region` | AWS region | us-east-1 |
+| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com |
+| `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) |
+| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false |
+
+**In Omnibus installations:**
+
+_The artifacts are stored by default in
+`/var/opt/gitlab/gitlab-rails/shared/artifacts`._
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with
+ the values you want:
+
+ ```ruby
+ gitlab_rails['artifacts_enabled'] = true
+ gitlab_rails['artifacts_object_store_enabled'] = true
+ gitlab_rails['artifacts_object_store_remote_directory'] = "artifacts"
+ gitlab_rails['artifacts_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'aws_access_key_id' => 'AWS_ACCESS_KEY_ID',
+ 'aws_secret_access_key' => 'AWS_SECRET_ACCESS_KEY'
+ }
+ ```
+
+ NOTE: For GitLab 9.4+, if you are using AWS IAM profiles, be sure to omit the
+ AWS access key and secret acces key/value pairs. For example:
+
+ ```ruby
+ gitlab_rails['artifacts_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'use_iam_profile' => true
+ }
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+1. Migrate any existing local artifacts to the object storage:
+
+ ```bash
+ gitlab-rake gitlab:artifacts:migrate
+ ```
+
+ Currently this has to be executed manually and it will allow you to
+ migrate the existing artifacts to the object storage, but all new
+ artifacts will still be stored on the local disk. In the future
+ you will be given an option to define a default storage artifacts for all
+ new files.
+
+---
+
+**In installations from source:**
+
+_The artifacts are stored by default in
+`/home/git/gitlab/shared/artifacts`._
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ artifacts:
+ enabled: true
+ object_store:
+ enabled: true
+ remote_directory: "artifacts" # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: eu-central-1
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+1. Migrate any existing local artifacts to the object storage:
+
+ ```bash
+ sudo -u git -H bundle exec rake gitlab:artifacts:migrate RAILS_ENV=production
+ ```
-[Learn how to use the object storage option.][ee-os]
+ Currently this has to be executed manually and it will allow you to
+ migrate the existing artifacts to the object storage, but all new
+ artifacts will still be stored on the local disk. In the future
+ you will be given an option to define a default storage artifacts for all
+ new files.
## Expiring artifacts
@@ -194,8 +309,7 @@ When clicking on a specific file, [GitLab Workhorse] extracts it
from the archive and the download begins. This implementation saves space,
memory and disk I/O.
-[reconfigure gitlab]: restart_gitlab.md "How to restart GitLab"
-[restart gitlab]: restart_gitlab.md "How to restart GitLab"
+[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab"
+[restart gitlab]: restart_gitlab.md#installations-from-source "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"
+[os]: https://docs.gitlab.com/administration/job_artifacts.html#using-object-storage
diff --git a/doc/administration/job_traces.md b/doc/administration/job_traces.md
new file mode 100644
index 00000000000..84a1ffeec98
--- /dev/null
+++ b/doc/administration/job_traces.md
@@ -0,0 +1,42 @@
+# Job traces (logs)
+
+By default, all job traces (logs) are saved to `/var/opt/gitlab/gitlab-ci/builds`
+and `/home/git/gitlab/builds` for Omnibus packages and installations from source
+respectively. The job logs are organized by year and month (for example, `2017_03`),
+and then by project ID.
+
+There isn't a way to automatically expire old job logs, but it's safe to remove
+them if they're taking up too much space. If you remove the logs manually, the
+job output in the UI will be empty.
+
+## Changing the job traces location
+
+To change the location where the job logs will be stored, follow the steps below.
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add or amend the following line:
+
+ ```
+ gitlab_ci['builds_directory'] = '/mnt/to/gitlab-ci/builds'
+ ```
+
+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
+ gitlab_ci:
+ # The location where build traces are stored (default: builds/).
+ # Relative paths are relative to Rails.root.
+ builds_path: path/to/builds/
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+
+[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab"
+[restart gitlab]: restart_gitlab.md#installations-from-source "How to restart GitLab"
diff --git a/doc/administration/logs.md b/doc/administration/logs.md
index debaa2330d0..c8a3ef80e8f 100644
--- a/doc/administration/logs.md
+++ b/doc/administration/logs.md
@@ -23,7 +23,7 @@ requests from the API are logged to a separate file in `api_json.log`.
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"}
+{"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":[{"key":"param_key","value":"param_value"}],"remote_ip":"18.245.0.1","user_id":1,"username":"admin","gitaly_calls":76}
```
In this example, you can see this was a GET request for a specific issue. Notice each line also contains performance data:
@@ -31,6 +31,9 @@ In this example, you can see this was a GET request for a specific issue. Notice
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
+4. `gitaly_calls`: total number of calls made to Gitaly
+
+User clone/fetch activity using http transport appears in this log as `action: git_upload_pack`.
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`).
@@ -143,6 +146,28 @@ this file. For example:
2014-06-10T18:18:26Z 14299 TID-55uqo INFO: Booting Sidekiq 3.0.0 with redis options {:url=>"redis://localhost:6379/0", :namespace=>"sidekiq"}
```
+Instead of the format above, you can opt to generate JSON logs for
+Sidekiq. For example:
+
+```json
+{"severity":"INFO","time":"2018-04-03T22:57:22.071Z","queue":"cronjob:update_all_mirrors","args":[],"class":"UpdateAllMirrorsWorker","retry":false,"queue_namespace":"cronjob","jid":"06aeaa3b0aadacf9981f368e","created_at":"2018-04-03T22:57:21.930Z","enqueued_at":"2018-04-03T22:57:21.931Z","pid":10077,"message":"UpdateAllMirrorsWorker JID-06aeaa3b0aadacf9981f368e: done: 0.139 sec","job_status":"done","duration":0.139,"completed_at":"2018-04-03T22:57:22.071Z"}
+```
+
+For Omnibus GitLab installations, add the configuration option:
+
+```ruby
+sidekiq['log_format'] = 'json'
+```
+
+For source installations, edit the `gitlab.yml` and set the Sidekiq
+`log_format` configuration option:
+
+```yaml
+ ## Sidekiq
+ sidekiq:
+ log_format: json
+```
+
## `gitlab-shell.log`
This file lives in `/var/log/gitlab/gitlab-shell/gitlab-shell.log` for
@@ -157,6 +182,8 @@ I, [2015-02-13T06:17:00.671315 #9291] INFO -- : Adding project root/example.git
I, [2015-02-13T06:17:00.679433 #9291] INFO -- : Moving existing hooks directory and symlinking global hooks directory for /var/opt/gitlab/git-data/repositories/root/example.git.
```
+User clone/fetch activity using ssh transport appears in this log as `executing git command <gitaly-upload-pack...`.
+
## `unicorn\_stderr.log`
This file lives in `/var/log/gitlab/unicorn/unicorn_stderr.log` for
@@ -201,4 +228,12 @@ is populated whenever `gitlab-ctl reconfigure` is run manually or as part of an
Reconfigure logs files are named according to the UNIX timestamp of when the reconfigure
was initiated, such as `1509705644.log`
+## `sidekiq_exporter.log`
+
+If Prometheus metrics and the Sidekiq Exporter are both enabled, Sidekiq will
+start a Web server and listen to the defined port (default: 3807). Access logs
+will be generated in `/var/log/gitlab/gitlab-rails/sidekiq_exporter.log` for
+Omnibus GitLab packages or in `/home/git/gitlab/log/sidekiq_exporter.log` for
+installations from source.
+
[repocheck]: repository_checks.md
diff --git a/doc/administration/monitoring/index.md b/doc/administration/monitoring/index.md
index d6333ee62b4..d18dddf09c0 100644
--- a/doc/administration/monitoring/index.md
+++ b/doc/administration/monitoring/index.md
@@ -5,5 +5,6 @@ Explore our features to monitor your GitLab instance:
- [Performance monitoring](performance/index.md): GitLab Performance Monitoring makes it possible to measure a wide variety of statistics of your instance.
- [Prometheus](prometheus/index.md): Prometheus is a powerful time-series monitoring service, providing a flexible platform for monitoring GitLab and other software products.
- [GitHub imports](github_imports.md): Monitor the health and progress of GitLab's GitHub importer with various Prometheus metrics.
-- [Monitoring uptime](../user/admin_area/monitoring/health_check.md): Check the server status using the health check endpoint.
+- [Monitoring uptime](../../user/admin_area/monitoring/health_check.md): Check the server status using the health check endpoint.
- [IP whitelists](ip_whitelist.md): Configure GitLab for monitoring endpoints that provide health check information when probed.
+- [nginx_status](https://docs.gitlab.com/omnibus/settings/nginx.html#enabling-disabling-nginx_status): Monitor your Nginx server status
diff --git a/doc/administration/monitoring/performance/img/performance_bar.png b/doc/administration/monitoring/performance/img/performance_bar.png
index b3c6bc474e3..48212f6276a 100644
--- a/doc/administration/monitoring/performance/img/performance_bar.png
+++ b/doc/administration/monitoring/performance/img/performance_bar.png
Binary files differ
diff --git a/doc/administration/monitoring/performance/img/performance_bar_gitaly_calls.png b/doc/administration/monitoring/performance/img/performance_bar_gitaly_calls.png
new file mode 100644
index 00000000000..52176df9ecd
--- /dev/null
+++ b/doc/administration/monitoring/performance/img/performance_bar_gitaly_calls.png
Binary files differ
diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md
index b9464945cea..dc4f685d843 100644
--- a/doc/administration/monitoring/performance/performance_bar.md
+++ b/doc/administration/monitoring/performance/performance_bar.md
@@ -11,12 +11,18 @@ It allows you to see (from left to right):
- the timing of the page (backend, frontend)
- time taken and number of DB queries, click through for details of these queries
![SQL profiling using the Performance Bar](img/performance_bar_sql_queries.png)
+- time taken and number of [Gitaly] calls, click through for details of these calls
+![Gitaly profiling using the Performance Bar](img/performance_bar_gitaly_calls.png)
+- profile of the code used to generate the page, line by line. In the profile view, the numbers in the left panel represent wall time, cpu time, and number of calls (based on [rblineprof](https://github.com/tmm1/rblineprof)).
+![Line profiling using the Performance Bar](img/performance_bar_line_profiling.png)
- time taken and number of calls to Redis
- time taken and number of background jobs created by Sidekiq
-- profile of the code used to generate the page, line by line for either _all_, _app & lib_ , or _views_. In the profile view, the numbers in the left panel represent wall time, cpu time, and number of calls (based on [rblineprof](https://github.com/tmm1/rblineprof)).
-![Line profiling using the Performance Bar](img/performance_bar_line_profiling.png)
- time taken and number of Ruby GC calls
+On the far right is a request selector that allows you to view the same metrics
+(excluding the page timing and line profiler) for any requests made while the
+page was open. Only the first two requests per unique URL are captured.
+
## Enable the Performance Bar via the Admin panel
GitLab Performance Bar is disabled by default. To enable it for a given group,
@@ -39,3 +45,5 @@ You can toggle the Bar using the same shortcut.
![GitLab Performance Bar Admin Settings](img/performance_bar_configuration_settings.png)
---
+
+[Gitaly]: ../../gitaly/index.md
diff --git a/doc/administration/monitoring/prometheus/index.md b/doc/administration/monitoring/prometheus/index.md
index f43c89dad87..3d24812c66a 100644
--- a/doc/administration/monitoring/prometheus/index.md
+++ b/doc/administration/monitoring/prometheus/index.md
@@ -62,7 +62,14 @@ To change the address/port that Prometheus listens on:
```
Replace `localhost:9090` with the address/port you want Prometheus to
- listen on.
+ listen on. If you would like to allow access to Prometheus to hosts other
+ than `localhost`, leave out the host, or use `0.0.0.0` to allow public access:
+
+ ```ruby
+ prometheus['listen_address'] = ':9090'
+ # or
+ prometheus['listen_address'] = '0.0.0.0:9090'
+ ```
1. Save the file and [reconfigure GitLab][reconfigure] for the changes to
take effect
diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md
new file mode 100644
index 00000000000..bd6c7bb07b5
--- /dev/null
+++ b/doc/administration/operations/fast_ssh_key_lookup.md
@@ -0,0 +1,176 @@
+# Fast lookup of authorized SSH keys in the database
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/1631) in
+> [GitLab Starter](https://about.gitlab.com/gitlab-ee) 9.3.
+>
+> [Available in](https://gitlab.com/gitlab-org/gitlab-ee/issues/3953) GitLab
+> Community Edition 10.4.
+
+Regular SSH operations become slow as the number of users grows because OpenSSH
+searches for a key to authorize a user via a linear search. In the worst case,
+such as when the user is not authorized to access GitLab, OpenSSH will scan the
+entire file to search for a key. This can take significant time and disk I/O,
+which will delay users attempting to push or pull to a repository. Making
+matters worse, if users add or remove keys frequently, the operating system may
+not be able to cache the `authorized_keys` file, which causes the disk to be
+accessed repeatedly.
+
+GitLab Shell solves this by providing a way to authorize SSH users via a fast,
+indexed lookup in the GitLab database. This page describes how to enable the fast
+lookup of authorized SSH keys.
+
+> **Warning:** OpenSSH version 6.9+ is required because
+`AuthorizedKeysCommand` must be able to accept a fingerprint. These
+instructions will break installations using older versions of OpenSSH, such as
+those included with CentOS 6 as of September 2017. If you want to use this
+feature for CentOS 6, follow [the instructions on how to build and install a custom OpenSSH package](#compiling-a-custom-version-of-openssh-for-centos-6) before continuing.
+
+## Setting up fast lookup via GitLab Shell
+
+GitLab Shell provides a way to authorize SSH users via a fast, indexed lookup
+to the GitLab database. GitLab Shell uses the fingerprint of the SSH key to
+check whether the user is authorized to access GitLab.
+
+Add the following to your `sshd_config` file. This is usuaully located at
+`/etc/ssh/sshd_config`, but it will be `/assets/sshd_config` if you're using
+Omnibus Docker:
+
+```
+AuthorizedKeysCommand /opt/gitlab/embedded/service/gitlab-shell/bin/gitlab-shell-authorized-keys-check git %u %k
+AuthorizedKeysCommandUser git
+```
+
+Reload OpenSSH:
+
+```bash
+# Debian or Ubuntu installations
+sudo service ssh reload
+
+# CentOS installations
+sudo service sshd reload
+```
+
+Confirm that SSH is working by removing your user's SSH key in the UI, adding a
+new one, and attempting to pull a repo.
+
+> **Warning:** Do not disable writes until SSH is confirmed to be working
+perfectly, because the file will quickly become out-of-date.
+
+In the case of lookup failures (which are common), the `authorized_keys`
+file will still be scanned. So git SSH performance will still be slow for many
+users as long as a large file exists.
+
+You can disable any more writes to the `authorized_keys` file by unchecking
+`Write to "authorized_keys" file` in the Application Settings of your GitLab
+installation.
+
+![Write to authorized keys setting](img/write_to_authorized_keys_setting.png)
+
+Again, confirm that SSH is working by removing your user's SSH key in the UI,
+adding a new one, and attempting to pull a repo.
+
+Then you can backup and delete your `authorized_keys` file for best performance.
+
+## How to go back to using the `authorized_keys` file
+
+This is a brief overview. Please refer to the above instructions for more context.
+
+1. [Rebuild the `authorized_keys` file](../raketasks/maintenance.md#rebuild-authorized_keys-file)
+1. Enable writes to the `authorized_keys` file in Application Settings
+1. Remove the `AuthorizedKeysCommand` lines from `/etc/ssh/sshd_config` or from `/assets/sshd_config` if you are using Omnibus Docker.
+1. Reload sshd: `sudo service sshd reload`
+1. Remove the `/opt/gitlab-shell/authorized_keys` file
+
+## Compiling a custom version of OpenSSH for CentOS 6
+
+Building a custom version of OpenSSH is not necessary for Ubuntu 16.04 users,
+since Ubuntu 16.04 ships with OpenSSH 7.2.
+
+It is also unnecessary for CentOS 7.4 users, as that version ships with
+OpenSSH 7.4. If you are using CentOS 7.0 - 7.3, we strongly recommend that you
+upgrade to CentOS 7.4 instead of following this procedure. This should be as
+simple as running `yum update`.
+
+CentOS 6 users must build their own OpenSSH package to enable SSH lookups via
+the database. The following instructions can be used to build OpenSSH 7.5:
+
+1. First, download the package and install the required packages:
+
+ ```
+ sudo su -
+ cd /tmp
+ curl --remote-name https://mirrors.evowise.com/pub/OpenBSD/OpenSSH/portable/openssh-7.5p1.tar.gz
+ tar xzvf openssh-7.5p1.tar.gz
+ yum install rpm-build gcc make wget openssl-devel krb5-devel pam-devel libX11-devel xmkmf libXt-devel
+ ```
+
+3. Prepare the build by copying files to the right place:
+
+ ```
+ mkdir -p /root/rpmbuild/{SOURCES,SPECS}
+ cp ./openssh-7.5p1/contrib/redhat/openssh.spec /root/rpmbuild/SPECS/
+ cp openssh-7.5p1.tar.gz /root/rpmbuild/SOURCES/
+ cd /root/rpmbuild/SPECS
+ ```
+
+3. Next, set the spec settings properly:
+
+ ```
+ sed -i -e "s/%define no_gnome_askpass 0/%define no_gnome_askpass 1/g" openssh.spec
+ sed -i -e "s/%define no_x11_askpass 0/%define no_x11_askpass 1/g" openssh.spec
+ sed -i -e "s/BuildPreReq/BuildRequires/g" openssh.spec
+ ```
+
+3. Build the RPMs:
+
+ ```
+ rpmbuild -bb openssh.spec
+ ```
+
+4. Ensure the RPMs were built:
+
+ ```
+ ls -al /root/rpmbuild/RPMS/x86_64/
+ ```
+
+ You should see something as the following:
+
+ ```
+ total 1324
+ drwxr-xr-x. 2 root root 4096 Jun 20 19:37 .
+ drwxr-xr-x. 3 root root 19 Jun 20 19:37 ..
+ -rw-r--r--. 1 root root 470828 Jun 20 19:37 openssh-7.5p1-1.x86_64.rpm
+ -rw-r--r--. 1 root root 490716 Jun 20 19:37 openssh-clients-7.5p1-1.x86_64.rpm
+ -rw-r--r--. 1 root root 17020 Jun 20 19:37 openssh-debuginfo-7.5p1-1.x86_64.rpm
+ -rw-r--r--. 1 root root 367516 Jun 20 19:37 openssh-server-7.5p1-1.x86_64.rpm
+ ```
+
+5. Install the packages. OpenSSH packages will replace `/etc/pam.d/sshd`
+ with its own version, which may prevent users from logging in, so be sure
+ that the file is backed up and restored after installation:
+
+ ```
+ timestamp=$(date +%s)
+ cp /etc/pam.d/sshd pam-ssh-conf-$timestamp
+ rpm -Uvh /root/rpmbuild/RPMS/x86_64/*.rpm
+ yes | cp pam-ssh-conf-$timestamp /etc/pam.d/sshd
+ ```
+
+6. Verify the installed version. In another window, attempt to login to the server:
+
+ ```
+ ssh -v <your-centos-machine>
+ ```
+
+ You should see a line that reads: "debug1: Remote protocol version 2.0, remote software version OpenSSH_7.5"
+
+ If not, you may need to restart sshd (e.g. `systemctl restart sshd.service`).
+
+7. *IMPORTANT!* Open a new SSH session to your server before exiting to make
+ sure everything is working! If you need to downgrade, simple install the
+ older package:
+
+ ```
+ # Only run this if you run into a problem logging in
+ yum downgrade openssh-server openssh openssh-clients
+ ```
diff --git a/doc/administration/operations/img/write_to_authorized_keys_setting.png b/doc/administration/operations/img/write_to_authorized_keys_setting.png
new file mode 100644
index 00000000000..232765f1917
--- /dev/null
+++ b/doc/administration/operations/img/write_to_authorized_keys_setting.png
Binary files differ
diff --git a/doc/administration/operations/index.md b/doc/administration/operations/index.md
index 320d71a9527..5655b7efec6 100644
--- a/doc/administration/operations/index.md
+++ b/doc/administration/operations/index.md
@@ -13,4 +13,5 @@ by GitLab to another file system or another server.
that to prioritize important jobs.
- [Sidekiq MemoryKiller](sidekiq_memory_killer.md): Configure Sidekiq MemoryKiller
to restart Sidekiq.
-- [Unicorn](unicorn.md): Understand Unicorn and unicorn-worker-killer. \ No newline at end of file
+- [Unicorn](unicorn.md): Understand Unicorn and unicorn-worker-killer.
+- [Speed up SSH operations](fast_ssh_key_lookup.md): Authorize SSH users via a fast, indexed lookup to the GitLab database.
diff --git a/doc/administration/operations/speed_up_ssh.md b/doc/administration/operations/speed_up_ssh.md
new file mode 100644
index 00000000000..89265b3018b
--- /dev/null
+++ b/doc/administration/operations/speed_up_ssh.md
@@ -0,0 +1 @@
+This document was moved to [another location](fast_ssh_key_lookup.md).
diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md
index 7d47aaac299..00c631fdaae 100644
--- a/doc/administration/pages/index.md
+++ b/doc/administration/pages/index.md
@@ -61,6 +61,21 @@ Before proceeding with the Pages configuration, you will need to:
NOTE: **Note:**
If your GitLab instance and the Pages daemon are deployed in a private network or behind a firewall, your GitLab Pages websites will only be accessible to devices/users that have access to the private network.
+### Add the domain to the Public Suffix List
+
+The [Public Suffix List](https://publicsuffix.org) is used by browsers to
+decide how to treat subdomains. If your GitLab instance allows members of the
+public to create GitLab Pages sites, it also allows those users to create
+subdomains on the pages domain (`example.io`). Adding the domain to the Public
+Suffix List prevents browsers from accepting
+[supercookies](https://en.wikipedia.org/wiki/HTTP_cookie#Supercookie),
+among other things.
+
+Follow [these instructions](https://publicsuffix.org/submit/) to submit your
+GitLab Pages subdomain. For instance, if your domain is `example.io`, you should
+request that `*.example.io` is added to the Public Suffix List. GitLab.com
+added `*.gitlab.io` [in 2016](https://gitlab.com/gitlab-com/infrastructure/issues/230).
+
### DNS configuration
GitLab Pages expect to run on their own virtual host. In your DNS server/provider
@@ -211,6 +226,18 @@ world. Custom domains and TLS are supported.
1. [Reconfigure GitLab][reconfigure]
+### Custom domain verification
+
+To prevent malicious users from hijacking domains that don't belong to them,
+GitLab supports [custom domain verification](../../user/project/pages/getting_started_part_three.md#dns-txt-record).
+When adding a custom domain, users will be required to prove they own it by
+adding a GitLab-controlled verification code to the DNS records for that domain.
+
+If your userbase is private or otherwise trusted, you can disable the
+verification requirement. Navigate to `Admin area âž” Settings` and uncheck
+**Require users to prove ownership of custom domains** in the Pages section.
+This setting is enabled by default.
+
## Change storage path
Follow the steps below to change the default path where GitLab Pages' contents
diff --git a/doc/administration/plugins.md b/doc/administration/plugins.md
new file mode 100644
index 00000000000..3ae41638ac3
--- /dev/null
+++ b/doc/administration/plugins.md
@@ -0,0 +1,80 @@
+# GitLab Plugin system
+
+> Introduced in GitLab 10.6.
+
+With custom plugins, GitLab administrators can introduce custom integrations
+without modifying GitLab's source code.
+
+NOTE: **Note:**
+Instead of writing and supporting your own plugin you can make changes
+directly to the GitLab source code and contribute back upstream. This way we can
+ensure functionality is preserved across versions and covered by tests.
+
+NOTE: **Note:**
+Plugins must be configured on the filesystem of the GitLab server. Only GitLab
+server administrators will be able to complete these tasks. Explore
+[system hooks] or [webhooks] as an option if you do not have filesystem access.
+
+A plugin will run on each event so it's up to you to filter events or projects
+within a plugin code. You can have as many plugins as you want. Each plugin will
+be triggered by GitLab asynchronously in case of an event. For a list of events
+see the [system hooks] documentation.
+
+## Setup
+
+The plugins must be placed directly into the `plugins` directory, subdirectories
+will be ignored. There is an
+[`example` directory inside `plugins`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/plugins/examples)
+where you can find some basic examples.
+
+Follow the steps below to set up a custom hook:
+
+1. On the GitLab server, navigate to the plugin directory.
+ For an installation from source the path is usually
+ `/home/git/gitlab/plugins/`. For Omnibus installs the path is
+ usually `/opt/gitlab/embedded/service/gitlab-rails/plugins`.
+1. Inside the `plugins` directory, create a file with a name of your choice,
+ without spaces or special characters.
+1. Make the hook file executable and make sure it's owned by the git user.
+1. Write the code to make the plugin function as expected. That can be
+ in any language, and ensure the 'shebang' at the top properly reflects the
+ language type. For example, if the script is in Ruby the shebang will
+ probably be `#!/usr/bin/env ruby`.
+1. The data to the plugin will be provided as JSON on STDIN. It will be exactly
+ same as for [system hooks]
+
+That's it! Assuming the plugin code is properly implemented, the hook will fire
+as appropriate. The plugins file list is updated for each event, there is no
+need to restart GitLab to apply a new plugin.
+
+If a plugin executes with non-zero exit code or GitLab fails to execute it, a
+message will be logged to `plugin.log`.
+
+## Validation
+
+Writing your own plugin can be tricky and it's easier if you can check it
+without altering the system. A rake task is provided so that you can use it
+in a staging environment to test your plugin before using it in production.
+The rake task will use a sample data and execute each of plugin. The output
+should be enough to determine if the system sees your plugin and if it was
+executed without errors.
+
+```bash
+# Omnibus installations
+sudo gitlab-rake plugins:validate
+
+# Installations from source
+cd /home/git/gitlab
+bundle exec rake plugins:validate RAILS_ENV=production
+```
+
+Example of output:
+
+```
+Validating plugins from /plugins directory
+* /home/git/gitlab/plugins/save_to_file.clj succeed (zero exit code)
+* /home/git/gitlab/plugins/save_to_file.rb failure (non-zero exit code)
+```
+
+[system hooks]: ../system_hooks/system_hooks.md
+[webhooks]: ../user/project/integrations/webhooks.md
diff --git a/doc/administration/raketasks/check.md b/doc/administration/raketasks/check.md
index c8b5434c068..7d34d35e7d1 100644
--- a/doc/administration/raketasks/check.md
+++ b/doc/administration/raketasks/check.md
@@ -28,19 +28,25 @@ exactly which repositories are causing the trouble.
### Check all GitLab repositories
+>**Note:**
+>
+> - `gitlab:repo:check` has been deprecated in favor of `gitlab:git:fsck`
+> - [Deprecated][ce-15931] in GitLab 10.4.
+> - `gitlab:repo:check` will be removed in the future. [Removal issue][ce-41699]
+
This task loops through all repositories on the GitLab server and runs the
3 integrity checks described previously.
**Omnibus Installation**
```
-sudo gitlab-rake gitlab:repo:check
+sudo gitlab-rake gitlab:git:fsck
```
**Source Installation**
```bash
-sudo -u git -H bundle exec rake gitlab:repo:check RAILS_ENV=production
+sudo -u git -H bundle exec rake gitlab:git:fsck RAILS_ENV=production
```
### Check repositories for a specific user
@@ -70,9 +76,56 @@ Example output:
![gitlab:user:check_repos output](../img/raketasks/check_repos_output.png)
+## Uploaded Files Integrity
+
+Various types of file can be uploaded to a GitLab installation by users.
+Checksums are generated and stored in the database upon upload, and integrity
+checks using those checksums can be run. These checks also detect missing files.
+
+Currently, integrity checks are supported for the following types of file:
+
+* CI artifacts (Available from version 10.7.0)
+* LFS objects (Available from version 10.6.0)
+* User uploads (Available from version 10.6.0)
+
+**Omnibus Installation**
+
+```
+sudo gitlab-rake gitlab:artifacts:check
+sudo gitlab-rake gitlab:lfs:check
+sudo gitlab-rake gitlab:uploads:check
+```
+
+**Source Installation**
+
+```bash
+sudo -u git -H bundle exec rake gitlab:artifacts:check RAILS_ENV=production
+sudo -u git -H bundle exec rake gitlab:lfs:check RAILS_ENV=production
+sudo -u git -H bundle exec rake gitlab:uploads:check RAILS_ENV=production
+```
+
+These tasks also accept some environment variables which you can use to override
+certain values:
+
+Variable | Type | Description
+--------- | ------- | -----------
+`BATCH` | integer | Specifies the size of the batch. Defaults to 200.
+`ID_FROM` | integer | Specifies the ID to start from, inclusive of the value.
+`ID_TO` | integer | Specifies the ID value to end at, inclusive of the value.
+`VERBOSE` | boolean | Causes failures to be listed individually, rather than being summarized.
+
+```bash
+sudo gitlab-rake gitlab:artifacts:check BATCH=100 ID_FROM=50 ID_TO=250
+sudo gitlab-rake gitlab:lfs:check BATCH=100 ID_FROM=50 ID_TO=250
+sudo gitlab-rake gitlab:uploads:check BATCH=100 ID_FROM=50 ID_TO=250
+```
+
## LDAP Check
The LDAP check Rake task will test the bind_dn and password credentials
(if configured) and will list a sample of LDAP users. This task is also
executed as part of the `gitlab:check` task, but can run independently.
See [LDAP Rake Tasks - LDAP Check](ldap.md#check) for details.
+
+[ce-15931]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15931
+[ce-41699]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41699
diff --git a/doc/administration/raketasks/maintenance.md b/doc/administration/raketasks/maintenance.md
index ecf92c379fd..2b4252a7b1d 100644
--- a/doc/administration/raketasks/maintenance.md
+++ b/doc/administration/raketasks/maintenance.md
@@ -240,3 +240,31 @@ sudo gitlab-rake gitlab:tcp_check[example.com,80]
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:tcp_check[example.com,80] RAILS_ENV=production
```
+
+## Clear exclusive lease (DANGER)
+
+GitLab uses a shared lock mechanism: `ExclusiveLease` to prevent simultaneous operations
+in a shared resource. An example is running periodic garbage collection on repositories.
+
+In very specific situations, a operation locked by an Exclusive Lease can fail without
+releasing the lock. If you can't wait for it to expire, you can run this task to manually
+clear it.
+
+To clear all exclusive leases:
+
+DANGER: **DANGER**:
+Don't run it while GitLab or Sidekiq is running
+
+```bash
+sudo gitlab-rake gitlab:exclusive_lease:clear
+```
+
+To specify a lease `type` or lease `type + id`, specify a scope:
+
+```bash
+# to clear all leases for repository garbage collection:
+sudo gitlab-rake gitlab:exclusive_lease:clear[project_housekeeping:*]
+
+# to clear a lease for repository garbage collection in a specific project: (id=4)
+sudo gitlab-rake gitlab:exclusive_lease:clear[project_housekeeping:4]
+```
diff --git a/doc/administration/raketasks/uploads/migrate.md b/doc/administration/raketasks/uploads/migrate.md
new file mode 100644
index 00000000000..0cd33ffc122
--- /dev/null
+++ b/doc/administration/raketasks/uploads/migrate.md
@@ -0,0 +1,74 @@
+# Uploads Migrate Rake Task
+
+## Migrate to Object Storage
+
+After [configuring the object storage](../../uploads.md#using-object-storage) for GitLab's uploads, you may use this task to migrate existing uploads from the local storage to the remote storage.
+
+>**Note:**
+All of the processing will be done in a background worker and requires **no downtime**.
+
+This tasks uses 3 parameters to find uploads to migrate.
+
+>**Note:**
+These parameters are mainly internal to GitLab's structure, you may want to refer to the task list instead below.
+
+Parameter | Type | Description
+--------- | ---- | -----------
+`uploader_class` | string | Type of the uploader to migrate from
+`model_class` | string | Type of the model to migrate from
+`mount_point` | string/symbol | Name of the model's column on which the uploader is mounted on.
+
+This task also accepts some environment variables which you can use to override
+certain values:
+
+Variable | Type | Description
+-------- | ---- | -----------
+`BATCH` | integer | Specifies the size of the batch. Defaults to 200.
+
+** Omnibus Installation**
+
+```bash
+# gitlab-rake gitlab:uploads:migrate[uploader_class, model_class, mount_point]
+
+# Avatars
+gitlab-rake "gitlab:uploads:migrate[AvatarUploader, Project, :avatar]"
+gitlab-rake "gitlab:uploads:migrate[AvatarUploader, Group, :avatar]"
+gitlab-rake "gitlab:uploads:migrate[AvatarUploader, User, :avatar]"
+
+# Attachments
+gitlab-rake "gitlab:uploads:migrate[AttachmentUploader, Note, :attachment]"
+gitlab-rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :logo]"
+gitlab-rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :header_logo]"
+
+# Markdown
+gitlab-rake "gitlab:uploads:migrate[FileUploader, Project]"
+gitlab-rake "gitlab:uploads:migrate[PersonalFileUploader, Snippet]"
+gitlab-rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]"
+gitlab-rake "gitlab:uploads:migrate[FileUploader, MergeRequest]"
+```
+
+**Source Installation**
+
+>**Note:**
+Use `RAILS_ENV=production` for every task.
+
+```bash
+# sudo -u git -H bundle exec rake gitlab:uploads:migrate
+
+# Avatars
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AvatarUploader, Project, :avatar]"
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AvatarUploader, Group, :avatar]"
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AvatarUploader, User, :avatar]"
+
+# Attachments
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AttachmentUploader, Note, :attachment]"
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :logo]"
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :header_logo]"
+
+# Markdown
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[FileUploader, Project]"
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[PersonalFileUploader, Snippet]"
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]"
+sudo -u git -H bundle exec rake "gitlab:uploads:migrate[FileUploader, MergeRequest]"
+
+```
diff --git a/doc/administration/reply_by_email.md b/doc/administration/reply_by_email.md
index 3a2cced37bf..426245c7aca 100644
--- a/doc/administration/reply_by_email.md
+++ b/doc/administration/reply_by_email.md
@@ -5,33 +5,7 @@ replying to notification emails.
## Requirement
-Reply by email requires an IMAP-enabled email account. GitLab allows you to use
-three strategies for this feature:
-- using email sub-addressing
-- using a dedicated email address
-- using a catch-all mailbox
-
-### Email sub-addressing
-
-**If your provider or server supports email sub-addressing, we recommend using it.
-Some features (e.g. create new issue via email) only work with sub-addressing.**
-
-[Sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) is
-a feature where any email to `user+some_arbitrary_tag@example.com` will end up
-in the mailbox for `user@example.com`, and is supported by providers such as
-Gmail, Google Apps, Yahoo! Mail, Outlook.com and iCloud, as well as the Postfix
-mail server which you can run on-premises.
-
-### Dedicated email address
-
-This solution is really simple to set up: you just have to create an email
-address dedicated to receive your users' replies to GitLab notifications.
-
-### Catch-all mailbox
-
-A [catch-all mailbox](https://en.wikipedia.org/wiki/Catch-all) for a domain will
-"catch all" the emails addressed to the domain that do not exist in the mail
-server.
+Make sure [incoming email](incoming_email.md) is setup.
## How it works?
@@ -65,329 +39,3 @@ the entity the notification was about (issue, merge request, commit...).
For more details about the `Message-ID`, `In-Reply-To`, and `References headers`,
please consult [RFC 5322](https://tools.ietf.org/html/rfc5322#section-3.6.4).
-
-## Set it up
-
-If you want to use Gmail / Google Apps with Reply by email, make sure you have
-[IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018)
-and [allowed less secure apps to access the account](https://support.google.com/accounts/answer/6010255)
-or [turn-on 2-step validation](https://support.google.com/accounts/answer/185839)
-and use [an application password](https://support.google.com/mail/answer/185833).
-
-To set up a basic Postfix mail server with IMAP access on Ubuntu, follow the
-[Postfix setup documentation](reply_by_email_postfix_setup.md).
-
-### Security Concerns
-
-**WARNING:** Be careful when choosing the domain used for receiving incoming
-email.
-
-For the sake of example, suppose your top-level company domain is `hooli.com`.
-All employees in your company have an email address at that domain via Google
-Apps, and your company's private Slack instance requires a valid `@hooli.com`
-email address in order to sign up.
-
-If you also host a public-facing GitLab instance at `hooli.com` and set your
-incoming email domain to `hooli.com`, an attacker could abuse the "Create new
-issue by email" or
-"[Create new merge request by email](../user/project/merge_requests/index.md#create-new-merge-requests-by-email)"
-features by using a project's unique address as the email when signing up for
-Slack, which would send a confirmation email, which would create a new issue or
-merge request on the project owned by the attacker, allowing them to click the
-confirmation link and validate their account on your company's private Slack
-instance.
-
-We recommend receiving incoming email on a subdomain, such as
-`incoming.hooli.com`, and ensuring that you do not employ any services that
-authenticate solely based on access to an email domain such as `*.hooli.com.`
-Alternatively, use a dedicated domain for GitLab email communications such as
-`hooli-gitlab.com`.
-
-See GitLab issue [#30366](https://gitlab.com/gitlab-org/gitlab-ce/issues/30366)
-for a real-world example of this exploit.
-
-### Omnibus package installations
-
-1. Find the `incoming_email` section in `/etc/gitlab/gitlab.rb`, enable the
- feature and fill in the details for your specific IMAP server and email account:
-
- ```ruby
- # Configuration for Postfix mail server, assumes mailbox incoming@gitlab.example.com
- gitlab_rails['incoming_email_enabled'] = true
-
- # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
- # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
- gitlab_rails['incoming_email_address'] = "incoming+%{key}@gitlab.example.com"
-
- # Email account username
- # With third party providers, this is usually the full email address.
- # With self-hosted email servers, this is usually the user part of the email address.
- gitlab_rails['incoming_email_email'] = "incoming"
- # Email account password
- gitlab_rails['incoming_email_password'] = "[REDACTED]"
-
- # IMAP server host
- gitlab_rails['incoming_email_host'] = "gitlab.example.com"
- # IMAP server port
- gitlab_rails['incoming_email_port'] = 143
- # Whether the IMAP server uses SSL
- gitlab_rails['incoming_email_ssl'] = false
- # Whether the IMAP server uses StartTLS
- gitlab_rails['incoming_email_start_tls'] = false
-
- # The mailbox where incoming mail will end up. Usually "inbox".
- gitlab_rails['incoming_email_mailbox_name'] = "inbox"
- # The IDLE command timeout.
- gitlab_rails['incoming_email_idle_timeout'] = 60
- ```
-
- ```ruby
- # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com
- gitlab_rails['incoming_email_enabled'] = true
-
- # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
- # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
- gitlab_rails['incoming_email_address'] = "gitlab-incoming+%{key}@gmail.com"
-
- # Email account username
- # With third party providers, this is usually the full email address.
- # With self-hosted email servers, this is usually the user part of the email address.
- gitlab_rails['incoming_email_email'] = "gitlab-incoming@gmail.com"
- # Email account password
- gitlab_rails['incoming_email_password'] = "[REDACTED]"
-
- # IMAP server host
- gitlab_rails['incoming_email_host'] = "imap.gmail.com"
- # IMAP server port
- gitlab_rails['incoming_email_port'] = 993
- # Whether the IMAP server uses SSL
- gitlab_rails['incoming_email_ssl'] = true
- # Whether the IMAP server uses StartTLS
- gitlab_rails['incoming_email_start_tls'] = false
-
- # The mailbox where incoming mail will end up. Usually "inbox".
- gitlab_rails['incoming_email_mailbox_name'] = "inbox"
- # The IDLE command timeout.
- gitlab_rails['incoming_email_idle_timeout'] = 60
- ```
-
- ```ruby
- # Configuration for Microsoft Exchange mail server w/ IMAP enabled, assumes mailbox incoming@exchange.example.com
- gitlab_rails['incoming_email_enabled'] = true
-
- # The email address replies are sent to - Exchange does not support sub-addressing so %{key} is not used here
- gitlab_rails['incoming_email_address'] = "incoming@exchange.example.com"
-
- # Email account username
- # Typically this is the userPrincipalName (UPN)
- gitlab_rails['incoming_email_email'] = "incoming@ad-domain.example.com"
- # Email account password
- gitlab_rails['incoming_email_password'] = "[REDACTED]"
-
- # IMAP server host
- gitlab_rails['incoming_email_host'] = "exchange.example.com"
- # IMAP server port
- gitlab_rails['incoming_email_port'] = 993
- # Whether the IMAP server uses SSL
- gitlab_rails['incoming_email_ssl'] = true
- ```
-
-1. Reconfigure GitLab for the changes to take effect:
-
- ```sh
- sudo gitlab-ctl reconfigure
- ```
-
-1. Verify that everything is configured correctly:
-
- ```sh
- sudo gitlab-rake gitlab:incoming_email:check
- ```
-
-1. Reply by email should now be working.
-
-### Installations from source
-
-1. Go to the GitLab installation directory:
-
- ```sh
- cd /home/git/gitlab
- ```
-
-1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature
- and fill in the details for your specific IMAP server and email account:
-
- ```sh
- sudo editor config/gitlab.yml
- ```
-
- ```yaml
- # Configuration for Postfix mail server, assumes mailbox incoming@gitlab.example.com
- incoming_email:
- enabled: true
-
- # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
- # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
- address: "incoming+%{key}@gitlab.example.com"
-
- # Email account username
- # With third party providers, this is usually the full email address.
- # With self-hosted email servers, this is usually the user part of the email address.
- user: "incoming"
- # Email account password
- password: "[REDACTED]"
-
- # IMAP server host
- host: "gitlab.example.com"
- # IMAP server port
- port: 143
- # Whether the IMAP server uses SSL
- ssl: false
- # Whether the IMAP server uses StartTLS
- start_tls: false
-
- # The mailbox where incoming mail will end up. Usually "inbox".
- mailbox: "inbox"
- # The IDLE command timeout.
- idle_timeout: 60
- ```
-
- ```yaml
- # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com
- incoming_email:
- enabled: true
-
- # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
- # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
- address: "gitlab-incoming+%{key}@gmail.com"
-
- # Email account username
- # With third party providers, this is usually the full email address.
- # With self-hosted email servers, this is usually the user part of the email address.
- user: "gitlab-incoming@gmail.com"
- # Email account password
- password: "[REDACTED]"
-
- # IMAP server host
- host: "imap.gmail.com"
- # IMAP server port
- port: 993
- # Whether the IMAP server uses SSL
- ssl: true
- # Whether the IMAP server uses StartTLS
- start_tls: false
-
- # The mailbox where incoming mail will end up. Usually "inbox".
- mailbox: "inbox"
- # The IDLE command timeout.
- idle_timeout: 60
- ```
-
- ```yaml
- # Configuration for Microsoft Exchange mail server w/ IMAP enabled, assumes mailbox incoming@exchange.example.com
- incoming_email:
- enabled: true
-
- # The email address replies are sent to - Exchange does not support sub-addressing so %{key} is not used here
- address: "incoming@exchange.example.com"
-
- # Email account username
- # Typically this is the userPrincipalName (UPN)
- user: "incoming@ad-domain.example.com"
- # Email account password
- password: "[REDACTED]"
-
- # IMAP server host
- host: "exchange.example.com"
- # IMAP server port
- port: 993
- # Whether the IMAP server uses SSL
- ssl: true
- # Whether the IMAP server uses StartTLS
- start_tls: false
-
- # The mailbox where incoming mail will end up. Usually "inbox".
- mailbox: "inbox"
- # The IDLE command timeout.
- idle_timeout: 60
- ```
-
-1. Enable `mail_room` in the init script at `/etc/default/gitlab`:
-
- ```sh
- sudo mkdir -p /etc/default
- echo 'mail_room_enabled=true' | sudo tee -a /etc/default/gitlab
- ```
-
-1. Restart GitLab:
-
- ```sh
- sudo service gitlab restart
- ```
-
-1. Verify that everything is configured correctly:
-
- ```sh
- sudo -u git -H bundle exec rake gitlab:incoming_email:check RAILS_ENV=production
- ```
-
-1. Reply by email should now be working.
-
-### Development
-
-1. Go to the GitLab installation directory.
-
-1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature and fill in the details for your specific IMAP server and email account:
-
- ```yaml
- # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com
- incoming_email:
- enabled: true
-
- # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
- # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
- address: "gitlab-incoming+%{key}@gmail.com"
-
- # Email account username
- # With third party providers, this is usually the full email address.
- # With self-hosted email servers, this is usually the user part of the email address.
- user: "gitlab-incoming@gmail.com"
- # Email account password
- password: "[REDACTED]"
-
- # IMAP server host
- host: "imap.gmail.com"
- # IMAP server port
- port: 993
- # Whether the IMAP server uses SSL
- ssl: true
- # Whether the IMAP server uses StartTLS
- start_tls: false
-
- # The mailbox where incoming mail will end up. Usually "inbox".
- mailbox: "inbox"
- # The IDLE command timeout.
- idle_timeout: 60
- ```
-
- As mentioned, the part after `+` is ignored, and this will end up in the mailbox for `gitlab-incoming@gmail.com`.
-
-1. Uncomment the `mail_room` line in your `Procfile`:
-
- ```yaml
- mail_room: bundle exec mail_room -q -c config/mail_room.yml
- ```
-
-1. Restart GitLab:
-
- ```sh
- bundle exec foreman start
- ```
-
-1. Verify that everything is configured correctly:
-
- ```sh
- bundle exec rake gitlab:incoming_email:check RAILS_ENV=development
- ```
-
-1. Reply by email should now be working.
diff --git a/doc/administration/reply_by_email_postfix_setup.md b/doc/administration/reply_by_email_postfix_setup.md
index a1bb3851951..3e8b78e56d5 100644
--- a/doc/administration/reply_by_email_postfix_setup.md
+++ b/doc/administration/reply_by_email_postfix_setup.md
@@ -1,7 +1,7 @@
-# Set up Postfix for Reply by email
+# Set up Postfix for incoming email
This document will take you through the steps of setting up a basic Postfix mail
-server with IMAP authentication on Ubuntu, to be used with [Reply by email].
+server with IMAP authentication on Ubuntu, to be used with [incoming email].
The instructions make the assumption that you will be using the email address `incoming@gitlab.example.com`, that is, username `incoming` on host `gitlab.example.com`. Don't forget to change it to your actual host when executing the example code snippets.
@@ -177,12 +177,12 @@ 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
@@ -329,10 +329,10 @@ Courier, which we will install later to add IMAP authentication, requires mailbo
## Done!
-If all the tests were successful, Postfix is all set up and ready to receive email! Continue with the [Reply by email](./reply_by_email.md) guide to configure GitLab.
+If all the tests were successful, Postfix is all set up and ready to receive email! Continue with the [incoming email] guide to configure GitLab.
---
_This document was adapted from https://help.ubuntu.com/community/PostfixBasicSetupHowto, by contributors to the Ubuntu documentation wiki._
-[reply by email]: reply_by_email.md
+[incoming email]: incoming_email.md
diff --git a/doc/administration/repository_storage_types.md b/doc/administration/repository_storage_types.md
index 21184fed6e9..39bd19ac851 100644
--- a/doc/administration/repository_storage_types.md
+++ b/doc/administration/repository_storage_types.md
@@ -4,50 +4,63 @@
## Legacy Storage
-Legacy Storage is the storage behavior prior to version 10.0. For historical reasons, GitLab replicated the same
-mapping structure from the projects URLs:
+Legacy Storage is the storage behavior prior to version 10.0. For historical
+reasons, GitLab replicated the same mapping structure from the projects URLs:
- * Project's repository: `#{namespace}/#{project_name}.git`
- * Project's wiki: `#{namespace}/#{project_name}.wiki.git`
+* Project's repository: `#{namespace}/#{project_name}.git`
+* Project's wiki: `#{namespace}/#{project_name}.wiki.git`
-This structure made simple to migrate from existing solutions to GitLab and easy for Administrators to find where the
-repository is stored.
+This structure made it simple to migrate from existing solutions to GitLab and
+easy for Administrators to find where the repository is stored.
On the other hand this has some drawbacks:
-Storage location will concentrate huge amount of top-level namespaces. The impact can be reduced by the introduction of [multiple storage paths][storage-paths].
+Storage location will concentrate huge amount of top-level namespaces. The
+impact can be reduced by the introduction of [multiple storage
+paths][storage-paths].
-Because Backups are a snapshot of the same URL mapping, if you try to recover a very old backup, you need to verify
-if any project has taken the place of an old removed project sharing the same URL. This means that `mygroup/myproject`
-from your backup may not be the same original project that is today in the same URL.
+Because backups are a snapshot of the same URL mapping, if you try to recover a
+very old backup, you need to verify whether any project has taken the place of
+an old removed or renamed project sharing the same URL. This means that
+`mygroup/myproject` from your backup may not be the same original project that
+is at that same URL today.
-Any change in the URL will need to be reflected on disk (when groups / users or projects are renamed). This can add a lot
-of load in big installations, and can be even worst if they are using any type of network based filesystem.
+Any change in the URL will need to be reflected on disk (when groups / users or
+projects are renamed). This can add a lot of load in big installations,
+especially if using any type of network based filesystem.
-Last, for GitLab Geo, this storage type means we have to synchronize the disk state, replicate renames in the correct
-order or we may end-up with wrong repository or missing data temporarily.
+For GitLab Geo in particular: Geo does work with legacy storage, but in some
+edge cases due to race conditions it can lead to errors when a project is
+renamed multiple times in short succession, or a project is deleted and
+recreated under the same name very quickly. We expect these race events to be
+rare, and we have not observed a race condition side-effect happening yet.
-This pattern also exists in other objects stored in GitLab, like issue Attachments, GitLab Pages artifacts,
-Docker Containers for the integrated Registry, etc.
+This pattern also exists in other objects stored in GitLab, like issue
+Attachments, GitLab Pages artifacts, Docker Containers for the integrated
+Registry, etc.
## Hashed Storage
-Hashed Storage is the new storage behavior we are rolling out with 10.0. It's not enabled by default yet, but we
-encourage everyone to try-it and take the time to fix any script you may have that depends on the old behavior.
+> **Warning:** Hashed storage is in **Beta**. For the latest updates, check the
+> associated [issue](https://gitlab.com/gitlab-com/infrastructure/issues/2821)
+> and please report any problems you encounter.
-Instead of coupling project URL and the folder structure where the repository will be stored on disk, we are coupling
-a hash, based on the project's ID.
+Hashed Storage is the new storage behavior we are rolling out with 10.0. Instead
+of coupling project URL and the folder structure where the repository will be
+stored on disk, we are coupling a hash, based on the project's ID. This makes
+the folder structure immutable, and therefore eliminates any requirement to
+synchronize state from URLs to disk structure. This means that renaming a group,
+user, or project will cost only the database transaction, and will take effect
+immediately.
-This makes the folder structure immutable, and therefore eliminates any requirement to synchronize state from URLs to
-disk structure. This means that renaming a group, user or project will cost only the database transaction, and will take
-effect immediately.
+The hash also helps to spread the repositories more evenly on the disk, so the
+top-level directory will contain less folders than the total amount of top-level
+namespaces.
-The hash also helps to spread the repositories more evenly on the disk, so the top-level directory will contain less
-folders than the total amount of top-level namespaces.
-
-Hash format is based on hexadecimal representation of SHA256: `SHA256(project.id)`.
-Top-level folder uses first 2 characters, followed by another folder with the next 2 characters. They are both stored in
-a special folder `@hashed`, to co-exist with existing Legacy projects:
+The hash format is based on the hexadecimal representation of SHA256:
+`SHA256(project.id)`. The top-level folder uses the first 2 characters, followed
+by another folder with the next 2 characters. They are both stored in a special
+`@hashed` folder, to be able to co-exist with existing Legacy Storage projects:
```ruby
# Project's repository:
@@ -57,15 +70,13 @@ a special folder `@hashed`, to co-exist with existing Legacy projects:
"@hashed/#{hash[0..1]}/#{hash[2..3]}/#{hash}.wiki.git"
```
-This new format also makes possible to restore backups with confidence, as when restoring a repository from the backup,
-you will never mistakenly restore a repository in the wrong project (considering the backup is made after the migration).
-
### How to migrate to Hashed Storage
-In GitLab, go to **Admin > Settings**, find the **Repository Storage** section and select
-"_Create new projects using hashed storage paths_".
+In GitLab, go to **Admin > Settings**, find the **Repository Storage** section
+and select "_Create new projects using hashed storage paths_".
-To migrate your existing projects to the new storage type, check the specific [rake tasks].
+To migrate your existing projects to the new storage type, check the specific
+[rake tasks].
[ce-28283]: https://gitlab.com/gitlab-org/gitlab-ce/issues/28283
[rake tasks]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage
@@ -73,11 +84,13 @@ To migrate your existing projects to the new storage type, check the specific [r
### Hashed Storage coverage
-We are incrementally moving every storable object in GitLab to the Hashed Storage pattern. You can check the current
-coverage status below.
+We are incrementally moving every storable object in GitLab to the Hashed
+Storage pattern. You can check the current coverage status below (and also see
+the [issue](https://gitlab.com/gitlab-com/infrastructure/issues/2821)).
-Note that things stored in an S3 compatible endpoint will not have the downsides mentioned earlier, if they are not
-prefixed with `#{namespace}/#{project_name}`, which is true for CI Cache and LFS Objects.
+Note that things stored in an S3 compatible endpoint will not have the downsides
+mentioned earlier, if they are not prefixed with `#{namespace}/#{project_name}`,
+which is true for CI Cache and LFS Objects.
| Storable Object | Legacy Storage | Hashed Storage | S3 Compatible | GitLab Version |
| --------------- | -------------- | -------------- | ------------- | -------------- |
@@ -87,6 +100,6 @@ prefixed with `#{namespace}/#{project_name}`, which is true for CI Cache and LFS
| Pages | Yes | No | - | - |
| Docker Registry | Yes | No | - | - |
| CI Build Logs | No | No | - | - |
-| CI Artifacts | No | No | Yes (EEP) | - |
+| CI Artifacts | No | No | Yes (Premium) | - |
| CI Cache | No | No | Yes | - |
-| LFS Objects | Yes | No | Yes (EEP) | - |
+| LFS Objects | Yes | No | Yes (Premium) | - |
diff --git a/doc/administration/uploads.md b/doc/administration/uploads.md
new file mode 100644
index 00000000000..2fa3284b6be
--- /dev/null
+++ b/doc/administration/uploads.md
@@ -0,0 +1,210 @@
+# Uploads administration
+
+>**Notes:**
+Uploads represent all user data that may be sent to GitLab as a single file. As an example, avatars and notes' attachments are uploads. Uploads are integral to GitLab functionality, and therefore cannot be disabled.
+
+### Using local storage
+
+>**Notes:**
+This is the default configuration
+
+To change the location where the uploads are stored locally, follow the steps
+below.
+
+---
+
+**In Omnibus installations:**
+
+>**Notes:**
+For historical reasons, uploads are stored into a base directory, which by default is `uploads/-/system`. It is strongly discouraged to change this configuration option on an existing GitLab installation.
+
+_The uploads are stored by default in `/var/opt/gitlab/gitlab-rails/public/uploads/-/system`._
+
+1. To change the storage path for example to `/mnt/storage/uploads`, edit
+ `/etc/gitlab/gitlab.rb` and add the following line:
+
+ ```ruby
+ gitlab_rails['uploads_storage_path'] = "/mnt/storage/"
+ gitlab_rails['uploads_base_dir'] = "uploads"
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+_The uploads are stored by default in
+`/home/git/gitlab/public/uploads/-/system`._
+
+1. To change the storage path for example to `/mnt/storage/uploads`, edit
+ `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines:
+
+ ```yaml
+ uploads:
+ storage_path: /mnt/storage
+ base_dir: uploads
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+
+### Using object storage
+
+>**Notes:**
+- [Introduced][ee-3867] in [GitLab Enterprise Edition Premium][eep] 10.5.
+
+If you don't want to use the local disk where GitLab is installed to store the
+uploads, you can use an object storage provider like AWS S3 instead.
+This configuration relies on valid AWS credentials to be configured already.
+
+### Object Storage Settings
+
+For source installations the following settings are nested under `uploads:` and then `object_store:`. On omnibus installs they are prefixed by `uploads_object_store_`.
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `enabled` | Enable/disable object storage | `false` |
+| `remote_directory` | The bucket name where Uploads will be stored| |
+| `direct_upload` | Set to true to enable direct upload of Uploads without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. This is beta option as it uses inefficient way of uploading data (via Unicorn). The accelerated uploads gonna be implemented in future releases | `false` |
+| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` |
+| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` |
+| `connection` | Various connection options described below | |
+
+#### S3 compatible connection settings
+
+The connection settings match those provided by [Fog](https://github.com/fog), and are as follows:
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `provider` | Always `AWS` for compatible hosts | AWS |
+| `aws_access_key_id` | AWS credentials, or compatible | |
+| `aws_secret_access_key` | AWS credentials, or compatible | |
+| `region` | AWS region | us-east-1 |
+| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com |
+| `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) |
+| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false |
+
+**In Omnibus installations:**
+
+_The uploads are stored by default in
+`/var/opt/gitlab/gitlab-rails/public/uploads/-/system`._
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with
+ the values you want:
+
+ ```ruby
+ gitlab_rails['uploads_object_store_enabled'] = true
+ gitlab_rails['uploads_object_store_remote_directory'] = "uploads"
+ gitlab_rails['uploads_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'aws_access_key_id' => 'AWS_ACCESS_KEY_ID',
+ 'aws_secret_access_key' => 'AWS_SECRET_ACCESS_KEY'
+ }
+ ```
+
+>**Note:**
+If you are using AWS IAM profiles, be sure to omit the AWS access key and secret acces key/value pairs.
+
+ ```ruby
+ gitlab_rails['uploads_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'use_iam_profile' => true
+ }
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+1. Migrate any existing local uploads to the object storage:
+
+>**Notes:**
+These task complies with the `BATCH` environment variable to process uploads in batch (200 by default). All of the processing will be done in a background worker and requires **no downtime**.
+
+ ```bash
+ # gitlab-rake gitlab:uploads:migrate[uploader_class, model_class, mount_point]
+
+ # Avatars
+ gitlab-rake "gitlab:uploads:migrate[AvatarUploader, Project, :avatar]"
+ gitlab-rake "gitlab:uploads:migrate[AvatarUploader, Group, :avatar]"
+ gitlab-rake "gitlab:uploads:migrate[AvatarUploader, User, :avatar]"
+
+ # Attachments
+ gitlab-rake "gitlab:uploads:migrate[AttachmentUploader, Note, :attachment]"
+ gitlab-rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :logo]"
+ gitlab-rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :header_logo]"
+
+ # Markdown
+ gitlab-rake "gitlab:uploads:migrate[FileUploader, Project]"
+ gitlab-rake "gitlab:uploads:migrate[PersonalFileUploader, Snippet]"
+ gitlab-rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]"
+ gitlab-rake "gitlab:uploads:migrate[FileUploader, MergeRequest]"
+ ```
+
+ Currently this has to be executed manually and it will allow you to
+ migrate the existing uploads to the object storage, but all new
+ uploads will still be stored on the local disk. In the future
+ you will be given an option to define a default storage for all
+ new files.
+
+---
+
+**In installations from source:**
+
+_The uploads are stored by default in
+`/home/git/gitlab/public/uploads/-/system`._
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ uploads:
+ object_store:
+ enabled: true
+ remote_directory: "uploads" # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: eu-central-1
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+1. Migrate any existing local uploads to the object storage:
+
+>**Notes:**
+
+- These task comply with the `BATCH` environment variable to process uploads in batch (200 by default). All of the processing will be done in a background worker and requires **no downtime**.
+
+- To migrate in production use `RAILS_ENV=production` environment variable.
+
+ ```bash
+ # sudo -u git -H bundle exec rake gitlab:uploads:migrate
+
+ # Avatars
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AvatarUploader, Project, :avatar]"
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AvatarUploader, Group, :avatar]"
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AvatarUploader, User, :avatar]"
+
+ # Attachments
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AttachmentUploader, Note, :attachment]"
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :logo]"
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[AttachmentUploader, Appearance, :header_logo]"
+
+ # Markdown
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[FileUploader, Project]"
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[PersonalFileUploader, Snippet]"
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[NamespaceFileUploader, Snippet]"
+ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[FileUploader, MergeRequest]"
+
+ ```
+
+ Currently this has to be executed manually and it will allow you to
+ migrate the existing uploads to the object storage, but all new
+ uploads will still be stored on the local disk. In the future
+ you will be given an option to define a default storage for all
+ new files.
+
+[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab"
+[restart gitlab]: restart_gitlab.md#installations-from-source "How to restart GitLab"
+[eep]: https://about.gitlab.com/gitlab-ee/ "GitLab Enterprise Edition Premium"
+[ee-3867]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/3867
diff --git a/doc/api/README.md b/doc/api/README.md
index f226716c3b5..ae4481b400e 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -24,9 +24,11 @@ following locations:
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Groups](groups.md)
- [Group Access Requests](access_requests.md)
+- [Group Badges](group_badges.md)
- [Group Members](members.md)
- [Issues](issues.md)
- [Issue Boards](boards.md)
+- [Group Issue Boards](group_boards.md)
- [Jobs](jobs.md)
- [Keys](keys.md)
- [Labels](labels.md)
@@ -35,6 +37,7 @@ following locations:
- [Group milestones](group_milestones.md)
- [Namespaces](namespaces.md)
- [Notes](notes.md) (comments)
+- [Discussions](discussions.md) (threaded comments)
- [Notification settings](notification_settings.md)
- [Open source license templates](templates/licenses.md)
- [Pages Domains](pages_domains.md)
@@ -43,12 +46,15 @@ following locations:
- [Pipeline Schedules](pipeline_schedules.md)
- [Projects](projects.md) including setting Webhooks
- [Project Access Requests](access_requests.md)
+- [Project Badges](project_badges.md)
+- [Project import/export](project_import_export.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)
+- [Search](search.md)
- [Services](services.md)
- [Settings](settings.md)
- [Sidekiq metrics](sidekiq_metrics.md)
diff --git a/doc/api/applications.md b/doc/api/applications.md
new file mode 100644
index 00000000000..933867ed0bb
--- /dev/null
+++ b/doc/api/applications.md
@@ -0,0 +1,37 @@
+# Applications API
+
+> [Introduced][ce-8160] in GitLab 10.5
+
+[ce-8160]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8160
+
+## Create a application
+
+Create a application by posting a JSON payload.
+
+User must be admin to do that.
+
+Returns `200` if the request succeeds.
+
+```
+POST /applications
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `name` | string | yes | The name of the application |
+| `redirect_uri` | string | yes | The redirect URI of the application |
+| `scopes` | string | yes | The scopes of the application |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "name=MyApplication&redirect_uri=http://redirect.uri&scopes=" https://gitlab.example.com/api/v3/applications
+```
+
+Example response:
+
+```json
+{
+ "application_id": "5832fc6e14300a0d962240a8144466eef4ee93ef0d218477e55f11cf12fc3737",
+ "secret": "ee1dd64b6adc89cf7e2c23099301ccc2c61b441064e9324d963c46902a85ec34",
+ "callback_url": "http://redirect.uri"
+}
+```
diff --git a/doc/api/award_emoji.md b/doc/api/award_emoji.md
index d6924741ee4..3f9542d6653 100644
--- a/doc/api/award_emoji.md
+++ b/doc/api/award_emoji.md
@@ -172,7 +172,7 @@ Parameters:
| --------- | ---- | -------- | ----------- |
| `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 an issue |
-| `award_id` | integer | yes | The ID of a award_emoji |
+| `award_id` | integer | yes | The ID of an award_emoji |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/award_emoji/344
@@ -197,7 +197,7 @@ Parameters:
| --------- | ---- | -------- | ----------- |
| `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 an issue |
-| `note_id` | integer | yes | The ID of an note |
+| `note_id` | integer | yes | The ID of a note |
```bash
@@ -323,7 +323,7 @@ Parameters:
| `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 an issue |
| `note_id` | integer | yes | The ID of a note |
-| `award_id` | integer | yes | The ID of a award_emoji |
+| `award_id` | integer | yes | The ID of an award_emoji |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/issues/80/award_emoji/345
diff --git a/doc/api/boards.md b/doc/api/boards.md
index 69c47abc806..246de50323e 100644
--- a/doc/api/boards.md
+++ b/doc/api/boards.md
@@ -15,10 +15,10 @@ GET /projects/:id/boards
| 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 |
+| `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/:id/boards
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/boards
```
Example response:
@@ -27,6 +27,19 @@ Example response:
[
{
"id" : 1,
+ "project": {
+ "id": 5,
+ "name": "Diaspora Project Site",
+ "name_with_namespace": "Diaspora / Diaspora Project Site",
+ "path": "diaspora-project-site",
+ "path_with_namespace": "diaspora/diaspora-project-site",
+ "http_url_to_repo": "http://example.com/diaspora/diaspora-project-site.git",
+ "web_url": "http://example.com/diaspora/diaspora-project-site"
+ },
+ "milestone": {
+ "id": 12
+ "title": "10.0"
+ },
"lists" : [
{
"id" : 1,
@@ -60,6 +73,74 @@ Example response:
]
```
+## Single board
+
+Get a single board.
+
+```
+GET /projects/:id/boards/:board_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 |
+| `board_id` | integer | yes | The ID of a board |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/boards/1
+```
+
+Example response:
+
+```json
+ {
+ "id": 1,
+ "name:": "project issue board",
+ "project": {
+ "id": 5,
+ "name": "Diaspora Project Site",
+ "name_with_namespace": "Diaspora / Diaspora Project Site",
+ "path": "diaspora-project-site",
+ "path_with_namespace": "diaspora/diaspora-project-site",
+ "http_url_to_repo": "http://example.com/diaspora/diaspora-project-site.git",
+ "web_url": "http://example.com/diaspora/diaspora-project-site"
+ },
+ "milestone": {
+ "id": 12
+ "title": "10.0"
+ },
+ "lists" : [
+ {
+ "id" : 1,
+ "label" : {
+ "name" : "Testing",
+ "color" : "#F0AD4E",
+ "description" : null
+ },
+ "position" : 1
+ },
+ {
+ "id" : 2,
+ "label" : {
+ "name" : "Ready",
+ "color" : "#FF0000",
+ "description" : null
+ },
+ "position" : 2
+ },
+ {
+ "id" : 3,
+ "label" : {
+ "name" : "Production",
+ "color" : "#FF5F00",
+ "description" : null
+ },
+ "position" : 3
+ }
+ ]
+ }
+```
+
## List board lists
Get a list of the board's lists.
@@ -71,8 +152,8 @@ GET /projects/:id/boards/:board_id/lists
| 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 |
-| `board_id` | integer | yes | The ID of a board |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `board_id` | integer | yes | The ID of a board |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/boards/1/lists
@@ -122,9 +203,9 @@ GET /projects/:id/boards/:board_id/lists/:list_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 |
-| `board_id` | integer | yes | The ID of a board |
-| `list_id`| integer | yes | The ID of a board's list |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `board_id` | integer | yes | The ID of a board |
+| `list_id`| integer | yes | The ID of a board's list |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/boards/1/lists/1
@@ -154,9 +235,9 @@ POST /projects/:id/boards/:board_id/lists
| 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 |
-| `board_id` | integer | yes | The ID of a board |
-| `label_id` | integer | yes | The ID of a label |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `board_id` | integer | yes | The ID of a board |
+| `label_id` | integer | yes | The ID of a label |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/boards/1/lists?label_id=5
@@ -186,10 +267,10 @@ PUT /projects/:id/boards/:board_id/lists/:list_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 |
-| `board_id` | integer | yes | The ID of a board |
-| `list_id` | integer | yes | The ID of a board's list |
-| `position` | integer | yes | The position of the list |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `board_id` | integer | yes | The ID of a board |
+| `list_id` | integer | yes | The ID of a board's list |
+| `position` | integer | yes | The position of the list |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/boards/1/lists/1?position=2
@@ -219,9 +300,9 @@ DELETE /projects/:id/boards/:board_id/lists/:list_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 |
-| `board_id` | integer | yes | The ID of a board |
-| `list_id` | integer | yes | The ID of a board's list |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `board_id` | integer | yes | The ID of a board |
+| `list_id` | integer | yes | The ID of a board's list |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/boards/1/lists/1
diff --git a/doc/api/branches.md b/doc/api/branches.md
index 80744258acb..01bb30c3859 100644
--- a/doc/api/branches.md
+++ b/doc/api/branches.md
@@ -13,6 +13,7 @@ GET /projects/:id/repository/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 |
+| `search` | string | no | Return list of branches matching the search criteria. |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/repository/branches
diff --git a/doc/api/commits.md b/doc/api/commits.md
index c9b72d4a1dd..d1584cf64de 100644
--- a/doc/api/commits.md
+++ b/doc/api/commits.md
@@ -14,6 +14,9 @@ GET /projects/:id/repository/commits
| `ref_name` | string | no | The name of a repository branch or tag or if not given the default branch |
| `since` | string | no | Only commits after or on this date will be returned in ISO 8601 format YYYY-MM-DDTHH:MM:SSZ |
| `until` | string | no | Only commits before or on this date will be returned in ISO 8601 format YYYY-MM-DDTHH:MM:SSZ |
+| `path` | string | no | The file path |
+| `all` | boolean | no | Retrieve every commit from the repository |
+
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/repository/commits"
@@ -159,6 +162,7 @@ Parameters:
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
| `sha` | string | yes | The commit hash or name of a repository branch or tag |
+| `stats` | boolean | no | Include commit stats. Default is true |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/repository/commits/master
@@ -197,6 +201,41 @@ Example response:
}
```
+## Get references a commit is pushed to
+
+> [Introduced][ce-15026] in GitLab 10.6
+
+Get all references (from branches or tags) a commit is pushed to.
+The pagination parameters `page` and `per_page` can be used to restrict the list of references.
+
+```
+GET /projects/:id/repository/commits/:sha/refs
+```
+
+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
+| `sha` | string | yes | The commit hash |
+| `type` | string | no | The scope of commits. Possible values `branch`, `tag`, `all`. Default is `all`. |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/repository/commits/5937ac0a7beb003549fc5fd26fc247adbce4a52e/refs?type=all"
+```
+
+Example response:
+
+```json
+[
+ {"type": "branch", "name": "'test'"},
+ {"type": "branch", "name": "add-balsamiq-file"},
+ {"type": "branch", "name": "wip"},
+ {"type": "tag", "name": "v1.1.0"}
+ ]
+
+```
+
## Cherry pick a commit
> [Introduced][ce-8047] in GitLab 8.15.
@@ -373,9 +412,10 @@ Example response:
Since GitLab 8.1, this is the new commit status API.
-### Get the status of a commit
+### List the statuses of a commit
-Get the statuses of a commit in a project.
+List the statuses of a commit in a project.
+The pagination parameters `page` and `per_page` can be used to restrict the list of references.
```
GET /projects/:id/repository/commits/:sha/statuses
@@ -497,5 +537,77 @@ Example response:
}
```
+## List Merge Requests associated with a commit
+
+> [Introduced][ce-18004] in GitLab 10.7.
+
+Get a list of Merge Requests related to the specified commit.
+
+```
+GET /projects/:id/repository/commits/:sha/merge_requests
+```
+
+| 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
+| `sha` | string | yes | The commit SHA
+
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/repository/commits/af5b13261899fb2c0db30abdd0af8b07cb44fdc5/merge_requests"
+```
+
+Example response:
+
+```json
+[
+ {
+ "id":45,
+ "iid":1,
+ "project_id":35,
+ "title":"Add new file",
+ "description":"",
+ "state":"opened",
+ "created_at":"2018-03-26T17:26:30.916Z",
+ "updated_at":"2018-03-26T17:26:30.916Z",
+ "target_branch":"master",
+ "source_branch":"test-branch",
+ "upvotes":0,
+ "downvotes":0,
+ "author" : {
+ "web_url" : "https://gitlab.example.com/thedude",
+ "name" : "Jeff Lebowski",
+ "avatar_url" : "https://gitlab.example.com/uploads/user/avatar/28/The-Big-Lebowski-400-400.png",
+ "username" : "thedude",
+ "state" : "active",
+ "id" : 28
+ },
+ "assignee":null,
+ "source_project_id":35,
+ "target_project_id":35,
+ "labels":[ ],
+ "work_in_progress":false,
+ "milestone":null,
+ "merge_when_pipeline_succeeds":false,
+ "merge_status":"can_be_merged",
+ "sha":"af5b13261899fb2c0db30abdd0af8b07cb44fdc5",
+ "merge_commit_sha":null,
+ "user_notes_count":0,
+ "discussion_locked":null,
+ "should_remove_source_branch":null,
+ "force_remove_source_branch":false,
+ "web_url":"http://https://gitlab.example.com/root/test-project/merge_requests/1",
+ "time_stats":{
+ "time_estimate":0,
+ "total_time_spent":0,
+ "human_time_estimate":null,
+ "human_total_time_spent":null
+ }
+ }
+]
+```
+
[ce-6096]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6096 "Multi-file commit"
[ce-8047]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8047
+[ce-15026]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15026
+[ce-18004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18004
diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md
index 273d5a56b6f..698fa22a438 100644
--- a/doc/api/deploy_keys.md
+++ b/doc/api/deploy_keys.md
@@ -19,15 +19,13 @@ Example response:
{
"id": 1,
"title": "Public key",
- "key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
- "can_push": false,
+ "key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
"created_at": "2013-10-02T10:12:29Z"
},
{
"id": 3,
"title": "Another Public key",
- "key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
- "can_push": true,
+ "key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
"created_at": "2013-10-02T11:12:29Z"
}
]
@@ -57,15 +55,15 @@ Example response:
"id": 1,
"title": "Public key",
"key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
- "can_push": false,
- "created_at": "2013-10-02T10:12:29Z"
+ "created_at": "2013-10-02T10:12:29Z",
+ "can_push": false
},
{
"id": 3,
"title": "Another Public key",
"key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
- "can_push": false,
- "created_at": "2013-10-02T11:12:29Z"
+ "created_at": "2013-10-02T11:12:29Z",
+ "can_push": false
}
]
```
@@ -96,8 +94,8 @@ Example response:
"id": 1,
"title": "Public key",
"key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=",
- "can_push": false,
- "created_at": "2013-10-02T10:12:29Z"
+ "created_at": "2013-10-02T10:12:29Z",
+ "can_push": false
}
```
@@ -135,6 +133,36 @@ Example response:
}
```
+## Update deploy key
+
+Updates a deploy key for a project.
+
+```
+PUT /projects/:id/deploy_keys/:key_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 |
+| `title` | string | no | New deploy key's title |
+| `can_push` | boolean | no | Can deploy key push to the project's repository |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "Content-Type: application/json" --data '{"title": "New deploy key", "can_push": true}' "https://gitlab.example.com/api/v4/projects/5/deploy_keys/11"
+```
+
+Example response:
+
+```json
+{
+ "id": 11,
+ "title": "New deploy key",
+ "key": "ssh-rsa AAAA...",
+ "created_at": "2015-08-29T12:44:31.550Z",
+ "can_push": true
+}
+```
+
## Delete deploy key
Removes a deploy key from the project. If the deploy key is used only for this project, it will be deleted from the system.
diff --git a/doc/api/deployments.md b/doc/api/deployments.md
index ab9e63e01d3..fd11894ea8f 100644
--- a/doc/api/deployments.md
+++ b/doc/api/deployments.md
@@ -11,6 +11,8 @@ GET /projects/:id/deployments
| 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 |
+| `order_by`| string | no | Return deployments ordered by `id` or `iid` or `created_at` or `ref` fields. Default is `id` |
+| `sort` | string | no | Return deployments sorted in `asc` or `desc` order. Default is `asc` |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/deployments"
diff --git a/doc/api/discussions.md b/doc/api/discussions.md
new file mode 100644
index 00000000000..c341b7f2009
--- /dev/null
+++ b/doc/api/discussions.md
@@ -0,0 +1,411 @@
+# Discussions API
+
+Discussions are set of related notes on snippets or issues.
+
+## Issues
+
+### List project issue discussions
+
+Gets a list of all discussions for a single issue.
+
+```
+GET /projects/:id/issues/:issue_iid/discussions
+```
+
+| Attribute | Type | Required | Description |
+| ------------------- | ---------------- | ---------- | ------------ |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `issue_iid` | integer | yes | The IID of an issue |
+
+```json
+[
+ {
+ "id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
+ "individual_note": false,
+ "notes": [
+ {
+ "id": 1126,
+ "type": "DiscussionNote",
+ "body": "discussion text",
+ "attachment": null,
+ "author": {
+ "id": 1,
+ "name": "root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2018-03-03T21:54:39.668Z",
+ "updated_at": "2018-03-03T21:54:39.668Z",
+ "system": false,
+ "noteable_id": 3,
+ "noteable_type": "Issue",
+ "noteable_iid": null
+ },
+ {
+ "id": 1129,
+ "type": "DiscussionNote",
+ "body": "reply to the discussion",
+ "attachment": null,
+ "author": {
+ "id": 1,
+ "name": "root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2018-03-04T13:38:02.127Z",
+ "updated_at": "2018-03-04T13:38:02.127Z",
+ "system": false,
+ "noteable_id": 3,
+ "noteable_type": "Issue",
+ "noteable_iid": null
+ }
+ ]
+ },
+ {
+ "id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6",
+ "individual_note": true,
+ "notes": [
+ {
+ "id": 1128,
+ "type": null,
+ "body": "a single comment",
+ "attachment": null,
+ "author": {
+ "id": 1,
+ "name": "root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2018-03-04T09:17:22.520Z",
+ "updated_at": "2018-03-04T09:17:22.520Z",
+ "system": false,
+ "noteable_id": 3,
+ "noteable_type": "Issue",
+ "noteable_iid": null
+ }
+ ]
+ }
+]
+```
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions
+```
+
+### Get single issue discussion
+
+Returns a single discussion for a specific project issue
+
+```
+GET /projects/:id/issues/:issue_iid/discussions/:discussion_id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `issue_iid` | integer | yes | The IID of an issue |
+| `discussion_id` | integer | yes | The ID of a discussion |
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7
+```
+
+### Create new issue discussion
+
+Creates a new discussion to a single project issue. This is similar to creating
+a note but but another comments (replies) can be added to it later.
+
+```
+POST /projects/:id/issues/:issue_iid/discussions
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `issue_iid` | integer | yes | The IID of an issue |
+| `body` | string | yes | The content of a discussion |
+| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions?body=comment
+```
+
+### Add note to existing issue discussion
+
+Adds a new note to the discussion.
+
+```
+POST /projects/:id/issues/:issue_iid/discussions/:discussion_id/notes
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `issue_iid` | integer | yes | The IID of an issue |
+| `discussion_id` | integer | yes | The ID of a discussion |
+| `note_id` | integer | yes | The ID of a discussion note |
+| `body` | string | yes | The content of a discussion |
+| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment
+```
+
+### Modify existing issue discussion note
+
+Modify existing discussion note of an issue.
+
+```
+PUT /projects/:id/issues/:issue_iid/discussions/:discussion_id/notes/:note_id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `issue_iid` | integer | yes | The IID of an issue |
+| `discussion_id` | integer | yes | The ID of a discussion |
+| `note_id` | integer | yes | The ID of a discussion note |
+| `body` | string | yes | The content of a discussion |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment
+```
+
+### Delete an issue discussion note
+
+Deletes an existing discussion note of an issue.
+
+```
+DELETE /projects/:id/issues/:issue_iid/discussions/:discussion_id/notes/:note_id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `issue_iid` | integer | yes | The IID of an issue |
+| `discussion_id` | integer | yes | The ID of a discussion |
+| `note_id` | integer | yes | The ID of a discussion note |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/636
+```
+
+## Snippets
+
+### List project snippet discussions
+
+Gets a list of all discussions for a single snippet.
+
+```
+GET /projects/:id/snippets/:snippet_id/discussions
+```
+
+| Attribute | Type | Required | Description |
+| ------------------- | ---------------- | ---------- | ------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `snippet_id` | integer | yes | The ID of an snippet |
+
+```json
+[
+ {
+ "id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
+ "individual_note": false,
+ "notes": [
+ {
+ "id": 1126,
+ "type": "DiscussionNote",
+ "body": "discussion text",
+ "attachment": null,
+ "author": {
+ "id": 1,
+ "name": "root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2018-03-03T21:54:39.668Z",
+ "updated_at": "2018-03-03T21:54:39.668Z",
+ "system": false,
+ "noteable_id": 3,
+ "noteable_type": "Snippet",
+ "noteable_id": null
+ },
+ {
+ "id": 1129,
+ "type": "DiscussionNote",
+ "body": "reply to the discussion",
+ "attachment": null,
+ "author": {
+ "id": 1,
+ "name": "root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2018-03-04T13:38:02.127Z",
+ "updated_at": "2018-03-04T13:38:02.127Z",
+ "system": false,
+ "noteable_id": 3,
+ "noteable_type": "Snippet",
+ "noteable_id": null
+ }
+ ]
+ },
+ {
+ "id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6",
+ "individual_note": true,
+ "notes": [
+ {
+ "id": 1128,
+ "type": null,
+ "body": "a single comment",
+ "attachment": null,
+ "author": {
+ "id": 1,
+ "name": "root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "created_at": "2018-03-04T09:17:22.520Z",
+ "updated_at": "2018-03-04T09:17:22.520Z",
+ "system": false,
+ "noteable_id": 3,
+ "noteable_type": "Snippet",
+ "noteable_id": null
+ }
+ ]
+ }
+]
+```
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions
+```
+
+### Get single snippet discussion
+
+Returns a single discussion for a specific project snippet
+
+```
+GET /projects/:id/snippets/:snippet_id/discussions/:discussion_id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `snippet_id` | integer | yes | The ID of an snippet |
+| `discussion_id` | integer | yes | The ID of a discussion |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7
+```
+
+### Create new snippet discussion
+
+Creates a new discussion to a single project snippet. This is similar to creating
+a note but but another comments (replies) can be added to it later.
+
+```
+POST /projects/:id/snippets/:snippet_id/discussions
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `snippet_id` | integer | yes | The ID of an snippet |
+| `body` | string | yes | The content of a discussion |
+| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions?body=comment
+```
+
+### Add note to existing snippet discussion
+
+Adds a new note to the discussion.
+
+```
+POST /projects/:id/snippets/:snippet_id/discussions/:discussion_id/notes
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `snippet_id` | integer | yes | The ID of an snippet |
+| `discussion_id` | integer | yes | The ID of a discussion |
+| `note_id` | integer | yes | The ID of a discussion note |
+| `body` | string | yes | The content of a discussion |
+| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment
+```
+
+### Modify existing snippet discussion note
+
+Modify existing discussion note of an snippet.
+
+```
+PUT /projects/:id/snippets/:snippet_id/discussions/:discussion_id/notes/:note_id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `snippet_id` | integer | yes | The ID of an snippet |
+| `discussion_id` | integer | yes | The ID of a discussion |
+| `note_id` | integer | yes | The ID of a discussion note |
+| `body` | string | yes | The content of a discussion |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment
+```
+
+### Delete an snippet discussion note
+
+Deletes an existing discussion note of an snippet.
+
+```
+DELETE /projects/:id/snippets/:snippet_id/discussions/:discussion_id/notes/:note_id
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| --------------- | -------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
+| `snippet_id` | integer | yes | The ID of an snippet |
+| `discussion_id` | integer | yes | The ID of a discussion |
+| `note_id` | integer | yes | The ID of a discussion note |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/636
+```
diff --git a/doc/api/events.md b/doc/api/events.md
index 129af0afa35..f4d26c4de1c 100644
--- a/doc/api/events.md
+++ b/doc/api/events.md
@@ -42,6 +42,10 @@ Dates for the `before` and `after` parameters should be supplied in the followin
YYYY-MM-DD
```
+### Event Time Period Limit
+
+GitLab removes events older than 1 year from the events table for performance reasons. The range of 1 year was chosen because user contribution calendars only show contributions of the past year.
+
## List currently authenticated user's events
>**Note:** This endpoint was introduced in GitLab 9.3.
diff --git a/doc/api/features.md b/doc/api/features.md
index 6861dbf00a2..6ee1c36ef5b 100644
--- a/doc/api/features.md
+++ b/doc/api/features.md
@@ -86,3 +86,11 @@ Example response:
]
}
```
+
+## Delete a feature
+
+Removes a feature gate. Response is equal when the gate exists, or doesn't.
+
+```
+DELETE /features/:name
+```
diff --git a/doc/api/group_badges.md b/doc/api/group_badges.md
new file mode 100644
index 00000000000..0d7d0fd9c42
--- /dev/null
+++ b/doc/api/group_badges.md
@@ -0,0 +1,194 @@
+# Group badges API
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17082)
+in GitLab 10.6.
+
+## Placeholder tokens
+
+Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are:
+
+- **%{project_path}**: will be replaced by the project path.
+- **%{project_id}**: will be replaced by the project id.
+- **%{default_branch}**: will be replaced by the project default branch.
+- **%{commit_sha}**: will be replaced by the last project's commit sha.
+
+Because these enpoints aren't inside a project's context, the information used to replace the placeholders will be
+from the first group's project by creation date. If the group hasn't got any project the original URL with the placeholders will be returned.
+
+## List all badges of a group
+
+Gets a list of a group's badges.
+
+```
+GET /groups/:id/badges
+```
+
+| 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 |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 1,
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "group"
+ },
+ {
+ "id": 2,
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "group"
+ },
+]
+```
+
+## Get a badge of a group
+
+Gets a badge of a group.
+
+```
+GET /groups/:id/badges/:badge_id
+```
+
+| 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 |
+| `badge_id` | integer | yes | The badge ID |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "group"
+}
+```
+
+## Add a badge to a group
+
+Adds a badge to a group.
+
+```
+POST /groups/:id/badges
+```
+
+| 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 |
+| `link_url` | string | yes | URL of the badge link |
+| `image_url` | string | yes | URL of the badge image |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/groups/:id/badges
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "image_url": "https://shields.io/my/badge1",
+ "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "rendered_image_url": "https://shields.io/my/badge1",
+ "kind": "group"
+}
+```
+
+## Edit a badge of a group
+
+Updates a badge of a group.
+
+```
+PUT /groups/:id/badges/:badge_id
+```
+
+| 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 |
+| `badge_id` | integer | yes | The badge ID |
+| `link_url` | string | no | URL of the badge link |
+| `image_url` | string | no | URL of the badge image |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "group"
+}
+```
+
+## Remove a badge from a group
+
+Removes a badge from a group.
+
+```
+DELETE /groups/:id/badges/:badge_id
+```
+
+| 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 |
+| `badge_id` | integer | yes | The badge ID |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
+```
+
+## Preview a badge from a group
+
+Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation.
+
+```
+GET /groups/:id/badges/render
+```
+
+| 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 |
+| `link_url` | string | yes | URL of the badge link|
+| `image_url` | string | yes | URL of the badge image |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge
+```
+
+Example response:
+
+```json
+{
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+}
+```
diff --git a/doc/api/group_boards.md b/doc/api/group_boards.md
new file mode 100644
index 00000000000..45a8544d6b1
--- /dev/null
+++ b/doc/api/group_boards.md
@@ -0,0 +1,284 @@
+# Group Issue Boards API
+
+Every API call to group boards must be authenticated.
+
+If a user is not a member of a group and the group is private, a `GET`
+request will result in `404` status code.
+
+## Group Board
+
+Lists Issue Boards in the given group.
+
+```
+GET /groups/:id/boards
+```
+
+| 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 |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 1,
+ "group_id": 5,
+ "lists" : [
+ {
+ "id" : 1,
+ "label" : {
+ "name" : "Testing",
+ "color" : "#F0AD4E",
+ "description" : null
+ },
+ "position" : 1
+ },
+ {
+ "id" : 2,
+ "label" : {
+ "name" : "Ready",
+ "color" : "#FF0000",
+ "description" : null
+ },
+ "position" : 2
+ },
+ {
+ "id" : 3,
+ "label" : {
+ "name" : "Production",
+ "color" : "#FF5F00",
+ "description" : null
+ },
+ "position" : 3
+ }
+ ]
+ }
+]
+```
+
+## Single board
+
+Gets a single board.
+
+```
+GET /groups/:id/boards/:board_id
+```
+
+| 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 |
+| `board_id` | integer | yes | The ID of a board |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1
+```
+
+Example response:
+
+```json
+ {
+ "id": 1,
+ "group_id": 5,
+ "lists" : [
+ {
+ "id" : 1,
+ "label" : {
+ "name" : "Testing",
+ "color" : "#F0AD4E",
+ "description" : null
+ },
+ "position" : 1
+ },
+ {
+ "id" : 2,
+ "label" : {
+ "name" : "Ready",
+ "color" : "#FF0000",
+ "description" : null
+ },
+ "position" : 2
+ },
+ {
+ "id" : 3,
+ "label" : {
+ "name" : "Production",
+ "color" : "#FF5F00",
+ "description" : null
+ },
+ "position" : 3
+ }
+ ]
+ }
+```
+
+## List board lists
+
+Get a list of the board's lists.
+Does not include `backlog` and `closed` lists
+
+```
+GET /groups/:id/boards/:board_id/lists
+```
+
+| 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 |
+| `board_id` | integer | yes | The ID of a board |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists
+```
+
+Example response:
+
+```json
+[
+ {
+ "id" : 1,
+ "label" : {
+ "name" : "Testing",
+ "color" : "#F0AD4E",
+ "description" : null
+ },
+ "position" : 1
+ },
+ {
+ "id" : 2,
+ "label" : {
+ "name" : "Ready",
+ "color" : "#FF0000",
+ "description" : null
+ },
+ "position" : 2
+ },
+ {
+ "id" : 3,
+ "label" : {
+ "name" : "Production",
+ "color" : "#FF5F00",
+ "description" : null
+ },
+ "position" : 3
+ }
+]
+```
+
+## Single board list
+
+Get a single board list.
+
+```
+GET /groups/:id/boards/:board_id/lists/:list_id
+```
+
+| 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 |
+| `board_id` | integer | yes | The ID of a board |
+| `list_id` | integer | yes | The ID of a board's list |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists/1
+```
+
+Example response:
+
+```json
+{
+ "id" : 1,
+ "label" : {
+ "name" : "Testing",
+ "color" : "#F0AD4E",
+ "description" : null
+ },
+ "position" : 1
+}
+```
+
+## New board list
+
+Creates a new Issue Board list.
+
+```
+POST /groups/:id/boards/:board_id/lists
+```
+
+| 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 |
+| `board_id` | integer | yes | The ID of a board |
+| `label_id` | integer | yes | The ID of a label |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists?label_id=5
+```
+
+Example response:
+
+```json
+{
+ "id" : 1,
+ "label" : {
+ "name" : "Testing",
+ "color" : "#F0AD4E",
+ "description" : null
+ },
+ "position" : 1
+}
+```
+
+## Edit board list
+
+Updates an existing Issue Board list. This call is used to change list position.
+
+```
+PUT /groups/:id/boards/:board_id/lists/:list_id
+```
+
+| 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 |
+| `board_id` | integer | yes | The ID of a board |
+| `list_id` | integer | yes | The ID of a board's list |
+| `position` | integer | yes | The position of the list |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/group/5/boards/1/lists/1?position=2
+```
+
+Example response:
+
+```json
+{
+ "id" : 1,
+ "label" : {
+ "name" : "Testing",
+ "color" : "#F0AD4E",
+ "description" : null
+ },
+ "position" : 1
+}
+```
+
+## Delete a board list
+
+Only for admins and group owners. Soft deletes the board list in question.
+
+```
+DELETE /groups/:id/boards/:board_id/lists/:list_id
+```
+
+| 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 |
+| `board_id` | integer | yes | The ID of a board |
+| `list_id` | integer | yes | The ID of a board's list |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists/1
+```
diff --git a/doc/api/group_milestones.md b/doc/api/group_milestones.md
index a96fb3124fc..21d3ac73000 100644
--- a/doc/api/group_milestones.md
+++ b/doc/api/group_milestones.md
@@ -73,7 +73,7 @@ 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
+- `title` (required) - The title of a 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
diff --git a/doc/api/groups.md b/doc/api/groups.md
index de730cdd869..1aed8aac64e 100644
--- a/doc/api/groups.md
+++ b/doc/api/groups.md
@@ -15,6 +15,7 @@ Parameters:
| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` |
| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
| `statistics` | boolean | no | Include group statistics (admins only) |
+| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `owned` | boolean | no | Limit to groups owned by the current user |
```
@@ -98,6 +99,7 @@ Parameters:
| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` |
| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
| `statistics` | boolean | no | Include group statistics (admins only) |
+| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `owned` | boolean | no | Limit to groups owned by the current user |
```
@@ -145,6 +147,7 @@ Parameters:
| `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 |
| `starred` | boolean | no | Limit by projects starred by the current user |
+| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
Example response:
@@ -204,6 +207,7 @@ 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 |
+| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/4
@@ -521,3 +525,7 @@ And to switch pages add:
```
[ce-15142]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15142
+
+## Group badges
+
+Read more in the [Group Badges](group_badges.md) documentation.
diff --git a/doc/api/issues.md b/doc/api/issues.md
index d2fefbe68aa..7479c1d2f93 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -46,6 +46,10 @@ GET /issues?my_reaction_emoji=star
| `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` |
+| `created_after` | datetime | no | Return issues created on or after the given time |
+| `created_before` | datetime | no | Return issues created on or before the given time |
+| `updated_after` | datetime | no | Return issues updated on or after the given time |
+| `updated_before` | datetime | no | Return issues updated on or before the given time |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/issues
@@ -96,6 +100,7 @@ Example response:
},
"updated_at" : "2016-01-04T15:31:51.081Z",
"closed_at" : null,
+ "closed_by" : null,
"id" : 76,
"title" : "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.",
"created_at" : "2016-01-04T15:31:51.081Z",
@@ -118,6 +123,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.
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## List group issues
Get a list of a group's issues.
@@ -152,6 +159,10 @@ GET /groups/:id/issues?my_reaction_emoji=star
| `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` |
+| `created_after` | datetime | no | Return issues created on or after the given time |
+| `created_before` | datetime | no | Return issues created on or before the given time |
+| `updated_after` | datetime | no | Return issues updated on or after the given time |
+| `updated_before` | datetime | no | Return issues updated on or before the given time |
```bash
@@ -208,6 +219,7 @@ Example response:
"updated_at" : "2016-01-04T15:31:46.176Z",
"created_at" : "2016-01-04T15:31:46.176Z",
"closed_at" : null,
+ "closed_by" : null,
"user_notes_count": 1,
"due_date": null,
"web_url": "http://example.com/example/example/issues/1",
@@ -225,6 +237,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.
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## List project issues
Get a list of a project's issues.
@@ -259,8 +273,10 @@ GET /projects/:id/issues?my_reaction_emoji=star
| `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) |
+| `created_after` | datetime | no | Return issues created on or after the given time |
+| `created_before` | datetime | no | Return issues created on or before the given time |
+| `updated_after` | datetime | no | Return issues updated on or after the given time |
+| `updated_before` | datetime | no | Return issues updated on or before the given time |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues
@@ -316,6 +332,14 @@ Example response:
"updated_at" : "2016-01-04T15:31:46.176Z",
"created_at" : "2016-01-04T15:31:46.176Z",
"closed_at" : "2016-01-05T15:31:46.176Z",
+ "closed_by" : {
+ "state" : "active",
+ "web_url" : "https://gitlab.example.com/root",
+ "avatar_url" : null,
+ "username" : "root",
+ "id" : 1,
+ "name" : "Administrator"
+ },
"user_notes_count": 1,
"due_date": "2016-07-22",
"web_url": "http://example.com/example/example/issues/1",
@@ -333,6 +357,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.
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## Single issue
Get a single project issue.
@@ -399,6 +425,8 @@ Example response:
"title" : "Ut commodi ullam eos dolores perferendis nihil sunt.",
"updated_at" : "2016-01-04T15:31:46.176Z",
"created_at" : "2016-01-04T15:31:46.176Z",
+ "closed_at" : null,
+ "closed_by" : null,
"subscribed": false,
"user_notes_count": 1,
"due_date": null,
@@ -422,6 +450,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.
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## New issue
Creates a new project issue.
@@ -474,6 +504,7 @@ Example response:
"description" : null,
"updated_at" : "2016-01-07T12:44:33.959Z",
"closed_at" : null,
+ "closed_by" : null,
"milestone" : null,
"subscribed" : true,
"user_notes_count": 0,
@@ -498,6 +529,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.
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## Edit issue
Updates an existing project issue. This call is also used to mark an issue as
@@ -546,6 +579,14 @@ Example response:
"description" : null,
"updated_at" : "2016-01-07T12:55:16.213Z",
"closed_at" : "2016-01-08T12:55:16.213Z",
+ "closed_by" : {
+ "state" : "active",
+ "web_url" : "https://gitlab.example.com/root",
+ "avatar_url" : null,
+ "username" : "root",
+ "id" : 1,
+ "name" : "Administrator"
+ },
"iid" : 15,
"labels" : [
"bug"
@@ -577,6 +618,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.
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## Delete an issue
Only for admins and project owners. Soft deletes the issue in question.
@@ -630,6 +673,7 @@ Example response:
"created_at": "2016-04-05T21:41:45.652Z",
"updated_at": "2016-04-07T12:20:17.596Z",
"closed_at": null,
+ "closed_by": null,
"labels": [],
"milestone": null,
"assignees": [{
@@ -677,6 +721,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.
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## Subscribe to an issue
Subscribes the authenticated user to an issue to receive notifications.
@@ -709,6 +755,7 @@ Example response:
"created_at": "2016-04-05T21:41:45.652Z",
"updated_at": "2016-04-07T12:20:17.596Z",
"closed_at": null,
+ "closed_by": null,
"labels": [],
"milestone": null,
"assignees": [{
@@ -756,6 +803,9 @@ Example response:
**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## Unsubscribe from an issue
Unsubscribes the authenticated user from the issue to not receive notifications
@@ -797,6 +847,8 @@ Example response:
"avatar_url": "http://www.gravatar.com/avatar/3e6f06a86cf27fa8b56f3f74f7615987?s=80&d=identicon",
"web_url": "https://gitlab.example.com/keyon"
},
+ "closed_at": null,
+ "closed_by": null,
"author": {
"name": "Vivian Hermann",
"username": "orville",
@@ -917,6 +969,9 @@ Example response:
**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
+**Note**: The `closed_by` attribute was [introduced in GitLab 10.6][ce-17042]. This value will only be present for issues which were closed after GitLab 10.6 and when the user account that closed the issue still exists.
+
## Set a time estimate for an issue
Sets an estimated time of work for this issue.
@@ -1102,6 +1157,8 @@ Example response:
"assignee": null,
"source_project_id": 1,
"target_project_id": 1,
+ "closed_at": null,
+ "closed_by": null,
"labels": [],
"work_in_progress": false,
"milestone": null,
@@ -1124,6 +1181,45 @@ Example response:
```
+## Participants on issues
+
+```
+GET /projects/:id/issues/:issue_iid/participants
+```
+
+| 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/participants
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 1,
+ "name": "John Doe1",
+ "username": "user1",
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
+ "web_url": "http://localhost/user1"
+ },
+ {
+ "id": 5,
+ "name": "John Doe5",
+ "username": "user5",
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/4aea8cf834ed91844a2da4ff7ae6b491?s=80&d=identicon",
+ "web_url": "http://localhost/user5"
+ }
+]
+```
+
+
## Comments on issues
Comments are done via the [notes](notes.md) resource.
@@ -1157,3 +1253,4 @@ Example response:
[ce-13004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13004
[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016
+[ce-17042]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17042
diff --git a/doc/api/jobs.md b/doc/api/jobs.md
index e7060e154f4..db4fe2f6880 100644
--- a/doc/api/jobs.md
+++ b/doc/api/jobs.md
@@ -294,9 +294,10 @@ Example of response
## Get job artifacts
-> [Introduced][ce-2893] in GitLab 8.5
+> **Notes**:
+- [Introduced][ce-2893] in GitLab 8.5.
-Get job artifacts of a project
+Get job artifacts of a project.
```
GET /projects/:id/jobs/:job_id/artifacts
@@ -307,8 +308,10 @@ GET /projects/:id/jobs/:job_id/artifacts
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `job_id` | integer | yes | The ID of a job |
+Example requests:
+
```
-curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/8/artifacts"
+curl --location --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/8/artifacts"
```
Response:
@@ -322,7 +325,8 @@ Response:
## Download the artifacts archive
-> [Introduced][ce-5347] in GitLab 8.10.
+> **Notes**:
+- [Introduced][ce-5347] in GitLab 8.10.
Download the artifacts archive from the given reference name and job provided the
job finished successfully.
@@ -339,7 +343,7 @@ Parameters
| `ref_name` | string | yes | The ref from a repository (can only be branch or tag name, not HEAD or SHA) |
| `job` | string | yes | The name of the job |
-Example request:
+Example requests:
```
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/master/download?job=test"
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index 4d3592e8f71..b9a4f661777 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -41,12 +41,16 @@ Parameters:
| `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) |
+| `created_after` | datetime | no | Return merge requests created on or after the given time |
+| `created_before` | datetime | no | Return merge requests created on or before the given time |
+| `updated_after` | datetime | no | Return merge requests updated on or after the given time |
+| `updated_before` | datetime | no | Return merge requests updated on or before the given time |
| `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` |
| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
+| `source_branch` | string | no | Return merge requests with the given source branch |
+| `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` |
```json
@@ -156,12 +160,16 @@ Parameters:
| `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) |
+| `created_after` | datetime | no | Return merge requests created on or after the given time |
+| `created_before` | datetime | no | Return merge requests created on or before the given time |
+| `updated_after` | datetime | no | Return merge requests updated on or after the given time |
+| `updated_before` | datetime | no | Return merge requests updated on or before the given time |
| `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)_ |
| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
+| `source_branch` | string | no | Return merge requests with the given source branch |
+| `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` |
```json
@@ -257,20 +265,20 @@ Parameters:
"upvotes": 0,
"downvotes": 0,
"author": {
- "id": 1,
- "username": "admin",
- "email": "admin@example.com",
- "name": "Administrator",
- "state": "active",
- "created_at": "2012-04-29T08:46:00Z"
+ "state" : "active",
+ "web_url" : "https://gitlab.example.com/root",
+ "avatar_url" : null,
+ "username" : "root",
+ "id" : 1,
+ "name" : "Administrator"
},
"assignee": {
- "id": 1,
- "username": "admin",
- "email": "admin@example.com",
- "name": "Administrator",
- "state": "active",
- "created_at": "2012-04-29T08:46:00Z"
+ "state" : "active",
+ "web_url" : "https://gitlab.example.com/root",
+ "avatar_url" : null,
+ "username" : "root",
+ "id" : 1,
+ "name" : "Administrator"
},
"source_project_id": 2,
"target_project_id": 3,
@@ -304,10 +312,65 @@ Parameters:
"total_time_spent": 0,
"human_time_estimate": null,
"human_total_time_spent": null
+ },
+ "closed_at": "2018-01-19T14:36:11.086Z",
+ "latest_build_started_at": null,
+ "latest_build_finished_at": null,
+ "first_deployed_to_production_at": null,
+ "pipeline": {
+ "id": 8,
+ "ref": "master",
+ "sha": "2dc6aa325a317eda67812f05600bdf0fcdc70ab0",
+ "status": "created"
+ },
+ "merged_by": null,
+ "merged_at": null,
+ "closed_by": {
+ "state" : "active",
+ "web_url" : "https://gitlab.example.com/root",
+ "avatar_url" : null,
+ "username" : "root",
+ "id" : 1,
+ "name" : "Administrator"
}
}
```
+## Get single MR participants
+
+Get a list of merge request participants.
+
+```
+GET /projects/:id/merge_requests/:merge_request_iid/participants
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `merge_request_iid` (required) - The internal ID of the merge request
+
+
+```json
+[
+ {
+ "id": 1,
+ "name": "John Doe1",
+ "username": "user1",
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
+ "web_url": "http://localhost/user1"
+ },
+ {
+ "id": 2,
+ "name": "John Doe2",
+ "username": "user2",
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=80&d=identicon",
+ "web_url": "http://localhost/user2"
+ },
+]
+```
+
## Get single MR commits
Get a list of merge request commits.
@@ -433,6 +496,32 @@ Parameters:
}
```
+## List MR pipelines
+
+> [Introduced][ce-15454] in GitLab 10.5.0.
+
+Get a list of merge request pipelines.
+
+```
+GET /projects/:id/merge_requests/:merge_request_iid/pipelines
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `merge_request_iid` (required) - The internal ID of the merge request
+
+```json
+[
+ {
+ "id": 77,
+ "sha": "959e04d7c7a30600c894bd3c0cd0e1ce7f42c11d",
+ "ref": "master",
+ "status": "success"
+ }
+]
+```
+
## Create MR
Creates a new merge request.
@@ -440,18 +529,19 @@ Creates a new merge request.
POST /projects/:id/merge_requests
```
-| 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 |
-| `source_branch` | string | yes | The source branch |
-| `target_branch` | string | yes | The target branch |
-| `title` | string | yes | Title of MR |
-| `assignee_id` | integer | no | Assignee user ID |
-| `description` | string | no | Description of MR |
-| `target_project_id` | integer | no | The target project (numeric id) |
-| `labels` | string | no | Labels for MR as a comma-separated list |
-| `milestone_id` | integer | no | The ID of a milestone |
-| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging |
+| 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 |
+| `source_branch` | string | yes | The source branch |
+| `target_branch` | string | yes | The target branch |
+| `title` | string | yes | Title of MR |
+| `assignee_id` | integer | no | Assignee user ID |
+| `description` | string | no | Description of MR |
+| `target_project_id` | integer | no | The target project (numeric id) |
+| `labels` | string | no | Labels for MR as a comma-separated list |
+| `milestone_id` | integer | no | The ID of a milestone |
+| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging |
+| `allow_maintainer_to_push` | boolean | no | Whether or not a maintainer of the target project can push to the source branch |
```json
{
@@ -459,7 +549,7 @@ POST /projects/:id/merge_requests
"iid": 1,
"target_branch": "master",
"source_branch": "test1",
- "project_id": 3,
+ "project_id": 4,
"title": "test1",
"state": "opened",
"upvotes": 0,
@@ -480,7 +570,7 @@ POST /projects/:id/merge_requests
"state": "active",
"created_at": "2012-04-29T08:46:00Z"
},
- "source_project_id": 4,
+ "source_project_id": 3,
"target_project_id": 4,
"labels": [ ],
"description": "fixed login page css paddings",
@@ -507,6 +597,7 @@ POST /projects/:id/merge_requests
"force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1",
"discussion_locked": false,
+ "allow_maintainer_to_push": false,
"time_stats": {
"time_estimate": 0,
"total_time_spent": 0,
@@ -524,19 +615,20 @@ Updates an existing merge request. You can change the target branch, title, or e
PUT /projects/:id/merge_requests/:merge_request_iid
```
-| 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 |
-| `merge_request_iid` | integer | yes | The ID of a merge request |
-| `target_branch` | string | no | The target branch |
-| `title` | string | no | Title of MR |
-| `assignee_id` | integer | no | The ID of the user to assign the merge request to. Set to `0` or provide an empty value to unassign all assignees. |
-| `milestone_id` | integer | no | The ID of a milestone to assign the merge request to. Set to `0` or provide an empty value to unassign a milestone.|
-| `labels` | string | no | Comma-separated label names for an merge request. Set to an empty string to unassign all labels. |
-| `description` | string | no | Description of MR |
-| `state_event` | string | no | New state (close/reopen) |
-| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging |
-| `discussion_locked` | boolean | no | Flag indicating if the merge request's discussion is locked. If the discussion is locked only project members can add, edit or resolve comments. |
+| 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 |
+| `merge_request_iid` | integer | yes | The ID of a merge request |
+| `target_branch` | string | no | The target branch |
+| `title` | string | no | Title of MR |
+| `assignee_id` | integer | no | The ID of the user to assign the merge request to. Set to `0` or provide an empty value to unassign all assignees. |
+| `milestone_id` | integer | no | The ID of a milestone to assign the merge request to. Set to `0` or provide an empty value to unassign a milestone.|
+| `labels` | string | no | Comma-separated label names for a merge request. Set to an empty string to unassign all labels. |
+| `description` | string | no | Description of MR |
+| `state_event` | string | no | New state (close/reopen) |
+| `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging |
+| `discussion_locked` | boolean | no | Flag indicating if the merge request's discussion is locked. If the discussion is locked only project members can add, edit or resolve comments. |
+| `allow_maintainer_to_push` | boolean | no | Whether or not a maintainer of the target project can push to the source branch |
Must include at least one non-required attribute from above.
@@ -545,7 +637,7 @@ Must include at least one non-required attribute from above.
"id": 1,
"iid": 1,
"target_branch": "master",
- "project_id": 3,
+ "project_id": 4,
"title": "test1",
"state": "opened",
"upvotes": 0,
@@ -566,7 +658,7 @@ Must include at least one non-required attribute from above.
"state": "active",
"created_at": "2012-04-29T08:46:00Z"
},
- "source_project_id": 4,
+ "source_project_id": 3,
"target_project_id": 4,
"labels": [ ],
"description": "description1",
@@ -593,6 +685,7 @@ Must include at least one non-required attribute from above.
"force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1",
"discussion_locked": false,
+ "allow_maintainer_to_push": false,
"time_stats": {
"time_estimate": 0,
"total_time_spent": 0,
@@ -1366,3 +1459,4 @@ Example response:
[ce-13060]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13060
[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016
+[ce-15454]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15454
diff --git a/doc/api/milestones.md b/doc/api/milestones.md
index 84930f0bdc9..07e66f89443 100644
--- a/doc/api/milestones.md
+++ b/doc/api/milestones.md
@@ -70,7 +70,7 @@ POST /projects/:id/milestones
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 an milestone
+- `title` (required) - The title of a 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
@@ -93,6 +93,19 @@ Parameters:
- `start_date` (optional) - The start date of the milestone
- `state_event` (optional) - The state event of the milestone (close|activate)
+## Delete project milestone
+
+Only for user with developer access to the project.
+
+```
+DELETE /projects/:id/milestones/:milestone_id
+```
+
+Parameters:
+
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `milestone_id` (required) - The ID of the project's milestone
+
## Get all issues assigned to a single milestone
Gets all issues assigned to a single project milestone.
diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md
index 25cae5ce1f9..1f0dc700640 100644
--- a/doc/api/namespaces.md
+++ b/doc/api/namespaces.md
@@ -39,7 +39,7 @@ Example response:
"path": "group1",
"kind": "group",
"full_path": "group1",
- "parent_id": "null",
+ "parent_id": null,
"members_count_with_descendants": 2
},
{
@@ -48,7 +48,7 @@ Example response:
"path": "bar",
"kind": "group",
"full_path": "foo/bar",
- "parent_id": "9",
+ "parent_id": 9,
"members_count_with_descendants": 5
}
]
@@ -84,7 +84,7 @@ Example response:
"path": "twitter",
"kind": "group",
"full_path": "twitter",
- "parent_id": "null",
+ "parent_id": null,
"members_count_with_descendants": 2
}
]
@@ -117,7 +117,7 @@ Example response:
"path": "group1",
"kind": "group",
"full_path": "group1",
- "parent_id": "null",
+ "parent_id": null,
"members_count_with_descendants": 2
}
```
@@ -137,7 +137,7 @@ Example response:
"path": "group1",
"kind": "group",
"full_path": "group1",
- "parent_id": "null",
+ "parent_id": null,
"members_count_with_descendants": 2
}
```
diff --git a/doc/api/notes.md b/doc/api/notes.md
index d02ef84d0bd..aa38d22845c 100644
--- a/doc/api/notes.md
+++ b/doc/api/notes.md
@@ -15,7 +15,7 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at
| 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
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
| `issue_iid` | integer | yes | The IID of an issue
| `sort` | string | no | Return issue notes sorted in `asc` or `desc` order. Default is `desc`
| `order_by` | string | no | Return issue notes ordered by `created_at` or `updated_at` fields. Default is `created_at`
@@ -63,6 +63,10 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at
]
```
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes
+```
+
### Get single issue note
Returns a single note for a specific project issue
@@ -73,14 +77,17 @@ GET /projects/:id/issues/:issue_iid/notes/:note_id
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `issue_iid` (required) - The IID of a project issue
- `note_id` (required) - The ID of an issue note
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes/1
+```
+
### Create new issue note
-Creates a new note to a single project issue. If you create a note where the body
-only contains an Award Emoji, you'll receive this object back.
+Creates a new note to a single project issue.
```
POST /projects/:id/issues/:issue_iid/notes
@@ -88,11 +95,15 @@ POST /projects/:id/issues/:issue_iid/notes
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `issue_id` (required) - The IID of an issue
- `body` (required) - The content of a note
- `created_at` (optional) - Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes?body=note
+```
+
### Modify existing issue note
Modify existing note of an issue.
@@ -103,11 +114,15 @@ PUT /projects/:id/issues/:issue_iid/notes/:note_id
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `issue_iid` (required) - The IID of an issue
- `note_id` (required) - The ID of a note
- `body` (required) - The content of a note
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/11/notes?body=note
+```
+
### Delete an issue note
Deletes an existing note of an issue.
@@ -120,7 +135,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 |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `issue_iid` | integer | yes | The IID of an issue |
| `note_id` | integer | yes | The ID of a note |
@@ -141,11 +156,15 @@ GET /projects/:id/snippets/:snippet_id/notes?sort=asc&order_by=updated_at
| 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
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
| `snippet_id` | integer | yes | The ID of a project snippet
| `sort` | string | no | Return snippet notes sorted in `asc` or `desc` order. Default is `desc`
| `order_by` | string | no | Return snippet notes ordered by `created_at` or `updated_at` fields. Default is `created_at`
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/notes
+```
+
### Get single snippet note
Returns a single note for a given snippet.
@@ -156,9 +175,9 @@ GET /projects/:id/snippets/:snippet_id/notes/:note_id
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `snippet_id` (required) - The ID of a project snippet
-- `note_id` (required) - The ID of an snippet note
+- `note_id` (required) - The ID of a snippet note
```json
{
@@ -179,6 +198,10 @@ Parameters:
}
```
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/notes/11
+```
+
### Create new snippet note
Creates a new note for a single snippet. Snippet notes are comments users can post to a snippet.
@@ -190,10 +213,14 @@ POST /projects/:id/snippets/:snippet_id/notes
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `snippet_id` (required) - The ID of a snippet
- `body` (required) - The content of a note
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippet/11/notes?body=note
+```
+
### Modify existing snippet note
Modify existing note of a snippet.
@@ -204,11 +231,15 @@ PUT /projects/:id/snippets/:snippet_id/notes/:note_id
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `snippet_id` (required) - The ID of a snippet
- `note_id` (required) - The ID of a note
- `body` (required) - The content of a note
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/notes?body=note
+```
+
### Delete a snippet note
Deletes an existing note of a snippet.
@@ -221,7 +252,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 |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `snippet_id` | integer | yes | The ID of a snippet |
| `note_id` | integer | yes | The ID of a note |
@@ -242,11 +273,15 @@ GET /projects/:id/merge_requests/:merge_request_iid/notes?sort=asc&order_by=upda
| 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
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
| `merge_request_iid` | integer | yes | The IID of a project merge request
| `sort` | string | no | Return merge request notes sorted in `asc` or `desc` order. Default is `desc`
| `order_by` | string | no | Return merge request notes ordered by `created_at` or `updated_at` fields. Default is `created_at`
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/notes
+```
+
### Get single merge request note
Returns a single note for a given merge request.
@@ -257,7 +292,7 @@ GET /projects/:id/merge_requests/:merge_request_iid/notes/:note_id
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `merge_request_iid` (required) - The IID of a project merge request
- `note_id` (required) - The ID of a merge request note
@@ -283,6 +318,10 @@ Parameters:
}
```
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/notes/1
+```
+
### Create new merge request note
Creates a new note for a single merge request.
@@ -295,7 +334,7 @@ POST /projects/:id/merge_requests/:merge_request_iid/notes
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `merge_request_iid` (required) - The IID of a merge request
- `body` (required) - The content of a note
@@ -309,11 +348,15 @@ PUT /projects/:id/merge_requests/:merge_request_iid/notes/:note_id
Parameters:
-- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
+- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `merge_request_iid` (required) - The IID of a merge request
- `note_id` (required) - The ID of a note
- `body` (required) - The content of a note
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/notes?body=note
+```
+
### Delete a merge request note
Deletes an existing note of a merge request.
@@ -326,7 +369,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 |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `note_id` | integer | yes | The ID of a note |
diff --git a/doc/api/notification_settings.md b/doc/api/notification_settings.md
index 3a2c398e355..f05ae647577 100644
--- a/doc/api/notification_settings.md
+++ b/doc/api/notification_settings.md
@@ -24,6 +24,7 @@ reopen_issue
close_issue
reassign_issue
new_merge_request
+push_to_merge_request
reopen_merge_request
close_merge_request
reassign_merge_request
@@ -75,6 +76,7 @@ curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab
| `close_issue` | boolean | no | Enable/disable this notification |
| `reassign_issue` | boolean | no | Enable/disable this notification |
| `new_merge_request` | boolean | no | Enable/disable this notification |
+| `push_to_merge_request` | boolean | no | Enable/disable this notification |
| `reopen_merge_request` | boolean | no | Enable/disable this notification |
| `close_merge_request` | boolean | no | Enable/disable this notification |
| `reassign_merge_request` | boolean | no | Enable/disable this notification |
@@ -141,6 +143,7 @@ curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab
| `close_issue` | boolean | no | Enable/disable this notification |
| `reassign_issue` | boolean | no | Enable/disable this notification |
| `new_merge_request` | boolean | no | Enable/disable this notification |
+| `push_to_merge_request` | boolean | no | Enable/disable this notification |
| `reopen_merge_request` | boolean | no | Enable/disable this notification |
| `close_merge_request` | boolean | no | Enable/disable this notification |
| `reassign_merge_request` | boolean | no | Enable/disable this notification |
@@ -164,6 +167,7 @@ Example responses:
"close_issue": false,
"reassign_issue": false,
"new_merge_request": false,
+ "push_to_merge_request": false,
"reopen_merge_request": false,
"close_merge_request": false,
"reassign_merge_request": false,
diff --git a/doc/api/pages_domains.md b/doc/api/pages_domains.md
index 50685f335f7..20275b902c6 100644
--- a/doc/api/pages_domains.md
+++ b/doc/api/pages_domains.md
@@ -21,6 +21,7 @@ curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/a
{
"domain": "ssl.domain.example",
"url": "https://ssl.domain.example",
+ "project_id": 1337,
"certificate": {
"expired": false,
"expiration": "2020-04-12T14:32:00.000Z"
diff --git a/doc/api/pipeline_triggers.md b/doc/api/pipeline_triggers.md
index 9030ae32d17..e881e61d4ef 100644
--- a/doc/api/pipeline_triggers.md
+++ b/doc/api/pipeline_triggers.md
@@ -24,7 +24,6 @@ curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/
"id": 10,
"description": "my trigger",
"created_at": "2016-01-07T09:53:58.235Z",
- "deleted_at": null,
"last_used": null,
"token": "6d056f63e50fe6f8c5f8f4aa10edb7",
"updated_at": "2016-01-07T09:53:58.235Z",
@@ -55,7 +54,6 @@ curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/
"id": 10,
"description": "my trigger",
"created_at": "2016-01-07T09:53:58.235Z",
- "deleted_at": null,
"last_used": null,
"token": "6d056f63e50fe6f8c5f8f4aa10edb7",
"updated_at": "2016-01-07T09:53:58.235Z",
@@ -85,7 +83,6 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form descri
"id": 10,
"description": "my trigger",
"created_at": "2016-01-07T09:53:58.235Z",
- "deleted_at": null,
"last_used": null,
"token": "6d056f63e50fe6f8c5f8f4aa10edb7",
"updated_at": "2016-01-07T09:53:58.235Z",
@@ -116,7 +113,6 @@ curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form descrip
"id": 10,
"description": "my trigger",
"created_at": "2016-01-07T09:53:58.235Z",
- "deleted_at": null,
"last_used": null,
"token": "6d056f63e50fe6f8c5f8f4aa10edb7",
"updated_at": "2016-01-07T09:53:58.235Z",
@@ -146,7 +142,6 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitl
"id": 10,
"description": "my trigger",
"created_at": "2016-01-07T09:53:58.235Z",
- "deleted_at": null,
"last_used": null,
"token": "6d056f63e50fe6f8c5f8f4aa10edb7",
"updated_at": "2016-01-07T09:53:58.235Z",
diff --git a/doc/api/project_badges.md b/doc/api/project_badges.md
new file mode 100644
index 00000000000..94389273e9c
--- /dev/null
+++ b/doc/api/project_badges.md
@@ -0,0 +1,191 @@
+# Project badges API
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17082)
+in GitLab 10.6.
+
+## Placeholder tokens
+
+Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are:
+
+- **%{project_path}**: will be replaced by the project path.
+- **%{project_id}**: will be replaced by the project id.
+- **%{default_branch}**: will be replaced by the project default branch.
+- **%{commit_sha}**: will be replaced by the last project's commit sha.
+
+## List all badges of a project
+
+Gets a list of a project's badges and its group badges.
+
+```
+GET /projects/:id/badges
+```
+
+| 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/:id/badges
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 1,
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "project"
+ },
+ {
+ "id": 2,
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "group"
+ },
+]
+```
+
+## Get a badge of a project
+
+Gets a badge of a project.
+
+```
+GET /projects/:id/badges/:badge_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 |
+| `badge_id` | integer | yes | The badge ID |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "project"
+}
+```
+
+## Add a badge to a project
+
+Adds a badge to a project.
+
+```
+POST /projects/:id/badges
+```
+
+| 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 |
+| `link_url` | string | yes | URL of the badge link |
+| `image_url` | string | yes | URL of the badge image |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/projects/:id/badges
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "image_url": "https://shields.io/my/badge1",
+ "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "rendered_image_url": "https://shields.io/my/badge1",
+ "kind": "project"
+}
+```
+
+## Edit a badge of a project
+
+Updates a badge of a project.
+
+```
+PUT /projects/:id/badges/:badge_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 |
+| `badge_id` | integer | yes | The badge ID |
+| `link_url` | string | no | URL of the badge link |
+| `image_url` | string | no | URL of the badge image |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
+ "rendered_image_url": "https://shields.io/my/badge",
+ "kind": "project"
+}
+```
+
+## Remove a badge from a project
+
+Removes a badge from a project. Only project's badges will be removed by using this endpoint.
+
+```
+DELETE /projects/:id/badges/:badge_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 |
+| `badge_id` | integer | yes | The badge ID |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
+```
+
+## Preview a badge from a project
+
+Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation.
+
+```
+GET /projects/:id/badges/render
+```
+
+| 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 |
+| `link_url` | string | yes | URL of the badge link|
+| `image_url` | string | yes | URL of the badge image |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge
+```
+
+Example response:
+
+```json
+{
+ "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
+ "image_url": "https://shields.io/my/badge",
+ "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
+ "rendered_image_url": "https://shields.io/my/badge",
+}
+```
diff --git a/doc/api/project_import_export.md b/doc/api/project_import_export.md
new file mode 100644
index 00000000000..d8f61852b11
--- /dev/null
+++ b/doc/api/project_import_export.md
@@ -0,0 +1,174 @@
+# Project import/export API
+
+[Introduced][ce-41899] in GitLab 10.6
+
+[See also the project import/export documentation](../user/project/settings/import_export.md)
+
+## Schedule an export
+
+Start a new export.
+
+The endpoint also accepts an `upload` param. This param is a hash that contains
+all the necessary information to upload the exported project to a web server or
+to any S3-compatible platform. At the moment we only support binary
+data file uploads to the final server.
+
+If the `upload` params is present, `upload[url]` param is required.
+ (**Note:** This feature was introduced in GitLab 10.7)
+
+```http
+POST /projects/:id/export
+```
+
+| 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 | no | Overrides the project description |
+| `upload` | hash | no | Hash that contains the information to upload the exported project to a web server |
+| `upload[url]` | string | yes | The URL to upload the project |
+| `upload[http_method]` | string | no | The HTTP method to upload the exported project. Only `PUT` and `POST` methods allowed. Default is `PUT` |
+
+```console
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/export --data "description=FooBar&upload[http_method]=PUT&upload[url]=https://example-bucket.s3.eu-west-3.amazonaws.com/backup?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIMBJHN2O62W8IELQ%2F20180312%2Feu-west-3%2Fs3%2Faws4_request&X-Amz-Date=20180312T110328Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=8413facb20ff33a49a147a0b4abcff4c8487cc33ee1f7e450c46e8f695569dbd"
+```
+
+```json
+{
+ "message": "202 Accepted"
+}
+```
+
+## Export status
+
+Get the status of export.
+
+```http
+GET /projects/:id/export
+```
+
+| 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 |
+
+```console
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/export
+```
+
+Status can be one of `none`, `started`, `after_export_action` or `finished`. The
+`after_export_action` state represents that the export process has been completed successfully and
+the platform is performing some actions on the resulted file. For example, sending
+an email notifying the user to download the file, uploading the exported file
+to a web server, etc.
+
+`_links` are only present when export has finished.
+
+```json
+{
+ "id": 1,
+ "description": "Itaque perspiciatis minima aspernatur corporis consequatur.",
+ "name": "Gitlab Test",
+ "name_with_namespace": "Gitlab Org / Gitlab Test",
+ "path": "gitlab-test",
+ "path_with_namespace": "gitlab-org/gitlab-test",
+ "created_at": "2017-08-29T04:36:44.383Z",
+ "export_status": "finished",
+ "_links": {
+ "api_url": "https://gitlab.example.com/api/v4/projects/1/export/download",
+ "web_url": "https://gitlab.example.com/gitlab-org/gitlab-test/download_export",
+ }
+}
+```
+
+## Export download
+
+Download the finished export.
+
+```http
+GET /projects/:id/export/download
+```
+
+| 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 |
+
+```console
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --remote-header-name --remote-name https://gitlab.example.com/api/v4/projects/5/export/download
+```
+
+```console
+ls *export.tar.gz
+2017-12-05_22-11-148_namespace_project_export.tar.gz
+```
+
+## Import a file
+
+```http
+POST /projects/import
+```
+
+| Attribute | Type | Required | Description |
+| --------- | -------------- | -------- | ---------------------------------------- |
+| `namespace` | integer/string | no | The ID or path of the namespace that the project will be imported to. Defaults to the current user's namespace |
+| `file` | string | yes | The file to be uploaded |
+| `path` | string | yes | Name and path for new project |
+| `overwrite` | boolean | no | If there is a project with the same path the import will overwrite it. Default to false |
+| `override_params` | Hash | no | Supports all fields defined in the [Project API](projects.md)] |
+
+The override params passed will take precendence over all values defined inside the export file.
+
+To upload a file from your filesystem, use the `--form` argument. This causes
+cURL to post data using the header `Content-Type: multipart/form-data`.
+The `file=` parameter must point to a file on your filesystem and be preceded
+by `@`. For example:
+
+```console
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "path=api-project" --form "file=@/path/to/file" https://gitlab.example.com/api/v4/projects/import
+```
+
+```json
+{
+ "id": 1,
+ "description": null,
+ "name": "api-project",
+ "name_with_namespace": "Administrator / api-project",
+ "path": "api-project",
+ "path_with_namespace": "root/api-project",
+ "created_at": "2018-02-13T09:05:58.023Z",
+ "import_status": "scheduled"
+}
+```
+
+## Import status
+
+Get the status of an import.
+
+```http
+GET /projects/:id/import
+```
+
+| 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 |
+
+```console
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/import
+```
+
+Status can be one of `none`, `scheduled`, `failed`, `started`, or `finished`.
+
+If the status is `failed`, it will include the import error message under `import_error`.
+
+```json
+{
+ "id": 1,
+ "description": "Itaque perspiciatis minima aspernatur corporis consequatur.",
+ "name": "Gitlab Test",
+ "name_with_namespace": "Gitlab Org / Gitlab Test",
+ "path": "gitlab-test",
+ "path_with_namespace": "gitlab-org/gitlab-test",
+ "created_at": "2017-08-29T04:36:44.383Z",
+ "import_status": "started"
+}
+```
+
+[ce-41899]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41899
diff --git a/doc/api/project_snippets.md b/doc/api/project_snippets.md
index ad2521230e6..cc495c5d091 100644
--- a/doc/api/project_snippets.md
+++ b/doc/api/project_snippets.md
@@ -131,12 +131,13 @@ 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 |
+| Attribute | Type | Required | Description |
+|---------------|---------|----------|--------------------------------------|
+| `id` | Integer | yes | The ID of a project |
+| `snippet_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
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/snippets/2/user_agent_detail
```
Example response:
diff --git a/doc/api/projects.md b/doc/api/projects.md
index 5a403f7593a..a0cb5aa0820 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -16,6 +16,21 @@ Values for the project visibility level are:
* `public`:
The project can be cloned without any authentication.
+## Project merge method
+
+There are currently three options for `merge_method` to choose from:
+
+* `merge`:
+ A merge commit is created for every merge, and merging is allowed as long as there are no conflicts.
+
+* `rebase_merge`:
+ A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible.
+ This way you could make sure that if this merge request would build, after merging to target branch it would also build.
+
+* `ff`:
+ No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.
+
+
## List all projects
Get a list of all visible projects across GitLab for the authenticated user.
@@ -37,6 +52,7 @@ GET /projects
| `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_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
@@ -93,6 +109,7 @@ GET /projects
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"statistics": {
"commit_count": 37,
"storage_size": 1038090,
@@ -172,6 +189,7 @@ GET /projects
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"statistics": {
"commit_count": 12,
"storage_size": 2066080,
@@ -220,6 +238,7 @@ GET /users/:user_id/projects
| `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_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
@@ -276,6 +295,7 @@ GET /users/:user_id/projects
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"statistics": {
"commit_count": 37,
"storage_size": 1038090,
@@ -355,6 +375,7 @@ GET /users/:user_id/projects
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"statistics": {
"commit_count": 12,
"storage_size": 2066080,
@@ -388,6 +409,7 @@ GET /projects/:id
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `statistics` | boolean | no | Include project statistics |
+| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
```json
{
@@ -464,6 +486,7 @@ GET /projects/:id
"only_allow_merge_if_all_discussions_are_resolved": false,
"printing_merge_requests_link_enabled": true,
"request_access_enabled": false,
+ "merge_method": "merge",
"statistics": {
"commit_count": 37,
"storage_size": 1038090,
@@ -547,6 +570,7 @@ POST /projects
| `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 |
+| `merge_method` | string | no | Set the merge method used |
| `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 |
@@ -583,6 +607,7 @@ POST /projects/user/:user_id
| `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 |
+| `merge_method` | string | no | Set the merge method used |
| `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 |
@@ -618,6 +643,7 @@ PUT /projects/:id
| `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 |
+| `merge_method` | string | no | Set the merge method used |
| `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 |
@@ -664,6 +690,7 @@ GET /projects/:id/forks
| `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_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
| `with_issues_enabled` | boolean | no | Limit by enabled issues feature |
| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature |
@@ -720,6 +747,7 @@ Example responses:
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"_links": {
"self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues",
@@ -797,6 +825,7 @@ Example response:
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"_links": {
"self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues",
@@ -873,6 +902,7 @@ Example response:
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"_links": {
"self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues",
@@ -967,6 +997,7 @@ Example response:
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"_links": {
"self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues",
@@ -982,7 +1013,7 @@ Example response:
## Unarchive a project
Unarchives the project if the user is either admin or the project owner of this project. This action is
-idempotent, thus unarchiving an non-archived project will not change the project.
+idempotent, thus unarchiving a non-archived project will not change the project.
```
POST /projects/:id/unarchive
@@ -1061,6 +1092,7 @@ Example response:
"only_allow_merge_if_pipeline_succeeds": false,
"only_allow_merge_if_all_discussions_are_resolved": false,
"request_access_enabled": false,
+ "merge_method": "merge",
"_links": {
"self": "http://example.com/api/v4/projects",
"issues": "http://example.com/api/v4/projects/1/issues",
@@ -1190,6 +1222,7 @@ GET /projects/:id/hooks/:hook_id
"project_id": 3,
"push_events": true,
"issues_events": true,
+ "confidential_issues_events": true,
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
@@ -1215,12 +1248,13 @@ POST /projects/:id/hooks
| `url` | string | yes | The hook URL |
| `push_events` | boolean | no | Trigger hook on push events |
| `issues_events` | boolean | no | Trigger hook on issues events |
+| `confidential_issues_events` | boolean | no | Trigger hook on confidential issues events |
| `merge_requests_events` | boolean | no | Trigger hook on merge requests events |
| `tag_push_events` | boolean | no | Trigger hook on tag push events |
| `note_events` | boolean | no | Trigger hook on note events |
| `job_events` | boolean | no | Trigger hook on job events |
| `pipeline_events` | boolean | no | Trigger hook on pipeline events |
-| `wiki_events` | boolean | no | Trigger hook on wiki events |
+| `wiki_page_events` | boolean | no | Trigger hook on wiki events |
| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook |
| `token` | string | no | Secret token to validate received payloads; this will not be returned in the response |
@@ -1239,6 +1273,7 @@ PUT /projects/:id/hooks/:hook_id
| `url` | string | yes | The hook URL |
| `push_events` | boolean | no | Trigger hook on push events |
| `issues_events` | boolean | no | Trigger hook on issues events |
+| `confidential_issues_events` | boolean | no | Trigger hook on confidential issues events |
| `merge_requests_events` | boolean | no | Trigger hook on merge requests events |
| `tag_push_events` | boolean | no | Trigger hook on tag push events |
| `note_events` | boolean | no | Trigger hook on note events |
@@ -1326,6 +1361,18 @@ POST /projects/:id/housekeeping
Read more in the [Branches](branches.md) documentation.
+## Project Import/Export
+
+Read more in the [Project import/export](project_import_export.md) documentation.
+
## Project members
Read more in the [Project members](members.md) documentation.
+
+## Project badges
+
+Read more in the [Project Badges](project_badges.md) documentation.
+
+## Issue and merge request description templates
+
+The non-default [issue and merge request description templates](../user/project/description_templates.md) are managed inside the project's repository. So you can manage them via the API through the [Repositories API](repositories.md) and the [Repository Files API](repository_files.md). \ No newline at end of file
diff --git a/doc/api/repositories.md b/doc/api/repositories.md
index 03b32577872..96609cd530f 100644
--- a/doc/api/repositories.md
+++ b/doc/api/repositories.md
@@ -113,7 +113,8 @@ GET /projects/:id/repository/archive
Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
-- `sha` (optional) - The commit SHA to download defaults to the tip of the default branch
+- `sha` (optional) - The commit SHA to download. A tag, branch reference or sha can be used. This defaults to the tip of the default branch if not specified
+- `format` (optional) - The archive format. Default is `tar.gz`. Options are `tar.gz`, `tar.bz2`, `tbz`, `tbz2`, `tb2`, `bz2`, `tar`, `zip`
## Compare branches, tags or commits
diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md
index a1a0b1b756c..c29dc22e12d 100644
--- a/doc/api/repository_files.md
+++ b/doc/api/repository_files.md
@@ -68,7 +68,7 @@ Example response:
```json
{
- "file_name": "app/project.rb",
+ "file_path": "app/project.rb",
"branch": "master"
}
```
@@ -98,7 +98,7 @@ Example response:
```json
{
- "file_name": "app/project.rb",
+ "file_path": "app/project.rb",
"branch": "master"
}
```
@@ -134,15 +134,6 @@ DELETE /projects/:id/repository/files/:file_path
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:
-
-```json
-{
- "file_name": "app/project.rb",
- "branch": "master"
-}
-```
-
Parameters:
- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb
diff --git a/doc/api/runners.md b/doc/api/runners.md
index 015b09a745e..f384ac57bfe 100644
--- a/doc/api/runners.md
+++ b/doc/api/runners.md
@@ -30,14 +30,18 @@ Example response:
"description": "test-1-20150125",
"id": 6,
"is_shared": false,
- "name": null
+ "name": null,
+ "online": true,
+ "status": "online"
},
{
"active": true,
"description": "test-2-20150125",
"id": 8,
"is_shared": false,
- "name": null
+ "name": null,
+ "online": false,
+ "status": "offline"
}
]
```
@@ -69,28 +73,36 @@ Example response:
"description": "shared-runner-1",
"id": 1,
"is_shared": true,
- "name": null
+ "name": null,
+ "online": true,
+ "status": "online"
},
{
"active": true,
"description": "shared-runner-2",
"id": 3,
"is_shared": true,
- "name": null
+ "name": null,
+ "online": false
+ "status": "offline"
},
{
"active": true,
"description": "test-1-20150125",
"id": 6,
"is_shared": false,
- "name": null
+ "name": null,
+ "online": true
+ "status": "paused"
},
{
"active": true,
"description": "test-2-20150125",
"id": 8,
"is_shared": false,
- "name": null
+ "name": null,
+ "online": false,
+ "status": "offline"
}
]
```
@@ -122,6 +134,8 @@ Example response:
"is_shared": false,
"contacted_at": "2016-01-25T16:39:48.066Z",
"name": null,
+ "online": true,
+ "status": "online",
"platform": null,
"projects": [
{
@@ -139,7 +153,8 @@ Example response:
"mysql"
],
"version": null,
- "access_level": "ref_protected"
+ "access_level": "ref_protected",
+ "maximum_timeout": 3600
}
```
@@ -160,6 +175,7 @@ PUT /runners/:id
| `run_untagged` | boolean | no | Flag indicating the runner can execute untagged jobs |
| `locked` | boolean | no | Flag indicating the runner is locked |
| `access_level` | string | no | The access_level of the runner; `not_protected` or `ref_protected` |
+| `maximum_timeout` | integer | no | Maximum timeout set when this Runner will handle the job |
```
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners/6" --form "description=test-1-20150125-test" --form "tag_list=ruby,mysql,tag1,tag2"
@@ -176,6 +192,8 @@ Example response:
"is_shared": false,
"contacted_at": "2016-01-25T16:39:48.066Z",
"name": null,
+ "online": true,
+ "status": "online",
"platform": null,
"projects": [
{
@@ -195,7 +213,8 @@ Example response:
"tag2"
],
"version": null,
- "access_level": "ref_protected"
+ "access_level": "ref_protected",
+ "maximum_timeout": null
}
```
@@ -327,14 +346,18 @@ Example response:
"description": "test-2-20150125",
"id": 8,
"is_shared": false,
- "name": null
+ "name": null,
+ "online": false,
+ "status": "offline"
},
{
"active": true,
"description": "development_runner",
"id": 5,
"is_shared": true,
- "name": null
+ "name": null,
+ "online": true
+ "status": "paused"
}
]
```
@@ -364,7 +387,9 @@ Example response:
"description": "test-2016-02-01",
"id": 9,
"is_shared": false,
- "name": null
+ "name": null,
+ "online": true,
+ "status": "online"
}
```
diff --git a/doc/api/search.md b/doc/api/search.md
new file mode 100644
index 00000000000..107ddaffa6a
--- /dev/null
+++ b/doc/api/search.md
@@ -0,0 +1,800 @@
+# Search API
+
+[Introduced][ce-41763] in GitLab 10.5
+
+Every API call to search must be authenticated.
+
+## Global Search API
+
+Search globally across the GitLab instance.
+
+```
+GET /search
+```
+
+| Attribute | Type | Required | Description |
+| ------------------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------|
+| `scope` | string | yes | The scope to search in |
+| `search` | string | yes | The search query |
+
+Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs.
+
+The response depends on the requested scope.
+
+### Scope: projects
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=projects&search=flight
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 6,
+ "description": "Nobis sed ipsam vero quod cupiditate veritatis hic.",
+ "name": "Flight",
+ "name_with_namespace": "Twitter / Flight",
+ "path": "flight",
+ "path_with_namespace": "twitter/flight",
+ "created_at": "2017-09-05T07:58:01.621Z",
+ "default_branch": "master",
+ "tag_list":[],
+ "ssh_url_to_repo": "ssh://jarka@localhost:2222/twitter/flight.git",
+ "http_url_to_repo": "http://localhost:3000/twitter/flight.git",
+ "web_url": "http://localhost:3000/twitter/flight",
+ "avatar_url": null,
+ "star_count": 0,
+ "forks_count": 0,
+ "last_activity_at": "2018-01-31T09:56:30.902Z"
+ }
+]
+```
+
+### Scope: issues
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=issues&search=file
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 83,
+ "iid": 1,
+ "project_id": 12,
+ "title": "Add file",
+ "description": "Add first file",
+ "state": "opened",
+ "created_at": "2018-01-24T06:02:15.514Z",
+ "updated_at": "2018-02-06T12:36:23.263Z",
+ "closed_at": null,
+ "labels":[],
+ "milestone": null,
+ "assignees": [{
+ "id": 20,
+ "name": "Ceola Deckow",
+ "username": "sammy.collier",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon",
+ "web_url": "http://localhost:3000/sammy.collier"
+ }],
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "assignee": {
+ "id": 20,
+ "name": "Ceola Deckow",
+ "username": "sammy.collier",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon",
+ "web_url": "http://localhost:3000/sammy.collier"
+ },
+ "user_notes_count": 0,
+ "upvotes": 0,
+ "downvotes": 0,
+ "due_date": null,
+ "confidential": false,
+ "discussion_locked": null,
+ "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
+ }
+]
+```
+
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
+### Scope: merge_requests
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=merge_requests&search=file
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 56,
+ "iid": 8,
+ "project_id": 6,
+ "title": "Add first file",
+ "description": "This is a test MR to add file",
+ "state": "opened",
+ "created_at": "2018-01-22T14:21:50.830Z",
+ "updated_at": "2018-02-06T12:40:33.295Z",
+ "target_branch": "master",
+ "source_branch": "jaja-test",
+ "upvotes": 0,
+ "downvotes": 0,
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "assignee": {
+ "id": 5,
+ "name": "Jacquelyn Kutch",
+ "username": "abigail",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon",
+ "web_url": "http://localhost:3000/abigail"
+ },
+ "source_project_id": 6,
+ "target_project_id": 6,
+ "labels": [
+ "ruby",
+ "tests"
+ ],
+ "work_in_progress": false,
+ "milestone": {
+ "id": 13,
+ "iid": 3,
+ "project_id": 6,
+ "title": "v2.0",
+ "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.",
+ "state": "active",
+ "created_at": "2017-09-05T07:58:29.099Z",
+ "updated_at": "2017-09-05T07:58:29.099Z",
+ "due_date": null,
+ "start_date": null
+ },
+ "merge_when_pipeline_succeeds": false,
+ "merge_status": "can_be_merged",
+ "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b",
+ "merge_commit_sha": null,
+ "user_notes_count": 0,
+ "discussion_locked": null,
+ "should_remove_source_branch": null,
+ "force_remove_source_branch": true,
+ "web_url": "http://localhost:3000/twitter/flight/merge_requests/8",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
+ }
+]
+```
+
+### Scope: milestones
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=milestones&search=release
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 44,
+ "iid": 1,
+ "project_id": 12,
+ "title": "next release",
+ "description": "Next release milestone",
+ "state": "active",
+ "created_at": "2018-02-06T12:43:39.271Z",
+ "updated_at": "2018-02-06T12:44:01.298Z",
+ "due_date": "2018-04-18",
+ "start_date": "2018-02-04"
+ }
+]
+```
+
+### Scope: snippet_titles
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=snippet_titles&search=sample
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 50,
+ "title": "Sample file",
+ "file_name": "file.rb",
+ "description": "Simple ruby file",
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "updated_at": "2018-02-06T12:49:29.104Z",
+ "created_at": "2017-11-28T08:20:18.071Z",
+ "project_id": 9,
+ "web_url": "http://localhost:3000/root/jira-test/snippets/50"
+ }
+]
+```
+
+### Scope: snippet_blobs
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/search?scope=snippet_blos&search=test
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 50,
+ "title": "Sample file",
+ "file_name": "file.rb",
+ "description": "Simple ruby file",
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "updated_at": "2018-02-06T12:49:29.104Z",
+ "created_at": "2017-11-28T08:20:18.071Z",
+ "project_id": 9,
+ "web_url": "http://localhost:3000/root/jira-test/snippets/50"
+ }
+]
+```
+
+
+## Group Search API
+
+Search within the specified group.
+
+If a user is not a member of a group and the group is private, a `GET` request on that group will result to a `404` status code.
+
+```
+GET /groups/:id/search
+```
+
+| 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 |
+| `scope` | string | yes | The scope to search in |
+| `search` | string | yes | The search query |
+
+Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones.
+
+The response depends on the requested scope.
+
+### Scope: projects
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=projects&search=flight
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 6,
+ "description": "Nobis sed ipsam vero quod cupiditate veritatis hic.",
+ "name": "Flight",
+ "name_with_namespace": "Twitter / Flight",
+ "path": "flight",
+ "path_with_namespace": "twitter/flight",
+ "created_at": "2017-09-05T07:58:01.621Z",
+ "default_branch": "master",
+ "tag_list":[],
+ "ssh_url_to_repo": "ssh://jarka@localhost:2222/twitter/flight.git",
+ "http_url_to_repo": "http://localhost:3000/twitter/flight.git",
+ "web_url": "http://localhost:3000/twitter/flight",
+ "avatar_url": null,
+ "star_count": 0,
+ "forks_count": 0,
+ "last_activity_at": "2018-01-31T09:56:30.902Z"
+ }
+]
+```
+
+### Scope: issues
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=issues&search=file
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 83,
+ "iid": 1,
+ "project_id": 12,
+ "title": "Add file",
+ "description": "Add first file",
+ "state": "opened",
+ "created_at": "2018-01-24T06:02:15.514Z",
+ "updated_at": "2018-02-06T12:36:23.263Z",
+ "closed_at": null,
+ "labels":[],
+ "milestone": null,
+ "assignees": [{
+ "id": 20,
+ "name": "Ceola Deckow",
+ "username": "sammy.collier",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon",
+ "web_url": "http://localhost:3000/sammy.collier"
+ }],
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "assignee": {
+ "id": 20,
+ "name": "Ceola Deckow",
+ "username": "sammy.collier",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon",
+ "web_url": "http://localhost:3000/sammy.collier"
+ },
+ "user_notes_count": 0,
+ "upvotes": 0,
+ "downvotes": 0,
+ "due_date": null,
+ "confidential": false,
+ "discussion_locked": null,
+ "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
+ }
+]
+```
+
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
+### Scope: merge_requests
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=merge_requests&search=file
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 56,
+ "iid": 8,
+ "project_id": 6,
+ "title": "Add first file",
+ "description": "This is a test MR to add file",
+ "state": "opened",
+ "created_at": "2018-01-22T14:21:50.830Z",
+ "updated_at": "2018-02-06T12:40:33.295Z",
+ "target_branch": "master",
+ "source_branch": "jaja-test",
+ "upvotes": 0,
+ "downvotes": 0,
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "assignee": {
+ "id": 5,
+ "name": "Jacquelyn Kutch",
+ "username": "abigail",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon",
+ "web_url": "http://localhost:3000/abigail"
+ },
+ "source_project_id": 6,
+ "target_project_id": 6,
+ "labels": [
+ "ruby",
+ "tests"
+ ],
+ "work_in_progress": false,
+ "milestone": {
+ "id": 13,
+ "iid": 3,
+ "project_id": 6,
+ "title": "v2.0",
+ "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.",
+ "state": "active",
+ "created_at": "2017-09-05T07:58:29.099Z",
+ "updated_at": "2017-09-05T07:58:29.099Z",
+ "due_date": null,
+ "start_date": null
+ },
+ "merge_when_pipeline_succeeds": false,
+ "merge_status": "can_be_merged",
+ "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b",
+ "merge_commit_sha": null,
+ "user_notes_count": 0,
+ "discussion_locked": null,
+ "should_remove_source_branch": null,
+ "force_remove_source_branch": true,
+ "web_url": "http://localhost:3000/twitter/flight/merge_requests/8",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
+ }
+]
+```
+
+### Scope: milestones
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=milestones&search=release
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 44,
+ "iid": 1,
+ "project_id": 12,
+ "title": "next release",
+ "description": "Next release milestone",
+ "state": "active",
+ "created_at": "2018-02-06T12:43:39.271Z",
+ "updated_at": "2018-02-06T12:44:01.298Z",
+ "due_date": "2018-04-18",
+ "start_date": "2018-02-04"
+ }
+]
+```
+
+## Project Search API
+
+Search within the specified project.
+
+If a user is not a member of a project and the project is private, a `GET` request on that project will result to a `404` status code.
+
+```
+GET /projects/:id/search
+```
+
+| 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 | yes | The scope to search in |
+| `search` | string | yes | The search query |
+
+Search the expression within the specified scope. Currently these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs.
+
+The response depends on the requested scope.
+
+
+### Scope: issues
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/search?scope=issues&search=file
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 83,
+ "iid": 1,
+ "project_id": 12,
+ "title": "Add file",
+ "description": "Add first file",
+ "state": "opened",
+ "created_at": "2018-01-24T06:02:15.514Z",
+ "updated_at": "2018-02-06T12:36:23.263Z",
+ "closed_at": null,
+ "labels":[],
+ "milestone": null,
+ "assignees": [{
+ "id": 20,
+ "name": "Ceola Deckow",
+ "username": "sammy.collier",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon",
+ "web_url": "http://localhost:3000/sammy.collier"
+ }],
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "assignee": {
+ "id": 20,
+ "name": "Ceola Deckow",
+ "username": "sammy.collier",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c23d85a4f50e0ea76ab739156c639231?s=80&d=identicon",
+ "web_url": "http://localhost:3000/sammy.collier"
+ },
+ "user_notes_count": 0,
+ "upvotes": 0,
+ "downvotes": 0,
+ "due_date": null,
+ "confidential": false,
+ "discussion_locked": null,
+ "web_url": "http://localhost:3000/h5bp/7bp/subgroup-prj/issues/1",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
+ }
+]
+```
+
+**Note**: `assignee` column is deprecated, now we show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
+### Scope: merge_requests
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=merge_requests&search=file
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 56,
+ "iid": 8,
+ "project_id": 6,
+ "title": "Add first file",
+ "description": "This is a test MR to add file",
+ "state": "opened",
+ "created_at": "2018-01-22T14:21:50.830Z",
+ "updated_at": "2018-02-06T12:40:33.295Z",
+ "target_branch": "master",
+ "source_branch": "jaja-test",
+ "upvotes": 0,
+ "downvotes": 0,
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/root"
+ },
+ "assignee": {
+ "id": 5,
+ "name": "Jacquelyn Kutch",
+ "username": "abigail",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/3138c66095ee4bd11a508c2f7f7772da?s=80&d=identicon",
+ "web_url": "http://localhost:3000/abigail"
+ },
+ "source_project_id": 6,
+ "target_project_id": 6,
+ "labels": [
+ "ruby",
+ "tests"
+ ],
+ "work_in_progress": false,
+ "milestone": {
+ "id": 13,
+ "iid": 3,
+ "project_id": 6,
+ "title": "v2.0",
+ "description": "Qui aut qui eos dolor beatae itaque tempore molestiae.",
+ "state": "active",
+ "created_at": "2017-09-05T07:58:29.099Z",
+ "updated_at": "2017-09-05T07:58:29.099Z",
+ "due_date": null,
+ "start_date": null
+ },
+ "merge_when_pipeline_succeeds": false,
+ "merge_status": "can_be_merged",
+ "sha": "78765a2d5e0a43585945c58e61ba2f822e4d090b",
+ "merge_commit_sha": null,
+ "user_notes_count": 0,
+ "discussion_locked": null,
+ "should_remove_source_branch": null,
+ "force_remove_source_branch": true,
+ "web_url": "http://localhost:3000/twitter/flight/merge_requests/8",
+ "time_stats": {
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null
+ }
+ }
+]
+```
+
+### Scope: milestones
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/search?scope=milestones&search=release
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 44,
+ "iid": 1,
+ "project_id": 12,
+ "title": "next release",
+ "description": "Next release milestone",
+ "state": "active",
+ "created_at": "2018-02-06T12:43:39.271Z",
+ "updated_at": "2018-02-06T12:44:01.298Z",
+ "due_date": "2018-04-18",
+ "start_date": "2018-02-04"
+ }
+]
+```
+
+### Scope: notes
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=notes&search=maxime
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 191,
+ "body": "Harum maxime consequuntur et et deleniti assumenda facilis.",
+ "attachment": null,
+ "author": {
+ "id": 23,
+ "name": "User 1",
+ "username": "user1",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/111d68d06e2d317b5a59c2c6c5bad808?s=80&d=identicon",
+ "web_url": "http://localhost:3000/user1"
+ },
+ "created_at": "2017-09-05T08:01:32.068Z",
+ "updated_at": "2017-09-05T08:01:32.068Z",
+ "system": false,
+ "noteable_id": 22,
+ "noteable_type": "Issue",
+ "noteable_iid": 2
+ }
+]
+```
+
+### Scope: wiki_blobs
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=wiki_blobs&search=bye
+```
+
+Example response:
+
+```json
+
+[
+ {
+ "basename": "home",
+ "data": "hello\n\nand bye\n\nend",
+ "filename": "home.md",
+ "id": null,
+ "ref": "master",
+ "startline": 5,
+ "project_id": 6
+ }
+]
+```
+
+### Scope: commits
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=commits&search=bye
+```
+
+Example response:
+
+```json
+
+[
+ {
+ "id": "4109c2d872d5fdb1ed057400d103766aaea97f98",
+ "short_id": "4109c2d8",
+ "title": "goodbye $.browser",
+ "created_at": "2013-02-18T22:02:54.000Z",
+ "parent_ids": [
+ "59d05353ab575bcc2aa958fe1782e93297de64c9"
+ ],
+ "message": "goodbye $.browser\n",
+ "author_name": "angus croll",
+ "author_email": "anguscroll@gmail.com",
+ "authored_date": "2013-02-18T22:02:54.000Z",
+ "committer_name": "angus croll",
+ "committer_email": "anguscroll@gmail.com",
+ "committed_date": "2013-02-18T22:02:54.000Z",
+ "project_id": 6
+ }
+]
+```
+
+### Scope: blobs
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=blobs&search=installation
+```
+
+Example response:
+
+```json
+
+[
+ {
+ "basename": "README",
+ "data": "```\n\n## Installation\n\nQuick start using the [pre-built",
+ "filename": "README.md",
+ "id": null,
+ "ref": "master",
+ "startline": 46,
+ "project_id": 6
+ }
+]
+```
+
+[ce-41763]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41763
diff --git a/doc/api/services.md b/doc/api/services.md
index 08df26db3ec..92f12acbc73 100644
--- a/doc/api/services.md
+++ b/doc/api/services.md
@@ -1,5 +1,7 @@
# Services API
+>**Note:** This API requires an access token with Master or Owner permissions
+
## Asana
Asana - Teamwork without email
@@ -16,8 +18,10 @@ PUT /projects/:id/services/asana
Parameters:
-- `api_key` (**required**) - User API token. User must have access to task, all comments will be attributed to this user.
-- `restrict_to_branch` (optional) - Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `api_key` | string | true | User API token. User must have access to task, all comments will be attributed to this user. |
+| `restrict_to_branch` | string | false | Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches. |
### Delete Asana service
@@ -49,8 +53,10 @@ PUT /projects/:id/services/assembla
Parameters:
-- `token` (**required**)
-- `subdomain` (optional)
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | true | The authentication token
+| `subdomain` | string | false | The subdomain setting |
### Delete Assembla service
@@ -84,10 +90,12 @@ PUT /projects/:id/services/bamboo
Parameters:
-- `bamboo_url` (**required**) - Bamboo root URL like https://bamboo.example.com
-- `build_key` (**required**) - Bamboo build plan key like KEY
-- `username` (**required**) - A user with API access, if applicable
-- `password` (**required**)
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `bamboo_url` | string | true | Bamboo root URL like https://bamboo.example.com |
+| `build_key` | string | true | Bamboo build plan key like KEY |
+| `username` | string | true | A user with API access, if applicable |
+| `password` | string | true | Password of the user |
### Delete Atlassian Bamboo CI service
@@ -105,6 +113,44 @@ Get Atlassian Bamboo CI service settings for a project.
GET /projects/:id/services/bamboo
```
+## Bugzilla
+
+Bugzilla Issue Tracker
+
+### Create/Edit Buildkite service
+
+Set Bugzilla service for a project.
+
+```
+PUT /projects/:id/services/bugzilla
+```
+
+Parameters:
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `new_issue_url` | string | true | New Issue url |
+| `issues_url` | string | true | Issue url |
+| `project_url` | string | true | Project url |
+| `description` | string | false | Description |
+| `title` | string | false | Title |
+
+### Delete Bugzilla Service
+
+Delete Bugzilla service for a project.
+
+```
+DELETE /projects/:id/services/bugzilla
+```
+
+### Get Bugzilla Service Settings
+
+Get Bugzilla service settings for a project.
+
+```
+GET /projects/:id/services/bugzilla
+```
+
## Buildkite
Continuous integration and deployments
@@ -119,9 +165,11 @@ PUT /projects/:id/services/buildkite
Parameters:
-- `token` (**required**) - Buildkite project GitLab token
-- `project_url` (**required**) - https://buildkite.com/example/project
-- `enable_ssl_verification` (optional) - Enable SSL verification
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | true | Buildkite project GitLab token |
+| `project_url` | string | true | https://buildkite.com/example/project |
+| `enable_ssl_verification` | boolean | false | Enable SSL verification |
### Delete Buildkite service
@@ -153,9 +201,11 @@ PUT /projects/:id/services/campfire
Parameters:
-- `token` (**required**)
-- `subdomain` (optional)
-- `room` (optional)
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | true | Campfire token |
+| `subdomain` | string | false | Campfire subdomain |
+| `room` | string | false | Campfire room |
### Delete Campfire service
@@ -187,11 +237,13 @@ PUT /projects/:id/services/custom-issue-tracker
Parameters:
-- `new_issue_url` (**required**) - New Issue url
-- `issues_url` (**required**) - Issue url
-- `project_url` (**required**) - Project url
-- `description` (optional) - Custom issue tracker
-- `title` (optional) - Custom Issue Tracker
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `new_issue_url` | string | true | New Issue url
+| `issues_url` | string | true | Issue url
+| `project_url` | string | true | Project url
+| `description` | string | false | Description
+| `title` | string | false | Title
### Delete Custom Issue Tracker service
@@ -223,9 +275,11 @@ PUT /projects/:id/services/drone-ci
Parameters:
-- `token` (**required**) - Drone CI project specific token
-- `drone_url` (**required**) - http://drone.example.com
-- `enable_ssl_verification` (optional) - Enable SSL verification
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | true | Drone CI project specific token |
+| `drone_url` | string | true | http://drone.example.com |
+| `enable_ssl_verification` | boolean | false | Enable SSL verification |
### Delete Drone CI service
@@ -257,9 +311,11 @@ PUT /projects/:id/services/emails-on-push
Parameters:
-- `recipients` (**required**) - Emails separated by whitespace
-- `disable_diffs` (optional) - Disable code diffs
-- `send_from_committer_email` (optional) - Send from committer
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `recipients` | string | true | Emails separated by whitespace |
+| `disable_diffs` | boolean | false | Disable code diffs |
+| `send_from_committer_email` | boolean | false | Send from committer |
### Delete Emails on push service
@@ -291,7 +347,9 @@ PUT /projects/:id/services/external-wiki
Parameters:
-- `external_wiki_url` (**required**) - The URL of the external Wiki
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `external_wiki_url` | string | true | The URL of the external Wiki |
### Delete External Wiki service
@@ -323,7 +381,9 @@ PUT /projects/:id/services/flowdock
Parameters:
-- `token` (**required**) - Flowdock Git source token
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | true | Flowdock Git source token |
### Delete Flowdock service
@@ -355,8 +415,10 @@ PUT /projects/:id/services/gemnasium
Parameters:
-- `api_key` (**required**) - Your personal API KEY on gemnasium.com
-- `token` (**required**) - The project's slug on gemnasium.com
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `api_key` | string | true | Your personal API KEY on gemnasium.com |
+| `token` | string | true | The project's slug on gemnasium.com |
### Delete Gemnasium service
@@ -388,12 +450,14 @@ PUT /projects/:id/services/hipchat
Parameters:
-- `token` (**required**) - Room token
-- `color` (optional)
-- `notify` (optional)
-- `room` (optional) - Room name or ID
-- `api_version` (optional) - Leave blank for default (v2)
-- `server` (optional) - Leave blank for default. https://hipchat.example.com
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | true | Room token |
+| `color` | string | false | The room color |
+| `notify` | boolean | false | Enable notifications |
+| `room` | string | false |Room name or ID |
+| `api_version` | string | false | Leave blank for default (v2) |
+| `server` | string | false | Leave blank for default. https://hipchat.example.com |
### Delete HipChat service
@@ -427,11 +491,13 @@ PUT /projects/:id/services/irker
Parameters:
-- `recipients` (**required**) - Recipients/channels separated by whitespaces
-- `default_irc_uri` (optional) - irc://irc.network.net:6697/
-- `server_port` (optional) - 6659
-- `server_host` (optional) - localhost
-- `colorize_messages` (optional)
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `recipients` | string | true | Recipients/channels separated by whitespaces |
+| `default_irc_uri` | string | false | irc://irc.network.net:6697/ |
+| `server_host` | string | false | localhost |
+| `server_port` | integer | false | 6659 |
+| `colorize_messages` | boolean | false | Colorize messages |
### Delete Irker (IRC gateway) service
@@ -474,7 +540,9 @@ Set JIRA service for a project.
PUT /projects/:id/services/jira
```
-| Attribute | Type | Required | Description |
+Parameters:
+
+| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | yes | The URL to the JIRA project which is being linked to this GitLab project, e.g., `https://jira.example.com`. |
| `project_key` | string | yes | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. |
@@ -494,6 +562,9 @@ DELETE /projects/:id/services/jira
Kubernetes / Openshift integration
+CAUTION: **Warning:**
+Kubernetes service integration has been deprecated in GitLab 10.3. API service endpoints will continue to work as long as the Kubernetes service is active, however if the service is inactive API endpoints will automatically return a `400 Bad Request`. Read [GitLab 10.3 release post](https://about.gitlab.com/2017/12/22/gitlab-10-3-released/#kubernetes-integration-service) for more information.
+
### Create/Edit Kubernetes service
Set Kubernetes service for a project.
@@ -548,6 +619,7 @@ Example response:
"active": true,
"push_events": true,
"issues_events": true,
+ "confidential_issues_events": true,
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
@@ -569,7 +641,7 @@ PUT /projects/:id/services/slack-slash-commands
Parameters:
-| Attribute | Type | Required | Description |
+| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `token` | string | yes | The Slack token |
@@ -604,7 +676,7 @@ PUT /projects/:id/services/mattermost-slash-commands
Parameters:
-| Attribute | Type | Required | Description |
+| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `token` | string | yes | The Mattermost token |
| `username` | string | no | The username to use to post the message |
@@ -665,7 +737,7 @@ PUT /projects/:id/services/pipelines-email
Parameters:
-| Attribute | Type | Required | Description |
+| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `recipients` | string | yes | Comma-separated list of recipient email addresses |
| `add_pusher` | boolean | no | Add pusher to recipients list |
@@ -701,8 +773,10 @@ PUT /projects/:id/services/pivotaltracker
Parameters:
-- `token` (**required**)
-- `restrict_to_branch` (optional) - Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `token` | string | true | The PivotalTracker token |
+| `restrict_to_branch` | boolean | false | Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches. |
### Delete PivotalTracker service
@@ -720,6 +794,40 @@ Get PivotalTracker service settings for a project.
GET /projects/:id/services/pivotaltracker
```
+## Prometheus
+
+Prometheus is a powerful time-series monitoring service.
+
+### Create/Edit Prometheus service
+
+Set Prometheus service for a project.
+
+```
+PUT /projects/:id/services/prometheus
+```
+
+Parameters:
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `api_url` | string | true | Prometheus API Base URL, like http://prometheus.example.com/ |
+
+### Delete Prometheus service
+
+Delete Prometheus service for a project.
+
+```
+DELETE /projects/:id/services/prometheus
+```
+
+### Get Prometheus service settings
+
+Get Prometheus service settings for a project.
+
+```
+GET /projects/:id/services/prometheus
+```
+
## Pushover
Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.
@@ -734,11 +842,13 @@ PUT /projects/:id/services/pushover
Parameters:
-- `api_key` (**required**) - Your application key
-- `user_key` (**required**) - Your user key
-- `priority` (**required**)
-- `device` (optional) - Leave blank for all active devices
-- `sound` (optional)
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `api_key` | string | true | Your application key |
+| `user_key` | string | true | Your user key |
+| `priority` | string | true | The priority |
+| `device` | string | false | Leave blank for all active devices |
+| `sound` | string | false | The sound of the notification |
### Delete Pushover service
@@ -770,10 +880,12 @@ PUT /projects/:id/services/redmine
Parameters:
-- `new_issue_url` (**required**) - New Issue url
-- `project_url` (**required**) - Project url
-- `issues_url` (**required**) - Issue url
-- `description` (optional) - Redmine issue tracker
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `new_issue_url` | string | true | New Issue url |
+| `project_url` | string | true | Project url |
+| `issues_url` | string | true | Issue url |
+| `description` | string | false | Description |
### Delete Redmine service
@@ -803,11 +915,33 @@ Set Slack service for a project.
PUT /projects/:id/services/slack
```
+>**Note:** Specific event parameters (e.g. `push_events` flag and `push_channel`) were [introduced in v10.4][11435]
+
Parameters:
-- `webhook` (**required**) - https://hooks.slack.com/services/...
-- `username` (optional) - username
-- `channel` (optional) - #channel
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `webhook` | string | true | https://hooks.slack.com/services/... |
+| `username` | string | false | username |
+| `channel` | string | false | Default channel to use if others are not configured |
+| `notify_only_broken_pipelines` | boolean | false | Send notifications for broken pipelines |
+| `notify_only_default_branch` | boolean | false | Send notifications only for the default branch |
+| `push_events` | boolean | false | Enable notifications for push events |
+| `issues_events` | boolean | false | Enable notifications for issue events |
+| `confidential_issues_events` | boolean | false | Enable notifications for confidential issue events |
+| `merge_requests_events` | boolean | false | Enable notifications for merge request events |
+| `tag_push_events` | boolean | false | Enable notifications for tag push events |
+| `note_events` | boolean | false | Enable notifications for note events |
+| `pipeline_events` | boolean | false | Enable notifications for pipeline events |
+| `wiki_page_events` | boolean | false | Enable notifications for wiki page events |
+| `push_channel` | string | false | The name of the channel to receive push events notifications |
+| `issue_channel` | string | false | The name of the channel to receive issues events notifications |
+| `confidential_issue_channel` | string | false | The name of the channel to receive confidential issues events notifications |
+| `merge_request_channel` | string | false | The name of the channel to receive merge request events notifications |
+| `note_channel` | string | false | The name of the channel to receive note events notifications |
+| `tag_push_channel` | string | false | The name of the channel to receive tag push events notifications |
+| `pipeline_channel` | string | false | The name of the channel to receive pipeline events notifications |
+| `wiki_page_channel` | string | false | The name of the channel to receive wiki page events notifications |
### Delete Slack service
@@ -825,6 +959,40 @@ Get Slack service settings for a project.
GET /projects/:id/services/slack
```
+## Microsoft Teams
+
+Group Chat Software
+
+### Create/Edit Microsoft Teams service
+
+Set Microsoft Teams service for a project.
+
+```
+PUT /projects/:id/services/microsoft_teams
+```
+
+Parameters:
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `webhook` | string | true | The Microsoft Teams webhook. e.g. https://outlook.office.com/webhook/... |
+
+### Delete Microsoft Teams service
+
+Delete Microsoft Teams service for a project.
+
+```
+DELETE /projects/:id/services/microsoft_teams
+```
+
+### Get Microsoft Teams service settings
+
+Get Microsoft Teams service settings for a project.
+
+```
+GET /projects/:id/services/microsoft_teams
+```
+
## Mattermost notifications
Receive event notifications in Mattermost
@@ -837,11 +1005,33 @@ Set Mattermost service for a project.
PUT /projects/:id/services/mattermost
```
+>**Note:** Specific event parameters (e.g. `push_events` flag and `push_channel`) were [introduced in v10.4][11435]
+
Parameters:
-- `webhook` (**required**) - https://mattermost.example/hooks/1298aff...
-- `username` (optional) - username
-- `channel` (optional) - #channel
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `webhook` | string | true | The Mattermost webhook. e.g. http://mattermost_host/hooks/... |
+| `username` | string | false | username |
+| `channel` | string | false | Default channel to use if others are not configured |
+| `notify_only_broken_pipelines` | boolean | false | Send notifications for broken pipelines |
+| `notify_only_default_branch` | boolean | false | Send notifications only for the default branch |
+| `push_events` | boolean | false | Enable notifications for push events |
+| `issues_events` | boolean | false | Enable notifications for issue events |
+| `confidential_issues_events` | boolean | false | Enable notifications for confidential issue events |
+| `merge_requests_events` | boolean | false | Enable notifications for merge request events |
+| `tag_push_events` | boolean | false | Enable notifications for tag push events |
+| `note_events` | boolean | false | Enable notifications for note events |
+| `pipeline_events` | boolean | false | Enable notifications for pipeline events |
+| `wiki_page_events` | boolean | false | Enable notifications for wiki page events |
+| `push_channel` | string | false | The name of the channel to receive push events notifications |
+| `issue_channel` | string | false | The name of the channel to receive issues events notifications |
+| `confidential_issue_channel` | string | false | The name of the channel to receive confidential issues events notifications |
+| `merge_request_channel` | string | false | The name of the channel to receive merge request events notifications |
+| `note_channel` | string | false | The name of the channel to receive note events notifications |
+| `tag_push_channel` | string | false | The name of the channel to receive tag push events notifications |
+| `pipeline_channel` | string | false | The name of the channel to receive pipeline events notifications |
+| `wiki_page_channel` | string | false | The name of the channel to receive wiki page events notifications |
### Delete Mattermost notifications service
@@ -875,10 +1065,12 @@ PUT /projects/:id/services/teamcity
Parameters:
-- `teamcity_url` (**required**) - TeamCity root URL like https://teamcity.example.com
-- `build_type` (**required**) - Build configuration ID
-- `username` (**required**) - A user with permissions to trigger a manual build
-- `password` (**required**)
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `teamcity_url` | string | true | TeamCity root URL like https://teamcity.example.com |
+| `build_type` | string | true | Build configuration ID |
+| `username` | string | true | A user with permissions to trigger a manual build |
+| `password` | string | true | The password of the user |
### Delete JetBrains TeamCity CI service
@@ -916,7 +1108,9 @@ PUT /projects/:id/services/mock-ci
Parameters:
-- `mock_service_url` (**required**) - http://localhost:4004
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `mock_service_url` | string | true | http://localhost:4004 |
### Delete MockCI service
@@ -933,3 +1127,5 @@ Get MockCI service settings for a project.
```
GET /projects/:id/services/mock-ci
```
+
+[11435]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11435
diff --git a/doc/api/settings.md b/doc/api/settings.md
index 0e4758cda2d..0b5b1f0c134 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -69,7 +69,7 @@ PUT /application/settings
| `after_sign_up_text` | string | no | Text shown to the user after signing up |
| `akismet_api_key` | string | no | API key for akismet spam protection |
| `akismet_enabled` | boolean | no | Enable or disable akismet spam protection |
-| `circuitbreaker_access_retries | integer | no | The number of attempts GitLab will make to access a storage. |
+| `circuitbreaker_access_retries` | integer | no | The number of attempts GitLab will make to access a storage. |
| `circuitbreaker_check_interval` | integer | no | Number of seconds in between storage checks. |
| `circuitbreaker_failure_count_threshold` | integer | no | The number of failures of after which GitLab will completely prevent access to the storage. |
| `circuitbreaker_failure_reset_time` | integer | no | Time in seconds GitLab will keep storage failure information. When no failures occur during this time, the failure information is reset. |
diff --git a/doc/api/snippets.md b/doc/api/snippets.md
index fdafbfb5b9e..42b760c107d 100644
--- a/doc/api/snippets.md
+++ b/doc/api/snippets.md
@@ -2,7 +2,7 @@
> [Introduced][ce-6373] in GitLab 8.15.
-### Snippet visibility level
+## Snippet visibility level
Snippets in GitLab can be either private, internal, or public.
You can set it with the `visibility` field in the snippet.
@@ -84,7 +84,11 @@ Parameters:
``` bash
-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
+curl --request POST \
+ --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' \
+ --header 'Content-Type: application/json' \
+ --header "PRIVATE-TOKEN: valid_api_token" \
+ https://gitlab.example.com/api/v4/snippets
```
Example response:
@@ -131,7 +135,11 @@ Parameters:
``` bash
-curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data '{"title": "foo", "content": "bar"}' https://gitlab.example.com/api/v4/snippets/1
+curl --request PUT \
+ --data '{"title": "foo", "content": "bar"}' \
+ --header 'Content-Type: application/json' \
+ --header "PRIVATE-TOKEN: valid_api_token" \
+ https://gitlab.example.com/api/v4/snippets/1
```
Example response:
@@ -265,4 +273,5 @@ Example response:
}
```
-[ce-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508
+[ce-6373]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6373
+[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12655
diff --git a/doc/api/system_hooks.md b/doc/api/system_hooks.md
index 9750475f0a6..dd424470b67 100644
--- a/doc/api/system_hooks.md
+++ b/doc/api/system_hooks.md
@@ -33,6 +33,7 @@ Example response:
"created_at":"2016-10-31T12:32:15.192Z",
"push_events":true,
"tag_push_events":false,
+ "merge_requests_events": true,
"enable_ssl_verification":true
}
]
@@ -54,6 +55,7 @@ POST /hooks
| `token` | string | no | Secret token to validate received payloads; this will not be returned in the response |
| `push_events` | boolean | no | When true, the hook will fire on push events |
| `tag_push_events` | boolean | no | When true, the hook will fire on new tags being pushed |
+| `merge_requests_events` | boolean | no | Trigger hook on merge requests events |
| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook |
Example request:
@@ -72,6 +74,7 @@ Example response:
"created_at":"2016-10-31T12:32:15.192Z",
"push_events":true,
"tag_push_events":false,
+ "merge_requests_events": true,
"enable_ssl_verification":true
}
]
diff --git a/doc/api/tags.md b/doc/api/tags.md
index fa25dc76452..4af096c3c0c 100644
--- a/doc/api/tags.md
+++ b/doc/api/tags.md
@@ -42,6 +42,7 @@ Parameters:
"description": "Amazing release. Wow"
},
"name": "v1.0.0",
+ "target": "2695effb5807a22ff3d138d593fd856244e155e7",
"message": null
}
]
@@ -73,6 +74,7 @@ Example Response:
{
"name": "v5.0.0",
"message": null,
+ "target": "60a8ff033665e1207714d6670fcd7b65304ec02f",
"commit": {
"id": "60a8ff033665e1207714d6670fcd7b65304ec02f",
"short_id": "60a8ff03",
@@ -132,12 +134,16 @@ Parameters:
"description": "Amazing release. Wow"
},
"name": "v1.0.0",
+ "target: "2695effb5807a22ff3d138d593fd856244e155e7",
"message": null
}
```
The message will be `null` when creating a lightweight tag otherwise
it will contain the annotation.
+The target will contain the tag objects ID when creating annotated tags,
+otherwise it will contain the commit ID when creating lightweight tags.
+
In case of an error,
status code `405` with an explaining error message is returned.
diff --git a/doc/api/users.md b/doc/api/users.md
index 478d747a50d..a4447e32908 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -51,6 +51,11 @@ GET /users?blocked=true
GET /users
```
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `order_by` | string | no | Return projects ordered by `id`, `name`, `username`, `created_at`, or `updated_at` fields. Default is `id` |
+| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` |
+
```json
[
{
@@ -160,6 +165,12 @@ You can filter by [custom attributes](custom_attributes.md) with:
GET /users?custom_attributes[key]=value&custom_attributes[other_key]=other_value
```
+You can include the users' [custom attributes](custom_attributes.md) in the response with:
+
+```
+GET /users?with_custom_attributes=true
+```
+
## Single user
Get a single user.
@@ -240,6 +251,12 @@ Parameters:
}
```
+You can include the user's [custom attributes](custom_attributes.md) in the response with:
+
+```
+GET /users/:id?with_custom_attributes=true
+```
+
## User creation
Creates a new user. Note only administrators can create new users. Either `password` or `reset_password` should be specified (`reset_password` takes priority).
@@ -415,6 +432,10 @@ GET /user
}
```
+## List user projects
+
+Please refer to the [List of user projects ](projects.md#list-user-projects).
+
## List SSH keys
Get a list of currently authenticated user's SSH keys.
diff --git a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md
index 25a24bc1d32..a8320c12e6b 100644
--- a/doc/articles/how_to_configure_ldap_gitlab_ce/index.md
+++ b/doc/articles/how_to_configure_ldap_gitlab_ce/index.md
@@ -1,267 +1 @@
-# 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.
+This document was moved to [another location](../../administration/auth/how_to_configure_ldap_gitlab_ce/index.md).
diff --git a/doc/articles/how_to_install_git/index.md b/doc/articles/how_to_install_git/index.md
index 37b60501ce2..3e6003a33b7 100644
--- a/doc/articles/how_to_install_git/index.md
+++ b/doc/articles/how_to_install_git/index.md
@@ -1,66 +1 @@
-# 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).
+This document was moved to [another location](../../topics/git/how_to_install_git/index.md).
diff --git a/doc/articles/index.md b/doc/articles/index.md
index 7b60ce1d8b9..9f85533ea94 100644
--- a/doc/articles/index.md
+++ b/doc/articles/index.md
@@ -1,117 +1,18 @@
-# Technical Articles
+---
+comments: false
+---
-[Technical Articles](../development/writing_documentation.md#technical-articles) are
+# Technical articles list (deprecated)
+
+[Technical articles](../development/writing_documentation.md#technical-articles) are
topic-related documentation, written with an user-friendly approach and language, aiming
to provide the community with guidance on specific processes to achieve certain objectives.
-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 |
-| :------------ | :------: | --------------: |
-| [Autoscaling GitLab Runners on AWS](runner_autoscale_aws/index.md) | Admin guide | 2017-11-24 |
-| [How to test and deploy Laravel/PHP applications with GitLab CI/CD and Envoy](laravel_with_gitlab_and_envoy/index.md) | Tutorial | 2017-08-31 |
-| [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
-
-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
-
-[Admin](../README.md#administrator-documentation), [install](../install/README.md),
-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
+The list of technical articles was [deprecated](https://gitlab.com/gitlab-org/gitlab-ce/issues/41138) in favor of having them linked from their topic-related documentation:
-| 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 |
+- [Git](../topics/git/index.md)
+- [GitLab administrator](../administration/index.md)
+- [GitLab CI/CD](../ci/README.md)
+- [GitLab Pages](../user/project/pages/index.md)
+- [GitLab user](../user/index.md)
+- [Install GitLab](../install/README.md)
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/index.md b/doc/articles/laravel_with_gitlab_and_envoy/index.md
index b20bd8c247a..b092cdb0f7a 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/index.md
+++ b/doc/articles/laravel_with_gitlab_and_envoy/index.md
@@ -1,680 +1 @@
-# Test and deploy Laravel applications with GitLab CI/CD and Envoy
-
-> **[Article Type](../../development/writing_documentation.md#types-of-technical-articles):** tutorial ||
-> **Level:** intermediary ||
-> **Author:** [Mehran Rasulian](https://gitlab.com/mehranrasulian) ||
-> **Publication date:** 2017-08-31
-
-## Introduction
-
-GitLab features our applications with Continuous Integration, and it is possible to easily deploy the new code changes to the production server whenever we want.
-
-In this tutorial, we'll show you how to initialize a [Laravel](http://laravel.com/) application and setup our [Envoy](https://laravel.com/docs/envoy) tasks, then we'll jump into see how to test and deploy it with [GitLab CI/CD](../../ci/README.md) via [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/).
-
-We assume you have a basic experience with Laravel, Linux servers,
-and you know how to use GitLab.
-
-Laravel is a high quality web framework written in PHP.
-It has a great community with a [fantastic documentation](https://laravel.com/docs).
-Aside from the usual routing, controllers, requests, responses, views, and (blade) templates, out of the box Laravel provides plenty of additional services such as cache, events, localization, authentication and many others.
-
-We will use [Envoy](https://laravel.com/docs/master/envoy) as an SSH task runner based on PHP.
-It uses a clean, minimal [Blade syntax](https://laravel.com/docs/blade) to setup tasks that can run on remote servers, such as, cloning your project from the repository, installing the Composer dependencies, and running [Artisan commands](https://laravel.com/docs/artisan).
-
-## Initialize our Laravel app on GitLab
-
-We assume [you have installed a new laravel project](https://laravel.com/docs/installation#installation), so let's start with a unit test, and initialize Git for the project.
-
-### Unit Test
-
-Every new installation of Laravel (currently 5.4) comes with two type of tests, 'Feature' and 'Unit', placed in the tests directory.
-Here's a unit test from `test/Unit/ExampleTest.php`:
-
-```php
-<?php
-
-namespace Tests\Unit;
-
-...
-
-class ExampleTest extends TestCase
-{
- public function testBasicTest()
- {
- $this->assertTrue(true);
- }
-}
-```
-
-This test is as simple as asserting that the given value is true.
-
-Laravel uses `PHPUnit` for tests by default.
-If we run `vendor/bin/phpunit` we should see the green output:
-
-```bash
-vendor/bin/phpunit
-OK (1 test, 1 assertions)
-```
-
-This test will be used later for continuously testing our app with GitLab CI/CD.
-
-### Push to GitLab
-
-Since we have our app up and running locally, it's time to push the codebase to our remote repository.
-Let's create [a new project](../../gitlab-basics/create-project.md) in GitLab named `laravel-sample`.
-After that, follow the command line instructions displayed on the project's homepage to initiate the repository on our machine and push the first commit.
-
-
-```bash
-cd laravel-sample
-git init
-git remote add origin git@gitlab.example.com:<USERNAME>/laravel-sample.git
-git add .
-git commit -m 'Initial Commit'
-git push -u origin master
-```
-
-## Configure the production server
-
-Before we begin setting up Envoy and GitLab CI/CD, let's quickly make sure the production server is ready for deployment.
-We have installed LEMP stack which stands for Linux, Nginx, MySQL and PHP on our Ubuntu 16.04.
-
-### Create a new user
-
-Let's now create a new user that will be used to deploy our website and give it
-the needed permissions using [Linux ACL](https://serversforhackers.com/video/linux-acls):
-
-```bash
-# Create user deployer
-sudo adduser deployer
-# Give the read-write-execute permissions to deployer user for directory /var/www
-sudo setfacl -R -m u:deployer:rwx /var/www
-```
-
-If you don't have ACL installed on your Ubuntu server, use this command to install it:
-
-```bash
-sudo apt install acl
-```
-
-### Add SSH key
-
-Let's suppose we want to deploy our app to the production server from a private repository on GitLab. First, we need to [generate a new SSH key pair **with no passphrase**](../../ssh/README.md) for the deployer user.
-
-After that, we need to copy the private key, which will be used to connect to our server as the deployer user with SSH, to be able to automate our deployment process:
-
-```bash
-# As the deployer user on server
-#
-# Copy the content of public key to authorized_keys
-cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
-# Copy the private key text block
-cat ~/.ssh/id_rsa
-```
-
-Now, let's add it to your GitLab project as a [secret variable](../../ci/variables/README.md#secret-variables).
-Secret variables are user-defined variables and are stored out of `.gitlab-ci.yml`, for security purposes.
-They can be added per project by navigating to the project's **Settings** > **CI/CD**.
-
-![secret variables page](img/secret_variables_page.png)
-
-To the field **KEY**, add the name `SSH_PRIVATE_KEY`, and to the **VALUE** field, paste the private key you've copied earlier.
-We'll use this variable in the `.gitlab-ci.yml` later, to easily connect to our remote server as the deployer user without entering its password.
-
-We also need to add the public key to **Project** > **Settings** > **Repository** as [Deploy Keys](../../ssh/README.md/#deploy-keys), which gives us the ability to access our repository from the server through [SSH protocol](../../gitlab-basics/command-line-commands.md/#start-working-on-your-project).
-
-
-```bash
-# As the deployer user on the server
-#
-# Copy the public key
-cat ~/.ssh/id_rsa.pub
-```
-
-![deploy keys page](img/deploy_keys_page.png)
-
-To the field **Title**, add any name you want, and paste the public key into the **Key** field.
-
-Now, let's clone our repository on the server just to make sure the `deployer` user has access to the repository.
-
-```bash
-# As the deployer user on server
-#
-git clone git@gitlab.example.com:<USERNAME>/laravel-sample.git
-```
-
->**Note:**
-Answer **yes** if asked `Are you sure you want to continue connecting (yes/no)?`.
-It adds GitLab.com to the known hosts.
-
-### Configuring Nginx
-
-Now, let's make sure our web server configuration points to the `current/public` rather than `public`.
-
-Open the default Nginx server block configuration file by typing:
-
-```bash
-sudo nano /etc/nginx/sites-available/default
-```
-
-The configuration should be like this.
-
-```
-server {
- root /var/www/app/current/public;
- server_name example.com;
- # Rest of the configuration
-}
-```
-
->**Note:**
-You may replace the app's name in `/var/www/app/current/public` with the folder name of your application.
-
-## Setting up Envoy
-
-So we have our Laravel app ready for production.
-The next thing is to use Envoy to perform the deploy.
-
-To use Envoy, we should first install it on our local machine [using the given instructions by Laravel](https://laravel.com/docs/envoy/#introduction).
-
-### How Envoy works
-
-The pros of Envoy is that it doesn't require Blade engine, it just uses Blade syntax to define tasks.
-To start, we create an `Envoy.blade.php` in the root of our app with a simple task to test Envoy.
-
-
-```php
-@servers(['web' => 'remote_username@remote_host'])
-
-@task('list', [on => 'web'])
- ls -l
-@endtask
-```
-
-As you may expect, we have an array within `@servers` directive at the top of the file, which contains a key named `web` with a value of the server's address (e.g. `deployer@192.168.1.1`).
-Then within our `@task` directive we define the bash commands that should be run on the server when the task is executed.
-
-On the local machine use the `run` command to run Envoy tasks.
-
-```bash
-envoy run list
-```
-
-It should execute the `list` task we defined earlier, which connects to the server and lists directory contents.
-
-Envoy is not a dependency of Laravel, therefore you can use it for any PHP application.
-
-### Zero downtime deployment
-
-Every time we deploy to the production server, Envoy downloads the latest release of our app from GitLab repository and replace it with preview's release.
-Envoy does this without any [downtime](https://en.wikipedia.org/wiki/Downtime),
-so we don't have to worry during the deployment while someone might be reviewing the site.
-Our deployment plan is to clone the latest release from GitLab repository, install the Composer dependencies and finally, activate the new release.
-
-#### @setup directive
-
-The first step of our deployment process is to define a set of variables within [@setup](https://laravel.com/docs/envoy/#setup) directive.
-You may change the `app` to your application's name:
-
-
-```php
-...
-
-@setup
- $repository = 'git@gitlab.example.com:<USERNAME>/laravel-sample.git';
- $releases_dir = '/var/www/app/releases';
- $app_dir = '/var/www/app';
- $release = date('YmdHis');
- $new_release_dir = $releases_dir .'/'. $release;
-@endsetup
-
-...
-```
-
-- `$repository` is the address of our repository
-- `$releases_dir` directory is where we deploy the app
-- `$app_dir` is the actual location of the app that is live on the server
-- `$release` contains a date, so every time that we deploy a new release of our app, we get a new folder with the current date as name
-- `$new_release_dir` is the full path of the new release which is used just to make the tasks cleaner
-
-#### @story directive
-
-The [@story](https://laravel.com/docs/envoy/#stories) directive allows us define a list of tasks that can be run as a single task.
-Here we have three tasks called `clone_repository`, `run_composer`, `update_symlinks`. These variables are usable to making our task's codes more cleaner:
-
-
-```php
-...
-
-@story('deploy')
- clone_repository
- run_composer
- update_symlinks
-@endstory
-
-...
-```
-
-Let's create these three tasks one by one.
-
-#### Clone the repository
-
-The first task will create the `releases` directory (if it doesn't exist), and then clone the `master` branch of the repository (by default) into the new release directory, given by the `$new_release_dir` variable.
-The `releases` directory will hold all our deployments:
-
-```php
-...
-
-@task('clone_repository')
- echo 'Cloning repository'
- [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }}
- git clone --depth 1 {{ $repository }} {{ $new_release_dir }}
-@endtask
-
-...
-```
-
-While our project grows, its Git history will be very very long over time.
-Since we are creating a directory per release, it might not be necessary to have the history of the project downloaded for each release.
-The `--depth 1` option is a great solution which saves systems time and disk space as well.
-
-#### Installing dependencies with Composer
-
-As you may know, this task just navigates to the new release directory and runs Composer to install the application dependencies:
-
-```php
-...
-
-@task('run_composer')
- echo "Starting deployment ({{ $release }})"
- cd {{ $new_release_dir }}
- composer install --prefer-dist --no-scripts -q -o
-@endtask
-
-...
-```
-
-#### Activate new release
-
-Next thing to do after preparing the requirements of our new release, is to remove the storage directory from it and to create two symbolic links to point the application's `storage` directory and `.env` file to the new release.
-Then, we need to create another symbolic link to the new release with the name of `current` placed in the app directory.
-The `current` symbolic link always points to the latest release of our app:
-
-```php
-...
-
-@task('update_symlinks')
- echo "Linking storage directory"
- rm -rf {{ $new_release_dir }}/storage
- ln -nfs {{ $app_dir }}/storage {{ $new_release_dir }}/storage
-
- echo 'Linking .env file'
- ln -nfs {{ $app_dir }}/.env {{ $new_release_dir }}/.env
-
- echo 'Linking current release'
- ln -nfs {{ $new_release_dir }} {{ $app_dir }}/current
-@endtask
-```
-
-As you see, we use `-nfs` as an option for `ln` command, which says that the `storage`, `.env` and `current` no longer points to the preview's release and will point them to the new release by force (`f` from `-nfs` means force), which is the case when we are doing multiple deployments.
-
-### Full script
-
-The script is ready, but make sure to change the `deployer@192.168.1.1` to your server and also change `/var/www/app` with the directory you want to deploy your app.
-
-At the end, our `Envoy.blade.php` file will look like this:
-
-```php
-@servers(['web' => 'deployer@192.168.1.1'])
-
-@setup
- $repository = 'git@gitlab.example.com:<USERNAME>/laravel-sample.git';
- $releases_dir = '/var/www/app/releases';
- $app_dir = '/var/www/app';
- $release = date('YmdHis');
- $new_release_dir = $releases_dir .'/'. $release;
-@endsetup
-
-@story('deploy')
- clone_repository
- run_composer
- update_symlinks
-@endstory
-
-@task('clone_repository')
- echo 'Cloning repository'
- [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }}
- git clone --depth 1 {{ $repository }} {{ $new_release_dir }}
-@endtask
-
-@task('run_composer')
- echo "Starting deployment ({{ $release }})"
- cd {{ $new_release_dir }}
- composer install --prefer-dist --no-scripts -q -o
-@endtask
-
-@task('update_symlinks')
- echo "Linking storage directory"
- rm -rf {{ $new_release_dir }}/storage
- ln -nfs {{ $app_dir }}/storage {{ $new_release_dir }}/storage
-
- echo 'Linking .env file'
- ln -nfs {{ $app_dir }}/.env {{ $new_release_dir }}/.env
-
- echo 'Linking current release'
- ln -nfs {{ $new_release_dir }} {{ $app_dir }}/current
-@endtask
-```
-
-One more thing we should do before any deployment is to manually copy our application `storage` folder to the `/var/www/app` directory on the server for the first time.
-You might want to create another Envoy task to do that for you.
-We also create the `.env` file in the same path to setup our production environment variables for Laravel.
-These are persistent data and will be shared to every new release.
-
-Now, we would need to deploy our app by running `envoy run deploy`, but it won't be necessary since GitLab can handle that for us with CI's [environments](../../ci/environments.md), which will be described [later](#setting-up-gitlab-ci-cd) in this tutorial.
-
-Now it's time to commit [Envoy.blade.php](https://gitlab.com/mehranrasulian/laravel-sample/blob/master/Envoy.blade.php) and push it to the `master` branch.
-To keep things simple, we commit directly to `master`, without using [feature-branches](../../workflow/gitlab_flow.md/#github-flow-as-a-simpler-alternative) since collaboration is beyond the scope of this tutorial.
-In a real world project, teams may use [Issue Tracker](../../user/project/issues/index.md) and [Merge Requests](../../user/project/merge_requests/index.md) to move their code across branches:
-
-```bash
-git add Envoy.blade.php
-git commit -m 'Add Envoy'
-git push origin master
-```
-
-## Continuous Integration with GitLab
-
-We have our app ready on GitLab, and we also can deploy it manually.
-But let's take a step forward to do it automatically with [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-delivery) method.
-We need to check every commit with a set of automated tests to become aware of issues at the earliest, and then, we can deploy to the target environment if we are happy with the result of the tests.
-
-[GitLab CI/CD](../../ci/README.md) allows us to use [Docker](https://docker.com/) engine to handle the process of testing and deploying our app.
-In the case you're not familiar with Docker, refer to [How to Automate Docker Deployments](http://paislee.io/how-to-automate-docker-deployments/).
-
-To be able to build, test, and deploy our app with GitLab CI/CD, we need to prepare our work environment.
-To do that, we'll use a Docker image which has the minimum requirements that a Laravel app needs to run.
-[There are other ways](../../ci/examples/php.md/#test-php-projects-using-the-docker-executor) to do that as well, but they may lead our builds run slowly, which is not what we want when there are faster options to use.
-
-With Docker images our builds run incredibly faster!
-
-### Create a Container Image
-
-Let's create a [Dockerfile](https://gitlab.com/mehranrasulian/laravel-sample/blob/master/Dockerfile) in the root directory of our app with the following content:
-
-```bash
-# Set the base image for subsequent instructions
-FROM php:7.1
-
-# Update packages
-RUN apt-get update
-
-# Install PHP and composer dependencies
-RUN apt-get install -qq git curl libmcrypt-dev libjpeg-dev libpng-dev libfreetype6-dev libbz2-dev
-
-# Clear out the local repository of retrieved package files
-RUN apt-get clean
-
-# Install needed extensions
-# Here you can install any other extension that you need during the test and deployment process
-RUN docker-php-ext-install mcrypt pdo_mysql zip
-
-# Install Composer
-RUN curl --silent --show-error https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
-
-# Install Laravel Envoy
-RUN composer global require "laravel/envoy=~1.0"
-```
-
-We added the [official PHP 7.1 Docker image](https://hub.docker.com/r/_/php/), which consist of a minimum installation of Debian Jessie with PHP pre-installed, and works perfectly for our use case.
-
-We used `docker-php-ext-install` (provided by the official PHP Docker image) to install the PHP extensions we need.
-
-#### Setting Up GitLab Container Registry
-
-Now that we have our `Dockerfile` let's build and push it to our [GitLab Container Registry](../../user/project/container_registry.md).
-
-> The registry is the place to store and tag images for later use. Developers may want to maintain their own registry for private, company images, or for throw-away images used only in testing. Using GitLab Container Registry means you don't need to set up and administer yet another service or use a public registry.
-
-On your GitLab project repository navigate to the **Registry** tab.
-
-![container registry page empty image](img/container_registry_page_empty_image.png)
-
-You may need to [enable Container Registry](../../user/project/container_registry.md#enable-the-container-registry-for-your-project) to your project to see this tab. You'll find it under your project's **Settings > General > Sharing and permissions**.
-
-![container registry checkbox](img/container_registry_checkbox.png)
-
-To start using Container Registry on our machine, we first need to login to the GitLab registry using our GitLab username and password:
-
-```bash
-docker login registry.gitlab.com
-```
-Then we can build and push our image to GitLab:
-
-```bash
-docker build -t registry.gitlab.com/<USERNAME>/laravel-sample .
-
-docker push registry.gitlab.com/<USERNAME>/laravel-sample
-```
-
->**Note:**
-To run the above commands, we first need to have [Docker](https://docs.docker.com/engine/installation/) installed on our machine.
-
-Congratulations! You just pushed the first Docker image to the GitLab Registry, and if you refresh the page you should be able to see it:
-
-![container registry page with image](img/container_registry_page_with_image.jpg)
-
->**Note:**
-You can also [use GitLab CI/CD](https://about.gitlab.com/2016/05/23/gitlab-container-registry/#use-with-gitlab-ci) to build and push your Docker images, rather than doing that on your machine.
-
-We'll use this image further down in the `.gitlab-ci.yml` configuration file to handle the process of testing and deploying our app.
-
-Let's commit the `Dockerfile` file.
-
-```bash
-git add Dockerfile
-git commit -m 'Add Dockerfile'
-git push origin master
-```
-
-### Setting up GitLab CI/CD
-
-In order to build and test our app with GitLab CI/CD, we need a file called `.gitlab-ci.yml` in our repository's root. It is similar to Circle CI and Travis CI, but built-in GitLab.
-
-Our `.gitlab-ci.yml` file will look like this:
-
-```yaml
-image: registry.gitlab.com/<USERNAME>/laravel-sample:latest
-
-services:
- - mysql:5.7
-
-variables:
- MYSQL_DATABASE: homestead
- MYSQL_ROOT_PASSWORD: secret
- DB_HOST: mysql
- DB_USERNAME: root
-
-stages:
- - test
- - deploy
-
-unit_test:
- stage: test
- script:
- - cp .env.example .env
- - composer install
- - php artisan key:generate
- - php artisan migrate
- - vendor/bin/phpunit
-
-deploy_production:
- stage: deploy
- script:
- - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- - eval $(ssh-agent -s)
- - ssh-add <(echo "$SSH_PRIVATE_KEY")
- - mkdir -p ~/.ssh
- - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
-
- - ~/.composer/vendor/bin/envoy run deploy
- environment:
- name: production
- url: http://192.168.1.1
- when: manual
- only:
- - master
-```
-
-That's a lot to take in, isn't it? Let's run through it step by step.
-
-#### Image and Services
-
-[GitLab Runners](../../ci/runners/README.md) run the script defined by `.gitlab-ci.yml`.
-The `image` keyword tells the Runners which image to use.
-The `services` keyword defines additional images [that are linked to the main image](../../ci/docker/using_docker_images.md/#what-is-a-service).
-Here we use the container image we created before as our main image and also use MySQL 5.7 as a service.
-
-```yaml
-image: registry.gitlab.com/<USERNAME>/laravel-sample:latest
-
-services:
- - mysql:5.7
-
-...
-```
-
->**Note:**
-If you wish to test your app with different PHP versions and [database management systems](../../ci/services/README.md), you can define different `image` and `services` keywords for each test job.
-
-#### Variables
-
-GitLab CI/CD allows us to use [environment variables](../../ci/yaml/README.md#variables) in our jobs.
-We defined MySQL as our database management system, which comes with a superuser root created by default.
-
-So we should adjust the configuration of MySQL instance by defining `MYSQL_DATABASE` variable as our database name and `MYSQL_ROOT_PASSWORD` variable as the password of `root`.
-Find out more about MySQL variables at the [official MySQL Docker Image](https://hub.docker.com/r/_/mysql/).
-
-Also set the variables `DB_HOST` to `mysql` and `DB_USERNAME` to `root`, which are Laravel specific variables.
-We define `DB_HOST` as `mysql` instead of `127.0.0.1`, as we use MySQL Docker image as a service which [is linked to the main Docker image](../../ci/docker/using_docker_images.md/#how-services-are-linked-to-the-build).
-
-```yaml
-...
-
-variables:
- MYSQL_DATABASE: homestead
- MYSQL_ROOT_PASSWORD: secret
- DB_HOST: mysql
- DB_USERNAME: root
-
-...
-```
-
-#### Unit Test as the first job
-
-We defined the required shell scripts as an array of the [script](../../ci/yaml/README.md#script) variable to be executed when running `unit_test` job.
-
-These scripts are some Artisan commands to prepare the Laravel, and, at the end of the script, we'll run the tests by `PHPUnit`.
-
-```yaml
-...
-
-unit_test:
- script:
- # Install app dependencies
- - composer install
- # Setup .env
- - cp .env.example .env
- # Generate an environment key
- - php artisan key:generate
- # Run migrations
- - php artisan migrate
- # Run tests
- - vendor/bin/phpunit
-
-...
-```
-
-#### Deploy to production
-
-The job `deploy_production` will deploy the app to the production server.
-To deploy our app with Envoy, we had to set up the `$SSH_PRIVATE_KEY` variable as an [SSH private key](../../ci/ssh_keys/README.md/#ssh-keys-when-using-the-docker-executor).
-If the SSH keys have added successfully, we can run Envoy.
-
-As mentioned before, GitLab supports [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-delivery) methods as well.
-The [environment](../../ci/yaml/README.md#environment) keyword tells GitLab that this job deploys to the `production` environment.
-The `url` keyword is used to generate a link to our application on the GitLab Environments page.
-The `only` keyword tells GitLab CI that the job should be executed only when the pipeline is building the `master` branch.
-Lastly, `when: manual` is used to turn the job from running automatically to a manual action.
-
-```yaml
-...
-
-deploy_production:
- script:
- # Add the private SSH key to the build environment
- - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- - eval $(ssh-agent -s)
- - ssh-add <(echo "$SSH_PRIVATE_KEY")
- - mkdir -p ~/.ssh
- - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
-
- # Run Envoy
- - ~/.composer/vendor/bin/envoy run deploy
-
- environment:
- name: production
- url: http://192.168.1.1
- when: manual
- only:
- - master
-```
-
-You may also want to add another job for [staging environment](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments), to final test your application before deploying to production.
-
-### Turn on GitLab CI/CD
-
-We have prepared everything we need to test and deploy our app with GitLab CI/CD.
-To do that, commit and push `.gitlab-ci.yml` to the `master` branch. It will trigger a pipeline, which you can watch live under your project's **Pipelines**.
-
-![pipelines page](img/pipelines_page.png)
-
-Here we see our **Test** and **Deploy** stages.
-The **Test** stage has the `unit_test` build running.
-click on it to see the Runner's output.
-
-![pipeline page](img/pipeline_page.png)
-
-After our code passed through the pipeline successfully, we can deploy to our production server by clicking the **play** button on the right side.
-
-![pipelines page deploy button](img/pipelines_page_deploy_button.png)
-
-Once the deploy pipeline passed successfully, navigate to **Pipelines > Environments**.
-
-![environments page](img/environments_page.png)
-
-If something doesn't work as expected, you can roll back to the latest working version of your app.
-
-![environment page](img/environment_page.png)
-
-By clicking on the external link icon specified on the right side, GitLab opens the production website.
-Our deployment successfully was done and we can see the application is live.
-
-![laravel welcome page](img/laravel_welcome_page.png)
-
-In the case that you're interested to know how is the application directory structure on the production server after deployment, here are three directories named `current`, `releases` and `storage`.
-As you know, the `current` directory is a symbolic link that points to the latest release.
-The `.env` file consists of our Laravel environment variables.
-
-![production server app directory](img/production_server_app_directory.png)
-
-If you navigate to the `current` directory, you should see the application's content.
-As you see, the `.env` is pointing to the `/var/www/app/.env` file and also `storage` is pointing to the `/var/www/app/storage/` directory.
-
-![production server current directory](img/production_server_current_directory.png)
-
-## Conclusion
-
-We configured GitLab CI to perform automated tests and used the method of [Continuous Delivery](https://continuousdelivery.com/) to deploy to production a Laravel application with Envoy, directly from the codebase.
-
-Envoy also was a great match to help us deploy the application without writing our custom bash script and doing Linux magics.
+This document was moved to [another location](../../ci/examples/laravel_with_gitlab_and_envoy/index.md).
diff --git a/doc/articles/numerous_undo_possibilities_in_git/index.md b/doc/articles/numerous_undo_possibilities_in_git/index.md
index 895bbccec08..3f46ee9a5e6 100644
--- a/doc/articles/numerous_undo_possibilities_in_git/index.md
+++ b/doc/articles/numerous_undo_possibilities_in_git/index.md
@@ -1,497 +1 @@
-# 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/
+This document was moved to [another location](../../topics/git/numerous_undo_possibilities_in_git/index.md).
diff --git a/doc/articles/openshift_and_gitlab/index.md b/doc/articles/openshift_and_gitlab/index.md
index c0bbcfe2a8a..b7594cfef7f 100644
--- a/doc/articles/openshift_and_gitlab/index.md
+++ b/doc/articles/openshift_and_gitlab/index.md
@@ -1,510 +1 @@
-# 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"
+This document was moved to [another location](../../install/openshift_and_gitlab/index.html).
diff --git a/doc/articles/runner_autoscale_aws/index.md b/doc/articles/runner_autoscale_aws/index.md
index 9d4c4a57ce5..e2667aebc5f 100644
--- a/doc/articles/runner_autoscale_aws/index.md
+++ b/doc/articles/runner_autoscale_aws/index.md
@@ -1,410 +1 @@
----
-last_updated: 2017-11-24
----
-
-> **[Article Type](../../development/writing_documentation.html#types-of-technical-articles):** Admin guide ||
-> **Level:** intermediary ||
-> **Author:** [Achilleas Pipinellis](https://gitlab.com/axil) ||
-> **Publication date:** 2017/11/24
-
-# Autoscaling GitLab Runner on AWS
-
-One of the biggest advantages of GitLab Runner is its ability to automatically
-spin up and down VMs to make sure your builds get processed immediately. It's a
-great feature, and if used correctly, it can be extremely useful in situations
-where you don't use your Runners 24/7 and want to have a cost-effective and
-scalable solution.
-
-## Introduction
-
-In this tutorial, we'll explore how to properly configure a GitLab Runner in
-AWS that will serve as the bastion where it will spawn new Docker machines on
-demand.
-
-In addition, we'll make use of [Amazon's EC2 Spot instances][spot] which will
-greatly reduce the costs of the Runner instances while still using quite
-powerful autoscaling machines.
-
-## Prerequisites
-
-NOTE: **Note:**
-A familiarity with Amazon Web Services (AWS) is required as this is where most
-of the configuration will take place.
-
-Your GitLab instance is going to need to talk to the Runners over the network,
-and that is something you need think about when configuring any AWS security
-groups or when setting up your DNS configuration.
-
-For example, you can keep the EC2 resources segmented away from public traffic
-in a different VPC to better strengthen your network security. Your environment
-is likely different, so consider what works best for your situation.
-
-### AWS security groups
-
-Docker Machine will attempt to use a
-[default security group](https://docs.docker.com/machine/drivers/aws/#security-group)
-with rules for port `2376`, which is required for communication with the Docker
-daemon. Instead of relying on Docker, you can create a security group with the
-rules you need and provide that in the Runner options as we will
-[see below](#the-runners-machine-section). This way, you can customize it to your
-liking ahead of time based on your networking environment.
-
-### AWS credentials
-
-You'll need an [AWS Access Key](https://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html)
-tied to a user with permission to scale (EC2) and update the cache (via S3).
-Create a new user with [policies](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-policies-for-amazon-ec2.html)
-for EC2 (AmazonEC2FullAccess) and S3 (AmazonS3FullAccess). To be more secure,
-you can disable console login for that user. Keep the tab open or copy paste the
-security credentials in an editor as we'll use them later during the
-[Runner configuration](#the-runners-machine-section).
-
-## Prepare the bastion instance
-
-The first step is to install GitLab Runner in an EC2 instance that will serve
-as the bastion that spawns new machines. This doesn't have to be a powerful
-machine since it will not run any jobs itself, a `t2.micro` instance will do.
-This machine will be a dedicated host since we need it always up and running,
-thus it will be the only standard cost.
-
-NOTE: **Note:**
-For the bastion instance, choose a distribution that both Docker and GitLab
-Runner support, for example either Ubuntu, Debian, CentOS or RHEL will work fine.
-
-Install the prerequisites:
-
-1. Log in to your server
-1. [Install GitLab Runner from the official GitLab repository](https://docs.gitlab.com/runner/install/linux-repository.html)
-1. [Install Docker](https://docs.docker.com/engine/installation/#server)
-1. [Install Docker Machine](https://docs.docker.com/machine/install-machine/)
-
-Now that the Runner is installed, it's time to register it.
-
-## Registering the GitLab Runner
-
-Before configuring the GitLab Runner, you need to first register it, so that
-it connects with your GitLab instance:
-
-1. [Obtain a Runner token](../../ci/runners/README.md)
-1. [Register the Runner](https://docs.gitlab.com/runner/register/index.html#gnu-linux)
-1. When asked the executor type, enter `docker+machine`
-
-You can now move on to the most important part, configuring the GitLab Runner.
-
-TIP: **Tip:**
-If you want every user in your instance to be able to use the autoscaled Runners,
-register the Runner as a shared one.
-
-## Configuring the GitLab Runner
-
-Now that the Runner is registered, you need to edit its configuration file and
-add the required options for the AWS machine driver.
-
-Let's first break it down to pieces.
-
-### The global section
-
-In the global section, you can define the limit of the jobs that can be run
-concurrently across all Runners (`concurrent`). This heavily depends on your
-needs, like how many users your Runners will accommodate, how much time your
-builds take, etc. You can start with something low like `10`, and increase or
-decrease its value going forward.
-
-The `check_interval` option defines how often the Runner should check GitLab
-for new jobs, in seconds.
-
-Example:
-
-```toml
-concurrent = 10
-check_interval = 0
-```
-
-[Read more](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section)
-about all the options you can use.
-
-### The `runners` section
-
-From the `[[runners]]` section, the most important part is the `executor` which
-must be set to `docker+machine`. Most of those settings are taken care of when
-you register the Runner for the first time.
-
-`limit` sets the maximum number of machines (running and idle) that this Runner
-will spawn. For more info check the [relationship between `limit`, `concurrent`
-and `IdleCount`](https://docs.gitlab.com/runner/configuration/autoscale.html#how-concurrent-limit-and-idlecount-generate-the-upper-limit-of-running-machines).
-
-Example:
-
-```toml
-[[runners]]
- name = "gitlab-aws-autoscaler"
- url = "<URL of your GitLab instance>"
- token = "<Runner's token>"
- executor = "docker+machine"
- limit = 20
-```
-
-[Read more](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section)
-about all the options you can use under `[[runners]]`.
-
-### The `runners.docker` section
-
-In the `[runners.docker]` section you can define the default Docker image to
-be used by the child Runners if it's not defined in [`.gitlab-ci.yml`](../../ci/yaml/README.md).
-By using `privileged = true`, all Runners will be able to run
-[Docker in Docker](../../ci/docker/using_docker_build.md#use-docker-in-docker-executor)
-which is useful if you plan to build your own Docker images via GitLab CI/CD.
-
-Next, we use `disable_cache = true` to disable the Docker executor's inner
-cache mechanism since we will use the distributed cache mode as described
-in the following section.
-
-Example:
-
-```toml
- [runners.docker]
- image = "alpine"
- privileged = true
- disable_cache = true
-```
-
-[Read more](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-docker-section)
-about all the options you can use under `[runners.docker]`.
-
-### The `runners.cache` section
-
-To speed up your jobs, GitLab Runner provides a cache mechanism where selected
-directories and/or files are saved and shared between subsequent jobs.
-While not required for this setup, it is recommended to use the distributed cache
-mechanism that GitLab Runner provides. Since new instances will be created on
-demand, it is essential to have a common place where the cache is stored.
-
-In the following example, we use Amazon S3:
-
-```toml
- [runners.cache]
- Type = "s3"
- ServerAddress = "s3.amazonaws.com"
- AccessKey = "<your AWS Access Key ID>"
- SecretKey = "<your AWS Secret Access Key>"
- BucketName = "<the bucket where your cache should be kept>"
- BucketLocation = "us-east-1"
- Shared = true
-```
-
-Here's some more info to further explore the cache mechanism:
-
-- [Reference for `runners.cache`](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-cache-section)
-- [Deploying and using a cache server for GitLab Runner](https://docs.gitlab.com/runner/configuration/autoscale.html#distributed-runners-caching)
-- [How cache works](../../ci/yaml/README.md#cache)
-
-### The `runners.machine` section
-
-This is the most important part of the configuration and it's the one that
-tells GitLab Runner how and when to spawn new or remove old Docker Machine
-instances.
-
-We will focus on the AWS machine options, for the rest of the settings read
-about the:
-
-- [Autoscaling algorithm and the parameters it's based on](https://docs.gitlab.com/runner/configuration/autoscale.html#autoscaling-algorithm-and-parameters) - depends on the needs of your organization
-- [Off peak time configuration](https://docs.gitlab.com/runner/configuration/autoscale.html#off-peak-time-mode-configuration) - useful when there are regular time periods in your organization when no work is done, for example weekends
-
-Here's an example of the `runners.machine` section:
-
-```toml
- [runners.machine]
- IdleCount = 1
- IdleTime = 1800
- MaxBuilds = 10
- OffPeakPeriods = [
- "* * 0-9,18-23 * * mon-fri *",
- "* * * * * sat,sun *"
- ]
- OffPeakIdleCount = 0
- OffPeakIdleTime = 1200
- MachineDriver = "amazonec2"
- MachineName = "gitlab-docker-machine-%s"
- MachineOptions = [
- "amazonec2-access-key=XXXX",
- "amazonec2-secret-key=XXXX",
- "amazonec2-region=us-central-1",
- "amazonec2-vpc-id=vpc-xxxxx",
- "amazonec2-subnet-id=subnet-xxxxx",
- "amazonec2-use-private-address=true",
- "amazonec2-tags=runner-manager-name,gitlab-aws-autoscaler,gitlab,true,gitlab-runner-autoscale,true",
- "amazonec2-security-group=docker-machine-scaler",
- "amazonec2-instance-type=m4.2xlarge",
- ]
-```
-
-The Docker Machine driver is set to `amazonec2` and the machine name has a
-standard prefix followed by `%s` (required) that is replaced by the ID of the
-child Runner: `gitlab-docker-machine-%s`.
-
-Now, depending on your AWS infrastructure, there are many options you can set up
-under `MachineOptions`. Below you can see the most common ones.
-
-| Machine option | Description |
-| -------------- | ----------- |
-| `amazonec2-access-key=XXXX` | The AWS access key of the user that has permissions to create EC2 instances, see [AWS credentials](#aws-credentials). |
-| `amazonec2-secret-key=XXXX` | The AWS secret key of the user that has permissions to create EC2 instances, see [AWS credentials](#aws-credentials). |
-| `amazonec2-region=eu-central-1` | The region to use when launching the instance. You can omit this entirely and the default `us-east-1` will be used. |
-| `amazonec2-vpc-id=vpc-xxxxx` | Your [VPC ID](https://docs.docker.com/machine/drivers/aws/#vpc-id) to launch the instance in. |
-| `amazonec2-subnet-id=subnet-xxxx` | The AWS VPC subnet ID. |
-| `amazonec2-use-private-address=true` | Use the private IP address of Docker Machines, but still create a public IP address. Useful to keep the traffic internal and avoid extra costs.|
-| `amazonec2-tags=runner-manager-name,gitlab-aws-autoscaler,gitlab,true,gitlab-runner-autoscale,true` | AWS extra tag key-value pairs, useful to identify the instances on the AWS console. The "Name" tag is set to the machine name by default. We set the "runner-manager-name" to match the Runner name set in `[[runners]]`, so that we can filter all the EC2 instances created by a specific manager setup. Read more about [using tags in AWS](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html). |
-| `amazonec2-security-group=docker-machine-scaler` | AWS VPC security group name, see [AWS security groups](#aws-security-groups). |
-| `amazonec2-instance-type=m4.2xlarge` | The instance type that the child Runners will run on. |
-
-TIP: **Tip:**
-Under `MachineOptions` you can add anything that the [AWS Docker Machine driver
-supports](https://docs.docker.com/machine/drivers/aws/#options). You are highly
-encouraged to read Docker's docs as your infrastructure setup may warrant
-different options to be applied.
-
-NOTE: **Note:**
-The child instances will use by default Ubuntu 16.04 unless you choose a
-different AMI ID by setting `amazonec2-ami`.
-
-NOTE: **Note:**
-If you specify `amazonec2-private-address-only=true` as one of the machine
-options, your EC2 instance won't get assigned a public IP. This is ok if your
-VPC is configured correctly with an Internet Gateway (IGW) and routing is fine,
-but it’s something to consider if you've got a more complex configuration. Read
-more in [Docker docs about VPC connectivity](https://docs.docker.com/machine/drivers/aws/#vpc-connectivity).
-
-[Read more](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-machine-section)
-about all the options you can use under `[runners.machine]`.
-
-### Getting it all together
-
-Here's the full example of `/etc/gitlab-runner/config.toml`:
-
-```toml
-concurrent = 10
-check_interval = 0
-
-[[runners]]
- name = "gitlab-aws-autoscaler"
- url = "<URL of your GitLab instance>"
- token = "<Runner's token>"
- executor = "docker+machine"
- limit = 20
- [runners.docker]
- image = "alpine"
- privileged = true
- disable_cache = true
- [runners.cache]
- Type = "s3"
- ServerAddress = "s3.amazonaws.com"
- AccessKey = "<your AWS Access Key ID>"
- SecretKey = "<your AWS Secret Access Key>"
- BucketName = "<the bucket where your cache should be kept>"
- BucketLocation = "us-east-1"
- Shared = true
- [runners.machine]
- IdleCount = 1
- IdleTime = 1800
- MaxBuilds = 100
- OffPeakPeriods = [
- "* * 0-9,18-23 * * mon-fri *",
- "* * * * * sat,sun *"
- ]
- OffPeakIdleCount = 0
- OffPeakIdleTime = 1200
- MachineDriver = "amazonec2"
- MachineName = "gitlab-docker-machine-%s"
- MachineOptions = [
- "amazonec2-access-key=XXXX",
- "amazonec2-secret-key=XXXX",
- "amazonec2-region=us-central-1",
- "amazonec2-vpc-id=vpc-xxxxx",
- "amazonec2-subnet-id=subnet-xxxxx",
- "amazonec2-use-private-address=true",
- "amazonec2-tags=runner-manager-name,gitlab-aws-autoscaler,gitlab,true,gitlab-runner-autoscale,true",
- "amazonec2-security-group=docker-machine-scaler",
- "amazonec2-instance-type=m4.2xlarge",
- ]
-```
-
-## Cutting down costs with Amazon EC2 Spot instances
-
-As [described by][spot] Amazon:
-
->
-Amazon EC2 Spot instances allow you to bid on spare Amazon EC2 computing capacity.
-Since Spot instances are often available at a discount compared to On-Demand
-pricing, you can significantly reduce the cost of running your applications,
-grow your application’s compute capacity and throughput for the same budget,
-and enable new types of cloud computing applications.
-
-In addition to the [`runners.machine`](#the-runners-machine-section) options
-you picked above, in `/etc/gitlab-runner/config.toml` under the `MachineOptions`
-section, add the following:
-
-```toml
- MachineOptions = [
- "amazonec2-request-spot-instance=true",
- "amazonec2-spot-price=0.03",
- "amazonec2-block-duration-minutes=60"
- ]
-```
-
-With this configuration, Docker Machines are created on Spot instances with a
-maximum bid price of $0.03 per hour and the duration of the Spot instance is
-capped at 60 minutes. The `0.03` number mentioned above is just an example, so
-be sure to check on the current pricing based on the region you picked.
-
-To learn more about Amazon EC2 Spot instances, visit the following links:
-
-- https://aws.amazon.com/ec2/spot/
-- https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-requests.html
-- https://aws.amazon.com/blogs/aws/focusing-on-spot-instances-lets-talk-about-best-practices/
-
-### Caveats of Spot instances
-
-While Spot instances is a great way to use unused resources and minimize the
-costs of your infrastructure, you must be aware of the implications.
-
-Running CI jobs on Spot instances may increase the failure rates because of the
-Spot instances pricing model. If the price exceeds your bid, the existing Spot
-instances will be immediately terminated and all your jobs on that host will fail.
-
-As a consequence, the auto-scale Runner would fail to create new machines while
-it will continue to request new instances. This eventually will make 60 requests
-and then AWS won't accept any more. Then once the Spot price is acceptable, you
-are locked out for a bit because the call amount limit is exceeded.
-
-If you encounter that case, you can use the following command in the bastion
-machine to see the Docker Machines state:
-
-```sh
-docker-machine ls -q --filter state=Error --format "{{.NAME}}"
-```
-
-NOTE: **Note:**
-There are some issues regarding making GitLab Runner gracefully handle Spot
-price changes, and there are reports of `docker-machine` attempting to
-continually remove a Docker Machine. GitLab has provided patches for both cases
-in the upstream project. For more information, see issues
-[#2771](https://gitlab.com/gitlab-org/gitlab-runner/issues/2771) and
-[#2772](https://gitlab.com/gitlab-org/gitlab-runner/issues/2772).
-
-## Conclusion
-
-In this guide we learned how to install and configure a GitLab Runner in
-autoscale mode on AWS.
-
-Using the autoscale feature of GitLab Runner can save you both time and money.
-Using the Spot instances that AWS provides can save you even more, but you must
-be aware of the implications. As long as your bid is high enough, there shouldn't
-be an issue.
-
-You can read the following use cases from which this tutorial was (heavily)
-influenced:
-
-- [HumanGeo - Scaling GitLab CI](http://blog.thehumangeo.com/gitlab-autoscale-runners.html)
-- [subtrakt Health - Autoscale GitLab CI Runners and save 90% on EC2 costs](https://substrakthealth.com/news/gitlab-ci-cost-savings/)
-
-[spot]: https://aws.amazon.com/ec2/spot/
+This document was moved to [another location](https://docs.gitlab.com/runner/configuration/runner_autoscale_aws/index.html).
diff --git a/doc/ci/README.md b/doc/ci/README.md
index 05d792dea0f..6aa0e5885db 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -2,151 +2,123 @@
comments: false
---
-# GitLab Continuous Integration (GitLab CI)
+# GitLab Continuous Integration (GitLab CI/CD)
![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
-Integration, Continuous Deployment, and Continuous Delivery support to build,
-test, and deploy your application.
+Integration, Continuous Deployment, and Continuous Delivery support
+to build, test, and deploy your application.
Here's some info we've gathered to get you started.
## Getting started
-The first steps towards your GitLab CI journey.
+The first steps towards your GitLab CI/CD journey.
-- [Getting started with GitLab CI](quick_start/README.md)
-- [Pipelines and jobs](pipelines.md)
-- [Configure a Runner, the application that runs your jobs](runners/README.md)
-- **Articles:**
- - [Getting started with GitLab and GitLab CI - Intro to CI](https://about.gitlab.com/2015/12/14/getting-started-with-gitlab-and-gitlab-ci/)
- - [Continuous Integration, Delivery, and Deployment with GitLab - Intro to CI/CD](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/)
- - [GitLab CI: Run jobs sequentially, in parallel, or build a custom pipeline](https://about.gitlab.com/2016/07/29/the-basics-of-gitlab-ci/)
- - [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/)
+- [Getting started with GitLab CI/CD](quick_start/README.md): understand how GitLab CI/CD works.
+- GitLab CI/CD configuration file: [`.gitlab-ci.yml`](yaml/README.md) - Learn all about the ins and outs of `.gitlab-ci.yml`.
+- [Pipelines and jobs](pipelines.md): configure your GitLab CI/CD pipelines to build, test, and deploy your application.
+- Runners: The [GitLab Runner](https://docs.gitlab.com/runner/) is responsible by running the jobs in your CI/CD pipeline. On GitLab.com, Shared Runners are enabled by default, so
+you don't need to set up anything to start to use them with GitLab CI/CD.
+
+### Introduction to GitLab CI/CD
+
+- Article (2016-08-05): [Continuous Integration, Delivery, and Deployment with GitLab - Intro to CI/CD](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/)
+- Article (2015-12-14): [Getting started with GitLab and GitLab CI - Intro to CI](https://about.gitlab.com/2015/12/14/getting-started-with-gitlab-and-gitlab-ci/)
+- Article (2017-07-13): [Making CI Easier with GitLab](https://about.gitlab.com/2017/07/13/making-ci-easier-with-gitlab/)
+- Article (2017-05-22): [Fast and Natural Continuous Integration with GitLab CI](https://about.gitlab.com/2017/05/22/fast-and-natural-continuous-integration-with-gitlab-ci/)
- **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/)
+ - 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:**
- [Intégration continue avec GitLab (September, 2016)](https://www.youtube.com/watch?v=URcMBXjIr24&t=13s)
- [GitLab CI for Minecraft Plugins (July, 2016)](https://www.youtube.com/watch?v=Z4pcI9F8yf8)
-## Reference guides
+### Why GitLab CI/CD?
-Once you get familiar with the getting started guides, you'll find yourself
-digging into specific reference guides.
+ - Article (2016-10-17): [Why We Chose GitLab CI for our CI/CD Solution](https://about.gitlab.com/2016/10/17/gitlab-ci-oohlala/)
+ - Article (2016-07-22): [Building our web-app on GitLab CI: 5 reasons why Captain Train migrated from Jenkins to GitLab CI](https://about.gitlab.com/2016/07/22/building-our-web-app-on-gitlab-ci/)
-- [`.gitlab-ci.yml` reference](yaml/README.md) - Learn all about the ins and
- outs of `.gitlab-ci.yml` definitions
-- [CI Variables](variables/README.md) - Learn how to use variables defined in
+## Exploring GitLab CI/CD
+
+- [CI/CD Variables](variables/README.md) - Learn how to use variables defined in
your `.gitlab-ci.yml` or secured ones defined in your project's settings
- **The permissions model** - Learn about the access levels a user can have for
performing certain CI actions
- [User permissions](../user/permissions.md#gitlab-ci)
- [Job permissions](../user/permissions.md#job-permissions)
-
-## Auto DevOps
-
-- [Auto DevOps](../topics/autodevops/index.md)
-
-## GitLab CI + Docker
-
-Leverage the power of Docker to run your CI pipelines.
-
-- [Use Docker images with GitLab Runner](docker/using_docker_images.md)
-- [Use CI to build Docker images](docker/using_docker_build.md)
-- [CI services (linked Docker containers)](services/README.md)
-- **Articles:**
- - [Setting up GitLab Runner For Continuous Integration](https://about.gitlab.com/2016/03/01/gitlab-runner-with-docker/)
+- [Configure a Runner, the application that runs your jobs](runners/README.md)
+- Article (2016-03-01): [Setting up GitLab Runner For Continuous Integration](https://about.gitlab.com/2016/03/01/gitlab-runner-with-docker/)
+- Article (2016-07-29): [GitLab CI: Run jobs sequentially, in parallel, or build a custom pipeline](https://about.gitlab.com/2016/07/29/the-basics-of-gitlab-ci/)
+- Article (2016-08-26): [GitLab CI: Deployment & environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/)
+- Article (2016-05-23): [Introduction to GitLab Container Registry](https://about.gitlab.com/2016/05/23/gitlab-container-registry/)
## Advanced use
-Once you get familiar with the basics of GitLab CI, it's time to dive in and
+Once you get familiar with the basics of GitLab CI/CD, it's time to dive in and
learn how to leverage its potential even more.
-- [Environments and deployments](environments.md) - Separate your jobs into
+- [Environments and deployments](environments.md): Separate your jobs into
environments and use them for different purposes like testing, building and
deploying
- [Job artifacts](../user/project/pipelines/job_artifacts.md)
+- [Caching dependencies](caching/index.md)
- [Git submodules](git_submodules.md) - How to run your CI jobs when Git
submodules are involved
-- [Auto deploy](autodeploy/index.md)
- [Use SSH keys in your build environment](ssh_keys/README.md)
- [Trigger pipelines through the GitLab API](triggers/README.md)
- [Trigger pipelines on a schedule](../user/project/pipelines/schedules.md)
+- [Kubernetes clusters](../user/project/clusters/index.md) - Integrate one or
+ more Kubernetes clusters to your project
-## Review Apps
+## GitLab CI/CD for Docker
-- [Review Apps](review_apps/index.md)
-- **Articles:**
- - [Introducing Review Apps](https://about.gitlab.com/2016/11/22/introducing-review-apps/)
- - [Example project that shows how to use Review Apps](https://gitlab.com/gitlab-examples/review-apps-nginx/)
+Leverage the power of Docker to run your CI pipelines.
-## GitLab CI for GitLab Pages
+- [Use Docker images with GitLab Runner](docker/using_docker_images.md)
+- [Use CI to build Docker images](docker/using_docker_build.md)
+- [CI services (linked Docker containers)](services/README.md)
+- Article (2016-03-01): [Setting up GitLab Runner For Continuous Integration](https://about.gitlab.com/2016/03/01/gitlab-runner-with-docker/)
-See the topic on [GitLab Pages](../user/project/pages/index.md).
+## Review Apps
-## Special configuration
+- [Review Apps documentation](review_apps/index.md)
+- Article (2016-11-22): [Introducing Review Apps](https://about.gitlab.com/2016/11/22/introducing-review-apps/)
+- [Example project that shows how to use Review Apps](https://gitlab.com/gitlab-examples/review-apps-nginx/)
-You can change the default behavior of GitLab CI in your whole GitLab instance
-as well as in each project.
+## Auto DevOps
-- **Project specific**
- - [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)
+- [Auto DevOps](../topics/autodevops/index.md): Auto DevOps automatically detects, builds, tests, deploys, and monitors your applications.
+
+## GitLab CI for GitLab Pages
+
+See the documentation on [GitLab Pages](../user/project/pages/index.md).
## Examples
->**Note:**
-A collection of `.gitlab-ci.yml` files is maintained at the
-[GitLab CI Yml project][gitlab-ci-templates].
-If your favorite programming language or framework is missing we would love
-your help by sending a merge request with a `.gitlab-ci.yml`.
-
-Here is an collection of tutorials and guides on setting up your CI pipeline.
-
-- [GitLab CI examples](examples/README.md) for the following languages and frameworks:
- - [PHP](examples/php.md)
- - [Ruby](examples/test-and-deploy-ruby-application-to-heroku.md)
- - [Python](examples/test-and-deploy-python-application-to-heroku.md)
- - [Clojure](examples/test-clojure-application.md)
- - [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)
- - [Analyze code quality with the Code Climate CLI](examples/code_climate.md)
-- **Articles**
- - [How to test and deploy Laravel/PHP applications with GitLab CI/CD and Envoy](../articles/laravel_with_gitlab_and_envoy/index.md)
- - [How to deploy Maven projects to Artifactory with GitLab CI/CD](examples/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/)
- - [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/)
- - [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/)
- - [CI/CD with GitLab in action](https://about.gitlab.com/2017/03/13/ci-cd-demo/)
- - [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/)
-- **Miscellaneous**
- - [Using `dpl` as deployment tool](examples/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)
- - [Example project that shows how to use Review Apps](https://gitlab.com/gitlab-examples/review-apps-nginx/)
+Check the [GitLab CI/CD examples](examples/README.md) for a collection of tutorials and guides on setting up your CI/CD pipeline for various programming languages, frameworks,
+and operating systems.
## Integrations
-- **Articles:**
- - [Continuous Delivery with GitLab and Convox](https://about.gitlab.com/2016/06/09/continuous-delivery-with-gitlab-and-convox/)
- - [Getting Started with GitLab and Shippable Continuous Integration](https://about.gitlab.com/2016/05/05/getting-started-gitlab-and-shippable/)
- - [GitLab Partners with DigitalOcean to make Continuous Integration faster, safer, and more affordable](https://about.gitlab.com/2016/04/19/gitlab-partners-with-digitalocean-to-make-continuous-integration-faster-safer-and-more-affordable/)
+- Article (2016-06-09): [Continuous Delivery with GitLab and Convox](https://about.gitlab.com/2016/06/09/continuous-delivery-with-gitlab-and-convox/)
+- Article (2016-05-05): [Getting Started with GitLab and Shippable Continuous Integration](https://about.gitlab.com/2016/05/05/getting-started-gitlab-and-shippable/)
+- Article (2016-04-19): [GitLab Partners with DigitalOcean to make Continuous Integration faster, safer, and more affordable](https://about.gitlab.com/2016/04/19/gitlab-partners-with-digitalocean-to-make-continuous-integration-faster-safer-and-more-affordable/)
-## Why GitLab CI?
+## Special configuration (GitLab admin)
-- **Articles:**
- - [Why We Chose GitLab CI for our CI/CD Solution](https://about.gitlab.com/2016/10/17/gitlab-ci-oohlala/)
- - [Building our web-app on GitLab CI: 5 reasons why Captain Train migrated from Jenkins to GitLab CI](https://about.gitlab.com/2016/07/22/building-our-web-app-on-gitlab-ci/)
+As a GitLab administrator, you can change the default behavior of GitLab CI/CD in
+your whole GitLab instance as well as in each project.
+
+- [Continuous Integration admin settings](../administration/index.md#continuous-integration-settings)
+- **Project specific:**
+ - [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)
## Breaking changes
diff --git a/doc/ci/autodeploy/index.md b/doc/ci/autodeploy/index.md
index 474cb28b9e4..7102af5c529 100644
--- a/doc/ci/autodeploy/index.md
+++ b/doc/ci/autodeploy/index.md
@@ -37,6 +37,8 @@ during the deployment.
We made a [simple guide](quick_start_guide.md) to using Auto Deploy with GitLab.com.
+For a demonstration of GitLab Auto Deploy, read the blog post [Auto Deploy from GitLab to an OpenShift Container Cluster](https://about.gitlab.com/2017/05/16/devops-containers-gitlab-openshift/)
+
## Supported templates
The list of supported auto deploy templates is available in the
diff --git a/doc/ci/caching/img/clear_runners_cache.png b/doc/ci/caching/img/clear_runners_cache.png
new file mode 100644
index 00000000000..e5db4a47b3e
--- /dev/null
+++ b/doc/ci/caching/img/clear_runners_cache.png
Binary files differ
diff --git a/doc/ci/caching/index.md b/doc/ci/caching/index.md
new file mode 100644
index 00000000000..c159198d16b
--- /dev/null
+++ b/doc/ci/caching/index.md
@@ -0,0 +1,518 @@
+# Cache dependencies in GitLab CI/CD
+
+GitLab CI/CD provides a caching mechanism that can be used to save time
+when your jobs are running.
+
+Caching is about speeding the time a job is executed by reusing the same
+content of a previous job. It can be particularly useful when your are
+developing software that depends on other libraries which are fetched via the
+internet during build time.
+
+If caching is enabled, it's shared between pipelines and jobs by default,
+starting from GitLab 9.0.
+
+Make sure you read the [`cache` reference](../yaml/README.md#cache) to learn
+how it is defined in `.gitlab-ci.yml`.
+
+## Good caching practices
+
+We have the cache from the perspective of the developers (who consume a cache
+within the job) and the cache from the perspective of the Runner. Depending on
+which type of Runner you are using, cache can act differently.
+
+From the perspective of the developer, to ensure maximum availability of the
+cache, when declaring `cache` in your jobs, use one or a mix of the following:
+
+- [Tag your Runners](../runners/README.md#using-tags) and use the tag on jobs
+ that share their cache.
+- [Use sticky Runners](../runners/README.md#locking-a-specific-runner-from-being-enabled-for-other-projects)
+ that will be only available to a particular project.
+- [Use a `key`](../yaml/README.md#cache-key) that fits your workflow (e.g.,
+ different caches on each branch). For that, you can take advantage of the
+ [CI/CD predefined variables](../variables/README.md#predefined-variables-environment-variables).
+
+TIP: **Tip:**
+Using the same Runner for your pipeline, is the most simple and efficient way to
+cache files in one stage or pipeline, and pass this cache to subsequent stages
+or pipelines in a guaranteed manner.
+
+From the perspective of the Runner, in order for cache to work effectively, one
+of the following must be true:
+
+- Use a single Runner for all your jobs
+- Use multiple Runners (in autoscale mode or not) that use
+ [distributed caching](https://docs.gitlab.com/runner/configuration/autoscale.html#distributed-runners-caching),
+ where the cache is stored in S3 buckets (like shared Runners on GitLab.com)
+- Use multiple Runners (not in autoscale mode) of the same architecture that
+ share a common network-mounted directory (using NFS or something similar)
+ where the cache will be stored
+
+TIP: **Tip:**
+Read about the [availability of the cache](#availability-of-the-cache)
+to learn more about the internals and get a better idea how cache works.
+
+### Sharing caches across the same branch
+
+Define a cache with the `key: ${CI_COMMIT_REF_SLUG}` so that jobs of each
+branch always use the same cache:
+
+```yaml
+cache:
+ key: ${CI_COMMIT_REF_SLUG}
+```
+
+While this feels like it might be safe from accidentally overwriting the cache,
+it means merge requests get slow first pipelines, which might be a bad
+developer experience. The next time a new commit is pushed to the branch, the
+cache will be re-used.
+
+To enable per-job and per-branch caching:
+
+```yaml
+cache:
+ key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
+```
+
+To enable per-branch and per-stage caching:
+
+```yaml
+cache:
+ key: "$CI_JOB_STAGE-$CI_COMMIT_REF_SLUG"
+```
+
+### Sharing caches across different branches
+
+If the files you are caching need to be shared across all branches and all jobs,
+you can use the same key for all of them:
+
+```yaml
+cache:
+ key: one-key-to-rull-them-all
+```
+
+To share the same cache between branches, but separate them by job:
+
+```yaml
+cache:
+ key: ${CI_JOB_NAME}
+```
+
+### Disabling cache on specific jobs
+
+If you have defined the cache globally, it means that each job will use the
+same definition. You can override this behavior per-job, and if you want to
+disable it completely, use an empty hash:
+
+```yaml
+job:
+ cache: {}
+```
+
+For more fine tuning, read also about the
+[`cache: policy`](../yaml/README.md#cache-policy).
+
+## Common use cases
+
+The most common use case of cache is to preserve contents between subsequent
+runs of jobs for things like dependencies and commonly used libraries
+(Nodejs packages, PHP packages, rubygems, python libraries, etc.),
+so they don't have to be re-fetched from the public internet.
+
+NOTE: **Note:**
+For more examples, check the [GitLab CI Yml](https://gitlab.com/gitlab-org/gitlab-ci-yml)
+project.
+
+### Caching Nodejs dependencies
+
+Assuming your project is using [npm](https://www.npmjs.com/) or
+[Yarn](https://yarnpkg.com/en/) to install the Nodejs dependencies, the
+following example defines `cache` globally so that all jobs inherit it.
+Nodejs modules are installed in `node_modules/` and are cached per-branch:
+
+```yaml
+#
+# https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Nodejs.gitlab-ci.yml
+#
+image: node:latest
+
+# Cache modules in between jobs
+cache:
+ key: ${CI_COMMIT_REF_SLUG}
+ paths:
+ - node_modules/
+
+before_script:
+ - npm install
+
+test_async:
+ script:
+ - node ./specs/start.js ./specs/async.spec.js
+```
+
+### Caching PHP dependencies
+
+Assuming your project is using [Composer](https://getcomposer.org/) to install
+the PHP dependencies, the following example defines `cache` globally so that
+all jobs inherit it. PHP libraries modules are installed in `vendor/` and
+are cached per-branch:
+
+```yaml
+#
+# https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/PHP.gitlab-ci.yml
+#
+image: php:7.2
+
+# Cache libraries in between jobs
+cache:
+ key: ${CI_COMMIT_REF_SLUG}
+ paths:
+ - vendor/
+
+before_script:
+# Install and run Composer
+- curl --show-error --silent https://getcomposer.org/installer | php
+- php composer.phar install
+
+test:
+ script:
+ - vendor/bin/phpunit --configuration phpunit.xml --coverage-text --colors=never
+```
+
+### Caching Python dependencies
+
+Assuming your project is using [pip](https://pip.pypa.io/en/stable/) to install
+the python dependencies, the following example defines `cache` globally so that
+all jobs inherit it. Python libraries are installed in a virtualenv under `venv/`,
+pip's cache is defined under `.cache/pip/` and both are cached per-branch:
+
+```yaml
+#
+# https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Python.gitlab-ci.yml
+#
+image: python:latest
+
+# Change pip's cache directory to be inside the project directory since we can
+# only cache local items.
+variables:
+ PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache"
+
+# Pip's cache doesn't store the python packages
+# https://pip.pypa.io/en/stable/reference/pip_install/#caching
+#
+# If you want to also cache the installed packages, you have to install
+# them in a virtualenv and cache it as well.
+cache:
+ paths:
+ - .cache/
+ - venv/
+
+before_script:
+ - python -V # Print out python version for debugging
+ - pip install virtualenv
+ - virtualenv venv
+ - source venv/bin/activate
+
+test:
+ script:
+ - python setup.py test
+ - pip install flake8
+ - flake8 .
+```
+
+### Caching Ruby dependencies
+
+Assuming your project is using [Bundler](https://bundler.io) to install the
+gem dependencies, the following example defines `cache` globally so that all
+jobs inherit it. Gems are installed in `vendor/ruby/` and are cached per-branch:
+
+```yaml
+#
+# https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Ruby.gitlab-ci.yml
+#
+image: ruby:2.5
+
+# Cache gems in between builds
+cache:
+ key: ${CI_COMMIT_REF_SLUG}
+ paths:
+ - vendor/ruby
+
+before_script:
+ - ruby -v # Print out ruby version for debugging
+ - gem install bundler --no-ri --no-rdoc # Bundler is not installed with the image
+ - bundle install -j $(nproc) --path vendor # Install dependencies into ./vendor/ruby
+
+rspec:
+ script:
+ - rspec spec
+```
+
+## Availability of the cache
+
+Caching is an optimization, but isn't guaranteed to always work, so you need to
+be prepared to regenerate any cached files in each job that needs them.
+
+Assuming you have properly [defined `cache` in `.gitlab-ci.yml`](../yaml/README.md#cache)
+according to your workflow, the availability of the cache ultimately depends on
+how the Runner has been configured (the executor type and whether different
+Runners are used for passing the cache between jobs).
+
+### Where the caches are stored
+
+Since the Runner is the one responsible for storing the cache, it's essential
+to know **where** it's stored. All the cache paths defined under a job in
+`.gitlab-ci.yml` are archived in a single `cache.zip` file and stored in the
+Runner's configured cache location. By default, they are stored locally in the
+machine where the Runner is installed and depends on the type of the executor.
+
+| GitLab Runner executor | Default path of the cache |
+| ---------------------- | ------------------------- |
+| [Shell](https://docs.gitlab.com/runner/executors/shell.html) | Locally, stored under the `gitlab-runner` user's home directory: `/home/gitlab-runner/cache/<user>/<project>/<cache-key>/cache.zip`. |
+| [Docker](https://docs.gitlab.com/runner/executors/docker.html) | Locally, stored under [Docker volumes](https://docs.gitlab.com/runner/executors/docker.html#the-builds-and-cache-storage): `/var/lib/docker/volumes/<volume-id>/_data/<user>/<project>/<cache-key>/cache.zip`. |
+| [Docker machine](https://docs.gitlab.com/runner/executors/docker_machine.html) (autoscale Runners) | Behaves the same as the Docker executor. |
+
+### How archiving and extracting works
+
+In the most simple scenario, consider that you use only one machine where the
+Runner is installed, and all jobs of your project run on the same host.
+
+Let's see the following example of two jobs that belong to two consecutive
+stages:
+
+```yaml
+stages:
+- build
+- test
+
+before_script:
+- echo "Hello"
+
+job A:
+ stage: build
+ script:
+ - mkdir vendor/
+ - echo "build" > vendor/hello.txt
+ cache:
+ key: build-cache
+ paths:
+ - vendor/
+ after_script:
+ - echo "World"
+
+job B:
+ stage: test
+ script:
+ - cat vendor/hello.txt
+ cache:
+ key: build-cache
+```
+
+Here's what happens behind the scenes:
+
+1. Pipeline starts
+1. `job A` runs
+1. `before_script` is executed
+1. `script` is executed
+1. `after_script` is executed
+1. `cache` runs and the `vendor/` directory is zipped into `cache.zip`.
+ This file is then saved in the directory based on the
+ [Runner's setting](#where-the-caches-are-stored) and the `cache: key`.
+1. `job B` runs
+1. The cache is extracted (if found)
+1. `before_script` is executed
+1. `script` is executed
+1. Pipeline finishes
+
+By using a single Runner on a single machine, you'll not have the issue where
+`job B` might execute on a Runner different from `job A`, thus guaranteeing the
+cache between stages. That will only work if the build goes from stage `build`
+to `test` in the same Runner/machine, otherwise, you [might not have the cache
+available](#cache-mismatch).
+
+During the caching process, there's also a couple of things to consider:
+
+- If some other job, with another cache configuration had saved its
+ cache in the same zip file, it is overwritten. If the S3 based shared cache is
+ used, the file is additionally uploaded to S3 to an object based on the cache
+ key. So, two jobs with different paths, but the same cache key, will overwrite
+ their cache.
+- When extracting the cache from `cache.zip`, everything in the zip file is
+ extracted in the job's working directory (usually the repository which is
+ pulled down), and the Runner doesn't mind if the archive of `job A` overwrites
+ things in the archive of `job B`.
+
+The reason why it works this way is because the cache created for one Runner
+often will not be valid when used by a different one which can run on a
+**different architecture** (e.g., when the cache includes binary files). And
+since the different steps might be executed by Runners running on different
+machines, it is a safe default.
+
+### Cache mismatch
+
+In the following table, you can see some reasons where you might hit a cache
+mismatch and a few ideas how to fix it.
+
+| Reason of a cache mismatch | How to fix it |
+| -------------------------- | ------------- |
+| You use multiple standalone Runners (not in autoscale mode) attached to one project without a shared cache | Use only one Runner for your project or use multiple Runners with distributed cache enabled |
+| You use Runners in autoscale mode without a distributed cache enabled | Configure the autoscale Runner to use a distributed cache |
+| The machine the Runner is installed on is low on disk space or, if you've set up distributed cache, the S3 bucket where the cache is stored doesn't have enough space | Make sure you clear some space to allow new caches to be stored. Currently, there's no automatic way to do this. |
+| You use the same `key` for jobs where they cache different paths. | Use different cache keys to that the cache archive is stored to a different location and doesn't overwrite wrong caches. |
+
+Let's explore some examples.
+
+---
+
+Let's assume you have only one Runner assigned to your project, so the cache
+will be stored in the Runner's machine by default. If two jobs, A and B,
+have the same cache key, but they cache different paths, cache B would overwrite
+cache A, even if their `paths` don't match:
+
+We want `job A` and `job B` to re-use their
+cache when the pipeline is run for a second time.
+
+```yaml
+stages:
+- build
+- test
+
+job A:
+ stage: build
+ script: make build
+ cache:
+ key: same-key
+ paths:
+ - public/
+
+job B:
+ stage: test
+ script: make test
+ cache:
+ key: same-key
+ paths:
+ - vendor/
+```
+
+1. `job A` runs
+1. `public/` is cached as cache.zip
+1. `job B` runs
+1. The previous cache, if any, is unzipped
+1. `vendor/` is cached as cache.zip and overwrites the previous one
+1. The next time `job A` runs it will use the cache of `job B` which is different
+ and thus will be ineffective
+
+To fix that, use different `keys` for each job.
+
+---
+
+In another case, let's assume you have more than one Runners assigned to your
+project, but the distributed cache is not enabled. We want the second time the
+pipeline is run, `job A` and `job B` to re-use their cache (which in this case
+will be different):
+
+```yaml
+stages:
+- build
+- test
+
+job A:
+ stage: build
+ script: build
+ cache:
+ key: keyA
+ paths:
+ - vendor/
+
+job B:
+ stage: test
+ script: test
+ cache:
+ key: keyB
+ paths:
+ - vendor/
+```
+
+In that case, even if the `key` is different (no fear of overwriting), you
+might experience the cached files to "get cleaned" before each stage if the
+jobs run on different Runners in the subsequent pipelines.
+
+## Clearing the cache
+
+GitLab Runners use [cache](../yaml/README.md#cache) to speed up the execution
+of your jobs by reusing existing data. This however, can sometimes lead to an
+inconsistent behavior.
+
+To start with a fresh copy of the cache, there are two ways to do that.
+
+### Clearing the cache by changing `cache:key`
+
+All you have to do is set a new `cache: key` in your `.gitlab-ci.yml`. In the
+next run of the pipeline, the cache will be stored in a different location.
+
+### Clearing the cache manually
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41249) in GitLab 10.4.
+
+If you want to avoid editing `.gitlab-ci.yml`, you can easily clear the cache
+via GitLab's UI:
+
+1. Navigate to your project's **CI/CD > Pipelines** page
+1. Click on the **Clear Runner caches** button to clean up the cache
+
+ ![Clear Runners cache](img/clear_runners_cache.png)
+
+1. On the next push, your CI/CD job will use a new cache
+
+Behind the scenes, this works by increasing a counter in the database, and the
+value of that counter is used to create the key for the cache by appending an
+integer to it: `-1`, `-2`, etc. After a push, a new key is generated and the
+old cache is not valid anymore.
+
+## Cache vs artifacts
+
+NOTE: **Note:**
+Be careful if you use cache and artifacts to store the same path in your jobs
+as **caches are restored before artifacts** and the content would be overwritten.
+
+Don't mix the caching with passing artifacts between stages. Caching is not
+designed to pass artifacts between stages. Cache is for runtime dependencies
+needed to compile the project:
+
+- `cache` - **Use for temporary storage for project dependencies.** Not useful
+ for keeping intermediate build results, like `jar` or `apk` files.
+ Cache was designed to be used to speed up invocations of subsequent runs of a
+ given job, by keeping things like dependencies (e.g., npm packages, Go vendor
+ packages, etc.) so they don't have to be re-fetched from the public internet.
+ While the cache can be abused to pass intermediate build results between stages,
+ there may be cases where artifacts are a better fit.
+- `artifacts` - **Use for stage results that will be passed between stages.**
+ Artifacts were designed to upload some compiled/generated bits of the build,
+ and they can be fetched by any number of concurrent Runners. They are
+ guaranteed to be available and are there to pass data between jobs. They are
+ also exposed to be downloaded from the UI.
+
+It's sometimes confusing because the name artifact sounds like something that
+is only useful outside of the job, like for downloading a final image. But
+artifacts are also available in between stages within a pipeline. So if you
+build your application by downloading all the required modules, you might want
+to declare them as artifacts so that each subsequent stage can depend on them
+being there. There are some optimizations like declaring an
+[expiry time](../yaml/README.md#artifacts-expire_in) so you don't keep artifacts
+around too long, and using [dependencies](../yaml/README.md#dependencies) to
+control exactly where artifacts are passed around.
+
+So, to sum up:
+- Caches are disabled if not defined globally or per job (using `cache:`)
+- Caches are available for all jobs in your `.gitlab-ci.yml` if enabled globally
+- Caches can be used by subsequent pipelines of that very same job (a script in
+ a stage) in which the cache was created (if not defined globally).
+- Caches are stored where the Runner is installed **and** uploaded to S3 if
+ [distributed cache is enabled](https://docs.gitlab.com/runner/configuration/autoscale.html#distributed-runners-caching)
+- Caches defined per job are only used either a) for the next pipeline of that job,
+ or b) if that same cache is also defined in a subsequent job of the same pipeline
+- Artifacts are disabled if not defined per job (using `artifacts:`)
+- Artifacts can only be enabled per job, not globally
+- Artifacts are created during a pipeline and can be used by the subsequent
+ jobs of that currently active pipeline
+- Artifacts are always uploaded to GitLab (known as coordinator)
+- Artifacts can have an expiration value for controlling disk usage (30 days by default)
diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md
index 22afcb9199d..183808641c0 100644
--- a/doc/ci/docker/using_docker_build.md
+++ b/doc/ci/docker/using_docker_build.md
@@ -1,26 +1,29 @@
-# Using Docker Build
+# Building Docker images with GitLab CI/CD
-GitLab CI allows you to use Docker Engine to build and test docker-based projects.
+GitLab CI/CD allows you to use Docker Engine to build and test docker-based projects.
-**This also allows to you to use `docker-compose` and other docker-enabled tools.**
+TIP: **Tip:**
+This also allows to you to use `docker-compose` and other docker-enabled tools.
One of the new trends in Continuous Integration/Deployment is to:
-1. create an application image,
-1. run tests against the created image,
-1. push image to a remote registry, and
-1. deploy to a server from the pushed image.
+1. Create an application image
+1. Run tests against the created image
+1. Push image to a remote registry
+1. Deploy to a server from the pushed image
-It's also useful when your application already has the `Dockerfile` that can be used to create and test an image:
+It's also useful when your application already has the `Dockerfile` that can be
+used to create and test an image:
```bash
-$ docker build -t my-image dockerfiles/
-$ docker run my-docker-image /script/to/run/tests
-$ docker tag my-image my-registry:5000/my-image
-$ docker push my-registry:5000/my-image
+docker build -t my-image dockerfiles/
+docker run my-docker-image /script/to/run/tests
+docker tag my-image my-registry:5000/my-image
+docker push my-registry:5000/my-image
```
-This requires special configuration of GitLab Runner to enable `docker` support during jobs.
+This requires special configuration of GitLab Runner to enable `docker` support
+during jobs.
## Runner Configuration
@@ -74,8 +77,8 @@ GitLab Runner then executes job scripts as the `gitlab-runner` user.
5. You can now use `docker` command and install `docker-compose` if needed.
-> **Note:**
-* By adding `gitlab-runner` to the `docker` group you are effectively granting `gitlab-runner` full root permissions.
+NOTE: **Note:**
+By adding `gitlab-runner` to the `docker` group you are effectively granting `gitlab-runner` full root permissions.
For more information please read [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful).
### Use docker-in-docker executor
@@ -259,8 +262,66 @@ aware of the following implications:
docker run --rm -t -i -v $(pwd)/src:/home/app/src test-image:latest run_app_tests
```
+## Making docker-in-docker builds faster with Docker layer caching
+
+When using docker-in-docker, Docker will download all layers of your image every
+time you create a build. Recent versions of Docker (Docker 1.13 and above) can
+use a pre-existing image as a cache during the `docker build` step, considerably
+speeding up the build process.
+
+### How Docker caching works
+
+When running `docker build`, each command in `Dockerfile` results in a layer.
+These layers are kept around as a cache and can be reused if there haven't been
+any changes. Change in one layer causes all subsequent layers to be recreated.
+
+You can specify a tagged image to be used as a cache source for the `docker build`
+command by using the `--cache-from` argument. Multiple images can be specified
+as a cache source by using multiple `--cache-from` arguments. Keep in mind that
+any image that's used with the `--cache-from` argument must first be pulled
+(using `docker pull`) before it can be used as a cache source.
+
+### Using Docker caching
+
+Here's a simple `.gitlab-ci.yml` file showing how Docker caching can be utilized:
+
+```yaml
+image: docker:latest
+
+services:
+ - docker:dind
+
+variables:
+ CONTAINER_IMAGE: registry.gitlab.com/$CI_PROJECT_PATH
+ DOCKER_DRIVER: overlay2
+
+before_script:
+ - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com
+
+build:
+ stage: build
+ script:
+ - docker pull $CONTAINER_IMAGE:latest || true
+ - docker build --cache-from $CONTAINER_IMAGE:latest --tag $CONTAINER_IMAGE:$CI_BUILD_REF --tag $CONTAINER_IMAGE:latest .
+ - docker push $CONTAINER_IMAGE:$CI_BUILD_REF
+ - docker push $CONTAINER_IMAGE:latest
+```
+
+The steps in the `script` section for the `build` stage can be summed up to:
+
+1. The first command tries to pull the image from the registry so that it can be
+ used as a cache for the `docker build` command.
+1. The second command builds a Docker image using the pulled image as a
+ cache (notice the `--cache-from $CONTAINER_IMAGE:latest` argument) if
+ available, and tags it.
+1. The last two commands push the tagged Docker images to the container registry
+ so that they may also be used as cache for subsequent builds.
+
## Using the OverlayFS driver
+NOTE: **Note:**
+The shared Runners on GitLab.com use the `overlay2` driver by default.
+
By default, when using `docker:dind`, Docker uses the `vfs` storage driver which
copies the filesystem on every run. This is a very disk-intensive operation
which can be avoided if a different driver is used, for example `overlay2`.
diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md
index fb5bfe26bb0..bc5d3840368 100644
--- a/doc/ci/docker/using_docker_images.md
+++ b/doc/ci/docker/using_docker_images.md
@@ -58,7 +58,7 @@ 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
-run a database container, eg. `mysql`. It's easier and faster to use an
+run a database container, e.g., `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.
@@ -83,6 +83,67 @@ So, in order to access your database service you have to connect to the host
named `mysql` instead of a socket or `localhost`. Read more in [accessing the
services](#accessing-the-services).
+### How the health check of services works
+
+Services are designed to provide additional functionality which is **network accessible**.
+It may be a database like MySQL, or Redis, and even `docker:dind` which
+allows you to use Docker in Docker. It can be practically anything that is
+required for the CI/CD job to proceed and is accessed by network.
+
+To make sure this works, the Runner:
+
+1. checks which ports are exposed from the container by default
+1. starts a special container that waits for these ports to be accessible
+
+When the second stage of the check fails, either because there is no opened port in the
+service, or the service was not started properly before the timeout and the port is not
+responding, it prints the warning: `*** WARNING: Service XYZ probably didn't start properly`.
+
+In most cases it will affect the job, but there may be situations when the job
+will still succeed even if that warning was printed. For example:
+
+- The service was started a little after the warning was raised, and the job is
+ not using the linked service from the very beginning. In that case, when the
+ job needed to access the service, it may have been already there waiting for
+ connections.
+- The service container is not providing any networking service, but it's doing
+ something with the job's directory (all services have the job directory mounted
+ as a volume under `/builds`). In that case, the service will do its job, and
+ since the job is not trying to connect to it, it won't fail.
+
+### What services are not for
+
+As it was mentioned before, this feature is designed to provide **network accessible**
+services. A database is the simplest example of such a service.
+
+NOTE: **Note:**
+The services feature is not designed to, and will not add any software from the
+defined `services` image(s) to the job's container.
+
+For example, if you have the following `services` defined in your job, the `php`,
+`node` or `go` commands will **not** be available for your script, and thus
+the job will fail:
+
+```yaml
+job:
+ services:
+ - php:7
+ - node:latest
+ - golang:1.10
+ image: alpine:3.7
+ script:
+ - php -v
+ - node -v
+ - go version
+```
+
+If you need to have `php`, `node` and `go` available for your script, you should
+either:
+
+- choose an existing Docker image that contains all required tools, or
+- create your own Docker image, which will have all the required tools included
+ and use that in your job
+
### Accessing the services
Let's say that you need a Wordpress instance to test some API integration with
diff --git a/doc/ci/environments.md b/doc/ci/environments.md
index c03e16b1b38..b3d9f0bc96c 100644
--- a/doc/ci/environments.md
+++ b/doc/ci/environments.md
@@ -247,10 +247,19 @@ declaring their names dynamically in `.gitlab-ci.yml`. Dynamic environments is
the basis of [Review apps](review_apps/index.md).
>**Note:**
-The `name` and `url` parameters can use any of the defined CI variables,
+The `name` and `url` parameters can use most of the defined CI variables,
including predefined, secure variables and `.gitlab-ci.yml`
-[`variables`](yaml/README.md#variables).
-You however cannot use variables defined under `script` or on the Runner's side.
+[`variables`](yaml/README.md#variables). You however cannot use variables
+defined under `script` or on the Runner's side. There are other variables that
+are unsupported in environment name context:
+- `CI_JOB_ID`
+- `CI_JOB_TOKEN`
+- `CI_BUILD_ID`
+- `CI_BUILD_TOKEN`
+- `CI_REGISTRY_USER`
+- `CI_REGISTRY_PASSWORD`
+- `CI_REPOSITORY_URL`
+- `CI_ENVIRONMENT_URL`
GitLab Runner exposes various [environment variables][variables] when a job runs,
and as such, you can use them as environment names. Let's add another job in
@@ -455,7 +464,7 @@ Mappings are defined as entries in the root YAML array, and are identified by a
- Literal periods (`.`) should be escaped as `\.`.
- `public`
- a string, starting and ending with `'`.
- - Can include `\N` expressions to refer to capture groups in the `source` regular expression in order of their occurence, starting with `\1`.
+ - Can include `\N` expressions to refer to capture groups in the `source` regular expression in order of their occurrence, starting with `\1`.
The public path for a source path is determined by finding the first `source` expression that matches it, and returning the corresponding `public` path, replacing the `\N` expressions with the values of the `()` capture groups if appropriate.
diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md
index 25a0c5dcff5..de60cd27cd1 100644
--- a/doc/ci/examples/README.md
+++ b/doc/ci/examples/README.md
@@ -2,80 +2,83 @@
comments: false
---
-# GitLab CI Examples
+# GitLab CI/CD Examples
-A collection of `.gitlab-ci.yml` files is maintained at the [GitLab CI Yml project][gitlab-ci-templates].
-If your favorite programming language or framework are missing we would love your help by sending a merge request
-with a `.gitlab-ci.yml`.
+A collection of `.gitlab-ci.yml` template files is maintained at the [GitLab CI/CD YAML project][gitlab-ci-templates]. When you create a new file via the UI,
+GitLab will give you the option to choose one of the templates existent on this project.
+If your favorite programming language or framework are missing we would love your
+help by sending a merge request with a new `.gitlab-ci.yml` to this project.
-Apart from those, here is an collection of tutorials and guides on setting up your CI pipeline:
+There's also a collection of repositories with [example projects](https://gitlab.com/gitlab-examples) for various languages. You can fork an adjust them to your own needs.
## Languages, frameworks, OSs
-### PHP
+- **PHP**:
+ - [Testing a PHP application](php.md)
+ - [Run PHP Composer & NPM scripts then deploy them to a staging server](deployment/composer-npm-deploy.md)
+ - [How to test and deploy Laravel/PHP applications with GitLab CI/CD and Envoy](laravel_with_gitlab_and_envoy/index.md)
+- **Ruby**: [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md)
+- **Python**: [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md)
+- **Java**: [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/)
+- **Scala**: [Test a Scala application](test-scala-application.md)
+- **Clojure**: [Test a Clojure application](test-clojure-application.md)
+- **Elixir**:
+ - [Testing a Phoenix application with GitLab CI/CD](test_phoenix_app_with_gitlab_ci_cd/index.md)
+ - [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/)
+- **iOS and macOS**:
+ - [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/)
+ - [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/)
+- **Android**: [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/)
+- **Debian**: [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/)
+- **Maven**: [How to deploy Maven projects to Artifactory with GitLab CI/CD](artifactory_and_gitlab/index.md)
+
+### Game development
+
+- [DevOps and Game Dev with GitLab CI/CD](devops_and_game_dev_with_gitlab_ci_cd/index.md)
+
+### Miscellaneous
-- [Testing a PHP application](php.md)
-- [Run PHP Composer & NPM scripts then deploy them to a staging server](deployment/composer-npm-deploy.md)
-
-### Ruby
-
-- [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md)
-
-### Python
-
-- [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md)
-
-### Java
-
-- [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/)
+- [Using `dpl` as deployment tool](deployment/README.md)
+- [The `.gitlab-ci.yml` file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml)
-### Scala
+## Code quality analysis
-- [Test a Scala application](test-scala-application.md)
+[Analyze code quality with the Code Climate CLI](code_climate.md).
-### Clojure
+## Static Application Security Testing (SAST)
-- [Test a Clojure application](test-clojure-application.md)
+**(Ultimate)** [Scan your code for vulnerabilities](https://docs.gitlab.com/ee/ci/examples/sast.html)
-### Elixir
+## Dependency Scanning
-- [Test a Phoenix application](test-phoenix-application.md)
-- [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/)
+**(Ultimate)** [Scan your dependencies for vulnerabilities](https://docs.gitlab.com/ee/ci/examples/dependency_scanning.html)
-### iOS
+## Container Scanning
-- [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/)
+[Scan your Docker images for vulnerabilities](container_scanning.md)
-### Android
+## Dynamic Application Security Testing (DAST)
-- [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/)
+Scan your app for vulnerabilities with GitLab [Dynamic Application Security Testing (DAST)](dast.md).
-### Code quality analysis
+## Browser Performance Testing with Sitespeed.io
-- [Analyze code quality with the Code Climate CLI](code_climate.md)
+Analyze your [browser performance with Sitespeed.io](browser_performance.md).
-### Other
+## GitLab CI/CD for Review Apps
-- [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)
-- [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/)
-- [How to deploy Maven projects to Artifactory with GitLab CI/CD](artifactory_and_gitlab/index.md)
+- [Example project](https://gitlab.com/gitlab-examples/review-apps-nginx/) that shows how to use GitLab CI/CD for [Review Apps](../review_apps/index.html).
+- [Dockerizing GitLab Review Apps](https://about.gitlab.com/2017/07/11/dockerizing-review-apps/)
## GitLab CI/CD for GitLab Pages
-- [Example projects](https://gitlab.com/pages)
-- [Creating and Tweaking `.gitlab-ci.yml` for GitLab Pages](../../user/project/pages/getting_started_part_four.md)
-- [SSGs Part 3: Build any SSG site with GitLab Pages](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/):
-examples for Ruby-, NodeJS-, Python-, and GoLang-based SSGs
-- [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/)
-- [Publish code coverage reports with GitLab Pages](https://about.gitlab.com/2016/11/03/publish-code-coverage-report-with-gitlab-pages/)
-
See the documentation on [GitLab Pages](../../user/project/pages/index.md) for a complete overview.
-## More
+## Contributing
-Contributions are very much welcomed! You can help your favorite programming
-language and GitLab by sending a merge request with a guide for that language.
+Contributions are very welcome! You can help your favorite programming
+language users and GitLab by sending a merge request with a guide for that language.
+You may want to apply for the [GitLab Community Writers Program](https://about.gitlab.com/community-writers/)
+to get paid for writing complete articles for GitLab.
[gitlab-ci-templates]: https://gitlab.com/gitlab-org/gitlab-ci-yml
diff --git a/doc/ci/examples/artifactory_and_gitlab/index.md b/doc/ci/examples/artifactory_and_gitlab/index.md
index 8e91cd05d8a..d931c9a77f4 100644
--- a/doc/ci/examples/artifactory_and_gitlab/index.md
+++ b/doc/ci/examples/artifactory_and_gitlab/index.md
@@ -1,14 +1,14 @@
---
redirect_from: 'https://docs.gitlab.com/ee/articles/artifactory_and_gitlab/index.html'
+author: Fabio Busatto
+author_gitlab: bikebilly
+level: intermediary
+article_type: tutorial
+date: 2017-08-15
---
# 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/)
diff --git a/doc/ci/examples/browser_performance.md b/doc/ci/examples/browser_performance.md
index a7945d05cd0..691370d7195 100644
--- a/doc/ci/examples/browser_performance.md
+++ b/doc/ci/examples/browser_performance.md
@@ -1,50 +1,113 @@
# Browser Performance Testing with the Sitespeed.io container
-This example shows how to run the [Sitespeed.io container](https://hub.docker.com/r/sitespeedio/sitespeed.io/) on your code by using
-GitLab CI/CD and [Sitespeed.io](https://www.sitespeed.io) using Docker-in-Docker.
+This example shows how to run the
+[Sitespeed.io container](https://hub.docker.com/r/sitespeedio/sitespeed.io/) on
+your code by using GitLab CI/CD and [Sitespeed.io](https://www.sitespeed.io)
+using Docker-in-Docker.
-First, you need a GitLab Runner with the [docker-in-docker executor](../docker/using_docker_build.md#use-docker-in-docker-executor).
-
-Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `performance`:
+First, you need a GitLab Runner with the
+[docker-in-docker executor](../docker/using_docker_build.md#use-docker-in-docker-executor).
+Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called
+`performance`:
```yaml
+performance:
stage: performance
image: docker:git
+ variables:
+ URL: https://example.com
services:
- docker:dind
script:
+ - mkdir gitlab-exporter
+ - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js
- mkdir sitespeed-results
- - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io --outputFolder sitespeed-results https://my.website.com
+ - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results $URL
+ - mv sitespeed-results/data/performance.json performance.json
artifacts:
paths:
+ - performance.json
- sitespeed-results/
```
-This will create a `performance` job in your CI/CD pipeline and will run Sitespeed.io against the webpage you define. The full HTML Sitespeed.io report will be saved as an artifact, and if you have Pages enabled it can be viewed directly in your browser. For further customization options of Sitespeed.io, including the ability to provide a list of URLs to test, please consult their [documentation](https://www.sitespeed.io/documentation/sitespeed.io/configuration/).
+The above example will:
+
+1. Create a `performance` job in your CI/CD pipeline and will run
+ Sitespeed.io against the webpage you defined in `URL`.
+1. The [GitLab plugin](https://gitlab.com/gitlab-org/gl-performance) for
+ Sitespeed.io is downloaded in order to export key metrics to JSON. The full
+ HTML Sitespeed.io report will also be saved as an artifact, and if you have
+ [GitLab Pages](../../user/project/pages/index.md) enabled, it can be viewed
+ directly in your browser.
+
+For further customization options of Sitespeed.io, including the ability to
+provide a list of URLs to test, please consult
+[their documentation](https://www.sitespeed.io/documentation/sitespeed.io/configuration/).
-For GitLab [Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/) users, a performance score can be automatically
-extracted and shown right in the merge request widget. Learn more about [Browser Performance Testing](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html).
+TIP: **Tip:**
+For [GitLab Premium](https://about.gitlab.com/pricing/) users, key metrics are automatically
+extracted and shown right in the merge request widget. Learn more about
+[Browser Performance Testing](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html).
## Performance testing on Review Apps
-The above CI YML is great for testing against static environments, and it can be extended for dynamic environments. There are a few extra steps to take to set this up:
-1. The `performance` job should run after the environment has started.
-1. In the `deploy` job, persist the hostname so it is available to the `performance` job. The same can be done for static environments like staging and production to unify the code path. Saving it as an artifact is as simple as `echo $CI_ENVIRONMENT_URL > environment_url.txt`.
-1. In the `performance` job read the artifact into an environment variable, like `$CI_ENVIRONMENT_URL`, and use it to parameterize the test URL's.
-1. Now you can run the Sitespeed.io container against the desired hostname and paths.
+The above CI YML is great for testing against static environments, and it can
+be extended for dynamic environments. There are a few extra steps to take to
+set this up:
-A simple `performance` job would look like:
+1. The `performance` job should run after the dynamic environment has started.
+1. In the `review` job, persist the hostname and upload it as an artifact so
+ it's available to the `performance` job (the same can be done for static
+ environments like staging and production to unify the code path). Saving it
+ as an artifact is as simple as `echo $CI_ENVIRONMENT_URL > environment_url.txt`
+ in your job's `script`.
+1. In the `performance` job, read the previous artifact into an environment
+ variable, like `$CI_ENVIRONMENT_URL`, and use it to parameterize the test
+ URLs.
+1. You can now run the Sitespeed.io container against the desired hostname and
+ paths.
+
+Your `.gitlab-ci.yml` file would look like:
```yaml
+stages:
+ - deploy
+ - performance
+
+review:
+ stage: deploy
+ environment:
+ name: review/$CI_COMMIT_REF_SLUG
+ url: http://$CI_COMMIT_REF_SLUG.$APPS_DOMAIN
+ script:
+ - run_deploy_script
+ - echo $CI_ENVIRONMENT_URL > environment_url.txt
+ artifacts:
+ paths:
+ - environment_url.txt
+ only:
+ - branches
+ except:
+ - master
+
+performance:
stage: performance
image: docker:git
services:
- docker:dind
+ dependencies:
+ - review
script:
- export CI_ENVIRONMENT_URL=$(cat environment_url.txt)
+ - mkdir gitlab-exporter
+ - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js
- mkdir sitespeed-results
- - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io --outputFolder sitespeed-results $CI_ENVIRONMENT_URL
+ - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL"
+ - mv sitespeed-results/data/performance.json performance.json
artifacts:
paths:
- - sitespeed-results/
+ - performance.json
+ - sitespeed-results/
```
+
+A complete example can be found in our [Auto DevOps CI YML](https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Auto-DevOps.gitlab-ci.yml).
diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md
index 4d0ba8bfef3..92317c77427 100644
--- a/doc/ci/examples/code_climate.md
+++ b/doc/ci/examples/code_climate.md
@@ -9,26 +9,30 @@ Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codequali
```yaml
codequality:
- image: docker:latest
+ image: docker:stable
variables:
- DOCKER_DRIVER: overlay
+ DOCKER_DRIVER: overlay2
+ allow_failure: true
services:
- - docker:dind
+ - docker:stable-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
+ - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
+ - docker run --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
artifacts:
paths: [codeclimate.json]
```
-This will create a `codequality` job in your CI pipeline and will allow you to
-download and analyze the report artifact in JSON format.
+The above example will create a `codequality` job in your CI/CD pipeline which
+will scan your source code for code quality issues. The report will be saved
+as an artifact that you can later download and analyze.
-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).
+TIP: **Tip:**
+Starting with [GitLab Starter][ee] 9.3, this information will
+be automatically extracted and shown right in the merge request widget. To do
+so, the CI/CD job must be named `codequality` and the artifact path must be
+`codeclimate.json`.
+[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/
+[ee]: https://about.gitlab.com/products/
diff --git a/doc/ci/examples/container_scanning.md b/doc/ci/examples/container_scanning.md
new file mode 100644
index 00000000000..c58efc7392a
--- /dev/null
+++ b/doc/ci/examples/container_scanning.md
@@ -0,0 +1,55 @@
+# Container Scanning with GitLab CI/CD
+
+You can check your Docker images (or more precisely the containers) for known
+vulnerabilities by using [Clair](https://github.com/coreos/clair) and
+[clair-scanner](https://github.com/arminc/clair-scanner), two open source tools
+for Vulnerability Static Analysis for containers.
+
+All you need is a GitLab Runner with the Docker executor (the shared Runners on
+GitLab.com will work fine). You can then add a new job to `.gitlab-ci.yml`,
+called `sast:container`:
+
+```yaml
+sast:container:
+ image: docker:stable
+ variables:
+ DOCKER_DRIVER: overlay2
+ ## Define two new variables based on GitLab's CI/CD predefined variables
+ ## https://docs.gitlab.com/ee/ci/variables/#predefined-variables-environment-variables
+ CI_APPLICATION_REPOSITORY: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG
+ CI_APPLICATION_TAG: $CI_COMMIT_SHA
+ allow_failure: true
+ services:
+ - docker:stable-dind
+ script:
+ - docker run -d --name db arminc/clair-db:latest
+ - docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan:v2.0.1
+ - apk add -U wget ca-certificates
+ - docker pull ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG}
+ - wget https://github.com/arminc/clair-scanner/releases/download/v8/clair-scanner_linux_amd64
+ - mv clair-scanner_linux_amd64 clair-scanner
+ - chmod +x clair-scanner
+ - touch clair-whitelist.yml
+ - ./clair-scanner -c http://docker:6060 --ip $(hostname -i) -r gl-sast-container-report.json -l clair.log -w clair-whitelist.yml ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG} || true
+ artifacts:
+ paths: [gl-sast-container-report.json]
+```
+
+The above example will create a `sast:container` job in your CI/CD pipeline, pull
+the image from the [Container Registry](../../user/project/container_registry.md)
+(whose name is defined from the two `CI_APPLICATION_` variables) and scan it
+for possible vulnerabilities. The report will be saved as an artifact that you
+can later download and analyze.
+
+If you want to whitelist some specific vulnerabilities, you can do so by defining
+them in a [YAML file](https://github.com/arminc/clair-scanner/blob/master/README.md#example-whitelist-yaml-file),
+in our case its named `clair-whitelist.yml`.
+
+TIP: **Tip:**
+Starting with [GitLab Ultimate][ee] 10.4, this information will
+be automatically extracted and shown right in the merge request widget. To do
+so, the CI/CD job must be named `sast:container` and the artifact path must be
+`gl-sast-container-report.json`.
+[Learn more on container scanning results shown in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/container_scanning.html).
+
+[ee]: https://about.gitlab.com/products/
diff --git a/doc/ci/examples/dast.md b/doc/ci/examples/dast.md
new file mode 100644
index 00000000000..8df223ee560
--- /dev/null
+++ b/doc/ci/examples/dast.md
@@ -0,0 +1,63 @@
+# Dynamic Application Security Testing with GitLab CI/CD
+
+[Dynamic Application Security Testing (DAST)](https://en.wikipedia.org/wiki/Dynamic_program_analysis)
+is using the popular open source tool [OWASP ZAProxy](https://github.com/zaproxy/zaproxy)
+to perform an analysis on your running web application.
+
+It can be very useful combined with [Review Apps](../review_apps/index.md).
+
+## Example
+
+All you need is a GitLab Runner with the Docker executor (the shared Runners on
+GitLab.com will work fine). You can then add a new job to `.gitlab-ci.yml`,
+called `dast`:
+
+```yaml
+dast:
+ image: registry.gitlab.com/gitlab-org/security-products/zaproxy
+ variables:
+ website: "https://example.com"
+ allow_failure: true
+ script:
+ - mkdir /zap/wrk/
+ - /zap/zap-baseline.py -J gl-dast-report.json -t $website || true
+ - cp /zap/wrk/gl-dast-report.json .
+ artifacts:
+ paths: [gl-dast-report.json]
+```
+
+The above example will create a `dast` job in your CI/CD pipeline which will run
+the tests on the URL defined in the `website` variable (change it to use your
+own) and finally write the results in the `gl-dast-report.json` file. You can
+then download and analyze the report artifact in JSON format.
+
+It's also possible to authenticate the user before performing DAST checks:
+
+```yaml
+dast:
+ image: registry.gitlab.com/gitlab-org/security-products/zaproxy
+ variables:
+ website: "https://example.com"
+ login_url: "https://example.com/sign-in"
+ allow_failure: true
+ script:
+ - mkdir /zap/wrk/
+ - /zap/zap-baseline.py -J gl-dast-report.json -t $website \
+ --auth-url $login_url \
+ --auth-username "john.doe@example.com" \
+ --auth-password "john-doe-password" || true
+ - cp /zap/wrk/gl-dast-report.json .
+ artifacts:
+ paths: [gl-dast-report.json]
+```
+See [zaproxy documentation](https://gitlab.com/gitlab-org/security-products/zaproxy)
+to learn more about authentication settings.
+
+TIP: **Tip:**
+Starting with [GitLab Ultimate][ee] 10.4, this information will
+be automatically extracted and shown right in the merge request widget. To do
+so, the CI job must be named `dast` and the artifact path must be
+`gl-dast-report.json`.
+[Learn more about DAST results shown in merge requests](https://docs.gitlab.com/ee/user/project/merge_requests/dast.html).
+
+[ee]: https://about.gitlab.com/products/
diff --git a/doc/ci/examples/deployment/README.md b/doc/ci/examples/deployment/README.md
index e80e246c5dd..2dcdc2d41ec 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 âž” Pipelines âž” Secret variables**. The variables that are defined
+**Settings âž” CI / CD âž” 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/devops_and_game_dev_with_gitlab_ci_cd/img/aws_config_window.png b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/aws_config_window.png
new file mode 100644
index 00000000000..76e0295722b
--- /dev/null
+++ b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/aws_config_window.png
Binary files differ
diff --git a/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/gitlab_config.png b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/gitlab_config.png
new file mode 100644
index 00000000000..050a97d2726
--- /dev/null
+++ b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/gitlab_config.png
Binary files differ
diff --git a/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/test_pipeline_pass.png b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/test_pipeline_pass.png
new file mode 100644
index 00000000000..4ab5d5f401a
--- /dev/null
+++ b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/img/test_pipeline_pass.png
Binary files differ
diff --git a/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md
new file mode 100644
index 00000000000..bfc8558a580
--- /dev/null
+++ b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md
@@ -0,0 +1,526 @@
+---
+author: Ryan Hall
+author_gitlab: blitzgren
+level: intermediary
+article_type: tutorial
+date: 2018-03-07
+---
+
+# DevOps and Game Dev with GitLab CI/CD
+
+With advances in WebGL and WebSockets, browsers are extremely viable as game development
+platforms without the use of plugins like Adobe Flash. Furthermore, by using GitLab and [AWS](https://aws.amazon.com/),
+single game developers, as well as game dev teams, can easily host browser-based games online.
+
+In this tutorial, we'll focus on DevOps, as well as testing and hosting games with Continuous
+Integration/Deployment methods. We assume you are familiar with GitLab, javascript,
+and the basics of game development.
+
+## The game
+
+Our [demo game](http://gitlab-game-demo.s3-website-us-east-1.amazonaws.com/) consists of a simple spaceship traveling in space that shoots by clicking the mouse in a given direction.
+
+Creating a strong CI/CD pipeline at the beginning of developing another game, [Dark Nova](http://darknova.io/about),
+was essential for the fast pace the team worked at. This tutorial will build upon my
+[previous introductory article](https://ryanhallcs.wordpress.com/2017/03/15/devops-and-game-dev/) and go through the following steps:
+
+1. Using code from the previous article to start with a barebones [Phaser](https://phaser.io) game built by a gulp file
+1. Adding and running unit tests
+1. Creating a `Weapon` class that can be triggered to spawn a `Bullet` in a given direction
+1. Adding a `Player` class that uses this weapon and moves around the screen
+1. Adding the sprites we will use for the `Player` and `Weapon`
+1. Testing and deploying with Continuous Integration and Continuous Deployment methods
+
+By the end, we'll have the core of a [playable game](http://gitlab-game-demo.s3-website-us-east-1.amazonaws.com/)
+that's tested and deployed on every push to the `master` branch of the [codebase](https://gitlab.com/blitzgren/gitlab-game-demo).
+This will also provide
+boilerplate code for starting a browser-based game with the following components:
+
+- Written in [Typescript](https://www.typescriptlang.org/) and [PhaserJs](https://phaser.io)
+- Building, running, and testing with [Gulp](http://gulpjs.com/)
+- Unit tests with [Chai](http://chaijs.com/) and [Mocha](https://mochajs.org/)
+- CI/CD with GitLab
+- Hosting the codebase on GitLab.com
+- Hosting the game on AWS
+- Deploying to AWS
+
+## Requirements and setup
+
+Please refer to my previous article [DevOps and Game Dev](https://ryanhallcs.wordpress.com/2017/03/15/devops-and-game-dev/) to learn the foundational
+development tools, running a Hello World-like game, and building this game using GitLab
+CI/CD from every new push to master. The `master` branch for this game's [repository](https://gitlab.com/blitzgren/gitlab-game-demo)
+contains a completed version with all configurations. If you would like to follow along
+with this article, you can clone and work from the `devops-article` branch:
+
+```sh
+git clone git@gitlab.com:blitzgren/gitlab-game-demo.git
+git checkout devops-article
+```
+
+Next, we'll create a small subset of tests that exemplify most of the states I expect
+this `Weapon` class to go through. To get started, create a folder called `lib/tests`
+and add the following code to a new file `weaponTests.ts`:
+
+```ts
+import { expect } from 'chai';
+import { Weapon, BulletFactory } from '../lib/weapon';
+
+describe('Weapon', () => {
+ var subject: Weapon;
+ var shotsFired: number = 0;
+ // Mocked bullet factory
+ var bulletFactory: BulletFactory = <BulletFactory>{
+ generate: function(px, py, vx, vy, rot) {
+ shotsFired++;
+ }
+ };
+ var parent: any = { x: 0, y: 0 };
+
+ beforeEach(() => {
+ shotsFired = 0;
+ subject = new Weapon(bulletFactory, parent, 0.25, 1);
+ });
+
+ it('should shoot if not in cooldown', () => {
+ subject.trigger(true);
+ subject.update(0.1);
+ expect(shotsFired).to.equal(1);
+ });
+
+ it('should not shoot during cooldown', () => {
+ subject.trigger(true);
+ subject.update(0.1);
+ subject.update(0.1);
+ expect(shotsFired).to.equal(1);
+ });
+
+ it('should shoot after cooldown ends', () => {
+ subject.trigger(true);
+ subject.update(0.1);
+ subject.update(0.3); // longer than timeout
+ expect(shotsFired).to.equal(2);
+ });
+
+ it('should not shoot if not triggered', () => {
+ subject.update(0.1);
+ subject.update(0.1);
+ expect(shotsFired).to.equal(0);
+ });
+});
+```
+
+To build and run these tests using gulp, let's also add the following gulp functions
+to the existing `gulpfile.js` file:
+
+```ts
+gulp.task('build-test', function () {
+ return gulp.src('src/tests/**/*.ts', { read: false })
+ .pipe(tap(function (file) {
+ // replace file contents with browserify's bundle stream
+ file.contents = browserify(file.path, { debug: true })
+ .plugin(tsify, { project: "./tsconfig.test.json" })
+ .bundle();
+ }))
+ .pipe(buffer())
+ .pipe(sourcemaps.init({loadMaps: true}) )
+ .pipe(gulp.dest('built/tests'));
+});
+
+gulp.task('run-test', function() {
+ gulp.src(['./built/tests/**/*.ts']).pipe(mocha());
+});
+```
+
+We will start implementing the first part of our game and get these `Weapon` tests to pass.
+The `Weapon` class will expose a method to trigger the generation of a bullet at a given
+direction and speed. Later we will implement a `Player` class that ties together the user input
+to trigger the weapon. In the `src/lib` folder create a `weapon.ts` file. We'll add two classes
+to it: `Weapon` and `BulletFactory` which will encapsulate Phaser's **sprite** and
+**group** objects, and the logic specific to our game.
+
+```ts
+export class Weapon {
+ private isTriggered: boolean = false;
+ private currentTimer: number = 0;
+
+ constructor(private bulletFactory: BulletFactory, private parent: Phaser.Sprite, private cooldown: number, private bulletSpeed: number) {
+ }
+
+ public trigger(on: boolean): void {
+ this.isTriggered = on;
+ }
+
+ public update(delta: number): void {
+ this.currentTimer -= delta;
+
+ if (this.isTriggered && this.currentTimer <= 0) {
+ this.shoot();
+ }
+ }
+
+ private shoot(): void {
+ // Reset timer
+ this.currentTimer = this.cooldown;
+
+ // Get velocity direction from player rotation
+ var parentRotation = this.parent.rotation + Math.PI / 2;
+ var velx = Math.cos(parentRotation);
+ var vely = Math.sin(parentRotation);
+
+ // Apply a small forward offset so bullet shoots from head of ship instead of the middle
+ var posx = this.parent.x - velx * 10
+ var posy = this.parent.y - vely * 10;
+
+ this.bulletFactory.generate(posx, posy, -velx * this.bulletSpeed, -vely * this.bulletSpeed, this.parent.rotation);
+ }
+}
+
+export class BulletFactory {
+
+ constructor(private bullets: Phaser.Group, private poolSize: number) {
+ // Set all the defaults for this BulletFactory's bullet object
+ this.bullets.enableBody = true;
+ this.bullets.physicsBodyType = Phaser.Physics.ARCADE;
+ this.bullets.createMultiple(30, 'bullet');
+ this.bullets.setAll('anchor.x', 0.5);
+ this.bullets.setAll('anchor.y', 0.5);
+ this.bullets.setAll('outOfBoundsKill', true);
+ this.bullets.setAll('checkWorldBounds', true);
+ }
+
+ public generate(posx: number, posy: number, velx: number, vely: number, rot: number): Phaser.Sprite {
+ // Pull a bullet from Phaser's Group pool
+ var bullet = this.bullets.getFirstExists(false);
+
+ // Set the few unique properties about this bullet: rotation, position, and velocity
+ if (bullet) {
+ bullet.reset(posx, posy);
+ bullet.rotation = rot;
+ bullet.body.velocity.x = velx;
+ bullet.body.velocity.y = vely;
+ }
+
+ return bullet;
+ }
+}
+```
+
+Lastly, we'll redo our entry point, `game.ts`, to tie together both `Player` and `Weapon` objects
+as well as add them to the update loop. Here is what the updated `game.ts` file looks like:
+
+```ts
+import { Player } from "./player";
+import { Weapon, BulletFactory } from "./weapon";
+
+window.onload = function() {
+ var game = new Phaser.Game(800, 600, Phaser.AUTO, 'gameCanvas', { preload: preload, create: create, update: update });
+ var player: Player;
+ var weapon: Weapon;
+
+ // Import all assets prior to loading the game
+ function preload () {
+ game.load.image('player', 'assets/player.png');
+ game.load.image('bullet', 'assets/bullet.png');
+ }
+
+ // Create all entities in the game, after Phaser loads
+ function create () {
+ // Create and position the player
+ var playerSprite = game.add.sprite(400, 550, 'player');
+ playerSprite.anchor.setTo(0.5);
+ player = new Player(game.input, playerSprite, 150);
+
+ var bulletFactory = new BulletFactory(game.add.group(), 30);
+ weapon = new Weapon(bulletFactory, player.sprite, 0.25, 1000);
+
+ player.loadWeapon(weapon);
+ }
+
+ // This function is called once every tick, default is 60fps
+ function update() {
+ var deltaSeconds = game.time.elapsedMS / 1000; // convert to seconds
+ player.update(deltaSeconds);
+ weapon.update(deltaSeconds);
+ }
+}
+```
+
+Run `gulp serve` and you can run around and shoot. Wonderful! Let's update our CI
+pipeline to include running the tests along with the existing build job.
+
+## Continuous Integration
+
+To ensure our changes don't break the build and all tests still pass, we utilize
+Continuous Integration (CI) to run these checks automatically for every push.
+Read through this article to understand [Continuous Integration, Continuous Delivery, and Continuous Deployment](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/),
+and how these methods are leveraged by GitLab.
+From the [last tutorial](https://ryanhallcs.wordpress.com/2017/03/15/devops-and-game-dev/) we already have a `gitlab-ci.yml` file set up for building our app from
+every push. We need to set up a new CI job for testing, which GitLab CI/CD will run after the build job using our generated artifacts from gulp.
+
+Please read through the [documentation on CI/CD configuration](../../../ci/yaml/README.md) file to explore its contents and adjust it to your needs.
+
+### Build your game with GitLab CI/CD
+
+We need to update our build job to ensure tests get run as well. Add `gulp build-test`
+to the end of the `script` array for the existing `build` job. Once these commands run,
+we know we will need to access everything in the `built` folder, given by GitLab CI/CD's `artifacts`.
+We'll also cache `node_modules` to avoid having to do a full re-pull of those dependencies:
+just pack them up in the cache. Here is the full `build` job:
+
+```yml
+build:
+ stage: build
+ script:
+ - npm i gulp -g
+ - npm i
+ - gulp
+ - gulp build-test
+ cache:
+ policy: push
+ paths:
+ - node_modules
+ artifacts:
+ paths:
+ - built
+```
+
+### Test your game with GitLab CI/CD
+
+For testing locally, we simply run `gulp run-tests`, which requires gulp to be installed
+globally like in the `build` job. We pull `node_modules` from the cache, so the `npm i`
+command won't have to do much. In preparation for deployment, we know we will still need
+the `built` folder in the artifacts, which will be brought over as default behavior from
+the previous job. Lastly, by convention, we let GitLab CI/CD know this needs to be run after
+the `build` job by giving it a `test` [stage](../../../ci/yaml/README.md#stages).
+Following the YAML structure, the `test` job should look like this:
+
+```yml
+test:
+ stage: test
+ script:
+ - npm i gulp -g
+ - npm i
+ - gulp run-test
+ cache:
+ policy: push
+ paths:
+ - node_modules/
+ artifacts:
+ paths:
+ - built/
+```
+
+We have added unit tests for a `Weapon` class that shoots on a specified interval.
+The `Player` class implements `Weapon` along with the ability to move around and shoot. Also,
+we've added test artifacts and a test stage to our GitLab CI/CD pipeline using `.gitlab-ci.yml`,
+allowing us to run our tests by every push.
+Our entire `.gitlab-ci.yml` file should now look like this:
+
+```yml
+image: node:6
+
+build:
+ stage: build
+ script:
+ - npm i gulp -g
+ - npm i
+ - gulp
+ - gulp build-test
+ cache:
+ policy: push
+ paths:
+ - node_modules/
+ artifacts:
+ paths:
+ - built/
+
+test:
+ stage: test
+ script:
+ - npm i gulp -g
+ - npm i
+ - gulp run-test
+ cache:
+ policy: pull
+ paths:
+ - node_modules/
+ artifacts:
+ paths:
+ - built/
+```
+
+### Run your CI/CD pipeline
+
+That's it! Add all your new files, commit, and push. For a reference of what our repo should
+look like at this point, please refer to the [final commit related to this article on my sample repository](https://gitlab.com/blitzgren/gitlab-game-demo/commit/8b36ef0ecebcf569aeb251be4ee13743337fcfe2).
+By applying both build and test stages, GitLab will run them sequentially at every push to
+our repository. If all goes well you'll end up with a green check mark on each job for the pipeline:
+
+![Passing Pipeline](img/test_pipeline_pass.png)
+
+You can confirm that the tests passed by clicking on the `test` job to enter the full build logs.
+Scroll to the bottom and observe, in all its passing glory:
+
+```sh
+$ gulp run-test
+[18:37:24] Using gulpfile /builds/blitzgren/gitlab-game-demo/gulpfile.js
+[18:37:24] Starting 'run-test'...
+[18:37:24] Finished 'run-test' after 21 ms
+
+
+ Weapon
+ ✓ should shoot if not in cooldown
+ ✓ should not shoot during cooldown
+ ✓ should shoot after cooldown ends
+ ✓ should not shoot if not triggered
+
+
+ 4 passing (18ms)
+
+Uploading artifacts...
+built/: found 17 matching files
+Uploading artifacts to coordinator... ok id=17095874 responseStatus=201 Created token=aaaaaaaa Job succeeded
+```
+
+## Continuous Deployment
+
+We have our codebase built and tested on every push. To complete the full pipeline with Continuous Deployment,
+let's set up [free web hosting with AWS S3](https://aws.amazon.com/s/dm/optimization/server-side-test/free-tier/free_np/) and a job through which our build artifacts get
+deployed. GitLab also has a free static site hosting service we could use, [GitLab Pages](https://about.gitlab.com/features/pages/),
+however Dark Nova specifically uses other AWS tools that necessitates using `AWS S3`.
+Read through this article that describes [deploying to both S3 and GitLab Pages](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/)
+and further delves into the principles of GitLab CI/CD than discussed in this article.
+
+### Set up S3 Bucket
+
+1. Log into your AWS account and go to [S3](https://console.aws.amazon.com/s3/home)
+1. Click the **Create Bucket** link at the top
+1. Enter a name of your choosing and click next
+1. Keep the default **Properties** and click next
+1. Click the **Manage group permissions** and allow **Read** for the **Everyone** group, click next
+1. Create the bucket, and select it in your S3 bucket list
+1. On the right side, click **Properties** and enable the **Static website hosting** category
+1. Update the radio button to the **Use this bucket to host a website** selection. Fill in `index.html` and `error.html` respectively
+
+### Set up AWS Secrets
+
+We need to be able to deploy to AWS with our AWS account credentials, but we certainly
+don't want to put secrets into source code. Luckily GitLab provides a solution for this
+with [Secret Variables](../../../ci/variables/README.md). This can get complicated
+due to [IAM](https://aws.amazon.com/iam/) management. As a best practice, you shouldn't
+use root security credentials. Proper IAM credential management is beyond the scope of this
+article, but AWS will remind you that using root credentials is unadvised and against their
+best practices, as they should. Feel free to follow best practices and use a custom IAM user's
+credentials, which will be the same two credentials (Key ID and Secret). It's a good idea to
+fully understand [IAM Best Practices in AWS](http://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html). We need to add these credentials to GitLab:
+
+1. Log into your AWS account and go to the [Security Credentials page](https://console.aws.amazon.com/iam/home#/security_credential)
+1. Click the **Access Keys** section and **Create New Access Key**. Create the key and keep the id and secret around, you'll need them later
+ ![AWS Access Key Config](img/aws_config_window.png)
+1. Go to your GitLab project, click **Settings > CI/CD** on the left sidebar
+1. Expand the **Secret Variables** section
+ ![GitLab Secret Config](img/gitlab_config.png)
+1. Add a key named `AWS_KEY_ID` and copy the key id from Step 2 into the **Value** textbox
+1. Add a key named `AWS_KEY_SECRET` and copy the key secret from Step 2 into the **Value** textbox
+
+### Deploy your game with GitLab CI/CD
+
+To deploy our build artifacts, we need to install the [AWS CLI](https://aws.amazon.com/cli/) on
+the Shared Runner. The Shared Runner also needs to be able to authenticate with your AWS
+account to deploy the artifacts. By convention, AWS CLI will look for `AWS_ACCESS_KEY_ID`
+and `AWS_SECRET_ACCESS_KEY`. GitLab's CI gives us a way to pass the secret variables we
+set up in the prior section using the `variables` portion of the `deploy` job. At the end,
+we add directives to ensure deployment `only` happens on pushes to `master`. This way, every
+single branch still runs through CI, and only merging (or committing directly) to master will
+trigger the `deploy` job of our pipeline. Put these together to get the following:
+
+```yml
+deploy:
+ stage: deploy
+ variables:
+ AWS_ACCESS_KEY_ID: "$AWS_KEY_ID"
+ AWS_SECRET_ACCESS_KEY: "$AWS_KEY_SECRET"
+ script:
+ - apt-get update
+ - apt-get install -y python3-dev python3-pip
+ - easy_install3 -U pip
+ - pip3 install --upgrade awscli
+ - aws s3 sync ./built s3://gitlab-game-demo --region "us-east-1" --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers --cache-control "no-cache, no-store, must-revalidate" --delete
+ only:
+ - master
+```
+
+Be sure to update the region and S3 URL in that last script command to fit your setup.
+Our final configuration file `.gitlab-ci.yml` looks like:
+
+```yml
+image: node:6
+
+build:
+ stage: build
+ script:
+ - npm i gulp -g
+ - npm i
+ - gulp
+ - gulp build-test
+ cache:
+ policy: push
+ paths:
+ - node_modules/
+ artifacts:
+ paths:
+ - built/
+
+test:
+ stage: test
+ script:
+ - npm i gulp -g
+ - gulp run-test
+ cache:
+ policy: pull
+ paths:
+ - node_modules/
+ artifacts:
+ paths:
+ - built/
+
+deploy:
+ stage: deploy
+ variables:
+ AWS_ACCESS_KEY_ID: "$AWS_KEY_ID"
+ AWS_SECRET_ACCESS_KEY: "$AWS_KEY_SECRET"
+ script:
+ - apt-get update
+ - apt-get install -y python3-dev python3-pip
+ - easy_install3 -U pip
+ - pip3 install --upgrade awscli
+ - aws s3 sync ./built s3://gitlab-game-demo --region "us-east-1" --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers --cache-control "no-cache, no-store, must-revalidate" --delete
+ only:
+ - master
+```
+
+## Conclusion
+
+Within the [demo repository](https://gitlab.com/blitzgren/gitlab-game-demo) you can also find a handful of boilerplate code to get
+[Typescript](https://www.typescriptlang.org/), [Mocha](https://mochajs.org/), [Gulp](http://gulpjs.com/) and [Phaser](https://phaser.io) all playing
+together nicely with GitLab CI/CD, which is the result of lessons learned while making [Dark Nova](http://darknova.io/).
+Using a combination of free and open source software, we have a full CI/CD pipeline, a game foundation,
+and unit tests, all running and deployed at every push to master - with shockingly little code.
+Errors can be easily debugged through GitLab's build logs, and within minutes of a successful commit,
+you can see the changes live on your game.
+
+Setting up Continous Integration and Continuous Deployment from the start with Dark Nova enables
+rapid but stable development. We can easily test changes in a separate [environment](../../../ci/environments.md#introduction-to-environments-and-deployments),
+or multiple environments if needed. Balancing and updating a multiplayer game can be ongoing
+and tedious, but having faith in a stable deployment with GitLab CI/CD allows
+a lot of breathing room in quickly getting changes to players.
+
+## Further settings
+
+Here are some ideas to further investigate that can speed up or improve your pipeline:
+
+- [Yarn](https://yarnpkg.com) instead of npm
+- Setup a custom [Docker](../../../ci/docker/using_docker_images.md#define-image-and-services-from-gitlab-ci-yml) image that can preload dependencies and tools (like AWS CLI)
+- Forward a [custom domain](http://docs.aws.amazon.com/AmazonS3/latest/dev/website-hosting-custom-domain-walkthrough.html) to your game's S3 static website
+- Combine jobs if you find it unnecessary for a small project
+- Avoid the queues and set up your own [custom GitLab CI/CD runner](https://about.gitlab.com/2016/03/01/gitlab-runner-with-docker/)
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png
index a56c07a0da7..a56c07a0da7 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png
index b1406fed6b8..b1406fed6b8 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg
index d1f0cbc08ab..d1f0cbc08ab 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png
index 9aae11b8679..9aae11b8679 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/environment_page.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/environment_page.png
index a06b6d417cd..a06b6d417cd 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/environment_page.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/environment_page.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/environments_page.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/environments_page.png
index d357ecda7d2..d357ecda7d2 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/environments_page.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/environments_page.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png
index 3bb21fd12b4..3bb21fd12b4 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png
index bc188f83fb1..bc188f83fb1 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/pipeline_page.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipeline_page.png
index baf8dec499c..baf8dec499c 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/pipeline_page.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipeline_page.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipelines_page.png
index d96c43bcf16..d96c43bcf16 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipelines_page.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png
index 997db10189f..997db10189f 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png
index 6dbc29fc25c..6dbc29fc25c 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png
index 8a6dcccfa38..8a6dcccfa38 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png
Binary files differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/secret_variables_page.png b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/secret_variables_page.png
index 658c0b5bcac..658c0b5bcac 100644
--- a/doc/articles/laravel_with_gitlab_and_envoy/img/secret_variables_page.png
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/img/secret_variables_page.png
Binary files differ
diff --git a/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md
new file mode 100644
index 00000000000..1f9b9d53fc1
--- /dev/null
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md
@@ -0,0 +1,684 @@
+---
+redirect_from: 'https://docs.gitlab.com/ee/articles/laravel_with_gitlab_and_envoy/index.html'
+author: Mehran Rasulian
+author_gitlab: mehranrasulian
+level: intermediary
+article_type: tutorial
+date: 2017-08-31
+---
+
+# Test and deploy Laravel applications with GitLab CI/CD and Envoy
+
+## Introduction
+
+GitLab features our applications with Continuous Integration, and it is possible to easily deploy the new code changes to the production server whenever we want.
+
+In this tutorial, we'll show you how to initialize a [Laravel](http://laravel.com/) application and setup our [Envoy](https://laravel.com/docs/envoy) tasks, then we'll jump into see how to test and deploy it with [GitLab CI/CD](../README.md) via [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/).
+
+We assume you have a basic experience with Laravel, Linux servers,
+and you know how to use GitLab.
+
+Laravel is a high quality web framework written in PHP.
+It has a great community with a [fantastic documentation](https://laravel.com/docs).
+Aside from the usual routing, controllers, requests, responses, views, and (blade) templates, out of the box Laravel provides plenty of additional services such as cache, events, localization, authentication and many others.
+
+We will use [Envoy](https://laravel.com/docs/master/envoy) as an SSH task runner based on PHP.
+It uses a clean, minimal [Blade syntax](https://laravel.com/docs/blade) to setup tasks that can run on remote servers, such as, cloning your project from the repository, installing the Composer dependencies, and running [Artisan commands](https://laravel.com/docs/artisan).
+
+## Initialize our Laravel app on GitLab
+
+We assume [you have installed a new laravel project](https://laravel.com/docs/installation#installation), so let's start with a unit test, and initialize Git for the project.
+
+### Unit Test
+
+Every new installation of Laravel (currently 5.4) comes with two type of tests, 'Feature' and 'Unit', placed in the tests directory.
+Here's a unit test from `test/Unit/ExampleTest.php`:
+
+```php
+<?php
+
+namespace Tests\Unit;
+
+...
+
+class ExampleTest extends TestCase
+{
+ public function testBasicTest()
+ {
+ $this->assertTrue(true);
+ }
+}
+```
+
+This test is as simple as asserting that the given value is true.
+
+Laravel uses `PHPUnit` for tests by default.
+If we run `vendor/bin/phpunit` we should see the green output:
+
+```bash
+vendor/bin/phpunit
+OK (1 test, 1 assertions)
+```
+
+This test will be used later for continuously testing our app with GitLab CI/CD.
+
+### Push to GitLab
+
+Since we have our app up and running locally, it's time to push the codebase to our remote repository.
+Let's create [a new project](../../../gitlab-basics/create-project.md) in GitLab named `laravel-sample`.
+After that, follow the command line instructions displayed on the project's homepage to initiate the repository on our machine and push the first commit.
+
+
+```bash
+cd laravel-sample
+git init
+git remote add origin git@gitlab.example.com:<USERNAME>/laravel-sample.git
+git add .
+git commit -m 'Initial Commit'
+git push -u origin master
+```
+
+## Configure the production server
+
+Before we begin setting up Envoy and GitLab CI/CD, let's quickly make sure the production server is ready for deployment.
+We have installed LEMP stack which stands for Linux, Nginx, MySQL and PHP on our Ubuntu 16.04.
+
+### Create a new user
+
+Let's now create a new user that will be used to deploy our website and give it
+the needed permissions using [Linux ACL](https://serversforhackers.com/video/linux-acls):
+
+```bash
+# Create user deployer
+sudo adduser deployer
+# Give the read-write-execute permissions to deployer user for directory /var/www
+sudo setfacl -R -m u:deployer:rwx /var/www
+```
+
+If you don't have ACL installed on your Ubuntu server, use this command to install it:
+
+```bash
+sudo apt install acl
+```
+
+### Add SSH key
+
+Let's suppose we want to deploy our app to the production server from a private repository on GitLab. First, we need to [generate a new SSH key pair **with no passphrase**](../../../ssh/README.md) for the deployer user.
+
+After that, we need to copy the private key, which will be used to connect to our server as the deployer user with SSH, to be able to automate our deployment process:
+
+```bash
+# As the deployer user on server
+#
+# Copy the content of public key to authorized_keys
+cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
+# Copy the private key text block
+cat ~/.ssh/id_rsa
+```
+
+Now, let's add it to your GitLab project as a [secret variable](../../variables/README.md#secret-variables).
+Secret variables are user-defined variables and are stored out of `.gitlab-ci.yml`, for security purposes.
+They can be added per project by navigating to the project's **Settings** > **CI/CD**.
+
+![secret variables page](img/secret_variables_page.png)
+
+To the field **KEY**, add the name `SSH_PRIVATE_KEY`, and to the **VALUE** field, paste the private key you've copied earlier.
+We'll use this variable in the `.gitlab-ci.yml` later, to easily connect to our remote server as the deployer user without entering its password.
+
+We also need to add the public key to **Project** > **Settings** > **Repository** as [Deploy Keys](../../../ssh/README.md/#deploy-keys), which gives us the ability to access our repository from the server through [SSH protocol](../../../gitlab-basics/command-line-commands.md/#start-working-on-your-project).
+
+
+```bash
+# As the deployer user on the server
+#
+# Copy the public key
+cat ~/.ssh/id_rsa.pub
+```
+
+![deploy keys page](img/deploy_keys_page.png)
+
+To the field **Title**, add any name you want, and paste the public key into the **Key** field.
+
+Now, let's clone our repository on the server just to make sure the `deployer` user has access to the repository.
+
+```bash
+# As the deployer user on server
+#
+git clone git@gitlab.example.com:<USERNAME>/laravel-sample.git
+```
+
+>**Note:**
+Answer **yes** if asked `Are you sure you want to continue connecting (yes/no)?`.
+It adds GitLab.com to the known hosts.
+
+### Configuring Nginx
+
+Now, let's make sure our web server configuration points to the `current/public` rather than `public`.
+
+Open the default Nginx server block configuration file by typing:
+
+```bash
+sudo nano /etc/nginx/sites-available/default
+```
+
+The configuration should be like this.
+
+```
+server {
+ root /var/www/app/current/public;
+ server_name example.com;
+ # Rest of the configuration
+}
+```
+
+>**Note:**
+You may replace the app's name in `/var/www/app/current/public` with the folder name of your application.
+
+## Setting up Envoy
+
+So we have our Laravel app ready for production.
+The next thing is to use Envoy to perform the deploy.
+
+To use Envoy, we should first install it on our local machine [using the given instructions by Laravel](https://laravel.com/docs/envoy/#introduction).
+
+### How Envoy works
+
+The pros of Envoy is that it doesn't require Blade engine, it just uses Blade syntax to define tasks.
+To start, we create an `Envoy.blade.php` in the root of our app with a simple task to test Envoy.
+
+
+```php
+@servers(['web' => 'remote_username@remote_host'])
+
+@task('list', ['on' => 'web'])
+ ls -l
+@endtask
+```
+
+As you may expect, we have an array within `@servers` directive at the top of the file, which contains a key named `web` with a value of the server's address (e.g. `deployer@192.168.1.1`).
+Then within our `@task` directive we define the bash commands that should be run on the server when the task is executed.
+
+On the local machine use the `run` command to run Envoy tasks.
+
+```bash
+envoy run list
+```
+
+It should execute the `list` task we defined earlier, which connects to the server and lists directory contents.
+
+Envoy is not a dependency of Laravel, therefore you can use it for any PHP application.
+
+### Zero downtime deployment
+
+Every time we deploy to the production server, Envoy downloads the latest release of our app from GitLab repository and replace it with preview's release.
+Envoy does this without any [downtime](https://en.wikipedia.org/wiki/Downtime),
+so we don't have to worry during the deployment while someone might be reviewing the site.
+Our deployment plan is to clone the latest release from GitLab repository, install the Composer dependencies and finally, activate the new release.
+
+#### @setup directive
+
+The first step of our deployment process is to define a set of variables within [@setup](https://laravel.com/docs/envoy/#setup) directive.
+You may change the `app` to your application's name:
+
+
+```php
+...
+
+@setup
+ $repository = 'git@gitlab.example.com:<USERNAME>/laravel-sample.git';
+ $releases_dir = '/var/www/app/releases';
+ $app_dir = '/var/www/app';
+ $release = date('YmdHis');
+ $new_release_dir = $releases_dir .'/'. $release;
+@endsetup
+
+...
+```
+
+- `$repository` is the address of our repository
+- `$releases_dir` directory is where we deploy the app
+- `$app_dir` is the actual location of the app that is live on the server
+- `$release` contains a date, so every time that we deploy a new release of our app, we get a new folder with the current date as name
+- `$new_release_dir` is the full path of the new release which is used just to make the tasks cleaner
+
+#### @story directive
+
+The [@story](https://laravel.com/docs/envoy/#stories) directive allows us define a list of tasks that can be run as a single task.
+Here we have three tasks called `clone_repository`, `run_composer`, `update_symlinks`. These variables are usable to making our task's codes more cleaner:
+
+
+```php
+...
+
+@story('deploy')
+ clone_repository
+ run_composer
+ update_symlinks
+@endstory
+
+...
+```
+
+Let's create these three tasks one by one.
+
+#### Clone the repository
+
+The first task will create the `releases` directory (if it doesn't exist), and then clone the `master` branch of the repository (by default) into the new release directory, given by the `$new_release_dir` variable.
+The `releases` directory will hold all our deployments:
+
+```php
+...
+
+@task('clone_repository')
+ echo 'Cloning repository'
+ [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }}
+ git clone --depth 1 {{ $repository }} {{ $new_release_dir }}
+@endtask
+
+...
+```
+
+While our project grows, its Git history will be very very long over time.
+Since we are creating a directory per release, it might not be necessary to have the history of the project downloaded for each release.
+The `--depth 1` option is a great solution which saves systems time and disk space as well.
+
+#### Installing dependencies with Composer
+
+As you may know, this task just navigates to the new release directory and runs Composer to install the application dependencies:
+
+```php
+...
+
+@task('run_composer')
+ echo "Starting deployment ({{ $release }})"
+ cd {{ $new_release_dir }}
+ composer install --prefer-dist --no-scripts -q -o
+@endtask
+
+...
+```
+
+#### Activate new release
+
+Next thing to do after preparing the requirements of our new release, is to remove the storage directory from it and to create two symbolic links to point the application's `storage` directory and `.env` file to the new release.
+Then, we need to create another symbolic link to the new release with the name of `current` placed in the app directory.
+The `current` symbolic link always points to the latest release of our app:
+
+```php
+...
+
+@task('update_symlinks')
+ echo "Linking storage directory"
+ rm -rf {{ $new_release_dir }}/storage
+ ln -nfs {{ $app_dir }}/storage {{ $new_release_dir }}/storage
+
+ echo 'Linking .env file'
+ ln -nfs {{ $app_dir }}/.env {{ $new_release_dir }}/.env
+
+ echo 'Linking current release'
+ ln -nfs {{ $new_release_dir }} {{ $app_dir }}/current
+@endtask
+```
+
+As you see, we use `-nfs` as an option for `ln` command, which says that the `storage`, `.env` and `current` no longer points to the preview's release and will point them to the new release by force (`f` from `-nfs` means force), which is the case when we are doing multiple deployments.
+
+### Full script
+
+The script is ready, but make sure to change the `deployer@192.168.1.1` to your server and also change `/var/www/app` with the directory you want to deploy your app.
+
+At the end, our `Envoy.blade.php` file will look like this:
+
+```php
+@servers(['web' => 'deployer@192.168.1.1'])
+
+@setup
+ $repository = 'git@gitlab.example.com:<USERNAME>/laravel-sample.git';
+ $releases_dir = '/var/www/app/releases';
+ $app_dir = '/var/www/app';
+ $release = date('YmdHis');
+ $new_release_dir = $releases_dir .'/'. $release;
+@endsetup
+
+@story('deploy')
+ clone_repository
+ run_composer
+ update_symlinks
+@endstory
+
+@task('clone_repository')
+ echo 'Cloning repository'
+ [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }}
+ git clone --depth 1 {{ $repository }} {{ $new_release_dir }}
+@endtask
+
+@task('run_composer')
+ echo "Starting deployment ({{ $release }})"
+ cd {{ $new_release_dir }}
+ composer install --prefer-dist --no-scripts -q -o
+@endtask
+
+@task('update_symlinks')
+ echo "Linking storage directory"
+ rm -rf {{ $new_release_dir }}/storage
+ ln -nfs {{ $app_dir }}/storage {{ $new_release_dir }}/storage
+
+ echo 'Linking .env file'
+ ln -nfs {{ $app_dir }}/.env {{ $new_release_dir }}/.env
+
+ echo 'Linking current release'
+ ln -nfs {{ $new_release_dir }} {{ $app_dir }}/current
+@endtask
+```
+
+One more thing we should do before any deployment is to manually copy our application `storage` folder to the `/var/www/app` directory on the server for the first time.
+You might want to create another Envoy task to do that for you.
+We also create the `.env` file in the same path to setup our production environment variables for Laravel.
+These are persistent data and will be shared to every new release.
+
+Now, we would need to deploy our app by running `envoy run deploy`, but it won't be necessary since GitLab can handle that for us with CI's [environments](../../environments.md), which will be described [later](#setting-up-gitlab-ci-cd) in this tutorial.
+
+Now it's time to commit [Envoy.blade.php](https://gitlab.com/mehranrasulian/laravel-sample/blob/master/Envoy.blade.php) and push it to the `master` branch.
+To keep things simple, we commit directly to `master`, without using [feature-branches](../../../workflow/gitlab_flow.md/#github-flow-as-a-simpler-alternative) since collaboration is beyond the scope of this tutorial.
+In a real world project, teams may use [Issue Tracker](../../../user/project/issues/index.md) and [Merge Requests](../../../user/project/merge_requests/index.md) to move their code across branches:
+
+```bash
+git add Envoy.blade.php
+git commit -m 'Add Envoy'
+git push origin master
+```
+
+## Continuous Integration with GitLab
+
+We have our app ready on GitLab, and we also can deploy it manually.
+But let's take a step forward to do it automatically with [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-delivery) method.
+We need to check every commit with a set of automated tests to become aware of issues at the earliest, and then, we can deploy to the target environment if we are happy with the result of the tests.
+
+[GitLab CI/CD](../../README.md) allows us to use [Docker](https://docker.com/) engine to handle the process of testing and deploying our app.
+In the case you're not familiar with Docker, refer to [How to Automate Docker Deployments](http://paislee.io/how-to-automate-docker-deployments/).
+
+To be able to build, test, and deploy our app with GitLab CI/CD, we need to prepare our work environment.
+To do that, we'll use a Docker image which has the minimum requirements that a Laravel app needs to run.
+[There are other ways](../php.md/#test-php-projects-using-the-docker-executor) to do that as well, but they may lead our builds run slowly, which is not what we want when there are faster options to use.
+
+With Docker images our builds run incredibly faster!
+
+### Create a Container Image
+
+Let's create a [Dockerfile](https://gitlab.com/mehranrasulian/laravel-sample/blob/master/Dockerfile) in the root directory of our app with the following content:
+
+```bash
+# Set the base image for subsequent instructions
+FROM php:7.1
+
+# Update packages
+RUN apt-get update
+
+# Install PHP and composer dependencies
+RUN apt-get install -qq git curl libmcrypt-dev libjpeg-dev libpng-dev libfreetype6-dev libbz2-dev
+
+# Clear out the local repository of retrieved package files
+RUN apt-get clean
+
+# Install needed extensions
+# Here you can install any other extension that you need during the test and deployment process
+RUN docker-php-ext-install mcrypt pdo_mysql zip
+
+# Install Composer
+RUN curl --silent --show-error https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+
+# Install Laravel Envoy
+RUN composer global require "laravel/envoy=~1.0"
+```
+
+We added the [official PHP 7.1 Docker image](https://hub.docker.com/r/_/php/), which consist of a minimum installation of Debian Jessie with PHP pre-installed, and works perfectly for our use case.
+
+We used `docker-php-ext-install` (provided by the official PHP Docker image) to install the PHP extensions we need.
+
+#### Setting Up GitLab Container Registry
+
+Now that we have our `Dockerfile` let's build and push it to our [GitLab Container Registry](../../../user/project/container_registry.md).
+
+> The registry is the place to store and tag images for later use. Developers may want to maintain their own registry for private, company images, or for throw-away images used only in testing. Using GitLab Container Registry means you don't need to set up and administer yet another service or use a public registry.
+
+On your GitLab project repository navigate to the **Registry** tab.
+
+![container registry page empty image](img/container_registry_page_empty_image.png)
+
+You may need to [enable Container Registry](../../../user/project/container_registry.md#enable-the-container-registry-for-your-project) to your project to see this tab. You'll find it under your project's **Settings > General > Sharing and permissions**.
+
+![container registry checkbox](img/container_registry_checkbox.png)
+
+To start using Container Registry on our machine, we first need to login to the GitLab registry using our GitLab username and password:
+
+```bash
+docker login registry.gitlab.com
+```
+Then we can build and push our image to GitLab:
+
+```bash
+docker build -t registry.gitlab.com/<USERNAME>/laravel-sample .
+
+docker push registry.gitlab.com/<USERNAME>/laravel-sample
+```
+
+>**Note:**
+To run the above commands, we first need to have [Docker](https://docs.docker.com/engine/installation/) installed on our machine.
+
+Congratulations! You just pushed the first Docker image to the GitLab Registry, and if you refresh the page you should be able to see it:
+
+![container registry page with image](img/container_registry_page_with_image.jpg)
+
+>**Note:**
+You can also [use GitLab CI/CD](https://about.gitlab.com/2016/05/23/gitlab-container-registry/#use-with-gitlab-ci) to build and push your Docker images, rather than doing that on your machine.
+
+We'll use this image further down in the `.gitlab-ci.yml` configuration file to handle the process of testing and deploying our app.
+
+Let's commit the `Dockerfile` file.
+
+```bash
+git add Dockerfile
+git commit -m 'Add Dockerfile'
+git push origin master
+```
+
+### Setting up GitLab CI/CD
+
+In order to build and test our app with GitLab CI/CD, we need a file called `.gitlab-ci.yml` in our repository's root. It is similar to Circle CI and Travis CI, but built-in GitLab.
+
+Our `.gitlab-ci.yml` file will look like this:
+
+```yaml
+image: registry.gitlab.com/<USERNAME>/laravel-sample:latest
+
+services:
+ - mysql:5.7
+
+variables:
+ MYSQL_DATABASE: homestead
+ MYSQL_ROOT_PASSWORD: secret
+ DB_HOST: mysql
+ DB_USERNAME: root
+
+stages:
+ - test
+ - deploy
+
+unit_test:
+ stage: test
+ script:
+ - cp .env.example .env
+ - composer install
+ - php artisan key:generate
+ - php artisan migrate
+ - vendor/bin/phpunit
+
+deploy_production:
+ stage: deploy
+ script:
+ - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
+ - eval $(ssh-agent -s)
+ - ssh-add <(echo "$SSH_PRIVATE_KEY")
+ - mkdir -p ~/.ssh
+ - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
+
+ - ~/.composer/vendor/bin/envoy run deploy
+ environment:
+ name: production
+ url: http://192.168.1.1
+ when: manual
+ only:
+ - master
+```
+
+That's a lot to take in, isn't it? Let's run through it step by step.
+
+#### Image and Services
+
+[GitLab Runners](../../runners/README.md) run the script defined by `.gitlab-ci.yml`.
+The `image` keyword tells the Runners which image to use.
+The `services` keyword defines additional images [that are linked to the main image](../../docker/using_docker_images.md/#what-is-a-service).
+Here we use the container image we created before as our main image and also use MySQL 5.7 as a service.
+
+```yaml
+image: registry.gitlab.com/<USERNAME>/laravel-sample:latest
+
+services:
+ - mysql:5.7
+
+...
+```
+
+>**Note:**
+If you wish to test your app with different PHP versions and [database management systems](../../services/README.md), you can define different `image` and `services` keywords for each test job.
+
+#### Variables
+
+GitLab CI/CD allows us to use [environment variables](../../yaml/README.md#variables) in our jobs.
+We defined MySQL as our database management system, which comes with a superuser root created by default.
+
+So we should adjust the configuration of MySQL instance by defining `MYSQL_DATABASE` variable as our database name and `MYSQL_ROOT_PASSWORD` variable as the password of `root`.
+Find out more about MySQL variables at the [official MySQL Docker Image](https://hub.docker.com/r/_/mysql/).
+
+Also set the variables `DB_HOST` to `mysql` and `DB_USERNAME` to `root`, which are Laravel specific variables.
+We define `DB_HOST` as `mysql` instead of `127.0.0.1`, as we use MySQL Docker image as a service which [is linked to the main Docker image](../../docker/using_docker_images.md/#how-services-are-linked-to-the-build).
+
+```yaml
+...
+
+variables:
+ MYSQL_DATABASE: homestead
+ MYSQL_ROOT_PASSWORD: secret
+ DB_HOST: mysql
+ DB_USERNAME: root
+
+...
+```
+
+#### Unit Test as the first job
+
+We defined the required shell scripts as an array of the [script](../../yaml/README.md#script) variable to be executed when running `unit_test` job.
+
+These scripts are some Artisan commands to prepare the Laravel, and, at the end of the script, we'll run the tests by `PHPUnit`.
+
+```yaml
+...
+
+unit_test:
+ script:
+ # Install app dependencies
+ - composer install
+ # Setup .env
+ - cp .env.example .env
+ # Generate an environment key
+ - php artisan key:generate
+ # Run migrations
+ - php artisan migrate
+ # Run tests
+ - vendor/bin/phpunit
+
+...
+```
+
+#### Deploy to production
+
+The job `deploy_production` will deploy the app to the production server.
+To deploy our app with Envoy, we had to set up the `$SSH_PRIVATE_KEY` variable as an [SSH private key](../../ssh_keys/README.md/#ssh-keys-when-using-the-docker-executor).
+If the SSH keys have added successfully, we can run Envoy.
+
+As mentioned before, GitLab supports [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-delivery) methods as well.
+The [environment](../../yaml/README.md#environment) keyword tells GitLab that this job deploys to the `production` environment.
+The `url` keyword is used to generate a link to our application on the GitLab Environments page.
+The `only` keyword tells GitLab CI that the job should be executed only when the pipeline is building the `master` branch.
+Lastly, `when: manual` is used to turn the job from running automatically to a manual action.
+
+```yaml
+...
+
+deploy_production:
+ script:
+ # Add the private SSH key to the build environment
+ - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
+ - eval $(ssh-agent -s)
+ - ssh-add <(echo "$SSH_PRIVATE_KEY")
+ - mkdir -p ~/.ssh
+ - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
+
+ # Run Envoy
+ - ~/.composer/vendor/bin/envoy run deploy
+
+ environment:
+ name: production
+ url: http://192.168.1.1
+ when: manual
+ only:
+ - master
+```
+
+You may also want to add another job for [staging environment](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments), to final test your application before deploying to production.
+
+### Turn on GitLab CI/CD
+
+We have prepared everything we need to test and deploy our app with GitLab CI/CD.
+To do that, commit and push `.gitlab-ci.yml` to the `master` branch. It will trigger a pipeline, which you can watch live under your project's **Pipelines**.
+
+![pipelines page](img/pipelines_page.png)
+
+Here we see our **Test** and **Deploy** stages.
+The **Test** stage has the `unit_test` build running.
+click on it to see the Runner's output.
+
+![pipeline page](img/pipeline_page.png)
+
+After our code passed through the pipeline successfully, we can deploy to our production server by clicking the **play** button on the right side.
+
+![pipelines page deploy button](img/pipelines_page_deploy_button.png)
+
+Once the deploy pipeline passed successfully, navigate to **Pipelines > Environments**.
+
+![environments page](img/environments_page.png)
+
+If something doesn't work as expected, you can roll back to the latest working version of your app.
+
+![environment page](img/environment_page.png)
+
+By clicking on the external link icon specified on the right side, GitLab opens the production website.
+Our deployment successfully was done and we can see the application is live.
+
+![laravel welcome page](img/laravel_welcome_page.png)
+
+In the case that you're interested to know how is the application directory structure on the production server after deployment, here are three directories named `current`, `releases` and `storage`.
+As you know, the `current` directory is a symbolic link that points to the latest release.
+The `.env` file consists of our Laravel environment variables.
+
+![production server app directory](img/production_server_app_directory.png)
+
+If you navigate to the `current` directory, you should see the application's content.
+As you see, the `.env` is pointing to the `/var/www/app/.env` file and also `storage` is pointing to the `/var/www/app/storage/` directory.
+
+![production server current directory](img/production_server_current_directory.png)
+
+## Conclusion
+
+We configured GitLab CI to perform automated tests and used the method of [Continuous Delivery](https://continuousdelivery.com/) to deploy to production a Laravel application with Envoy, directly from the codebase.
+
+Envoy also was a great match to help us deploy the application without writing our custom bash script and doing Linux magics.
diff --git a/doc/ci/examples/php.md b/doc/ci/examples/php.md
index 6768a2e012f..a2ba29a4ee2 100644
--- a/doc/ci/examples/php.md
+++ b/doc/ci/examples/php.md
@@ -167,7 +167,7 @@ Finally, push to GitLab and let the tests begin!
### Test against different PHP versions in Shell builds
The [phpenv][] project allows you to easily manage different versions of PHP
-each with its own config. This is specially usefull when testing PHP projects
+each with its own config. This is especially useful when testing PHP projects
with the Shell executor.
You will have to install it on your build machine under the `gitlab-runner`
@@ -227,7 +227,7 @@ following in your `.gitlab-ci.yml`:
...
# Composer stores all downloaded packages in the vendor/ directory.
-# Do not use the following if the vendor/ directory is commited to
+# Do not use the following if the vendor/ directory is committed to
# your git repository.
cache:
paths:
diff --git a/doc/ci/examples/sast_docker.md b/doc/ci/examples/sast_docker.md
new file mode 100644
index 00000000000..9f4a63e296d
--- /dev/null
+++ b/doc/ci/examples/sast_docker.md
@@ -0,0 +1 @@
+This document was moved to [another location](./container_scanning.md). \ No newline at end of file
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 a6ed1c54e16..a433cd5a5dd 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
@@ -42,7 +42,7 @@ production:
This project has three jobs:
1. `test` - used to test Django application,
2. `staging` - used to automatically deploy staging environment every push to `master` branch
-3. `production` - used to automatically deploy production environmnet for every created tag
+3. `production` - used to automatically deploy production environment for every created tag
## Store API keys
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 10fd2616fab..7f9ab1f3a5e 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
@@ -10,6 +10,7 @@ This is what the `.gitlab-ci.yml` file looks like for this project:
```yaml
test:
+ stage: test
script:
- apt-get update -qy
- apt-get install -y nodejs
@@ -18,7 +19,7 @@ test:
- bundle exec rake test
staging:
- type: deploy
+ stage: deploy
script:
- gem install dpl
- dpl --provider=heroku --app=gitlab-ci-ruby-test-staging --api-key=$HEROKU_STAGING_API_KEY
@@ -26,7 +27,7 @@ staging:
- master
production:
- type: deploy
+ stage: deploy
script:
- gem install dpl
- dpl --provider=heroku --app=gitlab-ci-ruby-test-prod --api-key=$HEROKU_PRODUCTION_API_KEY
diff --git a/doc/ci/examples/test-phoenix-application.md b/doc/ci/examples/test-phoenix-application.md
index f6c81b076bc..52db5740c34 100644
--- a/doc/ci/examples/test-phoenix-application.md
+++ b/doc/ci/examples/test-phoenix-application.md
@@ -1,56 +1,5 @@
-# Test a Phoenix application with GitLab CI/CD
+---
+redirect_to: '../../ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md'
+---
-This example demonstrates the integration of Gitlab CI with Phoenix, Elixir and
-Postgres.
-
-## Add `.gitlab-ci.yml` to project
-
-The following `.gitlab-ci.yml` should be added in the root of your
-repository to trigger CI:
-
-```yaml
-image: elixir:1.3
-
-services:
- - postgres:9.6
-
-variables:
- MIX_ENV: "test"
-
-before_script:
- # Setup phoenix dependencies
- - apt-get update
- - apt-get install -y postgresql-client
- - mix local.hex --force
- - mix deps.get --only test
- - mix ecto.reset
-
-test:
- script:
- - mix test
-```
-
-The variables will set the Mix environment to "test". The
-`before_script` will install `psql`, some Phoenix dependencies, and will also
-run your migrations.
-
-Finally, the test `script` will run your tests.
-
-## Update the Config Settings
-
-In `config/test.exs`, update the database hostname:
-
-```elixir
-config :my_app, MyApp.Repo,
- hostname: if(System.get_env("CI"), do: "postgres", else: "localhost"),
-```
-
-## Add the Migrations Folder
-
-If you do not have any migrations yet, you will need to create an empty
-`.gitkeep` file in `priv/repo/migrations`.
-
-## Sources
-
-- https://medium.com/@nahtnam/using-phoenix-on-gitlab-ci-5a51eec81142
-- https://davejlong.com/ci-with-phoenix-and-gitlab/
+The content of this page was incorporated in [this document](../../ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md).
diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/job-succeeded.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/job-succeeded.png
new file mode 100644
index 00000000000..0f94ac60fee
--- /dev/null
+++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/job-succeeded.png
Binary files differ
diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-new.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-new.png
new file mode 100644
index 00000000000..94828a20f51
--- /dev/null
+++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-new.png
Binary files differ
diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-server.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-server.png
new file mode 100644
index 00000000000..68503b392ed
--- /dev/null
+++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/mix-phoenix-server.png
Binary files differ
diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/pipelines.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/pipelines.png
new file mode 100644
index 00000000000..d73140ccdd9
--- /dev/null
+++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/pipelines.png
Binary files differ
diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select-template.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select-template.png
new file mode 100644
index 00000000000..38bfde0a3dd
--- /dev/null
+++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select-template.png
Binary files differ
diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/setup-ci.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/setup-ci.png
new file mode 100644
index 00000000000..bfe85c6a10b
--- /dev/null
+++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/setup-ci.png
Binary files differ
diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md
new file mode 100644
index 00000000000..7f6519fd38e
--- /dev/null
+++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md
@@ -0,0 +1,423 @@
+---
+author: Alexandre S Hostert
+author_gitlab: Hostert
+level: beginner
+article_type: tutorial
+date: 2018-02-20
+---
+
+# Testing a Phoenix application with GitLab CI/CD
+
+[Phoenix][phoenix-site] is a web development framework written in [Elixir][elixir-site], which is a
+functional language designed for productivity and maintainability that runs on the
+[Erlang VM][erlang-site]. Erlang VM is really really fast and can handle very large numbers of
+simultaneous users.
+
+That's why we're hearing so much about Phoenix today.
+
+In this tutorial, we'll teach you how to set up GitLab CI/CD to build and test a Phoenix
+application.
+
+_We assume that you know how to create a Phoenix app, run tests locally, and how to work with Git
+and GitLab UI._
+
+## Introduction
+
+### What is Phoenix?
+
+[Phoenix][phoenix-site] is a web development framework written in [Elixir][elixir-site] very useful
+ to build fast, reliable, and high-performance applications, as it uses [Erlang VM][erlang-site].
+
+Many components and concepts are similar to Ruby on Rails or Python's Django. High developer
+productivity and high application performance are only a few advantages on learning how to use it.
+Working on the MVC pattern, it's was designed to be modular and flexible. Easy to mantain a growing
+app is a plus.
+
+Phoenix can run in any OS where Erlang is supported:
+
+- Ubuntu
+- CentOS
+- Mac OS X
+- Debian
+- Windows
+- Fedora
+- Raspbian
+
+Check the [Phoenix learning guide][phoenix-learning-guide] for more information.
+
+### What is Elixir?
+
+[Elixir][elixir-site] is a dynamic, functional language created to use all the maturity of Erlang
+(30 years old!) in these days, in an easy way. It has similarities with Ruby, specially on sintax,
+so Ruby developers are quite excited with the rapid growing of Elixir. A full-stack Ruby developer
+can learn how to use Elixir and Phoenix in just a few weeks!
+
+In Elixir we have a command called `mix`, which is a helper to create projects, testing, run
+migrations and [much more][elixir-mix]. We'll use it later on in this tutorial.
+
+Check the [Elixir documentation][elixir-docs] for more information.
+
+## Requirements
+
+To follow this tutorial, you'll need to have installed:
+
+- Elixir [installation instructions][elixir-install]
+- Phoenix Framework [installation instructions][phoenix-install]
+- PostgreSQL (if you need to use MySQL server, check [Phoenix instructions][phoenix-mysql])
+
+### Create a new Phoenix project
+
+Open your terminal and go to the directory you wish to create your project.
+You don't need to create an empty directory for the project's files, because the `mix` command will
+do it for us.
+
+When we call `mix` command, we'll pass two arguments:
+
+- The task we want it to run: `phoenix.new`
+- And the parameter `phoenix.new` requires, which is the name of the new project. In this case,
+we're calling it `hello_gitlab_ci`, but you're free to set your own name:
+
+```bash
+mix phoenix.new hello_gitlab_ci
+```
+
+When asked, answer `Y` to fetch and install dependencies.
+
+If everything went fine, you'll get an output like this:
+
+![`mix phoenix.new`](img/mix-phoenix-new.png)
+
+Now, our project is located inside the directory with the same name we pass to `mix` command, for
+example, `~/GitLab/hello_gitlab_ci`.
+If we take a look at the directory, we'll see the Phoenix files and the dependencies needed to run.
+
+### Initialize the PostgreSQL database
+
+By default, Phoenix requires a PostgreSQL database to store whatever we need to store in our app. In
+this case, we'll only create an empty database.
+
+First, we need to navigate to our recently created project's directory, and then execute again
+`mix`. This time, `mix` will receive the parameter `ecto.create`, which is the task to create our
+new database. [Ecto][ecto] is the database wrapper for Elixir.
+
+When we do run `mix` the first time after creating our project, it will compile our files to
+bytecode, which will be interpreted by Erlang VM. In the next times, it will only compile our
+changes.
+
+Run the commands below to create our empty database:
+
+```bash
+cd hello_gitlab_ci
+mix ecto.create
+```
+
+We expect to see this output at the end of the command:
+
+```bash
+Generated hello_gitlab_ci app
+The database for HelloGitlabCi.Repo has been created
+```
+
+> **Note:**
+Phoenix assumes that our PostgreSQL database will have a `postgres` user account with the correct
+permissions and a password of `postgres`. If it's not your case, check
+[Ecto's instructions][ecto-repo].
+
+### Start Phoenix server
+
+Now, it's time to see if everything we did until now went well. We'll call `mix` again, this time
+with `phoenix.server` parameter, which will start Phoenix's HTTP Server.
+
+```bash
+mix phoenix.server
+```
+
+This will be the output to this command:
+
+```bash
+[info] Running HelloGitlabCi.Endpoint with Cowboy using http://localhost:4000
+23 May 11:44:35 - info: compiling
+23 May 11:44:37 - info: compiled 6 files into 2 files, copied 3 in 9.8 sec
+```
+
+Now, we have our app running locally. We can preview it directly on our browser. Let's open
+[`localhost:4000`](http://localhost:4000) to see our Phoenix Framework welcome page. If the link do
+not work, open [`127.0.0.1:4000`](http://127.0.0.1:4000) instead and later, configure your OS to
+point `localhost` to `127.0.0.1`.
+
+![`mix phoenix.server`](img/mix-phoenix-server.png)
+
+Great, now we have a local Phoenix Server running our app.
+
+Locally, our application is running in an `iex` session. [iex][iex] stands for Interactive Elixir.
+In this interactive mode, we can type any Elixir expression and get its result. To exit `iex`, we
+need to press `Ctrl+C` twice. So, when we need to stop the Phoenix server, we have to hit `Ctrl+C`
+twice.
+
+## Introducing GitLab CI/CD
+
+With GitLab, we can manage our development workflow, improve our productivity, track issues,
+perform code review, and much more from a single platform. With GitLab CI/CD, we can be much more
+productive, because every time we, or our co-workers push any code, GitLab CI/CD will build and
+test the changes, telling us in realtime if anything goes wrong.
+
+Certainly, when our application starts to grow, we'll need more developers working on the same
+project and this process of building and testing can easely become a mess without proper management.
+That's also why GitLab CI/CD is so important to our application. Every time someone pushes its code to
+GitLab, we'll quickly know if their changes broke something or not. We don't need to stop everything
+we're doing to test manually and locally every change our team does.
+
+Let's see this in practice.
+
+## Adjusting Phoenix configuration
+
+Now, we need to adjust our Phoenix configuration before configuring GitLab CI/CD.
+There is a directory (`config`) in your Phoenix project that contains a configuration file for every
+environment it can run. Since we will work with a single environment, we'll edit just the test
+configuration file (`test.exs`).
+
+But, why do we need to adjust our configuration? Well, GitLab CI/CD builds and tests our code in one
+isolated virtual machine, called [Runner][runner-site], using Docker technology. In this Runner,
+GitLab CI/CD has access to everything our Phoenix application need to run, exactly as we have in our
+`localhost`, but we have to tell GitLab CI/CD where to create and find this database using system
+variables. This way, GitLab CI/CD will create our test database inside the Runner, just like we do
+when running our Phoenix in our `localhost`.
+
+- Open `hello_gitlab_ci/config/test.exs` on your favorite code editor
+- Go to **Configure your database** session and edit the block to include `System.get_env`:
+
+ ```elixir
+ # Configure your database
+ config :hello_gitlab_ci, HelloGitlabCi.Repo,
+ adapter: Ecto.Adapters.Postgres,
+ username: System.get_env("POSTGRES_USER") || "postgres",
+ password: System.get_env("POSTGRES_PASSWORD") || "postgres",
+ database: System.get_env("POSTGRES_DB") || "hello_gitlab_ci_test",
+ hostname: System.get_env("POSTGRES_HOST") || "localhost",
+ pool: Ecto.Adapters.SQL.Sandbox
+ ```
+
+ We'll need these system variables later on.
+
+- Create an empty file named `.gitkeep` into `hello_gitlab_ci/priv/repo/migrations`
+
+ As our project is still fresh, we don't have any data on our database, so, the `migrations`
+directory will be empty.
+ Without `.gitkeep`, git will not upload this empty directory and we'll got an error when running our
+test on GitLab.
+
+ > **Note:**
+ If we add a folder via the GitLab UI, GitLab itself will add the `.gitkeep` to that new dir.
+
+Now, let's run a local test and see if everything we did didn't break anything.
+
+## Testing
+
+Earlier, when we created our project, we ran `mix phoenix.new`.
+This task created everything a Phoenix application needed, including some unit tests into
+`hello_gitlab_ci/test` directory.
+
+Let's run a new task with `mix` to run those tests for us. This time, the parameter expected is
+`test`. We can add `--trace` parameter for debugging purposes.
+
+In your terminal, navigate to the directory `hello_gitlab_ci` and run:
+
+```bash
+mix test
+```
+
+Our expected result is this:
+
+```bash
+....
+
+Finished in 0.7 seconds
+4 tests, 0 failures
+
+Randomized with seed 610000
+```
+
+Our test was successfull. It's time to push our files to GitLab.
+
+## Configuring CI/CD Pipeline
+
+The first step is to create a new file called `.gitlab-ci.yml` in `hello_gitlab_ci` directory of our
+project.
+
+- The fastest and easiest way to do this, is to click on **Set up CI** on project's main page:
+
+ ![Set up CI](img/setup-ci.png)
+
+- On next screen, we can select a template ready to go. Click on **Apply a GitLab CI/CD Yaml
+template** and select **Elixir**:
+
+ ![Select template](img/select-template.png)
+
+ This template file tells GitLab CI/CD about what we wish to do every time a new commit is made.
+ However, we have to adapt it to run a Phoenix app.
+
+- The first line tells GitLab what Docker image will be used.
+
+ Remember when we learn about Runners, the isolated virtual machine where GitLab CI/CD build and test
+ our application? This virtual machine must have all dependencies to run our application. This is
+ where a Docker image is needed. The correct image will provide the entire system for us.
+
+ As a suggestion, you can use [trenpixster's elixir image][docker-image], which already has all
+ dependencies for Phoenix installed, such as Elixir, Erlang, NodeJS and PostgreSQL:
+
+ ```yml
+ image: trenpixster/elixir:latest
+ ```
+
+- At `services` session, we'll only use `postgres`, so we'll delete `mysql` and `redis` lines:
+
+ ```yml
+ services:
+ - postgres:latest
+ ```
+
+- Now, we'll create a new entry called `variables`, before `before_script` session:
+
+ ```yml
+ variables:
+ POSTGRES_DB: hello_gitlab_ci_test
+ POSTGRES_HOST: postgres
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: "postgres"
+ MIX_ENV: "test"
+ ```
+
+ Here, we are setting up the values for GitLab CI/CD authenticate into PostgreSQL, as we did on
+ `config/test.exs` earlier.
+
+- In `before_script` session, we'll add some commands to prepare everything to the test:
+
+ ```yml
+ before_script:
+ - apt-get update && apt-get -y install postgresql-client
+ - mix local.hex --force
+ - mix deps.get --only test
+ - mix ecto.create
+ - mix ecto.migrate
+ ```
+
+ It's important to install `postgresql-client` to let GitLab CI/CD access PostgreSQL and create our
+ database with the login information provided earlier. More important is to respect the identation,
+ to avoid syntax errors when running the build.
+
+- And finally, we'll let `mix` session intact.
+
+Let's take a look at the completed file after the editions:
+
+```yml
+image: trenpixster/elixir:latest
+
+services:
+ - postgres:latest
+
+variables:
+ POSTGRES_DB: test_test
+ POSTGRES_HOST: postgres
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: "postgres"
+ MIX_ENV: "test"
+
+before_script:
+ - apt-get update && apt-get -y install postgresql-client
+ - mix deps.get
+ - mix ecto.create
+ - mix ecto.migrate
+
+mix:
+ script:
+ - mix test
+```
+
+For safety, we can check if we get any syntax errors before submiting this file to GitLab. Copy the
+contents of `.gitlab-ci.yml` and paste it on [GitLab CI/CD Lint tool][ci-lint]. Please note that
+this link will only work for logged in users.
+
+## Watching the build
+
+I don't know about you, but I love to watch that black screen being filled with compilation output.
+With this, I can feel the happiness of something I made working correctly. On `localhost` it's easy
+to watch our build, but on GitLab, is it possible? Yes!
+
+Let's go to **Pipelines** and see GitLab doing the job. Just click on **Pipelines** to find the
+actual running build job.
+
+![Pipelines](img/pipelines.png)
+
+Click on build's ID to watch the entire process. If everything went as expected, we can wait for the
+**Build succeeded** at the end of the process! :)
+
+```
+$ mix test
+....
+
+Finished in 0.3 seconds
+4 tests, 0 failures
+
+Randomized with seed 206909
+Build succeeded
+```
+
+If we take a look at the project's main page on the GitLab UI, we can see the status of the last
+build made by GitLab CI/CD.
+
+Time to show the world our green build badge! Navigate to your project's **Settings > CI/CD** and
+expand **General pipelines settings**. Scroll down to **Pipeline status** and copy the markdown code
+for your badge. Paste it on the top of your `README.md` file, to let people outside of our project
+see if our latest code is running without errors.
+
+When we finish this edition, GitLab will start another build and show a **build running** badge. It
+is expected, after all we just configured GitLab CI/CD to do this for every push! But you may think
+"Why run build and tests for simple things like editing README.md?" and it is a good question.
+For changes that don't affect your application, you can add the keyword [`[ci skip]`][skipping-jobs]
+to commit message and the build related to that commit will be skipped.
+
+In the end, we finally got our pretty green build succeeded badge! By outputting the result on the
+README file, it shows to whoever lands on your project's page that your code is up-to-date and
+working properly.
+
+## Conclusion
+
+When we have a growing application with many developers working on it, or when we have an open
+source project being watched and contributed by the community, it is really important to have our
+code permanently working. GitLab CI/CD is a time saving powerfull tool to help us mantain our code
+organized and working.
+
+As we could see in this post, GitLab CI/CD is really really easy to configure and use. We have [many
+other reasons][ci-reasons] to keep using GitLab CI/CD. The benefits to our teams will be huge!
+
+## References
+
+- [GitLab CI/CD introductory guide][ci-guide]
+- [GitLab CI/CD full Documentation][ci-docs]
+- [GitLab Runners documentation][gitlab-runners]
+- [Using Docker images documentation][using-docker]
+- [Example project: Hello GitLab CI/CD on GitLab][hello-gitlab]
+
+[phoenix-site]: http://phoenixframework.org/ "Phoenix Framework"
+[phoenix-learning-guide]: https://hexdocs.pm/phoenix/learning.html "Phoenix Learning Guide"
+[phoenix-install]: http://www.phoenixframework.org/docs/installation "Phoenix Installation"
+[phoenix-mysql]: http://www.phoenixframework.org/docs/using-mysql "Phoenix with MySQL"
+[elixir-site]: http://elixir-lang.org/ "Elixir"
+[elixir-mix]: http://elixir-lang.org/getting-started/mix-otp/introduction-to-mix.html "Introduction to mix"
+[elixir-docs]: http://elixir-lang.org/getting-started/introduction.html "Elixir Documentation"
+[erlang-site]: http://erlang.org "Erlang"
+[elixir-install]: https://elixir-lang.org/install.html "Elixir Installation"
+[ecto]: http://hexdocs.pm/ecto "Ecto"
+[ecto-repo]: https://hexdocs.pm/ecto/Ecto.html#module-repositories "Ecto Repositories"
+[mix-ecto]: https://hexdocs.pm/ecto/Mix.Tasks.Ecto.Create.html "mix and Ecto"
+[iex]: http://elixir-lang.org/getting-started/introduction.html#interactive-mode "Interactive Mode"
+[ci-lint]: https://gitlab.com/ci/lint "CI Lint Tool"
+[ci-reasons]: https://about.gitlab.com/2015/02/03/7-reasons-why-you-should-be-using-ci/ "7 Reasons Why You Should Be Using CI"
+[ci-guide]: https://about.gitlab.com/2015/12/14/getting-started-with-gitlab-and-gitlab-ci/ "Getting Started With GitLab And GitLab CI/CD"
+[ci-docs]: ../../README.md "GitLab CI/CD Documentation"
+[skipping-jobs]: ../../yaml/README.md#skipping-jobs "Skipping Jobs"
+[gitlab-runners]: ../../runners/README.md "GitLab Runners Documentation"
+[runner-site]: ../../runners/README.md#runners "Runners"
+[docker-image]: https://hub.docker.com/r/trenpixster/elixir/ "Elixir Docker Image"
+[using-docker]: ../../docker/using_docker_images.md "Using Docker Images"
+[hello-gitlab]: https://gitlab.com/Hostert/hello_gitlab_ci "Hello GitLab CI/CD"
diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md
index ac4a9b0ed27..301cccc80a3 100644
--- a/doc/ci/pipelines.md
+++ b/doc/ci/pipelines.md
@@ -2,6 +2,11 @@
> Introduced in GitLab 8.8.
+NOTE: **Note:**
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+you may need to enable pipeline triggering in your project's
+**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
+
## Pipelines
A pipeline is a group of [jobs][] that get executed in [stages][](batches).
@@ -121,7 +126,7 @@ 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 slash (`/`)
- a colon (`:`)
>**Note:**
diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md
index f621bf07251..fec0ff87326 100644
--- a/doc/ci/quick_start/README.md
+++ b/doc/ci/quick_start/README.md
@@ -104,8 +104,8 @@ Jobs are used to create jobs, which are then picked by
What is important is that each job is run independently from each other.
-If you want to check whether your `.gitlab-ci.yml` file is valid, there is a
-Lint tool under the page `/ci/lint` of your GitLab instance. You can also find
+If you want to check whether the `.gitlab-ci.yml` of your project is valid, there is a
+Lint tool under the page `/ci/lint` of your project namespace. You can also find
a "CI Lint" button to go to this page under **CI/CD âž” Pipelines** and
**Pipelines âž” Jobs** in your project.
@@ -126,6 +126,11 @@ git push origin master
Now if you go to the **Pipelines** page you will see that the pipeline is
pending.
+NOTE: **Note:**
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+you may need to enable pipeline triggering in your project's
+**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
+
You can also go to the **Commits** page and notice the little pause icon next
to the commit SHA.
@@ -135,9 +140,9 @@ Clicking on it you will be directed to the jobs page for that specific commit.
![Single commit jobs page](img/single_commit_status_pending.png)
-Notice that there are two jobs pending which are named after what we wrote in
-`.gitlab-ci.yml`. The red triangle indicates that there is no Runner configured
-yet for these jobs.
+Notice that there is a pending job which is named after what we wrote in
+`.gitlab-ci.yml`. "stuck" indicates that there is no Runner configured
+yet for this job.
The next step is to configure a Runner so that it picks the pending jobs.
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index df66810a838..60dc2ef9ac5 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -35,7 +35,7 @@ are:
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 âž” CI/CD**.
+under **Settings > CI/CD**.
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
@@ -76,7 +76,7 @@ Registering a specific can be done in two ways:
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:
-1. Go to **Settings âž” CI/CD** to obtain the token
+1. Go to **Settings > CI/CD** to obtain the token
1. [Register the Runner][register]
### Making an existing shared Runner specific
@@ -85,7 +85,7 @@ 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.
-1. Go to the Runners in the admin area **Overview âž” Runners** (`/admin/runners`)
+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
@@ -101,7 +101,7 @@ can be changed afterwards under each Runner's settings.
To lock/unlock a Runner:
-1. Visit your project's **Settings âž” CI/CD**
+1. Visit your project's **Settings > CI/CD**
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
@@ -115,7 +115,7 @@ you can enable the Runner also on any other project where you have Master permis
To enable/disable a Runner in your project:
-1. Visit your project's **Settings âž” CI/CD**
+1. Visit your project's **Settings > CI/CD**
1. Find the Runner you wish to enable/disable
1. Click **Enable for this project** or **Disable for this project**
@@ -124,6 +124,13 @@ Consider that if you don't lock your specific Runner to a specific project, any
user with Master role in you project can assign your runner to another arbitrary
project without requiring your authorization, so use it with caution.
+An admin can enable/disable a specific Runner for projects:
+
+1. Navigate to **Admin > Runners**
+2. Find the Runner you wish to enable/disable
+3. Click edit on the Runner
+4. Click **Enable** or **Disable** on the project
+
## Protected Runners
>
@@ -136,7 +143,7 @@ Whenever a Runner is protected, the Runner picks only jobs created on
To protect/unprotect Runners:
-1. Visit your project's **Settings âž” CI/CD**
+1. Visit your project's **Settings > CI/CD**
1. Find a Runner you want to protect/unprotect and make sure it's enabled
1. Click the pencil button besides the Runner name
1. Check the **Protected** option
@@ -144,6 +151,10 @@ To protect/unprotect Runners:
![specific Runners edit icon](img/protected_runners_check_box.png)
+## Manually clearing the Runners cache
+
+Read [clearing the cache](../caching/index.md#clearing-the-cache).
+
## How shared Runners pick jobs
Shared Runners abide to a process queue we call fair usage. The fair usage
@@ -206,15 +217,16 @@ that it may encounter on the projects it's shared over. This would be
problematic for large amounts of projects, if it wasn't for tags.
By tagging a Runner for the types of jobs it can handle, you can make sure
-shared Runners will only run the jobs they are equipped to run.
+shared Runners will [only run the jobs they are equipped to run](../yaml/README.md#tags).
For instance, at GitLab we have Runners tagged with "rails" if they contain
the appropriate dependencies to run Rails test suites.
### 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 can be enabled the first
+You can configure a Runner to prevent it from picking
+[jobs with tags](../yaml/README.md#tags) when 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.
@@ -226,6 +238,38 @@ To make a Runner pick tagged/untagged jobs:
1. Check the **Run untagged jobs** option
1. Click **Save changes** for the changes to take effect
+### Setting maximum job timeout for a Runner
+
+For each Runner you can specify a _maximum job timeout_. Such timeout,
+if smaller than [project defined timeout], will take the precedence. This
+feature can be used to prevent Shared Runner from being appropriated
+by a project by setting a ridiculous big timeout (e.g. one week).
+
+When not configured, Runner will not override project timeout.
+
+How this feature will work:
+
+**Example 1 - Runner timeout bigger than project timeout**
+
+1. You set the _maximum job timeout_ for a Runner to 24 hours
+1. You set the _CI/CD Timeout_ for a project to **2 hours**
+1. You start a job
+1. The job, if running longer, will be timeouted after **2 hours**
+
+**Example 2 - Runner timeout not configured**
+
+1. You remove the _maximum job timeout_ configuration from a Runner
+1. You set the _CI/CD Timeout_ for a project to **2 hours**
+1. You start a job
+1. The job, if running longer, will be timeouted after **2 hours**
+
+**Example 3 - Runner timeout smaller than project timeout**
+
+1. You set the _maximum job timeout_ for a Runner to **30 minutes**
+1. You set the _CI/CD Timeout_ for a project to 2 hours
+1. You start a job
+1. The job, if running longer, will be timeouted after **30 minutes**
+
### Be careful with sensitive information
With some [Runner Executors](https://docs.gitlab.com/runner/executors/README.html),
@@ -254,8 +298,42 @@ 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/).
+## Determining the IP address of a Runner
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17286) in GitLab 10.6.
+
+It may be useful to know the IP address of a Runner so you can troubleshoot
+issues with that Runner. GitLab stores and displays the IP address by viewing
+the source of the HTTP requests it makes to GitLab when polling for jobs. The
+IP address is always kept up to date so if the Runner IP changes it will be
+automatically updated in GitLab.
+
+The IP address for shared Runners and specific Runners can be found in
+different places.
+
+### Shared Runners
+
+To view the IP address of a shared Runner you must have admin access to
+the GitLab instance. To determine this:
+
+1. Visit **Admin area âž” Overview âž” Runners**
+1. Look for the Runner in the table and you should see a column for "IP Address"
+
+![shared Runner IP address](img/shared_runner_ip_address.png)
+
+### Specific Runners
+
+You can find the IP address of a Runner for a specific project by:
+
+1. Visit your project's **Settings âž” CI/CD**
+1. Find the Runner and click on it's ID which links you to the details page
+1. On the details page you should see a row for "IP Address"
+
+![specific Runner IP address](img/specific_runner_ip_address.png)
+
[install]: http://docs.gitlab.com/runner/install/
[fifo]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)
[register]: http://docs.gitlab.com/runner/register/
[protected branches]: ../../user/project/protected_branches.md
[protected tags]: ../../user/project/protected_tags.md
+[project defined timeout]: ../../user/project/pipelines/settings.html#timeout
diff --git a/doc/ci/runners/img/shared_runner_ip_address.png b/doc/ci/runners/img/shared_runner_ip_address.png
new file mode 100644
index 00000000000..3b1542d59d3
--- /dev/null
+++ b/doc/ci/runners/img/shared_runner_ip_address.png
Binary files differ
diff --git a/doc/ci/runners/img/specific_runner_ip_address.png b/doc/ci/runners/img/specific_runner_ip_address.png
new file mode 100644
index 00000000000..3b4c3e9f2eb
--- /dev/null
+++ b/doc/ci/runners/img/specific_runner_ip_address.png
Binary files differ
diff --git a/doc/ci/ssh_keys/README.md b/doc/ci/ssh_keys/README.md
index df0e1521150..693c8e9ef18 100644
--- a/doc/ci/ssh_keys/README.md
+++ b/doc/ci/ssh_keys/README.md
@@ -181,7 +181,7 @@ before_script:
## Assuming you created the SSH_KNOWN_HOSTS variable, uncomment the
## following two lines.
##
- - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts'
+ - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
##
@@ -194,7 +194,7 @@ before_script:
##
## You can optionally disable host key checking. Be aware that by adding that
- ## you are suspectible to man-in-the-middle attacks.
+ ## you are susceptible to man-in-the-middle attacks.
## WARNING: Use this only with the Docker executor, if you use it with shell
## you will overwrite your user's SSH config.
##
diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md
index 56a16f77e7f..47a576fdf5f 100644
--- a/doc/ci/triggers/README.md
+++ b/doc/ci/triggers/README.md
@@ -219,7 +219,7 @@ removed with one of the future versions of GitLab. You are advised to
[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/
+[ee]: https://about.gitlab.com/products/
[variables]: ../variables/README.md
[predef]: ../variables/README.md#predefined-variables-environment-variables
[registry]: ../../user/project/container_registry.md
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index b9d4a2098ed..4a504a98902 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -12,7 +12,7 @@ this order:
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 [job-level variables](../yaml/README.md#variables)
1. YAML-defined [global variables](../yaml/README.md#variables)
1. [Deployment variables](#deployment-variables)
1. [Predefined variables](#predefined-variables-environment-variables) (are the
@@ -56,6 +56,9 @@ future GitLab releases.**
| **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab |
| **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used |
| **CI_RUNNER_TAGS** | 8.10 | 0.5 | The defined runner tags |
+| **CI_RUNNER_VERSION** | all | 10.6 | GitLab Runner version that is executing the current job |
+| **CI_RUNNER_REVISION** | all | 10.6 | GitLab Runner revision that is executing the current job |
+| **CI_RUNNER_EXECUTABLE_ARCH** | all | 10.6 | The OS/architecture of the GitLab Runner executable (note that this is not necessarily the same as the environment of the executor) |
| **CI_PIPELINE_ID** | 8.10 | 0.5 | The unique id of the current pipeline that GitLab CI uses internally |
| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] |
| **CI_PIPELINE_SOURCE** | 10.0 | all | The source for this pipeline, one of: push, web, trigger, schedule, api, external. Pipelines created before 9.5 will have unknown as source |
@@ -87,7 +90,7 @@ future GitLab releases.**
## 9.0 Renaming
-To follow conventions of naming across GitLab, and to futher move away from the
+To follow conventions of naming across GitLab, and to further move away from the
`build` term and toward `job` CI variables have been renamed for the 9.0
release.
@@ -110,7 +113,7 @@ future GitLab releases.**
| `CI_BUILD_MANUAL` | `CI_JOB_MANUAL` |
| `CI_BUILD_TOKEN` | `CI_JOB_TOKEN` |
-## `.gitlab-ci.yaml` defined variables
+## `.gitlab-ci.yml` defined variables
>**Note:**
This feature requires GitLab Runner 0.5.0 or higher and GitLab CI 7.14 or higher.
@@ -446,8 +449,109 @@ export CI_REGISTRY_USER="gitlab-ci-token"
export CI_REGISTRY_PASSWORD="longalfanumstring"
```
+## Variables expressions
+
+> Variables expressions were added in GitLab 10.7.
+
+It is possible to use variables expressions with only / except policies in
+`.gitlab-ci.yml`. By using this approach you can limit what jobs are going to
+be created within a pipeline after pushing a code to GitLab.
+
+This is particularly useful in combination with secret variables and triggered
+pipeline variables.
+
+```yaml
+deploy:
+ script: cap staging deploy
+ environment: staging
+ only:
+ variables:
+ - $RELEASE == "staging"
+ - $STAGING
+```
+
+Each expression provided is going to be evaluated before creating a pipeline.
+
+If any of the conditions in `variables` evaluates to truth when using `only`,
+a new job is going to be created. If any of the expressions evaluates to truth
+when `except` is being used, a job is not going to be created.
+
+This follows usual rules for [`only` / `except` policies][builds-policies].
+
+### Supported syntax
+
+Below you can find supported syntax reference:
+
+1. Equality matching using a string
+
+ > Example: `$VARIABLE == "some value"`
+
+ You can use equality operator `==` to compare a variable content to a
+ string. We support both, double quotes and single quotes to define a string
+ value, so both `$VARIABLE == "some value"` and `$VARIABLE == 'some value'`
+ are supported. `"some value" == $VARIABLE` is correct too.
+
+1. Checking for an undefined value
+
+ > Example: `$VARIABLE == null`
+
+ It sometimes happens that you want to check whether a variable is defined
+ or not. To do that, you can compare a variable to `null` keyword, like
+ `$VARIABLE == null`. This expression is going to evaluate to truth if
+ variable is not defined.
+
+1. Checking for an empty variable
+
+ > Example: `$VARIABLE == ""`
+
+ If you want to check whether a variable is defined, but is empty, you can
+ simply compare it against an empty string, like `$VAR == ''`.
+
+1. Comparing two variables
+
+ > Example: `$VARIABLE_1 == $VARIABLE_2`
+
+ It is possible to compare two variables. This is going to compare values
+ of these variables.
+
+1. Variable presence check
+
+ > Example: `$STAGING`
+
+ If you only want to create a job when there is some variable present,
+ which means that it is defined and non-empty, you can simply use
+ variable name as an expression, like `$STAGING`. If `$STAGING` variable
+ is defined, and is non empty, expression will evaluate to truth.
+ `$STAGING` value needs to a string, with length higher than zero.
+ Variable that contains only whitespace characters is not an empty variable.
+
+### Unsupported predefined variables
+
+Because GitLab evaluates variables before creating jobs, we do not support a
+few variables that depend on persistence layer, like `$CI_JOB_ID`.
+
+Environments (like `production` or `staging`) are also being created based on
+what jobs pipeline consists of, thus some environment-specific variables are
+not supported as well.
+
+We do not support variables containing tokens because of security reasons.
+
+You can find a full list of unsupported variables below:
+
+- `CI_JOB_ID`
+- `CI_JOB_TOKEN`
+- `CI_BUILD_ID`
+- `CI_BUILD_TOKEN`
+- `CI_REGISTRY_USER`
+- `CI_REGISTRY_PASSWORD`
+- `CI_REPOSITORY_URL`
+- `CI_ENVIRONMENT_URL`
+
+These variables are also not supported in a contex of a
+[dynamic environment name][dynamic-environments].
+
[ce-13784]: https://gitlab.com/gitlab-org/gitlab-ce/issues/13784 "Simple protection of CI secret variables"
-[eep]: https://about.gitlab.com/gitlab-ee/ "Available only in GitLab Enterprise Edition Premium"
+[eep]: https://about.gitlab.com/products/ "Available only in GitLab Premium"
[envs]: ../environments.md
[protected branches]: ../../user/project/protected_branches.md
[protected tags]: ../../user/project/protected_tags.md
@@ -456,3 +560,5 @@ export CI_REGISTRY_PASSWORD="longalfanumstring"
[triggered]: ../triggers/README.md
[triggers]: ../triggers/README.md#pass-job-variables-to-a-trigger
[subgroups]: ../../user/group/subgroups/index.md
+[builds-policies]: ../yaml/README.md#only-and-except-complex
+[dynamic-environments]: ../environments.md#dynamic-environments
diff --git a/doc/ci/variables/img/secret_variables.png b/doc/ci/variables/img/secret_variables.png
index f70935069d9..3c1aa361dc2 100644
--- a/doc/ci/variables/img/secret_variables.png
+++ b/doc/ci/variables/img/secret_variables.png
Binary files differ
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 32464cbb259..68aa64b3834 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -3,18 +3,24 @@
This document describes the usage of `.gitlab-ci.yml`, the file that is used by
GitLab Runner to manage your project's jobs.
+From version 7.12, GitLab CI uses a [YAML](https://en.wikipedia.org/wiki/YAML)
+file (`.gitlab-ci.yml`) for the project configuration. It is placed in the root
+of your repository and contains definitions of how your project should be built.
+
If you want a quick introduction to GitLab CI, follow our
[quick start guide](../quick_start/README.md).
-## .gitlab-ci.yml
+NOTE: **Note:**
+If you have a [mirrored repository where GitLab pulls from](https://docs.gitlab.com/ee/workflow/repository_mirroring.html#pulling-from-a-remote-repository),
+you may need to enable pipeline triggering in your project's
+**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
-From version 7.12, GitLab CI uses a [YAML](https://en.wikipedia.org/wiki/YAML)
-file (`.gitlab-ci.yml`) for the project configuration. It is placed in the root
-of your repository and contains definitions of how your project should be built.
+## Jobs
The YAML file defines a set of jobs with constraints stating when they should
-be run. The jobs are defined as top-level elements with a name and always have
-to contain at least the `script` clause:
+be run. You can specify an unlimited number of jobs which are defined as
+top-level elements with an arbitrary name and always have to contain at least
+the `script` clause.
```yaml
job1:
@@ -24,9 +30,8 @@ job2:
script: "execute-script-for-job2"
```
-The above example is the simplest possible CI configuration with two separate
+The above example is the simplest possible CI/CD configuration with two separate
jobs, where each of the jobs executes a different command.
-
Of course a command can execute code directly (`./configure;make;make install`)
or run a script (`test.sh`) in the repository.
@@ -34,78 +39,115 @@ Jobs are picked up by [Runners](../runners/README.md) and executed within the
environment of the Runner. What is important, is that each job is run
independently from each other.
-The YAML syntax allows for using more complex job specifications than in the
-above example:
+Each job must have a unique name, but there are a few **reserved `keywords` that
+cannot be used as job names**:
-```yaml
-image: ruby:2.1
-services:
- - postgres
+- `image`
+- `services`
+- `stages`
+- `types`
+- `before_script`
+- `after_script`
+- `variables`
+- `cache`
-before_script:
- - bundle install
+A job is defined by a list of parameters that define the job behavior.
-after_script:
- - rm secrets
+| Keyword | Required | Description |
+|---------------|----------|-------------|
+| script | yes | Defines a shell script which is executed by Runner |
+| image | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
+| services | no | Use docker services, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
+| stage | no | Defines a job stage (default: `test`) |
+| type | no | Alias for `stage` |
+| variables | no | Define job variables on a job level |
+| only | no | Defines a list of git refs for which job is created |
+| except | no | Defines a list of git refs for which job is not created |
+| tags | no | Defines a list of tags which are used to select Runner |
+| allow_failure | no | Allow job to fail. Failed job doesn't contribute to commit status |
+| when | no | Define when to run job. Can be `on_success`, `on_failure`, `always` or `manual` |
+| dependencies | no | Define other jobs that a job depends on so that you can pass artifacts between them|
+| artifacts | no | Define list of [job artifacts](#artifacts) |
+| cache | no | Define list of files that should be cached between subsequent runs |
+| before_script | no | Override a set of commands that are executed before job |
+| 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 |
-stages:
- - build
- - test
- - deploy
+### `pages`
-job1:
- stage: build
+`pages` is a special job that is used to upload static content to GitLab that
+can be used to serve your website. It has a special syntax, so the two
+requirements below must be met:
+
+1. Any static content must be placed under a `public/` directory
+1. `artifacts` with a path to the `public/` directory must be defined
+
+The example below simply moves all files from the root of the project to the
+`public/` directory. The `.public` workaround is so `cp` doesn't also copy
+`public/` to itself in an infinite loop:
+
+```
+pages:
+ stage: deploy
script:
- - execute-script-for-job1
+ - mkdir .public
+ - cp -r * .public
+ - mv .public public
+ artifacts:
+ paths:
+ - public
only:
- - master
- tags:
- - docker
+ - master
```
-There are a few reserved `keywords` that **cannot** be used as job names:
-
-| Keyword | Required | Description |
-|---------------|----------|-------------|
-| image | no | Use docker image, covered in [Use Docker](../docker/README.md) |
-| services | no | Use docker services, covered in [Use Docker](../docker/README.md) |
-| stages | no | Define build stages |
-| types | no | Alias for `stages` (deprecated) |
-| before_script | no | Define commands that run before each job's script |
-| after_script | no | Define commands that run after each job's script |
-| variables | no | Define build variables |
-| cache | no | Define list of files that should be cached between subsequent runs |
+Read more on [GitLab Pages user documentation](../../user/project/pages/index.md).
-### image and services
+## `image` and `services`
This allows to specify a custom Docker image and a list of services that can be
used for time of the job. The configuration of this feature is covered in
[a separate document](../docker/README.md).
-### before_script
-
-`before_script` is used to define the command that should be run before all
-jobs, including deploy jobs, but after the restoration of artifacts. This can
-be an array or a multi-line string.
-
-### after_script
+## `before_script` and `after_script`
> Introduced in GitLab 8.7 and requires Gitlab Runner v1.2
+`before_script` is used to define the command that should be run before all
+jobs, including deploy jobs, but after the restoration of [artifacts](#artifacts).
+This can be an array or a multi-line string.
+
`after_script` is used to define the command that will be run after for all
-jobs. This has to be an array or a multi-line string.
+jobs, including failed ones. This has to be an array or a multi-line string.
-> **Note:**
The `before_script` and the main `script` are concatenated and run in a single context/container.
The `after_script` is run separately, so depending on the executor, changes done
outside of the working tree might not be visible, e.g. software installed in the
`before_script`.
-### stages
+It's possible to overwrite the globally defined `before_script` and `after_script`
+if you set it per-job:
-`stages` is used to define stages that can be used by jobs.
-The specification of `stages` allows for having flexible multi stage pipelines.
+```yaml
+before_script:
+- global before script
+
+job:
+ before_script:
+ - execute this instead of global before script
+ script:
+ - my command
+ after_script:
+ - execute this after my script
+```
+
+## `stages`
+`stages` is used to define stages that can be used by jobs and is defined
+globally.
+
+The specification of `stages` allows for having flexible multi stage pipelines.
The ordering of elements in `stages` defines the ordering of jobs' execution:
1. Jobs of the same stage are run in parallel.
@@ -134,280 +176,45 @@ There are also two edge cases worth mentioning:
`test` and `deploy` are allowed to be used as job's stage by default.
2. If a job doesn't specify a `stage`, the job is assigned the `test` stage.
-### types
-
-> Deprecated, and could be removed in one of the future releases. Use [stages](#stages) instead.
-
-Alias for [stages](#stages).
-
-### variables
-
-> Introduced in GitLab Runner v0.5.0.
-
-GitLab CI allows you to add variables to `.gitlab-ci.yml` that are set in the
-job environment. The variables are stored in the Git repository and are meant
-to store non-sensitive project configuration, for example:
-
-```yaml
-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
-[job level](#job-variables).
-
-Except for the user defined variables, there are also the ones set up by the
-Runner itself. One example would be `CI_COMMIT_REF_NAME` which has the value of
-the branch or tag name for which project is built. Apart from the variables
-you can set in `.gitlab-ci.yml`, there are also the so called secret variables
-which can be set in GitLab's UI.
-
-[Learn more about variables.][variables]
-
-### cache
-
->
-**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
-workspace.
-
-**By default caching is enabled and shared between pipelines and jobs,
-starting from GitLab 9.0**
-
-If `cache` is defined outside the scope of jobs, it means it is set
-globally and all jobs will use that definition.
-
-Cache all files in `binaries` and `.config`:
-
-```yaml
-rspec:
- script: test
- cache:
- paths:
- - binaries/
- - .config
-```
-
-Cache all Git untracked files:
+## `stage`
-```yaml
-rspec:
- script: test
- cache:
- untracked: true
-```
-
-Cache all Git untracked files and files in `binaries`:
-
-```yaml
-rspec:
- script: test
- cache:
- untracked: true
- paths:
- - binaries/
-```
-
-Locally defined cache overrides globally defined options. The following `rspec`
-job will cache only `binaries/`:
-
-```yaml
-cache:
- paths:
- - my/files
-
-rspec:
- script: test
- cache:
- key: rspec
- paths:
- - binaries/
-```
-
-Note that since cache is shared between jobs, if you're using different
-paths for different jobs, you should also set a different **cache:key**
-otherwise cache content can be overwritten.
-
-The cache is provided on a best-effort basis, so don't expect that the cache
-will be always present. For implementation details, please check GitLab Runner.
-
-#### cache:key
-
-> Introduced in GitLab Runner v1.0.0.
-
-The `key` directive allows you to define the affinity of caching
-between jobs, allowing to have a single cache for all jobs,
-cache per-job, cache per-branch or any other way you deem proper.
-
-This allows you to fine tune caching, allowing you to cache data between
-different jobs or even different branches.
-
-The `cache:key` variable can use any of the [predefined variables](../variables/README.md).
-
-The default key is **default** across the project, therefore everything is
-shared between each pipelines and jobs by default, starting from GitLab 9.0.
-
->**Note:** The `cache:key` variable cannot contain the `/` character.
-
----
-
-**Example configurations**
-
-To enable per-job caching:
-
-```yaml
-cache:
- key: "$CI_JOB_NAME"
- untracked: true
-```
-
-To enable per-branch caching:
-
-```yaml
-cache:
- key: "$CI_COMMIT_REF_NAME"
- untracked: true
-```
-
-To enable per-job and per-branch caching:
-
-```yaml
-cache:
- key: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
- untracked: true
-```
-
-To enable per-branch and per-stage caching:
-
-```yaml
-cache:
- key: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME"
- untracked: true
-```
-
-If you use **Windows Batch** to run your shell scripts you need to replace
-`$` with `%`:
-
-```yaml
-cache:
- key: "%CI_JOB_STAGE%-%CI_COMMIT_REF_NAME%"
- 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:
+`stage` is defined per-job and relies on [`stages`](#stages) which is defined
+globally. It allows to group jobs into different stages, and jobs of the same
+`stage` are executed in `parallel`. For example:
```yaml
stages:
- - setup
+ - build
- test
+ - deploy
-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
+job 1:
+ stage: build
+ script: make build dependencies
-`.gitlab-ci.yml` allows you to specify an unlimited number of jobs. Each job
-must have a unique name, which is not one of the keywords mentioned above.
-A job is defined by a list of parameters that define the job behavior.
+job 2:
+ stage: build
+ script: make build artifacts
-```yaml
-job_name:
- script:
- - rake spec
- - coverage
+job 3:
stage: test
- only:
- - master
- except:
- - develop
- tags:
- - ruby
- - postgres
- allow_failure: true
+ script: make test
+
+job 4:
+ stage: deploy
+ script: make deploy
```
-| Keyword | Required | Description |
-|---------------|----------|-------------|
-| script | yes | Defines a shell script which is executed by Runner |
-| image | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
-| services | no | Use docker services, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
-| stage | no | Defines a job stage (default: `test`) |
-| type | no | Alias for `stage` |
-| variables | no | Define job variables on a job level |
-| only | no | Defines a list of git refs for which job is created |
-| except | no | Defines a list of git refs for which job is not created |
-| tags | no | Defines a list of tags which are used to select Runner |
-| allow_failure | no | Allow job to fail. Failed job doesn't contribute to commit status |
-| when | no | Define when to run job. Can be `on_success`, `on_failure`, `always` or `manual` |
-| dependencies | no | Define other jobs that a job depends on so that you can pass artifacts between them|
-| artifacts | no | Define list of [job artifacts](../../user/project/pipelines/job_artifacts.md) |
-| cache | no | Define list of files that should be cached between subsequent runs |
-| before_script | no | Override a set of commands that are executed before job |
-| 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 |
+## `types`
-### script
+CAUTION: **Deprecated:**
+`types` is deprecated, and could be removed in one of the future releases.
+Use [stages](#stages) instead.
-`script` is a shell script which is executed by the Runner. For example:
+## `script`
+
+`script` is the only required keyword that a job needs. It's a shell script
+which is executed by the Runner. For example:
```yaml
job:
@@ -429,13 +236,7 @@ that the YAML parser knows to interpret the whole thing as a string rather than
a "key: value" pair. Be careful when using special characters:
`:`, `{`, `}`, `[`, `]`, `,`, `&`, `*`, `#`, `?`, `|`, `-`, `<`, `>`, `=`, `!`, `%`, `@`, `` ` ``.
-### stage
-
-`stage` allows to group jobs into different stages. Jobs of the same `stage`
-are executed in `parallel`. For more info about the use of `stage` please check
-[stages](#stages).
-
-### only and except (simplified)
+## `only` and `except` (simplified)
`only` and `except` are two parameters that set a job policy to limit when
jobs are created:
@@ -505,12 +306,13 @@ job:
The above example will run `job` for all branches on `gitlab-org/gitlab-ce`,
except master.
-### only and except (complex)
+## `only` and `except` (complex)
> Introduced in GitLab 10.0
-> This an _alpha_ feature, and it it subject to change at any time without
- prior notice!
+CAUTION: **Warning:**
+This an _alpha_ feature, and it it subject to change at any time without
+prior notice!
Since GitLab 10.0 it is possible to define a more elaborate only/except job
policy configuration.
@@ -518,9 +320,14 @@ policy configuration.
GitLab now supports both, simple and complex strategies, so it is possible to
use an array and a hash configuration scheme.
-Two keys are now available: `refs` and `kubernetes`. Refs strategy equals to
-simplified only/except configuration, whereas kubernetes strategy accepts only
-`active` keyword.
+Three keys are now available: `refs`, `kubernetes` and `variables`.
+Refs strategy equals to simplified only/except configuration, whereas
+kubernetes strategy accepts only `active` keyword.
+
+`variables` keyword is used to define variables expressions. In other words
+you can use predefined variables / secret variables / project / group or
+environment-scoped variables to define an expression GitLab is going to
+evaluate in order to decide whether a job should be created or not.
See the example below. Job is going to be created only when pipeline has been
scheduled or runs for a `master` branch, and only if kubernetes service is
@@ -535,24 +342,21 @@ job:
kubernetes: active
```
-### Job variables
-
-It is possible to define job variables using a `variables` keyword on a job
-level. It works basically the same way as its [global-level equivalent](#variables),
-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 hash:
+Example of using variables expressions:
```yaml
-job_name:
- variables: {}
+deploy:
+ only:
+ refs:
+ - branches
+ variables:
+ - $RELEASE == "staging"
+ - $STAGING
```
-Job variables priority is defined in the [variables documentation][variables].
+Learn more about variables expressions on [a separate page][variables-expressions].
-### tags
+## `tags`
`tags` is used to select specific Runners from the list of all Runners that are
allowed to run this project.
@@ -573,7 +377,7 @@ job:
The specification above, will make sure that `job` is built by a Runner that
has both `ruby` AND `postgres` tags defined.
-### allow_failure
+## `allow_failure`
`allow_failure` is used when you want to allow a job to fail without impacting
the rest of the CI suite. Failed jobs don't contribute to the commit status.
@@ -606,7 +410,7 @@ job3:
- deploy_to_staging
```
-### when
+## `when`
`when` is used to implement jobs that are run in case of failure or despite the
failure.
@@ -619,7 +423,7 @@ failure.
fails.
1. `always` - execute job regardless of the status of jobs from prior stages.
1. `manual` - execute job manually (added in GitLab 8.10). Read about
- [manual actions](#manual-actions) below.
+ [manual actions](#when-manual) below.
For example:
@@ -667,42 +471,41 @@ The above script will:
success or failure.
3. Allow you to manually execute `deploy_job` from GitLab's UI.
-#### Manual actions
-
-> Introduced in GitLab 8.10.
-> Blocking manual actions were introduced in GitLab 9.0
-> Protected actions were introduced in GitLab 9.2
+### `when:manual`
-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
-from pipeline, build, environment, and deployment views.
+> **Notes:**
+- Introduced in GitLab 8.10.
+- Blocking manual actions were introduced in GitLab 9.0.
+- Protected actions were introduced in GitLab 9.2.
-An example usage of manual actions is deployment to production.
+Manual actions are a special type of job that are not executed automatically,
+they need to be explicitly started by a user. An example usage of manual actions
+would be a deployment to a production environment. Manual actions can be started
+from the pipeline, job, environment, and deployment views. Read more at the
+[environments documentation][env-manual].
-Read more at the [environments documentation][env-manual].
-
-Manual actions can be either optional or blocking. Blocking manual action will
-block execution of the pipeline at stage this action is defined in. It is
+Manual actions can be either optional or blocking. Blocking manual actions will
+block the execution of the pipeline at the stage this action is defined in. It's
possible to resume execution of the pipeline when someone executes a blocking
-manual actions by clicking a _play_ button.
+manual action by clicking a _play_ button.
-When pipeline is blocked it will not be merged if Merge When Pipeline Succeeds
+When a pipeline is blocked, it will not be merged if Merge When Pipeline Succeeds
is set. Blocked pipelines also do have a special status, called _manual_.
-
Manual actions are non-blocking by default. If you want to make manual action
blocking, it is necessary to add `allow_failure: false` to the job's definition
in `.gitlab-ci.yml`.
-Optional manual actions have `allow_failure: true` set by default.
+Optional manual actions have `allow_failure: true` set by default and their
+Statuses do not contribute to the overall pipeline status. So, if a manual
+action fails, the pipeline will eventually succeed.
-**Statuses of optional actions do not contribute to overall pipeline status.**
+Manual actions are considered to be write actions, so permissions for
+[protected branches](../../user/project/protected_branches.md) 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.
-**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
+## `environment`
>
**Notes:**
@@ -727,7 +530,7 @@ deploy to production:
In the above example, the `deploy to production` job will be marked as doing a
deployment to the `production` environment.
-#### environment:name
+### `environment:name`
>
**Notes:**
@@ -766,7 +569,7 @@ deploy to production:
name: production
```
-#### environment:url
+### `environment:url`
>
**Notes:**
@@ -793,7 +596,7 @@ deploy to production:
url: https://prod.example.com
```
-#### environment:on_stop
+### `environment:on_stop`
>
**Notes:**
@@ -808,7 +611,7 @@ the environment.
Read the `environment:action` section for an example.
-#### environment:action
+### `environment:action`
> [Introduced][ce-6669] in GitLab 8.13.
@@ -849,7 +652,7 @@ The `stop_review_app` job is **required** to have the following keywords defined
- `stage` should be the same as the `review_app` in order for the environment
to stop automatically when the branch is deleted
-#### dynamic environments
+### Dynamic environments
>
**Notes:**
@@ -885,13 +688,186 @@ The common use case is to create dynamic environments for branches and use them
as Review Apps. You can see a simple example using Review Apps at
<https://gitlab.com/gitlab-examples/review-apps-nginx/>.
-### artifacts
+## `cache`
+
+>
+**Notes:**
+- Introduced in GitLab Runner v0.7.0.
+- `cache` can be set globally and per-job.
+- From GitLab 9.0, caching is enabled and shared between pipelines and jobs
+ by default.
+- From GitLab 9.2, caches are restored before [artifacts](#artifacts).
+
+TIP: **Learn more:**
+Read how caching works and find out some good practices in the
+[caching dependencies documentation](../caching/index.md).
+
+`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
+workspace.
+
+If `cache` is defined outside the scope of jobs, it means it is set
+globally and all jobs will use that definition.
+
+### `cache:paths`
+
+Use the `paths` directive to choose which files or directories will be cached.
+Wildcards can be used as well.
+
+Cache all files in `binaries` that end in `.apk` and the `.config` file:
+
+```yaml
+rspec:
+ script: test
+ cache:
+ paths:
+ - binaries/*.apk
+ - .config
+```
+
+Locally defined cache overrides globally defined options. The following `rspec`
+job will cache only `binaries/`:
+
+```yaml
+cache:
+ paths:
+ - my/files
+
+rspec:
+ script: test
+ cache:
+ paths:
+ - binaries/
+```
+
+### `cache:key`
+
+> Introduced in GitLab Runner v1.0.0.
+
+Since the cache is shared between jobs, if you're using different
+paths for different jobs, you should also set a different `cache:key`
+otherwise cache content can be overwritten.
+
+The `key` directive allows you to define the affinity of caching between jobs,
+allowing to have a single cache for all jobs, cache per-job, cache per-branch
+or any other way that fits your workflow. This way, you can fine tune caching,
+allowing you to cache data between different jobs or even different branches.
+
+The `cache:key` variable can use any of the
+[predefined variables](../variables/README.md), and the default key, if not set,
+is `$CI_JOB_NAME-$CI_COMMIT_REF_NAME` which translates as "per-job and
+per-branch". It is the default across the project, therefore everything is
+shared between pipelines and jobs running on the same branch by default.
+
+NOTE: **Note:**
+The `cache:key` variable cannot contain the `/` character, or the equivalent
+URI-encoded `%2F`; a value made only of dots (`.`, `%2E`) is also forbidden.
+
+For example, to enable per-branch caching:
+
+```yaml
+cache:
+ key: "$CI_COMMIT_REF_SLUG"
+ paths:
+ - binaries/
+```
+
+If you use **Windows Batch** to run your shell scripts you need to replace
+`$` with `%`:
+
+```yaml
+cache:
+ key: "%CI_JOB_STAGE%-%CI_COMMIT_REF_SLUG%"
+ paths:
+ - binaries/
+```
+
+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_SLUG"
+ paths:
+ - binaries/
+```
+
+### `cache:untracked`
+
+Set `untracked: true` to cache all files that are untracked in your Git
+repository:
+
+```yaml
+rspec:
+ script: test
+ cache:
+ untracked: true
+```
+
+Cache all Git untracked files and files in `binaries`:
+
+```yaml
+rspec:
+ script: test
+ cache:
+ untracked: true
+ paths:
+ - binaries/
+```
+
+### `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.
+
+## `artifacts`
>
**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.
@@ -960,7 +936,9 @@ release-job:
The artifacts will be sent to GitLab after the job finishes successfully and will
be available for download in the GitLab UI.
-#### artifacts:name
+[Read more about artifacts.](../../user/project/pipelines/job_artifacts.md)
+
+### `artifacts:name`
> Introduced in GitLab 8.6 and GitLab Runner v1.1.0.
@@ -970,10 +948,6 @@ useful when you'd like to download the archive from GitLab. The `artifacts:name`
variable can make use of any of the [predefined variables](../variables/README.md).
The default name is `artifacts`, which becomes `artifacts.zip` when downloaded.
----
-
-**Example configurations**
-
To create an archive with a name of the current job:
```yaml
@@ -998,7 +972,7 @@ tag including only the files that are untracked by Git:
```yaml
job:
artifacts:
- name: "${CI_JOB_NAME}_${CI_COMMIT_REF_NAME}"
+ name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
untracked: true
```
@@ -1007,7 +981,7 @@ To create an archive with a name of the current [stage](#stages) and branch name
```yaml
job:
artifacts:
- name: "${CI_JOB_STAGE}_${CI_COMMIT_REF_NAME}"
+ name: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME"
untracked: true
```
@@ -1019,7 +993,7 @@ If you use **Windows Batch** to run your shell scripts you need to replace
```yaml
job:
artifacts:
- name: "%CI_JOB_STAGE%_%CI_COMMIT_REF_NAME%"
+ name: "%CI_JOB_STAGE%-%CI_COMMIT_REF_NAME%"
untracked: true
```
@@ -1029,11 +1003,11 @@ If you use **Windows PowerShell** to run your shell scripts you need to replace
```yaml
job:
artifacts:
- name: "$env:CI_JOB_STAGE_$env:CI_COMMIT_REF_NAME"
+ name: "$env:CI_JOB_STAGE-$env:CI_COMMIT_REF_NAME"
untracked: true
```
-#### artifacts:when
+### `artifacts:when`
> Introduced in GitLab 8.9 and GitLab Runner v1.3.0.
@@ -1046,11 +1020,7 @@ failure.
1. `on_failure` - upload artifacts only when the job fails.
1. `always` - upload artifacts regardless of the job status.
----
-
-**Example configurations**
-
-To upload artifacts only when job fails.
+To upload artifacts only when job fails:
```yaml
job:
@@ -1058,22 +1028,23 @@ job:
when: on_failure
```
-#### artifacts:expire_in
+### `artifacts:expire_in`
> Introduced in GitLab 8.9 and GitLab Runner v1.3.0.
-`artifacts:expire_in` is used to delete uploaded artifacts after the specified
-time. By default, artifacts are stored on GitLab forever. `expire_in` allows you
-to specify how long artifacts should live before they expire, counting from the
-time they are uploaded and stored on GitLab.
+`expire_in` allows you to specify how long artifacts should live before they
+expire and therefore deleted, counting from the time they are uploaded and
+stored on GitLab. If the expiry time is not defined, it defaults to the
+[instance wide setting](../../user/admin_area/settings/continuous_integration.md#default-artifacts-expiration)
+(30 days by default, forever on GitLab.com).
You can use the **Keep** button on the job page to override expiration and
keep artifacts forever.
-After expiry, artifacts are actually deleted hourly by default (via a cron job),
-but they are not accessible after expiry.
+After their expiry, artifacts are deleted hourly by default (via a cron job),
+and are not accessible anymore.
-The value of `expire_in` is an elapsed time. Examples of parseable values:
+The value of `expire_in` is an elapsed time. Examples of parsable values:
- '3 mins 4 sec'
- '2 hrs 20 min'
@@ -1082,10 +1053,6 @@ The value of `expire_in` is an elapsed time. Examples of parseable values:
- '47 yrs 6 mos and 4d'
- '3 weeks and 2 days'
----
-
-**Example configurations**
-
To expire artifacts 1 week after being uploaded:
```yaml
@@ -1094,7 +1061,7 @@ job:
expire_in: 1 week
```
-### dependencies
+## `dependencies`
> Introduced in GitLab 8.6 and GitLab Runner v1.1.1.
@@ -1153,7 +1120,7 @@ deploy:
script: make deploy
```
-#### When a dependent job will fail
+### When a dependent job will fail
> Introduced in GitLab 10.3.
@@ -1167,27 +1134,9 @@ You can ask your administrator to
[flip this switch](../../administration/job_artifacts.md#validation-for-dependencies)
and bring back the old behavior.
-### before_script and after_script
-
-It's possible to overwrite the globally defined `before_script` and `after_script`:
-
-```yaml
-before_script:
-- global before script
-
-job:
- before_script:
- - execute this instead of global before script
- script:
- - my command
- after_script:
- - execute this after my script
-```
-
-### coverage
+## `coverage`
-**Notes:**
-- [Introduced][ce-7447] in GitLab 8.17.
+> [Introduced][ce-7447] in GitLab 8.17.
`coverage` allows you to configure how code coverage will be extracted from the
job output.
@@ -1205,10 +1154,9 @@ job1:
coverage: '/Code coverage: \d+\.\d+/'
```
-### retry
+## `retry`
-**Notes:**
-- [Introduced][ce-3442] in GitLab 9.5.
+> [Introduced][ce-12909] in GitLab 9.5.
`retry` allows you to configure how many times a job is going to be retried in
case of a failure.
@@ -1228,16 +1176,57 @@ test:
retry: 2
```
-## Git Strategy
+## `variables`
+
+> Introduced in GitLab Runner v0.5.0.
+
+NOTE: **Note:**
+Integers (as well as strings) are legal both for variable's name and value.
+Floats are not legal and cannot be used.
+
+GitLab CI/CD allows you to define variables inside `.gitlab-ci.yml` that are
+then passed in the job environment. They can be set globally and per-job.
+When the `variables` keyword is used on a job level, it overrides the global
+YAML variables and predefined ones.
+
+They are stored in the Git repository and are meant to store non-sensitive
+project configuration, for example:
+
+```yaml
+variables:
+ DATABASE_URL: "postgres://postgres@postgres/my_database"
+```
+
+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.
+
+To turn off global defined variables in a specific job, define an empty hash:
+
+```yaml
+job_name:
+ variables: {}
+```
+
+Except for the user defined variables, there are also the ones [set up by the
+Runner itself](../variables/README.md#predefined-variables-environment-variables).
+One example would be `CI_COMMIT_REF_NAME` which has the value of
+the branch or tag name for which project is built. Apart from the variables
+you can set in `.gitlab-ci.yml`, there are also the so called
+[secret variables](../variables/README.md#secret-variables)
+which can be set in GitLab's UI.
+
+[Learn more about variables and their priority.][variables]
+
+### Git strategy
> Introduced in GitLab 8.9 as an experimental feature. May change or be removed
completely in future releases. `GIT_STRATEGY=none` requires GitLab Runner
v1.7+.
You can set the `GIT_STRATEGY` used for getting recent application code, either
-in the global [`variables`](#variables) section or the [`variables`](#job-variables)
-section for individual jobs. If left unspecified, the default from project
-settings will be used.
+globally or per-job in the [`variables`](#variables) section. If left
+unspecified, the default from project settings will be used.
There are three possible values: `clone`, `fetch`, and `none`.
@@ -1269,44 +1258,13 @@ 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
+### Git submodule strategy
> Requires GitLab Runner v1.10+.
The `GIT_SUBMODULE_STRATEGY` variable is used to control if / how Git
-submodules are included when fetching the code before a build. Like
-`GIT_STRATEGY`, it can be set in either the global [`variables`](#variables)
-section or the [`variables`](#job-variables) section for individual jobs.
+submodules are included when fetching the code before a build. You can set them
+globally or per-job in the [`variables`](#variables) section.
There are three possible values: `none`, `normal`, and `recursive`:
@@ -1336,8 +1294,36 @@ Note that for this feature to work correctly, the submodules must be configured
- a relative path to another repository on the same GitLab server. See the
[Git submodules](../git_submodules.md) documentation.
+### 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. You can set them globally or per-job in the
+[`variables`](#variables) section.
+
+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
+```
-## Job stages attempts
+### Job stages attempts
> Introduced in GitLab, it requires GitLab Runner v1.9+.
@@ -1359,10 +1345,9 @@ variables:
GET_SOURCES_ATTEMPTS: 3
```
-You can set them in the global [`variables`](#variables) section or the
-[`variables`](#job-variables) section for individual jobs.
+You can set them globally or per-job in the [`variables`](#variables) section.
-## Shallow cloning
+### Shallow cloning
> Introduced in GitLab 8.9 as an experimental feature. May change in future
releases or be removed completely.
@@ -1393,7 +1378,17 @@ variables:
GIT_DEPTH: "3"
```
-## Hidden keys (jobs)
+You can set it globally or per-job in the [`variables`](#variables) section.
+
+## Special YAML features
+
+It's possible to use special YAML features like anchors (`&`), aliases (`*`)
+and map merging (`<<`), which will allow you to greatly reduce the complexity
+of `.gitlab-ci.yml`.
+
+Read more about the various [YAML features](https://learnxinyminutes.com/docs/yaml/).
+
+### Hidden keys (jobs)
> Introduced in GitLab 8.6 and GitLab Runner v1.1.1.
@@ -1419,14 +1414,6 @@ Use this feature to ignore jobs, or use the
[special YAML features](#special-yaml-features) and transform the hidden keys
into templates.
-## Special YAML features
-
-It's possible to use special YAML features like anchors (`&`), aliases (`*`)
-and map merging (`<<`), which will allow you to greatly reduce the complexity
-of `.gitlab-ci.yml`.
-
-Read more about the various [YAML features](https://learnxinyminutes.com/docs/yaml/).
-
### Anchors
> Introduced in GitLab 8.6 and GitLab Runner v1.1.1.
@@ -1556,50 +1543,22 @@ with an API call.
[Read more in the triggers documentation.](../triggers/README.md)
-### pages
-
-`pages` is a special job that is used to upload static content to GitLab that
-can be used to serve your website. It has a special syntax, so the two
-requirements below must be met:
-
-1. Any static content must be placed under a `public/` directory
-1. `artifacts` with a path to the `public/` directory must be defined
-
-The example below simply moves all files from the root of the project to the
-`public/` directory. The `.public` workaround is so `cp` doesn't also copy
-`public/` to itself in an infinite loop:
-
-```
-pages:
- stage: deploy
- script:
- - mkdir .public
- - cp -r * .public
- - mv .public public
- artifacts:
- paths:
- - public
- only:
- - master
-```
+## Skipping jobs
-Read more on [GitLab Pages user documentation](../../user/project/pages/index.md).
+If your commit message contains `[ci skip]` or `[skip ci]`, using any
+capitalization, the commit will be created but the pipeline will be skipped.
## Validate the .gitlab-ci.yml
-Each instance of GitLab CI has an embedded debug tool called Lint.
-You can find the link under `/ci/lint` of your gitlab instance.
+Each instance of GitLab CI has an embedded debug tool called Lint, which validates the
+content of your `.gitlab-ci.yml` files. You can find the Lint under the page `ci/lint` of your
+project namespace (e.g, `http://gitlab-example.com/gitlab-org/project-123/ci/lint`)
## Using reserved keywords
If you get validation error when using specific values (e.g., `true` or `false`),
try to quote them, or change them to a different form (e.g., `/bin/true`).
-## Skipping jobs
-
-If your commit message contains `[ci skip]` or `[skip ci]`, using any
-capitalization, the commit will be created but the jobs will be skipped.
-
## Examples
Visit the [examples README][examples] to see a list of examples using GitLab
@@ -1613,5 +1572,6 @@ 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
+[ce-12909]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12909
[schedules]: ../../user/project/pipelines/schedules.md
+[variables-expressions]: ../variables/README.md#variables-expressions
diff --git a/doc/customization/branded_login_page.md b/doc/customization/branded_login_page.md
index d4d9f5f7b5e..b892f59d777 100644
--- a/doc/customization/branded_login_page.md
+++ b/doc/customization/branded_login_page.md
@@ -1,6 +1,6 @@
# Changing the appearance of the login page
-GitLab Community Edition offers a way to put your company's identity on the login page of your GitLab server and make it a branded login page.
+GitLab offers a way to put your company's identity on the login page of your GitLab server and make it a branded login page.
By default, the page shows the GitLab logo and description.
diff --git a/doc/development/README.md b/doc/development/README.md
index b624aa37c70..45e9565f9a7 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -27,6 +27,7 @@ comments: false
## Backend guides
+- [GitLab utilities](utilities.md)
- [API styleguide](api_styleguide.md) Use this styleguide if you are
contributing to the API.
- [Sidekiq guidelines](sidekiq_style_guide.md) for working with Sidekiq workers
@@ -74,6 +75,7 @@ comments: false
- [Ordering table columns](ordering_table_columns.md)
- [Verifying database capabilities](verifying_database_capabilities.md)
- [Database Debugging and Troubleshooting](database_debugging.md)
+- [Query Count Limits](query_count_limits.md)
## Testing guides
diff --git a/doc/development/architecture.md b/doc/development/architecture.md
index 54029e00507..31117b5e723 100644
--- a/doc/development/architecture.md
+++ b/doc/development/architecture.md
@@ -2,9 +2,11 @@
## Software delivery
-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.
+There are two software distributions of GitLab: the open source [Community Edition](https://gitlab.com/gitlab-org/gitlab-ce/) (CE), and the open core [Enterprise Edition](https://gitlab.com/gitlab-org/gitlab-ee/) (EE). GitLab is available under [different subscriptions](https://about.gitlab.com/products/).
-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.
+New versions of GitLab are released in stable branches and the master branch is for bleeding edge development.
+
+For information, see the [GitLab Release Process](https://gitlab.com/gitlab-org/release/docs/tree/master#gitlab-release-process).
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.
@@ -133,8 +135,6 @@ Usage: /etc/init.d/postgresql {start|stop|restart|reload|force-reload|status} [v
### Log locations of the services
-Note: `/home/git/` is shorthand for `/home/git`.
-
gitlabhq (includes Unicorn and Sidekiq logs)
- `/home/git/gitlab/log/` contains `application.log`, `production.log`, `sidekiq.log`, `unicorn.stdout.log`, `githost.log` and `unicorn.stderr.log` normally.
diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md
index 4b9791c95bc..a85e5b1b1cc 100644
--- a/doc/development/automatic_ce_ee_merge.md
+++ b/doc/development/automatic_ce_ee_merge.md
@@ -5,17 +5,32 @@ Enterprise Edition (look for the [`CE Upstream` merge requests]).
This merge is done automatically in a
[scheduled pipeline](https://gitlab.com/gitlab-org/release-tools/-/jobs/43201679).
-If a merge is already in progress, the job [doesn't create a new one](https://gitlab.com/gitlab-org/release-tools/-/jobs/43157687).
-**If you are pinged in a `CE Upstream` merge request to resolve a conflict,
-please resolve the conflict as soon as possible or ask someone else to do it!**
-
->**Note:**
-It's ok to resolve more conflicts than the one that you are asked to resolve. In
-that case, it's a good habit to ask for a double-check on your resolution by
-someone who is familiar with the code you touched.
+## What to do if you are pinged in a `CE Upstream` merge request to resolve a conflict?
+
+1. Please resolve the conflict as soon as possible or ask someone else to do it
+ - It's ok to resolve more conflicts than the one that you are asked to resolve.
+ In that case, it's a good habit to ask for a double-check on your resolution
+ by someone who is familiar with the code you touched.
+1. Once you have resolved your conflicts, push to the branch (no force-push)
+1. Assign the merge request to the next person that has to resolve a conflict
+1. If all conflicts are resolved after your resolution is pushed, keep the merge
+ request assigned to you: **you are now responsible for the merge request to be
+ green**
+1. If you need any help, you can ping the current [release managers], or ask in
+ the `#ce-to-ee` Slack channel
+
+A few notes about the automatic CE->EE merge job:
+
+- If a merge is already in progress, the job
+ [doesn't create a new one](https://gitlab.com/gitlab-org/release-tools/-/jobs/43157687).
+- If there is nothing to merge (i.e. EE is up-to-date with CE), the job doesn't
+ create a new one
+- The job posts messages to the `#ce-to-ee` Slack channel to inform what's the
+ current CE->EE merge status (e.g. "A new MR has been created", "A MR is still pending")
[`CE Upstream` merge requests]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests?label_name%5B%5D=CE+upstream
+[release managers]: https://about.gitlab.com/release-managers/
## Always merge EE merge requests before their CE counterparts
@@ -61,7 +76,7 @@ against EE.
1. Tries to apply it to current EE `master`
1. If it applies cleanly, the job succeeds
-In the case where the job fails, it means you should create a `ee-<ce_branch>`
+In the case where the job fails, it means you should create an `ee-<ce_branch>`
or `<ce_branch>-ee` branch, push it to EE and open a merge request against EE
`master`.
At this point if you retry the failing job in your CE merge request, it should
@@ -88,6 +103,99 @@ Notes:
- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html)
to avoid resolving the same conflicts multiple times.
+### Cherry-picking from CE to EE
+
+For avoiding merge conflicts, we use a method of creating equivalent branches
+for CE and EE. If the `ee-compat-check` job fails, this process is required.
+
+This method only requires that you have cloned both CE and EE into your computer.
+If you don't have them yet, please go ahead and clone them:
+
+- Clone CE repo: `git clone git@gitlab.com:gitlab-org/gitlab-ce.git`
+- Clone EE repo: `git clone git@gitlab.com:gitlab-org/gitlab-ee.git`
+
+And the only additional setup we need is to add CE as remote of EE and vice-versa:
+
+- Open two terminal windows, one in CE, and another one in EE:
+ - In EE: `git remote add ce git@gitlab.com:gitlab-org/gitlab-ce.git`
+ - In CE: `git remote add ee git@gitlab.com:gitlab-org/gitlab-ee.git`
+
+That's all setup we need, so that we can cherry-pick a commit from CE to EE, and
+from EE to CE.
+
+Now, every time you create an MR for CE and EE:
+
+1. Open two terminal windows, one in CE, and another one in EE
+1. In the CE terminal:
+ 1. Create the CE branch, e.g., `branch-example`
+ 1. Make your changes and push a commit (commit A)
+ 1. Create the CE merge request in GitLab
+1. In the EE terminal:
+ 1. Create the EE-equivalent branch ending with `-ee`, e.g.,
+ `git checkout -b branch-example-ee`
+ 1. Fetch the CE branch: `git fetch ce branch-example`
+ 1. Cherry-pick the commit A: `git cherry-pick commit-A-SHA`
+ 1. If Git prompts you to fix the conflicts, do a `git status`
+ to check which files contain conflicts, fix them, save the files
+ 1. Add the changes with `git add .` but **DO NOT commit** them
+ 1. Continue cherry-picking: `git cherry-pick --continue`
+ 1. Push to EE: `git push origin branch-example-ee`
+1. Create the EE-equivalent MR and link to the CE MR from the
+description "Ports [CE-MR-LINK] to EE"
+1. Once all the jobs are passing in both CE and EE, you've addressed the
+feedback from your own team, and got them approved, the merge requests can be merged.
+1. When both MRs are ready, the EE merge request will be merged first, and the
+CE-equivalent will be merged next.
+
+**Important notes:**
+
+- The commit SHA can be easily found from the GitLab UI. From a merge request,
+open the tab **Commits** and click the copy icon to copy the commit SHA.
+- To cherry-pick a **commit range**, such as [A > B > C > D] use:
+
+ ```shell
+ git cherry-pick "oldest-commit-SHA^..newest-commit-SHA"
+ ```
+
+ For example, suppose the commit A is the oldest, and its SHA is `4f5e4018c09ed797fdf446b3752f82e46f5af502`,
+ and the commit D is the newest, and its SHA is `80e1c9e56783bd57bd7129828ec20b252ebc0538`.
+ The cherry-pick command will be:
+
+ ```shell
+ git cherry-pick "4f5e4018c09ed797fdf446b3752f82e46f5af502^..80e1c9e56783bd57bd7129828ec20b252ebc0538"
+ ```
+
+- To cherry-pick a **merge commit**, use the flag `-m 1`. For example, suppose that the
+merge commit SHA is `138f5e2f20289bb376caffa0303adb0cac859ce1`:
+
+ ```shell
+ git cherry-pick -m 1 138f5e2f20289bb376caffa0303adb0cac859ce1
+ ```
+- To cherry-pick multiple commits, such as B and D in a range [A > B > C > D], use:
+
+ ```shell
+ git cherry-pick commmit-B-SHA commit-D-SHA
+ ```
+
+ For example, suppose commit B SHA = `4f5e4018c09ed797fdf446b3752f82e46f5af502`,
+ and the commit D SHA = `80e1c9e56783bd57bd7129828ec20b252ebc0538`.
+ The cherry-pick command will be:
+
+ ```shell
+ git cherry-pick 4f5e4018c09ed797fdf446b3752f82e46f5af502 80e1c9e56783bd57bd7129828ec20b252ebc0538
+ ```
+
+ This case is particularly useful when you have a merge commit in a sequence of
+ commits and you want to cherry-pick all but the merge commit.
+
+- If you push more commits to the CE branch, you can safely repeat the procedure
+to cherry-pick them to the EE-equivalent branch. You can do that as many times as
+necessary, using the same CE and EE branches.
+- If you submitted the merge request to the CE repo and the `ee-compat-check` job passed,
+you are not required to submit the EE-equivalent MR, but it's still recommended. If the
+job failed, you are required to submit the EE MR so that you can fix the conflicts in EE
+before merging your changes into CE.
+
---
[Return to Development documentation](README.md)
diff --git a/doc/development/background_migrations.md b/doc/development/background_migrations.md
index fd2b9d0e908..fc1b202b5eb 100644
--- a/doc/development/background_migrations.md
+++ b/doc/development/background_migrations.md
@@ -94,6 +94,18 @@ jobs = [['BackgroundMigrationClassName', [1]],
BackgroundMigrationWorker.bulk_perform_in(5.minutes, jobs)
```
+### Rescheduling background migrations
+
+If one of the background migrations contains a bug that is fixed in a patch
+release, the background migration needs to be rescheduled so the migration would
+be repeated on systems that already performed the initial migration.
+
+When you reschedule the background migration, make sure to turn the original
+scheduling into a no-op by clearing up the `#up` and `#down` methods of the
+migration performing the scheduling. Otherwise the background migration would be
+scheduled multiple times on systems that are upgrading multiple patch releases at
+once.
+
## Cleaning Up
>**Note:**
@@ -123,7 +135,7 @@ roughly be as follows:
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.
+ jobs before continuing.
1. Remove the old column.
## Example
diff --git a/doc/development/changelog.md b/doc/development/changelog.md
index 48cffc0dd18..d5a4acff481 100644
--- a/doc/development/changelog.md
+++ b/doc/development/changelog.md
@@ -12,9 +12,9 @@ following format:
```yaml
---
-title: "Going through change[log]s"
+title: "Change[log]s"
merge_request: 1972
-author: Ozzy Osbourne
+author: Black Sabbath
type: added
```
@@ -44,6 +44,7 @@ the `author` field. GitLab team members **should not**.
- _Any_ contribution from a community member, no matter how small, **may** have
a changelog entry regardless of these guidelines if the contributor wants one.
Example: "Fixed a typo on the search results page. (Jane Smith)"
+- Performance improvements **should** have a changelog entry.
## Writing good changelog entries
@@ -125,9 +126,9 @@ author:
type:
```
If you're working on the GitLab EE repository, the entry will be added to
-`changelogs/unreleased-ee/` instead.
+`ee/changelogs/unreleased/` instead.
-#### Arguments
+### Arguments
| Argument | Shorthand | Purpose |
| ----------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
@@ -279,8 +280,8 @@ After much discussion we settled on the current solution of one file per entry,
and then compiling the entries into the overall `CHANGELOG.md` file during the
[release process].
-[boring solution]: https://about.gitlab.com/handbook/#boring-solutions
-[release managers]: https://gitlab.com/gitlab-org/release-tools/blob/master/doc/release-manager.md
+[boring solution]: https://about.gitlab.com/handbook/values/#boring-solutions
+[release managers]: https://gitlab.com/gitlab-org/release/docs/blob/master/quickstart/release-manager.md
[started brainstorming]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17826
[release process]: https://gitlab.com/gitlab-org/release-tools
diff --git a/doc/development/database_debugging.md b/doc/development/database_debugging.md
index 50eb8005b44..32f392f1303 100644
--- a/doc/development/database_debugging.md
+++ b/doc/development/database_debugging.md
@@ -53,3 +53,38 @@ bundle exec rails db RAILS_ENV=development
- `CREATE TABLE board_labels();`: Create a table called `board_labels`
- `SELECT * FROM schema_migrations WHERE version = '20170926203418';`: Check if a migration was run
- `DELETE FROM schema_migrations WHERE version = '20170926203418';`: Manually remove a migration
+
+
+## FAQ
+
+### `ActiveRecord::PendingMigrationError` with Spring
+
+When running specs with the [Spring preloader](./rake_tasks.md#speed-up-tests-rake-tasks-and-migrations),
+the test database can get into a corrupted state. Trying to run the migration or
+dropping/resetting the test database has no effect.
+
+```sh
+$ bundle exec spring rspec some_spec.rb
+...
+Failure/Error: ActiveRecord::Migration.maintain_test_schema!
+
+ActiveRecord::PendingMigrationError:
+
+
+ Migrations are pending. To resolve this issue, run:
+
+ bin/rake db:migrate RAILS_ENV=test
+# ~/.rvm/gems/ruby-2.3.3/gems/activerecord-4.2.10/lib/active_record/migration.rb:392:in `check_pending!'
+...
+0 examples, 0 failures, 1 error occurred outside of examples
+```
+
+To resolve, you can kill the spring server and app that lives between spec runs.
+
+```sh
+$ ps aux | grep spring
+eric 87304 1.3 2.9 3080836 482596 ?? Ss 10:12AM 4:08.36 spring app | gitlab | started 6 hours ago | test mode
+eric 37709 0.0 0.0 2518640 7524 s006 S Wed11AM 0:00.79 spring server | gitlab | started 29 hours ago
+$ kill 87304
+$ kill 37709
+```
diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md
index 9cb1f708a6a..41e3412c7ff 100644
--- a/doc/development/doc_styleguide.md
+++ b/doc/development/doc_styleguide.md
@@ -1,91 +1,28 @@
-# Documentation styleguide
+# Documentation style guidelines
-This styleguide recommends best practices to improve documentation and to keep
-it organized and easy to find.
+The documentation style guide defines the markup structure used in
+GitLab documentation. Check the
+[documentation guidelines](writing_documentation.md) for general development instructions.
-See also [writing documentation](writing_documentation.md).
-
-## Location and naming of documents
-
->**Note:**
-These guidelines derive from the discussion taken place in issue [#3349][ce-3349].
-
-The documentation hierarchy can be vastly improved by providing a better layout
-and organization of directories.
-
-Having a structured document layout, we will be able to have meaningful URLs
-like `docs.gitlab.com/user/project/merge_requests.html`. With this pattern,
-you can immediately tell that you are navigating a user related documentation
-and is about the project and its merge requests.
-
-Do not create summaries of similar types of content (e.g. an index of all articles, videos, etc.),
-rather organise content by its subject (e.g. everything related to CI goes together)
-and cross-link between any related content.
-
-The table below shows what kind of documentation goes where.
-
-| Directory | What belongs here |
-| --------- | -------------- |
-| `doc/user/` | User related documentation. Anything that can be done within the GitLab UI goes here including `/admin`. |
-| `doc/administration/` | Documentation that requires the user to have access to the server where GitLab is installed. The admin settings that can be accessed via GitLab's interface go under `doc/user/admin_area/`. |
-| `doc/api/` | API related documentation. |
-| `doc/development/` | Documentation related to the development of GitLab. Any styleguides should go here. |
-| `doc/legal/` | Legal documents about contributing to GitLab. |
-| `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). |
-| `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. |
-| `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`): all resources for that topic (user and admin documentation, articles, and third-party docs) |
-| `doc/articles/` | [Technical Articles](writing_documentation.md#technical-articles): user guides, admin guides, technical overviews, tutorials (`doc/articles/article-title/index.md`). |
-
----
-
-**General rules:**
-
-1. The correct naming and location of a new document, is a combination
- of the relative URL of the document in question and the GitLab Map design
- that is used for UX purposes ([source][graffle], [image][gitlab-map]).
-1. When creating a new document and it has more than one word in its name,
- make sure to use underscores instead of spaces or dashes (`-`). For example,
- a proper naming would be `import_projects_from_github.md`. The same rule
- applies to images.
-1. There are four main directories, `user`, `administration`, `api` and `development`.
-1. The `doc/user/` directory has five main subdirectories: `project/`, `group/`,
- `profile/`, `dashboard/` and `admin_area/`.
- 1. `doc/user/project/` should contain all project related documentation.
- 1. `doc/user/group/` should contain all group related documentation.
- 1. `doc/user/profile/` should contain all profile related documentation.
- Every page you would navigate under `/profile` should have its own document,
- i.e. `account.md`, `applications.md`, `emails.md`, etc.
- 1. `doc/user/dashboard/` should contain all dashboard related documentation.
- 1. `doc/user/admin_area/` should contain all admin related documentation
- describing what can be achieved by accessing GitLab's admin interface
- (_not to be confused with `doc/administration` where server access is
- required_).
- 1. Every category under `/admin/application_settings` should have its
- own document located at `doc/user/admin_area/settings/`. For example,
- the **Visibility and Access Controls** category should have a document
- located at `doc/user/admin_area/settings/visibility_and_access_controls.md`.
-1. The `doc/topics/` directory holds topic-related technical content. Create
- `doc/topics/topic-name/subtopic-name/index.md` when subtopics become necessary.
- General user- and admin- related documentation, should be placed accordingly.
-1. For technical articles, place their images under `doc/articles/article-title/img/`.
-
----
-
-If you are unsure where a document should live, you can ping `@axil` in your
-merge request.
+Check the GitLab hanbook for the [writing styles guidelines](https://about.gitlab.com/handbook/communication/#writing-style-guidelines).
## Text
-- Split up long lines, this makes it much easier to review and edit. Only
+- Split up long lines (wrap text), this makes it much easier to review and edit. Only
double line breaks are shown as a full line break in [GitLab markdown][gfm].
80-100 characters is a good line length
-- Make sure that the documentation is added in the correct directory and that
+- Make sure that the documentation is added in the correct
+ [directory](writing_documentation.md#documentation-directory-structure) and that
there's a link to it somewhere useful
- Do not duplicate information
- Be brief and clear
- Unless there's a logical reason not to, add documents in alphabetical order
- Write in US English
- Use [single spaces][] instead of double spaces
+- Jump a line between different markups (e.g., after every paragraph, hearder, list, etc)
+- Capitalize "G" and "L" in GitLab
+- Capitalize feature, products, and methods names. E.g.: GitLab Runner, Geo,
+Issue Boards, Git, Prometheus, Continuous Integration.
## Formatting
@@ -105,11 +42,13 @@ merge request.
links shift too, which eventually leads to dead links. If you think it is
compelling to add numbers in headings, make sure to at least discuss it with
someone in the Merge Request
+- [Avoid using symbols and special chars](https://gitlab.com/gitlab-com/gitlab-docs/issues/84)
+ in headers. Whenever possible, they should be plain and short text.
- Avoid adding things that show ephemeral statuses. For example, if a feature is
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`.
+ grammatically and syntactically correct. Mention one or all
+ of the following GitLab members for a review: `@axil` or `@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
@@ -123,71 +62,18 @@ merge request.
you can use `[Text][identifier]` and at the bottom of the section or the
document add: `[identifier]: https://example.com`, in which case, we do
encourage you to also add an alternative text: `[identifier]: https://example.com "Alternative text"` that appears when hovering your mouse on a link.
-
-### Linking to inline docs
-
-Sometimes it's needed to link to the built-in documentation that GitLab provides
-under `/help`. This is normally done in files inside the `app/views/` directory
-with the help of the `help_page_path` helper method.
-
-In its simplest form, the HAML code to generate a link to the `/help` page is:
-
-```haml
-= link_to 'Help page', help_page_path('user/permissions')
-```
-
-The `help_page_path` contains the path to the document you want to link to with
-the following conventions:
-
-- it is relative to the `doc/` directory in the GitLab repository
-- the `.md` extension must be omitted
-- it must not end with a slash (`/`)
-
-Below are some special cases where should be used depending on the context.
-You can combine one or more of the following:
-
-1. **Linking to an anchor link.** Use `anchor` as part of the `help_page_path`
- method:
-
- ```haml
- = link_to 'Help page', help_page_path('user/permissions', anchor: 'anchor-link')
- ```
-
-1. **Opening links in a new tab.** This should be the default behavior:
-
- ```haml
- = link_to 'Help page', help_page_path('user/permissions'), target: '_blank'
- ```
-
-1. **Linking to a circle icon.** Usually used in settings where a long
- description cannot be used, like near checkboxes. You can basically use
- any font awesome icon, but prefer the `question-circle`:
-
- ```haml
- = link_to icon('question-circle'), help_page_path('user/permissions')
- ```
-
-1. **Using a button link.** Useful in places where text would be out of context
- with the rest of the page layout:
-
- ```haml
- = link_to 'Help page', help_page_path('user/permissions'), class: 'btn btn-info'
- ```
-
-1. **Using links inline of some text.**
-
- ```haml
- Description to #{link_to 'Help page', help_page_path('user/permissions')}.
- ```
-
-1. **Adding a period at the end of the sentence.** Useful when you don't want
- the period to be part of the link:
-
- ```haml
- = succeed '.' do
- Learn more in the
- = link_to 'Help page', help_page_path('user/permissions')
- ```
+- To link to internal documentation, use relative links, not full URLs. Use `../` to
+ navigate tp high-level directories, and always add the file name `file.md` at the
+ end of the link with the `.md` extension, not `.html`.
+ Example: instead of `[text](../../merge_requests/)`, use
+ `[text](../../merge_requests/index.md)` or, `[text](../../ci/README.md)`, or,
+ for anchor links, `[text](../../ci/README.md#examples)`.
+ Using the markdown extension is necessary for the [`/help`](writing_documentation.md#gitlab-help)
+ section of GitLab.
+- To link from CE to EE-only documentation, use the EE-only doc full URL.
+- Use [meaningful anchor texts](https://www.futurehosting.com/blog/links-should-have-meaningful-anchor-text-heres-why/).
+ E.g., instead of writing something like `Read more about GitLab Issue Boards [here](LINK)`,
+ write `Read more about [GitLab Issue Boards](LINK)`.
## Images
@@ -203,7 +89,7 @@ You can combine one or more of the following:
- 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.
+- Compress gifs with <https://ezgif.com/optimize> or similar tool.
- Images should be used (only when necessary) to _illustrate_ the description
of a process, not to _replace_ it.
@@ -224,7 +110,7 @@ Inside the document:
- Notes should be quoted with the word `Note:` being bold. Use this form:
- ```
+ ```md
>**Note:**
This is something to note.
```
@@ -236,114 +122,70 @@ Inside the document:
If the note spans across multiple lines it's OK to split the line.
-## New features
+## Specific sections and terms
+
+To mention and/or reference specific terms in GitLab, please follow the styles
+below.
+
+### GitLab versions and tiers
- Every piece of documentation that comes with a new feature should declare the
GitLab version that feature got introduced. Right below the heading add a
note:
- ```
+ ```md
> Introduced in GitLab 8.3.
```
-- If possible every feature should have a link to the MR that introduced it.
+- If possible every feature should have a link to the MR, issue, or epic that introduced it.
The above note would be then transformed to:
- ```
+ ```md
> [Introduced][ce-1242] in GitLab 8.3.
```
, where the [link identifier](#links) is named after the repository (CE) and
the MR number.
-- If the feature is only in GitLab Enterprise Edition, don't forget to mention
- it, like:
+- If the feature is only available in GitLab Enterprise Edition, don't forget to mention
+ the [paid tier](https://about.gitlab.com/handbook/marketing/product-marketing/#tiers)
+ the feature is available in:
- ```
- > Introduced in GitLab Enterprise Edition 8.3.
+ ```md
+ > [Introduced][ee-1234] in [GitLab Starter](https://about.gitlab.com/products/) 8.3.
```
Otherwise, leave this mention out.
-## References
-
-- **GitLab Restart:**
- There are many cases that a restart/reconfigure of GitLab is required. To
- avoid duplication, link to the special document that can be found in
- [`doc/administration/restart_gitlab.md`][doc-restart]. Usually the text will
- read like:
-
- ```
- Save the file and [reconfigure GitLab](../administration/restart_gitlab.md)
- for the changes to take effect.
- ```
- If the document you are editing resides in a place other than the GitLab CE/EE
- `doc/` directory, instead of the relative link, use the full path:
- `http://docs.gitlab.com/ce/administration/restart_gitlab.html`.
- Replace `reconfigure` with `restart` where appropriate.
-
-## Installation guide
-
-- **Ruby:**
- In [step 2 of the installation guide](../install/installation.md#2-ruby),
- we install Ruby from source. Whenever there is a new version that needs to
- be updated, remember to change it throughout the codeblock and also replace
- the sha256sum (it can be found in the [downloads page][ruby-dl] of the Ruby
- website).
-
-[ruby-dl]: https://www.ruby-lang.org/en/downloads/ "Ruby download website"
+### GitLab Restart
-## Changing document location
+There are many cases that a restart/reconfigure of GitLab is required. To
+avoid duplication, link to the special document that can be found in
+[`doc/administration/restart_gitlab.md`][doc-restart]. Usually the text will
+read like:
-Changing a document's location is not to be taken lightly. Remember that the
-documentation is available to all installations under `help/` and not only to
-GitLab.com or http://docs.gitlab.com. Make sure this is discussed with the
-Documentation team beforehand.
+ ```
+ Save the file and [reconfigure GitLab](../administration/restart_gitlab.md)
+ for the changes to take effect.
+ ```
-If you indeed need to change a document's location, do NOT remove the old
-document, but rather replace all of its contents with a new line:
+If the document you are editing resides in a place other than the GitLab CE/EE
+`doc/` directory, instead of the relative link, use the full path:
+`http://docs.gitlab.com/ce/administration/restart_gitlab.html`.
+Replace `reconfigure` with `restart` where appropriate.
-```
-This document was moved to [another location](path/to/new_doc.md).
-```
+### Installation guide
-where `path/to/new_doc.md` is the relative path to the root directory `doc/`.
-
----
+**Ruby:**
+In [step 2 of the installation guide](../install/installation.md#2-ruby),
+we install Ruby from source. Whenever there is a new version that needs to
+be updated, remember to change it throughout the codeblock and also replace
+the sha256sum (it can be found in the [downloads page][ruby-dl] of the Ruby
+website).
-For example, if you were to move `doc/workflow/lfs/lfs_administration.md` to
-`doc/administration/lfs.md`, then the steps would be:
-
-1. Copy `doc/workflow/lfs/lfs_administration.md` to `doc/administration/lfs.md`
-1. Replace the contents of `doc/workflow/lfs/lfs_administration.md` with:
-
- ```
- This document was moved to [another location](../../administration/lfs.md).
- ```
-
-1. Find and replace any occurrences of the old location with the new one.
- A quick way to find them is to use `git grep`. First go to the root directory
- where you cloned the `gitlab-ce` repository and then do:
-
- ```
- git grep -n "workflow/lfs/lfs_administration"
- git grep -n "lfs/lfs_administration"
- ```
-
-Things to note:
-
-- Since we also use inline documentation, except for the documentation itself,
- the document might also be referenced in the views of GitLab (`app/`) which will
- render when visiting `/help`, and sometimes in the testing suite (`spec/`).
-- The above `git grep` command will search recursively in the directory you run
- it in for `workflow/lfs/lfs_administration` and `lfs/lfs_administration`
- and will print the file and the line where this file is mentioned.
- You may ask why the two greps. Since we use relative paths to link to
- documentation, sometimes it might be useful to search a path deeper.
-- The `*.md` extension is not used when a document is linked to GitLab's
- built-in help page, that's why we omit it in `git grep`.
+[ruby-dl]: https://www.ruby-lang.org/en/downloads/ "Ruby download website"
-## Configuration documentation for source and Omnibus installations
+### Configuration documentation for source and Omnibus installations
GitLab currently officially supports two installation methods: installations
from source and Omnibus packages installations.
@@ -360,7 +202,7 @@ When there is a list of steps to perform, usually that entails editing the
configuration file and reconfiguring/restarting GitLab. In such case, follow
the style below as a guide:
-````
+```md
**For Omnibus installations**
1. Edit `/etc/gitlab/gitlab.rb`:
@@ -387,19 +229,19 @@ the style below as a guide:
[reconfigure]: path/to/administration/restart_gitlab.md#omnibus-gitlab-reconfigure
[restart]: path/to/administration/restart_gitlab.md#installations-from-source
-````
+```
In this case:
- before each step list the installation method is declared in bold
-- three dashes (`---`) are used to create an horizontal line and separate the
+- three dashes (`---`) are used to create a horizontal line and separate the
two methods
- the code blocks are indented one or more spaces under the list item to render
correctly
- different highlighting languages are used for each config in the code block
- the [references](#references) guide is used for reconfigure/restart
-## Fake tokens
+### Fake tokens
There may be times where a token is needed to demonstrate an API call using
cURL or a secret variable used in CI. It is strongly advised not to use real
@@ -422,7 +264,7 @@ You can use the following fake tokens as examples.
| Health check token | `Tu7BgjR9qeZTEyRzGG2P` |
| Request profile token | `7VgpS4Ax5utVD2esNstz` |
-## API
+### API
Here is a list of must-have items. Use them in the exact order that appears
on this document. Further explanation is given below.
@@ -438,10 +280,10 @@ on this document. Further explanation is given below.
- Every method must have a cURL example.
- Every method must have a response body (in JSON format).
-### Method description
+#### Method description
Use the following table headers to describe the methods. Attributes should
-always be in code blocks using backticks (`).
+always be in code blocks using backticks (``` ` ```).
```
| Attribute | Type | Required | Description |
@@ -454,7 +296,7 @@ Rendered example:
| --------- | ---- | -------- | ----------- |
| `user` | string | yes | The GitLab username |
-### cURL commands
+#### cURL commands
- Use `https://gitlab.example.com/api/v4/` as an endpoint.
- Wherever needed use this personal access token: `9koXpg98eAheJpvBs5tK`.
@@ -471,11 +313,11 @@ Rendered example:
| `-X PUT` | Use this method when updating existing objects |
| `-X DELETE` | Use this method when removing existing objects |
-### cURL Examples
+#### cURL Examples
Below is a set of [cURL][] examples that you can use in the API documentation.
-#### Simple cURL command
+##### Simple cURL command
Get the details of a group:
@@ -483,7 +325,7 @@ Get the details of a group:
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/gitlab-org
```
-#### cURL example with parameters passed in the URL
+##### cURL example with parameters passed in the URL
Create a new project under the authenticated user's namespace:
@@ -491,7 +333,7 @@ Create a new project under the authenticated user's namespace:
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects?name=foo"
```
-#### Post data using cURL's --data
+##### Post data using cURL's --data
Instead of using `-X POST` and appending the parameters to the URI, you can use
cURL's `--data` option. The example below will create a new project `foo` under
@@ -501,7 +343,7 @@ the authenticated user's namespace.
curl --data "name=foo" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects"
```
-#### Post data using JSON content
+##### Post data using JSON content
> **Note:** In this example we create a new group. Watch carefully the single
and double quotes.
@@ -510,7 +352,7 @@ and double quotes.
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "Content-Type: application/json" --data '{"path": "my-group", "name": "My group"}' https://gitlab.example.com/api/v4/groups
```
-#### Post data using form-data
+##### Post data using form-data
Instead of using JSON or urlencode you can use multipart/form-data which
properly handles data encoding:
@@ -522,7 +364,7 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "title
The above example is run by and administrator and will add an SSH public key
titled ssh-key to user's account which has an id of 25.
-#### Escape special characters
+##### Escape special characters
Spaces or slashes (`/`) may sometimes result to errors, thus it is recommended
to escape them when possible. In the example below we create a new issue which
@@ -535,7 +377,7 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitl
Use `%2F` for slashes (`/`).
-#### Pass arrays to API calls
+##### Pass arrays to API calls
The GitLab API sometimes accepts arrays of strings or integers. For example, to
restrict the sign-up e-mail domains of a GitLab instance to `*.example.com` and
@@ -550,6 +392,3 @@ curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "domain
[gfm]: http://docs.gitlab.com/ce/user/markdown.html#newlines "GitLab flavored markdown documentation"
[ce-1242]: https://gitlab.com/gitlab-org/gitlab-ce/issues/1242
[doc-restart]: ../administration/restart_gitlab.md "GitLab restart documentation"
-[ce-3349]: https://gitlab.com/gitlab-org/gitlab-ce/issues/3349 "Documentation restructure"
-[graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/d8d39f4a87b90fb9ae89ca12dc565347b4900d5e/production/resources/gitlab-map.graffle
-[gitlab-map]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png
diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md
index 1af839a27e1..287143d6255 100644
--- a/doc/development/ee_features.md
+++ b/doc/development/ee_features.md
@@ -28,12 +28,65 @@ we still need to merge changes from GitLab CE to EE. To help us get there,
we should make sure that we no longer edit CE files in place in order to
implement EE features.
-Instead, all EE codes should be put inside the `ee/` top-level directory, and
-tests should be put inside `spec/ee/`. We don't use `ee/spec` for now due to
-technical limitation. The rest of codes should be as close as to the CE files.
+Instead, all EE code should be put inside the `ee/` top-level directory. The
+rest of the code should be as close to the CE files as possible.
[single code base]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2952#note_41016454
+### EE-specific comments
+
+When complete separation can't be achieved with the `ee/` directory, you can wrap
+code in EE specific comments to designate the difference from CE/EE and add
+some context for someone resolving a conflict.
+
+```rb
+# EE-specific start
+stub_licensed_features(variable_environment_scope: true)
+# EE specific end
+```
+
+```haml
+-# EE-specific start
+= render 'ci/variables/environment_scope', form_field: form_field, variable: variable
+-# EE-specific end
+```
+
+EE-specific comments should not be backported to CE.
+
+### Detection of EE-only files
+
+For each commit (except on `master`), the `ee-files-location-check` CI job tries
+to detect if there are any new files that are EE-only. If any file is detected,
+the job fails with an explanation of why and what to do to make it pass.
+
+Basically, the fix is simple: `git mv <file> ee/<file>`.
+
+#### How to name your branches?
+
+For any EE branch, the job will try to detect its CE counterpart by removing any
+`ee-` prefix or `-ee` suffix from the EE branch name, and matching the last
+branch that contains it.
+
+For instance, from the EE branch `new-shiny-feature-ee` (or
+`ee-new-shiny-feature`), the job would find the corresponding CE branches:
+
+- `new-shiny-feature`
+- `ce-new-shiny-feature`
+- `new-shiny-feature-ce`
+- `my-super-new-shiny-feature-in-ce`
+
+#### Whitelist some EE-only files that cannot be moved to `ee/`
+
+The `ee-files-location-check` CI job provides a whitelist of files or folders
+that cannot or should not be moved to `ee/`. Feel free to open an issue to
+discuss adding a new file/folder to this whitelist.
+
+For instance, it was decided that moving EE-only files from `qa/` to `ee/qa/`
+would make it difficult to build the `gitLab-{ce,ee}-qa` Docker images and it
+was [not worth the complexity].
+
+[not worth the complexity]: https://gitlab.com/gitlab-org/gitlab-ee/issues/4997#note_59764702
+
### EE-only features
If the feature being developed is not present in any form in CE, we don't
@@ -53,6 +106,11 @@ is applied not only to models. Here's a list of other examples:
- `ee/app/validators/foo_attr_validator.rb`
- `ee/app/workers/foo_worker.rb`
+This works because for every path that are present in CE's eager-load/auto-load
+paths, we add the same `ee/`-prepended path in [`config/application.rb`].
+
+[`config/application.rb`]: https://gitlab.com/gitlab-org/gitlab-ee/blob/d278b76d6600a0e27d8019a0be27971ba23ab640/config/application.rb#L41-51
+
### EE features based on CE features
For features that build on existing CE features, write a module in the
@@ -87,9 +145,9 @@ still having access the class's implementation with `super`.
There are a few gotchas with it:
-- you should always add a `raise NotImplementedError unless defined?(super)`
- guard clause in the "overrider" method to ensure that if the method gets
- renamed in CE, the EE override won't be silently forgotten.
+- you should always [`extend ::Gitlab::Utils::Override`] and use `override` to
+ guard the "overrider" method to ensure that if the method gets renamed in
+ CE, the EE override won't be silently forgotten.
- when the "overrider" would add a line in the middle of the CE
implementation, you should refactor the CE method and split it in
smaller methods. Or create a "hook" method that is empty in CE,
@@ -134,6 +192,9 @@ There are a few gotchas with it:
guards:
``` ruby
module EE::Base
+ extend ::Gitlab::Utils::Override
+
+ override :do_something
def do_something
# Follow the above pattern to call super and extend it
end
@@ -174,10 +235,11 @@ implementation:
```ruby
module EE
- class ApplicationController
- def after_sign_out_path_for(resource)
- raise NotImplementedError unless defined?(super)
+ module ApplicationController
+ extend ::Gitlab::Utils::Override
+ override :after_sign_out_path_for
+ def after_sign_out_path_for(resource)
if Gitlab::Geo.secondary?
Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state)
else
@@ -188,6 +250,8 @@ module EE
end
```
+[`extend ::Gitlab::Utils::Override`]: utilities.md#override
+
#### Use self-descriptive wrapper methods
When it's not possible/logical to modify the implementation of a
@@ -208,8 +272,8 @@ end
In EE, the implementation `ee/app/models/ee/users.rb` would be:
```ruby
+override :full_private_access?
def full_private_access?
- raise NotImplementedError unless defined?(super)
super || auditor?
end
```
@@ -306,43 +370,281 @@ class beneath the `EE` module just as you would normally.
For example, if CE has LDAP classes in `lib/gitlab/ldap/` then you would place
EE-specific LDAP classes in `ee/lib/ee/gitlab/ldap`.
+### Code in `lib/api/`
+
+It can be very tricky to extend EE features by a single line of `prepend`,
+and for each different [Grape](https://github.com/ruby-grape/grape) feature,
+we might need different strategies to extend it. To apply different strategies
+easily, we would use `extend ActiveSupport::Concern` in the EE module.
+
+Put the EE module files following
+[EE features based on CE features](#ee-features-based-on-ce-features).
+
+#### EE API routes
+
+For EE API routes, we put them in a `prepended` block:
+
+``` ruby
+module EE
+ module API
+ module MergeRequests
+ extend ActiveSupport::Concern
+
+ prepended do
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: ::API::API::PROJECT_ENDPOINT_REQUIREMENTS do
+ # ...
+ end
+ end
+ end
+ end
+end
+```
+
+Note that due to namespace differences, we need to use the full qualifier for some
+constants.
+
+#### EE params
+
+We can define `params` and utilize `use` in another `params` definition to
+include params defined in EE. However, we need to define the "interface" first
+in CE in order for EE to override it. We don't have to do this in other places
+due to `prepend`, but Grape is complex internally and we couldn't easily do
+that, so we'll follow regular object-oriented practices that we define the
+interface first here.
+
+For example, suppose we have a few more optional params for EE, given this CE
+API code:
+
+``` ruby
+module API
+ class MergeRequests < Grape::API
+ # EE::API::MergeRequests would override the following helpers
+ helpers do
+ params :optional_params_ee do
+ end
+ end
+
+ prepend EE::API::MergeRequests
+
+ params :optional_params do
+ # CE specific params go here...
+
+ use :optional_params_ee
+ end
+ end
+end
+```
+
+And then we could override it in EE module:
+
+``` ruby
+module EE
+ module API
+ module MergeRequests
+ extend ActiveSupport::Concern
+
+ prepended do
+ helpers do
+ params :optional_params_ee do
+ # EE specific params go here...
+ end
+ end
+ end
+ end
+ end
+end
+```
+
+This way, the only difference between CE and EE for that API file would be
+`prepend EE::API::MergeRequests`.
+
+#### EE helpers
+
+To make it easy for an EE module to override the CE helpers, we need to define
+those helpers we want to extend first. Try to do that immediately after the
+class definition to make it easy and clear:
+
+``` ruby
+module API
+ class JobArtifacts < Grape::API
+ # EE::API::JobArtifacts would override the following helpers
+ helpers do
+ def authorize_download_artifacts!
+ authorize_read_builds!
+ end
+ end
+
+ prepend EE::API::JobArtifacts
+ end
+end
+```
+
+And then we can follow regular object-oriented practices to override it:
+
+``` ruby
+module EE
+ module API
+ module JobArtifacts
+ extend ActiveSupport::Concern
+
+ prepended do
+ helpers do
+ def authorize_download_artifacts!
+ super
+ check_cross_project_pipelines_feature!
+ end
+ end
+ end
+ end
+ end
+end
+```
+
+#### EE-specific behaviour
+
+Sometimes we need EE-specific behaviour in some of the APIs. Normally we could
+use EE methods to override CE methods, however API routes are not methods and
+therefore can't be simply overridden. We need to extract them into a standalone
+method, or introduce some "hooks" where we could inject behavior in the CE
+route. Something like this:
+
+``` ruby
+module API
+ class MergeRequests < Grape::API
+ helpers do
+ # EE::API::MergeRequests would override the following helpers
+ def update_merge_request_ee(merge_request)
+ end
+ end
+
+ prepend EE::API::MergeRequests
+
+ put ':id/merge_requests/:merge_request_iid/merge' do
+ merge_request = find_project_merge_request(params[:merge_request_iid])
+
+ # ...
+
+ update_merge_request_ee(merge_request)
+
+ # ...
+ end
+ end
+end
+```
+
+Note that `update_merge_request_ee` doesn't do anything in CE, but
+then we could override it in EE:
+
+``` ruby
+module EE
+ module API
+ module MergeRequests
+ extend ActiveSupport::Concern
+
+ prepended do
+ helpers do
+ def update_merge_request_ee(merge_request)
+ # ...
+ end
+ end
+ end
+ end
+ end
+end
+```
+
+#### EE `route_setting`
+
+It's very hard to extend this in an EE module, and this is simply storing
+some meta-data for a particular route. Given that, we could simply leave the
+EE `route_setting` in CE as it won't hurt and we are just not going to use
+those meta-data in CE.
+
+We could revisit this policy when we're using `route_setting` more and whether
+or not we really need to extend it from EE. For now we're not using it much.
+
+#### Utilizing class methods for setting up EE-specific data
+
+Sometimes we need to use different arguments for a particular API route, and we
+can't easily extend it with an EE module because Grape has different context in
+different blocks. In order to overcome this, we could use class methods from the
+API class.
+
+For example, in one place we need to pass an extra argument to
+`at_least_one_of` so that the API could consider an EE-only argument as the
+least argument. This is not quite beautiful but it's working:
+
+``` ruby
+module API
+ class MergeRequests < Grape::API
+ def self.update_params_at_least_one_of
+ %i[
+ assignee_id
+ description
+ ]
+ end
+
+ prepend EE::API::MergeRequests
+
+ params do
+ at_least_one_of(*::API::MergeRequests.update_params_at_least_one_of)
+ end
+ end
+end
+```
+
+And then we could easily extend that argument in the EE class method:
+
+``` ruby
+module EE
+ module API
+ module MergeRequests
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def update_params_at_least_one_of
+ super.push(*%i[
+ squash
+ ])
+ end
+ end
+ end
+ end
+end
+```
+
+It could be annoying if we need this for a lot of routes, but it might be the
+simplest solution right now.
+
### Code in `spec/`
When you're testing EE-only features, avoid adding examples to the
existing CE specs. Also do no change existing CE examples, since they
should remain working as-is when EE is running without a license.
-Instead place EE specs in the `spec/ee/spec` folder.
+Instead place EE specs in the `ee/spec` folder.
## JavaScript code in `assets/javascripts/`
-To separate EE-specific JS-files we can also move the files into an `ee` folder.
+To separate EE-specific JS-files we should also move the files into an `ee` folder.
For example there can be an
`app/assets/javascripts/protected_branches/protected_branches_bundle.js` and an
EE counterpart
`ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js`.
-That way we can create a separate webpack bundle in `webpack.config.js`:
-
-```javascript
- protected_branches: '~/protected_branches',
- ee_protected_branches: 'ee/protected_branches/protected_branches_bundle.js',
-```
-
-With the separate bundle in place, we can decide which bundle to load inside the
-view, using the `page_specific_javascript_bundle_tag` helper.
-
-```haml
-- content_for :page_specific_javascripts do
- = page_specific_javascript_bundle_tag('protected_branches')
-```
+See the frontend guide [performance section](./fe_guide/performance.md) for
+information on managing page-specific javascript within EE.
## SCSS code in `assets/stylesheets`
To separate EE-specific styles in SCSS files, if a component you're adding styles for
is limited to only EE, it is better to have a separate SCSS file in appropriate directory
within `app/assets/stylesheets`.
+See [backporting changes](#backporting-changes) for instructions on how to merge changes safely.
In some cases, this is not entirely possible or creating dedicated SCSS file is an overkill,
e.g. a text style of some component is different for EE. In such cases,
@@ -373,14 +675,28 @@ to avoid conflicts during CE to EE merge.
}
}
-/* EE-specific styles */
+// EE-specific start
.section-body.ee-section-body {
.section-title {
background: $gl-header-color-cyan;
}
}
+// EE-specific end
```
+### Backporting changes from EE to CE
+
+When working in EE-specific features, you might have to tweak a few files that are not EE-specific. Here is a workflow to make sure those changes end up backported safely into CE too.
+(This approach does not refer to changes introduced via [csslab](https://gitlab.com/gitlab-org/csslab/).)
+
+1. **Make your changes in the EE branch.** If possible, keep a separated commit (to be squashed) to help backporting and review.
+1. **Open merge request to EE project.**
+1. **Apply the changes you made to CE files in a branch of the CE project.** (Tip: Use `patch` with the diff from your commit in EE branch)
+1. **Open merge request to CE project**, referring it's a backport of EE changes and link to MR open in EE.
+1. Once EE MR is merged, the MR towards CE can be merged. **But not before**.
+
+**Note:** regarding SCSS, make sure the files living outside `/ee/` don't diverge between CE and EE projects.
+
## gitlab-svgs
Conflicts in `app/assets/images/icons.json` or `app/assets/images/icons.svg` can
diff --git a/doc/development/emails.md b/doc/development/emails.md
index 18f47f44cb5..73cac82caf0 100644
--- a/doc/development/emails.md
+++ b/doc/development/emails.md
@@ -18,6 +18,80 @@ See the [Rails guides] for more info.
[previews]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/spec/mailers/previews
[Rails guides]: http://guides.rubyonrails.org/action_mailer_basics.html#previewing-emails
+## Incoming email
+
+1. Go to the GitLab installation directory.
+
+1. Find the `incoming_email` section in `config/gitlab.yml`, enable the
+ feature and fill in the details for your specific IMAP server and email
+ account:
+
+ Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com
+
+ ```yaml
+ incoming_email:
+ enabled: true
+
+ # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to.
+ # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
+ address: "gitlab-incoming+%{key}@gmail.com"
+
+ # Email account username
+ # With third party providers, this is usually the full email address.
+ # With self-hosted email servers, this is usually the user part of the email address.
+ user: "gitlab-incoming@gmail.com"
+ # Email account password
+ password: "[REDACTED]"
+
+ # IMAP server host
+ host: "imap.gmail.com"
+ # IMAP server port
+ port: 993
+ # Whether the IMAP server uses SSL
+ ssl: true
+ # Whether the IMAP server uses StartTLS
+ start_tls: false
+
+ # The mailbox where incoming mail will end up. Usually "inbox".
+ mailbox: "inbox"
+ # The IDLE command timeout.
+ idle_timeout: 60
+ ```
+
+ As mentioned, the part after `+` is ignored, and this will end up in the mailbox for `gitlab-incoming@gmail.com`.
+
+1. Run this command in the GitLab root directory to launch `mail_room`:
+
+ ```sh
+ bundle exec mail_room -q -c config/mail_room.yml
+ ```
+
+1. Verify that everything is configured correctly:
+
+ ```sh
+ bundle exec rake gitlab:incoming_email:check RAILS_ENV=development
+ ```
+
+1. Reply by email should now be working.
+
+## Email namespace
+
+If you need to implement a new feature which requires a new email handler, follow these rules:
+
+ - You must choose a namespace. The namespace cannot contain `/` or `+`, and cannot match `\h{16}`.
+ - If your feature is related to a project, you will append the namespace **after** the project path, separated by a `+`
+ - If you have different actions in the namespace, you add the actions **after** the namespace separated by a `+`. The action name cannot contain `/` or `+`, , and cannot match `\h{16}`.
+ - You will register your handlers in `lib/gitlab/email/handler.rb`
+
+Therefore, these are the only valid formats for an email handler:
+
+ - `path/to/project+namespace`
+ - `path/to/project+namespace+action`
+ - `namespace`
+ - `namespace+action`
+
+Please note that `path/to/project` is used in GitLab Premium as handler for the Service Desk feature.
+
---
[Return to Development documentation](README.md)
diff --git a/doc/development/fe_guide/axios.md b/doc/development/fe_guide/axios.md
index 1daa6758171..0d9397c3bd5 100644
--- a/doc/development/fe_guide/axios.md
+++ b/doc/development/fe_guide/axios.md
@@ -27,10 +27,23 @@ This exported module should be used instead of directly using `axios` to ensure
});
```
-## Mock axios response on tests
+## Mock axios response in tests
-To help us mock the responses we need we use [axios-mock-adapter][axios-mock-adapter]
+To help us mock the responses we are using [axios-mock-adapter][axios-mock-adapter].
+Advantages over [`spyOn()`]:
+
+- no need to create response objects
+- does not allow call through (which we want to avoid)
+- simple API to test error cases
+- provides `replyOnce()` to allow for different responses
+
+We have also decided against using [axios interceptors] because they are not suitable for mocking.
+
+[axios interceptors]: https://github.com/axios/axios#interceptors
+[`spyOn()`]: https://jasmine.github.io/api/edge/global.html#spyOn
+
+### Example
```javascript
import axios from '~/lib/utils/axios_utils';
@@ -50,13 +63,13 @@ To help us mock the responses we need we use [axios-mock-adapter][axios-mock-ada
});
afterEach(() => {
- mock.reset();
+ mock.restore();
});
```
-### Mock poll requests on tests with axios
+### Mock poll requests in tests with axios
-Because polling function requires an header object, we need to always include an object as the third argument:
+Because polling function requires a header object, we need to always include an object as the third argument:
```javascript
mock.onGet('/users').reply(200, { foo: 'bar' }, {});
diff --git a/doc/development/fe_guide/components.md b/doc/development/fe_guide/components.md
new file mode 100644
index 00000000000..66a8abe42f7
--- /dev/null
+++ b/doc/development/fe_guide/components.md
@@ -0,0 +1,61 @@
+# Components
+
+## Contents
+* [Dropdowns](#dropdowns)
+* [Modals](#modals)
+
+## Dropdowns
+
+See also the [corresponding UX guide](../ux_guide/components.md#dropdowns).
+
+### How to style a bootstrap dropdown
+1. Use the HTML structure provided by the [docs][bootstrap-dropdowns]
+1. Add a specific class to the top level `.dropdown` element
+
+
+ ```Haml
+ .dropdown.my-dropdown
+ %button{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false }
+ %span.dropdown-toggle-text
+ Toggle Dropdown
+ = icon('chevron-down')
+
+ %ul.dropdown-menu
+ %li
+ %a
+ item!
+ ```
+
+ Or use the helpers
+ ```Haml
+ .dropdown.my-dropdown
+ = dropdown_toggle('Toogle!', { toggle: 'dropdown' })
+ = dropdown_content
+ %li
+ %a
+ item!
+ ```
+
+[bootstrap-dropdowns]: https://getbootstrap.com/docs/3.3/javascript/#dropdowns
+
+## Modals
+
+See also the [corresponding UX guide](../ux_guide/components.md#modals).
+
+We have a reusable Vue component for modals: [vue_shared/components/gl-modal.vue](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/vue_shared/components/gl-modal.vue)
+
+Here is an example of how to use it:
+
+```html
+ <gl-modal
+ id="dogs-out-modal"
+ :header-title-text="s__('ModalExample|Let the dogs out?')"
+ footer-primary-button-variant="danger"
+ :footer-primary-button-text="s__('ModalExample|Let them out')"
+ @submit="letOut(theDogs)"
+ >
+ {{ s__('ModalExample|You’re about to let the dogs out.') }}
+ </gl-modal>
+```
+
+![example modal](img/gl-modal.png)
diff --git a/doc/development/fe_guide/dropdowns.md b/doc/development/fe_guide/dropdowns.md
index 6314f8f38d2..e9d6244355c 100644
--- a/doc/development/fe_guide/dropdowns.md
+++ b/doc/development/fe_guide/dropdowns.md
@@ -1,32 +1 @@
-# Dropdowns
-
-
-## How to style a bootstrap dropdown
-1. Use the HTML structure provided by the [docs][bootstrap-dropdowns]
-1. Add a specific class to the top level `.dropdown` element
-
-
- ```Haml
- .dropdown.my-dropdown
- %button{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false }
- %span.dropdown-toggle-text
- Toggle Dropdown
- = icon('chevron-down')
-
- %ul.dropdown-menu
- %li
- %a
- item!
- ```
-
- Or use the helpers
- ```Haml
- .dropdown.my-dropdown
- = dropdown_toggle('Toogle!', { toggle: 'dropdown' })
- = dropdown_content
- %li
- %a
- item!
- ```
-
-[bootstrap-dropdowns]: https://getbootstrap.com/docs/3.3/javascript/#dropdowns
+This page has moved [here](components.md#dropdowns).
diff --git a/doc/development/fe_guide/img/gl-modal.png b/doc/development/fe_guide/img/gl-modal.png
new file mode 100644
index 00000000000..47302e857bc
--- /dev/null
+++ b/doc/development/fe_guide/img/gl-modal.png
Binary files differ
diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md
index 72cb557d054..2280cf79f86 100644
--- a/doc/development/fe_guide/index.md
+++ b/doc/development/fe_guide/index.md
@@ -14,13 +14,15 @@ support through [webpack][webpack].
We also utilize [webpack][webpack] to handle the bundling, minification, and
compression of our assets.
-Working with our frontend assets requires Node (v4.3 or greater) and Yarn
-(v0.17 or greater). You can find information on how to install these on our
+Working with our frontend assets requires Node (v6.0 or greater) and Yarn
+(v1.2 or greater). You can find information on how to install these on our
[installation guide][install].
[jQuery][jquery] is used throughout the application's JavaScript, with
[Vue.js][vue] for particularly advanced, dynamic elements.
+We also use [Axios][axios] to handle all of our network requests.
+
### Browser Support
For our currently-supported browsers, see our [requirements][requirements].
@@ -77,8 +79,10 @@ Axios specific practices and gotchas.
## [Icons](icons.md)
How we use SVG for our Icons.
-## [Dropdowns](dropdowns.md)
-How we use dropdowns.
+## [Components](components.md)
+
+How we use UI components.
+
---
## Style Guides
@@ -122,6 +126,7 @@ The [externalization part of the guide](../i18n/externalization.md) explains the
[webpack]: https://webpack.js.org/
[jquery]: https://jquery.com/
[vue]: http://vuejs.org/
+[axios]: https://github.com/axios/axios
[airbnb-js-style-guide]: https://github.com/airbnb/javascript
[scss-lint]: https://github.com/brigade/scss-lint
[install]: ../../install/installation.md#4-node
diff --git a/doc/development/fe_guide/performance.md b/doc/development/fe_guide/performance.md
index 14ac1133cc0..1320efaf767 100644
--- a/doc/development/fe_guide/performance.md
+++ b/doc/development/fe_guide/performance.md
@@ -23,7 +23,7 @@ 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
+### Lazy Loading Images
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,
@@ -36,43 +36,114 @@ If you are asynchronously adding content which contains lazy images then you nee
`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.
+### Animations
+
+Only animate `opacity` & `transform` properties. Other properties (such as `top`, `left`, `margin`, and `padding`) all cause
+Layout to be recalculated, which is much more expensive. For details on this, see "Styles that Affect Layout" in
+[High Performance Animations][high-perf-animations].
+
+If you _do_ need to change layout (e.g. a sidebar that pushes main content over), prefer [FLIP][flip] to change expensive
+properties once, and handle the actual animation with transforms.
+
## Reducing Asset Footprint
-### Page-specific JavaScript
+### Universal code
-Certain pages may require the use of a third party library, such as [d3][d3] for
-the User Activity Calendar and [Chart.js][chartjs] for the Graphs pages. These
-libraries increase the page size significantly, and impact load times due to
-bandwidth bottlenecks and the browser needing to parse more JavaScript.
-
-In cases where libraries are only used on a few specific pages, we use
-"page-specific JavaScript" to prevent the main `main.js` file from
-becoming unnecessarily large.
-
-Steps to split page-specific JavaScript from the main `main.js`:
-
-1. Create a directory for the specific page(s), e.g. `graphs/`.
-1. In that directory, create a `namespace_bundle.js` file, e.g. `graphs_bundle.js`.
-1. Add the new "bundle" file to the list of entry files in `config/webpack.config.js`.
- - For example: `graphs: './graphs/graphs_bundle.js',`.
-1. Move code reliant on these libraries into the `graphs` directory.
-1. In `graphs_bundle.js` add CommonJS `require('./path_to_some_component.js');` statements to load any other files in this directory. Make sure to use relative urls.
-1. In the relevant views, add the scripts to the page with the following:
-
-```haml
-- content_for :page_specific_javascripts do
- = webpack_bundle_tag 'lib_chart'
- = webpack_bundle_tag 'graphs'
-```
+Code that is contained within `main.js` and `commons/index.js` are loaded and
+run on _all_ pages. **DO NOT ADD** anything to these files unless it is truly
+needed _everywhere_. These bundles include ubiquitous libraries like `vue`,
+`axios`, and `jQuery`, as well as code for the main navigation and sidebar.
+Where possible we should aim to remove modules from these bundles to reduce our
+code footprint.
-The above loads `chart.js` and `graphs_bundle.js` for this page only. `chart.js`
-is separated from the bundle file so it can be cached separately from the bundle
-and reused for other pages that also rely on the library. For an example, see
-[this Haml file][page-specific-js-example].
+### Page-specific JavaScript
+
+Webpack has been configured to automatically generate entry point bundles based
+on the file structure within `app/assets/javascripts/pages/*`. The directories
+within the `pages` directory correspond to Rails controllers and actions. These
+auto-generated bundles will be automatically included on the corresponding
+pages.
+
+For example, if you were to visit [gitlab.com/gitlab-org/gitlab-ce/issues](https://gitlab.com/gitlab-org/gitlab-ce/issues),
+you would be accessing the `app/controllers/projects/issues_controller.rb`
+controller with the `index` action. If a corresponding file exists at
+`pages/projects/issues/index/index.js`, it will be compiled into a webpack
+bundle and included on the page.
+
+> **Note:** Previously we had encouraged the use of
+> `content_for :page_specific_javascripts` within haml files, along with
+> manually generated webpack bundles. However under this new system you should
+> not ever need to manually add an entry point to the `webpack.config.js` file.
+
+> **Tip:**
+> If you are unsure what controller and action corresponds to a given page, you
+> can find this out by inspecting `document.body.dataset.page` within your
+> browser's developer console while on any page within gitlab.
+
+#### Important Considerations:
+
+- **Keep Entry Points Lite:**
+ Page-specific javascript entry points should be as lite as possible. These
+ files are exempt from unit tests, and should be used primarily for
+ instantiation and dependency injection of classes and methods that live in
+ modules outside of the entry point script. Just import, read the DOM,
+ instantiate, and nothing else.
+
+- **Entry Points May Be Asynchronous:**
+ _DO NOT ASSUME_ that the DOM has been fully loaded and available when an
+ entry point script is run. If you require that some code be run after the
+ DOM has loaded, you should attach an event handler to the `DOMContentLoaded`
+ event with:
+
+ ```javascript
+ import initMyWidget from './my_widget';
+
+ document.addEventListener('DOMContentLoaded', () => {
+ initMyWidget();
+ });
+ ```
+
+- **Supporting Module Placement:**
+ - If a class or a module is _specific to a particular route_, try to locate
+ it close to the entry point it will be used. For instance, if
+ `my_widget.js` is only imported within `pages/widget/show/index.js`, you
+ should place the module at `pages/widget/show/my_widget.js` and import it
+ with a relative path (e.g. `import initMyWidget from './my_widget';`).
+
+ - If a class or module is _used by multiple routes_, place it within a
+ shared directory at the closest common parent directory for the entry
+ points that import it. For example, if `my_widget.js` is imported within
+ both `pages/widget/show/index.js` and `pages/widget/run/index.js`, then
+ place the module at `pages/widget/shared/my_widget.js` and import it with
+ a relative path if possible (e.g. `../shared/my_widget`).
+
+- **Enterprise Edition Caveats:**
+ For GitLab Enterprise Edition, page-specific entry points will override their
+ Community Edition counterparts with the same name, so if
+ `ee/app/assets/javascripts/pages/foo/bar/index.js` exists, it will take
+ precedence over `app/assets/javascripts/pages/foo/bar/index.js`. If you want
+ to minimize duplicate code, you can import one entry point from the other.
+ This is not done automatically to allow for flexibility in overriding
+ functionality.
### Code Splitting
-> *TODO* flesh out this section once webpack is ready for code-splitting
+For any code that does not need to be run immediately upon page load, (e.g.
+modals, dropdowns, and other behaviors that can be lazy-loaded), you can split
+your module into asynchronous chunks with dynamic import statements. These
+imports return a Promise which will be resolved once the script has loaded:
+
+```javascript
+import(/* webpackChunkName: 'emoji' */ '~/emoji')
+ .then(/* do something */)
+ .catch(/* report error */)
+```
+
+Please try to use `webpackChunkName` when generating these dynamic imports as
+it will provide a deterministic filename for the chunk which can then be cached
+the browser across GitLab versions.
+
+More information is available in [webpack's code splitting documentation](https://webpack.js.org/guides/code-splitting/#dynamic-imports).
### Minimizing page size
@@ -86,7 +157,9 @@ General tips:
- Prefer font formats with better compression, e.g. WOFF2 is better than WOFF, which is better than TTF.
- Compress and minify assets wherever possible (For CSS/JS, Sprockets and webpack do this for us).
- If some functionality can reasonably be achieved without adding extra libraries, avoid them.
-- Use page-specific JavaScript as described above to dynamically load libraries that are only needed on certain pages.
+- Use page-specific JavaScript as described above to load libraries that are only needed on certain pages.
+- Use code-splitting dynamic imports wherever possible to lazy-load code that is not needed initially.
+- [High Performance Animations][high-perf-animations]
-------
@@ -102,6 +175,5 @@ General tips:
[pagespeed-insights]: https://developers.google.com/speed/pagespeed/insights/
[google-devtools-profiling]: https://developers.google.com/web/tools/chrome-devtools/profile/?hl=en
[browser-diet]: https://browserdiet.com/
-[d3]: https://d3js.org/
-[chartjs]: http://www.chartjs.org/
-[page-specific-js-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/13bb9ed77f405c5f6ee4fdbc964ecf635c9a223f/app/views/projects/graphs/_head.html.haml#L6-8
+[high-perf-animations]: https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/
+[flip]: https://aerotwist.com/blog/flip-your-animations/
diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md
index 1cd66f27492..7b5fa6ca42f 100644
--- a/doc/development/fe_guide/style_guide_js.md
+++ b/doc/development/fe_guide/style_guide_js.md
@@ -101,16 +101,16 @@ followed by any global declarations, then a blank newline prior to any imports o
```
Import statements are following usual naming guidelines, for example object literals use camel case:
-
+
```javascript
// some_object file
export default {
key: 'value',
};
-
+
// bad
import ObjectLiteral from 'some_object';
-
+
// good
import objectLiteral from 'some_object';
```
@@ -207,10 +207,39 @@ Do not use them anymore and feel free to remove them when refactoring legacy cod
var c = pureFunction(values.foo);
```
-1. Avoid constructors with side-effects
+1. Avoid constructors with side-effects.
+Although we aim for code without side-effects we need some side-effects for our code to run.
+
+If the class won't do anything if we only instantiate it, it's ok to add side effects into the constructor (_Note:_ The following is just an example. If the only purpose of the class is to add an event listener and handle the callback a function will be more suitable.)
+
+```javascript
+// Bad
+export class Foo {
+ constructor() {
+ this.init();
+ }
+ init() {
+ document.addEventListener('click', this.handleCallback)
+ },
+ handleCallback() {
+
+ }
+}
+
+// Good
+export class Foo {
+ constructor() {
+ document.addEventListener()
+ }
+ handleCallback() {
+ }
+}
+```
+
+On the other hand, if a class only needs to extend a third party/add event listeners in some specific cases, they should be initialized oustside of the constructor.
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`,
+A forEach will most likely cause side effects, it will be mutating the array being iterated. Prefer using `.map`,
`.reduce` or `.filter`
```javascript
const users = [ { name: 'Foo' }, { name: 'Bar' } ];
@@ -255,6 +284,10 @@ A forEach will cause side effects, it will be mutating the array being iterated.
### Vue.js
+#### `eslint-vue-plugin`
+We default to [eslint-vue-plugin][eslint-plugin-vue], with the `plugin:vue/recommended`.
+Please check this [rules][eslint-plugin-vue-rules] for more documentation.
+
#### Basic Rules
1. The service has it's own file
1. The store has it's own file
@@ -298,20 +331,20 @@ A forEach will cause side effects, it will be mutating the array being iterated.
#### Naming
1. **Extensions**: Use `.vue` extension for Vue components.
-1. **Reference Naming**: Use camelCase for their instances:
+1. **Reference Naming**: Use PascalCase for their instances:
```javascript
// bad
- import CardBoard from 'cardBoard'
+ import cardBoard from 'cardBoard.vue'
components: {
- CardBoard:
+ cardBoard,
};
// good
- import cardBoard from 'cardBoard'
+ import CardBoard from 'cardBoard.vue'
components: {
- cardBoard:
+ CardBoard,
};
```
@@ -360,6 +393,10 @@ A forEach will cause side effects, it will be mutating the array being iterated.
<component
bar="bar"
/>
+
+ // bad
+ <component
+ bar="bar" />
```
#### Quotes
@@ -509,26 +546,59 @@ On those a default key should not be provided.
```
1. Properties in a Vue Component:
- 1. `name`
- 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`
+ Check [order of properties in components rule][vue-order].
+
+#### `:key`
+When using `v-for` you need to provide a *unique* `:key` attribute for each item.
+
+1. If the elements of the array being iterated have an unique `id` it is advised to use it:
+ ```html
+ <div
+ v-for="item in items"
+ :key="item.id"
+ >
+ <!-- content -->
+ </div>
+ ```
+
+1. When the elements being iterated don't have a unique id, you can use the array index as the `:key` attribute
+ ```html
+ <div
+ v-for="(item, index) in items"
+ :key="index"
+ >
+ <!-- content -->
+ </div>
+ ```
+
+
+1. When using `v-for` with `template` and there is more than one child element, the `:key` values must be unique. It's advised to use `kebab-case` namespaces.
+ ```html
+ <template v-for="(item, index) in items">
+ <span :key="`span-${index}`"></span>
+ <button :key="`button-${index}`"></button>
+ </template>
+ ```
+
+1. When dealing with nested `v-for` use the same guidelines as above.
+ ```html
+ <div
+ v-for="item in items"
+ :key="item.id"
+ >
+ <span
+ v-for="element in array"
+ :key="element.id"
+ >
+ <!-- content -->
+ </span>
+ </div>
+ ```
+Useful links:
+1. [`key`](https://vuejs.org/v2/guide/list.html#key)
+1. [Vue Style Guide: Keyed v-for](https://vuejs.org/v2/style-guide/#Keyed-v-for-essential )
#### Vue and Bootstrap
1. Tooltips: Do not rely on `has-tooltip` class name for Vue components
@@ -582,3 +652,6 @@ The goal of this accord is to make sure we are all on the same page.
[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
+[eslint-plugin-vue]: https://github.com/vuejs/eslint-plugin-vue
+[eslint-plugin-vue-rules]: https://github.com/vuejs/eslint-plugin-vue#bulb-rules
+[vue-order]: https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/order-in-components.md
diff --git a/doc/development/fe_guide/style_guide_scss.md b/doc/development/fe_guide/style_guide_scss.md
index 77b308c4a43..655d94793dd 100644
--- a/doc/development/fe_guide/style_guide_scss.md
+++ b/doc/development/fe_guide/style_guide_scss.md
@@ -7,6 +7,8 @@ easy to maintain, and performant for the end-user.
### Naming
+Filenames should use `snake_case`.
+
CSS classes should use the `lowercase-hyphenated` format rather than
`snake_case` or `camelCase`.
@@ -216,7 +218,7 @@ If you want a line or set of lines to be ignored by the linter, you can use
```scss
// This lint rule is disabled because the class name comes from a gem.
// scss-lint:disable SelectorFormat
-.ui_charcoal {
+.ui_indigo {
background-color: #333;
}
// scss-lint:enable SelectorFormat
diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md
index 6e9f18dd1c3..c1170fa3b13 100644
--- a/doc/development/fe_guide/vue.md
+++ b/doc/development/fe_guide/vue.md
@@ -53,13 +53,13 @@ you can find a clear separation of concerns:
```
new_feature
├── components
-│ └── component.js.es6
+│ └── component.vue
│ └── ...
-├── store
-│ └── new_feature_store.js.es6
-├── service
-│ └── new_feature_service.js.es6
-├── new_feature_bundle.js.es6
+├── stores
+│ └── new_feature_store.js
+├── services
+│ └── new_feature_service.js
+├── new_feature_bundle.js
```
_For consistency purposes, we recommend you to follow the same structure._
@@ -456,7 +456,7 @@ describe('Todos App', () => {
});
```
#### `mountComponent` helper
-There is an helper in `spec/javascripts/helpers/vue_mount_component_helper.js` that allows you to mount a component with the given props:
+There is a helper in `spec/javascripts/helpers/vue_mount_component_helper.js` that allows you to mount a component with the given props:
```javascript
import Vue from 'vue';
@@ -507,13 +507,15 @@ This is the entry point for our store. You can use the following as a guide:
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
-import * as mutations from './mutations';
+import * as getters from './getters';
+import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
actions,
getters,
+ mutations,
state: {
users: [],
},
@@ -525,7 +527,7 @@ _Note:_ If the state of the application is too complex, an individual file for t
An action commits a mutatation. In this file, we will write the actions that will call the respective mutation:
```javascript
- import * as types from './mutation-types'
+ import * as types from './mutation_types';
export const addUser = ({ commit }, user) => {
commit(types.ADD_USER, user);
@@ -575,7 +577,8 @@ import { mapGetters } from 'vuex';
The only way to actually change state in a Vuex store is by committing a mutation.
```javascript
- import * as types from './mutation-types'
+ import * as types from './mutation_types';
+
export default {
[types.ADD_USER](state, user) {
state.users.push(user);
@@ -684,4 +687,3 @@ describe('component', () => {
[vuex-testing]: https://vuex.vuejs.org/en/testing.html
[axios]: https://github.com/axios/axios
[axios-interceptors]: https://github.com/axios/axios#interceptors
-
diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md
index 59e8a087e02..5d1f657015c 100644
--- a/doc/development/feature_flags.md
+++ b/doc/development/feature_flags.md
@@ -1,6 +1,6 @@
# Manage feature flags
-Starting from GitLab 9.3 we support feature flags via
+Starting from GitLab 9.3 we support feature flags for features in GitLab 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.
@@ -19,3 +19,8 @@ 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)
+
+## Feature flags for user applications
+
+GitLab does not yet support the use of feature flags in deployed user applications.
+You can follow the progress on that [in the issue on our issue tracker](https://gitlab.com/gitlab-org/gitlab-ee/issues/779). \ No newline at end of file
diff --git a/doc/development/file_storage.md b/doc/development/file_storage.md
index cf00e24e11a..34a02bd2c3c 100644
--- a/doc/development/file_storage.md
+++ b/doc/development/file_storage.md
@@ -14,9 +14,9 @@ There are many places where file uploading is used, according to contexts:
- User snippet attachments
* Project
- Project avatars
- - Issues/MR Markdown attachments
- - Issues/MR Legacy Markdown attachments
- - CI Build Artifacts
+ - Issues/MR/Notes Markdown attachments
+ - Issues/MR/Notes Legacy Markdown attachments
+ - CI Artifacts (archive, metadata, trace)
- LFS Objects
@@ -25,7 +25,7 @@ There are many places where file uploading is used, according to contexts:
GitLab started saving everything on local disk. While directory location changed from previous versions,
they are still not 100% standardized. You can see them below:
-| Description | In DB? | Relative path | Uploader class | model_type |
+| Description | In DB? | Relative path (from CarrierWave.root) | Uploader class | model_type |
| ------------------------------------- | ------ | ----------------------------------------------------------- | ---------------------- | ---------- |
| Instance logo | yes | uploads/-/system/appearance/logo/:id/:filename | `AttachmentUploader` | Appearance |
| Header logo | yes | uploads/-/system/appearance/header_logo/:id/:filename | `AttachmentUploader` | Appearance |
@@ -33,17 +33,107 @@ they are still not 100% standardized. You can see them below:
| User avatars | yes | uploads/-/system/user/avatar/:id/:filename | `AvatarUploader` | User |
| User snippet attachments | yes | uploads/-/system/personal_snippet/:id/:random_hex/:filename | `PersonalFileUploader` | Snippet |
| Project avatars | yes | uploads/-/system/project/avatar/:id/:filename | `AvatarUploader` | Project |
-| Issues/MR Markdown attachments | yes | uploads/:project_path_with_namespace/:random_hex/:filename | `FileUploader` | Project |
-| Issues/MR Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note |
-| CI Artifacts (CE) | yes | shared/artifacts/:year_:month/:project_id/:id | `ArtifactUploader` | Ci::Build |
+| Issues/MR/Notes Markdown attachments | yes | uploads/:project_path_with_namespace/:random_hex/:filename | `FileUploader` | Project |
+| Issues/MR/Notes Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note |
+| CI Artifacts (CE) | yes | shared/artifacts/:disk_hash[0..1]/:disk_hash[2..3]/:disk_hash/:year_:month_:date/:job_id/:job_artifact_id (:disk_hash is SHA256 digest of project_id) | `JobArtifactUploader` | Ci::JobArtifact |
| LFS Objects (CE) | yes | shared/lfs-objects/:hex/:hex/:object_hash | `LfsObjectUploader` | LfsObject |
CI Artifacts and LFS Objects behave differently in CE and EE. In CE they inherit the `GitlabUploader`
-while in EE they inherit the `ObjectStoreUploader` and store files in and S3 API compatible object store.
+while in EE they inherit the `ObjectStorage` and store files in and S3 API compatible object store.
-In the case of Issues/MR Markdown attachments, there is a different approach using the [Hashed Storage] layout,
+In the case of Issues/MR/Notes Markdown attachments, there is a different approach using the [Hashed Storage] layout,
instead of basing the path into a mutable variable `:project_path_with_namespace`, it's possible to use the
hash of the project ID instead, if project migrates to the new approach (introduced in 10.2).
+### Path segments
+
+Files are stored at multiple locations and use different path schemes.
+All the `GitlabUploader` derived classes should comply with this path segment schema:
+
+```
+| GitlabUploader
+| ----------------------- + ------------------------- + --------------------------------- + -------------------------------- |
+| `<gitlab_root>/public/` | `uploads/-/system/` | `user/avatar/:id/` | `:filename` |
+| ----------------------- + ------------------------- + --------------------------------- + -------------------------------- |
+| `CarrierWave.root` | `GitlabUploader.base_dir` | `GitlabUploader#dynamic_segment` | `CarrierWave::Uploader#filename` |
+| | `CarrierWave::Uploader#store_dir` | |
+
+| FileUploader
+| ----------------------- + ------------------------- + --------------------------------- + -------------------------------- |
+| `<gitlab_root>/shared/` | `artifacts/` | `:year_:month/:id` | `:filename` |
+| `<gitlab_root>/shared/` | `snippets/` | `:secret/` | `:filename` |
+| ----------------------- + ------------------------- + --------------------------------- + -------------------------------- |
+| `CarrierWave.root` | `GitlabUploader.base_dir` | `GitlabUploader#dynamic_segment` | `CarrierWave::Uploader#filename` |
+| | `CarrierWave::Uploader#store_dir` | |
+| | | `FileUploader#upload_path |
+
+| ObjectStore::Concern (store = remote)
+| ----------------------- + ------------------------- + ----------------------------------- + -------------------------------- |
+| `<bucket_name>` | <ignored> | `user/avatar/:id/` | `:filename` |
+| ----------------------- + ------------------------- + ----------------------------------- + -------------------------------- |
+| `#fog_dir` | `GitlabUploader.base_dir` | `GitlabUploader#dynamic_segment` | `CarrierWave::Uploader#filename` |
+| | | `ObjectStorage::Concern#store_dir` | |
+| | | `ObjectStorage::Concern#upload_path |
+```
+
+The `RecordsUploads::Concern` concern will create an `Upload` entry for every file stored by a `GitlabUploader` persisting the dynamic parts of the path using
+`GitlabUploader#dynamic_path`. You may then use the `Upload#build_uploader` method to manipulate the file.
+
+## Object Storage
+
+By including the `ObjectStorage::Concern` in the `GitlabUploader` derived class, you may enable the object storage for this uploader. To enable the object storage
+in your uploader, you need to either 1) include `RecordsUpload::Concern` and prepend `ObjectStorage::Extension::RecordsUploads` or 2) mount the uploader and create a new field named `<mount>_store`.
+
+The `CarrierWave::Uploader#store_dir` is overriden to
+
+ - `GitlabUploader.base_dir` + `GitlabUploader.dynamic_segment` when the store is LOCAL
+ - `GitlabUploader.dynamic_segment` when the store is REMOTE (the bucket name is used to namespace)
+
+### Using `ObjectStorage::Extension::RecordsUploads`
+
+> Note: this concern will automatically include `RecordsUploads::Concern` if not already included.
+
+The `ObjectStorage::Concern` uploader will search for the matching `Upload` to select the correct object store. The `Upload` is mapped using `#store_dirs + identifier` for each store (LOCAL/REMOTE).
+
+```ruby
+class SongUploader < GitlabUploader
+ include RecordsUploads::Concern
+ include ObjectStorage::Concern
+ prepend ObjectStorage::Extension::RecordsUploads
+
+ ...
+end
+
+class Thing < ActiveRecord::Base
+ mount :theme, SongUploader # we have a great theme song!
+
+ ...
+end
+```
+
+### Using a mounted uploader
+
+The `ObjectStorage::Concern` will query the `model.<mount>_store` attribute to select the correct object store.
+This column must be present in the model schema.
+
+```ruby
+class SongUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ ...
+end
+
+class Thing < ActiveRecord::Base
+ attr_reader :theme_store # this is an ActiveRecord attribute
+ mount :theme, SongUploader # we have a great theme song!
+
+ def theme_store
+ super || ObjectStorage::Store::LOCAL
+ end
+
+ ...
+end
+```
+
[CarrierWave]: https://github.com/carrierwaveuploader/carrierwave
[Hashed Storage]: ../administration/repository_storage_types.md
diff --git a/doc/development/gitaly.md b/doc/development/gitaly.md
index ca2048c7019..26abf967dcf 100644
--- a/doc/development/gitaly.md
+++ b/doc/development/gitaly.md
@@ -97,6 +97,29 @@ describe 'Gitaly Request count tests' do
end
```
+## Running tests with a locally modified version of Gitaly
+
+Normally, gitlab-ce/ee tests use a local clone of Gitaly in `tmp/tests/gitaly`
+pinned at the version specified in GITALY_SERVER_VERSION. If you want
+to run tests locally against a modified version of Gitaly you can
+replace `tmp/tests/gitaly` with a symlink.
+
+```shell
+rm -rf tmp/tests/gitaly
+ln -s /path/to/gitaly tmp/tests/gitaly
+```
+
+Make sure you run `make` in your local Gitaly directory before running
+tests. Otherwise, Gitaly will fail to boot.
+
+If you make changes to your local Gitaly in between test runs you need
+to manually run `make` again.
+
+Note that CI tests will not use your locally modified version of
+Gitaly. To use a custom Gitaly version in CI you need to update
+GITALY_SERVER_VERSION. You can use the format `=revision` to use a
+non-tagged commit from https://gitlab.com/gitlab-org/gitaly in CI.
+
---
[Return to Development documentation](README.md)
diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md
index f493ad4ae66..856ef882453 100644
--- a/doc/development/i18n/externalization.md
+++ b/doc/development/i18n/externalization.md
@@ -45,7 +45,7 @@ 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.
+1. JavaScript files: we mostly need to work with Vue templates.
### Ruby files
@@ -107,99 +107,28 @@ You can mark that content for translation with:
### JavaScript files
-In JavaScript we added the `__()` (double underscore parenthesis) function
-for translations.
+In JavaScript we added the `__()` (double underscore parenthesis) function that
+you can import from the `~/locale` file. For instance:
-## 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.pot` file with the newly externalized
-strings and remove any strings that aren't used anymore. You should check this
-file in. Once the changes are on master, they will be picked up by
-[Crowdin](http://translate.gitlab.com) and be presented for translation.
-
-The command also updates the translation files for each language: `locale/*/gitlab.po`
-These changes can be discarded, the languange files will be updated by Crowdin
-automatically.
-
-Discard all of them at once like this:
-
-```sh
-git checkout locale/*/gitlab.po
-```
-
-### Validating PO files
-
-To make sure we keep our translation files up to date, there's a linter that is
-running on CI as part of the `static-analysis` job.
-
-To lint the adjustments in PO files locally you can run `rake gettext:lint`.
-
-The linter will take the following into account:
-
-- Valid PO-file syntax
-- Variable usage
- - Only one unnamed (`%d`) variable, since the order of variables might change
- in different languages
- - All variables used in the message-id are used in the translation
- - There should be no variables used in a translation that aren't in the
- message-id
-- Errors during translation.
-
-The errors are grouped per file, and per message ID:
-
-```
-Errors in `locale/zh_HK/gitlab.po`:
- PO-syntax errors
- SimplePoParser::ParserErrorSyntax error in lines
- Syntax error in msgctxt
- Syntax error in msgid
- Syntax error in msgstr
- Syntax error in message_line
- There should be only whitespace until the end of line after the double quote character of a message text.
- Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
- SimplePoParser filtered backtrace: SimplePoParser::ParserError
-Errors in `locale/zh_TW/gitlab.po`:
- 1 pipeline
- <%d æ¢æµæ°´ç·š> is using unknown variables: [%d]
- Failure translating to zh_TW with []: too few arguments
+```js
+import { __ } from '~/locale';
+const label = __('Subscribe');
```
-In this output the `locale/zh_HK/gitlab.po` has syntax errors.
-The `locale/zh_TW/gitlab.po` has variables that are used in the translation that
-aren't in the message with id `1 pipeline`.
-
-## Working with special content
-
-
-### Just marking content for parsing
-
-- In Ruby/HAML:
-
- ```ruby
- _('Subscribe')
- ```
-
-- In JavaScript:
-
- ```js
- import { __ } from '../../../locale';
- const label = __('Subscribe');
- ```
+In order to test JavaScript translations you have to change the GitLab
+localization to other language than English and you have to generate JSON files
+using `bin/rake gettext:po_to_json` or `bin/rake gettext:compile`.
+### Dynamic translations
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).
+parser when running `bin/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).
+## Working with special content
+
### Interpolation
- In Ruby/HAML:
@@ -211,7 +140,7 @@ There is also and alternative method to [translate messages from validation erro
- In JavaScript:
```js
- import { __, sprintf } from '../../../locale';
+ import { __, sprintf } from '~/locale';
sprintf(__('Hello %{username}'), { username: 'Joe' }) => 'Hello Joe'
```
@@ -223,24 +152,30 @@ For example use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript.
- In Ruby/HAML:
```ruby
- n_('Apple', 'Apples', 3) => 'Apples'
+ n_('Apple', 'Apples', 3)
+ # => 'Apples'
```
Using interpolation:
```ruby
n_("There is a mouse.", "There are %d mice.", size) % size
+ # => When size == 1: 'There is a mouse.'
+ # => When size == 2: 'There are 2 mice.'
```
- In JavaScript:
```js
- n__('Apple', 'Apples', 3) => 'Apples'
+ n__('Apple', 'Apples', 3)
+ // => 'Apples'
```
Using interpolation:
```js
- n__('Last day', 'Last %d days', 30) => 'Last 30 days'
+ n__('Last day', 'Last %d days', x)
+ // => When x == 1: 'Last day'
+ // => When x == 2: 'Last 2 days'
```
### Namespaces
@@ -262,12 +197,15 @@ Sometimes you need to add some context to the text that you want to translate
s__('OpenedNDaysAgo|Opened')
```
+Note: The namespace should be removed from the translation. See the [translation
+guidelines for more details](./translation.md#namespaced-strings).
+
### Dates / times
- In JavaScript:
```js
-import { createDateTimeFormat } from '.../locale';
+import { createDateTimeFormat } from '~/locale';
const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
console.log(dateFormat.format(new Date('2063-04-05'))) // April 5, 2063
@@ -277,6 +215,100 @@ This makes use of [`Intl.DateTimeFormat`].
[`Intl.DateTimeFormat`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
+## Best practices
+
+### Splitting sentences
+
+Please never split a sentence as that would assume the sentence grammar and
+structure is the same in all languages.
+
+For instance, the following
+
+```js
+{{ s__("mrWidget|Set by") }}
+{{ author.name }}
+{{ s__("mrWidget|to be merged automatically when the pipeline succeeds") }}
+```
+
+should be externalized as follows:
+
+```js
+{{ sprintf(s__("mrWidget|Set by %{author} to be merged automatically when the pipeline succeeds"), { author: author.name }) }}
+```
+
+When in doubt, try to follow the best practices described in this [Mozilla
+Developer documentation][mdn].
+
+[mdn]: https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_content_best_practices#Splitting
+
+## 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
+bin/rake gettext:find
+```
+
+This command will update the `locale/gitlab.pot` file with the newly externalized
+strings and remove any strings that aren't used anymore. You should check this
+file in. Once the changes are on master, they will be picked up by
+[Crowdin](http://translate.gitlab.com) and be presented for translation.
+
+If there are merge conflicts in the `gitlab.pot` file, you can delete the file
+and regenerate it using the same command. Confirm that you are not deleting any strings accidentally by looking over the diff.
+
+The command also updates the translation files for each language: `locale/*/gitlab.po`
+These changes can be discarded, the languange files will be updated by Crowdin
+automatically.
+
+Discard all of them at once like this:
+
+```sh
+git checkout locale/*/gitlab.po
+```
+
+### Validating PO files
+
+To make sure we keep our translation files up to date, there's a linter that is
+running on CI as part of the `static-analysis` job.
+
+To lint the adjustments in PO files locally you can run `rake gettext:lint`.
+
+The linter will take the following into account:
+
+- Valid PO-file syntax
+- Variable usage
+ - Only one unnamed (`%d`) variable, since the order of variables might change
+ in different languages
+ - All variables used in the message-id are used in the translation
+ - There should be no variables used in a translation that aren't in the
+ message-id
+- Errors during translation.
+
+The errors are grouped per file, and per message ID:
+
+```
+Errors in `locale/zh_HK/gitlab.po`:
+ PO-syntax errors
+ SimplePoParser::ParserErrorSyntax error in lines
+ Syntax error in msgctxt
+ Syntax error in msgid
+ Syntax error in msgstr
+ Syntax error in message_line
+ There should be only whitespace until the end of line after the double quote character of a message text.
+ Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
+ SimplePoParser filtered backtrace: SimplePoParser::ParserError
+Errors in `locale/zh_TW/gitlab.po`:
+ 1 pipeline
+ <%d æ¢æµæ°´ç·š> is using unknown variables: [%d]
+ Failure translating to zh_TW with []: too few arguments
+```
+
+In this output the `locale/zh_HK/gitlab.po` has syntax errors.
+The `locale/zh_TW/gitlab.po` has variables that are used in the translation that
+aren't in the message with id `1 pipeline`.
+
## Adding a new language
Let's suppose you want to add translations for a new language, let's say French.
@@ -295,14 +327,14 @@ Let's suppose you want to add translations for a new language, let's say French.
1. Next, you need to add the language:
```sh
- bundle exec rake gettext:add_language[fr]
+ bin/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]
+ bin/rake gettext:add_language[en_GB]
```
Please note that you need to specify the region part in capitals.
@@ -316,7 +348,7 @@ Let's suppose you want to add translations for a new language, let's say French.
containing the translations:
```sh
- bundle exec rake gettext:compile
+ bin/rake gettext:compile
```
1. In order to see the translated content we need to change our preferred language
diff --git a/doc/development/i18n/index.md b/doc/development/i18n/index.md
index 8aa0462d213..7290a175501 100644
--- a/doc/development/i18n/index.md
+++ b/doc/development/i18n/index.md
@@ -40,37 +40,12 @@ See [Translation guidelines](translation.md).
### Proof reading
-Proof reading helps ensure the accuracy and consistency of translations.
-All translations are proof read before being accepted.
-If a translations requires changes, you will be notified with a comment explaining why.
-
-Community assistance proof reading translations is encouraged and appreciated.
-Requests to become a proof reader will be considered on the merits of previous translations.
-
-- Bulgarian
-- Chinese Simplified
- - [Huang Tao](https://crowdin.com/profile/htve)
-- Chinese Traditional
- - [Huang Tao](https://crowdin.com/profile/htve)
-- Chinese Traditional, Hong Kong
- - [Huang Tao](https://crowdin.com/profile/htve)
-- Dutch
-- Esperanto
-- French
-- German
-- Italian
- - [Paolo Falomo](https://crowdin.com/profile/paolo.falomo)
-- Japanese
-- Korean
- - [Huang Tao](https://crowdin.com/profile/htve)
-- Portuguese, Brazilian
-- Russian
- - [Alexy Lustin](https://crowdin.com/profile/lustin)
- - [Nikita Grylov](https://crowdin.com/profile/nixel2007)
-- Spanish
-- Ukrainian
-
-If you would like to be added as a proof reader, please [open an issue](https://gitlab.com/gitlab-org/gitlab-ce/issues).
+Proof reading helps ensure the accuracy and consistency of translations. All
+translations are proof read before being accepted. If a translations requires
+changes, you will be notified with a comment explaining why.
+
+See [Proofreading Translations](proofreader.md) for more information on who's
+able to proofread and instructions on becoming a proofreader yourself.
## Release
diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md
new file mode 100644
index 00000000000..cf62314bc29
--- /dev/null
+++ b/doc/development/i18n/proofreader.md
@@ -0,0 +1,74 @@
+# Proofread Translations
+
+Most translations are contributed, reviewed, and accepted by the community. We
+are very appreciative of the work done by translators and proofreaders!
+
+## Proofreaders
+
+- Bulgarian
+- Chinese Simplified
+ - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve)
+- Chinese Traditional
+ - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve)
+ - Weizhe Ding - [GitLab](https://gitlab.com/d.weizhe), [Crowdin](https://crowdin.com/profile/d.weizhe)
+- Chinese Traditional, Hong Kong
+ - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve)
+- Dutch
+- Esperanto
+- French
+ - Rémy Coutable - [GitLab](https://gitlab.com/rymai), [Crowdin](https://crowdin.com/profile/rymai)
+- German
+- Indonesian
+ - Ahmad Naufal Mukhtar - [GitLab](https://gitlab.com/anaufalm), [Crowdin](https://crowdin.com/profile/anaufalm)
+- Italian
+ - Paolo Falomo - [GitLab](https://gitlab.com/paolofalomo), [Crowdin](https://crowdin.com/profile/paolo.falomo)
+- Japanese
+- Korean
+ - Chang-Ho Cha - [GitLab](https://gitlab.com/changho-cha), [Crowdin](https://crowdin.com/profile/zzazang)
+ - Huang Tao - [GitLab](https://gitlab.com/htve), [Crowdin](https://crowdin.com/profile/htve)
+- Polish
+ - Filip Mech - [GitLab](https://gitlab.com/mehenz), [Crowdin](https://crowdin.com/profile/mehenz)
+- Portuguese, Brazilian
+ - Paulo George Gomes Bezerra - [GitLab](https://gitlab.com/paulobezerra), [Crowdin](https://crowdin.com/profile/paulogomes.rep)
+- Russian
+ - Nikita Grylov - [GitLab](https://gitlab.com/nixel2007), [Crowdin](https://crowdin.com/profile/nixel2007)
+ - Alexy Lustin - [GitLab](https://gitlab.com/allustin), [Crowdin](https://crowdin.com/profile/lustin)
+- Spanish
+- Ukrainian
+ - Volodymyr Sobotovych - [GitLab](https://gitlab.com/wheleph), [Crowdin](https://crowdin.com/profile/wheleph)
+ - Andrew Vityuk - [GitLab](https://gitlab.com/3_1_3_u), [Crowdin](https://crowdin.com/profile/andruwa13)
+
+## Become a proofreader
+
+> **Note:** Before requesting Proofreader permissions in Crowdin please make
+> sure that you have a history of contributing translations to the GitLab
+> project.
+
+1. Contribute translations to GitLab. See instructions for
+ [translating GitLab](translation.md).
+
+ Translating GitLab is a community effort that requires team work and
+ attention to detail. Proofreaders play an important role helping new
+ contributors, and ensuring the consistency and quality of translations.
+ Your conduct and contributions as a translator should reflect this before
+ requesting to be a proofreader.
+
+1. Request proofreader permissions by opening a merge request to add yourself
+ to the list of proofreaders.
+
+ Open the [proofreader.md source file][proofreader-src] and click **Edit**.
+
+ Add your language in alphabetical order, and add yourself to the list
+ including:
+
+ - name
+ - link to your GitLab profile
+ - link to your CrowdIn profile
+
+ In the merge request description, please include links to any projects you
+ have previously translated.
+
+1. Your request to become a proofreader will be considered on the merits of
+ your previous translations.
+
+[proofreader-src]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/i18n/proofreader.md
diff --git a/doc/development/i18n/translation.md b/doc/development/i18n/translation.md
index b34ec754742..99c0fe6db1d 100644
--- a/doc/development/i18n/translation.md
+++ b/doc/development/i18n/translation.md
@@ -37,33 +37,43 @@ Comments can be added to discuss a translation with the community.
Remember to **Save** each translation.
-## Translation Guidelines
+## General Translation Guidelines
Be sure to check the following guidelines before you translate any strings.
+### Namespaced strings
+
+When an externalized string is prepended with a namespace, e.g.
+`s_('OpenedNDaysAgo|Opened')`, the namespace should be removed from the final
+translation.
+For example in French `OpenedNDaysAgo|Opened` would be translated to
+`Ouvert•e`, not `OpenedNDaysAgo|Ouvert•e`.
+
### Technical terms
-Technical terms should be treated like proper nouns and not be translated.
-This helps maintain a logical connection and consistency between tools (e.g. `git` client) and
-GitLab.
+Some technical terms should be treated like proper nouns and not be translated.
-Technical terms that should always be in English are noted in the glossary when using
-[translate.gitlab.com](https://translate.gitlab.com).
+Technical terms that should always be in English are noted in the glossary when
+using [translate.gitlab.com](https://translate.gitlab.com).
+
+This helps maintain a logical connection and consistency between tools (e.g.
+`git` client) and GitLab.
### Formality
The level of formality used in software varies by language.
-For example, in French we translate `you` as the informal `tu`.
+For example, in French we translate `you` as the formal `vous`.
-You can refer to other translated strings and notes in the glossary to assist determining a
-suitable level of formality.
+You can refer to other translated strings and notes in the glossary to assist
+determining a suitable level of formality.
### Inclusive language
[Diversity] is one of GitLab's values.
-We ask you to avoid translations which exclude people based on their gender or ethnicity.
-In languages which distinguish between a male and female form,
-use both or choose a neutral formulation.
+We ask you to avoid translations which exclude people based on their gender or
+ethnicity.
+In languages which distinguish between a male and female form, use both or
+choose a neutral formulation.
For example in German, the word "user" can be translated into "Benutzer" (male) or "Benutzerin" (female).
Therefore "create a new user" would translate into "Benutzer(in) anlegen".
@@ -74,3 +84,14 @@ Therefore "create a new user" would translate into "Benutzer(in) anlegen".
To propose additions to the glossary please
[open an issue](https://gitlab.com/gitlab-org/gitlab-ce/issues).
+
+## French Translation Guidelines
+
+### Inclusive language in French
+
+In French, we should follow the guidelines from [ecriture-inclusive.fr]. For
+instance:
+
+- Utilisateur•rice•s
+
+[ecriture-inclusive.fr]: http://www.ecriture-inclusive.fr/
diff --git a/doc/development/licensing.md b/doc/development/licensing.md
index 274923c2d43..c06bc0d4731 100644
--- a/doc/development/licensing.md
+++ b/doc/development/licensing.md
@@ -1,6 +1,6 @@
# GitLab Licensing and Compatibility
-GitLab CE is licensed under the terms of the MIT License. GitLab EE is licensed under "The GitLab Enterprise Edition (EE) license" wherein there are more restrictions. See their respective LICENSE files ([CE][CE], [EE][EE]) for more information.
+[GitLab Community Edition](https://gitlab.com/gitlab-org/gitlab-ce/) (CE) is licensed [under the terms of the MIT License][CE]. [GitLab Enterprise Edition](https://gitlab.com/gitlab-org/gitlab-ee/) (EE) is licensed under "[The GitLab Enterprise Edition (EE) license][EE]" wherein there are more restrictions.
## Automated Testing
diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md
index a235dd74909..a211effdfa7 100644
--- a/doc/development/migration_style_guide.md
+++ b/doc/development/migration_style_guide.md
@@ -4,7 +4,7 @@ When writing migrations for GitLab, you have to take into account that
these will be ran by hundreds of thousands of organizations of all sizes, some with
many years of data in their database.
-In addition, having to take a server offline for a a upgrade small or big is a
+In addition, having to take a server offline for an upgrade small or big is a
big burden for most organizations. For this reason it is important that your
migrations are written carefully, can be applied online and adhere to the style
guide below.
@@ -23,10 +23,6 @@ When downtime is necessary the migration has to be approved by:
An up-to-date list of people holding these titles can be found at
<https://about.gitlab.com/team/>.
-The document ["What Requires Downtime?"](what_requires_downtime.md) specifies
-various database operations, whether they require downtime and how to
-work around that whenever possible.
-
When writing your migrations, also consider that databases might have stale data
or inconsistencies and guard for that. Try to make as few assumptions as
possible about the state of the database.
@@ -41,6 +37,18 @@ 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.
+## What Requires Downtime?
+
+The document ["What Requires Downtime?"](what_requires_downtime.md) specifies
+various database operations, such as
+
+- [adding, dropping, and renaming columns](what_requires_downtime.md#adding-columns)
+- [changing column constraints and types](what_requires_downtime.md#changing-column-constraints)
+- [adding and dropping indexes, tables, and foreign keys](what_requires_downtime.md#adding-indexes)
+
+and whether they require downtime and how to work around that whenever possible.
+
+
## Downtime Tagging
Every migration must specify if it requires downtime or not, and if it should
@@ -136,11 +144,14 @@ class MyMigration < ActiveRecord::Migration
disable_ddl_transaction!
def up
- remove_concurrent_index :table_name, :column_name if index_exists?(:table_name, :column_name)
+ remove_concurrent_index :table_name, :column_name
end
end
```
+Note that it is not necessary to check if the index exists prior to
+removing it.
+
## Adding indexes
If you need to add a unique index please keep in mind there is the possibility
diff --git a/doc/development/new_fe_guide/dependencies.md b/doc/development/new_fe_guide/dependencies.md
new file mode 100644
index 00000000000..3417d77a06d
--- /dev/null
+++ b/doc/development/new_fe_guide/dependencies.md
@@ -0,0 +1,3 @@
+# Dependencies
+
+> TODO: Add Dependencies \ No newline at end of file
diff --git a/doc/development/new_fe_guide/development/accessibility.md b/doc/development/new_fe_guide/development/accessibility.md
new file mode 100644
index 00000000000..ed35f08432f
--- /dev/null
+++ b/doc/development/new_fe_guide/development/accessibility.md
@@ -0,0 +1,3 @@
+# Accessibility
+
+> TODO: Add content
diff --git a/doc/development/new_fe_guide/development/components.md b/doc/development/new_fe_guide/development/components.md
new file mode 100644
index 00000000000..637099d1e83
--- /dev/null
+++ b/doc/development/new_fe_guide/development/components.md
@@ -0,0 +1,3 @@
+# Components
+
+> TODO: Add content
diff --git a/doc/development/new_fe_guide/development/design_patterns.md b/doc/development/new_fe_guide/development/design_patterns.md
new file mode 100644
index 00000000000..ee06566ed30
--- /dev/null
+++ b/doc/development/new_fe_guide/development/design_patterns.md
@@ -0,0 +1,3 @@
+# Design patterns
+
+> TODO: Add content
diff --git a/doc/development/new_fe_guide/development/index.md b/doc/development/new_fe_guide/development/index.md
new file mode 100644
index 00000000000..cee8e43ebad
--- /dev/null
+++ b/doc/development/new_fe_guide/development/index.md
@@ -0,0 +1,29 @@
+# Development
+
+## [Design patterns](design_patterns.md)
+
+Examples of proven design patterns used in our codebase.
+
+## [Components](components.md)
+
+Documentation on existing components and how to best create a new component.
+
+## [Accessibility](accessibility.md)
+
+Learn how to implement an accessible frontend.
+
+## [Network requests](network_requests.md)
+
+Learn how to handle network requests in our codebase.
+
+## [Security](security.md)
+
+Learn how to ensure that our frontend is secure.
+
+## [Performance](performance.md)
+
+Learn how to keep our frontend performant.
+
+## [Testing](testing.md)
+
+Learn how to keep our frontend tested.
diff --git a/doc/development/new_fe_guide/development/network_requests.md b/doc/development/new_fe_guide/development/network_requests.md
new file mode 100644
index 00000000000..047c00313bc
--- /dev/null
+++ b/doc/development/new_fe_guide/development/network_requests.md
@@ -0,0 +1,3 @@
+# Network requests
+
+> TODO: Add content
diff --git a/doc/development/new_fe_guide/development/performance.md b/doc/development/new_fe_guide/development/performance.md
new file mode 100644
index 00000000000..244dfb3756f
--- /dev/null
+++ b/doc/development/new_fe_guide/development/performance.md
@@ -0,0 +1,16 @@
+# Performance
+
+## Monitoring
+
+We have a performance dashboard available in one of our [grafana instances](https://performance.gprd.gitlab.com/dashboard/db/sitespeed-page-summary?orgId=1). This dashboard automatically aggregates metric data from [sitespeed.io](https://sitespeed.io) every 6 hours. These changes are displayed after a set number of pages are aggregated.
+
+These pages can be found inside a text file in the gitlab-build-images [repository](https://gitlab.com/gitlab-org/gitlab-build-images) called [gitlab.txt](https://gitlab.com/gitlab-org/gitlab-build-images/blob/master/scripts/gitlab.txt)
+Any frontend engineer can contribute to this dashboard. They can contribute by adding or removing urls of pages from this text file. Please have a [frontend monitoring expert](https://about.gitlab.com/team) review your changes before assigning to a maintainer of the `gitlab-build-images` project. The changes will go live on the next scheduled run after the changes are merged into `master`.
+
+There are 3 recommended high impact metrics to review on each page
+
+* [First visual change](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint)
+* [Speed Index](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index)
+* [Visual Complete 95%](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index)
+
+For these metrics, lower numbers are better as it means that the website is more performant.
diff --git a/doc/development/new_fe_guide/development/security.md b/doc/development/new_fe_guide/development/security.md
new file mode 100644
index 00000000000..5bb38f17988
--- /dev/null
+++ b/doc/development/new_fe_guide/development/security.md
@@ -0,0 +1,14 @@
+# Security
+
+## Avoid inline scripts and styles
+
+Inline scripts and styles should be avoided in almost all cases. In an effort to protect users from [XSS vulnerabilities](https://en.wikipedia.org/wiki/Cross-site_scripting), we will be disabling inline scripts using Content Security Policy.
+
+## Including external resources
+
+External fonts, CSS, and JavaScript should never be used with the exception of Google Analytics and Piwik - and only when the instance has enabled it. Assets should always be hosted and served locally from the GitLab instance. Embedded resources via `iframes` should never be used except in certain circumstances such as with ReCaptcha, which cannot be used without an `iframe`.
+
+## Resources for security testing
+
+- [Mozilla's HTTP Observatory CLI](https://github.com/mozilla/http-observatory-cli)
+- [Qualys SSL Labs Server Test](https://www.ssllabs.com/ssltest/analyze.html)
diff --git a/doc/development/new_fe_guide/development/testing.md b/doc/development/new_fe_guide/development/testing.md
new file mode 100644
index 00000000000..c359bd83ed1
--- /dev/null
+++ b/doc/development/new_fe_guide/development/testing.md
@@ -0,0 +1,3 @@
+# Testing
+
+> TODO: Add content
diff --git a/doc/development/new_fe_guide/index.md b/doc/development/new_fe_guide/index.md
new file mode 100644
index 00000000000..78931defa24
--- /dev/null
+++ b/doc/development/new_fe_guide/index.md
@@ -0,0 +1,28 @@
+# Frontend Development Guidelines
+
+This guide contains all the information to successfully contribute to GitLab's frontend.
+This is a living document, and we welcome contributions, feedback and suggestions.
+
+## [Principles](principles.md)
+
+Ensure that your frontend contribution starts off in the right direction.
+
+## [Initiatives](initiatives.md)
+
+High level overview of where we are going from a frontend perspective.
+
+## [Development](development/index.md)
+
+Guidance on topics related to development.
+
+## [Dependencies](dependencies.md)
+
+Learn about all the dependencies that make up our frontend, including some of our own custom built libraries.
+
+## [Style guides](style/index.md)
+
+Style guides to keep our code consistent.
+
+## [Tips](tips.md)
+
+Tips from our frontend team to develop more efficiently and effectively.
diff --git a/doc/development/new_fe_guide/initiatives.md b/doc/development/new_fe_guide/initiatives.md
new file mode 100644
index 00000000000..c81ed3579f0
--- /dev/null
+++ b/doc/development/new_fe_guide/initiatives.md
@@ -0,0 +1,3 @@
+# Initiatives
+
+> TODO: Add Initiatives
diff --git a/doc/development/new_fe_guide/principles.md b/doc/development/new_fe_guide/principles.md
new file mode 100644
index 00000000000..0af5f506a91
--- /dev/null
+++ b/doc/development/new_fe_guide/principles.md
@@ -0,0 +1,35 @@
+# Principles
+
+These principles will ensure that your frontend contribution starts off in the right direction.
+
+## Discuss architecture before implementation
+
+Discuss your architecture design in an issue before writing code. This helps decrease the review time and also provides good practice for writing and thinking about system design.
+
+## Be consistent
+
+There are multiple ways of writing code to accomplish the same results. We should be as consistent as possible in how we write code across our codebases. This will make it more easier us to maintain our code across GitLab.
+
+## Enhance progressively
+
+Whenever you see with existing code that does not follow our current style guide, update it proactively. Refrain from changing everything but each merge request should progressively enhance our codebase and reduce technical debt.
+
+## When to use Vue
+
+- Use Vue for feature that make use of heavy DOM manipulation
+- Use Vue for reusable components
+
+## When to use jQuery
+
+- Use jQuery to interact with Bootstrap JavaScript components
+- Avoid jQuery when a better alternative exists. We are slowly moving away from it [#43559][jquery-future]
+
+## Mixing Vue and jQuery
+
+- Mixing Vue and jQuery is not recommended.
+- If you need to use a specific jQuery plugin in Vue, [create a wrapper around it][select2].
+- It is acceptable for Vue to listen to existing jQuery events using jQuery event listeners.
+- It is not recommended to add new jQuery events for Vue to interact with jQuery.
+
+[jquery-future]: https://gitlab.com/gitlab-org/gitlab-ce/issues/43559
+[select2]: https://vuejs.org/v2/examples/select2.html
diff --git a/doc/development/new_fe_guide/style/html.md b/doc/development/new_fe_guide/style/html.md
new file mode 100644
index 00000000000..2d5b7d048ab
--- /dev/null
+++ b/doc/development/new_fe_guide/style/html.md
@@ -0,0 +1,53 @@
+# HTML style guide
+
+## Buttons
+
+<a name="button-type"></a><a name="1.1"></a>
+- [1.1](#button-type) **Use button type** Button tags requires a `type` attribute according to the [W3C HTML specification][button-type-spec].
+
+```
+// bad
+<button></button>
+
+// good
+<button type="button"></button>
+```
+
+<a name="button-role"></a><a name="1.2"></a>
+- [1.2](#button-role) **Use button role for non buttons** If an HTML element has an onClick handler but is not a button, it should have `role="button"`. This is more [accessible][button-role-accessible].
+
+```
+// bad
+<div onClick="doSomething"></div>
+
+// good
+<div role="button" onClick="doSomething"></div>
+```
+
+## Links
+
+<a name="blank-links"></a><a name="2.1"></a>
+- [2.1](#blank-links) **Use rel for target blank** Use `rel="noopener noreferrer"` whenever your links open in a new window i.e. `target="_blank"`. This prevents [the following][jitbit-target-blank] security vulnerability documented by JitBit
+
+```
+// bad
+<a href="url" target="_blank"></a>
+
+// good
+<a href="url" target="_blank" rel="noopener noreferrer"></a>
+```
+
+<a name="fake-links"></a><a name="2.2"></a>
+- [2.2](#fake-links) **Do not use fake links** Use a button tag if a link only invokes JavaScript click event handlers. This is more semantic.
+
+```
+// bad
+<a class="js-do-something" href="#"></a>
+
+// good
+<button class="js-do-something" type="button"></button>
+```
+
+[button-type-spec]: https://www.w3.org/TR/2011/WD-html5-20110525/the-button-element.html#dom-button-type
+[button-role-accessible]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_button_role
+[jitbit-target-blank]: https://www.jitbit.com/alexblog/256-targetblank---the-most-underestimated-vulnerability-ever/
diff --git a/doc/development/new_fe_guide/style/index.md b/doc/development/new_fe_guide/style/index.md
new file mode 100644
index 00000000000..335d9e66240
--- /dev/null
+++ b/doc/development/new_fe_guide/style/index.md
@@ -0,0 +1,15 @@
+# Style guides
+
+## [HTML style guide](html.md)
+
+## [SCSS style guide](scss.md)
+
+## [JavaScript style guide](javascript.md)
+
+## [Vue style guide](vue.md)
+
+# Tooling
+
+## [Prettier](prettier.md)
+
+Our code is automatically formatted with [Prettier](https://prettier.io) to follow our guidelines.
diff --git a/doc/development/new_fe_guide/style/javascript.md b/doc/development/new_fe_guide/style/javascript.md
new file mode 100644
index 00000000000..57efd9353bc
--- /dev/null
+++ b/doc/development/new_fe_guide/style/javascript.md
@@ -0,0 +1,195 @@
+# JavaScript style guide
+
+We use [Airbnb's JavaScript Style Guide][airbnb-style-guide] and it's accompanying linter to manage most of our JavaScript style guidelines.
+
+In addition to the style guidelines set by Airbnb, we also have a few specific rules listed below.
+
+> **Tip:**
+You can run eslint locally by running `yarn eslint`
+
+## Arrays
+
+<a name="avoid-foreach"></a><a name="1.1"></a>
+- [1.1](#avoid-foreach) **Avoid ForEach when mutating data** Use `map`, `reduce` or `filter` instead of `forEach` when mutating data. This will minimize mutations in functions ([which is aligned with Airbnb's style guide][airbnb-minimize-mutations])
+
+```
+// bad
+users.forEach((user, index) => {
+ user.id = index;
+});
+
+// good
+const usersWithId = users.map((user, index) => {
+ return Object.assign({}, user, { id: index });
+});
+```
+
+## Functions
+
+<a name="limit-params"></a><a name="2.1"></a>
+- [2.1](#limit-params) **Limit number of parameters** If your function or method has more than 3 parameters, use an object as a parameter instead.
+
+```
+// bad
+function a(p1, p2, p3) {
+ // ...
+};
+
+// good
+function a(p) {
+ // ...
+};
+```
+
+## Classes & constructors
+
+<a name="avoid-constructor-side-effects"></a><a name="3.1"></a>
+- [3.1](#avoid-constructor-side-effects) **Avoid side effects in constructors** Avoid making some operations in the `constructor`, such as asynchronous calls, API requests and DOM manipulations. Prefer moving them into separate functions. This will make tests easier to write and code easier to maintain.
+
+ ```javascript
+ // bad
+ class myClass {
+ constructor(config) {
+ this.config = config;
+ axios.get(this.config.endpoint)
+ }
+ }
+
+ // good
+ class myClass {
+ constructor(config) {
+ this.config = config;
+ }
+
+ makeRequest() {
+ axios.get(this.config.endpoint)
+ }
+ }
+ const instance = new myClass();
+ instance.makeRequest();
+
+ ```
+
+<a name="avoid-classes-to-handle-dom-events"></a><a name="3.2"></a>
+- [3.2](#avoid-classes-to-handle-dom-events) **Avoid classes to handle DOM events** If the only purpose of the class is to bind a DOM event and handle the callback, prefer using a function.
+
+```
+// bad
+class myClass {
+ constructor(config) {
+ this.config = config;
+ }
+
+ init() {
+ document.addEventListener('click', () => {});
+ }
+}
+
+// good
+
+const myFunction = () => {
+ document.addEventListener('click', () => {
+ // handle callback here
+ });
+}
+```
+
+<a name="element-container"></a><a name="3.3"></a>
+- [3.3](#element-container) **Pass element container to constructor** When your class manipulates the DOM, receive the element container as a parameter.
+This is more maintainable and performant.
+
+```
+// bad
+class a {
+ constructor() {
+ document.querySelector('.b');
+ }
+}
+
+// good
+class a {
+ constructor(options) {
+ options.container.querySelector('.b');
+ }
+}
+```
+
+## Type Casting & Coercion
+
+<a name="use-parseint"></a><a name="4.1"></a>
+- [4.1](#use-parseint) **Use ParseInt** Use `ParseInt` when converting a numeric string into a number.
+
+```
+// bad
+Number('10')
+
+
+// good
+parseInt('10', 10);
+```
+
+## CSS Selectors
+
+<a name="use-js-prefix"></a><a name="5.1"></a>
+- [5.1](#use-js-prefix) **Use js prefix** If a CSS class is only being used in JavaScript as a reference to the element, prefix the class name with `js-`
+
+```
+// bad
+<button class="add-user"></button>
+
+// good
+<button class="js-add-user"></button>
+```
+
+## Modules
+
+<a name="use-absolute-paths"></a><a name="6.1"></a>
+- [6.1](#use-absolute-paths) **Use absolute paths for nearby modules** Use absolute paths if the module you are importing is less than two levels up.
+
+```
+// bad
+import GitLabStyleGuide from '~/guides/GitLabStyleGuide';
+
+// good
+import GitLabStyleGuide from '../GitLabStyleGuide';
+```
+
+<a name="use-relative-paths"></a><a name="6.2"></a>
+- [6.2](#use-relative-paths) **Use relative paths for distant modules** If the module you are importing is two or more levels up, use a relative path instead of an absolute path.
+
+```
+// bad
+import GitLabStyleGuide from '../../../guides/GitLabStyleGuide';
+
+// good
+import GitLabStyleGuide from '~/GitLabStyleGuide';
+```
+
+<a name="global-namespace"></a><a name="6.3"></a>
+- [6.3](#global-namespace) **Do not add to global namespace**
+
+<a name="domcontentloaded"></a><a name="6.4"></a>
+- [6.4](#domcontentloaded) **Do not use DOMContentLoaded in non-page modules** Imported modules should act the same each time they are loaded. `DOMContentLoaded` events are only allowed on modules loaded in the `/pages/*` directory because those are loaded dynamically with webpack.
+
+## Security
+
+<a name="avoid-xss"></a><a name="7.1"></a>
+- [7.1](#avoid-xss) **Avoid XSS** Do not use `innerHTML`, `append()` or `html()` to set content. It opens up too many vulnerabilities.
+
+## ESLint
+
+<a name="disable-eslint-file"></a><a name="8.1"></a>
+- [8.1](#disable-eslint-file) **Disabling ESLint in new files** Do not disable ESLint when creating new files. Existing files may have existing rules disabled due to legacy compatibility reasons but they are in the process of being refactored.
+
+<a name="disable-eslint-rule"></a><a name="8.2"></a>
+- [8.2](#disable-eslint-rule) **Disabling ESLint rule** Do not disable specific ESLint rules. Due to technical debt, you may disable the following rules only if you are invoking/instantiating existing code modules
+
+ - [no-new][no-new]
+ - [class-method-use-this][class-method-use-this]
+
+> Note: Disable these rules on a per line basis. This makes it easier to refactor in the future. E.g. use `eslint-disable-next-line` or `eslint-disable-line`
+
+[airbnb-style-guide]: https://github.com/airbnb/javascript
+[airbnb-minimize-mutations]: https://github.com/airbnb/javascript#testing--for-real
+[no-new]: http://eslint.org/docs/rules/no-new
+[class-method-use-this]: http://eslint.org/docs/rules/class-methods-use-this
diff --git a/doc/development/new_fe_guide/style/prettier.md b/doc/development/new_fe_guide/style/prettier.md
new file mode 100644
index 00000000000..eb18189282b
--- /dev/null
+++ b/doc/development/new_fe_guide/style/prettier.md
@@ -0,0 +1,45 @@
+# Formatting with Prettier
+
+Our code is automatically formatted with [Prettier](https://prettier.io) to follow our style guides. Prettier is taking care of formatting .js, .vue, and .scss files based on the standard prettier rules. You can find all settings for Prettier in `.prettierrc`.
+
+## Editor
+
+The easiest way to include prettier in your workflow is by setting up your preferred editor (all major editors are supported) accordingly. We suggest setting up prettier to run automatically when each file is saved. Find [here](https://prettier.io/docs/en/editors.html) the best way to set it up in your preferred editor.
+
+Please take care that you only let Prettier format the same file types as the global Yarn script does (.js, .vue, and .scss). In VSCode by example you can easily exclude file formats in your settings file:
+
+```
+ "prettier.disableLanguages": [
+ "json",
+ "markdown"
+ ],
+```
+
+## Yarn Script
+
+The following yarn scripts are available to do global formatting:
+
+```
+yarn prettier-staged-save
+```
+
+Updates all currently staged files (based on `git diff`) with Prettier and saves the needed changes.
+
+```
+yarn prettier-staged
+```
+Checks all currently staged files (based on `git diff`) with Prettier and log which files would need manual updating to the console.
+
+```
+yarn prettier-all
+```
+
+Checks all files with Prettier and logs which files need manual updating to the console.
+
+```
+yarn prettier-all-save
+```
+
+Formats all files in the repository with Prettier. (This should only be used to test global rule updates otherwise you would end up with huge MR's).
+
+The source of these Yarn scripts can be found in `/scripts/frontend/prettier.js`.
diff --git a/doc/development/new_fe_guide/style/scss.md b/doc/development/new_fe_guide/style/scss.md
new file mode 100644
index 00000000000..6f5e818d7db
--- /dev/null
+++ b/doc/development/new_fe_guide/style/scss.md
@@ -0,0 +1,3 @@
+# SCSS style guide
+
+> TODO: Add content
diff --git a/doc/development/new_fe_guide/style/vue.md b/doc/development/new_fe_guide/style/vue.md
new file mode 100644
index 00000000000..fd9353e0d3f
--- /dev/null
+++ b/doc/development/new_fe_guide/style/vue.md
@@ -0,0 +1,3 @@
+# Vue style guide
+
+> TODO: Add content
diff --git a/doc/development/new_fe_guide/tips.md b/doc/development/new_fe_guide/tips.md
new file mode 100644
index 00000000000..f0cdf52d618
--- /dev/null
+++ b/doc/development/new_fe_guide/tips.md
@@ -0,0 +1,3 @@
+# Tips
+
+> TODO: Add tips
diff --git a/doc/development/performance.md b/doc/development/performance.md
index e7c5a6ca07a..c4162a05b77 100644
--- a/doc/development/performance.md
+++ b/doc/development/performance.md
@@ -36,7 +36,8 @@ graphs/dashboards.
GitLab provides built-in tools to aid the process of improving performance:
-* [Sherlock](profiling.md#sherlock)
+* [Profiling](profiling.md)
+ * [Sherlock](profiling.md#sherlock)
* [GitLab Performance Monitoring](../administration/monitoring/performance/index.md)
* [Request Profiling](../administration/monitoring/performance/request_profiling.md)
* [QueryRecoder](query_recorder.md) for preventing `N+1` regressions
diff --git a/doc/development/profiling.md b/doc/development/profiling.md
index af79353b721..11878b4009b 100644
--- a/doc/development/profiling.md
+++ b/doc/development/profiling.md
@@ -4,6 +4,52 @@ To make it easier to track down performance problems GitLab comes with a set of
profiling tools, some of these are available by default while others need to be
explicitly enabled.
+## Profiling a URL
+
+There is a `Gitlab::Profiler.profile` method, and corresponding
+`bin/profile-url` script, that enable profiling a GET or POST request to a
+specific URL, either as an anonymous user (the default) or as a specific user.
+
+When using the script, command-line documentation is available by passing no
+arguments.
+
+When using the method in an interactive console session, any changes to the
+application code within that console session will be reflected in the profiler
+output.
+
+For example:
+
+```ruby
+Gitlab::Profiler.profile('/my-user')
+# Returns a RubyProf::Profile for the regular operation of this request
+class UsersController; def show; sleep 100; end; end
+Gitlab::Profiler.profile('/my-user')
+# Returns a RubyProf::Profile where 100 seconds is spent in UsersController#show
+```
+
+For routes that require authorization you will need to provide a user to
+`Gitlab::Profiler`. You can do this like so:
+
+```ruby
+Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first)
+```
+
+The user you provide will need to have a [personal access
+token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html) in
+the GitLab instance.
+
+Passing a `logger:` keyword argument to `Gitlab::Profiler.profile` will send
+ActiveRecord and ActionController log output to that logger. Further options are
+documented with the method source.
+
+[GitLab-Profiler](https://gitlab.com/gitlab-com/gitlab-profiler) is a project
+that builds on this to add some additional niceties, such as allowing
+configuration with a single Yaml file for multiple URLs, and uploading of the
+profile and log output to S3.
+
+For GitLab.com, you can find the latest results here:
+<http://redash.gitlab.com/dashboard/gitlab-profiler-statistics>
+
## Sherlock
Sherlock is a custom profiling tool built into GitLab. Sherlock is _only_
@@ -27,13 +73,3 @@ Bullet will log query problems to both the Rails log as well as the Chrome
console.
As a follow up to finding `N+1` queries with Bullet, consider writing a [QueryRecoder test](query_recorder.md) to prevent a regression.
-
-## GitLab Profiler
-
-
-[Gitlab-Profiler](https://gitlab.com/gitlab-com/gitlab-profiler) was built to
-help developers understand why specific URLs of their application may be slow
-and to provide hard data that can help reduce load times.
-
-For GitLab.com, you can find the latest results here:
-<http://redash.gitlab.com/dashboard/gitlab-profiler-statistics>
diff --git a/doc/development/query_count_limits.md b/doc/development/query_count_limits.md
new file mode 100644
index 00000000000..310e3faf61b
--- /dev/null
+++ b/doc/development/query_count_limits.md
@@ -0,0 +1,64 @@
+# Query Count Limits
+
+Each controller or API endpoint is allowed to execute up to 100 SQL queries and
+in test environments we'll raise an error when this threshold is exceeded.
+
+## Solving Failing Tests
+
+When a test fails because it executes more than 100 SQL queries there are two
+solutions to this problem:
+
+1. Reduce the number of SQL queries that are executed.
+2. Whitelist the controller or API endpoint.
+
+You should only resort to whitelisting when an existing controller or endpoint
+is to blame as in this case reducing the number of SQL queries can take a lot of
+effort. Newly added controllers and endpoints are not allowed to execute more
+than 100 SQL queries and no exceptions will be made for this rule. _If_ a large
+number of SQL queries is necessary to perform certain work it's best to have
+this work performed by Sidekiq instead of doing this directly in a web request.
+
+## Whitelisting
+
+In the event that you _have_ to whitelist a controller you'll first need to
+create an issue. This issue should (preferably in the title) mention the
+controller or endpoint and include the appropriate labels (`database`,
+`performance`, and at least a team specific label such as `Discussion`).
+
+Once the issue has been created you can whitelist the code in question. For
+Rails controllers it's best to create a `before_action` hook that runs as early
+as possible. The called method in turn should call
+`Gitlab::QueryLimiting.whitelist('issue URL here')`. For example:
+
+```ruby
+class MyController < ApplicationController
+ before_action :whitelist_query_limiting, only: [:show]
+
+ def index
+ # ...
+ end
+
+ def show
+ # ...
+ end
+
+ def whitelist_query_limiting
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/...')
+ end
+end
+```
+
+By using a `before_action` you don't have to modify the controller method in
+question, reducing the likelihood of merge conflicts.
+
+For Grape API endpoints there unfortunately is not a reliable way of running a
+hook before a specific endpoint. This means that you have to add the whitelist
+call directly into the endpoint like so:
+
+```ruby
+get '/projects/:id/foo' do
+ Gitlab::QueryLimiting.whitelist('...')
+
+ # ...
+end
+```
diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md
index ceff57276d2..fdfa1f10402 100644
--- a/doc/development/rake_tasks.md
+++ b/doc/development/rake_tasks.md
@@ -8,7 +8,7 @@ Note that if your db user does not have advanced privileges you must create the
bundle exec rake setup
```
-The `setup` task is a alias for `gitlab:setup`.
+The `setup` task is an 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.
@@ -102,6 +102,12 @@ variable to `1`:
export ENABLE_SPRING=1
```
+Alternatively you can use the following on each spec run,
+
+```
+bundle exec spring rspec some_spec.rb
+```
+
## Compile Frontend Assets
You shouldn't ever need to compile frontend assets manually in development, but
diff --git a/doc/development/sidekiq_style_guide.md b/doc/development/sidekiq_style_guide.md
index 59ebf41e09f..76ff51446ba 100644
--- a/doc/development/sidekiq_style_guide.md
+++ b/doc/development/sidekiq_style_guide.md
@@ -17,6 +17,9 @@ would be `process_something`. If you're not sure what queue a worker uses,
you can find it using `SomeWorker.queue`. There is almost never a reason to
manually override the queue name using `sidekiq_options queue: :some_queue`.
+You must always add any new queues to `app/workers/all_queues.yml` otherwise
+your worker will not run.
+
## Queue Namespaces
While different workers cannot share a queue, they can share a queue namespace.
diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md
index edb8f372ea3..df80cd9f584 100644
--- a/doc/development/testing_guide/best_practices.md
+++ b/doc/development/testing_guide/best_practices.md
@@ -26,7 +26,6 @@ Here are some things to keep in mind regarding test performance:
- Use `.method` to describe class methods and `#method` to describe instance
methods.
- Use `context` to test branching logic.
-- 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)).
- 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.
@@ -89,6 +88,8 @@ Finished in 34.51 seconds (files took 0.76702 seconds to load)
1 example, 0 failures
```
+Note: `live_debug` only works on javascript enabled specs.
+
### `let` variables
GitLab's RSpec suite has made extensive use of `let` variables to reduce
diff --git a/doc/development/testing_guide/end_to_end_tests.md b/doc/development/testing_guide/end_to_end_tests.md
new file mode 100644
index 00000000000..d10a797a142
--- /dev/null
+++ b/doc/development/testing_guide/end_to_end_tests.md
@@ -0,0 +1,80 @@
+# End-to-End Testing
+
+## What is End-to-End testing?
+
+End-to-End testing is a strategy used to check whether your application works
+as expected across entire software stack and architecture, including
+integration of all microservices and components that are supposed to work
+together.
+
+## How do we test GitLab?
+
+We use [Omnibus GitLab][omnibus-gitlab] to build GitLab packages and then we
+test these packages using [GitLab QA][gitlab-qa] project, which is entirely
+black-box, click-driven testing framework.
+
+### Testing nightly builds
+
+We run scheduled pipeline each night to test nightly builds created by Omnibus.
+You can find these nightly pipelines at [GitLab QA pipelines page][gitlab-qa-pipelines].
+
+### Testing code in merge requests
+
+It is possible to run end-to-end tests (eventually being run within a
+[GitLab QA pipeline][gitlab-qa-pipelines]) for a merge request by triggering
+the `package-and-qa` manual action, that should be present in a merge request
+widget.
+
+Manual action that starts end-to-end tests is also available in merge requests
+in Omnibus GitLab project.
+
+Below you can read more about how to use it and how does it work.
+
+#### How does it work?
+
+Currently, we are using _multi-project pipeline_-like approach to run QA
+pipelines.
+
+1. Developer triggers a manual action, that can be found in CE and EE merge
+requests. This starts a chain of pipelines in multiple projects.
+
+1. The script being executed triggers a pipeline in GitLab Omnibus and waits
+for the resulting status. We call this a _status attribution_.
+
+1. GitLab packages are being built in Omnibus pipeline. Packages are going to be
+pushed to Container Registry.
+
+1. When packages are ready, and available in the registry, a final step in the
+pipeline, that is now running in Omnibus, triggers a new pipeline in the GitLab
+QA project. It also waits for a resulting status.
+
+1. GitLab QA pulls images from the registry, spins-up containers and runs tests
+against a test environment that has been just orchestrated by the `gitlab-qa`
+tool.
+
+1. The result of the GitLab QA pipeline is being propagated upstream, through
+Omnibus, back to CE / EE merge request.
+
+#### How do I write tests?
+
+In order to write new tests, you first need to learn more about GitLab QA
+architecture. See the [documentation about it][gitlab-qa-architecture] in
+GitLab QA project.
+
+Once you decided where to put test environment orchestration scenarios and
+instance specs, take a look at the [relevant documentation][instance-qa-readme]
+and examples in [the `qa/` directory][instance-qa-examples].
+
+## Where can I ask for help?
+
+You can ask question in the `#qa` channel on Slack (GitLab internal) or you can
+find an issue you would like to work on in [the issue tracker][gitlab-qa-issues]
+and start a new discussion there.
+
+[omnibus-gitlab]: https://gitlab.com/gitlab-org/omnibus-gitlab
+[gitlab-qa]: https://gitlab.com/gitlab-org/gitlab-qa
+[gitlab-qa-pipelines]: https://gitlab.com/gitlab-org/gitlab-qa/pipelines
+[gitlab-qa-architecture]: https://gitlab.com/gitlab-org/gitlab-qa/blob/master/docs/architecture.md
+[gitlab-qa-issues]: https://gitlab.com/gitlab-org/gitlab-qa/issues
+[instance-qa-readme]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/qa/README.md
+[instance-qa-examples]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/qa/qa
diff --git a/doc/development/testing_guide/index.md b/doc/development/testing_guide/index.md
index 65386f231a0..74d09eb91ff 100644
--- a/doc/development/testing_guide/index.md
+++ b/doc/development/testing_guide/index.md
@@ -65,6 +65,13 @@ Everything you should know about how to test Rake tasks.
---
+## [End-to-end tests](end_to_end_tests.md)
+
+Everything you should know about how to run end-to-end tests using
+[GitLab QA][gitlab-qa] testing framework.
+
+---
+
## Spinach (feature) tests
GitLab [moved from Cucumber to Spinach](https://github.com/gitlabhq/gitlabhq/pull/1426)
@@ -89,3 +96,4 @@ test should be re-implemented using RSpec instead.
[Capybara]: https://github.com/teamcapybara/capybara
[Karma]: http://karma-runner.github.io/
[Jasmine]: https://jasmine.github.io/
+[gitlab-qa]: https://gitlab.com/gitlab-org/gitlab-qa
diff --git a/doc/development/testing_guide/testing_levels.md b/doc/development/testing_guide/testing_levels.md
index 1cbd4350284..e86c1f5232a 100644
--- a/doc/development/testing_guide/testing_levels.md
+++ b/doc/development/testing_guide/testing_levels.md
@@ -121,6 +121,9 @@ running feature tests (i.e. using Capybara) against it.
The actual test scenarios and steps are [part of GitLab Rails] so that they're
always in-sync with the codebase.
+Read a separate document about [end-to-end tests](end_to_end_tests.md) to
+learn more.
+
[multiple pieces]: ../architecture.md#components
[GitLab Shell]: https://gitlab.com/gitlab-org/gitlab-shell
[GitLab Workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse
@@ -131,6 +134,10 @@ always in-sync with the codebase.
[GitLab QA]: https://gitlab.com/gitlab-org/gitlab-qa
[part of GitLab Rails]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/qa
+## EE-specific tests
+
+EE-specific tests follows the same organization, but under the `ee/spec` folder.
+
## How to test at the correct level?
As many things in life, deciding what to test at each level of testing is a
diff --git a/doc/development/utilities.md b/doc/development/utilities.md
new file mode 100644
index 00000000000..8f9aff1a35f
--- /dev/null
+++ b/doc/development/utilities.md
@@ -0,0 +1,137 @@
+# GitLab utilities
+
+We developed a number of utilities to ease development.
+
+## [`MergeHash`](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/utils/merge_hash.rb)
+
+* Deep merges an array of hashes:
+
+ ``` ruby
+ Gitlab::Utils::MergeHash.merge(
+ [{ hello: ["world"] },
+ { hello: "Everyone" },
+ { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] } },
+ "Goodbye", "Hallo"]
+ )
+ ```
+
+ Gives:
+
+ ``` ruby
+ [
+ {
+ hello:
+ [
+ "world",
+ "Everyone",
+ { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] }
+ ]
+ },
+ "Goodbye"
+ ]
+ ```
+
+* Extracts all keys and values from a hash into an array:
+
+ ``` ruby
+ Gitlab::Utils::MergeHash.crush(
+ { hello: "world", this: { crushes: ["an entire", "hash"] } }
+ )
+ ```
+
+ Gives:
+
+ ``` ruby
+ [:hello, "world", :this, :crushes, "an entire", "hash"]
+ ```
+
+## [`Override`](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/utils/override.rb)
+
+* This utility could help us check if a particular method would override
+ another method or not. It has the same idea of Java's `@Override` annotation
+ or Scala's `override` keyword. However we only do this check when
+ `ENV['STATIC_VERIFICATION']` is set to avoid production runtime overhead.
+ This is useful to check:
+
+ * If we have typos in overriding methods.
+ * If we renamed the overridden methods, making original overriding methods
+ overrides nothing.
+
+ Here's a simple example:
+
+ ``` ruby
+ class Base
+ def execute
+ end
+ end
+
+ class Derived < Base
+ extend ::Gitlab::Utils::Override
+
+ override :execute # Override check happens here
+ def execute
+ end
+ end
+ ```
+
+ This also works on modules:
+
+ ``` ruby
+ module Extension
+ extend ::Gitlab::Utils::Override
+
+ override :execute # Modules do not check this immediately
+ def execute
+ end
+ end
+
+ class Derived < Base
+ prepend Extension # Override check happens here, not in the module
+ end
+ ```
+
+## [`StrongMemoize`](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/utils/strong_memoize.rb)
+
+* Memoize the value even if it is `nil` or `false`.
+
+ We often do `@value ||= compute`, however this doesn't work well if
+ `compute` might eventually give `nil` and we don't want to compute again.
+ Instead we could use `defined?` to check if the value is set or not.
+ However it's tedious to write such pattern, and `StrongMemoize` would
+ help us use such pattern.
+
+ Instead of writing patterns like this:
+
+ ``` ruby
+ class Find
+ def result
+ return @result if defined?(@result)
+
+ @result = search
+ end
+ end
+ ```
+
+ We could write it like:
+
+ ``` ruby
+ class Find
+ include Gitlab::Utils::StrongMemoize
+
+ def result
+ strong_memoize(:result) do
+ search
+ end
+ end
+ end
+ ```
+
+* Clear memoization
+
+ ``` ruby
+ class Find
+ include Gitlab::Utils::StrongMemoize
+ end
+
+ Find.new.clear_memoization(:result)
+ ```
diff --git a/doc/development/ux_guide/animation.md b/doc/development/ux_guide/animation.md
index d190ee1b0ff..797390a6845 100644
--- a/doc/development/ux_guide/animation.md
+++ b/doc/development/ux_guide/animation.md
@@ -27,7 +27,7 @@ View the [interactive example](http://codepen.io/awhildy/full/GNyEvM/) here.
### Dropdowns
-The dropdown menu should feel like it is appearing from the triggering element. Combining a position shift `400ms cubic-bezier(0.23, 1, 0.32, 1)` with a opacity animation `200ms linear` on the second half of the motion achieves this affect.
+The dropdown menu should feel like it is appearing from the triggering element. Combining a position shift `400ms cubic-bezier(0.23, 1, 0.32, 1)` with an opacity animation `200ms linear` on the second half of the motion achieves this affect.
View the [interactive example](http://codepen.io/awhildy/full/jVLJpb/) here.
diff --git a/doc/development/ux_guide/components.md b/doc/development/ux_guide/components.md
index d396964e7c1..012c64be79f 100644
--- a/doc/development/ux_guide/components.md
+++ b/doc/development/ux_guide/components.md
@@ -108,7 +108,7 @@ Primary buttons communicate the main call to action. There should only be one ca
![Primary button example](img/button-primary.png)
#### Secondary
-Secondary buttons are for alternative commands. They should be conveyed by a button with an stroke, and no background fill.
+Secondary buttons are for alternative commands. They should be conveyed by a button with a stroke, and no background fill.
![Secondary button example](img/button-secondary.png)
@@ -181,7 +181,7 @@ A count element is used in navigation contexts where it is helpful to indicate t
## Lists
-Lists are used where ever there is a single column of information to display. Ths [issues list](https://gitlab.com/gitlab-org/gitlab-ce/issues) is an example of a important list in the GitLab UI.
+Lists are used where ever there is a single column of information to display. Ths [issues list](https://gitlab.com/gitlab-org/gitlab-ce/issues) is an example of an important list in the GitLab UI.
### Types
@@ -269,7 +269,7 @@ Modals are only used for having a conversation and confirmation with the user. T
* Modals contain the header, body, and actions.
* **Header(1):** The header title is a question instead of a descriptive phrase.
* **Body(2):** The content in body should never be ambiguous and unclear. It provides specific information.
- * **Actions(3):** Contains a affirmative action, a dismissive action, and an extra action. The order of actions from left to right: Dismissive action → Extra action → Affirmative action
+ * **Actions(3):** Contains an affirmative action, a dismissive action, and an extra action. The order of actions from left to right: Dismissive action → Extra action → Affirmative action
* Confirmations regarding labels should keep labeling styling.
* References to commits, branches, and tags should be **monospaced**.
diff --git a/doc/development/ux_guide/copy.md b/doc/development/ux_guide/copy.md
index af842da7f62..070efdc15b5 100644
--- a/doc/development/ux_guide/copy.md
+++ b/doc/development/ux_guide/copy.md
@@ -27,7 +27,7 @@ This means that, as a rule, copy should be very short. A long message or label i
>**Example:**
Use `Add` instead of `Add issue` as a button label.
-Preferrably use context and placement of controls to make it obvious what clicking on them will do.
+Preferably use context and placement of controls to make it obvious what clicking on them will do.
---
diff --git a/doc/development/ux_guide/index.md b/doc/development/ux_guide/index.md
index 42bcf234e12..c59e7b72a1a 100644
--- a/doc/development/ux_guide/index.md
+++ b/doc/development/ux_guide/index.md
@@ -1,3 +1,5 @@
+> We are in the process of transferring UX documentation to the [design.gitlab.com](https://gitlab.com/gitlab-org/design.gitlab.com) project. Any updates to these docs should be made in that project. If documentation does not yet exist within [design.gitlab.com](https://gitlab.com/gitlab-org/design.gitlab.com), [create an issue](https://gitlab.com/gitlab-org/design.gitlab.com/issues) and merge request to add your new changes.
+
# GitLab UX Guide
The goal of this guide is to provide standards, principles and in-depth information to design beautiful and effective GitLab features. This will be a living document, and we welcome contributions, feedback and suggestions.
diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md
index 05e0a64af18..9d0c62ecc35 100644
--- a/doc/development/what_requires_downtime.md
+++ b/doc/development/what_requires_downtime.md
@@ -195,6 +195,63 @@ end
And that's it, we're done!
+## Changing Column Types For Large Tables
+
+While `change_column_type_concurrently` can be used for changing the type of a
+column without downtime it doesn't work very well for large tables. Because all
+of the work happens in sequence the migration can take a very long time to
+complete, preventing a deployment from proceeding.
+`change_column_type_concurrently` can also produce a lot of pressure on the
+database due to it rapidly updating many rows in sequence.
+
+To reduce database pressure you should instead use
+`change_column_type_using_background_migration` when migrating a column in a
+large table (e.g. `issues`). This method works similar to
+`change_column_type_concurrently` but uses background migration to spread the
+work / load over a longer time period, without slowing down deployments.
+
+Usage of this method is fairly simple:
+
+```ruby
+class ExampleMigration < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ class Issue < ActiveRecord::Base
+ self.table_name = 'issues'
+
+ include EachBatch
+
+ def self.to_migrate
+ where('closed_at IS NOT NULL')
+ end
+ end
+
+ def up
+ change_column_type_using_background_migration(
+ Issue.to_migrate,
+ :closed_at,
+ :datetime_with_timezone
+ )
+ end
+
+ def down
+ change_column_type_using_background_migration(
+ Issue.to_migrate,
+ :closed_at,
+ :datetime
+ )
+ end
+end
+```
+
+This would change the type of `issues.closed_at` to `timestamp with time zone`.
+
+Keep in mind that the relation passed to
+`change_column_type_using_background_migration` _must_ include `EachBatch`,
+otherwise it will raise a `TypeError`.
+
## Adding Indexes
Adding indexes is an expensive process that blocks INSERT and UPDATE queries for
diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md
index 43a79ffcaa5..d6a13e7483a 100644
--- a/doc/development/writing_documentation.md
+++ b/doc/development/writing_documentation.md
@@ -1,13 +1,9 @@
-# Writing documentation
+# GitLab Documentation guidelines
- **General Documentation**: written by the [developers responsible by creating features](#contributing-to-docs). Should be submitted in the same merge request containing code. Feature proposals (by GitLab contributors) should also be accompanied by its respective documentation. They can be later improved by PMs and Technical Writers.
- **[Technical Articles](#technical-articles)**: written by any [GitLab Team](https://about.gitlab.com/team/) member, GitLab contributors, or [Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/).
- **Indexes per topic**: initially prepared by the Technical Writing Team, and kept up-to-date by developers and PMs in the same merge request containing code. They gather all resources for that topic in a single page (user and admin documentation, articles, and third-party docs).
-## Documentation style guidelines
-
-All the docs follow the same [styleguide](doc_styleguide.md).
-
## Contributing to docs
Whenever a feature is changed, updated, introduced, or deprecated, the merge
@@ -15,23 +11,38 @@ request introducing these changes must be accompanied by the documentation
(either updating existing ones or creating new ones). This is also valid when
changes are introduced to the UI.
-The one resposible for writing the first piece of documentation is the developer who
+The one responsible for writing the first piece of documentation is the developer who
wrote the code. It's the job of the Product Manager to ensure all features are
shipped with its docs, whether is a small or big change. At the pace GitLab evolves,
this is the only way to keep the docs up-to-date. If you have any questions about it,
-please ask a Technical Writer. Otherwise, when your content is ready, assign one of
+ask a Technical Writer. Otherwise, when your content is ready, assign one of
them to review it for you.
We use the [monthly release blog post](https://about.gitlab.com/handbook/marketing/blog/release-posts/#monthly-releases) as a changelog checklist to ensure everything
is documented.
+Whenever you submit a merge request for the documentation, use the documentation MR description template.
+
+Please check the [documentation workflow](https://about.gitlab.com/handbook/product/technical-writing/workflow/) before getting started.
+
+## Documentation structure
+
+- Overview and use cases: what it is, why it is necessary, why one would use it
+- Requirements: what do we need to get started
+- Tutorial: how to set it up, how to use it
+
+Always link a new document from its topic-related index, otherwise, it will
+not be included it in the documentation site search.
+
+_Note: to be extended._
+
### Feature overview and use cases
Every major feature (regardless if present in GitLab Community or Enterprise editions)
should present, at the beginning of the document, two main sections: **overview** and
**use cases**. Every GitLab EE-only feature should also contain these sections.
-**Overview**: at the name suggests, the goal here is to provide an overview of the feature.
+**Overview**: as the name suggests, the goal here is to provide an overview of the feature.
Describe what is it, what it does, why it is important/cool/nice-to-have,
what problem it solves, and what you can do with this feature that you couldn't
do before.
@@ -53,16 +64,169 @@ overview there.
> **Overview** and **use cases** are required to **every** Enterprise Edition feature,
and for every **major** feature present in Community Edition.
-### Markdown
+## Markdown and styles
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.
-### Previewing locally
+All the docs follow the [documentation style guidelines](doc_styleguide.md).
+
+## Documentation directory structure
+
+The documentation is structured based on the GitLab UI structure itself,
+separated by [`user`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/user),
+[`administrator`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/administration), and [`contributor`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/development).
+
+In order to have a [solid site structure](https://searchengineland.com/seo-benefits-developing-solid-site-structure-277456) for our documentation,
+all docs should be linked. Every new document should be cross-linked to its related documentation, and linked from its topic-related index, when existent.
+
+The directories `/workflow/`, `/gitlab-basics/`, `/university/`, and `/articles/` have
+been deprecated and the majority their docs have been moved to their correct location
+in small iterations. Please don't create new docs in these folders.
+
+### Location and naming documents
+
+The documentation hierarchy can be vastly improved by providing a better layout
+and organization of directories.
+
+Having a structured document layout, we will be able to have meaningful URLs
+like `docs.gitlab.com/user/project/merge_requests/index.html`. With this pattern,
+you can immediately tell that you are navigating a user related documentation
+and is about the project and its merge requests.
+
+Do not create summaries of similar types of content (e.g. an index of all articles, videos, etc.),
+rather organize content by its subject (e.g. everything related to CI goes together)
+and cross-link between any related content.
+
+The table below shows what kind of documentation goes where.
+
+| Directory | What belongs here |
+| --------- | -------------- |
+| `doc/user/` | User related documentation. Anything that can be done within the GitLab UI goes here including `/admin`. |
+| `doc/administration/` | Documentation that requires the user to have access to the server where GitLab is installed. The admin settings that can be accessed via GitLab's interface go under `doc/user/admin_area/`. |
+| `doc/api/` | API related documentation. |
+| `doc/development/` | Documentation related to the development of GitLab. Any styleguides should go here. |
+| `doc/legal/` | Legal documents about contributing to GitLab. |
+| `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). |
+| `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. |
+| `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`): all resources for that topic (user and admin documentation, articles, and third-party docs) |
+
+---
+
+**General rules:**
+
+1. The correct naming and location of a new document, is a combination
+ of the relative URL of the document in question and the GitLab Map design
+ that is used for UX purposes ([source][graffle], [image][gitlab-map]).
+1. When creating a new document and it has more than one word in its name,
+ make sure to use underscores instead of spaces or dashes (`-`). For example,
+ a proper naming would be `import_projects_from_github.md`. The same rule
+ applies to images.
+1. Start a new directory with an `index.md` file.
+1. There are four main directories, `user`, `administration`, `api` and `development`.
+1. The `doc/user/` directory has five main subdirectories: `project/`, `group/`,
+ `profile/`, `dashboard/` and `admin_area/`.
+ 1. `doc/user/project/` should contain all project related documentation.
+ 1. `doc/user/group/` should contain all group related documentation.
+ 1. `doc/user/profile/` should contain all profile related documentation.
+ Every page you would navigate under `/profile` should have its own document,
+ i.e. `account.md`, `applications.md`, `emails.md`, etc.
+ 1. `doc/user/dashboard/` should contain all dashboard related documentation.
+ 1. `doc/user/admin_area/` should contain all admin related documentation
+ describing what can be achieved by accessing GitLab's admin interface
+ (_not to be confused with `doc/administration` where server access is
+ required_).
+ 1. Every category under `/admin/application_settings` should have its
+ own document located at `doc/user/admin_area/settings/`. For example,
+ the **Visibility and Access Controls** category should have a document
+ located at `doc/user/admin_area/settings/visibility_and_access_controls.md`.
+1. The `doc/topics/` directory holds topic-related technical content. Create
+ `doc/topics/topic-name/subtopic-name/index.md` when subtopics become necessary.
+ General user- and admin- related documentation, should be placed accordingly.
+
+If you are unsure where a document should live, you can ping `@axil` or `@marcia` in your
+merge request.
+
+### Changing document location
+
+Changing a document's location is not to be taken lightly. Remember that the
+documentation is available to all installations under `help/` and not only to
+GitLab.com or http://docs.gitlab.com. Make sure this is discussed with the
+Documentation team beforehand.
+
+If you indeed need to change a document's location, do NOT remove the old
+document, but rather replace all of its contents with a new line:
-To preview your changes to documentation locally, please follow
-this [development guide](https://gitlab.com/gitlab-com/gitlab-docs/blob/master/README.md#development).
+```
+This document was moved to [another location](path/to/new_doc.md).
+```
+
+where `path/to/new_doc.md` is the relative path to the root directory `doc/`.
+
+---
-### Testing
+For example, if you were to move `doc/workflow/lfs/lfs_administration.md` to
+`doc/administration/lfs.md`, then the steps would be:
+
+1. Copy `doc/workflow/lfs/lfs_administration.md` to `doc/administration/lfs.md`
+1. Replace the contents of `doc/workflow/lfs/lfs_administration.md` with:
+
+ ```
+ This document was moved to [another location](../../administration/lfs.md).
+ ```
+
+1. Find and replace any occurrences of the old location with the new one.
+ A quick way to find them is to use `git grep`. First go to the root directory
+ where you cloned the `gitlab-ce` repository and then do:
+
+ ```
+ git grep -n "workflow/lfs/lfs_administration"
+ git grep -n "lfs/lfs_administration"
+ ```
+
+NOTE: **Note:**
+If the document being moved has any Disqus comments on it, there are extra steps
+to follow documented just [below](#redirections-for-pages-with-disqus-comments).
+
+Things to note:
+
+- Since we also use inline documentation, except for the documentation itself,
+ the document might also be referenced in the views of GitLab (`app/`) which will
+ render when visiting `/help`, and sometimes in the testing suite (`spec/`).
+- The above `git grep` command will search recursively in the directory you run
+ it in for `workflow/lfs/lfs_administration` and `lfs/lfs_administration`
+ and will print the file and the line where this file is mentioned.
+ You may ask why the two greps. Since we use relative paths to link to
+ documentation, sometimes it might be useful to search a path deeper.
+- The `*.md` extension is not used when a document is linked to GitLab's
+ built-in help page, that's why we omit it in `git grep`.
+- Use the checklist on the documentation MR description template.
+
+### Redirections for pages with Disqus comments
+
+If the documentation page being relocated already has any Disqus comments,
+we need to preserve the Disqus thread.
+
+Disqus uses an identifier per page, and for docs.gitlab.com, the page identifier
+is configured to be the page URL. Therefore, when we change the document location,
+we need to preserve the old URL as the same Disqus identifier.
+
+To do that, add to the frontmatter the variable `redirect_from`,
+using the old URL as value. For example, let's say I moved the document
+available under `https://docs.gitlab.com/my-old-location/README.html` to a new location,
+`https://docs.gitlab.com/my-new-location/index.html`.
+
+Into the **new document** frontmatter add the following:
+
+```yaml
+---
+redirect_from: 'https://docs.gitlab.com/my-old-location/README.html'
+---
+```
+
+Note: it is necessary to include the file name in the `redirect_from` URL,
+even if it's `index.html` or `README.html`.
+
+## Testing
We treat documentation as code, thus have implemented some testing.
Currently, the following tests are in place:
@@ -81,7 +245,7 @@ Currently, the following tests are in place:
Submitting an EE-equivalent merge request cherry-picking all commits from CE to EE is
essential to avoid them.
-### Branch naming
+## Branch naming
If your contribution contains **only** documentation changes, you can speed up
the CI process by following some branch naming conventions. You have three
@@ -96,7 +260,53 @@ choices:
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).
-### Previewing the changes live
+## Merge requests for GitLab documentation
+
+Before getting started, make sure you read the introductory section
+"[contributing to docs](#contributing-to-docs)" above and the
+[tech writing workflow](https://about.gitlab.com/handbook/product/technical-writing/workflow/)
+for GitLab Team members.
+
+- Use the current [merge request description template](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab/merge_request_templates/Documentation.md)
+- Use the correct [branch name](#branch-naming)
+- Label the MR `Documentation`
+- Assign the correct milestone (see note below)
+
+
+NOTE: **Note:**
+If the release version you want to add the documentation to has already been
+frozen or released, use the label `Pick into X.Y` to get it merged into
+the correct release. Avoid picking into a past release as much as you can, as
+it increases the work of the release managers.
+
+### Cherry-picking from CE to EE
+
+As we have the `master` branch of CE merged into EE once a day, it's common to
+run into merge conflicts. To avoid them, we [test for merge conflicts against EE](#testing)
+with the `ee-compat-check` job, and use the following method of creating equivalent
+branches for CE and EE.
+
+Follow this [method for cherry-picking from CE to EE](automatic_ce_ee_merge.md#cherry-picking-from-ce-to-ee), with a few adjustments:
+
+- Create the [CE branch](#branch-naming) starting with `docs-`,
+ e.g.: `git checkout -b docs-example`
+- Create the EE-equivalent branch ending with `-ee`, e.g.,
+ `git checkout -b docs-example-ee`
+- Once all the jobs are passing in CE and EE, and you've addressed the
+feedback from your own team, assign the CE MR to a technical writer for review
+- When both MRs are ready, the EE merge request will be merged first, and the
+CE-equivalent will be merged next.
+- Note that the review will occur only in the CE MR, as the EE MR
+contains the same commits as the CE MR.
+- If you have a few more changes that apply to the EE-version only, you can submit
+a couple more commits to the EE branch, but ask the reviewer to review the EE merge request
+additionally to the CE MR. If there are many EE-only changes though, start a new MR
+to EE only.
+
+## Previewing the changes live
+
+To preview your changes to documentation locally, please follow
+this [development guide](https://gitlab.com/gitlab-com/gitlab-docs/blob/master/README.md#development).
If you want to preview the doc changes of your merge request live, you can use
the manual `review-docs-deploy` job in your merge request. You will need at
@@ -156,7 +366,7 @@ working on. If you don't, the remote docs branch won't be removed either,
and the server where the Review Apps are hosted will eventually be out of
disk space.
-#### Technical aspects
+### Technical aspects
If you want to know the hot details, here's what's really happening:
@@ -191,6 +401,74 @@ The following GitLab features are used among others:
- [Artifacts](../ci/yaml/README.md#artifacts)
- [Specific Runner](../ci/runners/README.md#locking-a-specific-runner-from-being-enabled-for-other-projects)
+## GitLab `/help`
+
+Every GitLab instance includes the documentation, which is available from `/help`
+(`http://my-instance.com/help`), e.g., <https://gitlab.com/help>.
+
+When you're building a new feature, you may need to link the documentation
+from GitLab, the application. This is normally done in files inside the
+`app/views/` directory with the help of the `help_page_path` helper method.
+
+In its simplest form, the HAML code to generate a link to the `/help` page is:
+
+```haml
+= link_to 'Help page', help_page_path('user/permissions')
+```
+
+The `help_page_path` contains the path to the document you want to link to with
+the following conventions:
+
+- it is relative to the `doc/` directory in the GitLab repository
+- the `.md` extension must be omitted
+- it must not end with a slash (`/`)
+
+Below are some special cases where should be used depending on the context.
+You can combine one or more of the following:
+
+1. **Linking to an anchor link.** Use `anchor` as part of the `help_page_path`
+ method:
+
+ ```haml
+ = link_to 'Help page', help_page_path('user/permissions', anchor: 'anchor-link')
+ ```
+
+1. **Opening links in a new tab.** This should be the default behavior:
+
+ ```haml
+ = link_to 'Help page', help_page_path('user/permissions'), target: '_blank'
+ ```
+
+1. **Linking to a circle icon.** Usually used in settings where a long
+ description cannot be used, like near checkboxes. You can basically use
+ any font awesome icon, but prefer the `question-circle`:
+
+ ```haml
+ = link_to icon('question-circle'), help_page_path('user/permissions')
+ ```
+
+1. **Using a button link.** Useful in places where text would be out of context
+ with the rest of the page layout:
+
+ ```haml
+ = link_to 'Help page', help_page_path('user/permissions'), class: 'btn btn-info'
+ ```
+
+1. **Using links inline of some text.**
+
+ ```haml
+ Description to #{link_to 'Help page', help_page_path('user/permissions')}.
+ ```
+
+1. **Adding a period at the end of the sentence.** Useful when you don't want
+ the period to be part of the link:
+
+ ```haml
+ = succeed '.' do
+ Learn more in the
+ = link_to 'Help page', help_page_path('user/permissions')
+ ```
+
## General Documentation vs Technical Articles
### General documentation
@@ -205,7 +483,7 @@ They are topic-related documentation, written with an user-friendly approach and
A technical article guides users and/or admins to achieve certain objectives (within guides and tutorials), or provide an overview of that particular topic or feature (within technical overviews). It can also describe the use, implementation, or integration of third-party tools with GitLab.
-They should be placed in a new directory named `/article-title/index.md` under a topic-related folder, and their images should be placed in `/article-title/img/`. For example, a new article on GitLab Pages should be placed in `doc/user/project/pages/article-title/` and a new article on GitLab CI/CD should be placed in `doc/ci/article-title/`.
+They should be placed in a new directory named `/article-title/index.md` under a topic-related folder, and their images should be placed in `/article-title/img/`. For example, a new article on GitLab Pages should be placed in `doc/user/project/pages/article-title/` and a new article on GitLab CI/CD should be placed in `doc/ci/examples/article-title/`.
#### Types of Technical Articles
@@ -220,7 +498,7 @@ Suppose there's a process to go from point A to point B in 5 steps: `(A) 1 > 2 >
A **guide** can be understood as a description of certain processes to achieve a particular objective. A guide brings you from A to B describing the characteristics of that process, but not necessarily going over each step. It can mention, for example, steps 2 and 3, but does not necessarily explain how to accomplish them.
-- Live example: "GitLab Pages from A to Z - [Part 1](../user/project/pages/getting_started_part_one.md) to [Part 4](../user/project/pages/getting_started_part_four.md)"
+- Live example: "[Static sites and GitLab Pages domains (Part 1)](../user/project/pages/getting_started_part_one.md) to [Creating and Tweaking GitLab CI/CD for GitLab Pages (Part 4)](../user/project/pages/getting_started_part_four.md)"
A **tutorial** requires a clear **step-by-step** guidance to achieve a singular objective. It brings you from A to B, describing precisely all the necessary steps involved in that process, showing each of the 5 steps to go from A to B.
It does not only describes steps 2 and 3, but also shows you how to accomplish them.
@@ -234,21 +512,30 @@ through the process of how to use it systematically.
#### Special format
-Every **Technical Article** contains, in the very beginning, a blockquote with the following information:
+Every **Technical Article** contains a frontmatter at the beginning of the doc
+with the following information:
+
+- **Type of article** (user guide, admin guide, technical overview, tutorial)
+- **Knowledge level** expected from the reader to be able to follow through (beginner, intermediate, advanced)
+- **Author's name** and **GitLab.com handle**
+- **Publication date** (ISO format YYYY-MM-DD)
+
+For example:
-- 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
-> **[Article Type](../../development/writing_documentation.html#types-of-technical-articles):** tutorial ||
-> **Level:** intermediary ||
-> **Author:** [Name Surname](https://gitlab.com/username) ||
-> **Publication date:** AAAA-MM-DD
+```yaml
+---
+author: John Doe
+author_gitlab: johnDoe
+level: beginner
+article_type: user guide
+date: 2017-02-01
+---
```
#### Technical Articles - Writing Method
Use the [writing method](https://about.gitlab.com/handbook/product/technical-writing/#writing-method) defined by the Technical Writing team.
+[gitlab-map]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png
+[graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/d8d39f4a87b90fb9ae89ca12dc565347b4900d5e/production/resources/gitlab-map.graffle
diff --git a/doc/downgrade_ee_to_ce/README.md b/doc/downgrade_ee_to_ce/README.md
index 75bae324585..f656057e3da 100644
--- a/doc/downgrade_ee_to_ce/README.md
+++ b/doc/downgrade_ee_to_ce/README.md
@@ -70,7 +70,7 @@ To downgrade an Omnibus installation, it is sufficient to install the Community
Edition package on top of the currently installed one. You can do this manually,
by directly [downloading the package](https://packages.gitlab.com/gitlab/gitlab-ce)
you need, or by adding our CE package repository and following the
-[CE installation instructions](https://about.gitlab.com/downloads/).
+[CE installation instructions](https://about.gitlab.com/installation/?version=ce).
**Source Installation**
diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md
index e18711f3392..10e8059756d 100644
--- a/doc/gitlab-basics/create-project.md
+++ b/doc/gitlab-basics/create-project.md
@@ -33,5 +33,40 @@
1. Click **Create project**.
+## Push to create a new project
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/26388) in GitLab 10.5.
+
+When you create a new repo locally, instead of going to GitLab to manually
+create a new project and then push the repo, you can directly push it to
+GitLab to create the new project, all without leaving your terminal. If you have access to that
+namespace, we will automatically create a new project under that GitLab namespace with its
+visibility set to Private by default (you can later change it in the [project's settings](../public_access/public_access.md#how-to-change-project-visibility)).
+
+This can be done by using either SSH or HTTP:
+
+```
+## Git push using SSH
+git push --set-upstream git@gitlab.example.com:namespace/nonexistent-project.git master
+
+## Git push using HTTP
+git push --set-upstream https://gitlab.example.com/namespace/nonexistent-project.git master
+```
+
+Once the push finishes successfully, a remote message will indicate
+the command to set the remote and the URL to the new project:
+
+```
+remote:
+remote: The private project namespace/nonexistent-project was created.
+remote:
+remote: To configure the remote, run:
+remote: git remote add origin https://gitlab.example.com/namespace/nonexistent-project.git
+remote:
+remote: To view the project, visit:
+remote: https://gitlab.example.com/namespace/nonexistent-project
+remote:
+```
+
[import it]: ../workflow/importing/README.md
[reserved]: ../user/reserved_names.md
diff --git a/doc/img/devops_lifecycle.png b/doc/img/devops_lifecycle.png
new file mode 100644
index 00000000000..0616be46df8
--- /dev/null
+++ b/doc/img/devops_lifecycle.png
Binary files differ
diff --git a/doc/install/README.md b/doc/install/README.md
index 540cb0d3f38..9724b56910d 100644
--- a/doc/install/README.md
+++ b/doc/install/README.md
@@ -25,15 +25,19 @@ the hardware requirements.
## Install GitLab on cloud providers
-- [Installing in Kubernetes](kubernetes/index.md) - Install GitLab into a Kubernetes
+- [Installing in Kubernetes](kubernetes/index.md): Install GitLab into a Kubernetes
Cluster using our official Helm Chart Repository.
-- [Install GitLab on OpenShift](../articles/openshift_and_gitlab/index.md)
+- [Install GitLab on OpenShift](openshift_and_gitlab/index.md)
- [Install GitLab on DC/OS](https://mesosphere.com/blog/gitlab-dcos/) via [GitLab-Mesosphere integration](https://about.gitlab.com/2016/09/16/announcing-gitlab-and-mesosphere/)
- [Install GitLab on Azure](azure/index.md)
- [Install GitLab on Google Cloud Platform](google_cloud_platform/index.md)
+- [Install GitLab on Google Container Engine (GKE)](https://about.gitlab.com/2017/01/23/video-tutorial-idea-to-production-on-google-container-engine-gke/): video tutorial on
+the full process of installing GitLab on Google Container Engine (GKE), pushing an application to GitLab, building the app with GitLab CI/CD, and deploying to production.
- [Install on AWS](https://about.gitlab.com/aws/)
- _Testing only!_ [DigitalOcean and Docker Machine](digitaloceandocker.md) -
Quickly test any version of GitLab on DigitalOcean using Docker Machine.
+- [Getting started with GitLab and DigitalOcean](https://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/): requirements, installation process, updates.
+- [Demo: Cloud Native Development with GitLab](https://about.gitlab.com/2017/04/18/cloud-native-demo/): video demonstration on how to install GitLab on Kubernetes, build a project, create Review Apps, store Docker images in Container Registry, deploy to production on Kubernetes, and monitor with Prometheus.
## Database
diff --git a/doc/install/azure/index.md b/doc/install/azure/index.md
index 9cc4b56c932..b0c3ad960bb 100644
--- a/doc/install/azure/index.md
+++ b/doc/install/azure/index.md
@@ -38,9 +38,10 @@ create SQL Databases, author websites, and perform lots of other cloud tasks.
## Create New VM
The [Azure Marketplace][Azure-Marketplace] is an online store for pre-configured applications and
-services which have been optimized for the cloud by software vendors like GitLab, and both
-the [Community Edition ("CE")][CE] and the [Enterprise Edition ("EE")][EE] versions of GitLab are
-available on the Azure Marketplace as pre-configured solutions.
+services which have been optimized for the cloud by software vendors like GitLab,
+available on the Azure Marketplace as pre-configured solutions. In this tutorial
+we will install GitLab Community Edition, but for GitLab Enterprise Edition you
+can follow the same process.
To begin creating a new GitLab VM, click on the **+ New** icon, type "GitLab" into the search
box, and then click the **"GitLab Community Edition"** search result:
@@ -178,7 +179,7 @@ address. Read [IP address types and allocation methods in Azure][Azure-IP-Addres
At this stage you should have a running and fully operational VM. However, none of the services on
your VM (e.g. GitLab) will be publicly accessible via the internet until you have opened up the
-neccessary ports to enable access to those services.
+necessary ports to enable access to those services.
Ports are opened by adding _security rules_ to the **"Network security group"** (NSG) which our VM
has been assigned to. If you followed the process above, then Azure will have automatically created
@@ -436,4 +437,4 @@ Check out our other [Technical Articles][GitLab-Technical-Articles] or browse th
[SSH]: https://en.wikipedia.org/wiki/Secure_Shell
[PuTTY]: http://www.putty.org/
-[Using-SSH-In-Putty]: https://mediatemple.net/community/products/dv/204404604/using-ssh-in-putty- \ No newline at end of file
+[Using-SSH-In-Putty]: https://mediatemple.net/community/products/dv/204404604/using-ssh-in-putty-
diff --git a/doc/install/database_mysql.md b/doc/install/database_mysql.md
index f9ba1508705..5c7557ed2b3 100644
--- a/doc/install/database_mysql.md
+++ b/doc/install/database_mysql.md
@@ -6,7 +6,6 @@
and [problems](https://bugs.mysql.com/bug.php?id=65830) that
[suggested](https://bugs.mysql.com/bug.php?id=50909)
[fixes](https://bugs.mysql.com/bug.php?id=65830) [have](https://bugs.mysql.com/bug.php?id=63164).
-- We recommend using MySQL version 5.6 or later. Please see the following [issue][ce-38152].
## Initial database setup
diff --git a/doc/install/google_cloud_platform/img/boot_disk.png b/doc/install/google_cloud_platform/img/boot_disk.png
new file mode 100644
index 00000000000..37b2d9eaae7
--- /dev/null
+++ b/doc/install/google_cloud_platform/img/boot_disk.png
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/change_admin_passwd_email.png b/doc/install/google_cloud_platform/img/change_admin_passwd_email.png
deleted file mode 100644
index 1ffe14f60ff..00000000000
--- a/doc/install/google_cloud_platform/img/change_admin_passwd_email.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/chrome_not_secure_page.png b/doc/install/google_cloud_platform/img/chrome_not_secure_page.png
deleted file mode 100644
index e732066908f..00000000000
--- a/doc/install/google_cloud_platform/img/chrome_not_secure_page.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/first_signin.png b/doc/install/google_cloud_platform/img/first_signin.png
new file mode 100644
index 00000000000..6eb3392d674
--- /dev/null
+++ b/doc/install/google_cloud_platform/img/first_signin.png
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gcp_gitlab_being_deployed.png b/doc/install/google_cloud_platform/img/gcp_gitlab_being_deployed.png
deleted file mode 100644
index 2a1859da6e3..00000000000
--- a/doc/install/google_cloud_platform/img/gcp_gitlab_being_deployed.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gcp_gitlab_overview.png b/doc/install/google_cloud_platform/img/gcp_gitlab_overview.png
deleted file mode 100644
index 1c4c870dbc9..00000000000
--- a/doc/install/google_cloud_platform/img/gcp_gitlab_overview.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gcp_landing.png b/doc/install/google_cloud_platform/img/gcp_landing.png
index 6398d247ba0..d6390c4dd4f 100644
--- a/doc/install/google_cloud_platform/img/gcp_landing.png
+++ b/doc/install/google_cloud_platform/img/gcp_landing.png
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gcp_launcher_console_home_page.png b/doc/install/google_cloud_platform/img/gcp_launcher_console_home_page.png
deleted file mode 100644
index f492888ea4a..00000000000
--- a/doc/install/google_cloud_platform/img/gcp_launcher_console_home_page.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gcp_search_for_gitlab.png b/doc/install/google_cloud_platform/img/gcp_search_for_gitlab.png
deleted file mode 100644
index b38af3966e2..00000000000
--- a/doc/install/google_cloud_platform/img/gcp_search_for_gitlab.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gitlab_deployed_page.png b/doc/install/google_cloud_platform/img/gitlab_deployed_page.png
deleted file mode 100644
index fef9ae45f32..00000000000
--- a/doc/install/google_cloud_platform/img/gitlab_deployed_page.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gitlab_first_sign_in.png b/doc/install/google_cloud_platform/img/gitlab_first_sign_in.png
deleted file mode 100644
index 381c0fe48a5..00000000000
--- a/doc/install/google_cloud_platform/img/gitlab_first_sign_in.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/gitlab_launch_button.png b/doc/install/google_cloud_platform/img/gitlab_launch_button.png
deleted file mode 100644
index 50f66f66118..00000000000
--- a/doc/install/google_cloud_platform/img/gitlab_launch_button.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/launch_vm.png b/doc/install/google_cloud_platform/img/launch_vm.png
new file mode 100644
index 00000000000..3fd13f232bb
--- /dev/null
+++ b/doc/install/google_cloud_platform/img/launch_vm.png
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/new_gitlab_deployment_settings.png b/doc/install/google_cloud_platform/img/new_gitlab_deployment_settings.png
deleted file mode 100644
index 00060841619..00000000000
--- a/doc/install/google_cloud_platform/img/new_gitlab_deployment_settings.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/ssh_terminal.png b/doc/install/google_cloud_platform/img/ssh_terminal.png
new file mode 100644
index 00000000000..6a1a418d8e9
--- /dev/null
+++ b/doc/install/google_cloud_platform/img/ssh_terminal.png
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/ssh_via_button.png b/doc/install/google_cloud_platform/img/ssh_via_button.png
deleted file mode 100644
index 26106f159ad..00000000000
--- a/doc/install/google_cloud_platform/img/ssh_via_button.png
+++ /dev/null
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/vm_created.png b/doc/install/google_cloud_platform/img/vm_created.png
new file mode 100644
index 00000000000..fb467f40838
--- /dev/null
+++ b/doc/install/google_cloud_platform/img/vm_created.png
Binary files differ
diff --git a/doc/install/google_cloud_platform/img/vm_details.png b/doc/install/google_cloud_platform/img/vm_details.png
new file mode 100644
index 00000000000..2d230416a4b
--- /dev/null
+++ b/doc/install/google_cloud_platform/img/vm_details.png
Binary files differ
diff --git a/doc/install/google_cloud_platform/index.md b/doc/install/google_cloud_platform/index.md
index c6b767fff02..3389f0260f9 100644
--- a/doc/install/google_cloud_platform/index.md
+++ b/doc/install/google_cloud_platform/index.md
@@ -2,12 +2,7 @@
![GCP landing page](img/gcp_landing.png)
-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)
+Gettung started with GitLab on a [Google Cloud Platform (GCP)][gcp] instance is quick and easy.
## Prerequisites
@@ -17,84 +12,52 @@ There are only two prerequisites in order to install GitLab on GCP:
1. You need to sign up for the GCP program. If this is your first time, Google
gives you [$300 credit for free][freetrial] to consume over a 60-day period.
-Once you have performed those two steps, you can visit the
-[GCP launcher console][console] which has a list of all the things you can
-deploy on GCP.
-
-![GCP launcher console](img/gcp_launcher_console_home_page.png)
-
-The next step is to find and install GitLab.
+Once you have performed those two steps, you can [create a VM](#creating-the-vm).
-## Configuring and deploying the VM
+## Creating the VM
To deploy GitLab on GCP you need to follow five simple steps:
-1. Go to https://cloud.google.com/launcher and login with your Google credentials
-1. Search for GitLab from GitLab Inc. (not the same as Bitnami) and click on
- the tile.
+1. Go to https://console.cloud.google.com/compute/instances and login with your Google credentials.
- ![Search for GitLab](img/gcp_search_for_gitlab.png)
+1. Click on **Create**
-1. In the next page, you can see an overview of the GitLab VM as well as some
- estimated costs. Click the **Launch on Compute Engine** button to choose the
- hardware and network settings.
+ ![Search for GitLab](img/launch_vm.png)
- ![Launch on Compute Engine](img/gcp_gitlab_overview.png)
+1. On the next page, you can select the type of VM as well as the
+ estimated costs. Provide the name of the instance, desired datacenter, and machine type. Note that GitLab recommends at least 2 vCPU's and 4GB of RAM.
-1. In the settings page you can choose things like the datacenter where your GitLab
- server will be hosted, the number of CPUs and amount of RAM, the disk size
- and type, etc. Read GitLab's [requirements documentation][req] for more
- details on what to choose depending on your needs.
+ ![Launch on Compute Engine](img/vm_details.png)
- ![Deploy settings](img/new_gitlab_deployment_settings.png)
+1. Click **Change** under Boot disk to select the size, type, and desired operating system. GitLab supports a [variety of linux operating systems][req], including Ubuntu and Debian. Click **Select** when finished.
-1. As a last step, hit **Deploy** when ready. The process will finish in a few
- seconds.
+ ![Deploy in progress](img/boot_disk.png)
- ![Deploy in progress](img/gcp_gitlab_being_deployed.png)
+1. As a last step allow HTTP and HTTPS traffic, then click **Create**. The process will finish in a few seconds.
+## Installing GitLab
-## Visiting GitLab for the first time
+After a few seconds, the instance will be created and available to log in. The next step is to install GitLab onto the instance.
-After a few seconds, GitLab will be successfully deployed and you should be
-able to see the IP address that Google assigned to the VM, as well as the
-credentials to the GitLab admin account.
+![Deploy settings](img/vm_created.png)
-![Deploy settings](img/gitlab_deployed_page.png)
+1. Make a note of the IP address of the instance, as you will need that in a later step.
+1. Click on the SSH button to connect to the instance.
+1. A new window will appear, with you logged into the instance.
-1. Click on the IP under **Site address** to visit GitLab.
-1. Accept the self-signed certificate that Google automatically deployed in
- order to securely reach GitLab's login page.
-1. Use the username and password that are present in the Google console page
- to login into GitLab and click **Sign in**.
+ ![GitLab first sign in](img/ssh_terminal.png)
- ![GitLab first sign in](img/gitlab_first_sign_in.png)
+1. Next, follow the instructions for installing GitLab for the operating system you choose, at https://about.gitlab.com/installation/. You can use the IP address from the step above, as the hostname.
-Congratulations! GitLab is now installed and you can access it via your browser,
-but we're not done yet. There are some steps you need to take in order to have
-a fully functional GitLab installation.
+1. Congratulations! GitLab is now installed and you can access it via your browser. To finish installation, open the URL in your browser and provide the initial administrator password. The username for this account is `root`.
+
+ ![GitLab first sign in](img/first_signin.png)
## Next steps
These are the most important next steps to take after you installed GitLab for
the first time.
-### Changing the admin password and email
-
-Google assigned a random password for the GitLab admin account and you should
-change it ASAP:
-
-1. Visit the GitLab admin page through the link in the Google console under
- **Admin URL**.
-1. Find the Administrator user under the **Users** page and hit **Edit**.
-1. Change the email address to a real one and enter a new password.
-
- ![Change GitLab admin password](img/change_admin_passwd_email.png)
-
-1. Hit **Save changes** for the changes to take effect.
-1. After changing the password, you will be signed out from GitLab. Use the
- new credentials to login again.
-
### Assigning a static IP
By default, Google assigns an ephemeral IP to your instance. It is strongly
@@ -112,7 +75,7 @@ here's how you configure GitLab to be aware of the change:
1. SSH into the VM. You can easily use the **SSH** button in the Google console
and a new window will pop up.
- ![SSH button](img/ssh_via_button.png)
+ ![SSH button](img/vm_created.png)
In the future you might want to set up [connecting with an SSH key][ssh]
instead.
@@ -161,7 +124,6 @@ Kerberos, etc. Here are some documents you might be interested in reading:
- [GitLab Pages configuration](https://docs.gitlab.com/ce/administration/pages/index.html)
- [GitLab Container Registry configuration](https://docs.gitlab.com/ce/administration/container_registry.html)
-[console]: https://console.cloud.google.com/launcher "GCP launcher console"
[freetrial]: https://console.cloud.google.com/freetrial "GCP free trial"
[ip]: https://cloud.google.com/compute/docs/configure-instance-ip-addresses#promote_ephemeral_ip "Configuring an Instance's IP Addresses"
[gcp]: https://cloud.google.com/ "Google Cloud Platform"
diff --git a/doc/install/installation.md b/doc/install/installation.md
index 56888b05609..3cf6f7b7ddf 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -64,7 +64,7 @@ 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 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
+ 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 rsync 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).
@@ -80,7 +80,7 @@ Make sure you have the right version of Git installed
# Install Git
sudo apt-get install -y git-core
- # Make sure Git is version 2.14.3 or higher
+ # Make sure Git is version 2.9.5 or higher
git --version
Is the system packaged Git too old? Remove it and compile from source.
@@ -93,9 +93,9 @@ Is the system packaged Git too old? Remove it and compile from source.
# Download and compile from source
cd /tmp
- curl --remote-name --progress https://www.kernel.org/pub/software/scm/git/git-2.8.4.tar.gz
- echo '626e319f8a24fc0866167ea5f6bf3e2f38f69d6cb2e59e150f13709ca3ebf301 git-2.8.4.tar.gz' | shasum -a256 -c - && tar -xzf git-2.8.4.tar.gz
- cd git-2.8.4/
+ curl --remote-name --progress https://www.kernel.org/pub/software/scm/git/git-2.16.2.tar.gz
+ echo '9acc4339b7a2ab484eea69d705923271682b7058015219cf5a7e6ed8dee5b5fb git-2.16.2.tar.gz' | shasum -a256 -c - && tar -xzf git-2.16.2.tar.gz
+ cd git-2.16.2/
./configure
make prefix=/usr/local all
@@ -133,9 +133,10 @@ Remove the old Ruby 1.8 if present:
Download Ruby and compile it:
mkdir /tmp/ruby && cd /tmp/ruby
- curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.5.tar.gz
- echo '3247e217d6745c27ef23bdc77b6abdb4b57a118f ruby-2.3.5.tar.gz' | shasum -c - && tar xzf ruby-2.3.5.tar.gz
- cd ruby-2.3.5
+ curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.7.tar.gz
+ echo '540996fec64984ab6099e34d2f5820b14904f15a ruby-2.3.7.tar.gz' | shasum -c - && tar xzf ruby-2.3.7.tar.gz
+ cd ruby-2.3.7
+
./configure --disable-install-rdoc
make
sudo make install
@@ -162,13 +163,14 @@ page](https://golang.org/dl).
## 4. Node
-Since GitLab 8.17, GitLab requires the use of node >= v4.3.0 to compile
-javascript assets, and yarn >= v0.17.0 to manage javascript dependencies.
-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:
+Since GitLab 8.17, GitLab requires the use of Node to compile javascript
+assets, and Yarn to manage javascript dependencies. The current minimum
+requirements for these are node >= v6.0.0 and yarn >= v1.2.0. 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 | sudo bash -
+ # install node v8.x
+ curl --location https://deb.nodesource.com/setup_8.x | sudo bash -
sudo apt-get install -y nodejs
curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
@@ -299,9 +301,9 @@ sudo usermod -aG redis git
### Clone the Source
# Clone GitLab repository
- sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-3-stable gitlab
+ sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-6-stable gitlab
-**Note:** You can change `10-3-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
+**Note:** You can change `10-6-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
### Configure It
@@ -367,7 +369,7 @@ sudo usermod -aG redis git
# Enable packfile bitmaps
sudo -u git -H git config --global repack.writeBitmaps true
-
+
# Enable push options
sudo -u git -H git config --global receive.advertisePushOptions true
@@ -431,7 +433,7 @@ GitLab Shell is an SSH access and repository management software developed speci
**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.
+* Compile Ruby with `configure --disable-rubygems` to disable RubyGems by default. Not recommended 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
@@ -442,7 +444,7 @@ 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:
+You can specify a different Git repository by providing it as an extra parameter:
sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse,https://example.com/gitlab-workhorse.git]" RAILS_ENV=production
@@ -486,7 +488,7 @@ Make GitLab start on boot:
# 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:
+You can specify a different Git repository by providing it as an extra parameter:
sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly,https://example.com/gitaly.git]" RAILS_ENV=production
@@ -656,7 +658,7 @@ Checkout the [GitLab Runner section](https://about.gitlab.com/gitlab-ci/#gitlab-
### Adding your Trusted Proxies
-If you are using a reverse proxy on an separate machine, you may want to add the
+If you are using a reverse proxy on a separate machine, you may want to add the
proxy to the trusted proxies list. Otherwise users will appear signed in from the
proxy's IP address.
diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md
index 96968c1e3ab..84eeacac3fd 100644
--- a/doc/install/kubernetes/gitlab_chart.md
+++ b/doc/install/kubernetes/gitlab_chart.md
@@ -1,17 +1,17 @@
# GitLab Helm Chart
-> **Note**:
-* This chart is deprecated, and is being replaced by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). For more information on available charts, please see our [overview](index.md#chart-overview).
-* These charts have been tested on Google Kubernetes Engine and Azure Container Service. Other Kubernetes installations may work as well, if not please [open an issue](https://gitlab.com/charts/charts.gitlab.io/issues).
+> **Note:**
+* This chart has been tested on Google Kubernetes Engine and Azure Container Service.
+
+**This chart is deprecated.** For small installations on Kubernetes today, we recommend the beta [`gitlab-omnibus` Helm chart](gitlab_omnibus.md).
+A new [cloud native GitLab chart](index.md#cloud-native-gitlab-chart) is in development with increased scalability and resilience, among other benefits. The cloud native chart will replace both the `gitlab` and `gitlab-omnibus` charts when available later this year.
-For more information on available GitLab Helm Charts, please see our [overview](index.md#chart-overview).
+Due to the significant architectural changes, migrating will require backing up data out of this instance and restoring it into the new deployment. For more information on available GitLab Helm Charts, please see our [overview](index.md#chart-overview).
## Introduction
The `gitlab` Helm chart deploys just GitLab into your Kubernetes cluster, and offers extensive configuration options. This chart requires advanced knowledge of Kubernetes to successfully use. We **strongly recommend** the [gitlab-omnibus](gitlab_omnibus.md) chart.
-This chart is deprecated, and will be replaced by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). Due to the difficulty in supporting upgrades, migrating will require exporting data out of this instance and importing it into the new deployment.
-
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
diff --git a/doc/install/kubernetes/gitlab_omnibus.md b/doc/install/kubernetes/gitlab_omnibus.md
index 5a5f8d67ff5..9c5258c2cdf 100644
--- a/doc/install/kubernetes/gitlab_omnibus.md
+++ b/doc/install/kubernetes/gitlab_omnibus.md
@@ -1,17 +1,18 @@
# GitLab-Omnibus Helm Chart
-> **Note:**
-* This Helm chart is in beta, and will be deprecated by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md).
-* These charts have been tested on Google Kubernetes Engine and Azure Container Service. Other Kubernetes installations may work as well, if not please [open an issue](https://gitlab.com/charts/charts.gitlab.io/issues).
+> **Note:**.
+* This chart has been tested on Google Kubernetes Engine 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.
+**[This chart is beta](#limitations), and is the best way to install GitLab on Kubernetes today.** A new [cloud native GitLab chart](index.md#cloud-native-gitlab-chart) is in development with increased scalability and resilience, among other benefits. Once available, the cloud native chart will be the recommended installation method for Kubernetes, and this chart will be deprecated.
For more information on available GitLab Helm Charts, please see our [overview](index.md#chart-overview).
+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 via [Let's Encrypt](https://letsencrypt.org/).
-This Helm chart is in beta, and will be deprecated by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) once available. Due to the difficulty in supporting upgrades, migrating will require exporting data out of this instance and importing it into the new deployment.
+This Helm chart is in beta, and is suited for small to medium deployments. It will be deprecated by the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) once available. Due to the significant architectural changes, migrating will require backing up data out of this instance and importing it into the new deployment.
The deployment includes:
diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md
index ca9c95aeced..1f53e12d5f8 100644
--- a/doc/install/kubernetes/gitlab_runner_chart.md
+++ b/doc/install/kubernetes/gitlab_runner_chart.md
@@ -31,7 +31,7 @@ The default configuration can always be found in the [values.yaml](https://gitla
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
+ - `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.
@@ -47,7 +47,7 @@ Here is a snippet of the important settings:
## 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/
+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.
diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md
index 0932e1eee3a..aa9b8777359 100644
--- a/doc/install/kubernetes/index.md
+++ b/doc/install/kubernetes/index.md
@@ -9,11 +9,11 @@ should be deployed, upgraded, and configured.
## Chart Overview
-* **[GitLab-Omnibus](gitlab_omnibus.md)**: The best way to run GitLab on Kubernetes today, suited for small to medium deployments. The chart is in beta and will be deprecated by the [cloud native GitLab chart](#cloud-native-gitlab-chart).
-* **[Cloud Native GitLab Chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md)**: The next generation GitLab chart, currently in development. Will support large deployments with horizontal scaling of individual GitLab components.
+* **[GitLab-Omnibus](gitlab_omnibus.md)**: The best way to run GitLab on Kubernetes today, suited for small deployments. The chart is in beta and will be deprecated by the [cloud native GitLab chart](#cloud-native-gitlab-chart).
+* **[Cloud Native GitLab Chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md)**: The next generation GitLab chart, currently in alpha. Will support large deployments with horizontal scaling of individual GitLab components.
* Other Charts
* [GitLab Runner Chart](gitlab_runner_chart.md): For deploying just the GitLab Runner.
- * [Advanced GitLab Installation](gitlab_chart.md): Deprecated, being replaced by the [cloud native GitLab chart](#cloud-native-gitlab-chart). Provides additional deployment options, but provides less functionality out-of-the-box.
+ * [Advanced GitLab Installation](gitlab_chart.md): Deprecated, being replaced by the [cloud native GitLab chart](#cloud-native-gitlab-chart). Provides additional deployment options, but provides less functionality out-of-the-box.
* [Community Contributed Charts](#community-contributed-charts): Community contributed charts, deprecated by the official GitLab chart.
## GitLab-Omnibus Chart (Recommended)
@@ -35,9 +35,9 @@ By offering individual containers and charts, we will be able to provide a numbe
* Potential for rolling updates and canaries within a service,
* and plenty more.
-This is a large project and will be worked on over the span of multiple releases. For the most up-to-date status and release information, please see our [tracking issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2420). We are planning to launch this chart in beta by the end of 2017.
+Presently this chart is available in alpha for testing, and not recommended for production use.
-Learn more about the [cloud native GitLab chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md).
+Learn more about the [cloud native GitLab chart here ](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) and [here [Video]](https://youtu.be/Z6jWR8Z8dv8).
## Other Charts
diff --git a/doc/articles/openshift_and_gitlab/img/add-gitlab-to-project.png b/doc/install/openshift_and_gitlab/img/add-gitlab-to-project.png
index fcad4e59ae3..fcad4e59ae3 100644
--- a/doc/articles/openshift_and_gitlab/img/add-gitlab-to-project.png
+++ b/doc/install/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/install/openshift_and_gitlab/img/add-to-project.png
index bd915a229f6..bd915a229f6 100644
--- a/doc/articles/openshift_and_gitlab/img/add-to-project.png
+++ b/doc/install/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/install/openshift_and_gitlab/img/create-project-ui.png
index e72866f252a..e72866f252a 100644
--- a/doc/articles/openshift_and_gitlab/img/create-project-ui.png
+++ b/doc/install/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/install/openshift_and_gitlab/img/gitlab-logs.png
index 1e24080c7df..1e24080c7df 100644
--- a/doc/articles/openshift_and_gitlab/img/gitlab-logs.png
+++ b/doc/install/openshift_and_gitlab/img/gitlab-logs.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-overview.png b/doc/install/openshift_and_gitlab/img/gitlab-overview.png
index 3c5df0ea101..3c5df0ea101 100644
--- a/doc/articles/openshift_and_gitlab/img/gitlab-overview.png
+++ b/doc/install/openshift_and_gitlab/img/gitlab-overview.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-running.png b/doc/install/openshift_and_gitlab/img/gitlab-running.png
index c7db691cb30..c7db691cb30 100644
--- a/doc/articles/openshift_and_gitlab/img/gitlab-running.png
+++ b/doc/install/openshift_and_gitlab/img/gitlab-running.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-scale.png b/doc/install/openshift_and_gitlab/img/gitlab-scale.png
index 4903c7d7498..4903c7d7498 100644
--- a/doc/articles/openshift_and_gitlab/img/gitlab-scale.png
+++ b/doc/install/openshift_and_gitlab/img/gitlab-scale.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/gitlab-settings.png b/doc/install/openshift_and_gitlab/img/gitlab-settings.png
index db4360ffef0..db4360ffef0 100644
--- a/doc/articles/openshift_and_gitlab/img/gitlab-settings.png
+++ b/doc/install/openshift_and_gitlab/img/gitlab-settings.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/no-resources.png b/doc/install/openshift_and_gitlab/img/no-resources.png
index 480fb766468..480fb766468 100644
--- a/doc/articles/openshift_and_gitlab/img/no-resources.png
+++ b/doc/install/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/install/openshift_and_gitlab/img/openshift-infra-project.png
index 8b9f85aa341..8b9f85aa341 100644
--- a/doc/articles/openshift_and_gitlab/img/openshift-infra-project.png
+++ b/doc/install/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/install/openshift_and_gitlab/img/pods-overview.png
index e1cf08bd217..e1cf08bd217 100644
--- a/doc/articles/openshift_and_gitlab/img/pods-overview.png
+++ b/doc/install/openshift_and_gitlab/img/pods-overview.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/rc-name.png b/doc/install/openshift_and_gitlab/img/rc-name.png
index 889e34adbec..889e34adbec 100644
--- a/doc/articles/openshift_and_gitlab/img/rc-name.png
+++ b/doc/install/openshift_and_gitlab/img/rc-name.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/running-pods.png b/doc/install/openshift_and_gitlab/img/running-pods.png
index 3fd4e56662f..3fd4e56662f 100644
--- a/doc/articles/openshift_and_gitlab/img/running-pods.png
+++ b/doc/install/openshift_and_gitlab/img/running-pods.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/storage-volumes.png b/doc/install/openshift_and_gitlab/img/storage-volumes.png
index ae1e5381faa..ae1e5381faa 100644
--- a/doc/articles/openshift_and_gitlab/img/storage-volumes.png
+++ b/doc/install/openshift_and_gitlab/img/storage-volumes.png
Binary files differ
diff --git a/doc/articles/openshift_and_gitlab/img/web-console.png b/doc/install/openshift_and_gitlab/img/web-console.png
index aa1425d4f94..aa1425d4f94 100644
--- a/doc/articles/openshift_and_gitlab/img/web-console.png
+++ b/doc/install/openshift_and_gitlab/img/web-console.png
Binary files differ
diff --git a/doc/install/openshift_and_gitlab/index.md b/doc/install/openshift_and_gitlab/index.md
new file mode 100644
index 00000000000..e6ccfccd33f
--- /dev/null
+++ b/doc/install/openshift_and_gitlab/index.md
@@ -0,0 +1,524 @@
+---
+author: Achilleas Pipinellis
+author_gitlab: axil
+level: intermediary
+article_type: tutorial
+date: 2016-06-28
+---
+
+# Getting started with OpenShift Origin 3 and GitLab
+
+## 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.
+
+For a video demonstration on installing GitLab on Openshift, check the article [In 13 minutes from Kubernetes to a complete application development tool](https://about.gitlab.com/2016/11/14/idea-to-production/).
+
+---
+
+## 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 Constraints][scc].
+
+For OpenShift v3.0, you will need to do this manually:
+
+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
+
+For OpenShift v3.1 and above, you can do:
+
+```sh
+oc adm policy add-scc-to-user anyuid system:serviceaccount:gitlab:gitlab-ce-user
+```
+
+## 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]: ../../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"
+[scc]: https://docs.openshift.org/latest/admin_guide/manage_scc.html "Documentation - Managing Security Context Constraints" \ No newline at end of file
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index baecf9455b0..1f2b4d9d3d9 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -7,6 +7,7 @@
- Ubuntu
- Debian
- CentOS
+- openSUSE
- Red Hat Enterprise Linux (please use the CentOS packages and instructions)
- Scientific Linux (please use the CentOS packages and instructions)
- Oracle Linux (please use the CentOS packages and instructions)
@@ -121,7 +122,7 @@ Existing users using GitLab with MySQL/MariaDB are advised to
### PostgreSQL Requirements
-As of GitLab 10.0, PostgreSQL 9.6 or newer (but less than 10) is required, and earlier versions are
+As of GitLab 10.0, PostgreSQL 9.6 or newer is required, and earlier versions are
not supported. We highly recommend users to use PostgreSQL 9.6 as this
is the PostgreSQL version used for development and testing.
@@ -136,6 +137,14 @@ 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.
+#### Additional requirements for GitLab Geo
+
+If you are using [GitLab Geo](https://docs.gitlab.com/ee/development/geo.html), the [tracking database](https://docs.gitlab.com/ee/development/geo.html#geo-tracking-database) also requires the `postgres_fdw` extension.
+
+```
+CREATE EXTENSION postgres_fdw;
+```
+
## 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.
@@ -191,3 +200,6 @@ use the CI features.
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.
+
+Note: We do not support running GitLab with JavaScript disabled in the browser and have no plans of supporting that
+in the future because we have features such as Issue Boards which require JavaScript extensively.
diff --git a/doc/integration/auth0.md b/doc/integration/auth0.md
index c39d7ab57c6..a75836a915a 100644
--- a/doc/integration/auth0.md
+++ b/doc/integration/auth0.md
@@ -56,7 +56,8 @@ for initial settings.
"name" => "auth0",
"args" => { client_id: 'YOUR_AUTH0_CLIENT_ID',
client_secret: 'YOUR_AUTH0_CLIENT_SECRET',
- namespace: 'YOUR_AUTH0_DOMAIN'
+ domain: 'YOUR_AUTH0_DOMAIN',
+ scope: 'openid profile email'
}
}
]
@@ -69,8 +70,8 @@ for initial settings.
args: {
client_id: 'YOUR_AUTH0_CLIENT_ID',
client_secret: 'YOUR_AUTH0_CLIENT_SECRET',
- namespace: 'YOUR_AUTH0_DOMAIN'
- }
+ domain: 'YOUR_AUTH0_DOMAIN',
+ scope: 'openid profile email' }
}
```
diff --git a/doc/integration/google.md b/doc/integration/google.md
index 07a700f7b64..ae1d848f439 100644
--- a/doc/integration/google.md
+++ b/doc/integration/google.md
@@ -35,7 +35,7 @@ In Google's side:
1. You should now be able to see a Client ID and Client secret. Note them down
or keep this page open as you will need them later.
-1. From the **Dashboard** select **ENABLE APIS AND SERVICES > Compute > Google Container Engine API > Enable**
+1. From the **Dashboard** select **ENABLE APIS AND SERVICES > Compute > Google Kubernetes Engine API > Enable**
On your GitLab server:
diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md
index 20087a981f9..3edde3de83d 100644
--- a/doc/integration/omniauth.md
+++ b/doc/integration/omniauth.md
@@ -32,6 +32,7 @@ contains some settings that are common for all providers.
- [Auth0](auth0.md)
- [Authentiq](../administration/auth/authentiq.md)
- [OAuth2Generic](oauth2_generic.md)
+- [JWT](../administration/auth/jwt.md)
## Initial OmniAuth Configuration
diff --git a/doc/integration/openid_connect_provider.md b/doc/integration/openid_connect_provider.md
index 56f367d841e..ad41be52045 100644
--- a/doc/integration/openid_connect_provider.md
+++ b/doc/integration/openid_connect_provider.md
@@ -39,6 +39,7 @@ Currently the following user information is shared with clients:
| `website` | `string` | URL for the user's website
| `profile` | `string` | URL for the user's GitLab profile
| `picture` | `string` | URL for the user's GitLab avatar
+| `groups` | `array` | Names of the groups the user is a member of
[OpenID Connect]: http://openid.net/connect/ "OpenID Connect website"
[doorkeeper-openid_connect]: https://github.com/doorkeeper-gem/doorkeeper-openid_connect "Doorkeeper::OpenidConnect website"
diff --git a/doc/integration/saml.md b/doc/integration/saml.md
index 3ae98adc465..3f49432ce93 100644
--- a/doc/integration/saml.md
+++ b/doc/integration/saml.md
@@ -102,15 +102,15 @@ in your SAML IdP:
installation to generate the correct value).
1. Change the values of `idp_cert_fingerprint`, `idp_sso_target_url`,
- `name_identifier_format` to match your IdP. Check
+ `name_identifier_format` to match your IdP. If a fingerprint is used it must
+ be a SHA1 fingerprint; check
[the omniauth-saml documentation](https://github.com/omniauth/omniauth-saml)
- for details on these options.
+ for more details on these options.
1. Change the value of `issuer` to a unique name, which will identify the application
to the IdP.
-1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you
- installed GitLab via Omnibus or from source respectively.
+1. For the changes to take effect, you must [reconfigure][] GitLab if you installed via Omnibus or [restart GitLab][] if you installed from source.
1. Register the GitLab SP in your SAML 2.0 IdP, using the application name specified
in `issuer`.
@@ -312,6 +312,7 @@ need to be validated using a fingerprint, a certificate or a validator.
For this you need take the following into account:
+- If a fingerprint is used, it must be the SHA1 fingerprint
- If no certificate is provided in the settings, a fingerprint or fingerprint
validator needs to be provided and the response from the server must contain
a certificate (`<ds:KeyInfo><ds:X509Data><ds:X509Certificate>`)
diff --git a/doc/integration/slash_commands.md b/doc/integration/slash_commands.md
index 36a8844e953..7d73026a6c6 100644
--- a/doc/integration/slash_commands.md
+++ b/doc/integration/slash_commands.md
@@ -15,9 +15,10 @@ Taking the trigger term as `project-name`, the commands are:
| `/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 issue move <id> to <project>` | Moves issue ID `<id>` to `<project>` |
| `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment |
-Note that if you are using the [GitLab Slack application](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html) for
+Note that if you are using the [GitLab Slack application](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html) for
your GitLab.com projects, you need to [add the `gitlab` keyword at the beginning of the command](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html#usage).
## Issue commands
diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md
index 8d0afa9e692..7f028565412 100644
--- a/doc/policy/maintenance.md
+++ b/doc/policy/maintenance.md
@@ -44,7 +44,7 @@ This decision is made on a case-by-case basis.
## Upgrade recommendations
-We encourage everyone to run the latest stable release to ensure that you can
+We encourage everyone to run the [latest stable release](https://about.gitlab.com/blog/categories/release/) to ensure that you can
easily upgrade to the most secure and feature-rich GitLab experience. In order
to make sure you can easily run the most recent stable release, we are working
hard to keep the update process simple and reliable.
diff --git a/doc/raketasks/README.md b/doc/raketasks/README.md
index 2f916f5dea7..90187617c41 100644
--- a/doc/raketasks/README.md
+++ b/doc/raketasks/README.md
@@ -14,3 +14,4 @@ comments: false
- [Webhooks](web_hooks.md)
- [Import](import.md) of git repositories in bulk
- [Rebuild authorized_keys file](http://docs.gitlab.com/ce/raketasks/maintenance.html#rebuild-authorized_keys-file) task for administrators
+- [Migrate Uploads](../administration/raketasks/uploads/migrate.md)
diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md
index 54c3e20d61d..bbd2d214fe4 100644
--- a/doc/raketasks/backup_restore.md
+++ b/doc/raketasks/backup_restore.md
@@ -5,8 +5,8 @@
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 and type (CE/EE)**
-of GitLab on which it was created. The best way to migrate your repositories
+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
@@ -14,6 +14,19 @@ from one server to another is through backup restore.
GitLab provides a simple command line interface to backup your whole installation,
and is flexible enough to fit your needs.
+### Requirements
+
+If you're using GitLab with the Omnibus package, you're all set. If you
+installed GitLab from source, make sure the following packages are installed:
+
+* rsync
+
+If you're using Ubuntu, you could run:
+
+```
+sudo apt-get install -y rsync
+```
+
### Backup timestamp
>**Note:**
@@ -156,6 +169,32 @@ For Omnibus GitLab packages:
1. [Reconfigure GitLab] for the changes to take effect
+#### Digital Ocean Spaces
+
+This example can be used for a bucket in Amsterdam (AMS3).
+
+1. Add the following to `/etc/gitlab/gitlab.rb`:
+
+ ```ruby
+ gitlab_rails['backup_upload_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'ams3',
+ 'aws_access_key_id' => 'AKIAKIAKI',
+ 'aws_secret_access_key' => 'secret123',
+ 'endpoint' => 'https://ams3.digitaloceanspaces.com'
+ }
+ gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket'
+ ```
+
+1. [Reconfigure GitLab] for the changes to take effect
+
+#### Other S3 Providers
+
+Not all S3 providers are fully-compatible with the Fog library. For example,
+if you see `411 Length Required` errors after attempting to upload, you may
+need to downgrade the `aws_signature_version` value from the default value to
+2 [due to this issue](https://github.com/fog/fog-aws/issues/428).
+
---
For installations from source:
@@ -431,7 +470,7 @@ 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 and type (CE/EE)** of
+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
@@ -457,7 +496,7 @@ more of the following options:
- `BACKUP=timestamp_of_backup` - Required if more than one backup exists.
Read what the [backup timestamp is about](#backup-timestamp).
-- `force=yes` - Do not ask if the authorized_keys file should get regenerated.
+- `force=yes` - Does not ask if the authorized_keys file should get regenerated and assumes 'yes' for warning that database tables will be removed.
### Restore for installation from source
@@ -511,7 +550,7 @@ sudo service gitlab restart
This procedure assumes that:
-- You have installed the **exact same version and type (CE/EE)** of GitLab
+- 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/security/rack_attack.md b/doc/security/rack_attack.md
index 92066997be8..c61729581e8 100644
--- a/doc/security/rack_attack.md
+++ b/doc/security/rack_attack.md
@@ -1,25 +1,135 @@
-# Rack attack
+# Rack Attack
-To prevent abusive clients doing damage GitLab uses rack-attack gem.
+Rack Attack, also known as Rack::Attack, is [a rubygem](https://github.com/kickstarter/rack-attack)
+that is meant to protect GitLab with the ability to customize throttling and
+blocking user IPs.
+You can prevent brute-force passwords attacks, scrapers, or any other offenders
+by throttling requests from IP addresses making large volumes of requests.
+In case you find throttling is not enough to protect you against abusive clients,
+Rack Attack offers IP whitelisting, blacklisting, Fail2ban style filtering and
+tracking.
-If you installed or upgraded GitLab by following the official guides this should be enabled by default.
+By default, user sign-in, user sign-up (if enabled), and user password reset is
+limited to 6 requests per minute. After trying for 6 times, the client will
+have to wait for the next minute to be able to try again.
-If you are missing `config/initializers/rack_attack.rb` the following steps need to be taken in order to enable protection for your GitLab instance:
+If you installed or upgraded GitLab by following the [official guides](../install/README.md)
+this should be enabled by default. If your instance is not exposed to any incoming
+connections, it is recommended to disable Rack Attack.
-1. In config/application.rb find and uncomment the following line:
+For more information on how to use these options check out
+[rack-attack README](https://github.com/kickstarter/rack-attack/blob/master/README.md).
- config.middleware.use Rack::Attack
+## Settings
-1. Rename `config/initializers/rack_attack.rb.example` to `config/initializers/rack_attack.rb`.
+**Omnibus GitLab**
-1. Review the `paths_to_be_protected` and add any other path you need protecting.
+1. Open `/etc/gitlab/gitlab.rb` with you editor
+1. Add the following:
-1. Restart GitLab instance.
+ ```ruby
+ gitlab_rails['rack_attack_git_basic_auth'] = {
+ 'enabled' => true,
+ 'ip_whitelist' => ["127.0.0.1"],
+ 'maxretry' => 10,
+ 'findtime' => 60,
+ 'bantime' => 3600
+ }
+ ```
-By default, user sign-in, user sign-up(if enabled) and user password reset is limited to 6 requests per minute. After trying for 6 times, client will have to wait for the next minute to be able to try again. These settings can be found in `config/initializers/rack_attack.rb`
+3. Reconfigure GitLab:
-If you want more restrictive/relaxed throttle rule change the `limit` or `period` values. For example, more relaxed throttle rule will be if you set limit: 3 and period: 1.second(this will allow 3 requests per second). You can also add other paths to the protected list by adding to `paths_to_be_protected` variable. If you change any of these settings do not forget to restart your GitLab instance.
+ ```
+ sudo gitlab-ctl reconfigure
+ ```
-In case you find throttling is not enough to protect you against abusive clients, rack-attack gem offers IP whitelisting, blacklisting, Fail2ban style filter and tracking.
+The following settings can be configured:
-For more information on how to use these options check out [rack-attack README](https://github.com/kickstarter/rack-attack/blob/master/README.md).
+- `enabled`: By default this is set to `true`. Set this to `false` to disable Rack Attack.
+- `ip_whitelist`: Whitelist any IPs from being blocked. They must be formatted as strings within a ruby array.
+ For example, `["127.0.0.1", "127.0.0.2", "127.0.0.3"]`.
+- `maxretry`: The maximum amount of times a request can be made in the
+ specified time.
+- `findtime`: The maximum amount of time failed requests can count against an IP
+ before it's blacklisted.
+- `bantime`: The total amount of time that a blacklisted IP will be blocked in
+ seconds.
+
+**Installations from source**
+
+These settings can be found in `config/initializers/rack_attack.rb`. If you are
+missing `config/initializers/rack_attack.rb`, the following steps need to be
+taken in order to enable protection for your GitLab instance:
+
+1. In `config/application.rb` find and uncomment the following line:
+
+ ```ruby
+ config.middleware.use Rack::Attack
+ ```
+
+1. Copy `config/initializers/rack_attack.rb.example` to `config/initializers/rack_attack.rb`
+1. Open `config/initializers/rack_attack.rb`, review the
+ `paths_to_be_protected`, and add any other path you need protecting
+1. Restart GitLab:
+
+ ```sh
+ sudo service gitlab restart
+ ```
+
+If you want more restrictive/relaxed throttle rules, edit
+`config/initializers/rack_attack.rb` and change the `limit` or `period` values.
+For example, more relaxed throttle rules will be if you set
+`limit: 3` and `period: 1.seconds` (this will allow 3 requests per second).
+You can also add other paths to the protected list by adding to `paths_to_be_protected`
+variable. If you change any of these settings do not forget to restart your
+GitLab instance.
+
+## Remove blocked IPs from Rack Attack via Redis
+
+In case you want to remove a blocked IP, follow these steps:
+
+1. Find the IPs that have been blocked in the production log:
+
+ ```sh
+ grep "Rack_Attack" /var/log/gitlab/gitlab-rails/production.log
+ ```
+
+2. Since the blacklist is stored in Redis, you need to open up `redis-cli`:
+
+ ```sh
+ /opt/gitlab/embedded/bin/redis-cli -s /var/opt/gitlab/redis/redis.socket
+ ```
+
+3. You can remove the block using the following syntax, replacing `<ip>` with
+ the actual IP that is blacklisted:
+
+ ```
+ del cache:gitlab:rack::attack:allow2ban:ban:<ip>
+ ```
+
+4. Confirm that the key with the IP no longer shows up:
+
+ ```
+ keys *rack::attack*
+ ```
+
+5. Optionally, add the IP to the whitelist to prevent it from being blacklisted
+ again (see [settings](#settings)).
+
+## Troubleshooting
+
+### Rack attack is blacklisting the load balancer
+
+Rack Attack may block your load balancer if all traffic appears to come from
+the load balancer. In that case, you will need to:
+
+1. [Configure `nginx[real_ip_trusted_addresses]`](https://docs.gitlab.com/omnibus/settings/nginx.html#configuring-gitlab-trusted_proxies-and-the-nginx-real_ip-module).
+ This will keep users' IPs from being listed as the load balancer IPs.
+2. Whitelist the load balancer's IP address(es) in the Rack Attack [settings](#settings).
+3. Reconfigure GitLab:
+
+ ```
+ sudo gitlab-ctl reconfigure
+ ```
+
+4. [Remove the block via Redis.](#remove-blocked-ips-from-rack-attack-via-redis)
diff --git a/doc/ssh/README.md b/doc/ssh/README.md
index 33a2d7a88a7..aa14a39e4c9 100644
--- a/doc/ssh/README.md
+++ b/doc/ssh/README.md
@@ -35,8 +35,8 @@ to clipboard step.
If you don't see the string or would like to generate a SSH key pair with a
custom name continue onto the next step.
->
-**Note:** Public SSH key may also be named as follows:
+Note that Public SSH key may also be named as follows:
+
- `id_dsa.pub`
- `id_ecdsa.pub`
- `id_ed25519.pub`
@@ -73,7 +73,7 @@ custom name continue onto the next step.
key pair, but it is not required and you can skip creating a password by
pressing enter.
- >**Note:**
+ NOTE: **Note:**
If you want to change the password of your SSH key pair, you can use
`ssh-keygen -p <keyname>`.
@@ -162,11 +162,13 @@ That's why it needs to uniquely map to a single user.
## Deploy keys
+### Per-repository deploy keys
+
Deploy keys allow read-only or read-write (if enabled) access to one or
multiple projects with a single SSH key pair.
This is really useful for cloning repositories to your Continuous
-Integration (CI) server. By using deploy keys, you don't have to setup a
+Integration (CI) server. By using deploy keys, you don't have to set up a
dummy user account.
If you are a project master or owner, you can add a deploy key in the
@@ -185,6 +187,47 @@ a group.
Deploy keys can be shared between projects, you just need to add them to each
project.
+### Global shared deploy keys
+
+Global Shared Deploy keys allow read-only or read-write (if enabled) access to
+be configured on any repository in the entire GitLab installation.
+
+This is really useful for integrating repositories to secured, shared Continuous
+Integration (CI) services or other shared services.
+GitLab administrators can set up the Global Shared Deploy key in GitLab and
+add the private key to any shared systems. Individual repositories opt into
+exposing their repsitory using these keys when a project masters (or higher)
+authorizes a Global Shared Deploy key to be used with their project.
+
+Global Shared Keys can provide greater security compared to Per-Project Deploy
+Keys since an administrator of the target integrated system is the only one
+who needs to know and configure the private key.
+
+GitLab administrators set up Global Deploy keys in the Admin area under the
+section **Deploy Keys**. Ensure keys have a meaningful title as that will be
+the primary way for project masters and owners to identify the correct Global
+Deploy key to add. For instance, if the key gives access to a SaaS CI instance,
+use the name of that service in the key name if that is all it is used for.
+When creating Global Shared Deploy keys, give some thought to the granularity
+of keys - they could be of very narrow usage such as just a specific service or
+of broader usage for something like "Anywhere you need to give read access to
+your repository".
+
+Once a GitLab administrator adds the Global Deployment key, project masters
+and owners can add it in project's **Settings > Repository** section by expanding the
+**Deploy Key** section and clicking **Enable** next to the appropriate key listed
+under **Public deploy keys available to any project**.
+
+NOTE: **Note:**
+The heading **Public deploy keys available to any project** only appears
+if there is at least one Global Deploy Key configured.
+
+CAUTION: **Warning:**
+Defining Global Deploy Keys does not expose any given repository via
+the key until that respository adds the Global Deploy Key to their project.
+In this way the Global Deploy Keys enable access by other systems, but do
+not implicitly give any access just by setting them up.
+
## Applications
### Eclipse
diff --git a/doc/system_hooks/system_hooks.md b/doc/system_hooks/system_hooks.md
index f2a9b1d769b..9ba05c7815b 100644
--- a/doc/system_hooks/system_hooks.md
+++ b/doc/system_hooks/system_hooks.md
@@ -11,6 +11,7 @@ Your GitLab instance can perform HTTP POST requests on the following events:
- `user_remove_from_team`
- `user_create`
- `user_destroy`
+- `user_failed_login`
- `user_rename`
- `key_create`
- `key_destroy`
@@ -22,6 +23,8 @@ Your GitLab instance can perform HTTP POST requests on the following events:
The triggers for most of these are self-explanatory, but `project_update` and `project_rename` deserve some clarification: `project_update` is fired any time an attribute of a project is changed (name, description, tags, etc.) *unless* the `path` attribute is also changed. In that case, a `project_rename` is triggered instead (so that, for instance, if all you care about is the repo URL, you can just listen for `project_rename`).
+`user_failed_login` is sent whenever a **blocked** user attempts to login and denied access.
+
System hooks can be used, e.g. for logging or changing information in a LDAP server.
> **Note:**
@@ -196,6 +199,23 @@ Please refer to `group_rename` and `user_rename` for that case.
}
```
+**User failed login:**
+
+```json
+{
+ "event_name": "user_failed_login",
+ "created_at": "2017-10-03T06:08:48Z",
+ "updated_at": "2018-01-15T04:52:06Z",
+ "name": "John Smith",
+ "email": "user4@example.com",
+ "user_id": 26,
+ "username": "user4",
+ "state": "blocked"
+}
+```
+
+If the user is blocked via LDAP, `state` will be `ldap_blocked`.
+
**User renamed:**
```json
@@ -445,6 +465,135 @@ X-Gitlab-Event: System Hook
"total_commits_count": 0
}
```
+
+### Merge request events
+
+Triggered when a new merge request is created, an existing merge request was
+updated/merged/closed or a commit is added in the source branch.
+
+**Request header**:
+
+```
+X-Gitlab-Event: System Hook
+```
+
+```json
+{
+ "object_kind": "merge_request",
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
+ },
+ "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",
+ "ci_config_path": "",
+ "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"
+ },
+ "object_attributes": {
+ "id": 90,
+ "target_branch": "master",
+ "source_branch": "ms-viewport",
+ "source_project_id": 14,
+ "author_id": 51,
+ "assignee_id": 6,
+ "title": "MS-Viewport",
+ "created_at": "2017-09-20T08:31:45.944Z",
+ "updated_at": "2017-09-28T12:23:42.365Z",
+ "milestone_id": null,
+ "state": "opened",
+ "merge_status": "unchecked",
+ "target_project_id": 14,
+ "iid": 1,
+ "description": "",
+ "updated_by_id": 1,
+ "merge_error": null,
+ "merge_params": {
+ "force_remove_source_branch": "0"
+ },
+ "merge_when_pipeline_succeeds": false,
+ "merge_user_id": null,
+ "merge_commit_sha": null,
+ "deleted_at": null,
+ "in_progress_merge_commit_sha": null,
+ "lock_version": 5,
+ "time_estimate": 0,
+ "last_edited_at": "2017-09-27T12:43:37.558Z",
+ "last_edited_by_id": 1,
+ "head_pipeline_id": 61,
+ "ref_fetched": true,
+ "merge_jid": null,
+ "source": {
+ "name": "Awesome Project",
+ "description": "",
+ "web_url": "http://example.com/awesome_space/awesome_project",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "git_http_url": "http://example.com/awesome_space/awesome_project.git",
+ "namespace": "root",
+ "visibility_level": 0,
+ "path_with_namespace": "awesome_space/awesome_project",
+ "default_branch": "master",
+ "ci_config_path": "",
+ "homepage": "http://example.com/awesome_space/awesome_project",
+ "url": "http://example.com/awesome_space/awesome_project.git",
+ "ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "http_url": "http://example.com/awesome_space/awesome_project.git"
+ },
+ "target": {
+ "name": "Awesome Project",
+ "description": "Aut reprehenderit ut est.",
+ "web_url": "http://example.com/awesome_space/awesome_project",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "git_http_url": "http://example.com/awesome_space/awesome_project.git",
+ "namespace": "Awesome Space",
+ "visibility_level": 0,
+ "path_with_namespace": "awesome_space/awesome_project",
+ "default_branch": "master",
+ "ci_config_path": "",
+ "homepage": "http://example.com/awesome_space/awesome_project",
+ "url": "http://example.com/awesome_space/awesome_project.git",
+ "ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "http_url": "http://example.com/awesome_space/awesome_project.git"
+ },
+ "last_commit": {
+ "id": "ba3e0d8ff79c80d5b0bbb4f3e2e343e0aaa662b7",
+ "message": "fixed readme",
+ "timestamp": "2017-09-26T16:12:57Z",
+ "url": "http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+ "author": {
+ "name": "GitLab dev user",
+ "email": "gitlabdev@dv6700.(none)"
+ }
+ },
+ "work_in_progress": false,
+ "total_time_spent": 0,
+ "human_total_time_spent": null,
+ "human_time_estimate": null
+ },
+ "labels": null,
+ "repository": {
+ "name": "git-gpg-test",
+ "url": "git@example.com:awesome_space/awesome_project.git",
+ "description": "",
+ "homepage": "http://example.com/awesome_space/awesome_project"
+ }
+}
+```
+
## Repository Update events
Triggered only once when you push to the repository (including tags).
diff --git a/doc/topics/authentication/index.md b/doc/topics/authentication/index.md
index 1f30909b0aa..a8aa11265d0 100644
--- a/doc/topics/authentication/index.md
+++ b/doc/topics/authentication/index.md
@@ -20,7 +20,7 @@ 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 CE](../../administration/auth/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)
diff --git a/doc/topics/autodevops/img/auto_devops_settings.png b/doc/topics/autodevops/img/auto_devops_settings.png
deleted file mode 100644
index 067c9da3fdc..00000000000
--- a/doc/topics/autodevops/img/auto_devops_settings.png
+++ /dev/null
Binary files differ
diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md
index 0b48596006d..e88b787187c 100644
--- a/doc/topics/autodevops/index.md
+++ b/doc/topics/autodevops/index.md
@@ -20,8 +20,12 @@ project in an easy and automatic way:
1. [Auto Test](#auto-test)
1. [Auto Code Quality](#auto-code-quality)
1. [Auto SAST (Static Application Security Testing)](#auto-sast)
+1. [Auto Dependency Scanning](#auto-dependency-scanning)
+1. [Auto Container Scanning](#auto-container-scanning)
1. [Auto Review Apps](#auto-review-apps)
+1. [Auto DAST (Dynamic Application Security Testing)](#auto-dast)
1. [Auto Deploy](#auto-deploy)
+1. [Auto Browser Performance Testing](#auto-browser-performance-testing)
1. [Auto Monitoring](#auto-monitoring)
As Auto DevOps relies on many different components, it's good to have a basic
@@ -36,6 +40,8 @@ knowledge of the following:
Auto DevOps provides great defaults for all the stages; you can, however,
[customize](#customizing) almost everything to your needs.
+For an overview on the creation of Auto DevOps, read the blog post [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/).
+
## Prerequisites
TIP: **Tip:**
@@ -61,9 +67,8 @@ To make full use of Auto DevOps, you will need:
a domain configured with wildcard DNS which is gonna be used by all of your
Auto DevOps applications. [Read the specifics](#auto-devops-base-domain).
1. **Kubernetes** (needed for Auto Review Apps, Auto Deploy, and Auto Monitoring) -
- To enable deployments, you will need Kubernetes 1.5+. The [Kubernetes service][kubernetes-service]
- integration will need to be enabled for the project, or enabled as a
- [default service template](../../user/project/integrations/services_templates.md)
+ To enable deployments, you will need Kubernetes 1.5+. You need a [Kubernetes cluster][kubernetes-clusters]
+ for the project, or a Kubernetes [default service template](../../user/project/integrations/services_templates.md)
for the entire GitLab installation.
1. **A load balancer** - You can use NGINX ingress by deploying it to your
Kubernetes cluster using the
@@ -91,7 +96,9 @@ Auto Deploy, and Auto Monitoring will be silently skipped.
The Auto DevOps base domain is required if you want to make use of [Auto
Review Apps](#auto-review-apps) and [Auto Deploy](#auto-deploy). It is defined
-under the project's CI/CD settings while [enabling Auto DevOps](#enabling-auto-devops).
+either under the project's CI/CD settings while
+[enabling Auto DevOps](#enabling-auto-devops) or in instance-wide settings in
+the CI/CD section.
It can also be set at the project or group level as a variable, `AUTO_DEVOPS_DOMAIN`.
A wildcard DNS A record matching the base domain is required, for example,
@@ -122,11 +129,13 @@ Google Cloud.
## Enabling Auto DevOps
-**Note:**
If you haven't done already, read the [prerequisites](#prerequisites) to make
full use of Auto DevOps. If this is your fist time, we recommend you follow the
[quick start guide](#quick-start).
+To enable Auto DevOps to your project:
+
+1. Check that your project doesn't have a `.gitlab-ci.yml`, and remove it otherwise
1. Go to your project's **Settings > CI/CD > General pipelines settings** and
find the Auto DevOps section
1. Select "Enable Auto DevOps"
@@ -134,22 +143,13 @@ full use of Auto DevOps. If this is your fist time, we recommend you follow the
that will be used by Kubernetes to deploy your application
1. Hit **Save changes** for the changes to take effect
-![Project AutoDevops settings section](img/auto_devops_settings.png)
-
-Now that it's enabled, there are a few more steps depending on whether your project
-has a `.gitlab-ci.yml` or not:
-
-- **For projects with no `.gitlab-ci.yml` present:**
- A pipeline needs to be triggered either by pushing a new commit to the
- repository or manually visiting `https://example.gitlab.com/<username>/<project>/pipelines/new`
- and creating a new pipeline for your default branch, generally `master`.
-- **For projects with a `.gitlab-ci.yml` present:**
- All you need to do is remove your existing `.gitlab-ci.yml`, and you can even
- do that in a branch to test Auto DevOps before committing to `master`.
+Once saved, an Auto DevOps pipeline will be triggered on the default branch.
NOTE: **Note:**
-Starting with GitLab 10.3, when enabling Auto DevOps, a pipeline is
-automatically run on the default branch.
+For GitLab versions 10.0 - 10.2, when enabling Auto DevOps, a pipeline needs to be
+manually triggered either by pushing a new commit to the repository or by visiting
+`https://example.gitlab.com/<username>/<project>/pipelines/new` and creating
+a new pipeline for your default branch, generally `master`.
NOTE: **Note:**
If you are a GitLab Administrator, you can enable Auto DevOps instance wide
@@ -199,21 +199,50 @@ Auto Code Quality uses the open source
[`codeclimate` image](https://hub.docker.com/r/codeclimate/codeclimate/) to run
static analysis and other code checks on the current code. The report is
created, and is uploaded as an artifact which you can later download and check
-out. In GitLab Enterprise Edition Starter, differences between the source and
-target branches are
+out.
+
+In GitLab Starter, differences between the source and
+target branches are also
[shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html).
### Auto SAST
-> Introduced in [GitLab Enterprise Edition Ultimate][ee] 10.3.
+> Introduced in [GitLab Ultimate][ee] 10.3.
Static Application Security Testing (SAST) uses the
-[gl-sast Docker image](https://gitlab.com/gitlab-org/gl-sast) to run static
+[SAST Docker image](https://gitlab.com/gitlab-org/security-products/sast) to run static
analysis on the current code and checks for potential security issues. Once the
report is created, it's uploaded as an artifact which you can later download and
check out.
-Any security warnings are also [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/sast.html).
+In GitLab Ultimate, any security warnings are also
+[shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/sast.html).
+
+### Auto Dependency Scanning
+
+> Introduced in [GitLab Ultimate][ee] 10.7.
+
+Dependency Scanning uses the
+[Dependency Scanning Docker image](https://gitlab.com/gitlab-org/security-products/dependency-scanning)
+to run analysis on the project dependencies and checks for potential security issues. Once the
+report is created, it's uploaded as an artifact which you can later download and
+check out.
+
+In GitLab Ultimate, any security warnings are also
+[shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/dependency_scanning.html).
+
+### Auto Container Scanning
+
+> Introduced in GitLab 10.4.
+
+Vulnerability Static Analysis for containers uses
+[Clair](https://github.com/coreos/clair) to run static analysis on a
+Docker image and checks for potential security issues. Once the report is
+created, it's uploaded as an artifact which you can later download and
+check out.
+
+In GitLab Ultimate, any security warnings are also
+[shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/container_scanning.html).
### Auto Review Apps
@@ -241,6 +270,33 @@ up in the merge request widget for easy discovery. When the branch is deleted,
for example after the merge request is merged, the Review App will automatically
be deleted.
+### Auto DAST
+
+> Introduced in [GitLab Ultimate][ee] 10.4.
+
+Dynamic Application Security Testing (DAST) uses the
+popular open source tool [OWASP ZAProxy](https://github.com/zaproxy/zaproxy)
+to perform an analysis on the current code and checks for potential security
+issues. Once the report is created, it's uploaded as an artifact which you can
+later download and check out.
+
+In GitLab Ultimate, any security warnings are also
+[shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/dast.html).
+
+### Auto Browser Performance Testing
+
+> Introduced in [GitLab Premium][ee] 10.4.
+
+Auto Browser Performance Testing utilizes the [Sitespeed.io container](https://hub.docker.com/r/sitespeedio/sitespeed.io/) to measure the performance of a web page. A JSON report is created and uploaded as an artifact, which includes the overall performance score for each page. By default, the root page of Review and Production environments will be tested. If you would like to add additional URL's to test, simply add the paths to a file named `.gitlab-urls.txt` in the root directory, one per line. For example:
+
+```
+/
+/features
+/direction
+```
+
+In GitLab Premium, performance differences between the source and target branches are [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html).
+
### Auto Deploy
NOTE: **Note:**
@@ -267,6 +323,18 @@ enable them.
You can make use of [environment variables](#helm-chart-variables) to automatically
scale your pod replicas.
+It's important to note that when a project is deployed to a Kubernetes cluster,
+it relies on a Docker image that has been pushed to the
+[GitLab Container Registry](../../user/project/container_registry.md). Kubernetes
+fetches this image and uses it to run the application. If the project is public,
+the image can be accessed by Kubernetes without any authentication, allowing us
+to have deployments more usable. If the project is private/internal, the
+Registry requires credentials to pull the image. Currently, this is addressed
+by providing `CI_JOB_TOKEN` as the password that can be used, but this token will
+no longer be valid as soon as the deployment job finishes. This means that
+Kubernetes can run the application, but in case it should be restarted or
+executed somewhere else, it cannot be accessed again.
+
### Auto Monitoring
NOTE: **Note:**
@@ -355,7 +423,7 @@ If you want to modify the CI/CD pipeline used by Auto DevOps, you can copy the
Assuming that your project is new or it doesn't have a `.gitlab-ci.yml` file
present:
-1. From your project home page, either click on the "Set up CI" button, or click
+1. From your project home page, either click on the "Set up CI/CD" button, or click
on the plus button and (`+`), then "New file"
1. Pick `.gitlab-ci.yml` as the template type
1. Select "Auto-DevOps" from the template dropdown
@@ -546,11 +614,11 @@ curl --data "value=true" --header "PRIVATE-TOKEN: personal_access_token" https:/
```
[ce-37115]: https://gitlab.com/gitlab-org/gitlab-ce/issues/37115
-[kubernetes-service]: ../../user/project/integrations/kubernetes.md
+[kubernetes-clusters]: ../../user/project/clusters/index.md
[docker-in-docker]: ../../docker/using_docker_build.md#use-docker-in-docker-executor
[review-app]: ../../ci/review_apps/index.md
[container-registry]: ../../user/project/container_registry.md
[postgresql]: https://www.postgresql.org/
[Auto DevOps template]: https://gitlab.com/gitlab-org/gitlab-ci-yml/blob/master/Auto-DevOps.gitlab-ci.yml
[GitLab Omnibus Helm Chart]: ../../install/kubernetes/gitlab_omnibus.md
-[ee]: https://about.gitlab.com/gitlab-ee/
+[ee]: https://about.gitlab.com/products/
diff --git a/doc/topics/autodevops/quick_start_guide.md b/doc/topics/autodevops/quick_start_guide.md
index 4858735ee86..15567715c98 100644
--- a/doc/topics/autodevops/quick_start_guide.md
+++ b/doc/topics/autodevops/quick_start_guide.md
@@ -102,6 +102,11 @@ running:
kubectl get svc ruby-app-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
```
+NOTE: **Note:**
+If your ingress controller has been installed in a different way, you can find
+how to get the external IP address in the
+[Cluster documentation](../../user/project/clusters/index.md#getting-the-external-ip-address).
+
Use this IP address to configure your DNS. This part heavily depends on your
preferences and domain provider. But in case you are not sure, just create an
A record with a wildcard host like `*.<your-domain>`.
diff --git a/doc/topics/git/how_to_install_git/index.md b/doc/topics/git/how_to_install_git/index.md
new file mode 100644
index 00000000000..6c909a1ba86
--- /dev/null
+++ b/doc/topics/git/how_to_install_git/index.md
@@ -0,0 +1,69 @@
+---
+author: Sean Packham
+author_gitlab: SeanPackham
+level: beginner
+article_type: user guide
+date: 2017-05-15
+---
+
+# Installing Git
+
+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 dependency 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/topics/git/index.md b/doc/topics/git/index.md
index 588f4fa369f..2ca2bf743fb 100644
--- a/doc/topics/git/index.md
+++ b/doc/topics/git/index.md
@@ -14,6 +14,7 @@ We've gathered some resources to help you to get the best from Git with GitLab.
## Getting started
- [Git concepts](../../university/training/user_training.md#git-concepts)
+- [How to install Git](how_to_install_git/index.md)
- [Start using Git on the command line](../../gitlab-basics/start-using-git.md)
- [Command Line basic commands](../../gitlab-basics/command-line-commands.md)
- [GitLab Git Cheat Sheet (download)](https://gitlab.com/gitlab-com/marketing/raw/master/design/print/git-cheatsheet/print-pdf/git-cheatsheet.pdf)
@@ -21,27 +22,39 @@ We've gathered some resources to help you to get the best from Git with GitLab.
- [Revert a commit](../../user/project/merge_requests/revert_changes.md#reverting-a-commit)
- [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:**
- - [GLU Course: About Version Control](https://docs.google.com/presentation/d/16sX7hUrCZyOFbpvnrAFrg6tVO5_yT98IgdAqOmXwBho/edit?usp=sharing)
-- **Third-party resources:**
- - What is [Git](https://git-scm.com)
- - [Version control](https://git-scm.com/book/en/v2/Getting-Started-About-Version-Control)
- - [Getting Started - Git Basics](https://git-scm.com/book/en/v2/Getting-Started-Git-Basics)
- - [Getting Started - Installing Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
- - [Git on the Server - GitLab](https://git-scm.com/book/en/v2/Git-on-the-Server-GitLab)
+
+**Third-party references:**
+
+- [Getting Started - Git website](https://git-scm.com)
+- [Getting Started - Version control](https://git-scm.com/book/en/v2/Getting-Started-About-Version-Control)
+- [Getting Started - Git Basics](https://git-scm.com/book/en/v2/Getting-Started-Git-Basics)
+- [Getting Started - Installing Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
+- [Git on the Server - GitLab](https://git-scm.com/book/en/v2/Git-on-the-Server-GitLab)
+
+### Concepts
+
+- Article (2017-05-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/)
+- Article (2016-05-11): [The future of SaaS hosted Git repository pricing](https://about.gitlab.com/2016/05/11/git-repository-pricing/)
+- GLU Course (Presentation): [About Version Control](https://docs.google.com/presentation/d/16sX7hUrCZyOFbpvnrAFrg6tVO5_yT98IgdAqOmXwBho/edit?usp=sharing)
+
+## Exploring Git
+
+- [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/)
+
+## Troubleshooting Git
+
+- [Numerous _undo_ possibilities in Git](numerous_undo_possibilities_in_git/index.md)
+- Learn a few [Git troubleshooting](troubleshooting_git.md) techniques to help you out.
## Branching strategies
-- **Articles:**
- - [GitLab Flow](https://about.gitlab.com/2014/09/29/gitlab-flow/)
-- **Third-party resources:**
- - [Git Branching - Branches in a Nutshell](https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell)
- - [Git Branching - Branching Workflows](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows)
+- [GitLab Flow](https://about.gitlab.com/2014/09/29/gitlab-flow/)
+
+**Third-party references:**
+
+- [Git Branching - Branches in a Nutshell](https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell)
+- [Git Branching - Branching Workflows](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows)
## Advanced use
@@ -55,17 +68,7 @@ We've gathered some resources to help you to get the best from Git with GitLab.
## Git LFS
-- [Git LFS](../../workflow/lfs/manage_large_binaries_with_git_lfs.md)
+- [Getting Started with Git LFS](https://about.gitlab.com/2017/01/30/getting-started-with-git-lfs-tutorial/)
+- [GitLab Git LFS documentation](../../workflow/lfs/manage_large_binaries_with_git_lfs.md)
- [Git-Annex to Git-LFS migration guide](https://docs.gitlab.com/ee/workflow/lfs/migrate_from_git_annex_to_git_lfs.html)
-- **Articles:**
- - [Getting Started with Git LFS](https://about.gitlab.com/2017/01/30/getting-started-with-git-lfs-tutorial/)
- - [Towards a production quality open source Git LFS server](https://about.gitlab.com/2015/08/13/towards-a-production-quality-open-source-git-lfs-server/)
-
-## Troubleshooting
-
-- Learn a few [Git troubleshooting](troubleshooting_git.md) techniques to help you out.
-
-## General information
-
-- **Articles:**
- - [The future of SaaS hosted Git repository pricing](https://about.gitlab.com/2016/05/11/git-repository-pricing/)
+- Article (2015-08-13): [Towards a production quality open source Git LFS server](https://about.gitlab.com/2015/08/13/towards-a-production-quality-open-source-git-lfs-server/)
diff --git a/doc/articles/numerous_undo_possibilities_in_git/img/branching.png b/doc/topics/git/numerous_undo_possibilities_in_git/img/branching.png
index 9a80c211c99..9a80c211c99 100644
--- a/doc/articles/numerous_undo_possibilities_in_git/img/branching.png
+++ b/doc/topics/git/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/topics/git/numerous_undo_possibilities_in_git/img/rebase_reset.png
index ac7ea9ecddc..ac7ea9ecddc 100644
--- a/doc/articles/numerous_undo_possibilities_in_git/img/rebase_reset.png
+++ b/doc/topics/git/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/topics/git/numerous_undo_possibilities_in_git/img/revert.png
index 13b3a35ca45..13b3a35ca45 100644
--- a/doc/articles/numerous_undo_possibilities_in_git/img/revert.png
+++ b/doc/topics/git/numerous_undo_possibilities_in_git/img/revert.png
Binary files differ
diff --git a/doc/topics/git/numerous_undo_possibilities_in_git/index.md b/doc/topics/git/numerous_undo_possibilities_in_git/index.md
new file mode 100644
index 00000000000..4cb8f083fb5
--- /dev/null
+++ b/doc/topics/git/numerous_undo_possibilities_in_git/index.md
@@ -0,0 +1,500 @@
+---
+author: Crt Mori
+author_gitlab: Letme
+level: intermediary
+article_type: tutorial
+date: 2017-05-15
+---
+
+# Numerous undo possibilities in Git
+
+## 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](../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/university/glossary/README.md b/doc/university/glossary/README.md
index fbe7353c6ca..a9ccbf5a085 100644
--- a/doc/university/glossary/README.md
+++ b/doc/university/glossary/README.md
@@ -229,7 +229,7 @@ Our free on Premise solution with >100,000 users
### GitLab CI
-Our own Continuos Integration [feature](https://about.gitlab.com/gitlab-ci/) that is shipped with each instance
+Our own Continuous Integration [feature](https://about.gitlab.com/gitlab-ci/) that is shipped with each instance
### GitLab EE
diff --git a/doc/university/high-availability/aws/README.md b/doc/university/high-availability/aws/README.md
index 54625996dff..47ccd0e6dbc 100644
--- a/doc/university/high-availability/aws/README.md
+++ b/doc/university/high-availability/aws/README.md
@@ -26,6 +26,10 @@ Login to your AWS account through the `My Account` dropdown on
Amazon Web Services console from where we can choose all of the services
we'll be using to configure our cloud infrastructure.
+### Reference Architecture
+
+![Reference Architecture](img/reference-arch.png)
+
***
## Network
@@ -143,7 +147,7 @@ change which will be helpful is the database name for which we can use
## ElastiCache
EC is an in-memory hosted caching solution. Redis maintains its own
-persistance and is used for certain types of application.
+persistence and is used for certain types of application.
Let's choose the ElastiCache service in the Database section from our
AWS console. Now lets create a cache subnet group which will be very
@@ -307,7 +311,7 @@ Here is a tricky part though, when adding subnets we need to associate
public subnets instead of the private ones where our instances will
actually live.
-On the secruity group section let's create a new one named
+On the security group section let's create a new one named
`gitlab-loadbalancer-sec-group` and allow both HTTP ad HTTPS traffic
from anywhere.
diff --git a/doc/university/high-availability/aws/img/reference-arch.png b/doc/university/high-availability/aws/img/reference-arch.png
new file mode 100644
index 00000000000..271ee5bc614
--- /dev/null
+++ b/doc/university/high-availability/aws/img/reference-arch.png
Binary files differ
diff --git a/doc/update/10.1-to-10.2.md b/doc/update/10.1-to-10.2.md
index 9e0d8f79522..632e8befa74 100644
--- a/doc/update/10.1-to-10.2.md
+++ b/doc/update/10.1-to-10.2.md
@@ -339,11 +339,11 @@ sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
If all items are green, then congratulations, the upgrade is complete!
-## Things went south? Revert to previous version (10.0)
+## Things went south? Revert to previous version (10.1)
### 1. Revert the code to the previous version
-Follow the [upgrade guide from 9.5 to 10.0](9.5-to-10.0.md), except for the
+Follow the [upgrade guide from 10.0 to 10.1](10.0-to-10.1.md), except for the
database migration (the backup is already migrated to the previous version).
### 2. Restore from the backup
diff --git a/doc/update/10.2-to-10.3.md b/doc/update/10.2-to-10.3.md
index 07f9ee965f0..f8fe4a4b6bf 100644
--- a/doc/update/10.2-to-10.3.md
+++ b/doc/update/10.2-to-10.3.md
@@ -54,17 +54,16 @@ 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.
+GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets.
+We require a minimum version of node v6.0.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
+a version older than `v6.0.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.
@@ -339,11 +338,11 @@ sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
If all items are green, then congratulations, the upgrade is complete!
-## Things went south? Revert to previous version (10.0)
+## Things went south? Revert to previous version (10.2)
### 1. Revert the code to the previous version
-Follow the [upgrade guide from 9.5 to 10.0](9.5-to-10.0.md), except for the
+Follow the [upgrade guide from 10.1 to 10.2](10.1-to-10.2.md), except for the
database migration (the backup is already migrated to the previous version).
### 2. Restore from the backup
diff --git a/doc/update/10.3-to-10.4.md b/doc/update/10.3-to-10.4.md
new file mode 100644
index 00000000000..083f6090a8a
--- /dev/null
+++ b/doc/update/10.3-to-10.4.md
@@ -0,0 +1,361 @@
+---
+comments: false
+---
+
+# From 10.3 to 10.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
+
+NOTE: If you installed GitLab from source, make sure `rsync` is installed.
+
+```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.6.tar.gz
+echo '4e6a0f828819e15d274ae58485585fc8b7caace0 ruby-2.3.6.tar.gz' | shasum -c - && tar xzf ruby-2.3.6.tar.gz
+cd ruby-2.3.6
+./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.
+We require a minimum version of node v6.0.0.
+
+You can check which version you are running with `node -v`. If you are running
+a version older than `v6.0.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 10-4-stable
+```
+
+OR
+
+For GitLab Enterprise Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 10-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
+
+#### 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-10.1 '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/10-3-stable:config/gitlab.yml.example origin/10-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/10-3-stable:lib/support/nginx/gitlab-ssl origin/10-4-stable:lib/support/nginx/gitlab-ssl
+
+# For HTTP configurations
+git diff origin/10-3-stable:lib/support/nginx/gitlab origin/10-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/10-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/10-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/10-3-stable:lib/support/init.d/gitlab.default.example origin/10-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.
+
+```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 (10.3)
+
+### 1. Revert the code to the previous version
+
+Follow the [upgrade guide from 10.2 to 10.3](10.2-to-10.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/10-4-stable/config/gitlab.yml.example
+[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/10-4-stable/lib/support/init.d/gitlab.default.example
diff --git a/doc/update/10.4-to-10.5.md b/doc/update/10.4-to-10.5.md
new file mode 100644
index 00000000000..313419ed13d
--- /dev/null
+++ b/doc/update/10.4-to-10.5.md
@@ -0,0 +1,361 @@
+---
+comments: false
+---
+
+# From 10.4 to 10.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
+
+NOTE: If you installed GitLab from source, make sure `rsync` is installed.
+
+```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.6.tar.gz
+echo '4e6a0f828819e15d274ae58485585fc8b7caace0 ruby-2.3.6.tar.gz' | shasum -c - && tar xzf ruby-2.3.6.tar.gz
+cd ruby-2.3.6
+./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.
+We require a minimum version of node v6.0.0.
+
+You can check which version you are running with `node -v`. If you are running
+a version older than `v6.0.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 10-5-stable
+```
+
+OR
+
+For GitLab Enterprise Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 10-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-10.1 '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/10-4-stable:config/gitlab.yml.example origin/10-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/10-4-stable:lib/support/nginx/gitlab-ssl origin/10-5-stable:lib/support/nginx/gitlab-ssl
+
+# For HTTP configurations
+git diff origin/10-4-stable:lib/support/nginx/gitlab origin/10-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/10-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/10-5-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/10-4-stable:lib/support/init.d/gitlab.default.example origin/10-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 (10.4)
+
+### 1. Revert the code to the previous version
+
+Follow the [upgrade guide from 10.3 to 10.4](10.3-to-10.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/10-5-stable/config/gitlab.yml.example
+[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/10-5-stable/lib/support/init.d/gitlab.default.example
diff --git a/doc/update/10.5-to-10.6.md b/doc/update/10.5-to-10.6.md
new file mode 100644
index 00000000000..2f90fb62c4a
--- /dev/null
+++ b/doc/update/10.5-to-10.6.md
@@ -0,0 +1,361 @@
+---
+comments: false
+---
+
+# From 10.5 to 10.6
+
+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
+
+NOTE: If you installed GitLab from source, make sure `rsync` is installed.
+
+```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.6.tar.gz
+echo '4e6a0f828819e15d274ae58485585fc8b7caace0 ruby-2.3.6.tar.gz' | shasum -c - && tar xzf ruby-2.3.6.tar.gz
+cd ruby-2.3.6
+./configure --disable-install-rdoc
+make
+sudo make install
+```
+
+Install Bundler:
+
+```bash
+sudo gem install bundler --no-ri --no-rdoc
+```
+
+### 4. Update Node
+
+GitLab utilizes [webpack](http://webpack.js.org) to compile frontend assets.
+This requires a minimum version of node v6.0.0.
+
+You can check which version you are running with `node -v`. If you are running
+a version older than `v6.0.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/>
+
+GitLab also requires the use of yarn `>= v1.2.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 --prune
+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 10-6-stable
+```
+
+OR
+
+For GitLab Enterprise Edition:
+
+```bash
+cd /home/git/gitlab
+
+sudo -u git -H git checkout 10-6-stable-ee
+```
+
+### 7. Update gitlab-shell
+
+```bash
+cd /home/git/gitlab-shell
+
+sudo -u git -H git fetch --all --tags --prune
+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 --prune
+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-10.1 's/\[\[storages\]\]/[[storage]]/' /home/git/gitaly/config.toml
+```
+
+#### Compile Gitaly
+
+```shell
+cd /home/git/gitaly
+sudo -u git -H git fetch --all --tags --prune
+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/10-5-stable:config/gitlab.yml.example origin/10-6-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/10-5-stable:lib/support/nginx/gitlab-ssl origin/10-6-stable:lib/support/nginx/gitlab-ssl
+
+# For HTTP configurations
+git diff origin/10-5-stable:lib/support/nginx/gitlab origin/10-6-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/10-6-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/10-6-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/10-5-stable:lib/support/init.d/gitlab.default.example origin/10-6-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 (10.5)
+
+### 1. Revert the code to the previous version
+
+Follow the [upgrade guide from 10.4 to 10.5](10.4-to-10.5.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/10-6-stable/config/gitlab.yml.example
+[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/10-6-stable/lib/support/init.d/gitlab.default.example
diff --git a/doc/update/mysql_to_postgresql.md b/doc/update/mysql_to_postgresql.md
index fff47180099..44e9f6c5516 100644
--- a/doc/update/mysql_to_postgresql.md
+++ b/doc/update/mysql_to_postgresql.md
@@ -1,13 +1,15 @@
---
-last_updated: 2017-10-05
+last_updated: 2018-02-07
---
# Migrating from MySQL to PostgreSQL
-> **Note:** This guide assumes you have a working Omnibus GitLab instance with
+> **Note:** This guide assumes you have a working GitLab instance with
> MySQL and want to migrate to bundled PostgreSQL database.
-## Prerequisites
+## Omnibus installation
+
+### Prerequisites
First, we'll need to enable the bundled PostgreSQL database with up-to-date
schema. Next, we'll use [pgloader](http://pgloader.io) to migrate the data
@@ -19,7 +21,7 @@ Here's what you'll need to have installed:
- Omnibus GitLab
- MySQL
-## Enable bundled PostgreSQL database
+### Enable bundled PostgreSQL database
1. Stop GitLab:
@@ -65,7 +67,7 @@ Here's what you'll need to have installed:
After these steps, you'll have a fresh PostgreSQL database with up-to-date schema.
-## Migrate data from MySQL to PostgreSQL
+### Migrate data from MySQL to PostgreSQL
Now, you can use pgloader to migrate the data from MySQL to PostgreSQL:
@@ -104,122 +106,9 @@ the following:
----------------------------------------------- --------- --------- --------- --------------
public.abuse_reports 0 0 0 0.490s
public.appearances 0 0 0 0.488s
- public.approvals 0 0 0 0.273s
- public.application_settings 1 1 0 0.266s
- public.approvers 0 0 0 0.339s
- public.approver_groups 0 0 0 0.357s
- public.audit_events 1 1 0 0.410s
- public.award_emoji 0 0 0 0.441s
- public.boards 0 0 0 0.505s
- public.broadcast_messages 0 0 0 0.498s
- public.chat_names 0 0 0 0.576s
- public.chat_teams 0 0 0 0.617s
- public.ci_builds 0 0 0 0.611s
- public.ci_group_variables 0 0 0 0.620s
- public.ci_pipelines 0 0 0 0.599s
- public.ci_pipeline_schedules 0 0 0 0.622s
- public.ci_pipeline_schedule_variables 0 0 0 0.573s
- public.ci_pipeline_variables 0 0 0 0.594s
- public.ci_runners 0 0 0 0.533s
- public.ci_runner_projects 0 0 0 0.584s
- public.ci_sources_pipelines 0 0 0 0.564s
- public.ci_stages 0 0 0 0.595s
- public.ci_triggers 0 0 0 0.569s
- public.ci_trigger_requests 0 0 0 0.596s
- public.ci_variables 0 0 0 0.565s
- public.container_repositories 0 0 0 0.605s
- public.conversational_development_index_metrics 0 0 0 0.571s
- public.deployments 0 0 0 0.607s
- public.emails 0 0 0 0.602s
- public.deploy_keys_projects 0 0 0 0.557s
- public.events 160 160 0 0.677s
- public.environments 0 0 0 0.567s
- public.features 0 0 0 0.639s
- public.events_for_migration 160 160 0 0.582s
- public.feature_gates 0 0 0 0.579s
- public.forked_project_links 0 0 0 0.660s
- public.geo_nodes 0 0 0 0.686s
- public.geo_event_log 0 0 0 0.626s
- public.geo_repositories_changed_events 0 0 0 0.677s
- public.geo_node_namespace_links 0 0 0 0.618s
- public.geo_repository_renamed_events 0 0 0 0.696s
- public.gpg_keys 0 0 0 0.704s
- public.geo_repository_deleted_events 0 0 0 0.638s
- public.historical_data 0 0 0 0.729s
- public.geo_repository_updated_events 0 0 0 0.634s
- public.index_statuses 0 0 0 0.746s
- public.gpg_signatures 0 0 0 0.667s
- public.issue_assignees 80 80 0 0.769s
- public.identities 0 0 0 0.655s
- public.issue_metrics 80 80 0 0.781s
- public.issues 80 80 0 0.720s
- public.labels 0 0 0 0.795s
- public.issue_links 0 0 0 0.707s
- public.label_priorities 0 0 0 0.793s
- public.keys 0 0 0 0.734s
- public.lfs_objects 0 0 0 0.812s
- public.label_links 0 0 0 0.725s
- public.licenses 0 0 0 0.813s
- public.ldap_group_links 0 0 0 0.751s
- public.members 52 52 0 0.830s
- public.lfs_objects_projects 0 0 0 0.738s
- public.merge_requests_closing_issues 0 0 0 0.825s
- public.lists 0 0 0 0.769s
- public.merge_request_diff_commits 0 0 0 0.840s
- public.merge_request_metrics 0 0 0 0.837s
- public.merge_requests 0 0 0 0.753s
- public.merge_request_diffs 0 0 0 0.771s
- public.namespaces 30 30 0 0.874s
- public.merge_request_diff_files 0 0 0 0.775s
- public.notes 0 0 0 0.849s
- public.milestones 40 40 0 0.799s
- public.oauth_access_grants 0 0 0 0.979s
- public.namespace_statistics 0 0 0 0.797s
- public.oauth_applications 0 0 0 0.899s
- public.notification_settings 72 72 0 0.818s
- public.oauth_access_tokens 0 0 0 0.807s
- public.pages_domains 0 0 0 0.958s
- public.oauth_openid_requests 0 0 0 0.832s
- public.personal_access_tokens 0 0 0 0.965s
- public.projects 8 8 0 0.987s
- public.path_locks 0 0 0 0.925s
- public.plans 0 0 0 0.923s
- public.project_features 8 8 0 0.985s
- public.project_authorizations 66 66 0 0.969s
- public.project_import_data 8 8 0 1.002s
- public.project_statistics 8 8 0 1.001s
- public.project_group_links 0 0 0 0.949s
- public.project_mirror_data 0 0 0 0.972s
- public.protected_branch_merge_access_levels 0 0 0 1.017s
- public.protected_branches 0 0 0 0.969s
- public.protected_branch_push_access_levels 0 0 0 0.991s
- public.protected_tags 0 0 0 1.009s
- public.protected_tag_create_access_levels 0 0 0 0.985s
- public.push_event_payloads 0 0 0 1.041s
- public.push_rules 0 0 0 0.999s
- public.redirect_routes 0 0 0 1.020s
- public.remote_mirrors 0 0 0 1.034s
- public.releases 0 0 0 0.993s
- public.schema_migrations 896 896 0 1.057s
- public.routes 38 38 0 1.021s
- public.services 0 0 0 1.055s
- public.sent_notifications 0 0 0 1.003s
- public.slack_integrations 0 0 0 1.022s
- public.spam_logs 0 0 0 1.024s
- public.snippets 0 0 0 1.058s
- public.subscriptions 0 0 0 1.069s
- public.taggings 0 0 0 1.099s
- public.timelogs 0 0 0 1.104s
- public.system_note_metadata 0 0 0 1.038s
- public.tags 0 0 0 1.034s
- public.trending_projects 0 0 0 1.140s
- public.uploads 0 0 0 1.129s
- public.todos 80 80 0 1.085s
- public.users_star_projects 0 0 0 1.153s
- public.u2f_registrations 0 0 0 1.061s
- public.web_hooks 0 0 0 1.179s
- public.users 26 26 0 1.163s
- public.user_agent_details 0 0 0 1.068s
+ .
+ .
+ .
public.web_hook_logs 0 0 0 1.080s
----------------------------------------------- --------- --------- --------- --------------
COPY Threads Completion 4 4 0 2.008s
@@ -240,9 +129,9 @@ the following:
Now, you can verify that everything worked by visiting GitLab.
-## Troubleshooting
+### Troubleshooting
-### Permissions
+#### Permissions
Note that the PostgreSQL user that you use for the above MUST have **superuser** privileges. Otherwise, you may see
a similar message to the following:
@@ -256,7 +145,7 @@ debugger invoked on a CL-POSTGRES-ERROR:INSUFFICIENT-PRIVILEGE in thread
QUERY: ALTER TABLE approver_groups DISABLE TRIGGER ALL;
```
-### Experiencing 500 errors after the migration
+#### Experiencing 500 errors after the migration
If you experience 500 errors after the migration, try to clear the cache:
@@ -265,3 +154,130 @@ sudo gitlab-rake cache:clear
```
[reconfigure GitLab]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure
+
+## Source installation
+
+### Prerequisites
+
+#### Install PostgreSQL and create database
+
+See [installation guide](../install/installation.md#6-database).
+
+#### Install [pgloader](http://pgloader.io) 3.4.1+
+
+Install directly from your distro:
+``` bash
+sudo apt-get install pgloader
+```
+
+If this version is too old, use PostgreSQL's repository:
+``` bash
+# add repository
+sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
+
+# add key
+sudo apt-get install wget ca-certificates
+wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
+
+# install package
+sudo apt-get update
+sudo apt-get install pgloader
+```
+
+### Enable bundled PostgreSQL database
+
+1. Stop GitLab:
+
+ ``` bash
+ sudo service gitlab stop
+ ```
+
+1. Switch database from MySQL to PostgreSQL
+
+ ``` bash
+ cd /home/git/gitlab
+ sudo -u git mv config/database.yml config/database.yml.bak
+ sudo -u git cp config/database.yml.postgresql config/database.yml
+ sudo -u git -H chmod o-rwx config/database.yml
+ ```
+
+1. Run the following commands to prepare the schema:
+
+ ``` bash
+ sudo -u git -H bundle exec rake db:create db:migrate RAILS_ENV=production
+ ```
+
+After these steps, you'll have a fresh PostgreSQL database with up-to-date schema.
+
+### Migrate data from MySQL to PostgreSQL
+
+Now, you can use pgloader to migrate the data from MySQL to PostgreSQL:
+
+1. Save the following snippet in a `commands.load` file, and edit with your
+ MySQL `username`, `password` and `host`:
+
+ ```
+ LOAD DATABASE
+ FROM mysql://username:password@host/gitlabhq_production
+ INTO postgresql://postgres@unix://var/run/postgresql:/gitlabhq_production
+
+ WITH include no drop, truncate, disable triggers, create no tables,
+ create no indexes, preserve index names, no foreign keys,
+ data only
+
+ ALTER SCHEMA 'gitlabhq_production' RENAME TO 'public'
+
+ ;
+ ```
+
+1. Start the migration:
+
+ ``` bash
+ sudo -u postgres pgloader commands.load
+ ```
+
+1. Once the migration finishes, you should see a summary table that looks like
+the following:
+
+
+ ```
+ table name read imported errors total time
+ ----------------------------------------------- --------- --------- --------- --------------
+ fetch meta data 119 119 0 0.388s
+ Truncate 119 119 0 1.134s
+ ----------------------------------------------- --------- --------- --------- --------------
+ public.abuse_reports 0 0 0 0.490s
+ public.appearances 0 0 0 0.488s
+ .
+ .
+ .
+ public.web_hook_logs 0 0 0 1.080s
+ ----------------------------------------------- --------- --------- --------- --------------
+ COPY Threads Completion 4 4 0 2.008s
+ Reset Sequences 113 113 0 0.304s
+ Install Comments 0 0 0 0.000s
+ ----------------------------------------------- --------- --------- --------- --------------
+ Total import time 1894 1894 0 12.497s
+ ```
+
+ If there is no output for more than 30 minutes, it's possible pgloader encountered an error. See
+ the [troubleshooting guide](#Troubleshooting) for more details.
+
+1. Start GitLab:
+
+ ``` bash
+ sudo service gitlab start
+ ```
+
+Now, you can verify that everything worked by visiting GitLab.
+
+### Troubleshooting
+
+#### Experiencing 500 errors after the migration
+
+If you experience 500 errors after the migration, try to clear the cache:
+
+``` bash
+sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production
+```
+
diff --git a/doc/user/admin_area/monitoring/img/convdev_index.png b/doc/user/admin_area/monitoring/img/convdev_index.png
index ffe18d76c96..1bf1d6a83c9 100644
--- a/doc/user/admin_area/monitoring/img/convdev_index.png
+++ b/doc/user/admin_area/monitoring/img/convdev_index.png
Binary files differ
diff --git a/doc/user/admin_area/settings/email.md b/doc/user/admin_area/settings/email.md
new file mode 100644
index 00000000000..7c9e5bf882e
--- /dev/null
+++ b/doc/user/admin_area/settings/email.md
@@ -0,0 +1,5 @@
+# Email
+
+## Custom logo
+
+The logo in the header of some emails can be customized, see the [logo customization section](../../../customization/branded_page_and_email_header.md).
diff --git a/doc/user/admin_area/settings/img/update-available.png b/doc/user/admin_area/settings/img/update-available.png
new file mode 100644
index 00000000000..0dafdad618e
--- /dev/null
+++ b/doc/user/admin_area/settings/img/update-available.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 d874688cc29..381efdf5d67 100644
--- a/doc/user/admin_area/settings/usage_statistics.md
+++ b/doc/user/admin_area/settings/usage_statistics.md
@@ -8,20 +8,26 @@ under **Admin area > Settings > Usage statistics**.
## Version check
-GitLab can inform you when an update is available and the importance of it.
+If enabled, version check will inform you if a new version is available and the
+importance of it through a status. This is shown on the help page (i.e. `/help`)
+for all signed in users, and on the admin pages. The statuses are:
-No information other than the GitLab version and the instance's hostname (through the HTTP referer)
-are collected.
+* Green: You are running the latest version of GitLab.
+* Orange: An updated version of GitLab is available.
+* Red: The version of GitLab you are running is vulnerable. You should install
+ the latest version with security fixes as soon as possible.
-In the **Overview** tab you can see if your GitLab version is up to date. There
-are three cases: 1) you are up to date (green), 2) there is an update available
-(yellow) and 3) your version is vulnerable and a security fix is released (red).
+![Orange version check example](img/update-available.png)
-In any case, you will see a message informing you of the state and the
-importance of the update.
+GitLab Inc. collects your instance's version and hostname (through the HTTP
+referer) as part of the version check. No other information is collected.
-If enabled, the version status will also be shown in the help page (`/help`)
-for all signed in users.
+This information is used, among other things, to identify to which versions
+patches will need to be back ported, making sure active GitLab instances remain
+secure.
+
+If you disable version check, this information will not be collected. Enable or
+disable the version check at **Admin area > Settings > Usage statistics**.
## Usage ping
diff --git a/doc/user/admin_area/settings/visibility_and_access_controls.md b/doc/user/admin_area/settings/visibility_and_access_controls.md
index 633f16a617c..3d38588a9ed 100644
--- a/doc/user/admin_area/settings/visibility_and_access_controls.md
+++ b/doc/user/admin_area/settings/visibility_and_access_controls.md
@@ -32,9 +32,15 @@ When you choose to allow only one of the protocols, a couple of things will happ
On top of these UI restrictions, GitLab will deny all Git actions on the protocol
not selected.
+CAUTION: **Important:**
+Starting with [GitLab 10.7][ce-18021], HTTP(s) protocol will be allowed for
+git clone/fetch requests done by GitLab Runner from CI/CD Jobs, even if
+_Only SSH_ was selected.
+
> **Note:** Please keep in mind that disabling an access protocol does not actually
- block access to the server itself. The ports used for the protocol, be it SSH or
- HTTP, will still be accessible. What GitLab does is restrict access on the
- application level.
+block access to the server itself. The ports used for the protocol, be it SSH or
+HTTP, will still be accessible. What GitLab does is restrict access on the
+application level.
[ce-4696]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4696
+[ce-18021]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18021
diff --git a/doc/user/feature_highlight.md b/doc/user/feature_highlight.md
new file mode 100644
index 00000000000..bd98ea00757
--- /dev/null
+++ b/doc/user/feature_highlight.md
@@ -0,0 +1,15 @@
+# Feature highlight
+
+> [Introduced][ce-16379] in GitLab 10.5
+
+Feature highlights are represented by a pulsing blue dot. Hovering over the dot
+will open up callout with more information.
+They are used to emphasize a certain feature and make something more visible to the user.
+
+You can dismiss any feature highlight permanently by clicking the "Got it" link
+at the bottom of the callout. There isn't a way to restore the feature highlight
+after it has been dismissed.
+
+![Clusters feature highlight](img/feature_highlight_example.png)
+
+[ce-16379]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/16379
diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md
new file mode 100644
index 00000000000..7baccb796c6
--- /dev/null
+++ b/doc/user/gitlab_com/index.md
@@ -0,0 +1,342 @@
+# GitLab.com settings
+
+In this page you will find information about the settings that are used on
+[GitLab.com](https://about.gitlab.com/pricing).
+
+## SSH host keys fingerprints
+
+Below are the fingerprints for GitLab.com's SSH host keys.
+
+| Algorithm | MD5 | SHA256 |
+| --------- | --- | ------- |
+| DSA | `7a:47:81:3a:ee:89:89:64:33:ca:44:52:3d:30:d4:87` | `p8vZBUOR0XQz6sYiaWSMLmh0t9i8srqYKool/Xfdfqw` |
+| ECDSA | `f1:d0:fb:46:73:7a:70:92:5a:ab:5d:ef:43:e2:1c:35` | `HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw` |
+| ED25519 | `2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16` | `eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8` |
+| RSA | `b6:03:0e:39:97:9e:d0:e7:24:ce:a3:77:3e:01:42:09` | `ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ` |
+
+## Mail configuration
+
+GitLab.com sends emails from the `mg.gitlab.com` domain via [Mailgun] and has
+its own dedicated IP address (`198.61.254.240`).
+
+## Alternative SSH port
+
+GitLab.com can be reached via a [different SSH port][altssh] for `git+ssh`.
+
+| Setting | Value |
+| --------- | ------------------- |
+| `Hostname` | `altssh.gitlab.com` |
+| `Port` | `443` |
+
+An example `~/.ssh/config` is the following:
+
+```
+Host gitlab.com
+ Hostname altssh.gitlab.com
+ User git
+ Port 443
+ PreferredAuthentications publickey
+ IdentityFile ~/.ssh/gitlab
+```
+
+## GitLab Pages
+
+Below are the settings for [GitLab Pages].
+
+| Setting | GitLab.com | Default |
+| ----------------------- | ---------------- | ------------- |
+| Domain name | `gitlab.io` | - |
+| IP address | `52.167.214.135` | - |
+| Custom domains support | yes | no |
+| TLS certificates support| yes | no |
+
+The maximum size of your Pages site is regulated by the artifacts maximum size
+which is part of [GitLab CI/CD](#gitlab-ci-cd).
+
+## GitLab CI/CD
+
+Below are the current settings regarding [GitLab CI/CD](../../ci/README.md).
+
+| Setting | GitLab.com | Default |
+| ----------- | ----------------- | ------------- |
+| Artifacts maximum size | 1G | 100M |
+
+## Repository size limit
+
+The maximum size your Git repository is allowed to be including LFS.
+
+| Setting | GitLab.com | Default |
+| ----------- | ----------------- | ------------- |
+| Repository size including LFS | 10G | Unlimited |
+
+## Shared Runners
+
+Shared Runners on GitLab.com run in [autoscale mode] and powered by
+Google Cloud Platform and DigitalOcean. Autoscaling means reduced
+waiting times to spin up CI/CD jobs, and isolated VMs for each project,
+thus maximizing security.
+
+They're free to use for public open source projects and limited to 2000 CI
+minutes per month per group for private projects. Read about all
+[GitLab.com plans](https://about.gitlab.com/pricing/).
+
+In case of DigitalOcean based Runners, all your CI/CD jobs run on ephemeral
+instances with 2GB of RAM, CoreOS and the latest Docker Engine installed.
+Instances provide 2 vCPUs and 60GB of SSD disk space. The default region of the
+VMs is NYC1.
+
+In case of Google Cloud Platform based Runners, all your CI/CD jobs run on
+ephemeral instances with 3.75GB of RAM, CoreOS and the latest Docker Engine
+installed. Instances provide 1 vCPU and 25GB of HDD disk space. The default
+region of the VMs is US East1.
+
+Below are the shared Runners settings.
+
+| Setting | GitLab.com | Default |
+| ----------- | ----------------- | ---------- |
+| [GitLab Runner] | [Runner versions dashboard][ci_version_dashboard] | - |
+| Executor | `docker+machine` | - |
+| Default Docker image | `ruby:2.5` | - |
+| `privileged` (run [Docker in Docker]) | `true` | `false` |
+
+[ci_version_dashboard]: https://monitor.gitlab.net/dashboard/db/ci?from=now-1h&to=now&refresh=5m&orgId=1&panelId=12&fullscreen&theme=light
+
+### `config.toml`
+
+The full contents of our `config.toml` are:
+
+**DigitalOcean**
+
+```toml
+concurrent = X
+check_interval = 1
+metrics_server = "X"
+sentry_dsn = "X"
+
+[[runners]]
+ name = "docker-auto-scale"
+ request_concurrency = X
+ url = "https://gitlab.com/"
+ token = "SHARED_RUNNER_TOKEN"
+ executor = "docker+machine"
+ environment = [
+ "DOCKER_DRIVER=overlay2"
+ ]
+ limit = X
+ [runners.docker]
+ image = "ruby:2.5"
+ privileged = true
+ [runners.machine]
+ IdleCount = 20
+ IdleTime = 1800
+ OffPeakPeriods = ["* * * * * sat,sun *"]
+ OffPeakTimezone = "UTC"
+ OffPeakIdleCount = 5
+ OffPeakIdleTime = 1800
+ MaxBuilds = 1
+ MachineName = "srm-%s"
+ MachineDriver = "digitalocean"
+ MachineOptions = [
+ "digitalocean-image=X",
+ "digitalocean-ssh-user=core",
+ "digitalocean-region=nyc1",
+ "digitalocean-size=s-2vcpu-2gb",
+ "digitalocean-private-networking",
+ "digitalocean-tags=shared_runners,gitlab_com",
+ "engine-registry-mirror=http://INTERNAL_IP_OF_OUR_REGISTRY_MIRROR",
+ "digitalocean-access-token=DIGITAL_OCEAN_ACCESS_TOKEN",
+ ]
+ [runners.cache]
+ Type = "s3"
+ BucketName = "runner"
+ Insecure = true
+ Shared = true
+ ServerAddress = "INTERNAL_IP_OF_OUR_CACHE_SERVER"
+ AccessKey = "ACCESS_KEY"
+ SecretKey = "ACCESS_SECRET_KEY"
+```
+
+**Google Cloud Platform**
+
+```toml
+concurrent = X
+check_interval = 1
+metrics_server = "X"
+sentry_dsn = "X"
+
+[[runners]]
+ name = "docker-auto-scale"
+ request_concurrency = X
+ url = "https://gitlab.com/"
+ token = "SHARED_RUNNER_TOKEN"
+ executor = "docker+machine"
+ environment = [
+ "DOCKER_DRIVER=overlay2"
+ ]
+ limit = X
+ [runners.docker]
+ image = "ruby:2.5"
+ privileged = true
+ [runners.machine]
+ IdleCount = 20
+ IdleTime = 1800
+ OffPeakPeriods = ["* * * * * sat,sun *"]
+ OffPeakTimezone = "UTC"
+ OffPeakIdleCount = 5
+ OffPeakIdleTime = 1800
+ MaxBuilds = 1
+ MachineName = "srm-%s"
+ MachineDriver = "google"
+ MachineOptions = [
+ "google-project=PROJECT",
+ "google-disk-size=25",
+ "google-machine-type=n1-standard-1",
+ "google-username=core",
+ "google-tags=gitlab-com,srm",
+ "google-use-internal-ip",
+ "google-zone=us-east1-d",
+ "google-machine-image=PROJECT/global/images/IMAGE",
+ "engine-registry-mirror=http://INTERNAL_IP_OF_OUR_REGISTRY_MIRROR"
+ ]
+ [runners.cache]
+ Type = "s3"
+ BucketName = "runner"
+ Insecure = true
+ Shared = true
+ ServerAddress = "INTERNAL_IP_OF_OUR_CACHE_SERVER"
+ AccessKey = "ACCESS_KEY"
+ SecretKey = "ACCESS_SECRET_KEY"
+```
+
+## Sidekiq
+
+GitLab.com runs [Sidekiq][sidekiq] with arguments `--timeout=4 --concurrency=4`
+and the following environment variables:
+
+| Setting | GitLab.com | Default |
+|-------- |----------- |-------- |
+| `SIDEKIQ_MEMORY_KILLER_MAX_RSS` | `1000000` | `1000000` |
+| `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_SIGNAL` | `SIGKILL` | - |
+| `SIDEKIQ_LOG_ARGUMENTS` | `1` | - |
+
+## Cron jobs
+
+Periodically executed jobs by Sidekiq, to self-heal Gitlab, do external
+synchronizations, run scheduled pipelines, etc.:
+
+| Setting | GitLab.com | Default |
+|-------- |------------- |------------- |
+| `pipeline_schedule_worker` | `19 * * * *` | `19 * * * *` |
+
+## PostgreSQL
+
+GitLab.com being a fairly large installation of GitLab means we have changed
+various PostgreSQL settings to better suit our needs. For example, we use
+streaming replication and servers in hot-standby mode to balance queries across
+different database servers.
+
+The list of GitLab.com specific settings (and their defaults) is as follows:
+
+| Setting | GitLab.com | Default |
+|:------------------------------------|:--------------------------------------------------------------------|:--------------------------------------|
+| archive_command | `/usr/bin/envdir /etc/wal-e.d/env /opt/wal-e/bin/wal-e wal-push %p` | empty |
+| archive_mode | on | off |
+| autovacuum_analyze_scale_factor | 0.01 | 0.01 |
+| autovacuum_max_workers | 6 | 3 |
+| autovacuum_vacuum_cost_limit | 1000 | -1 |
+| autovacuum_vacuum_scale_factor | 0.01 | 0.02 |
+| checkpoint_completion_target | 0.7 | 0.9 |
+| checkpoint_segments | 32 | 10 |
+| effective_cache_size | 338688MB | Based on how much memory is available |
+| hot_standby | on | off |
+| hot_standby_feedback | on | off |
+| log_autovacuum_min_duration | 0 | -1 |
+| log_checkpoints | on | off |
+| log_line_prefix | `%t [%p]: [%l-1] ` | empty |
+| log_min_duration_statement | 1000 | -1 |
+| log_temp_files | 0 | -1 |
+| maintenance_work_mem | 2048MB | 16 MB |
+| max_replication_slots | 5 | 0 |
+| max_wal_senders | 32 | 0 |
+| max_wal_size | 5GB | 1GB |
+| shared_buffers | 112896MB | Based on how much memory is available |
+| shared_preload_libraries | pg_stat_statements | empty |
+| shmall | 30146560 | Based on the server's capabilities |
+| shmmax | 123480309760 | Based on the server's capabilities |
+| wal_buffers | 16MB | -1 |
+| wal_keep_segments | 512 | 10 |
+| wal_level | replica | minimal |
+| statement_timeout | 15s | 60s |
+| idle_in_transaction_session_timeout | 60s | 60s |
+
+Some of these settings are in the process being adjusted. For example, the value
+for `shared_buffers` is quite high and as such we are looking into adjusting it.
+More information on this particular change can be found at
+<https://gitlab.com/gitlab-com/infrastructure/issues/1555>. An up to date list
+of proposed changes can be found at
+<https://gitlab.com/gitlab-com/infrastructure/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=database&label_name[]=change>.
+
+## Unicorn
+
+GitLab.com adjusts the memory limits for the [unicorn-worker-killer][unicorn-worker-killer] gem.
+
+Base default:
+* `memory_limit_min` = 750MiB
+* `memory_limit_max` = 1024MiB
+
+Web front-ends:
+* `memory_limit_min` = 1024MiB
+* `memory_limit_max` = 1280MiB
+
+## GitLab.com at scale
+
+In addition to the GitLab Enterprise Edition Omnibus install, GitLab.com uses
+the following applications and settings to achieve scale. All settings are
+located publicly available [chef cookbooks](https://gitlab.com/gitlab-cookbooks).
+
+### ELK
+
+We use Elasticsearch, logstash, and Kibana for part of our monitoring solution:
+
+- [gitlab-cookbooks / gitlab-elk · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-elk)
+- [gitlab-cookbooks / gitlab_elasticsearch · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab_elasticsearch)
+
+### Prometheus
+
+Prometheus complete our monitoring stack:
+
+- [gitlab-cookbooks / gitlab-prometheus · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-prometheus)
+
+### Grafana
+
+For the visualization of monitoring data:
+
+- [gitlab-cookbooks / gitlab-grafana · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-grafana)
+
+### Sentry
+
+Open source error tracking:
+
+- [gitlab-cookbooks / gitlab-sentry · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-sentry)
+
+### Consul
+
+Service discovery:
+
+- [gitlab-cookbooks / gitlab_consul · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab_consul)
+
+### Haproxy
+
+High Performance TCP/HTTP Load Balancer:
+
+- [gitlab-cookbooks / gitlab-haproxy · GitLab](https://gitlab.com/gitlab-cookbooks/gitlab-haproxy)
+
+[autoscale mode]: https://docs.gitlab.com/runner/configuration/autoscale.html "How Autoscale works"
+[runners-post]: https://about.gitlab.com/2016/04/05/shared-runners/ "Shared Runners on GitLab.com"
+[GitLab Runner]: https://gitlab.com/gitlab-org/gitlab-runner
+[altssh]: https://about.gitlab.com/2016/02/18/gitlab-dot-com-now-supports-an-alternate-git-plus-ssh-port/ "GitLab.com now supports an alternate git+ssh port"
+[GitLab Pages]: https://about.gitlab.com/features/pages "GitLab Pages"
+[docker in docker]: https://hub.docker.com/_/docker/ "Docker in Docker at DockerHub"
+[mailgun]: https://www.mailgun.com/ "Mailgun website"
+[sidekiq]: http://sidekiq.org/ "Sidekiq website"
+[unicorn-worker-killer]: https://rubygems.org/gems/unicorn-worker-killer "unicorn-worker-killer"
diff --git a/doc/user/group/index.md b/doc/user/group/index.md
index 7f77a33aadc..88efddbfba8 100644
--- a/doc/user/group/index.md
+++ b/doc/user/group/index.md
@@ -168,6 +168,20 @@ Alternatively, you can [lock the sharing with group feature](#share-with-group-l
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.
+## Transfer groups to another group
+
+From 10.5 there are two different ways to transfer a group:
+
+- Either by transferring a group into another group (making it a subgroup of that group).
+- Or by converting a subgroup into a root group (a group with no parent).
+
+Please make sure to understand that:
+
+- Changing a group's parent can have unintended side effects. See [Redirects when changing repository paths](https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths)
+- You can only transfer the group to a group you manage.
+- You will need to update your local repositories to point to the new location.
+- If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.
+
## Group settings
Once you have created a group, you can manage its settings by navigating to
@@ -231,20 +245,22 @@ To enable this feature, navigate to the group settings page. Select
![Checkbox for share with group lock](img/share_with_group_lock.png)
-#### Member Lock (EES/EEP)
+#### Member Lock
+
+> Available in [GitLab Starter](https://about.gitlab.com/products/) and
+[GitLab.com Bronze](https://about.gitlab.com/gitlab-com/).
-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
+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).
+Learn more about [Member Lock](https://docs.gitlab.com/ee/user/group/index.html#member-lock).
### 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](https://about.gitlab.com/products/).)
+and [push rules](https://docs.gitlab.com/ee/push_rules/push_rules.html#push-rules) to your group (Push Rules is available in [GitLab Starter](https://about.gitlab.com/products/).)
- **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]).
+for the group (GitLab admins only, available in [GitLab Starter][ee]).
- **Pipelines quota**: keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group
diff --git a/doc/user/group/subgroups/img/create_subgroup_button.png b/doc/user/group/subgroups/img/create_subgroup_button.png
index 000b54c2855..d1355d4b2c3 100644
--- a/doc/user/group/subgroups/img/create_subgroup_button.png
+++ b/doc/user/group/subgroups/img/create_subgroup_button.png
Binary files differ
diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md
index 161a3af9903..2a982344e5f 100644
--- a/doc/user/group/subgroups/index.md
+++ b/doc/user/group/subgroups/index.md
@@ -90,7 +90,8 @@ structure.
To create a subgroup:
-1. In the group's dashboard go to the **Subgroups** page and click **New subgroup**.
+1. In the group's dashboard expand the **New project** split button, select
+ **New subgroup** and click the **New subgroup** button.
![Subgroups page](img/create_subgroup_button.png)
diff --git a/doc/user/img/feature_highlight_example.png b/doc/user/img/feature_highlight_example.png
new file mode 100644
index 00000000000..32ca05a6087
--- /dev/null
+++ b/doc/user/img/feature_highlight_example.png
Binary files differ
diff --git a/doc/user/index.md b/doc/user/index.md
index f239a15d441..43b6fd53b91 100644
--- a/doc/user/index.md
+++ b/doc/user/index.md
@@ -23,9 +23,20 @@ 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/).
+### Concepts
+
+For an overview on concepts involved when developing code on GitLab,
+read the articles on:
+
+- [Mastering Code Review With GitLab](https://about.gitlab.com/2017/03/17/demo-mastering-code-review-with-gitlab/).
+- [GitLab Workflow, an Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/#gitlab-workflow-use-case-scenario).
+- [Tutorial: It's all connected in GitLab](https://about.gitlab.com/2016/03/08/gitlab-tutorial-its-all-connected/): an overview on code collaboration with GitLab.
+- [Trends in Version Control Land: Microservices](https://about.gitlab.com/2016/08/16/trends-in-version-control-land-microservices/).
+- [Trends in Version Control Land: Innersourcing](https://about.gitlab.com/2016/07/07/trends-version-control-innersourcing/).
+
## Use cases
-GitLab is a git-based platforms that integrates a great number of essential tools for software development and deployment, and project management:
+GitLab is a Git-based platform 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
@@ -58,12 +69,6 @@ and [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.
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
@@ -102,6 +107,8 @@ personal access tokens, authorized applications, etc.
methods available in GitLab.
- [Permissions](permissions.md): Learn the different set of permissions levels for each
user type (guest, reporter, developer, master, owner).
+- [Feature highlight](feature_highlight.md): Learn more about the little blue dots
+around the app that explain certain features
## Groups
diff --git a/doc/user/markdown.md b/doc/user/markdown.md
index 552abac747b..650d60f1585 100644
--- a/doc/user/markdown.md
+++ b/doc/user/markdown.md
@@ -36,12 +36,16 @@ GFM honors the markdown specification in how [paragraphs and line breaks are han
A paragraph is simply one or more consecutive lines of text, separated by one or more blank lines.
Line-breaks, or softreturns, are rendered if you end a line with two or more spaces:
- Roses are red [followed by two or more spaces]
+[//]: # (Do *NOT* remove the two ending whitespaces in the following line.)
+[//]: # (They are needed for the Markdown text to render correctly.)
+ Roses are red [followed by two or more spaces]
Violets are blue
Sugar is sweet
-Roses are red
+[//]: # (Do *NOT* remove the two ending whitespaces in the following line.)
+[//]: # (They are needed for the Markdown text to render correctly.)
+Roses are red
Violets are blue
Sugar is sweet
@@ -226,7 +230,7 @@ https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#emoji
If you are new to this, don't be :fearful:. You can easily join the emoji :family:. All you need to do is to look up on the supported codes.
- Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported emoji codes. :thumbsup:
+ Consult the [Emoji Cheat Sheet](https://www.emojicopy.com) for a list of all supported emoji codes. :thumbsup:
Sometimes you want to :monkey: around a bit and add some :star2: to your :speech_balloon:. Well we have a gift for you:
@@ -236,7 +240,7 @@ You can use it to point out a :bug: or warn about :speak_no_evil: patches. And i
If you are new to this, don't be :fearful:. You can easily join the emoji :family:. All you need to do is to look up on the supported codes.
-Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported emoji codes. :thumbsup:
+Consult the [Emoji Cheat Sheet](https://www.emojicopy.com) for a list of all supported emoji codes. :thumbsup:
### Special GitLab References
@@ -253,7 +257,7 @@ GFM will recognize the following:
| `@user_name` | specific user |
| `@group_name` | specific group |
| `@all` | entire team |
-| `#123` | issue |
+| `#12345` | issue |
| `!123` | merge request |
| `$123` | snippet |
| `~123` | label by ID |
@@ -379,6 +383,45 @@ _Be advised that KaTeX only supports a [subset][katex-subset] of LaTeX._
>**Note:**
This also works for the asciidoctor `:stem: latexmath`. For details see the [asciidoctor user manual][asciidoctor-manual].
+### Colors
+
+> If this is not rendered correctly, see
+https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#colors
+
+It is possible to have color written in HEX, RGB or HSL format rendered with a color indicator.
+
+Color written inside backticks will be followed by a color "chip".
+
+Examples:
+
+ `#F00`
+ `#F00A`
+ `#FF0000`
+ `#FF0000AA`
+ `RGB(0,255,0)`
+ `RGB(0%,100%,0%)`
+ `RGBA(0,255,0,0.7)`
+ `HSL(540,70%,50%)`
+ `HSLA(540,70%,50%,0.7)`
+
+Becomes:
+
+`#F00`
+`#F00A`
+`#FF0000`
+`#FF0000AA`
+`RGB(0,255,0)`
+`RGB(0%,100%,0%)`
+`RGBA(0,255,0,0.7)`
+`HSL(540,70%,50%)`
+`HSLA(540,70%,50%,0.7)`
+
+#### Supported formats:
+
+* HEX: `` `#RGB[A]` `` or `` `#RRGGBB[AA]` ``
+* RGB: `` `RGB[A](R, G, B[, A])` ``
+* HSL: `` `HSL[A](H, S, L[, A])` ``
+
### Mermaid
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15107) in
@@ -711,7 +754,7 @@ This line is separated from the one above by two newlines, so it will be a *sepa
This line is also a separate paragraph, but...
This line is only separated by a single newline, so it *does not break* and just follows the previous line in the *same paragraph*.
-This line is also a separate paragraph, and...
+This line is also a separate paragraph, and...
This line is *on its own line*, because the previous line ends with two spaces. (but still in the *same paragraph*)
spaces.
@@ -724,7 +767,7 @@ This line is separated from the one above by two newlines, so it will be a *sepa
This line is also a separate paragraph, but...
This line is only separated by a single newline, so it *does not break* and just follows the previous line in the *same paragraph*.
-This line is also a separate paragraph, and...
+This line is also a separate paragraph, and...
This line is *on its own line*, because the previous line ends with two spaces. (but still in the *same paragraph*)
spaces.
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 4fa83388d0c..a520279c29e 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -25,7 +25,8 @@ The following table depicts the various user permission levels in a project.
| Create confidential issue | ✓ [^1] | ✓ | ✓ | ✓ | ✓ |
| View confidential issues | (✓) [^2] | ✓ | ✓ | ✓ | ✓ |
| Leave comments | ✓ [^1] | ✓ | ✓ | ✓ | ✓ |
-| Lock discussions (issues and merge requests) | | | | ✓ | ✓ |
+| Lock issue discussions | | ✓ | ✓ | ✓ | ✓ |
+| Lock merge request discussions | | | ✓ | ✓ | ✓ |
| See a list of jobs | ✓ [^3] | ✓ | ✓ | ✓ | ✓ |
| See a job log | ✓ [^3] | ✓ | ✓ | ✓ | ✓ |
| Download and browse job artifacts | ✓ [^3] | ✓ | ✓ | ✓ | ✓ |
@@ -117,14 +118,16 @@ and drag issues around. Read though the
[documentation on Issue Boards permissions](project/issue_board.md#permissions)
to learn more.
-### File Locking permissions (EEP)
+### File Locking permissions
+
+> Available in [GitLab Premium](https://about.gitlab.com/products/).
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.
+[GitLab Premium](https://about.gitlab.com/products/) only.
### Confidential Issues permissions
@@ -200,7 +203,7 @@ instance and project. In addition, all admins can use the admin interface under
|---------------------------------------|-----------------|-------------|----------|--------|
| See commits and jobs | ✓ | ✓ | ✓ | ✓ |
| Retry or cancel job | | ✓ | ✓ | ✓ |
-| Erase job artifacts and trace | | ✓ [^7] | ✓ | ✓ |
+| Erase job artifacts and trace | | ✓ [^5] | ✓ | ✓ |
| Remove project | | | ✓ | ✓ |
| Create project | | | ✓ | ✓ |
| Change project configuration | | | ✓ | ✓ |
@@ -223,13 +226,13 @@ users:
| Run CI job | | ✓ | ✓ | ✓ |
| Clone source and LFS from current project | | ✓ | ✓ | ✓ |
| Clone source and LFS from public projects | | ✓ | ✓ | ✓ |
-| Clone source and LFS from internal projects | | ✓ [^5] | ✓ [^5] | ✓ |
-| Clone source and LFS from private projects | | ✓ [^6] | ✓ [^6] | ✓ [^6] |
+| Clone source and LFS from internal projects | | ✓ [^6] | ✓ [^6] | ✓ |
+| Clone source and LFS from private projects | | ✓ [^7] | ✓ [^7] | ✓ [^7] |
| Push source and LFS | | | | |
| Pull container images from current project | | ✓ | ✓ | ✓ |
| Pull container images from public projects | | ✓ | ✓ | ✓ |
-| Pull container images from internal projects| | ✓ [^5] | ✓ [^5] | ✓ |
-| Pull container images from private projects | | ✓ [^6] | ✓ [^6] | ✓ [^6] |
+| Pull container images from internal projects| | ✓ [^6] | ✓ [^6] | ✓ |
+| Pull container images from private projects | | ✓ [^7] | ✓ [^7] | ✓ [^7] |
| Push container images to current project | | ✓ | ✓ | ✓ |
| Push container images to other projects | | | | |
@@ -251,20 +254,23 @@ for details about the pipelines security model.
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)
+## Auditor users permissions
+
+> Available in [GitLab Premium](https://about.gitlab.com/products/).
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/)
+Auditor users are available in [GitLab Premium](https://about.gitlab.com/products/)
only.
-[^1]: On public and internal projects, all users are able to perform this action.
+[^1]: On public and internal projects, all users are able to perform this action
[^2]: Guest users can only view the confidential issues they created themselves
[^3]: If **Public pipelines** is enabled in **Project Settings > CI/CD**
[^4]: Not allowed for Guest, Reporter, Developer, Master, or Owner
-[^5]: Only if user is not external one.
-[^6]: Only if user is a member of the project.
-[^7]: Only if the build was triggered by the user
+[^5]: Only if the job was triggered by the user
+[^6]: Only if user is not external one
+[^7]: Only if user is a member of the project
+
[ce-18994]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18994
[new-mod]: project/new_ci_build_permissions_model.md
diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md
index e7596f5c577..910bd20f882 100644
--- a/doc/user/profile/account/delete_account.md
+++ b/doc/user/profile/account/delete_account.md
@@ -1,7 +1,7 @@
# 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 **Remove 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 **Delete user**
## Associated Records
diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md
index 590c3f862fb..d3a2a7dcd14 100644
--- a/doc/user/profile/account/two_factor_authentication.md
+++ b/doc/user/profile/account/two_factor_authentication.md
@@ -212,7 +212,7 @@ Sign in and re-enable two-factor authentication as soon as possible.
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 `first.host.xyz` - U2F authentication succeeds.
- 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`.
diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md
index 022d6317555..930e506802a 100644
--- a/doc/user/profile/preferences.md
+++ b/doc/user/profile/preferences.md
@@ -41,7 +41,7 @@ select few, the amount of activity on the default Dashboard page can be
overwhelming. Changing this setting allows you to redefine what your default
dashboard will be.
-You have 6 options here that you can use for your default dashboard view:
+You have 8 options here that you can use for your default dashboard view:
- Your projects (default)
- Starred projects
@@ -49,6 +49,8 @@ You have 6 options here that you can use for your default dashboard view:
- Starred projects' activity
- Your groups
- Your [Todos]
+- Assigned Issues
+- Assigned Merge Requests
### Project home page content
diff --git a/doc/user/project/badges.md b/doc/user/project/badges.md
new file mode 100644
index 00000000000..c4e59444ef7
--- /dev/null
+++ b/doc/user/project/badges.md
@@ -0,0 +1,73 @@
+# Badges
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41174)
+in GitLab 10.7.
+
+Badges are a unified way to present condensed pieces of information about your
+projects. They consist of a small image and additionally a URL that the image
+points to. Examples for badges can be the [pipeline status], [test coverage],
+or ways to contact the project maintainers.
+
+![Badges on Project overview page](img/project_overview_badges.png)
+
+## Project badges
+
+Badges can be added to a project and will then be visible on the project's overview page.
+If you find that you have to add the same badges to several projects, you may want to add them at the [group level](#group-badges).
+
+To add a new badge to a project:
+
+1. Navigate to your project's **Settings > Badges**.
+1. Under "Link", enter the URL that the badges should point to and under
+ "Badge image URL" the URL of the image that should be displayed.
+1. Submit the badge by clicking the **Add badge** button.
+
+After adding a badge to a project, you can see it in the list below the form.
+You can edit it by clicking on the pen icon next to it or to delete it by
+clicking on the trash icon.
+
+Badges associated with a group can only be edited or deleted on the
+[group level](#group-badges).
+
+## Group badges
+
+Badges can be added to a group and will then be visible on every project's
+overview page that's under that group. In this case, they cannot be edited or
+deleted on the project level. If you need to have individual badges for each
+project, consider adding them on the [project level](#project-badges) or use
+[placeholders](#placeholders).
+
+To add a new badge to a group:
+
+1. Navigate to your group's **Settings > Project Badges**.
+1. Under "Link", enter the URL that the badges should point to and under
+ "Badge image URL" the URL of the image that should be displayed.
+1. Submit the badge by clicking the **Add badge** button.
+
+After adding a badge to a group, you can see it in the list below the form.
+You can edit the badge by clicking on the pen icon next to it or to delete it
+by clicking on the trash icon.
+
+Badges directly associated with a project can be configured on the
+[project level](#project-badges).
+
+## Placeholders
+
+The URL a badge points to, as well as the image URL, can contain placeholders
+which will be evaluated when displaying the badge. The following placeholders
+are available:
+
+- `%{project_path}`: Path of a project including the parent groups
+- `%{project_id}`: Database ID associated with a project
+- `%{default_branch}`: Default branch name configured for a project's repository
+- `%{commit_sha}`: ID of the most recent commit to the default branch of a
+ project's repository
+
+## API
+
+You can also configure badges via the GitLab API. As in the settings, there is
+a distinction between endpoints for badges on the
+[project level](../../api/project_badges.md) and [group level](../../api/group_badges.md).
+
+[pipeline status]: pipelines/settings.md#pipeline-status-badge
+[test coverage]: pipelines/settings.md#test-coverage-report-badge
diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md
index e2924c66e70..716787532fc 100644
--- a/doc/user/project/clusters/index.md
+++ b/doc/user/project/clusters/index.md
@@ -1,77 +1,148 @@
# Connecting GitLab with a Kubernetes cluster
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/35954) in 10.1.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/35954) in GitLab 10.1.
-CAUTION: **Warning:**
-The Cluster integration is currently in **Beta**.
+Connect your project to Google Kubernetes Engine (GKE) or an existing Kubernetes
+cluster in a few steps.
-With a cluster associated to your project, you can use Review Apps, deploy your
-applications, run your pipelines, and much more, in an easy way.
+## Overview
-Connect your project to Google Kubernetes Engine (GKE) or your own Kubernetes
-cluster in a few steps.
+With a Kubernetes cluster associated to your project, you can use
+[Review Apps](../../../ci/review_apps/index.md), deploy your applications, run
+your pipelines, and much more, in an easy way.
-NOTE: **Note:**
-The Cluster integration will eventually supersede the
-[Kubernetes integration](../integrations/kubernetes.md). For the moment,
-you can create only one cluster.
+There are two options when adding a new cluster to your project; either associate
+your account with Google Kubernetes Engine (GKE) so that you can [create new
+clusters](#adding-and-creating-a-new-gke-cluster-via-gitlab) from within GitLab,
+or provide the credentials to an [existing Kubernetes cluster](#adding-an-existing-kubernetes-cluster).
-## Prerequisites
+## Adding and creating a new GKE cluster via GitLab
-In order to be able to manage your GKE cluster through GitLab, the following
-prerequisites must be met:
+NOTE: **Note:**
+You need Master [permissions] and above to access the Kubernetes page.
+
+Before proceeding, make sure the following requirements are met:
- The [Google authentication integration](../../../integration/google.md) must
be enabled in GitLab at the instance level. If that's not the case, ask your
- administrator to enable it.
+ GitLab administrator to enable it.
- Your associated Google account must have the right privileges to manage
- clusters on GKE. That would mean that a
- [billing account](https://cloud.google.com/billing/docs/how-to/manage-billing-account)
- must be set up.
-- You must have Master [permissions] in order to be able to access the **Cluster**
- page.
+ clusters on GKE. That would mean that a [billing
+ account](https://cloud.google.com/billing/docs/how-to/manage-billing-account)
+ must be set up and that you have to have permissions to access it.
+- You must have Master [permissions] in order to be able to access the
+ **Kubernetes** page.
+- You must have [Cloud Billing API](https://cloud.google.com/billing/) enabled
+- You must have [Resource Manager
+ API](https://cloud.google.com/resource-manager/)
+
+If all of the above requirements are met, you can proceed to create and add a
+new Kubernetes cluster that will be hosted on GKE to your project:
-If all of the above requirements are met, you can proceed to add a new GKE
-cluster.
+1. Navigate to your project's **CI/CD > Kubernetes** page.
+1. Click on **Add Kubernetes cluster**.
+1. Click on **Create with GKE**.
+1. Connect your Google account if you haven't done already by clicking the
+ **Sign in with Google** button.
+1. Fill in the requested values:
+ - **Cluster name** (required) - The name you wish to give the cluster.
+ - **GCP project ID** (required) - The ID of the project you created in your GCP
+ console that will host the Kubernetes cluster. This must **not** be confused
+ with the project name. Learn more about [Google Cloud Platform projects](https://cloud.google.com/resource-manager/docs/creating-managing-projects).
+ - **Zone** - The [zone](https://cloud.google.com/compute/docs/regions-zones/)
+ under which the cluster will be created.
+ - **Number of nodes** - The number of nodes you wish the cluster to have.
+ - **Machine type** - The [machine type](https://cloud.google.com/compute/docs/machine-types)
+ of the Virtual Machine instance that the cluster will be based on.
+ - **Environment scope** - The [associated environment](#setting-the-environment-scope) to this cluster.
+1. Finally, click the **Create Kubernetes cluster** button.
+
+After a few moments, your cluster should be created. If something goes wrong,
+you will be notified.
+
+You can now proceed to install some pre-defined applications and then
+enable the Cluster integration.
-## Adding a cluster
+## Adding an existing Kubernetes cluster
NOTE: **Note:**
-You need Master [permissions] and above to add a cluster.
-
-There are two options when adding a new cluster; either use Google Kubernetes
-Engine (GKE) or provide the credentials to your own Kubernetes cluster.
-
-To add a new cluster:
-
-1. Navigate to your project's **CI/CD > Cluster** page
-1. If you want to let GitLab create a cluster on GKE for you, go through the
- following steps, otherwise skip to the next one.
- 1. Click on **Create with GKE**
- 1. Connect your Google account if you haven't done already by clicking the
- **Sign in with Google** button
- 1. Fill in the requested values:
- - **Cluster name** (required) - The name you wish to give the cluster.
- - **GCP project ID** (required) - The ID of the project you created in your GCP
- console that will host the Kubernetes cluster. This must **not** be confused
- with the project name. Learn more about [Google Cloud Platform projects](https://cloud.google.com/resource-manager/docs/creating-managing-projects).
- - **Zone** - The [zone](https://cloud.google.com/compute/docs/regions-zones/)
- under which the cluster will be created.
- - **Number of nodes** - The number of nodes you wish the cluster to have.
- - **Machine type** - The [machine type](https://cloud.google.com/compute/docs/machine-types)
- of the Virtual Machine instance that the cluster will be based on.
- - **Project namespace** - The unique namespace for this project. By default you
- don't have to fill it in; by leaving it blank, GitLab will create one for you.
-1. If you want to use your own existing Kubernetes cluster, click on
- **Add an existing cluster** and fill in the details as described in the
- [Kubernetes integration](../integrations/kubernetes.md) documentation.
-1. Finally, click the **Create cluster** button
+You need Master [permissions] and above to access the Kubernetes page.
+
+To add an existing Kubernetes cluster to your project:
+
+1. Navigate to your project's **CI/CD > Kubernetes** page.
+1. Click on **Add Kubernetes cluster**.
+1. Click on **Add an existing Kubernetes cluster** and fill in the details:
+ - **Kubernetes cluster name** (required) - The name you wish to give the cluster.
+ - **Environment scope** (required)- The
+ [associated environment](#setting-the-environment-scope) to this cluster.
+ - **API URL** (required) -
+ It's 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,
+ e.g., `https://kubernetes.example.com` rather than `https://kubernetes.example.com/api/v1`.
+ - **CA certificate** (optional) -
+ If the API is using a self-signed TLS certificate, you'll also need to include
+ the `ca.crt` contents here.
+ - **Token** -
+ GitLab authenticates against Kubernetes using service tokens, which are
+ scoped to a particular `namespace`. If you don't have a service token yet,
+ you can follow the
+ [Kubernetes documentation](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/)
+ to create one. You can also view or create service tokens in the
+ [Kubernetes dashboard](https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/#config)
+ (under **Config > Secrets**).
+ - **Project namespace** (optional) - The following apply:
+ - By default you don't have to fill it in; by leaving it blank, GitLab will
+ create one for you.
+ - Each project should have a unique namespace.
+ - The project namespace is not necessarily the namespace of the secret, if
+ you're using a secret with broader permissions, like the secret from `default`.
+ - You should **not** use `default` as the project namespace.
+ - If you or someone created a secret specifically for the project, usually
+ with limited permissions, the secret's namespace and project namespace may
+ be the same.
+1. Finally, click the **Create Kubernetes cluster** button.
After a few moments, your cluster should be created. If something goes wrong,
you will be notified.
You can now proceed to install some pre-defined applications and then
-enable the Cluster integration.
+enable the Kubernetes cluster integration.
+
+## Security implications
+
+CAUTION: **Important:**
+The whole cluster security is based on a model where [developers](../../permissions.md)
+are trusted, so **only trusted users should be allowed to control your clusters**.
+
+The default cluster configuration grants access to a wide set of
+functionalities needed to successfully build and deploy a containerized
+application. Bare in mind that the same credentials are used for all the
+applications running on the cluster.
+
+When GitLab creates the cluster, it enables and uses the legacy
+[Attribute-based access control (ABAC)](https://kubernetes.io/docs/admin/authorization/abac/).
+The newer [RBAC](https://kubernetes.io/docs/admin/authorization/rbac/)
+authorization will be supported in a
+[future release](https://gitlab.com/gitlab-org/gitlab-ce/issues/29398).
+
+### Security of GitLab Runners
+
+GitLab Runners have the [privileged mode](https://docs.gitlab.com/runner/executors/docker.html#the-privileged-mode)
+enabled by default, which allows them to execute special commands and running
+Docker in Docker. This functionality is needed to run some of the [Auto DevOps]
+jobs. This implies the containers are running in privileged mode and you should,
+therefore, be aware of some important details.
+
+The privileged flag gives all capabilities to the running container, which in
+turn can do almost everything that the host can do. Be aware of the
+inherent security risk associated with performing `docker run` operations on
+arbitrary images as they effectively have root access.
+
+If you don't want to use GitLab Runner in privileged mode, first make sure that
+you don't have it installed via the applications, and then use the
+[Runner's Helm chart](../../../install/kubernetes/gitlab_runner_chart.md) to
+install it manually.
## Installing applications
@@ -82,32 +153,244 @@ added directly to your configured cluster. Those applications are needed for
| Application | GitLab version | Description |
| ----------- | :------------: | ----------- |
| [Helm Tiller](https://docs.helm.sh/) | 10.2+ | Helm is a package manager for Kubernetes and is required to install all the other applications. It will be automatically installed as a dependency when you try to install a different app. It is installed in its own pod inside the cluster which can run the `helm` CLI in a safe environment. |
-| [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps](../../../topics/autodevops/index.md) or deploy your own web apps. |
+| [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps] or deploy your own web apps. |
+| [Prometheus](https://prometheus.io/docs/introduction/overview/) | 10.4+ | Prometheus is an open-source monitoring and alerting system useful to supervise your deployed applications |
+| [GitLab Runner](https://docs.gitlab.com/runner/) | 10.6+ | GitLab Runner is the open source project that is used to run your jobs and send the results back to GitLab. It is used in conjunction with [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/), the open-source continuous integration service included with GitLab that coordinates the jobs. When installing the GitLab Runner via the applications, it will run in **privileged mode** by default. Make sure you read the [security implications](#security-implications) before doing so. |
+
+## Getting the external IP address
+
+NOTE: **Note:**
+You need a load balancer installed in your cluster in order to obtain the
+external IP address with the following procedure. It can be deployed using the
+[**Ingress** application](#installing-applications).
+
+In order to publish your web application, you first need to find the external IP
+address associated to your load balancer.
+
+### Let GitLab fetch the IP address
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17052) in GitLab 10.6.
+
+If you installed the Ingress [via the **Applications**](#installing-applications),
+you should see the Ingress IP address on this same page within a few minutes.
+If you don't see this, GitLab might not be able to determine the IP address of
+your ingress application in which case you should manually determine it.
+
+### Manually determining the IP address
+
+If the cluster is on GKE, click on the **Google Kubernetes Engine** link in the
+**Advanced settings**, or go directly to the
+[Google Kubernetes Engine dashboard](https://console.cloud.google.com/kubernetes/)
+and select the proper project and cluster. Then click on **Connect** and execute
+the `gcloud` command in a local terminal or using the **Cloud Shell**.
+
+If the cluster is not on GKE, follow the specific instructions for your
+Kubernetes provider to configure `kubectl` with the right credentials.
+
+If you installed the Ingress [via the **Applications**](#installing-applications),
+run the following command:
+
+```bash
+kubectl get svc --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip} '
+```
+
+Otherwise, you can list the IP addresses of all load balancers:
+
+```bash
+kubectl get svc --all-namespaces -o jsonpath='{range.items[?(@.status.loadBalancer.ingress)]}{.status.loadBalancer.ingress[*].ip} '
+```
+
+The output is the external IP address of your cluster. This information can then
+be used to set up DNS entries and forwarding rules that allow external access to
+your deployed applications.
+
+### Using a static IP
+
+By default, an ephemeral external IP address is associated to the cluster's load
+balancer. If you associate the ephemeral IP with your DNS and the IP changes,
+your apps will not be able to be reached, and you'd have to change the DNS
+record again. In order to avoid that, you should change it into a static
+reserved IP.
+
+[Read how to promote an ephemeral external IP address in GKE.](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#promote_ephemeral_ip)
+
+### Pointing your DNS at the cluster IP
+
+Once you've set up the static IP, you should associate it to a [wildcard DNS
+record](https://en.wikipedia.org/wiki/Wildcard_DNS_record), in order to be able
+to reach your apps. This heavily depends on your domain provider, but in case
+you aren't sure, just create an A record with a wildcard host like
+`*.example.com.`.
-## Enabling or disabling the Cluster integration
+## Setting the environment scope
+
+NOTE: **Note:**
+This is only available for [GitLab Premium][ee] where you can add more than
+one Kubernetes cluster.
+
+When adding more than one Kubernetes clusters to your project, you need to
+differentiate them with an environment scope. The environment scope associates
+clusters and [environments](../../../ci/environments.md) in an 1:1 relationship
+similar to how the
+[environment-specific variables](../../../ci/variables/README.md#limiting-environment-scopes-of-secret-variables)
+work.
+
+The default environment scope is `*`, which means all jobs, regardless of their
+environment, will use that cluster. Each scope can only be used by a single
+cluster in a project, and a validation error will occur if otherwise.
+
+---
+
+For example, let's say the following Kubernetes clusters exist in a project:
+
+| Cluster | Environment scope |
+| ---------- | ------------------- |
+| Development| `*` |
+| Staging | `staging/*` |
+| Production | `production/*` |
+
+And the following environments are set in [`.gitlab-ci.yml`](../../../ci/yaml/README.md):
+
+```yaml
+stages:
+- test
+- deploy
+
+test:
+ stage: test
+ script: sh test
+
+deploy to staging:
+ stage: deploy
+ script: make deploy
+ environment:
+ name: staging/$CI_COMMIT_REF_NAME
+ url: https://staging.example.com/
+
+deploy to production:
+ stage: deploy
+ script: make deploy
+ environment:
+ name: production/$CI_COMMIT_REF_NAME
+ url: https://example.com/
+```
+
+The result will then be:
+
+- The development cluster will be used for the "test" job.
+- The staging cluster will be used for the "deploy to staging" job.
+- The production cluster will be used for the "deploy to production" job.
+
+## Multiple Kubernetes clusters
+
+> Introduced in [GitLab Premium][ee] 10.3.
+
+With GitLab Premium, you can associate more than one Kubernetes clusters to your
+project. That way you can have different clusters for different environments,
+like dev, staging, production, etc.
+
+Simply add another cluster, like you did the first time, and make sure to
+[set an environment scope](#setting-the-environment-scope) that will
+differentiate the new cluster with the rest.
+
+## Deployment variables
+
+The Kubernetes cluster integration exposes the following
+[deployment variables](../../../ci/variables/README.md#deployment-variables) in the
+GitLab CI/CD build environment.
+
+| Variable | Description |
+| -------- | ----------- |
+| `KUBE_URL` | Equal to the API URL. |
+| `KUBE_TOKEN` | The Kubernetes token. |
+| `KUBE_NAMESPACE` | The Kubernetes namespace is auto-generated if not specified. The default value is `<project_name>-<project_id>`. You can overwrite it to use different one if needed, otherwise the `KUBE_NAMESPACE` variable will receive the default value. |
+| `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. |
+
+## Enabling or disabling the Kubernetes cluster integration
After you have successfully added your cluster information, you can enable the
-Cluster integration:
+Kubernetes cluster integration:
1. Click the "Enabled/Disabled" switch
1. Hit **Save** for the changes to take effect
You can now start using your Kubernetes cluster for your deployments.
-To disable the Cluster integration, follow the same procedure.
+To disable the Kubernetes cluster integration, follow the same procedure.
-## Removing the Cluster integration
+## Removing the Kubernetes cluster integration
NOTE: **Note:**
-You need Master [permissions] and above to remove a cluster integration.
+You need Master [permissions] and above to remove a Kubernetes cluster integration.
NOTE: **Note:**
When you remove a cluster, you only remove its relation to GitLab, not the
cluster itself. To remove the cluster, you can do so by visiting the GKE
dashboard or using `kubectl`.
-To remove the Cluster integration from your project, simply click on the
+To remove the Kubernetes cluster integration from your project, simply click on the
**Remove integration** button. You will then be able to follow the procedure
-and [add a cluster](#adding-a-cluster) again.
+and add a Kubernetes cluster again.
+
+## What you can get with the Kubernetes integration
+
+Here's what you can do with GitLab if you enable the Kubernetes integration.
+
+### Deploy Boards
+
+> Available in [GitLab Premium][ee].
+
+GitLab's Deploy Boards offer a consolidated view of the current health and
+status of each CI [environment](../../../ci/environments.md) running on Kubernetes,
+displaying the status of the pods in the deployment. Developers and other
+teammates can view the progress and status of a rollout, pod by pod, in the
+workflow they already use without any need to access Kubernetes.
+
+[> Read more about Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html)
+
+### Canary Deployments
+
+> Available in [GitLab Premium][ee].
+
+Leverage [Kubernetes' Canary deployments](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/#canary-deployments)
+and visualize your canary deployments right inside the Deploy Board, without
+the need to leave GitLab.
+
+[> Read more about Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html)
+
+### Kubernetes monitoring
+
+Automatically detect and monitor Kubernetes metrics. Automatic monitoring of
+[NGINX ingress](../integrations/prometheus_library/nginx.md) is also supported.
+
+[> Read more about Kubernetes monitoring](../integrations/prometheus_library/kubernetes.md)
+
+### Auto DevOps
+
+Auto DevOps automatically detects, builds, tests, deploys, and monitors your
+applications.
+
+To make full use of Auto DevOps(Auto Deploy, Auto Review Apps, and Auto Monitoring)
+you will need the Kubernetes project integration enabled.
+
+[> Read more about Auto DevOps](../../../topics/autodevops/index.md)
+
+### Web terminals
+
+NOTE: **Note:**
+Introduced in GitLab 8.15. You must be the project owner or have `master` permissions
+to use terminals. Support is limited to the first container in the
+first pod of your environment.
+
+When enabled, the Kubernetes service adds [web terminal](../../../ci/environments.md#web-terminals)
+support to your [environments](../../../ci/environments.md). This is based on the `exec` functionality found in
+Docker and Kubernetes, so you get a new shell session within your existing
+containers. To use this integration, you should deploy to Kubernetes using
+the deployment variables above, ensuring any pods you create are labelled with
+`app=$CI_ENVIRONMENT_SLUG`. GitLab will do the rest!
[permissions]: ../../permissions.md
+[ee]: https://about.gitlab.com/products/
+[Auto DevOps]: ../../../topics/autodevops/index.md
diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md
index 394aa9209e4..9c5e3509046 100644
--- a/doc/user/project/container_registry.md
+++ b/doc/user/project/container_registry.md
@@ -115,15 +115,16 @@ and [Using the GitLab Container Registry documentation](../../ci/docker/using_do
## Using with private projects
-> [Introduced][ce-11845] in GitLab 9.3.
+> Personal Access tokens were [introduced][ce-11845] in GitLab 9.3.
+> Project Deploy Tokens were [introduced][ce-17894] in GitLab 10.7
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`.
+The preferred way to do this, is either by using a [personal access tokens][pat] or a [project deploy token][pdt].
+The minimal scope needed for both of them is `read_registry`.
Example of using a personal access token:
```
-docker login registry.example.com -u <your_username> -p <your_personal_access_token>
+docker login registry.example.com -u <your_username> -p <your_access_token>
```
## Troubleshooting the GitLab Container Registry
@@ -270,5 +271,7 @@ 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
+[ce-17894]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17894
[docker-docs]: https://docs.docker.com/engine/userguide/intro/
[pat]: ../profile/personal_access_tokens.md
+[pdt]: ../project/deploy_tokens/index.md
diff --git a/doc/user/project/deploy_tokens/img/deploy_tokens.png b/doc/user/project/deploy_tokens/img/deploy_tokens.png
new file mode 100644
index 00000000000..7e2d67a3120
--- /dev/null
+++ b/doc/user/project/deploy_tokens/img/deploy_tokens.png
Binary files differ
diff --git a/doc/user/project/deploy_tokens/index.md b/doc/user/project/deploy_tokens/index.md
new file mode 100644
index 00000000000..86fc58020e8
--- /dev/null
+++ b/doc/user/project/deploy_tokens/index.md
@@ -0,0 +1,76 @@
+# Deploy Tokens
+
+> [Introduced][ce-17894] in GitLab 10.7.
+
+Deploy tokens allow to download (through `git clone`), or read the container registry images of a project without the need of having a user and a password.
+
+Please note, that the expiration of deploy tokens happens on the date you define,
+at midnight UTC and that they can be only managed by [masters](https://docs.gitlab.com/ee/user/permissions.html).
+
+## Creating a Deploy Token
+
+You can create as many deploy tokens as you like from the settings of your project:
+
+1. Log in to your GitLab account.
+1. Go to the project you want to create Deploy Tokens for.
+1. Go to **Settings** > **Repository**
+1. Click on "Expand" on **Deploy Tokens** section
+1. Choose a name and optionally an expiry date for the token.
+1. Choose the [desired scopes](#limiting-scopes-of-a-deploy-token).
+1. Click on **Create deploy token**.
+1. Save the deploy token somewhere safe. Once you leave or refresh
+ the page, **you won't be able to access it again**.
+
+![Personal access tokens page](img/deploy_tokens.png)
+
+## Revoking a personal access token
+
+At any time, you can revoke any deploy token by just clicking the
+respective **Revoke** button under the 'Active deploy tokens' area.
+
+## Limiting scopes of a deploy token
+
+Deploy tokens can be created with two different scopes that allow various
+actions that a given token can perform. The available scopes are depicted in
+the following table.
+
+| Scope | Description |
+| ----- | ----------- |
+| `read_repository` | Allows read-access to the repository through `git clone` |
+| `read_registry` | Allows read-access to [container registry] images if a project is private and authorization is required. |
+
+## Usage
+
+### Git clone a repository
+
+To download a repository using a Deploy Token, you just need to:
+
+1. Create a Deploy Token with `read_repository` as a scope.
+2. Take note of your `username` and `token`
+3. `git clone` the project using the Deploy Token:
+
+
+```bash
+git clone http://<username>:<deploy_token>@gitlab.example.com/tanuki/awesome_project.git
+```
+
+Just replace `<username>` and `<deploy_token>` with the proper values
+
+### Read container registry images
+
+To read the container registry images, you'll need to:
+
+1. Create a Deploy Token with `read_registry` as a scope.
+2. Take note of your `username` and `token`
+3. Log in to GitLab’s Container Registry using the deploy token:
+
+```
+docker login registry.example.com -u <username> -p <deploy_token>
+```
+
+Just replace `<username>` and `<deploy_token>` with the proper values. Then you can simply
+pull images from your Container Registry.
+
+[ce-17894]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17894
+[ce-11845]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11845
+[container registry]: ../container_registry.md
diff --git a/doc/user/project/img/label_priority_sort_order.png b/doc/user/project/img/label_priority_sort_order.png
deleted file mode 100644
index 21c7a76a322..00000000000
--- a/doc/user/project/img/label_priority_sort_order.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_assign_label_sidebar.png b/doc/user/project/img/labels_assign_label_sidebar.png
deleted file mode 100644
index d74796fdb4d..00000000000
--- a/doc/user/project/img/labels_assign_label_sidebar.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_assign_label_sidebar_saved.png b/doc/user/project/img/labels_assign_label_sidebar_saved.png
deleted file mode 100644
index dabffe956dc..00000000000
--- a/doc/user/project/img/labels_assign_label_sidebar_saved.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_default.png b/doc/user/project/img/labels_default.png
index 7934e3bfb5e..7a7fab611a4 100644
--- a/doc/user/project/img/labels_default.png
+++ b/doc/user/project/img/labels_default.png
Binary files differ
diff --git a/doc/user/project/img/labels_description_tooltip.png b/doc/user/project/img/labels_description_tooltip.png
deleted file mode 100644
index eea4f8cf0f4..00000000000
--- a/doc/user/project/img/labels_description_tooltip.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_filter.png b/doc/user/project/img/labels_filter.png
deleted file mode 100644
index 6a1ebfc2ecb..00000000000
--- a/doc/user/project/img/labels_filter.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_generate.png b/doc/user/project/img/labels_generate.png
deleted file mode 100644
index 987f4b5be71..00000000000
--- a/doc/user/project/img/labels_generate.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_generate_default.png b/doc/user/project/img/labels_generate_default.png
new file mode 100644
index 00000000000..fca2a06e04f
--- /dev/null
+++ b/doc/user/project/img/labels_generate_default.png
Binary files differ
diff --git a/doc/user/project/img/labels_group_issues.png b/doc/user/project/img/labels_group_issues.png
new file mode 100644
index 00000000000..29dcf7ff45e
--- /dev/null
+++ b/doc/user/project/img/labels_group_issues.png
Binary files differ
diff --git a/doc/user/project/img/labels_list.png b/doc/user/project/img/labels_list.png
new file mode 100644
index 00000000000..12c47ea9766
--- /dev/null
+++ b/doc/user/project/img/labels_list.png
Binary files differ
diff --git a/doc/user/project/img/labels_new_label.png b/doc/user/project/img/labels_new_label.png
deleted file mode 100644
index e26425d0188..00000000000
--- a/doc/user/project/img/labels_new_label.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_new_label_on_the_fly.png b/doc/user/project/img/labels_new_label_on_the_fly.png
deleted file mode 100644
index 2ac9805b1ab..00000000000
--- a/doc/user/project/img/labels_new_label_on_the_fly.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_new_label_on_the_fly_create.png b/doc/user/project/img/labels_new_label_on_the_fly_create.png
deleted file mode 100644
index 02ccf68553b..00000000000
--- a/doc/user/project/img/labels_new_label_on_the_fly_create.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_prioritize.png b/doc/user/project/img/labels_prioritize.png
deleted file mode 100644
index d602a3c90ec..00000000000
--- a/doc/user/project/img/labels_prioritize.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_prioritized.png b/doc/user/project/img/labels_prioritized.png
new file mode 100644
index 00000000000..57dcfe89b3d
--- /dev/null
+++ b/doc/user/project/img/labels_prioritized.png
Binary files differ
diff --git a/doc/user/project/img/labels_promotion.png b/doc/user/project/img/labels_promotion.png
new file mode 100644
index 00000000000..8a5efd210a2
--- /dev/null
+++ b/doc/user/project/img/labels_promotion.png
Binary files differ
diff --git a/doc/user/project/img/labels_sidebar.png b/doc/user/project/img/labels_sidebar.png
new file mode 100644
index 00000000000..7349c6d4f0c
--- /dev/null
+++ b/doc/user/project/img/labels_sidebar.png
Binary files differ
diff --git a/doc/user/project/img/labels_sidebar_assign.png b/doc/user/project/img/labels_sidebar_assign.png
new file mode 100644
index 00000000000..61e8d04fc85
--- /dev/null
+++ b/doc/user/project/img/labels_sidebar_assign.png
Binary files differ
diff --git a/doc/user/project/img/labels_sidebar_inline.png b/doc/user/project/img/labels_sidebar_inline.png
new file mode 100644
index 00000000000..31fa397761d
--- /dev/null
+++ b/doc/user/project/img/labels_sidebar_inline.png
Binary files differ
diff --git a/doc/user/project/img/labels_sort_label_priority.png b/doc/user/project/img/labels_sort_label_priority.png
new file mode 100644
index 00000000000..c8b97639121
--- /dev/null
+++ b/doc/user/project/img/labels_sort_label_priority.png
Binary files differ
diff --git a/doc/user/project/img/labels_sort_priority.png b/doc/user/project/img/labels_sort_priority.png
new file mode 100644
index 00000000000..a95198e7f72
--- /dev/null
+++ b/doc/user/project/img/labels_sort_priority.png
Binary files differ
diff --git a/doc/user/project/img/labels_subscribe.png b/doc/user/project/img/labels_subscribe.png
deleted file mode 100644
index 56f24ae7bc8..00000000000
--- a/doc/user/project/img/labels_subscribe.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/img/labels_subscriptions.png b/doc/user/project/img/labels_subscriptions.png
new file mode 100644
index 00000000000..8bcb3b57f6c
--- /dev/null
+++ b/doc/user/project/img/labels_subscriptions.png
Binary files differ
diff --git a/doc/user/project/img/new_label_from_sidebar.gif b/doc/user/project/img/new_label_from_sidebar.gif
new file mode 100644
index 00000000000..572b29a86e1
--- /dev/null
+++ b/doc/user/project/img/new_label_from_sidebar.gif
Binary files differ
diff --git a/doc/user/project/img/project_overview_badges.png b/doc/user/project/img/project_overview_badges.png
new file mode 100644
index 00000000000..3067a7dfa13
--- /dev/null
+++ b/doc/user/project/img/project_overview_badges.png
Binary files differ
diff --git a/doc/user/project/import/github.md b/doc/user/project/import/github.md
index 72def9d1d1d..8c639bd5343 100644
--- a/doc/user/project/import/github.md
+++ b/doc/user/project/import/github.md
@@ -46,7 +46,7 @@ namespace that started the import process.
The importer will also import branches on forks of projects related to open pull
requests. These branches will be imported with a naming scheme similar to
-GH-SHA-Username/Pull-Request-number/fork-name/branch. This may lead to a discrepency
+GH-SHA-Username/Pull-Request-number/fork-name/branch. This may lead to a discrepancy
in branches compared to the GitHub Repository.
For a more technical description and an overview of the architecture you can
diff --git a/doc/user/project/import/img/import_projects_from_repo_url.png b/doc/user/project/import/img/import_projects_from_repo_url.png
new file mode 100644
index 00000000000..ec867da1087
--- /dev/null
+++ b/doc/user/project/import/img/import_projects_from_repo_url.png
Binary files differ
diff --git a/doc/user/project/import/index.md b/doc/user/project/import/index.md
index e2b285678c3..72cc58546b7 100644
--- a/doc/user/project/import/index.md
+++ b/doc/user/project/import/index.md
@@ -10,6 +10,7 @@
1. [From Perforce](perforce.md)
1. [From SVN](svn.md)
1. [From TFS](tfs.md)
+1. [From repo by URL](repo_by_url.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
diff --git a/doc/user/project/import/perforce.md b/doc/user/project/import/perforce.md
index aa7508e1e8e..a1ea716b606 100644
--- a/doc/user/project/import/perforce.md
+++ b/doc/user/project/import/perforce.md
@@ -48,3 +48,9 @@ Here's a few links to get you started:
- [git-p4 manual page](https://www.kernel.org/pub/software/scm/git/docs/git-p4.html)
- [git-p4 example usage](https://git.wiki.kernel.org/index.php/Git-p4_Usage)
- [Git book migration guide](https://git-scm.com/book/en/v2/Git-and-Other-Systems-Migrating-to-Git#_perforce_import)
+
+Note that `git p4` and `git filter-branch` are not very good at
+creating small and efficient Git pack files. So it might be a good
+idea to spend time and CPU to properly repack your repository before
+sending it for the first time to your GitLab server. See
+[this StackOverflow question](https://stackoverflow.com/questions/28720151/git-gc-aggressive-vs-git-repack/).
diff --git a/doc/user/project/import/repo_by_url.md b/doc/user/project/import/repo_by_url.md
new file mode 100644
index 00000000000..f43e384de88
--- /dev/null
+++ b/doc/user/project/import/repo_by_url.md
@@ -0,0 +1,12 @@
+# Import project from repo by URL
+
+You can import your existing repositories by providing the Git URL:
+
+1. From your GitLab dashboard click **New project**
+1. Switch to the **Import project** tab
+1. Click on the **Repo by URL** button
+1. Fill in the "Git repository URL" and the remaining project fields
+1. Click **Create project** to being the import process
+1. Once complete, you will be redirected to your newly created project
+
+![Import project by repo URL](img/import_projects_from_repo_url.png)
diff --git a/doc/user/project/index.md b/doc/user/project/index.md
index 4c772c62f8d..5ce4ebfa811 100644
--- a/doc/user/project/index.md
+++ b/doc/user/project/index.md
@@ -17,7 +17,7 @@ When you create a project in GitLab, you'll have access to a large number of
- [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
+ - [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board.html#multiple-issue-boards) (**Starter/Premium**): 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
- [Branches](repository/branches/index.md): use Git branching strategies to
@@ -27,9 +27,10 @@ integrated platform
- [Protected tags](protected_tags.md): Control over who has
permission to create tags, and prevent accidental update or deletion
- [Signing commits](gpg_signed_commits/index.md): use GPG to sign your commits
+ - [Deploy tokens](deploy_tokens/index.md): Manage project-based deploy tokens that allow permanent access to the repository and Container Registry.
- [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
+ - [Merge Request Approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) (**Starter/Premium**): 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
@@ -73,6 +74,7 @@ website with GitLab Pages
- [Cycle Analytics](cycle_analytics.md): Review your development lifecycle
- [Syntax highlighting](highlighting.md): An alternative to customize
your code blocks, overriding GitLab's default choice of language
+- [Badges](badges.md): Badges for the project overview
### Project's integrations
@@ -128,12 +130,9 @@ and Git push/pull redirects.
Depending on the situation, different things apply.
-When [transferring a project](settings/index.md#transferring-an-existing-project-into-another-namespace),
-or [renaming a user](../profile/index.md#changing-your-username) or
-[changing a group path](../group/index.md#changing-a-group-s-path):
+When [renaming a user](../profile/index.md#changing-your-username),
+[changing a group path](../group/index.md#changing-a-group-s-path) or [renaming a repository](settings/index.md#renaming-a-repository):
-- **The redirect to the new URL is permanent**, which means that the original
- namespace can't be claimed again by any group or user.
- Existing web URLs for the namespace and anything under it (e.g., projects) will
redirect to the new URLs.
- Starting with GitLab 10.3, existing Git remote URLs for projects under the
@@ -142,9 +141,5 @@ or [renaming a user](../profile/index.md#changing-your-username) or
your remote will be displayed instead of rejecting your action.
This means that any automation scripts, or Git clients will continue to
work after a rename, making any transition a lot smoother.
- To avoid pulling from or pushing to an entirely incorrect repository, the old
- path will be reserved.
-
-When [renaming-a-repository](settings/index.md#renaming-a-repository), the same
-things apply, except for the Git push/pull actions which will be rejected with a
-warning message to change to the new remote URL.
+- The redirects will be available as long as the original path is not claimed by
+ another group, user or project.
diff --git a/doc/user/project/integrations/bugzilla.md b/doc/user/project/integrations/bugzilla.md
index ba2adc1afda..671804035cc 100644
--- a/doc/user/project/integrations/bugzilla.md
+++ b/doc/user/project/integrations/bugzilla.md
@@ -11,11 +11,7 @@ in the table below.
| `issues_url` | The URL to the issue in Bugzilla project that is linked to this GitLab project. Note that the `issues_url` requires `:id` in the URL. This ID is used by GitLab as a placeholder to replace the issue number. |
| `new_issue_url` | This is the URL to create a new issue in Bugzilla for the project linked to this GitLab project. Note that the `new_issue_url` requires PRODUCT_NAME to be updated with the product/project name in Bugzilla. |
-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
+Once you have configured and enabled Bugzilla you'll see the Bugzilla link on the GitLab project pages that takes you to the appropriate Bugzilla project.
## Referencing issues in Bugzilla
diff --git a/doc/user/project/integrations/custom_issue_tracker.md b/doc/user/project/integrations/custom_issue_tracker.md
index 757522c2ae3..731291ebe84 100644
--- a/doc/user/project/integrations/custom_issue_tracker.md
+++ b/doc/user/project/integrations/custom_issue_tracker.md
@@ -13,6 +13,8 @@ in the table below.
| `issues_url` | The URL to the issue in the issue tracker project that is linked to this GitLab project. Note that the `issues_url` requires `:id` in the URL. This ID is used by GitLab as a placeholder to replace the issue number. For example, `https://customissuetracker.com/project-name/:id`. |
| `new_issue_url` | Currently unused. Will be changed in a future release. |
+Once you have configured and enabled Custom Issue Tracker Service you'll see a link on the GitLab project pages that takes you to that custom issue tracker.
+
## Referencing issues
diff --git a/doc/user/project/integrations/emails_on_push.md b/doc/user/project/integrations/emails_on_push.md
index 18109fc049c..c01da883562 100644
--- a/doc/user/project/integrations/emails_on_push.md
+++ b/doc/user/project/integrations/emails_on_push.md
@@ -10,7 +10,7 @@ In the _Recipients_ area, provide a list of emails separated by commas.
You can configure any of the following settings depending on your preference.
-+ **Push events** - Email will be triggered when a push event is recieved
++ **Push events** - Email will be triggered when a push event is received
+ **Tag push events** - Email will be triggered when a tag is created and pushed
+ **Send from committer** - Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. `user@gitlab.com`).
+ **Disable code diffs** - Don't include possibly sensitive code diffs in notification body.
diff --git a/doc/user/project/integrations/img/jira_workflow_screenshot.png b/doc/user/project/integrations/img/jira_workflow_screenshot.png
deleted file mode 100644
index e62fb202613..00000000000
--- a/doc/user/project/integrations/img/jira_workflow_screenshot.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/integrations/img/prometheus_dashboard.png b/doc/user/project/integrations/img/prometheus_dashboard.png
new file mode 100644
index 00000000000..bd19f1b44cc
--- /dev/null
+++ b/doc/user/project/integrations/img/prometheus_dashboard.png
Binary files differ
diff --git a/doc/user/project/integrations/img/prometheus_deploy.png b/doc/user/project/integrations/img/prometheus_deploy.png
new file mode 100644
index 00000000000..d39081bcc7b
--- /dev/null
+++ b/doc/user/project/integrations/img/prometheus_deploy.png
Binary files differ
diff --git a/doc/user/project/integrations/img/prometheus_gcp_firewall_rule.png b/doc/user/project/integrations/img/prometheus_gcp_firewall_rule.png
deleted file mode 100644
index e30cba211e6..00000000000
--- a/doc/user/project/integrations/img/prometheus_gcp_firewall_rule.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/integrations/img/prometheus_gcp_node_name.png b/doc/user/project/integrations/img/prometheus_gcp_node_name.png
deleted file mode 100644
index ea289431454..00000000000
--- a/doc/user/project/integrations/img/prometheus_gcp_node_name.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/integrations/irker.md b/doc/user/project/integrations/irker.md
index c63ea1316fe..ecdd83ce8f0 100644
--- a/doc/user/project/integrations/irker.md
+++ b/doc/user/project/integrations/irker.md
@@ -47,4 +47,8 @@ Irker accepts channel names of the form `chan` and `#chan`, both for the
case, `Aorimn` is treated as a nick and no more as a channel name.
Irker can also join password-protected channels. Users need to append
-`?key=thesecretpassword` to the chan name.
+`?key=thesecretpassword` to the chan name. When using this feature remember to
+**not** put the `#` sign in front of the channel name; failing to do so will
+result on irker joining a channel literally named `#chan?key=password` henceforth
+leaking the channel key through the `/whois` IRC command (depending on IRC server
+configuration). This is due to a long standing irker bug.
diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md
index 7dc234a9759..5933bcedc8b 100644
--- a/doc/user/project/integrations/jira.md
+++ b/doc/user/project/integrations/jira.md
@@ -113,10 +113,23 @@ in the table below.
| `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). |
-| `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.** |
+| `Transition ID` | This is the ID of a transition that moves issues to the desired state. **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** |
+
+### Getting a transition ID
+
+In the most recent JIRA UI, you can no longer see transition IDs in the workflow
+administration UI. You can get the ID you need in either of the following ways:
+
+1. By using the API, with a request like `https://yourcompany.atlassian.net/rest/api/2/issue/ISSUE-123/transitions`
+ using an issue that is in the appropriate "open" state
+1. By mousing over the link for the transition you want and looking for the
+ "action" parameter in the URL
+
+Note that the transition ID may vary between workflows (e.g., bug vs. story),
+even if the status you are changing to is the same.
After saving the configuration, your GitLab project will be able to interact
-with all JIRA projects in your JIRA instance.
+with all JIRA projects in your JIRA instance and you'll see the JIRA link on the GitLab project pages that takes you to the appropriate JIRA project.
![JIRA service page](img/jira_service_page.png)
@@ -173,6 +186,7 @@ where `PROJECT-1` is the issue ID of the JIRA project.
- Only commits and merges into the project's default branch (usually **master**) will
close an issue in Jira. You can change your projects default branch under
[project settings](img/jira_project_settings.png).
+- The JIRA issue will not be transitioned if it has a resolution.
### JIRA issue closing example
@@ -222,6 +236,10 @@ JIRA issue references and update comments will not work if the GitLab issue trac
Make sure the `Transition ID` you set within the JIRA settings matches the one
your project needs to close a ticket.
+Make sure that the JIRA issue is not already marked as resolved, in other words that
+the JIRA issue resolution field is not set. (It should not be struck through in
+JIRA lists.)
+
[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 e9738b683f9..f502d1c9821 100644
--- a/doc/user/project/integrations/kubernetes.md
+++ b/doc/user/project/integrations/kubernetes.md
@@ -1,9 +1,16 @@
---
-last_updated: 2017-09-25
+last_updated: 2017-12-28
---
# GitLab Kubernetes / OpenShift integration
+CAUTION: **Warning:**
+The Kubernetes service integration has been deprecated in GitLab 10.3. If the
+service is active, the cluster information will still be editable, however we
+advise to disable and reconfigure the clusters using the new
+[Clusters](../clusters/index.md) page. If the service is inactive, the fields
+will not be editable. Read [GitLab 10.3 release post](https://about.gitlab.com/2017/12/22/gitlab-10-3-released/#kubernetes-integration-service) for more information.
+
GitLab can be configured to interact with Kubernetes, or other systems using the
Kubernetes API (such as OpenShift).
@@ -74,9 +81,9 @@ GitLab CI/CD build environment:
Here's what you can do with GitLab if you enable the Kubernetes integration.
-### Deploy Boards (EEP)
+### Deploy Boards
-> Available in [GitLab Enterprise Edition Premium][ee].
+> Available in [GitLab Premium][ee].
GitLab's Deploy Boards offer a consolidated view of the current health and
status of each CI [environment](../../../ci/environments.md) running on Kubernetes,
@@ -86,9 +93,9 @@ workflow they already use without any need to access Kubernetes.
[> Read more about Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html)
-### Canary Deployments (EEP)
+### Canary Deployments
-> Available in [GitLab Enterprise Edition Premium][ee].
+> Available in [GitLab Premium][ee].
Leverage [Kubernetes' Canary deployments](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/#canary-deployments)
and visualize your canary deployments right inside the Deploy Board, without
@@ -127,4 +134,4 @@ containers. To use this integration, you should deploy to Kubernetes using
the deployment variables above, ensuring any pods you create are labelled with
`app=$CI_ENVIRONMENT_SLUG`. GitLab will do the rest!
-[ee]: https://about.gitlab.com/gitlab-ee/
+[ee]: https://about.gitlab.com/products/
diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md
index a0405161495..9496d6f2ce0 100644
--- a/doc/user/project/integrations/project_services.md
+++ b/doc/user/project/integrations/project_services.md
@@ -39,7 +39,7 @@ Click on the service links to see further configuration instructions and details
| [Irker (IRC gateway)](irker.md) | Send IRC messages, on update, to a list of recipients through an Irker gateway |
| [JIRA](jira.md) | JIRA issue tracker |
| JetBrains TeamCity CI | A continuous integration and build server |
-| [Kubernetes](kubernetes.md) | A containerized deployment service |
+| [Kubernetes](kubernetes.md) _(Has been deprecated in GitLab 10.3)_ | A containerized deployment service |
| [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands |
| [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 |
diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md
index 5fefb3b69c4..fa7e504c4aa 100644
--- a/doc/user/project/integrations/prometheus.md
+++ b/doc/user/project/integrations/prometheus.md
@@ -2,119 +2,73 @@
> [Introduced][ce-8935] in GitLab 9.0.
-GitLab offers powerful integration with [Prometheus] for monitoring your apps.
-Metrics are retrieved from the configured Prometheus server, and then displayed
+GitLab offers powerful integration with [Prometheus] for monitoring key metrics of your apps, directly within GitLab.
+Metrics for each environment are retrieved from Prometheus, and then displayed
within the GitLab interface.
-Each project can be configured with its own specific Prometheus server, see the
-[configuration](#configuration) section for more details. If you have a single
-Prometheus server which monitors all of your infrastructure, you can pre-fill
-the settings page with a default template. To configure the template, see the
-[Services templates](services_templates.md) document.
+![Environment Dashboard](img/prometheus_dashboard.png)
-## Requirements
+There are two ways to setup Prometheus integration, depending on where your apps are running:
+* For deployments on Kubernetes, GitLab can automatically [deploy and manage Prometheus](#managed-prometheus-on-kubernetes)
+* For other deployment targets, simply [specify the Prometheus server](#manual-configuration-of-prometheus).
-Integration with Prometheus requires the following:
-
-1. GitLab 9.0 or higher
-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 server
-
-## Getting started with Prometheus monitoring
+Once enabled, GitLab will automatically detect metrics from known services in the [metric library](#monitoring-ci-cd-environments).
-Depending on your deployment and where you have located your GitLab server, there are a few options to get started with Prometheus monitoring.
+## Enabling Prometheus Integration
-* 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).
+### Managed Prometheus on Kubernetes
+> **Note**: [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/28916) in GitLab 10.5
-### Getting started with Prometheus outside of 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)
+GitLab can seamlessly deploy and manage Prometheus on a [connected Kubernetes cluster](../clusters/index.md), making monitoring of your apps easy.
-### Configuring Omnibus GitLab Prometheus to monitor Kubernetes deployments
+#### Requirements
-With Omnibus GitLab running inside of Kubernetes, you can leverage the bundled
-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>).
+* A [connected Kubernetes cluster](../clusters/index.md)
+* Helm Tiller [installed by GitLab](../clusters/index.md#installing-applications)
-1. Read how to configure the bundled Prometheus server in the
- [Administration guide][gitlab-prometheus-k8s-monitor].
-1. Now that Prometheus is configured, proceed on
- [configuring the Prometheus project service in GitLab](#configuration-in-gitlab).
+#### Getting started
-### Configuring your own Prometheus server within Kubernetes
+Once you have a connected Kubernetes cluster with Helm installed, deploying a managed Prometheus is as easy as a single click.
-Setting up and configuring Prometheus within Kubernetes is quick and painless.
-The Prometheus project provides an [official Docker image][prometheus-docker-image]
-which we can use as a starting point.
+1. Go to the `CI/CD > Kubernetes` page, to view your connected clusters
+1. Select the cluster you would like to deploy Prometheus to
+1. Click the **Install** button to deploy Prometheus to the cluster
-To get started quickly, we have provided a [sample YML file][prometheus-yml]
-that can be used as a template. This file will create a `prometheus` **Namespace**,
-**Service**, **Deployment**, and **ConfigMap** in Kubernetes. You can upload
-this file to the Kubernetes dashboard using **+ Create** at the top right.
+![Managed Prometheus Deploy](img/prometheus_deploy.png)
-![Deploy Prometheus](img/prometheus_yaml_deploy.png)
+#### About managed Prometheus deployments
-Or use `kubectl`:
+Prometheus is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/kubernetes/charts/tree/master/stable/prometheus). Prometheus is only accessible within the cluster, with GitLab communicating through the [Kubernetes API](https://kubernetes.io/docs/concepts/overview/kubernetes-api/).
-```bash
-kubectl apply -f path/to/prometheus.yml
-```
+The Prometheus server will [automatically detect and monitor](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#%3Ckubernetes_sd_config%3E) nodes, pods, and endpoints. To configure a resource to be monitored by Prometheus, simply set the following [Kubernetes annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/):
+* `prometheus.io/scrape` to `true` to enable monitoring of the resource.
+* `prometheus.io/port` to define the port of the metrics endpoint.
+* `prometheus.io/path` to define the path of the metrics endpoint. Defaults to `/metrics`.
-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. It will also attempt to collect metrics from any Kubernetes Pods that have been [annotated for Prometheus](https://prometheus.io/docs/operating/configuration/#pod).
+CPU and Memory consumption is monitored, but requires [naming conventions](prometheus_library/kubernetes.html#specifying-the-environment) in order to determine the environment. If you are using [Auto DevOps](../../../topics/autodevops/), this is handled automatically.
-Since GitLab is not running within Kubernetes, the template provides external
-network access via a `NodePort` running on `30090`. This method allows access
-to be controlled using provider firewall rules, like within Google Compute Engine.
+The [NGINX Ingress](../clusters/index.md#installing-applications) that is deployed by GitLab to clusters, is automatically annotated for monitoring providing key response metrics: latency, throughput, and error rates.
-Since a `NodePort` does not automatically have firewall rules created for it,
-one will need to be created manually to allow access. In GCP/GKE, you will want
-to confirm the Node that the Prometheus pod is running on. This can be done
-either by looking at the Pod in the Kubernetes dashboard, or by running:
+### Manual configuration of Prometheus
-```bash
-kubectl describe pods -n prometheus
-```
+#### Requirements
-Next on GKE, we need to get the `tag` of the Node or VM Instance, so we can
-create an accurate firewall rule. The easiest way to do this is to go into the
-Google Cloud Platform Compute console and select the VM instance that matches
-the name of the Node gathered from the step above. In this case, the node tag
-needed is `gke-prometheus-demo-5d5ada10-node`. Also make a note of the
-**External IP**, which will be the IP address the Prometheus server is reachable
-on.
-
-![GCP Node Detail](img/prometheus_gcp_node_name.png)
-
-Armed with the proper Node tag, the firewall rule can now be created
-specifically for this node. To create the firewall rule, open the Google Cloud
-Platform Networking console, and select **Firewall Rules**.
-
-Create a new rule:
+Integration with Prometheus requires the following:
-- Specify the source IP range to match your desired access list, which should
- include your GitLab server. A sample of GitLab.com's IP address range is
- available [in this issue][gitlab.com-ip-range], but note that GitLab.com's IPs
- are subject to change without prior notification.
-- Allowed protocol and port should be `tcp:30090`.
-- The target tags should match the Node tag identified earlier in this step.
+1. GitLab 9.0 or higher
+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 server
-![GCP Firewall Rule](img/prometheus_gcp_firewall_rule.png)
+#### Getting started
----
+Installing and configuring Prometheus to monitor applications is fairly straight forward.
-Now that Prometheus is configured, proceed to
-[configure the Prometheus project service in GitLab](##configuration-in-gitlab).
+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)
-## Configuration in GitLab
+#### Configuration in GitLab
The actual configuration of Prometheus integration within GitLab is very simple.
All you will need is the DNS or IP address of the Prometheus server you'd like
@@ -133,9 +87,9 @@ to integrate with.
Once configured, GitLab will attempt to retrieve performance metrics for any
environment which has had a successful deployment.
-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 will automatically scan the Prometheus server for metrics from known serves like Kubernetes and NGINX, and attempt to identify individual environment. The supported metrics and scan process is detailed in our [Prometheus Metric Library documentation](prometheus_library/metrics.html).
-[Learn more about monitoring environments.](../../../ci/environments.md#monitoring-environments)
+You can view the performance dashboard for an environment by [clicking on the monitoring button](../../../ci/environments.md#monitoring-environments).
## Determining the performance impact of a merge
@@ -143,7 +97,7 @@ GitLab will automatically scan the Prometheus server for known metrics and attem
> 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
-Developers can view theperformance impact of their changes within the merge
+Developers can view the performance 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
@@ -159,7 +113,7 @@ Prometheus server.
## Troubleshooting
-If the "Attempting to load performance data" screen continues to appear, it could be due to:
+If the "No data found" screen continues to appear, it could be due to:
- No successful deployments have occurred to this environment.
- Prometheus does not have performance data for this environment, or the metrics
diff --git a/doc/user/project/integrations/prometheus_library/kubernetes.md b/doc/user/project/integrations/prometheus_library/kubernetes.md
index a6673fa2a00..6b190deaa6c 100644
--- a/doc/user/project/integrations/prometheus_library/kubernetes.md
+++ b/doc/user/project/integrations/prometheus_library/kubernetes.md
@@ -11,22 +11,27 @@ integration services must be enabled.
## Metrics supported
-| Name | Query |
-| ---- | ----- |
-| Average Memory Usage (MB) | (sum(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) without (job))) / count(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) without (job)) /1024/1024 |
-| Average CPU Utilization (%) | sum(avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}"}[2m])) without (job)) * 100 |
+- Average Memory Usage (MB):
-## Configuring Prometheus to monitor for Kubernetes node metrics
+ ```
+ avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024
+ ```
-In order for Prometheus to collect Kubernetes metrics, you first must have a
-Prometheus server up and running. You have two options here:
+- Average CPU Utilization (%):
-- 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).
+ ```
+ avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}[15m])) by (pod_name))
+ ```
-## Specifying the Environment label
+## Configuring Prometheus to monitor for Kubernetes metrics
-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).
+Prometheus needs to be deployed into the cluster and configured properly in order to gather Kubernetes metrics. GitLab supports two methods for doing so:
-If you are using [GitLab Auto-Deploy](../../../../ci/autodeploy/index.md) and one of the two [provided Kubernetes monitoring solutions](../prometheus.md#getting-started-with-prometheus-monitoring), the `environment` label will be automatically added.
+- GitLab [integrates with Kubernetes](../../clusters/index.md), and can [deploy Prometheus into a connected cluster](../prometheus.html#managed-prometheus-on-kubernetes). It is automatically configured to collect Kubernetes metrics.
+- To configure your own Prometheus server, you can follow the [Prometheus documentation](https://prometheus.io/docs/introduction/overview/).
+
+## Specifying the Environment
+
+In order to isolate and only display relevant CPU and Memory metrics for a given environment, GitLab needs a method to detect which containers it is running. Because these metrics are tracked at the container level, traditional Kubernetes labels are not available.
+
+Instead, the [Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/) or [DaemonSet](https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/) name should begin with [CI_ENVIRONMENT_SLUG](../../../../ci/variables/README.md#predefined-variables-environment-variables). It can be followed by a `-` and additional content if desired. For example, a deployment name of `review-homepage-5620p5` would match the `review/homepage` environment.
diff --git a/doc/user/project/integrations/prometheus_library/nginx_ingress.md b/doc/user/project/integrations/prometheus_library/nginx_ingress.md
index e6f13d0630b..49b34c82ae6 100644
--- a/doc/user/project/integrations/prometheus_library/nginx_ingress.md
+++ b/doc/user/project/integrations/prometheus_library/nginx_ingress.md
@@ -2,11 +2,11 @@
> [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.
+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) and above of the ingress.
## Requirements
-The [Prometheus service](../prometheus/index.md) must be enabled.
+[Prometheus integration](../prometheus/index.md) must be active.
## Metrics supported
@@ -18,24 +18,34 @@ The [Prometheus service](../prometheus/index.md) must be enabled.
## Configuring NGINX ingress monitoring
-If you have deployed with the [gitlab-omnibus](https://docs.gitlab.com/ee/install/kubernetes/gitlab_omnibus.md) Helm chart, and your application is running in the same cluster, no further action is required. The ingress metrics will be automatically enabled and annotated for Prometheus monitoring. Simply ensure Prometheus monitoring is [enabled for your project](../prometheus.md), which is on by default.
+If you have deployed NGINX Ingress using GitLab's [Kubernetes cluster integration](../../clusters/index.md#installing-applications), it will [automatically be monitored](#about-managed-nginx-ingress-deployments) by Prometheus.
-For other deployments, there is some configuration required depending on your installation:
-* NGINX Ingress should be version 0.9.0 or above
+For other deployments, there is [some configuration](#manually-setting-up-nginx-ingress-for-prometheus-monitoring) required depending on your installation:
+* NGINX Ingress should be version 0.9.0 or above, with metrics enabled
* NGINX Ingress should be annotated for Prometheus monitoring
* Prometheus should be configured to monitor annotated pods
-### Setting up NGINX Ingress for Prometheus monitoring
+### About managed NGINX Ingress deployments
+
+NGINX Ingress is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/kubernetes/charts/tree/master/stable/nginx-ingress). NGINX Ingress will be [externally reachable via the Load Balancer's IP](https://docs.gitlab.com/ce/user/project/clusters/index.html#getting-the-external-ip-address).
+
+NGINX is configured for Prometheus monitoring, by setting:
+* `enable-vts-status: "true"`, to export Prometheus metrics
+* `prometheus.io/scrape: "true"`, to enable automatic discovery
+* `prometheus.io/port: "10254"`, to specify the metrics port
+
+When used in conjunction with the GitLab deployed Prometheus service, response metrics will be automatically collected.
+
+### Manually setting up NGINX Ingress for Prometheus monitoring
Version 0.9.0 and above of [NGINX ingress](https://github.com/kubernetes/ingress/tree/master/controllers/nginx) have built-in support for exporting Prometheus metrics. To enable, a ConfigMap setting must be passed: `enable-vts-status: "true"`. Once enabled, a Prometheus metrics endpoint will start running on port 10254.
-With metric data now available, Prometheus needs to be configured to collect it. The easiest way to do this is to leverage Prometheus' [built-in Kubernetes service discovery](https://prometheus.io/docs/operating/configuration/#kubernetes_sd_config), which automatically detects a variety of Kubernetes components and makes them available for monitoring. Since NGINX ingress metrics are exposed per pod, a scrape job for Kubernetes pods is required. A sample pod scraping configuration [is available](https://github.com/prometheus/prometheus/blob/master/documentation/examples/prometheus-kubernetes.yml#L248). This configuration will detect pods and enable collection of metrics **only if** they have been specifically annotated for monitoring.
+Next, the ingress needs to be annotated for Prometheus monitoring. Two new annotations need to be added:
-Depending on how NGINX ingress was deployed, typically a DaemonSet or Deployment, edit the corresponding YML spec. Two new annotations need to be added:
* `prometheus.io/scrape: "true"`
* `prometheus.io/port: "10254"`
-Prometheus should now be collecting NGINX ingress metrics. To validate view the Prometheus Targets, available under `Status > Targets` on the Prometheus dashboard. New entries for NGINX should be listed in the kubernetes pod monitoring job, `kubernetes-pods`.
+Managing these settings depends on how NGINX ingress has been deployed. If you have deployed via the [official Helm chart](https://github.com/kubernetes/charts/tree/master/stable/nginx-ingress), metrics can be enabled with `controller.stats.enabled` along with the required annotations. Alternatively it is possible edit the NGINX ingress YML directly in the [Kubernetes dashboard](https://github.com/kubernetes/dashboard).
## Specifying the Environment label
diff --git a/doc/user/project/integrations/redmine.md b/doc/user/project/integrations/redmine.md
index f530b6cb649..de2cf6d4647 100644
--- a/doc/user/project/integrations/redmine.md
+++ b/doc/user/project/integrations/redmine.md
@@ -10,12 +10,9 @@ in the table below.
| `description` | A name for the issue tracker (to differentiate between instances, for example) |
| `project_url` | The URL to the project in Redmine which is being linked to this GitLab project |
| `issues_url` | The URL to the issue in Redmine project that is linked to this GitLab project. Note that the `issues_url` requires `:id` in the URL. This ID is used by GitLab as a placeholder to replace the issue number. |
- | `new_issue_url` | This is the URL to create a new issue in Redmine for the project linked to this GitLab project |
+ | `new_issue_url` | This is the URL to create a new issue in Redmine for the project linked to this GitLab project. **This is currently not being used and will be removed in a future release.** |
- Once you have configured and enabled Redmine:
- - the **Issues** link on the GitLab project pages takes you to the appropriate
- Redmine issue index
- - clicking **New issue** on the project dashboard creates a new Redmine issue
+ Once you have configured and enabled Redmine you'll see the Redmine link on the GitLab project pages that takes you to the appropriate Redmine project.
As an example, below is a configuration for a project named gitlab-ci.
diff --git a/doc/user/project/integrations/samples/prometheus.yml b/doc/user/project/integrations/samples/prometheus.yml
deleted file mode 100644
index 30b59e172a1..00000000000
--- a/doc/user/project/integrations/samples/prometheus.yml
+++ /dev/null
@@ -1,107 +0,0 @@
-apiVersion: v1
-kind: Namespace
-metadata:
- name: prometheus
----
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: prometheus
- namespace: prometheus
-data:
- prometheus.yml: |-
- scrape_configs:
- - job_name: 'kubernetes-nodes'
- scheme: https
- 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: node
- metric_relabel_configs:
- - source_labels: [pod_name]
- 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
-metadata:
- name: prometheus
- namespace: prometheus
-spec:
- selector:
- app: prometheus
- ports:
- - name: prometheus
- protocol: TCP
- port: 9090
- nodePort: 30090
- type: NodePort
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
- name: prometheus
- namespace: prometheus
-spec:
- replicas: 1
- template:
- metadata:
- labels:
- app: prometheus
- spec:
- containers:
- - name: prometheus
- image: prom/prometheus:latest
- args:
- - '-config.file=/prometheus-data/prometheus.yml'
- ports:
- - name: prometheus
- containerPort: 9090
- volumeMounts:
- - name: data-volume
- mountPath: /prometheus-data
- volumes:
- - name: data-volume
- configMap:
- name: prometheus
diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md
index eafdd28071d..19df78f4140 100644
--- a/doc/user/project/integrations/webhooks.md
+++ b/doc/user/project/integrations/webhooks.md
@@ -54,6 +54,12 @@ Below are described the supported events.
Triggered when you push to the repository except when pushing tags.
+> **Note:** When more than 20 commits are pushed at once, the `commits` web hook
+ attribute will only contain the first 20 for performance reasons. Loading
+ detailed commit data is expensive. Note that despite only 20 commits being
+ present in the `commits` attribute, the `total_commits_count` attribute will
+ contain the actual total.
+
**Request header**:
```
@@ -310,7 +316,7 @@ X-Gitlab-Event: Issue Hook
Triggered when a new comment is made on commits, merge requests, issues, and code snippets.
The note data will be stored in `object_attributes` (e.g. `note`, `noteable_type`). The
payload will also include information about the target of the comment. For example,
-a comment on a issue will include the specific issue information under the `issue` key.
+a comment on an issue will include the specific issue information under the `issue` key.
Valid target types:
1. `commit`
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index 8c2690ec3b2..7eab825fa32 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -34,7 +34,7 @@ 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/),
+only in [GitLab Ultimate](https://about.gitlab.com/products/),
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
@@ -235,6 +235,26 @@ to another list the label changes and a system not is recorded.
[Developers and up](../permissions.md) can use all the functionality of the
Issue Board, that is create/delete lists and drag issues around.
+## Group Issue Board
+
+>Introduced in GitLab 10.6
+
+Group issue board is analogous to project-level issue board and it is accessible at the group
+navigation level. A group-level issue board allows you to view all issues from all projects in that group or descendant subgroups. Similarly, you can only filter by group labels for these
+boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only
+group-level objects are available.
+
+## Features per tier
+
+Different issue board features are available in different [GitLab tiers](https://about.gitlab.com/pricing/), as shown in the following table:
+
+| Tier | Number of project issue boards | Board with configuration in project issue boards | Number of group issue boards | Board with configuration in group issue boards |
+| --- | --- | --- | --- | --- |
+| Core | 1 | No | 1 | No |
+| Starter | Multiple | Yes | 1 | No |
+| Premium | Multiple | Yes | Multiple | Yes |
+| Ultimate | Multiple | Yes | Multiple | Yes |
+
## Tips
A few things to remember:
diff --git a/doc/user/project/issues/create_new_issue.md b/doc/user/project/issues/create_new_issue.md
index 9af088374a1..1688edc1ee2 100644
--- a/doc/user/project/issues/create_new_issue.md
+++ b/doc/user/project/issues/create_new_issue.md
@@ -36,3 +36,25 @@ From an Issue Board, create a new issue by clicking on the plus sign (**+**) on
It opens a new issue for that project labeled after its respective list.
![From the issue board](img/new_issue_from_issue_board.png)
+
+## New issue via email
+
+*This feature needs [incoming email](../../../administration/incoming_email.md)
+to be configured by a GitLab administrator to be available for CE/EE users, and
+it's available on GitLab.com.*
+
+At the bottom of a project's issue page, click
+**Email a new issue to this project**, and you will find an email address
+which belongs to you. You could add this address to your contact.
+
+This is a private email address, generated just for you.
+**Keep it to yourself** as anyone who gets ahold of it can create issues or
+merge requests as if they were you. You can add this address to your contact
+list for easy access.
+
+Sending an email to this address will create a new issue on your behalf for
+this project, where the email subject becomes the issue title, and the email
+body becomes the issue description. [Markdown] and [quick actions] are
+supported.
+
+![Bottom of a project issues page](img/new_issue_from_email.png)
diff --git a/doc/user/project/issues/crosslinking_issues.md b/doc/user/project/issues/crosslinking_issues.md
index 5cc7ea383ae..cc8988be36b 100644
--- a/doc/user/project/issues/crosslinking_issues.md
+++ b/doc/user/project/issues/crosslinking_issues.md
@@ -40,7 +40,7 @@ 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
+When mentioning the issue "A" in issue "B", the issue "A" will also
display a notification in its tracker. The same is valid for mentioning
issues in merge requests.
diff --git a/doc/user/project/issues/img/new_issue_from_email.png b/doc/user/project/issues/img/new_issue_from_email.png
new file mode 100644
index 00000000000..775ea0cdffb
--- /dev/null
+++ b/doc/user/project/issues/img/new_issue_from_email.png
Binary files differ
diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md
index 3e81dcb78c6..be4436749f9 100644
--- a/doc/user/project/issues/index.md
+++ b/doc/user/project/issues/index.md
@@ -35,7 +35,7 @@ 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/)
+available in [GitLab Starter](https://about.gitlab.com/products/)
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.
@@ -64,9 +64,7 @@ You can also [search and filter](../../search/index.md#issues-and-merge-requests
### Issues per group
-View all the issues in a group (that is, all the issues across all projects in that
-group) by navigating to **Group > Issues**. This view also has the open and closed
-issue tabs.
+View issues in all projects in the group, including all projects of all descendant subgroups of the group. Navigate to **Group > Issues** to view these issues. This view also has the open and closed issues tabs.
![Group Issues list view](img/group_issues_list_view.png)
@@ -141,7 +139,7 @@ Find GitLab Issue Boards by navigating to your **Project's Dashboard** > **Issue
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
+With [GitLab Starter](https://about.gitlab.com/products/), 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).
### External Issue Tracker
diff --git a/doc/user/project/issues/issues_functionalities.md b/doc/user/project/issues/issues_functionalities.md
index 66140f389af..f2ca6a6822e 100644
--- a/doc/user/project/issues/issues_functionalities.md
+++ b/doc/user/project/issues/issues_functionalities.md
@@ -41,16 +41,17 @@ it's reassigned to someone else to take it from there.
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)
+##### 3.1. Multiple Assignees
-Multiple Assignees are only available in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/).
+> Available in [GitLab Starter](https://about.gitlab.com/products/) and
+[GitLab.com Bronze](https://about.gitlab.com/gitlab-com/).
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.
+In [GitLab Starter](https://about.gitlab.com/products/), 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).
@@ -88,9 +89,10 @@ 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)
+#### 8. Weight
-Issue Weights are only available in [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee/).
+> Available in [GitLab Starter](https://about.gitlab.com/products/) and
+[GitLab.com Bronze](https://about.gitlab.com/gitlab-com/).
- 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.
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index d7eb4bca89c..a89a1206170 100644
--- a/doc/user/project/labels.md
+++ b/doc/user/project/labels.md
@@ -1,174 +1,125 @@
# Labels
-Labels provide an easy way to categorize the issues or merge requests based on
-descriptive titles like `bug`, `documentation` or any other text you feel like.
-They can have different colors, a description, and are visible throughout
-the issue tracker or inside each issue individually.
+## Overview
-With labels, you can navigate the issue tracker and filter any bloated
-information to visualize only the issues you are interested in. Let's see how
-that works.
+Labels allow you to categorize issues or merge requests using descriptive titles like `bug`, `feature request`, or `docs`. Each label also has a customizable color. They allow you to quickly and dynamically filter and manage issues or merge requests you care about, and are visible throughout GitLab in most places where issues and merge requests are located.
-## Create new labels
+## Project labels and group labels
+
+In GitLab, you can create project and group labels:
+
+- **Project labels** can be assigned to issues or merge requests in that project only.
+- **Group labels** can be assigned to any issue or merge request of any project in that group or subgroup.
+- In the [future](https://gitlab.com/gitlab-org/gitlab-ce/issues/40915), you will be able to assign group labels to issues and merge reqeusts of projects in [subgroups](../group/subgroups/index.md).
+
+## Creating labels
>**Note:**
-A permission level of `Developer` or higher is required in order to manage
-labels.
+A permission level of `Developer` or higher is required to create labels.
-Head over a single project and navigate to **Issues > Labels**.
+### New project label
-The first time you visit this page, you'll notice that there are no labels
-created yet.
+To create a **project label**, navigate to **Issues > Labels** in the project.
-Creating a new label from scratch is as easy as pressing the **New label**
-button. From there on you can choose the name, give it an optional description,
-a color and you are set.
+Click the **New label** button. Enter the title, an optional description, and the background color. Click **Create label** to create the label.
-When you are ready press the **Create label** button to create the new label.
+If a project has no labels, you can generate a default set of project labels from its empty label list page:
-![New label](img/labels_new_label.png)
+![Labels generate default](img/labels_generate_default.png)
----
+GitLab will add the following default labels to the project:
-## Default labels
+![Labels default](img/labels_default.png)
-The very first time you visit the labels area, it's gonna be empty. In that
-case, it's possible to populate the labels for your project from a set of
-predefined labels.
+### New group label
-Click the link to 'Generate a default set of labels' and GitLab will
-generate them for you. There are 8 default generated labels in total:
+To create a **group label**, follow similar steps from above to project labels. Navigate to **Issues > Labels** in the group and create it from there.
-- bug
-- confirmed
-- critical
-- discussion
-- documentation
-- enhancement
-- suggestion
-- support
+Group labels appear in every label list page of the group's child projects.
-## Labels Overview
+![Labels list](img/labels_list.png)
-![Default generated labels](img/labels_default.png)
+### New project label from sidebar
-You can see that from the labels page you can have an overview of the number of
-issues and merge requests assigned to each label.
+From the sidebar of an issue or a merge request, you can create a create a new **project label** inline immediately, instead of navigating to the project label list page.
-## Prioritize labels
+![Labels inline](img/new_label_from_sidebar.gif)
->**Notes:**
->
-> - Introduced in GitLab 8.9.
-> - Priority sorting is based on the highest priority label only. This might
-> change in the future, follow the discussion in
-> https://gitlab.com/gitlab-org/gitlab-ce/issues/18554.
+## Editing labels
-Prioritized labels are like any other label, but sorted by priority. This allows
-you to sort issues and merge requests by label priority.
+NOTE: **Note:**
+A permission level of `Developer` or higher is required to edit labels.
-To prioritize labels, navigate to your project's **Issues > Labels** and click
-on the star icon next to them to put them in the priority list. Click on the
-star icon again to remove them from the list.
+You can update a label by navigating to **Issues > Labels** in the project or group and clicking the pencil icon.
-From there, you can drag them around to set the desired priority. Priority is
-set from high to low with an ascending order. Labels with no priority, count as
-having their priority set to null.
+You can delete a label by clicking the trash icon.
-![Prioritize labels](img/labels_prioritize.png)
+### Promoting project labels to group labels
-Now that you have labels prioritized, you can use the 'Label priority' and 'Priority'
-sort orders in the issues or merge requests tracker.
+If you are expanding from a few projects to a larger number of projects within the same group, you may want to share the same label among multiple projects in the same group. If you previously created a project label and now want to make it available for other projects, you can promote it to a group label.
-In the following, everything applies to both issues and merge requests, but we'll
-refer to just issues for brevity.
+From the project label list page, you can promote a project label to a group label. This will merge all project labels across all projects in this group with the same name into a single group label. All issues and merge requests that previously were assigned one of these project labels will now be assigned the new group label. This action cannot be reversed and the changes are permanent.
-The 'Label priority' sort order positions issues with higher priority labels
-toward the top, and issues with lower priority labels toward the bottom. A non-prioritized
-label is considered to have the lowest priority. For a given issue, we _only_ consider the
-highest priority label assigned to it in the comparison. ([We are discussing](https://gitlab.com/gitlab-org/gitlab-ce/issues/18554)
-including all the labels in a given issue for this comparison.) Given two issues
-are equal according to this sort comparison, their relative order is equal, and
-therefore it's not guaranteed that one will be always above the other.
+![Labels promotion](img/labels_promotion.png)
-![Label priority sort order](img/label_priority_sort_order.png)
+## Assigning labels from the sidebar
-The 'Priority' sort order comparison first considers an issue's milestone's due date,
-(if the issue is assigned a milestone and the milestone's due date exists), and then
-secondarily considers the label priority comparison above. Sooner due dates results
-a higher sort order. If an issue doesn't have a milestone due date, it is equivalent to
-being assigned to a milestone that has a due date in the infinite future. Given two issues
-are equal according to this two-stage sort comparison, their relative order is equal, and
-therefore it's not guaranteed that one will be always above the other.
+Every issue and merge request can be assigned any number of labels. The labels are visible on every issue and merge request page, in the sidebar. They are also visible in the issue board. From the sidebar, you can assign or unassign a label to the object (i.e. label or unlabel it). You can also perform this as a [quick action](quick_actions.md) in a comment.
-![Priority sort order](img/priority_sort_order.png)
+| View labels in sidebar | Assign labels from sidebar |
+|:---:|:---:|
+| ![Labels sidebar](img/labels_sidebar.png) | ![Labels sidebar assign](img/labels_sidebar_assign.png) |
+## Filtering issues and merge requests by label
-## Subscribe to labels
+### Filtering in list pages
-If you don’t want to miss issues or merge requests that are important to you,
-simply subscribe to a label. You’ll get notified whenever the label gets added
-to an issue or merge request, making sure you don’t miss a thing.
+From the project issue list page and the project merge request list page, you can [filter](../search/index.md#issues-and-merge-requests) by both group (including subgroup ancestors) labels and project labels.
-Go to your project's **Issues > Labels** area, find the label(s) you want to
-subscribe to and click on the eye icon. Click again to unsubscribe.
+From the group issue list page and the group merge request list page, you can [filter](../search/index.md#issues-and-merge-requests) by both group labels (including subgroup ancestors and subgroup descendants) and project labels.
-![Subscribe to labels](img/labels_subscribe.png)
+![Labels group issues](img/labels_group_issues.png)
-If you work on a large or popular project, try subscribing only to the labels
-that are relevant to you. You’ll notice it’ll be much easier to focus on what’s
-important.
+### Filtering in issue boards
-## Create a new label when inside an issue
+- From [project boards](issue_board.md), you can filter by both group labels and project labels in the [search and filter bar](../search/index.md#issue-boards).
-There are times when you are already inside an issue searching to assign a
-label, only to realize it doesn't exist. Instead of going to the **Labels**
-page and being distracted from your original purpose, you can create new
-labels on the fly.
+## Subscribing to labels
-Expand the issue sidebar and select **Create new label** from the labels dropdown
-list. Provide a name, pick a color and hit **Create**. The new label will be
-ready to used right away!
+From the project label list page and the group label list page, you can subscribe to [notifications](../../workflow/notifications.md) of a given label, to alert you that that label has been assigned to an issue or merge request.
-![New label on the fly](img/labels_new_label_on_the_fly.png)
+![Labels subscriptions](img/labels_subscriptions.png)
-## Assigning labels to issues and merge requests
+## Label priority
-There are generally two ways to assign a label to an issue or merge request.
+>**Notes:**
+>
+> - Introduced in GitLab 8.9.
+> - Priority sorting is based on the highest priority label only. [This discussion](https://gitlab.com/gitlab-org/gitlab-ce/issues/18554) considers changing this.
-The first one is to assign a label when you first create or edit an issue or
-merge request.
+Labels can have relative priorities, which are used in the "Label priority" and "Priority" sort orders of the issue and merge request list pages.
-The second way is by using the right sidebar when inside an issue or merge
-request. Expand it and hit **Edit** in the labels area. Start typing the name
-of the label you are looking for to narrow down the list, and select it. You
-can add more than one labels at once. When done, click outside the sidebar area
-for the changes to take effect.
+From the project label list page, star a label to indicate that it has a priority. Drag starred labels up and down to change their priority. Higher means higher priority. Prioritization happens at the project level, only on the project label list page, and not on the group label list page. However, both project and group labels can be prioritized on the project label list page since both types are displayed on the project label list page.
-![Assign label in sidebar](img/labels_assign_label_sidebar.png)
-![Save labels in sidebar](img/labels_assign_label_sidebar_saved.png)
+![Labels prioritized](img/labels_prioritized.png)
----
+On the project and group issue and merge request list pages, you can sort by `Label priority` and `Priority`, which account for objects (issues and merge requests) that have prioritized labels assigned to them.
-To remove labels, expand the left sidebar and unmark them from the labels list.
-Simple as that.
+If you sort by `Label priority`, GitLab considers this sort comparison order:
-## Use labels to filter issues
+- Object with a higher priority prioritized label.
+- Object without a prioritized label.
-Once you start adding labels to your issues, you'll see the benefit of it.
-Labels can have several uses, one of them being the quick filtering of issues
-or merge requests.
+Ties are broken arbitrarily. (Note that we _only_ consider the highest prioritized label in an object, and not any of the lower prioritized labels. [This discussion](https://gitlab.com/gitlab-org/gitlab-ce/issues/18554) considers changing this.)
-Pick an existing label from the dropdown _Label_ menu or click on an existing
-label from the issue tracker. In the latter case, you also get to see the
-label description like shown below.
+![Labels sort label priority](img/labels_sort_label_priority.png)
-![Filter labels](img/labels_filter.png)
+If you sort by `Priority`, GitLab considers this sort comparison order:
----
+- Object's assigned [milestone](milestones/index.md)'s due date is sooner, provided the object has a milestone and the milestone has a due date. If this isn't the case, consider the object having a due date in the infinite future.
+- Object with a higher priority prioritized label.
+- Object without a prioritized label.
-And if you added a description to your label, you can see it by hovering your
-mouse over the label in the issue tracker or wherever else the label is
-rendered.
+Ties are broken arbitrarily.
-![Label tooltips](img/labels_description_tooltip.png)
+![Labels sort priority](img/labels_sort_priority.png)
diff --git a/doc/user/project/members/share_project_with_groups.md b/doc/user/project/members/share_project_with_groups.md
index f5c748a03b3..5d819998dd9 100644
--- a/doc/user/project/members/share_project_with_groups.md
+++ b/doc/user/project/members/share_project_with_groups.md
@@ -16,19 +16,29 @@ say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'P
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 **Settings > Members** section.
+To share 'Project Acme' with the 'Engineering' group:
-![share project with groups](img/share_project_with_groups.png)
+1. For 'Project Acme' use the left navigation menu to go to **Settings > Members**
-Then select the 'Share with group' tab by clicking it.
+ ![share project with groups](img/share_project_with_groups.png)
-Now you can add the 'Engineering' group with the maximum access level of your choice. Click 'Share' to share it.
+1. Select the 'Share with group' tab
+1. Add the 'Engineering' group with the maximum access level of your choice
+1. Click **Share** to share it
-![share project with groups tab](img/share_project_with_groups_tab.png)
+ ![share project with groups tab](img/share_project_with_groups_tab.png)
-After sharing 'Project Acme' with 'Engineering', the project will be listed on the group dashboard.
+1. After sharing 'Project Acme' with 'Engineering', the project will be listed
+ on the group dashboard
-!['Project Acme' is listed as a shared project for 'Engineering'](img/other_group_sees_shared_project.png)
+ !['Project Acme' is listed as a shared project for 'Engineering'](img/other_group_sees_shared_project.png)
+
+Note that you can only share a project with:
+
+- groups for which you have an explicitly defined membership
+- groups that contain a nested subgroup or project for which you have an explicitly defined role
+
+Admins are able to share projects with any group in the system.
## Maximum access level
diff --git a/doc/user/project/merge_requests/fast_forward_merge.md b/doc/user/project/merge_requests/fast_forward_merge.md
index 085170d9f03..3cd91a185e3 100644
--- a/doc/user/project/merge_requests/fast_forward_merge.md
+++ b/doc/user/project/merge_requests/fast_forward_merge.md
@@ -9,7 +9,7 @@ When the fast-forward merge ([`--ff-only`][ffonly]) setting is enabled, no merge
commits will be created and all merges are fast-forwarded, which means that
merging is only allowed if the branch could be fast-forwarded.
-When a fast-forward merge is not possible, the user must rebase the branch manually.
+When a fast-forward merge is not possible, the user is given the option to rebase.
## Use cases
@@ -25,7 +25,7 @@ merge commits. In such cases, the fast-forward merge is the perfect candidate.
Now, when you visit the merge request page, you will be able to accept it
**only if a fast-forward merge is possible**.
-![Fast forward merge request](img/ff_merge_mr.png)
+![Fast forward merge request](img/ff_merge_rebase.png)
If the target branch is ahead of the source branch, you need to rebase the
source branch locally before you will be able to do a fast-forward merge.
diff --git a/doc/user/project/merge_requests/img/allow_maintainer_push.png b/doc/user/project/merge_requests/img/allow_maintainer_push.png
new file mode 100644
index 00000000000..91cc399f4ff
--- /dev/null
+++ b/doc/user/project/merge_requests/img/allow_maintainer_push.png
Binary files differ
diff --git a/doc/user/project/merge_requests/img/ff_merge_mr.png b/doc/user/project/merge_requests/img/ff_merge_mr.png
deleted file mode 100644
index 241cc990343..00000000000
--- a/doc/user/project/merge_requests/img/ff_merge_mr.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/merge_requests/img/ff_merge_rebase.png b/doc/user/project/merge_requests/img/ff_merge_rebase.png
new file mode 100644
index 00000000000..f6139f189ce
--- /dev/null
+++ b/doc/user/project/merge_requests/img/ff_merge_rebase.png
Binary files differ
diff --git a/doc/user/project/merge_requests/img/remove_source_branch_status.png b/doc/user/project/merge_requests/img/remove_source_branch_status.png
new file mode 100644
index 00000000000..1377fab54ec
--- /dev/null
+++ b/doc/user/project/merge_requests/img/remove_source_branch_status.png
Binary files differ
diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md
index 7037d7f5989..3640d236db4 100644
--- a/doc/user/project/merge_requests/index.md
+++ b/doc/user/project/merge_requests/index.md
@@ -28,13 +28,14 @@ With GitLab merge requests, you can:
- Enable [fast-forward merge requests](#fast-forward-merge-requests)
- Enable [semi-linear history merge requests](#semi-linear-history-merge-requests) as another security layer to guarantee the pipeline is passing in the target branch
- [Create new merge requests by email](#create-new-merge-requests-by-email)
+- Allow maintainers of the target project to push directly to the fork by [allowing edits from maintainers](maintainer_access.md)
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)
-- [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)
-- 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)
+- 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 Premium)
+- Request [approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers (available in GitLab 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 Starter)
+- Analyze 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 Starter)
## Use cases
@@ -42,10 +43,10 @@ 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 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 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 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 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
@@ -55,8 +56,8 @@ B. Consider you're a web developer writing a webpage for your company's:
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. You request the [approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your manager (available in GitLab 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 Starter)
1. Your production team [cherry picks](#cherry-pick-changes) the merge commit into production
## Merge requests per project
@@ -70,12 +71,28 @@ and you can use the tabs available to quickly filter by open and closed. You can
## Merge requests per group
-View all the merge requests in a group (that is, all the merge requests across all projects in that
-group) by navigating to **Group > Merge Requests**. This view also has the open, merged, and closed
-merge request tabs, from which you can [search and filter the results](../../search/index.md#issues-and-merge-requests-per-group).
+View merge requests in all projects in the group, including all projects of all descendant subgroups of the group. Navigate to **Group > Merge Requests** to view these merge requests. This view also has the open and closed merge requests tabs.
+
+You can [search and filter the results](../../search/index.md#issues-and-merge-requests-per-group) from here.
![Group Issues list view](img/group_merge_requests_list_view.png)
+## Removing the source branch
+
+When creating a merge request, select the "Remove source branch when merge
+request accepted" option and the source branch will be removed when the merge
+request is merged.
+
+This option is also visible in an existing merge request next to the merge
+request button and can be selected/deselected before merging. It's only visible
+to users with [Master permissions](../../permissions.md) in the source project.
+
+If the user viewing the merge request does not have the correct permissions to
+remove the source branch and the source branch is set for removal, the merge
+request widget will show the "Removes source branch" text.
+
+![Remove source branch status](img/remove_source_branch_status.png)
+
## Authorization for merge requests
There are two main ways to have a merge request flow with GitLab:
@@ -134,6 +151,10 @@ those conflicts in the GitLab UI.
## Create new merge requests by email
+*This feature needs [incoming email](../../../administration/incoming_email.md)
+to be configured by a GitLab administrator to be available for CE/EE users, and
+it's available on GitLab.com.*
+
You can create a new merge request by sending an email to a user-specific email
address. The address can be obtained on the merge requests page by clicking on
a **Email a new merge request to this project** button. The subject will be
@@ -146,6 +167,19 @@ administrator to do so.
![Create new merge requests by email](img/create_from_email.png)
+## Find the merge request that introduced a change
+
+> **Note**: this feature was [implemented in GitLab 10.5](https://gitlab.com/gitlab-org/gitlab-ce/issues/2383).
+
+When viewing the commit details page, GitLab will link to the merge request (or
+merge requests, if it's in more than one) containing that commit.
+
+This only applies to commits that are in the most recent version of a merge
+request - if a commit was in a merge request, then rebased out of that merge
+request, they will not be linked.
+
+[Read more about merge request versions](versions.md)
+
## Revert changes
GitLab implements Git's powerful feature to revert any commit with introducing
@@ -160,7 +194,7 @@ of merge request diff is created. When you visit a merge request that contains
more than one pushes, you can select and compare the versions of those merge
request diffs.
-[Read more about the merge requests versions.](versions.md)
+[Read more about merge request versions](versions.md)
## Work In Progress merge requests
@@ -287,4 +321,4 @@ git checkout origin/merge-requests/1
```
[protected branches]: ../protected_branches.md
-[ee]: https://about.gitlab.com/gitlab-ee/ "GitLab Enterprise Edition"
+[ee]: https://about.gitlab.com/products/ "GitLab Enterprise Edition"
diff --git a/doc/user/project/merge_requests/maintainer_access.md b/doc/user/project/merge_requests/maintainer_access.md
new file mode 100644
index 00000000000..c9763a3fe02
--- /dev/null
+++ b/doc/user/project/merge_requests/maintainer_access.md
@@ -0,0 +1,18 @@
+# Allow maintainer pushes for merge requests across forks
+
+> [Introduced][ce-17395] in GitLab 10.6.
+
+This feature is available for merge requests across forked projects that are
+publicly accessible. It makes it easier for maintainers of projects to
+collaborate on merge requests across forks.
+
+When enabled for a merge request, members with merge access to the target
+branch of the project will be granted write permissions to the source branch
+of the merge request.
+
+The feature can only be enabled by users who already have push access to the
+source project, and only lasts while the merge request is open.
+
+Enable this functionality while creating a merge request:
+
+![Enable maintainer edits](./img/allow_maintainer_push.png)
diff --git a/doc/user/project/merge_requests/work_in_progress_merge_requests.md b/doc/user/project/merge_requests/work_in_progress_merge_requests.md
index 546c8bdc5e5..f01da06fa6e 100644
--- a/doc/user/project/merge_requests/work_in_progress_merge_requests.md
+++ b/doc/user/project/merge_requests/work_in_progress_merge_requests.md
@@ -7,7 +7,8 @@ have been marked a **Work In Progress**.
![Blocked Accept Button](img/wip_blocked_accept_button.png)
To mark a merge request a Work In Progress, simply start its title with `[WIP]`
-or `WIP:`.
+or `WIP:`. As an alternative, you're also able to do it by sending a commit
+with its title starting with `wip` or `WIP` to the merge request's source branch.
![Mark as WIP](img/wip_mark_as_wip.png)
diff --git a/doc/user/project/milestones/img/milestone_create.png b/doc/user/project/milestones/img/milestone_create.png
deleted file mode 100644
index beb2caa897f..00000000000
--- a/doc/user/project/milestones/img/milestone_create.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/milestones/img/milestone_group_create.png b/doc/user/project/milestones/img/milestone_group_create.png
deleted file mode 100644
index 7aaa7c56c15..00000000000
--- a/doc/user/project/milestones/img/milestone_group_create.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/milestones/img/milestones_new_group_milestone.png b/doc/user/project/milestones/img/milestones_new_group_milestone.png
new file mode 100644
index 00000000000..8780394d72e
--- /dev/null
+++ b/doc/user/project/milestones/img/milestones_new_group_milestone.png
Binary files differ
diff --git a/doc/user/project/milestones/img/milestones_new_project_milestone.png b/doc/user/project/milestones/img/milestones_new_project_milestone.png
new file mode 100644
index 00000000000..ba058428dfa
--- /dev/null
+++ b/doc/user/project/milestones/img/milestones_new_project_milestone.png
Binary files differ
diff --git a/doc/user/project/milestones/img/milestones_project_milestone_page.png b/doc/user/project/milestones/img/milestones_project_milestone_page.png
new file mode 100644
index 00000000000..9717075b8d0
--- /dev/null
+++ b/doc/user/project/milestones/img/milestones_project_milestone_page.png
Binary files differ
diff --git a/doc/user/project/milestones/img/milestones_promote_milestone.png b/doc/user/project/milestones/img/milestones_promote_milestone.png
new file mode 100644
index 00000000000..99bee1240d4
--- /dev/null
+++ b/doc/user/project/milestones/img/milestones_promote_milestone.png
Binary files differ
diff --git a/doc/user/project/milestones/img/sidebar.png b/doc/user/project/milestones/img/sidebar.png
deleted file mode 100644
index 274962a936c..00000000000
--- a/doc/user/project/milestones/img/sidebar.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md
index 20249926910..10e6321eb82 100644
--- a/doc/user/project/milestones/index.md
+++ b/doc/user/project/milestones/index.md
@@ -1,63 +1,111 @@
# Milestones
-Milestones allow you to organize issues and merge requests into a cohesive group,
-optionally setting a due date. A common use is keeping track of an upcoming
-software version. Milestones can be created per-project or per-group.
+## Overview
-## Creating a project milestone
+Milestones in GitLab are a way to track issues and merge requests created to achieve a broader goal in a certain period of time.
+
+Milestones allow you to organize issues and merge requests into a cohesive group, with an optional start date and an optional due date.
+
+## Project milestones and group milestones
+
+- **Project milestones** can be assigned to issues or merge requests in that project only.
+- **Group milestones** can be assigned to any issue or merge request of any project in that group.
+- In the [future](https://gitlab.com/gitlab-org/gitlab-ce/issues/36862), you will be able to assign group milestones to issues and merge reqeusts of projects in [subgroups](../../group/subgroups/index.md).
+
+## Creating milestones
+
+>**Note:**
+A permission level of `Developer` or higher is required to create milestones.
+
+### New project milestone
+
+To create a **project milestone**, navigate to **Issues > Milestones** in the project.
+
+Click the **New milestone** button. Enter the title, an optional description, an optional start date, and an optional due date. Click **Create milestone** to create the milestone.
+
+![New project milestone](img/milestones_new_project_milestone.png)
+
+### New group milestone
+
+To create a **group milestone**, follow similar steps from above to project milestones. Navigate to **Issues > Milestones** in the group and create it from there.
+
+![New group milestone](img/milestones_new_group_milestone.png)
+
+## Editing milestones
>**Note:**
-You need [Master permissions](../../permissions.md) in order to create a milestone.
+A permission level of `Developer` or higher is required to edit milestones.
+
+You can update a milestone by navigating to **Issues > Milestones** in the project or group and clicking the **Edit** button.
-You can find the milestones page under your project's **Issues âž” Milestones**.
-To create a new milestone, simply click the **New milestone** button when in the
-milestones page. A milestone can have a title, a description and start/due dates.
-Once you fill in all the details, hit the **Create milestone** button.
+You can delete a milestone by clicking the **Delete** button.
-![Creating a milestone](img/milestone_create.png)
+### Promoting project milestones to group milestones
-## Creating a group milestone
+If you are expanding from a few projects to a larger number of projects within the same group, you may want to share the same milestone among multiple projects in the same group. If you previously created a project milestone and now want to make it available for other milestones, you can promote it to a group milestone.
+
+From the project milestone list page, you can promote a project milestone to a group milestone. This will merge all project milestones across all projects in this group with the same name into a single group milestones. All issues and merge requests that previously were assigned one of these project milestones will now be assigned the new group milestones. This action cannot be reversed and the changes are permanent.
>**Note:**
-You need [Master permissions](../../permissions.md) in order to create a milestone.
+Not all features on the project milestone view are available on the group milestone view. If you promote a project milestone to a group milestone, you will lose these features. See [Milestone view](#milestone-view) to see which features are missing from the group milestone view.
+
+![Promote milestone](img/milestones_promote_milestone.png)
+
+## Assigning milestones from the sidebar
+
+Every issue and merge request can be assigned a milestone. The milestones are visible on every issue and merge request page, in the sidebar. They are also visible in the issue board. From the sidebar, you can assign or unassign a milestones to the object. You can also perform this as a [quick action](../quick_actions.md) in a comment. [As mentioned](#project-milestones-and-group-milestones), for a given issue or merge request, both project milestones and group milestones can be selected and assigned to the object.
+
+## Filtering issues and merge requests by milestone
+
+### Filtering in list pages
+
+From the project issue/merge request list pages and the group issue/merge request list pages, you can [filter](../../search/index.md#issues-and-merge-requests) by both group milestones and project milestones.
+
+### Filtering in issue boards
+
+From [project issue boards](../issue_board.md), you can filter by both group milestones and project milestones in the [search and filter bar](../../search/index.md#issue-boards).
+
+### Special milestone filters
+
+When filtering by milestone, in addition to choosing a specific project milestone or group milestone, you can choose a special milestone filter.
-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.
+- **No Milestone**: Show issues or merge requests with no assigned milestone.
+- **Upcoming**: Show issues or merge requests that have been assigned the open milestone that has the next upcoming due date (i.e. nearest due date in the future).
+- **Started**: Show issues or merge requests that have an assigned milestone with a start date that is before today.
-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 view
-## Milestone promotion
+Not all features in the project milestone view are available in the group milestone view. This table summarizes the differences:
-Project milestones can be promoted to group milestones if its project belongs to a group. When a milestone is promoted all other milestones across the group projects with the same title will be merged into it, which means all milestone's children like issues, merge requests and boards will be moved into the new promoted milestone.
-The promote button can be found in the milestone view or milestones list.
+| Feature | Project milestone view | Group milestone view |
+|---|:---:|:---:|
+| Title an description | ✓ | ✓ |
+| Issues assigned to milestone | ✓ | |
+| Merge requests assigned to milestone | ✓ | |
+| Participants and labels used | ✓ | |
+| Percentage complete | ✓ | ✓ |
+| Start date and due date | ✓ | ✓ |
+| Total issue time spent | ✓ | ✓ |
+| Total issue weight | ✓ | |
-## Special milestone filters
+The milestone view shows the title and description.
-In addition to the milestones that exist in the project or group, there are some
-special options available when filtering by milestone:
+### Project milestone features
-* **No Milestone** - only show issues or merge requests without a milestone.
-* **Upcoming** - show issues or merge request that belong to the next open
- milestone with a due date, by project. (For example: if project A has
- milestone v1 due in three days, and project B has milestone v2 due in a week,
- then this will show issues or merge requests from milestone v1 in project A
- and milestone v2 in project B.)
-* **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.
+These features are only available for project milestones and not group milestones.
-## Milestone sidebar
+- Issues assigned to the milestone are displayed in three columns: Unstarted issues, ongoing issues, and completed issues.
+- Merge requests assigned to the milestone are displayed in four columns: Work in progress merge requests, waiting for merge, rejected, and closed.
+- Participants and labels that are used in issues and merge requests that have the milestone assigned are displayed.
-The milestone sidebar shows percentage complete, start date and due date,
-issues, total issue weight, total issue time spent, and merge requests.
+### Milestone sidebar
-The percentage complete is calcualted as: Closed and merged merge requests plus all closed issues divided by
-total merge requests and issues.
+The milestone sidebar on the milestone view shows the following:
-![Milestone sidebar](img/sidebar.png)
+- Percentage complete, which is calculated as number of closed issues plus number of closed/merged merge requests divided by total number issues and merge requests.
+- The start date and due date.
+- The total time spent on all issues that have the milestone assigned.
-## Quick actions
+For project milestones only, the milestone sidebar shows the total issue weight of all issues that have the milestone assigned.
-[Quick actions](../quick_actions.md) are available for assigning and removing
-project and group milestones.
+![Project milestone page](img/milestones_project_milestone_page.png)
diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md
index 17dcd152363..15455a54627 100644
--- a/doc/user/project/new_ci_build_permissions_model.md
+++ b/doc/user/project/new_ci_build_permissions_model.md
@@ -91,7 +91,7 @@ to steal the tokens of other jobs.
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
+to projects and their project permissions. To migrate trigger to use new permission
model use **Take ownership**.
## Before GitLab 8.12
diff --git a/doc/user/project/pages/getting_started_part_four.md b/doc/user/project/pages/getting_started_part_four.md
index bd0cb437924..e4ee2f7cdfa 100644
--- a/doc/user/project/pages/getting_started_part_four.md
+++ b/doc/user/project/pages/getting_started_part_four.md
@@ -1,16 +1,13 @@
-# GitLab Pages from A to Z: Part 4
+---
+last_updated: 2018-02-16
+author: Marcia Ramos
+author_gitlab: marcia
+level: intermediate
+article_type: user guide
+date: 2017-02-22
+---
-> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide ||
-> **Level**: intermediate ||
-> **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)
-- [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**
-
-## Creating and Tweaking `.gitlab-ci.yml` for GitLab Pages
+# Creating and Tweaking GitLab CI/CD for GitLab Pages
[GitLab CI](https://about.gitlab.com/gitlab-ci/) serves
numerous purposes, to build, test, and deploy your app
@@ -19,10 +16,13 @@ from GitLab through
methods. You will need it to build your website with GitLab Pages,
and deploy it to the Pages server.
+To implement GitLab CI/CD, the first thing we need is a configuration
+file called `.gitlab-ci.yml` placed at your website's root directory.
+
What this file actually does is telling the
[GitLab Runner](https://docs.gitlab.com/runner/) to run scripts
as you would do from the command line. The Runner acts as your
-terminal. GitLab CI tells the Runner which commands to run.
+terminal. GitLab CI/CD tells the Runner which commands to run.
Both are built-in in GitLab, and you don't need to set up
anything for them to work.
@@ -34,7 +34,7 @@ need to understand just a few things to be able to write our own
with its own syntax. You can always check your CI syntax with
the [GitLab CI Lint Tool](https://gitlab.com/ci/lint).
-**Practical Example:**
+## Practical example
Let's consider you have a [Jekyll](https://jekyllrb.com/) site.
To build it locally, you would open your terminal, and run `jekyll build`.
@@ -384,7 +384,3 @@ in parallel, or build a custom pipeline](https://about.gitlab.com/2016/07/29/the
[pulling specific directories from different projects](https://about.gitlab.com/2016/12/07/building-a-new-gitlab-docs-site-with-nanoc-gitlab-ci-and-gitlab-pages/)
to deploy this website you're looking at, docs.gitlab.com.
- On this blog post, we teach you [how to use GitLab Pages to produce a code coverage report](https://about.gitlab.com/2016/11/03/publish-code-coverage-report-with-gitlab-pages/).
-
-|||
-|:--|--:|
-|[**↠Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates**](getting_started_part_three.md)||
diff --git a/doc/user/project/pages/getting_started_part_one.md b/doc/user/project/pages/getting_started_part_one.md
index 1e19f422d94..290dfa5af84 100644
--- a/doc/user/project/pages/getting_started_part_one.md
+++ b/doc/user/project/pages/getting_started_part_one.md
@@ -1,31 +1,28 @@
-# GitLab Pages from A to Z: Part 1
-
-> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide ||
-> **Level**: beginner ||
-> **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 from A to Z
-
-This is a comprehensive guide, made for those who want to
+---
+last_updated: 2018-02-16
+author: Marcia Ramos
+author_gitlab: marcia
+level: beginner
+article_type: user guide
+date: 2017-02-22
+---
+
+# Static sites and GitLab Pages domains
+
+This document is the beginning of a comprehensive guide, made for those who want to
publish a website with GitLab Pages but aren't familiar with
the entire process involved.
-This [first part](#what-you-need-to-know-before-getting-started) of this series will present you to the concepts of
+This [first document](#what-you-need-to-know-before-getting-started) of this series will present you to the concepts of
static sites, and go over how the default Pages domains work.
-The [second part](getting_started_part_two.md) covers how to get started with GitLab Pages: deploy
+The [second document](getting_started_part_two.md) covers how to get started with GitLab Pages: deploy
a website from a forked project or create a new one from scratch.
-The [third part](getting_started_part_three.md) will show you how to set up a custom domain or subdomain
+The [third document](getting_started_part_three.md) will show you how to set up a custom domain or subdomain
to your site already deployed.
-The [fourth part](getting_started_part_four.md) will show you how to create and tweak GitLab CI for
+The [fourth document](getting_started_part_four.md) will show you how to create and tweak GitLab CI for
GitLab Pages.
To **enable** GitLab Pages for GitLab CE (Community Edition)
@@ -113,6 +110,4 @@ You can only create the highest level group website.
- On your GitLab instance, replace `gitlab.io` above with your
Pages server domain. Ask your sysadmin for this information.
-|||
-|:--|--:|
-||[**Part 2: Quick start guide - Setting up GitLab Pages →**](getting_started_part_two.md)|
+_Read on about [Projects for GitLab Pages and URL structure](getting_started_part_two.md)._
diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md
index 0096f8507d2..430fe3af1f8 100644
--- a/doc/user/project/pages/getting_started_part_three.md
+++ b/doc/user/project/pages/getting_started_part_three.md
@@ -1,27 +1,19 @@
---
-last_updated: 2017-09-28
+last_updated: 2018-02-16
+author: Marcia Ramos
+author_gitlab: marcia
+level: beginner
+article_type: user guide
+date: 2017-02-22
---
-# GitLab Pages from A to Z: Part 3
+# GitLab Pages custom domains and SSL/TLS Certificates
-> **[Article Type](../../../development/writing_documentation.md#types-of-technical-articles)**: user guide ||
-> **Level**: beginner ||
-> **Author**: [Marcia Ramos](https://gitlab.com/marcia) ||
-> **Publication date:** 2017-02-22 ||
-> **Last updated**: 2017-09-28
-
-- [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)
-- **Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates**
-- [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md)
-
-## Setting Up Custom Domains - DNS Records and SSL/TLS Certificates
-
-As described in the previous part of this series, setting up GitLab Pages with custom domains, and adding SSL/TLS certificates to them, are optional features of GitLab Pages.
+Setting up GitLab Pages with custom domains, and adding SSL/TLS certificates to them, are optional features of GitLab Pages.
These steps assume you've already [set your site up](getting_started_part_two.md) and and it's served under the default Pages domain `namespace.gitlab.io`, or `namespace.gitlab.io/project-name`.
-### Adding your custom domain to GitLab Pages
+## Adding your custom domain to GitLab Pages
To use one or more custom domain with your Pages site, there are two things
you should consider first, which we'll cover in this guide:
@@ -36,7 +28,7 @@ Let's start from the beginning with [DNS records](#dns-records).
If you already know how they work and want to skip the introduction to DNS,
you may be interested in skipping it until the [TL;DR](#tl-dr) section below.
-### DNS Records
+## DNS Records
A Domain Name System (DNS) web service routes visitors to websites
by translating domain names (such as `www.example.com`) into the
@@ -70,9 +62,9 @@ for the most popular hosting services:
- [Microsoft](https://msdn.microsoft.com/en-us/library/bb727018.aspx)
If your hosting service is not listed above, you can just try to
-search the web for "how to add dns record on <my hosting service>".
+search the web for `how to add dns record on <my hosting service>`.
-#### DNS A record
+### DNS A record
In case you want to point a root domain (`example.com`) to your
GitLab Pages site, deployed to `namespace.gitlab.io`, you need to
@@ -87,7 +79,7 @@ running on your instance).
![DNS A record pointing to GitLab.com Pages server](img/dns_add_new_a_record_example_updated.png)
-#### DNS CNAME record
+### DNS CNAME record
In case you want to point a subdomain (`hello-world.example.com`)
to your GitLab Pages site initially deployed to `namespace.gitlab.io`,
@@ -103,12 +95,32 @@ without any `/project-name`.
![DNS CNAME record pointing to GitLab.com project](img/dns_cname_record_example.png)
+#### DNS TXT record
+
+Unless your GitLab administrator has [disabled custom domain verification](../../../administration/pages/index.md#custom-domain-verification),
+you'll have to prove that you own the domain by creating a `TXT` record
+containing a verification code. The code will be displayed after you
+[add your custom domain to GitLab Pages settings](#add-your-custom-domain-to-gitlab-pages-settings).
+
+If using a [DNS A record](#dns-a-record), you can place the TXT record directly
+under the domain. If using a [DNS CNAME record](#dns-cname-record), the two record types won't
+co-exist, so you need to place the TXT record in a special subdomain of its own.
+
#### TL;DR
+If the domain has multiple uses (e.g., you host email on it as well):
+
| From | DNS Record | To |
| ---- | ---------- | -- |
| domain.com | A | 52.167.214.135 |
-| subdomain.domain.com | CNAME | namespace.gitlab.io |
+| domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff |
+
+If the domain is dedicated to GitLab Pages use and no other services run on it:
+
+| From | DNS Record | To |
+| ---- | ---------- | -- |
+| subdomain.domain.com | CNAME | gitlab.io |
+| _gitlab-pages-verification-code.subdomain.domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff |
> **Notes**:
>
@@ -119,7 +131,7 @@ domain. E.g., **do not** point your `subdomain.domain.com` to
`namespace.gitlab.io.` or `namespace.gitlab.io/`.
> - GitLab Pages IP on GitLab.com [has been changed](https://about.gitlab.com/2017/03/06/we-are-changing-the-ip-of-gitlab-pages-on-gitlab-com/) from `104.208.235.32` to `52.167.214.135`.
-### Add your custom domain to GitLab Pages settings
+## Add your custom domain to GitLab Pages settings
Once you've set the DNS record, you'll need navigate to your project's
**Setting > Pages** and click **+ New domain** to add your custom domain to
@@ -129,6 +141,17 @@ your site will be accessible only via HTTP:
![Add new domain](img/add_certificate_to_pages.png)
+Once you have added a new domain, you will need to **verify your ownership**
+(unless the GitLab administrator has disabled this feature). A verification code
+will be shown to you; add it as a [DNS TXT record](#dns-txt-record), then press
+the "Verify ownership" button to activate your new domain:
+
+![Verify your domain](img/verify_your_domain.png)
+
+Once your domain has been verified, leave the verification record in place -
+your domain will be periodically reverified, and may be disabled if the record
+is removed.
+
You can add more than one alias (custom domains and subdomains) to the same project.
An alias can be understood as having many doors leading to the same room.
@@ -136,13 +159,13 @@ All the aliases you've set to your site will be listed on **Setting > Pages**.
From that page, you can view, add, and remove them.
Note that [DNS propagation may take some time (up to 24h)](http://www.inmotionhosting.com/support/domain-names/dns-nameserver-changes/domain-names-dns-changes),
-although it's usually a matter of minutes to complete. Until it does, visit attempts
-to your domain will respond with a 404.
+although it's usually a matter of minutes to complete. Until it does, verification
+will fail and attempts to visit your domain will respond with a 404.
Read through the [general documentation on GitLab Pages](introduction.md#add-a-custom-domain-to-your-pages-website) to learn more about adding
custom domains to GitLab Pages sites.
-### SSL/TLS Certificates
+## SSL/TLS Certificates
Every GitLab Pages project on GitLab.com will be available under
HTTPS for the default Pages domain (`*.gitlab.io`). Once you set
@@ -155,16 +178,41 @@ Certificates are NOT required to add to your custom
(sub)domain on your GitLab Pages project, though they are
highly recommendable.
-The importance of having any website securely served under HTTPS
-is explained on the introductory section of the blog post
-[Secure GitLab Pages with StartSSL](https://about.gitlab.com/2016/06/24/secure-gitlab-pages-with-startssl/#https-a-quick-overview).
+Let's start with an introduction to the importance of HTTPS.
+Alternatively, jump ahead to [adding certificates to your project](#adding-certificates-to-your-project).
+
+### Why should I care about HTTPS?
+
+This might be your first question. If our sites are hosted by GitLab Pages,
+they are static, hence we are not dealing with server-side scripts
+nor credit card transactions, then why do we need secure connections?
-The reason why certificates are so important is that they encrypt
+Back in the 1990s, where HTTPS came out, [SSL](https://en.wikipedia.org/wiki/Transport_Layer_Security#SSL_1.0.2C_2.0_and_3.0) was considered a "special"
+security measure, necessary just for big companies, like banks and shoppings sites
+with financial transactions.
+Now we have a different picture. [According to Josh Aas](https://letsencrypt.org/2015/10/29/phishing-and-malware.html), Executive Director at [ISRG](https://en.wikipedia.org/wiki/Internet_Security_Research_Group):
+
+> _We’ve since come to realize that HTTPS is important for almost all websites. It’s important for any website that allows people to log in with a password, any website that [tracks its users](https://www.washingtonpost.com/news/the-switch/wp/2013/12/10/nsa-uses-google-cookies-to-pinpoint-targets-for-hacking/) in any way, any website that [doesn’t want its content altered](http://arstechnica.com/tech-policy/2014/09/why-comcasts-javascript-ad-injections-threaten-security-net-neutrality/), and for any site that offers content people might not want others to know they are consuming. We’ve also learned that any site not secured by HTTPS [can be used to attack other sites](http://krebsonsecurity.com/2015/04/dont-be-fodder-for-chinas-great-cannon/)._
+
+Therefore, the reason why certificates are so important is that they encrypt
the connection between the **client** (you, me, your visitors)
and the **server** (where you site lives), through a keychain of
authentications and validations.
-### Issuing Certificates
+How about taking Josh's advice and protecting our sites too? We will be
+well supported, and we'll contribute to a safer internet.
+
+### Organizations supporting HTTPS
+
+There is a huge movement in favor of securing all the web. W3C fully
+[supports the cause](https://w3ctag.github.io/web-https/) and explains very well
+the reasons for that. Richard Barnes, a writer for Mozilla Security Blog,
+suggested that [Firefox would deprecate HTTP](https://blog.mozilla.org/security/2015/04/30/deprecating-non-secure-http/),
+and would no longer accept unsecured connections. Recently, Mozilla published a
+[communication](https://blog.mozilla.org/security/2016/03/29/march-2016-ca-communication/)
+reiterating the importance of HTTPS.
+
+## Issuing Certificates
GitLab Pages accepts [PEM](https://support.quovadisglobal.com/kb/a37/what-is-pem-format.aspx) certificates issued by
[Certificate Authorities (CA)](https://en.wikipedia.org/wiki/Certificate_authority)
@@ -193,7 +241,7 @@ Their certs are valid up to 15 years. Read through the tutorial on
Regardless the CA you choose, the steps to add your certificate to
your Pages project are the same.
-#### What do you need
+### What do you need
1. A PEM certificate
1. An intermediate certificate
@@ -203,7 +251,7 @@ your Pages project are the same.
These fields are found under your **Project**'s **Settings** > **Pages** > **New Domain**.
-#### What's what?
+### What's what?
- A PEM certificate is the certificate generated by the CA,
which needs to be added to the field **Certificate (PEM)**.
@@ -216,7 +264,7 @@ are one of these cases.
- A public key is an encrypted key which validates
your PEM against your domain.
-#### Now what?
+### Now what?
Now that you hopefully understand why you need all
of this, it's simple:
@@ -233,6 +281,4 @@ just jumping a line between them.
regular text editors. Always use code editors (such as
Sublime Text, Atom, Dreamweaver, Brackets, etc).
-|||
-|:--|--:|
-|[**↠Part 2: Quick start guide - Setting up GitLab Pages**](getting_started_part_two.md)|[**Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages →**](getting_started_part_four.md)|
+_Read on about [Creating and Tweaking GitLab CI/CD for GitLab Pages](getting_started_part_four.md)_
diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md
index 64de0463dad..2274cac8ace 100644
--- a/doc/user/project/pages/getting_started_part_two.md
+++ b/doc/user/project/pages/getting_started_part_two.md
@@ -1,27 +1,23 @@
-# GitLab Pages from A to Z: Part 2
+---
+last_updated: 2018-02-16
+author: Marcia Ramos
+author_gitlab: marcia
+level: beginner
+article_type: user guide
+date: 2017-02-22
+---
-> **Article [Type](../../../development/writing_documentation.html#types-of-technical-articles)**: user guide ||
-> **Level**: beginner ||
-> **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**
-- [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)
-
-## Setting up GitLab Pages
-
-For a complete step-by-step tutorial, please read the
-blog post [Hosting on GitLab.com with GitLab Pages](https://about.gitlab.com/2016/04/07/gitlab-pages-setup/). The following sections will explain
-what do you need and why do you need them.
+# Projects for GitLab Pages and URL structure
## What you need to get started
+To get started with GitLab Pages, you need:
+
1. A project
1. A configuration file (`.gitlab-ci.yml`) to deploy your site
1. A specific `job` called `pages` in the configuration file
that will make GitLab aware that you are deploying a GitLab Pages website
+1. A `public` directory with the content of the website
Optional Features:
@@ -51,35 +47,26 @@ containing the most popular SSGs templates.
Watch the [video tutorial](https://youtu.be/TWqh9MtT4Bg) we've
created for the steps below.
-1. Choose your SSG template
-1. Fork a project from the [Pages group](https://gitlab.com/pages)
-1. Remove the fork relationship by navigating to your **Project**'s **Settings** > **Edit Project**
+1. [Fork a sample project](../../../gitlab-basics/fork-project.md) from the [Pages group](https://gitlab.com/pages)
+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**
+1. Optionally, remove the fork relationship by navigating to your project's **Settings** > expanding **Advanced settings** and scrolling down to **Remove fork relashionship**:
![remove fork relashionship](img/remove_fork_relashionship.png)
-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**
-
To turn a **project website** forked from the Pages group into a **user/group** website, you'll need to:
-- Rename it to `namespace.gitlab.io`: navigate to **Project**'s **Settings** > **Edit Project** > **Rename repository**
+- Rename it to `namespace.gitlab.io`: navigate to project's **Settings** > expand **Advanced settings** > and scroll down to **Rename repository**
- Adjust your SSG's [base URL](#urls-and-baseurls) to from `"project-name"` to `""`. This setting will be at a different place for each SSG, as each of them have their own structure and file tree. Most likelly, it will be in the SSG's config file.
> **Notes:**
>
->1. Why do I need to remove the fork relationship?
+> Why do I need to remove the fork relationship?
>
-> Unless you want to contribute to the original project,
+> Unless you want to contribute to the original project,
you won't need it connected to the upstream. A
[fork](https://about.gitlab.com/2016/12/01/how-to-keep-your-fork-up-to-date-with-its-origin/#fork)
is useful for submitting merge requests to the upstream.
->
-> 2. Why do I need to enable Shared Runners?
->
-> Shared Runners will run the script set by your GitLab CI
-configuration file. They're enabled by default to new projects,
-but not to forks.
### Create a project from scratch
@@ -88,9 +75,9 @@ click **New project**, and name it considering the
[practical examples](getting_started_part_one.md#practical-examples).
1. Clone it to your local computer, add your website
files to your project, add, commit and push to GitLab.
-1. From the your **Project**'s page, click **Set up CI**:
+1. From the your **Project**'s page, click **Set up CI/CD**:
- ![setup GitLab CI](img/setup_ci.png)
+ ![setup GitLab CI/CD](img/setup_ci.png)
1. Choose one of the templates from the dropbox menu.
Pick up the template corresponding to the SSG you're using (or plain HTML).
@@ -98,7 +85,7 @@ Pick up the template corresponding to the SSG you're using (or plain HTML).
![gitlab-ci templates](img/choose_ci_template.png)
Once you have both site files and `.gitlab-ci.yml` in your project's
-root, GitLab CI will build your site and deploy it with Pages.
+root, GitLab CI/CD will build your site and deploy it with Pages.
Once the first build passes, you see your site is live by
navigating to your **Project**'s **Settings** > **Pages**,
where you'll find its default URL.
@@ -108,7 +95,7 @@ where you'll find its default URL.
> - GitLab Pages [supports any SSG](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/), but,
if you don't find yours among the templates, you'll need
to configure your own `.gitlab-ci.yml`. Do do that, please
-read through the article [Creating and Tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md). New SSGs are very welcome among
+read through the article [Creating and Tweaking GitLab CI/CD for GitLab Pages](getting_started_part_four.md). New SSGs are very welcome among
the [example projects](https://gitlab.com/pages). If you set
up a new one, please
[contribute](https://gitlab.com/pages/pages.gitlab.io/blob/master/CONTRIBUTING.md)
@@ -121,7 +108,7 @@ you can run `git init` in your local website directory, add the
remote URL: `git remote add origin git@gitlab.com:namespace/project-name.git`,
then add, commit, and push.
-### URLs and Baseurls
+## URLs and Baseurls
Every Static Site Generator (SSG) default configuration expects
to find your website under a (sub)domain (`example.com`), not
@@ -149,11 +136,7 @@ example we've just mentioned, you'd have to change Jekyll's `_config.yml` to:
baseurl: ""
```
-### Custom Domains
+## Custom Domains
-GitLab Pages supports custom domains and subdomains, served under HTTPS or HTTPS.
+GitLab Pages supports custom domains and subdomains, served under HTTP or HTTPS.
Please check the [next part](getting_started_part_three.md) of this series for an overview.
-
-|||
-|:--|--:|
-|[**↠Part 1: Static sites, domains, DNS records, and SSL/TLS certificates**](getting_started_part_one.md)|[**Setting Up Custom Domains - DNS Records and SSL/TLS Certificates →**](getting_started_part_three.md)|
diff --git a/doc/user/project/pages/img/verify_your_domain.png b/doc/user/project/pages/img/verify_your_domain.png
new file mode 100644
index 00000000000..89c69cac9a5
--- /dev/null
+++ b/doc/user/project/pages/img/verify_your_domain.png
Binary files differ
diff --git a/doc/user/project/pages/index.md b/doc/user/project/pages/index.md
index 8404d789de6..a65aa758198 100644
--- a/doc/user/project/pages/index.md
+++ b/doc/user/project/pages/index.md
@@ -37,10 +37,10 @@ to secure them.
Read the following tutorials to know more about:
-- [Static websites and GitLab Pages domains](getting_started_part_one.md)
-- [Forking projects and creating new ones from scratch, URLs and baseurls](getting_started_part_two.md)
-- [Custom domains and subdomains, DNS records, SSL/TLS certificates](getting_started_part_three.md)
-- [How to create your own `.gitlab-ci.yml` for your site](getting_started_part_four.md)
+- [Static websites and GitLab Pages domains](getting_started_part_one.md): Understand what is a static website, and how GitLab Pages default domains work
+- [Projects for GitLab Pages and URL structure](getting_started_part_two.md): Forking projects and creating new ones from scratch, understanding URLs structure and baseurls
+- [GitLab Pages custom domains and SSL/TLS Certificates](getting_started_part_three.md): How to add custom domains and subdomains to your website, configure DNS records, and SSL/TLS certificates
+- [Creating and Tweaking GitLab CI/CD for GitLab Pages](getting_started_part_four.md): Understand how to create your own `.gitlab-ci.yml` for your site
- [Technical aspects, custom 404 pages, limitations](introduction.md)
- [Hosting on GitLab.com with GitLab Pages](https://about.gitlab.com/2016/04/07/gitlab-pages-setup/) (outdated)
@@ -54,7 +54,6 @@ _Blog posts for securing GitLab Pages custom domains with SSL/TLS certificates:_
- [CloudFlare](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/)
- [Let's Encrypt](https://about.gitlab.com/2016/04/11/tutorial-securing-your-gitlab-pages-with-tls-and-letsencrypt/) (outdated)
-- [StartSSL](https://about.gitlab.com/2016/06/24/secure-gitlab-pages-with-startssl/) (deprecated)
## Advanced use
diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md
index 3ab88948fbd..0b5076b8c5d 100644
--- a/doc/user/project/pages/introduction.md
+++ b/doc/user/project/pages/introduction.md
@@ -316,6 +316,47 @@ or various static site generators. Contributions are very welcome.
Visit the GitLab Pages group for a full list of example projects:
<https://gitlab.com/groups/pages>.
+### Serving compressed assets
+
+Most modern browsers support downloading files in a compressed format. This
+speeds up downloads by reducing the size of files.
+
+Before serving an uncompressed file, Pages will check whether the same file
+exists with a `.gz` extension. If it does, and the browser supports receiving
+compressed files, it will serve that version instead of the uncompressed one.
+
+To take advantage of this feature, the artifact you upload to the Pages should
+have this structure:
+
+```
+public/
+├─┬ index.html
+│ └ index.html.gz
+│
+├── css/
+│   └─┬ main.css
+│ └ main.css.gz
+│
+└── js/
+ └─┬ main.js
+ â”” main.js.gz
+```
+
+This can be achieved by including a `script:` command like this in your
+`.gitlab-ci.yml` pages job:
+
+```yaml
+pages:
+ # Other directives
+ script:
+ - # build the public/ directory first
+ - find public -type f -iregex '.*\.\(htm\|html\|txt\|text\|js\|css\)$' -execdir gzip -f --keep {} \;
+```
+
+By pre-compressing the files and including both versions in the artifact, Pages
+can serve requests for both compressed and uncompressed content without
+needing to compress files on-demand.
+
### Add a custom domain to your Pages website
For a complete guide on Pages domains, read through the article
@@ -373,7 +414,7 @@ configuration.
If the case of `404.html`, there are different scenarios. For example:
- If you use project Pages (served under `/projectname/`) and try to access
- `/projectname/non/exsiting_file`, GitLab Pages will try to serve first
+ `/projectname/non/existing_file`, GitLab Pages will try to serve first
`/projectname/404.html`, and then `/404.html`.
- If you use user/group Pages (served under `/`) and try to access
`/non/existing_file` GitLab Pages will try to serve `/404.html`.
diff --git a/doc/user/project/pipelines/img/pipeline_schedule_play.png b/doc/user/project/pipelines/img/pipeline_schedule_play.png
new file mode 100644
index 00000000000..f594ceee19d
--- /dev/null
+++ b/doc/user/project/pipelines/img/pipeline_schedule_play.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
index 50d9d184b05..2ab2061db94 100644
--- a/doc/user/project/pipelines/img/pipeline_schedules_list.png
+++ b/doc/user/project/pipelines/img/pipeline_schedules_list.png
Binary files differ
diff --git a/doc/user/project/pipelines/schedules.md b/doc/user/project/pipelines/schedules.md
index 2101e3b1d58..a13b1b4561c 100644
--- a/doc/user/project/pipelines/schedules.md
+++ b/doc/user/project/pipelines/schedules.md
@@ -12,7 +12,7 @@ month on the 22nd for a certain branch.
In order to schedule a pipeline:
-1. Navigate to your project's **Pipelines âž” Schedules** and click the
+1. Navigate to your project's **CI / CD âž” Schedules** and click the
**New Schedule** button.
1. Fill in the form
1. Hit **Save pipeline schedule** for the changes to take effect.
@@ -31,6 +31,20 @@ is installed on.
![Schedules list](img/pipeline_schedules_list.png)
+### Running a scheduled pipeline manually
+
+> [Introduced][ce-15700] in GitLab 10.4.
+
+To trigger a pipeline schedule manually, click the "Play" button:
+
+![Play Pipeline Schedule](img/pipeline_schedule_play.png)
+
+This will schedule a background job to run the pipeline schedule. A flash
+message will provide a link to the CI/CD Pipeline index page.
+
+To help avoid abuse, users are rate limited to triggering a pipeline once per
+minute.
+
### Making use of scheduled pipeline variables
> [Introduced][ce-12328] in GitLab 9.4.
@@ -90,4 +104,5 @@ 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
+[ce-15700]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15700
[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 43451844f2d..14f2e522f01 100644
--- a/doc/user/project/pipelines/settings.md
+++ b/doc/user/project/pipelines/settings.md
@@ -27,6 +27,13 @@ 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.
+### Timeout overriding on Runner level
+
+> - [Introduced][ce-17221] in GitLab 10.7.
+
+Project defined timeout (either specific timeout set by user or the default
+60 minutes timeout) may be [overridden on Runner level][timeout overriding].
+
## Custom CI config path
> - [Introduced][ce-12509] in GitLab 9.4.
@@ -99,7 +106,7 @@ 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
+## Pipeline Badges
In the pipelines settings page you can find pipeline status and test coverage
badges for your project. The latest successful pipeline will be used to read
@@ -152,5 +159,7 @@ into your `README.md`:
[var]: ../../../ci/yaml/README.md#git-strategy
[coverage report]: #test-coverage-parsing
+[timeout overriding]: ../../../ci/runners/README.html#setting-maximum-job-timeout-for-a-runner
[ce-9362]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9362
[ce-12509]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12509
+[ce-17221]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17221
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index e81e935e37d..442fc978284 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -39,3 +39,5 @@ do.
| `/board_move ~column` | Move issue to column on the board |
| `/duplicate #issue` | Closes this issue and marks it as a duplicate of another issue |
| `/move path/to/project` | Moves issue to another project |
+| `/tableflip` | Append the comment with `(╯°□°)╯︵ â”»â”â”»` |
+| `/shrug` | Append the comment with `¯\_(ツ)_/¯` | \ No newline at end of file
diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md
index 26c55891b3c..9d16a4c74f2 100644
--- a/doc/user/project/repository/branches/index.md
+++ b/doc/user/project/repository/branches/index.md
@@ -18,7 +18,7 @@ When you create a new [project](../../index.md), GitLab sets `master` as the def
branch for your project. You can choose another branch to be your project's
default under your project's **Settings > General**.
-The default branch is the branched affected by the
+The default branch is the branch affected by the
[issue closing pattern](../../issues/automatic_issue_closing.md),
which means that _an issue will be closed when a merge request is merged to
the **default branch**_.
diff --git a/doc/user/project/repository/gpg_signed_commits/index.md b/doc/user/project/repository/gpg_signed_commits/index.md
index 6b9976d133c..d41be0989d2 100644
--- a/doc/user/project/repository/gpg_signed_commits/index.md
+++ b/doc/user/project/repository/gpg_signed_commits/index.md
@@ -1,6 +1,11 @@
# Signing commits with GPG
-> [Introduced][ce-9546] in GitLab 9.5.
+NOTE: **Note:**
+The term GPG is used for all OpenPGP/PGP/GPG related material and
+implementations.
+
+> - [Introduced][ce-9546] in GitLab 9.5.
+> - Subkeys support was added in GitLab 10.1.
GitLab can show whether a commit is verified or not when signed with a GPG key.
All you need to do is upload the public GPG key in your profile settings.
diff --git a/doc/user/project/repository/img/jupyter_notebook.png b/doc/user/project/repository/img/jupyter_notebook.png
new file mode 100644
index 00000000000..52c5c5aea32
--- /dev/null
+++ b/doc/user/project/repository/img/jupyter_notebook.png
Binary files differ
diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md
index 9501db88f57..376f4e3cbe4 100644
--- a/doc/user/project/repository/index.md
+++ b/doc/user/project/repository/index.md
@@ -18,7 +18,7 @@ documentation.
> **Important:**
For security reasons, when using the command line, we strongly recommend
-you to [connect with GitLab via SSH](../../../ssh/README.md).
+that you [connect with GitLab via SSH](../../../ssh/README.md).
## Files
@@ -53,6 +53,22 @@ To get started with the command line, please read through the
Use GitLab's [file finder](../../../workflow/file_finder.md) to search for files in a repository.
+### Jupyter Notebook files
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/2508) in GitLab 9.1
+
+[Jupyter][jupyter] Notebook (previously IPython Notebook) files are used for
+interactive computing in many fields and contain a complete record of the
+user's sessions and include code, narrative text, equations and rich output.
+
+When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be
+rendered to HTML when viewed.
+
+![Jupyter Notebook Rich Output](img/jupyter_notebook.png)
+
+Interactive features, including JavaScript plots, will not work when viewed in
+GitLab.
+
## Branches
When you submit changes in a new [branch](branches/index.md), you create a new version
@@ -66,9 +82,8 @@ 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.
+With [GitLab Starter](https://about.gitlab.com/products/), you can also request
+[approval](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers.
To create, delete, and [branches](branches/index.md) via GitLab's UI:
@@ -117,8 +132,9 @@ Use GPG to [sign your commits](gpg_signed_commits/index.md).
## Repository size
-In GitLab.com, your repository size limit it 10GB. For other instances,
-the repository size is limited by your system administrators.
+On GitLab.com, your [repository size limit is 10GB](../../gitlab_com/index.md#repository-size-limit)
+(including LFS). 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).
@@ -147,13 +163,17 @@ Select branches to compare and view the changes inline:
Find it under your project's **Repository > Compare**.
-## Locked files (EEP)
+## Locked files
+
+> Available in [GitLab Premium](https://about.gitlab.com/products/).
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/).
+[GitLab Premium](https://about.gitlab.com/products/).
## Repository's API
You can access your repos via [repository API](../../../api/repositories.md).
+
+[jupyter]: https://jupyter.org
diff --git a/doc/user/project/repository/web_editor.md b/doc/user/project/repository/web_editor.md
index db0c3ed9d59..33c9a1a4d6b 100644
--- a/doc/user/project/repository/web_editor.md
+++ b/doc/user/project/repository/web_editor.md
@@ -45,7 +45,7 @@ has already been created, which creates a link to the license itself.
![New file button](img/web_editor_template_dropdown_buttons.png)
>**Note:**
-The **Set up CI** button will not appear on an empty repository. You have to at
+The **Set up CI/CD** button will not appear on an empty repository. You have to at
least add a file in order for the button to show up.
## Upload a file
diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md
index 1b8a84c9599..eb0ac221e30 100644
--- a/doc/user/project/settings/import_export.md
+++ b/doc/user/project/settings/import_export.md
@@ -22,6 +22,7 @@
> in the import side is required to map the users, based on email or username.
> Otherwise, a supplementary comment is left to mention the original author and
> the MRs, notes or issues will be owned by the importer.
+> - Control project Import/Export with the [API](../../../api/project_import_export.md).
Existing projects running on any GitLab instance or GitLab.com can be exported
with all their related data and be moved into a new GitLab instance.
@@ -30,7 +31,8 @@ with all their related data and be moved into a new GitLab instance.
| GitLab version | Import/Export version |
| ---------------- | --------------------- |
-| 10.3 to current | 0.2.1 |
+| 10.4 to current | 0.2.2 |
+| 10.3 | 0.2.1 |
| 10.0 | 0.2.0 |
| 9.4.0 | 0.1.8 |
| 9.2.0 | 0.1.7 |
@@ -55,11 +57,11 @@ The following items will be exported:
- Project configuration including web hooks and services
- Issues with comments, merge requests with diffs and comments, labels, milestones, snippets,
and other project entities
+- LFS objects
The following items will NOT be exported:
- Build traces and artifacts
-- LFS objects
- Container registry images
- CI variables
- Any encrypted tokens
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index f01fa5b1860..888dd0e143a 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -34,7 +34,7 @@ Set up your project's merge request settings:
- Set up the merge request method (merge commit, [fast-forward merge](../merge_requests/fast_forward_merge.html)).
- Merge request [description templates](../description_templates.md#description-templates).
-- Enable [merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html#merge-request-approvals), _available in [GitLab Enterprise Edition Starter](https://about.gitlab.com/gitlab-ee/)_.
+- Enable [merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html#merge-request-approvals), _available in [GitLab Starter](https://about.gitlab.com/products/)_.
- Enable [merge only of pipeline succeeds](../merge_requests/merge_when_pipeline_succeeds.md).
- Enable [merge only when all discussions are resolved](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-discussions-are-resolved).
@@ -42,7 +42,7 @@ Set up your project's merge request settings:
### Service Desk
-Enable [Service Desk](https://docs.gitlab.com/ee/user/project/service_desk.html) for your project to offer customer support. Service Desk is available in [GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/).
+Enable [Service Desk](https://docs.gitlab.com/ee/user/project/service_desk.html) for your project to offer customer support. Service Desk is available in [GitLab Premium](https://about.gitlab.com/products/).
### Export project
diff --git a/doc/user/project/wiki/img/wiki_move_page_1.png b/doc/user/project/wiki/img/wiki_move_page_1.png
new file mode 100644
index 00000000000..0331c9d3a5c
--- /dev/null
+++ b/doc/user/project/wiki/img/wiki_move_page_1.png
Binary files differ
diff --git a/doc/user/project/wiki/img/wiki_move_page_2.png b/doc/user/project/wiki/img/wiki_move_page_2.png
new file mode 100644
index 00000000000..a8e0c055051
--- /dev/null
+++ b/doc/user/project/wiki/img/wiki_move_page_2.png
Binary files differ
diff --git a/doc/user/project/wiki/index.md b/doc/user/project/wiki/index.md
index c0b8a87f038..d084ee41d8a 100644
--- a/doc/user/project/wiki/index.md
+++ b/doc/user/project/wiki/index.md
@@ -64,6 +64,18 @@ effect.
You can find the **Delete** button only when editing a page. Click on it and
confirm you want the page to be deleted.
+## Moving a wiki page
+
+You can move a wiki page from one directory to another by specifying the full
+path in the wiki page title in the [edit](#editing-a-wiki-page) form.
+
+![Moving a page](img/wiki_move_page_1.png)
+
+![After moving a page](img/wiki_move_page_2.png)
+
+In order to move a wiki page to the root directory, the wiki page title must
+be preceded by the slash (`/`) character.
+
## Viewing a list of all created wiki pages
Every wiki has a sidebar from which a short list of the created pages can be
diff --git a/doc/workflow/lfs/img/lfs-icon.png b/doc/workflow/lfs/img/lfs-icon.png
new file mode 100644
index 00000000000..eef9a14187a
--- /dev/null
+++ b/doc/workflow/lfs/img/lfs-icon.png
Binary files differ
diff --git a/doc/workflow/lfs/lfs_administration.md b/doc/workflow/lfs/lfs_administration.md
index d768b73286d..f824756c10c 100644
--- a/doc/workflow/lfs/lfs_administration.md
+++ b/doc/workflow/lfs/lfs_administration.md
@@ -5,6 +5,7 @@ Documentation on how to use Git LFS are under [Managing large binary files with
## Requirements
* Git LFS is supported in GitLab starting with version 8.2.
+* Support for object storage, such as AWS S3, was introduced in 10.0.
* Users need to install [Git LFS client](https://git-lfs.github.com) version 1.0.1 and up.
## Configuration
@@ -12,16 +13,18 @@ Documentation on how to use Git LFS are under [Managing large binary files with
Git LFS objects can be large in size. By default, they are stored on the server
GitLab is installed on.
-There are two configuration options to help GitLab server administrators:
+There are various configuration options to help GitLab server administrators:
* Enabling/disabling Git LFS support
* Changing the location of LFS object storage
+* Setting up AWS S3 compatible object storage
-### Omnibus packages
+### Configuration for Omnibus installations
In `/etc/gitlab/gitlab.rb`:
```ruby
+# Change to true to enable lfs
gitlab_rails['lfs_enabled'] = false
# Optionally, change the storage path location. Defaults to
@@ -30,16 +33,123 @@ gitlab_rails['lfs_enabled'] = false
gitlab_rails['lfs_storage_path'] = "/mnt/storage/lfs-objects"
```
-### Installations from source
+### Configuration for installations from source
In `config/gitlab.yml`:
```yaml
+# Change to true to enable lfs
lfs:
enabled: false
storage_path: /mnt/storage/lfs-objects
```
+## Storing the LFS objects in an S3-compatible object storage
+
+> [Introduced][ee-2760] in [GitLab Premium][eep] 10.0. Brought to GitLab Core
+in 10.7.
+
+It is possible to store LFS objects on a remote object storage which allows you
+to offload storage to an external AWS S3 compatible service, freeing up disk
+space locally. You can also host your own S3 compatible storage decoupled from
+GitLab, with with a service such as [Minio](https://www.minio.io/).
+
+Object storage currently transfers files first to GitLab, and then on the
+object storage in a second stage. This can be done either by using a rake task
+to transfer existing objects, or in a background job after each file is received.
+
+The following general settings are supported.
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `enabled` | Enable/disable object storage | `false` |
+| `remote_directory` | The bucket name where LFS objects will be stored| |
+| `direct_upload` | Set to true to enable direct upload of LFS without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. | `false` |
+| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` |
+| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` |
+| `connection` | Various connection options described below | |
+
+The `connection` settings match those provided by [Fog](https://github.com/fog).
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `provider` | Always `AWS` for compatible hosts | AWS |
+| `aws_access_key_id` | AWS credentials, or compatible | |
+| `aws_secret_access_key` | AWS credentials, or compatible | |
+| `region` | AWS region | us-east-1 |
+| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com |
+| `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) |
+| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false |
+
+### S3 for Omnibus installations
+
+On Omnibus installations, the settings are prefixed by `lfs_object_store_`:
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with
+ the values you want:
+
+ ```ruby
+ gitlab_rails['lfs_object_store_enabled'] = true
+ gitlab_rails['lfs_object_store_remote_directory'] = "lfs-objects"
+ gitlab_rails['lfs_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'aws_access_key_id' => '1ABCD2EFGHI34JKLM567N',
+ 'aws_secret_access_key' => 'abcdefhijklmnopQRSTUVwxyz0123456789ABCDE',
+ # The below options configure an S3 compatible host instead of AWS
+ 'host' => 'localhost',
+ 'endpoint' => 'http://127.0.0.1:9000',
+ 'path_style' => true
+ }
+ ```
+
+1. Save the file and [reconfigure GitLab]s for the changes to take effect.
+1. Migrate any existing local LFS objects to the object storage:
+
+ ```bash
+ gitlab-rake gitlab:lfs:migrate
+ ```
+
+ This will migrate existing LFS objects to object storage. New LFS objects
+ will be forwarded to object storage unless
+ `gitlab_rails['lfs_object_store_background_upload']` is set to false.
+
+### S3 for installations from source
+
+For source installations the settings are nested under `lfs:` and then
+`object_store:`:
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ lfs:
+ enabled: true
+ object_store:
+ enabled: false
+ remote_directory: lfs-objects # Bucket name
+ connection:
+ provider: AWS
+ aws_access_key_id: 1ABCD2EFGHI34JKLM567N
+ aws_secret_access_key: abcdefhijklmnopQRSTUVwxyz0123456789ABCDE
+ region: eu-central-1
+ # Use the following options to configure an AWS compatible host such as Minio
+ host: 'localhost'
+ endpoint: 'http://127.0.0.1:9000'
+ path_style: true
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+1. Migrate any existing local LFS objects to the object storage:
+
+ ```bash
+ sudo -u git -H bundle exec rake gitlab:lfs:migrate RAILS_ENV=production
+ ```
+
+ This will migrate existing LFS objects to object storage. New LFS objects
+ will be forwarded to object storage unless `background_upload` is set to
+ false.
+
## Storage statistics
You can see the total storage used for LFS objects on groups and projects
@@ -48,10 +158,13 @@ and [projects APIs](../../api/projects.md).
## Known limitations
-* Currently, storing GitLab Git LFS objects on a non-local storage (like S3 buckets)
- 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
every project linking to it
+
+[reconfigure gitlab]: ../../administration/restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab"
+[restart gitlab]: ../../administration/restart_gitlab.md#installations-from-source "How to restart GitLab"
+[eep]: https://about.gitlab.com/products/ "GitLab Premium"
+[ee-2760]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/2760
diff --git a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md
index 195285f9157..377eee69c11 100644
--- a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md
+++ b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md
@@ -4,6 +4,11 @@ Managing large files such as audio, video and graphics files has always been one
of the shortcomings of Git. The general recommendation is to not have Git repositories
larger than 1GB to preserve performance.
+![Git LFS tracking status](img/lfs-icon.png)
+
+An LFS icon is shown on files tracked by Git LFS to denote if a file is stored
+as a blob or as an LFS pointer.
+
## How it works
Git LFS client talks with the GitLab server over HTTPS. It uses HTTP Basic Authentication
@@ -29,7 +34,7 @@ Documentation for GitLab instance administrators is under [LFS administration do
credentials store is recommended
* Git LFS always assumes HTTPS so if you have GitLab server on HTTP you will have
to add the URL to Git config manually (see [troubleshooting](#troubleshooting))
-
+
>**Note**: With 8.12 GitLab added LFS support to SSH. The Git LFS communication
still goes over HTTP, but now the SSH client passes the correct credentials
to the Git LFS client, so no action is required by the user.
@@ -78,6 +83,74 @@ that are on the remote repository, eg. from branch `master`:
git lfs fetch master
```
+## File Locking
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/35856) in GitLab 10.5.
+
+The first thing to do before using File Locking is to tell Git LFS which
+kind of files are lockable. The following command will store PNG files
+in LFS and flag them as lockable:
+
+```bash
+git lfs track "*.png" --lockable
+```
+
+After executing the above command a file named `.gitattributes` will be
+created or updated with the following content:
+
+```bash
+*.png filter=lfs diff=lfs merge=lfs -text lockable
+```
+
+You can also register a file type as lockable without using LFS
+(In order to be able to lock/unlock a file you need a remote server that implements the LFS File Locking API),
+in order to do that you can edit the `.gitattributes` file manually:
+
+```bash
+*.pdf lockable
+```
+
+After a file type has been registered as lockable, Git LFS will make
+them readonly on the file system automatically. This means you will
+need to lock the file before editing it.
+
+### Managing Locked Files
+
+Once you're ready to edit your file you need to lock it first:
+
+```bash
+git lfs lock images/banner.png
+Locked images/banner.png
+```
+
+This will register the file as locked in your name on the server:
+
+```bash
+git lfs locks
+images/banner.png joe ID:123
+```
+
+Once you have pushed your changes, you can unlock the file so others can
+also edit it:
+
+```bash
+git lfs unlock images/banner.png
+```
+
+You can also unlock by id:
+
+```bash
+git lfs unlock --id=123
+```
+
+If for some reason you need to unlock a file that was not locked by you,
+you can use the `--force` flag as long as you have a `master` access on
+the project:
+
+```bash
+git lfs unlock --id=123 --force
+```
+
## Troubleshooting
### error: Repository or object not found
@@ -170,4 +243,4 @@ GitLab checks files to detect LFS pointers on push. If LFS pointers are detected
Verify that LFS in installed locally and consider a manual push with `git lfs push --all`.
-If you are storing LFS files outside of GitLab you can disable LFS on the project by settting `lfs_enabled: false` with the [projets api](../../api/projects.md#edit-project).
+If you are storing LFS files outside of GitLab you can disable LFS on the project by settting `lfs_enabled: false` with the [projects api](../../api/projects.md#edit-project).
diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md
index 3e2e7d0f7b6..c4095ee0f69 100644
--- a/doc/workflow/notifications.md
+++ b/doc/workflow/notifications.md
@@ -65,9 +65,9 @@ Below is the table of events users can be notified of:
| Group access level changed | User | Sent when user group access level is changed |
| Project moved | Project members [1] | [1] not disabled |
-### Issue / Merge Request events
+### Issue / Merge request events
-In all of the below cases, the notification will be sent to:
+In most of the below cases, the notification will be sent to:
- Participants:
- the author and assignee of the issue/merge request
- authors of comments on the issue/merge request
@@ -87,6 +87,7 @@ In all of the below cases, the notification will be sent to:
| Reassign issue | The above, plus the old assignee |
| Reopen issue | |
| New merge request | |
+| Push to merge request | Participants and Custom notification level with this event selected |
| Reassign merge request | The above, plus the old assignee |
| Close merge request | |
| Reopen merge request | |
@@ -104,3 +105,33 @@ You won't receive notifications for Issues, Merge Requests or Milestones
created by yourself. You will only receive automatic notifications when
somebody else comments or adds changes to the ones that you've created or
mentions you.
+
+### Email Headers
+
+Notification emails include headers that provide extra content about the notification received:
+
+| Header | Description |
+|-----------------------------|-------------------------------------------------------------------------|
+| X-GitLab-Project | The name of the project the notification belongs to |
+| X-GitLab-Project-Id | The ID of the project |
+| X-GitLab-Project-Path | The path of the project |
+| X-GitLab-(Resource)-ID | The ID of the resource the notification is for, where resource is `Issue`, `MergeRequest`, `Commit`, etc|
+| X-GitLab-Discussion-ID | Only in comment emails, the ID of the discussion the comment is from |
+| X-GitLab-Pipeline-Id | Only in pipeline emails, the ID of the pipeline the notification is for |
+| X-GitLab-Reply-Key | A unique token to support reply by email |
+| X-GitLab-NotificationReason | The reason for being notified. "mentioned", "assigned", etc |
+
+#### X-GitLab-NotificationReason
+This header holds the reason for the notification to have been sent out,
+where reason can be `mentioned`, `assigned`, `own_activity`, etc.
+Only one reason is sent out according to its priority:
+- `own_activity`
+- `assigned`
+- `mentioned`
+
+The reason in this header will also be shown in the footer of the notification email. For example an email with the
+reason `assigned` will have this sentence in the footer:
+`"You are receiving this email because you have been assigned an item on {configured GitLab hostname}"`
+
+**Note: Only reasons listed above have been implemented so far**
+Further implementation is [being discussed here](https://gitlab.com/gitlab-org/gitlab-ce/issues/42062)
diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md
index 3d8d3ce8f13..e612646cfbc 100644
--- a/doc/workflow/todos.md
+++ b/doc/workflow/todos.md
@@ -28,11 +28,10 @@ A Todo appears in your Todos dashboard when:
- an issue or merge request is assigned to you,
- you are `@mentioned` in an issue or merge request, be it the description of
the issue/merge request or in a comment,
+- you are `@mentioned` in a comment on a commit,
- a job in the CI pipeline running for your merge request failed, but this
job is not allowed to fail.
->**Note:** Commenting on a commit will _not_ trigger a Todo.
-
### Directly addressed Todos
> [Introduced][ce-7926] in GitLab 9.0.
diff --git a/features/explore/groups.feature b/features/explore/groups.feature
deleted file mode 100644
index 830810615e0..00000000000
--- a/features/explore/groups.feature
+++ /dev/null
@@ -1,105 +0,0 @@
-@public
-Feature: Explore Groups
- Background:
- Given group "TestGroup" has private project "Enterprise"
-
- @javascript
- Scenario: I should see group with private and internal projects as user
- Given group "TestGroup" has internal project "Internal"
- When I sign in as a user
- And I visit group "TestGroup" page
- Then I should see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group issues for internal project as user
- Given group "TestGroup" has internal project "Internal"
- When I sign in as a user
- And I visit group "TestGroup" issues page
- Then I should see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group merge requests for internal project as user
- Given group "TestGroup" has internal project "Internal"
- When I sign in as a user
- And I visit group "TestGroup" merge requests page
- Then I should see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group with private, internal and public projects as visitor
- Given group "TestGroup" has internal project "Internal"
- Given group "TestGroup" has public project "Community"
- When I visit group "TestGroup" page
- Then I should see project "Community" items
- And I should not see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group issues for public project as visitor
- Given group "TestGroup" has internal project "Internal"
- Given group "TestGroup" has public project "Community"
- When I visit group "TestGroup" issues page
- Then I should see project "Community" items
- And I should not see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group merge requests for public project as visitor
- Given group "TestGroup" has internal project "Internal"
- Given group "TestGroup" has public project "Community"
- When I visit group "TestGroup" merge requests page
- Then I should see project "Community" items
- And I should not see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group with private, internal and public projects as user
- Given group "TestGroup" has internal project "Internal"
- Given group "TestGroup" has public project "Community"
- When I sign in as a user
- And I visit group "TestGroup" page
- Then I should see project "Community" items
- And I should see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group issues for internal and public projects as user
- Given group "TestGroup" has internal project "Internal"
- Given group "TestGroup" has public project "Community"
- When I sign in as a user
- And I visit group "TestGroup" issues page
- Then I should see project "Community" items
- And I should see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group merge requests for internal and public projects as user
- Given group "TestGroup" has internal project "Internal"
- Given group "TestGroup" has public project "Community"
- When I sign in as a user
- And I visit group "TestGroup" merge requests page
- Then I should see project "Community" items
- And I should see project "Internal" items
- And I should not see project "Enterprise" items
-
- @javascript
- Scenario: I should see group with public project in public groups area
- Given group "TestGroup" has public project "Community"
- When I visit the public groups area
- Then I should see group "TestGroup"
-
- @javascript
- Scenario: I should see group with public project in public groups area as user
- Given group "TestGroup" has public project "Community"
- When I sign in as a user
- And I visit the public groups area
- Then I should see group "TestGroup"
-
- @javascript
- Scenario: I should see group with internal project in public groups area as user
- Given group "TestGroup" has internal project "Internal"
- When I sign in as a user
- And I visit the public groups area
- Then I should see group "TestGroup"
diff --git a/features/group/members.feature b/features/group/members.feature
deleted file mode 100644
index 49a44f57cbb..00000000000
--- a/features/group/members.feature
+++ /dev/null
@@ -1,12 +0,0 @@
-Feature: Group Members
- Background:
- Given I sign in as "John Doe"
- And "John Doe" is owner of group "Owned"
- And "John Doe" is guest of group "Guest"
-
- Scenario: Search member by name
- Given "Mary Jane" is guest of group "Guest"
- And I visit group "Guest" members page
- When I search for 'Mary' member
- Then I should see user "Mary Jane" in team list
- Then I should not see user "John Doe" in team list
diff --git a/features/group/milestones.feature b/features/group/milestones.feature
deleted file mode 100644
index 2211acfee20..00000000000
--- a/features/group/milestones.feature
+++ /dev/null
@@ -1,48 +0,0 @@
-Feature: Group Milestones
- Background:
- Given I sign in as "John Doe"
- And "John Doe" is owner of group "Owned"
-
- Scenario: I should see group "Owned" milestone index page with no milestones
- When I visit group "Owned" page
- And I click on group milestones
- Then I should see group milestones index page has no milestones
-
- Scenario: I should see group "Owned" milestone index page with milestones
- Given Group has projects with milestones
- When I visit group "Owned" page
- And I click on group milestones
- Then I should see group milestones index page with milestones
-
- Scenario: I should see group "Owned" milestone show page
- Given Group has projects with milestones
- When I visit group "Owned" page
- And I click on group milestones
- And I click on one group milestone
- 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 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 group milestone should be created
-
- Scenario: I should see Issues listed with labels
- Given Group has projects with milestones
- When I visit group "Owned" page
- And I click on group milestones
- And I click on one group milestone
- Then I should see the "bug" label
- 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
- And I click on group milestones
- And I click on one group milestone
- And I click on the "Labels" tab
- Then I should see the list of labels
diff --git a/features/groups.feature b/features/groups.feature
deleted file mode 100644
index 4044bd9be79..00000000000
--- a/features/groups.feature
+++ /dev/null
@@ -1,73 +0,0 @@
-Feature: Groups
- Background:
- Given I sign in as "John Doe"
- And "John Doe" is owner of group "Owned"
-
- Scenario: I should not see a group if it does not exist
- When I visit group "NonExistentGroup" page
- Then page status code should be 404
-
- @javascript
- Scenario: I should see group "Owned" dashboard list
- When I visit group "Owned" page
- Then I should see group "Owned" projects list
-
- @javascript
- Scenario: I should see group "Owned" activity feed
- When I visit group "Owned" activity page
- And I should see projects activity feed
-
- Scenario: I should see group "Owned" issues list
- Given project from group "Owned" has issues assigned to me
- When I visit group "Owned" issues page
- Then I should see issues from group "Owned" assigned to me
-
- Scenario: I should not see issues from archived project in "Owned" group issues list
- Given Group "Owned" has archived project
- And the archived project have some issues
- When I visit group "Owned" issues page
- Then I should not see issues from the archived project
-
- Scenario: I should see group "Owned" merge requests list
- Given project from group "Owned" has merge requests assigned to me
- When I visit group "Owned" merge requests page
- Then I should see merge requests from group "Owned" assigned to me
-
- Scenario: I should not see merge requests from archived project in "Owned" group merge requests list
- Given Group "Owned" has archived project
- And the archived project have some merge_requests
- When I visit group "Owned" merge requests page
- Then I should not see merge requests from the archived project
-
- Scenario: I edit group "Owned" avatar
- When I visit group "Owned" settings page
- And I change group "Owned" avatar
- And I visit group "Owned" settings page
- Then I should see new group "Owned" avatar
- And I should see the "Remove avatar" button
-
- Scenario: I remove group "Owned" avatar
- When I visit group "Owned" settings page
- And I have group "Owned" avatar
- And I visit group "Owned" settings page
- And I remove group "Owned" avatar
- Then I should not see group "Owned" avatar
- And I should not see the "Remove avatar" button
-
- # Group projects in settings
- Scenario: I should see all projects in the project list in settings
- Given Group "Owned" has archived project
- When I visit group "Owned" projects page
- Then I should see group "Owned" projects list
- And I should see "archived" label
-
- # Public group
- @javascript
- Scenario: Signed out user should see group
- Given "Mary Jane" is owner of group "Owned"
- And I am a signed out user
- And Group "Owned" has a public project "Public-project"
- When I visit group "Owned" page
- Then I should see group "Owned"
- Then I should see project "Public-project"
-
diff --git a/features/invites.feature b/features/invites.feature
deleted file mode 100644
index dc8eefaeaed..00000000000
--- a/features/invites.feature
+++ /dev/null
@@ -1,45 +0,0 @@
-Feature: Invites
- Background:
- Given "John Doe" is owner of group "Owned"
- And "John Doe" has invited "user@example.com" to group "Owned"
-
- Scenario: Viewing invitation when signed out
- When I visit the invitation page
- Then I should be redirected to the sign in page
- And I should see a notice telling me to sign in
-
- Scenario: Signing in to view invitation
- When I visit the invitation page
- And I sign in as "Mary Jane"
- Then I should be redirected to the invitation page
-
- Scenario: Viewing invitation when signed in
- Given I sign in as "Mary Jane"
- And I visit the invitation page
- Then I should see the invitation details
- And I should see an "Accept invitation" button
- And I should see a "Decline" button
-
- Scenario: Viewing invitation as an existing member
- Given I sign in as "John Doe"
- And I visit the invitation page
- Then I should see a message telling me I'm already a member
-
- Scenario: Accepting the invitation
- Given I sign in as "Mary Jane"
- And I visit the invitation page
- And I click the "Accept invitation" button
- Then I should be redirected to the group page
- And I should see a notice telling me I have access
-
- Scenario: Declining the application when signed in
- Given I sign in as "Mary Jane"
- And I visit the invitation page
- And I click the "Decline" button
- Then I should be redirected to the dashboard
- And I should see a notice telling me I have declined
-
- Scenario: Declining the application when signed out
- When I visit the invitation's decline page
- Then I should be redirected to the sign in page
- And I should see a notice telling me I have declined
diff --git a/features/profile/profile.feature b/features/profile/profile.feature
deleted file mode 100644
index 3263d3e212b..00000000000
--- a/features/profile/profile.feature
+++ /dev/null
@@ -1,85 +0,0 @@
-@profile
-Feature: Profile
- Background:
- Given I sign in as a user
-
- Scenario: I look at my profile
- Given I visit profile page
- Then I should see my profile info
-
- @javascript
- Scenario: I can see groups I belong to
- Given I have group with projects
- When I visit profile page
- And I click on my profile picture
- Then I should see my user page
- And I should see groups I belong to
-
- Scenario: I edit profile
- Given I visit profile page
- Then I change my profile info
- And I should see new profile info
-
- Scenario: I change my password without old one
- Given I visit profile password page
- When I try change my password w/o old one
- Then I should see a missing password error message
- And I should be redirected to password page
-
- Scenario: I change my password
- Given I visit profile password page
- Then I change my password
- And I should be redirected to sign in page
-
- Scenario: I edit my avatar
- Given I visit profile page
- Then I change my avatar
- And I should see new avatar
- And I should see the "Remove avatar" button
- And I should see the gravatar host link
-
- Scenario: I remove my avatar
- Given I visit profile page
- And I have an avatar
- When I remove my avatar
- Then I should see my gravatar
- And I should not see the "Remove avatar" button
- And I should see the gravatar host link
-
- Scenario: My password is expired
- Given my password is expired
- And I am not an ldap user
- Given I visit profile password page
- Then I redirected to expired password page
- And I submit new password
- And I redirected to sign in page
-
- Scenario: I unsuccessfully change my password
- Given I visit profile password page
- When I unsuccessfully change my password
- Then I should see a password error message
-
- Scenario: I visit history tab
- Given I logout
- And I sign in via the UI
- And I have activity
- When I visit Authentication log page
- Then I should see my activity
-
- Scenario: I visit my user page
- When I visit profile page
- And I click on my profile picture
- Then I should see my user page
-
- Scenario: I can manage application
- Given I visit profile applications page
- Then I should see application form
- Then I fill application form out and submit
- And I see application
- Then I click edit
- And I see edit application form
- Then I change name of application and submit
- And I see that application was changed
- Then I visit profile applications page
- And I click to remove application
- Then I see that application is removed
diff --git a/features/project/active_tab.feature b/features/project/active_tab.feature
deleted file mode 100644
index 3ea0aab5a67..00000000000
--- a/features/project/active_tab.feature
+++ /dev/null
@@ -1,138 +0,0 @@
-Feature: Project Active Tab
- Background:
- Given I sign in as a user
- And I own a project
-
- # Main Tabs
-
- Scenario: On Project Home
- Given I visit my project's home page
- Then the active sub tab should be Home
- And no other sub tabs should be active
- And the active main tab should be Project
-
- Scenario: On Project Repository
- Given I visit my project's files page
- Then the active main tab should be Repository
- And no other main tabs should be active
-
- Scenario: On Project Issues
- Given I visit my project's issues page
- Then the active main tab should be Issues
- And no other main tabs should be active
-
- Scenario: On Project Merge Requests
- Given I visit my project's merge requests page
- Then the active main tab should be Merge Requests
- And no other main tabs should be active
-
- Scenario: On Project Wiki
- Given I visit my project's wiki page
- 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
- Given I visit my project's home page
- Then the active sub tab should be Home
- And no other sub tabs should be active
- And the active main tab should be Project
- And no other main tabs should be active
-
- Scenario: On Project Home/Activity
- Given I visit my project's home page
- And I click the "Activity" tab
- Then the active sub tab should be Activity
- And no other sub tabs should be active
- And the active main tab should be Project
-
- # Sub Tabs: Settings
-
- Scenario: On Project Settings/Integrations
- Given I visit my project's settings page
- And I click the "Integrations" tab
- Then the active sub tab should be Integrations
- And no other sub tabs should be active
- And the active main tab should be Settings
-
- Scenario: On Project Settings/Repository
- Given I visit my project's settings page
- And I click the "Repository" tab
- Then the active sub tab should be Repository
- And no other sub tabs should be active
- And the active main tab should be Settings
-
- # Sub Tabs: Repository
-
- Scenario: On Project Repository/Files
- Given I visit my project's files page
- Then the active sub tab should be Files
- And no other sub tabs should be active
- And the active main tab should be Repository
-
- Scenario: On Project Repository/Commits
- Given I visit my project's commits page
- Then the active sub tab should be Commits
- And no other sub tabs should be active
- And the active main tab should be Repository
-
- Scenario: On Project Repository/Graph
- Given I visit my project's graph page
- Then the active sub tab should be Graph
- And no other sub tabs should be active
- And the active main tab should be Repository
-
- Scenario: On Project Repository/Compare
- Given I visit my project's commits page
- And I click the "Compare" tab
- Then the active sub tab should be Compare
- And no other sub tabs should be active
- And the active main tab should be Repository
-
- Scenario: On Project Repository/Charts
- Given I visit my project's commits page
- And I click the "Charts" tab
- Then the active sub tab should be Charts
- And no other sub tabs should be active
- And the active main tab should be Repository
-
- Scenario: On Project Repository/Branches
- Given I visit my project's commits page
- And I click the "Branches" tab
- Then the active sub tab should be Branches
- And no other sub tabs should be active
- And the active main tab should be Repository
-
- Scenario: On Project Repository/Tags
- Given I visit my project's commits page
- And I click the "Tags" tab
- Then the active sub tab should be Tags
- And no other sub tabs should be active
- And the active main tab should be Repository
-
- Scenario: On Project Issues/Browse
- Given I visit my project's issues page
- Then the active main tab should be Issues
- And no other main tabs should be active
-
- Scenario: On Project Issues/Milestones
- Given I visit my project's issues page
- And I click the "Milestones" sub tab
- Then the active main tab should be Issues
- Then the active sub tab should be Milestones
- And no other main tabs should be active
- And no other sub tabs should be active
-
- Scenario: On Project Issues/Labels
- Given I visit my project's issues page
- And I click the "Labels" sub tab
- Then the active main tab should be Issues
- Then the active sub tab should be Labels
- And no other main tabs should be active
- And no other sub tabs should be active
diff --git a/features/project/ff_merge_requests.feature b/features/project/ff_merge_requests.feature
index 995e52f9332..39035d551d1 100644
--- a/features/project/ff_merge_requests.feature
+++ b/features/project/ff_merge_requests.feature
@@ -22,3 +22,20 @@ Feature: Project Ff Merge Requests
Then I should see ff-only merge button
When I accept this merge request
Then I should see merged request
+
+ @javascript
+ Scenario: I do rebase before ff-only merge
+ Given ff merge enabled
+ And rebase before merge enabled
+ When I visit merge request page "Bug NS-05"
+ Then I should see rebase button
+ When I press rebase button
+ Then I should see rebase in progress message
+
+ @javascript
+ Scenario: I do rebase before regular merge
+ Given rebase before merge enabled
+ When I visit merge request page "Bug NS-05"
+ Then I should see rebase button
+ When I press rebase button
+ Then I should see rebase in progress message
diff --git a/features/project/fork.feature b/features/project/fork.feature
deleted file mode 100644
index ca3f2771aa5..00000000000
--- a/features/project/fork.feature
+++ /dev/null
@@ -1,49 +0,0 @@
-Feature: Project Fork
- Background:
- Given I sign in as a user
- And I am a member of project "Shop"
- When I visit project "Shop" page
-
- Scenario: User fork a project
- Given I click link "Fork"
- When I fork to my namespace
- Then I should see the forked project page
-
- Scenario: User already has forked the project
- Given I already have a project named "Shop" in my namespace
- And I click link "Fork"
- When I fork to my namespace
- Then I should see a "Name has already been taken" warning
-
- Scenario: Merge request on canonical repo goes to fork merge request page
- Given I click link "Fork"
- And I fork to my namespace
- Then I should see the forked project page
- When I visit project "Shop" page
- Then I should see "New merge request"
- And I goto the Merge Requests page
- Then I should see "New merge request"
- And I click link "New merge request"
- Then I should see the new merge request page for my namespace
-
- Scenario: Viewing forks of a Project
- Given I click link "Fork"
- When I fork to my namespace
- And I visit the forks page of the "Shop" project
- Then I should see my fork on the list
-
- Scenario: Viewing forks of a Project that has no repo
- Given I click link "Fork"
- When I fork to my namespace
- And I make forked repo invalid
- And I visit the forks page of the "Shop" project
- Then I should see my fork on the list
-
- Scenario: Viewing private forks of a Project
- Given There is an existent fork of the "Shop" project
- And I click link "Fork"
- When I fork to my namespace
- And I visit the forks page of the "Shop" project
- Then I should see my fork on the list
- And I should not see the other fork listed
- And I should see a private fork notice
diff --git a/features/project/graph.feature b/features/project/graph.feature
deleted file mode 100644
index b25c73ad870..00000000000
--- a/features/project/graph.feature
+++ /dev/null
@@ -1,33 +0,0 @@
-Feature: Project Graph
- Background:
- Given I sign in as a user
- And I own project "Shop"
-
- @javascript
- Scenario: I should see project graphs
- When I visit project "Shop" graph page
- Then page should have graphs
-
- @javascript
- Scenario: I should see project languages & commits graphs on commits graph url
- When I visit project "Shop" commits graph page
- Then page should have commits graphs
- Then page should have languages graphs
-
- @javascript
- Scenario: I should see project ci graphs
- Given project "Shop" has CI enabled
- When I visit project "Shop" CI graph page
- Then page should have CI graphs
-
- @javascript
- Scenario: I should see project languages & commits graphs on language graph url
- When I visit project "Shop" languages graph page
- Then page should have languages graphs
- Then page should have commits graphs
-
- @javascript
- Scenario: I should see project languages & commits graphs on charts url
- When I visit project "Shop" chart page
- Then page should have languages graphs
- Then page should have commits graphs
diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature
deleted file mode 100644
index d6cfa524a3a..00000000000
--- a/features/project/issues/issues.feature
+++ /dev/null
@@ -1,180 +0,0 @@
-@project_issues
-Feature: Project Issues
- Background:
- Given I sign in as a user
- And I own project "Shop"
- And project "Shop" have "Release 0.4" open issue
- And project "Shop" have "Tweet control" open issue
- And project "Shop" have "Release 0.3" closed issue
- And I visit project "Shop" issues page
-
- Scenario: I should see open 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
- And I should see "Release 0.4" in issues
-
- Scenario: I visit issue page
- Given I click link "Release 0.4"
- Then I should see issue "Release 0.4"
-
- Scenario: I submit new unassigned issue
- Given I click link "New Issue"
- And I submit new issue "500 error on profile"
- Then I should see issue "500 error on profile"
-
- @javascript
- Scenario: I submit new unassigned issue with labels
- Given project "Shop" has labels: "bug", "feature", "enhancement"
- And I click link "New Issue"
- And I submit new issue "500 error on profile" with label 'bug'
- Then I should see issue "500 error on profile"
- And I should see label 'bug' with issue
-
- @javascript
- Scenario: I comment issue
- Given I visit issue page "Release 0.4"
- And I leave a comment like "XML attached"
- Then I should see comment "XML attached"
- And I should see an error alert section within the comment form
-
- @javascript
- Scenario: Visiting Issues after being sorted the list
- Given I visit project "Shop" issues page
- And I sort the list by "Last updated"
- And I visit my project's home page
- And I visit project "Shop" issues page
- Then The list should be sorted by "Last updated"
-
- @javascript
- Scenario: Visiting Merge Requests after being sorted the list
- Given project "Shop" has a "Bugfix MR" merge request open
- And I visit project "Shop" issues page
- And I sort the list by "Last updated"
- And I visit project "Shop" merge requests page
- Then The list should be sorted by "Last updated"
-
- @javascript
- Scenario: Visiting Merge Requests from a differente Project after sorting
- Given project "Shop" has a "Bugfix MR" merge request open
- And I visit project "Shop" merge requests page
- And I sort the list by "Last updated"
- And I visit dashboard merge requests page
- Then The list should be sorted by "Last updated"
-
- @javascript
- Scenario: Sort issues by upvotes/downvotes
- Given project "Shop" have "Bugfix" open issue
- And issue "Release 0.4" have 2 upvotes and 1 downvote
- And issue "Tweet control" have 1 upvote and 2 downvotes
- And I sort the list by "Popularity"
- Then The list should be sorted by "Popularity"
-
- # 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
-
- @javascript
- Scenario: Headers inside comments should not have ids generated for them.
- Given I visit issue page "Release 0.4"
- And I leave a comment with a header containing "Comment with a header"
- Then The comment with the header should not have an ID
-
- @javascript
- Scenario: Blocks inside comments should not build relative links
- Given I visit issue page "Release 0.4"
- And I leave a comment with code block
- Then The code block should be unchanged
-
- Scenario: Issues on empty project
- Given empty project "Empty Project"
- And I have an ssh key
- When I visit empty project page
- And I see empty project details with ssh clone info
- When I visit empty project's issues page
- Given I click link "New Issue"
- And I submit new issue "500 error on profile"
- Then I should see issue "500 error on profile"
-
- Scenario: Clickable labels
- Given issue 'Release 0.4' has label 'bug'
- And I visit project "Shop" issues page
- When I click label 'bug'
- And I should see "Release 0.4" in issues
- And I should not see "Tweet control" in issues
-
- @javascript
- Scenario: Issue notes should be editable with +1
- Given project "Shop" have "Release 0.4" open issue
- When I visit issue page "Release 0.4"
- And I leave a comment with a header containing "Comment with a header"
- Then The comment with the header should not have an ID
- And I edit the last comment with a +1
- Then I should see +1 in the description
-
- # Issue description preview
-
- @javascript
- Scenario: I can't preview without text
- Given I click link "New Issue"
- And I haven't written any description text
- Then The Markdown preview tab should say there is nothing to do
-
- @javascript
- Scenario: I can preview with text
- Given I click link "New Issue"
- And I write a description like ":+1: Nice"
- Then The Markdown preview tab should display rendered Markdown
-
- @javascript
- Scenario: I preview an issue description
- Given I click link "New Issue"
- And I preview a description text like "Bug fixed :smile:"
- Then I should see the Markdown preview
- And I should not see the Markdown text field
-
- @javascript
- Scenario: I can edit after preview
- Given I click link "New Issue"
- And I preview a description text like "Bug fixed :smile:"
- Then I should see the Markdown write tab
-
- @javascript
- Scenario: I can preview when editing an existing issue
- Given I click link "Release 0.4"
- And I click link "Edit" for the issue
- And I preview a description text like "Bug fixed :smile:"
- Then I should see the Markdown write tab
-
- @javascript
- Scenario: I can unsubscribe from issue
- Given project "Shop" have "Release 0.4" open issue
- When I visit issue page "Release 0.4"
- Then I should see that I am subscribed
- When I click button "Unsubscribe"
- Then I should see that I am unsubscribed
-
- @javascript
- Scenario: I submit new unassigned issue as guest
- Given public project "Community"
- When I visit project "Community" page
- And I visit project "Community" issues page
- And I click link "New Issue"
- And I should not see assignee field
- And I should not see milestone field
- And I should not see labels field
- And I submit new issue "500 error on profile"
- Then I should see issue "500 error on profile"
diff --git a/features/project/issues/labels.feature b/features/project/issues/labels.feature
deleted file mode 100644
index 45de57f18e3..00000000000
--- a/features/project/issues/labels.feature
+++ /dev/null
@@ -1,48 +0,0 @@
-@project_issues
-Feature: Project Issues Labels
- Background:
- Given I sign in as a user
- And I own project "Shop"
- And project "Shop" has labels: "bug", "feature", "enhancement"
- Given I visit project "Shop" labels page
-
- Scenario: I should see labels list
- Then I should see label 'bug'
- And I should see label 'feature'
-
- Scenario: I create new label
- Given I visit project "Shop" new label page
- When I submit new label 'support'
- Then I should see label 'support'
-
- Scenario: I edit label
- Given I visit 'bug' label edit page
- When I change label 'bug' to 'fix'
- Then I should not see label 'bug'
- Then I should see label 'fix'
-
- Scenario: I remove label
- When I remove label 'bug'
- Then I should not see label 'bug'
-
- @javascript
- Scenario: I remove all labels
- When I delete all labels
- Then I should see labels help message
-
- Scenario: I create a label with invalid color
- Given I visit project "Shop" new label page
- When I submit new label with invalid color
- Then I should see label color error message
-
- Scenario: I create a label that already exists
- Given I visit project "Shop" new label page
- When I submit new label 'bug'
- Then I should see label label exist error message
-
- Scenario: I create the same label on another project
- Given I own project "Forum"
- And I visit project "Forum" labels page
- And I visit project "Forum" new label page
- When I submit new label 'bug'
- Then I should see label 'bug'
diff --git a/features/project/issues/milestones.feature b/features/project/issues/milestones.feature
index 1af05b3c326..77c8ed6e5bf 100644
--- a/features/project/issues/milestones.feature
+++ b/features/project/issues/milestones.feature
@@ -18,12 +18,15 @@ Feature: Project Issues Milestones
Given I click link "New Milestone"
And I submit new milestone "v2.3"
Then I should see milestone "v2.3"
- Given I click link to remove milestone
+ Given I click button to remove milestone
+ And I confirm in modal
When I visit project "Shop" activity page
Then I should see deleted milestone activity
+ @javascript
Scenario: I delete new milestone
- Given I click link to remove milestone
+ Given I click button to remove milestone
+ And I confirm in modal
And I should see no milestones
@javascript
@@ -36,4 +39,5 @@ Feature: Project Issues Milestones
Scenario: Headers inside the description should have ids generated for them.
Given I click link "v2.2"
+ # PLEASE USE the `have_header_with_correct_id_and_link(level, text, id, parent)` matcher on migrating this spec to rspec.
Then Header "Description header" should have correct id and link
diff --git a/features/project/network_graph.feature b/features/project/network_graph.feature
deleted file mode 100644
index 93c884e23c5..00000000000
--- a/features/project/network_graph.feature
+++ /dev/null
@@ -1,46 +0,0 @@
-Feature: Project Network Graph
- Background:
- Given I sign in as a user
- And I own project "Shop"
- And I visit project "Shop" network page
-
- @javascript
- Scenario: I should see project network
- Then page should have network graph
- And page should select "master" in select box
- And page should have "master" on graph
-
- @javascript
- Scenario: I should see project network with 'test' branch
- When I visit project network page on branch 'test'
- Then page should have 'test' on graph
-
- @javascript
- Scenario: I should switch "branch" and "tag"
- When I switch ref to "feature"
- Then page should select "feature" in select box
- And page should have "feature" on graph
- When I switch ref to "v1.0.0"
- Then page should select "v1.0.0" in select box
- And page should have "v1.0.0" on graph
-
- @javascript
- Scenario: I should looking for a commit by SHA
- When I looking for a commit by SHA of "v1.0.0"
- Then page should have network graph
- And page should select "master" in select box
- And page should have "v1.0.0" on graph
-
- @javascript
- Scenario: I should filter selected tag
- When I switch ref to "v1.0.0"
- Then page should have "v1.0.0" in title
- Then page should have content not containing "v1.0.0"
- When click "Show only selected branch" checkbox
- Then page should only have content from "v1.0.0"
- When click "Show only selected branch" checkbox
- Then page should have content not containing "v1.0.0"
-
- Scenario: I should fail to look for a commit
- When I look for a commit by ";"
- Then I should see non-existent git revision error message
diff --git a/features/project/pages.feature b/features/project/pages.feature
deleted file mode 100644
index 56e47287b5c..00000000000
--- a/features/project/pages.feature
+++ /dev/null
@@ -1,87 +0,0 @@
-Feature: Project Pages
- Background:
- Given I sign in as a user
- And I own a project
-
- Scenario: I cannot navigate to Pages settings if pages enabled
- Given 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
- When I visit the Project Pages
- Then I should see the usage of GitLab Pages
-
- Scenario: I can access the pages if deployed
- Given pages are enabled
- And pages are deployed
- When I visit the Project Pages
- Then I should be able to access the Pages
-
- Scenario: I should message that domains support is disabled
- Given pages are enabled
- And pages are deployed
- And support for external domains is disabled
- When I visit the Project Pages
- Then I should see that support for domains is disabled
-
- Scenario: I should see a new domain button
- Given pages are enabled
- And pages are exposed on external HTTP address
- When I visit the Project Pages
- And I should be able to add a New Domain
-
- Scenario: I should be able to add a new domain
- Given pages are enabled
- And pages are exposed on external HTTP address
- When I visit add a new Pages Domain
- And I fill the domain
- And I click on "Create New Domain"
- Then I should see a new domain added
-
- Scenario: I should be able to add a new domain for project in group namespace
- Given I own a project in some group namespace
- And pages are enabled
- And pages are exposed on external HTTP address
- When I visit add a new Pages Domain
- And I fill the domain
- And I click on "Create New Domain"
- Then I should see a new domain added
-
- Scenario: I should be denied to add the same domain twice
- Given pages are enabled
- And pages are exposed on external HTTP address
- And pages domain is added
- When I visit add a new Pages Domain
- And I fill the domain
- And I click on "Create New Domain"
- Then I should see error message that domain already exists
-
- Scenario: I should message that certificates support is disabled when trying to add a new domain
- Given pages are enabled
- And pages are exposed on external HTTP address
- And pages domain is added
- When I visit add a new Pages Domain
- Then I should see that support for certificates is disabled
-
- Scenario: I should be able to add a new domain with certificate
- Given pages are enabled
- And pages are exposed on external HTTPS address
- When I visit add a new Pages Domain
- And I fill the domain
- And I fill the certificate and key
- And I click on "Create New Domain"
- Then I should see a new domain added
-
- Scenario: I can remove the pages if deployed
- Given pages are enabled
- And pages are deployed
- When I visit the Project Pages
- And I click Remove Pages
- Then The Pages should get removed
diff --git a/features/project/redirects.feature b/features/project/redirects.feature
deleted file mode 100644
index a2e77e7bf30..00000000000
--- a/features/project/redirects.feature
+++ /dev/null
@@ -1,38 +0,0 @@
-Feature: Project Redirects
- Background:
- Given public project "Community"
- And private project "Enterprise"
-
- Scenario: I visit public project page
- When I visit project "Community" page
- Then I should see project "Community" home page
-
- Scenario: I visit private project page
- When I visit project "Enterprise" page
- Then I should be redirected to sign in page
-
- Scenario: I visit a non-existent project page
- When I visit project "CommunityDoesNotExist" page
- Then I should be redirected to sign in page
-
- Scenario: I visit a non-existent project page as user
- Given I sign in as a user
- When I visit project "CommunityDoesNotExist" page
- Then page status code should be 404
-
- Scenario: I visit unauthorized project page as user
- Given I sign in as a user
- When I visit project "Enterprise" page
- Then page status code should be 404
-
- Scenario: I visit a public project without signing in
- When I visit project "Community" page
- And I should see project "Community" home page
- And I click on "Sign In"
- And Authenticate
- Then I should be redirected to "Community" page
-
- Scenario: I visit private project page without signing in
- When I visit project "Enterprise" page
- And I get redirected to signin page where I sign in
- Then I should be redirected to "Enterprise" page
diff --git a/features/steps/explore/groups.rb b/features/steps/explore/groups.rb
deleted file mode 100644
index 409bf0cb416..00000000000
--- a/features/steps/explore/groups.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-class Spinach::Features::ExploreGroups < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedGroup
- include SharedProject
-
- step 'group "TestGroup" has private project "Enterprise"' do
- group_has_project("TestGroup", "Enterprise", Gitlab::VisibilityLevel::PRIVATE)
- end
-
- step 'group "TestGroup" has internal project "Internal"' do
- group_has_project("TestGroup", "Internal", Gitlab::VisibilityLevel::INTERNAL)
- end
-
- step 'group "TestGroup" has public project "Community"' do
- group_has_project("TestGroup", "Community", Gitlab::VisibilityLevel::PUBLIC)
- end
-
- step '"John Doe" is owner of group "TestGroup"' do
- group = Group.find_by(name: "TestGroup") || create(:group, name: "TestGroup")
- user = create(:user, name: "John Doe")
- group.add_owner(user)
- end
-
- step 'I visit group "TestGroup" page' do
- visit group_path(Group.find_by(name: "TestGroup"))
- end
-
- step 'I visit group "TestGroup" issues page' do
- visit issues_group_path(Group.find_by(name: "TestGroup"))
- end
-
- step 'I visit group "TestGroup" merge requests page' do
- visit merge_requests_group_path(Group.find_by(name: "TestGroup"))
- end
-
- step 'I visit group "TestGroup" members page' do
- visit group_group_members_path(Group.find_by(name: "TestGroup"))
- end
-
- step 'I should not see project "Enterprise" items' do
- expect(page).not_to have_content "Enterprise"
- end
-
- step 'I should see project "Internal" items' do
- expect(page).to have_content "Internal"
- end
-
- step 'I should not see project "Internal" items' do
- expect(page).not_to have_content "Internal"
- end
-
- step 'I should see project "Community" items' do
- expect(page).to have_content "Community"
- end
-
- step 'I change filter to Everyone\'s' do
- click_link "Everyone's"
- end
-
- step 'I should see group member "John Doe"' do
- expect(page).to have_content "John Doe"
- end
-
- protected
-
- def group_has_project(groupname, projectname, visibility_level)
- group = Group.find_by(name: groupname) || create(:group, name: groupname)
- project = create(:project,
- namespace: group,
- name: projectname,
- path: "#{groupname}-#{projectname}",
- visibility_level: visibility_level
- )
- create(:issue,
- title: "#{projectname} feature",
- project: project
- )
- create(:merge_request,
- title: "#{projectname} feature implemented",
- source_project: project,
- target_project: project
- )
- create(:closed_issue_event,
- project: project
- )
- end
-end
diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb
index 0ab1012660c..97bcca7730b 100644
--- a/features/steps/group/members.rb
+++ b/features/steps/group/members.rb
@@ -9,14 +9,6 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
expect(group_members_list).to have_content("John Doe")
end
- step 'I should not see user "John Doe" in team list' do
- expect(group_members_list).not_to have_content("John Doe")
- end
-
- step 'I should see user "Mary Jane" in team list' do
- expect(group_members_list).to have_content("Mary Jane")
- end
-
step 'I should not see user "Mary Jane" in team list' do
expect(group_members_list).not_to have_content("Mary Jane")
end
@@ -41,13 +33,6 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
# poltergeist always confirms popups.
end
- step 'I search for \'Mary\' member' do
- page.within '.member-search-form' do
- fill_in 'search', with: 'Mary'
- find('.member-search-btn').click
- end
- end
-
step 'I change the "Mary Jane" role to "Developer"' do
member = mary_jane_member
diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb
deleted file mode 100644
index 818bbb50d0e..00000000000
--- a/features/steps/group/milestones.rb
+++ /dev/null
@@ -1,135 +0,0 @@
-class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
- include WaitForRequests
- include SharedAuthentication
- include SharedPaths
- include SharedGroup
- include SharedUser
-
- step 'I click on group milestones' do
- visit group_milestones_path('owned')
- end
-
- step 'I should see group milestones index page has no milestones' do
- expect(page).to have_content('No milestones to show')
- end
-
- step 'Group has projects with milestones' do
- group_milestone
- end
-
- step 'I should see group milestones index page with milestones' do
- expect(page).to have_content('Version 7.2')
- expect(page).to have_content('GL-113')
- expect(page).to have_link('3 Issues', href: issues_group_path("owned", milestone_title: "Version 7.2"))
- expect(page).to have_link('0 Merge Requests', href: merge_requests_group_path("owned", milestone_title: "GL-113"))
- end
-
- step 'I click on one group milestone' do
- milestones = Milestone.where(title: 'GL-113')
- @global_milestone = GlobalMilestone.new('GL-113', milestones)
-
- click_link 'GL-113'
- end
-
- step 'I should see group milestone with descriptions and expiry date' do
- expect(page).to have_content('expires on Aug 20, 2114')
- end
-
- step 'I should see group milestone with all issues and MRs assigned to that milestone' do
- 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: project_issue_path(issue.project, issue))
- end
-
- step 'I fill milestone name' do
- fill_in 'milestone_title', with: 'v2.9.0'
- end
-
- step 'I click new milestone button' do
- page.within('.nav-controls') do
- click_link "New milestone"
- end
- end
-
- step 'I press create mileston button' do
- click_button "Create milestone"
- end
-
- step 'group milestone should be created' do
- group = Group.find_by(name: 'Owned')
- expect(page).to have_content group.milestones.find_by_title('v2.9.0').title
- end
-
- step 'I should see the "bug" label' do
- page.within('#tab-issues') do
- expect(page).to have_content 'bug'
- end
- end
-
- step 'I should see the "feature" label' do
- page.within('#tab-issues') do
- expect(page).to have_content 'bug'
- end
- end
-
- step 'I should see the project name in the Issue row' do
- page.within('#tab-issues') do
- @global_milestone.projects.each do |project|
- expect(page).to have_content project.name
- end
- end
- end
-
- step 'I click on the "Labels" tab' do
- page.within('.content .nav-links') do
- page.find(:xpath, "//a[@href='#tab-labels']").click
- end
- 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'
- end
- end
-
- private
-
- def group_milestone
- group = owned_group
-
- %w(gitlabhq gitlab-ci cookbook-gitlab).each do |path|
- project = create(:project, path: path, group: group)
- milestone = create :milestone, title: "Version 7.2", project: project
-
- create(:label, project: project, title: 'bug')
- create(:label, project: project, title: 'feature')
-
- create :issue,
- project: project,
- assignees: [current_user],
- author: current_user,
- milestone: milestone
-
- milestone = create :milestone,
- title: "GL-113",
- project: project,
- due_date: '2114-08-20',
- description: 'Lorem Ipsum is simply dummy text'
-
- issue = create :issue,
- project: project,
- assignees: [current_user],
- author: current_user,
- milestone: milestone
-
- issue.labels << project.labels.find_by(title: 'bug')
- issue.labels << project.labels.find_by(title: 'feature')
- end
-
- current_user.refresh_authorized_projects
- end
-end
diff --git a/features/steps/groups.rb b/features/steps/groups.rb
deleted file mode 100644
index 753694a5392..00000000000
--- a/features/steps/groups.rb
+++ /dev/null
@@ -1,147 +0,0 @@
-class Spinach::Features::Groups < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedGroup
- include SharedUser
-
- step 'I should see group "Owned"' do
- expect(page).to have_content 'Owned'
- end
-
- step 'I am a signed out user' do
- logout
- end
-
- step 'Group "Owned" has a public project "Public-project"' do
- group = owned_group
-
- @project = create :project, :public,
- group: group,
- name: "Public-project"
- end
-
- step 'I should see project "Public-project"' do
- expect(page).to have_content 'Public-project'
- end
-
- step 'I should see group "Owned" projects list' do
- owned_group.projects.each do |project|
- expect(page).to have_link project.name
- end
- end
-
- step 'I should see projects activity feed' do
- expect(page).to have_content 'joined project'
- end
-
- step 'I should see issues from group "Owned" assigned to me' do
- assigned_to_me(:issues).each do |issue|
- expect(page).to have_content issue.title
- end
- end
-
- step 'I should not see issues from the archived project' do
- @archived_project.issues.each do |issue|
- expect(page).not_to have_content issue.title
- end
- end
-
- step 'I should not see merge requests from the archived project' do
- @archived_project.merge_requests.each do |mr|
- expect(page).not_to have_content mr.title
- end
- end
-
- step 'I should see merge requests from group "Owned" assigned to me' do
- assigned_to_me(:merge_requests).each do |issue|
- expect(page).to have_content issue.title[0..80]
- end
- end
-
- step 'project from group "Owned" has issues assigned to me' do
- create :issue,
- project: project,
- assignees: [current_user],
- author: current_user
- end
-
- step 'project from group "Owned" has merge requests assigned to me' do
- create :merge_request,
- source_project: project,
- target_project: project,
- assignee: current_user,
- author: current_user
- end
-
- step 'I change group "Owned" avatar' do
- attach_file(:group_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif'))
- click_button "Save group"
- owned_group.reload
- end
-
- 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/-/system/group/avatar/#{Group.find_by(name: "Owned").id}/banana_sample.gif"
- end
-
- step 'I should see the "Remove avatar" button' do
- expect(page).to have_link("Remove avatar")
- end
-
- step 'I have group "Owned" avatar' do
- attach_file(:group_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif'))
- click_button "Save group"
- owned_group.reload
- end
-
- step 'I remove group "Owned" avatar' do
- click_link "Remove avatar"
- owned_group.reload
- end
-
- step 'I should not see group "Owned" avatar' do
- expect(owned_group.avatar?).to eq false
- end
-
- step 'I should not see the "Remove avatar" button' do
- expect(page).not_to have_link("Remove avatar")
- end
-
- step 'Group "Owned" has archived project' do
- group = Group.find_by(name: 'Owned')
- @archived_project = create(:project, :archived, namespace: group, path: "archived-project")
- end
-
- step 'I should see "archived" label' do
- expect(page).to have_xpath("//span[@class='label label-warning']", text: 'archived')
- end
-
- step 'I visit group "NonExistentGroup" page' do
- visit group_path("NonExistentGroup")
- end
-
- step 'the archived project have some issues' do
- create :issue,
- project: @archived_project,
- assignees: [current_user],
- author: current_user
- end
-
- step 'the archived project have some merge requests' do
- create :merge_request,
- source_project: @archived_project,
- target_project: @archived_project,
- assignee: current_user,
- author: current_user
- end
-
- private
-
- def assigned_to_me(key)
- project.send(key).assigned_to(current_user)
- end
-
- def project
- owned_group.projects.first
- end
-end
diff --git a/features/steps/invites.rb b/features/steps/invites.rb
deleted file mode 100644
index dac972172aa..00000000000
--- a/features/steps/invites.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-class Spinach::Features::Invites < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedUser
- include SharedGroup
-
- step '"John Doe" has invited "user@example.com" to group "Owned"' do
- user = User.find_by(name: "John Doe")
- group = Group.find_by(name: "Owned")
- group.add_developer("user@example.com", user)
- end
-
- step 'I visit the invitation page' do
- group = Group.find_by(name: "Owned")
- invite = group.group_members.invite.last
- invite.generate_invite_token!
- @raw_invite_token = invite.raw_invite_token
- visit invite_path(@raw_invite_token)
- end
-
- step 'I should be redirected to the sign in page' do
- expect(current_path).to eq(new_user_session_path)
- end
-
- step 'I should see a notice telling me to sign in' do
- expect(page).to have_content "To accept this invitation, sign in"
- end
-
- step 'I should be redirected to the invitation page' do
- expect(current_path).to eq(invite_path(@raw_invite_token))
- end
-
- step 'I should see the invitation details' do
- expect(page).to have_content("You have been invited by John Doe to join group Owned as Developer.")
- end
-
- step "I should see a message telling me I'm already a member" do
- expect(page).to have_content("However, you are already a member of this group.")
- end
-
- step 'I should see an "Accept invitation" button' do
- expect(page).to have_link("Accept invitation")
- end
-
- step 'I should see a "Decline" button' do
- expect(page).to have_link("Decline")
- end
-
- step 'I click the "Accept invitation" button' do
- page.click_link "Accept invitation"
- end
-
- step 'I should be redirected to the group page' do
- group = Group.find_by(name: "Owned")
- expect(current_path).to eq(group_path(group))
- end
-
- step 'I should see a notice telling me I have access' do
- expect(page).to have_content("You have been granted Developer access to group Owned.")
- end
-
- step 'I click the "Decline" button' do
- page.click_link "Decline"
- end
-
- step 'I should be redirected to the dashboard' do
- expect(current_path).to eq(dashboard_projects_path)
- end
-
- step 'I should see a notice telling me I have declined' do
- expect(page).to have_content("You have declined the invitation to join group Owned.")
- end
-
- step "I visit the invitation's decline page" do
- group = Group.find_by(name: "Owned")
- invite = group.group_members.invite.last
- invite.generate_invite_token!
- @raw_invite_token = invite.raw_invite_token
- visit decline_invite_path(@raw_invite_token)
- end
-end
diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb
deleted file mode 100644
index 4b88cb5e27f..00000000000
--- a/features/steps/profile/profile.rb
+++ /dev/null
@@ -1,226 +0,0 @@
-class Spinach::Features::Profile < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
-
- step 'I should see my profile info' do
- expect(page).to have_content "This information will appear on your profile"
- end
-
- step 'I change my profile info' do
- fill_in 'user_skype', with: 'testskype'
- fill_in 'user_linkedin', with: 'testlinkedin'
- fill_in 'user_twitter', with: 'testtwitter'
- fill_in 'user_website_url', with: 'testurl'
- fill_in 'user_location', with: 'Ukraine'
- fill_in 'user_bio', with: 'I <3 GitLab'
- fill_in 'user_organization', with: 'GitLab'
- click_button 'Update profile settings'
- @user.reload
- end
-
- step 'I should see new profile info' do
- expect(@user.skype).to eq 'testskype'
- expect(@user.linkedin).to eq 'testlinkedin'
- expect(@user.twitter).to eq 'testtwitter'
- expect(@user.website_url).to eq 'testurl'
- expect(@user.bio).to eq 'I <3 GitLab'
- expect(@user.organization).to eq 'GitLab'
- expect(find('#user_location').value).to eq 'Ukraine'
- end
-
- step 'I change my avatar' do
- attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif'))
- click_button "Update profile settings"
- @user.reload
- end
-
- step 'I should see new avatar' do
- expect(@user.avatar).to be_instance_of AvatarUploader
- 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
- expect(page).to have_link("Remove avatar")
- end
-
- step 'I have an avatar' do
- attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif'))
- click_button "Update profile settings"
- @user.reload
- end
-
- step 'I remove my avatar' do
- click_link "Remove avatar"
- @user.reload
- end
-
- step 'I should see my gravatar' do
- expect(@user.avatar?).to eq false
- end
-
- step 'I should not see the "Remove avatar" button' do
- expect(page).not_to have_link("Remove avatar")
- end
-
- step 'I should see the gravatar host link' do
- expect(page).to have_link("gravatar.com")
- end
-
- step 'I try change my password w/o old one' do
- page.within '.update-password' do
- fill_in "user_password", with: "22233344"
- fill_in "user_password_confirmation", with: "22233344"
- click_button "Save password"
- end
- end
-
- step 'I change my password' do
- page.within '.update-password' do
- fill_in "user_current_password", with: "12345678"
- fill_in "user_password", with: "22233344"
- fill_in "user_password_confirmation", with: "22233344"
- click_button "Save password"
- end
- end
-
- step 'I unsuccessfully change my password' do
- page.within '.update-password' do
- fill_in "user_current_password", with: "12345678"
- fill_in "user_password", with: "password"
- fill_in "user_password_confirmation", with: "confirmation"
- click_button "Save password"
- end
- end
-
- step "I should see a missing password error message" do
- page.within ".flash-container" do
- expect(page).to have_content "You must provide a valid current password"
- end
- end
-
- step "I should see a password error message" do
- page.within '.alert-danger' do
- expect(page).to have_content "Password confirmation doesn't match"
- end
- end
-
- step 'I have activity' do
- create(:closed_issue_event, author: current_user)
- end
-
- step 'I should see my activity' do
- expect(page).to have_content "Signed in with standard authentication"
- end
-
- step 'my password is expired' do
- current_user.update_attributes(password_expires_at: Time.now - 1.hour)
- end
-
- step "I am not an ldap user" do
- current_user.identities.delete
- expect(current_user.ldap_user?).to eq false
- end
-
- step 'I redirected to expired password page' do
- expect(current_path).to eq new_profile_password_path
- end
-
- step 'I submit new password' do
- fill_in :user_current_password, with: '12345678'
- fill_in :user_password, with: '12345678'
- fill_in :user_password_confirmation, with: '12345678'
- click_button "Set new password"
- end
-
- step 'I redirected to sign in page' do
- expect(current_path).to eq new_user_session_path
- end
-
- step 'I should be redirected to password page' do
- expect(current_path).to eq edit_profile_password_path
- end
-
- step 'I should be redirected to account page' do
- expect(current_path).to eq profile_account_path
- end
-
- step 'I click on my profile picture' do
- find(:css, '.header-user-dropdown-toggle').click
-
- page.within ".header-user" do
- click_link "Profile"
- end
- end
-
- step 'I should see my user page' do
- page.within ".cover-block" do
- expect(page).to have_content current_user.name
- expect(page).to have_content current_user.username
- end
- end
-
- step 'I have group with projects' do
- @group = create(:group)
- @group.add_owner(current_user)
- @project = create(:project, :repository, namespace: @group)
- @event = create(:closed_issue_event, project: @project)
-
- @project.team << [current_user, :master]
- end
-
- step 'I should see groups I belong to' do
- page.within ".content" do
- click_link "Groups"
- end
-
- page.within "#groups" do
- expect(page).to have_content @group.name
- end
- end
-
- step 'I should see application form' do
- expect(page).to have_content "Add new application"
- end
-
- step 'I fill application form out and submit' do
- fill_in :doorkeeper_application_name, with: 'test'
- fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com'
- click_on "Save application"
- end
-
- step 'I see application' do
- expect(page).to have_content "Application: test"
- expect(page).to have_content "Application Id"
- expect(page).to have_content "Secret"
- end
-
- step 'I click edit' do
- click_on "Edit"
- end
-
- step 'I see edit application form' do
- expect(page).to have_content "Edit application"
- end
-
- step 'I change name of application and submit' do
- expect(page).to have_content "Edit application"
- fill_in :doorkeeper_application_name, with: 'test_changed'
- click_on "Save application"
- end
-
- step 'I see that application was changed' do
- expect(page).to have_content "test_changed"
- expect(page).to have_content "Application Id"
- expect(page).to have_content "Secret"
- end
-
- step 'I click to remove application' do
- page.within '.oauth-applications' do
- click_on "Destroy"
- end
- end
-
- step "I see that application is removed" do
- expect(page.find(".oauth-applications")).not_to have_content "test_changed"
- end
-end
diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb
deleted file mode 100644
index 1a18f1d7065..00000000000
--- a/features/steps/project/active_tab.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
- include SharedActiveTab
- include SharedProjectTab
-
- # Sub Tabs: Home
-
- step 'I click the "Team" tab' do
- click_link('Members')
- end
-
- step 'I click the "Attachments" tab' do
- click_link('Attachments')
- end
-
- step 'I click the "Snippets" tab' do
- page.within('.layout-nav') do
- click_link('Snippets')
- end
- end
-
- step 'I click the "Edit Project"' do
- page.within '.nav-sidebar' do
- click_link('Edit Project')
- end
- end
-
- step 'I click the "Integrations" tab' do
- page.within '.nav-sidebar' do
- click_link('Integrations')
- end
- end
-
- step 'I click the "Repository" tab' do
- page.within '.sidebar-top-level-items > .active' do
- click_link('Repository')
- end
- end
-
- step 'I click the "Activity" tab' do
- page.within '.sidebar-top-level-items > .active' do
- click_link('Activity')
- end
- end
-
- step 'the active sub tab should be Members' do
- ensure_active_sub_tab('Members')
- end
-
- step 'the active sub tab should be Integrations' do
- ensure_active_sub_tab('Integrations')
- end
-
- step 'the active sub tab should be Repository' do
- ensure_active_sub_tab('Repository')
- end
-
- step 'the active sub tab should be Pages' do
- ensure_active_sub_tab('Pages')
- end
-
- step 'the active sub tab should be Activity' do
- ensure_active_sub_tab('Activity')
- end
-
- # Sub Tabs: Commits
-
- step 'I click the "Compare" tab' do
- click_link('Compare')
- end
-
- step 'I click the "Branches" tab' do
- page.within '.nav-sidebar' do
- click_link('Branches')
- end
- end
-
- step 'I click the "Tags" tab' do
- click_link('Tags')
- end
-
- step 'I click the "Charts" tab' do
- page.within('.sidebar-top-level-items > .active') do
- click_link('Charts')
- end
- end
-
- step 'the active sub tab should be Compare' do
- ensure_active_sub_tab('Compare')
- end
-
- step 'the active sub tab should be Branches' do
- ensure_active_sub_tab('Branches')
- end
-
- step 'the active sub tab should be Tags' do
- ensure_active_sub_tab('Tags')
- end
-
- # Sub Tabs: Issues
-
- step 'I click the "Milestones" sub tab' do
- page.within('.nav-sidebar') do
- click_link('Milestones')
- end
- end
-
- step 'I click the "Labels" sub tab' do
- page.within('.nav-sidebar') do
- click_link('Labels')
- end
- end
-
- step 'the active sub tab should be Issues' do
- ensure_active_sub_tab('Issues')
- end
-
- step 'the active sub tab should be Milestones' do
- ensure_active_sub_tab('Milestones')
- end
-
- step 'the active sub tab should be Labels' do
- ensure_active_sub_tab('Labels')
- end
-end
diff --git a/features/steps/project/commits/commits.rb b/features/steps/project/commits/commits.rb
index c623a516c47..959cf7d3e54 100644
--- a/features/steps/project/commits/commits.rb
+++ b/features/steps/project/commits/commits.rb
@@ -154,8 +154,8 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
step 'commit has ci status' do
@project.enable_ci
- pipeline = create :ci_pipeline, project: @project, sha: sample_commit.id
- create :ci_build, pipeline: pipeline
+ @pipeline = create(:ci_pipeline, project: @project, sha: sample_commit.id)
+ create(:ci_build, pipeline: @pipeline)
end
step 'repository contains ".gitlab-ci.yml" file' do
@@ -163,7 +163,7 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
end
step 'I see commit ci info' do
- expect(page).to have_content "Pipeline #1 pending"
+ expect(page).to have_content "Pipeline ##{@pipeline.id} pending"
end
step 'I search "submodules" commits' do
@@ -180,11 +180,13 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
dropdown.find(".compare-dropdown-toggle").click
dropdown.find('.dropdown-menu', visible: true)
dropdown.fill_in("Filter by Git revision", with: selection)
+
if is_commit
dropdown.find('input[type="search"]').send_keys(:return)
else
find_link(selection, visible: true).click
end
+
dropdown.find('.dropdown-menu', visible: false)
end
end
diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb
index b4403becb0d..9db31522c5c 100644
--- a/features/steps/project/deploy_keys.rb
+++ b/features/steps/project/deploy_keys.rb
@@ -48,11 +48,11 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
step 'other projects have deploy keys' do
@second_project = create(:project, namespace: create(:group))
- @second_project.team << [current_user, :master]
+ @second_project.add_master(current_user)
create(:deploy_keys_project, project: @second_project)
@third_project = create(:project, namespace: create(:group))
- @third_project.team << [current_user, :master]
+ @third_project.add_master(current_user)
create(:deploy_keys_project, project: @third_project, deploy_key: @second_project.deploy_keys.first)
end
diff --git a/features/steps/project/ff_merge_requests.rb b/features/steps/project/ff_merge_requests.rb
index d68fe71e16e..27efcfd65b6 100644
--- a/features/steps/project/ff_merge_requests.rb
+++ b/features/steps/project/ff_merge_requests.rb
@@ -17,6 +17,10 @@ class Spinach::Features::ProjectFfMergeRequests < Spinach::FeatureSteps
author: project.users.first)
end
+ step 'merge request is mergeable' do
+ expect(page).to have_button 'Merge'
+ end
+
step 'I should see ff-only merge button' do
expect(page).to have_content "Fast-forward merge without a merge commit"
expect(page).to have_button 'Merge'
@@ -45,6 +49,10 @@ class Spinach::Features::ProjectFfMergeRequests < Spinach::FeatureSteps
project.save!
end
+ step 'I should see rebase button' do
+ expect(page).to have_button "Rebase"
+ end
+
step 'merge request "Bug NS-05" is rebased' do
merge_request.source_branch = 'flatten-dir'
merge_request.target_branch = 'improve/awesome'
@@ -59,6 +67,20 @@ class Spinach::Features::ProjectFfMergeRequests < Spinach::FeatureSteps
merge_request.save!
end
+ step 'rebase before merge enabled' do
+ project = merge_request.target_project
+ project.merge_requests_rebase_enabled = true
+ project.save!
+ end
+
+ step 'I press rebase button' do
+ click_button "Rebase"
+ end
+
+ step "I should see rebase in progress message" do
+ expect(page).to have_content("Rebase in progress")
+ end
+
def merge_request
@merge_request ||= MergeRequest.find_by!(title: "Bug NS-05")
end
diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb
deleted file mode 100644
index 60707f26aee..00000000000
--- a/features/steps/project/fork.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-class Spinach::Features::ProjectFork < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
-
- step 'I click link "Fork"' do
- expect(page).to have_content "Shop"
- click_link "Fork"
- end
-
- step 'I am a member of project "Shop"' do
- @project = create(:project, :repository, name: "Shop")
- @project.team << [@user, :reporter]
- end
-
- step 'I should see the forked project page' do
- expect(page).to have_content "Forked from"
- end
-
- step 'I already have a project named "Shop" in my namespace' do
- @my_project = create(:project, :repository, name: "Shop", namespace: current_user.namespace)
- end
-
- step 'I should see a "Name has already been taken" warning' do
- expect(page).to have_content "Name has already been taken"
- end
-
- step 'I fork to my namespace' do
- page.within '.fork-thumbnail-container' do
- click_link current_user.name
- end
- end
-
- step 'I should see "New merge request"' do
- expect(page).to have_content(/new merge request/i)
- end
-
- step 'I goto the Merge Requests page' do
- page.within '.nav-sidebar' do
- first(:link, "Merge Requests").click
- end
- end
-
- step 'I click link "New merge request"' do
- 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
- current_path.should have_content(/#{current_user.namespace.name}/i)
- end
-
- step 'I visit the forks page of the "Shop" project' do
- @project = Project.where(name: 'Shop').first
- visit project_forks_path(@project)
- end
-
- step 'I should see my fork on the list' do
- page.within('.js-projects-list-holder') do
- project = @user.fork_of(@project.reload)
- expect(page).to have_content("#{project.namespace.human_name} / #{project.name}")
- end
- end
-
- step 'I make forked repo invalid' do
- project = @user.fork_of(@project.reload)
- project.path = 'test-crappy-path'
- project.save!
- end
-
- step 'There is an existent fork of the "Shop" project' do
- user = create(:user, name: 'Mike')
- @project.team << [user, :reporter]
- @forked_project = Projects::ForkService.new(@project, user).execute
- end
-
- step 'I should not see the other fork listed' do
- expect(page).not_to have_content("#{@forked_project.namespace.human_name} / #{@forked_project.name}")
- end
-
- step 'I should see a private fork notice' do
- expect(page).to have_content("1 private fork")
- end
-end
diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb
index 6781a906a94..fd51ee1a316 100644
--- a/features/steps/project/forked_merge_requests.rb
+++ b/features/steps/project/forked_merge_requests.rb
@@ -10,7 +10,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
step 'I am a member of project "Shop"' do
@project = ::Project.find_by(name: "Shop")
@project ||= create(:project, :repository, name: "Shop")
- @project.team << [@user, :reporter]
+ @project.add_reporter(@user)
end
step 'I have a project forked off of "Shop" called "Forked Shop"' do
diff --git a/features/steps/project/graph.rb b/features/steps/project/graph.rb
deleted file mode 100644
index b9cddf4041d..00000000000
--- a/features/steps/project/graph.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-class Spinach::Features::ProjectGraph < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedProject
-
- step 'page should have graphs' do
- expect(page).to have_selector ".stat-graph"
- end
-
- When 'I visit project "Shop" graph page' do
- visit project_graph_path(project, "master")
- end
-
- step 'I visit project "Shop" commits graph page' do
- visit commits_project_graph_path(project, "master")
- end
-
- step 'I visit project "Shop" languages graph page' do
- visit languages_project_graph_path(project, "master")
- end
-
- step 'I visit project "Shop" chart page' do
- visit charts_project_graph_path(project, "master")
- end
-
- step 'page should have languages graphs' do
- expect(page).to have_content /Ruby 66.* %/
- expect(page).to have_content /JavaScript 22.* %/
- end
-
- step 'page should have commits graphs' do
- expect(page).to have_content "Commit statistics for master"
- expect(page).to have_content "Commits per day of month"
- end
-
- step 'I visit project "Shop" CI graph page' do
- visit ci_project_graph_path(project, 'master')
- end
-
- step 'page should have CI graphs' do
- expect(page).to have_content 'Overall'
- expect(page).to have_content 'Pipelines for last week'
- expect(page).to have_content 'Pipelines for last month'
- expect(page).to have_content 'Pipelines for last year'
- expect(page).to have_content 'Commit duration in minutes for last 30 commits'
- end
-
- def project
- @project ||= Project.find_by(name: "Shop")
- end
-end
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index 3843374678c..baa78c23203 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -7,36 +7,14 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
include SharedMarkdown
include SharedUser
- step 'I should see "Release 0.4" in issues' do
- expect(page).to have_content "Release 0.4"
- end
-
step 'I should not see "Release 0.3" in issues' do
expect(page).not_to have_content "Release 0.3"
end
- step 'I should not see "Tweet control" in issues' do
- expect(page).not_to have_content "Tweet control"
- end
-
- step 'I should see that I am subscribed' do
- wait_for_requests
- expect(find('.js-issuable-subscribe-button span')).to have_content 'Unsubscribe'
- end
-
- step 'I should see that I am unsubscribed' do
- wait_for_requests
- expect(find('.js-issuable-subscribe-button span')).to have_content 'Subscribe'
- end
-
step 'I click link "Closed"' do
find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click
end
- step 'I click button "Unsubscribe"' do
- click_on "Unsubscribe"
- end
-
step 'I should see "Release 0.3" in issues' do
expect(page).to have_content "Release 0.3"
end
@@ -51,24 +29,10 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
expect(find('.issues-state-filters > .active')).to have_content 'All'
end
- step 'I click link "Release 0.4"' do
- click_link "Release 0.4"
- end
-
- step 'I should see issue "Release 0.4"' do
- expect(page).to have_content "Release 0.4"
- end
-
step 'I should see issue "Tweet control"' do
expect(page).to have_content "Tweet control"
end
- step 'I click link "New issue"' do
- 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
page.find('.js-author-search').click
sleep 1
@@ -81,18 +45,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
expect(users[1].text).to eq "#{current_user.name} #{current_user.to_reference}"
end
- step 'I submit new issue "500 error on profile"' do
- fill_in "issue_title", with: "500 error on profile"
- click_button "Submit issue"
- end
-
- step 'I submit new issue "500 error on profile" with label \'bug\'' do
- fill_in "issue_title", with: "500 error on profile"
- click_button "Label"
- click_link "bug"
- click_button "Submit issue"
- end
-
step 'I click link "500 error on profile"' do
click_link "500 error on profile"
end
@@ -103,13 +55,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
end
- step 'I should see issue "500 error on profile"' do
- issue = Issue.find_by(title: "500 error on profile")
- expect(page).to have_content issue.title
- expect(page).to have_content issue.author_name
- expect(page).to have_content issue.project.name
- end
-
step 'I fill in issue search with "Re"' do
filter_issue "Re"
end
@@ -163,49 +108,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
expect(find(issues_assignee_selector)).to have_content(assignee_name)
end
- step 'project "Shop" have "Release 0.4" open issue' do
- create(:issue,
- title: "Release 0.4",
- project: project,
- author: project.users.first,
- description: "# Description header"
- )
- wait_for_requests
- end
-
- step 'project "Shop" have "Tweet control" open issue' do
- create(:issue,
- title: "Tweet control",
- project: project,
- author: project.users.first)
- end
-
- step 'project "Shop" have "Bugfix" open issue' do
- create(:issue,
- title: "Bugfix",
- project: project,
- author: project.users.first)
- end
-
- step 'project "Shop" have "Release 0.3" closed issue' do
- create(:closed_issue,
- title: "Release 0.3",
- project: project,
- author: project.users.first)
- end
-
- step 'issue "Release 0.4" have 2 upvotes and 1 downvote' do
- awardable = Issue.find_by(title: 'Release 0.4')
- create_list(:award_emoji, 2, awardable: awardable)
- create(:award_emoji, :downvote, awardable: awardable)
- end
-
- step 'issue "Tweet control" have 1 upvote and 2 downvotes' do
- awardable = Issue.find_by(title: 'Tweet control')
- create(:award_emoji, :upvote, awardable: awardable)
- create_list(:award_emoji, 2, awardable: awardable, name: 'thumbsdown')
- end
-
step 'The list should be sorted by "Least popular"' do
page.within '.issues-list' do
page.within 'li.issue:nth-child(1)' do
@@ -225,69 +127,16 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
end
- step 'The list should be sorted by "Popularity"' do
- page.within '.issues-list' do
- page.within 'li.issue:nth-child(1)' do
- expect(page).to have_content 'Release 0.4'
- expect(page).to have_content '2 1'
- end
-
- page.within 'li.issue:nth-child(2)' do
- expect(page).to have_content 'Tweet control'
- expect(page).to have_content '1 2'
- end
-
- page.within 'li.issue:nth-child(3)' do
- expect(page).to have_content 'Bugfix'
- expect(page).not_to have_content '0 0'
- end
- end
- end
-
- step 'empty project "Empty Project"' do
- create :project_empty_repo, name: 'Empty Project', namespace: @user.namespace
- end
-
When 'I visit empty project page' do
project = Project.find_by(name: 'Empty Project')
visit project_path(project)
end
- step 'I see empty project details with ssh clone info' do
- project = Project.find_by(name: 'Empty Project')
- page.all(:css, '.git-empty .clone').each do |element|
- expect(element.text).to include(project.url_to_repo)
- end
- end
-
When "I visit project \"Community\" issues page" do
project = Project.find_by(name: 'Community')
visit project_issues_path(project)
end
- When "I visit empty project's issues page" do
- project = Project.find_by(name: 'Empty Project')
- visit project_issues_path(project)
- end
-
- step 'I leave a comment with code block' do
- page.within(".js-main-target-form") do
- fill_in "note[note]", with: "```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```"
- click_button "Comment"
- sleep 0.05
- end
- end
-
- step 'I should see an error alert section within the comment form' do
- page.within(".js-main-target-form") do
- find(".error-alert")
- end
- end
-
- step 'The code block should be unchanged' do
- expect(page).to have_content("```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```")
- end
-
step 'project \'Shop\' has issue \'Bugfix1\' with description: \'Description for issue1\'' do
create(:issue, title: 'Bugfix1', description: 'Description for issue1', project: project)
end
@@ -320,36 +169,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
expect(page).not_to have_content 'Bugfix1'
end
- step 'issue \'Release 0.4\' has label \'bug\'' do
- label = project.labels.create!(name: 'bug', color: '#990000')
- issue = Issue.find_by!(title: 'Release 0.4')
- issue.labels << label
- end
-
- step 'I click label \'bug\'' do
- page.within ".issues-list" do
- click_link 'bug'
- end
- end
-
- step 'I should not see labels field' do
- page.within '.issue-form' do
- expect(page).not_to have_content("Labels")
- end
- end
-
- step 'I should not see milestone field' do
- page.within '.issue-form' do
- expect(page).not_to have_content("Milestone")
- end
- end
-
- step 'I should not see assignee field' do
- page.within '.issue-form' do
- expect(page).not_to have_content("Assign to")
- end
- end
-
def filter_issue(text)
fill_in 'issuable_search', with: text
end
diff --git a/features/steps/project/issues/labels.rb b/features/steps/project/issues/labels.rb
deleted file mode 100644
index 196e0fff63a..00000000000
--- a/features/steps/project/issues/labels.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-class Spinach::Features::ProjectIssuesLabels < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedProject
- include SharedPaths
-
- step 'I visit \'bug\' label edit page' do
- visit edit_project_label_path(project, bug_label)
- end
-
- step 'I remove label \'bug\'' do
- page.within "#project_label_#{bug_label.id}" do
- first(:link, 'Delete').click
- end
- end
-
- step 'I delete all labels' do
- page.within '.labels' do
- page.all('.remove-row').each do
- accept_confirm { first('.remove-row').click }
- end
- end
- end
-
- step 'I should see labels help message' do
- page.within '.labels' do
- expect(page).to have_content 'Generate a default set of labels'
- expect(page).to have_content 'New label'
- end
- end
-
- step 'I submit new label \'support\'' do
- fill_in 'Title', with: 'support'
- fill_in 'Background color', with: '#F95610'
- click_button 'Create label'
- end
-
- step 'I submit new label \'bug\'' do
- fill_in 'Title', with: 'bug'
- fill_in 'Background color', with: '#F95610'
- click_button 'Create label'
- end
-
- step 'I submit new label with invalid color' do
- fill_in 'Title', with: 'support'
- fill_in 'Background color', with: '#12'
- click_button 'Create label'
- end
-
- step 'I should see label label exist error message' do
- page.within '.label-form' do
- expect(page).to have_content 'Title has already been taken'
- end
- end
-
- step 'I should see label color error message' do
- page.within '.label-form' do
- expect(page).to have_content 'Color must be a valid color code'
- end
- end
-
- step 'I should see label \'feature\'' do
- page.within '.other-labels .manage-labels-list' do
- expect(page).to have_content 'feature'
- end
- end
-
- step 'I should see label \'bug\'' do
- page.within '.other-labels .manage-labels-list' do
- expect(page).to have_content 'bug'
- end
- end
-
- step 'I should not see label \'bug\'' do
- page.within '.other-labels .manage-labels-list' do
- expect(page).not_to have_content 'bug'
- end
- end
-
- step 'I should see label \'support\'' do
- page.within '.other-labels .manage-labels-list' do
- expect(page).to have_content 'support'
- end
- end
-
- step 'I change label \'bug\' to \'fix\'' do
- fill_in 'Title', with: 'fix'
- fill_in 'Background color', with: '#F15610'
- click_button 'Save changes'
- end
-
- step 'I should see label \'fix\'' do
- page.within '.other-labels .manage-labels-list' do
- expect(page).to have_content 'fix'
- end
- end
-
- def bug_label
- project.labels.find_or_create_by(title: 'bug')
- end
-end
diff --git a/features/steps/project/issues/milestones.rb b/features/steps/project/issues/milestones.rb
index 33a24e8913a..4ce67aa651c 100644
--- a/features/steps/project/issues/milestones.rb
+++ b/features/steps/project/issues/milestones.rb
@@ -3,7 +3,6 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps
include SharedProject
include SharedPaths
include SharedMarkdown
- include CapybaraHelpers
step 'I should see milestone "v2.2"' do
milestone = @project.milestones.find_by(title: "v2.2")
@@ -65,8 +64,12 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps
expect(page).to have_selector('#tab-issues li.issuable-row', count: 4)
end
- step 'I click link to remove milestone' do
- confirm_modal_if_present { click_link 'Delete' }
+ step 'I click button to remove milestone' do
+ click_button 'Delete'
+ end
+
+ step 'I confirm in modal' do
+ click_button 'Delete milestone'
end
step 'I should see no milestones' do
diff --git a/features/steps/project/network_graph.rb b/features/steps/project/network_graph.rb
deleted file mode 100644
index ba98d861e7b..00000000000
--- a/features/steps/project/network_graph.rb
+++ /dev/null
@@ -1,116 +0,0 @@
-class Spinach::Features::ProjectNetworkGraph < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
-
- step 'page should have network graph' do
- expect(page).to have_selector ".network-graph"
- end
-
- When 'I visit project "Shop" network page' do
- # Stub Graph max_size to speed up test (10 commits vs. 650)
- Network::Graph.stub(max_count: 10)
-
- @project = Project.find_by(name: "Shop")
- visit project_network_path(@project, "master")
- end
-
- step "I visit project network page on branch 'test'" do
- visit project_network_path(@project, "'test'")
- end
-
- step 'page should select "master" in select box' do
- expect(page).to have_selector '.dropdown-menu-toggle', text: "master"
- end
-
- step 'page should select "v1.0.0" in select box' do
- expect(page).to have_selector '.dropdown-menu-toggle', text: "v1.0.0"
- end
-
- step 'page should have "master" on graph' do
- page.within '.network-graph' do
- expect(page).to have_content 'master'
- end
- end
-
- step "page should have 'test' on graph" do
- page.within '.network-graph' do
- expect(page).to have_content "'test'"
- end
- end
-
- When 'I switch ref to "feature"' do
- first('.js-project-refs-dropdown').click
-
- page.within '.project-refs-form' do
- click_link 'feature'
- end
- end
-
- When 'I switch ref to "v1.0.0"' do
- first('.js-project-refs-dropdown').click
-
- page.within '.project-refs-form' do
- click_link 'v1.0.0'
- end
- end
-
- When 'click "Show only selected branch" checkbox' do
- find('#filter_ref').click
- end
-
- step 'page should have content not containing "v1.0.0"' do
- page.within '.network-graph' do
- expect(page).to have_content 'Change some files'
- end
- end
-
- step 'page should have "v1.0.0" in title' do
- expect(page).to have_css 'title', text: 'Graph · v1.0.0', visible: false
- end
-
- step 'page should only have content from "v1.0.0"' do
- page.within '.network-graph' do
- expect(page).not_to have_content 'Change some files'
- end
- end
-
- step 'page should select "feature" in select box' do
- expect(page).to have_selector '.dropdown-menu-toggle', text: "feature"
- end
-
- step 'page should select "v1.0.0" in select box' do
- expect(page).to have_selector '.dropdown-menu-toggle', text: "v1.0.0"
- end
-
- step 'page should have "feature" on graph' do
- page.within '.network-graph' do
- expect(page).to have_content 'feature'
- end
- end
-
- When 'I looking for a commit by SHA of "v1.0.0"' do
- page.within ".network-form" do
- fill_in 'extended_sha1', with: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9'
- find('button').click
- end
- sleep 2
- end
-
- step 'page should have "v1.0.0" on graph' do
- page.within '.network-graph' do
- expect(page).to have_content 'v1.0.0'
- end
- end
-
- When 'I look for a commit by ";"' do
- page.within ".network-form" do
- fill_in 'extended_sha1', with: ';'
- find('button').click
- end
- end
-
- step 'I should see non-existent git revision error message' do
- expect(page).to have_selector '.flash-alert', text: "Git revision ';' does not exist."
- end
-end
diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb
deleted file mode 100644
index f03630e5a91..00000000000
--- a/features/steps/project/pages.rb
+++ /dev/null
@@ -1,152 +0,0 @@
-class Spinach::Features::ProjectPages < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
-
- step 'pages are enabled' do
- allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
- allow(Gitlab.config.pages).to receive(:host).and_return('example.com')
- allow(Gitlab.config.pages).to receive(:port).and_return(80)
- allow(Gitlab.config.pages).to receive(:https).and_return(false)
- end
-
- step 'pages are disabled' do
- allow(Gitlab.config.pages).to receive(:enabled).and_return(false)
- end
-
- step 'I visit the Project Pages' do
- 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 '.nav-sidebar' do
- expect(page).to have_link('Pages')
- end
- end
-
- step 'I should not see the "Pages" tab' do
- page.within '.nav-sidebar' do
- expect(page).not_to have_link('Pages')
- end
- end
-
- step 'pages are deployed' do
- pipeline = @project.pipelines.create(ref: 'HEAD',
- sha: @project.commit('HEAD').sha,
- source: :push,
- protected: false)
-
- build = build(:ci_build,
- project: @project,
- pipeline: pipeline,
- ref: 'HEAD',
- legacy_artifacts_file: fixture_file_upload(Rails.root + 'spec/fixtures/pages.zip'),
- legacy_artifacts_metadata: fixture_file_upload(Rails.root + 'spec/fixtures/pages.zip.meta')
- )
-
- result = ::Projects::UpdatePagesService.new(@project, build).execute
- expect(result[:status]).to eq(:success)
- end
-
- step 'I should be able to access the Pages' do
- expect(page).to have_content('Access pages')
- end
-
- step 'I should see that support for domains is disabled' do
- expect(page).to have_content('Support for domains and certificates is disabled')
- end
-
- step 'support for external domains is disabled' do
- allow(Gitlab.config.pages).to receive(:external_http).and_return(nil)
- allow(Gitlab.config.pages).to receive(:external_https).and_return(nil)
- end
-
- step 'pages are exposed on external HTTP address' do
- allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80'])
- allow(Gitlab.config.pages).to receive(:external_https).and_return(nil)
- end
-
- step 'pages are exposed on external HTTPS address' do
- allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80'])
- allow(Gitlab.config.pages).to receive(:external_https).and_return(['1.1.1.1:443'])
- end
-
- step 'I should be able to add a New Domain' do
- expect(page).to have_content('New Domain')
- end
-
- step 'I visit add a new Pages Domain' do
- visit new_project_pages_domain_path(@project)
- end
-
- step 'I fill the domain' do
- fill_in 'Domain', with: 'my.test.domain.com'
- end
-
- step 'I click on "Create New Domain"' do
- click_button 'Create New Domain'
- end
-
- step 'I should see a new domain added' do
- expect(page).to have_content('Domains (1)')
- expect(page).to have_content('my.test.domain.com')
- end
-
- step 'pages domain is added' do
- @project.pages_domains.create!(domain: 'my.test.domain.com')
- end
-
- step 'I should see error message that domain already exists' do
- expect(page).to have_content('Domain has already been taken')
- end
-
- step 'I should see that support for certificates is disabled' do
- expect(page).to have_content('Support for custom certificates is disabled')
- end
-
- step 'I fill the certificate and key' do
- fill_in 'Certificate (PEM)', with: '-----BEGIN CERTIFICATE-----
-MIICGzCCAYSgAwIBAgIBATANBgkqhkiG9w0BAQUFADAbMRkwFwYDVQQDExB0ZXN0
-LWNlcnRpZmljYXRlMB4XDTE2MDIxMjE0MzIwMFoXDTIwMDQxMjE0MzIwMFowGzEZ
-MBcGA1UEAxMQdGVzdC1jZXJ0aWZpY2F0ZTCBnzANBgkqhkiG9w0BAQEFAAOBjQAw
-gYkCgYEApL4J9L0ZxFJ1hI1LPIflAlAGvm6ZEvoT4qKU5Xf2JgU7/2geNR1qlNFa
-SvCc08Knupp5yTgmvyK/Xi09U0N82vvp4Zvr/diSc4A/RA6Mta6egLySNT438kdT
-nY2tR5feoTLwQpX0t4IMlwGQGT5h6Of2fKmDxzuwuyffcIHqLdsCAwEAAaNvMG0w
-DAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUxl9WSxBprB0z0ibJs3rXEk0+95AwCwYD
-VR0PBAQDAgXgMBEGCWCGSAGG+EIBAQQEAwIGQDAeBglghkgBhvhCAQ0EERYPeGNh
-IGNlcnRpZmljYXRlMA0GCSqGSIb3DQEBBQUAA4GBAGC4T8SlFHK0yPSa+idGLQFQ
-joZp2JHYvNlTPkRJ/J4TcXxBTJmArcQgTIuNoBtC+0A/SwdK4MfTCUY4vNWNdese
-5A4K65Nb7Oh1AdQieTBHNXXCdyFsva9/ScfQGEl7p55a52jOPs0StPd7g64uvjlg
-YHi2yesCrOvVXt+lgPTd
------END CERTIFICATE-----'
-
- fill_in 'Key (PEM)', with: '-----BEGIN PRIVATE KEY-----
-MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKS+CfS9GcRSdYSN
-SzyH5QJQBr5umRL6E+KilOV39iYFO/9oHjUdapTRWkrwnNPCp7qaeck4Jr8iv14t
-PVNDfNr76eGb6/3YknOAP0QOjLWunoC8kjU+N/JHU52NrUeX3qEy8EKV9LeCDJcB
-kBk+Yejn9nypg8c7sLsn33CB6i3bAgMBAAECgYA2D26w80T7WZvazYr86BNMePpd
-j2mIAqx32KZHzt/lhh40J/SRtX9+Kl0Y7nBoRR5Ja9u/HkAIxNxLiUjwg9r6cpg/
-uITEF5nMt7lAk391BuI+7VOZZGbJDsq2ulPd6lO+C8Kq/PI/e4kXcIjeH6KwQsuR
-5vrXfBZ3sQfflaiN4QJBANBt8JY2LIGQF8o89qwUpRL5vbnKQ4IzZ5+TOl4RLR7O
-AQpJ81tGuINghO7aunctb6rrcKJrxmEH1whzComybrMCQQDKV49nOBudRBAIgG4K
-EnLzsRKISUHMZSJiYTYnablof8cKw1JaQduw7zgrUlLwnroSaAGX88+Jw1f5n2Lh
-Vlg5AkBDdUGnrDLtYBCDEQYZHblrkc7ZAeCllDOWjxUV+uMqlCv8A4Ey6omvY57C
-m6I8DkWVAQx8VPtozhvHjUw80rZHAkB55HWHAM3h13axKG0htCt7klhPsZHpx6MH
-EPjGlXIT+aW2XiPmK3ZlCDcWIenE+lmtbOpI159Wpk8BGXs/s/xBAkEAlAY3ymgx
-63BDJEwvOb2IaP8lDDxNsXx9XJNVvQbv5n15vNsLHbjslHfAhAbxnLQ1fLhUPqSi
-nNp/xedE1YxutQ==
------END PRIVATE KEY-----'
- end
-
- step 'I click Remove Pages' do
- click_link 'Remove pages'
- end
-
- step 'The Pages should get removed' do
- expect(@project.pages_deployed?).to be_falsey
- end
-end
diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb
index 3a762be8f1f..bba30a72325 100644
--- a/features/steps/project/project.rb
+++ b/features/steps/project/project.rb
@@ -143,7 +143,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps
end
step 'I create bare repo' do
- click_link 'Create empty bare repository'
+ click_link 'Create empty repository'
end
step 'I should see command line instructions' do
diff --git a/features/steps/project/redirects.rb b/features/steps/project/redirects.rb
deleted file mode 100644
index 9ce86ca45d0..00000000000
--- a/features/steps/project/redirects.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedProject
-
- step 'public project "Community"' do
- create(:project, :public, name: 'Community')
- end
-
- step 'private project "Enterprise"' do
- create(:project, :private, name: 'Enterprise')
- end
-
- step 'I visit project "Community" page' do
- project = Project.find_by(name: 'Community')
- visit project_path(project)
- end
-
- step 'I should see project "Community" home page' do
- Gitlab.config.gitlab.should_receive(:host).and_return("www.example.com")
- page.within '.breadcrumbs .breadcrumb-item-text' do
- expect(page).to have_content 'Community'
- end
- end
-
- step 'I visit project "Enterprise" page' do
- project = Project.find_by(name: 'Enterprise')
- visit project_path(project)
- end
-
- step 'I visit project "CommunityDoesNotExist" page' do
- project = Project.find_by(name: 'Community')
- visit project_path(project) + 'DoesNotExist'
- end
-
- step 'I click on "Sign In"' do
- first(:link, "Sign in").click
- end
-
- step 'Authenticate' do
- admin = create(:admin)
- fill_in "user_login", with: admin.email
- fill_in "user_password", with: admin.password
- click_button "Sign in"
- Thread.current[:current_user] = admin
- end
-
- step 'I should be redirected to "Community" page' do
- project = Project.find_by(name: 'Community')
- expect(current_path).to eq "/#{project.full_path}"
- expect(status_code).to eq 200
- end
-
- step 'I get redirected to signin page where I sign in' do
- admin = create(:admin)
- fill_in "user_login", with: admin.email
- fill_in "user_password", with: admin.password
- click_button "Sign in"
- Thread.current[:current_user] = admin
- end
-
- step 'I should be redirected to "Enterprise" page' do
- project = Project.find_by(name: 'Enterprise')
- expect(current_path).to eq "/#{project.full_path}"
- expect(status_code).to eq 200
- end
-end
diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb
index 93b057430d3..afaad4b255e 100644
--- a/features/steps/project/source/browse_files.rb
+++ b/features/steps/project/source/browse_files.rb
@@ -8,7 +8,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
step "I don't have write access" do
@project = create(:project, :repository, name: "Other Project", path: "other-project")
- @project.team << [@user, :reporter]
+ @project.add_reporter(@user)
visit project_tree_path(@project, root_ref)
end
diff --git a/features/steps/project/source/markdown_render.rb b/features/steps/project/source/markdown_render.rb
index f6445b57ec0..db99c179439 100644
--- a/features/steps/project/source/markdown_render.rb
+++ b/features/steps/project/source/markdown_render.rb
@@ -10,7 +10,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
step 'I own project "Delta"' do
@project = ::Project.find_by(name: "Delta")
@project ||= create(:project, :repository, name: "Delta", namespace: @user.namespace)
- @project.team << [@user, :master]
+ @project.add_master(@user)
end
step 'I should see files from repository in markdown' do
@@ -193,7 +193,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'The link with text "/ID" should have url "tree/markdownID"' do
- find('a', text: /^\/#id$/)['href'] == current_host + project_tree_path(@project, "markdown") + '#id'
+ find('a', text: %r{^/#id$})['href'] == current_host + project_tree_path(@project, "markdown") + '#id'
end
step 'The link with text "README.mdID" '\
@@ -203,7 +203,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
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 + project_blob_path(@project, "d/markdown/README.md") + '#id'
+ find('a', text: %r{^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
@@ -212,7 +212,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
end
step 'The link with text "/ID" should have url "blob/markdown/README.mdID"' do
- find('a', text: /^\/#id$/)['href'] == current_host + project_blob_path(@project, "markdown/README.md") + '#id'
+ find('a', text: %r{^/#id$})['href'] == current_host + project_blob_path(@project, "markdown/README.md") + '#id'
end
# Wiki
diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb
index c267195f0e8..f5950145348 100644
--- a/features/steps/shared/builds.rb
+++ b/features/steps/shared/builds.rb
@@ -11,7 +11,7 @@ module SharedBuilds
step 'project has a recent build' do
@pipeline = create(:ci_empty_pipeline, project: @project, sha: @project.commit.sha, ref: 'master')
- @build = create(:ci_build, :coverage, pipeline: @pipeline)
+ @build = create(:ci_build, :running, :coverage, :trace_artifact, pipeline: @pipeline)
end
step 'recent build is successful' do
diff --git a/features/steps/shared/group.rb b/features/steps/shared/group.rb
index 03bc7e798e0..0a0588346b1 100644
--- a/features/steps/shared/group.rb
+++ b/features/steps/shared/group.rb
@@ -41,7 +41,7 @@ module SharedGroup
group.add_user(user, role)
project ||= create(:project, :repository, namespace: group)
create(:closed_issue_event, project: project)
- project.team << [user, :master]
+ project.add_master(user)
end
def owned_group
diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb
index f90247c3fe8..a9174efd334 100644
--- a/features/steps/shared/issuable.rb
+++ b/features/steps/shared/issuable.rb
@@ -105,17 +105,6 @@ module SharedIssuable
edit_issuable
end
- step 'I click link "Edit" for the issue' do
- edit_issuable
- end
-
- step 'I sort the list by "Last updated"' do
- find('button.dropdown-toggle').click
- page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do
- click_link "Last updated"
- end
- end
-
step 'I sort the list by "Least popular"' do
find('button.dropdown-toggle').click
@@ -124,18 +113,6 @@ module SharedIssuable
end
end
- step 'I sort the list by "Popularity"' do
- find('button.dropdown-toggle').click
-
- page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do
- click_link 'Popularity'
- end
- end
-
- step 'The list should be sorted by "Last updated"' do
- expect(find('.issues-filters')).to have_content('Last updated')
- end
-
step 'I click link "Next" in the sidebar' do
page.within '.issuable-sidebar' do
click_link 'Next'
diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb
index c2bec2a6320..c66280127e9 100644
--- a/features/steps/shared/markdown.rb
+++ b/features/steps/shared/markdown.rb
@@ -18,43 +18,6 @@ module SharedMarkdown
expect(find('.gfm-form .js-md-preview')).not_to be_visible
end
- step 'The Markdown preview tab should say there is nothing to do' do
- page.within('.gfm-form') do
- find('.js-md-preview-button').click
- expect(find('.js-md-preview')).to have_content('Nothing to preview.')
- end
- end
-
- step 'I should not see the Markdown text field' do
- expect(find('.gfm-form textarea')).not_to be_visible
- end
-
- step 'I should see the Markdown write tab' do
- expect(first('.gfm-form')).to have_link('Write', visible: true)
- end
-
- step 'I should see the Markdown preview' do
- expect(find('.gfm-form')).to have_css('.js-md-preview', visible: true)
- end
-
- step 'The Markdown preview tab should display rendered Markdown' do
- page.within('.gfm-form') do
- find('.js-md-preview-button').click
- expect(find('.js-md-preview')).to have_css('gl-emoji', visible: true)
- end
- end
-
- step 'I write a description like ":+1: Nice"' do
- find('.gfm-form').fill_in 'Description', with: ':+1: Nice'
- end
-
- step 'I preview a description text like "Bug fixed :smile:"' do
- page.within(first('.gfm-form')) do
- fill_in 'Description', with: 'Bug fixed :smile:'
- click_link 'Preview'
- end
- end
-
step 'I haven\'t written any description text' do
find('.gfm-form').fill_in 'Description', with: ''
end
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
index 95f0cd2156e..cbe1cae096e 100644
--- a/features/steps/shared/note.rb
+++ b/features/steps/shared/note.rb
@@ -114,34 +114,12 @@ module SharedNote
end
end
- step 'I should see comment "XML attached"' do
- page.within(".note") do
- expect(page).to have_content("XML attached")
- end
- end
-
step 'I should see no notes at all' do
expect(page).not_to have_css('.note')
end
# Markdown
- step 'I leave a comment with a header containing "Comment with a header"' do
- page.within(".js-main-target-form") do
- fill_in "note[note]", with: "# Comment with a header"
- click_button "Comment"
- end
-
- wait_for_requests
- end
-
- step 'The comment with the header should not have an ID' do
- page.within(".note-body > .note-text") do
- expect(page).to have_content("Comment with a header")
- expect(page).not_to have_css("#comment-with-a-header")
- end
- end
-
step 'I edit the last comment with a +1' do
page.within(".main-notes-list") do
note = find('.note')
diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb
index bff0d58aaf4..cc893b8391e 100644
--- a/features/steps/shared/paths.rb
+++ b/features/steps/shared/paths.rb
@@ -96,10 +96,6 @@ module SharedPaths
visit assigned_issues_dashboard_path
end
- step 'I visit dashboard merge requests page' do
- visit assigned_mrs_dashboard_path
- end
-
step 'I visit dashboard search page' do
visit search_path
end
@@ -200,10 +196,6 @@ module SharedPaths
# Generic Project
# ----------------------------------------
- step "I visit my project's home page" do
- visit project_path(@project)
- end
-
step "I visit my project's settings page" do
visit edit_project_path(@project)
end
@@ -339,20 +331,11 @@ module SharedPaths
visit project_commit_path(@project, sample_commit.id)
end
- step 'I visit project "Shop" issues page' do
- visit project_issues_path(project)
- end
-
step 'I visit issue page "Release 0.4"' do
issue = Issue.find_by(title: "Release 0.4")
visit project_issue_path(issue.project, issue)
end
- step 'I visit project "Shop" labels page' do
- project = Project.find_by(name: 'Shop')
- visit project_labels_path(project)
- end
-
step 'I visit project "Forum" labels page' do
project = Project.find_by(name: 'Forum')
visit project_labels_path(project)
@@ -394,10 +377,6 @@ module SharedPaths
wait_for_requests
end
- step 'I visit project "Shop" merge requests page' do
- visit project_merge_requests_path(project)
- end
-
step 'I visit forked project "Shop" merge requests page' do
visit project_merge_requests_path(project)
end
@@ -418,11 +397,6 @@ module SharedPaths
# Visibility Projects
# ----------------------------------------
- step 'I visit project "Community" page' do
- project = Project.find_by(name: "Community")
- visit project_path(project)
- end
-
step 'I visit project "Community" source page' do
project = Project.find_by(name: 'Community')
visit project_tree_path(project, root_ref)
@@ -442,11 +416,6 @@ module SharedPaths
# Empty Projects
# ----------------------------------------
- step "I visit empty project page" do
- project = Project.find_by(name: "Empty Public Project")
- visit project_path(project)
- end
-
step "I should not see command line instructions" do
expect(page).not_to have_css('.empty_wrapper')
end
diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb
index 5e4edaf99a6..be848ebafa0 100644
--- a/features/steps/shared/project.rb
+++ b/features/steps/shared/project.rb
@@ -4,13 +4,13 @@ module SharedProject
# Create a project without caring about what it's called
step "I own a project" do
@project = create(:project, :repository, namespace: @user.namespace)
- @project.team << [@user, :master]
+ @project.add_master(@user)
end
step "I own a project in some group namespace" do
@group = create(:group, name: 'some group')
@project = create(:project, namespace: @group)
- @project.team << [@user, :master]
+ @project.add_master(@user)
end
step "project exists in some group namespace" do
@@ -22,7 +22,7 @@ module SharedProject
step 'I own project "Shop"' do
@project = Project.find_by(name: "Shop")
@project ||= create(:project, :repository, name: "Shop", namespace: @user.namespace)
- @project.team << [@user, :master]
+ @project.add_master(@user)
end
step 'I disable snippets in project' do
@@ -40,7 +40,7 @@ module SharedProject
step 'I add a user to project "Shop"' do
@project = Project.find_by(name: "Shop")
other_user = create(:user, name: 'Alpha')
- @project.team << [other_user, :master]
+ @project.add_master(other_user)
end
# Create another specific project called "Forum"
@@ -49,14 +49,13 @@ module SharedProject
@project ||= create(:project, :repository, name: "Forum", namespace: @user.namespace, path: 'forum_project')
@project.build_project_feature
@project.project_feature.save
- @project.team << [@user, :master]
+ @project.add_master(@user)
end
# Create an empty project without caring about the name
step 'I own an empty project' do
- @project = create(:project,
- name: 'Empty Project', namespace: @user.namespace)
- @project.team << [@user, :master]
+ @project = create(:project, name: 'Empty Project', namespace: @user.namespace)
+ @project.add_master(@user)
end
step 'I visit my empty project page' do
@@ -83,7 +82,7 @@ module SharedProject
step 'I should see project "Shop" activity feed' do
project = Project.find_by(name: "Shop")
- expect(page).to have_content "#{@user.name} pushed new branch fix at #{project.name_with_namespace}"
+ expect(page).to have_content "#{@user.name} pushed new branch fix at #{project.full_name}"
end
step 'I should see project settings' do
@@ -101,11 +100,11 @@ module SharedProject
# ----------------------------------------
step 'I am member of a project with a guest role' do
- @project.team << [@user, Gitlab::Access::GUEST]
+ @project.add_guest(@user)
end
step 'I am member of a project with a reporter role' do
- @project.team << [@user, Gitlab::Access::REPORTER]
+ @project.add_reporter(@user)
end
# ----------------------------------------
@@ -114,12 +113,12 @@ module SharedProject
step 'I should not see project "Archive"' do
project = Project.find_by(name: "Archive")
- expect(page).not_to have_content project.name_with_namespace
+ expect(page).not_to have_content project.full_name
end
step 'I should see project "Archive"' do
project = Project.find_by(name: "Archive")
- expect(page).to have_content project.name_with_namespace
+ expect(page).to have_content project.full_name
end
# ----------------------------------------
@@ -237,14 +236,10 @@ module SharedProject
@project.update(public_builds: false)
end
- step 'project "Shop" has a "Bugfix MR" merge request open' do
- create(:merge_request, title: "Bugfix MR", target_project: project, source_project: project, author: project.users.first)
- end
-
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(:project, visibility, name: project_name, namespace: user.namespace)
- project.team << [user, :master]
+ project.add_master(user)
end
end
diff --git a/features/steps/shared/user.rb b/features/steps/shared/user.rb
index 9856c510aa0..9cadc91769d 100644
--- a/features/steps/shared/user.rb
+++ b/features/steps/shared/user.rb
@@ -19,10 +19,6 @@ module SharedUser
User.find_by(name: name) || create(:user, { name: name, admin: false }.merge(options))
end
- step 'I have an ssh key' do
- create(:personal_key, user: @user)
- end
-
step 'I have no ssh keys' do
@user.keys.delete_all
end
diff --git a/features/steps/user.rb b/features/steps/user.rb
deleted file mode 100644
index 321c1e942d5..00000000000
--- a/features/steps/user.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-class Spinach::Features::User < Spinach::FeatureSteps
- include SharedAuthentication
- include SharedPaths
- include SharedUser
- include SharedProject
-
- step 'I should see user "John Doe" page' do
- expect(title).to match(/^\s*John Doe/)
- end
-
- step '"John Doe" has contributions' do
- user = User.find_by(name: 'John Doe')
- project = contributed_project
-
- # Issue contribution
- issue_params = { title: 'Bug in old browser' }
- Issues::CreateService.new(project, user, issue_params).execute
-
- # Push code contribution
- event = create(:push_event, project: project, author: user)
-
- create(:push_event_payload, event: event, commit_count: 3)
- end
-
- step 'I should see contributed projects' do
- page.within '#contributed' do
- expect(page).to have_content(@contributed_project.name)
- end
- end
-
- step 'I should see contributions calendar' do
- expect(page).to have_css('.js-contrib-calendar')
- end
-
- def contributed_project
- @contributed_project ||= create(:project, :public, :empty_repo)
- end
-end
diff --git a/features/support/capybara.rb b/features/support/capybara.rb
index 5a77b859113..4e2b3c67af5 100644
--- a/features/support/capybara.rb
+++ b/features/support/capybara.rb
@@ -29,6 +29,9 @@ Capybara.register_driver :chrome do |app|
options.add_argument("disable-gpu")
end
+ # Disable /dev/shm use in CI. See https://gitlab.com/gitlab-org/gitlab-ee/issues/4252
+ options.add_argument("disable-dev-shm-usage") if ENV['CI'] || ENV['CI_SERVER']
+
Capybara::Selenium::Driver.new(
app,
browser: :chrome,
diff --git a/features/support/capybara_helpers.rb b/features/support/capybara_helpers.rb
deleted file mode 100644
index 647f8d087c3..00000000000
--- a/features/support/capybara_helpers.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-module CapybaraHelpers
- def confirm_modal_if_present
- if Capybara.current_driver == Capybara.javascript_driver
- accept_confirm { yield }
- return
- end
-
- yield
- end
-end
diff --git a/features/support/db_cleaner.rb b/features/support/db_cleaner.rb
index 8294bb1445f..31c922d23c3 100644
--- a/features/support/db_cleaner.rb
+++ b/features/support/db_cleaner.rb
@@ -1,6 +1,6 @@
require 'database_cleaner'
-DatabaseCleaner[:active_record].strategy = :truncation
+DatabaseCleaner[:active_record].strategy = :deletion
Spinach.hooks.before_scenario do
DatabaseCleaner.start
diff --git a/features/support/env.rb b/features/support/env.rb
index 91a92314959..15211995918 100644
--- a/features/support/env.rb
+++ b/features/support/env.rb
@@ -10,25 +10,26 @@ if ENV['CI']
Knapsack::Adapters::SpinachAdapter.bind
end
-%w(select2_helper test_env repo_helpers wait_for_requests sidekiq project_forks_helper).each do |f|
+WebMock.enable!
+
+%w(select2_helper test_env repo_helpers wait_for_requests sidekiq project_forks_helper webmock).each do |f|
require Rails.root.join('spec', 'support', f)
end
Dir["#{Rails.root}/features/steps/shared/*.rb"].each { |file| require file }
-WebMock.allow_net_connect!
-
Spinach.hooks.before_run do
include RSpec::Mocks::ExampleMethods
+ include ActiveJob::TestHelper
+ include FactoryBot::Syntax::Methods
+ include GitlabRoutingHelper
+
RSpec::Mocks.setup
TestEnv.init(mailer: false)
# skip pre-receive hook check so we can use
# web editor and merge
TestEnv.disable_pre_receive
-
- include FactoryBot::Syntax::Methods
- include GitlabRoutingHelper
end
Spinach.hooks.after_scenario do |scenario_data, step_definitions|
diff --git a/features/user.feature b/features/user.feature
deleted file mode 100644
index e0cadba30a1..00000000000
--- a/features/user.feature
+++ /dev/null
@@ -1,86 +0,0 @@
-Feature: User
- Background:
- Given User "John Doe" exists
- And "John Doe" owns private project "Enterprise"
-
- # Signed out
-
- @javascript
- Scenario: I visit user "John Doe" page while not signed in when he owns a public project
- Given "John Doe" owns internal project "Internal"
- And "John Doe" owns public project "Community"
- When I visit user "John Doe" page
- And I click on "Personal projects" tab
- Then I should see user "John Doe" page
- And I should not see project "Enterprise"
- And I should not see project "Internal"
- And I should see project "Community"
-
- # Signed in as someone else
-
- @javascript
- Scenario: I visit user "John Doe" page while signed in as someone else when he owns a public project
- Given "John Doe" owns public project "Community"
- And "John Doe" owns internal project "Internal"
- And I sign in as a user
- When I visit user "John Doe" page
- And I click on "Personal projects" tab
- Then I should see user "John Doe" page
- And I should not see project "Enterprise"
- And I should see project "Internal"
- And I should see project "Community"
-
- @javascript
- Scenario: I visit user "John Doe" page while signed in as someone else when he is not authorized to a public project
- Given "John Doe" owns internal project "Internal"
- And I sign in as a user
- When I visit user "John Doe" page
- And I click on "Personal projects" tab
- Then I should see user "John Doe" page
- And I should not see project "Enterprise"
- And I should see project "Internal"
- And I should not see project "Community"
-
- @javascript
- Scenario: I visit user "John Doe" page while signed in as someone else when he is not authorized to a project I can see
- Given I sign in as a user
- When I visit user "John Doe" page
- And I click on "Personal projects" tab
- Then I should see user "John Doe" page
- And I should not see project "Enterprise"
- And I should not see project "Internal"
- And I should not see project "Community"
-
- # Signed in as the user himself
-
- @javascript
- Scenario: I visit user "John Doe" page while signed in as "John Doe" when he has a public project
- Given "John Doe" owns internal project "Internal"
- And "John Doe" owns public project "Community"
- And I sign in as "John Doe"
- When I visit user "John Doe" page
- And I click on "Personal projects" tab
- Then I should see user "John Doe" page
- And I should see project "Enterprise"
- And I should see project "Internal"
- And I should see project "Community"
-
- @javascript
- Scenario: I visit user "John Doe" page while signed in as "John Doe" when he has no public project
- Given I sign in as "John Doe"
- When I visit user "John Doe" page
- And I click on "Personal projects" tab
- Then I should see user "John Doe" page
- And I should see project "Enterprise"
- And I should not see project "Internal"
- And I should not see project "Community"
-
- @javascript
- Scenario: "John Doe" contribution profile
- Given I sign in as a user
- And "John Doe" has contributions
- When I visit user "John Doe" page
- And I click on "Contributed projects" tab
- Then I should see user "John Doe" page
- And I should see contributed projects
- And I should see contributions calendar
diff --git a/lib/after_commit_queue.rb b/lib/after_commit_queue.rb
index db63c5038ae..a4d8507960e 100644
--- a/lib/after_commit_queue.rb
+++ b/lib/after_commit_queue.rb
@@ -14,7 +14,15 @@ module AfterCommitQueue
def run_after_commit_or_now(&block)
if AfterCommitQueue.inside_transaction?
- run_after_commit(&block)
+ if ActiveRecord::Base.connection.current_transaction.records.include?(self)
+ run_after_commit(&block)
+ else
+ # If the current transaction does not include this record, we can run
+ # the block now, even if it queues a Sidekiq job.
+ Sidekiq::Worker.skipping_transaction_check do
+ instance_eval(&block)
+ end
+ end
else
instance_eval(&block)
end
diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb
index 374b611f55e..ae13c248171 100644
--- a/lib/api/access_requests.rb
+++ b/lib/api/access_requests.rb
@@ -24,7 +24,7 @@ module API
access_requesters = AccessRequestsFinder.new(source).execute!(current_user)
access_requesters = paginate(access_requesters.includes(:user))
- present access_requesters.map(&:user), with: Entities::AccessRequester, source: source
+ present access_requesters, with: Entities::AccessRequester
end
desc "Requests access for the authenticated user to a #{source_type}." do
@@ -36,7 +36,7 @@ module API
access_requester = source.request_access(current_user)
if access_requester.persisted?
- present access_requester.user, with: Entities::AccessRequester, access_requester: access_requester
+ present access_requester, with: Entities::AccessRequester
else
render_validation_error!(access_requester)
end
@@ -53,10 +53,13 @@ module API
put ':id/access_requests/:user_id/approve' do
source = find_source(source_type, params[:id])
- member = ::Members::ApproveAccessRequestService.new(source, current_user, declared_params).execute
+ access_requester = source.requesters.find_by!(user_id: params[:user_id])
+ member = ::Members::ApproveAccessRequestService
+ .new(current_user, declared_params)
+ .execute(access_requester)
status :created
- present member.user, with: Entities::Member, member: member
+ present member, with: Entities::Member
end
desc 'Denies an access request for the given user.' do
@@ -70,8 +73,7 @@ module API
member = source.requesters.find_by!(user_id: params[:user_id])
destroy_conditionally!(member) do
- ::Members::DestroyService.new(source, current_user, params)
- .execute(:requesters)
+ ::Members::DestroyService.new(current_user).execute(member)
end
end
end
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 8094597d238..073471b4c4d 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -13,7 +13,8 @@ module API
formatter: Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp.new,
include: [
GrapeLogging::Loggers::FilterParameters.new,
- GrapeLogging::Loggers::ClientEnv.new
+ GrapeLogging::Loggers::ClientEnv.new,
+ Gitlab::GrapeLogging::Loggers::UserLogger.new
]
allow_access_with_scope :api
@@ -77,6 +78,14 @@ module API
rack_response({ 'message' => '404 Not found' }.to_json, 404)
end
+ rescue_from UploadedFile::InvalidPathError do |e|
+ rack_response({ 'message' => e.message }.to_json, 400)
+ end
+
+ rescue_from ObjectStorage::RemoteStoreError do |e|
+ rack_response({ 'message' => e.message }.to_json, 500)
+ end
+
# Retain 405 error rather than a 500 error for Grape 0.15.0+.
# https://github.com/ruby-grape/grape/blob/a3a28f5b5dfbb2797442e006dbffd750b27f2a76/UPGRADING.md#changes-to-method-not-allowed-routes
rescue_from Grape::Exceptions::MethodNotAllowed do |e|
@@ -105,7 +114,9 @@ module API
# Keep in alphabetical order
mount ::API::AccessRequests
+ mount ::API::Applications
mount ::API::AwardEmoji
+ mount ::API::Badges
mount ::API::Boards
mount ::API::Branches
mount ::API::BroadcastMessages
@@ -118,7 +129,9 @@ module API
mount ::API::Events
mount ::API::Features
mount ::API::Files
+ mount ::API::GroupBoards
mount ::API::Groups
+ mount ::API::GroupMilestones
mount ::API::Internal
mount ::API::Issues
mount ::API::Jobs
@@ -129,21 +142,24 @@ module API
mount ::API::Members
mount ::API::MergeRequestDiffs
mount ::API::MergeRequests
- mount ::API::ProjectMilestones
- mount ::API::GroupMilestones
mount ::API::Namespaces
mount ::API::Notes
+ mount ::API::Discussions
mount ::API::NotificationSettings
mount ::API::PagesDomains
mount ::API::Pipelines
mount ::API::PipelineSchedules
+ mount ::API::ProjectExport
+ mount ::API::ProjectImport
mount ::API::ProjectHooks
mount ::API::Projects
+ mount ::API::ProjectMilestones
mount ::API::ProjectSnippets
mount ::API::ProtectedBranches
mount ::API::Repositories
mount ::API::Runner
mount ::API::Runners
+ mount ::API::Search
mount ::API::Services
mount ::API::Settings
mount ::API::SidekiqMetrics
diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb
index 9aeebc34525..c2113551207 100644
--- a/lib/api/api_guard.rb
+++ b/lib/api/api_guard.rb
@@ -42,7 +42,7 @@ module API
include Gitlab::Auth::UserAuthFinders
def find_current_user!
- user = find_user_from_access_token || find_user_from_warden
+ user = find_user_from_sources
return unless user
forbidden!('User is blocked') unless Gitlab::UserAccess.new(user).allowed? && user.can?(:access_api)
@@ -50,6 +50,10 @@ module API
user
end
+ def find_user_from_sources
+ find_user_from_access_token || find_user_from_warden
+ end
+
private
# An array of scopes that were registered (using `allow_access_with_scope`)
diff --git a/lib/api/applications.rb b/lib/api/applications.rb
new file mode 100644
index 00000000000..b122cdefe4e
--- /dev/null
+++ b/lib/api/applications.rb
@@ -0,0 +1,27 @@
+module API
+ # External applications API
+ class Applications < Grape::API
+ before { authenticated_as_admin! }
+
+ resource :applications do
+ desc 'Create a new application' do
+ detail 'This feature was introduced in GitLab 10.5'
+ success Entities::ApplicationWithSecret
+ end
+ params do
+ requires :name, type: String, desc: 'Application name'
+ requires :redirect_uri, type: String, desc: 'Application redirect URI'
+ requires :scopes, type: String, desc: 'Application scopes'
+ end
+ post do
+ application = Doorkeeper::Application.new(declared_params)
+
+ if application.save
+ present application, with: Entities::ApplicationWithSecret
+ else
+ render_validation_error! application
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/badges.rb b/lib/api/badges.rb
new file mode 100644
index 00000000000..8ceffe9c5ef
--- /dev/null
+++ b/lib/api/badges.rb
@@ -0,0 +1,135 @@
+module API
+ class Badges < Grape::API
+ include PaginationParams
+
+ before { authenticate_non_get! }
+
+ helpers ::API::Helpers::BadgesHelpers
+
+ helpers do
+ def find_source_if_admin(source_type)
+ source = find_source(source_type, params[:id])
+
+ authorize_admin_source!(source_type, source)
+
+ source
+ end
+ end
+
+ %w[group project].each do |source_type|
+ params do
+ requires :id, type: String, desc: "The ID of a #{source_type}"
+ end
+ resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ desc "Gets a list of #{source_type} badges viewable by the authenticated user." do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::Badge
+ end
+ params do
+ use :pagination
+ end
+ get ":id/badges" do
+ source = find_source(source_type, params[:id])
+
+ present_badges(source, paginate(source.badges))
+ end
+
+ desc "Preview a badge from a #{source_type}." do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::BasicBadgeDetails
+ end
+ params do
+ requires :link_url, type: String, desc: 'URL of the badge link'
+ requires :image_url, type: String, desc: 'URL of the badge image'
+ end
+ get ":id/badges/render" do
+ authenticate!
+
+ source = find_source_if_admin(source_type)
+
+ badge = ::Badges::BuildService.new(declared_params(include_missing: false))
+ .execute(source)
+
+ if badge.valid?
+ present_badges(source, badge, with: Entities::BasicBadgeDetails)
+ else
+ render_validation_error!(badge)
+ end
+ end
+
+ desc "Gets a badge of a #{source_type}." do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::Badge
+ end
+ params do
+ requires :badge_id, type: Integer, desc: 'The badge ID'
+ end
+ get ":id/badges/:badge_id" do
+ source = find_source(source_type, params[:id])
+ badge = find_badge(source)
+
+ present_badges(source, badge)
+ end
+
+ desc "Adds a badge to a #{source_type}." do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::Badge
+ end
+ params do
+ requires :link_url, type: String, desc: 'URL of the badge link'
+ requires :image_url, type: String, desc: 'URL of the badge image'
+ end
+ post ":id/badges" do
+ source = find_source_if_admin(source_type)
+
+ badge = ::Badges::CreateService.new(declared_params(include_missing: false)).execute(source)
+
+ if badge.persisted?
+ present_badges(source, badge)
+ else
+ render_validation_error!(badge)
+ end
+ end
+
+ desc "Updates a badge of a #{source_type}." do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::Badge
+ end
+ params do
+ optional :link_url, type: String, desc: 'URL of the badge link'
+ optional :image_url, type: String, desc: 'URL of the badge image'
+ end
+ put ":id/badges/:badge_id" do
+ source = find_source_if_admin(source_type)
+
+ badge = ::Badges::UpdateService.new(declared_params(include_missing: false))
+ .execute(find_badge(source))
+
+ if badge.valid?
+ present_badges(source, badge)
+ else
+ render_validation_error!(badge)
+ end
+ end
+
+ desc 'Removes a badge from a project or group.' do
+ detail 'This feature was introduced in GitLab 10.6.'
+ end
+ params do
+ requires :badge_id, type: Integer, desc: 'The badge ID'
+ end
+ delete ":id/badges/:badge_id" do
+ source = find_source_if_admin(source_type)
+ badge = find_badge(source)
+
+ if badge.is_a?(GroupBadge) && source.is_a?(Project)
+ error!('To delete a Group badge please use the Group endpoint', 403)
+ end
+
+ destroy_conditionally!(badge)
+ body false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/boards.rb b/lib/api/boards.rb
index 366b0dc9a6f..6c706b2b4e1 100644
--- a/lib/api/boards.rb
+++ b/lib/api/boards.rb
@@ -1,45 +1,46 @@
module API
class Boards < Grape::API
+ include BoardsResponses
include PaginationParams
before { authenticate! }
+ helpers do
+ def board_parent
+ user_project
+ end
+ end
+
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
- desc 'Get all project boards' do
- detail 'This feature was introduced in 8.13'
- success Entities::Board
- end
- params do
- use :pagination
- end
- get ':id/boards' do
- authorize!(:read_board, user_project)
- present paginate(user_project.boards), with: Entities::Board
+ segment ':id/boards' do
+ desc 'Get all project boards' do
+ detail 'This feature was introduced in 8.13'
+ success Entities::Board
+ end
+ params do
+ use :pagination
+ end
+ get '/' do
+ authorize!(:read_board, user_project)
+ present paginate(board_parent.boards), with: Entities::Board
+ end
+
+ desc 'Find a project board' do
+ detail 'This feature was introduced in 10.4'
+ success Entities::Board
+ end
+ get '/:board_id' do
+ present board, with: Entities::Board
+ end
end
params do
requires :board_id, type: Integer, desc: 'The ID of a board'
end
segment ':id/boards/:board_id' do
- helpers do
- def project_board
- board = user_project.boards.first
-
- if params[:board_id] == board.id
- board
- else
- not_found!('Board')
- end
- end
-
- def board_lists
- project_board.lists.destroyable
- end
- end
-
desc 'Get the lists of a project board' do
detail 'Does not include `done` list. This feature was introduced in 8.13'
success Entities::List
@@ -72,22 +73,13 @@ module API
requires :label_id, type: Integer, desc: 'The ID of an existing label'
end
post '/lists' do
- unless available_labels.exists?(params[:label_id])
+ unless available_labels_for(user_project).exists?(params[:label_id])
render_api_error!({ error: 'Label not found!' }, 400)
end
authorize!(:admin_list, user_project)
- service = ::Boards::Lists::CreateService.new(user_project, current_user,
- { label_id: params[:label_id] })
-
- list = service.execute(project_board)
-
- if list.valid?
- present list, with: Entities::List
- else
- render_validation_error!(list)
- end
+ create_list
end
desc 'Moves a board list to a new position' do
@@ -99,18 +91,11 @@ module API
requires :position, type: Integer, desc: 'The position of the list'
end
put '/lists/:list_id' do
- list = project_board.lists.movable.find(params[:list_id])
+ list = board_lists.find(params[:list_id])
authorize!(:admin_list, user_project)
- service = ::Boards::Lists::MoveService.new(user_project, current_user,
- { position: params[:position] })
-
- if service.execute(list)
- present list, with: Entities::List
- else
- render_api_error!({ error: "List could not be moved!" }, 400)
- end
+ move_list(list)
end
desc 'Delete a board list' do
@@ -124,12 +109,7 @@ module API
authorize!(:admin_list, user_project)
list = board_lists.find(params[:list_id])
- destroy_conditionally!(list) do |list|
- service = ::Boards::Lists::DestroyService.new(user_project, current_user)
- unless service.execute(list)
- render_api_error!({ error: 'List could not be deleted!' }, 400)
- end
- end
+ destroy_list(list)
end
end
end
diff --git a/lib/api/boards_responses.rb b/lib/api/boards_responses.rb
new file mode 100644
index 00000000000..ead0943a74d
--- /dev/null
+++ b/lib/api/boards_responses.rb
@@ -0,0 +1,50 @@
+module API
+ module BoardsResponses
+ extend ActiveSupport::Concern
+
+ included do
+ helpers do
+ def board
+ board_parent.boards.find(params[:board_id])
+ end
+
+ def board_lists
+ board.lists.destroyable
+ end
+
+ def create_list
+ create_list_service =
+ ::Boards::Lists::CreateService.new(board_parent, current_user, { label_id: params[:label_id] })
+
+ list = create_list_service.execute(board)
+
+ if list.valid?
+ present list, with: Entities::List
+ else
+ render_validation_error!(list)
+ end
+ end
+
+ def move_list(list)
+ move_list_service =
+ ::Boards::Lists::MoveService.new(board_parent, current_user, { position: params[:position].to_i })
+
+ if move_list_service.execute(list)
+ present list, with: Entities::List
+ else
+ render_api_error!({ error: "List could not be moved!" }, 400)
+ end
+ end
+
+ def destroy_list(list)
+ destroy_conditionally!(list) do |list|
+ service = ::Boards::Lists::DestroyService.new(board_parent, current_user)
+ unless service.execute(list)
+ render_api_error!({ error: 'List could not be deleted!' }, 400)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/branches.rb b/lib/api/branches.rb
index 0791a110c39..13cfba728fa 100644
--- a/lib/api/branches.rb
+++ b/lib/api/branches.rb
@@ -16,6 +16,10 @@ module API
render_api_error!('The branch refname is invalid', 400)
end
end
+
+ params :filter_params do
+ optional :search, type: String, desc: 'Return list of branches matching the search criteria'
+ end
end
params do
@@ -27,13 +31,23 @@ module API
end
params do
use :pagination
+ use :filter_params
end
get ':id/repository/branches' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42329')
+
repository = user_project.repository
- branches = ::Kaminari.paginate_array(repository.branches.sort_by(&:name))
+
+ branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute
+
merged_branch_names = repository.merged_branch_names(branches.map(&:name))
- present paginate(branches), with: Entities::Branch, project: user_project, merged_branch_names: merged_branch_names
+ present(
+ paginate(::Kaminari.paginate_array(branches)),
+ with: Entities::Branch,
+ project: user_project,
+ merged_branch_names: merged_branch_names
+ )
end
resource ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
diff --git a/lib/api/circuit_breakers.rb b/lib/api/circuit_breakers.rb
index 598c76f6168..c13154dc0ec 100644
--- a/lib/api/circuit_breakers.rb
+++ b/lib/api/circuit_breakers.rb
@@ -17,11 +17,11 @@ module API
end
def storage_health
- @failing_storage_health ||= Gitlab::Git::Storage::Health.for_all_storages
+ @storage_health ||= Gitlab::Git::Storage::Health.for_all_storages
end
end
- desc 'Get all failing git storages' do
+ desc 'Get all git storages' do
detail 'This feature was introduced in GitLab 9.5'
success Entities::RepositoryStorageHealth
end
diff --git a/lib/api/commits.rb b/lib/api/commits.rb
index 38e05074353..684955a1b24 100644
--- a/lib/api/commits.rb
+++ b/lib/api/commits.rb
@@ -18,25 +18,28 @@ module API
optional :since, type: DateTime, desc: 'Only commits after or on this date will be returned'
optional :until, type: DateTime, desc: 'Only commits before or on this date will be returned'
optional :path, type: String, desc: 'The file path'
+ optional :all, type: Boolean, desc: 'Every commit will be returned'
use :pagination
end
get ':id/repository/commits' do
path = params[:path]
before = params[:until]
after = params[:since]
- ref = params[:ref_name] || user_project.try(:default_branch) || 'master'
+ ref = params[:ref_name] || user_project.try(:default_branch) || 'master' unless params[:all]
offset = (params[:page] - 1) * params[:per_page]
+ all = params[:all]
commits = user_project.repository.commits(ref,
path: path,
limit: params[:per_page],
offset: offset,
before: before,
- after: after)
+ after: after,
+ all: all)
commit_count =
- if path || before || after
- user_project.repository.count_commits(ref: ref, path: path, before: before, after: after)
+ if all || path || before || after
+ user_project.repository.count_commits(ref: ref, path: path, before: before, after: after, all: all)
else
# Cacheable commit count.
user_project.repository.commit_count_for_ref(ref)
@@ -82,13 +85,14 @@ module API
end
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
+ optional :stats, type: Boolean, default: true, desc: 'Include commit stats'
end
get ':id/repository/commits/:sha', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
- present commit, with: Entities::CommitDetail
+ present commit, with: Entities::CommitDetail, stats: params[:stats]
end
desc 'Get the diff for a specific commit of a project' do
@@ -96,13 +100,16 @@ module API
end
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
+ use :pagination
end
get ':id/repository/commits/:sha/diff', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! 'Commit' unless commit
- present commit.raw_diffs.to_a, with: Entities::Diff
+ raw_diffs = ::Kaminari.paginate_array(commit.raw_diffs.to_a)
+
+ present paginate(raw_diffs), with: Entities::Diff
end
desc "Get a commit's comments" do
@@ -155,6 +162,27 @@ module API
end
end
+ desc 'Get all references a commit is pushed to' do
+ detail 'This feature was introduced in GitLab 10.6'
+ success Entities::BasicRef
+ end
+ params do
+ requires :sha, type: String, desc: 'A commit sha'
+ optional :type, type: String, values: %w[branch tag all], default: 'all', desc: 'Scope'
+ use :pagination
+ end
+ get ':id/repository/commits/:sha/refs', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
+ commit = user_project.commit(params[:sha])
+ not_found!('Commit') unless commit
+
+ refs = []
+ refs.concat(user_project.repository.branch_names_contains(commit.id).map {|name| { type: 'branch', name: name }}) unless params[:type] == 'tag'
+ refs.concat(user_project.repository.tag_names_contains(commit.id).map {|name| { type: 'tag', name: name }}) unless params[:type] == 'branch'
+ refs = Kaminari.paginate_array(refs)
+
+ present paginate(refs), with: Entities::BasicRef
+ end
+
desc 'Post comment to commit' do
success Entities::CommitNote
end
@@ -164,7 +192,7 @@ module API
optional :path, type: String, desc: 'The file path'
given :path do
requires :line, type: Integer, desc: 'The line number'
- requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line'
+ requires :line_type, type: String, values: %w[new old], default: 'new', desc: 'The type of the line'
end
end
post ':id/repository/commits/:sha/comments', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
@@ -203,6 +231,20 @@ module API
render_api_error!("Failed to save note #{note.errors.messages}", 400)
end
end
+
+ desc 'Get Merge Requests associated with a commit' do
+ success Entities::MergeRequestBasic
+ end
+ params do
+ requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag on which to find Merge Requests'
+ use :pagination
+ end
+ get ':id/repository/commits/:sha/merge_requests', requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
+ commit = user_project.commit(params[:sha])
+ not_found! 'Commit' unless commit
+
+ present paginate(commit.merge_requests), with: Entities::MergeRequestBasic
+ end
end
end
end
diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb
index 281269b1190..70d43ac1d79 100644
--- a/lib/api/deploy_keys.rb
+++ b/lib/api/deploy_keys.rb
@@ -4,6 +4,16 @@ module API
before { authenticate! }
+ helpers do
+ def add_deploy_keys_project(project, attrs = {})
+ project.deploy_keys_projects.create(attrs)
+ end
+
+ def find_by_deploy_key(project, key_id)
+ project.deploy_keys_projects.find_by!(deploy_key: key_id)
+ end
+ end
+
desc 'Return all deploy keys'
params do
use :pagination
@@ -21,28 +31,31 @@ module API
before { authorize_admin_project }
desc "Get a specific project's deploy keys" do
- success Entities::SSHKey
+ success Entities::DeployKeysProject
end
params do
use :pagination
end
get ":id/deploy_keys" do
- present paginate(user_project.deploy_keys), with: Entities::SSHKey
+ keys = user_project.deploy_keys_projects.preload(:deploy_key)
+
+ present paginate(keys), with: Entities::DeployKeysProject
end
desc 'Get single deploy key' do
- success Entities::SSHKey
+ success Entities::DeployKeysProject
end
params do
requires :key_id, type: Integer, desc: 'The ID of the deploy key'
end
get ":id/deploy_keys/:key_id" do
- key = user_project.deploy_keys.find params[:key_id]
- present key, with: Entities::SSHKey
+ key = find_by_deploy_key(user_project, params[:key_id])
+
+ present key, with: Entities::DeployKeysProject
end
- desc 'Add new deploy key to currently authenticated user' do
- success Entities::SSHKey
+ desc 'Add new deploy key to a project' do
+ success Entities::DeployKeysProject
end
params do
requires :key, type: String, desc: 'The new deploy key'
@@ -53,26 +66,32 @@ module API
params[:key].strip!
# Check for an existing key joined to this project
- key = user_project.deploy_keys.find_by(key: params[:key])
- if key
- present key, with: Entities::SSHKey
+ deploy_key_project = user_project.deploy_keys_projects
+ .joins(:deploy_key)
+ .find_by(keys: { key: params[:key] })
+
+ if deploy_key_project
+ present deploy_key_project, with: Entities::DeployKeysProject
break
end
# Check for available deploy keys in other projects
key = current_user.accessible_deploy_keys.find_by(key: params[:key])
if key
- user_project.deploy_keys << key
- present key, with: Entities::SSHKey
+ deploy_key_project = add_deploy_keys_project(user_project, deploy_key: key, can_push: !!params[:can_push])
+
+ present deploy_key_project, with: Entities::DeployKeysProject
break
end
# Create a new deploy key
- key = DeployKey.new(declared_params(include_missing: false))
- if key.valid? && user_project.deploy_keys << key
- present key, with: Entities::SSHKey
+ deploy_key_attributes = declared_params.except(:can_push).merge(user: current_user)
+ deploy_key_project = add_deploy_keys_project(user_project, deploy_key_attributes: deploy_key_attributes, can_push: !!params[:can_push])
+
+ if deploy_key_project.valid?
+ present deploy_key_project, with: Entities::DeployKeysProject
else
- render_validation_error!(key)
+ render_validation_error!(deploy_key_project)
end
end
@@ -86,14 +105,21 @@ module API
at_least_one_of :title, :can_push
end
put ":id/deploy_keys/:key_id" do
- key = DeployKey.find(params.delete(:key_id))
+ deploy_keys_project = find_by_deploy_key(user_project, params[:key_id])
- authorize!(:update_deploy_key, key)
+ authorize!(:update_deploy_key, deploy_keys_project.deploy_key)
- if key.update_attributes(declared_params(include_missing: false))
- present key, with: Entities::SSHKey
+ can_push = params[:can_push].nil? ? deploy_keys_project.can_push : params[:can_push]
+ title = params[:title] || deploy_keys_project.deploy_key.title
+
+ result = deploy_keys_project.update_attributes(can_push: can_push,
+ deploy_key_attributes: { id: params[:key_id],
+ title: title })
+
+ if result
+ present deploy_keys_project, with: Entities::DeployKeysProject
else
- render_validation_error!(key)
+ render_validation_error!(deploy_keys_project)
end
end
@@ -122,7 +148,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the deploy key'
end
delete ":id/deploy_keys/:key_id" do
- key = user_project.deploy_keys_projects.find_by(deploy_key_id: params[:key_id])
+ key = user_project.deploy_keys.find(params[:key_id])
not_found!('Deploy Key') unless key
destroy_conditionally!(key)
diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb
index 1efee9a1324..184fae0eb76 100644
--- a/lib/api/deployments.rb
+++ b/lib/api/deployments.rb
@@ -15,11 +15,13 @@ module API
end
params do
use :pagination
+ optional :order_by, type: String, values: %w[id iid created_at ref], default: 'id', desc: 'Return deployments ordered by `id` or `iid` or `created_at` or `ref`'
+ optional :sort, type: String, values: %w[asc desc], default: 'asc', desc: 'Sort by asc (ascending) or desc (descending)'
end
get ':id/deployments' do
authorize! :read_deployment, user_project
- present paginate(user_project.deployments), with: Entities::Deployment
+ present paginate(user_project.deployments.order(params[:order_by] => params[:sort])), with: Entities::Deployment
end
desc 'Gets a specific deployment' do
diff --git a/lib/api/discussions.rb b/lib/api/discussions.rb
new file mode 100644
index 00000000000..6abd575b6ad
--- /dev/null
+++ b/lib/api/discussions.rb
@@ -0,0 +1,195 @@
+module API
+ class Discussions < Grape::API
+ include PaginationParams
+ helpers ::API::Helpers::NotesHelpers
+
+ before { authenticate! }
+
+ NOTEABLE_TYPES = [Issue, Snippet].freeze
+
+ NOTEABLE_TYPES.each do |noteable_type|
+ parent_type = noteable_type.parent_class.to_s.underscore
+ noteables_str = noteable_type.to_s.underscore.pluralize
+
+ params do
+ requires :id, type: String, desc: "The ID of a #{parent_type}"
+ end
+ resource parent_type.pluralize.to_sym, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ desc "Get a list of #{noteable_type.to_s.downcase} discussions" do
+ success Entities::Discussion
+ end
+ params do
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ use :pagination
+ end
+ get ":id/#{noteables_str}/:noteable_id/discussions" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+
+ return not_found!("Discussions") unless can?(current_user, noteable_read_ability_name(noteable), noteable)
+
+ notes = noteable.notes
+ .inc_relations_for_view
+ .includes(:noteable)
+ .fresh
+
+ notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
+ discussions = Kaminari.paginate_array(Discussion.build_collection(notes, noteable))
+
+ present paginate(discussions), with: Entities::Discussion
+ end
+
+ desc "Get a single #{noteable_type.to_s.downcase} discussion" do
+ success Entities::Discussion
+ end
+ params do
+ requires :discussion_id, type: String, desc: 'The ID of a discussion'
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ end
+ get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+ notes = readable_discussion_notes(noteable, params[:discussion_id])
+
+ if notes.empty? || !can?(current_user, noteable_read_ability_name(noteable), noteable)
+ return not_found!("Discussion")
+ end
+
+ discussion = Discussion.build(notes, noteable)
+
+ present discussion, with: Entities::Discussion
+ end
+
+ desc "Create a new #{noteable_type.to_s.downcase} discussion" do
+ success Entities::Discussion
+ end
+ params do
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ requires :body, type: String, desc: 'The content of a note'
+ optional :created_at, type: String, desc: 'The creation date of the note'
+ end
+ post ":id/#{noteables_str}/:noteable_id/discussions" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+
+ opts = {
+ note: params[:body],
+ created_at: params[:created_at],
+ type: 'DiscussionNote',
+ noteable_type: noteables_str.classify,
+ noteable_id: noteable.id
+ }
+
+ note = create_note(noteable, opts)
+
+ if note.valid?
+ present note.discussion, with: Entities::Discussion
+ else
+ bad_request!("Note #{note.errors.messages}")
+ end
+ end
+
+ desc "Get comments in a single #{noteable_type.to_s.downcase} discussion" do
+ success Entities::Discussion
+ end
+ params do
+ requires :discussion_id, type: String, desc: 'The ID of a discussion'
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ end
+ get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+ notes = readable_discussion_notes(noteable, params[:discussion_id])
+
+ if notes.empty? || !can?(current_user, noteable_read_ability_name(noteable), noteable)
+ return not_found!("Notes")
+ end
+
+ present notes, with: Entities::Note
+ end
+
+ desc "Add a comment to a #{noteable_type.to_s.downcase} discussion" do
+ success Entities::Note
+ end
+ params do
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ requires :discussion_id, type: String, desc: 'The ID of a discussion'
+ requires :body, type: String, desc: 'The content of a note'
+ optional :created_at, type: String, desc: 'The creation date of the note'
+ end
+ post ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+ notes = readable_discussion_notes(noteable, params[:discussion_id])
+
+ return not_found!("Discussion") if notes.empty?
+ return bad_request!("Discussion is an individual note.") unless notes.first.part_of_discussion?
+
+ opts = {
+ note: params[:body],
+ type: 'DiscussionNote',
+ in_reply_to_discussion_id: params[:discussion_id],
+ created_at: params[:created_at]
+ }
+ note = create_note(noteable, opts)
+
+ if note.valid?
+ present note, with: Entities::Note
+ else
+ bad_request!("Note #{note.errors.messages}")
+ end
+ end
+
+ desc "Get a comment in a #{noteable_type.to_s.downcase} discussion" do
+ success Entities::Note
+ end
+ params do
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ requires :discussion_id, type: String, desc: 'The ID of a discussion'
+ requires :note_id, type: Integer, desc: 'The ID of a note'
+ end
+ get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+
+ get_note(noteable, params[:note_id])
+ end
+
+ desc "Edit a comment in a #{noteable_type.to_s.downcase} discussion" do
+ success Entities::Note
+ end
+ params do
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ requires :discussion_id, type: String, desc: 'The ID of a discussion'
+ requires :note_id, type: Integer, desc: 'The ID of a note'
+ requires :body, type: String, desc: 'The content of a note'
+ end
+ put ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+
+ update_note(noteable, params[:note_id])
+ end
+
+ desc "Delete a comment in a #{noteable_type.to_s.downcase} discussion" do
+ success Entities::Note
+ end
+ params do
+ requires :noteable_id, type: Integer, desc: 'The ID of the noteable'
+ requires :discussion_id, type: String, desc: 'The ID of a discussion'
+ requires :note_id, type: Integer, desc: 'The ID of a note'
+ end
+ delete ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+
+ delete_note(noteable, params[:note_id])
+ end
+ end
+ end
+
+ helpers do
+ def readable_discussion_notes(noteable, discussion_id)
+ notes = noteable.notes
+ .where(discussion_id: discussion_id)
+ .inc_relations_for_view
+ .includes(:noteable)
+ .fresh
+
+ notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
+ end
+ end
+ end
+end
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 928706dfda7..8aad320e376 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -22,6 +22,7 @@ module API
end
expose :avatar_path, if: ->(user, options) { options.fetch(:only_path, false) && user.avatar_path }
+ expose :custom_attributes, using: 'API::Entities::CustomAttribute', if: :with_custom_attributes
expose :web_url do |user, options|
Gitlab::Routing.url_helpers.user_url(user)
@@ -65,13 +66,13 @@ module API
end
class Hook < Grape::Entity
- expose :id, :url, :created_at, :push_events, :tag_push_events, :repository_update_events
+ expose :id, :url, :created_at, :push_events, :tag_push_events, :merge_requests_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 :project_id, :issues_events, :confidential_issues_events
+ expose :note_events, :confidential_note_events, :pipeline_events, :wiki_page_events
expose :job_events
end
@@ -90,6 +91,28 @@ module API
expose :created_at
end
+ class ProjectExportStatus < ProjectIdentity
+ include ::API::Helpers::RelatedResourcesHelpers
+
+ expose :export_status
+ expose :_links, if: lambda { |project, _options| project.export_status == :finished } do
+ expose :api_url do |project|
+ expose_url(api_v4_projects_export_download_path(id: project.id))
+ end
+
+ expose :web_url do |project|
+ Gitlab::Routing.url_helpers.download_export_project_url(project)
+ end
+ end
+ end
+
+ class ProjectImportStatus < ProjectIdentity
+ expose :import_status
+
+ # TODO: Use `expose_nil` once we upgrade the grape-entity gem
+ expose :import_error, if: lambda { |status, _ops| status.import_error }
+ end
+
class BasicProjectDetails < ProjectIdentity
include ::API::ProjectsRelationBuilder
@@ -109,6 +132,8 @@ module API
expose :star_count, :forks_count
expose :last_activity_at
+ expose :custom_attributes, using: 'API::Entities::CustomAttribute', if: :with_custom_attributes
+
def self.preload_relation(projects_relation, options = {})
projects_relation.preload(:project_feature, :route)
.preload(namespace: [:route, :owner],
@@ -181,6 +206,7 @@ module API
expose :request_access_enabled
expose :only_allow_merge_if_all_discussions_are_resolved
expose :printing_merge_request_link_enabled
+ expose :merge_method
expose :statistics, using: 'API::Entities::ProjectStatistics', if: :statistics
@@ -205,22 +231,15 @@ module API
expose :build_artifacts_size, as: :job_artifacts_size
end
- class Member < UserBasic
- expose :access_level do |user, options|
- member = options[:member] || options[:source].members.find_by(user_id: user.id)
- member.access_level
- end
- expose :expires_at do |user, options|
- member = options[:member] || options[:source].members.find_by(user_id: user.id)
- member.expires_at
- end
+ class Member < Grape::Entity
+ expose :user, merge: true, using: UserBasic
+ expose :access_level
+ expose :expires_at
end
- class AccessRequester < UserBasic
- expose :requested_at do |user, options|
- access_requester = options[:access_requester] || options[:source].requesters.find_by(user_id: user.id)
- access_requester.requested_at
- end
+ class AccessRequester < Grape::Entity
+ expose :user, merge: true, using: UserBasic
+ expose :requested_at
end
class Group < Grape::Entity
@@ -237,6 +256,8 @@ module API
expose :parent_id
end
+ expose :custom_attributes, using: 'API::Entities::CustomAttribute', if: :with_custom_attributes
+
expose :statistics, if: :statistics do
with_options format_with: -> (value) { value.to_i } do
expose :storage_size
@@ -278,9 +299,14 @@ module API
end
class CommitDetail < Commit
- expose :stats, using: Entities::CommitStats
+ expose :stats, using: Entities::CommitStats, if: :stats
expose :status
expose :last_pipeline, using: 'API::Entities::PipelineBasic'
+ expose :project_id
+ end
+
+ class BasicRef < Grape::Entity
+ expose :type, :name
end
class Branch < Grape::Entity
@@ -321,24 +347,20 @@ module API
end
end
- class ProjectSnippet < Grape::Entity
+ class Snippet < Grape::Entity
expose :id, :title, :file_name, :description
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
-
- expose :web_url do |snippet, options|
+ expose :project_id
+ expose :web_url do |snippet|
Gitlab::UrlBuilder.build(snippet)
end
end
- class PersonalSnippet < Grape::Entity
- expose :id, :title, :file_name, :description
- expose :author, using: Entities::UserBasic
- expose :updated_at, :created_at
+ class ProjectSnippet < Snippet
+ end
- expose :web_url do |snippet|
- Gitlab::UrlBuilder.build(snippet)
- end
+ class PersonalSnippet < Snippet
expose :raw_url do |snippet|
Gitlab::UrlBuilder.build(snippet) + "/raw"
end
@@ -384,6 +406,7 @@ module API
class IssueBasic < ProjectEntity
expose :closed_at
+ expose :closed_by, using: Entities::UserBasic
expose :labels do |issue, options|
# Avoids an N+1 query since labels are preloaded
issue.labels.map(&:title).sort
@@ -475,6 +498,10 @@ module API
expose :id
end
+ class PipelineBasic < Grape::Entity
+ expose :id, :sha, :ref, :status
+ end
+
class MergeRequestSimple < ProjectEntity
expose :title
expose :web_url do |merge_request, options|
@@ -507,13 +534,22 @@ module API
expose :work_in_progress?, as: :work_in_progress
expose :milestone, using: Entities::Milestone
expose :merge_when_pipeline_succeeds
- expose :merge_status
+
+ # Ideally we should deprecate `MergeRequest#merge_status` exposure and
+ # use `MergeRequest#mergeable?` instead (boolean).
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/42344 for more
+ # information.
+ expose :merge_status do |merge_request|
+ merge_request.check_if_can_be_merged
+ merge_request.merge_status
+ end
expose :diff_head_sha, as: :sha
expose :merge_commit_sha
expose :user_notes_count
expose :discussion_locked
expose :should_remove_source_branch?, as: :should_remove_source_branch
expose :force_remove_source_branch?, as: :force_remove_source_branch
+ expose :allow_maintainer_to_push, if: -> (merge_request, _) { merge_request.for_fork? }
expose :web_url do |merge_request, options|
Gitlab::UrlBuilder.build(merge_request)
@@ -532,6 +568,42 @@ module API
expose :changes_count do |merge_request, _options|
merge_request.merge_request_diff.real_size
end
+
+ expose :merged_by, using: Entities::UserBasic do |merge_request, _options|
+ merge_request.metrics&.merged_by
+ end
+
+ expose :merged_at do |merge_request, _options|
+ merge_request.metrics&.merged_at
+ end
+
+ expose :closed_by, using: Entities::UserBasic do |merge_request, _options|
+ merge_request.metrics&.latest_closed_by
+ end
+
+ expose :closed_at do |merge_request, _options|
+ merge_request.metrics&.latest_closed_at
+ end
+
+ expose :latest_build_started_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options|
+ merge_request.metrics&.latest_build_started_at
+ end
+
+ expose :latest_build_finished_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options|
+ merge_request.metrics&.latest_build_finished_at
+ end
+
+ expose :first_deployed_to_production_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options|
+ merge_request.metrics&.first_deployed_to_production_at
+ end
+
+ expose :pipeline, using: Entities::PipelineBasic, if: -> (_, options) { build_available?(options) } do |merge_request, _options|
+ merge_request.metrics&.pipeline
+ end
+
+ def build_available?(options)
+ options[:project]&.feature_available?(:builds, options[:current_user])
+ end
end
class MergeRequestChanges < MergeRequest
@@ -554,13 +626,18 @@ module API
end
class SSHKey < Grape::Entity
- expose :id, :title, :key, :created_at, :can_push
+ expose :id, :title, :key, :created_at
end
class SSHKeyWithUser < SSHKey
expose :user, using: Entities::UserPublic
end
+ class DeployKeysProject < Grape::Entity
+ expose :deploy_key, merge: true, using: Entities::SSHKey
+ expose :can_push
+ end
+
class GPGKey < Grape::Entity
expose :id, :key, :created_at
end
@@ -570,6 +647,7 @@ module API
NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze
expose :id
+ expose :type
expose :note, as: :body
expose :attachment_identifier, as: :attachment
expose :author, using: Entities::UserBasic
@@ -581,6 +659,12 @@ module API
expose(:noteable_iid) { |note| note.noteable.iid if NOTEABLE_TYPES_WITH_IID.include?(note.noteable_type) }
end
+ class Discussion < Grape::Entity
+ expose :id
+ expose :individual_note?, as: :individual_note
+ expose :notes, using: Entities::Note
+ end
+
class AwardEmoji < Grape::Entity
expose :id
expose :name
@@ -708,15 +792,13 @@ 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, :pipeline_events
+ expose :push_events, :issues_events, :confidential_issues_events
+ expose :merge_requests_events, :tag_push_events, :note_events
+ expose :confidential_note_events, :pipeline_events, :wiki_page_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] }
- service.properties.slice(*field_names)
+ service.properties.slice(*service.api_field_names)
end
end
@@ -790,6 +872,8 @@ module API
class Board < Grape::Entity
expose :id
+ expose :project, using: Entities::BasicProjectDetails
+
expose :lists, using: Entities::List do |board|
board.lists.destroyable
end
@@ -844,7 +928,7 @@ module API
end
class Tag < Grape::Entity
- expose :name, :message
+ expose :name, :message, :target
expose :commit, using: Entities::Commit do |repo_tag, options|
options[:project].repository.commit(repo_tag.dereferenced_target)
@@ -861,12 +945,15 @@ module API
expose :active
expose :is_shared
expose :name
+ expose :online?, as: :online
+ expose :status
end
class RunnerDetails < Runner
expose :tag_list
expose :run_untagged
expose :locked
+ expose :maximum_timeout
expose :access_level
expose :version, :revision, :platform, :architecture
expose :contacted_at
@@ -888,10 +975,6 @@ module API
expose :filename, :size
end
- class PipelineBasic < Grape::Entity
- expose :id, :sha, :ref, :status
- end
-
class JobBasic < Grape::Entity
expose :id, :status, :stage, :name, :ref, :tag, :coverage
expose :created_at, :started_at, :finished_at
@@ -913,7 +996,7 @@ module API
class Trigger < Grape::Entity
expose :id
expose :token, :description
- expose :created_at, :updated_at, :deleted_at, :last_used
+ expose :created_at, :updated_at, :last_used
expose :owner, using: Entities::UserBasic
end
@@ -1039,7 +1122,7 @@ module API
end
class RunnerInfo < Grape::Entity
- expose :timeout
+ expose :metadata_timeout, as: :timeout
end
class Step < Grape::Entity
@@ -1132,6 +1215,11 @@ module API
class PagesDomainBasic < Grape::Entity
expose :domain
expose :url
+ expose :project_id
+ expose :verified?, as: :verified
+ expose :verification_code, as: :verification_code
+ expose :enabled_until
+
expose :certificate,
as: :certificate_expiration,
if: ->(pages_domain, _) { pages_domain.certificate? },
@@ -1143,11 +1231,53 @@ module API
class PagesDomain < Grape::Entity
expose :domain
expose :url
+ expose :verified?, as: :verified
+ expose :verification_code, as: :verification_code
+ expose :enabled_until
+
expose :certificate,
if: ->(pages_domain, _) { pages_domain.certificate? },
using: PagesDomainCertificate do |pages_domain|
pages_domain
end
end
+
+ class Application < Grape::Entity
+ expose :uid, as: :application_id
+ expose :redirect_uri, as: :callback_url
+ end
+
+ # Use with care, this exposes the secret
+ class ApplicationWithSecret < Application
+ expose :secret
+ end
+
+ class Blob < Grape::Entity
+ expose :basename
+ expose :data
+ expose :filename
+ expose :id
+ expose :ref
+ expose :startline
+ expose :project_id
+ end
+
+ class BasicBadgeDetails < Grape::Entity
+ expose :link_url
+ expose :image_url
+ expose :rendered_link_url do |badge, options|
+ badge.rendered_link_url(options.fetch(:project, nil))
+ end
+ expose :rendered_image_url do |badge, options|
+ badge.rendered_image_url(options.fetch(:project, nil))
+ end
+ end
+
+ class Badge < BasicBadgeDetails
+ expose :id
+ expose :kind do |badge|
+ badge.type == 'ProjectBadge' ? 'project' : 'group'
+ end
+ end
end
end
diff --git a/lib/api/features.rb b/lib/api/features.rb
index 9385c6ca174..11d848584d9 100644
--- a/lib/api/features.rb
+++ b/lib/api/features.rb
@@ -65,6 +65,13 @@ module API
present feature, with: Entities::Feature, current_user: current_user
end
+
+ desc 'Remove the gate value for the given feature'
+ delete ':name' do
+ Feature.get(params[:name]).remove
+
+ status 204
+ end
end
end
end
diff --git a/lib/api/group_boards.rb b/lib/api/group_boards.rb
new file mode 100644
index 00000000000..aa9fff25fc8
--- /dev/null
+++ b/lib/api/group_boards.rb
@@ -0,0 +1,117 @@
+module API
+ class GroupBoards < Grape::API
+ include BoardsResponses
+ include PaginationParams
+
+ before do
+ authenticate!
+ end
+
+ helpers do
+ def board_parent
+ user_group
+ end
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a group'
+ end
+
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ segment ':id/boards' do
+ desc 'Find a group board' do
+ detail 'This feature was introduced in 10.6'
+ success ::API::Entities::Board
+ end
+ get '/:board_id' do
+ present board, with: ::API::Entities::Board
+ end
+
+ desc 'Get all group boards' do
+ detail 'This feature was introduced in 10.6'
+ success Entities::Board
+ end
+ params do
+ use :pagination
+ end
+ get '/' do
+ present paginate(board_parent.boards), with: Entities::Board
+ end
+ end
+
+ params do
+ requires :board_id, type: Integer, desc: 'The ID of a board'
+ end
+ segment ':id/boards/:board_id' do
+ desc 'Get the lists of a group board' do
+ detail 'Does not include backlog and closed lists. This feature was introduced in 10.6'
+ success Entities::List
+ end
+ params do
+ use :pagination
+ end
+ get '/lists' do
+ present paginate(board_lists), with: Entities::List
+ end
+
+ desc 'Get a list of a group board' do
+ detail 'This feature was introduced in 10.6'
+ success Entities::List
+ end
+ params do
+ requires :list_id, type: Integer, desc: 'The ID of a list'
+ end
+ get '/lists/:list_id' do
+ present board_lists.find(params[:list_id]), with: Entities::List
+ end
+
+ desc 'Create a new board list' do
+ detail 'This feature was introduced in 10.6'
+ success Entities::List
+ end
+ params do
+ requires :label_id, type: Integer, desc: 'The ID of an existing label'
+ end
+ post '/lists' do
+ unless available_labels_for(board_parent).exists?(params[:label_id])
+ render_api_error!({ error: 'Label not found!' }, 400)
+ end
+
+ authorize!(:admin_list, user_group)
+
+ create_list
+ end
+
+ desc 'Moves a board list to a new position' do
+ detail 'This feature was introduced in 10.6'
+ success Entities::List
+ end
+ params do
+ requires :list_id, type: Integer, desc: 'The ID of a list'
+ requires :position, type: Integer, desc: 'The position of the list'
+ end
+ put '/lists/:list_id' do
+ list = board_lists.find(params[:list_id])
+
+ authorize!(:admin_list, user_group)
+
+ move_list(list)
+ end
+
+ desc 'Delete a board list' do
+ detail 'This feature was introduced in 10.6'
+ success Entities::List
+ end
+ params do
+ requires :list_id, type: Integer, desc: 'The ID of a board list'
+ end
+ delete "/lists/:list_id" do
+ authorize!(:admin_list, user_group)
+ list = board_lists.find(params[:list_id])
+
+ destroy_list(list)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index b81f07a1770..4a4df1b8b9e 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -1,6 +1,7 @@
module API
class Groups < Grape::API
include PaginationParams
+ include Helpers::CustomAttributes
before { authenticate_non_get! }
@@ -67,6 +68,8 @@ module API
}
groups = groups.with_statistics if options[:statistics]
+ groups, options = with_custom_attributes(groups, options)
+
present paginate(groups), options
end
end
@@ -79,6 +82,7 @@ module API
end
params do
use :group_list_params
+ use :with_custom_attributes
end
get do
groups = find_groups(params)
@@ -142,9 +146,20 @@ module API
desc 'Get a single group, with containing projects.' do
success Entities::GroupDetail
end
+ params do
+ use :with_custom_attributes
+ end
get ":id" do
group = find_group!(params[:id])
- present group, with: Entities::GroupDetail, current_user: current_user
+
+ options = {
+ with: Entities::GroupDetail,
+ current_user: current_user
+ }
+
+ group, options = with_custom_attributes(group, options)
+
+ present group, options
end
desc 'Remove a group.'
@@ -175,12 +190,19 @@ module API
optional :starred, type: Boolean, default: false, desc: 'Limit by starred status'
use :pagination
+ use :with_custom_attributes
end
get ":id/projects" do
projects = find_group_projects(params)
- entity = params[:simple] ? Entities::BasicProjectDetails : Entities::Project
- present entity.prepare_relation(projects), with: entity, current_user: current_user
+ options = {
+ with: params[:simple] ? Entities::BasicProjectDetails : Entities::Project,
+ current_user: current_user
+ }
+
+ projects, options = with_custom_attributes(projects, options)
+
+ present options[:with].prepare_relation(projects), options
end
desc 'Get a list of subgroups in this group.' do
@@ -188,6 +210,7 @@ module API
end
params do
use :group_list_params
+ use :with_custom_attributes
end
get ":id/subgroups" do
groups = find_groups(params)
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 9ba15893f55..61dab1dd5cb 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -5,6 +5,7 @@ module API
SUDO_HEADER = "HTTP_SUDO".freeze
SUDO_PARAM = :sudo
+ API_USER_ENV = 'gitlab.api.user'.freeze
def declared_params(options = {})
options = { include_parent_namespaces: false }.merge(options)
@@ -25,6 +26,7 @@ module API
check_unmodified_since!(last_updated)
status 204
+
if block_given?
yield resource
else
@@ -48,10 +50,16 @@ module API
validate_access_token!(scopes: scopes_registered_for_endpoint) unless sudo?
+ save_current_user_in_env(@current_user) if @current_user
+
@current_user
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
+ def save_current_user_in_env(user)
+ env[API_USER_ENV] = { user_id: user.id, username: user.username }
+ end
+
def sudo?
initial_current_user != current_user
end
@@ -69,13 +77,21 @@ module API
end
def wiki_page
- page = user_project.wiki.find_page(params[:slug])
+ page = ProjectWiki.new(user_project, current_user).find_page(params[:slug])
page || not_found!('Wiki Page')
end
- def available_labels
- @available_labels ||= LabelsFinder.new(current_user, project_id: user_project.id).execute
+ def available_labels_for(label_parent)
+ search_params = { include_ancestor_groups: true }
+
+ if label_parent.is_a?(Project)
+ search_params[:project_id] = label_parent.id
+ else
+ search_params.merge!(group_id: label_parent.id, only_group_labels: true)
+ end
+
+ LabelsFinder.new(current_user, search_params).execute
end
def find_user(id)
@@ -141,7 +157,9 @@ module API
end
def find_project_label(id)
- label = available_labels.find_by_id(id) || available_labels.find_by_title(id)
+ labels = available_labels_for(user_project)
+ label = labels.find_by_id(id) || labels.find_by_title(id)
+
label || not_found!('Label')
end
@@ -155,7 +173,7 @@ module API
def find_project_snippet(id)
finder_params = { project: user_project }
- SnippetsFinder.new(current_user, finder_params).execute.find(id)
+ SnippetsFinder.new(current_user, finder_params).find(id)
end
def find_merge_request_with_access(iid, access_level = :read_merge_request)
@@ -371,29 +389,7 @@ module API
# file helpers
- def uploaded_file(field, uploads_path)
- if params[field]
- bad_request!("#{field} is not a file") unless params[field][:filename]
- return params[field]
- end
-
- return nil unless params["#{field}.path"] && params["#{field}.name"]
-
- # sanitize file paths
- # this requires all paths to exist
- required_attributes! %W(#{field}.path)
- uploads_path = File.realpath(uploads_path)
- file_path = File.realpath(params["#{field}.path"])
- bad_request!('Bad file path') unless file_path.start_with?(uploads_path)
-
- UploadedFile.new(
- file_path,
- params["#{field}.name"],
- params["#{field}.type"] || 'application/octet-stream'
- )
- end
-
- def present_file!(path, filename, content_type = 'application/octet-stream')
+ def present_disk_file!(path, filename, content_type = 'application/octet-stream')
filename ||= File.basename(path)
header['Content-Disposition'] = "attachment; filename=#{filename}"
header['Content-Transfer-Encoding'] = 'binary'
@@ -409,13 +405,17 @@ module API
end
end
- def present_artifacts!(artifacts_file)
- return not_found! unless artifacts_file.exists?
+ def present_carrierwave_file!(file, supports_direct_download: true)
+ return not_found! unless file.exists?
- if artifacts_file.file_storage?
- present_file!(artifacts_file.path, artifacts_file.filename)
+ if file.file_storage?
+ present_disk_file!(file.path, file.filename)
+ elsif supports_direct_download && file.class.direct_download_enabled?
+ redirect(file.url)
else
- redirect_to(artifacts_file.url)
+ header(*Gitlab::Workhorse.send_url(file.url))
+ status :ok
+ body
end
end
@@ -468,8 +468,8 @@ module API
header(*Gitlab::Workhorse.send_git_blob(repository, blob))
end
- def send_git_archive(repository, ref:, format:)
- header(*Gitlab::Workhorse.send_git_archive(repository, ref: ref, format: format))
+ def send_git_archive(repository, **kwargs)
+ header(*Gitlab::Workhorse.send_git_archive(repository, **kwargs))
end
def send_artifacts_entry(build, entry)
diff --git a/lib/api/helpers/badges_helpers.rb b/lib/api/helpers/badges_helpers.rb
new file mode 100644
index 00000000000..1f8afbf3c90
--- /dev/null
+++ b/lib/api/helpers/badges_helpers.rb
@@ -0,0 +1,28 @@
+module API
+ module Helpers
+ module BadgesHelpers
+ include ::API::Helpers::MembersHelpers
+
+ def find_badge(source)
+ source.badges.find(params[:badge_id])
+ end
+
+ def present_badges(source, records, options = {})
+ entity_type = options[:with] || Entities::Badge
+ badge_params = badge_source_params(source).merge(with: entity_type)
+
+ present records, badge_params
+ end
+
+ def badge_source_params(source)
+ project = if source.is_a?(Project)
+ source
+ else
+ GroupProjectsFinder.new(group: source, current_user: current_user).execute.first
+ end
+
+ { project: project }
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb
index 322624c6092..9993caa5249 100644
--- a/lib/api/helpers/common_helpers.rb
+++ b/lib/api/helpers/common_helpers.rb
@@ -3,8 +3,10 @@ module API
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)]
+ assignee_id = params.delete(:assignee_id)
+
+ if assignee_id.present?
+ params[:assignee_ids] = [assignee_id]
end
end
end
diff --git a/lib/api/helpers/custom_attributes.rb b/lib/api/helpers/custom_attributes.rb
new file mode 100644
index 00000000000..70e4eda95f8
--- /dev/null
+++ b/lib/api/helpers/custom_attributes.rb
@@ -0,0 +1,28 @@
+module API
+ module Helpers
+ module CustomAttributes
+ extend ActiveSupport::Concern
+
+ included do
+ helpers do
+ params :with_custom_attributes do
+ optional :with_custom_attributes, type: Boolean, default: false, desc: 'Include custom attributes in the response'
+ end
+
+ def with_custom_attributes(collection_or_resource, options = {})
+ options = options.merge(
+ with_custom_attributes: params[:with_custom_attributes] &&
+ can?(current_user, :read_custom_attribute)
+ )
+
+ if options[:with_custom_attributes] && collection_or_resource.is_a?(ActiveRecord::Relation)
+ collection_or_resource = collection_or_resource.includes(:custom_attributes)
+ end
+
+ [collection_or_resource, options]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb
index eff1c5b70ea..abe3d353984 100644
--- a/lib/api/helpers/internal_helpers.rb
+++ b/lib/api/helpers/internal_helpers.rb
@@ -1,11 +1,6 @@
module API
module Helpers
module InternalHelpers
- SSH_GITALY_FEATURES = {
- 'git-receive-pack' => [:ssh_receive_pack, Gitlab::GitalyClient::MigrationStatus::OPT_IN],
- 'git-upload-pack' => [:ssh_upload_pack, Gitlab::GitalyClient::MigrationStatus::OPT_OUT]
- }.freeze
-
attr_reader :redirected_path
def wiki?
@@ -34,18 +29,6 @@ module API
{}
end
- def fix_git_env_repository_paths(env, repository_path)
- if obj_dir_relative = env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence
- env['GIT_OBJECT_DIRECTORY'] = File.join(repository_path, obj_dir_relative)
- end
-
- if alt_obj_dirs_relative = env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE'].presence
- env['GIT_ALTERNATE_OBJECT_DIRECTORIES'] = alt_obj_dirs_relative.map { |dir| File.join(repository_path, dir) }
- end
-
- env
- end
-
def log_user_activity(actor)
commands = Gitlab::GitAccess::DOWNLOAD_COMMANDS
@@ -65,8 +48,20 @@ module API
false
end
+ def project_path
+ project&.path || project_path_match[:project_path]
+ end
+
+ def namespace_path
+ project&.namespace&.full_path || project_path_match[:namespace_path]
+ end
+
private
+ def project_path_match
+ @project_path_match ||= params[:project].match(Gitlab::PathRegex.full_project_git_path_regex) || {}
+ end
+
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def set_project
if params[:gl_repository]
@@ -102,8 +97,7 @@ module API
# Return the Gitaly Address if it is enabled
def gitaly_payload(action)
- feature, status = SSH_GITALY_FEATURES[action]
- return unless feature && Gitlab::GitalyClient.feature_enabled?(feature, status: status)
+ return unless %w[git-receive-pack git-upload-pack git-upload-archive].include?(action)
{
repository: repository.gitaly_repository,
diff --git a/lib/api/helpers/notes_helpers.rb b/lib/api/helpers/notes_helpers.rb
new file mode 100644
index 00000000000..cd91df1ecd8
--- /dev/null
+++ b/lib/api/helpers/notes_helpers.rb
@@ -0,0 +1,76 @@
+module API
+ module Helpers
+ module NotesHelpers
+ def update_note(noteable, note_id)
+ note = noteable.notes.find(params[:note_id])
+
+ authorize! :admin_note, note
+
+ opts = {
+ note: params[:body]
+ }
+ parent = noteable_parent(noteable)
+ project = parent if parent.is_a?(Project)
+
+ note = ::Notes::UpdateService.new(project, current_user, opts).execute(note)
+
+ if note.valid?
+ present note, with: Entities::Note
+ else
+ bad_request!("Failed to save note #{note.errors.messages}")
+ end
+ end
+
+ def delete_note(noteable, note_id)
+ note = noteable.notes.find(note_id)
+
+ authorize! :admin_note, note
+
+ parent = noteable_parent(noteable)
+ project = parent if parent.is_a?(Project)
+ destroy_conditionally!(note) do |note|
+ ::Notes::DestroyService.new(project, current_user).execute(note)
+ end
+ end
+
+ def get_note(noteable, note_id)
+ note = noteable.notes.with_metadata.find(params[:note_id])
+ can_read_note = can?(current_user, noteable_read_ability_name(noteable), noteable) && !note.cross_reference_not_visible_for?(current_user)
+
+ if can_read_note
+ present note, with: Entities::Note
+ else
+ not_found!("Note")
+ end
+ end
+
+ def noteable_read_ability_name(noteable)
+ "read_#{noteable.class.to_s.underscore}".to_sym
+ end
+
+ def find_noteable(parent, noteables_str, noteable_id)
+ public_send("find_#{parent}_#{noteables_str.singularize}", noteable_id) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def noteable_parent(noteable)
+ public_send("user_#{noteable.class.parent_class.to_s.underscore}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def create_note(noteable, opts)
+ noteables_str = noteable.model_name.to_s.underscore.pluralize
+
+ return not_found!(noteables_str) unless can?(current_user, noteable_read_ability_name(noteable), noteable)
+
+ authorize! :create_note, noteable
+
+ parent = noteable_parent(noteable)
+ if opts[:created_at]
+ opts.delete(:created_at) unless current_user.admin? || parent.owner == current_user
+ end
+
+ project = parent if parent.is_a?(Project)
+ ::Notes::CreateService.new(project, current_user, opts).execute
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb
index bb70370ba77..09805049169 100644
--- a/lib/api/helpers/pagination.rb
+++ b/lib/api/helpers/pagination.rb
@@ -12,13 +12,16 @@ module API
private
def add_pagination_headers(paginated_data)
- header 'X-Total', paginated_data.total_count.to_s
- header 'X-Total-Pages', total_pages(paginated_data).to_s
header 'X-Per-Page', paginated_data.limit_value.to_s
header 'X-Page', paginated_data.current_page.to_s
header 'X-Next-Page', paginated_data.next_page.to_s
header 'X-Prev-Page', paginated_data.prev_page.to_s
header 'Link', pagination_links(paginated_data)
+
+ return if data_without_counts?(paginated_data)
+
+ header 'X-Total', paginated_data.total_count.to_s
+ header 'X-Total-Pages', total_pages(paginated_data).to_s
end
def pagination_links(paginated_data)
@@ -37,8 +40,10 @@ module API
request_params[:page] = 1
links << %(<#{request_url}?#{request_params.to_query}>; rel="first")
- request_params[:page] = total_pages(paginated_data)
- links << %(<#{request_url}?#{request_params.to_query}>; rel="last")
+ unless data_without_counts?(paginated_data)
+ request_params[:page] = total_pages(paginated_data)
+ links << %(<#{request_url}?#{request_params.to_query}>; rel="last")
+ end
links.join(', ')
end
@@ -55,6 +60,10 @@ module API
relation
end
+
+ def data_without_counts?(paginated_data)
+ paginated_data.is_a?(Kaminari::PaginatableWithoutCount)
+ end
end
end
end
diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb
new file mode 100644
index 00000000000..381d5e8968c
--- /dev/null
+++ b/lib/api/helpers/projects_helpers.rb
@@ -0,0 +1,38 @@
+module API
+ module Helpers
+ module ProjectsHelpers
+ extend ActiveSupport::Concern
+
+ included do
+ helpers do
+ params :optional_project_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'
+ optional :jobs_enabled, type: Boolean, desc: 'Flag indication if jobs are enabled'
+ optional :snippets_enabled, type: Boolean, desc: 'Flag indication if snippets are enabled'
+ optional :shared_runners_enabled, type: Boolean, desc: 'Flag indication if shared runners are enabled for that project'
+ optional :resolve_outdated_diff_discussions, type: Boolean, desc: 'Automatically resolve merge request diffs discussions on lines changed with a push'
+ optional :container_registry_enabled, type: Boolean, desc: 'Flag indication if the container registry is enabled for that project'
+ optional :lfs_enabled, type: Boolean, desc: 'Flag indication if Git LFS is enabled for that project'
+ optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the project.'
+ optional :public_builds, type: Boolean, desc: 'Perform public builds'
+ 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'
+ optional :merge_method, type: String, values: %w(ff rebase_merge merge), desc: 'The merge method used when merging merge requests'
+ end
+
+ params :optional_project_params do
+ use :optional_project_params_ce
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/helpers/related_resources_helpers.rb b/lib/api/helpers/related_resources_helpers.rb
index 1f677529b07..7f4d6e58b34 100644
--- a/lib/api/helpers/related_resources_helpers.rb
+++ b/lib/api/helpers/related_resources_helpers.rb
@@ -15,7 +15,7 @@ module API
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
+ URI::Generic.build(scheme: protocol, host: host, port: port, path: path).to_s
end
private
diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb
index 2cae53dba53..35ac0b4cbca 100644
--- a/lib/api/helpers/runner.rb
+++ b/lib/api/helpers/runner.rb
@@ -1,47 +1,34 @@
module API
module Helpers
module Runner
- include Gitlab::CurrentSettings
-
JOB_TOKEN_HEADER = 'HTTP_JOB_TOKEN'.freeze
JOB_TOKEN_PARAM = :token
- UPDATE_RUNNER_EVERY = 10 * 60
def runner_registration_token_valid?
ActiveSupport::SecurityUtils.variable_size_secure_compare(params[:token],
- current_application_settings.runners_registration_token)
- end
-
- def get_runner_version_from_params
- return unless params['info'].present?
-
- attributes_for_keys(%w(name version revision platform architecture), params['info'])
+ Gitlab::CurrentSettings.runners_registration_token)
end
def authenticate_runner!
forbidden! unless current_runner
- end
- def current_runner
- @runner ||= ::Ci::Runner.find_by_token(params[:token].to_s)
+ current_runner
+ .update_cached_info(get_runner_details_from_request)
end
- def update_runner_info
- return unless update_runner?
+ def get_runner_details_from_request
+ return get_runner_ip unless params['info'].present?
- current_runner.contacted_at = Time.now
- current_runner.assign_attributes(get_runner_version_from_params)
- current_runner.save if current_runner.changed?
+ attributes_for_keys(%w(name version revision platform architecture), params['info'])
+ .merge(get_runner_ip)
end
- def update_runner?
- # Use a random threshold to prevent beating DB updates.
- # It generates a distribution between [40m, 80m].
- #
- contacted_at_max_age = UPDATE_RUNNER_EVERY + Random.rand(UPDATE_RUNNER_EVERY)
+ def get_runner_ip
+ { ip_address: request.ip }
+ end
- current_runner.contacted_at.nil? ||
- (Time.now - current_runner.contacted_at) >= contacted_at_max_age
+ def current_runner
+ @runner ||= ::Ci::Runner.find_by_token(params[:token].to_s)
end
def validate_job!(job)
@@ -70,7 +57,7 @@ module API
end
def max_artifacts_size
- current_application_settings.max_artifacts_size.megabytes.to_i
+ Gitlab::CurrentSettings.max_artifacts_size.megabytes.to_i
end
end
end
diff --git a/lib/api/internal.rb b/lib/api/internal.rb
index ccaaeca10d4..fcbc248fc3b 100644
--- a/lib/api/internal.rb
+++ b/lib/api/internal.rb
@@ -13,7 +13,7 @@ module API
# key_id - ssh key id for Git over SSH
# user_id - user id for Git over HTTP
# protocol - Git access protocol being used, e.g. HTTP or SSH
- # project - project path with namespace
+ # project - project full_path (not path on disk)
# action - git action (git-upload-pack or git-receive-pack)
# changes - changes as "oldrev newrev ref", see Gitlab::ChangesList
post "/allowed" do
@@ -21,8 +21,7 @@ module API
# Stores some Git-specific env thread-safely
env = parse_env
- env = fix_git_env_repository_paths(env, repository_path) if project
- Gitlab::Git::Env.set(env)
+ Gitlab::Git::HookEnv.set(gl_repository, env) if project
actor =
if params[:key_id]
@@ -42,11 +41,14 @@ module API
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)
+ access_checker = access_checker_klass.new(actor, project,
+ protocol, authentication_abilities: ssh_authentication_abilities,
+ namespace_path: namespace_path, project_path: project_path,
+ redirected_path: redirected_path)
begin
access_checker.check(params[:action], params[:changes])
+ @project ||= access_checker.project
rescue Gitlab::GitAccess::UnauthorizedError, Gitlab::GitAccess::NotFoundError => e
return { status: false, message: e.message }
end
@@ -82,6 +84,18 @@ module API
end
#
+ # Get a ssh key using the fingerprint
+ #
+ get "/authorized_keys" do
+ fingerprint = params.fetch(:fingerprint) do
+ Gitlab::InsecureKeyFingerprint.new(params.fetch(:key)).fingerprint
+ end
+ key = Key.find_by(fingerprint: fingerprint)
+ not_found!("Key") if key.nil?
+ present key, with: Entities::SSHKey
+ end
+
+ #
# Discover user by ssh key or user id
#
get "/discover" do
@@ -91,6 +105,7 @@ module API
elsif params[:user_id]
user = User.find_by(id: params[:user_id])
end
+
present user, with: Entities::UserSafe
end
@@ -190,9 +205,15 @@ module API
project = Gitlab::GlRepository.parse(params[:gl_repository]).first
user = identify(params[:identifier])
- redirect_message = Gitlab::Checks::ProjectMoved.fetch_redirect_message(user.id, project.id)
- if redirect_message
- output[:redirected_message] = redirect_message
+
+ # A user is not guaranteed to be returned; an orphaned write deploy
+ # key could be used
+ if user
+ redirect_message = Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id)
+ project_created_message = Gitlab::Checks::ProjectCreated.fetch_message(user.id, project.id)
+
+ output[:redirected_message] = redirect_message if redirect_message
+ output[:project_created_message] = project_created_message if project_created_message
end
output
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index b29c5848aef..88e7f46c92c 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -32,6 +32,8 @@ module API
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 :updated_after, type: DateTime, desc: 'Return issues updated after the specified time'
+ optional :updated_before, type: DateTime, desc: 'Return issues updated 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],
@@ -95,7 +97,7 @@ module API
get ":id/issues" do
group = find_group!(params[:id])
- issues = paginate(find_issues(group_id: group.id))
+ issues = paginate(find_issues(group_id: group.id, include_subgroups: true))
options = {
with: Entities::IssueBasic,
@@ -161,6 +163,8 @@ module API
use :issue_params
end
post ':id/issues' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42320')
+
authorize! :create_issue, user_project
# Setting created_at time only allowed for admins and project owners
@@ -175,6 +179,7 @@ module API
issue = ::Issues::CreateService.new(user_project,
current_user,
issue_params.merge(request: request, api: true)).execute
+
if issue.spam?
render_api_error!({ error: 'Spam detected' }, 400)
end
@@ -200,6 +205,8 @@ module API
:labels, :created_at, :due_date, :confidential, :state_event
end
put ':id/issues/:issue_iid' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42322')
+
issue = user_project.issues.find_by!(iid: params.delete(:issue_iid))
authorize! :update_issue, issue
@@ -233,6 +240,8 @@ module API
requires :to_project_id, type: Integer, desc: 'The ID of the new project'
end
post ':id/issues/:issue_iid/move' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42323')
+
issue = user_project.issues.find_by(iid: params[:issue_iid])
not_found!('Issue') unless issue
@@ -277,6 +286,19 @@ module API
present paginate(merge_requests), with: Entities::MergeRequestBasic, current_user: current_user, project: user_project
end
+ desc 'List participants for an issue' do
+ success Entities::UserBasic
+ end
+ params do
+ requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue'
+ end
+ get ':id/issues/:issue_iid/participants' do
+ issue = find_project_issue(params[:issue_iid])
+ participants = ::Kaminari.paginate_array(issue.participants)
+
+ present paginate(participants), with: Entities::UserBasic, current_user: current_user, project: user_project
+ end
+
desc 'Get the user agent details for an issue' do
success Entities::UserAgentDetail
end
diff --git a/lib/api/job_artifacts.rb b/lib/api/job_artifacts.rb
index 2a8fa7659bf..b1adef49d46 100644
--- a/lib/api/job_artifacts.rb
+++ b/lib/api/job_artifacts.rb
@@ -2,39 +2,48 @@ module API
class JobArtifacts < Grape::API
before { authenticate_non_get! }
+ # EE::API::JobArtifacts would override the following helpers
+ helpers do
+ def authorize_download_artifacts!
+ authorize_read_builds!
+ end
+ end
+
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
- desc 'Download the artifacts file from a job' do
+ desc 'Download the artifacts archive from a job' do
detail 'This feature was introduced in GitLab 8.10'
end
params do
requires :ref_name, type: String, desc: 'The ref from repository'
requires :job, type: String, desc: 'The name for the job'
end
+ route_setting :authentication, job_token_allowed: true
get ':id/jobs/artifacts/:ref_name/download',
requirements: { ref_name: /.+/ } do
- authorize_read_builds!
+ authorize_download_artifacts!
builds = user_project.latest_successful_builds_for(params[:ref_name])
latest_build = builds.find_by!(name: params[:job])
- present_artifacts!(latest_build.artifacts_file)
+ present_carrierwave_file!(latest_build.artifacts_file)
end
- desc 'Download the artifacts file from a job' do
+ desc 'Download the artifacts archive from a job' do
detail 'This feature was introduced in GitLab 8.5'
end
params do
requires :job_id, type: Integer, desc: 'The ID of a job'
end
+ route_setting :authentication, job_token_allowed: true
get ':id/jobs/:job_id/artifacts' do
- authorize_read_builds!
+ authorize_download_artifacts!
build = find_build!(params[:job_id])
- present_artifacts!(build.artifacts_file)
+ present_carrierwave_file!(build.artifacts_file)
end
desc 'Download a specific file from artifacts archive' do
diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb
index a116ab3c9bd..60911c8d733 100644
--- a/lib/api/jobs.rb
+++ b/lib/api/jobs.rb
@@ -38,6 +38,7 @@ module API
builds = user_project.builds.order('id DESC')
builds = filter_builds(builds, params[:scope])
+ builds = builds.preload(:user, :job_artifacts_archive, :runner, pipeline: :project)
present paginate(builds), with: Entities::Job
end
@@ -71,7 +72,7 @@ module API
present build, with: Entities::Job
end
- # TODO: We should use `present_file!` and leave this implementation for backward compatibility (when build trace
+ # TODO: We should use `present_disk_file!` and leave this implementation for backward compatibility (when build trace
# is saved in the DB instead of file). But before that, we need to consider how to replace the value of
# `runners_token` with some mask (like `xxxxxx`) when sending trace file directly by workhorse.
desc 'Get a trace of a specific job of a project'
diff --git a/lib/api/labels.rb b/lib/api/labels.rb
index e41a1720ac1..81eaf56e48e 100644
--- a/lib/api/labels.rb
+++ b/lib/api/labels.rb
@@ -15,7 +15,7 @@ module API
use :pagination
end
get ':id/labels' do
- present paginate(available_labels), with: Entities::Label, current_user: current_user, project: user_project
+ present paginate(available_labels_for(user_project)), with: Entities::Label, current_user: current_user, project: user_project
end
desc 'Create a new label' do
@@ -30,7 +30,7 @@ module API
post ':id/labels' do
authorize! :admin_label, user_project
- label = available_labels.find_by(title: params[:name])
+ label = available_labels_for(user_project).find_by(title: params[:name])
conflict!('Label already exists') if label
priority = params.delete(:priority)
diff --git a/lib/api/members.rb b/lib/api/members.rb
index 22e4bdead41..8b12986d09e 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -21,10 +21,11 @@ module API
get ":id/members" do
source = find_source(source_type, params[:id])
- users = source.users
- users = users.merge(User.search(params[:query])) if params[:query]
+ members = source.members.where.not(user_id: nil).includes(:user)
+ members = members.joins(:user).merge(User.search(params[:query])) if params[:query].present?
+ members = paginate(members)
- present paginate(users), with: Entities::Member, source: source
+ present members, with: Entities::Member
end
desc 'Gets a member of a group or project.' do
@@ -39,7 +40,7 @@ module API
members = source.members
member = members.find_by!(user_id: params[:user_id])
- present member.user, with: Entities::Member, member: member
+ present member, with: Entities::Member
end
desc 'Adds a member to a group or project.' do
@@ -59,8 +60,10 @@ module API
member = source.add_user(params[:user_id], params[:access_level], current_user: current_user, expires_at: params[:expires_at])
- if member.persisted? && member.valid?
- present member.user, with: Entities::Member, member: member
+ if !member
+ not_allowed! # This currently can only be reached in EE
+ elsif member.persisted? && member.valid?
+ present member, with: Entities::Member
else
render_validation_error!(member)
end
@@ -78,12 +81,16 @@ module API
source = find_source(source_type, params.delete(:id))
authorize_admin_source!(source_type, source)
- member = source.members.find_by!(user_id: params.delete(:user_id))
+ member = source.members.find_by!(user_id: params[:user_id])
+ updated_member =
+ ::Members::UpdateService
+ .new(current_user, declared_params(include_missing: false))
+ .execute(member)
- if member.update_attributes(declared_params(include_missing: false))
- present member.user, with: Entities::Member, member: member
+ if updated_member.valid?
+ present updated_member, with: Entities::Member
else
- render_validation_error!(member)
+ render_validation_error!(updated_member)
end
end
@@ -96,7 +103,7 @@ module API
member = source.members.find_by!(user_id: params[:user_id])
destroy_conditionally!(member) do
- ::Members::DestroyService.new(source, current_user, declared_params).execute
+ ::Members::DestroyService.new(current_user).execute(member)
end
end
end
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 02f2b75ab9d..3264a26f7d2 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -6,6 +6,32 @@ module API
helpers ::Gitlab::IssuableMetadata
+ # EE::API::MergeRequests would override the following helpers
+ helpers do
+ params :optional_params_ee do
+ end
+
+ params :merge_params_ee do
+ end
+
+ def update_merge_request_ee(merge_request)
+ end
+ end
+
+ def self.update_params_at_least_one_of
+ %i[
+ assignee_id
+ description
+ labels
+ milestone_id
+ remove_source_branch
+ state_event
+ target_branch
+ title
+ discussion_locked
+ ]
+ end
+
helpers do
def find_merge_requests(args = {})
args = declared_params.merge(args)
@@ -24,6 +50,19 @@ module API
.preload(:notes, :author, :assignee, :milestone, :latest_merge_request_diff, :labels, :timelogs)
end
+ def merge_request_pipelines_with_access
+ authorize! :read_pipeline, user_project
+
+ mr = find_merge_request_with_access(params[:merge_request_iid])
+ mr.all_pipelines
+ end
+
+ def check_sha_param!(params, merge_request)
+ if params[:sha] && merge_request.diff_head_sha != params[:sha]
+ render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409)
+ end
+ 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'
@@ -35,12 +74,16 @@ module API
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 :updated_after, type: DateTime, desc: 'Return merge requests updated after the specified time'
+ optional :updated_before, type: DateTime, desc: 'Return merge requests updated 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`'
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
+ optional :source_branch, type: String, desc: 'Return merge requests with the given source branch'
+ optional :target_branch, type: String, desc: 'Return merge requests with the given target branch'
optional :search, type: String, desc: 'Search merge requests for text present in the title or description'
use :pagination
end
@@ -95,16 +138,15 @@ module API
render_api_error!(errors, 400)
end
- params :optional_params_ce do
+ params :optional_params 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'
optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign the merge request'
optional :labels, type: String, desc: 'Comma-separated list of label names'
optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging'
- end
+ optional :allow_maintainer_to_push, type: Boolean, desc: 'Whether a maintainer of the target project can push to the source project'
- params :optional_params do
- use :optional_params_ce
+ use :optional_params_ee
end
end
@@ -145,6 +187,8 @@ module API
use :optional_params
end
post ":id/merge_requests" do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42316')
+
authorize! :create_merge_request, user_project
mr_params = declared_params(include_missing: false)
@@ -185,6 +229,16 @@ module API
present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
end
+ desc 'Get the participants of a merge request' do
+ success Entities::UserBasic
+ end
+ get ':id/merge_requests/:merge_request_iid/participants' do
+ merge_request = find_merge_request_with_access(params[:merge_request_iid])
+ participants = ::Kaminari.paginate_array(merge_request.participants)
+
+ present paginate(participants), with: Entities::UserBasic
+ end
+
desc 'Get the commits of a merge request' do
success Entities::Commit
end
@@ -201,25 +255,22 @@ module API
get ':id/merge_requests/:merge_request_iid/changes' do
merge_request = find_merge_request_with_access(params[:merge_request_iid])
- present merge_request, with: Entities::MergeRequestChanges, current_user: current_user
+ present merge_request, with: Entities::MergeRequestChanges, current_user: current_user, project: user_project
+ end
+
+ desc 'Get the merge request pipelines' do
+ success Entities::PipelineBasic
+ end
+ get ':id/merge_requests/:merge_request_iid/pipelines' do
+ pipelines = merge_request_pipelines_with_access
+
+ present paginate(pipelines), with: Entities::PipelineBasic
end
desc 'Update a merge request' do
success Entities::MergeRequest
end
params do
- # CE
- at_least_one_of_ce = [
- :assignee_id,
- :description,
- :labels,
- :milestone_id,
- :remove_source_branch,
- :state_event,
- :target_branch,
- :title,
- :discussion_locked
- ]
optional :title, type: String, allow_blank: false, desc: 'The title of the merge request'
optional :target_branch, type: String, allow_blank: false, desc: 'The target branch'
optional :state_event, type: String, values: %w[close reopen],
@@ -227,9 +278,11 @@ module API
optional :discussion_locked, type: Boolean, desc: 'Whether the MR discussion is locked'
use :optional_params
- at_least_one_of(*at_least_one_of_ce)
+ at_least_one_of(*::API::MergeRequests.update_params_at_least_one_of)
end
put ':id/merge_requests/:merge_request_iid' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42318')
+
merge_request = find_merge_request_with_access(params.delete(:merge_request_iid), :update_merge_request)
mr_params = declared_params(include_missing: false)
@@ -248,15 +301,18 @@ module API
success Entities::MergeRequest
end
params do
- # CE
optional :merge_commit_message, type: String, desc: 'Custom merge commit message'
optional :should_remove_source_branch, type: Boolean,
desc: 'When true, the source branch will be deleted if possible'
optional :merge_when_pipeline_succeeds, type: Boolean,
desc: 'When true, this merge request will be merged when the pipeline succeeds'
optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch'
+
+ use :merge_params_ee
end
put ':id/merge_requests/:merge_request_iid/merge' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42317')
+
merge_request = find_project_merge_request(params[:merge_request_iid])
merge_when_pipeline_succeeds = to_boolean(params[:merge_when_pipeline_succeeds])
@@ -268,9 +324,9 @@ module API
render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds)
- if params[:sha] && merge_request.diff_head_sha != params[:sha]
- render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409)
- end
+ check_sha_param!(params, merge_request)
+
+ update_merge_request_ee(merge_request)
merge_params = {
commit_message: params[:merge_commit_message],
diff --git a/lib/api/notes.rb b/lib/api/notes.rb
index 3588dc85c9e..69f1df6b341 100644
--- a/lib/api/notes.rb
+++ b/lib/api/notes.rb
@@ -1,19 +1,23 @@
module API
class Notes < Grape::API
include PaginationParams
+ helpers ::API::Helpers::NotesHelpers
before { authenticate! }
NOTEABLE_TYPES = [Issue, MergeRequest, Snippet].freeze
- params do
- requires :id, type: String, desc: 'The ID of a project'
- end
- resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
- NOTEABLE_TYPES.each do |noteable_type|
+ NOTEABLE_TYPES.each do |noteable_type|
+ parent_type = noteable_type.parent_class.to_s.underscore
+ noteables_str = noteable_type.to_s.underscore.pluralize
+
+ params do
+ requires :id, type: String, desc: "The ID of a #{parent_type}"
+ end
+ resource parent_type.pluralize.to_sym, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
noteables_str = noteable_type.to_s.underscore.pluralize
- desc 'Get a list of project +noteable+ notes' do
+ desc "Get a list of #{noteable_type.to_s.downcase} notes" do
success Entities::Note
end
params do
@@ -25,7 +29,7 @@ module API
use :pagination
end
get ":id/#{noteables_str}/:noteable_id/notes" do
- noteable = find_project_noteable(noteables_str, params[:noteable_id])
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
if can?(current_user, noteable_read_ability_name(noteable), noteable)
# We exclude notes that are cross-references and that cannot be viewed
@@ -46,7 +50,7 @@ module API
end
end
- desc 'Get a single +noteable+ note' do
+ desc "Get a single #{noteable_type.to_s.downcase} note" do
success Entities::Note
end
params do
@@ -54,18 +58,11 @@ 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 = find_project_noteable(noteables_str, params[:noteable_id])
- note = noteable.notes.with_metadata.find(params[:note_id])
- can_read_note = can?(current_user, noteable_read_ability_name(noteable), noteable) && !note.cross_reference_not_visible_for?(current_user)
-
- if can_read_note
- present note, with: Entities::Note
- else
- not_found!("Note")
- end
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
+ get_note(noteable, params[:note_id])
end
- desc 'Create a new +noteable+ note' do
+ desc "Create a new #{noteable_type.to_s.downcase} note" do
success Entities::Note
end
params do
@@ -74,34 +71,25 @@ module API
optional :created_at, type: String, desc: 'The creation date of the note'
end
post ":id/#{noteables_str}/:noteable_id/notes" do
- noteable = find_project_noteable(noteables_str, params[:noteable_id])
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
opts = {
note: params[:body],
noteable_type: noteables_str.classify,
- noteable_id: noteable.id
+ noteable_id: noteable.id,
+ created_at: params[:created_at]
}
- if can?(current_user, noteable_read_ability_name(noteable), noteable)
- authorize! :create_note, noteable
+ note = create_note(noteable, opts)
- if params[:created_at] && (current_user.admin? || user_project.owner == current_user)
- opts[:created_at] = params[:created_at]
- end
-
- note = ::Notes::CreateService.new(user_project, current_user, opts).execute
-
- if note.valid?
- present note, with: Entities.const_get(note.class.name)
- else
- not_found!("Note #{note.errors.messages}")
- end
+ if note.valid?
+ present note, with: Entities.const_get(note.class.name)
else
- not_found!("Note")
+ bad_request!("Note #{note.errors.messages}")
end
end
- desc 'Update an existing +noteable+ note' do
+ desc "Update an existing #{noteable_type.to_s.downcase} note" do
success Entities::Note
end
params do
@@ -110,24 +98,12 @@ module API
requires :body, type: String, desc: 'The content of a note'
end
put ":id/#{noteables_str}/:noteable_id/notes/:note_id" do
- note = user_project.notes.find(params[:note_id])
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
- authorize! :admin_note, note
-
- opts = {
- note: params[:body]
- }
-
- note = ::Notes::UpdateService.new(user_project, current_user, opts).execute(note)
-
- if note.valid?
- present note, with: Entities::Note
- else
- render_api_error!("Failed to save note #{note.errors.messages}", 400)
- end
+ update_note(noteable, params[:note_id])
end
- desc 'Delete a +noteable+ note' do
+ desc "Delete a #{noteable_type.to_s.downcase} note" do
success Entities::Note
end
params do
@@ -135,25 +111,11 @@ module API
requires :note_id, type: Integer, desc: 'The ID of a note'
end
delete ":id/#{noteables_str}/:noteable_id/notes/:note_id" do
- note = user_project.notes.find(params[:note_id])
-
- authorize! :admin_note, note
+ noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
- destroy_conditionally!(note) do |note|
- ::Notes::DestroyService.new(user_project, current_user).execute(note)
- end
+ delete_note(noteable, params[:note_id])
end
end
end
-
- helpers do
- def find_project_noteable(noteables_str, noteable_id)
- public_send("find_project_#{noteables_str.singularize}", noteable_id) # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def noteable_read_ability_name(noteable)
- "read_#{noteable.class.to_s.underscore}".to_sym
- end
- end
end
end
diff --git a/lib/api/pages_domains.rb b/lib/api/pages_domains.rb
index d7b613a717e..ba33993d852 100644
--- a/lib/api/pages_domains.rb
+++ b/lib/api/pages_domains.rb
@@ -2,6 +2,8 @@ module API
class PagesDomains < Grape::API
include PaginationParams
+ PAGES_DOMAINS_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(domain: API::NO_SLASH_URL_PART_REGEX)
+
before do
authenticate!
end
@@ -48,7 +50,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
before do
require_pages_enabled!
end
@@ -71,7 +73,7 @@ module API
params do
requires :domain, type: String, desc: 'The domain'
end
- get ":id/pages/domains/:domain", requirements: { domain: %r{[^/]+} } do
+ get ":id/pages/domains/:domain", requirements: PAGES_DOMAINS_ENDPOINT_REQUIREMENTS do
authorize! :read_pages, user_project
present pages_domain, with: Entities::PagesDomain
@@ -105,7 +107,7 @@ module API
optional :certificate, allow_blank: false, types: [File, String], desc: 'The certificate'
optional :key, allow_blank: false, types: [File, String], desc: 'The key'
end
- put ":id/pages/domains/:domain", requirements: { domain: %r{[^/]+} } do
+ put ":id/pages/domains/:domain", requirements: PAGES_DOMAINS_ENDPOINT_REQUIREMENTS do
authorize! :update_pages, user_project
pages_domain_params = declared(params, include_parent_namespaces: false)
@@ -126,7 +128,7 @@ module API
params do
requires :domain, type: String, desc: 'The domain'
end
- delete ":id/pages/domains/:domain", requirements: { domain: %r{[^/]+} } do
+ delete ":id/pages/domains/:domain", requirements: PAGES_DOMAINS_ENDPOINT_REQUIREMENTS do
authorize! :update_pages, user_project
status 204
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
index 74b3376a1f3..d2b8b832e4e 100644
--- a/lib/api/pipelines.rb
+++ b/lib/api/pipelines.rb
@@ -42,12 +42,15 @@ module API
requires :ref, type: String, desc: 'Reference'
end
post ':id/pipeline' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42124')
+
authorize! :create_pipeline, user_project
new_pipeline = Ci::CreatePipelineService.new(user_project,
current_user,
declared_params(include_missing: 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_export.rb b/lib/api/project_export.rb
new file mode 100644
index 00000000000..5ef4e9d530c
--- /dev/null
+++ b/lib/api/project_export.rb
@@ -0,0 +1,63 @@
+module API
+ class ProjectExport < Grape::API
+ before do
+ not_found! unless Gitlab::CurrentSettings.project_export_enabled?
+ authorize_admin_project
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: { id: %r{[^/]+} } do
+ desc 'Get export status' do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::ProjectExportStatus
+ end
+ get ':id/export' do
+ present user_project, with: Entities::ProjectExportStatus
+ end
+
+ desc 'Download export' do
+ detail 'This feature was introduced in GitLab 10.6.'
+ end
+ get ':id/export/download' do
+ path = user_project.export_project_path
+
+ render_api_error!('404 Not found or has expired', 404) unless path
+
+ present_disk_file!(path, File.basename(path), 'application/gzip')
+ end
+
+ desc 'Start export' do
+ detail 'This feature was introduced in GitLab 10.6.'
+ end
+ params do
+ optional :description, type: String, desc: 'Override the project description'
+ optional :upload, type: Hash do
+ optional :url, type: String, desc: 'The URL to upload the project'
+ optional :http_method, type: String, default: 'PUT', desc: 'HTTP method to upload the exported project'
+ end
+ end
+ post ':id/export' do
+ project_export_params = declared_params(include_missing: false)
+ after_export_params = project_export_params.delete(:upload) || {}
+
+ export_strategy = if after_export_params[:url].present?
+ params = after_export_params.slice(:url, :http_method).symbolize_keys
+
+ Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy.new(params)
+ end
+
+ if export_strategy&.invalid?
+ render_validation_error!(export_strategy)
+ else
+ user_project.add_export_job(current_user: current_user,
+ after_export_strategy: export_strategy,
+ params: project_export_params)
+ end
+
+ accepted!
+ end
+ end
+ end
+end
diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb
index 86066e2b58f..68921ae439b 100644
--- a/lib/api/project_hooks.rb
+++ b/lib/api/project_hooks.rb
@@ -10,9 +10,11 @@ module API
requires :url, type: String, desc: "The URL to send the request to"
optional :push_events, type: Boolean, desc: "Trigger hook on push events"
optional :issues_events, type: Boolean, desc: "Trigger hook on issues events"
+ optional :confidential_issues_events, type: Boolean, desc: "Trigger hook on confidential issues events"
optional :merge_requests_events, type: Boolean, desc: "Trigger hook on merge request events"
optional :tag_push_events, type: Boolean, desc: "Trigger hook on tag push events"
optional :note_events, type: Boolean, desc: "Trigger hook on note(comment) events"
+ optional :confidential_note_events, type: Boolean, desc: "Trigger hook on confidential note(comment) events"
optional :job_events, type: Boolean, desc: "Trigger hook on job events"
optional :pipeline_events, type: Boolean, desc: "Trigger hook on pipeline events"
optional :wiki_page_events, type: Boolean, desc: "Trigger hook on wiki events"
diff --git a/lib/api/project_import.rb b/lib/api/project_import.rb
new file mode 100644
index 00000000000..bc5152e539f
--- /dev/null
+++ b/lib/api/project_import.rb
@@ -0,0 +1,81 @@
+module API
+ class ProjectImport < Grape::API
+ include PaginationParams
+ include Helpers::ProjectsHelpers
+
+ helpers do
+ def import_params
+ declared_params(include_missing: false)
+ end
+
+ def file_is_valid?
+ import_params[:file] && import_params[:file]['tempfile'].respond_to?(:read)
+ end
+
+ def validate_file!
+ render_api_error!('The file is invalid', 400) unless file_is_valid?
+ end
+ end
+
+ before do
+ forbidden! unless Gitlab::CurrentSettings.import_sources.include?('gitlab_project')
+ end
+
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ params do
+ requires :path, type: String, desc: 'The new project path and name'
+ requires :file, type: File, desc: 'The project export file to be imported'
+ optional :namespace, type: String, desc: "The ID or name of the namespace that the project will be imported into. Defaults to the current user's namespace."
+ optional :overwrite, type: Boolean, default: false, desc: 'If there is a project in the same namespace and with the same name overwrite it'
+ optional :override_params,
+ type: Hash,
+ desc: 'New project params to override values in the export' do
+ use :optional_project_params
+ end
+ end
+ desc 'Create a new project import' do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::ProjectImportStatus
+ end
+ post 'import' do
+ validate_file!
+
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42437')
+
+ namespace = if import_params[:namespace]
+ find_namespace!(import_params[:namespace])
+ else
+ current_user.namespace
+ end
+
+ project_params = {
+ path: import_params[:path],
+ namespace_id: namespace.id,
+ file: import_params[:file]['tempfile'],
+ overwrite: import_params[:overwrite]
+ }
+
+ override_params = import_params.delete(:override_params)
+
+ project = ::Projects::GitlabProjectsImportService.new(
+ current_user, project_params, override_params
+ ).execute
+
+ render_api_error!(project.errors.full_messages&.first, 400) unless project.saved?
+
+ present project, with: Entities::ProjectImportStatus
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ desc 'Get a project export status' do
+ detail 'This feature was introduced in GitLab 10.6.'
+ success Entities::ProjectImportStatus
+ end
+ get ':id/import' do
+ present user_project, with: Entities::ProjectImportStatus
+ end
+ end
+ end
+end
diff --git a/lib/api/project_milestones.rb b/lib/api/project_milestones.rb
index 0cb209a02d0..306dc0e63d7 100644
--- a/lib/api/project_milestones.rb
+++ b/lib/api/project_milestones.rb
@@ -60,6 +60,15 @@ module API
update_milestone_for(user_project)
end
+ desc 'Remove a project milestone'
+ delete ":id/milestones/:milestone_id" do
+ authorize! :admin_milestone, user_project
+
+ user_project.milestones.find(params[:milestone_id]).destroy
+
+ status(204)
+ end
+
desc 'Get all issues for a single project milestone' do
success Entities::IssueBasic
end
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index 2ccda1c1aa1..39c03c40bab 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -13,6 +13,7 @@ module API
if errors[:project_access].any?
error!(errors[:project_access], 422)
end
+
not_found!
end
@@ -142,7 +143,7 @@ module API
get ":id/snippets/:snippet_id/user_agent_detail" do
authenticated_as_admin!
- snippet = Snippet.find_by!(id: params[:id])
+ snippet = Snippet.find_by!(id: params[:snippet_id], project_id: params[:id])
return not_found!('UserAgentDetail') unless snippet.user_agent_detail
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index fa222bf2b1c..d0a4a23e074 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -3,36 +3,12 @@ require_dependency 'declarative_policy'
module API
class Projects < Grape::API
include PaginationParams
+ include Helpers::CustomAttributes
+ include Helpers::ProjectsHelpers
before { authenticate_non_get! }
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'
- optional :jobs_enabled, type: Boolean, desc: 'Flag indication if jobs are enabled'
- optional :snippets_enabled, type: Boolean, desc: 'Flag indication if snippets are enabled'
- optional :shared_runners_enabled, type: Boolean, desc: 'Flag indication if shared runners are enabled for that project'
- optional :resolve_outdated_diff_discussions, type: Boolean, desc: 'Automatically resolve merge request diffs discussions on lines changed with a push'
- optional :container_registry_enabled, type: Boolean, desc: 'Flag indication if the container registry is enabled for that project'
- optional :lfs_enabled, type: Boolean, desc: 'Flag indication if Git LFS is enabled for that project'
- optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the project.'
- optional :public_builds, type: Boolean, desc: 'Perform public builds'
- 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
-
params :statistics_params do
optional :statistics, type: Boolean, default: false, desc: 'Include project statistics'
end
@@ -76,10 +52,11 @@ module API
def present_projects(projects, options = {})
projects = reorder_projects(projects)
- projects = projects.with_statistics if params[:statistics]
- projects = projects.with_issues_enabled if params[:with_issues_enabled]
+ projects = projects.with_issues_available_for_user(current_user) if params[:with_issues_enabled]
projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled]
+ projects = projects.with_statistics if params[:statistics]
projects = paginate(projects)
+ projects, options = with_custom_attributes(projects, options)
if current_user
project_members = current_user.project_members.preload(:source, user: [notification_settings: :source])
@@ -107,6 +84,7 @@ module API
requires :user_id, type: String, desc: 'The ID or username of the user'
use :collection_params
use :statistics_params
+ use :with_custom_attributes
end
get ":user_id/projects" do
user = find_user(params[:user_id])
@@ -127,6 +105,7 @@ module API
params do
use :collection_params
use :statistics_params
+ use :with_custom_attributes
end
get do
present_projects load_projects
@@ -139,7 +118,7 @@ module API
optional :name, type: String, desc: 'The name of the project'
optional :path, type: String, desc: 'The path of the repository'
at_least_one_of :name, :path
- use :optional_params
+ use :optional_project_params
use :create_params
end
post do
@@ -154,6 +133,7 @@ module API
if project.errors[:limit_reached].present?
error!(project.errors[:limit_reached], 403)
end
+
render_validation_error!(project)
end
end
@@ -166,7 +146,7 @@ module API
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 :optional_project_params
use :create_params
end
post "user/:user_id" do
@@ -195,11 +175,19 @@ module API
end
params do
use :statistics_params
+ use :with_custom_attributes
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), statistics: params[:statistics]
+ options = {
+ with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails,
+ current_user: current_user,
+ user_can_admin_project: can?(current_user, :admin_project, user_project),
+ statistics: params[:statistics]
+ }
+
+ project, options = with_custom_attributes(user_project, options)
+
+ present project, options
end
desc 'Fork new project for the current user or provided namespace.' do
@@ -209,15 +197,13 @@ module API
optional :namespace, type: String, desc: 'The ID or name of the namespace that the project will be forked into'
end
post ':id/fork' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42284')
+
fork_params = declared_params(include_missing: false)
namespace_id = fork_params[:namespace]
if namespace_id.present?
- fork_params[:namespace] = if namespace_id =~ /^\d+$/
- Namespace.find_by(id: namespace_id)
- else
- Namespace.find_by_path_or_name(namespace_id)
- end
+ fork_params[:namespace] = find_namespace(namespace_id)
unless fork_params[:namespace] && can?(current_user, :create_projects, fork_params[:namespace])
not_found!('Target Namespace')
@@ -239,6 +225,7 @@ module API
end
params do
use :collection_params
+ use :with_custom_attributes
end
get ':id/forks' do
forks = ForkProjectsFinder.new(user_project, params: project_finder_params, current_user: current_user).execute
@@ -255,12 +242,14 @@ module API
[
:jobs_enabled,
:resolve_outdated_diff_discussions,
+ :ci_config_path,
:container_registry_enabled,
:default_branch,
:description,
:issues_enabled,
:lfs_enabled,
:merge_requests_enabled,
+ :merge_method,
:name,
:only_allow_merge_if_all_discussions_are_resolved,
:only_allow_merge_if_pipeline_succeeds,
@@ -278,7 +267,7 @@ module API
optional :default_branch, type: String, desc: 'The default branch of the project'
optional :path, type: String, desc: 'The path of the repository'
- use :optional_params
+ use :optional_project_params
at_least_one_of(*at_least_one_of_ce)
end
put ':id' do
diff --git a/lib/api/protected_branches.rb b/lib/api/protected_branches.rb
index 614822509f0..aa7cab4a741 100644
--- a/lib/api/protected_branches.rb
+++ b/lib/api/protected_branches.rb
@@ -2,7 +2,7 @@ module API
class ProtectedBranches < Grape::API
include PaginationParams
- BRANCH_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(branch: API::NO_SLASH_URL_PART_REGEX)
+ BRANCH_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(name: API::NO_SLASH_URL_PART_REGEX)
before { authorize_admin_project }
@@ -52,11 +52,7 @@ module API
conflict!("Protected branch '#{params[:name]}' already exists")
end
- # Replace with `declared(params)` after updating to grape v1.0.2
- # See https://github.com/ruby-grape/grape/pull/1710
- # and https://gitlab.com/gitlab-org/gitlab-ce/issues/40843
- declared_params = params.slice("name", "push_access_level", "merge_access_level", "allowed_to_push", "allowed_to_merge")
-
+ declared_params = declared_params(include_missing: false)
api_service = ::ProtectedBranches::ApiService.new(user_project, current_user, declared_params)
protected_branch = api_service.create
@@ -74,7 +70,10 @@ module API
delete ':id/protected_branches/:name', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
protected_branch = user_project.protected_branches.find_by!(name: params[:name])
- destroy_conditionally!(protected_branch)
+ destroy_conditionally!(protected_branch) do
+ destroy_service = ::ProtectedBranches::DestroyService.new(user_project, current_user)
+ destroy_service.execute(protected_branch)
+ end
end
end
end
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index 4f36bbd760f..2396dc73f0e 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -15,6 +15,7 @@ module API
if errors[:project_access].any?
error!(errors[:project_access], 422)
end
+
not_found!
end
@@ -87,7 +88,7 @@ module API
end
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]
+ send_git_archive user_project.repository, ref: params[:sha], format: params[:format], append_sha: true
rescue
not_found!('File')
end
diff --git a/lib/api/runner.rb b/lib/api/runner.rb
index 80feb629d54..60aeb69e10a 100644
--- a/lib/api/runner.rb
+++ b/lib/api/runner.rb
@@ -14,9 +14,11 @@ module API
optional :locked, type: Boolean, desc: 'Should Runner be locked for current project'
optional :run_untagged, type: Boolean, desc: 'Should Runner handle untagged jobs'
optional :tag_list, type: Array[String], desc: %q(List of Runner's tags)
+ optional :maximum_timeout, type: Integer, desc: 'Maximum timeout set when this Runner will handle the job'
end
post '/' do
- attributes = attributes_for_keys [:description, :locked, :run_untagged, :tag_list]
+ attributes = attributes_for_keys([:description, :locked, :run_untagged, :tag_list, :maximum_timeout])
+ .merge(get_runner_details_from_request)
runner =
if runner_registration_token_valid?
@@ -30,7 +32,6 @@ module API
return forbidden! unless runner
if runner.id
- runner.update(get_runner_version_from_params)
present runner, with: Entities::RunnerRegistrationDetails
else
not_found!
@@ -78,7 +79,6 @@ module API
post '/request' do
authenticate_runner!
no_content! unless current_runner.active?
- update_runner_info
if current_runner.runner_queue_value_latest?(params[:last_update])
header 'X-GitLab-Last-Update', params[:last_update]
@@ -186,7 +186,7 @@ module API
status 200
content_type Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE
- Gitlab::Workhorse.artifact_upload_ok
+ JobArtifactUploader.workhorse_authorize
end
desc 'Upload artifacts for job' do
@@ -201,12 +201,15 @@ module API
requires :id, type: Integer, desc: %q(Job's ID)
optional :token, type: String, desc: %q(Job's authentication token)
optional :expire_in, type: String, desc: %q(Specify when artifacts should expire)
- optional :file, type: File, desc: %q(Artifact's file)
optional 'file.path', type: String, desc: %q(path to locally stored body (generated by Workhorse))
optional 'file.name', type: String, desc: %q(real filename as send in Content-Disposition (generated by Workhorse))
optional 'file.type', type: String, desc: %q(real content type as send in Content-Type (generated by Workhorse))
+ optional 'file.size', type: Integer, desc: %q(real size of file (generated by Workhorse))
+ optional 'file.sha256', type: String, desc: %q(sha256 checksum of the file (generated by Workhorse))
optional 'metadata.path', type: String, desc: %q(path to locally stored body (generated by Workhorse))
optional 'metadata.name', type: String, desc: %q(filename (generated by Workhorse))
+ optional 'metadata.size', type: Integer, desc: %q(real size of metadata (generated by Workhorse))
+ optional 'metadata.sha256', type: String, desc: %q(sha256 checksum of metadata (generated by Workhorse))
end
post '/:id/artifacts' do
not_allowed! unless Gitlab.config.artifacts.enabled
@@ -215,21 +218,34 @@ module API
job = authenticate_job!
forbidden!('Job is not running!') unless job.running?
- artifacts_upload_path = JobArtifactUploader.artifacts_upload_path
- artifacts = uploaded_file(:file, artifacts_upload_path)
- metadata = uploaded_file(:metadata, artifacts_upload_path)
+ artifacts = UploadedFile.from_params(params, :file, JobArtifactUploader.workhorse_local_upload_path)
+ metadata = UploadedFile.from_params(params, :metadata, JobArtifactUploader.workhorse_local_upload_path)
bad_request!('Missing artifacts file!') unless artifacts
file_to_large! unless artifacts.size < max_artifacts_size
+ bad_request!("Already uploaded") if job.job_artifacts_archive
+
expire_in = params['expire_in'] ||
Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
- job.build_job_artifacts_archive(project: job.project, file_type: :archive, file: artifacts, expire_in: expire_in)
- job.build_job_artifacts_metadata(project: job.project, file_type: :metadata, file: metadata, expire_in: expire_in) if metadata
- job.artifacts_expire_in = expire_in
+ job.build_job_artifacts_archive(
+ project: job.project,
+ file: artifacts,
+ file_type: :archive,
+ file_sha256: artifacts.sha256,
+ expire_in: expire_in)
+
+ if metadata
+ job.build_job_artifacts_metadata(
+ project: job.project,
+ file: metadata,
+ file_type: :metadata,
+ file_sha256: metadata.sha256,
+ expire_in: expire_in)
+ end
- if job.save
+ if job.update(artifacts_expire_in: expire_in)
present job, with: Entities::JobRequest::Response
else
render_validation_error!(job)
@@ -244,11 +260,12 @@ module API
params do
requires :id, type: Integer, desc: %q(Job's ID)
optional :token, type: String, desc: %q(Job's authentication token)
+ optional :direct_download, default: false, type: Boolean, desc: %q(Perform direct download from remote storage instead of proxying artifacts)
end
get '/:id/artifacts' do
job = authenticate_job!
- present_artifacts!(job.artifacts_file)
+ present_carrierwave_file!(job.artifacts_file, supports_direct_download: params[:direct_download])
end
end
end
diff --git a/lib/api/runners.rb b/lib/api/runners.rb
index 996457c5dfe..5f2a9567605 100644
--- a/lib/api/runners.rb
+++ b/lib/api/runners.rb
@@ -57,6 +57,7 @@ module API
optional :locked, type: Boolean, desc: 'Flag indicating the runner is locked'
optional :access_level, type: String, values: Ci::Runner.access_levels.keys,
desc: 'The access_level of the runner'
+ optional :maximum_timeout, type: Integer, desc: 'Maximum timeout set when this Runner will handle the job'
at_least_one_of :description, :active, :tag_list, :run_untagged, :locked, :access_level
end
put ':id' do
diff --git a/lib/api/search.rb b/lib/api/search.rb
new file mode 100644
index 00000000000..5d9ec617cb7
--- /dev/null
+++ b/lib/api/search.rb
@@ -0,0 +1,111 @@
+module API
+ class Search < Grape::API
+ include PaginationParams
+
+ before { authenticate! }
+
+ helpers do
+ SCOPE_ENTITY = {
+ merge_requests: Entities::MergeRequestBasic,
+ issues: Entities::IssueBasic,
+ projects: Entities::BasicProjectDetails,
+ milestones: Entities::Milestone,
+ notes: Entities::Note,
+ commits: Entities::CommitDetail,
+ blobs: Entities::Blob,
+ wiki_blobs: Entities::Blob,
+ snippet_titles: Entities::Snippet,
+ snippet_blobs: Entities::Snippet
+ }.freeze
+
+ def search(additional_params = {})
+ search_params = {
+ scope: params[:scope],
+ search: params[:search],
+ snippets: snippets?,
+ page: params[:page],
+ per_page: params[:per_page]
+ }.merge(additional_params)
+
+ results = SearchService.new(current_user, search_params).search_objects
+
+ process_results(results)
+ end
+
+ def process_results(results)
+ case params[:scope]
+ when 'wiki_blobs'
+ paginate(results).map { |blob| Gitlab::ProjectSearchResults.parse_search_result(blob, user_project) }
+ when 'blobs'
+ paginate(results).map { |blob| blob[1] }
+ else
+ paginate(results)
+ end
+ end
+
+ def snippets?
+ %w(snippet_blobs snippet_titles).include?(params[:scope]).to_s
+ end
+
+ def entity
+ SCOPE_ENTITY[params[:scope].to_sym]
+ end
+ end
+
+ resource :search do
+ desc 'Search on GitLab' do
+ detail 'This feature was introduced in GitLab 10.5.'
+ end
+ params do
+ requires :search, type: String, desc: 'The expression it should be searched for'
+ requires :scope,
+ type: String,
+ desc: 'The scope of search, available scopes:
+ projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs',
+ values: %w(projects issues merge_requests milestones snippet_titles snippet_blobs)
+ use :pagination
+ end
+ get do
+ present search, with: entity
+ end
+ end
+
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ desc 'Search on GitLab' do
+ detail 'This feature was introduced in GitLab 10.5.'
+ end
+ params do
+ requires :id, type: String, desc: 'The ID of a group'
+ requires :search, type: String, desc: 'The expression it should be searched for'
+ requires :scope,
+ type: String,
+ desc: 'The scope of search, available scopes:
+ projects, issues, merge_requests, milestones',
+ values: %w(projects issues merge_requests milestones)
+ use :pagination
+ end
+ get ':id/(-/)search' do
+ present search(group_id: user_group.id), with: entity
+ end
+ end
+
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ desc 'Search on GitLab' do
+ detail 'This feature was introduced in GitLab 10.5.'
+ end
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ requires :search, type: String, desc: 'The expression it should be searched for'
+ requires :scope,
+ type: String,
+ desc: 'The scope of search, available scopes:
+ issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs',
+ values: %w(issues merge_requests milestones notes wiki_blobs commits blobs)
+ use :pagination
+ end
+ get ':id/(-/)search' do
+ present search(project_id: user_project.id), with: entity
+ end
+ end
+ end
+end
diff --git a/lib/api/services.rb b/lib/api/services.rb
index bbcc851d07a..794fdab8f2b 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -1,5 +1,144 @@
+# frozen_string_literal: true
module API
class Services < Grape::API
+ CHAT_NOTIFICATION_SETTINGS = [
+ {
+ required: true,
+ name: :webhook,
+ type: String,
+ desc: 'The chat webhook'
+ },
+ {
+ required: false,
+ name: :username,
+ type: String,
+ desc: 'The chat username'
+ },
+ {
+ required: false,
+ name: :channel,
+ type: String,
+ desc: 'The default chat channel'
+ }
+ ].freeze
+
+ CHAT_NOTIFICATION_FLAGS = [
+ {
+ required: false,
+ name: :notify_only_broken_pipelines,
+ type: Boolean,
+ desc: 'Send notifications for broken pipelines'
+ },
+ {
+ required: false,
+ name: :notify_only_default_branch,
+ type: Boolean,
+ desc: 'Send notifications only for the default branch'
+ }
+ ].freeze
+
+ CHAT_NOTIFICATION_CHANNELS = [
+ {
+ required: false,
+ name: :push_channel,
+ type: String,
+ desc: 'The name of the channel to receive push_events notifications'
+ },
+ {
+ required: false,
+ name: :issue_channel,
+ type: String,
+ desc: 'The name of the channel to receive issues_events notifications'
+ },
+ {
+ required: false,
+ name: :confidential_issue_channel,
+ type: String,
+ desc: 'The name of the channel to receive confidential_issues_events notifications'
+ },
+ {
+ required: false,
+ name: :merge_request_channel,
+ type: String,
+ desc: 'The name of the channel to receive merge_requests_events notifications'
+ },
+ {
+ required: false,
+ name: :note_channel,
+ type: String,
+ desc: 'The name of the channel to receive note_events notifications'
+ },
+ {
+ required: false,
+ name: :tag_push_channel,
+ type: String,
+ desc: 'The name of the channel to receive tag_push_events notifications'
+ },
+ {
+ required: false,
+ name: :pipeline_channel,
+ type: String,
+ desc: 'The name of the channel to receive pipeline_events notifications'
+ },
+ {
+ required: false,
+ name: :wiki_page_channel,
+ type: String,
+ desc: 'The name of the channel to receive wiki_page_events notifications'
+ }
+ ].freeze
+
+ CHAT_NOTIFICATION_EVENTS = [
+ {
+ required: false,
+ name: :push_events,
+ type: Boolean,
+ desc: 'Enable notifications for push_events'
+ },
+ {
+ required: false,
+ name: :issues_events,
+ type: Boolean,
+ desc: 'Enable notifications for issues_events'
+ },
+ {
+ required: false,
+ name: :confidential_issues_events,
+ type: Boolean,
+ desc: 'Enable notifications for confidential_issues_events'
+ },
+ {
+ required: false,
+ name: :merge_requests_events,
+ type: Boolean,
+ desc: 'Enable notifications for merge_requests_events'
+ },
+ {
+ required: false,
+ name: :note_events,
+ type: Boolean,
+ desc: 'Enable notifications for note_events'
+ },
+ {
+ required: false,
+ name: :tag_push_events,
+ type: Boolean,
+ desc: 'Enable notifications for tag_push_events'
+ },
+ {
+ required: false,
+ name: :pipeline_events,
+ type: Boolean,
+ desc: 'Enable notifications for pipeline_events'
+ },
+ {
+ required: false,
+ name: :wiki_page_events,
+ type: Boolean,
+ desc: 'Enable notifications for wiki_page_events'
+ }
+ ].freeze
+
services = {
'asana' => [
{
@@ -489,25 +628,11 @@ module API
}
],
'slack' => [
- {
- required: true,
- name: :webhook,
- type: String,
- desc: 'The Slack webhook. e.g. https://hooks.slack.com/services/...'
- },
- {
- required: false,
- name: :new_issue_url,
- type: String,
- desc: 'The user name'
- },
- {
- required: false,
- name: :channel,
- type: String,
- desc: 'The channel name'
- }
- ],
+ CHAT_NOTIFICATION_SETTINGS,
+ CHAT_NOTIFICATION_FLAGS,
+ CHAT_NOTIFICATION_CHANNELS,
+ CHAT_NOTIFICATION_EVENTS
+ ].flatten,
'microsoft-teams' => [
{
required: true,
@@ -517,19 +642,11 @@ module API
}
],
'mattermost' => [
- {
- required: true,
- name: :webhook,
- type: String,
- desc: 'The Mattermost webhook. e.g. http://mattermost_host/hooks/...'
- },
- {
- required: false,
- name: :username,
- type: String,
- desc: 'The username to use to post the message'
- }
- ],
+ CHAT_NOTIFICATION_SETTINGS,
+ CHAT_NOTIFICATION_FLAGS,
+ CHAT_NOTIFICATION_CHANNELS,
+ CHAT_NOTIFICATION_EVENTS
+ ].flatten,
'teamcity' => [
{
required: true,
@@ -608,7 +725,22 @@ module API
]
end
- trigger_services = {
+ SERVICES = services.freeze
+ SERVICE_CLASSES = service_classes.freeze
+
+ SERVICE_CLASSES.each do |service|
+ event_names = service.try(:event_names) || next
+ event_names.each do |event_name|
+ SERVICES[service.to_param.tr("_", "-")] << {
+ required: false,
+ name: event_name.to_sym,
+ type: String,
+ desc: service.event_description(event_name)
+ }
+ end
+ end
+
+ TRIGGER_SERVICES = {
'mattermost-slash-commands' => [
{
name: :token,
@@ -640,22 +772,9 @@ module API
end
end
- services.each do |service_slug, settings|
+ SERVICES.each do |service_slug, settings|
desc "Set #{service_slug} service for project"
params do
- service_classes.each do |service|
- event_names = service.try(:event_names) || next
- event_names.each do |event_name|
- services[service.to_param.tr("_", "-")] << {
- required: false,
- name: event_name.to_sym,
- type: String,
- desc: ServicesHelper.service_event_description(event_name)
- }
- end
- end
- services.freeze
-
settings.each do |setting|
if setting[:required]
requires setting[:name], type: setting[:type], desc: setting[:desc]
@@ -669,7 +788,7 @@ module API
service_params = declared_params(include_missing: false).merge(active: true)
if service.update_attributes(service_params)
- present service, with: Entities::ProjectService, include_passwords: current_user.admin?
+ present service, with: Entities::ProjectService
else
render_api_error!('400 Bad Request', 400)
end
@@ -678,7 +797,7 @@ module API
desc "Delete a service for project"
params do
- requires :service_slug, type: String, values: services.keys, desc: 'The name of the service'
+ requires :service_slug, type: String, values: SERVICES.keys, desc: 'The name of the service'
end
delete ":id/services/:service_slug" do
service = user_project.find_or_initialize_service(params[:service_slug].underscore)
@@ -698,7 +817,7 @@ module API
success Entities::ProjectService
end
params do
- requires :service_slug, type: String, values: services.keys, desc: 'The name of the service'
+ requires :service_slug, type: String, values: SERVICES.keys, desc: 'The name of the service'
end
get ":id/services/:service_slug" do
service = user_project.find_or_initialize_service(params[:service_slug].underscore)
@@ -706,7 +825,7 @@ module API
end
end
- trigger_services.each do |service_slug, settings|
+ TRIGGER_SERVICES.each do |service_slug, settings|
helpers do
def slash_command_service(project, service_slug, params)
project.services.active.where(template: false).find do |service|
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index cee4d309816..152df23a327 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -147,7 +147,7 @@ module API
attrs[:password_authentication_enabled_for_web] = attrs.delete(:password_authentication_enabled)
end
- if current_settings.update_attributes(attrs)
+ if ApplicationSettings::UpdateService.new(current_settings, current_user, attrs).execute
present current_settings, with: Entities::ApplicationSetting
else
render_validation_error!(current_settings)
diff --git a/lib/api/system_hooks.rb b/lib/api/system_hooks.rb
index 6b6a03e3300..c7a460df46a 100644
--- a/lib/api/system_hooks.rb
+++ b/lib/api/system_hooks.rb
@@ -26,6 +26,7 @@ module API
optional :token, type: String, desc: 'The token used to validate payloads'
optional :push_events, type: Boolean, desc: "Trigger hook on push events"
optional :tag_push_events, type: Boolean, desc: "Trigger hook on tag push events"
+ optional :merge_requests_events, type: Boolean, desc: "Trigger hook on tag push events"
optional :enable_ssl_verification, type: Boolean, desc: "Do SSL verification when triggering the hook"
end
post do
diff --git a/lib/api/templates.rb b/lib/api/templates.rb
index 6550b331fb8..41862768a3f 100644
--- a/lib/api/templates.rb
+++ b/lib/api/templates.rb
@@ -17,15 +17,15 @@ module API
}
}.freeze
PROJECT_TEMPLATE_REGEX =
- /[\<\{\[]
+ %r{[\<\{\[]
(project|description|
one\sline\s.+\swhat\sit\sdoes\.) # matching the start and end is enough here
- [\>\}\]]/xi.freeze
+ [\>\}\]]}xi.freeze
YEAR_TEMPLATE_REGEX = /[<{\[](year|yyyy)[>}\]]/i.freeze
FULLNAME_TEMPLATE_REGEX =
- /[\<\{\[]
+ %r{[\<\{\[]
(fullname|name\sof\s(author|copyright\sowner))
- [\>\}\]]/xi.freeze
+ [\>\}\]]}xi.freeze
helpers do
def parsed_license_template
diff --git a/lib/api/todos.rb b/lib/api/todos.rb
index ffccfebe752..c6dbcf84e3a 100644
--- a/lib/api/todos.rb
+++ b/lib/api/todos.rb
@@ -60,7 +60,7 @@ module API
end
post ':id/mark_as_done' do
TodoService.new.mark_todos_as_done_by_ids(params[:id], current_user)
- todo = Todo.find(params[:id])
+ todo = current_user.todos.find(params[:id])
present todo, with: Entities::Todo, current_user: current_user
end
diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb
index dd6801664b1..b3709455bc3 100644
--- a/lib/api/triggers.rb
+++ b/lib/api/triggers.rb
@@ -15,6 +15,8 @@ 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
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42283')
+
# validate variables
params[:variables] = params[:variables].to_h
unless params[:variables].all? { |key, value| key.is_a?(String) && value.is_a?(String) }
diff --git a/lib/api/users.rb b/lib/api/users.rb
index e5de31ad51b..3920171205f 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -2,6 +2,7 @@ module API
class Users < Grape::API
include PaginationParams
include APIGuard
+ include Helpers::CustomAttributes
allow_access_with_scope :read_user, if: -> (request) { request.get? }
@@ -18,6 +19,14 @@ module API
User.find_by(id: id) || not_found!('User')
end
+ def reorder_users(users)
+ if params[:order_by] && params[:sort]
+ users.reorder(params[:order_by] => params[:sort])
+ else
+ users
+ end
+ end
+
params :optional_attributes do
optional :skype, type: String, desc: 'The Skype username'
optional :linkedin, type: String, desc: 'The LinkedIn username'
@@ -35,6 +44,13 @@ module API
optional :avatar, type: File, desc: 'Avatar image for user'
all_or_none_of :extern_uid, :provider
end
+
+ params :sort_params do
+ optional :order_by, type: String, values: %w[id name username created_at updated_at],
+ default: 'id', desc: 'Return users ordered by a field'
+ optional :sort, type: String, values: %w[asc desc], default: 'desc',
+ desc: 'Return users sorted in ascending and descending order'
+ end
end
desc 'Get the list of users' do
@@ -53,16 +69,19 @@ module API
optional :created_before, type: DateTime, desc: 'Return users created before the specified time'
all_or_none_of :extern_uid, :provider
+ use :sort_params
use :pagination
+ use :with_custom_attributes
end
get do
authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?)
unless current_user&.admin?
- params.except!(:created_after, :created_before)
+ params.except!(:created_after, :created_before, :order_by, :sort)
end
users = UsersFinder.new(current_user, params).execute
+ users = reorder_users(users)
authorized = can?(current_user, :read_users_list)
@@ -77,8 +96,9 @@ module API
entity = current_user&.admin? ? Entities::UserWithAdmin : Entities::UserBasic
users = users.preload(:identities, :u2f_registrations) if entity == Entities::UserWithAdmin
+ users, options = with_custom_attributes(users, with: entity)
- present paginate(users), with: entity
+ present paginate(users), options
end
desc 'Get a single user' do
@@ -86,12 +106,16 @@ module API
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
+
+ use :with_custom_attributes
end
get ":id" do
user = User.find_by(id: params[:id])
not_found!('User') unless user && can?(current_user, :read_user, user)
opts = current_user&.admin? ? { with: Entities::UserWithAdmin } : { with: Entities::User }
+ user, opts = with_custom_attributes(user, opts)
+
present user, opts
end
@@ -383,6 +407,8 @@ module API
optional :hard_delete, type: Boolean, desc: "Whether to remove a user's contributions"
end
delete ":id" do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42279')
+
authenticated_as_admin!
user = User.find_by(id: params[:id])
diff --git a/lib/api/v3/branches.rb b/lib/api/v3/branches.rb
index b201bf77667..25176c5b38e 100644
--- a/lib/api/v3/branches.rb
+++ b/lib/api/v3/branches.rb
@@ -14,6 +14,8 @@ module API
success ::API::Entities::Branch
end
get ":id/repository/branches" do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42276')
+
repository = user_project.repository
branches = repository.branches.sort_by(&:name)
merged_branch_names = repository.merged_branch_names(branches.map(&:name))
diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb
index fa0bef39602..683b9c993cb 100644
--- a/lib/api/v3/builds.rb
+++ b/lib/api/v3/builds.rb
@@ -36,6 +36,7 @@ module API
builds = user_project.builds.order('id DESC')
builds = filter_builds(builds, params[:scope])
+ builds = builds.preload(:user, :job_artifacts_archive, :runner, pipeline: :project)
present paginate(builds), with: ::API::V3::Entities::Build
end
@@ -84,7 +85,7 @@ module API
build = get_build!(params[:build_id])
- present_artifacts!(build.artifacts_file)
+ present_carrierwave_file!(build.artifacts_file)
end
desc 'Download the artifacts file from build' do
@@ -101,10 +102,10 @@ module API
builds = user_project.latest_successful_builds_for(params[:ref_name])
latest_build = builds.find_by!(name: params[:job])
- present_artifacts!(latest_build.artifacts_file)
+ present_carrierwave_file!(latest_build.artifacts_file)
end
- # TODO: We should use `present_file!` and leave this implementation for backward compatibility (when build trace
+ # TODO: We should use `present_disk_file!` and leave this implementation for backward compatibility (when build trace
# is saved in the DB instead of file). But before that, we need to consider how to replace the value of
# `runners_token` with some mask (like `xxxxxx`) when sending trace file directly by workhorse.
desc 'Get a trace of a specific build of a project'
diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb
index 0ef26aa696a..4f6ea8f502e 100644
--- a/lib/api/v3/commits.rb
+++ b/lib/api/v3/commits.rb
@@ -71,13 +71,14 @@ module API
end
params do
requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag'
+ optional :stats, type: Boolean, default: true, desc: 'Include commit stats'
end
get ":id/repository/commits/:sha", requirements: API::COMMIT_ENDPOINT_REQUIREMENTS do
commit = user_project.commit(params[:sha])
not_found! "Commit" unless commit
- present commit, with: ::API::Entities::CommitDetail
+ present commit, with: ::API::Entities::CommitDetail, stats: params[:stats]
end
desc 'Get the diff for a specific commit of a project' do
diff --git a/lib/api/v3/deploy_keys.rb b/lib/api/v3/deploy_keys.rb
index b90e2061da3..47e54ca85a5 100644
--- a/lib/api/v3/deploy_keys.rb
+++ b/lib/api/v3/deploy_keys.rb
@@ -3,6 +3,16 @@ module API
class DeployKeys < Grape::API
before { authenticate! }
+ helpers do
+ def add_deploy_keys_project(project, attrs = {})
+ project.deploy_keys_projects.create(attrs)
+ end
+
+ def find_by_deploy_key(project, key_id)
+ project.deploy_keys_projects.find_by!(deploy_key: key_id)
+ end
+ end
+
get "deploy_keys" do
authenticated_as_admin!
@@ -18,25 +28,28 @@ module API
%w(keys deploy_keys).each do |path|
desc "Get a specific project's deploy keys" do
- success ::API::Entities::SSHKey
+ success ::API::Entities::DeployKeysProject
end
get ":id/#{path}" do
- present user_project.deploy_keys, with: ::API::Entities::SSHKey
+ keys = user_project.deploy_keys_projects.preload(:deploy_key)
+
+ present keys, with: ::API::Entities::DeployKeysProject
end
desc 'Get single deploy key' do
- success ::API::Entities::SSHKey
+ success ::API::Entities::DeployKeysProject
end
params do
requires :key_id, type: Integer, desc: 'The ID of the deploy key'
end
get ":id/#{path}/:key_id" do
- key = user_project.deploy_keys.find params[:key_id]
- present key, with: ::API::Entities::SSHKey
+ key = find_by_deploy_key(user_project, params[:key_id])
+
+ present key, with: ::API::Entities::DeployKeysProject
end
desc 'Add new deploy key to currently authenticated user' do
- success ::API::Entities::SSHKey
+ success ::API::Entities::DeployKeysProject
end
params do
requires :key, type: String, desc: 'The new deploy key'
@@ -47,24 +60,31 @@ module API
params[:key].strip!
# Check for an existing key joined to this project
- key = user_project.deploy_keys.find_by(key: params[:key])
+ key = user_project.deploy_keys_projects
+ .joins(:deploy_key)
+ .find_by(keys: { key: params[:key] })
+
if key
- present key, with: ::API::Entities::SSHKey
+ present key, with: ::API::Entities::DeployKeysProject
break
end
# Check for available deploy keys in other projects
key = current_user.accessible_deploy_keys.find_by(key: params[:key])
if key
- user_project.deploy_keys << key
- present key, with: ::API::Entities::SSHKey
+ added_key = add_deploy_keys_project(user_project, deploy_key: key, can_push: !!params[:can_push])
+
+ present added_key, with: ::API::Entities::DeployKeysProject
break
end
# Create a new deploy key
- key = DeployKey.new(declared_params(include_missing: false))
- if key.valid? && user_project.deploy_keys << key
- present key, with: ::API::Entities::SSHKey
+ key_attributes = { can_push: !!params[:can_push],
+ deploy_key_attributes: declared_params.except(:can_push) }
+ key = add_deploy_keys_project(user_project, key_attributes)
+
+ if key.valid?
+ present key, with: ::API::Entities::DeployKeysProject
else
render_validation_error!(key)
end
diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb
index c17b6f45ed8..68b4d7c3982 100644
--- a/lib/api/v3/entities.rb
+++ b/lib/api/v3/entities.rb
@@ -207,7 +207,7 @@ module API
end
class Trigger < Grape::Entity
- expose :token, :created_at, :updated_at, :deleted_at, :last_used
+ expose :token, :created_at, :updated_at, :last_used
expose :owner, using: ::API::Entities::UserBasic
end
@@ -252,21 +252,20 @@ 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, :pipeline_events
+ expose :push_events, :issues_events, :confidential_issues_events
+ expose :merge_requests_events, :tag_push_events, :note_events
+ expose :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] }
- service.properties.slice(*field_names)
+ service.properties.slice(*service.api_field_names)
end
end
class ProjectHook < ::API::Entities::Hook
- expose :project_id, :issues_events, :merge_requests_events
- expose :note_events, :pipeline_events, :wiki_page_events
+ expose :project_id, :issues_events, :confidential_issues_events
+ expose :merge_requests_events, :note_events, :pipeline_events
+ expose :wiki_page_events
expose :job_events, as: :build_events
end
diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb
index cb371fdbab8..b59947d81d9 100644
--- a/lib/api/v3/issues.rb
+++ b/lib/api/v3/issues.rb
@@ -134,6 +134,8 @@ module API
use :issue_params
end
post ':id/issues' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42131')
+
# Setting created_at time only allowed for admins and project owners
unless current_user.admin? || user_project.owner == current_user
params.delete(:created_at)
@@ -169,6 +171,8 @@ module API
:labels, :created_at, :due_date, :confidential, :state_event
end
put ':id/issues/:issue_id' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42132')
+
issue = user_project.issues.find(params.delete(:issue_id))
authorize! :update_issue, issue
@@ -201,6 +205,8 @@ module API
requires :to_project_id, type: Integer, desc: 'The ID of the new project'
end
post ':id/issues/:issue_id/move' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42133')
+
issue = user_project.issues.find_by(id: params[:issue_id])
not_found!('Issue') unless issue
diff --git a/lib/api/v3/labels.rb b/lib/api/v3/labels.rb
index bd5eb2175e8..4157462ec2a 100644
--- a/lib/api/v3/labels.rb
+++ b/lib/api/v3/labels.rb
@@ -11,7 +11,7 @@ module API
success ::API::Entities::Label
end
get ':id/labels' do
- present available_labels, with: ::API::Entities::Label, current_user: current_user, project: user_project
+ present available_labels_for(user_project), with: ::API::Entities::Label, current_user: current_user, project: user_project
end
desc 'Delete an existing label' do
diff --git a/lib/api/v3/members.rb b/lib/api/v3/members.rb
index 684860b553e..88dd598f1e9 100644
--- a/lib/api/v3/members.rb
+++ b/lib/api/v3/members.rb
@@ -22,10 +22,11 @@ module API
get ":id/members" do
source = find_source(source_type, params[:id])
- users = source.users
- users = users.merge(User.search(params[:query])) if params[:query]
+ members = source.members.where.not(user_id: nil).includes(:user)
+ members = members.joins(:user).merge(User.search(params[:query])) if params[:query].present?
+ members = paginate(members)
- present paginate(users), with: ::API::Entities::Member, source: source
+ present members, with: ::API::Entities::Member
end
desc 'Gets a member of a group or project.' do
@@ -40,7 +41,7 @@ module API
members = source.members
member = members.find_by!(user_id: params[:user_id])
- present member.user, with: ::API::Entities::Member, member: member
+ present member, with: ::API::Entities::Member
end
desc 'Adds a member to a group or project.' do
@@ -67,8 +68,9 @@ module API
unless member
member = source.add_user(params[:user_id], params[:access_level], current_user: current_user, expires_at: params[:expires_at])
end
+
if member.persisted? && member.valid?
- present member.user, with: ::API::Entities::Member, member: member
+ present member, with: ::API::Entities::Member
else
# This is to ensure back-compatibility but 400 behavior should be used
# for all validation errors in 9.0!
@@ -92,7 +94,7 @@ module API
member = source.members.find_by!(user_id: params.delete(:user_id))
if member.update_attributes(declared_params(include_missing: false))
- present member.user, with: ::API::Entities::Member, member: member
+ present member, with: ::API::Entities::Member
else
# This is to ensure back-compatibility but 400 behavior should be used
# for all validation errors in 9.0!
@@ -122,9 +124,9 @@ module API
status(200 )
{ message: "Access revoked", id: params[:user_id].to_i }
else
- ::Members::DestroyService.new(source, current_user, declared_params).execute
+ ::Members::DestroyService.new(current_user).execute(member)
- present member.user, with: ::API::Entities::Member, member: member
+ present member, with: ::API::Entities::Member
end
end
end
diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb
index 1d6d823f32b..ce216497996 100644
--- a/lib/api/v3/merge_requests.rb
+++ b/lib/api/v3/merge_requests.rb
@@ -91,6 +91,8 @@ module API
use :optional_params
end
post ":id/merge_requests" do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42126')
+
authorize! :create_merge_request, user_project
mr_params = declared_params(include_missing: false)
@@ -126,6 +128,7 @@ module API
if status == :deprecated
detail DEPRECATION_MESSAGE
end
+
success ::API::V3::Entities::MergeRequest
end
get path do
@@ -166,6 +169,8 @@ module API
:remove_source_branch
end
put path do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42127')
+
merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request)
mr_params = declared_params(include_missing: false)
diff --git a/lib/api/v3/pipelines.rb b/lib/api/v3/pipelines.rb
index c48cbd2b765..6d31c12f572 100644
--- a/lib/api/v3/pipelines.rb
+++ b/lib/api/v3/pipelines.rb
@@ -19,6 +19,8 @@ module API
desc: 'Either running, branches, or tags'
end
get ':id/pipelines' do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42123')
+
authorize! :read_pipeline, user_project
pipelines = PipelinesFinder.new(user_project, scope: params[:scope]).execute
diff --git a/lib/api/v3/project_hooks.rb b/lib/api/v3/project_hooks.rb
index 51014591a93..631944150c7 100644
--- a/lib/api/v3/project_hooks.rb
+++ b/lib/api/v3/project_hooks.rb
@@ -11,6 +11,7 @@ module API
requires :url, type: String, desc: "The URL to send the request to"
optional :push_events, type: Boolean, desc: "Trigger hook on push events"
optional :issues_events, type: Boolean, desc: "Trigger hook on issues events"
+ optional :confidential_issues_events, type: Boolean, desc: "Trigger hook on confidential issues events"
optional :merge_requests_events, type: Boolean, desc: "Trigger hook on merge request events"
optional :tag_push_events, type: Boolean, desc: "Trigger hook on tag push events"
optional :note_events, type: Boolean, desc: "Trigger hook on note(comment) events"
diff --git a/lib/api/v3/project_snippets.rb b/lib/api/v3/project_snippets.rb
index c41fee32610..6ba425ba8c7 100644
--- a/lib/api/v3/project_snippets.rb
+++ b/lib/api/v3/project_snippets.rb
@@ -14,6 +14,7 @@ module API
if errors[:project_access].any?
error!(errors[:project_access], 422)
end
+
not_found!
end
diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb
index 7c260b8d910..a2df969d819 100644
--- a/lib/api/v3/projects.rb
+++ b/lib/api/v3/projects.rb
@@ -41,6 +41,7 @@ module API
# private or internal, use the more conservative option, private.
attrs[:visibility_level] = (publik == true) ? Gitlab::VisibilityLevel::PUBLIC : Gitlab::VisibilityLevel::PRIVATE
end
+
attrs
end
@@ -172,9 +173,9 @@ module API
use :sort_params
use :pagination
end
- get "/search/:query", requirements: { query: /[^\/]+/ } do
- search_service = Search::GlobalService.new(current_user, search: params[:query]).execute
- projects = search_service.objects('projects', params[:page])
+ get "/search/:query", requirements: { query: %r{[^/]+} } do
+ search_service = ::Search::GlobalService.new(current_user, search: params[:query]).execute
+ projects = search_service.objects('projects', params[:page], false)
projects = projects.reorder(params[:order_by] => params[:sort])
present paginate(projects), with: ::API::V3::Entities::Project
@@ -201,6 +202,7 @@ module API
if project.errors[:limit_reached].present?
error!(project.errors[:limit_reached], 403)
end
+
render_validation_error!(project)
end
end
@@ -266,11 +268,7 @@ module API
namespace_id = fork_params[:namespace]
if namespace_id.present?
- fork_params[:namespace] = if namespace_id =~ /^\d+$/
- Namespace.find_by(id: namespace_id)
- else
- Namespace.find_by_path_or_name(namespace_id)
- end
+ fork_params[:namespace] = find_namespace(namespace_id)
unless fork_params[:namespace] && can?(current_user, :create_projects, fork_params[:namespace])
not_found!('Target Namespace')
diff --git a/lib/api/v3/repositories.rb b/lib/api/v3/repositories.rb
index f9a47101e27..f701d64e886 100644
--- a/lib/api/v3/repositories.rb
+++ b/lib/api/v3/repositories.rb
@@ -14,6 +14,7 @@ module API
if errors[:project_access].any?
error!(errors[:project_access], 422)
end
+
not_found!
end
end
@@ -74,7 +75,7 @@ module API
end
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]
+ send_git_archive user_project.repository, ref: params[:sha], format: params[:format], append_sha: true
rescue
not_found!('File')
end
diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb
index 44ed94d2869..20ca1021c71 100644
--- a/lib/api/v3/services.rb
+++ b/lib/api/v3/services.rb
@@ -622,7 +622,7 @@ module API
end
get ":id/services/:service_slug" do
service = user_project.find_or_initialize_service(params[:service_slug].underscore)
- present service, with: Entities::ProjectService, include_passwords: current_user.admin?
+ present service, with: Entities::ProjectService
end
end
diff --git a/lib/api/v3/snippets.rb b/lib/api/v3/snippets.rb
index 126ec72248e..85613c8ed84 100644
--- a/lib/api/v3/snippets.rb
+++ b/lib/api/v3/snippets.rb
@@ -97,6 +97,7 @@ module API
attrs = declared_params(include_missing: false)
UpdateSnippetService.new(nil, current_user, snippet, attrs).execute
+
if snippet.persisted?
present snippet, with: ::API::Entities::PersonalSnippet
else
diff --git a/lib/api/v3/templates.rb b/lib/api/v3/templates.rb
index 7298203df10..b82b02b5f49 100644
--- a/lib/api/v3/templates.rb
+++ b/lib/api/v3/templates.rb
@@ -16,15 +16,15 @@ module API
}
}.freeze
PROJECT_TEMPLATE_REGEX =
- /[\<\{\[]
+ %r{[\<\{\[]
(project|description|
one\sline\s.+\swhat\sit\sdoes\.) # matching the start and end is enough here
- [\>\}\]]/xi.freeze
+ [\>\}\]]}xi.freeze
YEAR_TEMPLATE_REGEX = /[<{\[](year|yyyy)[>}\]]/i.freeze
FULLNAME_TEMPLATE_REGEX =
- /[\<\{\[]
+ %r{[\<\{\[]
(fullname|name\sof\s(author|copyright\sowner))
- [\>\}\]]/xi.freeze
+ [\>\}\]]}xi.freeze
DEPRECATION_MESSAGE = ' This endpoint is deprecated and has been removed in V4.'.freeze
helpers do
diff --git a/lib/api/v3/todos.rb b/lib/api/v3/todos.rb
index 2f2cf259987..3e2c61f6dbd 100644
--- a/lib/api/v3/todos.rb
+++ b/lib/api/v3/todos.rb
@@ -12,7 +12,7 @@ module API
end
delete ':id' do
TodoService.new.mark_todos_as_done_by_ids(params[:id], current_user)
- todo = Todo.find(params[:id])
+ todo = current_user.todos.find(params[:id])
present todo, with: ::API::Entities::Todo, current_user: current_user
end
diff --git a/lib/api/v3/triggers.rb b/lib/api/v3/triggers.rb
index 534911fde5c..34f07dfb486 100644
--- a/lib/api/v3/triggers.rb
+++ b/lib/api/v3/triggers.rb
@@ -16,6 +16,8 @@ module API
optional :variables, type: Hash, desc: 'The list of variables to be injected into build'
end
post ":id/(ref/:ref/)trigger/builds", requirements: { ref: /.+/ } do
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42121')
+
# validate variables
params[:variables] = params[:variables].to_h
unless params[:variables].all? { |key, value| key.is_a?(String) && value.is_a?(String) }
diff --git a/lib/backup/artifacts.rb b/lib/backup/artifacts.rb
index 7a582a20056..6a5a223a614 100644
--- a/lib/backup/artifacts.rb
+++ b/lib/backup/artifacts.rb
@@ -3,11 +3,7 @@ require 'backup/files'
module Backup
class Artifacts < Files
def initialize
- super('artifacts', LegacyArtifactUploader.local_store_path)
- end
-
- def create_files_dir
- Dir.mkdir(app_files_dir, 0700)
+ super('artifacts', JobArtifactUploader.root)
end
end
end
diff --git a/lib/backup/builds.rb b/lib/backup/builds.rb
index 635967f4bd4..f869916e199 100644
--- a/lib/backup/builds.rb
+++ b/lib/backup/builds.rb
@@ -5,9 +5,5 @@ module Backup
def initialize
super('builds', Settings.gitlab_ci.builds_path)
end
-
- def create_files_dir
- Dir.mkdir(app_files_dir, 0700)
- end
end
end
diff --git a/lib/backup/database.rb b/lib/backup/database.rb
index d97e5d98229..5e6828de597 100644
--- a/lib/backup/database.rb
+++ b/lib/backup/database.rb
@@ -31,6 +31,7 @@ module Backup
pgsql_args << "-n"
pgsql_args << Gitlab.config.backup.pg_schema
end
+
spawn('pg_dump', *pgsql_args, config['database'], out: compress_wr)
end
compress_wr.close
diff --git a/lib/backup/files.rb b/lib/backup/files.rb
index 30a91647b77..88cb7e7b5a4 100644
--- a/lib/backup/files.rb
+++ b/lib/backup/files.rb
@@ -1,7 +1,10 @@
require 'open3'
+require_relative 'helper'
module Backup
class Files
+ include Backup::Helper
+
attr_reader :name, :app_files_dir, :backup_tarball, :files_parent_dir
def initialize(name, app_files_dir)
@@ -18,7 +21,7 @@ module Backup
FileUtils.rm_f(backup_tarball)
if ENV['STRATEGY'] == 'copy'
- cmd = %W(cp -a #{app_files_dir} #{Gitlab.config.backup.path})
+ cmd = %W(rsync -a --exclude=lost+found #{app_files_dir} #{Gitlab.config.backup.path})
output, status = Gitlab::Popen.popen(cmd)
unless status.zero?
@@ -26,24 +29,31 @@ module Backup
abort 'Backup failed'
end
- run_pipeline!([%W(tar -C #{@backup_files_dir} -cf - .), %w(gzip -c -1)], out: [backup_tarball, 'w', 0600])
+ run_pipeline!([%W(tar --exclude=lost+found -C #{@backup_files_dir} -cf - .), %w(gzip -c -1)], out: [backup_tarball, 'w', 0600])
FileUtils.rm_rf(@backup_files_dir)
else
- run_pipeline!([%W(tar -C #{app_files_dir} -cf - .), %w(gzip -c -1)], out: [backup_tarball, 'w', 0600])
+ run_pipeline!([%W(tar --exclude=lost+found -C #{app_files_dir} -cf - .), %w(gzip -c -1)], out: [backup_tarball, 'w', 0600])
end
end
def restore
backup_existing_files_dir
- create_files_dir
- run_pipeline!([%w(gzip -cd), %W(tar -C #{app_files_dir} -xf -)], in: backup_tarball)
+ run_pipeline!([%w(gzip -cd), %W(tar --unlink-first --recursive-unlink -C #{app_files_dir} -xf -)], in: backup_tarball)
end
def backup_existing_files_dir
- timestamped_files_path = File.join(files_parent_dir, "#{name}.#{Time.now.to_i}")
+ timestamped_files_path = File.join(Gitlab.config.backup.path, "tmp", "#{name}.#{Time.now.to_i}")
if File.exist?(app_files_dir)
- FileUtils.mv(app_files_dir, File.expand_path(timestamped_files_path))
+ # Move all files in the existing repos directory except . and .. to
+ # repositories.old.<timestamp> directory
+ FileUtils.mkdir_p(timestamped_files_path, mode: 0700)
+ files = Dir.glob(File.join(app_files_dir, "*"), File::FNM_DOTMATCH) - [File.join(app_files_dir, "."), File.join(app_files_dir, "..")]
+ begin
+ FileUtils.mv(files, timestamped_files_path)
+ rescue Errno::EACCES
+ access_denied_error(app_files_dir)
+ end
end
end
diff --git a/lib/backup/helper.rb b/lib/backup/helper.rb
new file mode 100644
index 00000000000..a1ee0faefe9
--- /dev/null
+++ b/lib/backup/helper.rb
@@ -0,0 +1,17 @@
+module Backup
+ module Helper
+ def access_denied_error(path)
+ message = <<~EOS
+
+ ### NOTICE ###
+ As part of restore, the task tried to move existing content from #{path}.
+ However, it seems that directory contains files/folders that are not owned
+ by the user #{Gitlab.config.gitlab.user}. To proceed, please move the files
+ or folders inside #{path} to a secure location so that #{path} is empty and
+ run restore task again.
+
+ EOS
+ raise message
+ end
+ end
+end
diff --git a/lib/backup/lfs.rb b/lib/backup/lfs.rb
index 4153467fbee..4e234e50a7a 100644
--- a/lib/backup/lfs.rb
+++ b/lib/backup/lfs.rb
@@ -5,9 +5,5 @@ module Backup
def initialize
super('lfs', Settings.lfs.storage_path)
end
-
- def create_files_dir
- Dir.mkdir(app_files_dir, 0700)
- end
end
end
diff --git a/lib/backup/manager.rb b/lib/backup/manager.rb
index 05aa79dc160..f27ce4d2b2b 100644
--- a/lib/backup/manager.rb
+++ b/lib/backup/manager.rb
@@ -108,7 +108,10 @@ module Backup
$progress.puts "Please make sure that file name ends with #{FILE_NAME_SUFFIX}"
exit 1
elsif backup_file_list.many? && ENV["BACKUP"].nil?
- $progress.puts 'Found more than one backup, please specify which one you want to restore:'
+ $progress.puts 'Found more than one backup:'
+ # print list of available backups
+ $progress.puts " " + available_timestamps.join("\n ")
+ $progress.puts 'Please specify which one you want to restore:'
$progress.puts 'rake gitlab:backup:restore BACKUP=timestamp_of_backup'
exit 1
end
@@ -169,6 +172,10 @@ module Backup
@backup_file_list ||= Dir.glob("*#{FILE_NAME_SUFFIX}")
end
+ def available_timestamps
+ @backup_file_list.map {|item| item.gsub("#{FILE_NAME_SUFFIX}", "")}
+ end
+
def connect_to_remote_directory(connection_settings)
# our settings use string keys, but Fog expects symbols
connection = ::Fog::Storage.new(connection_settings.symbolize_keys)
diff --git a/lib/backup/pages.rb b/lib/backup/pages.rb
index 215ded93bfe..5830b209d6e 100644
--- a/lib/backup/pages.rb
+++ b/lib/backup/pages.rb
@@ -5,9 +5,5 @@ module Backup
def initialize
super('pages', Gitlab.config.pages.path)
end
-
- def create_files_dir
- Dir.mkdir(app_files_dir, 0700)
- end
end
end
diff --git a/lib/backup/registry.rb b/lib/backup/registry.rb
index 67fe0231087..91698669402 100644
--- a/lib/backup/registry.rb
+++ b/lib/backup/registry.rb
@@ -5,9 +5,5 @@ module Backup
def initialize
super('registry', Settings.registry.path)
end
-
- def create_files_dir
- Dir.mkdir(app_files_dir, 0700)
- end
end
end
diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb
index 2a04c03919d..89e3f1d9076 100644
--- a/lib/backup/repository.rb
+++ b/lib/backup/repository.rb
@@ -1,8 +1,11 @@
require 'yaml'
+require_relative 'helper'
module Backup
class Repository
+ include Backup::Helper
# rubocop:disable Metrics/AbcSize
+
def dump
prepare
@@ -47,6 +50,7 @@ module Backup
if File.exist?(path_to_wiki_repo)
progress.print " * #{display_repo_path(wiki)} ... "
+
if empty_repo?(wiki)
progress.puts " [SKIPPED]".color(:cyan)
else
@@ -62,18 +66,27 @@ module Backup
end
end
- def restore
+ def prepare_directories
Gitlab.config.repositories.storages.each do |name, repository_storage|
- path = repository_storage['path']
+ path = repository_storage.legacy_disk_path
next unless File.exist?(path)
- # Move repos dir to 'repositories.old' dir
- bk_repos_path = File.join(path, '..', 'repositories.old.' + Time.now.to_i.to_s)
- FileUtils.mv(path, bk_repos_path)
- # This is expected from gitlab:check
- FileUtils.mkdir_p(path, mode: 02770)
+ # Move all files in the existing repos directory except . and .. to
+ # repositories.old.<timestamp> directory
+ bk_repos_path = File.join(Gitlab.config.backup.path, "tmp", "#{name}-repositories.old." + Time.now.to_i.to_s)
+ FileUtils.mkdir_p(bk_repos_path, mode: 0700)
+ files = Dir.glob(File.join(path, "*"), File::FNM_DOTMATCH) - [File.join(path, "."), File.join(path, "..")]
+
+ begin
+ FileUtils.mv(files, bk_repos_path)
+ rescue Errno::EACCES
+ access_denied_error(path)
+ end
end
+ end
+ def restore
+ prepare_directories
Project.find_each(batch_size: 1000) do |project|
progress.print " * #{display_repo_path(project)} ... "
path_to_project_repo = path_to_repo(project)
@@ -199,7 +212,7 @@ module Backup
end
def repository_storage_paths_args
- Gitlab.config.repositories.storages.values.map { |rs| rs['path'] }
+ Gitlab.config.repositories.storages.values.map { |rs| rs.legacy_disk_path }
end
def progress
diff --git a/lib/backup/uploads.rb b/lib/backup/uploads.rb
index 35118375499..d46e2cd869d 100644
--- a/lib/backup/uploads.rb
+++ b/lib/backup/uploads.rb
@@ -5,9 +5,5 @@ module Backup
def initialize
super('uploads', Rails.root.join('public/uploads'))
end
-
- def create_files_dir
- Dir.mkdir(app_files_dir)
- end
end
end
diff --git a/lib/banzai/color_parser.rb b/lib/banzai/color_parser.rb
new file mode 100644
index 00000000000..355c364b07b
--- /dev/null
+++ b/lib/banzai/color_parser.rb
@@ -0,0 +1,44 @@
+module Banzai
+ module ColorParser
+ ALPHA = /0(?:\.\d+)?|\.\d+|1(?:\.0+)?/ # 0.0..1.0
+ PERCENTS = /(?:\d{1,2}|100)%/ # 00%..100%
+ ALPHA_CHANNEL = /(?:,\s*(?:#{ALPHA}|#{PERCENTS}))?/
+ BITS = /\d{1,2}|1\d\d|2(?:[0-4]\d|5[0-5])/ # 00..255
+ DEGS = /-?\d+(?:deg)?/i # [-]digits[deg]
+ RADS = /-?(?:\d+(?:\.\d+)?|\.\d+)rad/i # [-](digits[.digits] OR .digits)rad
+ HEX_FORMAT = /\#(?:\h{3}|\h{4}|\h{6}|\h{8})/
+ RGB_FORMAT = %r{
+ (?:rgba?
+ \(
+ (?:
+ (?:(?:#{BITS},\s*){2}#{BITS})
+ |
+ (?:(?:#{PERCENTS},\s*){2}#{PERCENTS})
+ )
+ #{ALPHA_CHANNEL}
+ \)
+ )
+ }xi
+ HSL_FORMAT = %r{
+ (?:hsla?
+ \(
+ (?:#{DEGS}|#{RADS}),\s*#{PERCENTS},\s*#{PERCENTS}
+ #{ALPHA_CHANNEL}
+ \)
+ )
+ }xi
+
+ FORMATS = [HEX_FORMAT, RGB_FORMAT, HSL_FORMAT].freeze
+
+ COLOR_FORMAT = /\A(#{Regexp.union(FORMATS)})\z/ix
+
+ # Public: Analyzes whether the String is a color code.
+ #
+ # text - The String to be parsed.
+ #
+ # Returns the recognized color String or nil if none was found.
+ def self.parse(text)
+ text if COLOR_FORMAT =~ text
+ end
+ end
+end
diff --git a/lib/banzai/cross_project_reference.rb b/lib/banzai/cross_project_reference.rb
index d8fb7705b2a..3f1e95d4cc0 100644
--- a/lib/banzai/cross_project_reference.rb
+++ b/lib/banzai/cross_project_reference.rb
@@ -4,7 +4,7 @@ module Banzai
module CrossProjectReference
# Given a cross-project reference string, get the Project record
#
- # Defaults to value of `context[:project]` if:
+ # Defaults to value of `context[:project]`, or `context[:group]` if:
# * No reference is given OR
# * Reference given doesn't exist
#
@@ -12,7 +12,7 @@ module Banzai
#
# Returns a Project, or nil if the reference can't be found
def parent_from_ref(ref)
- return context[:project] unless ref
+ return context[:project] || context[:group] unless ref
Project.find_by_full_path(ref)
end
diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb
index e7e6a90b5fd..a848154b2d4 100644
--- a/lib/banzai/filter/abstract_reference_filter.rb
+++ b/lib/banzai/filter/abstract_reference_filter.rb
@@ -171,10 +171,12 @@ module Banzai
end
if object
- title = object_link_title(object)
+ title = object_link_title(object, matches)
klass = reference_class(object_sym)
- data = data_attributes_for(link_content || match, parent, object, link: !!link_content)
+ data = data_attributes_for(link_content || match, parent, object,
+ link_content: !!link_content,
+ link_reference: link_reference)
url =
if matches.names.include?("url") && matches[:url]
@@ -194,12 +196,15 @@ module Banzai
end
end
- def data_attributes_for(text, project, object, link: false)
+ def data_attributes_for(text, parent, object, link_content: false, link_reference: false)
+ object_parent_type = parent.is_a?(Group) ? :group : :project
+
data_attribute(
- original: text,
- link: link,
- project: project.id,
- object_sym => object.id
+ original: text,
+ link: link_content,
+ link_reference: link_reference,
+ object_parent_type => parent.id,
+ object_sym => object.id
)
end
@@ -210,10 +215,14 @@ module Banzai
extras << "comment #{$1}"
end
+ extension = matches[:extension] if matches.names.include?("extension")
+
+ extras << extension if extension
+
extras
end
- def object_link_title(object)
+ def object_link_title(object, matches)
object.title
end
@@ -334,6 +343,12 @@ module Banzai
def parent
parent_type == :project ? project : group
end
+
+ def full_group_path(group_ref)
+ return current_parent_path unless group_ref
+
+ group_ref
+ end
end
end
end
diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb
index b8d2673c1a6..4a143baeef6 100644
--- a/lib/banzai/filter/autolink_filter.rb
+++ b/lib/banzai/filter/autolink_filter.rb
@@ -21,12 +21,13 @@ module Banzai
#
# See http://en.wikipedia.org/wiki/URI_scheme
#
- # The negative lookbehind ensures that users can paste a URL followed by a
- # period or comma for punctuation without those characters being included
- # in the generated link.
+ # The negative lookbehind ensures that users can paste a URL followed by
+ # punctuation without those characters being included in the generated
+ # link. It matches the behaviour of Rinku 2.0.1:
+ # https://github.com/vmg/rinku/blob/v2.0.1/ext/rinku/autolink.c#L65
#
- # Rubular: http://rubular.com/r/cxjPyZc7Sb
- LINK_PATTERN = %r{([a-z][a-z0-9\+\.-]+://\S+)(?<!,|\.)}
+ # Rubular: http://rubular.com/r/nrL3r9yUiq
+ LINK_PATTERN = %r{([a-z][a-z0-9\+\.-]+://[^\s>]+)(?<!\?|!|\.|,|:)}
# Text matching LINK_PATTERN inside these elements will not be linked
IGNORE_PARENTS = %w(a code kbd pre script style).to_set
@@ -35,53 +36,19 @@ module Banzai
TEXT_QUERY = %Q(descendant-or-self::text()[
not(#{IGNORE_PARENTS.map { |p| "ancestor::#{p}" }.join(' or ')})
and contains(., '://')
- and not(starts-with(., 'http'))
- and not(starts-with(., 'ftp'))
]).freeze
+ PUNCTUATION_PAIRS = {
+ "'" => "'",
+ '"' => '"',
+ ')' => '(',
+ ']' => '[',
+ '}' => '{'
+ }.freeze
+
def call
return doc if context[:autolink] == false
- rinku_parse
- text_parse
- end
-
- private
-
- # Run the text through Rinku as a first pass
- #
- # This will quickly autolink http(s) and ftp links.
- #
- # `@doc` will be re-parsed with the HTML String from Rinku.
- def rinku_parse
- # Convert the options from a Hash to a String that Rinku expects
- options = tag_options(link_options)
-
- # NOTE: We don't parse email links because it will erroneously match
- # external Commit and CommitRange references.
- #
- # The final argument tells Rinku to link short URLs that don't include a
- # period (e.g., http://localhost:3000/)
- rinku = Rinku.auto_link(html, :urls, options, IGNORE_PARENTS.to_a, 1)
-
- return if rinku == html
-
- # Rinku returns a String, so parse it back to a Nokogiri::XML::Document
- # for further processing.
- @doc = parse_html(rinku)
- end
-
- # Return true if any of the UNSAFE_PROTOCOLS strings are included in the URI scheme
- def contains_unsafe?(scheme)
- return false unless scheme
-
- scheme = scheme.strip.downcase
- Banzai::Filter::SanitizationFilter::UNSAFE_PROTOCOLS.any? { |protocol| scheme.include?(protocol) }
- end
-
- # Autolinks any text matching LINK_PATTERN that Rinku didn't already
- # replace
- def text_parse
doc.xpath(TEXT_QUERY).each do |node|
content = node.to_html
@@ -97,6 +64,16 @@ module Banzai
doc
end
+ private
+
+ # Return true if any of the UNSAFE_PROTOCOLS strings are included in the URI scheme
+ def contains_unsafe?(scheme)
+ return false unless scheme
+
+ scheme = scheme.strip.downcase
+ Banzai::Filter::SanitizationFilter::UNSAFE_PROTOCOLS.any? { |protocol| scheme.include?(protocol) }
+ end
+
def autolink_match(match)
# start by stripping out dangerous links
begin
@@ -112,12 +89,34 @@ module Banzai
match.gsub!(/((?:&[\w#]+;)+)\z/, '')
dropped = ($1 || '').html_safe
- options = link_options.merge(href: match)
- content_tag(:a, match, options) + dropped
+ # To match the behaviour of Rinku, if the matched link ends with a
+ # closing part of a matched pair of punctuation, we remove that trailing
+ # character unless there are an equal number of closing and opening
+ # characters in the link.
+ if match.end_with?(*PUNCTUATION_PAIRS.keys)
+ close_character = match[-1]
+ close_count = match.count(close_character)
+ open_character = PUNCTUATION_PAIRS[close_character]
+ open_count = match.count(open_character)
+
+ if open_count != close_count || open_character == close_character
+ dropped += close_character
+ match = match[0..-2]
+ end
+ end
+
+ # match has come from node.to_html above, so we know it's encoded
+ # correctly.
+ html_safe_match = match.html_safe
+ options = link_options.merge(href: html_safe_match)
+
+ content_tag(:a, html_safe_match, options) + dropped
end
def autolink_filter(text)
- text.gsub(LINK_PATTERN) { |match| autolink_match(match) }
+ Gitlab::StringRegexMarker.new(CGI.unescapeHTML(text), text.html_safe).mark(LINK_PATTERN) do |link, left:, right:|
+ autolink_match(link)
+ end
end
def link_options
diff --git a/lib/banzai/filter/color_filter.rb b/lib/banzai/filter/color_filter.rb
new file mode 100644
index 00000000000..6ab29ac281f
--- /dev/null
+++ b/lib/banzai/filter/color_filter.rb
@@ -0,0 +1,31 @@
+module Banzai
+ module Filter
+ # HTML filter that renders `color` followed by a color "chip".
+ #
+ class ColorFilter < HTML::Pipeline::Filter
+ COLOR_CHIP_CLASS = 'gfm-color_chip'.freeze
+
+ def call
+ doc.css('code').each do |node|
+ color = ColorParser.parse(node.content)
+ node << color_chip(color) if color
+ end
+
+ doc
+ end
+
+ private
+
+ def color_chip(color)
+ checkerboard = doc.document.create_element('span', class: COLOR_CHIP_CLASS)
+ chip = doc.document.create_element('span', style: inline_styles(color: color))
+
+ checkerboard << chip
+ end
+
+ def inline_styles(color:)
+ "background-color: #{color};"
+ 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 21bcb1c5ca8..99fa2d9d8fb 100644
--- a/lib/banzai/filter/commit_range_reference_filter.rb
+++ b/lib/banzai/filter/commit_range_reference_filter.rb
@@ -34,7 +34,7 @@ module Banzai
range.to_param.merge(only_path: context[:only_path]))
end
- def object_link_title(range)
+ def object_link_title(range, matches)
nil
end
end
diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb
index eedb95197aa..43bf4fc6565 100644
--- a/lib/banzai/filter/commit_reference_filter.rb
+++ b/lib/banzai/filter/commit_reference_filter.rb
@@ -18,7 +18,8 @@ module Banzai
def find_object(project, id)
if project && project.valid_repo?
- project.commit(id)
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/43894
+ Gitlab::GitalyClient.allow_n_plus_1_calls { project.commit(id) }
end
end
diff --git a/lib/banzai/filter/commit_trailers_filter.rb b/lib/banzai/filter/commit_trailers_filter.rb
new file mode 100644
index 00000000000..ef16df1f3ae
--- /dev/null
+++ b/lib/banzai/filter/commit_trailers_filter.rb
@@ -0,0 +1,152 @@
+module Banzai
+ module Filter
+ # HTML filter that replaces users' names and emails in commit trailers
+ # with links to their GitLab accounts or mailto links to their mentioned
+ # emails.
+ #
+ # Commit trailers are special labels in the form of `*-by:` and fall on a
+ # single line, ex:
+ #
+ # Reported-By: John S. Doe <john.doe@foo.bar>
+ #
+ # More info about this can be found here:
+ # * https://git.wiki.kernel.org/index.php/CommitMessageConventions
+ class CommitTrailersFilter < HTML::Pipeline::Filter
+ include ActionView::Helpers::TagHelper
+ include ApplicationHelper
+ include AvatarsHelper
+
+ TRAILER_REGEXP = /(?<label>[[:alpha:]-]+-by:)/i.freeze
+ AUTHOR_REGEXP = /(?<author_name>.+)/.freeze
+ # Devise.email_regexp wouldn't work here since its designed to match
+ # against strings that only contains email addresses; the \A and \z
+ # around the expression will only match if the string being matched
+ # contains just the email nothing else.
+ MAIL_REGEXP = /&lt;(?<author_email>[^@\s]+@[^@\s]+)&gt;/.freeze
+ FILTER_REGEXP = /(?<trailer>^\s*#{TRAILER_REGEXP}\s*#{AUTHOR_REGEXP}\s+#{MAIL_REGEXP}$)/mi.freeze
+
+ def call
+ doc.xpath('descendant-or-self::text()').each do |node|
+ content = node.to_html
+
+ next unless content.match(FILTER_REGEXP)
+
+ html = trailer_filter(content)
+
+ next if html == content
+
+ node.replace(html)
+ end
+
+ doc
+ end
+
+ private
+
+ # Replace trailer lines with links to GitLab users or mailto links to
+ # non GitLab users.
+ #
+ # text - String text to replace trailers in.
+ #
+ # Returns a String with all trailer lines replaced with links to GitLab
+ # users and mailto links to non GitLab users. All links have `data-trailer`
+ # and `data-user` attributes attached.
+ def trailer_filter(text)
+ text.gsub(FILTER_REGEXP) do |author_match|
+ label = $~[:label]
+ "#{label} #{parse_user($~[:author_name], $~[:author_email], label)}"
+ end
+ end
+
+ # Find a GitLab user using the supplied email and generate
+ # a valid link to them, otherwise, generate a mailto link.
+ #
+ # name - String name used in the commit message for the user
+ # email - String email used in the commit message for the user
+ # trailer - String trailer used in the commit message
+ #
+ # Returns a String with a link to the user.
+ def parse_user(name, email, trailer)
+ link_to_user User.find_by_any_email(email),
+ name: name,
+ email: email,
+ trailer: trailer
+ end
+
+ def urls
+ Gitlab::Routing.url_helpers
+ end
+
+ def link_to_user(user, name:, email:, trailer:)
+ wrapper = link_wrapper(data: {
+ trailer: trailer,
+ user: user.try(:id)
+ })
+
+ avatar = user_avatar_without_link(
+ user: user,
+ user_email: email,
+ css_class: 'avatar-inline',
+ has_tooltip: false
+ )
+
+ link_href = user.nil? ? "mailto:#{email}" : urls.user_url(user)
+
+ avatar_link = link_tag(
+ link_href,
+ content: avatar,
+ title: email
+ )
+
+ name_link = link_tag(
+ link_href,
+ content: name,
+ title: email
+ )
+
+ email_link = link_tag(
+ "mailto:#{email}",
+ content: email,
+ title: email
+ )
+
+ wrapper << "#{avatar_link}#{name_link} <#{email_link}>"
+ end
+
+ def link_wrapper(data: {})
+ data_attributes = data_attributes_from_hash(data)
+
+ doc.document.create_element(
+ 'span',
+ data_attributes
+ )
+ end
+
+ def link_tag(url, title: "", content: "", data: {})
+ data_attributes = data_attributes_from_hash(data)
+
+ attributes = data_attributes.merge(
+ href: url,
+ title: title
+ )
+
+ link = doc.document.create_element('a', attributes)
+
+ if content.html_safe?
+ link << content
+ else
+ link.content = content # make sure we escape content using nokogiri's #content=
+ end
+
+ link
+ end
+
+ def data_attributes_from_hash(data = {})
+ data.reject! {|_, value| value.nil?}
+ data.map do |key, value|
+ [%(data-#{key.to_s.dasherize}), value]
+ end.to_h
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/emoji_filter.rb b/lib/banzai/filter/emoji_filter.rb
index 6255a611dbe..e1261e7bbbe 100644
--- a/lib/banzai/filter/emoji_filter.rb
+++ b/lib/banzai/filter/emoji_filter.rb
@@ -11,7 +11,7 @@ module Banzai
IGNORED_ANCESTOR_TAGS = %w(pre code tt).to_set
def call
- search_text_nodes(doc).each do |node|
+ doc.search(".//text()").each do |node|
content = node.to_html
next if has_ancestor?(node, IGNORED_ANCESTOR_TAGS)
@@ -54,9 +54,9 @@ module Banzai
# Build a regexp that matches all valid :emoji: names.
def self.emoji_pattern
@emoji_pattern ||=
- /(?<=[^[:alnum:]:]|\n|^)
+ %r{(?<=[^[:alnum:]:]|\n|^)
:(#{Gitlab::Emoji.emojis_names.map { |name| Regexp.escape(name) }.join('|')}):
- (?=[^[:alnum:]:]|$)/x
+ (?=[^[:alnum:]:]|$)}x
end
# Build a regexp that matches all valid unicode emojis names.
diff --git a/lib/banzai/filter/gollum_tags_filter.rb b/lib/banzai/filter/gollum_tags_filter.rb
index 2e259904673..f2e9a5a1116 100644
--- a/lib/banzai/filter/gollum_tags_filter.rb
+++ b/lib/banzai/filter/gollum_tags_filter.rb
@@ -51,13 +51,13 @@ module Banzai
# See https://github.com/gollum/gollum/wiki
#
# Rubular: http://rubular.com/r/7dQnE5CUCH
- TAGS_PATTERN = %r{\[\[(.+?)\]\]}.freeze
+ TAGS_PATTERN = /\[\[(.+?)\]\]/.freeze
# Pattern to match allowed image extensions
- ALLOWED_IMAGE_EXTENSIONS = %r{.+(jpg|png|gif|svg|bmp)\z}i.freeze
+ ALLOWED_IMAGE_EXTENSIONS = /.+(jpg|png|gif|svg|bmp)\z/i.freeze
def call
- search_text_nodes(doc).each do |node|
+ doc.search(".//text()").each do |node|
# A Gollum ToC tag is `[[_TOC_]]`, but due to MarkdownFilter running
# before this one, it will be converted into `[[<em>TOC</em>]]`, so it
# needs special-case handling
diff --git a/lib/banzai/filter/html_entity_filter.rb b/lib/banzai/filter/html_entity_filter.rb
index f3bd587c28b..e008fd428b0 100644
--- a/lib/banzai/filter/html_entity_filter.rb
+++ b/lib/banzai/filter/html_entity_filter.rb
@@ -5,7 +5,7 @@ module Banzai
# Text filter that escapes these HTML entities: & " < >
class HtmlEntityFilter < HTML::Pipeline::TextFilter
def call
- ERB::Util.html_escape_once(text)
+ ERB::Util.html_escape(text)
end
end
end
diff --git a/lib/banzai/filter/inline_diff_filter.rb b/lib/banzai/filter/inline_diff_filter.rb
index beb21b19ab3..73e82a4d7e3 100644
--- a/lib/banzai/filter/inline_diff_filter.rb
+++ b/lib/banzai/filter/inline_diff_filter.rb
@@ -4,7 +4,7 @@ module Banzai
IGNORED_ANCESTOR_TAGS = %w(pre code tt).to_set
def call
- search_text_nodes(doc).each do |node|
+ doc.search(".//text()").each do |node|
next if has_ancestor?(node, IGNORED_ANCESTOR_TAGS)
content = node.to_html
diff --git a/lib/banzai/filter/issuable_state_filter.rb b/lib/banzai/filter/issuable_state_filter.rb
index 327ea9449a1..8f541dcfdb2 100644
--- a/lib/banzai/filter/issuable_state_filter.rb
+++ b/lib/banzai/filter/issuable_state_filter.rb
@@ -15,7 +15,9 @@ module Banzai
issuables = extractor.extract([doc])
issuables.each do |node, issuable|
- if VISIBLE_STATES.include?(issuable.state) && node.inner_html == issuable.reference_link_text(project)
+ next if !can_read_cross_project? && issuable.project != project
+
+ if VISIBLE_STATES.include?(issuable.state) && issuable_reference?(node.inner_html, issuable)
node.content += " (#{issuable.state})"
end
end
@@ -25,6 +27,14 @@ module Banzai
private
+ def issuable_reference?(text, issuable)
+ text == issuable.reference_link_text(project || group)
+ end
+
+ def can_read_cross_project?
+ Ability.allowed?(current_user, :read_cross_project)
+ end
+
def current_user
context[:current_user]
end
@@ -32,6 +42,10 @@ module Banzai
def project
context[:project]
end
+
+ def group
+ context[:group]
+ end
end
end
end
diff --git a/lib/banzai/filter/label_reference_filter.rb b/lib/banzai/filter/label_reference_filter.rb
index d5360ad8f68..1cbada818fb 100644
--- a/lib/banzai/filter/label_reference_filter.rb
+++ b/lib/banzai/filter/label_reference_filter.rb
@@ -32,16 +32,25 @@ module Banzai
end
end
- def find_label(project_ref, label_id, label_name)
- project = parent_from_ref(project_ref)
- return unless project
+ def find_label(parent_ref, label_id, label_name)
+ parent = parent_from_ref(parent_ref)
+ return unless parent
label_params = label_params(label_id, label_name)
- find_labels(project).find_by(label_params)
+ find_labels(parent).find_by(label_params)
end
- def find_labels(project)
- LabelsFinder.new(nil, project_id: project.id).execute(skip_authorization: true)
+ def find_labels(parent)
+ params = if parent.is_a?(Group)
+ { group_id: parent.id,
+ include_ancestor_groups: true,
+ only_group_labels: true }
+ else
+ { project_id: parent.id,
+ include_ancestor_groups: true }
+ end
+
+ LabelsFinder.new(nil, params).execute(skip_authorization: true)
end
# Parameters to pass to `Label.find_by` based on the given arguments
@@ -59,25 +68,39 @@ module Banzai
end
end
- def url_for_object(label, project)
+ def url_for_object(label, parent)
h = Gitlab::Routing.url_helpers
- h.project_issues_url(project, label_name: label.name, only_path: context[:only_path])
+
+ if parent.is_a?(Project)
+ h.project_issues_url(parent, label_name: label.name, only_path: context[:only_path])
+ elsif context[:label_url_method]
+ h.public_send(context[:label_url_method], parent, label_name: label.name, only_path: context[:only_path]) # rubocop:disable GitlabSecurity/PublicSend
+ end
end
def object_link_text(object, matches)
- project_path = full_project_path(matches[:namespace], matches[:project])
- project_from_ref = from_ref_cached(project_path)
- reference = project_from_ref.to_human_reference(project)
- label_suffix = " <i>in #{reference}</i>" if reference.present?
+ label_suffix = ''
+
+ if project || full_path_ref?(matches)
+ project_path = full_project_path(matches[:namespace], matches[:project])
+ parent_from_ref = from_ref_cached(project_path)
+ reference = parent_from_ref.to_human_reference(project || group)
+
+ label_suffix = " <i>in #{reference}</i>" if reference.present?
+ end
LabelsHelper.render_colored_label(object, label_suffix)
end
+ def full_path_ref?(matches)
+ matches[:namespace] && matches[:project]
+ end
+
def unescape_html_entities(text)
CGI.unescapeHTML(text.to_s)
end
- def object_link_title(object)
+ def object_link_title(object, matches)
# use title of wrapped element instead
nil
end
diff --git a/lib/banzai/filter/markdown_engines/common_mark.rb b/lib/banzai/filter/markdown_engines/common_mark.rb
new file mode 100644
index 00000000000..bc9597df894
--- /dev/null
+++ b/lib/banzai/filter/markdown_engines/common_mark.rb
@@ -0,0 +1,45 @@
+# `CommonMark` markdown engine for GitLab's Banzai markdown filter.
+# This module is used in Banzai::Filter::MarkdownFilter.
+# Used gem is `commonmarker` which is a ruby wrapper for libcmark (CommonMark parser)
+# including GitHub's GFM extensions.
+# Homepage: https://github.com/gjtorikian/commonmarker
+
+module Banzai
+ module Filter
+ module MarkdownEngines
+ class CommonMark
+ EXTENSIONS = [
+ :autolink, # provides support for automatically converting URLs to anchor tags.
+ :strikethrough, # provides support for strikethroughs.
+ :table, # provides support for tables.
+ :tagfilter # strips out several "unsafe" HTML tags from being used: https://github.github.com/gfm/#disallowed-raw-html-extension-
+ ].freeze
+
+ PARSE_OPTIONS = [
+ :FOOTNOTES, # parse footnotes.
+ :STRIKETHROUGH_DOUBLE_TILDE, # parse strikethroughs by double tildes (as redcarpet does).
+ :VALIDATE_UTF8 # replace illegal sequences with the replacement character U+FFFD.
+ ].freeze
+
+ # The `:GITHUB_PRE_LANG` option is not used intentionally because
+ # it renders a fence block with language as `<pre lang="LANG"><code>some code\n</code></pre>`
+ # while GitLab's syntax is `<pre><code lang="LANG">some code\n</code></pre>`.
+ # If in the future the syntax is about to be made GitHub-compatible, please, add `:GITHUB_PRE_LANG` render option below
+ # and remove `code_block` method from `lib/banzai/renderer/common_mark/html.rb`.
+ RENDER_OPTIONS = [
+ :DEFAULT # default rendering system. Nothing special.
+ ].freeze
+
+ def initialize
+ @renderer = Banzai::Renderer::CommonMark::HTML.new(options: RENDER_OPTIONS)
+ end
+
+ def render(text)
+ doc = CommonMarker.render_doc(text, PARSE_OPTIONS, EXTENSIONS)
+
+ @renderer.render(doc)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/markdown_engines/redcarpet.rb b/lib/banzai/filter/markdown_engines/redcarpet.rb
new file mode 100644
index 00000000000..ac99941fefa
--- /dev/null
+++ b/lib/banzai/filter/markdown_engines/redcarpet.rb
@@ -0,0 +1,32 @@
+# `Redcarpet` markdown engine for GitLab's Banzai markdown filter.
+# This module is used in Banzai::Filter::MarkdownFilter.
+# Used gem is `redcarpet` which is a ruby library for markdown processing.
+# Homepage: https://github.com/vmg/redcarpet
+
+module Banzai
+ module Filter
+ module MarkdownEngines
+ class Redcarpet
+ OPTIONS = {
+ fenced_code_blocks: true,
+ footnotes: true,
+ lax_spacing: true,
+ no_intra_emphasis: true,
+ space_after_headers: true,
+ strikethrough: true,
+ superscript: true,
+ tables: true
+ }.freeze
+
+ def initialize
+ html_renderer = Banzai::Renderer::Redcarpet::HTML.new
+ @renderer = ::Redcarpet::Markdown.new(html_renderer, OPTIONS)
+ end
+
+ def render(text)
+ @renderer.render(text)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/markdown_filter.rb b/lib/banzai/filter/markdown_filter.rb
index 9cac303e645..c1e2b680240 100644
--- a/lib/banzai/filter/markdown_filter.rb
+++ b/lib/banzai/filter/markdown_filter.rb
@@ -1,34 +1,31 @@
module Banzai
module Filter
class MarkdownFilter < HTML::Pipeline::TextFilter
- # https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use
- REDCARPET_OPTIONS = {
- fenced_code_blocks: true,
- footnotes: true,
- lax_spacing: true,
- no_intra_emphasis: true,
- space_after_headers: true,
- strikethrough: true,
- superscript: true,
- tables: true
- }.freeze
-
def initialize(text, context = nil, result = nil)
- super text, context, result
- @text = @text.delete "\r"
+ super(text, context, result)
+
+ @renderer = renderer(context[:markdown_engine]).new
+ @text = @text.delete("\r")
end
def call
- html = self.class.renderer.render(@text)
- html.rstrip!
- html
+ @renderer.render(@text).rstrip
+ end
+
+ private
+
+ DEFAULT_ENGINE = :redcarpet
+
+ def engine(engine_from_context)
+ engine_from_context ||= DEFAULT_ENGINE
+
+ engine_from_context.to_s.classify
end
- def self.renderer
- Thread.current[:banzai_markdown_renderer] ||= begin
- renderer = Banzai::Renderer::HTML.new
- Redcarpet::Markdown.new(renderer, REDCARPET_OPTIONS)
- end
+ def renderer(engine_from_context)
+ "Banzai::Filter::MarkdownEngines::#{engine(engine_from_context)}".constantize
+ rescue NameError
+ raise NameError, "`#{engine_from_context}` is unknown markdown engine"
end
end
end
diff --git a/lib/banzai/filter/merge_request_reference_filter.rb b/lib/banzai/filter/merge_request_reference_filter.rb
index b3cfa97d0e0..5cbdb01c130 100644
--- a/lib/banzai/filter/merge_request_reference_filter.rb
+++ b/lib/banzai/filter/merge_request_reference_filter.rb
@@ -17,10 +17,19 @@ module Banzai
only_path: context[:only_path])
end
+ def object_link_title(object, matches)
+ object_link_commit_title(object, matches) || super
+ end
+
def object_link_text_extras(object, matches)
extras = super
+ if commit_ref = object_link_commit_ref(object, matches)
+ return extras.unshift(commit_ref)
+ end
+
path = matches[:path] if matches.names.include?("path")
+
case path
when '/diffs'
extras.unshift "diffs"
@@ -38,6 +47,36 @@ module Banzai
.where(iid: ids.to_a)
.includes(target_project: :namespace)
end
+
+ private
+
+ def object_link_commit_title(object, matches)
+ object_link_commit(object, matches)&.title
+ end
+
+ def object_link_commit_ref(object, matches)
+ object_link_commit(object, matches)&.short_id
+ end
+
+ def object_link_commit(object, matches)
+ return unless matches.names.include?('query') && query = matches[:query]
+
+ # Removes leading "?". CGI.parse expects "arg1&arg2&arg3"
+ params = CGI.parse(query.sub(/^\?/, ''))
+
+ return unless commit_sha = params['commit_id']&.first
+
+ if commit = find_commit_by_sha(object, commit_sha)
+ Commit.from_hash(commit.to_hash, object.project)
+ end
+ end
+
+ def find_commit_by_sha(object, commit_sha)
+ @all_commits ||= {}
+ @all_commits[object.id] ||= object.all_commits
+
+ @all_commits[object.id].find { |commit| commit.sha == commit_sha }
+ end
end
end
end
diff --git a/lib/banzai/filter/mermaid_filter.rb b/lib/banzai/filter/mermaid_filter.rb
index b545b947a2c..65c131e08d9 100644
--- a/lib/banzai/filter/mermaid_filter.rb
+++ b/lib/banzai/filter/mermaid_filter.rb
@@ -2,16 +2,7 @@ module Banzai
module Filter
class MermaidFilter < HTML::Pipeline::Filter
def call
- doc.css('pre[lang="mermaid"]').add_class('mermaid')
- doc.css('pre[lang="mermaid"]').add_class('js-render-mermaid')
-
- # The `<code></code>` blocks are added in the lib/banzai/filter/syntax_highlight_filter.rb
- # We want to keep context and consistency, so we the blocks are added for all filters.
- # Details: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15107/diffs?diff_id=7962900#note_45495859
- doc.css('pre[lang="mermaid"]').each do |pre|
- document = pre.at('code')
- document.replace(document.content)
- end
+ doc.css('pre[lang="mermaid"] > code').add_class('js-render-mermaid')
doc
end
diff --git a/lib/banzai/filter/milestone_reference_filter.rb b/lib/banzai/filter/milestone_reference_filter.rb
index 2a6b0964ac5..1a1d7dbeb3d 100644
--- a/lib/banzai/filter/milestone_reference_filter.rb
+++ b/lib/banzai/filter/milestone_reference_filter.rb
@@ -64,7 +64,7 @@ module Banzai
finder_params[:group_ids] = [project.group.id]
end
- MilestonesFinder.new(finder_params).execute.find_by(params)
+ MilestonesFinder.new(finder_params).find_by(params)
end
def url_for_object(milestone, project)
@@ -84,7 +84,7 @@ module Banzai
end
end
- def object_link_title(object)
+ def object_link_title(object, matches)
nil
end
end
diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/relative_link_filter.rb
index 758f15c8a67..262458a872a 100644
--- a/lib/banzai/filter/relative_link_filter.rb
+++ b/lib/banzai/filter/relative_link_filter.rb
@@ -2,19 +2,21 @@ require 'uri'
module Banzai
module Filter
- # HTML filter that "fixes" relative links to files in a repository.
+ # HTML filter that "fixes" relative links to uploads or files in a repository.
#
# Context options:
# :commit
+ # :group
# :project
# :project_wiki
# :ref
# :requested_path
class RelativeLinkFilter < HTML::Pipeline::Filter
- def call
- return doc unless linkable_files?
+ include Gitlab::Utils::StrongMemoize
+ def call
@uri_types = {}
+ clear_memoization(:linkable_files)
doc.search('a:not(.gfm)').each do |el|
process_link_attr el.attribute('href')
@@ -31,18 +33,47 @@ module Banzai
protected
def linkable_files?
- context[:project_wiki].nil? && repository.try(:exists?) && !repository.empty?
+ strong_memoize(:linkable_files) do
+ context[:project_wiki].nil? && repository.try(:exists?) && !repository.empty?
+ end
end
def process_link_attr(html_attr)
return if html_attr.blank?
return if html_attr.value.start_with?('//')
+ if html_attr.value.start_with?('/uploads/')
+ process_link_to_upload_attr(html_attr)
+ elsif linkable_files?
+ process_link_to_repository_attr(html_attr)
+ end
+ end
+
+ def process_link_to_upload_attr(html_attr)
+ path_parts = [Addressable::URI.unescape(html_attr.value)]
+
+ if group
+ path_parts.unshift(relative_url_root, 'groups', group.full_path, '-')
+ elsif project
+ path_parts.unshift(relative_url_root, project.full_path)
+ end
+
+ path = Addressable::URI.escape(File.join(*path_parts))
+
+ html_attr.value =
+ if context[:only_path]
+ path
+ else
+ Addressable::URI.join(Gitlab.config.gitlab.base_url, path).to_s
+ end
+ end
+
+ def process_link_to_repository_attr(html_attr)
uri = URI(html_attr.value)
if uri.relative? && uri.path.present?
html_attr.value = rebuild_relative_uri(uri).to_s
end
- rescue URI::Error
+ rescue URI::Error, Addressable::URI::InvalidURIError
# noop
end
@@ -51,9 +82,9 @@ module Banzai
uri.path = [
relative_url_root,
- context[:project].full_path,
+ project.full_path,
uri_type(file_path),
- Addressable::URI.escape(ref),
+ Addressable::URI.escape(ref).gsub('#', '%23'),
Addressable::URI.escape(file_path)
].compact.join('/').squeeze('/').chomp('/')
@@ -123,11 +154,19 @@ module Banzai
end
def ref
- context[:ref] || context[:project].default_branch
+ context[:ref] || project.default_branch
+ end
+
+ def group
+ context[:group]
+ end
+
+ def project
+ context[:project]
end
def repository
- @repository ||= context[:project].try(:repository)
+ @repository ||= project&.repository
end
end
end
diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb
index a79a0154846..6dbf0d68fe8 100644
--- a/lib/banzai/filter/syntax_highlight_filter.rb
+++ b/lib/banzai/filter/syntax_highlight_filter.rb
@@ -1,3 +1,4 @@
+require 'rouge/plugins/common_mark'
require 'rouge/plugins/redcarpet'
module Banzai
@@ -14,23 +15,33 @@ module Banzai
end
def highlight_node(node)
- code = node.text
css_classes = 'code highlight js-syntax-highlight'
- language = node.attr('lang')
+ lang = node.attr('lang')
+ retried = false
- if use_rouge?(language)
- lexer = lexer_for(language)
+ if use_rouge?(lang)
+ lexer = lexer_for(lang)
language = lexer.tag
+ else
+ lexer = Rouge::Lexers::PlainText.new
+ language = lang
+ end
+
+ begin
+ code = Rouge::Formatters::HTMLGitlab.format(lex(lexer, node.text), tag: language)
+ css_classes << " #{language}" if language
+ rescue
+ # Gracefully handle syntax highlighter bugs/errors to ensure users can
+ # still access an issue/comment/etc. First, retry with the plain text
+ # filter. If that fails, then just skip this entirely, but that would
+ # be a pretty bad upstream bug.
+ return if retried
- begin
- code = Rouge::Formatters::HTMLGitlab.format(lex(lexer, code), tag: language)
- css_classes << " #{language}"
- rescue
- # Gracefully handle syntax highlighter bugs/errors to ensure
- # users can still access an issue/comment/etc.
+ language = nil
+ lexer = Rouge::Lexers::PlainText.new
+ retried = true
- language = nil
- end
+ retry
end
highlighted = %(<pre class="#{css_classes}" lang="#{language}" v-pre="true"><code>#{code}</code></pre>)
diff --git a/lib/banzai/filter/upload_link_filter.rb b/lib/banzai/filter/upload_link_filter.rb
deleted file mode 100644
index d64f9ac4eb6..00000000000
--- a/lib/banzai/filter/upload_link_filter.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-require 'uri'
-
-module Banzai
- module Filter
- # HTML filter that "fixes" relative upload links to files.
- # Context options:
- # :project (required) - Current project
- #
- class UploadLinkFilter < HTML::Pipeline::Filter
- def call
- return doc unless project || group
-
- doc.xpath('descendant-or-self::a[starts-with(@href, "/uploads/")]').each do |el|
- process_link_attr el.attribute('href')
- end
-
- doc.xpath('descendant-or-self::img[starts-with(@src, "/uploads/")]').each do |el|
- process_link_attr el.attribute('src')
- end
-
- doc
- end
-
- protected
-
- def process_link_attr(html_attr)
- html_attr.value = build_url(html_attr.value).to_s
- end
-
- def build_url(uri)
- base_path = Gitlab.config.gitlab.url
-
- if group
- urls = Gitlab::Routing.url_helpers
- # we need to get last 2 parts of the uri which are secret and filename
- uri_parts = uri.split(File::SEPARATOR)
- file_path = urls.show_group_uploads_path(group, uri_parts[-2], uri_parts[-1])
- File.join(base_path, file_path)
- else
- File.join(base_path, project.full_path, uri)
- end
- end
-
- def project
- context[:project]
- end
-
- def group
- context[:group]
- end
-
- # Ensure that a :project key exists in context
- #
- # Note that while the key might exist, its value could be nil!
- def validate
- needs :project
- end
- end
- end
-end
diff --git a/lib/banzai/filter/wiki_link_filter/rewriter.rb b/lib/banzai/filter/wiki_link_filter/rewriter.rb
index e7a1ec8457d..072d24e5a11 100644
--- a/lib/banzai/filter/wiki_link_filter/rewriter.rb
+++ b/lib/banzai/filter/wiki_link_filter/rewriter.rb
@@ -9,6 +9,10 @@ module Banzai
end
def apply_rules
+ # Special case: relative URLs beginning with `/uploads/` refer to
+ # user-uploaded files and will be handled elsewhere.
+ return @uri.to_s if @uri.relative? && @uri.path.starts_with?('/uploads/')
+
apply_file_link_rules!
apply_hierarchical_link_rules!
apply_relative_link_rules!
diff --git a/lib/banzai/pipeline/broadcast_message_pipeline.rb b/lib/banzai/pipeline/broadcast_message_pipeline.rb
index adc09c8afbd..5dd572de3a1 100644
--- a/lib/banzai/pipeline/broadcast_message_pipeline.rb
+++ b/lib/banzai/pipeline/broadcast_message_pipeline.rb
@@ -7,6 +7,7 @@ module Banzai
Filter::SanitizationFilter,
Filter::EmojiFilter,
+ Filter::ColorFilter,
Filter::AutolinkFilter,
Filter::ExternalLinkFilter
]
diff --git a/lib/banzai/pipeline/commit_description_pipeline.rb b/lib/banzai/pipeline/commit_description_pipeline.rb
new file mode 100644
index 00000000000..607c2731ed3
--- /dev/null
+++ b/lib/banzai/pipeline/commit_description_pipeline.rb
@@ -0,0 +1,11 @@
+module Banzai
+ module Pipeline
+ class CommitDescriptionPipeline < SingleLinePipeline
+ def self.filters
+ @filters ||= super.concat FilterArray[
+ Filter::CommitTrailersFilter,
+ ]
+ end
+ end
+ end
+end
diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb
index 55874ad50a3..8b2f05fffec 100644
--- a/lib/banzai/pipeline/gfm_pipeline.rb
+++ b/lib/banzai/pipeline/gfm_pipeline.rb
@@ -2,10 +2,10 @@ module Banzai
module Pipeline
class GfmPipeline < BasePipeline
# These filters convert GitLab Flavored Markdown (GFM) to HTML.
- # The handlers defined in app/assets/javascripts/copy_as_gfm.js
+ # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
# consequently convert that same HTML to GFM to be copied to the clipboard.
# Every filter that generates HTML from GFM should have a handler in
- # app/assets/javascripts/copy_as_gfm.js, in reverse order.
+ # app/assets/javascripts/behaviors/markdown/copy_as_gfm.js, in reverse order.
# The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
def self.filters
@filters ||= FilterArray[
@@ -14,8 +14,8 @@ module Banzai
Filter::SyntaxHighlightFilter,
Filter::MathFilter,
+ Filter::ColorFilter,
Filter::MermaidFilter,
- Filter::UploadLinkFilter,
Filter::VideoLinkFilter,
Filter::ImageLazyLoadFilter,
Filter::ImageLinkFilter,
diff --git a/lib/banzai/redactor.rb b/lib/banzai/redactor.rb
index de3ebe72720..fd457bebf03 100644
--- a/lib/banzai/redactor.rb
+++ b/lib/banzai/redactor.rb
@@ -19,8 +19,9 @@ module Banzai
#
# Returns the documents passed as the first argument.
def redact(documents)
- all_document_nodes = document_nodes(documents)
+ redact_cross_project_references(documents) unless can_read_cross_project?
+ all_document_nodes = document_nodes(documents)
redact_document_nodes(all_document_nodes)
end
@@ -41,16 +42,45 @@ module Banzai
next if visible.include?(node)
doc_data[:visible_reference_count] -= 1
- # The reference should be replaced by the original link's content,
- # which is not always the same as the rendered one.
- content = node.attr('data-original') || node.inner_html
- node.replace(content)
+ redacted_content = redacted_node_content(node)
+ node.replace(redacted_content)
end
end
metadata
end
+ # Return redacted content of given node as either the original link (<a> tag),
+ # the original content (text), or the inner HTML of the node.
+ #
+ def redacted_node_content(node)
+ original_content = node.attr('data-original')
+ link_reference = node.attr('data-link-reference')
+
+ # Build the raw <a> tag just with a link as href and content if
+ # it's originally a link pattern. We shouldn't return a plain text href.
+ original_link =
+ if link_reference == 'true' && href = original_content
+ %(<a href="#{href}">#{href}</a>)
+ end
+
+ # The reference should be replaced by the original link's content,
+ # which is not always the same as the rendered one.
+ original_link || original_content || node.inner_html
+ end
+
+ def redact_cross_project_references(documents)
+ extractor = Banzai::IssuableExtractor.new(project, user)
+ issuables = extractor.extract(documents)
+
+ issuables.each do |node, issuable|
+ next if issuable.project == project
+
+ node['class'] = node['class'].gsub('has-tooltip', '')
+ node['title'] = nil
+ end
+ end
+
# Returns the nodes visible to the current user.
#
# nodes - The input nodes to check.
@@ -78,5 +108,11 @@ module Banzai
{ document: document, nodes: Querying.css(document, 'a.gfm[data-reference-type]') }
end
end
+
+ private
+
+ def can_read_cross_project?
+ Ability.allowed?(user, :read_cross_project)
+ end
end
end
diff --git a/lib/banzai/reference_parser/issuable_parser.rb b/lib/banzai/reference_parser/issuable_parser.rb
index 3953867eb83..fad127d7e5b 100644
--- a/lib/banzai/reference_parser/issuable_parser.rb
+++ b/lib/banzai/reference_parser/issuable_parser.rb
@@ -18,7 +18,7 @@ module Banzai
end
def can_read_reference?(user, issuable)
- can?(user, "read_#{issuable.class.to_s.underscore}".to_sym, issuable)
+ can?(user, "read_#{issuable.class.to_s.underscore}_iid".to_sym, issuable)
end
end
end
diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb
index 38d4e3f3e44..230827129b6 100644
--- a/lib/banzai/reference_parser/issue_parser.rb
+++ b/lib/banzai/reference_parser/issue_parser.rb
@@ -5,12 +5,31 @@ module Banzai
def nodes_visible_to_user(user, nodes)
issues = records_for_nodes(nodes)
+ issues_to_check = issues.values
- readable_issues = Ability
- .issues_readable_by_user(issues.values, user).to_set
+ unless can?(user, :read_cross_project)
+ issues_to_check, cross_project_issues = issues_to_check.partition do |issue|
+ issue.project == project
+ end
+ end
+
+ readable_issues = Ability.issues_readable_by_user(issues_to_check, user).to_set
nodes.select do |node|
- readable_issues.include?(issues[node])
+ issue_in_node = issues[node]
+
+ # We check the inclusion of readable issues first because it's faster.
+ #
+ # But we need to fall back to `read_issue_iid` if the user cannot read
+ # cross project, since it might be possible the user can see the IID
+ # but not the issue.
+ if readable_issues.include?(issue_in_node)
+ true
+ elsif cross_project_issues&.include?(issue_in_node)
+ can_read_reference?(user, issue_in_node)
+ else
+ false
+ end
end
end
diff --git a/lib/banzai/renderer/common_mark/html.rb b/lib/banzai/renderer/common_mark/html.rb
new file mode 100644
index 00000000000..c7a54629f31
--- /dev/null
+++ b/lib/banzai/renderer/common_mark/html.rb
@@ -0,0 +1,21 @@
+module Banzai
+ module Renderer
+ module CommonMark
+ class HTML < CommonMarker::HtmlRenderer
+ def code_block(node)
+ block do
+ code = node.string_content
+ lang = node.fence_info
+ lang_attr = lang.present? ? %Q{ lang="#{lang}"} : ''
+ result =
+ "<pre>" \
+ "<code#{lang_attr}>#{html_escape(code)}</code>" \
+ "</pre>"
+
+ out(result)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/banzai/renderer/html.rb b/lib/banzai/renderer/html.rb
deleted file mode 100644
index 252caa35947..00000000000
--- a/lib/banzai/renderer/html.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-module Banzai
- module Renderer
- class HTML < Redcarpet::Render::HTML
- def block_code(code, lang)
- lang_attr = lang ? %Q{ lang="#{lang}"} : ''
-
- "\n<pre>" \
- "<code#{lang_attr}>#{html_escape(code)}</code>" \
- "</pre>"
- end
- end
- end
-end
diff --git a/lib/banzai/renderer/redcarpet/html.rb b/lib/banzai/renderer/redcarpet/html.rb
new file mode 100644
index 00000000000..94df5d8b1e1
--- /dev/null
+++ b/lib/banzai/renderer/redcarpet/html.rb
@@ -0,0 +1,15 @@
+module Banzai
+ module Renderer
+ module Redcarpet
+ class HTML < ::Redcarpet::Render::HTML
+ def block_code(code, lang)
+ lang_attr = lang ? %Q{ lang="#{lang}"} : ''
+
+ "\n<pre>" \
+ "<code#{lang_attr}>#{html_escape(code)}</code>" \
+ "</pre>"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb
index b9279c33f5b..ba5a9e2f04c 100644
--- a/lib/bitbucket/connection.rb
+++ b/lib/bitbucket/connection.rb
@@ -57,7 +57,7 @@ module Bitbucket
end
def provider
- Gitlab::OAuth::Provider.config_for('bitbucket')
+ Gitlab::Auth::OAuth::Provider.config_for('bitbucket')
end
def options
diff --git a/lib/carrier_wave_string_file.rb b/lib/carrier_wave_string_file.rb
new file mode 100644
index 00000000000..6c848902e4a
--- /dev/null
+++ b/lib/carrier_wave_string_file.rb
@@ -0,0 +1,5 @@
+class CarrierWaveStringFile < StringIO
+ def original_filename
+ ""
+ end
+end
diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb
index fd2ac2db0a9..87649c50424 100644
--- a/lib/constraints/group_url_constrainer.rb
+++ b/lib/constraints/group_url_constrainer.rb
@@ -1,9 +1,11 @@
-class GroupUrlConstrainer
- def matches?(request)
- full_path = request.params[:group_id] || request.params[:id]
+module Constraints
+ class GroupUrlConstrainer
+ def matches?(request)
+ full_path = request.params[:group_id] || request.params[:id]
- return false unless NamespacePathValidator.valid_path?(full_path)
+ return false unless NamespacePathValidator.valid_path?(full_path)
- Group.find_by_full_path(full_path, follow_redirects: request.get?).present?
+ Group.find_by_full_path(full_path, follow_redirects: request.get?).present?
+ end
end
end
diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb
index e90ecb5ec69..32aea98f0f7 100644
--- a/lib/constraints/project_url_constrainer.rb
+++ b/lib/constraints/project_url_constrainer.rb
@@ -1,13 +1,15 @@
-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].join('/')
+module Constraints
+ 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].join('/')
- return false unless ProjectPathValidator.valid_path?(full_path)
+ return false unless ProjectPathValidator.valid_path?(full_path)
- # 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?
+ # 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
end
diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb
index b7633aa7cbb..8afa04d29a4 100644
--- a/lib/constraints/user_url_constrainer.rb
+++ b/lib/constraints/user_url_constrainer.rb
@@ -1,9 +1,11 @@
-class UserUrlConstrainer
- def matches?(request)
- full_path = request.params[:username]
+module Constraints
+ class UserUrlConstrainer
+ def matches?(request)
+ full_path = request.params[:username]
- return false unless UserPathValidator.valid_path?(full_path)
+ return false unless NamespacePathValidator.valid_path?(full_path)
- User.find_by_full_path(full_path, follow_redirects: request.get?).present?
+ User.find_by_full_path(full_path, follow_redirects: request.get?).present?
+ end
end
end
diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb
index c7263f302ab..010ca1ec27b 100644
--- a/lib/container_registry/client.rb
+++ b/lib/container_registry/client.rb
@@ -52,6 +52,8 @@ module ContainerRegistry
conn.request(:authorization, :bearer, options[:token].to_s)
end
+ yield(conn) if block_given?
+
conn.adapter :net_http
end
@@ -80,8 +82,7 @@ module ContainerRegistry
def faraday
@faraday ||= Faraday.new(@base_uri) do |conn|
- initialize_connection(conn, @options)
- accept_manifest(conn)
+ initialize_connection(conn, @options, &method(:accept_manifest))
end
end
diff --git a/lib/container_registry/registry.rb b/lib/container_registry/registry.rb
index 63bce655f57..f90d711474a 100644
--- a/lib/container_registry/registry.rb
+++ b/lib/container_registry/registry.rb
@@ -11,7 +11,7 @@ module ContainerRegistry
private
def default_path
- @uri.sub(/^https?:\/\//, '')
+ @uri.sub(%r{^https?://}, '')
end
end
end
diff --git a/lib/declarative_policy.rb b/lib/declarative_policy.rb
index b1949d693ad..1dd2855063d 100644
--- a/lib/declarative_policy.rb
+++ b/lib/declarative_policy.rb
@@ -1,6 +1,8 @@
require_dependency 'declarative_policy/cache'
require_dependency 'declarative_policy/condition'
-require_dependency 'declarative_policy/dsl'
+require_dependency 'declarative_policy/delegate_dsl'
+require_dependency 'declarative_policy/policy_dsl'
+require_dependency 'declarative_policy/rule_dsl'
require_dependency 'declarative_policy/preferred_scope'
require_dependency 'declarative_policy/rule'
require_dependency 'declarative_policy/runner'
diff --git a/lib/declarative_policy/delegate_dsl.rb b/lib/declarative_policy/delegate_dsl.rb
new file mode 100644
index 00000000000..f544dffe888
--- /dev/null
+++ b/lib/declarative_policy/delegate_dsl.rb
@@ -0,0 +1,16 @@
+module DeclarativePolicy
+ # 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.empty? && !block_given?
+
+ @rule_dsl.delegate(@delegate_name, m)
+ end
+ end
+end
diff --git a/lib/declarative_policy/dsl.rb b/lib/declarative_policy/dsl.rb
deleted file mode 100644
index 6ba1e7a3c5c..00000000000
--- a/lib/declarative_policy/dsl.rb
+++ /dev/null
@@ -1,103 +0,0 @@
-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/policy_dsl.rb b/lib/declarative_policy/policy_dsl.rb
new file mode 100644
index 00000000000..f11b6e9f730
--- /dev/null
+++ b/lib/declarative_policy/policy_dsl.rb
@@ -0,0 +1,44 @@
+module DeclarativePolicy
+ # 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
index b0754098149..5c214408dd0 100644
--- a/lib/declarative_policy/preferred_scope.rb
+++ b/lib/declarative_policy/preferred_scope.rb
@@ -1,4 +1,4 @@
-module DeclarativePolicy
+module DeclarativePolicy # rubocop:disable Naming/FileName
PREFERRED_SCOPE_KEY = :"DeclarativePolicy.preferred_scope"
class << self
diff --git a/lib/declarative_policy/rule_dsl.rb b/lib/declarative_policy/rule_dsl.rb
new file mode 100644
index 00000000000..e948b7f2de1
--- /dev/null
+++ b/lib/declarative_policy/rule_dsl.rb
@@ -0,0 +1,45 @@
+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.empty? && !block_given?
+
+ if @context_class.delegations.key?(m)
+ DelegateDsl.new(self, m)
+ else
+ cond(m.to_sym)
+ end
+ end
+ end
+end
diff --git a/lib/email_template_interceptor.rb b/lib/email_template_interceptor.rb
index f2bf3d0fb2b..3978a6d9fe4 100644
--- a/lib/email_template_interceptor.rb
+++ b/lib/email_template_interceptor.rb
@@ -1,10 +1,8 @@
# Read about interceptors in http://guides.rubyonrails.org/action_mailer_basics.html#intercepting-emails
class EmailTemplateInterceptor
- extend Gitlab::CurrentSettings
-
def self.delivering_email(message)
# Remove HTML part if HTML emails are disabled.
- unless current_application_settings.html_emails_enabled
+ unless Gitlab::CurrentSettings.html_emails_enabled
message.parts.delete_if do |part|
part.content_type.start_with?('text/html')
end
diff --git a/lib/extracts_path.rb b/lib/extracts_path.rb
index d8aca3304c5..a9b04c183ad 100644
--- a/lib/extracts_path.rb
+++ b/lib/extracts_path.rb
@@ -56,7 +56,7 @@ module ExtractsPath
if valid_refs.length == 0
# No exact ref match, so just try our best
- pair = id.match(/([^\/]+)(.*)/).captures
+ pair = id.match(%r{([^/]+)(.*)}).captures
else
# There is a distinct possibility that multiple refs prefix the ID.
# Use the longest match to maximize the chance that we have the
@@ -68,7 +68,7 @@ module ExtractsPath
end
# Remove ending slashes from path
- pair[1].gsub!(/^\/|\/$/, '')
+ pair[1].gsub!(%r{^/|/$}, '')
pair
end
diff --git a/lib/forever.rb b/lib/forever.rb
new file mode 100644
index 00000000000..7df17912544
--- /dev/null
+++ b/lib/forever.rb
@@ -0,0 +1,13 @@
+class Forever
+ POSTGRESQL_DATE = DateTime.new(3000, 1, 1)
+ MYSQL_DATE = DateTime.new(2038, 01, 19)
+
+ # MySQL timestamp has a range of '1970-01-01 00:00:01' UTC to '2038-01-19 03:14:07' UTC
+ def self.date
+ if Gitlab::Database.postgresql?
+ POSTGRESQL_DATE
+ else
+ MYSQL_DATE
+ end
+ end
+end
diff --git a/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb b/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb
index 7cb4bccb23c..91175b49c79 100644
--- a/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb
+++ b/lib/generators/rails/post_deployment_migration/post_deployment_migration_generator.rb
@@ -3,7 +3,7 @@ require 'rails/generators'
module Rails
class PostDeploymentMigrationGenerator < Rails::Generators::NamedBase
def create_migration_file
- timestamp = Time.now.strftime('%Y%m%d%H%I%S')
+ timestamp = Time.now.strftime('%Y%m%d%H%M%S')
template "migration.rb", "db/post_migrate/#{timestamp}_#{file_name}.rb"
end
diff --git a/lib/gitaly/server.rb b/lib/gitaly/server.rb
new file mode 100644
index 00000000000..605e93022e7
--- /dev/null
+++ b/lib/gitaly/server.rb
@@ -0,0 +1,43 @@
+module Gitaly
+ class Server
+ def self.all
+ Gitlab.config.repositories.storages.keys.map { |s| Gitaly::Server.new(s) }
+ end
+
+ attr_reader :storage
+
+ def initialize(storage)
+ @storage = storage
+ end
+
+ def server_version
+ info.server_version
+ end
+
+ def git_binary_version
+ info.git_version
+ end
+
+ def up_to_date?
+ server_version == Gitlab::GitalyClient.expected_server_version
+ end
+
+ def address
+ Gitlab::GitalyClient.address(@storage)
+ rescue RuntimeError => e
+ "Error getting the address: #{e.message}"
+ end
+
+ private
+
+ def info
+ @info ||=
+ begin
+ Gitlab::GitalyClient::ServerService.new(@storage).info
+ rescue GRPC::Unavailable, GRPC::GRPC::DeadlineExceeded
+ # This will show the server as being out of date
+ Gitaly::ServerInfoResponse.new(git_version: '', server_version: '')
+ end
+ end
+ end
+end
diff --git a/lib/gitlab.rb b/lib/gitlab.rb
index 11f7c8b9510..aa9fd36d9ff 100644
--- a/lib/gitlab.rb
+++ b/lib/gitlab.rb
@@ -2,6 +2,7 @@ require_dependency 'gitlab/git'
module Gitlab
COM_URL = 'https://gitlab.com'.freeze
+ APP_DIRS_PATTERN = %r{^/?(app|config|ee|lib|spec|\(\w*\))}
def self.com?
# Check `staging?` as well to keep parity with gitlab.com
diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb
index cead1c7eacd..62c41801d75 100644
--- a/lib/gitlab/asciidoc.rb
+++ b/lib/gitlab/asciidoc.rb
@@ -6,11 +6,10 @@ module Gitlab
# Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters
# the resulting HTML through HTML pipeline filters.
module Asciidoc
- extend Gitlab::CurrentSettings
-
DEFAULT_ADOC_ATTRS = [
'showtitle', 'idprefix=user-content-', 'idseparator=-', 'env=gitlab',
- 'env-gitlab', 'source-highlighter=html-pipeline', 'icons=font'
+ 'env-gitlab', 'source-highlighter=html-pipeline', 'icons=font',
+ 'outfilesuffix=.adoc'
].freeze
# Public: Converts the provided Asciidoc markup into HTML.
@@ -33,9 +32,9 @@ module Gitlab
def self.plantuml_setup
Asciidoctor::PlantUml.configure do |conf|
- conf.url = current_application_settings.plantuml_url
- conf.svg_enable = current_application_settings.plantuml_enabled
- conf.png_enable = current_application_settings.plantuml_enabled
+ conf.url = Gitlab::CurrentSettings.plantuml_url
+ conf.svg_enable = Gitlab::CurrentSettings.plantuml_enabled
+ conf.png_enable = Gitlab::CurrentSettings.plantuml_enabled
conf.txt_enable = false
end
end
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 65d7fd3ec70..2a44e11efb6 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -5,7 +5,7 @@ module Gitlab
REGISTRY_SCOPES = [:read_registry].freeze
# Scopes used for GitLab API access
- API_SCOPES = [:api, :read_user, :sudo].freeze
+ API_SCOPES = [:api, :read_user, :sudo, :read_repository].freeze
# Scopes used for OpenID Connect
OPENID_SCOPES = [:openid].freeze
@@ -14,8 +14,6 @@ module Gitlab
DEFAULT_SCOPES = [:api].freeze
class << self
- include Gitlab::CurrentSettings
-
def find_for_git_client(login, password, project:, ip:)
raise "Must provide an IP for rate limiting" if ip.nil?
@@ -28,6 +26,7 @@ module Gitlab
lfs_token_check(login, password, project) ||
oauth_access_token_check(login, password) ||
personal_access_token_check(password) ||
+ deploy_token_check(login, password) ||
user_with_password_for_git(login, password) ||
Gitlab::Auth::Result.new
@@ -42,8 +41,8 @@ module Gitlab
end
def find_with_user_password(login, password)
- # Avoid resource intensive login checks if password is not provided
- return unless password.present?
+ # Avoid resource intensive checks if login credentials are not provided
+ return unless login.present? && password.present?
# Nothing to do here if internal auth is disabled and LDAP is
# not configured
@@ -52,13 +51,29 @@ module Gitlab
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
user = User.by_login(login)
- # If no user is found, or it's an LDAP server, try LDAP.
- # LDAP users are only authenticated via LDAP
- if user.nil? || user.ldap_user?
- # Second chance - try LDAP authentication
- Gitlab::LDAP::Authentication.login(login, password)
- elsif current_application_settings.password_authentication_enabled_for_git?
- user if user.active? && user.valid_password?(password)
+ return if user && !user.active?
+
+ authenticators = []
+
+ if user
+ authenticators << Gitlab::Auth::OAuth::Provider.authentication(user, 'database')
+
+ # Add authenticators for all identities if user is not nil
+ user&.identities&.each do |identity|
+ authenticators << Gitlab::Auth::OAuth::Provider.authentication(user, identity.provider)
+ end
+ else
+ # If no user is provided, try LDAP.
+ # LDAP users are only authenticated via LDAP
+ authenticators << Gitlab::Auth::LDAP::Authentication
+ end
+
+ authenticators.compact!
+
+ # return found user that was authenticated first for given login credentials
+ authenticators.find do |auth|
+ authenticated_user = auth.login(login, password)
+ break authenticated_user if authenticated_user
end
end
end
@@ -87,7 +102,7 @@ module Gitlab
private
def authenticate_using_internal_or_ldap_password?
- current_application_settings.password_authentication_enabled_for_git? || Gitlab::LDAP::Config.enabled?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_git? || Gitlab::Auth::LDAP::Config.enabled?
end
def service_request_check(login, password, project)
@@ -149,7 +164,8 @@ module Gitlab
def abilities_for_scopes(scopes)
abilities_by_scope = {
api: full_authentication_abilities,
- read_registry: [:read_container_image]
+ read_registry: [:read_container_image],
+ read_repository: [:download_code]
}
scopes.flat_map do |scope|
@@ -157,6 +173,22 @@ module Gitlab
end.uniq
end
+ def deploy_token_check(login, password)
+ return unless password.present?
+
+ token =
+ DeployToken.active.find_by(token: password)
+
+ return unless token && login
+ return if login != token.username
+
+ scopes = abilities_for_scopes(token.scopes)
+
+ if valid_scoped_token?(token, available_scopes)
+ Gitlab::Auth::Result.new(token, token.project, :deploy_token, scopes)
+ end
+ end
+
def lfs_token_check(login, password, project)
deploy_key_matches = login.match(/\Alfs\+deploy-key-(\d+)\z/)
diff --git a/lib/gitlab/auth/blocked_user_tracker.rb b/lib/gitlab/auth/blocked_user_tracker.rb
new file mode 100644
index 00000000000..dae03a179e4
--- /dev/null
+++ b/lib/gitlab/auth/blocked_user_tracker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+module Gitlab
+ module Auth
+ class BlockedUserTracker
+ ACTIVE_RECORD_REQUEST_PARAMS = 'action_dispatch.request.request_parameters'
+
+ def self.log_if_user_blocked(env)
+ message = env.dig('warden.options', :message)
+
+ # Devise calls User#active_for_authentication? on the User model and then
+ # throws an exception to Warden with User#inactive_message:
+ # https://github.com/plataformatec/devise/blob/v4.2.1/lib/devise/hooks/activatable.rb#L8
+ #
+ # Since Warden doesn't pass the user record to the failure handler, we
+ # need to do a database lookup with the username. We can limit the
+ # lookups to happen when the user was blocked by checking the inactive
+ # message passed along by Warden.
+ return unless message == User::BLOCKED_MESSAGE
+
+ login = env.dig(ACTIVE_RECORD_REQUEST_PARAMS, 'user', 'login')
+
+ return unless login.present?
+
+ user = User.by_login(login)
+
+ return unless user&.blocked?
+
+ Gitlab::AppLogger.info("Failed login for blocked user: user=#{user.username} ip=#{env['REMOTE_ADDR']}")
+ SystemHooksService.new.execute_hooks_for(user, :failed_login)
+
+ true
+ rescue TypeError
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/database/authentication.rb b/lib/gitlab/auth/database/authentication.rb
new file mode 100644
index 00000000000..1234ace0334
--- /dev/null
+++ b/lib/gitlab/auth/database/authentication.rb
@@ -0,0 +1,16 @@
+# These calls help to authenticate to OAuth provider by providing username and password
+#
+
+module Gitlab
+ module Auth
+ module Database
+ class Authentication < Gitlab::Auth::OAuth::Authentication
+ def login(login, password)
+ return false unless Gitlab::CurrentSettings.password_authentication_enabled_for_git?
+
+ return user if user&.valid_password?(password)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/access.rb b/lib/gitlab/auth/ldap/access.rb
new file mode 100644
index 00000000000..34286900e72
--- /dev/null
+++ b/lib/gitlab/auth/ldap/access.rb
@@ -0,0 +1,91 @@
+# LDAP authorization model
+#
+# * Check if we are allowed access (not blocked)
+#
+module Gitlab
+ module Auth
+ module LDAP
+ class Access
+ attr_reader :provider, :user
+
+ def self.open(user, &block)
+ Gitlab::Auth::LDAP::Adapter.open(user.ldap_identity.provider) do |adapter|
+ block.call(self.new(user, adapter))
+ end
+ end
+
+ def self.allowed?(user)
+ self.open(user) do |access|
+ if access.allowed?
+ Users::UpdateService.new(user, user: user, last_credential_check_at: Time.now).execute
+
+ true
+ else
+ false
+ end
+ end
+ end
+
+ def initialize(user, adapter = nil)
+ @adapter = adapter
+ @user = user
+ @provider = user.ldap_identity.provider
+ end
+
+ def allowed?
+ if ldap_user
+ unless ldap_config.active_directory
+ unblock_user(user, 'is available again') if user.ldap_blocked?
+ return true
+ end
+
+ # Block user in GitLab if he/she was blocked in AD
+ if Gitlab::Auth::LDAP::Person.disabled_via_active_directory?(user.ldap_identity.extern_uid, adapter)
+ block_user(user, 'is disabled in Active Directory')
+ false
+ else
+ unblock_user(user, 'is not disabled anymore') if user.ldap_blocked?
+ true
+ end
+ else
+ # Block the user if they no longer exist in LDAP/AD
+ block_user(user, 'does not exist anymore')
+ false
+ end
+ rescue LDAPConnectionError
+ false
+ end
+
+ def adapter
+ @adapter ||= Gitlab::Auth::LDAP::Adapter.new(provider)
+ end
+
+ def ldap_config
+ Gitlab::Auth::LDAP::Config.new(provider)
+ end
+
+ def ldap_user
+ @ldap_user ||= Gitlab::Auth::LDAP::Person.find_by_dn(user.ldap_identity.extern_uid, adapter)
+ end
+
+ def block_user(user, reason)
+ user.ldap_block
+
+ Gitlab::AppLogger.info(
+ "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \
+ "blocking Gitlab user \"#{user.name}\" (#{user.email})"
+ )
+ end
+
+ def unblock_user(user, reason)
+ user.activate
+
+ Gitlab::AppLogger.info(
+ "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \
+ "unblocking Gitlab user \"#{user.name}\" (#{user.email})"
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb
new file mode 100644
index 00000000000..82ff1e77e5c
--- /dev/null
+++ b/lib/gitlab/auth/ldap/adapter.rb
@@ -0,0 +1,137 @@
+module Gitlab
+ module Auth
+ module LDAP
+ class Adapter
+ SEARCH_RETRY_FACTOR = [1, 1, 2, 3].freeze
+ MAX_SEARCH_RETRIES = Rails.env.test? ? 1 : SEARCH_RETRY_FACTOR.size.freeze
+
+ attr_reader :provider, :ldap
+
+ def self.open(provider, &block)
+ Net::LDAP.open(config(provider).adapter_options) do |ldap|
+ block.call(self.new(provider, ldap))
+ end
+ end
+
+ def self.config(provider)
+ Gitlab::Auth::LDAP::Config.new(provider)
+ end
+
+ def initialize(provider, ldap = nil)
+ @provider = provider
+ @ldap = ldap || renew_connection_adapter
+ end
+
+ def config
+ Gitlab::Auth::LDAP::Config.new(provider)
+ end
+
+ def users(fields, value, limit = nil)
+ options = user_options(Array(fields), value, limit)
+
+ entries = ldap_search(options).select do |entry|
+ entry.respond_to? config.uid
+ end
+
+ entries.map do |entry|
+ Gitlab::Auth::LDAP::Person.new(entry, provider)
+ end
+ end
+
+ def user(*args)
+ users(*args).first
+ end
+
+ def dn_matches_filter?(dn, filter)
+ ldap_search(base: dn,
+ filter: filter,
+ scope: Net::LDAP::SearchScope_BaseObject,
+ attributes: %w{dn}).any?
+ end
+
+ def ldap_search(*args)
+ retries ||= 0
+
+ # Net::LDAP's `time` argument doesn't work. Use Ruby `Timeout` instead.
+ Timeout.timeout(timeout_time(retries)) do
+ results = ldap.search(*args)
+
+ if results.nil?
+ response = ldap.get_operation_result
+
+ unless response.code.zero?
+ Rails.logger.warn("LDAP search error: #{response.message}")
+ end
+
+ []
+ else
+ results
+ end
+ end
+ rescue Net::LDAP::Error, Timeout::Error => error
+ retries += 1
+ error_message = connection_error_message(error)
+
+ Rails.logger.warn(error_message)
+
+ if retries < MAX_SEARCH_RETRIES
+ renew_connection_adapter
+ retry
+ else
+ raise LDAPConnectionError, error_message
+ end
+ end
+
+ private
+
+ def timeout_time(retry_number)
+ SEARCH_RETRY_FACTOR[retry_number] * config.timeout
+ end
+
+ def user_options(fields, value, limit)
+ options = {
+ attributes: Gitlab::Auth::LDAP::Person.ldap_attributes(config),
+ base: config.base
+ }
+
+ options[:size] = limit if limit
+
+ if fields.include?('dn')
+ raise ArgumentError, 'It is not currently possible to search the DN and other fields at the same time.' if fields.size > 1
+
+ options[:base] = value
+ options[:scope] = Net::LDAP::SearchScope_BaseObject
+ else
+ filter = fields.map { |field| Net::LDAP::Filter.eq(field, value) }.inject(:|)
+ end
+
+ options.merge(filter: user_filter(filter))
+ end
+
+ def user_filter(filter = nil)
+ user_filter = config.constructed_user_filter if config.user_filter.present?
+
+ if user_filter && filter
+ Net::LDAP::Filter.join(filter, user_filter)
+ elsif user_filter
+ user_filter
+ else
+ filter
+ end
+ end
+
+ def connection_error_message(exception)
+ if exception.is_a?(Timeout::Error)
+ "LDAP search timed out after #{config.timeout} seconds"
+ else
+ "LDAP search raised exception #{exception.class}: #{exception.message}"
+ end
+ end
+
+ def renew_connection_adapter
+ @ldap = Net::LDAP.new(config.adapter_options)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/auth_hash.rb b/lib/gitlab/auth/ldap/auth_hash.rb
new file mode 100644
index 00000000000..ac5c14d374d
--- /dev/null
+++ b/lib/gitlab/auth/ldap/auth_hash.rb
@@ -0,0 +1,48 @@
+# Class to parse and transform the info provided by omniauth
+#
+module Gitlab
+ module Auth
+ module LDAP
+ class AuthHash < Gitlab::Auth::OAuth::AuthHash
+ def uid
+ @uid ||= Gitlab::Auth::LDAP::Person.normalize_dn(super)
+ end
+
+ def username
+ super.tap do |username|
+ username.downcase! if ldap_config.lowercase_usernames
+ end
+ end
+
+ private
+
+ def get_info(key)
+ attributes = ldap_config.attributes[key.to_s]
+ return super unless attributes
+
+ attributes = Array(attributes)
+
+ value = nil
+ attributes.each do |attribute|
+ value = get_raw(attribute)
+ value = value.first if value
+ break if value.present?
+ end
+
+ return super unless value
+
+ Gitlab::Utils.force_utf8(value)
+ value
+ end
+
+ def get_raw(key)
+ auth_hash.extra[:raw_info][key] if auth_hash.extra
+ end
+
+ def ldap_config
+ @ldap_config ||= Gitlab::Auth::LDAP::Config.new(self.provider)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/authentication.rb b/lib/gitlab/auth/ldap/authentication.rb
new file mode 100644
index 00000000000..7c134fb6438
--- /dev/null
+++ b/lib/gitlab/auth/ldap/authentication.rb
@@ -0,0 +1,58 @@
+# These calls help to authenticate to LDAP by providing username and password
+#
+# Since multiple LDAP servers are supported, it will loop through all of them
+# until a valid bind is found
+#
+
+module Gitlab
+ module Auth
+ module LDAP
+ class Authentication < Gitlab::Auth::OAuth::Authentication
+ def self.login(login, password)
+ return unless Gitlab::Auth::LDAP::Config.enabled?
+ return unless login.present? && password.present?
+
+ # return found user that was authenticated by first provider for given login credentials
+ providers.find do |provider|
+ auth = new(provider)
+ break auth.user if auth.login(login, password) # true will exit the loop
+ end
+ end
+
+ def self.providers
+ Gitlab::Auth::LDAP::Config.providers
+ end
+
+ def login(login, password)
+ result = adapter.bind_as(
+ filter: user_filter(login),
+ size: 1,
+ password: password
+ )
+ return unless result
+
+ @user = Gitlab::Auth::LDAP::User.find_by_uid_and_provider(result.dn, provider)
+ end
+
+ def adapter
+ OmniAuth::LDAP::Adaptor.new(config.omniauth_options)
+ end
+
+ def config
+ Gitlab::Auth::LDAP::Config.new(provider)
+ end
+
+ def user_filter(login)
+ filter = Net::LDAP::Filter.equals(config.uid, login)
+
+ # Apply LDAP user filter if present
+ if config.user_filter.present?
+ filter = Net::LDAP::Filter.join(filter, config.constructed_user_filter)
+ end
+
+ filter
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/config.rb b/lib/gitlab/auth/ldap/config.rb
new file mode 100644
index 00000000000..77185f52ced
--- /dev/null
+++ b/lib/gitlab/auth/ldap/config.rb
@@ -0,0 +1,237 @@
+# Load a specific server configuration
+module Gitlab
+ module Auth
+ 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?
+ Gitlab.config.ldap.enabled
+ end
+
+ def self.servers
+ 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
+
+ def self.valid_provider?(provider)
+ providers.include?(provider)
+ end
+
+ def self.invalid_provider(provider)
+ raise "Unknown provider (#{provider}). Available providers: #{providers}"
+ end
+
+ def initialize(provider)
+ if self.class.valid_provider?(provider)
+ @provider = provider
+ else
+ self.class.invalid_provider(provider)
+ end
+
+ @options = config_for(@provider) # Use @provider, not provider
+ end
+
+ def enabled?
+ base_config.enabled
+ end
+
+ def adapter_options
+ opts = base_options.merge(
+ encryption: encryption_options
+ )
+
+ opts.merge!(auth_options) if has_auth?
+
+ opts
+ end
+
+ def omniauth_options
+ opts = base_options.merge(
+ base: base,
+ encryption: options['encryption'],
+ filter: omniauth_user_filter,
+ name_proc: name_proc,
+ disable_verify_certificates: !options['verify_certificates']
+ )
+
+ if has_auth?
+ opts.merge!(
+ bind_dn: options['bind_dn'],
+ password: options['password']
+ )
+ 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 base
+ options['base']
+ end
+
+ def uid
+ options['uid']
+ end
+
+ def sync_ssh_keys?
+ sync_ssh_keys.present?
+ end
+
+ # The LDAP attribute in which the ssh keys are stored
+ def sync_ssh_keys
+ options['sync_ssh_keys']
+ end
+
+ def user_filter
+ options['user_filter']
+ end
+
+ def constructed_user_filter
+ @constructed_user_filter ||= Net::LDAP::Filter.construct(user_filter)
+ end
+
+ def group_base
+ options['group_base']
+ end
+
+ def admin_group
+ options['admin_group']
+ end
+
+ def active_directory
+ options['active_directory']
+ end
+
+ def block_auto_created_users
+ options['block_auto_created_users']
+ end
+
+ def attributes
+ default_attributes.merge(options['attributes'])
+ end
+
+ def timeout
+ options['timeout'].to_i
+ end
+
+ def has_auth?
+ options['password'] || options['bind_dn']
+ end
+
+ def allow_username_or_email_login
+ options['allow_username_or_email_login']
+ end
+
+ def lowercase_usernames
+ options['lowercase_usernames']
+ end
+
+ def name_proc
+ if allow_username_or_email_login
+ proc { |name| name.gsub(/@.*\z/, '') }
+ else
+ proc { |name| name }
+ end
+ end
+
+ def default_attributes
+ {
+ 'username' => %w(uid sAMAccountName userid),
+ 'email' => %w(mail email userPrincipalName),
+ 'name' => 'cn',
+ 'first_name' => 'givenName',
+ 'last_name' => 'sn'
+ }
+ end
+
+ protected
+
+ def base_options
+ {
+ host: options['host'],
+ port: options['port']
+ }
+ end
+
+ def base_config
+ Gitlab.config.ldap
+ end
+
+ def config_for(provider)
+ base_config.servers.values.find { |server| server['provider_name'] == provider }
+ 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
+ {
+ auth: {
+ method: :simple,
+ username: options['bind_dn'],
+ password: options['password']
+ }
+ }
+ end
+
+ def omniauth_user_filter
+ uid_filter = Net::LDAP::Filter.eq(uid, '%{username}')
+
+ if user_filter.present?
+ Net::LDAP::Filter.join(uid_filter, constructed_user_filter).to_s
+ else
+ uid_filter.to_s
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/dn.rb b/lib/gitlab/auth/ldap/dn.rb
new file mode 100644
index 00000000000..1fa5338f5a6
--- /dev/null
+++ b/lib/gitlab/auth/ldap/dn.rb
@@ -0,0 +1,303 @@
+# -*- ruby encoding: utf-8 -*-
+
+# Based on the `ruby-net-ldap` gem's `Net::LDAP::DN`
+#
+# For our purposes, this class is used to normalize DNs in order to allow proper
+# comparison.
+#
+# E.g. DNs should be compared case-insensitively (in basically all LDAP
+# implementations or setups), therefore we downcase every DN.
+
+##
+# Objects of this class represent an LDAP DN ("Distinguished Name"). A DN
+# ("Distinguished Name") is a unique identifier for an entry within an LDAP
+# directory. It is made up of a number of other attributes strung together,
+# to identify the entry in the tree.
+#
+# Each attribute that makes up a DN needs to have its value escaped so that
+# the DN is valid. This class helps take care of that.
+#
+# A fully escaped DN needs to be unescaped when analysing its contents. This
+# class also helps take care of that.
+module Gitlab
+ module Auth
+ module LDAP
+ class DN
+ FormatError = Class.new(StandardError)
+ MalformedError = Class.new(FormatError)
+ UnsupportedError = Class.new(FormatError)
+
+ def self.normalize_value(given_value)
+ dummy_dn = "placeholder=#{given_value}"
+ normalized_dn = new(*dummy_dn).to_normalized_s
+ normalized_dn.sub(/\Aplaceholder=/, '')
+ end
+
+ ##
+ # Initialize a DN, escaping as required. Pass in attributes in name/value
+ # pairs. If there is a left over argument, it will be appended to the dn
+ # without escaping (useful for a base string).
+ #
+ # Most uses of this class will be to escape a DN, rather than to parse it,
+ # so storing the dn as an escaped String and parsing parts as required
+ # with a state machine seems sensible.
+ def initialize(*args)
+ if args.length > 1
+ initialize_array(args)
+ else
+ initialize_string(args[0])
+ end
+ end
+
+ ##
+ # Parse a DN into key value pairs using ASN from
+ # http://tools.ietf.org/html/rfc2253 section 3.
+ # rubocop:disable Metrics/AbcSize
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def each_pair
+ state = :key
+ key = StringIO.new
+ value = StringIO.new
+ hex_buffer = ""
+
+ @dn.each_char.with_index do |char, dn_index|
+ case state
+ when :key then
+ case char
+ when 'a'..'z', 'A'..'Z' then
+ state = :key_normal
+ key << char
+ when '0'..'9' then
+ state = :key_oid
+ key << char
+ when ' ' then state = :key
+ else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"")
+ end
+ when :key_normal then
+ case char
+ when '=' then state = :value
+ when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char
+ else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"")
+ end
+ when :key_oid then
+ case char
+ when '=' then state = :value
+ when '0'..'9', '.', ' ' then key << char
+ else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"")
+ end
+ when :value then
+ case char
+ when '\\' then state = :value_normal_escape
+ when '"' then state = :value_quoted
+ when ' ' then state = :value
+ when '#' then
+ state = :value_hexstring
+ value << char
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ else
+ state = :value_normal
+ value << char
+ end
+ when :value_normal then
+ case char
+ when '\\' then state = :value_normal_escape
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported")
+ else value << char
+ end
+ when :value_normal_escape then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_normal_escape_hex
+ hex_buffer = char
+ else
+ state = :value_normal
+ value << char
+ end
+ when :value_normal_escape_hex then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_normal
+ value << "#{hex_buffer}#{char}".to_i(16).chr
+ else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"")
+ end
+ when :value_quoted then
+ case char
+ when '\\' then state = :value_quoted_escape
+ when '"' then state = :value_end
+ else value << char
+ end
+ when :value_quoted_escape then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_quoted_escape_hex
+ hex_buffer = char
+ else
+ state = :value_quoted
+ value << char
+ end
+ when :value_quoted_escape_hex then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_quoted
+ value << "#{hex_buffer}#{char}".to_i(16).chr
+ else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"")
+ end
+ when :value_hexstring then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_hexstring_hex
+ value << char
+ when ' ' then state = :value_end
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"")
+ end
+ when :value_hexstring_hex then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_hexstring
+ value << char
+ else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"")
+ end
+ when :value_end then
+ case char
+ when ' ' then state = :value_end
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"")
+ end
+ else raise "Fell out of state machine"
+ end
+ end
+
+ # Last pair
+ raise(MalformedError, 'DN string ended unexpectedly') unless
+ [:value, :value_normal, :value_hexstring, :value_end].include? state
+
+ yield key.string.strip, rstrip_except_escaped(value.string, @dn.length)
+ end
+
+ def rstrip_except_escaped(str, dn_index)
+ str_ends_with_whitespace = str.match(/\s\z/)
+
+ if str_ends_with_whitespace
+ dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/)
+
+ if dn_part_ends_with_escaped_whitespace
+ dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1]
+ num_chars_to_remove = dn_part_rwhitespace.length - 1
+ str = str[0, str.length - num_chars_to_remove]
+ else
+ str.rstrip!
+ end
+ end
+
+ str
+ end
+
+ ##
+ # Returns the DN as an array in the form expected by the constructor.
+ def to_a
+ a = []
+ self.each_pair { |key, value| a << key << value } unless @dn.empty?
+ a
+ end
+
+ ##
+ # Return the DN as an escaped string.
+ def to_s
+ @dn
+ end
+
+ ##
+ # Return the DN as an escaped and normalized string.
+ def to_normalized_s
+ self.class.new(*to_a).to_s.downcase
+ end
+
+ # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions
+ # for DN values. All of the following must be escaped in any normal string
+ # using a single backslash ('\') as escape. The space character is left
+ # out here because in a "normalized" string, spaces should only be escaped
+ # if necessary (i.e. leading or trailing space).
+ NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze
+
+ # The following must be represented as escaped hex
+ HEX_ESCAPES = {
+ "\n" => '\0a',
+ "\r" => '\0d'
+ }.freeze
+
+ # Compiled character class regexp using the keys from the above hash, and
+ # checking for a space or # at the start, or space at the end, of the
+ # string.
+ ESCAPE_RE = Regexp.new("(^ |^#| $|[" +
+ NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join +
+ "])")
+
+ HEX_ESCAPE_RE = Regexp.new("([" +
+ HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join +
+ "])")
+
+ ##
+ # Escape a string for use in a DN value
+ def self.escape(string)
+ escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char }
+ escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] }
+ end
+
+ private
+
+ def initialize_array(args)
+ buffer = StringIO.new
+
+ args.each_with_index do |arg, index|
+ if index.even? # key
+ buffer << "," if index > 0
+ buffer << arg
+ else # value
+ buffer << "="
+ buffer << self.class.escape(arg)
+ end
+ end
+
+ @dn = buffer.string
+ end
+
+ def initialize_string(arg)
+ @dn = arg.to_s
+ end
+
+ ##
+ # Proxy all other requests to the string object, because a DN is mainly
+ # used within the library as a string
+ # rubocop:disable GitlabSecurity/PublicSend
+ def method_missing(method, *args, &block)
+ @dn.send(method, *args, &block)
+ end
+
+ ##
+ # Redefined to be consistent with redefined `method_missing` behavior
+ def respond_to?(sym, include_private = false)
+ @dn.respond_to?(sym, include_private)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/ldap_connection_error.rb b/lib/gitlab/auth/ldap/ldap_connection_error.rb
new file mode 100644
index 00000000000..ef0a695742b
--- /dev/null
+++ b/lib/gitlab/auth/ldap/ldap_connection_error.rb
@@ -0,0 +1,7 @@
+module Gitlab
+ module Auth
+ module LDAP
+ LDAPConnectionError = Class.new(StandardError)
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/person.rb b/lib/gitlab/auth/ldap/person.rb
new file mode 100644
index 00000000000..8dfae3ee541
--- /dev/null
+++ b/lib/gitlab/auth/ldap/person.rb
@@ -0,0 +1,122 @@
+module Gitlab
+ module Auth
+ module LDAP
+ class Person
+ # Active Directory-specific LDAP filter that checks if bit 2 of the
+ # userAccountControl attribute is set.
+ # Source: http://ctogonewild.com/2009/09/03/bitmask-searches-in-ldap/
+ AD_USER_DISABLED = Net::LDAP::Filter.ex("userAccountControl:1.2.840.113556.1.4.803", "2")
+
+ InvalidEntryError = Class.new(StandardError)
+
+ attr_accessor :entry, :provider
+
+ def self.find_by_uid(uid, adapter)
+ uid = Net::LDAP::Filter.escape(uid)
+ adapter.user(adapter.config.uid, uid)
+ end
+
+ def self.find_by_dn(dn, adapter)
+ adapter.user('dn', dn)
+ end
+
+ def self.find_by_email(email, adapter)
+ email_fields = adapter.config.attributes['email']
+
+ adapter.user(email_fields, email)
+ end
+
+ def self.disabled_via_active_directory?(dn, adapter)
+ adapter.dn_matches_filter?(dn, AD_USER_DISABLED)
+ end
+
+ def self.ldap_attributes(config)
+ [
+ 'dn',
+ config.uid,
+ *config.attributes['name'],
+ *config.attributes['email'],
+ *config.attributes['username']
+ ].compact.uniq
+ end
+
+ def self.normalize_dn(dn)
+ ::Gitlab::Auth::LDAP::DN.new(dn).to_normalized_s
+ rescue ::Gitlab::Auth::LDAP::DN::FormatError => e
+ Rails.logger.info("Returning original DN \"#{dn}\" due to error during normalization attempt: #{e.message}")
+
+ dn
+ end
+
+ # Returns the UID in a normalized form.
+ #
+ # 1. Excess spaces are stripped
+ # 2. The string is downcased (for case-insensitivity)
+ def self.normalize_uid(uid)
+ ::Gitlab::Auth::LDAP::DN.normalize_value(uid)
+ rescue ::Gitlab::Auth::LDAP::DN::FormatError => e
+ Rails.logger.info("Returning original UID \"#{uid}\" due to error during normalization attempt: #{e.message}")
+
+ uid
+ end
+
+ def initialize(entry, provider)
+ Rails.logger.debug { "Instantiating #{self.class.name} with LDIF:\n#{entry.to_ldif}" }
+ @entry = entry
+ @provider = provider
+ end
+
+ def name
+ attribute_value(:name).first
+ end
+
+ def uid
+ entry.public_send(config.uid).first # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def username
+ username = attribute_value(:username)
+
+ # Depending on the attribute, multiple values may
+ # be returned. We need only one for username.
+ # Ex. `uid` returns only one value but `mail` may
+ # return an array of multiple email addresses.
+ [username].flatten.first.tap do |username|
+ username.downcase! if config.lowercase_usernames
+ end
+ end
+
+ def email
+ attribute_value(:email)
+ end
+
+ def dn
+ self.class.normalize_dn(entry.dn)
+ end
+
+ private
+
+ def entry
+ @entry
+ end
+
+ def config
+ @config ||= Gitlab::Auth::LDAP::Config.new(provider)
+ end
+
+ # Using the LDAP attributes configuration, find and return the first
+ # attribute with a value. For example, by default, when given 'email',
+ # this method looks for 'mail', 'email' and 'userPrincipalName' and
+ # returns the first with a value.
+ def attribute_value(attribute)
+ attributes = Array(config.attributes[attribute.to_s])
+ selected_attr = attributes.find { |attr| entry.respond_to?(attr) }
+
+ return nil unless selected_attr
+
+ entry.public_send(selected_attr) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/ldap/user.rb b/lib/gitlab/auth/ldap/user.rb
new file mode 100644
index 00000000000..068212d9a21
--- /dev/null
+++ b/lib/gitlab/auth/ldap/user.rb
@@ -0,0 +1,54 @@
+# LDAP extension for User model
+#
+# * Find or create user from omniauth.auth data
+# * Links LDAP account with existing user
+# * Auth LDAP user with login and password
+#
+module Gitlab
+ module Auth
+ module LDAP
+ class User < Gitlab::Auth::OAuth::User
+ class << self
+ def find_by_uid_and_provider(uid, provider)
+ identity = ::Identity.with_extern_uid(provider, uid).take
+
+ identity && identity.user
+ end
+ end
+
+ def save
+ super('LDAP')
+ end
+
+ # instance methods
+ def find_user
+ find_by_uid_and_provider || find_by_email || build_new_user
+ end
+
+ def find_by_uid_and_provider
+ self.class.find_by_uid_and_provider(auth_hash.uid, auth_hash.provider)
+ end
+
+ def changed?
+ gl_user.changed? || gl_user.identities.any?(&:changed?)
+ end
+
+ def block_after_signup?
+ ldap_config.block_auto_created_users
+ end
+
+ def allowed?
+ Gitlab::Auth::LDAP::Access.allowed?(gl_user)
+ end
+
+ def ldap_config
+ Gitlab::Auth::LDAP::Config.new(auth_hash.provider)
+ end
+
+ def auth_hash=(auth_hash)
+ @auth_hash = Gitlab::Auth::LDAP::AuthHash.new(auth_hash)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/o_auth/auth_hash.rb b/lib/gitlab/auth/o_auth/auth_hash.rb
new file mode 100644
index 00000000000..ed8fba94305
--- /dev/null
+++ b/lib/gitlab/auth/o_auth/auth_hash.rb
@@ -0,0 +1,92 @@
+# Class to parse and transform the info provided by omniauth
+#
+module Gitlab
+ module Auth
+ module OAuth
+ class AuthHash
+ attr_reader :auth_hash
+ def initialize(auth_hash)
+ @auth_hash = auth_hash
+ end
+
+ def uid
+ @uid ||= Gitlab::Utils.force_utf8(auth_hash.uid.to_s)
+ end
+
+ def provider
+ @provider ||= auth_hash.provider.to_s
+ end
+
+ def name
+ @name ||= get_info(:name) || "#{get_info(:first_name)} #{get_info(:last_name)}"
+ end
+
+ def username
+ @username ||= username_and_email[:username].to_s
+ end
+
+ def email
+ @email ||= username_and_email[:email].to_s
+ end
+
+ def password
+ @password ||= Gitlab::Utils.force_utf8(Devise.friendly_token[0, 8].downcase)
+ end
+
+ def location
+ location = get_info(:address)
+ if location.is_a?(Hash)
+ [location.locality.presence, location.country.presence].compact.join(', ')
+ else
+ location
+ end
+ end
+
+ def has_attribute?(attribute)
+ if attribute == :location
+ get_info(:address).present?
+ else
+ get_info(attribute).present?
+ end
+ end
+
+ private
+
+ def info
+ auth_hash.info
+ end
+
+ def get_info(key)
+ value = info[key]
+ Gitlab::Utils.force_utf8(value) if value
+ value
+ end
+
+ def username_and_email
+ @username_and_email ||= begin
+ username = get_info(:username).presence || get_info(:nickname).presence
+ email = get_info(:email).presence
+
+ username ||= generate_username(email) if email
+ email ||= generate_temporarily_email(username) if username
+
+ {
+ username: username,
+ email: email
+ }
+ end
+ end
+
+ # Get the first part of the email address (before @)
+ # In addtion in removes illegal characters
+ def generate_username(email)
+ email.match(/^[^@]*/)[0].mb_chars.normalize(:kd).gsub(/[^\x00-\x7F]/, '').to_s
+ end
+
+ def generate_temporarily_email(username)
+ "temp-email-for-oauth-#{username}@gitlab.localhost"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/o_auth/authentication.rb b/lib/gitlab/auth/o_auth/authentication.rb
new file mode 100644
index 00000000000..d4e7f35c857
--- /dev/null
+++ b/lib/gitlab/auth/o_auth/authentication.rb
@@ -0,0 +1,22 @@
+# These calls help to authenticate to OAuth provider by providing username and password
+#
+
+module Gitlab
+ module Auth
+ module OAuth
+ class Authentication
+ attr_reader :provider, :user
+
+ def initialize(provider, user = nil)
+ @provider = provider
+ @user = user
+ end
+
+ # Implementation must return user object if login successful
+ def login(login, password)
+ raise NotImplementedError
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/o_auth/provider.rb b/lib/gitlab/auth/o_auth/provider.rb
new file mode 100644
index 00000000000..5fb61ffe00d
--- /dev/null
+++ b/lib/gitlab/auth/o_auth/provider.rb
@@ -0,0 +1,73 @@
+module Gitlab
+ module Auth
+ module OAuth
+ class Provider
+ LABELS = {
+ "github" => "GitHub",
+ "gitlab" => "GitLab.com",
+ "google_oauth2" => "Google"
+ }.freeze
+
+ def self.authentication(user, provider)
+ return unless user
+ return unless enabled?(provider)
+
+ authenticator =
+ case provider
+ when /^ldap/
+ Gitlab::Auth::LDAP::Authentication
+ when 'database'
+ Gitlab::Auth::Database::Authentication
+ end
+
+ authenticator&.new(provider, user)
+ end
+
+ def self.providers
+ Devise.omniauth_providers
+ end
+
+ def self.enabled?(name)
+ return true if name == 'database'
+
+ providers.include?(name.to_sym)
+ end
+
+ def self.ldap_provider?(name)
+ name.to_s.start_with?('ldap')
+ end
+
+ def self.sync_profile_from_provider?(provider)
+ return true if ldap_provider?(provider)
+
+ providers = Gitlab.config.omniauth.sync_profile_from_provider
+
+ if providers.is_a?(Array)
+ providers.include?(provider)
+ else
+ providers
+ end
+ end
+
+ def self.config_for(name)
+ name = name.to_s
+ if ldap_provider?(name)
+ if Gitlab::Auth::LDAP::Config.valid_provider?(name)
+ Gitlab::Auth::LDAP::Config.new(name).options
+ else
+ nil
+ end
+ else
+ Gitlab.config.omniauth.providers.find { |provider| provider.name == name }
+ end
+ end
+
+ def self.label_for(name)
+ name = name.to_s
+ config = config_for(name)
+ (config && config['label']) || LABELS[name] || name.titleize
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/o_auth/session.rb b/lib/gitlab/auth/o_auth/session.rb
new file mode 100644
index 00000000000..8f2b4d58552
--- /dev/null
+++ b/lib/gitlab/auth/o_auth/session.rb
@@ -0,0 +1,21 @@
+# :nocov:
+module Gitlab
+ module Auth
+ module OAuth
+ module Session
+ def self.create(provider, ticket)
+ Rails.cache.write("gitlab:#{provider}:#{ticket}", ticket, expires_in: Gitlab.config.omniauth.cas3.session_duration)
+ end
+
+ def self.destroy(provider, ticket)
+ Rails.cache.delete("gitlab:#{provider}:#{ticket}")
+ end
+
+ def self.valid?(provider, ticket)
+ Rails.cache.read("gitlab:#{provider}:#{ticket}").present?
+ end
+ end
+ end
+ end
+end
+# :nocov:
diff --git a/lib/gitlab/auth/o_auth/user.rb b/lib/gitlab/auth/o_auth/user.rb
new file mode 100644
index 00000000000..d0c6b0386ba
--- /dev/null
+++ b/lib/gitlab/auth/o_auth/user.rb
@@ -0,0 +1,249 @@
+# OAuth extension for User model
+#
+# * Find GitLab user based on omniauth uid and provider
+# * Create new user from omniauth data
+#
+module Gitlab
+ module Auth
+ module OAuth
+ class User
+ SignupDisabledError = Class.new(StandardError)
+ SigninDisabledForProviderError = Class.new(StandardError)
+
+ attr_accessor :auth_hash, :gl_user
+
+ def initialize(auth_hash)
+ self.auth_hash = auth_hash
+ update_profile
+ add_or_update_user_identities
+ end
+
+ def persisted?
+ gl_user.try(:persisted?)
+ end
+
+ def new?
+ !persisted?
+ end
+
+ def valid?
+ gl_user.try(:valid?)
+ end
+
+ def save(provider = 'OAuth')
+ raise SigninDisabledForProviderError if oauth_provider_disabled?
+ raise SignupDisabledError unless gl_user
+
+ block_after_save = needs_blocking?
+
+ Users::UpdateService.new(gl_user, user: gl_user).execute!
+
+ gl_user.block if block_after_save
+
+ log.info "(#{provider}) saving user #{auth_hash.email} from login with extern_uid => #{auth_hash.uid}"
+ gl_user
+ rescue ActiveRecord::RecordInvalid => e
+ log.info "(#{provider}) Error saving user #{auth_hash.uid} (#{auth_hash.email}): #{gl_user.errors.full_messages}"
+ return self, e.record.errors
+ end
+
+ def gl_user
+ return @gl_user if defined?(@gl_user)
+
+ @gl_user = find_user
+ end
+
+ def find_user
+ user = find_by_uid_and_provider
+
+ user ||= find_or_build_ldap_user if auto_link_ldap_user?
+ user ||= build_new_user if signup_enabled?
+
+ user.external = true if external_provider? && user&.new_record?
+
+ user
+ end
+
+ protected
+
+ def add_or_update_user_identities
+ return unless gl_user
+
+ # 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)
+ identity.extern_uid = auth_hash.uid
+
+ if auto_link_ldap_user? && !gl_user.ldap_user? && ldap_person
+ log.info "Correct LDAP account has been found. identity to user: #{gl_user.username}."
+ gl_user.identities.build(provider: ldap_person.provider, extern_uid: ldap_person.dn)
+ end
+ end
+
+ def find_or_build_ldap_user
+ return unless ldap_person
+
+ user = Gitlab::Auth::LDAP::User.find_by_uid_and_provider(ldap_person.dn, ldap_person.provider)
+ if user
+ log.info "LDAP account found for user #{user.username}. Building new #{auth_hash.provider} identity."
+ return user
+ end
+
+ log.info "No user found using #{auth_hash.provider} provider. Creating a new one."
+ build_new_user
+ end
+
+ def find_by_email
+ return unless auth_hash.has_attribute?(:email)
+
+ ::User.find_by(email: auth_hash.email.downcase)
+ end
+
+ def auto_link_ldap_user?
+ Gitlab.config.omniauth.auto_link_ldap_user
+ end
+
+ def creating_linked_ldap_user?
+ auto_link_ldap_user? && ldap_person
+ end
+
+ def ldap_person
+ return @ldap_person if defined?(@ldap_person)
+
+ # Look for a corresponding person with same uid in any of the configured LDAP providers
+ Gitlab::Auth::LDAP::Config.providers.each do |provider|
+ adapter = Gitlab::Auth::LDAP::Adapter.new(provider)
+ @ldap_person = find_ldap_person(auth_hash, adapter)
+ break if @ldap_person
+ end
+ @ldap_person
+ end
+
+ def find_ldap_person(auth_hash, adapter)
+ Gitlab::Auth::LDAP::Person.find_by_uid(auth_hash.uid, adapter) ||
+ Gitlab::Auth::LDAP::Person.find_by_email(auth_hash.uid, adapter) ||
+ Gitlab::Auth::LDAP::Person.find_by_dn(auth_hash.uid, adapter)
+
+ rescue Gitlab::Auth::LDAP::LDAPConnectionError
+ nil
+ end
+
+ def ldap_config
+ Gitlab::Auth::LDAP::Config.new(ldap_person.provider) if ldap_person
+ end
+
+ def needs_blocking?
+ new? && block_after_signup?
+ end
+
+ def signup_enabled?
+ providers = Gitlab.config.omniauth.allow_single_sign_on
+ if providers.is_a?(Array)
+ providers.include?(auth_hash.provider)
+ else
+ providers
+ end
+ end
+
+ def external_provider?
+ Gitlab.config.omniauth.external_providers.include?(auth_hash.provider)
+ end
+
+ def block_after_signup?
+ if creating_linked_ldap_user?
+ ldap_config.block_auto_created_users
+ else
+ Gitlab.config.omniauth.block_auto_created_users
+ end
+ end
+
+ def auth_hash=(auth_hash)
+ @auth_hash = AuthHash.new(auth_hash)
+ end
+
+ def find_by_uid_and_provider
+ identity = Identity.with_extern_uid(auth_hash.provider, auth_hash.uid).take
+ identity&.user
+ end
+
+ def build_new_user
+ user_params = user_attributes.merge(skip_confirmation: true)
+ Users::BuildService.new(nil, user_params).execute(skip_authorization: true)
+ end
+
+ def user_attributes
+ # Give preference to LDAP for sensitive information when creating a linked account
+ if creating_linked_ldap_user?
+ username = ldap_person.username.presence
+ email = ldap_person.email.first.presence
+ end
+
+ username ||= auth_hash.username
+ email ||= auth_hash.email
+
+ valid_username = ::Namespace.clean_path(username)
+
+ uniquify = Uniquify.new
+ valid_username = uniquify.string(valid_username) { |s| !NamespacePathValidator.valid_path?(s) }
+
+ name = auth_hash.name
+ name = valid_username if name.strip.empty?
+
+ {
+ name: name,
+ username: valid_username,
+ email: email,
+ password: auth_hash.password,
+ password_confirmation: auth_hash.password,
+ password_automatically_set: true
+ }
+ end
+
+ def sync_profile_from_provider?
+ Gitlab::Auth::OAuth::Provider.sync_profile_from_provider?(auth_hash.provider)
+ end
+
+ def update_profile
+ clear_user_synced_attributes_metadata
+
+ return unless sync_profile_from_provider? || creating_linked_ldap_user?
+
+ metadata = gl_user.build_user_synced_attributes_metadata
+
+ if sync_profile_from_provider?
+ UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES.each do |key|
+ if auth_hash.has_attribute?(key) && gl_user.sync_attribute?(key)
+ gl_user[key] = auth_hash.public_send(key) # rubocop:disable GitlabSecurity/PublicSend
+ metadata.set_attribute_synced(key, true)
+ else
+ metadata.set_attribute_synced(key, false)
+ end
+ end
+
+ metadata.provider = auth_hash.provider
+ end
+
+ if creating_linked_ldap_user? && gl_user.email == ldap_person.email.first
+ metadata.set_attribute_synced(:email, true)
+ metadata.provider = ldap_person.provider
+ end
+ end
+
+ def clear_user_synced_attributes_metadata
+ gl_user&.user_synced_attributes_metadata&.destroy
+ end
+
+ def log
+ Gitlab::AppLogger
+ end
+
+ def oauth_provider_disabled?
+ Gitlab::CurrentSettings.current_application_settings
+ .disabled_oauth_sign_in_sources
+ .include?(auth_hash.provider)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb
index 46ec040ce92..a0b5cd868c3 100644
--- a/lib/gitlab/auth/request_authenticator.rb
+++ b/lib/gitlab/auth/request_authenticator.rb
@@ -20,6 +20,14 @@ module Gitlab
rescue Gitlab::Auth::AuthenticationError
nil
end
+
+ def valid_access_token?(scopes: [])
+ validate_access_token!(scopes: scopes)
+
+ true
+ rescue Gitlab::Auth::AuthenticationError
+ false
+ end
end
end
end
diff --git a/lib/gitlab/auth/result.rb b/lib/gitlab/auth/result.rb
index 75451cf8aa9..00cdc94a9ef 100644
--- a/lib/gitlab/auth/result.rb
+++ b/lib/gitlab/auth/result.rb
@@ -1,4 +1,4 @@
-module Gitlab
+module Gitlab # rubocop:disable Naming/FileName
module Auth
Result = Struct.new(:actor, :project, :type, :authentication_abilities) do
def ci?(for_project)
diff --git a/lib/gitlab/auth/saml/auth_hash.rb b/lib/gitlab/auth/saml/auth_hash.rb
new file mode 100644
index 00000000000..c345a7e3f6c
--- /dev/null
+++ b/lib/gitlab/auth/saml/auth_hash.rb
@@ -0,0 +1,19 @@
+module Gitlab
+ module Auth
+ module Saml
+ class AuthHash < Gitlab::Auth::OAuth::AuthHash
+ def groups
+ Array.wrap(get_raw(Gitlab::Auth::Saml::Config.groups))
+ end
+
+ private
+
+ def get_raw(key)
+ # Needs to call `all` because of https://git.io/vVo4u
+ # otherwise just the first value is returned
+ auth_hash.extra[:raw_info].all[key]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/saml/config.rb b/lib/gitlab/auth/saml/config.rb
new file mode 100644
index 00000000000..2760b1a3247
--- /dev/null
+++ b/lib/gitlab/auth/saml/config.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Auth
+ module Saml
+ class Config
+ class << self
+ def options
+ Gitlab::Auth::OAuth::Provider.config_for('saml')
+ end
+
+ def groups
+ options[:groups_attribute]
+ end
+
+ def external_groups
+ options[:external_groups]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/saml/user.rb b/lib/gitlab/auth/saml/user.rb
new file mode 100644
index 00000000000..d4024e9ec39
--- /dev/null
+++ b/lib/gitlab/auth/saml/user.rb
@@ -0,0 +1,52 @@
+# SAML extension for User model
+#
+# * Find GitLab user based on SAML uid and provider
+# * Create new user from SAML data
+#
+module Gitlab
+ module Auth
+ module Saml
+ class User < Gitlab::Auth::OAuth::User
+ def save
+ super('SAML')
+ end
+
+ def find_user
+ user = find_by_uid_and_provider
+
+ user ||= find_by_email if auto_link_saml_user?
+ user ||= find_or_build_ldap_user if auto_link_ldap_user?
+ user ||= build_new_user if signup_enabled?
+
+ if external_users_enabled? && user
+ # Check if there is overlap between the user's groups and the external groups
+ # setting then set user as external or internal.
+ user.external = !(auth_hash.groups & Gitlab::Auth::Saml::Config.external_groups).empty?
+ end
+
+ user
+ end
+
+ def changed?
+ return true unless gl_user
+
+ gl_user.changed? || gl_user.identities.any?(&:changed?)
+ end
+
+ protected
+
+ def auto_link_saml_user?
+ Gitlab.config.omniauth.auto_link_saml_user
+ end
+
+ def external_users_enabled?
+ !Gitlab::Auth::Saml::Config.external_groups.nil?
+ end
+
+ def auth_hash=(auth_hash)
+ @auth_hash = Gitlab::Auth::Saml::AuthHash.new(auth_hash)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/auth/user_auth_finders.rb b/lib/gitlab/auth/user_auth_finders.rb
index b4114a3ac96..cf02030c577 100644
--- a/lib/gitlab/auth/user_auth_finders.rb
+++ b/lib/gitlab/auth/user_auth_finders.rb
@@ -96,9 +96,7 @@ module Gitlab
end
def ensure_action_dispatch_request(request)
- return request if request.is_a?(ActionDispatch::Request)
-
- ActionDispatch::Request.new(request.env)
+ ActionDispatch::Request.new(request.env.dup)
end
def current_request
diff --git a/lib/gitlab/background_migration/add_merge_request_diff_commits_count.rb b/lib/gitlab/background_migration/add_merge_request_diff_commits_count.rb
new file mode 100644
index 00000000000..d5cf9e0d53a
--- /dev/null
+++ b/lib/gitlab/background_migration/add_merge_request_diff_commits_count.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+# rubocop:disable Metrics/LineLength
+
+module Gitlab
+ module BackgroundMigration
+ class AddMergeRequestDiffCommitsCount
+ class MergeRequestDiff < ActiveRecord::Base
+ self.table_name = 'merge_request_diffs'
+ end
+
+ def perform(start_id, stop_id)
+ Rails.logger.info("Setting commits_count for merge request diffs: #{start_id} - #{stop_id}")
+
+ update = '
+ commits_count = (
+ SELECT count(*)
+ FROM merge_request_diff_commits
+ WHERE merge_request_diffs.id = merge_request_diff_commits.merge_request_diff_id
+ )'.squish
+
+ MergeRequestDiff.where(id: start_id..stop_id).where(commits_count: nil).update_all(update)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/cleanup_concurrent_type_change.rb b/lib/gitlab/background_migration/cleanup_concurrent_type_change.rb
new file mode 100644
index 00000000000..de622f657b2
--- /dev/null
+++ b/lib/gitlab/background_migration/cleanup_concurrent_type_change.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ # Background migration for cleaning up a concurrent column rename.
+ class CleanupConcurrentTypeChange
+ include Database::MigrationHelpers
+
+ RESCHEDULE_DELAY = 10.minutes
+
+ # table - The name of the table the migration is performed for.
+ # old_column - The name of the old (to drop) column.
+ # new_column - The name of the new column.
+ def perform(table, old_column, new_column)
+ return unless column_exists?(:issues, new_column)
+
+ rows_to_migrate = define_model_for(table)
+ .where(new_column => nil)
+ .where
+ .not(old_column => nil)
+
+ if rows_to_migrate.any?
+ BackgroundMigrationWorker.perform_in(
+ RESCHEDULE_DELAY,
+ 'CleanupConcurrentTypeChange',
+ [table, old_column, new_column]
+ )
+ else
+ cleanup_concurrent_column_type_change(table, old_column)
+ end
+ end
+
+ # These methods are necessary so we can re-use the migration helpers in
+ # this class.
+ def connection
+ ActiveRecord::Base.connection
+ end
+
+ def method_missing(name, *args, &block)
+ connection.__send__(name, *args, &block) # rubocop: disable GitlabSecurity/PublicSend
+ end
+
+ def respond_to_missing?(*args)
+ connection.respond_to?(*args) || super
+ end
+
+ def define_model_for(table)
+ Class.new(ActiveRecord::Base) do
+ self.table_name = table
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/copy_column.rb b/lib/gitlab/background_migration/copy_column.rb
new file mode 100644
index 00000000000..ef70f37d5eb
--- /dev/null
+++ b/lib/gitlab/background_migration/copy_column.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ # CopyColumn is a simple (reusable) background migration that can be used to
+ # update the value of a column based on the value of another column in the
+ # same table.
+ #
+ # For this background migration to work the table that is migrated _has_ to
+ # have an `id` column as the primary key.
+ class CopyColumn
+ # table - The name of the table that contains the columns.
+ # copy_from - The column containing the data to copy.
+ # copy_to - The column to copy the data to.
+ # start_id - The start ID of the range of rows to update.
+ # end_id - The end ID of the range of rows to update.
+ def perform(table, copy_from, copy_to, start_id, end_id)
+ return unless connection.column_exists?(table, copy_to)
+
+ quoted_table = connection.quote_table_name(table)
+ quoted_copy_from = connection.quote_column_name(copy_from)
+ quoted_copy_to = connection.quote_column_name(copy_to)
+
+ # We're using raw SQL here since this job may be frequently executed. As
+ # a result dynamically defining models would lead to many unnecessary
+ # schema information queries.
+ connection.execute <<-SQL.strip_heredoc
+ UPDATE #{quoted_table}
+ SET #{quoted_copy_to} = #{quoted_copy_from}
+ WHERE id BETWEEN #{start_id} AND #{end_id}
+ AND #{quoted_copy_from} IS NOT NULL
+ AND #{quoted_copy_to} IS NULL
+ SQL
+ end
+
+ def connection
+ ActiveRecord::Base.connection
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/create_fork_network_memberships_range.rb b/lib/gitlab/background_migration/create_fork_network_memberships_range.rb
index 03b17b319fa..1b4a9e8a194 100644
--- a/lib/gitlab/background_migration/create_fork_network_memberships_range.rb
+++ b/lib/gitlab/background_migration/create_fork_network_memberships_range.rb
@@ -14,6 +14,14 @@ module Gitlab
def perform(start_id, end_id)
log("Creating memberships for forks: #{start_id} - #{end_id}")
+ insert_members(start_id, end_id)
+
+ if missing_members?(start_id, end_id)
+ BackgroundMigrationWorker.perform_in(RESCHEDULE_DELAY, "CreateForkNetworkMembershipsRange", [start_id, end_id])
+ end
+ end
+
+ def insert_members(start_id, end_id)
ActiveRecord::Base.connection.execute <<~INSERT_MEMBERS
INSERT INTO fork_network_members (fork_network_id, project_id, forked_from_project_id)
@@ -33,10 +41,9 @@ module Gitlab
WHERE existing_members.project_id = forked_project_links.forked_to_project_id
)
INSERT_MEMBERS
-
- if missing_members?(start_id, end_id)
- BackgroundMigrationWorker.perform_in(RESCHEDULE_DELAY, "CreateForkNetworkMembershipsRange", [start_id, end_id])
- end
+ rescue ActiveRecord::RecordNotUnique => e
+ # `fork_network_member` was created concurrently in another migration
+ log(e.message)
end
def missing_members?(start_id, end_id)
diff --git a/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range.rb b/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range.rb
index a1af045a71f..21b626dde56 100644
--- a/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range.rb
+++ b/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range.rb
@@ -1,44 +1,12 @@
# frozen_string_literal: true
-# rubocop:disable Metrics/LineLength
# rubocop:disable Style/Documentation
module Gitlab
module BackgroundMigration
class DeleteConflictingRedirectRoutesRange
- class Route < ActiveRecord::Base
- self.table_name = 'routes'
- end
-
- class RedirectRoute < ActiveRecord::Base
- self.table_name = 'redirect_routes'
- 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?
-
- conflicts = RedirectRoute.where(routes_match_redirects_clause(start_id, end_id))
- num_rows = conflicts.delete_all
-
- Rails.logger.info("Gitlab::BackgroundMigration::DeleteConflictingRedirectRoutesRange [#{start_id}, #{end_id}] - Deleted #{num_rows} redirect routes that were conflicting with routes.")
- end
-
- def migrate?
- Route.table_exists? && RedirectRoute.table_exists?
- end
-
- def routes_match_redirects_clause(start_id, end_id)
- <<~ROUTES_MATCH_REDIRECTS
- EXISTS (
- SELECT 1 FROM routes
- WHERE (
- LOWER(redirect_routes.path) = LOWER(routes.path)
- OR LOWER(redirect_routes.path) LIKE LOWER(CONCAT(routes.path, '/%'))
- )
- AND routes.id BETWEEN #{start_id} AND #{end_id}
- )
- ROUTES_MATCH_REDIRECTS
+ # No-op.
+ # See https://gitlab.com/gitlab-com/infrastructure/issues/3460#note_53223252
end
end
end
diff --git a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
index fd5cbf76e47..a357538a885 100644
--- a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
+++ b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb
@@ -96,7 +96,7 @@ module Gitlab
commit_hash.merge(
merge_request_diff_id: merge_request_diff.id,
relative_order: index,
- sha: sha_attribute.type_cast_for_database(sha)
+ sha: sha_attribute.serialize(sha)
)
end
diff --git a/lib/gitlab/background_migration/migrate_build_stage.rb b/lib/gitlab/background_migration/migrate_build_stage.rb
new file mode 100644
index 00000000000..242e3143e71
--- /dev/null
+++ b/lib/gitlab/background_migration/migrate_build_stage.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+# rubocop:disable Metrics/AbcSize
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ class MigrateBuildStage
+ module Migratable
+ class Stage < ActiveRecord::Base
+ self.table_name = 'ci_stages'
+ end
+
+ class Build < ActiveRecord::Base
+ self.table_name = 'ci_builds'
+ self.inheritance_column = :_type_disabled
+
+ def ensure_stage!(attempts: 2)
+ find_stage || create_stage!
+ rescue ActiveRecord::RecordNotUnique
+ retry if (attempts -= 1) > 0
+ raise
+ end
+
+ def find_stage
+ Stage.find_by(name: self.stage || 'test',
+ pipeline_id: self.commit_id,
+ project_id: self.project_id)
+ end
+
+ def create_stage!
+ Stage.create!(name: self.stage || 'test',
+ pipeline_id: self.commit_id,
+ project_id: self.project_id)
+ end
+ end
+ end
+
+ def perform(start_id, stop_id)
+ stages = Migratable::Build.where('stage_id IS NULL')
+ .where('id BETWEEN ? AND ?', start_id, stop_id)
+ .map { |build| build.ensure_stage! }
+ .compact.map(&:id)
+
+ MigrateBuildStageIdReference.new.perform(start_id, stop_id)
+ MigrateStageStatus.new.perform(stages.min, stages.max)
+ 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
index 84ac00f1a5c..7088aa0860a 100644
--- a/lib/gitlab/background_migration/migrate_events_to_push_event_payloads.rb
+++ b/lib/gitlab/background_migration/migrate_events_to_push_event_payloads.rb
@@ -128,8 +128,14 @@ module Gitlab
end
def process_event(event)
- replicate_event(event)
- create_push_event_payload(event) if event.push_event?
+ ActiveRecord::Base.transaction do
+ replicate_event(event)
+ create_push_event_payload(event) if event.push_event?
+ end
+ rescue ActiveRecord::InvalidForeignKey => e
+ # A foreign key error means the associated event was removed. In this
+ # case we'll just skip migrating the event.
+ Rails.logger.error("Unable to migrate event #{event.id}: #{e}")
end
def replicate_event(event)
@@ -137,9 +143,6 @@ module Gitlab
.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)
@@ -156,9 +159,6 @@ module Gitlab
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)
diff --git a/lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb b/lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb
index 85749366bfd..d9d3d2e667b 100644
--- a/lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb
+++ b/lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb
@@ -16,281 +16,283 @@ module Gitlab
# And if the normalize behavior is changed in the future, it must be
# accompanied by another migration.
module Gitlab
- module LDAP
- class DN
- FormatError = Class.new(StandardError)
- MalformedError = Class.new(FormatError)
- UnsupportedError = Class.new(FormatError)
+ module Auth
+ module LDAP
+ class DN
+ FormatError = Class.new(StandardError)
+ MalformedError = Class.new(FormatError)
+ UnsupportedError = Class.new(FormatError)
- def self.normalize_value(given_value)
- dummy_dn = "placeholder=#{given_value}"
- normalized_dn = new(*dummy_dn).to_normalized_s
- normalized_dn.sub(/\Aplaceholder=/, '')
- end
+ def self.normalize_value(given_value)
+ dummy_dn = "placeholder=#{given_value}"
+ normalized_dn = new(*dummy_dn).to_normalized_s
+ normalized_dn.sub(/\Aplaceholder=/, '')
+ end
- ##
- # Initialize a DN, escaping as required. Pass in attributes in name/value
- # pairs. If there is a left over argument, it will be appended to the dn
- # without escaping (useful for a base string).
- #
- # Most uses of this class will be to escape a DN, rather than to parse it,
- # so storing the dn as an escaped String and parsing parts as required
- # with a state machine seems sensible.
- def initialize(*args)
- if args.length > 1
- initialize_array(args)
- else
- initialize_string(args[0])
+ ##
+ # Initialize a DN, escaping as required. Pass in attributes in name/value
+ # pairs. If there is a left over argument, it will be appended to the dn
+ # without escaping (useful for a base string).
+ #
+ # Most uses of this class will be to escape a DN, rather than to parse it,
+ # so storing the dn as an escaped String and parsing parts as required
+ # with a state machine seems sensible.
+ def initialize(*args)
+ if args.length > 1
+ initialize_array(args)
+ else
+ initialize_string(args[0])
+ end
end
- end
- ##
- # Parse a DN into key value pairs using ASN from
- # http://tools.ietf.org/html/rfc2253 section 3.
- # rubocop:disable Metrics/AbcSize
- # rubocop:disable Metrics/CyclomaticComplexity
- # rubocop:disable Metrics/PerceivedComplexity
- def each_pair
- state = :key
- key = StringIO.new
- value = StringIO.new
- hex_buffer = ""
+ ##
+ # Parse a DN into key value pairs using ASN from
+ # http://tools.ietf.org/html/rfc2253 section 3.
+ # rubocop:disable Metrics/AbcSize
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def each_pair
+ state = :key
+ key = StringIO.new
+ value = StringIO.new
+ hex_buffer = ""
- @dn.each_char.with_index do |char, dn_index|
- case state
- when :key then
- case char
- when 'a'..'z', 'A'..'Z' then
- state = :key_normal
- key << char
- when '0'..'9' then
- state = :key_oid
- key << char
- when ' ' then state = :key
- else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"")
- end
- when :key_normal then
- case char
- when '=' then state = :value
- when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char
- else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"")
- end
- when :key_oid then
- case char
- when '=' then state = :value
- when '0'..'9', '.', ' ' then key << char
- else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"")
- end
- when :value then
- case char
- when '\\' then state = :value_normal_escape
- when '"' then state = :value_quoted
- when ' ' then state = :value
- when '#' then
- state = :value_hexstring
- value << char
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- else
- state = :value_normal
- value << char
- end
- when :value_normal then
- case char
- when '\\' then state = :value_normal_escape
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported")
- else value << char
- end
- when :value_normal_escape then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_normal_escape_hex
- hex_buffer = char
- else
- state = :value_normal
- value << char
+ @dn.each_char.with_index do |char, dn_index|
+ case state
+ when :key then
+ case char
+ when 'a'..'z', 'A'..'Z' then
+ state = :key_normal
+ key << char
+ when '0'..'9' then
+ state = :key_oid
+ key << char
+ when ' ' then state = :key
+ else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"")
+ end
+ when :key_normal then
+ case char
+ when '=' then state = :value
+ when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char
+ else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"")
+ end
+ when :key_oid then
+ case char
+ when '=' then state = :value
+ when '0'..'9', '.', ' ' then key << char
+ else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"")
+ end
+ when :value then
+ case char
+ when '\\' then state = :value_normal_escape
+ when '"' then state = :value_quoted
+ when ' ' then state = :value
+ when '#' then
+ state = :value_hexstring
+ value << char
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ else
+ state = :value_normal
+ value << char
+ end
+ when :value_normal then
+ case char
+ when '\\' then state = :value_normal_escape
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported")
+ else value << char
+ end
+ when :value_normal_escape then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_normal_escape_hex
+ hex_buffer = char
+ else
+ state = :value_normal
+ value << char
+ end
+ when :value_normal_escape_hex then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_normal
+ value << "#{hex_buffer}#{char}".to_i(16).chr
+ else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"")
+ end
+ when :value_quoted then
+ case char
+ when '\\' then state = :value_quoted_escape
+ when '"' then state = :value_end
+ else value << char
+ end
+ when :value_quoted_escape then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_quoted_escape_hex
+ hex_buffer = char
+ else
+ state = :value_quoted
+ value << char
+ end
+ when :value_quoted_escape_hex then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_quoted
+ value << "#{hex_buffer}#{char}".to_i(16).chr
+ else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"")
+ end
+ when :value_hexstring then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_hexstring_hex
+ value << char
+ when ' ' then state = :value_end
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"")
+ end
+ when :value_hexstring_hex then
+ case char
+ when '0'..'9', 'a'..'f', 'A'..'F' then
+ state = :value_hexstring
+ value << char
+ else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"")
+ end
+ when :value_end then
+ case char
+ when ' ' then state = :value_end
+ when ',' then
+ state = :key
+ yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
+ key = StringIO.new
+ value = StringIO.new
+ else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"")
+ end
+ else raise "Fell out of state machine"
end
- when :value_normal_escape_hex then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_normal
- value << "#{hex_buffer}#{char}".to_i(16).chr
- else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"")
- end
- when :value_quoted then
- case char
- when '\\' then state = :value_quoted_escape
- when '"' then state = :value_end
- else value << char
- end
- when :value_quoted_escape then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_quoted_escape_hex
- hex_buffer = char
- else
- state = :value_quoted
- value << char
- end
- when :value_quoted_escape_hex then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_quoted
- value << "#{hex_buffer}#{char}".to_i(16).chr
- else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"")
- end
- when :value_hexstring then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_hexstring_hex
- value << char
- when ' ' then state = :value_end
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"")
- end
- when :value_hexstring_hex then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_hexstring
- value << char
- else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"")
- end
- when :value_end then
- case char
- when ' ' then state = :value_end
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"")
- end
- else raise "Fell out of state machine"
end
- end
- # Last pair
- raise(MalformedError, 'DN string ended unexpectedly') unless
- [:value, :value_normal, :value_hexstring, :value_end].include? state
+ # Last pair
+ raise(MalformedError, 'DN string ended unexpectedly') unless
+ [:value, :value_normal, :value_hexstring, :value_end].include? state
- yield key.string.strip, rstrip_except_escaped(value.string, @dn.length)
- end
+ yield key.string.strip, rstrip_except_escaped(value.string, @dn.length)
+ end
- def rstrip_except_escaped(str, dn_index)
- str_ends_with_whitespace = str.match(/\s\z/)
+ def rstrip_except_escaped(str, dn_index)
+ str_ends_with_whitespace = str.match(/\s\z/)
- if str_ends_with_whitespace
- dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/)
+ if str_ends_with_whitespace
+ dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/)
- if dn_part_ends_with_escaped_whitespace
- dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1]
- num_chars_to_remove = dn_part_rwhitespace.length - 1
- str = str[0, str.length - num_chars_to_remove]
- else
- str.rstrip!
+ if dn_part_ends_with_escaped_whitespace
+ dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1]
+ num_chars_to_remove = dn_part_rwhitespace.length - 1
+ str = str[0, str.length - num_chars_to_remove]
+ else
+ str.rstrip!
+ end
end
- end
- str
- end
+ str
+ end
- ##
- # Returns the DN as an array in the form expected by the constructor.
- def to_a
- a = []
- self.each_pair { |key, value| a << key << value } unless @dn.empty?
- a
- end
+ ##
+ # Returns the DN as an array in the form expected by the constructor.
+ def to_a
+ a = []
+ self.each_pair { |key, value| a << key << value } unless @dn.empty?
+ a
+ end
- ##
- # Return the DN as an escaped string.
- def to_s
- @dn
- end
+ ##
+ # Return the DN as an escaped string.
+ def to_s
+ @dn
+ end
- ##
- # Return the DN as an escaped and normalized string.
- def to_normalized_s
- self.class.new(*to_a).to_s.downcase
- end
+ ##
+ # Return the DN as an escaped and normalized string.
+ def to_normalized_s
+ self.class.new(*to_a).to_s.downcase
+ end
- # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions
- # for DN values. All of the following must be escaped in any normal string
- # using a single backslash ('\') as escape. The space character is left
- # out here because in a "normalized" string, spaces should only be escaped
- # if necessary (i.e. leading or trailing space).
- NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze
+ # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions
+ # for DN values. All of the following must be escaped in any normal string
+ # using a single backslash ('\') as escape. The space character is left
+ # out here because in a "normalized" string, spaces should only be escaped
+ # if necessary (i.e. leading or trailing space).
+ NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze
- # The following must be represented as escaped hex
- HEX_ESCAPES = {
- "\n" => '\0a',
- "\r" => '\0d'
- }.freeze
+ # The following must be represented as escaped hex
+ HEX_ESCAPES = {
+ "\n" => '\0a',
+ "\r" => '\0d'
+ }.freeze
- # Compiled character class regexp using the keys from the above hash, and
- # checking for a space or # at the start, or space at the end, of the
- # string.
- ESCAPE_RE = Regexp.new("(^ |^#| $|[" +
- NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join +
- "])")
+ # Compiled character class regexp using the keys from the above hash, and
+ # checking for a space or # at the start, or space at the end, of the
+ # string.
+ ESCAPE_RE = Regexp.new("(^ |^#| $|[" +
+ NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join +
+ "])")
- HEX_ESCAPE_RE = Regexp.new("([" +
- HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join +
- "])")
+ HEX_ESCAPE_RE = Regexp.new("([" +
+ HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join +
+ "])")
- ##
- # Escape a string for use in a DN value
- def self.escape(string)
- escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char }
- escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] }
- end
+ ##
+ # Escape a string for use in a DN value
+ def self.escape(string)
+ escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char }
+ escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] }
+ end
- private
+ private
- def initialize_array(args)
- buffer = StringIO.new
+ def initialize_array(args)
+ buffer = StringIO.new
- args.each_with_index do |arg, index|
- if index.even? # key
- buffer << "," if index > 0
- buffer << arg
- else # value
- buffer << "="
- buffer << self.class.escape(arg)
+ args.each_with_index do |arg, index|
+ if index.even? # key
+ buffer << "," if index > 0
+ buffer << arg
+ else # value
+ buffer << "="
+ buffer << self.class.escape(arg)
+ end
end
- end
- @dn = buffer.string
- end
+ @dn = buffer.string
+ end
- def initialize_string(arg)
- @dn = arg.to_s
- end
+ def initialize_string(arg)
+ @dn = arg.to_s
+ end
- ##
- # Proxy all other requests to the string object, because a DN is mainly
- # used within the library as a string
- # rubocop:disable GitlabSecurity/PublicSend
- def method_missing(method, *args, &block)
- @dn.send(method, *args, &block)
- end
+ ##
+ # Proxy all other requests to the string object, because a DN is mainly
+ # used within the library as a string
+ # rubocop:disable GitlabSecurity/PublicSend
+ def method_missing(method, *args, &block)
+ @dn.send(method, *args, &block)
+ end
- ##
- # Redefined to be consistent with redefined `method_missing` behavior
- def respond_to?(sym, include_private = false)
- @dn.respond_to?(sym, include_private)
+ ##
+ # Redefined to be consistent with redefined `method_missing` behavior
+ def respond_to?(sym, include_private = false)
+ @dn.respond_to?(sym, include_private)
+ end
end
end
end
@@ -302,11 +304,11 @@ module Gitlab
ldap_identities = Identity.where("provider like 'ldap%'").where(id: start_id..end_id)
ldap_identities.each do |identity|
begin
- identity.extern_uid = Gitlab::LDAP::DN.new(identity.extern_uid).to_normalized_s
+ identity.extern_uid = Gitlab::Auth::LDAP::DN.new(identity.extern_uid).to_normalized_s
unless identity.save
Rails.logger.info "Unable to normalize \"#{identity.extern_uid}\". Skipping."
end
- rescue Gitlab::LDAP::DN::FormatError => e
+ rescue Gitlab::Auth::LDAP::DN::FormatError => e
Rails.logger.info "Unable to normalize \"#{identity.extern_uid}\" due to \"#{e.message}\". Skipping."
end
end
diff --git a/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data.rb b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data.rb
new file mode 100644
index 00000000000..8a901a9bf39
--- /dev/null
+++ b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+# rubocop:disable Metrics/LineLength
+# rubocop:disable Metrics/MethodLength
+# rubocop:disable Metrics/ClassLength
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ class PopulateMergeRequestMetricsWithEventsData
+ def perform(min_merge_request_id, max_merge_request_id)
+ insert_metrics_for_range(min_merge_request_id, max_merge_request_id)
+ update_metrics_with_events_data(min_merge_request_id, max_merge_request_id)
+ end
+
+ # Inserts merge_request_metrics records for merge_requests without it for
+ # a given merge request batch.
+ def insert_metrics_for_range(min, max)
+ metrics_not_exists_clause =
+ <<-SQL.strip_heredoc
+ NOT EXISTS (SELECT 1 FROM merge_request_metrics
+ WHERE merge_request_metrics.merge_request_id = merge_requests.id)
+ SQL
+
+ MergeRequest.where(metrics_not_exists_clause).where(id: min..max).each_batch do |batch|
+ select_sql = batch.select(:id, :created_at, :updated_at).to_sql
+
+ execute("INSERT INTO merge_request_metrics (merge_request_id, created_at, updated_at) #{select_sql}")
+ end
+ end
+
+ def update_metrics_with_events_data(min, max)
+ if Gitlab::Database.postgresql?
+ # Uses WITH syntax in order to update merged and closed events with a single UPDATE.
+ # WITH is not supported by MySQL.
+ update_events_for_range(min, max)
+ else
+ update_merged_events_for_range(min, max)
+ update_closed_events_for_range(min, max)
+ end
+ end
+
+ private
+
+ # Updates merge_request_metrics latest_closed_at, latest_closed_by_id and merged_by_id
+ # based on the latest event records on events table for a given merge request batch.
+ def update_events_for_range(min, max)
+ sql = <<-SQL.strip_heredoc
+ WITH events_for_update AS (
+ SELECT DISTINCT ON (target_id, action) target_id, action, author_id, updated_at
+ FROM events
+ WHERE target_id BETWEEN #{min} AND #{max}
+ AND target_type = 'MergeRequest'
+ AND action IN (#{Event::CLOSED},#{Event::MERGED})
+ ORDER BY target_id, action, id DESC
+ )
+ UPDATE merge_request_metrics met
+ SET latest_closed_at = latest_closed.updated_at,
+ latest_closed_by_id = latest_closed.author_id,
+ merged_by_id = latest_merged.author_id
+ FROM (SELECT * FROM events_for_update WHERE action = #{Event::CLOSED}) AS latest_closed
+ FULL OUTER JOIN
+ (SELECT * FROM events_for_update WHERE action = #{Event::MERGED}) AS latest_merged
+ USING (target_id)
+ WHERE target_id = merge_request_id;
+ SQL
+
+ execute(sql)
+ end
+
+ # Updates merge_request_metrics latest_closed_at, latest_closed_by_id based on the latest closed
+ # records on events table for a given merge request batch.
+ def update_closed_events_for_range(min, max)
+ sql =
+ <<-SQL.strip_heredoc
+ UPDATE merge_request_metrics metrics,
+ (#{select_events(min, max, Event::CLOSED)}) closed_events
+ SET metrics.latest_closed_by_id = closed_events.author_id,
+ metrics.latest_closed_at = closed_events.updated_at #{where_matches_closed_events};
+ SQL
+
+ execute(sql)
+ end
+
+ # Updates merge_request_metrics merged_by_id based on the latest merged
+ # records on events table for a given merge request batch.
+ def update_merged_events_for_range(min, max)
+ sql =
+ <<-SQL.strip_heredoc
+ UPDATE merge_request_metrics metrics,
+ (#{select_events(min, max, Event::MERGED)}) merged_events
+ SET metrics.merged_by_id = merged_events.author_id #{where_matches_merged_events};
+ SQL
+
+ execute(sql)
+ end
+
+ def execute(sql)
+ @connection ||= ActiveRecord::Base.connection
+ @connection.execute(sql)
+ end
+
+ def select_events(min, max, action)
+ select_max_event_id = <<-SQL.strip_heredoc
+ SELECT max(id)
+ FROM events
+ WHERE action = #{action}
+ AND target_type = 'MergeRequest'
+ AND target_id BETWEEN #{min} AND #{max}
+ GROUP BY target_id
+ SQL
+
+ <<-SQL.strip_heredoc
+ SELECT author_id, updated_at, target_id
+ FROM events
+ WHERE id IN(#{select_max_event_id})
+ SQL
+ end
+
+ def where_matches_closed_events
+ <<-SQL.strip_heredoc
+ WHERE metrics.merge_request_id = closed_events.target_id
+ AND metrics.latest_closed_at IS NULL
+ AND metrics.latest_closed_by_id IS NULL
+ SQL
+ end
+
+ def where_matches_merged_events
+ <<-SQL.strip_heredoc
+ WHERE metrics.merge_request_id = merged_events.target_id
+ AND metrics.merged_by_id IS NULL
+ SQL
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/populate_untracked_uploads.rb b/lib/gitlab/background_migration/populate_untracked_uploads.rb
index 81e95e5832d..9232f20a063 100644
--- a/lib/gitlab/background_migration/populate_untracked_uploads.rb
+++ b/lib/gitlab/background_migration/populate_untracked_uploads.rb
@@ -5,157 +5,10 @@ module Gitlab
# This class processes a batch of rows in `untracked_files_for_uploads` by
# adding each file to the `uploads` table if it does not exist.
class PopulateUntrackedUploads # rubocop:disable Metrics/ClassLength
- # This class is responsible for producing the attributes necessary to
- # track an uploaded file in the `uploads` table.
- class UntrackedFile < ActiveRecord::Base # rubocop:disable Metrics/ClassLength, Metrics/LineLength
- self.table_name = 'untracked_files_for_uploads'
-
- # Ends with /:random_hex/:filename
- FILE_UPLOADER_PATH = %r{/\h+/[^/]+\z}
- FULL_PATH_CAPTURE = %r{\A(.+)#{FILE_UPLOADER_PATH}}
-
- # These regex patterns are tested against a relative path, relative to
- # the upload directory.
- # For convenience, if there exists a capture group in the pattern, then
- # it indicates the model_id.
- PATH_PATTERNS = [
- {
- pattern: %r{\A-/system/appearance/logo/(\d+)/},
- uploader: 'AttachmentUploader',
- model_type: 'Appearance'
- },
- {
- pattern: %r{\A-/system/appearance/header_logo/(\d+)/},
- uploader: 'AttachmentUploader',
- model_type: 'Appearance'
- },
- {
- pattern: %r{\A-/system/note/attachment/(\d+)/},
- uploader: 'AttachmentUploader',
- model_type: 'Note'
- },
- {
- pattern: %r{\A-/system/user/avatar/(\d+)/},
- uploader: 'AvatarUploader',
- model_type: 'User'
- },
- {
- pattern: %r{\A-/system/group/avatar/(\d+)/},
- uploader: 'AvatarUploader',
- model_type: 'Namespace'
- },
- {
- pattern: %r{\A-/system/project/avatar/(\d+)/},
- uploader: 'AvatarUploader',
- model_type: 'Project'
- },
- {
- pattern: FILE_UPLOADER_PATH,
- uploader: 'FileUploader',
- model_type: 'Project'
- }
- ].freeze
-
- def to_h
- @upload_hash ||= {
- path: upload_path,
- uploader: uploader,
- model_type: model_type,
- model_id: model_id,
- size: file_size,
- checksum: checksum
- }
- end
-
- def upload_path
- # UntrackedFile#path is absolute, but Upload#path depends on uploader
- @upload_path ||=
- if uploader == 'FileUploader'
- # Path relative to project directory in uploads
- matchd = path_relative_to_upload_dir.match(FILE_UPLOADER_PATH)
- matchd[0].sub(%r{\A/}, '') # remove leading slash
- else
- path
- end
- end
-
- def uploader
- matching_pattern_map[:uploader]
- end
-
- def model_type
- matching_pattern_map[:model_type]
- end
-
- def model_id
- return @model_id if defined?(@model_id)
-
- pattern = matching_pattern_map[:pattern]
- matchd = path_relative_to_upload_dir.match(pattern)
-
- # If something is captured (matchd[1] is not nil), it is a model_id
- # Only the FileUploader pattern will not match an ID
- @model_id = matchd[1] ? matchd[1].to_i : file_uploader_model_id
- end
-
- def file_size
- File.size(absolute_path)
- end
-
- def checksum
- Digest::SHA256.file(absolute_path).hexdigest
- end
-
- private
-
- def matching_pattern_map
- @matching_pattern_map ||= PATH_PATTERNS.find do |path_pattern_map|
- path_relative_to_upload_dir.match(path_pattern_map[:pattern])
- end
-
- unless @matching_pattern_map
- raise "Unknown upload path pattern \"#{path}\""
- end
-
- @matching_pattern_map
- end
-
- def file_uploader_model_id
- matchd = path_relative_to_upload_dir.match(FULL_PATH_CAPTURE)
- not_found_msg = <<~MSG
- Could not capture project full_path from a FileUploader path:
- "#{path_relative_to_upload_dir}"
- MSG
- raise not_found_msg unless matchd
-
- full_path = matchd[1]
- project = Project.find_by_full_path(full_path)
- return nil unless project
-
- project.id
- end
-
- # Not including a leading slash
- def path_relative_to_upload_dir
- upload_dir = Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR # rubocop:disable Metrics/LineLength
- base = %r{\A#{Regexp.escape(upload_dir)}/}
- @path_relative_to_upload_dir ||= path.sub(base, '')
- end
-
- def absolute_path
- File.join(CarrierWave.root, path)
- end
- end
-
- # This class is used to query the `uploads` table.
- class Upload < ActiveRecord::Base
- self.table_name = 'uploads'
- end
-
def perform(start_id, end_id)
return unless migrate?
- files = UntrackedFile.where(id: start_id..end_id)
+ files = Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::UntrackedFile.where(id: start_id..end_id)
processed_files = insert_uploads_if_needed(files)
processed_files.delete_all
@@ -165,7 +18,8 @@ module Gitlab
private
def migrate?
- UntrackedFile.table_exists? && Upload.table_exists?
+ Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::UntrackedFile.table_exists? &&
+ Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::Upload.table_exists?
end
def insert_uploads_if_needed(files)
@@ -197,7 +51,7 @@ module Gitlab
def filter_existing_uploads(files)
paths = files.map(&:upload_path)
- existing_paths = Upload.where(path: paths).pluck(:path).to_set
+ existing_paths = Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::Upload.where(path: paths).pluck(:path).to_set
files.reject do |file|
existing_paths.include?(file.upload_path)
@@ -229,7 +83,7 @@ module Gitlab
end
ids.each do |model_type, model_ids|
- model_class = Object.const_get(model_type)
+ model_class = "Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::#{model_type}".constantize
found_ids = model_class.where(id: model_ids.uniq).pluck(:id)
deleted_ids = ids[model_type] - found_ids
ids[model_type] = deleted_ids
@@ -249,8 +103,8 @@ module Gitlab
end
def drop_temp_table_if_finished
- if UntrackedFile.all.empty?
- UntrackedFile.connection.drop_table(:untracked_files_for_uploads,
+ if Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::UntrackedFile.all.empty? && !Rails.env.test? # Dropping a table intermittently breaks test cleanup
+ Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::UntrackedFile.connection.drop_table(:untracked_files_for_uploads,
if_exists: true)
end
end
diff --git a/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb
new file mode 100644
index 00000000000..a2c5acbde71
--- /dev/null
+++ b/lib/gitlab/background_migration/populate_untracked_uploads_dependencies.rb
@@ -0,0 +1,201 @@
+# frozen_string_literal: true
+module Gitlab
+ module BackgroundMigration
+ module PopulateUntrackedUploadsDependencies
+ # This class is responsible for producing the attributes necessary to
+ # track an uploaded file in the `uploads` table.
+ class UntrackedFile < ActiveRecord::Base # rubocop:disable Metrics/ClassLength, Metrics/LineLength
+ self.table_name = 'untracked_files_for_uploads'
+
+ # Ends with /:random_hex/:filename
+ FILE_UPLOADER_PATH = %r{/\h+/[^/]+\z}
+ FULL_PATH_CAPTURE = /\A(.+)#{FILE_UPLOADER_PATH}/
+
+ # These regex patterns are tested against a relative path, relative to
+ # the upload directory.
+ # For convenience, if there exists a capture group in the pattern, then
+ # it indicates the model_id.
+ PATH_PATTERNS = [
+ {
+ pattern: %r{\A-/system/appearance/logo/(\d+)/},
+ uploader: 'AttachmentUploader',
+ model_type: 'Appearance'
+ },
+ {
+ pattern: %r{\A-/system/appearance/header_logo/(\d+)/},
+ uploader: 'AttachmentUploader',
+ model_type: 'Appearance'
+ },
+ {
+ pattern: %r{\A-/system/note/attachment/(\d+)/},
+ uploader: 'AttachmentUploader',
+ model_type: 'Note'
+ },
+ {
+ pattern: %r{\A-/system/user/avatar/(\d+)/},
+ uploader: 'AvatarUploader',
+ model_type: 'User'
+ },
+ {
+ pattern: %r{\A-/system/group/avatar/(\d+)/},
+ uploader: 'AvatarUploader',
+ model_type: 'Namespace'
+ },
+ {
+ pattern: %r{\A-/system/project/avatar/(\d+)/},
+ uploader: 'AvatarUploader',
+ model_type: 'Project'
+ },
+ {
+ pattern: FILE_UPLOADER_PATH,
+ uploader: 'FileUploader',
+ model_type: 'Project'
+ }
+ ].freeze
+
+ def to_h
+ @upload_hash ||= {
+ path: upload_path,
+ uploader: uploader,
+ model_type: model_type,
+ model_id: model_id,
+ size: file_size,
+ checksum: checksum
+ }
+ end
+
+ def upload_path
+ # UntrackedFile#path is absolute, but Upload#path depends on uploader
+ @upload_path ||=
+ if uploader == 'FileUploader'
+ # Path relative to project directory in uploads
+ matchd = path_relative_to_upload_dir.match(FILE_UPLOADER_PATH)
+ matchd[0].sub(%r{\A/}, '') # remove leading slash
+ else
+ path
+ end
+ end
+
+ def uploader
+ matching_pattern_map[:uploader]
+ end
+
+ def model_type
+ matching_pattern_map[:model_type]
+ end
+
+ def model_id
+ return @model_id if defined?(@model_id)
+
+ pattern = matching_pattern_map[:pattern]
+ matchd = path_relative_to_upload_dir.match(pattern)
+
+ # If something is captured (matchd[1] is not nil), it is a model_id
+ # Only the FileUploader pattern will not match an ID
+ @model_id = matchd[1] ? matchd[1].to_i : file_uploader_model_id
+ end
+
+ def file_size
+ File.size(absolute_path)
+ end
+
+ def checksum
+ Digest::SHA256.file(absolute_path).hexdigest
+ end
+
+ private
+
+ def matching_pattern_map
+ @matching_pattern_map ||= PATH_PATTERNS.find do |path_pattern_map|
+ path_relative_to_upload_dir.match(path_pattern_map[:pattern])
+ end
+
+ unless @matching_pattern_map
+ raise "Unknown upload path pattern \"#{path}\""
+ end
+
+ @matching_pattern_map
+ end
+
+ def file_uploader_model_id
+ matchd = path_relative_to_upload_dir.match(FULL_PATH_CAPTURE)
+ not_found_msg = <<~MSG
+ Could not capture project full_path from a FileUploader path:
+ "#{path_relative_to_upload_dir}"
+ MSG
+ raise not_found_msg unless matchd
+
+ full_path = matchd[1]
+ project = Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::Project.find_by_full_path(full_path)
+ return nil unless project
+
+ project.id
+ end
+
+ # Not including a leading slash
+ def path_relative_to_upload_dir
+ upload_dir = Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR # rubocop:disable Metrics/LineLength
+ base = %r{\A#{Regexp.escape(upload_dir)}/}
+ @path_relative_to_upload_dir ||= path.sub(base, '')
+ end
+
+ def absolute_path
+ File.join(Gitlab.config.uploads.storage_path, path)
+ end
+ end
+
+ # Avoid using application code
+ class Upload < ActiveRecord::Base
+ self.table_name = 'uploads'
+ end
+
+ # Avoid using application code
+ class Appearance < ActiveRecord::Base
+ self.table_name = 'appearances'
+ end
+
+ # Avoid using application code
+ class Namespace < ActiveRecord::Base
+ self.table_name = 'namespaces'
+ end
+
+ # Avoid using application code
+ class Note < ActiveRecord::Base
+ self.table_name = 'notes'
+ end
+
+ # Avoid using application code
+ class User < ActiveRecord::Base
+ self.table_name = 'users'
+ end
+
+ # Since project Markdown upload paths don't contain the project ID, we have to find the
+ # project by its full_path. Due to MySQL/PostgreSQL differences, and historical reasons,
+ # the logic is somewhat complex, so I've mostly copied it in here.
+ class Project < ActiveRecord::Base
+ self.table_name = 'projects'
+
+ def self.find_by_full_path(path)
+ 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
+ end
+
+ def self.where_full_path_in(path)
+ cast_lower = Gitlab::Database.postgresql?
+
+ path = connection.quote(path)
+
+ where =
+ if cast_lower
+ "(LOWER(routes.path) = LOWER(#{path}))"
+ else
+ "(routes.path = #{path})"
+ end
+
+ joins("INNER JOIN routes ON routes.source_id = projects.id AND routes.source_type = 'Project'").where(where)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/prepare_untracked_uploads.rb b/lib/gitlab/background_migration/prepare_untracked_uploads.rb
index 476c46341ae..914a9e48a2f 100644
--- a/lib/gitlab/background_migration/prepare_untracked_uploads.rb
+++ b/lib/gitlab/background_migration/prepare_untracked_uploads.rb
@@ -7,12 +7,16 @@ module Gitlab
class PrepareUntrackedUploads # rubocop:disable Metrics/ClassLength
# For bulk_queue_background_migration_jobs_by_range
include Database::MigrationHelpers
+ include ::Gitlab::Utils::StrongMemoize
FIND_BATCH_SIZE = 500
RELATIVE_UPLOAD_DIR = "uploads".freeze
- ABSOLUTE_UPLOAD_DIR = "#{CarrierWave.root}/#{RELATIVE_UPLOAD_DIR}".freeze
+ ABSOLUTE_UPLOAD_DIR = File.join(
+ Gitlab.config.uploads.storage_path,
+ RELATIVE_UPLOAD_DIR
+ )
FOLLOW_UP_MIGRATION = 'PopulateUntrackedUploads'.freeze
- START_WITH_CARRIERWAVE_ROOT_REGEX = %r{\A#{CarrierWave.root}/}
+ START_WITH_ROOT_REGEX = %r{\A#{Gitlab.config.uploads.storage_path}/}
EXCLUDED_HASHED_UPLOADS_PATH = "#{ABSOLUTE_UPLOAD_DIR}/@hashed/*".freeze
EXCLUDED_TMP_UPLOADS_PATH = "#{ABSOLUTE_UPLOAD_DIR}/tmp/*".freeze
@@ -39,7 +43,11 @@ module Gitlab
store_untracked_file_paths
- schedule_populate_untracked_uploads_jobs
+ if UntrackedFile.all.empty?
+ drop_temp_table
+ else
+ schedule_populate_untracked_uploads_jobs
+ end
end
private
@@ -80,7 +88,7 @@ module Gitlab
paths = []
stdout.each_line("\0") do |line|
- paths << line.chomp("\0").sub(START_WITH_CARRIERWAVE_ROOT_REGEX, '')
+ paths << line.chomp("\0").sub(START_WITH_ROOT_REGEX, '')
if paths.size >= batch_size
yield(paths)
@@ -88,7 +96,7 @@ module Gitlab
end
end
- yield(paths)
+ yield(paths) if paths.any?
end
def build_find_command(search_dir)
@@ -142,7 +150,9 @@ module Gitlab
end
def postgresql?
- @postgresql ||= Gitlab::Database.postgresql?
+ strong_memoize(:postgresql) do
+ Gitlab::Database.postgresql?
+ end
end
def can_bulk_insert_and_ignore_duplicates?
@@ -150,14 +160,22 @@ module Gitlab
end
def postgresql_pre_9_5?
- @postgresql_pre_9_5 ||= postgresql? &&
- Gitlab::Database.version.to_f < 9.5
+ strong_memoize(:postgresql_pre_9_5) do
+ postgresql? && Gitlab::Database.version.to_f < 9.5
+ end
end
def schedule_populate_untracked_uploads_jobs
bulk_queue_background_migration_jobs_by_range(
UntrackedFile, FOLLOW_UP_MIGRATION)
end
+
+ def drop_temp_table
+ unless Rails.env.test? # Dropping a table intermittently breaks test cleanup
+ UntrackedFile.connection.drop_table(:untracked_files_for_uploads,
+ if_exists: true)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/background_migration/set_confidential_note_events_on_services.rb b/lib/gitlab/background_migration/set_confidential_note_events_on_services.rb
new file mode 100644
index 00000000000..e5e8837221e
--- /dev/null
+++ b/lib/gitlab/background_migration/set_confidential_note_events_on_services.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ # Ensures services which previously recieved all notes events continue
+ # to recieve confidential ones.
+ class SetConfidentialNoteEventsOnServices
+ class Service < ActiveRecord::Base
+ self.table_name = 'services'
+
+ include ::EachBatch
+
+ def self.services_to_update
+ where(confidential_note_events: nil, note_events: true)
+ end
+ end
+
+ def perform(start_id, stop_id)
+ Service.services_to_update
+ .where(id: start_id..stop_id)
+ .update_all(confidential_note_events: true)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/set_confidential_note_events_on_webhooks.rb b/lib/gitlab/background_migration/set_confidential_note_events_on_webhooks.rb
new file mode 100644
index 00000000000..171c8ef21b7
--- /dev/null
+++ b/lib/gitlab/background_migration/set_confidential_note_events_on_webhooks.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ # Ensures hooks which previously recieved all notes events continue
+ # to recieve confidential ones.
+ class SetConfidentialNoteEventsOnWebhooks
+ class WebHook < ActiveRecord::Base
+ self.table_name = 'web_hooks'
+
+ include ::EachBatch
+
+ def self.hooks_to_update
+ where(confidential_note_events: nil, note_events: true)
+ end
+ end
+
+ def perform(start_id, stop_id)
+ WebHook.hooks_to_update
+ .where(id: start_id..stop_id)
+ .update_all(confidential_note_events: true)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/badge/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb
index 9a0482306b7..778d78185ff 100644
--- a/lib/gitlab/badge/coverage/report.rb
+++ b/lib/gitlab/badge/coverage/report.rb
@@ -23,7 +23,7 @@ module Gitlab
@coverage ||= raw_coverage
return unless @coverage
- @coverage.to_i
+ @coverage.to_f.round(2)
end
def metadata
diff --git a/lib/gitlab/badge/coverage/template.rb b/lib/gitlab/badge/coverage/template.rb
index fcecb1d9665..afbf9dd17e3 100644
--- a/lib/gitlab/badge/coverage/template.rb
+++ b/lib/gitlab/badge/coverage/template.rb
@@ -25,7 +25,7 @@ module Gitlab
end
def value_text
- @status ? "#{@status}%" : 'unknown'
+ @status ? ("%.2f%%" % @status) : 'unknown'
end
def key_width
@@ -33,7 +33,7 @@ module Gitlab
end
def value_width
- @status ? 36 : 58
+ @status ? 54 : 58
end
def value_color
diff --git a/lib/gitlab/bare_repository_import/importer.rb b/lib/gitlab/bare_repository_import/importer.rb
index 298409d8b5a..1a25138e7d6 100644
--- a/lib/gitlab/bare_repository_import/importer.rb
+++ b/lib/gitlab/bare_repository_import/importer.rb
@@ -14,7 +14,7 @@ module Gitlab
repos_to_import.each do |repo_path|
bare_repo = Gitlab::BareRepositoryImport::Repository.new(import_path, repo_path)
- if bare_repo.hashed? || bare_repo.wiki?
+ unless bare_repo.processable?
log " * Skipping repo #{bare_repo.repo_path}".color(:yellow)
next
@@ -55,12 +55,16 @@ module Gitlab
name: project_name,
path: project_name,
skip_disk_validation: true,
- import_type: 'gitlab_project',
+ skip_wiki: bare_repo.wiki_exists?,
+ import_type: 'bare_repository',
namespace_id: group&.id).execute
if project.persisted? && mv_repo(project)
log " * Created #{project.name} (#{project_full_path})".color(:green)
+ project.write_repository_config
+ Gitlab::Git::Repository.create_hooks(project.repository.path_to_repo, Gitlab.config.gitlab_shell.hooks_path)
+
ProjectCacheWorker.perform_async(project.id)
else
log " * Failed trying to create #{project.name} (#{project_full_path})".color(:red)
diff --git a/lib/gitlab/bare_repository_import/repository.rb b/lib/gitlab/bare_repository_import/repository.rb
index fa7891c8dcc..fe267248275 100644
--- a/lib/gitlab/bare_repository_import/repository.rb
+++ b/lib/gitlab/bare_repository_import/repository.rb
@@ -1,44 +1,69 @@
+# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/953
+#
module Gitlab
module BareRepositoryImport
class Repository
+ include ::Gitlab::Utils::StrongMemoize
+
attr_reader :group_path, :project_name, :repo_path
def initialize(root_path, repo_path)
@root_path = root_path
@repo_path = repo_path
-
@root_path << '/' unless root_path.ends_with?('/')
+ full_path =
+ if hashed? && !wiki?
+ repository.config.get('gitlab.fullpath')
+ else
+ repo_relative_path
+ end
+
# Split path into 'all/the/namespaces' and 'project_name'
- @group_path, _, @project_name = repo_relative_path.rpartition('/')
+ @group_path, _, @project_name = full_path.to_s.rpartition('/')
end
def wiki_exists?
File.exist?(wiki_path)
end
- def wiki?
- @wiki ||= repo_path.end_with?('.wiki.git')
- end
-
def wiki_path
@wiki_path ||= repo_path.sub(/\.git$/, '.wiki.git')
end
- def hashed?
- @hashed ||= group_path.start_with?('@hashed')
- end
-
def project_full_path
@project_full_path ||= "#{group_path}/#{project_name}"
end
+ def processable?
+ return false if wiki?
+ return false if hashed? && (group_path.blank? || project_name.blank?)
+
+ true
+ end
+
private
+ def wiki?
+ strong_memoize(:wiki) do
+ repo_path.end_with?('.wiki.git')
+ end
+ end
+
+ def hashed?
+ strong_memoize(:hashed) do
+ repo_relative_path.include?('@hashed')
+ end
+ end
+
def repo_relative_path
# Remove root path and `.git` at the end
repo_path[@root_path.size...-4]
end
+
+ def repository
+ @repository ||= Rugged::Repository.new(repo_path)
+ end
end
end
end
diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb
index d48ae17aeaf..f3999e690fa 100644
--- a/lib/gitlab/bitbucket_import/importer.rb
+++ b/lib/gitlab/bitbucket_import/importer.rb
@@ -63,7 +63,7 @@ module Gitlab
disk_path = project.wiki.disk_path
import_url = project.import_url.sub(/\.git\z/, ".git/wiki")
- gitlab_shell.import_repository(project.repository_storage_path, disk_path, import_url)
+ gitlab_shell.import_repository(project.repository_storage, disk_path, import_url)
rescue StandardError => e
errors << { type: :wiki, errors: e.message }
end
@@ -135,7 +135,7 @@ module Gitlab
if label.valid?
@labels[label_params[:title]] = label
else
- raise "Failed to create label \"#{label_params[:title]}\" for project \"#{project.name_with_namespace}\""
+ raise "Failed to create label \"#{label_params[:title]}\" for project \"#{project.full_name}\""
end
end
end
diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb
index ef92fc5a0a0..51ba09aa129 100644
--- a/lib/gitlab/checks/change_access.rb
+++ b/lib/gitlab/checks/change_access.rb
@@ -16,11 +16,11 @@ module Gitlab
lfs_objects_missing: 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".'
}.freeze
- attr_reader :user_access, :project, :skip_authorization, :protocol
+ attr_reader :user_access, :project, :skip_authorization, :skip_lfs_integrity_check, :protocol, :oldrev, :newrev, :ref, :branch_name, :tag_name
def initialize(
change, user_access:, project:, skip_authorization: false,
- protocol:
+ skip_lfs_integrity_check: false, protocol:
)
@oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref)
@branch_name = Gitlab::Git.branch_name(@ref)
@@ -28,16 +28,18 @@ module Gitlab
@user_access = user_access
@project = project
@skip_authorization = skip_authorization
+ @skip_lfs_integrity_check = skip_lfs_integrity_check
@protocol = protocol
end
- def exec
+ def exec(skip_commits_check: false)
return true if skip_authorization
push_checks
branch_checks
tag_checks
- lfs_objects_exist_check
+ lfs_objects_exist_check unless skip_lfs_integrity_check
+ commits_check unless skip_commits_check
true
end
@@ -45,15 +47,15 @@ module Gitlab
protected
def push_checks
- if user_access.cannot_do_action?(:push_code)
+ unless can_push?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_code]
end
end
def branch_checks
- return unless @branch_name
+ return unless branch_name
- if deletion? && @branch_name == project.default_branch
+ if deletion? && branch_name == project.default_branch
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_default_branch]
end
@@ -61,7 +63,7 @@ module Gitlab
end
def protected_branch_checks
- return unless ProtectedBranch.protected?(project, @branch_name)
+ return unless ProtectedBranch.protected?(project, branch_name)
if forced_push?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:force_push_protected_branch]
@@ -75,29 +77,29 @@ module Gitlab
end
def protected_branch_deletion_checks
- unless user_access.can_delete_branch?(@branch_name)
+ unless user_access.can_delete_branch?(branch_name)
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_master_delete_protected_branch]
end
- unless protocol == 'web'
+ unless updated_from_web?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:non_web_delete_protected_branch]
end
end
def protected_branch_push_checks
if matching_merge_request?
- unless user_access.can_merge_to_branch?(@branch_name) || user_access.can_push_to_branch?(@branch_name)
+ 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
- unless user_access.can_push_to_branch?(@branch_name)
+ unless user_access.can_push_to_branch?(branch_name)
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:push_protected_branch]
end
end
end
def tag_checks
- return unless @tag_name
+ return unless tag_name
if tag_exists? && user_access.cannot_do_action?(:admin_project)
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:change_existing_tags]
@@ -107,45 +109,85 @@ module Gitlab
end
def protected_tag_checks
- return unless ProtectedTag.protected?(project, @tag_name)
+ return unless ProtectedTag.protected?(project, tag_name)
raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:update_protected_tag]) if update?
raise(GitAccess::UnauthorizedError, ERROR_MESSAGES[:delete_protected_tag]) if deletion?
- unless user_access.can_create_tag?(@tag_name)
+ unless user_access.can_create_tag?(tag_name)
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:create_protected_tag]
end
end
+ def commits_check
+ return if deletion? || newrev.nil?
+ return unless should_run_commit_validations?
+
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ee/issues/3593
+ ::Gitlab::GitalyClient.allow_n_plus_1_calls do
+ commits.each do |commit|
+ commit_check.validate(commit, validations_for_commit(commit))
+ end
+ end
+
+ commit_check.validate_file_paths
+ end
+
+ # Method overwritten in EE to inject custom validations
+ def validations_for_commit(_)
+ []
+ end
+
private
+ def should_run_commit_validations?
+ commit_check.validate_lfs_file_locks?
+ end
+
+ def updated_from_web?
+ protocol == 'web'
+ end
+
def tag_exists?
- project.repository.tag_exists?(@tag_name)
+ project.repository.tag_exists?(tag_name)
end
def forced_push?
- Gitlab::Checks::ForcePush.force_push?(@project, @oldrev, @newrev)
+ Gitlab::Checks::ForcePush.force_push?(project, oldrev, newrev)
end
def update?
- !Gitlab::Git.blank_ref?(@oldrev) && !deletion?
+ !Gitlab::Git.blank_ref?(oldrev) && !deletion?
end
def deletion?
- Gitlab::Git.blank_ref?(@newrev)
+ Gitlab::Git.blank_ref?(newrev)
end
def matching_merge_request?
- Checks::MatchingMergeRequest.new(@newrev, @branch_name, @project).match?
+ Checks::MatchingMergeRequest.new(newrev, branch_name, project).match?
end
def lfs_objects_exist_check
- lfs_check = Checks::LfsIntegrity.new(project, @newrev)
+ lfs_check = Checks::LfsIntegrity.new(project, newrev)
if lfs_check.objects_missing?
raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:lfs_objects_missing]
end
end
+
+ def commit_check
+ @commit_check ||= Gitlab::Checks::CommitCheck.new(project, user_access.user, newrev, oldrev)
+ end
+
+ def commits
+ @commits ||= project.repository.new_commits(newrev)
+ end
+
+ def can_push?
+ user_access.can_do_action?(:push_code) ||
+ user_access.can_push_to_branch?(branch_name)
+ end
end
end
end
diff --git a/lib/gitlab/checks/commit_check.rb b/lib/gitlab/checks/commit_check.rb
new file mode 100644
index 00000000000..43a52b493bb
--- /dev/null
+++ b/lib/gitlab/checks/commit_check.rb
@@ -0,0 +1,61 @@
+module Gitlab
+ module Checks
+ class CommitCheck
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :project, :user, :newrev, :oldrev
+
+ def initialize(project, user, newrev, oldrev)
+ @project = project
+ @user = user
+ @newrev = user
+ @oldrev = user
+ @file_paths = []
+ end
+
+ def validate(commit, validations)
+ return if validations.empty? && path_validations.empty?
+
+ commit.raw_deltas.each do |diff|
+ @file_paths << (diff.new_path || diff.old_path)
+
+ validations.each do |validation|
+ if error = validation.call(diff)
+ raise ::Gitlab::GitAccess::UnauthorizedError, error
+ end
+ end
+ end
+ end
+
+ def validate_file_paths
+ path_validations.each do |validation|
+ if error = validation.call(@file_paths)
+ raise ::Gitlab::GitAccess::UnauthorizedError, error
+ end
+ end
+ end
+
+ def validate_lfs_file_locks?
+ strong_memoize(:validate_lfs_file_locks) do
+ project.lfs_enabled? && project.lfs_file_locks.any? && newrev && oldrev
+ end
+ end
+
+ private
+
+ def lfs_file_locks_validation
+ lambda do |paths|
+ lfs_lock = project.lfs_file_locks.where(path: paths).where.not(user_id: user.id).first
+
+ if lfs_lock
+ return "The path '#{lfs_lock.path}' is locked in Git LFS by #{lfs_lock.user.name}"
+ end
+ end
+ end
+
+ def path_validations
+ validate_lfs_file_locks? ? [lfs_file_locks_validation] : []
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/checks/force_push.rb b/lib/gitlab/checks/force_push.rb
index dc5d285ea65..c9c3050cfc2 100644
--- a/lib/gitlab/checks/force_push.rb
+++ b/lib/gitlab/checks/force_push.rb
@@ -15,8 +15,8 @@ module Gitlab
.ancestor?(oldrev, newrev)
else
Gitlab::Git::RevList.new(
- path_to_repo: project.repository.path_to_repo,
- oldrev: oldrev, newrev: newrev).missed_ref.present?
+ project.repository.raw, oldrev: oldrev, newrev: newrev
+ ).missed_ref.present?
end
end
end
diff --git a/lib/gitlab/checks/lfs_integrity.rb b/lib/gitlab/checks/lfs_integrity.rb
index f7276a380dc..f0e5773ec3c 100644
--- a/lib/gitlab/checks/lfs_integrity.rb
+++ b/lib/gitlab/checks/lfs_integrity.rb
@@ -15,8 +15,7 @@ module Gitlab
return false unless new_lfs_pointers.present?
- existing_count = @project.lfs_storage_project
- .lfs_objects
+ existing_count = @project.all_lfs_objects
.where(oid: new_lfs_pointers.map(&:lfs_oid))
.count
diff --git a/lib/gitlab/checks/post_push_message.rb b/lib/gitlab/checks/post_push_message.rb
new file mode 100644
index 00000000000..473c0385b34
--- /dev/null
+++ b/lib/gitlab/checks/post_push_message.rb
@@ -0,0 +1,46 @@
+module Gitlab
+ module Checks
+ class PostPushMessage
+ def initialize(project, user, protocol)
+ @project = project
+ @user = user
+ @protocol = protocol
+ end
+
+ def self.fetch_message(user_id, project_id)
+ key = message_key(user_id, project_id)
+
+ Gitlab::Redis::SharedState.with do |redis|
+ message = redis.get(key)
+ redis.del(key)
+ message
+ end
+ end
+
+ def add_message
+ return unless user.present? && project.present?
+
+ Gitlab::Redis::SharedState.with do |redis|
+ key = self.class.message_key(user.id, project.id)
+ redis.setex(key, 5.minutes, message)
+ end
+ end
+
+ def message
+ raise NotImplementedError
+ end
+
+ protected
+
+ attr_reader :project, :user, :protocol
+
+ def self.message_key(user_id, project_id)
+ raise NotImplementedError
+ end
+
+ def url_to_repo
+ protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/checks/project_created.rb b/lib/gitlab/checks/project_created.rb
new file mode 100644
index 00000000000..cec270d6a58
--- /dev/null
+++ b/lib/gitlab/checks/project_created.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module Checks
+ class ProjectCreated < PostPushMessage
+ PROJECT_CREATED = "project_created".freeze
+
+ def message
+ <<~MESSAGE
+
+ The private project #{project.full_path} was successfully created.
+
+ To configure the remote, run:
+ git remote add origin #{url_to_repo}
+
+ To view the project, visit:
+ #{project_url}
+
+ MESSAGE
+ end
+
+ private
+
+ def self.message_key(user_id, project_id)
+ "#{PROJECT_CREATED}:#{user_id}:#{project_id}"
+ end
+
+ def project_url
+ Gitlab::Routing.url_helpers.project_url(project)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/checks/project_moved.rb b/lib/gitlab/checks/project_moved.rb
index 3a1c0a3455e..3a197078d08 100644
--- a/lib/gitlab/checks/project_moved.rb
+++ b/lib/gitlab/checks/project_moved.rb
@@ -1,65 +1,31 @@
module Gitlab
module Checks
- class ProjectMoved
+ class ProjectMoved < PostPushMessage
REDIRECT_NAMESPACE = "redirect_namespace".freeze
- def initialize(project, user, redirected_path, protocol)
- @project = project
- @user = user
+ def initialize(project, user, protocol, redirected_path)
@redirected_path = redirected_path
- @protocol = protocol
- end
-
- def self.fetch_redirect_message(user_id, project_id)
- redirect_key = redirect_message_key(user_id, project_id)
- Gitlab::Redis::SharedState.with do |redis|
- message = redis.get(redirect_key)
- redis.del(redirect_key)
- message
- end
+ super(project, user, protocol)
end
- def add_redirect_message
- Gitlab::Redis::SharedState.with do |redis|
- key = self.class.redirect_message_key(user.id, project.id)
- redis.setex(key, 5.minutes, redirect_message)
- end
- end
-
- def redirect_message(rejected: false)
- <<~MESSAGE.strip_heredoc
+ def message
+ <<~MESSAGE
Project '#{redirected_path}' was moved to '#{project.full_path}'.
Please update your Git remote:
- #{remote_url_message(rejected)}
+ git remote set-url origin #{url_to_repo}
MESSAGE
end
- def permanent_redirect?
- RedirectRoute.permanent.exists?(path: redirected_path)
- end
-
private
- attr_reader :project, :redirected_path, :protocol, :user
+ attr_reader :redirected_path
- def self.redirect_message_key(user_id, project_id)
+ def self.message_key(user_id, project_id)
"#{REDIRECT_NAMESPACE}:#{user_id}:#{project_id}"
end
-
- def remote_url_message(rejected)
- if rejected
- "git remote set-url origin #{url} and try again."
- else
- "git remote set-url origin #{url}"
- end
- end
-
- def url
- protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo
- end
end
end
end
diff --git a/lib/gitlab/ci/ansi2html.rb b/lib/gitlab/ci/ansi2html.rb
index 72b75791bbb..35eadf6fa93 100644
--- a/lib/gitlab/ci/ansi2html.rb
+++ b/lib/gitlab/ci/ansi2html.rb
@@ -148,6 +148,7 @@ module Gitlab
stream.seek(@offset)
append = @offset > 0
end
+
start_offset = @offset
open_new_tag
@@ -155,6 +156,7 @@ module Gitlab
stream.each_line do |line|
s = StringScanner.new(line)
until s.eos?
+
if s.scan(Gitlab::Regex.build_trace_section_regex)
handle_section(s)
elsif s.scan(/\e([@-_])(.*?)([@-~])/)
@@ -168,6 +170,7 @@ module Gitlab
else
@out << s.scan(/./m)
end
+
@offset += s.matched_size
end
end
@@ -234,10 +237,12 @@ module Gitlab
# Most terminals show bold colored text in the light color variant
# Let's mimic that here
if @style_mask & STYLE_SWITCHES[:bold] != 0
- fg_color.sub!(/fg-(\w{2,}+)/, 'fg-l-\1')
+ fg_color.sub!(/fg-([a-z]{2,}+)/, 'fg-l-\1')
end
+
css_classes << fg_color
end
+
css_classes << @bg_color unless @bg_color.nil?
STYLE_SWITCHES.each do |css_class, flag|
diff --git a/lib/gitlab/ci/build/artifacts/metadata/entry.rb b/lib/gitlab/ci/build/artifacts/metadata/entry.rb
index 5b2f09e03ea..428c0505808 100644
--- a/lib/gitlab/ci/build/artifacts/metadata/entry.rb
+++ b/lib/gitlab/ci/build/artifacts/metadata/entry.rb
@@ -97,7 +97,7 @@ module Gitlab
end
def total_size
- descendant_pattern = %r{^#{Regexp.escape(@path.to_s)}}
+ descendant_pattern = /^#{Regexp.escape(@path.to_s)}/
entries.sum do |path, entry|
(entry[:size] if path =~ descendant_pattern).to_i
end
diff --git a/lib/gitlab/ci/build/policy/kubernetes.rb b/lib/gitlab/ci/build/policy/kubernetes.rb
index b20d374288f..782f6c4c0af 100644
--- a/lib/gitlab/ci/build/policy/kubernetes.rb
+++ b/lib/gitlab/ci/build/policy/kubernetes.rb
@@ -9,7 +9,7 @@ module Gitlab
end
end
- def satisfied_by?(pipeline)
+ def satisfied_by?(pipeline, seed = nil)
pipeline.has_kubernetes_active?
end
end
diff --git a/lib/gitlab/ci/build/policy/refs.rb b/lib/gitlab/ci/build/policy/refs.rb
index eadc0948d2f..4aa5dc89f47 100644
--- a/lib/gitlab/ci/build/policy/refs.rb
+++ b/lib/gitlab/ci/build/policy/refs.rb
@@ -7,7 +7,7 @@ module Gitlab
@patterns = Array(refs)
end
- def satisfied_by?(pipeline)
+ def satisfied_by?(pipeline, seed = nil)
@patterns.any? do |pattern|
pattern, path = pattern.split('@', 2)
diff --git a/lib/gitlab/ci/build/policy/specification.rb b/lib/gitlab/ci/build/policy/specification.rb
index c317291f29d..f09ba42c074 100644
--- a/lib/gitlab/ci/build/policy/specification.rb
+++ b/lib/gitlab/ci/build/policy/specification.rb
@@ -15,7 +15,7 @@ module Gitlab
@spec = spec
end
- def satisfied_by?(pipeline)
+ def satisfied_by?(pipeline, seed = nil)
raise NotImplementedError
end
end
diff --git a/lib/gitlab/ci/build/policy/variables.rb b/lib/gitlab/ci/build/policy/variables.rb
new file mode 100644
index 00000000000..9d2a362b7d4
--- /dev/null
+++ b/lib/gitlab/ci/build/policy/variables.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module Ci
+ module Build
+ module Policy
+ class Variables < Policy::Specification
+ def initialize(expressions)
+ @expressions = Array(expressions)
+ end
+
+ def satisfied_by?(pipeline, seed)
+ variables = seed.to_resource.scoped_variables_hash
+
+ statements = @expressions.map do |statement|
+ ::Gitlab::Ci::Pipeline::Expression::Statement
+ .new(statement, variables)
+ end
+
+ statements.any?(&:truthful?)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/build/step.rb b/lib/gitlab/ci/build/step.rb
index 411f67f8ce7..0b1ebe4e048 100644
--- a/lib/gitlab/ci/build/step.rb
+++ b/lib/gitlab/ci/build/step.rb
@@ -14,7 +14,7 @@ module Gitlab
self.new(:script).tap do |step|
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.timeout = job.metadata_timeout
step.when = WHEN_ON_SUCCESS
end
end
@@ -25,7 +25,7 @@ module Gitlab
self.new(:after_script).tap do |step|
step.script = after_script
- step.timeout = job.timeout
+ step.timeout = job.metadata_timeout
step.when = WHEN_ALWAYS
step.allow_failure = true
end
diff --git a/lib/gitlab/ci/charts.rb b/lib/gitlab/ci/charts.rb
index 525563a97f5..46ed330dbbf 100644
--- a/lib/gitlab/ci/charts.rb
+++ b/lib/gitlab/ci/charts.rb
@@ -68,10 +68,11 @@ module Gitlab
class YearChart < Chart
include MonthlyInterval
+ attr_reader :to, :from
def initialize(*)
- @to = Date.today.end_of_month
- @from = @to.years_ago(1).beginning_of_month
+ @to = Date.today.end_of_month.end_of_day
+ @from = @to.years_ago(1).beginning_of_month.beginning_of_day
@format = '%d %B %Y'
super
@@ -80,10 +81,11 @@ module Gitlab
class MonthChart < Chart
include DailyInterval
+ attr_reader :to, :from
def initialize(*)
- @to = Date.today
- @from = @to - 30.days
+ @to = Date.today.end_of_day
+ @from = 1.month.ago.beginning_of_day
@format = '%d %B'
super
@@ -92,10 +94,11 @@ module Gitlab
class WeekChart < Chart
include DailyInterval
+ attr_reader :to, :from
def initialize(*)
- @to = Date.today
- @from = @to - 7.days
+ @to = Date.today.end_of_day
+ @from = 1.week.ago.beginning_of_day
@format = '%d %B'
super
diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb
index f7ff7ea212e..66ac4a40616 100644
--- a/lib/gitlab/ci/config.rb
+++ b/lib/gitlab/ci/config.rb
@@ -4,7 +4,8 @@ module Gitlab
# Base GitLab CI Configuration facade
#
class Config
- def initialize(config)
+ # EE would override this and utilize opts argument
+ def initialize(config, opts = {})
@config = Loader.new(config).load!
@global = Entry::Global.new(@config)
diff --git a/lib/gitlab/ci/config/entry/node.rb b/lib/gitlab/ci/config/entry/node.rb
index 1fba0b2db0b..26505c91be3 100644
--- a/lib/gitlab/ci/config/entry/node.rb
+++ b/lib/gitlab/ci/config/entry/node.rb
@@ -93,9 +93,7 @@ module Gitlab
private
- def entries
- @entries
- end
+ attr_reader :entries
end
end
end
diff --git a/lib/gitlab/ci/config/entry/policy.rb b/lib/gitlab/ci/config/entry/policy.rb
index 0027e9ec8c5..09e8e52b60f 100644
--- a/lib/gitlab/ci/config/entry/policy.rb
+++ b/lib/gitlab/ci/config/entry/policy.rb
@@ -25,15 +25,31 @@ module Gitlab
include Entry::Validatable
include Entry::Attributable
- attributes :refs, :kubernetes
+ attributes :refs, :kubernetes, :variables
validations do
validates :config, presence: true
- validates :config, allowed_keys: %i[refs kubernetes]
+ validates :config, allowed_keys: %i[refs kubernetes variables]
+ validate :variables_expressions_syntax
with_options allow_nil: true do
validates :refs, array_of_strings_or_regexps: true
validates :kubernetes, allowed_values: %w[active]
+ validates :variables, array_of_strings: true
+ end
+
+ def variables_expressions_syntax
+ return unless variables.is_a?(Array)
+
+ statements = variables.map do |statement|
+ ::Gitlab::Ci::Pipeline::Expression::Statement.new(statement)
+ end
+
+ statements.each do |statement|
+ unless statement.valid?
+ errors.add(:variables, "Invalid expression syntax")
+ end
+ end
end
end
end
diff --git a/lib/gitlab/ci/config/entry/validators.rb b/lib/gitlab/ci/config/entry/validators.rb
index eb606b57667..55658900628 100644
--- a/lib/gitlab/ci/config/entry/validators.rb
+++ b/lib/gitlab/ci/config/entry/validators.rb
@@ -64,10 +64,24 @@ module Gitlab
include LegacyValidationHelpers
def validate_each(record, attribute, value)
- unless validate_string(value)
+ if validate_string(value)
+ validate_path(record, attribute, value)
+ else
record.errors.add(attribute, 'should be a string or symbol')
end
end
+
+ private
+
+ def validate_path(record, attribute, value)
+ path = CGI.unescape(value.to_s)
+
+ if path.include?('/')
+ record.errors.add(attribute, 'cannot contain the "/" character')
+ elsif path == '.' || path == '..'
+ record.errors.add(attribute, 'cannot be "." or ".."')
+ end
+ end
end
class RegexpValidator < ActiveModel::EachValidator
diff --git a/lib/gitlab/ci/config/loader.rb b/lib/gitlab/ci/config/loader.rb
index e7d9f6a7761..141d2714cb6 100644
--- a/lib/gitlab/ci/config/loader.rb
+++ b/lib/gitlab/ci/config/loader.rb
@@ -6,6 +6,8 @@ module Gitlab
def initialize(config)
@config = YAML.safe_load(config, [Symbol], [], true)
+ rescue Psych::Exception => e
+ raise FormatError, e.message
end
def valid?
diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb
index 7b19b10e05b..a1849b01c5d 100644
--- a/lib/gitlab/ci/pipeline/chain/command.rb
+++ b/lib/gitlab/ci/pipeline/chain/command.rb
@@ -1,4 +1,4 @@
-module Gitlab
+module Gitlab # rubocop:disable Naming/FileName
module Ci
module Pipeline
module Chain
diff --git a/lib/gitlab/ci/pipeline/chain/create.rb b/lib/gitlab/ci/pipeline/chain/create.rb
index d19a2519803..f4c8d5342c1 100644
--- a/lib/gitlab/ci/pipeline/chain/create.rb
+++ b/lib/gitlab/ci/pipeline/chain/create.rb
@@ -9,35 +9,24 @@ module Gitlab
::Ci::Pipeline.transaction do
pipeline.save!
- @command.seeds_block&.call(pipeline)
-
- ::Ci::CreatePipelineStagesService
- .new(project, current_user)
- .execute(pipeline)
+ ##
+ # Create environments before the pipeline starts.
+ #
+ pipeline.builds.each do |build|
+ if build.has_environment?
+ project.environments.find_or_create_by(
+ name: build.expanded_environment_name
+ )
+ end
+ end
end
rescue ActiveRecord::RecordInvalid => e
error("Failed to persist the pipeline: #{e}")
- ensure
- if pipeline.builds.where(stage_id: nil).any?
- invalid_builds_counter.increment(node: hostname)
- end
end
def break?
!pipeline.persisted?
end
-
- private
-
- def invalid_builds_counter
- @counter ||= Gitlab::Metrics
- .counter(:gitlab_ci_invalid_builds_total,
- 'Invalid builds without stage assigned counter')
- end
-
- def hostname
- @hostname ||= Socket.gethostname
- end
end
end
end
diff --git a/lib/gitlab/ci/pipeline/chain/populate.rb b/lib/gitlab/ci/pipeline/chain/populate.rb
new file mode 100644
index 00000000000..d299a5677de
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/chain/populate.rb
@@ -0,0 +1,45 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Chain
+ class Populate < Chain::Base
+ include Chain::Helpers
+
+ PopulateError = Class.new(StandardError)
+
+ def perform!
+ ##
+ # Populate pipeline with block argument of CreatePipelineService#execute.
+ #
+ @command.seeds_block&.call(pipeline)
+
+ ##
+ # Populate pipeline with all stages and builds from pipeline seeds.
+ #
+ pipeline.stage_seeds.each do |stage|
+ pipeline.stages << stage.to_resource
+
+ stage.seeds.each do |build|
+ pipeline.builds << build.to_resource
+ end
+ end
+
+ if pipeline.stages.none?
+ return error('No stages / jobs for this pipeline.')
+ end
+
+ if pipeline.invalid?
+ return error('Failed to build the pipeline!')
+ end
+
+ raise Populate::PopulateError if pipeline.persisted?
+ end
+
+ def break?
+ pipeline.errors.any?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/chain/skip.rb b/lib/gitlab/ci/pipeline/chain/skip.rb
index 9a72de87bab..32cbb7ca6af 100644
--- a/lib/gitlab/ci/pipeline/chain/skip.rb
+++ b/lib/gitlab/ci/pipeline/chain/skip.rb
@@ -3,6 +3,8 @@ module Gitlab
module Pipeline
module Chain
class Skip < Chain::Base
+ include ::Gitlab::Utils::StrongMemoize
+
SKIP_PATTERN = /\[(ci[ _-]skip|skip[ _-]ci)\]/i
def perform!
@@ -24,7 +26,9 @@ module Gitlab
def commit_message_skips_ci?
return false unless @pipeline.git_commit_message
- @skipped ||= !!(@pipeline.git_commit_message =~ SKIP_PATTERN)
+ strong_memoize(:commit_message_skips_ci) do
+ !!(@pipeline.git_commit_message =~ SKIP_PATTERN)
+ end
end
end
end
diff --git a/lib/gitlab/ci/pipeline/chain/validate/config.rb b/lib/gitlab/ci/pipeline/chain/validate/config.rb
index 075504bcce5..a3bd2a5a23a 100644
--- a/lib/gitlab/ci/pipeline/chain/validate/config.rb
+++ b/lib/gitlab/ci/pipeline/chain/validate/config.rb
@@ -16,11 +16,7 @@ module Gitlab
@pipeline.drop!(:config_error)
end
- return error(@pipeline.yaml_errors)
- end
-
- unless @pipeline.has_stage_seeds?
- return error('No stages / jobs for this pipeline.')
+ error(@pipeline.yaml_errors)
end
end
diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/base.rb b/lib/gitlab/ci/pipeline/expression/lexeme/base.rb
new file mode 100644
index 00000000000..047ab66e9b3
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexeme/base.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ module Lexeme
+ class Base
+ def evaluate(**variables)
+ raise NotImplementedError
+ end
+
+ def self.build(token)
+ raise NotImplementedError
+ end
+
+ def self.scan(scanner)
+ if scanner.scan(self::PATTERN)
+ Expression::Token.new(scanner.matched, self)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/equals.rb b/lib/gitlab/ci/pipeline/expression/lexeme/equals.rb
new file mode 100644
index 00000000000..3a2f0c6924e
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexeme/equals.rb
@@ -0,0 +1,26 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ module Lexeme
+ class Equals < Lexeme::Operator
+ PATTERN = /==/.freeze
+
+ def initialize(left, right)
+ @left = left
+ @right = right
+ end
+
+ def evaluate(variables = {})
+ @left.evaluate(variables) == @right.evaluate(variables)
+ end
+
+ def self.build(_value, behind, ahead)
+ new(behind, ahead)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/null.rb b/lib/gitlab/ci/pipeline/expression/lexeme/null.rb
new file mode 100644
index 00000000000..a2778716924
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexeme/null.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ module Lexeme
+ class Null < Lexeme::Value
+ PATTERN = /null/.freeze
+
+ def initialize(value = nil)
+ @value = nil
+ end
+
+ def evaluate(variables = {})
+ nil
+ end
+
+ def self.build(_value)
+ self.new
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/operator.rb b/lib/gitlab/ci/pipeline/expression/lexeme/operator.rb
new file mode 100644
index 00000000000..f640d0b5855
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexeme/operator.rb
@@ -0,0 +1,15 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ module Lexeme
+ class Operator < Lexeme::Base
+ def self.type
+ :operator
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/string.rb b/lib/gitlab/ci/pipeline/expression/lexeme/string.rb
new file mode 100644
index 00000000000..346c92dc51e
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexeme/string.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ module Lexeme
+ class String < Lexeme::Value
+ PATTERN = /("(?<string>.*?)")|('(?<string>.*?)')/.freeze
+
+ def initialize(value)
+ @value = value
+ end
+
+ def evaluate(variables = {})
+ @value.to_s
+ end
+
+ def self.build(string)
+ new(string.match(PATTERN)[:string])
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/value.rb b/lib/gitlab/ci/pipeline/expression/lexeme/value.rb
new file mode 100644
index 00000000000..f2611d65faf
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexeme/value.rb
@@ -0,0 +1,15 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ module Lexeme
+ class Value < Lexeme::Base
+ def self.type
+ :value
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb b/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb
new file mode 100644
index 00000000000..37643c8ef53
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexeme/variable.rb
@@ -0,0 +1,25 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ module Lexeme
+ class Variable < Lexeme::Value
+ PATTERN = /\$(?<name>\w+)/.freeze
+
+ def initialize(name)
+ @name = name
+ end
+
+ def evaluate(variables = {})
+ variables.with_indifferent_access.fetch(@name, nil)
+ end
+
+ def self.build(string)
+ new(string.match(PATTERN)[:name])
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/lexer.rb b/lib/gitlab/ci/pipeline/expression/lexer.rb
new file mode 100644
index 00000000000..e1c68b7c3c2
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/lexer.rb
@@ -0,0 +1,59 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ class Lexer
+ include ::Gitlab::Utils::StrongMemoize
+
+ LEXEMES = [
+ Expression::Lexeme::Variable,
+ Expression::Lexeme::String,
+ Expression::Lexeme::Null,
+ Expression::Lexeme::Equals
+ ].freeze
+
+ SyntaxError = Class.new(Statement::StatementError)
+
+ MAX_TOKENS = 100
+
+ def initialize(statement, max_tokens: MAX_TOKENS)
+ @scanner = StringScanner.new(statement)
+ @max_tokens = max_tokens
+ end
+
+ def tokens
+ strong_memoize(:tokens) { tokenize }
+ end
+
+ def lexemes
+ tokens.map(&:to_lexeme)
+ end
+
+ private
+
+ def tokenize
+ tokens = []
+
+ @max_tokens.times do
+ @scanner.skip(/\s+/) # ignore whitespace
+
+ return tokens if @scanner.eos?
+
+ lexeme = LEXEMES.find do |type|
+ type.scan(@scanner).tap do |token|
+ tokens.push(token) if token.present?
+ end
+ end
+
+ unless lexeme.present?
+ raise Lexer::SyntaxError, 'Unknown lexeme found!'
+ end
+ end
+
+ raise Lexer::SyntaxError, 'Too many tokens!'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/parser.rb b/lib/gitlab/ci/pipeline/expression/parser.rb
new file mode 100644
index 00000000000..90f94d0b763
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/parser.rb
@@ -0,0 +1,40 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ class Parser
+ def initialize(tokens)
+ @tokens = tokens.to_enum
+ @nodes = []
+ end
+
+ ##
+ # This produces a reverse descent parse tree.
+ #
+ # It currently does not support precedence of operators.
+ #
+ def tree
+ while token = @tokens.next
+ case token.type
+ when :operator
+ token.build(@nodes.pop, tree).tap do |node|
+ @nodes.push(node)
+ end
+ when :value
+ token.build.tap do |leaf|
+ @nodes.push(leaf)
+ end
+ end
+ end
+ rescue StopIteration
+ @nodes.last || Lexeme::Null.new
+ end
+
+ def self.seed(statement)
+ new(Expression::Lexer.new(statement).tokens)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/statement.rb b/lib/gitlab/ci/pipeline/expression/statement.rb
new file mode 100644
index 00000000000..09a7c98464b
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/statement.rb
@@ -0,0 +1,49 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ class Statement
+ StatementError = Class.new(StandardError)
+
+ GRAMMAR = [
+ %w[variable equals string],
+ %w[variable equals variable],
+ %w[variable equals null],
+ %w[string equals variable],
+ %w[null equals variable],
+ %w[variable]
+ ].freeze
+
+ def initialize(statement, variables = {})
+ @lexer = Expression::Lexer.new(statement)
+ @variables = variables.with_indifferent_access
+ end
+
+ def parse_tree
+ raise StatementError if @lexer.lexemes.empty?
+
+ unless GRAMMAR.find { |syntax| syntax == @lexer.lexemes }
+ raise StatementError, 'Unknown pipeline expression!'
+ end
+
+ Expression::Parser.new(@lexer.tokens).tree
+ end
+
+ def evaluate
+ parse_tree.evaluate(@variables.to_h)
+ end
+
+ def truthful?
+ evaluate.present?
+ end
+
+ def valid?
+ parse_tree.is_a?(Lexeme::Base)
+ rescue StatementError
+ false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/expression/token.rb b/lib/gitlab/ci/pipeline/expression/token.rb
new file mode 100644
index 00000000000..58211800b88
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/expression/token.rb
@@ -0,0 +1,28 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Expression
+ class Token
+ attr_reader :value, :lexeme
+
+ def initialize(value, lexeme)
+ @value = value
+ @lexeme = lexeme
+ end
+
+ def build(*args)
+ @lexeme.build(@value, *args)
+ end
+
+ def type
+ @lexeme.type
+ end
+
+ def to_lexeme
+ @lexeme.name.demodulize.downcase
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/seed/base.rb b/lib/gitlab/ci/pipeline/seed/base.rb
new file mode 100644
index 00000000000..db9706924bb
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/seed/base.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Seed
+ class Base
+ def attributes
+ raise NotImplementedError
+ end
+
+ def included?
+ raise NotImplementedError
+ end
+
+ def to_resource
+ raise NotImplementedError
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb
new file mode 100644
index 00000000000..6980b0b7aff
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/seed/build.rb
@@ -0,0 +1,48 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Seed
+ class Build < Seed::Base
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :dig, to: :@attributes
+
+ def initialize(pipeline, attributes)
+ @pipeline = pipeline
+ @attributes = attributes
+
+ @only = Gitlab::Ci::Build::Policy
+ .fabricate(attributes.delete(:only))
+ @except = Gitlab::Ci::Build::Policy
+ .fabricate(attributes.delete(:except))
+ end
+
+ def included?
+ strong_memoize(:inclusion) do
+ @only.all? { |spec| spec.satisfied_by?(@pipeline, self) } &&
+ @except.none? { |spec| spec.satisfied_by?(@pipeline, self) }
+ end
+ end
+
+ def attributes
+ @attributes.merge(
+ pipeline: @pipeline,
+ project: @pipeline.project,
+ user: @pipeline.user,
+ ref: @pipeline.ref,
+ tag: @pipeline.tag,
+ trigger_request: @pipeline.legacy_trigger,
+ protected: @pipeline.protected_ref?
+ )
+ end
+
+ def to_resource
+ strong_memoize(:resource) do
+ ::Ci::Build.new(attributes)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/pipeline/seed/stage.rb b/lib/gitlab/ci/pipeline/seed/stage.rb
new file mode 100644
index 00000000000..c101f30d6e8
--- /dev/null
+++ b/lib/gitlab/ci/pipeline/seed/stage.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ module Ci
+ module Pipeline
+ module Seed
+ class Stage < Seed::Base
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :size, to: :seeds
+ delegate :dig, to: :seeds
+
+ def initialize(pipeline, attributes)
+ @pipeline = pipeline
+ @attributes = attributes
+
+ @builds = attributes.fetch(:builds).map do |attributes|
+ Seed::Build.new(@pipeline, attributes)
+ end
+ end
+
+ def attributes
+ { name: @attributes.fetch(:name),
+ pipeline: @pipeline,
+ project: @pipeline.project }
+ end
+
+ def seeds
+ strong_memoize(:seeds) do
+ @builds.select(&:included?)
+ end
+ end
+
+ def included?
+ seeds.any?
+ end
+
+ def to_resource
+ strong_memoize(:stage) do
+ ::Ci::Stage.new(attributes).tap do |stage|
+ seeds.each { |seed| stage.builds << seed.to_resource }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/stage/seed.rb b/lib/gitlab/ci/stage/seed.rb
deleted file mode 100644
index bc97aa63b02..00000000000
--- a/lib/gitlab/ci/stage/seed.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-module Gitlab
- module Ci
- module Stage
- class Seed
- attr_reader :pipeline
-
- delegate :project, to: :pipeline
- delegate :size, to: :@jobs
-
- 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,
- protected: protected_ref?)
- 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
-
- private
-
- def protected_ref?
- @protected_ref ||= project.protected_for?(pipeline.ref)
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/ci/status/build/action.rb b/lib/gitlab/ci/status/build/action.rb
index 45fd0d4aa07..6c9125647ad 100644
--- a/lib/gitlab/ci/status/build/action.rb
+++ b/lib/gitlab/ci/status/build/action.rb
@@ -2,6 +2,9 @@ module Gitlab
module Ci
module Status
module Build
+ ##
+ # Extended status for playable manual actions.
+ #
class Action < Status::Extended
def label
if has_action?
@@ -12,7 +15,7 @@ module Gitlab
end
def self.matches?(build, user)
- build.action?
+ build.playable?
end
end
end
diff --git a/lib/gitlab/ci/status/build/cancelable.rb b/lib/gitlab/ci/status/build/cancelable.rb
index 2d9166d6bdd..024047d4983 100644
--- a/lib/gitlab/ci/status/build/cancelable.rb
+++ b/lib/gitlab/ci/status/build/cancelable.rb
@@ -23,6 +23,10 @@ module Gitlab
'Cancel'
end
+ def action_button_title
+ _('Cancel this job')
+ end
+
def self.matches?(build, user)
build.cancelable?
end
diff --git a/lib/gitlab/ci/status/build/canceled.rb b/lib/gitlab/ci/status/build/canceled.rb
new file mode 100644
index 00000000000..c83e2734a73
--- /dev/null
+++ b/lib/gitlab/ci/status/build/canceled.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Canceled < Status::Extended
+ def illustration
+ {
+ image: 'illustrations/canceled-job_empty.svg',
+ size: 'svg-430',
+ title: _('This job has been canceled')
+ }
+ end
+
+ def self.matches?(build, user)
+ build.canceled?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/created.rb b/lib/gitlab/ci/status/build/created.rb
new file mode 100644
index 00000000000..5be8e9de425
--- /dev/null
+++ b/lib/gitlab/ci/status/build/created.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Created < Status::Extended
+ def illustration
+ {
+ image: 'illustrations/job_not_triggered.svg',
+ size: 'svg-306',
+ title: _('This job has not been triggered yet'),
+ content: _('This job depends on upstream jobs that need to succeed in order for this job to be triggered')
+ }
+ end
+
+ def self.matches?(build, user)
+ build.created?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/erased.rb b/lib/gitlab/ci/status/build/erased.rb
new file mode 100644
index 00000000000..3a5113b16b6
--- /dev/null
+++ b/lib/gitlab/ci/status/build/erased.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Erased < Status::Extended
+ def illustration
+ {
+ image: 'illustrations/skipped-job_empty.svg',
+ size: 'svg-430',
+ title: _('Job has been erased')
+ }
+ end
+
+ def self.matches?(build, user)
+ build.erased?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/factory.rb b/lib/gitlab/ci/status/build/factory.rb
index c852d607373..2b26ebb45a1 100644
--- a/lib/gitlab/ci/status/build/factory.rb
+++ b/lib/gitlab/ci/status/build/factory.rb
@@ -4,12 +4,20 @@ module Gitlab
module Build
class Factory < Status::Factory
def self.extended_statuses
- [[Status::Build::Cancelable,
+ [[Status::Build::Erased,
+ Status::Build::Manual,
+ Status::Build::Canceled,
+ Status::Build::Created,
+ Status::Build::Pending,
+ Status::Build::Skipped],
+ [Status::Build::Cancelable,
Status::Build::Retryable],
+ [Status::Build::Failed],
[Status::Build::FailedAllowed,
Status::Build::Play,
Status::Build::Stop],
- [Status::Build::Action]]
+ [Status::Build::Action],
+ [Status::Build::Retried]]
end
def self.common_helpers
diff --git a/lib/gitlab/ci/status/build/failed.rb b/lib/gitlab/ci/status/build/failed.rb
new file mode 100644
index 00000000000..155f4fc1343
--- /dev/null
+++ b/lib/gitlab/ci/status/build/failed.rb
@@ -0,0 +1,40 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Failed < Status::Extended
+ REASONS = {
+ 'unknown_failure' => 'unknown failure',
+ 'script_failure' => 'script failure',
+ 'api_failure' => 'API failure',
+ 'stuck_or_timeout_failure' => 'stuck or timeout failure',
+ 'runner_system_failure' => 'runner system failure',
+ 'missing_dependency_failure' => 'missing dependency failure'
+ }.freeze
+
+ def status_tooltip
+ base_message
+ end
+
+ def badge_tooltip
+ base_message
+ end
+
+ def self.matches?(build, user)
+ build.failed?
+ end
+
+ private
+
+ def base_message
+ "#{s_('CiStatusLabel|failed')} #{description}"
+ end
+
+ def description
+ "<br> (#{REASONS[subject.failure_reason]})"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/failed_allowed.rb b/lib/gitlab/ci/status/build/failed_allowed.rb
index dc90f398c7e..ca0046fb1f7 100644
--- a/lib/gitlab/ci/status/build/failed_allowed.rb
+++ b/lib/gitlab/ci/status/build/failed_allowed.rb
@@ -4,7 +4,7 @@ module Gitlab
module Build
class FailedAllowed < Status::Extended
def label
- 'failed (allowed to fail)'
+ "failed #{allowed_to_fail_title}"
end
def icon
@@ -15,9 +15,19 @@ module Gitlab
'failed_with_warnings'
end
+ def status_tooltip
+ "#{@status.status_tooltip} #{allowed_to_fail_title}"
+ end
+
def self.matches?(build, user)
build.failed? && build.allow_failure?
end
+
+ private
+
+ def allowed_to_fail_title
+ "(allowed to fail)"
+ end
end
end
end
diff --git a/lib/gitlab/ci/status/build/manual.rb b/lib/gitlab/ci/status/build/manual.rb
new file mode 100644
index 00000000000..042da6392d3
--- /dev/null
+++ b/lib/gitlab/ci/status/build/manual.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Manual < Status::Extended
+ def illustration
+ {
+ image: 'illustrations/manual_action.svg',
+ size: 'svg-394',
+ title: _('This job requires a manual action'),
+ content: _('This job depends on a user to trigger its process. Often they are used to deploy code to production environments')
+ }
+ end
+
+ def self.matches?(build, user)
+ build.playable?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/pending.rb b/lib/gitlab/ci/status/build/pending.rb
new file mode 100644
index 00000000000..9dd9a27ad57
--- /dev/null
+++ b/lib/gitlab/ci/status/build/pending.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Pending < Status::Extended
+ def illustration
+ {
+ image: 'illustrations/pending_job_empty.svg',
+ size: 'svg-430',
+ title: _('This job has not started yet'),
+ content: _('This job is in pending state and is waiting to be picked by a runner')
+ }
+ end
+
+ def self.matches?(build, user)
+ build.pending?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb
index b7b45466d3b..a8b9ebf0803 100644
--- a/lib/gitlab/ci/status/build/play.rb
+++ b/lib/gitlab/ci/status/build/play.rb
@@ -19,6 +19,10 @@ module Gitlab
'Play'
end
+ def action_button_title
+ _('Trigger this manual action')
+ end
+
def action_path
play_project_job_path(subject.project, subject)
end
diff --git a/lib/gitlab/ci/status/build/retried.rb b/lib/gitlab/ci/status/build/retried.rb
new file mode 100644
index 00000000000..6e190e4ee3c
--- /dev/null
+++ b/lib/gitlab/ci/status/build/retried.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Retried < Status::Extended
+ def status_tooltip
+ @status.status_tooltip + " (retried)"
+ end
+
+ def self.matches?(build, user)
+ build.retried?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/retryable.rb b/lib/gitlab/ci/status/build/retryable.rb
index 44ffe783e50..5aeb8e51480 100644
--- a/lib/gitlab/ci/status/build/retryable.rb
+++ b/lib/gitlab/ci/status/build/retryable.rb
@@ -15,6 +15,10 @@ module Gitlab
'Retry'
end
+ def action_button_title
+ _('Retry this job')
+ end
+
def action_path
retry_project_job_path(subject.project, subject)
end
diff --git a/lib/gitlab/ci/status/build/skipped.rb b/lib/gitlab/ci/status/build/skipped.rb
new file mode 100644
index 00000000000..3e678d0baee
--- /dev/null
+++ b/lib/gitlab/ci/status/build/skipped.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module Ci
+ module Status
+ module Build
+ class Skipped < Status::Extended
+ def illustration
+ {
+ image: 'illustrations/skipped-job_empty.svg',
+ size: 'svg-430',
+ title: _('This job has been skipped')
+ }
+ end
+
+ def self.matches?(build, user)
+ build.skipped?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb
index 46e730797e4..dea838bfa39 100644
--- a/lib/gitlab/ci/status/build/stop.rb
+++ b/lib/gitlab/ci/status/build/stop.rb
@@ -19,6 +19,10 @@ module Gitlab
'Stop'
end
+ def action_button_title
+ _('Stop this environment')
+ end
+
def action_path
play_project_job_path(subject.project, subject)
end
diff --git a/lib/gitlab/ci/status/core.rb b/lib/gitlab/ci/status/core.rb
index d4fd83b93f8..9d6a2f51c11 100644
--- a/lib/gitlab/ci/status/core.rb
+++ b/lib/gitlab/ci/status/core.rb
@@ -22,6 +22,10 @@ module Gitlab
raise NotImplementedError
end
+ def illustration
+ raise NotImplementedError
+ end
+
def label
raise NotImplementedError
end
@@ -57,6 +61,20 @@ module Gitlab
def action_title
raise NotImplementedError
end
+
+ def action_button_title
+ raise NotImplementedError
+ end
+
+ # Hint that appears on all the pipeline graph tooltips and builds on the right sidebar in Job detail view
+ def status_tooltip
+ label
+ end
+
+ # Hint that appears on the build badges
+ def badge_tooltip
+ subject.status
+ end
end
end
end
diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb
index baf55b1fa07..cedf4171ab1 100644
--- a/lib/gitlab/ci/trace.rb
+++ b/lib/gitlab/ci/trace.rb
@@ -1,6 +1,8 @@
module Gitlab
module Ci
class Trace
+ ArchiveError = Class.new(StandardError)
+
attr_reader :job
delegate :old_trace, to: :job
@@ -52,12 +54,14 @@ module Gitlab
end
def exist?
- current_path.present? || old_trace.present?
+ trace_artifact&.exists? || current_path.present? || old_trace.present?
end
def read
stream = Gitlab::Ci::Trace::Stream.new do
- if current_path
+ if trace_artifact
+ trace_artifact.open
+ elsif current_path
File.open(current_path, "rb")
elsif old_trace
StringIO.new(old_trace)
@@ -82,6 +86,8 @@ module Gitlab
end
def erase!
+ trace_artifact&.destroy
+
paths.each do |trace_path|
FileUtils.rm(trace_path, force: true)
end
@@ -89,8 +95,53 @@ module Gitlab
job.erase_old_trace!
end
+ def archive!
+ raise ArchiveError, 'Already archived' if trace_artifact
+ raise ArchiveError, 'Job is not finished yet' unless job.complete?
+
+ if current_path
+ File.open(current_path) do |stream|
+ archive_stream!(stream)
+ FileUtils.rm(current_path)
+ end
+ elsif old_trace
+ StringIO.new(old_trace, 'rb').tap do |stream|
+ archive_stream!(stream)
+ job.erase_old_trace!
+ end
+ end
+ end
+
private
+ def archive_stream!(stream)
+ clone_file!(stream, JobArtifactUploader.workhorse_upload_path) do |clone_path|
+ create_job_trace!(job, clone_path)
+ end
+ end
+
+ def clone_file!(src_stream, temp_dir)
+ FileUtils.mkdir_p(temp_dir)
+ Dir.mktmpdir('tmp-trace', temp_dir) do |dir_path|
+ temp_path = File.join(dir_path, "job.log")
+ FileUtils.touch(temp_path)
+ size = IO.copy_stream(src_stream, temp_path)
+ raise ArchiveError, 'Failed to copy stream' unless size == src_stream.size
+
+ yield(temp_path)
+ end
+ end
+
+ def create_job_trace!(job, path)
+ File.open(path) do |stream|
+ job.create_job_artifacts_trace!(
+ project: job.project,
+ file_type: :trace,
+ file: stream,
+ file_sha256: Digest::SHA256.file(path).hexdigest)
+ end
+ end
+
def ensure_path
return current_path if current_path
@@ -137,6 +188,10 @@ module Gitlab
"#{job.id}.log"
) if job.project&.ci_id
end
+
+ def trace_artifact
+ job.job_artifacts_trace
+ end
end
end
end
diff --git a/lib/gitlab/ci/trace/http_io.rb b/lib/gitlab/ci/trace/http_io.rb
new file mode 100644
index 00000000000..ac4308f4e2c
--- /dev/null
+++ b/lib/gitlab/ci/trace/http_io.rb
@@ -0,0 +1,187 @@
+##
+# This class is compatible with IO class (https://ruby-doc.org/core-2.3.1/IO.html)
+# source: https://gitlab.com/snippets/1685610
+module Gitlab
+ module Ci
+ class Trace
+ class HttpIO
+ BUFFER_SIZE = 128.kilobytes
+
+ InvalidURLError = Class.new(StandardError)
+ FailedToGetChunkError = Class.new(StandardError)
+
+ attr_reader :uri, :size
+ attr_reader :tell
+ attr_reader :chunk, :chunk_range
+
+ alias_method :pos, :tell
+
+ def initialize(url, size)
+ raise InvalidURLError unless ::Gitlab::UrlSanitizer.valid?(url)
+
+ @uri = URI(url)
+ @size = size
+ @tell = 0
+ end
+
+ def close
+ # no-op
+ end
+
+ def binmode
+ # no-op
+ end
+
+ def binmode?
+ true
+ end
+
+ def path
+ nil
+ end
+
+ def url
+ @uri.to_s
+ end
+
+ def seek(pos, where = IO::SEEK_SET)
+ new_pos =
+ case where
+ when IO::SEEK_END
+ size + pos
+ when IO::SEEK_SET
+ pos
+ when IO::SEEK_CUR
+ tell + pos
+ else
+ -1
+ end
+
+ raise 'new position is outside of file' if new_pos < 0 || new_pos > size
+
+ @tell = new_pos
+ end
+
+ def eof?
+ tell == size
+ end
+
+ def each_line
+ until eof?
+ line = readline
+ break if line.nil?
+
+ yield(line)
+ end
+ end
+
+ def read(length = nil)
+ out = ""
+
+ until eof? || (length && out.length >= length)
+ data = get_chunk
+ break if data.empty?
+
+ out << data
+ @tell += data.bytesize
+ end
+
+ out = out[0, length] if length && out.length > length
+
+ out
+ end
+
+ def readline
+ out = ""
+
+ until eof?
+ data = get_chunk
+ new_line = data.index("\n")
+
+ if !new_line.nil?
+ out << data[0..new_line]
+ @tell += new_line + 1
+ break
+ else
+ out << data
+ @tell += data.bytesize
+ end
+ end
+
+ out
+ end
+
+ def write(data)
+ raise NotImplementedError
+ end
+
+ def truncate(offset)
+ raise NotImplementedError
+ end
+
+ def flush
+ raise NotImplementedError
+ end
+
+ def present?
+ true
+ end
+
+ private
+
+ ##
+ # The below methods are not implemented in IO class
+ #
+ def in_range?
+ @chunk_range&.include?(tell)
+ end
+
+ def get_chunk
+ unless in_range?
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
+ http.request(request)
+ end
+
+ raise FailedToGetChunkError unless response.code == '200' || response.code == '206'
+
+ @chunk = response.body.force_encoding(Encoding::BINARY)
+ @chunk_range = response.content_range
+
+ ##
+ # Note: If provider does not return content_range, then we set it as we requested
+ # Provider: minio
+ # - When the file size is larger than requested Content-range, the Content-range is included in responces with Net::HTTPPartialContent 206
+ # - When the file size is smaller than requested Content-range, the Content-range is included in responces with Net::HTTPPartialContent 206
+ # Provider: AWS
+ # - When the file size is larger than requested Content-range, the Content-range is included in responces with Net::HTTPPartialContent 206
+ # - When the file size is smaller than requested Content-range, the Content-range is included in responces with Net::HTTPPartialContent 206
+ # Provider: GCS
+ # - When the file size is larger than requested Content-range, the Content-range is included in responces with Net::HTTPPartialContent 206
+ # - When the file size is smaller than requested Content-range, the Content-range is included in responces with Net::HTTPOK 200
+ @chunk_range ||= (chunk_start...(chunk_start + @chunk.length))
+ end
+
+ @chunk[chunk_offset..BUFFER_SIZE]
+ end
+
+ def request
+ Net::HTTP::Get.new(uri).tap do |request|
+ request.set_range(chunk_start, BUFFER_SIZE)
+ end
+ end
+
+ def chunk_offset
+ tell % BUFFER_SIZE
+ end
+
+ def chunk_start
+ (tell / BUFFER_SIZE) * BUFFER_SIZE
+ end
+
+ def chunk_end
+ [chunk_start + BUFFER_SIZE, size].min
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb
index d52194f688b..54894a46077 100644
--- a/lib/gitlab/ci/trace/stream.rb
+++ b/lib/gitlab/ci/trace/stream.rb
@@ -8,7 +8,7 @@ module Gitlab
attr_reader :stream
- delegate :close, :tell, :seek, :size, :path, :truncate, to: :stream, allow_nil: true
+ delegate :close, :tell, :seek, :size, :url, :truncate, to: :stream, allow_nil: true
delegate :valid?, to: :stream, as: :present?, allow_nil: true
@@ -25,6 +25,10 @@ module Gitlab
self.path.present?
end
+ def path
+ self.stream.path if self.stream.respond_to?(:path)
+ end
+
def limit(last_bytes = LIMIT_SIZE)
if last_bytes < size
stream.seek(-last_bytes, IO::SEEK_END)
diff --git a/lib/gitlab/ci/variables/collection.rb b/lib/gitlab/ci/variables/collection.rb
new file mode 100644
index 00000000000..ad30b3f427c
--- /dev/null
+++ b/lib/gitlab/ci/variables/collection.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ module Ci
+ module Variables
+ class Collection
+ include Enumerable
+
+ def initialize(variables = [])
+ @variables = []
+
+ variables.each { |variable| self.append(variable) }
+ end
+
+ def append(resource)
+ tap { @variables.append(Collection::Item.fabricate(resource)) }
+ end
+
+ def concat(resources)
+ tap { resources.each { |variable| self.append(variable) } }
+ end
+
+ def each
+ @variables.each { |variable| yield variable }
+ end
+
+ def +(other)
+ self.class.new.tap do |collection|
+ self.each { |variable| collection.append(variable) }
+ other.each { |variable| collection.append(variable) }
+ end
+ end
+
+ def to_runner_variables
+ self.map(&:to_runner_variable)
+ end
+
+ def to_hash
+ self.to_runner_variables
+ .map { |env| [env.fetch(:key), env.fetch(:value)] }
+ .to_h.with_indifferent_access
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/variables/collection/item.rb b/lib/gitlab/ci/variables/collection/item.rb
new file mode 100644
index 00000000000..23ed71db8b0
--- /dev/null
+++ b/lib/gitlab/ci/variables/collection/item.rb
@@ -0,0 +1,50 @@
+module Gitlab
+ module Ci
+ module Variables
+ class Collection
+ class Item
+ def initialize(**options)
+ @variable = {
+ key: options.fetch(:key),
+ value: options.fetch(:value),
+ public: options.fetch(:public, true),
+ file: options.fetch(:files, false)
+ }
+ end
+
+ def [](key)
+ @variable.fetch(key)
+ end
+
+ def ==(other)
+ to_runner_variable == self.class.fabricate(other).to_runner_variable
+ end
+
+ ##
+ # If `file: true` has been provided we expose it, otherwise we
+ # don't expose `file` attribute at all (stems from what the runner
+ # expects).
+ #
+ def to_runner_variable
+ @variable.reject do |hash_key, hash_value|
+ hash_key == :file && hash_value == false
+ end
+ end
+
+ def self.fabricate(resource)
+ case resource
+ when Hash
+ self.new(resource)
+ when ::HasVariable
+ self.new(resource.to_runner_variable)
+ when self
+ resource.dup
+ else
+ raise ArgumentError, "Unknown `#{resource.class}` variable resource!"
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb
index 0bd78b03448..e829f2a95f8 100644
--- a/lib/gitlab/ci/yaml_processor.rb
+++ b/lib/gitlab/ci/yaml_processor.rb
@@ -7,8 +7,8 @@ module Gitlab
attr_reader :cache, :stages, :jobs
- def initialize(config)
- @ci_config = Gitlab::Ci::Config.new(config)
+ def initialize(config, opts = {})
+ @ci_config = Gitlab::Ci::Config.new(config, opts)
@config = @ci_config.to_hash
unless @ci_config.valid?
@@ -27,7 +27,7 @@ module Gitlab
end
def build_attributes(name)
- job = @jobs[name.to_sym] || {}
+ job = @jobs.fetch(name.to_sym, {})
{ stage_idx: @stages.index(job[:stage]),
stage: job[:stage],
@@ -53,39 +53,33 @@ module Gitlab
}.compact }
end
- def pipeline_stage_builds(stage, pipeline)
- selected_jobs = @jobs.select do |_, job|
- next unless job[:stage] == stage
-
- only_specs = Gitlab::Ci::Build::Policy
- .fabricate(job.fetch(:only, {}))
- except_specs = Gitlab::Ci::Build::Policy
- .fabricate(job.fetch(:except, {}))
-
- only_specs.all? { |spec| spec.satisfied_by?(pipeline) } &&
- except_specs.none? { |spec| spec.satisfied_by?(pipeline) }
- end
-
- selected_jobs.map { |_, job| build_attributes(job[:name]) }
+ def stage_builds_attributes(stage)
+ @jobs.values
+ .select { |job| job[:stage] == stage }
+ .map { |job| build_attributes(job[:name]) }
end
- def stage_seeds(pipeline)
- seeds = @stages.uniq.map do |stage|
- builds = pipeline_stage_builds(stage, pipeline)
+ def stages_attributes
+ @stages.uniq.map do |stage|
+ seeds = stage_builds_attributes(stage).map do |attributes|
+ job = @jobs.fetch(attributes[:name].to_sym)
- Gitlab::Ci::Stage::Seed.new(pipeline, stage, builds) if builds.any?
- end
+ attributes
+ .merge(only: job.fetch(:only, {}))
+ .merge(except: job.fetch(:except, {}))
+ end
- seeds.compact
+ { name: stage, index: @stages.index(stage), builds: seeds }
+ end
end
- def self.validation_message(content)
+ def self.validation_message(content, opts = {})
return 'Please provide content of .gitlab-ci.yml' if content.blank?
begin
- Gitlab::Ci::YamlProcessor.new(content)
+ Gitlab::Ci::YamlProcessor.new(content, opts)
nil
- rescue ValidationError, Psych::SyntaxError => e
+ rescue ValidationError => e
e.message
end
end
diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb
index 76aee5a3deb..65a65b67975 100644
--- a/lib/gitlab/conflict/file_collection.rb
+++ b/lib/gitlab/conflict/file_collection.rb
@@ -1,24 +1,29 @@
module Gitlab
module Conflict
class FileCollection
+ include Gitlab::RepositoryCacheAdapter
+
attr_reader :merge_request, :resolver
def initialize(merge_request)
our_commit = merge_request.source_branch_head.raw
their_commit = merge_request.target_branch_head.raw
- target_repo = merge_request.target_project.repository.raw
+ @target_repo = merge_request.target_project.repository
@source_repo = merge_request.source_project.repository.raw
- @resolver = Gitlab::Git::Conflict::Resolver.new(target_repo, our_commit.id, their_commit.id)
+ @our_commit_id = our_commit.id
+ @their_commit_id = their_commit.id
+ @resolver = Gitlab::Git::Conflict::Resolver.new(@target_repo.raw, @our_commit_id, @their_commit_id)
@merge_request = merge_request
end
def resolve(user, commit_message, files)
+ msg = commit_message || default_commit_message
+ resolution = Gitlab::Git::Conflict::Resolution.new(user, files, msg)
args = {
source_branch: merge_request.source_branch,
- target_branch: merge_request.target_branch,
- commit_message: commit_message || default_commit_message
+ target_branch: merge_request.target_branch
}
- resolver.resolve_conflicts(@source_repo, user, files, args)
+ resolver.resolve_conflicts(@source_repo, resolution, args)
ensure
@merge_request.clear_memoized_shas
end
@@ -29,6 +34,20 @@ module Gitlab
end
end
+ def can_be_resolved_in_ui?
+ # 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.
+ files.each(&:lines)
+ files.any?
+ rescue Gitlab::Git::CommandError,
+ Gitlab::Git::Conflict::Parser::UnresolvableError,
+ Gitlab::Git::Conflict::Resolver::ConflictSideMissing,
+ Gitlab::Git::Conflict::File::UnsupportedEncoding
+ false
+ end
+ cache_method :can_be_resolved_in_ui?
+
def file_for_path(old_path, new_path)
files.find { |file| file.their_path == old_path && file.our_path == new_path }
end
@@ -55,6 +74,19 @@ Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branc
#{conflict_filenames.join("\n")}
EOM
end
+
+ private
+
+ def cache
+ @cache ||= begin
+ # Use the commit ids as a namespace so if the MR branches get
+ # updated we instantiate the cache under a different namespace. That
+ # way don't have to worry about explicitly invalidating the cache
+ namespace = "#{@our_commit_id}:#{@their_commit_id}"
+
+ Gitlab::RepositoryCache.new(@target_repo, extra_namespace: namespace)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb
index 0735243e021..d7369060cc5 100644
--- a/lib/gitlab/contributions_calendar.rb
+++ b/lib/gitlab/contributions_calendar.rb
@@ -23,7 +23,7 @@ module Gitlab
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")
+ .having(action: [Event::COMMENTED])
union = Gitlab::SQL::Union.new([repo_events, issue_events, mr_events, note_events])
events = Event.find_by_sql(union.to_sql).map(&:attributes)
@@ -34,6 +34,8 @@ module Gitlab
end
def events_by_date(date)
+ return Event.none unless can_read_cross_project?
+
events = Event.contributions.where(author_id: contributor.id)
.where(created_at: date.beginning_of_day..date.end_of_day)
.where(project_id: projects)
@@ -53,6 +55,10 @@ module Gitlab
private
+ def can_read_cross_project?
+ Ability.allowed?(current_user, :read_cross_project)
+ end
+
def event_counts(date_from, feature)
t = Event.arel_table
diff --git a/lib/gitlab/cross_project_access.rb b/lib/gitlab/cross_project_access.rb
new file mode 100644
index 00000000000..6eaed51b64c
--- /dev/null
+++ b/lib/gitlab/cross_project_access.rb
@@ -0,0 +1,67 @@
+module Gitlab
+ class CrossProjectAccess
+ class << self
+ delegate :add_check, :find_check, :checks,
+ to: :instance
+ end
+
+ def self.instance
+ @instance ||= new
+ end
+
+ attr_reader :checks
+
+ def initialize
+ @checks = {}
+ end
+
+ def add_check(
+ klass,
+ actions: {},
+ positive_condition: nil,
+ negative_condition: nil,
+ skip: false)
+
+ new_check = CheckInfo.new(actions,
+ positive_condition,
+ negative_condition,
+ skip
+ )
+
+ @checks[klass] ||= Gitlab::CrossProjectAccess::CheckCollection.new
+ @checks[klass].add_check(new_check)
+ recalculate_checks_for_class(klass)
+
+ @checks[klass]
+ end
+
+ def find_check(object)
+ @cached_checks ||= Hash.new do |cache, new_class|
+ parent_classes = @checks.keys.select { |existing_class| new_class <= existing_class }
+ closest_class = closest_parent(parent_classes, new_class)
+ cache[new_class] = @checks[closest_class]
+ end
+
+ @cached_checks[object.class]
+ end
+
+ private
+
+ def recalculate_checks_for_class(klass)
+ new_collection = @checks[klass]
+
+ @checks.each do |existing_class, existing_check_collection|
+ if existing_class < klass
+ existing_check_collection.add_collection(new_collection)
+ elsif klass < existing_class
+ new_collection.add_collection(existing_check_collection)
+ end
+ end
+ end
+
+ def closest_parent(classes, subject)
+ relevant_ancestors = subject.ancestors & classes
+ relevant_ancestors.first
+ end
+ end
+end
diff --git a/lib/gitlab/cross_project_access/check_collection.rb b/lib/gitlab/cross_project_access/check_collection.rb
new file mode 100644
index 00000000000..88376232065
--- /dev/null
+++ b/lib/gitlab/cross_project_access/check_collection.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ class CrossProjectAccess
+ class CheckCollection
+ attr_reader :checks
+
+ def initialize
+ @checks = []
+ end
+
+ def add_collection(collection)
+ @checks |= collection.checks
+ end
+
+ def add_check(check)
+ @checks << check
+ end
+
+ def should_run?(object)
+ skips, runs = arranged_checks
+
+ # If one rule tells us to skip, we skip the cross project check
+ return false if skips.any? { |check| check.should_skip?(object) }
+
+ # If the rule isn't skipped, we run it if any of the checks says we
+ # should run
+ runs.any? { |check| check.should_run?(object) }
+ end
+
+ def arranged_checks
+ return [@skips, @runs] if @skips && @runs
+
+ @skips = []
+ @runs = []
+
+ @checks.each do |check|
+ if check.skip
+ @skips << check
+ else
+ @runs << check
+ end
+ end
+
+ [@skips, @runs]
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cross_project_access/check_info.rb b/lib/gitlab/cross_project_access/check_info.rb
new file mode 100644
index 00000000000..e8a845c7f1e
--- /dev/null
+++ b/lib/gitlab/cross_project_access/check_info.rb
@@ -0,0 +1,66 @@
+module Gitlab
+ class CrossProjectAccess
+ class CheckInfo
+ attr_accessor :actions, :positive_condition, :negative_condition, :skip
+
+ def initialize(actions, positive_condition, negative_condition, skip)
+ @actions = actions
+ @positive_condition = positive_condition
+ @negative_condition = negative_condition
+ @skip = skip
+ end
+
+ def should_skip?(object)
+ return !should_run?(object) unless @skip
+
+ skip_for_action = @actions[current_action(object)]
+ skip_for_action = false if @actions[current_action(object)].nil?
+
+ # We need to do the opposite of what was defined in the following cases:
+ # - skip_cross_project_access_check index: true, if: -> { false }
+ # - skip_cross_project_access_check index: true, unless: -> { true }
+ if positive_condition_is_false?(object)
+ skip_for_action = !skip_for_action
+ end
+
+ if negative_condition_is_true?(object)
+ skip_for_action = !skip_for_action
+ end
+
+ skip_for_action
+ end
+
+ def should_run?(object)
+ return !should_skip?(object) if @skip
+
+ run_for_action = @actions[current_action(object)]
+ run_for_action = true if @actions[current_action(object)].nil?
+
+ # We need to do the opposite of what was defined in the following cases:
+ # - requires_cross_project_access index: true, if: -> { false }
+ # - requires_cross_project_access index: true, unless: -> { true }
+ if positive_condition_is_false?(object)
+ run_for_action = !run_for_action
+ end
+
+ if negative_condition_is_true?(object)
+ run_for_action = !run_for_action
+ end
+
+ run_for_action
+ end
+
+ def positive_condition_is_false?(object)
+ @positive_condition && !object.instance_exec(&@positive_condition)
+ end
+
+ def negative_condition_is_true?(object)
+ @negative_condition && object.instance_exec(&@negative_condition)
+ end
+
+ def current_action(object)
+ object.respond_to?(:action_name) ? object.action_name.to_sym : nil
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cross_project_access/class_methods.rb b/lib/gitlab/cross_project_access/class_methods.rb
new file mode 100644
index 00000000000..90eac94800c
--- /dev/null
+++ b/lib/gitlab/cross_project_access/class_methods.rb
@@ -0,0 +1,48 @@
+module Gitlab
+ class CrossProjectAccess
+ module ClassMethods
+ def requires_cross_project_access(*args)
+ positive_condition, negative_condition, actions = extract_params(args)
+
+ Gitlab::CrossProjectAccess.add_check(
+ self,
+ actions: actions,
+ positive_condition: positive_condition,
+ negative_condition: negative_condition
+ )
+ end
+
+ def skip_cross_project_access_check(*args)
+ positive_condition, negative_condition, actions = extract_params(args)
+
+ Gitlab::CrossProjectAccess.add_check(
+ self,
+ actions: actions,
+ positive_condition: positive_condition,
+ negative_condition: negative_condition,
+ skip: true
+ )
+ end
+
+ private
+
+ def extract_params(args)
+ actions = {}
+ positive_condition = nil
+ negative_condition = nil
+
+ args.each do |argument|
+ if argument.is_a?(Hash)
+ positive_condition = argument.delete(:if)
+ negative_condition = argument.delete(:unless)
+ actions.merge!(argument)
+ else
+ actions[argument] = true
+ end
+ end
+
+ [positive_condition, negative_condition, actions]
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb
index 91fd9cc7631..e392a015b91 100644
--- a/lib/gitlab/current_settings.rb
+++ b/lib/gitlab/current_settings.rb
@@ -1,73 +1,79 @@
module Gitlab
module CurrentSettings
- extend self
+ class << self
+ def current_application_settings
+ if RequestStore.active?
+ RequestStore.fetch(:current_application_settings) { ensure_application_settings! }
+ else
+ ensure_application_settings!
+ end
+ end
- def current_application_settings
- if RequestStore.active?
- RequestStore.fetch(:current_application_settings) { ensure_application_settings! }
- else
- ensure_application_settings!
+ def fake_application_settings(defaults = ::ApplicationSetting.defaults)
+ Gitlab::FakeApplicationSettings.new(defaults)
end
- end
- delegate :sidekiq_throttling_enabled?, to: :current_application_settings
+ def method_missing(name, *args, &block)
+ current_application_settings.send(name, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
+ end
- def fake_application_settings(defaults = ::ApplicationSetting.defaults)
- FakeApplicationSettings.new(defaults)
- end
+ def respond_to_missing?(name, include_private = false)
+ current_application_settings.respond_to?(name, include_private) || super
+ end
- private
+ private
- def ensure_application_settings!
- return in_memory_application_settings if ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true'
+ def ensure_application_settings!
+ return in_memory_application_settings if ENV['IN_MEMORY_APPLICATION_SETTINGS'] == 'true'
- cached_application_settings || uncached_application_settings
- end
+ cached_application_settings || uncached_application_settings
+ end
- 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
+ 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
+ end
end
- end
- def uncached_application_settings
- return fake_application_settings unless connect_to_db?
+ def uncached_application_settings
+ return fake_application_settings unless connect_to_db?
- db_settings = ::ApplicationSetting.current
+ 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
+ # 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
- return db_settings if db_settings.present?
+ return db_settings if db_settings.present?
- ::ApplicationSetting.create_from_defaults || in_memory_application_settings
- end
+ ::ApplicationSetting.create_from_defaults || in_memory_application_settings
+ end
- def in_memory_application_settings
- @in_memory_application_settings ||= ::ApplicationSetting.new(::ApplicationSetting.defaults) # rubocop:disable Gitlab/ModuleWithInstanceVariables
- 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 in_memory_application_settings
+ @in_memory_application_settings ||= ::ApplicationSetting.new(::ApplicationSetting.defaults) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ 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 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
+ 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
- active_db_connection &&
- ActiveRecord::Base.connection.table_exists?('application_settings')
- rescue ActiveRecord::NoDatabaseError
- false
+ active_db_connection &&
+ Gitlab::Database.cached_table_exists?('application_settings')
+ rescue ActiveRecord::NoDatabaseError
+ false
+ end
end
end
end
diff --git a/lib/gitlab/cycle_analytics/base_query.rb b/lib/gitlab/cycle_analytics/base_query.rb
index dcbdf9a64b0..86d708be0d6 100644
--- a/lib/gitlab/cycle_analytics/base_query.rb
+++ b/lib/gitlab/cycle_analytics/base_query.rb
@@ -8,14 +8,14 @@ module Gitlab
private
def base_query
- @base_query ||= stage_query
+ @base_query ||= stage_query(@project.id) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
- def stage_query
+ def stage_query(project_ids)
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)) # rubocop:disable Gitlab/ModuleWithInstanceVariables
- .where(issue_table[:deleted_at].eq(nil))
+ .project(issue_table[:project_id].as("project_id"))
+ .where(issue_table[:project_id].in(project_ids))
.where(issue_table[:created_at].gteq(@options[:from])) # rubocop:disable Gitlab/ModuleWithInstanceVariables
# Load merge_requests
diff --git a/lib/gitlab/cycle_analytics/base_stage.rb b/lib/gitlab/cycle_analytics/base_stage.rb
index cac31ea8cff..038d5a19bc4 100644
--- a/lib/gitlab/cycle_analytics/base_stage.rb
+++ b/lib/gitlab/cycle_analytics/base_stage.rb
@@ -21,17 +21,28 @@ module Gitlab
end
def median
- cte_table = Arel::Table.new("cte_table_for_#{name}")
+ BatchLoader.for(@project.id).batch(key: name) do |project_ids, loader|
+ cte_table = Arel::Table.new("cte_table_for_#{name}")
- # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time).
- # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time).
- # We compute the (end_time - start_time) interval, and give it an alias based on the current
- # cycle analytics stage.
- interval_query = Arel::Nodes::As.new(
- cte_table,
- subtract_datetimes(base_query.dup, start_time_attrs, end_time_attrs, name.to_s))
+ # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time).
+ # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time).
+ # We compute the (end_time - start_time) interval, and give it an alias based on the current
+ # cycle analytics stage.
+ interval_query = Arel::Nodes::As.new(cte_table,
+ subtract_datetimes(stage_query(project_ids), start_time_attrs, end_time_attrs, name.to_s))
- median_datetime(cte_table, interval_query, name)
+ if project_ids.one?
+ loader.call(@project.id, median_datetime(cte_table, interval_query, name))
+ else
+ begin
+ median_datetimes(cte_table, interval_query, name, :project_id)&.each do |project_id, median|
+ loader.call(project_id, median)
+ end
+ rescue NotSupportedError
+ {}
+ end
+ end
+ end
end
def name
diff --git a/lib/gitlab/cycle_analytics/production_helper.rb b/lib/gitlab/cycle_analytics/production_helper.rb
index 7a889b3877f..d0ca62e46e4 100644
--- a/lib/gitlab/cycle_analytics/production_helper.rb
+++ b/lib/gitlab/cycle_analytics/production_helper.rb
@@ -1,8 +1,8 @@
module Gitlab
module CycleAnalytics
module ProductionHelper
- def stage_query
- super
+ def stage_query(project_ids)
+ super(project_ids)
.where(mr_metrics_table[:first_deployed_to_production_at]
.gteq(@options[:from])) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb
index 2b5f72bef89..0e9d235ca79 100644
--- a/lib/gitlab/cycle_analytics/test_stage.rb
+++ b/lib/gitlab/cycle_analytics/test_stage.rb
@@ -25,11 +25,11 @@ module Gitlab
_("Total test time for all commits/merges")
end
- def stage_query
+ def stage_query(project_ids)
if @options[:branch]
- super.where(build_table[:ref].eq(@options[:branch]))
+ super(project_ids).where(build_table[:ref].eq(@options[:branch]))
else
- super
+ super(project_ids)
end
end
end
diff --git a/lib/gitlab/cycle_analytics/usage_data.rb b/lib/gitlab/cycle_analytics/usage_data.rb
new file mode 100644
index 00000000000..5122e3417ca
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/usage_data.rb
@@ -0,0 +1,72 @@
+module Gitlab
+ module CycleAnalytics
+ class UsageData
+ PROJECTS_LIMIT = 10
+
+ attr_reader :projects, :options
+
+ def initialize
+ @projects = Project.sorted_by_activity.limit(PROJECTS_LIMIT)
+ @options = { from: 7.days.ago }
+ end
+
+ def to_json
+ total = 0
+
+ values =
+ medians_per_stage.each_with_object({}) do |(stage_name, medians), hsh|
+ calculations = stage_values(medians)
+
+ total += calculations.values.compact.sum
+ hsh[stage_name] = calculations
+ end
+
+ values[:total] = total
+
+ { avg_cycle_analytics: values }
+ end
+
+ private
+
+ def medians_per_stage
+ projects.each_with_object({}) do |project, hsh|
+ ::CycleAnalytics.new(project, options).all_medians_per_stage.each do |stage_name, median|
+ hsh[stage_name] ||= []
+ hsh[stage_name] << median
+ end
+ end
+ end
+
+ def stage_values(medians)
+ medians = medians.map(&:presence).compact
+ average = calc_average(medians)
+
+ {
+ average: average,
+ sd: standard_deviation(medians, average),
+ missing: projects.length - medians.length
+ }
+ end
+
+ def calc_average(values)
+ return if values.empty?
+
+ (values.sum / values.length).to_i
+ end
+
+ def standard_deviation(values, average)
+ Math.sqrt(sample_variance(values, average)).to_i
+ end
+
+ def sample_variance(values, average)
+ return 0 if values.length <= 1
+
+ sum = values.inject(0) do |acc, val|
+ acc + (val - average)**2
+ end
+
+ sum / (values.length - 1)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/data_builder/build.rb b/lib/gitlab/data_builder/build.rb
index 8e74e18a311..2f1445a050a 100644
--- a/lib/gitlab/data_builder/build.rb
+++ b/lib/gitlab/data_builder/build.rb
@@ -31,7 +31,7 @@ module Gitlab
# TODO: do we still need it?
project_id: project.id,
- project_name: project.name_with_namespace,
+ project_name: project.full_name,
user: {
id: user.try(:id),
diff --git a/lib/gitlab/data_builder/note.rb b/lib/gitlab/data_builder/note.rb
index 50fea1232af..f573368e572 100644
--- a/lib/gitlab/data_builder/note.rb
+++ b/lib/gitlab/data_builder/note.rb
@@ -9,6 +9,7 @@ module Gitlab
#
# data = {
# object_kind: "note",
+ # event_type: "confidential_note",
# user: {
# name: String,
# username: String,
@@ -51,8 +52,11 @@ module Gitlab
end
def build_base_data(project, user, note)
+ event_type = note.confidential? ? 'confidential_note' : 'note'
+
base_data = {
object_kind: "note",
+ event_type: event_type,
user: user.hook_attrs,
project_id: project.id,
project: project.hook_attrs,
diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb
index e47fb85b5ee..1e283cc092b 100644
--- a/lib/gitlab/data_builder/pipeline.rb
+++ b/lib/gitlab/data_builder/pipeline.rb
@@ -22,6 +22,7 @@ module Gitlab
sha: pipeline.sha,
before_sha: pipeline.before_sha,
status: pipeline.status,
+ detailed_status: pipeline.detailed_status(nil).label,
stages: pipeline.stages_names,
created_at: pipeline.created_at,
finished_at: pipeline.finished_at,
diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb
index e51794fef99..76501dd50e8 100644
--- a/lib/gitlab/database.rb
+++ b/lib/gitlab/database.rb
@@ -183,6 +183,15 @@ module Gitlab
ActiveRecord::Base.connection
end
+ def self.cached_column_exists?(table_name, column_name)
+ connection.schema_cache.columns_hash(table_name).has_key?(column_name.to_s)
+ end
+
+ def self.cached_table_exists?(table_name)
+ # Rails 5 uses data_source_exists? instead of table_exists?
+ connection.schema_cache.table_exists?(table_name)
+ end
+
private_class_method :connection
def self.database_version
diff --git a/lib/gitlab/database/grant.rb b/lib/gitlab/database/grant.rb
index 9f76967fc77..d32837f5793 100644
--- a/lib/gitlab/database/grant.rb
+++ b/lib/gitlab/database/grant.rb
@@ -12,30 +12,40 @@ module Gitlab
# Returns true if the current user can create and execute triggers on the
# given table.
def self.create_and_execute_trigger?(table)
- priv =
- if Database.postgresql?
- where(privilege_type: 'TRIGGER', table_name: table)
- .where('grantee = user')
- else
- queries = [
- Grant.select(1)
- .from('information_schema.user_privileges')
- .where("PRIVILEGE_TYPE = 'SUPER'")
- .where("GRANTEE = CONCAT('\\'', REPLACE(CURRENT_USER(), '@', '\\'@\\''), '\\'')"),
+ if Database.postgresql?
+ # We _must not_ use quote_table_name as this will produce double
+ # quotes on PostgreSQL and for "has_table_privilege" we need single
+ # quotes.
+ quoted_table = connection.quote(table)
- Grant.select(1)
- .from('information_schema.schema_privileges')
- .where("PRIVILEGE_TYPE = 'TRIGGER'")
- .where('TABLE_SCHEMA = ?', Gitlab::Database.database_name)
- .where("GRANTEE = CONCAT('\\'', REPLACE(CURRENT_USER(), '@', '\\'@\\''), '\\'')")
- ]
+ begin
+ from(nil)
+ .pluck("has_table_privilege(#{quoted_table}, 'TRIGGER')")
+ .first
+ rescue ActiveRecord::StatementInvalid
+ # This error is raised when using a non-existing table name. In this
+ # case we just want to return false as a user technically can't
+ # create triggers for such a table.
+ false
+ end
+ else
+ queries = [
+ Grant.select(1)
+ .from('information_schema.user_privileges')
+ .where("PRIVILEGE_TYPE = 'SUPER'")
+ .where("GRANTEE = CONCAT('\\'', REPLACE(CURRENT_USER(), '@', '\\'@\\''), '\\'')"),
- union = SQL::Union.new(queries).to_sql
+ Grant.select(1)
+ .from('information_schema.schema_privileges')
+ .where("PRIVILEGE_TYPE = 'TRIGGER'")
+ .where('TABLE_SCHEMA = ?', Gitlab::Database.database_name)
+ .where("GRANTEE = CONCAT('\\'', REPLACE(CURRENT_USER(), '@', '\\'@\\''), '\\'')")
+ ]
- Grant.from("(#{union}) privs")
- end
+ union = SQL::Union.new(queries).to_sql
- priv.any?
+ Grant.from("(#{union}) privs").any?
+ end
end
end
end
diff --git a/lib/gitlab/database/median.rb b/lib/gitlab/database/median.rb
index 059054ac9ff..74fed447289 100644
--- a/lib/gitlab/database/median.rb
+++ b/lib/gitlab/database/median.rb
@@ -2,18 +2,14 @@
module Gitlab
module Database
module Median
+ NotSupportedError = Class.new(StandardError)
+
def median_datetime(arel_table, query_so_far, column_sym)
- median_queries =
- if Gitlab::Database.postgresql?
- pg_median_datetime_sql(arel_table, query_so_far, column_sym)
- elsif Gitlab::Database.mysql?
- mysql_median_datetime_sql(arel_table, query_so_far, column_sym)
- end
-
- results = Array.wrap(median_queries).map do |query|
- ActiveRecord::Base.connection.execute(query)
- end
- extract_median(results).presence
+ extract_median(execute_queries(arel_table, query_so_far, column_sym)).presence
+ end
+
+ def median_datetimes(arel_table, query_so_far, column_sym, partition_column)
+ extract_medians(execute_queries(arel_table, query_so_far, column_sym, partition_column)).presence
end
def extract_median(results)
@@ -21,13 +17,21 @@ module Gitlab
if Gitlab::Database.postgresql?
result = result.first.presence
- median = result['median'] if result
- median.to_f if median
+
+ result['median']&.to_f if result
elsif Gitlab::Database.mysql?
result.to_a.flatten.first
end
end
+ def extract_medians(results)
+ median_values = results.compact.first.values
+
+ median_values.each_with_object({}) do |(id, median), hash|
+ hash[id.to_i] = median&.to_f
+ end
+ 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))
@@ -53,7 +57,7 @@ module Gitlab
]
end
- def pg_median_datetime_sql(arel_table, query_so_far, column_sym)
+ def pg_median_datetime_sql(arel_table, query_so_far, column_sym, partition_column = nil)
# Create a CTE with the column we're operating on, row number (after sorting by the column
# we're operating on), and count of the table we're operating on (duplicated across) all rows
# of the CTE. For example, if we're looking to find the median of the `projects.star_count`
@@ -64,41 +68,107 @@ module Gitlab
# 5 | 1 | 3
# 9 | 2 | 3
# 15 | 3 | 3
+ #
+ # If a partition column is used we will do the same operation but for separate partitions,
+ # when that happens the CTE might look like this:
+ #
+ # project_id | star_count | row_id | ct
+ # ------------+------------+--------+----
+ # 1 | 5 | 1 | 2
+ # 1 | 9 | 2 | 2
+ # 2 | 10 | 1 | 3
+ # 2 | 15 | 2 | 3
+ # 2 | 20 | 3 | 3
cte_table = Arel::Table.new("ordered_records")
+
cte = Arel::Nodes::As.new(
cte_table,
- 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'),
- arel_table.project("COUNT(1)").as('ct')).
+ arel_table.project(*rank_rows(arel_table, column_sym, partition_column)).
# Disallow negative values
where(arel_table[column_sym].gteq(zero_interval)))
# 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(
- Arel::Nodes::Between.new(
- cte_table[:row_id],
- Arel::Nodes::And.new(
- [(cte_table[:ct] / Arel.sql('2.0')),
- (cte_table[:ct] / Arel.sql('2.0') + 1)]
+ result =
+ cte_table
+ .project(*median_projections(cte_table, column_sym, partition_column))
+ .where(
+ Arel::Nodes::Between.new(
+ cte_table[:row_id],
+ Arel::Nodes::And.new(
+ [(cte_table[:ct] / Arel.sql('2.0')),
+ (cte_table[:ct] / Arel.sql('2.0') + 1)]
+ )
)
)
- )
- .with(query_so_far, cte)
- .to_sql
+ .with(query_so_far, cte)
+
+ result.group(cte_table[partition_column]).order(cte_table[partition_column]) if partition_column
+
+ result.to_sql
end
private
+ def median_queries(arel_table, query_so_far, column_sym, partition_column = nil)
+ if Gitlab::Database.postgresql?
+ pg_median_datetime_sql(arel_table, query_so_far, column_sym, partition_column)
+ elsif Gitlab::Database.mysql?
+ raise NotSupportedError, "partition_column is not supported for MySQL" if partition_column
+
+ mysql_median_datetime_sql(arel_table, query_so_far, column_sym)
+ end
+ end
+
+ def execute_queries(arel_table, query_so_far, column_sym, partition_column = nil)
+ queries = median_queries(arel_table, query_so_far, column_sym, partition_column)
+
+ Array.wrap(queries).map { |query| ActiveRecord::Base.connection.execute(query) }
+ end
+
def average(args, as)
Arel::Nodes::NamedFunction.new("AVG", args, as)
end
+ def rank_rows(arel_table, column_sym, partition_column)
+ column_row = arel_table[column_sym].as(column_sym.to_s)
+
+ if partition_column
+ partition_row = arel_table[partition_column]
+ row_id =
+ Arel::Nodes::Over.new(
+ Arel::Nodes::NamedFunction.new('rank', []),
+ Arel::Nodes::Window.new.partition(arel_table[partition_column])
+ .order(arel_table[column_sym])
+ ).as('row_id')
+
+ count = arel_table.from(arel_table.alias)
+ .project('COUNT(*)')
+ .where(arel_table[partition_column].eq(arel_table.alias[partition_column]))
+ .as('ct')
+
+ [partition_row, column_row, row_id, count]
+ else
+ row_id =
+ Arel::Nodes::Over.new(
+ Arel::Nodes::NamedFunction.new('row_number', []),
+ Arel::Nodes::Window.new.order(arel_table[column_sym])
+ ).as('row_id')
+
+ count = arel_table.project("COUNT(1)").as('ct')
+
+ [column_row, row_id, count]
+ end
+ end
+
+ def median_projections(table, column_sym, partition_column)
+ projections = []
+ projections << table[partition_column] if partition_column
+ projections << average([extract_epoch(table[column_sym])], "median")
+ projections
+ end
+
def extract_epoch(arel_attribute)
Arel.sql(%Q{EXTRACT(EPOCH FROM "#{arel_attribute.relation.name}"."#{arel_attribute.name}")})
end
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 3f65bc912de..77079e5e72b 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -59,6 +59,11 @@ module Gitlab
disable_statement_timeout
end
+ if index_exists?(table_name, column_name, options)
+ Rails.logger.warn "Index not created because it already exists (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}"
+ return
+ end
+
add_index(table_name, column_name, options)
end
@@ -83,6 +88,11 @@ module Gitlab
disable_statement_timeout
end
+ unless index_exists?(table_name, column_name, options)
+ Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, column_name: #{column_name}"
+ return
+ end
+
remove_index(table_name, options.merge({ column: column_name }))
end
@@ -107,6 +117,11 @@ module Gitlab
disable_statement_timeout
end
+ unless index_exists_by_name?(table_name, index_name)
+ Rails.logger.warn "Index not removed because it does not exist (this may be due to an aborted migration or similar): table_name: #{table_name}, index_name: #{index_name}"
+ return
+ end
+
remove_index(table_name, options.merge({ name: index_name }))
end
@@ -140,6 +155,13 @@ module Gitlab
# of PostgreSQL's "VALIDATE CONSTRAINT". As a result we'll just fall
# back to the normal foreign key procedure.
if Database.mysql?
+ if foreign_key_exists?(source, target, column: column)
+ Rails.logger.warn "Foreign key not created because it exists already " \
+ "(this may be due to an aborted migration or similar): " \
+ "source: #{source}, target: #{target}, column: #{column}"
+ return
+ end
+
return add_foreign_key(source, target,
column: column,
on_delete: on_delete)
@@ -151,25 +173,43 @@ module Gitlab
key_name = concurrent_foreign_key_name(source, column)
- # Using NOT VALID allows us to create a key without immediately
- # validating it. This means we keep the ALTER TABLE lock only for a
- # short period of time. The key _is_ enforced for any newly created
- # data.
- execute <<-EOF.strip_heredoc
- ALTER TABLE #{source}
- ADD CONSTRAINT #{key_name}
- FOREIGN KEY (#{column})
- REFERENCES #{target} (id)
- #{on_delete ? "ON DELETE #{on_delete.upcase}" : ''}
- NOT VALID;
- EOF
+ unless foreign_key_exists?(source, target, column: column)
+ Rails.logger.warn "Foreign key not created because it exists already " \
+ "(this may be due to an aborted migration or similar): " \
+ "source: #{source}, target: #{target}, column: #{column}"
+
+ # Using NOT VALID allows us to create a key without immediately
+ # validating it. This means we keep the ALTER TABLE lock only for a
+ # short period of time. The key _is_ enforced for any newly created
+ # data.
+ execute <<-EOF.strip_heredoc
+ ALTER TABLE #{source}
+ ADD CONSTRAINT #{key_name}
+ FOREIGN KEY (#{column})
+ REFERENCES #{target} (id)
+ #{on_delete ? "ON DELETE #{on_delete.upcase}" : ''}
+ NOT VALID;
+ EOF
+ end
# Validate the existing constraint. This can potentially take a very
# long time to complete, but fortunately does not lock the source table
# while running.
+ #
+ # Note this is a no-op in case the constraint is VALID already
execute("ALTER TABLE #{source} VALIDATE CONSTRAINT #{key_name};")
end
+ def foreign_key_exists?(source, target = nil, column: nil)
+ foreign_keys(source).any? do |key|
+ if column
+ key.options[:column].to_s == column.to_s
+ else
+ key.to_table.to_s == target.to_s
+ end
+ end
+ end
+
# Returns the name for a concurrent foreign key.
#
# PostgreSQL constraint names have a limit of 63 bytes. The logic used
@@ -385,10 +425,27 @@ module Gitlab
# 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)
+ install_rename_triggers(table, old, new)
+
+ 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
+
+ # Installs triggers in a table that keep a new column in sync with an old
+ # one.
+ #
+ # table - The name of the table to install the trigger in.
+ # old_column - The name of the old column.
+ # new_column - The name of the new column.
+ def install_rename_triggers(table, old_column, new_column)
+ trigger_name = rename_trigger_name(table, old_column, new_column)
quoted_table = quote_table_name(table)
- quoted_old = quote_column_name(old)
- quoted_new = quote_column_name(new)
+ quoted_old = quote_column_name(old_column)
+ quoted_new = quote_column_name(new_column)
if Database.postgresql?
install_rename_triggers_for_postgresql(trigger_name, quoted_table,
@@ -397,13 +454,6 @@ module Gitlab
install_rename_triggers_for_mysql(trigger_name, quoted_table,
quoted_old, quoted_new)
end
-
- 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
# Changes the type of a column concurrently.
@@ -455,6 +505,99 @@ module Gitlab
remove_column(table, old)
end
+ # Changes the column type of a table using a background migration.
+ #
+ # Because this method uses a background migration it's more suitable for
+ # large tables. For small tables it's better to use
+ # `change_column_type_concurrently` since it can complete its work in a
+ # much shorter amount of time and doesn't rely on Sidekiq.
+ #
+ # Example usage:
+ #
+ # class Issue < ActiveRecord::Base
+ # self.table_name = 'issues'
+ #
+ # include EachBatch
+ #
+ # def self.to_migrate
+ # where('closed_at IS NOT NULL')
+ # end
+ # end
+ #
+ # change_column_type_using_background_migration(
+ # Issue.to_migrate,
+ # :closed_at,
+ # :datetime_with_timezone
+ # )
+ #
+ # Reverting a migration like this is done exactly the same way, just with
+ # a different type to migrate to (e.g. `:datetime` in the above example).
+ #
+ # relation - An ActiveRecord relation to use for scheduling jobs and
+ # figuring out what table we're modifying. This relation _must_
+ # have the EachBatch module included.
+ #
+ # column - The name of the column for which the type will be changed.
+ #
+ # new_type - The new type of the column.
+ #
+ # batch_size - The number of rows to schedule in a single background
+ # migration.
+ #
+ # interval - The time interval between every background migration.
+ def change_column_type_using_background_migration(
+ relation,
+ column,
+ new_type,
+ batch_size: 10_000,
+ interval: 10.minutes
+ )
+
+ unless relation.model < EachBatch
+ raise TypeError, 'The relation must include the EachBatch module'
+ end
+
+ temp_column = "#{column}_for_type_change"
+ table = relation.table_name
+ max_index = 0
+
+ add_column(table, temp_column, new_type)
+ install_rename_triggers(table, column, temp_column)
+
+ # Schedule the jobs that will copy the data from the old column to the
+ # new one. Rows with NULL values in our source column are skipped since
+ # the target column is already NULL at this point.
+ relation.where.not(column => nil).each_batch(of: batch_size) do |batch, index|
+ start_id, end_id = batch.pluck('MIN(id), MAX(id)').first
+ max_index = index
+
+ BackgroundMigrationWorker.perform_in(
+ index * interval,
+ 'CopyColumn',
+ [table, column, temp_column, start_id, end_id]
+ )
+ end
+
+ # Schedule the renaming of the column to happen (initially) 1 hour after
+ # the last batch finished.
+ BackgroundMigrationWorker.perform_in(
+ (max_index * interval) + 1.hour,
+ 'CleanupConcurrentTypeChange',
+ [table, column, temp_column]
+ )
+
+ if perform_background_migration_inline?
+ # To ensure the schema is up to date immediately we perform the
+ # migration inline in dev / test environments.
+ Gitlab::BackgroundMigration.steal('CopyColumn')
+ Gitlab::BackgroundMigration.steal('CleanupConcurrentTypeChange')
+ end
+ end
+
+ def perform_background_migration_inline?
+ Rails.env.test? || Rails.env.development?
+ end
+
# Performs a concurrent column rename when using PostgreSQL.
def install_rename_triggers_for_postgresql(trigger, table, old, new)
execute <<-EOF.strip_heredoc
@@ -717,7 +860,7 @@ into similar problems in the future (e.g. when new tables are created).
# Each job is scheduled with a `delay_interval` in between.
# If you use a small interval, then some jobs may run at the same time.
#
- # model_class - The table being iterated over
+ # model_class - The table or relation being iterated over
# job_class_name - The background migration job class as a string
# delay_interval - The duration between each job's scheduled time (must respond to `to_f`)
# batch_size - The maximum number of rows per job
@@ -741,6 +884,12 @@ into similar problems in the future (e.g. when new tables are created).
def queue_background_migration_jobs_by_range_at_intervals(model_class, job_class_name, delay_interval, batch_size: BACKGROUND_MIGRATION_BATCH_SIZE)
raise "#{model_class} does not have an ID to use for batch ranges" unless model_class.column_names.include?('id')
+ # To not overload the worker too much we enforce a minimum interval both
+ # when scheduling and performing jobs.
+ if delay_interval < BackgroundMigrationWorker::MIN_INTERVAL
+ delay_interval = BackgroundMigrationWorker::MIN_INTERVAL
+ end
+
model_class.each_batch(of: batch_size) do |relation, index|
start_id, end_id = relation.pluck('MIN(id), MAX(id)').first
@@ -750,6 +899,44 @@ into similar problems in the future (e.g. when new tables are created).
BackgroundMigrationWorker.perform_in(delay_interval * index, job_class_name, [start_id, end_id])
end
end
+
+ # Fetches indexes on a column by name for postgres.
+ #
+ # This will include indexes using an expression on the column, for example:
+ # `CREATE INDEX CONCURRENTLY index_name ON table (LOWER(column));`
+ #
+ # For mysql, it falls back to the default ActiveRecord implementation that
+ # will not find custom indexes. But it will select by name without passing
+ # a column.
+ #
+ # We can remove this when upgrading to Rails 5 with an updated `index_exists?`:
+ # - https://github.com/rails/rails/commit/edc2b7718725016e988089b5fb6d6fb9d6e16882
+ #
+ # Or this can be removed when we no longer support postgres < 9.5, so we
+ # can use `CREATE INDEX IF NOT EXISTS`.
+ def index_exists_by_name?(table, index)
+ # We can't fall back to the normal `index_exists?` method because that
+ # does not find indexes without passing a column name.
+ if indexes(table).map(&:name).include?(index.to_s)
+ true
+ elsif Gitlab::Database.postgresql?
+ postgres_exists_by_name?(table, index)
+ else
+ false
+ end
+ end
+
+ def postgres_exists_by_name?(table, name)
+ index_sql = <<~SQL
+ SELECT COUNT(*)
+ FROM pg_index
+ JOIN pg_class i ON (indexrelid=i.oid)
+ JOIN pg_class t ON (indrelid=t.oid)
+ WHERE i.relname = '#{name}' AND t.relname = '#{table}'
+ SQL
+
+ connection.select_value(index_sql).to_i > 0
+ 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
index fd4a8832ec2..62d4d0a92a6 100644
--- a/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb
+++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb
@@ -74,7 +74,7 @@ module Gitlab
}.freeze
def repository_storage_path
- Gitlab.config.repositories.storages[repository_storage]['path']
+ Gitlab.config.repositories.storages[repository_storage].legacy_disk_path
end
# Overridden to have the correct `source_type` for the `route` relation
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
index d32616862f0..979225dd216 100644
--- a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb
+++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb
@@ -26,6 +26,7 @@ module Gitlab
move_repository(project, old_full_path, new_full_path)
move_repository(project, "#{old_full_path}.wiki", "#{new_full_path}.wiki")
end
+
move_uploads(old_full_path, new_full_path) unless project.hashed_storage?(:attachments)
move_pages(old_full_path, new_full_path)
end
diff --git a/lib/gitlab/database/sha_attribute.rb b/lib/gitlab/database/sha_attribute.rb
index d9400e04b83..b2d8ee81977 100644
--- a/lib/gitlab/database/sha_attribute.rb
+++ b/lib/gitlab/database/sha_attribute.rb
@@ -1,12 +1,20 @@
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
+ 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
+ # In Rails 5.0 `Type` has been moved from `ActiveRecord` to `ActiveModel`
+ # https://github.com/rails/rails/commit/9cc8c6f3730df3d94c81a55be9ee1b7b4ffd29f6#diff-f8ba7983a51d687976e115adcd95822b
+ # Remove this method and leave just `ActiveModel::Type::Binary` when removing Gitlab.rails5? code.
+ if Gitlab.rails5?
+ ActiveModel::Type::Binary
+ else
+ ActiveRecord::Type::Binary
+ end
+ end
# Class for casting binary data to hexadecimal SHA1 hashes (and vice-versa).
#
@@ -16,18 +24,39 @@ module Gitlab
class ShaAttribute < BINARY_TYPE
PACK_FORMAT = 'H*'.freeze
- # Casts binary data to a SHA1 in hexadecimal.
+ # It is called from activerecord-4.2.10/lib/active_record internal methods.
+ # Remove this method when removing Gitlab.rails5? code.
def type_cast_from_database(value)
- value = super
+ unpack_sha(super)
+ end
+
+ # It is called from activerecord-4.2.10/lib/active_record internal methods.
+ # Remove this method when removing Gitlab.rails5? code.
+ def type_cast_for_database(value)
+ serialize(value)
+ end
+ # It is called from activerecord-5.0.6/lib/active_record/attribute.rb
+ # Remove this method when removing Gitlab.rails5? code..
+ def deserialize(value)
+ value = Gitlab.rails5? ? super : method(:type_cast_from_database).super_method.call(value)
+
+ unpack_sha(value)
+ end
+
+ # Rename this method to `deserialize(value)` removing Gitlab.rails5? code.
+ # Casts binary data to a SHA1 in hexadecimal.
+ def unpack_sha(value)
+ # Uncomment this line when removing Gitlab.rails5? code.
+ # 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)
+ def serialize(value)
arg = value ? [value].pack(PACK_FORMAT) : nil
- super(arg)
+ Gitlab.rails5? ? super(arg) : method(:type_cast_for_database).super_method.call(arg)
end
end
end
diff --git a/lib/gitlab/dependency_linker/composer_json_linker.rb b/lib/gitlab/dependency_linker/composer_json_linker.rb
index 0245bf4077a..cfd4ec15125 100644
--- a/lib/gitlab/dependency_linker/composer_json_linker.rb
+++ b/lib/gitlab/dependency_linker/composer_json_linker.rb
@@ -11,7 +11,7 @@ module Gitlab
end
def package_url(name)
- "https://packagist.org/packages/#{name}" if name =~ %r{\A#{REPO_REGEX}\z}
+ "https://packagist.org/packages/#{name}" if name =~ /\A#{REPO_REGEX}\z/
end
end
end
diff --git a/lib/gitlab/dependency_linker/gemfile_linker.rb b/lib/gitlab/dependency_linker/gemfile_linker.rb
index d034ea67387..bfea836bcb2 100644
--- a/lib/gitlab/dependency_linker/gemfile_linker.rb
+++ b/lib/gitlab/dependency_linker/gemfile_linker.rb
@@ -15,7 +15,7 @@ module Gitlab
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_regex(/(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]/, &:itself)
# Link `source "https://rubygems.org"` to https://rubygems.org
link_method_call('source', URL_REGEX, &:itself)
diff --git a/lib/gitlab/dependency_linker/podspec_linker.rb b/lib/gitlab/dependency_linker/podspec_linker.rb
index a52c7a02439..924e55e9820 100644
--- a/lib/gitlab/dependency_linker/podspec_linker.rb
+++ b/lib/gitlab/dependency_linker/podspec_linker.rb
@@ -12,7 +12,7 @@ module Gitlab
def link_dependencies
link_method_call('homepage', URL_REGEX, &:itself)
- link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]}, &:itself)
+ link_regex(/(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))
diff --git a/lib/gitlab/diff/diff_refs.rb b/lib/gitlab/diff/diff_refs.rb
index 88e0db830f6..81df47964be 100644
--- a/lib/gitlab/diff/diff_refs.rb
+++ b/lib/gitlab/diff/diff_refs.rb
@@ -44,7 +44,11 @@ module Gitlab
project.commit(head_sha)
else
straight = start_sha == base_sha
- CompareService.new(project, head_sha).execute(project, start_sha, straight: straight)
+
+ CompareService.new(project, head_sha).execute(project,
+ start_sha,
+ base_sha: base_sha,
+ straight: straight)
end
end
end
diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb
index d0cfe2386ca..014854da55c 100644
--- a/lib/gitlab/diff/file.rb
+++ b/lib/gitlab/diff/file.rb
@@ -27,8 +27,8 @@ module Gitlab
@fallback_diff_refs = fallback_diff_refs
# Ensure items are collected in the the batch
- new_blob
- old_blob
+ new_blob_lazy
+ old_blob_lazy
end
def position(position_marker, position_type: :text)
@@ -61,7 +61,9 @@ module Gitlab
end
def line_for_position(pos)
- diff_lines.find { |line| position(line) == pos }
+ return nil unless pos.position_type == 'text'
+
+ diff_lines.find { |line| line.old_line == pos.old_line && line.new_line == pos.new_line }
end
def position_for_line_code(code)
@@ -99,15 +101,11 @@ module Gitlab
end
def new_blob
- return unless new_content_sha
-
- Blob.lazy(repository.project, new_content_sha, file_path)
+ new_blob_lazy&.itself
end
def old_blob
- return unless old_content_sha
-
- Blob.lazy(repository.project, old_content_sha, old_path)
+ old_blob_lazy&.itself
end
def content_sha
@@ -171,7 +169,7 @@ module Gitlab
end
def binary?
- has_binary_notice? || old_blob&.binary? || new_blob&.binary?
+ has_binary_notice? || try_blobs(:binary?)
end
def text?
@@ -179,15 +177,15 @@ module Gitlab
end
def external_storage_error?
- old_blob&.external_storage_error? || new_blob&.external_storage_error?
+ try_blobs(:external_storage_error?)
end
def stored_externally?
- old_blob&.stored_externally? || new_blob&.stored_externally?
+ try_blobs(:stored_externally?)
end
def external_storage
- old_blob&.external_storage || new_blob&.external_storage
+ try_blobs(:external_storage)
end
def content_changed?
@@ -202,15 +200,15 @@ module Gitlab
end
def size
- [old_blob&.size, new_blob&.size].compact.sum
+ valid_blobs.map(&:size).sum
end
def raw_size
- [old_blob&.raw_size, new_blob&.raw_size].compact.sum
+ valid_blobs.map(&:raw_size).sum
end
def raw_binary?
- old_blob&.raw_binary? || new_blob&.raw_binary?
+ try_blobs(:raw_binary?)
end
def raw_text?
@@ -233,6 +231,16 @@ module Gitlab
private
+ # We can't use Object#try because Blob doesn't inherit from Object, but
+ # from BasicObject (via SimpleDelegator).
+ def try_blobs(meth)
+ old_blob&.public_send(meth) || new_blob&.public_send(meth)
+ end
+
+ def valid_blobs
+ [old_blob, new_blob].compact
+ end
+
def text_position_properties(line)
{ old_line: line.old_line, new_line: line.new_line }
end
@@ -245,6 +253,18 @@ module Gitlab
old_blob && new_blob && old_blob.id != new_blob.id
end
+ def new_blob_lazy
+ return unless new_content_sha
+
+ Blob.lazy(repository.project, new_content_sha, file_path)
+ end
+
+ def old_blob_lazy
+ return unless old_content_sha
+
+ Blob.lazy(repository.project, old_content_sha, old_path)
+ end
+
def simple_viewer_class
return DiffViewer::NotDiffable unless diffable?
diff --git a/lib/gitlab/diff/file_collection/merge_request_diff.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb
index fcda1fe2233..c358ae428cf 100644
--- a/lib/gitlab/diff/file_collection/merge_request_diff.rb
+++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb
@@ -13,22 +13,32 @@ module Gitlab
end
def diff_files
- super.tap { |_| store_highlight_cache }
+ # Make sure to _not_ send any method call to Gitlab::Diff::File
+ # _before_ all of them were collected (`super`). Premature method calls will
+ # trigger N+1 RPCs to Gitaly through BatchLoader records (Blob.lazy).
+ #
+ diff_files = super
+
+ diff_files.each { |diff_file| cache_highlight!(diff_file) if cacheable?(diff_file) }
+ store_highlight_cache
+
+ diff_files
end
def real_size
@merge_request_diff.real_size
end
- private
+ def clear_cache!
+ Rails.cache.delete(cache_key)
+ end
- # Extracted method to highlight in the same iteration to the diff_collection.
- def decorate_diff!(diff)
- diff_file = super
- cache_highlight!(diff_file) if cacheable?(diff_file)
- diff_file
+ def cache_key
+ [@merge_request_diff, 'highlighted-diff-files', diff_options]
end
+ private
+
def highlight_diff_file_from_cache!(diff_file, cache_diff_lines)
diff_file.highlighted_diff_lines = cache_diff_lines.map do |line|
Gitlab::Diff::Line.init_from_hash(line)
@@ -62,16 +72,12 @@ module Gitlab
end
def store_highlight_cache
- Rails.cache.write(cache_key, highlight_cache) if @highlight_cache_was_empty
+ Rails.cache.write(cache_key, highlight_cache, expires_in: 1.week) if @highlight_cache_was_empty
end
def cacheable?(diff_file)
@merge_request_diff.present? && diff_file.text? && diff_file.diffable?
end
-
- def cache_key
- [@merge_request_diff, 'highlighted-diff-files', diff_options]
- end
end
end
end
diff --git a/lib/gitlab/diff/highlight.rb b/lib/gitlab/diff/highlight.rb
index b669ee5b799..269016daac2 100644
--- a/lib/gitlab/diff/highlight.rb
+++ b/lib/gitlab/diff/highlight.rb
@@ -14,6 +14,7 @@ module Gitlab
else
@diff_lines = diff_lines
end
+
@raw_lines = @diff_lines.map(&:text)
end
@@ -26,7 +27,17 @@ module Gitlab
rich_line = highlight_line(diff_line) || diff_line.text
if line_inline_diffs = inline_diffs[i]
- rich_line = InlineDiffMarker.new(diff_line.text, rich_line).mark(line_inline_diffs)
+ begin
+ rich_line = InlineDiffMarker.new(diff_line.text, rich_line).mark(line_inline_diffs)
+ # This should only happen when the encoding of the diff doesn't
+ # match the blob, which is a bug. But we shouldn't fail to render
+ # completely in that case, even though we want to report the error.
+ rescue RangeError => e
+ if Gitlab::Sentry.enabled?
+ Gitlab::Sentry.context
+ Raven.capture_exception(e)
+ end
+ end
end
diff_line.text = rich_line
diff --git a/lib/gitlab/diff/inline_diff_marker.rb b/lib/gitlab/diff/inline_diff_marker.rb
index 010b4be7b40..81e91ea0ab7 100644
--- a/lib/gitlab/diff/inline_diff_marker.rb
+++ b/lib/gitlab/diff/inline_diff_marker.rb
@@ -1,11 +1,14 @@
module Gitlab
module Diff
class InlineDiffMarker < Gitlab::StringRangeMarker
+ def initialize(line, rich_line = nil)
+ super(line, rich_line || line)
+ end
+
def mark(line_inline_diffs, mode: nil)
- mark = super(line_inline_diffs) do |text, left:, right:|
+ super(line_inline_diffs) do |text, left:, right:|
%{<span class="#{html_class_names(left, right, mode)}">#{text}</span>}
end
- mark.html_safe
end
private
diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb
index 37face8e7d0..5fdd5dcd374 100644
--- a/lib/gitlab/ee_compat_check.rb
+++ b/lib/gitlab/ee_compat_check.rb
@@ -2,66 +2,90 @@
module Gitlab
# Checks if a set of migrations requires downtime or not.
class EeCompatCheck
- DEFAULT_CE_PROJECT_URL = 'https://gitlab.com/gitlab-org/gitlab-ce'.freeze
- EE_REPO_URL = 'https://gitlab.com/gitlab-org/gitlab-ee.git'.freeze
+ CANONICAL_CE_PROJECT_URL = 'https://gitlab.com/gitlab-org/gitlab-ce'.freeze
+ CANONICAL_EE_REPO_URL = 'https://gitlab.com/gitlab-org/gitlab-ee.git'.freeze
CHECK_DIR = Rails.root.join('ee_compat_check')
- IGNORED_FILES_REGEX = /(VERSION|CHANGELOG\.md:\d+)/.freeze
+ IGNORED_FILES_REGEX = %r{VERSION|CHANGELOG\.md|db/schema\.rb}i.freeze
PLEASE_READ_THIS_BANNER = %Q{
============================================================
===================== PLEASE READ THIS =====================
============================================================
}.freeze
+ STAY_STRONG_LINK_TO_DOCS = %Q{
+ Stay 💪! For more information, see
+ https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html
+ }.freeze
THANKS_FOR_READING_BANNER = %Q{
============================================================
==================== THANKS FOR READING ====================
============================================================\n
}.freeze
- attr_reader :ee_repo_dir, :patches_dir, :ce_project_url, :ce_repo_url, :ce_branch, :ee_branch_found
+ attr_reader :ee_repo_dir, :patches_dir
+ attr_reader :ce_project_url, :ee_repo_url
+ attr_reader :ce_branch, :ee_remote_with_branch, :ee_branch_found
attr_reader :job_id, :failed_files
- def initialize(branch:, ce_project_url: DEFAULT_CE_PROJECT_URL, job_id: nil)
+ def initialize(branch:, ce_project_url: CANONICAL_CE_PROJECT_URL, job_id: nil)
@ee_repo_dir = CHECK_DIR.join('ee-repo')
@patches_dir = CHECK_DIR.join('patches')
@ce_branch = branch
@ce_project_url = ce_project_url
- @ce_repo_url = "#{ce_project_url}.git"
+ @ee_repo_url = ce_public_repo_url.sub('gitlab-ce', 'gitlab-ee')
@job_id = job_id
end
def check
ensure_patches_dir
- add_remote('canonical-ce', "#{DEFAULT_CE_PROJECT_URL}.git")
- generate_patch(branch: ce_branch, patch_path: ce_patch_full_path, remote: 'canonical-ce')
+ # We're generating the patch against the canonical-ce remote since forks'
+ # master branch are not necessarily up-to-date.
+ add_remote('canonical-ce', "#{CANONICAL_CE_PROJECT_URL}.git")
+ generate_patch(branch: ce_branch, patch_path: ce_patch_full_path, branch_remote: 'origin', master_remote: 'canonical-ce')
ensure_ee_repo
Dir.chdir(ee_repo_dir) do
step("In the #{ee_repo_dir} directory")
- add_remote('canonical-ee', EE_REPO_URL)
+ ee_remotes.each do |key, url|
+ add_remote(key, url)
+ end
+ fetch(branch: 'master', depth: 20, remote: 'canonical-ee')
status = catch(:halt_check) do
ce_branch_compat_check!
delete_ee_branches_locally!
ee_branch_presence_check!
- step("Checking out #{ee_branch_found}", %W[git checkout -b #{ee_branch_found} canonical-ee/#{ee_branch_found}])
- generate_patch(branch: ee_branch_found, patch_path: ee_patch_full_path, remote: 'canonical-ee')
+ step("Checking out #{ee_remote_with_branch}/#{ee_branch_found}", %W[git checkout -b #{ee_branch_found} #{ee_remote_with_branch}/#{ee_branch_found}])
+ generate_patch(branch: ee_branch_found, patch_path: ee_patch_full_path, branch_remote: ee_remote_with_branch, master_remote: 'canonical-ee')
ee_branch_compat_check!
end
delete_ee_branches_locally!
- if status.nil?
- true
- else
- false
- end
+ status.nil?
end
end
private
+ def fork?
+ ce_project_url != CANONICAL_CE_PROJECT_URL
+ end
+
+ def ee_remotes
+ return @ee_remotes if defined?(@ee_remotes)
+
+ remotes =
+ {
+ 'ee' => ee_repo_url,
+ 'canonical-ee' => CANONICAL_EE_REPO_URL
+ }
+ remotes.delete('ee') unless fork?
+
+ @ee_remotes = remotes
+ end
+
def add_remote(name, url)
step(
"Adding the #{name} remote (#{url})",
@@ -70,28 +94,32 @@ module Gitlab
end
def ensure_ee_repo
- if Dir.exist?(ee_repo_dir)
- step("#{ee_repo_dir} already exists")
- else
- step(
- "Cloning #{EE_REPO_URL} into #{ee_repo_dir}",
- %W[git clone --branch master --single-branch --depth=200 #{EE_REPO_URL} #{ee_repo_dir}]
- )
+ unless clone_repo(ee_repo_url, ee_repo_dir)
+ # Fallback to using the canonical EE if there is no forked EE
+ clone_repo(CANONICAL_EE_REPO_URL, ee_repo_dir)
end
end
+ def clone_repo(url, dir)
+ _, status = step(
+ "Cloning #{url} into #{dir}",
+ %W[git clone --branch master --single-branch --depth=200 #{url} #{dir}]
+ )
+ status.zero?
+ end
+
def ensure_patches_dir
FileUtils.mkdir_p(patches_dir)
end
- def generate_patch(branch:, patch_path:, remote:)
+ def generate_patch(branch:, patch_path:, branch_remote:, master_remote:)
FileUtils.rm(patch_path, force: true)
- find_merge_base_with_master(branch: branch, master_remote: remote)
+ find_merge_base_with_master(branch: branch, branch_remote: branch_remote, master_remote: master_remote)
step(
- "Generating the patch against #{remote}/master in #{patch_path}",
- %W[git diff --binary #{remote}/master...origin/#{branch}]
+ "Generating the patch against #{master_remote}/master in #{patch_path}",
+ %W[git diff --binary #{master_remote}/master...#{branch_remote}/#{branch}]
) do |output, status|
throw(:halt_check, :ko) unless status.zero?
@@ -109,23 +137,22 @@ module Gitlab
end
def ee_branch_presence_check!
- _, status = step("Fetching origin/#{ee_branch_prefix}", %W[git fetch canonical-ee #{ee_branch_prefix}])
-
- if status.zero?
- @ee_branch_found = ee_branch_prefix
- return
+ ee_remotes.keys.each do |remote|
+ [ee_branch_prefix, ee_branch_suffix].each do |branch|
+ _, status = step("Fetching #{remote}/#{ee_branch_prefix}", %W[git fetch #{remote} #{branch}])
+
+ if status.zero?
+ @ee_remote_with_branch = remote
+ @ee_branch_found = branch
+ return true
+ end
+ end
end
- _, status = step("Fetching origin/#{ee_branch_suffix}", %W[git fetch canonical-ee #{ee_branch_suffix}])
-
- if status.zero?
- @ee_branch_found = ee_branch_suffix
- else
- puts
- puts ce_branch_doesnt_apply_cleanly_and_no_ee_branch_msg
+ puts
+ puts ce_branch_doesnt_apply_cleanly_and_no_ee_branch_msg
- throw(:halt_check, :ko)
- end
+ throw(:halt_check, :ko)
end
def ee_branch_compat_check!
@@ -156,12 +183,14 @@ module Gitlab
%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:')
file = line.sub(/\Aerror: patch failed: /, '')
memo << file unless file =~ IGNORED_FILES_REGEX
end
+
memo
end
@@ -179,10 +208,10 @@ module Gitlab
command(%W[git branch --delete --force #{ee_branch_suffix}])
end
- def merge_base_found?(master_remote:, branch:)
+ def merge_base_found?(branch:, branch_remote:, master_remote:)
step(
"Finding merge base with #{master_remote}/master",
- %W[git merge-base #{master_remote}/master origin/#{branch}]
+ %W[git merge-base #{master_remote}/master #{branch_remote}/#{branch}]
) do |output, status|
if status.zero?
puts "Merge base was found: #{output}"
@@ -191,7 +220,7 @@ module Gitlab
end
end
- def find_merge_base_with_master(branch:, master_remote:)
+ def find_merge_base_with_master(branch:, branch_remote:, master_remote:)
# 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
@@ -200,10 +229,10 @@ module Gitlab
depth += Math.exp(factor).to_i
# Repository is initially cloned with a depth of 20 so we need to fetch
# deeper in the case the branch has more than 20 commits on top of master
- fetch(branch: branch, depth: depth, remote: 'origin')
+ fetch(branch: branch, depth: depth, remote: branch_remote)
fetch(branch: 'master', depth: depth, remote: master_remote)
- merge_base_found?(master_remote: master_remote, branch: branch)
+ merge_base_found?(branch: branch, branch_remote: branch_remote, master_remote: master_remote)
end
raise "\n#{branch} is too far behind #{master_remote}/master, please rebase it!\n" unless success
@@ -272,6 +301,13 @@ module Gitlab
Gitlab::Popen.popen(cmd)
end
+ # We're "re-creating" the repo URL because ENV['CI_REPOSITORY_URL'] contains
+ # redacted credentials (e.g. "***:****") which are useless in instructions
+ # the job gives.
+ def ce_public_repo_url
+ "#{ce_project_url}.git"
+ end
+
def applies_cleanly_msg(branch)
%Q{
#{PLEASE_READ_THIS_BANNER}
@@ -286,13 +322,15 @@ module Gitlab
end
def ce_branch_doesnt_apply_cleanly_and_no_ee_branch_msg
+ ee_repos = ee_remotes.values.uniq
+
%Q{
#{PLEASE_READ_THIS_BANNER}
💥 Oh no! 💥
The `#{ce_branch}` branch does not apply cleanly to the current
EE/master, and no `#{ee_branch_prefix}` or `#{ee_branch_suffix}` branch
- was found in the EE repository.
+ was found in #{ee_repos.join(' nor in ')}.
If you're a community contributor, don't worry, someone from
GitLab Inc. will take care of this, and you don't have to do anything.
@@ -312,17 +350,17 @@ module Gitlab
1. Create a new branch from master and cherry-pick your CE commits
# In the EE repo
- $ git fetch #{EE_REPO_URL} master
+ $ git fetch #{CANONICAL_EE_REPO_URL} master
$ git checkout -b #{ee_branch_prefix} FETCH_HEAD
- $ git fetch #{ce_repo_url} #{ce_branch}
+ $ git fetch #{ce_public_repo_url} #{ce_branch}
$ git cherry-pick SHA # Repeat for all the commits you want to pick
- You can squash the `#{ce_branch}` commits into a single "Port of #{ce_branch} to EE" commit.
+ Note: You can squash the `#{ce_branch}` commits into a single "Port of #{ce_branch} to EE" commit.
2. Apply your branch's patch to EE
# In the EE repo
- $ git fetch #{EE_REPO_URL} master
+ $ git fetch #{CANONICAL_EE_REPO_URL} master
$ git checkout -b #{ee_branch_prefix} FETCH_HEAD
$ wget #{patch_url} && git apply --3way #{ce_patch_name}
@@ -354,10 +392,9 @@ module Gitlab
âš ï¸ 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.
+ Once this is done, you can retry this failed job, and it should pass.
- Stay 💪 ! For more information, see
- https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html
+ #{STAY_STRONG_LINK_TO_DOCS}
#{THANKS_FOR_READING_BANNER}
}
end
@@ -369,16 +406,15 @@ module Gitlab
The `#{ce_branch}` does not apply cleanly to the current EE/master, and
even though a `#{ee_branch_found}` branch
- exists in the EE repository, it does not apply cleanly either to
+ exists in #{ee_repo_url}, it does not apply cleanly either to
EE/master!
#{conflicting_files_msg}
Please update the `#{ee_branch_found}`, push it again to gitlab-ee, and
- retry this build.
+ retry this job.
- Stay 💪 ! For more information, see
- https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html
+ #{STAY_STRONG_LINK_TO_DOCS}
#{THANKS_FOR_READING_BANNER}
}
end
diff --git a/lib/gitlab/email/handler/create_issue_handler.rb b/lib/gitlab/email/handler/create_issue_handler.rb
index a616a80e8f5..05a60deb7d3 100644
--- a/lib/gitlab/email/handler/create_issue_handler.rb
+++ b/lib/gitlab/email/handler/create_issue_handler.rb
@@ -14,7 +14,7 @@ module Gitlab
end
def can_handle?
- !incoming_email_token.nil?
+ !incoming_email_token.nil? && !incoming_email_token.include?("+") && !mail_key.include?(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX)
end
def execute
diff --git a/lib/gitlab/email/handler/create_merge_request_handler.rb b/lib/gitlab/email/handler/create_merge_request_handler.rb
index e2f7c1d0257..3436306e122 100644
--- a/lib/gitlab/email/handler/create_merge_request_handler.rb
+++ b/lib/gitlab/email/handler/create_merge_request_handler.rb
@@ -10,6 +10,7 @@ module Gitlab
def initialize(mail, mail_key)
super(mail, mail_key)
+
if m = /\A([^\+]*)\+merge-request\+(.*)/.match(mail_key.to_s)
@project_path, @incoming_email_token = m.captures
end
diff --git a/lib/gitlab/email/reply_parser.rb b/lib/gitlab/email/reply_parser.rb
index 558df87f36d..01c28d051ee 100644
--- a/lib/gitlab/email/reply_parser.rb
+++ b/lib/gitlab/email/reply_parser.rb
@@ -43,7 +43,7 @@ module Gitlab
return "" unless decoded
# Certain trigger phrases that means we didn't parse correctly
- if decoded =~ /(Content\-Type\:|multipart\/alternative|text\/plain)/
+ if decoded =~ %r{(Content\-Type\:|multipart/alternative|text/plain)}
return ""
end
diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb
index 582028493e9..0b8f6cfe3cb 100644
--- a/lib/gitlab/encoding_helper.rb
+++ b/lib/gitlab/encoding_helper.rb
@@ -14,14 +14,7 @@ module Gitlab
ENCODING_CONFIDENCE_THRESHOLD = 50
def encode!(message)
- return nil unless message.respond_to?(:force_encoding)
- return message if message.encoding == Encoding::UTF_8 && message.valid_encoding?
-
- if message.respond_to?(:frozen?) && message.frozen?
- message = message.dup
- end
-
- message.force_encoding("UTF-8")
+ message = force_encode_utf8(message)
return message if message.valid_encoding?
# return message if message type is binary
@@ -35,7 +28,9 @@ module Gitlab
# encode and clean the bad chars
message.replace clean(message)
- rescue
+ rescue ArgumentError => e
+ return unless e.message.include?('unknown encoding name')
+
encoding = detect ? detect[:encoding] : "unknown"
"--broken encoding: #{encoding}"
end
@@ -54,8 +49,8 @@ module Gitlab
end
def encode_utf8(message)
- return nil unless message.is_a?(String)
- return message if message.encoding == Encoding::UTF_8 && message.valid_encoding?
+ message = force_encode_utf8(message)
+ return message if message.valid_encoding?
detect = CharlockHolmes::EncodingDetector.detect(message)
if detect && detect[:encoding]
@@ -69,12 +64,33 @@ module Gitlab
else
clean(message)
end
+ rescue ArgumentError
+ return nil
+ end
+
+ def encode_binary(s)
+ return "" if s.nil?
+
+ s.dup.force_encoding(Encoding::ASCII_8BIT)
+ end
+
+ def binary_stringio(s)
+ StringIO.new(s || '').tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
end
private
+ def force_encode_utf8(message)
+ raise ArgumentError unless message.respond_to?(:force_encoding)
+ return message if message.encoding == Encoding::UTF_8 && message.valid_encoding?
+
+ message = message.dup if message.respond_to?(:frozen?) && message.frozen?
+
+ message.force_encoding("UTF-8")
+ end
+
def clean(message)
- message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "")
+ message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "".encode("UTF-16BE"))
.encode("UTF-8")
.gsub("\0".encode("UTF-8"), "")
end
diff --git a/lib/gitlab/exclusive_lease.rb b/lib/gitlab/exclusive_lease.rb
index 3f7b42456af..12b5e240962 100644
--- a/lib/gitlab/exclusive_lease.rb
+++ b/lib/gitlab/exclusive_lease.rb
@@ -41,6 +41,16 @@ module Gitlab
"gitlab:exclusive_lease:#{key}"
end
+ # Removes any existing exclusive_lease from redis
+ # Don't run this in a live system without making sure no one is using the leases
+ def self.reset_all!(scope = '*')
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.scan_each(match: redis_shared_state_key(scope)).each do |key|
+ redis.del(key)
+ end
+ end
+ end
+
def initialize(key, uuid: nil, timeout:)
@redis_shared_state_key = self.class.redis_shared_state_key(key)
@timeout = timeout
@@ -71,5 +81,16 @@ module Gitlab
redis.exists(@redis_shared_state_key)
end
end
+
+ # Returns the TTL of the Redis key.
+ #
+ # This method will return `nil` if no TTL could be obtained.
+ def ttl
+ Gitlab::Redis::SharedState.with do |redis|
+ ttl = redis.ttl(@redis_shared_state_key)
+
+ ttl if ttl.positive?
+ end
+ end
end
end
diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb
index 0e9ef4f897c..cc2638172ec 100644
--- a/lib/gitlab/file_detector.rb
+++ b/lib/gitlab/file_detector.rb
@@ -6,14 +6,14 @@ module Gitlab
module FileDetector
PATTERNS = {
# Project files
- readme: /\Areadme[^\/]*\z/i,
- changelog: /\A(changelog|history|changes|news)[^\/]*\z/i,
- license: /\A(licen[sc]e|copying)(\.[^\/]+)?\z/i,
- contributing: /\Acontributing[^\/]*\z/i,
+ readme: %r{\Areadme[^/]*\z}i,
+ changelog: %r{\A(changelog|history|changes|news)[^/]*\z}i,
+ license: %r{\A(licen[sc]e|copying)(\.[^/]+)?\z}i,
+ contributing: %r{\Acontributing[^/]*\z}i,
version: 'version',
avatar: /\Alogo\.(png|jpg|gif)\z/,
- issue_template: /\A\.gitlab\/issue_templates\/[^\/]+\.md\z/,
- merge_request_template: /\A\.gitlab\/merge_request_templates\/[^\/]+\.md\z/,
+ issue_template: %r{\A\.gitlab/issue_templates/[^/]+\.md\z},
+ merge_request_template: %r{\A\.gitlab/merge_request_templates/[^/]+\.md\z},
# Configuration files
gitignore: '.gitignore',
@@ -22,17 +22,17 @@ module Gitlab
route_map: '.gitlab/route-map.yml',
# Dependency files
- cartfile: /\ACartfile[^\/]*\z/,
+ cartfile: %r{\ACartfile[^/]*\z},
composer_json: 'composer.json',
gemfile: /\A(Gemfile|gems\.rb)\z/,
gemfile_lock: 'Gemfile.lock',
- gemspec: /\A[^\/]*\.gemspec\z/,
+ gemspec: %r{\A[^/]*\.gemspec\z},
godeps_json: 'Godeps.json',
package_json: 'package.json',
podfile: 'Podfile',
- podspec_json: /\A[^\/]*\.podspec\.json\z/,
- podspec: /\A[^\/]*\.podspec\z/,
- requirements_txt: /\A[^\/]*requirements\.txt\z/,
+ podspec_json: %r{\A[^/]*\.podspec\.json\z},
+ podspec: %r{\A[^/]*\.podspec\z},
+ requirements_txt: %r{\A[^/]*requirements\.txt\z},
yarn_lock: 'yarn.lock'
}.freeze
diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb
index 10ffc345bd5..8c082c0c336 100644
--- a/lib/gitlab/file_finder.rb
+++ b/lib/gitlab/file_finder.rb
@@ -28,7 +28,7 @@ module Gitlab
def find_by_content(query)
results = repository.search_files_by_content(query, ref).first(BATCH_SIZE)
- results.map { |result| Gitlab::ProjectSearchResults.parse_search_result(result) }
+ results.map { |result| Gitlab::ProjectSearchResults.parse_search_result(result, project) }
end
def find_by_filename(query, except: [])
@@ -45,7 +45,8 @@ module Gitlab
basename: File.basename(blob.path),
ref: ref,
startline: 1,
- data: blob.data
+ data: blob.data,
+ project: project
)
end
end
diff --git a/lib/gitlab/fogbugz_import/importer.rb b/lib/gitlab/fogbugz_import/importer.rb
index 5e426b13ade..8953bc8c148 100644
--- a/lib/gitlab/fogbugz_import/importer.rb
+++ b/lib/gitlab/fogbugz_import/importer.rb
@@ -112,6 +112,7 @@ module Gitlab
[bug['sCategory'], bug['sPriority']].each do |label|
unless label.blank?
labels << label
+
unless @known_labels.include?(label)
create_label(label)
@known_labels << label
@@ -265,6 +266,7 @@ module Gitlab
if content.blank?
content = '*(No description has been entered for this issue)*'
end
+
body << content
body.join("\n\n")
@@ -278,6 +280,7 @@ module Gitlab
if content.blank?
content = "*(No comment has been entered for this change)*"
end
+
body << content
if updates.any?
diff --git a/lib/gitlab/gfm/uploads_rewriter.rb b/lib/gitlab/gfm/uploads_rewriter.rb
index 8fab5489616..1b74f735679 100644
--- a/lib/gitlab/gfm/uploads_rewriter.rb
+++ b/lib/gitlab/gfm/uploads_rewriter.rb
@@ -27,7 +27,7 @@ module Gitlab
with_link_in_tmp_dir(file.file) do |open_tmp_file|
new_uploader.store!(open_tmp_file)
end
- new_uploader.to_markdown
+ new_uploader.markdown_link
end
end
@@ -46,7 +46,7 @@ module Gitlab
private
def find_file(project, secret, file)
- uploader = FileUploader.new(project, secret)
+ uploader = FileUploader.new(project, secret: secret)
uploader.retrieve_from_store!(file)
uploader.file
end
diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb
index 1f7c35cafaa..d4e893b881c 100644
--- a/lib/gitlab/git.rb
+++ b/lib/gitlab/git.rb
@@ -6,12 +6,13 @@ module Gitlab
CommandError = Class.new(StandardError)
CommitError = Class.new(StandardError)
+ OSError = Class.new(StandardError)
class << self
include Gitlab::EncodingHelper
def ref_name(ref)
- encode_utf8(ref).sub(/\Arefs\/(tags|heads|remotes)\//, '')
+ encode!(ref).sub(%r{\Arefs/(tags|heads|remotes)/}, '')
end
def branch_name(ref)
diff --git a/lib/gitlab/git/attributes.rb b/lib/gitlab/git/attributes.rb
deleted file mode 100644
index 2d20cd473a7..00000000000
--- a/lib/gitlab/git/attributes.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-# 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
- # file patterns.
- #
- # Unlike Rugged this parser only needs a single IO call (a call to `open`),
- # vastly reducing the time spent in extracting attributes.
- #
- # This class _only_ supports parsing the attributes file located at
- # `$GIT_DIR/info/attributes` as GitLab doesn't use any other files
- # (`.gitattributes` is copied to this particular path).
- #
- # Basic usage:
- #
- # attributes = Gitlab::Git::Attributes.new(some_repo.path)
- #
- # attributes.attributes('README.md') # => { "eol" => "lf }
- class Attributes
- # path - The path to the Git repository.
- def initialize(path)
- @path = File.expand_path(path)
- @patterns = nil
- end
-
- # Returns all the Git attributes for the given path.
- #
- # path - A path to a file for which to get the attributes.
- #
- # Returns a Hash.
- def attributes(path)
- full_path = File.join(@path, path)
-
- patterns.each do |pattern, attrs|
- return attrs if File.fnmatch?(pattern, full_path)
- end
-
- {}
- end
-
- # Returns a Hash containing the file patterns and their attributes.
- def patterns
- @patterns ||= parse_file
- end
-
- # Parses an attribute string.
- #
- # These strings can be in the following formats:
- #
- # text # => { "text" => true }
- # -text # => { "text" => false }
- # key=value # => { "key" => "value" }
- #
- # string - The string to parse.
- #
- # Returns a Hash containing the attributes and their values.
- def parse_attributes(string)
- values = {}
- dash = '-'
- equal = '='
- binary = 'binary'
-
- string.split(/\s+/).each do |chunk|
- # Data such as "foo = bar" should be treated as "foo" and "bar" being
- # separate boolean attributes.
- next if chunk == equal
-
- key = chunk
-
- # Input: "-foo"
- if chunk.start_with?(dash)
- key = chunk.byteslice(1, chunk.length - 1)
- value = false
-
- # Input: "foo=bar"
- elsif chunk.include?(equal)
- key, value = chunk.split(equal, 2)
-
- # Input: "foo"
- else
- value = true
- end
-
- values[key] = value
-
- # When the "binary" option is set the "diff" option should be set to
- # the inverse. If "diff" is later set it should overwrite the
- # automatically set value.
- values['diff'] = false if key == binary && value
- end
-
- values
- end
-
- # Iterates over every line in the attributes file.
- def each_line
- full_path = File.join(@path, 'info/attributes')
-
- return unless File.exist?(full_path)
-
- File.open(full_path, 'r') do |handle|
- handle.each_line do |line|
- break unless line.valid_encoding?
-
- yield line.strip
- end
- end
- end
-
- private
-
- # Parses the Git attributes file.
- def parse_file
- pairs = []
- comment = '#'
-
- each_line do |line|
- next if line.start_with?(comment) || line.empty?
-
- pattern, attrs = line.split(/\s+/, 2)
-
- parsed = attrs ? parse_attributes(attrs) : {}
-
- pairs << [File.join(@path, pattern), parsed]
- end
-
- # Newer entries take precedence over older entries.
- pairs.reverse.to_h
- end
- end
- end
-end
diff --git a/lib/gitlab/git/attributes_at_ref_parser.rb b/lib/gitlab/git/attributes_at_ref_parser.rb
new file mode 100644
index 00000000000..26b5bd520d5
--- /dev/null
+++ b/lib/gitlab/git/attributes_at_ref_parser.rb
@@ -0,0 +1,14 @@
+module Gitlab
+ module Git
+ # Parses root .gitattributes file at a given ref
+ class AttributesAtRefParser
+ delegate :attributes, to: :@parser
+
+ def initialize(repository, ref)
+ blob = repository.blob_at(ref, '.gitattributes')
+
+ @parser = AttributesParser.new(blob&.data)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/attributes_parser.rb b/lib/gitlab/git/attributes_parser.rb
new file mode 100644
index 00000000000..d8aeabb6cba
--- /dev/null
+++ b/lib/gitlab/git/attributes_parser.rb
@@ -0,0 +1,115 @@
+module Gitlab
+ module Git
+ # Class for parsing Git attribute files and extracting the attributes for
+ # file patterns.
+ class AttributesParser
+ def initialize(attributes_data)
+ @data = attributes_data || ""
+
+ if @data.is_a?(File)
+ @patterns = parse_file
+ end
+ end
+
+ # Returns all the Git attributes for the given path.
+ #
+ # file_path - A path to a file for which to get the attributes.
+ #
+ # Returns a Hash.
+ def attributes(file_path)
+ absolute_path = File.join('/', file_path)
+
+ patterns.each do |pattern, attrs|
+ return attrs if File.fnmatch?(pattern, absolute_path)
+ end
+
+ {}
+ end
+
+ # Returns a Hash containing the file patterns and their attributes.
+ def patterns
+ @patterns ||= parse_file
+ end
+
+ # Parses an attribute string.
+ #
+ # These strings can be in the following formats:
+ #
+ # text # => { "text" => true }
+ # -text # => { "text" => false }
+ # key=value # => { "key" => "value" }
+ #
+ # string - The string to parse.
+ #
+ # Returns a Hash containing the attributes and their values.
+ def parse_attributes(string)
+ values = {}
+ dash = '-'
+ equal = '='
+ binary = 'binary'
+
+ string.split(/\s+/).each do |chunk|
+ # Data such as "foo = bar" should be treated as "foo" and "bar" being
+ # separate boolean attributes.
+ next if chunk == equal
+
+ key = chunk
+
+ # Input: "-foo"
+ if chunk.start_with?(dash)
+ key = chunk.byteslice(1, chunk.length - 1)
+ value = false
+
+ # Input: "foo=bar"
+ elsif chunk.include?(equal)
+ key, value = chunk.split(equal, 2)
+
+ # Input: "foo"
+ else
+ value = true
+ end
+
+ values[key] = value
+
+ # When the "binary" option is set the "diff" option should be set to
+ # the inverse. If "diff" is later set it should overwrite the
+ # automatically set value.
+ values['diff'] = false if key == binary && value
+ end
+
+ values
+ end
+
+ # Iterates over every line in the attributes file.
+ def each_line
+ @data.each_line do |line|
+ break unless line.valid_encoding?
+
+ yield line.strip
+ end
+ end
+
+ private
+
+ # Parses the Git attributes file.
+ def parse_file
+ pairs = []
+ comment = '#'
+
+ each_line do |line|
+ next if line.start_with?(comment) || line.empty?
+
+ pattern, attrs = line.split(/\s+/, 2)
+
+ parsed = attrs ? parse_attributes(attrs) : {}
+
+ absolute_pattern = File.join('/', pattern)
+ pairs << [absolute_pattern, parsed]
+ end
+
+ # Newer entries take precedence over older entries.
+ pairs.reverse.to_h
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/blame.rb b/lib/gitlab/git/blame.rb
index 31effdba292..6d6ed065f79 100644
--- a/lib/gitlab/git/blame.rb
+++ b/lib/gitlab/git/blame.rb
@@ -42,9 +42,7 @@ module Gitlab
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 }
+ @repo.shell_blame(@sha, @path)
end
def process_raw_blame(output)
diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb
index 228d97a87ab..eabcf46cf58 100644
--- a/lib/gitlab/git/blob.rb
+++ b/lib/gitlab/git/blob.rb
@@ -34,7 +34,7 @@ module Gitlab
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)
+ repository.gitaly_blob_client.get_blob(oid: sha, limit: MAX_DATA_DISPLAY_SIZE)
else
rugged_raw(repository, sha, limit: MAX_DATA_DISPLAY_SIZE)
end
@@ -50,10 +50,15 @@ module Gitlab
# to the caller to limit the number of blobs and blob_size_limit.
#
# Gitaly migration issue: https://gitlab.com/gitlab-org/gitaly/issues/798
- 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)
+ def batch(repository, blob_references, blob_size_limit: MAX_DATA_DISPLAY_SIZE)
+ Gitlab::GitalyClient.migrate(:list_blobs_by_sha_path) do |is_enabled|
+ if is_enabled
+ repository.gitaly_blob_client.get_blobs(blob_references, blob_size_limit).to_a
+ else
+ blob_references.map do |sha, path|
+ find_by_rugged(repository, sha, path, limit: blob_size_limit)
+ end
+ end
end
end
@@ -61,11 +66,17 @@ module Gitlab
# Returns array of Gitlab::Git::Blob
# Does not guarantee blob data will be set
def batch_lfs_pointers(repository, blob_ids)
- blob_ids.lazy
- .select { |sha| possible_lfs_blob?(repository, sha) }
- .map { |sha| rugged_raw(repository, sha, limit: LFS_POINTER_MAX_SIZE) }
- .select(&:lfs_pointer?)
- .force
+ repository.gitaly_migrate(:batch_lfs_pointers) do |is_enabled|
+ if is_enabled
+ repository.gitaly_blob_client.batch_lfs_pointers(blob_ids.to_a)
+ else
+ blob_ids.lazy
+ .select { |sha| possible_lfs_blob?(repository, sha) }
+ .map { |sha| rugged_raw(repository, sha, limit: LFS_POINTER_MAX_SIZE) }
+ .select(&:lfs_pointer?)
+ .force
+ end
+ end
end
def binary?(data)
@@ -92,7 +103,7 @@ module Gitlab
def find_entry_by_path(repository, root_id, path)
root_tree = repository.lookup(root_id)
# Strip leading slashes
- path[/^\/*/] = ''
+ path[%r{^/*}] = ''
path_arr = path.split('/')
entry = root_tree.find do |entry|
@@ -122,13 +133,25 @@ module Gitlab
)
end
- def find_by_gitaly(repository, sha, path)
- path = path.sub(/\A\/*/, '')
+ def find_by_gitaly(repository, sha, path, limit: MAX_DATA_DISPLAY_SIZE)
+ return unless path
+
+ path = path.sub(%r{\A/*}, '')
path = '/' if path.empty?
name = File.basename(path)
- entry = Gitlab::GitalyClient::CommitService.new(repository).tree_entry(sha, path, MAX_DATA_DISPLAY_SIZE)
+
+ # Gitaly will think that setting the limit to 0 means unlimited, while
+ # the client might only need the metadata and thus set the limit to 0.
+ # In this method we'll then set the limit to 1, but clear the byte of data
+ # that we got back so for the outside world it looks like the limit was
+ # actually 0.
+ req_limit = limit == 0 ? 1 : limit
+
+ entry = Gitlab::GitalyClient::CommitService.new(repository).tree_entry(sha, path, req_limit)
return unless entry
+ entry.data = "" if limit == 0
+
case entry.type
when :COMMIT
new(
@@ -154,8 +177,10 @@ module Gitlab
end
def find_by_rugged(repository, sha, path, limit:)
- commit = repository.lookup(sha)
- root_tree = commit.tree
+ return unless path
+
+ rugged_commit = repository.lookup(sha)
+ root_tree = rugged_commit.tree
blob_entry = find_entry_by_path(repository, root_tree.oid, path)
@@ -213,9 +238,9 @@ module Gitlab
self.__send__("#{key}=", options[key.to_sym]) # rubocop:disable GitlabSecurity/PublicSend
end
- @loaded_all_data = false
# Retain the actual size before it is encoded
@loaded_size = @data.bytesize if @data
+ @loaded_all_data = @loaded_size == size
end
def binary?
@@ -230,12 +255,17 @@ module Gitlab
# memory as a Ruby string.
def load_all_data!(repository)
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
+ # Even if we return early, recalculate wether this blob is binary in
+ # case a blob was initialized as text but the full data isn't
+ @binary = nil
+
+ return if @loaded_all_data
+
+ @data = Gitlab::GitalyClient.migrate(:git_blob_load_all_data) do |is_enabled|
+ begin
if is_enabled
- Gitlab::GitalyClient::BlobService.new(repository).get_blob(oid: id, limit: -1).data
+ repository.gitaly_blob_client.get_blob(oid: id, limit: -1).data
else
repository.lookup(id).content
end
@@ -244,7 +274,6 @@ module Gitlab
@loaded_all_data = true
@loaded_size = @data.bytesize
- @binary = nil
end
def name
diff --git a/lib/gitlab/git/branch.rb b/lib/gitlab/git/branch.rb
index 3487e099381..6351cfb83e3 100644
--- a/lib/gitlab/git/branch.rb
+++ b/lib/gitlab/git/branch.rb
@@ -1,8 +1,8 @@
-# Gitaly note: JV: no RPC's here.
-
module Gitlab
module Git
class Branch < Ref
+ STALE_BRANCH_THRESHOLD = 3.months
+
def self.find(repo, branch_name)
if branch_name.is_a?(Gitlab::Git::Branch)
branch_name
@@ -14,6 +14,18 @@ module Gitlab
def initialize(repository, name, target, target_commit)
super(repository, name, target, target_commit)
end
+
+ def active?
+ self.dereferenced_target.committed_date >= STALE_BRANCH_THRESHOLD.ago
+ end
+
+ def stale?
+ !active?
+ end
+
+ def state
+ active? ? :active : :stale
+ end
end
end
end
diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb
index 145721dea76..0fb82441bf8 100644
--- a/lib/gitlab/git/commit.rb
+++ b/lib/gitlab/git/commit.rb
@@ -15,8 +15,6 @@ module Gitlab
attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator
- delegate :tree, to: :rugged_commit
-
def ==(other)
return false unless other.is_a?(Gitlab::Git::Commit)
@@ -233,7 +231,8 @@ module Gitlab
# relation to each other. The last 10 commits for a branch for example,
# should go through .where
def batch_by_oid(repo, oids)
- repo.gitaly_migrate(:list_commits_by_oid) do |is_enabled|
+ repo.gitaly_migrate(:list_commits_by_oid,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
repo.gitaly_commit_client.list_commits_by_oid(oids)
else
@@ -241,6 +240,63 @@ module Gitlab
end
end
end
+
+ def extract_signature(repository, commit_id)
+ repository.gitaly_migrate(:extract_commit_signature) do |is_enabled|
+ if is_enabled
+ repository.gitaly_commit_client.extract_signature(commit_id)
+ else
+ rugged_extract_signature(repository, commit_id)
+ end
+ end
+ end
+
+ def extract_signature_lazily(repository, commit_id)
+ BatchLoader.for({ repository: repository, commit_id: commit_id }).batch do |items, loader|
+ items_by_repo = items.group_by { |i| i[:repository] }
+
+ items_by_repo.each do |repo, items|
+ commit_ids = items.map { |i| i[:commit_id] }
+
+ signatures = batch_signature_extraction(repository, commit_ids)
+
+ signatures.each do |commit_sha, signature_data|
+ loader.call({ repository: repository, commit_id: commit_sha }, signature_data)
+ end
+ end
+ end
+ end
+
+ def batch_signature_extraction(repository, commit_ids)
+ repository.gitaly_migrate(:extract_commit_signature_in_batch) do |is_enabled|
+ if is_enabled
+ gitaly_batch_signature_extraction(repository, commit_ids)
+ else
+ rugged_batch_signature_extraction(repository, commit_ids)
+ end
+ end
+ end
+
+ def gitaly_batch_signature_extraction(repository, commit_ids)
+ repository.gitaly_commit_client.get_commit_signatures(commit_ids)
+ end
+
+ def rugged_batch_signature_extraction(repository, commit_ids)
+ commit_ids.each_with_object({}) do |commit_id, signatures|
+ signature_data = rugged_extract_signature(repository, commit_id)
+ next unless signature_data
+
+ signatures[commit_id] = signature_data
+ end
+ end
+
+ def rugged_extract_signature(repository, commit_id)
+ begin
+ Rugged::Commit.extract_signature(repository.rugged, commit_id)
+ rescue Rugged::OdbError
+ nil
+ end
+ end
end
def initialize(repository, raw_commit, head = nil)
@@ -292,7 +348,7 @@ module Gitlab
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/324
def to_diff
- Gitlab::GitalyClient.migrate(:commit_patch) do |is_enabled|
+ Gitlab::GitalyClient.migrate(:commit_patch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
@repository.gitaly_commit_client.patch(id)
else
@@ -386,15 +442,6 @@ module Gitlab
end
end
- # Get a collection of Rugged::Reference objects for this commit.
- #
- # Ex.
- # commit.ref(repo)
- #
- def refs(repo)
- repo.refs_hash[id]
- end
-
# Get ref names collection
#
# Ex.
@@ -402,7 +449,7 @@ module Gitlab
#
def ref_names(repo)
refs(repo).map do |ref|
- ref.name.sub(%r{^refs/(heads|remotes|tags)/}, "")
+ ref.sub(%r{^refs/(heads|remotes|tags)/}, "")
end
end
@@ -438,6 +485,16 @@ module Gitlab
parent_ids.size > 1
end
+ def tree_entry(path)
+ @repository.gitaly_migrate(:commit_tree_entry) do |is_migrated|
+ if is_migrated
+ gitaly_tree_entry(path)
+ else
+ rugged_tree_entry(path)
+ end
+ end
+ end
+
def to_gitaly_commit
return raw_commit if raw_commit.is_a?(Gitaly::GitCommit)
@@ -491,13 +548,35 @@ module Gitlab
@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
+ @parent_ids = Array(commit.parent_ids)
end
def serialize_keys
SERIALIZE_KEYS
end
+ def gitaly_tree_entry(path)
+ # We're only interested in metadata, so limit actual data to 1 byte
+ # since Gitaly doesn't support "send no data" option.
+ entry = @repository.gitaly_commit_client.tree_entry(id, path, 1)
+ return unless entry
+
+ # To be compatible with the rugged format
+ entry = entry.to_h
+ entry.delete(:data)
+ entry[:name] = File.basename(path)
+ entry[:type] = entry[:type].downcase
+
+ entry
+ end
+
+ # Is this the same as Blob.find_entry_by_path ?
+ def rugged_tree_entry(path)
+ rugged_commit.tree.path(path)
+ rescue Rugged::TreeError
+ nil
+ end
+
def gitaly_commit_author_from_rugged(author_or_committer)
Gitaly::CommitAuthor.new(
name: author_or_committer[:name].b,
@@ -505,6 +584,15 @@ module Gitlab
date: Google::Protobuf::Timestamp.new(seconds: author_or_committer[:time].to_i)
)
end
+
+ # Get a collection of Gitlab::Git::Ref objects for this commit.
+ #
+ # Ex.
+ # commit.ref(repo)
+ #
+ def refs(repo)
+ repo.refs_hash[id]
+ end
end
end
end
diff --git a/lib/gitlab/git/commit_stats.rb b/lib/gitlab/git/commit_stats.rb
index 6bf49a0af18..8463b1eb794 100644
--- a/lib/gitlab/git/commit_stats.rb
+++ b/lib/gitlab/git/commit_stats.rb
@@ -34,13 +34,8 @@ module Gitlab
def rugged_stats(commit)
diff = commit.rugged_diff_from_parent
-
- diff.each_patch do |p|
- # TODO: Use the new Rugged convenience methods when they're released
- @additions += p.stat[0]
- @deletions += p.stat[1]
- @total += p.changes
- end
+ _files_changed, @additions, @deletions = diff.stat
+ @total = @additions + @deletions
end
end
end
diff --git a/lib/gitlab/git/conflict/file.rb b/lib/gitlab/git/conflict/file.rb
index b2a625e08fa..f08dab59ce4 100644
--- a/lib/gitlab/git/conflict/file.rb
+++ b/lib/gitlab/git/conflict/file.rb
@@ -2,15 +2,19 @@ module Gitlab
module Git
module Conflict
class File
- attr_reader :content, :their_path, :our_path, :our_mode, :repository, :commit_oid
+ UnsupportedEncoding = Class.new(StandardError)
- def initialize(repository, commit_oid, conflict, content)
+ attr_reader :their_path, :our_path, :our_mode, :repository, :commit_oid
+
+ attr_accessor :raw_content
+
+ def initialize(repository, commit_oid, conflict, raw_content)
@repository = repository
@commit_oid = commit_oid
@their_path = conflict[:theirs][:path]
@our_path = conflict[:ours][:path]
@our_mode = conflict[:ours][:mode]
- @content = content
+ @raw_content = raw_content
end
def lines
@@ -27,6 +31,14 @@ module Gitlab
end
end
+ def content
+ @content ||= @raw_content.dup.force_encoding('UTF-8')
+
+ raise UnsupportedEncoding unless @content.valid_encoding?
+
+ @content
+ end
+
def type
lines unless @type
diff --git a/lib/gitlab/git/conflict/parser.rb b/lib/gitlab/git/conflict/parser.rb
index 3effa9d2d31..fb5717dd556 100644
--- a/lib/gitlab/git/conflict/parser.rb
+++ b/lib/gitlab/git/conflict/parser.rb
@@ -4,7 +4,6 @@ module Gitlab
class Parser
UnresolvableError = Class.new(StandardError)
UnmergeableFile = Class.new(UnresolvableError)
- UnsupportedEncoding = Class.new(UnresolvableError)
# Recoverable errors - the conflict can be resolved in an editor, but not with
# sections.
@@ -75,10 +74,6 @@ module Gitlab
def validate_text!(text)
raise UnmergeableFile if text.blank? # Typically a binary file
raise UnmergeableFile if text.length > 200.kilobytes
-
- text.force_encoding('UTF-8')
-
- raise UnsupportedEncoding unless text.valid_encoding?
end
def validate_delimiter!(condition)
diff --git a/lib/gitlab/git/conflict/resolution.rb b/lib/gitlab/git/conflict/resolution.rb
new file mode 100644
index 00000000000..ab9be683e15
--- /dev/null
+++ b/lib/gitlab/git/conflict/resolution.rb
@@ -0,0 +1,15 @@
+module Gitlab
+ module Git
+ module Conflict
+ class Resolution
+ attr_reader :user, :files, :commit_message
+
+ def initialize(user, files, commit_message)
+ @user = user
+ @files = files
+ @commit_message = commit_message
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/conflict/resolver.rb b/lib/gitlab/git/conflict/resolver.rb
index 03e5c0fcd6f..c3cb0264112 100644
--- a/lib/gitlab/git/conflict/resolver.rb
+++ b/lib/gitlab/git/conflict/resolver.rb
@@ -13,37 +13,27 @@ module Gitlab
def conflicts
@conflicts ||= begin
- target_index = @target_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid)
-
- # We don't need to do `with_repo_branch_commit` here, because the target
- # project always fetches source refs when creating merge request diffs.
- conflict_files(@target_repository, target_index)
+ @target_repository.gitaly_migrate(:conflicts_list_conflict_files) do |is_enabled|
+ if is_enabled
+ gitaly_conflicts_client(@target_repository).list_conflict_files.to_a
+ else
+ rugged_list_conflict_files
+ end
+ end
end
+ rescue GRPC::FailedPrecondition => e
+ raise Gitlab::Git::Conflict::Resolver::ConflictSideMissing.new(e.message)
+ rescue Rugged::ReferenceError, Rugged::OdbError, GRPC::BadStatus => e
+ raise Gitlab::Git::CommandError.new(e)
end
- def resolve_conflicts(source_repository, user, files, source_branch:, target_branch:, commit_message:)
- source_repository.with_repo_branch_commit(@target_repository, target_branch) do
- index = source_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid)
- conflicts = conflict_files(source_repository, index)
-
- files.each do |file_params|
- conflict_file = conflict_for_path(conflicts, file_params[:old_path], file_params[:new_path])
-
- write_resolved_file_to_index(source_repository, index, conflict_file, file_params)
- end
-
- unless index.conflicts.empty?
- missing_files = index.conflicts.map { |file| file[:ours][:path] }
-
- raise ResolutionError, "Missing resolutions for the following files: #{missing_files.join(', ')}"
+ def resolve_conflicts(source_repository, resolution, source_branch:, target_branch:)
+ source_repository.gitaly_migrate(:conflicts_resolve_conflicts) do |is_enabled|
+ if is_enabled
+ gitaly_conflicts_client(source_repository).resolve_conflicts(@target_repository, resolution, source_branch, target_branch)
+ else
+ rugged_resolve_conflicts(source_repository, resolution, source_branch, target_branch)
end
-
- commit_params = {
- message: commit_message,
- parents: [@our_commit_oid, @their_commit_oid]
- }
-
- source_repository.commit_index(user, source_branch, index, commit_params)
end
end
@@ -68,6 +58,10 @@ module Gitlab
end
end
+ def gitaly_conflicts_client(repository)
+ repository.gitaly_conflicts_client(@our_commit_oid, @their_commit_oid)
+ end
+
def write_resolved_file_to_index(repository, index, file, params)
if params[:sections]
resolved_lines = file.resolve_lines(params[:sections])
@@ -84,6 +78,40 @@ module Gitlab
index.add(path: our_path, oid: oid, mode: file.our_mode)
index.conflict_remove(our_path)
end
+
+ def rugged_list_conflict_files
+ target_index = @target_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid)
+
+ # We don't need to do `with_repo_branch_commit` here, because the target
+ # project always fetches source refs when creating merge request diffs.
+ conflict_files(@target_repository, target_index)
+ end
+
+ def rugged_resolve_conflicts(source_repository, resolution, source_branch, target_branch)
+ source_repository.with_repo_branch_commit(@target_repository, target_branch) do
+ index = source_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid)
+ conflicts = conflict_files(source_repository, index)
+
+ resolution.files.each do |file_params|
+ conflict_file = conflict_for_path(conflicts, file_params[:old_path], file_params[:new_path])
+
+ write_resolved_file_to_index(source_repository, index, conflict_file, file_params)
+ end
+
+ unless index.conflicts.empty?
+ missing_files = index.conflicts.map { |file| file[:ours][:path] }
+
+ raise ResolutionError, "Missing resolutions for the following files: #{missing_files.join(', ')}"
+ end
+
+ commit_params = {
+ message: resolution.commit_message,
+ parents: [@our_commit_oid, @their_commit_oid]
+ }
+
+ source_repository.commit_index(resolution.user, source_branch, index, commit_params)
+ end
+ end
end
end
end
diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb
index ca94b4baa59..a203587aec1 100644
--- a/lib/gitlab/git/diff.rb
+++ b/lib/gitlab/git/diff.rb
@@ -44,7 +44,7 @@ module Gitlab
# branch1...branch2) From the git documentation:
# "git diff A...B" is equivalent to "git diff
# $(git-merge-base A B) B"
- repo.merge_base_commit(head, base)
+ repo.merge_base(head, base)
end
options ||= {}
diff --git a/lib/gitlab/git/env.rb b/lib/gitlab/git/env.rb
deleted file mode 100644
index 9d0b47a1a6d..00000000000
--- a/lib/gitlab/git/env.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# Gitaly note: JV: no RPC's here.
-
-module Gitlab
- module Git
- # Ephemeral (per request) storage for environment variables that some Git
- # commands may need.
- #
- # For example, in pre-receive hooks, new objects are put in a temporary
- # $GIT_OBJECT_DIRECTORY. Without it set, the new objects cannot be retrieved
- # (this would break push rules for instance).
- #
- # This class is thread-safe via RequestStore.
- class Env
- WHITELISTED_VARIABLES = %w[
- GIT_OBJECT_DIRECTORY
- GIT_OBJECT_DIRECTORY_RELATIVE
- GIT_ALTERNATE_OBJECT_DIRECTORIES
- GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE
- ].freeze
-
- def self.set(env)
- return unless RequestStore.active?
-
- RequestStore.store[:gitlab_git_env] = whitelist_git_env(env)
- end
-
- def self.all
- return {} unless RequestStore.active?
-
- RequestStore.fetch(:gitlab_git_env) { {} }
- end
-
- def self.to_env_hash
- env = {}
-
- all.compact.each do |key, value|
- value = value.join(File::PATH_SEPARATOR) if value.is_a?(Array)
- env[key.to_s] = value
- end
-
- env
- end
-
- def self.[](key)
- all[key]
- end
-
- def self.whitelist_git_env(env)
- env.select { |key, _| WHITELISTED_VARIABLES.include?(key.to_s) }.with_indifferent_access
- end
- end
- end
-end
diff --git a/lib/gitlab/git/gitlab_projects.rb b/lib/gitlab/git/gitlab_projects.rb
index d948d7895ed..099709620b3 100644
--- a/lib/gitlab/git/gitlab_projects.rb
+++ b/lib/gitlab/git/gitlab_projects.rb
@@ -2,19 +2,16 @@ module Gitlab
module Git
class GitlabProjects
include Gitlab::Git::Popen
+ include Gitlab::Utils::StrongMemoize
- # Absolute path to directory where repositories are stored.
- # Example: /home/git/repositories
- attr_reader :shard_path
+ # Name of shard where repositories are stored.
+ # Example: nfs-file06
+ attr_reader :shard_name
# Relative path is a directory name for repository with .git at the end.
# Example: gitlab-org/gitlab-test.git
attr_reader :repository_relative_path
- # Absolute path to the repository.
- # Example: /home/git/repositorities/gitlab-org/gitlab-test.git
- attr_reader :repository_absolute_path
-
# This is the path at which the gitlab-shell hooks directory can be found.
# It's essential for integration between git and GitLab proper. All new
# repositories should have their hooks directory symlinked here.
@@ -22,13 +19,12 @@ module Gitlab
attr_reader :logger
- def initialize(shard_path, repository_relative_path, global_hooks_path:, logger:)
- @shard_path = shard_path
+ def initialize(shard_name, repository_relative_path, global_hooks_path:, logger:)
+ @shard_name = shard_name
@repository_relative_path = repository_relative_path
@logger = logger
@global_hooks_path = global_hooks_path
- @repository_absolute_path = File.join(shard_path, repository_relative_path)
@output = StringIO.new
end
@@ -38,88 +34,50 @@ module Gitlab
io.read
end
- def rm_project
- logger.info "Removing repository <#{repository_absolute_path}>."
- FileUtils.rm_rf(repository_absolute_path)
- end
-
- # Move repository from one directory to another
- #
- # Example: gitlab/gitlab-ci.git -> randx/six.git
- #
- # Won't work if target namespace directory does not exist
- #
- def mv_project(new_path)
- new_absolute_path = File.join(shard_path, new_path)
-
- # verify that the source repo exists
- unless File.exist?(repository_absolute_path)
- logger.error "mv-project failed: source path <#{repository_absolute_path}> does not exist."
- return false
+ # Absolute path to the repository.
+ # Example: /home/git/repositorities/gitlab-org/gitlab-test.git
+ # Probably will be removed when we fully migrate to Gitaly, part of
+ # https://gitlab.com/gitlab-org/gitaly/issues/1124.
+ def repository_absolute_path
+ strong_memoize(:repository_absolute_path) do
+ File.join(shard_path, repository_relative_path)
end
+ end
- # ...and that the target repo does not exist
- if File.exist?(new_absolute_path)
- logger.error "mv-project failed: destination path <#{new_absolute_path}> already exists."
- return false
+ def shard_path
+ strong_memoize(:shard_path) do
+ Gitlab.config.repositories.storages.fetch(shard_name).legacy_disk_path
end
-
- logger.info "Moving repository from <#{repository_absolute_path}> to <#{new_absolute_path}>."
- FileUtils.mv(repository_absolute_path, new_absolute_path)
end
# Import project via git clone --bare
# URL must be publicly cloneable
def import_project(source, timeout)
- # Skip import if repo already exists
- return false if File.exist?(repository_absolute_path)
-
- masked_source = mask_password_in_url(source)
-
- logger.info "Importing project from <#{masked_source}> to <#{repository_absolute_path}>."
- cmd = %W(git clone --bare -- #{source} #{repository_absolute_path})
-
- success = run_with_timeout(cmd, timeout, nil)
-
- unless success
- logger.error("Importing project from <#{masked_source}> to <#{repository_absolute_path}> failed.")
- FileUtils.rm_rf(repository_absolute_path)
- return false
+ Gitlab::GitalyClient.migrate(:import_repository) do |is_enabled|
+ if is_enabled
+ gitaly_import_repository(source)
+ else
+ git_import_repository(source, timeout)
+ end
end
-
- Gitlab::Git::Repository.create_hooks(repository_absolute_path, global_hooks_path)
-
- # The project was imported successfully.
- # Remove the origin URL since it may contain password.
- remove_origin_in_repo
-
- true
end
- def fork_repository(new_shard_path, new_repository_relative_path)
- from_path = repository_absolute_path
- to_path = File.join(new_shard_path, new_repository_relative_path)
-
- # The repository cannot already exist
- if File.exist?(to_path)
- logger.error "fork-repository failed: destination repository <#{to_path}> already exists."
- return false
+ def fork_repository(new_shard_name, new_repository_relative_path)
+ Gitlab::GitalyClient.migrate(:fork_repository) do |is_enabled|
+ if is_enabled
+ gitaly_fork_repository(new_shard_name, new_repository_relative_path)
+ else
+ git_fork_repository(new_shard_name, new_repository_relative_path)
+ end
end
-
- # Ensure the namepsace / hashed storage directory exists
- FileUtils.mkdir_p(File.dirname(to_path), mode: 0770)
-
- logger.info "Forking repository from <#{from_path}> to <#{to_path}>."
- cmd = %W(git clone --bare --no-local -- #{from_path} #{to_path})
-
- run(cmd, nil) && Gitlab::Git::Repository.create_hooks(to_path, global_hooks_path)
end
- def fetch_remote(name, timeout, force:, tags:, ssh_key: nil, known_hosts: nil)
+ def fetch_remote(name, timeout, force:, tags:, ssh_key: nil, known_hosts: nil, prune: true)
tags_option = tags ? '--tags' : '--no-tags'
logger.info "Fetching remote #{name} for repository #{repository_absolute_path}."
- cmd = %W(git fetch #{name} --prune --quiet)
+ cmd = %W(#{Gitlab.config.git.bin_path} fetch #{name} --quiet)
+ cmd << '--prune' if prune
cmd << '--force' if force
cmd << tags_option
@@ -136,7 +94,7 @@ module Gitlab
def push_branches(remote_name, timeout, force, branch_names)
logger.info "Pushing branches from #{repository_absolute_path} to remote #{remote_name}: #{branch_names}"
- cmd = %w(git push)
+ cmd = %W(#{Gitlab.config.git.bin_path} push)
cmd << '--force' if force
cmd += %W(-- #{remote_name}).concat(branch_names)
@@ -153,7 +111,7 @@ module Gitlab
branches = branch_names.map { |branch_name| ":#{branch_name}" }
logger.info "Pushing deleted branches from #{repository_absolute_path} to remote #{remote_name}: #{branch_names}"
- cmd = %W(git push -- #{remote_name}).concat(branches)
+ cmd = %W(#{Gitlab.config.git.bin_path} push -- #{remote_name}).concat(branches)
success = run(cmd, repository_absolute_path)
@@ -194,7 +152,7 @@ module Gitlab
end
def remove_origin_in_repo
- cmd = %w(git remote rm origin)
+ cmd = %W(#{Gitlab.config.git.bin_path} remote rm origin)
run(cmd, repository_absolute_path)
end
@@ -253,6 +211,74 @@ module Gitlab
known_hosts_file&.close!
script&.close!
end
+
+ private
+
+ def git_import_repository(source, timeout)
+ # Skip import if repo already exists
+ return false if File.exist?(repository_absolute_path)
+
+ masked_source = mask_password_in_url(source)
+
+ logger.info "Importing project from <#{masked_source}> to <#{repository_absolute_path}>."
+ cmd = %W(#{Gitlab.config.git.bin_path} clone --bare -- #{source} #{repository_absolute_path})
+
+ success = run_with_timeout(cmd, timeout, nil)
+
+ unless success
+ logger.error("Importing project from <#{masked_source}> to <#{repository_absolute_path}> failed.")
+ FileUtils.rm_rf(repository_absolute_path)
+ return false
+ end
+
+ Gitlab::Git::Repository.create_hooks(repository_absolute_path, global_hooks_path)
+
+ # The project was imported successfully.
+ # Remove the origin URL since it may contain password.
+ remove_origin_in_repo
+
+ true
+ end
+
+ def gitaly_import_repository(source)
+ raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil)
+
+ Gitlab::GitalyClient::RepositoryService.new(raw_repository).import_repository(source)
+ true
+ rescue GRPC::BadStatus => e
+ @output << e.message
+ false
+ end
+
+ def git_fork_repository(new_shard_name, new_repository_relative_path)
+ from_path = repository_absolute_path
+ new_shard_path = Gitlab.config.repositories.storages.fetch(new_shard_name).legacy_disk_path
+ to_path = File.join(new_shard_path, new_repository_relative_path)
+
+ # The repository cannot already exist
+ if File.exist?(to_path)
+ logger.error "fork-repository failed: destination repository <#{to_path}> already exists."
+ return false
+ end
+
+ # Ensure the namepsace / hashed storage directory exists
+ FileUtils.mkdir_p(File.dirname(to_path), mode: 0770)
+
+ logger.info "Forking repository from <#{from_path}> to <#{to_path}>."
+ cmd = %W(#{Gitlab.config.git.bin_path} clone --bare --no-local -- #{from_path} #{to_path})
+
+ run(cmd, nil) && Gitlab::Git::Repository.create_hooks(to_path, global_hooks_path)
+ end
+
+ def gitaly_fork_repository(new_shard_name, new_repository_relative_path)
+ target_repository = Gitlab::Git::Repository.new(new_shard_name, new_repository_relative_path, nil)
+ raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil)
+
+ Gitlab::GitalyClient::RepositoryService.new(target_repository).fork_repository(raw_repository)
+ rescue GRPC::BadStatus => e
+ logger.error "fork-repository failed: #{e.message}"
+ false
+ end
end
end
end
diff --git a/lib/gitlab/git/gitmodules_parser.rb b/lib/gitlab/git/gitmodules_parser.rb
index 4a43b9b444d..4b505312f60 100644
--- a/lib/gitlab/git/gitmodules_parser.rb
+++ b/lib/gitlab/git/gitmodules_parser.rb
@@ -46,6 +46,8 @@ module Gitlab
iterator = State.new
@content.split("\n").each_with_object(iterator) do |text, iterator|
+ text.chomp!
+
next if text =~ /^\s*#/
if text =~ /\A\[submodule "(?<name>[^"]+)"\]\z/
@@ -55,7 +57,7 @@ module Gitlab
next unless text =~ /\A\s*(?<key>\w+)\s*=\s*(?<value>.*)\z/
- value = $~[:value].chomp
+ value = $~[:value]
iterator.set_attribute($~[:key], value)
end
end
diff --git a/lib/gitlab/git/hook.rb b/lib/gitlab/git/hook.rb
index e29a1f7afa1..7c201c6169b 100644
--- a/lib/gitlab/git/hook.rb
+++ b/lib/gitlab/git/hook.rb
@@ -82,20 +82,26 @@ module Gitlab
end
def call_update_hook(gl_id, gl_username, oldrev, newrev, ref)
- Dir.chdir(repo_path) do
- env = {
- 'GL_ID' => gl_id,
- 'GL_USERNAME' => gl_username
- }
- stdout, stderr, status = Open3.capture3(env, path, ref, oldrev, newrev)
- [status.success?, (stderr.presence || stdout).gsub(/\R/, "<br>").html_safe]
- end
+ env = {
+ 'GL_ID' => gl_id,
+ 'GL_USERNAME' => gl_username,
+ 'PWD' => repo_path
+ }
+
+ options = {
+ chdir: repo_path
+ }
+
+ args = [ref, oldrev, newrev]
+
+ stdout, stderr, status = Open3.capture3(env, path, *args, options)
+ [status.success?, Gitlab::Utils.nlbr(stderr.presence || stdout)]
end
def retrieve_error_message(stderr, stdout)
err_message = stderr.read
err_message = err_message.blank? ? stdout.read : err_message
- err_message.gsub(/\R/, "<br>").html_safe
+ Gitlab::Utils.nlbr(err_message)
end
end
end
diff --git a/lib/gitlab/git/hook_env.rb b/lib/gitlab/git/hook_env.rb
new file mode 100644
index 00000000000..455e8451c10
--- /dev/null
+++ b/lib/gitlab/git/hook_env.rb
@@ -0,0 +1,51 @@
+# Gitaly note: JV: no RPC's here.
+
+module Gitlab
+ module Git
+ # Ephemeral (per request) storage for environment variables that some Git
+ # commands need during internal API calls made from Git push hooks.
+ #
+ # For example, in pre-receive hooks, new objects are put in a temporary
+ # $GIT_OBJECT_DIRECTORY. Without it set, the new objects cannot be retrieved
+ # (this would break push rules for instance).
+ #
+ # This class is thread-safe via RequestStore.
+ class HookEnv
+ WHITELISTED_VARIABLES = %w[
+ GIT_OBJECT_DIRECTORY_RELATIVE
+ GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE
+ ].freeze
+
+ def self.set(gl_repository, env)
+ return unless RequestStore.active?
+
+ raise "missing gl_repository" if gl_repository.blank?
+
+ RequestStore.store[:gitlab_git_env] ||= {}
+ RequestStore.store[:gitlab_git_env][gl_repository] = whitelist_git_env(env)
+ end
+
+ def self.all(gl_repository)
+ return {} unless RequestStore.active?
+
+ h = RequestStore.fetch(:gitlab_git_env) { {} }
+ h.fetch(gl_repository, {})
+ end
+
+ def self.to_env_hash(gl_repository)
+ env = {}
+
+ all(gl_repository).compact.each do |key, value|
+ value = value.join(File::PATH_SEPARATOR) if value.is_a?(Array)
+ env[key.to_s] = value
+ end
+
+ env
+ end
+
+ def self.whitelist_git_env(env)
+ env.select { |key, _| WHITELISTED_VARIABLES.include?(key.to_s) }.with_indifferent_access
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/index.rb b/lib/gitlab/git/index.rb
index db532600d1b..d94082a3e30 100644
--- a/lib/gitlab/git/index.rb
+++ b/lib/gitlab/git/index.rb
@@ -10,6 +10,7 @@ module Gitlab
DEFAULT_MODE = 0o100644
ACTIONS = %w(create create_dir update move delete).freeze
+ ACTION_OPTIONS = %i(file_path previous_path content encoding).freeze
attr_reader :repository, :raw_index
@@ -20,6 +21,11 @@ module Gitlab
delegate :read_tree, :get, to: :raw_index
+ def apply(action, options)
+ validate_action!(action)
+ public_send(action, options.slice(*ACTION_OPTIONS)) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
def write_tree
raw_index.write_tree(repository.rugged)
end
@@ -140,6 +146,12 @@ module Gitlab
rescue Rugged::IndexError => e
raise IndexError, e.message
end
+
+ def validate_action!(action)
+ unless ACTIONS.include?(action.to_s)
+ raise ArgumentError, "Unknown action '#{action}'"
+ end
+ end
end
end
end
diff --git a/lib/gitlab/git/info_attributes.rb b/lib/gitlab/git/info_attributes.rb
new file mode 100644
index 00000000000..e79a440950b
--- /dev/null
+++ b/lib/gitlab/git/info_attributes.rb
@@ -0,0 +1,49 @@
+# 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
+ # Parses gitattributes at `$GIT_DIR/info/attributes`
+ #
+ # Unlike Rugged this parser only needs a single IO call (a call to `open`),
+ # vastly reducing the time spent in extracting attributes.
+ #
+ # This class _only_ supports parsing the attributes file located at
+ # `$GIT_DIR/info/attributes` as GitLab doesn't use any other files
+ # (`.gitattributes` is copied to this particular path).
+ #
+ # Basic usage:
+ #
+ # attributes = Gitlab::Git::InfoAttributes.new(some_repo.path)
+ #
+ # attributes.attributes('README.md') # => { "eol" => "lf }
+ class InfoAttributes
+ delegate :attributes, :patterns, to: :parser
+
+ # path - The path to the Git repository.
+ def initialize(path)
+ @repo_path = File.expand_path(path)
+ end
+
+ def parser
+ @parser ||= begin
+ if File.exist?(attributes_path)
+ File.open(attributes_path, 'r') do |file_handle|
+ AttributesParser.new(file_handle)
+ end
+ else
+ AttributesParser.new("")
+ end
+ end
+ end
+
+ private
+
+ def attributes_path
+ @attributes_path ||= File.join(@repo_path, 'info/attributes')
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/lfs_changes.rb b/lib/gitlab/git/lfs_changes.rb
index 732dd5d998a..b9e5cf258f4 100644
--- a/lib/gitlab/git/lfs_changes.rb
+++ b/lib/gitlab/git/lfs_changes.rb
@@ -7,6 +7,28 @@ module Gitlab
end
def new_pointers(object_limit: nil, not_in: nil)
+ @repository.gitaly_migrate(:blob_get_new_lfs_pointers) do |is_enabled|
+ if is_enabled
+ @repository.gitaly_blob_client.get_new_lfs_pointers(@newrev, object_limit, not_in)
+ else
+ git_new_pointers(object_limit, not_in)
+ end
+ end
+ end
+
+ def all_pointers
+ @repository.gitaly_migrate(:blob_get_all_lfs_pointers) do |is_enabled|
+ if is_enabled
+ @repository.gitaly_blob_client.get_all_lfs_pointers(@newrev)
+ else
+ git_all_pointers
+ end
+ end
+ end
+
+ private
+
+ def git_new_pointers(object_limit, not_in)
@new_pointers ||= begin
rev_list.new_objects(not_in: not_in, require_path: true) do |object_ids|
object_ids = object_ids.take(object_limit) if object_limit
@@ -16,17 +38,14 @@ module Gitlab
end
end
- def all_pointers
+ def git_all_pointers
rev_list.all_objects(require_path: true) do |object_ids|
Gitlab::Git::Blob.batch_lfs_pointers(@repository, object_ids)
end
end
- private
-
def rev_list
- ::Gitlab::Git::RevList.new(path_to_repo: @repository.path_to_repo,
- newrev: @newrev)
+ Gitlab::Git::RevList.new(@repository, newrev: @newrev)
end
end
end
diff --git a/lib/gitlab/git/lfs_pointer_file.rb b/lib/gitlab/git/lfs_pointer_file.rb
new file mode 100644
index 00000000000..2ae0a889590
--- /dev/null
+++ b/lib/gitlab/git/lfs_pointer_file.rb
@@ -0,0 +1,32 @@
+module Gitlab
+ module Git
+ class LfsPointerFile
+ VERSION = "https://git-lfs.github.com/spec/v1".freeze
+ VERSION_LINE = "version #{VERSION}".freeze
+
+ def initialize(data)
+ @data = data
+ end
+
+ def pointer
+ @pointer ||= <<~FILE
+ #{VERSION_LINE}
+ oid sha256:#{sha256}
+ size #{size}
+ FILE
+ end
+
+ def size
+ @size ||= @data.bytesize
+ end
+
+ def sha256
+ @sha256 ||= Digest::SHA256.hexdigest(@data)
+ end
+
+ def inspect
+ "#<#{self.class}:#{object_id} @size=#{size}, @sha256=#{sha256.inspect}>"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/operation_service.rb b/lib/gitlab/git/operation_service.rb
index ef5bdbaf819..280def182d5 100644
--- a/lib/gitlab/git/operation_service.rb
+++ b/lib/gitlab/git/operation_service.rb
@@ -97,6 +97,11 @@ module Gitlab
end
end
+ def update_branch(branch_name, newrev, oldrev)
+ ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name
+ update_ref_in_hooks(ref, newrev, oldrev)
+ end
+
private
# Returns [newrev, should_run_after_create, should_run_after_create_branch]
@@ -126,7 +131,10 @@ module Gitlab
oldrev = branch.target
- if oldrev == repository.merge_base(newrev, branch.target)
+ merge_base = repository.merge_base(newrev, branch.target)
+ raise Gitlab::Git::Repository::InvalidRef unless merge_base
+
+ if oldrev == merge_base
oldrev
else
raise Gitlab::Git::CommitError.new('Branch diverged')
diff --git a/lib/gitlab/git/path_helper.rb b/lib/gitlab/git/path_helper.rb
index 42c80aabd0a..155cf52f050 100644
--- a/lib/gitlab/git/path_helper.rb
+++ b/lib/gitlab/git/path_helper.rb
@@ -6,7 +6,7 @@ module Gitlab
class << self
def normalize_path(filename)
# Strip all leading slashes so that //foo -> foo
- filename[/^\/*/] = ''
+ filename[%r{^/*}] = ''
# Expand relative paths (e.g. foo/../bar)
filename = Pathname.new(filename)
diff --git a/lib/gitlab/git/popen.rb b/lib/gitlab/git/popen.rb
index 1ccca13ce2f..c1767046ff0 100644
--- a/lib/gitlab/git/popen.rb
+++ b/lib/gitlab/git/popen.rb
@@ -19,11 +19,13 @@ module Gitlab
cmd_output = ""
cmd_status = 0
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
+ stdout.set_encoding(Encoding::ASCII_8BIT)
+
yield(stdin) if block_given?
stdin.close
if lazy_block
- return lazy_block.call(stdout.lazy)
+ return [lazy_block.call(stdout.lazy), 0]
else
cmd_output << stdout.read
end
diff --git a/lib/gitlab/git/ref.rb b/lib/gitlab/git/ref.rb
index 372ce005b94..fa71a4e7ea7 100644
--- a/lib/gitlab/git/ref.rb
+++ b/lib/gitlab/git/ref.rb
@@ -23,7 +23,7 @@ module Gitlab
# Ex.
# Ref.extract_branch_name('refs/heads/master') #=> 'master'
def self.extract_branch_name(str)
- str.gsub(/\Arefs\/heads\//, '')
+ str.gsub(%r{\Arefs/heads/}, '')
end
# Gitaly: this method will probably be migrated indirectly via its call sites.
@@ -33,9 +33,9 @@ module Gitlab
object
end
- def initialize(repository, name, target, derefenced_target)
+ def initialize(repository, name, target, dereferenced_target)
@name = Gitlab::Git.ref_name(name)
- @dereferenced_target = derefenced_target
+ @dereferenced_target = dereferenced_target
@target = if target.respond_to?(:oid)
target.oid
elsif target.respond_to?(:name)
diff --git a/lib/gitlab/git/remote_mirror.rb b/lib/gitlab/git/remote_mirror.rb
new file mode 100644
index 00000000000..ebe46722890
--- /dev/null
+++ b/lib/gitlab/git/remote_mirror.rb
@@ -0,0 +1,89 @@
+module Gitlab
+ module Git
+ class RemoteMirror
+ def initialize(repository, ref_name)
+ @repository = repository
+ @ref_name = ref_name
+ end
+
+ def update(only_branches_matching: [])
+ @repository.gitaly_migrate(:remote_update_remote_mirror) do |is_enabled|
+ if is_enabled
+ gitaly_update(only_branches_matching)
+ else
+ rugged_update(only_branches_matching)
+ end
+ end
+ end
+
+ private
+
+ def gitaly_update(only_branches_matching)
+ @repository.gitaly_remote_client.update_remote_mirror(@ref_name, only_branches_matching)
+ end
+
+ def rugged_update(only_branches_matching)
+ local_branches = refs_obj(@repository.local_branches, only_refs_matching: only_branches_matching)
+ remote_branches = refs_obj(@repository.remote_branches(@ref_name), only_refs_matching: only_branches_matching)
+
+ updated_branches = changed_refs(local_branches, remote_branches)
+ push_branches(updated_branches.keys) if updated_branches.present?
+
+ delete_refs(local_branches, remote_branches)
+
+ local_tags = refs_obj(@repository.tags)
+ remote_tags = refs_obj(@repository.remote_tags(@ref_name))
+
+ updated_tags = changed_refs(local_tags, remote_tags)
+ @repository.push_remote_branches(@ref_name, updated_tags.keys) if updated_tags.present?
+
+ delete_refs(local_tags, remote_tags)
+ end
+
+ def refs_obj(refs, only_refs_matching: [])
+ refs.each_with_object({}) do |ref, refs|
+ next if only_refs_matching.present? && !only_refs_matching.include?(ref.name)
+
+ refs[ref.name] = ref
+ end
+ end
+
+ def changed_refs(local_refs, remote_refs)
+ local_refs.select do |ref_name, ref|
+ remote_ref = remote_refs[ref_name]
+
+ remote_ref.nil? || ref.dereferenced_target != remote_ref.dereferenced_target
+ end
+ end
+
+ def push_branches(branches)
+ default_branch, branches = branches.partition do |branch|
+ @repository.root_ref == branch
+ end
+
+ # Push the default branch first so it works fine when remote mirror is empty.
+ branches.unshift(*default_branch)
+
+ @repository.push_remote_branches(@ref_name, branches)
+ end
+
+ def delete_refs(local_refs, remote_refs)
+ refs = refs_to_delete(local_refs, remote_refs)
+
+ @repository.delete_remote_branches(@ref_name, refs.keys) if refs.present?
+ end
+
+ def refs_to_delete(local_refs, remote_refs)
+ default_branch_id = @repository.commit.id
+
+ remote_refs.select do |remote_ref_name, remote_ref|
+ next false if local_refs[remote_ref_name] # skip if branch or tag exist in local repo
+
+ remote_ref_id = remote_ref.dereferenced_target.try(:id)
+
+ remote_ref_id && @repository.rugged_is_ancestor?(remote_ref_id, default_branch_id)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 044c60caa05..f1b575bd872 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -8,6 +8,7 @@ module Gitlab
class Repository
include Gitlab::Git::RepositoryMirroring
include Gitlab::Git::Popen
+ include Gitlab::EncodingHelper
ALLOWED_OBJECT_DIRECTORIES_VARIABLES = %w[
GIT_OBJECT_DIRECTORY
@@ -21,6 +22,8 @@ module Gitlab
REBASE_WORKTREE_PREFIX = 'rebase'.freeze
SQUASH_WORKTREE_PREFIX = 'squash'.freeze
GITALY_INTERNAL_URL = 'ssh://gitaly/internal.git'.freeze
+ GITLAB_PROJECTS_TIMEOUT = Gitlab.config.gitlab_shell.git_timeout
+ EMPTY_REPOSITORY_CHECKSUM = '0000000000000000000000000000000000000000'.freeze
NoRepository = Class.new(StandardError)
InvalidBlobName = Class.new(StandardError)
@@ -29,6 +32,7 @@ module Gitlab
DeleteBranchError = Class.new(StandardError)
CreateTreeError = Class.new(StandardError)
TagExistsError = Class.new(StandardError)
+ ChecksumError = Class.new(StandardError)
class << self
# Unlike `new`, `create` takes the repository path
@@ -56,11 +60,12 @@ module Gitlab
# Do nothing if hooks already exist
unless real_local_hooks_path == File.realpath(global_hooks_path)
- # Move the existing hooks somewhere safe
- FileUtils.mv(
- local_hooks_path,
- "#{local_hooks_path}.old.#{Time.now.to_i}"
- ) if File.exist?(local_hooks_path)
+ if File.exist?(local_hooks_path)
+ # Move the existing hooks somewhere safe
+ FileUtils.mv(
+ local_hooks_path,
+ "#{local_hooks_path}.old.#{Time.now.to_i}")
+ end
# Create the hooks symlink
FileUtils.ln_sf(global_hooks_path, local_hooks_path)
@@ -82,7 +87,7 @@ module Gitlab
# Rugged repo object
attr_reader :rugged
- attr_reader :storage, :gl_repository, :relative_path
+ attr_reader :gitlab_projects, :storage, :gl_repository, :relative_path
# This initializer method is only used on the client side (gitlab-ce).
# Gitaly-ruby uses a different initializer.
@@ -91,10 +96,16 @@ module Gitlab
@relative_path = relative_path
@gl_repository = gl_repository
- storage_path = Gitlab.config.repositories.storages[@storage]['path']
+ storage_path = Gitlab.config.repositories.storages[@storage].legacy_disk_path
+ @gitlab_projects = Gitlab::Git::GitlabProjects.new(
+ storage,
+ relative_path,
+ global_hooks_path: Gitlab.config.gitlab_shell.hooks_path,
+ logger: Rails.logger
+ )
@path = File.join(storage_path, @relative_path)
@name = @relative_path.split("/").last
- @attributes = Gitlab::Git::Attributes.new(path)
+ @attributes = Gitlab::Git::InfoAttributes.new(path)
end
def ==(other)
@@ -120,12 +131,16 @@ module Gitlab
raise NoRepository.new('no repository for such path')
end
+ def cleanup
+ @rugged&.close
+ end
+
def circuit_breaker
@circuit_breaker ||= Gitlab::Git::Storage::CircuitBreaker.for_storage(storage)
end
def exists?
- Gitlab::GitalyClient.migrate(:repository_exists, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled|
+ Gitlab::GitalyClient.migrate(:repository_exists, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled|
if enabled
gitaly_repository_client.exists?
else
@@ -187,7 +202,7 @@ module Gitlab
end
def local_branches(sort_by: nil)
- gitaly_migrate(:local_branches, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
+ gitaly_migrate(:local_branches) do |is_enabled|
if is_enabled
gitaly_ref_client.local_branches(sort_by: sort_by)
else
@@ -216,7 +231,7 @@ module Gitlab
end
def has_local_branches?
- gitaly_migrate(:has_local_branches) do |is_enabled|
+ gitaly_migrate(:has_local_branches, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
gitaly_repository_client.has_local_branches?
else
@@ -381,17 +396,24 @@ module Gitlab
nil
end
- def archive_prefix(ref, sha)
+ def archive_prefix(ref, sha, append_sha:)
+ append_sha = (ref != sha) if append_sha.nil?
+
project_name = self.name.chomp('.git')
- "#{project_name}-#{ref.tr('/', '-')}-#{sha}"
+ formatted_ref = ref.tr('/', '-')
+
+ prefix_segments = [project_name, formatted_ref]
+ prefix_segments << sha if append_sha
+
+ prefix_segments.join('-')
end
- def archive_metadata(ref, storage_path, format = "tar.gz")
+ def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:)
ref ||= root_ref
commit = Gitlab::Git::Commit.find(self, ref)
return {} if commit.nil?
- prefix = archive_prefix(ref, commit.id)
+ prefix = archive_prefix(ref, commit.id, append_sha: append_sha)
{
'RepoPath' => path,
@@ -454,15 +476,19 @@ module Gitlab
path: nil,
follow: false,
skip_merges: false,
- disable_walk: false,
after: nil,
- before: nil
+ before: nil,
+ all: false
}
options = default_options.merge(options)
- options[:limit] ||= 0
options[:offset] ||= 0
+ limit = options[:limit]
+ if limit == 0 || !limit.is_a?(Integer)
+ raise ArgumentError.new("invalid Repository#log limit: #{limit.inspect}")
+ end
+
gitaly_migrate(:find_commits) do |is_enabled|
if is_enabled
gitaly_commit_client.find_commits(options)
@@ -474,35 +500,32 @@ module Gitlab
# Used in gitaly-ruby
def raw_log(options)
- actual_ref = options[:ref] || root_ref
- begin
- sha = sha_from_ref(actual_ref)
- rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError
- # Return an empty array if the ref wasn't found
- return []
- end
+ sha =
+ unless options[:all]
+ actual_ref = options[: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
+ end
- if log_using_shell?(options)
- log_by_shell(sha, options)
- else
- log_by_walk(sha, options)
- end
+ log_by_shell(sha, options)
end
def count_commits(options)
+ count_commits_options = process_count_commits_options(options)
+
gitaly_migrate(:count_commits) do |is_enabled|
if is_enabled
- count_commits_by_gitaly(options)
+ count_commits_by_gitaly(count_commits_options)
else
- count_commits_by_shelling_out(options)
+ count_commits_by_shelling_out(count_commits_options)
end
end
end
- def sha_from_ref(ref)
- rev_parse_target(ref).oid
- end
-
# Return the object that +revspec+ points to. If +revspec+ is an
# annotated tag, then return the tag's target instead.
def rev_parse_target(revspec)
@@ -532,36 +555,57 @@ module Gitlab
end
# Counts the amount of commits between `from` and `to`.
- def count_commits_between(from, to)
- count_commits(ref: "#{from}..#{to}")
+ def count_commits_between(from, to, options = {})
+ count_commits(from: from, to: to, **options)
end
# Returns the SHA of the most recent common ancestor of +from+ and +to+
- def merge_base_commit(from, to)
+ def merge_base(from, to)
gitaly_migrate(:merge_base) do |is_enabled|
if is_enabled
gitaly_repository_client.find_merge_base(from, to)
else
- rugged.merge_base(from, to)
+ rugged_merge_base(from, to)
end
end
end
- alias_method :merge_base, :merge_base_commit
# 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
+ rugged_merge_base(ancestor_id, descendant_id) == ancestor_id
+ rescue Rugged::OdbError
+ false
end
# Returns true is +from+ is direct ancestor to +to+, otherwise false
def ancestor?(from, to)
- gitaly_commit_client.ancestor?(from, to)
+ Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
+ if is_enabled
+ gitaly_commit_client.ancestor?(from, to)
+ else
+ rugged_is_ancestor?(from, to)
+ end
+ end
end
def merged_branch_names(branch_names = [])
- Set.new(git_merged_branch_names(branch_names))
+ return [] unless root_ref
+
+ root_sha = find_branch(root_ref)&.target
+
+ return [] unless root_sha
+
+ branches = gitaly_migrate(:merged_branch_names) do |is_enabled|
+ if is_enabled
+ gitaly_merged_branch_names(branch_names, root_sha)
+ else
+ git_merged_branch_names(branch_names, root_sha)
+ end
+ end
+
+ Set.new(branches)
end
# Return an array of Diff objects that represent the diff
@@ -588,62 +632,29 @@ module Gitlab
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})
+ args = %W(for-each-ref --count=1 #{ref_path} --contains #{sha})
# Not found -> ["", 0]
# Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
- popen(args, @path).first.split.last
+ run_git(args).first.split.last
end
end
end
- # Returns branch names collection that contains the special commit(SHA1
- # or name)
- #
- # Ex.
- # repo.branch_names_contains('master')
- #
- def branch_names_contains(commit)
- branches_contains(commit).map { |c| c.name }
- end
-
- # Returns branch collection that contains the special commit(SHA1 or name)
- #
- # Ex.
- # repo.branch_names_contains('master')
- #
- def branches_contains(commit)
- commit_obj = rugged.rev_parse(commit)
- parent = commit_obj.parents.first unless commit_obj.parents.empty?
+ # Get refs hash which key is is the commit id
+ # and value is a Gitlab::Git::Tag or Gitlab::Git::Branch
+ # Note that both inherit from Gitlab::Git::Ref
+ def refs_hash
+ return @refs_hash if @refs_hash
- walker = Rugged::Walker.new(rugged)
+ @refs_hash = Hash.new { |h, k| h[k] = [] }
- rugged.branches.select do |branch|
- walker.push(branch.target_id)
- walker.hide(parent) if parent
- result = walker.any? { |c| c.oid == commit_obj.oid }
- walker.reset
+ (tags + branches).each do |ref|
+ next unless ref.target && ref.name
- result
+ @refs_hash[ref.dereferenced_target.id] << ref.name
end
- end
- # Get refs hash which key is SHA1
- # and value is a Rugged::Reference
- def refs_hash
- # Initialize only when first call
- if @refs_hash.nil?
- @refs_hash = Hash.new { |h, k| h[k] = [] }
-
- rugged.references.each do |r|
- # Symbolic/remote references may not have an OID; skip over them
- target_oid = r.target.try(:oid)
- if target_oid
- sha = rev_parse_target(target_oid).oid
- @refs_hash[sha] << r
- end
- end
- end
@refs_hash
end
@@ -680,11 +691,7 @@ module Gitlab
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
+ rugged_commit_count(ref)
end
end
end
@@ -714,7 +721,7 @@ module Gitlab
end
def add_branch(branch_name, user:, target:)
- gitaly_migrate(:operation_user_create_branch) do |is_enabled|
+ gitaly_migrate(:operation_user_create_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
gitaly_add_branch(branch_name, user, target)
else
@@ -724,7 +731,7 @@ module Gitlab
end
def add_tag(tag_name, user:, target:, message: nil)
- gitaly_migrate(:operation_user_add_tag) do |is_enabled|
+ gitaly_migrate(:operation_user_add_tag, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
gitaly_add_tag(tag_name, user: user, target: target, message: message)
else
@@ -734,7 +741,7 @@ module Gitlab
end
def rm_branch(branch_name, user:)
- gitaly_migrate(:operation_user_delete_branch) do |is_enabled|
+ gitaly_migrate(:operation_user_delete_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
gitaly_operations_client.user_delete_branch(branch_name, user)
else
@@ -809,7 +816,7 @@ module Gitlab
end
def revert(user:, commit:, branch_name:, message:, start_branch_name:, start_repository:)
- gitaly_migrate(:revert) do |is_enabled|
+ gitaly_migrate(:revert, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
args = {
user: user,
commit: commit,
@@ -875,7 +882,7 @@ module Gitlab
# Delete the specified branch from the repository
def delete_branch(branch_name)
- gitaly_migrate(:delete_branch) do |is_enabled|
+ gitaly_migrate(:delete_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
gitaly_ref_client.delete_branch(branch_name)
else
@@ -887,17 +894,13 @@ module Gitlab
end
def delete_refs(*ref_names)
- instructions = ref_names.map do |ref|
- "delete #{ref}\x00\x00"
- end
-
- command = %W[#{Gitlab.config.git.bin_path} update-ref --stdin -z]
- message, status = popen(command, path) do |stdin|
- stdin.write(instructions.join)
- end
-
- unless status.zero?
- raise GitError.new("Could not delete refs #{ref_names}: #{message}")
+ gitaly_migrate(:delete_refs,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
+ if is_enabled
+ gitaly_delete_refs(*ref_names)
+ else
+ git_delete_refs(*ref_names)
+ end
end
end
@@ -907,7 +910,7 @@ module Gitlab
# create_branch("feature")
# create_branch("other-feature", "master")
def create_branch(ref, start_point = "HEAD")
- gitaly_migrate(:create_branch) do |is_enabled|
+ gitaly_migrate(:create_branch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
gitaly_ref_client.create_branch(ref, start_point)
else
@@ -918,31 +921,23 @@ module Gitlab
# If `mirror_refmap` is present the remote is set as mirror with that mapping
def add_remote(remote_name, url, mirror_refmap: nil)
- rugged.remotes.create(remote_name, url)
-
- set_remote_as_mirror(remote_name, refmap: mirror_refmap) if mirror_refmap
- rescue Rugged::ConfigError
- remote_update(remote_name, url: url)
+ gitaly_migrate(:remote_add_remote) do |is_enabled|
+ if is_enabled
+ gitaly_remote_client.add_remote(remote_name, url, mirror_refmap)
+ else
+ rugged_add_remote(remote_name, url, mirror_refmap)
+ end
+ end
end
def remove_remote(remote_name)
- # When a remote is deleted all its remote refs are deleted too, but in
- # the case of mirrors we map its refs (that would usualy go under
- # [remote_name]/) to the top level namespace. We clean the mapping so
- # those don't get deleted.
- if rugged.config["remote.#{remote_name}.mirror"]
- rugged.config.delete("remote.#{remote_name}.fetch")
+ gitaly_migrate(:remote_remove_remote) do |is_enabled|
+ if is_enabled
+ gitaly_remote_client.remove_remote(remote_name)
+ else
+ rugged_remove_remote(remote_name)
+ end
end
-
- rugged.remotes.delete(remote_name)
- true
- rescue Rugged::ConfigError
- false
- end
-
- # Returns true if a remote exists.
- def remote_exists?(name)
- rugged.remotes[name].present?
end
# Update the specified remote using the values in the +options+ hash
@@ -1009,8 +1004,21 @@ module Gitlab
attributes(path)[name]
end
+ # Check .gitattributes for a given ref
+ #
+ # This only checks the root .gitattributes file,
+ # it does not traverse subfolders to find additional .gitattributes files
+ #
+ # This method is around 30 times slower than `attributes`, which uses
+ # `$GIT_DIR/info/attributes`. Consider caching AttributesAtRefParser
+ # and reusing that for multiple calls instead of this method.
+ def attributes_at(ref, file_path)
+ parser = AttributesAtRefParser.new(self, ref)
+ parser.attributes(file_path)
+ end
+
def languages(ref = nil)
- Gitlab::GitalyClient.migrate(:commit_languages) do |is_enabled|
+ gitaly_migrate(:commit_languages, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
gitaly_commit_client.languages(ref)
else
@@ -1036,6 +1044,22 @@ module Gitlab
end
end
+ def license_short_name
+ gitaly_migrate(:license_short_name,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.license_short_name
+ else
+ begin
+ # The licensee gem creates a Rugged object from the path:
+ # https://github.com/benbalter/licensee/blob/v8.7.0/lib/licensee/projects/git_project.rb
+ Licensee.license(path).try(:key)
+ rescue Rugged::Error
+ end
+ end
+ end
+ end
+
def with_repo_branch_commit(start_repository, start_branch_name)
Gitlab::Git.check_namespace!(start_repository)
start_repository = RemoteRepository.new(start_repository) unless start_repository.is_a?(RemoteRepository)
@@ -1101,12 +1125,16 @@ module Gitlab
end
end
- def write_ref(ref_path, ref)
- raise ArgumentError, "invalid ref_path #{ref_path.inspect}" if ref_path.include?(' ')
- raise ArgumentError, "invalid ref #{ref.inspect}" if ref.include?("\x00")
+ def write_ref(ref_path, ref, old_ref: nil, shell: true)
+ ref_path = "#{Gitlab::Git::BRANCH_REF_PREFIX}#{ref_path}" unless ref_path.start_with?("refs/") || ref_path == "HEAD"
- input = "update #{ref_path}\x00#{ref}\x00\x00"
- run_git!(%w[update-ref --stdin -z]) { |stdin| stdin.write(input) }
+ gitaly_migrate(:write_ref) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.write_ref(ref_path, ref, old_ref, shell)
+ else
+ local_write_ref(ref_path, ref, old_ref: old_ref, shell: shell)
+ end
+ end
end
def fetch_ref(source_repository, source_ref:, target_ref:)
@@ -1128,30 +1156,6 @@ module Gitlab
end
# Refactoring aid; allows us to copy code from app/models/repository.rb
- def run_git(args, chdir: path, env: {}, nice: false, &block)
- cmd = [Gitlab.config.git.bin_path, *args]
- cmd.unshift("nice") if nice
- circuit_breaker.perform do
- popen(cmd, chdir, env, &block)
- end
- end
-
- def run_git!(args, chdir: path, env: {}, nice: false, &block)
- output, status = run_git(args, chdir: chdir, env: env, nice: nice, &block)
-
- raise GitError, output unless status.zero?
-
- output
- end
-
- # Refactoring aid; allows us to copy code from app/models/repository.rb
- def run_git_with_timeout(args, timeout, env: {})
- circuit_breaker.perform do
- popen_with_timeout([Gitlab.config.git.bin_path, *args], timeout, path, env)
- end
- end
-
- # Refactoring aid; allows us to copy code from app/models/repository.rb
def commit(ref = 'HEAD')
Gitlab::Git::Commit.find(self, ref)
end
@@ -1161,23 +1165,13 @@ module Gitlab
end
def fetch_repository_as_mirror(repository)
- remote_name = "tmp-#{SecureRandom.hex}"
-
- # Notice that this feature flag is not for `fetch_repository_as_mirror`
- # as a whole but for the fetching mechanism (file path or gitaly-ssh).
- url, env = gitaly_migrate(:fetch_internal) do |is_enabled|
+ gitaly_migrate(:remote_fetch_internal_remote) do |is_enabled|
if is_enabled
- repository = RemoteRepository.new(repository) unless repository.is_a?(RemoteRepository)
- [GITALY_INTERNAL_URL, repository.fetch_env]
+ gitaly_remote_client.fetch_internal_remote(repository)
else
- [repository.path, nil]
+ rugged_fetch_repository_as_mirror(repository)
end
end
-
- add_remote(remote_name, url, mirror_refmap: :all_refs)
- fetch_remote(remote_name, env: env)
- ensure
- remove_remote(remote_name)
end
def blob_at(sha, path)
@@ -1185,7 +1179,7 @@ module Gitlab
end
# Items should be of format [[commit_id, path], [commit_id1, path1]]
- def batch_blobs(items, blob_size_limit: nil)
+ def batch_blobs(items, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
Gitlab::Git::Blob.batch(self, items, blob_size_limit: blob_size_limit)
end
@@ -1204,73 +1198,132 @@ module Gitlab
end
def fsck
- gitaly_migrate(:git_fsck) do |is_enabled|
- msg, status = if is_enabled
- gitaly_fsck
- else
- shell_fsck
- end
+ msg, status = gitaly_repository_client.fsck
+
+ raise GitError.new("Could not fsck repository: #{msg}") unless status.zero?
+ end
- raise GitError.new("Could not fsck repository: #{msg}") unless status.zero?
+ def create_from_bundle(bundle_path)
+ gitaly_migrate(:create_repo_from_bundle) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.create_from_bundle(bundle_path)
+ else
+ run_git!(%W(clone --bare -- #{bundle_path} #{path}), chdir: nil)
+ self.class.create_hooks(path, File.expand_path(Gitlab.config.gitlab_shell.hooks_path))
+ end
end
+
+ true
end
def rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:)
- rebase_path = worktree_path(REBASE_WORKTREE_PREFIX, rebase_id)
- env = git_env_for_user(user)
-
- with_worktree(rebase_path, branch, env: env) do
- run_git!(
- %W(pull --rebase #{remote_repository.path} #{remote_branch}),
- chdir: rebase_path, env: env
- )
+ gitaly_migrate(:rebase) do |is_enabled|
+ if is_enabled
+ gitaly_rebase(user, rebase_id,
+ branch: branch,
+ branch_sha: branch_sha,
+ remote_repository: remote_repository,
+ remote_branch: remote_branch)
+ else
+ git_rebase(user, rebase_id,
+ branch: branch,
+ branch_sha: branch_sha,
+ remote_repository: remote_repository,
+ remote_branch: remote_branch)
+ end
+ end
+ end
- rebase_sha = run_git!(%w(rev-parse HEAD), chdir: rebase_path, env: env).strip
+ def rebase_in_progress?(rebase_id)
+ gitaly_migrate(:rebase_in_progress) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.rebase_in_progress?(rebase_id)
+ else
+ fresh_worktree?(worktree_path(REBASE_WORKTREE_PREFIX, rebase_id))
+ end
+ end
+ end
- Gitlab::Git::OperationService.new(user, self)
- .update_branch(branch, rebase_sha, branch_sha)
+ def squash(user, squash_id, branch:, start_sha:, end_sha:, author:, message:)
+ gitaly_migrate(:squash) do |is_enabled|
+ if is_enabled
+ gitaly_operation_client.user_squash(user, squash_id, branch,
+ start_sha, end_sha, author, message)
+ else
+ git_squash(user, squash_id, branch, start_sha, end_sha, author, message)
+ end
+ end
+ end
- rebase_sha
+ def squash_in_progress?(squash_id)
+ gitaly_migrate(:squash_in_progress) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.squash_in_progress?(squash_id)
+ else
+ fresh_worktree?(worktree_path(SQUASH_WORKTREE_PREFIX, squash_id))
+ end
end
end
- def rebase_in_progress?(rebase_id)
- fresh_worktree?(worktree_path(REBASE_WORKTREE_PREFIX, rebase_id))
+ def push_remote_branches(remote_name, branch_names, forced: true)
+ success = @gitlab_projects.push_branches(remote_name, GITLAB_PROJECTS_TIMEOUT, forced, branch_names)
+
+ success || gitlab_projects_error
end
- def squash(user, squash_id, branch:, start_sha:, end_sha:, author:, message:)
- squash_path = worktree_path(SQUASH_WORKTREE_PREFIX, squash_id)
- env = git_env_for_user(user).merge(
- 'GIT_AUTHOR_NAME' => author.name,
- 'GIT_AUTHOR_EMAIL' => author.email
- )
- diff_range = "#{start_sha}...#{end_sha}"
- diff_files = run_git!(
- %W(diff --name-only --diff-filter=a --binary #{diff_range})
- ).chomp
+ def delete_remote_branches(remote_name, branch_names)
+ success = @gitlab_projects.delete_remote_branches(remote_name, branch_names)
- with_worktree(squash_path, branch, sparse_checkout_files: diff_files, env: env) do
- # Apply diff of the `diff_range` to the worktree
- diff = run_git!(%W(diff --binary #{diff_range}))
- run_git!(%w(apply --index), chdir: squash_path, env: env) do |stdin|
- stdin.write(diff)
+ success || gitlab_projects_error
+ end
+
+ def delete_remote_branches(remote_name, branch_names)
+ success = @gitlab_projects.delete_remote_branches(remote_name, branch_names)
+
+ success || gitlab_projects_error
+ end
+
+ def bundle_to_disk(save_path)
+ gitaly_migrate(:bundle_to_disk) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.create_bundle(save_path)
+ else
+ run_git!(%W(bundle create #{save_path} --all))
end
+ end
- # Commit the `diff_range` diff
- run_git!(%W(commit --no-verify --message #{message}), chdir: squash_path, env: env)
+ true
+ end
- # Return the squash sha. May print a warning for ambiguous refs, but
- # we can ignore that with `--quiet` and just take the SHA, if present.
- # HEAD here always refers to the current HEAD commit, even if there is
- # another ref called HEAD.
- run_git!(
- %w(rev-parse --quiet --verify HEAD), chdir: squash_path, env: env
- ).chomp
+ # rubocop:disable Metrics/ParameterLists
+ def multi_action(
+ user, branch_name:, message:, actions:,
+ author_email: nil, author_name: nil,
+ start_branch_name: nil, start_repository: self)
+
+ gitaly_migrate(:operation_user_commit_files) do |is_enabled|
+ if is_enabled
+ gitaly_operation_client.user_commit_files(user, branch_name,
+ message, actions, author_email, author_name,
+ start_branch_name, start_repository)
+ else
+ rugged_multi_action(user, branch_name, message, actions,
+ author_email, author_name, start_branch_name, start_repository)
+ end
end
end
+ # rubocop:enable Metrics/ParameterLists
- def squash_in_progress?(squash_id)
- fresh_worktree?(worktree_path(SQUASH_WORKTREE_PREFIX, squash_id))
+ def write_config(full_path:)
+ return unless full_path.present?
+
+ gitaly_migrate(:write_config) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.write_config(full_path: full_path)
+ else
+ rugged_write_config(full_path: full_path)
+ end
+ end
end
def gitaly_repository
@@ -1297,6 +1350,18 @@ module Gitlab
@gitaly_operation_client ||= Gitlab::GitalyClient::OperationService.new(self)
end
+ def gitaly_remote_client
+ @gitaly_remote_client ||= Gitlab::GitalyClient::RemoteService.new(self)
+ end
+
+ def gitaly_blob_client
+ @gitaly_blob_client ||= Gitlab::GitalyClient::BlobService.new(self)
+ end
+
+ def gitaly_conflicts_client(our_commit_oid, their_commit_oid)
+ Gitlab::GitalyClient::ConflictsService.new(self, our_commit_oid, their_commit_oid)
+ end
+
def gitaly_migrate(method, status: Gitlab::GitalyClient::MigrationStatus::OPT_IN, &block)
Gitlab::GitalyClient.migrate(method, status: status, &block)
rescue GRPC::NotFound => e
@@ -1307,10 +1372,110 @@ module Gitlab
raise CommandError.new(e)
end
- private
+ def clean_stale_repository_files
+ gitaly_migrate(:repository_cleanup, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
+ gitaly_repository_client.cleanup if is_enabled && exists?
+ end
+ rescue Gitlab::Git::CommandError => e # Don't fail if we can't cleanup
+ Rails.logger.error("Unable to clean repository on storage #{storage} with path #{path}: #{e.message}")
+ Gitlab::Metrics.counter(
+ :failed_repository_cleanup_total,
+ 'Number of failed repository cleanup events'
+ ).increment
+ end
- def fresh_worktree?(path)
- File.exist?(path) && !clean_stuck_worktree(path)
+ def branch_names_contains_sha(sha)
+ gitaly_migrate(:branch_names_contains_sha) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.branch_names_contains_sha(sha)
+ else
+ refs_contains_sha('refs/heads/', sha)
+ end
+ end
+ end
+
+ def tag_names_contains_sha(sha)
+ gitaly_migrate(:tag_names_contains_sha) do |is_enabled|
+ if is_enabled
+ gitaly_ref_client.tag_names_contains_sha(sha)
+ else
+ refs_contains_sha('refs/tags/', sha)
+ end
+ end
+ end
+
+ def search_files_by_content(query, ref)
+ return [] if empty? || query.blank?
+
+ offset = 2
+ args = %W(grep -i -I -n -z --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref})
+
+ run_git(args).first.scrub.split(/^--\n/)
+ end
+
+ def can_be_merged?(source_sha, target_branch)
+ gitaly_migrate(:can_be_merged) do |is_enabled|
+ if is_enabled
+ gitaly_can_be_merged?(source_sha, find_branch(target_branch, true).target)
+ else
+ rugged_can_be_merged?(source_sha, target_branch)
+ end
+ end
+ end
+
+ def search_files_by_name(query, ref)
+ safe_query = Regexp.escape(query.sub(%r{^/*}, ""))
+
+ return [] if empty? || safe_query.blank?
+
+ args = %W(ls-tree --full-tree -r #{ref || root_ref} --name-status | #{safe_query})
+
+ run_git(args).first.lines.map(&:strip)
+ end
+
+ def find_commits_by_message(query, ref, path, limit, offset)
+ 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 shell_blame(sha, path)
+ output, _status = run_git(%W(blame -p #{sha} -- #{path}))
+ output
+ end
+
+ def last_commit_for_path(sha, path)
+ 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 rev_list(including: [], excluding: [], objects: false, &block)
+ args = ['rev-list']
+
+ args.push(*rev_list_param(including))
+
+ exclude_param = *rev_list_param(excluding)
+ if exclude_param.any?
+ args.push('--not')
+ args.push(*exclude_param)
+ end
+
+ args.push('--objects') if objects
+
+ run_git!(args, lazy_block: block)
+ end
+
+ def missed_ref(oldrev, newrev)
+ run_git!(['rev-list', '--max-count=1', oldrev, "^#{newrev}"])
end
def with_worktree(worktree_path, branch, sparse_checkout_files: nil, env:)
@@ -1323,7 +1488,7 @@ module Gitlab
if sparse_checkout_files
# Create worktree without checking out
run_git!(base_args + ['--no-checkout', worktree_path], env: env)
- worktree_git_path = run_git!(%w(rev-parse --git-dir), chdir: worktree_path)
+ worktree_git_path = run_git!(%w(rev-parse --git-dir), chdir: worktree_path).chomp
configure_sparse_checkout(worktree_git_path, sparse_checkout_files)
@@ -1340,6 +1505,102 @@ module Gitlab
FileUtils.rm_rf(worktree_git_path) if worktree_git_path && File.exist?(worktree_git_path)
end
+ def checksum
+ gitaly_migrate(:calculate_checksum) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.calculate_checksum
+ else
+ calculate_checksum_by_shelling_out
+ end
+ end
+ end
+
+ private
+
+ def local_write_ref(ref_path, ref, old_ref: nil, shell: true)
+ if shell
+ shell_write_ref(ref_path, ref, old_ref)
+ else
+ rugged_write_ref(ref_path, ref)
+ end
+ end
+
+ def refs_contains_sha(refs_prefix, sha)
+ refs_prefix << "/" unless refs_prefix.ends_with?('/')
+
+ # By forcing the output to %(refname) each line wiht a ref will start with
+ # the ref prefix. All other lines can be discarded.
+ args = %W(for-each-ref --contains=#{sha} --format=%(refname) #{refs_prefix})
+ names, code = run_git(args)
+
+ return [] unless code.zero?
+
+ refs = []
+ left_slice_count = refs_prefix.length
+ names.lines.each do |line|
+ next unless line.start_with?(refs_prefix)
+
+ refs << encode_utf8(line.rstrip[left_slice_count..-1])
+ end
+
+ refs
+ end
+
+ def rugged_write_config(full_path:)
+ rugged.config['gitlab.fullpath'] = full_path
+ end
+
+ def shell_write_ref(ref_path, ref, old_ref)
+ raise ArgumentError, "invalid ref_path #{ref_path.inspect}" if ref_path.include?(' ')
+ raise ArgumentError, "invalid ref #{ref.inspect}" if ref.include?("\x00")
+ raise ArgumentError, "invalid old_ref #{old_ref.inspect}" if !old_ref.nil? && old_ref.include?("\x00")
+
+ input = "update #{ref_path}\x00#{ref}\x00#{old_ref}\x00"
+ run_git!(%w[update-ref --stdin -z]) { |stdin| stdin.write(input) }
+ end
+
+ def rugged_write_ref(ref_path, ref)
+ rugged.references.create(ref_path, ref, force: true)
+ rescue Rugged::ReferenceError => ex
+ Rails.logger.error "Unable to create #{ref_path} reference for repository #{path}: #{ex}"
+ rescue Rugged::OSError => ex
+ raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/
+
+ Rails.logger.error "Unable to create #{ref_path} reference for repository #{path}: #{ex}"
+ end
+
+ def run_git(args, chdir: path, env: {}, nice: false, lazy_block: nil, &block)
+ cmd = [Gitlab.config.git.bin_path, *args]
+ cmd.unshift("nice") if nice
+
+ object_directories = alternate_object_directories
+ if object_directories.any?
+ env['GIT_ALTERNATE_OBJECT_DIRECTORIES'] = object_directories.join(File::PATH_SEPARATOR)
+ end
+
+ circuit_breaker.perform do
+ popen(cmd, chdir, env, lazy_block: lazy_block, &block)
+ end
+ end
+
+ def run_git!(args, chdir: path, env: {}, nice: false, lazy_block: nil, &block)
+ output, status = run_git(args, chdir: chdir, env: env, nice: nice, lazy_block: lazy_block, &block)
+
+ raise GitError, output unless status.zero?
+
+ output
+ end
+
+ def run_git_with_timeout(args, timeout, env: {})
+ circuit_breaker.perform do
+ popen_with_timeout([Gitlab.config.git.bin_path, *args], timeout, path, env)
+ end
+ end
+
+ def fresh_worktree?(path)
+ File.exist?(path) && !clean_stuck_worktree(path)
+ end
+
def clean_stuck_worktree(path)
return false unless File.mtime(path) < 15.minutes.ago
@@ -1360,14 +1621,6 @@ module Gitlab
File.write(File.join(worktree_info_path, 'sparse-checkout'), files)
end
- def gitaly_fsck
- gitaly_repository_client.fsck
- end
-
- def shell_fsck
- run_git(%W[--git-dir=#{path} fsck], nice: true)
- end
-
def rugged_fetch_source_branch(source_repository, source_branch, local_ref)
with_repo_branch_commit(source_repository, source_branch) do |commit|
if commit
@@ -1399,29 +1652,19 @@ module Gitlab
# 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)
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37464
- branches = Gitlab::GitalyClient.allow_n_plus_1_calls do
- 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
- end
+ 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
- # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/695
- def git_merged_branch_names(branch_names = [])
- return [] unless root_ref
-
- root_sha = find_branch(root_ref)&.target
-
- return [] unless root_sha
-
+ def git_merged_branch_names(branch_names, root_sha)
git_arguments =
%W[branch --merged #{root_sha}
--format=%(refname:short)\ %(objectname)] + branch_names
@@ -1435,22 +1678,32 @@ module Gitlab
end
end
- def log_using_shell?(options)
- options[:path].present? ||
- options[:disable_walk] ||
- options[:skip_merges] ||
- options[:after] ||
- options[:before]
+ def gitaly_merged_branch_names(branch_names, root_sha)
+ qualified_branch_names = branch_names.map { |b| "refs/heads/#{b}" }
+
+ gitaly_ref_client.merged_branches(qualified_branch_names)
+ .reject { |b| b.target == root_sha }
+ .map(&:name)
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
+ def process_count_commits_options(options)
+ if options[:from] || options[:to]
+ ref =
+ if options[:left_right] # Compare with merge-base for left-right
+ "#{options[:from]}...#{options[:to]}"
+ else
+ "#{options[:from]}..#{options[:to]}"
+ end
+
+ options.merge(ref: ref)
+
+ elsif options[:ref] && options[:left_right]
+ from, to = options[:ref].match(/\A([^\.]*)\.{2,3}([^\.]*)\z/)[1..2]
+
+ options.merge(from: from, to: to)
+ else
+ options
+ end
end
# Gitaly note: JV: although #log_by_shell shells out to Git I think the
@@ -1466,7 +1719,7 @@ module Gitlab
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 = %w[log]
cmd << "--max-count=#{limit}"
cmd << '--format=%H'
cmd << "--skip=#{offset}" unless offset_in_ruby
@@ -1474,7 +1727,12 @@ module Gitlab
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
+
+ if options[:all]
+ cmd += %w[--all --reverse]
+ else
+ cmd << sha
+ end
# :path can be a string or an array of strings
if options[:path].present?
@@ -1482,7 +1740,7 @@ module Gitlab
cmd += Array(options[:path])
end
- raw_output = IO.popen(cmd) { |io| io.read }
+ raw_output, _status = run_git(cmd)
lines = offset_in_ruby ? raw_output.lines.drop(offset) : raw_output.lines
lines.map! { |c| Rugged::Commit.new(rugged, c.strip) }
@@ -1520,16 +1778,11 @@ module Gitlab
end
def alternate_object_directories
- relative_paths = Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_RELATIVE_DIRECTORIES_VARIABLES).flatten.compact
+ relative_object_directories.map { |d| File.join(path, d) }
+ end
- if relative_paths.any?
- relative_paths.map { |d| File.join(path, d) }
- else
- Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_DIRECTORIES_VARIABLES)
- .flatten
- .compact
- .flat_map { |d| d.split(File::PATH_SEPARATOR) }
- end
+ def relative_object_directories
+ Gitlab::Git::HookEnv.all(gl_repository).values_at(*ALLOWED_OBJECT_RELATIVE_DIRECTORIES_VARIABLES).flatten.compact
end
# Get the content of a blob for a given commit. If the blob is a commit
@@ -1640,7 +1893,7 @@ module Gitlab
end
def last_commit_for_path_by_rugged(sha, path)
- sha = last_commit_id_for_path(sha, path)
+ sha = last_commit_id_for_path_by_shelling_out(sha, path)
commit(sha)
end
@@ -1657,19 +1910,68 @@ module Gitlab
end
def count_commits_by_gitaly(options)
- gitaly_commit_client.commit_count(options[:ref], options)
+ if options[:left_right]
+ from = options[:from]
+ to = options[:to]
+
+ right_count = gitaly_commit_client
+ .commit_count("#{from}..#{to}", options)
+ left_count = gitaly_commit_client
+ .commit_count("#{to}..#{from}", options)
+
+ [left_count, right_count]
+ else
+ gitaly_commit_client.commit_count(options[:ref], options)
+ end
end
def count_commits_by_shelling_out(options)
- cmd = %W[#{Gitlab.config.git.bin_path} --git-dir=#{path} rev-list]
+ cmd = count_commits_shelling_command(options)
+
+ raw_output, _status = run_git(cmd)
+
+ process_count_commits_raw_output(raw_output, options)
+ end
+
+ def count_commits_shelling_command(options)
+ cmd = %w[rev-list]
cmd << "--after=#{options[:after].iso8601}" if options[:after]
cmd << "--before=#{options[:before].iso8601}" if options[:before]
- cmd += %W[--count #{options[:ref]}]
+ cmd << "--max-count=#{options[:max_count]}" if options[:max_count]
+ cmd << "--left-right" if options[:left_right]
+ cmd << '--count'
+
+ cmd << if options[:all]
+ '--all'
+ elsif options[:ref]
+ options[:ref]
+ else
+ raise ArgumentError, "Please specify a valid ref or set the 'all' attribute to true"
+ end
+
cmd += %W[-- #{options[:path]}] if options[:path].present?
+ cmd
+ end
+
+ def process_count_commits_raw_output(raw_output, options)
+ if options[:left_right]
+ result = raw_output.scan(/\d+/).map(&:to_i)
- raw_output = IO.popen(cmd) { |io| io.read }
+ if result.sum != options[:max_count]
+ result
+ else # Reaching max count, right is not accurate
+ right_option =
+ process_count_commits_options(options
+ .except(:left_right, :from, :to)
+ .merge(ref: options[:to]))
- raw_output.to_i
+ right = count_commits_by_shelling_out(right_option)
+
+ [result.first, right] # left should be accurate in the first call
+ end
+ else
+ raw_output.to_i
+ end
end
def gitaly_ls_files(ref)
@@ -1686,20 +1988,17 @@ module Gitlab
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})
+ cmd = %W(ls-tree -r --full-tree --full-name -- #{actual_ref})
+ raw_output, _status = run_git(cmd)
- raw_output = IO.popen(cmd, &:read).split("\n").map do |f|
+ lines = raw_output.split("\n").map do |f|
stuff, path = f.split("\t")
_mode, type, _sha = stuff.split(" ")
path if type == "blob"
# Contain only blob type
end
- raw_output.compact
+ lines.compact
end
# Returns true if the given ref name exists
@@ -1773,7 +2072,7 @@ module Gitlab
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("Branch #{ref} already exists") if e.to_s =~ %r{'refs/heads/#{ref}'}
raise InvalidRef.new("Invalid reference #{start_point}")
end
@@ -1889,6 +2188,72 @@ module Gitlab
tree_id
end
+ def gitaly_rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:)
+ gitaly_operation_client.user_rebase(user, rebase_id,
+ branch: branch,
+ branch_sha: branch_sha,
+ remote_repository: remote_repository,
+ remote_branch: remote_branch)
+ end
+
+ def git_rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:)
+ rebase_path = worktree_path(REBASE_WORKTREE_PREFIX, rebase_id)
+ env = git_env_for_user(user)
+
+ if remote_repository.is_a?(RemoteRepository)
+ env.merge!(remote_repository.fetch_env)
+ remote_repo_path = GITALY_INTERNAL_URL
+ else
+ remote_repo_path = remote_repository.path
+ end
+
+ with_worktree(rebase_path, branch, env: env) do
+ run_git!(
+ %W(pull --rebase #{remote_repo_path} #{remote_branch}),
+ chdir: rebase_path, env: env
+ )
+
+ rebase_sha = run_git!(%w(rev-parse HEAD), chdir: rebase_path, env: env).strip
+
+ Gitlab::Git::OperationService.new(user, self)
+ .update_branch(branch, rebase_sha, branch_sha)
+
+ rebase_sha
+ end
+ end
+
+ def git_squash(user, squash_id, branch, start_sha, end_sha, author, message)
+ squash_path = worktree_path(SQUASH_WORKTREE_PREFIX, squash_id)
+ env = git_env_for_user(user).merge(
+ 'GIT_AUTHOR_NAME' => author.name,
+ 'GIT_AUTHOR_EMAIL' => author.email
+ )
+ diff_range = "#{start_sha}...#{end_sha}"
+ diff_files = run_git!(
+ %W(diff --name-only --diff-filter=ar --binary #{diff_range})
+ ).chomp
+
+ with_worktree(squash_path, branch, sparse_checkout_files: diff_files, env: env) do
+ # Apply diff of the `diff_range` to the worktree
+ diff = run_git!(%W(diff --binary #{diff_range}))
+ run_git!(%w(apply --index --whitespace=nowarn), chdir: squash_path, env: env) do |stdin|
+ stdin.binmode
+ stdin.write(diff)
+ end
+
+ # Commit the `diff_range` diff
+ run_git!(%W(commit --no-verify --message #{message}), chdir: squash_path, env: env)
+
+ # Return the squash sha. May print a warning for ambiguous refs, but
+ # we can ignore that with `--quiet` and just take the SHA, if present.
+ # HEAD here always refers to the current HEAD commit, even if there is
+ # another ref called HEAD.
+ run_git!(
+ %w(rev-parse --quiet --verify HEAD), chdir: squash_path, env: env
+ ).chomp
+ end
+ end
+
def local_fetch_ref(source_path, source_ref:, target_ref:)
args = %W(fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref})
run_git(args)
@@ -1912,13 +2277,190 @@ module Gitlab
source_sha
end
- rescue Rugged::ReferenceError
+ rescue Rugged::ReferenceError, InvalidRef
raise ArgumentError, 'Invalid merge source'
end
+ def rugged_add_remote(remote_name, url, mirror_refmap)
+ rugged.remotes.create(remote_name, url)
+
+ set_remote_as_mirror(remote_name, refmap: mirror_refmap) if mirror_refmap
+ rescue Rugged::ConfigError
+ remote_update(remote_name, url: url)
+ end
+
+ def git_delete_refs(*ref_names)
+ instructions = ref_names.map do |ref|
+ "delete #{ref}\x00\x00"
+ end
+
+ message, status = run_git(%w[update-ref --stdin -z]) do |stdin|
+ stdin.write(instructions.join)
+ end
+
+ unless status.zero?
+ raise GitError.new("Could not delete refs #{ref_names}: #{message}")
+ end
+ end
+
+ def gitaly_delete_refs(*ref_names)
+ gitaly_ref_client.delete_refs(refs: ref_names)
+ end
+
+ def rugged_remove_remote(remote_name)
+ # When a remote is deleted all its remote refs are deleted too, but in
+ # the case of mirrors we map its refs (that would usualy go under
+ # [remote_name]/) to the top level namespace. We clean the mapping so
+ # those don't get deleted.
+ if rugged.config["remote.#{remote_name}.mirror"]
+ rugged.config.delete("remote.#{remote_name}.fetch")
+ end
+
+ rugged.remotes.delete(remote_name)
+ true
+ rescue Rugged::ConfigError
+ false
+ end
+
+ def rugged_fetch_repository_as_mirror(repository)
+ remote_name = "tmp-#{SecureRandom.hex}"
+ repository = RemoteRepository.new(repository) unless repository.is_a?(RemoteRepository)
+
+ add_remote(remote_name, GITALY_INTERNAL_URL, mirror_refmap: :all_refs)
+ fetch_remote(remote_name, env: repository.fetch_env)
+ ensure
+ remove_remote(remote_name)
+ end
+
+ def rugged_multi_action(
+ user, branch_name, message, actions, author_email, author_name,
+ start_branch_name, start_repository)
+
+ OperationService.new(user, self).with_branch(
+ branch_name,
+ start_branch_name: start_branch_name,
+ start_repository: start_repository
+ ) do |start_commit|
+ index = Gitlab::Git::Index.new(self)
+ parents = []
+
+ if start_commit
+ index.read_tree(start_commit.rugged_commit.tree)
+ parents = [start_commit.sha]
+ end
+
+ actions.each { |opts| index.apply(opts.delete(:action), opts) }
+
+ committer = user_to_committer(user)
+ author = Gitlab::Git.committer_hash(email: author_email, name: author_name) || committer
+ options = {
+ tree: index.write_tree,
+ message: message,
+ parents: parents,
+ author: author,
+ committer: committer
+ }
+
+ create_commit(options)
+ end
+ end
+
def fetch_remote(remote_name = 'origin', env: nil)
run_git(['fetch', remote_name], env: env).last.zero?
end
+
+ def gitaly_can_be_merged?(their_commit, our_commit)
+ !gitaly_conflicts_client(our_commit, their_commit).conflicts?
+ end
+
+ def rugged_can_be_merged?(their_commit, our_commit)
+ !rugged.merge_commits(our_commit, their_commit).conflicts?
+ end
+
+ def gitlab_projects_error
+ raise CommandError, @gitlab_projects.output
+ 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)
+ gitaly_commit_client
+ .commits_by_message(query, revision: ref, path: path, limit: limit, offset: offset)
+ .map { |c| commit(c) }
+ end
+
+ def last_commit_for_path_by_gitaly(sha, path)
+ gitaly_commit_client.last_commit_for_path(sha, path)
+ end
+
+ def last_commit_id_for_path_by_shelling_out(sha, path)
+ args = %W(rev-list --max-count=1 #{sha} -- #{path})
+ run_git_with_timeout(args, Gitlab::Git::Popen::FAST_GIT_PROCESS_TIMEOUT).first.strip
+ end
+
+ def rugged_merge_base(from, to)
+ rugged.merge_base(from, to)
+ rescue Rugged::ReferenceError
+ nil
+ end
+
+ def rugged_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
+ rescue Rugged::ReferenceError
+ 0
+ end
+
+ def rev_list_param(spec)
+ spec == :all ? ['--all'] : spec
+ end
+
+ def sha_from_ref(ref)
+ rev_parse_target(ref).oid
+ end
+
+ def calculate_checksum_by_shelling_out
+ raise NoRepository unless exists?
+
+ args = %W(--git-dir=#{path} show-ref --heads --tags)
+ output, status = run_git(args)
+
+ if status.nil? || !status.zero?
+ # Empty repositories return with a non-zero status and an empty output.
+ return EMPTY_REPOSITORY_CHECKSUM if output&.empty?
+
+ raise ChecksumError, output
+ end
+
+ refs = output.split("\n")
+
+ result = refs.inject(nil) do |checksum, ref|
+ value = Digest::SHA1.hexdigest(ref).hex
+
+ if checksum.nil?
+ value
+ else
+ checksum ^ value
+ end
+ end
+
+ result.to_s(16)
+ end
end
end
end
diff --git a/lib/gitlab/git/repository_mirroring.rb b/lib/gitlab/git/repository_mirroring.rb
index effb1f0ca19..dc424a433fb 100644
--- a/lib/gitlab/git/repository_mirroring.rb
+++ b/lib/gitlab/git/repository_mirroring.rb
@@ -43,7 +43,7 @@ module Gitlab
branches = []
rugged.references.each("refs/remotes/#{remote_name}/*").map do |ref|
- name = ref.name.sub(/\Arefs\/remotes\/#{remote_name}\//, '')
+ name = ref.name.sub(%r{\Arefs/remotes/#{remote_name}/}, '')
begin
target_commit = Gitlab::Git::Commit.find(self, ref.target)
diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb
index 4974205b8fd..38c3a55f96f 100644
--- a/lib/gitlab/git/rev_list.rb
+++ b/lib/gitlab/git/rev_list.rb
@@ -5,17 +5,17 @@ module Gitlab
class RevList
include Gitlab::Git::Popen
- attr_reader :oldrev, :newrev, :path_to_repo
+ attr_reader :oldrev, :newrev, :repository
- def initialize(path_to_repo:, newrev:, oldrev: nil)
+ def initialize(repository, newrev:, oldrev: nil)
@oldrev = oldrev
@newrev = newrev
- @path_to_repo = path_to_repo
+ @repository = repository
end
# This method returns an array of new commit references
def new_refs
- execute([*base_args, newrev, '--not', '--all'])
+ repository.rev_list(including: newrev, excluding: :all).split("\n")
end
# Finds newly added objects
@@ -28,66 +28,39 @@ module Gitlab
# When given a block it will yield objects as a lazy enumerator so
# the caller can limit work done instead of processing megabytes of data
def new_objects(require_path: nil, not_in: nil, &lazy_block)
- args = [*base_args, newrev, *not_in_refs(not_in), '--objects']
+ opts = {
+ including: newrev,
+ excluding: not_in.nil? ? :all : not_in,
+ require_path: require_path
+ }
- get_objects(args, require_path: require_path, &lazy_block)
+ get_objects(opts, &lazy_block)
end
def all_objects(require_path: nil, &lazy_block)
- args = [*base_args, '--all', '--objects']
-
- get_objects(args, require_path: require_path, &lazy_block)
+ get_objects(including: :all, require_path: require_path, &lazy_block)
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}"])
+ repository.missed_ref(oldrev, newrev).split("\n")
end
private
- def not_in_refs(references)
- return ['--not', '--all'] unless references
- return [] if references.empty?
-
- references.prepend('--not')
- end
-
def execute(args)
- output, status = popen(args, nil, Gitlab::Git::Env.to_env_hash)
-
- unless status.zero?
- raise "Got a non-zero exit code while calling out `#{args.join(' ')}`: #{output}"
- end
-
- output.split("\n")
- end
-
- def lazy_execute(args, &lazy_block)
- popen(args, nil, Gitlab::Git::Env.to_env_hash, lazy_block: lazy_block)
- end
-
- def base_args
- [
- Gitlab.config.git.bin_path,
- "--git-dir=#{path_to_repo}",
- 'rev-list'
- ]
+ repository.rev_list(args).split("\n")
end
- def get_objects(args, require_path: nil)
- if block_given?
- lazy_execute(args) do |lazy_output|
- objects = objects_from_output(lazy_output, require_path: require_path)
+ def get_objects(including: [], excluding: [], require_path: nil)
+ opts = { including: including, excluding: excluding, objects: true }
- yield(objects)
- end
- else
- object_output = execute(args)
+ repository.rev_list(opts) do |lazy_output|
+ objects = objects_from_output(lazy_output, require_path: require_path)
- objects_from_output(object_output, require_path: require_path)
+ yield(objects)
end
end
@@ -95,7 +68,7 @@ module Gitlab
object_output.map do |output_line|
sha, path = output_line.split(' ', 2)
- next if require_path && path.blank?
+ next if require_path && path.to_s.empty?
sha
end.reject(&:nil?)
diff --git a/lib/gitlab/git/storage/checker.rb b/lib/gitlab/git/storage/checker.rb
index d3c37f82101..2f611cef37b 100644
--- a/lib/gitlab/git/storage/checker.rb
+++ b/lib/gitlab/git/storage/checker.rb
@@ -35,7 +35,7 @@ module Gitlab
def initialize(storage, logger = Rails.logger)
@storage = storage
config = Gitlab.config.repositories.storages[@storage]
- @storage_path = config['path']
+ @storage_path = config.legacy_disk_path
@logger = logger
@hostname = Gitlab::Environment.hostname
diff --git a/lib/gitlab/git/storage/circuit_breaker.rb b/lib/gitlab/git/storage/circuit_breaker.rb
index 898bb1b65be..e35054466ff 100644
--- a/lib/gitlab/git/storage/circuit_breaker.rb
+++ b/lib/gitlab/git/storage/circuit_breaker.rb
@@ -25,7 +25,7 @@ module Gitlab
if !config.present?
NullCircuitBreaker.new(storage, hostname, error: Misconfiguration.new("Storage '#{storage}' is not configured"))
- elsif !config['path'].present?
+ elsif !config.legacy_disk_path.present?
NullCircuitBreaker.new(storage, hostname, error: Misconfiguration.new("Path for storage '#{storage}' is not configured"))
else
new(storage, hostname)
diff --git a/lib/gitlab/git/storage/forked_storage_check.rb b/lib/gitlab/git/storage/forked_storage_check.rb
index 1307f400700..0a4e557b59b 100644
--- a/lib/gitlab/git/storage/forked_storage_check.rb
+++ b/lib/gitlab/git/storage/forked_storage_check.rb
@@ -27,6 +27,7 @@ module Gitlab
status = nil
while status.nil?
+
if deadline > Time.now.utc
sleep(wait_time)
_pid, status = Process.wait2(filesystem_check_pid, Process::WNOHANG)
diff --git a/lib/gitlab/git/tag.rb b/lib/gitlab/git/tag.rb
index bc4e160dce9..8a8f7b051ed 100644
--- a/lib/gitlab/git/tag.rb
+++ b/lib/gitlab/git/tag.rb
@@ -1,5 +1,3 @@
-# Gitaly note: JV: no RPC's here.
-#
module Gitlab
module Git
class Tag < Ref
diff --git a/lib/gitlab/git/tree.rb b/lib/gitlab/git/tree.rb
index 5cf336af3c6..b6ceb542dd1 100644
--- a/lib/gitlab/git/tree.rb
+++ b/lib/gitlab/git/tree.rb
@@ -14,14 +14,14 @@ module Gitlab
# Uses rugged for raw objects
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/320
- def where(repository, sha, path = nil)
+ def where(repository, sha, path = nil, recursive = false)
path = nil if path == '' || path == '/'
Gitlab::GitalyClient.migrate(:tree_entries) do |is_enabled|
if is_enabled
- repository.gitaly_commit_client.tree_entries(repository, sha, path)
+ repository.gitaly_commit_client.tree_entries(repository, sha, path, recursive)
else
- tree_entries_from_rugged(repository, sha, path)
+ tree_entries_from_rugged(repository, sha, path, recursive)
end
end
end
@@ -57,7 +57,22 @@ module Gitlab
end
end
- def tree_entries_from_rugged(repository, sha, path)
+ def tree_entries_from_rugged(repository, sha, path, recursive)
+ current_path_entries = get_tree_entries_from_rugged(repository, sha, path)
+ ordered_entries = []
+
+ current_path_entries.each do |entry|
+ ordered_entries << entry
+
+ if recursive && entry.dir?
+ ordered_entries.concat(tree_entries_from_rugged(repository, sha, entry.path, true))
+ end
+ end
+
+ ordered_entries
+ end
+
+ def get_tree_entries_from_rugged(repository, sha, path)
commit = repository.lookup(sha)
root_tree = commit.tree
@@ -83,6 +98,8 @@ module Gitlab
commit_id: sha
)
end
+ rescue Rugged::ReferenceError
+ []
end
end
diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb
index d4a53d32c28..8d82820915d 100644
--- a/lib/gitlab/git/wiki.rb
+++ b/lib/gitlab/git/wiki.rb
@@ -29,7 +29,6 @@ module Gitlab
@repository.gitaly_migrate(:wiki_write_page) do |is_enabled|
if is_enabled
gitaly_write_page(name, format, content, commit_details)
- gollum_wiki.clear_cache
else
gollum_write_page(name, format, content, commit_details)
end
@@ -40,7 +39,6 @@ module Gitlab
@repository.gitaly_migrate(:wiki_delete_page) do |is_enabled|
if is_enabled
gitaly_delete_page(page_path, commit_details)
- gollum_wiki.clear_cache
else
gollum_delete_page(page_path, commit_details)
end
@@ -51,7 +49,6 @@ module Gitlab
@repository.gitaly_migrate(:wiki_update_page) do |is_enabled|
if is_enabled
gitaly_update_page(page_path, title, format, content, commit_details)
- gollum_wiki.clear_cache
else
gollum_update_page(page_path, title, format, content, commit_details)
end
@@ -59,7 +56,7 @@ module Gitlab
end
def pages(limit: nil)
- @repository.gitaly_migrate(:wiki_get_all_pages, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled|
+ @repository.gitaly_migrate(:wiki_get_all_pages) do |is_enabled|
if is_enabled
gitaly_get_all_pages
else
@@ -93,11 +90,23 @@ module Gitlab
# :per_page - The number of items per page.
# :limit - Total number of items to return.
def page_versions(page_path, options = {})
- current_page = gollum_page_by_path(page_path)
+ @repository.gitaly_migrate(:wiki_page_versions) do |is_enabled|
+ if is_enabled
+ versions = gitaly_wiki_client.page_versions(page_path, options)
+
+ # Gitaly uses gollum-lib to get the versions. Gollum defaults to 20
+ # per page, but also fetches 20 if `limit` or `per_page` < 20.
+ # Slicing returns an array with the expected number of items.
+ slice_bound = options[:limit] || options[:per_page] || Gollum::Page.per_page
+ versions[0..slice_bound]
+ else
+ current_page = gollum_page_by_path(page_path)
- commits_from_page(current_page, options).map do |gitlab_git_commit|
- gollum_page = gollum_wiki.page(current_page.title, gitlab_git_commit.id)
- Gitlab::Git::WikiPageVersion.new(gitlab_git_commit, gollum_page&.format)
+ commits_from_page(current_page, options).map do |gitlab_git_commit|
+ gollum_page = gollum_wiki.page(current_page.title, gitlab_git_commit.id)
+ Gitlab::Git::WikiPageVersion.new(gitlab_git_commit, gollum_page&.format)
+ end
+ end
end
end
@@ -117,6 +126,20 @@ module Gitlab
page.url_path
end
+ def page_formatted_data(title:, dir: nil, version: nil)
+ version = version&.id
+
+ @repository.gitaly_migrate(:wiki_page_formatted_data) do |is_enabled|
+ if is_enabled
+ gitaly_wiki_client.get_formatted_data(title: title, dir: dir, version: version)
+ else
+ # We don't use #page because if wiki_find_page feature is enabled, we would
+ # get a page without formatted_data.
+ gollum_find_page(title: title, dir: dir, version: version)&.formatted_data
+ end
+ end
+ end
+
private
# options:
@@ -178,7 +201,10 @@ module Gitlab
assert_type!(format, Symbol)
assert_type!(commit_details, CommitDetails)
- gollum_wiki.write_page(name, format, content, commit_details.to_h)
+ filename = File.basename(name)
+ dir = (tmp_dir = File.dirname(name)) == '.' ? '' : tmp_dir
+
+ gollum_wiki.write_page(filename, format, content, commit_details.to_h, dir)
nil
rescue Gollum::DuplicatePageError => e
@@ -196,7 +222,15 @@ module Gitlab
assert_type!(format, Symbol)
assert_type!(commit_details, CommitDetails)
- gollum_wiki.update_page(gollum_page_by_path(page_path), title, format, content, commit_details.to_h)
+ page = gollum_page_by_path(page_path)
+ committer = Gollum::Committer.new(page.wiki, commit_details.to_h)
+
+ # Instead of performing two renames if the title has changed,
+ # the update_page will only update the format and content and
+ # the rename_page will do anything related to moving/renaming
+ gollum_wiki.update_page(page, page.name, format, content, committer: committer)
+ gollum_wiki.rename_page(page, title, committer: committer)
+ committer.commit
nil
end
diff --git a/lib/gitlab/git/wiki_page.rb b/lib/gitlab/git/wiki_page.rb
index a06bac4414f..669ae11a423 100644
--- a/lib/gitlab/git/wiki_page.rb
+++ b/lib/gitlab/git/wiki_page.rb
@@ -1,7 +1,7 @@
module Gitlab
module Git
class WikiPage
- attr_reader :url_path, :title, :format, :path, :version, :raw_data, :name, :text_data, :historical
+ attr_reader :url_path, :title, :format, :path, :version, :raw_data, :name, :text_data, :historical, :formatted_data
# This class is meant to be serializable so that it can be constructed
# by Gitaly and sent over the network to GitLab.
@@ -21,6 +21,7 @@ module Gitlab
@raw_data = gollum_page.raw_data
@name = gollum_page.name
@historical = gollum_page.historical?
+ @formatted_data = gollum_page.formatted_data if gollum_page.is_a?(Gollum::Page)
@version = version
end
diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb
index 56f6febe86d..0d1ee73ca1a 100644
--- a/lib/gitlab/git_access.rb
+++ b/lib/gitlab/git_access.rb
@@ -2,15 +2,19 @@
# class return an instance of `GitlabAccessStatus`
module Gitlab
class GitAccess
+ include Gitlab::Utils::StrongMemoize
+
UnauthorizedError = Class.new(StandardError)
NotFoundError = Class.new(StandardError)
+ ProjectCreationError = Class.new(StandardError)
ProjectMovedError = Class.new(NotFoundError)
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.',
+ auth_upload: 'You are not allowed to upload code.',
+ auth_download: 'You are not allowed to download code.',
+ 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.',
project_not_found: 'The project you were looking for could not be found.',
account_blocked: 'Your account has been blocked.',
@@ -25,24 +29,32 @@ module Gitlab
PUSH_COMMANDS = %w{ git-receive-pack }.freeze
ALL_COMMANDS = DOWNLOAD_COMMANDS + PUSH_COMMANDS
- attr_reader :actor, :project, :protocol, :authentication_abilities, :redirected_path
+ attr_reader :actor, :project, :protocol, :authentication_abilities, :namespace_path, :project_path, :redirected_path, :auth_result_type
- def initialize(actor, project, protocol, authentication_abilities:, redirected_path: nil)
+ def initialize(actor, project, protocol, authentication_abilities:, namespace_path: nil, project_path: nil, redirected_path: nil, auth_result_type: nil)
@actor = actor
@project = project
@protocol = protocol
- @redirected_path = redirected_path
@authentication_abilities = authentication_abilities
+ @namespace_path = namespace_path
+ @project_path = project_path
+ @redirected_path = redirected_path
+ @auth_result_type = auth_result_type
end
def check(cmd, changes)
check_protocol!
check_valid_actor!
check_active_user!
- check_project_accessibility!
- check_project_moved!
+ check_authentication_abilities!(cmd)
check_command_disabled!(cmd)
check_command_existence!(cmd)
+ check_db_accessibility!(cmd)
+
+ ensure_project_on_push!(cmd, changes)
+
+ check_project_accessibility!
+ add_project_moved_message!
check_repository_existence!
case cmd
@@ -67,6 +79,12 @@ module Gitlab
authentication_abilities.include?(:build_download_code) && user_access.can_do_action?(:build_download_code)
end
+ def request_from_ci_build?
+ return false unless protocol == 'http'
+
+ auth_result_type == :build || auth_result_type == :ci
+ end
+
def protocol_allowed?
Gitlab::ProtocolAccess.allowed?(protocol)
end
@@ -82,35 +100,44 @@ module Gitlab
end
def check_protocol!
+ return if request_from_ci_build?
+
unless protocol_allowed?
raise UnauthorizedError, "Git access over #{protocol.upcase} is not allowed"
end
end
def check_active_user!
- return if deploy_key?
-
if user && !user_access.allowed?
raise UnauthorizedError, ERROR_MESSAGES[:account_blocked]
end
end
+ def check_authentication_abilities!(cmd)
+ case cmd
+ when *DOWNLOAD_COMMANDS
+ unless authentication_abilities.include?(:download_code) || authentication_abilities.include?(:build_download_code)
+ raise UnauthorizedError, ERROR_MESSAGES[:auth_download]
+ end
+ when *PUSH_COMMANDS
+ unless authentication_abilities.include?(:push_code)
+ raise UnauthorizedError, ERROR_MESSAGES[:auth_upload]
+ end
+ end
+ end
+
def check_project_accessibility!
if project.blank? || !can_read_project?
raise NotFoundError, ERROR_MESSAGES[:project_not_found]
end
end
- def check_project_moved!
+ def add_project_moved_message!
return if redirected_path.nil?
- project_moved = Checks::ProjectMoved.new(project, user, redirected_path, protocol)
+ project_moved = Checks::ProjectMoved.new(project, user, protocol, redirected_path)
- if project_moved.permanent_redirect?
- project_moved.add_redirect_message
- else
- raise ProjectMovedError, project_moved.redirect_message(rejected: true)
- end
+ project_moved.add_message
end
def check_command_disabled!(cmd)
@@ -139,16 +166,50 @@ module Gitlab
end
end
+ def check_db_accessibility!(cmd)
+ return unless receive_pack?(cmd)
+
+ if Gitlab::Database.read_only?
+ raise UnauthorizedError, push_to_read_only_message
+ end
+ end
+
+ def ensure_project_on_push!(cmd, changes)
+ return if project || deploy_key?
+ return unless receive_pack?(cmd) && changes == '_any' && authentication_abilities.include?(:push_code)
+
+ namespace = Namespace.find_by_full_path(namespace_path)
+
+ return unless user&.can?(:create_projects, namespace)
+
+ project_params = {
+ path: project_path,
+ namespace_id: namespace.id,
+ visibility_level: Gitlab::VisibilityLevel::PRIVATE
+ }
+
+ project = Projects::CreateService.new(user, project_params).execute
+
+ unless project.saved?
+ raise ProjectCreationError, "Could not create project: #{project.errors.full_messages.join(', ')}"
+ end
+
+ @project = project
+ user_access.project = @project
+
+ Checks::ProjectCreated.new(project, user, protocol).add_message
+ end
+
def check_repository_existence!
- unless project.repository.exists?
- raise UnauthorizedError, ERROR_MESSAGES[:no_repo]
+ unless repository.exists?
+ raise NotFoundError, ERROR_MESSAGES[:no_repo]
end
end
def check_download_access!
- return if deploy_key?
-
- passed = user_can_download_code? ||
+ passed = deploy_key? ||
+ deploy_token? ||
+ user_can_download_code? ||
build_can_download_code? ||
guest_can_download_code?
@@ -162,52 +223,46 @@ module Gitlab
raise UnauthorizedError, ERROR_MESSAGES[:read_only]
end
- if Gitlab::Database.read_only?
- raise UnauthorizedError, push_to_read_only_message
- end
-
- if deploy_key
- check_deploy_key_push_access!
+ if deploy_key?
+ unless deploy_key.can_push_to?(project)
+ raise UnauthorizedError, ERROR_MESSAGES[:deploy_key_upload]
+ end
elsif user
- check_user_push_access!
+ # User access is verified in check_change_access!
else
raise UnauthorizedError, ERROR_MESSAGES[:upload]
end
- return if changes.blank? # Allow access.
+ return if changes.blank? # Allow access this is needed for EE.
check_change_access!(changes)
end
- def check_user_push_access!
- unless authentication_abilities.include?(:push_code)
- raise UnauthorizedError, ERROR_MESSAGES[:upload]
- end
- end
-
- def check_deploy_key_push_access!
- unless deploy_key.can_push_to?(project)
- raise UnauthorizedError, ERROR_MESSAGES[:deploy_key_upload]
- end
- end
-
def check_change_access!(changes)
+ # If there are worktrees with a HEAD pointing to a non-existent object,
+ # calls to `git rev-list --all` will fail in git 2.15+. This should also
+ # clear stale lock files.
+ project.repository.clean_stale_repository_files
+
changes_list = Gitlab::ChangesList.new(changes)
# Iterate over all changes to find if user allowed all of them to be applied
- changes_list.each do |change|
+ changes_list.each.with_index do |change, index|
+ first_change = index == 0
+
# 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)
+ check_single_change_access(change, skip_lfs_integrity_check: !first_change)
end
end
- def check_single_change_access(change)
+ def check_single_change_access(change, skip_lfs_integrity_check: false)
Checks::ChangeAccess.new(
change,
user_access: user_access,
project: project,
skip_authorization: deploy_key?,
+ skip_lfs_integrity_check: skip_lfs_integrity_check,
protocol: protocol
).exec
end
@@ -220,6 +275,14 @@ module Gitlab
actor.is_a?(DeployKey)
end
+ def deploy_token
+ actor if deploy_token?
+ end
+
+ def deploy_token?
+ actor.is_a?(DeployToken)
+ end
+
def ci?
actor == :ci
end
@@ -227,6 +290,8 @@ module Gitlab
def can_read_project?
if deploy_key?
deploy_key.has_access_to?(project)
+ elsif deploy_token?
+ deploy_token.has_access_to?(project)
elsif user
user.can?(:read_project, project)
elsif ci?
@@ -263,8 +328,10 @@ module Gitlab
case actor
when User
actor
+ when DeployKey
+ nil
when Key
- actor.user unless actor.is_a?(DeployKey)
+ actor.user
when :ci
nil
end
@@ -281,5 +348,9 @@ module Gitlab
def push_to_read_only_message
ERROR_MESSAGES[:cannot_push_to_read_only]
end
+
+ def repository
+ project.repository
+ end
end
end
diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb
index 1c9477e84b2..a5b3902ebf4 100644
--- a/lib/gitlab/git_access_wiki.rb
+++ b/lib/gitlab/git_access_wiki.rb
@@ -13,7 +13,7 @@ module Gitlab
authentication_abilities.include?(:download_code) && user_access.can_do_action?(:download_wiki_code)
end
- def check_single_change_access(change)
+ def check_single_change_access(change, _options = {})
unless user_access.can_do_action?(:create_wiki)
raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki]
end
@@ -28,5 +28,11 @@ module Gitlab
def push_to_read_only_message
ERROR_MESSAGES[:read_only]
end
+
+ private
+
+ def repository
+ project.wiki.repository
+ end
end
end
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index b753ac46291..0abae70c443 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -1,9 +1,12 @@
require 'base64'
require 'gitaly'
+require 'grpc/health/v1/health_pb'
+require 'grpc/health/v1/health_services_pb'
module Gitlab
module GitalyClient
+ include Gitlab::Metrics::Methods
module MigrationStatus
DISABLED = 1
OPT_IN = 2
@@ -31,8 +34,6 @@ module Gitlab
CLIENT_NAME = (Sidekiq.server? ? 'gitlab-sidekiq' : 'gitlab-web').freeze
MUTEX = Mutex.new
- METRICS_MUTEX = Mutex.new
- private_constant :MUTEX, :METRICS_MUTEX
class << self
attr_accessor :query_time
@@ -40,28 +41,14 @@ module Gitlab
self.query_time = 0
- def self.migrate_histogram
- @migrate_histogram ||=
- METRICS_MUTEX.synchronize do
- # If a thread was blocked on the mutex, the value was set already
- return @migrate_histogram if @migrate_histogram
-
- Gitlab::Metrics.histogram(:gitaly_migrate_call_duration_seconds,
- "Gitaly migration call execution timings",
- gitaly_enabled: nil, feature: nil)
- end
+ define_histogram :gitaly_migrate_call_duration_seconds do
+ docstring "Gitaly migration call execution timings"
+ base_labels gitaly_enabled: nil, feature: nil
end
- def self.gitaly_call_histogram
- @gitaly_call_histogram ||=
- METRICS_MUTEX.synchronize do
- # If a thread was blocked on the mutex, the value was set already
- return @gitaly_call_histogram if @gitaly_call_histogram
-
- Gitlab::Metrics.histogram(:gitaly_controller_action_duration_seconds,
- "Gitaly endpoint histogram by controller and action combination",
- Gitlab::Metrics::Transaction::BASE_LABELS.merge(gitaly_service: nil, rpc: nil))
- end
+ define_histogram :gitaly_controller_action_duration_seconds do
+ docstring "Gitaly endpoint histogram by controller and action combination"
+ base_labels Gitlab::Metrics::Transaction::BASE_LABELS.merge(gitaly_service: nil, rpc: nil)
end
def self.stub(name, storage)
@@ -69,20 +56,37 @@ module Gitlab
@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 = stub_class(name)
+ addr = stub_address(storage)
klass.new(addr, :this_channel_is_insecure)
end
end
end
+ def self.stub_class(name)
+ if name == :health_check
+ Grpc::Health::V1::Health::Stub
+ else
+ Gitaly.const_get(name.to_s.camelcase.to_sym).const_get(:Stub)
+ end
+ end
+
+ def self.stub_address(storage)
+ addr = address(storage)
+ addr = addr.sub(%r{^tcp://}, '') if URI(addr).scheme == 'tcp'
+ addr
+ end
+
def self.clear_stubs!
MUTEX.synchronize do
@stubs = nil
end
end
+ def self.random_storage
+ Gitlab.config.repositories.storages.keys.sample
+ end
+
def self.address(storage)
params = Gitlab.config.repositories.storages[storage]
raise "storage not found: #{storage.inspect}" if params.nil?
@@ -119,22 +123,52 @@ module Gitlab
#
def self.call(storage, service, rpc, request, remote_storage: nil, timeout: nil)
start = Gitlab::Metrics::System.monotonic_time
+ request_hash = request.is_a?(Google::Protobuf::MessageExts) ? request.to_h : {}
+ @current_call_id ||= SecureRandom.uuid
+
enforce_gitaly_request_limits(:call)
kwargs = request_kwargs(storage, timeout, remote_storage: remote_storage)
kwargs = yield(kwargs) if block_given?
stub(service, storage).__send__(rpc, request, kwargs) # rubocop:disable GitlabSecurity/PublicSend
+ rescue GRPC::Unavailable => ex
+ handle_grpc_unavailable!(ex)
ensure
duration = Gitlab::Metrics::System.monotonic_time - start
# Keep track, seperately, for the performance bar
self.query_time += duration
- gitaly_call_histogram.observe(
+ gitaly_controller_action_duration_seconds.observe(
current_transaction_labels.merge(gitaly_service: service.to_s, rpc: rpc.to_s),
duration)
+
+ add_call_details(id: @current_call_id, feature: service, duration: duration, request: request_hash)
+
+ @current_call_id = nil
end
+ def self.handle_grpc_unavailable!(ex)
+ status = ex.to_status
+ raise ex unless status.details == 'Endpoint read failed'
+
+ # There is a bug in grpc 1.8.x that causes a client process to get stuck
+ # always raising '14:Endpoint read failed'. The only thing that we can
+ # do to recover is to restart the process.
+ #
+ # See https://gitlab.com/gitlab-org/gitaly/issues/1029
+
+ if Sidekiq.server?
+ raise Gitlab::SidekiqMiddleware::Shutdown::WantShutdown.new(ex.to_s)
+ else
+ # SIGQUIT requests a Unicorn worker to shut down gracefully after the current request.
+ Process.kill('QUIT', Process.pid)
+ end
+
+ raise ex
+ end
+ private_class_method :handle_grpc_unavailable!
+
def self.current_transaction_labels
Gitlab::Metrics::Transaction.current&.labels || {}
end
@@ -229,12 +263,16 @@ module Gitlab
feature_stack.unshift(feature)
begin
start = Gitlab::Metrics::System.monotonic_time
+ @current_call_id = SecureRandom.uuid
+ call_details = { id: @current_call_id }
yield is_enabled
ensure
total_time = Gitlab::Metrics::System.monotonic_time - start
- migrate_histogram.observe({ gitaly_enabled: is_enabled, feature: feature }, total_time)
+ gitaly_migrate_call_duration_seconds.observe({ gitaly_enabled: is_enabled, feature: feature }, total_time)
feature_stack.shift
Thread.current[:gitaly_feature_stack] = nil if feature_stack.empty?
+
+ add_call_details(call_details.merge(feature: feature, duration: total_time))
end
end
end
@@ -321,29 +359,29 @@ module Gitlab
end
end
- def self.expected_server_version
- path = Rails.root.join(SERVER_VERSION_FILE)
- path.read.chomp
- end
+ def self.add_call_details(details)
+ id = details.delete(:id)
- def self.timestamp(t)
- Google::Protobuf::Timestamp.new(seconds: t.to_i)
+ return unless id && RequestStore.active? && RequestStore.store[:peek_enabled]
+
+ RequestStore.store['gitaly_call_details'] ||= {}
+ RequestStore.store['gitaly_call_details'][id] ||= {}
+ RequestStore.store['gitaly_call_details'][id].merge!(details)
end
- def self.encode(s)
- return "" if s.nil?
+ def self.list_call_details
+ return {} unless RequestStore.active? && RequestStore.store[:peek_enabled]
- s.dup.force_encoding(Encoding::ASCII_8BIT)
+ RequestStore.store['gitaly_call_details'] || {}
end
- def self.binary_stringio(s)
- io = StringIO.new(s || '')
- io.set_encoding(Encoding::ASCII_8BIT)
- io
+ def self.expected_server_version
+ path = Rails.root.join(SERVER_VERSION_FILE)
+ path.read.chomp
end
- def self.encode_repeated(a)
- Google::Protobuf::RepeatedField.new(:bytes, a.map { |s| self.encode(s) } )
+ def self.timestamp(t)
+ Google::Protobuf::Timestamp.new(seconds: t.to_i)
end
# The default timeout on all Gitaly calls
diff --git a/lib/gitlab/gitaly_client/blob_service.rb b/lib/gitlab/gitaly_client/blob_service.rb
index a250eb75bd4..28554208984 100644
--- a/lib/gitlab/gitaly_client/blob_service.rb
+++ b/lib/gitlab/gitaly_client/blob_service.rb
@@ -1,6 +1,8 @@
module Gitlab
module GitalyClient
class BlobService
+ include Gitlab::EncodingHelper
+
def initialize(repository)
@gitaly_repo = repository.gitaly_repository
end
@@ -32,6 +34,87 @@ module Gitlab
binary: Gitlab::Git::Blob.binary?(data)
)
end
+
+ def batch_lfs_pointers(blob_ids)
+ return [] if blob_ids.empty?
+
+ request = Gitaly::GetLFSPointersRequest.new(
+ repository: @gitaly_repo,
+ blob_ids: blob_ids
+ )
+
+ response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_lfs_pointers, request)
+
+ map_lfs_pointers(response)
+ end
+
+ def get_blobs(revision_paths, limit = -1)
+ return [] if revision_paths.empty?
+
+ revision_paths.map! do |rev, path|
+ Gitaly::GetBlobsRequest::RevisionPath.new(revision: rev, path: encode_binary(path))
+ end
+
+ request = Gitaly::GetBlobsRequest.new(
+ repository: @gitaly_repo,
+ revision_paths: revision_paths,
+ limit: limit
+ )
+
+ response = GitalyClient.call(
+ @gitaly_repo.storage_name,
+ :blob_service,
+ :get_blobs,
+ request,
+ timeout: GitalyClient.default_timeout
+ )
+
+ GitalyClient::BlobsStitcher.new(response)
+ end
+
+ def get_new_lfs_pointers(revision, limit, not_in)
+ request = Gitaly::GetNewLFSPointersRequest.new(
+ repository: @gitaly_repo,
+ revision: encode_binary(revision),
+ limit: limit || 0
+ )
+
+ if not_in.nil? || not_in == :all
+ request.not_in_all = true
+ else
+ request.not_in_refs += not_in
+ end
+
+ response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_new_lfs_pointers, request)
+
+ map_lfs_pointers(response)
+ end
+
+ def get_all_lfs_pointers(revision)
+ request = Gitaly::GetNewLFSPointersRequest.new(
+ repository: @gitaly_repo,
+ revision: encode_binary(revision)
+ )
+
+ response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_all_lfs_pointers, request)
+
+ map_lfs_pointers(response)
+ end
+
+ private
+
+ def map_lfs_pointers(response)
+ response.flat_map do |message|
+ message.lfs_pointers.map do |lfs_pointer|
+ Gitlab::Git::Blob.new(
+ id: lfs_pointer.oid,
+ size: lfs_pointer.size,
+ data: lfs_pointer.data,
+ binary: Gitlab::Git::Blob.binary?(lfs_pointer.data)
+ )
+ end
+ end
+ end
end
end
end
diff --git a/lib/gitlab/gitaly_client/blobs_stitcher.rb b/lib/gitlab/gitaly_client/blobs_stitcher.rb
new file mode 100644
index 00000000000..5ca592ff812
--- /dev/null
+++ b/lib/gitlab/gitaly_client/blobs_stitcher.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ module GitalyClient
+ class BlobsStitcher
+ include Enumerable
+
+ def initialize(rpc_response)
+ @rpc_response = rpc_response
+ end
+
+ def each
+ current_blob_data = nil
+
+ @rpc_response.each do |msg|
+ begin
+ if msg.oid.blank? && msg.data.blank?
+ next
+ elsif msg.oid.present?
+ yield new_blob(current_blob_data) if current_blob_data
+
+ current_blob_data = msg.to_h.slice(:oid, :path, :size, :revision, :mode)
+ current_blob_data[:data] = msg.data.dup
+ else
+ current_blob_data[:data] << msg.data
+ end
+ end
+ end
+
+ yield new_blob(current_blob_data) if current_blob_data
+ end
+
+ private
+
+ def new_blob(blob_data)
+ Gitlab::Git::Blob.new(
+ id: blob_data[:oid],
+ mode: blob_data[:mode].to_s(8),
+ name: File.basename(blob_data[:path]),
+ path: blob_data[:path],
+ size: blob_data[:size],
+ commit_id: blob_data[:revision],
+ data: blob_data[:data],
+ binary: Gitlab::Git::Blob.binary?(blob_data[:data])
+ )
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb
index fb3e27770b4..456a8a1a2d6 100644
--- a/lib/gitlab/gitaly_client/commit_service.rb
+++ b/lib/gitlab/gitaly_client/commit_service.rb
@@ -1,6 +1,8 @@
module Gitlab
module GitalyClient
class CommitService
+ include Gitlab::EncodingHelper
+
# 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
@@ -13,7 +15,7 @@ module Gitlab
def ls_files(revision)
request = Gitaly::ListFilesRequest.new(
repository: @gitaly_repo,
- revision: GitalyClient.encode(revision)
+ revision: encode_binary(revision)
)
response = GitalyClient.call(@repository.storage, :commit_service, :list_files, request, timeout: GitalyClient.medium_timeout)
@@ -36,19 +38,27 @@ module Gitlab
from_id = case from
when NilClass
EMPTY_TREE_ID
- when Rugged::Commit
- from.oid
else
- from
+ if from.respond_to?(:oid)
+ # This is meant to match a Rugged::Commit. This should be impossible in
+ # the future.
+ from.oid
+ else
+ from
+ end
end
to_id = case to
when NilClass
EMPTY_TREE_ID
- when Rugged::Commit
- to.oid
else
- to
+ if to.respond_to?(:oid)
+ # This is meant to match a Rugged::Commit. This should be impossible in
+ # the future.
+ to.oid
+ else
+ to
+ end
end
request_params = diff_between_commits_request_params(from_id, to_id, options)
@@ -73,7 +83,7 @@ module Gitlab
request = Gitaly::TreeEntryRequest.new(
repository: @gitaly_repo,
revision: ref,
- path: GitalyClient.encode(path),
+ path: encode_binary(path),
limit: limit.to_i
)
@@ -95,11 +105,12 @@ module Gitlab
entry unless entry.oid.blank?
end
- def tree_entries(repository, revision, path)
+ def tree_entries(repository, revision, path, recursive)
request = Gitaly::GetTreeEntriesRequest.new(
repository: @gitaly_repo,
- revision: GitalyClient.encode(revision),
- path: path.present? ? GitalyClient.encode(path) : '.'
+ revision: encode_binary(revision),
+ path: path.present? ? encode_binary(path) : '.',
+ recursive: recursive
)
response = GitalyClient.call(@repository.storage, :commit_service, :get_tree_entries, request, timeout: GitalyClient.medium_timeout)
@@ -112,8 +123,8 @@ module Gitlab
type: gitaly_tree_entry.type.downcase,
mode: gitaly_tree_entry.mode.to_s(8),
name: File.basename(gitaly_tree_entry.path),
- path: GitalyClient.encode(gitaly_tree_entry.path),
- flat_path: GitalyClient.encode(gitaly_tree_entry.flat_path),
+ path: encode_binary(gitaly_tree_entry.path),
+ flat_path: encode_binary(gitaly_tree_entry.flat_path),
commit_id: gitaly_tree_entry.commit_oid
)
end
@@ -123,11 +134,13 @@ module Gitlab
def commit_count(ref, options = {})
request = Gitaly::CountCommitsRequest.new(
repository: @gitaly_repo,
- revision: ref
+ revision: encode_binary(ref),
+ all: !!options[:all]
)
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?
+ request.path = encode_binary(options[:path]) if options[:path].present?
+ request.max_count = options[:max_count] if options[:max_count].present?
GitalyClient.call(@repository.storage, :commit_service, :count_commits, request, timeout: GitalyClient.medium_timeout).count
end
@@ -135,8 +148,8 @@ module Gitlab
def last_commit_for_path(revision, path)
request = Gitaly::LastCommitForPathRequest.new(
repository: @gitaly_repo,
- revision: GitalyClient.encode(revision),
- path: GitalyClient.encode(path.to_s)
+ revision: encode_binary(revision),
+ path: encode_binary(path.to_s)
)
gitaly_commit = GitalyClient.call(@repository.storage, :commit_service, :last_commit_for_path, request, timeout: GitalyClient.fast_timeout).commit
@@ -174,7 +187,7 @@ module Gitlab
response = GitalyClient.call(@repository.storage, :commit_service, :list_commits_by_oid, request, timeout: GitalyClient.medium_timeout)
consume_commits_response(response)
- rescue GRPC::Unknown # If no repository is found, happens mainly during testing
+ rescue GRPC::NotFound # If no repository is found, happens mainly during testing
[]
end
@@ -202,8 +215,8 @@ module Gitlab
def raw_blame(revision, path)
request = Gitaly::RawBlameRequest.new(
repository: @gitaly_repo,
- revision: GitalyClient.encode(revision),
- path: GitalyClient.encode(path)
+ revision: encode_binary(revision),
+ path: encode_binary(path)
)
response = GitalyClient.call(@repository.storage, :commit_service, :raw_blame, request, timeout: GitalyClient.medium_timeout)
@@ -211,20 +224,31 @@ module Gitlab
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, timeout: GitalyClient.medium_timeout)
-
- response.commit
+ if RequestStore.active?
+ # We don't use RequeStstore.fetch(key) { ... } directly because `revision`
+ # can be a branch name, so we can't use it as a key as it could point
+ # to another commit later on (happens a lot in tests).
+ key = {
+ storage: @gitaly_repo.storage_name,
+ relative_path: @gitaly_repo.relative_path,
+ commit_id: revision
+ }
+ return RequestStore[key] if RequestStore.exist?(key)
+
+ commit = call_find_commit(revision)
+ return unless commit
+
+ key[:commit_id] = commit.id
+ RequestStore[key] = commit
+ else
+ call_find_commit(revision)
+ end
end
def patch(revision)
request = Gitaly::CommitPatchRequest.new(
repository: @gitaly_repo,
- revision: GitalyClient.encode(revision)
+ revision: encode_binary(revision)
)
response = GitalyClient.call(@repository.storage, :diff_service, :commit_patch, request, timeout: GitalyClient.medium_timeout)
@@ -234,7 +258,7 @@ module Gitlab
def commit_stats(revision)
request = Gitaly::CommitStatsRequest.new(
repository: @gitaly_repo,
- revision: GitalyClient.encode(revision)
+ revision: encode_binary(revision)
)
GitalyClient.call(@repository.storage, :commit_service, :commit_stats, request, timeout: GitalyClient.medium_timeout)
end
@@ -246,13 +270,14 @@ module Gitlab
offset: options[:offset],
follow: options[:follow],
skip_merges: options[:skip_merges],
- disable_walk: options[:disable_walk]
+ all: !!options[:all],
+ disable_walk: true # This option is deprecated. The 'walk' implementation is being removed.
)
request.after = GitalyClient.timestamp(options[:after]) if options[:after]
request.before = GitalyClient.timestamp(options[:before]) if options[:before]
- request.revision = GitalyClient.encode(options[:ref]) if options[:ref]
+ request.revision = encode_binary(options[:ref]) if options[:ref]
- request.paths = GitalyClient.encode_repeated(Array(options[:path])) if options[:path].present?
+ request.paths = encode_repeated(Array(options[:path])) if options[:path].present?
response = GitalyClient.call(@repository.storage, :commit_service, :find_commits, request, timeout: GitalyClient.medium_timeout)
@@ -264,7 +289,7 @@ module Gitlab
enum = Enumerator.new do |y|
shas.each_slice(20) do |revs|
- request.shas = GitalyClient.encode_repeated(revs)
+ request.shas = encode_repeated(revs)
y.yield request
@@ -279,6 +304,40 @@ module Gitlab
end
end
+ def extract_signature(commit_id)
+ request = Gitaly::ExtractCommitSignatureRequest.new(repository: @gitaly_repo, commit_id: commit_id)
+ response = GitalyClient.call(@repository.storage, :commit_service, :extract_commit_signature, request)
+
+ signature = ''.b
+ signed_text = ''.b
+
+ response.each do |message|
+ signature << message.signature
+ signed_text << message.signed_text
+ end
+
+ return if signature.blank? && signed_text.blank?
+
+ [signature, signed_text]
+ end
+
+ def get_commit_signatures(commit_ids)
+ request = Gitaly::GetCommitSignaturesRequest.new(repository: @gitaly_repo, commit_ids: commit_ids)
+ response = GitalyClient.call(@repository.storage, :commit_service, :get_commit_signatures, request)
+
+ signatures = Hash.new { |h, k| h[k] = [''.b, ''.b] }
+ current_commit_id = nil
+
+ response.each do |message|
+ current_commit_id = message.commit_id if message.commit_id.present?
+
+ signatures[current_commit_id].first << message.signature
+ signatures[current_commit_id].last << message.signed_text
+ end
+
+ signatures
+ end
+
private
def call_commit_diff(request_params, options = {})
@@ -303,7 +362,7 @@ module Gitlab
repository: @gitaly_repo,
left_commit_id: from_id,
right_commit_id: to_id,
- paths: options.fetch(:paths, []).compact.map { |path| GitalyClient.encode(path) }
+ paths: options.fetch(:paths, []).compact.map { |path| encode_binary(path) }
}
end
@@ -314,6 +373,21 @@ module Gitlab
end
end
end
+
+ def encode_repeated(a)
+ Google::Protobuf::RepeatedField.new(:bytes, a.map { |s| encode_binary(s) } )
+ end
+
+ def call_find_commit(revision)
+ request = Gitaly::FindCommitRequest.new(
+ repository: @gitaly_repo,
+ revision: encode_binary(revision)
+ )
+
+ response = GitalyClient.call(@repository.storage, :commit_service, :find_commit, request, timeout: GitalyClient.medium_timeout)
+
+ response.commit
+ end
end
end
end
diff --git a/lib/gitlab/gitaly_client/conflict_files_stitcher.rb b/lib/gitlab/gitaly_client/conflict_files_stitcher.rb
new file mode 100644
index 00000000000..c275a065bce
--- /dev/null
+++ b/lib/gitlab/gitaly_client/conflict_files_stitcher.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ module GitalyClient
+ class ConflictFilesStitcher
+ include Enumerable
+
+ def initialize(rpc_response)
+ @rpc_response = rpc_response
+ end
+
+ def each
+ current_file = nil
+
+ @rpc_response.each do |msg|
+ msg.files.each do |gitaly_file|
+ if gitaly_file.header
+ yield current_file if current_file
+
+ current_file = file_from_gitaly_header(gitaly_file.header)
+ else
+ current_file.raw_content << gitaly_file.content
+ end
+ end
+ end
+
+ yield current_file if current_file
+ end
+
+ private
+
+ def file_from_gitaly_header(header)
+ Gitlab::Git::Conflict::File.new(
+ Gitlab::GitalyClient::Util.git_repository(header.repository),
+ header.commit_oid,
+ conflict_from_gitaly_file_header(header),
+ ''
+ )
+ end
+
+ def conflict_from_gitaly_file_header(header)
+ {
+ ours: { path: header.our_path, mode: header.our_mode },
+ theirs: { path: header.their_path }
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/conflicts_service.rb b/lib/gitlab/gitaly_client/conflicts_service.rb
new file mode 100644
index 00000000000..e14734495a8
--- /dev/null
+++ b/lib/gitlab/gitaly_client/conflicts_service.rb
@@ -0,0 +1,72 @@
+module Gitlab
+ module GitalyClient
+ class ConflictsService
+ include Gitlab::EncodingHelper
+
+ MAX_MSG_SIZE = 128.kilobytes.freeze
+
+ def initialize(repository, our_commit_oid, their_commit_oid)
+ @gitaly_repo = repository.gitaly_repository
+ @repository = repository
+ @our_commit_oid = our_commit_oid
+ @their_commit_oid = their_commit_oid
+ end
+
+ def list_conflict_files
+ request = Gitaly::ListConflictFilesRequest.new(
+ repository: @gitaly_repo,
+ our_commit_oid: @our_commit_oid,
+ their_commit_oid: @their_commit_oid
+ )
+ response = GitalyClient.call(@repository.storage, :conflicts_service, :list_conflict_files, request)
+
+ GitalyClient::ConflictFilesStitcher.new(response)
+ end
+
+ def conflicts?
+ list_conflict_files.any?
+ rescue GRPC::FailedPrecondition
+ # The server raises this exception when it encounters ConflictSideMissing, which
+ # means a conflict exists but its `theirs` or `ours` data is nil due to a non-existent
+ # file in one of the trees.
+ true
+ end
+
+ def resolve_conflicts(target_repository, resolution, source_branch, target_branch)
+ reader = binary_stringio(resolution.files.to_json)
+
+ req_enum = Enumerator.new do |y|
+ header = resolve_conflicts_request_header(target_repository, resolution, source_branch, target_branch)
+ y.yield Gitaly::ResolveConflictsRequest.new(header: header)
+
+ until reader.eof?
+ chunk = reader.read(MAX_MSG_SIZE)
+
+ y.yield Gitaly::ResolveConflictsRequest.new(files_json: chunk)
+ end
+ end
+
+ response = GitalyClient.call(@repository.storage, :conflicts_service, :resolve_conflicts, req_enum, remote_storage: target_repository.storage)
+
+ if response.resolution_error.present?
+ raise Gitlab::Git::Conflict::Resolver::ResolutionError, response.resolution_error
+ end
+ end
+
+ private
+
+ def resolve_conflicts_request_header(target_repository, resolution, source_branch, target_branch)
+ Gitaly::ResolveConflictsRequestHeader.new(
+ repository: @gitaly_repo,
+ our_commit_oid: @our_commit_oid,
+ target_repository: target_repository.gitaly_repository,
+ their_commit_oid: @their_commit_oid,
+ source_branch: source_branch,
+ target_branch: target_branch,
+ commit_message: resolution.commit_message,
+ user: Gitlab::Git::User.from_gitlab(resolution.user).to_gitaly
+ )
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/health_check_service.rb b/lib/gitlab/gitaly_client/health_check_service.rb
new file mode 100644
index 00000000000..6c1213f5e20
--- /dev/null
+++ b/lib/gitlab/gitaly_client/health_check_service.rb
@@ -0,0 +1,19 @@
+module Gitlab
+ module GitalyClient
+ class HealthCheckService
+ def initialize(storage)
+ @storage = storage
+ end
+
+ # Sends a gRPC health ping to the Gitaly server for the storage shard.
+ def check
+ request = Grpc::Health::V1::HealthCheckRequest.new
+ response = GitalyClient.call(@storage, :health_check, :check, request, timeout: GitalyClient.fast_timeout)
+
+ { success: response&.status == :SERVING }
+ rescue GRPC::BadStatus => e
+ { success: false, message: e.to_s }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb
index 400a4af363b..831cfd1e014 100644
--- a/lib/gitlab/gitaly_client/operation_service.rb
+++ b/lib/gitlab/gitaly_client/operation_service.rb
@@ -1,6 +1,10 @@
module Gitlab
module GitalyClient
class OperationService
+ include Gitlab::EncodingHelper
+
+ MAX_MSG_SIZE = 128.kilobytes.freeze
+
def initialize(repository)
@gitaly_repo = repository.gitaly_repository
@repository = repository
@@ -9,7 +13,7 @@ module Gitlab
def rm_tag(tag_name, user)
request = Gitaly::UserDeleteTagRequest.new(
repository: @gitaly_repo,
- tag_name: GitalyClient.encode(tag_name),
+ tag_name: encode_binary(tag_name),
user: Gitlab::Git::User.from_gitlab(user).to_gitaly
)
@@ -24,9 +28,9 @@ module Gitlab
request = Gitaly::UserCreateTagRequest.new(
repository: @gitaly_repo,
user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
- tag_name: GitalyClient.encode(tag_name),
- target_revision: GitalyClient.encode(target),
- message: GitalyClient.encode(message.to_s)
+ tag_name: encode_binary(tag_name),
+ target_revision: encode_binary(target),
+ message: encode_binary(message.to_s)
)
response = GitalyClient.call(@repository.storage, :operation_service, :user_create_tag, request)
@@ -44,12 +48,13 @@ module Gitlab
def user_create_branch(branch_name, user, start_point)
request = Gitaly::UserCreateBranchRequest.new(
repository: @gitaly_repo,
- branch_name: GitalyClient.encode(branch_name),
+ branch_name: encode_binary(branch_name),
user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
- start_point: GitalyClient.encode(start_point)
+ start_point: encode_binary(start_point)
)
response = GitalyClient.call(@repository.storage, :operation_service,
:user_create_branch, request)
+
if response.pre_receive_error.present?
raise Gitlab::Git::HooksService::PreReceiveError.new(response.pre_receive_error)
end
@@ -64,7 +69,7 @@ module Gitlab
def user_delete_branch(branch_name, user)
request = Gitaly::UserDeleteBranchRequest.new(
repository: @gitaly_repo,
- branch_name: GitalyClient.encode(branch_name),
+ branch_name: encode_binary(branch_name),
user: Gitlab::Git::User.from_gitlab(user).to_gitaly
)
@@ -89,8 +94,8 @@ module Gitlab
repository: @gitaly_repo,
user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
commit_id: source_sha,
- branch: GitalyClient.encode(target_branch),
- message: GitalyClient.encode(message)
+ branch: encode_binary(target_branch),
+ message: encode_binary(message)
)
)
@@ -98,7 +103,14 @@ module Gitlab
request_enum.push(Gitaly::UserMergeBranchRequest.new(apply: true))
- branch_update = response_enum.next.branch_update
+ second_response = response_enum.next
+
+ if second_response.pre_receive_error.present?
+ raise Gitlab::Git::HooksService::PreReceiveError, second_response.pre_receive_error
+ end
+
+ branch_update = second_response.branch_update
+ return if branch_update.nil?
raise Gitlab::Git::CommitError.new('failed to apply merge to branch') unless branch_update.commit_id.present?
Gitlab::Git::OperationService::BranchUpdate.from_gitaly(branch_update)
@@ -111,7 +123,7 @@ module Gitlab
repository: @gitaly_repo,
user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
commit_id: source_sha,
- branch: GitalyClient.encode(target_branch)
+ branch: encode_binary(target_branch)
)
branch_update = GitalyClient.call(
@@ -143,6 +155,103 @@ module Gitlab
start_repository: start_repository)
end
+ def user_rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:)
+ request = Gitaly::UserRebaseRequest.new(
+ repository: @gitaly_repo,
+ user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
+ rebase_id: rebase_id.to_s,
+ branch: encode_binary(branch),
+ branch_sha: branch_sha,
+ remote_repository: remote_repository.gitaly_repository,
+ remote_branch: encode_binary(remote_branch)
+ )
+
+ response = GitalyClient.call(
+ @repository.storage,
+ :operation_service,
+ :user_rebase,
+ request,
+ remote_storage: remote_repository.storage
+ )
+
+ if response.pre_receive_error.presence
+ raise Gitlab::Git::HooksService::PreReceiveError, response.pre_receive_error
+ elsif response.git_error.presence
+ raise Gitlab::Git::Repository::GitError, response.git_error
+ else
+ response.rebase_sha
+ end
+ end
+
+ def user_squash(user, squash_id, branch, start_sha, end_sha, author, message)
+ request = Gitaly::UserSquashRequest.new(
+ repository: @gitaly_repo,
+ user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
+ squash_id: squash_id.to_s,
+ branch: encode_binary(branch),
+ start_sha: start_sha,
+ end_sha: end_sha,
+ author: Gitlab::Git::User.from_gitlab(author).to_gitaly,
+ commit_message: encode_binary(message)
+ )
+
+ response = GitalyClient.call(
+ @repository.storage,
+ :operation_service,
+ :user_squash,
+ request
+ )
+
+ if response.git_error.presence
+ raise Gitlab::Git::Repository::GitError, response.git_error
+ end
+
+ response.squash_sha
+ end
+
+ def user_commit_files(
+ user, branch_name, commit_message, actions, author_email, author_name,
+ start_branch_name, start_repository)
+
+ req_enum = Enumerator.new do |y|
+ header = user_commit_files_request_header(user, branch_name,
+ commit_message, actions, author_email, author_name,
+ start_branch_name, start_repository)
+
+ y.yield Gitaly::UserCommitFilesRequest.new(header: header)
+
+ actions.each do |action|
+ action_header = user_commit_files_action_header(action)
+ y.yield Gitaly::UserCommitFilesRequest.new(
+ action: Gitaly::UserCommitFilesAction.new(header: action_header)
+ )
+
+ reader = binary_stringio(action[:content])
+
+ until reader.eof?
+ chunk = reader.read(MAX_MSG_SIZE)
+
+ y.yield Gitaly::UserCommitFilesRequest.new(
+ action: Gitaly::UserCommitFilesAction.new(content: chunk)
+ )
+ end
+ end
+ end
+
+ response = GitalyClient.call(@repository.storage, :operation_service,
+ :user_commit_files, req_enum, remote_storage: start_repository.storage)
+
+ if (pre_receive_error = response.pre_receive_error.presence)
+ raise Gitlab::Git::HooksService::PreReceiveError, pre_receive_error
+ end
+
+ if (index_error = response.index_error.presence)
+ raise Gitlab::Git::Index::IndexError, index_error
+ end
+
+ Gitlab::Git::OperationService::BranchUpdate.from_gitaly(response.branch_update)
+ end
+
private
def call_cherry_pick_or_revert(rpc, user:, commit:, branch_name:, message:, start_branch_name:, start_repository:)
@@ -152,9 +261,9 @@ module Gitlab
repository: @gitaly_repo,
user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
commit: commit.to_gitaly_commit,
- branch_name: GitalyClient.encode(branch_name),
- message: GitalyClient.encode(message),
- start_branch_name: GitalyClient.encode(start_branch_name.to_s),
+ branch_name: encode_binary(branch_name),
+ message: encode_binary(message),
+ start_branch_name: encode_binary(start_branch_name.to_s),
start_repository: start_repository.gitaly_repository
)
@@ -180,6 +289,33 @@ module Gitlab
Gitlab::Git::OperationService::BranchUpdate.from_gitaly(response.branch_update)
end
end
+
+ def user_commit_files_request_header(
+ user, branch_name, commit_message, actions, author_email, author_name,
+ start_branch_name, start_repository)
+
+ Gitaly::UserCommitFilesRequestHeader.new(
+ repository: @gitaly_repo,
+ user: Gitlab::Git::User.from_gitlab(user).to_gitaly,
+ branch_name: encode_binary(branch_name),
+ commit_message: encode_binary(commit_message),
+ commit_author_name: encode_binary(author_name),
+ commit_author_email: encode_binary(author_email),
+ start_branch_name: encode_binary(start_branch_name),
+ start_repository: start_repository.gitaly_repository
+ )
+ end
+
+ def user_commit_files_action_header(action)
+ Gitaly::UserCommitFilesActionHeader.new(
+ action: action[:action].upcase.to_sym,
+ file_path: encode_binary(action[:file_path]),
+ previous_path: encode_binary(action[:previous_path]),
+ base64_content: action[:encoding] == 'base64'
+ )
+ rescue RangeError
+ raise ArgumentError, "Unknown action '#{action[:action]}'"
+ end
end
end
end
diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb
index 066e4e183c0..ba6b577fd17 100644
--- a/lib/gitlab/gitaly_client/ref_service.rb
+++ b/lib/gitlab/gitaly_client/ref_service.rb
@@ -14,12 +14,18 @@ module Gitlab
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
+ consume_find_all_branches_response(response)
+ end
+
+ def merged_branches(branch_names = [])
+ request = Gitaly::FindAllBranchesRequest.new(
+ repository: @gitaly_repo,
+ merged_only: true,
+ merged_branches: branch_names.map { |s| encode_binary(s) }
+ )
+ response = GitalyClient.call(@storage, :ref_service, :find_all_branches, request)
+
+ consume_find_all_branches_response(response)
end
def default_branch_name
@@ -62,7 +68,7 @@ module Gitlab
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)
+ consume_find_local_branches_response(response)
end
def tags
@@ -72,7 +78,7 @@ module Gitlab
end
def ref_exists?(ref_name)
- request = Gitaly::RefExistsRequest.new(repository: @gitaly_repo, ref: GitalyClient.encode(ref_name))
+ request = Gitaly::RefExistsRequest.new(repository: @gitaly_repo, ref: encode_binary(ref_name))
response = GitalyClient.call(@storage, :ref_service, :ref_exists, request)
response.value
rescue GRPC::InvalidArgument => e
@@ -82,7 +88,7 @@ module Gitlab
def find_branch(branch_name)
request = Gitaly::FindBranchRequest.new(
repository: @gitaly_repo,
- name: GitalyClient.encode(branch_name)
+ name: encode_binary(branch_name)
)
response = GitalyClient.call(@repository.storage, :ref_service, :find_branch, request)
@@ -96,8 +102,8 @@ module Gitlab
def create_branch(ref, start_point)
request = Gitaly::CreateBranchRequest.new(
repository: @gitaly_repo,
- name: GitalyClient.encode(ref),
- start_point: GitalyClient.encode(start_point)
+ name: encode_binary(ref),
+ start_point: encode_binary(start_point)
)
response = GitalyClient.call(@repository.storage, :ref_service, :create_branch, request)
@@ -121,19 +127,48 @@ module Gitlab
def delete_branch(branch_name)
request = Gitaly::DeleteBranchRequest.new(
repository: @gitaly_repo,
- name: GitalyClient.encode(branch_name)
+ name: encode_binary(branch_name)
)
GitalyClient.call(@repository.storage, :ref_service, :delete_branch, request)
end
- def delete_refs(except_with_prefixes:)
+ def delete_refs(refs: [], except_with_prefixes: [])
request = Gitaly::DeleteRefsRequest.new(
repository: @gitaly_repo,
- except_with_prefix: except_with_prefixes
+ refs: refs.map { |r| encode_binary(r) },
+ except_with_prefix: except_with_prefixes.map { |r| encode_binary(r) }
+ )
+
+ response = GitalyClient.call(@repository.storage, :ref_service, :delete_refs, request)
+
+ raise Gitlab::Git::Repository::GitError, response.git_error if response.git_error.present?
+ end
+
+ # Limit: 0 implies no limit, thus all tag names will be returned
+ def tag_names_contains_sha(sha, limit: 0)
+ request = Gitaly::ListTagNamesContainingCommitRequest.new(
+ repository: @gitaly_repo,
+ commit_id: sha,
+ limit: limit
)
- GitalyClient.call(@repository.storage, :ref_service, :delete_refs, request)
+ stream = GitalyClient.call(@repository.storage, :ref_service, :list_tag_names_containing_commit, request)
+
+ consume_ref_contains_sha_response(stream, :tag_names)
+ end
+
+ # Limit: 0 implies no limit, thus all tag names will be returned
+ def branch_names_contains_sha(sha, limit: 0)
+ request = Gitaly::ListBranchNamesContainingCommitRequest.new(
+ repository: @gitaly_repo,
+ commit_id: sha,
+ limit: limit
+ )
+
+ stream = GitalyClient.call(@repository.storage, :ref_service, :list_branch_names_containing_commit, request)
+
+ consume_ref_contains_sha_response(stream, :branch_names)
end
private
@@ -151,7 +186,7 @@ module Gitlab
enum_value
end
- def consume_branches_response(response)
+ def consume_find_local_branches_response(response)
response.flat_map do |message|
message.branches.map do |gitaly_branch|
Gitlab::Git::Branch.new(
@@ -164,6 +199,15 @@ module Gitlab
end
end
+ def consume_find_all_branches_response(response)
+ 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 consume_tags_response(response)
response.flat_map do |message|
message.tags.map { |gitaly_tag| Util.gitlab_tag_from_gitaly_tag(@repository, gitaly_tag) }
@@ -197,6 +241,13 @@ module Gitlab
Gitlab::Git::Commit.decorate(@repository, hash)
end
+ def consume_ref_contains_sha_response(stream, collection_name)
+ stream.each_with_object([]) do |response, array|
+ encoded_names = response.send(collection_name).map { |b| Gitlab::Git.ref_name(b) } # rubocop:disable GitlabSecurity/PublicSend
+ array.concat(encoded_names)
+ end
+ end
+
def invalid_ref!(message)
raise Gitlab::Git::Repository::InvalidRef.new(message)
end
diff --git a/lib/gitlab/gitaly_client/remote_service.rb b/lib/gitlab/gitaly_client/remote_service.rb
new file mode 100644
index 00000000000..f2d699d9dfb
--- /dev/null
+++ b/lib/gitlab/gitaly_client/remote_service.rb
@@ -0,0 +1,81 @@
+module Gitlab
+ module GitalyClient
+ class RemoteService
+ MAX_MSG_SIZE = 128.kilobytes.freeze
+
+ def self.exists?(remote_url)
+ request = Gitaly::FindRemoteRepositoryRequest.new(remote: remote_url)
+
+ response = GitalyClient.call(GitalyClient.random_storage,
+ :remote_service,
+ :find_remote_repository, request,
+ timeout: GitalyClient.medium_timeout)
+
+ response.exists
+ end
+
+ def initialize(repository)
+ @repository = repository
+ @gitaly_repo = repository.gitaly_repository
+ @storage = repository.storage
+ end
+
+ def add_remote(name, url, mirror_refmaps)
+ request = Gitaly::AddRemoteRequest.new(
+ repository: @gitaly_repo,
+ name: name,
+ url: url,
+ mirror_refmaps: Array.wrap(mirror_refmaps).map(&:to_s)
+ )
+
+ GitalyClient.call(@storage, :remote_service, :add_remote, request)
+ end
+
+ def remove_remote(name)
+ request = Gitaly::RemoveRemoteRequest.new(repository: @gitaly_repo, name: name)
+
+ response = GitalyClient.call(@storage, :remote_service, :remove_remote, request)
+
+ response.result
+ end
+
+ def fetch_internal_remote(repository)
+ request = Gitaly::FetchInternalRemoteRequest.new(
+ repository: @gitaly_repo,
+ remote_repository: repository.gitaly_repository
+ )
+
+ response = GitalyClient.call(@storage, :remote_service,
+ :fetch_internal_remote, request,
+ remote_storage: repository.storage)
+
+ response.result
+ end
+
+ def update_remote_mirror(ref_name, only_branches_matching)
+ req_enum = Enumerator.new do |y|
+ y.yield Gitaly::UpdateRemoteMirrorRequest.new(
+ repository: @gitaly_repo,
+ ref_name: ref_name
+ )
+
+ current_size = 0
+
+ slices = only_branches_matching.slice_before do |branch_name|
+ current_size += branch_name.bytesize
+
+ next false if current_size < MAX_MSG_SIZE
+
+ current_size = 0
+ end
+
+ slices.each do |slice|
+ y.yield Gitaly::UpdateRemoteMirrorRequest.new(only_branches_matching: slice)
+ end
+ end
+
+ GitalyClient.call(@storage, :remote_service, :update_remote_mirror, req_enum)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb
index c1f95396878..6441065f5fe 100644
--- a/lib/gitlab/gitaly_client/repository_service.rb
+++ b/lib/gitlab/gitaly_client/repository_service.rb
@@ -1,6 +1,10 @@
module Gitlab
module GitalyClient
class RepositoryService
+ include Gitlab::EncodingHelper
+
+ MAX_MSG_SIZE = 128.kilobytes.freeze
+
def initialize(repository)
@repository = repository
@gitaly_repo = repository.gitaly_repository
@@ -15,6 +19,11 @@ module Gitlab
response.exists
end
+ def cleanup
+ request = Gitaly::CleanupRequest.new(repository: @gitaly_repo)
+ GitalyClient.call(@storage, :repository_service, :cleanup, request)
+ 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)
@@ -37,12 +46,15 @@ module Gitlab
end
def apply_gitattributes(revision)
- request = Gitaly::ApplyGitattributesRequest.new(repository: @gitaly_repo, revision: revision)
+ request = Gitaly::ApplyGitattributesRequest.new(repository: @gitaly_repo, revision: encode_binary(revision))
GitalyClient.call(@storage, :repository_service, :apply_gitattributes, request)
end
- def fetch_remote(remote, ssh_auth: nil, forced: false, no_tags: false)
- request = Gitaly::FetchRemoteRequest.new(repository: @gitaly_repo, remote: remote, force: forced, no_tags: no_tags)
+ def fetch_remote(remote, ssh_auth:, forced:, no_tags:, timeout:, prune: true)
+ request = Gitaly::FetchRemoteRequest.new(
+ repository: @gitaly_repo, remote: remote, force: forced,
+ no_tags: no_tags, timeout: timeout, no_prune: !prune
+ )
if ssh_auth&.ssh_import?
if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
@@ -72,13 +84,78 @@ module Gitlab
def find_merge_base(*revisions)
request = Gitaly::FindMergeBaseRequest.new(
repository: @gitaly_repo,
- revisions: revisions.map { |r| GitalyClient.encode(r) }
+ revisions: revisions.map { |r| encode_binary(r) }
)
response = GitalyClient.call(@storage, :repository_service, :find_merge_base, request)
response.base.presence
end
+ def fork_repository(source_repository)
+ request = Gitaly::CreateForkRequest.new(
+ repository: @gitaly_repo,
+ source_repository: source_repository.gitaly_repository
+ )
+
+ GitalyClient.call(
+ @storage,
+ :repository_service,
+ :create_fork,
+ request,
+ remote_storage: source_repository.storage,
+ timeout: GitalyClient.default_timeout
+ )
+ end
+
+ def import_repository(source)
+ request = Gitaly::CreateRepositoryFromURLRequest.new(
+ repository: @gitaly_repo,
+ url: source
+ )
+
+ GitalyClient.call(
+ @storage,
+ :repository_service,
+ :create_repository_from_url,
+ request,
+ timeout: GitalyClient.default_timeout
+ )
+ end
+
+ def rebase_in_progress?(rebase_id)
+ request = Gitaly::IsRebaseInProgressRequest.new(
+ repository: @gitaly_repo,
+ rebase_id: rebase_id.to_s
+ )
+
+ response = GitalyClient.call(
+ @storage,
+ :repository_service,
+ :is_rebase_in_progress,
+ request,
+ timeout: GitalyClient.default_timeout
+ )
+
+ response.in_progress
+ end
+
+ def squash_in_progress?(squash_id)
+ request = Gitaly::IsSquashInProgressRequest.new(
+ repository: @gitaly_repo,
+ squash_id: squash_id.to_s
+ )
+
+ response = GitalyClient.call(
+ @storage,
+ :repository_service,
+ :is_squash_in_progress,
+ request,
+ timeout: GitalyClient.default_timeout
+ )
+
+ response.in_progress
+ end
+
def fetch_source_branch(source_repository, source_branch, local_ref)
request = Gitaly::FetchSourceBranchRequest.new(
repository: @gitaly_repo,
@@ -108,6 +185,89 @@ module Gitlab
return response.error.b, 1
end
end
+
+ def create_bundle(save_path)
+ request = Gitaly::CreateBundleRequest.new(repository: @gitaly_repo)
+ response = GitalyClient.call(
+ @storage,
+ :repository_service,
+ :create_bundle,
+ request,
+ timeout: GitalyClient.default_timeout
+ )
+
+ File.open(save_path, 'wb') do |f|
+ response.each do |message|
+ f.write(message.data)
+ end
+ end
+ end
+
+ def create_from_bundle(bundle_path)
+ request = Gitaly::CreateRepositoryFromBundleRequest.new(repository: @gitaly_repo)
+ enum = Enumerator.new do |y|
+ File.open(bundle_path, 'rb') do |f|
+ while data = f.read(MAX_MSG_SIZE)
+ request.data = data
+
+ y.yield request
+
+ request = Gitaly::CreateRepositoryFromBundleRequest.new
+ end
+ end
+ end
+
+ GitalyClient.call(
+ @storage,
+ :repository_service,
+ :create_repository_from_bundle,
+ enum,
+ timeout: GitalyClient.default_timeout
+ )
+ end
+
+ def write_ref(ref_path, ref, old_ref, shell)
+ request = Gitaly::WriteRefRequest.new(
+ repository: @gitaly_repo,
+ ref: ref_path.b,
+ revision: ref.b,
+ shell: shell
+ )
+ request.old_revision = old_ref.b unless old_ref.nil?
+
+ response = GitalyClient.call(@storage, :repository_service, :write_ref, request)
+
+ raise Gitlab::Git::CommandError, encode!(response.error) if response.error.present?
+
+ true
+ end
+
+ def write_config(full_path:)
+ request = Gitaly::WriteConfigRequest.new(repository: @gitaly_repo, full_path: full_path)
+ response = GitalyClient.call(
+ @storage,
+ :repository_service,
+ :write_config,
+ request,
+ timeout: GitalyClient.fast_timeout
+ )
+
+ raise Gitlab::Git::OSError.new(response.error) unless response.error.empty?
+ end
+
+ def license_short_name
+ request = Gitaly::FindLicenseRequest.new(repository: @gitaly_repo)
+
+ response = GitalyClient.call(@storage, :repository_service, :find_license, request, timeout: GitalyClient.fast_timeout)
+
+ response.license_short_name.presence
+ end
+
+ def calculate_checksum
+ request = Gitaly::CalculateChecksumRequest.new(repository: @gitaly_repo)
+ response = GitalyClient.call(@storage, :repository_service, :calculate_checksum, request)
+ response.checksum.presence
+ end
end
end
end
diff --git a/lib/gitlab/gitaly_client/server_service.rb b/lib/gitlab/gitaly_client/server_service.rb
new file mode 100644
index 00000000000..2e1076d1f66
--- /dev/null
+++ b/lib/gitlab/gitaly_client/server_service.rb
@@ -0,0 +1,16 @@
+module Gitlab
+ module GitalyClient
+ # Meant for extraction of server data, and later maybe to perform misc task
+ #
+ # Not meant for connection logic, look in Gitlab::GitalyClient
+ class ServerService
+ def initialize(storage)
+ @storage = storage
+ end
+
+ def info
+ GitalyClient.call(@storage, :server_service, :server_info, Gitaly::ServerInfoRequest.new)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/storage_settings.rb b/lib/gitlab/gitaly_client/storage_settings.rb
new file mode 100644
index 00000000000..8668caf0c55
--- /dev/null
+++ b/lib/gitlab/gitaly_client/storage_settings.rb
@@ -0,0 +1,35 @@
+module Gitlab
+ module GitalyClient
+ # This is a chokepoint that is meant to help us stop remove all places
+ # where production code (app, config, db, lib) touches Git repositories
+ # directly.
+ class StorageSettings
+ DirectPathAccessError = Class.new(StandardError)
+
+ # This class will give easily recognizable NoMethodErrors
+ Deprecated = Class.new
+
+ attr_reader :legacy_disk_path
+
+ def initialize(storage)
+ raise "expected a Hash, got a #{storage.class.name}" unless storage.is_a?(Hash)
+
+ # Support a nil 'path' field because some of the circuit breaker tests use it.
+ @legacy_disk_path = File.expand_path(storage['path'], Rails.root) if storage['path']
+
+ storage['path'] = Deprecated
+ @hash = storage
+ end
+
+ def gitaly_address
+ @hash.fetch(:gitaly_address)
+ end
+
+ private
+
+ def method_missing(m, *args, &block)
+ @hash.public_send(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb
index b1a033280b4..405567db94a 100644
--- a/lib/gitlab/gitaly_client/util.rb
+++ b/lib/gitlab/gitaly_client/util.rb
@@ -3,21 +3,25 @@ module Gitlab
module Util
class << self
def repository(repository_storage, relative_path, gl_repository)
- git_object_directory = Gitlab::Git::Env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence ||
- Gitlab::Git::Env['GIT_OBJECT_DIRECTORY'].presence
- git_alternate_object_directories =
- Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE']).presence ||
- Array.wrap(Gitlab::Git::Env['GIT_ALTERNATE_OBJECT_DIRECTORIES']).flat_map { |d| d.split(File::PATH_SEPARATOR) }
+ git_env = Gitlab::Git::HookEnv.all(gl_repository)
+ git_object_directory = git_env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence
+ git_alternate_object_directories = Array.wrap(git_env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE'])
Gitaly::Repository.new(
storage_name: repository_storage,
relative_path: relative_path,
- gl_repository: gl_repository,
+ gl_repository: gl_repository.to_s,
git_object_directory: git_object_directory.to_s,
git_alternate_object_directories: git_alternate_object_directories
)
end
+ def git_repository(gitaly_repository)
+ Gitlab::Git::Repository.new(gitaly_repository.storage_name,
+ gitaly_repository.relative_path,
+ gitaly_repository.gl_repository)
+ end
+
def gitlab_tag_from_gitaly_tag(repository, gitaly_tag)
if gitaly_tag.target_commit.present?
commit = Gitlab::Git::Commit.decorate(repository, gitaly_tag.target_commit)
diff --git a/lib/gitlab/gitaly_client/wiki_page.rb b/lib/gitlab/gitaly_client/wiki_page.rb
index 7339468e911..a02d15db5dd 100644
--- a/lib/gitlab/gitaly_client/wiki_page.rb
+++ b/lib/gitlab/gitaly_client/wiki_page.rb
@@ -4,6 +4,7 @@ module Gitlab
ATTRS = %i(title format url_path path name historical raw_data).freeze
include AttributesBag
+ include Gitlab::EncodingHelper
def initialize(params)
super
@@ -11,6 +12,10 @@ module Gitlab
# All gRPC strings in a response are frozen, so we get an unfrozen
# version here so appending to `raw_data` doesn't blow up.
@raw_data = @raw_data.dup
+
+ @title = encode_utf8(@title)
+ @path = encode_utf8(@path)
+ @name = encode_utf8(@name)
end
def historical?
diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb
index 337d225d081..0d8dd5cb8f4 100644
--- a/lib/gitlab/gitaly_client/wiki_service.rb
+++ b/lib/gitlab/gitaly_client/wiki_service.rb
@@ -3,6 +3,8 @@ require 'stringio'
module Gitlab
module GitalyClient
class WikiService
+ include Gitlab::EncodingHelper
+
MAX_MSG_SIZE = 128.kilobytes.freeze
def initialize(repository)
@@ -13,12 +15,12 @@ module Gitlab
def write_page(name, format, content, commit_details)
request = Gitaly::WikiWritePageRequest.new(
repository: @gitaly_repo,
- name: GitalyClient.encode(name),
+ name: encode_binary(name),
format: format.to_s,
commit_details: gitaly_commit_details(commit_details)
)
- strio = GitalyClient.binary_stringio(content)
+ strio = binary_stringio(content)
enum = Enumerator.new do |y|
until strio.eof?
@@ -39,13 +41,13 @@ module Gitlab
def update_page(page_path, title, format, content, commit_details)
request = Gitaly::WikiUpdatePageRequest.new(
repository: @gitaly_repo,
- page_path: GitalyClient.encode(page_path),
- title: GitalyClient.encode(title),
+ page_path: encode_binary(page_path),
+ title: encode_binary(title),
format: format.to_s,
commit_details: gitaly_commit_details(commit_details)
)
- strio = GitalyClient.binary_stringio(content)
+ strio = binary_stringio(content)
enum = Enumerator.new do |y|
until strio.eof?
@@ -63,7 +65,7 @@ module Gitlab
def delete_page(page_path, commit_details)
request = Gitaly::WikiDeletePageRequest.new(
repository: @gitaly_repo,
- page_path: GitalyClient.encode(page_path),
+ page_path: encode_binary(page_path),
commit_details: gitaly_commit_details(commit_details)
)
@@ -73,9 +75,9 @@ module Gitlab
def find_page(title:, version: nil, dir: nil)
request = Gitaly::WikiFindPageRequest.new(
repository: @gitaly_repo,
- title: GitalyClient.encode(title),
- revision: GitalyClient.encode(version),
- directory: GitalyClient.encode(dir)
+ title: encode_binary(title),
+ revision: encode_binary(version),
+ directory: encode_binary(dir)
)
response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_find_page, request)
@@ -99,11 +101,35 @@ module Gitlab
pages
end
+ # options:
+ # :page - The Integer page number.
+ # :per_page - The number of items per page.
+ # :limit - Total number of items to return.
+ def page_versions(page_path, options)
+ request = Gitaly::WikiGetPageVersionsRequest.new(
+ repository: @gitaly_repo,
+ page_path: encode_binary(page_path),
+ page: options[:page] || 1,
+ per_page: options[:per_page] || Gollum::Page.per_page
+ )
+
+ stream = GitalyClient.call(@repository.storage, :wiki_service, :wiki_get_page_versions, request)
+
+ versions = []
+ stream.each do |message|
+ message.versions.each do |version|
+ versions << new_wiki_page_version(version)
+ end
+ end
+
+ versions
+ end
+
def find_file(name, revision)
request = Gitaly::WikiFindFileRequest.new(
repository: @gitaly_repo,
- name: GitalyClient.encode(name),
- revision: GitalyClient.encode(revision)
+ name: encode_binary(name),
+ revision: encode_binary(revision)
)
response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_find_file, request)
@@ -125,9 +151,21 @@ module Gitlab
wiki_file
end
+ def get_formatted_data(title:, dir: nil, version: nil)
+ request = Gitaly::WikiGetFormattedDataRequest.new(
+ repository: @gitaly_repo,
+ title: encode_binary(title),
+ revision: encode_binary(version),
+ directory: encode_binary(dir)
+ )
+
+ response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_get_formatted_data, request)
+ response.reduce("") { |memo, msg| memo << msg.data }
+ end
+
private
- # If a block is given and the yielded value is true, iteration will be
+ # If a block is given and the yielded value is truthy, iteration will be
# stopped early at that point; else the iterator is consumed entirely.
# The iterator is traversed with `next` to allow resuming the iteration.
def wiki_page_from_iterator(iterator)
@@ -144,10 +182,7 @@ module Gitlab
else
wiki_page = GitalyClient::WikiPage.new(page.to_h)
- version = Gitlab::Git::WikiPageVersion.new(
- Gitlab::Git::Commit.decorate(@repository, page.version.commit),
- page.version.format
- )
+ version = new_wiki_page_version(page.version)
end
end
@@ -156,11 +191,18 @@ module Gitlab
[wiki_page, version]
end
+ def new_wiki_page_version(version)
+ Gitlab::Git::WikiPageVersion.new(
+ Gitlab::Git::Commit.decorate(@repository, version.commit),
+ version.format
+ )
+ end
+
def gitaly_commit_details(commit_details)
Gitaly::WikiCommitDetails.new(
- name: GitalyClient.encode(commit_details.name),
- email: GitalyClient.encode(commit_details.email),
- message: GitalyClient.encode(commit_details.message)
+ name: encode_binary(commit_details.name),
+ email: encode_binary(commit_details.email),
+ message: encode_binary(commit_details.message)
)
end
end
diff --git a/lib/gitlab/github_import/client.rb b/lib/gitlab/github_import/client.rb
index 5da9befa08e..a61beafae0d 100644
--- a/lib/gitlab/github_import/client.rb
+++ b/lib/gitlab/github_import/client.rb
@@ -14,6 +14,8 @@ module Gitlab
# puts label.name
# end
class Client
+ include ::Gitlab::Utils::StrongMemoize
+
attr_reader :octokit
# A single page of data and the corresponding page number.
@@ -173,7 +175,9 @@ module Gitlab
end
def rate_limiting_enabled?
- @rate_limiting_enabled ||= api_endpoint.include?('.github.com')
+ strong_memoize(:rate_limiting_enabled) do
+ api_endpoint.include?('.github.com')
+ end
end
def api_endpoint
@@ -193,10 +197,7 @@ module Gitlab
end
def github_omniauth_provider
- @github_omniauth_provider ||=
- Gitlab.config.omniauth.providers
- .find { |provider| provider.name == 'github' }
- .to_h
+ @github_omniauth_provider ||= Gitlab::Auth::OAuth::Provider.config_for('github').to_h
end
def rate_limit_counter
diff --git a/lib/gitlab/github_import/importer/pull_requests_importer.rb b/lib/gitlab/github_import/importer/pull_requests_importer.rb
index 5437e32e9f1..e70361c163b 100644
--- a/lib/gitlab/github_import/importer/pull_requests_importer.rb
+++ b/lib/gitlab/github_import/importer/pull_requests_importer.rb
@@ -57,10 +57,7 @@ module Gitlab
end
def commit_exists?(sha)
- project.repository.lookup(sha)
- true
- rescue Rugged::Error
- false
+ project.repository.commit(sha).present?
end
def collection_method
diff --git a/lib/gitlab/github_import/importer/repository_importer.rb b/lib/gitlab/github_import/importer/repository_importer.rb
index 7dd68a0d1cd..01168abde6c 100644
--- a/lib/gitlab/github_import/importer/repository_importer.rb
+++ b/lib/gitlab/github_import/importer/repository_importer.rb
@@ -16,7 +16,8 @@ module Gitlab
# Returns true if we should import the wiki for the project.
def import_wiki?
client.repository(project.import_source)&.has_wiki &&
- !project.wiki_repository_exists?
+ !project.wiki_repository_exists? &&
+ Gitlab::GitalyClient::RemoteService.exists?(wiki_url)
end
# Imports the repository data.
@@ -55,20 +56,23 @@ module Gitlab
def import_wiki_repository
wiki_path = "#{project.disk_path}.wiki"
- wiki_url = project.import_url.sub(/\.git\z/, '.wiki.git')
- storage_path = project.repository_storage_path
- gitlab_shell.import_repository(storage_path, wiki_path, wiki_url)
+ gitlab_shell.import_repository(project.repository_storage, wiki_path, wiki_url)
true
rescue Gitlab::Shell::Error => e
if e.message !~ /repository not exported/
+ project.create_wiki
fail_import("Failed to import the wiki: #{e.message}")
else
true
end
end
+ def wiki_url
+ project.import_url.sub(/\.git\z/, '.wiki.git')
+ end
+
def update_clone_time
project.update_column(:last_repository_updated_at, Time.zone.now)
end
diff --git a/lib/gitlab/github_import/representation/diff_note.rb b/lib/gitlab/github_import/representation/diff_note.rb
index bb7439a0641..be1334ca98a 100644
--- a/lib/gitlab/github_import/representation/diff_note.rb
+++ b/lib/gitlab/github_import/representation/diff_note.rb
@@ -13,7 +13,7 @@ module Gitlab
:diff_hunk, :author, :note, :created_at, :updated_at,
:github_id
- NOTEABLE_ID_REGEX = /\/pull\/(?<iid>\d+)/i
+ NOTEABLE_ID_REGEX = %r{/pull/(?<iid>\d+)}i
# Builds a diff note from a GitHub API response.
#
diff --git a/lib/gitlab/github_import/representation/note.rb b/lib/gitlab/github_import/representation/note.rb
index a68bc4c002f..070e3b2db8d 100644
--- a/lib/gitlab/github_import/representation/note.rb
+++ b/lib/gitlab/github_import/representation/note.rb
@@ -12,7 +12,7 @@ module Gitlab
expose_attribute :noteable_id, :noteable_type, :author, :note,
:created_at, :updated_at, :github_id
- NOTEABLE_TYPE_REGEX = /\/(?<type>(pull|issues))\/(?<iid>\d+)/i
+ NOTEABLE_TYPE_REGEX = %r{/(?<type>(pull|issues))/(?<iid>\d+)}i
# Builds a note from a GitHub API response.
#
diff --git a/lib/gitlab/gitlab_import/client.rb b/lib/gitlab/gitlab_import/client.rb
index 075b3982608..5482504e72e 100644
--- a/lib/gitlab/gitlab_import/client.rb
+++ b/lib/gitlab/gitlab_import/client.rb
@@ -72,7 +72,7 @@ module Gitlab
end
def config
- Gitlab.config.omniauth.providers.find {|provider| provider.name == "gitlab"}
+ Gitlab::Auth::OAuth::Provider.config_for('gitlab')
end
def gitlab_options
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index dfcdfc307b6..a7e055ac444 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -3,24 +3,24 @@
module Gitlab
module GonHelper
include WebpackHelper
- include Gitlab::CurrentSettings
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.max_file_size = Gitlab::CurrentSettings.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.sentry_dsn = Gitlab::CurrentSettings.clientside_sentry_dsn if Gitlab::CurrentSettings.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')
gon.sprite_icons = IconsHelper.sprite_icon_path
+ gon.sprite_file_icons = IconsHelper.sprite_file_icons_path
+ gon.test_env = Rails.env.test?
+ gon.suggested_label_colors = LabelsHelper.suggested_colors
if current_user
gon.current_user_id = current_user.id
diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb
index ab38c0c3e34..46b49128140 100644
--- a/lib/gitlab/google_code_import/importer.rb
+++ b/lib/gitlab/google_code_import/importer.rb
@@ -302,6 +302,7 @@ module Gitlab
else
"#{project.namespace.full_path}/#{name}##{id}"
end
+
text = "~~#{text}~~" if deleted
text
end
@@ -329,6 +330,7 @@ module Gitlab
if content.blank?
content = "*(No comment has been entered for this change)*"
end
+
body << content
if updates.any?
@@ -352,6 +354,7 @@ module Gitlab
if content.blank?
content = "*(No description has been entered for this issue)*"
end
+
body << content
if attachments.any?
diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb
index 0f4ba6f83fc..6d2278d0876 100644
--- a/lib/gitlab/gpg/commit.rb
+++ b/lib/gitlab/gpg/commit.rb
@@ -1,19 +1,29 @@
module Gitlab
module Gpg
class Commit
+ include Gitlab::Utils::StrongMemoize
+
def initialize(commit)
@commit = commit
- @signature_text, @signed_text =
- begin
- Rugged::Commit.extract_signature(@commit.project.repository.rugged, @commit.sha)
- rescue Rugged::OdbError
- nil
- end
+ repo = commit.project.repository.raw_repository
+ @signature_data = Gitlab::Git::Commit.extract_signature_lazily(repo, commit.sha || commit.id)
+ end
+
+ def signature_text
+ strong_memoize(:signature_text) do
+ @signature_data&.itself && @signature_data[0]
+ end
+ end
+
+ def signed_text
+ strong_memoize(:signed_text) do
+ @signature_data&.itself && @signature_data[1]
+ end
end
def has_signature?
- !!(@signature_text && @signed_text)
+ !!(signature_text && signed_text)
end
def signature
@@ -57,14 +67,16 @@ module Gitlab
end
def verified_signature
- @verified_signature ||= GPGME::Crypto.new.verify(@signature_text, signed_text: @signed_text) do |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))
+ signature = GpgSignature.new(attributes(gpg_key))
+ signature.save! unless Gitlab::Database.read_only?
+ signature
end
end
diff --git a/lib/gitlab/grape_logging/loggers/user_logger.rb b/lib/gitlab/grape_logging/loggers/user_logger.rb
new file mode 100644
index 00000000000..fa172861967
--- /dev/null
+++ b/lib/gitlab/grape_logging/loggers/user_logger.rb
@@ -0,0 +1,18 @@
+# This grape_logging module (https://github.com/aserafin/grape_logging) makes it
+# possible to log the user who performed the Grape API action by retrieving
+# the user context from the request environment.
+module Gitlab
+ module GrapeLogging
+ module Loggers
+ class UserLogger < ::GrapeLogging::Loggers::Base
+ def parameters(request, _)
+ params = request.env[::API::Helpers::API_USER_ENV]
+
+ return {} unless params
+
+ params.slice(:user_id, :username)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/fs_shards_check.rb b/lib/gitlab/health_checks/fs_shards_check.rb
index afaa59b1018..6e554383270 100644
--- a/lib/gitlab/health_checks/fs_shards_check.rb
+++ b/lib/gitlab/health_checks/fs_shards_check.rb
@@ -77,7 +77,7 @@ module Gitlab
end
def storage_path(storage_name)
- storages_paths&.dig(storage_name, 'path')
+ storages_paths[storage_name]&.legacy_disk_path
end
# All below test methods use shell commands to perform actions on storage volumes.
diff --git a/lib/gitlab/health_checks/gitaly_check.rb b/lib/gitlab/health_checks/gitaly_check.rb
new file mode 100644
index 00000000000..11416c002e3
--- /dev/null
+++ b/lib/gitlab/health_checks/gitaly_check.rb
@@ -0,0 +1,53 @@
+module Gitlab
+ module HealthChecks
+ class GitalyCheck
+ extend BaseAbstractCheck
+
+ METRIC_PREFIX = 'gitaly_health_check'.freeze
+
+ class << self
+ def readiness
+ repository_storages.map do |storage_name|
+ check(storage_name)
+ end
+ end
+
+ def metrics
+ repository_storages.flat_map do |storage_name|
+ result, elapsed = with_timing { check(storage_name) }
+ labels = { shard: storage_name }
+
+ [
+ metric("#{metric_prefix}_success", successful?(result) ? 1 : 0, **labels),
+ metric("#{metric_prefix}_latency_seconds", elapsed, **labels)
+ ].flatten
+ end
+ end
+
+ def check(storage_name)
+ serv = Gitlab::GitalyClient::HealthCheckService.new(storage_name)
+ result = serv.check
+ HealthChecks::Result.new(result[:success], result[:message], shard: storage_name)
+ end
+
+ private
+
+ def metric_prefix
+ METRIC_PREFIX
+ end
+
+ def successful?(result)
+ result[:success]
+ end
+
+ def repository_storages
+ storages.keys
+ end
+
+ def storages
+ Gitlab.config.repositories.storages
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/health_checks/metric.rb b/lib/gitlab/health_checks/metric.rb
index 1a2eab0b005..d62d9136886 100644
--- a/lib/gitlab/health_checks/metric.rb
+++ b/lib/gitlab/health_checks/metric.rb
@@ -1,3 +1,3 @@
-module Gitlab::HealthChecks
+module Gitlab::HealthChecks # rubocop:disable Naming/FileName
Metric = Struct.new(:name, :value, :labels)
end
diff --git a/lib/gitlab/health_checks/result.rb b/lib/gitlab/health_checks/result.rb
index 8086760023e..e323e2c9723 100644
--- a/lib/gitlab/health_checks/result.rb
+++ b/lib/gitlab/health_checks/result.rb
@@ -1,3 +1,3 @@
-module Gitlab::HealthChecks
+module Gitlab::HealthChecks # rubocop:disable Naming/FileName
Result = Struct.new(:success, :message, :labels)
end
diff --git a/lib/gitlab/hook_data/issuable_builder.rb b/lib/gitlab/hook_data/issuable_builder.rb
index 4febb0ab430..6ab36676127 100644
--- a/lib/gitlab/hook_data/issuable_builder.rb
+++ b/lib/gitlab/hook_data/issuable_builder.rb
@@ -11,7 +11,8 @@ module Gitlab
def build(user: nil, changes: {})
hook_data = {
- object_kind: issuable.class.name.underscore,
+ object_kind: object_kind,
+ event_type: event_type,
user: user.hook_attrs,
project: issuable.project.hook_attrs,
object_attributes: issuable.hook_attrs,
@@ -36,6 +37,18 @@ module Gitlab
private
+ def object_kind
+ issuable.class.name.underscore
+ end
+
+ def event_type
+ if issuable.try(:confidential?)
+ "confidential_#{object_kind}"
+ else
+ object_kind
+ end
+ end
+
def issuable_builder
case issuable
when Issue
diff --git a/lib/gitlab/hook_data/issue_builder.rb b/lib/gitlab/hook_data/issue_builder.rb
index e29dd0d5b0e..f9b1a3caf5e 100644
--- a/lib/gitlab/hook_data/issue_builder.rb
+++ b/lib/gitlab/hook_data/issue_builder.rb
@@ -7,7 +7,6 @@ module Gitlab
closed_at
confidential
created_at
- deleted_at
description
due_date
id
diff --git a/lib/gitlab/hook_data/merge_request_builder.rb b/lib/gitlab/hook_data/merge_request_builder.rb
index ae9b68eb648..aff786864f2 100644
--- a/lib/gitlab/hook_data/merge_request_builder.rb
+++ b/lib/gitlab/hook_data/merge_request_builder.rb
@@ -5,7 +5,6 @@ module Gitlab
assignee_id
author_id
created_at
- deleted_at
description
head_pipeline_id
id
diff --git a/lib/gitlab/http.rb b/lib/gitlab/http.rb
new file mode 100644
index 00000000000..9aca3b0fb26
--- /dev/null
+++ b/lib/gitlab/http.rb
@@ -0,0 +1,13 @@
+# This class is used as a proxy for all outbounding http connection
+# coming from callbacks, services and hooks. The direct use of the HTTParty
+# is discouraged because it can lead to several security problems, like SSRF
+# calling internal IP or services.
+module Gitlab
+ class HTTP
+ BlockedUrlError = Class.new(StandardError)
+
+ include HTTParty # rubocop:disable Gitlab/HTTParty
+
+ connection_adapter ProxyHTTPConnectionAdapter
+ end
+end
diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb
index bdc0f04b56b..3772ef11c7f 100644
--- a/lib/gitlab/i18n.rb
+++ b/lib/gitlab/i18n.rb
@@ -18,7 +18,10 @@ module Gitlab
'uk' => 'УкраїнÑька',
'ja' => '日本語',
'ko' => '한국어',
- 'nl_NL' => 'Nederlands'
+ 'nl_NL' => 'Nederlands',
+ 'tr_TR' => 'Türkçe',
+ 'id_ID' => 'Bahasa Indonesia',
+ 'fil_PH' => 'Filipino'
}.freeze
def available_locales
diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb
index 2066005dddc..af203ff711d 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.2.1'.freeze
+ VERSION = '0.2.2'.freeze
FILENAME_LIMIT = 50
def export_path(relative_path:)
diff --git a/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb b/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb
new file mode 100644
index 00000000000..aef371d81eb
--- /dev/null
+++ b/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy.rb
@@ -0,0 +1,83 @@
+module Gitlab
+ module ImportExport
+ module AfterExportStrategies
+ class BaseAfterExportStrategy
+ include ActiveModel::Validations
+ extend Forwardable
+
+ StrategyError = Class.new(StandardError)
+
+ AFTER_EXPORT_LOCK_FILE_NAME = '.after_export_action'.freeze
+
+ private
+
+ attr_reader :project, :current_user
+
+ public
+
+ def initialize(attributes = {})
+ @options = OpenStruct.new(attributes)
+
+ self.class.instance_eval do
+ def_delegators :@options, *attributes.keys
+ end
+ end
+
+ def execute(current_user, project)
+ return unless project&.export_project_path
+
+ @project = project
+ @current_user = current_user
+
+ if invalid?
+ log_validation_errors
+
+ return
+ end
+
+ create_or_update_after_export_lock
+ strategy_execute
+
+ true
+ rescue => e
+ project.import_export_shared.error(e)
+ false
+ ensure
+ delete_after_export_lock
+ end
+
+ def to_json(options = {})
+ @options.to_h.merge!(klass: self.class.name).to_json
+ end
+
+ def self.lock_file_path(project)
+ return unless project&.export_path
+
+ File.join(project.export_path, AFTER_EXPORT_LOCK_FILE_NAME)
+ end
+
+ protected
+
+ def strategy_execute
+ raise NotImplementedError
+ end
+
+ private
+
+ def create_or_update_after_export_lock
+ FileUtils.touch(self.class.lock_file_path(project))
+ end
+
+ def delete_after_export_lock
+ lock_file = self.class.lock_file_path(project)
+
+ FileUtils.rm(lock_file) if lock_file.present? && File.exist?(lock_file)
+ end
+
+ def log_validation_errors
+ errors.full_messages.each { |msg| project.import_export_shared.add_error_message(msg) }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/after_export_strategies/download_notification_strategy.rb b/lib/gitlab/import_export/after_export_strategies/download_notification_strategy.rb
new file mode 100644
index 00000000000..4371a7eff56
--- /dev/null
+++ b/lib/gitlab/import_export/after_export_strategies/download_notification_strategy.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module ImportExport
+ module AfterExportStrategies
+ class DownloadNotificationStrategy < BaseAfterExportStrategy
+ private
+
+ def strategy_execute
+ notification_service.project_exported(project, current_user)
+ end
+
+ def notification_service
+ @notification_service ||= NotificationService.new
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb b/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb
new file mode 100644
index 00000000000..938664a95a1
--- /dev/null
+++ b/lib/gitlab/import_export/after_export_strategies/web_upload_strategy.rb
@@ -0,0 +1,61 @@
+module Gitlab
+ module ImportExport
+ module AfterExportStrategies
+ class WebUploadStrategy < BaseAfterExportStrategy
+ PUT_METHOD = 'PUT'.freeze
+ POST_METHOD = 'POST'.freeze
+ INVALID_HTTP_METHOD = 'invalid. Only PUT and POST methods allowed.'.freeze
+
+ validates :url, url: true
+
+ validate do
+ unless [PUT_METHOD, POST_METHOD].include?(http_method.upcase)
+ errors.add(:http_method, INVALID_HTTP_METHOD)
+ end
+ end
+
+ def initialize(url:, http_method: PUT_METHOD)
+ super
+ end
+
+ protected
+
+ def strategy_execute
+ handle_response_error(send_file)
+
+ project.remove_exported_project_file
+ end
+
+ def handle_response_error(response)
+ unless response.success?
+ error_code = response.dig('Error', 'Code') || response.code
+ error_message = response.dig('Error', 'Message') || response.message
+
+ raise StrategyError.new("Error uploading the project. Code #{error_code}: #{error_message}")
+ end
+ end
+
+ private
+
+ def send_file
+ export_file = File.open(project.export_project_path)
+
+ Gitlab::HTTP.public_send(http_method.downcase, url, send_file_options(export_file)) # rubocop:disable GitlabSecurity/PublicSend
+ ensure
+ export_file.close if export_file
+ end
+
+ def send_file_options(export_file)
+ {
+ body_stream: export_file,
+ headers: headers
+ }
+ end
+
+ def headers
+ { 'Content-Length' => File.size(project.export_project_path).to_s }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/after_export_strategy_builder.rb b/lib/gitlab/import_export/after_export_strategy_builder.rb
new file mode 100644
index 00000000000..7eabcae2380
--- /dev/null
+++ b/lib/gitlab/import_export/after_export_strategy_builder.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module ImportExport
+ class AfterExportStrategyBuilder
+ StrategyNotFoundError = Class.new(StandardError)
+
+ def self.build!(strategy_klass, attributes = {})
+ return default_strategy.new unless strategy_klass
+
+ attributes ||= {}
+ klass = strategy_klass.constantize rescue nil
+
+ unless klass && klass < AfterExportStrategies::BaseAfterExportStrategy
+ raise StrategyNotFoundError.new("Strategy #{strategy_klass} not found")
+ end
+
+ klass.new(**attributes.symbolize_keys)
+ end
+
+ def self.default_strategy
+ AfterExportStrategies::DownloadNotificationStrategy
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/command_line_util.rb b/lib/gitlab/import_export/command_line_util.rb
index 0135b3c6f22..2f163db936b 100644
--- a/lib/gitlab/import_export/command_line_util.rb
+++ b/lib/gitlab/import_export/command_line_util.rb
@@ -11,10 +11,6 @@ module Gitlab
untar_with_options(archive: archive, dir: dir, options: 'zxf')
end
- def git_bundle(repo_path:, bundle_path:)
- execute(%W(#{git_bin_path} --git-dir=#{repo_path} bundle create #{bundle_path} --all))
- end
-
def mkdir_p(path)
FileUtils.mkdir_p(path, mode: DEFAULT_MODE)
FileUtils.chmod(DEFAULT_MODE, path)
diff --git a/lib/gitlab/import_export/file_importer.rb b/lib/gitlab/import_export/file_importer.rb
index 989342389bc..0f4c3498036 100644
--- a/lib/gitlab/import_export/file_importer.rb
+++ b/lib/gitlab/import_export/file_importer.rb
@@ -17,12 +17,16 @@ module Gitlab
def import
mkdir_p(@shared.export_path)
+ remove_symlinks!
+
wait_for_archived_file do
decompress_archive
end
rescue => e
@shared.error(e)
false
+ ensure
+ remove_symlinks!
end
private
@@ -43,7 +47,7 @@ module Gitlab
raise Projects::ImportService::Error.new("Unable to decompress #{@archive_file} into #{@shared.export_path}") unless result
- remove_symlinks!
+ result
end
def remove_symlinks!
@@ -55,7 +59,7 @@ module Gitlab
end
def extracted_files
- Dir.glob("#{@shared.export_path}/**/*", File::FNM_DOTMATCH).reject { |f| f =~ /.*\/\.{1,2}$/ }
+ Dir.glob("#{@shared.export_path}/**/*", File::FNM_DOTMATCH).reject { |f| f =~ %r{.*/\.{1,2}$} }
end
end
end
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index f2b193c79cb..cd840bd5b01 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -27,6 +27,8 @@ project_tree:
- :releases
- project_members:
- :user
+ - lfs_file_locks:
+ - :user
- merge_requests:
- notes:
- :author
@@ -49,8 +51,8 @@ project_tree:
- :author
- events:
- :push_event_payload
- - :stages
- - :statuses
+ - stages:
+ - :statuses
- :auto_devops
- :triggers
- :pipeline_schedules
@@ -63,6 +65,7 @@ project_tree:
- :create_access_levels
- :project_feature
- :custom_attributes
+ - :project_badges
# Only include the following attributes for the models specified.
included_attributes:
@@ -102,6 +105,7 @@ excluded_attributes:
- :last_repository_updated_at
- :last_repository_check_at
- :storage_version
+ - :description_html
snippets:
- :expired_at
merge_request_diff:
@@ -121,8 +125,12 @@ excluded_attributes:
- :trace
- :token
- :when
+ - :artifacts_file
+ - :artifacts_metadata
push_event_payload:
- :event_id
+ project_badges:
+ - :group_id
methods:
labels:
@@ -139,9 +147,9 @@ methods:
- :diff_head_sha
- :source_branch_sha
- :target_branch_sha
- project:
- - :description_html
events:
- :action
push_event_payload:
- :action
+ project_badges:
+ - :type
diff --git a/lib/gitlab/import_export/importer.rb b/lib/gitlab/import_export/importer.rb
index c14646b0611..63cab07324a 100644
--- a/lib/gitlab/import_export/importer.rb
+++ b/lib/gitlab/import_export/importer.rb
@@ -1,6 +1,9 @@
module Gitlab
module ImportExport
class Importer
+ include Gitlab::Allowable
+ include Gitlab::Utils::StrongMemoize
+
def self.imports_repository?
true
end
@@ -9,21 +12,28 @@ module Gitlab
@archive_file = project.import_source
@current_user = project.creator
@project = project
- @shared = Gitlab::ImportExport::Shared.new(relative_path: path_with_namespace)
+ @shared = project.import_export_shared
end
def execute
- if import_file && check_version! && [repo_restorer, wiki_restorer, project_tree, avatar_restorer, uploads_restorer].all?(&:restore)
+ if import_file && check_version! && restorers.all?(&:restore) && overwrite_project
project_tree.restored_project
else
raise Projects::ImportService::Error.new(@shared.errors.join(', '))
end
-
+ rescue => e
+ raise Projects::ImportService::Error.new(e.message)
+ ensure
remove_import_file
end
private
+ def restorers
+ [repo_restorer, wiki_restorer, project_tree, avatar_restorer,
+ uploads_restorer, lfs_restorer, statistics_restorer]
+ end
+
def import_file
Gitlab::ImportExport::FileImporter.import(archive_file: @archive_file,
shared: @shared)
@@ -50,15 +60,24 @@ module Gitlab
end
def wiki_restorer
- Gitlab::ImportExport::RepoRestorer.new(path_to_bundle: wiki_repo_path,
+ Gitlab::ImportExport::WikiRestorer.new(path_to_bundle: wiki_repo_path,
shared: @shared,
- project: ProjectWiki.new(project_tree.restored_project))
+ project: ProjectWiki.new(project_tree.restored_project),
+ wiki_enabled: @project.wiki_enabled?)
end
def uploads_restorer
Gitlab::ImportExport::UploadsRestorer.new(project: project_tree.restored_project, shared: @shared)
end
+ def lfs_restorer
+ Gitlab::ImportExport::LfsRestorer.new(project: project_tree.restored_project, shared: @shared)
+ end
+
+ def statistics_restorer
+ Gitlab::ImportExport::StatisticsRestorer.new(project: project_tree.restored_project, shared: @shared)
+ end
+
def path_with_namespace
File.join(@project.namespace.full_path, @project.path)
end
@@ -74,6 +93,33 @@ module Gitlab
def remove_import_file
FileUtils.rm_rf(@archive_file)
end
+
+ def overwrite_project
+ project = project_tree.restored_project
+
+ return unless can?(@current_user, :admin_namespace, project.namespace)
+
+ if overwrite_project?
+ ::Projects::OverwriteProjectService.new(project, @current_user)
+ .execute(project_to_overwrite)
+ end
+
+ true
+ end
+
+ def original_path
+ @project.import_data&.data&.fetch('original_path', nil)
+ end
+
+ def overwrite_project?
+ original_path.present? && project_to_overwrite.present?
+ end
+
+ def project_to_overwrite
+ strong_memoize(:project_to_overwrite) do
+ Project.find_by_full_path("#{@project.namespace.full_path}/#{original_path}")
+ end
+ end
end
end
end
diff --git a/lib/gitlab/import_export/lfs_restorer.rb b/lib/gitlab/import_export/lfs_restorer.rb
new file mode 100644
index 00000000000..b28c3c161b7
--- /dev/null
+++ b/lib/gitlab/import_export/lfs_restorer.rb
@@ -0,0 +1,43 @@
+module Gitlab
+ module ImportExport
+ class LfsRestorer
+ def initialize(project:, shared:)
+ @project = project
+ @shared = shared
+ end
+
+ def restore
+ return true if lfs_file_paths.empty?
+
+ lfs_file_paths.each do |file_path|
+ link_or_create_lfs_object!(file_path)
+ end
+
+ true
+ rescue => e
+ @shared.error(e)
+ false
+ end
+
+ private
+
+ def link_or_create_lfs_object!(path)
+ size = File.size(path)
+ oid = LfsObject.calculate_oid(path)
+
+ lfs_object = LfsObject.find_or_initialize_by(oid: oid, size: size)
+ lfs_object.file = File.open(path) unless lfs_object.file&.exists?
+
+ @project.all_lfs_objects << lfs_object
+ end
+
+ def lfs_file_paths
+ @lfs_file_paths ||= Dir.glob("#{lfs_storage_path}/*")
+ end
+
+ def lfs_storage_path
+ File.join(@shared.export_path, 'lfs-objects')
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/lfs_saver.rb b/lib/gitlab/import_export/lfs_saver.rb
new file mode 100644
index 00000000000..29410e2331c
--- /dev/null
+++ b/lib/gitlab/import_export/lfs_saver.rb
@@ -0,0 +1,55 @@
+module Gitlab
+ module ImportExport
+ class LfsSaver
+ include Gitlab::ImportExport::CommandLineUtil
+
+ def initialize(project:, shared:)
+ @project = project
+ @shared = shared
+ end
+
+ def save
+ @project.all_lfs_objects.each do |lfs_object|
+ save_lfs_object(lfs_object)
+ end
+
+ true
+ rescue => e
+ @shared.error(e)
+
+ false
+ end
+
+ private
+
+ def save_lfs_object(lfs_object)
+ if lfs_object.local_store?
+ copy_file_for_lfs_object(lfs_object)
+ else
+ download_file_for_lfs_object(lfs_object)
+ end
+ end
+
+ def download_file_for_lfs_object(lfs_object)
+ destination = destination_path_for_object(lfs_object)
+ mkdir_p(File.dirname(destination))
+
+ File.open(destination, 'w') do |file|
+ IO.copy_stream(URI.parse(lfs_object.file.url).open, file)
+ end
+ end
+
+ def copy_file_for_lfs_object(lfs_object)
+ copy_files(lfs_object.file.path, destination_path_for_object(lfs_object))
+ end
+
+ def destination_path_for_object(lfs_object)
+ File.join(lfs_export_path, lfs_object.oid)
+ end
+
+ def lfs_export_path
+ File.join(@shared.export_path, 'lfs-objects')
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/project_creator.rb b/lib/gitlab/import_export/project_creator.rb
deleted file mode 100644
index 77bb3ca6581..00000000000
--- a/lib/gitlab/import_export/project_creator.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module Gitlab
- module ImportExport
- class ProjectCreator
- def initialize(namespace_id, current_user, file, project_path)
- @namespace_id = namespace_id
- @current_user = current_user
- @file = file
- @project_path = project_path
- end
-
- def execute
- ::Projects::CreateService.new(
- @current_user,
- name: @project_path,
- path: @project_path,
- namespace_id: @namespace_id,
- import_type: "gitlab_project",
- import_source: @file
- ).execute
- end
- end
- end
-end
diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb
index c518943be59..d5590dde40f 100644
--- a/lib/gitlab/import_export/project_tree_restorer.rb
+++ b/lib/gitlab/import_export/project_tree_restorer.rb
@@ -35,6 +35,8 @@ module Gitlab
end
def restored_project
+ return @project unless @tree_hash
+
@restored_project ||= restore_project
end
@@ -75,20 +77,31 @@ module Gitlab
end
def default_relation_list
- Gitlab::ImportExport::Reader.new(shared: @shared).tree.reject do |model|
+ reader.tree.reject do |model|
model.is_a?(Hash) && model[:project_members]
end
end
def restore_project
- return @project unless @tree_hash
-
@project.update_columns(project_params)
@project
end
def project_params
- @tree_hash.reject do |key, value|
+ @project_params ||= json_params.merge(override_params)
+ end
+
+ def override_params
+ return {} unless params = @project.import_data&.data&.fetch('override_params', nil)
+
+ @override_params ||= params.select do |key, _value|
+ Project.column_names.include?(key.to_s) &&
+ !reader.project_tree[:except].include?(key.to_sym)
+ end
+ end
+
+ def json_params
+ @json_params ||= @tree_hash.reject do |key, value|
# return params that are not 1 to many or 1 to 1 relations
value.respond_to?(:each) && !Project.column_names.include?(key)
end
@@ -148,6 +161,7 @@ module Gitlab
else
relation_hash = relation_item[sub_relation.to_s]
end
+
[relation_hash, sub_relation]
end
@@ -174,6 +188,10 @@ module Gitlab
relation_hash.merge(params)
end
+
+ def reader
+ @reader ||= Gitlab::ImportExport::Reader.new(shared: @shared)
+ end
end
end
end
diff --git a/lib/gitlab/import_export/project_tree_saver.rb b/lib/gitlab/import_export/project_tree_saver.rb
index 3473b466936..5510c0b8b2f 100644
--- a/lib/gitlab/import_export/project_tree_saver.rb
+++ b/lib/gitlab/import_export/project_tree_saver.rb
@@ -5,7 +5,8 @@ module Gitlab
attr_reader :full_path
- def initialize(project:, current_user:, shared:)
+ def initialize(project:, current_user:, shared:, params: {})
+ @params = params
@project = project
@current_user = current_user
@shared = shared
@@ -25,6 +26,10 @@ module Gitlab
private
def project_json_tree
+ if @params[:description].present?
+ project_json['description'] = @params[:description]
+ end
+
project_json['project_members'] += group_members_json
project_json.to_json
diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb
index d7d1b05e8b9..598832fb2df 100644
--- a/lib/gitlab/import_export/relation_factory.rb
+++ b/lib/gitlab/import_export/relation_factory.rb
@@ -16,9 +16,10 @@ module Gitlab
priorities: :label_priorities,
auto_devops: :project_auto_devops,
label: :project_label,
- custom_attributes: 'ProjectCustomAttribute' }.freeze
+ custom_attributes: 'ProjectCustomAttribute',
+ project_badges: 'Badge' }.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
+ 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 closed_by_id].freeze
PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze
@@ -62,12 +63,14 @@ module Gitlab
when :notes then setup_note
when :project_label, :project_labels then setup_label
when :milestone, :milestones then setup_milestone
+ when 'Ci::Pipeline' then setup_pipeline
else
@relation_hash['project_id'] = @project.id
end
update_user_references
update_project_references
+ remove_duplicate_assignees
reset_tokens!
remove_encrypted_attributes!
@@ -81,6 +84,14 @@ module Gitlab
end
end
+ def remove_duplicate_assignees
+ return unless @relation_hash['issue_assignees']
+
+ # When an assignee did not exist in the members mapper, the importer is
+ # assigned. We only need to assign each user once.
+ @relation_hash['issue_assignees'].uniq!(&:user_id)
+ end
+
def setup_note
set_note_author
# attachment is deprecated and note uploads are handled by Markdown uploader
@@ -112,9 +123,7 @@ module Gitlab
@relation_hash.delete('trace') # old export files have trace
@relation_hash.delete('token')
- imported_object do |object|
- object.commit_id = nil
- end
+ imported_object
elsif @relation_name == :merge_requests
MergeRequestParser.new(@project, @relation_hash.delete('diff_head_sha'), imported_object, @relation_hash).parse!
else
@@ -140,13 +149,12 @@ module Gitlab
end
def setup_label
- return unless @relation_hash['type'] == 'GroupLabel'
-
# If there's no group, move the label to a project label
- if @relation_hash['group_id']
+ if @relation_hash['type'] == 'GroupLabel' && @relation_hash['group_id']
@relation_hash['project_id'] = nil
@relation_name = :group_label
else
+ @relation_hash['group_id'] = nil
@relation_hash['type'] = 'ProjectLabel'
end
end
@@ -182,8 +190,9 @@ module Gitlab
end
def imported_object
- yield(existing_or_new_object) if block_given?
- existing_or_new_object.importing = true if existing_or_new_object.respond_to?(:importing)
+ if existing_or_new_object.respond_to?(:importing)
+ existing_or_new_object.importing = true
+ end
existing_or_new_object
rescue ActiveRecord::RecordNotUnique
@@ -211,6 +220,14 @@ module Gitlab
@relation_hash['diff'] = @relation_hash.delete('utf8_diff')
end
+ def setup_pipeline
+ @relation_hash.fetch('stages').each do |stage|
+ stage.statuses.each do |status|
+ status.pipeline = imported_object
+ end
+ end
+ 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.
@@ -259,6 +276,7 @@ module Gitlab
else
%w[title group_id]
end
+
finder_hash = parsed_relation_hash.slice(*finder_attributes)
if label?
diff --git a/lib/gitlab/import_export/repo_restorer.rb b/lib/gitlab/import_export/repo_restorer.rb
index 32ca2809b2f..5a9bbceac67 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.disk_path, @path_to_bundle)
+ @project.repository.create_from_bundle(@path_to_bundle)
rescue => e
@shared.error(e)
false
diff --git a/lib/gitlab/import_export/repo_saver.rb b/lib/gitlab/import_export/repo_saver.rb
index a7028a32570..695462c7dd2 100644
--- a/lib/gitlab/import_export/repo_saver.rb
+++ b/lib/gitlab/import_export/repo_saver.rb
@@ -21,7 +21,7 @@ module Gitlab
def bundle_to_disk
mkdir_p(@shared.export_path)
- git_bundle(repo_path: path_to_repo, bundle_path: @full_path)
+ @project.repository.bundle_to_disk(@full_path)
rescue => e
@shared.error(e)
false
diff --git a/lib/gitlab/import_export/saver.rb b/lib/gitlab/import_export/saver.rb
index 6130c124dd1..2daeba90a51 100644
--- a/lib/gitlab/import_export/saver.rb
+++ b/lib/gitlab/import_export/saver.rb
@@ -37,7 +37,7 @@ module Gitlab
end
def archive_file
- @archive_file ||= File.join(@shared.export_path, '..', Gitlab::ImportExport.export_filename(project: @project))
+ @archive_file ||= File.join(@shared.archive_path, Gitlab::ImportExport.export_filename(project: @project))
end
end
end
diff --git a/lib/gitlab/import_export/shared.rb b/lib/gitlab/import_export/shared.rb
index 9fd0b709ef2..6d7c36ce38b 100644
--- a/lib/gitlab/import_export/shared.rb
+++ b/lib/gitlab/import_export/shared.rb
@@ -1,29 +1,62 @@
module Gitlab
module ImportExport
class Shared
- attr_reader :errors, :opts
+ attr_reader :errors, :project
- def initialize(opts)
- @opts = opts
+ def initialize(project)
+ @project = project
@errors = []
end
+ def active_export_count
+ Dir[File.join(archive_path, '*')].count { |name| File.directory?(name) }
+ end
+
def export_path
- @export_path ||= Gitlab::ImportExport.export_path(relative_path: opts[:relative_path])
+ @export_path ||= Gitlab::ImportExport.export_path(relative_path: relative_path)
+ end
+
+ def archive_path
+ @archive_path ||= Gitlab::ImportExport.export_path(relative_path: relative_archive_path)
end
def error(error)
error_out(error.message, caller[0].dup)
- @errors << error.message
+ add_error_message(error.message)
+
# Debug:
- Rails.logger.error(error.backtrace.join("\n"))
+ if error.backtrace
+ Rails.logger.error("Import/Export backtrace: #{error.backtrace.join("\n")}")
+ else
+ Rails.logger.error("No backtrace found")
+ end
+ end
+
+ def add_error_message(error_message)
+ @errors << error_message
+ end
+
+ def after_export_in_progress?
+ File.exist?(after_export_lock_file)
end
private
+ def relative_path
+ File.join(relative_archive_path, SecureRandom.hex)
+ end
+
+ def relative_archive_path
+ @project.disk_path
+ end
+
def error_out(message, caller)
Rails.logger.error("Import/Export error raised on #{caller}: #{message}")
end
+
+ def after_export_lock_file
+ AfterExportStrategies::BaseAfterExportStrategy.lock_file_path(project)
+ end
end
end
end
diff --git a/lib/gitlab/import_export/statistics_restorer.rb b/lib/gitlab/import_export/statistics_restorer.rb
new file mode 100644
index 00000000000..bcdd9c12c85
--- /dev/null
+++ b/lib/gitlab/import_export/statistics_restorer.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module ImportExport
+ class StatisticsRestorer
+ def initialize(project:, shared:)
+ @project = project
+ @shared = shared
+ end
+
+ def restore
+ @project.statistics.refresh!
+ rescue => e
+ @shared.error(e)
+ false
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/uploads_saver.rb b/lib/gitlab/import_export/uploads_saver.rb
index 627a487d577..2f08dda55fd 100644
--- a/lib/gitlab/import_export/uploads_saver.rb
+++ b/lib/gitlab/import_export/uploads_saver.rb
@@ -17,15 +17,13 @@ module Gitlab
false
end
- private
+ def uploads_path
+ FileUploader.absolute_base_dir(@project)
+ end
def uploads_export_path
File.join(@shared.export_path, 'uploads')
end
-
- def uploads_path
- FileUploader.dynamic_path_segment(@project)
- end
end
end
end
diff --git a/lib/gitlab/import_export/wiki_repo_saver.rb b/lib/gitlab/import_export/wiki_repo_saver.rb
index 1e6722a7bba..5fa2e101e29 100644
--- a/lib/gitlab/import_export/wiki_repo_saver.rb
+++ b/lib/gitlab/import_export/wiki_repo_saver.rb
@@ -10,7 +10,7 @@ module Gitlab
def bundle_to_disk(full_path)
mkdir_p(@shared.export_path)
- git_bundle(repo_path: path_to_repo, bundle_path: full_path)
+ @wiki.repository.bundle_to_disk(full_path)
rescue => e
@shared.error(e)
false
diff --git a/lib/gitlab/import_export/wiki_restorer.rb b/lib/gitlab/import_export/wiki_restorer.rb
new file mode 100644
index 00000000000..f33bfb332ab
--- /dev/null
+++ b/lib/gitlab/import_export/wiki_restorer.rb
@@ -0,0 +1,23 @@
+module Gitlab
+ module ImportExport
+ class WikiRestorer < RepoRestorer
+ def initialize(project:, shared:, path_to_bundle:, wiki_enabled:)
+ super(project: project, shared: shared, path_to_bundle: path_to_bundle)
+
+ @wiki_enabled = wiki_enabled
+ end
+
+ def restore
+ @project.wiki if create_empty_wiki?
+
+ super
+ end
+
+ private
+
+ def create_empty_wiki?
+ !File.exist?(@path_to_bundle) && @wiki_enabled
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_sources.rb b/lib/gitlab/import_sources.rb
index eeb03625479..60d5fa4d29a 100644
--- a/lib/gitlab/import_sources.rb
+++ b/lib/gitlab/import_sources.rb
@@ -7,6 +7,7 @@ module Gitlab
module ImportSources
ImportSource = Struct.new(:name, :title, :importer)
+ # We exclude `bare_repository` here as it has no import class associated
ImportTable = [
ImportSource.new('github', 'GitHub', Gitlab::GithubImport::ParallelImporter),
ImportSource.new('bitbucket', 'Bitbucket', Gitlab::BitbucketImport::Importer),
diff --git a/lib/gitlab/insecure_key_fingerprint.rb b/lib/gitlab/insecure_key_fingerprint.rb
new file mode 100644
index 00000000000..f85b6e9197f
--- /dev/null
+++ b/lib/gitlab/insecure_key_fingerprint.rb
@@ -0,0 +1,23 @@
+module Gitlab
+ #
+ # Calculates the fingerprint of a given key without using
+ # openssh key validations. For this reason, only use
+ # for calculating the fingerprint to find the key with it.
+ #
+ # DO NOT use it for checking the validity of a ssh key.
+ #
+ class InsecureKeyFingerprint
+ attr_accessor :key
+
+ #
+ # Gets the base64 encoded string representing a rsa or dsa key
+ #
+ def initialize(key_base64)
+ @key = key_base64
+ end
+
+ def fingerprint
+ OpenSSL::Digest::MD5.hexdigest(Base64.decode64(@key)).scan(/../).join(':')
+ end
+ end
+end
diff --git a/lib/gitlab/job_waiter.rb b/lib/gitlab/job_waiter.rb
index f654508c391..f7a8eae0be4 100644
--- a/lib/gitlab/job_waiter.rb
+++ b/lib/gitlab/job_waiter.rb
@@ -15,16 +15,22 @@ module Gitlab
# push to that array when done. Once the waiter has popped `count` items, it
# knows all the jobs are done.
class JobWaiter
+ KEY_PREFIX = "gitlab:job_waiter".freeze
+
def self.notify(key, jid)
Gitlab::Redis::SharedState.with { |redis| redis.lpush(key, jid) }
end
+ def self.key?(key)
+ key.is_a?(String) && key =~ /\A#{KEY_PREFIX}:\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
+ end
+
attr_reader :key, :finished
attr_accessor :jobs_remaining
# jobs_remaining - the number of jobs left to wait for
# key - The key of this waiter.
- def initialize(jobs_remaining = 0, key = "gitlab:job_waiter:#{SecureRandom.uuid}")
+ def initialize(jobs_remaining = 0, key = "#{KEY_PREFIX}:#{SecureRandom.uuid}")
@key = key
@jobs_remaining = jobs_remaining
@finished = []
diff --git a/lib/gitlab/kubernetes/config_map.rb b/lib/gitlab/kubernetes/config_map.rb
new file mode 100644
index 00000000000..95e1054919d
--- /dev/null
+++ b/lib/gitlab/kubernetes/config_map.rb
@@ -0,0 +1,37 @@
+module Gitlab
+ module Kubernetes
+ class ConfigMap
+ def initialize(name, values)
+ @name = name
+ @values = values
+ end
+
+ def generate
+ resource = ::Kubeclient::Resource.new
+ resource.metadata = metadata
+ resource.data = { values: values }
+ resource
+ end
+
+ private
+
+ attr_reader :name, :values
+
+ def metadata
+ {
+ name: config_map_name,
+ namespace: namespace,
+ labels: { name: config_map_name }
+ }
+ end
+
+ def config_map_name
+ "values-content-configuration-#{name}"
+ end
+
+ def namespace
+ Gitlab::Kubernetes::Helm::NAMESPACE
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/helm.rb b/lib/gitlab/kubernetes/helm.rb
index 407cdefc04d..0f0588b8b23 100644
--- a/lib/gitlab/kubernetes/helm.rb
+++ b/lib/gitlab/kubernetes/helm.rb
@@ -1,96 +1,8 @@
module Gitlab
module Kubernetes
- class Helm
+ module Helm
HELM_VERSION = '2.7.0'.freeze
NAMESPACE = 'gitlab-managed-apps'.freeze
- INSTALL_DEPS = <<-EOS.freeze
- set -eo pipefail
- apk add -U ca-certificates openssl >/dev/null
- wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v${HELM_VERSION}-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
- mv /tmp/linux-amd64/helm /usr/bin/
- EOS
-
- InstallCommand = Struct.new(:name, :install_helm, :chart) do
- def pod_name
- "install-#{name}"
- end
- end
-
- def initialize(kubeclient)
- @kubeclient = kubeclient
- @namespace = Gitlab::Kubernetes::Namespace.new(NAMESPACE, kubeclient)
- end
-
- def install(command)
- @namespace.ensure_exists!
- @kubeclient.create_pod(pod_resource(command))
- end
-
- ##
- # Returns Pod phase
- #
- # https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase
- #
- # values: "Pending", "Running", "Succeeded", "Failed", "Unknown"
- #
- def installation_status(pod_name)
- @kubeclient.get_pod(pod_name, @namespace.name).status.phase
- end
-
- def installation_log(pod_name)
- @kubeclient.get_pod_log(pod_name, @namespace.name).body
- end
-
- def delete_installation_pod!(pod_name)
- @kubeclient.delete_pod(pod_name, @namespace.name)
- end
-
- private
-
- def pod_resource(command)
- labels = { 'gitlab.org/action': 'install', 'gitlab.org/application': command.name }
- metadata = { name: command.pod_name, namespace: @namespace.name, labels: labels }
- container = {
- name: 'helm',
- image: 'alpine:3.6',
- env: generate_pod_env(command),
- command: %w(/bin/sh),
- args: %w(-c $(COMMAND_SCRIPT))
- }
- spec = { containers: [container], restartPolicy: 'Never' }
-
- ::Kubeclient::Resource.new(metadata: metadata, spec: spec)
- end
-
- def generate_pod_env(command)
- {
- HELM_VERSION: HELM_VERSION,
- TILLER_NAMESPACE: @namespace.name,
- COMMAND_SCRIPT: generate_script(command)
- }.map { |key, value| { name: key, value: value } }
- end
-
- def generate_script(command)
- [
- INSTALL_DEPS,
- helm_init_command(command),
- helm_install_command(command)
- ].join("\n")
- end
-
- def helm_init_command(command)
- if command.install_helm
- 'helm init >/dev/null'
- else
- 'helm init --client-only >/dev/null'
- end
- end
-
- def helm_install_command(command)
- return if command.chart.nil?
-
- "helm install #{command.chart} --name #{command.name} --namespace #{@namespace.name} >/dev/null"
- end
end
end
end
diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb
new file mode 100644
index 00000000000..2edd34109ba
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm/api.rb
@@ -0,0 +1,45 @@
+module Gitlab
+ module Kubernetes
+ module Helm
+ class Api
+ def initialize(kubeclient)
+ @kubeclient = kubeclient
+ @namespace = Gitlab::Kubernetes::Namespace.new(Gitlab::Kubernetes::Helm::NAMESPACE, kubeclient)
+ end
+
+ def install(command)
+ @namespace.ensure_exists!
+ create_config_map(command) if command.config_map?
+ @kubeclient.create_pod(command.pod_resource)
+ end
+
+ ##
+ # Returns Pod phase
+ #
+ # https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase
+ #
+ # values: "Pending", "Running", "Succeeded", "Failed", "Unknown"
+ #
+ def installation_status(pod_name)
+ @kubeclient.get_pod(pod_name, @namespace.name).status.phase
+ end
+
+ def installation_log(pod_name)
+ @kubeclient.get_pod_log(pod_name, @namespace.name).body
+ end
+
+ def delete_installation_pod!(pod_name)
+ @kubeclient.delete_pod(pod_name, @namespace.name)
+ end
+
+ private
+
+ def create_config_map(command)
+ command.config_map_resource.tap do |config_map_resource|
+ @kubeclient.create_config_map(config_map_resource)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/helm/base_command.rb b/lib/gitlab/kubernetes/helm/base_command.rb
new file mode 100644
index 00000000000..6e4df05aa7e
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm/base_command.rb
@@ -0,0 +1,40 @@
+module Gitlab
+ module Kubernetes
+ module Helm
+ class BaseCommand
+ attr_reader :name
+
+ def initialize(name)
+ @name = name
+ end
+
+ def pod_resource
+ Gitlab::Kubernetes::Helm::Pod.new(self, namespace).generate
+ end
+
+ def generate_script
+ <<~HEREDOC
+ set -eo pipefail
+ apk add -U ca-certificates openssl >/dev/null
+ wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v#{Gitlab::Kubernetes::Helm::HELM_VERSION}-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
+ mv /tmp/linux-amd64/helm /usr/bin/
+ HEREDOC
+ end
+
+ def config_map?
+ false
+ end
+
+ def pod_name
+ "install-#{name}"
+ end
+
+ private
+
+ def namespace
+ Gitlab::Kubernetes::Helm::NAMESPACE
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/helm/init_command.rb b/lib/gitlab/kubernetes/helm/init_command.rb
new file mode 100644
index 00000000000..a02e64561f6
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm/init_command.rb
@@ -0,0 +1,19 @@
+module Gitlab
+ module Kubernetes
+ module Helm
+ class InitCommand < BaseCommand
+ def generate_script
+ super + [
+ init_helm_command
+ ].join("\n")
+ end
+
+ private
+
+ def init_helm_command
+ "helm init >/dev/null"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb
new file mode 100644
index 00000000000..30af3e97b4a
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm/install_command.rb
@@ -0,0 +1,48 @@
+module Gitlab
+ module Kubernetes
+ module Helm
+ class InstallCommand < BaseCommand
+ attr_reader :name, :chart, :repository, :values
+
+ def initialize(name, chart:, values:, repository: nil)
+ @name = name
+ @chart = chart
+ @values = values
+ @repository = repository
+ end
+
+ def generate_script
+ super + [
+ init_command,
+ repository_command,
+ script_command
+ ].compact.join("\n")
+ end
+
+ def config_map?
+ true
+ end
+
+ def config_map_resource
+ Gitlab::Kubernetes::ConfigMap.new(name, values).generate
+ end
+
+ private
+
+ def init_command
+ 'helm init --client-only >/dev/null'
+ end
+
+ def repository_command
+ "helm repo add #{name} #{repository}" if repository
+ end
+
+ def script_command
+ <<~HEREDOC
+ helm install #{chart} --name #{name} --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE} -f /data/helm/#{name}/config/values.yaml >/dev/null
+ HEREDOC
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/helm/pod.rb b/lib/gitlab/kubernetes/helm/pod.rb
new file mode 100644
index 00000000000..1e12299eefd
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm/pod.rb
@@ -0,0 +1,81 @@
+module Gitlab
+ module Kubernetes
+ module Helm
+ class Pod
+ def initialize(command, namespace_name)
+ @command = command
+ @namespace_name = namespace_name
+ end
+
+ def generate
+ spec = { containers: [container_specification], restartPolicy: 'Never' }
+
+ if command.config_map?
+ spec[:volumes] = volumes_specification
+ spec[:containers][0][:volumeMounts] = volume_mounts_specification
+ end
+
+ ::Kubeclient::Resource.new(metadata: metadata, spec: spec)
+ end
+
+ private
+
+ attr_reader :command, :namespace_name, :kubeclient, :config_map
+
+ def container_specification
+ {
+ name: 'helm',
+ image: 'alpine:3.6',
+ env: generate_pod_env(command),
+ command: %w(/bin/sh),
+ args: %w(-c $(COMMAND_SCRIPT))
+ }
+ end
+
+ def labels
+ {
+ 'gitlab.org/action': 'install',
+ 'gitlab.org/application': command.name
+ }
+ end
+
+ def metadata
+ {
+ name: command.pod_name,
+ namespace: namespace_name,
+ labels: labels
+ }
+ end
+
+ def generate_pod_env(command)
+ {
+ HELM_VERSION: Gitlab::Kubernetes::Helm::HELM_VERSION,
+ TILLER_NAMESPACE: namespace_name,
+ COMMAND_SCRIPT: command.generate_script
+ }.map { |key, value| { name: key, value: value } }
+ end
+
+ def volumes_specification
+ [
+ {
+ name: 'configuration-volume',
+ configMap: {
+ name: "values-content-configuration-#{command.name}",
+ items: [{ key: 'values', path: 'values.yaml' }]
+ }
+ }
+ ]
+ end
+
+ def volume_mounts_specification
+ [
+ {
+ name: 'configuration-volume',
+ mountPath: "/data/helm/#{command.name}/config"
+ }
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/namespace.rb b/lib/gitlab/kubernetes/namespace.rb
index fbbddb7bffa..e6ff6160ab9 100644
--- a/lib/gitlab/kubernetes/namespace.rb
+++ b/lib/gitlab/kubernetes/namespace.rb
@@ -10,7 +10,7 @@ module Gitlab
def exists?
@client.get_namespace(name)
- rescue ::KubeException => ke
+ rescue ::Kubeclient::HttpError => ke
raise ke unless ke.error_code == 404
false
diff --git a/lib/gitlab/ldap/access.rb b/lib/gitlab/ldap/access.rb
deleted file mode 100644
index e60ceba27c8..00000000000
--- a/lib/gitlab/ldap/access.rb
+++ /dev/null
@@ -1,87 +0,0 @@
-# LDAP authorization model
-#
-# * Check if we are allowed access (not blocked)
-#
-module Gitlab
- module LDAP
- class Access
- attr_reader :provider, :user
-
- def self.open(user, &block)
- Gitlab::LDAP::Adapter.open(user.ldap_identity.provider) do |adapter|
- block.call(self.new(user, adapter))
- end
- end
-
- def self.allowed?(user)
- self.open(user) do |access|
- if access.allowed?
- Users::UpdateService.new(user, user: user, last_credential_check_at: Time.now).execute
-
- true
- else
- false
- end
- end
- end
-
- def initialize(user, adapter = nil)
- @adapter = adapter
- @user = user
- @provider = user.ldap_identity.provider
- end
-
- def allowed?
- if ldap_user
- unless ldap_config.active_directory
- unblock_user(user, 'is available again') if user.ldap_blocked?
- return true
- end
-
- # Block user in GitLab if he/she was blocked in AD
- if Gitlab::LDAP::Person.disabled_via_active_directory?(user.ldap_identity.extern_uid, adapter)
- block_user(user, 'is disabled in Active Directory')
- false
- else
- unblock_user(user, 'is not disabled anymore') if user.ldap_blocked?
- true
- end
- else
- # Block the user if they no longer exist in LDAP/AD
- block_user(user, 'does not exist anymore')
- false
- end
- end
-
- def adapter
- @adapter ||= Gitlab::LDAP::Adapter.new(provider)
- end
-
- def ldap_config
- Gitlab::LDAP::Config.new(provider)
- end
-
- def ldap_user
- @ldap_user ||= Gitlab::LDAP::Person.find_by_dn(user.ldap_identity.extern_uid, adapter)
- end
-
- def block_user(user, reason)
- user.ldap_block
-
- Gitlab::AppLogger.info(
- "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \
- "blocking Gitlab user \"#{user.name}\" (#{user.email})"
- )
- end
-
- def unblock_user(user, reason)
- user.activate
-
- Gitlab::AppLogger.info(
- "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \
- "unblocking Gitlab user \"#{user.name}\" (#{user.email})"
- )
- end
- end
- end
-end
diff --git a/lib/gitlab/ldap/adapter.rb b/lib/gitlab/ldap/adapter.rb
deleted file mode 100644
index 0afaa2306b5..00000000000
--- a/lib/gitlab/ldap/adapter.rb
+++ /dev/null
@@ -1,108 +0,0 @@
-module Gitlab
- module LDAP
- class Adapter
- attr_reader :provider, :ldap
-
- def self.open(provider, &block)
- Net::LDAP.open(config(provider).adapter_options) do |ldap|
- block.call(self.new(provider, ldap))
- end
- end
-
- def self.config(provider)
- Gitlab::LDAP::Config.new(provider)
- end
-
- def initialize(provider, ldap = nil)
- @provider = provider
- @ldap = ldap || Net::LDAP.new(config.adapter_options)
- end
-
- def config
- Gitlab::LDAP::Config.new(provider)
- end
-
- def users(fields, value, limit = nil)
- options = user_options(Array(fields), value, limit)
-
- entries = ldap_search(options).select do |entry|
- entry.respond_to? config.uid
- end
-
- entries.map do |entry|
- Gitlab::LDAP::Person.new(entry, provider)
- end
- end
-
- def user(*args)
- users(*args).first
- end
-
- def dn_matches_filter?(dn, filter)
- ldap_search(base: dn,
- filter: filter,
- scope: Net::LDAP::SearchScope_BaseObject,
- attributes: %w{dn}).any?
- end
-
- def ldap_search(*args)
- # Net::LDAP's `time` argument doesn't work. Use Ruby `Timeout` instead.
- Timeout.timeout(config.timeout) do
- results = ldap.search(*args)
-
- if results.nil?
- response = ldap.get_operation_result
-
- unless response.code.zero?
- Rails.logger.warn("LDAP search error: #{response.message}")
- end
-
- []
- else
- results
- end
- end
- rescue Net::LDAP::Error => error
- Rails.logger.warn("LDAP search raised exception #{error.class}: #{error.message}")
- []
- rescue Timeout::Error
- Rails.logger.warn("LDAP search timed out after #{config.timeout} seconds")
- []
- end
-
- private
-
- def user_options(fields, value, limit)
- options = {
- attributes: Gitlab::LDAP::Person.ldap_attributes(config).compact.uniq,
- base: config.base
- }
-
- options[:size] = limit if limit
-
- if fields.include?('dn')
- raise ArgumentError, 'It is not currently possible to search the DN and other fields at the same time.' if fields.size > 1
-
- options[:base] = value
- options[:scope] = Net::LDAP::SearchScope_BaseObject
- else
- filter = fields.map { |field| Net::LDAP::Filter.eq(field, value) }.inject(:|)
- end
-
- options.merge(filter: user_filter(filter))
- end
-
- def user_filter(filter = nil)
- user_filter = config.constructed_user_filter if config.user_filter.present?
-
- if user_filter && filter
- Net::LDAP::Filter.join(filter, user_filter)
- elsif user_filter
- user_filter
- else
- filter
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/ldap/auth_hash.rb b/lib/gitlab/ldap/auth_hash.rb
deleted file mode 100644
index 1bd0965679a..00000000000
--- a/lib/gitlab/ldap/auth_hash.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# Class to parse and transform the info provided by omniauth
-#
-module Gitlab
- module LDAP
- class AuthHash < Gitlab::OAuth::AuthHash
- def uid
- @uid ||= Gitlab::LDAP::Person.normalize_dn(super)
- end
-
- private
-
- def get_info(key)
- attributes = ldap_config.attributes[key.to_s]
- return super unless attributes
-
- attributes = Array(attributes)
-
- value = nil
- attributes.each do |attribute|
- value = get_raw(attribute)
- value = value.first if value
- break if value.present?
- end
-
- return super unless value
-
- Gitlab::Utils.force_utf8(value)
- value
- end
-
- def get_raw(key)
- auth_hash.extra[:raw_info][key] if auth_hash.extra
- end
-
- def ldap_config
- @ldap_config ||= Gitlab::LDAP::Config.new(self.provider)
- end
- end
- end
-end
diff --git a/lib/gitlab/ldap/authentication.rb b/lib/gitlab/ldap/authentication.rb
deleted file mode 100644
index 7274d1c3b43..00000000000
--- a/lib/gitlab/ldap/authentication.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-# These calls help to authenticate to LDAP by providing username and password
-#
-# Since multiple LDAP servers are supported, it will loop through all of them
-# until a valid bind is found
-#
-
-module Gitlab
- module LDAP
- class Authentication
- def self.login(login, password)
- return unless Gitlab::LDAP::Config.enabled?
- return unless login.present? && password.present?
-
- auth = nil
- # loop through providers until valid bind
- providers.find do |provider|
- auth = new(provider)
- auth.login(login, password) # true will exit the loop
- end
-
- # If (login, password) was invalid for all providers, the value of auth is now the last
- # Gitlab::LDAP::Authentication instance we tried.
- auth.user
- end
-
- def self.providers
- Gitlab::LDAP::Config.providers
- end
-
- attr_accessor :provider, :ldap_user
-
- def initialize(provider)
- @provider = provider
- end
-
- def login(login, password)
- @ldap_user = adapter.bind_as(
- filter: user_filter(login),
- size: 1,
- password: password
- )
- end
-
- def adapter
- OmniAuth::LDAP::Adaptor.new(config.omniauth_options)
- end
-
- def config
- Gitlab::LDAP::Config.new(provider)
- end
-
- def user_filter(login)
- filter = Net::LDAP::Filter.equals(config.uid, login)
-
- # Apply LDAP user filter if present
- if config.user_filter.present?
- filter = Net::LDAP::Filter.join(filter, config.constructed_user_filter)
- end
-
- filter
- end
-
- def user
- return nil unless ldap_user
-
- Gitlab::LDAP::User.find_by_uid_and_provider(ldap_user.dn, provider)
- end
- end
- end
-end
diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb
deleted file mode 100644
index c8f19cd52d5..00000000000
--- a/lib/gitlab/ldap/config.rb
+++ /dev/null
@@ -1,230 +0,0 @@
-# Load a specific server configuration
-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?
- Gitlab.config.ldap.enabled
- end
-
- def self.servers
- 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
-
- def self.valid_provider?(provider)
- providers.include?(provider)
- end
-
- def self.invalid_provider(provider)
- raise "Unknown provider (#{provider}). Available providers: #{providers}"
- end
-
- def initialize(provider)
- if self.class.valid_provider?(provider)
- @provider = provider
- else
- self.class.invalid_provider(provider)
- end
- @options = config_for(@provider) # Use @provider, not provider
- end
-
- def enabled?
- base_config.enabled
- end
-
- def adapter_options
- opts = base_options.merge(
- encryption: encryption_options
- )
-
- opts.merge!(auth_options) if has_auth?
-
- opts
- end
-
- def omniauth_options
- opts = base_options.merge(
- base: base,
- encryption: options['encryption'],
- filter: omniauth_user_filter,
- name_proc: name_proc,
- disable_verify_certificates: !options['verify_certificates']
- )
-
- if has_auth?
- opts.merge!(
- bind_dn: options['bind_dn'],
- password: options['password']
- )
- 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 base
- options['base']
- end
-
- def uid
- options['uid']
- end
-
- def sync_ssh_keys?
- sync_ssh_keys.present?
- end
-
- # The LDAP attribute in which the ssh keys are stored
- def sync_ssh_keys
- options['sync_ssh_keys']
- end
-
- def user_filter
- options['user_filter']
- end
-
- def constructed_user_filter
- @constructed_user_filter ||= Net::LDAP::Filter.construct(user_filter)
- end
-
- def group_base
- options['group_base']
- end
-
- def admin_group
- options['admin_group']
- end
-
- def active_directory
- options['active_directory']
- end
-
- def block_auto_created_users
- options['block_auto_created_users']
- end
-
- def attributes
- default_attributes.merge(options['attributes'])
- end
-
- def timeout
- options['timeout'].to_i
- end
-
- def has_auth?
- options['password'] || options['bind_dn']
- end
-
- def allow_username_or_email_login
- options['allow_username_or_email_login']
- end
-
- def name_proc
- if allow_username_or_email_login
- proc { |name| name.gsub(/@.*\z/, '') }
- else
- proc { |name| name }
- end
- end
-
- def default_attributes
- {
- 'username' => %w(uid userid sAMAccountName),
- 'email' => %w(mail email userPrincipalName),
- 'name' => 'cn',
- 'first_name' => 'givenName',
- 'last_name' => 'sn'
- }
- end
-
- protected
-
- def base_options
- {
- host: options['host'],
- port: options['port']
- }
- end
-
- def base_config
- Gitlab.config.ldap
- end
-
- def config_for(provider)
- base_config.servers.values.find { |server| server['provider_name'] == provider }
- 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
- {
- auth: {
- method: :simple,
- username: options['bind_dn'],
- password: options['password']
- }
- }
- end
-
- def omniauth_user_filter
- uid_filter = Net::LDAP::Filter.eq(uid, '%{username}')
-
- if user_filter.present?
- Net::LDAP::Filter.join(uid_filter, constructed_user_filter).to_s
- else
- uid_filter.to_s
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/ldap/dn.rb b/lib/gitlab/ldap/dn.rb
deleted file mode 100644
index d6142dc6549..00000000000
--- a/lib/gitlab/ldap/dn.rb
+++ /dev/null
@@ -1,301 +0,0 @@
-# -*- ruby encoding: utf-8 -*-
-
-# Based on the `ruby-net-ldap` gem's `Net::LDAP::DN`
-#
-# For our purposes, this class is used to normalize DNs in order to allow proper
-# comparison.
-#
-# E.g. DNs should be compared case-insensitively (in basically all LDAP
-# implementations or setups), therefore we downcase every DN.
-
-##
-# Objects of this class represent an LDAP DN ("Distinguished Name"). A DN
-# ("Distinguished Name") is a unique identifier for an entry within an LDAP
-# directory. It is made up of a number of other attributes strung together,
-# to identify the entry in the tree.
-#
-# Each attribute that makes up a DN needs to have its value escaped so that
-# the DN is valid. This class helps take care of that.
-#
-# A fully escaped DN needs to be unescaped when analysing its contents. This
-# class also helps take care of that.
-module Gitlab
- module LDAP
- class DN
- FormatError = Class.new(StandardError)
- MalformedError = Class.new(FormatError)
- UnsupportedError = Class.new(FormatError)
-
- def self.normalize_value(given_value)
- dummy_dn = "placeholder=#{given_value}"
- normalized_dn = new(*dummy_dn).to_normalized_s
- normalized_dn.sub(/\Aplaceholder=/, '')
- end
-
- ##
- # Initialize a DN, escaping as required. Pass in attributes in name/value
- # pairs. If there is a left over argument, it will be appended to the dn
- # without escaping (useful for a base string).
- #
- # Most uses of this class will be to escape a DN, rather than to parse it,
- # so storing the dn as an escaped String and parsing parts as required
- # with a state machine seems sensible.
- def initialize(*args)
- if args.length > 1
- initialize_array(args)
- else
- initialize_string(args[0])
- end
- end
-
- ##
- # Parse a DN into key value pairs using ASN from
- # http://tools.ietf.org/html/rfc2253 section 3.
- # rubocop:disable Metrics/AbcSize
- # rubocop:disable Metrics/CyclomaticComplexity
- # rubocop:disable Metrics/PerceivedComplexity
- def each_pair
- state = :key
- key = StringIO.new
- value = StringIO.new
- hex_buffer = ""
-
- @dn.each_char.with_index do |char, dn_index|
- case state
- when :key then
- case char
- when 'a'..'z', 'A'..'Z' then
- state = :key_normal
- key << char
- when '0'..'9' then
- state = :key_oid
- key << char
- when ' ' then state = :key
- else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"")
- end
- when :key_normal then
- case char
- when '=' then state = :value
- when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char
- else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"")
- end
- when :key_oid then
- case char
- when '=' then state = :value
- when '0'..'9', '.', ' ' then key << char
- else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"")
- end
- when :value then
- case char
- when '\\' then state = :value_normal_escape
- when '"' then state = :value_quoted
- when ' ' then state = :value
- when '#' then
- state = :value_hexstring
- value << char
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- else
- state = :value_normal
- value << char
- end
- when :value_normal then
- case char
- when '\\' then state = :value_normal_escape
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported")
- else value << char
- end
- when :value_normal_escape then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_normal_escape_hex
- hex_buffer = char
- else
- state = :value_normal
- value << char
- end
- when :value_normal_escape_hex then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_normal
- value << "#{hex_buffer}#{char}".to_i(16).chr
- else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"")
- end
- when :value_quoted then
- case char
- when '\\' then state = :value_quoted_escape
- when '"' then state = :value_end
- else value << char
- end
- when :value_quoted_escape then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_quoted_escape_hex
- hex_buffer = char
- else
- state = :value_quoted
- value << char
- end
- when :value_quoted_escape_hex then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_quoted
- value << "#{hex_buffer}#{char}".to_i(16).chr
- else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"")
- end
- when :value_hexstring then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_hexstring_hex
- value << char
- when ' ' then state = :value_end
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"")
- end
- when :value_hexstring_hex then
- case char
- when '0'..'9', 'a'..'f', 'A'..'F' then
- state = :value_hexstring
- value << char
- else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"")
- end
- when :value_end then
- case char
- when ' ' then state = :value_end
- when ',' then
- state = :key
- yield key.string.strip, rstrip_except_escaped(value.string, dn_index)
- key = StringIO.new
- value = StringIO.new
- else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"")
- end
- else raise "Fell out of state machine"
- end
- end
-
- # Last pair
- raise(MalformedError, 'DN string ended unexpectedly') unless
- [:value, :value_normal, :value_hexstring, :value_end].include? state
-
- yield key.string.strip, rstrip_except_escaped(value.string, @dn.length)
- end
-
- def rstrip_except_escaped(str, dn_index)
- str_ends_with_whitespace = str.match(/\s\z/)
-
- if str_ends_with_whitespace
- dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/)
-
- if dn_part_ends_with_escaped_whitespace
- dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1]
- num_chars_to_remove = dn_part_rwhitespace.length - 1
- str = str[0, str.length - num_chars_to_remove]
- else
- str.rstrip!
- end
- end
-
- str
- end
-
- ##
- # Returns the DN as an array in the form expected by the constructor.
- def to_a
- a = []
- self.each_pair { |key, value| a << key << value } unless @dn.empty?
- a
- end
-
- ##
- # Return the DN as an escaped string.
- def to_s
- @dn
- end
-
- ##
- # Return the DN as an escaped and normalized string.
- def to_normalized_s
- self.class.new(*to_a).to_s.downcase
- end
-
- # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions
- # for DN values. All of the following must be escaped in any normal string
- # using a single backslash ('\') as escape. The space character is left
- # out here because in a "normalized" string, spaces should only be escaped
- # if necessary (i.e. leading or trailing space).
- NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze
-
- # The following must be represented as escaped hex
- HEX_ESCAPES = {
- "\n" => '\0a',
- "\r" => '\0d'
- }.freeze
-
- # Compiled character class regexp using the keys from the above hash, and
- # checking for a space or # at the start, or space at the end, of the
- # string.
- ESCAPE_RE = Regexp.new("(^ |^#| $|[" +
- NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join +
- "])")
-
- HEX_ESCAPE_RE = Regexp.new("([" +
- HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join +
- "])")
-
- ##
- # Escape a string for use in a DN value
- def self.escape(string)
- escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char }
- escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] }
- end
-
- private
-
- def initialize_array(args)
- buffer = StringIO.new
-
- args.each_with_index do |arg, index|
- if index.even? # key
- buffer << "," if index > 0
- buffer << arg
- else # value
- buffer << "="
- buffer << self.class.escape(arg)
- end
- end
-
- @dn = buffer.string
- end
-
- def initialize_string(arg)
- @dn = arg.to_s
- end
-
- ##
- # Proxy all other requests to the string object, because a DN is mainly
- # used within the library as a string
- # rubocop:disable GitlabSecurity/PublicSend
- def method_missing(method, *args, &block)
- @dn.send(method, *args, &block)
- end
-
- ##
- # Redefined to be consistent with redefined `method_missing` behavior
- def respond_to?(sym, include_private = false)
- @dn.respond_to?(sym, include_private)
- end
- end
- end
-end
diff --git a/lib/gitlab/ldap/person.rb b/lib/gitlab/ldap/person.rb
deleted file mode 100644
index 38d7a9ba2f5..00000000000
--- a/lib/gitlab/ldap/person.rb
+++ /dev/null
@@ -1,109 +0,0 @@
-module Gitlab
- module LDAP
- class Person
- # Active Directory-specific LDAP filter that checks if bit 2 of the
- # userAccountControl attribute is set.
- # Source: http://ctogonewild.com/2009/09/03/bitmask-searches-in-ldap/
- AD_USER_DISABLED = Net::LDAP::Filter.ex("userAccountControl:1.2.840.113556.1.4.803", "2")
-
- attr_accessor :entry, :provider
-
- def self.find_by_uid(uid, adapter)
- uid = Net::LDAP::Filter.escape(uid)
- adapter.user(adapter.config.uid, uid)
- end
-
- def self.find_by_dn(dn, adapter)
- adapter.user('dn', dn)
- end
-
- def self.find_by_email(email, adapter)
- email_fields = adapter.config.attributes['email']
-
- adapter.user(email_fields, email)
- end
-
- def self.disabled_via_active_directory?(dn, adapter)
- adapter.dn_matches_filter?(dn, AD_USER_DISABLED)
- end
-
- def self.ldap_attributes(config)
- [
- 'dn', # Used in `dn`
- config.uid, # Used in `uid`
- *config.attributes['name'], # Used in `name`
- *config.attributes['email'] # Used in `email`
- ]
- end
-
- def self.normalize_dn(dn)
- ::Gitlab::LDAP::DN.new(dn).to_normalized_s
- rescue ::Gitlab::LDAP::DN::FormatError => e
- Rails.logger.info("Returning original DN \"#{dn}\" due to error during normalization attempt: #{e.message}")
-
- dn
- end
-
- # Returns the UID in a normalized form.
- #
- # 1. Excess spaces are stripped
- # 2. The string is downcased (for case-insensitivity)
- def self.normalize_uid(uid)
- ::Gitlab::LDAP::DN.normalize_value(uid)
- rescue ::Gitlab::LDAP::DN::FormatError => e
- Rails.logger.info("Returning original UID \"#{uid}\" due to error during normalization attempt: #{e.message}")
-
- uid
- end
-
- def initialize(entry, provider)
- Rails.logger.debug { "Instantiating #{self.class.name} with LDIF:\n#{entry.to_ldif}" }
- @entry = entry
- @provider = provider
- end
-
- def name
- attribute_value(:name).first
- end
-
- def uid
- entry.public_send(config.uid).first # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def username
- uid
- end
-
- def email
- attribute_value(:email)
- end
-
- def dn
- self.class.normalize_dn(entry.dn)
- end
-
- private
-
- def entry
- @entry
- end
-
- def config
- @config ||= Gitlab::LDAP::Config.new(provider)
- end
-
- # Using the LDAP attributes configuration, find and return the first
- # attribute with a value. For example, by default, when given 'email',
- # this method looks for 'mail', 'email' and 'userPrincipalName' and
- # returns the first with a value.
- def attribute_value(attribute)
- attributes = Array(config.attributes[attribute.to_s])
- selected_attr = attributes.find { |attr| entry.respond_to?(attr) }
-
- return nil unless selected_attr
-
- entry.public_send(selected_attr) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
- end
-end
diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb
deleted file mode 100644
index 84ee94e38e4..00000000000
--- a/lib/gitlab/ldap/user.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# LDAP extension for User model
-#
-# * Find or create user from omniauth.auth data
-# * Links LDAP account with existing user
-# * Auth LDAP user with login and password
-#
-module Gitlab
- module LDAP
- class User < Gitlab::OAuth::User
- class << self
- def find_by_uid_and_provider(uid, provider)
- identity = ::Identity.with_extern_uid(provider, uid).take
-
- identity && identity.user
- end
- end
-
- def save
- super('LDAP')
- end
-
- # instance methods
- def find_user
- find_by_uid_and_provider || find_by_email || build_new_user
- end
-
- def find_by_uid_and_provider
- self.class.find_by_uid_and_provider(auth_hash.uid, auth_hash.provider)
- end
-
- def changed?
- gl_user.changed? || gl_user.identities.any?(&:changed?)
- end
-
- def block_after_signup?
- ldap_config.block_auto_created_users
- end
-
- def allowed?
- Gitlab::LDAP::Access.allowed?(gl_user)
- end
-
- def ldap_config
- Gitlab::LDAP::Config.new(auth_hash.provider)
- end
-
- def auth_hash=(auth_hash)
- @auth_hash = Gitlab::LDAP::AuthHash.new(auth_hash)
- end
- end
- end
-end
diff --git a/lib/gitlab/legacy_github_import/client.rb b/lib/gitlab/legacy_github_import/client.rb
index 53c910d44bd..d8ed0ebca9d 100644
--- a/lib/gitlab/legacy_github_import/client.rb
+++ b/lib/gitlab/legacy_github_import/client.rb
@@ -83,7 +83,7 @@ module Gitlab
end
def config
- Gitlab.config.omniauth.providers.find { |provider| provider.name == "github" }
+ Gitlab::Auth::OAuth::Provider.config_for('github')
end
def github_options
diff --git a/lib/gitlab/legacy_github_import/importer.rb b/lib/gitlab/legacy_github_import/importer.rb
index 0526ef9eb13..7edd0ad2033 100644
--- a/lib/gitlab/legacy_github_import/importer.rb
+++ b/lib/gitlab/legacy_github_import/importer.rb
@@ -259,7 +259,7 @@ module Gitlab
def import_wiki
unless project.wiki.repository_exists?
wiki = WikiFormatter.new(project)
- gitlab_shell.import_repository(project.repository_storage_path, wiki.disk_path, wiki.import_url)
+ gitlab_shell.import_repository(project.repository_storage, wiki.disk_path, wiki.import_url)
end
rescue Gitlab::Shell::Error => e
# GitHub error message when the wiki repo has not been created,
diff --git a/lib/gitlab/legacy_github_import/project_creator.rb b/lib/gitlab/legacy_github_import/project_creator.rb
index 41e7eac4d08..3ce245a8050 100644
--- a/lib/gitlab/legacy_github_import/project_creator.rb
+++ b/lib/gitlab/legacy_github_import/project_creator.rb
@@ -1,8 +1,6 @@
module Gitlab
module LegacyGithubImport
class ProjectCreator
- include Gitlab::CurrentSettings
-
attr_reader :repo, :name, :namespace, :current_user, :session_data, :type
def initialize(repo, name, namespace, current_user, session_data, type: 'github')
@@ -14,9 +12,8 @@ module Gitlab
@type = type
end
- def execute
- ::Projects::CreateService.new(
- current_user,
+ def execute(extra_attrs = {})
+ attrs = {
name: name,
path: name,
description: repo.description,
@@ -26,7 +23,9 @@ module Gitlab
import_source: repo.full_name,
import_url: import_url,
skip_wiki: skip_wiki
- ).execute
+ }.merge!(extra_attrs)
+
+ ::Projects::CreateService.new(current_user, attrs).execute
end
private
@@ -36,7 +35,7 @@ module Gitlab
end
def visibility_level
- repo.private ? Gitlab::VisibilityLevel::PRIVATE : current_application_settings.default_project_visibility
+ repo.private ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::CurrentSettings.default_project_visibility
end
#
diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb
index 4779755bb22..7d63ca5627d 100644
--- a/lib/gitlab/metrics.rb
+++ b/lib/gitlab/metrics.rb
@@ -1,7 +1,7 @@
module Gitlab
module Metrics
- extend Gitlab::Metrics::InfluxDb
- extend Gitlab::Metrics::Prometheus
+ include Gitlab::Metrics::InfluxDb
+ include Gitlab::Metrics::Prometheus
def self.enabled?
influx_metrics_enabled? || prometheus_metrics_enabled?
diff --git a/lib/gitlab/metrics/influx_db.rb b/lib/gitlab/metrics/influx_db.rb
index 877cebf6786..66f30e3b397 100644
--- a/lib/gitlab/metrics/influx_db.rb
+++ b/lib/gitlab/metrics/influx_db.rb
@@ -1,178 +1,187 @@
module Gitlab
module Metrics
module InfluxDb
- include Gitlab::CurrentSettings
- extend self
+ extend ActiveSupport::Concern
+ include Gitlab::Metrics::Methods
+
+ EXECUTION_MEASUREMENT_BUCKETS = [0.001, 0.01, 0.1, 1].freeze
MUTEX = Mutex.new
private_constant :MUTEX
- def influx_metrics_enabled?
- settings[:enabled] || false
- end
+ class_methods do
+ def influx_metrics_enabled?
+ settings[:enabled] || false
+ end
- # Prometheus histogram buckets used for arbitrary code measurements
- EXECUTION_MEASUREMENT_BUCKETS = [0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1].freeze
- 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
+ # Prometheus histogram buckets used for arbitrary code measurements
+
+ def settings
+ @settings ||= begin
+ current_settings = Gitlab::CurrentSettings.current_application_settings
+
+ {
+ enabled: current_settings[:metrics_enabled],
+ pool_size: current_settings[:metrics_pool_size],
+ timeout: current_settings[:metrics_timeout],
+ method_call_threshold: current_settings[:metrics_method_call_threshold],
+ host: current_settings[:metrics_host],
+ port: current_settings[:metrics_port],
+ sample_interval: current_settings[:metrics_sample_interval] || 15,
+ packet_size: current_settings[:metrics_packet_size] || 1
+ }
+ end
+ end
- def mri?
- RUBY_ENGINE == 'ruby'
- 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.
- # in milliseconds
- @method_call_threshold ||= settings[:method_call_threshold]
- 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.
+ # in milliseconds
+ @method_call_threshold ||= settings[:method_call_threshold]
+ end
- def submit_metrics(metrics)
- prepared = prepare_metrics(metrics)
+ 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
+ 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
- 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
+ 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)
+ 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
- new_hash
+ def escape_value(value)
+ value.to_s.gsub('=', '\\=')
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)
+ cpu_time = cpu_stop - cpu_start
+
+ real_duration_seconds = fetch_histogram("gitlab_#{name}_real_duration_seconds".to_sym) do
+ docstring "Measure #{name}"
+ base_labels Transaction::BASE_LABELS
+ buckets EXECUTION_MEASUREMENT_BUCKETS
+ 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)
- cpu_time = cpu_stop - cpu_start
-
- Gitlab::Metrics.histogram("gitlab_#{name}_real_duration_seconds".to_sym,
- "Measure #{name}",
- Transaction::BASE_LABELS,
- EXECUTION_MEASUREMENT_BUCKETS)
- .observe(trans.labels, real_time)
-
- Gitlab::Metrics.histogram("gitlab_#{name}_cpu_duration_seconds".to_sym,
- "Measure #{name}",
- Transaction::BASE_LABELS,
- EXECUTION_MEASUREMENT_BUCKETS)
- .observe(trans.labels, cpu_time / 1000.0)
-
- # InfluxDB stores the _real_time time values as milliseconds
- trans.increment("#{name}_real_time", real_time * 1000, false)
- trans.increment("#{name}_cpu_time", cpu_time, false)
- trans.increment("#{name}_call_count", 1, false)
-
- retval
- end
+ real_duration_seconds.observe(trans.labels, real_time)
- # Sets the action of the current transaction (if any)
- #
- # action - The name of the action.
- def action=(action)
- trans = current_transaction
+ cpu_duration_seconds = fetch_histogram("gitlab_#{name}_cpu_duration_seconds".to_sym) do
+ docstring "Measure #{name}"
+ base_labels Transaction::BASE_LABELS
+ buckets EXECUTION_MEASUREMENT_BUCKETS
+ with_feature "prometheus_metrics_measure_#{name}_cpu_duration"
+ end
+ cpu_duration_seconds.observe(trans.labels, cpu_time)
- trans&.action = action
- end
+ # InfluxDB stores the _real_time and _cpu_time time values as milliseconds
+ trans.increment("#{name}_real_time", real_time.in_milliseconds, false)
+ trans.increment("#{name}_cpu_time", cpu_time.in_milliseconds, false)
+ trans.increment("#{name}_call_count", 1, false)
- # Tracks an event.
- #
- # See `Gitlab::Metrics::Transaction#add_event` for more details.
- def add_event(*args)
- trans = current_transaction
+ retval
+ end
- trans&.add_event(*args)
- end
+ # Sets the action of the current transaction (if any)
+ #
+ # action - The name of the action.
+ def action=(action)
+ trans = current_transaction
- # Returns the prefix to use for the name of a series.
- def series_prefix
- @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
- end
+ trans&.action = action
+ end
- # Allow access from other metrics related middlewares
- def current_transaction
- Transaction.current
- 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.
- # rubocop:disable Gitlab/ModuleWithInstanceVariables
- 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 })
+ # When enabled this should be set before being used as the usual pattern
+ # "@foo ||= bar" is _not_ thread-safe.
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ 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
- @pool
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
- # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
end
end
diff --git a/lib/gitlab/metrics/method_call.rb b/lib/gitlab/metrics/method_call.rb
index 329b07af5db..b11520a79bb 100644
--- a/lib/gitlab/metrics/method_call.rb
+++ b/lib/gitlab/metrics/method_call.rb
@@ -4,26 +4,15 @@ module Gitlab
module Metrics
# Class for tracking timing information about method calls
class MethodCall
- @@measurement_enabled_cache = Concurrent::AtomicBoolean.new(false)
- @@measurement_enabled_cache_expires_at = Concurrent::AtomicFixnum.new(Time.now.to_i)
- MUTEX = Mutex.new
+ include Gitlab::Metrics::Methods
BASE_LABELS = { module: nil, method: nil }.freeze
attr_reader :real_time, :cpu_time, :call_count, :labels
- def self.call_duration_histogram
- return @call_duration_histogram if @call_duration_histogram
-
- MUTEX.synchronize do
- @call_duration_histogram ||= Gitlab::Metrics.histogram(
- :gitlab_method_call_duration_seconds,
- 'Method calls real duration',
- Transaction::BASE_LABELS.merge(BASE_LABELS),
- [0.01, 0.05, 0.1, 0.5, 1])
- end
- end
-
- def self.measurement_enabled_cache_expires_at
- @@measurement_enabled_cache_expires_at
+ define_histogram :gitlab_method_call_duration_seconds do
+ docstring 'Method calls real duration'
+ base_labels Transaction::BASE_LABELS.merge(BASE_LABELS)
+ buckets [0.01, 0.05, 0.1, 0.5, 1]
+ with_feature :prometheus_metrics_method_instrumentation
end
# name - The full name of the method (including namespace) such as
@@ -53,8 +42,8 @@ module Gitlab
@cpu_time += cpu_time
@call_count += 1
- if call_measurement_enabled? && above_threshold?
- self.class.call_duration_histogram.observe(@transaction.labels.merge(labels), real_time)
+ if above_threshold?
+ self.class.gitlab_method_call_duration_seconds.observe(@transaction.labels.merge(labels), real_time)
end
retval
@@ -78,17 +67,6 @@ module Gitlab
def above_threshold?
real_time.in_milliseconds >= Metrics.method_call_threshold
end
-
- def call_measurement_enabled?
- expires_at = @@measurement_enabled_cache_expires_at.value
- if expires_at < Time.now.to_i
- if @@measurement_enabled_cache_expires_at.compare_and_set(expires_at, 1.minute.from_now.to_i)
- @@measurement_enabled_cache.value = Feature.get(:prometheus_metrics_method_instrumentation).enabled?
- end
- end
-
- @@measurement_enabled_cache.value
- end
end
end
end
diff --git a/lib/gitlab/metrics/methods.rb b/lib/gitlab/metrics/methods.rb
new file mode 100644
index 00000000000..f79eb0cd1bf
--- /dev/null
+++ b/lib/gitlab/metrics/methods.rb
@@ -0,0 +1,129 @@
+# rubocop:disable Style/ClassVars
+
+module Gitlab
+ module Metrics
+ module Methods
+ extend ActiveSupport::Concern
+
+ included do
+ @@_metric_provider_mutex ||= Mutex.new
+ @@_metrics_provider_cache = {}
+ end
+
+ class_methods do
+ def reload_metric!(name)
+ @@_metrics_provider_cache.delete(name)
+ end
+
+ private
+
+ def define_metric(type, name, opts = {}, &block)
+ if respond_to?(name)
+ raise ArgumentError, "method #{name} already exists"
+ end
+
+ define_singleton_method(name) do
+ # inlining fetch_metric method to avoid method call overhead when instrumenting hot spots
+ @@_metrics_provider_cache[name] || init_metric(type, name, opts, &block)
+ end
+ end
+
+ def fetch_metric(type, name, opts = {}, &block)
+ @@_metrics_provider_cache[name] || init_metric(type, name, opts, &block)
+ end
+
+ def init_metric(type, name, opts = {}, &block)
+ options = MetricOptions.new(opts)
+ options.evaluate(&block)
+
+ if disabled_by_feature(options)
+ synchronized_cache_fill(name) { NullMetric.instance }
+ else
+ synchronized_cache_fill(name) { build_metric!(type, name, options) }
+ end
+ end
+
+ def synchronized_cache_fill(key)
+ @@_metric_provider_mutex.synchronize do
+ @@_metrics_provider_cache[key] ||= yield
+ end
+ end
+
+ def disabled_by_feature(options)
+ options.with_feature && !::Feature.get(options.with_feature).enabled?
+ end
+
+ def build_metric!(type, name, options)
+ case type
+ when :gauge
+ Gitlab::Metrics.gauge(name, options.docstring, options.base_labels, options.multiprocess_mode)
+ when :counter
+ Gitlab::Metrics.counter(name, options.docstring, options.base_labels)
+ when :histogram
+ Gitlab::Metrics.histogram(name, options.docstring, options.base_labels, options.buckets)
+ when :summary
+ raise NotImplementedError, "summary metrics are not currently supported"
+ else
+ raise ArgumentError, "uknown metric type #{type}"
+ end
+ end
+
+ # Fetch and/or initialize counter metric
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def fetch_counter(name, opts = {}, &block)
+ fetch_metric(:counter, name, opts, &block)
+ end
+
+ # Fetch and/or initialize gauge metric
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def fetch_gauge(name, opts = {}, &block)
+ fetch_metric(:gauge, name, opts, &block)
+ end
+
+ # Fetch and/or initialize histogram metric
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def fetch_histogram(name, opts = {}, &block)
+ fetch_metric(:histogram, name, opts, &block)
+ end
+
+ # Fetch and/or initialize summary metric
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def fetch_summary(name, opts = {}, &block)
+ fetch_metric(:summary, name, opts, &block)
+ end
+
+ # Define metric accessor method for a Counter
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def define_counter(name, opts = {}, &block)
+ define_metric(:counter, name, opts, &block)
+ end
+
+ # Define metric accessor method for a Gauge
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def define_gauge(name, opts = {}, &block)
+ define_metric(:gauge, name, opts, &block)
+ end
+
+ # Define metric accessor method for a Histogram
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def define_histogram(name, opts = {}, &block)
+ define_metric(:histogram, name, opts, &block)
+ end
+
+ # Define metric accessor method for a Summary
+ # @param [Symbol] name
+ # @param [Hash] opts
+ def define_summary(name, opts = {}, &block)
+ define_metric(:summary, name, opts, &block)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/methods/metric_options.rb b/lib/gitlab/metrics/methods/metric_options.rb
new file mode 100644
index 00000000000..70e122d4e15
--- /dev/null
+++ b/lib/gitlab/metrics/methods/metric_options.rb
@@ -0,0 +1,61 @@
+module Gitlab
+ module Metrics
+ module Methods
+ class MetricOptions
+ SMALL_NETWORK_BUCKETS = [0.005, 0.01, 0.1, 1, 10].freeze
+
+ def initialize(options = {})
+ @multiprocess_mode = options[:multiprocess_mode] || :all
+ @buckets = options[:buckets] || SMALL_NETWORK_BUCKETS
+ @base_labels = options[:base_labels] || {}
+ @docstring = options[:docstring]
+ @with_feature = options[:with_feature]
+ end
+
+ # Documentation describing metric in metrics endpoint '/-/metrics'
+ def docstring(docstring = nil)
+ @docstring = docstring unless docstring.nil?
+
+ @docstring
+ end
+
+ # Gauge aggregation mode for multiprocess metrics
+ # - :all (default) returns each gauge for every process
+ # - :livesum all process'es gauges summed up
+ # - :max maximum value of per process gauges
+ # - :min minimum value of per process gauges
+ def multiprocess_mode(mode = nil)
+ @multiprocess_mode = mode unless mode.nil?
+
+ @multiprocess_mode
+ end
+
+ # Measurement buckets for histograms
+ def buckets(buckets = nil)
+ @buckets = buckets unless buckets.nil?
+
+ @buckets
+ end
+
+ # Base labels are merged with per metric labels
+ def base_labels(base_labels = nil)
+ @base_labels = base_labels unless base_labels.nil?
+
+ @base_labels
+ end
+
+ # Use feature toggle to control whether certain metric is enabled/disabled
+ def with_feature(name = nil)
+ @with_feature = name unless name.nil?
+
+ @with_feature
+ end
+
+ def evaluate(&block)
+ instance_eval(&block) if block_given?
+ self
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/null_metric.rb b/lib/gitlab/metrics/null_metric.rb
index 3b5a2907195..aabada5c21a 100644
--- a/lib/gitlab/metrics/null_metric.rb
+++ b/lib/gitlab/metrics/null_metric.rb
@@ -2,6 +2,8 @@ module Gitlab
module Metrics
# Mocks ::Prometheus::Client::Metric and all derived metrics
class NullMetric
+ include Singleton
+
def method_missing(name, *args, &block)
nil
end
diff --git a/lib/gitlab/metrics/prometheus.rb b/lib/gitlab/metrics/prometheus.rb
index b0b8e8436db..d12ba0ec176 100644
--- a/lib/gitlab/metrics/prometheus.rb
+++ b/lib/gitlab/metrics/prometheus.rb
@@ -3,73 +3,76 @@ require 'prometheus/client'
module Gitlab
module Metrics
module Prometheus
- include Gitlab::CurrentSettings
- include Gitlab::Utils::StrongMemoize
+ extend ActiveSupport::Concern
REGISTRY_MUTEX = Mutex.new
PROVIDER_MUTEX = Mutex.new
- def metrics_folder_present?
- multiprocess_files_dir = ::Prometheus::Client.configuration.multiprocess_files_dir
+ class_methods do
+ include Gitlab::Utils::StrongMemoize
- multiprocess_files_dir &&
- ::Dir.exist?(multiprocess_files_dir) &&
- ::File.writable?(multiprocess_files_dir)
- end
+ def metrics_folder_present?
+ multiprocess_files_dir = ::Prometheus::Client.configuration.multiprocess_files_dir
- def prometheus_metrics_enabled?
- strong_memoize(:prometheus_metrics_enabled) do
- prometheus_metrics_enabled_unmemoized
+ multiprocess_files_dir &&
+ ::Dir.exist?(multiprocess_files_dir) &&
+ ::File.writable?(multiprocess_files_dir)
+ end
+
+ def prometheus_metrics_enabled?
+ strong_memoize(:prometheus_metrics_enabled) do
+ prometheus_metrics_enabled_unmemoized
+ end
end
- end
- def registry
- strong_memoize(:registry) do
- REGISTRY_MUTEX.synchronize do
- strong_memoize(:registry) do
- ::Prometheus::Client.registry
+ def registry
+ strong_memoize(:registry) do
+ REGISTRY_MUTEX.synchronize do
+ strong_memoize(:registry) do
+ ::Prometheus::Client.registry
+ end
end
end
end
- end
- def counter(name, docstring, base_labels = {})
- safe_provide_metric(:counter, name, docstring, base_labels)
- end
+ def counter(name, docstring, base_labels = {})
+ safe_provide_metric(:counter, name, docstring, base_labels)
+ end
- def summary(name, docstring, base_labels = {})
- safe_provide_metric(:summary, name, docstring, base_labels)
- end
+ def summary(name, docstring, base_labels = {})
+ safe_provide_metric(:summary, name, docstring, base_labels)
+ end
- def gauge(name, docstring, base_labels = {}, multiprocess_mode = :all)
- safe_provide_metric(:gauge, name, docstring, base_labels, multiprocess_mode)
- end
+ def gauge(name, docstring, base_labels = {}, multiprocess_mode = :all)
+ safe_provide_metric(:gauge, name, docstring, base_labels, multiprocess_mode)
+ end
- def histogram(name, docstring, base_labels = {}, buckets = ::Prometheus::Client::Histogram::DEFAULT_BUCKETS)
- safe_provide_metric(:histogram, name, docstring, base_labels, buckets)
- end
+ def histogram(name, docstring, base_labels = {}, buckets = ::Prometheus::Client::Histogram::DEFAULT_BUCKETS)
+ safe_provide_metric(:histogram, name, docstring, base_labels, buckets)
+ end
- private
+ private
- def safe_provide_metric(method, name, *args)
- metric = provide_metric(name)
- return metric if metric
+ def safe_provide_metric(method, name, *args)
+ metric = provide_metric(name)
+ return metric if metric
- PROVIDER_MUTEX.synchronize do
- provide_metric(name) || registry.method(method).call(name, *args)
+ PROVIDER_MUTEX.synchronize do
+ provide_metric(name) || registry.method(method).call(name, *args)
+ end
end
- end
- def provide_metric(name)
- if prometheus_metrics_enabled?
- registry.get(name)
- else
- NullMetric.new
+ def provide_metric(name)
+ if prometheus_metrics_enabled?
+ registry.get(name)
+ else
+ NullMetric.instance
+ end
end
- end
- def prometheus_metrics_enabled_unmemoized
- metrics_folder_present? && current_application_settings[:prometheus_metrics_enabled] || false
+ def prometheus_metrics_enabled_unmemoized
+ metrics_folder_present? && Gitlab::CurrentSettings.prometheus_metrics_enabled || false
+ end
end
end
end
diff --git a/lib/gitlab/metrics/sidekiq_metrics_exporter.rb b/lib/gitlab/metrics/sidekiq_metrics_exporter.rb
index 5980a4ded2b..47b4af5d649 100644
--- a/lib/gitlab/metrics/sidekiq_metrics_exporter.rb
+++ b/lib/gitlab/metrics/sidekiq_metrics_exporter.rb
@@ -4,6 +4,8 @@ require 'prometheus/client/rack/exporter'
module Gitlab
module Metrics
class SidekiqMetricsExporter < Daemon
+ LOG_FILENAME = File.join(Rails.root, 'log', 'sidekiq_exporter.log')
+
def enabled?
Gitlab::Metrics.metrics_folder_present? && settings.enabled
end
@@ -17,13 +19,19 @@ module Gitlab
attr_reader :server
def start_working
- @server = ::WEBrick::HTTPServer.new(Port: settings.port, BindAddress: settings.address)
+ logger = WEBrick::Log.new(LOG_FILENAME)
+ access_log = [
+ [logger, WEBrick::AccessLog::COMBINED_LOG_FORMAT]
+ ]
+
+ @server = ::WEBrick::HTTPServer.new(Port: settings.port, BindAddress: settings.address,
+ Logger: logger, AccessLog: access_log)
server.mount "/", Rack::Handler::WEBrick, rack_app
server.start
end
def stop_working
- server.shutdown
+ server.shutdown if server
@server = nil
end
diff --git a/lib/gitlab/metrics/subscribers/action_view.rb b/lib/gitlab/metrics/subscribers/action_view.rb
index 3da474fc1ec..b600e8a2a50 100644
--- a/lib/gitlab/metrics/subscribers/action_view.rb
+++ b/lib/gitlab/metrics/subscribers/action_view.rb
@@ -3,6 +3,14 @@ module Gitlab
module Subscribers
# Class for tracking the rendering timings of views.
class ActionView < ActiveSupport::Subscriber
+ include Gitlab::Metrics::Methods
+ define_histogram :gitlab_view_rendering_duration_seconds do
+ docstring 'View rendering time'
+ base_labels Transaction::BASE_LABELS.merge({ path: nil })
+ buckets [0.001, 0.01, 0.1, 1, 10.0]
+ with_feature :prometheus_metrics_view_instrumentation
+ end
+
attach_to :action_view
SERIES = 'views'.freeze
@@ -15,30 +23,18 @@ module Gitlab
private
- def metric_view_rendering_duration_seconds
- @metric_view_rendering_duration_seconds ||= Gitlab::Metrics.histogram(
- :gitlab_view_rendering_duration_seconds,
- 'View rendering time',
- Transaction::BASE_LABELS.merge({ path: nil }),
- [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0]
- )
- end
-
def track(event)
values = values_for(event)
tags = tags_for(event)
- metric_view_rendering_duration_seconds.observe(
- current_transaction.labels.merge(tags),
- event.duration
- )
+ self.class.gitlab_view_rendering_duration_seconds.observe(current_transaction.labels.merge(tags), event.duration)
current_transaction.increment(:view_duration, event.duration)
current_transaction.add_metric(SERIES, values, tags)
end
def relative_path(path)
- path.gsub(/^#{Rails.root.to_s}\/?/, '')
+ path.gsub(%r{^#{Rails.root.to_s}/?}, '')
end
def values_for(event)
diff --git a/lib/gitlab/metrics/subscribers/active_record.rb b/lib/gitlab/metrics/subscribers/active_record.rb
index ead1acb8d44..4b3e8d0a6a0 100644
--- a/lib/gitlab/metrics/subscribers/active_record.rb
+++ b/lib/gitlab/metrics/subscribers/active_record.rb
@@ -3,12 +3,13 @@ module Gitlab
module Subscribers
# Class for tracking the total query duration of a transaction.
class ActiveRecord < ActiveSupport::Subscriber
+ include Gitlab::Metrics::Methods
attach_to :active_record
def sql(event)
return unless current_transaction
- metric_sql_duration_seconds.observe(current_transaction.labels, event.duration / 1000.0)
+ self.class.gitlab_sql_duration_seconds.observe(current_transaction.labels, event.duration / 1000.0)
current_transaction.increment(:sql_duration, event.duration, false)
current_transaction.increment(:sql_count, 1, false)
@@ -16,17 +17,14 @@ module Gitlab
private
- def current_transaction
- Transaction.current
+ define_histogram :gitlab_sql_duration_seconds do
+ docstring 'SQL time'
+ base_labels Transaction::BASE_LABELS
+ buckets [0.001, 0.01, 0.1, 1.0, 10.0]
end
- def metric_sql_duration_seconds
- @metric_sql_duration_seconds ||= Gitlab::Metrics.histogram(
- :gitlab_sql_duration_seconds,
- 'SQL time',
- Transaction::BASE_LABELS,
- [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0]
- )
+ def current_transaction
+ Transaction.current
end
end
end
diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb
index e7975c023a9..f3e48083c19 100644
--- a/lib/gitlab/metrics/transaction.rb
+++ b/lib/gitlab/metrics/transaction.rb
@@ -2,11 +2,12 @@ module Gitlab
module Metrics
# Class for storing metrics information of a single transaction.
class Transaction
+ include Gitlab::Metrics::Methods
+
# base labels shared among all transactions
BASE_LABELS = { controller: nil, action: nil }.freeze
THREAD_KEY = :_gitlab_metrics_transaction
- METRICS_MUTEX = Mutex.new
# The series to store events (e.g. Git pushes) in.
EVENT_SERIES = 'events'.freeze
@@ -54,8 +55,8 @@ module Gitlab
@memory_after = System.memory_usage
@finished_at = System.monotonic_time
- self.class.metric_transaction_duration_seconds.observe(labels, duration)
- self.class.metric_transaction_allocated_memory_bytes.observe(labels, allocated_memory * 1024.0)
+ self.class.gitlab_transaction_duration_seconds.observe(labels, duration)
+ self.class.gitlab_transaction_allocated_memory_bytes.observe(labels, allocated_memory * 1024.0)
Thread.current[THREAD_KEY] = nil
end
@@ -72,7 +73,7 @@ module Gitlab
# event_name - The name of the event (e.g. "git_push").
# tags - A set of tags to attach to the event.
def add_event(event_name, tags = {})
- self.class.metric_event_counter(event_name, tags).increment(tags.merge(labels))
+ self.class.transaction_metric(event_name, :counter, prefix: 'event_', use_feature_flag: true, tags: tags).increment(tags.merge(labels))
@metrics << Metric.new(EVENT_SERIES, { count: 1 }, tags.merge(event: event_name), :event)
end
@@ -86,12 +87,12 @@ module Gitlab
end
def increment(name, value, use_prometheus = true)
- self.class.metric_transaction_counter(name).increment(labels, value) if use_prometheus
+ self.class.transaction_metric(name, :counter).increment(labels, value) if use_prometheus
@values[name] += value
end
def set(name, value, use_prometheus = true)
- self.class.metric_transaction_gauge(name).set(labels, value) if use_prometheus
+ self.class.transaction_metric(name, :gauge).set(labels, value) if use_prometheus
@values[name] = value
end
@@ -136,64 +137,29 @@ module Gitlab
"#{labels[:controller]}##{labels[:action]}" if labels && !labels.empty?
end
- def self.metric_transaction_duration_seconds
- return @metric_transaction_duration_seconds if @metric_transaction_duration_seconds
-
- METRICS_MUTEX.synchronize do
- @metric_transaction_duration_seconds ||= Gitlab::Metrics.histogram(
- :gitlab_transaction_duration_seconds,
- 'Transaction duration',
- BASE_LABELS,
- [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0]
- )
- end
- end
-
- def self.metric_transaction_allocated_memory_bytes
- return @metric_transaction_allocated_memory_bytes if @metric_transaction_allocated_memory_bytes
-
- METRICS_MUTEX.synchronize do
- @metric_transaction_allocated_memory_bytes ||= Gitlab::Metrics.histogram(
- :gitlab_transaction_allocated_memory_bytes,
- 'Transaction allocated memory bytes',
- BASE_LABELS,
- [1000, 10000, 20000, 500000, 1000000, 2000000, 5000000, 10000000, 20000000, 100000000]
- )
- end
+ define_histogram :gitlab_transaction_duration_seconds do
+ docstring 'Transaction duration'
+ base_labels BASE_LABELS
+ buckets [0.001, 0.01, 0.1, 1.0, 10.0]
end
- def self.metric_event_counter(event_name, tags)
- return @metric_event_counters[event_name] if @metric_event_counters&.has_key?(event_name)
-
- METRICS_MUTEX.synchronize do
- @metric_event_counters ||= {}
- @metric_event_counters[event_name] ||= Gitlab::Metrics.counter(
- "gitlab_transaction_event_#{event_name}_total".to_sym,
- "Transaction event #{event_name} counter",
- tags.merge(BASE_LABELS)
- )
- end
- end
-
- def self.metric_transaction_counter(name)
- return @metric_transaction_counters[name] if @metric_transaction_counters&.has_key?(name)
-
- METRICS_MUTEX.synchronize do
- @metric_transaction_counters ||= {}
- @metric_transaction_counters[name] ||= Gitlab::Metrics.counter(
- "gitlab_transaction_#{name}_total".to_sym, "Transaction #{name} counter", BASE_LABELS
- )
- end
+ define_histogram :gitlab_transaction_allocated_memory_bytes do
+ docstring 'Transaction allocated memory bytes'
+ base_labels BASE_LABELS
+ buckets [100, 1000, 10000, 100000, 1000000, 10000000]
+ with_feature :prometheus_metrics_transaction_allocated_memory
end
- def self.metric_transaction_gauge(name)
- return @metric_transaction_gauges[name] if @metric_transaction_gauges&.has_key?(name)
+ def self.transaction_metric(name, type, prefix: nil, use_feature_flag: false, tags: {})
+ metric_name = "gitlab_transaction_#{prefix}#{name}_total".to_sym
+ fetch_metric(type, metric_name) do
+ docstring "Transaction #{prefix}#{name} #{type}"
+ base_labels tags.merge(BASE_LABELS)
+ with_feature "prometheus_transaction_#{prefix}#{name}_total".to_sym if use_feature_flag
- METRICS_MUTEX.synchronize do
- @metric_transaction_gauges ||= {}
- @metric_transaction_gauges[name] ||= Gitlab::Metrics.gauge(
- "gitlab_transaction_#{name}".to_sym, "Transaction gauge #{name}", BASE_LABELS, :livesum
- )
+ if type == :gauge
+ multiprocess_mode :livesum
+ end
end
end
end
diff --git a/lib/gitlab/middleware/go.rb b/lib/gitlab/middleware/go.rb
index c6a56277922..1fd8f147b44 100644
--- a/lib/gitlab/middleware/go.rb
+++ b/lib/gitlab/middleware/go.rb
@@ -4,7 +4,6 @@ module Gitlab
module Middleware
class Go
include ActionView::Helpers::TagHelper
- include Gitlab::CurrentSettings
PROJECT_PATH_REGEX = %r{\A(#{Gitlab::PathRegex.full_namespace_route_regex}/#{Gitlab::PathRegex.project_route_regex})/}.freeze
@@ -42,7 +41,7 @@ module Gitlab
project_url = URI.join(config.gitlab.url, path)
import_prefix = strip_url(project_url.to_s)
- repository_url = if current_application_settings.enabled_git_access_protocol == 'ssh'
+ repository_url = if Gitlab::CurrentSettings.enabled_git_access_protocol == 'ssh'
shell = config.gitlab_shell
port = ":#{shell.ssh_port}" unless shell.ssh_port == 22
"ssh://#{shell.ssh_user}@#{shell.ssh_host}#{port}/#{path}.git"
@@ -56,12 +55,12 @@ module Gitlab
end
def strip_url(url)
- url.gsub(/\Ahttps?:\/\//, '')
+ url.gsub(%r{\Ahttps?://}, '')
end
def project_path(request)
path_info = request.env["PATH_INFO"]
- path_info.sub!(/^\//, '')
+ path_info.sub!(%r{^/}, '')
project_path_match = "#{path_info}/".match(PROJECT_PATH_REGEX)
return unless project_path_match
@@ -115,7 +114,15 @@ module Gitlab
end
def current_user(request)
- request.env['warden']&.authenticate
+ authenticator = Gitlab::Auth::RequestAuthenticator.new(request)
+ user = authenticator.find_user_from_access_token || authenticator.find_user_from_warden
+
+ return unless user&.can?(:access_api)
+
+ # Right now, the `api` scope is the only one that should be able to determine private project existence.
+ return unless authenticator.valid_access_token?(scopes: [:api])
+
+ user
end
end
end
diff --git a/lib/gitlab/middleware/multipart.rb b/lib/gitlab/middleware/multipart.rb
index fee741b47be..a5f5d719cc1 100644
--- a/lib/gitlab/middleware/multipart.rb
+++ b/lib/gitlab/middleware/multipart.rb
@@ -42,11 +42,12 @@ module Gitlab
key, value = parsed_field.first
if value.nil?
- value = open_file(tmp_path)
+ value = open_file(tmp_path, @request.params["#{key}.name"])
@open_files << value
else
value = decorate_params_value(value, @request.params[key], tmp_path)
end
+
@request.update_param(key, value)
end
@@ -60,6 +61,7 @@ module Gitlab
unless path_hash.is_a?(Hash) && path_hash.count == 1
raise "invalid path: #{path_hash.inspect}"
end
+
path_key, path_value = path_hash.first
unless value_hash.is_a?(Hash) && value_hash[path_key]
@@ -68,7 +70,7 @@ module Gitlab
case path_value
when nil
- value_hash[path_key] = open_file(tmp_path)
+ value_hash[path_key] = open_file(tmp_path, value_hash.dig(path_key, '.name'))
@open_files << value_hash[path_key]
value_hash
when Hash
@@ -79,8 +81,8 @@ module Gitlab
end
end
- def open_file(path)
- ::UploadedFile.new(path, File.basename(path), 'application/octet-stream')
+ def open_file(path, name)
+ ::UploadedFile.new(path, filename: name || File.basename(path), content_type: 'application/octet-stream')
end
end
diff --git a/lib/gitlab/middleware/read_only.rb b/lib/gitlab/middleware/read_only.rb
index c26656704d7..7f63e39b3aa 100644
--- a/lib/gitlab/middleware/read_only.rb
+++ b/lib/gitlab/middleware/read_only.rb
@@ -1,90 +1,19 @@
module Gitlab
module Middleware
class ReadOnly
- DISALLOWED_METHODS = %w(POST PATCH PUT DELETE).freeze
- APPLICATION_JSON = 'application/json'.freeze
API_VERSIONS = (3..4)
+ def self.internal_routes
+ @internal_routes ||=
+ API_VERSIONS.map { |version| "api/v#{version}/internal" }
+ end
+
def initialize(app)
@app = app
- @whitelisted = internal_routes
end
def call(env)
- @env = env
- @route_hash = nil
-
- if disallowed_request? && Gitlab::Database.read_only?
- Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation')
- error_message = 'You cannot do writing operations on a read-only GitLab instance'
-
- if json_request?
- return [403, { 'Content-Type' => 'application/json' }, [{ 'message' => error_message }.to_json]]
- else
- rack_flash.alert = error_message
- rack_session['flash'] = rack_flash.to_session_value
-
- return [301, { 'Location' => last_visited_url }, []]
- end
- end
-
- @app.call(env)
- end
-
- private
-
- def internal_routes
- API_VERSIONS.flat_map { |version| "api/v#{version}/internal" }
- end
-
- def disallowed_request?
- DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) && !whitelisted_routes
- end
-
- def json_request?
- request.media_type == APPLICATION_JSON
- end
-
- def rack_flash
- @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session)
- end
-
- def rack_session
- @env['rack.session']
- end
-
- def request
- @env['rack.request'] ||= Rack::Request.new(@env)
- end
-
- def last_visited_url
- @env['HTTP_REFERER'] || rack_session['user_return_to'] || Gitlab::Routing.url_helpers.root_url
- end
-
- def route_hash
- @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {}
- end
-
- def whitelisted_routes
- grack_route || @whitelisted.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route
- end
-
- def sidekiq_route
- request.path.start_with?('/admin/sidekiq')
- end
-
- def grack_route
- # Calling route_hash may be expensive. Only do it if we think there's a possible match
- return false unless request.path.end_with?('.git/git-upload-pack')
-
- route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack'
- end
-
- def lfs_route
- # Calling route_hash may be expensive. Only do it if we think there's a possible match
- return false unless request.path.end_with?('/info/lfs/objects/batch')
-
- route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch'
+ ::Gitlab::Middleware::ReadOnly::Controller.new(@app, env).call
end
end
end
diff --git a/lib/gitlab/middleware/read_only/controller.rb b/lib/gitlab/middleware/read_only/controller.rb
new file mode 100644
index 00000000000..45b644e6510
--- /dev/null
+++ b/lib/gitlab/middleware/read_only/controller.rb
@@ -0,0 +1,86 @@
+module Gitlab
+ module Middleware
+ class ReadOnly
+ class Controller
+ DISALLOWED_METHODS = %w(POST PATCH PUT DELETE).freeze
+ APPLICATION_JSON = 'application/json'.freeze
+ ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'.freeze
+
+ def initialize(app, env)
+ @app = app
+ @env = env
+ end
+
+ def call
+ if disallowed_request? && Gitlab::Database.read_only?
+ Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation')
+
+ if json_request?
+ return [403, { 'Content-Type' => APPLICATION_JSON }, [{ 'message' => ERROR_MESSAGE }.to_json]]
+ else
+ rack_flash.alert = ERROR_MESSAGE
+ rack_session['flash'] = rack_flash.to_session_value
+
+ return [301, { 'Location' => last_visited_url }, []]
+ end
+ end
+
+ @app.call(@env)
+ end
+
+ private
+
+ def disallowed_request?
+ DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) &&
+ !whitelisted_routes
+ end
+
+ def json_request?
+ request.media_type == APPLICATION_JSON
+ end
+
+ def rack_flash
+ @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session)
+ end
+
+ def rack_session
+ @env['rack.session']
+ end
+
+ def request
+ @env['rack.request'] ||= Rack::Request.new(@env)
+ end
+
+ def last_visited_url
+ @env['HTTP_REFERER'] || rack_session['user_return_to'] || Gitlab::Routing.url_helpers.root_url
+ end
+
+ def route_hash
+ @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {}
+ end
+
+ def whitelisted_routes
+ grack_route || ReadOnly.internal_routes.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route
+ end
+
+ def sidekiq_route
+ request.path.start_with?('/admin/sidekiq')
+ end
+
+ def grack_route
+ # Calling route_hash may be expensive. Only do it if we think there's a possible match
+ return false unless request.path.end_with?('.git/git-upload-pack')
+
+ route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack'
+ end
+
+ def lfs_route
+ # Calling route_hash may be expensive. Only do it if we think there's a possible match
+ return false unless request.path.end_with?('/info/lfs/objects/batch')
+
+ route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/middleware/release_env.rb b/lib/gitlab/middleware/release_env.rb
new file mode 100644
index 00000000000..bfe8e113b5e
--- /dev/null
+++ b/lib/gitlab/middleware/release_env.rb
@@ -0,0 +1,14 @@
+module Gitlab # rubocop:disable Naming/FileName
+ module Middleware
+ # Some of middleware would hold env for no good reason even after the
+ # request had already been processed, and we could not garbage collect
+ # them due to this. Put this middleware as the first middleware so that
+ # it would clear the env after the request is done, allowing GC gets a
+ # chance to release memory for the last request.
+ ReleaseEnv = Struct.new(:app) do
+ def call(env)
+ app.call(env).tap { env.clear }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/middleware/static.rb b/lib/gitlab/middleware/static.rb
index 85ffa8aca68..aa1e9dc0fdb 100644
--- a/lib/gitlab/middleware/static.rb
+++ b/lib/gitlab/middleware/static.rb
@@ -1,7 +1,7 @@
module Gitlab
module Middleware
class Static < ActionDispatch::Static
- UPLOADS_REGEX = /\A\/uploads(\/|\z)/.freeze
+ UPLOADS_REGEX = %r{\A/uploads(/|\z)}.freeze
def call(env)
return @app.call(env) if env['PATH_INFO'] =~ UPLOADS_REGEX
diff --git a/lib/gitlab/multi_collection_paginator.rb b/lib/gitlab/multi_collection_paginator.rb
index c22d0a84860..43921a8c1c0 100644
--- a/lib/gitlab/multi_collection_paginator.rb
+++ b/lib/gitlab/multi_collection_paginator.rb
@@ -37,6 +37,7 @@ module Gitlab
else
per_page - first_collection_last_page_size
end
+
hash[page] = second_collection.page(second_collection_page)
.per(per_page - paginated_first_collection(page).size)
.padding(offset)
diff --git a/lib/gitlab/o_auth/auth_hash.rb b/lib/gitlab/o_auth/auth_hash.rb
deleted file mode 100644
index 5b5ed449f94..00000000000
--- a/lib/gitlab/o_auth/auth_hash.rb
+++ /dev/null
@@ -1,90 +0,0 @@
-# Class to parse and transform the info provided by omniauth
-#
-module Gitlab
- module OAuth
- class AuthHash
- attr_reader :auth_hash
- def initialize(auth_hash)
- @auth_hash = auth_hash
- end
-
- def uid
- @uid ||= Gitlab::Utils.force_utf8(auth_hash.uid.to_s)
- end
-
- def provider
- @provider ||= auth_hash.provider.to_s
- end
-
- def name
- @name ||= get_info(:name) || "#{get_info(:first_name)} #{get_info(:last_name)}"
- end
-
- def username
- @username ||= username_and_email[:username].to_s
- end
-
- def email
- @email ||= username_and_email[:email].to_s
- end
-
- def password
- @password ||= Gitlab::Utils.force_utf8(Devise.friendly_token[0, 8].downcase)
- end
-
- def location
- location = get_info(:address)
- if location.is_a?(Hash)
- [location.locality.presence, location.country.presence].compact.join(', ')
- else
- location
- end
- end
-
- def has_attribute?(attribute)
- if attribute == :location
- get_info(:address).present?
- else
- get_info(attribute).present?
- end
- end
-
- private
-
- def info
- auth_hash.info
- end
-
- def get_info(key)
- value = info[key]
- Gitlab::Utils.force_utf8(value) if value
- value
- end
-
- def username_and_email
- @username_and_email ||= begin
- username = get_info(:username).presence || get_info(:nickname).presence
- email = get_info(:email).presence
-
- username ||= generate_username(email) if email
- email ||= generate_temporarily_email(username) if username
-
- {
- username: username,
- email: email
- }
- end
- end
-
- # Get the first part of the email address (before @)
- # In addtion in removes illegal characters
- def generate_username(email)
- email.match(/^[^@]*/)[0].mb_chars.normalize(:kd).gsub(/[^\x00-\x7F]/, '').to_s
- end
-
- def generate_temporarily_email(username)
- "temp-email-for-oauth-#{username}@gitlab.localhost"
- end
- end
- end
-end
diff --git a/lib/gitlab/o_auth/provider.rb b/lib/gitlab/o_auth/provider.rb
deleted file mode 100644
index 657db29c85a..00000000000
--- a/lib/gitlab/o_auth/provider.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-module Gitlab
- module OAuth
- class Provider
- LABELS = {
- "github" => "GitHub",
- "gitlab" => "GitLab.com",
- "google_oauth2" => "Google"
- }.freeze
-
- def self.providers
- Devise.omniauth_providers
- end
-
- def self.enabled?(name)
- providers.include?(name.to_sym)
- end
-
- def self.ldap_provider?(name)
- name.to_s.start_with?('ldap')
- end
-
- def self.sync_profile_from_provider?(provider)
- return true if ldap_provider?(provider)
-
- providers = Gitlab.config.omniauth.sync_profile_from_provider
-
- if providers.is_a?(Array)
- providers.include?(provider)
- else
- providers
- end
- end
-
- def self.config_for(name)
- name = name.to_s
- if ldap_provider?(name)
- 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
- end
-
- def self.label_for(name)
- name = name.to_s
- config = config_for(name)
- (config && config['label']) || LABELS[name] || name.titleize
- end
- end
- end
-end
diff --git a/lib/gitlab/o_auth/session.rb b/lib/gitlab/o_auth/session.rb
deleted file mode 100644
index 30739f2a2c5..00000000000
--- a/lib/gitlab/o_auth/session.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# :nocov:
-module Gitlab
- module OAuth
- module Session
- def self.create(provider, ticket)
- Rails.cache.write("gitlab:#{provider}:#{ticket}", ticket, expires_in: Gitlab.config.omniauth.cas3.session_duration)
- end
-
- def self.destroy(provider, ticket)
- Rails.cache.delete("gitlab:#{provider}:#{ticket}")
- end
-
- def self.valid?(provider, ticket)
- Rails.cache.read("gitlab:#{provider}:#{ticket}").present?
- end
- end
- end
-end
-# :nocov:
diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb
deleted file mode 100644
index d33f33d192f..00000000000
--- a/lib/gitlab/o_auth/user.rb
+++ /dev/null
@@ -1,234 +0,0 @@
-# OAuth extension for User model
-#
-# * Find GitLab user based on omniauth uid and provider
-# * Create new user from omniauth data
-#
-module Gitlab
- module OAuth
- SignupDisabledError = Class.new(StandardError)
-
- class User
- attr_accessor :auth_hash, :gl_user
-
- def initialize(auth_hash)
- self.auth_hash = auth_hash
- update_profile
- add_or_update_user_identities
- end
-
- def persisted?
- gl_user.try(:persisted?)
- end
-
- def new?
- !persisted?
- end
-
- def valid?
- gl_user.try(:valid?)
- end
-
- def save(provider = 'OAuth')
- unauthorized_to_create unless gl_user
-
- block_after_save = needs_blocking?
-
- Users::UpdateService.new(gl_user, user: gl_user).execute!
-
- gl_user.block if block_after_save
-
- log.info "(#{provider}) saving user #{auth_hash.email} from login with extern_uid => #{auth_hash.uid}"
- gl_user
- rescue ActiveRecord::RecordInvalid => e
- log.info "(#{provider}) Error saving user #{auth_hash.uid} (#{auth_hash.email}): #{gl_user.errors.full_messages}"
- return self, e.record.errors
- end
-
- def gl_user
- return @gl_user if defined?(@gl_user)
-
- @gl_user = find_user
- end
-
- def find_user
- user = find_by_uid_and_provider
-
- user ||= find_or_build_ldap_user if auto_link_ldap_user?
- user ||= build_new_user if signup_enabled?
-
- user.external = true if external_provider? && user
-
- user
- end
-
- protected
-
- def add_or_update_user_identities
- return unless gl_user
-
- # 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)
- identity.extern_uid = auth_hash.uid
-
- if auto_link_ldap_user? && !gl_user.ldap_user? && ldap_person
- log.info "Correct LDAP account has been found. identity to user: #{gl_user.username}."
- gl_user.identities.build(provider: ldap_person.provider, extern_uid: ldap_person.dn)
- end
- end
-
- def find_or_build_ldap_user
- return unless ldap_person
-
- user = Gitlab::LDAP::User.find_by_uid_and_provider(ldap_person.dn, ldap_person.provider)
- if user
- log.info "LDAP account found for user #{user.username}. Building new #{auth_hash.provider} identity."
- return user
- end
-
- log.info "No user found using #{auth_hash.provider} provider. Creating a new one."
- build_new_user
- end
-
- def find_by_email
- return unless auth_hash.has_attribute?(:email)
-
- ::User.find_by(email: auth_hash.email.downcase)
- end
-
- def auto_link_ldap_user?
- Gitlab.config.omniauth.auto_link_ldap_user
- end
-
- def creating_linked_ldap_user?
- auto_link_ldap_user? && ldap_person
- end
-
- def ldap_person
- return @ldap_person if defined?(@ldap_person)
-
- # 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 = find_ldap_person(auth_hash, adapter)
- break if @ldap_person
- end
- @ldap_person
- end
-
- def find_ldap_person(auth_hash, adapter)
- Gitlab::LDAP::Person.find_by_uid(auth_hash.uid, adapter) ||
- Gitlab::LDAP::Person.find_by_email(auth_hash.uid, adapter) ||
- 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
-
- def needs_blocking?
- new? && block_after_signup?
- end
-
- def signup_enabled?
- providers = Gitlab.config.omniauth.allow_single_sign_on
- if providers.is_a?(Array)
- providers.include?(auth_hash.provider)
- else
- providers
- end
- end
-
- def external_provider?
- Gitlab.config.omniauth.external_providers.include?(auth_hash.provider)
- end
-
- def block_after_signup?
- if creating_linked_ldap_user?
- ldap_config.block_auto_created_users
- else
- Gitlab.config.omniauth.block_auto_created_users
- end
- end
-
- def auth_hash=(auth_hash)
- @auth_hash = AuthHash.new(auth_hash)
- end
-
- def find_by_uid_and_provider
- identity = Identity.with_extern_uid(auth_hash.provider, auth_hash.uid).take
- identity && identity.user
- end
-
- def build_new_user
- user_params = user_attributes.merge(skip_confirmation: true)
- Users::BuildService.new(nil, user_params).execute(skip_authorization: true)
- end
-
- def user_attributes
- # Give preference to LDAP for sensitive information when creating a linked account
- if creating_linked_ldap_user?
- username = ldap_person.username.presence
- email = ldap_person.email.first.presence
- end
-
- username ||= auth_hash.username
- email ||= auth_hash.email
-
- valid_username = ::Namespace.clean_path(username)
-
- uniquify = Uniquify.new
- valid_username = uniquify.string(valid_username) { |s| !UserPathValidator.valid_path?(s) }
-
- name = auth_hash.name
- name = valid_username if name.strip.empty?
-
- {
- name: name,
- username: valid_username,
- email: email,
- password: auth_hash.password,
- password_confirmation: auth_hash.password,
- password_automatically_set: true
- }
- end
-
- def sync_profile_from_provider?
- Gitlab::OAuth::Provider.sync_profile_from_provider?(auth_hash.provider)
- end
-
- def update_profile
- return unless sync_profile_from_provider? || creating_linked_ldap_user?
-
- metadata = gl_user.user_synced_attributes_metadata || gl_user.build_user_synced_attributes_metadata
-
- if sync_profile_from_provider?
- UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES.each do |key|
- if auth_hash.has_attribute?(key) && gl_user.sync_attribute?(key)
- gl_user[key] = auth_hash.public_send(key) # rubocop:disable GitlabSecurity/PublicSend
- metadata.set_attribute_synced(key, true)
- else
- metadata.set_attribute_synced(key, false)
- end
- end
-
- metadata.provider = auth_hash.provider
- end
-
- if creating_linked_ldap_user? && gl_user.email == ldap_person.email.first
- metadata.set_attribute_synced(:email, true)
- metadata.provider = ldap_person.provider
- end
- end
-
- def log
- Gitlab::AppLogger
- end
-
- def unauthorized_to_create
- raise SignupDisabledError
- end
- end
- end
-end
diff --git a/lib/gitlab/omniauth_initializer.rb b/lib/gitlab/omniauth_initializer.rb
new file mode 100644
index 00000000000..35ed3a5ac05
--- /dev/null
+++ b/lib/gitlab/omniauth_initializer.rb
@@ -0,0 +1,75 @@
+module Gitlab
+ class OmniauthInitializer
+ def initialize(devise_config)
+ @devise_config = devise_config
+ end
+
+ def execute(providers)
+ providers.each do |provider|
+ add_provider(provider['name'].to_sym, *arguments_for(provider))
+ end
+ end
+
+ private
+
+ def add_provider(*args)
+ @devise_config.omniauth(*args)
+ end
+
+ def arguments_for(provider)
+ provider_arguments = []
+
+ %w[app_id app_secret].each do |argument|
+ provider_arguments << provider[argument] if provider[argument]
+ end
+
+ case provider['args']
+ when Array
+ # An Array from the configuration will be expanded.
+ provider_arguments.concat provider['args']
+ when Hash
+ hash_arguments = provider['args'].merge(provider_defaults(provider))
+
+ # A Hash from the configuration will be passed as is.
+ provider_arguments << hash_arguments.symbolize_keys
+ end
+
+ provider_arguments
+ end
+
+ def provider_defaults(provider)
+ case provider['name']
+ when 'cas3'
+ { on_single_sign_out: cas3_signout_handler }
+ when 'authentiq'
+ { remote_sign_out_handler: authentiq_signout_handler }
+ when 'shibboleth'
+ { fail_with_empty_uid: true }
+ else
+ {}
+ end
+ end
+
+ def cas3_signout_handler
+ lambda do |request|
+ ticket = request.params[:session_index]
+ raise "Service Ticket not found." unless Gitlab::Auth::OAuth::Session.valid?(:cas3, ticket)
+
+ Gitlab::Auth::OAuth::Session.destroy(:cas3, ticket)
+ true
+ end
+ end
+
+ def authentiq_signout_handler
+ lambda do |request|
+ authentiq_session = request.params['sid']
+ if Gitlab::Auth::OAuth::Session.valid?(:authentiq, authentiq_session)
+ Gitlab::Auth::OAuth::Session.destroy(:authentiq, authentiq_session)
+ true
+ else
+ false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index 7e5dfd33502..4dc38aae61e 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -171,24 +171,16 @@ module Gitlab
@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
+ def full_project_git_path_regex
+ @full_project_git_path_regex ||= %r{\A\/?(?<namespace_path>#{full_namespace_route_regex})\/(?<project_path>#{project_route_regex})\.git\z}
end
def namespace_format_regex
diff --git a/lib/gitlab/performance_bar.rb b/lib/gitlab/performance_bar.rb
index e73245b82c1..92a308a12dc 100644
--- a/lib/gitlab/performance_bar.rb
+++ b/lib/gitlab/performance_bar.rb
@@ -1,18 +1,18 @@
module Gitlab
module PerformanceBar
- extend Gitlab::CurrentSettings
-
ALLOWED_USER_IDS_KEY = 'performance_bar_allowed_user_ids:v2'.freeze
EXPIRY_TIME = 5.minutes
def self.enabled?(user = nil)
+ return true if Rails.env.development?
+ return true if user&.admin?
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
+ Gitlab::CurrentSettings.performance_bar_allowed_group_id
end
def self.allowed_user_ids
diff --git a/lib/gitlab/plugin.rb b/lib/gitlab/plugin.rb
new file mode 100644
index 00000000000..0d1cb16b378
--- /dev/null
+++ b/lib/gitlab/plugin.rb
@@ -0,0 +1,26 @@
+module Gitlab
+ module Plugin
+ def self.files
+ Dir.glob(Rails.root.join('plugins/*')).select do |entry|
+ File.file?(entry)
+ end
+ end
+
+ def self.execute_all_async(data)
+ args = files.map { |file| [file, data] }
+
+ PluginWorker.bulk_perform_async(args)
+ end
+
+ def self.execute(file, data)
+ result = Gitlab::Popen.popen_with_detail([file]) do |stdin|
+ stdin.write(data.to_json)
+ end
+
+ exit_status = result.status&.exitstatus
+ [exit_status.zero?, result.stderr]
+ rescue => e
+ [false, e.message]
+ end
+ end
+end
diff --git a/lib/gitlab/plugin_logger.rb b/lib/gitlab/plugin_logger.rb
new file mode 100644
index 00000000000..c4f6ec3e21d
--- /dev/null
+++ b/lib/gitlab/plugin_logger.rb
@@ -0,0 +1,7 @@
+module Gitlab
+ class PluginLogger < Gitlab::Logger
+ def self.file_name_noext
+ 'plugin'
+ end
+ end
+end
diff --git a/lib/gitlab/polling_interval.rb b/lib/gitlab/polling_interval.rb
index 4780675a492..fe4bdfe3831 100644
--- a/lib/gitlab/polling_interval.rb
+++ b/lib/gitlab/polling_interval.rb
@@ -1,12 +1,10 @@
module Gitlab
class PollingInterval
- extend Gitlab::CurrentSettings
-
HEADER_NAME = 'Poll-Interval'.freeze
def self.set_header(response, interval:)
if polling_enabled?
- multiplier = current_application_settings.polling_interval_multiplier
+ multiplier = Gitlab::CurrentSettings.polling_interval_multiplier
value = (interval * multiplier).to_i
else
value = -1
@@ -16,7 +14,7 @@ module Gitlab
end
def self.polling_enabled?
- !current_application_settings.polling_interval_multiplier.zero?
+ !Gitlab::CurrentSettings.polling_interval_multiplier.zero?
end
end
end
diff --git a/lib/gitlab/popen.rb b/lib/gitlab/popen.rb
index 4bc5cda8cb5..b9832a724c4 100644
--- a/lib/gitlab/popen.rb
+++ b/lib/gitlab/popen.rb
@@ -5,7 +5,17 @@ module Gitlab
module Popen
extend self
- def popen(cmd, path = nil, vars = {})
+ Result = Struct.new(:cmd, :stdout, :stderr, :status, :duration)
+
+ # Returns [stdout + stderr, status]
+ def popen(cmd, path = nil, vars = {}, &block)
+ result = popen_with_detail(cmd, path, vars, &block)
+
+ [result.stdout << result.stderr, result.status&.exitstatus]
+ end
+
+ # Returns Result
+ def popen_with_detail(cmd, path = nil, vars = {})
unless cmd.is_a?(Array)
raise "System commands must be given as an array of strings"
end
@@ -18,18 +28,21 @@ module Gitlab
FileUtils.mkdir_p(path)
end
- cmd_output = ""
- cmd_status = 0
+ cmd_stdout = ''
+ cmd_stderr = ''
+ cmd_status = nil
+ start = Time.now
+
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
yield(stdin) if block_given?
stdin.close
- cmd_output << stdout.read
- cmd_output << stderr.read
- cmd_status = wait_thr.value.exitstatus
+ cmd_stdout = stdout.read
+ cmd_stderr = stderr.read
+ cmd_status = wait_thr.value
end
- [cmd_output, cmd_status]
+ Result.new(cmd, cmd_stdout, cmd_stderr, cmd_status, Time.now - start)
end
end
end
diff --git a/lib/gitlab/popen/runner.rb b/lib/gitlab/popen/runner.rb
new file mode 100644
index 00000000000..f44035a48bb
--- /dev/null
+++ b/lib/gitlab/popen/runner.rb
@@ -0,0 +1,46 @@
+module Gitlab
+ module Popen
+ class Runner
+ attr_reader :results
+
+ def initialize
+ @results = []
+ end
+
+ def run(commands, &block)
+ commands.each do |cmd|
+ # yield doesn't support blocks, so we need to use a block variable
+ block.call(cmd) do # rubocop:disable Performance/RedundantBlockCall
+ cmd_result = Gitlab::Popen.popen_with_detail(cmd)
+
+ results << cmd_result
+
+ cmd_result
+ end
+ end
+ end
+
+ def all_success_and_clean?
+ all_success? && all_stderr_empty?
+ end
+
+ def all_success?
+ results.all? { |result| result.status.success? }
+ end
+
+ def all_stderr_empty?
+ results.all? { |result| result.stderr.empty? }
+ end
+
+ def failed_results
+ results.reject { |result| result.status.success? }
+ end
+
+ def warned_results
+ results.select do |result|
+ result.status.success? && !result.stderr.empty?
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/profiler.rb b/lib/gitlab/profiler.rb
new file mode 100644
index 00000000000..18540e64d4c
--- /dev/null
+++ b/lib/gitlab/profiler.rb
@@ -0,0 +1,147 @@
+# coding: utf-8
+module Gitlab
+ module Profiler
+ FILTERED_STRING = '[FILTERED]'.freeze
+
+ IGNORE_BACKTRACES = %w[
+ lib/gitlab/i18n.rb
+ lib/gitlab/request_context.rb
+ config/initializers
+ lib/gitlab/database/load_balancing/
+ lib/gitlab/etag_caching/
+ lib/gitlab/metrics/
+ lib/gitlab/middleware/
+ lib/gitlab/performance_bar/
+ lib/gitlab/request_profiler/
+ lib/gitlab/profiler.rb
+ ].freeze
+
+ # Takes a URL to profile (can be a fully-qualified URL, or an absolute path)
+ # and returns the ruby-prof profile result. Formatting that result is the
+ # caller's responsibility. Requests are GET requests unless post_data is
+ # passed.
+ #
+ # Optional arguments:
+ # - logger: will be used for SQL logging, including a summary at the end of
+ # the log file of the total time spent per model class.
+ #
+ # - post_data: a string of raw POST data to use. Changes the HTTP verb to
+ # POST.
+ #
+ # - user: a user to authenticate as. Only works if the user has a valid
+ # personal access token.
+ #
+ # - private_token: instead of providing a user instance, the token can be
+ # given as a string. Takes precedence over the user option.
+ def self.profile(url, logger: nil, post_data: nil, user: nil, private_token: nil)
+ app = ActionDispatch::Integration::Session.new(Rails.application)
+ verb = :get
+ headers = {}
+
+ if post_data
+ verb = :post
+ headers['Content-Type'] = 'application/json'
+ end
+
+ if user
+ private_token ||= user.personal_access_tokens.active.pluck(:token).first
+ raise 'Your user must have a personal_access_token' unless private_token
+ end
+
+ headers['Private-Token'] = private_token if private_token
+ logger = create_custom_logger(logger, private_token: private_token)
+
+ RequestStore.begin!
+
+ # Make an initial call for an asset path in development mode to avoid
+ # sprockets dominating the profiler output.
+ ActionController::Base.helpers.asset_path('katex.css') if Rails.env.development?
+
+ # Rails loads internationalization files lazily the first time a
+ # translation is needed. Running this prevents this overhead from showing
+ # up in profiles.
+ ::I18n.t('.')[:test_string]
+
+ # Remove API route mounting from the profile.
+ app.get('/api/v4/users')
+
+ result = with_custom_logger(logger) do
+ RubyProf.profile { app.public_send(verb, url, post_data, headers) } # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ RequestStore.end!
+
+ log_load_times_by_model(logger)
+
+ result
+ end
+
+ def self.create_custom_logger(logger, private_token: nil)
+ return unless logger
+
+ logger.dup.tap do |new_logger|
+ new_logger.instance_variable_set(:@private_token, private_token)
+
+ class << new_logger
+ attr_reader :load_times_by_model, :private_token
+
+ def debug(message, *)
+ message.gsub!(private_token, FILTERED_STRING) if private_token
+
+ _, type, time = *message.match(/(\w+) Load \(([0-9.]+)ms\)/)
+
+ if type && time
+ @load_times_by_model ||= {}
+ @load_times_by_model[type] ||= []
+ @load_times_by_model[type] << time.to_f
+ end
+
+ super
+
+ backtrace = Rails.backtrace_cleaner.clean(caller)
+
+ backtrace.each do |caller_line|
+ next if caller_line.match(Regexp.union(IGNORE_BACKTRACES))
+
+ stripped_caller_line = caller_line.sub("#{Rails.root}/", '')
+
+ super(" ↳ #{stripped_caller_line}")
+ end
+ end
+ end
+ end
+ end
+
+ def self.with_custom_logger(logger)
+ original_colorize_logging = ActiveSupport::LogSubscriber.colorize_logging
+ original_activerecord_logger = ActiveRecord::Base.logger
+ original_actioncontroller_logger = ActionController::Base.logger
+
+ if logger
+ ActiveSupport::LogSubscriber.colorize_logging = false
+ ActiveRecord::Base.logger = logger
+ ActionController::Base.logger = logger
+ end
+
+ result = yield
+
+ ActiveSupport::LogSubscriber.colorize_logging = original_colorize_logging
+ ActiveRecord::Base.logger = original_activerecord_logger
+ ActionController::Base.logger = original_actioncontroller_logger
+
+ result
+ end
+
+ def self.log_load_times_by_model(logger)
+ return unless logger.respond_to?(:load_times_by_model)
+
+ summarised_load_times = logger.load_times_by_model.to_a.map do |(model, times)|
+ [model, times.count, times.sum]
+ end
+
+ summarised_load_times.sort_by(&:last).reverse.each do |(model, query_count, time)|
+ logger.info("#{model} total (#{query_count}): #{time.round(2)}ms")
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb
index e2662fc362b..390efda326a 100644
--- a/lib/gitlab/project_search_results.rb
+++ b/lib/gitlab/project_search_results.rb
@@ -2,11 +2,12 @@ module Gitlab
class ProjectSearchResults < SearchResults
attr_reader :project, :repository_ref
- def initialize(current_user, project, query, repository_ref = nil)
+ def initialize(current_user, project, query, repository_ref = nil, per_page: 20)
@current_user = current_user
@project = project
@repository_ref = repository_ref.presence || project.default_branch
@query = query
+ @per_page = per_page
end
def objects(scope, page = nil)
@@ -20,7 +21,7 @@ module Gitlab
when 'commits'
Kaminari.paginate_array(commits).page(page).per(per_page)
else
- super
+ super(scope, page, false)
end
end
@@ -28,8 +29,18 @@ module Gitlab
@blobs_count ||= blobs.count
end
- def notes_count
- @notes_count ||= notes.count
+ def limited_notes_count
+ return @limited_notes_count if defined?(@limited_notes_count)
+
+ types = %w(issue merge_request commit snippet)
+ @limited_notes_count = 0
+
+ types.each do |type|
+ @limited_notes_count += notes_finder(type).limit(count_limit).count
+ break if @limited_notes_count >= count_limit
+ end
+
+ @limited_notes_count
end
def wiki_blobs_count
@@ -40,29 +51,24 @@ module Gitlab
@commits_count ||= commits.count
end
- def self.parse_search_result(result)
+ def self.parse_search_result(result, project = nil)
ref = nil
filename = nil
basename = nil
+ data = ""
startline = 0
result.each_line.each_with_index do |line, index|
- matches = line.match(/^(?<ref>[^:]*):(?<filename>.*):(?<startline>\d+):/)
- if matches
+ prefix ||= line.match(/^(?<ref>[^:]*):(?<filename>.*)\x00(?<startline>\d+)\x00/)&.tap do |matches|
ref = matches[:ref]
filename = matches[:filename]
startline = matches[:startline]
startline = startline.to_i - index
extname = Regexp.escape(File.extname(filename))
basename = filename.sub(/#{extname}$/, '')
- break
end
- end
- data = ""
-
- result.each_line do |line|
- data << line.sub(ref, '').sub(filename, '').sub(/^:-\d+-/, '').sub(/^::\d+:/, '')
+ data << line.sub(prefix.to_s, '')
end
FoundBlob.new(
@@ -70,16 +76,18 @@ module Gitlab
basename: basename,
ref: ref,
startline: startline,
- data: data
+ data: data,
+ project_id: project ? project.id : nil
)
end
def single_commit_result?
- commits_count == 1 && total_result_count == 1
- end
+ return false if commits_count != 1
- def total_result_count
- issues_count + merge_requests_count + milestones_count + notes_count + blobs_count + wiki_blobs_count + commits_count
+ counts = %i(limited_milestones_count limited_notes_count
+ limited_merge_requests_count limited_issues_count
+ blobs_count wiki_blobs_count)
+ counts.all? { |count_method| public_send(count_method).zero? } # rubocop:disable GitlabSecurity/PublicSend
end
private
@@ -109,7 +117,11 @@ module Gitlab
end
def notes
- @notes ||= NotesFinder.new(project, @current_user, search: query).execute.user.order('updated_at DESC')
+ @notes ||= notes_finder(nil)
+ end
+
+ def notes_finder(type)
+ NotesFinder.new(project, @current_user, search: query, target_type: type).execute.user.order('updated_at DESC')
end
def commits
diff --git a/lib/gitlab/project_transfer.rb b/lib/gitlab/project_transfer.rb
index 1bba0b78e2f..690c38737c0 100644
--- a/lib/gitlab/project_transfer.rb
+++ b/lib/gitlab/project_transfer.rb
@@ -1,13 +1,19 @@
module Gitlab
+ # This class is used to move local, unhashed files owned by projects to their new location
class ProjectTransfer
- def move_project(project_path, namespace_path_was, namespace_path)
- new_namespace_folder = File.join(root_dir, namespace_path)
- FileUtils.mkdir_p(new_namespace_folder) unless Dir.exist?(new_namespace_folder)
- from = File.join(root_dir, namespace_path_was, project_path)
- to = File.join(root_dir, namespace_path, project_path)
+ # nil parent_path (or parent_path_was) represents a root namespace
+ def move_namespace(path, parent_path_was, parent_path)
+ parent_path_was ||= ''
+ parent_path ||= ''
+ new_parent_folder = File.join(root_dir, parent_path)
+ FileUtils.mkdir_p(new_parent_folder)
+ from = File.join(root_dir, parent_path_was, path)
+ to = File.join(root_dir, parent_path, path)
move(from, to, "")
end
+ alias_method :move_project, :move_namespace
+
def rename_project(path_was, path, namespace_path)
base_dir = File.join(root_dir, namespace_path)
move(path_was, path, base_dir)
diff --git a/lib/gitlab/prometheus/additional_metrics_parser.rb b/lib/gitlab/prometheus/additional_metrics_parser.rb
index cb95daf2260..bb1172f82a1 100644
--- a/lib/gitlab/prometheus/additional_metrics_parser.rb
+++ b/lib/gitlab/prometheus/additional_metrics_parser.rb
@@ -1,10 +1,12 @@
module Gitlab
module Prometheus
module AdditionalMetricsParser
+ CONFIG_ROOT = 'config/prometheus'.freeze
+ MUTEX = Mutex.new
extend self
- def load_groups_from_yaml
- additional_metrics_raw.map(&method(:group_from_entry))
+ def load_groups_from_yaml(file_name = 'additional_metrics.yml')
+ yaml_metrics_raw(file_name).map(&method(:group_from_entry))
end
private
@@ -22,13 +24,20 @@ module Gitlab
MetricGroup.new(entry).tap(&method(:validate!))
end
- def additional_metrics_raw
- load_yaml_file&.map(&:deep_symbolize_keys).freeze
+ def yaml_metrics_raw(file_name)
+ load_yaml_file(file_name)&.map(&:deep_symbolize_keys).freeze
end
- def load_yaml_file
- @loaded_yaml_file ||= YAML.load_file(Rails.root.join('config/prometheus/additional_metrics.yml'))
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def load_yaml_file(file_name)
+ return YAML.load_file(Rails.root.join(CONFIG_ROOT, file_name)) if Rails.env.development?
+
+ MUTEX.synchronize do
+ @loaded_yaml_cache ||= {}
+ @loaded_yaml_cache[file_name] ||= YAML.load_file(Rails.root.join(CONFIG_ROOT, file_name))
+ end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
end
end
diff --git a/lib/gitlab/prometheus/metric_group.rb b/lib/gitlab/prometheus/metric_group.rb
index 729fef34b35..e91c6fb2e27 100644
--- a/lib/gitlab/prometheus/metric_group.rb
+++ b/lib/gitlab/prometheus/metric_group.rb
@@ -6,9 +6,14 @@ module Gitlab
attr_accessor :name, :priority, :metrics
validates :name, :priority, :metrics, presence: true
- def self.all
+ def self.common_metrics
AdditionalMetricsParser.load_groups_from_yaml
end
+
+ # EE only
+ def self.for_project(_)
+ common_metrics
+ end
end
end
end
diff --git a/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb
index 69d055c901c..e677ec84cd4 100644
--- a/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb
+++ b/lib/gitlab/prometheus/queries/additional_metrics_deployment_query.rb
@@ -7,6 +7,7 @@ module Gitlab
def query(deployment_id)
Deployment.find_by(id: deployment_id).try do |deployment|
query_metrics(
+ deployment.project,
common_query_context(
deployment.environment,
timeframe_start: (deployment.created_at - 30.minutes).to_f,
diff --git a/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb b/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb
index 32fe8201a8d..9273e69e158 100644
--- a/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb
+++ b/lib/gitlab/prometheus/queries/additional_metrics_environment_query.rb
@@ -7,6 +7,7 @@ module Gitlab
def query(environment_id)
::Environment.find_by(id: environment_id).try do |environment|
query_metrics(
+ environment.project,
common_query_context(environment, timeframe_start: 8.hours.ago.to_f, timeframe_end: Time.now.to_f)
)
end
diff --git a/lib/gitlab/prometheus/queries/base_query.rb b/lib/gitlab/prometheus/queries/base_query.rb
index c60828165bd..29cab6e9c15 100644
--- a/lib/gitlab/prometheus/queries/base_query.rb
+++ b/lib/gitlab/prometheus/queries/base_query.rb
@@ -20,6 +20,10 @@ module Gitlab
def query(*args)
raise NotImplementedError
end
+
+ def self.transform_reactive_result(result)
+ result
+ end
end
end
end
diff --git a/lib/gitlab/prometheus/queries/deployment_query.rb b/lib/gitlab/prometheus/queries/deployment_query.rb
index 170f483540e..c2626581897 100644
--- a/lib/gitlab/prometheus/queries/deployment_query.rb
+++ b/lib/gitlab/prometheus/queries/deployment_query.rb
@@ -25,6 +25,11 @@ module Gitlab
}
end
end
+
+ def self.transform_reactive_result(result)
+ result[:metrics] = result.delete :data
+ result
+ end
end
end
end
diff --git a/lib/gitlab/prometheus/queries/environment_query.rb b/lib/gitlab/prometheus/queries/environment_query.rb
index 1d17d3cfd56..b62910c8de6 100644
--- a/lib/gitlab/prometheus/queries/environment_query.rb
+++ b/lib/gitlab/prometheus/queries/environment_query.rb
@@ -19,6 +19,11 @@ module Gitlab
}
end
end
+
+ def self.transform_reactive_result(result)
+ result[:metrics] = result.delete :data
+ result
+ end
end
end
end
diff --git a/lib/gitlab/prometheus/queries/matched_metric_query.rb b/lib/gitlab/prometheus/queries/matched_metric_query.rb
new file mode 100644
index 00000000000..d920e9a749f
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/matched_metric_query.rb
@@ -0,0 +1,80 @@
+module Gitlab
+ module Prometheus
+ module Queries
+ class MatchedMetricQuery < 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.common_metrics)
+ 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/matched_metrics_query.rb b/lib/gitlab/prometheus/queries/matched_metrics_query.rb
deleted file mode 100644
index 4c3edccc71a..00000000000
--- a/lib/gitlab/prometheus/queries/matched_metrics_query.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-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
index 5cddc96a643..f5879de1e94 100644
--- a/lib/gitlab/prometheus/queries/query_additional_metrics.rb
+++ b/lib/gitlab/prometheus/queries/query_additional_metrics.rb
@@ -2,10 +2,17 @@ module Gitlab
module Prometheus
module Queries
module QueryAdditionalMetrics
- def query_metrics(query_context)
+ def query_metrics(project, query_context)
+ matched_metrics(project).map(&query_group(query_context))
+ .select(&method(:group_with_any_metrics))
+ end
+
+ protected
+
+ def query_group(query_context)
query_processor = method(:process_query).curry[query_context]
- groups = matched_metrics.map do |group|
+ lambda do |group|
metrics = group.metrics.map do |metric|
{
title: metric.title,
@@ -21,8 +28,6 @@ module Gitlab
metrics: metrics.select(&method(:metric_with_any_queries))
}
end
-
- groups.select(&method(:group_with_any_metrics))
end
private
@@ -60,8 +65,8 @@ module Gitlab
@available_metrics ||= client_label_values || []
end
- def matched_metrics
- result = Gitlab::Prometheus::MetricGroup.all.map do |group|
+ def matched_metrics(project)
+ result = Gitlab::Prometheus::MetricGroup.for_project(project).map do |group|
group.metrics.select! do |metric|
metric.required_metrics.all?(&available_metrics.method(:include?))
end
@@ -72,12 +77,17 @@ module Gitlab
end
def common_query_context(environment, timeframe_start:, timeframe_end:)
- {
- timeframe_start: timeframe_start,
- timeframe_end: timeframe_end,
+ base_query_context(timeframe_start, timeframe_end).merge({
ci_environment_slug: environment.slug,
- kube_namespace: environment.project.deployment_platform&.actual_namespace || '',
+ kube_namespace: environment.deployment_platform&.actual_namespace || '',
environment_filter: %{container_name!="POD",environment="#{environment.slug}"}
+ })
+ end
+
+ def base_query_context(timeframe_start, timeframe_end)
+ {
+ timeframe_start: timeframe_start,
+ timeframe_end: timeframe_end
}
end
end
diff --git a/lib/gitlab/prometheus_client.rb b/lib/gitlab/prometheus_client.rb
index aa94614bf18..b66253a10e0 100644
--- a/lib/gitlab/prometheus_client.rb
+++ b/lib/gitlab/prometheus_client.rb
@@ -1,12 +1,13 @@
module Gitlab
- PrometheusError = Class.new(StandardError)
-
# Helper methods to interact with Prometheus network services & resources
class PrometheusClient
- attr_reader :api_url
+ Error = Class.new(StandardError)
+ QueryError = Class.new(Gitlab::PrometheusClient::Error)
+
+ attr_reader :rest_client, :headers
- def initialize(api_url:)
- @api_url = api_url
+ def initialize(rest_client)
+ @rest_client = rest_client
end
def ping
@@ -22,10 +23,10 @@ module Gitlab
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)
+ query: query,
+ start: start.to_f,
+ end: stop.to_f,
+ step: 1.minute.to_i)
end
end
@@ -40,39 +41,48 @@ module Gitlab
private
def json_api_get(type, args = {})
- get(join_api_url(type, args))
+ path = ['api', 'v1', type].join('/')
+ get(path, args)
+ rescue JSON::ParserError
+ raise PrometheusClient::Error, 'Parsing response failed'
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
+ raise PrometheusClient::Error, 'Connection refused'
end
- def get(url)
- handle_response(HTTParty.get(url))
+ def get(path, args)
+ response = rest_client[path].get(params: args)
+ handle_response(response)
rescue SocketError
- raise PrometheusError, "Can't connect to #{url}"
+ raise PrometheusClient::Error, "Can't connect to #{rest_client.url}"
rescue OpenSSL::SSL::SSLError
- raise PrometheusError, "#{url} contains invalid SSL data"
- rescue HTTParty::Error
- raise PrometheusError, "Network connection error"
+ raise PrometheusClient::Error, "#{rest_client.url} contains invalid SSL data"
+ rescue RestClient::ExceptionWithResponse => ex
+ if ex.response
+ handle_exception_response(ex.response)
+ else
+ raise PrometheusClient::Error, "Network connection error"
+ end
+ rescue RestClient::Exception
+ raise PrometheusClient::Error, "Network connection error"
end
def handle_response(response)
+ json_data = JSON.parse(response.body)
+ if response.code == 200 && json_data['status'] == 'success'
+ json_data['data'] || {}
+ else
+ raise PrometheusClient::Error, "#{response.code} - #{response.body}"
+ end
+ end
+
+ def handle_exception_response(response)
if response.code == 200 && response['status'] == 'success'
response['data'] || {}
elsif response.code == 400
- raise PrometheusError, response['error'] || 'Bad data received'
+ json_data = JSON.parse(response.body)
+ raise PrometheusClient::QueryError, json_data['error'] || 'Bad data received'
else
- raise PrometheusError, "#{response.code} - #{response.body}"
+ raise PrometheusClient::Error, "#{response.code} - #{response.body}"
end
end
diff --git a/lib/gitlab/protocol_access.rb b/lib/gitlab/protocol_access.rb
index 09fa14764e6..2819c7d062c 100644
--- a/lib/gitlab/protocol_access.rb
+++ b/lib/gitlab/protocol_access.rb
@@ -1,14 +1,12 @@
module Gitlab
module ProtocolAccess
- extend Gitlab::CurrentSettings
-
def self.allowed?(protocol)
if protocol == 'web'
true
- elsif current_application_settings.enabled_git_access_protocol.blank?
+ elsif Gitlab::CurrentSettings.enabled_git_access_protocol.blank?
true
else
- protocol == current_application_settings.enabled_git_access_protocol
+ protocol == Gitlab::CurrentSettings.enabled_git_access_protocol
end
end
end
diff --git a/lib/gitlab/proxy_http_connection_adapter.rb b/lib/gitlab/proxy_http_connection_adapter.rb
new file mode 100644
index 00000000000..d682289b632
--- /dev/null
+++ b/lib/gitlab/proxy_http_connection_adapter.rb
@@ -0,0 +1,34 @@
+# This class is part of the Gitlab::HTTP wrapper. Depending on the value
+# of the global setting allow_local_requests_from_hooks_and_services this adapter
+# will allow/block connection to internal IPs and/or urls.
+#
+# This functionality can be overriden by providing the setting the option
+# allow_local_requests = true in the request. For example:
+# Gitlab::HTTP.get('http://www.gitlab.com', allow_local_requests: true)
+#
+# This option will take precedence over the global setting.
+module Gitlab
+ class ProxyHTTPConnectionAdapter < HTTParty::ConnectionAdapter
+ def connection
+ unless allow_local_requests?
+ begin
+ Gitlab::UrlBlocker.validate!(uri, allow_local_network: false)
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ raise Gitlab::HTTP::BlockedUrlError, "URL '#{uri}' is blocked: #{e.message}"
+ end
+ end
+
+ super
+ end
+
+ private
+
+ def allow_local_requests?
+ options.fetch(:allow_local_requests, allow_settings_local_requests?)
+ end
+
+ def allow_settings_local_requests?
+ Gitlab::CurrentSettings.allow_local_requests_from_hooks_and_services?
+ end
+ end
+end
diff --git a/lib/gitlab/query_limiting.rb b/lib/gitlab/query_limiting.rb
new file mode 100644
index 00000000000..9f69a9e4a39
--- /dev/null
+++ b/lib/gitlab/query_limiting.rb
@@ -0,0 +1,36 @@
+module Gitlab
+ module QueryLimiting
+ # Returns true if we should enable tracking of query counts.
+ #
+ # This is only enabled in production/staging if we're running on GitLab.com.
+ # This ensures we don't produce any errors that users can't do anything
+ # about themselves.
+ def self.enable?
+ Rails.env.development? || Rails.env.test?
+ end
+
+ # Allows the current request to execute any number of SQL queries.
+ #
+ # This method should _only_ be used when there's a corresponding issue to
+ # reduce the number of queries.
+ #
+ # The issue URL is only meant to push developers into creating an issue
+ # instead of blindly whitelisting offending blocks of code.
+ def self.whitelist(issue_url)
+ return unless enable_whitelist?
+
+ unless issue_url.start_with?('https://')
+ raise(
+ ArgumentError,
+ 'You must provide a valid issue URL in order to whitelist a block of code'
+ )
+ end
+
+ Transaction&.current&.whitelisted = true
+ end
+
+ def self.enable_whitelist?
+ Rails.env.development? || Rails.env.test?
+ end
+ end
+end
diff --git a/lib/gitlab/query_limiting/active_support_subscriber.rb b/lib/gitlab/query_limiting/active_support_subscriber.rb
new file mode 100644
index 00000000000..4c83581c4b1
--- /dev/null
+++ b/lib/gitlab/query_limiting/active_support_subscriber.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ module QueryLimiting
+ class ActiveSupportSubscriber < ActiveSupport::Subscriber
+ attach_to :active_record
+
+ def sql(event)
+ unless event.payload[:name] == 'CACHE'
+ Transaction.current&.increment
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/query_limiting/middleware.rb b/lib/gitlab/query_limiting/middleware.rb
new file mode 100644
index 00000000000..949ae79a047
--- /dev/null
+++ b/lib/gitlab/query_limiting/middleware.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module QueryLimiting
+ # Middleware for reporting (or raising) when a request performs more than a
+ # certain amount of database queries.
+ class Middleware
+ CONTROLLER_KEY = 'action_controller.instance'.freeze
+ ENDPOINT_KEY = 'api.endpoint'.freeze
+
+ def initialize(app)
+ @app = app
+ end
+
+ def call(env)
+ transaction, retval = Transaction.run do
+ @app.call(env)
+ end
+
+ transaction.action = action_name(env)
+ transaction.act_upon_results
+
+ retval
+ end
+
+ def action_name(env)
+ if env[CONTROLLER_KEY]
+ action_for_rails(env)
+ elsif env[ENDPOINT_KEY]
+ action_for_grape(env)
+ end
+ end
+
+ private
+
+ def action_for_rails(env)
+ controller = env[CONTROLLER_KEY]
+ action = "#{controller.class.name}##{controller.action_name}"
+
+ if controller.content_type == 'text/html'
+ action
+ else
+ "#{action} (#{controller.content_type})"
+ end
+ end
+
+ def action_for_grape(env)
+ endpoint = env[ENDPOINT_KEY]
+ route = endpoint.route rescue nil
+
+ "#{route.request_method} #{route.path}" if route
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/query_limiting/transaction.rb b/lib/gitlab/query_limiting/transaction.rb
new file mode 100644
index 00000000000..66d7d9275cf
--- /dev/null
+++ b/lib/gitlab/query_limiting/transaction.rb
@@ -0,0 +1,77 @@
+module Gitlab
+ module QueryLimiting
+ class Transaction
+ THREAD_KEY = :__gitlab_query_counts_transaction
+
+ attr_accessor :count, :whitelisted
+
+ # The name of the action (e.g. `UsersController#show`) that is being
+ # executed.
+ attr_accessor :action
+
+ # The maximum number of SQL queries that can be executed in a request. For
+ # the sake of keeping things simple we hardcode this value here, it's not
+ # supposed to be changed very often anyway.
+ THRESHOLD = 100
+
+ # Error that is raised whenever exceeding the maximum number of queries.
+ ThresholdExceededError = Class.new(StandardError)
+
+ def self.current
+ Thread.current[THREAD_KEY]
+ end
+
+ # Starts a new transaction and returns it and the blocks' return value.
+ #
+ # Example:
+ #
+ # transaction, retval = Transaction.run do
+ # 10
+ # end
+ #
+ # retval # => 10
+ def self.run
+ transaction = new
+ Thread.current[THREAD_KEY] = transaction
+
+ [transaction, yield]
+ ensure
+ Thread.current[THREAD_KEY] = nil
+ end
+
+ def initialize
+ @action = nil
+ @count = 0
+ @whitelisted = false
+ end
+
+ # Sends a notification based on the number of executed SQL queries.
+ def act_upon_results
+ return unless threshold_exceeded?
+
+ error = ThresholdExceededError.new(error_message)
+
+ raise(error) if raise_error?
+ end
+
+ def increment
+ @count += 1 unless whitelisted
+ end
+
+ def raise_error?
+ Rails.env.test?
+ end
+
+ def threshold_exceeded?
+ count > THRESHOLD
+ end
+
+ def error_message
+ header = 'Too many SQL queries were executed'
+ header += " in #{action}" if action
+
+ "#{header}: a maximum of #{THRESHOLD} is allowed but #{count} SQL queries were executed"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/quick_actions/command_definition.rb b/lib/gitlab/quick_actions/command_definition.rb
index 3937d9c153a..96415271316 100644
--- a/lib/gitlab/quick_actions/command_definition.rb
+++ b/lib/gitlab/quick_actions/command_definition.rb
@@ -24,15 +24,14 @@ module Gitlab
action_block.nil?
end
- def available?(opts)
+ def available?(context)
return true unless condition_block
- context = OpenStruct.new(opts)
context.instance_exec(&condition_block)
end
- def explain(context, opts, arg)
- return unless available?(opts)
+ def explain(context, arg)
+ return unless available?(context)
if explanation.respond_to?(:call)
execute_block(explanation, context, arg)
@@ -41,15 +40,13 @@ module Gitlab
end
end
- def execute(context, opts, arg)
- return if noop? || !available?(opts)
+ def execute(context, arg)
+ return if noop? || !available?(context)
execute_block(action_block, context, arg)
end
- def to_h(opts)
- context = OpenStruct.new(opts)
-
+ def to_h(context)
desc = description
if desc.respond_to?(:call)
desc = context.instance_exec(&desc) rescue ''
diff --git a/lib/gitlab/quick_actions/dsl.rb b/lib/gitlab/quick_actions/dsl.rb
index 536765305e1..d82dccd0db5 100644
--- a/lib/gitlab/quick_actions/dsl.rb
+++ b/lib/gitlab/quick_actions/dsl.rb
@@ -62,9 +62,8 @@ module Gitlab
# 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`.
- #
+ # It accepts a block that will be evaluated with the context
+ # of a QuickActions::InterpretService instance
# Example:
#
# condition do
diff --git a/lib/gitlab/quick_actions/extractor.rb b/lib/gitlab/quick_actions/extractor.rb
index 3ebfa3bd4b8..075ff91700c 100644
--- a/lib/gitlab/quick_actions/extractor.rb
+++ b/lib/gitlab/quick_actions/extractor.rb
@@ -29,7 +29,7 @@ module Gitlab
# commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']]
# msg #=> "hello\nworld"
# ```
- def extract_commands(content, opts = {})
+ def extract_commands(content)
return [content, []] unless content
content = content.dup
@@ -37,7 +37,7 @@ module Gitlab
commands = []
content.delete!("\r")
- content.gsub!(commands_regex(opts)) do
+ content.gsub!(commands_regex) do
if $~[:cmd]
commands << [$~[:cmd], $~[:arg]].reject(&:blank?)
''
@@ -60,8 +60,8 @@ module Gitlab
# It looks something like:
#
# /^\/(?<cmd>close|reopen|...)(?:( |$))(?<arg>[^\/\n]*)(?:\n|$)/
- def commands_regex(opts)
- names = command_names(opts).map(&:to_s)
+ def commands_regex
+ names = command_names.map(&:to_s)
@commands_regex ||= %r{
(?<code>
@@ -126,13 +126,14 @@ module Gitlab
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)
+ def command_names
command_definitions.flat_map do |command|
next if command.noop?
diff --git a/lib/gitlab/quick_actions/spend_time_and_date_separator.rb b/lib/gitlab/quick_actions/spend_time_and_date_separator.rb
index 3f52402b31f..7328c517a30 100644
--- a/lib/gitlab/quick_actions/spend_time_and_date_separator.rb
+++ b/lib/gitlab/quick_actions/spend_time_and_date_separator.rb
@@ -9,7 +9,7 @@ module Gitlab
# if date doesn't present return time with current date
# in other cases return nil
class SpendTimeAndDateSeparator
- DATE_REGEX = /(\d{2,4}[\/\-.]\d{1,2}[\/\-.]\d{1,2})/
+ DATE_REGEX = %r{(\d{2,4}[/\-.]\d{1,2}[/\-.]\d{1,2})}
def initialize(spend_command_arg)
@spend_arg = spend_command_arg
diff --git a/lib/gitlab/recaptcha.rb b/lib/gitlab/recaptcha.rb
index c463dd487a0..c9efa28d7e7 100644
--- a/lib/gitlab/recaptcha.rb
+++ b/lib/gitlab/recaptcha.rb
@@ -1,12 +1,10 @@
module Gitlab
module Recaptcha
- extend Gitlab::CurrentSettings
-
def self.load_configurations!
- if current_application_settings.recaptcha_enabled
+ if Gitlab::CurrentSettings.recaptcha_enabled
::Recaptcha.configure do |config|
- config.public_key = current_application_settings.recaptcha_site_key
- config.private_key = current_application_settings.recaptcha_private_key
+ config.public_key = Gitlab::CurrentSettings.recaptcha_site_key
+ config.private_key = Gitlab::CurrentSettings.recaptcha_private_key
end
true
@@ -14,7 +12,7 @@ module Gitlab
end
def self.enabled?
- current_application_settings.recaptcha_enabled
+ Gitlab::CurrentSettings.recaptcha_enabled
end
end
end
diff --git a/lib/gitlab/redis/cache.rb b/lib/gitlab/redis/cache.rb
index 9bf019b72e6..a991933e910 100644
--- a/lib/gitlab/redis/cache.rb
+++ b/lib/gitlab/redis/cache.rb
@@ -1,5 +1,5 @@
# please require all dependencies below:
-require_relative 'wrapper' unless defined?(::Gitlab::Redis::Wrapper)
+require_relative 'wrapper' unless defined?(::Rails) && ::Rails.root.present?
module Gitlab
module Redis
diff --git a/lib/gitlab/redis/wrapper.rb b/lib/gitlab/redis/wrapper.rb
index 8ad06480575..4178b436acf 100644
--- a/lib/gitlab/redis/wrapper.rb
+++ b/lib/gitlab/redis/wrapper.rb
@@ -24,6 +24,7 @@ module Gitlab
# the pool will be used in a multi-threaded context
size += Sidekiq.options[:concurrency]
end
+
size
end
@@ -104,6 +105,7 @@ module Gitlab
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)
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index 2c7b8af83f2..ac3de2a8f71 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -37,15 +37,19 @@ module Gitlab
end
def environment_name_regex_chars
- 'a-zA-Z0-9_/\\$\\{\\}\\. -'
+ 'a-zA-Z0-9_/\\$\\{\\}\\. \\-'
+ end
+
+ def environment_name_regex_chars_without_slash
+ 'a-zA-Z0-9_\\$\\{\\}\\. -'
end
def environment_name_regex
- @environment_name_regex ||= /\A[#{environment_name_regex_chars}]+\z/.freeze
+ @environment_name_regex ||= /\A[#{environment_name_regex_chars_without_slash}]([#{environment_name_regex_chars}]*[#{environment_name_regex_chars_without_slash}])?\z/.freeze
end
def environment_name_regex_message
- "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces"
+ "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces, but it cannot start or end with '/'"
end
def kubernetes_namespace_regex
@@ -67,7 +71,7 @@ module Gitlab
end
def build_trace_section_regex
- @build_trace_section_regexp ||= /section_((?:start)|(?:end)):(\d+):([^\r]+)\r\033\[0K/.freeze
+ @build_trace_section_regexp ||= /section_((?:start)|(?:end)):(\d+):([a-zA-Z0-9_.-]+)\r\033\[0K/.freeze
end
end
end
diff --git a/lib/gitlab/repo_path.rb b/lib/gitlab/repo_path.rb
index 3591fa9145e..1fa2a19b0af 100644
--- a/lib/gitlab/repo_path.rb
+++ b/lib/gitlab/repo_path.rb
@@ -21,16 +21,16 @@ module Gitlab
result = repo_path
storage = Gitlab.config.repositories.storages.values.find do |params|
- repo_path.start_with?(params['path'])
+ repo_path.start_with?(params.legacy_disk_path)
end
if storage
- result = result.sub(storage['path'], '')
+ result = result.sub(storage.legacy_disk_path, '')
elsif fail_on_not_found
raise NotFoundError.new("No known storage path matches #{repo_path.inspect}")
end
- result.sub(/\A\/*/, '')
+ result.sub(%r{\A/*}, '')
end
def self.find_project(project_path)
diff --git a/lib/gitlab/repository_cache.rb b/lib/gitlab/repository_cache.rb
new file mode 100644
index 00000000000..b1bf3ca4143
--- /dev/null
+++ b/lib/gitlab/repository_cache.rb
@@ -0,0 +1,33 @@
+# Interface to the Redis-backed cache store
+module Gitlab
+ class RepositoryCache
+ attr_reader :repository, :namespace, :backend
+
+ def initialize(repository, extra_namespace: nil, backend: Rails.cache)
+ @repository = repository
+ @namespace = "#{repository.full_path}:#{repository.project.id}"
+ @namespace += ":#{extra_namespace}" if extra_namespace
+ @backend = backend
+ end
+
+ def cache_key(type)
+ "#{type}:#{namespace}"
+ end
+
+ def expire(key)
+ backend.delete(cache_key(key))
+ end
+
+ def fetch(key, &block)
+ backend.fetch(cache_key(key), &block)
+ end
+
+ def exist?(key)
+ backend.exist?(cache_key(key))
+ end
+
+ def read(key)
+ backend.read(cache_key(key))
+ end
+ end
+end
diff --git a/lib/gitlab/repository_cache_adapter.rb b/lib/gitlab/repository_cache_adapter.rb
new file mode 100644
index 00000000000..7f64a8c9e46
--- /dev/null
+++ b/lib/gitlab/repository_cache_adapter.rb
@@ -0,0 +1,84 @@
+module Gitlab
+ module RepositoryCacheAdapter
+ extend ActiveSupport::Concern
+
+ class_methods do
+ # Wraps around the given method and caches its output in Redis and an instance
+ # variable.
+ #
+ # This only works for methods that do not take any arguments.
+ def 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, memoize_only: memoize_only) do
+ __send__(original) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+ end
+
+ # RepositoryCache to be used. Should be overridden by the including class
+ def cache
+ raise NotImplementedError
+ end
+
+ # Caches the supplied block both in a cache and in an instance variable.
+ #
+ # The cache key and instance variable are named the same way as the value of
+ # the `key` argument.
+ #
+ # This method will return `nil` if the corresponding instance variable is also
+ # set to `nil`. This ensures we don't keep yielding the block when it returns
+ # `nil`.
+ #
+ # key - The name of the key to cache the data in.
+ # fallback - A value to fall back to in the event of a Git error.
+ def cache_method_output(key, fallback: nil, memoize_only: false, &block)
+ ivar = cache_instance_variable_name(key)
+
+ if instance_variable_defined?(ivar)
+ instance_variable_get(ivar)
+ else
+ # If the repository doesn't exist and a fallback was specified we return
+ # that value inmediately. This saves us Rugged/gRPC invocations.
+ return fallback unless fallback.nil? || cache.repository.exists?
+
+ begin
+ value =
+ if memoize_only
+ yield
+ else
+ cache.fetch(key, &block)
+ end
+
+ instance_variable_set(ivar, value)
+ rescue Gitlab::Git::Repository::NoRepository
+ # Even if the above `#exists?` check passes these errors might still
+ # occur (for example because of a non-existing HEAD). We want to
+ # gracefully handle this and not cache anything
+ fallback
+ end
+ end
+ end
+
+ # Expires the caches of a specific set of methods
+ def expire_method_caches(methods)
+ methods.each do |key|
+ cache.expire(key)
+
+ ivar = cache_instance_variable_name(key)
+
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
+ end
+ end
+
+ private
+
+ def cache_instance_variable_name(key)
+ :"@#{key.to_s.tr('?!', '')}"
+ end
+ end
+end
diff --git a/lib/gitlab/saml/auth_hash.rb b/lib/gitlab/saml/auth_hash.rb
deleted file mode 100644
index 33d19373098..00000000000
--- a/lib/gitlab/saml/auth_hash.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-module Gitlab
- module Saml
- class AuthHash < Gitlab::OAuth::AuthHash
- def groups
- Array.wrap(get_raw(Gitlab::Saml::Config.groups))
- end
-
- private
-
- def get_raw(key)
- # Needs to call `all` because of https://git.io/vVo4u
- # otherwise just the first value is returned
- auth_hash.extra[:raw_info].all[key]
- end
- end
- end
-end
diff --git a/lib/gitlab/saml/config.rb b/lib/gitlab/saml/config.rb
deleted file mode 100644
index 574c3a4b28c..00000000000
--- a/lib/gitlab/saml/config.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-module Gitlab
- module Saml
- class Config
- class << self
- def options
- Gitlab.config.omniauth.providers.find { |provider| provider.name == 'saml' }
- end
-
- def groups
- options[:groups_attribute]
- end
-
- def external_groups
- options[:external_groups]
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/saml/user.rb b/lib/gitlab/saml/user.rb
deleted file mode 100644
index d8faf7aad8c..00000000000
--- a/lib/gitlab/saml/user.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# SAML extension for User model
-#
-# * Find GitLab user based on SAML uid and provider
-# * Create new user from SAML data
-#
-module Gitlab
- module Saml
- class User < Gitlab::OAuth::User
- def save
- super('SAML')
- end
-
- def find_user
- user = find_by_uid_and_provider
-
- user ||= find_by_email if auto_link_saml_user?
- user ||= find_or_build_ldap_user if auto_link_ldap_user?
- user ||= build_new_user if signup_enabled?
-
- if external_users_enabled? && user
- # Check if there is overlap between the user's groups and the external groups
- # setting then set user as external or internal.
- user.external = !(auth_hash.groups & Gitlab::Saml::Config.external_groups).empty?
- end
-
- user
- end
-
- def changed?
- return true unless gl_user
-
- gl_user.changed? || gl_user.identities.any?(&:changed?)
- end
-
- protected
-
- def auto_link_saml_user?
- Gitlab.config.omniauth.auto_link_saml_user
- end
-
- def external_users_enabled?
- !Gitlab::Saml::Config.external_groups.nil?
- end
-
- def auth_hash=(auth_hash)
- @auth_hash = Gitlab::Saml::AuthHash.new(auth_hash)
- end
- end
- end
-end
diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb
index 7037e2e61cc..1e45d074e0a 100644
--- a/lib/gitlab/search_results.rb
+++ b/lib/gitlab/search_results.rb
@@ -1,15 +1,19 @@
module Gitlab
class SearchResults
class FoundBlob
- attr_reader :id, :filename, :basename, :ref, :startline, :data
+ include EncodingHelper
+
+ attr_reader :id, :filename, :basename, :ref, :startline, :data, :project_id
def initialize(opts = {})
@id = opts.fetch(:id, nil)
- @filename = opts.fetch(:filename, nil)
- @basename = opts.fetch(:basename, nil)
+ @filename = encode_utf8(opts.fetch(:filename, nil))
+ @basename = encode_utf8(opts.fetch(:basename, nil))
@ref = opts.fetch(:ref, nil)
@startline = opts.fetch(:startline, nil)
- @data = opts.fetch(:data, nil)
+ @data = encode_utf8(opts.fetch(:data, nil))
+ @per_page = opts.fetch(:per_page, 20)
+ @project_id = opts.fetch(:project_id, nil)
end
def path
@@ -21,7 +25,7 @@ module Gitlab
end
end
- attr_reader :current_user, :query
+ attr_reader :current_user, :query, :per_page
# Limit search results by passed projects
# It allows us to search only for projects user has access to
@@ -33,56 +37,74 @@ module Gitlab
# query
attr_reader :default_project_filter
- def initialize(current_user, limit_projects, query, default_project_filter: false)
+ def initialize(current_user, limit_projects, query, default_project_filter: false, per_page: 20)
@current_user = current_user
@limit_projects = limit_projects || Project.all
@query = query
@default_project_filter = default_project_filter
+ @per_page = per_page
end
- def objects(scope, page = nil)
- case scope
- when 'projects'
- projects.page(page).per(per_page)
- when 'issues'
- issues.page(page).per(per_page)
- when 'merge_requests'
- merge_requests.page(page).per(per_page)
- when 'milestones'
- milestones.page(page).per(per_page)
- else
- Kaminari.paginate_array([]).page(page).per(per_page)
- end
+ def objects(scope, page = nil, without_count = true)
+ collection = case scope
+ when 'projects'
+ projects.page(page).per(per_page)
+ when 'issues'
+ issues.page(page).per(per_page)
+ when 'merge_requests'
+ merge_requests.page(page).per(per_page)
+ when 'milestones'
+ milestones.page(page).per(per_page)
+ else
+ Kaminari.paginate_array([]).page(page).per(per_page)
+ end
+
+ without_count ? collection.without_count : collection
end
- def projects_count
- @projects_count ||= projects.count
+ def limited_projects_count
+ @limited_projects_count ||= projects.limit(count_limit).count
end
- def issues_count
- @issues_count ||= issues.count
+ def limited_issues_count
+ return @limited_issues_count if @limited_issues_count
+
+ # By default getting limited count (e.g. 1000+) is fast on issuable
+ # collections except for issues, where filtering both not confidential
+ # and confidential issues user has access to, is too complex.
+ # It's faster to try to fetch all public issues first, then only
+ # if necessary try to fetch all issues.
+ sum = issues(public_only: true).limit(count_limit).count
+ @limited_issues_count = sum < count_limit ? issues.limit(count_limit).count : sum
end
- def merge_requests_count
- @merge_requests_count ||= merge_requests.count
+ def limited_merge_requests_count
+ @limited_merge_requests_count ||= merge_requests.limit(count_limit).count
end
- def milestones_count
- @milestones_count ||= milestones.count
+ def limited_milestones_count
+ @limited_milestones_count ||= milestones.limit(count_limit).count
end
def single_commit_result?
false
end
+ def count_limit
+ 1001
+ end
+
private
def projects
limit_projects.search(query)
end
- def issues
- issues = IssuesFinder.new(current_user).execute.where(project_id: project_ids_relation)
+ def issues(finder_params = {})
+ issues = IssuesFinder.new(current_user, finder_params).execute
+ unless default_project_filter
+ issues = issues.where(project_id: project_ids_relation)
+ end
issues =
if query =~ /#(\d+)\z/
@@ -91,13 +113,13 @@ module Gitlab
issues.full_search(query)
end
- issues.order('updated_at DESC')
+ issues.reorder('updated_at DESC')
end
def milestones
milestones = Milestone.where(project_id: project_ids_relation)
milestones = milestones.search(query)
- milestones.order('updated_at DESC')
+ milestones.reorder('updated_at DESC')
end
def merge_requests
@@ -112,17 +134,14 @@ module Gitlab
else
merge_requests.full_search(query)
end
- merge_requests.order('updated_at DESC')
+
+ merge_requests.reorder('updated_at DESC')
end
def default_scope
'projects'
end
- def per_page
- 20
- end
-
def project_ids_relation
limit_projects.select(:id).reorder(nil)
end
diff --git a/lib/gitlab/seeder.rb b/lib/gitlab/seeder.rb
index 30df7e4a831..98f005cb61b 100644
--- a/lib/gitlab/seeder.rb
+++ b/lib/gitlab/seeder.rb
@@ -5,10 +5,16 @@ module DeliverNever
end
end
+module MuteNotifications
+ def new_note(note)
+ end
+end
+
module Gitlab
class Seeder
def self.quiet
- mute_mailer unless Rails.env.test?
+ mute_notifications
+ mute_mailer
SeedFu.quiet = true
@@ -18,6 +24,10 @@ module Gitlab
puts "\nOK".color(:green)
end
+ def self.mute_notifications
+ NotificationService.prepend(MuteNotifications)
+ end
+
def self.mute_mailer
ActionMailer::MessageDelivery.prepend(DeliverNever)
end
diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb
index 159d0e7952e..4a22fc80f75 100644
--- a/lib/gitlab/sentry.rb
+++ b/lib/gitlab/sentry.rb
@@ -1,9 +1,7 @@
module Gitlab
module Sentry
- extend Gitlab::CurrentSettings
-
def self.enabled?
- Rails.env.production? && current_application_settings.sentry_enabled?
+ Rails.env.production? && Gitlab::CurrentSettings.sentry_enabled?
end
def self.context(current_user = nil)
diff --git a/lib/gitlab/setup_helper.rb b/lib/gitlab/setup_helper.rb
new file mode 100644
index 00000000000..e5c02dd8ecc
--- /dev/null
+++ b/lib/gitlab/setup_helper.rb
@@ -0,0 +1,61 @@
+module Gitlab
+ module SetupHelper
+ class << self
+ # 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 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.
+ # For development and testing purposes, an extra storage is added to gitaly,
+ # which is not known to Rails, but must be explicitly stubbed.
+ def gitaly_configuration_toml(gitaly_dir, gitaly_ruby: true)
+ storages = []
+ address = nil
+
+ Gitlab.config.repositories.storages.each do |key, val|
+ if address
+ if address != val['gitaly_address']
+ raise ArgumentError, "Your gitlab.yml contains more than one gitaly_address."
+ end
+ elsif URI(val['gitaly_address']).scheme != 'unix'
+ raise ArgumentError, "Automatic config.toml generation only supports 'unix:' addresses."
+ else
+ address = val['gitaly_address']
+ end
+
+ storages << { name: key, path: val.legacy_disk_path }
+ end
+
+ if Rails.env.test?
+ storages << { name: 'test_second_storage', path: Rails.root.join('tmp', 'tests', 'second_storage').to_s }
+ end
+
+ config = { socket_path: address.sub(/\Aunix:/, ''), storage: storages }
+ config[:auth] = { token: 'secret' } if Rails.env.test?
+ config[:'gitaly-ruby'] = { dir: File.join(gitaly_dir, 'ruby') } if gitaly_ruby
+ config[:'gitlab-shell'] = { dir: Gitlab.config.gitlab_shell.path }
+ config[:bin_dir] = Gitlab.config.gitaly.client_path
+
+ TomlRB.dump(config)
+ end
+
+ # rubocop:disable Rails/Output
+ def create_gitaly_configuration(dir, force: false)
+ config_path = File.join(dir, 'config.toml')
+ FileUtils.rm_f(config_path) if force
+
+ File.open(config_path, File::WRONLY | File::CREAT | File::EXCL) do |f|
+ f.puts gitaly_configuration_toml(dir)
+ end
+ rescue Errno::EEXIST
+ puts "Skipping config.toml generation:"
+ puts "A configuration file already exists."
+ rescue ArgumentError => e
+ puts "Skipping config.toml generation:"
+ puts e.message
+ end
+ # rubocop:enable Rails/Output
+ end
+ end
+end
diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb
index 9cdd3d22f18..67407b651a5 100644
--- a/lib/gitlab/shell.rb
+++ b/lib/gitlab/shell.rb
@@ -69,39 +69,43 @@ module Gitlab
# name - project disk path
#
# Ex.
- # add_repository("/path/to/storage", "gitlab/gitlab-ci")
+ # create_repository("/path/to/storage", "gitlab/gitlab-ci")
#
- # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
- def add_repository(storage, name)
+ def create_repository(storage, name)
relative_path = name.dup
relative_path << '.git' unless relative_path.end_with?('.git')
- gitaly_migrate(:create_repository) do |is_enabled|
+ gitaly_migrate(:create_repository,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |is_enabled|
if is_enabled
repository = Gitlab::Git::Repository.new(storage, relative_path, '')
repository.gitaly_repository_client.create_repository
true
else
- repo_path = File.join(Gitlab.config.repositories.storages[storage]['path'], relative_path)
+ repo_path = File.join(Gitlab.config.repositories.storages[storage].legacy_disk_path, relative_path)
Gitlab::Git::Repository.create(repo_path, bare: true, symlink_hooks_to: gitlab_shell_hooks_path)
end
end
- rescue => err
+ rescue => err # Once the Rugged codes gets removes this can be improved
Rails.logger.error("Failed to add repository #{storage}/#{name}: #{err}")
false
end
# Import repository
#
- # storage - project's storage path
+ # storage - project's storage name
# name - project disk path
# url - URL to import from
#
# Ex.
- # import_repository("/path/to/storage", "gitlab/gitlab-ci", "https://gitlab.com/gitlab-org/gitlab-test.git")
+ # import_repository("nfs-file06", "gitlab/gitlab-ci", "https://gitlab.com/gitlab-org/gitlab-test.git")
#
- # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/874
def import_repository(storage, name, url)
+ if url.start_with?('.', '/')
+ raise Error.new("don't use disk paths with import_repository: #{url.inspect}")
+ end
+
# The timeout ensures the subprocess won't hang forever
cmd = gitlab_projects(storage, "#{name}.git")
success = cmd.import_project(url, git_timeout)
@@ -122,19 +126,20 @@ module Gitlab
# Ex.
# fetch_remote(my_repo, "upstream")
#
- # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
- def fetch_remote(repository, remote, ssh_auth: nil, forced: false, no_tags: false)
+ def fetch_remote(repository, remote, ssh_auth: nil, forced: false, no_tags: false, prune: true)
gitaly_migrate(:fetch_remote) do |is_enabled|
if is_enabled
- repository.gitaly_repository_client.fetch_remote(remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags)
+ repository.gitaly_repository_client.fetch_remote(remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, timeout: git_timeout, prune: prune)
else
- storage_path = Gitlab.config.repositories.storages[repository.storage]["path"]
- local_fetch_remote(storage_path, repository.relative_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags)
+ local_fetch_remote(repository.storage, repository.relative_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune)
end
end
end
- # Move repository
+ # Move repository reroutes to mv_directory which is an alias for
+ # mv_namespace. Given the underlying implementation is a move action,
+ # indescriminate of what the folders might be.
+ #
# storage - project's storage path
# path - project disk path
# new_path - new project disk path
@@ -142,27 +147,31 @@ 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
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/873
def mv_repository(storage, path, new_path)
- gitlab_projects(storage, "#{path}.git").mv_project("#{new_path}.git")
+ return false if path.empty? || new_path.empty?
+
+ !!mv_directory(storage, "#{path}.git", "#{new_path}.git")
end
# Fork repository to new path
- # forked_from_storage - forked-from project's storage path
- # forked_from_disk_path - project disk path
- # forked_to_storage - forked-to project's storage path
- # forked_to_disk_path - forked project disk path
+ # forked_from_storage - forked-from project's storage name
+ # forked_from_disk_path - project disk relative path
+ # forked_to_storage - forked-to project's storage name
+ # forked_to_disk_path - forked project disk relative path
#
# Ex.
- # fork_repository("/path/to/forked_from/storage", "gitlab/gitlab-ci", "/path/to/forked_to/storage", "new-namespace/gitlab-ci")
+ # fork_repository("nfs-file06", "gitlab/gitlab-ci", "nfs-file07", "new-namespace/gitlab-ci")
#
- # Gitaly note: JV: not easy to migrate because this involves two Gitaly servers, not one.
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/817
def fork_repository(forked_from_storage, forked_from_disk_path, forked_to_storage, forked_to_disk_path)
gitlab_projects(forked_from_storage, "#{forked_from_disk_path}.git")
.fork_repository(forked_to_storage, "#{forked_to_disk_path}.git")
end
- # Remove repository from file system
+ # Removes a repository from file system, using rm_diretory which is an alias
+ # for rm_namespace. Given the underlying implementation removes the name
+ # passed as second argument on the passed storage.
#
# storage - project's storage path
# name - project disk path
@@ -170,9 +179,14 @@ module Gitlab
# Ex.
# remove_repository("/path/to/storage", "gitlab/gitlab-ci")
#
- # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
+ # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/873
def remove_repository(storage, name)
- gitlab_projects(storage, "#{name}.git").rm_project
+ return false if name.empty?
+
+ !!rm_directory(storage, "#{name}.git")
+ rescue ArgumentError => e
+ Rails.logger.warn("Repository does not exist: #{e} at: #{name}.git")
+ false
end
# Add new key to gitlab-shell
@@ -181,6 +195,8 @@ module Gitlab
# add_key("key-42", "sha-rsa ...")
#
def add_key(key_id, key_content)
+ return unless self.authorized_keys_enabled?
+
gitlab_shell_fast_execute([gitlab_shell_keys_path,
'add-key', key_id, self.class.strip_key(key_content)])
end
@@ -190,6 +206,8 @@ module Gitlab
# Ex.
# batch_add_keys { |adder| adder.add_key("key-42", "sha-rsa ...") }
def batch_add_keys(&block)
+ return unless self.authorized_keys_enabled?
+
IO.popen(%W(#{gitlab_shell_path}/bin/gitlab-keys batch-add-keys), 'w') do |io|
yield(KeyAdder.new(io))
end
@@ -200,10 +218,11 @@ module Gitlab
# Ex.
# remove_key("key-342", "sha-rsa ...")
#
- def remove_key(key_id, key_content)
+ def remove_key(key_id, key_content = nil)
+ return unless self.authorized_keys_enabled?
+
args = [gitlab_shell_keys_path, 'rm-key', key_id]
args << key_content if key_content
-
gitlab_shell_fast_execute(args)
end
@@ -213,17 +232,70 @@ module Gitlab
# remove_all_keys
#
def remove_all_keys
+ return unless self.authorized_keys_enabled?
+
gitlab_shell_fast_execute([gitlab_shell_keys_path, 'clear'])
end
+ # Remove ssh keys from gitlab shell that are not in the DB
+ #
+ # Ex.
+ # remove_keys_not_found_in_db
+ #
+ def remove_keys_not_found_in_db
+ return unless self.authorized_keys_enabled?
+
+ Rails.logger.info("Removing keys not found in DB")
+
+ batch_read_key_ids do |ids_in_file|
+ ids_in_file.uniq!
+ keys_in_db = Key.where(id: ids_in_file)
+
+ next unless ids_in_file.size > keys_in_db.count # optimization
+
+ ids_to_remove = ids_in_file - keys_in_db.pluck(:id)
+ ids_to_remove.each do |id|
+ Rails.logger.info("Removing key-#{id} not found in DB")
+ remove_key("key-#{id}")
+ end
+ end
+ end
+
+ # Iterate over all ssh key IDs from gitlab shell, in batches
+ #
+ # Ex.
+ # batch_read_key_ids { |batch| keys = Key.where(id: batch) }
+ #
+ def batch_read_key_ids(batch_size: 100, &block)
+ return unless self.authorized_keys_enabled?
+
+ list_key_ids do |key_id_stream|
+ key_id_stream.lazy.each_slice(batch_size) do |lines|
+ key_ids = lines.map { |l| l.chomp.to_i }
+ yield(key_ids)
+ end
+ end
+ end
+
+ # Stream all ssh key IDs from gitlab shell, separated by newlines
+ #
+ # Ex.
+ # list_key_ids
+ #
+ def list_key_ids(&block)
+ return unless self.authorized_keys_enabled?
+
+ IO.popen(%W(#{gitlab_shell_path}/bin/gitlab-keys list-key-ids), &block)
+ end
+
# Add empty directory for storing repositories
#
# Ex.
# add_namespace("/path/to/storage", "gitlab")
#
- # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/385
def add_namespace(storage, name)
- Gitlab::GitalyClient.migrate(:add_namespace) do |enabled|
+ Gitlab::GitalyClient.migrate(:add_namespace,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled|
if enabled
gitaly_namespace_client(storage).add(name)
else
@@ -243,9 +315,9 @@ module Gitlab
# Ex.
# rm_namespace("/path/to/storage", "gitlab")
#
- # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/385
def rm_namespace(storage, name)
- Gitlab::GitalyClient.migrate(:remove_namespace) do |enabled|
+ Gitlab::GitalyClient.migrate(:remove_namespace,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled|
if enabled
gitaly_namespace_client(storage).remove(name)
else
@@ -255,15 +327,16 @@ module Gitlab
rescue GRPC::InvalidArgument => e
raise ArgumentError, e.message
end
+ alias_method :rm_directory, :rm_namespace
# Move namespace directory inside repositories storage
#
# 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)
- Gitlab::GitalyClient.migrate(:rename_namespace) do |enabled|
+ Gitlab::GitalyClient.migrate(:rename_namespace,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled|
if enabled
gitaly_namespace_client(storage).rename(old_name, new_name)
else
@@ -275,6 +348,7 @@ module Gitlab
rescue GRPC::InvalidArgument
false
end
+ alias_method :mv_directory, :mv_namespace
def url_to_repo(path)
Gitlab.config.gitlab_shell.ssh_path_prefix + "#{path}.git"
@@ -297,7 +371,8 @@ module Gitlab
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/385
def exists?(storage, dir_name)
- Gitlab::GitalyClient.migrate(:namespace_exists) do |enabled|
+ Gitlab::GitalyClient.migrate(:namespace_exists,
+ status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled|
if enabled
gitaly_namespace_client(storage).exists?(dir_name)
else
@@ -306,47 +381,6 @@ module Gitlab
end
end
- # Push branch to remote repository
- #
- # storage - project's storage path
- # project_name - project's disk path
- # remote_name - remote name
- # branch_names - remote branch names to push
- # forced - should we use --force flag
- #
- # Ex.
- # push_remote_branches('/path/to/storage', 'gitlab-org/gitlab-test' 'upstream', ['feature'])
- #
- def push_remote_branches(storage, project_name, remote_name, branch_names, forced: true)
- cmd = gitlab_projects(storage, "#{project_name}.git")
-
- success = cmd.push_branches(remote_name, git_timeout, forced, branch_names)
-
- raise Error, cmd.output unless success
-
- success
- end
-
- # Delete branch from remote repository
- #
- # storage - project's storage path
- # project_name - project's disk path
- # remote_name - remote name
- # branch_names - remote branch names
- #
- # Ex.
- # delete_remote_branches('/path/to/storage', 'gitlab-org/gitlab-test', 'upstream', ['feature'])
- #
- def delete_remote_branches(storage, project_name, remote_name, branch_names)
- cmd = gitlab_projects(storage, "#{project_name}.git")
-
- success = cmd.delete_remote_branches(remote_name, branch_names)
-
- raise Error, cmd.output unless success
-
- success
- end
-
protected
def gitlab_shell_path
@@ -375,19 +409,27 @@ module Gitlab
File.join(gitlab_shell_path, 'bin', 'gitlab-keys')
end
+ def authorized_keys_enabled?
+ # Return true if nil to ensure the authorized_keys methods work while
+ # fixing the authorized_keys file during migration.
+ return true if Gitlab::CurrentSettings.current_application_settings.authorized_keys_enabled.nil?
+
+ Gitlab::CurrentSettings.current_application_settings.authorized_keys_enabled
+ end
+
private
- def gitlab_projects(shard_path, disk_path)
+ def gitlab_projects(shard_name, disk_path)
Gitlab::Git::GitlabProjects.new(
- shard_path,
+ shard_name,
disk_path,
global_hooks_path: Gitlab.config.gitlab_shell.hooks_path,
logger: Rails.logger
)
end
- def local_fetch_remote(storage_path, repository_relative_path, remote, ssh_auth: nil, forced: false, no_tags: false)
- vars = { force: forced, tags: !no_tags }
+ def local_fetch_remote(storage_name, repository_relative_path, remote, ssh_auth: nil, forced: false, no_tags: false, prune: true)
+ vars = { force: forced, tags: !no_tags, prune: prune }
if ssh_auth&.ssh_import?
if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
@@ -399,7 +441,7 @@ module Gitlab
end
end
- cmd = gitlab_projects(storage_path, repository_relative_path)
+ cmd = gitlab_projects(storage_name, repository_relative_path)
success = cmd.fetch_remote(remote, git_timeout, vars)
@@ -435,7 +477,7 @@ module Gitlab
def gitaly_namespace_client(storage_path)
storage, _value = Gitlab.config.repositories.storages.find do |storage, value|
- value['path'] == storage_path
+ value.legacy_disk_path == storage_path
end
Gitlab::GitalyClient::NamespaceService.new(storage)
@@ -445,8 +487,8 @@ module Gitlab
Gitlab.config.gitlab_shell.git_timeout
end
- def gitaly_migrate(method, &block)
- Gitlab::GitalyClient.migrate(method, &block)
+ def gitaly_migrate(method, status: Gitlab::GitalyClient::MigrationStatus::OPT_IN, &block)
+ Gitlab::GitalyClient.migrate(method, status: status, &block)
rescue GRPC::NotFound, GRPC::BadStatus => e
# Old Popen code returns [Error, output] to the caller, so we
# need to do the same here...
diff --git a/lib/gitlab/sherlock/file_sample.rb b/lib/gitlab/sherlock/file_sample.rb
index 8a3e1a5e5bf..89072b01f2e 100644
--- a/lib/gitlab/sherlock/file_sample.rb
+++ b/lib/gitlab/sherlock/file_sample.rb
@@ -16,7 +16,7 @@ module Gitlab
end
def relative_path
- @relative_path ||= @file.gsub(/^#{Rails.root.to_s}\/?/, '')
+ @relative_path ||= @file.gsub(%r{^#{Rails.root.to_s}/?}, '')
end
def to_param
diff --git a/lib/gitlab/sherlock/middleware.rb b/lib/gitlab/sherlock/middleware.rb
index 687332fc5fc..4c88e33699a 100644
--- a/lib/gitlab/sherlock/middleware.rb
+++ b/lib/gitlab/sherlock/middleware.rb
@@ -2,7 +2,7 @@ module Gitlab
module Sherlock
# Rack middleware used for tracking request metrics.
class Middleware
- CONTENT_TYPES = /text\/html|application\/json/i
+ CONTENT_TYPES = %r{text/html|application/json}i
IGNORE_PATHS = %r{^/sherlock}
diff --git a/lib/gitlab/sherlock/query.rb b/lib/gitlab/sherlock/query.rb
index 948bf5e6528..02ddc3f47eb 100644
--- a/lib/gitlab/sherlock/query.rb
+++ b/lib/gitlab/sherlock/query.rb
@@ -4,7 +4,7 @@ module Gitlab
attr_reader :id, :query, :started_at, :finished_at, :backtrace
# SQL identifiers that should be prefixed with newlines.
- PREFIX_NEWLINE = /
+ PREFIX_NEWLINE = %r{
\s+(FROM
|(LEFT|RIGHT)?INNER\s+JOIN
|(LEFT|RIGHT)?OUTER\s+JOIN
@@ -13,7 +13,7 @@ module Gitlab
|GROUP\s+BY
|ORDER\s+BY
|LIMIT
- |OFFSET)\s+/ix # Vim indent breaks when this is on a newline :<
+ |OFFSET)\s+}ix # Vim indent breaks when this is on a newline :<
# Creates a new Query using a String and a separate Array of bindings.
#
diff --git a/lib/gitlab/sidekiq_logging/json_formatter.rb b/lib/gitlab/sidekiq_logging/json_formatter.rb
new file mode 100644
index 00000000000..98f8222fd03
--- /dev/null
+++ b/lib/gitlab/sidekiq_logging/json_formatter.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module SidekiqLogging
+ class JSONFormatter
+ def call(severity, timestamp, progname, data)
+ output = {
+ severity: severity,
+ time: timestamp.utc.iso8601(3)
+ }
+
+ case data
+ when String
+ output[:message] = data
+ when Hash
+ output.merge!(data)
+ end
+
+ output.to_json + "\n"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/sidekiq_logging/structured_logger.rb b/lib/gitlab/sidekiq_logging/structured_logger.rb
new file mode 100644
index 00000000000..9a89ae70b98
--- /dev/null
+++ b/lib/gitlab/sidekiq_logging/structured_logger.rb
@@ -0,0 +1,96 @@
+module Gitlab
+ module SidekiqLogging
+ class StructuredLogger
+ START_TIMESTAMP_FIELDS = %w[created_at enqueued_at].freeze
+ DONE_TIMESTAMP_FIELDS = %w[started_at retried_at failed_at completed_at].freeze
+
+ def call(job, queue)
+ started_at = current_time
+ base_payload = parse_job(job)
+
+ Sidekiq.logger.info log_job_start(started_at, base_payload)
+
+ yield
+
+ Sidekiq.logger.info log_job_done(started_at, base_payload)
+ rescue => job_exception
+ Sidekiq.logger.warn log_job_done(started_at, base_payload, job_exception)
+
+ raise
+ end
+
+ private
+
+ def base_message(payload)
+ "#{payload['class']} JID-#{payload['jid']}"
+ end
+
+ def log_job_start(started_at, payload)
+ payload['message'] = "#{base_message(payload)}: start"
+ payload['job_status'] = 'start'
+
+ payload
+ end
+
+ def log_job_done(started_at, payload, job_exception = nil)
+ payload = payload.dup
+ payload['duration'] = elapsed(started_at)
+ payload['completed_at'] = Time.now.utc
+
+ message = base_message(payload)
+
+ if job_exception
+ payload['message'] = "#{message}: fail: #{payload['duration']} sec"
+ payload['job_status'] = 'fail'
+ payload['error_message'] = job_exception.message
+ payload['error'] = job_exception.class
+ payload['error_backtrace'] = backtrace_cleaner.clean(job_exception.backtrace)
+ else
+ payload['message'] = "#{message}: done: #{payload['duration']} sec"
+ payload['job_status'] = 'done'
+ end
+
+ convert_to_iso8601(payload, DONE_TIMESTAMP_FIELDS)
+
+ payload
+ end
+
+ def parse_job(job)
+ job = job.dup
+
+ # Add process id params
+ job['pid'] = ::Process.pid
+
+ job.delete('args') unless ENV['SIDEKIQ_LOG_ARGUMENTS']
+
+ convert_to_iso8601(job, START_TIMESTAMP_FIELDS)
+
+ job
+ end
+
+ def convert_to_iso8601(payload, keys)
+ keys.each do |key|
+ payload[key] = format_time(payload[key]) if payload[key]
+ end
+ end
+
+ def elapsed(start)
+ (current_time - start).round(3)
+ end
+
+ def current_time
+ Gitlab::Metrics::System.monotonic_time
+ end
+
+ def backtrace_cleaner
+ @backtrace_cleaner ||= ActiveSupport::BacktraceCleaner.new
+ end
+
+ def format_time(timestamp)
+ return timestamp if timestamp.is_a?(String)
+
+ Time.at(timestamp).utc.iso8601(3)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/sidekiq_middleware/memory_killer.rb b/lib/gitlab/sidekiq_middleware/memory_killer.rb
deleted file mode 100644
index 2bfb7caefd9..00000000000
--- a/lib/gitlab/sidekiq_middleware/memory_killer.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-module Gitlab
- module SidekiqMiddleware
- class MemoryKiller
- # Default the RSS limit to 0, meaning the MemoryKiller is disabled
- MAX_RSS = (ENV['SIDEKIQ_MEMORY_KILLER_MAX_RSS'] || 0).to_s.to_i
- # Give Sidekiq 15 minutes of grace time after exceeding the RSS limit
- GRACE_TIME = (ENV['SIDEKIQ_MEMORY_KILLER_GRACE_TIME'] || 15 * 60).to_s.to_i
- # Wait 30 seconds for running jobs to finish during graceful shutdown
- SHUTDOWN_WAIT = (ENV['SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT'] || 30).to_s.to_i
-
- # Create a mutex used to ensure there will be only one thread waiting to
- # shut Sidekiq down
- MUTEX = Mutex.new
-
- def call(worker, job, queue)
- yield
-
- current_rss = get_rss
-
- return unless MAX_RSS > 0 && current_rss > MAX_RSS
-
- Thread.new do
- # Return if another thread is already waiting to shut Sidekiq down
- return unless MUTEX.try_lock
-
- Sidekiq.logger.warn "Sidekiq worker PID-#{pid} current RSS #{current_rss}"\
- " exceeds maximum RSS #{MAX_RSS} after finishing job #{worker.class} JID-#{job['jid']}"
- Sidekiq.logger.warn "Sidekiq worker PID-#{pid} will stop fetching new jobs in #{GRACE_TIME} seconds, and will be shut down #{SHUTDOWN_WAIT} seconds later"
-
- # Wait `GRACE_TIME` to give the memory intensive job time to finish.
- # Then, tell Sidekiq to stop fetching new jobs.
- wait_and_signal(GRACE_TIME, 'SIGSTP', 'stop fetching new jobs')
-
- # Wait `SHUTDOWN_WAIT` to give already fetched jobs time to finish.
- # Then, tell Sidekiq to gracefully shut down by giving jobs a few more
- # moments to finish, killing and requeuing them if they didn't, and
- # then terminating itself.
- wait_and_signal(SHUTDOWN_WAIT, 'SIGTERM', 'gracefully shut down')
-
- # Wait for Sidekiq to shutdown gracefully, and kill it if it didn't.
- wait_and_signal(Sidekiq.options[:timeout] + 2, 'SIGKILL', 'die')
- end
- end
-
- private
-
- def get_rss
- output, status = Gitlab::Popen.popen(%W(ps -o rss= -p #{pid}))
- return 0 unless status.zero?
-
- output.to_i
- end
-
- def wait_and_signal(time, signal, explanation)
- Sidekiq.logger.warn "waiting #{time} seconds before sending Sidekiq worker PID-#{pid} #{signal} (#{explanation})"
- sleep(time)
-
- Sidekiq.logger.warn "sending Sidekiq worker PID-#{pid} #{signal} (#{explanation})"
- Process.kill(signal, pid)
- end
-
- def pid
- Process.pid
- end
- end
- end
-end
diff --git a/lib/gitlab/sidekiq_middleware/shutdown.rb b/lib/gitlab/sidekiq_middleware/shutdown.rb
new file mode 100644
index 00000000000..c2b8d6de66e
--- /dev/null
+++ b/lib/gitlab/sidekiq_middleware/shutdown.rb
@@ -0,0 +1,133 @@
+require 'mutex_m'
+
+module Gitlab
+ module SidekiqMiddleware
+ class Shutdown
+ extend Mutex_m
+
+ # Default the RSS limit to 0, meaning the MemoryKiller is disabled
+ MAX_RSS = (ENV['SIDEKIQ_MEMORY_KILLER_MAX_RSS'] || 0).to_s.to_i
+ # Give Sidekiq 15 minutes of grace time after exceeding the RSS limit
+ GRACE_TIME = (ENV['SIDEKIQ_MEMORY_KILLER_GRACE_TIME'] || 15 * 60).to_s.to_i
+ # Wait 30 seconds for running jobs to finish during graceful shutdown
+ SHUTDOWN_WAIT = (ENV['SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT'] || 30).to_s.to_i
+
+ # This exception can be used to request that the middleware start shutting down Sidekiq
+ WantShutdown = Class.new(StandardError)
+
+ ShutdownWithoutRaise = Class.new(WantShutdown)
+ private_constant :ShutdownWithoutRaise
+
+ # For testing only, to avoid race conditions (?) in Rspec mocks.
+ attr_reader :trace
+
+ # We store the shutdown thread in a class variable to ensure that there
+ # can be only one shutdown thread in the process.
+ def self.create_shutdown_thread
+ mu_synchronize do
+ return unless @shutdown_thread.nil?
+
+ @shutdown_thread = Thread.new { yield }
+ end
+ end
+
+ # For testing only: so we can wait for the shutdown thread to finish.
+ def self.shutdown_thread
+ mu_synchronize { @shutdown_thread }
+ end
+
+ # For testing only: so that we can reset the global state before each test.
+ def self.clear_shutdown_thread
+ mu_synchronize { @shutdown_thread = nil }
+ end
+
+ def initialize
+ @trace = Queue.new if Rails.env.test?
+ end
+
+ def call(worker, job, queue)
+ shutdown_exception = nil
+
+ begin
+ yield
+ check_rss!
+ rescue WantShutdown => ex
+ shutdown_exception = ex
+ end
+
+ return unless shutdown_exception
+
+ self.class.create_shutdown_thread do
+ do_shutdown(worker, job, shutdown_exception)
+ end
+
+ raise shutdown_exception unless shutdown_exception.is_a?(ShutdownWithoutRaise)
+ end
+
+ private
+
+ def do_shutdown(worker, job, shutdown_exception)
+ Sidekiq.logger.warn "Sidekiq worker PID-#{pid} shutting down because of #{shutdown_exception} after job "\
+ "#{worker.class} JID-#{job['jid']}"
+ Sidekiq.logger.warn "Sidekiq worker PID-#{pid} will stop fetching new jobs in #{GRACE_TIME} seconds, and will be shut down #{SHUTDOWN_WAIT} seconds later"
+
+ # Wait `GRACE_TIME` to give the memory intensive job time to finish.
+ # Then, tell Sidekiq to stop fetching new jobs.
+ wait_and_signal(GRACE_TIME, 'SIGTSTP', 'stop fetching new jobs')
+
+ # Wait `SHUTDOWN_WAIT` to give already fetched jobs time to finish.
+ # Then, tell Sidekiq to gracefully shut down by giving jobs a few more
+ # moments to finish, killing and requeuing them if they didn't, and
+ # then terminating itself.
+ wait_and_signal(SHUTDOWN_WAIT, 'SIGTERM', 'gracefully shut down')
+
+ # Wait for Sidekiq to shutdown gracefully, and kill it if it didn't.
+ wait_and_signal(Sidekiq.options[:timeout] + 2, 'SIGKILL', 'die')
+ end
+
+ def check_rss!
+ return unless MAX_RSS > 0
+
+ current_rss = get_rss
+ return unless current_rss > MAX_RSS
+
+ raise ShutdownWithoutRaise.new("current RSS #{current_rss} exceeds maximum RSS #{MAX_RSS}")
+ end
+
+ def get_rss
+ output, status = Gitlab::Popen.popen(%W(ps -o rss= -p #{pid}), Rails.root.to_s)
+ return 0 unless status.zero?
+
+ output.to_i
+ end
+
+ def wait_and_signal(time, signal, explanation)
+ Sidekiq.logger.warn "waiting #{time} seconds before sending Sidekiq worker PID-#{pid} #{signal} (#{explanation})"
+ sleep(time)
+
+ Sidekiq.logger.warn "sending Sidekiq worker PID-#{pid} #{signal} (#{explanation})"
+ kill(signal, pid)
+ end
+
+ def pid
+ Process.pid
+ end
+
+ def sleep(time)
+ if Rails.env.test?
+ @trace << [:sleep, time]
+ else
+ Kernel.sleep(time)
+ end
+ end
+
+ def kill(signal, pid)
+ if Rails.env.test?
+ @trace << [:kill, signal, pid]
+ else
+ Process.kill(signal, pid)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/base_command.rb b/lib/gitlab/slash_commands/base_command.rb
index cc3c9a50555..466554e398c 100644
--- a/lib/gitlab/slash_commands/base_command.rb
+++ b/lib/gitlab/slash_commands/base_command.rb
@@ -31,10 +31,11 @@ module Gitlab
raise NotImplementedError
end
- attr_accessor :project, :current_user, :params
+ attr_accessor :project, :current_user, :params, :chat_name
- def initialize(project, user, params = {})
- @project, @current_user, @params = project, user, params.dup
+ def initialize(project, chat_name, params = {})
+ @project, @current_user, @params = project, chat_name.user, params.dup
+ @chat_name = chat_name
end
private
diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb
index a78408b0519..bb778f37096 100644
--- a/lib/gitlab/slash_commands/command.rb
+++ b/lib/gitlab/slash_commands/command.rb
@@ -5,6 +5,7 @@ module Gitlab
Gitlab::SlashCommands::IssueShow,
Gitlab::SlashCommands::IssueNew,
Gitlab::SlashCommands::IssueSearch,
+ Gitlab::SlashCommands::IssueMove,
Gitlab::SlashCommands::Deploy
].freeze
@@ -13,12 +14,13 @@ module Gitlab
if command
if command.allowed?(project, current_user)
- command.new(project, current_user, params).execute(match)
+ command.new(project, chat_name, 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])
+ Gitlab::SlashCommands::Help.new(project, chat_name, params)
+ .execute(available_commands, params[:text])
end
end
diff --git a/lib/gitlab/slash_commands/issue_move.rb b/lib/gitlab/slash_commands/issue_move.rb
new file mode 100644
index 00000000000..3985e635983
--- /dev/null
+++ b/lib/gitlab/slash_commands/issue_move.rb
@@ -0,0 +1,45 @@
+module Gitlab
+ module SlashCommands
+ class IssueMove < IssueCommand
+ def self.match(text)
+ %r{
+ \A # the beginning of a string
+ issue\s+move\s+ # the command
+ \#?(?<iid>\d+)\s+ # the issue id, may preceded by hash sign
+ (to\s+)? # aid the command to be much more human-ly
+ (?<project_path>[^\s]+) # named group for id of dest. project
+ }x.match(text)
+ end
+
+ def self.help_message
+ 'issue move <issue_id> (to)? <project_path>'
+ end
+
+ def self.allowed?(project, user)
+ can?(user, :admin_issue, project)
+ end
+
+ def execute(match)
+ old_issue = find_by_iid(match[:iid])
+ target_project = Project.find_by_full_path(match[:project_path])
+
+ unless current_user.can?(:read_project, target_project) && old_issue
+ return Gitlab::SlashCommands::Presenters::Access.new.not_found
+ end
+
+ new_issue = Issues::MoveService.new(project, current_user)
+ .execute(old_issue, target_project)
+
+ presenter(new_issue).present(old_issue)
+ rescue Issues::MoveService::MoveError => e
+ presenter(old_issue).display_move_error(e.message)
+ end
+
+ private
+
+ def presenter(issue)
+ Gitlab::SlashCommands::Presenters::IssueMove.new(issue)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/issue_move.rb b/lib/gitlab/slash_commands/presenters/issue_move.rb
new file mode 100644
index 00000000000..03921729941
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/issue_move.rb
@@ -0,0 +1,53 @@
+# coding: utf-8
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class IssueMove < Presenters::Base
+ include Presenters::IssueBase
+
+ def present(old_issue)
+ in_channel_response(moved_issue(old_issue))
+ end
+
+ def display_move_error(error)
+ message = header_with_list("The action was not successful, because:", [error])
+
+ ephemeral_response(text: message)
+ end
+
+ private
+
+ def moved_issue(old_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(old_issue),
+ color: color(@resource),
+ fields: fields,
+ mrkdwn_in: [
+ :title,
+ :pretext,
+ :text,
+ :fields
+ ]
+ }
+ ]
+ }
+ end
+
+ def pretext(old_issue)
+ "Moved issue *#{issue_link(old_issue)}* to *#{issue_link(@resource)}*"
+ end
+
+ def issue_link(issue)
+ "[#{issue.to_reference}](#{project_issue_url(issue.project, issue)})"
+ 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
index 86490a39cc1..5964bfe9960 100644
--- a/lib/gitlab/slash_commands/presenters/issue_new.rb
+++ b/lib/gitlab/slash_commands/presenters/issue_new.rb
@@ -38,7 +38,7 @@ module Gitlab
end
def project_link
- "[#{project.name_with_namespace}](#{project.web_url})"
+ "[#{project.full_name}](#{project.web_url})"
end
def author_profile_link
diff --git a/lib/gitlab/slash_commands/presenters/issue_show.rb b/lib/gitlab/slash_commands/presenters/issue_show.rb
index c99316df667..562f15f403c 100644
--- a/lib/gitlab/slash_commands/presenters/issue_show.rb
+++ b/lib/gitlab/slash_commands/presenters/issue_show.rb
@@ -53,7 +53,7 @@ module Gitlab
end
def pretext
- "Issue *#{@resource.to_reference}* from #{project.name_with_namespace}"
+ "Issue *#{@resource.to_reference}* from #{project.full_name}"
end
end
end
diff --git a/lib/gitlab/slash_commands/result.rb b/lib/gitlab/slash_commands/result.rb
index 7021b4b01b2..3669dedf0fe 100644
--- a/lib/gitlab/slash_commands/result.rb
+++ b/lib/gitlab/slash_commands/result.rb
@@ -1,4 +1,4 @@
-module Gitlab
+module Gitlab # rubocop:disable Naming/FileName
module SlashCommands
Result = Struct.new(:type, :message)
end
diff --git a/lib/gitlab/snippet_search_results.rb b/lib/gitlab/snippet_search_results.rb
index b85f70e450e..4f86b3e8f73 100644
--- a/lib/gitlab/snippet_search_results.rb
+++ b/lib/gitlab/snippet_search_results.rb
@@ -16,7 +16,7 @@ module Gitlab
when 'snippet_blobs'
snippet_blobs.page(page).per(per_page)
else
- super
+ super(scope, nil, false)
end
end
diff --git a/lib/gitlab/sql/pattern.rb b/lib/gitlab/sql/pattern.rb
index 5f0c98cb5a4..53744bad1f4 100644
--- a/lib/gitlab/sql/pattern.rb
+++ b/lib/gitlab/sql/pattern.rb
@@ -25,7 +25,11 @@ module Gitlab
query.length >= MIN_CHARS_FOR_PARTIAL_MATCHING
end
- def fuzzy_arel_match(column, query)
+ # column - The column name to search in.
+ # query - The text to search for.
+ # lower_exact_match - When set to `true` we'll fall back to using
+ # `LOWER(column) = query` instead of using `ILIKE`.
+ def fuzzy_arel_match(column, query, lower_exact_match: false)
query = query.squish
return nil unless query.present?
@@ -36,7 +40,13 @@ module Gitlab
else
# No words of at least 3 chars, but we can search for an exact
# case insensitive match with the query as a whole
- arel_table[column].matches(sanitize_sql_like(query))
+ if lower_exact_match
+ Arel::Nodes::NamedFunction
+ .new('LOWER', [arel_table[column]])
+ .eq(query)
+ else
+ arel_table[column].matches(sanitize_sql_like(query))
+ end
end
end
diff --git a/lib/gitlab/ssh_public_key.rb b/lib/gitlab/ssh_public_key.rb
index 89ca1298120..6f63ea91ae8 100644
--- a/lib/gitlab/ssh_public_key.rb
+++ b/lib/gitlab/ssh_public_key.rb
@@ -21,6 +21,22 @@ module Gitlab
technology(name)&.supported_sizes
end
+ def self.sanitize(key_content)
+ ssh_type, *parts = key_content.strip.split
+
+ return key_content if parts.empty?
+
+ parts.each_with_object("#{ssh_type} ").with_index do |(part, content), index|
+ content << part
+
+ if Gitlab::SSHPublicKey.new(content).valid?
+ break [content, parts[index + 1]].compact.join(' ') # Add the comment part if present
+ elsif parts.size == index + 1 # return original content if we've reached the last element
+ break key_content
+ end
+ end
+ end
+
attr_reader :key_text, :key
# Unqualified MD5 fingerprint for compatibility
@@ -37,23 +53,23 @@ module Gitlab
end
def valid?
- key.present?
+ SSHKey.valid_ssh_public_key?(key_text)
end
def type
- technology.name if valid?
+ technology.name if key.present?
end
def bits
- return unless valid?
+ return if key.blank?
case type
when :rsa
- key.n.num_bits
+ key.n&.num_bits
when :dsa
- key.p.num_bits
+ key.p&.num_bits
when :ecdsa
- key.group.order.num_bits
+ key.group.order&.num_bits
when :ed25519
256
else
diff --git a/lib/gitlab/storage_check/cli.rb b/lib/gitlab/storage_check/cli.rb
index 04bf1bf1d26..9b64c8e033a 100644
--- a/lib/gitlab/storage_check/cli.rb
+++ b/lib/gitlab/storage_check/cli.rb
@@ -59,9 +59,11 @@ module Gitlab
if response.skipped_shards.any?
warnings << "Skipped shards: #{response.skipped_shards.join(', ')}"
end
+
if response.failing_shards.any?
warnings << "Failing shards: #{response.failing_shards.join(', ')}"
end
+
logger.warn(warnings.join(' - ')) if warnings.any?
end
end
diff --git a/lib/gitlab/string_placeholder_replacer.rb b/lib/gitlab/string_placeholder_replacer.rb
new file mode 100644
index 00000000000..9a2219b7d77
--- /dev/null
+++ b/lib/gitlab/string_placeholder_replacer.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ class StringPlaceholderReplacer
+ # This method accepts the following paras
+ # - string: the string to be analyzed
+ # - placeholder_regex: i.e. /%{project_path|project_id|default_branch|commit_sha}/
+ # - block: this block will be called with each placeholder found in the string using
+ # the placeholder regex. If the result of the block is nil, the original
+ # placeholder will be returned.
+
+ def self.replace_string_placeholders(string, placeholder_regex = nil, &block)
+ return string if string.blank? || placeholder_regex.blank? || !block_given?
+
+ replace_placeholders(string, placeholder_regex, &block)
+ end
+
+ class << self
+ private
+
+ # If the result of the block is nil, then the placeholder is returned
+ def replace_placeholders(string, placeholder_regex, &block)
+ string.gsub(/%{(#{placeholder_regex})}/) do |arg|
+ yield($~[1]) || arg
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/string_range_marker.rb b/lib/gitlab/string_range_marker.rb
index f9faa134206..c6ad997a4d4 100644
--- a/lib/gitlab/string_range_marker.rb
+++ b/lib/gitlab/string_range_marker.rb
@@ -14,7 +14,7 @@ module Gitlab
end
def mark(marker_ranges)
- return rich_line unless marker_ranges
+ return rich_line unless marker_ranges&.any?
if html_escaped
rich_marker_ranges = []
diff --git a/lib/gitlab/string_regex_marker.rb b/lib/gitlab/string_regex_marker.rb
index 7ebf1c0428c..b19aa6dea35 100644
--- a/lib/gitlab/string_regex_marker.rb
+++ b/lib/gitlab/string_regex_marker.rb
@@ -1,13 +1,15 @@
module Gitlab
class StringRegexMarker < StringRangeMarker
def mark(regex, group: 0, &block)
- regex_match = raw_line.match(regex)
- return rich_line unless regex_match
+ ranges = []
- begin_index, end_index = regex_match.offset(group)
- name_range = begin_index..(end_index - 1)
+ raw_line.scan(regex) do
+ begin_index, end_index = Regexp.last_match.offset(group)
- super([name_range], &block)
+ ranges << (begin_index..(end_index - 1))
+ end
+
+ super(ranges, &block)
end
end
end
diff --git a/lib/gitlab/task_helpers.rb b/lib/gitlab/task_helpers.rb
new file mode 100644
index 00000000000..42be301fd9b
--- /dev/null
+++ b/lib/gitlab/task_helpers.rb
@@ -0,0 +1,169 @@
+require 'rainbow/ext/string'
+require 'gitlab/utils/strong_memoize'
+
+# rubocop:disable Rails/Output
+module Gitlab
+ TaskFailedError = Class.new(StandardError)
+ TaskAbortedByUserError = Class.new(StandardError)
+
+ module TaskHelpers
+ include Gitlab::Utils::StrongMemoize
+
+ extend self
+
+ # Ask if the user wants to continue
+ #
+ # Returns "yes" the user chose to continue
+ # Raises Gitlab::TaskAbortedByUserError if the user chose *not* to continue
+ def ask_to_continue
+ answer = prompt("Do you want to continue (yes/no)? ".color(:blue), %w{yes no})
+ raise Gitlab::TaskAbortedByUserError unless answer == "yes"
+ end
+
+ # Check which OS is running
+ #
+ # It will primarily use lsb_relase to determine the OS.
+ # It has fallbacks to Debian, SuSE, OS X and systems running systemd.
+ def os_name
+ os_name = run_command(%w(lsb_release -irs))
+ os_name ||=
+ if File.readable?('/etc/system-release')
+ File.read('/etc/system-release')
+ elsif File.readable?('/etc/debian_version')
+ "Debian #{File.read('/etc/debian_version')}"
+ elsif File.readable?('/etc/SuSE-release')
+ File.read('/etc/SuSE-release')
+ elsif os_x_version = run_command(%w(sw_vers -productVersion))
+ "Mac OS X #{os_x_version}"
+ elsif File.readable?('/etc/os-release')
+ File.read('/etc/os-release').match(/PRETTY_NAME=\"(.+)\"/)[1]
+ end
+
+ os_name.try(:squish!)
+ end
+
+ # Prompt the user to input something
+ #
+ # message - the message to display before input
+ # choices - array of strings of acceptable answers or nil for any answer
+ #
+ # Returns the user's answer
+ def prompt(message, choices = nil)
+ begin
+ print(message)
+ answer = STDIN.gets.chomp
+ end while choices.present? && !choices.include?(answer)
+ answer
+ end
+
+ # Runs the given command and matches the output against the given pattern
+ #
+ # Returns nil if nothing matched
+ # Returns the MatchData if the pattern matched
+ #
+ # see also #run_command
+ # see also String#match
+ def run_and_match(command, regexp)
+ run_command(command).try(:match, regexp)
+ end
+
+ # Runs the given command
+ #
+ # Returns '' if the command was not found
+ # Returns the output of the command otherwise
+ #
+ # see also #run_and_match
+ def run_command(command)
+ output, _ = Gitlab::Popen.popen(command)
+ output
+ rescue Errno::ENOENT
+ '' # if the command does not exist, return an empty string
+ end
+
+ # Runs the given command and raises a Gitlab::TaskFailedError exception if
+ # the command does not exit with 0
+ #
+ # Returns the output of the command otherwise
+ def run_command!(command)
+ output, status = Gitlab::Popen.popen(command)
+
+ raise Gitlab::TaskFailedError.new(output) unless status.zero?
+
+ output
+ end
+
+ def uid_for(user_name)
+ run_command(%W(id -u #{user_name})).chomp.to_i
+ end
+
+ def gid_for(group_name)
+ Etc.getgrnam(group_name).gid
+ rescue ArgumentError # no group
+ "group #{group_name} doesn't exist"
+ end
+
+ def gitlab_user
+ Gitlab.config.gitlab.user
+ end
+
+ def gitlab_user?
+ strong_memoize(:is_gitlab_user) do
+ current_user = run_command(%w(whoami)).chomp
+ current_user == gitlab_user
+ end
+ end
+
+ def warn_user_is_not_gitlab
+ return if gitlab_user?
+
+ strong_memoize(:warned_user_not_gitlab) do
+ current_user = run_command(%w(whoami)).chomp
+
+ 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
+ end
+
+ def all_repos
+ Gitlab.config.repositories.storages.each_value do |repository_storage|
+ IO.popen(%W(find #{repository_storage.legacy_disk_path} -mindepth 2 -type d -name *.git)) do |find|
+ find.each_line do |path|
+ yield path.chomp
+ end
+ end
+ end
+ end
+
+ def repository_storage_paths_args
+ Gitlab.config.repositories.storages.values.map { |rs| rs.legacy_disk_path }
+ end
+
+ def user_home
+ Rails.env.test? ? Rails.root.join('tmp/tests') : Gitlab.config.gitlab.user_home
+ end
+
+ def checkout_or_clone_version(version:, repo:, target_dir:)
+ version =
+ if version.starts_with?("=")
+ version.sub(/\A=/, '') # tag or branch
+ else
+ "v#{version}" # tag
+ end
+
+ clone_repo(repo, target_dir) unless Dir.exist?(target_dir)
+ checkout_version(version, target_dir)
+ end
+
+ def clone_repo(repo, target_dir)
+ run_command!(%W[#{Gitlab.config.git.bin_path} clone -- #{repo} #{target_dir}])
+ end
+
+ def checkout_version(version, target_dir)
+ 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/gitlab/testing/request_blocker_middleware.rb b/lib/gitlab/testing/request_blocker_middleware.rb
index 4a8e3c2eee0..53333b9b06b 100644
--- a/lib/gitlab/testing/request_blocker_middleware.rb
+++ b/lib/gitlab/testing/request_blocker_middleware.rb
@@ -37,12 +37,14 @@ module Gitlab
def call(env)
increment_active_requests
+
if block_requests?
block_request(env)
else
sleep 0.2 if slow_requests?
@app.call(env)
end
+
ensure
decrement_active_requests
end
diff --git a/lib/gitlab/timeless.rb b/lib/gitlab/timeless.rb
index b290c716f97..76a1808c8ac 100644
--- a/lib/gitlab/timeless.rb
+++ b/lib/gitlab/timeless.rb
@@ -9,6 +9,7 @@ module Gitlab
else
block.call
end
+
ensure
model.record_timestamps = original_record_timestamps
end
diff --git a/lib/gitlab/upgrader.rb b/lib/gitlab/upgrader.rb
index 961df0468a4..024be6aca44 100644
--- a/lib/gitlab/upgrader.rb
+++ b/lib/gitlab/upgrader.rb
@@ -1,6 +1,3 @@
-require_relative "popen"
-require_relative "version_info"
-
module Gitlab
class Upgrader
def execute
@@ -12,6 +9,7 @@ module Gitlab
puts "You are using the latest GitLab version"
else
puts "Newer GitLab version is available"
+
answer = if ARGV.first == "-y"
"yes"
else
@@ -51,7 +49,7 @@ module Gitlab
def fetch_git_tags
remote_tags, _ = Gitlab::Popen.popen(%W(#{Gitlab.config.git.bin_path} ls-remote --tags https://gitlab.com/gitlab-org/gitlab-ce.git))
- remote_tags.split("\n").grep(/tags\/v#{current_version.major}/)
+ remote_tags.split("\n").grep(%r{tags/v#{current_version.major}})
end
def update_commands
@@ -77,6 +75,7 @@ module Gitlab
update_commands.each do |title, cmd|
puts title
puts " -> #{cmd.join(' ')}"
+
if system(env, *cmd)
puts " -> OK"
else
diff --git a/lib/gitlab/uploads_transfer.rb b/lib/gitlab/uploads_transfer.rb
index b5f41240529..7d7400bdabf 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, FileUploader.base_dir)
+ FileUploader.root
end
end
end
diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb
index 13150ddab67..db97f65bd54 100644
--- a/lib/gitlab/url_blocker.rb
+++ b/lib/gitlab/url_blocker.rb
@@ -2,49 +2,84 @@ require 'resolv'
module Gitlab
class UrlBlocker
- class << self
- # Used to specify what hosts and port numbers should be prohibited for project
- # imports.
- VALID_PORTS = [22, 80, 443].freeze
-
- def blocked_url?(url)
- return false if url.nil?
+ BlockedUrlError = Class.new(StandardError)
- blocked_ips = ["127.0.0.1", "::1", "0.0.0.0"]
- blocked_ips.concat(Socket.ip_address_list.map(&:ip_address))
+ class << self
+ def validate!(url, allow_localhost: false, allow_local_network: true, valid_ports: [])
+ return true if url.nil?
begin
uri = Addressable::URI.parse(url)
- # Allow imports from the GitLab instance itself but only from the configured ports
- return false if internal?(uri)
+ rescue Addressable::URI::InvalidURIError
+ raise BlockedUrlError, "URI is invalid"
+ end
- 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)
+ # Allow imports from the GitLab instance itself but only from the configured ports
+ return true if internal?(uri)
- server_ips = Addrinfo.getaddrinfo(uri.hostname, 80, nil, :STREAM).map(&:ip_address)
- return true if (blocked_ips & server_ips).any?
- rescue Addressable::URI::InvalidURIError
- return true
+ port = uri.port || uri.default_port
+ validate_port!(port, valid_ports) if valid_ports.any?
+ validate_user!(uri.user)
+ validate_hostname!(uri.hostname)
+
+ begin
+ addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM)
rescue SocketError
- return false
+ return true
end
+ validate_localhost!(addrs_info) unless allow_localhost
+ validate_local_network!(addrs_info) unless allow_local_network
+
+ true
+ end
+
+ def blocked_url?(*args)
+ validate!(*args)
+
false
+ rescue BlockedUrlError
+ true
end
private
- def blocked_port?(port)
- return false if port.blank?
+ def validate_port!(port, valid_ports)
+ return if port.blank?
+ # Only ports under 1024 are restricted
+ return if port >= 1024
+ return if valid_ports.include?(port)
+
+ raise BlockedUrlError, "Only allowed ports are #{valid_ports.join(', ')}, and any over 1024"
+ end
+
+ def validate_user!(value)
+ return if value.blank?
+ return if value =~ /\A\p{Alnum}/
+
+ raise BlockedUrlError, "Username needs to start with an alphanumeric character"
+ end
+
+ def validate_hostname!(value)
+ return if value.blank?
+ return if value =~ /\A\p{Alnum}/
+
+ raise BlockedUrlError, "Hostname needs to start with an alphanumeric character"
+ end
+
+ def validate_localhost!(addrs_info)
+ local_ips = ["127.0.0.1", "::1", "0.0.0.0"]
+ local_ips.concat(Socket.ip_address_list.map(&:ip_address))
+
+ return if (local_ips & addrs_info.map(&:ip_address)).empty?
- port < 1024 && !VALID_PORTS.include?(port)
+ raise BlockedUrlError, "Requests to localhost are not allowed"
end
- def blocked_user_or_hostname?(value)
- return false if value.blank?
+ def validate_local_network!(addrs_info)
+ return unless addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? }
- value !~ /\A\p{Alnum}/
+ raise BlockedUrlError, "Requests to the local network are not allowed"
end
def internal?(uri)
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 2adcc9809b3..8c0a4d55ea2 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -1,8 +1,6 @@
module Gitlab
class UsageData
class << self
- include Gitlab::CurrentSettings
-
def data(force_refresh: false)
Rails.cache.fetch('usage_data', force: force_refresh, expires_in: 2.weeks) { uncached_data }
end
@@ -11,6 +9,7 @@ module Gitlab
license_usage_data.merge(system_usage_data)
.merge(features_usage_data)
.merge(components_usage_data)
+ .merge(cycle_analytics_usage_data)
end
def to_json(force_refresh: false)
@@ -19,7 +18,7 @@ module Gitlab
def license_usage_data
usage_data = {
- uuid: current_application_settings.uuid,
+ uuid: Gitlab::CurrentSettings.uuid,
hostname: Gitlab.config.gitlab.host,
version: Gitlab::VERSION,
active_user_count: User.active.count,
@@ -31,6 +30,7 @@ module Gitlab
usage_data
end
+ # rubocop:disable Metrics/AbcSize
def system_usage_data
{
counts: {
@@ -51,6 +51,12 @@ module Gitlab
clusters: ::Clusters::Cluster.count,
clusters_enabled: ::Clusters::Cluster.enabled.count,
clusters_disabled: ::Clusters::Cluster.disabled.count,
+ clusters_platforms_gke: ::Clusters::Cluster.gcp_installed.enabled.count,
+ clusters_platforms_user: ::Clusters::Cluster.user_provided.enabled.count,
+ clusters_applications_helm: ::Clusters::Applications::Helm.installed.count,
+ clusters_applications_ingress: ::Clusters::Applications::Ingress.installed.count,
+ clusters_applications_prometheus: ::Clusters::Applications::Prometheus.installed.count,
+ clusters_applications_runner: ::Clusters::Applications::Runner.installed.count,
in_review_folder: ::Environment.in_review_folder.count,
groups: Group.count,
issues: Issue.count,
@@ -73,15 +79,19 @@ module Gitlab
}
end
+ def cycle_analytics_usage_data
+ Gitlab::CycleAnalytics::UsageData.new.to_json
+ end
+
def features_usage_data
features_usage_data_ce
end
def features_usage_data_ce
{
- signup: current_application_settings.allow_signup?,
+ signup: Gitlab::CurrentSettings.allow_signup?,
ldap: Gitlab.config.ldap.enabled,
- gravatar: current_application_settings.gravatar_enabled?,
+ gravatar: Gitlab::CurrentSettings.gravatar_enabled?,
omniauth: Gitlab.config.omniauth.enabled,
reply_by_email: Gitlab::IncomingEmail.enabled?,
container_registry: Gitlab.config.registry.enabled,
diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb
index d9a5af09f08..24393f96d96 100644
--- a/lib/gitlab/user_access.rb
+++ b/lib/gitlab/user_access.rb
@@ -6,7 +6,8 @@ module Gitlab
[user&.id, project&.id]
end
- attr_reader :user, :project
+ attr_reader :user
+ attr_accessor :project
def initialize(user, project: nil)
@user = user
@@ -16,8 +17,10 @@ module Gitlab
def can_do_action?(action)
return false unless can_access_git?
- @permission_cache ||= {}
- @permission_cache[action] ||= user.can?(action, project)
+ permission_cache[action] =
+ permission_cache.fetch(action) do
+ user.can?(action, project)
+ end
end
def cannot_do_action?(action)
@@ -28,7 +31,7 @@ module Gitlab
return false unless can_access_git?
if user.requires_ldap_check? && user.try_obtain_ldap_lease
- return false unless Gitlab::LDAP::Access.allowed?(user)
+ return false unless Gitlab::Auth::LDAP::Access.allowed?(user)
end
true
@@ -60,13 +63,12 @@ module Gitlab
request_cache def can_push_to_branch?(ref)
return false unless can_access_git?
+ return false unless user.can?(:push_code, project) || project.branch_allows_maintainer_push?(user, ref)
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)
+ project.user_can_push_to_empty_repo?(user) || protected_branch_accessible_to?(ref, action: :push)
else
- user.can?(:push_code, project)
+ true
end
end
@@ -88,6 +90,10 @@ module Gitlab
private
+ def permission_cache
+ @permission_cache ||= {}
+ end
+
def can_access_git?
user && user.can?(:access_git)
end
diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb
index b3baaf036d8..b0a492eaa58 100644
--- a/lib/gitlab/utils.rb
+++ b/lib/gitlab/utils.rb
@@ -27,6 +27,15 @@ module Gitlab
.gsub(/(\A-+|-+\z)/, '')
end
+ # Converts newlines into HTML line break elements
+ def nlbr(str)
+ ActionView::Base.full_sanitizer.sanitize(str, tags: []).gsub(/\r?\n/, '<br>').html_safe
+ end
+
+ def remove_line_breaks(str)
+ str.gsub(/\r?\n/, '')
+ end
+
def to_boolean(value)
return value if [true, false].include?(value)
return true if value =~ /^(true|t|yes|y|1|on)$/i
@@ -63,5 +72,13 @@ module Gitlab
nil
end
+
+ # Used in EE
+ # Accepts either an Array or a String and returns an array
+ def ensure_array_from_string(string_or_array)
+ return string_or_array if string_or_array.is_a?(Array)
+
+ string_or_array.split(',').map(&:strip)
+ end
end
end
diff --git a/lib/gitlab/utils/override.rb b/lib/gitlab/utils/override.rb
new file mode 100644
index 00000000000..8bf6bcb1fe2
--- /dev/null
+++ b/lib/gitlab/utils/override.rb
@@ -0,0 +1,111 @@
+module Gitlab
+ module Utils
+ module Override
+ class Extension
+ def self.verify_class!(klass, method_name)
+ instance_method_defined?(klass, method_name) ||
+ raise(
+ NotImplementedError.new(
+ "#{klass}\##{method_name} doesn't exist!"))
+ end
+
+ def self.instance_method_defined?(klass, name, include_super: true)
+ klass.instance_methods(include_super).include?(name) ||
+ klass.private_instance_methods(include_super).include?(name)
+ end
+
+ attr_reader :subject
+
+ def initialize(subject)
+ @subject = subject
+ end
+
+ def add_method_name(method_name)
+ method_names << method_name
+ end
+
+ def add_class(klass)
+ classes << klass
+ end
+
+ def verify!
+ classes.each do |klass|
+ index = klass.ancestors.index(subject)
+ parents = klass.ancestors.drop(index + 1)
+
+ method_names.each do |method_name|
+ parents.any? do |parent|
+ self.class.instance_method_defined?(
+ parent, method_name, include_super: false)
+ end ||
+ raise(
+ NotImplementedError.new(
+ "#{klass}\##{method_name} doesn't exist!"))
+ end
+ end
+ end
+
+ private
+
+ def method_names
+ @method_names ||= []
+ end
+
+ def classes
+ @classes ||= []
+ end
+ end
+
+ # Instead of writing patterns like this:
+ #
+ # def f
+ # raise NotImplementedError unless defined?(super)
+ #
+ # true
+ # end
+ #
+ # We could write it like:
+ #
+ # extend ::Gitlab::Utils::Override
+ #
+ # override :f
+ # def f
+ # true
+ # end
+ #
+ # This would make sure we're overriding something. See:
+ # https://gitlab.com/gitlab-org/gitlab-ee/issues/1819
+ def override(method_name)
+ return unless ENV['STATIC_VERIFICATION']
+
+ if is_a?(Class)
+ Extension.verify_class!(self, method_name)
+ else # We delay the check for modules
+ Override.extensions[self] ||= Extension.new(self)
+ Override.extensions[self].add_method_name(method_name)
+ end
+ end
+
+ def included(base = nil)
+ return super if base.nil? # Rails concern, ignoring it
+
+ super
+
+ if base.is_a?(Class) # We could check for Class in `override`
+ # This could be `nil` if `override` was never called
+ Override.extensions[self]&.add_class(base)
+ end
+ end
+
+ alias_method :prepended, :included
+
+ def self.extensions
+ @extensions ||= {}
+ end
+
+ def self.verify!
+ extensions.values.each(&:verify!)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/verify/batch_verifier.rb b/lib/gitlab/verify/batch_verifier.rb
new file mode 100644
index 00000000000..1ef369a4b67
--- /dev/null
+++ b/lib/gitlab/verify/batch_verifier.rb
@@ -0,0 +1,64 @@
+module Gitlab
+ module Verify
+ class BatchVerifier
+ attr_reader :batch_size, :start, :finish
+
+ def initialize(batch_size:, start: nil, finish: nil)
+ @batch_size = batch_size
+ @start = start
+ @finish = finish
+ end
+
+ # Yields a Range of IDs and a Hash of failed verifications (object => error)
+ def run_batches(&blk)
+ relation.in_batches(of: batch_size, start: start, finish: finish) do |relation| # rubocop: disable Cop/InBatches
+ range = relation.first.id..relation.last.id
+ failures = run_batch(relation)
+
+ yield(range, failures)
+ end
+ end
+
+ def name
+ raise NotImplementedError.new
+ end
+
+ def describe(_object)
+ raise NotImplementedError.new
+ end
+
+ private
+
+ def run_batch(relation)
+ relation.map { |upload| verify(upload) }.compact.to_h
+ end
+
+ def verify(object)
+ expected = expected_checksum(object)
+ actual = actual_checksum(object)
+
+ raise 'Checksum missing' unless expected.present?
+ raise 'Checksum mismatch' unless expected == actual
+
+ nil
+ rescue => err
+ [object, err]
+ end
+
+ # This should return an ActiveRecord::Relation suitable for calling #in_batches on
+ def relation
+ raise NotImplementedError.new
+ end
+
+ # The checksum we expect the object to have
+ def expected_checksum(_object)
+ raise NotImplementedError.new
+ end
+
+ # The freshly-recalculated checksum of the object
+ def actual_checksum(_object)
+ raise NotImplementedError.new
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/verify/job_artifacts.rb b/lib/gitlab/verify/job_artifacts.rb
new file mode 100644
index 00000000000..03500a61074
--- /dev/null
+++ b/lib/gitlab/verify/job_artifacts.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module Verify
+ class JobArtifacts < BatchVerifier
+ def name
+ 'Job artifacts'
+ end
+
+ def describe(object)
+ "Job artifact: #{object.id}"
+ end
+
+ private
+
+ def relation
+ ::Ci::JobArtifact.all
+ end
+
+ def expected_checksum(artifact)
+ artifact.file_sha256
+ end
+
+ def actual_checksum(artifact)
+ Digest::SHA256.file(artifact.file.path).hexdigest
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/verify/lfs_objects.rb b/lib/gitlab/verify/lfs_objects.rb
new file mode 100644
index 00000000000..970e2a7b718
--- /dev/null
+++ b/lib/gitlab/verify/lfs_objects.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module Verify
+ class LfsObjects < BatchVerifier
+ def name
+ 'LFS objects'
+ end
+
+ def describe(object)
+ "LFS object: #{object.oid}"
+ end
+
+ private
+
+ def relation
+ LfsObject.with_files_stored_locally
+ end
+
+ def expected_checksum(lfs_object)
+ lfs_object.oid
+ end
+
+ def actual_checksum(lfs_object)
+ LfsObject.calculate_oid(lfs_object.file.path)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/verify/rake_task.rb b/lib/gitlab/verify/rake_task.rb
new file mode 100644
index 00000000000..dd138e6b92b
--- /dev/null
+++ b/lib/gitlab/verify/rake_task.rb
@@ -0,0 +1,53 @@
+module Gitlab
+ module Verify
+ class RakeTask
+ def self.run!(verify_kls)
+ verifier = verify_kls.new(
+ batch_size: ENV.fetch('BATCH', 200).to_i,
+ start: ENV['ID_FROM'],
+ finish: ENV['ID_TO']
+ )
+
+ verbose = Gitlab::Utils.to_boolean(ENV['VERBOSE'])
+
+ new(verifier, verbose).run!
+ end
+
+ attr_reader :verifier, :output
+
+ def initialize(verifier, verbose)
+ @verifier = verifier
+ @verbose = verbose
+ end
+
+ def run!
+ say "Checking integrity of #{verifier.name}"
+
+ verifier.run_batches { |*args| run_batch(*args) }
+
+ say 'Done!'
+ end
+
+ def verbose?
+ !!@verbose
+ end
+
+ private
+
+ def say(text)
+ puts(text) # rubocop:disable Rails/Output
+ end
+
+ def run_batch(range, failures)
+ status_color = failures.empty? ? :green : :red
+ say "- #{range}: Failures: #{failures.count}".color(status_color)
+
+ return unless verbose?
+
+ failures.each do |object, error|
+ say " - #{verifier.describe(object)}: #{error.inspect}".color(:red)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/verify/uploads.rb b/lib/gitlab/verify/uploads.rb
new file mode 100644
index 00000000000..0ffa71a6d72
--- /dev/null
+++ b/lib/gitlab/verify/uploads.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module Verify
+ class Uploads < BatchVerifier
+ def name
+ 'Uploads'
+ end
+
+ def describe(object)
+ "Upload: #{object.id}"
+ end
+
+ private
+
+ def relation
+ Upload.with_files_stored_locally
+ end
+
+ def expected_checksum(upload)
+ upload.checksum
+ end
+
+ def actual_checksum(upload)
+ Upload.hexdigest(upload.absolute_path)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb
index 11472ce6cce..2612208a927 100644
--- a/lib/gitlab/visibility_level.rb
+++ b/lib/gitlab/visibility_level.rb
@@ -5,7 +5,6 @@
#
module Gitlab
module VisibilityLevel
- extend CurrentSettings
extend ActiveSupport::Concern
included do
@@ -57,11 +56,17 @@ module Gitlab
}
end
- def highest_allowed_level
- restricted_levels = current_application_settings.restricted_visibility_levels
+ def allowed_levels
+ restricted_levels = Gitlab::CurrentSettings.restricted_visibility_levels
- allowed_levels = self.values - restricted_levels
- allowed_levels.max || PRIVATE
+ self.values - Array(restricted_levels)
+ end
+
+ def closest_allowed_level(target_level)
+ highest_allowed_level = allowed_levels.select { |level| level <= target_level }.max
+
+ # If all levels are restricted, fall back to PRIVATE
+ highest_allowed_level || PRIVATE
end
def allowed_for?(user, level)
@@ -75,7 +80,7 @@ module Gitlab
end
def non_restricted_level?(level)
- restricted_levels = current_application_settings.restricted_visibility_levels
+ restricted_levels = Gitlab::CurrentSettings.restricted_visibility_levels
if restricted_levels.nil?
true
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index 5ab6cd5a4ef..153cb2a8bb1 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -10,6 +10,7 @@ module Gitlab
INTERNAL_API_CONTENT_TYPE = 'application/vnd.gitlab-workhorse+json'.freeze
INTERNAL_API_REQUEST_HEADER = 'Gitlab-Workhorse-Api-Request'.freeze
NOTIFICATION_CHANNEL = 'workhorse:notifications'.freeze
+ ALLOWED_GIT_HTTP_ACTIONS = %w[git_receive_pack git_upload_pack info_refs].freeze
# Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32
# bytes https://tools.ietf.org/html/rfc4868#section-2.6
@@ -17,52 +18,26 @@ module Gitlab
class << self
def git_http_ok(repository, is_wiki, user, action, show_all_refs: false)
+ raise "Unsupported action: #{action}" unless ALLOWED_GIT_HTTP_ACTIONS.include?(action.to_s)
+
project = repository.project
- repo_path = repository.path_to_repo
- params = {
+
+ {
GL_ID: Gitlab::GlId.gl_id(user),
GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki),
GL_USERNAME: user&.username,
- RepoPath: repo_path,
- ShowAllRefs: show_all_refs
- }
- 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'
- true
- when 'info_refs'
- true
- else
- raise "Unsupported action: #{action}"
- end
- if feature_enabled
- params[:GitalyServer] = server
- end
-
- params
- end
-
- def lfs_upload_ok(oid, size)
- {
- StoreLFSPath: "#{Gitlab.config.lfs.storage_path}/tmp/upload",
- LfsOid: oid,
- LfsSize: size
+ ShowAllRefs: show_all_refs,
+ Repository: repository.gitaly_repository.to_h,
+ RepoPath: 'ignored but not allowed to be empty in gitlab-workhorse',
+ GitalyServer: {
+ address: Gitlab::GitalyClient.address(project.repository_storage),
+ token: Gitlab::GitalyClient.token(project.repository_storage)
+ }
}
end
- def artifact_upload_ok
- { TempPath: JobArtifactUploader.artifacts_upload_path }
- end
-
def send_git_blob(repository, blob)
- params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_raw_show)
+ params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_raw_show, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT)
{
'GitalyServer' => gitaly_server_hash(repository),
'GetBlobRequest' => {
@@ -84,19 +59,22 @@ module Gitlab
]
end
- def send_git_archive(repository, ref:, format:)
+ def send_git_archive(repository, ref:, format:, append_sha:)
format ||= 'tar.gz'
format.downcase!
- params = repository.archive_metadata(ref, Gitlab.config.gitlab.repository_downloads_path, format)
+ params = repository.archive_metadata(ref, Gitlab.config.gitlab.repository_downloads_path, format, append_sha: append_sha)
raise "Repository or ref not found" if params.empty?
- if Gitlab::GitalyClient.feature_enabled?(:workhorse_archive)
+ if Gitlab::GitalyClient.feature_enabled?(:workhorse_archive, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT)
params.merge!(
'GitalyServer' => gitaly_server_hash(repository),
'GitalyRepository' => repository.gitaly_repository.to_h
)
end
+ # If present DisableCache must be a Boolean. Otherwise workhorse ignores it.
+ params['DisableCache'] = true if git_archive_cache_disabled?
+
[
SEND_DATA_HEADER,
"git-archive:#{encode(params)}"
@@ -104,7 +82,7 @@ module Gitlab
end
def send_git_diff(repository, diff_refs)
- params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_send_git_diff)
+ params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_send_git_diff, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT)
{
'GitalyServer' => gitaly_server_hash(repository),
'RawDiffRequest' => Gitaly::RawDiffRequest.new(
@@ -122,7 +100,7 @@ module Gitlab
end
def send_git_patch(repository, diff_refs)
- params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_send_git_patch)
+ params = if Gitlab::GitalyClient.feature_enabled?(:workhorse_send_git_patch, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT)
{
'GitalyServer' => gitaly_server_hash(repository),
'RawPatchRequest' => Gitaly::RawPatchRequest.new(
@@ -140,8 +118,11 @@ module Gitlab
end
def send_artifacts_entry(build, entry)
+ file = build.artifacts_file
+ archive = file.file_storage? ? file.path : file.url
+
params = {
- 'Archive' => build.artifacts_file.path,
+ 'Archive' => archive,
'Entry' => Base64.encode64(entry.to_s)
}
@@ -151,6 +132,18 @@ module Gitlab
]
end
+ def send_url(url, allow_redirects: false)
+ params = {
+ 'URL' => url,
+ 'AllowRedirects' => allow_redirects
+ }
+
+ [
+ SEND_DATA_HEADER,
+ "send-url:#{encode(params)}"
+ ]
+ end
+
def terminal_websocket(terminal)
details = {
'Terminal' => {
@@ -244,6 +237,10 @@ module Gitlab
right_commit_id: diff_refs.head_sha
}
end
+
+ def git_archive_cache_disabled?
+ ENV['WORKHORSE_ARCHIVE_CACHE_DISABLED'].present? || Feature.enabled?(:workhorse_archive_cache_disabled)
+ end
end
end
end
diff --git a/lib/google_api/auth.rb b/lib/google_api/auth.rb
index 99a82c849e0..1aeaa387a49 100644
--- a/lib/google_api/auth.rb
+++ b/lib/google_api/auth.rb
@@ -32,7 +32,7 @@ module GoogleApi
private
def config
- Gitlab.config.omniauth.providers.find { |provider| provider.name == "google_oauth2" }
+ Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
end
def client
diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb
index b0563fb2d69..f30dd995695 100644
--- a/lib/google_api/cloud_platform/client.rb
+++ b/lib/google_api/cloud_platform/client.rb
@@ -1,4 +1,6 @@
require 'google/apis/container_v1'
+require 'google/apis/cloudbilling_v1'
+require 'google/apis/cloudresourcemanager_v1'
module GoogleApi
module CloudPlatform
@@ -40,6 +42,22 @@ module GoogleApi
true
end
+ def projects_list
+ service = Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService.new
+ service.authorization = access_token
+
+ service.fetch_all(items: :projects) do |token|
+ service.list_projects(page_token: token, options: user_agent_header)
+ end
+ end
+
+ def projects_get_billing_info(project_id)
+ service = Google::Apis::CloudbillingV1::CloudbillingService.new
+ service.authorization = access_token
+
+ service.get_project_billing_info("projects/#{project_id}", options: user_agent_header)
+ end
+
def projects_zones_clusters_get(project_id, zone, cluster_id)
service = Google::Apis::ContainerV1::ContainerService.new
service.authorization = access_token
@@ -58,9 +76,13 @@ module GoogleApi
"initial_node_count": cluster_size,
"node_config": {
"machine_type": machine_type
+ },
+ "legacy_abac": {
+ "enabled": true
}
}
- } )
+ }
+ )
service.create_cluster(project_id, zone, request_body, options: user_agent_header)
end
diff --git a/lib/haml_lint/inline_javascript.rb b/lib/haml_lint/inline_javascript.rb
index f5485eb89fa..adbed20f152 100644
--- a/lib/haml_lint/inline_javascript.rb
+++ b/lib/haml_lint/inline_javascript.rb
@@ -1,4 +1,4 @@
-unless Rails.env.production?
+unless Rails.env.production? # rubocop:disable Naming/FileName
require 'haml_lint/haml_visitor'
require 'haml_lint/linter'
require 'haml_lint/linter_registry'
@@ -12,6 +12,12 @@ unless Rails.env.production?
record_lint(node, 'Inline JavaScript is discouraged (https://docs.gitlab.com/ee/development/gotchas.html#do-not-use-inline-javascript-in-views)')
end
+
+ def visit_tag(node)
+ return unless node.tag_name == 'script'
+
+ 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/session.rb b/lib/mattermost/session.rb
index ef08bd46e17..85f78e44f32 100644
--- a/lib/mattermost/session.rb
+++ b/lib/mattermost/session.rb
@@ -22,16 +22,14 @@ module Mattermost
# going.
class Session
include Doorkeeper::Helpers::Controller
- include HTTParty
LEASE_TIMEOUT = 60
- base_uri Settings.mattermost.host
-
- attr_accessor :current_resource_owner, :token
+ attr_accessor :current_resource_owner, :token, :base_uri
def initialize(current_user)
@current_resource_owner = current_user
+ @base_uri = Settings.mattermost.host
end
def with_session
@@ -73,18 +71,32 @@ module Mattermost
def get(path, options = {})
handle_exceptions do
- self.class.get(path, options.merge(headers: @headers))
+ Gitlab::HTTP.get(path, build_options(options))
end
end
def post(path, options = {})
handle_exceptions do
- self.class.post(path, options.merge(headers: @headers))
+ Gitlab::HTTP.post(path, build_options(options))
+ end
+ end
+
+ def delete(path, options = {})
+ handle_exceptions do
+ Gitlab::HTTP.delete(path, build_options(options))
end
end
private
+ def build_options(options)
+ options.tap do |hash|
+ hash[:headers] = @headers
+ hash[:allow_local_requests] = true
+ hash[:base_uri] = base_uri if base_uri.presence
+ end
+ end
+
def create
raise Mattermost::NoSessionError unless oauth_uri
raise Mattermost::NoSessionError unless token_uri
@@ -159,14 +171,14 @@ module Mattermost
def handle_exceptions
yield
- rescue HTTParty::Error => e
+ rescue Gitlab::HTTP::Error => e
raise Mattermost::ConnectionError.new(e.message)
rescue Errno::ECONNREFUSED => e
raise Mattermost::ConnectionError.new(e.message)
end
def parse_cookie(response)
- cookie_hash = CookieHash.new
+ cookie_hash = Gitlab::HTTP::CookieHash.new
response.get_fields('Set-Cookie').each { |c| cookie_hash.add_cookies(c) }
cookie_hash
end
diff --git a/lib/mattermost/team.rb b/lib/mattermost/team.rb
index b2511f3af1d..75513a9ba04 100644
--- a/lib/mattermost/team.rb
+++ b/lib/mattermost/team.rb
@@ -16,10 +16,9 @@ module Mattermost
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
+ # On the mattermost side, this triggers an soft deletion
def destroy(team_id:)
- session_delete("/api/v4/teams/#{team_id}?permanent=true")
+ session_delete("/api/v4/teams/#{team_id}")
end
end
end
diff --git a/lib/microsoft_teams/notifier.rb b/lib/microsoft_teams/notifier.rb
index 3bef68a1bcb..c08d3e933a8 100644
--- a/lib/microsoft_teams/notifier.rb
+++ b/lib/microsoft_teams/notifier.rb
@@ -9,14 +9,15 @@ module MicrosoftTeams
result = false
begin
- response = HTTParty.post(
+ response = Gitlab::HTTP.post(
@webhook.to_str,
headers: @header,
+ allow_local_requests: true,
body: body(options)
)
result = true if response
- rescue HTTParty::Error, StandardError => error
+ rescue Gitlab::HTTP::Error, StandardError => error
Rails.logger.info("#{self.class.name}: Error while connecting to #{@webhook}: #{error.message}")
end
diff --git a/lib/peek/views/gitaly.rb b/lib/peek/views/gitaly.rb
index d519d8e86fa..ab35f7a2258 100644
--- a/lib/peek/views/gitaly.rb
+++ b/lib/peek/views/gitaly.rb
@@ -10,11 +10,29 @@ module Peek
end
def results
- { duration: formatted_duration, calls: calls }
+ {
+ duration: formatted_duration,
+ calls: calls,
+ details: details
+ }
end
private
+ def details
+ ::Gitlab::GitalyClient.list_call_details
+ .values
+ .sort { |a, b| b[:duration] <=> a[:duration] }
+ .map(&method(:format_call_details))
+ end
+
+ def format_call_details(call)
+ pretty_request = call[:request]&.reject { |k, v| v.blank? }.to_h.pretty_inspect
+
+ call.merge(duration: (call[:duration] * 1000).round(3),
+ request: pretty_request || {})
+ end
+
def formatted_duration
ms = duration * 1000
if ms >= 1000
diff --git a/lib/peek/views/host.rb b/lib/peek/views/host.rb
new file mode 100644
index 00000000000..43c8a35c7ea
--- /dev/null
+++ b/lib/peek/views/host.rb
@@ -0,0 +1,9 @@
+module Peek
+ module Views
+ class Host < View
+ def results
+ { hostname: Gitlab::Environment.hostname }
+ end
+ end
+ end
+end
diff --git a/lib/repository_cache.rb b/lib/repository_cache.rb
deleted file mode 100644
index 068a95790c0..00000000000
--- a/lib/repository_cache.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# Interface to the Redis-backed cache store used by the Repository model
-class RepositoryCache
- attr_reader :namespace, :backend, :project_id
-
- def initialize(namespace, project_id, backend = Rails.cache)
- @namespace = namespace
- @backend = backend
- @project_id = project_id
- end
-
- def cache_key(type)
- "#{type}:#{namespace}:#{project_id}"
- end
-
- def expire(key)
- backend.delete(cache_key(key))
- end
-
- def fetch(key, &block)
- backend.fetch(cache_key(key), &block)
- end
-
- def exist?(key)
- backend.exist?(cache_key(key))
- end
-
- def read(key)
- backend.read(cache_key(key))
- end
-end
diff --git a/lib/rouge/plugins/common_mark.rb b/lib/rouge/plugins/common_mark.rb
new file mode 100644
index 00000000000..8f9de061124
--- /dev/null
+++ b/lib/rouge/plugins/common_mark.rb
@@ -0,0 +1,20 @@
+# A rouge plugin for CommonMark markdown engine.
+# Used to highlight code generated by CommonMark.
+
+module Rouge
+ module Plugins
+ module CommonMark
+ def code_block(code, language)
+ lexer = Lexer.find_fancy(language, code) || Lexers::PlainText
+
+ formatter = rouge_formatter(lexer)
+ formatter.format(lexer.lex(code))
+ end
+
+ # override this method for custom formatting behavior
+ def rouge_formatter(lexer)
+ Formatters::HTMLLegacy.new(css_class: "highlight #{lexer.tag}")
+ end
+ end
+ end
+end
diff --git a/lib/support/nginx/gitlab b/lib/support/nginx/gitlab
index 54f51d9d633..0e27a28ea6e 100644
--- a/lib/support/nginx/gitlab
+++ b/lib/support/nginx/gitlab
@@ -17,6 +17,8 @@
## See installation.md#using-https for additional HTTPS configuration details.
upstream gitlab-workhorse {
+ # Gitlab socket file,
+ # for Omnibus this would be: unix:/var/opt/gitlab/gitlab-workhorse/socket
server unix:/home/git/gitlab/tmp/sockets/gitlab-workhorse.socket fail_timeout=0;
}
@@ -110,6 +112,8 @@ server {
error_page 502 /502.html;
error_page 503 /503.html;
location ~ ^/(404|422|500|502|503)\.html$ {
+ # Location to the Gitlab's public directory,
+ # for Omnibus this would be: /opt/gitlab/embedded/service/gitlab-rails/public.
root /home/git/gitlab/public;
internal;
}
diff --git a/lib/support/nginx/gitlab-ssl b/lib/support/nginx/gitlab-ssl
index ed8131ef24f..8218d68f9ba 100644
--- a/lib/support/nginx/gitlab-ssl
+++ b/lib/support/nginx/gitlab-ssl
@@ -21,6 +21,8 @@
## See installation.md#using-https for additional HTTPS configuration details.
upstream gitlab-workhorse {
+ # Gitlab socket file,
+ # for Omnibus this would be: unix:/var/opt/gitlab/gitlab-workhorse/socket
server unix:/home/git/gitlab/tmp/sockets/gitlab-workhorse.socket fail_timeout=0;
}
@@ -160,6 +162,8 @@ server {
error_page 502 /502.html;
error_page 503 /503.html;
location ~ ^/(404|422|500|502|503)\.html$ {
+ # Location to the Gitlab's public directory,
+ # for Omnibus this would be: /opt/gitlab/embedded/service/gitlab-rails/public
root /home/git/gitlab/public;
internal;
}
diff --git a/lib/system_check/app/git_version_check.rb b/lib/system_check/app/git_version_check.rb
index 6ee8c8874ec..44ec888c197 100644
--- a/lib/system_check/app/git_version_check.rb
+++ b/lib/system_check/app/git_version_check.rb
@@ -5,7 +5,7 @@ module SystemCheck
set_check_pass -> { "yes (#{self.current_version})" }
def self.required_version
- @required_version ||= Gitlab::VersionInfo.new(2, 7, 3)
+ @required_version ||= Gitlab::VersionInfo.new(2, 9, 5)
end
def self.current_version
diff --git a/lib/system_check/helpers.rb b/lib/system_check/helpers.rb
index c42ae4fe4c4..6227e461d24 100644
--- a/lib/system_check/helpers.rb
+++ b/lib/system_check/helpers.rb
@@ -1,5 +1,3 @@
-require 'tasks/gitlab/task_helpers'
-
module SystemCheck
module Helpers
include ::Gitlab::TaskHelpers
@@ -52,7 +50,7 @@ module SystemCheck
if should_sanitize?
"#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... "
else
- "#{project.name_with_namespace.color(:yellow)} ... "
+ "#{project.full_name.color(:yellow)} ... "
end
end
diff --git a/lib/system_check/orphans/namespace_check.rb b/lib/system_check/orphans/namespace_check.rb
index b8446300f72..b5f443abe06 100644
--- a/lib/system_check/orphans/namespace_check.rb
+++ b/lib/system_check/orphans/namespace_check.rb
@@ -6,8 +6,8 @@ module SystemCheck
def multi_check
Gitlab.config.repositories.storages.each do |storage_name, repository_storage|
$stdout.puts
- $stdout.puts "* Storage: #{storage_name} (#{repository_storage['path']})".color(:yellow)
- toplevel_namespace_dirs = disk_namespaces(repository_storage['path'])
+ $stdout.puts "* Storage: #{storage_name} (#{repository_storage.legacy_disk_path})".color(:yellow)
+ toplevel_namespace_dirs = disk_namespaces(repository_storage.legacy_disk_path)
orphans = (toplevel_namespace_dirs - existing_namespaces)
print_orphans(orphans, storage_name)
diff --git a/lib/system_check/orphans/repository_check.rb b/lib/system_check/orphans/repository_check.rb
index 9b6b2429783..5ef0b93ad08 100644
--- a/lib/system_check/orphans/repository_check.rb
+++ b/lib/system_check/orphans/repository_check.rb
@@ -6,10 +6,12 @@ module SystemCheck
def multi_check
Gitlab.config.repositories.storages.each do |storage_name, repository_storage|
+ storage_path = repository_storage.legacy_disk_path
+
$stdout.puts
- $stdout.puts "* Storage: #{storage_name} (#{repository_storage['path']})".color(:yellow)
+ $stdout.puts "* Storage: #{storage_name} (#{storage_path})".color(:yellow)
- repositories = disk_repositories(repository_storage['path'])
+ repositories = disk_repositories(storage_path)
orphans = (repositories - fetch_repositories(storage_name))
print_orphans(orphans, storage_name)
diff --git a/lib/system_check/simple_executor.rb b/lib/system_check/simple_executor.rb
index 8b145fb4511..d268f501b4a 100644
--- a/lib/system_check/simple_executor.rb
+++ b/lib/system_check/simple_executor.rb
@@ -66,6 +66,7 @@ module SystemCheck
if check.can_repair?
$stdout.print 'Trying to fix error automatically. ...'
+
if check.repair!
$stdout.puts 'Success'.color(:green)
return
diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake
index e65609d7001..4beb94eeb8e 100644
--- a/lib/tasks/dev.rake
+++ b/lib/tasks/dev.rake
@@ -7,4 +7,9 @@ namespace :dev do
Rake::Task["gitlab:setup"].invoke
Rake::Task["gitlab:shell:setup"].invoke
end
+
+ desc "GitLab | Eager load application"
+ task load: :environment do
+ Rails.application.eager_load!
+ end
end
diff --git a/lib/tasks/flay.rake b/lib/tasks/flay.rake
index 7ad2b2e4d39..4b4881cecb8 100644
--- a/lib/tasks/flay.rake
+++ b/lib/tasks/flay.rake
@@ -1,8 +1,8 @@
desc 'Code duplication analyze via flay'
task :flay do
- output = `bundle exec flay --mass 35 app/ lib/gitlab/`
+ output = `bundle exec flay --mass 35 app/ lib/gitlab/ 2> #{File::NULL}`
- if output.include? "Similar code found"
+ if output.include?("Similar code found") || output.include?("IDENTICAL code found")
puts output
exit 1
end
diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake
index c2d3a6b6950..c6942d22926 100644
--- a/lib/tasks/gemojione.rake
+++ b/lib/tasks/gemojione.rake
@@ -115,7 +115,7 @@ namespace :gemojione do
end
end
- style_path = Rails.root.join(*%w(app assets stylesheets framework emoji-sprites.scss))
+ style_path = Rails.root.join(*%w(app assets stylesheets framework emoji_sprites.scss))
# Combine the resized assets into a packed sprite and re-generate the SCSS
SpriteFactory.cssurl = "image-url('$IMAGE')"
diff --git a/lib/tasks/gitlab/artifacts/check.rake b/lib/tasks/gitlab/artifacts/check.rake
new file mode 100644
index 00000000000..a105261ed51
--- /dev/null
+++ b/lib/tasks/gitlab/artifacts/check.rake
@@ -0,0 +1,8 @@
+namespace :gitlab do
+ namespace :artifacts do
+ desc 'GitLab | Artifacts | Check integrity of uploaded job artifacts'
+ task check: :environment do
+ Gitlab::Verify::RakeTask.run!(Gitlab::Verify::JobArtifacts)
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/artifacts/migrate.rake b/lib/tasks/gitlab/artifacts/migrate.rake
new file mode 100644
index 00000000000..bfca4bfb3f7
--- /dev/null
+++ b/lib/tasks/gitlab/artifacts/migrate.rake
@@ -0,0 +1,25 @@
+require 'logger'
+require 'resolv-replace'
+
+desc "GitLab | Migrate files for artifacts to comply with new storage format"
+namespace :gitlab do
+ namespace :artifacts do
+ task migrate: :environment do
+ logger = Logger.new(STDOUT)
+ logger.info('Starting transfer of artifacts')
+
+ Ci::Build.joins(:project)
+ .with_artifacts_stored_locally
+ .find_each(batch_size: 10) do |build|
+ begin
+ build.artifacts_file.migrate!(ObjectStorage::Store::REMOTE)
+ build.artifacts_metadata.migrate!(ObjectStorage::Store::REMOTE)
+
+ logger.info("Transferred artifacts of #{build.id} of #{build.artifacts_size} to object storage")
+ rescue => e
+ logger.error("Failed to transfer artifacts of #{build.id} with error: #{e.message}")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake
index 9dcf44fdc3e..24e37f6c6cc 100644
--- a/lib/tasks/gitlab/backup.rake
+++ b/lib/tasks/gitlab/backup.rake
@@ -4,7 +4,7 @@ namespace :gitlab do
namespace :backup do
# Create backup of GitLab system
desc "GitLab | Create a backup of the GitLab system"
- task create: :environment do
+ task create: :gitlab_environment do
warn_user_is_not_gitlab
configure_cron_mode
@@ -25,7 +25,7 @@ namespace :gitlab do
# Restore backup of GitLab system
desc 'GitLab | Restore a previously created backup'
- task restore: :environment do
+ task restore: :gitlab_environment do
warn_user_is_not_gitlab
configure_cron_mode
@@ -46,6 +46,7 @@ namespace :gitlab do
puts 'Removing all tables. Press `Ctrl-C` within 5 seconds to abort'.color(:yellow)
sleep(5)
end
+
# Drop all tables Load the schema to ensure we don't have any newer tables
# hanging out from a failed upgrade
$progress.puts 'Cleaning the database ... '.color(:blue)
@@ -72,7 +73,7 @@ namespace :gitlab do
end
namespace :repo do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping repositories ...".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("repositories")
@@ -83,7 +84,7 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring repositories ...".color(:blue)
Backup::Repository.new.restore
$progress.puts "done".color(:green)
@@ -91,7 +92,7 @@ namespace :gitlab do
end
namespace :db do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping database ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("db")
@@ -102,7 +103,7 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring database ... ".color(:blue)
Backup::Database.new.restore
$progress.puts "done".color(:green)
@@ -110,7 +111,7 @@ namespace :gitlab do
end
namespace :builds do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping builds ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("builds")
@@ -121,7 +122,7 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring builds ... ".color(:blue)
Backup::Builds.new.restore
$progress.puts "done".color(:green)
@@ -129,7 +130,7 @@ namespace :gitlab do
end
namespace :uploads do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping uploads ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("uploads")
@@ -140,7 +141,7 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring uploads ... ".color(:blue)
Backup::Uploads.new.restore
$progress.puts "done".color(:green)
@@ -148,7 +149,7 @@ namespace :gitlab do
end
namespace :artifacts do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping artifacts ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("artifacts")
@@ -159,7 +160,7 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring artifacts ... ".color(:blue)
Backup::Artifacts.new.restore
$progress.puts "done".color(:green)
@@ -167,7 +168,7 @@ namespace :gitlab do
end
namespace :pages do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping pages ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("pages")
@@ -178,7 +179,7 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring pages ... ".color(:blue)
Backup::Pages.new.restore
$progress.puts "done".color(:green)
@@ -186,7 +187,7 @@ namespace :gitlab do
end
namespace :lfs do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping lfs objects ... ".color(:blue)
if ENV["SKIP"] && ENV["SKIP"].include?("lfs")
@@ -197,7 +198,7 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring lfs objects ... ".color(:blue)
Backup::Lfs.new.restore
$progress.puts "done".color(:green)
@@ -205,7 +206,7 @@ namespace :gitlab do
end
namespace :registry do
- task create: :environment do
+ task create: :gitlab_environment do
$progress.puts "Dumping container registry images ... ".color(:blue)
if Gitlab.config.registry.enabled
@@ -220,8 +221,9 @@ namespace :gitlab do
end
end
- task restore: :environment do
+ task restore: :gitlab_environment do
$progress.puts "Restoring container registry images ... ".color(:blue)
+
if Gitlab.config.registry.enabled
Backup::Registry.new.restore
$progress.puts "done".color(:green)
diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake
index dfade1f3885..abef8cd2bcc 100644
--- a/lib/tasks/gitlab/check.rake
+++ b/lib/tasks/gitlab/check.rake
@@ -1,7 +1,3 @@
-# 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'
task check: %w{gitlab:gitlab_shell:check
@@ -12,7 +8,7 @@ namespace :gitlab do
namespace :app do
desc 'GitLab | Check the configuration of the GitLab Rails app'
- task check: :environment do
+ task check: :gitlab_environment do
warn_user_is_not_gitlab
checks = [
@@ -43,7 +39,7 @@ namespace :gitlab do
namespace :gitlab_shell do
desc "GitLab | Check the configuration of GitLab Shell"
- task check: :environment do
+ task check: :gitlab_environment do
warn_user_is_not_gitlab
start_checking "GitLab Shell"
@@ -65,7 +61,7 @@ namespace :gitlab do
puts "Repo base directory exists?"
Gitlab.config.repositories.storages.each do |name, repository_storage|
- repo_base_path = repository_storage['path']
+ repo_base_path = repository_storage.legacy_disk_path
print "#{name}... "
if File.exist?(repo_base_path)
@@ -90,7 +86,7 @@ namespace :gitlab do
puts "Repo storage directories are symlinks?"
Gitlab.config.repositories.storages.each do |name, repository_storage|
- repo_base_path = repository_storage['path']
+ repo_base_path = repository_storage.legacy_disk_path
print "#{name}... "
unless File.exist?(repo_base_path)
@@ -114,7 +110,7 @@ namespace :gitlab do
puts "Repo paths access is drwxrws---?"
Gitlab.config.repositories.storages.each do |name, repository_storage|
- repo_base_path = repository_storage['path']
+ repo_base_path = repository_storage.legacy_disk_path
print "#{name}... "
unless File.exist?(repo_base_path)
@@ -144,7 +140,7 @@ namespace :gitlab do
puts "Repo paths owned by #{gitlab_shell_ssh_user}:root, or #{gitlab_shell_ssh_user}:#{Gitlab.config.gitlab_shell.owner_group}?"
Gitlab.config.repositories.storages.each do |name, repository_storage|
- repo_base_path = repository_storage['path']
+ repo_base_path = repository_storage.legacy_disk_path
print "#{name}... "
unless File.exist?(repo_base_path)
@@ -180,6 +176,7 @@ namespace :gitlab do
puts "can't check, you have no projects".color(:magenta)
return
end
+
puts ""
Project.find_each(batch_size: 100) do |project|
@@ -210,6 +207,7 @@ namespace :gitlab do
gitlab_shell_repo_base = gitlab_shell_path
check_cmd = File.expand_path('bin/check', gitlab_shell_repo_base)
puts "Running #{check_cmd}"
+
if system(check_cmd, chdir: gitlab_shell_repo_base)
puts 'gitlab-shell self-check successful'.color(:green)
else
@@ -249,7 +247,7 @@ namespace :gitlab do
namespace :sidekiq do
desc "GitLab | Check the configuration of Sidekiq"
- task check: :environment do
+ task check: :gitlab_environment do
warn_user_is_not_gitlab
start_checking "Sidekiq"
@@ -285,6 +283,7 @@ namespace :gitlab do
return if process_count.zero?
print 'Number of Sidekiq processes ... '
+
if process_count == 1
puts '1'.color(:green)
else
@@ -307,7 +306,7 @@ namespace :gitlab do
namespace :incoming_email do
desc "GitLab | Check the configuration of Reply by email"
- task check: :environment do
+ task check: :gitlab_environment do
warn_user_is_not_gitlab
if Gitlab.config.incoming_email.enabled
@@ -330,14 +329,14 @@ namespace :gitlab do
end
namespace :ldap do
- task :check, [:limit] => :environment do |_, args|
+ task :check, [:limit] => :gitlab_environment do |_, args|
# Only show up to 100 results because LDAP directories can be very big.
# This setting only affects the `rake gitlab:check` script.
args.with_defaults(limit: 100)
warn_user_is_not_gitlab
start_checking "LDAP"
- if Gitlab::LDAP::Config.enabled?
+ if Gitlab::Auth::LDAP::Config.enabled?
check_ldap(args.limit)
else
puts 'LDAP is disabled in config/gitlab.yml'
@@ -347,13 +346,13 @@ namespace :gitlab do
end
def check_ldap(limit)
- servers = Gitlab::LDAP::Config.providers
+ servers = Gitlab::Auth::LDAP::Config.providers
servers.each do |server|
puts "Server: #{server}"
begin
- Gitlab::LDAP::Adapter.open(server) do |adapter|
+ Gitlab::Auth::LDAP::Adapter.open(server) do |adapter|
check_ldap_auth(adapter)
puts "LDAP users with access to your GitLab server (only showing the first #{limit} results)"
@@ -386,21 +385,15 @@ namespace :gitlab do
namespace :repo do
desc "GitLab | Check the integrity of the repositories managed by GitLab"
- task check: :environment do
- Gitlab.config.repositories.storages.each do |name, repository_storage|
- namespace_dirs = Dir.glob(File.join(repository_storage['path'], '*'))
-
- namespace_dirs.each do |namespace_dir|
- repo_dirs = Dir.glob(File.join(namespace_dir, '*'))
- repo_dirs.each { |repo_dir| check_repo_integrity(repo_dir) }
- end
- end
+ task check: :gitlab_environment do
+ puts "This task is deprecated. Please use gitlab:git:fsck instead".color(:red)
+ Rake::Task["gitlab:git:fsck"].execute
end
end
namespace :orphans do
desc 'Gitlab | Check for orphaned namespaces and repositories'
- task check: :environment do
+ task check: :gitlab_environment do
warn_user_is_not_gitlab
checks = [
SystemCheck::Orphans::NamespaceCheck,
@@ -411,7 +404,7 @@ namespace :gitlab do
end
desc 'GitLab | Check for orphaned namespaces in the repositories path'
- task check_namespaces: :environment do
+ task check_namespaces: :gitlab_environment do
warn_user_is_not_gitlab
checks = [SystemCheck::Orphans::NamespaceCheck]
@@ -419,7 +412,7 @@ namespace :gitlab do
end
desc 'GitLab | Check for orphaned repositories in the repositories path'
- task check_repositories: :environment do
+ task check_repositories: :gitlab_environment do
warn_user_is_not_gitlab
checks = [SystemCheck::Orphans::RepositoryCheck]
@@ -429,8 +422,8 @@ namespace :gitlab do
namespace :user do
desc "GitLab | Check the integrity of a specific user's repositories"
- task :check_repos, [:username] => :environment do |t, args|
- username = args[:username] || prompt("Check repository integrity for fsername? ".color(:blue))
+ task :check_repos, [:username] => :gitlab_environment do |t, args|
+ username = args[:username] || prompt("Check repository integrity for username? ".color(:blue))
user = User.find_by(username: username)
if user
repo_dirs = user.authorized_projects.map do |p|
@@ -461,35 +454,4 @@ namespace :gitlab do
puts "FAIL. Please update gitlab-shell to #{required_version} from #{current_version}".color(:red)
end
end
-
- def check_repo_integrity(repo_dir)
- puts "\nChecking repo at #{repo_dir.color(:yellow)}"
-
- git_fsck(repo_dir)
- check_config_lock(repo_dir)
- check_ref_locks(repo_dir)
- end
-
- def git_fsck(repo_dir)
- puts "Running `git fsck`".color(:yellow)
- system(*%W(#{Gitlab.config.git.bin_path} fsck), chdir: repo_dir)
- end
-
- def check_config_lock(repo_dir)
- config_exists = File.exist?(File.join(repo_dir, 'config.lock'))
- config_output = config_exists ? 'yes'.color(:red) : 'no'.color(:green)
- puts "'config.lock' file exists?".color(:yellow) + " ... #{config_output}"
- end
-
- def check_ref_locks(repo_dir)
- lock_files = Dir.glob(File.join(repo_dir, 'refs/heads/*.lock'))
- if lock_files.present?
- puts "Ref lock files exist:".color(:red)
- lock_files.each do |lock_file|
- puts " #{lock_file}"
- end
- else
- puts "No ref lock files exist".color(:green)
- end
- end
end
diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake
index eb0f757aea7..d6d15285489 100644
--- a/lib/tasks/gitlab/cleanup.rake
+++ b/lib/tasks/gitlab/cleanup.rake
@@ -1,16 +1,18 @@
+# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/954
+#
namespace :gitlab do
namespace :cleanup do
HASHED_REPOSITORY_NAME = '@hashed'.freeze
desc "GitLab | Cleanup | Clean namespaces"
- task dirs: :environment do
+ task dirs: :gitlab_environment do
warn_user_is_not_gitlab
remove_flag = ENV['REMOVE']
namespaces = Namespace.pluck(:path)
namespaces << HASHED_REPOSITORY_NAME # add so that it will be ignored
Gitlab.config.repositories.storages.each do |name, repository_storage|
- git_base_path = repository_storage['path']
+ git_base_path = repository_storage.legacy_disk_path
all_dirs = Dir.glob(git_base_path + '/*')
puts git_base_path.color(:yellow)
@@ -47,12 +49,12 @@ namespace :gitlab do
end
desc "GitLab | Cleanup | Clean repositories"
- task repos: :environment do
+ task repos: :gitlab_environment do
warn_user_is_not_gitlab
move_suffix = "+orphaned+#{Time.now.to_i}"
Gitlab.config.repositories.storages.each do |name, repository_storage|
- repo_root = repository_storage['path']
+ repo_root = repository_storage.legacy_disk_path
# Look for global repos (legacy, depth 1) and normal repos (depth 2)
IO.popen(%W(find #{repo_root} -mindepth 1 -maxdepth 2 -name *.git)) do |find|
find.each_line do |path|
@@ -76,7 +78,7 @@ namespace :gitlab do
end
desc "GitLab | Cleanup | Block users that have been removed in LDAP"
- task block_removed_ldap_users: :environment do
+ task block_removed_ldap_users: :gitlab_environment do
warn_user_is_not_gitlab
block_flag = ENV['BLOCK']
@@ -84,7 +86,8 @@ namespace :gitlab do
next unless user.ldap_user?
print "#{user.name} (#{user.ldap_identity.extern_uid}) ..."
- if Gitlab::LDAP::Access.allowed?(user)
+
+ if Gitlab::Auth::LDAP::Access.allowed?(user)
puts " [OK]".color(:green)
else
if block_flag
@@ -106,7 +109,7 @@ namespace :gitlab do
# released. So likely this should only be run once on gitlab.com
# Faulty refs are moved so they are kept around, else some features break.
desc 'GitLab | Cleanup | Remove faulty deployment refs'
- task move_faulty_deployment_refs: :environment do
+ task move_faulty_deployment_refs: :gitlab_environment do
projects = Project.where(id: Deployment.select(:project_id).distinct)
projects.find_each do |project|
diff --git a/lib/tasks/gitlab/dev.rake b/lib/tasks/gitlab/dev.rake
index ba221e44e5d..77c28615856 100644
--- a/lib/tasks/gitlab/dev.rake
+++ b/lib/tasks/gitlab/dev.rake
@@ -14,6 +14,7 @@ namespace :gitlab do
puts "Must specify a branch as an argument".color(:red)
exit 1
end
+
args
end
diff --git a/lib/tasks/gitlab/exclusive_lease.rake b/lib/tasks/gitlab/exclusive_lease.rake
new file mode 100644
index 00000000000..83722bf6d94
--- /dev/null
+++ b/lib/tasks/gitlab/exclusive_lease.rake
@@ -0,0 +1,9 @@
+namespace :gitlab do
+ namespace :exclusive_lease do
+ desc 'GitLab | Clear existing exclusive leases for specified scope (default: *)'
+ task :clear, [:scope] => [:environment] do |_, args|
+ args[:scope].nil? ? Gitlab::ExclusiveLease.reset_all! : Gitlab::ExclusiveLease.reset_all!(args[:scope])
+ puts 'All exclusive lease entries were removed.'
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/git.rake b/lib/tasks/gitlab/git.rake
index cf82134d97e..cb4f7e5c8a8 100644
--- a/lib/tasks/gitlab/git.rake
+++ b/lib/tasks/gitlab/git.rake
@@ -1,7 +1,7 @@
namespace :gitlab do
namespace :git do
desc "GitLab | Git | Repack"
- task repack: :environment do
+ task repack: :gitlab_environment do
failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} repack -a --quiet), "Repacking repo")
if failures.empty?
puts "Done".color(:green)
@@ -11,7 +11,7 @@ namespace :gitlab do
end
desc "GitLab | Git | Run garbage collection on all repos"
- task gc: :environment do
+ task gc: :gitlab_environment do
failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} gc --auto --quiet), "Garbage Collecting")
if failures.empty?
puts "Done".color(:green)
@@ -21,7 +21,7 @@ namespace :gitlab do
end
desc "GitLab | Git | Prune all repos"
- task prune: :environment do
+ task prune: :gitlab_environment do
failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} prune), "Git Prune")
if failures.empty?
puts "Done".color(:green)
@@ -30,6 +30,20 @@ namespace :gitlab do
end
end
+ desc 'GitLab | Git | Check all repos integrity'
+ task fsck: :gitlab_environment do
+ failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} fsck --name-objects --no-progress), "Checking integrity") do |repo|
+ check_config_lock(repo)
+ check_ref_locks(repo)
+ end
+
+ if failures.empty?
+ puts "Done".color(:green)
+ else
+ output_failures(failures)
+ end
+ end
+
def perform_git_cmd(cmd, message)
puts "Starting #{message} on all repositories"
@@ -40,6 +54,8 @@ namespace :gitlab do
else
failures << repo
end
+
+ yield(repo) if block_given?
end
failures
@@ -49,5 +65,24 @@ namespace :gitlab do
puts "The following repositories reported errors:".color(:red)
failures.each { |f| puts "- #{f}" }
end
+
+ def check_config_lock(repo_dir)
+ config_exists = File.exist?(File.join(repo_dir, 'config.lock'))
+ config_output = config_exists ? 'yes'.color(:red) : 'no'.color(:green)
+
+ puts "'config.lock' file exists?".color(:yellow) + " ... #{config_output}"
+ end
+
+ def check_ref_locks(repo_dir)
+ lock_files = Dir.glob(File.join(repo_dir, 'refs/heads/*.lock'))
+
+ if lock_files.present?
+ puts "Ref lock files exist:".color(:red)
+
+ lock_files.each { |lock_file| puts " #{lock_file}" }
+ else
+ puts "No ref lock files exist".color(:green)
+ end
+ end
end
end
diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake
index 4d880c05f99..e9ca6404fe8 100644
--- a/lib/tasks/gitlab/gitaly.rake
+++ b/lib/tasks/gitlab/gitaly.rake
@@ -1,13 +1,15 @@
namespace :gitlab do
namespace :gitaly do
desc "GitLab | Install or upgrade gitaly"
- task :install, [:dir, :repo] => :environment do |t, args|
- require 'toml'
+ task :install, [:dir, :repo] => :gitlab_environment do |t, args|
+ require 'toml-rb'
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
@@ -19,10 +21,14 @@ namespace :gitlab do
_, status = Gitlab::Popen.popen(%w[which gmake])
command << (status.zero? ? 'gmake' : 'make')
- command << 'BUNDLE_FLAGS=--no-deployment' if Rails.env.test?
+ if Rails.env.test?
+ command.push(
+ 'BUNDLE_FLAGS=--no-deployment',
+ "BUNDLE_PATH=#{Bundler.bundle_path}")
+ end
+ Gitlab::SetupHelper.create_gitaly_configuration(args.dir)
Dir.chdir(args.dir) do
- create_gitaly_configuration
# In CI we run scripts/gitaly-test-build instead of this command
unless ENV['CI'].present?
Bundler.with_original_env { run_command!(command) }
@@ -32,67 +38,14 @@ namespace :gitlab do
desc "GitLab | Print storage configuration in TOML format"
task storage_config: :environment do
- require 'toml'
+ require 'toml-rb'
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."
# Exclude gitaly-ruby configuration because that depends on the gitaly
# installation directory.
- puts gitaly_configuration_toml(gitaly_ruby: false)
- end
-
- private
-
- # 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 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.
- # For development and testing purposes, an extra storage is added to gitaly,
- # which is not known to Rails, but must be explicitly stubbed.
- def gitaly_configuration_toml(gitaly_ruby: true)
- storages = []
- address = nil
-
- Gitlab.config.repositories.storages.each do |key, val|
- if address
- if address != val['gitaly_address']
- raise ArgumentError, "Your gitlab.yml contains more than one gitaly_address."
- end
- elsif URI(val['gitaly_address']).scheme != 'unix'
- raise ArgumentError, "Automatic config.toml generation only supports 'unix:' addresses."
- else
- address = val['gitaly_address']
- end
-
- storages << { name: key, path: val['path'] }
- end
-
- if Rails.env.test?
- storages << { name: 'test_second_storage', path: Rails.root.join('tmp', 'tests', 'second_storage').to_s }
- 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 }
- config[:bin_dir] = Gitlab.config.gitaly.client_path
-
- TOML.dump(config)
- end
-
- def create_gitaly_configuration
- File.open("config.toml", File::WRONLY | File::CREAT | File::EXCL) do |f|
- f.puts gitaly_configuration_toml
- end
- rescue Errno::EEXIST
- puts "Skipping config.toml generation:"
- puts "A configuration file already exists."
- rescue ArgumentError => e
- puts "Skipping config.toml generation:"
- puts e.message
+ puts Gitlab::SetupHelper.gitaly_configuration_toml('', gitaly_ruby: false)
end
end
end
diff --git a/lib/tasks/gitlab/helpers.rake b/lib/tasks/gitlab/helpers.rake
index b0a24790c4a..14d1125a03d 100644
--- a/lib/tasks/gitlab/helpers.rake
+++ b/lib/tasks/gitlab/helpers.rake
@@ -1,8 +1,6 @@
-require 'tasks/gitlab/task_helpers'
-
# Prevent StateMachine warnings from outputting during a cron task
StateMachines::Machine.ignore_method_conflicts = true if ENV['CRON']
-namespace :gitlab do
+task gitlab_environment: :environment do
extend SystemCheck::Helpers
end
diff --git a/lib/tasks/gitlab/info.rake b/lib/tasks/gitlab/info.rake
index e9fb6a008b0..47ed522aec3 100644
--- a/lib/tasks/gitlab/info.rake
+++ b/lib/tasks/gitlab/info.rake
@@ -1,7 +1,7 @@
namespace :gitlab do
namespace :env do
desc "GitLab | Show information about GitLab and its environment"
- task info: :environment do
+ task info: :gitlab_environment do
# check if there is an RVM environment
rvm_version = run_and_match(%w(rvm --version), /[\d\.]+/).try(:to_s)
# check Ruby version
@@ -68,7 +68,7 @@ namespace :gitlab do
puts "Version:\t#{gitlab_shell_version || "unknown".color(:red)}"
puts "Repository storage paths:"
Gitlab.config.repositories.storages.each do |name, repository_storage|
- puts "- #{name}: \t#{repository_storage['path']}"
+ puts "- #{name}: \t#{repository_storage.legacy_disk_path}"
end
puts "Hooks:\t\t#{Gitlab.config.gitlab_shell.hooks_path}"
puts "Git:\t\t#{Gitlab.config.git.bin_path}"
diff --git a/lib/tasks/gitlab/lfs/check.rake b/lib/tasks/gitlab/lfs/check.rake
new file mode 100644
index 00000000000..869463d4e5d
--- /dev/null
+++ b/lib/tasks/gitlab/lfs/check.rake
@@ -0,0 +1,8 @@
+namespace :gitlab do
+ namespace :lfs do
+ desc 'GitLab | LFS | Check integrity of uploaded LFS objects'
+ task check: :environment do
+ Gitlab::Verify::RakeTask.run!(Gitlab::Verify::LfsObjects)
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/lfs/migrate.rake b/lib/tasks/gitlab/lfs/migrate.rake
new file mode 100644
index 00000000000..a45e5ca91e0
--- /dev/null
+++ b/lib/tasks/gitlab/lfs/migrate.rake
@@ -0,0 +1,22 @@
+require 'logger'
+
+desc "GitLab | Migrate LFS objects to remote storage"
+namespace :gitlab do
+ namespace :lfs do
+ task migrate: :environment do
+ logger = Logger.new(STDOUT)
+ logger.info('Starting transfer of LFS files to object storage')
+
+ LfsObject.with_files_stored_locally
+ .find_each(batch_size: 10) do |lfs_object|
+ begin
+ lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
+
+ logger.info("Transferred LFS object #{lfs_object.oid} of size #{lfs_object.size.to_i.bytes} to object storage")
+ rescue => e
+ logger.error("Failed to transfer LFS object #{lfs_object.oid} with error: #{e.message}")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/list_repos.rake b/lib/tasks/gitlab/list_repos.rake
index b732db9db6e..d7f28691098 100644
--- a/lib/tasks/gitlab/list_repos.rake
+++ b/lib/tasks/gitlab/list_repos.rake
@@ -8,6 +8,7 @@ namespace :gitlab do
namespace_ids = Namespace.where(['updated_at > ?', date]).pluck(:id).sort
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.disk_path)
puts base + '.git'
diff --git a/lib/tasks/gitlab/setup.rake b/lib/tasks/gitlab/setup.rake
index 05fcb8e3da5..1d903c81358 100644
--- a/lib/tasks/gitlab/setup.rake
+++ b/lib/tasks/gitlab/setup.rake
@@ -1,6 +1,6 @@
namespace :gitlab do
desc "GitLab | Setup production application"
- task setup: :environment do
+ task setup: :gitlab_environment do
setup_db
end
diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake
index 0e6aed32c52..4fcbbbf8c9d 100644
--- a/lib/tasks/gitlab/shell.rake
+++ b/lib/tasks/gitlab/shell.rake
@@ -1,7 +1,7 @@
namespace :gitlab do
namespace :shell do
desc "GitLab | Install or upgrade gitlab-shell"
- task :install, [:repo] => :environment do |t, args|
+ task :install, [:repo] => :gitlab_environment do |t, args|
warn_user_is_not_gitlab
default_version = Gitlab::Shell.version_required
@@ -54,32 +54,22 @@ namespace :gitlab do
# (Re)create hooks
Rake::Task['gitlab:shell:create_hooks'].invoke
- # Required for debian packaging with PKGR: Setup .ssh/environment with
- # the current PATH, so that the correct ruby version gets loaded
- # Requires to set "PermitUserEnvironment yes" in sshd config (should not
- # be an issue since it is more than likely that there are no "normal"
- # user accounts on a gitlab server). The alternative is for the admin to
- # install a ruby (1.9.3+) in the global path.
- File.open(File.join(user_home, ".ssh", "environment"), "w+") do |f|
- f.puts "PATH=#{ENV['PATH']}"
- end
-
Gitlab::Shell.ensure_secret_token!
end
desc "GitLab | Setup gitlab-shell"
- task setup: :environment do
+ task setup: :gitlab_environment do
setup
end
desc "GitLab | Build missing projects"
- task build_missing_projects: :environment do
+ task build_missing_projects: :gitlab_environment do
Project.find_each(batch_size: 1000) do |project|
path_to_repo = project.repository.path_to_repo
if File.exist?(path_to_repo)
print '-'
else
- if Gitlab::Shell.new.add_repository(project.repository_storage,
+ if Gitlab::Shell.new.create_repository(project.repository_storage,
project.disk_path)
print '.'
else
@@ -90,7 +80,7 @@ namespace :gitlab do
end
desc 'Create or repair repository hooks symlink'
- task create_hooks: :environment do
+ task create_hooks: :gitlab_environment do
warn_user_is_not_gitlab
puts 'Creating/Repairing hooks symlinks for all repositories'
diff --git a/lib/tasks/gitlab/task_helpers.rb b/lib/tasks/gitlab/task_helpers.rb
deleted file mode 100644
index 6723662703c..00000000000
--- a/lib/tasks/gitlab/task_helpers.rb
+++ /dev/null
@@ -1,170 +0,0 @@
-require 'rainbow/ext/string'
-require 'gitlab/utils/strong_memoize'
-
-module Gitlab
- TaskFailedError = Class.new(StandardError)
- TaskAbortedByUserError = Class.new(StandardError)
-
- module TaskHelpers
- include Gitlab::Utils::StrongMemoize
-
- extend self
-
- # Ask if the user wants to continue
- #
- # Returns "yes" the user chose to continue
- # Raises Gitlab::TaskAbortedByUserError if the user chose *not* to continue
- def ask_to_continue
- answer = prompt("Do you want to continue (yes/no)? ".color(:blue), %w{yes no})
- raise Gitlab::TaskAbortedByUserError unless answer == "yes"
- end
-
- # Check which OS is running
- #
- # It will primarily use lsb_relase to determine the OS.
- # It has fallbacks to Debian, SuSE, OS X and systems running systemd.
- def os_name
- os_name = run_command(%w(lsb_release -irs))
- os_name ||=
- if File.readable?('/etc/system-release')
- File.read('/etc/system-release')
- elsif File.readable?('/etc/debian_version')
- "Debian #{File.read('/etc/debian_version')}"
- elsif File.readable?('/etc/SuSE-release')
- File.read('/etc/SuSE-release')
- elsif os_x_version = run_command(%w(sw_vers -productVersion))
- "Mac OS X #{os_x_version}"
- elsif File.readable?('/etc/os-release')
- File.read('/etc/os-release').match(/PRETTY_NAME=\"(.+)\"/)[1]
- end
-
- os_name.try(:squish!)
- end
-
- # Prompt the user to input something
- #
- # message - the message to display before input
- # choices - array of strings of acceptable answers or nil for any answer
- #
- # Returns the user's answer
- def prompt(message, choices = nil)
- begin
- print(message)
- answer = STDIN.gets.chomp
- end while choices.present? && !choices.include?(answer)
- answer
- end
-
- # Runs the given command and matches the output against the given pattern
- #
- # Returns nil if nothing matched
- # Returns the MatchData if the pattern matched
- #
- # see also #run_command
- # see also String#match
- def run_and_match(command, regexp)
- run_command(command).try(:match, regexp)
- end
-
- # Runs the given command
- #
- # Returns '' if the command was not found
- # Returns the output of the command otherwise
- #
- # see also #run_and_match
- def run_command(command)
- output, _ = Gitlab::Popen.popen(command)
- output
- rescue Errno::ENOENT
- '' # if the command does not exist, return an empty string
- end
-
- # Runs the given command and raises a Gitlab::TaskFailedError exception if
- # the command does not exit with 0
- #
- # Returns the output of the command otherwise
- def run_command!(command)
- output, status = Gitlab::Popen.popen(command)
-
- raise Gitlab::TaskFailedError.new(output) unless status.zero?
-
- output
- end
-
- def uid_for(user_name)
- run_command(%W(id -u #{user_name})).chomp.to_i
- end
-
- def gid_for(group_name)
- begin
- Etc.getgrnam(group_name).gid
- rescue ArgumentError # no group
- "group #{group_name} doesn't exist"
- end
- end
-
- def gitlab_user
- Gitlab.config.gitlab.user
- end
-
- def gitlab_user?
- strong_memoize(:is_gitlab_user) do
- current_user = run_command(%w(whoami)).chomp
- current_user == gitlab_user
- end
- end
-
- def warn_user_is_not_gitlab
- return if gitlab_user?
-
- strong_memoize(:warned_user_not_gitlab) do
- current_user = run_command(%w(whoami)).chomp
-
- 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
- end
-
- def all_repos
- Gitlab.config.repositories.storages.each_value do |repository_storage|
- IO.popen(%W(find #{repository_storage['path']} -mindepth 2 -maxdepth 2 -type d -name *.git)) do |find|
- find.each_line do |path|
- yield path.chomp
- end
- end
- end
- end
-
- def repository_storage_paths_args
- Gitlab.config.repositories.storages.values.map { |rs| rs['path'] }
- end
-
- def user_home
- Rails.env.test? ? Rails.root.join('tmp/tests') : Gitlab.config.gitlab.user_home
- end
-
- def checkout_or_clone_version(version:, repo:, target_dir:)
- version =
- if version.starts_with?("=")
- version.sub(/\A=/, '') # tag or branch
- else
- "v#{version}" # tag
- end
-
- clone_repo(repo, target_dir) unless Dir.exist?(target_dir)
- checkout_version(version, target_dir)
- end
-
- def clone_repo(repo, target_dir)
- run_command!(%W[#{Gitlab.config.git.bin_path} clone -- #{repo} #{target_dir}])
- end
-
- def checkout_version(version, target_dir)
- 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/traces.rake b/lib/tasks/gitlab/traces.rake
new file mode 100644
index 00000000000..fd2a4f2d11a
--- /dev/null
+++ b/lib/tasks/gitlab/traces.rake
@@ -0,0 +1,24 @@
+require 'logger'
+require 'resolv-replace'
+
+desc "GitLab | Archive legacy traces to trace artifacts"
+namespace :gitlab do
+ namespace :traces do
+ task archive: :environment do
+ logger = Logger.new(STDOUT)
+ logger.info('Archiving legacy traces')
+
+ Ci::Build.finished
+ .where('NOT EXISTS (?)',
+ Ci::JobArtifact.select(1).trace.where('ci_builds.id = ci_job_artifacts.job_id'))
+ .order(id: :asc)
+ .find_in_batches(batch_size: 1000) do |jobs|
+ job_ids = jobs.map { |job| [job.id] }
+
+ ArchiveTraceWorker.bulk_perform_async(job_ids)
+
+ logger.info("Scheduled #{job_ids.count} jobs. From #{job_ids.min} to #{job_ids.max}")
+ end
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake
index 7728c485e8d..6b22499a5c8 100644
--- a/lib/tasks/gitlab/two_factor.rake
+++ b/lib/tasks/gitlab/two_factor.rake
@@ -1,7 +1,7 @@
namespace :gitlab do
namespace :two_factor do
desc "GitLab | Disable Two-factor authentication (2FA) for all users"
- task disable_for_all_users: :environment do
+ task disable_for_all_users: :gitlab_environment do
scope = User.with_two_factor
count = scope.count
diff --git a/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake
index f44abc2b81b..a25f7ce59c7 100644
--- a/lib/tasks/gitlab/update_templates.rake
+++ b/lib/tasks/gitlab/update_templates.rake
@@ -10,6 +10,7 @@ namespace :gitlab do
puts "This rake task is not meant fo production instances".red
exit(1)
end
+
admin = User.find_by(admin: true)
unless admin
diff --git a/lib/tasks/gitlab/uploads/check.rake b/lib/tasks/gitlab/uploads/check.rake
new file mode 100644
index 00000000000..2be2ec7f9c9
--- /dev/null
+++ b/lib/tasks/gitlab/uploads/check.rake
@@ -0,0 +1,8 @@
+namespace :gitlab do
+ namespace :uploads do
+ desc 'GitLab | Uploads | Check integrity of uploaded files'
+ task check: :environment do
+ Gitlab::Verify::RakeTask.run!(Gitlab::Verify::Uploads)
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/uploads/migrate.rake b/lib/tasks/gitlab/uploads/migrate.rake
new file mode 100644
index 00000000000..78e18992a8e
--- /dev/null
+++ b/lib/tasks/gitlab/uploads/migrate.rake
@@ -0,0 +1,34 @@
+namespace :gitlab do
+ namespace :uploads do
+ desc 'GitLab | Uploads | Migrate the uploaded files to object storage'
+ task :migrate, [:uploader_class, :model_class, :mounted_as] => :environment do |task, args|
+ batch_size = ENV.fetch('BATCH', 200).to_i
+ @to_store = ObjectStorage::Store::REMOTE
+ @mounted_as = args.mounted_as&.gsub(':', '')&.to_sym
+ @uploader_class = args.uploader_class.constantize
+ @model_class = args.model_class.constantize
+
+ uploads.each_batch(of: batch_size, &method(:enqueue_batch)) # rubocop: disable Cop/InBatches
+ end
+
+ def enqueue_batch(batch, index)
+ job = ObjectStorage::MigrateUploadsWorker.enqueue!(batch,
+ @model_class,
+ @mounted_as,
+ @to_store)
+ puts "Enqueued job ##{index}: #{job}"
+ rescue ObjectStorage::MigrateUploadsWorker::SanityCheckError => e
+ # continue for the next batch
+ puts "Could not enqueue batch (#{batch.ids}) #{e.message}".color(:red)
+ end
+
+ def uploads
+ Upload.class_eval { include EachBatch } unless Upload < EachBatch
+
+ Upload
+ .where(store: [nil, ObjectStorage::Store::LOCAL],
+ uploader: @uploader_class.to_s,
+ model_type: @model_class.base_class.sti_name)
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/workhorse.rake b/lib/tasks/gitlab/workhorse.rake
index e7ac0b5859f..b917a293095 100644
--- a/lib/tasks/gitlab/workhorse.rake
+++ b/lib/tasks/gitlab/workhorse.rake
@@ -1,11 +1,13 @@
namespace :gitlab do
namespace :workhorse do
desc "GitLab | Install or upgrade gitlab-workhorse"
- task :install, [:dir, :repo] => :environment do |t, args|
+ task :install, [:dir, :repo] => :gitlab_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
diff --git a/lib/tasks/lint.rake b/lib/tasks/lint.rake
index 7b63e93db0e..fe5032cae18 100644
--- a/lib/tasks/lint.rake
+++ b/lib/tasks/lint.rake
@@ -1,8 +1,69 @@
unless Rails.env.production?
namespace :lint do
+ task :static_verification_env do
+ ENV['STATIC_VERIFICATION'] = 'true'
+ end
+
+ desc "GitLab | lint | Static verification"
+ task static_verification: %w[
+ lint:static_verification_env
+ dev:load
+ ] do
+ Gitlab::Utils::Override.verify!
+ end
+
desc "GitLab | lint | Lint JavaScript files using ESLint"
task :javascript do
Rake::Task['eslint'].invoke
end
+
+ desc "GitLab | lint | Run several lint checks"
+ task :all do
+ status = 0
+
+ %w[
+ config_lint
+ haml_lint
+ scss_lint
+ flay
+ gettext:lint
+ lint:static_verification
+ ].each do |task|
+ pid = Process.fork do
+ rd, wr = IO.pipe
+ stdout = $stdout.dup
+ stderr = $stderr.dup
+ $stdout.reopen(wr)
+ $stderr.reopen(wr)
+
+ begin
+ begin
+ Rake::Task[task].invoke
+ rescue RuntimeError # The haml_lint tasks raise a RuntimeError
+ exit(1)
+ end
+ rescue SystemExit => ex
+ msg = "*** Rake task #{task} failed with the following error(s):"
+ raise ex
+ ensure
+ $stdout.reopen(stdout)
+ $stderr.reopen(stderr)
+ wr.close
+
+ if msg
+ warn "\n#{msg}\n\n"
+ IO.copy_stream(rd, $stderr)
+ else
+ IO.copy_stream(rd, $stdout)
+ end
+ end
+ end
+
+ Process.waitpid(pid)
+ status += $?.exitstatus
+ end
+
+ exit(status)
+ end
end
end
diff --git a/lib/tasks/migrate/migrate_iids.rake b/lib/tasks/migrate/migrate_iids.rake
index fc2cea8c016..aa2d01730d7 100644
--- a/lib/tasks/migrate/migrate_iids.rake
+++ b/lib/tasks/migrate/migrate_iids.rake
@@ -4,6 +4,7 @@ task migrate_iids: :environment do
Issue.where(iid: nil).find_each(batch_size: 100) do |issue|
begin
issue.set_iid
+
if issue.update_attribute(:iid, issue.iid)
print '.'
else
@@ -19,6 +20,7 @@ task migrate_iids: :environment do
MergeRequest.where(iid: nil).find_each(batch_size: 100) do |mr|
begin
mr.set_iid
+
if mr.update_attribute(:iid, mr.iid)
print '.'
else
@@ -34,6 +36,7 @@ task migrate_iids: :environment do
Milestone.where(iid: nil).find_each(batch_size: 100) do |m|
begin
m.set_iid
+
if m.update_attribute(:iid, m.iid)
print '.'
else
diff --git a/lib/tasks/migrate/setup_postgresql.rake b/lib/tasks/migrate/setup_postgresql.rake
index 9cc986535e1..af30ecb0e9b 100644
--- a/lib/tasks/migrate/setup_postgresql.rake
+++ b/lib/tasks/migrate/setup_postgresql.rake
@@ -1,18 +1,22 @@
-require Rails.root.join('lib/gitlab/database')
-require Rails.root.join('lib/gitlab/database/migration_helpers')
-require Rails.root.join('db/migrate/20151007120511_namespaces_projects_path_lower_indexes')
-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
+ require Rails.root.join('db/migrate/20151007120511_namespaces_projects_path_lower_indexes')
+ 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')
+ require Rails.root.join('db/migrate/20171220191323_add_index_on_namespaces_lower_name.rb')
+ require Rails.root.join('db/migrate/20180215181245_users_name_lower_index.rb')
+ require Rails.root.join('db/post_migrate/20180306164012_add_path_index_to_redirect_routes.rb')
+
NamespacesProjectsPathLowerIndexes.new.up
AddUsersLowerUsernameEmailIndexes.new.up
AddLowerPathIndexToRoutes.new.up
IndexRoutesPathForLike.new.up
AddLowerPathIndexToRedirectRoutes.new.up
IndexRedirectRoutesPathForLike.new.up
+ AddIndexOnNamespacesLowerName.new.up
+ UsersNameLowerIndex.new.up
+ AddPathIndexToRedirectRoutes.new.up
end
diff --git a/lib/tasks/plugins.rake b/lib/tasks/plugins.rake
new file mode 100644
index 00000000000..e73dd7e68df
--- /dev/null
+++ b/lib/tasks/plugins.rake
@@ -0,0 +1,16 @@
+namespace :plugins do
+ desc 'Validate existing plugins'
+ task validate: :environment do
+ puts 'Validating plugins from /plugins directory'
+
+ Gitlab::Plugin.files.each do |file|
+ success, message = Gitlab::Plugin.execute(file, Gitlab::DataBuilder::Push::SAMPLE_DATA)
+
+ if success
+ puts "* #{file} succeed (zero exit code)."
+ else
+ puts "* #{file} failure (non-zero exit code). #{message}"
+ end
+ end
+ end
+end
diff --git a/lib/tasks/test.rake b/lib/tasks/test.rake
index 3e01f91d32c..b52af81fc16 100644
--- a/lib/tasks/test.rake
+++ b/lib/tasks/test.rake
@@ -4,8 +4,3 @@ desc "GitLab | Run all tests"
task :test do
Rake::Task["gitlab:test"].invoke
end
-
-unless Rails.env.production?
- desc "GitLab | Run all tests on CI with simplecov"
- task test_ci: [:rubocop, :brakeman, :karma, :spinach, :spec]
-end
diff --git a/lib/uploaded_file.rb b/lib/uploaded_file.rb
index 4a3c40f88eb..5dc85b2baea 100644
--- a/lib/uploaded_file.rb
+++ b/lib/uploaded_file.rb
@@ -1,8 +1,10 @@
require "tempfile"
+require "tmpdir"
require "fileutils"
-# Taken from: Rack::Test::UploadedFile
class UploadedFile
+ InvalidPathError = Class.new(StandardError)
+
# The filename, *not* including the path, of the "uploaded" file
attr_reader :original_filename
@@ -12,14 +14,46 @@ class UploadedFile
# The content type of the "uploaded" file
attr_accessor :content_type
- def initialize(path, filename, content_type = "text/plain")
- raise "#{path} file does not exist" unless ::File.exist?(path)
+ attr_reader :remote_id
+ attr_reader :sha256
+
+ def initialize(path, filename: nil, content_type: "application/octet-stream", sha256: nil, remote_id: nil)
+ raise InvalidPathError, "#{path} file does not exist" unless ::File.exist?(path)
@content_type = content_type
@original_filename = filename || ::File.basename(path)
+ @content_type = content_type
+ @sha256 = sha256
+ @remote_id = remote_id
@tempfile = File.new(path, 'rb')
end
+ def self.from_params(params, field, upload_path)
+ unless params["#{field}.path"]
+ raise InvalidPathError, "file is invalid" if params["#{field}.remote_id"]
+
+ return
+ end
+
+ file_path = File.realpath(params["#{field}.path"])
+
+ unless self.allowed_path?(file_path, [upload_path, Dir.tmpdir].compact)
+ raise InvalidPathError, "insecure path used '#{file_path}'"
+ end
+
+ UploadedFile.new(file_path,
+ filename: params["#{field}.name"],
+ content_type: params["#{field}.type"] || 'application/octet-stream',
+ sha256: params["#{field}.sha256"],
+ remote_id: params["#{field}.remote_id"])
+ end
+
+ def self.allowed_path?(file_path, paths)
+ paths.any? do |path|
+ File.exist?(path) && file_path.start_with?(File.realpath(path))
+ end
+ end
+
def path
@tempfile.path
end
diff --git a/locale/bg/gitlab.po b/locale/bg/gitlab.po
index 374164cbe65..a170014844f 100644
--- a/locale/bg/gitlab.po
+++ b/locale/bg/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-03 12:29-0400\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:34-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Bulgarian\n"
"Language: bg_BG\n"
@@ -16,39 +16,76 @@ msgstr ""
"X-Crowdin-Language: bg\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d подаване"
msgstr[1] "%d подаваниÑ"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
msgstr[1] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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] "%s подаване беше пропуÑнато, за да не Ñе натоварва ÑиÑтемата."
msgstr[1] "%s Ð¿Ð¾Ð´Ð°Ð²Ð°Ð½Ð¸Ñ Ð±Ñха пропуÑнати, за да не Ñе натоварва ÑиÑтемата."
-msgid "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} подаде %{commit_timeago}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
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."
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
@@ -56,6 +93,9 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts
msgstr[0] ""
msgstr[1] ""
+msgid "%{text} is available"
+msgstr ""
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr ""
@@ -76,15 +116,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Ðабор от графики отноÑно непрекъÑнатата интеграциÑ"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "ОтноÑно автоматичното внедрÑване"
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -94,6 +149,9 @@ msgstr ""
msgid "Account"
msgstr ""
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "Ðктивно"
@@ -112,73 +170,283 @@ msgstr "ДобавÑне на ръководÑтво за ÑътрудничеÑ
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
msgid "Add License"
msgstr "ДобавÑне на лиценз"
-msgid "Add an SSH key to your profile to pull or push via SSH."
-msgstr "Добавете SSH ключ в профила Ñи, за да можете да изтеглÑте или изпращате промени чрез SSH."
+msgid "Add Readme"
+msgstr ""
msgid "Add new directory"
msgstr "ДобавÑне на нова папка"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr ""
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr ""
msgid "Applications"
msgstr ""
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+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 leave this group?"
-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 you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr ""
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Прикачете файл чрез влачене и пуÑкане или %{upload_link}"
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
+msgstr ""
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -196,7 +464,31 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
@@ -253,10 +545,10 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Клон"
-msgstr[1] "Клонове"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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}"
@@ -264,14 +556,29 @@ msgstr "Клонът <strong>%{branch_name}</strong> беше Ñъздаден.
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
-msgstr "ТърÑете в клоновете"
+msgstr "ТърÑете в клоните"
msgid "BranchSwitcherTitle|Switch branch"
msgstr "Превключване на клона"
msgid "Branches"
-msgstr "Клонове"
+msgstr "Клони"
+
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -318,12 +625,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -369,14 +703,23 @@ msgstr "Преглед на файловете"
msgid "Browse files"
msgstr "Разглеждане на файловете"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "от"
msgid "CI / CD"
msgstr ""
-msgid "CI configuration"
-msgstr "ÐšÐ¾Ð½Ñ„Ð¸Ð³ÑƒÑ€Ð°Ñ†Ð¸Ñ Ð½Ð° непрекъÑната интеграциÑ"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -384,12 +727,21 @@ msgstr ""
msgid "Cancel"
msgstr "Отказ"
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "Избиране в клона"
@@ -402,22 +754,55 @@ msgstr "Подбиране"
msgid "ChangeTypeAction|Revert"
msgstr "ОтмÑна"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr "СпиÑък Ñ Ð¿Ñ€Ð¾Ð¼ÐµÐ½Ð¸"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "Графики"
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr "Подбиране на това подаване"
msgid "Cherry-pick this merge request"
msgstr "Подбиране на тази заÑвка за Ñливане"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -474,55 +859,145 @@ msgstr "пропуÑнато"
msgid "CiStatus|running"
msgstr "протича в момента"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Environment scope"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -534,43 +1009,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|More information"
+msgstr ""
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -582,16 +1159,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -603,18 +1201,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr ""
@@ -623,8 +1236,8 @@ msgid_plural "Commits"
msgstr[0] "Подаване"
msgstr[1] "ПодаваниÑ"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgstr[1] ""
@@ -637,6 +1250,12 @@ msgstr "Времетраене на подаваниÑта в минути за
msgid "Commit message"
msgstr "Съобщение за подаването"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "Подаване"
@@ -649,15 +1268,99 @@ msgstr "ПодаваниÑ"
msgid "Commits feed"
msgstr "Поток от подаваниÑ"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "ИÑториÑ"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "Подадено от"
msgid "Compare"
msgstr "Сравнение"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -703,12 +1406,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "РъководÑтво за ÑътрудничеÑтво"
msgid "Contributors"
msgstr "Сътрудници"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -721,27 +1442,60 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr "Копиране на адреÑа в буфера за обмен"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "Копиране на идентификатора на подаването в буфера за обмен"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "Създаване на нова папка"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "Създайте Ñи личен жетон за доÑтъп в акаунта Ñи, за да можете да изтеглÑте и изпращате промени чрез %{protocol}."
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr "Създаване на папка"
-msgid "Create empty bare repository"
-msgstr "Създаване на празно хранилище"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr "Създаване на заÑвка за Ñливане"
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -751,9 +1505,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr "Създаване на нов…"
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "РазклонÑване"
@@ -763,24 +1523,36 @@ msgstr "Етикет"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "Ñи Ñъздадете личен жетон за доÑтъп"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr "ЧаÑова зона за „Cron“"
msgid "Cron syntax"
msgstr "СинтакÑÐ¸Ñ Ð½Ð° „Cron“"
+msgid "Current node"
+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 "Customize colors"
+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 "Програмиране"
@@ -808,6 +1580,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr "Задайте потребителÑки шаблон, използвайки ÑинтакÑиÑа на „Cron“"
@@ -831,10 +1612,19 @@ msgstr ""
msgid "Details"
msgstr ""
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr "Име на папката"
-msgid "Discard changes"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
@@ -843,9 +1633,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "Да не Ñе показва повече"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "СвалÑне"
@@ -862,7 +1658,7 @@ msgid "Download zip"
msgstr "СвалÑне във формат „zip“"
msgid "DownloadArtifacts|Download"
-msgstr "СвалÑне"
+msgstr "СвалÑне на"
msgid "DownloadCommit|Email Patches"
msgstr "Изпращане на кръпките по е-поща"
@@ -873,15 +1669,171 @@ msgstr "Обикновен файл Ñ Ñ€Ð°Ð·Ð»Ð¸ÐºÐ¸"
msgid "DownloadSource|Download"
msgstr "СвалÑне"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Редактиране"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Редактиране на плана %{id} за Ñхема"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
@@ -909,24 +1861,75 @@ msgstr "Ð’Ñеки меÑец (на 1-во чиÑло, в 4 ч. Ñутринта
msgid "Every week (Sundays at 4:00am)"
msgstr "Ð’ÑÑка Ñедмица (в неделÑ, в 4 ч. Ñутринта)"
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "СобÑтвеникът не може да бъде променен"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "Планът за Ñхема не може да бъде премахнат"
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr "Файлове"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "Филтриране по Ñъобщение"
@@ -936,12 +1939,21 @@ msgstr "ТърÑене по път"
msgid "Find file"
msgstr "ТърÑене на файл"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "Първо"
msgid "FirstPushedBy|pushed by"
msgstr "изпращане на промени от"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Разклонение"
@@ -953,25 +1965,178 @@ msgstr "Разклонение на"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr "От Ñъздаването на проблема до внедрÑването в крайната верÑиÑ"
msgid "From merge request merge until deploy to production"
msgstr "От прилагането на заÑвката за Ñливане до внедрÑването в крайната верÑиÑ"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -980,12 +2145,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr ""
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr ""
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr "Към Вашето разклонение"
@@ -995,6 +2193,27 @@ msgstr "Разклонение"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1031,12 +2250,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1064,6 +2277,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr ""
@@ -1082,15 +2301,59 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr "ОÑвежаването започна уÑпешно"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "ВнаÑÑне на хранилище"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1100,6 +2363,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
@@ -1108,6 +2374,15 @@ msgid_plural "Instances"
msgstr[0] ""
msgstr[1] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1123,9 +2398,6 @@ msgstr "ПредÑтавÑме Ви анализа на циклите"
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr ""
@@ -1138,15 +2410,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "Изключено"
msgid "LFSStatus|Enabled"
msgstr "Включено"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "ПоÑÐ»ÐµÐ´Ð½Ð¸Ñ %d ден"
@@ -1176,6 +2520,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "Ðаучете повече в"
@@ -1194,32 +2547,78 @@ msgstr "ÐапуÑкане на проекта"
msgid "License"
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 "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "Медиана"
msgid "Members"
msgstr ""
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1229,22 +2628,145 @@ msgstr ""
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "добавите SSH ключ"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr ""
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1252,15 +2774,27 @@ msgid_plural "New Issues"
msgstr[0] "Ðов проблем"
msgstr[1] "Ðови проблема"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "Ðов план за Ñхема"
msgid "New branch"
msgstr "Ðов клон"
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "Ðова папка"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "Ðов файл"
@@ -1270,6 +2804,9 @@ msgstr ""
msgid "New issue"
msgstr "Ðов проблем"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "Ðова заÑвка за Ñливане"
@@ -1288,7 +2825,28 @@ msgstr ""
msgid "New tag"
msgstr "Ðов етикет"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1300,12 +2858,39 @@ msgstr "ÐÑма планове"
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "Ðе е налично"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "ÐÑма доÑтатъчно данни"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "Ð¡ÑŠÐ±Ð¸Ñ‚Ð¸Ñ Ð·Ð° извеÑÑ‚Ñване"
@@ -1363,18 +2948,48 @@ msgstr "Ðаблюдение"
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr "Филтър"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Отворен"
@@ -1384,12 +2999,21 @@ msgstr ""
msgid "Options"
msgstr "Опции"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr "СобÑтвеник"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1402,10 +3026,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1450,12 +3083,6 @@ msgstr "Ð’Ñички"
msgid "PipelineSchedules|Inactive"
msgstr "Ðеактивно"
-msgid "PipelineSchedules|Input variable key"
-msgstr "Въведете ключ за променливата"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "Въведете ÑтойноÑтта на променливата"
-
msgid "PipelineSchedules|Next Run"
msgstr "Следващо изпълнение"
@@ -1465,9 +3092,6 @@ msgstr "Ðищо"
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "Въведете кратко опиÑание за тази Ñхема"
-msgid "PipelineSchedules|Remove variable row"
-msgstr "Премахване на реда за променлива"
-
msgid "PipelineSchedules|Take ownership"
msgstr "Поемане на ÑобÑтвеноÑтта"
@@ -1495,6 +3119,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "вÑички"
@@ -1507,15 +3182,39 @@ msgstr "Ñ ÐµÑ‚Ð°Ð¿"
msgid "Pipeline|with stages"
msgstr "Ñ ÐµÑ‚Ð°Ð¿Ð¸"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1555,6 +3254,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1570,6 +3275,12 @@ msgstr "Проектът „%{project_name}“ беше обновен уÑпеÑ
msgid "Project access must be granted explicitly to each user."
msgstr "ДоÑтъпът до проекта Ñ‚Ñ€Ñбва да бъде даван поотделно на вÑеки потребител."
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr ""
@@ -1588,6 +3299,21 @@ msgstr "ИзнаÑÑнето на проекта започна. Ще получ
msgid "ProjectActivityRSS|Subscribe"
msgstr ""
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "Изключено"
@@ -1651,6 +3377,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1663,21 +3476,39 @@ msgstr ""
msgid "Push events"
msgstr ""
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr "Прочетете повече"
msgid "Readme"
msgstr "ПрочетиМе"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
-msgstr "Клонове"
+msgstr "Клони"
msgid "RefSwitcher|Tags"
msgstr "Етикети"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1699,15 +3530,42 @@ msgstr "Свързани заÑвки за Ñливане"
msgid "Related Merged Requests"
msgstr "Свързани приложени заÑвки за Ñливане"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "ÐапомнÑне по-къÑно"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "Премахване на проекта"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "ЗаÑвка за доÑтъп"
@@ -1720,16 +3578,54 @@ msgstr ""
msgid "Reset runners registration token"
msgstr ""
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Revert this commit"
msgstr "ОтмÑна на това подаване"
msgid "Revert this merge request"
msgstr "ОтмÑна на тази заÑвка за Ñливане"
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
msgstr ""
-msgid "Save"
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1738,50 +3634,116 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr "Запазване на плана за Ñхема"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "Създаване на нов план за Ñхема"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "Планиране на Ñхемите"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
-msgstr "ТърÑете в клоновете и етикетите"
+msgstr "ТърÑете в клоните и етикетите"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "Изберете формата на архива"
msgid "Select a timezone"
msgstr "Изберете чаÑова зона"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "Изберете целеви клон"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr "Задайте парола на акаунта Ñи, за да можете да изтеглÑте и изпращате промени чрез %{protocol}."
-msgid "Set up CI"
-msgstr "ÐаÑтройка на ÐИ"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "ÐаÑтройка на „Koding“"
-msgid "Set up auto deploy"
-msgstr "ÐаÑтройка на авт. внедрÑване"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "зададете парола"
@@ -1789,6 +3751,24 @@ msgstr "зададете парола"
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1800,13 +3780,46 @@ msgid_plural "Showing %d events"
msgstr[0] "Показване на %d Ñъбитие"
msgstr[1] "Показване на %d ÑъбитиÑ"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1815,6 +3828,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1914,18 +3930,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "Изходен код"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr "Звезда"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1935,29 +3969,119 @@ msgstr "Създайте %{new_merge_request} Ñ Ñ‚ÐµÐ·Ð¸ промени"
msgid "Start the Runner!"
msgstr ""
-msgid "Subgroups"
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
msgstr ""
-msgid "Subscribe"
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr "Преминаване към клон/етикет"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "Етикет"
-msgstr[1] "Етикети"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
+msgstr[1] ""
msgid "Tags"
msgstr "Етикети"
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "Целеви клон"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1967,7 +4091,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1976,30 +4106,39 @@ msgstr "Етапът на програмиране показва времето
msgid "The collection of events added to the data gathered for that stage."
msgstr "СъвкупноÑтта от ÑÑŠÐ±Ð¸Ñ‚Ð¸Ñ Ð´Ð¾Ð±Ð°Ð²ÐµÐ½Ð¸ към данните Ñъбрани за този етап."
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "Връзката на разклонение беше премахната."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "Етапът на издаване показва общото време, което е нужно от Ñъздаването на проблем до внедрÑването на кода в крайната верÑиÑ. Данните ще бъдат добавени автоматично Ñлед като завършите един пълен цикъл и превърнете първата Ñи Ð¸Ð´ÐµÑ Ð² реалноÑÑ‚."
@@ -2012,9 +4151,18 @@ msgstr "Ð’Ñеки може да има доÑтъп до проекта, без
msgid "The repository for this project does not exist."
msgstr "Хранилището за този проект не ÑъщеÑтвува."
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 "Етапът на подготовка за издаване показва времето между прилагането на заÑвката за Ñливане и внедрÑването на кода в Ñредата на работещата крайна верÑиÑ. Данните ще бъдат добавени автоматично Ñлед като направите първото Ñи внедрÑване в крайната верÑиÑ."
@@ -2027,16 +4175,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
msgid "The time taken by each data entry gathered by that stage."
msgstr "Времето, което отнема вÑеки Ð·Ð°Ð¿Ð¸Ñ Ð¾Ñ‚ данни за ÑÑŠÐ¾Ñ‚Ð²ÐµÑ‚Ð½Ð¸Ñ ÐµÑ‚Ð°Ð¿."
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr "СтойноÑтта, коÑто Ñе намира в Ñредата на поÑледователноÑтта от наблюдавани данни. Ðапример: медианата на 3, 5 и 9 е 5, а медианата на 3, 5, 7 и 8 е (5+7)/2 = 6."
+msgid "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2045,18 +4226,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "Това означава, че нÑма да можете да изпращате код, докато не Ñъздадете празно хранилище или не внеÑете ÑъщеÑтвуващо такова."
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "Време преди един проблем да бъде планиран за работа"
@@ -2066,9 +4283,27 @@ msgstr "Време преди работата по проблем да запо
msgid "Time between merge request creation and merge/close"
msgstr "Време между Ñъздаване на заÑвка за Ñливане и прилагането/отхвърлÑнето Ñ"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "Време преди първата заÑвка за Ñливане"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "преди %s дни"
@@ -2205,25 +4440,97 @@ msgstr[1] "мин"
msgid "Time|s"
msgstr "Ñек"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr "Общо време"
msgid "Total test time for all commits/merges"
msgstr "Общо време за теÑтване на вÑички подаваниÑ/ÑливаниÑ"
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "Без звезда"
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2247,24 +4554,72 @@ msgstr "Качване на нов файл"
msgid "Upload file"
msgstr "Качване на файл"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "щракнете за качване"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr "Използване на глобалната Ви наÑтройка за извеÑтиÑта"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "Преглед на отворената заÑвка за Ñливане"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "Вътрешен"
@@ -2280,16 +4635,28 @@ msgstr "ÐеизвеÑтно"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "ИÑкате ли да видите данните? Помолете админиÑтратор за доÑтъп."
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "ÐÑма доÑтатъчно данни за този етап."
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2310,6 +4677,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2400,39 +4773,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "ОттеглÑне на заÑвката за доÑтъп"
+msgid "Write a commit message..."
+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 %{project_full_name}. 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 "Ðа път Ñте да премахнете връзката на разклонението към Ð¾Ñ€Ð¸Ð³Ð¸Ð½Ð°Ð»Ð½Ð¸Ñ Ð¿Ñ€Ð¾ÐµÐºÑ‚, „%{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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "Можете да добавÑте файлове Ñамо когато Ñе намирате в клон"
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "Ðе можете да Ñъздавате повече проекти"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "ТрÑбва да Ñе впишете, за да отбележите проект ÑÑŠÑ Ð·Ð²ÐµÐ·Ð´Ð°"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "Ðуждаете Ñе от разрешение."
@@ -2457,6 +4860,36 @@ 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 "ÐÑма да можете да изтеглÑте или изпращате код в проекта чрез SSH, докато не %{add_ssh_key_link} в профила Ñи"
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2469,7 +4902,108 @@ msgstr "Вашето име"
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
@@ -2477,12 +5011,243 @@ msgid_plural "days"
msgstr[0] "ден"
msgstr[1] "дни"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "нова заÑвка за Ñливане"
msgid "notification emails"
msgstr "извеÑÑ‚Ð¸Ñ Ð¿Ð¾ е-поща"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "родител"
@@ -2494,9 +5259,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po
index a79a7d1a353..5a5cf1a19a3 100644
--- a/locale/de/gitlab.po
+++ b/locale/de/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-28 11:32-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:37-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -16,46 +16,86 @@ msgstr ""
"X-Crowdin-Language: de\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d Commit"
msgstr[1] "%d Commits"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
msgstr[1] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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] "%s zusätzlicher Commit wurde ausgelassen um Leistungsprobleme zu verhindern."
msgstr[1] "%s zusätzliche Commits wurden ausgelassen um Leistungsprobleme zu verhindern."
-msgid "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} hat %{commit_timeago} committet"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr ""
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "%{number_of_failures} von %{maximum_failures} Fehlschlägen. GitLab wird den Zugriff beim nächsten Versuch zulassen."
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "%{number_of_failures} von %{maximum_failures} Fehlschlägen. GitLab wird den Zugriff für %{number_of_seconds} Sekunden blockieren."
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "%{number_of_failures} von %{maximum_failures} Fehlschlägen. GitLab wird es nicht weiter versuchen. Setze die Speicherinformation nach Behebung des Problems zurück."
+msgid "%{openOrClose} %{noteable}"
+msgstr ""
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name}: fehlgeschlagener Speicherzugriff auf Host:"
msgstr[1] "%{storage_name}: %{failed_attempts} fehlgeschlagene Speicherzugriffe:"
+msgid "%{text} is available"
+msgstr ""
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(beachte die Informationen zur Installation auf %{link})."
@@ -76,15 +116,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Eine Sammlung von Graphen bezüglich kontinuierlicher Integration"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "Ãœber automatische Bereitstellung "
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -94,6 +149,9 @@ msgstr "Zugriff auf fehlerhafte Speicher wurde vorübergehend deaktiviert, um di
msgid "Account"
msgstr "Konto"
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "Aktiv"
@@ -112,73 +170,283 @@ msgstr "Mitarbeitsanleitung hinzufügen"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
msgid "Add License"
msgstr "Lizenz hinzufügen"
-msgid "Add an SSH key to your profile to pull or push via SSH."
-msgstr "Füge einen SSH Schlüssel zu deinem Profil hinzu, um mittels SSH zu übertragen (push) oder abzurufen (pull)."
+msgid "Add Readme"
+msgstr ""
msgid "Add new directory"
msgstr "Erstelle eine neues Verzeichnis"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr "Alle"
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr ""
msgid "Applications"
msgstr "Anwendungen"
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+msgstr ""
+
msgid "Archived project! Repository is read-only"
msgstr "Archiviertes Projekt! Repository ist nicht änderbar."
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr "Bist Du sicher, dass Du diesen Pipeline-Zeitplan löschen möchtest?"
-msgid "Are you sure you want to discard your changes?"
-msgstr "Bist Du sicher, dass Du alle Änderungen zurücksetzen willst?"
-
-msgid "Are you sure you want to leave this group?"
-msgstr ""
-
msgid "Are you sure you want to reset registration token?"
msgstr "Bist Du sicher, dass Du den Registrierungstoken zurücksetzen willst?"
msgid "Are you sure you want to reset the health check token?"
msgstr "Bist Du sicher, dass Du den Systemüberwachungstoken zurücksetzen willst?"
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr "Bist Du sicher?"
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Datei mittels Drag &amp; Drop oder %{upload_link} hinzufügen"
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
+msgstr ""
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -196,11 +464,35 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
-msgstr "Abrechnung"
+msgstr ""
msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
msgstr ""
@@ -253,10 +545,10 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Zweig"
-msgstr[1] "Zweige"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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 "Branch <strong>%{branch_name}</strong> wurde erstellt. Um die automatische Bereitstellung einzurichten, wähle eine GitLab CI Yaml Vorlage und committe Deine Änderungen. %{link_to_autodeploy_doc}"
@@ -264,6 +556,12 @@ msgstr "Branch <strong>%{branch_name}</strong> wurde erstellt. Um die automatisc
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "Branches durchsuchen"
@@ -273,6 +571,15 @@ msgstr "Branch wechseln"
msgid "Branches"
msgstr ""
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -318,12 +625,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -369,14 +703,23 @@ msgstr "Dateien durchsuchen"
msgid "Browse files"
msgstr "Dateien durchsuchen"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "von"
msgid "CI / CD"
msgstr "CI / CD"
-msgid "CI configuration"
-msgstr "CI-Konfiguration"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -384,12 +727,21 @@ msgstr ""
msgid "Cancel"
msgstr "Abbrechen"
-msgid "Cancel edit"
-msgstr "Bearbeitung abbrechen"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "In dem Branch wählen"
@@ -402,22 +754,55 @@ msgstr "Herauspicken"
msgid "ChangeTypeAction|Revert"
msgstr "Wiederherstellen "
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr "Änderungsliste "
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "Diagramme"
msgid "Chat"
msgstr "Chat"
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr "Diesen Commit herauspicken "
msgid "Cherry-pick this merge request"
msgstr "Diesen Merge Request herauspicken"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -474,55 +859,145 @@ msgstr "übersprungen"
msgid "CiStatus|running"
msgstr "laufend"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
-msgstr "Schließen"
+msgstr ""
+
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr ""
-msgid "Cluster"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -534,43 +1009,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Manage"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
+msgstr ""
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -582,16 +1159,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -603,18 +1201,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr "Kommentare"
@@ -623,8 +1236,8 @@ msgid_plural "Commits"
msgstr[0] "Commit"
msgstr[1] "Commits"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgstr[1] ""
@@ -637,6 +1250,12 @@ msgstr "Dauer der Commits in Minuten für die letzten 30 Commits"
msgid "Commit message"
msgstr "Commit Nachricht"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "Commit"
@@ -649,15 +1268,99 @@ msgstr "Commits"
msgid "Commits feed"
msgstr "Liste der Commits"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "Verlauf"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "Committed von"
msgid "Compare"
msgstr "Vergleichen"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -703,12 +1406,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "Mitarbeitsanleitung"
msgid "Contributors"
msgstr "Mitarbeiter"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -716,32 +1437,65 @@ msgid "Control the maximum concurrency of repository backfill for this secondary
msgstr ""
msgid "Copy SSH public key to clipboard"
-msgstr "Öffentlichen SSH-Schlüssel in die Zwischenablage kopieren"
+msgstr ""
msgid "Copy URL to clipboard"
msgstr "Kopiere URL in die Zwischenablage"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "Kopiere Commit SHA in die Zwischenablage"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "Erstelle neues Verzeichnis"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "Erstelle einen persönlichen Zugriffstoken in Deinem Konto um mittels %{protocol} zu übertragen (push) oder abzurufen (pull)."
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr "Erstelle Verzeichnis"
-msgid "Create empty bare repository"
-msgstr "Erstelle leeres Repository"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr "Erstelle Merge Request"
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -751,9 +1505,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr "Erstelle neues..."
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "Ableger"
@@ -763,24 +1523,36 @@ msgstr "Tag "
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "Erstelle einen persönlichen Zugriffstoken"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr "Cron Zeitzone"
msgid "Cron syntax"
msgstr "Cron Syntax"
+msgid "Current node"
+msgstr ""
+
msgid "Custom notification events"
msgstr "Individuelle Benachrichtigungsereignisse"
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 "Individuelle Benachrichtigungsstufen sind identisch mit den Beteiligungsstufen. Mit individuellen Benachrichtigungsstufen erhältst Du ebenfalls Mitteilungen für ausgewählte Ereignisse. Für weitere Informationen lies %{notification_link}. "
+msgid "Customize colors"
+msgstr ""
+
msgid "Cycle Analytics"
msgstr "Arbeitsablaufsanalysen"
-msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
-msgstr "Arbeitsablaufsanalysen verschaffen einen Überblick, welche Zeit Dein Projekt von der Idee zur Realisierung benötigt."
-
msgid "CycleAnalyticsStage|Code"
msgstr "Entwicklung"
@@ -808,6 +1580,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr "Erstelle ein individuelles Muster mittels Cron Syntax"
@@ -831,11 +1612,20 @@ msgstr ""
msgid "Details"
msgstr "Details"
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr "Verzeichnisname"
-msgid "Discard changes"
-msgstr "Änderungen verwerfen"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
+msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
msgstr ""
@@ -843,9 +1633,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "Nicht erneut anzeigen"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "Herunterladen"
@@ -873,15 +1669,171 @@ msgstr "Unterschiede"
msgid "DownloadSource|Download"
msgstr "Herunterladen"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Bearbeiten"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Pipeline Zeitplan bearbeiten %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr "E-Mails"
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr "Filtere alle"
@@ -909,24 +1861,75 @@ msgstr "Monatlich (am Ersten um 4:00 Uhr)"
msgid "Every week (Sundays at 4:00am)"
msgstr "Wöchentlich (Sonntags um 4:00 Uhr)"
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "Wechsel des Besitzers fehlgeschlagen"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "Entfernung der Pipelineplanung fehlgeschlagen"
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr "Dateien"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "Filter nach Commit Nachricht"
@@ -936,12 +1939,21 @@ msgstr "Finde über den Pfad"
msgid "Find file"
msgstr "Finde Datei"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "Erster"
msgid "FirstPushedBy|pushed by"
msgstr "übertragen von"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Ableger"
@@ -953,25 +1965,178 @@ msgstr "Ableger von"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr "Von der Ticketbeschreibung bis zur Bereitstellung"
msgid "From merge request merge until deploy to production"
msgstr "Vom Umsetzen des Merge Request bis zur Bereitstellung auf dem Produktivsystem"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -980,12 +2145,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr "Informationen über den Speicherzustand von Gitlab wurden zurückgesetzt."
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "GitLab Runner Bereich"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr "Gehe zu Deinem Ableger"
@@ -995,6 +2193,27 @@ msgstr "Ableger"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1031,12 +2250,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1064,6 +2277,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "Systemzustand"
@@ -1082,15 +2301,59 @@ msgstr "Keine Probleme erkannt"
msgid "HealthCheck|Unhealthy"
msgstr "Problematisch"
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr "Aufräumen erfolgreich gestartet"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "Repository importieren"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1100,6 +2363,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr "Installiere einen Runner der mit GitLab CI kompatibel ist"
@@ -1108,6 +2374,15 @@ msgid_plural "Instances"
msgstr[0] ""
msgstr[1] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1123,9 +2398,6 @@ msgstr "Arbeitsablaufsanalysen vorgestellt"
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr "Ticketereignisse"
@@ -1138,15 +2410,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "Deaktiviert"
msgid "LFSStatus|Enabled"
msgstr "Aktiviert"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "Letzten %d Tag"
@@ -1176,6 +2520,15 @@ msgstr "Du übertrugst an"
msgid "LastPushEvent|at"
msgstr "am"
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "Erfahre mehr in den"
@@ -1192,34 +2545,80 @@ msgid "Leave project"
msgstr "Verlasse das Projekt"
msgid "License"
-msgstr "Lizenz"
+msgstr ""
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] "Limitiere die Anzeige auf höchstens %d Ereignis"
-msgstr[1] "Limitiere die Anzeige auf höchstens %d Ereignisse"
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
-msgstr "Gesperrte Dateien"
+msgstr ""
+
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "Median"
msgid "Members"
msgstr "Mitglieder"
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1229,22 +2628,145 @@ msgstr "Ereignisse zusammenführen"
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr "Nachrichten"
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "einen SSH Schlüssel hinzufügst"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr "Ãœberwachung"
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr "hier"
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1252,15 +2774,27 @@ msgid_plural "New Issues"
msgstr[0] "Neues Ticket"
msgstr[1] "Neue Tickets"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "Neuer Pipeline Zeitplan"
msgid "New branch"
msgstr "Neuer Branch"
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "Neues Verzeichnis"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "Neue Datei"
@@ -1270,6 +2804,9 @@ msgstr ""
msgid "New issue"
msgstr "Neues Ticket"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "Neuer Merge Request"
@@ -1288,7 +2825,28 @@ msgstr ""
msgid "New tag"
msgstr "Neuer Tag"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1300,12 +2858,39 @@ msgstr "Keine Zeitpläne"
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "Nicht verfügbar"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "Nicht genügend Daten"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "Benachrichtigungsereignisse"
@@ -1363,18 +2948,48 @@ msgstr "Beobachten"
msgid "Notifications"
msgstr "Benachrichtigungen"
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr "Filter"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Ungelöst"
@@ -1384,12 +2999,21 @@ msgstr ""
msgid "Options"
msgstr "Optionen"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr "Ãœbersicht"
msgid "Owner"
msgstr "Besitzer"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1402,10 +3026,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr "Passwort"
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1450,12 +3083,6 @@ msgstr "Alle"
msgid "PipelineSchedules|Inactive"
msgstr "Inaktiv"
-msgid "PipelineSchedules|Input variable key"
-msgstr "Schlüssel der Eingangsvariable"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "Wert der Eingangsvariable"
-
msgid "PipelineSchedules|Next Run"
msgstr "Nächste Durchführung"
@@ -1465,9 +3092,6 @@ msgstr "Nichts"
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "Beschreibe diese Pipeline"
-msgid "PipelineSchedules|Remove variable row"
-msgstr "Entferne Variablenreihe"
-
msgid "PipelineSchedules|Take ownership"
msgstr "Eigentümer werden"
@@ -1495,6 +3119,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr "Pipelines des letzten Jahres"
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "Alle"
@@ -1507,15 +3182,39 @@ msgstr "mit Stage"
msgid "Pipeline|with stages"
msgstr "mit Stages"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr "Profil"
@@ -1555,6 +3254,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1570,6 +3275,12 @@ msgstr "Das Projekt '%{project_name}' wurde erfolgreich aktualisiert."
msgid "Project access must be granted explicitly to each user."
msgstr "Jedem Nutzer muss explizit der Zugriff auf das Projekt gewährt werden."
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr "Projektdetails"
@@ -1588,6 +3299,21 @@ msgstr "Export des Projektes gestartet. Ein Link zum herunterladen wir Dir per E
msgid "ProjectActivityRSS|Subscribe"
msgstr "Abonnieren"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "Dekativiert"
@@ -1637,7 +3363,7 @@ msgid "ProjectsDropdown|Loading projects"
msgstr ""
msgid "ProjectsDropdown|Projects you visit often will appear here"
-msgstr "ProjectsDropdown | Projekte, die Sie häufig besuchen, werden hier angezeigt"
+msgstr "Projekte, die du häufig besuchst, werden hier angezeigt"
msgid "ProjectsDropdown|Search your projects"
msgstr ""
@@ -1651,6 +3377,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1663,21 +3476,39 @@ msgstr ""
msgid "Push events"
msgstr "Ãœbertragungsereignisse"
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr "Mehr lesen"
msgid "Readme"
msgstr "Lies mich"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "Branches"
msgid "RefSwitcher|Tags"
msgstr "Tags"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1699,15 +3530,42 @@ msgstr "Zugehörige Merge Requests"
msgid "Related Merged Requests"
msgstr "Zugehörige umgesetzte Merge Requests"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "Später erinnern"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "Projekt entfernen"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "Anfrage auf Zugriff"
@@ -1720,68 +3578,172 @@ msgstr "Zugriffstoken für Systemzustand zurücksetzen"
msgid "Reset runners registration token"
msgstr "Registrierungstoken für Runner zurücksetzen"
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Revert this commit"
msgstr "Commit zurücksetzen"
msgid "Revert this merge request"
msgstr "Merge Request zurücksetzen"
-msgid "SSH Keys"
-msgstr "SSH-Schlüssel"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
-msgid "Save"
+msgid "Running"
msgstr ""
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
+msgstr "SSH-Schlüssel"
+
msgid "Save changes"
msgstr ""
msgid "Save pipeline schedule"
msgstr "Zeitplan der Pipeline speichern"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "Plane eine neue Pipeline"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "Pipelines planen"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "Suche nach Branches und Tags"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "Archivierungsformat auswählen"
msgid "Select a timezone"
msgstr "Zeitzone auswählen"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "Zielbranch auswählen"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr "Lege ein Passwort für dein Konto fest, um mittels %{protocol} zu übertragen (push) oder abzurufen (pull)."
-msgid "Set up CI"
-msgstr "CI einrichten"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "Koding einrichten"
-msgid "Set up auto deploy"
-msgstr "Automatische Bereitstellung einrichten"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "ein Passwort festlegst"
@@ -1789,6 +3751,24 @@ msgstr "ein Passwort festlegst"
msgid "Settings"
msgstr "Einstellungen"
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1800,13 +3780,46 @@ msgid_plural "Showing %d events"
msgstr[0] "Zeige %d Ereignis"
msgstr[1] "Zeige %d Ereignisse"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1815,6 +3828,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1914,18 +3930,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "Quellcode"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr "Spam-Protokolle"
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "Lege die folgende URL während des Runner Setups fest:"
msgid "StarProject|Star"
msgstr "Favorisieren"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1935,29 +3969,119 @@ msgstr "Beginne einen %{new_merge_request} mit diesen Änderungen"
msgid "Start the Runner!"
msgstr "Starte den Runner!"
-msgid "Subgroups"
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
msgstr ""
-msgid "Subscribe"
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr "Zu Branch/Tag wechseln"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
msgstr[0] ""
msgstr[1] ""
msgid "Tags"
msgstr ""
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "Zielbranch"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1967,7 +4091,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1976,30 +4106,39 @@ msgstr "Die Entwicklungsphase stellt die Zeit vom ersten Commit bis zum Erstelle
msgid "The collection of events added to the data gathered for that stage."
msgstr "Ereignisse, die für diese Phase ausgewertet wurden."
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "Die Beziehung des Ablegers wurde entfernt."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 "Die Ticketphase stellt die Zeit vom Anlegen eines Tickets bis zum Zuweisen eines Meilensteins oder Hinzufügen zur Aufgabentafel dar. Erstelle einen Ticket, damit dessen Daten hier erscheinen."
-msgid "The number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
msgid "The phase of the development lifecycle."
msgstr "Die Phase des Entwicklungslebenszyklus."
-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 "Die Pipelinezeitpläne starten Pipelines in der Zukunft, wiederholend, für bestimmte Branches oder Tags. Diese geplanten Pipelines haben denselben begrenzten Zugriff auf das Projekt, wie der zugeordnete Nutzer."
-
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 Übertragen des ersten Commits dar. Sobald Du den ersten Commit überträgst, werden dessen Daten hier erscheinen."
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "Die Produktionsphase stellt die Gesamtzeit vom Anlegen eines Tickets bis zur Bereitstellung des Codes auf dem Produktivsystem dar. Sobald Du den vollständigen Entwicklungszyklus, von einer Idee bis zur Fertigstellung, durchlaufen hast, erscheinen die zugehörigen Daten hier."
@@ -2012,9 +4151,18 @@ msgstr "Auf das Projekt kann ohne Authentifizierung zugegriffen werden."
msgid "The repository for this project does not exist."
msgstr "Das Repository für das Projekt existiert nicht."
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 "Die Überprüfungsphase stellt die Zeit vom Anlegen eines Merge Requests bis dessen Umsetzung dar. Sobald Du Deinen ersten Merge Request abschließt, werden dessen Daten hier automatisch angezeigt."
+msgid "The roadmap shows the progress of your epics along a timeline"
+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 "Die Staging-Phase stellt die Zeit zwischen der Umsetzung eines Merge Requests und der Bereitstellung des Codes auf dem Produktivsystem dar. Sobald Du das erste Mal auf das Produktivsystem ausgeliefert hast, werden dessen Daten hier automatisch angezeigt."
@@ -2027,16 +4175,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
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 "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr "Es gibt ein Problem beim Zugriff auf den Gitspeicher:"
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2045,18 +4226,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "Dies bedeutet, dass Du keinen Code übertragen kannst, bevor Du kein leeres Repositorium erstellt oder ein Existierendes importiert hast."
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "Zeit bis ein Ticket geplant wird"
@@ -2066,9 +4283,27 @@ msgstr "Zeit bis die Implementierung für ein Ticket beginnt"
msgid "Time between merge request creation and merge/close"
msgstr "Zeit zwischen einem Merge Request und dessen Umsetzung / Schließung"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "Zeit bis zum ersten Merge Request"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "vor %s Tagen"
@@ -2205,25 +4440,97 @@ msgstr[1] "Min."
msgid "Time|s"
msgstr "Sek."
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr "Gesamtzeit"
msgid "Total test time for all commits/merges"
msgstr "Gesamte Testzeit für alle Commits/Merges"
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "Entfavorisieren"
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2247,24 +4554,72 @@ msgstr "Eine Neue Datei hochladen"
msgid "Upload file"
msgstr "Eine Datei hochladen"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "Zum Upload klicken"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr "Benutze den folgenden Registrierungstoken während des Setups:"
msgid "Use your global notification setting"
msgstr "Benutze Deine globalen Benachrichtigungseinstellungen"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "Zeige offene Merge Requests."
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "Intern"
@@ -2280,16 +4635,28 @@ msgstr "Unbekannt"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "Du möchtest diese Daten sehen? Bitte frage einen Administrator nach dem Zugang."
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2310,6 +4677,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2400,39 +4773,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "Zugriffsanfrage widerrufen"
+msgid "Write a commit message..."
+msgstr ""
+
msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
msgstr "Du bist dabei %{group_name} zu entfernen. Entfernte Gruppen können NICHT wiederhergestellt werden! Bist Du dir WIRKLICH sicher?"
-msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
-msgstr "Du bist dabei %{project_name_with_namespace} zu entfernen. Entfernte Projekte können NICHT wiederhergestellt werden! Bist Du dir WIRKLICH sicher?"
+msgid "You are going to remove %{project_full_name}. 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 "Du bist dabei, die Beziehung des Ablegers zum Ursprungsprojekt %{forked_from_project}, zu entfernen. Bist Du dir WIRKLICH sicher?"
-msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
-msgstr "Du bist dabei %{project_name_with_namespace} einem andere Besitzer zu übergeben. Bist Du dir WIRKLICH sicher?"
+msgid "You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "Du kannst Dateien nur hinzufügen, wenn Du dich auf einem Branch befindest."
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "Du hast die Projektbegrenzung erreicht."
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "Du musst angemeldet sein, um ein Projekt zu favorisieren."
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "Du brauchst eine Genehmigung."
@@ -2457,6 +4860,36 @@ msgstr "Du kannst erst mittels '%{protocol}' übertragen (push) oder abrufen (pu
msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
msgstr "Du kannst erst mittels SSH übertragen (push) oder abrufen (pull), nachdem Du Deinem Konto '%{add_ssh_key_link}'."
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2469,20 +4902,352 @@ msgstr "Dein Name"
msgid "Your projects"
msgstr "Deine Projekte"
-msgid "commit"
-msgstr "Commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
msgid "day"
msgid_plural "days"
msgstr[0] "Tag"
msgstr[1] "Tage"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "Neuer Merge Request"
msgid "notification emails"
msgstr "Benachrichtungsemail"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "Vorgänger"
@@ -2494,9 +5259,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/eo/gitlab.po b/locale/eo/gitlab.po
index f7be343c4e1..009ad31d28b 100644
--- a/locale/eo/gitlab.po
+++ b/locale/eo/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-03 12:30-0400\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:38-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Esperanto\n"
"Language: eo_UY\n"
@@ -16,39 +16,76 @@ msgstr ""
"X-Crowdin-Language: eo\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d enmetado"
msgstr[1] "%d enmetadoj"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
msgstr[1] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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] "%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 "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} enmetis %{commit_timeago}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
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."
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
@@ -56,6 +93,9 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts
msgstr[0] ""
msgstr[1] ""
+msgid "%{text} is available"
+msgstr ""
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr ""
@@ -76,15 +116,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Aro da diagramoj pri la seninterrompa integrado"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "Pri la aÅ­tomata disponigado"
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -94,6 +149,9 @@ msgstr ""
msgid "Account"
msgstr ""
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "Aktiva"
@@ -112,73 +170,283 @@ msgstr "Aldoni gvidliniojn por kontribuado"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
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 Readme"
+msgstr ""
msgid "Add new directory"
msgstr "Aldoni novan dosierujon"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr ""
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr ""
msgid "Applications"
msgstr ""
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+msgstr ""
+
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 "Are you sure you want to discard your changes?"
-msgstr ""
-
-msgid "Are you sure you want to leave this group?"
-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 you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr ""
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Alkroĉu dosieron per Åovmetado aÅ­ %{upload_link}"
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
+msgstr ""
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -196,7 +464,31 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
@@ -253,10 +545,10 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Branĉo"
-msgstr[1] "Branĉoj"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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 "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}"
@@ -264,6 +556,12 @@ msgstr "La branĉo <strong>%{branch_name}</strong> estis kreita. Por agordi aŭt
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "Serĉu branĉon"
@@ -273,6 +571,15 @@ msgstr "Iri al branĉo"
msgid "Branches"
msgstr "Branĉoj"
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -318,12 +625,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -369,14 +703,23 @@ msgstr "Foliumi dosierojn"
msgid "Browse files"
msgstr "Elekti dosierojn"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "de"
msgid "CI / CD"
msgstr ""
-msgid "CI configuration"
-msgstr "Agordoj de seninterrompa integrado"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -384,12 +727,21 @@ msgstr ""
msgid "Cancel"
msgstr "Nuligi"
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "Elekti en branĉon"
@@ -402,22 +754,55 @@ msgstr "Precize elekti"
msgid "ChangeTypeAction|Revert"
msgstr "Malfari"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr "Listo de ÅanÄoj"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "Diagramoj"
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
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 "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -474,55 +859,145 @@ msgstr "transsaltita"
msgid "CiStatus|running"
msgstr "plenumiÄanta"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Environment scope"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -534,43 +1009,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|More information"
+msgstr ""
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -582,16 +1159,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -603,18 +1201,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr ""
@@ -623,8 +1236,8 @@ msgid_plural "Commits"
msgstr[0] "Enmetado"
msgstr[1] "Enmetadoj"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgstr[1] ""
@@ -637,6 +1250,12 @@ msgstr "DaÅ­ro de la enmetadoj por la lastaj 30 enmetadoj"
msgid "Commit message"
msgstr "MesaÄo pri la enmetado"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "Enmeti"
@@ -649,15 +1268,99 @@ msgstr "Enmetadoj"
msgid "Commits feed"
msgstr "Fluo de enmetadoj"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "Historio"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "Enmetita de"
msgid "Compare"
msgstr "Kompari"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -703,12 +1406,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "Gvidlinioj por kontribuado"
msgid "Contributors"
msgstr "Kontribuantoj"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -721,27 +1442,60 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr "Kopii la adreson en la kopibufron"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "Kopii la identigilon de la enmetado"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "Krei novan dosierujon"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
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 branch"
+msgstr ""
+
msgid "Create directory"
msgstr "Krei dosierujon"
-msgid "Create empty bare repository"
-msgstr "Krei malplenan deponejon"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr "Krei peton pri kunfando"
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -751,9 +1505,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr "Krei novan…"
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "Disbranĉigi"
@@ -763,24 +1523,36 @@ msgstr "Etikedo"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "kreos propran atingoĵetonon"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr "Horzono por Cron"
msgid "Cron syntax"
msgstr "La sintakso de Cron"
+msgid "Current node"
+msgstr ""
+
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 "Customize colors"
+msgstr ""
+
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"
@@ -808,6 +1580,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr "Difini propran Åablonon, uzante la sintakson de Cron"
@@ -831,10 +1612,19 @@ msgstr ""
msgid "Details"
msgstr ""
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr "Nomo de dosierujo"
-msgid "Discard changes"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
@@ -843,9 +1633,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "Ne montru denove"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "ElÅuti"
@@ -873,15 +1669,171 @@ msgstr "Normala dosiero kun diferencoj"
msgid "DownloadSource|Download"
msgstr "ElÅuti"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Redakti"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Redakti ĉenstablan planon %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
@@ -909,24 +1861,75 @@ 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 "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "Ne eblas ÅanÄi la posedanton"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "Ne eblas forigi la ĉenstablan planon"
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr "Dosieroj"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "Filtri per mesaÄo"
@@ -936,12 +1939,21 @@ msgstr "Trovi per dosierindiko"
msgid "Find file"
msgstr "Trovi dosieron"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "Unue"
msgid "FirstPushedBy|pushed by"
msgstr "alpuÅita de"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Disbranĉigo"
@@ -953,25 +1965,178 @@ msgstr "Disbranĉigita el"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
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 "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -980,12 +2145,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr ""
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr ""
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr "Al via disbranĉigo"
@@ -995,6 +2193,27 @@ msgstr "Disbranĉigo"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1031,12 +2250,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1064,6 +2277,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr ""
@@ -1082,15 +2301,59 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr "La refreÅigo komenciÄis sukcese"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "Enporti deponejon"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1100,6 +2363,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
@@ -1108,6 +2374,15 @@ msgid_plural "Instances"
msgstr[0] ""
msgstr[1] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1123,9 +2398,6 @@ msgstr "Ni prezentas al vi la ciklan analizon"
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr ""
@@ -1138,15 +2410,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "MalÅaltita"
msgid "LFSStatus|Enabled"
msgstr "Åœaltita"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "La lasta %d tago"
@@ -1176,6 +2520,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "Lernu pli en la"
@@ -1194,32 +2547,78 @@ msgstr "Forlasi la projekton"
msgid "License"
msgstr ""
-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 "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "Mediano"
msgid "Members"
msgstr ""
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1229,22 +2628,145 @@ msgstr ""
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "aldonos SSH-Ålosilon"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr ""
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1252,15 +2774,27 @@ msgid_plural "New Issues"
msgstr[0] "Nova problemo"
msgstr[1] "Novaj problemoj"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "Nova ĉenstabla plano"
msgid "New branch"
msgstr "Nova branĉo"
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "Nova dosierujo"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "Nova dosiero"
@@ -1270,6 +2804,9 @@ msgstr ""
msgid "New issue"
msgstr "Nova problemo"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "Nova peto pri kunfando"
@@ -1288,7 +2825,28 @@ msgstr ""
msgid "New tag"
msgstr "Nova etikedo"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1300,12 +2858,39 @@ msgstr "Ne estas planoj"
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "Ne disponebla"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "Ne estas sufiĉe da datenoj"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "Sciigaj eventoj"
@@ -1363,18 +2948,48 @@ msgstr "Rigardado"
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr "Filtrilo"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Malfermita"
@@ -1384,12 +2999,21 @@ msgstr ""
msgid "Options"
msgstr "Opcioj"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr "Posedanto"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1402,10 +3026,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1450,12 +3083,6 @@ 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"
@@ -1465,9 +3092,6 @@ 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"
@@ -1495,6 +3119,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "ĉiuj"
@@ -1507,15 +3182,39 @@ msgstr "kun etapo"
msgid "Pipeline|with stages"
msgstr "kun etapoj"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1555,6 +3254,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1570,6 +3275,12 @@ msgstr "La projekto „%{project_name}“ estis sukcese Äisdatigita."
msgid "Project access must be granted explicitly to each user."
msgstr "Ĉiu uzanto devas akiri propran atingon al la projekto."
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr ""
@@ -1588,6 +3299,21 @@ msgstr "La elporto de la projekto komenciÄis. Vi ricevos ligilon per retpoÅto
msgid "ProjectActivityRSS|Subscribe"
msgstr ""
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "MalÅaltita"
@@ -1651,6 +3377,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1663,21 +3476,39 @@ msgstr ""
msgid "Push events"
msgstr ""
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr "Legu pli"
msgid "Readme"
msgstr "LeguMin"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "Branĉoj"
msgid "RefSwitcher|Tags"
msgstr "Etikedoj"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1699,15 +3530,42 @@ msgstr "Rilataj petoj pri kunfando"
msgid "Related Merged Requests"
msgstr "Rilataj aplikitaj petoj pri kunfando"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "Rememorigu denove"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "Forigi la projekton"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "Peti atingeblon"
@@ -1720,16 +3578,54 @@ msgstr ""
msgid "Reset runners registration token"
msgstr ""
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Revert this commit"
msgstr "Malfari ĉi tiun enmetadon"
msgid "Revert this merge request"
msgstr "Malfari ĉi tiun peton pri kunfando"
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
msgstr ""
-msgid "Save"
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1738,50 +3634,116 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr "Konservi ĉenstablan planon"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "Plani novan ĉenstablon"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "Planado de la ĉenstabloj"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "Serĉu branĉon aŭ etikedon"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "Elektu formaton de arkivo"
msgid "Select a timezone"
msgstr "Elektu horzonon"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "Elektu celan branĉon"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
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 default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "Agordi „Koding“"
-msgid "Set up auto deploy"
-msgstr "Agordi aÅ­tomatan disponigadon"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "kreos pasvorton"
@@ -1789,6 +3751,24 @@ msgstr "kreos pasvorton"
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1800,13 +3780,46 @@ msgid_plural "Showing %d events"
msgstr[0] "Estas montrata %d evento"
msgstr[1] "Estas montrataj %d eventoj"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1815,6 +3828,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1914,18 +3930,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "Kodo"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr "Steligi"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1935,29 +3969,119 @@ msgstr "Kreu %{new_merge_request} kun ĉi tiuj ÅanÄoj"
msgid "Start the Runner!"
msgstr ""
-msgid "Subgroups"
+msgid "Started"
msgstr ""
-msgid "Subscribe"
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr "Iri al branĉo/etikedo"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "Etikedo"
-msgstr[1] "Etikedoj"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
+msgstr[1] ""
msgid "Tags"
msgstr "Etikedoj"
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "Cela branĉo"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1967,7 +4091,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1976,30 +4106,39 @@ msgstr "La etapo de programado montras la tempon de la unua enmetado Äis la kre
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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "La rilato de disbranĉigo estis forigita."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 "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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "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."
@@ -2012,9 +4151,18 @@ 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 repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 "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 roadmap shows the progress of your epics along a timeline"
+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 "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."
@@ -2027,16 +4175,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
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 "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2045,18 +4226,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
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 "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "Tempo antaÅ­ problemo estas planita por ellabori"
@@ -2066,9 +4283,27 @@ 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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "Tempo Äis la unua peto pri kunfando"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "antaÅ­ %s tagoj"
@@ -2205,25 +4440,97 @@ msgstr[1] "min"
msgid "Time|s"
msgstr "s"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
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 "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "Malsteligi"
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2247,24 +4554,72 @@ msgstr "AlÅuti novan dosieron"
msgid "Upload file"
msgstr "AlÅuti dosieron"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "alklaku por alÅuti"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr "Uzi vian Äeneralan agordon pri la sciigoj"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "Vidi la malfermitan peton pri kunfando"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "Interna"
@@ -2280,16 +4635,28 @@ 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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "Ne estas sufiĉe da datenoj por montri ĉi tiun etapon."
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2310,6 +4677,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2400,39 +4773,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "Nuligi la peton pri atingeblo"
+msgid "Write a commit message..."
+msgstr ""
+
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 %{project_full_name}. 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 "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 estas transigonta „%{project_name_with_namespace}“ al alia posedanto. Ĉu vi estas ABSOLUTE certa?"
+msgid "You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
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 can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "Vi ne povas krei pliajn projektojn"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "Oni devas ensaluti por steligi projekton"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "VI bezonas permeson."
@@ -2457,6 +4860,36 @@ msgstr "Vi ne povos eltiri aÅ­ alpuÅi kodon per %{protocol} antaÅ­ ol vi %{set_
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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2469,7 +4902,108 @@ msgstr "Via nomo"
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
@@ -2477,12 +5011,243 @@ msgid_plural "days"
msgstr[0] "tago"
msgstr[1] "tagoj"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "novan peton pri kunfando"
msgid "notification emails"
msgstr "sciigoj per retpoÅto"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "patro"
@@ -2494,9 +5259,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po
index c35a3503019..e2af97f1b83 100644
--- a/locale/es/gitlab.po
+++ b/locale/es/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-03 12:31-0400\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:35-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -16,13 +16,41 @@ msgstr ""
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d cambio"
msgstr[1] "%d cambios"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d layer"
msgid_plural "%d layers"
+msgstr[0] "%d capa"
+msgstr[1] "%d capas"
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
msgstr[0] ""
msgstr[1] ""
@@ -31,67 +59,97 @@ msgid_plural "%s additional commits have been omitted to prevent performance iss
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 "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "%{count} participante"
+msgstr[1] "%{count} participantes"
-msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
+msgid "%{loadingIcon} Started"
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr ""
+msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
+msgstr "%{number_commits_behind} commits detrás de %{default_branch}, %{number_commits_ahead} commits por delante"
+
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
+msgstr "%{number_of_failures} de %{maximum_failures} intentos fallidos. GitLab permitirá el acceso en el siguiente intento."
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgstr "%{number_of_failures} de %{maximum_failures} intentos fallidos. GitLab no reintentará automáticamente. Debe reinicializar la información de almacenamiento cuando el problema sea solventado."
+
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "%{storage_name}: intento de acceso fallido al almacenamiento en host:"
+msgstr[1] "%{storage_name}: %{failed_attempts} intentos de acceso fallido al almacenamiento:"
+
+msgid "%{text} is available"
+msgstr "%{text} esta disponible"
msgid "(checkout the %{link} for information on how to install it)."
-msgstr ""
+msgstr "(para obtener información sobre cómo instalarlo visite %{link})."
msgid "+ %{moreCount} more"
-msgstr ""
+msgstr "+ %{moreCount} más"
msgid "- show less"
-msgstr ""
+msgstr "- mostrar menos"
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipelines"
msgid "1st contribution!"
-msgstr ""
+msgstr "¡1ra contribución!"
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Una colección de gráficos sobre Integración Continua"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "Acerca del auto despliegue"
msgid "Abuse Reports"
+msgstr "Reportes de abuso"
+
+msgid "Abuse reports"
msgstr ""
msgid "Access Tokens"
-msgstr ""
+msgstr "Tokens de acceso"
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 "Account"
+msgstr "Cuenta"
+
+msgid "Account and limit settings"
msgstr ""
msgid "Active"
@@ -101,7 +159,7 @@ msgid "Activity"
msgstr "Actividad"
msgid "Add"
-msgstr ""
+msgstr "Añadir"
msgid "Add Changelog"
msgstr "Agregar Changelog"
@@ -110,159 +168,399 @@ msgid "Add Contribution guide"
msgstr "Agregar guía de contribución"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
+msgstr "Añadir Webhooks Grupales y Gitlab Enterprise Edition."
+
+msgid "Add Kubernetes cluster"
msgstr ""
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 Readme"
+msgstr ""
msgid "Add new directory"
msgstr "Agregar nuevo directorio"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr "Detener todos los trabajos"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "Detener todos los trabajos?"
+
+msgid "AdminArea|Stop jobs"
+msgstr "Detener trabajos"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr "Error al detener trabajos"
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
+msgstr "Página de estado"
+
+msgid "AdminProjects|Delete"
msgstr ""
-msgid "Advanced settings"
+msgid "AdminProjects|Delete Project %{projectName}?"
msgstr ""
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr "Avanzado"
+
+msgid "Advanced settings"
+msgstr "Configuración avanzada"
+
msgid "All"
+msgstr "Todos"
+
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr "Ha ocurrido un error visualizando el blob"
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "Se produjo un error al activar/desactivar la suscripción de notificación"
+
+msgid "An error occurred when updating the issue weight"
+msgstr "Se produjo un error al actualizar el peso de la incidencia"
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr "Se produjo un error al obtener datos de la barra lateral"
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
msgstr ""
msgid "An error occurred. Please try again."
+msgstr "Se produjo un error. Por favor inténtelo de nuevo."
+
+msgid "Any Label"
msgstr ""
msgid "Appearance"
-msgstr ""
+msgstr "Apariencia"
msgid "Applications"
+msgstr "Aplicaciones"
+
+msgid "Apr"
msgstr ""
+msgid "April"
+msgstr "Abril"
+
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 "Are you sure you want to discard your changes?"
+msgid "Are you sure you want to reset registration token?"
+msgstr "¿Está seguro que desea reinicializar el token de registro?"
+
+msgid "Are you sure you want to reset the health check token?"
+msgstr "¿Está seguro que desea reinicializar el token de Verificación de Estado?"
+
+msgid "Are you sure you want to unlock %{path_lock_path}?"
msgstr ""
-msgid "Are you sure you want to leave this group?"
+msgid "Are you sure?"
+msgstr "¿Estás seguro?"
+
+msgid "Artifacts"
+msgstr "Artefactos"
+
+msgid "Assertion consumer service URL"
msgstr ""
-msgid "Are you sure you want to reset registration token?"
+msgid "Assign custom color like #FF0000"
msgstr ""
-msgid "Are you sure you want to reset the health check token?"
+msgid "Assign labels"
+msgstr "Asignar etiquetas"
+
+msgid "Assign milestone"
+msgstr "Asignar milestone"
+
+msgid "Assign to"
+msgstr "Asignar a"
+
+msgid "Assigned Issues"
msgstr ""
-msgid "Are you sure?"
+msgid "Assigned Merge Requests"
msgstr ""
-msgid "Artifacts"
+msgid "Assigned to :name"
msgstr ""
+msgid "Assignee"
+msgstr "Asignado a"
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Adjunte un archivo arrastrando &amp; soltando o %{upload_link}"
+msgid "Aug"
+msgstr "Ago"
+
+msgid "August"
+msgstr "Agosto"
+
msgid "Authentication Log"
-msgstr ""
+msgstr "Registro de Autenticación"
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
+msgstr "Autores: %{authors}"
+
+msgid "Auto DevOps enabled"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps, runners and job artifacts"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
msgstr ""
-msgid "AutoDevOps|Auto DevOps (Beta)"
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
msgstr ""
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgstr "Tanto las Auto Review Apps como Auto Deploy necesitan un dominio para funcionar correctamente."
+
+msgid "AutoDevOps|Auto DevOps (Beta)"
+msgstr "Auto DevOps (Beta)"
+
msgid "AutoDevOps|Auto DevOps documentation"
-msgstr ""
+msgstr "Documentación de Auto DevOps"
msgid "AutoDevOps|Enable in settings"
-msgstr ""
+msgstr "Habilitar en la configuración"
msgid "AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration."
-msgstr ""
+msgstr "Automáticamente construirán, probarán y desplegarán su aplicación con base en la configuración predefinida de CI/CD."
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
+msgstr "Más información en %{link_to_documentation}"
+
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|add a Kubernetes cluster"
msgstr ""
-msgid "Billing"
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
msgstr ""
-msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
+msgid "Available"
+msgstr "Disponible"
+
+msgid "Avatar will be removed. Are you sure?"
msgstr ""
-msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
+msgid "Average per day: %{average}"
+msgstr "Promedio por día: %{average}"
+
+msgid "Background Color"
msgstr ""
-msgid "BillingPlans|Current plan"
+msgid "Background jobs"
msgstr ""
+msgid "Begin with the selected commit"
+msgstr "Iniciar con el commit seleccionado"
+
+msgid "Billing"
+msgstr "Facturación"
+
+msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
+msgstr "%{group_name} está actualmente en el plan %{plan_link}."
+
+msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
+msgstr "Aumentar o disminuir automáticamente las características de algunos Planes no está disponible actualmente."
+
+msgid "BillingPlans|Current plan"
+msgstr "Plan actual"
+
msgid "BillingPlans|Customer Support"
-msgstr ""
+msgstr "Atención al cliente"
msgid "BillingPlans|Downgrade"
msgstr ""
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
-msgstr ""
+msgstr "Obtenga más información sobre cada plan al leer nuestro %{faq_link}."
msgid "BillingPlans|Manage plan"
-msgstr ""
+msgstr "Gestionar plan"
msgid "BillingPlans|Please contact %{customer_support_link} in that case."
-msgstr ""
+msgstr "Por favor contacte a %{customer_support_link} en ese caso."
msgid "BillingPlans|See all %{plan_name} features"
-msgstr ""
+msgstr "Ver todas la funcionalidades del plan %{plan_name}"
msgid "BillingPlans|This group uses the plan associated with its parent group."
msgstr ""
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
-msgstr ""
+msgstr "Para gestionar el plan para este grupo, visite la sección de facturación de %{parent_billing_page_link}."
msgid "BillingPlans|Upgrade"
msgstr ""
msgid "BillingPlans|You are currently on the %{plan_link} plan."
-msgstr ""
+msgstr "Actualmente estás en el plan %{plan_link}."
msgid "BillingPlans|frequently asked questions"
-msgstr ""
+msgstr "preguntas frecuentes"
msgid "BillingPlans|monthly"
-msgstr ""
+msgstr "mensual"
msgid "BillingPlans|paid annually at %{price_per_year}"
-msgstr ""
+msgstr "%{price_per_year} pagados anualmente"
msgid "BillingPlans|per user"
-msgstr ""
+msgstr "por usuario"
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Rama"
-msgstr[1] "Ramas"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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 "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 "Branch has changed"
-msgstr ""
+msgstr "La rama ha cambiado"
+
+msgid "Branch is already taken"
+msgstr "La rama ya existe"
+
+msgid "Branch name"
+msgstr "Nombre de la rama"
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "Buscar ramas"
@@ -273,35 +571,44 @@ msgstr "Cambiar rama"
msgid "Branches"
msgstr "Ramas"
-msgid "Branches|Cant find HEAD commit for this branch"
+msgid "Branches|Active"
msgstr ""
-msgid "Branches|Compare"
+msgid "Branches|Active branches"
msgstr ""
-msgid "Branches|Delete all branches that are merged into '%{default_branch}'"
+msgid "Branches|All"
msgstr ""
+msgid "Branches|Cant find HEAD commit for this branch"
+msgstr "No puedo encontrar el commit HEAD para esta rama"
+
+msgid "Branches|Compare"
+msgstr "Comparar"
+
+msgid "Branches|Delete all branches that are merged into '%{default_branch}'"
+msgstr "Borrar todas las ramas que se fusionen con '%{default_branch}'"
+
msgid "Branches|Delete branch"
-msgstr ""
+msgstr "Eliminar la rama"
msgid "Branches|Delete merged branches"
-msgstr ""
+msgstr "Borrar ramas fusionadas"
msgid "Branches|Delete protected branch"
-msgstr ""
+msgstr "Borrar rama protegida"
msgid "Branches|Delete protected branch '%{branch_name}'?"
-msgstr ""
+msgstr "¿Borrar rama protegida '%{branch_name}'?"
msgid "Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?"
-msgstr ""
+msgstr "El borrado de la rama '%{branch_name}' no podrá deshacerse. ¿Está seguro?"
msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
-msgstr ""
+msgstr "El borrado de las ramas fusionadas no podrá deshacerse. ¿Está usted seguro?"
msgid "Branches|Filter by branch name"
-msgstr ""
+msgstr "Filtrar por nombre de rama"
msgid "Branches|Merged into %{default_branch}"
msgstr ""
@@ -318,12 +625,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -369,14 +703,23 @@ msgstr "Examinar archivos"
msgid "Browse files"
msgstr "Examinar archivos"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "por"
msgid "CI / CD"
+msgstr "CI / CD"
+
+msgid "CI/CD"
msgstr ""
-msgid "CI configuration"
-msgstr "Configuración de CI"
+msgid "CI/CD configuration"
+msgstr "Configuración de CI/CD"
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -384,10 +727,19 @@ msgstr ""
msgid "Cancel"
msgstr "Cancelar"
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
msgstr ""
msgid "Change Weight"
+msgstr "Cambiar peso"
+
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
msgstr ""
msgid "ChangeTypeActionLabel|Pick into branch"
@@ -402,24 +754,57 @@ msgstr "Cherry-pick"
msgid "ChangeTypeAction|Revert"
msgstr "Revertir"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr ""
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "Gráficos"
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr "Comprobando disponibilidad de %{text}..."
+
+msgid "Checking branch availability..."
+msgstr "Verificando disponibilidad de la rama..."
+
msgid "Cherry-pick this commit"
msgstr "Escoger este cambio"
msgid "Cherry-pick this merge request"
msgstr "Escoger esta solicitud de fusión"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr "Elegir archivo..."
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr "Elija qué grupos desea sincronizar a este nodo secundario."
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
msgstr ""
+msgid "Choose which shards you wish to synchronize to this secondary node."
+msgstr "Elija qué fragmentos desea sincronizar a este nodo secundario."
+
msgid "CiStatusLabel|canceled"
msgstr "cancelado"
@@ -474,162 +859,390 @@ msgstr "omitido"
msgid "CiStatus|running"
msgstr "en ejecución"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr "Haga clic para expandir el texto"
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
+msgstr "Cerrar"
+
+msgid "Closed"
+msgstr "Cerrado"
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
-msgid "Cluster"
+msgid "ClusterIntegration|API URL"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Add Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Applications"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr "Copiar Certificado CA"
+
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Google Cloud Platform project ID"
+msgid "ClusterIntegration|Copy Token"
+msgstr "Copiar Token"
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr "Crear cluster de Kubernetes"
+
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Google Kubernetes Engine"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr "Crear en GKE"
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr "Ingrese los detalles para un cluster Kubernetes existente"
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
msgstr ""
+msgid "ClusterIntegration|GitLab Integration"
+msgstr "Integración GitLab"
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr "GitLab Runner"
+
+msgid "ClusterIntegration|Google Cloud Platform project ID"
+msgstr "ID del proyecto de Google Cloud Platform"
+
+msgid "ClusterIntegration|Google Kubernetes Engine"
+msgstr "Google Kubernetes Engine"
+
msgid "ClusterIntegration|Google Kubernetes Engine project"
+msgstr "Proyecto Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Helm Tiller"
+msgstr "Helm Tiller"
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr "Instalar"
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr "Instalado"
+
+msgid "ClusterIntegration|Installing"
+msgstr "Instalando"
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr "Estado de integración"
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr "cluster de Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr "Detalles del cluster de Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr "Integración de cluster de Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr "Nombre de cluster de Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
msgstr ""
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
+msgstr "Conozca más sobre %{link_to_documentation}"
+
+msgid "ClusterIntegration|Learn more about environments"
+msgstr "Conozca más sobre los entornos"
+
+msgid "ClusterIntegration|Learn more about security configuration"
msgstr ""
msgid "ClusterIntegration|Machine type"
-msgstr ""
+msgstr "Tipo de máquina"
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Manage"
+msgstr "Administrar"
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr "Administre su cluster de Kubernetes visitando %{link_gke}"
+
+msgid "ClusterIntegration|More information"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
msgstr ""
+msgid "ClusterIntegration|Note:"
+msgstr "Nota:"
+
msgid "ClusterIntegration|Number of nodes"
+msgstr "Número de nodos"
+
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
msgstr ""
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgstr "Asegúrese de que su cuenta de Google cumpla con los siguientes requisitos:"
+
+msgid "ClusterIntegration|Project ID"
+msgstr "ID de Proyecto"
+
+msgid "ClusterIntegration|Project namespace"
msgstr ""
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
+msgstr "Eliminar integración"
+
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr "Falló la solicitud para iniciar la instalación"
+
+msgid "ClusterIntegration|Save changes"
+msgstr "Guardar cambios"
+
+msgid "ClusterIntegration|Security"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
-msgstr ""
+msgstr "Ver tipos de máquina"
msgid "ClusterIntegration|See your projects"
-msgstr ""
+msgstr "Ver tus proyectos"
msgid "ClusterIntegration|See zones"
+msgstr "Ver zonas"
+
+msgid "ClusterIntegration|Service token"
msgstr ""
+msgid "ClusterIntegration|Show"
+msgstr "Mostrar"
+
msgid "ClusterIntegration|Something went wrong on our end."
+msgstr "Algo salió mal de nuestro lado."
+
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr "Algo salió mal durante la instalación de %{title}"
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
msgstr ""
-msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Zone"
+msgid "ClusterIntegration|Token"
+msgstr "Token"
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
+msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
+msgstr "Su cuenta debe tener %{link_to_kubernetes_engine}"
+
+msgid "ClusterIntegration|Zone"
+msgstr "Zona"
+
msgid "ClusterIntegration|access to Google Kubernetes Engine"
-msgstr ""
+msgstr "acceso a Google Kubernetes Engine"
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
msgstr ""
+msgid "ClusterIntegration|documentation"
+msgstr "documentación"
+
msgid "ClusterIntegration|help page"
-msgstr ""
+msgstr "página de ayuda"
+
+msgid "ClusterIntegration|installing applications"
+msgstr "Instalando aplicaciones"
msgid "ClusterIntegration|meets the requirements"
-msgstr ""
+msgstr "cumple con los requisitos"
msgid "ClusterIntegration|properly configured"
msgstr ""
-msgid "Comments"
+msgid "Collapse"
+msgstr "Contraer"
+
+msgid "Comment and resolve discussion"
msgstr ""
+msgid "Comment and unresolve discussion"
+msgstr ""
+
+msgid "Comments"
+msgstr "Comentarios"
+
msgid "Commit"
msgid_plural "Commits"
msgstr[0] "Cambio"
msgstr[1] "Cambios"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgstr[1] ""
msgid "Commit Message"
-msgstr ""
+msgstr "Mensaje del commit"
msgid "Commit duration in minutes for last 30 commits"
msgstr "Duración de los cambios en minutos para los últimos 30"
@@ -637,6 +1250,12 @@ msgstr "Duración de los cambios en minutos para los últimos 30"
msgid "Commit message"
msgstr "Mensaje del cambio"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "Cambio"
@@ -649,66 +1268,168 @@ msgstr "Cambios"
msgid "Commits feed"
msgstr "Feed de cambios"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "Historial"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "Enviado por"
msgid "Compare"
msgstr "Comparar"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr "Confidencialidad"
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
msgid "ContainerRegistry|Created"
-msgstr ""
+msgstr "Creado"
msgid "ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:"
msgstr ""
msgid "ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:"
-msgstr ""
+msgstr "Gitlab soporta hasta 3 niveles para nombres de imágenes. Los siguientes ejemplos de imágenes son válidos para tu proyecto:"
msgid "ContainerRegistry|How to use the Container Registry"
msgstr ""
msgid "ContainerRegistry|Learn more about"
-msgstr ""
+msgstr "Conozca más sobre"
msgid "ContainerRegistry|No tags in Container Registry for this container image."
-msgstr ""
+msgstr "No hay etiquetas en el Container Registry para esta imagen de contenedor."
msgid "ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands"
-msgstr ""
+msgstr "Una vez que inicies sesión, eres libre de crear y cargar una imagen de contenedor utilizando los comandos comunes %{build} y %{push}"
msgid "ContainerRegistry|Remove repository"
-msgstr ""
+msgstr "Borrar repositorio"
msgid "ContainerRegistry|Remove tag"
-msgstr ""
+msgstr "Borrar etiqueta"
msgid "ContainerRegistry|Size"
-msgstr ""
+msgstr "Tamaño"
msgid "ContainerRegistry|Tag"
-msgstr ""
+msgstr "Etiqueta"
msgid "ContainerRegistry|Tag ID"
-msgstr ""
+msgstr "Etiqueta ID"
msgid "ContainerRegistry|Use different image names"
-msgstr ""
+msgstr "Usar nombres de imagen diferentes"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "Guía de contribución"
msgid "Contributors"
msgstr "Contribuidores"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -716,44 +1437,83 @@ msgid "Control the maximum concurrency of repository backfill for this secondary
msgstr ""
msgid "Copy SSH public key to clipboard"
-msgstr ""
+msgstr "Copiar la clave pública SSH al portapapeles"
msgid "Copy URL to clipboard"
msgstr "Copiar URL al portapapeles"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "Copiar SHA del cambio al portapapeles"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr "Crear"
+
msgid "Create New Directory"
msgstr "Crear Nuevo Directorio"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
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 branch"
+msgstr ""
+
msgid "Create directory"
msgstr "Crear directorio"
-msgid "Create empty bare repository"
-msgstr "Crear repositorio vacío"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr "Crear epic"
msgid "Create file"
+msgstr "Crear archivo"
+
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
msgstr ""
msgid "Create merge request"
msgstr "Crear solicitud de fusión"
-msgid "Create new branch"
+msgid "Create merge request and branch"
msgstr ""
+msgid "Create new branch"
+msgstr "Crear una nueva rama"
+
msgid "Create new directory"
-msgstr ""
+msgstr "Crear nuevo directorio"
msgid "Create new file"
-msgstr ""
+msgstr "Crear nuevo archivo"
+
+msgid "Create new label"
+msgstr "Crear nueva etiqueta"
msgid "Create new..."
msgstr "Crear nuevo..."
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "Bifurcar"
@@ -763,23 +1523,35 @@ msgstr "Etiqueta"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "crear un token de acceso personal"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr "Creando epic"
+
msgid "Cron Timezone"
msgstr "Zona horaria del Cron"
msgid "Cron syntax"
msgstr "Sintaxis de Cron"
+msgid "Current node"
+msgstr "Nodo actual"
+
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"
+msgid "Customize colors"
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 ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."
+msgid "Cycle Analytics"
+msgstr ""
msgid "CycleAnalyticsStage|Code"
msgstr "Código"
@@ -803,9 +1575,18 @@ msgid "CycleAnalyticsStage|Test"
msgstr "Pruebas"
msgid "DashboardProjects|All"
-msgstr ""
+msgstr "Todos"
msgid "DashboardProjects|Personal"
+msgstr "Personales"
+
+msgid "Dec"
+msgstr "Dic"
+
+msgid "December"
+msgstr "Diciembre"
+
+msgid "Default classification label"
msgstr ""
msgid "Define a custom pattern with cron syntax"
@@ -829,12 +1610,21 @@ msgid "Description templates allow you to define context-specific templates for
msgstr ""
msgid "Details"
+msgstr "Detalles"
+
+msgid "Diffs|No file name available"
msgstr ""
msgid "Directory name"
msgstr "Nombre del directorio"
-msgid "Discard changes"
+msgid "Disable"
+msgstr "Deshabilitar"
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
@@ -843,9 +1633,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "No mostrar de nuevo"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "Descargar"
@@ -873,20 +1669,176 @@ msgstr "Diferencias en texto plano"
msgid "DownloadSource|Download"
msgstr "Descargar"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Editar"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Editar Programación del Pipeline %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr "Habilitar"
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr "Actualizado"
+
+msgid "Environments|You don't have any environments right now."
+msgstr "No tiene ningún entorno en este momento."
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr "Epics"
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr "Error creando epic"
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr "Se produjo un error al activar/desactivar la suscripción de notificación"
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
msgid "EventFilterBy|Filter by comments"
-msgstr ""
+msgstr "Filtrar por comentarios"
msgid "EventFilterBy|Filter by issue events"
msgstr ""
@@ -898,7 +1850,7 @@ msgid "EventFilterBy|Filter by push events"
msgstr ""
msgid "EventFilterBy|Filter by team"
-msgstr ""
+msgstr "Filtrar por equipo"
msgid "Every day (at 4:00am)"
msgstr "Todos los días (a las 4:00 am)"
@@ -909,24 +1861,75 @@ 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 "Expand"
+msgstr "Expandir"
+
msgid "Explore projects"
-msgstr ""
+msgstr "Explorar proyectos"
msgid "Explore public groups"
+msgstr "Explorar grupos públicos"
+
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
msgstr ""
msgid "Failed to change the owner"
msgstr "Error al cambiar el propietario"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "Error al eliminar la programación del pipeline"
-msgid "File name"
+msgid "Failed to update issues, please try again."
msgstr ""
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr "Febrero"
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
+msgid "File name"
+msgstr "Nombre de archivo"
+
msgid "Files"
msgstr "Archivos"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "Filtrar por mensaje del cambio"
@@ -936,12 +1939,21 @@ msgstr "Buscar por ruta"
msgid "Find file"
msgstr "Buscar archivo"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "Primer"
msgid "FirstPushedBy|pushed by"
msgstr "enviado por"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Bifurcación"
@@ -953,7 +1965,13 @@ msgstr "Bifurcado de"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
+msgstr "Formato"
+
+msgid "From %{provider_title}"
msgstr ""
msgid "From issue creation until deploy to production"
@@ -962,28 +1980,208 @@ 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 "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
+msgstr "Llaves GPG"
+
+msgid "Generate a default set of labels"
msgstr ""
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr "El nodo está fallando o dañado."
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr "El nodo está lento, sobrecargado o se esta recuperando de una interrupción."
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
+msgstr "Capacidad de sincronización de archivos"
+
+msgid "Geo|Groups to synchronize"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Projects in certain groups"
msgstr ""
-msgid "Geo|Repository sync capacity"
+msgid "Geo|Projects in certain storage shards"
msgstr ""
+msgid "Geo|Repository sync capacity"
+msgstr "Capacidad de sincronización del Repositorio"
+
msgid "Geo|Select groups to replicate."
+msgstr "Seleccionar grupos a replicar."
+
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
msgstr ""
msgid "Git storage health information has been reset"
msgstr ""
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
+msgstr "Sección GitLab Runner"
+
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
msgstr ""
msgid "Go to your fork"
@@ -993,11 +2191,32 @@ msgid "GoToYourFork|Fork"
msgstr "Bifurcación"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
+msgstr "La autenticación de Google no se encuentra %{link_to_documentation}. Pregúntale a tu administrador de GitLab si quieres usar este servicio."
+
+msgid "Got it!"
msgstr ""
-msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
msgstr ""
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
+msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
+msgstr "Prevenir que se comparta un proyecto de %{group} con otros grupos"
+
msgid "GroupSettings|Share with group lock"
msgstr ""
@@ -1026,42 +2245,42 @@ msgid "GroupsEmptyState|If you organize your projects under a group, it works li
msgstr ""
msgid "GroupsEmptyState|No groups found"
-msgstr ""
+msgstr "No se encuentran grupos"
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
-msgstr ""
+msgstr "Crear un proyecto en este grupo."
msgid "GroupsTree|Create a subgroup in this group."
-msgstr ""
+msgstr "Crear un sub-grupo en este grupo."
msgid "GroupsTree|Edit group"
-msgstr ""
+msgstr "Editar grupo"
msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
-msgstr ""
+msgstr "No pudiste dejar el grupo. Por favor, asegúrate que no seas el único propietario."
msgid "GroupsTree|Filter by name..."
-msgstr ""
+msgstr "Filtrar por nombre..."
msgid "GroupsTree|Leave this group"
-msgstr ""
+msgstr "Dejar este grupo"
msgid "GroupsTree|Loading groups"
-msgstr ""
+msgstr "Cargando grupos"
msgid "GroupsTree|Sorry, no groups matched your search"
-msgstr ""
+msgstr "Lo sentimos, no existen grupos que coincidan con su búsqueda"
msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgstr "Lo sentimos, no existen grupos ni proyectos que coincidan con su búsqueda"
+
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
msgstr ""
msgid "Health Check"
@@ -1071,49 +2290,105 @@ msgid "Health information can be retrieved from the following endpoints. More in
msgstr ""
msgid "HealthCheck|Access token is"
-msgstr ""
+msgstr "Token de Acceso es"
msgid "HealthCheck|Healthy"
-msgstr ""
+msgstr "Saludable"
msgid "HealthCheck|No Health Problems Detected"
msgstr ""
msgid "HealthCheck|Unhealthy"
+msgstr "Poco Saludable"
+
+msgid "Help"
msgstr ""
-msgid "History"
+msgid "Help page"
msgstr ""
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "History"
+msgstr "Historial"
+
msgid "Housekeeping successfully started"
msgstr "Servicio de limpieza iniciado con éxito"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "Importar repositorio"
-msgid "Improve Issue boards with GitLab Enterprise Edition."
+msgid "ImportButtons|Connect repositories from"
msgstr ""
+msgid "Improve Issue boards with GitLab Enterprise Edition."
+msgstr "Mejore los tableros de Incidencias con GitLab Enterprise Edition."
+
msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
-msgstr ""
+msgstr "Mejore la gestión de incidencias con Peso de Incidencias y GitLab Enterprise Edition."
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
-msgid "Install a Runner compatible with GitLab CI"
+msgid "Install Runner on Kubernetes"
msgstr ""
+msgid "Install a Runner compatible with GitLab CI"
+msgstr "Instala un Runner compatible con GitLab CI"
+
msgid "Instance"
msgid_plural "Instances"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "Instancia"
+msgstr[1] "Instancias"
-msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgid "Instance does not support multiple Kubernetes clusters"
msgstr ""
-msgid "Internal - The project can be accessed by any logged in user."
+msgid "Integrations"
msgstr ""
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
+msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgstr "Interno - cualquier usuario que haya iniciado sesión puede ver el grupo y cualquier proyecto interno."
+
+msgid "Internal - The project can be accessed by any logged in user."
+msgstr "Interno - cualquier usuario haya iniciado sesión puede acceder a este proyecto."
+
msgid "Interval Pattern"
msgstr "Patrón de intervalo"
@@ -1121,21 +2396,69 @@ msgid "Introducing Cycle Analytics"
msgstr "Introducción a Cycle Analytics"
msgid "Issue board focus mode"
+msgstr "Modo enfocado del Tablero de Incidencias"
+
+msgid "Issue events"
+msgstr "Eventos de incidencia"
+
+msgid "IssueBoards|Board"
+msgstr "Tablero"
+
+msgid "IssueBoards|Boards"
+msgstr "Tableros"
+
+msgid "Issues"
msgstr ""
-msgid "Issue boards with milestones"
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
msgstr ""
-msgid "Issue events"
+msgid "Jan"
msgstr ""
-msgid "IssueBoards|Board"
+msgid "January"
+msgstr "Enero"
+
+msgid "Jobs"
msgstr ""
-msgid "IssueBoards|Boards"
+msgid "Jul"
msgstr ""
-msgid "Issues"
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr "Junio"
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
msgstr ""
msgid "LFSStatus|Disabled"
@@ -1144,9 +2467,30 @@ msgstr "Deshabilitado"
msgid "LFSStatus|Enabled"
msgstr "Habilitado"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "Último %d día"
@@ -1162,13 +2506,13 @@ msgid "Last edited %{date}"
msgstr ""
msgid "Last edited by %{name}"
-msgstr ""
+msgstr "Última edición por %{name}"
msgid "Last update"
-msgstr ""
+msgstr "Última actualización"
msgid "Last updated"
-msgstr ""
+msgstr "Última actualización"
msgid "LastPushEvent|You pushed to"
msgstr ""
@@ -1176,6 +2520,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr "Conozca más"
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "Más información en la"
@@ -1192,32 +2545,78 @@ msgid "Leave project"
msgstr "Abandonar proyecto"
msgid "License"
+msgstr "Licencia"
+
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
msgstr ""
-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 "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
+msgstr "Bloquear"
+
+msgid "Lock %{issuableDisplayName}"
msgstr ""
-msgid "Locked"
+msgid "Lock not found"
msgstr ""
+msgid "Locked"
+msgstr "Bloqueado"
+
msgid "Locked Files"
+msgstr "Archivos Bloqueados"
+
+msgid "Locks give the ability to lock specific file or folder."
msgstr ""
msgid "Login"
+msgstr "Iniciar sesión"
+
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr "Marzo"
+
+msgid "Mark done"
msgstr ""
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "Mediana"
msgid "Members"
+msgstr "Miembros"
+
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
msgstr ""
msgid "Merge Requests"
@@ -1229,22 +2628,145 @@ msgstr ""
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
+msgstr "Mensajes"
+
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
msgstr ""
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "agregar una clave SSH"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr ""
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1252,29 +2774,44 @@ msgid_plural "New Issues"
msgstr[0] "Nueva incidencia"
msgstr[1] "Nuevas incidencias"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "Nueva Programación del Pipeline"
msgid "New branch"
msgstr "Nueva rama"
+msgid "New branch unavailable"
+msgstr "Nueva rama no disponible"
+
msgid "New directory"
msgstr "Nuevo directorio"
+msgid "New epic"
+msgstr "Nuevo epic"
+
msgid "New file"
msgstr "Nuevo archivo"
msgid "New group"
-msgstr ""
+msgstr "Nuevo grupo"
msgid "New issue"
msgstr "Nueva incidencia"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "Nueva solicitud de fusión"
msgid "New project"
-msgstr ""
+msgstr "Nuevo proyecto"
msgid "New schedule"
msgstr "Nueva programación"
@@ -1283,12 +2820,33 @@ msgid "New snippet"
msgstr "Nuevo fragmento de código"
msgid "New subgroup"
-msgstr ""
+msgstr "Nuevo sub-grupo"
msgid "New tag"
msgstr "Nueva etiqueta"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1298,14 +2856,41 @@ msgid "No schedules"
msgstr "No hay programaciones"
msgid "None"
+msgstr "Ninguno"
+
+msgid "Not allowed to merge"
msgstr ""
msgid "Not available"
msgstr "No disponible"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "No hay suficientes datos"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "Eventos de notificación"
@@ -1361,35 +2946,74 @@ msgid "NotificationLevel|Watch"
msgstr "Vigilancia"
msgid "Notifications"
+msgstr "Notificaciones"
+
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
msgstr ""
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr "Noviembre"
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
msgstr ""
+msgid "October"
+msgstr "Octubre"
+
msgid "OfSearchInADropdown|Filter"
msgstr "Filtrar"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
+msgstr "Sólo los miembros de proyecto pueden comentar."
+
+msgid "Open"
msgstr ""
+msgid "Opened"
+msgstr "Abierto"
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Abierto"
msgid "Opens in a new window"
-msgstr ""
+msgstr "Abre en una nueva ventana"
msgid "Options"
msgstr "Opciones"
-msgid "Overview"
+msgid "Otherwise it is recommended you start with one of the options below."
msgstr ""
+msgid "Outbound requests"
+msgstr ""
+
+msgid "Overview"
+msgstr "Resumen"
+
msgid "Owner"
msgstr "Propietario"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1402,15 +3026,24 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
+msgstr "Contraseña"
+
+msgid "Pending"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Performance optimization"
msgstr ""
-msgid "Pipeline"
+msgid "Personal Access Token"
msgstr ""
+msgid "Pipeline"
+msgstr "Pipeline"
+
msgid "Pipeline Health"
msgstr "Estado del Pipeline"
@@ -1450,12 +3083,6 @@ 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"
@@ -1465,9 +3092,6 @@ 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"
@@ -1481,7 +3105,7 @@ msgid "PipelineSheduleIntervalPattern|Custom"
msgstr "Personalizado"
msgid "Pipelines"
-msgstr ""
+msgstr "Pipelines"
msgid "Pipelines charts"
msgstr "Gráficos de los pipelines"
@@ -1495,6 +3119,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "todos"
@@ -1507,17 +3182,41 @@ msgstr "con etapa"
msgid "Pipeline|with stages"
msgstr "con etapas"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
+msgstr "Preferencias"
+
+msgid "Primary"
msgstr ""
msgid "Private - Project access must be granted explicitly to each user."
-msgstr ""
+msgstr "Privado - El acceso al proyecto debe ser concedido explícitamente para cada usuario."
msgid "Private - The group and its projects can only be viewed by members."
+msgstr "Privado - El grupo y sus proyectos sólo pueden ser vistos por sus miembros."
+
+msgid "Private projects can be created in your personal namespace with:"
msgstr ""
msgid "Profile"
-msgstr ""
+msgstr "Perfil"
msgid "Profiles|Account scheduled for removal."
msgstr ""
@@ -1535,29 +3234,35 @@ msgid "Profiles|Deleting an account has the following effects:"
msgstr ""
msgid "Profiles|Invalid password"
-msgstr ""
+msgstr "Contraseña inválida"
msgid "Profiles|Invalid username"
-msgstr ""
+msgstr "Nombre de usuario inválido"
msgid "Profiles|Type your %{confirmationValue} to confirm:"
-msgstr ""
+msgstr "Escribe tu %{confirmationValue} para confirmar:"
msgid "Profiles|You don't have access to delete this user."
-msgstr ""
+msgstr "No tienes acceso para eliminar este usuario."
msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
-msgstr ""
+msgstr "Debes transferir o eliminar estos grupos antes de que puedas eliminar tu cuenta."
msgid "Profiles|Your account is currently an owner in these groups:"
-msgstr ""
+msgstr "Actualmente su cuenta es propietaria de estos grupos:"
msgid "Profiles|your account"
+msgstr "tu cuenta"
+
+msgid "Profiling - Performance bar"
msgstr ""
-msgid "Project '%{project_name}' is in the process of being deleted."
+msgid "Programming languages used in this repository"
msgstr ""
+msgid "Project '%{project_name}' is in the process of being deleted."
+msgstr "Proyecto '%{project_name}' está en proceso de ser eliminado."
+
msgid "Project '%{project_name}' queued for deletion."
msgstr "Proyecto ‘%{project_name}’ en cola para eliminación."
@@ -1570,9 +3275,15 @@ msgstr "Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente."
msgid "Project access must be granted explicitly to each user."
msgstr "El acceso al proyecto debe concederse explícitamente a cada usuario."
-msgid "Project details"
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
msgstr ""
+msgid "Project details"
+msgstr "Detalles del Proyecto"
+
msgid "Project export could not be deleted."
msgstr "No se pudo eliminar la exportación del proyecto."
@@ -1586,6 +3297,21 @@ 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 "ProjectActivityRSS|Subscribe"
+msgstr "Suscribirse"
+
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
msgstr ""
msgid "ProjectFeature|Disabled"
@@ -1610,60 +3336,156 @@ msgid "ProjectNetworkGraph|Graph"
msgstr "Historial gráfico"
msgid "ProjectSettings|Contact an admin to change this setting."
-msgstr ""
+msgstr "Póngase en contacto con un administrador para cambiar esta opción."
msgid "ProjectSettings|Only signed commits can be pushed to this repository."
-msgstr ""
+msgstr "Solo se pueden enviar commits firmados a este repositorio."
msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
-msgstr ""
+msgstr "Esta configuración se aplica a nivel del servidor y puede ser redefinida por un administrador."
msgid "ProjectSettings|This setting is applied on the server level but has been overridden for this project."
-msgstr ""
+msgstr "Esta configuración se aplica a nivel del servidor pero se ha redefinido para este proyecto."
msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin."
-msgstr ""
+msgstr "Esta configuración se aplicará a todos los proyectos a menos que sea redefinida por un administrador."
msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
msgstr ""
msgid "Projects"
-msgstr ""
+msgstr "Proyectos"
msgid "ProjectsDropdown|Frequently visited"
msgstr ""
msgid "ProjectsDropdown|Loading projects"
-msgstr ""
+msgstr "Cargando proyectos"
msgid "ProjectsDropdown|Projects you visit often will appear here"
msgstr ""
msgid "ProjectsDropdown|Search your projects"
-msgstr ""
+msgstr "Busca en tus proyectos"
msgid "ProjectsDropdown|Something went wrong on our end."
-msgstr ""
+msgstr "Algo salió mal por nuestra parte."
msgid "ProjectsDropdown|Sorry, no projects matched your search"
-msgstr ""
+msgstr "Lo sentimos, no hay proyectos que coincidan con su búsqueda"
msgid "ProjectsDropdown|This feature requires browser localStorage support"
+msgstr "Esta función requiere que el navegador soporte localStorage"
+
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
msgstr ""
-msgid "Public - The group and any public projects can be viewed without any authentication."
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
msgstr ""
-msgid "Public - The project can be accessed without any authentication."
+msgid "PrometheusService|Active"
msgstr ""
-msgid "Push Rules"
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr "De manera predeterminada, Prometheus escucha en 'http://localhost:9090'. No se recomienda cambiar la dirección y el puerto predeterminados, ya que esto podría afectar o entrar en conflicto con otros servicios que se ejecutan en el servidor de GitLab."
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr "Encontrar y configurar métricas..."
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr "Métricas"
+
+msgid "PrometheusService|Missing environment variable"
+msgstr "Falta la variable de entorno"
+
+msgid "PrometheusService|More information"
+msgstr "Más información"
+
+msgid "PrometheusService|New metric"
msgstr ""
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr "URL Base de Prometheus, como http://prometheus.example.com/"
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr "Público - El grupo y cualquier proyecto público puede ser visto sin autenticación."
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr "Público - El proyecto puede ser accedido sin ninguna autenticación."
+
+msgid "Push Rules"
+msgstr "Reglas Push"
+
msgid "Push events"
+msgstr "Eventos Push"
+
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
msgstr ""
msgid "PushRule|Committer restriction"
+msgstr "Restricción de Committer"
+
+msgid "Quick actions can be used in the issues description and comment boxes."
msgstr ""
msgid "Read more"
@@ -1672,15 +3494,24 @@ msgstr "Leer más"
msgid "Readme"
msgstr "Léeme"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "Ramas"
msgid "RefSwitcher|Tags"
msgstr "Etiquetas"
-msgid "Registry"
+msgid "Reference:"
msgstr ""
+msgid "Register / Sign In"
+msgstr ""
+
+msgid "Registry"
+msgstr "Registro"
+
msgid "Related Commits"
msgstr "Cambios Relacionados"
@@ -1699,13 +3530,40 @@ msgstr "Solicitudes de fusión Relacionadas"
msgid "Related Merged Requests"
msgstr "Solicitudes de fusión Relacionadas"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "Recordar después"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "Eliminar proyecto"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
+msgstr "Repositorio"
+
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
msgstr ""
msgid "Request Access"
@@ -1718,45 +3576,107 @@ msgid "Reset health check access token"
msgstr ""
msgid "Reset runners registration token"
+msgstr "Reinicializar el token de registro del runner"
+
+msgid "Resolve discussion"
msgstr ""
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Revert this commit"
msgstr "Revertir este cambio"
msgid "Revert this merge request"
msgstr "Revertir esta solicitud de fusión"
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
msgstr ""
-msgid "Save"
+msgid "Reviewing"
msgstr ""
-msgid "Save changes"
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
msgstr ""
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
+msgstr "Llaves SSH"
+
+msgid "Save changes"
+msgstr "Guardar los cambios"
+
msgid "Save pipeline schedule"
msgstr "Guardar programación del pipeline"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "Programar un nuevo pipeline"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "Programación de Pipelines"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "Buscar ramas y etiquetas"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search users"
msgstr ""
+msgid "Seconds before reseting failure information"
+msgstr "Segundos antes de reinicializar información de fallas"
+
msgid "Seconds to wait for a storage access attempt"
+msgstr "Segundos a esperar para intentar acceder al almacenamiento"
+
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
msgstr ""
msgid "Select Archive Format"
@@ -1765,33 +3685,93 @@ msgstr "Seleccionar formato de archivo"
msgid "Select a timezone"
msgstr "Selecciona una zona horaria"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "Selecciona una rama de destino"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr "Septiembre"
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
+msgstr "Plantillas de Servicio"
+
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
msgstr ""
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 default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "Configurar Koding"
-msgid "Set up auto deploy"
-msgstr "Configurar auto despliegue"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "establecer una contraseña"
msgid "Settings"
+msgstr "Configuración"
+
+msgid "Setup a specific Runner automatically"
msgstr ""
-msgid "Show parent pages"
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
msgstr ""
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
+msgid "Show parent pages"
+msgstr "Mostrar páginas padre"
+
msgid "Show parent subgroups"
msgstr ""
@@ -1800,13 +3780,46 @@ msgid_plural "Showing %d events"
msgstr[0] "Mostrando %d evento"
msgstr[1] "Mostrando %d eventos"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1815,9 +3828,12 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
-msgid "Sort by"
+msgid "Something went wrong. Please try again."
msgstr ""
+msgid "Sort by"
+msgstr "Ordenar por"
+
msgid "SortOptions|Access level, ascending"
msgstr ""
@@ -1914,60 +3930,174 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr "Fuente"
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "Código fuente"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr "Destacar"
-msgid "Starred projects"
+msgid "Starred Projects"
msgstr ""
+msgid "Starred Projects' Activity"
+msgstr ""
+
+msgid "Starred projects"
+msgstr "Proyectos favoritos"
+
msgid "Start a %{new_merge_request} with these changes"
msgstr "Iniciar una %{new_merge_request} con estos cambios"
msgid "Start the Runner!"
msgstr ""
-msgid "Subgroups"
+msgid "Started"
msgstr ""
-msgid "Subscribe"
+msgid "State your message to activate"
msgstr ""
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr "Detenido"
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
+msgstr "Sub-grupos"
+
msgid "Switch branch/tag"
msgstr "Cambiar rama/etiqueta"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
+msgstr "Hooks de sistema"
+
+msgid "System header and footer:"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "Etiqueta"
-msgstr[1] "Etiquetas"
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
+msgstr[1] ""
msgid "Tags"
msgstr "Etiquetas"
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "Rama de destino"
-msgid "Team"
+msgid "Target branch"
msgstr ""
+msgid "Team"
+msgstr "Equipo"
+
msgid "Thanks! Don't show me this again"
-msgstr ""
+msgstr "Gracias! No mostrar esto nuevamente"
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1976,30 +4106,39 @@ msgstr "La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la
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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "La relación con la bifurcación se ha eliminado."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 "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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "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."
@@ -2012,9 +4151,18 @@ 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 repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 "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 roadmap shows the progress of your epics along a timeline"
+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 "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."
@@ -2027,16 +4175,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
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 "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2045,18 +4226,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
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 "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "Tiempo antes de que una incidencia sea programada"
@@ -2066,9 +4283,27 @@ 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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "Tiempo hasta la primera solicitud de fusión"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "hace %s días"
@@ -2205,25 +4440,97 @@ msgstr[1] "mins"
msgid "Time|s"
msgstr "s"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr "Título"
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
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 "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
-msgid "Unlock"
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
msgstr ""
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
+msgid "Unlock"
+msgstr "Desbloquear"
+
msgid "Unlocked"
+msgstr "Desbloqueado"
+
+msgid "Unresolve discussion"
msgstr ""
msgid "Unstar"
msgstr "No Destacar"
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2247,22 +4554,70 @@ msgstr "Subir nuevo archivo"
msgid "Upload file"
msgstr "Subir archivo"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "Hacer clic para subir"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr "Utiliza tu configuración de notificación global"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
+msgstr "Ver archivo @ "
+
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
msgstr ""
msgid "View open merge request"
msgstr "Ver solicitud de fusión abierta"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
+msgstr "Ver archivo reemplazado @ "
+
+msgid "Visibility and access controls"
msgstr ""
msgid "VisibilityLevel|Internal"
@@ -2280,20 +4635,32 @@ 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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "No hay suficientes datos para mostrar en esta etapa."
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
-msgstr ""
+msgstr "Peso"
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
-msgstr ""
+msgstr "Wiki"
msgid "WikiClone|Clone your wiki"
msgstr ""
@@ -2310,6 +4677,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2400,39 +4773,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "Retirar Solicitud de Acceso"
+msgid "Write a commit message..."
+msgstr ""
+
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 %{project_full_name}. 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 "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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
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 can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "Has alcanzado el límite de tu proyecto"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "Debes iniciar sesión para destacar un proyecto"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "Necesitas permisos."
@@ -2457,19 +4860,150 @@ msgstr "No podrás actualizar o enviar código al proyecto a través de %{protoc
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 comment will not be visible to the public."
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
msgstr ""
-msgid "Your groups"
+msgid "You'll need to use different branch names to get a valid comparison."
msgstr ""
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr "La información del cluster de Kubernetes en esta página aún se puede editar, pero se recomienda desactivarla y modificar"
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
+msgid "Your comment will not be visible to the public."
+msgstr "Tus comentarios no serán visibles al público."
+
+msgid "Your groups"
+msgstr "Tus grupos"
+
msgid "Your name"
msgstr "Tu nombre"
msgid "Your projects"
+msgstr "Tus proyectos"
+
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr "nombre de la rama"
+
+msgid "by"
+msgstr "por"
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
msgstr ""
-msgid "commit"
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
@@ -2477,26 +5011,278 @@ msgid_plural "days"
msgstr[0] "día"
msgstr[1] "días"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "nueva solicitud de fusión"
msgid "notification emails"
msgstr "correos electrónicos de notificación"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "padre"
msgstr[1] "padres"
msgid "password"
-msgstr ""
+msgstr "contraseña"
msgid "personal access token"
+msgstr "token de acceso personal"
+
+msgid "private key does not match certificate."
msgstr ""
-msgid "to help your contributors communicate effectively!"
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
msgstr ""
+msgid "this document"
+msgstr ""
+
+msgid "to help your contributors communicate effectively!"
+msgstr "para ayudar a sus colaboradores a comunicarse de manera efectiva!"
+
msgid "username"
+msgstr "usuario"
+
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
msgstr ""
diff --git a/locale/fil_PH/gitlab.po b/locale/fil_PH/gitlab.po
new file mode 100644
index 00000000000..ed955f68381
--- /dev/null
+++ b/locale/fil_PH/gitlab.po
@@ -0,0 +1,5288 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab-ee\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:34-0400\n"
+"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
+"Language-Team: Filipino\n"
+"Language: fil_PH\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"
+"X-Generator: crowdin.com\n"
+"X-Crowdin-Project: gitlab-ee\n"
+"X-Crowdin-Language: fil\n"
+"X-Crowdin-File: /master/locale/gitlab.pot\n"
+
+msgid " and"
+msgstr ""
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d layer"
+msgid_plural "%d layers"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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 "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
+
+msgid "%{count} participant"
+msgid_plural "%{count} participants"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
+msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
+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 not retry automatically. Reset storage information when the problem is resolved."
+msgstr ""
+
+msgid "%{openOrClose} %{noteable}"
+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 "%{text} is available"
+msgstr ""
+
+msgid "(checkout the %{link} for information on how to install it)."
+msgstr ""
+
+msgid "+ %{moreCount} more"
+msgstr ""
+
+msgid "- show less"
+msgstr ""
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "1st contribution!"
+msgstr ""
+
+msgid "2FA enabled"
+msgstr ""
+
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr ""
+
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Abuse Reports"
+msgstr ""
+
+msgid "Abuse reports"
+msgstr ""
+
+msgid "Access Tokens"
+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 "Account"
+msgstr ""
+
+msgid "Account and limit settings"
+msgstr ""
+
+msgid "Active"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add Group Webhooks and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Add Kubernetes cluster"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add Readme"
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
+msgid "AdminHealthPageLink|health page"
+msgstr ""
+
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
+msgid "Advanced settings"
+msgstr ""
+
+msgid "All"
+msgstr ""
+
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
+msgid "An error occurred. Please try again."
+msgstr ""
+
+msgid "Any Label"
+msgstr ""
+
+msgid "Appearance"
+msgstr ""
+
+msgid "Applications"
+msgstr ""
+
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+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 reset registration token?"
+msgstr ""
+
+msgid "Are you sure you want to reset the health check token?"
+msgstr ""
+
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr ""
+
+msgid "Are you sure?"
+msgstr ""
+
+msgid "Artifacts"
+msgstr ""
+
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr ""
+
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
+msgid "Authentication Log"
+msgstr ""
+
+msgid "Author"
+msgstr ""
+
+msgid "Authors: %{authors}"
+msgstr ""
+
+msgid "Auto DevOps enabled"
+msgstr ""
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgstr ""
+
+msgid "AutoDevOps|Auto DevOps (Beta)"
+msgstr ""
+
+msgid "AutoDevOps|Auto DevOps documentation"
+msgstr ""
+
+msgid "AutoDevOps|Enable in settings"
+msgstr ""
+
+msgid "AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration."
+msgstr ""
+
+msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
+msgstr ""
+
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr ""
+
+msgid "Billing"
+msgstr ""
+
+msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
+msgstr ""
+
+msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
+msgstr ""
+
+msgid "BillingPlans|Current plan"
+msgstr ""
+
+msgid "BillingPlans|Customer Support"
+msgstr ""
+
+msgid "BillingPlans|Downgrade"
+msgstr ""
+
+msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
+msgstr ""
+
+msgid "BillingPlans|Manage plan"
+msgstr ""
+
+msgid "BillingPlans|Please contact %{customer_support_link} in that case."
+msgstr ""
+
+msgid "BillingPlans|See all %{plan_name} features"
+msgstr ""
+
+msgid "BillingPlans|This group uses the plan associated with its parent group."
+msgstr ""
+
+msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
+msgstr ""
+
+msgid "BillingPlans|Upgrade"
+msgstr ""
+
+msgid "BillingPlans|You are currently on the %{plan_link} plan."
+msgstr ""
+
+msgid "BillingPlans|frequently asked questions"
+msgstr ""
+
+msgid "BillingPlans|monthly"
+msgstr ""
+
+msgid "BillingPlans|paid annually at %{price_per_year}"
+msgstr ""
+
+msgid "BillingPlans|per user"
+msgstr ""
+
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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 "Branch has changed"
+msgstr ""
+
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr ""
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
+msgid "Branches|Cant find HEAD commit for this branch"
+msgstr ""
+
+msgid "Branches|Compare"
+msgstr ""
+
+msgid "Branches|Delete all branches that are merged into '%{default_branch}'"
+msgstr ""
+
+msgid "Branches|Delete branch"
+msgstr ""
+
+msgid "Branches|Delete merged branches"
+msgstr ""
+
+msgid "Branches|Delete protected branch"
+msgstr ""
+
+msgid "Branches|Delete protected branch '%{branch_name}'?"
+msgstr ""
+
+msgid "Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "Branches|Filter by branch name"
+msgstr ""
+
+msgid "Branches|Merged into %{default_branch}"
+msgstr ""
+
+msgid "Branches|New branch"
+msgstr ""
+
+msgid "Branches|No branches to show"
+msgstr ""
+
+msgid "Branches|Once you confirm and press %{delete_protected_branch}, it cannot be undone or recovered."
+msgstr ""
+
+msgid "Branches|Only a project master or owner can delete a protected branch"
+msgstr ""
+
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
+msgstr ""
+
+msgid "Branches|Sort by"
+msgstr ""
+
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
+msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
+msgstr ""
+
+msgid "Branches|The default branch cannot be deleted"
+msgstr ""
+
+msgid "Branches|This branch hasn’t been merged into %{default_branch}."
+msgstr ""
+
+msgid "Branches|To avoid data loss, consider merging this branch before deleting it."
+msgstr ""
+
+msgid "Branches|To confirm, type %{branch_name_confirmation}:"
+msgstr ""
+
+msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above."
+msgstr ""
+
+msgid "Branches|You’re about to permanently delete the protected branch %{branch_name}."
+msgstr ""
+
+msgid "Branches|diverged from upstream"
+msgstr ""
+
+msgid "Branches|merged"
+msgstr ""
+
+msgid "Branches|project settings"
+msgstr ""
+
+msgid "Branches|protected"
+msgstr ""
+
+msgid "Browse Directory"
+msgstr ""
+
+msgid "Browse File"
+msgstr ""
+
+msgid "Browse Files"
+msgstr ""
+
+msgid "Browse files"
+msgstr ""
+
+msgid "Business"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr ""
+
+msgid "CI / CD"
+msgstr ""
+
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
+
+msgid "CICD|Jobs"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
+msgstr ""
+
+msgid "Change Weight"
+msgstr ""
+
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr ""
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr ""
+
+msgid "ChangeTypeAction|Revert"
+msgstr ""
+
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "Chat"
+msgstr ""
+
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
+msgid "Cherry-pick this commit"
+msgstr ""
+
+msgid "Cherry-pick this merge request"
+msgstr ""
+
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+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 "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
+msgid "CircuitBreakerApiLink|circuitbreaker api"
+msgstr ""
+
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
+msgid "Clone repository"
+msgstr ""
+
+msgid "Close"
+msgstr ""
+
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Copy API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Token"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr ""
+
+msgid "ClusterIntegration|Google Cloud Platform project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Google Kubernetes Engine project"
+msgstr ""
+
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
+msgid "ClusterIntegration|Machine type"
+msgstr ""
+
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
+msgstr ""
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
+msgstr ""
+
+msgid "ClusterIntegration|Number of nodes"
+msgstr ""
+
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
+msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgstr ""
+
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace (optional, unique)"
+msgstr ""
+
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Remove integration"
+msgstr ""
+
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|See machine types"
+msgstr ""
+
+msgid "ClusterIntegration|See your projects"
+msgstr ""
+
+msgid "ClusterIntegration|See zones"
+msgstr ""
+
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong on our end."
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr ""
+
+msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
+msgstr ""
+
+msgid "ClusterIntegration|Zone"
+msgstr ""
+
+msgid "ClusterIntegration|access to Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
+msgstr ""
+
+msgid "ClusterIntegration|help page"
+msgstr ""
+
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
+msgid "ClusterIntegration|meets the requirements"
+msgstr ""
+
+msgid "ClusterIntegration|properly configured"
+msgstr ""
+
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
+msgid "Comments"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Commit Message"
+msgstr ""
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr ""
+
+msgid "Commit message"
+msgstr ""
+
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
+msgid "CommitBoxTitle|Commit"
+msgstr ""
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr ""
+
+msgid "Commits"
+msgstr ""
+
+msgid "Commits feed"
+msgstr ""
+
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
+msgid "Commits|History"
+msgstr ""
+
+msgid "Commits|No related merge requests found"
+msgstr ""
+
+msgid "Committed by"
+msgstr ""
+
+msgid "Compare"
+msgstr ""
+
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
+msgid "Container Registry"
+msgstr ""
+
+msgid "ContainerRegistry|Created"
+msgstr ""
+
+msgid "ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:"
+msgstr ""
+
+msgid "ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:"
+msgstr ""
+
+msgid "ContainerRegistry|How to use the Container Registry"
+msgstr ""
+
+msgid "ContainerRegistry|Learn more about"
+msgstr ""
+
+msgid "ContainerRegistry|No tags in Container Registry for this container image."
+msgstr ""
+
+msgid "ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands"
+msgstr ""
+
+msgid "ContainerRegistry|Remove repository"
+msgstr ""
+
+msgid "ContainerRegistry|Remove tag"
+msgstr ""
+
+msgid "ContainerRegistry|Size"
+msgstr ""
+
+msgid "ContainerRegistry|Tag"
+msgstr ""
+
+msgid "ContainerRegistry|Tag ID"
+msgstr ""
+
+msgid "ContainerRegistry|Use different image names"
+msgstr ""
+
+msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
+msgstr ""
+
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
+msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
+msgstr ""
+
+msgid "Control the maximum concurrency of repository backfill for this secondary node"
+msgstr ""
+
+msgid "Copy SSH public key to clipboard"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Create branch"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
+
+msgid "Create file"
+msgstr ""
+
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
+msgid "Create merge request"
+msgstr ""
+
+msgid "Create merge request and branch"
+msgstr ""
+
+msgid "Create new branch"
+msgstr ""
+
+msgid "Create new directory"
+msgstr ""
+
+msgid "Create new file"
+msgstr ""
+
+msgid "Create new label"
+msgstr ""
+
+msgid "Create new..."
+msgstr ""
+
+msgid "Create project label"
+msgstr ""
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "CreateTag|Tag"
+msgstr ""
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Cron syntax"
+msgstr ""
+
+msgid "Current node"
+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 "Customize colors"
+msgstr ""
+
+msgid "Cycle Analytics"
+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 "DashboardProjects|All"
+msgstr ""
+
+msgid "DashboardProjects|Personal"
+msgstr ""
+
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
+msgid "Define a custom pattern with cron syntax"
+msgstr ""
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Deploy Keys"
+msgstr ""
+
+msgid "Description"
+msgstr ""
+
+msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
+msgstr ""
+
+msgid "Details"
+msgstr ""
+
+msgid "Diffs|No file name available"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
+msgstr ""
+
+msgid "Dismiss Cycle Analytics introduction box"
+msgstr ""
+
+msgid "Dismiss Merge Request promotion"
+msgstr ""
+
+msgid "Documentation for popular identity providers"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Done"
+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 "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
+msgid "Emails"
+msgstr ""
+
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
+msgid "EventFilterBy|Filter by all"
+msgstr ""
+
+msgid "EventFilterBy|Filter by comments"
+msgstr ""
+
+msgid "EventFilterBy|Filter by issue events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by merge events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by push events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by team"
+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 "Expand"
+msgstr ""
+
+msgid "Explore projects"
+msgstr ""
+
+msgid "Explore public groups"
+msgstr ""
+
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
+msgid "File name"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
+msgid "Filter by commit message"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "Finished"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr ""
+
+msgid "FirstPushedBy|pushed by"
+msgstr ""
+
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
+msgstr ""
+
+msgid "Forking in progress"
+msgstr ""
+
+msgid "Format"
+msgstr ""
+
+msgid "From %{provider_title}"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr ""
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
+msgid "GPG Keys"
+msgstr ""
+
+msgid "Generate a default set of labels"
+msgstr ""
+
+msgid "Geo Nodes"
+msgstr ""
+
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
+msgid "Geo|File sync capacity"
+msgstr ""
+
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
+msgstr ""
+
+msgid "Geo|Repository sync capacity"
+msgstr ""
+
+msgid "Geo|Select groups to replicate."
+msgstr ""
+
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
+msgid "Git storage health information has been reset"
+msgstr ""
+
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
+msgid "GitLab Runner section"
+msgstr ""
+
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
+msgstr ""
+
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
+msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
+msgstr ""
+
+msgid "GroupSettings|Share with group lock"
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup."
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. To share projects in this group with another group, ask the owner to override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. You can override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr ""
+
+msgid "GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually."
+msgstr ""
+
+msgid "GroupSettings|cannot be disabled when the parent group \"Share with group lock\" is enabled, except by the owner of the parent group"
+msgstr ""
+
+msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
+msgstr ""
+
+msgid "GroupsEmptyState|A group is a collection of several projects."
+msgstr ""
+
+msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder."
+msgstr ""
+
+msgid "GroupsEmptyState|No groups found"
+msgstr ""
+
+msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
+msgstr ""
+
+msgid "GroupsTree|Create a project in this group."
+msgstr ""
+
+msgid "GroupsTree|Create a subgroup in this group."
+msgstr ""
+
+msgid "GroupsTree|Edit group"
+msgstr ""
+
+msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
+msgstr ""
+
+msgid "GroupsTree|Filter by name..."
+msgstr ""
+
+msgid "GroupsTree|Leave this group"
+msgstr ""
+
+msgid "GroupsTree|Loading groups"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups matched your search"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgstr ""
+
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+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 "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "History"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
+msgid "Improve Issue boards with GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
+msgid "Install a Runner compatible with GitLab CI"
+msgstr ""
+
+msgid "Instance"
+msgid_plural "Instances"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
+msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgstr ""
+
+msgid "Internal - The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr ""
+
+msgid "Issue board focus mode"
+msgstr ""
+
+msgid "Issue events"
+msgstr ""
+
+msgid "IssueBoards|Board"
+msgstr ""
+
+msgid "IssueBoards|Boards"
+msgstr ""
+
+msgid "Issues"
+msgstr ""
+
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
+msgid "Labels"
+msgstr ""
+
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Last edited %{date}"
+msgstr ""
+
+msgid "Last edited by %{name}"
+msgstr ""
+
+msgid "Last update"
+msgstr ""
+
+msgid "Last updated"
+msgstr ""
+
+msgid "LastPushEvent|You pushed to"
+msgstr ""
+
+msgid "LastPushEvent|at"
+msgstr ""
+
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
+msgid "Learn more in the"
+msgstr ""
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr ""
+
+msgid "Leave"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "License"
+msgstr ""
+
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
+
+msgid "Lock"
+msgstr ""
+
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
+msgid "Locked"
+msgstr ""
+
+msgid "Locked Files"
+msgstr ""
+
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
+msgid "Login"
+msgstr ""
+
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
+msgid "Maximum git storage failures"
+msgstr ""
+
+msgid "May"
+msgstr ""
+
+msgid "Median"
+msgstr ""
+
+msgid "Members"
+msgstr ""
+
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
+msgid "Merge Requests"
+msgstr ""
+
+msgid "Merge events"
+msgstr ""
+
+msgid "Merge request"
+msgstr ""
+
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
+msgid "Messages"
+msgstr ""
+
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
+msgid "Monitoring"
+msgstr ""
+
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
+msgid "More information is available|here"
+msgstr ""
+
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
+msgid "Multiple issue boards"
+msgstr ""
+
+msgid "Name new label"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New branch unavailable"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New epic"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+msgid "New group"
+msgstr ""
+
+msgid "New issue"
+msgstr ""
+
+msgid "New label"
+msgstr ""
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New project"
+msgstr ""
+
+msgid "New schedule"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New subgroup"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "None"
+msgstr ""
+
+msgid "Not allowed to merge"
+msgstr ""
+
+msgid "Not available"
+msgstr ""
+
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
+msgid "Not enough data"
+msgstr ""
+
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+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 "Notifications"
+msgstr ""
+
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
+msgid "Number of access attempts"
+msgstr ""
+
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
+msgstr ""
+
+msgid "OfSearchInADropdown|Filter"
+msgstr ""
+
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
+msgid "Only project members can comment."
+msgstr ""
+
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr ""
+
+msgid "Opens in a new window"
+msgstr ""
+
+msgid "Options"
+msgstr ""
+
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
+msgid "Overview"
+msgstr ""
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pages"
+msgstr ""
+
+msgid "Pagination|Last »"
+msgstr ""
+
+msgid "Pagination|Next"
+msgstr ""
+
+msgid "Pagination|Prev"
+msgstr ""
+
+msgid "Pagination|« First"
+msgstr ""
+
+msgid "Part of merge request changes"
+msgstr ""
+
+msgid "Password"
+msgstr ""
+
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr ""
+
+msgid "Pipeline"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr ""
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "Pipeline quota"
+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|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 "PipelineSchedules|Variables"
+msgstr ""
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr ""
+
+msgid "Pipelines"
+msgstr ""
+
+msgid "Pipelines charts"
+msgstr ""
+
+msgid "Pipelines for last month"
+msgstr ""
+
+msgid "Pipelines for last week"
+msgstr ""
+
+msgid "Pipelines for last year"
+msgstr ""
+
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|all"
+msgstr ""
+
+msgid "Pipeline|success"
+msgstr ""
+
+msgid "Pipeline|with stage"
+msgstr ""
+
+msgid "Pipeline|with stages"
+msgstr ""
+
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
+msgid "Preferences"
+msgstr ""
+
+msgid "Primary"
+msgstr ""
+
+msgid "Private - Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Private - The group and its projects can only be viewed by members."
+msgstr ""
+
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
+msgid "Profile"
+msgstr ""
+
+msgid "Profiles|Account scheduled for removal."
+msgstr ""
+
+msgid "Profiles|Delete Account"
+msgstr ""
+
+msgid "Profiles|Delete account"
+msgstr ""
+
+msgid "Profiles|Delete your account?"
+msgstr ""
+
+msgid "Profiles|Deleting an account has the following effects:"
+msgstr ""
+
+msgid "Profiles|Invalid password"
+msgstr ""
+
+msgid "Profiles|Invalid username"
+msgstr ""
+
+msgid "Profiles|Type your %{confirmationValue} to confirm:"
+msgstr ""
+
+msgid "Profiles|You don't have access to delete this user."
+msgstr ""
+
+msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
+msgstr ""
+
+msgid "Profiles|Your account is currently an owner in these groups:"
+msgstr ""
+
+msgid "Profiles|your account"
+msgstr ""
+
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
+msgid "Project '%{project_name}' is in the process of being deleted."
+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 access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+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 "ProjectActivityRSS|Subscribe"
+msgstr ""
+
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+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 "ProjectSettings|Contact an admin to change this setting."
+msgstr ""
+
+msgid "ProjectSettings|Only signed commits can be pushed to this repository."
+msgstr ""
+
+msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
+msgstr ""
+
+msgid "ProjectSettings|This setting is applied on the server level but has been overridden for this project."
+msgstr ""
+
+msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin."
+msgstr ""
+
+msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
+msgstr ""
+
+msgid "Projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Frequently visited"
+msgstr ""
+
+msgid "ProjectsDropdown|Loading projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Projects you visit often will appear here"
+msgstr ""
+
+msgid "ProjectsDropdown|Search your projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Something went wrong on our end."
+msgstr ""
+
+msgid "ProjectsDropdown|Sorry, no projects matched your search"
+msgstr ""
+
+msgid "ProjectsDropdown|This feature requires browser localStorage support"
+msgstr ""
+
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr ""
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr ""
+
+msgid "Push Rules"
+msgstr ""
+
+msgid "Push events"
+msgstr ""
+
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
+msgid "PushRule|Committer restriction"
+msgstr ""
+
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
+msgid "Read more"
+msgstr ""
+
+msgid "Readme"
+msgstr ""
+
+msgid "Real-time features"
+msgstr ""
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
+msgid "Registry"
+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 "Related merge requests"
+msgstr ""
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
+msgid "Repository"
+msgstr ""
+
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+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 "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Revert this commit"
+msgstr ""
+
+msgid "Revert this merge request"
+msgstr ""
+
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
+msgstr ""
+
+msgid "Save changes"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Save variables"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Scheduled"
+msgstr ""
+
+msgid "Schedules"
+msgstr ""
+
+msgid "Scheduling Pipelines"
+msgstr ""
+
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
+msgstr ""
+
+msgid "Seconds to wait for a storage access attempt"
+msgstr ""
+
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
+msgid "Service Templates"
+msgstr ""
+
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Settings"
+msgstr ""
+
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
+msgid "Show parent pages"
+msgstr ""
+
+msgid "Show parent subgroups"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
+msgid "Snippets"
+msgstr ""
+
+msgid "Something went wrong on our end"
+msgstr ""
+
+msgid "Something went wrong on our end."
+msgstr ""
+
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
+msgstr ""
+
+msgid "Something went wrong while fetching the projects."
+msgstr ""
+
+msgid "Something went wrong while fetching the registry list."
+msgstr ""
+
+msgid "Something went wrong. Please try again."
+msgstr ""
+
+msgid "Sort by"
+msgstr ""
+
+msgid "SortOptions|Access level, ascending"
+msgstr ""
+
+msgid "SortOptions|Access level, descending"
+msgstr ""
+
+msgid "SortOptions|Created date"
+msgstr ""
+
+msgid "SortOptions|Due date"
+msgstr ""
+
+msgid "SortOptions|Due later"
+msgstr ""
+
+msgid "SortOptions|Due soon"
+msgstr ""
+
+msgid "SortOptions|Label priority"
+msgstr ""
+
+msgid "SortOptions|Largest group"
+msgstr ""
+
+msgid "SortOptions|Largest repository"
+msgstr ""
+
+msgid "SortOptions|Last created"
+msgstr ""
+
+msgid "SortOptions|Last joined"
+msgstr ""
+
+msgid "SortOptions|Last updated"
+msgstr ""
+
+msgid "SortOptions|Least popular"
+msgstr ""
+
+msgid "SortOptions|Less weight"
+msgstr ""
+
+msgid "SortOptions|Milestone"
+msgstr ""
+
+msgid "SortOptions|Milestone due later"
+msgstr ""
+
+msgid "SortOptions|Milestone due soon"
+msgstr ""
+
+msgid "SortOptions|More weight"
+msgstr ""
+
+msgid "SortOptions|Most popular"
+msgstr ""
+
+msgid "SortOptions|Name"
+msgstr ""
+
+msgid "SortOptions|Name, ascending"
+msgstr ""
+
+msgid "SortOptions|Name, descending"
+msgstr ""
+
+msgid "SortOptions|Oldest created"
+msgstr ""
+
+msgid "SortOptions|Oldest joined"
+msgstr ""
+
+msgid "SortOptions|Oldest sign in"
+msgstr ""
+
+msgid "SortOptions|Oldest updated"
+msgstr ""
+
+msgid "SortOptions|Popularity"
+msgstr ""
+
+msgid "SortOptions|Priority"
+msgstr ""
+
+msgid "SortOptions|Recent sign in"
+msgstr ""
+
+msgid "SortOptions|Start later"
+msgstr ""
+
+msgid "SortOptions|Start soon"
+msgstr ""
+
+msgid "SortOptions|Weight"
+msgstr ""
+
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
+msgid "Source code"
+msgstr ""
+
+msgid "Source is not available"
+msgstr ""
+
+msgid "Spam Logs"
+msgstr ""
+
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
+msgid "Specify the following URL during the Runner setup:"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
+msgid "Starred projects"
+msgstr ""
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr ""
+
+msgid "Start the Runner!"
+msgstr ""
+
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "System"
+msgstr ""
+
+msgid "System Hooks"
+msgstr ""
+
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "Target branch"
+msgstr ""
+
+msgid "Team"
+msgstr ""
+
+msgid "Thanks! Don't show me this again"
+msgstr ""
+
+msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 maximum file size allowed is 200KB."
+msgstr ""
+
+msgid "The number of attempts GitLab will make to access a storage."
+msgstr ""
+
+msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
+msgstr ""
+
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
+msgid "The phase of the development lifecycle."
+msgstr ""
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr ""
+
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+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 repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 in seconds GitLab will keep failure information. When no failures occur during this time, information about the mount is reset."
+msgstr ""
+
+msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
+msgstr ""
+
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+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 no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
+msgid "There are problems accessing Git storage: "
+msgstr ""
+
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
+msgstr ""
+
+msgid "This is a confidential issue."
+msgstr ""
+
+msgid "This is the author's first Merge Request to this project."
+msgstr ""
+
+msgid "This issue is confidential"
+msgstr ""
+
+msgid "This issue is confidential and locked."
+msgstr ""
+
+msgid "This issue is locked."
+msgstr ""
+
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "This merge request is locked."
+msgstr ""
+
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
+msgid "Time until first merge request"
+msgstr ""
+
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+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 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|in a while"
+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 "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
+msgid "Total Time"
+msgstr ""
+
+msgid "Total test time for all commits/merges"
+msgstr ""
+
+msgid "Total: %{total}"
+msgstr ""
+
+msgid "Track activity with Contribution Analytics."
+msgstr ""
+
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
+msgid "Unlock"
+msgstr ""
+
+msgid "Unlocked"
+msgstr ""
+
+msgid "Unresolve discussion"
+msgstr ""
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Up to date"
+msgstr ""
+
+msgid "Upgrade your plan to activate Advanced Global Search."
+msgstr ""
+
+msgid "Upgrade your plan to activate Contribution Analytics."
+msgstr ""
+
+msgid "Upgrade your plan to activate Group Webhooks."
+msgstr ""
+
+msgid "Upgrade your plan to activate Issue weight."
+msgstr ""
+
+msgid "Upgrade your plan to improve Issue boards."
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Upload new avatar"
+msgstr ""
+
+msgid "UploadLink|click to upload"
+msgstr ""
+
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
+msgid "Use the following registration token during setup:"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
+msgid "View file @ "
+msgstr ""
+
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
+msgid "View open merge request"
+msgstr ""
+
+msgid "View project labels"
+msgstr ""
+
+msgid "View replaced file @ "
+msgstr ""
+
+msgid "Visibility and access controls"
+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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
+msgid "We don't have enough data to show this stage."
+msgstr ""
+
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
+msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
+msgstr ""
+
+msgid "Weight"
+msgstr ""
+
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr ""
+
+msgid "Wiki"
+msgstr ""
+
+msgid "WikiClone|Clone your wiki"
+msgstr ""
+
+msgid "WikiClone|Git Access"
+msgstr ""
+
+msgid "WikiClone|Install Gollum"
+msgstr ""
+
+msgid "WikiClone|It is recommended to install %{markdown} so that GFM features render locally:"
+msgstr ""
+
+msgid "WikiClone|Start Gollum and edit locally"
+msgstr ""
+
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
+msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
+msgstr ""
+
+msgid "WikiHistoricalPage|This is an old version of this page."
+msgstr ""
+
+msgid "WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}."
+msgstr ""
+
+msgid "WikiHistoricalPage|history"
+msgstr ""
+
+msgid "WikiHistoricalPage|most recent version"
+msgstr ""
+
+msgid "WikiMarkdownDocs|More examples are in the %{docs_link}"
+msgstr ""
+
+msgid "WikiMarkdownDocs|documentation"
+msgstr ""
+
+msgid "WikiMarkdownTip|To link to a (new) page, simply type %{link_example}"
+msgstr ""
+
+msgid "WikiNewPagePlaceholder|how-to-setup"
+msgstr ""
+
+msgid "WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories."
+msgstr ""
+
+msgid "WikiNewPageTitle|New Wiki Page"
+msgstr ""
+
+msgid "WikiPageConfirmDelete|Are you sure you want to delete this page?"
+msgstr ""
+
+msgid "WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs."
+msgstr ""
+
+msgid "WikiPageConflictMessage|the page"
+msgstr ""
+
+msgid "WikiPageCreate|Create %{page_title}"
+msgstr ""
+
+msgid "WikiPageEdit|Update %{page_title}"
+msgstr ""
+
+msgid "WikiPage|Page slug"
+msgstr ""
+
+msgid "WikiPage|Write your content or drag files here..."
+msgstr ""
+
+msgid "Wiki|Create Page"
+msgstr ""
+
+msgid "Wiki|Create page"
+msgstr ""
+
+msgid "Wiki|Edit Page"
+msgstr ""
+
+msgid "Wiki|Empty page"
+msgstr ""
+
+msgid "Wiki|More Pages"
+msgstr ""
+
+msgid "Wiki|New page"
+msgstr ""
+
+msgid "Wiki|Page history"
+msgstr ""
+
+msgid "Wiki|Page version"
+msgstr ""
+
+msgid "Wiki|Pages"
+msgstr ""
+
+msgid "Wiki|Wiki Pages"
+msgstr ""
+
+msgid "With contribution analytics you can have an overview for the activity of issues, merge requests and push events of your organization and its members."
+msgstr ""
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid "Write a commit message..."
+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_full_name}. 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_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are on a read-only GitLab instance."
+msgstr ""
+
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
+msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
+msgstr ""
+
+msgid "You cannot write to this read-only GitLab instance."
+msgstr ""
+
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
+msgid "You have reached your project limit"
+msgstr ""
+
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need a different license to enable FileLocks feature"
+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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
+msgid "Your comment will not be visible to the public."
+msgstr ""
+
+msgid "Your groups"
+msgstr ""
+
+msgid "Your name"
+msgstr ""
+
+msgid "Your projects"
+msgstr ""
+
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
+msgid "new merge request"
+msgstr ""
+
+msgid "notification emails"
+msgstr ""
+
+msgid "or"
+msgstr ""
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "password"
+msgstr ""
+
+msgid "personal access token"
+msgstr ""
+
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
+msgid "to help your contributors communicate effectively!"
+msgstr ""
+
+msgid "username"
+msgstr ""
+
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/fr/gitlab.po b/locale/fr/gitlab.po
index a0e523339db..3f30c5c4b87 100644
--- a/locale/fr/gitlab.po
+++ b/locale/fr/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-21 16:43-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:38-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -16,46 +16,86 @@ msgstr ""
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr " et"
+
msgid "%d commit"
msgid_plural "%d commits"
-msgstr[0] "%d validation"
-msgstr[1] "%d validations"
+msgstr[0] "%d commit"
+msgstr[1] "%d commits"
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] "%d commit de retard"
+msgstr[1] "%d commits de retard"
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] "%d exportateur"
+msgstr[1] "%d exportateurs"
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] "%d ticket"
+msgstr[1] "%d tickets"
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] "%d couche"
msgstr[1] "%d couches"
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] "%d demande de fusion"
+msgstr[1] "%d demandes de fusion"
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] "%d métrique"
+msgstr[1] "%d métriques"
+
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."
+msgstr[1] "%s commits supplémentaires ont été masqués afin d'éviter un problème de performance."
+
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr "%{actionText} et %{openOrClose} %{noteable}"
-msgid "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} a validé %{commit_timeago}"
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr "%{commit_author_link} a créé %{commit_timeago}"
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] "%{count} participant•e"
msgstr[1] "%{count} participant•e•s"
+msgid "%{loadingIcon} Started"
+msgstr "%{loadingIcon} Démarré"
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr "%{lock_path} est verrouillé par l’utilisateur GitLab %{lock_user_id}"
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
-msgstr "%{number_commits_behind} validations de retard sur %{default_branch}, %{number_commits_ahead} validations d'avance"
+msgstr "%{number_commits_behind} commits de retard sur %{default_branch}, %{number_commits_ahead} commits d'avance"
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "%{number_of_failures} sur %{maximum_failures} tentative(s). GitLab va vous permettre d'accéder à la prochaine tentative."
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "%{number_of_failures} échecs sur %{maximum_failures}. GitLab va bloquer l’accès pendant %{number_of_seconds} secondes."
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "%{number_of_failures} échecs sur %{maximum_failures}. GitLab ne va plus réessayer automatiquement. Réinitialisez les informations de stockage lorsque le problème est résolu."
+msgid "%{openOrClose} %{noteable}"
+msgstr "%{openOrClose} %{noteable}"
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name} : la tentative d’accès au stockage a échouée sur l’hôte :"
msgstr[1] "%{storage_name} : %{failed_attempts} tentatives d’accès au stockage ont échouées :"
+msgid "%{text} is available"
+msgstr "%{text} est disponible"
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(Lisez %{link} pour savoir comment l'installer)."
@@ -76,15 +116,30 @@ msgstr "1ère contribution !"
msgid "2FA enabled"
msgstr "2FA activé"
+msgid "<strong>Removes</strong> source branch"
+msgstr "<strong>Supprime</strong> la branche source"
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Un ensemble de graphiques concernant l’Intégration Continue (CI)"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr "Une nouvelle branche sera créée dans votre fourche et une nouvelle demande de fusion sera lancée."
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr "Un projet est l’endroit où vous hébergez vos fichiers (dépôt), planifiez votre travail (tickets) et publiez votre documentation (wiki), %{among_other_things_link}."
+
+msgid "A user with write access to the source branch selected this option"
+msgstr "Un•e utilisateur•rice avec un accès en écriture à la branche source a sélectionné cette option"
+
msgid "About auto deploy"
msgstr "À propos de l'auto-déploiement"
msgid "Abuse Reports"
msgstr "Rapports d’abus"
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr "Jetons d'Accès"
@@ -94,6 +149,9 @@ msgstr "L'accès aux stockages défaillants a été temporairement désactivé p
msgid "Account"
msgstr "Compte"
+msgid "Account and limit settings"
+msgstr "Paramètres de compte et de limitation"
+
msgid "Active"
msgstr "Actif"
@@ -112,26 +170,191 @@ msgstr "Ajouter un guide de contribution"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr "Ajouter des Webhooks de groupe et GitLab Enterprise Edition."
+msgid "Add Kubernetes cluster"
+msgstr "Ajouter un cluster Kubernetes"
+
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 Readme"
+msgstr "Ajouter un fichier Readme"
msgid "Add new directory"
msgstr "Ajouter un nouveau dossier"
+msgid "Add todo"
+msgstr "Ajouter à la liste à faire"
+
+msgid "AdminArea|Stop all jobs"
+msgstr "Arrêtez tous les travaux"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "Arrêtez tous les travaux?"
+
+msgid "AdminArea|Stop jobs"
+msgstr "Arrêtez les travaux"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr "L’arrêt des travaux a échoué"
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr "Vous êtes sur le point d’arrêter tous les travaux. Tous les travaux en cours seront interrompus."
+
msgid "AdminHealthPageLink|health page"
msgstr "État des services"
+msgid "AdminProjects|Delete"
+msgstr "Supprimer"
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr "Supprimer le projet %{projectName} ?"
+
+msgid "AdminProjects|Delete project"
+msgstr "Supprimer le projet"
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr "Spécifiez un domaine à utiliser par défaut pour les étapes Auto Review Apps et Auto Deploy de chaque projet."
+
+msgid "AdminUsers|Block user"
+msgstr "Bloquer l’utilisateur•rice"
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr "Supprimer l’utilisateur•rice %{username} et ses contributions ?"
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr "Supprimer l’utilisateur•rice %{username} ?"
+
+msgid "AdminUsers|Delete user"
+msgstr "Supprimer un•e utilisateur•rice"
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr "Supprimer l’utilisateur•rice et ses contributions"
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr "Pour confirmer, veuillez saisir %{projectName}"
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr "Pour confirmer, veuillez saisir %{username}"
+
+msgid "Advanced"
+msgstr "Avancé"
+
msgid "Advanced settings"
msgstr "Paramètres avancés"
msgid "All"
msgstr "Tous"
+msgid "All changes are committed"
+msgstr "Toutes les modifications sont validées"
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr "Toutes les fonctionnalités sont activées pour les projets vierges, à partir de modèles ou lors de l’importation, mais vous pouvez les désactiver ultérieurement dans les paramètres du projet."
+
+msgid "Allow edits from maintainers."
+msgstr "Autoriser les modifications par les mainteneur•se•s."
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr "Vous permet d’ajouter et de gérer des clusters Kubernetes."
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr "Alternativement, vous pouvez utiliser un %{personal_access_token_link}. Lorsque vous créez votre jeton d’accès, vous devrez sélectionner le champ <code>repo</code>, afin que nous puissions afficher une liste de vos dépôts publics et privés qui sont disponibles pour se connecter."
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr "Alternativement, vous pouvez utiliser un %{personal_access_token_link}. Lorsque vous créez votre jeton d’accès, vous devrez sélectionner le champ <code>repo</code>, afin que nous puissions afficher une liste de vos dépôts publics et privés qui sont disponibles pour être importés."
+
+msgid "An error occurred previewing the blob"
+msgstr "Une erreur s’est produite lors de la prévisualisation du blob"
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "Une erreur s’est produite lors de l’activation/désactivation de l’abonnement aux notifications"
+
+msgid "An error occurred when updating the issue weight"
+msgstr "Une erreur s'est produite lors de la mise à jour du poids du ticket"
+
+msgid "An error occurred while adding approver"
+msgstr "Une erreur s’est produite lors de l’ajout de l'approbateur•rice"
+
+msgid "An error occurred while detecting host keys"
+msgstr "Une erreur s’est produite lors de la détection des clés de l'hôte"
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr "Une erreur s’est produite lors de la révocation de la mise en avant de la fonctionnalité. Actualisez la page et essayez de la révoquer à nouveau."
+
+msgid "An error occurred while fetching markdown preview"
+msgstr "Une erreur s'est produite lors de la prévisualisation markdown"
+
+msgid "An error occurred while fetching sidebar data"
+msgstr "Une erreur s'est produite lors de la récupération des données de la barre latérale"
+
+msgid "An error occurred while fetching the pipeline."
+msgstr "Une erreur est survenue pendant la récupération du pipeline."
+
+msgid "An error occurred while getting projects"
+msgstr "Une erreur s’est produite lors de la récupération des projets"
+
+msgid "An error occurred while importing project"
+msgstr "Une erreur s’est produite lors de l’importation du projet"
+
+msgid "An error occurred while initializing path locks"
+msgstr "Une erreur s’est produite lors de l’initialisation des verrous de chemin"
+
+msgid "An error occurred while loading commits"
+msgstr "Une erreur s’est produite lors du chargement des commits"
+
+msgid "An error occurred while loading diff"
+msgstr "Une erreur s’est produite lors du chargement du diff"
+
+msgid "An error occurred while loading filenames"
+msgstr "Une erreur s’est produite lors du chargement des noms de fichiers"
+
+msgid "An error occurred while loading the file"
+msgstr "Une erreur s’est produite lors du chargement du fichier"
+
+msgid "An error occurred while making the request."
+msgstr "Une erreur s’est produite lors de la requête."
+
+msgid "An error occurred while removing approver"
+msgstr "Une erreur s’est produite lors de la suppression de l’approbateur•rice"
+
+msgid "An error occurred while rendering KaTeX"
+msgstr "Une erreur s’est produite lors du rendu de KaTeX"
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr "Une erreur s’est produite lors de la prévisualisation de la bannière"
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr "Une erreur s’est produite lors de la récupération de l’activité du calendrier"
+
+msgid "An error occurred while retrieving diff"
+msgstr "Une erreur s’est produite lors de la récupération du diff"
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr "Une erreur s’est produite lors de l’enregistrement du statut de remplacement LDAP. Veuillez réessayer."
+
+msgid "An error occurred while saving assignees"
+msgstr "Une erreur s’est produite lors de l’enregistrement des destinataires"
+
+msgid "An error occurred while validating username"
+msgstr "Une erreur s’est produite lors de la validation du nom d’utilisateur•rice"
+
msgid "An error occurred. Please try again."
-msgstr "Une erreur est survenue. Merci de réessayer."
+msgstr "Une erreur s’est produite. Veuillez réessayer."
+
+msgid "Any Label"
+msgstr "Tout label"
msgid "Appearance"
msgstr "Apparence"
@@ -139,48 +362,93 @@ msgstr "Apparence"
msgid "Applications"
msgstr "Applications"
+msgid "Apr"
+msgstr "Avr."
+
+msgid "April"
+msgstr "Avril"
+
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·e de vouloir supprimer ce pipeline programmé ?"
-msgid "Are you sure you want to discard your changes?"
-msgstr "Êtes-vous sûr·e de vouloir annuler vos modifications ?"
-
-msgid "Are you sure you want to leave this group?"
-msgstr "Êtes-vous sûr·e de vouloir quitter ce groupe ?"
-
msgid "Are you sure you want to reset registration token?"
msgstr "Êtes-vous sûr·e de vouloir réinitialiser le jeton d’inscription ?"
msgid "Are you sure you want to reset the health check token?"
msgstr "Êtes-vous sûr de vouloir réinitialiser le jeton de bilan de santé ?"
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr "Êtes-vous sûr•e de vouloir déverrouiller %{path_lock_path} ?"
+
msgid "Are you sure?"
msgstr "Êtes-vous certain ?"
msgid "Artifacts"
msgstr "Artéfacts"
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr "Attribuer une couleur personnalisée comme #FF0000"
+
+msgid "Assign labels"
+msgstr "Attribuer des labels"
+
+msgid "Assign milestone"
+msgstr "Attribuer un jalon"
+
+msgid "Assign to"
+msgstr "Assigner à"
+
+msgid "Assigned Issues"
+msgstr "Tickets assignés"
+
+msgid "Assigned Merge Requests"
+msgstr "Demandes de fusion assignées"
+
+msgid "Assigned to :name"
+msgstr "Assigné•e à :name"
+
+msgid "Assignee"
+msgstr "Assigné•e"
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Attachez un fichier par glisser &amp; déposer ou %{upload_link}"
+msgid "Aug"
+msgstr "Août"
+
+msgid "August"
+msgstr "Août"
+
msgid "Authentication Log"
msgstr "Journal d'authentification"
msgid "Author"
msgstr "Auteur"
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
-msgstr "Les applications de révision automatique et le déploiement automatique requièrent un nom de domaine et un %{kubernetes} pour fonctionner correctement."
+msgid "Authors: %{authors}"
+msgstr "Auteur•e•s : %{authors}"
+
+msgid "Auto DevOps enabled"
+msgstr "Auto DevOps activé"
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr "Auto Review Apps et Auto Deploy ont besoin d’un %{kubernetes} qui fonctionne correctement."
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr "Auto Review Apps et Auto Deploy ont besoin d’un nom de domaine et d’un %{kubernetes} qui fonctionne correctement."
msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr "Les applications de révision automatique et de déploiement automatique requièrent un nom de domaine pour fonctionner correctement."
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
-msgstr "Les applications de révision automatique et de déploiement automatique requièrent un %{kubernetes} pour fonctionner correctement."
-
msgid "AutoDevOps|Auto DevOps (Beta)"
msgstr "Auto DevOps (Béta)"
@@ -196,8 +464,32 @@ msgstr "AutoDevOps vous permet de construire, tester et déployer automatiquemen
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr "En savoir plus dans %{link_to_documentation}"
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
-msgstr "Vous pouvez activer %{link_to_settings} pour ce projet."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr "Vous pouvez automatiquement générer et tester votre application si vous %{link_to_auto_devops_settings} pour ce projet. Vous pouvez aussi la déployer automatiquement, si vous %{link_to_add_kubernetes_cluster}."
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr "ajouter un cluster Kubernetes"
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr "activer Auto DevOps (Bêta)"
+
+msgid "Available"
+msgstr "Disponible"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr "L’avatar sera supprimé. Êtes-vous sûr•e ?"
+
+msgid "Average per day: %{average}"
+msgstr "Moyenne par jour : %{average}"
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr "Commencer avec le commit sélectionné"
msgid "Billing"
msgstr "Facturation"
@@ -218,7 +510,7 @@ msgid "BillingPlans|Downgrade"
msgstr "Retour à un forfait inférieur"
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
-msgstr "En savoir plus sur chaque abonnement en lisant nos %{faq_link}."
+msgstr "En savoir plus sur chaque forfait en lisant nos %{faq_link}."
msgid "BillingPlans|Manage plan"
msgstr "Gérer l'abonnement"
@@ -227,13 +519,13 @@ msgid "BillingPlans|Please contact %{customer_support_link} in that case."
msgstr "Merci de contacter %{customer_support_link} à ce sujet."
msgid "BillingPlans|See all %{plan_name} features"
-msgstr "Voir toutes les fonctionnalités de l’abonnement %{plan_name}"
+msgstr "Voir toutes les fonctionnalités du forfait %{plan_name}"
msgid "BillingPlans|This group uses the plan associated with its parent group."
-msgstr "Ce groupe utilise l’abonnement associé à son groupe parent."
+msgstr "Ce groupe utilise le forfait associé à son groupe parent."
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
-msgstr "Pour gérer le plan de ce groupe, visitez la section facturation de %{parent_billing_page_link}."
+msgstr "Pour gérer l‘abonnement de ce groupe, visitez la section facturation de %{parent_billing_page_link}."
msgid "BillingPlans|Upgrade"
msgstr "Mise à niveau"
@@ -242,21 +534,21 @@ msgid "BillingPlans|You are currently on the %{plan_link} plan."
msgstr "Vous êtes actuellement abonné·e au forfait %{plan_link}."
msgid "BillingPlans|frequently asked questions"
-msgstr "Foire aux questions"
+msgstr "foire aux questions"
msgid "BillingPlans|monthly"
-msgstr "Mensuellement"
+msgstr "mensuel"
msgid "BillingPlans|paid annually at %{price_per_year}"
-msgstr "au prix annuel de %{price_per_year}"
+msgstr "payé annuellement pour %{price_per_year}"
msgid "BillingPlans|per user"
msgstr "par utilisateur"
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Branche"
-msgstr[1] "Branches"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+msgstr[0] "Branche (%{branch_count})"
+msgstr[1] "Branches (%{branch_count})"
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}"
@@ -264,6 +556,12 @@ msgstr "La branche <strong>%{branch_name}</strong> a été créée. Pour mettre
msgid "Branch has changed"
msgstr "La branche a été modifiée"
+msgid "Branch is already taken"
+msgstr "Ce nom de branche existe déjà"
+
+msgid "Branch name"
+msgstr "Nom de la branche"
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "Rechercher la branche"
@@ -273,8 +571,17 @@ msgstr "Changer de branche"
msgid "Branches"
msgstr "Branches"
+msgid "Branches|Active"
+msgstr "Active"
+
+msgid "Branches|Active branches"
+msgstr "Branches actives"
+
+msgid "Branches|All"
+msgstr "Toutes"
+
msgid "Branches|Cant find HEAD commit for this branch"
-msgstr "Impossible de trouver la validation HEAD pour cette branche"
+msgstr "Impossible de trouver le commit HEAD pour cette branche"
msgid "Branches|Compare"
msgstr "Comparer"
@@ -318,12 +625,39 @@ msgstr "Une fois que vous aurez confirmé et cliqué sur %{delete_protected_bran
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr "Seulement un maître ou un propriétaire du projet peut supprimer une branche protégée"
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
-msgstr "Les branches protégées peuvent être gérées dans %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr "Vue d’ensemble"
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr "Les branches protégées peuvent être gérées dans %{project_settings_link}."
+
+msgid "Branches|Show active branches"
+msgstr "Afficher les branches actives"
+
+msgid "Branches|Show all branches"
+msgstr "Afficher toutes les branches"
+
+msgid "Branches|Show more active branches"
+msgstr "Afficher plus de branches actives"
+
+msgid "Branches|Show more stale branches"
+msgstr "Afficher plus de branches périmées"
+
+msgid "Branches|Show overview of the branches"
+msgstr "Afficher l’aperçu des branches"
+
+msgid "Branches|Show stale branches"
+msgstr "Afficher les branches périmées"
msgid "Branches|Sort by"
msgstr "Trier par"
+msgid "Branches|Stale"
+msgstr "Périmée"
+
+msgid "Branches|Stale branches"
+msgstr "Branches périmées"
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr "Cette branche ne peut pas être mise à jour automatiquement car elle a dévié par rapport à son dépôt en amont."
@@ -369,14 +703,23 @@ msgstr "Parcourir les fichiers"
msgid "Browse files"
msgstr "Parcourir les fichiers"
+msgid "Business"
+msgstr "Entreprise"
+
msgid "ByAuthor|by"
msgstr "par"
msgid "CI / CD"
msgstr "Intégration continu / Déploiement continu"
-msgid "CI configuration"
-msgstr "Configuration de l'intégration continue (CI)"
+msgid "CI/CD"
+msgstr "CI/CD"
+
+msgid "CI/CD configuration"
+msgstr "Configuration CI/CD"
+
+msgid "CI/CD for external repo"
+msgstr "CI / CD pour dépôt externe"
msgid "CICD|Jobs"
msgstr "Tâches"
@@ -384,12 +727,21 @@ msgstr "Tâches"
msgid "Cancel"
msgstr "Annuler"
-msgid "Cancel edit"
-msgstr "Annuler modification"
+msgid "Cannot be merged automatically"
+msgstr "Ne peut être fusionnée automatiquement"
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr "Impossible de modifier le cluster géré par Kubernetes"
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
msgstr "Changer le poids"
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "Picorer dans la branche"
@@ -402,23 +754,56 @@ msgstr "Picorer"
msgid "ChangeTypeAction|Revert"
msgstr "Défaire"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr "Cela va créer un nouveau commit afin de défaire les modifications existantes."
+
msgid "Changelog"
msgstr "Journal des modifications"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr "Les modifications sont affichées comme si la révision <b>source</b> était fusionnée dans la révision<b>cible</b>."
+
msgid "Charts"
msgstr "Statistiques"
msgid "Chat"
msgstr "Chat"
+msgid "Check interval"
+msgstr "Intervalle de vérification"
+
+msgid "Checking %{text} availability…"
+msgstr "Vérification de la disponibilité de %{text}…"
+
+msgid "Checking branch availability..."
+msgstr "Vérification de la disponibilité du nom de branche..."
+
msgid "Cherry-pick this commit"
-msgstr "Picorer cette validation"
+msgstr "Picorer ce commit"
msgid "Cherry-pick this merge request"
msgstr "Picorer cette demande de fusion"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
-msgstr "Choisissez quels groupes vous souhaitez répliquer sur le nœud secondaire. Laissez vide pour tous les répliquer."
+msgid "Choose File ..."
+msgstr "Choisir le fichier…"
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr "Choisissez une branche / tag (par exemple %{master}) ou entrez un commit (par exemple %{sha}) pour voir ce qui a changé ou pour créer une demande de fusion."
+
+msgid "Choose file..."
+msgstr "Choisir le fichier…"
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr "Choisissez les groupes que vous souhaitez synchroniser avec ce nœud secondaire."
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr "Choisissez à quels dépôts vous voulez connecter et exécuter des pipelines CI/CD."
+
+msgid "Choose which repositories you want to import."
+msgstr "Choisissez les dépôts que vous voulez importer."
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+msgstr "Choisissez les partitions que vous souhaitez synchroniser avec ce nœud secondaire."
msgid "CiStatusLabel|canceled"
msgstr "annulé"
@@ -474,56 +859,146 @@ msgstr "ignoré"
msgid "CiStatus|running"
msgstr "en cours"
+msgid "CiVariables|Input variable key"
+msgstr "Nom de la variable"
+
+msgid "CiVariables|Input variable value"
+msgstr "Valeur de la variable"
+
+msgid "CiVariables|Remove variable row"
+msgstr "Supprimer cette variable"
+
+msgid "CiVariable|* (All environments)"
+msgstr "* (Tous les environnements)"
+
+msgid "CiVariable|All environments"
+msgstr "Tous les environnements"
+
+msgid "CiVariable|Create wildcard"
+msgstr "Créer un caractère générique"
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr "Une erreur s’est produite pendant la sauvegarde des variables"
+
+msgid "CiVariable|New environment"
+msgstr "Nouvel environnement"
+
+msgid "CiVariable|Protected"
+msgstr "Protégée"
+
+msgid "CiVariable|Search environments"
+msgstr "Chercher dans les environnements"
+
+msgid "CiVariable|Toggle protected"
+msgstr "Changer l’état de protection"
+
+msgid "CiVariable|Validation failed"
+msgstr "La validation a échoué"
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr "CircuitBreaker API"
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr "Cliquez sur le bouton ci-dessous pour lancer le processus d’installation en accédant à la page Kubernetes"
+
+msgid "Click to expand text"
+msgstr "Cliquez pour agrandir le texte"
+
+msgid "Client authentication certificate"
+msgstr "Certificat d’authentification du client"
+
+msgid "Client authentication key"
+msgstr "Clé d’authentification du client"
+
+msgid "Client authentication key password"
+msgstr "Mot de passe de la clé d’authentification client"
+
msgid "Clone repository"
msgstr "Cloner le dépôt"
msgid "Close"
msgstr "Fermer"
-msgid "Cluster"
-msgstr "Cluster"
+msgid "Closed"
+msgstr "Fermée"
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
-msgstr "Un %{link_to_container_project} doit avoir été créé pour ce compte"
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr "%{appList} a été installé avec succès sur votre cluster Kubernetes"
-msgid "ClusterIntegration|Cluster details"
-msgstr "Détails du cluster"
+msgid "ClusterIntegration|API URL"
+msgstr "URL de l'API"
-msgid "ClusterIntegration|Cluster integration"
-msgstr "Intégration du cluster"
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr "Ajouter un cluster Kubernetes"
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr "Ajouter un cluster Kubernetes existant"
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr "Options avancées sur l’intégration de ce cluster Kubernetes"
+
+msgid "ClusterIntegration|Applications"
+msgstr "Applications"
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
-msgstr "L'intégration du cluster est désactivée pour ce projet."
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr "Êtes-vous sûr•e de vouloir supprimer l'intégration de ce cluster Kubernetes? Cela ne supprimera pas votre cluster Kubernetes."
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
-msgstr "L'intégration du cluster est activée pour ce projet."
+msgid "ClusterIntegration|CA Certificate"
+msgstr "Certificat d‘autorité de certification"
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
-msgstr "L'intégration de cluster est activée pour ce projet. La désactivation de cette intégration n’affectera pas votre cluster, il coupera temporairement la connexion de GitLab à celui-ci."
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr "Paquet de l‘Autorité de certification (format PEM)"
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
-msgstr "Le cluster est en cours de création sur Google Kubernetes Engine…"
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr "Choisissez comment configurer l’intégration de cluster Kubernetes"
-msgid "ClusterIntegration|Cluster name"
-msgstr "Nom du cluster"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr "Choisissez les environnements de votre projet qui utiliseront ce cluster Kubernetes."
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
-msgstr "Le cluster a été correctement créé sur Google Kubernetes Engine"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr "Contrôlez l’intégration de votre cluster Kubernetes avec GitLab"
-msgid "ClusterIntegration|Copy cluster name"
-msgstr "Copier le nom du cluster"
+msgid "ClusterIntegration|Copy API URL"
+msgstr "Copier l’URL de l’API"
-msgid "ClusterIntegration|Create cluster"
-msgstr "Créer le cluster"
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr "Copier le certificat CA"
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
-msgstr "Créer un nouveau cluster sur Google Kubernetes Engine"
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr "Copier l’adresse IP entrante dans le presse-papiers"
-msgid "ClusterIntegration|Enable cluster integration"
-msgstr "Activer l’intégration du cluster"
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr "Copier le nom du cluster Kubernetes"
+
+msgid "ClusterIntegration|Copy Token"
+msgstr "Copier le jeton"
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr "Créer un cluster Kubernetes"
+
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr "Créer un cluster Kubernetes sur Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr "Créer un nouveau cluster Kubernetes sur Google Kubernetes Engine directement depuis GitLab"
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr "Créer sur GKE"
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr "Entrer les détails pour le cluster Kubernetes existant"
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr "Entrez les détails de votre cluster Kubernetes"
+
+msgid "ClusterIntegration|Environment scope"
+msgstr "Portée de l’environnement"
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr "Intégration GitLab"
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr "Éxécuteur GitLab"
msgid "ClusterIntegration|Google Cloud Platform project ID"
msgstr "ID de projet Google Cloud Platform"
@@ -532,46 +1007,148 @@ msgid "ClusterIntegration|Google Kubernetes Engine"
msgstr "Google Kubernetes Engine"
msgid "ClusterIntegration|Google Kubernetes Engine project"
-msgstr "Google Kubernetes Engine"
+msgstr "Projet Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Helm Tiller"
+msgstr "Helm Tiller"
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr "Afin d’afficher l’état du cluster, nous devons mettre votre cluster à disposition de Prometheus pour récupérer les données nécessaires."
+
+msgid "ClusterIntegration|Ingress"
+msgstr "Ingress"
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr "Adresse IP entrante"
+
+msgid "ClusterIntegration|Install"
+msgstr "Installer"
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr "Installer Prometheus"
+
+msgid "ClusterIntegration|Installed"
+msgstr "Installé"
+
+msgid "ClusterIntegration|Installing"
+msgstr "En cours d’installation"
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr "Intégrez l’automatisation du cluster Kubernetes"
+
+msgid "ClusterIntegration|Integration status"
+msgstr "Statut d’intégration"
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr "Cluster Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr "Détails du cluster Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr "État du cluster Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr "Intégration d’un cluster Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr "L’intégration de cluster Kubernetes est désactivée pour ce projet."
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr "L’intégration de cluster Kubernetes est activée pour ce projet."
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr "L’intégration de cluster Kubernetes est activée pour ce projet. La désactivation de cette intégration n’affectera pas votre cluster Kubernetes, elle ne désactivera que temporairement la connexion de GitLab."
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr "Le cluster Kubernetes est en cours de création sur Google Kubernetes Engine…"
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr "Nom du cluster Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr "Le cluster Kubernetes a été créé avec succès sur Google Kubernetes Engine. Actualisez la page pour voir les détails du cluster Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr "Les clusters Kubernetes vous permettent d’utiliser des applications de révision, de déployer vos applications, d’exécuter vos pipelines et bien plus encore. %{link_to_help_page}"
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr "Les clusters Kubernetes peuvent être utilisés pour déployer des applications et fournir des applications de révision pour ce projet"
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr "En savoir plus sur %{link_to_documentation}"
+msgid "ClusterIntegration|Learn more about environments"
+msgstr "En savoir plus sur les environnements"
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr "En savoir plus sur la configuration de la sécurité"
+
msgid "ClusterIntegration|Machine type"
msgstr "Type de machine"
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
-msgstr "Assurez-vous que votre compte %{link_to_requirements} pour créer des clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr "Assurez-vous que votre compte %{link_to_requirements} pour créer des clusters Kubernetes"
+
+msgid "ClusterIntegration|Manage"
+msgstr "Gérer"
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr "Gérez votre cluster Kubernetes en visitant %{link_gke}"
+
+msgid "ClusterIntegration|More information"
+msgstr "Plus d’informations"
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
-msgstr "Gérer l’intégration du cluster sur votre projet GitLab"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr "Plusieurs clusters Kubernetes sont disponibles dans GitLab Enterprise Edition Premium et Ultimate"
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
-msgstr "Gérer votre cluster en visitant le lien %{link_gke}"
+msgid "ClusterIntegration|Note:"
+msgstr "Remarque :"
msgid "ClusterIntegration|Number of nodes"
msgstr "Nombre de nœuds"
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr "Veuillez entrer les informations d’accès de votre cluster Kubernetes. Si vous avez besoin d'aide, vous pouvez lire notre %{link_to_help_page} sur Kubernetes"
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr "Veuillez vous assurer que votre compte Google répond aux exigences suivantes : "
+msgid "ClusterIntegration|Project ID"
+msgstr "ID du projet"
+
+msgid "ClusterIntegration|Project namespace"
+msgstr "Espace de noms du projet"
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr "Espace de noms du projet (facultatif, unique)"
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr "Lire notre %{link_to_help_page} sur l’intégration d’un cluster."
+msgid "ClusterIntegration|Prometheus"
+msgstr "Prometheus"
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr "Lisez notre %{link_to_help_page} sur l’intégration d’un cluster Kubernetes."
-msgid "ClusterIntegration|Remove cluster integration"
-msgstr "Retirer l’intégration du cluster"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr "Supprimer l’intégration du cluster Kubernetes"
msgid "ClusterIntegration|Remove integration"
msgstr "Retirer l’intégration"
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
-msgstr "Supprimer l'intégration du cluster supprimera sa configuration que vous avez ajoutée pour ce projet. Cela ne supprimera pas votre projet."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr "Supprimer la configuration de ce cluster Kubernetes de ce projet. Cela ne supprimera pas votre cluster Kubernetes actuel."
-msgid "ClusterIntegration|See and edit the details for your cluster"
-msgstr "Voir et modifier les détails de votre cluster"
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr "La demande de lancement d'installation a échoué"
+
+msgid "ClusterIntegration|Save changes"
+msgstr "Enregistrer les modifications"
+
+msgid "ClusterIntegration|Security"
+msgstr "Sécurité"
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr "Voir et modifier les détails de votre cluster Kubernetes"
msgid "ClusterIntegration|See machine types"
msgstr "Voir les types de machine"
@@ -582,17 +1159,38 @@ msgstr "Voir vos projets"
msgid "ClusterIntegration|See zones"
msgstr "Voir les zones"
+msgid "ClusterIntegration|Service token"
+msgstr "Jeton de service"
+
+msgid "ClusterIntegration|Show"
+msgstr "Afficher"
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr "Un problème est survenu de notre côté."
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
-msgstr "Un problème est survenu lors de la création de votre cluster sur Google Kubernetes Engine."
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr "Une erreur s’est produite lors de la création de votre cluster Kubernetes sur Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr "Une erreur s’est produite lors de l'installation de %{title}"
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr "La configuration par défaut du cluster permet d’accéder à un large éventail de fonctionnalités nécessaires pour construire et déployer, avec succès, une application conteneurisée."
-msgid "ClusterIntegration|Toggle Cluster"
-msgstr "Activer/désactiver le cluster"
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr "Ce compte doit disposer des autorisations pour créer un cluster Kubernetes dans le %{link_to_container_project} spécifié ci-dessous"
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
-msgstr "Avec un cluster associé à ce projet, vous pouvez utiliser des applications de revue, déployer vos applications, exécuter vos pipelines et bien plus encore, de manière très simple."
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr "Activer le cluster Kubernetes"
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr "Activer/désactiver le cluster Kubernetes"
+
+msgid "ClusterIntegration|Token"
+msgstr "Jeton"
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr "Avec un cluster Kubernetes associé à ce projet, vous pouvez utiliser des applications de révision, déployer vos applications, exécuter vos pipelines, et bien plus encore."
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
msgstr "Votre compte doit disposer de %{link_to_kubernetes_engine}"
@@ -601,63 +1199,168 @@ msgid "ClusterIntegration|Zone"
msgstr "Zone"
msgid "ClusterIntegration|access to Google Kubernetes Engine"
-msgstr "Accéder à Google Kubernetes Engine"
+msgstr "Accèder à Google Kubernetes Engine"
-msgid "ClusterIntegration|cluster"
-msgstr "cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr "vérifiez le prix ici"
+
+msgid "ClusterIntegration|documentation"
+msgstr "documentation"
msgid "ClusterIntegration|help page"
msgstr "page d’aide"
+msgid "ClusterIntegration|installing applications"
+msgstr "Installation des applications"
+
msgid "ClusterIntegration|meets the requirements"
msgstr "répond aux exigences"
msgid "ClusterIntegration|properly configured"
msgstr "correctement configuré"
+msgid "Collapse"
+msgstr "Réduire"
+
+msgid "Comment and resolve discussion"
+msgstr "Commenter et résoudre la discussion"
+
+msgid "Comment and unresolve discussion"
+msgstr "Commenter et marquer la discussion comme non résolue"
+
msgid "Comments"
msgstr "Commentaires"
msgid "Commit"
msgid_plural "Commits"
-msgstr[0] "Validation"
-msgstr[1] "Validations"
+msgstr[0] "Commit"
+msgstr[1] "Commits"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
-msgstr[0] "Valider %d fichier"
-msgstr[1] "Valider %d fichiers"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] "Commit (%{commit_count})"
+msgstr[1] "Commits (%{commit_count})"
msgid "Commit Message"
-msgstr "Message de validation"
+msgstr "Message du commit"
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"
+msgstr "Message de commit"
+
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr "Statistiques des commits pour %{ref} %{start_time} - %{end_time}"
+
+msgid "Commit to %{branchName} branch"
+msgstr "Valider dans la branche %{branchName}"
msgid "CommitBoxTitle|Commit"
-msgstr "Validation"
+msgstr "Commit"
msgid "CommitMessage|Add %{file_name}"
msgstr "Ajout de %{file_name}"
msgid "Commits"
-msgstr "Validations"
+msgstr "Commits"
msgid "Commits feed"
-msgstr "Flux de validations"
+msgstr "Flux des commits"
+
+msgid "Commits per day hour (UTC)"
+msgstr "Commits par heure du jour (UTC)"
+
+msgid "Commits per day of month"
+msgstr "Commits par jour du mois"
+
+msgid "Commits per weekday"
+msgstr "Commits par jour de la semaine"
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr "Une erreur s'est produite lors de la récupération des données de demandes de fusion."
+
+msgid "Commits|Commit: %{commitText}"
+msgstr "Commit : %{commitText}"
msgid "Commits|History"
msgstr "Historique"
+msgid "Commits|No related merge requests found"
+msgstr "Aucune demande de fusion associée trouvée"
+
msgid "Committed by"
msgstr "Validé par"
msgid "Compare"
msgstr "Comparer"
+msgid "Compare Git revisions"
+msgstr "Comparer les révisions Git"
+
+msgid "Compare Revisions"
+msgstr "Comparer les révisions"
+
+msgid "Compare changes with the last commit"
+msgstr "Comparer les changements avec le dernier commit"
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr "%{source_branch} et %{target_branch} sont identiques."
+
+msgid "CompareBranches|Compare"
+msgstr "Comparer"
+
+msgid "CompareBranches|Source"
+msgstr "Source"
+
+msgid "CompareBranches|Target"
+msgstr "Cible"
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr "Il n’y a rien à comparer."
+
+msgid "Confidential"
+msgstr "Confidentiel"
+
+msgid "Confidentiality"
+msgstr "Confidentialité"
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr "Configurez la façon dont un•e utilisateur•rice crée un nouveau compte."
+
+msgid "Connect"
+msgstr "Connecter"
+
+msgid "Connect all repositories"
+msgstr "Connecter tous les dépôts"
+
+msgid "Connect repositories from GitHub"
+msgstr "Se connecter à des dépôts à partir de GitHub"
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr "Connectez vos dépôts externes et les pipelines CI / CD s’exécuteront pour les nouveaux commits. Un projet GitLab sera créé avec uniquement les fonctionnalités CI / CD activées."
+
+msgid "Connecting..."
+msgstr "Connexion en cours…"
+
msgid "Container Registry"
msgstr "Registre de conteneur"
@@ -703,12 +1406,30 @@ msgstr "Utilisez des noms d’images différents"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr "Avec le registre de conteneur Docker intégré à GitLab, chaque projet peut avoir son propre espace pour stocker ses images Docker."
+msgid "Continuous Integration and Deployment"
+msgstr "Intégration et déploiement continus"
+
+msgid "Contribution"
+msgstr "Contribution"
+
msgid "Contribution guide"
msgstr "Guide de contribution"
msgid "Contributors"
msgstr "Contributeurs"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr "%{startDate} - %{endDate}"
+
+msgid "ContributorsPage|Building repository graph."
+msgstr "Construction du graphique du dépôt."
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr "Commit sur %{branch_name}, à l'exclusion des commits de fusion. Limité à 6 000 commits."
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr "Veuillez patienter, cette page va être automatiquement actualisée."
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr "Contrôler la concurrence maximale des remplacements de fichier-joint LFS pour ce nœud secondaire"
@@ -721,27 +1442,60 @@ msgstr "Copier la clé publique SSH dans le presse-papier"
msgid "Copy URL to clipboard"
msgstr "Copier l'URL dans le presse-papier"
+msgid "Copy branch name to clipboard"
+msgstr "Copier le nom de la branche dans le presse-papiers"
+
+msgid "Copy command to clipboard"
+msgstr "Copier la commande dans le presse-papiers"
+
msgid "Copy commit SHA to clipboard"
-msgstr "Copier le SHA de la validation"
+msgstr "Copier le SHA du commit"
+
+msgid "Copy reference to clipboard"
+msgstr "Copier la référence dans le presse-papiers"
+
+msgid "Create"
+msgstr "Créer"
msgid "Create New Directory"
msgstr "Créer un nouveau dossier"
+msgid "Create a new branch"
+msgstr "Créer une nouvelle branche"
+
+msgid "Create a new branch and merge request"
+msgstr "Créer une nouvelle branche et demande de fusion"
+
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 branch"
+msgstr "Créer une branche"
+
msgid "Create directory"
msgstr "Créer un dossier"
-msgid "Create empty bare repository"
+msgid "Create empty repository"
msgstr "Créer un dépôt vide"
+msgid "Create epic"
+msgstr "Créer l'épopée"
+
msgid "Create file"
msgstr "Créer un fichier"
+msgid "Create group label"
+msgstr "Créer un label de groupe"
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr "Créer des listes à partir de labels. Les tickets avec ce label apparaissent dans cette liste."
+
msgid "Create merge request"
msgstr "Créer une demande de fusion"
+msgid "Create merge request and branch"
+msgstr "Créer une demande de fusion et une branche"
+
msgid "Create new branch"
msgstr "Créer une nouvelle branche"
@@ -751,9 +1505,15 @@ msgstr "Créer un nouveau dossier"
msgid "Create new file"
msgstr "Créer un nouveau fichier"
+msgid "Create new label"
+msgstr "Créer un nouveau label"
+
msgid "Create new..."
msgstr "Créer nouveau..."
+msgid "Create project label"
+msgstr "Créer un label de projet"
+
msgid "CreateNewFork|Fork"
msgstr "Fourcher"
@@ -763,24 +1523,36 @@ msgstr "Tag"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "Créer un jeton d'accès personnel"
+msgid "Creates a new branch from %{branchName}"
+msgstr "Crée une nouvelle branche à partir de %{branchName}"
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr "Crée une nouvelle branche à partir de %{branchName} et redirige pour créer une nouvelle demande de fusion"
+
+msgid "Creating epic"
+msgstr "Création de l'épopée en cours"
+
msgid "Cron Timezone"
msgstr "Fuseau horaire de Cron"
msgid "Cron syntax"
msgstr "Syntaxe Cron"
+msgid "Current node"
+msgstr "NÅ“ud actuel"
+
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 "Customize colors"
+msgstr ""
+
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"
@@ -808,6 +1580,15 @@ msgstr "Tous"
msgid "DashboardProjects|Personal"
msgstr "Personnels"
+msgid "Dec"
+msgstr "Déc."
+
+msgid "December"
+msgstr "Décembre"
+
+msgid "Default classification label"
+msgstr "Label de classement par défaut"
+
msgid "Define a custom pattern with cron syntax"
msgstr "Définir un schéma personnalisé avec une syntaxe Cron"
@@ -831,11 +1612,20 @@ msgstr "Les modèles de description permettent de définir des modèles spécifi
msgid "Details"
msgstr "Détails"
+msgid "Diffs|No file name available"
+msgstr "Aucun nom de fichier disponible"
+
msgid "Directory name"
msgstr "Nom du dossier"
-msgid "Discard changes"
-msgstr "Supprimer les modifications"
+msgid "Disable"
+msgstr "Désactiver"
+
+msgid "Discard draft"
+msgstr "Supprimer le brouillon"
+
+msgid "Discover GitLab Geo."
+msgstr "Découvrez GitLab Geo."
msgid "Dismiss Cycle Analytics introduction box"
msgstr "Passer l’introduction Cycle Analytics"
@@ -843,9 +1633,15 @@ msgstr "Passer l’introduction Cycle Analytics"
msgid "Dismiss Merge Request promotion"
msgstr "Rejeter la promotion de la demande de fusion"
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "Ne plus montrer"
+msgid "Done"
+msgstr "Fait"
+
msgid "Download"
msgstr "Télécharger"
@@ -873,15 +1669,171 @@ msgstr "Diff simple"
msgid "DownloadSource|Download"
msgstr "Télécharger"
+msgid "Downvotes"
+msgstr "Votes négatifs"
+
+msgid "Due date"
+msgstr "Date d’échéance"
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Éditer"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Éditer le pipeline programmé %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr "Modifier les fichiers dans l'éditeur et valider les modifications ici"
+
+msgid "Editing"
+msgstr "En cours de modification"
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr "Courriels"
+msgid "Enable"
+msgstr "Activer"
+
+msgid "Enable Auto DevOps"
+msgstr "Activer Auto DevOps"
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr "Activer et configurer les métriques InfluxDB."
+
+msgid "Enable and configure Prometheus metrics."
+msgstr "Activer et configurer les métriques Prometheus."
+
+msgid "Enable classification control using an external service"
+msgstr "Activer le contrôle de classification à l’aide d’un service externe"
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr "Une erreur s‘est produite lors de la récupération des environnements."
+
+msgid "Environments|An error occurred while making the request."
+msgstr "Une erreur s’est produite lors de la requête."
+
+msgid "Environments|Commit"
+msgstr "Commit"
+
+msgid "Environments|Deployment"
+msgstr "Déploiement"
+
+msgid "Environments|Environment"
+msgstr "Environnement"
+
+msgid "Environments|Environments"
+msgstr "Environnements"
+
+msgid "Environments|Job"
+msgstr "Tâche"
+
+msgid "Environments|New environment"
+msgstr "Nouvel environnement"
+
+msgid "Environments|No deployments yet"
+msgstr "Aucun déploiement pour le moment"
+
+msgid "Environments|Open"
+msgstr "Ouvert"
+
+msgid "Environments|Re-deploy"
+msgstr "Re-déployer"
+
+msgid "Environments|Read more about environments"
+msgstr "En savoir plus sur les environnements"
+
+msgid "Environments|Rollback"
+msgstr "Revenir en arrière"
+
+msgid "Environments|Show all"
+msgstr "Afficher tous"
+
+msgid "Environments|Updated"
+msgstr "Mise à jour"
+
+msgid "Environments|You don't have any environments right now."
+msgstr "Vous n’avez aucun environnement pour le moment."
+
+msgid "Epic will be removed! Are you sure?"
+msgstr "L’épopée sera supprimée ! Êtes-vous sûr•e ?"
+
+msgid "Epics"
+msgstr "Épopées"
+
+msgid "Epics Roadmap"
+msgstr "Feuille de route des épopées"
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "Les épopées vous permettent de gérer votre portefeuille de projets plus efficacement et avec moins d'effort"
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr "Erreur lors de la vérification des données de branche. Veuillez réessayer."
+
+msgid "Error committing changes. Please try again."
+msgstr "Erreur lors de la validation des modifications. Veuillez réessayer."
+
+msgid "Error creating epic"
+msgstr "Erreur lors de la création de l’épopée"
+
+msgid "Error fetching contributors data."
+msgstr "Erreur lors de l’extraction des données des contributeur•rice•s."
+
+msgid "Error fetching labels."
+msgstr "Erreur lors de la récupération des labels."
+
+msgid "Error fetching network graph."
+msgstr "Erreur lors de la récupération du graphique du réseau."
+
+msgid "Error fetching refs"
+msgstr "Erreur lors de la récupération des refs"
+
+msgid "Error fetching usage ping data."
+msgstr "Erreur lors de la récupération des données d’utilisation."
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr "Une erreur s’est produite lors de l’activation/désactivation de l’abonnement aux notifications"
+
+msgid "Error saving label update."
+msgstr "Erreur lors de la mise à jour du label."
+
+msgid "Error updating status for all todos."
+msgstr "Erreur lors de la mise à jour de l’état de la liste de tâches à faire."
+
+msgid "Error updating todo status."
+msgstr "Erreur lors de la mise à jour du statut de la tâche à faire."
+
msgid "EventFilterBy|Filter by all"
msgstr "Aucun filtre"
@@ -909,26 +1861,77 @@ msgstr "Chaque mois (le 1er à 4:00 du matin)"
msgid "Every week (Sundays at 4:00am)"
msgstr "Chaque semaine (dimanche à 4h00 du matin)"
+msgid "Expand"
+msgstr "Ouvrir"
+
msgid "Explore projects"
msgstr "Explorer les projets"
msgid "Explore public groups"
msgstr "Explorer les groupes publics"
+msgid "External Classification Policy Authorization"
+msgstr "Autorisation de politique de classification externe"
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr "L’autorisation externe a refusé l’accès à ce projet"
+
+msgid "External authorization request timeout"
+msgstr "Expiration de la demande d’autorisation externe"
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr "Label de classification"
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr "Label de classification"
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr "Quand aucun label de classification n’est défini, le label par défaut `%{default_label}` sera utilisé."
+
+msgid "Failed"
+msgstr "Échec"
+
+msgid "Failed Jobs"
+msgstr "Tâches ayant échoué"
+
msgid "Failed to change the owner"
msgstr "Échec du changement de propriétaire"
+msgid "Failed to remove issue from board, please try again."
+msgstr "Impossible de supprimer le ticket du tableau, veuillez réessayer."
+
msgid "Failed to remove the pipeline schedule"
msgstr "Échec de la suppression du pipeline programmé"
+msgid "Failed to update issues, please try again."
+msgstr "Échec de la mise à jour du ticket. Veuillez réessayer."
+
+msgid "Feb"
+msgstr "Févr."
+
+msgid "February"
+msgstr "Février"
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr "Les champs sur cette page sont désormais non modifiables, vous pouvez configurer"
+
msgid "File name"
msgstr "Nom du fichier"
msgid "Files"
msgstr "Fichiers"
+msgid "Files (%{human_size})"
+msgstr "Fichiers (%{human_size})"
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
-msgstr "Filtrer par message de validation"
+msgstr "Filtrer par message de commit"
msgid "Find by path"
msgstr "Rechercher par chemin"
@@ -936,12 +1939,21 @@ msgstr "Rechercher par chemin"
msgid "Find file"
msgstr "Rechercher un fichier"
+msgid "Finished"
+msgstr "Terminé"
+
msgid "FirstPushedBy|First"
msgstr "En premier"
msgid "FirstPushedBy|pushed by"
msgstr "poussé par"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Fourche"
@@ -953,26 +1965,179 @@ msgstr "Fourché depuis"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr "Fourché depuis %{project_name} (supprimé)"
+msgid "Forking in progress"
+msgstr "Fourchage en cours"
+
msgid "Format"
msgstr "Format"
+msgid "From %{provider_title}"
+msgstr "De %{provider_title}"
+
msgid "From issue creation until deploy to production"
msgstr "Depuis la création du ticket 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 "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr "À partir de l’affichage des détails du cluster Kubernetes, installez un Exécuteur à partir de la liste des applications"
+
msgid "GPG Keys"
msgstr "Clés GPG"
+msgid "Generate a default set of labels"
+msgstr "Générer un ensemble de labels par défaut"
+
msgid "Geo Nodes"
msgstr "NÅ“uds Geo"
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr "Le nœud est défaillant ou cassé."
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr "Le nœud est lent, surchargé, ou il vient juste de récupérer après un problème."
+
+msgid "GeoNodes|Checksummed"
+msgstr "Vérifié par somme de contrôle"
+
+msgid "GeoNodes|Database replication lag:"
+msgstr "Retard de réplication de la base de données :"
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr "La désactivation d’un nœud arrête le processus de synchronisation. Êtes-vous sûr•e?"
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr "Ne correspond pas à la configuration de stockage principale"
+
+msgid "GeoNodes|Failed"
+msgstr "Échec"
+
+msgid "GeoNodes|Full"
+msgstr "Complet"
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr "La version de GitLab ne correspond pas à la version du nœud principal"
+
+msgid "GeoNodes|GitLab version:"
+msgstr "Version de GitLab :"
+
+msgid "GeoNodes|Health status:"
+msgstr "État :"
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr "Dernier ID d’événement traité par le curseur :"
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr "Dernier événement vu du nœud primaire :"
+
+msgid "GeoNodes|Loading nodes"
+msgstr "Chargement des nœuds"
+
+msgid "GeoNodes|Local Attachments:"
+msgstr "Pièces jointes locales :"
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr "Objets LFS locaux :"
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr "Artefacts de tâches locaux :"
+
+msgid "GeoNodes|New node"
+msgstr "Nouveau nœud"
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr "Le nœud d'Authentification a été réparé avec succès."
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr "Le nœud a été supprimé avec succès."
+
+msgid "GeoNodes|Not checksummed"
+msgstr "Non vérifié par somme de contrôle"
+
+msgid "GeoNodes|Out of sync"
+msgstr "Désynchronisé"
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr "Supprimer un nœud arrête le processus de synchronisation. Êtes-vous sûr•e ?"
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr "Emplacement de réplication WAL :"
+
+msgid "GeoNodes|Replication slots:"
+msgstr "Emplacements de réplication :"
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr "Dépôts avec une somme de contrôle calculée:"
+
+msgid "GeoNodes|Repositories:"
+msgstr "Dépôts :"
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr "Dépôts avec une somme de contrôle vérifiée :"
+
+msgid "GeoNodes|Selective"
+msgstr "Sélectif"
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr "Une erreur s’est produite lors du changement de statut du nœud"
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr "Une erreur s’est produite lors de la suppression du nœud"
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr "Une erreur s’est produite lors de la réparation du nœud"
+
+msgid "GeoNodes|Storage config:"
+msgstr "Configuration de stockage :"
+
+msgid "GeoNodes|Sync settings:"
+msgstr "Paramètres de synchronisation :"
+
+msgid "GeoNodes|Synced"
+msgstr "Synchronisé"
+
+msgid "GeoNodes|Unused slots"
+msgstr "Emplacements non utilisés"
+
+msgid "GeoNodes|Unverified"
+msgstr "Non vérifié"
+
+msgid "GeoNodes|Used slots"
+msgstr "Emplacements utilisés"
+
+msgid "GeoNodes|Verified"
+msgstr "Vérifié"
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr "Wiki avec une somme de contrôle vérifiée :"
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr "Wiki avec une somme de contrôle calculée :"
+
+msgid "GeoNodes|Wikis:"
+msgstr "Wikis :"
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr "Vous avez configuré des nœuds Geo en utilisant une connexion HTTP non sécurisée. Nous recommandons l’utilisation de HTTPS."
+
+msgid "Geo|All projects"
+msgstr "Tous les projets"
+
msgid "Geo|File sync capacity"
msgstr "Capacité de synchronisation de fichier"
-msgid "Geo|Groups to replicate"
-msgstr "Groupes à répliquer"
+msgid "Geo|Groups to synchronize"
+msgstr "Groupes à synchroniser"
+
+msgid "Geo|Projects in certain groups"
+msgstr "Projets dans certains groupes"
+
+msgid "Geo|Projects in certain storage shards"
+msgstr "Projets dans certains fragments de stockage"
msgid "Geo|Repository sync capacity"
msgstr "Capacité de synchronisation du dépôt"
@@ -980,12 +2145,45 @@ msgstr "Capacité de synchronisation du dépôt"
msgid "Geo|Select groups to replicate."
msgstr "Sélectionner les groupes à répliquer."
+msgid "Geo|Shards to synchronize"
+msgstr "Fragments à synchroniser"
+
+msgid "Git repository URL"
+msgstr "URL du dépôt Git"
+
+msgid "Git revision"
+msgstr "Révision de Git"
+
msgid "Git storage health information has been reset"
msgstr "Les informations de santé du stockage Git ont été réinitialisées"
+msgid "Git version"
+msgstr "Version de Git"
+
+msgid "GitHub import"
+msgstr "Importation de GitHub"
+
+msgid "GitLab CI Linter has been moved"
+msgstr "GitLab CI Linter a été déplacé"
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "Section de l'Exécuteur GitLab"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr "Serveurs Gitaly"
+
+msgid "Go back"
+msgstr "Retour en arrière"
+
msgid "Go to your fork"
msgstr "Aller à votre fourche"
@@ -995,6 +2193,27 @@ msgstr "Fourche"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr "L’authentification Google n’est pas %{link_to_documentation}. Demandez à votre administrateur GitLab si vous souhaitez utiliser ce service."
+msgid "Got it!"
+msgstr "Compris !"
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "Les épopées vous permettent de gérer votre portefeuille de projets plus efficacement et avec moins d’effort"
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr "À partir de %{dateWord}"
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr "Chargement de la feuille de route"
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr "Une erreur s’est produite lors de la récupération des épopées"
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr "Pour afficher la feuille de route, ajoutez une date de début ou de fin planifiée à l'une de vos épopées dans ce groupe ou ses sous-groupes. Seules les épopées des 3 derniers mois et des 3 prochains mois sont affichées &ndash; de %{startDate} à %{endDate}."
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr "Jusqu’au %{dateWord}"
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr "Empêcher le partage d'un projet de %{group} avec d'autres groupes"
@@ -1031,12 +2250,6 @@ msgstr "Aucun groupe trouvé"
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "Vous pouvez gérer les autorisations des membres de votre groupe et accéder à chacun de ses projets."
-msgid "GroupsTreeRole|as"
-msgstr "comme"
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr "Êtes-vous sûr·e de vouloir quitter le groupe « ${this.group.fullName} » ?"
-
msgid "GroupsTree|Create a project in this group."
msgstr "Créez un projet dans ce groupe."
@@ -1064,6 +2277,12 @@ msgstr "Désolé, aucun groupe ne correspond à vos critères de recherche"
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr "Désolé, aucun groupe ni projet ne correspond à vos critères de recherche"
+msgid "Have your users email"
+msgstr "Lister les emails utilisateurs"
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "État des services"
@@ -1082,17 +2301,61 @@ msgstr "Aucun problème détecté"
msgid "HealthCheck|Unhealthy"
msgstr "En mauvaise santé"
+msgid "Help"
+msgstr "Aide"
+
+msgid "Help page"
+msgstr "Page d’aide"
+
+msgid "Help page text and support page url."
+msgstr "Texte de la page d’aide et URL de la page de support."
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] "Masquer la valeur"
+msgstr[1] "Masquer les valeurs"
+
msgid "History"
msgstr "Historique"
msgid "Housekeeping successfully started"
msgstr "Maintenance démarrée avec succès"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr "Si activé, l’accès aux projets sera validé sur un service externe en utilisant leur étiquette de classification."
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr "Si vous utilisez GitHub, vous verrez les statuts des pipelines sur GitHub pour vos commit et demandes de fusion. %{more_info_link}"
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr "Si vous avez déjà des fichiers, vous pouvez les pousser à l’aide de la %{link_to_cli} ci-dessous."
+
+msgid "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>."
+msgstr "Si votre dépôt HTTP n’est pas accessible publiquement, ajoutez des informations d’authentification à l’URL: <code>https: //utilisateur:mot_de_passe@gitlab.company.com/group/project.git</code>."
+
+msgid "Import"
+msgstr "Importer"
+
+msgid "Import all repositories"
+msgstr "Importer tous les dépôts"
+
+msgid "Import in progress"
+msgstr "Importation en cours"
+
+msgid "Import repositories from GitHub"
+msgstr "Importer des dépôts à partir de GitHub"
+
msgid "Import repository"
msgstr "Importer un dépôt"
+msgid "ImportButtons|Connect repositories from"
+msgstr "Connecter des dépôts à partir de"
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
-msgstr "Améliorer le tableau de tickets avec GitLab Entreprise Edition."
+msgstr "Améliorer les tableaux de tickets avec Gitlab Entreprise Edition."
msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
msgstr "Améliorer la gestion des tickets avec les poids de ticket et GitLab Entreprise Edition."
@@ -1100,6 +2363,9 @@ msgstr "Améliorer la gestion des tickets avec les poids de ticket et GitLab Ent
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr "Améliorer la recherche avec la Recherche Globale Avancée et GitLab Enterprise Edition."
+msgid "Install Runner on Kubernetes"
+msgstr "Installez un Exécuteur sur Kubernetes"
+
msgid "Install a Runner compatible with GitLab CI"
msgstr "Installez un Exécuteur compatible avec l'intégration continue de GitLab"
@@ -1108,6 +2374,15 @@ msgid_plural "Instances"
msgstr[0] "Instance"
msgstr[1] "Instances"
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr "L’instance ne prend pas en charge plusieurs clusters Kubernetes"
+
+msgid "Integrations"
+msgstr "Intégrations"
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr "Les parties intéressées peuvent même contribuer en poussant des commits si elles le souhaitent."
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr "Interne - Le groupe ainsi que tous les projets internes sont accessibles pour n’importe quel·le utilisa·teur·trice connecté·e."
@@ -1123,9 +2398,6 @@ msgstr "Introduction à l'analyseur de cycle"
msgid "Issue board focus mode"
msgstr "Mode focus du tableau de tickets"
-msgid "Issue boards with milestones"
-msgstr "Tableaux des tickets avec leurs jalons"
-
msgid "Issue events"
msgstr "Événements du ticket"
@@ -1138,15 +2410,87 @@ msgstr "Tableaux"
msgid "Issues"
msgstr "Tickets"
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr "Les tickets peuvent être des bugs, des tâches ou des idées à discuter. De plus, les tickets sont consultables et filtrables."
+
+msgid "Jan"
+msgstr "Janv."
+
+msgid "January"
+msgstr "Janvier"
+
+msgid "Jobs"
+msgstr "Tâches"
+
+msgid "Jul"
+msgstr "Juill."
+
+msgid "July"
+msgstr "Juillet"
+
+msgid "Jun"
+msgstr "Juin"
+
+msgid "June"
+msgstr "Juin"
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr "Kubernetes"
+
+msgid "Kubernetes Cluster"
+msgstr "Cluster Kubernetes"
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr "Le temps de création du cluster Kubernetes dépasse le délai d’expiration ; %{timeout}"
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr "L’intégration du cluster Kubernetes n’a pas été supprimée."
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr "L’intégration du cluster Kubernetes a été supprimée avec succès."
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr "Le cluster Kubernetes a été mis à jour avec succès."
+
+msgid "Kubernetes configured"
+msgstr "Kubernetes configuré"
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr "L’intégration du service Kubernetes est obsolète. %{deprecated_message_content} vos clusters Kubernetes en utilisant la nouvelle page <a href=\"%{url}\"/>Clusters Kubernetes</a>"
+
msgid "LFSStatus|Disabled"
msgstr "Désactivé"
msgid "LFSStatus|Enabled"
msgstr "Activé"
+msgid "Label"
+msgstr "Label"
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr "%{firstLabelName} +%{remainingLabelCount} de plus"
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr "%{labelsString} et %{remainingLabelCount} de plus"
+
msgid "Labels"
msgstr "Labels"
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr "Les labels peuvent être appliqués à %{features}. Des labels de groupe sont disponibles pour tout type de projet au sein du groupe."
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr "Les labels peuvent être appliqués aux tickets et aux demandes de fusion pour les classer."
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr "<span>Promouvoir le label</span> %{labelTitle} <span>en label de groupe ?</span>"
+
+msgid "Labels|Promote Label"
+msgstr "Promouvoir le label"
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "Le dernier %d jour"
@@ -1156,7 +2500,7 @@ msgid "Last Pipeline"
msgstr "Dernier pipeline"
msgid "Last commit"
-msgstr "Dernière validation"
+msgstr "Dernier commit"
msgid "Last edited %{date}"
msgstr "Dernière modification le %{date}"
@@ -1176,6 +2520,15 @@ msgstr "Vous avez poussé sur"
msgid "LastPushEvent|at"
msgstr "à"
+msgid "Learn more"
+msgstr "En savoir plus"
+
+msgid "Learn more about Kubernetes"
+msgstr "En savoir plus sur Kubernetes"
+
+msgid "Learn more about protected branches"
+msgstr "En savoir plus sur les branches protégées"
+
msgid "Learn more in the"
msgstr "En apprendre plus dans le"
@@ -1194,32 +2547,78 @@ msgstr "Quitter le projet"
msgid "License"
msgstr "Licence"
-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 "List"
+msgstr "Liste"
+
+msgid "List your GitHub repositories"
+msgstr "Lister vos dépôts GitHub"
+
+msgid "Loading the GitLab IDE..."
+msgstr "Chargement de l’IDE GitLab…"
msgid "Lock"
msgstr "Verrouiller"
+msgid "Lock %{issuableDisplayName}"
+msgstr "Verrouiller %{issuableDisplayName}"
+
+msgid "Lock not found"
+msgstr "Verrou non trouvé"
+
msgid "Locked"
msgstr "Verrouillé"
msgid "Locked Files"
msgstr "Fichiers verrouillés"
+msgid "Locks give the ability to lock specific file or folder."
+msgstr "Les verrous donnent la possibilité de verrouiller un fichier ou un dossier spécifique."
+
msgid "Login"
msgstr "Se connecter"
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr "Rendez votre équipe plus productive quel que soit son emplacement. GitLab Geo crée des miroirs en lecture seule de votre instance GitLab afin que vous puissiez réduire le temps nécessaire pour cloner et récupérer de gros dépôts."
+
+msgid "Manage all notifications"
+msgstr "Gérer toutes les notifications"
+
+msgid "Manage group labels"
+msgstr "Gérer les labels de groupe"
+
+msgid "Manage labels"
+msgstr "Gérer les labels"
+
+msgid "Manage project labels"
+msgstr "Gérer les labels de projet"
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr "Mars"
+
+msgid "March"
+msgstr "Mars"
+
+msgid "Mark done"
+msgstr "Marquer comme fait"
+
msgid "Maximum git storage failures"
msgstr "Nombre maximum d’échecs du stockage git"
+msgid "May"
+msgstr "Mai"
+
msgid "Median"
msgstr "Médian"
msgid "Members"
msgstr "Membres"
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr "Demandes de fusion"
@@ -1229,38 +2628,173 @@ msgstr "Événements de fusion"
msgid "Merge request"
msgstr "Demande de fusion"
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr "Les demandes de fusion permettent de proposer les modifications que vous avez apportées à un projet et de discuter de ces modifications avec les autres"
+
+msgid "Merged"
+msgstr "Fusionnée"
+
msgid "Messages"
msgstr "Messages"
+msgid "Metrics - Influx"
+msgstr "Métriques - Influx"
+
+msgid "Metrics - Prometheus"
+msgstr "Métriques - Prometheus"
+
+msgid "Metrics|Business"
+msgstr "Affaires"
+
+msgid "Metrics|Create metric"
+msgstr "Créer une métrique"
+
+msgid "Metrics|Edit metric"
+msgstr "Modifier la métrique"
+
+msgid "Metrics|For grouping similar metrics"
+msgstr "Pour regrouper des métriques similaires"
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr "Etiquette de l’axe vertical du graphique. Habituellement, le type de l’unité étant cartographiée. L’axe horizontal (axe X) représente toujours le temps."
+
+msgid "Metrics|Legend label (optional)"
+msgstr "Légende de l’étiquette (facultatif)"
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr "Doit être une requête PromQL valide."
+
+msgid "Metrics|Name"
+msgstr "Nom"
+
+msgid "Metrics|New metric"
+msgstr "Nouvelle métrique"
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr "Documentation de requête Prometheus"
+
+msgid "Metrics|Query"
+msgstr "Requête"
+
+msgid "Metrics|Response"
+msgstr "Réponse"
+
+msgid "Metrics|System"
+msgstr "Système"
+
+msgid "Metrics|Type"
+msgstr "Type"
+
+msgid "Metrics|Unit label"
+msgstr "Étiquette de l’unité"
+
+msgid "Metrics|Used as a title for the chart"
+msgstr "Utilisé comme titre pour le graphique"
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr "Utilisé si la requête renvoie une seule série. Si elle renvoie plusieurs séries, leurs étiquettes de légende seront récupérées à partir de la réponse."
+
+msgid "Metrics|Y-axis label"
+msgstr "Étiquette de l’axe Y"
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr "Par exemple, requêtes HTTP"
+
+msgid "Metrics|e.g. Requests/second"
+msgstr "Par exemple, requêtes / seconde"
+
+msgid "Metrics|e.g. Throughput"
+msgstr "Par exemple, débit"
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr "Par exemple, rate(http_requests_total[5m])"
+
+msgid "Metrics|e.g. req/sec"
+msgstr "Par exemple, req/sec"
+
+msgid "Milestone"
+msgstr "Jalon"
+
+msgid "Milestones|Delete milestone"
+msgstr "Supprimer le jalon"
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr "Supprimer le jalon %{milestoneTitle} ?"
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr "Impossible de supprimer le jalon %{milestoneTitle}"
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr "Le jalon %{milestoneTitle} est introuvable"
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr "Promouvoir %{milestoneTitle} à un jalon de groupe ?"
+
+msgid "Milestones|Promote Milestone"
+msgstr "Promouvoir le jalon"
+
+msgid "Milestones|This action cannot be reversed."
+msgstr "Cette action ne peut pas être annulée."
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "ajouter une clé SSH"
+msgid "Modal|Cancel"
+msgstr "Annuler"
+
+msgid "Modal|Close"
+msgstr "Fermer"
+
msgid "Monitoring"
msgstr "Surveillance"
+msgid "More info"
+msgstr "En savoir plus"
+
+msgid "More information"
+msgstr "Plus d’informations"
+
msgid "More information is available|here"
msgstr "ici"
+msgid "Move"
+msgstr "Déplacer"
+
+msgid "Move issue"
+msgstr "Déplacer le ticket"
+
msgid "Multiple issue boards"
msgstr "Multiple tableaux de tickets"
-msgid "New Cluster"
-msgstr "Nouveau cluster"
+msgid "Name new label"
+msgstr "Nommez le nouveau label"
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "Nouveau ticket"
msgstr[1] "Nouveaux tickets"
+msgid "New Kubernetes Cluster"
+msgstr "Nouveau cluster Kubernetes"
+
+msgid "New Kubernetes cluster"
+msgstr "Nouveau cluster Kubernetes"
+
msgid "New Pipeline Schedule"
msgstr "Nouveau pipeline programmé"
msgid "New branch"
msgstr "Nouvelle branche"
+msgid "New branch unavailable"
+msgstr "Nouvelle branche indisponible"
+
msgid "New directory"
msgstr "Nouveau dossier"
+msgid "New epic"
+msgstr "Nouvelle épopée"
+
msgid "New file"
msgstr "Nouveau fichier"
@@ -1270,6 +2804,9 @@ msgstr "Nouveau groupe"
msgid "New issue"
msgstr "Nouveau ticket"
+msgid "New label"
+msgstr "Nouveau label"
+
msgid "New merge request"
msgstr "Nouvelle demande de fusion"
@@ -1288,8 +2825,29 @@ msgstr "Nouveau sous-groupe"
msgid "New tag"
msgstr "Nouveau tag"
-msgid "No container images stored for this project. Add one by following the instructions above."
-msgstr "Aucune image de conteneur stockée pour ce projet. Ajoutez en une en suivant les instructions ci-dessous."
+msgid "No Label"
+msgstr "Pas de label"
+
+msgid "No assignee"
+msgstr "Aucune personne assignée"
+
+msgid "No changes"
+msgstr "Aucun changement"
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr "Aucune connexion n’a pu être établie avec un serveur Gitaly, veuillez vérifier vos logs !"
+
+msgid "No due date"
+msgstr "Aucune date d’échéance"
+
+msgid "No estimate or time spent"
+msgstr "Aucune estimation ou temps passé"
+
+msgid "No file chosen"
+msgstr "Aucun fichier sélectionné"
+
+msgid "No labels created yet."
+msgstr "Aucun label créé pour le moment."
msgid "No repository"
msgstr "Pas de dépôt"
@@ -1300,12 +2858,39 @@ msgstr "Aucun programme"
msgid "None"
msgstr "Aucun·e"
+msgid "Not allowed to merge"
+msgstr "Non autorisé•e à fusionner"
+
msgid "Not available"
msgstr "Indisponible"
+msgid "Not available for private projects"
+msgstr "Non disponible pour les projets privés"
+
+msgid "Not available for protected branches"
+msgstr "Non disponible pour les branches protégées"
+
+msgid "Not confidential"
+msgstr "Pas confidentiel•le"
+
msgid "Not enough data"
msgstr "Données insuffisantes"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr "Notez que la branche principale est automatiquement protégée. %{link_to_protected_branches}"
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr "Remarque : En tant qu’administrateur•rice, vous pouvez configurer %{github_integration_link}, ce qui vous permettra de vous connecter via GitHub et de permettre la connexion de dépôts sans générer de jeton d’accès personnel."
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr "Remarque : En tant qu’administrateur•rice, vous pouvez configurer %{github_integration_link}, ce qui vous permettra de vous connecter via GitHub et de permettre l’importation de dépôts sans générer de jeton d’accès personnel."
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr "Remarque : Envisagez de demander à votre administrateur•rice GitLab de configurer %{github_integration_link}, ce qui vous permettra de vous connecter via GitHub et de permettre la connexion des dépôts sans générer de jeton d’accès personnel."
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr "Remarque : Envisagez de demander à votre administrateur•rice GitLab de configurer %{github_integration_link}, ce qui vous permettra de vous connecter via GitHub et d’importer des dépôts sans générer de jeton d’accès personnel."
+
msgid "Notification events"
msgstr "Événement de notifications"
@@ -1363,18 +2948,48 @@ msgstr "Surveillé"
msgid "Notifications"
msgstr "Notifications"
+msgid "Notifications off"
+msgstr "Notifications désactivées"
+
+msgid "Notifications on"
+msgstr "Notifications activées"
+
+msgid "Nov"
+msgstr "Nov."
+
+msgid "November"
+msgstr "Novembre"
+
msgid "Number of access attempts"
msgstr "Nombre de tentatives d'accès"
-msgid "Number of failures before backing off"
-msgstr "Nombre d'échecs avant annulation"
+msgid "OK"
+msgstr "OK"
+
+msgid "Oct"
+msgstr "Oct."
+
+msgid "October"
+msgstr "Octobre"
msgid "OfSearchInADropdown|Filter"
msgstr "Filtre"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr "Une fois importés, les dépôts peuvent être mis en miroir via SSH. Lire plus %{ssh_link}"
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr "Seuls les membres du projet peuvent commenter."
+msgid "Open"
+msgstr "Ouvrir"
+
+msgid "Opened"
+msgstr "Ouvert"
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Ouvert"
@@ -1384,12 +2999,21 @@ msgstr "Ouvrir dans une nouvelle fenêtre"
msgid "Options"
msgstr "Paramètres"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr "Sinon, il est recommandé de commencer avec l’une des options ci-dessous."
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr "Vue d'ensemble"
msgid "Owner"
msgstr "Propriétaire"
+msgid "Pages"
+msgstr "Pages"
+
msgid "Pagination|Last »"
msgstr "Dernière »"
@@ -1402,11 +3026,20 @@ msgstr "Précédente"
msgid "Pagination|« First"
msgstr "« Première"
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr "Mot de Passe"
-msgid "People without permission will never get a notification and won\\'t be able to comment."
-msgstr "Les personnes sans autorisation ne recevront jamais de notifications et ne pourront pas commenter."
+msgid "Pending"
+msgstr "En attente"
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr "Jeton d’accès personnel"
msgid "Pipeline"
msgstr "Pipeline"
@@ -1450,12 +3083,6 @@ 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"
@@ -1465,9 +3092,6 @@ 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"
@@ -1495,6 +3119,57 @@ msgstr "Pipelines de la semaine dernière"
msgid "Pipelines for last year"
msgstr "Pipelines de l’année dernière"
+msgid "Pipelines|Build with confidence"
+msgstr "Construire en toute confiance"
+
+msgid "Pipelines|CI Lint"
+msgstr "CI Lint"
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr "Vider les caches des Exécuteurs"
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr "Premiers pas avec les Pipelines"
+
+msgid "Pipelines|Loading Pipelines"
+msgstr "Chargement des pipelines"
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr "Réinitialisation réussie du cache de projet."
+
+msgid "Pipelines|Run Pipeline"
+msgstr "Éxécuter un pipeline"
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr "Une erreur s’est produite lors du nettoyage du cache des exécuteurs."
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr "Il n’y a actuellement pas de pipelines %{scope}."
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr "Il n’y a actuellement pas de pipelines."
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr "Ce projet n’est actuellement pas configuré pour exécuter des pipelines."
+
+msgid "Pipeline|Retry pipeline"
+msgstr "Relancer le pipeline"
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr "Relancer le pipeline #%{pipelineId} ?"
+
+msgid "Pipeline|Stop pipeline"
+msgstr "Arrêter le pipeline"
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr "Arrêter le pipeline #%{pipelineId} ?"
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr "Vous êtes sur le point de relancer le pipeline %{pipelineId}."
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr "Vous êtes sur le point d’arrêter le pipeline %{pipelineId}."
+
msgid "Pipeline|all"
msgstr "Tous"
@@ -1507,15 +3182,39 @@ msgstr "avec l'étape"
msgid "Pipeline|with stages"
msgstr "avec les étapes"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr "Lancer"
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr "Merci d’<a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">activer la facturation pour un de vos projets afin d’être en mesure de créer un cluster Kubernetes</a>, puis essayez à nouveau."
+
+msgid "Please solve the reCAPTCHA"
+msgstr "Veuillez résoudre le reCAPTCHA"
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr "Veuillez patienter pendant que nous nous connectons à votre dépôt. Actualisez à volonté."
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr "Veuillez patienter pendant que nous importons le dépôt pour vous. Actualisez à volonté."
+
msgid "Preferences"
msgstr "Préférences"
+msgid "Primary"
+msgstr "Principal"
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr "Privé - L’accès au projet doit être autorisé explicitement pour chaque utilisa·teur·trice."
msgid "Private - The group and its projects can only be viewed by members."
msgstr "Privé - Le groupe ainsi que ses projets ne sont accessibles qu’à ses membres."
+msgid "Private projects can be created in your personal namespace with:"
+msgstr "Des projets privés peuvent être créés dans votre espace de noms personnel avec :"
+
msgid "Profile"
msgstr "Profil"
@@ -1555,6 +3254,12 @@ msgstr "Votre compte est actuellement propriétaire des groupes suivants :"
msgid "Profiles|your account"
msgstr "votre compte"
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr "Langages de programmation utilisés dans ce dépôt"
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr "Le projet “%{project_name}†est en train d’être supprimé."
@@ -1570,6 +3275,12 @@ msgstr "Projet '%{project_name}' mis à jour avec succès."
msgid "Project access must be granted explicitly to each user."
msgstr "L’accès au projet doit être explicitement accordé à chaque utilisateur."
+msgid "Project avatar"
+msgstr "Avatar du projet"
+
+msgid "Project avatar in repository: %{link}"
+msgstr "Avatar du project dans le dépôt : %{link}"
+
msgid "Project details"
msgstr "Détails du projet"
@@ -1588,6 +3299,21 @@ msgstr "L'export du projet a débuté. Un lien de téléchargement sera envoyé
msgid "ProjectActivityRSS|Subscribe"
msgstr "S’abonner"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr "Autorisé•e à créer des projets"
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr "Protection de création de projet par défaut"
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr "Développeur•euse•s + Expert•e•s"
+
+msgid "ProjectCreationLevel|Masters"
+msgstr "Expert•e•s"
+
+msgid "ProjectCreationLevel|No one"
+msgstr "Personne"
+
msgid "ProjectFeature|Disabled"
msgstr "Désactivé"
@@ -1613,7 +3339,7 @@ msgid "ProjectSettings|Contact an admin to change this setting."
msgstr "Contactez un administrateur pour modifier ce paramètre."
msgid "ProjectSettings|Only signed commits can be pushed to this repository."
-msgstr "Seules les validations signées peuvent être poussées sur ce dépôt."
+msgstr "Seules les commits signés peuvent être poussés sur ce dépôt."
msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
msgstr "Ce paramètre est appliqué au niveau du serveur et peut être modifié par un administrateur."
@@ -1622,10 +3348,10 @@ msgid "ProjectSettings|This setting is applied on the server level but has been
msgstr "Ce paramètre est appliqué au niveau du serveur mais il a été modifié pour ce projet."
msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin."
-msgstr "Ce paramètre s’appliquera à tous les projets à moins qu’un administrateur ne le modifie."
+msgstr "Ce paramètre s’appliquera à tous les projets à moins qu’un•e administrat•eur•rice ne le modifie."
msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
-msgstr "Les utilisateurs peuvent uniquement pousser sur ce dépôt des validations qui ont été validées avec une de leurs adresses courriels vérifiées."
+msgstr "Les utilisateurs peuvent uniquement pousser sur ce dépôt des commits qui ont été validés avec une de leurs adresses courriels vérifiées."
msgid "Projects"
msgstr "Projets"
@@ -1651,6 +3377,93 @@ msgstr "Désolé, aucun projet ne correspond à votre recherche"
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr "Cette fonctionnalité requiert le support du localStorage par votre navigateur"
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr "%{exporters} avec %{metrics} ont été trouvés"
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr "<p class=\"text-tertiary\">Aucune <a href=\"%{docsUrl}\">métrique commune</a> trouvée</p>"
+
+msgid "PrometheusService|Active"
+msgstr "Actif"
+
+msgid "PrometheusService|Auto configuration"
+msgstr "Configuration automatique"
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr "Déployer et configurer automatiquement Prometheus sur vos clusters pour surveiller les environnements de vos projets"
+
+msgid "PrometheusService|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."
+msgstr "Par défaut, Prometheus écoute sur 'http://localhost:9090'. Il n’est pas recommandé de changer l’adresse et le port par défaut car cela pourrait affecter ou entrer en conflit avec d'autres services fonctionnant sur le serveur GitLab."
+
+msgid "PrometheusService|Common metrics"
+msgstr "Métriques communes"
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr "Les métriques communes sont automatiquement surveillées en fonction d’une bibliothèque de métriques provenant d’exportateurs populaires."
+
+msgid "PrometheusService|Custom metrics"
+msgstr "Métriques personnalisés"
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr "Recherche et configuration des métriques en cours…"
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr "Recherche de métriques personnalisées…"
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr "Installer Prometheus sur les clusters"
+
+msgid "PrometheusService|Manage clusters"
+msgstr "Gérer les clusters"
+
+msgid "PrometheusService|Manual configuration"
+msgstr "Configuration manuelle"
+
+msgid "PrometheusService|Metrics"
+msgstr "Métriques"
+
+msgid "PrometheusService|Missing environment variable"
+msgstr "Variable d’environnement manquante"
+
+msgid "PrometheusService|More information"
+msgstr "Plus d’informations"
+
+msgid "PrometheusService|New metric"
+msgstr "Nouvelle métrique"
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr "URL de base de l’API Prometheus, comme http://prometheus.example.com/"
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr "Prometheus est géré automatiquement sur vos clusters"
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr "Ces métriques ne seront surveillés qu’après votre premier déploiement dans un environnement"
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr "Service de surveillance de séries temporelles"
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr "Pour activer la configuration manuelle, désinstallez Prometheus de vos clusters"
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr "Pour activer l’installation de Prometheus sur vos clusters, désactivez la configuration manuelle ci-dessous"
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr "En attente de votre premier déploiement dans un environnement pour trouver des métriques communes"
+
+msgid "Promote"
+msgstr "Promouvoir"
+
+msgid "Promote to Group Label"
+msgstr "Promouvoir en label de groupe"
+
+msgid "Promote to Group Milestone"
+msgstr "Promouvoir en jalon de groupe"
+
+msgid "Protip:"
+msgstr "Astuce :"
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr "Public - Le groupe ainsi que n’importe quel projet public est accessible sans authentification."
@@ -1663,26 +3476,44 @@ msgstr "Règles de poussée"
msgid "Push events"
msgstr "Évènements de poussée"
+msgid "Push project from command line"
+msgstr "Pousser le projet en ligne de commande"
+
+msgid "Push to create a project"
+msgstr "Pousser pour créer un projet"
+
msgid "PushRule|Committer restriction"
msgstr "Restriction du validateur"
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr "Les actions rapides peuvent être utilisées dans la description des tickets et dans les zones de commentaire."
+
msgid "Read more"
msgstr "Lire plus"
msgid "Readme"
msgstr "LisezMoi"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "Branches"
msgid "RefSwitcher|Tags"
msgstr "Tags"
+msgid "Reference:"
+msgstr "Référence :"
+
+msgid "Register / Sign In"
+msgstr "Inscription / Connexion"
+
msgid "Registry"
msgstr "Registre"
msgid "Related Commits"
-msgstr "Validations liées"
+msgstr "Commits liés"
msgid "Related Deployed Jobs"
msgstr "Tâches de déploiement liées"
@@ -1699,15 +3530,42 @@ msgstr "Demandes de fusion liées"
msgid "Related Merged Requests"
msgstr "Demandes fusionnées liées"
+msgid "Related merge requests"
+msgstr "Demandes de fusion liées"
+
msgid "Remind later"
msgstr "Me le rappeler ultérieurement"
+msgid "Remove"
+msgstr "Supprimer"
+
+msgid "Remove avatar"
+msgstr "Supprimer l’avatar"
+
msgid "Remove project"
msgstr "Supprimer le projet"
+msgid "Repair authentication"
+msgstr "Réparer l’authentification"
+
+msgid "Repo by URL"
+msgstr "Dépôt par URL"
+
msgid "Repository"
msgstr "Dépôt"
+msgid "Repository has no locks."
+msgstr "Le dépôt n’a pas de verrous."
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "Demander l'accès"
@@ -1720,68 +3578,172 @@ msgstr "Réinitialiser le jeton d’accès au bilan de santé"
msgid "Reset runners registration token"
msgstr "Réinitialiser le jeton d’inscription des exécuteurs"
+msgid "Resolve discussion"
+msgstr "Résoudre la discussion"
+
+msgid "Response"
+msgstr "Réponse"
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] "Révéler la valeur"
+msgstr[1] "Révéler les valeurs"
+
msgid "Revert this commit"
-msgstr "Défaire cette validation"
+msgstr "Défaire ce commit"
msgid "Revert this merge request"
msgstr "Défaire cette demande de fusion"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr "Examiner"
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr "Feuille de route"
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr "Exécuter des pipelines CI / CD pour les dépôts externes"
+
+msgid "Runners"
+msgstr "Exécuteurs"
+
+msgid "Running"
+msgstr "En cours"
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
msgid "SSH Keys"
msgstr "Clés SSH"
-msgid "Save"
-msgstr "Enregistrer"
-
msgid "Save changes"
msgstr "Enregistrer les modifications"
msgid "Save pipeline schedule"
msgstr "Sauvegarder le pipeline programmé"
+msgid "Save variables"
+msgstr "Enregistrer les variables"
+
msgid "Schedule a new pipeline"
msgstr "Programmer un nouveau pipeline"
+msgid "Scheduled"
+msgstr "Planifié"
+
msgid "Schedules"
msgstr "Programmes"
msgid "Scheduling Pipelines"
msgstr "Programmer des pipelines"
+msgid "Scoped issue boards"
+msgstr "Tableaux de tickets avec portée limitée"
+
+msgid "Search"
+msgstr "Rechercher"
+
msgid "Search branches and tags"
msgstr "Rechercher dans les branches et les étiquettes"
+msgid "Search milestones"
+msgstr "Recherche de jalons"
+
+msgid "Search project"
+msgstr "Recherche de projets"
+
+msgid "Search users"
+msgstr "Recherche d’utilisateur•rice•s"
+
msgid "Seconds before reseting failure information"
msgstr "Nombre de secondes avant de réinitialiser les informations d’échec"
-msgid "Seconds to wait after a storage failure"
-msgstr "Nombre de secondes d'attente après un échec de stockage"
-
msgid "Seconds to wait for a storage access attempt"
msgstr "Nombre de secondes d’attente pour un essai d'accès au stockage"
+msgid "Secret variables"
+msgstr "Variables secrètes"
+
+msgid "Security report"
+msgstr "Rapport de sécurité"
+
msgid "Select Archive Format"
msgstr "Sélectionnez le format de l'archive"
msgid "Select a timezone"
msgstr "Sélectionnez un fuseau horaire"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr "Sélectionnez un cluster Kubernetes existant ou créez-en un nouveau"
+
+msgid "Select assignee"
+msgstr "Sélectionner une personne assignée"
+
+msgid "Select branch/tag"
+msgstr "Sélectionnez une branche / un tag"
+
msgid "Select target branch"
msgstr "Sélectionnez une branche cible"
+msgid "Selective synchronization"
+msgstr "Synchronisation sélective"
+
+msgid "Send email"
+msgstr "Envoyer un courriel"
+
+msgid "Sep"
+msgstr "Sept."
+
+msgid "September"
+msgstr "Septembre"
+
+msgid "Server version"
+msgstr "Version du serveur"
+
msgid "Service Templates"
msgstr "Modèles de service"
+msgid "Service URL"
+msgstr "URL du service"
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr "Expiration de la session, limite des projets et taille des pièces jointes."
+
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 default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr "Définir les valeurs par défaut et limiter les niveaux de visibilité. Configurer les sources d’importation et le protocole d’accès git."
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr "Définir les exigences pour la connexion d’un utilisateur. Activer l’authentification obligatoire à deux facteurs."
+
+msgid "Set up CI/CD"
+msgstr "Configurer CI/CD"
msgid "Set up Koding"
msgstr "Mettre en place Koding"
-msgid "Set up auto deploy"
-msgstr "Mettre en place l’auto-déploiement"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "définir un mot de passe"
@@ -1789,6 +3751,24 @@ msgstr "définir un mot de passe"
msgid "Settings"
msgstr "Paramètres"
+msgid "Setup a specific Runner automatically"
+msgstr "Configurer un Exécuteur spécifique automatiquement"
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr "En réinitialisant les minutes du pipeline pour cet espace de noms, les minutes actuellement utilisées seront remises à zéro."
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr "Réinitialiser les minutes du pipeline"
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr "Réinitialiser les minutes de pipeline utilisées"
+
+msgid "Show command"
+msgstr "Afficher la commande"
+
msgid "Show parent pages"
msgstr "Afficher les pages parentes"
@@ -1800,14 +3780,47 @@ msgid_plural "Showing %d events"
msgstr[0] "Affichage de %d évènement"
msgstr[1] "Affichage de %d évènements"
+msgid "Sidebar|Change weight"
+msgstr "Changer le poids"
+
+msgid "Sidebar|No"
+msgstr "Non"
+
+msgid "Sidebar|None"
+msgstr "Aucun"
+
+msgid "Sidebar|Weight"
+msgstr "Poids"
+
+msgid "Sign-in restrictions"
+msgstr "Restrictions de connexion"
+
+msgid "Sign-up restrictions"
+msgstr "Restrictions d’inscription"
+
+msgid "Size and domain settings for static websites"
+msgstr "Paramètres de taille et de domaine pour les sites web statiques"
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr "Extraits de code"
+msgid "Something went wrong on our end"
+msgstr "Une erreur est survenue de notre côté"
+
msgid "Something went wrong on our end."
msgstr "Une erreur est survenue de notre côté."
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
-msgstr "Quelque chose ne s'est pas bien passé en essayant de changer l’état de verrouillage de cet ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr "Une erreur s’est produite lors du basculement du bouton"
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr "Une erreur s’est produite lors de la recherche des dépendances."
+
+msgid "Something went wrong while fetching SAST."
+msgstr "Une erreur s’est produite lors de la récupération de la SAST."
msgid "Something went wrong while fetching the projects."
msgstr "Une erreur s'est produite lors de la récupération des projets."
@@ -1815,6 +3828,9 @@ msgstr "Une erreur s'est produite lors de la récupération des projets."
msgid "Something went wrong while fetching the registry list."
msgstr "Une erreur s'est produite lors de la récupération de la liste du registre."
+msgid "Something went wrong. Please try again."
+msgstr "Quelque chose s’est mal passé. Veuillez réessayer."
+
msgid "Sort by"
msgstr "Trier par"
@@ -1914,18 +3930,36 @@ msgstr "Commence bientôt"
msgid "SortOptions|Weight"
msgstr "Poids"
+msgid "Source"
+msgstr "Source"
+
+msgid "Source (branch or tag)"
+msgstr "Source (branche ou tag)"
+
msgid "Source code"
msgstr "Code source"
+msgid "Source is not available"
+msgstr "La source n’est pas disponible"
+
msgid "Spam Logs"
msgstr "Journaux des messages indésirables"
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "Spécifiez l’URL suivante lors de la configuration de l'Exécuteur :"
msgid "StarProject|Star"
msgstr "Mettre en favori"
+msgid "Starred Projects"
+msgstr "Projets favoris"
+
+msgid "Starred Projects' Activity"
+msgstr "Activité des projets favoris"
+
msgid "Starred projects"
msgstr "Projets favoris"
@@ -1935,70 +3969,175 @@ msgstr "Créer une %{new_merge_request} avec ces changements"
msgid "Start the Runner!"
msgstr "Démarrer l'Exécuteur !"
+msgid "Started"
+msgstr "Démarré"
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr "État"
+
+msgid "Stopped"
+msgstr "Arrêté"
+
+msgid "Storage"
+msgstr "Stockage"
+
msgid "Subgroups"
msgstr "Sous-groupes"
-msgid "Subscribe"
-msgstr "S’abonner"
-
msgid "Switch branch/tag"
msgstr "Changer de branche / tag"
+msgid "System"
+msgstr "Système"
+
msgid "System Hooks"
msgstr "Crochets système"
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "Étiquette"
-msgstr[1] "Tags"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] "Tag (%{tag_count})"
+msgstr[1] "Tags (%{tag_count})"
msgid "Tags"
msgstr "Tags"
+msgid "TagsPage|Browse commits"
+msgstr "Parcourir les commits"
+
+msgid "TagsPage|Browse files"
+msgstr "Parcourir les fichiers"
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr "Impossible de trouver le commit HEAD pour ce tag"
+
+msgid "TagsPage|Cancel"
+msgstr "Annuler"
+
+msgid "TagsPage|Create tag"
+msgstr "Créer le tag"
+
+msgid "TagsPage|Delete tag"
+msgstr "Supprimer le tag"
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr "La suppression du tag %{tag_name} ne peut être annulée. Êtes-vous sûr•e ?"
+
+msgid "TagsPage|Edit release notes"
+msgstr "Modifier les notes de version"
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr "Nom de branche, tag, ou SHA de commit existant"
+
+msgid "TagsPage|Filter by tag name"
+msgstr "Filtrer par nom de tag"
+
+msgid "TagsPage|New Tag"
+msgstr "Nouveau tag"
+
+msgid "TagsPage|New tag"
+msgstr "Nouveau tag"
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr "Éventuellement, ajoutez un message au tag."
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr "Éventuellement, ajouter des notes de version pour le tag. Elles seront stockées dans la base de données de GitLab et affichées sur la page des tags."
+
+msgid "TagsPage|Release notes"
+msgstr "Notes de version"
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr "Le dépôt n‘a pas de tags pour le moment."
+
+msgid "TagsPage|Sort by"
+msgstr "Trier par"
+
+msgid "TagsPage|Tags"
+msgstr "Tags"
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr "Les tags permettent de marquer des validations spécifiques commes importantes dans l‘historique du project"
+
+msgid "TagsPage|This tag has no release notes."
+msgstr "Ce tag n‘a pas de notes de version."
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr "Utilisez la commande git tag pour en ajouter un nouveau :"
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr "Écrivez les notes de version ou faîtes glisser des fichiers ici…"
+
+msgid "TagsPage|protected"
+msgstr "protégé"
+
msgid "Target Branch"
msgstr "Branche cible"
+msgid "Target branch"
+msgstr "Branche cible"
+
msgid "Team"
msgstr "Équipe"
msgid "Thanks! Don't show me this again"
-msgstr "Merci de ne plus afficher ce message"
+msgstr "Merci ! Ne plus afficher ce message"
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
-msgstr "La Recherche Globale Avancée de GitLab est un outils qui vous fait gagner du temps. Au lieu de créer du code similaire et perdre du temps, vous pouvez maintenant chercher dans le code d'autres équipes pour vous aider sur votre projet."
+msgstr "La Recherche Globale Avancée de Gitlab est un outils puissant qui vous fait gagner du temps. Au lieu de créer du code similaire et perdre du temps, vous pouvez maintenant chercher dans le code d'autres équipes pour vous aider sur votre projet."
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr "Le suivi des tickets est l’endroit où ajouter des éléments à améliorer ou à résoudre dans un projet"
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
-msgstr "Le seuil d’interruption du disjoncteur devrait être inférieur au seuil de nombre de défaillance"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr "Le suivi des tickets est l’endroit où ajouter des éléments à améliorer ou à résoudre dans un projet. Vous pouvez vous inscrire ou vous connecter pour créer des tickets pour ce projet."
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+msgstr "Le certificat X509 à utiliser lorsque le protocole TLS est requis pour communiquer avec le service d’autorisation externe. Si ce champ est vide, le certificat du serveur est utilisé lors de l’accès via HTTPS."
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."
+msgstr "L’étape de développement montre le temps entre le premier commit 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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr "La connexion expirera après %{timeout}. Pour les dépôts qui prennent plus de temps, utilisez une combinaison cloner / pousser."
+
msgid "The fork relationship has been removed."
msgstr "La relation de fourche a été supprimée."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr "L’importation expirera après %{timeout}. Pour les dépôts qui prennent plus de temps, utilisez une combinaison clone / push."
+
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 tickets montre le temps nécessaire entre la création d'un ticket et son assignation à un jalon, ou son ajout à une liste d'un tableau de tickets. Commencez par créer des tickets pour voir des données pour cette étape."
+msgid "The maximum file size allowed is 200KB."
+msgstr "La taille de fichier maximale autorisée est de 200 Ko."
+
msgid "The number of attempts GitLab will make to access a storage."
msgstr "Le nombre de tentatives que GitLab va effectuer pour accéder au stockage."
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
-msgstr "Le nombre d'échecs avant que GitLab ne commence à désactiver l'accès à la partition de stockage sur l'hôte"
-
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr "Nombre d’échecs avant que GitLab n’empêche tout accès au stockage. Ce nombre d’échecs peut être réinitialisé dans l’interface d’administration : %{link_to_health_page} ou en suivant le %{api_documentation_link}."
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr "La phrase secrète est requise pour déchiffrer la clé privée. Ceci est facultatif et la valeur est cryptée à l’arrêt."
+
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 tags spécifiées. Ces pipelines programmés héritent d’un accès partiel au projet basé sur l’utilisa·teur·trice qui leurs est associé·e."
-
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."
+msgstr "L’étape de planification montre le temps entre l’étape précédente et l’envoi de votre premier commit. Ce temps sera automatiquement ajouté quand vous pousserez votre premier commit."
+
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+msgstr "La clé privée à utiliser lorsqu’un certificat client est fourni. Cette valeur est cryptée à l’arrêt."
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 ticket 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."
@@ -2012,9 +4151,18 @@ 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 repository for this project is empty"
+msgstr "Le dépôt de ce projet est vide"
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+msgstr "Le dépôt doit être accessible via <code>http://</code>, <code>https://</code> ou <code>git://</code>."
+
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 roadmap shows the progress of your epics along a timeline"
+msgstr "La feuille de route montre la progression de vos épopées dans le temps"
+
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."
@@ -2027,17 +4175,50 @@ msgstr "Délai en secondes pendant lequel GitLab gardera les informations d’é
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr "Temps en secondes pendant lequel GitLab essaiera d’accéder au stockage. Après ce délai, une erreur d’expiration d’attente sera déclenchée."
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr "Le temps en secondes entre les vérifications de stockage. GitLab ne débute pas de nouvelle vérification si une vérification est déjà en cours."
+
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 "There are no issues to show"
+msgstr "Il n’y a aucun ticket à afficher"
+
+msgid "There are no merge requests to show"
+msgstr "Il n’y a aucune demande de fusion à afficher"
+
msgid "There are problems accessing Git storage: "
msgstr "Il y a des difficultés à accéder aux données Git : "
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
-msgstr "Cette branche a changé depuis le début de l’édition. Souhaitez-vous créer une nouvelle branche ?"
+msgid "There was an error loading results"
+msgstr "Une erreur s’est produite lors du chargement des résultats"
+
+msgid "There was an error loading users activity calendar."
+msgstr "Une erreur s’est produite lors du chargement du calendrier d’activité des utilisateurs."
+
+msgid "There was an error saving your notification settings."
+msgstr "Une erreur s’est produite lors de l’enregistrement de vos paramètres de notification."
+
+msgid "There was an error subscribing to this label."
+msgstr "Une erreur s’est produite lors de l’abonnement à ce label."
+
+msgid "There was an error when reseting email token."
+msgstr "Une erreur s’est produite lors de la réinitialisation du jeton de courriel."
+
+msgid "There was an error when subscribing to this label."
+msgstr "Une erreur s’est produite lors de l’abonnement à ce label."
+
+msgid "There was an error when unsubscribing from this label."
+msgstr "Une erreur s’est produite lors de la désinscription à ce label."
+
+msgid "This board\\'s scope is reduced"
+msgstr "La portée de ce tableau est limitée"
+
+msgid "This directory"
+msgstr "Ce répertoire"
msgid "This is a confidential issue."
msgstr "Ce ticket est confidentiel."
@@ -2045,18 +4226,54 @@ msgstr "Ce ticket est confidentiel."
msgid "This is the author's first Merge Request to this project."
msgstr "C’est la première demande de fusion de cet auteur pour ce projet."
+msgid "This issue is confidential"
+msgstr "Ce ticket est confidentiel"
+
msgid "This issue is confidential and locked."
msgstr "Ce ticket est confidentiel et verrouillé."
msgid "This issue is locked."
msgstr "Ce ticket est verrouillé."
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr "Cette tâche dépend de l’utilisateur•rice pour déclencher son processus. Elles sont souvent utilisées pour déployer du code dans des environnements de production"
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr "Cette tâche dépend des tâches en amont qui doivent réussir pour que celle-ci soit déclenchée"
+
+msgid "This job has not been triggered yet"
+msgstr "Cette tâche n’a pas encore été déclenchée"
+
+msgid "This job has not started yet"
+msgstr "Cete tâche n’a pas encore commencée"
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr "Cette tâche est en attente et attend d’être choisie par un exécuteur"
+
+msgid "This job requires a manual action"
+msgstr "Cette tâche nécessite une action manuelle"
+
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 n’avez pas créé un dépôt vide, ou que vous n’avez pas importé un dépôt existant."
msgid "This merge request is locked."
msgstr "Cette demande de fusion est verrouillée."
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr "Cette page n’est pas disponible car vous n’êtes pas autorisé à lire des informations dans plusieurs projets."
+
+msgid "This project"
+msgstr "Ce projet"
+
+msgid "This repository"
+msgstr "Ce dépôt"
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr "Cela supprimera la métrique personnalisée, êtes-vous sûr•e ?"
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr "Ces emails deviennent automatiquement des tickets (les commentaires étant extrait de la conversation par email) répertoriés ici."
+
msgid "Time before an issue gets scheduled"
msgstr "Temps avant qu’un ticket ne soit planifié"
@@ -2066,9 +4283,27 @@ msgstr "Temps avant que la résolution du ticket 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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr "Temps en secondes où GitLab attendra une réponse du service externe. Si le service ne répond pas à temps, l’accès sera refusé."
+
+msgid "Time tracking"
+msgstr "Suivi du temps"
+
msgid "Time until first merge request"
msgstr "Temps jusqu’à la première demande de fusion"
+msgid "TimeTrackingEstimated|Est"
+msgstr "Est"
+
+msgid "TimeTracking|Estimated:"
+msgstr "Estimé :"
+
+msgid "TimeTracking|Spent"
+msgstr "Passé"
+
msgid "Timeago|%s days ago"
msgstr "il y a %s jours"
@@ -2205,26 +4440,98 @@ msgstr[1] "mins"
msgid "Time|s"
msgstr "s"
+msgid "Tip:"
+msgstr "Astuce :"
+
+msgid "Title"
+msgstr "Titre"
+
+msgid "To GitLab"
+msgstr "Vers GitLab"
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr "Pour connecter les dépôts GitHub, vous pouvez utiliser un %{personal_access_token_link}. Lorsque vous créez votre jeton d’accès, vous devrez sélectionner le champ <code>repo</code>, afin que nous puissions afficher une liste de vos dépôts publics et privés qui sont disponibles pour se connecter."
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr "Pour connecter les dépôts GitHub, vous devez d’abord autoriser GitLab à accéder à la liste de vos dépôts GitHub :"
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr "Pour connecter un dépôt SVN, consultez %{svn_link}."
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr "Pour importer les dépôts GitHub, vous pouvez utiliser un %{personal_access_token_link}. Lorsque vous créez votre jeton d’accès, vous devrez sélectionner le champ <code>repo</code>, afin que nous puissions afficher une liste de vos dépôts publics et privés qui sont disponibles pour être importés."
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr "Pour importer des dépôts GitHub, vous devez d’abord autoriser GitLab à accéder à la liste de vos dépôts GitHub :"
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr "Pour importer un dépôt SVN, consultez %{svn_link}."
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr "Pour utiliser uniquement les fonctionnalités CI / CD pour un dépôt externe, choisissez <strong>CI / CD pour le dépôt externe</strong>."
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr "Pour valider vos configurations GitLab CI, allez dans 'CI / CD → Pipelines' dans votre projet, et cliquez sur le bouton 'CI Lint'."
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr "Pour afficher la feuille de route, ajoutez une date de début ou de fin planifiée à l’une de vos épopées dans ce groupe ou ses sous-groupes. Seules les épopées des 3 derniers mois et des 3 prochains mois sont affichées."
+
+msgid "Todo"
+msgstr "Tâche"
+
+msgid "Toggle sidebar"
+msgstr "Afficher/masquer la barre latérale"
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr "État du commutateur : Inactif"
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr "État du commutateur : Actif"
+
msgid "Total Time"
msgstr "Temps total"
msgid "Total test time for all commits/merges"
-msgstr "Temps total de test pour toutes les validations/fusions"
+msgstr "Temps total de test pour tous les commits/fusions"
+
+msgid "Total: %{total}"
+msgstr "Total : %{total}"
msgid "Track activity with Contribution Analytics."
msgstr "Suivre l’activité avec Contribution Analytics."
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr "Suivez les groupes de tickets qui partagent un thème, entre projets et jalons"
+
+msgid "Track time with quick actions"
+msgstr "Suivre le temps estimé/passé avec les actions rapides"
+
+msgid "Trigger this manual action"
+msgstr "Déclencher cette action manuelle"
+
+msgid "Turn on Service Desk"
+msgstr "Activer le Service Desk"
+
+msgid "Unknown"
+msgstr "Inconnu"
+
msgid "Unlock"
msgstr "Déverrouiller"
msgid "Unlocked"
msgstr "Déverrouillé"
+msgid "Unresolve discussion"
+msgstr "Marquer la discussion comme non résolue"
+
msgid "Unstar"
msgstr "Supprimer des favoris"
-msgid "Unsubscribe"
-msgstr "Se désabonner"
+msgid "Up to date"
+msgstr "À jour"
msgid "Upgrade your plan to activate Advanced Global Search."
msgstr "Mettez à jour votre abonnement pour activer la Recherche Globale Avancée."
@@ -2239,7 +4546,7 @@ msgid "Upgrade your plan to activate Issue weight."
msgstr "Mettez à niveau votre abonnement pour activer les poids de ticket."
msgid "Upgrade your plan to improve Issue boards."
-msgstr "Mettez à niveau votre abonnement pour améliorer les tableaux de tickets."
+msgstr "Mettez à niveau votre forfait pour améliorer les tableaux de tickets."
msgid "Upload New File"
msgstr "Téléverser un nouveau fichier"
@@ -2247,24 +4554,72 @@ msgstr "Téléverser un nouveau fichier"
msgid "Upload file"
msgstr "Téléverser un fichier"
+msgid "Upload new avatar"
+msgstr "Importer un nouvel avatar"
+
msgid "UploadLink|click to upload"
msgstr "Cliquez pour envoyer"
+msgid "Upvotes"
+msgstr "Votes positifs"
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr "Utilisez Service Desk pour intéragir avec vos utilisateurs (par exemple pour offrir un support client) par email directement dans GitLab"
+
msgid "Use the following registration token during setup:"
msgstr "Utiliser le jeton d’inscription suivant pendant l’installation :"
msgid "Use your global notification setting"
msgstr "Utiliser vos paramètres de notification globaux"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr "Les variables sont appliquées aux environnements via l’exécuteur. Elles peuvent être protégées en les exposant uniquement à des branches ou des tags protégées. Vous pouvez utiliser des variables pour les mots de passe, les clés secrètes ou tout ce que vous voulez."
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr "Afficher et modifier les lignes"
+
+msgid "View epics list"
+msgstr "Afficher la liste des épopées"
+
msgid "View file @ "
msgstr "Voir le fichier @ "
+msgid "View group labels"
+msgstr "Afficher les labels de groupe"
+
+msgid "View labels"
+msgstr "Afficher les labels"
+
msgid "View open merge request"
msgstr "Afficher la demande de fusion"
+msgid "View project labels"
+msgstr "Afficher les labels de projet"
+
msgid "View replaced file @ "
msgstr "Voir le fichier remplacé @ "
+msgid "Visibility and access controls"
+msgstr "Contrôles de visibilité et d’accès"
+
msgid "VisibilityLevel|Internal"
msgstr "Interne"
@@ -2280,17 +4635,29 @@ 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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr "Nous n’avons pas pu vérifier que l’un de vos projets sur GCP est activé pour la facturation. Veuillez réessayer."
+
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 "We want to be sure it is you, please confirm you are not a robot."
+msgstr "Nous voulons être sûrs que c'est bien vous, merci de confirmer que vous n’êtes pas un robot."
+
+msgid "Web IDE"
+msgstr "Web IDE"
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr "Les webhooks vous permettent d’appeler une URL si, par exemple, du nouveau code est poussé ou un nouveau ticket est créé. Vous pouvez configurer les webhooks pour écouter les événements spécifiques comme des poussées de code, des tickets ou des demandes de fusion. Les webhooks de groupes s’appliqueront à tous les projets dans un groupe, ce qui vous permet de normaliser la fonctionnalité du webhook dans votre groupe entier."
msgid "Weight"
msgstr "Poids"
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
-msgstr "Si l’accès à un stockage échoue, GitLab empêchera l’accès au stockage pendant la durée spécifiée ici. Cela permettra au système de fichiers de récupérer. Les dépôts présents sur les secteurs en erreur seront temporairement indisponibles."
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr "Lorsque vous laissez l’URL vide, les étiquettes de classification peuvent toujours être spécifiées sans désactiver les fonctionnalités inter-projets ou effectuer des vérifications d’autorisation externes."
msgid "Wiki"
msgstr "Wiki"
@@ -2310,6 +4677,12 @@ msgstr "Il est recommandé d’installer %{markdown} pour que les spécificités
msgid "WikiClone|Start Gollum and edit locally"
msgstr "Démarrer Gollum et modifier localement"
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr "Astuce : Vous pouvez déplacer cette page en ajoutant le chemin au début du titre."
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr "Il y a déjà une page avec le même titre pour ce chemin."
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr "Vous n’êtes pas autorisé·e à créer des pages wiki"
@@ -2400,39 +4773,69 @@ msgstr "Avec Contribution Analytics vous pouvez avoir un aperçu de l’activitÃ
msgid "Withdraw Access Request"
msgstr "Retirer la demande d'accès"
+msgid "Write a commit message..."
+msgstr "Écrire un message de commit…"
+
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·e ?"
-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·e ?"
+msgid "You are going to remove %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Vous êtes sur le point de supprimer %{project_full_name}. Les projets supprimés NE PEUVENT PAS être restaurés ! Êtes-vous ABSOLUMENT sûr•e ?"
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 ABSOLUMENT sûr·e ?"
-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 ABSOLUMENT sûr·e ?"
+msgid "You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr "Vous allez transférer %{project_full_name} à un•e nouveau•elle propriétaire. Êtes-vous VRAIMENT sûr•e ?"
msgid "You are on a read-only GitLab instance."
msgstr "Vous êtes sur une instance GitLab en lecture seule."
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
-msgstr "Vous êtes sur une instance GitLab en lecture seule. Si vous souhaitez apporter des modifications, vous devez aller sur %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr "Vous êtes sur un nœud Geo secondaire (en lecture seule). Si vous voulez apporter des modifications, vous devez visiter le %{primary_node}."
+
+msgid "You can also create a project from the command line."
+msgstr "Vous pouvez également créer un projet en ligne de commande."
+
+msgid "You can also star a label to make it a priority label."
+msgstr "Vous pouvez marquer un label comme important pour en faire un label prioritaire."
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr "Vous pouvez facilement installer un Exécuteur sur un cluster Kubernetes. %{link_to_help_page}"
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr "Vous pouvez vous déplacer dans le graphique en utilisant les touches fléchées."
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 can only edit files when you are on a branch"
+msgstr "Vous ne pouvez éditer de fichier que dans une branche"
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr "Vous ne pouvez pas écrire sur une instance GitLab Geo secondaire en lecture-seule. Veuillez utiliser le %{link_to_primary_node} à la place."
msgid "You cannot write to this read-only GitLab instance."
msgstr "Vous ne pouvez pas écrire sur cette instance GitLab en lecture-seule."
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr "Vous ne disposez pas des autorisations appropriées pour remplacer les paramètres de synchronisation du groupe LDAP."
+
+msgid "You have no permissions"
+msgstr "Vous n’avez pas les autorisations"
+
msgid "You have reached your project limit"
msgstr "Vous avez atteint votre limite de projet"
+msgid "You must have master access to force delete a lock"
+msgstr "Vous devez avoir un accès Expert pour forcer la suppression d’un verrou"
+
msgid "You must sign in to star a project"
msgstr "Vous devez vous connecter pour mettre un projet en favori"
+msgid "You need a different license to enable FileLocks feature"
+msgstr "Vous avez besoin d’une licence différente pour activer la fonctionnalité FileLocks"
+
msgid "You need permission."
msgstr "Vous avez besoin d’une autorisation."
@@ -2457,6 +4860,36 @@ msgstr "Vous ne pourrez pas récupérer ou pousser de code par %{protocol} tant
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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr "Vous ne pourrez pas récupérer ou pousser de code par SSH tant que vous n’aurez pas ajouté de clé SSH à votre profil"
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr "Vous devrez utiliser différents noms de branches pour obtenir une comparaison valide."
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr "Vous recevez ce courriel en raison de votre compte sur %{host}. %{manage_notifications_link} &middot; %{help_link}"
+
+msgid "Your Groups"
+msgstr "Vos groupes"
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr "Vos informations de cluster Kubernetes sur cette page sont toujours modifiables, mais il est conseillé de désactiver et reconfigurer"
+
+msgid "Your Projects (default)"
+msgstr "Vos projets (défaut)"
+
+msgid "Your Projects' Activity"
+msgstr "Activité de vos projets favoris"
+
+msgid "Your Todos"
+msgstr "Vos tâches à faire"
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr "Vos modifications peuvent être validées sur %{branch_name} car une demande de fusion est ouverte."
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr "Vos modifications ont été validées. Commit %{commitId} %{commitStats}"
+
msgid "Your comment will not be visible to the public."
msgstr "Votre commentaire ne sera pas visible publiquement."
@@ -2469,20 +4902,352 @@ msgstr "Votre nom"
msgid "Your projects"
msgstr "Vos projets"
-msgid "commit"
-msgstr "validation"
+msgid "among other things"
+msgstr "entre autres choses"
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] "et %d vulnérabilité corrigée"
+msgstr[1] "et %d vulnérabilités corrigées"
+
+msgid "assign yourself"
+msgstr "assignez vous"
+
+msgid "branch name"
+msgstr "nom de la branche"
+
+msgid "by"
+msgstr "par"
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr "%{type} n’a détecté aucune nouvelle vulnérabilité de sécurité"
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr "%{type} n’a détecté aucune vulnérabilité de sécurité"
+
+msgid "ciReport|Code quality"
+msgstr "Qualité du code"
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr "DAST n’a détecté aucune alerte en analysant l’application de revue"
+
+msgid "ciReport|Dependency scanning"
+msgstr "Analyse de dépendance"
+
+msgid "ciReport|Dependency scanning detected"
+msgstr "Analyse de dépendance détectée"
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr "L’analyse des dépendances n’a détecté aucune nouvelle vulnérabilité de sécurité"
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr "L’analyse des dépendances n’a détecté aucune vulnérabilité de sécurité"
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr "Impossible de charger le rapport %{reportName}"
+
+msgid "ciReport|Fixed:"
+msgstr "Réparé :"
+
+msgid "ciReport|Instances"
+msgstr "Instances"
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr "En savoir plus sur la liste blanche"
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr "Chargement du rapport %{reportName}"
+
+msgid "ciReport|No changes to code quality"
+msgstr "Aucun changement dans la qualité du code"
+
+msgid "ciReport|No changes to performance metrics"
+msgstr "Aucun changement dans les indicateurs de performance"
+
+msgid "ciReport|Performance metrics"
+msgstr "Indicateurs de performance"
+
+msgid "ciReport|SAST"
+msgstr "SAST"
+
+msgid "ciReport|SAST detected"
+msgstr "SAST détectée"
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr "SAST n’a détecté aucune nouvelle vulnérabilité de sécurité"
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr "SAST n’a détecté aucune vulnérabilité de sécurité"
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr "SAST:conteneur aucune vulnérabilité n’a été trouvée"
+
+msgid "ciReport|Security scanning"
+msgstr "Analyse de sécurité"
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr "L’analyse de sécurité n’a pas réussi à charger de résultats"
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr "Afficher le rapport complet sur les vulnérabilités du code"
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr "Les vulnérabilités non approuvées (en rouge) peuvent être marquées comme approuvées. %{helpLink}"
+
+msgid "ciReport|no vulnerabilities"
+msgstr "aucune vulnérabilité"
+
+msgid "command line instructions"
+msgstr "instructions en ligne de commande"
+
+msgid "connecting"
+msgstr "connexion en cours"
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr "impossible de lire la clé privée, la phrase secrète est-elle correcte ?"
msgid "day"
msgid_plural "days"
msgstr[0] "jour"
msgstr[1] "jours"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] "a détecté %d vulnérabilité corrigée"
+msgstr[1] "a détecté %d vulnérabilités corrigées"
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] "a détecté %d nouvelle vulnérabilité"
+msgstr[1] "a détecté %d nouvelle vulnérabilité"
+
+msgid "detected no vulnerabilities"
+msgstr "n’a détecté aucune vulnérabilité"
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr "%{slash_command} mettra à jour la durée estimée avec la dernière commande."
+
+msgid "here"
+msgstr "ici"
+
+msgid "importing"
+msgstr "importation en cours"
+
+msgid "in progress"
+msgstr "en cours"
+
+msgid "is invalid because there is downstream lock"
+msgstr "est invalide car il y a un verrou en aval"
+
+msgid "is invalid because there is upstream lock"
+msgstr "est invalide car il y a un verrou en amont"
+
+msgid "is not a valid X509 certificate."
+msgstr "n’est pas un certificat X509 valide."
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr "verrouillé par %{path_lock_user_name} %{created_at}"
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] "demande de fusion"
+msgstr[1] "demandes de fusion"
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr "Veuillez la restaurer ou utiliser une autre branche %{missingBranchName}"
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr "%{metricsLinkStart}L’usage mémoire%{metricsLinkEnd} %{emphasisStart}a diminué%{emphasisEnd} de %{memoryFrom}MO à %{memoryTo}MO"
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr "%{metricsLinkStart}L’usage mémoire%{metricsLinkEnd} %{emphasisStart}a augmenté%{emphasisEnd} de %{memoryFrom}MO à %{memoryTo}MO"
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr "%{metricsLinkStart}L’usage mémoire%{metricsLinkEnd} %{emphasisStart}est resté stable%{emphasisEnd} à %{memoryFrom}MO"
+
+msgid "mrWidget|Add approval"
+msgstr "Ajouter une approbation"
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr "Autoriser les modifications par les mainteneurs"
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr "Une erreur est survenue lors de la suppression de votre approbation."
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr "Une erreur s’est produite lors de la récupération des données d’approbation pour cette demande de fusion."
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr "Une erreur est survenue pendant l’envoi de votre approbation."
+
+msgid "mrWidget|Approve"
+msgstr "Approuver"
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr "Approuvée par"
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr "Annuler la fusion automatique"
+
+msgid "mrWidget|Check out branch"
+msgstr "Récupérer la branche"
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr "Vérification de la possibilité de fusion automatique"
+
+msgid "mrWidget|Cherry-pick"
+msgstr "Picorer"
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr "Picorer cette demande de fusion dans une nouvelle demande de fusion"
+
+msgid "mrWidget|Closed"
+msgstr "Fermée"
+
+msgid "mrWidget|Closed by"
+msgstr "Fermée par"
+
+msgid "mrWidget|Closes"
+msgstr "Résout"
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr "Les statistiques de déploiement ne sont pas disponibles pour le moment"
+
+msgid "mrWidget|Did not close"
+msgstr "N’a pas résolu"
+
+msgid "mrWidget|Email patches"
+msgstr "Patchs par courriel"
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr "Impossible de charger les statistiques de déploiement"
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr "Si la branche %{branch} existe dans votre dépôt local, vous pouvez fusionner cette demande de fusion manuellement à l’aide de"
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr "Si la branche %{missingBranchName} existe dans votre dépôt local, vous pouvez fusionner cette demande de fusion manuellement en ligne de commande"
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr "mrWidget | Chargement des statistiques de déploiement"
+
+msgid "mrWidget|Mentions"
+msgstr "Mentionne"
+
+msgid "mrWidget|Merge"
+msgstr "Fusionner"
+
+msgid "mrWidget|Merge failed."
+msgstr "La fusion a échoué."
+
+msgid "mrWidget|Merge locally"
+msgstr "Fusionner localement"
+
+msgid "mrWidget|Merged by"
+msgstr "Fusionnée par"
+
+msgid "mrWidget|Plain diff"
+msgstr "Diff simple"
+
+msgid "mrWidget|Refresh"
+msgstr "Actualiser"
+
+msgid "mrWidget|Refresh now"
+msgstr "Actualiser maintenant"
+
+msgid "mrWidget|Refreshing now"
+msgstr "Actualisation en cours"
+
+msgid "mrWidget|Remove Source Branch"
+msgstr "Supprimer la branche source"
+
+msgid "mrWidget|Remove source branch"
+msgstr "Supprimer la branche source"
+
+msgid "mrWidget|Remove your approval"
+msgstr "Supprimer votre approbation"
+
+msgid "mrWidget|Request to merge"
+msgstr "Demande de fusion de"
+
+msgid "mrWidget|Resolve conflicts"
+msgstr "Résoudre les conflits"
+
+msgid "mrWidget|Revert"
+msgstr "Défaire"
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr "Défaire cette demande de fusion dans une nouvelle demande de fusion"
+
+msgid "mrWidget|Set by"
+msgstr "Marqué par"
+
+msgid "mrWidget|The changes were merged into"
+msgstr "Les modifications ont été fusionnées dans"
+
+msgid "mrWidget|The changes were not merged into"
+msgstr "Les modifications n’ont pas été fusionnées dans"
+
+msgid "mrWidget|The changes will be merged into"
+msgstr "Les modifications seront fusionnées dans"
+
+msgid "mrWidget|The source branch has been removed"
+msgstr "La branche source a été supprimée"
+
+msgid "mrWidget|The source branch is being removed"
+msgstr "La branche source est en cours de suppression"
+
+msgid "mrWidget|The source branch will be removed"
+msgstr "La branche source sera supprimée"
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr "La branche source ne sera pas supprimée"
+
+msgid "mrWidget|There are merge conflicts"
+msgstr "Il y a des conflits de fusion"
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr "Cette demande de fusion n’a pas pu être fusionnée automatiquement"
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr "Cette demande de fusion est en cours de fusion"
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr "Ce projet est archivé, l’accès en écriture a été désactivé"
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr "Vous pouvez fusionner cette demande de fusion manuellement à l’aide de la"
+
+msgid "mrWidget|You can remove source branch now"
+msgstr "Vous pouvez maintenant supprimer la branche source"
+
+msgid "mrWidget|branch does not exist."
+msgstr "la branche n’existe pas."
+
+msgid "mrWidget|command line"
+msgstr "ligne de commande"
+
+msgid "mrWidget|into"
+msgstr "dans"
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr "pour être fusionnée automatiquement lorsque le pipeline réussit"
+
msgid "new merge request"
msgstr "nouvelle demande de fusion"
msgid "notification emails"
msgstr "courriels de notification"
+msgid "or"
+msgstr "ou"
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "parent"
@@ -2494,9 +5259,30 @@ msgstr "mot de passe"
msgid "personal access token"
msgstr "jeton d’accès personnel"
+msgid "private key does not match certificate."
+msgstr "clé privée ne correspond pas au certificat."
+
+msgid "remove due date"
+msgstr "supprimer la date d’échéance"
+
+msgid "source"
+msgstr "source"
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr "%{slash_command} mettra à jour la somme du temps passé."
+
+msgid "this document"
+msgstr "ce document"
+
msgid "to help your contributors communicate effectively!"
msgstr "pour aider vos contributeurs à communiquer efficacement !"
msgid "username"
msgstr "nom d’utilisateur"
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr "utilise les clusters Kubernetes pour déployer votre code !"
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr "avec %{additions} ajouts, %{deletions} suppressions."
+
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3ebc7859232..d7eb123b48b 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-12-05 20:31+0100\n"
-"PO-Revision-Date: 2017-12-05 20:31+0100\n"
+"POT-Creation-Date: 2018-04-04 18:02+0200\n"
+"PO-Revision-Date: 2018-04-04 18:02+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -23,17 +23,45 @@ msgid_plural "%d commits"
msgstr[0] ""
msgstr[1] ""
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
msgstr[1] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
msgid "%{count} participant"
@@ -41,16 +69,19 @@ msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
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."
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
@@ -81,15 +112,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr ""
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr ""
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -99,6 +145,9 @@ msgstr ""
msgid "Account"
msgstr ""
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr ""
@@ -111,27 +160,153 @@ msgstr ""
msgid "Add Contribution guide"
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
msgid "Add License"
msgstr ""
+msgid "Add Readme"
+msgstr ""
+
msgid "Add new directory"
msgstr ""
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr ""
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
msgid "An error occurred when toggling the notification subscription"
msgstr ""
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
msgid "An error occurred while fetching sidebar data"
msgstr ""
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
@@ -153,12 +328,6 @@ 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 leave this group?"
-msgstr ""
-
msgid "Are you sure you want to reset registration token?"
msgstr ""
@@ -171,6 +340,30 @@ msgstr ""
msgid "Artifacts"
msgstr ""
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr ""
@@ -186,13 +379,22 @@ msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -210,14 +412,32 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
msgstr ""
msgid "Available"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr ""
+
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
msgstr[0] ""
msgstr[1] ""
@@ -242,6 +462,15 @@ msgstr ""
msgid "Branches"
msgstr ""
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -287,12 +516,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The default branch cannot be deleted"
msgstr ""
@@ -335,7 +591,7 @@ msgstr ""
msgid "CI / CD"
msgstr ""
-msgid "CI configuration"
+msgid "CI/CD configuration"
msgstr ""
msgid "CICD|Jobs"
@@ -344,7 +600,10 @@ msgstr ""
msgid "Cancel"
msgstr ""
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
msgstr ""
msgid "ChangeTypeActionLabel|Pick into branch"
@@ -359,15 +618,24 @@ msgstr ""
msgid "ChangeTypeAction|Revert"
msgstr ""
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr ""
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr ""
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
msgid "Checking %{text} availability…"
msgstr ""
@@ -380,6 +648,18 @@ msgstr ""
msgid "Cherry-pick this merge request"
msgstr ""
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
msgid "CiStatusLabel|canceled"
msgstr ""
@@ -434,76 +714,82 @@ msgstr ""
msgid "CiStatus|running"
msgstr ""
-msgid "CircuitBreakerApiLink|circuitbreaker api"
+msgid "CiVariables|Input variable key"
msgstr ""
-msgid "Clone repository"
+msgid "CiVariables|Input variable value"
msgstr ""
-msgid "Cluster"
+msgid "CiVariables|Remove variable row"
msgstr ""
-msgid "ClusterIntegration|%{appList} was successfully installed on your cluster"
+msgid "CiVariable|* (All environments)"
msgstr ""
-msgid "ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which incur additional costs. See %{pricingLink}"
+msgid "CiVariable|All environments"
msgstr ""
-msgid "ClusterIntegration|API URL"
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|Protected"
msgstr ""
-msgid "ClusterIntegration|Active"
+msgid "CiVariable|Toggle protected"
msgstr ""
-msgid "ClusterIntegration|Add an existing cluster"
+msgid "CiVariable|Validation failed"
msgstr ""
-msgid "ClusterIntegration|Add cluster"
+msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
-msgid "ClusterIntegration|All"
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
msgstr ""
-msgid "ClusterIntegration|Applications"
+msgid "Click to expand text"
msgstr ""
-msgid "ClusterIntegration|CA Certificate"
+msgid "Clone repository"
msgstr ""
-msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgid "Close"
msgstr ""
-msgid "ClusterIntegration|Choose how to set up cluster integration"
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster"
+msgid "ClusterIntegration|API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Add Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Applications"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine. Refresh the page to see cluster's details"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
msgid "ClusterIntegration|Copy API URL"
@@ -512,37 +798,37 @@ msgstr ""
msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Copy Token"
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Create a new cluster on Google Engine right from GitLab"
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Create cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Create on GKE"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Enter the details for your cluster"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Environment pattern"
+msgid "ClusterIntegration|Environment scope"
msgstr ""
-msgid "ClusterIntegration|GKE pricing"
+msgid "ClusterIntegration|GitLab Integration"
msgstr ""
msgid "ClusterIntegration|GitLab Runner"
@@ -560,16 +846,13 @@ msgstr ""
msgid "ClusterIntegration|Helm Tiller"
msgstr ""
-msgid "ClusterIntegration|Inactive"
-msgstr ""
-
msgid "ClusterIntegration|Ingress"
msgstr ""
-msgid "ClusterIntegration|Install"
+msgid "ClusterIntegration|Ingress IP Address"
msgstr ""
-msgid "ClusterIntegration|Install applications on your cluster. Read more about %{helpLink}"
+msgid "ClusterIntegration|Install"
msgstr ""
msgid "ClusterIntegration|Installed"
@@ -578,28 +861,67 @@ msgstr ""
msgid "ClusterIntegration|Installing"
msgstr ""
-msgid "ClusterIntegration|Integrate cluster automation"
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
msgstr ""
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
-msgid "ClusterIntegration|Learn more about Clusters"
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
msgstr ""
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
msgstr ""
-msgid "ClusterIntegration|Manage cluster integration on your GitLab project"
+msgid "ClusterIntegration|Manage"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
msgstr ""
-msgid "ClusterIntegration|Multiple clusters are available in GitLab Entreprise Edition Premium and Ultimate"
+msgid "ClusterIntegration|More information"
msgstr ""
msgid "ClusterIntegration|Note:"
@@ -608,18 +930,12 @@ msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
-msgid "ClusterIntegration|Please enter access information for your cluster. If you need help, you can read our %{link_to_help_page} on clusters"
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
msgstr ""
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
-msgid "ClusterIntegration|Problem setting up the cluster"
-msgstr ""
-
-msgid "ClusterIntegration|Problem setting up the clusters list"
-msgstr ""
-
msgid "ClusterIntegration|Project ID"
msgstr ""
@@ -629,16 +945,19 @@ msgstr ""
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your cluster on Google Kubernetes Engine."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
msgstr ""
msgid "ClusterIntegration|Request to begin installing failed"
@@ -647,7 +966,10 @@ msgstr ""
msgid "ClusterIntegration|Save changes"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -668,25 +990,28 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
msgid "ClusterIntegration|Something went wrong while installing %{title}"
msgstr ""
-msgid "ClusterIntegration|There are no clusters to show"
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
msgstr ""
-msgid "ClusterIntegration|This account must have permissions to create a cluster in the %{link_to_container_project} specified below"
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|Token"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -698,7 +1023,7 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
msgstr ""
msgid "ClusterIntegration|documentation"
@@ -716,6 +1041,12 @@ msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr ""
@@ -724,6 +1055,11 @@ msgid_plural "Commits"
msgstr[0] ""
msgstr[1] ""
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Commit Message"
msgstr ""
@@ -733,6 +1069,12 @@ msgstr ""
msgid "Commit message"
msgstr ""
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr ""
@@ -745,15 +1087,87 @@ msgstr ""
msgid "Commits feed"
msgstr ""
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr ""
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr ""
msgid "Compare"
msgstr ""
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -799,12 +1213,21 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr ""
msgid "Contributors"
msgstr ""
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
msgid "ContributorsPage|Building repository graph."
msgstr ""
@@ -817,27 +1240,57 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr ""
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr ""
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr ""
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr ""
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr ""
-msgid "Create empty bare repository"
+msgid "Create empty repository"
msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr ""
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -847,9 +1300,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr ""
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr ""
@@ -859,6 +1318,12 @@ msgstr ""
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr ""
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
msgid "Cron Timezone"
msgstr ""
@@ -874,9 +1339,6 @@ 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 ""
@@ -930,10 +1392,13 @@ msgstr ""
msgid "Details"
msgstr ""
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr ""
-msgid "Discard changes"
+msgid "Discard draft"
msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
@@ -942,6 +1407,9 @@ msgstr ""
msgid "Don't show again"
msgstr ""
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr ""
@@ -969,15 +1437,45 @@ msgstr ""
msgid "DownloadSource|Download"
msgstr ""
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
msgid "Edit"
msgstr ""
msgid "Edit Pipeline Schedule %{id}"
msgstr ""
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
msgid "Environments|An error occurred while fetching the environments."
msgstr ""
@@ -996,9 +1494,6 @@ msgstr ""
msgid "Environments|Environments"
msgstr ""
-msgid "Environments|Environments are places where code gets deployed, such as staging or production."
-msgstr ""
-
msgid "Environments|Job"
msgstr ""
@@ -1029,9 +1524,42 @@ msgstr ""
msgid "Environments|You don't have any environments right now."
msgstr ""
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
msgid "Error occurred when toggling the notification subscription"
msgstr ""
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
@@ -1065,24 +1593,42 @@ msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr ""
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr ""
+msgid "Failed to update issues, please try again."
+msgstr ""
+
msgid "Feb"
msgstr ""
msgid "February"
msgstr ""
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr ""
+msgid "Files (%{human_size})"
+msgstr ""
+
msgid "Filter by commit message"
msgstr ""
@@ -1092,6 +1638,9 @@ msgstr ""
msgid "Find file"
msgstr ""
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr ""
@@ -1109,24 +1658,57 @@ msgstr ""
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr ""
msgid "From merge request merge until deploy to production"
msgstr ""
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr ""
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr ""
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr ""
@@ -1136,6 +1718,9 @@ msgstr ""
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1172,12 +1757,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1223,18 +1802,59 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr ""
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1256,12 +1876,18 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
msgid "Jan"
msgstr ""
msgid "January"
msgstr ""
+msgid "Jobs"
+msgstr ""
+
msgid "Jul"
msgstr ""
@@ -1274,15 +1900,60 @@ msgstr ""
msgid "June"
msgstr ""
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr ""
msgid "LFSStatus|Enabled"
msgstr ""
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] ""
@@ -1312,6 +1983,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr ""
@@ -1327,26 +2007,45 @@ 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 "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Login"
msgstr ""
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
msgid "Mar"
msgstr ""
msgid "March"
msgstr ""
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
@@ -1368,19 +2067,67 @@ msgstr ""
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr ""
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr ""
-msgid "New Cluster"
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1388,6 +2135,12 @@ msgid_plural "New Issues"
msgstr[0] ""
msgstr[1] ""
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr ""
@@ -1409,6 +2162,9 @@ msgstr ""
msgid "New issue"
msgstr ""
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr ""
@@ -1427,7 +2183,25 @@ msgstr ""
msgid "New tag"
msgstr ""
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1436,18 +2210,36 @@ msgstr ""
msgid "No schedules"
msgstr ""
-msgid "No time spent"
+msgid "None"
msgstr ""
-msgid "None"
+msgid "Not allowed to merge"
msgstr ""
msgid "Not available"
msgstr ""
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr ""
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr ""
@@ -1505,6 +2297,12 @@ msgstr ""
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
msgid "Nov"
msgstr ""
@@ -1514,9 +2312,6 @@ msgstr ""
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
-msgstr ""
-
msgid "Oct"
msgstr ""
@@ -1538,12 +2333,21 @@ msgstr ""
msgid "Options"
msgstr ""
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr ""
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1556,10 +2360,16 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1601,12 +2411,6 @@ msgstr ""
msgid "PipelineSchedules|Inactive"
msgstr ""
-msgid "PipelineSchedules|Input variable key"
-msgstr ""
-
-msgid "PipelineSchedules|Input variable value"
-msgstr ""
-
msgid "PipelineSchedules|Next Run"
msgstr ""
@@ -1616,9 +2420,6 @@ msgstr ""
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr ""
-msgid "PipelineSchedules|Remove variable row"
-msgstr ""
-
msgid "PipelineSchedules|Take ownership"
msgstr ""
@@ -1646,6 +2447,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr ""
@@ -1658,6 +2510,18 @@ msgstr ""
msgid "Pipeline|with stages"
msgstr ""
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
@@ -1667,6 +2531,9 @@ msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1706,6 +2573,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1721,6 +2594,12 @@ msgstr ""
msgid "Project access must be granted explicitly to each user."
msgstr ""
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr ""
@@ -1760,12 +2639,6 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph"
msgstr ""
-msgid "ProjectSettings|Immediately run a pipeline on the default branch"
-msgstr ""
-
-msgid "ProjectSettings|Problem setting up the CI/CD settings JavaScript"
-msgstr ""
-
msgid "Projects"
msgstr ""
@@ -1790,12 +2663,39 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
msgid "PrometheusService|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."
msgstr ""
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
msgid "PrometheusService|Finding and configuring metrics..."
msgstr ""
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
msgid "PrometheusService|Metrics"
msgstr ""
@@ -1805,22 +2705,37 @@ msgstr ""
msgid "PrometheusService|Missing environment variable"
msgstr ""
-msgid "PrometheusService|Monitored"
+msgid "PrometheusService|More information"
msgstr ""
-msgid "PrometheusService|More information"
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
msgstr ""
-msgid "PrometheusService|No metrics are being monitored. To start monitoring, deploy to an environment."
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
msgstr ""
-msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgid "PrometheusService|Time-series monitoring service"
msgstr ""
-msgid "PrometheusService|Prometheus monitoring"
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
msgstr ""
-msgid "PrometheusService|View environments"
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
msgstr ""
msgid "Public - The group and any public projects can be viewed without any authentication."
@@ -1832,6 +2747,15 @@ msgstr ""
msgid "Push events"
msgstr ""
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr ""
@@ -1844,6 +2768,12 @@ msgstr ""
msgid "RefSwitcher|Tags"
msgstr ""
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Related Commits"
msgstr ""
@@ -1862,15 +2792,27 @@ msgstr ""
msgid "Related Merged Requests"
msgstr ""
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr ""
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr ""
msgid "Repository"
msgstr ""
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr ""
@@ -1883,16 +2825,33 @@ msgstr ""
msgid "Reset runners registration token"
msgstr ""
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Revert this commit"
msgstr ""
msgid "Revert this merge request"
msgstr ""
-msgid "SSH Keys"
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Runners"
msgstr ""
-msgid "Save"
+msgid "Running"
+msgstr ""
+
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1901,55 +2860,97 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr ""
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr ""
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr ""
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr ""
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
msgid "Select Archive Format"
msgstr ""
msgid "Select a timezone"
msgstr ""
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr ""
+msgid "Send email"
+msgstr ""
+
msgid "Sep"
msgstr ""
msgid "September"
msgstr ""
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr ""
-msgid "Set up CI"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
msgstr ""
-msgid "Set up Koding"
+msgid "Set notification email for abuse reports."
msgstr ""
-msgid "Set up auto deploy"
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
+
+msgid "Set up Koding"
msgstr ""
msgid "SetPasswordToCloneLink|set a password"
@@ -1958,6 +2959,12 @@ msgstr ""
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1969,13 +2976,25 @@ msgid_plural "Showing %d events"
msgstr[0] ""
msgstr[1] ""
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName}"
+msgid "Something went wrong when toggling the button"
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1984,6 +3003,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -2077,6 +3099,9 @@ msgstr ""
msgid "Source"
msgstr ""
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr ""
@@ -2086,12 +3111,21 @@ msgstr ""
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr ""
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -2101,13 +3135,19 @@ msgstr ""
msgid "Start the Runner!"
msgstr ""
+msgid "Started"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
msgid "Stopped"
msgstr ""
-msgid "Subgroups"
+msgid "Storage"
msgstr ""
-msgid "Subscribe"
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
@@ -2116,8 +3156,8 @@ msgstr ""
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
msgstr[0] ""
msgstr[1] ""
@@ -2196,10 +3236,16 @@ msgstr ""
msgid "Target Branch"
msgstr ""
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
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."
@@ -2211,13 +3257,16 @@ msgstr ""
msgid "The fork relationship has been removed."
msgstr ""
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
@@ -2226,9 +3275,6 @@ 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 ""
@@ -2244,6 +3290,12 @@ msgstr ""
msgid "The repository for this project does not exist."
msgstr ""
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 ""
@@ -2259,16 +3311,43 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+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 no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2277,18 +3356,48 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr ""
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr ""
@@ -2298,9 +3407,21 @@ msgstr ""
msgid "Time between merge request creation and merge/close"
msgstr ""
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr ""
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr ""
@@ -2437,25 +3558,64 @@ msgstr[1] ""
msgid "Time|s"
msgstr ""
-msgid "Total Time"
+msgid "Tip:"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
msgstr ""
-msgid "Total issue time spent"
+msgid "Total Time"
msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
+msgid "Total: %{total}"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr ""
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upload New File"
@@ -2464,24 +3624,51 @@ msgstr ""
msgid "Upload file"
msgstr ""
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr ""
+msgid "Upvotes"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr ""
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr ""
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr ""
@@ -2497,10 +3684,16 @@ msgstr ""
msgid "Want to see the data? Please ask an administrator for access."
msgstr ""
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
msgstr ""
msgid "Wiki"
@@ -2521,6 +3714,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2608,24 +3807,42 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr ""
+msgid "Write a commit message..."
+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?"
+msgid "You are going to remove %{project_full_name}. 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?"
+msgid "You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr ""
+
msgid "You can only add files when you are on a branch"
msgstr ""
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
@@ -2662,6 +3879,33 @@ msgstr ""
msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
msgstr ""
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2674,20 +3918,208 @@ msgstr ""
msgid "Your projects"
msgstr ""
+msgid "among other things"
+msgstr ""
+
+msgid "assign yourself"
+msgstr ""
+
msgid "branch name"
msgstr ""
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
msgid "day"
msgid_plural "days"
msgstr[0] ""
msgstr[1] ""
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr ""
msgid "notification emails"
msgstr ""
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] ""
@@ -2699,8 +4131,23 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "remove due date"
+msgstr ""
+
msgid "source"
msgstr ""
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "username"
msgstr ""
+
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
diff --git a/locale/id_ID/gitlab.po b/locale/id_ID/gitlab.po
new file mode 100644
index 00000000000..adb9746e854
--- /dev/null
+++ b/locale/id_ID/gitlab.po
@@ -0,0 +1,5257 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab-ee\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:39-0400\n"
+"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
+"Language-Team: Indonesian\n"
+"Language: id_ID\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Generator: crowdin.com\n"
+"X-Crowdin-Project: gitlab-ee\n"
+"X-Crowdin-Language: id\n"
+"X-Crowdin-File: /master/locale/gitlab.pot\n"
+
+msgid " and"
+msgstr ""
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] ""
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+
+msgid "%d layer"
+msgid_plural "%d layers"
+msgstr[0] ""
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] ""
+
+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] ""
+
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
+
+msgid "%{count} participant"
+msgid_plural "%{count} participants"
+msgstr[0] ""
+
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
+msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
+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 not retry automatically. Reset storage information when the problem is resolved."
+msgstr ""
+
+msgid "%{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{storage_name}: failed storage access attempt on host:"
+msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
+msgstr[0] ""
+
+msgid "%{text} is available"
+msgstr ""
+
+msgid "(checkout the %{link} for information on how to install it)."
+msgstr ""
+
+msgid "+ %{moreCount} more"
+msgstr ""
+
+msgid "- show less"
+msgstr ""
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] ""
+
+msgid "1st contribution!"
+msgstr ""
+
+msgid "2FA enabled"
+msgstr ""
+
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr ""
+
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Abuse Reports"
+msgstr ""
+
+msgid "Abuse reports"
+msgstr ""
+
+msgid "Access Tokens"
+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 "Account"
+msgstr ""
+
+msgid "Account and limit settings"
+msgstr ""
+
+msgid "Active"
+msgstr ""
+
+msgid "Activity"
+msgstr ""
+
+msgid "Add"
+msgstr ""
+
+msgid "Add Changelog"
+msgstr ""
+
+msgid "Add Contribution guide"
+msgstr ""
+
+msgid "Add Group Webhooks and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Add Kubernetes cluster"
+msgstr ""
+
+msgid "Add License"
+msgstr ""
+
+msgid "Add Readme"
+msgstr ""
+
+msgid "Add new directory"
+msgstr ""
+
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
+msgid "AdminHealthPageLink|health page"
+msgstr ""
+
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
+msgid "Advanced settings"
+msgstr ""
+
+msgid "All"
+msgstr ""
+
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
+msgid "An error occurred. Please try again."
+msgstr ""
+
+msgid "Any Label"
+msgstr ""
+
+msgid "Appearance"
+msgstr ""
+
+msgid "Applications"
+msgstr ""
+
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+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 reset registration token?"
+msgstr ""
+
+msgid "Are you sure you want to reset the health check token?"
+msgstr ""
+
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr ""
+
+msgid "Are you sure?"
+msgstr ""
+
+msgid "Artifacts"
+msgstr ""
+
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr ""
+
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
+msgid "Authentication Log"
+msgstr ""
+
+msgid "Author"
+msgstr ""
+
+msgid "Authors: %{authors}"
+msgstr ""
+
+msgid "Auto DevOps enabled"
+msgstr ""
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgstr ""
+
+msgid "AutoDevOps|Auto DevOps (Beta)"
+msgstr ""
+
+msgid "AutoDevOps|Auto DevOps documentation"
+msgstr ""
+
+msgid "AutoDevOps|Enable in settings"
+msgstr ""
+
+msgid "AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration."
+msgstr ""
+
+msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
+msgstr ""
+
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr ""
+
+msgid "Billing"
+msgstr ""
+
+msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
+msgstr ""
+
+msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
+msgstr ""
+
+msgid "BillingPlans|Current plan"
+msgstr ""
+
+msgid "BillingPlans|Customer Support"
+msgstr ""
+
+msgid "BillingPlans|Downgrade"
+msgstr ""
+
+msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
+msgstr ""
+
+msgid "BillingPlans|Manage plan"
+msgstr ""
+
+msgid "BillingPlans|Please contact %{customer_support_link} in that case."
+msgstr ""
+
+msgid "BillingPlans|See all %{plan_name} features"
+msgstr ""
+
+msgid "BillingPlans|This group uses the plan associated with its parent group."
+msgstr ""
+
+msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
+msgstr ""
+
+msgid "BillingPlans|Upgrade"
+msgstr ""
+
+msgid "BillingPlans|You are currently on the %{plan_link} plan."
+msgstr ""
+
+msgid "BillingPlans|frequently asked questions"
+msgstr ""
+
+msgid "BillingPlans|monthly"
+msgstr ""
+
+msgid "BillingPlans|paid annually at %{price_per_year}"
+msgstr ""
+
+msgid "BillingPlans|per user"
+msgstr ""
+
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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 ""
+
+msgid "Branch has changed"
+msgstr ""
+
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr ""
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
+msgid "Branches|Cant find HEAD commit for this branch"
+msgstr ""
+
+msgid "Branches|Compare"
+msgstr ""
+
+msgid "Branches|Delete all branches that are merged into '%{default_branch}'"
+msgstr ""
+
+msgid "Branches|Delete branch"
+msgstr ""
+
+msgid "Branches|Delete merged branches"
+msgstr ""
+
+msgid "Branches|Delete protected branch"
+msgstr ""
+
+msgid "Branches|Delete protected branch '%{branch_name}'?"
+msgstr ""
+
+msgid "Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "Branches|Filter by branch name"
+msgstr ""
+
+msgid "Branches|Merged into %{default_branch}"
+msgstr ""
+
+msgid "Branches|New branch"
+msgstr ""
+
+msgid "Branches|No branches to show"
+msgstr ""
+
+msgid "Branches|Once you confirm and press %{delete_protected_branch}, it cannot be undone or recovered."
+msgstr ""
+
+msgid "Branches|Only a project master or owner can delete a protected branch"
+msgstr ""
+
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
+msgstr ""
+
+msgid "Branches|Sort by"
+msgstr ""
+
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
+msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
+msgstr ""
+
+msgid "Branches|The default branch cannot be deleted"
+msgstr ""
+
+msgid "Branches|This branch hasn’t been merged into %{default_branch}."
+msgstr ""
+
+msgid "Branches|To avoid data loss, consider merging this branch before deleting it."
+msgstr ""
+
+msgid "Branches|To confirm, type %{branch_name_confirmation}:"
+msgstr ""
+
+msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above."
+msgstr ""
+
+msgid "Branches|You’re about to permanently delete the protected branch %{branch_name}."
+msgstr ""
+
+msgid "Branches|diverged from upstream"
+msgstr ""
+
+msgid "Branches|merged"
+msgstr ""
+
+msgid "Branches|project settings"
+msgstr ""
+
+msgid "Branches|protected"
+msgstr ""
+
+msgid "Browse Directory"
+msgstr ""
+
+msgid "Browse File"
+msgstr ""
+
+msgid "Browse Files"
+msgstr ""
+
+msgid "Browse files"
+msgstr ""
+
+msgid "Business"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr ""
+
+msgid "CI / CD"
+msgstr ""
+
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
+
+msgid "CICD|Jobs"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
+msgstr ""
+
+msgid "Change Weight"
+msgstr ""
+
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr ""
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr ""
+
+msgid "ChangeTypeAction|Revert"
+msgstr ""
+
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "Chat"
+msgstr ""
+
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
+msgid "Cherry-pick this commit"
+msgstr ""
+
+msgid "Cherry-pick this merge request"
+msgstr ""
+
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+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 "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
+msgid "CircuitBreakerApiLink|circuitbreaker api"
+msgstr ""
+
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
+msgid "Clone repository"
+msgstr ""
+
+msgid "Close"
+msgstr ""
+
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Copy API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Token"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr ""
+
+msgid "ClusterIntegration|Google Cloud Platform project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Google Kubernetes Engine project"
+msgstr ""
+
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
+msgid "ClusterIntegration|Machine type"
+msgstr ""
+
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
+msgstr ""
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
+msgstr ""
+
+msgid "ClusterIntegration|Number of nodes"
+msgstr ""
+
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
+msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgstr ""
+
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace (optional, unique)"
+msgstr ""
+
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Remove integration"
+msgstr ""
+
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|See machine types"
+msgstr ""
+
+msgid "ClusterIntegration|See your projects"
+msgstr ""
+
+msgid "ClusterIntegration|See zones"
+msgstr ""
+
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong on our end."
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr ""
+
+msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
+msgstr ""
+
+msgid "ClusterIntegration|Zone"
+msgstr ""
+
+msgid "ClusterIntegration|access to Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
+msgstr ""
+
+msgid "ClusterIntegration|help page"
+msgstr ""
+
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
+msgid "ClusterIntegration|meets the requirements"
+msgstr ""
+
+msgid "ClusterIntegration|properly configured"
+msgstr ""
+
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
+msgid "Comments"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] ""
+
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] ""
+
+msgid "Commit Message"
+msgstr ""
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr ""
+
+msgid "Commit message"
+msgstr ""
+
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
+msgid "CommitBoxTitle|Commit"
+msgstr ""
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr ""
+
+msgid "Commits"
+msgstr ""
+
+msgid "Commits feed"
+msgstr ""
+
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
+msgid "Commits|History"
+msgstr ""
+
+msgid "Commits|No related merge requests found"
+msgstr ""
+
+msgid "Committed by"
+msgstr ""
+
+msgid "Compare"
+msgstr ""
+
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
+msgid "Container Registry"
+msgstr ""
+
+msgid "ContainerRegistry|Created"
+msgstr ""
+
+msgid "ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:"
+msgstr ""
+
+msgid "ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:"
+msgstr ""
+
+msgid "ContainerRegistry|How to use the Container Registry"
+msgstr ""
+
+msgid "ContainerRegistry|Learn more about"
+msgstr ""
+
+msgid "ContainerRegistry|No tags in Container Registry for this container image."
+msgstr ""
+
+msgid "ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands"
+msgstr ""
+
+msgid "ContainerRegistry|Remove repository"
+msgstr ""
+
+msgid "ContainerRegistry|Remove tag"
+msgstr ""
+
+msgid "ContainerRegistry|Size"
+msgstr ""
+
+msgid "ContainerRegistry|Tag"
+msgstr ""
+
+msgid "ContainerRegistry|Tag ID"
+msgstr ""
+
+msgid "ContainerRegistry|Use different image names"
+msgstr ""
+
+msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
+msgstr ""
+
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
+msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
+msgstr ""
+
+msgid "Control the maximum concurrency of repository backfill for this secondary node"
+msgstr ""
+
+msgid "Copy SSH public key to clipboard"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Create branch"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
+
+msgid "Create file"
+msgstr ""
+
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
+msgid "Create merge request"
+msgstr ""
+
+msgid "Create merge request and branch"
+msgstr ""
+
+msgid "Create new branch"
+msgstr ""
+
+msgid "Create new directory"
+msgstr ""
+
+msgid "Create new file"
+msgstr ""
+
+msgid "Create new label"
+msgstr ""
+
+msgid "Create new..."
+msgstr ""
+
+msgid "Create project label"
+msgstr ""
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "CreateTag|Tag"
+msgstr ""
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Cron syntax"
+msgstr ""
+
+msgid "Current node"
+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 "Customize colors"
+msgstr ""
+
+msgid "Cycle Analytics"
+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 "DashboardProjects|All"
+msgstr ""
+
+msgid "DashboardProjects|Personal"
+msgstr ""
+
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
+msgid "Define a custom pattern with cron syntax"
+msgstr ""
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] ""
+
+msgid "Deploy Keys"
+msgstr ""
+
+msgid "Description"
+msgstr ""
+
+msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
+msgstr ""
+
+msgid "Details"
+msgstr ""
+
+msgid "Diffs|No file name available"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
+msgstr ""
+
+msgid "Dismiss Cycle Analytics introduction box"
+msgstr ""
+
+msgid "Dismiss Merge Request promotion"
+msgstr ""
+
+msgid "Documentation for popular identity providers"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Done"
+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 "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
+msgid "Emails"
+msgstr ""
+
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
+msgid "EventFilterBy|Filter by all"
+msgstr ""
+
+msgid "EventFilterBy|Filter by comments"
+msgstr ""
+
+msgid "EventFilterBy|Filter by issue events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by merge events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by push events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by team"
+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 "Expand"
+msgstr ""
+
+msgid "Explore projects"
+msgstr ""
+
+msgid "Explore public groups"
+msgstr ""
+
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
+msgid "File name"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
+msgid "Filter by commit message"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "Finished"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr ""
+
+msgid "FirstPushedBy|pushed by"
+msgstr ""
+
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] ""
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
+msgstr ""
+
+msgid "Forking in progress"
+msgstr ""
+
+msgid "Format"
+msgstr ""
+
+msgid "From %{provider_title}"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr ""
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
+msgid "GPG Keys"
+msgstr ""
+
+msgid "Generate a default set of labels"
+msgstr ""
+
+msgid "Geo Nodes"
+msgstr ""
+
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
+msgid "Geo|File sync capacity"
+msgstr ""
+
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
+msgstr ""
+
+msgid "Geo|Repository sync capacity"
+msgstr ""
+
+msgid "Geo|Select groups to replicate."
+msgstr ""
+
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
+msgid "Git storage health information has been reset"
+msgstr ""
+
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
+msgid "GitLab Runner section"
+msgstr ""
+
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
+msgstr ""
+
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
+msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
+msgstr ""
+
+msgid "GroupSettings|Share with group lock"
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup."
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. To share projects in this group with another group, ask the owner to override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. You can override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr ""
+
+msgid "GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually."
+msgstr ""
+
+msgid "GroupSettings|cannot be disabled when the parent group \"Share with group lock\" is enabled, except by the owner of the parent group"
+msgstr ""
+
+msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
+msgstr ""
+
+msgid "GroupsEmptyState|A group is a collection of several projects."
+msgstr ""
+
+msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder."
+msgstr ""
+
+msgid "GroupsEmptyState|No groups found"
+msgstr ""
+
+msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
+msgstr ""
+
+msgid "GroupsTree|Create a project in this group."
+msgstr ""
+
+msgid "GroupsTree|Create a subgroup in this group."
+msgstr ""
+
+msgid "GroupsTree|Edit group"
+msgstr ""
+
+msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
+msgstr ""
+
+msgid "GroupsTree|Filter by name..."
+msgstr ""
+
+msgid "GroupsTree|Leave this group"
+msgstr ""
+
+msgid "GroupsTree|Loading groups"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups matched your search"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgstr ""
+
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+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 "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+
+msgid "History"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
+msgid "Improve Issue boards with GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
+msgid "Install a Runner compatible with GitLab CI"
+msgstr ""
+
+msgid "Instance"
+msgid_plural "Instances"
+msgstr[0] ""
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
+msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgstr ""
+
+msgid "Internal - The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr ""
+
+msgid "Issue board focus mode"
+msgstr ""
+
+msgid "Issue events"
+msgstr ""
+
+msgid "IssueBoards|Board"
+msgstr ""
+
+msgid "IssueBoards|Boards"
+msgstr ""
+
+msgid "Issues"
+msgstr ""
+
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
+msgid "Labels"
+msgstr ""
+
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] ""
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Last edited %{date}"
+msgstr ""
+
+msgid "Last edited by %{name}"
+msgstr ""
+
+msgid "Last update"
+msgstr ""
+
+msgid "Last updated"
+msgstr ""
+
+msgid "LastPushEvent|You pushed to"
+msgstr ""
+
+msgid "LastPushEvent|at"
+msgstr ""
+
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
+msgid "Learn more in the"
+msgstr ""
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr ""
+
+msgid "Leave"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "License"
+msgstr ""
+
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
+
+msgid "Lock"
+msgstr ""
+
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
+msgid "Locked"
+msgstr ""
+
+msgid "Locked Files"
+msgstr ""
+
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
+msgid "Login"
+msgstr ""
+
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
+msgid "Maximum git storage failures"
+msgstr ""
+
+msgid "May"
+msgstr ""
+
+msgid "Median"
+msgstr ""
+
+msgid "Members"
+msgstr ""
+
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
+msgid "Merge Requests"
+msgstr ""
+
+msgid "Merge events"
+msgstr ""
+
+msgid "Merge request"
+msgstr ""
+
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
+msgid "Messages"
+msgstr ""
+
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
+msgid "Monitoring"
+msgstr ""
+
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
+msgid "More information is available|here"
+msgstr ""
+
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
+msgid "Multiple issue boards"
+msgstr ""
+
+msgid "Name new label"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] ""
+
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New branch unavailable"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New epic"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+msgid "New group"
+msgstr ""
+
+msgid "New issue"
+msgstr ""
+
+msgid "New label"
+msgstr ""
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New project"
+msgstr ""
+
+msgid "New schedule"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New subgroup"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "None"
+msgstr ""
+
+msgid "Not allowed to merge"
+msgstr ""
+
+msgid "Not available"
+msgstr ""
+
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
+msgid "Not enough data"
+msgstr ""
+
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+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 "Notifications"
+msgstr ""
+
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
+msgid "Number of access attempts"
+msgstr ""
+
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
+msgstr ""
+
+msgid "OfSearchInADropdown|Filter"
+msgstr ""
+
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
+msgid "Only project members can comment."
+msgstr ""
+
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr ""
+
+msgid "Opens in a new window"
+msgstr ""
+
+msgid "Options"
+msgstr ""
+
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
+msgid "Overview"
+msgstr ""
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pages"
+msgstr ""
+
+msgid "Pagination|Last »"
+msgstr ""
+
+msgid "Pagination|Next"
+msgstr ""
+
+msgid "Pagination|Prev"
+msgstr ""
+
+msgid "Pagination|« First"
+msgstr ""
+
+msgid "Part of merge request changes"
+msgstr ""
+
+msgid "Password"
+msgstr ""
+
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr ""
+
+msgid "Pipeline"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr ""
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "Pipeline quota"
+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|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 "PipelineSchedules|Variables"
+msgstr ""
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr ""
+
+msgid "Pipelines"
+msgstr ""
+
+msgid "Pipelines charts"
+msgstr ""
+
+msgid "Pipelines for last month"
+msgstr ""
+
+msgid "Pipelines for last week"
+msgstr ""
+
+msgid "Pipelines for last year"
+msgstr ""
+
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|all"
+msgstr ""
+
+msgid "Pipeline|success"
+msgstr ""
+
+msgid "Pipeline|with stage"
+msgstr ""
+
+msgid "Pipeline|with stages"
+msgstr ""
+
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
+msgid "Preferences"
+msgstr ""
+
+msgid "Primary"
+msgstr ""
+
+msgid "Private - Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Private - The group and its projects can only be viewed by members."
+msgstr ""
+
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
+msgid "Profile"
+msgstr ""
+
+msgid "Profiles|Account scheduled for removal."
+msgstr ""
+
+msgid "Profiles|Delete Account"
+msgstr ""
+
+msgid "Profiles|Delete account"
+msgstr ""
+
+msgid "Profiles|Delete your account?"
+msgstr ""
+
+msgid "Profiles|Deleting an account has the following effects:"
+msgstr ""
+
+msgid "Profiles|Invalid password"
+msgstr ""
+
+msgid "Profiles|Invalid username"
+msgstr ""
+
+msgid "Profiles|Type your %{confirmationValue} to confirm:"
+msgstr ""
+
+msgid "Profiles|You don't have access to delete this user."
+msgstr ""
+
+msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
+msgstr ""
+
+msgid "Profiles|Your account is currently an owner in these groups:"
+msgstr ""
+
+msgid "Profiles|your account"
+msgstr ""
+
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
+msgid "Project '%{project_name}' is in the process of being deleted."
+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 access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+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 "ProjectActivityRSS|Subscribe"
+msgstr ""
+
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+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 "ProjectSettings|Contact an admin to change this setting."
+msgstr ""
+
+msgid "ProjectSettings|Only signed commits can be pushed to this repository."
+msgstr ""
+
+msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
+msgstr ""
+
+msgid "ProjectSettings|This setting is applied on the server level but has been overridden for this project."
+msgstr ""
+
+msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin."
+msgstr ""
+
+msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
+msgstr ""
+
+msgid "Projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Frequently visited"
+msgstr ""
+
+msgid "ProjectsDropdown|Loading projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Projects you visit often will appear here"
+msgstr ""
+
+msgid "ProjectsDropdown|Search your projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Something went wrong on our end."
+msgstr ""
+
+msgid "ProjectsDropdown|Sorry, no projects matched your search"
+msgstr ""
+
+msgid "ProjectsDropdown|This feature requires browser localStorage support"
+msgstr ""
+
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr ""
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr ""
+
+msgid "Push Rules"
+msgstr ""
+
+msgid "Push events"
+msgstr ""
+
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
+msgid "PushRule|Committer restriction"
+msgstr ""
+
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
+msgid "Read more"
+msgstr ""
+
+msgid "Readme"
+msgstr ""
+
+msgid "Real-time features"
+msgstr ""
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
+msgid "Registry"
+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 "Related merge requests"
+msgstr ""
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
+msgid "Repository"
+msgstr ""
+
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+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 "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+
+msgid "Revert this commit"
+msgstr ""
+
+msgid "Revert this merge request"
+msgstr ""
+
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
+msgstr ""
+
+msgid "Save changes"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Save variables"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Scheduled"
+msgstr ""
+
+msgid "Schedules"
+msgstr ""
+
+msgid "Scheduling Pipelines"
+msgstr ""
+
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
+msgstr ""
+
+msgid "Seconds to wait for a storage access attempt"
+msgstr ""
+
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
+msgid "Service Templates"
+msgstr ""
+
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Settings"
+msgstr ""
+
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
+msgid "Show parent pages"
+msgstr ""
+
+msgid "Show parent subgroups"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] ""
+
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
+msgid "Snippets"
+msgstr ""
+
+msgid "Something went wrong on our end"
+msgstr ""
+
+msgid "Something went wrong on our end."
+msgstr ""
+
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
+msgstr ""
+
+msgid "Something went wrong while fetching the projects."
+msgstr ""
+
+msgid "Something went wrong while fetching the registry list."
+msgstr ""
+
+msgid "Something went wrong. Please try again."
+msgstr ""
+
+msgid "Sort by"
+msgstr ""
+
+msgid "SortOptions|Access level, ascending"
+msgstr ""
+
+msgid "SortOptions|Access level, descending"
+msgstr ""
+
+msgid "SortOptions|Created date"
+msgstr ""
+
+msgid "SortOptions|Due date"
+msgstr ""
+
+msgid "SortOptions|Due later"
+msgstr ""
+
+msgid "SortOptions|Due soon"
+msgstr ""
+
+msgid "SortOptions|Label priority"
+msgstr ""
+
+msgid "SortOptions|Largest group"
+msgstr ""
+
+msgid "SortOptions|Largest repository"
+msgstr ""
+
+msgid "SortOptions|Last created"
+msgstr ""
+
+msgid "SortOptions|Last joined"
+msgstr ""
+
+msgid "SortOptions|Last updated"
+msgstr ""
+
+msgid "SortOptions|Least popular"
+msgstr ""
+
+msgid "SortOptions|Less weight"
+msgstr ""
+
+msgid "SortOptions|Milestone"
+msgstr ""
+
+msgid "SortOptions|Milestone due later"
+msgstr ""
+
+msgid "SortOptions|Milestone due soon"
+msgstr ""
+
+msgid "SortOptions|More weight"
+msgstr ""
+
+msgid "SortOptions|Most popular"
+msgstr ""
+
+msgid "SortOptions|Name"
+msgstr ""
+
+msgid "SortOptions|Name, ascending"
+msgstr ""
+
+msgid "SortOptions|Name, descending"
+msgstr ""
+
+msgid "SortOptions|Oldest created"
+msgstr ""
+
+msgid "SortOptions|Oldest joined"
+msgstr ""
+
+msgid "SortOptions|Oldest sign in"
+msgstr ""
+
+msgid "SortOptions|Oldest updated"
+msgstr ""
+
+msgid "SortOptions|Popularity"
+msgstr ""
+
+msgid "SortOptions|Priority"
+msgstr ""
+
+msgid "SortOptions|Recent sign in"
+msgstr ""
+
+msgid "SortOptions|Start later"
+msgstr ""
+
+msgid "SortOptions|Start soon"
+msgstr ""
+
+msgid "SortOptions|Weight"
+msgstr ""
+
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
+msgid "Source code"
+msgstr ""
+
+msgid "Source is not available"
+msgstr ""
+
+msgid "Spam Logs"
+msgstr ""
+
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
+msgid "Specify the following URL during the Runner setup:"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
+msgid "Starred projects"
+msgstr ""
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr ""
+
+msgid "Start the Runner!"
+msgstr ""
+
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "System"
+msgstr ""
+
+msgid "System Hooks"
+msgstr ""
+
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "Target branch"
+msgstr ""
+
+msgid "Team"
+msgstr ""
+
+msgid "Thanks! Don't show me this again"
+msgstr ""
+
+msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 maximum file size allowed is 200KB."
+msgstr ""
+
+msgid "The number of attempts GitLab will make to access a storage."
+msgstr ""
+
+msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
+msgstr ""
+
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
+msgid "The phase of the development lifecycle."
+msgstr ""
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr ""
+
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+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 repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 in seconds GitLab will keep failure information. When no failures occur during this time, information about the mount is reset."
+msgstr ""
+
+msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
+msgstr ""
+
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+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 no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
+msgid "There are problems accessing Git storage: "
+msgstr ""
+
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
+msgstr ""
+
+msgid "This is a confidential issue."
+msgstr ""
+
+msgid "This is the author's first Merge Request to this project."
+msgstr ""
+
+msgid "This issue is confidential"
+msgstr ""
+
+msgid "This issue is confidential and locked."
+msgstr ""
+
+msgid "This issue is locked."
+msgstr ""
+
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "This merge request is locked."
+msgstr ""
+
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
+msgid "Time until first merge request"
+msgstr ""
+
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+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 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|in a while"
+msgstr ""
+
+msgid "Timeago|less than a minute ago"
+msgstr ""
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] ""
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] ""
+
+msgid "Time|s"
+msgstr ""
+
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
+msgid "Total Time"
+msgstr ""
+
+msgid "Total test time for all commits/merges"
+msgstr ""
+
+msgid "Total: %{total}"
+msgstr ""
+
+msgid "Track activity with Contribution Analytics."
+msgstr ""
+
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
+msgid "Unlock"
+msgstr ""
+
+msgid "Unlocked"
+msgstr ""
+
+msgid "Unresolve discussion"
+msgstr ""
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Up to date"
+msgstr ""
+
+msgid "Upgrade your plan to activate Advanced Global Search."
+msgstr ""
+
+msgid "Upgrade your plan to activate Contribution Analytics."
+msgstr ""
+
+msgid "Upgrade your plan to activate Group Webhooks."
+msgstr ""
+
+msgid "Upgrade your plan to activate Issue weight."
+msgstr ""
+
+msgid "Upgrade your plan to improve Issue boards."
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Upload new avatar"
+msgstr ""
+
+msgid "UploadLink|click to upload"
+msgstr ""
+
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
+msgid "Use the following registration token during setup:"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
+msgid "View file @ "
+msgstr ""
+
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
+msgid "View open merge request"
+msgstr ""
+
+msgid "View project labels"
+msgstr ""
+
+msgid "View replaced file @ "
+msgstr ""
+
+msgid "Visibility and access controls"
+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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
+msgid "We don't have enough data to show this stage."
+msgstr ""
+
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
+msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
+msgstr ""
+
+msgid "Weight"
+msgstr ""
+
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr ""
+
+msgid "Wiki"
+msgstr ""
+
+msgid "WikiClone|Clone your wiki"
+msgstr ""
+
+msgid "WikiClone|Git Access"
+msgstr ""
+
+msgid "WikiClone|Install Gollum"
+msgstr ""
+
+msgid "WikiClone|It is recommended to install %{markdown} so that GFM features render locally:"
+msgstr ""
+
+msgid "WikiClone|Start Gollum and edit locally"
+msgstr ""
+
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
+msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
+msgstr ""
+
+msgid "WikiHistoricalPage|This is an old version of this page."
+msgstr ""
+
+msgid "WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}."
+msgstr ""
+
+msgid "WikiHistoricalPage|history"
+msgstr ""
+
+msgid "WikiHistoricalPage|most recent version"
+msgstr ""
+
+msgid "WikiMarkdownDocs|More examples are in the %{docs_link}"
+msgstr ""
+
+msgid "WikiMarkdownDocs|documentation"
+msgstr ""
+
+msgid "WikiMarkdownTip|To link to a (new) page, simply type %{link_example}"
+msgstr ""
+
+msgid "WikiNewPagePlaceholder|how-to-setup"
+msgstr ""
+
+msgid "WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories."
+msgstr ""
+
+msgid "WikiNewPageTitle|New Wiki Page"
+msgstr ""
+
+msgid "WikiPageConfirmDelete|Are you sure you want to delete this page?"
+msgstr ""
+
+msgid "WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs."
+msgstr ""
+
+msgid "WikiPageConflictMessage|the page"
+msgstr ""
+
+msgid "WikiPageCreate|Create %{page_title}"
+msgstr ""
+
+msgid "WikiPageEdit|Update %{page_title}"
+msgstr ""
+
+msgid "WikiPage|Page slug"
+msgstr ""
+
+msgid "WikiPage|Write your content or drag files here..."
+msgstr ""
+
+msgid "Wiki|Create Page"
+msgstr ""
+
+msgid "Wiki|Create page"
+msgstr ""
+
+msgid "Wiki|Edit Page"
+msgstr ""
+
+msgid "Wiki|Empty page"
+msgstr ""
+
+msgid "Wiki|More Pages"
+msgstr ""
+
+msgid "Wiki|New page"
+msgstr ""
+
+msgid "Wiki|Page history"
+msgstr ""
+
+msgid "Wiki|Page version"
+msgstr ""
+
+msgid "Wiki|Pages"
+msgstr ""
+
+msgid "Wiki|Wiki Pages"
+msgstr ""
+
+msgid "With contribution analytics you can have an overview for the activity of issues, merge requests and push events of your organization and its members."
+msgstr ""
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid "Write a commit message..."
+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_full_name}. 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_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are on a read-only GitLab instance."
+msgstr ""
+
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
+msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
+msgstr ""
+
+msgid "You cannot write to this read-only GitLab instance."
+msgstr ""
+
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
+msgid "You have reached your project limit"
+msgstr ""
+
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need a different license to enable FileLocks feature"
+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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
+msgid "Your comment will not be visible to the public."
+msgstr ""
+
+msgid "Your groups"
+msgstr ""
+
+msgid "Your name"
+msgstr ""
+
+msgid "Your projects"
+msgstr ""
+
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] ""
+
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
+msgid "new merge request"
+msgstr ""
+
+msgid "notification emails"
+msgstr ""
+
+msgid "or"
+msgstr ""
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] ""
+
+msgid "password"
+msgstr ""
+
+msgid "personal access token"
+msgstr ""
+
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
+msgid "to help your contributors communicate effectively!"
+msgstr ""
+
+msgid "username"
+msgstr ""
+
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/it/gitlab.po b/locale/it/gitlab.po
index 8a987129452..41d6a76be66 100644
--- a/locale/it/gitlab.po
+++ b/locale/it/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-20 03:59-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:37-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -16,13 +16,41 @@ msgstr ""
"X-Crowdin-Language: it\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
+msgstr[0] "%d commit"
+msgstr[1] "%d commits"
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d layer"
msgid_plural "%d layers"
+msgstr[0] "%d livello"
+msgstr[1] "%d livelli"
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
msgstr[0] ""
msgstr[1] ""
@@ -31,67 +59,97 @@ msgid_plural "%s additional commits have been omitted to prevent performance iss
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 "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} ha committato %{commit_timeago}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "%{count} partecipante"
+msgstr[1] "%{count} partecipanti"
-msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
+msgid "%{loadingIcon} Started"
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr ""
+msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
+msgstr "%{number_commits_behind} commits precedenti %{default_branch}, %{number_commits_ahead} commits avanti"
+
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
+msgstr "%{number_of_failures} di %{maximum_failures} fallimenti. GitLab consentirà l'accesso al prossimo tentativo."
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgstr "%{number_of_failures} di %{maximum_failures} fallimenti. Gitlab non ritenterà automaticamente. Ripristina l'informazioni d'archiviazione quando il problema è risolto."
+
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "%{storage_name}: tentativo d'accesso all'archiviazione fallito da parte dell'host:"
+msgstr[1] "%{storage_name}: %{failed_attempts} tentativi d'accesso all'archiviazione falliti:"
+
+msgid "%{text} is available"
+msgstr "%{text} è disponibile"
msgid "(checkout the %{link} for information on how to install it)."
-msgstr ""
+msgstr "(vedi il %{link} su come installarlo)."
msgid "+ %{moreCount} more"
-msgstr ""
+msgstr "+ %{moreCount} più"
msgid "- show less"
-msgstr ""
+msgstr "- riduci"
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipeline"
msgid "1st contribution!"
-msgstr ""
+msgstr "Primo contributo!"
msgid "2FA enabled"
+msgstr "2FA abilitata"
+
+msgid "<strong>Removes</strong> source branch"
msgstr ""
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Un insieme di grafici riguardo la Continuous Integration"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "Riguardo il rilascio automatico"
msgid "Abuse Reports"
+msgstr "Segnalazioni di abuso"
+
+msgid "Abuse reports"
msgstr ""
msgid "Access Tokens"
-msgstr ""
+msgstr "Token di accesso"
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 ""
+msgstr "L'accesso agli storages è stato temporaneamente disabilitato per consentire il mount di ripristino. Resetta le info d'archiviazione dopo che l'issue è stato risolto per consentire nuovamente l'accesso."
msgid "Account"
+msgstr "Account"
+
+msgid "Account and limit settings"
msgstr ""
msgid "Active"
@@ -112,32 +170,203 @@ msgstr "Aggiungi Guida per contribuire"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
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 Readme"
+msgstr ""
msgid "Add new directory"
msgstr "Aggiungi una directory (cartella)"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
+msgstr "Pagina di stato"
+
+msgid "AdminProjects|Delete"
msgstr ""
-msgid "Advanced settings"
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
msgstr ""
+msgid "Advanced settings"
+msgstr "Impostazioni Avanzate"
+
msgid "All"
+msgstr "Tutto"
+
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "Errore durante l'attivazione/disattivazione della sottoscrizione per l'iscrizione"
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr "Errore durante il recupero dei dati della barra laterale"
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
msgstr ""
msgid "An error occurred. Please try again."
+msgstr "Si è verificato un errore. Riprova."
+
+msgid "Any Label"
msgstr ""
msgid "Appearance"
-msgstr ""
+msgstr "Aspetto"
msgid "Applications"
-msgstr ""
+msgstr "Applicazioni"
+
+msgid "Apr"
+msgstr "Apr"
+
+msgid "April"
+msgstr "Aprile"
msgid "Archived project! Repository is read-only"
msgstr "Progetto archiviato! La Repository è sola-lettura"
@@ -145,58 +374,121 @@ 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 "Are you sure you want to discard your changes?"
+msgid "Are you sure you want to reset registration token?"
+msgstr "Sei sicuro di voler ripristinare il token di registrazione?"
+
+msgid "Are you sure you want to reset the health check token?"
+msgstr "Confermi di voler resettare il token di controllo di stato?"
+
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr ""
+
+msgid "Are you sure?"
+msgstr "Sei sicuro?"
+
+msgid "Artifacts"
+msgstr "Artefatti"
+
+msgid "Assertion consumer service URL"
msgstr ""
-msgid "Are you sure you want to leave this group?"
+msgid "Assign custom color like #FF0000"
msgstr ""
-msgid "Are you sure you want to reset registration token?"
+msgid "Assign labels"
msgstr ""
-msgid "Are you sure you want to reset the health check token?"
+msgid "Assign milestone"
msgstr ""
-msgid "Are you sure?"
+msgid "Assign to"
msgstr ""
-msgid "Artifacts"
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
msgstr ""
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 "Aug"
+msgstr "Ago"
+
+msgid "August"
+msgstr "Agosto"
+
msgid "Authentication Log"
-msgstr ""
+msgstr "Log di autenticazione"
msgid "Author"
+msgstr "Autore"
+
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Auto DevOps enabled"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps, runners and job artifacts"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
msgstr ""
-msgid "AutoDevOps|Auto DevOps (Beta)"
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
msgstr ""
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgstr "Le app d'auto-review e l'Auto Deploy (rilascio automatico) necessita di un nome dominio per funzionare correttamente."
+
+msgid "AutoDevOps|Auto DevOps (Beta)"
+msgstr "Auto DevOps (Béta)"
+
msgid "AutoDevOps|Auto DevOps documentation"
-msgstr ""
+msgstr "Documentazione Auto DevOps"
msgid "AutoDevOps|Enable in settings"
-msgstr ""
+msgstr "Attiva in impostazioni"
msgid "AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration."
-msgstr ""
+msgstr "Farà automaticamente le build, i test e i rilasci della tua applicazione basato sulla tua configurazione CI/CD."
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
+msgstr "Approfondisci: %{link_to_documentation}"
+
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "Available"
+msgstr "Disponibile"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
@@ -253,8 +545,8 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
msgstr[0] ""
msgstr[1] ""
@@ -262,7 +554,13 @@ msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy
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 "Branch has changed"
-msgstr ""
+msgstr "La branche è cambiata"
+
+msgid "Branch is already taken"
+msgstr "La Branch esiste già"
+
+msgid "Branch name"
+msgstr "Nome Branch"
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "Cerca branches"
@@ -271,91 +569,127 @@ msgid "BranchSwitcherTitle|Switch branch"
msgstr "Cambia branch"
msgid "Branches"
+msgstr "Branch"
+
+msgid "Branches|Active"
msgstr ""
-msgid "Branches|Cant find HEAD commit for this branch"
+msgid "Branches|Active branches"
msgstr ""
-msgid "Branches|Compare"
+msgid "Branches|All"
msgstr ""
+msgid "Branches|Cant find HEAD commit for this branch"
+msgstr "Impossibile trovare l'HEAD commit per questa branch"
+
+msgid "Branches|Compare"
+msgstr "Confronta"
+
msgid "Branches|Delete all branches that are merged into '%{default_branch}'"
-msgstr ""
+msgstr "Elimina tutte le branches che sono state mergiate in '%{default_branch}'"
msgid "Branches|Delete branch"
-msgstr ""
+msgstr "Elimina Branch"
msgid "Branches|Delete merged branches"
-msgstr ""
+msgstr "Elimina branch mergiate"
msgid "Branches|Delete protected branch"
-msgstr ""
+msgstr "Elimina la branch protetta"
msgid "Branches|Delete protected branch '%{branch_name}'?"
-msgstr ""
+msgstr "Eliminare la branch protetta %{branch_name}?"
msgid "Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?"
-msgstr ""
+msgstr "Eliminando la branch %{branch_name} è un'operazione irreversibile. Sicuro di voler procedere?"
msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
-msgstr ""
+msgstr "Eliminare le branch mergiate è un'operazione irreversibile. Sicuro di voler procedere?"
msgid "Branches|Filter by branch name"
-msgstr ""
+msgstr "Filtra per nome branch"
msgid "Branches|Merged into %{default_branch}"
-msgstr ""
+msgstr "Mergiata in %{default_branch}"
msgid "Branches|New branch"
-msgstr ""
+msgstr "Nuova branch"
msgid "Branches|No branches to show"
-msgstr ""
+msgstr "Nessuna branch da mostrare"
msgid "Branches|Once you confirm and press %{delete_protected_branch}, it cannot be undone or recovered."
-msgstr ""
+msgstr "Una volta confermato e premuto %{delete_protected_branch} non sarà possibile ripristinare allo stato precedente."
msgid "Branches|Only a project master or owner can delete a protected branch"
+msgstr "Solo gli Owner e i Master possono eliminare una branch protetta"
+
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
+msgstr "Ordina per"
+
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
msgstr ""
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
msgid "Branches|The default branch cannot be deleted"
-msgstr ""
+msgstr "La branch predefinita non può esser eliminata"
msgid "Branches|This branch hasn’t been merged into %{default_branch}."
-msgstr ""
+msgstr "Questa branch non è stata mergiata in %{default_branch}."
msgid "Branches|To avoid data loss, consider merging this branch before deleting it."
-msgstr ""
+msgstr "Per evitare perdita di dati considera di mergiare questa branch prima di eliminarla."
msgid "Branches|To confirm, type %{branch_name_confirmation}:"
-msgstr ""
+msgstr "Per confermare, scrivi %{branch_name_confirmation}:"
msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above."
msgstr ""
msgid "Branches|You’re about to permanently delete the protected branch %{branch_name}."
-msgstr ""
+msgstr "Stai per eliminare la branch protetta (%{branch_name}) in maniera permanente."
msgid "Branches|diverged from upstream"
msgstr ""
msgid "Branches|merged"
-msgstr ""
+msgstr "mergiata"
msgid "Branches|project settings"
-msgstr ""
+msgstr "Impostazioni"
msgid "Branches|protected"
-msgstr ""
+msgstr "Protetta"
msgid "Browse Directory"
msgstr "Naviga direttori"
@@ -369,27 +703,45 @@ msgstr "Esplora Files"
msgid "Browse files"
msgstr "Guarda i files"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "per"
msgid "CI / CD"
+msgstr "CI / CD"
+
+msgid "CI/CD"
msgstr ""
-msgid "CI configuration"
-msgstr "Configurazione CI (Integrazione Continua)"
+msgid "CI/CD configuration"
+msgstr ""
-msgid "CICD|Jobs"
+msgid "CI/CD for external repo"
msgstr ""
+msgid "CICD|Jobs"
+msgstr "Jobs"
+
msgid "Cancel"
msgstr "Cancella"
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "Preleva nella branch"
@@ -402,22 +754,55 @@ msgstr "Cherry-pick"
msgid "ChangeTypeAction|Revert"
msgstr "Ripristina"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
+msgstr "Changelog"
+
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
msgstr ""
msgid "Charts"
msgstr "Grafici"
msgid "Chat"
+msgstr "Chat"
+
+msgid "Check interval"
msgstr ""
+msgid "Checking %{text} availability…"
+msgstr "Controllo disponibilità per %{text}…"
+
+msgid "Checking branch availability..."
+msgstr "Controllo disponibilità branch..."
+
msgid "Cherry-pick this commit"
-msgstr ""
+msgstr "Cherry-pick di questo commit"
msgid "Cherry-pick this merge request"
msgstr "Cherry-pick questa richiesta di merge"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -474,103 +859,295 @@ msgstr "saltata"
msgid "CiStatus|running"
msgstr "in corso"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
+msgstr "api circuitbreaker"
+
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
msgstr ""
-msgid "Clone repository"
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
msgstr ""
+msgid "Clone repository"
+msgstr "Clona repository"
+
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|API URL"
+msgstr "API URL"
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Applications"
+msgstr "Applicazioni"
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|CA Certificate"
+msgstr "Certificato CA"
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr "Certificate Authority bundle (formato PEM)"
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Copy API URL"
+msgstr "Copia URL API"
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr "Copia Certificato CA"
+
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Copy Token"
+msgstr "Copia Token"
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Google Cloud Platform project ID"
+msgid "ClusterIntegration|Create on GKE"
+msgstr "Crea su GKE"
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr "Inserisci i dettagli per un cluster Kubernetes esistente"
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
msgstr ""
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr "Gitlab Runner"
+
+msgid "ClusterIntegration|Google Cloud Platform project ID"
+msgstr "ID Progetto di Google Cloud Platform"
+
msgid "ClusterIntegration|Google Kubernetes Engine"
msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr "Helm Tiller"
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr "Ingresso"
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr "Installa"
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr "Installato"
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
+msgstr "Tipo di macchina"
+
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|More information"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
msgstr ""
+msgid "ClusterIntegration|Note:"
+msgstr "Nota:"
+
msgid "ClusterIntegration|Number of nodes"
+msgstr "Numero di nodi"
+
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
msgstr ""
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -582,16 +1159,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|Token"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -603,33 +1201,48 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
-msgid "Comments"
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
msgstr ""
+msgid "Comment and unresolve discussion"
+msgstr ""
+
+msgid "Comments"
+msgstr "Commenti"
+
msgid "Commit"
msgid_plural "Commits"
msgstr[0] ""
msgstr[1] ""
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgstr[1] ""
msgid "Commit Message"
-msgstr ""
+msgstr "Messaggio di commit"
msgid "Commit duration in minutes for last 30 commits"
msgstr "Durata del commit (in minuti) per gli ultimi 30 commit"
@@ -637,6 +1250,12 @@ msgstr "Durata del commit (in minuti) per gli ultimi 30 commit"
msgid "Commit message"
msgstr "Messaggio del commit"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "Commit"
@@ -644,20 +1263,104 @@ msgid "CommitMessage|Add %{file_name}"
msgstr "Aggiungi %{file_name}"
msgid "Commits"
-msgstr ""
+msgstr "Commits"
msgid "Commits feed"
msgstr "Feed dei Commits"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "Cronologia"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "Committato da "
msgid "Compare"
msgstr "Confronta"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -689,18 +1392,24 @@ msgid "ContainerRegistry|Remove tag"
msgstr ""
msgid "ContainerRegistry|Size"
-msgstr ""
+msgstr "Dimensione"
msgid "ContainerRegistry|Tag"
-msgstr ""
+msgstr "Tag"
msgid "ContainerRegistry|Tag ID"
-msgstr ""
+msgstr "Tag ID"
msgid "ContainerRegistry|Use different image names"
-msgstr ""
+msgstr "Utilizza nomi d'immagine differenti"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
+msgstr "Con il Docker Container Registry integrato in Gitlab, ogni progetto può avere il suo spazio d'archiviazione sulle immagini Docker."
+
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
msgstr ""
msgid "Contribution guide"
@@ -709,6 +1418,18 @@ msgstr "Guida per contribuire"
msgid "Contributors"
msgstr "Collaboratori"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr "Genero grafico della repository."
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr "Esegui i commit su %{branch_name}, escludendo i commit di merge. Limitati a 6000 commits."
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr "Attendere prego, questa pagina si ricaricherà automaticamente appena pronta."
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -721,39 +1442,78 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr "Copia URL negli appunti"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "Copia l'SHA del commit negli appunti"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "Crea una nuova cartella"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
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 branch"
+msgstr ""
+
msgid "Create directory"
msgstr "Crea cartella"
-msgid "Create empty bare repository"
-msgstr "Crea una repository vuota"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
+msgstr "Crea file"
+
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
msgstr ""
msgid "Create merge request"
msgstr "Crea una richiesta di merge"
-msgid "Create new branch"
+msgid "Create merge request and branch"
msgstr ""
+msgid "Create new branch"
+msgstr "Crea un nuova branch"
+
msgid "Create new directory"
-msgstr ""
+msgstr "Crea una nuova cartella"
msgid "Create new file"
+msgstr "Crea un nuovo File"
+
+msgid "Create new label"
msgstr ""
msgid "Create new..."
msgstr "Crea nuovo..."
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "Fork"
@@ -763,24 +1523,36 @@ msgstr "Tag"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "Crea token d'accesso personale"
-msgid "Cron Timezone"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
msgstr ""
+msgid "Cron Timezone"
+msgstr "Timezone del Cron"
+
msgid "Cron syntax"
msgstr "Sintassi Cron"
+msgid "Current node"
+msgstr ""
+
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 "Customize colors"
+msgstr ""
+
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"
@@ -803,9 +1575,18 @@ msgid "CycleAnalyticsStage|Test"
msgstr "Test"
msgid "DashboardProjects|All"
-msgstr ""
+msgstr "Tutti"
msgid "DashboardProjects|Personal"
+msgstr "Personale"
+
+msgid "Dec"
+msgstr "Dic"
+
+msgid "December"
+msgstr "Dicembre"
+
+msgid "Default classification label"
msgstr ""
msgid "Define a custom pattern with cron syntax"
@@ -820,7 +1601,7 @@ msgstr[0] "Rilascio"
msgstr[1] "Rilasci"
msgid "Deploy Keys"
-msgstr ""
+msgstr "Chiavi di Deploy (rilascio)"
msgid "Description"
msgstr "Descrizione"
@@ -829,23 +1610,38 @@ msgid "Description templates allow you to define context-specific templates for
msgstr ""
msgid "Details"
+msgstr "Dettagli"
+
+msgid "Diffs|No file name available"
msgstr ""
msgid "Directory name"
msgstr "Nome cartella"
-msgid "Discard changes"
+msgid "Disable"
msgstr ""
-msgid "Dismiss Cycle Analytics introduction box"
+msgid "Discard draft"
msgstr ""
+msgid "Discover GitLab Geo."
+msgstr ""
+
+msgid "Dismiss Cycle Analytics introduction box"
+msgstr "Chiudi l'introduzione alle Analisi Cicliche"
+
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "Non mostrare più"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "Scarica"
@@ -873,33 +1669,189 @@ msgstr "Differenze"
msgid "DownloadSource|Download"
msgstr "Scarica"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Modifica"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Cambia programmazione della pipeline %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
+msgstr "E-mail"
+
+msgid "Enable"
msgstr ""
-msgid "EventFilterBy|Filter by all"
+msgid "Enable Auto DevOps"
msgstr ""
-msgid "EventFilterBy|Filter by comments"
+msgid "Enable SAML authentication for this group"
msgstr ""
-msgid "EventFilterBy|Filter by issue events"
+msgid "Enable Sentry for error reporting and logging."
msgstr ""
-msgid "EventFilterBy|Filter by merge events"
+msgid "Enable and configure InfluxDB metrics."
msgstr ""
-msgid "EventFilterBy|Filter by push events"
+msgid "Enable and configure Prometheus metrics."
msgstr ""
-msgid "EventFilterBy|Filter by team"
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
msgstr ""
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr "Errore durante il fetch degli ambienti."
+
+msgid "Environments|An error occurred while making the request."
+msgstr "Errore durante l'esecuzione della richiesta."
+
+msgid "Environments|Commit"
+msgstr "Commit"
+
+msgid "Environments|Deployment"
+msgstr "Rilascio"
+
+msgid "Environments|Environment"
+msgstr "Ambiente"
+
+msgid "Environments|Environments"
+msgstr "Ambienti"
+
+msgid "Environments|Job"
+msgstr "Job"
+
+msgid "Environments|New environment"
+msgstr "Nuovo ambiente"
+
+msgid "Environments|No deployments yet"
+msgstr "Ancora nessuna chiave di rilascio"
+
+msgid "Environments|Open"
+msgstr "Apri"
+
+msgid "Environments|Re-deploy"
+msgstr "Rilascia di nuovo"
+
+msgid "Environments|Read more about environments"
+msgstr "Leggi di più sugli ambienti"
+
+msgid "Environments|Rollback"
+msgstr "Rollback (ripristina)"
+
+msgid "Environments|Show all"
+msgstr "Mostra tutti"
+
+msgid "Environments|Updated"
+msgstr "Aggiornato"
+
+msgid "Environments|You don't have any environments right now."
+msgstr "Attualmente non hai alcun ambiente."
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr "Errore durante l'attivazione/disattivazione della sottoscrizione per l'iscrizione"
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
+msgid "EventFilterBy|Filter by all"
+msgstr "Filtra per tutti"
+
+msgid "EventFilterBy|Filter by comments"
+msgstr "Filtra per commenti"
+
+msgid "EventFilterBy|Filter by issue events"
+msgstr "Filtra per eventi di issue"
+
+msgid "EventFilterBy|Filter by merge events"
+msgstr "Filtra per eventi di merge"
+
+msgid "EventFilterBy|Filter by push events"
+msgstr "Filtra per eventi di push"
+
+msgid "EventFilterBy|Filter by team"
+msgstr "Filtra per team"
+
msgid "Every day (at 4:00am)"
msgstr "Ogni giorno (alle 4 del mattino)"
@@ -909,22 +1861,73 @@ 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 "Explore projects"
+msgid "Expand"
msgstr ""
+msgid "Explore projects"
+msgstr "Esplora progetti"
+
msgid "Explore public groups"
+msgstr "Esplora gruppi pubblici"
+
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
msgstr ""
msgid "Failed to change the owner"
msgstr "Impossibile cambiare owner"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "Impossibile rimuovere la pipeline pianificata"
-msgid "File name"
+msgid "Failed to update issues, please try again."
msgstr ""
+msgid "Feb"
+msgstr "Feb"
+
+msgid "February"
+msgstr "Febbraio"
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
+msgid "File name"
+msgstr "Nome file"
+
msgid "Files"
+msgstr "Files"
+
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
msgstr ""
msgid "Filter by commit message"
@@ -936,24 +1939,39 @@ msgstr "Trova in percorso"
msgid "Find file"
msgstr "Trova file"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "Primo"
msgid "FirstPushedBy|pushed by"
msgstr "Push di"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "Fork"
+msgstr[1] "Forks"
msgid "ForkedFromProjectPath|Forked from"
msgstr "Fork da"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
+msgstr "Fork da %{project_name} (eliminato)"
+
+msgid "Forking in progress"
msgstr ""
msgid "Format"
+msgstr "Formato"
+
+msgid "From %{provider_title}"
msgstr ""
msgid "From issue creation until deploy to production"
@@ -962,16 +1980,163 @@ 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 "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
+msgstr "Chiavi GPG"
+
+msgid "Generate a default set of labels"
msgstr ""
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -980,10 +2145,43 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
+msgstr "Le informazioni sullo stato dell'archiviazione Git è stata ripristinata"
+
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
msgstr ""
msgid "GitLab Runner section"
+msgstr "Sezione Gitlab Runner"
+
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
msgstr ""
msgid "Go to your fork"
@@ -993,23 +2191,44 @@ msgid "GoToYourFork|Fork"
msgstr "Fork"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
+msgstr "L'autenticazione Google non è %{link_to_documentation}. Richiedi al tuo amministratore Gitlab se desideri utilizzare il servizio."
+
+msgid "Got it!"
msgstr ""
-msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
msgstr ""
-msgid "GroupSettings|Share with group lock"
+msgid "GroupRoadmap|From %{dateWord}"
msgstr ""
-msgid "GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup."
+msgid "GroupRoadmap|Loading roadmap"
msgstr ""
-msgid "GroupSettings|This setting is applied on %{ancestor_group}. To share projects in this group with another group, ask the owner to override the setting or %{remove_ancestor_share_with_group_lock}."
+msgid "GroupRoadmap|Something went wrong while fetching epics"
msgstr ""
-msgid "GroupSettings|This setting is applied on %{ancestor_group}. You can override the setting or %{remove_ancestor_share_with_group_lock}."
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
msgstr ""
+msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
+msgstr "Blocca la condivisione di un progetto di %{group} con altri gruppi"
+
+msgid "GroupSettings|Share with group lock"
+msgstr "Condividi con il gruppo chiuso"
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup."
+msgstr "Questa modifica è stata applicata su %{ancestor_group} ed è stata ignorata nel sottogruppo."
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. To share projects in this group with another group, ask the owner to override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr "Questa impostazione è stata applicata a %{ancestor_group}. Per condividere i progetti con altri gruppi chiedi all'owner di eseguire l'override delle impostazioni oppure %{remove_ancestor_share_with_group_lock}."
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. You can override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr "Questa impostazione è stata applicata a %{ancestor_group}. Puoi eseguire l'override delle impostazioni o %{remove_ancestor_share_with_group_lock}."
+
msgid "GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually."
msgstr ""
@@ -1031,12 +2250,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1064,9 +2277,15 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
-msgid "Health Check"
+msgid "Have your users email"
msgstr ""
+msgid "Header message"
+msgstr ""
+
+msgid "Health Check"
+msgstr "Verifica stato"
+
msgid "Health information can be retrieved from the following endpoints. More information is available"
msgstr ""
@@ -1082,15 +2301,59 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
-msgid "History"
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
msgstr ""
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "History"
+msgstr "Cronologia"
+
msgid "Housekeeping successfully started"
msgstr "Housekeeping iniziato con successo"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "Importa repository"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1100,6 +2363,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
@@ -1108,6 +2374,15 @@ msgid_plural "Instances"
msgstr[0] ""
msgstr[1] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1123,9 +2398,6 @@ msgstr "Introduzione delle Analisi Cicliche"
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr ""
@@ -1138,15 +2410,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr "Gen"
+
+msgid "January"
+msgstr "Gennaio"
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr "Lug"
+
+msgid "July"
+msgstr "Luglio"
+
+msgid "Jun"
+msgstr "Giu"
+
+msgid "June"
+msgstr "Giugno"
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "Disabilitato"
msgid "LFSStatus|Enabled"
msgstr "Abilitato"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "L'ultimo %d giorno"
@@ -1159,16 +2503,16 @@ msgid "Last commit"
msgstr "Ultimo Commit"
msgid "Last edited %{date}"
-msgstr ""
+msgstr "Ultima modifica %{date}"
msgid "Last edited by %{name}"
-msgstr ""
+msgstr "Modificato da %{name}"
msgid "Last update"
-msgstr ""
+msgstr "Ultimo aggiornamento"
msgid "Last updated"
-msgstr ""
+msgstr "Ultimo aggiornamento"
msgid "LastPushEvent|You pushed to"
msgstr ""
@@ -1176,6 +2520,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "Leggi di più su"
@@ -1194,57 +2547,226 @@ msgstr "Abbandona il progetto"
msgid "License"
msgstr ""
-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 "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
-msgid "Locked"
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
msgstr ""
+msgid "Locked"
+msgstr "Bloccato"
+
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
+msgstr "Login"
+
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr "Mar"
+
+msgid "March"
+msgstr "Marzo"
+
+msgid "Mark done"
msgstr ""
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "Mediano"
msgid "Members"
+msgstr "Membri"
+
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
msgstr ""
msgid "Merge Requests"
-msgstr ""
+msgstr "Richieste di merge"
msgid "Merge events"
msgstr ""
msgid "Merge request"
+msgstr "Richiesta di merge"
+
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
msgstr ""
msgid "Messages"
+msgstr "Messaggi"
+
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
msgstr ""
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "aggiungi una chiave SSH"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
+msgstr "Monitoraggio"
+
+msgid "More info"
+msgstr ""
+
+msgid "More information"
msgstr ""
msgid "More information is available|here"
+msgstr "Ulteriori informazioni sono disponibili | qui"
+
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
msgstr ""
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1252,29 +2774,44 @@ msgid_plural "New Issues"
msgstr[0] "Nuovo Issue"
msgstr[1] "Nuovi Issues"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "Nuova pianificazione Pipeline"
msgid "New branch"
msgstr "Nuova Branch"
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "Nuova directory"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "Nuovo file"
msgid "New group"
-msgstr ""
+msgstr "Nuovo gruppo"
msgid "New issue"
msgstr "Nuovo Issue"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "Nuova richiesta di merge"
msgid "New project"
-msgstr ""
+msgstr "Nuovo progetto"
msgid "New schedule"
msgstr "Nuova pianficazione"
@@ -1283,12 +2820,33 @@ msgid "New snippet"
msgstr "Nuovo snippet"
msgid "New subgroup"
-msgstr ""
+msgstr "Nuovo sottogruppo"
msgid "New tag"
msgstr "Nuovo tag"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1298,14 +2856,41 @@ msgid "No schedules"
msgstr "Nessuna pianificazione"
msgid "None"
+msgstr "Nessuno"
+
+msgid "Not allowed to merge"
msgstr ""
msgid "Not available"
msgstr "Non disponibile"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "Dati insufficienti "
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "Notifica eventi"
@@ -1361,56 +2946,104 @@ msgid "NotificationLevel|Watch"
msgstr "Osserva"
msgid "Notifications"
+msgstr "Notifiche"
+
+msgid "Notifications off"
msgstr ""
-msgid "Number of access attempts"
+msgid "Notifications on"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "Nov"
+msgstr "Nov"
+
+msgid "November"
+msgstr "Novembre"
+
+msgid "Number of access attempts"
+msgstr "Numero di tentativi di accesso raggiunto"
+
+msgid "OK"
msgstr ""
+msgid "Oct"
+msgstr "Ott"
+
+msgid "October"
+msgstr "Ottobre"
+
msgid "OfSearchInADropdown|Filter"
msgstr "Filtra"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
+msgstr "Solo i membri del progetto possono commentare."
+
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
msgstr ""
msgid "OpenedNDaysAgo|Opened"
msgstr "Aperto"
msgid "Opens in a new window"
-msgstr ""
+msgstr "Si apre in una nuova finestra"
msgid "Options"
msgstr "Opzioni"
-msgid "Overview"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
msgstr ""
+msgid "Overview"
+msgstr "Panoramica"
+
msgid "Owner"
+msgstr "Proprietario"
+
+msgid "Pages"
msgstr ""
msgid "Pagination|Last »"
-msgstr ""
+msgstr "Ultima »"
msgid "Pagination|Next"
-msgstr ""
+msgstr "Successiva"
msgid "Pagination|Prev"
-msgstr ""
+msgstr "Precedente"
msgid "Pagination|« First"
+msgstr "« Prima"
+
+msgid "Part of merge request changes"
msgstr ""
msgid "Password"
+msgstr "Password"
+
+msgid "Pending"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Performance optimization"
msgstr ""
-msgid "Pipeline"
+msgid "Personal Access Token"
msgstr ""
+msgid "Pipeline"
+msgstr "Pipeline"
+
msgid "Pipeline Health"
msgstr "Stato della Pipeline"
@@ -1450,12 +3083,6 @@ 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"
@@ -1465,9 +3092,6 @@ 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"
@@ -1487,12 +3111,63 @@ msgid "Pipelines charts"
msgstr "Grafici pipeline"
msgid "Pipelines for last month"
-msgstr ""
+msgstr "Pipeline per il mese scorso"
msgid "Pipelines for last week"
-msgstr ""
+msgstr "Pipeline per la settimana scorsa"
msgid "Pipelines for last year"
+msgstr "Pipeline per l'ultimo anno"
+
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
msgstr ""
msgid "Pipeline|all"
@@ -1507,57 +3182,87 @@ msgstr "con stadio"
msgid "Pipeline|with stages"
msgstr "con più stadi"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
+msgstr "Preferenze"
+
+msgid "Primary"
msgstr ""
msgid "Private - Project access must be granted explicitly to each user."
-msgstr ""
+msgstr "Privato - L'accesso al progetto deve essere fornito esplicitamente ad ogni utente."
msgid "Private - The group and its projects can only be viewed by members."
+msgstr "Privato - Il gruppo e i suoi progetti possono essere visualizzati solo dai membri."
+
+msgid "Private projects can be created in your personal namespace with:"
msgstr ""
msgid "Profile"
-msgstr ""
+msgstr "Profilo"
msgid "Profiles|Account scheduled for removal."
-msgstr ""
+msgstr "Account pianificato per la rimozione."
msgid "Profiles|Delete Account"
-msgstr ""
+msgstr "Elimina account"
msgid "Profiles|Delete account"
-msgstr ""
+msgstr "Elimina account"
msgid "Profiles|Delete your account?"
-msgstr ""
+msgstr "Eliminare il tuo account?"
msgid "Profiles|Deleting an account has the following effects:"
msgstr ""
msgid "Profiles|Invalid password"
-msgstr ""
+msgstr "Password non valida"
msgid "Profiles|Invalid username"
-msgstr ""
+msgstr "Username non valido"
msgid "Profiles|Type your %{confirmationValue} to confirm:"
-msgstr ""
+msgstr "Inserisci il tuo %{confirmationValue} per confermare:"
msgid "Profiles|You don't have access to delete this user."
-msgstr ""
+msgstr "Non hai i permessi per eliminare questo utente."
msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
-msgstr ""
+msgstr "Devi trasferire la proprietà o eliminare questi gruppi prima che tu possa eliminare l'account."
msgid "Profiles|Your account is currently an owner in these groups:"
-msgstr ""
+msgstr "Il tuo account è attualmente proprietario in questi gruppi:"
msgid "Profiles|your account"
+msgstr "il tuo account"
+
+msgid "Profiling - Performance bar"
msgstr ""
-msgid "Project '%{project_name}' is in the process of being deleted."
+msgid "Programming languages used in this repository"
msgstr ""
+msgid "Project '%{project_name}' is in the process of being deleted."
+msgstr "Il progetto '%{project_name}' è in fase di eliminazione."
+
msgid "Project '%{project_name}' queued for deletion."
msgstr "Il Progetto '%{project_name}' in coda di eliminazione."
@@ -1570,9 +3275,15 @@ msgstr "Il Progetto '%{project_name}' è stato aggiornato con successo."
msgid "Project access must be granted explicitly to each user."
msgstr "L'accesso al progetto dev'esser fornito esplicitamente ad ogni utente"
-msgid "Project details"
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
msgstr ""
+msgid "Project details"
+msgstr "Dettagli del progetto"
+
msgid "Project export could not be deleted."
msgstr "L'esportazione del progetto non può essere eliminata."
@@ -1586,6 +3297,21 @@ 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 "ProjectActivityRSS|Subscribe"
+msgstr "Iscriviti"
+
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
msgstr ""
msgid "ProjectFeature|Disabled"
@@ -1628,56 +3354,161 @@ msgid "ProjectSettings|Users can only push commits to this repository that were
msgstr ""
msgid "Projects"
-msgstr ""
+msgstr "Progetti"
msgid "ProjectsDropdown|Frequently visited"
-msgstr ""
+msgstr "Visitati di frequente"
msgid "ProjectsDropdown|Loading projects"
-msgstr ""
+msgstr "Caricamento progetti"
msgid "ProjectsDropdown|Projects you visit often will appear here"
-msgstr ""
+msgstr "I progetti che visiti spesso appariranno qui"
msgid "ProjectsDropdown|Search your projects"
-msgstr ""
+msgstr "Cerca tra i tuoi progetti"
msgid "ProjectsDropdown|Something went wrong on our end."
-msgstr ""
+msgstr "Qualcosa è andato storto dalla nostra parte."
msgid "ProjectsDropdown|Sorry, no projects matched your search"
-msgstr ""
+msgstr "Siamo spiacenti, non ci sono progetti che corrispondono alla tua ricerca"
msgid "ProjectsDropdown|This feature requires browser localStorage support"
+msgstr "Questa feature richiede il supporto del localStorage del browser"
+
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
msgstr ""
-msgid "Public - The group and any public projects can be viewed without any authentication."
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
msgstr ""
-msgid "Public - The project can be accessed without any authentication."
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr "Di default, Prometheus è in ascolto su ‘http://localhost:9090‘. Non è consigliabile cambiare l'indirizzo e la porta di default in quanto ciò potrebbe influenzare o causare conflitto con altri servizi in esecuzione sul server GitLab."
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr "Ricerco e configuro le metriche..."
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr "Metriche"
+
+msgid "PrometheusService|Missing environment variable"
+msgstr "Variabile d'ambiente mancante"
+
+msgid "PrometheusService|More information"
+msgstr "Ulteriori informazioni"
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
msgstr ""
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr "Pubblico - il gruppo e tutti i progetti pubblici possono essere visualizzati senza alcuna autenticazione."
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr "Public - Chiunque può accedere a questo progetto senza alcuna autenticazione."
+
msgid "Push Rules"
msgstr ""
msgid "Push events"
msgstr ""
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr "Vedi altro"
msgid "Readme"
msgstr "Leggimi"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "Branches"
msgid "RefSwitcher|Tags"
msgstr "Tags"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1699,15 +3530,42 @@ msgstr "Richieste di Merge Correlate"
msgid "Related Merged Requests"
msgstr "Richieste di Merge Completate Correlate"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "Ricordamelo più tardi"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "Rimuovi progetto"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "Richiedi accesso"
@@ -1720,73 +3578,195 @@ msgstr ""
msgid "Reset runners registration token"
msgstr ""
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Revert this commit"
msgstr "Ripristina questo commit"
msgid "Revert this merge request"
msgstr "Ripristina questa richiesta di merge"
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
msgstr ""
-msgid "Save"
+msgid "Reviewing"
msgstr ""
-msgid "Save changes"
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
msgstr ""
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
+msgstr "Chiavi SSH"
+
+msgid "Save changes"
+msgstr "Salva modifiche"
+
msgid "Save pipeline schedule"
msgstr "Salva pianificazione pipeline"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "Pianifica una nuova Pipeline"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "Pianificazione pipelines"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "Ricerca branches e tags"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "Seleziona formato d'archivio"
msgid "Select a timezone"
msgstr "Seleziona una timezone"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "Seleziona una branch di destinazione"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr "Set"
+
+msgid "September"
+msgstr "Settembre"
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
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 default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "Configura Koding"
-msgid "Set up auto deploy"
-msgstr "Configura il rilascio automatico"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "imposta una password"
msgid "Settings"
+msgstr "Impostazioni"
+
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
msgstr ""
msgid "Show parent pages"
@@ -1800,23 +3780,59 @@ msgid_plural "Showing %d events"
msgstr[0] "Visualizza %d evento"
msgstr[1] "Visualizza %d eventi"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
+msgstr "Snippet"
+
+msgid "Something went wrong on our end"
msgstr ""
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
msgstr ""
-msgid "Something went wrong while fetching the projects."
+msgid "Something went wrong while fetching Dependency Scanning."
msgstr ""
+msgid "Something went wrong while fetching SAST."
+msgstr ""
+
+msgid "Something went wrong while fetching the projects."
+msgstr "Qualcosa è andato storto durante il fetch dei progetti."
+
msgid "Something went wrong while fetching the registry list."
+msgstr "Qualcosa è andato storto durante il recupero dell'elenco dei registri."
+
+msgid "Something went wrong. Please try again."
msgstr ""
msgid "Sort by"
-msgstr ""
+msgstr "Ordina per"
msgid "SortOptions|Access level, ascending"
msgstr ""
@@ -1849,7 +3865,7 @@ msgid "SortOptions|Last created"
msgstr ""
msgid "SortOptions|Last joined"
-msgstr ""
+msgstr "Ultimo che ha Joinato"
msgid "SortOptions|Last updated"
msgstr ""
@@ -1888,7 +3904,7 @@ msgid "SortOptions|Oldest created"
msgstr ""
msgid "SortOptions|Oldest joined"
-msgstr ""
+msgstr "Dal primo Joinato"
msgid "SortOptions|Oldest sign in"
msgstr ""
@@ -1914,18 +3930,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "Codice Sorgente"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr "Star"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1935,29 +3969,119 @@ msgstr "inizia una %{new_merge_request} con queste modifiche"
msgid "Start the Runner!"
msgstr ""
-msgid "Subgroups"
+msgid "Started"
msgstr ""
-msgid "Subscribe"
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr "Cambia branch/tag"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
msgstr[0] ""
msgstr[1] ""
msgid "Tags"
msgstr ""
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "Branch di destinazione"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1967,7 +4091,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1976,30 +4106,39 @@ msgstr "Lo stadio di programmazione mostra il tempo trascorso dal primo commit a
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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "La relazione del fork è stata rimossa"
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 "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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "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"
@@ -2012,9 +4151,18 @@ 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 repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 "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 roadmap shows the progress of your epics along a timeline"
+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 "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"
@@ -2027,16 +4175,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
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 "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2045,18 +4226,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
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 "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "Il tempo che impiega un issue per esser pianificato"
@@ -2066,9 +4283,27 @@ 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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "Il tempo fino alla prima richiesta di merge"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "%s giorni fa"
@@ -2205,25 +4440,97 @@ msgstr[1] "mins"
msgid "Time|s"
msgstr "s"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr "Titolo"
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
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 "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr ""
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2247,24 +4554,72 @@ msgstr "Carica un nuovo file"
msgid "Upload file"
msgstr "Carica file"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "clicca per caricare"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr "Usa le tue impostazioni globali "
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "Mostra la richieste di merge aperte"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "Interno"
@@ -2280,16 +4635,28 @@ 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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "Non ci sono sufficienti dati da mostrare su questo stadio"
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2310,6 +4677,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2400,39 +4773,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "Ritira richiesta d'accesso"
+msgid "Write a commit message..."
+msgstr ""
+
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 %{project_full_name}. 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 "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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "Puoi aggiungere files solo quando sei in una branch"
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "Hai raggiunto il tuo limite di progetto"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "Devi accedere per porre una star al progetto"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "Necessiti del permesso."
@@ -2457,6 +4860,36 @@ msgstr "Non sarai in grado di eseguire pull o push di codice tramite %{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 "Non sarai in grado di effettuare push o pull tramite SSH fino a che %{add_ssh_key_link} al tuo profilo"
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2469,7 +4902,108 @@ msgstr "Il tuo nome"
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
@@ -2477,12 +5011,243 @@ msgid_plural "days"
msgstr[0] "giorno"
msgstr[1] "giorni"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "Nuova richiesta di merge"
msgid "notification emails"
msgstr "Notifiche via email"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] ""
@@ -2494,9 +5259,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po
index 8d93a936be9..b526b0ba202 100644
--- a/locale/ja/gitlab.po
+++ b/locale/ja/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-03 12:31-0400\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:36-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Japanese\n"
"Language: ja_JP\n"
@@ -16,41 +16,76 @@ msgstr ""
"X-Crowdin-Language: ja\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d個ã®ã‚³ãƒŸãƒƒãƒˆ"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] ""
+
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 "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
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."
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] ""
+msgid "%{text} is available"
+msgstr ""
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr ""
@@ -70,15 +105,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "CIã«ã¤ã„ã¦ã®ã‚°ãƒ©ãƒ•"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "自動デプロイã«ã¤ã„ã¦"
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -88,6 +138,9 @@ msgstr ""
msgid "Account"
msgstr ""
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "有効"
@@ -106,73 +159,283 @@ msgstr "貢献者å‘ã‘ガイドを追加"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
msgid "Add License"
msgstr "ライセンスを追加"
-msgid "Add an SSH key to your profile to pull or push via SSH."
-msgstr "SSHã§ãƒ—ルやプッシュã™ã‚‹å ´åˆã¯ã€ãƒ—ロフィールã«SSHéµã‚’追加ã—ã¦ãã ã•ã„。"
+msgid "Add Readme"
+msgstr ""
msgid "Add new directory"
msgstr "æ–°è¦ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã‚’追加"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr ""
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr ""
msgid "Applications"
msgstr ""
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+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 leave this group?"
-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 you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr ""
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "ドラッグ&ドロップã¾ãŸã¯ %{upload_link} ã§ãƒ•ã‚¡ã‚¤ãƒ«ã‚’添付"
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -190,7 +453,31 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
@@ -247,9 +534,9 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "ブランãƒ"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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}"
@@ -257,6 +544,12 @@ msgstr "<strong>%{branch_name}</strong> ブランãƒãŒä½œæˆã•ã‚Œã¾ã—ãŸã€‚è
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "ブランãƒã‚’検索"
@@ -266,6 +559,15 @@ msgstr "ブランãƒã‚’切替"
msgid "Branches"
msgstr "ブランãƒ"
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -311,12 +613,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -362,14 +691,23 @@ msgstr "ファイルを表示"
msgid "Browse files"
msgstr "ファイルを表示"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "作者"
msgid "CI / CD"
msgstr ""
-msgid "CI configuration"
-msgstr "CI 設定"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -377,12 +715,21 @@ msgstr ""
msgid "Cancel"
msgstr "キャンセル"
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "ピック先ブランãƒ:"
@@ -395,22 +742,55 @@ msgstr "ãƒã‚§ãƒªãƒ¼ãƒ”ック"
msgid "ChangeTypeAction|Revert"
msgstr "リãƒãƒ¼ãƒˆ"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr "変更履歴"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "ãƒãƒ£ãƒ¼ãƒˆ"
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr "ã“ã®ã‚³ãƒŸãƒƒãƒˆã‚’ãƒã‚§ãƒªãƒ¼ãƒ”ック"
msgid "Cherry-pick this merge request"
msgstr "ã“ã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’ãƒã‚§ãƒªãƒ¼ãƒ”ック"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -467,55 +847,145 @@ msgstr "スキップ済ã¿"
msgid "CiStatus|running"
msgstr "実行中"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -527,43 +997,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -575,16 +1147,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -596,18 +1189,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr ""
@@ -615,8 +1223,8 @@ msgid "Commit"
msgid_plural "Commits"
msgstr[0] "コミット"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgid "Commit Message"
@@ -628,6 +1236,12 @@ msgstr "ç›´è¿‘30コミットã®æ‰€è¦æ™‚é–“(分)"
msgid "Commit message"
msgstr "コミットメッセージ"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "コミット"
@@ -640,15 +1254,99 @@ msgstr "コミット"
msgid "Commits feed"
msgstr "コミットフィード"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "履歴"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "コミット担当者: "
msgid "Compare"
msgstr "比較"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -694,12 +1392,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "貢献者å‘ã‘ガイド"
msgid "Contributors"
msgstr "貢献者"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -712,27 +1428,60 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr "クリップボードã«URLをコピー"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "コミットã®SHAをクリップボードã«ã‚³ãƒ”ー"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "æ–°è¦ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã‚’作æˆ"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "%{protocol} ã§ãƒ—ッシュやプルã™ã‚‹ãŸã‚ã®ã‚ãªãŸå€‹äººç”¨ã‚¢ã‚¯ã‚»ã‚¹ãƒˆãƒ¼ã‚¯ãƒ³ã‚’作æˆ"
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr "ディレクトリを作æˆ"
-msgid "Create empty bare repository"
-msgstr "空ã®bareレãƒã‚¸ãƒˆãƒªãƒ¼ã‚’作æˆ"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr "マージリクエストを作æˆ"
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -742,9 +1491,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr "æ–°è¦ä½œæˆ"
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "フォーク"
@@ -754,24 +1509,36 @@ msgstr "ã‚¿ã‚°"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "個人用アクセストークンを作æˆ"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr "Cron ã®ã‚¿ã‚¤ãƒ ã‚¾ãƒ¼ãƒ³"
msgid "Cron syntax"
msgstr "Cron ã®æ§‹æ–‡"
+msgid "Current node"
+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 "Customize colors"
+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 "コード"
@@ -799,6 +1566,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr "Cron 構文ã§ã‚«ã‚¹ã‚¿ãƒ ãªãƒ‘ターンを指定ã™ã‚‹"
@@ -821,10 +1597,19 @@ msgstr ""
msgid "Details"
msgstr ""
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr "ディレクトリå"
-msgid "Discard changes"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
@@ -833,9 +1618,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "次回ã‹ã‚‰è¡¨ç¤ºã—ãªã„"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "ダウンロード"
@@ -863,15 +1654,171 @@ msgstr "プレーン差分"
msgid "DownloadSource|Download"
msgstr "ダウンロード"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "編集"
msgid "Edit Pipeline Schedule %{id}"
msgstr "パイプラインスケジュール %{id} を編集"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
@@ -899,24 +1846,75 @@ msgstr "毎月 (1æ—¥ã®åˆå‰4:00)"
msgid "Every week (Sundays at 4:00am)"
msgstr "毎週 (日曜日ã®åˆå‰4:00)"
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "オーナーを変更ã§ãã¾ã›ã‚“ã§ã—ãŸ"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "パイプラインスケジュールを削除ã§ãã¾ã›ã‚“ã§ã—ãŸ"
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr "ファイル"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "コミットメッセージã§çµžã‚Šè¾¼ã¿"
@@ -926,12 +1924,21 @@ msgstr "パスã§æ¤œç´¢"
msgid "Find file"
msgstr "ファイルを検索"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "åˆå›ž"
msgid "FirstPushedBy|pushed by"
msgstr "プッシュã—ãŸäºº"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "フォーク"
@@ -942,25 +1949,178 @@ msgstr "フォーク元"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr "課題ãŒç™»éŒ²ã•ã‚Œã¦ã‹ã‚‰ãƒ—ロダクションã«ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§"
msgid "From merge request merge until deploy to production"
msgstr "マージリクエストãŒãƒžãƒ¼ã‚¸ã•ã‚Œã¦ã‹ã‚‰ãƒ—ロダクションã«ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -969,12 +2129,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr ""
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr ""
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr "自分ã®ãƒ•ã‚©ãƒ¼ã‚¯ã¸ç§»å‹•"
@@ -984,6 +2177,27 @@ msgstr "フォーク"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1020,12 +2234,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1053,6 +2261,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr ""
@@ -1071,15 +2285,58 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr "ãƒã‚¦ã‚¹ã‚­ãƒ¼ãƒ”ングã¯æ­£å¸¸ã«èµ·å‹•ã—ã¾ã—ãŸã€‚"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "レãƒã‚¸ãƒˆãƒªãƒ¼ã‚’インãƒãƒ¼ãƒˆ"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1089,6 +2346,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
@@ -1096,6 +2356,15 @@ msgid "Instance"
msgid_plural "Instances"
msgstr[0] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1111,9 +2380,6 @@ msgstr "サイクル分æžã®ã”紹介"
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr ""
@@ -1126,15 +2392,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "無効"
msgid "LFSStatus|Enabled"
msgstr "有効"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "éŽåŽ»%d日間"
@@ -1163,6 +2501,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "詳ã—ã見る:"
@@ -1181,31 +2528,78 @@ msgstr "プロジェクトを離脱"
msgid "License"
msgstr ""
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] "イベント表示数を最大 %d 個ã«åˆ¶é™"
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "中央値"
msgid "Members"
msgstr ""
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1215,37 +2609,172 @@ msgstr ""
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "SSH éµã‚’追加"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr ""
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "æ–°è¦èª²é¡Œ"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "æ–°è¦ãƒ‘イプラインスケジュール"
msgid "New branch"
msgstr "æ–°è¦ãƒ–ランãƒ"
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "æ–°è¦ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒª"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "æ–°è¦ãƒ•ã‚¡ã‚¤ãƒ«"
@@ -1255,6 +2784,9 @@ msgstr ""
msgid "New issue"
msgstr "æ–°è¦èª²é¡Œ"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "æ–°è¦ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
@@ -1273,7 +2805,28 @@ msgstr ""
msgid "New tag"
msgstr "æ–°è¦ã‚¿ã‚°"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1285,12 +2838,39 @@ msgstr "スケジュールãªã—"
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "利用ã§ãã¾ã›ã‚“"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "データä¸è¶³"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "イベント通知"
@@ -1348,18 +2928,48 @@ msgstr "ã™ã¹ã¦é€šçŸ¥"
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr "フィルター"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "オープンã•ã‚ŒãŸã®ã¯"
@@ -1369,12 +2979,21 @@ msgstr ""
msgid "Options"
msgstr "オプション"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr "オーナー"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1387,10 +3006,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1435,12 +3063,6 @@ msgstr "全件"
msgid "PipelineSchedules|Inactive"
msgstr "無効"
-msgid "PipelineSchedules|Input variable key"
-msgstr "変数ã®åå‰ã‚’入力"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "変数ã®å€¤ã‚’入力"
-
msgid "PipelineSchedules|Next Run"
msgstr "次ã®å®Ÿè¡Œ"
@@ -1450,9 +3072,6 @@ msgstr "ãªã—"
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "ã“ã®ãƒ‘イプラインã«ã¤ã„ã¦ç°¡å˜ã«è¨˜è¿°ã—ã¦ãã ã•ã„。"
-msgid "PipelineSchedules|Remove variable row"
-msgstr "変数を削除"
-
msgid "PipelineSchedules|Take ownership"
msgstr "権é™ã‚’å–å¾—ã™ã‚‹"
@@ -1480,6 +3099,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "全件"
@@ -1492,15 +3162,39 @@ msgstr "ステージã‚ã‚Š"
msgid "Pipeline|with stages"
msgstr "ステージã‚ã‚Š"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1540,6 +3234,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1555,6 +3255,12 @@ msgstr "'%{project_name}' プロジェクトã¯æ­£å¸¸ã«æ›´æ–°ã•ã‚Œã¾ã—ãŸã€‚
msgid "Project access must be granted explicitly to each user."
msgstr "ユーザーã”ã¨ã«ãƒ—ロジェクトアクセスã®æ¨©é™ã‚’指定ã—ãªã‘ã‚Œã°ãªã‚Šã¾ã›ã‚“。"
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr ""
@@ -1573,6 +3279,21 @@ msgstr "プロジェクトã®ã‚¨ã‚¯ã‚¹ãƒãƒ¼ãƒˆã‚’開始ã—ã¾ã—ãŸã€‚ダウン
msgid "ProjectActivityRSS|Subscribe"
msgstr ""
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "無効"
@@ -1636,6 +3357,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1648,21 +3456,39 @@ msgstr ""
msgid "Push events"
msgstr ""
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr "続ãを読む"
msgid "Readme"
msgstr ""
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "ブランãƒ"
msgid "RefSwitcher|Tags"
msgstr "ã‚¿ã‚°"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1684,15 +3510,42 @@ msgstr "関連ã™ã‚‹ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
msgid "Related Merged Requests"
msgstr "関連ã™ã‚‹ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "後ã§é€šçŸ¥"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "プロジェクトを削除"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "アクセス権é™ã‚’リクエストã™ã‚‹"
@@ -1705,16 +3558,53 @@ msgstr ""
msgid "Reset runners registration token"
msgstr ""
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+
msgid "Revert this commit"
msgstr "ã“ã®ã‚³ãƒŸãƒƒãƒˆã‚’リãƒãƒ¼ãƒˆ"
msgid "Revert this merge request"
msgstr "ã“ã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’リãƒãƒ¼ãƒˆ"
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
msgstr ""
-msgid "Save"
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1723,50 +3613,116 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr "パイプラインスケジュールをä¿å­˜"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "æ–°ã—ã„パイプラインã®ã‚¹ã‚±ã‚¸ãƒ¥ãƒ¼ãƒ«ã‚’作æˆ"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "パイプラインスケジューリング"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "ブランãƒã¾ãŸã¯ã‚¿ã‚°ã‚’検索"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "アーカイブã®ãƒ•ã‚©ãƒ¼ãƒžãƒƒãƒˆã‚’é¸æŠž"
msgid "Select a timezone"
msgstr "タイムゾーンをé¸æŠž"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "ターゲットブランãƒã‚’é¸æŠž"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr "%{protocol} プロコトル経由ã§ãƒ—ルã€ãƒ—ッシュã™ã‚‹ãŸã‚ã«ã‚¢ã‚«ã‚¦ãƒ³ãƒˆã®ãƒ‘スワードを設定。"
-msgid "Set up CI"
-msgstr "CI を設定"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "Koding を設定"
-msgid "Set up auto deploy"
-msgstr "自動デプロイを設定"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "パスワードを設定"
@@ -1774,6 +3730,24 @@ msgstr "パスワードを設定"
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1784,13 +3758,46 @@ msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] "%d ã®ã‚¤ãƒ™ãƒ³ãƒˆã‚’表示中"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1799,6 +3806,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1898,18 +3908,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "ソースコード"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr "スターを付ã‘ã‚‹"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1919,28 +3947,118 @@ msgstr "ã“ã®å¤‰æ›´ã§ %{new_merge_request} を作æˆã™ã‚‹"
msgid "Start the Runner!"
msgstr ""
-msgid "Subgroups"
+msgid "Started"
msgstr ""
-msgid "Subscribe"
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr "ブランãƒãƒ»ã‚¿ã‚°åˆ‡ã‚Šæ›¿ãˆ"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "ã‚¿ã‚°"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
msgid "Tags"
msgstr "ã‚¿ã‚°"
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "ターゲットブランãƒ"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1950,7 +4068,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1959,30 +4083,39 @@ msgstr "コーディングステージã§ã¯ã€æœ€åˆã®ã‚³ãƒŸãƒƒãƒˆã‹ã‚‰ãƒžãƒ¼
msgid "The collection of events added to the data gathered for that stage."
msgstr "ã“ã®ã‚¹ãƒ†ãƒ¼ã‚¸ã§è¨ˆæ¸¬ãƒ‡ãƒ¼ã‚¿ã«è¿½åŠ ã•ã‚ŒãŸã‚¤ãƒ™ãƒ³ãƒˆãƒªã‚¹ãƒˆ"
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "フォークã®ãƒªãƒ¬ãƒ¼ã‚·ãƒ§ãƒ³ãŒå‰Šé™¤ã•ã‚Œã¾ã—ãŸã€‚"
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "プロダクションステージã§ã¯ã€èª²é¡ŒãŒä½œæˆã•ã‚Œã¦ã‹ã‚‰ãƒ—ロダクションã¸ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚アイディアã®æ™‚点ã‹ã‚‰ãƒ—ロダクションã¾ã§ã®å…¨ã‚¹ãƒ†ãƒ¼ã‚¸ãŒå®Œäº†ã—ãŸã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
@@ -1995,9 +4128,18 @@ msgstr "プロジェクトã¯ã€ãƒ­ã‚°ã‚¤ãƒ³ãªã—ã«èª°ã§ã‚‚アクセスã§ã
msgid "The repository for this project does not exist."
msgstr "ã“ã®ãƒ—ロジェクトã«ãƒ¬ãƒã‚¸ãƒˆãƒªãƒ¼ã¯ã‚ã‚Šã¾ã›ã‚“。"
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 "ステージングステージã§ã¯ã€ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆãŒãƒžãƒ¼ã‚¸ã•ã‚Œã¦ã‹ã‚‰ã‚³ãƒ¼ãƒ‰ãŒãƒ—ロダクション環境ã«ãƒ‡ãƒ—ロイã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚ã“ã®ãƒ‡ãƒ¼ã‚¿ã¯æœ€åˆã«ãƒ—ロダクションã«ãƒ‡ãƒ—ロイã—ãŸã¨ãã«è‡ªå‹•çš„ã«è¿½åŠ ã•ã‚Œã¾ã™ã€‚"
@@ -2010,16 +4152,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
msgid "The time taken by each data entry gathered by that stage."
msgstr "ã“ã®ã‚¹ãƒ†ãƒ¼ã‚¸ã«åŽé›†ã•ã‚ŒãŸãƒ‡ãƒ¼ã‚¿æ¯Žã®æ™‚é–“"
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr "得られãŸä¸€é€£ã®ãƒ‡ãƒ¼ã‚¿ã‚’å°ã•ã„é †ã«ä¸¦ã¹ãŸã¨ãã«ä¸­å¤®ã«ä½ç½®ã™ã‚‹å€¤ã€‚例ãˆã°ã€3, 5, 9ã®ä¸­å¤®å€¤ã¯5。3, 5, 7, 8ã®ä¸­å¤®å€¤ã¯ (5+7)/2 = 6。"
+msgid "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2028,18 +4203,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "空レãƒã‚¸ãƒˆãƒªãƒ¼ã‚’作æˆã¾ãŸã¯æ—¢å­˜ãƒ¬ãƒã‚¸ãƒˆãƒªãƒ¼ã‚’インãƒãƒ¼ãƒˆã‚’ã—ãªã‘ã‚Œã°ã€ã‚³ãƒ¼ãƒ‰ã®ãƒ—ッシュã¯ã§ãã¾ã›ã‚“。"
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "課題ãŒè¨ˆç”»ã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“"
@@ -2049,9 +4260,27 @@ msgstr "課題ã®å®Ÿè£…ãŒé–‹å§‹ã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“"
msgid "Time between merge request creation and merge/close"
msgstr "マージリクエストãŒä½œæˆã•ã‚Œã¦ã‹ã‚‰ãƒžãƒ¼ã‚¸ã¾ãŸã¯ã‚¯ãƒ­ãƒ¼ã‚ºã•ã‚Œã‚‹ã¾ã§ã®æ™‚é–“"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "最åˆã®ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã¾ã§ã®æ™‚é–“"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "%sæ—¥å‰"
@@ -2186,25 +4415,97 @@ msgstr[0] "分"
msgid "Time|s"
msgstr "秒"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr "åˆè¨ˆæ™‚é–“"
msgid "Total test time for all commits/merges"
msgstr "ã™ã¹ã¦ã®ã‚³ãƒŸãƒƒãƒˆ/マージã®åˆè¨ˆãƒ†ã‚¹ãƒˆæ™‚é–“"
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "スターを外ã™"
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2228,24 +4529,72 @@ msgstr "æ–°è¦ãƒ•ã‚¡ã‚¤ãƒ«ã‚’アップロード"
msgid "Upload file"
msgstr "ファイルをアップロード"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "クリックã—ã¦ã‚¢ãƒƒãƒ—ロード"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr "全体通知設定を利用"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "オープンãªãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’表示"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "内部"
@@ -2261,16 +4610,28 @@ msgstr "ä¸æ˜Ž"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "ã“ã®ãƒ‡ãƒ¼ã‚¿ã‚’å‚ç…§ã—ãŸã„ã§ã™ã‹ï¼Ÿã‚¢ã‚¯ã‚»ã‚¹ã™ã‚‹ã«ã¯ç®¡ç†è€…ã«å•ã„åˆã‚ã›ã¦ãã ã•ã„。"
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "データä¸è¶³ã®ãŸã‚ã€ã“ã®ã‚¹ãƒ†ãƒ¼ã‚¸ã®è¡¨ç¤ºã¯ã§ãã¾ã›ã‚“。"
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2291,6 +4652,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2381,39 +4748,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "アクセスリクエストをå–り消ã™"
+msgid "Write a commit message..."
+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 %{project_full_name}. 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 "å…ƒã®ãƒ—ロジェクト (%{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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "ファイルを追加ã™ã‚‹ã«ã¯ã€ã©ã“ã‹ã®ãƒ–ランãƒã«ã„ãªã‘ã‚Œã°ã„ã‘ã¾ã›ã‚“"
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "プロジェクト数ã®ä¸Šé™ã«é”ã—ã¦ã„ã¾ã™"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "プロジェクトã«ã‚¹ã‚¿ãƒ¼ã‚’ã¤ã‘ãŸã„å ´åˆã¯ãƒ­ã‚°ã‚¤ãƒ³ã—ã¦ãã ã•ã„"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "権é™ãŒå¿…è¦ã§ã™"
@@ -2438,6 +4835,36 @@ msgstr "%{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 "%{add_ssh_key_link} をプロファイルã«è¿½åŠ ã—ã¦ã„ãªã„ã®ã§ã€ãƒ—ロジェクトã«ã‚½ãƒ¼ã‚¹ã‚³ãƒ¼ãƒ‰ã‚’プッシュã€ãƒ—ルã§ãã¾ã›ã‚“"
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2450,19 +4877,347 @@ msgstr "åå‰"
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
msgid_plural "days"
msgstr[0] "æ—¥"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "æ–°è¦ãƒžãƒ¼ã‚¸ãƒªã‚¯ã‚¨ã‚¹ãƒˆ"
msgid "notification emails"
msgstr "メール通知"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "親"
@@ -2473,9 +5228,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po
index d6c1ff2deeb..91f68dfdee1 100644
--- a/locale/ko/gitlab.po
+++ b/locale/ko/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-03 12:31-0400\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:34-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Korean\n"
"Language: ko_KR\n"
@@ -16,41 +16,76 @@ msgstr ""
"X-Crowdin-Language: ko\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d 커밋"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] ""
+
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 "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr ""
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "%{number_of_failures} / %{maximum_failures} 실패. GitLab ì€ ë‹¤ìŒ ì‹œë„ì—ì„œ 성공하면 ì ‘ê·¼ì„ í—ˆìš©í•  것입니다."
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "%{number_of_failures} / %{maximum_failures} 실패. GitLab ì€ %{number_of_seconds} ì´ˆ ê°„ ì ‘ê·¼ì„ ì œí•œí•˜ê² ìŠµë‹ˆë‹¤."
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "%{number_of_failures} / %{maximum_failures} 실패. GitLab ì€ ìžë™ìœ¼ë¡œ 다시 ì‹œë„하지 않습니다. 문제가 í•´ê²°ë˜ë©´ 저장 공간 정보를 초기화 해주세요. "
+msgid "%{openOrClose} %{noteable}"
+msgstr ""
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] ""
+msgid "%{text} is available"
+msgstr ""
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "설치 ë°©ë²•ì— ëŒ€í•œ 정보를 얻기 위해 %{link} 를 ì²´í¬ì•„웃하세요."
@@ -70,15 +105,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "지ì†ì ì¸ í†µí•©ì— ê´€í•œ 그래프 모ìŒ"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "ìžë™ ë°°í¬ ì •ë³´"
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -88,6 +138,9 @@ msgstr "오ë™ìž‘ì¤‘ì¸ ì €ìž¥ê³µê°„ì— ëŒ€í•œ ì ‘ê·¼ì´ ë³µêµ¬ ìž‘ì—…ì„ ìœ„í•´
msgid "Account"
msgstr ""
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "활성"
@@ -106,73 +159,283 @@ msgstr "기여 ê°€ì´ë“œ 추가"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+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 Readme"
+msgstr ""
msgid "Add new directory"
msgstr "새 디렉토리 추가"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr "ì „ì²´"
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr ""
msgid "Applications"
msgstr ""
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+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 leave this group?"
-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 you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr "확실합니까?"
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "드래그 &amp; 드롭 ë˜ëŠ” %{upload_link}"
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -190,7 +453,31 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
@@ -247,9 +534,9 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "브랜치"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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}"
@@ -257,6 +544,12 @@ msgstr "<strong>%{branch_name}</strong> 브랜치가 ìƒì„±ë˜ì—ˆìŠµë‹ˆë‹¤. ìžë
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "브랜치 검색"
@@ -266,6 +559,15 @@ msgstr "브랜치 변경"
msgid "Branches"
msgstr "브랜치"
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -311,12 +613,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -362,14 +691,23 @@ msgstr "íŒŒì¼ ì°¾ì•„ë³´ê¸°"
msgid "Browse files"
msgstr "íŒŒì¼ ì°¾ì•„ë³´ê¸°"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "작성ìž"
msgid "CI / CD"
msgstr ""
-msgid "CI configuration"
-msgstr "CI 설정"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -377,12 +715,21 @@ msgstr ""
msgid "Cancel"
msgstr "취소"
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "브랜치ì—ì„œ Pick"
@@ -395,22 +742,55 @@ msgstr "Cherry-pick"
msgid "ChangeTypeAction|Revert"
msgstr "Revert"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr "변경사항"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "차트"
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr "ì´ ì»¤ë°‹ì„ Cherry-pick"
msgid "Cherry-pick this merge request"
msgstr "ì´ ë¨¸ì§€ 리퀘스트를 Cherry-pick"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -467,55 +847,145 @@ msgstr "건너 뜀"
msgid "CiStatus|running"
msgstr "실행 중"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -527,43 +997,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -575,16 +1147,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -596,18 +1189,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr ""
@@ -615,8 +1223,8 @@ msgid "Commit"
msgid_plural "Commits"
msgstr[0] "커밋"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgid "Commit Message"
@@ -628,6 +1236,12 @@ msgstr "최근 30 ê±´ì˜ ì»¤ë°‹ 소요시간 (분)"
msgid "Commit message"
msgstr "커밋 메시지"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "커밋"
@@ -640,15 +1254,99 @@ msgstr "커밋"
msgid "Commits feed"
msgstr "커밋 피드"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "ì´ë ¥"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "커밋한 사용ìž"
msgid "Compare"
msgstr "비êµ"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -694,12 +1392,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "ê¸°ì—¬ì— ëŒ€í•œ 안내"
msgid "Contributors"
msgstr "기여해 주신 분들"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -712,27 +1428,60 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr "URLì„ í´ë¦½ë³´ë“œì— 복사"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "ì»¤ë°‹ì˜ SHA를 í´ë¦½ë³´ë“œë¡œ 복사합니다"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "새 디렉토리 만들기"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "%{protocol}ì„ (를) 통해 Pull 하거나 Push í•  ê°œì¸ ì•¡ì„¸ìŠ¤ 토í°ì„ 만드십시오."
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr "디렉토리 만들기"
-msgid "Create empty bare repository"
-msgstr "빈 bare 저장소 만들기"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr "머지 리퀘스트 만들기"
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -742,9 +1491,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr "새로 만들기 ..."
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "í¬í¬"
@@ -754,23 +1509,35 @@ msgstr "태그"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "ê°œì¸ ì•¡ì„¸ìŠ¤ í† í° ë§Œë“¤ê¸°"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr "Cron 시간대"
msgid "Cron syntax"
msgstr "í¬ë¡  구문"
+msgid "Current node"
+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"
+msgid "Customize colors"
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는 프로ì íŠ¸ì—ì„œ ì•„ì´ë””어를 프로ë•ì…˜ìœ¼ë¡œ 옮기는 ë° ê±¸ë¦¬ëŠ” ì‹œê°„ì„ ëŒ€ëžµì ìœ¼ë¡œ ë³´ì—¬ì¤ë‹ˆë‹¤."
+msgid "Cycle Analytics"
+msgstr ""
msgid "CycleAnalyticsStage|Code"
msgstr "코드"
@@ -799,6 +1566,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr "cron êµ¬ë¬¸ì„ ì‚¬ìš©í•˜ì—¬ ì‚¬ìš©ìž ì •ì˜ íŒ¨í„´ ì •ì˜"
@@ -821,11 +1597,20 @@ msgstr ""
msgid "Details"
msgstr "ìƒì„¸"
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr "디렉토리 ì´ë¦„"
-msgid "Discard changes"
-msgstr "변경 내용 취소"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
+msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
msgstr ""
@@ -833,9 +1618,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "다시 표시하지 ì•ŠìŒ"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "다운로드"
@@ -863,15 +1654,171 @@ msgstr "Plain Diff"
msgid "DownloadSource|Download"
msgstr "다운로드"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "편집"
msgid "Edit Pipeline Schedule %{id}"
msgstr "파ì´í”„ë¼ì¸ 스케줄 편집 %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr "모든 ê°’ì„ ê¸°ì¤€ìœ¼ë¡œ í•„í„°"
@@ -899,24 +1846,75 @@ msgstr "매월 (1ì¼ ì˜¤ì „ 4ì‹œ)"
msgid "Every week (Sundays at 4:00am)"
msgstr "매주 (ì¼ìš”ì¼ ì˜¤ì „ 4ì‹œì—)"
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "소유ìžë¥¼ 변경하지 못했습니다"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "파ì´í”„ë¼ì¸ ìŠ¤ì¼€ì¤„ì„ ì œê±°í•˜ì§€ 못했습니다."
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr "파ì¼"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "커밋 메시지로 필터"
@@ -926,12 +1924,21 @@ msgstr "경로로 찾기"
msgid "Find file"
msgstr "íŒŒì¼ ì°¾ê¸°"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "처ìŒ"
msgid "FirstPushedBy|pushed by"
msgstr "푸시한 사용ìž"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "í¬í¬"
@@ -942,25 +1949,178 @@ msgstr "í¬í¬í•œ 사용ìž"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr "ì´ìŠˆ ìƒì„±ì—ì„œ 프로ë•ì…˜ ë°°í¬ê¹Œì§€"
msgid "From merge request merge until deploy to production"
msgstr "머지 리퀘스트 머지ì—ì„œ 프로ë•ì…˜ í™˜ê²½ì— ë°°í¬ê¹Œì§€"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -969,12 +2129,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr "git storage ìƒíƒœ ì •ë³´ê°€ 초기화ë˜ì—ˆìŠµë‹ˆë‹¤."
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "GitLab Runner 섹션"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr "ë‹¹ì‹ ì˜ í¬í¬ë¡œ ì´ë™í•˜ì„¸ìš”"
@@ -984,6 +2177,27 @@ msgstr "í¬í¬"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1020,12 +2234,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1053,6 +2261,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "헬스 ì²´í¬"
@@ -1071,15 +2285,58 @@ msgstr " 헬스 문제가 발견ë˜ì§€ 않았습니다."
msgid "HealthCheck|Unhealthy"
msgstr "비정ìƒ"
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr "Housekeepingì´ ì„±ê³µì ìœ¼ë¡œ 시작ë˜ì—ˆìŠµë‹ˆë‹¤"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "저장소 가져 오기"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1089,6 +2346,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr "GitLab CI 와 호환ë˜ëŠ” Runner 설치"
@@ -1096,6 +2356,15 @@ msgid "Instance"
msgid_plural "Instances"
msgstr[0] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1111,9 +2380,6 @@ msgstr "Cycle Analytics 소개"
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr "ì´ìŠˆ ì´ë²¤íŠ¸"
@@ -1126,15 +2392,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "Disabled"
msgid "LFSStatus|Enabled"
msgstr "Enabled"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "최근 %d ì¼"
@@ -1163,6 +2501,15 @@ msgstr "푸쉬: "
msgid "LastPushEvent|at"
msgstr "at"
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "ë” ìžì„¸ížˆ 알아보기"
@@ -1181,31 +2528,78 @@ msgstr "프로ì íŠ¸ì—ì„œ 나가기"
msgid "License"
msgstr ""
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] "최대 %d ì´ë²¤íŠ¸ 만 표시하는 것으로 제한ë©ë‹ˆë‹¤."
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "중앙값"
msgid "Members"
msgstr ""
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1215,37 +2609,172 @@ msgstr "머지 ì´ë²¤íŠ¸"
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "SSH 키 추가"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr "여기"
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "새 ì´ìŠˆ"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "새로운 파ì´í”„ë¼ì¸ ì¼ì •"
msgid "New branch"
msgstr "새 브랜치"
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "새 디렉토리"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "새 파ì¼"
@@ -1255,6 +2784,9 @@ msgstr ""
msgid "New issue"
msgstr "새 ì´ìŠˆ"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "새 머지 리퀘스트"
@@ -1273,7 +2805,28 @@ msgstr ""
msgid "New tag"
msgstr "새 태그 "
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1285,12 +2838,39 @@ msgstr "ì¼ì • ì—†ìŒ"
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "사용할 수 ì—†ìŒ"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "ë°ì´í„°ê°€ 충분하지 않습니다."
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "알림 ì´ë²¤íŠ¸"
@@ -1348,18 +2928,48 @@ msgstr "Watch"
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr "í•„í„°"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "열린"
@@ -1369,12 +2979,21 @@ msgstr ""
msgid "Options"
msgstr "옵션 "
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr "소유ìž"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1387,10 +3006,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1435,12 +3063,6 @@ msgstr "모ë‘"
msgid "PipelineSchedules|Inactive"
msgstr "비활성"
-msgid "PipelineSchedules|Input variable key"
-msgstr "입력 변수 키"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "입력 변수 값"
-
msgid "PipelineSchedules|Next Run"
msgstr "ë‹¤ìŒ ì‹¤í–‰"
@@ -1450,9 +3072,6 @@ msgstr "ì—†ìŒ"
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "ì´ íŒŒì´í”„ë¼ì¸ì— 대한 간단한 설명 제공"
-msgid "PipelineSchedules|Remove variable row"
-msgstr "변수 행 제거"
-
msgid "PipelineSchedules|Take ownership"
msgstr "소유권 가져 오기"
@@ -1480,6 +3099,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "모ë‘"
@@ -1492,15 +3162,39 @@ msgstr "스테ì´ì§•"
msgid "Pipeline|with stages"
msgstr "스테ì´ì§•"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1540,6 +3234,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1555,6 +3255,12 @@ msgstr "'%{project_name}'프로ì íŠ¸ê°€ 성공ì ìœ¼ë¡œ ì—…ë°ì´íŠ¸ë˜ì—ˆìŠµë‹
msgid "Project access must be granted explicitly to each user."
msgstr "프로ì íŠ¸ 액세스는 ê° ì‚¬ìš©ìžì—게 명시ì ìœ¼ë¡œ 부여ë˜ì–´ì•¼í•©ë‹ˆë‹¤."
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr "프로ì íŠ¸ ìƒì„¸"
@@ -1573,6 +3279,21 @@ msgstr "프로ì íŠ¸ 내보내기가 시작ë˜ì—ˆìŠµë‹ˆë‹¤. 다운로드 ë§í¬ë
msgid "ProjectActivityRSS|Subscribe"
msgstr "구ë…"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "사용 안 함"
@@ -1636,6 +3357,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1648,21 +3456,39 @@ msgstr ""
msgid "Push events"
msgstr "푸쉬 ì´ë²¤íŠ¸"
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr "ë” ì½ê¸°"
msgid "Readme"
msgstr ""
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "브랜치"
msgid "RefSwitcher|Tags"
msgstr "태그"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1684,15 +3510,42 @@ msgstr "관련 머지 리퀘스트"
msgid "Related Merged Requests"
msgstr "관련 머지 리퀘스트"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "ë‚˜ì¤‘ì— ë‹¤ì‹œ 알림"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "프로ì íŠ¸ ì‚­ì œ"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "액세스 요청"
@@ -1705,16 +3558,53 @@ msgstr "헬스 ì²´í¬ ì ‘ê·¼ í† í° ì´ˆê¸°í™”"
msgid "Reset runners registration token"
msgstr "runner ë“±ë¡ í† í° ì´ˆê¸°í™”"
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+
msgid "Revert this commit"
msgstr "ì´ ì»¤ë°‹ ë˜ëŒë¦¬ê¸°"
msgid "Revert this merge request"
msgstr "ì´ ë¨¸ì§€ 리퀘스트 ë˜ëŒë¦¬ê¸°"
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
msgstr ""
-msgid "Save"
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1723,50 +3613,116 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr "파ì´í”„ë¼ì¸ 스케줄 저장"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "새로운 파ì´í”„ë¼ì¸ 스케줄 잡기"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "파ì´í”„ë¼ì¸ 스케줄ë§"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "브랜치 ë° íƒœê·¸ 검색"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "ì•„ì¹´ì´ë¸Œ í¬ë§· ì„ íƒ"
msgid "Select a timezone"
msgstr "시간대 ì„ íƒ"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "ëŒ€ìƒ ë¸Œëžœì¹˜ ì„ íƒ"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+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 default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "Koding 설정"
-msgid "Set up auto deploy"
-msgstr "ìžë™ ë°°í¬ ì„¤ì •"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "패스워드 설정"
@@ -1774,6 +3730,24 @@ msgstr "패스워드 설정"
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1784,13 +3758,46 @@ msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] "%d ê°œì˜ ì´ë²¤íŠ¸ 표시 중"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1799,6 +3806,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1898,18 +3908,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "소스 코드"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "Runner 설정 중 ë‹¤ìŒ URLì„ ì§€ì •í•˜ì„¸ìš”."
msgid "StarProject|Star"
msgstr "별표"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1919,28 +3947,118 @@ msgstr "ì´ ë³€ê²½ 사항으로 %{new_merge_request} ì„ ì‹œìž‘í•˜ì‹­ì‹œì˜¤."
msgid "Start the Runner!"
msgstr "Runner 시작!"
-msgid "Subgroups"
+msgid "Started"
msgstr ""
-msgid "Subscribe"
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr "스위치 브랜치/태그"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "태그"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
msgid "Tags"
msgstr "태그 "
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "ëŒ€ìƒ ë¸Œëžœì¹˜"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1950,7 +4068,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1959,30 +4083,39 @@ msgstr "Coding Stage는 첫 번째 커밋ì—서부터 머지 리퀘스트 ìƒì„±
msgid "The collection of events added to the data gathered for that stage."
msgstr "해당 단계ì—ì„œ 수집 ëœ ë°ì´í„°ê°€ ì´ë²¤íŠ¸ 모ìŒì— 추가ë˜ì—ˆìŠµë‹ˆë‹¤."
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "í¬í¬ 관계가 제거ë˜ì—ˆìŠµë‹ˆë‹¤."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "프로ë•ì…˜ 단계ì—서는 문제를 만들고 코드를 프로ë•ì…˜ 환경으로 ë°°í¬í•˜ëŠ” ë° ê±¸ë¦¬ëŠ” ì´ ì‹œê°„ì„ ë³´ì—¬ì¤ë‹ˆë‹¤. ìƒì‚°ì£¼ê¸°ì— 대한 완전한 ì•„ì´ë””어를 ì–»ì€ í›„ì—는 ë°ì´í„°ê°€ ìžë™ìœ¼ë¡œ 추가ë©ë‹ˆë‹¤."
@@ -1995,9 +4128,18 @@ msgstr "ì´ í”„ë¡œì íŠ¸ëŠ” ì¸ì¦ì—†ì´ 액세스 í•  수 있습니다."
msgid "The repository for this project does not exist."
msgstr "ì´ í”„ë¡œì íŠ¸ì˜ 저장소가 존재하지 않습니다."
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 "Staging 단계ì—서는 MR 머지과 프로ë•ì…˜ í™˜ê²½ì— ì½”ë“œ ë°°í¬ ì‚¬ì´ì˜ ì‹œê°„ì„ ë³´ì—¬ì¤ë‹ˆë‹¤. ë°ì´í„°ë¥¼ Production í™˜ê²½ì— ì²˜ìŒ ë°°í¬í•˜ë©´ ë°ì´í„°ê°€ ìžë™ìœ¼ë¡œ 추가ë©ë‹ˆë‹¤."
@@ -2010,16 +4152,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
msgid "The time taken by each data entry gathered by that stage."
msgstr "해당 단계ì—ì„œ 수집 í•œ ê° ë°ì´í„° ìž…ë ¥ì— ì†Œìš” ëœ ì‹œê°„"
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr "ê°’ì€ ì¼ë ¨ì˜ 관측 ê°’ 중ì ì— 있습니다. 예를 들어, 3, 5, 9 사ì´ì˜ 중간 ê°’ì€ 5입니다. 3, 5, 7, 8 사ì´ì˜ 중간 ê°’ì€ (5 + 7) / 2 = 6입니다."
+msgid "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr "git storageì— ì ‘ê·¼í•˜ëŠ”ë° ë¬¸ì œê°€ ë°œìƒí–ˆìŠµë‹ˆë‹¤. "
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2028,18 +4203,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "즉, 빈 저장소를 만들거나 기존 저장소를 가져올 때까지 코드를 Push 할 수 없습니다."
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "ì´ìŠˆê°€ 스케줄ë˜ê¸° ì „ì˜ ì‹œê°„"
@@ -2049,9 +4260,27 @@ msgstr "ì´ìŠˆê°€ 구현ë˜ê¸° ì „ì˜ ì‹œê°„"
msgid "Time between merge request creation and merge/close"
msgstr "머지 리퀘스트 ìƒì„±ê³¼ 머지 / 닫기 사ì´ì˜ 시간"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "첫 번째 머지 ë¦¬í€˜ìŠ¤íŠ¸ê¹Œì§€ì˜ ì‹œê°„"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "%s ì¼ ì „"
@@ -2186,25 +4415,97 @@ msgstr[0] "분"
msgid "Time|s"
msgstr "ì´ˆ"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr "시간 합계:"
msgid "Total test time for all commits/merges"
msgstr "모든 커밋 / ë¨¸ì§€ì˜ ì´ í…ŒìŠ¤íŠ¸ 시간"
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "별표 제거"
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2228,24 +4529,72 @@ msgstr "새 íŒŒì¼ ì—…ë¡œë“œ"
msgid "Upload file"
msgstr "íŒŒì¼ ì—…ë¡œë“œ"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "업로드하려면 í´ë¦­í•˜ì‹­ì‹œì˜¤."
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr "설정 ì¤‘ì— ë‹¤ìŒ ë“±ë¡ í† í° ì´ìš© : "
msgid "Use your global notification setting"
msgstr "전체 알림 설정 사용"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "열린 머지 리퀘스트보기"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "내부"
@@ -2261,16 +4610,28 @@ msgstr "ì•Œ 수 ì—†ìŒ"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "ì´ ë°ì´í„°ë¥¼ ë³´ê³  싶ì€ê°€ìš”? 관리ìžì—게 액세스 ê¶Œí•œì„ ìš”ì²­í•˜ì„¸ìš”."
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "ì´ ë‹¨ê³„ë¥¼ ë³´ì—¬ì£¼ê¸°ì— ì¶©ë¶„í•œ ë°ì´í„°ê°€ 없습니다."
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2291,6 +4652,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2381,39 +4748,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "액세스 요청 철회"
+msgid "Write a commit message..."
+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 %{project_full_name}. 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 "í¬í¬ 관계를 소스 프로ì íŠ¸ %{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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "ë¸Œëžœì¹˜ì— ìžˆì„ ë•Œì—만 파ì¼ì„ 추가 í•  수 있습니다."
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "프로ì íŠ¸ ìˆ«ìž í•œë„ì— ë„달했습니다."
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "프로ì íŠ¸ì— 별표를 표시하려면 ë¡œê·¸ì¸ í•´ì•¼ 합니다."
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "ê¶Œí•œì´ í•„ìš”í•©ë‹ˆë‹¤."
@@ -2438,6 +4835,36 @@ 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를 통해 프로ì íŠ¸ 코드를 Pull 하거나 Push í•  수 없습니다"
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2450,19 +4877,347 @@ msgstr "ê·€í•˜ì˜ ì´ë¦„"
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
msgid_plural "days"
msgstr[0] "ì¼"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "새 머지 리퀘스트"
msgid "notification emails"
msgstr "알림 ì´ë©”ì¼"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "부모"
@@ -2473,9 +5228,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/nl_NL/gitlab.po b/locale/nl_NL/gitlab.po
index 68d1f809bb4..1b3811198a8 100644
--- a/locale/nl_NL/gitlab.po
+++ b/locale/nl_NL/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-03 12:31-0400\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:39-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -16,22 +16,53 @@ msgstr ""
"X-Crowdin-Language: nl\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d commit"
msgstr[1] "%d commits"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
msgstr[1] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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] "%s andere commit is weggelaten om prestatieproblemen te voorkomen."
msgstr[1] "%s andere commits zijn weggelaten om prestatieproblemen te voorkomen."
-msgid "%{commit_author_link} committed %{commit_timeago}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
msgid "%{count} participant"
@@ -39,16 +70,22 @@ msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
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."
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
@@ -56,6 +93,9 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts
msgstr[0] ""
msgstr[1] ""
+msgid "%{text} is available"
+msgstr ""
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(bekijk de %{link} voor meer info over hoe je het kan installeren)."
@@ -76,15 +116,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr ""
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "Over auto deploy"
msgid "Abuse Reports"
msgstr "Misbruik rapporten"
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr "Toegangstokens"
@@ -94,6 +149,9 @@ msgstr ""
msgid "Account"
msgstr "Account"
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "Actief"
@@ -101,7 +159,7 @@ msgid "Activity"
msgstr "Activiteit"
msgid "Add"
-msgstr "Voeg toe"
+msgstr ""
msgid "Add Changelog"
msgstr "Changelog toevoegen"
@@ -112,43 +170,208 @@ msgstr ""
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
msgid "Add License"
msgstr "Licentie toevoegen"
-msgid "Add an SSH key to your profile to pull or push via SSH."
+msgid "Add Readme"
msgstr ""
msgid "Add new directory"
msgstr "Nieuwe map toevoegen"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr "Alles"
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr "Uiterlijk"
msgid "Applications"
msgstr "Applicaties"
-msgid "Archived project! Repository is read-only"
+msgid "Apr"
msgstr ""
-msgid "Are you sure you want to delete this pipeline schedule?"
+msgid "April"
msgstr ""
-msgid "Are you sure you want to discard your changes?"
+msgid "Archived project! Repository is read-only"
msgstr ""
-msgid "Are you sure you want to leave this group?"
+msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
msgid "Are you sure you want to reset registration token?"
@@ -157,28 +380,73 @@ msgstr ""
msgid "Are you sure you want to reset the health check token?"
msgstr ""
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr ""
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr ""
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr "Auteur"
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -196,11 +464,35 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
-msgstr "Facturatie"
+msgstr ""
msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
msgstr ""
@@ -253,8 +545,8 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
msgstr[0] ""
msgstr[1] ""
@@ -264,6 +556,12 @@ msgstr ""
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "BranchSwitcherPlaceholder|Zoek branches"
@@ -273,6 +571,15 @@ msgstr "BranchSwitcherTitle|Ga naar branch"
msgid "Branches"
msgstr "Branches"
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr "Branches|Kan geen HEAD-commit vinden voor deze branch"
@@ -318,12 +625,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -369,14 +703,23 @@ msgstr "Door bestanden bladeren"
msgid "Browse files"
msgstr "Door bestanden bladeren"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "door"
msgid "CI / CD"
msgstr "CI / CD"
-msgid "CI configuration"
-msgstr "CI Configuratie"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -384,12 +727,21 @@ msgstr ""
msgid "Cancel"
msgstr "Annuleren"
-msgid "Cancel edit"
-msgstr "Bewerken annuleren"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr ""
@@ -402,22 +754,55 @@ msgstr ""
msgid "ChangeTypeAction|Revert"
msgstr ""
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr ""
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "Grafieken"
msgid "Chat"
msgstr "Chat"
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr "Cherry-pick deze commit"
msgid "Cherry-pick this merge request"
msgstr ""
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -474,55 +859,145 @@ msgstr "overgeslagen"
msgid "CiStatus|running"
msgstr ""
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -534,43 +1009,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -582,16 +1159,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -603,18 +1201,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr "Opmerkingen"
@@ -623,8 +1236,8 @@ msgid_plural "Commits"
msgstr[0] ""
msgstr[1] ""
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgstr[1] ""
@@ -637,6 +1250,12 @@ msgstr ""
msgid "Commit message"
msgstr ""
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "Commit"
@@ -649,15 +1268,99 @@ msgstr "Commits"
msgid "Commits feed"
msgstr ""
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "Geschiedenis"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "Gecommit door"
msgid "Compare"
msgstr "Vergelijk"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -703,12 +1406,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr ""
msgid "Contributors"
msgstr ""
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -721,27 +1442,60 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr ""
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr ""
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr ""
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr ""
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr "Maak map aan"
-msgid "Create empty bare repository"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr ""
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -751,9 +1505,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr ""
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr ""
@@ -763,22 +1523,34 @@ msgstr ""
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr ""
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr ""
msgid "Cron syntax"
msgstr ""
+msgid "Current node"
+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"
+msgid "Customize colors"
msgstr ""
-msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgid "Cycle Analytics"
msgstr ""
msgid "CycleAnalyticsStage|Code"
@@ -808,6 +1580,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr ""
@@ -831,10 +1612,19 @@ msgstr ""
msgid "Details"
msgstr ""
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr ""
-msgid "Discard changes"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
@@ -843,9 +1633,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr ""
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr ""
@@ -873,15 +1669,171 @@ msgstr ""
msgid "DownloadSource|Download"
msgstr ""
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr ""
msgid "Edit Pipeline Schedule %{id}"
msgstr ""
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
@@ -909,24 +1861,75 @@ msgstr ""
msgid "Every week (Sundays at 4:00am)"
msgstr ""
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr ""
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr ""
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr ""
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr ""
@@ -936,12 +1939,21 @@ msgstr ""
msgid "Find file"
msgstr ""
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr ""
msgid "FirstPushedBy|pushed by"
msgstr ""
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] ""
@@ -953,25 +1965,178 @@ msgstr ""
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr ""
msgid "From merge request merge until deploy to production"
msgstr ""
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -980,12 +2145,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr ""
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr ""
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr ""
@@ -995,6 +2193,27 @@ msgstr ""
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1031,12 +2250,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1064,6 +2277,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr ""
@@ -1082,15 +2301,59 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr ""
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr ""
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1100,6 +2363,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
@@ -1108,6 +2374,15 @@ msgid_plural "Instances"
msgstr[0] ""
msgstr[1] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1123,9 +2398,6 @@ msgstr ""
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr ""
@@ -1138,15 +2410,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr ""
msgid "LFSStatus|Enabled"
msgstr ""
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] ""
@@ -1176,6 +2520,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr ""
@@ -1194,32 +2547,78 @@ msgstr ""
msgid "License"
msgstr ""
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] ""
-msgstr[1] ""
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr ""
msgid "Members"
msgstr ""
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1229,22 +2628,145 @@ msgstr ""
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr ""
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr ""
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1252,15 +2774,27 @@ msgid_plural "New Issues"
msgstr[0] "Nieuwe issue"
msgstr[1] "Nieuwe issues"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr ""
msgid "New branch"
msgstr ""
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr ""
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr ""
@@ -1270,6 +2804,9 @@ msgstr ""
msgid "New issue"
msgstr ""
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr ""
@@ -1288,7 +2825,28 @@ msgstr ""
msgid "New tag"
msgstr ""
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1300,12 +2858,39 @@ msgstr ""
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr ""
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr ""
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr ""
@@ -1363,18 +2948,48 @@ msgstr ""
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr ""
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Geopend"
@@ -1384,12 +2999,21 @@ msgstr ""
msgid "Options"
msgstr ""
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr ""
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1402,10 +3026,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1450,12 +3083,6 @@ msgstr ""
msgid "PipelineSchedules|Inactive"
msgstr ""
-msgid "PipelineSchedules|Input variable key"
-msgstr ""
-
-msgid "PipelineSchedules|Input variable value"
-msgstr ""
-
msgid "PipelineSchedules|Next Run"
msgstr ""
@@ -1465,9 +3092,6 @@ msgstr ""
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr ""
-msgid "PipelineSchedules|Remove variable row"
-msgstr ""
-
msgid "PipelineSchedules|Take ownership"
msgstr ""
@@ -1495,6 +3119,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr ""
@@ -1507,15 +3182,39 @@ msgstr ""
msgid "Pipeline|with stages"
msgstr ""
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1555,6 +3254,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1570,6 +3275,12 @@ msgstr ""
msgid "Project access must be granted explicitly to each user."
msgstr ""
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr ""
@@ -1588,6 +3299,21 @@ msgstr ""
msgid "ProjectActivityRSS|Subscribe"
msgstr ""
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr ""
@@ -1651,6 +3377,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1663,21 +3476,39 @@ msgstr ""
msgid "Push events"
msgstr ""
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr ""
msgid "Readme"
msgstr ""
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr ""
msgid "RefSwitcher|Tags"
msgstr ""
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1699,15 +3530,42 @@ msgstr ""
msgid "Related Merged Requests"
msgstr ""
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr ""
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr ""
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr ""
@@ -1720,16 +3578,54 @@ msgstr ""
msgid "Reset runners registration token"
msgstr ""
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Revert this commit"
msgstr ""
msgid "Revert this merge request"
msgstr ""
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
msgstr ""
-msgid "Save"
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1738,49 +3634,115 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr ""
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr ""
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr ""
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr ""
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr ""
msgid "Select a timezone"
msgstr ""
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr ""
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr ""
-msgid "Set up CI"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
msgstr ""
msgid "Set up Koding"
msgstr ""
-msgid "Set up auto deploy"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
msgstr ""
msgid "SetPasswordToCloneLink|set a password"
@@ -1789,6 +3751,24 @@ msgstr ""
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1800,13 +3780,46 @@ msgid_plural "Showing %d events"
msgstr[0] ""
msgstr[1] ""
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1815,6 +3828,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1914,18 +3930,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr ""
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr ""
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1935,29 +3969,119 @@ msgstr ""
msgid "Start the Runner!"
msgstr ""
-msgid "Subgroups"
+msgid "Started"
msgstr ""
-msgid "Subscribe"
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr ""
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
msgstr[0] ""
msgstr[1] ""
msgid "Tags"
msgstr ""
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr ""
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1967,7 +4091,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1976,30 +4106,39 @@ msgstr ""
msgid "The collection of events added to the data gathered for that stage."
msgstr ""
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr ""
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
-msgid "The phase of the development lifecycle."
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
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."
+msgid "The phase of the development lifecycle."
msgstr ""
msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
msgstr ""
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+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 ""
@@ -2012,9 +4151,18 @@ msgstr ""
msgid "The repository for this project does not exist."
msgstr ""
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 ""
@@ -2027,16 +4175,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+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 no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2045,18 +4226,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr ""
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr ""
@@ -2066,9 +4283,27 @@ msgstr ""
msgid "Time between merge request creation and merge/close"
msgstr ""
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr ""
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr ""
@@ -2205,25 +4440,97 @@ msgstr[1] ""
msgid "Time|s"
msgstr "s"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr ""
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2247,24 +4554,72 @@ msgstr ""
msgid "Upload file"
msgstr ""
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr ""
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr ""
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr ""
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr ""
@@ -2280,16 +4635,28 @@ msgstr ""
msgid "Want to see the data? Please ask an administrator for access."
msgstr ""
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr ""
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2310,6 +4677,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2400,39 +4773,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr ""
+msgid "Write a commit message..."
+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?"
+msgid "You are going to remove %{project_full_name}. 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?"
+msgid "You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr ""
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr ""
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr ""
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr ""
@@ -2457,6 +4860,36 @@ 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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2469,7 +4902,108 @@ msgstr ""
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
@@ -2477,12 +5011,243 @@ msgid_plural "days"
msgstr[0] ""
msgstr[1] ""
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr ""
msgid "notification emails"
msgstr ""
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] ""
@@ -2494,9 +5259,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/pl_PL/gitlab.po b/locale/pl_PL/gitlab.po
index c48909540b1..4ae57235f90 100644
--- a/locale/pl_PL/gitlab.po
+++ b/locale/pl_PL/gitlab.po
@@ -2,39 +2,83 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-20 11:16-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:36-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Polish\n"
"Language: pl_PL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"Plural-Forms: nplurals=4; plural=((n == 1) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || n%10 == 1 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 12 && n%100 <= 14)) ? 2 : 3));\n"
"X-Generator: crowdin.com\n"
"X-Crowdin-Project: gitlab-ee\n"
"X-Crowdin-Language: pl\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
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] ""
msgstr[2] ""
+msgstr[3] ""
-msgid "%{commit_author_link} committed %{commit_timeago}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
msgid "%{count} participant"
@@ -42,6 +86,13 @@ msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr ""
@@ -49,10 +100,10 @@ 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."
+msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr ""
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
+msgid "%{openOrClose} %{noteable}"
msgstr ""
msgid "%{storage_name}: failed storage access attempt on host:"
@@ -60,6 +111,10 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "%{text} is available"
+msgstr ""
msgid "(checkout the %{link} for information on how to install it)."
msgstr ""
@@ -75,6 +130,7 @@ msgid_plural "%d pipelines"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "1st contribution!"
msgstr ""
@@ -82,15 +138,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr ""
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr ""
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -100,6 +171,9 @@ msgstr ""
msgid "Account"
msgstr ""
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr ""
@@ -118,43 +192,208 @@ msgstr ""
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
msgid "Add License"
msgstr ""
-msgid "Add an SSH key to your profile to pull or push via SSH."
+msgid "Add Readme"
msgstr ""
msgid "Add new directory"
msgstr ""
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr ""
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr ""
msgid "Applications"
msgstr ""
-msgid "Archived project! Repository is read-only"
+msgid "Apr"
msgstr ""
-msgid "Are you sure you want to delete this pipeline schedule?"
+msgid "April"
msgstr ""
-msgid "Are you sure you want to discard your changes?"
+msgid "Archived project! Repository is read-only"
msgstr ""
-msgid "Are you sure you want to leave this group?"
+msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
msgid "Are you sure you want to reset registration token?"
@@ -163,28 +402,73 @@ msgstr ""
msgid "Are you sure you want to reset the health check token?"
msgstr ""
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr ""
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr ""
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
+msgstr ""
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -202,7 +486,31 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
@@ -259,11 +567,12 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
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 ""
@@ -271,6 +580,12 @@ msgstr ""
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr ""
@@ -280,6 +595,15 @@ msgstr ""
msgid "Branches"
msgstr ""
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -325,12 +649,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -376,13 +727,22 @@ msgstr ""
msgid "Browse files"
msgstr ""
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr ""
msgid "CI / CD"
msgstr ""
-msgid "CI configuration"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
msgstr ""
msgid "CICD|Jobs"
@@ -391,12 +751,21 @@ msgstr ""
msgid "Cancel"
msgstr ""
-msgid "Cancel edit"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr ""
@@ -409,22 +778,55 @@ msgstr ""
msgid "ChangeTypeAction|Revert"
msgstr ""
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr ""
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr ""
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr ""
msgid "Cherry-pick this merge request"
msgstr ""
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -481,55 +883,145 @@ msgstr ""
msgid "CiStatus|running"
msgstr ""
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Create on GKE"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -541,43 +1033,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -589,16 +1183,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|Token"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -610,18 +1225,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr ""
@@ -630,12 +1260,14 @@ msgid_plural "Commits"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "Commit Message"
msgstr ""
@@ -646,6 +1278,12 @@ msgstr ""
msgid "Commit message"
msgstr ""
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr ""
@@ -658,15 +1296,99 @@ msgstr ""
msgid "Commits feed"
msgstr ""
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr ""
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr ""
msgid "Compare"
msgstr ""
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -712,12 +1434,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr ""
msgid "Contributors"
msgstr ""
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -730,27 +1470,60 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr ""
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr ""
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr ""
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr ""
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr ""
-msgid "Create empty bare repository"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr ""
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -760,9 +1533,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr ""
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr ""
@@ -772,22 +1551,34 @@ msgstr ""
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr ""
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr ""
msgid "Cron syntax"
msgstr ""
+msgid "Current node"
+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"
+msgid "Customize colors"
msgstr ""
-msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgid "Cycle Analytics"
msgstr ""
msgid "CycleAnalyticsStage|Code"
@@ -817,6 +1608,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr ""
@@ -828,6 +1628,7 @@ msgid_plural "Deploys"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "Deploy Keys"
msgstr ""
@@ -841,10 +1642,19 @@ msgstr ""
msgid "Details"
msgstr ""
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr ""
-msgid "Discard changes"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
@@ -853,9 +1663,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr ""
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr ""
@@ -883,15 +1699,171 @@ msgstr ""
msgid "DownloadSource|Download"
msgstr ""
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr ""
msgid "Edit Pipeline Schedule %{id}"
msgstr ""
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
@@ -919,24 +1891,75 @@ msgstr ""
msgid "Every week (Sundays at 4:00am)"
msgstr ""
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr ""
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr ""
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr ""
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr ""
@@ -946,17 +1969,27 @@ msgstr ""
msgid "Find file"
msgstr ""
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr ""
msgid "FirstPushedBy|pushed by"
msgstr ""
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "ForkedFromProjectPath|Forked from"
msgstr ""
@@ -964,25 +1997,178 @@ msgstr ""
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr ""
msgid "From merge request merge until deploy to production"
msgstr ""
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -991,12 +2177,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr ""
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr ""
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr ""
@@ -1006,6 +2225,27 @@ msgstr ""
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1042,12 +2282,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1075,6 +2309,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr ""
@@ -1093,15 +2333,61 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr ""
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr ""
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1111,6 +2397,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr ""
@@ -1119,6 +2408,16 @@ msgid_plural "Instances"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1135,9 +2434,6 @@ msgstr ""
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr ""
@@ -1150,20 +2446,93 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr ""
msgid "LFSStatus|Enabled"
msgstr ""
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "Last Pipeline"
msgstr ""
@@ -1189,6 +2558,15 @@ msgstr ""
msgid "LastPushEvent|at"
msgstr ""
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr ""
@@ -1207,33 +2585,78 @@ msgstr ""
msgid "License"
msgstr ""
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] ""
-msgstr[1] ""
-msgstr[2] ""
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr ""
msgid "Members"
msgstr ""
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1243,22 +2666,145 @@ msgstr ""
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr ""
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr ""
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
@@ -1266,6 +2812,13 @@ msgid_plural "New Issues"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
msgid "New Pipeline Schedule"
msgstr ""
@@ -1273,9 +2826,15 @@ msgstr ""
msgid "New branch"
msgstr ""
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr ""
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr ""
@@ -1285,6 +2844,9 @@ msgstr ""
msgid "New issue"
msgstr ""
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr ""
@@ -1303,7 +2865,28 @@ msgstr ""
msgid "New tag"
msgstr ""
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1315,12 +2898,39 @@ msgstr ""
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr ""
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr ""
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr ""
@@ -1378,18 +2988,48 @@ msgstr ""
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr ""
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr ""
@@ -1399,12 +3039,21 @@ msgstr ""
msgid "Options"
msgstr ""
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr ""
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1417,10 +3066,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1465,12 +3123,6 @@ msgstr ""
msgid "PipelineSchedules|Inactive"
msgstr ""
-msgid "PipelineSchedules|Input variable key"
-msgstr ""
-
-msgid "PipelineSchedules|Input variable value"
-msgstr ""
-
msgid "PipelineSchedules|Next Run"
msgstr ""
@@ -1480,9 +3132,6 @@ msgstr ""
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr ""
-msgid "PipelineSchedules|Remove variable row"
-msgstr ""
-
msgid "PipelineSchedules|Take ownership"
msgstr ""
@@ -1510,6 +3159,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr ""
@@ -1522,15 +3222,39 @@ msgstr ""
msgid "Pipeline|with stages"
msgstr ""
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1570,6 +3294,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1585,6 +3315,12 @@ msgstr ""
msgid "Project access must be granted explicitly to each user."
msgstr ""
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr ""
@@ -1603,6 +3339,21 @@ msgstr ""
msgid "ProjectActivityRSS|Subscribe"
msgstr ""
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr ""
@@ -1666,6 +3417,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1678,21 +3516,39 @@ msgstr ""
msgid "Push events"
msgstr ""
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr ""
msgid "Readme"
msgstr ""
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr ""
msgid "RefSwitcher|Tags"
msgstr ""
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1714,15 +3570,42 @@ msgstr ""
msgid "Related Merged Requests"
msgstr ""
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr ""
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr ""
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr ""
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr ""
@@ -1735,16 +3618,56 @@ msgstr ""
msgid "Reset runners registration token"
msgstr ""
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
msgid "Revert this commit"
msgstr ""
msgid "Revert this merge request"
msgstr ""
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
msgstr ""
-msgid "Save"
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1753,49 +3676,115 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr ""
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr ""
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr ""
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr ""
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr ""
msgid "Select a timezone"
msgstr ""
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr ""
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr ""
-msgid "Set up CI"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
msgstr ""
msgid "Set up Koding"
msgstr ""
-msgid "Set up auto deploy"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
msgstr ""
msgid "SetPasswordToCloneLink|set a password"
@@ -1804,6 +3793,24 @@ msgstr ""
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1815,14 +3822,48 @@ msgid_plural "Showing %d events"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1831,6 +3872,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1930,18 +3974,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr ""
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr ""
msgid "StarProject|Star"
msgstr ""
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1951,30 +4013,121 @@ msgstr ""
msgid "Start the Runner!"
msgstr ""
-msgid "Subgroups"
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
msgstr ""
-msgid "Subscribe"
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr ""
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "Tags"
msgstr ""
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr ""
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr ""
@@ -1984,7 +4137,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1993,30 +4152,39 @@ msgstr ""
msgid "The collection of events added to the data gathered for that stage."
msgstr ""
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr ""
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
-msgid "The phase of the development lifecycle."
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
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."
+msgid "The phase of the development lifecycle."
msgstr ""
msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
msgstr ""
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+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 ""
@@ -2029,9 +4197,18 @@ msgstr ""
msgid "The repository for this project does not exist."
msgstr ""
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 ""
@@ -2044,16 +4221,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+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 no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2062,18 +4272,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr ""
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr ""
@@ -2083,9 +4329,27 @@ msgstr ""
msgid "Time between merge request creation and merge/close"
msgstr ""
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr ""
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr ""
@@ -2214,35 +4478,109 @@ msgid_plural "Time|hrs"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "Time|min"
msgid_plural "Time|mins"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "Time|s"
msgstr ""
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr ""
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2266,24 +4604,72 @@ msgstr ""
msgid "Upload file"
msgstr ""
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr ""
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr ""
msgid "Use your global notification setting"
msgstr ""
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr ""
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr ""
@@ -2299,16 +4685,28 @@ msgstr ""
msgid "Want to see the data? Please ask an administrator for access."
msgstr ""
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr ""
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2329,6 +4727,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2419,39 +4823,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr ""
+msgid "Write a commit message..."
+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?"
+msgid "You are going to remove %{project_full_name}. 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?"
+msgid "You are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr ""
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr ""
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr ""
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr ""
@@ -2476,6 +4910,36 @@ 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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2488,7 +4952,110 @@ msgstr ""
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
@@ -2496,6 +5063,241 @@ msgid_plural "days"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
msgid "new merge request"
msgstr ""
@@ -2503,11 +5305,15 @@ msgstr ""
msgid "notification emails"
msgstr ""
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] ""
msgstr[1] ""
msgstr[2] ""
+msgstr[3] ""
msgid "password"
msgstr ""
@@ -2515,9 +5321,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/pt_BR/gitlab.po b/locale/pt_BR/gitlab.po
index 78e0967c3bc..96a59d6d0d3 100644
--- a/locale/pt_BR/gitlab.po
+++ b/locale/pt_BR/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-18 12:51-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:36-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Portuguese, Brazilian\n"
"Language: pt_BR\n"
@@ -16,46 +16,86 @@ msgstr ""
"X-Crowdin-Language: pt-BR\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr " e"
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d commit"
msgstr[1] "%d commits"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] "%d commit atrás"
+msgstr[1] "%d commits atrás"
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] "%d exporter"
+msgstr[1] "%d exporters"
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] "%d issue"
+msgstr[1] "%d issues"
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] "%d camada"
msgstr[1] "%d camadas"
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] "%d merge request"
+msgstr[1] "%d merge requests"
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] "%d métrica"
+msgstr[1] "%d métricas"
+
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 "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} fez commit %{commit_timeago}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr "%{actionText} & %{openOrClose} %{noteable}"
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr "%{commit_author_link} fez commit à %{commit_timeago}"
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] "%{count} participante"
msgstr[1] "%{count} participantes"
+msgid "%{loadingIcon} Started"
+msgstr "%{loadingIcon} Iniciado"
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr "%{lock_path} está bloqueado pelo usuário do GitLab %{lock_user_id}"
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr "%{number_commits_behind} commits atrás de %{default_branch}, %{number_commits_ahead} commits à frente"
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "%{number_of_failures} de %{maximum_failures} falhas. O GitLab permitirá o acesso na próxima tentativa."
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "%{number_of_failures} de %{maximum_failures} falhas. O GitLab irá bloquear o acesso por %{number_of_seconds} segundos."
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "%{number_of_failures} de %{maximum_failures} falhas. O GitLab não tentará mais automaticamente. Redefina as informações de storage quando o problema for resolvido."
+msgid "%{openOrClose} %{noteable}"
+msgstr "%{openOrClose} %{noteable}"
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name}: falha na tentativa de acesso ao storage no host:"
msgstr[1] "%{storage_name}: %{failed_attempts} falhas de acesso ao storage:"
+msgid "%{text} is available"
+msgstr "%{text} está disponível"
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(veja o %{link} para informações de como instalar)."
@@ -76,15 +116,30 @@ msgstr "1ª contribuição!"
msgid "2FA enabled"
msgstr "Autenticação de 2 passos ativada"
+msgid "<strong>Removes</strong> source branch"
+msgstr "<strong>Remover</strong> branch de origem"
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Uma coleção de gráficos sobre Integração Contínua"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr "Um novo \"branch\" será criado no seu \"fork\" e um novo merge request será iniciado."
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr "Um projeto é onde você armazena seus arquivos (repositório), planeja seu trabalho (issues), e publica sua documentação (wiki), %{among_other_things_link}."
+
+msgid "A user with write access to the source branch selected this option"
+msgstr "Um usuário com permissão de escrita no branch de origem selecionou esta opção"
+
msgid "About auto deploy"
msgstr "Sobre o deploy automático"
msgid "Abuse Reports"
msgstr "Relatórios de abuso"
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr "Tokens de acesso"
@@ -94,6 +149,9 @@ msgstr "Os acessos à storages com defeito foram temporariamente desabilitados p
msgid "Account"
msgstr "Conta"
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "Ativo"
@@ -110,77 +168,287 @@ msgid "Add Contribution guide"
msgstr "Adicionar Guia de contribuição"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
-msgstr "Adicione o grupo de Webhooks e GitLab Enterprise Edition."
+msgstr "Adicione o Webhooks de Grupos e GitLab Enterprise Edition."
+
+msgid "Add Kubernetes cluster"
+msgstr "Adicionar cluster Kubernetes"
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 Readme"
+msgstr "Adicionar leia-me"
msgid "Add new directory"
msgstr "Adicionar novo diretório"
+msgid "Add todo"
+msgstr "Adicionar tarefa"
+
+msgid "AdminArea|Stop all jobs"
+msgstr "Parar todos os processos"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "Parar todos os processos?"
+
+msgid "AdminArea|Stop jobs"
+msgstr "Parar processos"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr "Erro ao parar processos"
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr "Você parará todos os processos. Os processos em execução serão abruptamente interrompidos."
+
msgid "AdminHealthPageLink|health page"
msgstr "página de saúde"
+msgid "AdminProjects|Delete"
+msgstr "Excluir"
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr "Excluir o projeto %{projectName}?"
+
+msgid "AdminProjects|Delete project"
+msgstr "Excluir projeto"
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr "Especifique um domínio a ser usado por padrão para os estágios de Auto Review Application e Auto Deploy de cada projeto."
+
+msgid "AdminUsers|Block user"
+msgstr "Bloquear usuário"
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr "Excluir o usuário %{username} e suas contribuições?"
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr "Excluir o usuário %{username}?"
+
+msgid "AdminUsers|Delete user"
+msgstr "Apagar usuário"
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr "Excluir o usuário e suas contribuições"
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr "Para confirmar, digite %{projectName}"
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr "Para confirmar, digite %{username}"
+
+msgid "Advanced"
+msgstr "Avançado"
+
msgid "Advanced settings"
msgstr "Configurações avançadas"
msgid "All"
msgstr "Todos"
+msgid "All changes are committed"
+msgstr "Houve commit com todas as mudanças"
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr "Todas as funcionalidades estão habilitadas para projetos em branco, a partir de templates ou ao importar, mas você pode desativá-los posteriormente nas configurações do projeto."
+
+msgid "Allow edits from maintainers."
+msgstr "Permitir as edições dos mantenedores."
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr "Permite adicionar e gerenciar clusters do Kubernetes."
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr "Alternativamente, você pode usar um %{personal_access_token_link}. Quando você cria seu Token de Acesso Pessoal, você precisará selecionar o escopo do <code>repositório</code>, para que possamos exibir uma lista de seus repositórios públicos e privados que estão disponíveis para se conectar."
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr "Alternativamente, você pode usar um %{personal_access_token_link}. Quando você cria seu Token de Acesso Pessoal, você precisará selecionar o escopo do <code>repositório</code>, para que possamos exibir uma lista de seus repositórios públicos e privados que estão disponíveis para se importar."
+
+msgid "An error occurred previewing the blob"
+msgstr "Erro ao pré-visualizar o blob"
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "Erro ao modificar notificação de assinatura"
+
+msgid "An error occurred when updating the issue weight"
+msgstr "Um erro aconteceu ao atualizar o peso da issue"
+
+msgid "An error occurred while adding approver"
+msgstr "Erro ao adicionar o aprovador"
+
+msgid "An error occurred while detecting host keys"
+msgstr "Erro ao detectar a chave do host"
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr "Erro ao remover alerta. Atualize a página e tente novamente."
+
+msgid "An error occurred while fetching markdown preview"
+msgstr "Erro ao gerar pré-visualização do markdown"
+
+msgid "An error occurred while fetching sidebar data"
+msgstr "Erro ao recuperar informações da barra lateral"
+
+msgid "An error occurred while fetching the pipeline."
+msgstr "Erro ao recuperar informações da pipeline."
+
+msgid "An error occurred while getting projects"
+msgstr "Erro ao recuperar projetos"
+
+msgid "An error occurred while importing project"
+msgstr "Erro ao importar o projeto"
+
+msgid "An error occurred while initializing path locks"
+msgstr "Erro ao iniciar o bloqueio do Path"
+
+msgid "An error occurred while loading commits"
+msgstr "Erro ao carregar os Commits"
+
+msgid "An error occurred while loading diff"
+msgstr "Erro ao carregar o Diff"
+
+msgid "An error occurred while loading filenames"
+msgstr "Erro ao carregar nomes de arquivos"
+
+msgid "An error occurred while loading the file"
+msgstr "Erro ao carregar o arquivo"
+
+msgid "An error occurred while making the request."
+msgstr "Erro ao fazer a requisição."
+
+msgid "An error occurred while removing approver"
+msgstr "Erro ao remover o aprovador"
+
+msgid "An error occurred while rendering KaTeX"
+msgstr "Erro ao renderizar o KaTeX"
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr "Erro ao renderizar pré-visualização da mensagem de transmissão"
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr "Erro ao recuperar calendário de atividades"
+
+msgid "An error occurred while retrieving diff"
+msgstr "Erro ao recuperar o diff"
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr "Um erro ocorreu ao sobrescrever o status LDAP."
+
+msgid "An error occurred while saving assignees"
+msgstr "Erro ao salvar assignees"
+
+msgid "An error occurred while validating username"
+msgstr "Erro ao validar o nome de usuário"
+
msgid "An error occurred. Please try again."
msgstr "Ocorreu um erro. Tente novamente."
+msgid "Any Label"
+msgstr "Qualquer Label"
+
msgid "Appearance"
msgstr "Aparência"
msgid "Applications"
msgstr "Aplicações"
+msgid "Apr"
+msgstr "Abr"
+
+msgid "April"
+msgstr "Abril"
+
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 "Are you sure you want to discard your changes?"
-msgstr "Você tem certeza que deseja descartar suas alterações?"
-
-msgid "Are you sure you want to leave this group?"
-msgstr "Tem certeza que quer sair desse grupo?"
-
msgid "Are you sure you want to reset registration token?"
msgstr "Você tem certeza que quer recriar o token de registro?"
msgid "Are you sure you want to reset the health check token?"
msgstr "Você tem certeza que quer reiniciar o token de status de saúde?"
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr "Tem certeza que deseja desbloquear %{path_lock_path}?"
+
msgid "Are you sure?"
msgstr "Você tem certeza?"
msgid "Artifacts"
msgstr "Artefatos"
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr "Coloque uma cor personalizada, como #FF0000"
+
+msgid "Assign labels"
+msgstr "Atribuir labels"
+
+msgid "Assign milestone"
+msgstr "Atribuir milestone"
+
+msgid "Assign to"
+msgstr "Atribuir à"
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr "Responsável"
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Para anexar arquivo, arraste e solte ou %{upload_link}"
+msgid "Aug"
+msgstr "Ago"
+
+msgid "August"
+msgstr "Agosto"
+
msgid "Authentication Log"
msgstr "Log de autenticação"
msgid "Author"
msgstr "Autor"
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
-msgstr "Apps de revisão automática e Auto Deploy precisam de um nome de domínio e o %{kubernetes} para que funcione corretamente."
+msgid "Authors: %{authors}"
+msgstr "Autores: %{authors}"
+
+msgid "Auto DevOps enabled"
+msgstr "Auto DevOps ativo"
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr "Apps de revisão e deploy automáticos precisam de um %{kubernetes} para funcionar corretamente."
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr "Apps de revisão e deploy automáticos precisam de um nome de domínio e um %{kubernetes} para funcionar corretamente."
msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr "Apps de revisão automática e Auto Deploy precisam de um nome de domínio para que funcione corretamente."
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
-msgstr "Apps de revisão automática e Auto Deploy precisam do %{kubernetes} para que funcione corretamente."
-
msgid "AutoDevOps|Auto DevOps (Beta)"
msgstr "Auto DevOps (Beta)"
@@ -196,8 +464,32 @@ msgstr "Ele gerará a build, testará e fará deploy de sua aplicação automati
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr "Saiba mais em %{link_to_documentation}"
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
-msgstr "Você pode ativar %{link_to_settings} para esse projeto."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr "Você pode automaticamente construir e testar sua aplicação, se você %{link_to_auto_devops_settings} para este projeto. Você pode também fazer o deploy automaticamente, se você %{link_to_add_kubernetes_cluster}."
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr "adicionar um cluster Kubernetes"
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr "ativar auto DevOps (Beta)"
+
+msgid "Available"
+msgstr "Disponível"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr "Foto de perfil será removida. Tem certeza?"
+
+msgid "Average per day: %{average}"
+msgstr "Média diária: %{average}"
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr "Comece com o commit selecionado"
msgid "Billing"
msgstr "Cobrança"
@@ -218,28 +510,28 @@ msgid "BillingPlans|Downgrade"
msgstr "Downgrade"
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
-msgstr "Aprenda mais sobre cada plano lendo nossa %{faq_link}."
+msgstr "Saiba mais sobre cada plano lendo nossa %{faq_link}."
msgid "BillingPlans|Manage plan"
msgstr "Gerenciar plano"
msgid "BillingPlans|Please contact %{customer_support_link} in that case."
-msgstr "Por favor contacte o %{customer_support_link} para resolver seu caso."
+msgstr "Por favor, entre em contato com %{customer_support_link} para resolver seu caso."
msgid "BillingPlans|See all %{plan_name} features"
-msgstr "Veja todas as funcionalidades do seu plano %{plan_name}"
+msgstr "Veja todas as funcionalidades de %{plan_name}"
msgid "BillingPlans|This group uses the plan associated with its parent group."
msgstr "Esse grupo utiliza o plano associado ao seu grupo pai."
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
-msgstr "Para gerenciar o plano para esse grupo, visite a sessão de cobrança de %{parent_billing_page_link}."
+msgstr "Para gerenciar o plano desse grupo, visite a sessão de cobrança de %{parent_billing_page_link}."
msgid "BillingPlans|Upgrade"
-msgstr "Atualizar"
+msgstr "Upgrade"
msgid "BillingPlans|You are currently on the %{plan_link} plan."
-msgstr "Vocês está utilizando o plano %{plan_link}."
+msgstr "Você está atualmente no plano %{plan_link}."
msgid "BillingPlans|frequently asked questions"
msgstr "perguntas frequentes"
@@ -253,10 +545,10 @@ msgstr "pago %{price_per_year} anualmente"
msgid "BillingPlans|per user"
msgstr "por usuário"
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Branch"
-msgstr[1] "Branches"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+msgstr[0] "Branch (%{branch_count})"
+msgstr[1] "Branches (%{branch_count})"
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}"
@@ -264,6 +556,12 @@ msgstr "O branch <strong>%{branch_name}</strong> foi criado. Para configurar o d
msgid "Branch has changed"
msgstr "Branch foi alterado"
+msgid "Branch is already taken"
+msgstr "Branch já utilizada"
+
+msgid "Branch name"
+msgstr "Nome da branch"
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "Procurar por branches"
@@ -273,6 +571,15 @@ msgstr "Mudar de branch"
msgid "Branches"
msgstr "Branches"
+msgid "Branches|Active"
+msgstr "Ativos"
+
+msgid "Branches|Active branches"
+msgstr "Branches ativos"
+
+msgid "Branches|All"
+msgstr "Todos"
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr "Não foi possível encontrar o commit HEAD para essa branch"
@@ -318,14 +625,41 @@ msgstr "Uma vez que você confirmar e pressionar %{delete_protected_branch}, nã
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr "Somente alguém master ou dono do projeto poderá apagar branches protegidas"
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
-msgstr "Ramos protegidos podem ser gerenciados em %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr "Visão Geral"
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr "Branches protegidas podem ser gerenciadas em %{project_settings_link}."
+
+msgid "Branches|Show active branches"
+msgstr "Mostrar branches ativas"
+
+msgid "Branches|Show all branches"
+msgstr "Mostrar todas as branches"
+
+msgid "Branches|Show more active branches"
+msgstr "Mostrar as branches mais ativas"
+
+msgid "Branches|Show more stale branches"
+msgstr "Mostrar os branches obsoletos"
+
+msgid "Branches|Show overview of the branches"
+msgstr "Mostrar visão geral dos branches"
+
+msgid "Branches|Show stale branches"
+msgstr "Mostrar branches obsoletos"
msgid "Branches|Sort by"
msgstr "Ordernar por"
+msgid "Branches|Stale"
+msgstr "Obsoleto"
+
+msgid "Branches|Stale branches"
+msgstr "Branches obsoletos"
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
-msgstr "A branch não pode ser atualizada automaticamente porque diverge do seu upstream."
+msgstr "O branch não pode ser atualizado automaticamente porque diverge do seu upstream."
msgid "Branches|The default branch cannot be deleted"
msgstr "A branch padrão não pode ser apagada"
@@ -369,14 +703,23 @@ msgstr "Acessar arquivos"
msgid "Browse files"
msgstr "Navegar pelos arquivos"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "por"
msgid "CI / CD"
msgstr "CI / CD"
-msgid "CI configuration"
-msgstr "Configuração da IC"
+msgid "CI/CD"
+msgstr "CI/CD"
+
+msgid "CI/CD configuration"
+msgstr "Configuração de CI/CD"
+
+msgid "CI/CD for external repo"
+msgstr "CI/CD para um repositório externo"
msgid "CICD|Jobs"
msgstr "Jobs"
@@ -384,11 +727,20 @@ msgstr "Jobs"
msgid "Cancel"
msgstr "Cancelar"
-msgid "Cancel edit"
-msgstr "Cancelar edição"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr "Não se pode modificar um cluster Kubernetes gerenciado"
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
-msgstr "Mudar peso"
+msgstr "Alterar peso"
+
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "Pick para um branch"
@@ -402,23 +754,56 @@ msgstr "Cherry-pick"
msgid "ChangeTypeAction|Revert"
msgstr "Reverter"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr "Isso criará um novo commit para reverter as mudanças existentes."
+
msgid "Changelog"
msgstr "Registro de mudanças"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr "Mudanças serão mostradas se revisão de <b>origem</b> tiver sofrido merge na revisão <b>alvo</b>."
+
msgid "Charts"
msgstr "Gráficos"
msgid "Chat"
msgstr "Bate-papo"
+msgid "Check interval"
+msgstr "Intervalo de verificação"
+
+msgid "Checking %{text} availability…"
+msgstr "Verificando disponibilidade de %{text}…"
+
+msgid "Checking branch availability..."
+msgstr "Verificando disponibilidade de branch..."
+
msgid "Cherry-pick this commit"
msgstr "Cherry-pick esse commit"
msgid "Cherry-pick this merge request"
msgstr "Cherry-pick esse merge request"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
-msgstr "Escolha quais os grupos que você deseja replicar para este nó secundário. Deixe em branco para replicar todos."
+msgid "Choose File ..."
+msgstr "Escolha o arquivo ..."
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr "Escolha a branch/tag (ex: %{master}) ou número do commit (ex: %{sha}) para ver o que mudou ou para criar um merge request."
+
+msgid "Choose file..."
+msgstr "Escolha o arquivo..."
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr "Escolha quais grupos você deseja sincronizar nesse nó secundário."
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr "Escolha quais repositórios você deseja se conectar e executar CI/CD pipeline."
+
+msgid "Choose which repositories you want to import."
+msgstr "Escolha quais repositórios você deseja importar."
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+msgstr "Escolha quais shards você deseja que sincronizem com esse nó secundário."
msgid "CiStatusLabel|canceled"
msgstr "cancelado"
@@ -474,56 +859,146 @@ msgstr "ignorado"
msgid "CiStatus|running"
msgstr "executando"
+msgid "CiVariables|Input variable key"
+msgstr "Digite o nome da variável"
+
+msgid "CiVariables|Input variable value"
+msgstr "Digite o valor da variável"
+
+msgid "CiVariables|Remove variable row"
+msgstr "Remover a variável"
+
+msgid "CiVariable|* (All environments)"
+msgstr "* (Todos os ambientes)"
+
+msgid "CiVariable|All environments"
+msgstr "Todos os ambientes"
+
+msgid "CiVariable|Create wildcard"
+msgstr "Criar um curinga"
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr "Erro ao salvar variáveis"
+
+msgid "CiVariable|New environment"
+msgstr "Novo ambiente"
+
+msgid "CiVariable|Protected"
+msgstr "Protegido"
+
+msgid "CiVariable|Search environments"
+msgstr "Procurar ambientes"
+
+msgid "CiVariable|Toggle protected"
+msgstr "Alternar proteção"
+
+msgid "CiVariable|Validation failed"
+msgstr "Falha na validação"
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr "interruptor da api"
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr "Clique no botão abaixo para iniciar o processo de instalação navegando para a página do Kubernetes"
+
+msgid "Click to expand text"
+msgstr "Cliquei pra expandir o texto"
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr "Clonar repositório"
msgid "Close"
msgstr "Fechar"
-msgid "Cluster"
-msgstr "Cluster"
+msgid "Closed"
+msgstr "Fechado"
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
-msgstr "Um %{link_to_container_project} deve ter sido criado com essa conta"
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr "%{appList} foi instalado com sucesso no seu cluster Kubernetes"
-msgid "ClusterIntegration|Cluster details"
-msgstr "Detalhes do cluster"
+msgid "ClusterIntegration|API URL"
+msgstr "API URL"
-msgid "ClusterIntegration|Cluster integration"
-msgstr "Integração do cluster"
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr "Adicionar cluster Kubernetes"
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
-msgstr "Integração do cluster está desabilitada para esse projeto."
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr "Adicionar um cluster Kubernetes existente"
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
-msgstr "Integração do cluster está ativada nesse projeto."
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr "Opções avançadas na integração deste cluster Kubernetes"
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
-msgstr "Integração do cluster está ativada para esse projeto. Desabilitar a integração não afetará seu cluster, mas desligará temporariamente a conexão do Gitlab com ele."
+msgid "ClusterIntegration|Applications"
+msgstr "Aplicações"
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
-msgstr "O cluster está sendo criado no Google Kubernetes Engine..."
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr "Tem certeza de que deseja remover a integração deste cluster do Kubernetes? Isso não excluirá o seu cluster atual do Kubernetes."
-msgid "ClusterIntegration|Cluster name"
-msgstr "Nome do cluster"
+msgid "ClusterIntegration|CA Certificate"
+msgstr "Certificado CA"
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
-msgstr "O cluster foi criado com sucesso no Google Kubernetes Engine"
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr "Pacote de autoridade certificadora (Formato PEM)"
-msgid "ClusterIntegration|Copy cluster name"
-msgstr "Copiar nome do cluster"
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr "Escolha como configurar a integração do cluster Kubernetes"
-msgid "ClusterIntegration|Create cluster"
-msgstr "Criar cluster"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr "Escolha qual dos ambientes do seu projeto usará este cluster Kubernetes."
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
-msgstr "Criar novo cluster no Google Kubernetes Engine"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Copy API URL"
+msgstr "Copiar URL da API"
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr "Copiar certificado CA"
-msgid "ClusterIntegration|Enable cluster integration"
-msgstr "Ativar integração com o cluster"
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Token"
+msgstr "Copiar token"
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr "Criar no GKE"
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr "Insira os detalhes para o cluster Kubernetes existente"
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr "Gitlab Runner"
msgid "ClusterIntegration|Google Cloud Platform project ID"
msgstr "ID do projeto no Google Cloud Platform"
@@ -532,46 +1007,148 @@ msgid "ClusterIntegration|Google Kubernetes Engine"
msgstr "Google Kubernetes Engine"
msgid "ClusterIntegration|Google Kubernetes Engine project"
-msgstr "Projeto no Google Kubernetes Engine"
+msgstr "Projeto do Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Helm Tiller"
+msgstr "Helm Tiller"
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr "Ingressar"
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr "Instalar"
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr "Instalado"
+
+msgid "ClusterIntegration|Installing"
+msgstr "Instalando"
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr "Leia mais sobre %{link_to_documentation}"
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr "Tipo de máquina"
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
-msgstr "Confira se sua conta %{link_to_requirements} para criar clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
-msgstr "Gerenciar integração de cluster com o projeto no GitLab"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
-msgstr "Gerencie seu cluster visitando %{link_gke}"
+msgid "ClusterIntegration|More information"
+msgstr "Mais informações"
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr "Multiplos clusters Kubernetes estão disponíveis no GitLab Enterprise Edition Premium e Ultimate"
+
+msgid "ClusterIntegration|Note:"
+msgstr "Nota:"
msgid "ClusterIntegration|Number of nodes"
msgstr "Número de nós"
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr "Por favor, tenha certeza que sua conta no Google cumpre com os requisitos:"
+msgid "ClusterIntegration|Project ID"
+msgstr "ID do projeto"
+
+msgid "ClusterIntegration|Project namespace"
+msgstr "Namespace do projeto"
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr "Namespace do projeto (opcional, único)"
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr "Ler nossa %{link_to_help_page} na integração com cluster."
+msgid "ClusterIntegration|Prometheus"
+msgstr "Prometheus"
-msgid "ClusterIntegration|Remove cluster integration"
-msgstr "Remover integração com cluster"
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr "Remover integração"
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
-msgstr "Remover integração com o cluster irá apagar a configuração de cluster que você adicionou à esse projeto. Não excluirá seu projeto."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr "Solicitação para início de instalação falhou"
-msgid "ClusterIntegration|See and edit the details for your cluster"
-msgstr "Ver e editar os detalhes para seu cluster"
+msgid "ClusterIntegration|Save changes"
+msgstr "Salvar alterações"
+
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr "Veja e edite os detalhes de seus cluster Kubernates"
msgid "ClusterIntegration|See machine types"
msgstr "Ver tipos de máquina"
@@ -582,39 +1159,75 @@ msgstr "Ver seus projetos"
msgid "ClusterIntegration|See zones"
msgstr "Ver zonas"
+msgid "ClusterIntegration|Service token"
+msgstr "Token de serviço"
+
+msgid "ClusterIntegration|Show"
+msgstr "Mostrar"
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr "Alguma coisa deu errado do nosso lado."
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
-msgstr "Algo deu errado ao criar seu cluster no Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr "Erro ao criar cluster Kubernetes no Google Kubernetes Engine"
-msgid "ClusterIntegration|Toggle Cluster"
-msgstr "Alternar cluster"
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr "Algo deu errado ao instalar %{title}"
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
-msgstr "Com um cluster associado à esse projeto, você pode usar revisão de apps, fazer deploy de suas aplicações, rodar suas pipelines e muito mais de um jeito simples."
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr "Token"
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
-msgstr "Sua conta precisa ter %{link_to_kubernetes_engine}"
+msgstr "Sua conta precisa de %{link_to_kubernetes_engine}"
msgid "ClusterIntegration|Zone"
msgstr "Zona"
msgid "ClusterIntegration|access to Google Kubernetes Engine"
-msgstr "Acesso ao Google Kubernetes Engine"
+msgstr "acesso ao Google Container Engine"
-msgid "ClusterIntegration|cluster"
-msgstr "cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
+msgstr "documentação"
msgid "ClusterIntegration|help page"
msgstr "ajuda"
+msgid "ClusterIntegration|installing applications"
+msgstr "Instalando aplicações"
+
msgid "ClusterIntegration|meets the requirements"
msgstr "atende aos requisitos"
msgid "ClusterIntegration|properly configured"
msgstr "configurado corretamente"
+msgid "Collapse"
+msgstr "Recolher"
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr "Comente e marque discussão como não resolvida"
+
msgid "Comments"
msgstr "Comentários"
@@ -623,10 +1236,10 @@ msgid_plural "Commits"
msgstr[0] "Commit"
msgstr[1] "Commits"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
-msgstr[0] "Commit %d arquivo"
-msgstr[1] "Commit %d arquivos"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] "Commit (%{commit_count})"
+msgstr[1] "Commits (%{commit_count})"
msgid "Commit Message"
msgstr "Mensagem de Commit"
@@ -637,6 +1250,12 @@ msgstr "Duração do commit em minutos para os últimos 30 commits"
msgid "Commit message"
msgstr "Mensagem de commit"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr "Estatísticas de commits para %{ref} %{start_time} - %{end_time}"
+
+msgid "Commit to %{branchName} branch"
+msgstr "Commit para a branch %{branchName}"
+
msgid "CommitBoxTitle|Commit"
msgstr "Commit"
@@ -649,15 +1268,99 @@ msgstr "Commits"
msgid "Commits feed"
msgstr "Feed de commits"
+msgid "Commits per day hour (UTC)"
+msgstr "Commits por hora (UTC)"
+
+msgid "Commits per day of month"
+msgstr "Commits por dia do mês"
+
+msgid "Commits per weekday"
+msgstr "Commits por dia da semana"
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr "Erro ao recuperar dados do merge request."
+
+msgid "Commits|Commit: %{commitText}"
+msgstr "Commit: %{commitText}"
+
msgid "Commits|History"
msgstr "Histórico"
+msgid "Commits|No related merge requests found"
+msgstr "Nenhum merge request relacionado foi encontrado"
+
msgid "Committed by"
msgstr "Commit feito por"
msgid "Compare"
msgstr "Comparar"
+msgid "Compare Git revisions"
+msgstr "Parar versões do Git"
+
+msgid "Compare Revisions"
+msgstr "Comparar revisões"
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr "%{source_branch} e %{target_branch} são o mesmo."
+
+msgid "CompareBranches|Compare"
+msgstr "Comparar"
+
+msgid "CompareBranches|Source"
+msgstr "Origem"
+
+msgid "CompareBranches|Target"
+msgstr "Alvo"
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr "Não há nada para comparar."
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr "Confidencialidade"
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr "Conectar"
+
+msgid "Connect all repositories"
+msgstr "Conectar todos repositórios"
+
+msgid "Connect repositories from GitHub"
+msgstr "Conectar repositórios do GitHub"
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr "Conectando..."
+
msgid "Container Registry"
msgstr "Container Registry"
@@ -703,45 +1406,96 @@ msgstr "Use nomes de imagem diferentes"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr "Com o Container Registry do Docker integrado ao Gitlab, todo projeto pode ter seu próprio espaço para guardar suas imagens."
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr "Contribuições"
+
msgid "Contribution guide"
msgstr "Guia de contribuição"
msgid "Contributors"
msgstr "Contribuidores"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr "%{startDate} - %{endDate}"
+
+msgid "ContributorsPage|Building repository graph."
+msgstr "Gerando gráfico do repositório."
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr "Commits à %{branch_name}, excluindo commits de merge. Limitado à 6000 commits."
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr "Por favor, espere um momento, essa página será atualizada automaticamente."
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
-msgstr "Controle a concorrência máxima de LFS/preenchimento de anexos para o nó secundário"
+msgstr "Controle a concorrência máxima de LFS/preenchimento de repositórios para esse nó secundário"
msgid "Control the maximum concurrency of repository backfill for this secondary node"
-msgstr "Controle a concorrência máxima de preenchimento de repositórios para o nó secundário"
+msgstr "Controle a concorrência máxima de preenchimento de repositório para esse nó secundário"
msgid "Copy SSH public key to clipboard"
-msgstr "Copiar chave SSH pública para área de transferência"
+msgstr "Copiar chave públic SSH para área de transferência"
msgid "Copy URL to clipboard"
msgstr "Copiar URL para área de transferência"
+msgid "Copy branch name to clipboard"
+msgstr "Copiar nome do branch para área de transferência"
+
+msgid "Copy command to clipboard"
+msgstr "Copiar o comando para área de transferência"
+
msgid "Copy commit SHA to clipboard"
msgstr "Copiar SHA do commit para a área de transferência"
+msgid "Copy reference to clipboard"
+msgstr "Copiar referência para área de transferência"
+
+msgid "Create"
+msgstr "Criar"
+
msgid "Create New Directory"
msgstr "Criar Novo Diretório"
+msgid "Create a new branch"
+msgstr "Criar uma nova branch"
+
+msgid "Create a new branch and merge request"
+msgstr "Criar um novo branch e abrir merge request"
+
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 branch"
+msgstr "Criar a branch"
+
msgid "Create directory"
msgstr "Criar diretório"
-msgid "Create empty bare repository"
-msgstr "Criar repositório bruto vazio"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr "Criar épico"
msgid "Create file"
msgstr "Criar arquivo"
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr "Criar lista a partir de labels. Issues com labels aparecem nestas listas."
+
msgid "Create merge request"
msgstr "Criar merge request"
+msgid "Create merge request and branch"
+msgstr "Abrir merge request e criar branch"
+
msgid "Create new branch"
msgstr "Criar novo branch"
@@ -751,9 +1505,15 @@ msgstr "Criar nova pasta"
msgid "Create new file"
msgstr "Criar novo arquivo"
+msgid "Create new label"
+msgstr "Criar nova label"
+
msgid "Create new..."
msgstr "Criar novo..."
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "Fork"
@@ -763,24 +1523,36 @@ msgstr "Tag"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "criar um token de acesso pessoal"
+msgid "Creates a new branch from %{branchName}"
+msgstr "Cria um novo branch de %{branchName}"
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr "Criando épico"
+
msgid "Cron Timezone"
msgstr "Fuso horário do cron"
msgid "Cron syntax"
msgstr "Sintaxe do cron"
+msgid "Current node"
+msgstr "Nó atual"
+
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 "Customize colors"
+msgstr ""
+
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"
@@ -808,6 +1580,15 @@ msgstr "Todos"
msgid "DashboardProjects|Personal"
msgstr "Pessoal"
+msgid "Dec"
+msgstr "Dez"
+
+msgid "December"
+msgstr "Dezembro"
+
+msgid "Default classification label"
+msgstr "Label de classificação padrão"
+
msgid "Define a custom pattern with cron syntax"
msgstr "Defina um padrão personalizado utilizando a sintaxe do cron"
@@ -826,16 +1607,25 @@ msgid "Description"
msgstr "Descrição"
msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
-msgstr "Modelos de descrição permitem que você defina modelos de contextos específicos para issue e descrição de merge requests para seu projeto."
+msgstr "Modelos de descrição permitem que você defina modelos de contextos específicos para issue e campos de descrição de merge requests para seu projeto."
msgid "Details"
msgstr "Detalhes"
+msgid "Diffs|No file name available"
+msgstr "Nenhum nome de arquivo disponível"
+
msgid "Directory name"
msgstr "Nome do diretório"
-msgid "Discard changes"
-msgstr "Descartar alterações"
+msgid "Disable"
+msgstr "Desabilitar"
+
+msgid "Discard draft"
+msgstr "Descartar rascunho"
+
+msgid "Discover GitLab Geo."
+msgstr "Descubra Gitlab Geo."
msgid "Dismiss Cycle Analytics introduction box"
msgstr "Ignorar introdução do Cycle Analytics"
@@ -843,9 +1633,15 @@ msgstr "Ignorar introdução do Cycle Analytics"
msgid "Dismiss Merge Request promotion"
msgstr "Ignorar anúncio do merge request"
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "Não exibir novamente"
+msgid "Done"
+msgstr "Pronto"
+
msgid "Download"
msgstr "Baixar"
@@ -873,15 +1669,171 @@ msgstr "Arquivo de texto com as mudanças"
msgid "DownloadSource|Download"
msgstr "Baixar"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr "Validade"
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Alterar"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Alterar Agendamento do Pipeline %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr "Alterar arquivos no editor e fazer commit das alterações aqui"
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr "Emails"
+msgid "Enable"
+msgstr "Ativar"
+
+msgid "Enable Auto DevOps"
+msgstr "Ativar Auto DevOps"
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr "Um erro ocorreu ao recuperar ambientes."
+
+msgid "Environments|An error occurred while making the request."
+msgstr "Um erro ocorreu ao fazer a requisição."
+
+msgid "Environments|Commit"
+msgstr "Commit"
+
+msgid "Environments|Deployment"
+msgstr "Deploy"
+
+msgid "Environments|Environment"
+msgstr "Ambiente"
+
+msgid "Environments|Environments"
+msgstr "Ambientes"
+
+msgid "Environments|Job"
+msgstr "Job"
+
+msgid "Environments|New environment"
+msgstr "Novo ambiente"
+
+msgid "Environments|No deployments yet"
+msgstr "Nenhum deploy"
+
+msgid "Environments|Open"
+msgstr "Abrir"
+
+msgid "Environments|Re-deploy"
+msgstr "Refazer"
+
+msgid "Environments|Read more about environments"
+msgstr "Ler mais sobre ambiente"
+
+msgid "Environments|Rollback"
+msgstr "Rollback"
+
+msgid "Environments|Show all"
+msgstr "Mostrar tudo"
+
+msgid "Environments|Updated"
+msgstr "Atualizado"
+
+msgid "Environments|You don't have any environments right now."
+msgstr "Você não tem nenhum ambiente."
+
+msgid "Epic will be removed! Are you sure?"
+msgstr "Épico será removido! Tem certeza?"
+
+msgid "Epics"
+msgstr "Épicos"
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "Epics permite que você gerencie seu portfólio de projetos de forma mais eficiente e com menos esforço"
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr "Erro ao verificar dados do branch. Favor tentar novamente."
+
+msgid "Error committing changes. Please try again."
+msgstr "Erro ao realizar o commit das alterações. Favor tentar novamente."
+
+msgid "Error creating epic"
+msgstr "Erro ao criar épico"
+
+msgid "Error fetching contributors data."
+msgstr "Erro ao recuperar informações de contribuintes."
+
+msgid "Error fetching labels."
+msgstr "Erro ao carregar labels."
+
+msgid "Error fetching network graph."
+msgstr "Erro ao recuperar gráfico de rede."
+
+msgid "Error fetching refs"
+msgstr "Erro ao recuperar refs"
+
+msgid "Error fetching usage ping data."
+msgstr "Erro ao recupera dados de ping."
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr "Erro ao alterar configuração de notificação de assinatura"
+
+msgid "Error saving label update."
+msgstr "Erro ao salvar alteração de label."
+
+msgid "Error updating status for all todos."
+msgstr "Erro ao atualizar status para todas as tarefas."
+
+msgid "Error updating todo status."
+msgstr "Erro ao atualizar status das tarefas."
+
msgid "EventFilterBy|Filter by all"
msgstr "EventFilterBy|Filtrar por tudo"
@@ -909,24 +1861,75 @@ 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 "Expand"
+msgstr "Expandir"
+
msgid "Explore projects"
msgstr "Explorar projetos"
msgid "Explore public groups"
msgstr "Explorar grupos públicos"
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr "O acesso a este projeto foi negado para autorização externa"
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr "Falha"
+
+msgid "Failed Jobs"
+msgstr "Jobs falharam"
+
msgid "Failed to change the owner"
msgstr "Erro ao alterar o proprietário"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "Erro ao excluir o agendamento do pipeline"
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr "Fev"
+
+msgid "February"
+msgstr "Fevereiro"
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr "Campos nessa página não são mais editáveis, você pode configurar"
+
msgid "File name"
msgstr "Nome do arquivo"
msgid "Files"
msgstr "Arquivos"
+msgid "Files (%{human_size})"
+msgstr "Arquivos (%{human_size})"
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "Filtrar por mensagem de commit"
@@ -936,12 +1939,21 @@ msgstr "Localizar por caminho"
msgid "Find file"
msgstr "Localizar arquivo"
+msgid "Finished"
+msgstr "Finalizado"
+
msgid "FirstPushedBy|First"
msgstr "Primeiro"
msgid "FirstPushedBy|pushed by"
msgstr "publicado por"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Fork"
@@ -953,39 +1965,225 @@ msgstr "Fork criado a partir de"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr "Fork a partir de %{project_name} (apagado)"
+msgid "Forking in progress"
+msgstr "Fork em andamento"
+
msgid "Format"
msgstr "Formato"
+msgid "From %{provider_title}"
+msgstr "De %{provider_title}"
+
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 "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr "Chaves GPG"
+msgid "Generate a default set of labels"
+msgstr "Gerar labels padrão"
+
msgid "Geo Nodes"
msgstr "Nós de geo"
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr "Nó está falhando ou quebrado."
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr "Nó está lento, sobrecarregado, ou acabou de recuperar após uma interrupção."
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr "Atraso na replicação do banco de dados:"
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr "Desabilitar um nó para o processo de sincronização. Você tem certeza?"
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr "Não corresponde á configuração de armazenamento primário"
+
+msgid "GeoNodes|Failed"
+msgstr "Falha"
+
+msgid "GeoNodes|Full"
+msgstr "Completo"
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr "Versão do GitLab não corresponde a versão do nó primário"
+
+msgid "GeoNodes|GitLab version:"
+msgstr "Versão do GitLab:"
+
+msgid "GeoNodes|Health status:"
+msgstr "Saúde dos serviços:"
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr "Último ID de evento processado pelo cursor:"
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr "Último ID de evento visto pelo primário:"
+
+msgid "GeoNodes|Loading nodes"
+msgstr "Carregando nós"
+
+msgid "GeoNodes|Local Attachments:"
+msgstr "Anexos locais:"
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr "Objetos LFS locais:"
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr "Artefatos de processos locais:"
+
+msgid "GeoNodes|New node"
+msgstr "Novo nó"
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr "Fora de sincronia"
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr "Wikis:"
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr "Você configurou Geo nodes usando uma conexão HTTP insegura. Recomendamos o uso de HTTPS."
+
+msgid "Geo|All projects"
+msgstr "Todos os projetos"
+
msgid "Geo|File sync capacity"
-msgstr "Capacidade de sincronização de arquivo"
+msgstr "Capacidade de sincronização de arquivos"
+
+msgid "Geo|Groups to synchronize"
+msgstr "Grupos para sincronizar"
+
+msgid "Geo|Projects in certain groups"
+msgstr "Projetos em certos grupos"
-msgid "Geo|Groups to replicate"
-msgstr "Grupos para replicar"
+msgid "Geo|Projects in certain storage shards"
+msgstr ""
msgid "Geo|Repository sync capacity"
-msgstr "Capacidade de sincronização de repositório"
+msgstr "Capacidade de sincronização do repositório"
msgid "Geo|Select groups to replicate."
msgstr "Selecione grupos para replicar."
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr "Revisão do Git"
+
msgid "Git storage health information has been reset"
msgstr "Informações sobre o status de saúde do storage Git foram reiniciadas"
+msgid "Git version"
+msgstr "Versão do Git"
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "Seção GitLab Runner"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr "Servidores Gitaly"
+
+msgid "Go back"
+msgstr "Voltar"
+
msgid "Go to your fork"
msgstr "Ir para seu fork"
@@ -995,6 +2193,27 @@ msgstr "Fork"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr "Autenticação do Google não está %{link_to_documentation}. Peça ao administrador do Gitlab se você deseja usar esse serviço."
+msgid "Got it!"
+msgstr "Entendi!"
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr "Bloquear compartilhamento de projetos do grupo %{group} com outros grupos"
@@ -1031,12 +2250,6 @@ msgstr "Nenhum grupo encontrado"
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "Você pode gerenciar permissões de membros e acesso do seu grupo para cada projeto no grupo."
-msgid "GroupsTreeRole|as"
-msgstr "como"
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr "Você tem certeza que deseja sair do grupo \"${this.group.fullName}\"?"
-
msgid "GroupsTree|Create a project in this group."
msgstr "Criar um projeto nesse grupo."
@@ -1064,6 +2277,12 @@ msgstr "Desculpe, nenhum grupo corresponde à sua pesquisa"
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr "Desculpe, nenhum grupo ou projeto correspondem à sua pesquisa"
+msgid "Have your users email"
+msgstr "E-mail para abertura de issues"
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "Status de Saúde"
@@ -1082,24 +2301,71 @@ msgstr "Nenhum problema de saúde detectado"
msgid "HealthCheck|Unhealthy"
msgstr "Não saudável"
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] "Ocultar valor"
+msgstr[1] "Ocultar valores"
+
msgid "History"
msgstr "Histórico"
msgid "Housekeeping successfully started"
msgstr "Manutenção iniciada com sucesso"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr "Importar"
+
+msgid "Import all repositories"
+msgstr "Importar todos repositórios"
+
+msgid "Import in progress"
+msgstr "Importação em andamento"
+
+msgid "Import repositories from GitHub"
+msgstr "Importar repositórios do GitHub"
+
msgid "Import repository"
msgstr "Importar repositório"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr "Melhorar issue boards com o GitLab Enterprise Edition."
msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
-msgstr "Melhore gestão das issues com os pesos no GitLab Enterprise Edition."
+msgstr "Melhore a gerência de issues com pesos no GitLab Enterprise Edition."
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr "Encontre o que precisa mais facilmente com a pesquisa global avançada com GitLab Enterprise Edition."
+msgid "Install Runner on Kubernetes"
+msgstr "Instalar Runner no Kubernates"
+
msgid "Install a Runner compatible with GitLab CI"
msgstr "Instalar um Runner compatível com o GitLab CI"
@@ -1108,6 +2374,15 @@ msgid_plural "Instances"
msgstr[0] "Instância"
msgstr[1] "Instâncias"
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr "A instância não suporta múltiplos clusters Kubernetes"
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr "Interno - O grupo e projetos internos podem ser visualizados por qualquer usuário autenticado."
@@ -1121,10 +2396,7 @@ msgid "Introducing Cycle Analytics"
msgstr "Apresentando a Análise de Ciclo"
msgid "Issue board focus mode"
-msgstr "Modo de foco no issue board"
-
-msgid "Issue boards with milestones"
-msgstr "Issue board com milestone"
+msgstr "Focus mode no issue board"
msgid "Issue events"
msgstr "Eventos de issue"
@@ -1138,15 +2410,87 @@ msgstr "Boards"
msgid "Issues"
msgstr "Issues"
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr "Jan"
+
+msgid "January"
+msgstr "Janeiro"
+
+msgid "Jobs"
+msgstr "Jobs"
+
+msgid "Jul"
+msgstr "Jul"
+
+msgid "July"
+msgstr "Julho"
+
+msgid "Jun"
+msgstr "Jun"
+
+msgid "June"
+msgstr "Junho"
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr "Kubernetes"
+
+msgid "Kubernetes Cluster"
+msgstr "Cluster Kubernetes"
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "Desabilitado"
msgid "LFSStatus|Enabled"
msgstr "Habilitado"
+msgid "Label"
+msgstr "Label"
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr "Etiquetas"
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "Último %d dia"
@@ -1176,6 +2520,15 @@ msgstr "Você fez o push para"
msgid "LastPushEvent|at"
msgstr "em"
+msgid "Learn more"
+msgstr "Saiba mais"
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "Saiba mais em"
@@ -1194,32 +2547,78 @@ msgstr "Sair do projeto"
msgid "License"
msgstr "Licença"
-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 "List"
+msgstr "Lista"
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr "Carregando IDE do GitLab..."
msgid "Lock"
msgstr "Bloquear"
+msgid "Lock %{issuableDisplayName}"
+msgstr "Bloquear %{issuableDisplayName}"
+
+msgid "Lock not found"
+msgstr "Bloqueio não encontrado"
+
msgid "Locked"
msgstr "Bloqueado"
msgid "Locked Files"
msgstr "Arquivos bloqueados"
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr "Entrar"
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr "Gerenciar etiquetas"
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr "Mar"
+
+msgid "March"
+msgstr "Março"
+
+msgid "Mark done"
+msgstr "Marcar como feito"
+
msgid "Maximum git storage failures"
msgstr "Máximo de falhas do git storage"
+msgid "May"
+msgstr "Mai"
+
msgid "Median"
msgstr "Mediana"
msgid "Members"
msgstr "Membros"
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr "Merge Requests"
@@ -1229,38 +2628,173 @@ msgstr "Eventos de merge"
msgid "Merge request"
msgstr "Merge requests"
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr "A tela de Merge request é um lugar para propor mudanças em um projeto e discutir essas mudanças com outros"
+
+msgid "Merged"
+msgstr "Merge realizado"
+
msgid "Messages"
msgstr "Mensagens"
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr "Milestone"
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "adicione uma chave SSH"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr "Monitoramento"
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr "Mais informações estão disponíveis|aqui"
+msgid "Move"
+msgstr "Mover"
+
+msgid "Move issue"
+msgstr "Mover issue"
+
msgid "Multiple issue boards"
msgstr "Múltiplos issue boards"
-msgid "New Cluster"
-msgstr "Novo cluster"
+msgid "Name new label"
+msgstr "Nome da nova label"
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "Nova Issue"
msgstr[1] "Novas Issues"
+msgid "New Kubernetes Cluster"
+msgstr "Novo cluster Kubernetes"
+
+msgid "New Kubernetes cluster"
+msgstr "Novo cluster Kubernetes"
+
msgid "New Pipeline Schedule"
msgstr "Novo Agendamento de Pipeline"
msgid "New branch"
msgstr "Novo branch"
+msgid "New branch unavailable"
+msgstr "Novo branch indisponível"
+
msgid "New directory"
msgstr "Novo diretório"
+msgid "New epic"
+msgstr "Novo épico"
+
msgid "New file"
msgstr "Novo arquivo"
@@ -1270,6 +2804,9 @@ msgstr "Novo grupo"
msgid "New issue"
msgstr "Nova issue"
+msgid "New label"
+msgstr "Nova label"
+
msgid "New merge request"
msgstr "Novo merge request"
@@ -1288,8 +2825,29 @@ msgstr "Novo subgrupo"
msgid "New tag"
msgstr "Nova tag"
-msgid "No container images stored for this project. Add one by following the instructions above."
-msgstr "Nenhuma imagem gravada para esse projeto. Adiciona uma com as instruções a seguir."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr "Sem responsável"
+
+msgid "No changes"
+msgstr "Sem alterarções"
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr "Nenhuma conexão pode ser feita para um servidor Gitaly, por favor check os logs!"
+
+msgid "No due date"
+msgstr "Sem validade"
+
+msgid "No estimate or time spent"
+msgstr "Sem estimativa de tempo gasto"
+
+msgid "No file chosen"
+msgstr "Nenhum arquivo escolhido"
+
+msgid "No labels created yet."
+msgstr ""
msgid "No repository"
msgstr "Nenhum repositório"
@@ -1300,12 +2858,39 @@ msgstr "Nenhum agendamento"
msgid "None"
msgstr "Nenhum"
+msgid "Not allowed to merge"
+msgstr "Merge não permitido"
+
msgid "Not available"
msgstr "Não disponível"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr "Não confidencial"
+
msgid "Not enough data"
msgstr "Dados insuficientes"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "Eventos de notificação"
@@ -1363,18 +2948,48 @@ msgstr "Observar"
msgid "Notifications"
msgstr "Notificações"
+msgid "Notifications off"
+msgstr "Notificações deligadas"
+
+msgid "Notifications on"
+msgstr "Notificações ligadas"
+
+msgid "Nov"
+msgstr "Nov"
+
+msgid "November"
+msgstr "Novembro"
+
msgid "Number of access attempts"
msgstr "Número de tentativas de acesso"
-msgid "Number of failures before backing off"
-msgstr "Número de falhas antes de reverter"
+msgid "OK"
+msgstr "OK"
+
+msgid "Oct"
+msgstr "Out"
+
+msgid "October"
+msgstr "Outubro"
msgid "OfSearchInADropdown|Filter"
msgstr "Filtrar"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr "Somente membros do projeto podem comentar."
+msgid "Open"
+msgstr "Abrir"
+
+msgid "Opened"
+msgstr "Aberto"
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Aberto"
@@ -1384,12 +2999,21 @@ msgstr "Abrir em nova janela"
msgid "Options"
msgstr "Opções"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr "Visão geral"
msgid "Owner"
msgstr "Proprietário"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr "Último >>"
@@ -1402,11 +3026,20 @@ msgstr "Anterior"
msgid "Pagination|« First"
msgstr "<< Primeiro"
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr "Senha"
-msgid "People without permission will never get a notification and won\\'t be able to comment."
-msgstr "Pessoas sem permissão nunca receberão uma notificação e não serão capazes de comentar."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr ""
msgid "Pipeline"
msgstr "Pipeline"
@@ -1450,12 +3083,6 @@ 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"
@@ -1465,9 +3092,6 @@ 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"
@@ -1495,6 +3119,57 @@ msgstr "Pipelines para a última semana"
msgid "Pipelines for last year"
msgstr "Pipelines para o último ano"
+msgid "Pipelines|Build with confidence"
+msgstr "Construa com confiança"
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr "Saiba como funcionam as pipelines"
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "todos"
@@ -1507,15 +3182,39 @@ msgstr "com etapa"
msgid "Pipeline|with stages"
msgstr "com etapas"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr "Iniciar"
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr "Por favor, <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener no referrer\">ative a cobrança para um de seus projetos para ser possível criar um cluster Kubernetes</a>, depois tente novamente."
+
+msgid "Please solve the reCAPTCHA"
+msgstr "Por favor, resolva o reCAPTCHA"
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr "Preferências"
+msgid "Primary"
+msgstr "Primário"
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr "Privado - O acesso ao projeto deve ser concedido explicitamente para cada usuário."
msgid "Private - The group and its projects can only be viewed by members."
msgstr "Privado - O grupo e seus projetos só podem ser vistos por seus membros."
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr "Perfil"
@@ -1555,6 +3254,12 @@ msgstr "Sua conta é atualmente proprietária dos seguintes grupos:"
msgid "Profiles|your account"
msgstr "sua conta"
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr "Linguagens de programação usadas nesse repositório"
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr "O projeto '%{project_name}' está sendo excluído."
@@ -1570,6 +3275,12 @@ msgstr "Projeto '%{project_name}' atualizado com sucesso."
msgid "Project access must be granted explicitly to each user."
msgstr "Acesso ao projeto deve ser concedido explicitamente para cada usuário."
+msgid "Project avatar"
+msgstr "Imagem do projeto"
+
+msgid "Project avatar in repository: %{link}"
+msgstr "Imagem do projeto no repositório: %{link}"
+
msgid "Project details"
msgstr "Detalhes do projeto"
@@ -1588,6 +3299,21 @@ msgstr "Exportação do projeto iniciada. Um link para baixá-la será enviado p
msgid "ProjectActivityRSS|Subscribe"
msgstr "Inscreva-se"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr "Permitido a criação de projetos"
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr "Proteção de criação de projeto padrão"
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr "Desenvolvedores + Masters"
+
+msgid "ProjectCreationLevel|Masters"
+msgstr "Masters"
+
+msgid "ProjectCreationLevel|No one"
+msgstr "Ninguém"
+
msgid "ProjectFeature|Disabled"
msgstr "Desabilitado"
@@ -1616,16 +3342,16 @@ msgid "ProjectSettings|Only signed commits can be pushed to this repository."
msgstr "Esse repositório só aceita push de commits assinados."
msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
-msgstr "Essa configuração está aplicada à nivel de servidor e pode ser sobrescrita por um adminstrador."
+msgstr "Essa configuração é aplicada em nível de servidor e pode ser sobrescrita por qualquer administrador."
msgid "ProjectSettings|This setting is applied on the server level but has been overridden for this project."
-msgstr "Essa configuração está aplicada à nivel de servidor mas pode ser sobrescrita para esse projeto."
+msgstr "Essa configuração está aplicada à nivel de servidor mas foi sobrescrita para esse projeto."
msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin."
-msgstr "Essa configuração será aplicada à todos os projetos, a não ser que sejam sobrescritos pelo administrador."
+msgstr "Essa configuração será aplicada à todos os projetos, a não ser que seja sobrescrita pelo administrador."
msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
-msgstr "Nesse repositório, usuários só podem fazer push de commits verificados pelos seus e-mails."
+msgstr "Usuários só podem fazer push de commits para esse repositório se os commits estiverem assinados com um de seus próprios e-mails verificados."
msgid "Projects"
msgstr "Projetos"
@@ -1651,6 +3377,93 @@ msgstr "Desculpe, nenhum projeto corresponde a sua pesquisa"
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr "Esta funcionalidade necessita de suporte à localStorage do navegador"
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr "Por padrão, Prometheus escuta em 'http://localhost:9090'. Não é recomendado mudar o endereço padrão e sua porta, porque pode conflitar com outros serviços que estão executando no sevidor do Gitlab."
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr "Encontrando e configurando métricas..."
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr "Métricas"
+
+msgid "PrometheusService|Missing environment variable"
+msgstr "Variável de ambiente ausente"
+
+msgid "PrometheusService|More information"
+msgstr "Mais informações"
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr "URL da API base do Prometheus. como http://prometheus.example.com/"
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr "Serviço de monitoramento de tempo-de-série"
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr "Dicas:"
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr "Público - O grupo e seus projetos podem ser visualizados por todos sem autenticação."
@@ -1663,23 +3476,41 @@ msgstr "Regras de push"
msgid "Push events"
msgstr "Eventos de push"
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr "Restrição de commit"
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr "Ações rápidas podem ser usadas nas descrições das issues e nas caixas de comentário."
+
msgid "Read more"
msgstr "Leia mais"
msgid "Readme"
msgstr "Leia-me"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "Branches"
msgid "RefSwitcher|Tags"
msgstr "Tags"
+msgid "Reference:"
+msgstr "Referência:"
+
+msgid "Register / Sign In"
+msgstr "Registrar/Login"
+
msgid "Registry"
-msgstr "Registry"
+msgstr "Registro"
msgid "Related Commits"
msgstr "Commits Relacionados"
@@ -1699,15 +3530,42 @@ msgstr "Merge Requests Relacionados"
msgid "Related Merged Requests"
msgstr "Merge Requests Relacionados"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "Lembrar mais tarde"
+msgid "Remove"
+msgstr "Remover"
+
+msgid "Remove avatar"
+msgstr "Remover imagem"
+
msgid "Remove project"
msgstr "Remover projeto"
+msgid "Repair authentication"
+msgstr "Reparar autenticação"
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr "Repositório"
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "Solicitar acesso"
@@ -1720,68 +3578,172 @@ msgstr "Recriar o token de status de saúde"
msgid "Reset runners registration token"
msgstr "Recriar o token de registro de runners"
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] "Mostrar valor"
+msgstr[1] "Mostrar valores"
+
msgid "Revert this commit"
msgstr "Reverter este commit"
msgid "Revert this merge request"
msgstr "Reverter esse merge request"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr "Executando"
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
msgid "SSH Keys"
msgstr "Chaves SSH"
-msgid "Save"
-msgstr "Salvar"
-
msgid "Save changes"
msgstr "Salvar alterações"
msgid "Save pipeline schedule"
msgstr "Salvar agendamento da pipeline"
+msgid "Save variables"
+msgstr "Salvar variáveis"
+
msgid "Schedule a new pipeline"
msgstr "Agendar nova pipeline"
+msgid "Scheduled"
+msgstr "Agendado"
+
msgid "Schedules"
msgstr "Agendamentos"
msgid "Scheduling Pipelines"
msgstr "Agendando pipelines"
+msgid "Scoped issue boards"
+msgstr "Issue board de escopo"
+
+msgid "Search"
+msgstr "Pesquisar"
+
msgid "Search branches and tags"
msgstr "Procurar branch e tags"
+msgid "Search milestones"
+msgstr "Pesquisar milestones"
+
+msgid "Search project"
+msgstr "Procurar projeto"
+
+msgid "Search users"
+msgstr "Procurar usuários"
+
msgid "Seconds before reseting failure information"
msgstr "Segundos antes de redefinir as informações de falha"
-msgid "Seconds to wait after a storage failure"
-msgstr "Segundos a esperar após uma falha de armazenamento"
-
msgid "Seconds to wait for a storage access attempt"
msgstr "Segundo de espera para tentativa de acesso ao storage"
+msgid "Secret variables"
+msgstr "Variáveis secretas"
+
+msgid "Security report"
+msgstr "Relatório de segurança"
+
msgid "Select Archive Format"
msgstr "Selecionar Formato do Arquivo"
msgid "Select a timezone"
msgstr "Selecionar fuso horário"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr "Selecione o responsável"
+
+msgid "Select branch/tag"
+msgstr "Selecionar o branch/tag"
+
msgid "Select target branch"
msgstr "Selecionar branch de destino"
+msgid "Selective synchronization"
+msgstr "Sincronização seletiva"
+
+msgid "Send email"
+msgstr "Enviar e-mail"
+
+msgid "Sep"
+msgstr "Set"
+
+msgid "September"
+msgstr "Setembro"
+
+msgid "Server version"
+msgstr "Versão do servidor"
+
msgid "Service Templates"
msgstr "Modelos de serviço"
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
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 default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr "Configurar CI/CD"
msgid "Set up Koding"
msgstr "Configurar Koding"
-msgid "Set up auto deploy"
-msgstr "Configurar implantação automática"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "defina uma senha"
@@ -1789,6 +3751,24 @@ msgstr "defina uma senha"
msgid "Settings"
msgstr "Configurações"
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr "Mostrar páginas acima"
@@ -1800,14 +3780,47 @@ msgid_plural "Showing %d events"
msgstr[0] "Mostrando %d evento"
msgstr[1] "Mostrando %d eventos"
+msgid "Sidebar|Change weight"
+msgstr "Mudar peso"
+
+msgid "Sidebar|No"
+msgstr "Não"
+
+msgid "Sidebar|None"
+msgstr "Nenhum"
+
+msgid "Sidebar|Weight"
+msgstr "Peso"
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr "Snippets"
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
-msgstr "Algo deu errado do nosso lado."
+msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
-msgstr "Algo deu errado ao tentar mudar o estado de bloqueio de ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
+msgstr ""
msgid "Something went wrong while fetching the projects."
msgstr "Algo deu errado ao recuperar os projetos."
@@ -1815,6 +3828,9 @@ msgstr "Algo deu errado ao recuperar os projetos."
msgid "Something went wrong while fetching the registry list."
msgstr "Algo deu errado ao recuperar a lista de registro."
+msgid "Something went wrong. Please try again."
+msgstr "Algo deu errado. Por favor, tente novamente."
+
msgid "Sort by"
msgstr "Ordenar por"
@@ -1858,7 +3874,7 @@ msgid "SortOptions|Least popular"
msgstr "Menos populares"
msgid "SortOptions|Less weight"
-msgstr "Menor peso"
+msgstr "Menos peso"
msgid "SortOptions|Milestone"
msgstr "Milestone"
@@ -1914,18 +3930,36 @@ msgstr "Iniciar mais próximo"
msgid "SortOptions|Weight"
msgstr "Peso"
+msgid "Source"
+msgstr "Origem"
+
+msgid "Source (branch or tag)"
+msgstr "Fonte (branch or tag)"
+
msgid "Source code"
msgstr "Código-fonte"
+msgid "Source is not available"
+msgstr "Origem não está disponível"
+
msgid "Spam Logs"
msgstr "Logs de spam"
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "Especifique a seguinte URL durante a configuração do Runner:"
msgid "StarProject|Star"
msgstr "Marcar"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr "Projetos favoritos"
@@ -1935,29 +3969,119 @@ msgstr "Iniciar um %{new_merge_request} a partir dessas alterações"
msgid "Start the Runner!"
msgstr "Inicie o Runner!"
+msgid "Started"
+msgstr "Iniciado"
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr "Status"
+
+msgid "Stopped"
+msgstr "Parado"
+
+msgid "Storage"
+msgstr "Armazenamento"
+
msgid "Subgroups"
msgstr "Subgrupos"
-msgid "Subscribe"
-msgstr "Assine"
-
msgid "Switch branch/tag"
msgstr "Trocar branch/tag"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr "Hooks do sistema"
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "Tag"
-msgstr[1] "Tags"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] "Tag (%{tag_count})"
+msgstr[1] "Tags (%{tag_count})"
msgid "Tags"
msgstr "Tags"
+msgid "TagsPage|Browse commits"
+msgstr "Navegar nos commits"
+
+msgid "TagsPage|Browse files"
+msgstr "Navegar nos arquivos"
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr "Não foi possível encontrar o commit HEAD para esse tag"
+
+msgid "TagsPage|Cancel"
+msgstr "Cancelar"
+
+msgid "TagsPage|Create tag"
+msgstr "Criar tag"
+
+msgid "TagsPage|Delete tag"
+msgstr "Apagar tag"
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr "Apagar a tag %{tag_name} é uma ação destrutiva e irreversível. Tem certeza?"
+
+msgid "TagsPage|Edit release notes"
+msgstr "Editar o release notes"
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr "Nome de branch, tag ou SHA do commit existente"
+
+msgid "TagsPage|Filter by tag name"
+msgstr "Filtrar tag por nome"
+
+msgid "TagsPage|New Tag"
+msgstr "Novo tag"
+
+msgid "TagsPage|New tag"
+msgstr "Novo tag"
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr "Opcionalmente, adicionar a mensagem à essa tag."
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr "Opcionalmente, adicionar release notes à tag. Eles serão armazenados no banco de dados do GitLab e mostrado na página de tags."
+
+msgid "TagsPage|Release notes"
+msgstr "Release notes"
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr "Repositório ainda não tem tags."
+
+msgid "TagsPage|Sort by"
+msgstr "Ordenar por"
+
+msgid "TagsPage|Tags"
+msgstr "Tags"
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr "Tags dão capacidade de marcar pontos específicos na história como sendo importantes"
+
+msgid "TagsPage|This tag has no release notes."
+msgstr "Essa tag não tem release notes."
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr "Use o comando \"git tag\" para adiciona uma nova tag:"
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr "Escreve seu release notes ou arraste o arquivo aqui..."
+
+msgid "TagsPage|protected"
+msgstr "protegido"
+
msgid "Target Branch"
msgstr "Branch de destino"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr "Equipe"
@@ -1965,10 +4089,16 @@ msgid "Thanks! Don't show me this again"
msgstr "Obrigado! Não mostrar novamente"
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
-msgstr "A pesquisa global avançado no GitLab é um serviço poderoso de pesquisa que poupa seu tempo. Ao invés de criar código duplicado e perder seu tempo, você pode agora pesquisar por código de outros times que podem ajudar no seu próprio projeto."
+msgstr "A pesquisa global avançada no GitLab é um serviço de pesquisa poderoso que economiza seu tempo. Ao invés de criar códigos duplicados e perder seu tempo, você pode agora pesquisar códigos de outros times que podem ajudar em seu projeto."
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr "Issue Tracker é o lugar para adicionar coisas que precisam ser melhoradas ou resolvidas em um projeto"
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr "Issue Tracker é o lugar para adicionar coisas que precisam ser melhoradas ou resolvidas em um projeto. Você precisa se registrar ou fazer login para criar alguma Issue para este projeto."
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
-msgstr "O limite do recuso do circuitbreaker deve ser inferior ao limite de contagem de falhas"
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+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 "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."
@@ -1976,30 +4106,39 @@ msgstr "A etapa de codificação mostra o tempo desde a entrega do primeiro comm
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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "O relacionamento como fork foi removido."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 "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 maximum file size allowed is 200KB."
+msgstr ""
+
msgid "The number of attempts GitLab will make to access a storage."
msgstr "O número de tentativas que gitlab fará para acessar um storage."
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
-msgstr "O número de falhas até o GitLab começar a desabilitar temporariamente o acesso a um nó de storage em um host"
-
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr "O número de falhas para que o GitLab desabilite o acesso ao storage. O número de falhas pode ser redefinido na interface do administrador: %{link_to_health_page} ou %{api_documentation_link}."
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "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."
@@ -2012,9 +4151,18 @@ 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 repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 "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 roadmap shows the progress of your epics along a timeline"
+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 "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."
@@ -2027,17 +4175,50 @@ msgstr "Tempo em segundos para o GitLab manter as informações de falha. Se nen
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr "Tempo em segundos que o GitLab tentará acessar o storage. Depois desse tempo, um erro de tempo excedido será disparado."
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
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 "There are no issues to show"
+msgstr "Não há issues para mostrar"
+
+msgid "There are no merge requests to show"
+msgstr "Não há merge requests pra mostrar"
+
msgid "There are problems accessing Git storage: "
msgstr "Há problemas para acessar o storage Git: "
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
-msgstr "Esse branch mudou desde quando você começou sua edição. Você quer criar um novo branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr "Erro ao carregar calendário de atividades."
+
+msgid "There was an error saving your notification settings."
+msgstr "Erro ao salvar suas configurações de notificação."
+
+msgid "There was an error subscribing to this label."
+msgstr "Erro ao se inscrever nessa label."
+
+msgid "There was an error when reseting email token."
+msgstr "Erro ao redefinir token do email."
+
+msgid "There was an error when subscribing to this label."
+msgstr "Erro ao se inscrever nessa label."
+
+msgid "There was an error when unsubscribing from this label."
+msgstr "Erro ao se anular a inscrição dessa label."
+
+msgid "This board\\'s scope is reduced"
+msgstr "O escopo desse board está reduzido"
+
+msgid "This directory"
+msgstr "Esse diretório"
msgid "This is a confidential issue."
msgstr "Essa issue é confidencial."
@@ -2045,18 +4226,54 @@ msgstr "Essa issue é confidencial."
msgid "This is the author's first Merge Request to this project."
msgstr "Esse é o autor do primeiro merge request desse projeto."
+msgid "This issue is confidential"
+msgstr "Essa issue é confidencial"
+
msgid "This issue is confidential and locked."
msgstr "Essa issue é confidencial e está bloqueada."
msgid "This issue is locked."
msgstr "Essa issue está bloqueada."
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr "Esse processo ainda não começou"
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr "Este Job exige uma ação manual"
+
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 "This merge request is locked."
msgstr "Esse merge request está bloqueado."
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr "Esta página não está disponível porque você não tem permissão para ler informações de vários projetos."
+
+msgid "This project"
+msgstr "Esse projeto"
+
+msgid "This repository"
+msgstr "Esse repositório"
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr "Esta ação excluirá uma métrica personalizada, você tem certeza?"
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr "Esses e-mails se tornarão issues automaticamente (com os comentários se tornando uma conversa de e-mail) listadas aqui."
+
msgid "Time before an issue gets scheduled"
msgstr "Tempo até que uma issue seja agendada"
@@ -2066,9 +4283,27 @@ 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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "Tempo até a primeira solicitação de incorporação"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "há %s dias"
@@ -2205,14 +4440,83 @@ msgstr[1] "mins"
msgid "Time|s"
msgstr "s"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr "Título"
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr "Pendente"
+
+msgid "Toggle sidebar"
+msgstr "Ativar/Desativar barra lateral"
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr "Mudar Status: Desligado"
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr "Mudar Status: Ligado"
+
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 "Total: %{total}"
+msgstr "Total: %{total}"
+
msgid "Track activity with Contribution Analytics."
-msgstr "Acompanhar atividade com o Contribution Analytics."
+msgstr "Acompanhe a atividade com o Contribution Analytics."
+
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr "Acompanhe grupos de questões que compartilhem um tema, em projetos e milestones"
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr "Ativar Service Desk"
+
+msgid "Unknown"
+msgstr "Desconhecido"
msgid "Unlock"
msgstr "Desbloquear"
@@ -2220,20 +4524,23 @@ msgstr "Desbloquear"
msgid "Unlocked"
msgstr "Desbloqueado"
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "Desmarcar"
-msgid "Unsubscribe"
-msgstr "Desassinar"
+msgid "Up to date"
+msgstr "Atualizado"
msgid "Upgrade your plan to activate Advanced Global Search."
-msgstr "Atualize seu plano para ativar a pesquisa global avançada."
+msgstr "Atualize seu plano para ativar a Pesquisa Global Avançada."
msgid "Upgrade your plan to activate Contribution Analytics."
msgstr "Atualize seu plano para ativar o Contribution Analytics."
msgid "Upgrade your plan to activate Group Webhooks."
-msgstr "Atualize seu plano para ativar Webhooks de grupo."
+msgstr "Atualize seu plano para ativar Webhooks do grupo."
msgid "Upgrade your plan to activate Issue weight."
msgstr "Atualize seu plano para ativar peso nas issues."
@@ -2247,24 +4554,72 @@ msgstr "Enviar Novo Arquivo"
msgid "Upload file"
msgstr "Enviar arquivo"
+msgid "Upload new avatar"
+msgstr "Fazer upload de nova imagem"
+
msgid "UploadLink|click to upload"
msgstr "clique para fazer upload"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr "Use o Service Desk para se conectar com seus usuários (por exemplo, para oferecer suporte ao cliente) por email dentro do GitLab"
+
msgid "Use the following registration token during setup:"
msgstr "Use o seguinte token de registro durante a configuração:"
msgid "Use your global notification setting"
msgstr "Utilizar configuração de notificação global"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr "Ver arquivo @ "
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr "Visualizar etiquetas"
+
msgid "View open merge request"
msgstr "Ver merge request aberto"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr "Ver arquivo substituído @ "
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "Interno"
@@ -2280,17 +4635,29 @@ 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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr "Não foi possível verificar se um dos seus projetos no GCP possui o faturamento ativado. Por favor, tente novamente."
+
msgid "We don't have enough data to show this stage."
msgstr "Esta etapa não possui dados suficientes para exibição."
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr "Queremos ter certeza de que é você, confirme que você não é um robô."
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
-msgstr "Webhooks permitem que você acione uma URL se, por exemplo, um novo código for feito push ou uma nova issue criada. Você pode configurar os webhooks para escutar eventos específicos como push, issue ou merge request. Webhooks de grupo aplicarão para todos os projetos no grupo, permitindo você padronizar o funcionamento em todo o grupo."
+msgstr "Webhooks permitem que você acione uma URL se, por exemplo, quando um novo código for feito push ou uma nova issue criada. Você pode configurar os webhooks para escutar eventos específicos como push, issue ou merge request. Webhooks de grupo aplicarão para todos os projetos no grupo, permitindo você padronizar o funcionamento em todo o grupo."
msgid "Weight"
msgstr "Peso"
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
-msgstr "Falha ao acessar o storage. Gitlab impedirá o acesso ao storage pelo tempo especificado aqui. Isso permite que o sistema de arquivos se recupere. Repositórios que estiverem em nós com falha ficarão temporariamente indisponíveis"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr ""
msgid "Wiki"
msgstr "Wiki"
@@ -2310,6 +4677,12 @@ msgstr "É recomendado instalar %{markdown} para que as funções GFM sejam rend
msgid "WikiClone|Start Gollum and edit locally"
msgstr "Inicie o Gollum e edite localmente"
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr "Você não tem permissão para criar páginas web"
@@ -2395,44 +4768,74 @@ msgid "Wiki|Wiki Pages"
msgstr "Páginas Wiki"
msgid "With contribution analytics you can have an overview for the activity of issues, merge requests and push events of your organization and its members."
-msgstr "Com o contribution analytics você pode ter uma visão geral da atividade em issue, merge request e evento de push para sua organização e seus membros."
+msgstr "Com a análise de contribuição, você pode ter uma visão geral da atividade de issues, merge requests e eventos push de sua organização e seus membros."
msgid "Withdraw Access Request"
msgstr "Remover Requisição de Acesso"
+msgid "Write a commit message..."
+msgstr "Escrever uma mensagem de commit..."
+
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 remove %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Você irá remover %{project_full_name}. O projeto removido NÃO PODE ser restaurado! Tem certeza ABSOLUTA?"
msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
msgstr "Você está prestes a remover a relação de fork do projeto original %{forked_from_project}. Você tem CERTEZA disso?"
-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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr "Você irá transferir %{project_full_name} para outro proprietário. Tem certeza ABSOLUTA?"
msgid "You are on a read-only GitLab instance."
-msgstr "Você está em uma instância somente-leitura do GitLab."
+msgstr ""
+
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
-msgstr "Você está em uma instância somente-leitura do GitLab. Se você quiser fazer qualquer alteração visite %{link_to_primary_node}."
+msgid "You can also create a project from the command line."
+msgstr "Você também pode criar um projeto a partir da linha de comando."
+
+msgid "You can also star a label to make it a priority label."
+msgstr "Você também pode marcar uma label para torná-la uma label de prioridade."
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr "Você pode instalar facilmente um Runner em um cluster Kubernetes. %{link_to_help_page}"
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr "Você pode mover o gráfico usando as setas do teclado."
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 can only edit files when you are on a branch"
+msgstr "Você só pode editar arquivos quando estiver em um branch"
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr "Você não pode escrever numa instância secundária de somente leitura do GitLab Geo. Por favor use %{link_to_primary_node}."
msgid "You cannot write to this read-only GitLab instance."
msgstr "Você não pode escrever nesta instância somente-leitura do GitLab."
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr "Você não tem permissão"
+
msgid "You have reached your project limit"
msgstr "Você atingiu o limite de seu projeto"
+msgid "You must have master access to force delete a lock"
+msgstr "Você deve ter o acesso master para apagar um bloqueio"
+
msgid "You must sign in to star a project"
msgstr "Você deve estar autenticado para marcar um projeto"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "Você precisa de permissão."
@@ -2457,6 +4860,36 @@ msgstr "Você não poderá fazer pull ou push via %{protocol} até que %{set_pas
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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr "Você não poderá fazer push ou pull do código via SSH enquanto não adicionar sua chave SSH no seu perfil"
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr "Você precisará usar nomes de branch diferentes para obter uma comparação válida."
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr "Commit com suas alterações realizado. Commit %{commitId} %{commitStats}"
+
msgid "Your comment will not be visible to the public."
msgstr "Seu comentário não estará visível ao público."
@@ -2469,20 +4902,352 @@ msgstr "Seu nome"
msgid "Your projects"
msgstr "Seus projetos"
-msgid "commit"
-msgstr "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr "atribuir a si mesmo"
+
+msgid "branch name"
+msgstr "nome da branch"
+
+msgid "by"
+msgstr "por"
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr "Qualidade de código"
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
msgid "day"
msgid_plural "days"
msgstr[0] "dia"
msgstr[1] "dias"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "novo merge request"
msgid "notification emails"
msgstr "emails de notificação"
+msgid "or"
+msgstr "ou"
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "pai"
@@ -2494,9 +5259,30 @@ msgstr "senha"
msgid "personal access token"
msgstr "token de acesso pessoal"
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr "remover a data de vencimento"
+
+msgid "source"
+msgstr "origem"
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr "%{slash_command} irá atualizar a soma do tempo gasto."
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr "para ajudar seus contribuintes à se comunicar de maneira eficaz!"
msgid "username"
msgstr "nome do usuário"
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/ru/gitlab.po b/locale/ru/gitlab.po
index b25a5d1e75b..a5e145a06ed 100644
--- a/locale/ru/gitlab.po
+++ b/locale/ru/gitlab.po
@@ -2,46 +2,97 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-17 07:55-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:35-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\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"
+"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Generator: crowdin.com\n"
"X-Crowdin-Project: gitlab-ee\n"
"X-Crowdin-Language: ru\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr " и"
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d коммит"
msgstr[1] "%d коммита"
msgstr[2] "%d коммитов"
+msgstr[3] "%d коммитов"
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] "на %d коммит позади"
+msgstr[1] "на %d коммита позади"
+msgstr[2] "на %d коммитов позади"
+msgstr[3] "на %d коммитов позади"
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] "%d обÑуждение"
+msgstr[1] "%d обÑуждений"
+msgstr[2] "%d обÑуждений"
+msgstr[3] "%d обÑуждений"
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] "%d Ñлой"
msgstr[1] "%d ÑлоÑ"
-msgstr[2] "%d Ñлоёв"
+msgstr[2] "%d Ñлоев"
+msgstr[3] "%d Ñлоёв"
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] "%d Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+msgstr[1] "%d запроÑа на ÑлиÑние"
+msgstr[2] "%d запроÑов на ÑлиÑние"
+msgstr[3] "%d запроÑов на ÑлиÑние"
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
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 добавленных коммитов были иÑключены Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ Ð¿Ñ€Ð¾Ð¸Ð·Ð²Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð¾Ñтью."
+msgstr[0] "%s дополнительный коммит был пропущен Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ Ð¿Ñ€Ð¾Ð¸Ð·Ð²Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð¾Ñтью."
+msgstr[1] "%s дополнительных коммита было пропущено Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ Ð¿Ñ€Ð¾Ð¸Ð·Ð²Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð¾Ñтью."
+msgstr[2] "%s дополнительных коммитов было пропущено Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ Ð¿Ñ€Ð¾Ð¸Ð·Ð²Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð¾Ñтью."
+msgstr[3] "%s дополнительных коммитов было пропущено Ð´Ð»Ñ Ð¿Ñ€ÐµÐ´Ð¾Ñ‚Ð²Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ Ñ Ð¿Ñ€Ð¾Ð¸Ð·Ð²Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð¾Ñтью."
+
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
-msgid "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} добавил коммит %{commit_timeago}"
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] "%{count} учаÑтник"
msgstr[1] "%{count} учаÑтника"
msgstr[2] "%{count} учаÑтников"
+msgstr[3] "%{count} учаÑтников"
+
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr "%{lock_path} заблокирован пользователем GitLab %{lock_user_id}"
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr "на %{number_commits_behind} коммитов позади %{default_branch}, на %{number_commits_ahead} коммитов впереди"
@@ -49,17 +100,21 @@ msgstr "на %{number_commits_behind} коммитов позади %{default_br
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "%{number_of_failures} из %{maximum_failures} возможных неудачных попыток. GitLab будет доÑтупен поÑле Ñледующей попытки."
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "%{number_of_failures} из %{maximum_failures} возможных неудачных попыток. GitLab заблокирует доÑтуп на %{number_of_seconds} Ñекунд."
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "%{number_of_failures} из %{maximum_failures} возможных неудачных попыток. GitLab не будет автоматичеÑки повторÑÑ‚ÑŒ попытку. СброÑьте информацию хранилища поÑле уÑÑ‚Ñ€Ð°Ð½ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ñ‹."
+msgid "%{openOrClose} %{noteable}"
+msgstr ""
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name}: Ð½ÐµÑƒÐ´Ð°Ñ‡Ð½Ð°Ñ Ð¿Ð¾Ð¿Ñ‹Ñ‚ÐºÐ° доÑтупа к хранилищу на хоÑте:"
-msgstr[1] "%{storage_name}: %{failed_attempts} - неудачные попытки доÑтупа к хранилищу:"
-msgstr[2] "%{storage_name}: %{failed_attempts} - неудачные попытки доÑтупа к хранилищу:"
+msgstr[1] "%{storage_name}: %{failed_attempts} неудачные попытки доÑтупа к хранилищу:"
+msgstr[2] "%{storage_name}: %{failed_attempts} неудачных попыток доÑтупа к хранилищу:"
+msgstr[3] "%{storage_name}: %{failed_attempts} неудачных попыток доÑтупа к хранилищу:"
+
+msgid "%{text} is available"
+msgstr "%{text} доÑтупен"
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(перейдите по ÑÑылке %{link} Ð´Ð»Ñ Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ð¸ об уÑтановке)."
@@ -75,6 +130,7 @@ msgid_plural "%d pipelines"
msgstr[0] "1 ÑÐ±Ð¾Ñ€Ð¾Ñ‡Ð½Ð°Ñ Ð»Ð¸Ð½Ð¸Ñ"
msgstr[1] "%d Ñборочных линии"
msgstr[2] "%d Ñборочных линий"
+msgstr[3] "%d Ñборочных линий"
msgid "1st contribution!"
msgstr "Первый вклад!"
@@ -82,15 +138,30 @@ msgstr "Первый вклад!"
msgid "2FA enabled"
msgstr "Ð”Ð²ÑƒÑ…Ñ„Ð°ÐºÑ‚Ð¾Ñ€Ð½Ð°Ñ Ð°Ð²Ñ‚Ð¾Ñ€Ð¸Ð·Ð°Ñ†Ð¸Ñ Ð²ÐºÐ»ÑŽÑ‡ÐµÐ½Ð°"
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "Графики непрерывной интеграции (CI)"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "Об автоматичеÑком развёртывании"
msgid "Abuse Reports"
msgstr "Отчёты о Жалобах"
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr "Токены ДоÑтупа"
@@ -100,6 +171,9 @@ msgstr "ДоÑтуп к вышедшим из ÑÑ‚Ñ€Ð¾Ñ Ñ…Ñ€Ð°Ð½Ð¸Ð»Ð¸Ñ‰Ð°Ð¼ в
msgid "Account"
msgstr "Ð£Ñ‡ÐµÑ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ"
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "Ðктивный"
@@ -118,80 +192,290 @@ msgstr "Добавить РуководÑтво учаÑтника"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr "Добавить групповые веб-обработчики и GitLab Enterprise Edition."
+msgid "Add Kubernetes cluster"
+msgstr "Добавить Kubernetes клаÑтер"
+
msgid "Add License"
msgstr "Добавить Лицензию"
-msgid "Add an SSH key to your profile to pull or push via SSH."
-msgstr "Добавьте ключ SSH в Ñвой профиль, чтобы отправлÑÑ‚ÑŒ или получать код через SSH."
+msgid "Add Readme"
+msgstr "Добавить Информацию"
msgid "Add new directory"
msgstr "Добавить новый каталог"
+msgid "Add todo"
+msgstr "Добавить todo"
+
+msgid "AdminArea|Stop all jobs"
+msgstr "ОÑтановить вÑе заданиÑ"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "ОÑтановить вÑе заданиÑ?"
+
+msgid "AdminArea|Stop jobs"
+msgstr "ОÑтановить заданиÑ"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr "ОÑтановка заданий не удалаÑÑŒ"
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr "Ð’Ñ‹ ÑобираетеÑÑŒ оÑтановить вÑе заданиÑ. Это дейÑтвие оÑтановит вÑе текущие заданиÑ, находÑщиеÑÑ Ð² процеÑÑе выполнениÑ."
+
msgid "AdminHealthPageLink|health page"
msgstr "Ñтраница работоÑпоÑобноÑти"
+msgid "AdminProjects|Delete"
+msgstr "Удалить"
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr "Удалить Проект %{projectName}?"
+
+msgid "AdminProjects|Delete project"
+msgstr "Удалить проект"
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr "Укажите домен, который будет иÑпользоватьÑÑ Ð¿Ð¾ умолчанию Ð´Ð»Ñ Ð²Ñех проектов в Auto Review приложениÑÑ… и ÑтадиÑÑ… Auto Deploy."
+
+msgid "AdminUsers|Block user"
+msgstr "Заблокировать пользователÑ"
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr "Удалить ÐŸÐ¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ %{username} и внеÑённые им Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ ?"
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr "Удалить Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ %{username} ?"
+
+msgid "AdminUsers|Delete user"
+msgstr "Удалить пользователÑ"
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr "Удалить Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ Ð¸ внеÑённые им изменениÑ"
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr "Ð”Ð»Ñ Ð¿Ð¾Ð´Ñ‚Ð²ÐµÑ€Ð¶Ð´ÐµÐ½Ð¸Ñ, введите %{projectName}"
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr "Ð”Ð»Ñ Ð¿Ð¾Ð´Ñ‚Ð²ÐµÑ€Ð¶Ð´ÐµÐ½Ð¸Ñ, введите %{username}"
+
+msgid "Advanced"
+msgstr "Дополнительно"
+
msgid "Advanced settings"
msgstr "РаÑширенные наÑтройки"
msgid "All"
msgstr "Ð’Ñе"
+msgid "All changes are committed"
+msgstr "Ð’Ñе Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ Ð·Ð°Ñ„Ð¸ÐºÑированы"
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr "ПозволÑет добавлÑÑ‚ÑŒ и управлÑÑ‚ÑŒ клаÑтерами Kubernetes."
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr "Произошла ошибка при предварительном проÑмотре объекта"
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "Произошла ошибка при переключении подпиÑки на оповещениÑ"
+
+msgid "An error occurred when updating the issue weight"
+msgstr "Произошла ошибка при обновлении веÑа обÑуждениÑ"
+
+msgid "An error occurred while adding approver"
+msgstr "Произошла ошибка при добавлении утверждающего"
+
+msgid "An error occurred while detecting host keys"
+msgstr "Произошла ошибка при обнаружении ключей хоÑта"
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr "Произошла ошибка при отключении ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ð¾ функции. Обновите Ñтраницу и повторите попытку."
+
+msgid "An error occurred while fetching markdown preview"
+msgstr "Произошла ошибка при предварительном проÑмотре markdown"
+
+msgid "An error occurred while fetching sidebar data"
+msgstr "Произошла ошибка при получении денег данных Ð´Ð»Ñ Ð±Ð¾ÐºÐ¾Ð²Ð¾Ð¹ панели"
+
+msgid "An error occurred while fetching the pipeline."
+msgstr "Произошла ошибка при получении Ñборочной линии."
+
+msgid "An error occurred while getting projects"
+msgstr "Произошла ошибка при получении ÑпиÑка проектов"
+
+msgid "An error occurred while importing project"
+msgstr "Произошла ошибка при импорте проекта"
+
+msgid "An error occurred while initializing path locks"
+msgstr "Произошла ошибка при инициализации блокировок пути"
+
+msgid "An error occurred while loading commits"
+msgstr "Произошла ошибка при загрузке коммитов"
+
+msgid "An error occurred while loading diff"
+msgstr "Произошла ошибка при загрузке различий"
+
+msgid "An error occurred while loading filenames"
+msgstr "Произошла ошибка при загрузке ÑпиÑка файлов"
+
+msgid "An error occurred while loading the file"
+msgstr "Произошла ошибка при загрузке файла"
+
+msgid "An error occurred while making the request."
+msgstr "Произошла ошибка при выполнении запроÑа."
+
+msgid "An error occurred while removing approver"
+msgstr "Произошла ошибка при удалении утверждающего"
+
+msgid "An error occurred while rendering KaTeX"
+msgstr "Произошла ошибка при рендеринге KaTeX"
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr "Произошла ошибка при визуализации проÑмотра широковещательного ÑообщениÑ"
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr "Произошла ошибка при получении ÐºÐ°Ð»ÐµÐ½Ð´Ð°Ñ€Ñ Ð°ÐºÑ‚Ð¸Ð²Ð½Ð¾Ñти"
+
+msgid "An error occurred while retrieving diff"
+msgstr "Произошла ошибка при получении различий"
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr "Произошла ошибка при Ñохранении ÑтатуÑа наÑтроенного LDAP. ПожалуйÑта, попробуйте еще раз."
+
+msgid "An error occurred while saving assignees"
+msgstr "Произошла ошибка при Ñохранении иÑполнителÑ"
+
+msgid "An error occurred while validating username"
+msgstr "Произошла ошибка при проверке имени пользователÑ"
+
msgid "An error occurred. Please try again."
msgstr "Произошла ошибка. ПожалуйÑта, попробуйте Ñнова."
+msgid "Any Label"
+msgstr "Ð›ÑŽÐ±Ð°Ñ ÐœÐµÑ‚ÐºÐ°"
+
msgid "Appearance"
msgstr "Оформление"
msgid "Applications"
msgstr "ПриложениÑ"
+msgid "Apr"
+msgstr "Ðпр."
+
+msgid "April"
+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 leave this group?"
-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 you want to unlock %{path_lock_path}?"
+msgstr "Ð’Ñ‹ дейÑтвительно хотите разблокировать %{path_lock_path}?"
+
msgid "Are you sure?"
msgstr "Вы уверены?"
msgid "Artifacts"
msgstr "Ðртефакты"
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr "Ðазначьте пользовательÑкий цвет, например #FF0000"
+
+msgid "Assign labels"
+msgstr "Ðазначить метки"
+
+msgid "Assign milestone"
+msgstr "Ðазначить Ñтап"
+
+msgid "Assign to"
+msgstr "Ðазначить"
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Приложить файл через drag &amp; drop или %{upload_link}"
+msgid "Aug"
+msgstr "Ðвг."
+
+msgid "August"
+msgstr "ÐвгуÑÑ‚"
+
msgid "Authentication Log"
msgstr "Журнал аутентификации"
msgid "Author"
msgstr "Ðвтор"
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
+msgstr "Ðвторы: %{authors}"
+
+msgid "Auto DevOps enabled"
+msgstr "Auto DevOps включен"
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr "ÐŸÑ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡ÐµÑкого ревью и автоматичеÑкого Ñ€Ð°Ð·Ð²Ñ‘Ñ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ñ‚Ñ€ÐµÐ±ÑƒÑŽÑ‚ ÑƒÐºÐ°Ð·Ð°Ð½Ð¸Ñ %{kubernetes} Ð´Ð»Ñ ÐºÐ¾Ñ€Ñ€ÐµÐºÑ‚Ð½Ð¾Ð¹ работы."
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
msgstr "ÐŸÑ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡ÐµÑкого ревью и автоматичеÑкого Ñ€Ð°Ð·Ð²Ñ‘Ñ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ñ‚Ñ€ÐµÐ±ÑƒÑŽÑ‚ ÑƒÐºÐ°Ð·Ð°Ð½Ð¸Ñ Ð¸Ð¼ÐµÐ½Ð¸ домена и %{kubernetes} Ð´Ð»Ñ ÐºÐ¾Ñ€Ñ€ÐµÐºÑ‚Ð½Ð¾Ð¹ работы."
msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr "ÐŸÑ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡ÐµÑкого ревью и автоматичеÑкого Ñ€Ð°Ð·Ð²Ñ‘Ñ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ñ‚Ñ€ÐµÐ±ÑƒÑŽÑ‚ ÑƒÐºÐ°Ð·Ð°Ð½Ð¸Ñ Ð¸Ð¼ÐµÐ½Ð¸ домена Ð´Ð»Ñ ÐºÐ¾Ñ€Ñ€ÐµÐºÑ‚Ð½Ð¾Ð¹ работы."
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
-msgstr "ÐŸÑ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡ÐµÑкого ревью и автоматичеÑкого Ñ€Ð°Ð·Ð²Ñ‘Ñ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ñ‚Ñ€ÐµÐ±ÑƒÑŽÑ‚ ÑƒÐºÐ°Ð·Ð°Ð½Ð¸Ñ %{kubernetes} Ð´Ð»Ñ ÐºÐ¾Ñ€Ñ€ÐµÐºÑ‚Ð½Ð¾Ð¹ работы."
-
msgid "AutoDevOps|Auto DevOps (Beta)"
msgstr "Auto DevOps (бета)"
msgid "AutoDevOps|Auto DevOps documentation"
-msgstr ""
+msgstr "Ð”Ð¾ÐºÑƒÐ¼ÐµÐ½Ñ‚Ð°Ñ†Ð¸Ñ Ð¿Ð¾ Auto DevOps"
msgid "AutoDevOps|Enable in settings"
msgstr "Включить в наÑтройках"
@@ -202,8 +486,32 @@ msgstr "Ð”Ð»Ñ Ñтого проекта может быть активировÐ
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr "Подробнее по ÑÑылке %{link_to_documentation}"
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
-msgstr "Ð’Ñ‹ можете активировать %{link_to_settings} Ð´Ð»Ñ Ñтого проекта."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr "Ð’Ñ‹ можете автоматичеÑки Ñобирать и теÑтировать Ñвое приложение, еÑли Ð´Ð»Ñ Ñтого проекта %{link_to_auto_devops_settings}. Ð’Ñ‹ также можете автоматичеÑки развернуть Ñвое приложение, еÑли вы %{link_to_add_kubernetes_cluster}."
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr "добавите клаÑтер Kubernetes"
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr "включен Auto DevOps (Beta)"
+
+msgid "Available"
+msgstr "ДоÑтупен"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr "Ðватар будет удален. Ð’Ñ‹ уверены?"
+
+msgid "Average per day: %{average}"
+msgstr "Ð’ Ñреднем за день: %{average}"
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr "Ðачать Ñ Ð²Ñ‹Ð±Ñ€Ð°Ð½Ð½Ð¾Ð³Ð¾ коммита"
msgid "Billing"
msgstr "Тариф"
@@ -212,7 +520,7 @@ msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
msgstr "%{group_name} иÑпользует тарифный план %{plan_link}."
msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
-msgstr "ÐвтоматичеÑкое повышение или понижение недоÑтупно Ð´Ð»Ñ Ð½ÐµÐºÐ¾Ñ‚Ð¾Ñ€Ñ‹Ñ… тарифных планов в наÑтоÑщее времÑ."
+msgstr "Ð’ наÑтоÑщее Ð²Ñ€ÐµÐ¼Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡ÐµÑкое повышение или понижение недоÑтупно Ð´Ð»Ñ Ð½ÐµÐºÐ¾Ñ‚Ð¾Ñ€Ñ‹Ñ… тарифных планов."
msgid "BillingPlans|Current plan"
msgstr "Текущий тарифный план"
@@ -221,10 +529,10 @@ msgid "BillingPlans|Customer Support"
msgstr "Поддержка Клиентов"
msgid "BillingPlans|Downgrade"
-msgstr "Понижение"
+msgstr "Понизить"
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
-msgstr "Узнайте больше о каждом тарифном плане прочитав наш %{faq_link}."
+msgstr "Узнайте больше о каждом тарифном плане, прочитав нашу Ñтраницу %{faq_link}."
msgid "BillingPlans|Manage plan"
msgstr "Управление тарифным планом"
@@ -236,19 +544,19 @@ msgid "BillingPlans|See all %{plan_name} features"
msgstr "ПоÑмотреть возможноÑти %{plan_name} полноÑтью"
msgid "BillingPlans|This group uses the plan associated with its parent group."
-msgstr "Эта группа иÑпользует тарифный план ÑвÑзанный Ñ Ñ€Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÑкой группой."
+msgstr "Эта группа иÑпользует тарифный план, ÑвÑзанный Ñ Ñ€Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÑкой группой."
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
msgstr "Ð”Ð»Ñ ÑƒÐ¿Ñ€Ð°Ð²Ð»ÐµÐ½Ð¸Ñ Ñ‚Ð°Ñ€Ð¸Ñ„Ð½Ñ‹Ð¼ планом Ñтой группы поÑетите раздел тарификации %{parent_billing_page_link}."
msgid "BillingPlans|Upgrade"
-msgstr "Повышение"
+msgstr "ПовыÑить"
msgid "BillingPlans|You are currently on the %{plan_link} plan."
msgstr "Ð’ наÑтоÑщее Ð²Ñ€ÐµÐ¼Ñ Ð²Ñ‹ иÑпользуете тарифный план %{plan_link}."
msgid "BillingPlans|frequently asked questions"
-msgstr "ЧаÑто задаваемые вопроÑÑ‹"
+msgstr "чаÑто задаваемых вопроÑов"
msgid "BillingPlans|monthly"
msgstr "ежемеÑÑчно"
@@ -259,11 +567,12 @@ msgstr "оплачиваетÑÑ ÐµÐ¶ÐµÐ³Ð¾Ð´Ð½Ð¾ в размере %{price_per_
msgid "BillingPlans|per user"
msgstr "за пользователÑ"
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Ветка"
-msgstr[1] "Ветки"
-msgstr[2] "Ветки"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+msgstr[0] "Ветка (%{branch_count})"
+msgstr[1] "Ветки (%{branch_count})"
+msgstr[2] "Ветки (%{branch_count})"
+msgstr[3] "Ветки (%{branch_count})"
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}"
@@ -271,6 +580,12 @@ msgstr "Ветка <strong>%{branch_name}</strong> Ñоздана. Ð”Ð»Ñ Ð½Ð°Ñ
msgid "Branch has changed"
msgstr "Ветка была изменена"
+msgid "Branch is already taken"
+msgstr "Ветвь уже ÑущеÑтвует"
+
+msgid "Branch name"
+msgstr "Ð˜Ð¼Ñ Ð²ÐµÑ‚Ð²Ð¸"
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "ПоиÑк веток"
@@ -280,6 +595,15 @@ msgstr "Переключить ветку"
msgid "Branches"
msgstr "Ветки"
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr "Ðевозможно найти HEAD-коммит Ñтой ветки"
@@ -302,7 +626,7 @@ msgid "Branches|Delete protected branch '%{branch_name}'?"
msgstr "Удалить защищённую ветку '%{branch_name}'?"
msgid "Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?"
-msgstr "Уделение ветки '%{branch_name}' невозможно отменить. Вы уверены?"
+msgstr "Удаление ветки '%{branch_name}' невозможно отменить. Вы уверены?"
msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
msgstr "Удаление влитых веток невозможно отменить. Вы уверены?"
@@ -325,14 +649,41 @@ msgstr "Как только вы подтвердите и нажмёте %{dele
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr "Только маÑтер или владелец проекта может удалить защищённую ветку"
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
-msgstr "Управление защищёнными ветками возможно в %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
+msgstr ""
msgid "Branches|Sort by"
msgstr "Сортировать по"
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
-msgstr "Ветка не может быть обновлена автоматичеÑки, потому что она имеет раÑÑ…Ð¾Ð¶Ð´ÐµÐ½Ð¸Ñ Ñ ÐµÑ‘ двойником в родительÑком репозитории."
+msgstr "Ветвь не может быть обновлена автоматичеÑки, потому что она имеет раÑÑ…Ð¾Ð¶Ð´ÐµÐ½Ð¸Ñ Ñ Ñ€Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÑким репозиторием."
msgid "Branches|The default branch cannot be deleted"
msgstr "Ветка \"по умолчанию\" не может быть удалена"
@@ -347,13 +698,13 @@ msgid "Branches|To confirm, type %{branch_name_confirmation}:"
msgstr "Ð”Ð»Ñ Ð¿Ð¾Ð´Ñ‚Ð²ÐµÑ€Ð¶Ð´ÐµÐ½Ð¸Ñ, введите %{branch_name_confirmation}:"
msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above."
-msgstr "Чтобы отменить локальные Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ Ð¸ перезапиÑать ветку верÑией из родительÑкого репозиториÑ, удалите её здеÑÑŒ и выберите \"Обновить ÑейчаÑ\" выше."
+msgstr "Чтобы отменить локальные Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ Ð¸ перезапиÑать ветвь верÑией из родительÑкого репозиториÑ, удалите её здеÑÑŒ и выберите \"Обновить ÑейчаÑ\" выше."
msgid "Branches|You’re about to permanently delete the protected branch %{branch_name}."
msgstr "Ð’Ñ‹ ÑобираетеÑÑŒ безвозвратно удалить защищённую ветку %{branch_name}."
msgid "Branches|diverged from upstream"
-msgstr "раÑходÑÑ‚ÑÑ Ñ Ñ€Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÑким репозиторием"
+msgstr "раÑходитÑÑ Ñ Ñ€Ð¾Ð´Ð¸Ñ‚ÐµÐ»ÑŒÑким репозиторием"
msgid "Branches|merged"
msgstr "влита"
@@ -376,14 +727,23 @@ msgstr "ПроÑмотр файлов"
msgid "Browse files"
msgstr "ПроÑмотр файлов"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "по автору"
msgid "CI / CD"
msgstr "CI / CD"
-msgid "CI configuration"
-msgstr "ÐаÑтройка CI"
+msgid "CI/CD"
+msgstr "CI/CD"
+
+msgid "CI/CD configuration"
+msgstr "ÐšÐ¾Ð½Ñ„Ð¸Ð³ÑƒÑ€Ð°Ñ†Ð¸Ñ CI/CD"
+
+msgid "CI/CD for external repo"
+msgstr "CI/CD Ð´Ð»Ñ Ð²Ð½ÐµÑˆÐ½ÐµÐ³Ð¾ репозиториÑ"
msgid "CICD|Jobs"
msgstr "ЗаданиÑ"
@@ -391,12 +751,21 @@ msgstr "ЗаданиÑ"
msgid "Cancel"
msgstr "Отмена"
-msgid "Cancel edit"
-msgstr "Отменить редактирование"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr "Ðевозможно изменить управлÑемый клаÑтер Kubernetes"
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
msgstr "Изменить ВеÑ"
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "Выбрать в ветке"
@@ -409,23 +778,56 @@ msgstr "Подобрать"
msgid "ChangeTypeAction|Revert"
msgstr "Отменить"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr "Это ÑоздаÑÑ‚ новый коммит Ð´Ð»Ñ Ñ‚Ð¾Ð³Ð¾, чтобы откатить ÑущеÑтвующие изменениÑ."
+
msgid "Changelog"
msgstr "Журнал изменений"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "Диаграммы"
msgid "Chat"
msgstr "Чат"
+msgid "Check interval"
+msgstr "Интервал проверки"
+
+msgid "Checking %{text} availability…"
+msgstr "Проверка доÑтупноÑти %{text} ..."
+
+msgid "Checking branch availability..."
+msgstr "Проверка доÑтупноÑти ветви..."
+
msgid "Cherry-pick this commit"
msgstr "Подобрать в Ñтом коммите"
msgid "Cherry-pick this merge request"
msgstr "Подобрать Ñтот Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
-msgstr "Выберите группы, которые хотите Ñкопировать на вторичный узел. ОÑтавьте пуÑтым Ð´Ð»Ñ ÐºÐ¾Ð¿Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ðµ вÑего."
+msgid "Choose File ..."
+msgstr "Выберите Файл..."
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr "Выберите ветку/тег (например, %{master}) или введите коммит(например, %{sha}), чтобы увидеть, что изменилоÑÑŒ или Ñоздать Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние."
+
+msgid "Choose file..."
+msgstr "Выберите файл..."
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr "Выберите группы, которые вы хотите Ñинхронизировать Ñ Ñтим вторичным узлом."
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+msgstr ""
msgid "CiStatusLabel|canceled"
msgstr "отменено"
@@ -481,56 +883,146 @@ msgstr "пропущено"
msgid "CiStatus|running"
msgstr "выполнÑетÑÑ"
+msgid "CiVariables|Input variable key"
+msgstr "Ключ входной переменной"
+
+msgid "CiVariables|Input variable value"
+msgstr "Значение входной переменной"
+
+msgid "CiVariables|Remove variable row"
+msgstr "Удалить Ñтроку переменных"
+
+msgid "CiVariable|* (All environments)"
+msgstr " * (Ð’Ñе Ñреды)"
+
+msgid "CiVariable|All environments"
+msgstr "Ð’Ñе Ñреды"
+
+msgid "CiVariable|Create wildcard"
+msgstr "Создать шаблон"
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr "Произошла ошибка при Ñохранении переменных"
+
+msgid "CiVariable|New environment"
+msgstr "ÐÐ¾Ð²Ð°Ñ Ñреда"
+
+msgid "CiVariable|Protected"
+msgstr "Защищено"
+
+msgid "CiVariable|Search environments"
+msgstr "ПоиÑк Ñред"
+
+msgid "CiVariable|Toggle protected"
+msgstr "Включить защиту"
+
+msgid "CiVariable|Validation failed"
+msgstr "Проверка не удалаÑÑŒ"
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr "CircuitBreaker API"
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr "Ðажмите кнопку ниже, чтобы начать процеÑÑ ÑƒÑтановки, Ð¿ÐµÑ€ÐµÐ¹Ð´Ñ Ð½Ð° Ñтраницу Kubernetes"
+
+msgid "Click to expand text"
+msgstr "Ðажмите, чтобы раÑкрыть текÑÑ‚"
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr "Клонировать репозиторий"
msgid "Close"
msgstr "Закрыть"
-msgid "Cluster"
-msgstr "КлаÑтер"
+msgid "Closed"
+msgstr "Закрыто"
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
-msgstr "%{link_to_container_project} должен быть Ñоздан под Ñтой учетной запиÑью"
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr "%{appList} уÑпешно уÑтановлены на вашем клаÑтере Kubernetes"
-msgid "ClusterIntegration|Cluster details"
-msgstr "Параметры клаÑтера"
+msgid "ClusterIntegration|API URL"
+msgstr "ÐÐ´Ñ€ÐµÑ API"
-msgid "ClusterIntegration|Cluster integration"
-msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтеров"
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr "Добавить клаÑтер Kubernetes"
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
-msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтеров отключена Ð´Ð»Ñ Ñтого проекта."
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr "Добавить ÑущеÑтвующий клаÑтер Kubernetes"
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
-msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтеров включена Ð´Ð»Ñ Ñтого проекта."
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr "Дополнительные опции Ð´Ð»Ñ Ñтой интеграции Ñ ÐºÐ»Ð°Ñтером Kubernetes"
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
-msgstr "Ð”Ð»Ñ Ñтого проекта включена Ð¸Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтеров. Отключение интеграции не повлиÑет на клаÑтер, но Ñоединение Ñ GitLab будет временно отключено."
+msgid "ClusterIntegration|Applications"
+msgstr "ПриложениÑ"
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
-msgstr "СоздаетÑÑ ÐºÐ»Ð°Ñтер в Google Kubernetes Engine..."
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr "Ð’Ñ‹ уверены, что хотите удалить интеграцию Ñ Ñтим клаÑтером Kubernetes? Это не приведет к удалению вашего клаÑтера Kubernetes."
-msgid "ClusterIntegration|Cluster name"
-msgstr "Ðазвание клаÑтера"
+msgid "ClusterIntegration|CA Certificate"
+msgstr "Сертификат удоÑтоверÑющего центра"
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
-msgstr "КлаÑтер был уÑпешно Ñоздан в Google Kubernetes Engine"
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr "Комплект Ñертификатов удоÑтоверÑющего центра (формат PEM)"
-msgid "ClusterIntegration|Copy cluster name"
-msgstr "Копировать название клаÑтера"
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr "Выберите ÑпоÑоб наÑтройки интеграции Ñ ÐºÐ»Ð°Ñтером Kubernetes"
-msgid "ClusterIntegration|Create cluster"
-msgstr "Создать клаÑтер"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr "Выберите, какие Ñреды вашего проекта будут иÑпользовать Ñтот клаÑтер Kubernetes."
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
-msgstr "Создать новый клаÑтер в Google Kubernetes Engine"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr "УправлÑйте тем, как ваш клаÑтер Kubernetes интегрируетÑÑ Ñ GitLab"
-msgid "ClusterIntegration|Enable cluster integration"
-msgstr "Включить интеграцию Ñ ÐºÐ»Ð°Ñтерами"
+msgid "ClusterIntegration|Copy API URL"
+msgstr "Скопировать Ð°Ð´Ñ€ÐµÑ API"
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr "Копировать Сертификат УдоÑтоверÑющего Центра"
+
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr "Скопировать IP-Ð°Ð´Ñ€ÐµÑ Ingress в буфер обмена"
+
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr "Скопировать Ð¸Ð¼Ñ ÐºÐ»Ð°Ñтера Kubernetes"
+
+msgid "ClusterIntegration|Copy Token"
+msgstr "Скопировать Токен"
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr "Создать клаÑтер Kubernetes"
+
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr "Создать клаÑтер при помощи Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr "Создайте новый клаÑтер Kubernetes в Google Kubernetes Engine прÑмо из GitLab"
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr "Создать в GKE"
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr "Укажите параметры ÑущеÑтвующего клаÑтера Kubernetes"
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr "Введите ÑÐ²ÐµÐ´ÐµÐ½Ð¸Ñ Ð¾ вашем клаÑтере Kubernetes"
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ Ñ GitLab"
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr "GitLab Runner"
msgid "ClusterIntegration|Google Cloud Platform project ID"
msgstr "Идентификатор проекта в Google Cloud Platform"
@@ -541,44 +1033,146 @@ msgstr "Google Kubernetes Engine"
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr "Проект Google Kubernetes Engine"
+msgid "ClusterIntegration|Helm Tiller"
+msgstr "Helm Tiller"
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr "Ingress"
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr "IP-Ð°Ð´Ñ€ÐµÑ Ingress"
+
+msgid "ClusterIntegration|Install"
+msgstr "УÑтановить"
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr "УÑтановлен"
+
+msgid "ClusterIntegration|Installing"
+msgstr "УÑтановка"
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ð·Ð°Ñ†Ð¸Ð¸ клаÑтеров Kubernetes"
+
+msgid "ClusterIntegration|Integration status"
+msgstr " Ð¡Ñ‚Ð°Ñ‚ÑƒÑ Ð¸Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ð¸"
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr "КлаÑтер Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr "Ð¡Ð²ÐµÐ´ÐµÐ½Ð¸Ñ Ð¾ клаÑтере Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтера Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтера Kubernetes Ð´Ð»Ñ Ñтого проекта отключена."
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтера Kubernetes Ð´Ð»Ñ Ñтого проекта включена."
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr "Ð”Ð»Ñ Ñтого проекта включена Ð¸Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтера Kubernetes. Отключение интеграции не повлиÑет на клаÑтер Kubernetes, но Ñоединение Ñ GitLab будет временно отключено."
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr "КлаÑтер Kubernetes ÑоздаётÑÑ Ð² Google Kubernetes Engine..."
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr "Ð˜Ð¼Ñ ÐºÐ»Ð°Ñтера Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr "КлаÑтер Kubernetes был уÑпешно Ñоздан в Google Kubernetes Engine. Обновите Ñтраницу, чтобы увидеть ÑÐ²ÐµÐ´ÐµÐ½Ð¸Ñ Ð¾ клаÑтере Kubernetes"
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr "КлаÑтер Kubernetes позволÑет вам иÑпользовать Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ñ€ÐµÐ²ÑŒÑŽ, развёртывать ваши приложениÑ, запуÑкать ваши Ñборочные линии и многое другое более проÑтым ÑпоÑобом. %{link_to_help_page}"
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr "КлаÑтеры Kubernetes могут иÑпользоватьÑÑ Ð´Ð»Ñ Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ð¹ и предоÑÑ‚Ð°Ð²Ð»ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ð¹ Review Ð´Ð»Ñ Ñтого проекта"
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr "Узнайте больше на %{link_to_documentation}"
+msgid "ClusterIntegration|Learn more about environments"
+msgstr "Узнайте больше о Ñредах"
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr "Тип машины"
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
-msgstr "УбедитеÑÑŒ, что ваша ÑƒÑ‡ÐµÑ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ %{link_to_requirements} Ð´Ð»Ñ ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ ÐºÐ»Ð°Ñтеров"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr "Управление"
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
-msgstr "Управление интеграцией клаÑтера на вашем проекте Gitlab"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr "УправлÑйте вашему клаÑтером Kubernetes, Ð¿ÐµÑ€ÐµÐ¹Ð´Ñ Ð¿Ð¾ ÑÑылке %{link_gke}"
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
-msgstr "УправлÑйте клаÑтером, Ð¿ÐµÑ€ÐµÐ¹Ð´Ñ Ð¿Ð¾ ÑÑылке %{link_gke}"
+msgid "ClusterIntegration|More information"
+msgstr "Ð”Ð¾Ð¿Ð¾Ð»Ð½Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ"
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr "ÐеÑколько клаÑтеров Kubernetes доÑтупно в GitLab Entreprise Edition Premium и Ultimate"
+
+msgid "ClusterIntegration|Note:"
+msgstr "Примечание:"
msgid "ClusterIntegration|Number of nodes"
msgstr "КоличеÑтво узлов"
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr "ПожалуйÑта, укажите параметры доÑтупа к вашему клаÑтеру Kubernetes. ЕÑли вам необходима помощь, вы можете ознакомитьÑÑ Ñ Ð½Ð°ÑˆÐµÐ¹ Ñтраницей %{link_to_help_page} по Kubernetes"
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr "ПожалуйÑта, убедитеÑÑŒ, что ваш аккаунт Google отвечает Ñледующим требованиÑм:"
+msgid "ClusterIntegration|Project ID"
+msgstr "ID проекта"
+
+msgid "ClusterIntegration|Project namespace"
+msgstr "ПроÑтранÑтво имён проекта"
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr "ПроÑтранÑтво имен проекта (необÑзательное, уникальное)"
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr "Прочтите нашу документацию %{link_to_help_page} по интеграции клаÑтера."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
-msgstr "Удалить интеграцию Ñ ÐºÐ»Ð°Ñтером"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr "Удалить интеграцию клаÑтера Kubernetes"
msgid "ClusterIntegration|Remove integration"
msgstr "Удалить интеграцию"
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
-msgstr "При удалении интеграции Ñ ÐºÐ»Ð°Ñтером будет удалена ÐºÐ¾Ð½Ñ„Ð¸Ð³ÑƒÑ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтера, которую вы добавили в Ñтот проект. Данное дейÑтвие не удалит Ñам проект."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr "Удалить Ñту конфигурацию клаÑтера Kubernetes из Ñтого проекта. Это не приведет к удалению вашего фактичеÑкого клаÑтера Kubernetes."
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr "Ðе удалоÑÑŒ выполнить Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° запуÑк процеÑÑа уÑтановки"
+
+msgid "ClusterIntegration|Save changes"
+msgstr "Сохранить изменениÑ"
+
+msgid "ClusterIntegration|Security"
+msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
-msgstr "ПроÑмотреть и отредактировать параметры Ð´Ð»Ñ Ð²Ð°ÑˆÐµÐ³Ð¾ клаÑтера"
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr "ПроÑмотр и редактирование информации о вашем клаÑтере Kubernetes"
msgid "ClusterIntegration|See machine types"
msgstr "См. типы машин"
@@ -589,17 +1183,38 @@ msgstr "См. ваши проекты"
msgid "ClusterIntegration|See zones"
msgstr "См. зоны"
+msgid "ClusterIntegration|Service token"
+msgstr "Служебный токен"
+
+msgid "ClusterIntegration|Show"
+msgstr "Показать"
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr " У Ð½Ð°Ñ Ñ‡Ñ‚Ð¾-то пошло не так."
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
-msgstr "Что-то пошло не так во Ð²Ñ€ÐµÐ¼Ñ ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ ÐºÐ»Ð°Ñтера в Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr "Что-то пошло не так при Ñоздании клаÑтера Kubernetes в Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr "Произошли ошибки во Ð²Ñ€ÐµÐ¼Ñ ÑƒÑтановки %{title}"
-msgid "ClusterIntegration|Toggle Cluster"
-msgstr "Переключить КлаÑтер"
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
-msgstr "ЕÑли привÑзать клаÑтер к Ñтому проекту, вы Ñ Ð»Ñ‘Ð³ÐºÐ¾Ñтью Ñможете иÑпользовать Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ñ€ÐµÐ²ÑŒÑŽ, развертывать ваши приложениÑ, запуÑкать Ñборочные линии и многое другое."
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr "Переключить КлаÑтер Kubernetes"
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr "Переключить клаÑтер Kubernetes"
+
+msgid "ClusterIntegration|Token"
+msgstr "Токен"
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr "ЕÑли привÑзать клаÑтер Kubernetes к Ñтому проекту, вы Ñ Ð»Ñ‘Ð³ÐºÐ¾Ñтью Ñможете иÑпользовать Ð¿Ñ€Ð¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ Ð´Ð»Ñ Ñ€ÐµÐ²ÑŒÑŽ, развертывать ваши приложениÑ, запуÑкать Ñборочные линии и многое другое."
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
msgstr "Ваша ÑƒÑ‡ÐµÑ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ должна иметь %{link_to_kubernetes_engine}"
@@ -610,32 +1225,49 @@ msgstr "Зона"
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr "доÑтуп к Google Kubernetes Engine"
-msgid "ClusterIntegration|cluster"
-msgstr "клаÑтер"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
+msgstr "документациÑ"
msgid "ClusterIntegration|help page"
msgstr "Ñтраница Ñправки"
+msgid "ClusterIntegration|installing applications"
+msgstr "ClusterIntegration | уÑтановка приложений"
+
msgid "ClusterIntegration|meets the requirements"
msgstr "отвечает требованиÑм"
msgid "ClusterIntegration|properly configured"
msgstr "правильно наÑтроен"
+msgid "Collapse"
+msgstr "Свернуть"
+
+msgid "Comment and resolve discussion"
+msgstr "Прокомментировать и закрыть диÑкуÑÑию"
+
+msgid "Comment and unresolve discussion"
+msgstr "Прокомментировать и переоткрыть диÑкуÑÑию"
+
msgid "Comments"
msgstr "Комментарии"
msgid "Commit"
msgid_plural "Commits"
msgstr[0] "Коммит"
-msgstr[1] "Коммиты"
-msgstr[2] "Коммиты"
+msgstr[1] "Коммита"
+msgstr[2] "Коммитов"
+msgstr[3] "Коммиты"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
-msgstr[0] "ЗафикÑировать %d файл"
-msgstr[1] "ЗафикÑировать %d файла"
-msgstr[2] "ЗафикÑировать %d файлов"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] "Коммит (%{commit_count})"
+msgstr[1] "Коммита (%{commit_count})"
+msgstr[2] "Коммитов (%{commit_count})"
+msgstr[3] "Коммитов (%{commit_count})"
msgid "Commit Message"
msgstr "ОпиÑание Коммита"
@@ -646,6 +1278,12 @@ msgstr "ПродолжительноÑÑ‚ÑŒ поÑледних 30 коммитоÐ
msgid "Commit message"
msgstr "ОпиÑание коммита"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "Коммит"
@@ -658,15 +1296,99 @@ msgstr "Коммиты"
msgid "Commits feed"
msgstr "Лента коммитов"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr "Произошла ошибка при получении данных запроÑа на ÑлиÑниÑ."
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "ИÑториÑ"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "ЗафикÑировано автором"
msgid "Compare"
msgstr "Сравнить"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr "Сравнить"
+
+msgid "CompareBranches|Source"
+msgstr "ИÑточник"
+
+msgid "CompareBranches|Target"
+msgstr "Цель"
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr "Ðечего Ñравнивать."
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr "КонфиденциальноÑÑ‚ÑŒ"
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr "Подключение..."
+
msgid "Container Registry"
msgstr "РееÑÑ‚Ñ€ Контейнеров"
@@ -712,12 +1434,30 @@ msgstr "ИÑпользовать различные имена образов"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr "Когда рееÑÑ‚Ñ€ контейнеров Docker интегрирован Ñ GitLab, каждый проект может иметь Ñвое ÑобÑтвенное проÑтранÑтво Ð´Ð»Ñ Ñ…Ñ€Ð°Ð½ÐµÐ½Ð¸Ñ ÐµÐ³Ð¾ Docker образов."
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "РуководÑтво учаÑтника"
msgid "Contributors"
msgstr "УчаÑтники"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr "%{startDate} - %{endDate}"
+
+msgid "ContributorsPage|Building repository graph."
+msgstr "ПоÑтроение графа репозиториÑ."
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr "Коммиты в %{branch_name}, за иÑключением коммитов ÑлиÑниÑ. Ограничено 6,000 коммитами."
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr "ПожалуйÑта подождите, Ñта Ñтраница автоматичеÑки обновитÑÑ Ð¿Ð¾ готовноÑти."
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr "Контролировать макÑимальное количеÑтво потоков фоновой загрузки LFS/вложений Ð´Ð»Ñ Ñтого вторичного узла"
@@ -730,27 +1470,60 @@ msgstr "Скопировать публичный ключ SSH в буфер оÐ
msgid "Copy URL to clipboard"
msgstr "Копировать URL в буфер обмена"
+msgid "Copy branch name to clipboard"
+msgstr "Скопировать Ð¸Ð¼Ñ Ð²ÐµÑ‚ÐºÐ¸ в буфер обмена"
+
+msgid "Copy command to clipboard"
+msgstr "Копировать команду в буфер обмена"
+
msgid "Copy commit SHA to clipboard"
msgstr "Копировать SHA коммита в буфер обмена"
+msgid "Copy reference to clipboard"
+msgstr "Скопировать ÑÑылку в буфер обмена"
+
+msgid "Create"
+msgstr "Создать"
+
msgid "Create New Directory"
msgstr "Создать Ðовый каталог"
+msgid "Create a new branch"
+msgstr "Создать новую ветку"
+
+msgid "Create a new branch and merge request"
+msgstr "Создать новую ветку и Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "Создать личный токен на аккаунте Ð´Ð»Ñ Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð¸Ð»Ð¸ отправки через %{protocol}."
+msgid "Create branch"
+msgstr "Создать ветку"
+
msgid "Create directory"
msgstr "Создать каталог"
-msgid "Create empty bare repository"
-msgstr "Создать пуÑтой репозиторий"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr "Создать Ñпик"
msgid "Create file"
msgstr "Создать файл"
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr "Создать ÑпиÑок из меток. ОбÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ñ Ñтой меткой поÑвлÑÑŽÑ‚ÑÑ Ð² Ñтом ÑпиÑке."
+
msgid "Create merge request"
msgstr "Создать Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+msgid "Create merge request and branch"
+msgstr "Создать Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние и ветку"
+
msgid "Create new branch"
msgstr "Создать новую ветку"
@@ -760,9 +1533,15 @@ msgstr "Создать новый каталог"
msgid "Create new file"
msgstr "Создать новый файл"
+msgid "Create new label"
+msgstr "Создать новую метку"
+
msgid "Create new..."
msgstr "Ðовый"
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "Ответвить"
@@ -772,24 +1551,36 @@ msgstr "Тег"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "Ñоздать перÑональный токен доÑтупа"
+msgid "Creates a new branch from %{branchName}"
+msgstr "Создает новую ветку из %{branchName}"
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr "Создает новую ветку из %{branchName} и перенаправлÑет на Ñоздание нового запроÑа на ÑлиÑние"
+
+msgid "Creating epic"
+msgstr "Создание Ñпика"
+
msgid "Cron Timezone"
msgstr "Ð’Ñ€ÐµÐ¼ÐµÐ½Ð½Ð°Ñ Ð·Ð¾Ð½Ð° Cron"
msgid "Cron syntax"
msgstr "СинтакÑÐ¸Ñ Cron"
+msgid "Current node"
+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 "Customize colors"
+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 "ÐапиÑание кода"
@@ -817,6 +1608,15 @@ msgstr "Ð’Ñе"
msgid "DashboardProjects|Personal"
msgstr "Личные"
+msgid "Dec"
+msgstr "Дек."
+
+msgid "December"
+msgstr "Декабрь"
+
+msgid "Default classification label"
+msgstr "Метка клаÑÑификации по умолчанию"
+
msgid "Define a custom pattern with cron syntax"
msgstr "Определить наÑтраиваемый шаблон Ñ ÑинтакÑиÑом cron"
@@ -825,9 +1625,10 @@ msgstr "Удалить"
msgid "Deploy"
msgid_plural "Deploys"
-msgstr[0] "Развернуть"
-msgstr[1] "Развертывание"
-msgstr[2] "Развертывание"
+msgstr[0] "Развертывание"
+msgstr[1] "РазвертываниÑ"
+msgstr[2] "Развертываний"
+msgstr[3] "РазвертываниÑ"
msgid "Deploy Keys"
msgstr "Ключи РазвертываниÑ"
@@ -836,26 +1637,41 @@ msgid "Description"
msgstr "ОпиÑание"
msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
-msgstr "Шаблоны опиÑаний позволÑÑŽÑ‚ вам определить Ñпецифичные шаблоны Ð·Ð°Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ Ð¾Ð±Ñуждений и запроÑов на ÑлиÑние в вашем проекте."
+msgstr "Шаблоны опиÑаний позволÑÑŽÑ‚ вам определить контекÑтно-завиÑимые шаблоны Ð·Ð°Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ Ð¾Ð±Ñуждений и запроÑов на ÑлиÑние в вашем проекте."
msgid "Details"
msgstr "ÐŸÐ¾Ð´Ñ€Ð¾Ð±Ð½Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ"
+msgid "Diffs|No file name available"
+msgstr "Ð˜Ð¼Ñ Ñ„Ð°Ð¹Ð»Ð° недоÑтупно"
+
msgid "Directory name"
msgstr "Ð˜Ð¼Ñ ÐºÐ°Ñ‚Ð°Ð»Ð¾Ð³Ð°"
-msgid "Discard changes"
-msgstr "Отменить изменениÑ"
+msgid "Disable"
+msgstr "Отключить"
+
+msgid "Discard draft"
+msgstr "Удалить черновик"
+
+msgid "Discover GitLab Geo."
+msgstr "Откройте Ð´Ð»Ñ ÑÐµÐ±Ñ GitLab Geo."
msgid "Dismiss Cycle Analytics introduction box"
msgstr "Отключить блок Ð²Ð²ÐµÐ´ÐµÐ½Ð¸Ñ Ð² Ðналитику Цикла"
msgid "Dismiss Merge Request promotion"
-msgstr "Отключить анонÑÑ‹ Ð´Ð»Ñ Ð—Ð°Ð¿Ñ€Ð¾Ñов на СлиÑние"
+msgstr "Отключить анонÑÑ‹ Ð´Ð»Ñ Ð—Ð°Ð¿Ñ€Ð¾Ñов на ÑлиÑние"
+
+msgid "Documentation for popular identity providers"
+msgstr ""
msgid "Don't show again"
msgstr "Ðе показывать Ñнова"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "Скачать"
@@ -883,15 +1699,171 @@ msgstr "ПроÑтой Diff"
msgid "DownloadSource|Download"
msgstr "Скачать"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr "Срок"
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Редактировать"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Изменить раÑпиÑание Ñборочной линии %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr "Редактируйте файлы в редакторе и зафикÑируйте Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ Ð·Ð´ÐµÑÑŒ"
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr "Email-адреÑа"
+msgid "Enable"
+msgstr "Включить"
+
+msgid "Enable Auto DevOps"
+msgstr "Включить Auto DevOps"
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr "Произошла ошибка при получении окружений."
+
+msgid "Environments|An error occurred while making the request."
+msgstr "Произошла ошибка во Ð²Ñ€ÐµÐ¼Ñ Ð²Ñ‹Ð¿Ð¾Ð»Ð½ÐµÐ½Ð¸Ñ Ð·Ð°Ð¿Ñ€Ð¾Ñа."
+
+msgid "Environments|Commit"
+msgstr "Коммит"
+
+msgid "Environments|Deployment"
+msgstr "Развертывание"
+
+msgid "Environments|Environment"
+msgstr "Окружение"
+
+msgid "Environments|Environments"
+msgstr "ОкружениÑ"
+
+msgid "Environments|Job"
+msgstr "Задание"
+
+msgid "Environments|New environment"
+msgstr "Ðовое окружение"
+
+msgid "Environments|No deployments yet"
+msgstr "Еще нет развертываний"
+
+msgid "Environments|Open"
+msgstr "Открыть"
+
+msgid "Environments|Re-deploy"
+msgstr "Переразвернуть"
+
+msgid "Environments|Read more about environments"
+msgstr "Подробнее об окружениÑÑ…"
+
+msgid "Environments|Rollback"
+msgstr "Откатить"
+
+msgid "Environments|Show all"
+msgstr "Показать вÑе"
+
+msgid "Environments|Updated"
+msgstr "Обновлено"
+
+msgid "Environments|You don't have any environments right now."
+msgstr "Ð’Ñ‹ пока не наÑтроили ни одного окружениÑ."
+
+msgid "Epic will be removed! Are you sure?"
+msgstr "Эпик будет удален! Вы уверены?"
+
+msgid "Epics"
+msgstr "Эпики"
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "Эпики позволÑÑ‚ вам управлÑÑ‚ÑŒ портфелем проектов более Ñффективно и Ñ Ð¼ÐµÐ½ÑŒÑˆÐ¸Ð¼Ð¸ уÑилиÑми"
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr "Ошибка проверки данных ветки. ПожалуйÑта, попробуйте еще раз."
+
+msgid "Error committing changes. Please try again."
+msgstr "Ошибка при Ñохранении изменений. ПожалуйÑта, попробуйте еще раз."
+
+msgid "Error creating epic"
+msgstr "Ошибка ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ñпика"
+
+msgid "Error fetching contributors data."
+msgstr "Ошибка Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð´Ð°Ð½Ð½Ñ‹Ñ… учаÑтников."
+
+msgid "Error fetching labels."
+msgstr "Ошибка Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ð¼ÐµÑ‚Ð¾Ðº."
+
+msgid "Error fetching network graph."
+msgstr "Ошибка Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ Ñетевого графа."
+
+msgid "Error fetching refs"
+msgstr "Ошибка Ð¿Ð¾Ð»ÑƒÑ‡ÐµÐ½Ð¸Ñ ÑÑылок"
+
+msgid "Error fetching usage ping data."
+msgstr "Ошибка при получении данных об иÑпользовании ping."
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr "Произошла ошибка при переключении подпиÑки на оповещение"
+
+msgid "Error saving label update."
+msgstr "Ошибка при обновлении метки."
+
+msgid "Error updating status for all todos."
+msgstr "Ошибка при обновлении ÑтатуÑа Ð´Ð»Ñ Ð²Ñех todo."
+
+msgid "Error updating todo status."
+msgstr "Ошибка при обновлении ÑтатуÑа todo."
+
msgid "EventFilterBy|Filter by all"
msgstr "Фильтр по вÑему"
@@ -919,24 +1891,75 @@ msgstr "ЕжемеÑÑчно (каждое 1-е чиÑло в 4:00)"
msgid "Every week (Sundays at 4:00am)"
msgstr "Еженедельно (по воÑкреÑениÑм в 4:00)"
+msgid "Expand"
+msgstr "Развернуть"
+
msgid "Explore projects"
msgstr "Обзор проектов"
msgid "Explore public groups"
msgstr "ИÑÑледовать публичные группы"
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr "Ðевыполненные ЗаданиÑ"
+
msgid "Failed to change the owner"
msgstr "Ðе удалоÑÑŒ изменить владельца"
+msgid "Failed to remove issue from board, please try again."
+msgstr "Ошибка при удалении обÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ñ Ð´Ð¾Ñки, повторите попытку."
+
msgid "Failed to remove the pipeline schedule"
msgstr "Ðе удалоÑÑŒ удалить раÑпиÑание Ñборочной линии"
+msgid "Failed to update issues, please try again."
+msgstr "Ошибка Ð¾Ð±Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ Ð¾Ð±Ñуждений, пожалуйÑта, попробуйте Ñнова."
+
+msgid "Feb"
+msgstr "Фев."
+
+msgid "February"
+msgstr "Февраль"
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr "ÐŸÐ¾Ð»Ñ Ð½Ð° Ñтой Ñтранице ÑÐµÐ¹Ñ‡Ð°Ñ Ð½ÐµÐ´Ð¾Ñтупны Ð´Ð»Ñ Ñ€ÐµÐ´Ð°ÐºÑ‚Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ, вы можете наÑтроить"
+
msgid "File name"
msgstr "Ð˜Ð¼Ñ Ñ„Ð°Ð¹Ð»Ð°"
msgid "Files"
msgstr "Файлы"
+msgid "Files (%{human_size})"
+msgstr "Файлов (%{human_size})"
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "Фильтр по комментариÑми к коммитам"
@@ -946,17 +1969,27 @@ msgstr "ПоиÑк по пути"
msgid "Find file"
msgstr "Ðайти файл"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "Первый"
msgid "FirstPushedBy|pushed by"
msgstr "отправлено автором"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Ответвление"
msgstr[1] "ОтветвлениÑ"
-msgstr[2] "ОтветвлениÑ"
+msgstr[2] "Ответвлений"
+msgstr[3] "ОтветвлениÑ"
msgid "ForkedFromProjectPath|Forked from"
msgstr "Ответвлено от"
@@ -964,26 +1997,179 @@ msgstr "Ответвлено от"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr "Ответвление от %{project_name} (удалено)"
+msgid "Forking in progress"
+msgstr "ВыполнÑетÑÑ Ð¾Ñ‚Ð²ÐµÑ‚Ð²Ð»ÐµÐ½Ð¸Ðµ"
+
msgid "Format"
msgstr "Формат"
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr "От ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð¾Ð±ÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ð´Ð¾ Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ñ€ÐµÐ°Ð»Ð¸Ð·Ð°Ñ†Ð¸Ð¸ в рабочей Ñреде"
msgid "From merge request merge until deploy to production"
msgstr "От запроÑа на ÑлиÑние до Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð² рабочей Ñреде"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr "GPG Ключи"
+msgid "Generate a default set of labels"
+msgstr "Создать Ñтандартный набор меток"
+
msgid "Geo Nodes"
msgstr "ГеографичеÑкие Узлы"
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr "Ðа узле Ñбой или он не работает."
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr "Узел функционирует медленно, перегужен или только что воÑÑтановлен поÑле ÑбоÑ."
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr "Объем хранилища Ð´Ð»Ñ Ñинхронизации файлов"
-msgid "Geo|Groups to replicate"
-msgstr "Группы Ð´Ð»Ñ Ñ€ÐµÐ¿Ð»Ð¸ÐºÐ°Ñ†Ð¸Ð¸"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
+msgstr ""
msgid "Geo|Repository sync capacity"
msgstr "Объем хранилища Ð´Ð»Ñ Ñинхронизации репозиториÑ"
@@ -991,12 +2177,45 @@ msgstr "Объем хранилища Ð´Ð»Ñ Ñинхронизации репо
msgid "Geo|Select groups to replicate."
msgstr "Выберите группы Ð´Ð»Ñ Ñ€ÐµÐ¿Ð»Ð¸ÐºÐ°Ñ†Ð¸Ð¸."
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr "URL-Ð°Ð´Ñ€ÐµÑ Ñ€ÐµÐ¿Ð¾Ð·Ð¸Ñ‚Ð¾Ñ€Ð¸Ñ Git"
+
+msgid "Git revision"
+msgstr "Ð ÐµÐ²Ð¸Ð·Ð¸Ñ git"
+
msgid "Git storage health information has been reset"
msgstr "Ð˜Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ Ð¾ ÑтабильноÑти Git хранилища была Ñброшена"
+msgid "Git version"
+msgstr "ВерÑÐ¸Ñ Git"
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "Ð¡ÐµÐºÑ†Ð¸Ñ Gitlab Runner"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr "Серверы Gitaly"
+
+msgid "Go back"
+msgstr "ВернутьÑÑ"
+
msgid "Go to your fork"
msgstr "Перейти к вашему ответвлению"
@@ -1006,6 +2225,27 @@ msgstr "Ответвление"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr "ÐÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ñ Google не %{link_to_documentation}. ПопроÑите Ñвоего админиÑтратора GitLab, еÑли вы хотите воÑпользоватьÑÑ Ñтим ÑервиÑом."
+msgid "Got it!"
+msgstr "ПонÑтно!"
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr "Запретить публикацию проектов из %{group} в других группах"
@@ -1042,12 +2282,6 @@ msgstr "Группы не найдены"
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "Ð’Ñ‹ можете управлÑÑ‚ÑŒ правами и доÑтупом учаÑтников вашей группы к каждому проекту в группе."
-msgid "GroupsTreeRole|as"
-msgstr "как"
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr "Вы уверены, что вы хотите покинуть группу \"${this.group.fullName}\"?"
-
msgid "GroupsTree|Create a project in this group."
msgstr "Создать проект в Ñтой группе."
@@ -1075,6 +2309,12 @@ msgstr "К Ñожалению, по вашему запроÑу групп не
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr "К Ñожалению, по вашему запроÑу групп или проектов не найдено"
+msgid "Have your users email"
+msgstr "Ð­Ð»ÐµÐºÑ‚Ñ€Ð¾Ð½Ð½Ð°Ñ Ð¿Ð¾Ñ‡Ñ‚Ð° Ð´Ð»Ñ Ð¾Ð±Ñ€Ð°Ñ‰ÐµÐ½Ð¸Ð¹ пользователей"
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "Проверка работоÑпоÑобноÑти"
@@ -1093,23 +2333,72 @@ msgstr "Проблем работоÑпоÑобноÑти не обнаружеÐ
msgid "HealthCheck|Unhealthy"
msgstr "ÐеÑтабильный"
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
msgid "History"
msgstr "ИÑториÑ"
msgid "Housekeeping successfully started"
msgstr "ОчиÑтка уÑпешно запущена"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr "ВыполнÑетÑÑ Ð¸Ð¼Ð¿Ð¾Ñ€Ñ‚"
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "Импорт репозиториÑ"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
-msgstr "Улучшить доÑки обÑуждений Ñ Ð¿Ð¾Ð¼Ð¾Ñ‰ÑŒÑŽ верÑии GitLab Enterprise Edition."
+msgstr "Улучшить доÑки обÑуждений Ñ Ð¿Ð¾Ð¼Ð¾Ñ‰ÑŒÑŽ GitLab Enterprise Edition."
msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
-msgstr "Улучшить управление обÑуждениÑми Ñ Ð²Ð¾Ð·Ð¼Ð¾Ð¶Ð½Ð¾Ñтью Ð¾Ð¿Ñ€ÐµÐ´ÐµÐ»ÐµÐ½Ð¸Ñ Ð²ÐµÑа обÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ð¿Ñ€Ð¸ помощи GitLab Enterprise Edition."
+msgstr "Улучшить управление обÑуждениÑми возможноÑтью Ð¾Ð¿Ñ€ÐµÐ´ÐµÐ»ÐµÐ½Ð¸Ñ Ð²ÐµÑа обÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ð¸ GitLab Enterprise Edition."
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
-msgstr "Улучшить поиÑк при помощи РаÑширенного Глобального ПоиÑка в верÑии GitLab Enterprise Edition."
+msgstr "Улучшить поиÑк при помощи РаÑширенного Глобального ПоиÑка и GitLab Enterprise Edition."
+
+msgid "Install Runner on Kubernetes"
+msgstr "УÑтановить Runner на Kubernetes"
msgid "Install a Runner compatible with GitLab CI"
msgstr "УÑтановите Gitlab Runner ÑовмеÑтимый Ñ Gitlab CI"
@@ -1117,8 +2406,18 @@ msgstr "УÑтановите Gitlab Runner ÑовмеÑтимый Ñ Gitlab CI"
msgid "Instance"
msgid_plural "Instances"
msgstr[0] "ЭкземплÑÑ€"
-msgstr[1] "ЭкземплÑры"
-msgstr[2] "ЭкземплÑры"
+msgstr[1] "ЭкземплÑра"
+msgstr[2] "ЭкземплÑров"
+msgstr[3] "ЭкземплÑры"
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr "ЭкземплÑÑ€ не поддерживает неÑколько клаÑтеров Kubernetes"
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr "Внутренний - Группу и включённые в неё проекты может видеть любой зарегиÑтрированный пользователь."
@@ -1135,9 +2434,6 @@ msgstr "Внедрение Цикла Ðналитик"
msgid "Issue board focus mode"
msgstr "Режим фокуÑировки над доÑкой обÑуждений"
-msgid "Issue boards with milestones"
-msgstr "ДоÑки обÑуждений Ñ Ð²ÐµÑ…Ð°Ð¼Ð¸"
-
msgid "Issue events"
msgstr "Ð¡Ð¾Ð±Ñ‹Ñ‚Ð¸Ñ Ð¾Ð±Ñуждений"
@@ -1150,20 +2446,93 @@ msgstr "ДоÑки"
msgid "Issues"
msgstr "ОбÑуждениÑ"
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr "Янв."
+
+msgid "January"
+msgstr "Январь"
+
+msgid "Jobs"
+msgstr "ЗаданиÑ"
+
+msgid "Jul"
+msgstr "Июл."
+
+msgid "July"
+msgstr "Июль"
+
+msgid "Jun"
+msgstr "Июн."
+
+msgid "June"
+msgstr "Июнь"
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr "Kubernetes"
+
+msgid "Kubernetes Cluster"
+msgstr "КлаÑтер Kubernetes"
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr "Ð’Ñ€ÐµÐ¼Ñ ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ ÐºÐ»Ð°Ñтера Kubernetes превышает Ð²Ñ€ÐµÐ¼Ñ Ð¾Ð¶Ð¸Ð´Ð°Ð½Ð¸Ñ; %{timeout}"
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтера Кубернете не была удалена."
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr "Ð˜Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ð¸Ñ ÐºÐ»Ð°Ñтера Kubernetes была уÑпешно удалена."
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr "КлаÑтер Kubernetes был уÑпешно обновлён."
+
+msgid "Kubernetes configured"
+msgstr "Kubernetes наÑтроен"
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "Отключено"
msgid "LFSStatus|Enabled"
msgstr "Включено"
+msgid "Label"
+msgstr "Метка"
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr "Метки"
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "ПоÑледний %d день"
-msgstr[1] "ПоÑледние %d дни"
-msgstr[2] "ПоÑледние %d дни"
+msgstr[1] "ПоÑледние %d днÑ"
+msgstr[2] "ПоÑледние %d дней"
+msgstr[3] "ПоÑледние %d дни"
msgid "Last Pipeline"
msgstr "ПоÑледнÑÑ Ð¡Ð±Ð¾Ñ€Ð¾Ñ‡Ð½Ð°Ñ Ð›Ð¸Ð½Ð¸Ñ"
@@ -1189,6 +2558,15 @@ msgstr "Вы отправили в"
msgid "LastPushEvent|at"
msgstr "в"
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "Узнайте больше в"
@@ -1207,33 +2585,78 @@ msgstr "Покинуть проект"
msgid "License"
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 "List"
+msgstr "СпиÑок"
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr "Загрузка GitLab IDE..."
msgid "Lock"
msgstr "Блокировка"
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr "Заблокировано"
msgid "Locked Files"
msgstr "Заблокированные Файлы"
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr "Войти"
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr "Мар."
+
+msgid "March"
+msgstr "Март"
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr "МакÑимальное количеÑтво Ñбоев хранилища git"
+msgid "May"
+msgstr "Май"
+
msgid "Median"
msgstr "Среднее"
msgid "Members"
msgstr "УчаÑтники"
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr "ЗапроÑÑ‹ на СлиÑние"
@@ -1243,29 +2666,159 @@ msgstr "Ð¡Ð¾Ð±Ñ‹Ñ‚Ð¸Ñ ÑлиÑний"
msgid "Merge request"
msgstr "Ð—Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr "СообщениÑ"
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "добавить ключ SSH"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr "Мониторинг"
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr "Больше информации доÑтупно|тут"
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
-msgstr "Сводные доÑки задач"
+msgstr "Сводные доÑки обÑуждений"
-msgid "New Cluster"
-msgstr "Ðовый КлаÑтер"
+msgid "Name new label"
+msgstr ""
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "Ðовое ОбÑуждение"
msgstr[1] "Ðовых ОбÑуждениÑ"
msgstr[2] "Ðовых ОбÑуждений"
+msgstr[3] "Ðовые ОбÑуждениÑ"
+
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
msgid "New Pipeline Schedule"
msgstr "Ðовое РаÑпиÑание Сборочной Линии"
@@ -1273,9 +2826,15 @@ msgstr "Ðовое РаÑпиÑание Сборочной Линии"
msgid "New branch"
msgstr "ÐÐ¾Ð²Ð°Ñ Ð²ÐµÑ‚ÐºÐ°"
+msgid "New branch unavailable"
+msgstr "ÐÐ¾Ð²Ð°Ñ Ð²ÐµÑ‚ÐºÐ° недоÑтупна"
+
msgid "New directory"
msgstr "Ðовый каталог"
+msgid "New epic"
+msgstr "Ðовый Ñпик"
+
msgid "New file"
msgstr "Ðовый файл"
@@ -1285,6 +2844,9 @@ msgstr "ÐÐ¾Ð²Ð°Ñ Ð³Ñ€ÑƒÐ¿Ð¿Ð°"
msgid "New issue"
msgstr "Ðовое обÑуждение"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "Ðовый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
@@ -1303,8 +2865,29 @@ msgstr "ÐÐ¾Ð²Ð°Ñ Ð¿Ð¾Ð´Ð³Ñ€ÑƒÐ¿Ð¿Ð°"
msgid "New tag"
msgstr "Ðовый тег"
-msgid "No container images stored for this project. Add one by following the instructions above."
-msgstr "Ðет образов контейнеров Ð´Ð»Ñ Ñтого проекта. Добавьте образ, ÑÐ»ÐµÐ´ÑƒÑ Ð¸Ð½ÑтрукциÑм выше."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
+msgstr ""
msgid "No repository"
msgstr "Ðет репозиториÑ"
@@ -1315,12 +2898,39 @@ msgstr "Ðет раÑпиÑаний"
msgid "None"
msgstr "ПуÑто"
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "ÐедоÑтупно"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "ÐедоÑтаточно данных"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "Ð£Ð²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ð¾ ÑобытиÑÑ…"
@@ -1378,18 +2988,48 @@ msgstr "ОтÑлеживать"
msgid "Notifications"
msgstr "УведомлениÑ"
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr "ÐоÑб."
+
+msgid "November"
+msgstr "ÐоÑбрь"
+
msgid "Number of access attempts"
msgstr "КоличеÑтво попыток доÑтупа"
-msgid "Number of failures before backing off"
+msgid "OK"
msgstr ""
+msgid "Oct"
+msgstr "Окт."
+
+msgid "October"
+msgstr "ОктÑбрь"
+
msgid "OfSearchInADropdown|Filter"
msgstr "Фильтр"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr "Только учаÑтники проекта могут оÑтавлÑÑ‚ÑŒ комментарии."
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr "Открыт"
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Открыто"
@@ -1399,12 +3039,21 @@ msgstr "ОткроетÑÑ Ð² новом окне"
msgid "Options"
msgstr "ÐаÑтройки"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr "Обзор"
msgid "Owner"
msgstr "Владелец"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr "ПоÑледнÑÑ Â»"
@@ -1417,11 +3066,20 @@ msgstr "ПредыдущаÑ"
msgid "Pagination|« First"
msgstr "« ПерваÑ"
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr "Пароль"
-msgid "People without permission will never get a notification and won\\'t be able to comment."
-msgstr "Люди без разрешений не получат уведомление и не Ñмогут комментировать."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr ""
msgid "Pipeline"
msgstr "Ð¡Ð±Ð¾Ñ€Ð¾Ñ‡Ð½Ð°Ñ Ð»Ð¸Ð½Ð¸Ñ"
@@ -1465,12 +3123,6 @@ msgstr "Ð’Ñе"
msgid "PipelineSchedules|Inactive"
msgstr "Ðеактивно"
-msgid "PipelineSchedules|Input variable key"
-msgstr "Ввод ключевой переменной"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "Ð’Ñтавить значение"
-
msgid "PipelineSchedules|Next Run"
msgstr "Следующий запуÑк"
@@ -1480,9 +3132,6 @@ msgstr "ОтÑутÑтвует"
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "ПредоÑтавьте краткое опиÑание Ñтой Ñборочной линии"
-msgid "PipelineSchedules|Remove variable row"
-msgstr "Удалить значение"
-
msgid "PipelineSchedules|Take ownership"
msgstr "Стать владельцем"
@@ -1510,6 +3159,57 @@ msgstr "Сборочные линии за поÑледнюю неделю"
msgid "Pipelines for last year"
msgstr "Сборочные линии за поÑледний год"
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "вÑе"
@@ -1522,15 +3222,39 @@ msgstr "Ñо Ñтадией"
msgid "Pipeline|with stages"
msgstr "Ñо ÑтадиÑми"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr "ПожалуйÑта, решите reCAPTCHA"
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr "ПредпочтениÑ"
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr "Приватный - ДоÑтуп к проекту должен предоÑтавлÑÑ‚ÑŒÑÑ Ñвно каждому пользователю."
msgid "Private - The group and its projects can only be viewed by members."
msgstr "ÐŸÑ€Ð¸Ð²Ð°Ñ‚Ð½Ð°Ñ - Группу и включённые в неё проекты могут видеть только члены группы."
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr "Профиль"
@@ -1570,6 +3294,12 @@ msgstr "Ваша ÑƒÑ‡ÐµÑ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ в наÑтоÑщее Ð²Ñ€ÐµÐ¼Ñ Ñ
msgid "Profiles|your account"
msgstr "ваша ÑƒÑ‡ÐµÑ‚Ð½Ð°Ñ Ð·Ð°Ð¿Ð¸ÑÑŒ"
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr "Проект '%{project_name}' находитÑÑ Ð² процеÑÑе удалениÑ."
@@ -1585,6 +3315,12 @@ msgstr "Проект '%{project_name}' уÑпешно обновлен."
msgid "Project access must be granted explicitly to each user."
msgstr "ДоÑтуп к проекту должен предоÑтавлÑÑ‚ÑŒÑÑ Ñвно каждому пользователю."
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr "Детали проекта"
@@ -1603,6 +3339,21 @@ msgstr "Ðачат ÑкÑпорт проекта. СÑылка Ð´Ð»Ñ Ñкачи
msgid "ProjectActivityRSS|Subscribe"
msgstr "ПодпиÑатьÑÑ"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "Отключено"
@@ -1640,7 +3391,7 @@ msgid "ProjectSettings|This setting will be applied to all projects unless overr
msgstr "Эта наÑтройка будет применена Ð´Ð»Ñ Ð²Ñех проектов, еÑли иное поведение не переопределено админиÑтратором."
msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
-msgstr ""
+msgstr "Пользователи могут отправлÑÑ‚ÑŒ коммиты в данный репозиторий, только в Ñлучае еÑли коммит подпиÑан одним из подтвержденных адреÑов почты."
msgid "Projects"
msgstr "Проекты"
@@ -1666,6 +3417,93 @@ msgstr "К Ñожалению, по вашему запроÑу проекты Ð
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr "Эта функциональноÑÑ‚ÑŒ требует поддержки localStorage в вашем браузере"
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr "По умолчанию, Prometheus запуÑкаетÑÑ Ð¿Ð¾ адреÑу ‘http://localhost:9090’. Ðе рекомендуетÑÑ Ð¸Ð·Ð¼ÐµÐ½ÑÑ‚ÑŒ Ð°Ð´Ñ€ÐµÑ Ð¸ порт по умолчанию, так как Ñто может привеÑти к конфликту Ñ Ð´Ñ€ÑƒÐ³Ð¸Ð¼ ÑервиÑами запущенными на GitLab Ñервере."
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr "Определение и наÑтройка метрик..."
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr "Метрики"
+
+msgid "PrometheusService|Missing environment variable"
+msgstr "Пропущена Ð¿ÐµÑ€ÐµÐ¼ÐµÐ½Ð½Ð°Ñ Ð¾ÐºÑ€ÑƒÐ¶ÐµÐ½Ð¸Ñ"
+
+msgid "PrometheusService|More information"
+msgstr "Ð”Ð¾Ð¿Ð¾Ð»Ð½Ð¸Ñ‚ÐµÐ»ÑŒÐ½Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ"
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr "Базовый Ð°Ð´Ñ€ÐµÑ Prometheus API, например http://prometheus.example.com/"
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr "Публичный - Группу и включённые в неё проекты могут видеть вÑе, без какой-либо проверки подлинноÑти."
@@ -1678,7 +3516,16 @@ msgstr "Правила Отправки"
msgid "Push events"
msgstr "Ð¡Ð¾Ð±Ñ‹Ñ‚Ð¸Ñ Ð¾Ñ‚Ð¿Ñ€Ð°Ð²ÐºÐ¸"
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
+msgstr "ÐžÐ³Ñ€Ð°Ð½Ð¸Ñ‡ÐµÐ½Ð¸Ñ Ð´Ð»Ñ ÐºÐ¾Ð¼Ð¼Ð¸Ñ‚ÐµÑ€Ð°"
+
+msgid "Quick actions can be used in the issues description and comment boxes."
msgstr ""
msgid "Read more"
@@ -1687,12 +3534,21 @@ msgstr "Подробнее"
msgid "Readme"
msgstr "ИнÑтрукциÑ"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "Ветки"
msgid "RefSwitcher|Tags"
msgstr "Теги"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr "РееÑÑ‚Ñ€"
@@ -1700,13 +3556,13 @@ msgid "Related Commits"
msgstr "СвÑзанные коммиты"
msgid "Related Deployed Jobs"
-msgstr "СвÑзанные задачи выгрузки"
+msgstr "СвÑзанные Ð—Ð°Ð´Ð°Ð½Ð¸Ñ Ð Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ"
msgid "Related Issues"
msgstr "СвÑзанные ОбÑуждениÑ"
msgid "Related Jobs"
-msgstr "СвÑзанные задачи"
+msgstr "СвÑзанные ЗаданиÑ"
msgid "Related Merge Requests"
msgstr "СвÑзанные ЗапроÑÑ‹ на СлиÑние"
@@ -1714,15 +3570,42 @@ msgstr "СвÑзанные ЗапроÑÑ‹ на СлиÑние"
msgid "Related Merged Requests"
msgstr "СвÑзанные Влитые ЗапроÑÑ‹"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "Ðапомнить позже"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "Удалить проект"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr "Репозиторий"
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "Ð—Ð°Ð¿Ñ€Ð¾Ñ Ð´Ð¾Ñтупа"
@@ -1735,68 +3618,174 @@ msgstr "СброÑить ключ доÑтупа проверки работоÑ
msgid "Reset runners registration token"
msgstr "СброÑить ключ региÑтрации Gitlab Runners"
+msgid "Resolve discussion"
+msgstr "Закрыть диÑкуÑÑию"
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
msgid "Revert this commit"
msgstr "Отменить Ñто коммит"
msgid "Revert this merge request"
msgstr "Отменить Ñтот Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
msgid "SSH Keys"
msgstr "SSH Ключи"
-msgid "Save"
-msgstr "Сохранить"
-
msgid "Save changes"
msgstr "Сохранить изменениÑ"
msgid "Save pipeline schedule"
msgstr "Сохранить раÑпиÑание Ñборочной лини"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "РаÑпиÑание новой Ñборочной линии"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr "РаÑпиÑаниÑ"
msgid "Scheduling Pipelines"
msgstr "Планирование Сборочных Линий"
+msgid "Scoped issue boards"
+msgstr "ТематичеÑкие доÑки обÑуждений"
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "Ðайти ветки и теги"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
msgid "Seconds before reseting failure information"
msgstr "Секунд до очиÑтки информации о ÑбоÑÑ…"
-msgid "Seconds to wait after a storage failure"
-msgstr "Секунд задержки поÑле ÑÐ±Ð¾Ñ Ð² хранилище"
-
msgid "Seconds to wait for a storage access attempt"
msgstr "Секунд задержки между попытками доÑтупа к хранилищу"
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "Выбрать формат архива"
msgid "Select a timezone"
msgstr "Выбор временной зоны"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "Выбор целевой ветки"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr "Сент."
+
+msgid "September"
+msgstr "СентÑбрь"
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr "Шаблоны Служб"
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr "УÑтановите пароль в Ñвоем аккаунте, чтобы отправлÑÑ‚ÑŒ или получать код через %{protocol}."
-msgid "Set up CI"
-msgstr "ÐаÑтройка CI"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "ÐаÑтройка Koding"
-msgid "Set up auto deploy"
-msgstr "ÐаÑтройка автоматичеÑкого развертываниÑ"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "уÑтановите пароль"
@@ -1804,6 +3793,24 @@ msgstr "уÑтановите пароль"
msgid "Settings"
msgstr "ÐаÑтройки"
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr "Показать родительÑкие Ñтраницы"
@@ -1813,17 +3820,51 @@ msgstr "Показать родительÑкие подгруппы"
msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] "Показано %d Ñобытие"
-msgstr[1] "Показано %d Ñобытий"
+msgstr[1] "Показано %d ÑобытиÑ"
msgstr[2] "Показано %d Ñобытий"
+msgstr[3] "Показано %d Ñобытий"
+
+msgid "Sidebar|Change weight"
+msgstr "Изменить веÑ"
+
+msgid "Sidebar|No"
+msgstr "Ðет"
+
+msgid "Sidebar|None"
+msgstr "ОтÑутÑтвует"
+
+msgid "Sidebar|Weight"
+msgstr "ВеÑ"
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
msgid "Snippets"
msgstr "Сниппеты"
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
-msgstr "У Ð½Ð°Ñ Ñ‡Ñ‚Ð¾-то пошло не так."
+msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
-msgstr "Что-то пошло не так при попытке Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ Ð·Ð°Ð±Ð»Ð¾ÐºÐ¸Ñ€Ð¾Ð²Ð°Ð½Ð½Ð¾Ð³Ð¾ ÑоÑтоÑÐ½Ð¸Ñ Ñтого ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
+msgstr ""
msgid "Something went wrong while fetching the projects."
msgstr "Что-то пошло не так при получении проектов."
@@ -1831,6 +3872,9 @@ msgstr "Что-то пошло не так при получении проекÑ
msgid "Something went wrong while fetching the registry list."
msgstr "Что-то пошло не так при получении ÑпиÑка рееÑтров."
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr "Сортировать по"
@@ -1930,18 +3974,36 @@ msgstr "Ðачатые недавно"
msgid "SortOptions|Weight"
msgstr "ВеÑ"
+msgid "Source"
+msgstr "ИÑточник"
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "ИÑходный код"
+msgid "Source is not available"
+msgstr "ИÑходный текÑÑ‚ недоÑтупен"
+
msgid "Spam Logs"
msgstr "Спам Логи"
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "Укажите Ñледующий URL во Ð²Ñ€ÐµÐ¼Ñ Ð½Ð°Ñтройки Gitlab Runner:"
msgid "StarProject|Star"
msgstr "Отметить"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr "Отмеченные проекты"
@@ -1951,30 +4013,121 @@ msgstr "Ðачать %{new_merge_request} Ñ Ñтих изменений"
msgid "Start the Runner!"
msgstr "ЗапуÑтить GitLab Runner!"
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr "ОÑтановлен"
+
+msgid "Storage"
+msgstr ""
+
msgid "Subgroups"
msgstr "Подгруппы"
-msgid "Subscribe"
-msgstr "ПодпиÑатьÑÑ"
-
msgid "Switch branch/tag"
msgstr "Переключить ветка/тег"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr "СиÑтемные Обработчики"
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "Тег"
-msgstr[1] "Теги"
-msgstr[2] "Теги"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
msgid "Tags"
msgstr "Теги"
+msgid "TagsPage|Browse commits"
+msgstr "ПроÑмотреть коммиты"
+
+msgid "TagsPage|Browse files"
+msgstr "ПроÑмотреть файлы"
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr "Ðевозможно найти HEAD коммит Ð´Ð»Ñ Ñтого тега"
+
+msgid "TagsPage|Cancel"
+msgstr "Отмена"
+
+msgid "TagsPage|Create tag"
+msgstr "Создать тег"
+
+msgid "TagsPage|Delete tag"
+msgstr "Удалить тег"
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr "Удаление тега %{tag_name} не может быть отменено. Вы уверены?"
+
+msgid "TagsPage|Edit release notes"
+msgstr "Редактировать заметки к релизу"
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr "Ð˜Ð¼Ñ ÑущеÑтвующей ветки, тега или SHA коммита"
+
+msgid "TagsPage|Filter by tag name"
+msgstr "Фильтр по имени тега"
+
+msgid "TagsPage|New Tag"
+msgstr "Ðовый Тег"
+
+msgid "TagsPage|New tag"
+msgstr "Ðовый тег"
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr "При желании вы можете добавить Ñообщение в тег."
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr "Опционально, добавьте опиÑание релиза к тегу. Оно будет Ñохранено в базе данных GitLab и отобразитÑÑ Ð½Ð° Ñтранице тегов."
+
+msgid "TagsPage|Release notes"
+msgstr "Заметки к релизу"
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr "Репозитарий не Ñодержит тегов."
+
+msgid "TagsPage|Sort by"
+msgstr "Сортировать по"
+
+msgid "TagsPage|Tags"
+msgstr "Теги"
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr "Теги дают возможноÑÑ‚ÑŒ отмечать определенные точки в иÑтории как важные"
+
+msgid "TagsPage|This tag has no release notes."
+msgstr "Этот тег не Ñодержит заметок к релизу."
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr "ИÑпользуйте команду git tag, чтобы добавить новый тег:"
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr "Ðапишите Ñвои заметки к релизу или перетащите файлы Ñюда..."
+
+msgid "TagsPage|protected"
+msgstr "защищенный"
+
msgid "Target Branch"
msgstr "Ветка"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr "Команда"
@@ -1982,10 +4135,16 @@ msgid "Thanks! Don't show me this again"
msgstr "СпаÑибо! Больше не показывайте мне Ñто Ñообщение"
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
-msgstr "РаÑширенный глобальный поиÑк в GitLab - Ñто Ñерьезный инÑтрумент который Ñокращает ваше времÑ. ВмеÑто ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð´ÑƒÐ±Ð»Ð¸Ñ€ÑƒÑŽÑ‰ÐµÐ³Ð¾ кода и траты времени, вы можете иÑкать код внутри других команд, который поможет вам в вашем проекте."
+msgstr "РаÑширенный глобальный поиÑк в GitLab - Ñто мощный инÑтрумент Ð´Ð»Ñ Ð¿Ð¾Ð¸Ñка, который Ñокращает ваше времÑ. ВмеÑто ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð´ÑƒÐ±Ð»Ð¸Ñ€ÑƒÑŽÑ‰ÐµÐ³Ð¾ кода и траты времени, вы можете иÑкать код внутри других команд, который поможет вам в вашем проекте."
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
-msgstr "Порог ÑÑ€Ð°Ð±Ð°Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð´Ð»Ñ Ð¡ircuitBreaker должен быть меньше, чем порог ÑÑ€Ð°Ð±Ð°Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð´Ð»Ñ Ð¾Ð¿Ñ€ÐµÐ´ÐµÐ»ÐµÐ½Ð¸Ñ ÑбоÑ"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+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 "Этап напиÑÐ°Ð½Ð¸Ñ ÐºÐ¾Ð´Ð° показывает Ð²Ñ€ÐµÐ¼Ñ Ñ Ð¿ÐµÑ€Ð²Ð¾Ð³Ð¾ коммита до ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð·Ð°Ð¿Ñ€Ð¾Ñа на ÑлиÑние. Данные автоматичеÑки добавÑÑ‚ÑÑ Ñюда поÑле того, как вы Ñоздать Ñвой первый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние."
@@ -1993,30 +4152,39 @@ msgstr "Этап напиÑÐ°Ð½Ð¸Ñ ÐºÐ¾Ð´Ð° показывает Ð²Ñ€ÐµÐ¼Ñ Ñ
msgid "The collection of events added to the data gathered for that stage."
msgstr "ÐšÐ¾Ð»Ð»ÐµÐºÑ†Ð¸Ñ Ñобытий добавленных в данные Ñобранные Ð´Ð»Ñ Ñтого Ñтапа."
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "СвÑзь Ñ Ð¾Ñ‚Ð²ÐµÑ‚Ð²Ð»ÐµÐ½Ð¸ÐµÐ¼ удалена."
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 maximum file size allowed is 200KB."
+msgstr ""
+
msgid "The number of attempts GitLab will make to access a storage."
msgstr "КоличеÑтво попыток, которые GitLab будет предпринимать Ð´Ð»Ñ Ð´Ð¾Ñтупа к хранилищу."
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
-msgstr "КоличеÑтво ошибок, поÑле которого GitLab начнёт временно отключать доÑтуп к шарду хранилища на хоÑте"
-
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr "КоличеÑтво Ñбоев, поÑле которого Gitlab полноÑтью прекратит доÑтуп к хранилищу. Изменение Ñчётчика \"количеÑтво Ñбоев\" может быть произведено через админиÑтративный интерфейÑ: %{link_to_health_page} или при помощи API %{api_documentation_link}."
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "ПроизводÑтвенный Ñтап показывает общее Ð²Ñ€ÐµÐ¼Ñ Ð¼ÐµÐ¶Ð´Ñƒ Ñозданием обÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ð¸ развертыванием кода в продуктивной Ñреде. Данные будут автоматичеÑки добавлены поÑле полного Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð¸Ñ Ð¸Ð´ÐµÐ¸."
@@ -2029,9 +4197,18 @@ msgstr "ДоÑтуп к проекту возможен без какой-либ
msgid "The repository for this project does not exist."
msgstr "Репозиторий Ð´Ð»Ñ Ñтого проекта не ÑущеÑтвует."
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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\" и развертыванием кода в производÑтвенной Ñреде. Данные будут автоматичеÑки добавлены поÑле Ñ€Ð°Ð·Ð²ÐµÑ€Ñ‚Ñ‹Ð²Ð°Ð½Ð¸Ñ Ð² производÑтве первый раз."
@@ -2044,17 +4221,50 @@ msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð² Ñекундах, в течение которого GitLa
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð² Ñекундах в течении которого GitLab будет пытатьÑÑ Ð¿Ð¾Ð»ÑƒÑ‡Ð¸Ñ‚ÑŒ доÑтуп к хранилищу. ПоÑле Ñтого времени будет зафикÑирована ошибка Ð¿Ñ€ÐµÐ²Ñ‹ÑˆÐµÐ½Ð¸Ñ Ð²Ñ€ÐµÐ¼ÐµÐ½Ð¸ ожиданиÑ."
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
msgid "The time taken by each data entry gathered by that stage."
msgstr "ВремÑ, затраченное каждым Ñлементом, Ñобранным на Ñтом Ñтапе."
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr "Среднее значение в Ñ€Ñду. Пример: между 3, 5, 9, Ñреднее 5, между 3, 5, 7, 8, Ñреднее (5+7)/2 = 6."
+msgid "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr "Проблемы Ñ Ð´Ð¾Ñтупом к Git хранилищу: "
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
-msgstr "Эта ветка была изменена, пока вы её редактировали. Ð’Ñ‹ хотите Ñоздать новую ветку?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr "УÑтановлен отбор"
+
+msgid "This directory"
+msgstr ""
msgid "This is a confidential issue."
msgstr "Это конфиденциальное обÑуждение."
@@ -2062,18 +4272,54 @@ msgstr "Это конфиденциальное обÑуждение."
msgid "This is the author's first Merge Request to this project."
msgstr "Это первый Ð—Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние от автора в Ñтот проект."
+msgid "This issue is confidential"
+msgstr "Это обÑуждение ÑвлÑетÑÑ ÐºÐ¾Ð½Ñ„Ð¸Ð´ÐµÐ½Ñ†Ð¸Ð°Ð»ÑŒÐ½Ñ‹Ð¼"
+
msgid "This issue is confidential and locked."
msgstr "Это обÑуждение конфиденциально и заблокировано."
msgid "This issue is locked."
msgstr "ОбÑуждение заблокировано."
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr "Это задание завиÑит от рабочих заданий на верхнем уровне, которые должны завершитьÑÑ ÑƒÑпешно, чтобы Ñто задание было запущено"
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "Это означает, что вы не можете отправить код, пока не Ñоздадите пуÑтой репозиторий или не импортируете ÑущеÑтвующий."
msgid "This merge request is locked."
msgstr "Ð—Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние заблокирован."
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr "Эти Ñлектронные пиÑьма автоматичеÑки преобразуютÑÑ Ð² обÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ (комментарии раÑÑылаютÑÑ ÐºÐ°Ðº ветвь обÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ð² почте), перечиÑленные здеÑÑŒ."
+
msgid "Time before an issue gets scheduled"
msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð´Ð¾ начала Ð¿Ð¾Ð¿Ð°Ð´Ð°Ð½Ð¸Ñ Ð¾Ð±ÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ð² планировщик"
@@ -2083,9 +4329,27 @@ msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð´Ð¾ начала работы над обÑуждением"
msgid "Time between merge request creation and merge/close"
msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð¼ÐµÐ¶Ð´Ñƒ Ñозданием запроÑа ÑлиÑÐ½Ð¸Ñ Ð¸ ÑлиÑнием / закрытием"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "Ð’Ñ€ÐµÐ¼Ñ Ð´Ð¾ первого запроÑа на ÑлиÑние"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr "%s дней назад"
@@ -2214,36 +4478,110 @@ msgid_plural "Time|hrs"
msgstr[0] "ч"
msgstr[1] "ч"
msgstr[2] "ч"
+msgstr[3] "ч"
msgid "Time|min"
msgid_plural "Time|mins"
msgstr[0] "мин"
msgstr[1] "мин"
msgstr[2] "мин"
+msgstr[3] "мин"
msgid "Time|s"
msgstr "Ñ"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr "Заголовок"
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr "Общее времÑ"
msgid "Total test time for all commits/merges"
msgstr "Общее Ð²Ñ€ÐµÐ¼Ñ Ñ‚ÐµÑÑ‚Ð¸Ñ€Ð¾Ð²Ð°Ð½Ð¸Ñ Ñ„Ð¸ÐºÑаций/ÑлиÑний"
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr "ОтÑлеживать активноÑÑ‚ÑŒ Ñ Ð¿Ð¾Ð¼Ð¾Ñ‰ÑŒÑŽ Ðналитики УчаÑтников."
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr "Следите за обÑуждениÑми, Ñгруппированными по темам, Ñразу из неÑкольких проектов и Ñтапов"
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr "Включить Службу Поддержки"
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr "Разблокировать"
msgid "Unlocked"
msgstr "Разблокировано"
+msgid "Unresolve discussion"
+msgstr "Переоткрыть диÑкуÑÑию"
+
msgid "Unstar"
msgstr "СнÑÑ‚ÑŒ отметку"
-msgid "Unsubscribe"
-msgstr "ОтпиÑатьÑÑ"
+msgid "Up to date"
+msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
msgstr "ПовыÑьте ваш тарифный план, чтобы активировать Улучшенный Глобальный ПоиÑк."
@@ -2255,10 +4593,10 @@ msgid "Upgrade your plan to activate Group Webhooks."
msgstr "ПовыÑьте ваш тарифный план, чтобы активировать Групповые Веб-Обработчики."
msgid "Upgrade your plan to activate Issue weight."
-msgstr "Обновите ваш тарифный план Ð´Ð»Ñ Ð¿Ð¾ÑÐ²Ð»ÐµÐ½Ð¸Ñ Ð²ÐµÑа у обÑуждений."
+msgstr "ПовыÑьте ваш тарифный план чтобы активировать Ð²ÐµÑ Ð¾Ð±Ñуждений."
msgid "Upgrade your plan to improve Issue boards."
-msgstr "Обновите ваш тарифный план, чтобы улучшить доÑки обÑуждений."
+msgstr "ПовыÑьте ваш тарифный план, чтобы улучшить доÑки обÑуждений."
msgid "Upload New File"
msgstr "Загрузить новый файл"
@@ -2266,24 +4604,72 @@ msgstr "Загрузить новый файл"
msgid "Upload file"
msgstr "Загрузить файл"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "кликните Ð´Ð»Ñ Ð·Ð°Ð³Ñ€ÑƒÐ·ÐºÐ¸"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr "ИÑпользуйте Службу поддержки Ð´Ð»Ñ ÑвÑзи Ñ Ð²Ð°ÑˆÐ¸Ð¼Ð¸ пользователÑми (например, Ð´Ð»Ñ Ð¾ÑущеÑÑ‚Ð²Ð»ÐµÐ½Ð¸Ñ Ð¿Ð¾Ð´Ð´ÐµÑ€Ð¶ÐºÐ¸ клиентов) через Ñлектронную почту непоÑредÑтвенно в GitLab"
+
msgid "Use the following registration token during setup:"
msgstr "ИÑпользуйте Ñледующий токен региÑтрации в процеÑÑе уÑтановки:"
msgid "Use your global notification setting"
msgstr "ИÑпользуютÑÑ Ð³Ð»Ð¾Ð±Ð°Ð»ÑŒÐ½Ñ‹Ð¹ наÑтройки уведомлений"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr "ПроÑмотр файла @ "
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "ПроÑмотреть открытый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr "ПроÑмотр заменённого файла @ "
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "Ограниченный"
@@ -2299,17 +4685,29 @@ msgstr "Ðе определен"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "Хотите увидеть данные? ОбратитеÑÑŒ к админиÑтратору за доÑтупом."
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "Ð˜Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ Ð¿Ð¾ Ñтапу отÑутÑтвует."
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr "Мы хотим быть уверены, что Ñто вы, пожалуйÑта, подтвердите, что вы не робот."
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr "Веб-обработчики позволÑÑŽÑ‚ вам вызывать Ð°Ð´Ñ€ÐµÑ URL еÑли, например, отправлен новый код или Ñоздано новое обÑуждение. Ð’Ñ‹ можете наÑтроить веб-обработчики так, чтобы они реагировали на определённые ÑобытиÑ, такие как отправки кода, обÑÑƒÐ¶Ð´ÐµÐ½Ð¸Ñ Ð¸Ð»Ð¸ запроÑÑ‹ на ÑлиÑние. Групповые веб-обработчики применÑÑŽÑ‚ÑÑ ÐºÐ¾ вÑем проектам в группе и позволÑÑŽÑ‚ вам Ñтандартизовать функциональноÑÑ‚ÑŒ веб-обработчиков Ð´Ð»Ñ Ð²Ñей вашей группы."
msgid "Weight"
msgstr "ВеÑ"
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
-msgstr "Когда доÑтуп к хранилищу получить не удалоÑÑŒ, GitLab приоÑтановит доÑтуп к хранилищу на времÑ, указанное здеÑÑŒ. Это позволит файловой ÑиÑтеме воÑÑтановитьÑÑ. Репозитории на Ñбойных \"шардах\" будут временно недоÑтупны"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr ""
msgid "Wiki"
msgstr "Wiki"
@@ -2329,6 +4727,12 @@ msgstr "РекомендуетÑÑ ÑƒÑтановить %{markdown}, чтобы
msgid "WikiClone|Start Gollum and edit locally"
msgstr "ЗапуÑтите Gollum и редактируете локально"
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr "Ð’Ñ‹ не можете Ñоздавать вики-Ñтраницы"
@@ -2336,13 +4740,13 @@ msgid "WikiHistoricalPage|This is an old version of this page."
msgstr "Это уÑÑ‚Ð°Ñ€ÐµÐ²ÑˆÐ°Ñ Ð²ÐµÑ€ÑÐ¸Ñ Ñтой Ñтраницы."
msgid "WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}."
-msgstr "Ð’Ñ‹ можете увидеть %{most_recent_link} либо проÑмотреть %{history_link}."
+msgstr "Ð’Ñ‹ можете увидеть %{most_recent_link}, либо проÑмотреть %{history_link}."
msgid "WikiHistoricalPage|history"
-msgstr "иÑториÑ"
+msgstr "иÑторию изменений"
msgid "WikiHistoricalPage|most recent version"
-msgstr "поÑледнÑÑ Ð²ÐµÑ€ÑиÑ"
+msgstr "поÑледнюю верÑию"
msgid "WikiMarkdownDocs|More examples are in the %{docs_link}"
msgstr "Дополнительные примеры находÑÑ‚ÑÑ Ð² %{docs_link}"
@@ -2419,39 +4823,69 @@ msgstr "С аналитикой учаÑтников вы можете изучÐ
msgid "Withdraw Access Request"
msgstr "Отменить Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð´Ð¾Ñтупа"
+msgid "Write a commit message..."
+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 %{project_full_name}. 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 "Ð’Ñ‹ ÑобираетеÑÑŒ удалить ÑвÑзь Ð¾Ñ‚Ð²ÐµÑ‚Ð²Ð»ÐµÐ½Ð¸Ñ Ñ Ð¸Ñходным проектом %{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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
-msgstr "Ð’Ñ‹ находитеÑÑŒ на ÑкземплÑре \"только Ð´Ð»Ñ Ñ‡Ñ‚ÐµÐ½Ð¸Ñ\" клаÑтера GitLab."
+msgstr ""
+
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
-msgstr "Ð’Ñ‹ находитеÑÑŒ на ÑкземплÑре \"только Ð´Ð»Ñ Ñ‡Ñ‚ÐµÐ½Ð¸Ñ\" клаÑтера GitLab. ЕÑли вы хотите произвеÑти любые изменениÑ, вы должны перейти на \"оÑновной\" ÑкземплÑÑ€ по ÑÑылке %{link_to_primary_node}."
+msgid "You can move around the graph by using the arrow keys."
+msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "Ð’Ñ‹ можете добавлÑÑ‚ÑŒ только файлы, когда находитеÑÑŒ в ветке"
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr "Ð’Ñ‹ не можете запиÑывать на подчиненные ÑкземплÑры \"только Ð´Ð»Ñ Ñ‡Ñ‚ÐµÐ½Ð¸Ñ\" клаÑтера GitLab Geo. ИÑпользуйте вмеÑто Ñтого %{link_to_primary_node}."
msgid "You cannot write to this read-only GitLab instance."
msgstr "Ð’Ñ‹ не можете запиÑывать на Ñтот ÑкземплÑÑ€ \"только Ð´Ð»Ñ Ñ‡Ñ‚ÐµÐ½Ð¸Ñ\" клаÑтера GitLab."
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "Ð’Ñ‹ доÑтигли Ð¾Ð³Ñ€Ð°Ð½Ð¸Ñ‡ÐµÐ½Ð¸Ñ Ð² вашем проекте"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "Ðеобходимо войти, чтобы оценить проект"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "Вам нужно разрешение."
@@ -2476,6 +4910,36 @@ 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 "Ð’Ñ‹ не Ñможете получать и отправлÑÑ‚ÑŒ код проекта через SSH пока %{add_ssh_key_link} в ваш профиль."
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr "Ð’Ñ‹ не Ñможете работать Ñ Ð¿Ñ€Ð¾ÐµÐºÑ‚Ð¾Ð¼ через SSH, пока не добавите в Ñвой профиль SSH ключ"
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr "Ваш комментарий не будет виден вÑем."
@@ -2488,14 +4952,352 @@ msgstr "Ваше имÑ"
msgid "Your projects"
msgstr "Ваши проекты"
-msgid "commit"
-msgstr "коммит"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr "Ð¸Ð¼Ñ Ð²ÐµÑ‚Ð²Ð¸"
+
+msgid "by"
+msgstr "по"
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
msgid "day"
msgid_plural "days"
-msgstr[0] "день"
-msgstr[1] "дней"
-msgstr[2] "дней"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr "Отменить автоматичеÑкое ÑлиÑние"
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr "Разрешить конфликты"
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
msgid "new merge request"
msgstr "новый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
@@ -2503,11 +5305,15 @@ msgstr "новый Ð·Ð°Ð¿Ñ€Ð¾Ñ Ð½Ð° ÑлиÑние"
msgid "notification emails"
msgstr "email Ð´Ð»Ñ ÑƒÐ²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ð¹"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
-msgstr[0] "иÑточник"
-msgstr[1] "иÑточники"
-msgstr[2] "иÑточники"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
msgid "password"
msgstr "пароль"
@@ -2515,9 +5321,30 @@ msgstr "пароль"
msgid "personal access token"
msgstr "токен Ð´Ð»Ñ Ð¿ÐµÑ€Ñонального доÑтупа"
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr "иÑходный текÑÑ‚"
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
-msgstr "чтобы помочь вашим учаÑтникам взаимодейÑтвовать Ñффективнее!"
+msgstr "чтобы помочь учаÑтникам взаимодейÑтвовать Ñффективнее!"
msgid "username"
msgstr "Ð¸Ð¼Ñ Ð¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ñ‚ÐµÐ»Ñ"
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/tr_TR/gitlab.po b/locale/tr_TR/gitlab.po
new file mode 100644
index 00000000000..6a025dfbae5
--- /dev/null
+++ b/locale/tr_TR/gitlab.po
@@ -0,0 +1,5288 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab-ee\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:35-0400\n"
+"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
+"Language-Team: Turkish\n"
+"Language: tr_TR\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"
+"X-Generator: crowdin.com\n"
+"X-Crowdin-Project: gitlab-ee\n"
+"X-Crowdin-Language: tr\n"
+"X-Crowdin-File: /master/locale/gitlab.pot\n"
+
+msgid " and"
+msgstr " ve"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d layer"
+msgid_plural "%d layers"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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 "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
+
+msgid "%{count} participant"
+msgid_plural "%{count} participants"
+msgstr[0] "%{count} katılımcı"
+msgstr[1] "%{count} katılımcı"
+
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
+msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
+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 not retry automatically. Reset storage information when the problem is resolved."
+msgstr ""
+
+msgid "%{openOrClose} %{noteable}"
+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 "%{text} is available"
+msgstr ""
+
+msgid "(checkout the %{link} for information on how to install it)."
+msgstr ""
+
+msgid "+ %{moreCount} more"
+msgstr "+ %{moreCount} daha fazla"
+
+msgid "- show less"
+msgstr "- daha az göster"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "1st contribution!"
+msgstr "İlk katkı!"
+
+msgid "2FA enabled"
+msgstr ""
+
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr ""
+
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
+msgid "About auto deploy"
+msgstr ""
+
+msgid "Abuse Reports"
+msgstr "Kötüye Kullanım Raporları"
+
+msgid "Abuse reports"
+msgstr ""
+
+msgid "Access Tokens"
+msgstr "Erişim anahtarları"
+
+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 "Account"
+msgstr "Hesap"
+
+msgid "Account and limit settings"
+msgstr ""
+
+msgid "Active"
+msgstr "Etkin"
+
+msgid "Activity"
+msgstr "Etkinlik"
+
+msgid "Add"
+msgstr "Ekle"
+
+msgid "Add Changelog"
+msgstr "DeÄŸiÅŸiklik Bilgisi Ekle"
+
+msgid "Add Contribution guide"
+msgstr "Katkı kılavuzu ekle"
+
+msgid "Add Group Webhooks and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Add Kubernetes cluster"
+msgstr ""
+
+msgid "Add License"
+msgstr "Lisans Ekle"
+
+msgid "Add Readme"
+msgstr ""
+
+msgid "Add new directory"
+msgstr "Yeni dizin ekle"
+
+msgid "Add todo"
+msgstr "Yapılacaklara Ekle"
+
+msgid "AdminArea|Stop all jobs"
+msgstr "Tüm işleri durdur"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "Tüm işleri durdur?"
+
+msgid "AdminArea|Stop jobs"
+msgstr "Ä°ÅŸleri durdur"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
+msgid "AdminHealthPageLink|health page"
+msgstr ""
+
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr "GeliÅŸmiÅŸ"
+
+msgid "Advanced settings"
+msgstr "GeliÅŸmiÅŸ ayarlar"
+
+msgid "All"
+msgstr "Tümü"
+
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr "Kubernetes kümelerini eklemeye ve yönetmenize olanak tanır."
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr "Markdown ön izlemesi yüklenirken hata oluştu"
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr "Projeler yüklenirken bir hata oluştu"
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr "Dosya isimleri yüklenirken bir hata oluştu"
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr "KaTeX'i iÅŸlerken bir hata oluÅŸtu"
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr "Önizleme yayını iletisi oluşturulurken bir hata oluştu"
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr "Değişiklikler yüklenirken bir hata oluştu"
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr "Kullanıcı adı doğrulanırken bir hata oluştu"
+
+msgid "An error occurred. Please try again."
+msgstr "Bir hata oluştu. Lütfen tekrar deneyin."
+
+msgid "Any Label"
+msgstr ""
+
+msgid "Appearance"
+msgstr "Görünüm"
+
+msgid "Applications"
+msgstr "Uygulamalar"
+
+msgid "Apr"
+msgstr "Nis"
+
+msgid "April"
+msgstr "Nisan"
+
+msgid "Archived project! Repository is read-only"
+msgstr "Arşivlenmiş proje! Sadece okuma yapılabilir"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+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 you want to unlock %{path_lock_path}?"
+msgstr ""
+
+msgid "Are you sure?"
+msgstr "Emin misiniz?"
+
+msgid "Artifacts"
+msgstr ""
+
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr "#FF0000 gibi özel renk ata"
+
+msgid "Assign labels"
+msgstr "Etiket tanımla"
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr "Ata"
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
+msgid "Attach a file by drag &amp; drop or %{upload_link}"
+msgstr "Sürükleyip bırakarak bir dosya ekle veya %{upload_link}"
+
+msgid "Aug"
+msgstr "AÄŸustos"
+
+msgid "August"
+msgstr "AÄŸustos"
+
+msgid "Authentication Log"
+msgstr "Kimlik Doğrulama Günlüğü"
+
+msgid "Author"
+msgstr "Yazar"
+
+msgid "Authors: %{authors}"
+msgstr "Yazarlar: %{authors}"
+
+msgid "Auto DevOps enabled"
+msgstr ""
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgstr ""
+
+msgid "AutoDevOps|Auto DevOps (Beta)"
+msgstr ""
+
+msgid "AutoDevOps|Auto DevOps documentation"
+msgstr ""
+
+msgid "AutoDevOps|Enable in settings"
+msgstr ""
+
+msgid "AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration."
+msgstr ""
+
+msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
+msgstr ""
+
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr "Kullanılabilir"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr "Avatar kaldırılacak. Emin misiniz?"
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr ""
+
+msgid "Billing"
+msgstr ""
+
+msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
+msgstr ""
+
+msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
+msgstr ""
+
+msgid "BillingPlans|Current plan"
+msgstr ""
+
+msgid "BillingPlans|Customer Support"
+msgstr ""
+
+msgid "BillingPlans|Downgrade"
+msgstr ""
+
+msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
+msgstr ""
+
+msgid "BillingPlans|Manage plan"
+msgstr ""
+
+msgid "BillingPlans|Please contact %{customer_support_link} in that case."
+msgstr ""
+
+msgid "BillingPlans|See all %{plan_name} features"
+msgstr ""
+
+msgid "BillingPlans|This group uses the plan associated with its parent group."
+msgstr ""
+
+msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
+msgstr ""
+
+msgid "BillingPlans|Upgrade"
+msgstr ""
+
+msgid "BillingPlans|You are currently on the %{plan_link} plan."
+msgstr ""
+
+msgid "BillingPlans|frequently asked questions"
+msgstr ""
+
+msgid "BillingPlans|monthly"
+msgstr ""
+
+msgid "BillingPlans|paid annually at %{price_per_year}"
+msgstr ""
+
+msgid "BillingPlans|per user"
+msgstr ""
+
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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 "Branch has changed"
+msgstr ""
+
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr ""
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr ""
+
+msgid "Branches"
+msgstr ""
+
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
+msgid "Branches|Cant find HEAD commit for this branch"
+msgstr ""
+
+msgid "Branches|Compare"
+msgstr ""
+
+msgid "Branches|Delete all branches that are merged into '%{default_branch}'"
+msgstr ""
+
+msgid "Branches|Delete branch"
+msgstr ""
+
+msgid "Branches|Delete merged branches"
+msgstr ""
+
+msgid "Branches|Delete protected branch"
+msgstr ""
+
+msgid "Branches|Delete protected branch '%{branch_name}'?"
+msgstr ""
+
+msgid "Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "Branches|Filter by branch name"
+msgstr ""
+
+msgid "Branches|Merged into %{default_branch}"
+msgstr ""
+
+msgid "Branches|New branch"
+msgstr ""
+
+msgid "Branches|No branches to show"
+msgstr ""
+
+msgid "Branches|Once you confirm and press %{delete_protected_branch}, it cannot be undone or recovered."
+msgstr ""
+
+msgid "Branches|Only a project master or owner can delete a protected branch"
+msgstr ""
+
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
+msgstr ""
+
+msgid "Branches|Sort by"
+msgstr ""
+
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
+msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
+msgstr ""
+
+msgid "Branches|The default branch cannot be deleted"
+msgstr ""
+
+msgid "Branches|This branch hasn’t been merged into %{default_branch}."
+msgstr ""
+
+msgid "Branches|To avoid data loss, consider merging this branch before deleting it."
+msgstr ""
+
+msgid "Branches|To confirm, type %{branch_name_confirmation}:"
+msgstr ""
+
+msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above."
+msgstr ""
+
+msgid "Branches|You’re about to permanently delete the protected branch %{branch_name}."
+msgstr ""
+
+msgid "Branches|diverged from upstream"
+msgstr ""
+
+msgid "Branches|merged"
+msgstr ""
+
+msgid "Branches|project settings"
+msgstr ""
+
+msgid "Branches|protected"
+msgstr ""
+
+msgid "Browse Directory"
+msgstr ""
+
+msgid "Browse File"
+msgstr ""
+
+msgid "Browse Files"
+msgstr ""
+
+msgid "Browse files"
+msgstr ""
+
+msgid "Business"
+msgstr ""
+
+msgid "ByAuthor|by"
+msgstr ""
+
+msgid "CI / CD"
+msgstr ""
+
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
+
+msgid "CICD|Jobs"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
+msgstr ""
+
+msgid "Change Weight"
+msgstr ""
+
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr ""
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr ""
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr ""
+
+msgid "ChangeTypeAction|Revert"
+msgstr ""
+
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
+msgid "Changelog"
+msgstr ""
+
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
+msgid "Charts"
+msgstr ""
+
+msgid "Chat"
+msgstr ""
+
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
+msgid "Cherry-pick this commit"
+msgstr ""
+
+msgid "Cherry-pick this merge request"
+msgstr ""
+
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+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 "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
+msgid "CircuitBreakerApiLink|circuitbreaker api"
+msgstr ""
+
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
+msgid "Clone repository"
+msgstr ""
+
+msgid "Close"
+msgstr ""
+
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
+msgstr ""
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Copy API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Token"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr ""
+
+msgid "ClusterIntegration|Google Cloud Platform project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Google Kubernetes Engine project"
+msgstr ""
+
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
+msgid "ClusterIntegration|Machine type"
+msgstr ""
+
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
+
+msgid "ClusterIntegration|More information"
+msgstr ""
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
+msgstr ""
+
+msgid "ClusterIntegration|Number of nodes"
+msgstr ""
+
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
+msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
+msgstr ""
+
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace (optional, unique)"
+msgstr ""
+
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Remove integration"
+msgstr ""
+
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
+msgstr ""
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|See machine types"
+msgstr ""
+
+msgid "ClusterIntegration|See your projects"
+msgstr ""
+
+msgid "ClusterIntegration|See zones"
+msgstr ""
+
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong on our end."
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr ""
+
+msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
+msgstr ""
+
+msgid "ClusterIntegration|Zone"
+msgstr ""
+
+msgid "ClusterIntegration|access to Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
+msgstr ""
+
+msgid "ClusterIntegration|help page"
+msgstr ""
+
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
+msgid "ClusterIntegration|meets the requirements"
+msgstr ""
+
+msgid "ClusterIntegration|properly configured"
+msgstr ""
+
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
+msgid "Comments"
+msgstr ""
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Commit Message"
+msgstr ""
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr ""
+
+msgid "Commit message"
+msgstr ""
+
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
+msgid "CommitBoxTitle|Commit"
+msgstr ""
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr ""
+
+msgid "Commits"
+msgstr ""
+
+msgid "Commits feed"
+msgstr ""
+
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
+msgid "Commits|History"
+msgstr ""
+
+msgid "Commits|No related merge requests found"
+msgstr ""
+
+msgid "Committed by"
+msgstr ""
+
+msgid "Compare"
+msgstr ""
+
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
+msgid "Container Registry"
+msgstr ""
+
+msgid "ContainerRegistry|Created"
+msgstr ""
+
+msgid "ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:"
+msgstr ""
+
+msgid "ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:"
+msgstr ""
+
+msgid "ContainerRegistry|How to use the Container Registry"
+msgstr ""
+
+msgid "ContainerRegistry|Learn more about"
+msgstr ""
+
+msgid "ContainerRegistry|No tags in Container Registry for this container image."
+msgstr ""
+
+msgid "ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands"
+msgstr ""
+
+msgid "ContainerRegistry|Remove repository"
+msgstr ""
+
+msgid "ContainerRegistry|Remove tag"
+msgstr ""
+
+msgid "ContainerRegistry|Size"
+msgstr ""
+
+msgid "ContainerRegistry|Tag"
+msgstr ""
+
+msgid "ContainerRegistry|Tag ID"
+msgstr ""
+
+msgid "ContainerRegistry|Use different image names"
+msgstr ""
+
+msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
+msgstr ""
+
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
+msgid "Contribution guide"
+msgstr ""
+
+msgid "Contributors"
+msgstr ""
+
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
+msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
+msgstr ""
+
+msgid "Control the maximum concurrency of repository backfill for this secondary node"
+msgstr ""
+
+msgid "Copy SSH public key to clipboard"
+msgstr ""
+
+msgid "Copy URL to clipboard"
+msgstr ""
+
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
+msgid "Copy commit SHA to clipboard"
+msgstr ""
+
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
+msgid "Create New Directory"
+msgstr ""
+
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Create branch"
+msgstr ""
+
+msgid "Create directory"
+msgstr ""
+
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
+
+msgid "Create file"
+msgstr ""
+
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
+msgid "Create merge request"
+msgstr ""
+
+msgid "Create merge request and branch"
+msgstr ""
+
+msgid "Create new branch"
+msgstr ""
+
+msgid "Create new directory"
+msgstr ""
+
+msgid "Create new file"
+msgstr ""
+
+msgid "Create new label"
+msgstr ""
+
+msgid "Create new..."
+msgstr ""
+
+msgid "Create project label"
+msgstr ""
+
+msgid "CreateNewFork|Fork"
+msgstr ""
+
+msgid "CreateTag|Tag"
+msgstr ""
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
+msgid "Cron Timezone"
+msgstr ""
+
+msgid "Cron syntax"
+msgstr ""
+
+msgid "Current node"
+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 "Customize colors"
+msgstr ""
+
+msgid "Cycle Analytics"
+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 "DashboardProjects|All"
+msgstr ""
+
+msgid "DashboardProjects|Personal"
+msgstr ""
+
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
+msgid "Define a custom pattern with cron syntax"
+msgstr ""
+
+msgid "Delete"
+msgstr ""
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Deploy Keys"
+msgstr ""
+
+msgid "Description"
+msgstr ""
+
+msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
+msgstr ""
+
+msgid "Details"
+msgstr ""
+
+msgid "Diffs|No file name available"
+msgstr ""
+
+msgid "Directory name"
+msgstr ""
+
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
+msgstr ""
+
+msgid "Dismiss Cycle Analytics introduction box"
+msgstr ""
+
+msgid "Dismiss Merge Request promotion"
+msgstr ""
+
+msgid "Documentation for popular identity providers"
+msgstr ""
+
+msgid "Don't show again"
+msgstr ""
+
+msgid "Done"
+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 "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr ""
+
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
+msgid "Emails"
+msgstr ""
+
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
+msgid "EventFilterBy|Filter by all"
+msgstr ""
+
+msgid "EventFilterBy|Filter by comments"
+msgstr ""
+
+msgid "EventFilterBy|Filter by issue events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by merge events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by push events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by team"
+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 "Expand"
+msgstr ""
+
+msgid "Explore projects"
+msgstr ""
+
+msgid "Explore public groups"
+msgstr ""
+
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
+msgid "Failed to change the owner"
+msgstr ""
+
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
+msgid "Failed to remove the pipeline schedule"
+msgstr ""
+
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
+msgid "File name"
+msgstr ""
+
+msgid "Files"
+msgstr ""
+
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
+msgid "Filter by commit message"
+msgstr ""
+
+msgid "Find by path"
+msgstr ""
+
+msgid "Find file"
+msgstr ""
+
+msgid "Finished"
+msgstr ""
+
+msgid "FirstPushedBy|First"
+msgstr ""
+
+msgid "FirstPushedBy|pushed by"
+msgstr ""
+
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr ""
+
+msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
+msgstr ""
+
+msgid "Forking in progress"
+msgstr ""
+
+msgid "Format"
+msgstr ""
+
+msgid "From %{provider_title}"
+msgstr ""
+
+msgid "From issue creation until deploy to production"
+msgstr ""
+
+msgid "From merge request merge until deploy to production"
+msgstr ""
+
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
+msgid "GPG Keys"
+msgstr ""
+
+msgid "Generate a default set of labels"
+msgstr ""
+
+msgid "Geo Nodes"
+msgstr ""
+
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
+msgid "Geo|File sync capacity"
+msgstr ""
+
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
+msgstr ""
+
+msgid "Geo|Repository sync capacity"
+msgstr ""
+
+msgid "Geo|Select groups to replicate."
+msgstr ""
+
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
+msgid "Git storage health information has been reset"
+msgstr ""
+
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
+msgid "GitLab Runner section"
+msgstr ""
+
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
+msgid "Go to your fork"
+msgstr ""
+
+msgid "GoToYourFork|Fork"
+msgstr ""
+
+msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
+msgstr ""
+
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
+msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
+msgstr ""
+
+msgid "GroupSettings|Share with group lock"
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup."
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. To share projects in this group with another group, ask the owner to override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr ""
+
+msgid "GroupSettings|This setting is applied on %{ancestor_group}. You can override the setting or %{remove_ancestor_share_with_group_lock}."
+msgstr ""
+
+msgid "GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually."
+msgstr ""
+
+msgid "GroupSettings|cannot be disabled when the parent group \"Share with group lock\" is enabled, except by the owner of the parent group"
+msgstr ""
+
+msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
+msgstr ""
+
+msgid "GroupsEmptyState|A group is a collection of several projects."
+msgstr ""
+
+msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder."
+msgstr ""
+
+msgid "GroupsEmptyState|No groups found"
+msgstr ""
+
+msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
+msgstr ""
+
+msgid "GroupsTree|Create a project in this group."
+msgstr ""
+
+msgid "GroupsTree|Create a subgroup in this group."
+msgstr ""
+
+msgid "GroupsTree|Edit group"
+msgstr ""
+
+msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
+msgstr ""
+
+msgid "GroupsTree|Filter by name..."
+msgstr ""
+
+msgid "GroupsTree|Leave this group"
+msgstr ""
+
+msgid "GroupsTree|Loading groups"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups matched your search"
+msgstr ""
+
+msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgstr ""
+
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+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 "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "History"
+msgstr ""
+
+msgid "Housekeeping successfully started"
+msgstr ""
+
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
+msgid "Import repository"
+msgstr ""
+
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
+msgid "Improve Issue boards with GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
+msgstr ""
+
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
+msgid "Install a Runner compatible with GitLab CI"
+msgstr ""
+
+msgid "Instance"
+msgid_plural "Instances"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
+msgid "Internal - The group and any internal projects can be viewed by any logged in user."
+msgstr ""
+
+msgid "Internal - The project can be accessed by any logged in user."
+msgstr ""
+
+msgid "Interval Pattern"
+msgstr ""
+
+msgid "Introducing Cycle Analytics"
+msgstr ""
+
+msgid "Issue board focus mode"
+msgstr ""
+
+msgid "Issue events"
+msgstr ""
+
+msgid "IssueBoards|Board"
+msgstr ""
+
+msgid "IssueBoards|Boards"
+msgstr ""
+
+msgid "Issues"
+msgstr ""
+
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
+msgid "LFSStatus|Disabled"
+msgstr ""
+
+msgid "LFSStatus|Enabled"
+msgstr ""
+
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
+msgid "Labels"
+msgstr ""
+
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Last Pipeline"
+msgstr ""
+
+msgid "Last commit"
+msgstr ""
+
+msgid "Last edited %{date}"
+msgstr ""
+
+msgid "Last edited by %{name}"
+msgstr ""
+
+msgid "Last update"
+msgstr ""
+
+msgid "Last updated"
+msgstr ""
+
+msgid "LastPushEvent|You pushed to"
+msgstr ""
+
+msgid "LastPushEvent|at"
+msgstr ""
+
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
+msgid "Learn more in the"
+msgstr ""
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr ""
+
+msgid "Leave"
+msgstr ""
+
+msgid "Leave group"
+msgstr ""
+
+msgid "Leave project"
+msgstr ""
+
+msgid "License"
+msgstr ""
+
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
+
+msgid "Lock"
+msgstr ""
+
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
+msgid "Locked"
+msgstr ""
+
+msgid "Locked Files"
+msgstr ""
+
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
+msgid "Login"
+msgstr ""
+
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
+msgid "Maximum git storage failures"
+msgstr ""
+
+msgid "May"
+msgstr ""
+
+msgid "Median"
+msgstr ""
+
+msgid "Members"
+msgstr ""
+
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
+msgid "Merge Requests"
+msgstr ""
+
+msgid "Merge events"
+msgstr ""
+
+msgid "Merge request"
+msgstr ""
+
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
+msgid "Messages"
+msgstr ""
+
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr ""
+
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
+msgid "Monitoring"
+msgstr ""
+
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
+msgid "More information is available|here"
+msgstr ""
+
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
+msgid "Multiple issue boards"
+msgstr ""
+
+msgid "Name new label"
+msgstr ""
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
+msgid "New Pipeline Schedule"
+msgstr ""
+
+msgid "New branch"
+msgstr ""
+
+msgid "New branch unavailable"
+msgstr ""
+
+msgid "New directory"
+msgstr ""
+
+msgid "New epic"
+msgstr ""
+
+msgid "New file"
+msgstr ""
+
+msgid "New group"
+msgstr ""
+
+msgid "New issue"
+msgstr ""
+
+msgid "New label"
+msgstr ""
+
+msgid "New merge request"
+msgstr ""
+
+msgid "New project"
+msgstr ""
+
+msgid "New schedule"
+msgstr ""
+
+msgid "New snippet"
+msgstr ""
+
+msgid "New subgroup"
+msgstr ""
+
+msgid "New tag"
+msgstr ""
+
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
+msgstr ""
+
+msgid "No repository"
+msgstr ""
+
+msgid "No schedules"
+msgstr ""
+
+msgid "None"
+msgstr ""
+
+msgid "Not allowed to merge"
+msgstr ""
+
+msgid "Not available"
+msgstr ""
+
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
+msgid "Not enough data"
+msgstr ""
+
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+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 "Notifications"
+msgstr ""
+
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
+msgid "Number of access attempts"
+msgstr ""
+
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
+msgstr ""
+
+msgid "OfSearchInADropdown|Filter"
+msgstr ""
+
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
+msgid "Only project members can comment."
+msgstr ""
+
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr ""
+
+msgid "Opens in a new window"
+msgstr ""
+
+msgid "Options"
+msgstr ""
+
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
+msgid "Overview"
+msgstr ""
+
+msgid "Owner"
+msgstr ""
+
+msgid "Pages"
+msgstr ""
+
+msgid "Pagination|Last »"
+msgstr ""
+
+msgid "Pagination|Next"
+msgstr ""
+
+msgid "Pagination|Prev"
+msgstr ""
+
+msgid "Pagination|« First"
+msgstr ""
+
+msgid "Part of merge request changes"
+msgstr ""
+
+msgid "Password"
+msgstr ""
+
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr ""
+
+msgid "Pipeline"
+msgstr ""
+
+msgid "Pipeline Health"
+msgstr ""
+
+msgid "Pipeline Schedule"
+msgstr ""
+
+msgid "Pipeline Schedules"
+msgstr ""
+
+msgid "Pipeline quota"
+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|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 "PipelineSchedules|Variables"
+msgstr ""
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr ""
+
+msgid "Pipelines"
+msgstr ""
+
+msgid "Pipelines charts"
+msgstr ""
+
+msgid "Pipelines for last month"
+msgstr ""
+
+msgid "Pipelines for last week"
+msgstr ""
+
+msgid "Pipelines for last year"
+msgstr ""
+
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|all"
+msgstr ""
+
+msgid "Pipeline|success"
+msgstr ""
+
+msgid "Pipeline|with stage"
+msgstr ""
+
+msgid "Pipeline|with stages"
+msgstr ""
+
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
+msgid "Preferences"
+msgstr ""
+
+msgid "Primary"
+msgstr ""
+
+msgid "Private - Project access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Private - The group and its projects can only be viewed by members."
+msgstr ""
+
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
+msgid "Profile"
+msgstr ""
+
+msgid "Profiles|Account scheduled for removal."
+msgstr ""
+
+msgid "Profiles|Delete Account"
+msgstr ""
+
+msgid "Profiles|Delete account"
+msgstr ""
+
+msgid "Profiles|Delete your account?"
+msgstr ""
+
+msgid "Profiles|Deleting an account has the following effects:"
+msgstr ""
+
+msgid "Profiles|Invalid password"
+msgstr ""
+
+msgid "Profiles|Invalid username"
+msgstr ""
+
+msgid "Profiles|Type your %{confirmationValue} to confirm:"
+msgstr ""
+
+msgid "Profiles|You don't have access to delete this user."
+msgstr ""
+
+msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
+msgstr ""
+
+msgid "Profiles|Your account is currently an owner in these groups:"
+msgstr ""
+
+msgid "Profiles|your account"
+msgstr ""
+
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
+msgid "Project '%{project_name}' is in the process of being deleted."
+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 access must be granted explicitly to each user."
+msgstr ""
+
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+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 "ProjectActivityRSS|Subscribe"
+msgstr ""
+
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+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 "ProjectSettings|Contact an admin to change this setting."
+msgstr ""
+
+msgid "ProjectSettings|Only signed commits can be pushed to this repository."
+msgstr ""
+
+msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
+msgstr ""
+
+msgid "ProjectSettings|This setting is applied on the server level but has been overridden for this project."
+msgstr ""
+
+msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin."
+msgstr ""
+
+msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
+msgstr ""
+
+msgid "Projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Frequently visited"
+msgstr ""
+
+msgid "ProjectsDropdown|Loading projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Projects you visit often will appear here"
+msgstr ""
+
+msgid "ProjectsDropdown|Search your projects"
+msgstr ""
+
+msgid "ProjectsDropdown|Something went wrong on our end."
+msgstr ""
+
+msgid "ProjectsDropdown|Sorry, no projects matched your search"
+msgstr ""
+
+msgid "ProjectsDropdown|This feature requires browser localStorage support"
+msgstr ""
+
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
+msgid "Public - The group and any public projects can be viewed without any authentication."
+msgstr ""
+
+msgid "Public - The project can be accessed without any authentication."
+msgstr ""
+
+msgid "Push Rules"
+msgstr ""
+
+msgid "Push events"
+msgstr ""
+
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
+msgid "PushRule|Committer restriction"
+msgstr ""
+
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
+msgid "Read more"
+msgstr ""
+
+msgid "Readme"
+msgstr ""
+
+msgid "Real-time features"
+msgstr ""
+
+msgid "RefSwitcher|Branches"
+msgstr ""
+
+msgid "RefSwitcher|Tags"
+msgstr ""
+
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
+msgid "Registry"
+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 "Related merge requests"
+msgstr ""
+
+msgid "Remind later"
+msgstr ""
+
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
+msgid "Remove project"
+msgstr ""
+
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
+msgid "Repository"
+msgstr ""
+
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+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 "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Revert this commit"
+msgstr ""
+
+msgid "Revert this merge request"
+msgstr ""
+
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
+msgstr ""
+
+msgid "Save changes"
+msgstr ""
+
+msgid "Save pipeline schedule"
+msgstr ""
+
+msgid "Save variables"
+msgstr ""
+
+msgid "Schedule a new pipeline"
+msgstr ""
+
+msgid "Scheduled"
+msgstr ""
+
+msgid "Schedules"
+msgstr ""
+
+msgid "Scheduling Pipelines"
+msgstr ""
+
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
+msgid "Search branches and tags"
+msgstr ""
+
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
+msgstr ""
+
+msgid "Seconds to wait for a storage access attempt"
+msgstr ""
+
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
+msgid "Select Archive Format"
+msgstr ""
+
+msgid "Select a timezone"
+msgstr ""
+
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
+msgid "Select target branch"
+msgstr ""
+
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
+msgid "Service Templates"
+msgstr ""
+
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr ""
+
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
+
+msgid "Set up Koding"
+msgstr ""
+
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr ""
+
+msgid "Settings"
+msgstr ""
+
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
+msgid "Show parent pages"
+msgstr ""
+
+msgid "Show parent subgroups"
+msgstr ""
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
+msgid "Snippets"
+msgstr ""
+
+msgid "Something went wrong on our end"
+msgstr ""
+
+msgid "Something went wrong on our end."
+msgstr ""
+
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
+msgstr ""
+
+msgid "Something went wrong while fetching the projects."
+msgstr ""
+
+msgid "Something went wrong while fetching the registry list."
+msgstr ""
+
+msgid "Something went wrong. Please try again."
+msgstr ""
+
+msgid "Sort by"
+msgstr ""
+
+msgid "SortOptions|Access level, ascending"
+msgstr ""
+
+msgid "SortOptions|Access level, descending"
+msgstr ""
+
+msgid "SortOptions|Created date"
+msgstr ""
+
+msgid "SortOptions|Due date"
+msgstr ""
+
+msgid "SortOptions|Due later"
+msgstr ""
+
+msgid "SortOptions|Due soon"
+msgstr ""
+
+msgid "SortOptions|Label priority"
+msgstr ""
+
+msgid "SortOptions|Largest group"
+msgstr ""
+
+msgid "SortOptions|Largest repository"
+msgstr ""
+
+msgid "SortOptions|Last created"
+msgstr ""
+
+msgid "SortOptions|Last joined"
+msgstr ""
+
+msgid "SortOptions|Last updated"
+msgstr ""
+
+msgid "SortOptions|Least popular"
+msgstr ""
+
+msgid "SortOptions|Less weight"
+msgstr ""
+
+msgid "SortOptions|Milestone"
+msgstr ""
+
+msgid "SortOptions|Milestone due later"
+msgstr ""
+
+msgid "SortOptions|Milestone due soon"
+msgstr ""
+
+msgid "SortOptions|More weight"
+msgstr ""
+
+msgid "SortOptions|Most popular"
+msgstr ""
+
+msgid "SortOptions|Name"
+msgstr ""
+
+msgid "SortOptions|Name, ascending"
+msgstr ""
+
+msgid "SortOptions|Name, descending"
+msgstr ""
+
+msgid "SortOptions|Oldest created"
+msgstr ""
+
+msgid "SortOptions|Oldest joined"
+msgstr ""
+
+msgid "SortOptions|Oldest sign in"
+msgstr ""
+
+msgid "SortOptions|Oldest updated"
+msgstr ""
+
+msgid "SortOptions|Popularity"
+msgstr ""
+
+msgid "SortOptions|Priority"
+msgstr ""
+
+msgid "SortOptions|Recent sign in"
+msgstr ""
+
+msgid "SortOptions|Start later"
+msgstr ""
+
+msgid "SortOptions|Start soon"
+msgstr ""
+
+msgid "SortOptions|Weight"
+msgstr ""
+
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
+msgid "Source code"
+msgstr ""
+
+msgid "Source is not available"
+msgstr ""
+
+msgid "Spam Logs"
+msgstr ""
+
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
+msgid "Specify the following URL during the Runner setup:"
+msgstr ""
+
+msgid "StarProject|Star"
+msgstr ""
+
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
+msgid "Starred projects"
+msgstr ""
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr ""
+
+msgid "Start the Runner!"
+msgstr ""
+
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
+msgstr ""
+
+msgid "Switch branch/tag"
+msgstr ""
+
+msgid "System"
+msgstr ""
+
+msgid "System Hooks"
+msgstr ""
+
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Tags"
+msgstr ""
+
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
+msgid "Target Branch"
+msgstr ""
+
+msgid "Target branch"
+msgstr ""
+
+msgid "Team"
+msgstr ""
+
+msgid "Thanks! Don't show me this again"
+msgstr ""
+
+msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+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 connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
+msgid "The fork relationship has been removed."
+msgstr ""
+
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 maximum file size allowed is 200KB."
+msgstr ""
+
+msgid "The number of attempts GitLab will make to access a storage."
+msgstr ""
+
+msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
+msgstr ""
+
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+msgstr ""
+
+msgid "The phase of the development lifecycle."
+msgstr ""
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr ""
+
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+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 repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 in seconds GitLab will keep failure information. When no failures occur during this time, information about the mount is reset."
+msgstr ""
+
+msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
+msgstr ""
+
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+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 no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
+msgid "There are problems accessing Git storage: "
+msgstr ""
+
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
+msgstr ""
+
+msgid "This is a confidential issue."
+msgstr ""
+
+msgid "This is the author's first Merge Request to this project."
+msgstr ""
+
+msgid "This issue is confidential"
+msgstr ""
+
+msgid "This issue is confidential and locked."
+msgstr ""
+
+msgid "This issue is locked."
+msgstr ""
+
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr ""
+
+msgid "This merge request is locked."
+msgstr ""
+
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+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 between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
+msgid "Time until first merge request"
+msgstr ""
+
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+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 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|in a while"
+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 "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
+msgid "Total Time"
+msgstr ""
+
+msgid "Total test time for all commits/merges"
+msgstr ""
+
+msgid "Total: %{total}"
+msgstr ""
+
+msgid "Track activity with Contribution Analytics."
+msgstr ""
+
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
+msgid "Unlock"
+msgstr ""
+
+msgid "Unlocked"
+msgstr ""
+
+msgid "Unresolve discussion"
+msgstr ""
+
+msgid "Unstar"
+msgstr ""
+
+msgid "Up to date"
+msgstr ""
+
+msgid "Upgrade your plan to activate Advanced Global Search."
+msgstr ""
+
+msgid "Upgrade your plan to activate Contribution Analytics."
+msgstr ""
+
+msgid "Upgrade your plan to activate Group Webhooks."
+msgstr ""
+
+msgid "Upgrade your plan to activate Issue weight."
+msgstr ""
+
+msgid "Upgrade your plan to improve Issue boards."
+msgstr ""
+
+msgid "Upload New File"
+msgstr ""
+
+msgid "Upload file"
+msgstr ""
+
+msgid "Upload new avatar"
+msgstr ""
+
+msgid "UploadLink|click to upload"
+msgstr ""
+
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
+msgid "Use the following registration token during setup:"
+msgstr ""
+
+msgid "Use your global notification setting"
+msgstr ""
+
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
+msgid "View file @ "
+msgstr ""
+
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
+msgid "View open merge request"
+msgstr ""
+
+msgid "View project labels"
+msgstr ""
+
+msgid "View replaced file @ "
+msgstr ""
+
+msgid "Visibility and access controls"
+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 could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
+msgid "We don't have enough data to show this stage."
+msgstr ""
+
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
+msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
+msgstr ""
+
+msgid "Weight"
+msgstr ""
+
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr ""
+
+msgid "Wiki"
+msgstr ""
+
+msgid "WikiClone|Clone your wiki"
+msgstr ""
+
+msgid "WikiClone|Git Access"
+msgstr ""
+
+msgid "WikiClone|Install Gollum"
+msgstr ""
+
+msgid "WikiClone|It is recommended to install %{markdown} so that GFM features render locally:"
+msgstr ""
+
+msgid "WikiClone|Start Gollum and edit locally"
+msgstr ""
+
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
+msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
+msgstr ""
+
+msgid "WikiHistoricalPage|This is an old version of this page."
+msgstr ""
+
+msgid "WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}."
+msgstr ""
+
+msgid "WikiHistoricalPage|history"
+msgstr ""
+
+msgid "WikiHistoricalPage|most recent version"
+msgstr ""
+
+msgid "WikiMarkdownDocs|More examples are in the %{docs_link}"
+msgstr ""
+
+msgid "WikiMarkdownDocs|documentation"
+msgstr ""
+
+msgid "WikiMarkdownTip|To link to a (new) page, simply type %{link_example}"
+msgstr ""
+
+msgid "WikiNewPagePlaceholder|how-to-setup"
+msgstr ""
+
+msgid "WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories."
+msgstr ""
+
+msgid "WikiNewPageTitle|New Wiki Page"
+msgstr ""
+
+msgid "WikiPageConfirmDelete|Are you sure you want to delete this page?"
+msgstr ""
+
+msgid "WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs."
+msgstr ""
+
+msgid "WikiPageConflictMessage|the page"
+msgstr ""
+
+msgid "WikiPageCreate|Create %{page_title}"
+msgstr ""
+
+msgid "WikiPageEdit|Update %{page_title}"
+msgstr ""
+
+msgid "WikiPage|Page slug"
+msgstr ""
+
+msgid "WikiPage|Write your content or drag files here..."
+msgstr ""
+
+msgid "Wiki|Create Page"
+msgstr ""
+
+msgid "Wiki|Create page"
+msgstr ""
+
+msgid "Wiki|Edit Page"
+msgstr ""
+
+msgid "Wiki|Empty page"
+msgstr ""
+
+msgid "Wiki|More Pages"
+msgstr ""
+
+msgid "Wiki|New page"
+msgstr ""
+
+msgid "Wiki|Page history"
+msgstr ""
+
+msgid "Wiki|Page version"
+msgstr ""
+
+msgid "Wiki|Pages"
+msgstr ""
+
+msgid "Wiki|Wiki Pages"
+msgstr ""
+
+msgid "With contribution analytics you can have an overview for the activity of issues, merge requests and push events of your organization and its members."
+msgstr ""
+
+msgid "Withdraw Access Request"
+msgstr ""
+
+msgid "Write a commit message..."
+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_full_name}. 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_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
+
+msgid "You are on a read-only GitLab instance."
+msgstr ""
+
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr ""
+
+msgid "You can only add files when you are on a branch"
+msgstr ""
+
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
+msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
+msgstr ""
+
+msgid "You cannot write to this read-only GitLab instance."
+msgstr ""
+
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
+msgid "You have reached your project limit"
+msgstr ""
+
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
+msgid "You must sign in to star a project"
+msgstr ""
+
+msgid "You need a different license to enable FileLocks feature"
+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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
+msgid "Your comment will not be visible to the public."
+msgstr ""
+
+msgid "Your groups"
+msgstr ""
+
+msgid "Your name"
+msgstr ""
+
+msgid "Your projects"
+msgstr ""
+
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
+msgid "new merge request"
+msgstr ""
+
+msgid "notification emails"
+msgstr ""
+
+msgid "or"
+msgstr ""
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "password"
+msgstr ""
+
+msgid "personal access token"
+msgstr ""
+
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
+msgid "to help your contributors communicate effectively!"
+msgstr ""
+
+msgid "username"
+msgstr ""
+
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/uk/gitlab.po b/locale/uk/gitlab.po
index 53054bdaa27..2c7d3751531 100644
--- a/locale/uk/gitlab.po
+++ b/locale/uk/gitlab.po
@@ -2,39 +2,83 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-21 16:43-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:35-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\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"
+"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Generator: crowdin.com\n"
"X-Crowdin-Project: gitlab-ee\n"
"X-Crowdin-Language: uk\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr " Ñ–"
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d коміт"
msgstr[1] "%d коміта"
msgstr[2] "%d комітів"
+msgstr[3] "%d комітів"
+
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] "%d коміт позаду"
+msgstr[1] "%d коміта позаду"
+msgstr[2] "%d комітів позаду"
+msgstr[3] "%d комітів позаду"
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] "%d екÑпортер"
+msgstr[1] "%d екÑпортера"
+msgstr[2] "%d екÑпортерів"
+msgstr[3] "%d екÑпортерів"
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] "%d проблема"
+msgstr[1] "%d проблеми"
+msgstr[2] "%d проблем"
+msgstr[3] "%d проблем"
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] "%d шар"
msgstr[1] "%d шари"
msgstr[2] "%d шарів"
+msgstr[3] "%d шарів"
+
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] "%d запит на злиттÑ"
+msgstr[1] "%d запита на злиттÑ"
+msgstr[2] "%d запитів на злиттÑ"
+msgstr[3] "%d запитів на злиттÑ"
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] "%d метрика"
+msgstr[1] "%d метрики"
+msgstr[2] "%d метрик"
+msgstr[3] "%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 доданий коміт був виключений Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ із швидкодією."
-msgstr[1] "%s доданих коміта були виключені Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ із швидкодією."
-msgstr[2] "%s доданих комітів були виключені Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ із швидкодією."
+msgstr[0] "%s доданий коміт був виключений Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ із продуктивніÑÑ‚ÑŽ."
+msgstr[1] "%s доданих коміта були виключені Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ із продуктивніÑÑ‚ÑŽ."
+msgstr[2] "%s доданих комітів були виключені Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ із продуктивніÑÑ‚ÑŽ."
+msgstr[3] "%s доданих комітів були виключені Ð´Ð»Ñ Ð·Ð°Ð¿Ð¾Ð±Ñ–Ð³Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼ із продуктивніÑÑ‚ÑŽ."
+
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr "%{actionText} Ñ– %{openOrClose} %{noteable}"
-msgid "%{commit_author_link} committed %{commit_timeago}"
+msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr "%{commit_author_link} закомітив %{commit_timeago}"
msgid "%{count} participant"
@@ -42,6 +86,13 @@ msgid_plural "%{count} participants"
msgstr[0] "%{count} учаÑтник"
msgstr[1] "%{count} учаÑтника"
msgstr[2] "%{count} учаÑтників"
+msgstr[3] "%{count} учаÑтників"
+
+msgid "%{loadingIcon} Started"
+msgstr "%{loadingIcon} Початок"
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr "%{lock_path} заблоковано кориÑтувачем GitLab %{lock_user_id}"
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr "на %{number_commits_behind} комітів позаду %{default_branch}, на %{number_commits_ahead} комітів попереду"
@@ -49,17 +100,21 @@ msgstr "на %{number_commits_behind} комітів позаду %{default_bran
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "%{number_of_failures} від %{maximum_failures} невдач. GitLab надаÑÑ‚ÑŒ доÑтуп на наÑтупну Ñпробу."
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "%{number_of_failures} із %{maximum_failures} невдач. GitLab заблокує доÑтуп на %{number_of_seconds} Ñекунд."
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "%{number_of_failures} від %{maximum_failures} невдач. GitLab автоматично не повторюватиме Ñпробу. Скиньте інформацію Ñховища при уÑуненні проблеми."
+msgid "%{openOrClose} %{noteable}"
+msgstr "%{openOrClose} %{noteable}"
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name}: Ñпроба невдалого доÑтупу до Ñховища на хоÑÑ‚Ñ–:"
msgstr[1] "%{storage_name}: %{failed_attempts} невдалі Ñпроби доÑтупу до Ñховища:"
msgstr[2] "%{storage_name}: %{failed_attempts} невдалих Ñпроб доÑтупу до Ñховища:"
+msgstr[3] "%{storage_name}: %{failed_attempts} невдалих Ñпроб доÑтупу до Ñховища:"
+
+msgid "%{text} is available"
+msgstr "%{text} доÑтупний"
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(перейдіть за поÑиланнÑм %{link} Ð´Ð»Ñ Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ð½Ð½Ñ Ñ–Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ñ–Ñ— ÑтоÑовно вÑтановленнÑ)."
@@ -75,6 +130,7 @@ msgid_plural "%d pipelines"
msgstr[0] "1 конвеєр"
msgstr[1] "%d конвеєра"
msgstr[2] "%d конвеєрів"
+msgstr[3] "%d конвеєрів"
msgid "1st contribution!"
msgstr "Перший внеÑок!"
@@ -82,8 +138,20 @@ msgstr "Перший внеÑок!"
msgid "2FA enabled"
msgstr "Двоетапна Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ñ–ÐºÐ°Ñ†Ñ–Ñ ÑƒÐ²Ñ–Ð¼ÐºÐ½ÐµÐ½Ð°"
+msgid "<strong>Removes</strong> source branch"
+msgstr "<strong>ВидалÑÑ”</strong> гілку-джерело"
+
msgid "A collection of graphs regarding Continuous Integration"
-msgstr "Це набір графічних елементів Ð´Ð»Ñ Ð±ÐµÐ·Ð¿ÐµÑ€ÐµÑ€Ð²Ð½Ð¾Ñ— інтеграції"
+msgstr "Ðабір графіків відноÑно безперервної інтеграції"
+
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr "У вашому форку буде Ñтворено нову гілку, а також буде ініційований новий запит на злиттÑ."
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr "Проект — це міÑце де ви можете розміщувати Ñвої файли (репозиторій), планувати роботу (проблеми) Ñ– публікувати документацію (wiki), %{among_other_things_link}."
+
+msgid "A user with write access to the source branch selected this option"
+msgstr "КориÑтувач із правом запиÑу в гілку-джерело вибрав цей варіант"
msgid "About auto deploy"
msgstr "Про авто розгортаннÑ"
@@ -91,15 +159,21 @@ msgstr "Про авто розгортаннÑ"
msgid "Abuse Reports"
msgstr "Звіти про зловживаннÑ"
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
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 "ДоÑтуп до помилкових Ñховищ тимчаÑово відключений Ð´Ð»Ñ Ð¼Ð¾Ð¶Ð»Ð¸Ð²Ð¾ÑÑ‚Ñ– Ð¼Ð¾Ð½Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ñ‚Ð° відновленнÑ. Скиньте інформацію про Ñховища піÑÐ»Ñ ÑƒÑÑƒÐ½ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸, щоб дозволити доÑтуп."
+msgstr "ДоÑтуп до Ñховищ, що вийшли з ладу, тимчаÑово прибраний Ð·Ð°Ð´Ð»Ñ Ð²Ñ–Ð´Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ð¼Ð¾Ð½Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ. ПіÑÐ»Ñ Ð²Ð¸Ñ€Ñ–ÑˆÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ обнуліть інформацію Ñховища Ð´Ð»Ñ Ð²Ñ–Ð´Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ð´Ð¾Ñтупу."
msgid "Account"
msgstr "Обліковий запиÑ"
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "Ðктивний"
@@ -110,58 +184,226 @@ msgid "Add"
msgstr "Додати"
msgid "Add Changelog"
-msgstr "Додати ÑпиÑок змін (Changelog)"
+msgstr "Додати ÑпиÑок змін"
msgid "Add Contribution guide"
-msgstr "Додати керівництво Ð´Ð»Ñ ÐºÐ¾Ð½Ñ‚Ñ€Ð¸Ð±â€™ÑŽÑ‚Ð¾Ñ€Ñ–Ð²"
+msgstr "Додати керівництво Ð´Ð»Ñ ÑƒÑ‡Ð°Ñників"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
-msgstr "Додайте групу Webhooks та GitLab Enterprise Edition."
+msgstr "Додайте групові веб-гуки та GitLab Enterprise Edition."
+
+msgid "Add Kubernetes cluster"
+msgstr "Додати Kubernetes-клаÑтер"
msgid "Add License"
msgstr "Додати ліцензію"
-msgid "Add an SSH key to your profile to pull or push via SSH."
-msgstr "Додайте SSH ключ в Ñвій профіль, щоб мати можливіÑÑ‚ÑŒ завантажити чи надіÑлати зміни через SSH."
+msgid "Add Readme"
+msgstr "Додати інÑтрукцію"
msgid "Add new directory"
msgstr "Додати новий каталог"
+msgid "Add todo"
+msgstr "Додати задачу"
+
+msgid "AdminArea|Stop all jobs"
+msgstr "Зупинити вÑÑ– завданнÑ"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "Зупинити вÑÑ– завданнÑ?"
+
+msgid "AdminArea|Stop jobs"
+msgstr "Зупинити завданнÑ"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr "Зупинка завдань пройшла невдало"
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr "Зараз ви зупинете вÑÑ– завданнÑ. Це обірве уÑÑ– запущені завданнÑ."
+
msgid "AdminHealthPageLink|health page"
msgstr "Ñторінка ÑтатуÑу"
+msgid "AdminProjects|Delete"
+msgstr "Видалити"
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr "Видалити проект %{projectName}?"
+
+msgid "AdminProjects|Delete project"
+msgstr "Видалити проект"
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr "Вкажіть домен, Ñкий буде викориÑтовуватиÑÑ Ð² проекті за замовчуваннÑм Ð´Ð»Ñ Ñтадій Auto Review Apps Ñ– Auto Deploy."
+
+msgid "AdminUsers|Block user"
+msgstr "Заблоквати кориÑтувача"
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr "Видалити кориÑтувача %{username} та його внеÑки?"
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr "Видалити кориÑтувача %{username}?"
+
+msgid "AdminUsers|Delete user"
+msgstr "Видалити кориÑтувача"
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr "Видалити кориÑтувача Ñ– його внеÑки"
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr "Ð”Ð»Ñ Ð¿Ñ–Ð´Ñ‚Ð²ÐµÑ€Ð´Ð¶ÐµÐ½Ð½Ñ Ð²Ð²ÐµÐ´Ñ–Ñ‚ÑŒ %{projectName}"
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr "Ð”Ð»Ñ Ð¿Ñ–Ð´Ñ‚Ð²ÐµÑ€Ð´Ð¶ÐµÐ½Ð½Ñ Ð²Ð²ÐµÐ´Ñ–Ñ‚ÑŒ %{username}"
+
+msgid "Advanced"
+msgstr "Розширений"
+
msgid "Advanced settings"
msgstr "Додаткові параметри"
msgid "All"
msgstr "Ð’ÑÑ–"
+msgid "All changes are committed"
+msgstr "Ð’ÑÑ– зміни закомічені"
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr "Ð’ÑÑ– функції Ð´Ð»Ñ Ð½Ð¾Ð²Ð¸Ñ… проектів берутьÑÑ Ñ–Ð· шаблонів або під Ñ‡Ð°Ñ Ñ–Ð¼Ð¿Ð¾Ñ€Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ, але ви можете вимикати Ñ—Ñ… пізніше в налаштуваннÑÑ… проекту."
+
+msgid "Allow edits from maintainers."
+msgstr "Дозволити Ñ€ÐµÐ´Ð°Ð³ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÐ¾Ð¼Ð°Ð½Ð´Ð¾ÑŽ проекту."
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr "ДозволÑÑ” додавати та керувати клаÑтерами Kubernetes."
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr "Крім того, ви можете викориÑтовувати %{personal_access_token_link}. Коли ви Ñтворюватимете Ñвій перÑональний токен доÑтупу, вам потрібно буде вибрати облаÑÑ‚ÑŒ <code>репозиторіÑ</code>, щоб ми могли відобразити ÑпиÑок ваших публічних та приватних репозиторіїв, доÑтупних Ð´Ð»Ñ Ñ–Ð¼Ð¿Ð¾Ñ€Ñ‚Ñƒ."
+
+msgid "An error occurred previewing the blob"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° під Ñ‡Ð°Ñ Ð¿Ð¾Ð¿ÐµÑ€ÐµÐ´Ð½ÑŒÐ¾Ð³Ð¾ переглÑду об'єкта"
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "Виникла помилка під Ñ‡Ð°Ñ Ð·Ð¼Ñ–Ð½Ð¸ підпиÑки на ÑповіщеннÑ"
+
+msgid "An error occurred when updating the issue weight"
+msgstr "Збій під Ñ‡Ð°Ñ Ð¾Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ð²Ð°Ð³Ð¸ проблеми"
+
+msgid "An error occurred while adding approver"
+msgstr "Помилка при додаванні учаÑника Ð´Ð»Ñ Ð·Ð°Ñ‚Ð²ÐµÑ€Ð´Ð¶ÐµÐ½Ð½Ñ"
+
+msgid "An error occurred while detecting host keys"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при виÑвленні ключів хоÑта"
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при вимкненні Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾ функцію. Оновіть Ñторінку Ñ– Ñпробуйте знову."
+
+msgid "An error occurred while fetching markdown preview"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при попередньому переглÑді markdown"
+
+msgid "An error occurred while fetching sidebar data"
+msgstr "Виникла помилка під Ñ‡Ð°Ñ Ð·Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ Ð´Ð°Ð½Ð¸Ñ… Ð´Ð»Ñ Ð±Ñ–Ñ‡Ð½Ð¾Ñ— панелі"
+
+msgid "An error occurred while fetching the pipeline."
+msgstr "Помилка при отриманні данних конвеєра."
+
+msgid "An error occurred while getting projects"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при отриманні проектів"
+
+msgid "An error occurred while importing project"
+msgstr "Помилка при імпорті проекту"
+
+msgid "An error occurred while initializing path locks"
+msgstr "Помилка при ініціалізації Ð±Ð»Ð¾ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ñ„Ð°Ð¹Ð»Ð¾Ð²Ð¸Ñ… шлÑхів"
+
+msgid "An error occurred while loading commits"
+msgstr "Помилка при завантажені комітів"
+
+msgid "An error occurred while loading diff"
+msgstr "Помилка при завантаженні разниці (diff)"
+
+msgid "An error occurred while loading filenames"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при завантаженні файлів"
+
+msgid "An error occurred while loading the file"
+msgstr "Помилка при завантаженні файлу"
+
+msgid "An error occurred while making the request."
+msgstr "Помилка при Ñтворенні запиту."
+
+msgid "An error occurred while removing approver"
+msgstr "Помилка при видаленні учаÑника Ð´Ð»Ñ Ð·Ð°Ñ‚Ð²ÐµÑ€Ð´Ð¶ÐµÐ½Ð½Ñ"
+
+msgid "An error occurred while rendering KaTeX"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при рендерингу KaTeX"
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr "Помилка при попередньому переглÑді Ð¾Ð³Ð¾Ð»Ð¾ÑˆÐµÐ½Ð½Ñ Ð´Ð»Ñ ÐºÐ¾Ñ€Ð¸Ñтувачів"
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при отриманні ÐºÐ°Ð»ÐµÐ½Ð´Ð°Ñ€Ñ Ð°ÐºÑ‚Ð¸Ð²Ð½Ð¾ÑÑ‚Ñ–"
+
+msgid "An error occurred while retrieving diff"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при отриманні різниці"
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr "Помилка при збереженні ÑтатуÑу Ð¿ÐµÑ€ÐµÐ²Ð¸Ð·Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ LDAP. Будь лаÑка, Ñпробуйте ще раз."
+
+msgid "An error occurred while saving assignees"
+msgstr "Помилка при збереженні виконавців"
+
+msgid "An error occurred while validating username"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° під Ñ‡Ð°Ñ Ð¿ÐµÑ€ÐµÐ²Ñ–Ñ€ÐºÐ¸ імені кориÑтувача"
+
msgid "An error occurred. Please try again."
msgstr "СталаÑÑŒ помилка. Спробуйте ще раз."
+msgid "Any Label"
+msgstr "Будь-Ñка мітка"
+
msgid "Appearance"
msgstr "Зовнішній виглÑд"
msgid "Applications"
-msgstr "Додатки"
+msgstr "ЗаÑтоÑунки"
+
+msgid "Apr"
+msgstr "квіт."
+
+msgid "April"
+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 leave this group?"
-msgstr "Ви впевнені що хочете залишити цю групу?"
+msgstr "Ви впевнені, що хочете видалити цей розклад Ð´Ð»Ñ ÐºÐ¾Ð½Ð²ÐµÑ”Ñ€Ð°?"
msgid "Are you sure you want to reset registration token?"
-msgstr "Ви впевнені, що бажаєте Ñкинути реєÑтраційний токен?"
+msgstr "Ви впевнені, що бажаєте перегенерувати реєÑтраційний токен?"
msgid "Are you sure you want to reset the health check token?"
-msgstr "Ви впевнені, що Ви хочете Ñкинути цей ключ перевірки працездатноÑÑ‚Ñ–?"
+msgstr "Ви впевнені, що Ви хочете перегенерувати цей ключ перевірки працездатноÑÑ‚Ñ–?"
+
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr "Ви впевнені, що хочете розблокувати %{path_lock_path}?"
msgid "Are you sure?"
msgstr "Ви впевнені?"
@@ -169,41 +411,107 @@ msgstr "Ви впевнені?"
msgid "Artifacts"
msgstr "Ðртефакти"
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr "Призначити влаÑний колір типу #FF0000"
+
+msgid "Assign labels"
+msgstr "Призначити мітку"
+
+msgid "Assign milestone"
+msgstr "Призначити етап"
+
+msgid "Assign to"
+msgstr "Призначити"
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr "Виконавець"
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "Прикріпити файл за допомогою перетÑÐ³ÑƒÐ²Ð°Ð½Ð½Ñ Ð°Ð±Ð¾ %{upload_link}"
+msgid "Aug"
+msgstr "Ñерп."
+
+msgid "August"
+msgstr "Ñерпень"
+
msgid "Authentication Log"
msgstr "Журнал автентифікації"
msgid "Author"
msgstr "Ðвтор"
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
+msgstr "Ðвтори: %{authors}"
+
+msgid "Auto DevOps enabled"
+msgstr "Auto DevOps увімкнено"
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr "Ð”Ð»Ñ ÐºÐ¾Ñ€ÐµÐºÑ‚Ð½Ð¾Ñ— роботи Auto Review Apps та Auto Deploy необхіден %{kubernetes}."
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
msgstr "Ð”Ð»Ñ ÐºÐ¾Ñ€ÐµÐºÑ‚Ð½Ð¾Ñ— роботи Auto Review Apps та Auto Deploy необхідно вказати доменне Ñ–Ð¼â€™Ñ Ñ‚Ð° %{kubernetes}."
msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr "Ð”Ð»Ñ ÐºÐ¾Ñ€ÐµÐºÑ‚Ð½Ð¾Ñ— роботи Auto Review Apps та Auto Deploy необхідно вказати доменне ім’Ñ."
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
-msgstr "Ð”Ð»Ñ ÐºÐ¾Ñ€ÐµÐºÑ‚Ð½Ð¾Ñ— роботи Auto Review Apps та Auto Deploy необхіден %{kubernetes}."
-
msgid "AutoDevOps|Auto DevOps (Beta)"
msgstr "Auto DevOps (бета)"
msgid "AutoDevOps|Auto DevOps documentation"
-msgstr "Auto DevOps документаціÑ"
+msgstr "Auto DevOps документації"
msgid "AutoDevOps|Enable in settings"
msgstr "Включити в налаштуваннÑÑ…"
msgid "AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration."
-msgstr "AutoDevOps буде автоматично збирати, теÑтувати та розгортати вашу програму на оÑнові визначеної CI/CD конфігурації."
+msgstr "AutoDevOps буде автоматично збирати, теÑтувати та розгортати ваш заÑтоÑунок на оÑнові визначеної CI/CD конфігурації."
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr "ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ в %{link_to_documentation}"
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
-msgstr "Ви можете активувати %{link_to_settings} Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr "Ви можете автоматично збирати й теÑтувати ваш заÑтоÑунок, Ñкщо %{link_to_auto_devops_settings} Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту. Ви також можете його автоматично розгортати, Ñкщо %{link_to_add_kubernetes_cluster}."
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr "додати Kubernetes-клаÑтер"
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr "Увімкнути Auto DevOps (Beta)"
+
+msgid "Available"
+msgstr "ДоÑтупно"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr "Ðватар буде видалено. Ви впевнені?"
+
+msgid "Average per day: %{average}"
+msgstr "Ð’ Ñередньому за день: %{average}"
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr "Почати із виділеного коміту"
msgid "Billing"
msgstr "Білінг"
@@ -236,10 +544,10 @@ msgid "BillingPlans|See all %{plan_name} features"
msgstr "ПодивітьÑÑ Ð²ÑÑ– можливоÑÑ‚Ñ– %{plan_name}"
msgid "BillingPlans|This group uses the plan associated with its parent group."
-msgstr "Ð¦Ñ Ð³Ñ€ÑƒÐ¿Ð° викориÑтовує план, пов'Ñзаний з батьківÑькою групою."
+msgstr "Ð¦Ñ Ð³Ñ€ÑƒÐ¿Ð° викориÑтовує план, пов'Ñзаний із батьківÑькою групою."
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
-msgstr "Ð”Ð»Ñ ÑƒÐ¿Ñ€Ð°Ð²Ð»Ñ–Ð½Ð½Ñ Ð¿Ð»Ð°Ð½Ð¾Ð¼ цієї групи відвідайте Ñекцію оплати %{parent_billing_page_link}."
+msgstr "Щоб керувати планом цієї групи, відвідайте розділ білінгу на %{parent_billing_page_link}."
msgid "BillingPlans|Upgrade"
msgstr "Підвищити"
@@ -257,13 +565,14 @@ msgid "BillingPlans|paid annually at %{price_per_year}"
msgstr "ОплачуєтьÑÑ Ñ‰Ð¾Ñ€Ñ–Ñ‡Ð½Ð¾ %{price_per_year}"
msgid "BillingPlans|per user"
-msgstr "За кориÑтувача"
+msgstr "за кориÑтувача"
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "Гілка"
-msgstr[1] "Гілки"
-msgstr[2] "Гілок"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+msgstr[0] "Гілка (%{branch_count})"
+msgstr[1] "Гілки (%{branch_count})"
+msgstr[2] "Гілок (%{branch_count})"
+msgstr[3] "Гілок (%{branch_count})"
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}"
@@ -271,15 +580,30 @@ msgstr "Гілка <strong>%{branch_name}</strong> Ñтворена. Ð”Ð»Ñ Ð½Ð°
msgid "Branch has changed"
msgstr "Гілка змінилаÑÑŒ"
+msgid "Branch is already taken"
+msgstr "Гілка вже Ñ–Ñнує"
+
+msgid "Branch name"
+msgstr "Ðазва гілки"
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "Пошук гілок"
msgid "BranchSwitcherTitle|Switch branch"
-msgstr "Переключити гілку"
+msgstr "Перейти в гілку"
msgid "Branches"
msgstr "Гілки"
+msgid "Branches|Active"
+msgstr "Ðктивні"
+
+msgid "Branches|Active branches"
+msgstr "Ðктивні гілки"
+
+msgid "Branches|All"
+msgstr "Ð’ÑÑ–"
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr "Ðе можу знайти HEAD-коміт Ð´Ð»Ñ Ñ†Ñ–Ñ”Ñ— гілки"
@@ -325,12 +649,39 @@ msgstr "Як тільки ви підтвердите Ñ– натиÑнете %{de
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr "Тільки керівник або влаÑник проекту може видалити захищену гілку"
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
-msgstr "Керувати захищеними гілками можливо в %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr "ОглÑд"
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr "Керувати захищеними гілками можливо в %{project_settings_link}."
+
+msgid "Branches|Show active branches"
+msgstr "Показувати активні гілки"
+
+msgid "Branches|Show all branches"
+msgstr "Показувати вÑÑ– гілки"
+
+msgid "Branches|Show more active branches"
+msgstr "Показати більше активних гілок"
+
+msgid "Branches|Show more stale branches"
+msgstr "Показати більше заÑтарілих гілок"
+
+msgid "Branches|Show overview of the branches"
+msgstr "Показати Ð¾Ð¿Ð¸Ñ Ð³Ñ–Ð»Ð¾Ðº"
+
+msgid "Branches|Show stale branches"
+msgstr "Показати заÑтарілі гілки"
msgid "Branches|Sort by"
msgstr "Сортувати за"
+msgid "Branches|Stale"
+msgstr "ЗаÑтарілі"
+
+msgid "Branches|Stale branches"
+msgstr "ЗаÑтарілі гілки"
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr "Гілка не може бути оновлена автоматично, тому що вона має розбіжноÑÑ‚Ñ– із upstream."
@@ -362,7 +713,7 @@ msgid "Branches|project settings"
msgstr "ÐалаштуваннÑÑ… проекту"
msgid "Branches|protected"
-msgstr "захищені"
+msgstr "захищена"
msgid "Browse Directory"
msgstr "ПереглÑнути каталог"
@@ -376,14 +727,23 @@ msgstr "ПереглÑд файлів"
msgid "Browse files"
msgstr "ПереглÑд файлів"
+msgid "Business"
+msgstr "БізнеÑ"
+
msgid "ByAuthor|by"
msgstr "від"
msgid "CI / CD"
msgstr "CI / CD"
-msgid "CI configuration"
-msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ CI"
+msgid "CI/CD"
+msgstr "CI/CD"
+
+msgid "CI/CD configuration"
+msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ CI/CD"
+
+msgid "CI/CD for external repo"
+msgstr "CI/CD Ð´Ð»Ñ Ð·Ð¾Ð²Ð½Ñ–ÑˆÐ½ÑŒÐ¾Ð³Ð¾ репозиторіÑ"
msgid "CICD|Jobs"
msgstr "ЗавданнÑ"
@@ -391,26 +751,41 @@ msgstr "ЗавданнÑ"
msgid "Cancel"
msgstr "СкаÑувати"
-msgid "Cancel edit"
-msgstr "Відмінити правку"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr "Ðеможливо змінити керований клаÑтер Kubernetes"
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
msgstr "Вага зміни"
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "Вибрати в гілці"
msgid "ChangeTypeActionLabel|Revert in branch"
-msgstr "СкаÑувати у гілці"
+msgstr "Ðнулювати у гілці"
msgid "ChangeTypeAction|Cherry-pick"
-msgstr "Cherry-pick"
+msgstr "Вибрати (cherry-pick)"
msgid "ChangeTypeAction|Revert"
-msgstr "СкаÑувати"
+msgstr "Ðнулювати коміт"
+
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr "Це Ñтворить новий коміт, щоб анулювати Ñ–Ñнуючі зміни."
msgid "Changelog"
-msgstr "СпиÑок змін (Changelog)"
+msgstr "СпиÑок змін"
+
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr "Зміни відображаютьÑÑ Ñ‚Ð°Ðº, ніби <b>редакціÑ-джерело</b> була злита в <b>цільову редакцію</b>."
msgid "Charts"
msgstr "Графіки"
@@ -418,14 +793,41 @@ msgstr "Графіки"
msgid "Chat"
msgstr "Чат"
+msgid "Check interval"
+msgstr "Інтервал перевірки"
+
+msgid "Checking %{text} availability…"
+msgstr "Перевірка доÑтупноÑÑ‚Ñ– %{text}…"
+
+msgid "Checking branch availability..."
+msgstr "Перевірка доÑтупноÑÑ‚Ñ– гілки..."
+
msgid "Cherry-pick this commit"
-msgstr "Cherry-pick в цьому коміті"
+msgstr "Вибрати (cherry-pick) цей коміт"
msgid "Cherry-pick this merge request"
-msgstr "Cherry-pick в цьому запиті на злиттÑ"
+msgstr "Вибрати (cherry-pick) цей запит на злиттÑ"
+
+msgid "Choose File ..."
+msgstr "Виберіть файл ..."
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr "Виберіть гілку чи тег (напр. %{master}) або введіть коміт (напр. %{sha}) Ð´Ð»Ñ Ð¿ÐµÑ€ÐµÐ³Ð»Ñду змін або Ð´Ð»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ на злиттÑ."
+
+msgid "Choose file..."
+msgstr "Виберіть файл..."
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr "Виберіть групи Ð´Ð»Ñ Ñинхронізації на цей вторинний вузол."
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
-msgstr "Виберіть, Ñкі групи ви хочете реплікувати на цю вторинну ноду. Залиште порожнім, щоб реплікувати вÑе."
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr "Виберіть, Ñкі репозиторії ви хочете підключити Ñ– запуÑтити конвеєри CI/CD."
+
+msgid "Choose which repositories you want to import."
+msgstr "Виберіть, Ñкі репозиторії ви хочете імпортувати."
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+msgstr "Виберіть Ñегменти Ð´Ð»Ñ Ñинхронізації на цей вторинний вузол."
msgid "CiStatusLabel|canceled"
msgstr "ÑкаÑовано"
@@ -481,56 +883,146 @@ msgstr "пропущено"
msgid "CiStatus|running"
msgstr "виконуєтьÑÑ"
+msgid "CiVariables|Input variable key"
+msgstr "Ключ вхідної змінної"
+
+msgid "CiVariables|Input variable value"
+msgstr "Ð—Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ Ð²Ñ…Ñ–Ð´Ð½Ð¾Ñ— змінної"
+
+msgid "CiVariables|Remove variable row"
+msgstr "Видалити Ñ€Ñдок змінних"
+
+msgid "CiVariable|* (All environments)"
+msgstr "* (Ð’ÑÑ– Ñередовища)"
+
+msgid "CiVariable|All environments"
+msgstr "Ð’ÑÑ– Ñередовища"
+
+msgid "CiVariable|Create wildcard"
+msgstr "Створити шаблон"
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr "Помилка при збереженні змінних"
+
+msgid "CiVariable|New environment"
+msgstr "Ðове Ñередовище"
+
+msgid "CiVariable|Protected"
+msgstr "Захищений"
+
+msgid "CiVariable|Search environments"
+msgstr "Пошук Ñередовищ"
+
+msgid "CiVariable|Toggle protected"
+msgstr "Ввімкнути/вимкнути захиÑÑ‚"
+
+msgid "CiVariable|Validation failed"
+msgstr "Перевірка невдала"
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr "circuitbreaker api"
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr "ÐатиÑніть кнопку нижче, щоб розпочати Ð¿Ñ€Ð¾Ñ†ÐµÑ Ð²ÑтановленнÑ, перейшовши на Ñторінку Kubernetes"
+
+msgid "Click to expand text"
+msgstr "ÐатиÑніть, щоб розгорнути текÑÑ‚"
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr "Клонувати репозиторій"
msgid "Close"
msgstr "Закрити"
-msgid "Cluster"
-msgstr "КлаÑтер"
+msgid "Closed"
+msgstr "Закрито"
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr "%{appList} були уÑпішно вÑтановлені на ваш Kubernetes-клаÑтер"
+
+msgid "ClusterIntegration|API URL"
+msgstr "API URL"
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr "Додати Kubernetes клаÑтер"
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr "Додати Ñ–Ñнуючий Kubernetes-клаÑтер"
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr "Детальні Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ñ–Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ— із цим Kubernetes-клаÑтером"
+
+msgid "ClusterIntegration|Applications"
+msgstr "ЗаÑтоÑунки"
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr "Ви впевнені, що хочете видалити інтеграцію із цим Kubernetes-клаÑтером? Це не призведе до Ð²Ð¸Ð´Ð°Ð»ÐµÐ½Ð½Ñ Ñамого клаÑтера."
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr "Сертифікат центру Ñертифікації"
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr "Ðабір Ñертифікатів (формат PEM)"
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr "Виберіть ÑпоÑіб Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ñ–Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ— із Kubernetes-клаÑтером"
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
-msgstr "%{link_to_container_project} напевно було Ñтворено під цим обліковим запиÑом"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr "Виберіть Ñкі з Ñередовищ вашого проекту викориÑтовуватимуть цей Kubernetes-клаÑтер."
-msgid "ClusterIntegration|Cluster details"
-msgstr "Параметри клаÑтера"
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr "Керуйте ÑпоÑобом інтеграції вашого Kubernetes-клаÑтера з GitLab"
-msgid "ClusterIntegration|Cluster integration"
-msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· клаÑтером"
+msgid "ClusterIntegration|Copy API URL"
+msgstr "Скопіювати API URL"
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
-msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· клаÑтером вимкнена Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту."
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr "Скопіювати Ñертифікат центру Ñертифікації"
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
-msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· клаÑтером увімкнена Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту."
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr "Копіювати IP-адреÑу Ingress в буфер обміну"
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
-msgstr "Ð”Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту увімкнена Ñ–Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· клаÑтером. Ð’Ð¸ÐºÐ½ÐµÐ½Ð½Ñ Ñ–Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ— не вплине на клаÑтер, але з'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ GitLab з ним буде тимчаÑово розірване."
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr "Скопіювати Ñ–Ð¼â€™Ñ Kubernetes-клаÑтера"
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
-msgstr "СтворюєтьÑÑ ÐºÐ»Ð°Ñтер в Google Kubernetes Engine..."
+msgid "ClusterIntegration|Copy Token"
+msgstr "Скопіювати Токен"
-msgid "ClusterIntegration|Cluster name"
-msgstr "Ім'Ñ ÐºÐ»Ð°Ñтера"
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr "Створити Kubernetes-клаÑтер"
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
-msgstr "КлаÑтер був уÑпішно Ñтворений в Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr "Створити Kubernetes-клаÑтер на Google Kubernetes Engine"
-msgid "ClusterIntegration|Copy cluster name"
-msgstr "Копіювати назву клаÑтера"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr "Створити Kubernetes-клаÑтер на Google Kubernetes Engine прÑмо із GitLab"
-msgid "ClusterIntegration|Create cluster"
-msgstr "Створити клаÑтер"
+msgid "ClusterIntegration|Create on GKE"
+msgstr "Створити в GKE"
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
-msgstr "Створити новий клаÑтер в Google Kubernetes Engine"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr "Вкажіть параметри Ñ–Ñнуючого клаÑтера Kubernetes"
-msgid "ClusterIntegration|Enable cluster integration"
-msgstr "Увімкнути інтеграцію із клаÑтерами"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr "Введіть параметри вашого Kubernetes-клаÑтера"
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ð· GitLab"
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr "GitLab Runner"
msgid "ClusterIntegration|Google Cloud Platform project ID"
msgstr "Ідентифікатор проекту в Google Cloud Platform"
@@ -541,44 +1033,146 @@ msgstr "Google Kubernetes Engine"
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr "Проект Google Kubernetes Engine"
+msgid "ClusterIntegration|Helm Tiller"
+msgstr "Helm Tiller"
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr "Ð”Ð»Ñ Ñ‚Ð¾Ð³Ð¾, щоб показувати Ñтан клаÑтера, нам потрібно буде вÑтановити Prometheus на ваш клаÑтер Ð´Ð»Ñ Ð·Ð±Ð¾Ñ€Ñƒ необхідних даних."
+
+msgid "ClusterIntegration|Ingress"
+msgstr "Ingress"
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr "Ingress IP-адреÑа"
+
+msgid "ClusterIntegration|Install"
+msgstr "Ð’Ñтановити"
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr "Ð’Ñтановити Prometheus"
+
+msgid "ClusterIntegration|Installed"
+msgstr "Ð’Ñтановлений"
+
+msgid "ClusterIntegration|Installing"
+msgstr "Ð’ÑтановленнÑ"
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ ÐºÐ»Ð°Ñтерної автоматизації Kubernetes"
+
+msgid "ClusterIntegration|Integration status"
+msgstr "Ð¡Ñ‚Ð°Ñ‚ÑƒÑ Ñ–Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ—"
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr "Kubernetes-клаÑтер"
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr "Параметри Kubernetes-клаÑтера"
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr "Стан Kubernetes-клаÑтера"
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· Kubernetes-клаÑтером"
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· Kubernetes-клаÑтером вимкнена Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту."
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· Kubernetes-клаÑтером увімкнена Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту."
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· Kubernetes-клаÑтером увімкнена Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту. Ð’Ð¸Ð¼ÐºÐ½ÐµÐ½Ð½Ñ Ñ†Ñ–Ñ”Ñ— інтеграції не вплине на клаÑтер, а лише тимчаÑово перерве Ð·â€™Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð· GitLab."
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr "Kubernetes-клаÑтер ÑтворюєтьÑÑ Ð½Ð° Google Kubernetes Engine..."
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr "Ð†Ð¼â€™Ñ Kubernetes-клаÑтера"
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr "Kubernetes-клаÑтер був уÑпішно Ñтворений на Google Kubernetes Engine. Оновіть Ñторінку, щоб побачити параметри клаÑтера"
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr "Kubernetes-клаÑтери дозволÑÑŽÑ‚ÑŒ вам викориÑтовувати Review Apps, розгортати ваші заÑтоÑунки, запуÑкати конвеєри Ñ– багато іншого проÑтим ÑпоÑобом. %{link_to_help_page}"
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr "Kubernetes-клаÑтери можуть бути викориÑтані Ð´Ð»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð·Ð°ÑтоÑунків Ñ– викориÑÑ‚Ð°Ð½Ð½Ñ Review Apps Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту"
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr "ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ про %{link_to_documentation}"
+msgid "ClusterIntegration|Learn more about environments"
+msgstr "ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ про Ñередовища"
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr "ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ про конфігурацію безпеки"
+
msgid "ClusterIntegration|Machine type"
msgstr "Тип машини"
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
-msgstr "ПереконайтеÑÑ, що ваш обліковий Ð·Ð°Ð¿Ð¸Ñ %{link_to_requirements} Ð´Ð»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ ÐºÐ»Ð°Ñтерів"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr "ВпевнітьÑÑ, що ваш обліковий Ð·Ð°Ð¿Ð¸Ñ Ð·Ð°Ð´Ð¾Ð²Ñ–Ð»ÑŒÐ½ÑÑ” %{link_to_requirements} Ð´Ð»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Kubernetes-клаÑтерів"
+
+msgid "ClusterIntegration|Manage"
+msgstr "УправліннÑ"
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr "Керуйте вашим Kubernetes-клаÑтером за допомогою %{link_gke}"
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
-msgstr "Ð£Ð¿Ñ€Ð°Ð²Ð»Ñ–Ð½Ð½Ñ Ñ–Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ”ÑŽ із клаÑтером у вашому Gitlab-проекті."
+msgid "ClusterIntegration|More information"
+msgstr "Додаткова інформаціÑ"
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
-msgstr "Ð”Ð»Ñ ÐºÐµÑ€ÑƒÐ²Ð°Ð½Ð½Ñ Ñвоїм клаÑтером перейдіть на %{link_gke}"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr "Декілька Kubernetes-клаÑтерів доÑтупні в GitLab Enterprise Edition Premium та Ultimate"
+
+msgid "ClusterIntegration|Note:"
+msgstr "Примітка:"
msgid "ClusterIntegration|Number of nodes"
msgstr "КількіÑÑ‚ÑŒ вузлів"
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr "Введіть інформацію про доÑтуп до Ñвого Kubernetes-клаÑтера. Якщо вам потрібна допомога, ви можете прочитати наші %{link_to_help_page} про Kubernetes"
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
-msgstr "Будь-лаÑка впевнітьÑÑ, що ваш Google-аккаунт задовольнÑÑ” наÑтупним вимогам:"
+msgstr "Будь-лаÑка впевнітьÑÑ, що ваш обліковий Ð·Ð°Ð¿Ð¸Ñ Google задовольнÑÑ” наÑтупним вимогам:"
+
+msgid "ClusterIntegration|Project ID"
+msgstr "Ідентифікатор проекту"
+
+msgid "ClusterIntegration|Project namespace"
+msgstr "ПроÑÑ‚Ñ–Ñ€ імен проекту"
msgid "ClusterIntegration|Project namespace (optional, unique)"
-msgstr "Namespace проекту (не обов’Ñзковий, унікальний)"
+msgstr "ПроÑÑ‚Ñ–Ñ€ імен проекту (не обов’Ñзковий, унікальний)"
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr "Прочитайте нашу документацію %{link_to_help_page} по інтеграції із клаÑтером."
+msgid "ClusterIntegration|Prometheus"
+msgstr "Prometheus"
-msgid "ClusterIntegration|Remove cluster integration"
-msgstr "Видалити інтеграцію з клаÑтером"
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr "ПереглÑньте нашу %{link_to_help_page} про інтеграцію із Kubernetes."
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr "Відалити інтеграцію із Kubernetes-клаÑтером"
msgid "ClusterIntegration|Remove integration"
msgstr "Видалити інтеграцію"
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
-msgstr "При видаленні інтеграції з клаÑтером буде видалена ÐºÐ¾Ð½Ñ„Ñ–Ð³ÑƒÑ€Ð°Ñ†Ñ–Ñ ÐºÐ»Ð°Ñтера, Ñку ви додали в цей проект. Дана Ð´Ñ–Ñ Ð½Ðµ видалить Ñам проект."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr "Видалити конфігурацію Kubernetes-клаÑтера Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту. Це не призведе до Ð²Ð¸Ð´Ð°Ð»ÐµÐ½Ð½Ñ Ñамого клаÑтера."
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr "Запит про початок вÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ð½Ðµ виконано"
-msgid "ClusterIntegration|See and edit the details for your cluster"
-msgstr "ПереглÑнути та редагувати параметри вашого клаÑтера"
+msgid "ClusterIntegration|Save changes"
+msgstr "Зберегти зміни"
+
+msgid "ClusterIntegration|Security"
+msgstr "Безпека"
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr "ПереглÑнути та редагувати параметри вашого Kubernetes-клаÑтера"
msgid "ClusterIntegration|See machine types"
msgstr "ПереглÑнути типи машин"
@@ -589,17 +1183,38 @@ msgstr "ПереглÑнути ваші проекти"
msgid "ClusterIntegration|See zones"
msgstr "ПереглÑнути зони"
+msgid "ClusterIntegration|Service token"
+msgstr "Токен СервіÑа"
+
+msgid "ClusterIntegration|Show"
+msgstr "Показати"
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr "ЩоÑÑŒ пішло не так з нашого боку."
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
-msgstr "ЩоÑÑŒ пішло не так під Ñ‡Ð°Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ ÐºÐ»Ð°Ñтера в Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr "Помилка при Ñтворенні вашого Kubernetes-клаÑтера на Google Kubernetes Engine"
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr "Під Ñ‡Ð°Ñ Ð²ÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ %{title} ÑталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ°"
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr "Стандартна ÐºÐ¾Ð½Ñ„Ñ–Ð³ÑƒÑ€Ð°Ñ†Ñ–Ñ ÐºÐ»Ð°Ñтера надає доÑтуп до широкого набору функцій, необхідних Ð´Ð»Ñ ÑƒÑпішного ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ñ‚Ð° Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð·Ð°ÑтоÑунків-контейнерів."
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr "Цей обліковий Ð·Ð°Ð¿Ð¸Ñ Ð¿Ð¾Ð²Ð¸Ð½ÐµÐ½ мати наÑтупні права Ð´Ð»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Kubernetes-клаÑтера в %{link_to_container_project}"
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr "Увімкнути/вимкнути Kubernetes-клаÑтер"
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr "Увімкнути/вимкнути Kubernetes-клаÑтер"
-msgid "ClusterIntegration|Toggle Cluster"
-msgstr "Переключити КлаÑтер"
+msgid "ClusterIntegration|Token"
+msgstr "Токен"
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
-msgstr "За допомогою підключеного до цього проекту клаÑтера, ви можете викориÑтовувати Review Apps, розгортати ваші проекти, запуÑкати конвеєри збірки та багато іншого."
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr "За допомогою підключеного до цього проекту Kubernetes-клаÑтера, ви можете викориÑтовувати Review Apps, розгортати ваші проекти, запуÑкати конвеєри збірки тощо."
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
msgstr "Ваш обліковий Ð·Ð°Ð¿Ð¸Ñ Ð¿Ð¾Ð²Ð¸Ð½ÐµÐ½ мати %{link_to_kubernetes_engine}"
@@ -610,18 +1225,33 @@ msgstr "Зона"
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr "доÑтуп до Google Kubernetes Engine"
-msgid "ClusterIntegration|cluster"
-msgstr "клаÑтер"
+msgid "ClusterIntegration|check the pricing here"
+msgstr "переглÑнути ціни тут"
+
+msgid "ClusterIntegration|documentation"
+msgstr "документації"
msgid "ClusterIntegration|help page"
msgstr "Ñторінка допомоги"
+msgid "ClusterIntegration|installing applications"
+msgstr "вÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ð·Ð°ÑтоÑунків"
+
msgid "ClusterIntegration|meets the requirements"
msgstr "задовольнÑÑ” вимогам"
msgid "ClusterIntegration|properly configured"
msgstr "правильно налаштований"
+msgid "Collapse"
+msgstr "Згорнути"
+
+msgid "Comment and resolve discussion"
+msgstr "Залишити коментар Ñ– завершити обговореннÑ"
+
+msgid "Comment and unresolve discussion"
+msgstr "Залишити коментар Ñ– повторно відкрити обговореннÑ"
+
msgid "Comments"
msgstr "Коментарі"
@@ -630,12 +1260,14 @@ msgid_plural "Commits"
msgstr[0] "Коміт"
msgstr[1] "Коміта"
msgstr[2] "Комітів"
+msgstr[3] "Комітів"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
-msgstr[0] "Закомітити %d файл"
-msgstr[1] "Закомітити %d файли"
-msgstr[2] "Закомітити %d файлів"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] "Коміт (%{commit_count})"
+msgstr[1] "Коміта (%{commit_count})"
+msgstr[2] "Комітів (%{commit_count})"
+msgstr[3] "Комітів (%{commit_count})"
msgid "Commit Message"
msgstr "Коміт-повідомелннÑ"
@@ -646,6 +1278,12 @@ msgstr "ТриваліÑÑ‚ÑŒ оÑтанніх 30 комітів у хвилинÐ
msgid "Commit message"
msgstr "Коміт-повідомленнÑ"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr "СтатиÑтика комітів Ð´Ð»Ñ %{ref} %{start_time} - %{end_time}"
+
+msgid "Commit to %{branchName} branch"
+msgstr "Закомітити в гілку %{branchName}"
+
msgid "CommitBoxTitle|Commit"
msgstr "Коміт"
@@ -658,15 +1296,99 @@ msgstr "Коміти"
msgid "Commits feed"
msgstr "Канал комітів"
+msgid "Commits per day hour (UTC)"
+msgstr "Комітів по годинам Ð´Ð½Ñ (за Грінвічем)"
+
+msgid "Commits per day of month"
+msgstr "Комітів по днÑм міÑÑцÑ"
+
+msgid "Commits per weekday"
+msgstr "Комітів по днÑм тижнÑ"
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° під Ñ‡Ð°Ñ Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ð½Ð½Ñ Ð´Ð°Ð½Ð¸Ñ… запиту на злиттÑ."
+
+msgid "Commits|Commit: %{commitText}"
+msgstr "Коміт: %{commitText}"
+
msgid "Commits|History"
msgstr "ІÑторіÑ"
+msgid "Commits|No related merge requests found"
+msgstr "Ðе знайдено пов'Ñзаних запитів на злиттÑ"
+
msgid "Committed by"
msgstr "Коміт від"
msgid "Compare"
msgstr "ПорівнÑти"
+msgid "Compare Git revisions"
+msgstr "ПорівнÑти Git-редакції"
+
+msgid "Compare Revisions"
+msgstr "ПорівнÑÐ½Ð½Ñ Ñ€ÐµÐ´Ð°ÐºÑ†Ñ–Ð¹"
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr "%{source_branch} і %{target_branch} однакові."
+
+msgid "CompareBranches|Compare"
+msgstr "ПорівнÑти"
+
+msgid "CompareBranches|Source"
+msgstr "Джерело"
+
+msgid "CompareBranches|Target"
+msgstr "Ціль"
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr "КонфіденційніÑÑ‚ÑŒ"
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr "Підключити"
+
+msgid "Connect all repositories"
+msgstr "Підключити вÑÑ– репозиторії"
+
+msgid "Connect repositories from GitHub"
+msgstr "Підключити репозиторії з GitHub"
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr "Підключіть ваші зовнішні репозиторії, Ñ– CI/CD конвеєри будуть запуÑкатиÑÑ Ð´Ð»Ñ Ð½Ð¾Ð²Ð¸Ñ… комітів. Створений GitLab-проект буде мати лише CI/CD фунції."
+
+msgid "Connecting..."
+msgstr "З'єднаннÑ..."
+
msgid "Container Registry"
msgstr "РеєÑÑ‚Ñ€ Контейнерів"
@@ -704,7 +1426,7 @@ msgid "ContainerRegistry|Tag"
msgstr "Тег"
msgid "ContainerRegistry|Tag ID"
-msgstr "Тег ID"
+msgstr "Ідентифікатор тегу"
msgid "ContainerRegistry|Use different image names"
msgstr "ВикориÑтовуйте різні імена образів"
@@ -712,11 +1434,29 @@ msgstr "ВикориÑтовуйте різні імена образів"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr "За допомогою вбудованого в GitLab реєÑтру Docker контейнерів кожен проект може мати влаÑне міÑце Ð´Ð»Ñ Ð·Ð±ÐµÑ€Ñ–Ð³Ð°Ð½Ð½Ñ Docker образів."
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr "ВнеÑок"
+
msgid "Contribution guide"
-msgstr "Керівництво контриб’юторів"
+msgstr "ІнÑÑ‚Ñ€ÑƒÐºÑ†Ñ–Ñ Ð´Ð»Ñ ÑƒÑ‡Ð°Ñників"
msgid "Contributors"
-msgstr "Контриб’ютори"
+msgstr "УчаÑники"
+
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr "%{startDate} – %{endDate}"
+
+msgid "ContributorsPage|Building repository graph."
+msgstr "Ð¡Ñ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ñхеми репозиторію."
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr "Коміти в %{branch_name}, за винÑтком комітів злиттÑ. Обмежено 6000 комітів."
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr "Будь лаÑка, зачекайте, Ñ†Ñ Ñторінка автоматично оновитьÑÑ, коли буде готова."
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr "Задати макÑимальну кількіÑÑ‚ÑŒ потоків Ð´Ð»Ñ Ñ„Ð¾Ð½Ð¾Ð²Ð¾Ð³Ð¾ Ð·Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ LFS/вкладень Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ вторинного вузла"
@@ -730,27 +1470,60 @@ msgstr "Скопіюйте відкритий SSH-ключ в буфер обмÑ
msgid "Copy URL to clipboard"
msgstr "Скопіювати URL в буфер обміну"
+msgid "Copy branch name to clipboard"
+msgstr "Скопіювати назву гілки в буфер обміну"
+
+msgid "Copy command to clipboard"
+msgstr "Скопіювати команду в буфер обміну"
+
msgid "Copy commit SHA to clipboard"
msgstr "Скопіювати ідентифікатор в буфер обміну"
+msgid "Copy reference to clipboard"
+msgstr "Скопіювати поÑÐ¸Ð»Ð°Ð½Ð½Ñ Ð² буфер обміну"
+
+msgid "Create"
+msgstr "Створити"
+
msgid "Create New Directory"
msgstr "Створити новий каталог"
+msgid "Create a new branch"
+msgstr "Створити нову гілку"
+
+msgid "Create a new branch and merge request"
+msgstr "Створити нову гілку Ñ– запит на злиттÑ"
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
-msgstr "Створити токен доÑтупу Ð´Ð»Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ аккауета, щоб відправлÑти або отримувати через %{protocol}."
+msgstr "Створіть токен доÑтупу Ð´Ð»Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ аккаунта, щоб відправлÑти та отримувати через %{protocol}."
+
+msgid "Create branch"
+msgstr "Створити гілку"
msgid "Create directory"
msgstr "Створити каталог"
-msgid "Create empty bare repository"
-msgstr "Створити порожній репозиторій"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr "Створити епік"
msgid "Create file"
msgstr "Створити файл"
+msgid "Create group label"
+msgstr "Створити мітку групи"
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr "Створити ÑпиÑок на оÑтнові міток. Ð’ ньому будуть проблеми з такими мітками."
+
msgid "Create merge request"
msgstr "Створити запит на злиттÑ"
+msgid "Create merge request and branch"
+msgstr "Створити запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ñ‚Ð° гілку"
+
msgid "Create new branch"
msgstr "Створити нову гілку"
@@ -760,9 +1533,15 @@ msgstr "Створити новий каталог"
msgid "Create new file"
msgstr "Створити новий файл"
+msgid "Create new label"
+msgstr "Створити нову мітку"
+
msgid "Create new..."
msgstr "Створити..."
+msgid "Create project label"
+msgstr "Створити мітку проекту"
+
msgid "CreateNewFork|Fork"
msgstr "Форк"
@@ -772,26 +1551,38 @@ msgstr "Тег"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "Створити токен Ð´Ð»Ñ Ð¾ÑобиÑтого доÑтупу"
+msgid "Creates a new branch from %{branchName}"
+msgstr "Створює нову гілку із %{branchName}"
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr "Створює нову гілку із %{branchName} Ñ– перенаправлÑÑ” Ð´Ð»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð½Ð¾Ð²Ð¾Ð³Ð¾ запиту на злиттÑ"
+
+msgid "Creating epic"
+msgstr "Ð¡Ñ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ ÐµÐ¿Ñ–ÐºÑƒ"
+
msgid "Cron Timezone"
msgstr "ЧаÑовий поÑÑ Cron"
msgid "Cron syntax"
msgstr "СинтакÑÐ¸Ñ Cron"
+msgid "Current node"
+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 "Customize colors"
+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 "Код"
+msgstr "ÐапиÑÐ°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ"
msgid "CycleAnalyticsStage|Issue"
msgstr "Проблема"
@@ -800,13 +1591,13 @@ msgid "CycleAnalyticsStage|Plan"
msgstr "ПлануваннÑ"
msgid "CycleAnalyticsStage|Production"
-msgstr "ПРОД"
+msgstr "Production"
msgid "CycleAnalyticsStage|Review"
msgstr "ЗатвердженнÑ"
msgid "CycleAnalyticsStage|Staging"
-msgstr "ДЕВ"
+msgstr "Staging"
msgid "CycleAnalyticsStage|Test"
msgstr "ТеÑтуваннÑ"
@@ -817,6 +1608,15 @@ msgstr "Ð’ÑÑ–"
msgid "DashboardProjects|Personal"
msgstr "ОÑобиÑÑ‚Ñ–"
+msgid "Dec"
+msgstr "груд."
+
+msgid "December"
+msgstr "грудень"
+
+msgid "Default classification label"
+msgstr "Мітка клаÑифікації за замовчуваннÑм"
+
msgid "Define a custom pattern with cron syntax"
msgstr "Визначте влаÑний шаблон за допомогою ÑинтакÑиÑу cron"
@@ -828,34 +1628,50 @@ msgid_plural "Deploys"
msgstr[0] "РозгортаннÑ"
msgstr[1] "РозгортаннÑ"
msgstr[2] "Розгортань"
+msgstr[3] "Розгортань"
msgid "Deploy Keys"
-msgstr "Ключи Ð´Ð»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ"
+msgstr "Ключі Ð´Ð»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ"
msgid "Description"
msgstr "ОпиÑ"
msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
-msgstr "Шаблони опиÑу дозволÑÑŽÑ‚ÑŒ визначити конкретні шаблони обговорень та запитів на Ð·Ð»Ð¸Ð²Ð°Ð½Ð½Ñ Ð´Ð»Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ проекту."
+msgstr "Шаблони опиÑу дозволÑÑŽÑ‚ÑŒ визначити конкретні шаблони обговорень проблем та запитів на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð´Ð»Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ проекту."
msgid "Details"
msgstr "Деталі"
+msgid "Diffs|No file name available"
+msgstr "Ім'Ñ Ñ„Ð°Ð¹Ð»Ñƒ не доÑтупне"
+
msgid "Directory name"
msgstr "Ім'Ñ ÐºÐ°Ñ‚Ð°Ð»Ð¾Ð³Ñƒ"
-msgid "Discard changes"
-msgstr "СкаÑувати зміни"
+msgid "Disable"
+msgstr "Вимкнути"
+
+msgid "Discard draft"
+msgstr "Видалити чернетку"
+
+msgid "Discover GitLab Geo."
+msgstr "Відкрийте GitLab Geo."
msgid "Dismiss Cycle Analytics introduction box"
msgstr "Відмінити блок вÑтупу до Ðналитики Циклу"
msgid "Dismiss Merge Request promotion"
-msgstr "Ðе показувати промоушн запитів на злиттÑ"
+msgstr "Вимкнути рекламу Ð´Ð»Ñ Ð—Ð°Ð¿Ð¸Ñ‚Ñ–Ð² на злиттÑ"
+
+msgid "Documentation for popular identity providers"
+msgstr ""
msgid "Don't show again"
msgstr "Ðе показувати знову"
+msgid "Done"
+msgstr "Готово"
+
msgid "Download"
msgstr "Завантажити"
@@ -875,23 +1691,179 @@ msgid "DownloadArtifacts|Download"
msgstr "Завантажити"
msgid "DownloadCommit|Email Patches"
-msgstr "Email-патчи"
+msgstr "Email-патчі"
msgid "DownloadCommit|Plain Diff"
-msgstr "Plain Diff"
+msgstr "ПроÑте порівнÑÐ½Ð½Ñ (diff)"
msgid "DownloadSource|Download"
msgstr "Завантажити"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr "Запланована дата завершеннÑ"
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "Редагувати"
msgid "Edit Pipeline Schedule %{id}"
msgstr "Редагувати Розклад Конвеєра %{id}"
+msgid "Edit files in the editor and commit changes here"
+msgstr "Редагуйте файли в редакторі і закомітьте зміни тут"
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr "ÐдреÑи електронної пошти"
+msgid "Enable"
+msgstr "Увімкнути"
+
+msgid "Enable Auto DevOps"
+msgstr "Увімкнути Auto DevOps"
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr "Виникла помилка при завантаженні Ñередовищ."
+
+msgid "Environments|An error occurred while making the request."
+msgstr "Під Ñ‡Ð°Ñ Ð²Ð¸ÐºÐ¾Ð½Ð°Ð½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ ÑталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ°."
+
+msgid "Environments|Commit"
+msgstr "Коміт"
+
+msgid "Environments|Deployment"
+msgstr "РозгортаннÑ"
+
+msgid "Environments|Environment"
+msgstr "Середовище"
+
+msgid "Environments|Environments"
+msgstr "Середовища"
+
+msgid "Environments|Job"
+msgstr "ЗавданнÑ"
+
+msgid "Environments|New environment"
+msgstr "Ðове Ñередовище"
+
+msgid "Environments|No deployments yet"
+msgstr "Ще немає розгортань"
+
+msgid "Environments|Open"
+msgstr "Відкрити"
+
+msgid "Environments|Re-deploy"
+msgstr "Повторно розгорнути"
+
+msgid "Environments|Read more about environments"
+msgstr "ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ про Ñередовища"
+
+msgid "Environments|Rollback"
+msgstr "Відкотити"
+
+msgid "Environments|Show all"
+msgstr "Показати вÑÑ–"
+
+msgid "Environments|Updated"
+msgstr "Оновлено"
+
+msgid "Environments|You don't have any environments right now."
+msgstr "Ви поки не налаштували жодного Ñередовища."
+
+msgid "Epic will be removed! Are you sure?"
+msgstr "Епік буде видалено! Ви впевнені?"
+
+msgid "Epics"
+msgstr "Епіки"
+
+msgid "Epics Roadmap"
+msgstr "План-графік епіків"
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "Епіки дозволÑÑŽÑ‚ÑŒ керувати вашим портфелем проектів ефективніше та з меншими зуÑиллÑми"
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr "Помилка при отриманні даних гілки. Будь лаÑка, Ñпробуйте пізніше."
+
+msgid "Error committing changes. Please try again."
+msgstr "Помилка при коміті змін. Будь лаÑка, Ñпробуйте пізніше."
+
+msgid "Error creating epic"
+msgstr "Помилка при Ñтворенні епіку"
+
+msgid "Error fetching contributors data."
+msgstr "Помилка Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ð½Ð½Ñ Ð´Ð°Ð½Ð¸Ñ… учаÑників."
+
+msgid "Error fetching labels."
+msgstr "Помилка Ð·Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ Ð¼Ñ–Ñ‚Ð¾Ðº."
+
+msgid "Error fetching network graph."
+msgstr "Помилка при отриманні графа мережі."
+
+msgid "Error fetching refs"
+msgstr "Помилка Ð·Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ refs"
+
+msgid "Error fetching usage ping data."
+msgstr "Помилка при отриманні данних по перевірці з’єднаннÑ."
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr "СталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° під Ñ‡Ð°Ñ Ð¿Ñ–Ð´ÐºÐ»ÑŽÑ‡ÐµÐ½Ð½Ñ Ð¿Ñ–Ð´Ð¿Ð¸Ñки на ÑповіщеннÑ"
+
+msgid "Error saving label update."
+msgstr "Помилка при збереженні мітки."
+
+msgid "Error updating status for all todos."
+msgstr "Помилка Ð¾Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ ÑтатуÑу Ð´Ð»Ñ Ð²ÑÑ–Ñ… задач."
+
+msgid "Error updating todo status."
+msgstr "Помилка при оновленні ÑтатуÑу задачі."
+
msgid "EventFilterBy|Filter by all"
msgstr "Фільтрувати по вÑім"
@@ -905,7 +1877,7 @@ msgid "EventFilterBy|Filter by merge events"
msgstr "Фільтрувати по запитам на злиттÑ"
msgid "EventFilterBy|Filter by push events"
-msgstr "Фільтрувати по push-подіÑÑ…"
+msgstr "Фільтрувати за подіÑми Ð²Ñ–Ð´Ð¿Ñ€Ð°Ð²Ð»ÐµÐ½Ð½Ñ (push)"
msgid "EventFilterBy|Filter by team"
msgstr "Фільтрувати по команді"
@@ -919,17 +1891,62 @@ msgstr "Кожен міÑÑць (1-го чиÑла о 4:00 ранку)"
msgid "Every week (Sundays at 4:00am)"
msgstr "Ð©Ð¾Ñ‚Ð¸Ð¶Ð½Ñ (в неділю о 4:00 ранку)"
+msgid "Expand"
+msgstr "Розгорнути"
+
msgid "Explore projects"
msgstr "ОглÑд проектів"
msgid "Explore public groups"
msgstr "ПереглÑнути публічні групи"
+msgid "External Classification Policy Authorization"
+msgstr "ÐÐ²Ñ‚Ð¾Ñ€Ð¸Ð·Ð°Ñ†Ñ–Ñ Ð²Ñ–Ð´Ð¿Ð¾Ð²Ñ–Ð´Ð½Ð¾ до зовнішньої політики"
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr "Ð—Ð¾Ð²Ð½Ñ–ÑˆÐ½Ñ Ð°Ð²Ñ‚Ð¾Ñ€Ð¸Ð·Ð°Ñ†Ñ–Ñ Ð·Ð°Ð±Ð¾Ñ€Ð¾Ð½Ð¸Ð»Ð° доÑтуп до цього проекту"
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr "Мітка клаÑифікації"
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr "Мітка клаÑифікації"
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr "Якщо клаÑифікаційну мітку не вÑтановлено, викориÑтовуватиметьÑÑ Ñтандартна мітка `%{default_label}`."
+
+msgid "Failed"
+msgstr "Ðевдало"
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "Ðе вдалоÑÑ Ð·Ð¼Ñ–Ð½Ð¸Ñ‚Ð¸ влаÑника"
+msgid "Failed to remove issue from board, please try again."
+msgstr "Ðе вдалоÑÑ Ð²Ð¸Ð´Ð°Ð»Ð¸Ñ‚Ð¸ проблему з дошки, будь лаÑка, Ñпробуйте ще раз."
+
msgid "Failed to remove the pipeline schedule"
-msgstr "Ðе вдалоÑÑ Ð²Ð¸Ð´Ð°Ð»Ð¸Ñ‚Ð¸ розклад Конвеєра"
+msgstr "Ðе вдалоÑÑ Ð²Ð¸Ð´Ð°Ð»Ð¸Ñ‚Ð¸ розклад конвеєра"
+
+msgid "Failed to update issues, please try again."
+msgstr "Ðе вдалоÑÑ Ð¾Ð½Ð¾Ð²Ð¸Ñ‚Ð¸ проблеми. Будь лаÑка, Ñпробуйте ще раз."
+
+msgid "Feb"
+msgstr "лют."
+
+msgid "February"
+msgstr "лютий"
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr "ÐŸÐ¾Ð»Ñ Ð½Ð° цій Ñторінці зараз недоÑтупні Ð´Ð»Ñ Ñ€ÐµÐ´Ð°Ð³ÑƒÐ²Ð°Ð½Ð½Ñ, ви можете налаштувати"
msgid "File name"
msgstr "Ім'Ñ Ñ„Ð°Ð¹Ð»Ñƒ"
@@ -937,6 +1954,12 @@ msgstr "Ім'Ñ Ñ„Ð°Ð¹Ð»Ñƒ"
msgid "Files"
msgstr "Файли"
+msgid "Files (%{human_size})"
+msgstr "Файли (%{human_size})"
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "Фільтрувати за коміт-повідомленнÑм"
@@ -946,17 +1969,27 @@ msgstr "Пошук по шлÑху"
msgid "Find file"
msgstr "Знайти файл"
+msgid "Finished"
+msgstr "Завершено"
+
msgid "FirstPushedBy|First"
msgstr "Перший"
msgid "FirstPushedBy|pushed by"
-msgstr "ÐадіÑлані зміни від"
+msgstr "відправлено"
+
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "Форк"
msgstr[1] "Форки"
msgstr[2] "Форків"
+msgstr[3] "Форків"
msgid "ForkedFromProjectPath|Forked from"
msgstr "Форк від"
@@ -964,26 +1997,179 @@ msgstr "Форк від"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr "Форк із %{project_name} (видалено)"
+msgid "Forking in progress"
+msgstr "ВідбуваєтьÑÑ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ñ„Ð¾Ñ€ÐºÑƒ"
+
msgid "Format"
msgstr "Формат"
+msgid "From %{provider_title}"
+msgstr "З %{provider_title}"
+
msgid "From issue creation until deploy to production"
-msgstr "З моменту ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ до Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð½Ð° ПРОД"
+msgstr "З моменту ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ до Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð½Ð° production"
msgid "From merge request merge until deploy to production"
-msgstr "З об'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð´Ð¾ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð½Ð° ПРОД"
+msgstr "Від Ð²Ð¸ÐºÐ¾Ð½Ð°Ð½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð´Ð¾ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð½Ð° production"
+
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr "Із Ñторінки деталей Kubernetes-клаÑтера, вÑтановіть runner зі ÑпиÑку заÑтоÑунків"
msgid "GPG Keys"
msgstr "GPG ключі"
+msgid "Generate a default set of labels"
+msgstr "Створити Ñтандартний набір міткок"
+
msgid "Geo Nodes"
msgstr "Гео-Вузли"
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr "Вузол не працює або зламаний."
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr "Вузол працює повільно, перевантажений або тільки що відновивÑÑ Ð¿Ñ–ÑÐ»Ñ Ð·Ð±Ð¾ÑŽ."
+
+msgid "GeoNodes|Checksummed"
+msgstr "Із контрольною Ñумою"
+
+msgid "GeoNodes|Database replication lag:"
+msgstr "Затримка реплікації бази даних:"
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr "Ð’Ñ–Ð´ÐºÐ»ÑŽÑ‡ÐµÐ½Ð½Ñ Ð²ÑƒÐ·Ð»Ð° зупинÑÑ” Ð¿Ñ€Ð¾Ñ†ÐµÑ Ñинхронізації. Ви впевнені?"
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr "Ðе відповідає оÑновній конфігурації Ñховища"
+
+msgid "GeoNodes|Failed"
+msgstr "Ðевдало"
+
+msgid "GeoNodes|Full"
+msgstr "Повний"
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr "ВерÑÑ–Ñ GitLab не відповідає верÑÑ–Ñ— оÑновного вузла"
+
+msgid "GeoNodes|GitLab version:"
+msgstr "ВерÑÑ–Ñ GitLab:"
+
+msgid "GeoNodes|Health status:"
+msgstr "Стан працездатноÑÑ‚Ñ–:"
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr "Ідентифікатор оÑтанньої події, обробленої курÑором:"
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr "Ідентифікатор оÑтанньої події видимої із оÑновного:"
+
+msgid "GeoNodes|Loading nodes"
+msgstr "Ð—Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ Ð²ÑƒÐ·Ð»Ñ–Ð²"
+
+msgid "GeoNodes|Local Attachments:"
+msgstr "Локальні Ð²ÐºÐ»Ð°Ð´ÐµÐ½Ð½Ñ (attachments):"
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr "Локальні LFS-об’єкти:"
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr "Ðртефакти локальних завдань:"
+
+msgid "GeoNodes|New node"
+msgstr "Ðовий вузол"
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr "Ðутентифікацію вузла уÑпішно полагоджено."
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr "Вузол уÑпішно видалено."
+
+msgid "GeoNodes|Not checksummed"
+msgstr "Без контрольної Ñуми"
+
+msgid "GeoNodes|Out of sync"
+msgstr "РозÑинхронізовані"
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr "Ð’Ð¸Ð´Ð°Ð»ÐµÐ½Ð½Ñ Ð²ÑƒÐ·Ð»Ð° зупинÑÑ” Ð¿Ñ€Ð¾Ñ†ÐµÑ Ñинхронізації. Ви впевнені?"
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr "Слот реплікації WAL:"
+
+msgid "GeoNodes|Replication slots:"
+msgstr "Слоти реплікації:"
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr "Репозиторії із контрольними Ñумами:"
+
+msgid "GeoNodes|Repositories:"
+msgstr "Репозиторії:"
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr "Репозиторії із підтвердженою контрольною Ñумою:"
+
+msgid "GeoNodes|Selective"
+msgstr "Вибіркові"
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr "Проблема при зміні ÑтатуÑа вузла"
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr "Проблема при видаленні вузла"
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr "Проблема при полагодженні вузла"
+
+msgid "GeoNodes|Storage config:"
+msgstr "ÐšÐ¾Ð½Ñ„Ñ–Ð³ÑƒÑ€Ð°Ñ†Ñ–Ñ Ñховища:"
+
+msgid "GeoNodes|Sync settings:"
+msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ñинхронізації:"
+
+msgid "GeoNodes|Synced"
+msgstr "Синхронізовано"
+
+msgid "GeoNodes|Unused slots"
+msgstr "ÐевикориÑтані Ñлоти"
+
+msgid "GeoNodes|Unverified"
+msgstr "Ðепідтверджені"
+
+msgid "GeoNodes|Used slots"
+msgstr "ВикориÑтані Ñлоти"
+
+msgid "GeoNodes|Verified"
+msgstr "Підтверджені"
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr "Wiki репозиторії із підтвердженою контрольною Ñумою:"
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr "Wiki із контрольною Ñумою:"
+
+msgid "GeoNodes|Wikis:"
+msgstr "Wiki:"
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr "Ви налаштували Geo-вузли через незахищене HTTP-з’єднаннÑ. Ми рекомендуємо викориÑтовувати HTTPS."
+
+msgid "Geo|All projects"
+msgstr "Ð’ÑÑ– проекти"
+
msgid "Geo|File sync capacity"
msgstr "ПропуÑкна здатніÑÑ‚ÑŒ Ñинхронізації файлів"
-msgid "Geo|Groups to replicate"
-msgstr "Групи Ð´Ð»Ñ Ñ€ÐµÐ¿Ð»Ñ–ÐºÐ°Ñ†Ñ–Ñ—"
+msgid "Geo|Groups to synchronize"
+msgstr "Групи Ð´Ð»Ñ Ñинхронізації"
+
+msgid "Geo|Projects in certain groups"
+msgstr "Проекти в певних групах"
+
+msgid "Geo|Projects in certain storage shards"
+msgstr "Проекти в певних Ñегментах Ñховищ"
msgid "Geo|Repository sync capacity"
msgstr "ПропуÑкна здатніÑÑ‚ÑŒ Ñинхронізації репозиторіїв"
@@ -991,12 +2177,45 @@ msgstr "ПропуÑкна здатніÑÑ‚ÑŒ Ñинхронізації репÐ
msgid "Geo|Select groups to replicate."
msgstr "Виберіть групи Ð´Ð»Ñ Ñ€ÐµÐ¿Ð»Ñ–ÐºÐ°Ñ†Ñ–Ñ—."
+msgid "Geo|Shards to synchronize"
+msgstr "Сегменти Ð´Ð»Ñ Ñинхронізації"
+
+msgid "Git repository URL"
+msgstr "URL Git-репозиторіÑ"
+
+msgid "Git revision"
+msgstr "Git-редакціÑ"
+
msgid "Git storage health information has been reset"
msgstr "Ð†Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ñ–Ñ Ð¿Ñ€Ð¾ ÑÑ‚Ð°Ñ‚ÑƒÑ Ð·Ð±ÐµÑ€Ñ–Ð³Ð°Ð½Ð½Ñ Git була Ñкинута"
+msgid "Git version"
+msgstr "Git-верÑÑ–Ñ"
+
+msgid "GitHub import"
+msgstr "GitHub-імпорт"
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "Розділ GitLab Runner"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr "Сервери Gitaly"
+
+msgid "Go back"
+msgstr "ПовернутиÑÑ"
+
msgid "Go to your fork"
msgstr "Перейти до вашого форку"
@@ -1006,6 +2225,27 @@ msgstr "Форк"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr "ÐÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ñ–ÐºÐ°Ñ†Ñ–Ñ Google не %{link_to_documentation}. ПопроÑÑ–Ñ‚ÑŒ Ñвого адмініÑтратора GitLab, Ñкщо ви хочете ÑкориÑтатиÑÑ Ñ†Ð¸Ð¼ ÑервіÑом."
+msgid "Got it!"
+msgstr "Зрозуміло!"
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "Епіки дозволють вам керувати портфоліо ваших проектів більш ефективно Ñ– з меншими зуÑиллÑми"
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr "Від %{dateWord}"
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr "Ð—Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ Ð¿Ð»Ð°Ð½Ñƒ-графіку"
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr "Проблема при завантаженні епіків"
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr "Ð”Ð»Ñ Ð¿ÐµÑ€ÐµÐ³Ð»Ñду плану-графіку додайте заплановані дати початку та Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð´Ð¾ одного з ваших епіків у цій групі або Ñ—Ñ— підгрупах. ВідображаютьÑÑ Ð»Ð¸ÑˆÐµ епіки за попередні та наÑтупні 3 міÑÑці: від %{startDate} до %{endDate}."
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr "До %{dateWord}"
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr "Заборонити Ñпільний доÑтуп до проекту в рамках %{group} з іншими групами"
@@ -1042,12 +2282,6 @@ msgstr "Групи не знайдені"
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "Ви можете керувати правами доÑтупу членів групи мати доÑтуп до кожного проекту в ній."
-msgid "GroupsTreeRole|as"
-msgstr "Ñк"
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr "Ви впевнені, що хочете залишити групу \"${this.group.fullName}\"?"
-
msgid "GroupsTree|Create a project in this group."
msgstr "Створити проект у групі."
@@ -1075,23 +2309,45 @@ msgstr "Ðа жаль жодна группа не задовольнÑÑ” пар
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr "Ðа жаль жодна группа чи проект не задовольнÑÑ” параметрам вашого запиту"
+msgid "Have your users email"
+msgstr "Електронна пошта Ð´Ð»Ñ Ð·Ð²ÐµÑ€Ñ‚Ð°Ð½ÑŒ кориÑтувачів"
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
-msgstr "Перевірки працездатноÑÑ‚Ñ–"
+msgstr "Перевірка ПрацездатноÑÑ‚Ñ–"
msgid "Health information can be retrieved from the following endpoints. More information is available"
msgstr "Інформацію про працездатніÑÑ‚ÑŒ можна отримати з наÑтупних ендпойнтів. Більше інформації доÑтупно"
msgid "HealthCheck|Access token is"
-msgstr "Токен доÑтупу Ñ”"
+msgstr "Токен доÑтупу"
msgid "HealthCheck|Healthy"
msgstr "Здоровий"
msgid "HealthCheck|No Health Problems Detected"
-msgstr "Жодних проблем із здоров'Ñм не виÑвлено"
+msgstr "Проблем із здоров'Ñм не виÑвлено"
msgid "HealthCheck|Unhealthy"
-msgstr "Ðездорові"
+msgstr "Ðездоровий"
+
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] "Сховати значеннÑ"
+msgstr[1] "Сховати значеннÑ"
+msgstr[2] "Сховати значень"
+msgstr[3] "Сховати значень"
msgid "History"
msgstr "ІÑторіÑ"
@@ -1099,11 +2355,41 @@ msgstr "ІÑторіÑ"
msgid "Housekeeping successfully started"
msgstr "ÐžÑ‡Ð¸Ñ‰ÐµÐ½Ð½Ñ ÑƒÑпішно розпочато"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr "При викориÑтанні GitHub, ви побачите ÑтатуÑи конвеєрів Ð´Ð»Ñ ÐºÐ¾Ð¼Ñ–Ñ‚Ñ–Ð² Ñ– запитів на злиттÑ. %{more_info_link}"
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr "Якщо у Ð²Ð°Ñ ÑƒÐ¶Ðµ Ñ” файли, ви можете відправити Ñ—Ñ… за допомогою %{link_to_cli} нижче."
+
+msgid "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>."
+msgstr "Якщо ваш HTTP-репозиторій не Ñ” публічним, додайте дані Ð´Ð»Ñ Ð°ÑƒÑ‚ÐµÐ½Ñ‚Ð¸Ñ„Ñ–ÐºÐ°Ñ†Ñ–Ñ— до URL: <code>https://username:password@gitlab.company.com/group/project.git</code>."
+
+msgid "Import"
+msgstr "Імпорт"
+
+msgid "Import all repositories"
+msgstr "Імпорт вÑÑ–Ñ… репозиторіїв"
+
+msgid "Import in progress"
+msgstr "Імпорт триває"
+
+msgid "Import repositories from GitHub"
+msgstr "Імпорт репозиторіїв з GitHub"
+
msgid "Import repository"
-msgstr "Імпорт репозеторіÑ"
+msgstr "Імпорт репозиторію"
+
+msgid "ImportButtons|Connect repositories from"
+msgstr "Підключити репозиторії із"
msgid "Improve Issue boards with GitLab Enterprise Edition."
-msgstr "Покращити дошки обговорень за допомогою верÑÑ–Ñ— GitLab Enterprise Edition."
+msgstr "Покращити дошки обговорень проблем за допомогою верÑÑ–Ñ— GitLab Enterprise Edition."
msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
msgstr "Покращити ÑƒÐ¿Ñ€Ð°Ð²Ð»Ñ–Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð°Ð¼Ð¸ з можливіÑÑ‚ÑŽ Ð²Ð¸Ð·Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ Ð²Ð°Ð³Ð¸ проблеми за допомогою GitLab Enterprise Edition."
@@ -1111,14 +2397,27 @@ msgstr "Покращити ÑƒÐ¿Ñ€Ð°Ð²Ð»Ñ–Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð°Ð¼Ð¸ з можл
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr "Покращити пошук за допомогою розширеного глобального пошук в верÑÑ–Ñ— GitLab Enterprise Edition."
+msgid "Install Runner on Kubernetes"
+msgstr "Ð’Ñтановити Runner на Kubernetes"
+
msgid "Install a Runner compatible with GitLab CI"
msgstr "Ð’Ñтановіть Runner, ÑуміÑний з GitLab CI"
msgid "Instance"
msgid_plural "Instances"
msgstr[0] "ІнÑтанÑ"
-msgstr[1] "ІнÑтанÑа"
+msgstr[1] "IнÑтанÑи"
msgstr[2] "ІнÑтанÑів"
+msgstr[3] "ІнÑтанÑів"
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr "Цей інÑÑ‚Ð°Ð½Ñ Ð½Ðµ підтримує декілька Kubernetes-клаÑтерів"
+
+msgid "Integrations"
+msgstr "Інтеграції"
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr "Зацікавлені Ñторони за бажаннÑм можуть навіть робити внеÑки шлÑхом Ð²Ñ–Ð´Ð¿Ñ€Ð°Ð²Ð»ÐµÐ½Ð½Ñ ÐºÐ¾Ð¼Ñ–Ñ‚Ñ–Ð²."
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr "Ð’Ð½ÑƒÑ‚Ñ€Ñ–ÑˆÐ½Ñ â€” будь-Ñкий автентифікований кориÑтувач має доÑтуп до цієї групи та уÑÑ–Ñ… Ñ—Ñ— внутрішніх проектів."
@@ -1133,10 +2432,7 @@ msgid "Introducing Cycle Analytics"
msgstr "ПредÑтавлÑємо аналітику циклу"
msgid "Issue board focus mode"
-msgstr "Режим фокуÑÑƒÐ²Ð°Ð½Ð½Ñ Ð½Ð°Ð´ дошкою обговорень"
-
-msgid "Issue boards with milestones"
-msgstr "Дошка обговорень із етапами"
+msgstr "Режим фокуÑÑƒÐ²Ð°Ð½Ð½Ñ Ð´Ð»Ñ Ð´Ð¾ÑˆÐºÐ¸ обговорень"
msgid "Issue events"
msgstr "Проблеми"
@@ -1150,20 +2446,93 @@ msgstr "Дошки"
msgid "Issues"
msgstr "Проблеми"
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr "Проблеми можуть бути помилками, завданнÑми чи ідеÑми Ð´Ð»Ñ Ð¾Ð±Ð³Ð¾Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ. Крім того, проблеми доÑтупні Ð´Ð»Ñ Ð¿Ð¾ÑˆÑƒÐºÑƒ та фільтруваннÑ."
+
+msgid "Jan"
+msgstr "Ñіч."
+
+msgid "January"
+msgstr "Ñічень"
+
+msgid "Jobs"
+msgstr "ЗавданнÑ"
+
+msgid "Jul"
+msgstr "лип."
+
+msgid "July"
+msgstr "липень"
+
+msgid "Jun"
+msgstr "чер."
+
+msgid "June"
+msgstr "червень"
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr "Kubernetes"
+
+msgid "Kubernetes Cluster"
+msgstr "КлаÑтер Kubernetes"
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr "ÐŸÐµÑ€ÐµÐ²Ð¸Ñ‰ÐµÐ½Ð½Ñ Ð»Ñ–Ð¼Ñ–Ñ‚Ñƒ чаÑу при Ñтворенні Kubernetes-клаÑтера; %{timeout}"
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· Kubernetes-клаÑтером не була видалена."
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr "Ð†Ð½Ñ‚ÐµÐ³Ñ€Ð°Ñ†Ñ–Ñ Ñ–Ð· Kubernetes-клаÑтером була уÑпішно видалена."
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr "Kubernetes-клаÑтер уÑпішно оновлено."
+
+msgid "Kubernetes configured"
+msgstr "Kubernetes налаштовано"
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr "СпоÑіб Інтеграції Kubernetes Ñк ÑервіÑа заÑтарів. %{deprecated_message_content} ваші Kubernetes-клаÑтери за допомогою нової Ñторінки <a href=\"%{url}\"/>КлаÑтери Kubernetes</a>"
+
msgid "LFSStatus|Disabled"
msgstr "Вимкнено"
msgid "LFSStatus|Enabled"
msgstr "Увімкнено"
+msgid "Label"
+msgstr "Мітка"
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr "%{firstLabelName} + %{remainingLabelCount} ще"
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr "%{labelsString} і %{remainingLabelCount} ще"
+
msgid "Labels"
msgstr "Мітки"
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr "Мітки можуть бути заÑтоÑовані до %{features}. Групові мітки доÑтупні Ð´Ð»Ñ Ð±ÑƒÐ´ÑŒ-Ñкого проекту в межах групи."
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr "Мітки можуть бути заÑтоÑовані до проблем та запитів на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð´Ð»Ñ Ñ—Ñ… категоризації."
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr "Підвищити мітку"
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "ОÑтанній %d день"
msgstr[1] "ОÑтанніх %d дні"
msgstr[2] "ОÑтанніх %d днів"
+msgstr[3] "ОÑтанніх %d днів"
msgid "Last Pipeline"
msgstr "ОÑтанній Конвеєр"
@@ -1184,11 +2553,20 @@ msgid "Last updated"
msgstr "ВоÑтаннє оновленно"
msgid "LastPushEvent|You pushed to"
-msgstr "Ви надіÑлали зміни до"
+msgstr "Ви відправили зміни до"
msgid "LastPushEvent|at"
msgstr "в"
+msgid "Learn more"
+msgstr "ДізнатиÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ"
+
+msgid "Learn more about Kubernetes"
+msgstr "ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ про Kubernetes"
+
+msgid "Learn more about protected branches"
+msgstr "ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ про захищені гілки"
+
msgid "Learn more in the"
msgstr "ДізнайтеÑÑŒ більше"
@@ -1207,65 +2585,240 @@ msgstr "Залишити проект"
msgid "License"
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 "List"
+msgstr "СпиÑок"
+
+msgid "List your GitHub repositories"
+msgstr "СпиÑок ваших репозиторіїв GitHub"
+
+msgid "Loading the GitLab IDE..."
+msgstr "Ð—Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ IDE GitLab..."
msgid "Lock"
msgstr "БлокуваннÑ"
+msgid "Lock %{issuableDisplayName}"
+msgstr "Заблокувати %{issuableDisplayName}"
+
+msgid "Lock not found"
+msgstr "Ð‘Ð»Ð¾ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ð½Ðµ знайдено"
+
msgid "Locked"
msgstr "Заблоковано"
msgid "Locked Files"
msgstr "Заблоковані файли"
+msgid "Locks give the ability to lock specific file or folder."
+msgstr "Ð‘Ð»Ð¾ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ð¼Ð¾Ð¶Ðµ бути заÑтоÑоване до конкретного файлу або директорії."
+
msgid "Login"
msgstr "Вхід"
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr "Зробіть кожного учаÑника команди більш продуктивним незалежно від його міÑцезнаходженнÑ. GitLab Geo Ñтворює копії \"тільки Ð´Ð»Ñ Ñ‡Ð¸Ñ‚Ð°Ð½Ð½Ñ\" вашого GitLab Ñервера, щоб Ñкоротити Ñ‡Ð°Ñ Ð´Ð»Ñ ÐºÐ»Ð¾Ð½ÑƒÐ²Ð°Ð½Ð½Ñ Ñ– Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ з великих репозиторіїв."
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr "ÐšÐµÑ€ÑƒÐ²Ð°Ð½Ð½Ñ Ð¼Ñ–Ñ‚ÐºÐ°Ð¼Ð¸ групи"
+
+msgid "Manage labels"
+msgstr "Керувати мітками"
+
+msgid "Manage project labels"
+msgstr "ÐšÐµÑ€ÑƒÐ²Ð°Ð½Ð½Ñ Ð¼Ñ–Ñ‚ÐºÐ°Ð¼Ð¸ проекту"
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr "бер."
+
+msgid "March"
+msgstr "березень"
+
+msgid "Mark done"
+msgstr "Позначити виконаним"
+
msgid "Maximum git storage failures"
msgstr "МакÑимальна кількіÑÑ‚ÑŒ невдач в Ñховищі даних git"
+msgid "May"
+msgstr "травень"
+
msgid "Median"
msgstr "Медіана"
msgid "Members"
msgstr "КориÑтувачі"
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr "Запити на злиттÑ"
msgid "Merge events"
-msgstr "Запити на злиттÑ"
+msgstr "Події злиттÑ"
msgid "Merge request"
msgstr "Запит на злиттÑ"
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr "Запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ â€” це ÑпоÑіб запропонувати Ñвої зміни до проекту Ñ– обговорити Ñ—Ñ… із іншими"
+
+msgid "Merged"
+msgstr "Злито"
+
msgid "Messages"
msgstr "ПовідомленнÑ"
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr "БізнеÑ"
+
+msgid "Metrics|Create metric"
+msgstr "Створити метрику"
+
+msgid "Metrics|Edit metric"
+msgstr "Редагувати метрику"
+
+msgid "Metrics|For grouping similar metrics"
+msgstr "Ð”Ð»Ñ Ð³Ñ€ÑƒÐ¿ÑƒÐ²Ð°Ð½Ð½Ñ Ð¿Ð¾Ð´Ñ–Ð±Ð½Ð¸Ñ… метрик"
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr "Ðазва вертикальної оÑÑ– графіка. Зазвичай це — одиниці вимірюваннÑ. Горизонтальна віÑÑŒ (віÑÑŒ X) завжди відображає чаÑ."
+
+msgid "Metrics|Legend label (optional)"
+msgstr "Заголовок легенди (необов’Ñзковий)"
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr "Має бути коректним запитом PromQL."
+
+msgid "Metrics|Name"
+msgstr "Ім'Ñ"
+
+msgid "Metrics|New metric"
+msgstr "Ðова метрика"
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr "Ð”Ð¾ÐºÑƒÐ¼ÐµÐ½Ñ‚Ð°Ñ†Ñ–Ñ Ð¿Ð¾ запитам Prometheus"
+
+msgid "Metrics|Query"
+msgstr "Запит"
+
+msgid "Metrics|Response"
+msgstr "Відповідь"
+
+msgid "Metrics|System"
+msgstr "СиÑтема"
+
+msgid "Metrics|Type"
+msgstr "Тип"
+
+msgid "Metrics|Unit label"
+msgstr "Одиниці вимірюваннÑ"
+
+msgid "Metrics|Used as a title for the chart"
+msgstr "ВикориÑтовуєтьÑÑ Ñк заголовок графіка"
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr "ВикориÑтовуєтьÑÑ, Ñкщо запит повертає єдину поÑлідовніÑÑ‚ÑŒ. Якщо ж він повертає декілька, Ñ—Ñ… назви берутьÑÑ Ñ–Ð· відповіді."
+
+msgid "Metrics|Y-axis label"
+msgstr "Ðазва оÑÑ– Y"
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr "напр. HTTP-запити"
+
+msgid "Metrics|e.g. Requests/second"
+msgstr "напр. запитів/Ñек"
+
+msgid "Metrics|e.g. Throughput"
+msgstr "напр. пропуÑкна здатніÑÑ‚ÑŒ"
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr "напр. rate(http_requests_total[5m])"
+
+msgid "Metrics|e.g. req/sec"
+msgstr "напр. зап/Ñек"
+
+msgid "Milestone"
+msgstr "Етап"
+
+msgid "Milestones|Delete milestone"
+msgstr "Видалити етап"
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr "Видалити етап %{milestoneTitle}?"
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr "Ðе вдалоÑÑ Ð²Ð¸Ð´Ð°Ð»Ð¸Ñ‚Ð¸ етап %{milestoneTitle}"
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr "Етап %{milestoneTitle} не знайдено"
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr "Підвищити %{milestoneTitle} до групового етапу?"
+
+msgid "Milestones|Promote Milestone"
+msgstr "Підвищити Етап"
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "не додаÑте SSH ключ"
+msgid "Modal|Cancel"
+msgstr "СкаÑувати"
+
+msgid "Modal|Close"
+msgstr "Закрити"
+
msgid "Monitoring"
msgstr "Моніторинг"
+msgid "More info"
+msgstr "Детальніше"
+
+msgid "More information"
+msgstr "Детальніше"
+
msgid "More information is available|here"
msgstr "тут"
+msgid "Move"
+msgstr "ПереміÑтити"
+
+msgid "Move issue"
+msgstr "ПереміÑтити проблему"
+
msgid "Multiple issue boards"
-msgstr "Зведені дошки обговореннÑ"
+msgstr "Кілька дошок обговореннÑ"
-msgid "New Cluster"
-msgstr "Ðовий клаÑтер"
+msgid "Name new label"
+msgstr "Ðазвіть нову мітку"
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "Ðова проблема"
msgstr[1] "Ðові проблеми"
msgstr[2] "Ðових проблем"
+msgstr[3] "Ðових проблем"
+
+msgid "New Kubernetes Cluster"
+msgstr "Ðовий Kubernetes-клаÑтер"
+
+msgid "New Kubernetes cluster"
+msgstr "Ðовий Kubernetes-клаÑтер"
msgid "New Pipeline Schedule"
msgstr "Ðовий розклад Конвеєра"
@@ -1273,9 +2826,15 @@ msgstr "Ðовий розклад Конвеєра"
msgid "New branch"
msgstr "Ðова гілка"
+msgid "New branch unavailable"
+msgstr "Ðова гілка недоÑтупна"
+
msgid "New directory"
msgstr "Ðовий каталог"
+msgid "New epic"
+msgstr "Ðовий епік"
+
msgid "New file"
msgstr "Ðовий файл"
@@ -1285,6 +2844,9 @@ msgstr "Ðова група"
msgid "New issue"
msgstr "Ðова проблема"
+msgid "New label"
+msgstr "Ðова мітка"
+
msgid "New merge request"
msgstr "Ðовий запит на злиттÑ"
@@ -1295,7 +2857,7 @@ msgid "New schedule"
msgstr "Ðовий Розклад"
msgid "New snippet"
-msgstr "Ðовий Ñніппет"
+msgstr "Ðовий Ñніпет"
msgid "New subgroup"
msgstr "Ðова підгрупа"
@@ -1303,24 +2865,72 @@ msgstr "Ðова підгрупа"
msgid "New tag"
msgstr "Ðовий тег"
-msgid "No container images stored for this project. Add one by following the instructions above."
-msgstr "Ð’ цьому проекті немає жодного образа контейнера. Додайте його за інÑтрукціÑми вище."
+msgid "No Label"
+msgstr "Без Мітки"
+
+msgid "No assignee"
+msgstr "Ðемає виконавцÑ"
+
+msgid "No changes"
+msgstr "Ðемає змін"
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr "Ðеможливо з'єднатиÑÑŒ із Ñервером Gitaly, будь лаÑка, перевірте логи!"
+
+msgid "No due date"
+msgstr "Ðемає"
+
+msgid "No estimate or time spent"
+msgstr "Ðемає запланованого або витраченого чаÑу"
+
+msgid "No file chosen"
+msgstr "Файл не вибрано"
+
+msgid "No labels created yet."
+msgstr "Мітки ще не Ñтворені."
msgid "No repository"
-msgstr "Ðемає репозеторіÑ"
+msgstr "Ðемає репозиторію"
msgid "No schedules"
msgstr "немає Розкладів"
msgid "None"
-msgstr "Жоден"
+msgstr "Ðемає"
+
+msgid "Not allowed to merge"
+msgstr "Ð—Ð»Ð¸Ñ‚Ñ‚Ñ Ð½Ðµ допуÑкаєтьÑÑ"
msgid "Not available"
msgstr "ÐедоÑтупний"
+msgid "Not available for private projects"
+msgstr "ÐедоÑтупно Ð´Ð»Ñ Ð¿Ñ€Ð¸Ð²Ð°Ñ‚Ð½Ð¸Ñ… проектів"
+
+msgid "Not available for protected branches"
+msgstr "ÐедоÑтупно Ð´Ð»Ñ Ð·Ð°Ñ…Ð¸Ñ‰ÐµÐ½Ð¸Ñ… гілок"
+
+msgid "Not confidential"
+msgstr "Ðе конфіденційно"
+
msgid "Not enough data"
msgstr "ÐедоÑтатньо даних"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr "Майте на увазі, що гілка master захищена автоматично. %{link_to_protected_branches}"
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr "Примітка: Ñк адмініÑтратор ви можете налаштувати %{github_integration_link}, що дозволить входити через GitHub Ñ– підключати репозиторії без ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¾ÑобиÑтого токену доÑтупу."
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr "Примітка: Ñк адмініÑтратор ви можете налаштувати %{github_integration_link}, що дозволить входити через GitHub Ñ– імпортувати репозиторії без ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¾ÑобиÑтого токену доÑтупу."
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr "Примітка: звернітьÑÑ Ð´Ð¾ вашого адмініÑтратора GitLab, щоб налаштувати %{github_integration_link}, Ñкий дозволить входити за допомогою GitHub Ñ– приєднувати репозиторії без генерації перÑонального токена доÑтупу."
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr "Примітка: звернітьÑÑ Ð´Ð¾ вашого адмініÑтратора GitLab, щоб налаштувати %{github_integration_link}, Ñкий дозволить входити за допомогою GitHub та імпортувати репозиторії без генерації перÑонального токена доÑтупу."
+
msgid "Notification events"
msgstr "ÐŸÐ¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾ події"
@@ -1328,13 +2938,13 @@ msgid "NotificationEvent|Close issue"
msgstr "Проблема закрита"
msgid "NotificationEvent|Close merge request"
-msgstr "Запит на об'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð·Ð°ÐºÑ€Ð¸Ñ‚Ð¸Ð¹"
+msgstr "Запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð·Ð°ÐºÑ€Ð¸Ñ‚Ð¸Ð¹"
msgid "NotificationEvent|Failed pipeline"
msgstr "Ðевдача в конвеєрі"
msgid "NotificationEvent|Merge merge request"
-msgstr "Об'єднати запит на злиттÑ"
+msgstr "Запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð²Ð¸ÐºÐ¾Ð½Ð°Ð½Ð¾"
msgid "NotificationEvent|New issue"
msgstr "Ðова проблема"
@@ -1349,7 +2959,7 @@ msgid "NotificationEvent|Reassign issue"
msgstr "Перепризначити проблему"
msgid "NotificationEvent|Reassign merge request"
-msgstr "Перепризначити запит на злиттÑ"
+msgstr "Запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð¿ÐµÑ€ÐµÐ¿Ñ€Ð¸Ð·Ð½Ð°Ñ‡ÐµÐ½Ð¾"
msgid "NotificationEvent|Reopen issue"
msgstr "Повторне Ð²Ñ–Ð´ÐºÑ€Ð¸Ñ‚Ñ‚Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ñƒ"
@@ -1378,18 +2988,48 @@ msgstr "ВідÑтежувати"
msgid "Notifications"
msgstr "СповіщеннÑ"
+msgid "Notifications off"
+msgstr "Ð¡Ð¿Ð¾Ð²Ñ–Ñ‰ÐµÐ½Ð½Ñ Ð²Ð¸Ð¼ÐºÐ½ÐµÐ½Ð¾"
+
+msgid "Notifications on"
+msgstr "Ð¡Ð¿Ð¾Ð²Ñ–Ñ‰ÐµÐ½Ð½Ñ ÑƒÐ²Ñ–Ð¼ÐºÐ½ÐµÐ½Ð¾"
+
+msgid "Nov"
+msgstr "лиÑÑ‚."
+
+msgid "November"
+msgstr "лиÑтопад"
+
msgid "Number of access attempts"
msgstr "КількіÑÑ‚ÑŒ Ñпроб доÑтупу"
-msgid "Number of failures before backing off"
-msgstr "КількіÑÑ‚ÑŒ помилок до призупиненнÑ"
+msgid "OK"
+msgstr "OK"
+
+msgid "Oct"
+msgstr "жовт."
+
+msgid "October"
+msgstr "жовтень"
msgid "OfSearchInADropdown|Filter"
msgstr "Фільтр"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr "ПіÑÐ»Ñ Ñ–Ð¼Ð¿Ð¾Ñ€Ñ‚Ñƒ репозиторії можуть бути віддзеркалені через SSH. ДізнайтеÑÑ Ð±Ñ–Ð»ÑŒÑˆÐµ %{ssh_link}"
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr "Тільки учаÑники проекту можуть залишати коментарі."
+msgid "Open"
+msgstr "Відкрити"
+
+msgid "Opened"
+msgstr "Відкрито"
+
msgid "OpenedNDaysAgo|Opened"
msgstr "Відкрито"
@@ -1399,12 +3039,21 @@ msgstr "ВідкриваєтьÑÑ Ñƒ новому вікні"
msgid "Options"
msgstr "Параметри"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr "Ð’ іншому разі рекомендуєтьÑÑ Ð¿Ð¾Ñ‡Ð°Ñ‚Ð¸ з одного з наведених нижче варіантів."
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr "ОглÑд"
msgid "Owner"
msgstr "ВлаÑник"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr "ОÑÑ‚Ð°Ð½Ð½Ñ Â»"
@@ -1417,11 +3066,20 @@ msgstr "ПопереднÑ"
msgid "Pagination|« First"
msgstr "« Перша"
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr "Пароль"
-msgid "People without permission will never get a notification and won\\'t be able to comment."
-msgstr "Люди без дозволу ніколи не отримуватимуть Ñповіщень Ñ– не зможуть коментувати."
+msgid "Pending"
+msgstr "В очікуванні"
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr "Токену перÑонального доÑтупу"
msgid "Pipeline"
msgstr "Конвеєр"
@@ -1439,7 +3097,7 @@ msgid "Pipeline quota"
msgstr "Квота на конвеєри"
msgid "PipelineCharts|Failed:"
-msgstr "Ðе вдалоÑÑ:"
+msgstr "Ðевдалі:"
msgid "PipelineCharts|Overall statistics"
msgstr "Загальна ÑтатиÑтика"
@@ -1465,12 +3123,6 @@ msgstr "Ð’ÑÑ–"
msgid "PipelineSchedules|Inactive"
msgstr "Ðеактивні"
-msgid "PipelineSchedules|Input variable key"
-msgstr "Введіть ім'Ñ Ð·Ð¼Ñ–Ð½Ð½Ð¾Ñ—"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "Вхідні Ð·Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ Ð·Ð¼Ñ–Ð½Ð½Ð¸Ñ…"
-
msgid "PipelineSchedules|Next Run"
msgstr "ÐаÑтупний запуÑк"
@@ -1478,10 +3130,7 @@ msgid "PipelineSchedules|None"
msgstr "Ðемає"
msgid "PipelineSchedules|Provide a short description for this pipeline"
-msgstr "Задайте короткий Ð¾Ð¿Ð¸Ñ Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ Конвеєру"
-
-msgid "PipelineSchedules|Remove variable row"
-msgstr "Видалити змінні"
+msgstr "Задайте короткий Ð¾Ð¿Ð¸Ñ Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ конвеєру"
msgid "PipelineSchedules|Take ownership"
msgstr "Стати влаÑником"
@@ -1493,7 +3142,7 @@ msgid "PipelineSchedules|Variables"
msgstr "Змінні"
msgid "PipelineSheduleIntervalPattern|Custom"
-msgstr "ВлаÑні"
+msgstr "Спеціальні"
msgid "Pipelines"
msgstr "Конвеєри"
@@ -1510,6 +3159,57 @@ msgstr "Конвеєри за оÑтанній тиждень"
msgid "Pipelines for last year"
msgstr "Конвеєри за оÑтанній рік"
+msgid "Pipelines|Build with confidence"
+msgstr "Виконуйте збірки із впевненіÑÑ‚ÑŽ"
+
+msgid "Pipelines|CI Lint"
+msgstr "Перевірка конфігурації (CI Lint)"
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr "ОчиÑтити кеш Runner'ів"
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr "Розпочати роботу з Конвеєрами"
+
+msgid "Pipelines|Loading Pipelines"
+msgstr "Ð—Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ ÐºÐ¾Ð½Ð²ÐµÑ”Ñ€Ñ–Ð²"
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr "Кеш проекту уÑпішно очищено."
+
+msgid "Pipelines|Run Pipeline"
+msgstr "ЗапуÑтити Конвеєр"
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr "Помилка при очищенні кеша runner'ів."
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr "Ð’ даний Ñ‡Ð°Ñ Ð½ÐµÐ¼Ð°Ñ” %{scope} конвеєрів."
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr "Ð’ даний Ñ‡Ð°Ñ Ð½ÐµÐ¼Ð°Ñ” конвеєрів."
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr "Цей проект в даний Ñ‡Ð°Ñ Ð½Ðµ налаштований Ð´Ð»Ñ Ð·Ð°Ð¿ÑƒÑку конвеєрів."
+
+msgid "Pipeline|Retry pipeline"
+msgstr "ПерезапуÑтити конвеєр"
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr "ПерезапуÑтити конвеєр #%{pipelineId}?"
+
+msgid "Pipeline|Stop pipeline"
+msgstr "Зупинити конвеєр"
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr "Зупинити конвеєр #%{pipelineId}?"
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr "Зараз ви перезапуÑтите конвеєр %{pipelineId}."
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr "Зараз ви зупинете конвеєр %{pipelineId}."
+
msgid "Pipeline|all"
msgstr "вÑÑ–"
@@ -1522,15 +3222,39 @@ msgstr "зі Ñтадією"
msgid "Pipeline|with stages"
msgstr "зі ÑтадіÑми"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr "Відтворити"
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr "Будь лаÑка, <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">увімкніть білінга Ð´Ð»Ñ Ð¾Ð´Ð½Ð¾Ð³Ð¾ з ваших проектів, щоб мати можливіÑÑ‚ÑŒ Ñтворити Kubernetes-клаÑтер</a>, Ñ– повторіть Ñпробу."
+
+msgid "Please solve the reCAPTCHA"
+msgstr "Будь лаÑка, пройдіть reCAPTCHA"
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr "Будь лаÑка, почекайте поки ми з’єднуємоÑÑ Ñ–Ð· вашим репозиторієм. Оновлюйте Ñторінку за бажаннÑм."
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr "Будь лаÑка, почекайте поки ми імпортуємо ваш репозиторій. Оновлюйте Ñторінку за бажаннÑм."
+
msgid "Preferences"
msgstr "ÐалаштуваннÑ"
+msgid "Primary"
+msgstr "Головний"
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr "Приватний — доÑтуп до проекту повинен надаватиÑÑ ÐºÐ¾Ð¶Ð½Ð¾Ð¼Ñƒ кориÑтувачеві."
msgid "Private - The group and its projects can only be viewed by members."
msgstr "Приватна — цю групу та Ñ—Ñ— проекти можуть бачити тільки Ñ—Ñ— кориÑтувачі."
+msgid "Private projects can be created in your personal namespace with:"
+msgstr "Приватні проекти можуть бути Ñтворені у вашому перÑональному проÑторі імен з:"
+
msgid "Profile"
msgstr "Профіль"
@@ -1570,6 +3294,12 @@ msgstr "Ваш обліковий Ð·Ð°Ð¿Ð¸Ñ Ñ” влаÑником в цих гÑ
msgid "Profiles|your account"
msgstr "ваш обліковий запиÑ"
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr "Мови програмуваннÑ, що викориÑтовуєтьÑÑ Ð² цьому репозиторії"
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr "Проект '%{project_name}' перебуває в процеÑÑ– видаленнÑ."
@@ -1585,6 +3315,12 @@ msgstr "Проект '%{project_name}' уÑпішно оновлено."
msgid "Project access must be granted explicitly to each user."
msgstr "ДоÑтуп до проекту повинен надаватиÑÑ ÐºÐ¾Ð¶Ð½Ð¾Ð¼Ñƒ кориÑтувачеві."
+msgid "Project avatar"
+msgstr "Ðватар проекту"
+
+msgid "Project avatar in repository: %{link}"
+msgstr "Ðватар проекту в репозиторії: %{link}"
+
msgid "Project details"
msgstr "Деталі проекту"
@@ -1603,11 +3339,26 @@ msgstr "Розпочато екÑпорт проекту. ПоÑÐ¸Ð»Ð°Ð½Ð½Ñ Ð´Ð
msgid "ProjectActivityRSS|Subscribe"
msgstr "ПідпиÑатиÑÑ"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr "Дозволено Ñтворювати проекти"
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr "Розробники + Керівники"
+
+msgid "ProjectCreationLevel|Masters"
+msgstr "Керівники"
+
+msgid "ProjectCreationLevel|No one"
+msgstr "Ðіхто"
+
msgid "ProjectFeature|Disabled"
msgstr "Вимкнено"
msgid "ProjectFeature|Everyone with access"
-msgstr "Ð’Ñе з доÑтупом"
+msgstr "Ð’ÑÑ– із доÑтупом"
msgid "ProjectFeature|Only team members"
msgstr "Тільки члени команди"
@@ -1619,7 +3370,7 @@ msgid "ProjectLastActivity|Never"
msgstr "Ðіколи"
msgid "ProjectLifecycle|Stage"
-msgstr "Етап"
+msgstr "СтадіÑ"
msgid "ProjectNetworkGraph|Graph"
msgstr "ІÑторіÑ"
@@ -1640,7 +3391,7 @@ msgid "ProjectSettings|This setting will be applied to all projects unless overr
msgstr "Цей параметр буде заÑтоÑовано до вÑÑ–Ñ… проектів, Ñкщо адмініÑтратор не змінить його."
msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
-msgstr "КориÑтувачі можуть виконувати push в цей репозиторій лише тих комітів, Ñкі міÑÑ‚ÑÑ‚ÑŒ одну із підтверджених Ð°Ð´Ñ€ÐµÑ ÐµÐ»ÐµÐºÑ‚Ñ€Ð¾Ð½Ð½Ð¾Ñ— пошти."
+msgstr "КориÑтувачі можуть відправлÑти в цей репозиторій лише Ñ‚Ñ– коміти, Ñкі міÑÑ‚ÑÑ‚ÑŒ одну із їхніх підтверджених Ð°Ð´Ñ€ÐµÑ ÐµÐ»ÐµÐºÑ‚Ñ€Ð¾Ð½Ð½Ð¾Ñ— пошти."
msgid "Projects"
msgstr "Проекти"
@@ -1666,6 +3417,93 @@ msgstr "Ðа жаль, по вашоу запиту проектів не зна
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr "Ð¦Ñ Ñ„ÑƒÐ½ÐºÑ†Ñ–Ñ Ð¿Ð¾Ñ‚Ñ€ÐµÐ±ÑƒÑ” підтримки localStorage вашим браузером"
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr "було знайдено %{exporters} з %{metrics}"
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr "<p class=\"text-tertiary\">ÐÑ–Ñких <a href=\"%{docsUrl}\">загальних метрик</a> не знайдено</p>"
+
+msgid "PrometheusService|Active"
+msgstr "Ðктивний"
+
+msgid "PrometheusService|Auto configuration"
+msgstr "Ðвтоматична конфігураціÑ"
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr "Ðвтоматично розгортайте та налаштовуйте Prometheus на ваші клаÑтери Ð´Ð»Ñ Ð¼Ð¾Ð½Ñ–Ñ‚Ð¾Ñ€Ð¸Ð½Ð³Ñƒ Ñередовищ проекту"
+
+msgid "PrometheusService|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."
+msgstr "За замовчуваннÑм, Prometheus запуÑкаєтьÑÑ Ð·Ð° адреÑою ‘http://localhost:9090’. Ðе рекомендуєтьÑÑ Ð·Ð¼Ñ–Ð½ÑŽÐ²Ð°Ñ‚Ð¸ цю адреÑу Ñ– порт, бо це може призвеÑти до конфлікту з іншими ÑервіÑами запущеними на GitLab Ñервері."
+
+msgid "PrometheusService|Common metrics"
+msgstr "Загальні метрики"
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr "Загальні метрики автоматично збираютьÑÑ Ð½Ð° оÑнові набору метрик від популÑрних екÑпортерів."
+
+msgid "PrometheusService|Custom metrics"
+msgstr "ВлаÑні метрики"
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr "Пошук та Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð¼ÐµÑ‚Ñ€Ð¸Ðº..."
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr "Пошук влаÑних метрик..."
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr "Ð’Ñтановити Prometheus на клаÑтери"
+
+msgid "PrometheusService|Manage clusters"
+msgstr "Ð£Ð¿Ñ€Ð°Ð²Ð»Ñ–Ð½Ð½Ñ ÐºÐ»Ð°Ñтерами"
+
+msgid "PrometheusService|Manual configuration"
+msgstr "Ручні налаштуваннÑ"
+
+msgid "PrometheusService|Metrics"
+msgstr "Метрики"
+
+msgid "PrometheusService|Missing environment variable"
+msgstr "Пропущена змінна Ñередовища"
+
+msgid "PrometheusService|More information"
+msgstr "Додаткова інформаціÑ"
+
+msgid "PrometheusService|New metric"
+msgstr "Ðова метрика"
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr "Базова адреÑа Prometheus API, наприклад http://prometheus.example.com/"
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr "Prometheus автоматично налаштований на ваших клаÑтерах"
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr "Ці метрики будуть збиратиÑÑ Ñ‚Ñ–Ð»ÑŒÐºÐ¸ піÑÐ»Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ð² ÑкомуÑÑŒ Ñеридовищі"
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr "Ð¡ÐµÑ€Ð²Ñ–Ñ Ð¼Ð¾Ð½Ñ–Ñ‚Ð¾Ñ€Ð¸Ð½Ð³Ñƒ чаÑових Ñ€Ñдів"
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr "Ð”Ð»Ñ Ð¼Ð¾Ð¶Ð»Ð¸Ð²Ð¾ÑÑ‚Ñ– ручного налаштуваннÑ, видаліть Prometheus із ваших клаÑтерів"
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr "Ð”Ð»Ñ Ð¼Ð¾Ð¶Ð»Ð¸Ð²Ð¾ÑÑ‚Ñ– вÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Prometheus на ваші клаÑтери, деактивуйте ручні Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð½Ð¸Ð¶Ñ‡Ðµ"
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr "ÐžÑ‡Ñ–ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ñƒ Ñеридовищі Ð´Ð»Ñ Ð·Ð±Ð¾Ñ€Ñƒ загальних метрик"
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr "ПеренеÑти мітку на рівень групи"
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr "Підказка:"
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr "Публічна — група та вÑÑ– публічні проекти можуть переглÑдатиÑÑ Ð±ÐµÐ· автентифікації."
@@ -1673,19 +3511,31 @@ msgid "Public - The project can be accessed without any authentication."
msgstr "Публічний — проект может переглÑдатиÑÑ Ð±ÐµÐ· автентифікації."
msgid "Push Rules"
-msgstr "Push-правила"
+msgstr "Правила відправленнÑ"
msgid "Push events"
-msgstr "Push-події"
+msgstr "Події Ð²Ñ–Ð´Ð¿Ñ€Ð°Ð²Ð»ÐµÐ½Ð½Ñ (push)"
+
+msgid "Push project from command line"
+msgstr "Виконати push проекту за допомогою командного Ñ€Ñдка"
+
+msgid "Push to create a project"
+msgstr "ÐатиÑніть, щоб Ñтворити проект"
msgid "PushRule|Committer restriction"
msgstr "ÐžÐ±Ð¼ÐµÐ¶ÐµÐ½Ð½Ñ Ð´Ð»Ñ ÐºÐ¾Ð¼Ñ–Ñ‚Ñ‚ÐµÑ€Ð°"
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr "Швидкі дії можна викориÑтовувати в опиÑах проблем Ñ– коментарÑÑ…."
+
msgid "Read more"
msgstr "Докладніше"
msgid "Readme"
-msgstr "Прочитай Мене"
+msgstr "ІнÑтрукціÑ"
+
+msgid "Real-time features"
+msgstr ""
msgid "RefSwitcher|Branches"
msgstr "Гілки"
@@ -1693,6 +3543,12 @@ msgstr "Гілки"
msgid "RefSwitcher|Tags"
msgstr "Теги"
+msgid "Reference:"
+msgstr "ПоÑиланнÑ:"
+
+msgid "Register / Sign In"
+msgstr "ЗареєÑтруватиÑÑ / Увійти"
+
msgid "Registry"
msgstr "РеєÑÑ‚Ñ€"
@@ -1700,29 +3556,56 @@ msgid "Related Commits"
msgstr "Пов'Ñзані Коміти"
msgid "Related Deployed Jobs"
-msgstr "Пов’Ñзані розгорнуті задачі (Jobs)"
+msgstr "Пов’Ñзані розгорнуті Ð·Ð°Ð²Ð´Ð°Ð½Ð½Ñ (Jobs)"
msgid "Related Issues"
msgstr "Пов’Ñзані Проблеми (Issues)"
msgid "Related Jobs"
-msgstr "Пов’Ñзані Задачі (Jobs)"
+msgstr "Пов’Ñзані Ð—Ð°Ð²Ð´Ð°Ð½Ð½Ñ (Jobs)"
msgid "Related Merge Requests"
msgstr "Пов'Ñзані запити на злиттÑ"
msgid "Related Merged Requests"
-msgstr "Пов'Ñзані об'єднані запити"
+msgstr "Пов'Ñзані виконані запити"
+
+msgid "Related merge requests"
+msgstr ""
msgid "Remind later"
msgstr "Ðагадати пізніше"
+msgid "Remove"
+msgstr "Видалити"
+
+msgid "Remove avatar"
+msgstr "Видалити аватар"
+
msgid "Remove project"
msgstr "Видалити проект"
+msgid "Repair authentication"
+msgstr "Відновити аутентифікацію"
+
+msgid "Repo by URL"
+msgstr "Репозиторії по URL"
+
msgid "Repository"
msgstr "Репозиторій"
+msgid "Repository has no locks."
+msgstr "Репозиторій не має блокувань."
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "Запит доÑтупу"
@@ -1733,70 +3616,176 @@ msgid "Reset health check access token"
msgstr "Оновити токен доÑтупу Ð´Ð»Ñ Ð¿ÐµÑ€ÐµÐ²Ñ–Ñ€ÐºÐ¸ працездатноÑÑ‚Ñ–"
msgid "Reset runners registration token"
-msgstr "Скинути реєÑтраційний токен runner-ів"
+msgstr "Перегенерувати реєÑтраційний токен runner-ів"
+
+msgid "Resolve discussion"
+msgstr "Завершити обговореннÑ"
+
+msgid "Response"
+msgstr "Відповідь"
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] "Показати значеннÑ"
+msgstr[1] "Показати значеннÑ"
+msgstr[2] "Показати значень"
+msgstr[3] "Показати значень"
msgid "Revert this commit"
-msgstr "СкаÑувати цей коміт"
+msgstr "Ðнулювати цей коміт"
msgid "Revert this merge request"
-msgstr "СкаÑувати цей запит на злиттÑ"
+msgstr "Ðнулювати цей запит на злиттÑ"
+
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr "План-графік"
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr "ЗапуÑтити CI/CD конвеєри Ð´Ð»Ñ Ð·Ð¾Ð²Ð½Ñ–ÑˆÐ½Ñ–Ñ… репозиторіїв"
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr "ВиконуєтьÑÑ"
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
msgid "SSH Keys"
msgstr "Ключі SSH"
-msgid "Save"
-msgstr "Зберегти"
-
msgid "Save changes"
msgstr "Зберегти зміни"
msgid "Save pipeline schedule"
-msgstr "Зберегти Розклад Конвеєра"
+msgstr "Зберегти розклад конвеєра"
+
+msgid "Save variables"
+msgstr "Зберегти змінні"
msgid "Schedule a new pipeline"
msgstr "Розклад нового конвеєра"
+msgid "Scheduled"
+msgstr "Заплановано"
+
msgid "Schedules"
msgstr "Розклади"
msgid "Scheduling Pipelines"
msgstr "ÐŸÐ»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÐ¾Ð½Ð²ÐµÑ”Ñ€Ñ–Ð²"
+msgid "Scoped issue boards"
+msgstr "Тематичні дошки проблем"
+
+msgid "Search"
+msgstr "Пошук"
+
msgid "Search branches and tags"
msgstr "Пошук гілок та тегів"
+msgid "Search milestones"
+msgstr "Пошук етапів"
+
+msgid "Search project"
+msgstr "Пошук в проекті"
+
+msgid "Search users"
+msgstr "Пошук кориÑтувачів"
+
msgid "Seconds before reseting failure information"
msgstr "КількіÑÑ‚ÑŒ Ñекунд до ÑÐºÐ¸Ð´Ð°Ð½Ð½Ñ Ñ–Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ñ–Ñ— про збої"
-msgid "Seconds to wait after a storage failure"
-msgstr "Скільки Ñекунд очікувати піÑÐ»Ñ Ð·Ð±Ð¾ÑŽ в Ñховищі даних"
-
msgid "Seconds to wait for a storage access attempt"
msgstr "КількіÑÑ‚ÑŒ Ñекунд Ð¾Ñ‡Ñ–ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ð¿ÐµÑ€ÐµÐ´ повторною Ñпробою доÑтупу до Ñховища даних"
+msgid "Secret variables"
+msgstr "Секретні змінні"
+
+msgid "Security report"
+msgstr "Звіт по безпеці"
+
msgid "Select Archive Format"
msgstr "Виберіть формат архіву"
msgid "Select a timezone"
msgstr "Вибрати чаÑовий поÑÑ"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr "Виберіть Ñ–Ñнуючий клаÑтер Kubernetes або Ñтворіть новий"
+
+msgid "Select assignee"
+msgstr "Виберіть виконавцÑ"
+
+msgid "Select branch/tag"
+msgstr "Виберіть гілку або тег"
+
msgid "Select target branch"
msgstr "Вибір цільової гілки"
+msgid "Selective synchronization"
+msgstr "Вибіркова ÑинхронізаціÑ"
+
+msgid "Send email"
+msgstr "ÐадіÑлати лиÑта"
+
+msgid "Sep"
+msgstr "вер."
+
+msgid "September"
+msgstr "вереÑень"
+
+msgid "Server version"
+msgstr "ВерÑÑ–Ñ Ñервера"
+
msgid "Service Templates"
-msgstr "Ð¡ÐµÑ€Ð²Ñ–Ñ ÑˆÐ°Ð±Ð»Ð¾Ð½Ñ–Ð²"
+msgstr "Шаблони ÑервіÑів"
+
+msgid "Service URL"
+msgstr "Ð¡ÐµÑ€Ð²Ñ–Ñ URL"
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
msgid "Set a password on your account to pull or push via %{protocol}."
-msgstr "Ð’Ñтановіть пароль Ñвого облікового запиÑу, щоб відправлÑти або отримувати код через %{protocol}."
+msgstr "Ð’Ñтановіть пароль Ð´Ð»Ñ Ñвого облікового запиÑу, щоб мати можливіÑÑ‚ÑŒ відправлÑти та отримувати через %{protocol}."
-msgid "Set up CI"
-msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ CI"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ CI/CD"
msgid "Set up Koding"
msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Koding"
-msgid "Set up auto deploy"
-msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡Ð½Ðµ розгортаннÑ"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "вÑтановити пароль"
@@ -1804,6 +3793,24 @@ msgstr "вÑтановити пароль"
msgid "Settings"
msgstr "ÐалаштуваннÑ"
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr "При обнуленні хвилин конвеєрів Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проÑтору імен, кількіÑÑ‚ÑŒ вже викориÑтаних хвилин буде дорівнювати 0."
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr "Обнулити хвилини в конвеєрі"
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr "Обнулити викориÑтані хвилини в конвеєрі"
+
+msgid "Show command"
+msgstr "Показати команду"
+
msgid "Show parent pages"
msgstr "Показати батьківÑькі Ñторінки"
@@ -1815,15 +3822,49 @@ msgid_plural "Showing %d events"
msgstr[0] "Показано %d подію"
msgstr[1] "Показано %d події"
msgstr[2] "Показано %d подій"
+msgstr[3] "Показано %d подій"
+
+msgid "Sidebar|Change weight"
+msgstr "Змінити вагу"
+
+msgid "Sidebar|No"
+msgstr "ÐÑ–"
+
+msgid "Sidebar|None"
+msgstr "Ðемає"
+
+msgid "Sidebar|Weight"
+msgstr "Вага"
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
msgid "Snippets"
-msgstr "Фрагменти"
+msgstr "Сніпети"
-msgid "Something went wrong on our end."
+msgid "Something went wrong on our end"
msgstr "ЩоÑÑŒ пішло не так з нашого боку"
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
-msgstr "ЩоÑÑŒ пішло не так, намагаючиÑÑŒ змінити ÑÑ‚Ð°Ñ‚ÑƒÑ Ð±Ð»Ð¾ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ñ†ÑŒÐ¾Ð³Ð¾ ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong on our end."
+msgstr ""
+
+msgid "Something went wrong when toggling the button"
+msgstr "Помилка при перемиканні кнопки"
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
+msgstr "Помилка при отриманні SAST."
msgid "Something went wrong while fetching the projects."
msgstr "ЩоÑÑŒ пішло не так під Ñ‡Ð°Ñ Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾ÐµÐºÑ‚Ñ–Ð²"
@@ -1831,6 +3872,9 @@ msgstr "ЩоÑÑŒ пішло не так під Ñ‡Ð°Ñ Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð
msgid "Something went wrong while fetching the registry list."
msgstr "ЩоÑÑŒ пішло не так при отриманні ÑпиÑку із реєÑтру."
+msgid "Something went wrong. Please try again."
+msgstr "ЩоÑÑŒ пішло не так. Будь лаÑка Ñпробуйте ще раз."
+
msgid "Sort by"
msgstr "Сортувати за"
@@ -1874,7 +3918,7 @@ msgid "SortOptions|Least popular"
msgstr "Ðайменш популÑрний"
msgid "SortOptions|Less weight"
-msgstr "Ðайменша вага"
+msgstr "Менша вага"
msgid "SortOptions|Milestone"
msgstr "Етап"
@@ -1886,7 +3930,7 @@ msgid "SortOptions|Milestone due soon"
msgstr "Етап запланований незабаром"
msgid "SortOptions|More weight"
-msgstr "Ðайбільша вага"
+msgstr "Більша вага"
msgid "SortOptions|Most popular"
msgstr "Ðайбільш популÑрний"
@@ -1930,20 +3974,38 @@ msgstr "Розпочатий нещодавно"
msgid "SortOptions|Weight"
msgstr "Вага"
+msgid "Source"
+msgstr "Джерело"
+
+msgid "Source (branch or tag)"
+msgstr "Джерело (гілка або тег)"
+
msgid "Source code"
msgstr "Код"
+msgid "Source is not available"
+msgstr "Джерело недоÑтупне"
+
msgid "Spam Logs"
msgstr "Спам-журнал"
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "Зазначте наÑтупний URL під Ñ‡Ð°Ñ Ð²ÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Runner-а:"
msgid "StarProject|Star"
-msgstr "ПідпиÑатиÑÑ"
+msgstr "В обрані"
+
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
msgid "Starred projects"
-msgstr "Відмічені проекти"
+msgstr "Обрані проекти"
msgid "Start a %{new_merge_request} with these changes"
msgstr "Почати %{new_merge_request} з цими змінами"
@@ -1951,30 +4013,121 @@ msgstr "Почати %{new_merge_request} з цими змінами"
msgid "Start the Runner!"
msgstr "ЗапуÑÑ‚Ñ–Ñ‚ÑŒ Runner!"
+msgid "Started"
+msgstr "Запущений"
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr "СтатуÑ"
+
+msgid "Stopped"
+msgstr "Зупинено"
+
+msgid "Storage"
+msgstr "Сховище"
+
msgid "Subgroups"
msgstr "Підгрупи"
-msgid "Subscribe"
-msgstr "ПідпиÑатиÑÑ"
-
msgid "Switch branch/tag"
-msgstr "тег"
+msgstr "Перейти в гілку/тег"
+
+msgid "System"
+msgstr "СиÑтема"
msgid "System Hooks"
-msgstr "СиÑтемні Hook'и"
+msgstr "СиÑтемні гуки"
+
+msgid "System header and footer:"
+msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "Тег"
-msgstr[1] "Теги"
-msgstr[2] "Тегів"
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] "Тег (%{tag_count})"
+msgstr[1] "Тега (%{tag_count})"
+msgstr[2] "Тегів (%{tag_count})"
+msgstr[3] "Тегів (%{tag_count})"
msgid "Tags"
msgstr "Теги"
+msgid "TagsPage|Browse commits"
+msgstr "ПереглÑнути коміти"
+
+msgid "TagsPage|Browse files"
+msgstr "ПереглÑнути файли"
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr "Ðеможливо знайти HEAD коміт до цього тегу"
+
+msgid "TagsPage|Cancel"
+msgstr "СкаÑувати"
+
+msgid "TagsPage|Create tag"
+msgstr "Створити тег"
+
+msgid "TagsPage|Delete tag"
+msgstr "Видалити тег"
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr "Ð’Ð¸Ð´Ð°Ð»ÐµÐ½Ð½Ñ Ñ‚ÐµÐ³Ð° %{tag_name} не може бути ÑкаÑовано. Ви впевнені?"
+
+msgid "TagsPage|Edit release notes"
+msgstr "Редагувати Ð¾Ð¿Ð¸Ñ Ñ€ÐµÐ»Ñ–Ð·Ñƒ"
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr "Ім'Ñ Ñ–Ñнуючої гілки, тега або SHA коміта"
+
+msgid "TagsPage|Filter by tag name"
+msgstr "Фільтр по імені тега"
+
+msgid "TagsPage|New Tag"
+msgstr "Ðовий тег"
+
+msgid "TagsPage|New tag"
+msgstr "Ðовий тег"
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr "При бажанні Ви можете додати Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð² тег."
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr "При бажанні, додайте Ð¾Ð¿Ð¸Ñ Ñ€ÐµÐ»Ñ–Ð·Ñƒ до тегу. Він буде збережений в базі даних GitLab Ñ– відображатиметьÑÑ Ð½Ð° Ñторінці тегів."
+
+msgid "TagsPage|Release notes"
+msgstr "ÐžÐ¿Ð¸Ñ Ñ€ÐµÐ»Ñ–Ð·Ñƒ"
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr "Репозиторій не міÑтить тегів."
+
+msgid "TagsPage|Sort by"
+msgstr "Сортувати за"
+
+msgid "TagsPage|Tags"
+msgstr "Теги"
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr "Теги дають можливіÑÑ‚ÑŒ позначати певні моменти в Ñ–Ñторії Ñк важливі"
+
+msgid "TagsPage|This tag has no release notes."
+msgstr "Цей тег не міÑтить опиÑу релізу."
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr "ВикориÑтовуйте команду git tag, щоб додати новий тег:"
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr "Ðапишіть Ñвій Ð¾Ð¿Ð¸Ñ Ñ€ÐµÐ»Ñ–Ð·Ñƒ або перетÑгніть файли Ñюди..."
+
+msgid "TagsPage|protected"
+msgstr "захищений"
+
msgid "Target Branch"
msgstr "Цільова гілка"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr "Команда"
@@ -1982,43 +4135,58 @@ msgid "Thanks! Don't show me this again"
msgstr "ДÑкую! Більше не показувати це повідомленнÑ"
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
-msgstr "Розширений глобальний пошук в GitLab - це потужний інÑтрумент Ñкий заощаджує ваш чаÑ. ЗаміÑÑ‚ÑŒ Ð´ÑƒÐ±Ð»ÑŽÐ²Ð°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ Ñ– витрати чаÑу, ви можете шукати код вÑередині інших команд, Ñкий може допомогти у вашому проекті."
+msgstr "Розширений глобальний пошук в GitLab - це потужний інÑтрумент Ñкий заощаджує ваш чаÑ. ЗаміÑÑ‚ÑŒ Ð´ÑƒÐ±Ð»ÑŽÐ²Ð°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ Ñ– витрати чаÑу, ви можете шукати код інших команд, Ñкий може допомогти у вашому проекті."
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
-msgstr "Поріг Ð¿Ñ€Ð¸Ð·ÑƒÐ¿Ð¸Ð½ÐµÐ½Ð½Ñ circuitbreaker має бути нижчий за поріг повного відключеннÑ"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+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 "Ðа Ñтадії напиÑÐ°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ, показує Ñ‡Ð°Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ коміту до ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ на об'єднаннÑ. Дані будуть автоматично додані піÑÐ»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ першого запиту на об'єднаннÑ."
+msgstr "Ð¡Ñ‚Ð°Ð´Ñ–Ñ ÐапиÑÐ°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ показує Ñ‡Ð°Ñ Ð²Ñ–Ð´ першого коміту до ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ на злиттÑ. Дані будуть автоматично додані піÑÐ»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ першого запиту на злиттÑ."
msgid "The collection of events added to the data gathered for that stage."
-msgstr "ÐšÐ¾Ð»ÐµÐºÑ†Ñ–Ñ Ð¿Ð¾Ð´Ñ–Ð¹ додана до даних, зібраних Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ етапу."
+msgstr "ÐšÐ¾Ð»ÐµÐºÑ†Ñ–Ñ Ð¿Ð¾Ð´Ñ–Ð¹ додана до даних, зібраних Ð´Ð»Ñ Ñ†Ñ–Ñ”Ñ— Ñтадії."
+
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr "Ð—â€™Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð±ÑƒÐ´Ðµ припинено піÑÐ»Ñ %{timeout}. Ð”Ð»Ñ Ñ€ÐµÐ¿Ð¾Ð·Ð¸Ñ‚Ð¾Ñ€Ñ–Ñ—Ð², Ñким потрібно більше чаÑу, викориÑтовуйте комбінацію clone/push."
msgid "The fork relationship has been removed."
-msgstr "Зв'Ñзок форка видалена."
+msgstr "Зв'Ñзок форку видалено."
+
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr "Імпорт буде припинено піÑÐ»Ñ %{timeout}. Ð”Ð»Ñ Ñ€ÐµÐ¿Ð¾Ð·Ð¸Ñ‚Ð¾Ñ€Ñ–Ñ—Ð², Ñким потрібно більше чаÑу, викориÑтовуйте комбінацію clone/push."
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 "Етап випуÑку показує, Ñкільки чаÑу потрібно від ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ до приÑÐ²Ð¾Ñ”Ð½Ð½Ñ Ð²Ð¸Ð¿ÑƒÑку, або Ð´Ð¾Ð´Ð°Ð²Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ в вашу дошку проблем. Почніть Ñтворювати проблеми, щоб переглÑдати дані Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ етапу."
+msgstr "Ð¡Ñ‚Ð°Ð´Ñ–Ñ ÐŸÑ€Ð¾Ð±Ð»ÐµÐ¼Ð° показує, Ñкільки чаÑу потрібно від ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ до Ð²ÐºÐ»ÑŽÑ‡ÐµÐ½Ð½Ñ Ñ—Ñ— до ÑкогоÑÑŒ етапу, або Ð´Ð¾Ð´Ð°Ð²Ð°Ð½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ на дошку. Почніть Ñтворювати проблеми, щоб переглÑдати дані Ð´Ð»Ñ Ñ†Ñ–Ñ”Ñ— Ñтадії."
+
+msgid "The maximum file size allowed is 200KB."
+msgstr "МакÑимальний розмір файлу — 200 Кб."
msgid "The number of attempts GitLab will make to access a storage."
msgstr "КількіÑÑ‚ÑŒ Ñпроб, Ñкі зробить GitLab Ð´Ð»Ñ Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ð½Ð½Ñ Ð´Ð¾Ñтупу до Ñховища даних."
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
-msgstr "КількіÑÑ‚ÑŒ невдач, піÑÐ»Ñ Ñ‡Ð¾Ð³Ð¾ GitLab почне тимчаÑово блокувати доÑтуп до Ñховища на хоÑÑ‚Ñ–"
-
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
-msgstr "КількіÑÑ‚ÑŒ збоїв піÑÐ»Ñ Ñкої Gitlab повніÑÑ‚ÑŽ заблокує доÑтуп до Ñховища данних. Лічильник кількоÑÑ‚Ñ– збоїв може бути Ñкинутий в інтерфейÑÑ– адмініÑтратора (%{link_to_health_page}), або через %{api_documentation_link}."
+msgstr "КількіÑÑ‚ÑŒ збоїв піÑÐ»Ñ Ñкої Gitlab повніÑÑ‚ÑŽ заблокує доÑтуп до Ñховища данних. Лічильник кількоÑÑ‚Ñ– збоїв можна буде обнулити в інтерфейÑÑ– адмініÑтратора (%{link_to_health_page}), або через %{api_documentation_link}."
+
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 "Ðа етапі Ð¿Ð»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ Ð²Ñ–Ð´Ð¾Ð±Ñ€Ð°Ð¶Ð°Ñ”Ñ‚ÑŒÑÑ Ñ‡Ð°Ñ Ð²Ñ–Ð´ попереднього кроку до першого коміту. ДодаєтьÑÑ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡Ð½Ð¾, Ñк тільки відправитÑÑ Ð¿ÐµÑ€ÑˆÐ¸Ð¹ коміт."
+msgstr "Ð¡Ñ‚Ð°Ð´Ñ–Ñ ÐŸÐ»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ Ð²Ñ–Ð´Ð¾Ð±Ñ€Ð°Ð¶Ð°Ñ” Ñ‡Ð°Ñ Ð²Ñ–Ð´ попереднього кроку до першого коміту. ДодаєтьÑÑ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡Ð½Ð¾, Ñк тільки відправитьÑÑ Ð¿ÐµÑ€ÑˆÐ¸Ð¹ коміт."
+
+msgid "The private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "Ð¡Ñ‚Ð°Ð´Ñ–Ñ ÐŸÐ ÐžÐ”Ð°ÐºÑˆÐ¸Ð½ показує загальний Ñ‡Ð°Ñ Ð¼Ñ–Ð¶ ÑтвореннÑм проблеми та розгортаннÑм коду у ПРОДакшині. Дані будуть автоматично додані піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿Ð¾Ð²Ð½Ð¾Ñ— ідеї до ПРОДакшин циклу."
+msgstr "Ð¡Ñ‚Ð°Ð´Ñ–Ñ Production показує загальний Ñ‡Ð°Ñ Ð¼Ñ–Ð¶ ÑтвореннÑм проблеми та розгортаннÑм коду у production. Дані будуть автоматично додані піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿Ð¾Ð²Ð½Ð¾Ñ— ідеї до production циклу."
msgid "The project can be accessed by any logged in user."
msgstr "ДоÑтуп до проекту можливий будь-Ñким зареєÑтрованим кориÑтувачем."
@@ -2029,14 +4197,23 @@ msgstr "ДоÑтуп до проекту можливий без будь-Ñко
msgid "The repository for this project does not exist."
msgstr "Репозиторій Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту не Ñ–Ñнує."
+msgid "The repository for this project is empty"
+msgstr "Репозиторій цього проекту порожній"
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+msgstr "Репозиторій має бути доÑтупним через <code>http://</code>, <code>https://</code> або <code>git://</code>."
+
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 "Ð¡Ñ‚Ð°Ð´Ñ–Ñ Ð¾Ð³Ð»Ñду показує Ñ‡Ð°Ñ Ð²Ñ–Ð´ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ про об'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð´Ð¾ його виконаннÑ. Дані будуть автоматично додані піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ запиту на злиттÑ."
+msgstr "Ð¡Ñ‚Ð°Ð´Ñ–Ñ Ð—Ð°Ñ‚Ð²ÐµÑ€Ð´Ð¶ÐµÐ½Ð½Ñ Ð¿Ð¾ÐºÐ°Ð·ÑƒÑ” Ñ‡Ð°Ñ Ð²Ñ–Ð´ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ про об'Ñ”Ð´Ð½Ð°Ð½Ð½Ñ Ð´Ð¾ його виконаннÑ. Дані будуть автоматично додані піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ запиту на злиттÑ."
+
+msgid "The roadmap shows the progress of your epics along a timeline"
+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\" та розгортаннÑм коду у ПРОДакшин. Дані автоматично додаютьÑÑ Ð¿Ñ–ÑÐ»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ñƒ ПРОДакшин вперше."
+msgstr "Ð¡Ñ‚Ð°Ð´Ñ–Ñ Staging показує Ñ‡Ð°Ñ Ð¼Ñ–Ð¶ Ð²Ð¸ÐºÐ¾Ð½Ð°Ð½Ð½Ñ Ð·Ð°Ð¿Ð¸Ñ‚Ñƒ на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ñ‚Ð° розгортаннÑм коду у production. Дані автоматично додаютьÑÑ Ð¿Ñ–ÑÐ»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ Ñƒ 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 виконує Ð´Ð»Ñ Ð·Ð°Ð¿ÑƒÑку кожного конвеєра Ð´Ð»Ñ Ð²Ñ–Ð´Ð¿Ð¾Ð²Ñ–Ð´Ð½Ð¾Ð³Ð¾ запиту злиттÑ. Дані будуть автоматично додані піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ конвеєра."
+msgstr "Ð¡Ñ‚Ð°Ð´Ñ–Ñ Ð¢ÐµÑÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð¿Ð¾ÐºÐ°Ð·ÑƒÑ” чаÑ, Ñкий GitLab CI витрачає Ð´Ð»Ñ Ð²Ð¸ÐºÐ¾Ð½Ð°Ð½Ð½Ñ ÐºÐ¾Ð¶Ð½Ð¾Ð³Ð¾ конвеєра Ð´Ð»Ñ Ð²Ñ–Ð´Ð¿Ð¾Ð²Ñ–Ð´Ð½Ð¾Ð³Ð¾ запиту злиттÑ. Дані будуть автоматично додані піÑÐ»Ñ Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð¿ÐµÑ€ÑˆÐ¾Ð³Ð¾ конвеєра."
msgid "The time in seconds GitLab will keep failure information. When no failures occur during this time, information about the mount is reset."
msgstr "КількіÑÑ‚ÑŒ Ñекунд, протÑгом Ñкої GitLab зберігає інформацію про збої. Якщо протÑгом цього періоду жодних збоїв не відбуваєтьÑÑ, Ñ–Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ñ–Ñ Ð¿Ñ€Ð¾ точку Ð¼Ð¾Ð½Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ ÑкидаєтьÑÑ."
@@ -2044,17 +4221,50 @@ msgstr "КількіÑÑ‚ÑŒ Ñекунд, протÑгом Ñкої GitLab збе
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr "КількіÑÑ‚ÑŒ Ñекунд, протÑгом Ñкої GitLab намагатиметьÑÑ Ð¾Ñ‚Ñ€Ð¸Ð¼Ð°Ñ‚Ð¸ доÑтуп до Ñховища даних. По завершенню цього періоду буде згенерована помилка про Ð¿ÐµÑ€ÐµÐ²Ð¸Ñ‰ÐµÐ½Ð½Ñ Ð»Ñ–Ð¼Ñ–Ñ‚Ñƒ чаÑу."
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr "Ð§Ð°Ñ Ñƒ Ñекундах між перевірками Ñховища. Якщо Ð¿Ð¾Ð¿ÐµÑ€ÐµÐ´Ð½Ñ Ð¿ÐµÑ€ÐµÐ²Ñ–Ñ€ÐºÐ° іще на завершена, GitLab пропуÑтить наÑтупну."
+
msgid "The time taken by each data entry gathered by that stage."
-msgstr "ЧаÑ, витрачений на кожен елемент, зібраний на цьому етапі."
+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 "There are no issues to show"
+msgstr "Ðемає проблем Ð´Ð»Ñ Ð²Ñ–Ð´Ð¾Ð±Ñ€Ð°Ð¶ÐµÐ½Ð½Ñ"
+
+msgid "There are no merge requests to show"
+msgstr "Ðемає запитів на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð´Ð»Ñ Ð²Ñ–Ð´Ð¾Ð±Ñ€Ð°Ð¶ÐµÐ½Ð½Ñ"
+
msgid "There are problems accessing Git storage: "
-msgstr "Є проблеми з доÑтупом до Ñховища: "
+msgstr "Є проблеми з доÑтупом до Ñховища git: "
+
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr "Помилка при завантаженні ÐºÐ°Ð»ÐµÐ½Ð´Ð°Ñ€Ñ Ð°ÐºÑ‚Ð¸Ð²Ð½Ð¾ÑÑ‚Ñ– кориÑтувачів."
+
+msgid "There was an error saving your notification settings."
+msgstr "Помилка при збереженні ваших налаштувань Ñповіщень."
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
-msgstr "Ð¦Ñ Ð³Ñ–Ð»ÐºÐ° була змінена піÑÐ»Ñ Ñ‚Ð¾Ð³Ð¾ моменту, коли ви почали Ñ—Ñ— редагувати. Ви хотіли б Ñтворити нову?"
+msgid "There was an error subscribing to this label."
+msgstr "Помилка при підпиÑці на цю мітку."
+
+msgid "There was an error when reseting email token."
+msgstr "Помилка при Ñкиданні токена електронної пошти."
+
+msgid "There was an error when subscribing to this label."
+msgstr "Помилка при підпиÑці на цю мітку."
+
+msgid "There was an error when unsubscribing from this label."
+msgstr "Помилка при відпиÑці від цієї мітки."
+
+msgid "This board\\'s scope is reduced"
+msgstr "ВидиміÑÑ‚ÑŒ цієї дошки обмежена"
+
+msgid "This directory"
+msgstr "Цей каталог"
msgid "This is a confidential issue."
msgstr "Це конфіденційна проблема."
@@ -2062,18 +4272,54 @@ msgstr "Це конфіденційна проблема."
msgid "This is the author's first Merge Request to this project."
msgstr "Це перший запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð²Ñ–Ð´ цього автора Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ проекту."
+msgid "This issue is confidential"
+msgstr "Ð¦Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð° Ñ” конфіденційною"
+
msgid "This issue is confidential and locked."
msgstr "Ð¦Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð° конфіденційна Ñ– заблокована."
msgid "This issue is locked."
msgstr "Ð¦Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð° заблокована."
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr "Це Ð·Ð°Ð²Ð´Ð°Ð½Ð½Ñ Ð·Ð°Ð¿ÑƒÑкаєтьÑÑ ÐºÐ¾Ñ€Ð¸Ñтувачем. ЧаÑто вони викориÑтовуютьÑÑ Ð´Ð»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ на production"
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr "Це Ð·Ð°Ð²Ð´Ð°Ð½Ð½Ñ Ð·Ð°Ð»ÐµÐ¶Ð¸Ñ‚ÑŒ від попередніх, Ñкі повинні завершитиÑÑ ÑƒÑпішно Ð´Ð»Ñ Ð¹Ð¾Ð³Ð¾ запуÑку"
+
+msgid "This job has not been triggered yet"
+msgstr "Ð—Ð°Ð²Ð´Ð°Ð½Ð½Ñ Ñ‰Ðµ не було запущене"
+
+msgid "This job has not started yet"
+msgstr "Це Ð·Ð°Ð²Ð´Ð°Ð½Ð½Ñ Ñ‰Ðµ не запуÑтилоÑÑ"
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr "Ð—Ð°Ð²Ð´Ð°Ð½Ð½Ñ Ð²Ð¸Ð¼Ð°Ð³Ð°Ñ” ручних дій"
+
msgid "This means you can not push code until you create an empty repository or import existing one."
-msgstr "Це означає, що ви не можете відправлÑти код, поки не Ñтворите порожній репозиторій або ÐЕ імпортуєте Ñ–Ñнуючий."
+msgstr "Це означає, що ви не можете відправлÑти код, поки не Ñтворите порожній репозиторій або не імпортуєте Ñ–Ñнуючий."
msgid "This merge request is locked."
msgstr "Цей запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð·Ð°Ð±Ð»Ð¾ÐºÐ¾Ð²Ð°Ð½Ð¾."
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr "Ð¦Ñ Ñторінка недоÑтупна, тому що ви не можете переглÑдати інформацію по кількох проектах."
+
+msgid "This project"
+msgstr "Цей проект"
+
+msgid "This repository"
+msgstr "Цей репозиторій"
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr "Це призведе до Ð²Ð¸Ð´Ð°Ð»ÐµÐ½Ð½Ñ Ð²Ð»Ð°Ñної метрики, ви впевнені?"
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr "Ці Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ ÐµÐ»ÐµÐºÑ‚Ñ€Ð¾Ð½Ð½Ð¾Ñ— пошти автоматично Ñтануть обговореннÑми проблем, Ñкі відображатимутьÑÑ Ñ‚ÑƒÑ‚ (причому коментарі Ñтануть чаÑтиною перепиÑки)."
+
msgid "Time before an issue gets scheduled"
msgstr "Ð§Ð°Ñ Ð´Ð¾ початку потраплÑÐ½Ð½Ñ Ð¿Ñ€Ð¾Ð±Ð»ÐµÐ¼Ð¸ в планувальник"
@@ -2081,11 +4327,29 @@ msgid "Time before an issue starts implementation"
msgstr "Ð§Ð°Ñ Ð´Ð¾ початку роботи над проблемою"
msgid "Time between merge request creation and merge/close"
-msgstr "Ð§Ð°Ñ Ð¼Ñ–Ð¶ ÑтвореннÑм запиту Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ñ– злиттÑм або закриттÑм"
+msgstr "Ð§Ð°Ñ Ð¼Ñ–Ð¶ ÑтвореннÑм запиту Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ñ– його виконаннÑм або закриттÑм"
+
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr "ВідÑÑ‚ÐµÐ¶ÐµÐ½Ð½Ñ Ñ‡Ð°Ñу"
msgid "Time until first merge request"
msgstr "Ð§Ð°Ñ Ð´Ð¾ першого запиту на злиттÑ"
+msgid "TimeTrackingEstimated|Est"
+msgstr "Запланований чаÑ"
+
+msgid "TimeTracking|Estimated:"
+msgstr "Запланований чаÑ:"
+
+msgid "TimeTracking|Spent"
+msgstr "Витрачено"
+
msgid "Timeago|%s days ago"
msgstr "%s днів тому"
@@ -2214,24 +4478,95 @@ msgid_plural "Time|hrs"
msgstr[0] "година"
msgstr[1] "години"
msgstr[2] "годин"
+msgstr[3] "годин"
msgid "Time|min"
msgid_plural "Time|mins"
msgstr[0] "хвилина"
msgstr[1] "хвилини"
msgstr[2] "хвилин"
+msgstr[3] "хвилин"
msgid "Time|s"
msgstr "Ñекунд(а)"
+msgid "Tip:"
+msgstr "Порада:"
+
+msgid "Title"
+msgstr "Ðазва"
+
+msgid "To GitLab"
+msgstr "Ð’ GitLab"
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr "Ð”Ð»Ñ Ð¿Ñ–Ð´ÐºÐ»ÑŽÑ‡ÐµÐ½Ð½Ñ Ñ€ÐµÐ¿Ð¾Ð·Ð¸Ñ‚Ð¾Ñ€Ñ–Ñ—Ð² з GitHub, ви Ñпочатку повинні дозволити Gitlab доÑтуп до ÑпиÑку ваших репозиторіїв на GitHub:"
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr "Ð”Ð»Ñ Ð¿Ñ€Ð¸Ñ”Ð´Ð½Ð°Ð½Ð½Ñ SVN-репозиторію, переглÑньте %{svn_link}."
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr "Ð”Ð»Ñ Ñ–Ð¼Ð¿Ð¾Ñ€Ñ‚Ñƒ репозиторіїв з GitHub, ви Ñпочатку повинні дозволити Gitlab доÑтуп до ÑпиÑку ваших репозиторіїв на GitHub:"
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr "Ð”Ð»Ñ Ñ–Ð¼Ð¿Ð¾Ñ€Ñ‚ÑƒÐ²Ð°Ð½Ð½Ñ SVN-репозиторію, переглÑньте %{svn_link}."
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr "Щоб викориÑтовувати лише функції CI/CD Ð´Ð»Ñ Ð·Ð¾Ð²Ð½Ñ–ÑˆÐ½ÑŒÐ¾Ð³Ð¾ репозиторію, виберіть <strong>CI/CD Ð´Ð»Ñ Ð·Ð¾Ð²Ð½Ñ–ÑˆÐ½ÑŒÐ¾Ð³Ð¾ репозиторію</strong>."
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr "Ð”Ð»Ñ Ð¿ÐµÑ€ÐµÐ³Ð»Ñду плану-графіку додайте заплановані дати початку та Ð·Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ð´Ð¾ одного з ваших епіків у цій групі або Ñ—Ñ— підгрупах. ВідображаютьÑÑ Ð»Ð¸ÑˆÐµ епіки за попередні та наÑтупні 3 міÑÑці."
+
+msgid "Todo"
+msgstr "Задача"
+
+msgid "Toggle sidebar"
+msgstr "Перемикач бічної панелі"
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr "Ð¡Ñ‚Ð°Ñ‚ÑƒÑ Ð¿ÐµÑ€ÐµÐ¼Ð¸ÐºÐ°Ñ‡Ð°: ВИМКÐЕÐО"
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr "Ð¡Ñ‚Ð°Ñ‚ÑƒÑ Ð¿ÐµÑ€ÐµÐ¼Ð¸ÐºÐ°Ñ‡Ð°: УВІМКÐЕÐО"
+
msgid "Total Time"
msgstr "Загальний чаÑ"
msgid "Total test time for all commits/merges"
msgstr "Загальний чаÑ, щоб перевірити вÑÑ– коміти/злиттÑ"
+msgid "Total: %{total}"
+msgstr "Ð’Ñього: %{total}"
+
msgid "Track activity with Contribution Analytics."
-msgstr "ВідÑтежувати активніÑÑ‚ÑŒ за допомогою Ðналітики контриб’юторів."
+msgstr "ВідÑтежувати активніÑÑ‚ÑŒ за допомогою Ðналітики учаÑників."
+
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr "ВідÑтежуйте групи проблем зі Ñпільною темою з різних проектів та етапів"
+
+msgid "Track time with quick actions"
+msgstr "ВідÑтежуйте Ñ‡Ð°Ñ Ð·Ð° допомогою швидких дій"
+
+msgid "Trigger this manual action"
+msgstr "ЗапуÑтити цю ручну дію"
+
+msgid "Turn on Service Desk"
+msgstr "Ввімкнути Service Desk"
+
+msgid "Unknown"
+msgstr "Ðевідомо"
msgid "Unlock"
msgstr "Розблокувати"
@@ -2239,26 +4574,29 @@ msgstr "Розблокувати"
msgid "Unlocked"
msgstr "Розблоковано"
+msgid "Unresolve discussion"
+msgstr "Повторно відкрити обговореннÑ"
+
msgid "Unstar"
-msgstr "ВідпиÑатиÑÑŒ"
+msgstr "Видалити із обраних"
-msgid "Unsubscribe"
-msgstr "ВідпиÑатиÑÑ"
+msgid "Up to date"
+msgstr "Ðктуальний"
msgid "Upgrade your plan to activate Advanced Global Search."
msgstr "Перейдіть на вищий тарифний план щоб активувати Покращений Глобальний Пошук."
msgid "Upgrade your plan to activate Contribution Analytics."
-msgstr "Перейдіть на вищий тарифний план Ð´Ð»Ñ Ð°ÐºÑ‚Ð¸Ð²Ð°Ñ†Ñ–Ñ— Ðналітики контриб’юторів."
+msgstr "Перейдіть на вищий тарифний план Ð´Ð»Ñ Ð°ÐºÑ‚Ð¸Ð²Ð°Ñ†Ñ–Ñ— Ðналітики учаÑників."
msgid "Upgrade your plan to activate Group Webhooks."
-msgstr "Перейдіть на вищий тарифний план щоб активувати Групові Webhook’и."
+msgstr "Перейдіть на вищий тарифний план щоб активувати групові веб-гуки."
msgid "Upgrade your plan to activate Issue weight."
-msgstr "Підвищіть план щоб активувати вагу обговорень."
+msgstr "Перейдіть на вищий тарифний план щоб активувати вагу обговорень проблем."
msgid "Upgrade your plan to improve Issue boards."
-msgstr "Підвищіть Ñвій план, щоб покращити дошки обговорень."
+msgstr "Перейдіть на вищий тарифний план щоб покращити дошки обговорень."
msgid "Upload New File"
msgstr "Завантажити новий файл"
@@ -2266,24 +4604,72 @@ msgstr "Завантажити новий файл"
msgid "Upload file"
msgstr "Завантажити файл"
+msgid "Upload new avatar"
+msgstr "Завантажити новий аватар"
+
msgid "UploadLink|click to upload"
msgstr "ÐатиÑніть, щоб завантажити"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr "ВикориÑтовуйте Service Desk Ð´Ð»Ñ Ð·Ð²â€™Ñзку з вашими кориÑтувачами (наприклад, щоб запропонувати клієнтÑьку підтримку) через електронну пошту безпоÑередньо із GitLab"
+
msgid "Use the following registration token during setup:"
msgstr "ВикориÑтовувати токен під Ñ‡Ð°Ñ ÑƒÑтановки:"
msgid "Use your global notification setting"
msgstr "ВикориÑтовуютьÑÑ Ð³Ð»Ð¾Ð±Ð°Ð»ÑŒÐ½Ñ– Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½ÑŒ"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr "Змінні заÑтоÑовуютьÑÑ Ð´Ð¾ Ñередовищ через runner. Вони можуть бути захищені, в такому випадку вони доÑтупні тільки Ð´Ð»Ñ Ð·Ð°Ñ…Ð¸Ñ‰ÐµÐ½Ð¸Ñ… гілок та тегів. Ви можете викориÑтовувати змінні Ð´Ð»Ñ Ð¿Ð°Ñ€Ð¾Ð»Ñ–Ð², Ñекретний ключів тощо."
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr "ПереглÑнути та відредагувати Ñ€Ñдки"
+
+msgid "View epics list"
+msgstr "ПереглÑнути ÑпиÑок епіків"
+
msgid "View file @ "
msgstr "ПереглÑд файла @ "
+msgid "View group labels"
+msgstr "ПереглÑнути мітки групи"
+
+msgid "View labels"
+msgstr "ПереглÑнути мітки"
+
msgid "View open merge request"
-msgstr "ПереглÑд відкритих запитів на злиттÑ"
+msgstr "ПереглÑнути відкритий запит на злиттÑ"
+
+msgid "View project labels"
+msgstr "ПереглÑнути мітки проекту"
msgid "View replaced file @ "
msgstr "ПереглÑд заміненого файлу @ "
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "Внутрішній"
@@ -2299,17 +4685,29 @@ msgstr "Ðевідомий"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "Хочете побачити дані? Будь лаÑка, попроÑить у адмініÑтратора доÑтуп."
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr "Ми не змогли перевірити, що один із ваших проектів в GCP має ввімкнений білінг. Будь лаÑка, Ñпробуйте ще раз."
+
msgid "We don't have enough data to show this stage."
-msgstr "Ми не маємо доÑтатньо даних Ð´Ð»Ñ Ð¿Ð¾ÐºÐ°Ð·Ñƒ цього етапу."
+msgstr "Ми не маємо доÑтатньо даних Ð´Ð»Ñ Ð²Ñ–Ð´Ð¾Ð±Ñ€Ð°Ð¶ÐµÐ½Ð½Ñ Ñ†Ñ–Ñ”Ñ— Ñтадії."
+
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr "Ми хочемо бути впевнені, що це ви, будь лаÑка, підтвердіть, що ви не робот."
+
+msgid "Web IDE"
+msgstr "Веб-IDE"
+
+msgid "Web terminal"
+msgstr ""
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
-msgstr "Webhook дозволÑÑŽÑ‚ÑŒ вам викликати адреÑу URL Ñкщо, наприклад, відправлений новий код або Ñтворено нову тему повідомленнÑ. Ви можете налаштувати Webhook так, щоб він реагував на певні події, такі Ñк відправки коду, Ð¾Ð±Ð³Ð¾Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð°Ð±Ð¾ запити на злиттÑ. Групові Webhook’и заÑтоÑовуютьÑÑ Ð´Ð¾ вÑÑ–Ñ… проектів в групі Ñ– дозволÑÑŽÑ‚ÑŒ вам Ñтандартизувати функціональніÑÑ‚ÑŒ Webhook’ів Ð´Ð»Ñ Ð²Ñієї вашої групи."
+msgstr "Веб-гук дозволÑÑ” вам викликати URL Ñкщо, наприклад, був відправлений новий код або Ñтворено нову проблему. Ви можете налаштувати його так, щоб він реагував на певні події (відправки коду, проблеми або запити на злиттÑ). Групові веб-гуки заÑтоÑовуютьÑÑ Ð´Ð¾ вÑÑ–Ñ… проектів в групі Ñ– дозволÑÑŽÑ‚ÑŒ вам Ñтандартизувати Ñ—Ñ… Ð´Ð»Ñ Ð²Ñієї вашої групи."
msgid "Weight"
msgstr "Вага"
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
-msgstr "Коли відбуваєтьÑÑ Ð·Ð±Ñ–Ð¹ при доÑтупі до Ñховища даних, GitLab блокує доÑуп до нього протÑгом періоду чаÑу, заданому тут. Це дає можливіÑÑ‚ÑŒ файловій ÑиÑтемі відновитиÑÑ. Репозиторії на шардах (shards) зі збоÑми тимчаÑово не доÑтупні"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr ""
msgid "Wiki"
msgstr "Wiki"
@@ -2329,8 +4727,14 @@ msgstr "РекомендуєтьÑÑ Ð²Ñтановити %{markdown}, з тим
msgid "WikiClone|Start Gollum and edit locally"
msgstr "ЗапуÑÑ‚Ñ–Ñ‚ÑŒ Gollum Ñ– редагуйте локально"
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr "Порада: Ви можете переміÑтити цю Ñторінку, додавши шлÑÑ… до початку заголовка."
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr "Вже Ñ–Ñнує Ñторінка з таким шлÑхом Ñ– заголовком."
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
-msgstr "Ви не можете Ñтворювати вікі-Ñторінки"
+msgstr "Ви не можете Ñтворювати wiki-Ñторінки"
msgid "WikiHistoricalPage|This is an old version of this page."
msgstr "Це — Ñтара верÑÑ–Ñ Ñторінки."
@@ -2348,7 +4752,7 @@ msgid "WikiMarkdownDocs|More examples are in the %{docs_link}"
msgstr "Більше прикладів знаходитьÑÑ Ð² %{docs_link}"
msgid "WikiMarkdownDocs|documentation"
-msgstr "документаціÑ"
+msgstr "документації"
msgid "WikiMarkdownTip|To link to a (new) page, simply type %{link_example}"
msgstr "Ð”Ð»Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿Ð¾ÑÐ¸Ð»Ð°Ð½Ð½Ñ Ð½Ð° (нову) Ñторінку, проÑто введіть %{link_example}"
@@ -2360,7 +4764,7 @@ msgid "WikiNewPageTip|Tip: You can specify the full path for the new file. We wi
msgstr "Порада: можна вказати повний шлÑÑ… до нового файлу. Ми автоматично Ñтворимо вÑÑ– відÑутні каталоги."
msgid "WikiNewPageTitle|New Wiki Page"
-msgstr "Ðова Вікі-Ñторінка"
+msgstr "Ðова wiki-Ñторінка"
msgid "WikiPageConfirmDelete|Are you sure you want to delete this page?"
msgstr "Ви дійÑно бажаєте видалити цю Ñторінку?"
@@ -2390,7 +4794,7 @@ msgid "Wiki|Create page"
msgstr "Створити Ñторінку"
msgid "Wiki|Edit Page"
-msgstr "Редагувати Ñторінку"
+msgstr "Ð ÐµÐ´Ð°Ð³ÑƒÐ²Ð°Ð½Ð½Ñ cторінки"
msgid "Wiki|Empty page"
msgstr "ÐŸÐ¾Ñ€Ð¾Ð¶Ð½Ñ Ñторінка"
@@ -2411,34 +4815,52 @@ msgid "Wiki|Pages"
msgstr "Сторінки"
msgid "Wiki|Wiki Pages"
-msgstr "Вікі-Ñторінки"
+msgstr "Wiki-Ñторінки"
msgid "With contribution analytics you can have an overview for the activity of issues, merge requests and push events of your organization and its members."
-msgstr "З аналітикою контриб’юторів ви може вивчати активніÑÑ‚ÑŒ в обговореннÑÑ…, запитах на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ñ– подій відправки коду Ð´Ð»Ñ Ð²Ð°ÑˆÐ¾Ñ— організації Ñ– Ñ—Ñ— учаÑників."
+msgstr "З аналітикою учаÑників ви може вивчати активніÑÑ‚ÑŒ в обговореннÑÑ…, запитах на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ñ– змінах у коді Ð´Ð»Ñ Ð²Ð°ÑˆÐ¾Ñ— організації та Ñ—Ñ— учаÑників."
msgid "Withdraw Access Request"
msgstr "СкаÑувати запит доÑтупу"
+msgid "Write a commit message..."
+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 %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Ви хочете видалити %{project_full_name}. Видалений проект ÐЕ МОЖЕ бути відновлений! Ви ÐБСОЛЮТÐО впевнені?"
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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr "Ви збираєтеÑÑ Ð¿ÐµÑ€ÐµÐ´Ð°Ñ‚Ð¸ проект %{project_full_name} іншому влаÑнику. Ви ÐБСОЛЮТÐО впевнені?"
msgid "You are on a read-only GitLab instance."
-msgstr "Ви знаходитеÑÑ Ð½Ð° інÑтанÑÑ– Gitlab \"тільки Ð´Ð»Ñ Ñ‡Ð¸Ñ‚Ð°Ð½Ð½Ñ\"."
+msgstr ""
+
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr "Ви також можете Ñтворити проект із командного Ñ€Ñдка."
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
-msgstr "Ви знаходитеÑÑ Ð½Ð° інÑтанÑÑ– Gitlab \"тільки Ð´Ð»Ñ Ñ‡Ð¸Ñ‚Ð°Ð½Ð½Ñ\". Якщо ви хочете внеÑти зміни, вам необхідно перейти на %{link_to_primary_node}."
+msgid "You can also star a label to make it a priority label."
+msgstr "Ви можете додати мітку в обрані, щоб зробити її пріоритетною."
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr "Ви можете легко вÑтановити Runner на клаÑтері Kubernetes. %{link_to_help_page}"
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr "Ви можете переÑуватиÑÑ Ð¿Ð¾ графіку за допомогою клавіш зі Ñтрілками."
msgid "You can only add files when you are on a branch"
-msgstr "Ви можете додавати тільки файли, коли перебуваєте в гілці"
+msgstr "Ви можете додавати файли тільки коли перебуваєте в гілці"
+
+msgid "You can only edit files when you are on a branch"
+msgstr "Ви можете редагувати файли, лише перебуваючи у ÑкійÑÑŒ гілці"
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr "Ви не можете запиÑувати на вторинні інÑтанÑи \"тільки Ð´Ð»Ñ Ñ‡Ð¸Ñ‚Ð°Ð½Ð½Ñ\" GitLab Geo. Будь лаÑка викориÑтовуйте %{link_to_primary_node}."
@@ -2446,12 +4868,24 @@ msgstr "Ви не можете запиÑувати на вторинні інÑ
msgid "You cannot write to this read-only GitLab instance."
msgstr "Ви не можете запиÑувати на цей \"тільки Ð´Ð»Ñ Ñ‡Ð¸Ñ‚Ð°Ð½Ð½Ñ\" інÑÑ‚Ð°Ð½Ñ GitLab."
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr "У Ð²Ð°Ñ Ð½ÐµÐ¼Ð°Ñ” необхідних прав доÑтупу, щоб перевизначити Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ñинхронізації LDAP-груп."
+
+msgid "You have no permissions"
+msgstr "У Ð²Ð°Ñ Ð½ÐµÐ¼Ð°Ñ” прав доÑтупу"
+
msgid "You have reached your project limit"
msgstr "Ви доÑÑгли Ð¾Ð±Ð¼ÐµÐ¶ÐµÐ½Ð½Ñ Ð² вашому проекті"
+msgid "You must have master access to force delete a lock"
+msgstr "У Ð²Ð°Ñ Ð¿Ð¾Ð²Ð¸Ð½ÐµÐ½ бути доÑтуп на рівні керівника, Ð´Ð»Ñ Ð¿Ñ€Ð¸Ð¼ÑƒÑового Ð²Ð¸Ð´Ð°Ð»ÐµÐ½Ð½Ñ Ð±Ð»Ð¾ÐºÑƒÐ²Ð°Ð½Ð½Ñ"
+
msgid "You must sign in to star a project"
msgstr "Ðеобхідно увійти, щоб оцінити проект"
+msgid "You need a different license to enable FileLocks feature"
+msgstr "Ð”Ð»Ñ Ð°ÐºÑ‚Ð¸Ð²Ð°Ñ†Ñ–Ñ— функції Ð‘Ð»Ð¾ÐºÑƒÐ²Ð°Ð½Ð½Ñ Ð¤Ð°Ð¹Ð»Ñ–Ð² вам потрібна інша ліцензіÑ"
+
msgid "You need permission."
msgstr "Вам потрібен дозвіл"
@@ -2471,10 +4905,40 @@ msgid "You will receive notifications only for comments in which you were @menti
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} в ваш аккаунт"
+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} в ваш профіль."
+msgstr "Ви не зможете відправлÑти та отримувати код проекту через SSH поки не %{add_ssh_key_link} до вашого профілю"
+
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr "Ви не зможете відправлÑти та отримувати код проекту через SSH, поки не додаÑте в Ñвій профіль SSH ключ"
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr "Вам необхідно викориÑтовувати різні імена гілок Ð´Ð»Ñ ÐºÐ¾Ñ€ÐµÐºÑ‚Ð½Ð¾Ð³Ð¾ порівнÑннÑ."
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr "Ð†Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ñ–Ñ Ð¿Ñ€Ð¾ ваш Kubernetes-клаÑтер вÑе ще доÑтупна Ð´Ð»Ñ Ñ€ÐµÐ´Ð°Ð³ÑƒÐ²Ð°Ð½Ð½Ñ Ð½Ð° цій Ñторінці, але ми радимо вимкнути Ñ– повторно налаштувати"
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr "Ваші зміни можуть бути закомічені до %{branch_name}, оÑкільки запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð²Ñ–Ð´ÐºÑ€Ð¸Ñ‚Ð¸Ð¹."
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr "Ваші зміни закомічено. Коміт %{commitId} %{commitStats}"
msgid "Your comment will not be visible to the public."
msgstr "Ваш коментар не буде видимим Ð´Ð»Ñ Ð²ÑÑ–Ñ…."
@@ -2488,14 +4952,352 @@ msgstr "Ваше ім'Ñ"
msgid "Your projects"
msgstr "Ваші проекти"
-msgid "commit"
-msgstr "коміт"
+msgid "among other things"
+msgstr "між іншим"
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "assign yourself"
+msgstr "призначити Ñебе"
+
+msgid "branch name"
+msgstr "ім'Ñ Ð³Ñ–Ð»ÐºÐ¸"
+
+msgid "by"
+msgstr "від"
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr "ЯкіÑÑ‚ÑŒ коду"
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr "DAST не виÑвив попереджень при аналізі цього review app"
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr "Помилка при завантаженні звіту %{reportName}"
+
+msgid "ciReport|Fixed:"
+msgstr "Виправлено:"
+
+msgid "ciReport|Instances"
+msgstr "ІнÑтанÑи"
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr "Ð—Ð°Ð²Ð°Ð½Ñ‚Ð°Ð¶ÐµÐ½Ð½Ñ Ð·Ð²Ñ–Ñ‚Ñƒ %{reportName}"
+
+msgid "ciReport|No changes to code quality"
+msgstr "Ðемає змін у ÑкоÑÑ‚Ñ– коду"
+
+msgid "ciReport|No changes to performance metrics"
+msgstr "Ðемає змін у показниках продуктивноÑÑ‚Ñ–"
+
+msgid "ciReport|Performance metrics"
+msgstr "Показники продуктивноÑÑ‚Ñ–"
+
+msgid "ciReport|SAST"
+msgstr "SAST"
+
+msgid "ciReport|SAST detected"
+msgstr "SAST виÑвив"
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr "SAST не виÑвив нових вразливоÑтей"
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr "SAST не виÑвив жодних вразливоÑтей"
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr "Показати повний звіт про вразливоÑÑ‚Ñ– в коді"
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr "Ðезатверджені вразливоÑÑ‚Ñ– (червоні) можуть бути відмічені Ñк затверджені. %{helpLink}"
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr "інÑтрукції Ð´Ð»Ñ ÐºÐ¾Ð¼Ð°Ð½Ð´Ð½Ð¾Ð³Ð¾ Ñ€Ñдка"
+
+msgid "connecting"
+msgstr "з'єднаннÑ"
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
msgid "day"
msgid_plural "days"
msgstr[0] "день"
msgstr[1] "дні"
msgstr[2] "днів"
+msgstr[3] "днів"
+
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+msgstr[1] ""
+msgstr[2] ""
+msgstr[3] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr "%{slash_command} перезезапиÑує запланований Ñ‡Ð°Ñ Ð¾Ñтаннім значеннÑм."
+
+msgid "here"
+msgstr "тут"
+
+msgid "importing"
+msgstr "імпорт"
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr "неправильний через наÑвніÑÑ‚ÑŒ блокувань на нижчих рівнÑÑ…"
+
+msgid "is invalid because there is upstream lock"
+msgstr "неправильний через наÑвніÑÑ‚ÑŒ блокувань на вищих рівнÑÑ…"
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr "заблоковано %{path_lock_user_name} %{created_at}"
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] "запит на злиттÑ"
+msgstr[1] "запити на злиттÑ"
+msgstr[2] "запитів на злиттÑ"
+msgstr[3] "запитів на злиттÑ"
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr "Будь лаÑка відновіть Ñ—Ñ— або викориÑтовуйте іншу %{missingBranchName} гілку"
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr "Додати затвердженнÑ"
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr "Дозволити Ñ€ÐµÐ´Ð°Ð³ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÐ¾Ð¼Ð°Ð½Ð´Ð¾ÑŽ проекту"
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr "Під Ñ‡Ð°Ñ Ð²Ð¸Ð´Ð°Ð»ÐµÐ½Ð½Ñ Ð²Ð°ÑˆÐ¾Ð³Ð¾ Ð·Ð°Ñ‚Ð²ÐµÑ€Ð´Ð¶ÐµÐ½Ð½Ñ ÑталаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ°."
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr "Помилка при отриманні даних про Ð·Ð°Ñ‚Ð²ÐµÑ€Ð´Ð¶ÐµÐ½Ð½Ñ Ð´Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ запиту на злиттÑ."
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr "Помилка при обробці вашого затвердженнÑ."
+
+msgid "mrWidget|Approve"
+msgstr "Затвердити"
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr "СкаÑувати автоматичне злиттÑ"
+
+msgid "mrWidget|Check out branch"
+msgstr "Перейти в гілку"
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr "перевірка можливоÑÑ‚Ñ– автоматичного злиттÑ"
+
+msgid "mrWidget|Cherry-pick"
+msgstr "вибір (коміта)"
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr "Вибрати (cherry-pick) цей запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð² новий запит на злиттÑ"
+
+msgid "mrWidget|Closed"
+msgstr "Закриті"
+
+msgid "mrWidget|Closed by"
+msgstr "Закритий"
+
+msgid "mrWidget|Closes"
+msgstr "Закриває"
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr "Ðе закрив"
+
+msgid "mrWidget|Email patches"
+msgstr "Email-патчі"
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr "Якщо гілка %{branch} Ñ–Ñнує у вашому локальному репозиторії, то ви можете заÑтоÑувати цей запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð²Ñ€ÑƒÑ‡Ð½Ñƒ за допомогою"
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr "Якщо гілка %{missingBranchName} Ñ–Ñнує у вашому локальному репозиторії, то ви можете заÑтоÑувати цей запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð²Ñ€ÑƒÑ‡Ð½Ñƒ за допомогою командного Ñ€Ñдка"
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr "Згадки"
+
+msgid "mrWidget|Merge"
+msgstr "ЗлиттÑ"
+
+msgid "mrWidget|Merge failed."
+msgstr "Ð—Ð»Ð¸Ñ‚Ñ‚Ñ Ð¿Ñ€Ð¾Ð¹ÑˆÐ»Ð¾ невдало."
+
+msgid "mrWidget|Merge locally"
+msgstr "Злити локально"
+
+msgid "mrWidget|Merged by"
+msgstr "Злито"
+
+msgid "mrWidget|Plain diff"
+msgstr "ПроÑте порівнÑÐ½Ð½Ñ (diff)"
+
+msgid "mrWidget|Refresh"
+msgstr "Оновити"
+
+msgid "mrWidget|Refresh now"
+msgstr "Оновити зараз"
+
+msgid "mrWidget|Refreshing now"
+msgstr "ВідбуваєтьÑÑ Ð¾Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ"
+
+msgid "mrWidget|Remove Source Branch"
+msgstr "Видалити гілку-джерело"
+
+msgid "mrWidget|Remove source branch"
+msgstr "Видалити гілку-джерело"
+
+msgid "mrWidget|Remove your approval"
+msgstr "Видалити ваше затвердженнÑ"
+
+msgid "mrWidget|Request to merge"
+msgstr "Запит на злиттÑ"
+
+msgid "mrWidget|Resolve conflicts"
+msgstr "Вирішити конфлікти"
+
+msgid "mrWidget|Revert"
+msgstr "Ðнулювати"
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr "Ðнулювати цей запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð·Ð° допомогою нового запиту на злиттÑ"
+
+msgid "mrWidget|Set by"
+msgstr "Ð’Ñтановлено"
+
+msgid "mrWidget|The changes were merged into"
+msgstr "Зміни були злиті в"
+
+msgid "mrWidget|The changes were not merged into"
+msgstr "Зміни не були злиті в"
+
+msgid "mrWidget|The changes will be merged into"
+msgstr "Зміни будуть злиті в"
+
+msgid "mrWidget|The source branch has been removed"
+msgstr "Гілку-джерело видалено"
+
+msgid "mrWidget|The source branch is being removed"
+msgstr "Гілка-джерело в процеÑÑ– видаленнÑ"
+
+msgid "mrWidget|The source branch will be removed"
+msgstr "Гілку-джерело буде видалено"
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr "Гілку-джерело не буде видалено"
+
+msgid "mrWidget|There are merge conflicts"
+msgstr "Ñ–Ñнують конфлікти при злитті"
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr "ВідбулаÑÑ Ð¿Ð¾Ð¼Ð¸Ð»ÐºÐ° при автоматичному злитті цього запиту"
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr "Запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð² процеÑÑ– виконаннÑ"
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr "Цей проект заархівований, доÑтуп до запиÑу було відключено"
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr "Ви можете прийнÑти цей запит на Ð·Ð»Ð¸Ñ‚Ñ‚Ñ Ð²Ñ€ÑƒÑ‡Ð½Ñƒ за допомогою"
+
+msgid "mrWidget|You can remove source branch now"
+msgstr "Тепер ви можете видалити гілку-джерело"
+
+msgid "mrWidget|branch does not exist."
+msgstr "гілка не Ñ–Ñнує."
+
+msgid "mrWidget|command line"
+msgstr "командного Ñ€Ñдка"
+
+msgid "mrWidget|into"
+msgstr "в"
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr "буде злито автоматично, коли конвеєр завершитьÑÑ ÑƒÑпішно"
msgid "new merge request"
msgstr "Ðовий запит на злиттÑ"
@@ -2503,11 +5305,15 @@ msgstr "Ðовий запит на злиттÑ"
msgid "notification emails"
msgstr "ÐŸÐ¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ ÐµÐ»ÐµÐºÑ‚Ñ€Ð¾Ð½Ð½Ð¾ÑŽ поштою"
+msgid "or"
+msgstr "або"
+
msgid "parent"
msgid_plural "parents"
-msgstr[0] "джерело"
-msgstr[1] "джерела"
-msgstr[2] "джерел"
+msgstr[0] "батьківÑький об’єкт"
+msgstr[1] "батьківÑькі об’єкти"
+msgstr[2] "батьківÑький об’єктів"
+msgstr[3] "батьківÑький об’єктів"
msgid "password"
msgstr "пароль"
@@ -2515,9 +5321,30 @@ msgstr "пароль"
msgid "personal access token"
msgstr "оÑобиÑтий токен доÑтупу"
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr "видалити заплановану дату завершеннÑ"
+
+msgid "source"
+msgstr "джерело"
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr "%{slash_command} оновлює Ñуму витраченого чаÑу."
+
+msgid "this document"
+msgstr "цей документ"
+
msgid "to help your contributors communicate effectively!"
-msgstr "щоб допомогти вашим контриб’юторам ефективно ÑпілкуватиÑÑ!"
+msgstr "щоб допомогти учаÑникам ефективно ÑпілкуватиÑÑ!"
msgid "username"
msgstr "ім'Ñ ÐºÐ¾Ñ€Ð¸Ñтувача"
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr "викориÑтовує клаÑтери Kubernetes Ð´Ð»Ñ Ñ€Ð¾Ð·Ð³Ð¾Ñ€Ñ‚Ð°Ð½Ð½Ñ ÐºÐ¾Ð´Ñƒ!"
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr "з %{additions} додаваннÑми Ñ– %{deletions} видаленнÑми."
+
diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po
index e1bc9219908..c25e892e568 100644
--- a/locale/zh_CN/gitlab.po
+++ b/locale/zh_CN/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-23 02:44-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:36-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -16,41 +16,76 @@ msgstr ""
"X-Crowdin-Language: zh-CN\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr " 和"
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d 次æ交"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] "è½åŽ %d 个æ交"
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] "%d 导出器"
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] "%d 个议题"
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] "%d 层"
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] "%d 个åˆå¹¶è¯·æ±‚"
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr "%{actionText} & %{openOrClose} %{noteable}"
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr "ç”± %{commit_author_link} æ交于 %{commit_timeago}"
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] "%{count} ä½å‚与者"
+msgid "%{loadingIcon} Started"
+msgstr "%{loadingIcon} 已开始"
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr "%{lock_path} 被GitLab用户 %{lock_user_id} é”定"
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr "%{number_commits_behind} 个è½åŽ %{default_branch} 分支的æ交, %{number_commits_ahead} 早超å‰çš„æ交"
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "已失败 %{number_of_failures} 次/最多å…许失败失败 %{maximum_failures} 次,GitLab 将继续é‡è¯•ã€‚"
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "已失败 %{number_of_failures} 次/最多å…许失败 %{maximum_failures} 次,GitLab 将在 %{number_of_seconds} 秒åŽé‡è¯•ã€‚"
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "已失败 %{number_of_failures} 次/最多å…许失败 %{maximum_failures} 次,GitLab ä¸ä¼šç»§ç»­è‡ªåŠ¨é‡è¯•ã€‚请在问题解决åŽé‡ç½®å­˜å‚¨å¥åº·ä¿¡æ¯ã€‚"
+msgid "%{openOrClose} %{noteable}"
+msgstr "%{openOrClose} %{noteable}"
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name}:已 %{failed_attempts} 次å°è¯•è®¿é—®å­˜å‚¨å¤±è´¥ï¼š"
+msgid "%{text} is available"
+msgstr "%{text}å¯ç”¨"
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(如需了解更多的安装信æ¯ï¼Œè¯·æŸ¥çœ‹ %{link})"
@@ -70,15 +105,30 @@ msgstr "最高贡献"
msgid "2FA enabled"
msgstr "å¯ç”¨ä¸¤æ­¥éªŒè¯"
+msgid "<strong>Removes</strong> source branch"
+msgstr "<strong>删除</strong>æºåˆ†æ”¯"
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "æŒç»­é›†æˆæ•°æ®å›¾"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr "将在派生(fork)项目中中创建一个新的分支, 并开å¯ä¸€ä¸ªæ–°çš„åˆå¹¶è¯·æ±‚。"
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr "项目å¯ä»¥ç”¨äºŽå­˜æ”¾æ–‡ä»¶(仓库),安排计划(议题),并å‘布文档(wiki), %{among_other_things_link}。"
+
+msgid "A user with write access to the source branch selected this option"
+msgstr "具有对æºåˆ†æ”¯çš„写入æƒé™çš„用户选择了此选项"
+
msgid "About auto deploy"
msgstr "关于自动部署"
msgid "Abuse Reports"
msgstr "滥用报告"
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr "访问令牌"
@@ -88,6 +138,9 @@ msgstr "为方便修å¤æŒ‚载问题,访问故障存储已被暂时ç¦ç”¨ã€‚在
msgid "Account"
msgstr "å¸å·"
+msgid "Account and limit settings"
+msgstr "å¸æˆ·å’Œé™åˆ¶è®¾ç½®"
+
msgid "Active"
msgstr "å¯ç”¨"
@@ -104,76 +157,286 @@ msgid "Add Contribution guide"
msgstr "添加贡献指å—"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
-msgstr "添加æ¥è‡ª Webhooks 或者 GitLab ä¼ä¸šç‰ˆçš„团队。"
+msgstr "添加组Webhookså’ŒGitLabä¼ä¸šç‰ˆã€‚"
+
+msgid "Add Kubernetes cluster"
+msgstr "添加 Kubernetes 集群"
msgid "Add License"
msgstr "添加许å¯è¯"
-msgid "Add an SSH key to your profile to pull or push via SSH."
-msgstr "新建一个用于推é€æˆ–拉å–çš„ SSH 秘钥到账å·ä¸­ã€‚"
+msgid "Add Readme"
+msgstr "添加自述文件"
msgid "Add new directory"
msgstr "添加目录"
+msgid "Add todo"
+msgstr "添加待办事项"
+
+msgid "AdminArea|Stop all jobs"
+msgstr "åœæ­¢æ‰€æœ‰ä½œä¸š"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "åœæ­¢æ‰€æœ‰ä½œä¸šå—?"
+
+msgid "AdminArea|Stop jobs"
+msgstr "åœæ­¢ä½œä¸š"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr "åœæ­¢ä½œä¸šå¤±è´¥"
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr "您å³å°†åœæ­¢æ‰€æœ‰ä½œä¸šã€‚所有正在è¿è¡Œçš„都会被åœæ­¢ã€‚"
+
msgid "AdminHealthPageLink|health page"
msgstr "å¥åº·é¡µé¢"
+msgid "AdminProjects|Delete"
+msgstr "删除"
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr "删除项目 %{projectName}?"
+
+msgid "AdminProjects|Delete project"
+msgstr "删除项目"
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr "为æ¯ä¸ªé¡¹ç›®çš„自动审阅应用 (Auto Review Apps) 和自动部署 (Auto Deploy) 阶段指定一个默认使用的域。"
+
+msgid "AdminUsers|Block user"
+msgstr "ç¦ç”¨ç”¨æˆ·"
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr "删除用户 %{username} 以åŠç›¸å…³è´¡çŒ®å—?"
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr "删除用户 %{username}?"
+
+msgid "AdminUsers|Delete user"
+msgstr "删除用户"
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr "删除用户åŠç›¸å…³è´¡çŒ®"
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr "请输入 %{projectName} æ¥ç¡®è®¤"
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr "请输入 %{username} æ¥ç¡®è®¤"
+
+msgid "Advanced"
+msgstr "高级"
+
msgid "Advanced settings"
msgstr "高级设置"
msgid "All"
msgstr "全部"
+msgid "All changes are committed"
+msgstr "所有更改å‡å·²æ交"
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr "从模æ¿æˆ–导入时为空白项目将å¯ç”¨æ‰€æœ‰åŠŸèƒ½ï¼Œä½†å¯ä»¥åœ¨é¡¹ç›®è®¾ç½®ä¸­å°†å…¶ç¦ç”¨ã€‚"
+
+msgid "Allow edits from maintainers."
+msgstr "å…许上游项目维护人员进行编辑。"
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr "这里å¯ä»¥æ·»åŠ å’Œç®¡ç† Kubernetes 集群。"
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr "此外,也å¯ä»¥ä½¿ç”¨ %{personal_access_token_link}。创建Personal Access Token时,在范围中需选择 <code>repo</code> ,以便显示å¯ä¾›è¿žæŽ¥çš„公开和ç§æœ‰çš„仓库列表。"
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr "此外,也å¯ä»¥ä½¿ç”¨ %{personal_access_token_link}。创建Personal Access Token时,在范围中需选择 <code>repo</code> ,以便显示å¯ä¾›å¯¼å…¥å…¬å¼€å’Œç§æœ‰çš„仓库列表"
+
+msgid "An error occurred previewing the blob"
+msgstr "预览 blob 时出错"
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "切æ¢é€šçŸ¥è®¢é˜…æ—¶å‘生错误"
+
+msgid "An error occurred when updating the issue weight"
+msgstr "更新议题æƒé‡æ—¶å‘生错误"
+
+msgid "An error occurred while adding approver"
+msgstr "添加批准人时å‘生错误"
+
+msgid "An error occurred while detecting host keys"
+msgstr "检测主机密钥时å‘生错误"
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr "关闭功能çªå‡ºæ˜¾ç¤ºæ—¶å‘生错误。请刷新页é¢å¹¶å†æ¬¡å°è¯•ã€‚"
+
+msgid "An error occurred while fetching markdown preview"
+msgstr "èŽ·å– markdown 预览时出错"
+
+msgid "An error occurred while fetching sidebar data"
+msgstr "获å–侧边æ æ•°æ®æ—¶å‘生错误"
+
+msgid "An error occurred while fetching the pipeline."
+msgstr "获å–æµæ°´çº¿æ—¶å‡ºé”™"
+
+msgid "An error occurred while getting projects"
+msgstr "获å–项目时å‘生错误"
+
+msgid "An error occurred while importing project"
+msgstr "在导入项目时å‘生错误。"
+
+msgid "An error occurred while initializing path locks"
+msgstr "åˆå§‹åŒ–路径é”æ—¶å‘生错误"
+
+msgid "An error occurred while loading commits"
+msgstr "加载æ交时å‘生错误"
+
+msgid "An error occurred while loading diff"
+msgstr "加载差异时å‘生错误"
+
+msgid "An error occurred while loading filenames"
+msgstr "加载文件åæ—¶å‘生错误"
+
+msgid "An error occurred while loading the file"
+msgstr "加载文件时å‘生错误"
+
+msgid "An error occurred while making the request."
+msgstr "å‘é€è¯·æ±‚æ—¶å‘生错误。"
+
+msgid "An error occurred while removing approver"
+msgstr "删除批准者时å‘生错误"
+
+msgid "An error occurred while rendering KaTeX"
+msgstr "渲染KaTeXæ—¶å‘生错误"
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr "渲染广播消æ¯æ—¶å‘生错误"
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr "获å–日历活动时å‘生错误"
+
+msgid "An error occurred while retrieving diff"
+msgstr "获å–差异时å‘生错误"
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr "ä¿å­˜LDAP覆盖状æ€æ—¶å‘生错误。请å†è¯•ä¸€æ¬¡ã€‚"
+
+msgid "An error occurred while saving assignees"
+msgstr "ä¿å­˜è¢«æŒ‡æ´¾äººæ—¶å‡ºçŽ°é”™è¯¯ã€‚"
+
+msgid "An error occurred while validating username"
+msgstr "验è¯ç”¨æˆ·åæ—¶å‘生错误"
+
msgid "An error occurred. Please try again."
msgstr "å‘生了错误,请å†è¯•ä¸€æ¬¡ã€‚"
+msgid "Any Label"
+msgstr "任何标记"
+
msgid "Appearance"
msgstr "外观"
msgid "Applications"
msgstr "应用程åº"
+msgid "Apr"
+msgstr "å››"
+
+msgid "April"
+msgstr "四月"
+
msgid "Archived project! Repository is read-only"
-msgstr "项目已归档ï¼å­˜å‚¨åº“为åªè¯»çŠ¶æ€"
+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 leave this group?"
-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 you want to unlock %{path_lock_path}?"
+msgstr "确定è¦è§£é” %{path_lock_path} å—?"
+
msgid "Are you sure?"
msgstr "确定å—?"
msgid "Artifacts"
msgstr "产物"
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr "分é…自定义颜色,如FF0000"
+
+msgid "Assign labels"
+msgstr "指派标记"
+
+msgid "Assign milestone"
+msgstr "分é…里程碑"
+
+msgid "Assign to"
+msgstr "分é…到"
+
+msgid "Assigned Issues"
+msgstr "已分é…议题"
+
+msgid "Assigned Merge Requests"
+msgstr "已分é…åˆå¹¶è¯·æ±‚"
+
+msgid "Assigned to :name"
+msgstr "已分é…至 :name"
+
+msgid "Assignee"
+msgstr "指派人"
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "拖放文件到此处或者 %{upload_link}"
+msgid "Aug"
+msgstr "å…«"
+
+msgid "August"
+msgstr "八月"
+
msgid "Authentication Log"
msgstr "认è¯æ—¥å¿—"
msgid "Author"
msgstr "作者"
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
-msgstr "自动审查程åºå’Œè‡ªåŠ¨éƒ¨ç½²ç¨‹åºéœ€è¦ä¸€ä¸ªåŸŸåå’Œ %{kubernetes} æ‰èƒ½æ­£å¸¸å·¥ä½œã€‚"
+msgid "Authors: %{authors}"
+msgstr "作者:%{authors}"
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
-msgstr "自动审查程åºå’Œè‡ªåŠ¨éƒ¨ç½²ç¨‹åºéœ€è¦ä¸€ä¸ªåŸŸåæ‰èƒ½æ­£å¸¸å·¥ä½œã€‚"
+msgid "Auto DevOps enabled"
+msgstr "å¯ç”¨Auto DevOps"
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
-msgstr "自动审查程åºå’Œè‡ªåŠ¨éƒ¨ç½²ç¨‹åºéœ€è¦ %{kubernetes} æ‰èƒ½æ­£å¸¸å·¥ä½œã€‚"
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr "自动审阅程åºå’Œè‡ªåŠ¨éƒ¨ç½²ç¨‹åºéœ€è¦ %{kubernetes} æ‰èƒ½æ­£å¸¸å·¥ä½œã€‚"
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr "自动审阅程åºå’Œè‡ªåŠ¨éƒ¨ç½²ç¨‹åºéœ€è¦ä¸€ä¸ªåŸŸåå’Œ %{kubernetes} æ‰èƒ½æ­£å¸¸å·¥ä½œã€‚"
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgstr "自动审阅程åºå’Œè‡ªåŠ¨éƒ¨ç½²ç¨‹åºéœ€è¦ä¸€ä¸ªåŸŸåæ‰èƒ½æ­£å¸¸å·¥ä½œã€‚"
msgid "AutoDevOps|Auto DevOps (Beta)"
msgstr "DevOps 自动化(测试版)"
@@ -190,53 +453,77 @@ msgstr "将根æ®é¢„定义的 CI/CD é…置自动构建ã€æµ‹è¯•å’Œéƒ¨ç½²åº”用ç¨
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr "想了解更多请访问 %{link_to_documentation}"
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
-msgstr "您å¯ä»¥ä¸ºæ­¤é¡¹ç›®æ¿€æ´» %{link_to_settings}。"
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr "如果当å‰é¡¹ç›®%{link_to_auto_devops_settings}, å¯ä»¥è‡ªåŠ¨çš„构建和测试应用。如果已%{link_to_add_kubernetes_cluster},则也å¯ä»¥å®žçŽ°è‡ªåŠ¨éƒ¨ç½²ã€‚"
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr "添加Kubernetes群集"
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr "å¯ç”¨Auto DevOps (Beta)"
+
+msgid "Available"
+msgstr "å¯ç”¨çš„"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr "å³å°†åˆ é™¤å¤´åƒã€‚确定继续å—?"
+
+msgid "Average per day: %{average}"
+msgstr "å¹³å‡æ¯å¤©: %{average}"
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr "从选定的æ交开始"
msgid "Billing"
msgstr "è´¦å•"
msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
-msgstr "%{group_name} 正在使用 %{plan_link} 方案。"
+msgstr "%{group_name}ç›®å‰ä½äºŽ%{plan_link}计划中。"
msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
-msgstr "在当å‰æ–¹æ¡ˆä¸­ä¸å¯ä½¿ç”¨è‡ªåŠ¨é™çº§æˆ–自动å‡çº§ã€‚"
+msgstr "自动é™çº§ã€å‡çº§åˆ°æŸäº›è®¡åˆ’ç›®å‰ä¸å¯ç”¨ã€‚"
msgid "BillingPlans|Current plan"
-msgstr "当å‰æ–¹æ¡ˆ"
+msgstr "当å‰è®¡åˆ’"
msgid "BillingPlans|Customer Support"
-msgstr "用户支æŒ"
+msgstr "客户支æŒ"
msgid "BillingPlans|Downgrade"
msgstr "é™çº§"
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
-msgstr "阅读 %{faq_link} 以了解更多信æ¯ã€‚"
+msgstr "请查阅%{faq_link} 进一步了解æ¯ä¸ªè®¡åˆ’的相关信æ¯ã€‚"
msgid "BillingPlans|Manage plan"
-msgstr "管ç†æ–¹æ¡ˆ"
+msgstr "管ç†è®¡åˆ’"
msgid "BillingPlans|Please contact %{customer_support_link} in that case."
-msgstr "请è”ç³» %{customer_support_link}"
+msgstr "在这ç§æƒ…况下请è”ç³»%{customer_support_link}。"
msgid "BillingPlans|See all %{plan_name} features"
-msgstr "查看所有 %{plan_name} 功能"
+msgstr "查看所有%{plan_name}功能"
msgid "BillingPlans|This group uses the plan associated with its parent group."
-msgstr "使用与其父项目一致的方案"
+msgstr "该群组使用其父群组相关è”的计划。"
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
-msgstr "访问 %{parent_billing_page_link} 以管ç†è¯¥é¡¹ç›®çš„计费方案。"
+msgstr "è¦ç®¡ç†æ­¤ç¾¤ç»„的计划,请访问%{parent_billing_page_link}çš„è´¦å•éƒ¨åˆ†ã€‚"
msgid "BillingPlans|Upgrade"
msgstr "å‡çº§"
msgid "BillingPlans|You are currently on the %{plan_link} plan."
-msgstr "您目å‰æ­£åœ¨ä½¿ç”¨ %{plan_link} 方案。"
+msgstr "您目å‰æ­£åœ¨ä½¿ç”¨%{plan_link}计划。"
msgid "BillingPlans|frequently asked questions"
-msgstr "常è§é—®é¢˜"
+msgstr "常问问题"
msgid "BillingPlans|monthly"
msgstr "æ¯æœˆ"
@@ -245,11 +532,11 @@ msgid "BillingPlans|paid annually at %{price_per_year}"
msgstr "æ¯å¹´æ”¯ä»˜ %{price_per_year}"
msgid "BillingPlans|per user"
-msgstr "æ¯ä¸ªç”¨æˆ·"
+msgstr "æ¯ç”¨æˆ·"
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "分支"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+msgstr[0] "分支(%{branch_count})"
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}"
@@ -257,6 +544,12 @@ msgstr "已创建分支 <strong>%{branch_name}</strong> 。如需设置自动部
msgid "Branch has changed"
msgstr "分支已有新å˜æ›´"
+msgid "Branch is already taken"
+msgstr "分支已被采用"
+
+msgid "Branch name"
+msgstr "分支å称"
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "æœç´¢åˆ†æ”¯"
@@ -266,6 +559,15 @@ msgstr "切æ¢åˆ†æ”¯"
msgid "Branches"
msgstr "分支"
+msgid "Branches|Active"
+msgstr "活跃"
+
+msgid "Branches|Active branches"
+msgstr "活跃分支"
+
+msgid "Branches|All"
+msgstr "全部"
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr "ä¸èƒ½æ‰¾åˆ°è¿™ä¸ªåˆ†æ”¯çš„ HEAD æ交"
@@ -291,7 +593,7 @@ msgid "Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you s
msgstr "删除 â€%{branch_name}†åŽå°†æ— æ³•æ¢å¤ï¼Œæ‚¨ç¡®å®šï¼Ÿ"
msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
-msgstr "删除已åˆå¹¶çš„分支åŽå°†æ— æ³•æ¢å¤ï¼Œæ‚¨ç¡®å®šï¼Ÿ"
+msgstr "删除已åˆå¹¶çš„分支åŽå°†æ— æ³•æ¢å¤ï¼Œç¡®å®šç»§ç»­å—?"
msgid "Branches|Filter by branch name"
msgstr "按分支å称筛选"
@@ -311,14 +613,41 @@ msgstr "确认执行 %{delete_protected_branch} åŽå°†æ— æ³•æ’¤é”€æˆ–æ¢å¤ã€‚"
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr "åªæœ‰é¡¹ç›®ç®¡ç†è€…或所有者æ‰èƒ½åˆ é™¤å—ä¿æŠ¤çš„分支ï¼"
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
-msgstr "在 %{project_settings_link} 管ç†å—ä¿æŠ¤çš„分支"
+msgid "Branches|Overview"
+msgstr "概览"
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr "在 %{project_settings_link} 中管ç†å—ä¿æŠ¤çš„分支。"
+
+msgid "Branches|Show active branches"
+msgstr "查看活跃分支"
+
+msgid "Branches|Show all branches"
+msgstr "查看所有分支"
+
+msgid "Branches|Show more active branches"
+msgstr "查看更多活跃分支"
+
+msgid "Branches|Show more stale branches"
+msgstr "查看更多éžæ´»è·ƒåˆ†æ”¯"
+
+msgid "Branches|Show overview of the branches"
+msgstr "查看分支概览"
+
+msgid "Branches|Show stale branches"
+msgstr "查看éžæ´»è·ƒåˆ†æ”¯"
msgid "Branches|Sort by"
msgstr "排åº"
+msgid "Branches|Stale"
+msgstr "éžæ´»è·ƒ"
+
+msgid "Branches|Stale branches"
+msgstr "éžæ´»è·ƒåˆ†æ”¯"
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
-msgstr "分支无法自动æ交,因为与上游分支冲çªã€‚"
+msgstr "分支ä¸èƒ½è‡ªåŠ¨æ›´æ–°ï¼Œå› ä¸ºå®ƒä¸Žä¸Šæ¸¸åˆ†æ”¯ä¸ä¸€è‡´ã€‚"
msgid "Branches|The default branch cannot be deleted"
msgstr "无法删除默认分支"
@@ -330,25 +659,25 @@ msgid "Branches|To avoid data loss, consider merging this branch before deleting
msgstr "为é¿å…æ•°æ®ä¸¢å¤±ï¼Œè¯·åœ¨åˆ é™¤ä¹‹å‰åˆå¹¶æ­¤åˆ†æ”¯ã€‚"
msgid "Branches|To confirm, type %{branch_name_confirmation}:"
-msgstr "è¦ç¡®è®¤ï¼Ÿè¯·è¾“å…¥ %{branch_name_confirmation} :"
+msgstr "请输入 %{branch_name_confirmation} æ¥ç¡®è®¤ï¼š"
msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above."
-msgstr "è‹¥è¦æ”¾å¼ƒæœ¬åœ°æ›´æ”¹å¹¶ä½¿ç”¨ä¸Šæ¸¸ç‰ˆæœ¬è¦†ç›–本分支,请先删除并“立å³æ›´æ–°â€ã€‚"
+msgstr "è¦æ”¾å¼ƒæœ¬åœ°æ›´æ”¹å¹¶è¦†ç›–上游版本的分支,请在此处将其删除,然åŽé€‰æ‹©ä¸Šé¢çš„“立å³æ›´æ–°â€ã€‚"
msgid "Branches|You’re about to permanently delete the protected branch %{branch_name}."
-msgstr "å°†è¦æ°¸ä¹…删除å—ä¿æŠ¤ %{branch_name} 分支。"
+msgstr "å°†è¦æ°¸ä¹…删除å—ä¿æŠ¤çš„ %{branch_name} 分支。"
msgid "Branches|diverged from upstream"
-msgstr "与上游存在差异"
+msgstr "上游分支"
msgid "Branches|merged"
-msgstr "å·²åˆå¹¶çš„"
+msgstr "å·²åˆå¹¶"
msgid "Branches|project settings"
msgstr "项目设置"
msgid "Branches|protected"
-msgstr "å—ä¿æŠ¤çš„"
+msgstr "å—ä¿æŠ¤"
msgid "Browse Directory"
msgstr "æµè§ˆç›®å½•"
@@ -362,14 +691,23 @@ msgstr "æµè§ˆæ–‡ä»¶"
msgid "Browse files"
msgstr "æµè§ˆæ–‡ä»¶"
+msgid "Business"
+msgstr "业务"
+
msgid "ByAuthor|by"
msgstr "作者:"
msgid "CI / CD"
msgstr "CI / CD"
-msgid "CI configuration"
-msgstr "CI é…ç½®"
+msgid "CI/CD"
+msgstr "CI/CD"
+
+msgid "CI/CD configuration"
+msgstr "CI/CD é…ç½®"
+
+msgid "CI/CD for external repo"
+msgstr "外部仓库的 CI/CD"
msgid "CICD|Jobs"
msgstr "作业"
@@ -377,11 +715,20 @@ msgstr "作业"
msgid "Cancel"
msgstr "å–消"
-msgid "Cancel edit"
-msgstr "å–消编辑"
+msgid "Cannot be merged automatically"
+msgstr "无法自动åˆå¹¶"
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr "无法修改托管的 Kubernetes 群集"
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
-msgstr "å˜æ›´æƒé‡"
+msgstr "改å˜æƒé‡"
+
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "选择分支"
@@ -395,23 +742,56 @@ msgstr "优选"
msgid "ChangeTypeAction|Revert"
msgstr "还原"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr "这将创建一个新的æ交, æ¥è¿˜åŽŸçŽ°æœ‰çš„更改。"
+
msgid "Changelog"
msgstr "更新日志"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr "差异显示方å¼ä¾<b>æº</b>版本åˆå¹¶åˆ°<b>目标</b>版本的形å¼ã€‚"
+
msgid "Charts"
msgstr "统计图"
msgid "Chat"
msgstr "å³æ—¶é€šè®¯"
+msgid "Check interval"
+msgstr "检查间隔"
+
+msgid "Checking %{text} availability…"
+msgstr "正在检查%{text}çš„å¯ç”¨æ€§..."
+
+msgid "Checking branch availability..."
+msgstr "正在检查分支的å¯ç”¨æ€§..."
+
msgid "Cherry-pick this commit"
msgstr "优选此æ交"
msgid "Cherry-pick this merge request"
msgstr "优选此åˆå¹¶è¯·æ±‚"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
-msgstr "选择è¦å¤åˆ¶åˆ°æ­¤èŠ‚点的群组。留空则å¤åˆ¶æ‰€æœ‰ã€‚"
+msgid "Choose File ..."
+msgstr "选择文件 ……"
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr "选择分支/标签(例如%{master})或输入æ交(例如%{sha})以查看更改内容或创建åˆå¹¶è¯·æ±‚。"
+
+msgid "Choose file..."
+msgstr "选择文件..."
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr "选择è¦åŒæ­¥åˆ°æ­¤æ¬¡èŠ‚点的群组。"
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr "清选择è¦è¿žæŽ¥å¹¶è¿è¡Œ CI/CD æµæ°´çº¿çš„代ç ä»“库。"
+
+msgid "Choose which repositories you want to import."
+msgstr "选择è¦å¯¼å…¥çš„仓库"
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+msgstr "选择è¦åŒæ­¥åˆ°æ­¤æ¬¡èŠ‚点的切片。"
msgid "CiStatusLabel|canceled"
msgstr "å·²å–消"
@@ -467,56 +847,146 @@ msgstr "已跳过"
msgid "CiStatus|running"
msgstr "è¿è¡Œä¸­"
+msgid "CiVariables|Input variable key"
+msgstr "输入å˜é‡çš„å称"
+
+msgid "CiVariables|Input variable value"
+msgstr "输入å˜é‡çš„值"
+
+msgid "CiVariables|Remove variable row"
+msgstr "删除å˜é‡è¡Œ"
+
+msgid "CiVariable|* (All environments)"
+msgstr "* (所有环境)"
+
+msgid "CiVariable|All environments"
+msgstr "所有环境"
+
+msgid "CiVariable|Create wildcard"
+msgstr "创建通é…符"
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr "ä¿å­˜å˜é‡æ—¶å‘生错误"
+
+msgid "CiVariable|New environment"
+msgstr "新建环境"
+
+msgid "CiVariable|Protected"
+msgstr "å—ä¿æŠ¤"
+
+msgid "CiVariable|Search environments"
+msgstr "æœç´¢çŽ¯å¢ƒ"
+
+msgid "CiVariable|Toggle protected"
+msgstr "开关ä¿æŠ¤çŠ¶æ€"
+
+msgid "CiVariable|Validation failed"
+msgstr "验è¯å¤±è´¥"
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr "断路器 API"
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr "点击下é¢çš„按钮转到Kubernetes页é¢å¼€å§‹å®‰è£…过程"
+
+msgid "Click to expand text"
+msgstr "点击展开文本"
+
+msgid "Client authentication certificate"
+msgstr "客户端认è¯è¯ä¹¦"
+
+msgid "Client authentication key"
+msgstr "客户端认è¯å¯†é’¥"
+
+msgid "Client authentication key password"
+msgstr "客户端认è¯å¯†é’¥å¯†ç "
+
msgid "Clone repository"
-msgstr "克隆存储库"
+msgstr "克隆仓库"
msgid "Close"
msgstr "关闭"
-msgid "Cluster"
-msgstr "集群"
+msgid "Closed"
+msgstr "已关闭"
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr "%{appList} å·²æˆåŠŸå®‰è£…到Kubernetes群集上"
+
+msgid "ClusterIntegration|API URL"
+msgstr "API地å€"
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr "添加 Kubernetes 群集"
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr "添加现有的Kubernetes群集"
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
-msgstr "必须在此å¸æˆ·ä¸‹åˆ›å»º %{link_to_container_project}"
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr "Kubernetes集群集æˆçš„高级选项"
-msgid "ClusterIntegration|Cluster details"
-msgstr "集群详情"
+msgid "ClusterIntegration|Applications"
+msgstr "应用程åº"
+
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr "确定è¦åˆ é™¤æ­¤Kubernetes集群的集æˆå—?注æ„这并ä¸ä¼šåˆ é™¤å®žé™…çš„Kubernetes群集本身。"
+
+msgid "ClusterIntegration|CA Certificate"
+msgstr "CAè¯ä¹¦"
+
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr "è¯ä¹¦æŽˆæƒåŒ…(PEMæ ¼å¼)"
+
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr "选择如何设置Kubernetes集群集æˆ"
+
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr "请选择使用此Kubernetes群集的环境。"
+
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr "控制Kubernetes集群与GitLab集æˆæ–¹å¼"
+
+msgid "ClusterIntegration|Copy API URL"
+msgstr "å¤åˆ¶API地å€"
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr "å¤åˆ¶CAè¯ä¹¦"
-msgid "ClusterIntegration|Cluster integration"
-msgstr "集群集æˆ"
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr "å¤åˆ¶Ingress IP地å€åˆ°å‰ªè´´æ¿"
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
-msgstr "此项目已ç¦ç”¨é›†ç¾¤é›†æˆã€‚"
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr "å¤åˆ¶Kubernetes集群å称"
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
-msgstr "此项目已å¯ç”¨é›†ç¾¤é›†æˆã€‚"
+msgid "ClusterIntegration|Copy Token"
+msgstr "å¤åˆ¶ä»¤ç‰Œ"
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
-msgstr "此项目已å¯ç”¨é›†ç¾¤é›†æˆã€‚ç¦ç”¨æ­¤é›†æˆä¸ä¼šå½±å“您的集群,它åªä¼šæš‚时关闭 GitLab 的连接。"
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr "创建Kubernetes群集"
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
-msgstr "集群正在 Google Kubernetes Engine 上创建..."
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr "在Google Kubernetes引擎上创建Kubernetes集群"
-msgid "ClusterIntegration|Cluster name"
-msgstr "集群å称"
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr "通过GitLab在Google Kubernetes引擎上创建Kubernetes集群"
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
-msgstr "集群已在 Google Kubernetes Engine 上æˆåŠŸåˆ›å»º"
+msgid "ClusterIntegration|Create on GKE"
+msgstr "在GKE中创建"
-msgid "ClusterIntegration|Copy cluster name"
-msgstr "å¤åˆ¶é›†ç¾¤å称"
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr "输入现有的 Kubernetes 集群详细信æ¯"
-msgid "ClusterIntegration|Create cluster"
-msgstr "创建集群"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr "输入Kubernetes群集的详细信æ¯"
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
-msgstr "在 Google Kubernetes Engine 上创建新集群"
+msgid "ClusterIntegration|Environment scope"
+msgstr "环境范围"
-msgid "ClusterIntegration|Enable cluster integration"
-msgstr "å¯ç”¨é›†ç¾¤é›†æˆ"
+msgid "ClusterIntegration|GitLab Integration"
+msgstr "GitLab集æˆ"
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr "GitLab Runner"
msgid "ClusterIntegration|Google Cloud Platform project ID"
msgstr "Google 云平å°é¡¹ç›®ID"
@@ -527,44 +997,146 @@ msgstr "Google Kubernetes Engine"
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr "Google Kubernetes Engine 项目"
+msgid "ClusterIntegration|Helm Tiller"
+msgstr "Helm Tiller"
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr "为了显示集群的å¥åº·çŠ¶å†µï¼Œæ‚¨çš„集群需è¦é…ç½®Prometheus以收集所需的数æ®ã€‚"
+
+msgid "ClusterIntegration|Ingress"
+msgstr "å…¥å£"
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr "Ingress IP地å€"
+
+msgid "ClusterIntegration|Install"
+msgstr "安装"
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr "安装Prometheus"
+
+msgid "ClusterIntegration|Installed"
+msgstr "已安装"
+
+msgid "ClusterIntegration|Installing"
+msgstr "安装中"
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr "集æˆKubernetes集群自动化"
+
+msgid "ClusterIntegration|Integration status"
+msgstr "集æˆçŠ¶æ€"
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr "Kubernetes 群集"
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr "Kubernetes群集详细信æ¯"
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr "Kubernetes集群å¥åº·åº¦"
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr "Kubernetes集群集æˆ"
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr "此项目已ç¦ç”¨ Kubernetes 群集集æˆã€‚"
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr "此项目已å¯ç”¨ Kubernetes 群集集æˆã€‚"
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr "此项目已å¯ç”¨ Kubernetes 群集集æˆã€‚ç¦ç”¨æ­¤é›†æˆä¸ä¼šå½±å“ Kubernetes 群集本身, åªä¼šæš‚时关闭 GitLab 与其连接。"
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr "正在Google Kubernetes Engine上创建Kubernetes群集..."
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr "Kubernetes 群集å称"
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr "Kubernetes集群已在Google Kubernetes Engine上æˆåŠŸåˆ›å»ºã€‚刷新页é¢ä»¥æŸ¥çœ‹Kubernetes群集的详细信æ¯"
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr "通过Kubernetes 群集集æˆï¼Œå¯ä»¥æ–¹ä¾¿åœ°ä½¿ç”¨å®¡é˜…应用ã€éƒ¨ç½²åº”用程åºã€è¿è¡Œæµæ°´çº¿ç­‰ç­‰ã€‚%{link_to_help_page}"
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr "Kubernetes 群集å¯ç”¨äºŽéƒ¨ç½²åº”用程åºå’Œæ供此项目的审阅应用"
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
-msgstr "了解详细%{link_to_documentation}"
+msgstr "进一步了解%{link_to_documentation}"
+
+msgid "ClusterIntegration|Learn more about environments"
+msgstr "进一步了解有关环境的信æ¯"
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr "进一步了解安全相关é…ç½®"
msgid "ClusterIntegration|Machine type"
msgstr "机器类型"
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
-msgstr "ç¡®ä¿æ‚¨çš„å¸æˆ·ç¬¦åˆåˆ›å»ºé›†ç¾¤çš„%{link_to_requirements}"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr "请确ä¿æ‚¨çš„å¸æˆ· %{link_to_requirements} å¯ä»¥åˆ›å»º Kubernetes 群集"
+
+msgid "ClusterIntegration|Manage"
+msgstr "管ç†"
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr "通过访问 %{link_gke} ç®¡ç† Kubernetes 群集"
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
-msgstr "管ç†æ‚¨çš„ GitLab 项目集群集æˆ"
+msgid "ClusterIntegration|More information"
+msgstr "更多信æ¯"
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
-msgstr "访问%{link_gke}æ¥ç®¡ç†æ‚¨çš„集群"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr "在GitLabä¼ä¸šé«˜çº§å’Œæ——舰版中å¯ä»¥ä½¿ç”¨å¤šä¸ªKubernetes集群"
+
+msgid "ClusterIntegration|Note:"
+msgstr "注æ„:"
msgid "ClusterIntegration|Number of nodes"
msgstr "节点数é‡"
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr "请输入Kubernetes集群的访问信æ¯ã€‚如需帮助,å¯ä»¥é˜…读Kubernetes集群的 %{link_to_help_page}"
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr "请确ä¿æ‚¨çš„ Google å¸æˆ·ç¬¦åˆä»¥ä¸‹è¦æ±‚:"
+msgid "ClusterIntegration|Project ID"
+msgstr "项目 ID"
+
+msgid "ClusterIntegration|Project namespace"
+msgstr "项目命å空间"
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr "项目命å空间(å¯é€‰ï¼Œå”¯ä¸€)"
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
-msgstr "请阅读关于集群集æˆçš„%{link_to_help_page}。"
+msgid "ClusterIntegration|Prometheus"
+msgstr "Prometheus"
-msgid "ClusterIntegration|Remove cluster integration"
-msgstr "删除集群集æˆ"
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr "请阅读关于Kubernetes集群集æˆçš„%{link_to_help_page}。"
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr "删除Kubernetes集群集æˆ"
msgid "ClusterIntegration|Remove integration"
msgstr "删除集æˆ"
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
-msgstr "删除集群集æˆå°†åˆ é™¤æ‚¨æ·»åŠ åˆ°æ­¤é¡¹ç›®çš„集群é…置。它ä¸ä¼šåˆ é™¤æ‚¨çš„项目。"
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr "从当å‰é¡¹ç›®ä¸­åˆ é™¤æ­¤Kubernetes集群的é…置。该æ“作并ä¸ä¼šåˆ é™¤å®žé™…Kubernetes群集。"
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr "请求安装失败"
-msgid "ClusterIntegration|See and edit the details for your cluster"
-msgstr "查看并编辑集群的详细信æ¯"
+msgid "ClusterIntegration|Save changes"
+msgstr "ä¿å­˜æ›´æ”¹"
+
+msgid "ClusterIntegration|Security"
+msgstr "安全"
+
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr "查看并编辑Kubernetes集群的详细信æ¯"
msgid "ClusterIntegration|See machine types"
msgstr "å‚è§æœºå™¨ç±»åž‹"
@@ -575,20 +1147,41 @@ msgstr "看到您的项目"
msgid "ClusterIntegration|See zones"
msgstr "查看区域"
+msgid "ClusterIntegration|Service token"
+msgstr "æœåŠ¡ä»¤ç‰Œ"
+
+msgid "ClusterIntegration|Show"
+msgstr "显示"
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr "å‘生了内部错误"
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
-msgstr "在 Google Kubernetes Engine 上创建集群时å‘生错误"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr "在 Google Kubernetes Engine 上创建Kubernetes集群时å‘生错误"
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr "安装 %{title} æ—¶å‘生故障"
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr "默认集群é…ç½®æ供了æˆåŠŸæž„建和部署容器化应用所需的大é‡ç›¸å…³åŠŸèƒ½ã€‚"
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr "该å¸æˆ·éœ€å…·å¤‡åœ¨ä¸‹é¢æŒ‡å®šçš„%{link_to_container_project}中创建 Kubernetes集群的æƒé™"
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr "开关Kubernetes 群集"
-msgid "ClusterIntegration|Toggle Cluster"
-msgstr "切æ¢é›†ç¾¤"
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr "开关Kubernetes 群集"
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
-msgstr "使用与此项目关è”的集群,您å¯ä»¥ä½¿ç”¨å®¡é˜…应用程åºï¼Œéƒ¨ç½²åº”用程åºï¼Œè¿è¡Œæµæ°´çº¿ç­‰ç­‰ã€‚"
+msgid "ClusterIntegration|Token"
+msgstr "令牌"
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr "使用与此项目关è”çš„Kubernetes集群,å¯ä»¥æ–¹ä¾¿åœ°ä½¿ç”¨å®¡é˜…应用,部署应用程åºï¼Œè¿è¡Œæµæ°´çº¿ç­‰ç­‰ã€‚"
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
-msgstr "您的å¸æˆ·å¿…须有%{link_to_kubernetes_engine}"
+msgstr "您的å¸æˆ·å¿…须拥有%{link_to_kubernetes_engine}"
msgid "ClusterIntegration|Zone"
msgstr "区域"
@@ -596,18 +1189,33 @@ msgstr "区域"
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr "访问 Google Kubernetes Engine"
-msgid "ClusterIntegration|cluster"
-msgstr "集群"
+msgid "ClusterIntegration|check the pricing here"
+msgstr "查看定价"
+
+msgid "ClusterIntegration|documentation"
+msgstr "文档"
msgid "ClusterIntegration|help page"
msgstr "帮助页é¢"
+msgid "ClusterIntegration|installing applications"
+msgstr "安装应用程åº"
+
msgid "ClusterIntegration|meets the requirements"
msgstr "符åˆè¦æ±‚"
msgid "ClusterIntegration|properly configured"
msgstr "正确é…ç½®"
+msgid "Collapse"
+msgstr "收起"
+
+msgid "Comment and resolve discussion"
+msgstr "评论并解决讨论"
+
+msgid "Comment and unresolve discussion"
+msgstr "评论并将讨论å˜ä¸ºæœªå†³"
+
msgid "Comments"
msgstr "评论"
@@ -615,9 +1223,9 @@ msgid "Commit"
msgid_plural "Commits"
msgstr[0] "æ交"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
-msgstr[0] "æ交 %d 个文件"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] "æ交(%{commit_count})"
msgid "Commit Message"
msgstr "æ交消æ¯"
@@ -628,6 +1236,12 @@ msgstr "最近30次æ交相应æŒç»­é›†æˆèŠ±è´¹çš„时间(分钟)"
msgid "Commit message"
msgstr "æ交信æ¯"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr "æ交统计 %{ref} %{start_time} - %{end_time}"
+
+msgid "Commit to %{branchName} branch"
+msgstr "æ交到 %{branchName} 分支"
+
msgid "CommitBoxTitle|Commit"
msgstr "æ交"
@@ -640,15 +1254,99 @@ msgstr "æ交"
msgid "Commits feed"
msgstr "æ交动æ€"
+msgid "Commits per day hour (UTC)"
+msgstr "一天中æ¯å°æ—¶(UTC)æ交数"
+
+msgid "Commits per day of month"
+msgstr "一个月中æ¯å¤©çš„æ交数"
+
+msgid "Commits per weekday"
+msgstr "一周中æ¯æ—¥æ交数"
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr "获å–åˆå¹¶è¯·æ±‚æ•°æ®æ—¶å‡ºé”™"
+
+msgid "Commits|Commit: %{commitText}"
+msgstr "æ交内容:%{commitText}"
+
msgid "Commits|History"
msgstr "历å²"
+msgid "Commits|No related merge requests found"
+msgstr "无相关åˆå¹¶è¯·æ±‚"
+
msgid "Committed by"
msgstr "æ交者:"
msgid "Compare"
msgstr "比较"
+msgid "Compare Git revisions"
+msgstr "比较Gitæ交版本"
+
+msgid "Compare Revisions"
+msgstr "比较版本"
+
+msgid "Compare changes with the last commit"
+msgstr "与上个æ交比较å˜æ›´å†…容"
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr "%{source_branch} å’Œ %{target_branch} 是相åŒçš„"
+
+msgid "CompareBranches|Compare"
+msgstr "比较"
+
+msgid "CompareBranches|Source"
+msgstr "æºåˆ†æ”¯"
+
+msgid "CompareBranches|Target"
+msgstr "目标分支"
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr "无需比较。"
+
+msgid "Confidential"
+msgstr "机密"
+
+msgid "Confidentiality"
+msgstr "ç§å¯†æ€§"
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr "é…置用户创建新å¸æˆ·çš„æ–¹å¼ã€‚"
+
+msgid "Connect"
+msgstr "连接"
+
+msgid "Connect all repositories"
+msgstr "连接所有仓库"
+
+msgid "Connect repositories from GitHub"
+msgstr "从 Github 中导入代ç ä»“库"
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr "连接外部仓库åŽï¼Œæ–°æ交将会å¯åŠ¨CI/CDæµæ°´çº¿ã€‚ä»…å¯ç”¨CI/CD功能的Gitlab项目将会被创建。"
+
+msgid "Connecting..."
+msgstr "正在连接..."
+
msgid "Container Registry"
msgstr "容器注册"
@@ -665,7 +1363,7 @@ msgid "ContainerRegistry|How to use the Container Registry"
msgstr "如何使用容器注册表"
msgid "ContainerRegistry|Learn more about"
-msgstr "关于更多"
+msgstr "进一步了解"
msgid "ContainerRegistry|No tags in Container Registry for this container image."
msgstr "容器注册表中没有此容器镜åƒçš„标签。"
@@ -674,7 +1372,7 @@ msgid "ContainerRegistry|Once you log in, you&rsquo;re free to create and upload
msgstr "登录åŽæ‚¨å¯ä»¥ä½¿ç”¨é€šç”¨çš„%{build}å’Œ%{push}命令创建和上传容器镜åƒ"
msgid "ContainerRegistry|Remove repository"
-msgstr "删除存储库"
+msgstr "删除仓库"
msgid "ContainerRegistry|Remove tag"
msgstr "删除标签"
@@ -694,17 +1392,35 @@ msgstr "使用ä¸åŒçš„é•œåƒå称"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr "å°† Docker 容器注册表集æˆåˆ° GitLab 中,æ¯ä¸ªé¡¹ç›®éƒ½å¯ä»¥æœ‰å„自的空间æ¥å­˜å‚¨ Docker çš„é•œåƒã€‚"
+msgid "Continuous Integration and Deployment"
+msgstr "æŒç»­é›†æˆå’Œéƒ¨ç½²"
+
+msgid "Contribution"
+msgstr "贡献"
+
msgid "Contribution guide"
msgstr "贡献指å—"
msgid "Contributors"
msgstr "贡献者"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr "%{startDate} – %{endDate}"
+
+msgid "ContributorsPage|Building repository graph."
+msgstr "构建图表中。"
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr "%{branch_name} 分支上的æ交,ä¸å«åˆå¹¶æ交。é™6000次。"
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr "请ç¨å€™ï¼Œå›¾è¡¨æž„建完æˆåŽé¡µé¢ä¼šè‡ªåŠ¨åˆ·æ–°ã€‚"
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
-msgstr "控制此次è¦èŠ‚点的 LFS/attachment 的最大并å‘性"
+msgstr "控制此次è¦èŠ‚点的 LFS/attachment 的最大并å‘"
msgid "Control the maximum concurrency of repository backfill for this secondary node"
-msgstr "控制此次è¦èŠ‚点的存储库的最大并å‘"
+msgstr "控制此次è¦èŠ‚点的仓库最大并å‘"
msgid "Copy SSH public key to clipboard"
msgstr "å¤åˆ¶ SSH 公钥到剪贴æ¿"
@@ -712,27 +1428,60 @@ msgstr "å¤åˆ¶ SSH 公钥到剪贴æ¿"
msgid "Copy URL to clipboard"
msgstr "å¤åˆ¶ URL 到剪贴æ¿"
+msgid "Copy branch name to clipboard"
+msgstr "将分支å称å¤åˆ¶åˆ°å‰ªè´´æ¿"
+
+msgid "Copy command to clipboard"
+msgstr "将命令å¤åˆ¶åˆ°å‰ªè´´æ¿"
+
msgid "Copy commit SHA to clipboard"
msgstr "å¤åˆ¶æ交 SHA 的值到剪贴æ¿"
+msgid "Copy reference to clipboard"
+msgstr "将索引å¤åˆ¶åˆ°å‰ªè´´æ¿"
+
+msgid "Create"
+msgstr "创建"
+
msgid "Create New Directory"
msgstr "创建新目录"
+msgid "Create a new branch"
+msgstr "创建一个新分支"
+
+msgid "Create a new branch and merge request"
+msgstr "创建一个新的分支åŠåˆå¹¶è¯·æ±‚"
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "在å¸æˆ·ä¸Šåˆ›å»ºä¸ªäººè®¿é—®ä»¤ç‰Œï¼Œä»¥é€šè¿‡ %{protocol} æ¥æ‹‰å–或推é€ã€‚"
+msgid "Create branch"
+msgstr "创建分支"
+
msgid "Create directory"
msgstr "创建目录"
-msgid "Create empty bare repository"
-msgstr "创建空的存储库"
+msgid "Create empty repository"
+msgstr "创建空的仓库"
+
+msgid "Create epic"
+msgstr "创建å²è¯—故事"
msgid "Create file"
msgstr "创建文件"
+msgid "Create group label"
+msgstr "创建群组标记"
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr "从标记创建列表,å«æœ‰è¯¥æ ‡è®°çš„议题将出现在相应的列中。"
+
msgid "Create merge request"
msgstr "创建åˆå¹¶è¯·æ±‚"
+msgid "Create merge request and branch"
+msgstr "创建åˆå¹¶è¯·æ±‚åŠåˆ†æ”¯"
+
msgid "Create new branch"
msgstr "创建新分支"
@@ -742,9 +1491,15 @@ msgstr "创建新目录"
msgid "Create new file"
msgstr "创建新文件"
+msgid "Create new label"
+msgstr "创建新标记"
+
msgid "Create new..."
msgstr "创建..."
+msgid "Create project label"
+msgstr "创建项目标记"
+
msgid "CreateNewFork|Fork"
msgstr "派生"
@@ -754,24 +1509,36 @@ msgstr "标签"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "创建个人访问令牌"
+msgid "Creates a new branch from %{branchName}"
+msgstr "自%{branchName} 创建一个新分支"
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr "自%{branchName} 创建一个新分支,并转到创建一个新的åˆå¹¶è¯·æ±‚"
+
+msgid "Creating epic"
+msgstr "创建å²è¯—故事中"
+
msgid "Cron Timezone"
msgstr "Cron 时区"
msgid "Cron syntax"
msgstr "Cron 语法"
+msgid "Current node"
+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 "Customize colors"
+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 "ç¼–ç "
@@ -799,6 +1566,15 @@ msgstr "所有"
msgid "DashboardProjects|Personal"
msgstr "个人"
+msgid "Dec"
+msgstr "å二"
+
+msgid "December"
+msgstr "å二月"
+
+msgid "Default classification label"
+msgstr "默认分类标签"
+
msgid "Define a custom pattern with cron syntax"
msgstr "使用 Cron 语法定义自定义模å¼"
@@ -816,16 +1592,25 @@ msgid "Description"
msgstr "æè¿°"
msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
-msgstr "æ述模æ¿å…许您为项目的议题和åˆå¹¶è¯·æ±‚在创建时选择特定的模版。"
+msgstr "æ述模æ¿å…许您为项目的问题和åˆå¹¶è¯·æ±‚定义æ述字段的特定模æ¿ã€‚"
msgid "Details"
msgstr "详情"
+msgid "Diffs|No file name available"
+msgstr "没有å¯ç”¨çš„文件å"
+
msgid "Directory name"
msgstr "目录å称"
-msgid "Discard changes"
-msgstr "放弃更改"
+msgid "Disable"
+msgstr "ç¦ç”¨"
+
+msgid "Discard draft"
+msgstr "èˆå¼ƒè‰ç¨¿"
+
+msgid "Discover GitLab Geo."
+msgstr "å‘现GitLab Geo。"
msgid "Dismiss Cycle Analytics introduction box"
msgstr "关闭循环分æžä»‹ç»æ¡†"
@@ -833,9 +1618,15 @@ msgstr "关闭循环分æžä»‹ç»æ¡†"
msgid "Dismiss Merge Request promotion"
msgstr "关闭åˆå¹¶è¯·æ±‚中的促销广告"
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "ä¸å†æ˜¾ç¤º"
+msgid "Done"
+msgstr "完æˆ"
+
msgid "Download"
msgstr "下载"
@@ -863,15 +1654,171 @@ msgstr "差异文件"
msgid "DownloadSource|Download"
msgstr "下载"
+msgid "Downvotes"
+msgstr "踩"
+
+msgid "Due date"
+msgstr "截止日期"
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "编辑"
msgid "Edit Pipeline Schedule %{id}"
msgstr "编辑 %{id} æµæ°´çº¿è®¡åˆ’"
+msgid "Edit files in the editor and commit changes here"
+msgstr "在编辑器中编辑文件并在这里​​æ交å˜æ›´å†…容"
+
+msgid "Editing"
+msgstr "编辑"
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr "电å­é‚®ä»¶"
+msgid "Enable"
+msgstr "å¯ç”¨"
+
+msgid "Enable Auto DevOps"
+msgstr "å¯ç”¨Auto DevOps"
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr "å¯ç”¨å¹¶é…ç½®InfluxDB指标。"
+
+msgid "Enable and configure Prometheus metrics."
+msgstr "å¯ç”¨å¹¶é…ç½®Prometheus指标。"
+
+msgid "Enable classification control using an external service"
+msgstr "使用外部æœåŠ¡å¯ç”¨åˆ†ç±»æŽ§åˆ¶"
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr "获å–环境时å‘生错误。"
+
+msgid "Environments|An error occurred while making the request."
+msgstr "å‘é€è¯·æ±‚æ—¶å‘生错误。"
+
+msgid "Environments|Commit"
+msgstr "æ交"
+
+msgid "Environments|Deployment"
+msgstr "部署"
+
+msgid "Environments|Environment"
+msgstr "环境"
+
+msgid "Environments|Environments"
+msgstr "环境"
+
+msgid "Environments|Job"
+msgstr "作业"
+
+msgid "Environments|New environment"
+msgstr "新建环境"
+
+msgid "Environments|No deployments yet"
+msgstr "未部署"
+
+msgid "Environments|Open"
+msgstr "打开"
+
+msgid "Environments|Re-deploy"
+msgstr "é‡æ–°éƒ¨ç½²"
+
+msgid "Environments|Read more about environments"
+msgstr "了解有关环境的更多信æ¯"
+
+msgid "Environments|Rollback"
+msgstr "还原"
+
+msgid "Environments|Show all"
+msgstr "显示全部"
+
+msgid "Environments|Updated"
+msgstr "已更新"
+
+msgid "Environments|You don't have any environments right now."
+msgstr "当å‰æœªè®¾ç½®çŽ¯å¢ƒ"
+
+msgid "Epic will be removed! Are you sure?"
+msgstr "EPIC将被删除!是å¦ç¡®å®šï¼Ÿ"
+
+msgid "Epics"
+msgstr "EPIC"
+
+msgid "Epics Roadmap"
+msgstr "å²è¯—故事路线图"
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "利用å²è¯—故事(Epics),产å“线管ç†ä¼šå˜å¾—æ›´è½»æ¾ä¸”更高效"
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr "检查分支数æ®æ—¶å‡ºé”™ã€‚请å†è¯•ä¸€æ¬¡ã€‚"
+
+msgid "Error committing changes. Please try again."
+msgstr "æ交更改时出错。请å†è¯•ä¸€æ¬¡ã€‚"
+
+msgid "Error creating epic"
+msgstr "创建å²è¯—故事时出错"
+
+msgid "Error fetching contributors data."
+msgstr "获å–贡献者数æ®æ—¶å‡ºé”™ã€‚"
+
+msgid "Error fetching labels."
+msgstr "获å–标记时出错。"
+
+msgid "Error fetching network graph."
+msgstr "获å–网络图时出错。"
+
+msgid "Error fetching refs"
+msgstr "获å–refs时出错。"
+
+msgid "Error fetching usage ping data."
+msgstr "获å–使用情况(usage ping) æ•°æ®æ—¶å‡ºé”™ã€‚"
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr "切æ¢é€šçŸ¥è®¢é˜…æ—¶å‘生错误"
+
+msgid "Error saving label update."
+msgstr "ä¿å­˜æ ‡è®°æ›´æ–°æ—¶å‡ºé”™ã€‚"
+
+msgid "Error updating status for all todos."
+msgstr "更新所有待办事项的状æ€æ—¶å‡ºé”™ã€‚"
+
+msgid "Error updating todo status."
+msgstr "更新待办事项状æ€æ—¶å‡ºé”™ã€‚"
+
msgid "EventFilterBy|Filter by all"
msgstr "全部"
@@ -899,24 +1846,75 @@ msgstr "æ¯æœˆæ‰§è¡Œï¼ˆæ¯æœˆ 1 日凌晨 4 点)"
msgid "Every week (Sundays at 4:00am)"
msgstr "æ¯å‘¨æ‰§è¡Œï¼ˆå‘¨æ—¥å‡Œæ™¨ 4 点)"
+msgid "Expand"
+msgstr "展开"
+
msgid "Explore projects"
msgstr "查看项目"
msgid "Explore public groups"
msgstr "æœç´¢å…¬å…±ç¾¤ç»„"
+msgid "External Classification Policy Authorization"
+msgstr "外部分类政策授æƒ"
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr "外部授æƒæ‹’ç»è®¿é—®æ­¤é¡¹ç›®"
+
+msgid "External authorization request timeout"
+msgstr "外部授æƒè¯·æ±‚超时"
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr "分类标签"
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr "分类标签"
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr "未设置分类标签时,将使用默认的分类标签`%{default_label}`。"
+
+msgid "Failed"
+msgstr "已失败"
+
+msgid "Failed Jobs"
+msgstr "失败的作业"
+
msgid "Failed to change the owner"
msgstr "无法å˜æ›´æ‰€æœ‰è€…"
+msgid "Failed to remove issue from board, please try again."
+msgstr "无法从看æ¿ç§»é™¤é—®é¢˜ï¼Œè¯·é‡è¯•ã€‚"
+
msgid "Failed to remove the pipeline schedule"
msgstr "无法删除æµæ°´çº¿è®¡åˆ’"
+msgid "Failed to update issues, please try again."
+msgstr "更新议题失败, 请é‡è¯•"
+
+msgid "Feb"
+msgstr "二"
+
+msgid "February"
+msgstr "二月"
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr "当å‰é¡µé¢ä¸Šçš„字段ä¸å¯ç¼–辑,å¯ä»¥é…ç½®"
+
msgid "File name"
msgstr "文件å"
msgid "Files"
msgstr "文件"
+msgid "Files (%{human_size})"
+msgstr "文件(%{human_size})"
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "按æ交消æ¯è¿‡æ»¤"
@@ -926,12 +1924,21 @@ msgstr "按路径查找"
msgid "Find file"
msgstr "查找文件"
+msgid "Finished"
+msgstr "已完æˆ"
+
msgid "FirstPushedBy|First"
msgstr "首次推é€"
msgid "FirstPushedBy|pushed by"
msgstr "推é€è€…:"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "派生"
@@ -942,39 +1949,225 @@ msgstr "派生自"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr "派生自 %{project_name} (删除)"
+msgid "Forking in progress"
+msgstr "派生(Fork)中"
+
msgid "Format"
msgstr "æ ¼å¼"
+msgid "From %{provider_title}"
+msgstr "æ¥è‡ª %{provider_title}"
+
msgid "From issue creation until deploy to production"
msgstr "从创建议题到部署至生产环境"
msgid "From merge request merge until deploy to production"
msgstr "从åˆå¹¶è¯·æ±‚被åˆå¹¶åŽåˆ°éƒ¨ç½²è‡³ç”Ÿäº§çŽ¯å¢ƒ"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr "在Kubernetes群集详细信æ¯è§†å›¾ä¸­ï¼Œä»Žåº”用程åºåˆ—表中安装Runner"
+
msgid "GPG Keys"
msgstr "GPG 密钥"
+msgid "Generate a default set of labels"
+msgstr "生æˆä¸€ç»„默认的标记"
+
msgid "Geo Nodes"
msgstr "Geo 节点"
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr "节点出现故障或æŸå。"
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr "节点è¿è¡Œç¼“æ…¢ã€è¶…è½½, 或者在åœæœºåŽåˆšåˆšæ¢å¤ã€‚"
+
+msgid "GeoNodes|Checksummed"
+msgstr "已校验"
+
+msgid "GeoNodes|Database replication lag:"
+msgstr "æ•°æ®åº“åŒæ­¥æ»žåŽ"
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr "ç¦ç”¨èŠ‚点会中止åŒæ­¥è¿‡ç¨‹ã€‚确定继续å—?"
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr "与主存储é…ç½®ä¸ä¸€è‡´"
+
+msgid "GeoNodes|Failed"
+msgstr "失败"
+
+msgid "GeoNodes|Full"
+msgstr "全部"
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr "GitLab版本与主节点版本ä¸ä¸€è‡´"
+
+msgid "GeoNodes|GitLab version:"
+msgstr "GitLab版本:"
+
+msgid "GeoNodes|Health status:"
+msgstr "å¥åº·çŠ¶å†µï¼š"
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr "游标处ç†çš„最åŽäº‹ä»¶ID:"
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr "主节点中最åŽäº‹ä»¶ID:"
+
+msgid "GeoNodes|Loading nodes"
+msgstr "载入节点"
+
+msgid "GeoNodes|Local Attachments:"
+msgstr "本地附件:"
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr "本地LFS对象:"
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr "本地作业生æˆç‰©:"
+
+msgid "GeoNodes|New node"
+msgstr "新建节点"
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr "节点认è¯å·²æˆåŠŸä¿®å¤ã€‚"
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr "节点已æˆåŠŸåˆ é™¤ã€‚"
+
+msgid "GeoNodes|Not checksummed"
+msgstr "未校验"
+
+msgid "GeoNodes|Out of sync"
+msgstr "ä¸åŒæ­¥"
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr "删除节点会åœæ­¢åŒæ­¥ã€‚确定继续?"
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr "å¤åˆ¶æ§½WAL:"
+
+msgid "GeoNodes|Replication slots:"
+msgstr "å¤åˆ¶æ§½ï¼š"
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr "已校验仓库:"
+
+msgid "GeoNodes|Repositories:"
+msgstr "仓库:"
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr "仓库校验和已验è¯ï¼š"
+
+msgid "GeoNodes|Selective"
+msgstr "选择性"
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr "更改节点状æ€æ—¶å‘生错误"
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr "删除节点时å‘生错误"
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr "ä¿®å¤èŠ‚点时å‘生错误"
+
+msgid "GeoNodes|Storage config:"
+msgstr "存储设置:"
+
+msgid "GeoNodes|Sync settings:"
+msgstr "åŒæ­¥è®¾ç½®:"
+
+msgid "GeoNodes|Synced"
+msgstr "å·²åŒæ­¥"
+
+msgid "GeoNodes|Unused slots"
+msgstr "未使用的槽"
+
+msgid "GeoNodes|Unverified"
+msgstr "未验è¯"
+
+msgid "GeoNodes|Used slots"
+msgstr "已使用的槽"
+
+msgid "GeoNodes|Verified"
+msgstr "已验è¯"
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr " Wiki校验已验è¯ï¼š"
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr "wiki已校验"
+
+msgid "GeoNodes|Wikis:"
+msgstr "Wiki:"
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr "当å‰Geo节点é…置使用éžåŠ å¯†çš„HTTP连接, 建议使用HTTPS。"
+
+msgid "Geo|All projects"
+msgstr "所有项目"
+
msgid "Geo|File sync capacity"
-msgstr "文件åŒæ­¥å®¹é‡"
+msgstr "文件åŒæ­¥é‡"
+
+msgid "Geo|Groups to synchronize"
+msgstr "需åŒæ­¥çš„群组"
-msgid "Geo|Groups to replicate"
-msgstr "è¦å¤åˆ¶çš„群组"
+msgid "Geo|Projects in certain groups"
+msgstr "特定群组中的项目"
+
+msgid "Geo|Projects in certain storage shards"
+msgstr "特定存储片中的项目"
msgid "Geo|Repository sync capacity"
-msgstr "存储库åŒæ­¥å®¹é‡"
+msgstr "仓库åŒæ­¥é‡"
msgid "Geo|Select groups to replicate."
msgstr "选择è¦å¤åˆ¶çš„群组。"
+msgid "Geo|Shards to synchronize"
+msgstr "需åŒæ­¥çš„存储片"
+
+msgid "Git repository URL"
+msgstr "Git仓库URL"
+
+msgid "Git revision"
+msgstr "Gitæ交版本"
+
msgid "Git storage health information has been reset"
msgstr "Git 存储å¥åº·ä¿¡æ¯å·²é‡ç½®"
+msgid "Git version"
+msgstr "Git 版本"
+
+msgid "GitHub import"
+msgstr "GitHub导入"
+
+msgid "GitLab CI Linter has been moved"
+msgstr "GitLab CI Linter已被转移"
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "GitLab Runner"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr "GitalyæœåŠ¡å™¨"
+
+msgid "Go back"
+msgstr "返回"
+
msgid "Go to your fork"
msgstr "跳转到派生项目"
@@ -984,6 +2177,27 @@ msgstr "跳转到派生项目"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr "Google 身份验è¯ä¸æ˜¯%{link_to_documentation}。如果您想使用此æœåŠ¡ï¼Œè¯·å’¨è¯¢æ‚¨çš„ GitLab 管ç†å‘˜ã€‚"
+msgid "Got it!"
+msgstr "了解ï¼"
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr "利用å²è¯—故事(Epics),产å“线管ç†ä¼šå˜å¾—æ›´è½»æ¾ä¸”更高效"
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr "从 %{dateWord}"
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr "载入路线图"
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr "读å–å²è¯—故事时出错"
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr "如需查看路线图,请将计划的开始或结æŸæ—¥æœŸæ·»åŠ åˆ°å½“å‰ç¾¤ç»„或其å­ç»„中的æŸä¸ªå²è¯—故事。åªæ˜¾ç¤ºè¿‡åŽ»3个月和接下æ¥3个月的å²è¯—故事&ndash; 从 %{startDate} 至 %{endDate}."
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr "直到 %{dateWord}"
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr "ç¦æ­¢ä¸Žå…¶ä»–群组共享 %{group} 中的项目"
@@ -1000,7 +2214,7 @@ msgid "GroupSettings|This setting is applied on %{ancestor_group}. You can overr
msgstr "此设置已应用于 %{ancestor_group}。 您å¯ä»¥è¦†ç›–此设置或 %{remove_ancestor_share_with_group_lock}。"
msgid "GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually."
-msgstr "此设置将应用于所有å­ç»„,除éžç”±ç»„所有者覆盖。已ç»æœ‰æƒè®¿é—®è¯¥é¡¹ç›®çš„群组将继续访问,除éžæ‰‹åŠ¨ç§»é™¤ã€‚"
+msgstr "此设置将应用于所有å­ç»„,除éžç”±ç»„所有者覆盖。已ç»æœ‰æƒè®¿é—®è¯¥é¡¹ç›®çš„群组将ä»ç„¶å…·æœ‰è®¿é—®æƒé™ï¼Œé™¤éžè®¿é—®æƒé™è¢«æ‰‹åŠ¨ç§»é™¤ã€‚"
msgid "GroupSettings|cannot be disabled when the parent group \"Share with group lock\" is enabled, except by the owner of the parent group"
msgstr "无法ç¦ç”¨çˆ¶ç»„的“共享群组é”â€ï¼Œåªæœ‰çˆ¶ç¾¤ç»„的所有者æ‰å¯ä»¥æ“作ï¼"
@@ -1020,12 +2234,6 @@ msgstr "找ä¸åˆ°ç¾¤ç»„"
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "您å¯ä»¥ç®¡ç†ç¾¤ç»„æˆå‘˜çš„æƒé™å¹¶è®¿é—®ç¾¤ç»„中的æ¯ä¸ªé¡¹ç›®ã€‚"
-msgid "GroupsTreeRole|as"
-msgstr "çš„"
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr "您确定è¦ç¦»å¼€ç¾¤ç»„“${this.group.fullName}â€å—?"
-
msgid "GroupsTree|Create a project in this group."
msgstr "在此群组中创建一个项目。"
@@ -1053,6 +2261,12 @@ msgstr "对ä¸èµ·ï¼Œæ²¡æœ‰æœç´¢åˆ°ä»»ä½•ç¬¦åˆçš„群组"
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr "对ä¸èµ·ï¼Œæ²¡æœ‰ä»»ä½•ç¾¤ç»„或项目符åˆæ‚¨çš„æœç´¢"
+msgid "Have your users email"
+msgstr "有你的用户邮件"
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "å¥åº·æ£€æŸ¥"
@@ -1060,7 +2274,7 @@ msgid "Health information can be retrieved from the following endpoints. More in
msgstr "å¥åº·ä¿¡æ¯å¯ä»¥ä»Žä»¥ä¸‹API路径获å–。如需了解更多信æ¯ï¼Œè¯·æŸ¥çœ‹"
msgid "HealthCheck|Access token is"
-msgstr "访问令牌是"
+msgstr "访问令牌为"
msgid "HealthCheck|Healthy"
msgstr "å¥åº·"
@@ -1071,14 +2285,57 @@ msgstr "没有检测到å¥åº·é—®é¢˜"
msgid "HealthCheck|Unhealthy"
msgstr "éžå¥åº·"
+msgid "Help"
+msgstr "帮助"
+
+msgid "Help page"
+msgstr "帮助页é¢"
+
+msgid "Help page text and support page url."
+msgstr "帮助页é¢æ–‡æœ¬å’Œæ”¯æŒé¡µé¢ç½‘å€ã€‚"
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] "éšè—值"
+
msgid "History"
msgstr "历å²"
msgid "Housekeeping successfully started"
msgstr "已开始维护"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr "如果å¯ç”¨ï¼Œåˆ™ä½¿ç”¨å¤–部æœåŠ¡ä¸Šçš„分类标签æ¥éªŒè¯å¯¹é¡¹ç›®çš„访问æƒé™ã€‚"
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr "如使用GitHub,GitHub上的æ交和拉å–请求(pull request)将会显示æµæ°´çº¿çŠ¶æ€ã€‚ %{more_info_link}"
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr "如果文件已存在,å¯ä»¥ä½¿ç”¨ä¸‹é¢çš„ %{link_to_cli} 推é€å®ƒä»¬ã€‚"
+
+msgid "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>."
+msgstr "如果HTTP仓库ä¸å¯å…¬å¼€è®¿é—®ï¼Œè¯·å°†èº«ä»½éªŒè¯ä¿¡æ¯æ·»åŠ åˆ°URL: <code>https://username:password@gitlab.company.com/group/project.git</code>."
+
+msgid "Import"
+msgstr "导入"
+
+msgid "Import all repositories"
+msgstr "导入所有仓库"
+
+msgid "Import in progress"
+msgstr "正在导入"
+
+msgid "Import repositories from GitHub"
+msgstr "从 GitHub 导入仓库"
+
msgid "Import repository"
-msgstr "导入存储库"
+msgstr "导入仓库"
+
+msgid "ImportButtons|Connect repositories from"
+msgstr "用以下方å¼è¿žæŽ¥ä»“库"
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr "å助改进 GitLab ä¼ä¸šç‰ˆçš„议题看æ¿ã€‚"
@@ -1089,12 +2346,24 @@ msgstr "å助改善GitLab ä¼ä¸šç‰ˆçš„议题管ç†ä¸Žæƒé‡ã€‚"
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr "å助改进GitLab ä¼ä¸šç‰ˆçš„æœç´¢å’Œé«˜çº§å…¨å±€æœç´¢ 。"
+msgid "Install Runner on Kubernetes"
+msgstr "在Kubernetes上安装Runner"
+
msgid "Install a Runner compatible with GitLab CI"
msgstr "安装一个与 GitLab CI 兼容的 Runner"
msgid "Instance"
msgid_plural "Instances"
-msgstr[0] "例å­"
+msgstr[0] "实例"
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr "实例ä¸æ”¯æŒå¤šä¸ªKubernetes群集"
+
+msgid "Integrations"
+msgstr "导入所有仓库"
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr "相关人员甚至å¯ä»¥é€šè¿‡æŽ¨é€æ交æ¥ä¸ºé¡¹ç›®ä½œå‡ºè´¡çŒ®ã€‚"
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr "内部 - 任何登录的用户都å¯ä»¥æŸ¥çœ‹è¯¥ç¾¤ç»„和任何内部项目。"
@@ -1111,9 +2380,6 @@ msgstr "周期分æžç®€ä»‹"
msgid "Issue board focus mode"
msgstr "议题看æ¿æ¨¡å¼"
-msgid "Issue boards with milestones"
-msgstr "议题看æ¿ä¸Žé‡Œç¨‹ç¢‘"
-
msgid "Issue events"
msgstr "议题事件"
@@ -1126,14 +2392,86 @@ msgstr "看æ¿"
msgid "Issues"
msgstr "议题"
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr "议题å¯ä»¥æ˜¯ç¼ºé™·ï¼Œä»»åŠ¡æˆ–è¦è®¨è®ºçš„想法。此外,å¯ä»¥é€šè¿‡æœç´¢å’Œè¿‡æ»¤æ¥æŸ¥æ‰¾è®®é¢˜ã€‚"
+
+msgid "Jan"
+msgstr "一"
+
+msgid "January"
+msgstr "一月"
+
+msgid "Jobs"
+msgstr "作业"
+
+msgid "Jul"
+msgstr "七"
+
+msgid "July"
+msgstr "七月"
+
+msgid "Jun"
+msgstr "å…­"
+
+msgid "June"
+msgstr "六月"
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr "Kubernetes"
+
+msgid "Kubernetes Cluster"
+msgstr "Kubernetes集群"
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr "Kubernetes集群创建时间超过超时; %{timeout}"
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr "Kubernetes集群集æˆæœªè¢«åˆ é™¤ã€‚"
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr "Kubernetes集群集æˆå·²æˆåŠŸåˆ é™¤ã€‚"
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr "Kubernetes群集已æˆåŠŸæ›´æ–°ã€‚"
+
+msgid "Kubernetes configured"
+msgstr "Kuberneteså·²é…ç½®"
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr "KubernetesæœåŠ¡é›†æˆå³å°†è¢«åœç”¨ã€‚ 请使用新的 <a href=\"%{url}\"/>Kubernetes群集</a> 页é¢%{deprecated_message_content} Kubernetes集群"
+
msgid "LFSStatus|Disabled"
msgstr "åœç”¨"
msgid "LFSStatus|Enabled"
msgstr "å¯ç”¨"
+msgid "Label"
+msgstr "标记"
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr "%{firstLabelName} +%{remainingLabelCount} 更多"
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr "%{labelsString}和 %{remainingLabelCount} 更多"
+
msgid "Labels"
-msgstr "标签"
+msgstr "标记"
+
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr "标记å¯ä»¥åº”用于 %{features}。群组标记å¯ç”¨äºŽç¾¤ç»„中的所有项目。"
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr "标记å¯ç”¨äºŽå¯¹è®®é¢˜å’Œåˆå¹¶è¯·æ±‚进行分类。"
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr "<span>将标记</span> %{labelTitle} <span>å‡çº§ä¸ºç¾¤ç»„标记?</span>"
+
+msgid "Labels|Promote Label"
+msgstr "å‡çº§æ ‡è®°"
msgid "Last %d day"
msgid_plural "Last %d days"
@@ -1163,6 +2501,15 @@ msgstr "您推é€äº†"
msgid "LastPushEvent|at"
msgstr "于"
+msgid "Learn more"
+msgstr "进一步了解"
+
+msgid "Learn more about Kubernetes"
+msgstr "进一步了解关于Kubernetesçš„ä¿¡æ¯"
+
+msgid "Learn more about protected branches"
+msgstr "进一步了解ä¿æŠ¤åˆ†æ”¯"
+
msgid "Learn more in the"
msgstr "了解更多"
@@ -1181,31 +2528,78 @@ msgstr "退出项目"
msgid "License"
msgstr "许å¯åè®®"
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] "最多显示 %d 个事件"
+msgid "List"
+msgstr "列表"
+
+msgid "List your GitHub repositories"
+msgstr "列出GitHub仓库"
+
+msgid "Loading the GitLab IDE..."
+msgstr "加载GitLab IDE..."
msgid "Lock"
msgstr "é”定"
+msgid "Lock %{issuableDisplayName}"
+msgstr "é”定 %{issuableDisplayName}"
+
+msgid "Lock not found"
+msgstr "未找到é”"
+
msgid "Locked"
msgstr "å·²é”定"
msgid "Locked Files"
msgstr "å·²é”定文件"
+msgid "Locks give the ability to lock specific file or folder."
+msgstr "加é”å¯ä»¥é”定特定的文件或文件夹。"
+
msgid "Login"
msgstr "登录"
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr "GitLab Geo å¯ä»¥åˆ›å»º GitLab 实例的åªè¯»é•œåƒ, 使得从远端克隆和拉å–大型代ç ä»“库的时间大大缩短,æ高团队æˆå‘˜çš„工作效率。"
+
+msgid "Manage all notifications"
+msgstr "管ç†å…¨éƒ¨é€šçŸ¥"
+
+msgid "Manage group labels"
+msgstr "管ç†ç¾¤ç»„标记"
+
+msgid "Manage labels"
+msgstr "管ç†æ ‡è®°"
+
+msgid "Manage project labels"
+msgstr "管ç†é¡¹ç›®æ ‡è®°"
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr "三"
+
+msgid "March"
+msgstr "三月"
+
+msgid "Mark done"
+msgstr "标记为已完æˆ"
+
msgid "Maximum git storage failures"
msgstr "最大 git 存储失败"
+msgid "May"
+msgstr "五"
+
msgid "Median"
msgstr "中ä½æ•°"
msgid "Members"
msgstr "æˆå‘˜"
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr "åˆå¹¶è¯·æ±‚"
@@ -1215,37 +2609,172 @@ msgstr "åˆå¹¶äº‹ä»¶"
msgid "Merge request"
msgstr "åˆå¹¶è¯·æ±‚"
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr "åˆå¹¶è¯·æ±‚用于æ出对项目的更改与他人讨论"
+
+msgid "Merged"
+msgstr "å·²åˆå¹¶"
+
msgid "Messages"
msgstr "消æ¯"
+msgid "Metrics - Influx"
+msgstr "指标 - Influx"
+
+msgid "Metrics - Prometheus"
+msgstr "指标 - Prometheus"
+
+msgid "Metrics|Business"
+msgstr "业务"
+
+msgid "Metrics|Create metric"
+msgstr "创建指标"
+
+msgid "Metrics|Edit metric"
+msgstr "编辑指标"
+
+msgid "Metrics|For grouping similar metrics"
+msgstr "用于分组类似指标"
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr "图表纵轴的标签。通常表示绘制å•ä½ã€‚水平轴(X轴)一般表示时间。"
+
+msgid "Metrics|Legend label (optional)"
+msgstr "图例标签(å¯é€‰ï¼‰"
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr "必须是有效的 PromQL 查询。"
+
+msgid "Metrics|Name"
+msgstr "å称"
+
+msgid "Metrics|New metric"
+msgstr "创建指标"
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr "Prometheus查询文档"
+
+msgid "Metrics|Query"
+msgstr "查询"
+
+msgid "Metrics|Response"
+msgstr "å“应"
+
+msgid "Metrics|System"
+msgstr "系统"
+
+msgid "Metrics|Type"
+msgstr "类型"
+
+msgid "Metrics|Unit label"
+msgstr "å•ä½æ ‡ç­¾"
+
+msgid "Metrics|Used as a title for the chart"
+msgstr "用作图表的标题"
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr "用于查询返回å•ä¸ªç³»åˆ—时。如果返回多个系列,相应的图例标签将从返回数æ®ä¸­é€‰å–。"
+
+msgid "Metrics|Y-axis label"
+msgstr "Y轴标签"
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr "例如HTTP请求"
+
+msgid "Metrics|e.g. Requests/second"
+msgstr "例如æ¯ç§’请求数"
+
+msgid "Metrics|e.g. Throughput"
+msgstr "例如åžåé‡"
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr "速率(5分钟内所有http请求)"
+
+msgid "Metrics|e.g. req/sec"
+msgstr "例如æ¯ç§’请求数"
+
+msgid "Milestone"
+msgstr "里程碑"
+
+msgid "Milestones|Delete milestone"
+msgstr "删除里程碑"
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr "删除里程碑 %{milestoneTitle}?"
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr "删除里程碑 %{milestoneTitle}失败"
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr "未找到里程碑 %{milestoneTitle}"
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr "å°† %{milestoneTitle} å‡çº§ä¸ºç¾¤ç»„里程碑?"
+
+msgid "Milestones|Promote Milestone"
+msgstr "å‡çº§é‡Œç¨‹ç¢‘"
+
+msgid "Milestones|This action cannot be reversed."
+msgstr "该æ“作无法撤销。"
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "新建 SSH 公钥"
+msgid "Modal|Cancel"
+msgstr "å–消"
+
+msgid "Modal|Close"
+msgstr "关闭"
+
msgid "Monitoring"
msgstr "监控"
+msgid "More info"
+msgstr "更多信æ¯"
+
+msgid "More information"
+msgstr "更多信æ¯"
+
msgid "More information is available|here"
msgstr "帮助文档"
+msgid "Move"
+msgstr "移动"
+
+msgid "Move issue"
+msgstr "移动议题"
+
msgid "Multiple issue boards"
msgstr "多个议题看æ¿"
-msgid "New Cluster"
-msgstr "新集群"
+msgid "Name new label"
+msgstr "命å新标记"
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "新建议题"
+msgid "New Kubernetes Cluster"
+msgstr "新建Kubernetes集群"
+
+msgid "New Kubernetes cluster"
+msgstr "新建Kubernetes集群"
+
msgid "New Pipeline Schedule"
msgstr "创建æµæ°´çº¿è®¡åˆ’"
msgid "New branch"
msgstr "新建分支"
+msgid "New branch unavailable"
+msgstr "新分支ä¸å¯ç”¨"
+
msgid "New directory"
msgstr "新建目录"
+msgid "New epic"
+msgstr "新建å²è¯—故事"
+
msgid "New file"
msgstr "新建文件"
@@ -1255,11 +2784,14 @@ msgstr "新群组"
msgid "New issue"
msgstr "新建议题"
+msgid "New label"
+msgstr "新建标记"
+
msgid "New merge request"
msgstr "新建åˆå¹¶è¯·æ±‚"
msgid "New project"
-msgstr "新项目"
+msgstr "新建项目"
msgid "New schedule"
msgstr "新建计划"
@@ -1268,16 +2800,37 @@ msgid "New snippet"
msgstr "新建代ç ç‰‡æ®µ"
msgid "New subgroup"
-msgstr "æ–°å­ç¾¤ç»„"
+msgstr "新建å­ç¾¤ç»„"
msgid "New tag"
msgstr "新建标签"
-msgid "No container images stored for this project. Add one by following the instructions above."
-msgstr "此项目当å‰æœªå­˜å‚¨å®¹å™¨é•œåƒã€‚如需使用,请å‚照上述说明新建容器镜åƒã€‚"
+msgid "No Label"
+msgstr "无标记"
+
+msgid "No assignee"
+msgstr "未指派"
+
+msgid "No changes"
+msgstr "æ— å˜æ›´å†…容"
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr "无法连接到GitalyæœåŠ¡å™¨ï¼Œè¯·æ£€æŸ¥ç›¸å…³æ—¥å¿—ï¼"
+
+msgid "No due date"
+msgstr "无截止日期"
+
+msgid "No estimate or time spent"
+msgstr "无估算或消耗的时间"
+
+msgid "No file chosen"
+msgstr "未选定任何文件"
+
+msgid "No labels created yet."
+msgstr "尚未创建标记"
msgid "No repository"
-msgstr "没有存储库"
+msgstr "没有仓库"
msgid "No schedules"
msgstr "没有计划"
@@ -1285,12 +2838,39 @@ msgstr "没有计划"
msgid "None"
msgstr "æ— "
+msgid "Not allowed to merge"
+msgstr "ä¸å…许åˆå¹¶"
+
msgid "Not available"
msgstr "æ•°æ®ä¸è¶³"
+msgid "Not available for private projects"
+msgstr "对ç§æœ‰é¡¹ç›®ä¸å¯ç”¨"
+
+msgid "Not available for protected branches"
+msgstr "对å—ä¿æŠ¤çš„分支ä¸å¯ç”¨"
+
+msgid "Not confidential"
+msgstr "éžæœºå¯†"
+
msgid "Not enough data"
msgstr "æ•°æ®ä¸è¶³"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr "请注æ„,master分支自动å—ä¿æŠ¤ã€‚%{link_to_protected_branches}"
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr "æ示:作为GitLab管ç†å‘˜ï¼Œå¯ä»¥é…ç½® %{github_integration_link},这将å…许通过GitHub登录并å…许连接Github代ç ä»“库而ä¸éœ€è¦ä¸ªäººè®¿é—®ä»¤ç‰Œã€‚"
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr "æ示:作为GitLab管ç†å‘˜ï¼Œå¯ä»¥é…ç½® %{github_integration_link},这将å…许通过GitHub登录并å…许导入Github代ç ä»“库而ä¸éœ€è¦ä¸ªäººè®¿é—®ä»¤ç‰Œã€‚"
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr "æ示:如GitLab管ç†å‘˜é…ç½® %{github_integration_link},将å…许通过GitHub登录并å…许连接Github代ç ä»“库而ä¸éœ€è¦ä¸ªäººè®¿é—®ä»¤ç‰Œã€‚"
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr "æ示:如GitLab管ç†å‘˜é…ç½® %{github_integration_link},将å…许通过GitHub登录并å…许导入Github代ç ä»“库而ä¸éœ€è¦ä¸ªäººè®¿é—®ä»¤ç‰Œã€‚"
+
msgid "Notification events"
msgstr "通知事件"
@@ -1348,18 +2928,48 @@ msgstr "关注"
msgid "Notifications"
msgstr "通知"
+msgid "Notifications off"
+msgstr "ç¦ç”¨é€šçŸ¥"
+
+msgid "Notifications on"
+msgstr "å¯ç”¨é€šçŸ¥"
+
+msgid "Nov"
+msgstr "å一"
+
+msgid "November"
+msgstr "å一月"
+
msgid "Number of access attempts"
msgstr "å°è¯•è®¿é—®æ¬¡æ•°"
-msgid "Number of failures before backing off"
-msgstr "退出å‰çš„失败次数"
+msgid "OK"
+msgstr "确定"
+
+msgid "Oct"
+msgstr "å"
+
+msgid "October"
+msgstr "å月"
msgid "OfSearchInADropdown|Filter"
msgstr "筛选"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr "仓库导入åŽï¼Œå¯ä»¥é€šè¿‡ SSH 拉å–é•œåƒã€‚了解更多 %{ssh_link}"
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr "åªæœ‰é¡¹ç›®æˆå‘˜å¯ä»¥å‘表评论。"
+msgid "Open"
+msgstr "打开"
+
+msgid "Opened"
+msgstr "已打开"
+
msgid "OpenedNDaysAgo|Opened"
msgstr "创建于"
@@ -1369,12 +2979,21 @@ msgstr "打开一个新窗å£"
msgid "Options"
msgstr "æ“作"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr "å¦åˆ™ï¼Œå»ºè®®æ‚¨ä»Žä¸‹é¢çš„一个选项开始。"
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr "概览"
msgid "Owner"
msgstr "所有者"
+msgid "Pages"
+msgstr "Pages"
+
msgid "Pagination|Last »"
msgstr "尾页 »"
@@ -1387,11 +3006,20 @@ msgstr "上一页"
msgid "Pagination|« First"
msgstr "« 首页"
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr "密ç "
-msgid "People without permission will never get a notification and won\\'t be able to comment."
-msgstr "未ç»è®¸å¯çš„人将永远ä¸ä¼šæ”¶åˆ°é€šçŸ¥å¹¶ä¸”无法评论。"
+msgid "Pending"
+msgstr "等待处ç†"
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr "个人访问凭è¯"
msgid "Pipeline"
msgstr "æµæ°´çº¿"
@@ -1435,12 +3063,6 @@ msgstr "所有"
msgid "PipelineSchedules|Inactive"
msgstr "未å¯ç”¨"
-msgid "PipelineSchedules|Input variable key"
-msgstr "输入å˜é‡å"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "输入å˜é‡å€¼"
-
msgid "PipelineSchedules|Next Run"
msgstr "下次è¿è¡Œæ—¶é—´"
@@ -1450,9 +3072,6 @@ msgstr "æ— "
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "为此æµæ°´çº¿æ供简短æè¿°"
-msgid "PipelineSchedules|Remove variable row"
-msgstr "删除å˜é‡"
-
msgid "PipelineSchedules|Take ownership"
msgstr "å–得所有æƒ"
@@ -1480,6 +3099,57 @@ msgstr "上周的æµæ°´çº¿"
msgid "Pipelines for last year"
msgstr "去年的æµæ°´çº¿"
+msgid "Pipelines|Build with confidence"
+msgstr "自信地构建"
+
+msgid "Pipelines|CI Lint"
+msgstr "CI é…置检查(CI Lint)"
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr "清除Runner缓存"
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr "æµæ°´çº¿å…¥é—¨"
+
+msgid "Pipelines|Loading Pipelines"
+msgstr "载入æµæ°´çº¿"
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr "项目缓存é‡ç½®æˆåŠŸã€‚"
+
+msgid "Pipelines|Run Pipeline"
+msgstr "è¿è¡Œæµæ°´çº¿"
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr "清ç†runner缓存时å‘生错误。"
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr "当å‰æ²¡æœ‰ %{scope}çš„æµæ°´çº¿ã€‚"
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr "当å‰æ²¡æœ‰æµæ°´çº¿ã€‚"
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr "此项目当å‰æœªé…ç½®è¿è¡Œæµæ°´çº¿ã€‚"
+
+msgid "Pipeline|Retry pipeline"
+msgstr "é‡è¯•æµæ°´çº¿"
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr "é‡è¯•æµæ°´çº¿ï¼ƒ%{pipelineId}å—?"
+
+msgid "Pipeline|Stop pipeline"
+msgstr "åœæ­¢æµæ°´çº¿"
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr "åœæ­¢æµæ°´çº¿ï¼ƒ%{pipelineId}å—?"
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr "å³å°†é‡è¯•æµæ°´çº¿ %{pipelineId}。"
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr "å³å°†åœæ­¢æµæ°´çº¿ %{pipelineId}。"
+
msgid "Pipeline|all"
msgstr "所有"
@@ -1492,15 +3162,39 @@ msgstr "于阶段"
msgid "Pipeline|with stages"
msgstr "于阶段"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr "è¿è¡Œ"
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr "请 <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">为æŸä¸ªé¡¹ç›®å¯ç”¨è®¡è´¹åŠŸèƒ½ï¼Œä»¥ä¾¿èƒ½å¤Ÿåˆ›å»ºKubernetes群集</a>,然åŽé‡è¯•ã€‚"
+
+msgid "Please solve the reCAPTCHA"
+msgstr "请填写验è¯ç ã€‚"
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr "连接代ç ä»“库中,请ç¨å€™ã€‚å¯åœ¨ä»»æ„时刻刷新以获å–当å‰çŠ¶æ€ã€‚"
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr "导入代ç ä»“库中,请ç¨å€™ã€‚å¯åœ¨ä»»æ„时刻刷新以获å–当å‰çŠ¶æ€ã€‚"
+
msgid "Preferences"
msgstr "å好设置"
+msgid "Primary"
+msgstr "主è¦"
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr "ç§äºº - å¿…é¡»å‘æ¯ä¸ªç”¨æˆ·æ˜Žç¡®æŽˆäºˆé¡¹ç›®è®¿é—®æƒé™ã€‚"
msgid "Private - The group and its projects can only be viewed by members."
msgstr "ç§äºº - 群组åŠå…¶é¡¹ç›®åªèƒ½ç”±æˆå‘˜æŸ¥çœ‹ã€‚"
+msgid "Private projects can be created in your personal namespace with:"
+msgstr "ç§æœ‰é¡¹ç›®å¯ä»¥åœ¨ä¸ªäººå称空间中创建:"
+
msgid "Profile"
msgstr "用户信æ¯"
@@ -1540,6 +3234,12 @@ msgstr "您的å¸æˆ·ç›®å‰æ˜¯è¿™äº›ç¾¤ç»„的所有者:"
msgid "Profiles|your account"
msgstr "您的å¸æˆ·"
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr "当å‰ä»£ç ä»“库中使用的编程语言"
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr "项目 “%{project_name}†正在被删除。"
@@ -1555,6 +3255,12 @@ msgstr "项目 '%{project_name}' 已更新完æˆã€‚"
msgid "Project access must be granted explicitly to each user."
msgstr "项目访问æƒé™å¿…须明确授æƒç»™æ¯ä¸ªç”¨æˆ·ã€‚"
+msgid "Project avatar"
+msgstr "项目头åƒ"
+
+msgid "Project avatar in repository: %{link}"
+msgstr "项目头åƒåœ¨ä»“库中:%{link}"
+
msgid "Project details"
msgstr "项目详情"
@@ -1573,6 +3279,21 @@ msgstr "项目导出已开始。下载链接将通过电å­é‚®ä»¶å‘é€ã€‚"
msgid "ProjectActivityRSS|Subscribe"
msgstr "订阅"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr "å…许创建项目"
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr "默认项目创建ä¿æŠ¤"
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr "Developers + Masters"
+
+msgid "ProjectCreationLevel|Masters"
+msgstr "Masters"
+
+msgid "ProjectCreationLevel|No one"
+msgstr "ç¦æ­¢"
+
msgid "ProjectFeature|Disabled"
msgstr "åœç”¨"
@@ -1598,7 +3319,7 @@ msgid "ProjectSettings|Contact an admin to change this setting."
msgstr "è”系管ç†å‘˜æ›´æ”¹æ­¤è®¾ç½®ã€‚"
msgid "ProjectSettings|Only signed commits can be pushed to this repository."
-msgstr "åªæœ‰å·²ç­¾ç½²æ交æ‰å¯ä»¥æŽ¨é€åˆ°æ­¤å­˜å‚¨åº“。"
+msgstr "åªæœ‰å·²ç­¾ç½²æ交æ‰å¯ä»¥æŽ¨é€åˆ°æ­¤ä»“库。"
msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
msgstr "此设置已应用于æœåŠ¡å™¨çº§åˆ«ï¼Œå¯ç”±ç®¡ç†å‘˜è¦†ç›–。"
@@ -1610,7 +3331,7 @@ msgid "ProjectSettings|This setting will be applied to all projects unless overr
msgstr "此设置将应用于所有项目,除éžè¢«ç®¡ç†å‘˜è¦†ç›–。"
msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
-msgstr "用户åªèƒ½é€šè¿‡è‡ªå·±å·²éªŒè¯çš„电å­é‚®ä»¶åœ°å€å°†æ交到此存储库中。"
+msgstr "用户åªèƒ½é€šè¿‡è‡ªå·±å·²éªŒè¯çš„电å­é‚®ä»¶åœ°å€å°†æ交到此仓库中。"
msgid "Projects"
msgstr "项目"
@@ -1636,6 +3357,93 @@ msgstr "对ä¸èµ·ï¼Œæ²¡æœ‰æœç´¢åˆ°ç¬¦åˆæ¡ä»¶çš„项目"
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr "此功能需è¦æµè§ˆå™¨æ”¯æŒ localStorage"
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr "找到%{exporters} åŠ %{metrics}"
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr "<p class=\"text-tertiary\">无<a href=\"%{docsUrl}\">常用指标</a> </p>"
+
+msgid "PrometheusService|Active"
+msgstr "å¯ç”¨"
+
+msgid "PrometheusService|Auto configuration"
+msgstr "自动é…ç½®"
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr "自动部署和é…ç½®Prometheus到集群æ¥ç›‘测项目的环境"
+
+msgid "PrometheusService|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."
+msgstr "默认情况下,Prometheus ä¾¦å¬ â€˜http://localhost:9090’。ä¸å»ºè®®æ›´æ”¹é»˜è®¤åœ°å€å’Œç«¯å£ï¼Œå› ä¸ºè¿™å¯èƒ½ä¼šå½±å“或冲çªåœ¨ GitLab æœåŠ¡å™¨ä¸Šè¿è¡Œçš„其他æœåŠ¡ã€‚"
+
+msgid "PrometheusService|Common metrics"
+msgstr "常用指标"
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr "常用指标会根æ®åº”用广泛的导出器指标库自动监控。"
+
+msgid "PrometheusService|Custom metrics"
+msgstr "自定义指标"
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr "查找和é…置指标..."
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr "查找自定义指标..."
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr "在群集上安装Prometheus"
+
+msgid "PrometheusService|Manage clusters"
+msgstr "管ç†é›†ç¾¤"
+
+msgid "PrometheusService|Manual configuration"
+msgstr "手动é…ç½®"
+
+msgid "PrometheusService|Metrics"
+msgstr "指标"
+
+msgid "PrometheusService|Missing environment variable"
+msgstr "没有环境å˜é‡"
+
+msgid "PrometheusService|More information"
+msgstr "更多的信æ¯"
+
+msgid "PrometheusService|New metric"
+msgstr "新建指标"
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr "Prometheus API 地å€ï¼Œä¾‹å¦‚ http://prometheus.example.com/"
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr "Prometheus正在被群集自动管ç†"
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr "在首次部署到æŸçŽ¯å¢ƒä¹‹åŽ, 这些指标æ‰ä¼šè¢«ç›‘控"
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr "时间åºåˆ—监控æœåŠ¡"
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr "如需å¯ç”¨æ‰‹åŠ¨é…置,请从群集中å¸è½½Prometheus"
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr "如需在群集上å¯ç”¨Prometheus的安装,请å–消以下的手动é…ç½®"
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr "等待首次部署到环境以查找常用指标"
+
+msgid "Promote"
+msgstr "å‡çº§"
+
+msgid "Promote to Group Label"
+msgstr "å‡çº§åˆ°ç¾¤ç»„标记"
+
+msgid "Promote to Group Milestone"
+msgstr "å‡çº§åˆ°ç¾¤ç»„里程碑"
+
+msgid "Protip:"
+msgstr "专家æ示:"
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr "公开 - 群组和任何公共项目å¯ä»¥åœ¨æ²¡æœ‰ä»»ä½•èº«ä»½éªŒè¯çš„情况下查看。"
@@ -1648,21 +3456,39 @@ msgstr "推é€è§„则"
msgid "Push events"
msgstr "推é€äº‹ä»¶"
+msgid "Push project from command line"
+msgstr "从命令行推é€é¡¹ç›®"
+
+msgid "Push to create a project"
+msgstr "通过推é€åˆ›å»ºé¡¹ç›®"
+
msgid "PushRule|Committer restriction"
msgstr "æ交é™åˆ¶"
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr "快速æ“作å¯ç”¨äºŽè®®é¢˜æ述和评论框。"
+
msgid "Read more"
-msgstr "了解更多"
+msgstr "进一步了解"
msgid "Readme"
msgstr "自述文件"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "分支"
msgid "RefSwitcher|Tags"
msgstr "标签"
+msgid "Reference:"
+msgstr "引用:"
+
+msgid "Register / Sign In"
+msgstr "注册/登录"
+
msgid "Registry"
msgstr "注册表"
@@ -1684,14 +3510,41 @@ msgstr "相关的åˆå¹¶è¯·æ±‚"
msgid "Related Merged Requests"
msgstr "相关已åˆå¹¶çš„åˆå¹¶è¯·æ±‚"
+msgid "Related merge requests"
+msgstr "相关åˆå¹¶è¯·æ±‚"
+
msgid "Remind later"
msgstr "ç¨åŽæ醒"
+msgid "Remove"
+msgstr "删除"
+
+msgid "Remove avatar"
+msgstr "删除头åƒ"
+
msgid "Remove project"
msgstr "删除项目"
+msgid "Repair authentication"
+msgstr "ä¿®å¤è®¤è¯"
+
+msgid "Repo by URL"
+msgstr "从URL导入仓库"
+
msgid "Repository"
-msgstr "存储库"
+msgstr "仓库"
+
+msgid "Repository has no locks."
+msgstr "当å‰ä»“库无加é”文件。"
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
msgid "Request Access"
msgstr "申请æƒé™"
@@ -1705,68 +3558,171 @@ msgstr "é‡ç½®å¥åº·æ£€æŸ¥è®¿é—®ä»¤ç‰Œ"
msgid "Reset runners registration token"
msgstr "é‡ç½® Runner 注册令牌"
+msgid "Resolve discussion"
+msgstr "解决讨论"
+
+msgid "Response"
+msgstr "å“应"
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] "显示值"
+
msgid "Revert this commit"
msgstr "还原此æ交"
msgid "Revert this merge request"
msgstr "还原此åˆå¹¶è¯·æ±‚"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr "检查"
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr "路线图"
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr "使用外部仓库的CI/CDæµæ°´çº¿"
+
+msgid "Runners"
+msgstr "Runner"
+
+msgid "Running"
+msgstr "è¿è¡Œä¸­"
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
msgid "SSH Keys"
msgstr "SSH 密钥"
-msgid "Save"
-msgstr "ä¿å­˜"
-
msgid "Save changes"
msgstr "ä¿å­˜ä¿®æ”¹"
msgid "Save pipeline schedule"
msgstr "ä¿å­˜æµæ°´çº¿è®¡åˆ’"
+msgid "Save variables"
+msgstr "ä¿å­˜å˜é‡"
+
msgid "Schedule a new pipeline"
msgstr "新建æµæ°´çº¿è®¡åˆ’"
+msgid "Scheduled"
+msgstr "已加入日程"
+
msgid "Schedules"
msgstr "日程"
msgid "Scheduling Pipelines"
msgstr "æµæ°´çº¿è®¡åˆ’"
+msgid "Scoped issue boards"
+msgstr "议题看æ¿èŒƒå›´"
+
+msgid "Search"
+msgstr "æœç´¢"
+
msgid "Search branches and tags"
msgstr "æœç´¢åˆ†æ”¯å’Œæ ‡ç­¾"
+msgid "Search milestones"
+msgstr "æœç´¢é‡Œç¨‹ç¢‘"
+
+msgid "Search project"
+msgstr "æœç´¢é¡¹ç›®"
+
+msgid "Search users"
+msgstr "æœç´¢ç”¨æˆ·"
+
msgid "Seconds before reseting failure information"
msgstr "é‡ç½®å¤±è´¥ä¿¡æ¯ç­‰å¾…时间(秒)"
-msgid "Seconds to wait after a storage failure"
-msgstr "存储失败åŽç­‰å¾…时间(秒)"
-
msgid "Seconds to wait for a storage access attempt"
msgstr "等待存储访问å°è¯•æ—¶é—´(秒)"
+msgid "Secret variables"
+msgstr "加密å˜é‡"
+
+msgid "Security report"
+msgstr "安全报告"
+
msgid "Select Archive Format"
msgstr "选择下载格å¼"
msgid "Select a timezone"
msgstr "选择时区"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr "选择一个既有的Kubernetes集群或者创建一个新的"
+
+msgid "Select assignee"
+msgstr "选择指派人"
+
+msgid "Select branch/tag"
+msgstr "选择分支/标签"
+
msgid "Select target branch"
msgstr "选择目标分支"
+msgid "Selective synchronization"
+msgstr "选择性åŒæ­¥"
+
+msgid "Send email"
+msgstr "å‘é€ç”µå­é‚®ä»¶"
+
+msgid "Sep"
+msgstr "ä¹"
+
+msgid "September"
+msgstr "ä¹æœˆ"
+
+msgid "Server version"
+msgstr "æœåŠ¡å™¨ç‰ˆæœ¬"
+
msgid "Service Templates"
msgstr "æœåŠ¡æ¨¡æ¿"
+msgid "Service URL"
+msgstr "æœåŠ¡URL"
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr "会è¯æœ‰æ•ˆæœŸï¼Œé¡¹ç›®é™åˆ¶åŠé™„件大å°ã€‚"
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr "为账å·åˆ›å»ºä¸€ä¸ªç”¨äºŽæŽ¨é€æˆ–拉å–çš„ %{protocol} 密ç ã€‚"
-msgid "Set up CI"
-msgstr "设置 CI"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr "设定缺çœåŠå—é™å¯è§æ€§çº§åˆ«ã€‚é…置导入æ¥æºåŠgit访问å议。"
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr "设定用户登录的æ¡ä»¶ã€‚å¯ç”¨å¼ºåˆ¶åŒé‡è®¤è¯ã€‚"
+
+msgid "Set up CI/CD"
+msgstr "é…ç½® CI/CD"
msgid "Set up Koding"
msgstr "设置 Koding"
-msgid "Set up auto deploy"
-msgstr "设置自动部署"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "设置密ç "
@@ -1774,6 +3730,24 @@ msgstr "设置密ç "
msgid "Settings"
msgstr "设置"
+msgid "Setup a specific Runner automatically"
+msgstr "自动创建独享Runner"
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr "通过é‡ç½®æ­¤å‘½å空间的æµæ°´çº¿åˆ†é’Ÿæ•°ï¼Œå½“å‰ä½¿ç”¨çš„分钟数将被归零。"
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr "é‡ç½®æµæ°´çº¿åˆ†é’Ÿæ•°"
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr "é‡ç½®å·²ç”¨æµæ°´çº¿åˆ†é’Ÿæ•°"
+
+msgid "Show command"
+msgstr "显示命令"
+
msgid "Show parent pages"
msgstr "查看上级页é¢"
@@ -1784,14 +3758,47 @@ msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] "显示 %d 个事件"
+msgid "Sidebar|Change weight"
+msgstr "编辑æƒé‡"
+
+msgid "Sidebar|No"
+msgstr "æ— "
+
+msgid "Sidebar|None"
+msgstr "æ— "
+
+msgid "Sidebar|Weight"
+msgstr "æƒé‡"
+
+msgid "Sign-in restrictions"
+msgstr "登录é™åˆ¶"
+
+msgid "Sign-up restrictions"
+msgstr "注册é™åˆ¶"
+
+msgid "Size and domain settings for static websites"
+msgstr "é™æ€ç½‘站的大å°å’ŒåŸŸè®¾ç½®"
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr "代ç ç‰‡æ®µ"
+msgid "Something went wrong on our end"
+msgstr "出错了,抱歉。"
+
msgid "Something went wrong on our end."
-msgstr "å‘生了错误。"
+msgstr "出错了,抱歉。"
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
-msgstr "å°è¯•æ›´æ”¹ ${this.issuableDisplayName(this.issuableType)} çš„é”定状æ€æ—¶å‘生错误"
+msgid "Something went wrong when toggling the button"
+msgstr "点击按钮时出错"
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr "读å–ä¾èµ–扫æ结果时å‘生错误。"
+
+msgid "Something went wrong while fetching SAST."
+msgstr "读å–SAST 时出错。"
msgid "Something went wrong while fetching the projects."
msgstr "拉å–项目时å‘生错误。"
@@ -1799,6 +3806,9 @@ msgstr "拉å–项目时å‘生错误。"
msgid "Something went wrong while fetching the registry list."
msgstr "拉å–注册表列表时å‘生错误。"
+msgid "Something went wrong. Please try again."
+msgstr "出现错误。请é‡è¯•ã€‚"
+
msgid "Sort by"
msgstr "排åº"
@@ -1821,13 +3831,13 @@ msgid "SortOptions|Due soon"
msgstr "å³å°†æˆªæ­¢"
msgid "SortOptions|Label priority"
-msgstr "标签优先"
+msgstr "标记优先"
msgid "SortOptions|Largest group"
msgstr "最大群组"
msgid "SortOptions|Largest repository"
-msgstr "最大存储库"
+msgstr "最大仓库"
msgid "SortOptions|Last created"
msgstr "最近创建"
@@ -1854,7 +3864,7 @@ msgid "SortOptions|Milestone due soon"
msgstr "å³å°†æˆªæ­¢çš„里程碑"
msgid "SortOptions|More weight"
-msgstr "更大的æƒé‡"
+msgstr "最高æƒé‡"
msgid "SortOptions|Most popular"
msgstr "最å—欢迎"
@@ -1898,18 +3908,36 @@ msgstr "现在开始"
msgid "SortOptions|Weight"
msgstr "æƒé‡"
+msgid "Source"
+msgstr "æº"
+
+msgid "Source (branch or tag)"
+msgstr "æº(分支或标签)"
+
msgid "Source code"
msgstr "æºä»£ç "
+msgid "Source is not available"
+msgstr "æºä¸å¯ç”¨"
+
msgid "Spam Logs"
msgstr "垃圾信æ¯æ—¥å¿—"
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "在 Runner 设置时指定以下 URL:"
msgid "StarProject|Star"
msgstr "星标"
+msgid "Starred Projects"
+msgstr "已星标项目"
+
+msgid "Starred Projects' Activity"
+msgstr "已星标项目的活动"
+
msgid "Starred projects"
msgstr "已星标项目"
@@ -1919,39 +3947,135 @@ msgstr "由此更改 %{new_merge_request}"
msgid "Start the Runner!"
msgstr "å¯åŠ¨ Runner!"
+msgid "Started"
+msgstr "å·²å¯åŠ¨"
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr "状æ€"
+
+msgid "Stopped"
+msgstr "å·²åœæ­¢"
+
+msgid "Storage"
+msgstr "存储"
+
msgid "Subgroups"
msgstr "å­ç¾¤ç»„"
-msgid "Subscribe"
-msgstr "订阅"
-
msgid "Switch branch/tag"
msgstr "切æ¢åˆ†æ”¯/标签"
+msgid "System"
+msgstr "系统"
+
msgid "System Hooks"
msgstr "系统钩å­"
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "标签"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] "标签(%{tag_count})"
msgid "Tags"
msgstr "标签"
+msgid "TagsPage|Browse commits"
+msgstr "æµè§ˆæ交"
+
+msgid "TagsPage|Browse files"
+msgstr "æµè§ˆæ–‡ä»¶"
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr "无法找到此标记的HEADæ交"
+
+msgid "TagsPage|Cancel"
+msgstr "å–消"
+
+msgid "TagsPage|Create tag"
+msgstr "创建标签"
+
+msgid "TagsPage|Delete tag"
+msgstr "删除标签"
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr "删除 %{tag_name} åŽå°†æ— æ³•æ¢å¤ï¼Œæ‚¨ç¡®å®šï¼Ÿ"
+
+msgid "TagsPage|Edit release notes"
+msgstr "编辑å‘行记录"
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr "已存在分支å称,标记或æ交SHA"
+
+msgid "TagsPage|Filter by tag name"
+msgstr "æ ¹æ®æ ‡ç­¾å称过滤"
+
+msgid "TagsPage|New Tag"
+msgstr "新建标签"
+
+msgid "TagsPage|New tag"
+msgstr "新建标签"
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr "(å¯é€‰)添加一æ¡æ¶ˆæ¯åˆ°æ ‡ç­¾ã€‚"
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr "(å¯é€‰)å°†å‘行说明添加到标签。它们将被存储在GitLabæ•°æ®åº“中并显示在标签页上。"
+
+msgid "TagsPage|Release notes"
+msgstr "å‘行说明"
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr "版本库还没有标签。"
+
+msgid "TagsPage|Sort by"
+msgstr "排åº"
+
+msgid "TagsPage|Tags"
+msgstr "标签"
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr "使用标签,å¯ä»¥æ ‡è®°æ交历å²ä¸Šçš„特定点为é‡è¦æ交"
+
+msgid "TagsPage|This tag has no release notes."
+msgstr "此标签没有å‘行说明。"
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr "使用git tag命令添加一个:"
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr "撰写å‘行说明或拖放文件到这里..."
+
+msgid "TagsPage|protected"
+msgstr "å·²ä¿æŠ¤"
+
msgid "Target Branch"
msgstr "目标分支"
+msgid "Target branch"
+msgstr "目标分支"
+
msgid "Team"
msgstr "团队"
msgid "Thanks! Don't show me this again"
-msgstr "ä¸å†æ˜¾ç¤ºè¯¥æ示"
+msgstr "谢谢 ! 请ä¸è¦å†æ˜¾ç¤º"
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
-msgstr "GitLab 中的高级全局æœç´¢åŠŸèƒ½æ˜¯éžå¸¸å¼ºå¤§çš„æœç´¢æœåŠ¡ã€‚您å¯ä»¥æœç´¢å…¶ä»–团队的代ç ä»¥å¸®åŠ©æ‚¨å®Œå–„自己项目中的代ç ã€‚从而é¿å…创建é‡å¤çš„代ç æˆ–浪费时间。"
+msgstr "GitLab 中的高级全局æœç´¢æ˜¯ä¸€é¡¹åŠŸèƒ½å¼ºå¤§çš„æœç´¢æœåŠ¡ï¼Œæœ‰åŠ©äºŽèŠ‚约项目开å‘时间。您å¯ä»¥æœç´¢å…¶ä»–团队的代ç ä¸­å¯¹è‡ªå·±é¡¹ç›®æœ‰å¸®åŠ©çš„部分加以利用,从而é¿å…创建é‡å¤ä»£ç å’Œæµªè´¹æ—¶é—´ã€‚"
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
-msgstr "断路器关闭阈值应该低于故障计数阈值"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr "议题跟踪用于管ç†éœ€è¦æ”¹è¿›æˆ–者解决的问题"
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr "议题跟踪用于管ç†éœ€è¦æ”¹è¿›æˆ–者解决的问题。请注册或登录åŽä¸ºå½“å‰é¡¹ç›®åˆ›å»ºè®®é¢˜ã€‚"
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+msgstr "在需è¦ç›¸äº’ TLS 与外部授æƒæœåŠ¡é€šä¿¡æ—¶ä½¿ç”¨çš„ X509 è¯ä¹¦ã€‚如果ä¿ç•™ä¸ºç©º, 则在访问 HTTPS æ—¶ä»ç„¶éªŒè¯æœåŠ¡å™¨è¯ä¹¦ã€‚"
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 "ç¼–ç é˜¶æ®µæ¦‚述了从第一次æ交到创建åˆå¹¶è¯·æ±‚的时间。创建第一个åˆå¹¶è¯·æ±‚åŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
@@ -1959,30 +4083,39 @@ msgstr "ç¼–ç é˜¶æ®µæ¦‚述了从第一次æ交到创建åˆå¹¶è¯·æ±‚的时间。
msgid "The collection of events added to the data gathered for that stage."
msgstr "与该阶段相关的事件集åˆã€‚"
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr "该连接将在 %{timeout}åŽè¶…时。对于需è¦é•¿äºŽè¯¥æ—¶é—´æ‰èƒ½å¯¼å…¥çš„仓库,请使用克隆/推é€ç»„åˆã€‚"
+
msgid "The fork relationship has been removed."
msgstr "派生关系已被删除。"
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr "该导入过程将在 %{timeout}åŽè¶…时。对于需è¦é•¿äºŽè¯¥æ—¶é—´æ‰èƒ½å¯¼å…¥çš„仓库,请使用克隆/推é€ç»„åˆã€‚"
+
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 maximum file size allowed is 200KB."
+msgstr "文件大å°é™åˆ¶ä¸º 200KB。"
+
msgid "The number of attempts GitLab will make to access a storage."
msgstr "GitLab 访问存储的次数。"
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
-msgstr "GitLab 将临时ç¦ç”¨å¯¹ä¸»å­˜å‚¨åˆ†ç‰‡çš„访问"
-
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr "GitLab 将完全阻止访问存储的故障次数。å¯ä»¥åœ¨ç®¡ç†ç•Œé¢%{link_to_health_page}或使用%{api_documentation_link}é‡ç½®æ•…障次数。"
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "生产阶段概述了从创建一个议题到将代ç éƒ¨ç½²åˆ°ç”Ÿäº§çŽ¯å¢ƒçš„总时间。当完æˆæƒ³æ³•åˆ°éƒ¨ç½²ç”Ÿäº§çš„循环,数æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
@@ -1993,11 +4126,20 @@ msgid "The project can be accessed without any authentication."
msgstr "该项目å…许任何人访问。"
msgid "The repository for this project does not exist."
-msgstr "此项目的存储库ä¸å­˜åœ¨ã€‚"
+msgstr "此项目的仓库ä¸å­˜åœ¨ã€‚"
+
+msgid "The repository for this project is empty"
+msgstr "该项目的仓库是空的"
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+msgstr "该仓库必须å¯é€šè¿‡<code>http://</code>, <code>https://</code> 或 <code>git://</code>进行访问。"
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 roadmap shows the progress of your epics along a timeline"
+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 "预å‘布阶段概述了从åˆå¹¶è¯·æ±‚被åˆå¹¶åˆ°éƒ¨ç½²è‡³ç”Ÿäº§çŽ¯å¢ƒçš„总时间。首次部署到生产环境åŽï¼Œæ•°æ®å°†è‡ªåŠ¨æ·»åŠ åˆ°æ­¤å¤„。"
@@ -2010,23 +4152,59 @@ msgstr "GitLab å°†ä¿æŒå¤±è´¥ä¿¡æ¯çš„时间(秒)。在此期间ä¸å‘生故障
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr "GitLab å°†å°è¯•è®¿é—®å­˜å‚¨çš„时间(秒)。在此时间之åŽå°†å¼•å‘超时错误。"
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr "存储检查之间的时间间隔(秒)。如上次检查尚未完æˆï¼ŒGitLab将跳过当å‰æ£€æŸ¥ã€‚"
+
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 "There are no issues to show"
+msgstr "当å‰æ— è®®é¢˜"
+
+msgid "There are no merge requests to show"
+msgstr "当å‰æ— åˆå¹¶è¯·æ±‚"
+
msgid "There are problems accessing Git storage: "
msgstr "访问 Git 存储时出现问题:"
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
-msgstr "自您开始编辑åŽ, 此分支已更改。您想创建一个新的分支å—?"
+msgid "There was an error loading results"
+msgstr "加载结果时出错"
+
+msgid "There was an error loading users activity calendar."
+msgstr "加载用户活动日历时出错。"
+
+msgid "There was an error saving your notification settings."
+msgstr "ä¿å­˜é€šçŸ¥è®¾ç½®æ—¶å‘生错误。"
+
+msgid "There was an error subscribing to this label."
+msgstr "订阅此标记时出错。"
+
+msgid "There was an error when reseting email token."
+msgstr "é‡ç½®ç”µå­é‚®ä»¶ä»¤ç‰Œæ—¶å‡ºé”™ã€‚"
+
+msgid "There was an error when subscribing to this label."
+msgstr "订阅此标记时出错。"
+
+msgid "There was an error when unsubscribing from this label."
+msgstr "å–消订阅此标记时出错。"
+
+msgid "This board\\'s scope is reduced"
+msgstr "这个看æ¿çš„范围缩å°äº†"
+
+msgid "This directory"
+msgstr "当å‰ç›®å½•"
msgid "This is a confidential issue."
msgstr "这是一个机密议题。"
msgid "This is the author's first Merge Request to this project."
-msgstr "这是作者为项目贡献的第一个åˆå¹¶è¯·æ±‚。"
+msgstr "这是作者为当å‰é¡¹ç›®è´¡çŒ®çš„第一个åˆå¹¶è¯·æ±‚。"
+
+msgid "This issue is confidential"
+msgstr "当å‰é—®é¢˜ä¸ºç§å¯†é—®é¢˜"
msgid "This issue is confidential and locked."
msgstr "这个是机密且已é”定的议题。"
@@ -2034,12 +4212,45 @@ msgstr "这个是机密且已é”定的议题。"
msgid "This issue is locked."
msgstr "此议题已é”定。"
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr "当å‰ä½œä¸šéœ€è¦ç”¨æˆ·è§¦å‘其过程。此类作业通常用于将代ç éƒ¨ç½²åˆ°ç”Ÿäº§çŽ¯å¢ƒ"
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr "当å‰ä½œä¸šéœ€åœ¨ä¸Šçº§ä½œä¸šæˆåŠŸåŽæ‰å¯è¢«å¯åŠ¨ã€‚"
+
+msgid "This job has not been triggered yet"
+msgstr "作业还未被触å‘"
+
+msgid "This job has not started yet"
+msgstr "作业还未开始"
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr "作业挂起中,等待进入队列"
+
+msgid "This job requires a manual action"
+msgstr "作业需手工æ“作"
+
msgid "This means you can not push code until you create an empty repository or import existing one."
-msgstr "在创建一个空的存储库或导入现有存储库之å‰ï¼Œå°†æ— æ³•æŽ¨é€ä»£ç ã€‚"
+msgstr "在创建一个空的仓库或导入现有仓库之å‰ï¼Œå°†æ— æ³•æŽ¨é€ä»£ç ã€‚"
msgid "This merge request is locked."
msgstr "æ­¤åˆå¹¶è¯·æ±‚å·²é”定。"
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr "此页é¢ä¸å¯ç”¨ï¼Œæ‚¨æ— æƒè·¨é¡¹ç›®é˜…读相关信æ¯ã€‚"
+
+msgid "This project"
+msgstr "当å‰é¡¹ç›®"
+
+msgid "This repository"
+msgstr "当å‰ä»“库"
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr "æ­¤æ“作将删除自定义指标,确定继续å—?"
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr "这些电å­é‚®ä»¶è‡ªåŠ¨ç”Ÿæˆä¸ºé—®é¢˜(评论生æˆä¸ºç”µå­é‚®ä»¶å¯¹è¯)在这里列出。"
+
msgid "Time before an issue gets scheduled"
msgstr "议题被列入日程表的时间"
@@ -2049,9 +4260,27 @@ msgstr "开始进行编ç å‰çš„时间"
msgid "Time between merge request creation and merge/close"
msgstr "从创建åˆå¹¶è¯·æ±‚到被åˆå¹¶æˆ–关闭的时间"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr "GitLab等待外部æœåŠ¡çš„å“应时间(秒)。当æœåŠ¡æ²¡æœ‰åŠæ—¶å“应时,访问将被拒ç»ã€‚"
+
+msgid "Time tracking"
+msgstr "工时统计"
+
msgid "Time until first merge request"
msgstr "创建第一个åˆå¹¶è¯·æ±‚之å‰çš„时间"
+msgid "TimeTrackingEstimated|Est"
+msgstr "预计"
+
+msgid "TimeTracking|Estimated:"
+msgstr "预计:"
+
+msgid "TimeTracking|Spent"
+msgstr "已用:"
+
msgid "Timeago|%s days ago"
msgstr " %s 天å‰"
@@ -2186,14 +4415,83 @@ msgstr[0] "分钟"
msgid "Time|s"
msgstr "秒"
+msgid "Tip:"
+msgstr "æ示:"
+
+msgid "Title"
+msgstr "标题"
+
+msgid "To GitLab"
+msgstr "到GitLab"
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr "å¯ä»¥ä½¿ç”¨ %{personal_access_token_link}连接GitHub仓库。当创建个人访问令牌时,需è¦é€‰æ‹© <code>repo</code> 范围,以显示å¯ä¾›è¿žæŽ¥çš„公共和ç§æœ‰çš„仓库列表。"
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr "è¦è¿žæŽ¥GitHub仓库,首先需è¦æŽˆæƒGitLab访问列表中的GitHub仓库:"
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr "è¦è¿žæŽ¥SVN仓库,请查看 %{svn_link}。"
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr "å¯ä»¥ä½¿ç”¨ %{personal_access_token_link}导入GitHub仓库。当创建个人访问令牌时,需è¦é€‰æ‹© <code>repo</code> 范围,以显示å¯å¯¼å…¥çš„公共和ç§æœ‰çš„仓库列表。"
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr "è¦å¯¼å…¥GitHub仓库,首先需è¦æŽˆæƒGitLab访问列表中的GitHub仓库:"
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr "è¦å¯¼å…¥SVN仓库,请查看 %{svn_link}。"
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr "è¦ä»…为外部仓库使用CI / CD功能时,请选择</strong>使用外部仓库è¿è¡ŒCI/CD<strong>。"
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr "如需验è¯GitLab CI设置,请访问当å‰é¡¹ç›®çš„'CI/CD → æµæ°´çº¿',然åŽç‚¹å‡»'CI Lint'按钮。"
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr "如需查看路线图,请将计划的开始或结æŸæ—¥æœŸæ·»åŠ åˆ°å½“å‰ç¾¤ç»„或其å­ç»„中的æŸä¸ªå²è¯—故事。åªæ˜¾ç¤ºè¿‡åŽ»3个月和接下æ¥3个月的å²è¯—故事。"
+
+msgid "Todo"
+msgstr "待办事项"
+
+msgid "Toggle sidebar"
+msgstr "切æ¢è¾¹æ "
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr "切æ¢çŠ¶æ€ï¼šå…³é—­"
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr "切æ¢çŠ¶æ€ï¼šå¼€å¯"
+
msgid "Total Time"
msgstr "总时间"
msgid "Total test time for all commits/merges"
msgstr "所有æ交和åˆå¹¶çš„总测试时间"
+msgid "Total: %{total}"
+msgstr "总计:%{total}"
+
msgid "Track activity with Contribution Analytics."
-msgstr "跟踪分æžè´¡çŒ®ä¸Žæ´»åŠ¨ã€‚"
+msgstr "跟踪活动与贡献的分æžã€‚"
+
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr "在ä¸åŒé¡¹ç›®å’Œé‡Œç¨‹ç¢‘中跟踪具有åŒä¸€ä¸»é¢˜çš„议题组"
+
+msgid "Track time with quick actions"
+msgstr "使用快æ·æ“作æ¥ç»Ÿè®¡å·¥æ—¶"
+
+msgid "Trigger this manual action"
+msgstr "触å‘此手动æ“作"
+
+msgid "Turn on Service Desk"
+msgstr "打开æœåŠ¡å°"
+
+msgid "Unknown"
+msgstr "未知的"
msgid "Unlock"
msgstr "解é”"
@@ -2201,11 +4499,14 @@ msgstr "解é”"
msgid "Unlocked"
msgstr "已解é”"
+msgid "Unresolve discussion"
+msgstr "待解决的讨论"
+
msgid "Unstar"
msgstr "å–消星标"
-msgid "Unsubscribe"
-msgstr "退订"
+msgid "Up to date"
+msgstr "已是最新"
msgid "Upgrade your plan to activate Advanced Global Search."
msgstr "å‡çº§æ‚¨çš„方案以å¯ç”¨é«˜çº§å…¨å±€æœç´¢ã€‚"
@@ -2228,24 +4529,72 @@ msgstr "上传新文件"
msgid "Upload file"
msgstr "上传文件"
+msgid "Upload new avatar"
+msgstr "上传新头åƒ"
+
msgid "UploadLink|click to upload"
msgstr "点击上传"
+msgid "Upvotes"
+msgstr "顶"
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr "使用æœåŠ¡å°åœ¨GitLab内部通过电å­é‚®ä»¶ä¸Žç”¨æˆ·è”系(例如æ供客户支æŒï¼‰"
+
msgid "Use the following registration token during setup:"
msgstr "在安装过程中使用以下注册令牌:"
msgid "Use your global notification setting"
msgstr "使用全局通知设置"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr "å˜é‡é€šè¿‡runner作用于环境中。å¯å°†å˜é‡é™åˆ¶ä¸ºä»…å—ä¿æŠ¤çš„分支或标签å¯ä»¥è®¿é—®ã€‚å¯ä»¥ä½¿ç”¨å˜é‡æ¥ä¿å­˜å¯†ç ã€å¯†é’¥æˆ–任何其他内容。"
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr "查看和编辑行"
+
+msgid "View epics list"
+msgstr "查看å²è¯—故事列表"
+
msgid "View file @ "
msgstr "æµè§ˆæ–‡ä»¶ @ "
+msgid "View group labels"
+msgstr "查看群组标记"
+
+msgid "View labels"
+msgstr "查看标记"
+
msgid "View open merge request"
msgstr "查看待处ç†çš„åˆå¹¶è¯·æ±‚"
+msgid "View project labels"
+msgstr "查看项目标记"
+
msgid "View replaced file @ "
msgstr "查看替æ¢æ–‡ä»¶ @ "
+msgid "Visibility and access controls"
+msgstr "å¯è§æ€§ä¸Žè®¿é—®æŽ§åˆ¶"
+
msgid "VisibilityLevel|Internal"
msgstr "内部"
@@ -2261,17 +4610,29 @@ msgstr "未知"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "æƒé™ä¸è¶³ã€‚如需查看相关数æ®ï¼Œè¯·å‘管ç†å‘˜ç”³è¯·æƒé™ã€‚"
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr "无法验è¯æ‚¨åœ¨ GCP 上的æŸä¸ªé¡¹ç›®æ˜¯å¦å¯ç”¨äº†è®¡è´¹ã€‚请é‡è¯•ã€‚"
+
msgid "We don't have enough data to show this stage."
msgstr "该阶段的数æ®ä¸è¶³ï¼Œæ— æ³•æ˜¾ç¤ºã€‚"
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr "我们è¦ç¡®å®šä½ æ˜¯ä¸æ˜¯æœºå™¨äººã€‚"
+
+msgid "Web IDE"
+msgstr "Web IDE"
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr "如果有新的推é€æˆ–新的议题,Webhook将自动触å‘您设置URL。 您å¯ä»¥é…ç½® Webhook æ¥ç›‘å¬ç‰¹å®šäº‹ä»¶ï¼Œå¦‚推é€ã€è®®é¢˜æˆ–åˆå¹¶è¯·æ±‚。 群组 Webhook 将适用于团队中的所有项目,并å…许您设置整个团队中的 Webhook 。"
msgid "Weight"
msgstr "æƒé‡"
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
-msgstr "访问存储失败时。 GitLab 将在此处指定的时间内阻止对存储的访问。这å…许文件系统æ¢å¤ã€‚故障分片上的存储库暂时无法使用"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr "å°†URLä¿ç•™ä¸ºç©ºç™½æ—¶ï¼Œä»å¯æŒ‡å®šåˆ†ç±»æ ‡ç­¾ï¼Œè€Œæ— éœ€ç¦ç”¨è·¨é¡¹ç›®åŠŸèƒ½æˆ–执行外部授æƒæ£€æŸ¥ã€‚"
msgid "Wiki"
msgstr "Wiki"
@@ -2291,6 +4652,12 @@ msgstr "建议安装 %{markdown},以便 GFM 功能在本地渲染:"
msgid "WikiClone|Start Gollum and edit locally"
msgstr "å¯åŠ¨ Gollum 并在本地编辑"
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr "æ示:å¯ä»¥é€šè¿‡å°†è·¯å¾„添加到标题开头æ¥ç§»åŠ¨æ­¤é¡µé¢ã€‚"
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr "在该路径中已ç»æœ‰ä¸€ä¸ªå…·æœ‰ç›¸åŒæ ‡é¢˜çš„页é¢ã€‚"
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr "您ä¸èƒ½åˆ›å»º wiki 页é¢"
@@ -2381,39 +4748,69 @@ msgstr "通过贡献分æžï¼Œæ‚¨å¯ä»¥åˆ†æžæ‚¨çš„组织åŠå…¶æˆå‘˜çš„议题ã€
msgid "Withdraw Access Request"
msgstr "å–消æƒé™ç”³è¯·"
+msgid "Write a commit message..."
+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 %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "å°†è¦åˆ é™¤ %{project_full_name}。删除åŽå°†æ— æ³•æ¢å¤ï¼ç¡®å®šæ‰§è¡Œæ­¤æ“作?"
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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr "å°†è¦æŠŠ %{project_full_name} 转移给å¦ä¸€ä¸ªæ‰€æœ‰è€…。确定执行此æ“作?"
msgid "You are on a read-only GitLab instance."
-msgstr "您在一个åªè¯» GitLab 实例上。"
+msgstr "当å‰æ­£åœ¨è®¿é—®åªè¯» GitLab 实例。"
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
-msgstr "您在一个åªè¯»çš„ GitLab 实例上。如果您想进行任何更改,您必须访问%{link_to_primary_node}。"
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr "当å‰æ­£åœ¨è®¿é—®Geo次(åªè¯»)节点。如需进行任何写入æ“作,必须访问%{primary_node}。"
+
+msgid "You can also create a project from the command line."
+msgstr "å¯ä»¥ä½¿ç”¨å‘½ä»¤è¡Œæ¥åˆ›å»ºé¡¹ç›®ã€‚"
+
+msgid "You can also star a label to make it a priority label."
+msgstr "å¯ä»¥é€šè¿‡ä¸ºæ ‡è®°è®¾ç½®æ˜Ÿæ ‡æ¥æ高其优先级。"
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr "å¯ä»¥è½»æ¾åœ°åœ¨Kubernetes群集上安装Runner。 %{link_to_help_page}"
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr "å¯ä»¥ä½¿ç”¨æ–¹å‘键移动图形。"
msgid "You can only add files when you are on a branch"
msgstr "åªèƒ½åœ¨åˆ†æ”¯ä¸Šæ·»åŠ æ–‡ä»¶"
+msgid "You can only edit files when you are on a branch"
+msgstr "åªèƒ½åœ¨åˆ†æ”¯ä¸Šç¼–辑文件"
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr "您ä¸èƒ½å†™å…¥åªè¯»çš„辅助 GitLab Geo 实例。请改用%{link_to_primary_node}。"
msgid "You cannot write to this read-only GitLab instance."
msgstr "您ä¸èƒ½å†™å…¥è¿™ä¸ªåªè¯»çš„ GitLab 实例。"
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr "没有足够æƒé™æ¥ä¿®æ”¹LDAP组åŒæ­¥ä¸­çš„设置。"
+
+msgid "You have no permissions"
+msgstr "没有æƒé™"
+
msgid "You have reached your project limit"
msgstr "您已达到项目数é‡é™åˆ¶"
+msgid "You must have master access to force delete a lock"
+msgstr "必须拥有 master æƒé™æ‰èƒ½å¼ºåˆ¶è§£é™¤é”定"
+
msgid "You must sign in to star a project"
msgstr "必须登录æ‰èƒ½å¯¹é¡¹ç›®åŠ æ˜Ÿæ ‡"
+msgid "You need a different license to enable FileLocks feature"
+msgstr "需è¦ä½¿ç”¨ä¸Žå½“å‰ä¸åŒçš„许å¯(license)æ‰èƒ½å¯ç”¨FileLocks功能"
+
msgid "You need permission."
msgstr "需è¦ç›¸å…³çš„æƒé™ã€‚"
@@ -2438,6 +4835,36 @@ 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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr "在您的个人资料中添加SSH密钥之å‰ï¼Œæ‚¨ä¸èƒ½é€šè¿‡SSHæ¥æ‹‰å–或推é€é¡¹ç›®ä»£ç ã€‚"
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr "需è¦ä½¿ç”¨ä¸åŒçš„分支æ‰èƒ½è¿›è¡Œæœ‰æ•ˆçš„比较。"
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr "您收到这å°ç”µå­é‚®ä»¶æ˜¯å› ä¸ºä½ åœ¨ %{host} 拥有å¸æˆ·ã€‚ %{manage_notifications_link} &middot; %{help_link}"
+
+msgid "Your Groups"
+msgstr "您的群组"
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr "在此页é¢ä¸Šçš„Kubernetes群集信æ¯ä»å¯ç¼–辑,但建议您ç¦ç”¨å¹¶é‡æ–°é…ç½®"
+
+msgid "Your Projects (default)"
+msgstr "您的项目 (默认值)"
+
+msgid "Your Projects' Activity"
+msgstr "您的项目活动"
+
+msgid "Your Todos"
+msgstr "您的待办事项"
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr "åˆå¹¶è¯·æ±‚已开å¯ï¼Œå¯ä»¥æ交å˜æ›´åˆ°%{branch_name}。"
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr "更改已æ交。æ交 %{commitId} %{commitStats}"
+
msgid "Your comment will not be visible to the public."
msgstr "您的评论将ä¸ä¼šå…¬å¼€æ˜¾ç¤ºã€‚"
@@ -2450,19 +4877,347 @@ msgstr "您的åå­—"
msgid "Your projects"
msgstr "您的项目"
-msgid "commit"
-msgstr "æ交"
+msgid "among other things"
+msgstr "除其他事项外"
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] "åŠ%d个修å¤çš„æ¼æ´ž"
+
+msgid "assign yourself"
+msgstr "分é…给自己"
+
+msgid "branch name"
+msgstr "分支å称"
+
+msgid "by"
+msgstr "æ¥è‡ª"
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr "%{type} 未å‘现新的安全æ¼æ´ž"
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr "%{type} 未å‘现安全æ¼æ´ž"
+
+msgid "ciReport|Code quality"
+msgstr "代ç è´¨é‡"
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr "DAST在审阅应用中未检测到告警"
+
+msgid "ciReport|Dependency scanning"
+msgstr "ä¾èµ–关系扫æ"
+
+msgid "ciReport|Dependency scanning detected"
+msgstr "ä¾èµ–关系扫æ检测到"
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr "ä¾èµ–关系扫æ未检测到新的安全æ¼æ´ž"
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr "ä¾èµ–关系扫æ未检测到安全æ¼æ´ž"
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr "无法加载 %{reportName} 报告"
+
+msgid "ciReport|Fixed:"
+msgstr "失败:"
+
+msgid "ciReport|Instances"
+msgstr "实例"
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr "进一步了解关于白åå•çš„ä¿¡æ¯"
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr "载入%{reportName} 报告"
+
+msgid "ciReport|No changes to code quality"
+msgstr "代ç è´¨é‡æ— å˜åŒ–"
+
+msgid "ciReport|No changes to performance metrics"
+msgstr "性能指标无å˜åŒ–"
+
+msgid "ciReport|Performance metrics"
+msgstr "性能指标"
+
+msgid "ciReport|SAST"
+msgstr "SAST"
+
+msgid "ciReport|SAST detected"
+msgstr "SAST检测到"
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr "SAST未å‘现新的安全æ¼æ´ž"
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr "SAST未å‘现安全æ¼æ´ž"
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr "SAST:container未å‘现æ¼æ´ž"
+
+msgid "ciReport|Security scanning"
+msgstr "安全扫æ"
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr "安全扫æ无法加载任何结果"
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr "显示完整的代ç æ¼æ´žæŠ¥å‘Š"
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr "未批准的æ¼æ´ž (红色) å¯ä»¥æ ‡è®°ä¸ºå·²æ‰¹å‡†ã€‚ %{helpLink}"
+
+msgid "ciReport|no vulnerabilities"
+msgstr "未检测到安全æ¼æ´ž"
+
+msgid "command line instructions"
+msgstr "命令行指å—"
+
+msgid "connecting"
+msgstr "连接中"
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr "无法读å–ç§é’¥ï¼Œå¯†ç çŸ­è¯­æ˜¯å¦æ­£ç¡®ï¼Ÿ"
msgid "day"
msgid_plural "days"
msgstr[0] "天"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] "检测到%d个安全æ¼æ´žå·²ä¿®å¤"
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] "检测到%d个新的安全æ¼æ´ž"
+
+msgid "detected no vulnerabilities"
+msgstr "未检测到安全æ¼æ´ž"
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr "最åŽä¸€æ¬¡%{slash_command} 命令将更新预计时间。"
+
+msgid "here"
+msgstr "此处"
+
+msgid "importing"
+msgstr "导入中"
+
+msgid "in progress"
+msgstr "进行中"
+
+msgid "is invalid because there is downstream lock"
+msgstr "因下游é”定而无效"
+
+msgid "is invalid because there is upstream lock"
+msgstr "因上游é”定而无效"
+
+msgid "is not a valid X509 certificate."
+msgstr "ä¸æ˜¯æœ‰æ•ˆçš„X509è¯ä¹¦ã€‚"
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr "被 %{path_lock_user_name} 于 %{created_at} é”定"
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] "åˆå¹¶è¯·æ±‚"
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr "请æ¢å¤æ­¤åˆ†æ”¯æˆ–使用其他的 %{missingBranchName} 分支"
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr "%{metricsLinkStart} 内存 %{metricsLinkEnd} å ç”¨ %{emphasisStart} ä¸‹é™ %{emphasisEnd},从 %{memoryFrom}MB 到 %{memoryTo}MB"
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr "%{metricsLinkStart} 内存 %{metricsLinkEnd} å ç”¨ %{emphasisStart} ä¸Šå‡ %{emphasisEnd},从 %{memoryFrom}MB 到 %{memoryTo}MB"
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr "%{metricsLinkStart} 内存 %{metricsLinkEnd} å ç”¨ %{emphasisStart} æ— å˜åŒ– %{emphasisEnd}, ä¿æŒåœ¨ %{memoryFrom}MB"
+
+msgid "mrWidget|Add approval"
+msgstr "增加批准"
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr "å…许上游项目维护人员进行编辑。"
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr "删除批准时å‘生错误。"
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr "读å–æ­¤åˆå¹¶è¯·æ±‚的批准数æ®æ—¶å‘生错误。"
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr "æ交批准时å‘生错误。"
+
+msgid "mrWidget|Approve"
+msgstr "批准"
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr "批准人:"
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr "å–消自动åˆå¹¶"
+
+msgid "mrWidget|Check out branch"
+msgstr "检出分支"
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr "检查是å¦å¯ä»¥è‡ªåŠ¨åˆå¹¶"
+
+msgid "mrWidget|Cherry-pick"
+msgstr "优选"
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr "通过新的åˆå¹¶è¯·æ±‚中优选此åˆå¹¶è¯·æ±‚"
+
+msgid "mrWidget|Closed"
+msgstr "已关闭"
+
+msgid "mrWidget|Closed by"
+msgstr "关闭:"
+
+msgid "mrWidget|Closes"
+msgstr "关闭"
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr "部署统计信æ¯å½“å‰ä¸å¯ç”¨"
+
+msgid "mrWidget|Did not close"
+msgstr "没有关闭"
+
+msgid "mrWidget|Email patches"
+msgstr "通过电å­é‚®ä»¶å‘出补ä¸"
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr "无法加载部署统计信æ¯"
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr "如果 %{branch} 分支存在于本地仓库中,则å¯ä»¥æ‰‹åŠ¨åˆå¹¶è¯¥åˆå¹¶è¯·æ±‚。需使用"
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr "如果 %{missingBranchName} 分支存在于本地仓库中,则å¯ä»¥é€šè¿‡ä»¥ä¸‹å‘½ä»¤è¡Œæ‰‹åŠ¨åˆå¹¶è¯¥åˆå¹¶è¯·æ±‚。"
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr "加载部署统计信æ¯ä¸­"
+
+msgid "mrWidget|Mentions"
+msgstr "æåŠ"
+
+msgid "mrWidget|Merge"
+msgstr "åˆå¹¶"
+
+msgid "mrWidget|Merge failed."
+msgstr "åˆå¹¶å¤±è´¥ã€‚"
+
+msgid "mrWidget|Merge locally"
+msgstr "本地åˆå¹¶"
+
+msgid "mrWidget|Merged by"
+msgstr "åˆå¹¶:"
+
+msgid "mrWidget|Plain diff"
+msgstr "文本差异"
+
+msgid "mrWidget|Refresh"
+msgstr "刷新"
+
+msgid "mrWidget|Refresh now"
+msgstr "ç«‹å³åˆ·æ–°"
+
+msgid "mrWidget|Refreshing now"
+msgstr "ç«‹å³åˆ·æ–°"
+
+msgid "mrWidget|Remove Source Branch"
+msgstr "删除æºåˆ†æ”¯"
+
+msgid "mrWidget|Remove source branch"
+msgstr "删除æºåˆ†æ”¯"
+
+msgid "mrWidget|Remove your approval"
+msgstr "删除您的批准"
+
+msgid "mrWidget|Request to merge"
+msgstr "请求åˆå¹¶"
+
+msgid "mrWidget|Resolve conflicts"
+msgstr "解决冲çª"
+
+msgid "mrWidget|Revert"
+msgstr "还原"
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr "通过新的åˆå¹¶è¯·æ±‚中还原此åˆå¹¶è¯·æ±‚"
+
+msgid "mrWidget|Set by"
+msgstr "设置:"
+
+msgid "mrWidget|The changes were merged into"
+msgstr "更改已åˆå¹¶åˆ°"
+
+msgid "mrWidget|The changes were not merged into"
+msgstr "更改未åˆå¹¶åˆ°"
+
+msgid "mrWidget|The changes will be merged into"
+msgstr "更改将被åˆå¹¶åˆ°"
+
+msgid "mrWidget|The source branch has been removed"
+msgstr "æºåˆ†æ”¯å·²è¢«åˆ é™¤"
+
+msgid "mrWidget|The source branch is being removed"
+msgstr "æºåˆ†æ”¯æ­£åœ¨è¢«åˆ é™¤"
+
+msgid "mrWidget|The source branch will be removed"
+msgstr "æºåˆ†æ”¯å°†è¢«åˆ é™¤"
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr "æºåˆ†æ”¯ä¸ä¼šè¢«åˆ é™¤"
+
+msgid "mrWidget|There are merge conflicts"
+msgstr "存在åˆå¹¶å†²çª"
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr "该åˆå¹¶è¯·æ±‚未能自动åˆå¹¶"
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr "该åˆå¹¶è¯·æ±‚正在被åˆå¹¶"
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr "该项目已存档,ç¦æ­¢å†™å…¥"
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr "å¯ä»¥æ‰‹åŠ¨åˆå¹¶æ­¤åˆå¹¶è¯·æ±‚,使用以下"
+
+msgid "mrWidget|You can remove source branch now"
+msgstr "当å‰å·²å¯ä»¥åˆ é™¤æºåˆ†æ”¯"
+
+msgid "mrWidget|branch does not exist."
+msgstr "分支ä¸å­˜åœ¨"
+
+msgid "mrWidget|command line"
+msgstr "命令行"
+
+msgid "mrWidget|into"
+msgstr "å…¥"
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr "æµæ°´çº¿æˆåŠŸæ—¶è‡ªåŠ¨åˆå¹¶"
+
msgid "new merge request"
msgstr "新建åˆå¹¶è¯·æ±‚"
msgid "notification emails"
msgstr "通知邮件"
+msgid "or"
+msgstr "或"
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "上级"
@@ -2473,9 +5228,30 @@ msgstr "密ç "
msgid "personal access token"
msgstr "个人访问令牌"
+msgid "private key does not match certificate."
+msgstr "ç§é’¥ä¸Žè¯ä¹¦ä¸åŒ¹é…。"
+
+msgid "remove due date"
+msgstr "删除截止日期"
+
+msgid "source"
+msgstr "æº"
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr "%{slash_command} 将会更新消耗的总时长。"
+
+msgid "this document"
+msgstr "此文档"
+
msgid "to help your contributors communicate effectively!"
msgstr "帮助您的贡献者进行有效沟通ï¼"
msgid "username"
msgstr "用户å"
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr "使用 Kubernetes 集群æ¥éƒ¨ç½²ä»£ç ï¼"
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr "å…± %{additions} æ¡æ–°å¢ž, %{deletions} æ¡åˆ é™¤."
+
diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po
index b851809fc7c..6bfcae6aa91 100644
--- a/locale/zh_HK/gitlab.po
+++ b/locale/zh_HK/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-15 02:54-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:39-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Chinese Traditional, Hong Kong\n"
"Language: zh_HK\n"
@@ -16,41 +16,76 @@ msgstr ""
"X-Crowdin-Language: zh-HK\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr ""
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] " %d 次æ交"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] ""
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] ""
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] ""
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] ""
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+msgstr[0] ""
+
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_author_link} æ交於 %{commit_timeago}"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
+msgid "%{loadingIcon} Started"
+msgstr ""
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr ""
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "已失敗 %{number_of_failures} 次,最大失敗 %{maximum_failures} 次,GitLab å°‡é‡è©¦ã€‚"
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "已失敗 %{number_of_failures} 次,最大失敗 %{maximum_failures} 次,GitLab 將在 %{number_of_seconds} 秒後é‡è©¦ã€‚"
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "已失敗 %{number_of_failures} 次,最大失敗 %{maximum_failures} 次,GitLabä¸æœƒé‡è©¦ã€‚當å•é¡Œè§£æ±ºæ™‚é‡ç½®å­˜å„²ä¿¡æ¯ã€‚"
+msgid "%{openOrClose} %{noteable}"
+msgstr ""
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name}:已訪å•æ­¤ä¸»æ©Ÿå¤±æ•— %{failed_attempts} 次"
+msgid "%{text} is available"
+msgstr ""
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(想了解更多的安è£è¨Šæ¯è«‹æŸ¥çœ‹ %{link})"
@@ -70,15 +105,30 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "<strong>Removes</strong> source branch"
+msgstr ""
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "相關æŒçºŒé›†æˆçš„圖åƒé›†åˆ"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr ""
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr ""
+
+msgid "A user with write access to the source branch selected this option"
+msgstr ""
+
msgid "About auto deploy"
msgstr "關於自動部署"
msgid "Abuse Reports"
msgstr ""
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr ""
@@ -88,6 +138,9 @@ msgstr "å› æ¢å¾©å®‰è£ï¼Œè¨ªå•æ•…障存儲已被暫時ç¦ç”¨ã€‚在å•é¡Œè§£æ±º
msgid "Account"
msgstr ""
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "啟用"
@@ -106,73 +159,283 @@ msgstr "添加貢ç»æŒ‡å—"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
+msgid "Add Kubernetes cluster"
+msgstr ""
+
msgid "Add License"
msgstr "添加許å¯è­‰"
-msgid "Add an SSH key to your profile to pull or push via SSH."
-msgstr "新增壹個用於推é€æˆ–拉å–çš„ SSH 秘鑰到賬號中。"
+msgid "Add Readme"
+msgstr ""
msgid "Add new directory"
msgstr "添加新目錄"
+msgid "Add todo"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs"
+msgstr ""
+
+msgid "AdminArea|Stop all jobs?"
+msgstr ""
+
+msgid "AdminArea|Stop jobs"
+msgstr ""
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr ""
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr ""
+
msgid "AdminHealthPageLink|health page"
msgstr ""
+msgid "AdminProjects|Delete"
+msgstr ""
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr ""
+
+msgid "AdminProjects|Delete project"
+msgstr ""
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr ""
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr ""
+
+msgid "AdminUsers|Delete user"
+msgstr ""
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr ""
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr ""
+
+msgid "Advanced"
+msgstr ""
+
msgid "Advanced settings"
msgstr ""
msgid "All"
msgstr "全部"
+msgid "All changes are committed"
+msgstr ""
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr ""
+
+msgid "Allow edits from maintainers."
+msgstr ""
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr ""
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr ""
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr ""
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr ""
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr ""
+msgid "Any Label"
+msgstr ""
+
msgid "Appearance"
msgstr ""
msgid "Applications"
msgstr ""
+msgid "Apr"
+msgstr ""
+
+msgid "April"
+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 leave this group?"
-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 you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr "確定嗎?"
msgid "Artifacts"
msgstr ""
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr ""
+
+msgid "Assign labels"
+msgstr ""
+
+msgid "Assign milestone"
+msgstr ""
+
+msgid "Assign to"
+msgstr ""
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
+msgstr ""
+
+msgid "Assignee"
+msgstr ""
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "拖放文件到此處或者 %{upload_link}"
+msgid "Aug"
+msgstr ""
+
+msgid "August"
+msgstr ""
+
msgid "Authentication Log"
msgstr ""
msgid "Author"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
+msgid "Authors: %{authors}"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
+msgid "Auto DevOps enabled"
msgstr ""
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr ""
msgid "AutoDevOps|Auto DevOps (Beta)"
@@ -190,7 +453,31 @@ msgstr ""
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr ""
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr ""
+
+msgid "Available"
+msgstr ""
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr ""
+
+msgid "Average per day: %{average}"
+msgstr ""
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
msgstr ""
msgid "Billing"
@@ -247,9 +534,9 @@ msgstr ""
msgid "BillingPlans|per user"
msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "分支"
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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}"
@@ -257,6 +544,12 @@ msgstr "分支 <strong>%{branch_name}</strong> 已創建。如需設置自動部
msgid "Branch has changed"
msgstr ""
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "æœç´¢åˆ†æ”¯"
@@ -266,6 +559,15 @@ msgstr "切æ›åˆ†æ”¯"
msgid "Branches"
msgstr "分支"
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr ""
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr ""
@@ -311,12 +613,39 @@ msgstr ""
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr ""
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
msgstr ""
msgid "Branches|Sort by"
msgstr ""
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
msgstr ""
@@ -362,14 +691,23 @@ msgstr "ç€è¦½æ–‡ä»¶"
msgid "Browse files"
msgstr "ç€è¦½æ–‡ä»¶"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "作者:"
msgid "CI / CD"
msgstr ""
-msgid "CI configuration"
-msgstr "CI é…ç½®"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr ""
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr ""
@@ -377,12 +715,21 @@ msgstr ""
msgid "Cancel"
msgstr "å–消"
-msgid "Cancel edit"
-msgstr "å–消编辑"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
msgstr ""
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "挑é¸åˆ°åˆ†æ”¯"
@@ -395,22 +742,55 @@ msgstr "優é¸"
msgid "ChangeTypeAction|Revert"
msgstr "還原"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr "更新日誌"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "統計圖"
msgid "Chat"
msgstr ""
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr "優é¸æ­¤æ交"
msgid "Cherry-pick this merge request"
msgstr "優é¸æ­¤åˆä½µè«‹æ±‚"
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
+msgid "Choose File ..."
+msgstr ""
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr ""
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "CiStatusLabel|canceled"
@@ -467,55 +847,145 @@ msgstr "已跳éŽ"
msgid "CiStatus|running"
msgstr "é‹è¡Œä¸­"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr ""
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr ""
msgid "Close"
msgstr ""
-msgid "Cluster"
+msgid "Closed"
+msgstr ""
+
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
+
+msgid "ClusterIntegration|Applications"
msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster details"
+msgid "ClusterIntegration|CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster integration"
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
+msgid "ClusterIntegration|Copy API URL"
msgstr ""
-msgid "ClusterIntegration|Cluster name"
+msgid "ClusterIntegration|Copy CA Certificate"
msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
msgstr ""
-msgid "ClusterIntegration|Create cluster"
+msgid "ClusterIntegration|Copy Token"
msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Create Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
@@ -527,43 +997,145 @@ msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
msgstr ""
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
+
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr ""
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr ""
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
+
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
+msgid "ClusterIntegration|More information"
msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr ""
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr ""
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
+msgstr ""
+
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr ""
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|See machine types"
@@ -575,16 +1147,37 @@ msgstr ""
msgid "ClusterIntegration|See zones"
msgstr ""
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr ""
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
+
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
+msgid "ClusterIntegration|Token"
msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
@@ -596,18 +1189,33 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
-msgid "ClusterIntegration|cluster"
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
+
+msgid "ClusterIntegration|documentation"
msgstr ""
msgid "ClusterIntegration|help page"
msgstr ""
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr ""
msgid "ClusterIntegration|properly configured"
msgstr ""
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr "è©•è«–"
@@ -615,8 +1223,8 @@ msgid "Commit"
msgid_plural "Commits"
msgstr[0] "æ交"
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
msgstr[0] ""
msgid "Commit Message"
@@ -628,6 +1236,12 @@ msgstr "最近30次æ交花費的時間(分é˜ï¼‰"
msgid "Commit message"
msgstr "æ交信æ¯"
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "æ交"
@@ -640,15 +1254,99 @@ msgstr "æ交"
msgid "Commits feed"
msgstr "æ交動態"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "æ­·å²"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "æ交者:"
msgid "Compare"
msgstr "比較"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -694,12 +1392,30 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr ""
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "è²¢ç»æŒ‡å—"
msgid "Contributors"
msgstr "è²¢ç»è€…"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
msgstr ""
@@ -712,27 +1428,60 @@ msgstr ""
msgid "Copy URL to clipboard"
msgstr "複製URL到剪貼æ¿"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "複製æ交 SHA 到剪貼æ¿"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "創建新目錄"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "在帳戶上創建個人訪å•ä»¤ç‰Œï¼Œä»¥é€šéŽ %{protocol} 來拉å–或推é€ã€‚"
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr "創建目錄"
-msgid "Create empty bare repository"
-msgstr "創建空的存儲庫"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
msgstr ""
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr "創建åˆä½µè«‹æ±‚"
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr ""
@@ -742,9 +1491,15 @@ msgstr ""
msgid "Create new file"
msgstr ""
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr "創建..."
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "派生"
@@ -754,24 +1509,36 @@ msgstr "標籤"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "創建個人訪å•ä»¤ç‰Œ"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr "Cron 時å€"
msgid "Cron syntax"
msgstr "Cron 語法"
+msgid "Current node"
+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 "Customize colors"
+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 "編碼"
@@ -799,6 +1566,15 @@ msgstr ""
msgid "DashboardProjects|Personal"
msgstr ""
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr "使用 Cron 語法定義自定義模å¼"
@@ -821,11 +1597,20 @@ msgstr ""
msgid "Details"
msgstr "詳情"
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr "目錄å稱"
-msgid "Discard changes"
-msgstr "放棄更改"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
+msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
msgstr ""
@@ -833,9 +1618,15 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "ä¸å†é¡¯ç¤º"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "下載"
@@ -863,15 +1654,171 @@ msgstr "差異文件"
msgid "DownloadSource|Download"
msgstr "下載"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "編輯"
msgid "Edit Pipeline Schedule %{id}"
msgstr "編輯 %{id} æµæ°´ç·šè¨ˆåŠƒ"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr ""
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr "全部"
@@ -899,24 +1846,75 @@ msgstr "æ¯æœˆåŸ·è¡Œï¼ˆæ¯æœˆ 1 日淩晨 4 點)"
msgid "Every week (Sundays at 4:00am)"
msgstr "æ¯é€±åŸ·è¡Œï¼ˆå‘¨æ—¥æ·©æ™¨ 4 點)"
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr ""
msgid "Explore public groups"
msgstr ""
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "無法變更所有者"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "無法刪除æµæ°´ç·šè¨ˆåŠƒ"
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr ""
msgid "Files"
msgstr "文件"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "按æ交消æ¯éŽæ¿¾"
@@ -926,12 +1924,21 @@ msgstr "按路徑查找"
msgid "Find file"
msgstr "查找文件"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "首次推é€"
msgid "FirstPushedBy|pushed by"
msgstr "推é€è€…:"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "派生"
@@ -942,25 +1949,178 @@ msgstr "派生自"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr ""
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr ""
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr "從創建議題到部署到生產環境"
msgid "From merge request merge until deploy to production"
msgstr "從åˆä½µè«‹æ±‚çš„åˆä½µåˆ°éƒ¨ç½²è‡³ç”Ÿç”¢ç’°å¢ƒ"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr ""
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
msgstr ""
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
+
msgid "Geo|File sync capacity"
msgstr ""
-msgid "Geo|Groups to replicate"
+msgid "Geo|Groups to synchronize"
+msgstr ""
+
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
msgstr ""
msgid "Geo|Repository sync capacity"
@@ -969,12 +2129,45 @@ msgstr ""
msgid "Geo|Select groups to replicate."
msgstr ""
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
+
msgid "Git storage health information has been reset"
msgstr "Git 存儲å¥åº·ä¿¡æ¯å·²é‡ç½®"
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "GitLab Runner 介紹"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr "跳轉到派生項目"
@@ -984,6 +2177,27 @@ msgstr "跳轉到派生項目"
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr ""
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
@@ -1020,12 +2234,6 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
-msgid "GroupsTreeRole|as"
-msgstr ""
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr ""
-
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -1053,6 +2261,12 @@ msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr ""
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "å¥åº·æª¢æŸ¥ (Health Check)"
@@ -1071,15 +2285,58 @@ msgstr "沒有檢測到å¥åº·å•é¡Œ"
msgid "HealthCheck|Unhealthy"
msgstr "ä¸è‰¯"
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+
msgid "History"
msgstr ""
msgid "Housekeeping successfully started"
msgstr "已開始維護"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "導入存儲庫"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
msgstr ""
@@ -1089,6 +2346,9 @@ msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
msgstr ""
+msgid "Install Runner on Kubernetes"
+msgstr ""
+
msgid "Install a Runner compatible with GitLab CI"
msgstr "安è£å£¹å€‹èˆ‡ GitLab CI 兼容的 Runner"
@@ -1096,6 +2356,15 @@ msgid "Instance"
msgid_plural "Instances"
msgstr[0] ""
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
+
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr ""
@@ -1111,9 +2380,6 @@ msgstr "週期分æžç°¡ä»‹"
msgid "Issue board focus mode"
msgstr ""
-msgid "Issue boards with milestones"
-msgstr ""
-
msgid "Issue events"
msgstr "議題事件 (issue event)"
@@ -1126,15 +2392,87 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "åœç”¨"
msgid "LFSStatus|Enabled"
msgstr "啟用"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr ""
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "最近 %d 天"
@@ -1163,6 +2501,15 @@ msgstr "您推é€äº†"
msgid "LastPushEvent|at"
msgstr "在"
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "了解更多"
@@ -1181,31 +2528,78 @@ msgstr "退出項目"
msgid "License"
msgstr ""
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] "最多顯示 %d 個事件"
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr ""
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr ""
msgid "Locked Files"
msgstr ""
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
+
msgid "Login"
msgstr ""
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "中ä½æ•¸"
msgid "Members"
msgstr ""
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr ""
@@ -1215,37 +2609,172 @@ msgstr "åˆä½µäº‹ä»¶ (merge event)"
msgid "Merge request"
msgstr ""
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr ""
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "添加壹個 SSH 公鑰"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr ""
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr "幫助文檔"
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
msgstr ""
-msgid "New Cluster"
+msgid "Name new label"
msgstr ""
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "新建議題"
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "創建æµæ°´ç·šè¨ˆåŠƒ"
msgid "New branch"
msgstr "新增分支"
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "新增目錄"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "新增文件"
@@ -1255,6 +2784,9 @@ msgstr ""
msgid "New issue"
msgstr "新議題"
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "新增åˆä½µè«‹æ±‚"
@@ -1273,7 +2805,28 @@ msgstr ""
msgid "New tag"
msgstr "新增標籤"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1285,12 +2838,39 @@ msgstr "沒有計劃"
msgid "None"
msgstr ""
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "ä¸å¯ç”¨"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "數據ä¸è¶³"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "通知事件"
@@ -1348,18 +2928,48 @@ msgstr "關注"
msgid "Notifications"
msgstr ""
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr ""
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr "篩é¸"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr ""
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "開始於"
@@ -1369,12 +2979,21 @@ msgstr ""
msgid "Options"
msgstr "æ“作"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr ""
msgid "Owner"
msgstr "所有者"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr ""
@@ -1387,10 +3006,19 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr ""
-msgid "People without permission will never get a notification and won\\'t be able to comment."
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
msgstr ""
msgid "Pipeline"
@@ -1435,12 +3063,6 @@ msgstr "所有"
msgid "PipelineSchedules|Inactive"
msgstr "未啟用"
-msgid "PipelineSchedules|Input variable key"
-msgstr "輸入變é‡å"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "輸入變é‡å€¼"
-
msgid "PipelineSchedules|Next Run"
msgstr "下次é‹è¡Œæ™‚é–“"
@@ -1450,9 +3072,6 @@ msgstr "ç„¡"
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "為此æµæ°´ç·šæ供簡短æè¿°"
-msgid "PipelineSchedules|Remove variable row"
-msgstr "刪除變é‡"
-
msgid "PipelineSchedules|Take ownership"
msgstr "å–得所有權"
@@ -1480,6 +3099,57 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr ""
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr ""
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "所有"
@@ -1492,15 +3162,39 @@ msgstr "於階段"
msgid "Pipeline|with stages"
msgstr "於階段"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr ""
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr ""
msgid "Private - The group and its projects can only be viewed by members."
msgstr ""
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr ""
@@ -1540,6 +3234,12 @@ msgstr ""
msgid "Profiles|your account"
msgstr ""
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -1555,6 +3255,12 @@ msgstr "é …ç›® '%{project_name}' 已更新完æˆã€‚"
msgid "Project access must be granted explicitly to each user."
msgstr "項目訪å•æ¬Šé™å¿…須明確授權給æ¯å€‹ç”¨æˆ¶ã€‚"
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr "專案詳情"
@@ -1573,6 +3279,21 @@ msgstr "項目導出已開始。下載éˆæŽ¥å°‡é€šéŽé›»å­éƒµä»¶ç™¼é€ã€‚"
msgid "ProjectActivityRSS|Subscribe"
msgstr "訂閱"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "åœç”¨"
@@ -1636,6 +3357,93 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr ""
@@ -1648,21 +3456,39 @@ msgstr ""
msgid "Push events"
msgstr "推é€äº‹ä»¶ (push event) "
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
msgstr ""
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
+
msgid "Read more"
msgstr "了解更多"
msgid "Readme"
msgstr "自述文件"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "分支"
msgid "RefSwitcher|Tags"
msgstr "標籤"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
msgstr ""
@@ -1684,15 +3510,42 @@ msgstr "相關的åˆä½µè«‹æ±‚"
msgid "Related Merged Requests"
msgstr "相關已åˆä½µçš„åˆä½µè«‹æ±‚"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "ç¨å¾Œæ醒"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "刪除項目"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr "存儲庫"
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "申請權é™"
@@ -1705,16 +3558,53 @@ msgstr "é‡ç½®å¥åº·æª¢æŸ¥è¨ªå•ä»¤ç‰Œ"
msgid "Reset runners registration token"
msgstr "é‡ç½® Runner 註冊令牌"
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+
msgid "Revert this commit"
msgstr "還原此æ交"
msgid "Revert this merge request"
msgstr "還原此åˆä½µè«‹æ±‚"
-msgid "SSH Keys"
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
msgstr ""
-msgid "Save"
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
+msgid "SSH Keys"
msgstr ""
msgid "Save changes"
@@ -1723,50 +3613,116 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr "ä¿å­˜æµæ°´ç·šè¨ˆåŠƒ"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "新建æµæ°´ç·šè¨ˆåŠƒ"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr ""
msgid "Scheduling Pipelines"
msgstr "æµæ°´ç·šè¨ˆåŠƒ"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "æœç´¢åˆ†æ”¯å’Œæ¨™ç±¤"
-msgid "Seconds before reseting failure information"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
msgstr ""
-msgid "Seconds to wait after a storage failure"
+msgid "Search users"
+msgstr ""
+
+msgid "Seconds before reseting failure information"
msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "é¸æ“‡ä¸‹è¼‰æ ¼å¼"
msgid "Select a timezone"
msgstr "é¸æ“‡æ™‚å€"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "é¸æ“‡ç›®æ¨™åˆ†æ”¯"
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr ""
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
msgid "Set a password on your account to pull or push via %{protocol}."
msgstr "為賬號添加壹個用於推é€æˆ–拉å–çš„ %{protocol} 密碼。"
-msgid "Set up CI"
-msgstr "設置 CI"
+msgid "Set default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "設置 Koding"
-msgid "Set up auto deploy"
-msgstr "設置自動部署"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "設置密碼"
@@ -1774,6 +3730,24 @@ msgstr "設置密碼"
msgid "Settings"
msgstr ""
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -1784,13 +3758,46 @@ msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] "顯示 %d 個事件"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr ""
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
msgstr ""
msgid "Something went wrong while fetching the projects."
@@ -1799,6 +3806,9 @@ msgstr ""
msgid "Something went wrong while fetching the registry list."
msgstr ""
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr ""
@@ -1898,18 +3908,36 @@ msgstr ""
msgid "SortOptions|Weight"
msgstr ""
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
+
msgid "Source code"
msgstr "æºä»£ç¢¼"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr ""
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "在 Runner 設置時指定以下 URL:"
msgid "StarProject|Star"
msgstr "星標"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr ""
@@ -1919,28 +3947,118 @@ msgstr "由此更改 %{new_merge_request}"
msgid "Start the Runner!"
msgstr "é‹ä½œ Runner!"
-msgid "Subgroups"
+msgid "Started"
msgstr ""
-msgid "Subscribe"
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
+msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag"
msgstr "切æ›åˆ†æ”¯/標籤"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr ""
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "標籤"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
msgid "Tags"
msgstr "標籤"
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "目標分支"
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr "團隊"
@@ -1950,7 +4068,13 @@ msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
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."
@@ -1959,30 +4083,39 @@ msgstr "編碼階段概述了從第壹次æ交到創建åˆä½µè«‹æ±‚的時間。
msgid "The collection of events added to the data gathered for that stage."
msgstr "與該階段相關的事件。"
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "派生關係已被刪除。"
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 number of attempts GitLab will make to access a storage."
+msgid "The maximum file size allowed is 200KB."
msgstr ""
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
+msgid "The number of attempts GitLab will make to access a storage."
msgstr ""
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr ""
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完æˆå®Œæ•´çš„想法到部署生產,數據將自動添加到此處。"
@@ -1995,9 +4128,18 @@ msgstr "該項目å…許任何人訪å•ã€‚"
msgid "The repository for this project does not exist."
msgstr "此項目的存儲庫ä¸å­˜åœ¨ã€‚"
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 roadmap shows the progress of your epics along a timeline"
+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 "é ç™¼å¸ƒéšŽæ®µæ¦‚述了åˆä½µè«‹æ±‚çš„åˆä½µåˆ°éƒ¨ç½²ä»£ç¢¼åˆ°ç”Ÿç”¢ç’°å¢ƒçš„總時間。當首次部署到生產環境後,數據將自動添加到此處。"
@@ -2010,16 +4152,49 @@ msgstr ""
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr ""
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
msgid "The time taken by each data entry gathered by that stage."
msgstr "該階段æ¯æ¢æ•¸æ“šæ‰€èŠ±çš„時間"
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr "中ä½æ•¸æ˜¯å£¹å€‹æ•¸åˆ—中最中間的值。例如在 3ã€5ã€9 之間,中ä½æ•¸æ˜¯ 5。在 3ã€5ã€7ã€8 之間,中ä½æ•¸æ˜¯ (5 + 7)/ 2 = 6。"
+msgid "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr "è¨ªå• Git 存儲時出ç¾å•é¡Œï¼š"
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
msgstr ""
msgid "This is a confidential issue."
@@ -2028,18 +4203,54 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr ""
msgid "This issue is locked."
msgstr ""
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "在創建壹個空的存儲庫或導入ç¾æœ‰å­˜å„²åº«ä¹‹å‰ï¼Œæ‚¨å°‡ç„¡æ³•æŽ¨é€ä»£ç¢¼ã€‚"
msgid "This merge request is locked."
msgstr ""
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "議題被列入日程表的時間"
@@ -2049,9 +4260,27 @@ msgstr "開始進行編碼å‰çš„時間"
msgid "Time between merge request creation and merge/close"
msgstr "從創建åˆä½µè«‹æ±‚到被åˆä½µæˆ–關閉的時間"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr ""
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "創建第壹個åˆä½µè«‹æ±‚之å‰çš„時間"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr " %s 天å‰"
@@ -2186,25 +4415,97 @@ msgstr[0] "分é˜"
msgid "Time|s"
msgstr "秒"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr "總時間"
msgid "Total test time for all commits/merges"
msgstr "所有æ交和åˆä½µçš„總測試時間"
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
msgstr ""
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
+
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "å–消星標"
-msgid "Unsubscribe"
+msgid "Up to date"
msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
@@ -2228,24 +4529,72 @@ msgstr "上傳新文件"
msgid "Upload file"
msgstr "上傳文件"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "點擊上傳"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr "在安è£éŽç¨‹ä¸­ä½¿ç”¨ä»¥ä¸‹è¨»å†Šä»¤ç‰Œï¼š"
msgid "Use your global notification setting"
msgstr "使用全局通知設置"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr ""
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "查看開啟的åˆä¸¦è«‹æ±‚"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr ""
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "內部"
@@ -2261,16 +4610,28 @@ msgstr "未知"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "權é™ä¸è¶³ã€‚如需查看相關數據,請å‘管ç†å“¡ç”³è«‹æ¬Šé™ã€‚"
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "該階段的數據ä¸è¶³ï¼Œç„¡æ³•é¡¯ç¤ºã€‚"
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
msgstr ""
msgid "Wiki"
@@ -2291,6 +4652,12 @@ msgstr ""
msgid "WikiClone|Start Gollum and edit locally"
msgstr ""
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr ""
@@ -2381,39 +4748,69 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr "å–消權é™ç”³è¯·"
+msgid "Write a commit message..."
+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 %{project_full_name}. 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 "å³å°‡åˆªé™¤èˆ‡æºé …ç›® %{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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "åªèƒ½åœ¨åˆ†æ”¯ä¸Šæ·»åŠ æ–‡ä»¶"
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "您已é”到項目數é‡é™åˆ¶"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "必須登錄æ‰èƒ½å°é …目加星標"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "需è¦ç›¸é—œçš„權é™ã€‚"
@@ -2438,6 +4835,36 @@ 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 "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -2450,19 +4877,347 @@ msgstr "您的åå­—"
msgid "Your projects"
msgstr ""
-msgid "commit"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
msgstr ""
msgid "day"
msgid_plural "days"
msgstr[0] "天"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "新建åˆä½µè«‹æ±‚"
msgid "notification emails"
msgstr "通知郵件"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "父級"
@@ -2473,9 +5228,30 @@ msgstr ""
msgid "personal access token"
msgstr ""
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "username"
msgstr ""
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po
index b6d4ed27487..553050d06a1 100644
--- a/locale/zh_TW/gitlab.po
+++ b/locale/zh_TW/gitlab.po
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab-ee\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-11-02 14:42+0100\n"
-"PO-Revision-Date: 2017-11-20 03:59-0500\n"
+"POT-Creation-Date: 2018-04-04 19:35+0200\n"
+"PO-Revision-Date: 2018-04-05 03:39-0400\n"
"Last-Translator: gitlab <mbartlett+crowdin@gitlab.com>\n"
"Language-Team: Chinese Traditional\n"
"Language: zh_TW\n"
@@ -16,41 +16,76 @@ msgstr ""
"X-Crowdin-Language: zh-TW\n"
"X-Crowdin-File: /master/locale/gitlab.pot\n"
+msgid " and"
+msgstr " 和"
+
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d 個更動 (commit)"
+msgid "%d commit behind"
+msgid_plural "%d commits behind"
+msgstr[0] "è½å¾Œ %d 個更動紀錄"
+
+msgid "%d exporter"
+msgid_plural "%d exporters"
+msgstr[0] "%d 導出"
+
+msgid "%d issue"
+msgid_plural "%d issues"
+msgstr[0] "%d 個議題"
+
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] "%d 個圖層"
+msgid "%d merge request"
+msgid_plural "%d merge requests"
+msgstr[0] "%d 個åˆä½µè«‹æ±‚"
+
+msgid "%d metric"
+msgid_plural "%d metrics"
+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 個更動 (commit)。"
-msgid "%{commit_author_link} committed %{commit_timeago}"
-msgstr "%{commit_author_link} 在 %{commit_timeago} é€äº¤"
+msgid "%{actionText} & %{openOrClose} %{noteable}"
+msgstr ""
+
+msgid "%{commit_author_link} authored %{commit_timeago}"
+msgstr "ç”± %{commit_author_link} æ交於 %{commit_timeago}"
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] "%{count} åƒèˆ‡è€…"
+msgid "%{loadingIcon} Started"
+msgstr "%{loadingIcon} 開始"
+
+msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
+msgstr "%{lock_path} 被使用者 %{lock_user_id} 鎖定"
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr ""
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will allow access on the next attempt."
msgstr "ç›®å‰å·²å¤±æ•— %{number_of_failures} 次。GitLab å…許在 %{maximum_failures} 次之內å¯å†å˜—è©¦è®€å– ã€‚"
-msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will block access for %{number_of_seconds} seconds."
-msgstr "已失敗 %{number_of_failures} 次,在失敗 %{maximum_failures} æ¬¡å‰ GitLab 會在 %{number_of_seconds} 秒後é‡è©¦ã€‚"
-
msgid "%{number_of_failures} of %{maximum_failures} failures. GitLab will not retry automatically. Reset storage information when the problem is resolved."
msgstr "已失敗 %{number_of_failures} / %{maximum_failures} 次,GitLab å°‡ä¸å†è‡ªå‹•é‡è©¦ã€‚請在確èªå•é¡Œè§£æ±ºå¾Œæ‰‹å‹•é‡ç½®å„²å­˜ç©ºé–“資訊。"
+msgid "%{openOrClose} %{noteable}"
+msgstr "%{openOrClose} %{noteable}"
+
msgid "%{storage_name}: failed storage access attempt on host:"
msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts:"
msgstr[0] "%{storage_name}:已存å–此主機失敗 %{failed_attempts} 次"
+msgid "%{text} is available"
+msgstr "%{text} å¯ç”¨"
+
msgid "(checkout the %{link} for information on how to install it)."
msgstr "(如何安è£è«‹åƒé–± %{link})"
@@ -70,15 +105,30 @@ msgstr "第一次å”作"
msgid "2FA enabled"
msgstr "已啟用雙é‡èªè­‰"
+msgid "<strong>Removes</strong> source branch"
+msgstr "<strong>刪除</strong>來æºåˆ†æ”¯"
+
msgid "A collection of graphs regarding Continuous Integration"
msgstr "æŒçºŒæ•´åˆ (CI) 相關的圖表"
+msgid "A new branch will be created in your fork and a new merge request will be started."
+msgstr "將會å†å‰µå»ºä¸€å€‹æ–°çš„分支,並建立一個新的åˆä½µè«‹æ±‚。"
+
+msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}."
+msgstr "一個專案æ供了以下功能,存放你的文件(存儲庫),計劃你的工作(è­°é¡Œ),並發布你的文件(維基), %{among_other_things_link}。"
+
+msgid "A user with write access to the source branch selected this option"
+msgstr "一個有存å–原始分支權é™çš„使用者,é¸æ“‡äº†æ­¤é …ç›®"
+
msgid "About auto deploy"
msgstr "關於自動部署"
msgid "Abuse Reports"
msgstr "濫用報告"
+msgid "Abuse reports"
+msgstr ""
+
msgid "Access Tokens"
msgstr "å­˜å–憑證 (access token)"
@@ -88,6 +138,9 @@ msgstr "已暫時åœç”¨å¤±æ•—çš„ Git 儲存空間。當儲存空間æ¢å¾©æ­£å¸¸å
msgid "Account"
msgstr "帳號"
+msgid "Account and limit settings"
+msgstr ""
+
msgid "Active"
msgstr "啟用"
@@ -104,77 +157,287 @@ msgid "Add Contribution guide"
msgstr "新增å”作指å—"
msgid "Add Group Webhooks and GitLab Enterprise Edition."
-msgstr "加入來自 Webhooks 或者是 GitLab ä¼æ¥­ç‰ˆçš„群組"
+msgstr "啟用群組 Webhooks åŠ GitLab ä¼æ¥­ç‰ˆã€‚"
+
+msgid "Add Kubernetes cluster"
+msgstr "增加 Kubernetes å¢é›†"
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 Readme"
+msgstr "增加說明檔案"
msgid "Add new directory"
msgstr "新增目錄"
+msgid "Add todo"
+msgstr "增加待辦事項"
+
+msgid "AdminArea|Stop all jobs"
+msgstr "åœæ­¢æ‰€æœ‰ä»»å‹™"
+
+msgid "AdminArea|Stop all jobs?"
+msgstr "è¦åœæ­¢æ‰€æœ‰ä»»å‹™å—Žï¼Ÿ"
+
+msgid "AdminArea|Stop jobs"
+msgstr "åœæ­¢ä»»å‹™"
+
+msgid "AdminArea|Stopping jobs failed"
+msgstr "åœæ­¢ä»»å‹™å¤±æ•—"
+
+msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
+msgstr "您將åœæ­¢æ‰€æœ‰ä»»å‹™ï¼Œé€™å°‡æœƒæš«åœæ‰€æœ‰æ­£åœ¨é‹è¡Œçš„任務。"
+
msgid "AdminHealthPageLink|health page"
msgstr "系統狀態"
+msgid "AdminProjects|Delete"
+msgstr "刪除"
+
+msgid "AdminProjects|Delete Project %{projectName}?"
+msgstr "刪除專案 %{projectName} ?"
+
+msgid "AdminProjects|Delete project"
+msgstr "刪除專案"
+
+msgid "AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages."
+msgstr ""
+
+msgid "AdminUsers|Block user"
+msgstr "å°éŽ–使用者"
+
+msgid "AdminUsers|Delete User %{username} and contributions?"
+msgstr "刪除使用者 %{username} åŠå…¶è²¢ç»ï¼Ÿ"
+
+msgid "AdminUsers|Delete User %{username}?"
+msgstr "刪除使用者 %{username} ?"
+
+msgid "AdminUsers|Delete user"
+msgstr "刪除使用者"
+
+msgid "AdminUsers|Delete user and contributions"
+msgstr "刪除使用者åŠå…¶è²¢ç»"
+
+msgid "AdminUsers|To confirm, type %{projectName}"
+msgstr "請輸入 %{projectName} 以進行確èª"
+
+msgid "AdminUsers|To confirm, type %{username}"
+msgstr "請輸入 %{username} 以進行確èª"
+
+msgid "Advanced"
+msgstr "進階設置"
+
msgid "Advanced settings"
msgstr "進階設定"
msgid "All"
msgstr "全部"
+msgid "All changes are committed"
+msgstr "所有改變都已經æ交"
+
+msgid "All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings."
+msgstr "從模æ¿å»ºç«‹æˆ–導入專案時將啟用所有功能,您å¯ä»¥åœ¨å°ˆæ¡ˆè¨­ç½®ä¸­å°‡å…¶é—œé–‰ã€‚"
+
+msgid "Allow edits from maintainers."
+msgstr "å…許維護人員編輯。"
+
+msgid "Allow rendering of PlantUML diagrams in Asciidoc documents."
+msgstr ""
+
+msgid "Allow requests to the local network from hooks and services."
+msgstr ""
+
+msgid "Allows you to add and manage Kubernetes clusters."
+msgstr "å…許您增加和管ç†Kuberneteså¢é›†ã€‚"
+
+msgid "Also called \"Issuer\" or \"Relying party trust identifier\""
+msgstr ""
+
+msgid "Also called \"Relying party service URL\" or \"Reply URL\""
+msgstr ""
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr "或者,你å¯ä»¥ä½¿ç”¨ %{personal_access_token_link}。當你建立你的個人存å–權æ–時,你將需è¦é¸æ“‡<code>檔案庫</code>範åœï¼Œæ‰€ä»¥æˆ‘們將會顯示你公開åŠç§äººçš„檔案庫清單進行連çµã€‚"
+
+msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "An error occurred previewing the blob"
+msgstr "é è¦½ blob 檔案時發生錯誤"
+
+msgid "An error occurred when toggling the notification subscription"
+msgstr "切æ›è¨‚閱通知時發生錯誤"
+
+msgid "An error occurred when updating the issue weight"
+msgstr ""
+
+msgid "An error occurred while adding approver"
+msgstr ""
+
+msgid "An error occurred while detecting host keys"
+msgstr ""
+
+msgid "An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again."
+msgstr "解除亮高顯示時發生錯誤,請é‡æ–°æ•´ç†é é¢å†æ¬¡å˜—試。"
+
+msgid "An error occurred while fetching markdown preview"
+msgstr ""
+
+msgid "An error occurred while fetching sidebar data"
+msgstr ""
+
+msgid "An error occurred while fetching the pipeline."
+msgstr ""
+
+msgid "An error occurred while getting projects"
+msgstr ""
+
+msgid "An error occurred while importing project"
+msgstr ""
+
+msgid "An error occurred while initializing path locks"
+msgstr ""
+
+msgid "An error occurred while loading commits"
+msgstr ""
+
+msgid "An error occurred while loading diff"
+msgstr ""
+
+msgid "An error occurred while loading filenames"
+msgstr ""
+
+msgid "An error occurred while loading the file"
+msgstr ""
+
+msgid "An error occurred while making the request."
+msgstr ""
+
+msgid "An error occurred while removing approver"
+msgstr ""
+
+msgid "An error occurred while rendering KaTeX"
+msgstr ""
+
+msgid "An error occurred while rendering preview broadcast message"
+msgstr ""
+
+msgid "An error occurred while retrieving calendar activity"
+msgstr ""
+
+msgid "An error occurred while retrieving diff"
+msgstr ""
+
+msgid "An error occurred while saving LDAP override status. Please try again."
+msgstr ""
+
+msgid "An error occurred while saving assignees"
+msgstr "儲存被指派人時發生錯誤"
+
+msgid "An error occurred while validating username"
+msgstr ""
+
msgid "An error occurred. Please try again."
msgstr "發生錯誤,請å†è©¦ä¸€æ¬¡ã€‚"
+msgid "Any Label"
+msgstr "ä»»æ„標籤"
+
msgid "Appearance"
msgstr "外觀"
msgid "Applications"
msgstr "應用程å¼"
+msgid "Apr"
+msgstr "四月"
+
+msgid "April"
+msgstr "四月"
+
msgid "Archived project! Repository is read-only"
msgstr "此專案已å°å­˜ï¼æª”案庫 (repository) 為唯讀狀態"
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr "確定è¦åˆªé™¤æ­¤æµæ°´ç·š (pipeline) 排程嗎?"
-msgid "Are you sure you want to discard your changes?"
-msgstr "確定è¦æ”¾æ£„修改嗎?"
-
-msgid "Are you sure you want to leave this group?"
-msgstr "確定è¦é›¢é–‹é€™å€‹ç¾¤çµ„嗎?"
-
msgid "Are you sure you want to reset registration token?"
msgstr "確定è¦é‡ç½®è¨»å†Šæ†‘è­‰ (registration token) 嗎?"
msgid "Are you sure you want to reset the health check token?"
msgstr "確定è¦é‡ç½®å¥åº·æª¢æŸ¥å­˜å–憑證 (access token) 嗎?"
+msgid "Are you sure you want to unlock %{path_lock_path}?"
+msgstr ""
+
msgid "Are you sure?"
msgstr "確定嗎?"
msgid "Artifacts"
+msgstr "產物"
+
+msgid "Assertion consumer service URL"
+msgstr ""
+
+msgid "Assign custom color like #FF0000"
+msgstr "自定義é¡è‰²ï¼Œä¾‹å¦‚ #FF0000"
+
+msgid "Assign labels"
+msgstr "指派標籤"
+
+msgid "Assign milestone"
+msgstr "指派里程碑"
+
+msgid "Assign to"
+msgstr "指派給"
+
+msgid "Assigned Issues"
+msgstr ""
+
+msgid "Assigned Merge Requests"
+msgstr ""
+
+msgid "Assigned to :name"
msgstr ""
+msgid "Assignee"
+msgstr "指派人"
+
msgid "Attach a file by drag &amp; drop or %{upload_link}"
msgstr "拖放檔案到此處或者 %{upload_link}"
+msgid "Aug"
+msgstr "八月"
+
+msgid "August"
+msgstr "八月"
+
msgid "Authentication Log"
msgstr "登入紀錄"
msgid "Author"
msgstr "作者"
-msgid "Auto Review Apps and Auto Deploy need a domain name and the %{kubernetes} to work correctly."
-msgstr "自動複閱應用 (review apps) 與自動部署需è¦ç¶²åŸŸå’Œ %{kubernetes} æ‰èƒ½é‹ä½œã€‚"
+msgid "Authors: %{authors}"
+msgstr "作者:%{authors}"
+
+msgid "Auto DevOps enabled"
+msgstr "啟動自動 DevOps"
+
+msgid "Auto DevOps, runners and job artifacts"
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly."
+msgstr ""
+
+msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly."
+msgstr ""
msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly."
msgstr "自動複閱應用 (review apps) 與自動部署需è¦ç¶²åŸŸæ‰èƒ½é‹ä½œã€‚"
-msgid "Auto Review Apps and Auto Deploy need the %{kubernetes} to work correctly."
-msgstr "自動複閱應用 (review apps) èˆ‡è‡ªå‹•éƒ¨ç½²éœ€è¦ %{kubernetes} æ‰èƒ½é‹ä½œã€‚"
-
msgid "AutoDevOps|Auto DevOps (Beta)"
msgstr "DevOps 自動化(beta)"
@@ -190,66 +453,90 @@ msgstr "將根據設定的的 CI / CD æµç¨‹è‡ªå‹•å»ºæ§‹ã€æ¸¬è©¦å’Œéƒ¨ç½²æ‡‰ç”¨
msgid "AutoDevOps|Learn more in the %{link_to_documentation}"
msgstr "了解更多於 %{link_to_documentation}"
-msgid "AutoDevOps|You can activate %{link_to_settings} for this project."
-msgstr "ä½ å¯ä»¥ç‚ºæ­¤å°ˆæ¡ˆå•Ÿå‹• %{link_to_settings}"
+msgid "AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}."
+msgstr ""
+
+msgid "AutoDevOps|add a Kubernetes cluster"
+msgstr ""
+
+msgid "AutoDevOps|enable Auto DevOps (Beta)"
+msgstr "啟用自動 DevOps (測試版)"
+
+msgid "Available"
+msgstr "能é‹åšçš„"
+
+msgid "Avatar will be removed. Are you sure?"
+msgstr "大頭貼將被刪除。你確定嗎?"
+
+msgid "Average per day: %{average}"
+msgstr "å¹³å‡æ¯å¤©ï¼š%{average}"
+
+msgid "Background Color"
+msgstr ""
+
+msgid "Background jobs"
+msgstr ""
+
+msgid "Begin with the selected commit"
+msgstr "從é¸å®šçš„變更紀錄開始"
msgid "Billing"
-msgstr "方案"
+msgstr "帳單"
msgid "BillingPlans|%{group_name} is currently on the %{plan_link} plan."
-msgstr "%{group_name} ç›®å‰ä½¿ç”¨ %{plan_link} 方案。"
+msgstr ""
msgid "BillingPlans|Automatic downgrade and upgrade to some plans is currently not available."
-msgstr "ç›®å‰ç„¡æ³•è‡ªå‹•å‡ç´šæˆ–é™ç´šè‡³å…¶ä»–方案。"
+msgstr ""
msgid "BillingPlans|Current plan"
-msgstr "ç›®å‰æ–¹æ¡ˆ"
+msgstr ""
msgid "BillingPlans|Customer Support"
-msgstr "客戶æœå‹™"
+msgstr ""
msgid "BillingPlans|Downgrade"
-msgstr "é™ç´š"
+msgstr ""
msgid "BillingPlans|Learn more about each plan by reading our %{faq_link}."
-msgstr "了解更多我們的方案,或是閱讀 %{faq_link}"
+msgstr ""
msgid "BillingPlans|Manage plan"
-msgstr "管ç†æ–¹æ¡ˆ"
+msgstr ""
msgid "BillingPlans|Please contact %{customer_support_link} in that case."
-msgstr "è«‹è¯ç¹« %{customer_support_link}"
+msgstr ""
msgid "BillingPlans|See all %{plan_name} features"
-msgstr "查看更多 %{plan_name} 功能"
+msgstr ""
msgid "BillingPlans|This group uses the plan associated with its parent group."
-msgstr "此群組與上層群組使用相åŒçš„方案。"
+msgstr ""
msgid "BillingPlans|To manage the plan for this group, visit the billing section of %{parent_billing_page_link}."
-msgstr "請至 %{parent_billing_page_link} 來管ç†æ­¤ç¾¤çµ„的方案。"
+msgstr ""
msgid "BillingPlans|Upgrade"
-msgstr "å‡ç´š"
+msgstr ""
msgid "BillingPlans|You are currently on the %{plan_link} plan."
-msgstr "ç›®å‰ä½¿ç”¨ %{plan_link} 方案。"
+msgstr ""
msgid "BillingPlans|frequently asked questions"
-msgstr "常見å•é¡Œ"
+msgstr ""
msgid "BillingPlans|monthly"
-msgstr "æ¯å€‹æœˆ"
+msgstr ""
msgid "BillingPlans|paid annually at %{price_per_year}"
-msgstr "æ¯å¹´æ”¶å– %{price_per_year}"
+msgstr ""
msgid "BillingPlans|per user"
-msgstr "æ¯ä½ä½¿ç”¨è€…"
+msgstr ""
-msgid "Branch"
-msgid_plural "Branches"
-msgstr[0] "分支 (branch) "
+msgid "Branch (%{branch_count})"
+msgid_plural "Branches (%{branch_count})"
+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 "已建立分支 (branch) <strong>%{branch_name}</strong> 。如需設定自動部署, 在é¸æ“‡åˆé©çš„ GitLab CI Yaml 模æ¿å¾Œï¼Œè«‹é€äº¤ (commit) 您的編輯內容。%{link_to_autodeploy_doc}"
@@ -257,6 +544,12 @@ msgstr "已建立分支 (branch) <strong>%{branch_name}</strong> 。如需設定
msgid "Branch has changed"
msgstr "分支(branch)已變更"
+msgid "Branch is already taken"
+msgstr ""
+
+msgid "Branch name"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr "æœå°‹åˆ†æ”¯ (branches)"
@@ -266,6 +559,15 @@ msgstr "切æ›åˆ†æ”¯ (branch)"
msgid "Branches"
msgstr "分支 (branch) "
+msgid "Branches|Active"
+msgstr ""
+
+msgid "Branches|Active branches"
+msgstr ""
+
+msgid "Branches|All"
+msgstr "全部"
+
msgid "Branches|Cant find HEAD commit for this branch"
msgstr "找ä¸åˆ°æ­¤åˆ†æ”¯çš„ HEAD 更動。"
@@ -311,14 +613,41 @@ msgstr "一旦你確èªä¸¦æŒ‰ä¸‹ %{delete_protected_branch} 之後,此動作å°
msgid "Branches|Only a project master or owner can delete a protected branch"
msgstr "åªæœ‰å°ˆæ¡ˆç®¡ç†è€…或æ“有者æ‰èƒ½åˆªé™¤è¢«ä¿è­·çš„分支。"
-msgid "Branches|Protected branches can be managed in %{project_settings_link}"
-msgstr "在 %{project_settings_link} 管ç†å—ä¿è­·çš„分支"
+msgid "Branches|Overview"
+msgstr ""
+
+msgid "Branches|Protected branches can be managed in %{project_settings_link}."
+msgstr ""
+
+msgid "Branches|Show active branches"
+msgstr ""
+
+msgid "Branches|Show all branches"
+msgstr ""
+
+msgid "Branches|Show more active branches"
+msgstr ""
+
+msgid "Branches|Show more stale branches"
+msgstr ""
+
+msgid "Branches|Show overview of the branches"
+msgstr ""
+
+msgid "Branches|Show stale branches"
+msgstr ""
msgid "Branches|Sort by"
msgstr "排åºè‡ª"
+msgid "Branches|Stale"
+msgstr ""
+
+msgid "Branches|Stale branches"
+msgstr ""
+
msgid "Branches|The branch could not be updated automatically because it has diverged from its upstream counterpart."
-msgstr "分支無法自動é€äº¤ï¼Œå› ç‚ºèˆ‡ä¸Šæ¸¸åˆ†æ”¯è¡çªã€‚"
+msgstr ""
msgid "Branches|The default branch cannot be deleted"
msgstr "無法刪除é è¨­åˆ†æ”¯"
@@ -339,7 +668,7 @@ msgid "Branches|You’re about to permanently delete the protected branch %{bran
msgstr "你將永久刪除å—ä¿è­·çš„ %{branch_name} 分支。"
msgid "Branches|diverged from upstream"
-msgstr "與上游分歧"
+msgstr ""
msgid "Branches|merged"
msgstr "å·²åˆä½µ"
@@ -362,14 +691,23 @@ msgstr "ç€è¦½æª”案"
msgid "Browse files"
msgstr "ç€è¦½æª”案"
+msgid "Business"
+msgstr ""
+
msgid "ByAuthor|by"
msgstr "作者:"
msgid "CI / CD"
msgstr "CI / CD"
-msgid "CI configuration"
-msgstr "CI 組態"
+msgid "CI/CD"
+msgstr ""
+
+msgid "CI/CD configuration"
+msgstr "CI/CD 設定"
+
+msgid "CI/CD for external repo"
+msgstr ""
msgid "CICD|Jobs"
msgstr "作業"
@@ -377,11 +715,20 @@ msgstr "作業"
msgid "Cancel"
msgstr "å–消"
-msgid "Cancel edit"
-msgstr "å–消編輯"
+msgid "Cannot be merged automatically"
+msgstr ""
+
+msgid "Cannot modify managed Kubernetes cluster"
+msgstr ""
+
+msgid "Certificate fingerprint"
+msgstr ""
msgid "Change Weight"
-msgstr "變更權é‡"
+msgstr ""
+
+msgid "Change this value to influence how frequently the GitLab UI polls for updates."
+msgstr ""
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "挑é¸åˆ°åˆ†æ”¯ (branch) "
@@ -395,23 +742,56 @@ msgstr "挑é¸"
msgid "ChangeTypeAction|Revert"
msgstr "還原"
+msgid "ChangeTypeAction|This will create a new commit in order to revert the existing changes."
+msgstr ""
+
msgid "Changelog"
msgstr "更新日誌"
+msgid "Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision."
+msgstr ""
+
msgid "Charts"
msgstr "統計圖"
msgid "Chat"
msgstr "å³æ™‚通訊"
+msgid "Check interval"
+msgstr ""
+
+msgid "Checking %{text} availability…"
+msgstr ""
+
+msgid "Checking branch availability..."
+msgstr ""
+
msgid "Cherry-pick this commit"
msgstr "挑é¸æ­¤æ›´å‹•è¨˜éŒ„ (commit) "
msgid "Cherry-pick this merge request"
msgstr "挑é¸æ­¤åˆä½µè«‹æ±‚ (merge request) "
-msgid "Choose which groups you wish to replicate to this secondary node. Leave blank to replicate all."
-msgstr "é¸æ“‡ä½ æƒ³è¦è¤‡è£½åˆ°ç¬¬äºŒç¯€é»žçš„群組。若留白則會複製全部的群組。"
+msgid "Choose File ..."
+msgstr "é¸æ“‡æª”案⋯"
+
+msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
+msgstr ""
+
+msgid "Choose file..."
+msgstr "é¸æ“‡æª”案⋯"
+
+msgid "Choose which groups you wish to synchronize to this secondary node."
+msgstr ""
+
+msgid "Choose which repositories you want to connect and run CI/CD pipelines."
+msgstr ""
+
+msgid "Choose which repositories you want to import."
+msgstr ""
+
+msgid "Choose which shards you wish to synchronize to this secondary node."
+msgstr ""
msgid "CiStatusLabel|canceled"
msgstr "å·²å–消"
@@ -467,104 +847,296 @@ msgstr "已略éŽ"
msgid "CiStatus|running"
msgstr "執行中"
+msgid "CiVariables|Input variable key"
+msgstr ""
+
+msgid "CiVariables|Input variable value"
+msgstr ""
+
+msgid "CiVariables|Remove variable row"
+msgstr ""
+
+msgid "CiVariable|* (All environments)"
+msgstr ""
+
+msgid "CiVariable|All environments"
+msgstr ""
+
+msgid "CiVariable|Create wildcard"
+msgstr ""
+
+msgid "CiVariable|Error occured while saving variables"
+msgstr ""
+
+msgid "CiVariable|New environment"
+msgstr ""
+
+msgid "CiVariable|Protected"
+msgstr ""
+
+msgid "CiVariable|Search environments"
+msgstr ""
+
+msgid "CiVariable|Toggle protected"
+msgstr ""
+
+msgid "CiVariable|Validation failed"
+msgstr ""
+
msgid "CircuitBreakerApiLink|circuitbreaker api"
msgstr "斷路器 (circuitbreaker) API"
+msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
+msgstr ""
+
+msgid "Click to expand text"
+msgstr ""
+
+msgid "Client authentication certificate"
+msgstr ""
+
+msgid "Client authentication key"
+msgstr ""
+
+msgid "Client authentication key password"
+msgstr ""
+
msgid "Clone repository"
msgstr "複製(clone)檔案庫(repository)"
msgid "Close"
-msgstr "關閉"
+msgstr ""
-msgid "Cluster"
-msgstr "å¢é›†"
+msgid "Closed"
+msgstr ""
-msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
-msgstr "必須在此帳號下建立 %{link_to_container_project}"
+msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
+msgstr ""
-msgid "ClusterIntegration|Cluster details"
-msgstr "å¢é›†è©³æƒ…"
+msgid "ClusterIntegration|API URL"
+msgstr ""
-msgid "ClusterIntegration|Cluster integration"
-msgstr "å¢é›†æ•´åˆ"
+msgid "ClusterIntegration|Add Kubernetes cluster"
+msgstr ""
-msgid "ClusterIntegration|Cluster integration is disabled for this project."
-msgstr "此專案已經ç¦ç”¨å¢é›†æ•´åˆ"
+msgid "ClusterIntegration|Add an existing Kubernetes cluster"
+msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project."
-msgstr "此專案已經啟用å¢é›†æ•´åˆ"
+msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
+msgstr ""
-msgid "ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab's connection to it."
-msgstr "此專案已啟用å¢é›†æ•´åˆã€‚ç¦æ­¢å¢é›†æ•´åˆä¸æœƒå½±éŸ¿æ‚¨çš„å¢é›†ï¼Œå®ƒåªæ˜¯æš«æ™‚關閉 GitLab 的連接。"
+msgid "ClusterIntegration|Applications"
+msgstr ""
-msgid "ClusterIntegration|Cluster is being created on Google Kubernetes Engine..."
-msgstr "在 Google 容器引擎中建立新的å¢é›†"
+msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster."
+msgstr ""
-msgid "ClusterIntegration|Cluster name"
-msgstr "å¢é›†å稱"
+msgid "ClusterIntegration|CA Certificate"
+msgstr ""
-msgid "ClusterIntegration|Cluster was successfully created on Google Kubernetes Engine"
-msgstr "在 Google 容器引擎上æˆåŠŸå»ºç«‹å¢é›†"
+msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
+msgstr ""
-msgid "ClusterIntegration|Copy cluster name"
-msgstr "複製å¢é›†å稱"
+msgid "ClusterIntegration|Choose how to set up Kubernetes cluster integration"
+msgstr ""
-msgid "ClusterIntegration|Create cluster"
-msgstr "建立å¢é›†"
+msgid "ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Control how your Kubernetes cluster integrates with GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Copy API URL"
+msgstr ""
+
+msgid "ClusterIntegration|Copy CA Certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Ingress IP Address to clipboard"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Copy Token"
+msgstr ""
+
+msgid "ClusterIntegration|Create Kubernetes cluster"
+msgstr ""
-msgid "ClusterIntegration|Create new cluster on Google Kubernetes Engine"
-msgstr "在 Google 容器引擎中建立新的å¢é›†"
+msgid "ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab"
+msgstr ""
+
+msgid "ClusterIntegration|Create on GKE"
+msgstr ""
+
+msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster"
+msgstr ""
-msgid "ClusterIntegration|Enable cluster integration"
-msgstr "å•Ÿå‹•å¢é›†æ•´åˆ"
+msgid "ClusterIntegration|Enter the details for your Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Environment scope"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Integration"
+msgstr ""
+
+msgid "ClusterIntegration|GitLab Runner"
+msgstr ""
msgid "ClusterIntegration|Google Cloud Platform project ID"
msgstr "Google 雲端專案 ID"
msgid "ClusterIntegration|Google Kubernetes Engine"
-msgstr "Google 容器引擎"
+msgstr ""
msgid "ClusterIntegration|Google Kubernetes Engine project"
-msgstr "Google 容器引擎專案"
+msgstr ""
+
+msgid "ClusterIntegration|Helm Tiller"
+msgstr ""
+
+msgid "ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data."
+msgstr ""
+
+msgid "ClusterIntegration|Ingress"
+msgstr ""
+
+msgid "ClusterIntegration|Ingress IP Address"
+msgstr ""
+
+msgid "ClusterIntegration|Install"
+msgstr ""
+
+msgid "ClusterIntegration|Install Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Installed"
+msgstr ""
+
+msgid "ClusterIntegration|Installing"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgstr ""
+
+msgid "ClusterIntegration|Integration status"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster health"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is disabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster integration is enabled for this project. Disabling this integration will not affect your Kubernetes cluster, it will only temporarily turn off GitLab's connection to it."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine..."
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster name"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}"
+msgstr ""
+
+msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
+msgstr ""
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr "學習更多有關於%{link_to_documentation}"
+msgid "ClusterIntegration|Learn more about environments"
+msgstr ""
+
+msgid "ClusterIntegration|Learn more about security configuration"
+msgstr ""
+
msgid "ClusterIntegration|Machine type"
msgstr "機器型別"
-msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
-msgstr "請確èªæ‚¨çš„帳戶中%{link_to_requirements} 是å¦å»ºç«‹å¢é›†"
+msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
+msgstr ""
+
+msgid "ClusterIntegration|Manage"
+msgstr ""
-msgid "ClusterIntegration|Manage Cluster integration on your GitLab project"
-msgstr "在你的 GitLab 專案上管ç†å¢é›†æ•´åˆ"
+msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}"
+msgstr ""
-msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}"
-msgstr "請至 %{link_gke} 管ç†ä½ çš„å¢é›†"
+msgid "ClusterIntegration|More information"
+msgstr ""
+
+msgid "ClusterIntegration|Multiple Kubernetes clusters are available in GitLab Enterprise Edition Premium and Ultimate"
+msgstr ""
+
+msgid "ClusterIntegration|Note:"
+msgstr ""
msgid "ClusterIntegration|Number of nodes"
msgstr "所有的端點數é‡"
+msgid "ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes"
+msgstr ""
+
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr "請確èªä½ çš„ Google 帳號是å¦ç¬¦åˆé€™äº›æ¢ä»¶"
+msgid "ClusterIntegration|Project ID"
+msgstr ""
+
+msgid "ClusterIntegration|Project namespace"
+msgstr ""
+
msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr "專案命å空間(é¸å¡«ï¼Œä¸å¯é‡è¤‡ï¼‰"
-msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
+msgid "ClusterIntegration|Prometheus"
+msgstr ""
+
+msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration."
msgstr ""
-msgid "ClusterIntegration|Remove cluster integration"
-msgstr "刪除å¢é›†æ•´åˆ"
+msgid "ClusterIntegration|Remove Kubernetes cluster integration"
+msgstr ""
msgid "ClusterIntegration|Remove integration"
msgstr "刪除整åˆ"
-msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
-msgstr "當刪除å¢é›†éœ€è¦åŠ å…¥å°ˆæ¡ˆçš„定義組態檔,會刪除å¢é›†æ•´åˆã€‚這並ä¸æœƒåˆªé™¤ä½ çš„專案。 刪除å¢é›†çš„åŒæ™‚,將一起刪除已加入此專案的定義組態檔,但你的專案ä¸æœƒå› æ­¤è¢«åˆªé™¤ã€‚"
+msgid "ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster."
+msgstr ""
+
+msgid "ClusterIntegration|Request to begin installing failed"
+msgstr ""
+
+msgid "ClusterIntegration|Save changes"
+msgstr ""
+
+msgid "ClusterIntegration|Security"
+msgstr ""
-msgid "ClusterIntegration|See and edit the details for your cluster"
-msgstr "查看與編輯你的å¢é›†å…§å®¹"
+msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster"
+msgstr ""
msgid "ClusterIntegration|See machine types"
msgstr "查看機器型別"
@@ -575,39 +1147,75 @@ msgstr "查看您的專案"
msgid "ClusterIntegration|See zones"
msgstr "查看å€åŸŸ"
+msgid "ClusterIntegration|Service token"
+msgstr ""
+
+msgid "ClusterIntegration|Show"
+msgstr ""
+
msgid "ClusterIntegration|Something went wrong on our end."
msgstr "內部發生了錯誤"
-msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Kubernetes Engine"
-msgstr "在 Google Kubernetes Engine 上建立å¢é›†æ™‚發生了錯誤"
+msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|Something went wrong while installing %{title}"
+msgstr ""
+
+msgid "ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application."
+msgstr ""
-msgid "ClusterIntegration|Toggle Cluster"
-msgstr "å¢é›†é–‹é—œ"
+msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below"
+msgstr ""
-msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
-msgstr "當å¢é›†é€£çµåˆ°æ­¤å°ˆæ¡ˆï¼Œä½ å¯ä»¥ä½¿ç”¨è¤‡é–±æ‡‰ç”¨ (review apps),部署你的應用程å¼ï¼ŒåŸ·è¡Œä½ çš„æµæ°´ç·š (pipelines),還有更多容易上手的方å¼å¯ä»¥ä½¿ç”¨ã€‚"
+msgid "ClusterIntegration|Toggle Kubernetes Cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Toggle Kubernetes cluster"
+msgstr ""
+
+msgid "ClusterIntegration|Token"
+msgstr ""
+
+msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
+msgstr ""
msgid "ClusterIntegration|Your account must have %{link_to_kubernetes_engine}"
-msgstr "您的帳號必須有 %{link_to_kubernetes_engine}"
+msgstr ""
msgid "ClusterIntegration|Zone"
msgstr "å€åŸŸ"
msgid "ClusterIntegration|access to Google Kubernetes Engine"
-msgstr "å­˜å– Google Kubernetes Engine"
+msgstr ""
+
+msgid "ClusterIntegration|check the pricing here"
+msgstr ""
-msgid "ClusterIntegration|cluster"
-msgstr "å¢é›†"
+msgid "ClusterIntegration|documentation"
+msgstr ""
msgid "ClusterIntegration|help page"
msgstr "說明é é¢"
+msgid "ClusterIntegration|installing applications"
+msgstr ""
+
msgid "ClusterIntegration|meets the requirements"
msgstr "符åˆéœ€æ±‚"
msgid "ClusterIntegration|properly configured"
msgstr "設定正確"
+msgid "Collapse"
+msgstr ""
+
+msgid "Comment and resolve discussion"
+msgstr ""
+
+msgid "Comment and unresolve discussion"
+msgstr ""
+
msgid "Comments"
msgstr "留言"
@@ -615,9 +1223,9 @@ msgid "Commit"
msgid_plural "Commits"
msgstr[0] "更動記錄 (commit) "
-msgid "Commit %d file"
-msgid_plural "Commit %d files"
-msgstr[0] "æ交 %d 個檔案"
+msgid "Commit (%{commit_count})"
+msgid_plural "Commits (%{commit_count})"
+msgstr[0] ""
msgid "Commit Message"
msgstr "更動訊æ¯"
@@ -628,6 +1236,12 @@ msgstr "最近 30 次更動所花費的時間(分é˜ï¼‰"
msgid "Commit message"
msgstr "更動說明 (commit) "
+msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
+msgstr ""
+
+msgid "Commit to %{branchName} branch"
+msgstr ""
+
msgid "CommitBoxTitle|Commit"
msgstr "é€äº¤"
@@ -640,15 +1254,99 @@ msgstr "更動記錄 (commit) "
msgid "Commits feed"
msgstr "æ›´å‹•æ‘˜è¦ (commit feed)"
+msgid "Commits per day hour (UTC)"
+msgstr ""
+
+msgid "Commits per day of month"
+msgstr ""
+
+msgid "Commits per weekday"
+msgstr ""
+
+msgid "Commits|An error occurred while fetching merge requests data."
+msgstr ""
+
+msgid "Commits|Commit: %{commitText}"
+msgstr ""
+
msgid "Commits|History"
msgstr "更動紀錄 (commit)"
+msgid "Commits|No related merge requests found"
+msgstr ""
+
msgid "Committed by"
msgstr "é€äº¤è€…為 "
msgid "Compare"
msgstr "比較"
+msgid "Compare Git revisions"
+msgstr ""
+
+msgid "Compare Revisions"
+msgstr ""
+
+msgid "Compare changes with the last commit"
+msgstr ""
+
+msgid "Compare changes with the merge request target branch"
+msgstr ""
+
+msgid "CompareBranches|%{source_branch} and %{target_branch} are the same."
+msgstr ""
+
+msgid "CompareBranches|Compare"
+msgstr ""
+
+msgid "CompareBranches|Source"
+msgstr ""
+
+msgid "CompareBranches|Target"
+msgstr ""
+
+msgid "CompareBranches|There isn't anything to compare."
+msgstr ""
+
+msgid "Confidential"
+msgstr ""
+
+msgid "Confidentiality"
+msgstr ""
+
+msgid "Configure Gitaly timeouts."
+msgstr ""
+
+msgid "Configure Sidekiq job throttling."
+msgstr ""
+
+msgid "Configure automatic git checks and housekeeping on repositories."
+msgstr ""
+
+msgid "Configure limits for web and API requests."
+msgstr ""
+
+msgid "Configure storage path and circuit breaker settings."
+msgstr ""
+
+msgid "Configure the way a user creates a new account."
+msgstr ""
+
+msgid "Connect"
+msgstr ""
+
+msgid "Connect all repositories"
+msgstr ""
+
+msgid "Connect repositories from GitHub"
+msgstr ""
+
+msgid "Connect your external repositories, and CI/CD pipelines will run for new commits. A GitLab project will be created with only CI/CD features enabled."
+msgstr ""
+
+msgid "Connecting..."
+msgstr ""
+
msgid "Container Registry"
msgstr "Container Registry"
@@ -694,45 +1392,96 @@ msgstr "使用ä¸åŒçš„映åƒæª”å稱"
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images."
msgstr "å°‡ Docker Container Registry æ•´åˆåˆ° GitLab 中後,æ¯å€‹å°ˆæ¡ˆéƒ½å¯ä»¥æœ‰è‡ªå·±çš„空間來儲存 Docker 的映åƒæª”"
+msgid "Continuous Integration and Deployment"
+msgstr ""
+
+msgid "Contribution"
+msgstr ""
+
msgid "Contribution guide"
msgstr "å”作指å—"
msgid "Contributors"
msgstr "å”作者"
+msgid "ContributorsPage|%{startDate} – %{endDate}"
+msgstr ""
+
+msgid "ContributorsPage|Building repository graph."
+msgstr ""
+
+msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
+msgstr ""
+
+msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
+msgstr ""
+
msgid "Control the maximum concurrency of LFS/attachment backfill for this secondary node"
-msgstr "控制次節點 (secondary node) åŒæ­¥ LFS 和附檔的最大並行率 (concurrency)"
+msgstr ""
msgid "Control the maximum concurrency of repository backfill for this secondary node"
-msgstr "控制次節點 (secondary node) åŒæ­¥æª”案庫 (repository) 的最大並行率 (concurrency)"
+msgstr ""
msgid "Copy SSH public key to clipboard"
-msgstr "複製 SSH 金鑰到剪貼簿"
+msgstr ""
msgid "Copy URL to clipboard"
msgstr "複製網å€åˆ°å‰ªè²¼ç°¿"
+msgid "Copy branch name to clipboard"
+msgstr ""
+
+msgid "Copy command to clipboard"
+msgstr ""
+
msgid "Copy commit SHA to clipboard"
msgstr "複製更動記錄 (commit) 的 SHA 值到剪貼簿"
+msgid "Copy reference to clipboard"
+msgstr ""
+
+msgid "Create"
+msgstr ""
+
msgid "Create New Directory"
msgstr "建立新目錄"
+msgid "Create a new branch"
+msgstr ""
+
+msgid "Create a new branch and merge request"
+msgstr ""
+
msgid "Create a personal access token on your account to pull or push via %{protocol}."
msgstr "建立個人存å–憑證 (access token) 以使用 %{protocol} 來上傳 (push) 或下載 (pull) 。"
+msgid "Create branch"
+msgstr ""
+
msgid "Create directory"
msgstr "建立目錄"
-msgid "Create empty bare repository"
-msgstr "建立一個新的 bare repository"
+msgid "Create empty repository"
+msgstr ""
+
+msgid "Create epic"
+msgstr ""
msgid "Create file"
msgstr "新增檔案"
+msgid "Create group label"
+msgstr ""
+
+msgid "Create lists from labels. Issues with that label appear in that list."
+msgstr ""
+
msgid "Create merge request"
msgstr "發出åˆä½µè«‹æ±‚ (merge request) "
+msgid "Create merge request and branch"
+msgstr ""
+
msgid "Create new branch"
msgstr "新增分支(branch)"
@@ -742,9 +1491,15 @@ msgstr "新增資料夾"
msgid "Create new file"
msgstr "新增檔案"
+msgid "Create new label"
+msgstr ""
+
msgid "Create new..."
msgstr "建立..."
+msgid "Create project label"
+msgstr ""
+
msgid "CreateNewFork|Fork"
msgstr "分支 (fork) "
@@ -754,24 +1509,36 @@ msgstr "建立標籤"
msgid "CreateTokenToCloneLink|create a personal access token"
msgstr "建立個人存å–憑證 (access token)"
+msgid "Creates a new branch from %{branchName}"
+msgstr ""
+
+msgid "Creates a new branch from %{branchName} and re-directs to create a new merge request"
+msgstr ""
+
+msgid "Creating epic"
+msgstr ""
+
msgid "Cron Timezone"
msgstr "Cron 時å€"
msgid "Cron syntax"
msgstr "Cron 語法"
+msgid "Current node"
+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 "Customize colors"
+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 "程å¼é–‹ç™¼"
@@ -799,6 +1566,15 @@ msgstr "全部"
msgid "DashboardProjects|Personal"
msgstr "個人"
+msgid "Dec"
+msgstr ""
+
+msgid "December"
+msgstr ""
+
+msgid "Default classification label"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr "使用 Cron 語法自訂排程"
@@ -816,16 +1592,25 @@ msgid "Description"
msgstr "æè¿°"
msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
-msgstr "æ述範本 (Description templates) 讓你在建立專案的議題 (Issue) å’Œåˆä½µè«‹æ±‚時å¯ä»¥é¸æ“‡ç‰¹å®šçš„範本。"
+msgstr ""
msgid "Details"
msgstr "細節"
+msgid "Diffs|No file name available"
+msgstr ""
+
msgid "Directory name"
msgstr "目錄å稱"
-msgid "Discard changes"
-msgstr "放棄修改"
+msgid "Disable"
+msgstr ""
+
+msgid "Discard draft"
+msgstr ""
+
+msgid "Discover GitLab Geo."
+msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
msgstr "關閉循環分æžä»‹ç´¹è¦–窗"
@@ -833,9 +1618,15 @@ msgstr "關閉循環分æžä»‹ç´¹è¦–窗"
msgid "Dismiss Merge Request promotion"
msgstr ""
+msgid "Documentation for popular identity providers"
+msgstr ""
+
msgid "Don't show again"
msgstr "ä¸å†é¡¯ç¤º"
+msgid "Done"
+msgstr ""
+
msgid "Download"
msgstr "下載"
@@ -863,15 +1654,171 @@ msgstr "差異檔 (diff)"
msgid "DownloadSource|Download"
msgstr "下載原始碼"
+msgid "Downvotes"
+msgstr ""
+
+msgid "Due date"
+msgstr ""
+
+msgid "During this process, you’ll be asked for URLs from GitLab’s side. Use the URLs shown below."
+msgstr ""
+
msgid "Edit"
msgstr "編輯"
msgid "Edit Pipeline Schedule %{id}"
msgstr "編輯 %{id} æµæ°´ç·š (pipeline) 排程"
+msgid "Edit files in the editor and commit changes here"
+msgstr ""
+
+msgid "Editing"
+msgstr ""
+
+msgid "Elasticsearch"
+msgstr ""
+
+msgid "Elasticsearch intergration. Elasticsearch AWS IAM."
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
msgid "Emails"
msgstr "é›»å­éƒµä»¶"
+msgid "Enable"
+msgstr ""
+
+msgid "Enable Auto DevOps"
+msgstr ""
+
+msgid "Enable SAML authentication for this group"
+msgstr ""
+
+msgid "Enable Sentry for error reporting and logging."
+msgstr ""
+
+msgid "Enable and configure InfluxDB metrics."
+msgstr ""
+
+msgid "Enable and configure Prometheus metrics."
+msgstr ""
+
+msgid "Enable classification control using an external service"
+msgstr ""
+
+msgid "Enable or disable version check and usage ping."
+msgstr ""
+
+msgid "Enable reCAPTCHA or Akismet and set IP limits."
+msgstr ""
+
+msgid "Enable the Performance Bar for a given group."
+msgstr ""
+
+msgid "Enabled"
+msgstr ""
+
+msgid "Environments|An error occurred while fetching the environments."
+msgstr ""
+
+msgid "Environments|An error occurred while making the request."
+msgstr ""
+
+msgid "Environments|Commit"
+msgstr ""
+
+msgid "Environments|Deployment"
+msgstr ""
+
+msgid "Environments|Environment"
+msgstr ""
+
+msgid "Environments|Environments"
+msgstr ""
+
+msgid "Environments|Job"
+msgstr ""
+
+msgid "Environments|New environment"
+msgstr ""
+
+msgid "Environments|No deployments yet"
+msgstr ""
+
+msgid "Environments|Open"
+msgstr ""
+
+msgid "Environments|Re-deploy"
+msgstr ""
+
+msgid "Environments|Read more about environments"
+msgstr ""
+
+msgid "Environments|Rollback"
+msgstr ""
+
+msgid "Environments|Show all"
+msgstr ""
+
+msgid "Environments|Updated"
+msgstr ""
+
+msgid "Environments|You don't have any environments right now."
+msgstr ""
+
+msgid "Epic will be removed! Are you sure?"
+msgstr ""
+
+msgid "Epics"
+msgstr ""
+
+msgid "Epics Roadmap"
+msgstr ""
+
+msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "Error Reporting and Logging"
+msgstr ""
+
+msgid "Error checking branch data. Please try again."
+msgstr ""
+
+msgid "Error committing changes. Please try again."
+msgstr ""
+
+msgid "Error creating epic"
+msgstr ""
+
+msgid "Error fetching contributors data."
+msgstr ""
+
+msgid "Error fetching labels."
+msgstr ""
+
+msgid "Error fetching network graph."
+msgstr ""
+
+msgid "Error fetching refs"
+msgstr ""
+
+msgid "Error fetching usage ping data."
+msgstr ""
+
+msgid "Error occurred when toggling the notification subscription"
+msgstr ""
+
+msgid "Error saving label update."
+msgstr ""
+
+msgid "Error updating status for all todos."
+msgstr ""
+
+msgid "Error updating todo status."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr "顯示全部"
@@ -899,24 +1846,75 @@ msgstr "æ¯æœˆåŸ·è¡Œï¼ˆæ¯æœˆä¸€æ—¥æ·©æ™¨å››é»žï¼‰"
msgid "Every week (Sundays at 4:00am)"
msgstr "æ¯é€±åŸ·è¡Œï¼ˆé€±æ—¥æ·©æ™¨ 四點)"
+msgid "Expand"
+msgstr ""
+
msgid "Explore projects"
msgstr "ç€è¦½å°ˆæ¡ˆ"
msgid "Explore public groups"
msgstr "æœå°‹å…¬é–‹çš„群組"
+msgid "External Classification Policy Authorization"
+msgstr ""
+
+msgid "External authentication"
+msgstr ""
+
+msgid "External authorization denied access to this project"
+msgstr ""
+
+msgid "External authorization request timeout"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification Label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|Classification label"
+msgstr ""
+
+msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used."
+msgstr ""
+
+msgid "Failed"
+msgstr ""
+
+msgid "Failed Jobs"
+msgstr ""
+
msgid "Failed to change the owner"
msgstr "無法變更所有權"
+msgid "Failed to remove issue from board, please try again."
+msgstr ""
+
msgid "Failed to remove the pipeline schedule"
msgstr "無法刪除æµæ°´ç·š (pipeline) 排程"
+msgid "Failed to update issues, please try again."
+msgstr ""
+
+msgid "Feb"
+msgstr ""
+
+msgid "February"
+msgstr ""
+
+msgid "Fields on this page are now uneditable, you can configure"
+msgstr ""
+
msgid "File name"
msgstr "檔案å稱"
msgid "Files"
msgstr "檔案"
+msgid "Files (%{human_size})"
+msgstr ""
+
+msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
+msgstr ""
+
msgid "Filter by commit message"
msgstr "以更動說明篩é¸"
@@ -926,12 +1924,21 @@ msgstr "以路徑æœå°‹"
msgid "Find file"
msgstr "æœå°‹æª”案"
+msgid "Finished"
+msgstr ""
+
msgid "FirstPushedBy|First"
msgstr "é¦–æ¬¡æŽ¨é€ (push) "
msgid "FirstPushedBy|pushed by"
msgstr "推é€è€… (push) :"
+msgid "Font Color"
+msgstr ""
+
+msgid "Footer message"
+msgstr ""
+
msgid "Fork"
msgid_plural "Forks"
msgstr[0] "分支 (fork) "
@@ -942,39 +1949,225 @@ msgstr "分支 (fork) 自"
msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)"
msgstr "從 %{project_name} Fork. (deleted)"
+msgid "Forking in progress"
+msgstr ""
+
msgid "Format"
msgstr "æ ¼å¼"
+msgid "From %{provider_title}"
+msgstr ""
+
msgid "From issue creation until deploy to production"
msgstr "從議題 (issue) 建立直到部署至營é‹ç’°å¢ƒ"
msgid "From merge request merge until deploy to production"
msgstr "從請求被åˆä½µå¾Œ (merge request merged) 直到部署至營é‹ç’°å¢ƒ"
+msgid "From the Kubernetes cluster details view, install Runner from the applications list"
+msgstr ""
+
msgid "GPG Keys"
msgstr "GPG 金鑰"
+msgid "Generate a default set of labels"
+msgstr ""
+
msgid "Geo Nodes"
-msgstr "Geo 節點"
+msgstr ""
+
+msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is failing or broken."
+msgstr ""
+
+msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
+msgstr ""
+
+msgid "GeoNodes|Checksummed"
+msgstr ""
+
+msgid "GeoNodes|Database replication lag:"
+msgstr ""
+
+msgid "GeoNodes|Disabling a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Does not match the primary storage configuration"
+msgstr ""
+
+msgid "GeoNodes|Failed"
+msgstr ""
+
+msgid "GeoNodes|Full"
+msgstr ""
+
+msgid "GeoNodes|GitLab version does not match the primary node version"
+msgstr ""
+
+msgid "GeoNodes|GitLab version:"
+msgstr ""
+
+msgid "GeoNodes|Health status:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID processed by cursor:"
+msgstr ""
+
+msgid "GeoNodes|Last event ID seen from primary:"
+msgstr ""
+
+msgid "GeoNodes|Loading nodes"
+msgstr ""
+
+msgid "GeoNodes|Local Attachments:"
+msgstr ""
+
+msgid "GeoNodes|Local LFS objects:"
+msgstr ""
+
+msgid "GeoNodes|Local job artifacts:"
+msgstr ""
+
+msgid "GeoNodes|New node"
+msgstr ""
+
+msgid "GeoNodes|Node Authentication was successfully repaired."
+msgstr ""
+
+msgid "GeoNodes|Node was successfully removed."
+msgstr ""
+
+msgid "GeoNodes|Not checksummed"
+msgstr ""
+
+msgid "GeoNodes|Out of sync"
+msgstr ""
+
+msgid "GeoNodes|Removing a node stops the sync process. Are you sure?"
+msgstr ""
+
+msgid "GeoNodes|Replication slot WAL:"
+msgstr ""
+
+msgid "GeoNodes|Replication slots:"
+msgstr ""
+
+msgid "GeoNodes|Repositories checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Repositories:"
+msgstr ""
+
+msgid "GeoNodes|Repository checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Selective"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while changing node status"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while removing node"
+msgstr ""
+
+msgid "GeoNodes|Something went wrong while repairing node"
+msgstr ""
+
+msgid "GeoNodes|Storage config:"
+msgstr ""
+
+msgid "GeoNodes|Sync settings:"
+msgstr ""
+
+msgid "GeoNodes|Synced"
+msgstr ""
+
+msgid "GeoNodes|Unused slots"
+msgstr ""
+
+msgid "GeoNodes|Unverified"
+msgstr ""
+
+msgid "GeoNodes|Used slots"
+msgstr ""
+
+msgid "GeoNodes|Verified"
+msgstr ""
+
+msgid "GeoNodes|Wiki checksums verified:"
+msgstr ""
+
+msgid "GeoNodes|Wikis checksummed:"
+msgstr ""
+
+msgid "GeoNodes|Wikis:"
+msgstr ""
+
+msgid "GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS."
+msgstr ""
+
+msgid "Geo|All projects"
+msgstr ""
msgid "Geo|File sync capacity"
-msgstr "檔案åŒæ­¥å®¹é‡"
+msgstr ""
+
+msgid "Geo|Groups to synchronize"
+msgstr ""
-msgid "Geo|Groups to replicate"
-msgstr "è¦è¤‡è£½çš„群組"
+msgid "Geo|Projects in certain groups"
+msgstr ""
+
+msgid "Geo|Projects in certain storage shards"
+msgstr ""
msgid "Geo|Repository sync capacity"
-msgstr "檔案庫(repository)åŒæ­¥é‡"
+msgstr ""
msgid "Geo|Select groups to replicate."
-msgstr "é¸æ“‡æ¬²è¤‡è£½ä¹‹ç¾¤çµ„。"
+msgstr ""
+
+msgid "Geo|Shards to synchronize"
+msgstr ""
+
+msgid "Git repository URL"
+msgstr ""
+
+msgid "Git revision"
+msgstr ""
msgid "Git storage health information has been reset"
msgstr "Git 儲存空間å¥åº·æŒ‡æ•¸å·²é‡ç½®"
+msgid "Git version"
+msgstr ""
+
+msgid "GitHub import"
+msgstr ""
+
+msgid "GitLab CI Linter has been moved"
+msgstr ""
+
+msgid "GitLab Geo"
+msgstr ""
+
msgid "GitLab Runner section"
msgstr "GitLab Runner"
+msgid "GitLab single sign on URL"
+msgstr ""
+
+msgid "Gitaly"
+msgstr ""
+
+msgid "Gitaly Servers"
+msgstr ""
+
+msgid "Go back"
+msgstr ""
+
msgid "Go to your fork"
msgstr "å‰å¾€æ‚¨çš„分支 (fork) "
@@ -984,6 +2177,27 @@ msgstr "å‰å¾€æ‚¨çš„分支 (fork) "
msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service."
msgstr "Google 身份驗證ä¸æ˜¯ %{link_to_documentation}。如果您想使用此æœå‹™ï¼Œè«‹è«®è©¢ç®¡ç†å“¡ã€‚"
+msgid "Got it!"
+msgstr ""
+
+msgid "GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort"
+msgstr ""
+
+msgid "GroupRoadmap|From %{dateWord}"
+msgstr ""
+
+msgid "GroupRoadmap|Loading roadmap"
+msgstr ""
+
+msgid "GroupRoadmap|Something went wrong while fetching epics"
+msgstr ""
+
+msgid "GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}."
+msgstr ""
+
+msgid "GroupRoadmap|Until %{dateWord}"
+msgstr ""
+
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr "ç¦æ­¢èˆ‡å…¶ä»–群組共享 %{group} 中的專案"
@@ -1020,12 +2234,6 @@ msgstr "找ä¸åˆ°ç¾¤çµ„"
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "ä½ å¯ä»¥ç®¡ç†ç¾¤çµ„內所有æˆå“¡çš„æ¯å€‹å°ˆæ¡ˆçš„å­˜å–權é™"
-msgid "GroupsTreeRole|as"
-msgstr "çš„"
-
-msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
-msgstr "你確定è¦é›¢é–‹ç¾¤çµ„ \"${this.group.fullName}\" 嗎?"
-
msgid "GroupsTree|Create a project in this group."
msgstr "在此群組建立新的專案"
@@ -1053,6 +2261,12 @@ msgstr "ä¸å¥½æ„æ€ï¼Œæ²’有æœå°‹åˆ°ä»»ä½•ç¬¦åˆæ¢ä»¶çš„群組"
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgstr "ä¸å¥½æ„æ€ï¼Œæ²’有æœå°‹åˆ°ä»»ä½•ç¬¦åˆæ¢ä»¶çš„群組或專案"
+msgid "Have your users email"
+msgstr ""
+
+msgid "Header message"
+msgstr ""
+
msgid "Health Check"
msgstr "å¥åº·æª¢æŸ¥"
@@ -1071,30 +2285,85 @@ msgstr "沒有檢測到å¥åº·å•é¡Œ"
msgid "HealthCheck|Unhealthy"
msgstr "ä¸è‰¯"
+msgid "Help"
+msgstr ""
+
+msgid "Help page"
+msgstr ""
+
+msgid "Help page text and support page url."
+msgstr ""
+
+msgid "Hide value"
+msgid_plural "Hide values"
+msgstr[0] ""
+
msgid "History"
msgstr "æ­·å²"
msgid "Housekeeping successfully started"
msgstr "已開始維護"
+msgid "Identity provider single sign on URL"
+msgstr ""
+
+msgid "If enabled, access to projects will be validated on an external service using their classification label."
+msgstr ""
+
+msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
+msgstr ""
+
+msgid "If you already have files you can push them using the %{link_to_cli} below."
+msgstr ""
+
+msgid "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>."
+msgstr ""
+
+msgid "Import"
+msgstr ""
+
+msgid "Import all repositories"
+msgstr ""
+
+msgid "Import in progress"
+msgstr ""
+
+msgid "Import repositories from GitHub"
+msgstr ""
+
msgid "Import repository"
msgstr "匯入檔案庫 (repository)"
+msgid "ImportButtons|Connect repositories from"
+msgstr ""
+
msgid "Improve Issue boards with GitLab Enterprise Edition."
-msgstr "å”助改進 GitLab ä¼æ¥­ç‰ˆçš„議題看æ¿ï¼ˆissue boards)"
+msgstr ""
msgid "Improve issues management with Issue weight and GitLab Enterprise Edition."
-msgstr "å”助改進 GitLab ä¼æ¥­ç‰ˆçš„議題管ç†èˆ‡æ¬Šé‡ã€‚"
+msgstr ""
msgid "Improve search with Advanced Global Search and GitLab Enterprise Edition."
-msgstr "å”助改進 GitLab ä¼æ¥­ç‰ˆçš„æœå°‹ & 進階全局æœå°‹ã€‚"
+msgstr ""
+
+msgid "Install Runner on Kubernetes"
+msgstr ""
msgid "Install a Runner compatible with GitLab CI"
msgstr "安è£èˆ‡ GitLab CI 相容的 Runner"
msgid "Instance"
msgid_plural "Instances"
-msgstr[0] "主機"
+msgstr[0] ""
+
+msgid "Instance does not support multiple Kubernetes clusters"
+msgstr ""
+
+msgid "Integrations"
+msgstr ""
+
+msgid "Interested parties can even contribute by pushing commits if they want to."
+msgstr ""
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
msgstr "內部 - 任何登入的使用者都å¯ä»¥æŸ¥çœ‹è©²ç¾¤çµ„åŠå…¶å°ˆæ¡ˆ"
@@ -1109,10 +2378,7 @@ msgid "Introducing Cycle Analytics"
msgstr "週期分æžç°¡ä»‹"
msgid "Issue board focus mode"
-msgstr "議題看æ¿ï¼ˆissue boards)模å¼"
-
-msgid "Issue boards with milestones"
-msgstr "議題看æ¿ï¼ˆissue boards)與里程碑"
+msgstr ""
msgid "Issue events"
msgstr "議題 (issue) 事件"
@@ -1121,20 +2387,92 @@ msgid "IssueBoards|Board"
msgstr "看æ¿"
msgid "IssueBoards|Boards"
-msgstr "看æ¿"
+msgstr ""
msgid "Issues"
msgstr "議題"
+msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
+msgstr ""
+
+msgid "Jan"
+msgstr ""
+
+msgid "January"
+msgstr ""
+
+msgid "Jobs"
+msgstr ""
+
+msgid "Jul"
+msgstr ""
+
+msgid "July"
+msgstr ""
+
+msgid "Jun"
+msgstr ""
+
+msgid "June"
+msgstr ""
+
+msgid "Koding"
+msgstr ""
+
+msgid "Kubernetes"
+msgstr ""
+
+msgid "Kubernetes Cluster"
+msgstr ""
+
+msgid "Kubernetes cluster creation time exceeds timeout; %{timeout}"
+msgstr ""
+
+msgid "Kubernetes cluster integration was not removed."
+msgstr ""
+
+msgid "Kubernetes cluster integration was successfully removed."
+msgstr ""
+
+msgid "Kubernetes cluster was successfully updated."
+msgstr ""
+
+msgid "Kubernetes configured"
+msgstr ""
+
+msgid "Kubernetes service integration has been deprecated. %{deprecated_message_content} your Kubernetes clusters using the new <a href=\"%{url}\"/>Kubernetes Clusters</a> page"
+msgstr ""
+
msgid "LFSStatus|Disabled"
msgstr "åœç”¨"
msgid "LFSStatus|Enabled"
msgstr "啟用"
+msgid "Label"
+msgstr ""
+
+msgid "LabelSelect|%{firstLabelName} +%{remainingLabelCount} more"
+msgstr ""
+
+msgid "LabelSelect|%{labelsString}, and %{remainingLabelCount} more"
+msgstr ""
+
msgid "Labels"
msgstr "標籤"
+msgid "Labels can be applied to %{features}. Group labels are available for any project within the group."
+msgstr ""
+
+msgid "Labels can be applied to issues and merge requests to categorize them."
+msgstr ""
+
+msgid "Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>"
+msgstr ""
+
+msgid "Labels|Promote Label"
+msgstr ""
+
msgid "Last %d day"
msgid_plural "Last %d days"
msgstr[0] "最近 %d 天"
@@ -1163,6 +2501,15 @@ msgstr "您上傳 (push) 了"
msgid "LastPushEvent|at"
msgstr "æ–¼"
+msgid "Learn more"
+msgstr ""
+
+msgid "Learn more about Kubernetes"
+msgstr ""
+
+msgid "Learn more about protected branches"
+msgstr ""
+
msgid "Learn more in the"
msgstr "了解更多"
@@ -1179,33 +2526,80 @@ msgid "Leave project"
msgstr "退出專案"
msgid "License"
-msgstr "授權"
+msgstr ""
-msgid "Limited to showing %d event at most"
-msgid_plural "Limited to showing %d events at most"
-msgstr[0] "é™åˆ¶æœ€å¤šé¡¯ç¤º %d 個事件"
+msgid "List"
+msgstr ""
+
+msgid "List your GitHub repositories"
+msgstr ""
+
+msgid "Loading the GitLab IDE..."
+msgstr ""
msgid "Lock"
msgstr "鎖定"
+msgid "Lock %{issuableDisplayName}"
+msgstr ""
+
+msgid "Lock not found"
+msgstr ""
+
msgid "Locked"
msgstr "鎖定"
msgid "Locked Files"
-msgstr "被鎖定的檔案"
+msgstr ""
+
+msgid "Locks give the ability to lock specific file or folder."
+msgstr ""
msgid "Login"
msgstr "登入"
+msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
+msgstr ""
+
+msgid "Manage all notifications"
+msgstr ""
+
+msgid "Manage group labels"
+msgstr ""
+
+msgid "Manage labels"
+msgstr ""
+
+msgid "Manage project labels"
+msgstr ""
+
+msgid "Manage your group’s membership while adding another level of security with SAML."
+msgstr ""
+
+msgid "Mar"
+msgstr ""
+
+msgid "March"
+msgstr ""
+
+msgid "Mark done"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr "最大 git 儲存失敗"
+msgid "May"
+msgstr ""
+
msgid "Median"
msgstr "中ä½æ•¸"
msgid "Members"
msgstr "æˆå“¡"
+msgid "Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
+msgstr ""
+
msgid "Merge Requests"
msgstr "åˆä½µè«‹æ±‚ (merge request)"
@@ -1215,37 +2609,172 @@ msgstr "åˆä½µ (merge) 事件"
msgid "Merge request"
msgstr "åˆä½µè«‹æ±‚"
+msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
+msgstr ""
+
+msgid "Merged"
+msgstr ""
+
msgid "Messages"
msgstr "公告"
+msgid "Metrics - Influx"
+msgstr ""
+
+msgid "Metrics - Prometheus"
+msgstr ""
+
+msgid "Metrics|Business"
+msgstr ""
+
+msgid "Metrics|Create metric"
+msgstr ""
+
+msgid "Metrics|Edit metric"
+msgstr ""
+
+msgid "Metrics|For grouping similar metrics"
+msgstr ""
+
+msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time."
+msgstr ""
+
+msgid "Metrics|Legend label (optional)"
+msgstr ""
+
+msgid "Metrics|Must be a valid PromQL query."
+msgstr ""
+
+msgid "Metrics|Name"
+msgstr ""
+
+msgid "Metrics|New metric"
+msgstr ""
+
+msgid "Metrics|Prometheus Query Documentation"
+msgstr ""
+
+msgid "Metrics|Query"
+msgstr ""
+
+msgid "Metrics|Response"
+msgstr ""
+
+msgid "Metrics|System"
+msgstr ""
+
+msgid "Metrics|Type"
+msgstr ""
+
+msgid "Metrics|Unit label"
+msgstr ""
+
+msgid "Metrics|Used as a title for the chart"
+msgstr ""
+
+msgid "Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response."
+msgstr ""
+
+msgid "Metrics|Y-axis label"
+msgstr ""
+
+msgid "Metrics|e.g. HTTP requests"
+msgstr ""
+
+msgid "Metrics|e.g. Requests/second"
+msgstr ""
+
+msgid "Metrics|e.g. Throughput"
+msgstr ""
+
+msgid "Metrics|e.g. rate(http_requests_total[5m])"
+msgstr ""
+
+msgid "Metrics|e.g. req/sec"
+msgstr ""
+
+msgid "Milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone"
+msgstr ""
+
+msgid "Milestones|Delete milestone %{milestoneTitle}?"
+msgstr ""
+
+msgid "Milestones|Failed to delete milestone %{milestoneTitle}"
+msgstr ""
+
+msgid "Milestones|Milestone %{milestoneTitle} was not found"
+msgstr ""
+
+msgid "Milestones|Promote %{milestoneTitle} to group milestone?"
+msgstr ""
+
+msgid "Milestones|Promote Milestone"
+msgstr ""
+
+msgid "Milestones|This action cannot be reversed."
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr "新增 SSH 金鑰"
+msgid "Modal|Cancel"
+msgstr ""
+
+msgid "Modal|Close"
+msgstr ""
+
msgid "Monitoring"
msgstr "監控"
+msgid "More info"
+msgstr ""
+
+msgid "More information"
+msgstr ""
+
msgid "More information is available|here"
msgstr "å¥åº·æª¢æŸ¥"
+msgid "Move"
+msgstr ""
+
+msgid "Move issue"
+msgstr ""
+
msgid "Multiple issue boards"
-msgstr "å¤šå€‹è­°é¡Œçœ‹æ¿ (issue boards)"
+msgstr ""
-msgid "New Cluster"
-msgstr "æ–°å¢é›†"
+msgid "Name new label"
+msgstr ""
msgid "New Issue"
msgid_plural "New Issues"
msgstr[0] "建立議題 (issue) "
+msgid "New Kubernetes Cluster"
+msgstr ""
+
+msgid "New Kubernetes cluster"
+msgstr ""
+
msgid "New Pipeline Schedule"
msgstr "建立æµæ°´ç·š (pipeline) 排程"
msgid "New branch"
msgstr "新分支 (branch) "
+msgid "New branch unavailable"
+msgstr ""
+
msgid "New directory"
msgstr "新增目錄"
+msgid "New epic"
+msgstr ""
+
msgid "New file"
msgstr "新增檔案"
@@ -1255,6 +2784,9 @@ msgstr "新群組"
msgid "New issue"
msgstr "新增議題 (issue) "
+msgid "New label"
+msgstr ""
+
msgid "New merge request"
msgstr "新增åˆä½µè«‹æ±‚ (merge request) "
@@ -1273,7 +2805,28 @@ msgstr "æ–°å­ç¾¤çµ„"
msgid "New tag"
msgstr "新增標籤"
-msgid "No container images stored for this project. Add one by following the instructions above."
+msgid "No Label"
+msgstr ""
+
+msgid "No assignee"
+msgstr ""
+
+msgid "No changes"
+msgstr ""
+
+msgid "No connection could be made to a Gitaly Server, please check your logs!"
+msgstr ""
+
+msgid "No due date"
+msgstr ""
+
+msgid "No estimate or time spent"
+msgstr ""
+
+msgid "No file chosen"
+msgstr ""
+
+msgid "No labels created yet."
msgstr ""
msgid "No repository"
@@ -1285,12 +2838,39 @@ msgstr "沒有排程"
msgid "None"
msgstr "ç„¡"
+msgid "Not allowed to merge"
+msgstr ""
+
msgid "Not available"
msgstr "無法使用"
+msgid "Not available for private projects"
+msgstr ""
+
+msgid "Not available for protected branches"
+msgstr ""
+
+msgid "Not confidential"
+msgstr ""
+
msgid "Not enough data"
msgstr "資料ä¸è¶³"
+msgid "Note that the master branch is automatically protected. %{link_to_protected_branches}"
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow connecting repositories without generating a Personal Access Token."
+msgstr ""
+
+msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
+msgstr ""
+
msgid "Notification events"
msgstr "事件通知"
@@ -1348,18 +2928,48 @@ msgstr "關注"
msgid "Notifications"
msgstr "通知"
+msgid "Notifications off"
+msgstr ""
+
+msgid "Notifications on"
+msgstr ""
+
+msgid "Nov"
+msgstr ""
+
+msgid "November"
+msgstr ""
+
msgid "Number of access attempts"
msgstr "嘗試存å–的次數"
-msgid "Number of failures before backing off"
+msgid "OK"
+msgstr ""
+
+msgid "Oct"
+msgstr ""
+
+msgid "October"
msgstr ""
msgid "OfSearchInADropdown|Filter"
msgstr "篩é¸"
+msgid "Once imported, repositories can be mirrored over SSH. Read more %{ssh_link}"
+msgstr ""
+
+msgid "Online IDE integration settings."
+msgstr ""
+
msgid "Only project members can comment."
msgstr "åªæœ‰ç¾¤çµ„æˆå“¡æ‰èƒ½ç•™è¨€ã€‚"
+msgid "Open"
+msgstr ""
+
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr "開始於"
@@ -1369,12 +2979,21 @@ msgstr "於新視窗開啟"
msgid "Options"
msgstr "é¸é …"
+msgid "Otherwise it is recommended you start with one of the options below."
+msgstr ""
+
+msgid "Outbound requests"
+msgstr ""
+
msgid "Overview"
msgstr "總覽"
msgid "Owner"
msgstr "所有權"
+msgid "Pages"
+msgstr ""
+
msgid "Pagination|Last »"
msgstr "æœ€æœ«é  Â»"
@@ -1387,11 +3006,20 @@ msgstr "上一é "
msgid "Pagination|« First"
msgstr "« 第一é "
+msgid "Part of merge request changes"
+msgstr ""
+
msgid "Password"
msgstr "密碼"
-msgid "People without permission will never get a notification and won\\'t be able to comment."
-msgstr "沒有權é™çš„使用者將ä¸æœƒæ”¶åˆ°é€šçŸ¥ï¼Œä¹Ÿç„¡æ³•ç•™è¨€ã€‚"
+msgid "Pending"
+msgstr ""
+
+msgid "Performance optimization"
+msgstr ""
+
+msgid "Personal Access Token"
+msgstr ""
msgid "Pipeline"
msgstr "æµæ°´ç·š (pipeline) "
@@ -1406,7 +3034,7 @@ msgid "Pipeline Schedules"
msgstr "æµæ°´ç·š (pipeline) 排程"
msgid "Pipeline quota"
-msgstr "æµæ°´ç·šé¡åº¦"
+msgstr ""
msgid "PipelineCharts|Failed:"
msgstr "失敗:"
@@ -1435,12 +3063,6 @@ msgstr "所有"
msgid "PipelineSchedules|Inactive"
msgstr "未啟用"
-msgid "PipelineSchedules|Input variable key"
-msgstr "變數å稱"
-
-msgid "PipelineSchedules|Input variable value"
-msgstr "變數值"
-
msgid "PipelineSchedules|Next Run"
msgstr "下次執行時間"
@@ -1450,9 +3072,6 @@ msgstr "ç„¡"
msgid "PipelineSchedules|Provide a short description for this pipeline"
msgstr "請簡單說明此æµæ°´ç·š (pipeline) "
-msgid "PipelineSchedules|Remove variable row"
-msgstr "刪除變數"
-
msgid "PipelineSchedules|Take ownership"
msgstr "å–得所有權"
@@ -1480,6 +3099,57 @@ msgstr "上週的æµæ°´ç·š"
msgid "Pipelines for last year"
msgstr "去年的æµæ°´ç·š"
+msgid "Pipelines|Build with confidence"
+msgstr ""
+
+msgid "Pipelines|CI Lint"
+msgstr "CI Lint"
+
+msgid "Pipelines|Clear Runner Caches"
+msgstr "清除é‹è¡Œå™¨å¿«å–"
+
+msgid "Pipelines|Get started with Pipelines"
+msgstr ""
+
+msgid "Pipelines|Loading Pipelines"
+msgstr ""
+
+msgid "Pipelines|Project cache successfully reset."
+msgstr ""
+
+msgid "Pipelines|Run Pipeline"
+msgstr ""
+
+msgid "Pipelines|Something went wrong while cleaning runners cache."
+msgstr ""
+
+msgid "Pipelines|There are currently no %{scope} pipelines."
+msgstr ""
+
+msgid "Pipelines|There are currently no pipelines."
+msgstr ""
+
+msgid "Pipelines|This project is not currently set up to run pipelines."
+msgstr ""
+
+msgid "Pipeline|Retry pipeline"
+msgstr ""
+
+msgid "Pipeline|Retry pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline"
+msgstr ""
+
+msgid "Pipeline|Stop pipeline #%{pipelineId}?"
+msgstr ""
+
+msgid "Pipeline|You’re about to retry pipeline %{pipelineId}."
+msgstr ""
+
+msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
+msgstr ""
+
msgid "Pipeline|all"
msgstr "所有"
@@ -1492,15 +3162,39 @@ msgstr "於階段"
msgid "Pipeline|with stages"
msgstr "於階段"
+msgid "PlantUML"
+msgstr ""
+
+msgid "Play"
+msgstr ""
+
+msgid "Please <a href=%{link_to_billing} target=\"_blank\" rel=\"noopener noreferrer\">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again."
+msgstr ""
+
+msgid "Please solve the reCAPTCHA"
+msgstr ""
+
+msgid "Please wait while we connect to your repository. Refresh at will."
+msgstr ""
+
+msgid "Please wait while we import the repository for you. Refresh at will."
+msgstr ""
+
msgid "Preferences"
msgstr "å好設定"
+msgid "Primary"
+msgstr ""
+
msgid "Private - Project access must be granted explicitly to each user."
msgstr "ç§æœ‰ - 專案權é™å¿…須一一指派給æ¯å€‹ä½¿ç”¨è€…"
msgid "Private - The group and its projects can only be viewed by members."
msgstr "ç§æœ‰ - 群組åŠæ——下專案åªèƒ½è¢«è©²ç¾¤çµ„æˆå“¡æŸ¥çœ‹"
+msgid "Private projects can be created in your personal namespace with:"
+msgstr ""
+
msgid "Profile"
msgstr "個人資料"
@@ -1540,6 +3234,12 @@ msgstr "你的帳號目å‰æ“有這些群組:"
msgid "Profiles|your account"
msgstr "你的帳號"
+msgid "Profiling - Performance bar"
+msgstr ""
+
+msgid "Programming languages used in this repository"
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr "專案 \"%{project_name}\" 正在被刪除。"
@@ -1555,6 +3255,12 @@ msgstr "專案 '%{project_name}' 更新完æˆã€‚"
msgid "Project access must be granted explicitly to each user."
msgstr "專案權é™å¿…須一一指派給æ¯å€‹ä½¿ç”¨è€…。"
+msgid "Project avatar"
+msgstr ""
+
+msgid "Project avatar in repository: %{link}"
+msgstr ""
+
msgid "Project details"
msgstr "專案細節"
@@ -1573,6 +3279,21 @@ msgstr "專案導出已開始。完æˆå¾Œä¸‹è¼‰é€£çµæœƒé€åˆ°æ‚¨çš„信箱。"
msgid "ProjectActivityRSS|Subscribe"
msgstr "訂閱"
+msgid "ProjectCreationLevel|Allowed to create projects"
+msgstr ""
+
+msgid "ProjectCreationLevel|Default project creation protection"
+msgstr ""
+
+msgid "ProjectCreationLevel|Developers + Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|Masters"
+msgstr ""
+
+msgid "ProjectCreationLevel|No one"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr "åœç”¨"
@@ -1595,22 +3316,22 @@ msgid "ProjectNetworkGraph|Graph"
msgstr "分支圖"
msgid "ProjectSettings|Contact an admin to change this setting."
-msgstr "è¯çµ¡ç®¡ç†å“¡ä»¥è®Šæ›´è¨­å®šã€‚"
+msgstr ""
msgid "ProjectSettings|Only signed commits can be pushed to this repository."
-msgstr "åªæœ‰å·²ç°½ç« çš„變更æ‰èƒ½è¢«æŽ¨é€åˆ°æª”案庫(repository)。"
+msgstr ""
msgid "ProjectSettings|This setting is applied on the server level and can be overridden by an admin."
-msgstr "此設定已經套用於伺æœå™¨å±¤ç´šï¼Œä¸¦ä¸”å¯è¢«ç®¡ç†å“¡è¦†å¯«ã€‚"
+msgstr ""
msgid "ProjectSettings|This setting is applied on the server level but has been overridden for this project."
-msgstr "此設定已經套用至伺æœå™¨å±¤ç´šï¼Œä½†æ­¤å°ˆæ¡ˆä½¿ç”¨å¦ä¸€å€‹è¨­å®šã€‚"
+msgstr ""
msgid "ProjectSettings|This setting will be applied to all projects unless overridden by an admin."
-msgstr "此設定將套用至所有專案,除éžè¢«ç®¡ç†å“¡è¦†å¯«ã€‚"
+msgstr ""
msgid "ProjectSettings|Users can only push commits to this repository that were committed with one of their own verified emails."
-msgstr "使用者推é€çš„修改 (commits) åªèƒ½ä½¿ç”¨ä»–們自己的電å­éƒµä»¶ã€‚"
+msgstr ""
msgid "Projects"
msgstr "專案"
@@ -1636,6 +3357,93 @@ msgstr "抱歉,沒有符åˆæœå°‹æ¢ä»¶çš„專案"
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr "此功能需è¦ç€è¦½å™¨æ”¯æ´ localStorage"
+msgid "PrometheusService|%{exporters} with %{metrics} were found"
+msgstr ""
+
+msgid "PrometheusService|<p class=\"text-tertiary\">No <a href=\"%{docsUrl}\">common metrics</a> were found</p>"
+msgstr ""
+
+msgid "PrometheusService|Active"
+msgstr ""
+
+msgid "PrometheusService|Auto configuration"
+msgstr ""
+
+msgid "PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments"
+msgstr ""
+
+msgid "PrometheusService|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."
+msgstr ""
+
+msgid "PrometheusService|Common metrics"
+msgstr ""
+
+msgid "PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters."
+msgstr ""
+
+msgid "PrometheusService|Custom metrics"
+msgstr ""
+
+msgid "PrometheusService|Finding and configuring metrics..."
+msgstr ""
+
+msgid "PrometheusService|Finding custom metrics..."
+msgstr ""
+
+msgid "PrometheusService|Install Prometheus on clusters"
+msgstr ""
+
+msgid "PrometheusService|Manage clusters"
+msgstr ""
+
+msgid "PrometheusService|Manual configuration"
+msgstr ""
+
+msgid "PrometheusService|Metrics"
+msgstr ""
+
+msgid "PrometheusService|Missing environment variable"
+msgstr ""
+
+msgid "PrometheusService|More information"
+msgstr ""
+
+msgid "PrometheusService|New metric"
+msgstr ""
+
+msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/"
+msgstr ""
+
+msgid "PrometheusService|Prometheus is being automatically managed on your clusters"
+msgstr ""
+
+msgid "PrometheusService|These metrics will only be monitored after your first deployment to an environment"
+msgstr ""
+
+msgid "PrometheusService|Time-series monitoring service"
+msgstr ""
+
+msgid "PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters"
+msgstr ""
+
+msgid "PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below"
+msgstr ""
+
+msgid "PrometheusService|Waiting for your first deployment to an environment to find common metrics"
+msgstr ""
+
+msgid "Promote"
+msgstr ""
+
+msgid "Promote to Group Label"
+msgstr ""
+
+msgid "Promote to Group Milestone"
+msgstr ""
+
+msgid "Protip:"
+msgstr ""
+
msgid "Public - The group and any public projects can be viewed without any authentication."
msgstr "公開 - 未登入的情æ³ä¸‹ä¾ç„¶å¯ä»¥æŸ¥çœ‹ä»»ä½•å…¬é–‹å°ˆæ¡ˆ"
@@ -1643,13 +3451,22 @@ msgid "Public - The project can be accessed without any authentication."
msgstr "公開 - 無須任何身份驗證å³å¯å­˜å–該專案"
msgid "Push Rules"
-msgstr "æŽ¨é€ [Push] è¦å‰‡"
+msgstr ""
msgid "Push events"
msgstr "æŽ¨é€ (push) 事件"
+msgid "Push project from command line"
+msgstr ""
+
+msgid "Push to create a project"
+msgstr ""
+
msgid "PushRule|Committer restriction"
-msgstr "æ交é™åˆ¶"
+msgstr ""
+
+msgid "Quick actions can be used in the issues description and comment boxes."
+msgstr ""
msgid "Read more"
msgstr "瞭解更多"
@@ -1657,14 +3474,23 @@ msgstr "瞭解更多"
msgid "Readme"
msgstr "說明檔"
+msgid "Real-time features"
+msgstr ""
+
msgid "RefSwitcher|Branches"
msgstr "分支 (branch) "
msgid "RefSwitcher|Tags"
msgstr "標籤"
+msgid "Reference:"
+msgstr ""
+
+msgid "Register / Sign In"
+msgstr ""
+
msgid "Registry"
-msgstr "登錄表"
+msgstr ""
msgid "Related Commits"
msgstr "相關的更動記錄 (commit) "
@@ -1684,15 +3510,42 @@ msgstr "相關的åˆä½µè«‹æ±‚ (merge request) "
msgid "Related Merged Requests"
msgstr "相關已åˆä½µçš„請求"
+msgid "Related merge requests"
+msgstr ""
+
msgid "Remind later"
msgstr "ç¨å¾Œæ醒"
+msgid "Remove"
+msgstr ""
+
+msgid "Remove avatar"
+msgstr ""
+
msgid "Remove project"
msgstr "刪除專案"
+msgid "Repair authentication"
+msgstr ""
+
+msgid "Repo by URL"
+msgstr ""
+
msgid "Repository"
msgstr "檔案庫 (repository)"
+msgid "Repository has no locks."
+msgstr ""
+
+msgid "Repository maintenance"
+msgstr ""
+
+msgid "Repository mirror settings"
+msgstr ""
+
+msgid "Repository storage"
+msgstr ""
+
msgid "Request Access"
msgstr "申請權é™"
@@ -1705,68 +3558,171 @@ msgstr "é‡ç½®å¥åº·æª¢æŸ¥å­˜å–憑證 (access token)"
msgid "Reset runners registration token"
msgstr "é‡ç½® Runner 註冊憑證 (registration token)"
+msgid "Resolve discussion"
+msgstr ""
+
+msgid "Response"
+msgstr ""
+
+msgid "Reveal value"
+msgid_plural "Reveal values"
+msgstr[0] ""
+
msgid "Revert this commit"
msgstr "還原此更動記錄 (commit)"
msgid "Revert this merge request"
msgstr "還原此åˆä½µè«‹æ±‚ (merge request) "
+msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"."
+msgstr ""
+
+msgid "Reviewing"
+msgstr ""
+
+msgid "Reviewing (merge request !%{mergeRequestId})"
+msgstr ""
+
+msgid "Roadmap"
+msgstr ""
+
+msgid "Run CI/CD pipelines for external repositories"
+msgstr ""
+
+msgid "Runners"
+msgstr ""
+
+msgid "Running"
+msgstr ""
+
+msgid "SAML Single Sign On"
+msgstr ""
+
+msgid "SAML Single Sign On Settings"
+msgstr ""
+
+msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
+msgstr ""
+
msgid "SSH Keys"
msgstr "SSH 金鑰"
-msgid "Save"
-msgstr "儲存"
-
msgid "Save changes"
msgstr "儲存變更"
msgid "Save pipeline schedule"
msgstr "儲存æµæ°´ç·š (pipeline) 排程"
+msgid "Save variables"
+msgstr ""
+
msgid "Schedule a new pipeline"
msgstr "建立æµæ°´ç·š (pipeline) 排程"
+msgid "Scheduled"
+msgstr ""
+
msgid "Schedules"
msgstr "排程"
msgid "Scheduling Pipelines"
msgstr "æµæ°´ç·š (pipeline) 排程"
+msgid "Scoped issue boards"
+msgstr ""
+
+msgid "Search"
+msgstr ""
+
msgid "Search branches and tags"
msgstr "æœå°‹åˆ†æ”¯ (branch) 和標籤"
+msgid "Search milestones"
+msgstr ""
+
+msgid "Search project"
+msgstr ""
+
+msgid "Search users"
+msgstr ""
+
msgid "Seconds before reseting failure information"
msgstr "é‡ç½®å¤±æ•—訊æ¯ç­‰å¾…時間(秒)"
-msgid "Seconds to wait after a storage failure"
-msgstr "儲存失敗後等待時間(秒)"
-
msgid "Seconds to wait for a storage access attempt"
msgstr "等待存å–儲存空間的嘗試時間(秒)"
+msgid "Secret variables"
+msgstr ""
+
+msgid "Security report"
+msgstr ""
+
msgid "Select Archive Format"
msgstr "é¸æ“‡ä¸‹è¼‰æ ¼å¼"
msgid "Select a timezone"
msgstr "é¸æ“‡æ™‚å€"
+msgid "Select an existing Kubernetes cluster or create a new one"
+msgstr ""
+
+msgid "Select assignee"
+msgstr ""
+
+msgid "Select branch/tag"
+msgstr ""
+
msgid "Select target branch"
msgstr "é¸æ“‡ç›®æ¨™åˆ†æ”¯ (branch) "
+msgid "Selective synchronization"
+msgstr ""
+
+msgid "Send email"
+msgstr ""
+
+msgid "Sep"
+msgstr ""
+
+msgid "September"
+msgstr ""
+
+msgid "Server version"
+msgstr ""
+
msgid "Service Templates"
msgstr "æœå‹™ç¯„本"
+msgid "Service URL"
+msgstr ""
+
+msgid "Session expiration, projects limit and attachment size."
+msgstr ""
+
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 default and restrict visibility levels. Configure import sources and git access protocol."
+msgstr ""
+
+msgid "Set max session time for web terminal."
+msgstr ""
+
+msgid "Set notification email for abuse reports."
+msgstr ""
+
+msgid "Set requirements for a user to sign-in. Enable mandatory two-factor authentication."
+msgstr ""
+
+msgid "Set up CI/CD"
+msgstr ""
msgid "Set up Koding"
msgstr "設定 Koding"
-msgid "Set up auto deploy"
-msgstr "設定自動部署"
+msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
+msgstr ""
msgid "SetPasswordToCloneLink|set a password"
msgstr "設定密碼"
@@ -1774,6 +3730,24 @@ msgstr "設定密碼"
msgid "Settings"
msgstr "設定"
+msgid "Setup a specific Runner automatically"
+msgstr ""
+
+msgid "Share the <strong>%{sso_label}</strong> with members so they can sign in to your group through your identity provider"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset pipeline minutes"
+msgstr ""
+
+msgid "SharedRunnersMinutesSettings|Reset used pipeline minutes"
+msgstr ""
+
+msgid "Show command"
+msgstr ""
+
msgid "Show parent pages"
msgstr "顯示上層é é¢"
@@ -1784,14 +3758,47 @@ msgid "Showing %d event"
msgid_plural "Showing %d events"
msgstr[0] "顯示 %d 個事件"
+msgid "Sidebar|Change weight"
+msgstr ""
+
+msgid "Sidebar|No"
+msgstr ""
+
+msgid "Sidebar|None"
+msgstr ""
+
+msgid "Sidebar|Weight"
+msgstr ""
+
+msgid "Sign-in restrictions"
+msgstr ""
+
+msgid "Sign-up restrictions"
+msgstr ""
+
+msgid "Size and domain settings for static websites"
+msgstr ""
+
+msgid "Slack application"
+msgstr ""
+
msgid "Snippets"
msgstr "文字片段"
+msgid "Something went wrong on our end"
+msgstr ""
+
msgid "Something went wrong on our end."
-msgstr "發生了錯誤。"
+msgstr ""
-msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
-msgstr "有個地方出錯了,因為他嘗試去變更 ${this.issuableDisplayName(this.issuableType)} 的鎖定狀態。"
+msgid "Something went wrong when toggling the button"
+msgstr ""
+
+msgid "Something went wrong while fetching Dependency Scanning."
+msgstr ""
+
+msgid "Something went wrong while fetching SAST."
+msgstr ""
msgid "Something went wrong while fetching the projects."
msgstr "讀å–專案時發生錯誤。"
@@ -1799,6 +3806,9 @@ msgstr "讀å–專案時發生錯誤。"
msgid "Something went wrong while fetching the registry list."
msgstr "讀å–註冊列表時發生錯誤。"
+msgid "Something went wrong. Please try again."
+msgstr ""
+
msgid "Sort by"
msgstr "排åº"
@@ -1842,7 +3852,7 @@ msgid "SortOptions|Least popular"
msgstr "最ä¸å—æ­¡è¿Ž"
msgid "SortOptions|Less weight"
-msgstr "最低權é‡"
+msgstr ""
msgid "SortOptions|Milestone"
msgstr "里程碑"
@@ -1854,7 +3864,7 @@ msgid "SortOptions|Milestone due soon"
msgstr "å³å°‡æˆªæ­¢çš„里程碑"
msgid "SortOptions|More weight"
-msgstr "更大的權é‡"
+msgstr ""
msgid "SortOptions|Most popular"
msgstr "最å—æ­¡è¿Ž"
@@ -1896,20 +3906,38 @@ msgid "SortOptions|Start soon"
msgstr "ç¾åœ¨é–‹å§‹"
msgid "SortOptions|Weight"
-msgstr "權é‡"
+msgstr ""
+
+msgid "Source"
+msgstr ""
+
+msgid "Source (branch or tag)"
+msgstr ""
msgid "Source code"
msgstr "原始碼"
+msgid "Source is not available"
+msgstr ""
+
msgid "Spam Logs"
msgstr "垃圾訊æ¯è¨˜éŒ„"
+msgid "Spam and Anti-bot Protection"
+msgstr ""
+
msgid "Specify the following URL during the Runner setup:"
msgstr "åœ¨å®‰è£ Runner 時指定以下 URL:"
msgid "StarProject|Star"
msgstr "收è—"
+msgid "Starred Projects"
+msgstr ""
+
+msgid "Starred Projects' Activity"
+msgstr ""
+
msgid "Starred projects"
msgstr "星標項目"
@@ -1919,39 +3947,135 @@ msgstr "以這些改動建立一個新的 %{new_merge_request} "
msgid "Start the Runner!"
msgstr "å•Ÿå‹• Runner!"
+msgid "Started"
+msgstr ""
+
+msgid "State your message to activate"
+msgstr ""
+
+msgid "Status"
+msgstr ""
+
+msgid "Stopped"
+msgstr ""
+
+msgid "Storage"
+msgstr ""
+
msgid "Subgroups"
msgstr "å­ç¾¤çµ„"
-msgid "Subscribe"
-msgstr "訂閱"
-
msgid "Switch branch/tag"
msgstr "切æ›åˆ†æ”¯ (branch) 或標籤"
+msgid "System"
+msgstr ""
+
msgid "System Hooks"
msgstr "系統鉤å­"
-msgid "Tag"
-msgid_plural "Tags"
-msgstr[0] "標籤"
+msgid "System header and footer:"
+msgstr ""
+
+msgid "Tag (%{tag_count})"
+msgid_plural "Tags (%{tag_count})"
+msgstr[0] ""
msgid "Tags"
msgstr "標籤"
+msgid "TagsPage|Browse commits"
+msgstr ""
+
+msgid "TagsPage|Browse files"
+msgstr ""
+
+msgid "TagsPage|Can't find HEAD commit for this tag"
+msgstr ""
+
+msgid "TagsPage|Cancel"
+msgstr ""
+
+msgid "TagsPage|Create tag"
+msgstr ""
+
+msgid "TagsPage|Delete tag"
+msgstr ""
+
+msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?"
+msgstr ""
+
+msgid "TagsPage|Edit release notes"
+msgstr ""
+
+msgid "TagsPage|Existing branch name, tag, or commit SHA"
+msgstr ""
+
+msgid "TagsPage|Filter by tag name"
+msgstr ""
+
+msgid "TagsPage|New Tag"
+msgstr ""
+
+msgid "TagsPage|New tag"
+msgstr ""
+
+msgid "TagsPage|Optionally, add a message to the tag."
+msgstr ""
+
+msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
+msgstr ""
+
+msgid "TagsPage|Release notes"
+msgstr ""
+
+msgid "TagsPage|Repository has no tags yet."
+msgstr ""
+
+msgid "TagsPage|Sort by"
+msgstr ""
+
+msgid "TagsPage|Tags"
+msgstr ""
+
+msgid "TagsPage|Tags give the ability to mark specific points in history as being important"
+msgstr ""
+
+msgid "TagsPage|This tag has no release notes."
+msgstr ""
+
+msgid "TagsPage|Use git tag command to add a new one:"
+msgstr ""
+
+msgid "TagsPage|Write your release notes or drag files here..."
+msgstr ""
+
+msgid "TagsPage|protected"
+msgstr ""
+
msgid "Target Branch"
msgstr "目標分支 (branch) "
+msgid "Target branch"
+msgstr ""
+
msgid "Team"
msgstr "團隊"
msgid "Thanks! Don't show me this again"
-msgstr "æ„Ÿè¬ï¼è«‹ä¸è¦å†æ¬¡é¡¯ç¤º"
+msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
-msgstr "GitLab 的進階全局æœå°‹åŠŸèƒ½æ˜¯éžå¸¸å¼·å¤§çš„æœå°‹æœå‹™ã€‚您å¯ä»¥æœå°‹å…¶ä»–團隊的代碼以幫助您完善自己項目中的代碼。從而é¿å…建立é‡è¤‡çš„代碼浪費時間。"
+msgstr ""
-msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold"
-msgstr "é™æµé˜»æ–·å…ƒä»¶çš„觸發門檻應低於計數錯誤門檻"
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project"
+msgstr ""
+
+msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project."
+msgstr ""
+
+msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS."
+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 "程å¼é–‹ç™¼éšŽæ®µé¡¯ç¤ºå¾žç¬¬ä¸€æ¬¡æ›´å‹•è¨˜éŒ„ (commit) 到建立åˆä½µè«‹æ±‚ (merge request) 的時間。建立第一個åˆä½µè«‹æ±‚後,資料將自動填入。"
@@ -1959,30 +4083,39 @@ msgstr "程å¼é–‹ç™¼éšŽæ®µé¡¯ç¤ºå¾žç¬¬ä¸€æ¬¡æ›´å‹•è¨˜éŒ„ (commit) 到建立åˆä½
msgid "The collection of events added to the data gathered for that stage."
msgstr "該階段中的相關事件集åˆã€‚"
+msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+msgstr ""
+
msgid "The fork relationship has been removed."
msgstr "åˆ†æ”¯èˆ‡ä¸»å¹¹é–“çš„é—œè¯ (fork relationship) 已被刪除。"
+msgid "The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
+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 "è­°é¡Œ (issue) éšŽæ®µé¡¯ç¤ºå¾žè­°é¡Œå»ºç«‹åˆ°è¨­å®šé‡Œç¨‹ç¢‘æ‰€èŠ±çš„æ™‚é–“ï¼Œæˆ–æ˜¯è­°é¡Œè¢«åˆ†é¡žåˆ°è­°é¡Œçœ‹æ¿ (issue board) 中所花的時間。建立第一個議題後,資料將自動填入。"
+msgid "The maximum file size allowed is 200KB."
+msgstr ""
+
msgid "The number of attempts GitLab will make to access a storage."
msgstr "GitLab å­˜å–儲存空間的嘗試次數。"
-msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host"
-msgstr ""
-
msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}."
msgstr "GitLab 將阻擋存å–失敗的次數。在管ç†è€…介é¢ä¸­å¯ä»¥é‡ç½®å¤±æ•—次數: %{link_to_health_page} 或使用 %{api_documentation_link}。"
+msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
+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 "在指定了特定分支 (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 private key to use when a client certificate is provided. This value is encrypted at rest."
+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 "營é‹éšŽæ®µé¡¯ç¤ºå¾žå»ºç«‹è­°é¡Œ (issue) 到部署程å¼ä¸Šç·šæ‰€èŠ±çš„時間。完æˆå¾žç™¼æƒ³åˆ°ä¸Šç·šçš„完整開發週期後,資料將自動填入。"
@@ -1995,9 +4128,18 @@ msgstr "本專案å¯è®“任何人存å–"
msgid "The repository for this project does not exist."
msgstr "本專案沒有檔案庫 (repository) "
+msgid "The repository for this project is empty"
+msgstr ""
+
+msgid "The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>."
+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 "複閱階段顯示從åˆä½µè«‹æ±‚ (merge request) 建立後至被åˆä½µçš„時間。當建立第一個åˆä½µè«‹æ±‚ (merge request) 後,資料將自動填入。"
+msgid "The roadmap shows the progress of your epics along a timeline"
+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 "試營é‹æ®µé¡¯ç¤ºå¾žåˆä½µè«‹æ±‚ (merge request) 被åˆä½µå¾Œè‡³éƒ¨ç½²ç‡Ÿé‹çš„時間。當第一次部署營é‹å¾Œï¼Œè³‡æ–™å°‡è‡ªå‹•å¡«å…¥"
@@ -2010,17 +4152,50 @@ msgstr "GitLab ä¿å­˜å¤±æ•—訊æ¯çš„時間(秒)。在此時間內若沒有發生
msgid "The time in seconds GitLab will try to access storage. After this time a timeout error will be raised."
msgstr "GitLab 嘗試存å–檔案庫 (repository) 的時間 (秒)。超éŽæ­¤æ™‚間將會引發逾時錯誤。"
+msgid "The time in seconds between storage checks. When a previous check did complete yet, GitLab will skip a check."
+msgstr ""
+
msgid "The time taken by each data entry gathered by that stage."
msgstr "該階段中æ¯ä¸€å€‹è³‡æ–™é …目所花的時間。"
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr "中ä½æ•¸æ˜¯ä¸€å€‹æ•¸åˆ—中最中間的值。例如在 3ã€5ã€9 之間,中ä½æ•¸æ˜¯ 5。在 3ã€5ã€7ã€8 之間,中ä½æ•¸æ˜¯ (5 + 7)/ 2 = 6。"
+msgid "There are no issues to show"
+msgstr ""
+
+msgid "There are no merge requests to show"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr "å­˜å– Git 儲存空間時出ç¾å•é¡Œï¼š"
-msgid "This branch has changed since you started editing. Would you like to create a new branch?"
-msgstr "在您編輯後,此分支已被更改,您想è¦å»ºç«‹ä¸€å€‹æ–°çš„分支嗎?"
+msgid "There was an error loading results"
+msgstr ""
+
+msgid "There was an error loading users activity calendar."
+msgstr ""
+
+msgid "There was an error saving your notification settings."
+msgstr ""
+
+msgid "There was an error subscribing to this label."
+msgstr ""
+
+msgid "There was an error when reseting email token."
+msgstr ""
+
+msgid "There was an error when subscribing to this label."
+msgstr ""
+
+msgid "There was an error when unsubscribing from this label."
+msgstr ""
+
+msgid "This board\\'s scope is reduced"
+msgstr ""
+
+msgid "This directory"
+msgstr ""
msgid "This is a confidential issue."
msgstr "這是個隱密å•é¡Œã€‚"
@@ -2028,18 +4203,54 @@ msgstr "這是個隱密å•é¡Œã€‚"
msgid "This is the author's first Merge Request to this project."
msgstr "這是作者第一次åˆä½µè«‹æ±‚至本專案。"
+msgid "This issue is confidential"
+msgstr ""
+
msgid "This issue is confidential and locked."
msgstr "這個å•é¡Œæ˜¯ä¿å¯†ä¸”鎖定的。"
msgid "This issue is locked."
msgstr "這個å•é¡Œå·²è¢«éŽ–定。"
+msgid "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+msgstr ""
+
+msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
+msgstr ""
+
+msgid "This job has not been triggered yet"
+msgstr ""
+
+msgid "This job has not started yet"
+msgstr ""
+
+msgid "This job is in pending state and is waiting to be picked by a runner"
+msgstr ""
+
+msgid "This job requires a manual action"
+msgstr ""
+
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "這代表在您建立一個空的檔案庫 (repository) 或是匯入一個ç¾å­˜çš„檔案庫之å‰ï¼Œæ‚¨å°‡ç„¡æ³•ä¸Šå‚³æ›´æ–° (push) 。"
msgid "This merge request is locked."
msgstr "這個åˆä½µè«‹æ±‚已被鎖定。"
+msgid "This page is unavailable because you are not allowed to read information across multiple projects."
+msgstr ""
+
+msgid "This project"
+msgstr ""
+
+msgid "This repository"
+msgstr ""
+
+msgid "This will delete the custom metric, Are you sure?"
+msgstr ""
+
+msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
+msgstr ""
+
msgid "Time before an issue gets scheduled"
msgstr "議題 (issue) 被列入日程表的時間"
@@ -2049,9 +4260,27 @@ msgstr "議題 (issue) 等待開始實作的時間"
msgid "Time between merge request creation and merge/close"
msgstr "åˆä½µè«‹æ±‚ (merge request) 從建立到被åˆä½µæˆ–是關閉的時間"
+msgid "Time between updates and capacity settings."
+msgstr ""
+
+msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied."
+msgstr "GitLab 等待外部æœå‹™çš„回應時間(秒)。當æœå‹™æ²’有在時間內回應時,存å–將被拒絕。"
+
+msgid "Time tracking"
+msgstr ""
+
msgid "Time until first merge request"
msgstr "第一個åˆä½µè«‹æ±‚ (merge request) 被建立å‰çš„時間"
+msgid "TimeTrackingEstimated|Est"
+msgstr ""
+
+msgid "TimeTracking|Estimated:"
+msgstr ""
+
+msgid "TimeTracking|Spent"
+msgstr ""
+
msgid "Timeago|%s days ago"
msgstr " %s 天å‰"
@@ -2186,14 +4415,83 @@ msgstr[0] "分é˜"
msgid "Time|s"
msgstr "秒"
+msgid "Tip:"
+msgstr ""
+
+msgid "Title"
+msgstr ""
+
+msgid "To GitLab"
+msgstr ""
+
+msgid "To connect GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to connect."
+msgstr ""
+
+msgid "To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To connect an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import."
+msgstr ""
+
+msgid "To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:"
+msgstr ""
+
+msgid "To import an SVN repository, check out %{svn_link}."
+msgstr ""
+
+msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
+msgstr ""
+
+msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
+msgstr ""
+
+msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button."
+msgstr ""
+
+msgid "To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown."
+msgstr ""
+
+msgid "Todo"
+msgstr ""
+
+msgid "Toggle sidebar"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: OFF"
+msgstr ""
+
+msgid "ToggleButton|Toggle Status: ON"
+msgstr ""
+
msgid "Total Time"
msgstr "總時間"
msgid "Total test time for all commits/merges"
msgstr "åˆä½µ (merge) 與更動記錄 (commit) 的總測試時間"
+msgid "Total: %{total}"
+msgstr ""
+
msgid "Track activity with Contribution Analytics."
-msgstr "追蹤分æžè²¢ç»èˆ‡æ´»å‹•ã€‚"
+msgstr ""
+
+msgid "Track groups of issues that share a theme, across projects and milestones"
+msgstr ""
+
+msgid "Track time with quick actions"
+msgstr ""
+
+msgid "Trigger this manual action"
+msgstr ""
+
+msgid "Turn on Service Desk"
+msgstr ""
+
+msgid "Unknown"
+msgstr ""
msgid "Unlock"
msgstr "解鎖"
@@ -2201,26 +4499,29 @@ msgstr "解鎖"
msgid "Unlocked"
msgstr "已解鎖"
+msgid "Unresolve discussion"
+msgstr ""
+
msgid "Unstar"
msgstr "å–消收è—"
-msgid "Unsubscribe"
-msgstr "å–消訂閱"
+msgid "Up to date"
+msgstr ""
msgid "Upgrade your plan to activate Advanced Global Search."
-msgstr "å‡ç´šæ‚¨çš„方案以啟用進階全局æœå°‹ã€‚"
+msgstr ""
msgid "Upgrade your plan to activate Contribution Analytics."
-msgstr "å‡ç´šæ‚¨çš„方案以啟用貢ç»åˆ†æžã€‚"
+msgstr ""
msgid "Upgrade your plan to activate Group Webhooks."
-msgstr "å‡ç´šæ‚¨çš„方案以啟用群組 Webhooks。"
+msgstr ""
msgid "Upgrade your plan to activate Issue weight."
-msgstr "å‡ç´šæ‚¨çš„方案以啟用å•é¡Œæ¬Šé‡ã€‚"
+msgstr ""
msgid "Upgrade your plan to improve Issue boards."
-msgstr "å‡ç´šæ‚¨çš„方案以使用議題看æ¿ï¼ˆissue boards)"
+msgstr ""
msgid "Upload New File"
msgstr "上傳新檔案"
@@ -2228,24 +4529,72 @@ msgstr "上傳新檔案"
msgid "Upload file"
msgstr "上傳檔案"
+msgid "Upload new avatar"
+msgstr ""
+
msgid "UploadLink|click to upload"
msgstr "點擊上傳"
+msgid "Upvotes"
+msgstr ""
+
+msgid "Usage statistics"
+msgstr ""
+
+msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
+msgstr ""
+
msgid "Use the following registration token during setup:"
msgstr "在安è£éŽç¨‹ä¸­ä½¿ç”¨æ­¤è¨»å†Šæ†‘è­‰ (registration token):"
msgid "Use your global notification setting"
msgstr "使用全域通知設定"
+msgid "Used by members to sign in to your group in GitLab"
+msgstr ""
+
+msgid "User and IP Rate Limits"
+msgstr ""
+
+msgid "Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want."
+msgstr ""
+
+msgid "Various container registry settings."
+msgstr ""
+
+msgid "Various email settings."
+msgstr ""
+
+msgid "Various settings that affect GitLab performance."
+msgstr ""
+
+msgid "View and edit lines"
+msgstr ""
+
+msgid "View epics list"
+msgstr ""
+
msgid "View file @ "
msgstr "ç€è¦½æª”案 @ "
+msgid "View group labels"
+msgstr ""
+
+msgid "View labels"
+msgstr ""
+
msgid "View open merge request"
msgstr "查看此分支的åˆä½µè«‹æ±‚ (merge request)"
+msgid "View project labels"
+msgstr ""
+
msgid "View replaced file @ "
msgstr "ç€è¦½å·²æ›¿æ›æª”案 @ "
+msgid "Visibility and access controls"
+msgstr ""
+
msgid "VisibilityLevel|Internal"
msgstr "內部"
@@ -2261,17 +4610,29 @@ msgstr "ä¸æ˜Ž"
msgid "Want to see the data? Please ask an administrator for access."
msgstr "權é™ä¸è¶³ã€‚如需查看相關資料,請å‘管ç†å“¡ç”³è«‹æ¬Šé™ã€‚"
+msgid "We could not verify that one of your projects on GCP has billing enabled. Please try again."
+msgstr ""
+
msgid "We don't have enough data to show this stage."
msgstr "因該階段的資料ä¸è¶³è€Œç„¡æ³•é¡¯ç¤ºç›¸é—œè³‡è¨Š"
+msgid "We want to be sure it is you, please confirm you are not a robot."
+msgstr ""
+
+msgid "Web IDE"
+msgstr ""
+
+msgid "Web terminal"
+msgstr ""
+
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr ""
msgid "Weight"
-msgstr "權é‡"
+msgstr ""
-msgid "When access to a storage fails. GitLab will prevent access to the storage for the time specified here. This allows the filesystem to recover. Repositories on failing shards are temporarly unavailable"
-msgstr "當存å–檔案庫 (repository) 失敗時, GitLab 將在此處指定的時間內防止檔案庫的存å–,以此等待檔案系統æ¢å¾©ã€‚å¤±æ•—çš„æª”æ¡ˆåº«åˆ†æµ (shard) 會暫時無法使用。"
+msgid "When leaving the URL blank, classification labels can still be specified whitout disabling cross project features or performing external authorization checks."
+msgstr ""
msgid "Wiki"
msgstr "Wiki"
@@ -2291,6 +4652,12 @@ msgstr "å®ƒè¢«æŽ¨è–¦å®‰è£ %{markdown} 所以那 GFM 功能在本機呈ç¾ï¼š"
msgid "WikiClone|Start Gollum and edit locally"
msgstr "開始你的 Gollum 並在本機編輯。"
+msgid "WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title."
+msgstr ""
+
+msgid "WikiEdit|There is already a page with the same title in that path."
+msgstr ""
+
msgid "WikiEmptyPageError|You are not allowed to create wiki pages"
msgstr "你沒有權é™å»ºç«‹ Wiki é é¢"
@@ -2376,44 +4743,74 @@ msgid "Wiki|Wiki Pages"
msgstr "維基é é¢"
msgid "With contribution analytics you can have an overview for the activity of issues, merge requests and push events of your organization and its members."
-msgstr "é€éŽè²¢ç»åˆ†æžï¼Œæ‚¨å¯ä»¥åˆ†æžæ‚¨çš„組織åŠå…¶æˆå“¡çš„å•é¡Œã€åˆä½µè«‹æ±‚和推é€æ´»å‹•ã€‚"
+msgstr ""
msgid "Withdraw Access Request"
msgstr "å–消權é™ç”³è«‹"
+msgid "Write a commit message..."
+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 %{project_full_name}. 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 "å°‡è¦åˆªé™¤æœ¬åˆ†æ”¯å°ˆæ¡ˆèˆ‡ä¸»å¹¹ %{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 are going to transfer %{project_full_name} to another owner. Are you ABSOLUTELY sure?"
+msgstr ""
msgid "You are on a read-only GitLab instance."
-msgstr "您在唯讀的 GitLab 主機上。"
+msgstr ""
-msgid "You are on a read-only GitLab instance. If you want to make any changes, you must visit the %{link_to_primary_node}."
-msgstr "您在唯讀的 GitLab 主機上,如果您想è¦é€²è¡Œä¿®æ”¹ï¼Œå¿…須到 %{link_to_primary_node}"
+msgid "You are on a secondary (read-only) Geo node. If you want to make any changes, you must visit the %{primary_node}."
+msgstr ""
+
+msgid "You can also create a project from the command line."
+msgstr ""
+
+msgid "You can also star a label to make it a priority label."
+msgstr ""
+
+msgid "You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}"
+msgstr ""
+
+msgid "You can move around the graph by using the arrow keys."
+msgstr ""
msgid "You can only add files when you are on a branch"
msgstr "åªèƒ½åœ¨åˆ†æ”¯ (branch) 上建立檔案"
+msgid "You can only edit files when you are on a branch"
+msgstr ""
+
msgid "You cannot write to a read-only secondary GitLab Geo instance. Please use %{link_to_primary_node} instead."
-msgstr "您ä¸èƒ½å¯«å…¥å”¯è®€çš„æ¬¡è¦ GitLab Geo 主機。請改用 %{link_to_primary_node}。"
+msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr "您ä¸èƒ½ä¿®æ”¹é€™å€‹å”¯è®€çš„ GitLab 主機。"
+msgid "You do not have the correct permissions to override the settings from the LDAP group sync."
+msgstr ""
+
+msgid "You have no permissions"
+msgstr ""
+
msgid "You have reached your project limit"
msgstr "您已é”到專案數é‡é™åˆ¶"
+msgid "You must have master access to force delete a lock"
+msgstr ""
+
msgid "You must sign in to star a project"
msgstr "必須登入æ‰èƒ½æ”¶è—專案"
+msgid "You need a different license to enable FileLocks feature"
+msgstr ""
+
msgid "You need permission."
msgstr "需è¦æ¬Šé™æ‰èƒ½é€™éº¼åšã€‚"
@@ -2438,6 +4835,36 @@ 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 上傳 (push) 或下載 (pull) 程å¼ç¢¼ã€‚"
+msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
+msgstr ""
+
+msgid "You'll need to use different branch names to get a valid comparison."
+msgstr ""
+
+msgid "You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}"
+msgstr ""
+
+msgid "Your Groups"
+msgstr ""
+
+msgid "Your Kubernetes cluster information on this page is still editable, but you are advised to disable and reconfigure"
+msgstr ""
+
+msgid "Your Projects (default)"
+msgstr ""
+
+msgid "Your Projects' Activity"
+msgstr ""
+
+msgid "Your Todos"
+msgstr ""
+
+msgid "Your changes can be committed to %{branch_name} because a merge request is open."
+msgstr ""
+
+msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr "你的留言將ä¸æœƒè¢«å…¬é–‹ã€‚"
@@ -2450,19 +4877,347 @@ msgstr "您的åå­—"
msgid "Your projects"
msgstr "你的計劃"
-msgid "commit"
-msgstr "æ›´å‹•"
+msgid "among other things"
+msgstr ""
+
+msgid "and %d fixed vulnerability"
+msgid_plural "and %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "assign yourself"
+msgstr ""
+
+msgid "branch name"
+msgstr ""
+
+msgid "by"
+msgstr ""
+
+msgid "ciReport|%{type} detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|%{type} detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Code quality"
+msgstr ""
+
+msgid "ciReport|DAST detected no alerts by analyzing the review app"
+msgstr ""
+
+msgid "ciReport|Dependency scanning"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Dependency scanning detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|Failed to load %{reportName} report"
+msgstr ""
+
+msgid "ciReport|Fixed:"
+msgstr ""
+
+msgid "ciReport|Instances"
+msgstr ""
+
+msgid "ciReport|Learn more about whitelisting"
+msgstr ""
+
+msgid "ciReport|Loading %{reportName} report"
+msgstr ""
+
+msgid "ciReport|No changes to code quality"
+msgstr ""
+
+msgid "ciReport|No changes to performance metrics"
+msgstr ""
+
+msgid "ciReport|Performance metrics"
+msgstr ""
+
+msgid "ciReport|SAST"
+msgstr ""
+
+msgid "ciReport|SAST detected"
+msgstr ""
+
+msgid "ciReport|SAST detected no new security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST detected no security vulnerabilities"
+msgstr ""
+
+msgid "ciReport|SAST:container no vulnerabilities were found"
+msgstr ""
+
+msgid "ciReport|Security scanning"
+msgstr ""
+
+msgid "ciReport|Security scanning failed loading any results"
+msgstr ""
+
+msgid "ciReport|Show complete code vulnerabilities report"
+msgstr ""
+
+msgid "ciReport|Unapproved vulnerabilities (red) can be marked as approved. %{helpLink}"
+msgstr ""
+
+msgid "ciReport|no vulnerabilities"
+msgstr ""
+
+msgid "command line instructions"
+msgstr ""
+
+msgid "connecting"
+msgstr ""
+
+msgid "could not read private key, is the passphrase correct?"
+msgstr ""
msgid "day"
msgid_plural "days"
msgstr[0] "天"
+msgid "detected %d fixed vulnerability"
+msgid_plural "detected %d fixed vulnerabilities"
+msgstr[0] ""
+
+msgid "detected %d new vulnerability"
+msgid_plural "detected %d new vulnerabilities"
+msgstr[0] ""
+
+msgid "detected no vulnerabilities"
+msgstr ""
+
+msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
+msgstr ""
+
+msgid "here"
+msgstr ""
+
+msgid "importing"
+msgstr ""
+
+msgid "in progress"
+msgstr ""
+
+msgid "is invalid because there is downstream lock"
+msgstr ""
+
+msgid "is invalid because there is upstream lock"
+msgstr ""
+
+msgid "is not a valid X509 certificate."
+msgstr ""
+
+msgid "locked by %{path_lock_user_name} %{created_at}"
+msgstr ""
+
+msgid "merge request"
+msgid_plural "merge requests"
+msgstr[0] ""
+
+msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB"
+msgstr ""
+
+msgid "mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage is %{emphasisStart} unchanged %{emphasisEnd} at %{memoryFrom}MB"
+msgstr ""
+
+msgid "mrWidget|Add approval"
+msgstr ""
+
+msgid "mrWidget|Allows edits from maintainers"
+msgstr ""
+
+msgid "mrWidget|An error occured while removing your approval."
+msgstr ""
+
+msgid "mrWidget|An error occured while retrieving approval data for this merge request."
+msgstr ""
+
+msgid "mrWidget|An error occured while submitting your approval."
+msgstr ""
+
+msgid "mrWidget|Approve"
+msgstr ""
+
+msgid "mrWidget|Approved"
+msgstr ""
+
+msgid "mrWidget|Approved by"
+msgstr ""
+
+msgid "mrWidget|Cancel automatic merge"
+msgstr ""
+
+msgid "mrWidget|Check out branch"
+msgstr ""
+
+msgid "mrWidget|Checking ability to merge automatically"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick"
+msgstr ""
+
+msgid "mrWidget|Cherry-pick this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Closed"
+msgstr ""
+
+msgid "mrWidget|Closed by"
+msgstr ""
+
+msgid "mrWidget|Closes"
+msgstr ""
+
+msgid "mrWidget|Deployment statistics are not available currently"
+msgstr ""
+
+msgid "mrWidget|Did not close"
+msgstr ""
+
+msgid "mrWidget|Email patches"
+msgstr ""
+
+msgid "mrWidget|Failed to load deployment statistics"
+msgstr ""
+
+msgid "mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line"
+msgstr ""
+
+msgid "mrWidget|Loading deployment statistics"
+msgstr ""
+
+msgid "mrWidget|Mentions"
+msgstr ""
+
+msgid "mrWidget|Merge"
+msgstr ""
+
+msgid "mrWidget|Merge failed."
+msgstr ""
+
+msgid "mrWidget|Merge locally"
+msgstr ""
+
+msgid "mrWidget|Merged by"
+msgstr ""
+
+msgid "mrWidget|Plain diff"
+msgstr ""
+
+msgid "mrWidget|Refresh"
+msgstr ""
+
+msgid "mrWidget|Refresh now"
+msgstr ""
+
+msgid "mrWidget|Refreshing now"
+msgstr ""
+
+msgid "mrWidget|Remove Source Branch"
+msgstr ""
+
+msgid "mrWidget|Remove source branch"
+msgstr ""
+
+msgid "mrWidget|Remove your approval"
+msgstr ""
+
+msgid "mrWidget|Request to merge"
+msgstr ""
+
+msgid "mrWidget|Resolve conflicts"
+msgstr ""
+
+msgid "mrWidget|Revert"
+msgstr ""
+
+msgid "mrWidget|Revert this merge request in a new merge request"
+msgstr ""
+
+msgid "mrWidget|Set by"
+msgstr ""
+
+msgid "mrWidget|The changes were merged into"
+msgstr ""
+
+msgid "mrWidget|The changes were not merged into"
+msgstr ""
+
+msgid "mrWidget|The changes will be merged into"
+msgstr ""
+
+msgid "mrWidget|The source branch has been removed"
+msgstr ""
+
+msgid "mrWidget|The source branch is being removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will be removed"
+msgstr ""
+
+msgid "mrWidget|The source branch will not be removed"
+msgstr ""
+
+msgid "mrWidget|There are merge conflicts"
+msgstr ""
+
+msgid "mrWidget|This merge request failed to be merged automatically"
+msgstr ""
+
+msgid "mrWidget|This merge request is in the process of being merged"
+msgstr ""
+
+msgid "mrWidget|This project is archived, write access has been disabled"
+msgstr ""
+
+msgid "mrWidget|Web IDE"
+msgstr ""
+
+msgid "mrWidget|You can merge this merge request manually using the"
+msgstr ""
+
+msgid "mrWidget|You can remove source branch now"
+msgstr ""
+
+msgid "mrWidget|branch does not exist."
+msgstr ""
+
+msgid "mrWidget|command line"
+msgstr ""
+
+msgid "mrWidget|into"
+msgstr ""
+
+msgid "mrWidget|to be merged automatically when the pipeline succeeds"
+msgstr ""
+
msgid "new merge request"
msgstr "建立åˆä½µè«‹æ±‚"
msgid "notification emails"
msgstr "通知信"
+msgid "or"
+msgstr ""
+
msgid "parent"
msgid_plural "parents"
msgstr[0] "上層"
@@ -2473,9 +5228,30 @@ msgstr "密碼"
msgid "personal access token"
msgstr "ç§äººå­˜å–憑證 (access token)"
+msgid "private key does not match certificate."
+msgstr ""
+
+msgid "remove due date"
+msgstr ""
+
+msgid "source"
+msgstr ""
+
+msgid "spendCommand|%{slash_command} will update the sum of the time spent."
+msgstr ""
+
+msgid "this document"
+msgstr ""
+
msgid "to help your contributors communicate effectively!"
-msgstr "幫助你的貢ç»è€…進行有效的æºé€šï¼"
+msgstr ""
msgid "username"
msgstr "使用者å稱"
+msgid "uses Kubernetes clusters to deploy your code!"
+msgstr ""
+
+msgid "with %{additions} additions, %{deletions} deletions."
+msgstr ""
+
diff --git a/package.json b/package.json
index aa4e4e79f49..27f612aca38 100644
--- a/package.json
+++ b/package.json
@@ -1,23 +1,25 @@
{
"private": true,
"scripts": {
- "dev-server": "nodemon --watch config/webpack.config.js -- ./node_modules/.bin/webpack-dev-server --config config/webpack.config.js",
+ "dev-server": "nodemon -w 'config/webpack.config.js' --exec 'webpack-dev-server --config config/webpack.config.js'",
"eslint": "eslint --max-warnings 0 --ext .js,.vue .",
"eslint-fix": "eslint --max-warnings 0 --ext .js,.vue --fix .",
"eslint-report": "eslint --max-warnings 0 --ext .js,.vue --format html --output-file ./eslint-report.html .",
"karma": "karma start config/karma.config.js --single-run",
"karma-coverage": "BABEL_ENV=coverage karma start config/karma.config.js --single-run",
"karma-start": "karma start config/karma.config.js",
- "svg": "node config/svg.config.js",
+ "prettier-staged": "node ./scripts/frontend/prettier.js",
+ "prettier-staged-save": "node ./scripts/frontend/prettier.js save",
+ "prettier-all": "node ./scripts/frontend/prettier.js check-all",
+ "prettier-all-save": "node ./scripts/frontend/prettier.js save-all",
"webpack": "webpack --config config/webpack.config.js",
"webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js"
},
"dependencies": {
+ "@gitlab-org/gitlab-svgs": "^1.17.0",
"autosize": "^4.0.0",
"axios": "^0.17.1",
- "axios-mock-adapter": "^1.10.0",
"babel-core": "^6.26.0",
- "babel-eslint": "^8.0.2",
"babel-loader": "^7.1.2",
"babel-plugin-transform-define": "^1.3.0",
"babel-preset-latest": "^6.24.1",
@@ -25,13 +27,14 @@
"blackst0ne-mermaid": "^7.1.0-fixed",
"bootstrap-sass": "^3.3.6",
"brace-expansion": "^1.1.8",
+ "chart.js": "1.0.2",
"classlist-polyfill": "^1.2.0",
"clipboard": "^1.7.1",
- "compression-webpack-plugin": "^1.0.0",
- "copy-webpack-plugin": "^4.0.1",
+ "compression-webpack-plugin": "^1.1.7",
+ "copy-webpack-plugin": "^4.4.1",
"core-js": "^2.4.1",
"cropper": "^2.3.0",
- "css-loader": "^0.28.0",
+ "css-loader": "^0.28.9",
"d3-array": "^1.2.1",
"d3-axis": "^1.0.8",
"d3-brush": "^1.0.4",
@@ -45,69 +48,78 @@
"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",
+ "exports-loader": "^0.7.0",
+ "file-loader": "^1.1.8",
"fuzzaldrin-plus": "^0.5.0",
- "imports-loader": "^0.7.1",
+ "glob": "^7.1.2",
+ "imports-loader": "^0.8.0",
"jed": "^1.1.1",
- "jquery": "^2.2.4",
+ "jquery": "^3.2.1",
"jquery-ujs": "1.2.2",
+ "jquery.waitforimages": "^2.2.0",
"js-cookie": "^2.1.3",
"jszip": "^3.1.3",
"jszip-utils": "^0.0.2",
- "marked": "^0.3.6",
+ "katex": "^0.8.3",
+ "marked": "^0.3.12",
"monaco-editor": "0.10.0",
"mousetrap": "^1.4.6",
"name-all-modules-plugin": "^1.0.1",
"pikaday": "^1.6.1",
"prismjs": "^1.6.0",
"raphael": "^2.2.7",
- "raven-js": "^3.14.0",
+ "raven-js": "^3.22.1",
"raw-loader": "^0.5.1",
- "react-dev-utils": "^0.5.2",
+ "react-dev-utils": "^5.0.0",
+ "sanitize-html": "^1.16.1",
"select2": "3.5.2-browserify",
"sql.js": "^0.4.0",
+ "style-loader": "^0.20.2",
"svg4everybody": "2.1.9",
"three": "^0.84.0",
"three-orbit-controls": "^82.1.0",
"three-stl-loader": "^1.0.4",
"timeago.js": "^3.0.2",
"underscore": "^1.8.3",
- "url-loader": "^0.5.8",
+ "url-loader": "^0.6.2",
"visibilityjs": "^1.2.4",
- "vue": "^2.5.8",
- "vue-loader": "^13.5.0",
- "vue-resource": "^1.3.4",
+ "vue": "^2.5.13",
+ "vue-loader": "^14.1.1",
+ "vue-resource": "^1.3.5",
"vue-router": "^3.0.1",
- "vue-template-compiler": "^2.5.8",
+ "vue-template-compiler": "^2.5.13",
"vuex": "^3.0.1",
- "webpack": "^3.5.5",
- "webpack-bundle-analyzer": "^2.8.2",
+ "webpack": "^3.11.0",
+ "webpack-bundle-analyzer": "^2.10.0",
"webpack-stats-plugin": "^0.1.5",
"worker-loader": "^1.1.0"
},
"devDependencies": {
- "@gitlab-org/gitlab-svgs": "^1.3.0",
+ "axios-mock-adapter": "^1.10.0",
+ "babel-eslint": "^8.0.2",
"babel-plugin-istanbul": "^4.1.5",
- "eslint": "^3.10.1",
+ "eslint": "^3.18.0",
"eslint-config-airbnb-base": "^10.0.1",
"eslint-import-resolver-webpack": "^0.8.3",
"eslint-plugin-filenames": "^1.1.0",
+ "eslint-plugin-html": "2.0.1",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jasmine": "^2.1.0",
"eslint-plugin-promise": "^3.5.0",
+ "eslint-plugin-vue": "^4.0.1",
+ "ignore": "^3.3.7",
"istanbul": "^0.4.5",
- "jasmine-core": "^2.6.3",
+ "jasmine-core": "^2.9.0",
"jasmine-jquery": "^2.1.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": "^2.0.0",
+ "karma-chrome-launcher": "^2.2.0",
+ "karma-coverage-istanbul-reporter": "^1.4.1",
+ "karma-jasmine": "^1.1.1",
+ "karma-mocha-reporter": "^2.2.5",
"karma-sourcemap-loader": "^0.3.7",
- "karma-webpack": "^2.0.4",
- "nodemon": "^1.11.0",
- "webpack-dev-server": "^2.6.1"
+ "karma-webpack": "2.0.7",
+ "nodemon": "^1.15.1",
+ "prettier": "1.11.1",
+ "webpack-dev-server": "^2.11.2"
}
}
diff --git a/plugins/examples/save_to_file.clj b/plugins/examples/save_to_file.clj
new file mode 100755
index 00000000000..a59d83749d3
--- /dev/null
+++ b/plugins/examples/save_to_file.clj
@@ -0,0 +1,3 @@
+#!/usr/bin/env clojure
+(let [in (slurp *in*)]
+ (spit "/tmp/clj-data.txt" in))
diff --git a/plugins/examples/save_to_file.rb b/plugins/examples/save_to_file.rb
new file mode 100755
index 00000000000..61b0df9bfd6
--- /dev/null
+++ b/plugins/examples/save_to_file.rb
@@ -0,0 +1,3 @@
+#!/usr/bin/env ruby
+x = STDIN.read
+File.write('/tmp/rb-data.txt', x)
diff --git a/public/robots.txt b/public/robots.txt
index 123272a9834..1f9d42f4adc 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -20,6 +20,7 @@ Disallow: /projects/new
Disallow: /groups/new
Disallow: /groups/*/edit
Disallow: /users
+Disallow: /help
# Global snippets
User-Agent: *
diff --git a/qa/Gemfile b/qa/Gemfile
index 4c866a3f893..c3e61568f3d 100644
--- a/qa/Gemfile
+++ b/qa/Gemfile
@@ -6,3 +6,5 @@ gem 'capybara-screenshot', '~> 1.0.18'
gem 'rake', '~> 12.3.0'
gem 'rspec', '~> 3.7'
gem 'selenium-webdriver', '~> 3.8.0'
+gem 'net-ssh', require: false
+gem 'airborne', '~> 0.2.13'
diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock
index 88d5fe834a0..51d2e4d7a10 100644
--- a/qa/Gemfile.lock
+++ b/qa/Gemfile.lock
@@ -1,8 +1,19 @@
GEM
remote: https://rubygems.org/
specs:
+ activesupport (5.1.4)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (~> 0.7)
+ minitest (~> 5.1)
+ tzinfo (~> 1.1)
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
+ airborne (0.2.13)
+ activesupport
+ rack
+ rack-test (~> 0.6, >= 0.6.2)
+ rest-client (>= 1.7.3, < 3.0)
+ rspec (~> 3.1)
byebug (9.1.0)
capybara (2.16.1)
addressable
@@ -17,13 +28,26 @@ GEM
childprocess (0.8.0)
ffi (~> 1.0, >= 1.0.11)
coderay (1.1.2)
+ concurrent-ruby (1.0.5)
diff-lcs (1.3)
+ domain_name (0.5.20170404)
+ unf (>= 0.0.5, < 1.0.0)
ffi (1.9.18)
+ http-cookie (1.0.3)
+ domain_name (~> 0.5)
+ i18n (0.9.1)
+ concurrent-ruby (~> 1.0)
launchy (2.4.3)
addressable (~> 2.3)
method_source (0.9.0)
+ mime-types (3.1)
+ mime-types-data (~> 3.2015)
+ mime-types-data (3.2016.0521)
mini_mime (1.0.0)
mini_portile2 (2.3.0)
+ minitest (5.11.1)
+ net-ssh (4.1.0)
+ netrc (0.11.0)
nokogiri (1.8.1)
mini_portile2 (~> 2.3.0)
pry (0.11.3)
@@ -37,11 +61,15 @@ GEM
rack-test (0.8.2)
rack (>= 1.0, < 3)
rake (12.3.0)
+ rest-client (2.0.2)
+ http-cookie (>= 1.0.2, < 2.0)
+ mime-types (>= 1.16, < 4.0)
+ netrc (~> 0.8)
rspec (3.7.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.7.0)
- rspec-core (3.7.0)
+ rspec-core (3.7.1)
rspec-support (~> 3.7.0)
rspec-expectations (3.7.0)
diff-lcs (>= 1.2.0, < 2.0)
@@ -54,6 +82,12 @@ GEM
selenium-webdriver (3.8.0)
childprocess (~> 0.5)
rubyzip (~> 1.0)
+ thread_safe (0.3.6)
+ tzinfo (1.2.4)
+ thread_safe (~> 0.1)
+ unf (0.1.4)
+ unf_ext
+ unf_ext (0.0.7.4)
xpath (2.1.0)
nokogiri (~> 1.3)
@@ -61,12 +95,14 @@ PLATFORMS
ruby
DEPENDENCIES
+ airborne (~> 0.2.13)
capybara (~> 2.16.1)
capybara-screenshot (~> 1.0.18)
+ net-ssh
pry-byebug (~> 3.5.1)
rake (~> 12.3.0)
rspec (~> 3.7)
selenium-webdriver (~> 3.8.0)
BUNDLED WITH
- 1.16.0
+ 1.16.1
diff --git a/qa/README.md b/qa/README.md
index 7f2dd39ff63..a4b4398645e 100644
--- a/qa/README.md
+++ b/qa/README.md
@@ -17,6 +17,17 @@ against any existing instance.
1. Along with GitLab Docker Images we also build and publish GitLab QA images.
1. GitLab QA project uses these images to execute integration tests.
+## Validating GitLab views / partials / selectors in merge requests
+
+We recently added a new CI job that is going to be triggered for every push
+event in CE and EE projects. The job is called `qa:selectors` and it will
+verify coupling between page objects implemented as a part of GitLab QA
+and corresponding views / partials / selectors in CE / EE.
+
+Whenever `qa:selectors` job fails in your merge request, you are supposed to
+fix [page objects](qa/page/README.md). You should also trigger end-to-end tests
+using `package-and-qa` manual action, to test if everything works fine.
+
## How can I use it?
You can use GitLab QA to exercise tests on any live instance! For example, the
@@ -27,13 +38,17 @@ following call would login to a local [GDK] instance and run all specs in
bin/qa Test::Instance http://localhost:3000
```
+### Writing tests
+
+1. [Using page objects](qa/page/README.md)
+
### Running specific tests
You can also supply specific tests to run as another parameter. For example, to
-test the EE license specs, you can run:
+run the repository-related specs, you can execute:
```
-EE_LICENSE="<YOUR LICENSE KEY>" bin/qa Test::Instance http://localhost qa/specs/features/ee
+bin/qa Test::Instance http://localhost qa/specs/features/repository/
```
Since the arguments would be passed to `rspec`, you could use all `rspec`
@@ -55,6 +70,30 @@ If you need to authenticate as a different user, you can provide the
GITLAB_USERNAME=jsmith GITLAB_PASSWORD=password bin/qa Test::Instance https://gitlab.example.com
```
+If your user doesn't have permission to default sandbox group
+`gitlab-qa-sandbox`, you could also use another sandbox group by giving
+`GITLAB_SANDBOX_NAME`:
+
+```
+GITLAB_USERNAME=jsmith GITLAB_PASSWORD=password GITLAB_SANDBOX_NAME=jsmith-qa-sandbox bin/qa Test::Instance https://gitlab.example.com
+```
+
+In addition, the `GITLAB_USER_TYPE` can be set to "ldap" to sign in as an LDAP user:
+
+```
+GITLAB_USER_TYPE=ldap GITLAB_USERNAME=jsmith GITLAB_PASSWORD=password GITLAB_SANDBOX_NAME=jsmith-qa-sandbox bin/qa Test::Instance https://gitlab.example.com
+```
+
All [supported environment variables are here](https://gitlab.com/gitlab-org/gitlab-qa#supported-environment-variables).
+### Building a Docker image to test
+
+Once you have made changes to the CE/EE repositories, you may want to build a
+Docker image to test locally instead of waiting for the `gitlab-ce-qa` or
+`gitlab-ee-qa` nightly builds. To do that, you can run from this directory:
+
+```sh
+docker build -t gitlab/gitlab-ce-qa:nightly .
+```
+
[GDK]: https://gitlab.com/gitlab-org/gitlab-development-kit/
diff --git a/qa/qa.rb b/qa/qa.rb
index 340f5e35c67..56a99c32b26 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -10,6 +10,10 @@ module QA
autoload :Namespace, 'qa/runtime/namespace'
autoload :Scenario, 'qa/runtime/scenario'
autoload :Browser, 'qa/runtime/browser'
+ autoload :Env, 'qa/runtime/env'
+ autoload :RSAKey, 'qa/runtime/rsa_key'
+ autoload :Address, 'qa/runtime/address'
+ autoload :API, 'qa/runtime/api'
end
##
@@ -23,7 +27,13 @@ module QA
module Resource
autoload :Sandbox, 'qa/factory/resource/sandbox'
autoload :Group, 'qa/factory/resource/group'
+ autoload :Issue, 'qa/factory/resource/issue'
autoload :Project, 'qa/factory/resource/project'
+ autoload :MergeRequest, 'qa/factory/resource/merge_request'
+ autoload :DeployKey, 'qa/factory/resource/deploy_key'
+ autoload :SecretVariable, 'qa/factory/resource/secret_variable'
+ autoload :Runner, 'qa/factory/resource/runner'
+ autoload :PersonalAccessToken, 'qa/factory/resource/personal_access_token'
end
module Repository
@@ -44,7 +54,7 @@ module QA
#
autoload :Bootable, 'qa/scenario/bootable'
autoload :Actable, 'qa/scenario/actable'
- autoload :Entrypoint, 'qa/scenario/entrypoint'
+ autoload :Taggable, 'qa/scenario/taggable'
autoload :Template, 'qa/scenario/template'
##
@@ -54,8 +64,13 @@ module QA
autoload :Instance, 'qa/scenario/test/instance'
module Integration
+ autoload :LDAP, 'qa/scenario/test/integration/ldap'
autoload :Mattermost, 'qa/scenario/test/integration/mattermost'
end
+
+ module Sanity
+ autoload :Selectors, 'qa/scenario/test/sanity/selectors'
+ end
end
end
@@ -66,13 +81,26 @@ module QA
#
module Page
autoload :Base, 'qa/page/base'
+ autoload :View, 'qa/page/view'
+ autoload :Element, 'qa/page/element'
+ autoload :Validator, 'qa/page/validator'
module Main
autoload :Login, 'qa/page/main/login'
- autoload :Menu, 'qa/page/main/menu'
autoload :OAuth, 'qa/page/main/oauth'
end
+ module Settings
+ autoload :Common, 'qa/page/settings/common'
+ end
+
+ module Menu
+ autoload :Main, 'qa/page/menu/main'
+ autoload :Side, 'qa/page/menu/side'
+ autoload :Admin, 'qa/page/menu/admin'
+ autoload :Profile, 'qa/page/menu/profile'
+ end
+
module Dashboard
autoload :Projects, 'qa/page/dashboard/projects'
autoload :Groups, 'qa/page/dashboard/groups'
@@ -86,17 +114,63 @@ module QA
module Project
autoload :New, 'qa/page/project/new'
autoload :Show, 'qa/page/project/show'
+ autoload :Activity, 'qa/page/project/activity'
+
+ module Pipeline
+ autoload :Index, 'qa/page/project/pipeline/index'
+ autoload :Show, 'qa/page/project/pipeline/show'
+ end
+
+ module Job
+ autoload :Show, 'qa/page/project/job/show'
+ end
+
+ module Settings
+ autoload :Common, 'qa/page/project/settings/common'
+ autoload :Advanced, 'qa/page/project/settings/advanced'
+ autoload :Main, 'qa/page/project/settings/main'
+ autoload :Repository, 'qa/page/project/settings/repository'
+ autoload :CICD, 'qa/page/project/settings/ci_cd'
+ autoload :DeployKeys, 'qa/page/project/settings/deploy_keys'
+ autoload :SecretVariables, 'qa/page/project/settings/secret_variables'
+ autoload :Runners, 'qa/page/project/settings/runners'
+ autoload :MergeRequest, 'qa/page/project/settings/merge_request'
+ end
+
+ module Issue
+ autoload :New, 'qa/page/project/issue/new'
+ autoload :Show, 'qa/page/project/issue/show'
+ autoload :Index, 'qa/page/project/issue/index'
+ end
+ end
+
+ module Profile
+ autoload :PersonalAccessTokens, 'qa/page/profile/personal_access_tokens'
+ end
+
+ module MergeRequest
+ autoload :New, 'qa/page/merge_request/new'
+ autoload :Show, 'qa/page/merge_request/show'
end
module Admin
- autoload :Menu, 'qa/page/admin/menu'
- autoload :Settings, 'qa/page/admin/settings'
+ module Settings
+ autoload :RepositoryStorage, 'qa/page/admin/settings/repository_storage'
+ autoload :Main, 'qa/page/admin/settings/main'
+ end
end
module Mattermost
autoload :Main, 'qa/page/mattermost/main'
autoload :Login, 'qa/page/mattermost/login'
end
+
+ ##
+ # Classes describing components that are used by several pages.
+ #
+ module Component
+ autoload :Dropzone, 'qa/page/component/dropzone'
+ end
end
##
@@ -104,13 +178,17 @@ module QA
#
module Git
autoload :Repository, 'qa/git/repository'
+ autoload :Location, 'qa/git/location'
end
##
- # Classes describing shell interaction with GitLab
+ # Classes describing services being part of GitLab and how we can interact
+ # with these services, like through the shell.
#
- module Shell
- autoload :Omnibus, 'qa/shell/omnibus'
+ module Service
+ autoload :Shellout, 'qa/service/shellout'
+ autoload :Omnibus, 'qa/service/omnibus'
+ autoload :Runner, 'qa/service/runner'
end
##
diff --git a/qa/qa/factory/base.rb b/qa/qa/factory/base.rb
index 00851a7bece..afaa96b4541 100644
--- a/qa/qa/factory/base.rb
+++ b/qa/qa/factory/base.rb
@@ -1,12 +1,19 @@
+require 'forwardable'
+
module QA
module Factory
class Base
+ extend SingleForwardable
+
+ def_delegators :evaluator, :dependency, :dependencies
+ def_delegators :evaluator, :product, :attributes
+
def fabricate!(*_args)
raise NotImplementedError
end
def self.fabricate!(*args)
- Factory::Product.populate!(new) do |factory|
+ new.tap do |factory|
yield factory if block_given?
dependencies.each do |name, signature|
@@ -14,19 +21,37 @@ module QA
end
factory.fabricate!(*args)
+
+ return Factory::Product.populate!(factory)
end
end
- def self.dependencies
- @dependencies ||= {}
+ def self.evaluator
+ @evaluator ||= Factory::Base::DSL.new(self)
end
- def self.dependency(factory, as:, &block)
- as.tap do |name|
- class_eval { attr_accessor name }
+ class DSL
+ attr_reader :dependencies, :attributes
+
+ def initialize(base)
+ @base = base
+ @dependencies = {}
+ @attributes = {}
+ end
+
+ def dependency(factory, as:, &block)
+ as.tap do |name|
+ @base.class_eval { attr_accessor name }
+
+ Dependency::Signature.new(factory, block).tap do |signature|
+ @dependencies.store(name, signature)
+ end
+ end
+ end
- Dependency::Signature.new(factory, block).tap do |signature|
- dependencies.store(name, signature)
+ def product(attribute, &block)
+ Product::Attribute.new(attribute, block).tap do |signature|
+ @attributes.store(attribute, signature)
end
end
end
diff --git a/qa/qa/factory/dependency.rb b/qa/qa/factory/dependency.rb
index d0e85a68237..fc5dc82ce29 100644
--- a/qa/qa/factory/dependency.rb
+++ b/qa/qa/factory/dependency.rb
@@ -16,20 +16,21 @@ module QA
def build!
return if overridden?
- Builder.new(@signature).fabricate!.tap do |product|
+ Builder.new(@signature, @factory).fabricate!.tap do |product|
@factory.public_send("#{@name}=", product)
end
end
class Builder
- def initialize(signature)
+ def initialize(signature, caller_factory)
@factory = signature.factory
@block = signature.block
+ @caller_factory = caller_factory
end
def fabricate!
@factory.fabricate! do |factory|
- @block&.call(factory)
+ @block&.call(factory, @caller_factory)
end
end
end
diff --git a/qa/qa/factory/product.rb b/qa/qa/factory/product.rb
index df35bbbb443..996b7f14f61 100644
--- a/qa/qa/factory/product.rb
+++ b/qa/qa/factory/product.rb
@@ -5,8 +5,9 @@ module QA
class Product
include Capybara::DSL
- def initialize(factory)
- @factory = factory
+ Attribute = Struct.new(:name, :block)
+
+ def initialize
@location = current_url
end
@@ -15,11 +16,14 @@ module QA
end
def self.populate!(factory)
- raise ArgumentError unless block_given?
-
- yield factory
-
- new(factory)
+ new.tap do |product|
+ factory.class.attributes.each_value do |attribute|
+ product.instance_exec(factory, attribute.block) do |factory, block|
+ value = block.call(factory)
+ product.define_singleton_method(attribute.name) { value }
+ end
+ end
+ end
end
end
end
diff --git a/qa/qa/factory/repository/push.rb b/qa/qa/factory/repository/push.rb
index 2f4de4173d4..6e8905cde78 100644
--- a/qa/qa/factory/repository/push.rb
+++ b/qa/qa/factory/repository/push.rb
@@ -2,7 +2,7 @@ module QA
module Factory
module Repository
class Push < Factory::Base
- attr_writer :file_name, :file_content, :commit_message, :branch_name
+ attr_writer :file_name, :file_content, :commit_message, :branch_name, :new_branch
dependency Factory::Resource::Project, as: :project do |project|
project.name = 'project-with-code'
@@ -14,6 +14,7 @@ module QA
@file_content = '# This is test project'
@commit_message = "Add #{@file_name}"
@branch_name = 'master'
+ @new_branch = true
end
def fabricate!
@@ -29,6 +30,7 @@ module QA
repository.clone
repository.configure_identity('GitLab QA', 'root@gitlab.com')
+ repository.checkout(@branch_name) unless @new_branch
repository.add_file(@file_name, @file_content)
repository.commit(@commit_message)
repository.push_changes(@branch_name)
diff --git a/qa/qa/factory/resource/deploy_key.rb b/qa/qa/factory/resource/deploy_key.rb
new file mode 100644
index 00000000000..ff0b4a46b77
--- /dev/null
+++ b/qa/qa/factory/resource/deploy_key.rb
@@ -0,0 +1,43 @@
+module QA
+ module Factory
+ module Resource
+ class DeployKey < Factory::Base
+ attr_accessor :title, :key
+
+ product :title do
+ Page::Project::Settings::Repository.act do
+ expand_deploy_keys(&:key_title)
+ end
+ end
+
+ product :fingerprint do
+ Page::Project::Settings::Repository.act do
+ expand_deploy_keys(&:key_fingerprint)
+ end
+ end
+
+ dependency Factory::Resource::Project, as: :project do |project|
+ project.name = 'project-to-deploy'
+ project.description = 'project for adding deploy key test'
+ end
+
+ def fabricate!
+ project.visit!
+
+ Page::Menu::Side.act do
+ click_repository_settings
+ end
+
+ Page::Project::Settings::Repository.perform do |setting|
+ setting.expand_deploy_keys do |page|
+ page.fill_key_title(title)
+ page.fill_key_value(key)
+
+ page.add_key
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/factory/resource/issue.rb b/qa/qa/factory/resource/issue.rb
new file mode 100644
index 00000000000..95f48e20b3e
--- /dev/null
+++ b/qa/qa/factory/resource/issue.rb
@@ -0,0 +1,32 @@
+module QA
+ module Factory
+ module Resource
+ class Issue < Factory::Base
+ attr_writer :title, :description, :project
+
+ dependency Factory::Resource::Project, as: :project do |project|
+ project.name = 'project-for-issues'
+ project.description = 'project for adding issues'
+ end
+
+ product :title do
+ Page::Project::Issue::Show.act { issue_title }
+ end
+
+ def fabricate!
+ project.visit!
+
+ Page::Project::Show.act do
+ go_to_new_issue
+ end
+
+ Page::Project::Issue::New.perform do |page|
+ page.add_title(@title)
+ page.add_description(@description)
+ page.create_new_issue
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/factory/resource/merge_request.rb b/qa/qa/factory/resource/merge_request.rb
new file mode 100644
index 00000000000..539fe6b8a70
--- /dev/null
+++ b/qa/qa/factory/resource/merge_request.rb
@@ -0,0 +1,58 @@
+require 'securerandom'
+
+module QA
+ module Factory
+ module Resource
+ class MergeRequest < Factory::Base
+ attr_accessor :title,
+ :description,
+ :source_branch,
+ :target_branch
+
+ product :project do |factory|
+ factory.project
+ end
+
+ product :source_branch do |factory|
+ factory.source_branch
+ end
+
+ dependency Factory::Resource::Project, as: :project do |project|
+ project.name = 'project-with-merge-request'
+ end
+
+ dependency Factory::Repository::Push, as: :target do |push, factory|
+ factory.project.visit!
+ push.project = factory.project
+ push.branch_name = "master:#{factory.target_branch}"
+ end
+
+ dependency Factory::Repository::Push, as: :source do |push, factory|
+ push.project = factory.project
+ push.branch_name = "#{factory.target_branch}:#{factory.source_branch}"
+ push.file_name = "added_file.txt"
+ push.file_content = "File Added"
+ end
+
+ def initialize
+ @title = 'QA test - merge request'
+ @description = 'This is a test merge request'
+ @source_branch = "qa-test-feature-#{SecureRandom.hex(8)}"
+ @target_branch = "master"
+ end
+
+ def fabricate!
+ project.visit!
+
+ Page::Project::Show.act { new_merge_request }
+
+ Page::MergeRequest::New.perform do |page|
+ page.fill_title(@title)
+ page.fill_description(@description)
+ page.create_merge_request
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/factory/resource/personal_access_token.rb b/qa/qa/factory/resource/personal_access_token.rb
new file mode 100644
index 00000000000..514e3615d18
--- /dev/null
+++ b/qa/qa/factory/resource/personal_access_token.rb
@@ -0,0 +1,27 @@
+module QA
+ module Factory
+ module Resource
+ ##
+ # Create a personal access token that can be used by the api
+ #
+ class PersonalAccessToken < Factory::Base
+ attr_accessor :name
+
+ product :access_token do
+ Page::Profile::PersonalAccessTokens.act { created_access_token }
+ end
+
+ def fabricate!
+ Page::Menu::Main.act { go_to_profile_settings }
+ Page::Menu::Profile.act { click_access_tokens }
+
+ Page::Profile::PersonalAccessTokens.perform do |page|
+ page.fill_token_name(name || 'api-test-token')
+ page.check_api
+ page.create_token
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/factory/resource/project.rb b/qa/qa/factory/resource/project.rb
index 07c2e3086d1..7df2dc6618c 100644
--- a/qa/qa/factory/resource/project.rb
+++ b/qa/qa/factory/resource/project.rb
@@ -13,6 +13,10 @@ module QA
@description = 'My awesome project'
end
+ product :name do
+ Page::Project::Show.act { project_name }
+ end
+
def fabricate!
group.visit!
diff --git a/qa/qa/factory/resource/runner.rb b/qa/qa/factory/resource/runner.rb
new file mode 100644
index 00000000000..03b69eb1bdf
--- /dev/null
+++ b/qa/qa/factory/resource/runner.rb
@@ -0,0 +1,47 @@
+require 'securerandom'
+
+module QA
+ module Factory
+ module Resource
+ class Runner < Factory::Base
+ attr_writer :name, :tags, :image
+
+ dependency Factory::Resource::Project, as: :project do |project|
+ project.name = 'project-with-ci-cd'
+ project.description = 'Project with CI/CD Pipelines'
+ end
+
+ def name
+ @name || "qa-runner-#{SecureRandom.hex(4)}"
+ end
+
+ def tags
+ @tags || %w[qa e2e]
+ end
+
+ def image
+ @image || 'gitlab/gitlab-runner:alpine'
+ end
+
+ def fabricate!
+ project.visit!
+
+ Page::Menu::Side.act { click_ci_cd_settings }
+
+ Service::Runner.new(name).tap do |runner|
+ Page::Project::Settings::CICD.perform do |settings|
+ settings.expand_runners_settings do |runners|
+ runner.pull
+ runner.token = runners.registration_token
+ runner.address = runners.coordinator_address
+ runner.tags = tags
+ runner.image = image
+ runner.register!
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/factory/resource/sandbox.rb b/qa/qa/factory/resource/sandbox.rb
index 558da1c973b..ad376988e82 100644
--- a/qa/qa/factory/resource/sandbox.rb
+++ b/qa/qa/factory/resource/sandbox.rb
@@ -11,7 +11,7 @@ module QA
end
def fabricate!
- Page::Main::Menu.act { go_to_groups }
+ Page::Menu::Main.act { go_to_groups }
Page::Dashboard::Groups.perform do |page|
if page.has_group?(@name)
diff --git a/qa/qa/factory/resource/secret_variable.rb b/qa/qa/factory/resource/secret_variable.rb
new file mode 100644
index 00000000000..c734d739b4a
--- /dev/null
+++ b/qa/qa/factory/resource/secret_variable.rb
@@ -0,0 +1,29 @@
+module QA
+ module Factory
+ module Resource
+ class SecretVariable < Factory::Base
+ attr_accessor :key, :value
+
+ dependency Factory::Resource::Project, as: :project do |project|
+ project.name = 'project-with-secret-variables'
+ project.description = 'project for adding secret variable test'
+ end
+
+ def fabricate!
+ project.visit!
+
+ Page::Menu::Side.act { click_ci_cd_settings }
+
+ Page::Project::Settings::CICD.perform do |setting|
+ setting.expand_secret_variables do |page|
+ page.fill_variable_key(key)
+ page.fill_variable_value(value)
+
+ page.save_variables
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/factory/settings/hashed_storage.rb b/qa/qa/factory/settings/hashed_storage.rb
index eb3b28f2613..c69ebed3c6b 100644
--- a/qa/qa/factory/settings/hashed_storage.rb
+++ b/qa/qa/factory/settings/hashed_storage.rb
@@ -6,15 +6,17 @@ module QA
raise ArgumentError unless traits.include?(:enabled)
Page::Main::Login.act { sign_in_using_credentials }
- Page::Main::Menu.act { go_to_admin_area }
- Page::Admin::Menu.act { go_to_settings }
+ Page::Menu::Main.act { go_to_admin_area }
+ Page::Menu::Admin.act { go_to_settings }
- Page::Admin::Settings.act do
- enable_hashed_storage
- save_settings
+ Page::Admin::Settings::Main.perform do |setting|
+ setting.expand_repository_storage do |page|
+ page.enable_hashed_storage
+ page.save_settings
+ end
end
- QA::Page::Main::Menu.act { sign_out }
+ QA::Page::Menu::Main.act { sign_out }
end
end
end
diff --git a/qa/qa/git/location.rb b/qa/qa/git/location.rb
new file mode 100644
index 00000000000..30538388530
--- /dev/null
+++ b/qa/qa/git/location.rb
@@ -0,0 +1,32 @@
+require 'uri'
+require 'forwardable'
+
+module QA
+ module Git
+ class Location
+ extend Forwardable
+
+ attr_reader :git_uri, :uri
+ def_delegators :@uri, :user, :host, :path
+
+ # See: config/initializers/1_settings.rb
+ # Settings#build_gitlab_shell_ssh_path_prefix
+ def initialize(git_uri)
+ @git_uri = git_uri
+ @uri =
+ if git_uri.start_with?('ssh://')
+ URI.parse(git_uri)
+ else
+ *rest, path = git_uri.split(':')
+ # Host cannot have : so we'll need to escape it
+ user_host = rest.join('%3A').sub(/\A\[(.+)\]\z/, '\1')
+ URI.parse("ssh://#{user_host}/#{path}")
+ end
+ end
+
+ def port
+ uri.port || 22
+ end
+ end
+ end
+end
diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb
index 59cd147e055..b3150e8f3fa 100644
--- a/qa/qa/git/repository.rb
+++ b/qa/qa/git/repository.rb
@@ -1,3 +1,4 @@
+require 'cgi'
require 'uri'
module QA
@@ -23,7 +24,7 @@ module QA
def password=(pass)
@password = pass
- @uri.password = CGI.escape(pass)
+ @uri.password = CGI.escape(pass).gsub('+', '%20')
end
def use_default_credentials
@@ -32,7 +33,11 @@ module QA
end
def clone(opts = '')
- `git clone #{opts} #{@uri.to_s} ./`
+ `git clone #{opts} #{@uri.to_s} ./ #{suppress_output}`
+ end
+
+ def checkout(branch_name)
+ `git checkout "#{branch_name}"`
end
def shallow_clone
@@ -60,12 +65,22 @@ module QA
end
def push_changes(branch = 'master')
- `git push #{@uri.to_s} #{branch}`
+ `git push #{@uri.to_s} #{branch} #{suppress_output}`
end
def commits
`git log --oneline`.split("\n")
end
+
+ private
+
+ def suppress_output
+ # If we're running as the default user, it's probably a temporary
+ # instance and output can be useful for debugging
+ return if @username == Runtime::User.default_name
+
+ "&> #{File::NULL}"
+ end
end
end
end
diff --git a/qa/qa/page/README.md b/qa/qa/page/README.md
new file mode 100644
index 00000000000..d38223f690d
--- /dev/null
+++ b/qa/qa/page/README.md
@@ -0,0 +1,122 @@
+# Page objects in GitLab QA
+
+In GitLab QA we are using a known pattern, called _Page Objects_.
+
+This means that we have built an abstraction for all GitLab pages that we use
+to drive GitLab QA scenarios. Whenever we do something on a page, like filling
+in a form, or clicking a button, we do that only through a page object
+associated with this area of GitLab.
+
+For example, when GitLab QA test harness signs in into GitLab, it needs to fill
+in a user login and user password. In order to do that, we have a class, called
+`Page::Main::Login` and `sign_in_using_credentials` methods, that is the only
+piece of the code, that has knowledge about `user_login` and `user_password`
+fields.
+
+## Why do we need that?
+
+We need page objects, because we need to reduce duplication and avoid problems
+whenever someone changes some selectors in GitLab's source code.
+
+Imagine that we have a hundred specs in GitLab QA, and we need to sign into
+GitLab each time, before we make assertions. Without a page object one would
+need to rely on volatile helpers or invoke Capybara methods directly. Imagine
+invoking `fill_in :user_login` in every `*_spec.rb` file / test example.
+
+When someone later changes `t.text_field :login` in the view associated with
+this page to `t.text_field :username` it will generate a different field
+identifier, what would effectively break all tests.
+
+Because we are using `Page::Main::Login.act { sign_in_using_credentials }`
+everywhere, when we want to sign into GitLab, the page object is the single
+source of truth, and we will need to update `fill_in :user_login`
+to `fill_in :user_username` only in a one place.
+
+## What problems did we have in the past?
+
+We do not run QA tests for every commit, because of performance reasons, and
+the time it would take to build packages and test everything.
+
+That is why when someone changes `t.text_field :login` to
+`t.text_field :username` in the _new session_ view we won't know about this
+change until our GitLab QA nightly pipeline fails, or until someone triggers
+`package-and-qa` action in their merge request.
+
+Obviously such a change would break all tests. We call this problem a _fragile
+tests problem_.
+
+In order to make GitLab QA more reliable and robust, we had to solve this
+problem by introducing coupling between GitLab CE / EE views and GitLab QA.
+
+## How did we solve fragile tests problem?
+
+Currently, when you add a new `Page::Base` derived class, you will also need to
+define all selectors that your page objects depends on.
+
+Whenever you push your code to CE / EE repository, `qa:selectors` sanity test
+job is going to be run as a part of a CI pipeline.
+
+This test is going to validate all page objects that we have implemented in
+`qa/page` directory. When it fails, you will be notified about missing
+or invalid views / selectors definition.
+
+## How to properly implement a page object?
+
+We have built a DSL to define coupling between a page object and GitLab views
+it is actually implemented by. See an example below.
+
+```ruby
+module Page
+ module Main
+ class Login < Page::Base
+ view 'app/views/devise/passwords/edit.html.haml' do
+ element :password_field, 'password_field :password'
+ element :password_confirmation, 'password_field :password_confirmation'
+ element :change_password_button, 'submit "Change your password"'
+ end
+
+ view 'app/views/devise/sessions/_new_base.html.haml' do
+ element :login_field, 'text_field :login'
+ element :password_field, 'password_field :password'
+ element :sign_in_button, 'submit "Sign in"'
+ end
+
+ # ...
+ end
+end
+```
+
+It is possible to use `element` DSL method without value, with a String value
+or with a Regexp.
+
+```ruby
+view 'app/views/my/view.html.haml' do
+ # Require `f.submit "Sign in"` to be present in `my/view.html.haml
+ element :my_button, 'f.submit "Sign in"'
+
+ # Match every line in `my/view.html.haml` against
+ # `/link_to .* "My Profile"/` regexp.
+ element :profile_link, /link_to .* "My Profile"/
+
+ # Implicitly require `.qa-logout-button` CSS class to be present in the view
+ element :logout_button
+end
+```
+
+## Running the test locally
+
+During development, you can run the `qa:selectors` test by running
+
+```shell
+bin/qa Test::Sanity::Selectors
+```
+
+from within the `qa` directory.
+
+## Where to ask for help?
+
+If you need more information, ask for help on `#qa` channel on Slack (GitLab
+Team only).
+
+If you are not a Team Member, and you still need help to contribute, please
+open an issue in GitLab QA issue tracker.
diff --git a/qa/qa/page/admin/menu.rb b/qa/qa/page/admin/menu.rb
deleted file mode 100644
index dd289ffe269..00000000000
--- a/qa/qa/page/admin/menu.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-module QA
- module Page
- module Admin
- class Menu < Page::Base
- def go_to_license
- click_link 'License'
- end
-
- def go_to_settings
- click_link 'Settings'
- end
- end
- end
- end
-end
diff --git a/qa/qa/page/admin/settings.rb b/qa/qa/page/admin/settings.rb
deleted file mode 100644
index 39e2f2062ad..00000000000
--- a/qa/qa/page/admin/settings.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-module QA
- module Page
- module Admin
- class Settings < Page::Base
- def enable_hashed_storage
- scroll_to 'legend', text: 'Repository Storage'
- check 'Create new projects using hashed storage paths'
- end
-
- def save_settings
- scroll_to '.form-actions' do
- click_button 'Save'
- end
- end
- end
- end
- end
-end
diff --git a/qa/qa/page/admin/settings/main.rb b/qa/qa/page/admin/settings/main.rb
new file mode 100644
index 00000000000..e7c1220c967
--- /dev/null
+++ b/qa/qa/page/admin/settings/main.rb
@@ -0,0 +1,21 @@
+module QA
+ module Page
+ module Admin
+ module Settings
+ class Main < Page::Base
+ include QA::Page::Settings::Common
+
+ view 'app/views/admin/application_settings/show.html.haml' do
+ element :advanced_settings_section, 'Repository storage'
+ end
+
+ def expand_repository_storage(&block)
+ expand_section('Repository storage') do
+ RepositoryStorage.perform(&block)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/admin/settings/repository_storage.rb b/qa/qa/page/admin/settings/repository_storage.rb
new file mode 100644
index 00000000000..b4a1344216e
--- /dev/null
+++ b/qa/qa/page/admin/settings/repository_storage.rb
@@ -0,0 +1,23 @@
+module QA
+ module Page
+ module Admin
+ module Settings
+ class RepositoryStorage < Page::Base
+ view 'app/views/admin/application_settings/_repository_storage.html.haml' do
+ element :submit, "submit 'Save changes'"
+ element :hashed_storage,
+ 'Create new projects using hashed storage paths'
+ end
+
+ def enable_hashed_storage
+ check 'Create new projects using hashed storage paths'
+ end
+
+ def save_settings
+ click_button 'Save changes'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb
index 99eba02b6e3..a313d46205d 100644
--- a/qa/qa/page/base.rb
+++ b/qa/qa/page/base.rb
@@ -5,21 +5,27 @@ module QA
class Base
include Capybara::DSL
include Scenario::Actable
+ extend SingleForwardable
+
+ def_delegators :evaluator, :view, :views
def refresh
visit current_url
end
- def wait(css = '.application', time: 60)
- Time.now.tap do |start|
- while Time.now - start < time
- break if page.has_css?(css, wait: 5)
+ def wait(max: 60, time: 1, reload: true)
+ start = Time.now
- refresh
- end
+ while Time.now - start < max
+ result = yield
+ return result if result
+
+ sleep(time)
+
+ refresh if reload
end
- yield if block_given?
+ false
end
def scroll_to(selector, text: nil)
@@ -37,9 +43,78 @@ module QA
page.within(selector) { yield } if block_given?
end
+ # Returns true if successfully GETs the given URL
+ # Useful because `page.status_code` is unsupported by our driver, and
+ # we don't have access to the `response` to use `have_http_status`.
+ def asset_exists?(url)
+ page.execute_script <<~JS
+ xhr = new XMLHttpRequest();
+ xhr.open('GET', '#{url}', true);
+ xhr.send();
+ JS
+
+ return false unless wait(time: 0.5, max: 60, reload: false) do
+ page.evaluate_script('xhr.readyState == XMLHttpRequest.DONE')
+ end
+
+ page.evaluate_script('xhr.status') == 200
+ end
+
+ def find_element(name)
+ find(element_selector_css(name))
+ end
+
+ def click_element(name)
+ find_element(name).click
+ end
+
+ def fill_element(name, content)
+ find_element(name).set(content)
+ end
+
+ def within_element(name)
+ page.within(element_selector_css(name)) do
+ yield
+ end
+ end
+
+ def element_selector_css(name)
+ Page::Element.new(name).selector_css
+ end
+
def self.path
raise NotImplementedError
end
+
+ def self.evaluator
+ @evaluator ||= Page::Base::DSL.new
+ end
+
+ def self.errors
+ if views.empty?
+ return ["Page class does not have views / elements defined!"]
+ end
+
+ views.map(&:errors).flatten
+ end
+
+ def self.elements
+ views.map(&:elements).flatten
+ end
+
+ class DSL
+ attr_reader :views
+
+ def initialize
+ @views = []
+ end
+
+ def view(path, &block)
+ Page::View.evaluate(&block).tap do |view|
+ @views.push(Page::View.new(path, view.elements))
+ end
+ end
+ end
end
end
end
diff --git a/qa/qa/page/component/dropzone.rb b/qa/qa/page/component/dropzone.rb
new file mode 100644
index 00000000000..15bdc742fda
--- /dev/null
+++ b/qa/qa/page/component/dropzone.rb
@@ -0,0 +1,31 @@
+module QA
+ module Page
+ module Component
+ class Dropzone
+ attr_reader :page, :container
+
+ # page - A QA::Page::Base object
+ # container - CSS selector of the comment textarea's container
+ def initialize(page, container)
+ @page = page
+ @container = container
+ end
+
+ # Not tested and not expected to work with multiple dropzones
+ # instantiated on one page because there is no distinguishing
+ # attribute per dropzone file field.
+ def attach_file(attachment)
+ filename = File.basename(attachment)
+
+ field_style = { visibility: 'visible', height: '', width: '' }
+ page.attach_file(attachment, class: 'dz-hidden-input', make_visible: field_style)
+
+ # Wait for link to be appended to dropzone text
+ page.wait(reload: false) do
+ page.find("#{container} textarea").value.match(filename)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/dashboard/groups.rb b/qa/qa/page/dashboard/groups.rb
index 083d2e1ab16..e853e0d85e0 100644
--- a/qa/qa/page/dashboard/groups.rb
+++ b/qa/qa/page/dashboard/groups.rb
@@ -2,6 +2,15 @@ module QA
module Page
module Dashboard
class Groups < Page::Base
+ view 'app/views/shared/groups/_search_form.html.haml' do
+ element :groups_filter, 'search_field_tag :filter'
+ element :groups_filter_placeholder, 'Filter by name...'
+ end
+
+ view 'app/views/dashboard/_groups_head.html.haml' do
+ element :new_group_button, 'link_to _("New group")'
+ end
+
def filter_by_name(name)
fill_in 'Filter by name...', with: name
end
diff --git a/qa/qa/page/dashboard/projects.rb b/qa/qa/page/dashboard/projects.rb
index 7ed27da6d89..73942cb856a 100644
--- a/qa/qa/page/dashboard/projects.rb
+++ b/qa/qa/page/dashboard/projects.rb
@@ -2,9 +2,22 @@ module QA
module Page
module Dashboard
class Projects < Page::Base
+ view 'app/views/dashboard/projects/index.html.haml'
+ view 'app/views/shared/projects/_search_form.html.haml' do
+ element :form_filter_by_name, /form_tag.+id: 'project-filter-form'/
+ end
+
def go_to_project(name)
+ filter_by_name(name)
+
find_link(text: name).click
end
+
+ def filter_by_name(name)
+ page.within('form#project-filter-form') do
+ fill_in :name, with: name
+ end
+ end
end
end
end
diff --git a/qa/qa/page/element.rb b/qa/qa/page/element.rb
new file mode 100644
index 00000000000..9944a39ce07
--- /dev/null
+++ b/qa/qa/page/element.rb
@@ -0,0 +1,32 @@
+module QA
+ module Page
+ class Element
+ attr_reader :name
+
+ def initialize(name, pattern = nil)
+ @name = name
+ @pattern = pattern || selector
+ end
+
+ def selector
+ "qa-#{@name.to_s.tr('_', '-')}"
+ end
+
+ def selector_css
+ ".#{selector}"
+ end
+
+ def expression
+ if @pattern.is_a?(String)
+ @_regexp ||= Regexp.new(Regexp.escape(@pattern))
+ else
+ @pattern
+ end
+ end
+
+ def matches?(line)
+ !!(line =~ expression)
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/group/new.rb b/qa/qa/page/group/new.rb
index 53fdaaed078..48b71a7c883 100644
--- a/qa/qa/page/group/new.rb
+++ b/qa/qa/page/group/new.rb
@@ -2,6 +2,17 @@ module QA
module Page
module Group
class New < Page::Base
+ view 'app/views/shared/_group_form.html.haml' do
+ element :group_path_field, 'text_field :path'
+ element :group_name_field, 'text_field :name'
+ element :group_description_field, 'text_area :description'
+ end
+
+ view 'app/views/groups/new.html.haml' do
+ element :create_group_button, "submit 'Create group'"
+ element :visibility_radios, 'visibility_level:'
+ end
+
def set_path(path)
fill_in 'group_path', with: path
fill_in 'group_name', with: path
diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb
index 0a16c07d64b..d215518d316 100644
--- a/qa/qa/page/group/show.rb
+++ b/qa/qa/page/group/show.rb
@@ -2,6 +2,21 @@ module QA
module Page
module Group
class Show < Page::Base
+ view 'app/views/groups/show.html.haml' do
+ element :new_project_or_subgroup_dropdown, '.new-project-subgroup'
+ element :new_project_or_subgroup_dropdown_toggle, '.dropdown-toggle'
+ element :new_project_option, /%li.*data:.*value: "new-project"/
+ element :new_project_button, /%input.*data:.*action: "new-project"/
+ element :new_subgroup_option, /%li.*data:.*value: "new-subgroup"/
+
+ # data-value and data-action get modified by JS for subgroup
+ element :new_subgroup_button, /%input.*\.js-new-group-child/
+ end
+
+ view 'app/assets/javascripts/groups/constants.js' do
+ element :no_result_text, 'Sorry, no groups or projects matched your search'
+ end
+
def go_to_subgroup(name)
click_link name
end
@@ -13,26 +28,41 @@ module QA
def has_subgroup?(name)
filter_by_name(name)
- page.has_link?(name)
+ wait(reload: false) do
+ return false if page.has_content?('Sorry, no groups or projects matched your search')
+
+ page.has_link?(name)
+ end
end
def go_to_new_subgroup
- within '.new-project-subgroup' do
- find('.dropdown-toggle').click
- find("li[data-value='new-subgroup']").click
- end
+ click_new('subgroup')
find("input[data-action='new-subgroup']").click
end
def go_to_new_project
- within '.new-project-subgroup' do
- find('.dropdown-toggle').click
- find("li[data-value='new-project']").click
- end
+ click_new('project')
find("input[data-action='new-project']").click
end
+
+ private
+
+ def click_new(kind)
+ within '.new-project-subgroup' do
+ css = "li[data-value='new-#{kind}']"
+
+ # May need to click again because it is possible to click the button quicker than the JS is bound
+ wait(reload: false) do
+ find('.dropdown-toggle').click
+
+ page.has_css?(css)
+ end
+
+ find(css).click
+ end
+ end
end
end
end
diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb
index f88325f408b..596205fe540 100644
--- a/qa/qa/page/main/login.rb
+++ b/qa/qa/page/main/login.rb
@@ -2,20 +2,73 @@ module QA
module Page
module Main
class Login < Page::Base
+ view 'app/views/devise/passwords/edit.html.haml' do
+ element :password_field, 'password_field :password'
+ element :password_confirmation, 'password_field :password_confirmation'
+ element :change_password_button, 'submit "Change your password"'
+ end
+
+ view 'app/views/devise/sessions/_new_base.html.haml' do
+ element :login_field, 'text_field :login'
+ element :password_field, 'password_field :password'
+ element :sign_in_button, 'submit "Sign in"'
+ end
+
+ view 'app/views/devise/sessions/_new_ldap.html.haml' do
+ element :username_field, 'text_field_tag :username'
+ element :password_field, 'password_field_tag :password'
+ element :sign_in_button, 'submit_tag "Sign in"'
+ end
+
+ view 'app/views/devise/shared/_tabs_ldap.html.haml' do
+ element :ldap_tab, "link_to server['label']"
+ element :standard_tab, "link_to 'Standard'"
+ end
+
def initialize
- wait('.application', time: 500)
+ wait(max: 500) do
+ page.has_css?('.application')
+ end
end
- def sign_in_using_credentials
+ def set_initial_password_if_present
if page.has_content?('Change your password')
fill_in :user_password, with: Runtime::User.password
fill_in :user_password_confirmation, with: Runtime::User.password
click_button 'Change your password'
end
+ end
+
+ def sign_in_using_credentials
+ if Runtime::User.ldap_user?
+ sign_in_using_ldap_credentials
+ else
+ sign_in_using_gitlab_credentials
+ end
+ end
+
+ def sign_in_using_ldap_credentials
+ using_wait_time 0 do
+ set_initial_password_if_present
+
+ click_link 'LDAP'
+
+ fill_in :username, with: Runtime::User.ldap_username
+ fill_in :password, with: Runtime::User.ldap_password
+ click_button 'Sign in'
+ end
+ end
+
+ def sign_in_using_gitlab_credentials
+ using_wait_time 0 do
+ set_initial_password_if_present
- fill_in :user_login, with: Runtime::User.name
- fill_in :user_password, with: Runtime::User.password
- click_button 'Sign in'
+ click_link 'Standard' if page.has_content?('LDAP')
+
+ fill_in :user_login, with: Runtime::User.name
+ fill_in :user_password, with: Runtime::User.password
+ click_button 'Sign in'
+ end
end
def self.path
diff --git a/qa/qa/page/main/menu.rb b/qa/qa/page/main/menu.rb
deleted file mode 100644
index bc9c4ec1215..00000000000
--- a/qa/qa/page/main/menu.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-module QA
- module Page
- module Main
- class Menu < Page::Base
- def go_to_groups
- within_top_menu { click_link 'Groups' }
- end
-
- def go_to_projects
- within_top_menu do
- click_link 'Projects'
- click_link 'Your projects'
- end
- end
-
- def go_to_admin_area
- within_top_menu { find('.admin-icon').click }
- end
-
- def sign_out
- within_user_menu do
- click_link('Sign out')
- end
- end
-
- def has_personal_area?
- page.has_selector?('.header-user-dropdown-toggle')
- end
-
- private
-
- def within_top_menu
- page.within('.navbar') do
- yield
- end
- end
-
- def within_user_menu
- within_top_menu do
- find('.header-user-dropdown-toggle').click
-
- page.within('.dropdown-menu-nav') do
- yield
- end
- end
- end
- end
- end
- end
-end
diff --git a/qa/qa/page/main/oauth.rb b/qa/qa/page/main/oauth.rb
index e746cff0a80..6f548148363 100644
--- a/qa/qa/page/main/oauth.rb
+++ b/qa/qa/page/main/oauth.rb
@@ -2,6 +2,10 @@ module QA
module Page
module Main
class OAuth < Page::Base
+ view 'app/views/doorkeeper/authorizations/new.html.haml' do
+ element :authorization_button, 'submit_tag "Authorize"'
+ end
+
def needs_authorization?
page.current_url.include?('/oauth')
end
diff --git a/qa/qa/page/mattermost/login.rb b/qa/qa/page/mattermost/login.rb
index 8ffd4fdad13..9b21300ea3c 100644
--- a/qa/qa/page/mattermost/login.rb
+++ b/qa/qa/page/mattermost/login.rb
@@ -2,6 +2,13 @@ module QA
module Page
module Mattermost
class Login < Page::Base
+ ##
+ # TODO, define all selectors required by this page object
+ #
+ # See gitlab-org/gitlab-qa#154
+ #
+ view 'app/views/projects/mattermosts/new.html.haml'
+
def sign_in_using_oauth
click_link class: 'btn btn-custom-login gitlab'
diff --git a/qa/qa/page/mattermost/main.rb b/qa/qa/page/mattermost/main.rb
index 4b8fc28e53f..bc2f9acc729 100644
--- a/qa/qa/page/mattermost/main.rb
+++ b/qa/qa/page/mattermost/main.rb
@@ -2,6 +2,13 @@ module QA
module Page
module Mattermost
class Main < Page::Base
+ ##
+ # TODO, define all selectors required by this page object
+ #
+ # See gitlab-org/gitlab-qa#154
+ #
+ view 'app/views/projects/mattermosts/new.html.haml'
+
def initialize
visit(Runtime::Scenario.mattermost_address)
end
diff --git a/qa/qa/page/menu/admin.rb b/qa/qa/page/menu/admin.rb
new file mode 100644
index 00000000000..573b98f7386
--- /dev/null
+++ b/qa/qa/page/menu/admin.rb
@@ -0,0 +1,15 @@
+module QA
+ module Page
+ module Menu
+ class Admin < Page::Base
+ view 'app/views/layouts/nav/sidebar/_admin.html.haml' do
+ element :settings, "_('Settings')"
+ end
+
+ def go_to_settings
+ click_link 'Settings'
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/menu/main.rb b/qa/qa/page/menu/main.rb
new file mode 100644
index 00000000000..df93a5fa2d2
--- /dev/null
+++ b/qa/qa/page/menu/main.rb
@@ -0,0 +1,78 @@
+module QA
+ module Page
+ module Menu
+ class Main < Page::Base
+ view 'app/views/layouts/header/_default.html.haml' do
+ element :navbar
+ element :user_avatar
+ element :user_menu, '.dropdown-menu-nav'
+ element :user_sign_out_link, 'link_to "Sign out"'
+ element :settings_link, 'link_to "Settings"'
+ end
+
+ view 'app/views/layouts/nav/_dashboard.html.haml' do
+ element :admin_area_link
+ element :projects_dropdown
+ element :groups_link
+ end
+
+ view 'app/views/layouts/nav/projects_dropdown/_show.html.haml' do
+ element :projects_dropdown_sidebar
+ element :your_projects_link
+ end
+
+ def go_to_groups
+ within_top_menu { click_element :groups_link }
+ end
+
+ def go_to_projects
+ within_top_menu do
+ click_element :projects_dropdown
+ end
+
+ page.within('.qa-projects-dropdown-sidebar') do
+ click_element :your_projects_link
+ end
+ end
+
+ def go_to_admin_area
+ within_top_menu { click_element :admin_area_link }
+ end
+
+ def sign_out
+ within_user_menu do
+ click_link 'Sign out'
+ end
+ end
+
+ def go_to_profile_settings
+ within_user_menu do
+ click_link 'Settings'
+ end
+ end
+
+ def has_personal_area?
+ page.has_selector?('.qa-user-avatar')
+ end
+
+ private
+
+ def within_top_menu
+ page.within('.qa-navbar') do
+ yield
+ end
+ end
+
+ def within_user_menu
+ within_top_menu do
+ click_element :user_avatar
+
+ page.within('.dropdown-menu-nav') do
+ yield
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/menu/profile.rb b/qa/qa/page/menu/profile.rb
new file mode 100644
index 00000000000..95e88d863e4
--- /dev/null
+++ b/qa/qa/page/menu/profile.rb
@@ -0,0 +1,27 @@
+module QA
+ module Page
+ module Menu
+ class Profile < Page::Base
+ view 'app/views/layouts/nav/sidebar/_profile.html.haml' do
+ element :access_token_link, 'link_to profile_personal_access_tokens_path'
+ element :access_token_title, 'Access Tokens'
+ element :top_level_items, '.sidebar-top-level-items'
+ end
+
+ def click_access_tokens
+ within_sidebar do
+ click_link('Access Tokens')
+ end
+ end
+
+ private
+
+ def within_sidebar
+ page.within('.sidebar-top-level-items') do
+ yield
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/menu/side.rb b/qa/qa/page/menu/side.rb
new file mode 100644
index 00000000000..7e028add2ef
--- /dev/null
+++ b/qa/qa/page/menu/side.rb
@@ -0,0 +1,84 @@
+module QA
+ module Page
+ module Menu
+ class Side < Page::Base
+ view 'app/views/layouts/nav/sidebar/_project.html.haml' do
+ element :settings_item
+ element :settings_link, 'link_to edit_project_path'
+ element :repository_link, "title: 'Repository'"
+ element :pipelines_settings_link, "title: 'CI / CD'"
+ element :issues_link, /link_to.*shortcuts-issues/
+ element :issues_link_text, "Issues"
+ element :top_level_items, '.sidebar-top-level-items'
+ element :activity_link, "title: 'Activity'"
+ end
+
+ view 'app/assets/javascripts/fly_out_nav.js' do
+ element :fly_out, "classList.add('fly-out-list')"
+ end
+
+ def click_repository_settings
+ hover_settings do
+ within_submenu do
+ click_link('Repository')
+ end
+ end
+ end
+
+ def click_ci_cd_settings
+ hover_settings do
+ within_submenu do
+ click_link('CI / CD')
+ end
+ end
+ end
+
+ def click_ci_cd_pipelines
+ within_sidebar do
+ click_link('CI / CD')
+ end
+ end
+
+ def go_to_settings
+ within_sidebar do
+ click_on 'Settings'
+ end
+ end
+
+ def click_issues
+ within_sidebar do
+ click_link('Issues')
+ end
+ end
+
+ private
+
+ def hover_settings
+ within_sidebar do
+ find('.qa-settings-item').hover
+
+ yield
+ end
+ end
+
+ def within_sidebar
+ page.within('.sidebar-top-level-items') do
+ yield
+ end
+ end
+
+ def go_to_activity
+ within_sidebar do
+ click_on 'Activity'
+ end
+ end
+
+ def within_submenu
+ page.within('.fly-out-list') do
+ yield
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/merge_request/new.rb b/qa/qa/page/merge_request/new.rb
new file mode 100644
index 00000000000..ec94ff4ac98
--- /dev/null
+++ b/qa/qa/page/merge_request/new.rb
@@ -0,0 +1,31 @@
+module QA
+ module Page
+ module MergeRequest
+ class New < Page::Base
+ view 'app/views/shared/issuable/_form.html.haml' do
+ element :issuable_create_button
+ end
+
+ view 'app/views/shared/issuable/form/_title.html.haml' do
+ element :issuable_form_title
+ end
+
+ view 'app/views/shared/form_elements/_description.html.haml' do
+ element :issuable_form_description
+ end
+
+ def create_merge_request
+ click_element :issuable_create_button
+ end
+
+ def fill_title(title)
+ fill_element :issuable_form_title, title
+ end
+
+ def fill_description(description)
+ fill_element :issuable_form_description, description
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb
new file mode 100644
index 00000000000..2f2506f08fb
--- /dev/null
+++ b/qa/qa/page/merge_request/show.rb
@@ -0,0 +1,47 @@
+module QA
+ module Page
+ module MergeRequest
+ class Show < Page::Base
+ view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js' do
+ element :merge_button
+ element :fast_forward_message, 'Fast-forward merge without a merge commit'
+ end
+
+ view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue' do
+ element :merged_status, 'The changes were merged into'
+ end
+
+ view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue' do
+ element :mr_rebase_button
+ element :no_fast_forward_message, 'Fast-forward merge is not possible'
+ end
+
+ def rebase!
+ click_element :mr_rebase_button
+
+ wait(reload: false) do
+ has_text?('Fast-forward merge without a merge commit')
+ end
+ end
+
+ def fast_forward_possible?
+ !has_text?('Fast-forward merge is not possible')
+ end
+
+ def has_merge_button?
+ refresh
+
+ has_selector?('.accept-merge-request')
+ end
+
+ def merge!
+ click_element :merge_button
+
+ wait(reload: false) do
+ has_text?('The changes were merged into')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/profile/personal_access_tokens.rb b/qa/qa/page/profile/personal_access_tokens.rb
new file mode 100644
index 00000000000..f5ae47dadd0
--- /dev/null
+++ b/qa/qa/page/profile/personal_access_tokens.rb
@@ -0,0 +1,33 @@
+module QA
+ module Page
+ module Profile
+ class PersonalAccessTokens < Page::Base
+ view 'app/views/shared/_personal_access_tokens_form.html.haml' do
+ element :personal_access_token_name_field, 'text_field :name'
+ element :create_token_button, 'submit "Create #{type} token"' # rubocop:disable Lint/InterpolationCheck
+ element :scopes_api_radios, "label :scopes"
+ end
+
+ view 'app/views/profiles/personal_access_tokens/index.html.haml' do
+ element :create_token_field, "text_field_tag 'created-personal-access-token'"
+ end
+
+ def fill_token_name(name)
+ fill_in 'personal_access_token_name', with: name
+ end
+
+ def check_api
+ check 'personal_access_token_scopes_api'
+ end
+
+ def create_token
+ click_on 'Create personal access token'
+ end
+
+ def created_access_token
+ page.find('#created-personal-access-token').value
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/activity.rb b/qa/qa/page/project/activity.rb
new file mode 100644
index 00000000000..0196922c889
--- /dev/null
+++ b/qa/qa/page/project/activity.rb
@@ -0,0 +1,15 @@
+module QA
+ module Page
+ module Project
+ class Activity < Page::Base
+ view 'app/views/shared/_event_filter.html.haml' do
+ element :push_events, "event_filter_link EventFilter.push, _('Push events')"
+ end
+
+ def go_to_push_events
+ click_on 'Push events'
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/issue/index.rb b/qa/qa/page/project/issue/index.rb
new file mode 100644
index 00000000000..b5903f536a4
--- /dev/null
+++ b/qa/qa/page/project/issue/index.rb
@@ -0,0 +1,17 @@
+module QA
+ module Page
+ module Project
+ module Issue
+ class Index < Page::Base
+ view 'app/views/projects/issues/_issue.html.haml' do
+ element :issue_link, 'link_to issue.title'
+ end
+
+ def go_to_issue(title)
+ click_link(title)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/issue/new.rb b/qa/qa/page/project/issue/new.rb
new file mode 100644
index 00000000000..7fc581da1ed
--- /dev/null
+++ b/qa/qa/page/project/issue/new.rb
@@ -0,0 +1,33 @@
+module QA
+ module Page
+ module Project
+ module Issue
+ class New < Page::Base
+ view 'app/views/shared/issuable/_form.html.haml' do
+ element :submit_issue_button, 'form.submit "Submit'
+ end
+
+ view 'app/views/shared/issuable/form/_title.html.haml' do
+ element :issue_title_textbox, 'form.text_field :title'
+ end
+
+ view 'app/views/shared/form_elements/_description.html.haml' do
+ element :issue_description_textarea, "render 'projects/zen', f: form, attr: :description"
+ end
+
+ def add_title(title)
+ fill_in 'issue_title', with: title
+ end
+
+ def add_description(description)
+ fill_in 'issue_description', with: description
+ end
+
+ def create_new_issue
+ click_on 'Submit issue'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb
new file mode 100644
index 00000000000..5bc0598a524
--- /dev/null
+++ b/qa/qa/page/project/issue/show.rb
@@ -0,0 +1,40 @@
+module QA
+ module Page
+ module Project
+ module Issue
+ class Show < Page::Base
+ view 'app/views/projects/issues/show.html.haml' do
+ element :issue_details, '.issue-details'
+ element :title, '.title'
+ end
+
+ view 'app/views/shared/notes/_form.html.haml' do
+ element :new_note_form, 'new-note'
+ element :new_note_form, 'attr: :note'
+ end
+
+ view 'app/views/shared/notes/_comment_button.html.haml' do
+ element :comment_button, '%strong Comment'
+ end
+
+ def issue_title
+ find('.issue-details .title').text
+ end
+
+ # Adds a comment to an issue
+ # attachment option should be an absolute path
+ def comment(text, attachment: nil)
+ fill_in(with: text, name: 'note[note]')
+
+ unless attachment.nil?
+ QA::Page::Component::Dropzone.new(self, '.new-note')
+ .attach_file(attachment)
+ end
+
+ click_on 'Comment'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/job/show.rb b/qa/qa/page/project/job/show.rb
new file mode 100644
index 00000000000..21bda74efb2
--- /dev/null
+++ b/qa/qa/page/project/job/show.rb
@@ -0,0 +1,19 @@
+module QA::Page
+ module Project::Job
+ class Show < QA::Page::Base
+ view 'app/views/projects/jobs/show.html.haml' do
+ element :build_output, '.js-build-output'
+ end
+
+ def output
+ css = '.js-build-output'
+
+ wait(reload: false) do
+ has_css?(css)
+ end
+
+ find(css).text
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/new.rb b/qa/qa/page/project/new.rb
index b31bec27b59..186a4724326 100644
--- a/qa/qa/page/project/new.rb
+++ b/qa/qa/page/project/new.rb
@@ -2,9 +2,18 @@ module QA
module Page
module Project
class New < Page::Base
+ view 'app/views/projects/_new_project_fields.html.haml' do
+ element :project_namespace_select
+ element :project_namespace_field, /select :namespace_id.*class: 'select2/
+ element :project_path, 'text_field :path'
+ element :project_description, 'text_area :description'
+ element :project_create_button, "submit 'Create project'"
+ end
+
def choose_test_namespace
- find('#s2id_project_namespace_id').click
- find('.select2-result-label', text: Runtime::Namespace.name).click
+ click_element :project_namespace_select
+
+ find('ul.select2-result-sub > li', text: Runtime::Namespace.path).click
end
def choose_name(name)
diff --git a/qa/qa/page/project/pipeline/index.rb b/qa/qa/page/project/pipeline/index.rb
new file mode 100644
index 00000000000..ce430a2a6ee
--- /dev/null
+++ b/qa/qa/page/project/pipeline/index.rb
@@ -0,0 +1,19 @@
+module QA::Page
+ module Project::Pipeline
+ class Index < QA::Page::Base
+ view 'app/assets/javascripts/pipelines/components/pipeline_url.vue' do
+ element :pipeline_link, 'class="js-pipeline-url-link"'
+ end
+
+ def go_to_latest_pipeline
+ css = '.js-pipeline-url-link'
+
+ link = wait(reload: false) do
+ first(css)
+ end
+
+ link.click
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/pipeline/show.rb b/qa/qa/page/project/pipeline/show.rb
new file mode 100644
index 00000000000..b183552d46c
--- /dev/null
+++ b/qa/qa/page/project/pipeline/show.rb
@@ -0,0 +1,46 @@
+module QA::Page
+ module Project::Pipeline
+ class Show < QA::Page::Base
+ view 'app/assets/javascripts/vue_shared/components/header_ci_component.vue' do
+ element :pipeline_header, /header class.*ci-header-container.*/
+ end
+
+ view 'app/assets/javascripts/pipelines/components/graph/graph_component.vue' do
+ element :pipeline_graph, /class.*pipeline-graph.*/
+ end
+
+ view 'app/assets/javascripts/pipelines/components/graph/job_component.vue' do
+ element :job_component, /class.*ci-job-component.*/
+ element :job_link, /class.*js-pipeline-graph-job-link.*/
+ end
+
+ view 'app/assets/javascripts/vue_shared/components/ci_icon.vue' do
+ element :status_icon, 'ci-status-icon-${status}'
+ end
+
+ def running?
+ within('.ci-header-container') do
+ return page.has_content?('running')
+ end
+ end
+
+ def has_build?(name, status: :success)
+ within('.pipeline-graph') do
+ within('.ci-job-component', text: name) do
+ return has_selector?(".ci-status-icon-#{status}")
+ end
+ end
+ end
+
+ def go_to_first_job
+ css = '.js-pipeline-graph-job-link'
+
+ wait(reload: false) do
+ has_css?(css)
+ end
+
+ first(css).click
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/advanced.rb b/qa/qa/page/project/settings/advanced.rb
new file mode 100644
index 00000000000..5ef00504fdf
--- /dev/null
+++ b/qa/qa/page/project/settings/advanced.rb
@@ -0,0 +1,33 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ class Advanced < Page::Base
+ view 'app/views/projects/edit.html.haml' do
+ element :project_path_field, 'f.text_field :path'
+ element :project_name_field, 'f.text_field :name'
+ element :rename_project_button, "f.submit 'Rename project'"
+ end
+
+ def rename_to(path)
+ fill_project_name(path)
+ fill_project_path(path)
+ rename_project!
+ end
+
+ def fill_project_path(path)
+ fill_in :project_path, with: path
+ end
+
+ def fill_project_name(name)
+ fill_in :project_name, with: name
+ end
+
+ def rename_project!
+ click_on 'Rename project'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb
new file mode 100644
index 00000000000..17a1bc904e4
--- /dev/null
+++ b/qa/qa/page/project/settings/ci_cd.rb
@@ -0,0 +1,28 @@
+module QA # rubocop:disable Naming/FileName
+ module Page
+ module Project
+ module Settings
+ class CICD < Page::Base
+ include Common
+
+ view 'app/views/projects/settings/ci_cd/show.html.haml' do
+ element :runners_settings, 'Runners settings'
+ element :secret_variables, 'Secret variables'
+ end
+
+ def expand_runners_settings(&block)
+ expand_section('Runners settings') do
+ Settings::Runners.perform(&block)
+ end
+ end
+
+ def expand_secret_variables(&block)
+ expand_section('Secret variables') do
+ Settings::SecretVariables.perform(&block)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/common.rb b/qa/qa/page/project/settings/common.rb
new file mode 100644
index 00000000000..874fb381554
--- /dev/null
+++ b/qa/qa/page/project/settings/common.rb
@@ -0,0 +1,19 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ module Common
+ include QA::Page::Settings::Common
+
+ def self.included(base)
+ base.class_eval do
+ view 'app/views/projects/edit.html.haml' do
+ element :advanced_settings_expand, "= expanded ? 'Collapse' : 'Expand'"
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/deploy_keys.rb b/qa/qa/page/project/settings/deploy_keys.rb
new file mode 100644
index 00000000000..332e84724c7
--- /dev/null
+++ b/qa/qa/page/project/settings/deploy_keys.rb
@@ -0,0 +1,56 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ class DeployKeys < Page::Base
+ view 'app/views/projects/deploy_keys/_form.html.haml' do
+ element :deploy_key_title, 'text_field :title'
+ element :deploy_key_key, 'text_area :key'
+ end
+
+ view 'app/assets/javascripts/deploy_keys/components/app.vue' do
+ element :deploy_keys_section, /class=".*deploy\-keys.*"/
+ element :project_deploy_keys, 'class="qa-project-deploy-keys"'
+ end
+
+ view 'app/assets/javascripts/deploy_keys/components/key.vue' do
+ element :key_title, /class=".*qa-key-title.*"/
+ element :key_fingerprint, /class=".*qa-key-fingerprint.*"/
+ end
+
+ def fill_key_title(title)
+ fill_in 'deploy_key_title', with: title
+ end
+
+ def fill_key_value(key)
+ fill_in 'deploy_key_key', with: key
+ end
+
+ def add_key
+ click_on 'Add key'
+ end
+
+ def key_title
+ within_project_deploy_keys do
+ find_element(:key_title).text
+ end
+ end
+
+ def key_fingerprint
+ within_project_deploy_keys do
+ find_element(:key_fingerprint).text
+ end
+ end
+
+ private
+
+ def within_project_deploy_keys
+ within_element(:project_deploy_keys) do
+ yield
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/main.rb b/qa/qa/page/project/settings/main.rb
new file mode 100644
index 00000000000..5d743f4c9c8
--- /dev/null
+++ b/qa/qa/page/project/settings/main.rb
@@ -0,0 +1,21 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ class Main < Page::Base
+ include Common
+
+ view 'app/views/projects/edit.html.haml' do
+ element :advanced_settings_section, 'Advanced settings'
+ end
+
+ def expand_advanced_settings(&block)
+ expand_section('Advanced settings') do
+ Advanced.perform(&block)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/merge_request.rb b/qa/qa/page/project/settings/merge_request.rb
new file mode 100644
index 00000000000..b147c91b467
--- /dev/null
+++ b/qa/qa/page/project/settings/merge_request.rb
@@ -0,0 +1,27 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ class MergeRequest < QA::Page::Base
+ include Common
+
+ view 'app/views/projects/_merge_request_fast_forward_settings.html.haml' do
+ element :radio_button_merge_ff
+ end
+
+ view 'app/views/projects/edit.html.haml' do
+ element :merge_request_settings, 'Merge request settings'
+ element :save_merge_request_changes
+ end
+
+ def enable_ff_only
+ expand_section('Merge request settings') do
+ click_element :radio_button_merge_ff
+ click_element :save_merge_request_changes
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/repository.rb b/qa/qa/page/project/settings/repository.rb
new file mode 100644
index 00000000000..22362164a1a
--- /dev/null
+++ b/qa/qa/page/project/settings/repository.rb
@@ -0,0 +1,21 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ class Repository < Page::Base
+ include Common
+
+ view 'app/views/projects/deploy_keys/_index.html.haml' do
+ element :deploy_keys_section, 'Deploy Keys'
+ end
+
+ def expand_deploy_keys(&block)
+ expand_section('Deploy Keys') do
+ DeployKeys.perform(&block)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/runners.rb b/qa/qa/page/project/settings/runners.rb
new file mode 100644
index 00000000000..b41668c94cd
--- /dev/null
+++ b/qa/qa/page/project/settings/runners.rb
@@ -0,0 +1,35 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ class Runners < Page::Base
+ view 'app/views/ci/runner/_how_to_setup_runner.html.haml' do
+ element :registration_token, '%code#registration_token'
+ element :coordinator_address, '%code#coordinator_address'
+ end
+
+ ##
+ # TODO, phase-out CSS classes added in Ruby helpers.
+ #
+ view 'app/helpers/runners_helper.rb' do
+ # rubocop:disable Lint/InterpolationCheck
+ element :runner_status, 'runner-status-#{status}'
+ # rubocop:enable Lint/InterpolationCheck
+ end
+
+ def registration_token
+ find('code#registration_token').text
+ end
+
+ def coordinator_address
+ find('code#coordinator_address').text
+ end
+
+ def has_online_runner?
+ page.has_css?('.runner-status-online')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/settings/secret_variables.rb b/qa/qa/page/project/settings/secret_variables.rb
new file mode 100644
index 00000000000..c95c79f137d
--- /dev/null
+++ b/qa/qa/page/project/settings/secret_variables.rb
@@ -0,0 +1,46 @@
+module QA
+ module Page
+ module Project
+ module Settings
+ class SecretVariables < Page::Base
+ include Common
+
+ view 'app/views/ci/variables/_variable_row.html.haml' do
+ element :variable_row, '.ci-variable-row-body'
+ element :variable_key, '.js-ci-variable-input-key'
+ element :variable_value, '.js-ci-variable-input-value'
+ element :key_placeholder, 'Input variable key'
+ element :value_placeholder, 'Input variable value'
+ end
+
+ view 'app/views/ci/variables/_index.html.haml' do
+ element :save_variables, '.js-secret-variables-save-button'
+ element :reveal_values, '.js-secret-value-reveal-button'
+ end
+
+ def fill_variable_key(key)
+ fill_in('Input variable key', with: key, match: :first)
+ end
+
+ def fill_variable_value(value)
+ fill_in('Input variable value', with: value, match: :first)
+ end
+
+ def save_variables
+ find('.js-secret-variables-save-button').click
+ end
+
+ def reveal_variables
+ find('.js-secret-value-reveal-button').click
+ end
+
+ def variable_value(key)
+ within('.ci-variable-row-body', text: key) do
+ find('.js-ci-variable-input-value').value
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb
index 3b2bac84f3f..0c7ad46d36b 100644
--- a/qa/qa/page/project/show.rb
+++ b/qa/qa/page/project/show.rb
@@ -2,24 +2,80 @@ module QA
module Page
module Project
class Show < Page::Base
+ view 'app/views/shared/_clone_panel.html.haml' do
+ element :clone_dropdown
+ element :clone_options_dropdown, '.clone-options-dropdown'
+ element :project_repository_location, 'text_field_tag :project_clone'
+ end
+
+ view 'app/views/projects/_last_push.html.haml' do
+ element :create_merge_request
+ end
+
+ view 'app/views/projects/_home_panel.html.haml' do
+ element :project_name
+ end
+
+ view 'app/views/layouts/header/_new_dropdown.haml' do
+ element :new_menu_toggle
+ element :new_issue_link, "link_to 'New issue', new_project_issue_path(@project)"
+ end
+
def choose_repository_clone_http
- find('#clone-dropdown').click
+ choose_repository_clone('HTTP', 'http')
+ end
- page.within('.clone-options-dropdown') do
- click_link('HTTP')
- end
+ def choose_repository_clone_ssh
+ # It's not always beginning with ssh:// so detecting with @
+ # would be more reliable because ssh would always contain it.
+ # We can't use .git because HTTP also contain that part.
+ choose_repository_clone('SSH', '@')
end
def repository_location
find('#project_clone').value
end
+ def repository_location_uri
+ Git::Location.new(repository_location)
+ end
+
def project_name
- find('.project-title').text
+ find('.qa-project-name').text
+ end
+
+ def new_merge_request
+ wait(reload: true) do
+ has_css?(element_selector_css(:create_merge_request))
+ end
+
+ click_element :create_merge_request
end
def wait_for_push
sleep 5
+ refresh
+ end
+
+ def go_to_new_issue
+ click_element :new_menu_toggle
+
+ click_link 'New issue'
+ end
+
+ private
+
+ def choose_repository_clone(kind, detect_text)
+ wait(reload: false) do
+ click_element :clone_dropdown
+
+ page.within('.clone-options-dropdown') do
+ click_link(kind)
+ end
+
+ # Ensure git clone textbox was updated
+ repository_location.include?(detect_text)
+ end
end
end
end
diff --git a/qa/qa/page/settings/common.rb b/qa/qa/page/settings/common.rb
new file mode 100644
index 00000000000..a683a6829d5
--- /dev/null
+++ b/qa/qa/page/settings/common.rb
@@ -0,0 +1,25 @@
+module QA
+ module Page
+ module Settings
+ module Common
+ # Click the Expand button present in the specified section
+ #
+ # @param [String] name present in the container in the DOM
+ def expand_section(name)
+ page.within('#content-body') do
+ page.within('section', text: name) do
+ # Because it is possible to click the button before the JS toggle code is bound
+ wait(reload: false) do
+ click_button 'Expand' unless first('button', text: 'Collapse')
+
+ page.has_content?('Collapse')
+ end
+
+ yield if block_given?
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/validator.rb b/qa/qa/page/validator.rb
new file mode 100644
index 00000000000..117d8d4db67
--- /dev/null
+++ b/qa/qa/page/validator.rb
@@ -0,0 +1,52 @@
+module QA
+ module Page
+ class Validator
+ ValidationError = Class.new(StandardError)
+
+ Error = Struct.new(:page, :message) do
+ def to_s
+ "Error: #{page} - #{message}"
+ end
+ end
+
+ def initialize(constant)
+ @module = constant
+ end
+
+ def constants
+ @consts ||= @module.constants.map do |const|
+ @module.const_get(const)
+ end
+ end
+
+ def descendants
+ @descendants ||= constants.map do |const|
+ case const
+ when Class
+ const if const < Page::Base
+ when Module
+ Page::Validator.new(const).descendants
+ end
+ end
+
+ @descendants.flatten.compact
+ end
+
+ def errors
+ [].tap do |errors|
+ descendants.each do |page|
+ page.errors.each do |message|
+ errors.push(Error.new(page.name, message))
+ end
+ end
+ end
+ end
+
+ def validate!
+ return if errors.none?
+
+ raise ValidationError, 'Page views / elements validation error!'
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/view.rb b/qa/qa/page/view.rb
new file mode 100644
index 00000000000..6635e1ce039
--- /dev/null
+++ b/qa/qa/page/view.rb
@@ -0,0 +1,55 @@
+module QA
+ module Page
+ class View
+ attr_reader :path, :elements
+
+ def initialize(path, elements)
+ @path = path
+ @elements = elements
+ end
+
+ def pathname
+ @pathname ||= Pathname.new(File.join(__dir__, '../../../', @path))
+ .cleanpath.expand_path
+ end
+
+ def errors
+ unless pathname.readable?
+ return ["Missing view partial `#{pathname}`!"]
+ end
+
+ ##
+ # Reduce required elements by streaming view and making assertions on
+ # elements' existence.
+ #
+ @missing ||= @elements.dup.tap do |elements|
+ File.foreach(pathname.to_s) do |line|
+ elements.reject! { |element| element.matches?(line) }
+ end
+ end
+
+ @missing.map do |missing|
+ "Missing element `#{missing.name}` in `#{pathname}` view partial!"
+ end
+ end
+
+ def self.evaluate(&block)
+ Page::View::DSL.new.tap do |evaluator|
+ evaluator.instance_exec(&block) if block_given?
+ end
+ end
+
+ class DSL
+ attr_reader :elements
+
+ def initialize
+ @elements = []
+ end
+
+ def element(name, pattern = nil)
+ @elements.push(Page::Element.new(name, pattern))
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/runtime/address.rb b/qa/qa/runtime/address.rb
new file mode 100644
index 00000000000..ffad3974b02
--- /dev/null
+++ b/qa/qa/runtime/address.rb
@@ -0,0 +1,20 @@
+module QA
+ module Runtime
+ class Address
+ attr_reader :address
+
+ def initialize(instance, page = nil)
+ @instance = instance
+ @address = host + (page.is_a?(String) ? page : page&.path)
+ end
+
+ def host
+ if @instance.is_a?(Symbol)
+ Runtime::Scenario.send("#{@instance}_address")
+ else
+ @instance.to_s
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/runtime/api.rb b/qa/qa/runtime/api.rb
new file mode 100644
index 00000000000..e2a096b971d
--- /dev/null
+++ b/qa/qa/runtime/api.rb
@@ -0,0 +1,82 @@
+require 'airborne'
+
+module QA
+ module Runtime
+ module API
+ class Client
+ attr_reader :address
+
+ def initialize(address = :gitlab)
+ @address = address
+ end
+
+ def personal_access_token
+ @personal_access_token ||= get_personal_access_token
+ end
+
+ def get_personal_access_token
+ # you can set the environment variable PERSONAL_ACCESS_TOKEN
+ # to use a specific access token rather than create one from the UI
+ if Runtime::Env.personal_access_token
+ Runtime::Env.personal_access_token
+ else
+ create_personal_access_token
+ end
+ end
+
+ private
+
+ def create_personal_access_token
+ Runtime::Browser.visit(@address, Page::Main::Login) do
+ Page::Main::Login.act { sign_in_using_credentials }
+ Factory::Resource::PersonalAccessToken.fabricate!.access_token
+ end
+ end
+ end
+
+ class Request
+ API_VERSION = 'v4'.freeze
+
+ def initialize(api_client, path, personal_access_token: nil)
+ personal_access_token ||= api_client.personal_access_token
+ request_path = request_path(path, personal_access_token: personal_access_token)
+ @session_address = Runtime::Address.new(api_client.address, request_path)
+ end
+
+ def url
+ @session_address.address
+ end
+
+ # Prepend a request path with the path to the API
+ #
+ # path - Path to append
+ #
+ # Examples
+ #
+ # >> request_path('/issues')
+ # => "/api/v4/issues"
+ #
+ # >> request_path('/issues', personal_access_token: 'sometoken)
+ # => "/api/v4/issues?private_token=..."
+ #
+ # Returns the relative path to the requested API resource
+ def request_path(path, version: API_VERSION, personal_access_token: nil, oauth_access_token: nil)
+ full_path = File.join('/api', version, path)
+
+ if oauth_access_token
+ query_string = "access_token=#{oauth_access_token}"
+ elsif personal_access_token
+ query_string = "private_token=#{personal_access_token}"
+ end
+
+ if query_string
+ full_path << (path.include?('?') ? '&' : '?')
+ full_path << query_string
+ end
+
+ full_path
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb
index 220bb45741b..a12d95683af 100644
--- a/qa/qa/runtime/browser.rb
+++ b/qa/qa/runtime/browser.rb
@@ -23,13 +23,11 @@ module QA
# In case of an address that is a symbol we will try to guess address
# based on `Runtime::Scenario#something_address`.
#
- def visit(address, page, &block)
- Browser::Session.new(address, page).tap do |session|
- session.perform(&block)
- end
+ def visit(address, page = nil, &block)
+ Browser::Session.new(address, page).perform(&block)
end
- def self.visit(address, page, &block)
+ def self.visit(address, page = nil, &block)
new.visit(address, page, &block)
end
@@ -38,28 +36,55 @@ module QA
Capybara.register_driver :chrome do |app|
capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
- 'chromeOptions' => {
- 'args' => %w[headless no-sandbox disable-gpu window-size=1280,1680]
+ # This enables access to logs with `page.driver.manage.get_log(:browser)`
+ loggingPrefs: {
+ browser: "ALL",
+ client: "ALL",
+ driver: "ALL",
+ server: "ALL"
}
)
- Capybara::Selenium::Driver
- .new(app, browser: :chrome, desired_capabilities: capabilities)
- end
+ options = Selenium::WebDriver::Chrome::Options.new
+ options.add_argument("window-size=1240,1680")
- Capybara::Screenshot.register_driver(:chrome) do |driver, path|
- driver.browser.save_screenshot(path)
+ # Chrome won't work properly in a Docker container in sandbox mode
+ options.add_argument("no-sandbox")
+
+ # Run headless by default unless CHROME_HEADLESS is false
+ if QA::Runtime::Env.chrome_headless?
+ options.add_argument("headless")
+
+ # Chrome documentation says this flag is needed for now
+ # https://developers.google.com/web/updates/2017/04/headless-chrome#cli
+ options.add_argument("disable-gpu")
+ end
+
+ # Disable /dev/shm use in CI. See https://gitlab.com/gitlab-org/gitlab-ee/issues/4252
+ options.add_argument("disable-dev-shm-usage") if QA::Runtime::Env.running_in_ci?
+
+ Capybara::Selenium::Driver.new(
+ app,
+ browser: :chrome,
+ desired_capabilities: capabilities,
+ options: options
+ )
end
# Keep only the screenshots generated from the last failing test suite
Capybara::Screenshot.prune_strategy = :keep_last_run
+ # From https://github.com/mattheworiordan/capybara-screenshot/issues/84#issuecomment-41219326
+ Capybara::Screenshot.register_driver(:chrome) do |driver, path|
+ driver.browser.save_screenshot(path)
+ end
+
Capybara.configure do |config|
config.default_driver = :chrome
config.javascript_driver = :chrome
config.default_max_wait_time = 10
# https://github.com/mattheworiordan/capybara-screenshot/issues/164
- config.save_path = 'tmp'
+ config.save_path = File.expand_path('../../tmp', __dir__)
end
end
@@ -67,20 +92,15 @@ module QA
include Capybara::DSL
def initialize(instance, page = nil)
- @instance = instance
- @address = host + page&.path
+ @session_address = Runtime::Address.new(instance, page)
end
- def host
- if @instance.is_a?(Symbol)
- Runtime::Scenario.send("#{@instance}_address")
- else
- @instance.to_s
- end
+ def url
+ @session_address.address
end
def perform(&block)
- visit(@address)
+ visit(url)
yield if block_given?
rescue
@@ -103,7 +123,7 @@ module QA
# See gitlab-org/gitlab-qa#102
#
def clear!
- visit(@address)
+ visit(url)
reset_session!
end
end
diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb
new file mode 100644
index 00000000000..fe432edfa2a
--- /dev/null
+++ b/qa/qa/runtime/env.rb
@@ -0,0 +1,51 @@
+module QA
+ module Runtime
+ module Env
+ extend self
+
+ # set to 'false' to have Chrome run visibly instead of headless
+ def chrome_headless?
+ (ENV['CHROME_HEADLESS'] =~ /^(false|no|0)$/i) != 0
+ end
+
+ def running_in_ci?
+ ENV['CI'] || ENV['CI_SERVER']
+ end
+
+ # specifies token that can be used for the api
+ def personal_access_token
+ ENV['PERSONAL_ACCESS_TOKEN']
+ end
+
+ # By default, "standard" denotes a standard GitLab user login.
+ # Set this to "ldap" if the user should be logged in via LDAP.
+ def user_type
+ (ENV['GITLAB_USER_TYPE'] || 'standard').tap do |type|
+ unless %w(ldap standard).include?(type)
+ raise ArgumentError.new("Invalid user type '#{type}': must be 'ldap' or 'standard'")
+ end
+ end
+ end
+
+ def user_username
+ ENV['GITLAB_USERNAME']
+ end
+
+ def user_password
+ ENV['GITLAB_PASSWORD']
+ end
+
+ def ldap_username
+ ENV['GITLAB_LDAP_USERNAME']
+ end
+
+ def ldap_password
+ ENV['GITLAB_LDAP_PASSWORD']
+ end
+
+ def sandbox_name
+ ENV['GITLAB_SANDBOX_NAME']
+ end
+ end
+ end
+end
diff --git a/qa/qa/runtime/namespace.rb b/qa/qa/runtime/namespace.rb
index b00e925986b..8d05b387416 100644
--- a/qa/qa/runtime/namespace.rb
+++ b/qa/qa/runtime/namespace.rb
@@ -11,8 +11,12 @@ module QA
'qa-test-' + time.strftime('%d-%m-%Y-%H-%M-%S')
end
+ def path
+ "#{sandbox_name}/#{name}"
+ end
+
def sandbox_name
- 'gitlab-qa-sandbox'
+ Runtime::Env.sandbox_name || 'gitlab-qa-sandbox'
end
end
end
diff --git a/qa/qa/runtime/rsa_key.rb b/qa/qa/runtime/rsa_key.rb
new file mode 100644
index 00000000000..fcd7dcc4f02
--- /dev/null
+++ b/qa/qa/runtime/rsa_key.rb
@@ -0,0 +1,21 @@
+require 'net/ssh'
+require 'forwardable'
+
+module QA
+ module Runtime
+ class RSAKey
+ extend Forwardable
+
+ attr_reader :key
+ def_delegators :@key, :fingerprint, :to_pem
+
+ def initialize(bits = 4096)
+ @key = OpenSSL::PKey::RSA.new(bits)
+ end
+
+ def public_key
+ @public_key ||= "#{key.ssh_type} #{[key.to_blob].pack('m0')}"
+ end
+ end
+ end
+end
diff --git a/qa/qa/runtime/user.rb b/qa/qa/runtime/user.rb
index 60027c89ab1..c80ee6d4d96 100644
--- a/qa/qa/runtime/user.rb
+++ b/qa/qa/runtime/user.rb
@@ -3,12 +3,28 @@ module QA
module User
extend self
+ def default_name
+ 'root'
+ end
+
def name
- ENV['GITLAB_USERNAME'] || 'root'
+ Runtime::Env.user_username || default_name
end
def password
- ENV['GITLAB_PASSWORD'] || '5iveL!fe'
+ Runtime::Env.user_password || '5iveL!fe'
+ end
+
+ def ldap_user?
+ Runtime::Env.user_type == 'ldap'
+ end
+
+ def ldap_username
+ Runtime::Env.ldap_username || name
+ end
+
+ def ldap_password
+ Runtime::Env.ldap_password || password
end
end
end
diff --git a/qa/qa/scenario/bootable.rb b/qa/qa/scenario/bootable.rb
index d6de4d404c8..dd12ea6d492 100644
--- a/qa/qa/scenario/bootable.rb
+++ b/qa/qa/scenario/bootable.rb
@@ -23,7 +23,7 @@ module QA
arguments.parse!(argv)
- self.perform(**Runtime::Scenario.attributes)
+ self.perform(Runtime::Scenario.attributes, *arguments.default_argv)
end
private
diff --git a/qa/qa/scenario/entrypoint.rb b/qa/qa/scenario/entrypoint.rb
deleted file mode 100644
index ae099fd911e..00000000000
--- a/qa/qa/scenario/entrypoint.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-module QA
- module Scenario
- ##
- # Base class for running the suite against any GitLab instance,
- # including staging and on-premises installation.
- #
- class Entrypoint < Template
- include Bootable
-
- def perform(address, *files)
- Runtime::Scenario.define(:gitlab_address, address)
-
- ##
- # Perform before hooks, which are different for CE and EE
- #
- Runtime::Release.perform_before_hooks
-
- Specs::Runner.perform do |specs|
- specs.tty = true
- specs.tags = self.class.get_tags
- specs.files = files.any? ? files : 'qa/specs/features'
- end
- end
-
- def self.tags(*tags)
- @tags = tags
- end
-
- def self.get_tags
- @tags
- end
- end
- end
-end
diff --git a/qa/qa/scenario/taggable.rb b/qa/qa/scenario/taggable.rb
new file mode 100644
index 00000000000..b1f24d742e0
--- /dev/null
+++ b/qa/qa/scenario/taggable.rb
@@ -0,0 +1,17 @@
+module QA
+ module Scenario
+ module Taggable
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ def tags(*tags)
+ @tags = tags
+ end
+
+ def focus
+ @tags.to_a
+ end
+
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+ end
+ end
+end
diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb
index e2a1f6bf2bd..567e5fd6cca 100644
--- a/qa/qa/scenario/test/instance.rb
+++ b/qa/qa/scenario/test/instance.rb
@@ -2,11 +2,34 @@ module QA
module Scenario
module Test
##
- # Run test suite against any GitLab instance,
+ # Base class for running the suite against any GitLab instance,
# including staging and on-premises installation.
#
- class Instance < Entrypoint
+ class Instance < Template
+ include Bootable
+ extend Taggable
+
tags :core
+
+ def perform(address, *rspec_options)
+ Runtime::Scenario.define(:gitlab_address, address)
+
+ ##
+ # Perform before hooks, which are different for CE and EE
+ #
+ Runtime::Release.perform_before_hooks
+
+ Specs::Runner.perform do |specs|
+ specs.tty = true
+ specs.tags = self.class.focus
+ specs.options =
+ if rspec_options.any?
+ rspec_options
+ else
+ File.expand_path('../../specs/features', __dir__)
+ end
+ end
+ end
end
end
end
diff --git a/qa/qa/scenario/test/integration/ldap.rb b/qa/qa/scenario/test/integration/ldap.rb
new file mode 100644
index 00000000000..257ed81d9e1
--- /dev/null
+++ b/qa/qa/scenario/test/integration/ldap.rb
@@ -0,0 +1,11 @@
+module QA
+ module Scenario
+ module Test
+ module Integration
+ class LDAP < Test::Instance
+ tags :ldap
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb
index 7d0702afdb1..13bfad28b0b 100644
--- a/qa/qa/scenario/test/integration/mattermost.rb
+++ b/qa/qa/scenario/test/integration/mattermost.rb
@@ -6,13 +6,13 @@ module QA
# Run test suite against any GitLab instance where mattermost is enabled,
# including staging and on-premises installation.
#
- class Mattermost < Scenario::Entrypoint
+ class Mattermost < Test::Instance
tags :core, :mattermost
- def perform(address, mattermost, *files)
+ def perform(address, mattermost, *rspec_options)
Runtime::Scenario.define(:mattermost_address, mattermost)
- super(address, *files)
+ super(address, *rspec_options)
end
end
end
diff --git a/qa/qa/scenario/test/sanity/selectors.rb b/qa/qa/scenario/test/sanity/selectors.rb
new file mode 100644
index 00000000000..c87eb5f3dfb
--- /dev/null
+++ b/qa/qa/scenario/test/sanity/selectors.rb
@@ -0,0 +1,54 @@
+module QA
+ module Scenario
+ module Test
+ module Sanity
+ class Selectors < Scenario::Template
+ include Scenario::Bootable
+
+ PAGES = [QA::Page].freeze
+
+ def perform(*)
+ validators = PAGES.map do |pages|
+ Page::Validator.new(pages)
+ end
+
+ validators.map(&:errors).flatten.tap do |errors|
+ break if errors.none?
+
+ warn <<~EOS
+ GitLab QA sanity selectors validation test detected problems
+ with your merge request!
+
+ The purpose of this test is to make sure that GitLab QA tests,
+ that are entirely black-box, click-driven scenarios, do match
+ pages structure / layout in GitLab CE / EE repositories.
+
+ It looks like you have changed views / pages / selectors, and
+ these are now out of sync with what we have defined in `qa/`
+ directory.
+
+ Please update the code in `qa/` directory to make it match
+ current changes in this merge request.
+
+ For more help see documentation in `qa/page/README.md` file or
+ ask for help on #qa channel on Slack (GitLab Team only).
+
+ If you are not a Team Member, and you still need help to
+ contribute, please open an issue in GitLab QA issue tracker.
+
+ Please see errors described below.
+
+ EOS
+
+ warn errors
+ end
+
+ validators.each(&:validate!)
+
+ puts 'Views / selectors validation passed!'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/service/omnibus.rb b/qa/qa/service/omnibus.rb
new file mode 100644
index 00000000000..b5c06874e5c
--- /dev/null
+++ b/qa/qa/service/omnibus.rb
@@ -0,0 +1,20 @@
+module QA
+ module Service
+ class Omnibus
+ include Scenario::Actable
+ include Service::Shellout
+
+ def initialize(container)
+ @name = container
+ end
+
+ def gitlab_ctl(command, input: nil)
+ if input.nil?
+ shell "docker exec #{@name} gitlab-ctl #{command}"
+ else
+ shell "docker exec #{@name} bash -c '#{input} | gitlab-ctl #{command}'"
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/service/runner.rb b/qa/qa/service/runner.rb
new file mode 100644
index 00000000000..c0352e0467a
--- /dev/null
+++ b/qa/qa/service/runner.rb
@@ -0,0 +1,49 @@
+require 'securerandom'
+
+module QA
+ module Service
+ class Runner
+ include Scenario::Actable
+ include Service::Shellout
+
+ attr_accessor :token, :address, :tags, :image
+
+ def initialize(name)
+ @image = 'gitlab/gitlab-runner:alpine'
+ @name = name || "qa-runner-#{SecureRandom.hex(4)}"
+ @network = Runtime::Scenario.attributes[:network] || 'test'
+ @tags = %w[qa test]
+ end
+
+ def network
+ shell "docker network inspect #{@network}"
+ rescue CommandError
+ 'bridge'
+ else
+ @network
+ end
+
+ def pull
+ shell "docker pull #{@image}"
+ end
+
+ def register!
+ shell <<~CMD.tr("\n", ' ')
+ docker run -d --rm --entrypoint=/bin/sh
+ --network #{network} --name #{@name}
+ -e CI_SERVER_URL=#{@address}
+ -e REGISTER_NON_INTERACTIVE=true
+ -e REGISTRATION_TOKEN=#{@token}
+ -e RUNNER_EXECUTOR=shell
+ -e RUNNER_TAG_LIST=#{@tags.join(',')}
+ -e RUNNER_NAME=#{@name}
+ #{@image} -c 'gitlab-runner register && gitlab-runner run'
+ CMD
+ end
+
+ def remove!
+ shell "docker rm -f #{@name}"
+ end
+ end
+ end
+end
diff --git a/qa/qa/service/shellout.rb b/qa/qa/service/shellout.rb
new file mode 100644
index 00000000000..76fb2af6319
--- /dev/null
+++ b/qa/qa/service/shellout.rb
@@ -0,0 +1,25 @@
+require 'open3'
+
+module QA
+ module Service
+ module Shellout
+ CommandError = Class.new(StandardError)
+
+ ##
+ # TODO, make it possible to use generic QA framework classes
+ # as a library - gitlab-org/gitlab-qa#94
+ #
+ def shell(command)
+ puts "Executing `#{command}`"
+
+ Open3.popen2e(command) do |_in, out, wait|
+ out.each { |line| puts line }
+
+ if wait.value.exited? && wait.value.exitstatus.nonzero?
+ raise CommandError, "Command `#{command}` failed!"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/shell/omnibus.rb b/qa/qa/shell/omnibus.rb
deleted file mode 100644
index 6b3628d3109..00000000000
--- a/qa/qa/shell/omnibus.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-require 'open3'
-
-module QA
- module Shell
- class Omnibus
- include Scenario::Actable
-
- def initialize(container)
- @name = container
- end
-
- def gitlab_ctl(command, input: nil)
- if input.nil?
- shell "docker exec #{@name} gitlab-ctl #{command}"
- else
- shell "docker exec #{@name} bash -c '#{input} | gitlab-ctl #{command}'"
- end
- end
-
- private
-
- ##
- # TODO, make it possible to use generic QA framework classes
- # as a library - gitlab-org/gitlab-qa#94
- #
- def shell(command)
- puts "Executing `#{command}`"
-
- Open3.popen2e(command) do |_in, out, wait|
- out.each { |line| puts line }
-
- if wait.value.exited? && wait.value.exitstatus.nonzero?
- raise "Docker command `#{command}` failed!"
- end
- end
- end
- end
- end
-end
diff --git a/qa/qa/specs/features/api/users_spec.rb b/qa/qa/specs/features/api/users_spec.rb
new file mode 100644
index 00000000000..d4ff4ebbc9a
--- /dev/null
+++ b/qa/qa/specs/features/api/users_spec.rb
@@ -0,0 +1,42 @@
+module QA
+ feature 'API users', :core do
+ before(:context) do
+ @api_client = Runtime::API::Client.new(:gitlab)
+ end
+
+ context 'when authenticated' do
+ let(:request) { Runtime::API::Request.new(@api_client, '/users') }
+
+ scenario 'get list of users' do
+ get request.url
+
+ expect_status(200)
+ end
+
+ scenario 'submit request with a valid user name' do
+ get request.url, { params: { username: Runtime::User.name } }
+
+ expect_status(200)
+ expect(json_body).to be_an Array
+ expect(json_body.size).to eq(1)
+ expect(json_body.first[:username]).to eq Runtime::User.name
+ end
+
+ scenario 'submit request with an invalid user name' do
+ get request.url, { params: { username: SecureRandom.hex(10) } }
+
+ expect_status(200)
+ expect(json_body).to be_an Array
+ expect(json_body.size).to eq(0)
+ end
+ end
+
+ scenario 'submit request with an invalid token' do
+ request = Runtime::API::Request.new(@api_client, '/users', personal_access_token: 'invalid')
+
+ get request.url
+
+ expect_status(401)
+ end
+ end
+end
diff --git a/qa/qa/specs/features/login/ldap_spec.rb b/qa/qa/specs/features/login/ldap_spec.rb
new file mode 100644
index 00000000000..ac2bd5a3c39
--- /dev/null
+++ b/qa/qa/specs/features/login/ldap_spec.rb
@@ -0,0 +1,15 @@
+module QA
+ feature 'LDAP user login', :ldap do
+ scenario 'user logs in using LDAP credentials' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_ldap_credentials }
+
+ # TODO, since `Signed in successfully` message was removed
+ # this is the only way to tell if user is signed in correctly.
+ #
+ Page::Menu::Main.perform do |menu|
+ expect(menu).to have_personal_area
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/login/standard_spec.rb b/qa/qa/specs/features/login/standard_spec.rb
index 9eaa2b772e6..141ffa3cfb7 100644
--- a/qa/qa/specs/features/login/standard_spec.rb
+++ b/qa/qa/specs/features/login/standard_spec.rb
@@ -7,7 +7,7 @@ module QA
# TODO, since `Signed in successfully` message was removed
# this is the only way to tell if user is signed in correctly.
#
- Page::Main::Menu.perform do |menu|
+ Page::Menu::Main.perform do |menu|
expect(menu).to have_personal_area
end
end
diff --git a/qa/qa/specs/features/mattermost/group_create_spec.rb b/qa/qa/specs/features/mattermost/group_create_spec.rb
index b3dbe44bf6e..2e27a285223 100644
--- a/qa/qa/specs/features/mattermost/group_create_spec.rb
+++ b/qa/qa/specs/features/mattermost/group_create_spec.rb
@@ -3,7 +3,7 @@ module QA
scenario 'creating a group with a mattermost team' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
- Page::Main::Menu.act { go_to_groups }
+ Page::Menu::Main.act { go_to_groups }
Page::Dashboard::Groups.perform do |page|
page.go_to_new_group
diff --git a/qa/qa/specs/features/merge_request/create_spec.rb b/qa/qa/specs/features/merge_request/create_spec.rb
new file mode 100644
index 00000000000..fbf9a4d17e5
--- /dev/null
+++ b/qa/qa/specs/features/merge_request/create_spec.rb
@@ -0,0 +1,17 @@
+module QA
+ feature 'creates a merge request', :core do
+ scenario 'user creates a new merge request' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ Factory::Resource::MergeRequest.fabricate! do |merge_request|
+ merge_request.title = 'This is a merge request'
+ merge_request.description = 'Great feature'
+ end
+
+ expect(page).to have_content('This is a merge request')
+ expect(page).to have_content('Great feature')
+ expect(page).to have_content('Opened less than a minute ago')
+ end
+ end
+end
diff --git a/qa/qa/specs/features/merge_request/rebase_spec.rb b/qa/qa/specs/features/merge_request/rebase_spec.rb
new file mode 100644
index 00000000000..2a44d42af6f
--- /dev/null
+++ b/qa/qa/specs/features/merge_request/rebase_spec.rb
@@ -0,0 +1,39 @@
+module QA
+ feature 'merge request rebase', :core do
+ scenario 'rebases source branch of merge request' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ project = Factory::Resource::Project.fabricate! do |project|
+ project.name = "only-fast-forward"
+ end
+
+ Page::Menu::Side.act { go_to_settings }
+ Page::Project::Settings::MergeRequest.act { enable_ff_only }
+
+ merge_request = Factory::Resource::MergeRequest.fabricate! do |merge_request|
+ merge_request.project = project
+ merge_request.title = 'Needs rebasing'
+ end
+
+ Factory::Repository::Push.fabricate! do |push|
+ push.project = project
+ push.file_name = "other.txt"
+ push.file_content = "New file added!"
+ end
+
+ merge_request.visit!
+
+ Page::MergeRequest::Show.perform do |merge_request|
+ expect(merge_request).to have_content('Needs rebasing')
+ expect(merge_request).not_to be_fast_forward_possible
+ expect(merge_request).not_to have_merge_button
+
+ merge_request.rebase!
+
+ expect(merge_request).to have_merge_button
+ expect(merge_request.fast_forward_possible?).to be_truthy
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/activity_spec.rb b/qa/qa/specs/features/project/activity_spec.rb
new file mode 100644
index 00000000000..ba94ce8cf28
--- /dev/null
+++ b/qa/qa/specs/features/project/activity_spec.rb
@@ -0,0 +1,20 @@
+module QA
+ feature 'activity page', :core do
+ scenario 'push creates an event in the activity page' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ Factory::Repository::Push.fabricate! do |push|
+ push.file_name = 'README.md'
+ push.file_content = '# This is a test project'
+ push.commit_message = 'Add README.md'
+ end
+
+ Page::Menu::Side.act { go_to_activity }
+
+ Page::Project::Activity.act { go_to_push_events }
+
+ expect(page).to have_content('pushed new branch master')
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/add_deploy_key_spec.rb b/qa/qa/specs/features/project/add_deploy_key_spec.rb
new file mode 100644
index 00000000000..b9998dda895
--- /dev/null
+++ b/qa/qa/specs/features/project/add_deploy_key_spec.rb
@@ -0,0 +1,20 @@
+module QA
+ feature 'deploy keys support', :core do
+ scenario 'user adds a deploy key' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ key = Runtime::RSAKey.new
+ deploy_key_title = 'deploy key title'
+ deploy_key_value = key.public_key
+
+ deploy_key = Factory::Resource::DeployKey.fabricate! do |resource|
+ resource.title = deploy_key_title
+ resource.key = deploy_key_value
+ end
+
+ expect(deploy_key.title).to eq(deploy_key_title)
+ expect(deploy_key.fingerprint).to eq(key.fingerprint)
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/add_secret_variable_spec.rb b/qa/qa/specs/features/project/add_secret_variable_spec.rb
new file mode 100644
index 00000000000..d1bf7849bd0
--- /dev/null
+++ b/qa/qa/specs/features/project/add_secret_variable_spec.rb
@@ -0,0 +1,24 @@
+module QA
+ feature 'secret variables support', :core do
+ scenario 'user adds a secret variable' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ Factory::Resource::SecretVariable.fabricate! do |resource|
+ resource.key = 'VARIABLE_KEY'
+ resource.value = 'some secret variable'
+ end
+
+ Page::Project::Settings::CICD.perform do |settings|
+ settings.expand_secret_variables do |page|
+ expect(page).to have_field(with: 'VARIABLE_KEY')
+ expect(page).not_to have_field(with: 'some secret variable')
+
+ page.reveal_variables
+
+ expect(page).to have_field(with: 'some secret variable')
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/create_issue_spec.rb b/qa/qa/specs/features/project/create_issue_spec.rb
new file mode 100644
index 00000000000..b73f108c2d9
--- /dev/null
+++ b/qa/qa/specs/features/project/create_issue_spec.rb
@@ -0,0 +1,18 @@
+module QA
+ feature 'creates issue', :core do
+ let(:issue_title) { 'issue title' }
+
+ scenario 'user creates issue' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ Factory::Resource::Issue.fabricate! do |issue|
+ issue.title = issue_title
+ end
+
+ Page::Menu::Side.act { click_issues }
+
+ expect(page).to have_content(issue_title)
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/create_spec.rb b/qa/qa/specs/features/project/create_spec.rb
index 61c19378ae0..b1c07249892 100644
--- a/qa/qa/specs/features/project/create_spec.rb
+++ b/qa/qa/specs/features/project/create_spec.rb
@@ -4,11 +4,13 @@ module QA
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
- Factory::Resource::Project.fabricate! do |project|
+ created_project = Factory::Resource::Project.fabricate! do |project|
project.name = 'awesome-project'
project.description = 'create awesome project test'
end
+ expect(created_project.name).to match /^awesome-project-\h{16}$/
+
expect(page).to have_content(
/Project \S?awesome-project\S+ was successfully created/
)
diff --git a/qa/qa/specs/features/project/deploy_key_clone_spec.rb b/qa/qa/specs/features/project/deploy_key_clone_spec.rb
new file mode 100644
index 00000000000..19d3c83758a
--- /dev/null
+++ b/qa/qa/specs/features/project/deploy_key_clone_spec.rb
@@ -0,0 +1,81 @@
+require 'digest/sha1'
+
+module QA
+ feature 'cloning code using a deploy key', :core, :docker do
+ let(:runner_name) { "qa-runner-#{Time.now.to_i}" }
+ let(:key) { Runtime::RSAKey.new }
+
+ given(:project) do
+ Factory::Resource::Project.fabricate! do |resource|
+ resource.name = 'deploy-key-clone-project'
+ end
+ end
+
+ after do
+ Service::Runner.new(runner_name).remove!
+ end
+
+ scenario 'user sets up a deploy key to clone code using pipelines' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ Factory::Resource::Runner.fabricate! do |resource|
+ resource.project = project
+ resource.name = runner_name
+ resource.tags = %w[qa docker]
+ resource.image = 'gitlab/gitlab-runner:ubuntu'
+ end
+
+ Factory::Resource::DeployKey.fabricate! do |resource|
+ resource.project = project
+ resource.title = 'deploy key title'
+ resource.key = key.public_key
+ end
+
+ Factory::Resource::SecretVariable.fabricate! do |resource|
+ resource.project = project
+ resource.key = 'DEPLOY_KEY'
+ resource.value = key.to_pem
+ end
+
+ project.visit!
+
+ repository_uri = Page::Project::Show.act do
+ choose_repository_clone_ssh
+ repository_location_uri
+ end
+
+ gitlab_ci = <<~YAML
+ cat-config:
+ script:
+ - mkdir -p ~/.ssh
+ - ssh-keyscan -p #{repository_uri.port} #{repository_uri.host} >> ~/.ssh/known_hosts
+ - eval $(ssh-agent -s)
+ - echo "$DEPLOY_KEY" | ssh-add -
+ - git clone #{repository_uri.git_uri}
+ - sha1sum #{project.name}/.gitlab-ci.yml
+ tags:
+ - qa
+ - docker
+ YAML
+
+ Factory::Repository::Push.fabricate! do |resource|
+ resource.project = project
+ resource.file_name = '.gitlab-ci.yml'
+ resource.commit_message = 'Add .gitlab-ci.yml'
+ resource.file_content = gitlab_ci
+ end
+
+ sha1sum = Digest::SHA1.hexdigest(gitlab_ci)
+
+ Page::Project::Show.act { wait_for_push }
+ Page::Menu::Side.act { click_ci_cd_pipelines }
+ Page::Project::Pipeline::Index.act { go_to_latest_pipeline }
+ Page::Project::Pipeline::Show.act { go_to_first_job }
+
+ Page::Project::Job::Show.perform do |job|
+ expect(job.output).to include(sha1sum)
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/project/pipelines_spec.rb b/qa/qa/specs/features/project/pipelines_spec.rb
new file mode 100644
index 00000000000..74f6474443d
--- /dev/null
+++ b/qa/qa/specs/features/project/pipelines_spec.rb
@@ -0,0 +1,102 @@
+module QA
+ feature 'CI/CD Pipelines', :core, :docker do
+ let(:executor) { "qa-runner-#{Time.now.to_i}" }
+
+ after do
+ Service::Runner.new(executor).remove!
+ end
+
+ scenario 'user registers a new specific runner' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ Factory::Resource::Runner.fabricate! do |runner|
+ runner.name = executor
+ end
+
+ Page::Project::Settings::CICD.perform do |settings|
+ sleep 5 # Runner should register within 5 seconds
+ settings.refresh
+
+ settings.expand_runners_settings do |page|
+ expect(page).to have_content(executor)
+ expect(page).to have_online_runner
+ end
+ end
+ end
+
+ scenario 'users creates a new pipeline' do
+ Runtime::Browser.visit(:gitlab, Page::Main::Login)
+ Page::Main::Login.act { sign_in_using_credentials }
+
+ project = Factory::Resource::Project.fabricate! do |project|
+ project.name = 'project-with-pipelines'
+ project.description = 'Project with CI/CD Pipelines.'
+ end
+
+ Factory::Resource::Runner.fabricate! do |runner|
+ runner.project = project
+ runner.name = executor
+ runner.tags = %w[qa test]
+ end
+
+ Factory::Repository::Push.fabricate! do |push|
+ push.project = project
+ push.file_name = '.gitlab-ci.yml'
+ push.commit_message = 'Add .gitlab-ci.yml'
+ push.file_content = <<~EOF
+ test-success:
+ tags:
+ - qa
+ - test
+ script: echo 'OK'
+
+ test-failure:
+ tags:
+ - qa
+ - test
+ script:
+ - echo 'FAILURE'
+ - exit 1
+
+ test-tags:
+ tags:
+ - qa
+ - docker
+ script: echo 'NOOP'
+
+ test-artifacts:
+ tags:
+ - qa
+ - test
+ script: mkdir my-artifacts; echo "CONTENTS" > my-artifacts/artifact.txt
+ artifacts:
+ paths:
+ - my-artifacts/
+ EOF
+ end
+
+ Page::Project::Show.act { wait_for_push }
+
+ expect(page).to have_content('Add .gitlab-ci.yml')
+
+ Page::Menu::Side.act { click_ci_cd_pipelines }
+
+ expect(page).to have_content('All 1')
+ expect(page).to have_content('Add .gitlab-ci.yml')
+
+ puts 'Waiting for the runner to process the pipeline'
+ sleep 15 # Runner should process all jobs within 15 seconds.
+
+ Page::Project::Pipeline::Index.act { go_to_latest_pipeline }
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ expect(pipeline).to be_running
+ expect(pipeline).to have_build('test-success', status: :success)
+ expect(pipeline).to have_build('test-failure', status: :failed)
+ expect(pipeline).to have_build('test-tags', status: :pending)
+ expect(pipeline).to have_build('test-artifacts', status: :success)
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/repository/push_spec.rb b/qa/qa/specs/features/repository/push_spec.rb
index 4f6ffe14c9f..51d9c2c7fd2 100644
--- a/qa/qa/specs/features/repository/push_spec.rb
+++ b/qa/qa/specs/features/repository/push_spec.rb
@@ -11,10 +11,7 @@ module QA
push.commit_message = 'Add README.md'
end
- Page::Project::Show.act do
- wait_for_push
- refresh
- end
+ Page::Project::Show.act { wait_for_push }
expect(page).to have_content('README.md')
expect(page).to have_content('This is a test project')
diff --git a/qa/qa/specs/runner.rb b/qa/qa/specs/runner.rb
index 3f7b75df986..f8f6fe65599 100644
--- a/qa/qa/specs/runner.rb
+++ b/qa/qa/specs/runner.rb
@@ -3,19 +3,19 @@ require 'rspec/core'
module QA
module Specs
class Runner < Scenario::Template
- attr_accessor :tty, :tags, :files
+ attr_accessor :tty, :tags, :options
def initialize
@tty = false
@tags = []
- @files = ['qa/specs/features']
+ @options = [File.expand_path('./features', __dir__)]
end
def perform
args = []
args.push('--tty') if tty
tags.to_a.each { |tag| args.push(['-t', tag.to_s]) }
- args.push(files)
+ args.push(options)
Runtime::Browser.configure!
diff --git a/qa/spec/factory/base_spec.rb b/qa/spec/factory/base_spec.rb
index a3ba0176819..04e04886699 100644
--- a/qa/spec/factory/base_spec.rb
+++ b/qa/spec/factory/base_spec.rb
@@ -1,11 +1,13 @@
describe QA::Factory::Base do
+ let(:factory) { spy('factory') }
+ let(:product) { spy('product') }
+
describe '.fabricate!' do
subject { Class.new(described_class) }
- let(:factory) { spy('factory') }
- let(:product) { spy('product') }
before do
allow(QA::Factory::Product).to receive(:new).and_return(product)
+ allow(QA::Factory::Product).to receive(:populate!).and_return(product)
end
it 'instantiates the factory and calls factory method' do
@@ -18,7 +20,6 @@ describe QA::Factory::Base do
it 'returns fabrication product' do
allow(subject).to receive(:new).and_return(factory)
- allow(factory).to receive(:fabricate!).and_return('something')
result = subject.fabricate!('something')
@@ -59,30 +60,73 @@ describe QA::Factory::Base do
it 'defines dependency accessors' do
expect(subject.new).to respond_to :mydep, :mydep=
end
- end
- describe 'building dependencies' do
- let(:dependency) { double('dependency') }
- let(:instance) { spy('instance') }
+ describe 'dependencies fabrication' do
+ let(:dependency) { double('dependency') }
+ let(:instance) { spy('instance') }
+
+ subject do
+ Class.new(described_class) do
+ dependency Some::MyDependency, as: :mydep
+ end
+ end
+
+ before do
+ stub_const('Some::MyDependency', dependency)
+
+ allow(subject).to receive(:new).and_return(instance)
+ allow(instance).to receive(:mydep).and_return(nil)
+ allow(QA::Factory::Product).to receive(:new)
+ allow(QA::Factory::Product).to receive(:populate!)
+ end
+
+ it 'builds all dependencies first' do
+ expect(dependency).to receive(:fabricate!).once
+
+ subject.fabricate!
+ end
+ end
+ end
+ describe '.product' do
subject do
Class.new(described_class) do
- dependency Some::MyDependency, as: :mydep
+ def fabricate!
+ "any"
+ end
+
+ # Defined only to be stubbed
+ def self.find_page
+ end
+
+ product :token do
+ find_page.do_something_on_page!
+ 'resulting value'
+ end
end
end
- before do
- stub_const('Some::MyDependency', dependency)
-
- allow(subject).to receive(:new).and_return(instance)
- allow(instance).to receive(:mydep).and_return(nil)
- allow(QA::Factory::Product).to receive(:new)
+ it 'appends new product attribute' do
+ expect(subject.attributes).to be_one
+ expect(subject.attributes).to have_key(:token)
end
- it 'builds all dependencies first' do
- expect(dependency).to receive(:fabricate!).once
+ describe 'populating fabrication product with data' do
+ let(:page) { spy('page') }
+
+ before do
+ allow(factory).to receive(:class).and_return(subject)
+ allow(QA::Factory::Product).to receive(:new).and_return(product)
+ allow(product).to receive(:page).and_return(page)
+ allow(subject).to receive(:find_page).and_return(page)
+ end
+
+ it 'populates product after fabrication' do
+ subject.fabricate!
- subject.fabricate!
+ expect(product.token).to eq 'resulting value'
+ expect(page).to have_received(:do_something_on_page!)
+ end
end
end
end
diff --git a/qa/spec/factory/dependency_spec.rb b/qa/spec/factory/dependency_spec.rb
index 32405415126..8aaa6665a18 100644
--- a/qa/spec/factory/dependency_spec.rb
+++ b/qa/spec/factory/dependency_spec.rb
@@ -54,6 +54,19 @@ describe QA::Factory::Dependency do
expect(factory).to have_received(:mydep=).with(dependency)
end
+
+ context 'when receives a caller factory as block argument' do
+ let(:dependency) { QA::Factory::Base }
+
+ it 'calls given block with dependency factory and caller factory' do
+ allow_any_instance_of(QA::Factory::Base).to receive(:fabricate!).and_return(factory)
+ allow(QA::Factory::Product).to receive(:populate!).and_return(spy('any'))
+
+ subject.build!
+
+ expect(block).to have_received(:call).with(an_instance_of(QA::Factory::Base), factory)
+ end
+ end
end
end
end
diff --git a/qa/spec/factory/product_spec.rb b/qa/spec/factory/product_spec.rb
index 3d9e86a641b..f245aabbf43 100644
--- a/qa/spec/factory/product_spec.rb
+++ b/qa/spec/factory/product_spec.rb
@@ -1,32 +1,28 @@
describe QA::Factory::Product do
- let(:factory) { spy('factory') }
- let(:product) { spy('product') }
+ let(:factory) do
+ QA::Factory::Base.new
+ end
- describe '.populate!' do
- it 'instantiates and yields factory' do
- expect(described_class).to receive(:new).with(factory)
+ let(:attributes) do
+ { test: QA::Factory::Product::Attribute.new(:test, proc { 'returned' }) }
+ end
- described_class.populate!(factory) do |instance|
- instance.something = 'string'
- end
+ let(:product) { spy('product') }
- expect(factory).to have_received(:something=).with('string')
- end
+ before do
+ allow(QA::Factory::Base).to receive(:attributes).and_return(attributes)
+ end
- it 'returns a fabrication product' do
- expect(described_class).to receive(:new)
- .with(factory).and_return(product)
+ describe '.populate!' do
+ it 'returns a fabrication product and define factory attributes as its methods' do
+ expect(described_class).to receive(:new).and_return(product)
result = described_class.populate!(factory) do |instance|
instance.something = 'string'
end
expect(result).to be product
- end
-
- it 'raises unless block given' do
- expect { described_class.populate!(factory) }
- .to raise_error ArgumentError
+ expect(result.test).to eq('returned')
end
end
@@ -37,8 +33,7 @@ describe QA::Factory::Product do
allow_any_instance_of(described_class)
.to receive(:visit).and_return('visited some url')
- expect(described_class.new(factory).visit!)
- .to eq 'visited some url'
+ expect(subject.visit!).to eq 'visited some url'
end
end
end
diff --git a/qa/spec/fixtures/banana_sample.gif b/qa/spec/fixtures/banana_sample.gif
new file mode 100644
index 00000000000..1322ac92d14
--- /dev/null
+++ b/qa/spec/fixtures/banana_sample.gif
Binary files differ
diff --git a/qa/spec/git/location_spec.rb b/qa/spec/git/location_spec.rb
new file mode 100644
index 00000000000..aef906ee836
--- /dev/null
+++ b/qa/spec/git/location_spec.rb
@@ -0,0 +1,55 @@
+describe QA::Git::Location do
+ describe '.new' do
+ context 'when URI starts with ssh://' do
+ context 'when URI has port' do
+ it 'parses correctly' do
+ uri = described_class
+ .new('ssh://git@qa.test:2222/sandbox/qa/repo.git')
+
+ expect(uri.user).to eq('git')
+ expect(uri.host).to eq('qa.test')
+ expect(uri.port).to eq(2222)
+ expect(uri.path).to eq('/sandbox/qa/repo.git')
+ end
+ end
+
+ context 'when URI does not have port' do
+ it 'parses correctly' do
+ uri = described_class
+ .new('ssh://git@qa.test/sandbox/qa/repo.git')
+
+ expect(uri.user).to eq('git')
+ expect(uri.host).to eq('qa.test')
+ expect(uri.port).to eq(22)
+ expect(uri.path).to eq('/sandbox/qa/repo.git')
+ end
+ end
+ end
+
+ context 'when URI does not start with ssh://' do
+ context 'when host does not have colons' do
+ it 'parses correctly' do
+ uri = described_class
+ .new('git@qa.test:sandbox/qa/repo.git')
+
+ expect(uri.user).to eq('git')
+ expect(uri.host).to eq('qa.test')
+ expect(uri.port).to eq(22)
+ expect(uri.path).to eq('/sandbox/qa/repo.git')
+ end
+ end
+
+ context 'when host has a colon' do
+ it 'parses correctly' do
+ uri = described_class
+ .new('[git@qa:test]:sandbox/qa/repo.git')
+
+ expect(uri.user).to eq('git')
+ expect(uri.host).to eq('qa%3Atest')
+ expect(uri.port).to eq(22)
+ expect(uri.path).to eq('/sandbox/qa/repo.git')
+ end
+ end
+ end
+ end
+end
diff --git a/qa/spec/page/base_spec.rb b/qa/spec/page/base_spec.rb
new file mode 100644
index 00000000000..52daa9697ee
--- /dev/null
+++ b/qa/spec/page/base_spec.rb
@@ -0,0 +1,62 @@
+describe QA::Page::Base do
+ describe 'page helpers' do
+ it 'exposes helpful page helpers' do
+ expect(subject).to respond_to :refresh, :wait, :scroll_to
+ end
+ end
+
+ describe '.view', 'DSL for defining view partials' do
+ subject do
+ Class.new(described_class) do
+ view 'path/to/some/view.html.haml' do
+ element :something, 'string pattern'
+ element :something_else, /regexp pattern/
+ end
+
+ view 'path/to/some/_partial.html.haml' do
+ element :another_element, 'string pattern'
+ end
+ end
+ end
+
+ it 'makes it possible to define page views' do
+ expect(subject.views.size).to eq 2
+ expect(subject.views).to all(be_an_instance_of QA::Page::View)
+ end
+
+ it 'populates views objects with data about elements' do
+ expect(subject.elements.size).to eq 3
+ expect(subject.elements).to all(be_an_instance_of QA::Page::Element)
+ expect(subject.elements.map(&:name))
+ .to eq [:something, :something_else, :another_element]
+ end
+ end
+
+ describe '.errors' do
+ let(:view) { double('view') }
+
+ context 'when page has views and elements defined' do
+ before do
+ allow(described_class).to receive(:views)
+ .and_return([view])
+
+ allow(view).to receive(:errors).and_return(['some error'])
+ end
+
+ it 'iterates views composite and returns errors' do
+ expect(described_class.errors).to eq ['some error']
+ end
+ end
+
+ context 'when page has no views and elements defined' do
+ before do
+ allow(described_class).to receive(:views).and_return([])
+ end
+
+ it 'appends an error about missing views / elements block' do
+ expect(described_class.errors)
+ .to include 'Page class does not have views / elements defined!'
+ end
+ end
+ end
+end
diff --git a/qa/spec/page/element_spec.rb b/qa/spec/page/element_spec.rb
new file mode 100644
index 00000000000..8598c57ad34
--- /dev/null
+++ b/qa/spec/page/element_spec.rb
@@ -0,0 +1,51 @@
+describe QA::Page::Element do
+ describe '#selector' do
+ it 'transforms element name into QA-specific selector' do
+ expect(described_class.new(:sign_in_button).selector)
+ .to eq 'qa-sign-in-button'
+ end
+ end
+
+ describe '#selector_css' do
+ it 'transforms element name into QA-specific clickable css selector' do
+ expect(described_class.new(:sign_in_button).selector_css)
+ .to eq '.qa-sign-in-button'
+ end
+ end
+
+ context 'when pattern is an expression' do
+ subject { described_class.new(:something, /button 'Sign in'/) }
+
+ it 'matches when there is a match' do
+ expect(subject.matches?("button 'Sign in'")).to be true
+ end
+
+ it 'does not match if pattern is not present' do
+ expect(subject.matches?("button 'Sign out'")).to be false
+ end
+ end
+
+ context 'when pattern is a string' do
+ subject { described_class.new(:something, 'button') }
+
+ it 'matches when there is match' do
+ expect(subject.matches?('some button in the view')).to be true
+ end
+
+ it 'does not match if pattern is not present' do
+ expect(subject.matches?('text_field :name')).to be false
+ end
+ end
+
+ context 'when pattern is not provided' do
+ subject { described_class.new(:some_name) }
+
+ it 'matches when QA specific selector is present' do
+ expect(subject.matches?('some qa-some-name selector')).to be true
+ end
+
+ it 'does not match if QA selector is not there' do
+ expect(subject.matches?('some_name selector')).to be false
+ end
+ end
+end
diff --git a/qa/spec/page/validator_spec.rb b/qa/spec/page/validator_spec.rb
new file mode 100644
index 00000000000..55957649904
--- /dev/null
+++ b/qa/spec/page/validator_spec.rb
@@ -0,0 +1,79 @@
+describe QA::Page::Validator do
+ describe '#constants' do
+ subject do
+ described_class.new(QA::Page::Project)
+ end
+
+ it 'returns all constants that are module children' do
+ expect(subject.constants)
+ .to include QA::Page::Project::New, QA::Page::Project::Settings
+ end
+ end
+
+ describe '#descendants' do
+ subject do
+ described_class.new(QA::Page::Project)
+ end
+
+ it 'recursively returns all descendants that are page objects' do
+ expect(subject.descendants)
+ .to include QA::Page::Project::New, QA::Page::Project::Settings::Repository
+ end
+
+ it 'does not return modules that aggregate page objects' do
+ expect(subject.descendants)
+ .not_to include QA::Page::Project::Settings
+ end
+ end
+
+ context 'when checking validation errors' do
+ let(:view) { spy('view') }
+
+ before do
+ allow(QA::Page::Admin::Settings::Main)
+ .to receive(:views).and_return([view])
+ end
+
+ subject do
+ described_class.new(QA::Page::Admin)
+ end
+
+ context 'when there are no validation errors' do
+ before do
+ allow(view).to receive(:errors).and_return([])
+ end
+
+ describe '#errors' do
+ it 'does not return errors' do
+ expect(subject.errors).to be_empty
+ end
+ end
+
+ describe '#validate!' do
+ it 'does not raise error' do
+ expect { subject.validate! }.not_to raise_error
+ end
+ end
+ end
+
+ context 'when there are validation errors' do
+ before do
+ allow(view).to receive(:errors)
+ .and_return(['some error', 'another error'])
+ end
+
+ describe '#errors' do
+ it 'returns errors' do
+ expect(subject.errors.count).to eq 2
+ end
+ end
+
+ describe '#validate!' do
+ it 'raises validation error' do
+ expect { subject.validate! }
+ .to raise_error described_class::ValidationError
+ end
+ end
+ end
+ end
+end
diff --git a/qa/spec/page/view_spec.rb b/qa/spec/page/view_spec.rb
new file mode 100644
index 00000000000..aedbc3863a7
--- /dev/null
+++ b/qa/spec/page/view_spec.rb
@@ -0,0 +1,70 @@
+describe QA::Page::View do
+ let(:element) do
+ double('element', name: :something, pattern: /some element/)
+ end
+
+ subject { described_class.new('some/file.html', [element]) }
+
+ describe '.evaluate' do
+ it 'evaluates a block and returns a DSL object' do
+ results = described_class.evaluate do
+ element :something, 'my pattern'
+ element :something_else, /another pattern/
+ end
+
+ expect(results.elements.size).to eq 2
+ end
+ end
+
+ describe '#pathname' do
+ it 'returns an absolute and clean path to the view' do
+ expect(subject.pathname.to_s).not_to include 'qa/page/'
+ expect(subject.pathname.to_s).to include 'some/file.html'
+ end
+ end
+
+ describe '#errors' do
+ context 'when view partial is present' do
+ before do
+ allow(subject.pathname).to receive(:readable?)
+ .and_return(true)
+ end
+
+ context 'when pattern is found' do
+ before do
+ allow(File).to receive(:foreach)
+ .and_yield('some element').once
+ allow(element).to receive(:matches?)
+ .with('some element').and_return(true)
+ end
+
+ it 'walks through the view and asserts on elements existence' do
+ expect(subject.errors).to be_empty
+ end
+ end
+
+ context 'when pattern has not been found' do
+ before do
+ allow(File).to receive(:foreach)
+ .and_yield('some element').once
+ allow(element).to receive(:matches?)
+ .with('some element').and_return(false)
+ end
+
+ it 'returns an array of errors related to missing elements' do
+ expect(subject.errors).not_to be_empty
+ expect(subject.errors.first)
+ .to match %r(Missing element `.*` in `.*/some/file.html` view)
+ end
+ end
+ end
+
+ context 'when view partial has not been found' do
+ it 'returns an error when it is not able to find the partial' do
+ expect(subject.errors).to be_one
+ expect(subject.errors.first)
+ .to match %r(Missing view partial `.*/some/file.html`!)
+ end
+ end
+ end
+end
diff --git a/qa/spec/runtime/api_client_spec.rb b/qa/spec/runtime/api_client_spec.rb
new file mode 100644
index 00000000000..d497d8839b8
--- /dev/null
+++ b/qa/spec/runtime/api_client_spec.rb
@@ -0,0 +1,30 @@
+describe QA::Runtime::API::Client do
+ include Support::StubENV
+
+ describe 'initialization' do
+ it 'defaults to :gitlab address' do
+ expect(described_class.new.address).to eq :gitlab
+ end
+
+ it 'uses specified address' do
+ client = described_class.new('http:///example.com')
+
+ expect(client.address).to eq 'http:///example.com'
+ end
+ end
+
+ describe '#get_personal_access_token' do
+ it 'returns specified token from env' do
+ stub_env('PERSONAL_ACCESS_TOKEN', 'a_token')
+
+ expect(described_class.new.get_personal_access_token).to eq 'a_token'
+ end
+
+ it 'returns a created token' do
+ allow_any_instance_of(described_class)
+ .to receive(:create_personal_access_token).and_return('created_token')
+
+ expect(described_class.new.get_personal_access_token).to eq 'created_token'
+ end
+ end
+end
diff --git a/qa/spec/runtime/api_request_spec.rb b/qa/spec/runtime/api_request_spec.rb
new file mode 100644
index 00000000000..9a1ed8a7a46
--- /dev/null
+++ b/qa/spec/runtime/api_request_spec.rb
@@ -0,0 +1,42 @@
+describe QA::Runtime::API::Request do
+ include Support::StubENV
+
+ before do
+ stub_env('PERSONAL_ACCESS_TOKEN', 'a_token')
+ end
+
+ let(:client) { QA::Runtime::API::Client.new('http://example.com') }
+ let(:request) { described_class.new(client, '/users') }
+
+ describe '#url' do
+ it 'returns the full api request url' do
+ expect(request.url).to eq 'http://example.com/api/v4/users?private_token=a_token'
+ end
+ end
+
+ describe '#request_path' do
+ it 'prepends the api path' do
+ expect(request.request_path('/users')).to eq '/api/v4/users'
+ end
+
+ it 'adds the personal access token' do
+ expect(request.request_path('/users', personal_access_token: 'token'))
+ .to eq '/api/v4/users?private_token=token'
+ end
+
+ it 'adds the oauth access token' do
+ expect(request.request_path('/users', oauth_access_token: 'otoken'))
+ .to eq '/api/v4/users?access_token=otoken'
+ end
+
+ it 'respects query parameters' do
+ expect(request.request_path('/users?page=1')).to eq '/api/v4/users?page=1'
+ expect(request.request_path('/users?page=1', personal_access_token: 'token'))
+ .to eq '/api/v4/users?page=1&private_token=token'
+ end
+
+ it 'uses a different api version' do
+ expect(request.request_path('/users', version: 'v3')).to eq '/api/v3/users'
+ end
+ end
+end
diff --git a/qa/spec/runtime/env_spec.rb b/qa/spec/runtime/env_spec.rb
new file mode 100644
index 00000000000..2b6365dbc41
--- /dev/null
+++ b/qa/spec/runtime/env_spec.rb
@@ -0,0 +1,79 @@
+describe QA::Runtime::Env do
+ include Support::StubENV
+
+ describe '.chrome_headless?' do
+ context 'when there is an env variable set' do
+ it 'returns false when falsey values specified' do
+ stub_env('CHROME_HEADLESS', 'false')
+ expect(described_class.chrome_headless?).to be_falsey
+
+ stub_env('CHROME_HEADLESS', 'no')
+ expect(described_class.chrome_headless?).to be_falsey
+
+ stub_env('CHROME_HEADLESS', '0')
+ expect(described_class.chrome_headless?).to be_falsey
+ end
+
+ it 'returns true when anything else specified' do
+ stub_env('CHROME_HEADLESS', 'true')
+ expect(described_class.chrome_headless?).to be_truthy
+
+ stub_env('CHROME_HEADLESS', '1')
+ expect(described_class.chrome_headless?).to be_truthy
+
+ stub_env('CHROME_HEADLESS', 'anything')
+ expect(described_class.chrome_headless?).to be_truthy
+ end
+ end
+
+ context 'when there is no env variable set' do
+ it 'returns the default, true' do
+ stub_env('CHROME_HEADLESS', nil)
+ expect(described_class.chrome_headless?).to be_truthy
+ end
+ end
+ end
+
+ describe '.running_in_ci?' do
+ context 'when there is an env variable set' do
+ it 'returns true if CI' do
+ stub_env('CI', 'anything')
+ expect(described_class.running_in_ci?).to be_truthy
+ end
+
+ it 'returns true if CI_SERVER' do
+ stub_env('CI_SERVER', 'anything')
+ expect(described_class.running_in_ci?).to be_truthy
+ end
+ end
+
+ context 'when there is no env variable set' do
+ it 'returns true' do
+ stub_env('CI', nil)
+ stub_env('CI_SERVER', nil)
+ expect(described_class.running_in_ci?).to be_falsey
+ end
+ end
+ end
+
+ describe '.user_type' do
+ it 'returns standard if not defined' do
+ expect(described_class.user_type).to eq('standard')
+ end
+
+ it 'returns standard as defined' do
+ stub_env('GITLAB_USER_TYPE', 'standard')
+ expect(described_class.user_type).to eq('standard')
+ end
+
+ it 'returns ldap as defined' do
+ stub_env('GITLAB_USER_TYPE', 'ldap')
+ expect(described_class.user_type).to eq('ldap')
+ end
+
+ it 'returns an error if invalid user type' do
+ stub_env('GITLAB_USER_TYPE', 'foobar')
+ expect { described_class.user_type }.to raise_error(ArgumentError)
+ end
+ end
+end
diff --git a/qa/spec/runtime/rsa_key.rb b/qa/spec/runtime/rsa_key.rb
new file mode 100644
index 00000000000..6d7ab4dcd2e
--- /dev/null
+++ b/qa/spec/runtime/rsa_key.rb
@@ -0,0 +1,9 @@
+describe QA::Runtime::RSAKey do
+ describe '#public_key' do
+ subject { described_class.new.public_key }
+
+ it 'generates a public RSA key' do
+ expect(subject).to match(%r{\Assh\-rsa AAAA[0-9A-Za-z+/]+={0,3}\z})
+ end
+ end
+end
diff --git a/qa/spec/scenario/entrypoint_spec.rb b/qa/spec/scenario/entrypoint_spec.rb
deleted file mode 100644
index aec79dcea04..00000000000
--- a/qa/spec/scenario/entrypoint_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-describe QA::Scenario::Entrypoint do
- subject do
- Class.new(QA::Scenario::Entrypoint) do
- tags :rspec
- end
- end
-
- context '#perform' do
- let(:arguments) { spy('Runtime::Scenario') }
- let(:release) { spy('Runtime::Release') }
- let(:runner) { spy('Specs::Runner') }
-
- before do
- stub_const('QA::Runtime::Release', release)
- stub_const('QA::Runtime::Scenario', arguments)
- stub_const('QA::Specs::Runner', runner)
-
- allow(runner).to receive(:perform).and_yield(runner)
- end
-
- it 'sets an address of the subject' do
- subject.perform("hello")
-
- expect(arguments).to have_received(:define)
- .with(:gitlab_address, "hello")
- end
-
- context 'no paths' do
- it 'should call runner with default arguments' do
- subject.perform("test")
-
- expect(runner).to have_received(:files=).with('qa/specs/features')
- end
- end
-
- context 'specifying paths' do
- it 'should call runner with paths' do
- subject.perform('test', 'path1', 'path2')
-
- expect(runner).to have_received(:files=).with(%w[path1 path2])
- end
- end
- end
-end
diff --git a/qa/spec/scenario/test/instance_spec.rb b/qa/spec/scenario/test/instance_spec.rb
new file mode 100644
index 00000000000..a74a9538be8
--- /dev/null
+++ b/qa/spec/scenario/test/instance_spec.rb
@@ -0,0 +1,45 @@
+describe QA::Scenario::Test::Instance do
+ subject do
+ Class.new(described_class) do
+ tags :rspec
+ end
+ end
+
+ context '#perform' do
+ let(:arguments) { spy('Runtime::Scenario') }
+ let(:release) { spy('Runtime::Release') }
+ let(:runner) { spy('Specs::Runner') }
+
+ before do
+ stub_const('QA::Runtime::Release', release)
+ stub_const('QA::Runtime::Scenario', arguments)
+ stub_const('QA::Specs::Runner', runner)
+
+ allow(runner).to receive(:perform).and_yield(runner)
+ end
+
+ it 'sets an address of the subject' do
+ subject.perform("hello")
+
+ expect(arguments).to have_received(:define)
+ .with(:gitlab_address, "hello")
+ end
+
+ context 'no paths' do
+ it 'should call runner with default arguments' do
+ subject.perform("test")
+
+ expect(runner).to have_received(:options=)
+ .with(File.expand_path('../../../qa/specs/features', __dir__))
+ end
+ end
+
+ context 'specifying paths' do
+ it 'should call runner with paths' do
+ subject.perform('test', 'path1', 'path2')
+
+ expect(runner).to have_received(:options=).with(%w[path1 path2])
+ end
+ end
+ end
+end
diff --git a/qa/spec/scenario/test/sanity/selectors_spec.rb b/qa/spec/scenario/test/sanity/selectors_spec.rb
new file mode 100644
index 00000000000..45d21d54955
--- /dev/null
+++ b/qa/spec/scenario/test/sanity/selectors_spec.rb
@@ -0,0 +1,40 @@
+describe QA::Scenario::Test::Sanity::Selectors do
+ let(:validator) { spy('validator') }
+
+ before do
+ stub_const('QA::Page::Validator', validator)
+ end
+
+ context 'when there are errors detected' do
+ before do
+ allow(validator).to receive(:errors).and_return(['some error'])
+ end
+
+ it 'outputs information about errors' do
+ expect { described_class.perform }
+ .to output(/some error/).to_stderr
+
+ expect { described_class.perform }
+ .to output(/electors validation test detected problems/)
+ .to_stderr
+ end
+ end
+
+ context 'when there are no errors detected' do
+ before do
+ allow(validator).to receive(:errors).and_return([])
+ end
+
+ it 'processes pages module' do
+ described_class.perform
+
+ expect(validator).to have_received(:new).with(QA::Page)
+ end
+
+ it 'triggers validation' do
+ described_class.perform
+
+ expect(validator).to have_received(:validate!).at_least(:once)
+ end
+ end
+end
diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb
index 64d06ef6558..c2c6cf95406 100644
--- a/qa/spec/spec_helper.rb
+++ b/qa/spec/spec_helper.rb
@@ -1,5 +1,7 @@
require_relative '../qa'
+Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
+
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
diff --git a/qa/spec/support/stub_env.rb b/qa/spec/support/stub_env.rb
new file mode 100644
index 00000000000..bc8f3a5e22e
--- /dev/null
+++ b/qa/spec/support/stub_env.rb
@@ -0,0 +1,38 @@
+# Inspired by https://github.com/ljkbennett/stub_env/blob/master/lib/stub_env/helpers.rb
+module Support
+ module StubENV
+ 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(:key?).with(key).and_return(true)
+ 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(:key?).and_call_original
+ allow(ENV).to receive(:fetch).and_call_original
+ add_stubbed_value(STUBBED_KEY, true)
+ end
+ end
+end
diff --git a/rubocop/cop/active_record_dependent.rb b/rubocop/cop/active_record_dependent.rb
deleted file mode 100644
index 8d15f150885..00000000000
--- a/rubocop/cop/active_record_dependent.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index 204caf37f8b..00000000000
--- a/rubocop/cop/active_record_serialize.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-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/custom_error_class.rb b/rubocop/cop/custom_error_class.rb
deleted file mode 100644
index 38d93acfe88..00000000000
--- a/rubocop/cop/custom_error_class.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-module RuboCop
- module Cop
- # This cop makes sure that custom error classes, when empty, are declared
- # with Class.new.
- #
- # @example
- # # bad
- # class FooError < StandardError
- # end
- #
- # # okish
- # class FooError < StandardError; end
- #
- # # good
- # FooError = Class.new(StandardError)
- class CustomErrorClass < RuboCop::Cop::Cop
- MSG = 'Use `Class.new(SuperClass)` to define an empty custom error class.'.freeze
-
- def on_class(node)
- _klass, parent, body = node.children
-
- return if body
-
- parent_klass = class_name_from_node(parent)
-
- return unless parent_klass && parent_klass.to_s.end_with?('Error')
-
- add_offense(node, :expression)
- end
-
- def autocorrect(node)
- klass, parent, _body = node.children
- replacement = "#{class_name_from_node(klass)} = Class.new(#{class_name_from_node(parent)})"
-
- lambda do |corrector|
- corrector.replace(node.source_range, replacement)
- end
- end
-
- private
-
- # The nested constant `Foo::Bar::Baz` looks like:
- #
- # s(:const,
- # s(:const,
- # s(:const, nil, :Foo), :Bar), :Baz)
- #
- # So recurse through that to get the name as written in the source.
- #
- def class_name_from_node(node, suffix = nil)
- return unless node&.type == :const
-
- name = node.children[1].to_s
- name = "#{name}::#{suffix}" if suffix
-
- if node.children[0]
- class_name_from_node(node.children[0], name)
- else
- name
- end
- end
- end
- end
-end
diff --git a/rubocop/cop/gem_fetcher.rb b/rubocop/cop/gem_fetcher.rb
deleted file mode 100644
index e157d8e0791..00000000000
--- a/rubocop/cop/gem_fetcher.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-module RuboCop
- module Cop
- # This cop prevents usage of the `git` and `github` arguments to `gem` in a
- # `Gemfile` in order to avoid additional points of failure beyond
- # rubygems.org.
- class GemFetcher < RuboCop::Cop::Cop
- MSG = 'Do not use gems from git repositories, only use gems from RubyGems.'.freeze
-
- GIT_KEYS = [:git, :github].freeze
-
- def on_send(node)
- return unless gemfile?(node)
-
- func_name = node.children[1]
- return unless func_name == :gem
-
- node.children.last.each_node(:pair) do |pair|
- key_name = pair.children[0].children[0].to_sym
- if GIT_KEYS.include?(key_name)
- add_offense(node, pair.source_range, MSG)
- end
- end
- end
-
- private
-
- def gemfile?(node)
- node
- .location
- .expression
- .source_buffer
- .name
- .end_with?("Gemfile")
- end
- end
- end
-end
diff --git a/rubocop/cop/gitlab/has_many_through_scope.rb b/rubocop/cop/gitlab/has_many_through_scope.rb
new file mode 100644
index 00000000000..770a2a0529f
--- /dev/null
+++ b/rubocop/cop/gitlab/has_many_through_scope.rb
@@ -0,0 +1,45 @@
+require 'gitlab/styles/rubocop/model_helpers'
+
+module RuboCop
+ module Cop
+ module Gitlab
+ class HasManyThroughScope < RuboCop::Cop::Cop
+ include ::Gitlab::Styles::Rubocop::ModelHelpers
+
+ MSG = 'Always provide an explicit scope calling auto_include(false) when using has_many :through'.freeze
+
+ def_node_search :through?, <<~PATTERN
+ (pair (sym :through) _)
+ PATTERN
+
+ def_node_matcher :has_many_through?, <<~PATTERN
+ (send nil? :has_many ... #through?)
+ PATTERN
+
+ def_node_search :disables_auto_include?, <<~PATTERN
+ (send _ :auto_include false)
+ PATTERN
+
+ def_node_matcher :scope_disables_auto_include?, <<~PATTERN
+ (block (send nil? :lambda) _ #disables_auto_include?)
+ PATTERN
+
+ def on_send(node)
+ return unless in_model?(node)
+ return unless has_many_through?(node)
+
+ target = node
+ scope_argument = node.children[3]
+
+ if scope_argument.children[0].children.last == :lambda
+ return if scope_disables_auto_include?(scope_argument)
+
+ target = scope_argument
+ end
+
+ add_offense(target, location: :expression)
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/gitlab/httparty.rb b/rubocop/cop/gitlab/httparty.rb
new file mode 100644
index 00000000000..215f18b6993
--- /dev/null
+++ b/rubocop/cop/gitlab/httparty.rb
@@ -0,0 +1,62 @@
+require_relative '../../spec_helpers'
+
+module RuboCop
+ module Cop
+ module Gitlab
+ class HTTParty < RuboCop::Cop::Cop
+ include SpecHelpers
+
+ MSG_SEND = <<~EOL.freeze
+ Avoid calling `HTTParty` directly. Instead, use the Gitlab::HTTP
+ wrapper. To allow request to localhost or the private network set
+ the option :allow_local_requests in the request call.
+ EOL
+
+ MSG_INCLUDE = <<~EOL.freeze
+ Avoid including `HTTParty` directly. Instead, use the Gitlab::HTTP
+ wrapper. To allow request to localhost or the private network set
+ the option :allow_local_requests in the request call.
+ EOL
+
+ def_node_matcher :includes_httparty?, <<~PATTERN
+ (send nil? :include (const nil? :HTTParty))
+ PATTERN
+
+ def_node_matcher :httparty_node?, <<~PATTERN
+ (send (const nil? :HTTParty)...)
+ PATTERN
+
+ def on_send(node)
+ return if in_spec?(node)
+
+ add_offense(node, location: :expression, message: MSG_SEND) if httparty_node?(node)
+ add_offense(node, location: :expression, message: MSG_INCLUDE) if includes_httparty?(node)
+ end
+
+ def autocorrect(node)
+ if includes_httparty?(node)
+ autocorrect_includes_httparty(node)
+ else
+ autocorrect_httparty_node(node)
+ end
+ end
+
+ def autocorrect_includes_httparty(node)
+ lambda do |corrector|
+ corrector.remove(node.source_range)
+ end
+ end
+
+ def autocorrect_httparty_node(node)
+ _, method_name, *arg_nodes = *node
+
+ replacement = "Gitlab::HTTP.#{method_name}(#{arg_nodes.map(&:source).join(', ')})"
+
+ lambda do |corrector|
+ corrector.replace(node.source_range, replacement)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/gitlab/module_with_instance_variables.rb b/rubocop/cop/gitlab/module_with_instance_variables.rb
index 5c9cde98512..dd8bd2dfdf0 100644
--- a/rubocop/cop/gitlab/module_with_instance_variables.rb
+++ b/rubocop/cop/gitlab/module_with_instance_variables.rb
@@ -30,12 +30,12 @@ module RuboCop
if only_ivar_or_assignment?(definition)
# We don't allow if any other ivar is used
definition.each_descendant(:ivar) do |offense|
- add_offense(offense, :expression)
+ add_offense(offense, location: :expression)
end
# We allow initialize method and single ivar
elsif !initialize_method?(definition) && !single_ivar?(definition)
definition.each_descendant(:ivar, :ivasgn) do |offense|
- add_offense(offense, :expression)
+ add_offense(offense, location: :expression)
end
end
end
diff --git a/rubocop/cop/gitlab/predicate_memoization.rb b/rubocop/cop/gitlab/predicate_memoization.rb
new file mode 100644
index 00000000000..3c25d61d087
--- /dev/null
+++ b/rubocop/cop/gitlab/predicate_memoization.rb
@@ -0,0 +1,39 @@
+module RuboCop
+ module Cop
+ module Gitlab
+ class PredicateMemoization < RuboCop::Cop::Cop
+ MSG = <<~EOL.freeze
+ Avoid using `@value ||= query` inside predicate methods in order to
+ properly memoize `false` or `nil` values.
+ https://docs.gitlab.com/ee/development/utilities.html#strongmemoize
+ EOL
+
+ def on_def(node)
+ return unless predicate_method?(node)
+
+ select_offenses(node).each do |offense|
+ add_offense(offense, location: :expression)
+ end
+ end
+
+ private
+
+ def predicate_method?(node)
+ node.method_name.to_s.end_with?('?')
+ end
+
+ def or_ivar_assignment?(or_assignment)
+ lhs = or_assignment.each_child_node.first
+
+ lhs.ivasgn_type?
+ end
+
+ def select_offenses(node)
+ node.each_descendant(:or_asgn).select do |or_assignment|
+ or_ivar_assignment?(or_assignment)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/in_batches.rb b/rubocop/cop/in_batches.rb
deleted file mode 100644
index c0240187e66..00000000000
--- a/rubocop/cop/in_batches.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-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/include_sidekiq_worker.rb b/rubocop/cop/include_sidekiq_worker.rb
index 4a6332286a2..8da4a147219 100644
--- a/rubocop/cop/include_sidekiq_worker.rb
+++ b/rubocop/cop/include_sidekiq_worker.rb
@@ -9,14 +9,14 @@ module RuboCop
MSG = 'Include `ApplicationWorker`, not `Sidekiq::Worker`.'.freeze
def_node_matcher :includes_sidekiq_worker?, <<~PATTERN
- (send nil :include (const (const nil :Sidekiq) :Worker))
+ (send nil? :include (const (const nil? :Sidekiq) :Worker))
PATTERN
def on_send(node)
return if in_spec?(node)
return unless includes_sidekiq_worker?(node)
- add_offense(node.arguments.first, :expression)
+ add_offense(node.arguments.first, location: :expression)
end
def autocorrect(node)
diff --git a/rubocop/cop/line_break_after_guard_clauses.rb b/rubocop/cop/line_break_after_guard_clauses.rb
deleted file mode 100644
index 67477f064ab..00000000000
--- a/rubocop/cop/line_break_after_guard_clauses.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-# frozen_string_literal: true
-
-module RuboCop
- module Cop
- # Ensures a line break after guard clauses.
- #
- # @example
- # # bad
- # return unless condition
- # do_stuff
- #
- # # good
- # return unless condition
- #
- # do_stuff
- #
- # # bad
- # raise if condition
- # do_stuff
- #
- # # good
- # raise if condition
- #
- # do_stuff
- #
- # Multiple guard clauses are allowed without
- # line break.
- #
- # # good
- # return unless condition_a
- # return unless condition_b
- #
- # do_stuff
- #
- # Guard clauses in case statement are allowed without
- # line break.
- #
- # # good
- # case model
- # when condition_a
- # return true unless condition_b
- # when
- # ...
- # end
- #
- # Guard clauses before end are allowed without
- # line break.
- #
- # # good
- # if condition_a
- # do_something
- # else
- # do_something_else
- # return unless condition
- # end
- #
- # do_something_more
- class LineBreakAfterGuardClauses < RuboCop::Cop::Cop
- MSG = 'Add a line break after guard clauses'
-
- def_node_matcher :guard_clause_node?, <<-PATTERN
- [{(send nil? {:raise :fail :throw} ...) return break next} single_line?]
- PATTERN
-
- def on_if(node)
- return unless node.single_line?
- return unless guard_clause?(node)
- return if next_line(node).blank? || clause_last_line?(next_line(node)) || guard_clause?(next_sibling(node))
-
- add_offense(node, :expression, MSG)
- end
-
- def autocorrect(node)
- lambda do |corrector|
- corrector.insert_after(node.loc.expression, "\n")
- end
- end
-
- private
-
- def guard_clause?(node)
- return false unless node.if_type?
-
- guard_clause_node?(node.if_branch)
- end
-
- def next_sibling(node)
- node.parent.children[node.sibling_index + 1]
- end
-
- def next_line(node)
- processed_source[node.loc.line]
- end
-
- def clause_last_line?(line)
- line =~ /^\s*(?:end|elsif|else|when|rescue|ensure)/
- end
- end
- end
-end
diff --git a/rubocop/cop/line_break_around_conditional_block.rb b/rubocop/cop/line_break_around_conditional_block.rb
new file mode 100644
index 00000000000..3e7021e724e
--- /dev/null
+++ b/rubocop/cop/line_break_around_conditional_block.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+module RuboCop
+ module Cop
+ # Ensures a line break around conditional blocks.
+ #
+ # @example
+ # # bad
+ # do_something
+ # if condition
+ # do_extra_stuff
+ # end
+ # do_something_more
+ #
+ # # good
+ # do_something
+ #
+ # if condition
+ # do_extra_stuff
+ # end
+ #
+ # do_something_more
+ #
+ # # bad
+ # do_something
+ # unless condition
+ # do_extra_stuff
+ # end
+ #
+ # do_something_more
+ #
+ # # good
+ # def a_method
+ # if condition
+ # do_something
+ # end
+ # end
+ #
+ # # good
+ # on_block do
+ # if condition
+ # do_something
+ # end
+ # end
+ class LineBreakAroundConditionalBlock < RuboCop::Cop::Cop
+ MSG = 'Add a line break around conditional blocks'
+
+ def on_if(node)
+ return if node.single_line?
+ return unless node.if? || node.unless?
+
+ add_offense(node, location: :expression, message: MSG) unless previous_line_valid?(node)
+ add_offense(node, location: :expression, message: MSG) unless last_line_valid?(node)
+ end
+
+ def autocorrect(node)
+ lambda do |corrector|
+ line = range_by_whole_lines(node.source_range)
+ unless previous_line_valid?(node)
+ corrector.insert_before(line, "\n")
+ end
+
+ unless last_line_valid?(node)
+ corrector.insert_after(line, "\n")
+ end
+ end
+ end
+
+ private
+
+ def previous_line_valid?(node)
+ previous_line(node).empty? ||
+ start_clause_line?(previous_line(node)) ||
+ block_start?(previous_line(node)) ||
+ begin_line?(previous_line(node)) ||
+ assignment_line?(previous_line(node))
+ end
+
+ def last_line_valid?(node)
+ last_line(node).empty? ||
+ end_line?(last_line(node)) ||
+ end_clause_line?(last_line(node))
+ end
+
+ def previous_line(node)
+ processed_source[node.loc.line - 2]
+ end
+
+ def last_line(node)
+ processed_source[node.loc.last_line]
+ end
+
+ def start_clause_line?(line)
+ line =~ /^\s*(def|=end|#|module|class|if|unless|else|elsif|ensure|when)/
+ end
+
+ def end_clause_line?(line)
+ line =~ /^\s*(rescue|else|elsif|when)/
+ end
+
+ def begin_line?(line)
+ # an assignment followed by a begin or ust a begin
+ line =~ /^\s*(@?(\w|\|+|=|\[|\]|\s)+begin|begin)/
+ end
+
+ def assignment_line?(line)
+ line =~ /^\s*.*=/
+ end
+
+ def block_start?(line)
+ line.match(/ (do|{)( \|.*?\|)?\s?$/)
+ end
+
+ def end_line?(line)
+ line =~ /^\s*(end|})/
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/migration/add_column.rb b/rubocop/cop/migration/add_column.rb
index d2cf36c454a..2530d6477e8 100644
--- a/rubocop/cop/migration/add_column.rb
+++ b/rubocop/cop/migration/add_column.rb
@@ -29,7 +29,7 @@ module RuboCop
opts.each_node(:pair) do |pair|
if hash_key_type(pair) == :sym && hash_key_name(pair) == :default
- add_offense(node, :selector)
+ add_offense(node, location: :selector)
end
end
end
diff --git a/rubocop/cop/migration/add_concurrent_foreign_key.rb b/rubocop/cop/migration/add_concurrent_foreign_key.rb
index d1fc94d55be..d78c7b9b043 100644
--- a/rubocop/cop/migration/add_concurrent_foreign_key.rb
+++ b/rubocop/cop/migration/add_concurrent_foreign_key.rb
@@ -15,7 +15,7 @@ module RuboCop
name = node.children[1]
- add_offense(node, :selector) if name == :add_foreign_key
+ add_offense(node, location: :selector) if name == :add_foreign_key
end
def method_name(node)
diff --git a/rubocop/cop/migration/add_concurrent_index.rb b/rubocop/cop/migration/add_concurrent_index.rb
index 69852f4d580..a2e4ac72565 100644
--- a/rubocop/cop/migration/add_concurrent_index.rb
+++ b/rubocop/cop/migration/add_concurrent_index.rb
@@ -21,7 +21,7 @@ module RuboCop
node.each_ancestor(:def) do |def_node|
next unless method_name(def_node) == :change
- add_offense(def_node, :name)
+ add_offense(def_node, location: :name)
end
end
diff --git a/rubocop/cop/migration/add_index.rb b/rubocop/cop/migration/add_index.rb
index fa21a0d6555..4aea3c0cce3 100644
--- a/rubocop/cop/migration/add_index.rb
+++ b/rubocop/cop/migration/add_index.rb
@@ -27,7 +27,7 @@ module RuboCop
# data in these tables yet.
next if new_tables.include?(first_arg)
- add_offense(send_node, :selector)
+ add_offense(send_node, location: :selector)
end
end
diff --git a/rubocop/cop/migration/add_timestamps.rb b/rubocop/cop/migration/add_timestamps.rb
index 08ddd91e54d..ba32d6a9960 100644
--- a/rubocop/cop/migration/add_timestamps.rb
+++ b/rubocop/cop/migration/add_timestamps.rb
@@ -13,7 +13,7 @@ module RuboCop
def on_send(node)
return unless in_migration?(node)
- add_offense(node, :selector) if method_name(node) == :add_timestamps
+ add_offense(node, location: :selector) if method_name(node) == :add_timestamps
end
def method_name(node)
diff --git a/rubocop/cop/migration/datetime.rb b/rubocop/cop/migration/datetime.rb
index 9cba3c35b26..03ad3f3f601 100644
--- a/rubocop/cop/migration/datetime.rb
+++ b/rubocop/cop/migration/datetime.rb
@@ -17,7 +17,7 @@ module RuboCop
method_name = node.children[1]
if method_name == :datetime || method_name == :timestamp
- add_offense(send_node, :selector, format(MSG, method_name))
+ add_offense(send_node, location: :selector, message: format(MSG, method_name))
end
end
end
@@ -32,7 +32,7 @@ module RuboCop
last_argument = descendant.children.last
if last_argument == :datetime || last_argument == :timestamp
- add_offense(node, :expression, format(MSG, last_argument))
+ add_offense(node, location: :expression, message: format(MSG, last_argument))
end
end
end
diff --git a/rubocop/cop/migration/hash_index.rb b/rubocop/cop/migration/hash_index.rb
index 2cc59691d84..3206b73bd3d 100644
--- a/rubocop/cop/migration/hash_index.rb
+++ b/rubocop/cop/migration/hash_index.rb
@@ -29,7 +29,7 @@ module RuboCop
hash_key_name(pair) == :using
if hash_key_value(pair).to_s == 'hash'
- add_offense(pair, :expression)
+ add_offense(pair, location: :expression)
end
end
end
diff --git a/rubocop/cop/migration/remove_column.rb b/rubocop/cop/migration/remove_column.rb
index e53eb2e07b2..fffb4ab7fab 100644
--- a/rubocop/cop/migration/remove_column.rb
+++ b/rubocop/cop/migration/remove_column.rb
@@ -20,7 +20,7 @@ module RuboCop
send_method = send_node.children[1]
if send_method == :remove_column
- add_offense(send_node, :selector)
+ add_offense(send_node, location: :selector)
end
end
end
diff --git a/rubocop/cop/migration/remove_concurrent_index.rb b/rubocop/cop/migration/remove_concurrent_index.rb
index 268c49865cb..2328740cf36 100644
--- a/rubocop/cop/migration/remove_concurrent_index.rb
+++ b/rubocop/cop/migration/remove_concurrent_index.rb
@@ -16,7 +16,7 @@ module RuboCop
return unless node.children[1] == :remove_concurrent_index
node.each_ancestor(:def) do |def_node|
- add_offense(def_node, :name) if method_name(def_node) == :change
+ add_offense(def_node, location: :name) if method_name(def_node) == :change
end
end
diff --git a/rubocop/cop/migration/remove_index.rb b/rubocop/cop/migration/remove_index.rb
index 613b35dd00d..4df3b1ba756 100644
--- a/rubocop/cop/migration/remove_index.rb
+++ b/rubocop/cop/migration/remove_index.rb
@@ -13,7 +13,7 @@ module RuboCop
return unless in_migration?(node)
node.each_descendant(:send) do |send_node|
- add_offense(send_node, :selector) if method_name(send_node) == :remove_index
+ add_offense(send_node, location: :selector) if method_name(send_node) == :remove_index
end
end
diff --git a/rubocop/cop/migration/reversible_add_column_with_default.rb b/rubocop/cop/migration/reversible_add_column_with_default.rb
index f413f06f39b..dd49188defa 100644
--- a/rubocop/cop/migration/reversible_add_column_with_default.rb
+++ b/rubocop/cop/migration/reversible_add_column_with_default.rb
@@ -9,7 +9,7 @@ module RuboCop
include MigrationHelpers
def_node_matcher :add_column_with_default?, <<~PATTERN
- (send nil :add_column_with_default $...)
+ (send nil? :add_column_with_default $...)
PATTERN
def_node_matcher :defines_change?, <<~PATTERN
@@ -26,7 +26,7 @@ module RuboCop
node.each_ancestor(:def) do |def_node|
next unless defines_change?(def_node)
- add_offense(def_node, :name)
+ add_offense(def_node, location: :name)
end
end
end
diff --git a/rubocop/cop/migration/safer_boolean_column.rb b/rubocop/cop/migration/safer_boolean_column.rb
index 0335c25d85d..dc5c55df6fb 100644
--- a/rubocop/cop/migration/safer_boolean_column.rb
+++ b/rubocop/cop/migration/safer_boolean_column.rb
@@ -28,7 +28,7 @@ module RuboCop
].freeze
def_node_matcher :add_column?, <<~PATTERN
- (send nil :add_column $...)
+ (send nil? :add_column $...)
PATTERN
def on_send(node)
@@ -54,7 +54,7 @@ module RuboCop
NULL_OFFENSE
end
- add_offense(node, :expression, format(offense, table)) if offense
+ add_offense(node, location: :expression, message: format(offense, table)) if offense
end
def no_default?(opts)
diff --git a/rubocop/cop/migration/timestamps.rb b/rubocop/cop/migration/timestamps.rb
index 71a9420cc3b..6cf5648b996 100644
--- a/rubocop/cop/migration/timestamps.rb
+++ b/rubocop/cop/migration/timestamps.rb
@@ -14,7 +14,7 @@ module RuboCop
return unless in_migration?(node)
node.each_descendant(:send) do |send_node|
- add_offense(send_node, :selector) if method_name(send_node) == :timestamps
+ add_offense(send_node, location: :selector) if method_name(send_node) == :timestamps
end
end
diff --git a/rubocop/cop/migration/update_column_in_batches.rb b/rubocop/cop/migration/update_column_in_batches.rb
index 3f886cbfea3..5461abc5ee0 100644
--- a/rubocop/cop/migration/update_column_in_batches.rb
+++ b/rubocop/cop/migration/update_column_in_batches.rb
@@ -18,7 +18,7 @@ module RuboCop
spec_path = spec_filename(node)
unless File.exist?(File.expand_path(spec_path, rails_root))
- add_offense(node, :expression, format(MSG, spec_path))
+ add_offense(node, location: :expression, message: format(MSG, spec_path))
end
end
@@ -29,7 +29,7 @@ module RuboCop
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+_}, '')
+ filename = File.basename(source_name, '.rb').sub(/\A\d+_/, '')
File.join(dirname, "#{filename}_spec.rb")
end
diff --git a/rubocop/cop/migration/update_large_table.rb b/rubocop/cop/migration/update_large_table.rb
index 3ae3fb1b68e..bb14d0f4f56 100644
--- a/rubocop/cop/migration/update_large_table.rb
+++ b/rubocop/cop/migration/update_large_table.rb
@@ -35,7 +35,7 @@ module RuboCop
].freeze
def_node_matcher :batch_update?, <<~PATTERN
- (send nil ${:add_column_with_default :update_column_in_batches} $(sym ...) ...)
+ (send nil? ${:add_column_with_default :update_column_in_batches} $(sym ...) ...)
PATTERN
def on_send(node)
@@ -49,7 +49,7 @@ module RuboCop
return unless LARGE_TABLES.include?(table)
- add_offense(node, :expression, format(MSG, update_method, table))
+ add_offense(node, location: :expression, message: format(MSG, update_method, table))
end
end
end
diff --git a/rubocop/cop/polymorphic_associations.rb b/rubocop/cop/polymorphic_associations.rb
deleted file mode 100644
index 7d554704550..00000000000
--- a/rubocop/cop/polymorphic_associations.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-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
index 3e1ce71ac06..f3810622eb1 100644
--- a/rubocop/cop/project_path_helper.rb
+++ b/rubocop/cop/project_path_helper.rb
@@ -17,7 +17,7 @@ module RuboCop
return unless method_name(namespace_expr) == :namespace
return unless receiver(namespace_expr) == project_expr
- add_offense(node, :selector)
+ add_offense(node, location: :selector)
end
def autocorrect(node)
diff --git a/rubocop/cop/redirect_with_status.rb b/rubocop/cop/redirect_with_status.rb
deleted file mode 100644
index 36810642c88..00000000000
--- a/rubocop/cop/redirect_with_status.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-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/env_assignment.rb b/rubocop/cop/rspec/env_assignment.rb
index 257454af0e1..8b61fa8e264 100644
--- a/rubocop/cop/rspec/env_assignment.rb
+++ b/rubocop/cop/rspec/env_assignment.rb
@@ -1,4 +1,3 @@
-require 'rubocop-rspec'
require_relative '../../spec_helpers'
module RuboCop
@@ -17,7 +16,7 @@ module RuboCop
# before do
# stub_env('FOO', 'bar')
# end
- class EnvAssignment < Cop
+ class EnvAssignment < RuboCop::Cop::Cop
include SpecHelpers
MESSAGE = "Don't assign to ENV, use `stub_env` instead.".freeze
@@ -32,7 +31,7 @@ module RuboCop
return unless in_spec?(node)
return unless env_assignment?(node)
- add_offense(node, :expression, MESSAGE)
+ add_offense(node, location: :expression, message: MESSAGE)
end
def autocorrect(node)
diff --git a/rubocop/cop/rspec/factories_in_migration_specs.rb b/rubocop/cop/rspec/factories_in_migration_specs.rb
new file mode 100644
index 00000000000..0c5aa838a2c
--- /dev/null
+++ b/rubocop/cop/rspec/factories_in_migration_specs.rb
@@ -0,0 +1,40 @@
+require_relative '../../spec_helpers'
+
+module RuboCop
+ module Cop
+ module RSpec
+ # This cop checks for the usage of factories in migration specs
+ #
+ # @example
+ #
+ # # bad
+ # let(:user) { create(:user) }
+ #
+ # # good
+ # let(:users) { table(:users) }
+ # let(:user) { users.create!(name: 'User 1', username: 'user1') }
+ class FactoriesInMigrationSpecs < RuboCop::Cop::Cop
+ include SpecHelpers
+
+ MESSAGE = "Don't use FactoryBot.%s in migration specs, use `table` instead.".freeze
+ FORBIDDEN_METHODS = %i[build build_list create create_list].freeze
+
+ def_node_search :forbidden_factory_usage?, <<~PATTERN
+ (send {(const nil? :FactoryBot) nil?} {#{FORBIDDEN_METHODS.map(&:inspect).join(' ')}} ...)
+ PATTERN
+
+ # Following is what node.children looks like on a match:
+ # - Without FactoryBot namespace: [nil, :build, s(:sym, :user)]
+ # - With FactoryBot namespace: [s(:const, nil, :FactoryBot), :build, s(:sym, :user)]
+ def on_send(node)
+ return unless in_migration_spec?(node)
+ return unless forbidden_factory_usage?(node)
+
+ method = node.children[1]
+
+ add_offense(node, location: :expression, message: MESSAGE % method)
+ end
+ end
+ end
+ end
+end
diff --git a/rubocop/cop/rspec/single_line_hook.rb b/rubocop/cop/rspec/single_line_hook.rb
deleted file mode 100644
index be611054323..00000000000
--- a/rubocop/cop/rspec/single_line_hook.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-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/cop/rspec/verbose_include_metadata.rb b/rubocop/cop/rspec/verbose_include_metadata.rb
deleted file mode 100644
index 58390622d60..00000000000
--- a/rubocop/cop/rspec/verbose_include_metadata.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-# frozen_string_literal: true
-
-require 'rubocop-rspec'
-
-module RuboCop
- module Cop
- module RSpec
- # Checks for verbose include metadata used in the specs.
- #
- # @example
- # # bad
- # describe MyClass, js: true do
- # end
- #
- # # good
- # describe MyClass, :js do
- # end
- class VerboseIncludeMetadata < Cop
- MSG = 'Use `%s` instead of `%s`.'
-
- SELECTORS = %i[describe context feature example_group it specify example scenario its].freeze
-
- def_node_matcher :include_metadata, <<-PATTERN
- (send {(const nil :RSpec) nil} {#{SELECTORS.map(&:inspect).join(' ')}}
- !const
- ...
- (hash $...))
- PATTERN
-
- def_node_matcher :invalid_metadata?, <<-PATTERN
- (pair
- (sym $...)
- (true))
- PATTERN
-
- def on_send(node)
- invalid_metadata_matches(node) do |match|
- add_offense(node, :expression, format(MSG, good(match), bad(match)))
- end
- end
-
- def autocorrect(node)
- lambda do |corrector|
- invalid_metadata_matches(node) do |match|
- corrector.replace(match.loc.expression, good(match))
- end
- end
- end
-
- private
-
- def invalid_metadata_matches(node)
- include_metadata(node) do |matches|
- matches.select(&method(:invalid_metadata?)).each do |match|
- yield match
- end
- end
- end
-
- def bad(match)
- "#{metadata_key(match)}: true"
- end
-
- def good(match)
- ":#{metadata_key(match)}"
- end
-
- def metadata_key(match)
- match.children[0].source
- end
- end
- end
- end
-end
diff --git a/rubocop/cop/sidekiq_options_queue.rb b/rubocop/cop/sidekiq_options_queue.rb
index 43b35ba0214..253d2958866 100644
--- a/rubocop/cop/sidekiq_options_queue.rb
+++ b/rubocop/cop/sidekiq_options_queue.rb
@@ -9,7 +9,7 @@ module RuboCop
MSG = 'Do not manually set a queue; `ApplicationWorker` sets one automatically.'.freeze
def_node_matcher :sidekiq_options?, <<~PATTERN
- (send nil :sidekiq_options $...)
+ (send nil? :sidekiq_options $...)
PATTERN
def on_send(node)
@@ -19,7 +19,7 @@ module RuboCop
node.arguments.first.each_node(:pair) do |pair|
key_name = pair.key.children[0]
- add_offense(pair, :expression) if key_name == :queue
+ add_offense(pair, location: :expression) if key_name == :queue
end
end
end
diff --git a/rubocop/model_helpers.rb b/rubocop/model_helpers.rb
deleted file mode 100644
index 309723dc34c..00000000000
--- a/rubocop/model_helpers.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-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 8aa82e9413d..c2254332e7d 100644
--- a/rubocop/rubocop.rb
+++ b/rubocop/rubocop.rb
@@ -1,15 +1,10 @@
-require_relative 'cop/active_record_dependent'
-require_relative 'cop/active_record_serialize'
-require_relative 'cop/custom_error_class'
-require_relative 'cop/gem_fetcher'
-require_relative 'cop/in_batches'
-require_relative 'cop/include_sidekiq_worker'
-require_relative 'cop/line_break_after_guard_clauses'
-require_relative 'cop/polymorphic_associations'
-require_relative 'cop/project_path_helper'
-require_relative 'cop/redirect_with_status'
+# rubocop:disable Naming/FileName
+require_relative 'cop/gitlab/has_many_through_scope'
+require_relative 'cop/gitlab/httparty'
require_relative 'cop/gitlab/module_with_instance_variables'
-require_relative 'cop/sidekiq_options_queue'
+require_relative 'cop/gitlab/predicate_memoization'
+require_relative 'cop/include_sidekiq_worker'
+require_relative 'cop/line_break_around_conditional_block'
require_relative 'cop/migration/add_column'
require_relative 'cop/migration/add_concurrent_foreign_key'
require_relative 'cop/migration/add_concurrent_index'
@@ -25,6 +20,7 @@ require_relative 'cop/migration/safer_boolean_column'
require_relative 'cop/migration/timestamps'
require_relative 'cop/migration/update_column_in_batches'
require_relative 'cop/migration/update_large_table'
+require_relative 'cop/project_path_helper'
require_relative 'cop/rspec/env_assignment'
-require_relative 'cop/rspec/single_line_hook'
-require_relative 'cop/rspec/verbose_include_metadata'
+require_relative 'cop/rspec/factories_in_migration_specs'
+require_relative 'cop/sidekiq_options_queue'
diff --git a/rubocop/spec_helpers.rb b/rubocop/spec_helpers.rb
index a702a083958..6c0f0193b1a 100644
--- a/rubocop/spec_helpers.rb
+++ b/rubocop/spec_helpers.rb
@@ -6,7 +6,18 @@ module RuboCop
def in_spec?(node)
path = node.location.expression.source_buffer.name
- !SPEC_HELPERS.include?(File.basename(path)) && path.start_with?(File.join(Dir.pwd, 'spec'))
+ !SPEC_HELPERS.include?(File.basename(path)) &&
+ path.start_with?(File.join(Dir.pwd, 'spec'), File.join(Dir.pwd, 'ee', 'spec'))
+ end
+
+ # Returns true if the given node originated from a migration spec.
+ def in_migration_spec?(node)
+ path = node.location.expression.source_buffer.name
+
+ in_spec?(node) &&
+ path.start_with?(
+ File.join(Dir.pwd, 'spec', 'migrations'),
+ File.join(Dir.pwd, 'ee', 'spec', 'migrations'))
end
end
end
diff --git a/scripts/create_mysql_user.sh b/scripts/create_mysql_user.sh
index 28f6cfb50ae..286b1325f1d 100644
--- a/scripts/create_mysql_user.sh
+++ b/scripts/create_mysql_user.sh
@@ -1,7 +1,7 @@
#!/bin/bash
mysql --user=root --host=mysql <<EOF
-CREATE DATABASE IF NOT EXISTS gitlabhq_test;
+CREATE DATABASE IF NOT EXISTS gitlabhq_test DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER IF NOT EXISTS 'gitlab'@'%';
GRANT ALL PRIVILEGES ON gitlabhq_test.* TO 'gitlab'@'%';
FLUSH PRIVILEGES;
diff --git a/scripts/frontend/frontend_script_utils.js b/scripts/frontend/frontend_script_utils.js
new file mode 100644
index 00000000000..e42b912d359
--- /dev/null
+++ b/scripts/frontend/frontend_script_utils.js
@@ -0,0 +1,24 @@
+const execFileSync = require('child_process').execFileSync;
+
+const exec = (command, args) => {
+ const options = {
+ cwd: process.cwd(),
+ env: process.env,
+ encoding: 'utf-8',
+ };
+ return execFileSync(command, args, options);
+};
+
+const execGitCmd = args =>
+ exec('git', args)
+ .trim()
+ .toString()
+ .split('\n');
+
+module.exports = {
+ getStagedFiles: fileExtensionFilter => {
+ const gitOptions = ['diff', '--name-only', '--cached', '--diff-filter=ACMRTUB'];
+ if (fileExtensionFilter) gitOptions.push(...fileExtensionFilter);
+ return execGitCmd(gitOptions);
+ },
+};
diff --git a/scripts/frontend/prettier.js b/scripts/frontend/prettier.js
new file mode 100644
index 00000000000..39de77bc333
--- /dev/null
+++ b/scripts/frontend/prettier.js
@@ -0,0 +1,117 @@
+const glob = require('glob');
+const prettier = require('prettier');
+const fs = require('fs');
+const path = require('path');
+const prettierIgnore = require('ignore')();
+
+const getStagedFiles = require('./frontend_script_utils').getStagedFiles;
+
+const mode = process.argv[2] || 'check';
+const shouldSave = mode === 'save' || mode === 'save-all';
+const allFiles = mode === 'check-all' || mode === 'save-all';
+
+const config = {
+ patterns: ['**/*.js', '**/*.vue', '**/*.scss'],
+ /*
+ * The ignore patterns below are just to reduce search time with glob, as it includes the
+ * folders with the most ignored assets, the actual `.prettierignore` will be used later on
+ */
+ ignore: ['**/node_modules/**', '**/vendor/**', '**/public/**'],
+ parsers: {
+ js: 'babylon',
+ vue: 'vue',
+ scss: 'css',
+ },
+};
+
+/*
+ * Unfortunately the prettier API does not expose support for `.prettierignore` files, they however
+ * use the ignore package, so we do the same. We simply cannot use the glob package, because
+ * gitignore style is not compatible with globs ignore style.
+ */
+prettierIgnore.add(
+ fs
+ .readFileSync(path.join(__dirname, '../../', '.prettierignore'))
+ .toString()
+ .trim()
+ .split(/\r?\n/)
+);
+
+const availableExtensions = Object.keys(config.parsers);
+
+console.log(`Loading ${allFiles ? 'All' : 'Staged'} Files ...`);
+
+const stagedFiles = allFiles ? null : getStagedFiles(availableExtensions.map(ext => `*.${ext}`));
+
+if (stagedFiles) {
+ if (!stagedFiles.length || (stagedFiles.length === 1 && !stagedFiles[0])) {
+ console.log('No matching staged files.');
+ return;
+ }
+ console.log(`Matching staged Files : ${stagedFiles.length}`);
+}
+
+let didWarn = false;
+let didError = false;
+
+let files;
+if (allFiles) {
+ const ignore = config.ignore;
+ const patterns = config.patterns;
+ const globPattern = patterns.length > 1 ? `{${patterns.join(',')}}` : `${patterns.join(',')}`;
+ files = glob.sync(globPattern, { ignore }).filter(f => allFiles || stagedFiles.includes(f));
+} else {
+ files = stagedFiles.filter(f => availableExtensions.includes(f.split('.').pop()));
+}
+
+files = prettierIgnore.filter(files);
+
+if (!files.length) {
+ console.log('No Files found to process with Prettier');
+ return;
+}
+
+console.log(`${shouldSave ? 'Updating' : 'Checking'} ${files.length} file(s)`);
+
+prettier
+ .resolveConfig('.')
+ .then(options => {
+ console.log('Found options : ', options);
+ files.forEach(file => {
+ try {
+ const fileExtension = file.split('.').pop();
+ Object.assign(options, {
+ parser: config.parsers[fileExtension],
+ });
+
+ const input = fs.readFileSync(file, 'utf8');
+
+ if (shouldSave) {
+ const output = prettier.format(input, options);
+ if (output !== input) {
+ fs.writeFileSync(file, output, 'utf8');
+ console.log(`Prettified : ${file}`);
+ }
+ } else if (!prettier.check(input, options)) {
+ if (!didWarn) {
+ console.log(
+ '\n===============================\nGitLab uses Prettier to format all JavaScript code.\nPlease format each file listed below or run "yarn prettier-staged-save"\n===============================\n'
+ );
+ didWarn = true;
+ }
+ console.log(`Prettify Manually : ${file}`);
+ }
+ } catch (error) {
+ didError = true;
+ console.log(`\n\nError with ${file}: ${error.message}`);
+ }
+ });
+
+ if (didWarn || didError) {
+ process.exit(1);
+ }
+ })
+ .catch(e => {
+ console.log(`Error on loading the Config File: ${e.message}`);
+ process.exit(1);
+ });
diff --git a/scripts/gitaly-test-build b/scripts/gitaly-test-build
index 95d9fe0f176..b42ae2a2595 100755
--- a/scripts/gitaly-test-build
+++ b/scripts/gitaly-test-build
@@ -9,11 +9,21 @@ require 'fileutils'
# called 'bundle install' using a different Gemfile, as happens with
# gitlab-ce and gitaly.
-dir = 'tmp/tests/gitaly'
+tmp_tests_gitaly_dir = File.expand_path('../tmp/tests/gitaly', __dir__)
-abort 'gitaly build failed' unless system('make', chdir: dir)
+# Use the top-level bundle vendor folder so that we don't reinstall gems twice
+bundle_vendor_path = File.expand_path('../vendor', __dir__)
+
+env = {
+ # This ensure the `clean` config set in `scripts/prepare_build.sh` isn't taken into account
+ 'BUNDLE_IGNORE_CONFIG' => 'true',
+ 'BUNDLE_GEMFILE' => File.join(tmp_tests_gitaly_dir, 'ruby', 'Gemfile'),
+ 'BUNDLE_FLAGS' => "--jobs=4 --path=#{bundle_vendor_path} --retry=3"
+}
+
+abort 'gitaly build failed' unless system(env, 'make', chdir: tmp_tests_gitaly_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))
+FileUtils.touch(File.join(tmp_tests_gitaly_dir, 'gitaly'), mtime: Time.now + (1 << 24))
diff --git a/scripts/lint-changelog-yaml b/scripts/lint-changelog-yaml
index cce5f1c7667..6553e02ffca 100755
--- a/scripts/lint-changelog-yaml
+++ b/scripts/lint-changelog-yaml
@@ -8,11 +8,13 @@ invalid_changelogs = Dir['changelogs/**/*'].reject do |changelog|
begin
YAML.load_file(changelog)
- rescue
+ rescue => exception
+ puts exception
end
end
if invalid_changelogs.any?
+ puts
puts "Invalid changelogs found!\n"
puts invalid_changelogs.sort
exit 1
diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh
index e5242fee32b..178b209aacf 100755
--- a/scripts/lint-doc.sh
+++ b/scripts/lint-doc.sh
@@ -3,7 +3,7 @@
cd "$(dirname "$0")/.."
# Use long options (e.g. --header instead of -H) for curl examples in documentation.
-echo 'Checking for curl short options...'
+echo '=> Checking for cURL short options...'
grep --extended-regexp --recursive --color=auto 'curl (.+ )?-[^- ].*' doc/ >/dev/null 2>&1
if [ $? == 0 ]
then
@@ -15,7 +15,7 @@ fi
# Ensure that the CHANGELOG.md does not contain duplicate versions
DUPLICATE_CHANGELOG_VERSIONS=$(grep --extended-regexp '^## .+' CHANGELOG.md | sed -E 's| \(.+\)||' | sort -r | uniq -d)
-echo 'Checking for CHANGELOG.md duplicate entries...'
+echo '=> Checking for CHANGELOG.md duplicate entries...'
if [ "${DUPLICATE_CHANGELOG_VERSIONS}" != "" ]
then
echo '✖ ERROR: Duplicate versions in CHANGELOG.md:' >&2
@@ -25,7 +25,7 @@ fi
# Make sure no files in doc/ are executable
EXEC_PERM_COUNT=$(find doc/ app/ -type f -perm 755 | wc -l)
-echo 'Checking for executable permissions...'
+echo '=> Checking for executable permissions...'
if [ "${EXEC_PERM_COUNT}" -ne 0 ]
then
echo '✖ ERROR: Executable permissions should not be used in documentation! Use `chmod 644` to the files in question:' >&2
@@ -33,5 +33,33 @@ then
exit 1
fi
+# Do not use 'README.md', instead use 'index.md'
+# Number of 'README.md's as of 2018-03-26
+NUMBER_READMES_CE=42
+NUMBER_READMES_EE=46
+FIND_READMES=$(find doc/ -name "README.md" | wc -l)
+echo '=> Checking for new README.md files...'
+if [ "${CI_PROJECT_NAME}" == 'gitlab-ce' ]
+then
+ if [ ${FIND_READMES} -ne ${NUMBER_READMES_CE} ]
+ then
+ echo
+ echo ' ✖ ERROR: New README.md file(s) detected, prefer index.md over README.md.' >&2
+ echo ' https://docs.gitlab.com/ee/development/writing_documentation.html#location-and-naming-documents'
+ echo
+ exit 1
+ fi
+elif [ "${CI_PROJECT_NAME}" == 'gitlab-ee' ]
+then
+ if [ ${FIND_READMES} -ne $NUMBER_READMES_EE ]
+ then
+ echo
+ echo ' ✖ ERROR: New README.md file(s) detected, prefer index.md over README.md.' >&2
+ echo ' https://docs.gitlab.com/ee/development/writing_documentation.html#location-and-naming-documents'
+ echo
+ exit 1
+ fi
+fi
+
echo "✔ Linting passed"
exit 0
diff --git a/scripts/lint-rugged b/scripts/lint-rugged
new file mode 100755
index 00000000000..cabd083e9f9
--- /dev/null
+++ b/scripts/lint-rugged
@@ -0,0 +1,34 @@
+#!/usr/bin/env ruby
+
+ALLOWED = [
+ # Can be fixed once Rugged is no longer used in production. Doesn't make Rugged calls.
+ 'config/initializers/8_metrics.rb',
+
+ # Can be deleted once wiki's are fully (mandatory) migrated
+ 'config/initializers/gollum.rb',
+
+ # Needs to be migrated, https://gitlab.com/gitlab-org/gitaly/issues/953
+ 'lib/gitlab/bare_repository_import/repository.rb',
+
+ # Needs to be migrated, https://gitlab.com/gitlab-org/gitaly/issues/954
+ 'lib/tasks/gitlab/cleanup.rake',
+
+ # The only place where Rugged code is still allowed in production
+ 'lib/gitlab/git/'
+].freeze
+
+rugged_lines = IO.popen(%w[git grep -i -n rugged -- app config lib], &:read).lines
+rugged_lines = rugged_lines.select { |l| /^[^:]*\.rb:/ =~ l }
+rugged_lines = rugged_lines.reject { |l| l.start_with?(*ALLOWED) }
+rugged_lines = rugged_lines.reject do |line|
+ code, _comment = line.split('# ', 2)
+ code !~ /rugged/i
+end
+
+exit if rugged_lines.empty?
+
+puts "Using Rugged is only allowed in test and #{ALLOWED}\n\n"
+
+puts rugged_lines
+
+exit(false)
diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh
index ea406aadf39..206d62dbc78 100644
--- a/scripts/prepare_build.sh
+++ b/scripts/prepare_build.sh
@@ -3,7 +3,7 @@
export SETUP_DB=${SETUP_DB:-true}
export CREATE_DB_USER=${CREATE_DB_USER:-$SETUP_DB}
export USE_BUNDLE_INSTALL=${USE_BUNDLE_INSTALL:-true}
-export BUNDLE_INSTALL_FLAGS="--without production --jobs $(nproc) --path vendor --retry 3 --quiet"
+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
diff --git a/scripts/security-harness b/scripts/security-harness
new file mode 100755
index 00000000000..c60b3410095
--- /dev/null
+++ b/scripts/security-harness
@@ -0,0 +1,57 @@
+#!/usr/bin/env ruby
+
+require 'digest'
+require 'fileutils'
+
+harness_path = File.expand_path('../.git/security_harness', __dir__)
+hook_path = File.expand_path("../.git/hooks/pre-push", __dir__)
+
+if File.exist?(hook_path)
+ # Deal with a pre-existing hook
+ source_sum = Digest::SHA256.hexdigest(DATA.read)
+ dest_sum = Digest::SHA256.file(hook_path).hexdigest
+
+ if source_sum != dest_sum
+ puts "#{hook_path} exists and is different from our hook!"
+ puts "Remove it and re-run this script to continue."
+
+ exit 1
+ end
+else
+ File.open(hook_path, 'w') do |file|
+ IO.copy_stream(DATA, file)
+ end
+
+ File.chmod(0755, hook_path)
+end
+
+# Toggle the harness on or off
+if File.exist?(harness_path)
+ FileUtils.rm(harness_path)
+
+ puts "Security harness removed -- you can now push to all remotes."
+else
+ FileUtils.touch(harness_path)
+
+ puts "Security harness installed -- you will only be able to push to dev.gitlab.org!"
+end
+
+__END__
+#!/bin/sh
+
+set -e
+
+url="$2"
+harness=`dirname "$0"`/../security_harness
+
+if [ -e "$harness" ]
+then
+ if [[ "$url" != *"dev.gitlab.org"* ]]
+ then
+ echo "Pushing to remotes other than dev.gitlab.org has been disabled!"
+ echo "Run scripts/security-harness to disable this check."
+ echo
+
+ exit 1
+ fi
+fi
diff --git a/scripts/static-analysis b/scripts/static-analysis
index 51a2fd81a79..0e67eabfec1 100755
--- a/scripts/static-analysis
+++ b/scripts/static-analysis
@@ -1,34 +1,49 @@
#!/usr/bin/env ruby
-require ::File.expand_path('../lib/gitlab/popen', __dir__)
+# We don't have auto-loading here
+require_relative '../lib/gitlab/popen'
+require_relative '../lib/gitlab/popen/runner'
+
+def emit_warnings(static_analysis)
+ static_analysis.warned_results.each do |result|
+ puts
+ puts "**** #{result.cmd.join(' ')} had the following warning(s):"
+ puts
+ puts result.stderr
+ puts
+ end
+end
+
+def emit_errors(static_analysis)
+ static_analysis.failed_results.each do |result|
+ puts
+ puts "**** #{result.cmd.join(' ')} failed with the following error(s):"
+ puts
+ puts result.stdout
+ puts result.stderr
+ puts
+ end
+end
tasks = [
- %w[bundle exec bundle-audit check --update],
- %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[bin/rake lint:all],
%w[bundle exec license_finder],
%w[yarn run eslint],
%w[bundle exec rubocop --parallel],
%w[scripts/lint-conflicts.sh],
- %w[bundle exec rake gettext:lint],
- %w[scripts/lint-changelog-yaml]
+ %w[scripts/lint-rugged]
]
-failed_tasks = tasks.reduce({}) do |failures, task|
- start = Time.now
- puts
- puts "$ #{task.join(' ')}"
+static_analysis = Gitlab::Popen::Runner.new
- output, status = Gitlab::Popen.popen(task)
- puts "==> Finished in #{Time.now - start} seconds"
+static_analysis.run(tasks) do |cmd, &run|
puts
+ puts "$ #{cmd.join(' ')}"
- failures[task.join(' ')] = output unless status.zero?
+ result = run.call
- failures
+ puts "==> Finished in #{result.duration} seconds"
+ puts
end
puts
@@ -36,17 +51,20 @@ puts '==================================================='
puts
puts
-if failed_tasks.empty?
+if static_analysis.all_success_and_clean?
puts 'All static analyses passed successfully.'
+elsif static_analysis.all_success?
+ puts 'All static analyses passed successfully, but we have warnings:'
+ puts
+
+ emit_warnings(static_analysis)
+
+ exit 2
else
puts 'Some static analyses failed:'
- failed_tasks.each do |failed_task, output|
- puts
- puts "**** #{failed_task} failed with the following error:"
- puts
- puts output
- end
+ emit_warnings(static_analysis)
+ emit_errors(static_analysis)
exit 1
end
diff --git a/scripts/trigger-build-docs b/scripts/trigger-build-docs
index a270823b857..c9aaba91aa0 100755
--- a/scripts/trigger-build-docs
+++ b/scripts/trigger-build-docs
@@ -7,7 +7,7 @@ require 'gitlab'
#
Gitlab.configure do |config|
config.endpoint = 'https://gitlab.com/api/v4'
- config.private_token = ENV["DOCS_API_TOKEN"] # GitLab Docs bot access token which has only Developer access to gitlab-docs
+ config.private_token = ENV["DOCS_API_TOKEN"] # GitLab Docs bot access token with Developer access to gitlab-docs
end
#
@@ -24,20 +24,40 @@ def docs_branch
# The maximum string length a file can have on a filesystem (ext4)
# is 63 characters. Let's use something smaller to be 100% sure.
max = 42
- # Prefix the remote branch with 'preview-' in order to avoid
- # name conflicts in the rare case the branch name already
+ # Prefix the remote branch with the slug of the project in order
+ # to avoid name conflicts in the rare case the branch name already
# exists in the docs repo and truncate to max length.
"#{slug}-#{ENV["CI_COMMIT_REF_SLUG"]}"[0...max]
end
#
-# Create a remote branch in gitlab-docs
+# Create a remote branch in gitlab-docs and immediately cancel the pipeline
+# to avoid race conditions, since a triggered pipeline will also run right
+# after the branch creation. This only happens the very first time a branch
+# is created and will be skipped in subsequent runs. Read more in
+# https://gitlab.com/gitlab-com/gitlab-docs/issues/154.
#
def create_remote_branch
Gitlab.create_branch(GITLAB_DOCS_REPO, docs_branch, 'master')
- puts "Remote branch '#{docs_branch}' created"
+ puts "=> Remote branch '#{docs_branch}' created"
+
+ pipelines = nil
+
+ # Wait until the pipeline is started
+ loop do
+ sleep 1
+ puts "=> Waiting for pipeline to start..."
+ pipelines = Gitlab.pipelines(GITLAB_DOCS_REPO, { ref: docs_branch })
+ break if pipelines.any?
+ end
+
+ # Get the first pipeline ID which should be the only one for the branch
+ pipeline_id = pipelines.first.id
+
+ # Cancel the pipeline
+ Gitlab.cancel_pipeline(GITLAB_DOCS_REPO, pipeline_id)
rescue Gitlab::Error::BadRequest
- puts "Remote branch '#{docs_branch}' already exists"
+ puts "=> Remote branch '#{docs_branch}' already exists"
end
#
@@ -45,7 +65,7 @@ end
#
def remove_remote_branch
Gitlab.delete_branch(GITLAB_DOCS_REPO, docs_branch)
- puts "Remote branch '#{docs_branch}' deleted"
+ puts "=> Remote branch '#{docs_branch}' deleted"
end
#
@@ -78,18 +98,22 @@ def trigger_pipeline
# The review app URL
app_url = "http://#{docs_branch}.#{ENV["DOCS_REVIEW_APPS_DOMAIN"]}/#{slug}"
- # Create the pipeline
- puts "=> Triggering a pipeline..."
+ # Create the cross project pipeline using CI_JOB_TOKEN
pipeline = Gitlab.run_trigger(GITLAB_DOCS_REPO, ENV["CI_JOB_TOKEN"], docs_branch, { param_name => ENV["CI_COMMIT_REF_NAME"] })
- puts "=> Pipeline created:"
+ puts "=> Follow the status of the triggered pipeline:"
puts ""
puts "https://gitlab.com/gitlab-com/gitlab-docs/pipelines/#{pipeline.id}"
puts ""
- puts "=> Preview your changes live at:"
+ puts "=> In a few minutes, you will be able to preview your changes under the following URL:"
puts ""
puts app_url
puts ""
+ puts "=> For more information, read the documentation"
+ puts "=> https://docs.gitlab.com/ee/development/writing_documentation.html#previewing-the-changes-live"
+ puts ""
+ puts "=> If something doesn't work, drop a line in the #docs chat channel."
+ puts ""
end
#
diff --git a/scripts/trigger-build-omnibus b/scripts/trigger-build-omnibus
index 85ea4aa74ac..95f35b44f5a 100755
--- a/scripts/trigger-build-omnibus
+++ b/scripts/trigger-build-omnibus
@@ -9,6 +9,7 @@ module Omnibus
class Trigger
TOKEN = ENV['BUILD_TRIGGER_TOKEN']
+ TRIGGERER = ENV['CI_PROJECT_NAME']
def initialize
@uri = URI("https://gitlab.com/api/v4/projects/#{CGI.escape(Omnibus::PROJECT_PATH)}/trigger/pipeline")
@@ -32,7 +33,7 @@ module Omnibus
private
def ee?
- File.exist?('CHANGELOG-EE.md')
+ TRIGGERER == 'gitlab-ee' || File.exist?('CHANGELOG-EE.md')
end
def env_params
diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb
index 2565622f8df..cc1b1e5039e 100644
--- a/spec/controllers/admin/application_settings_controller_spec.rb
+++ b/spec/controllers/admin/application_settings_controller_spec.rb
@@ -51,6 +51,13 @@ describe Admin::ApplicationSettingsController do
sign_in(admin)
end
+ it 'updates the password_authentication_enabled_for_git setting' do
+ put :update, application_setting: { password_authentication_enabled_for_git: "0" }
+
+ expect(response).to redirect_to(admin_application_settings_path)
+ expect(ApplicationSetting.current.password_authentication_enabled_for_git).to eq(false)
+ end
+
it 'updates the default_project_visibility for string value' do
put :update, application_setting: { default_project_visibility: "20" }
diff --git a/spec/controllers/admin/gitaly_servers_controller_spec.rb b/spec/controllers/admin/gitaly_servers_controller_spec.rb
new file mode 100644
index 00000000000..b7378aa37d0
--- /dev/null
+++ b/spec/controllers/admin/gitaly_servers_controller_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe Admin::GitalyServersController do
+ describe '#index' do
+ before do
+ sign_in(create(:admin))
+ end
+
+ it 'shows the gitaly servers page' do
+ get :index
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+end
diff --git a/spec/controllers/admin/hooks_controller_spec.rb b/spec/controllers/admin/hooks_controller_spec.rb
index e6ba596117a..d2c1e634930 100644
--- a/spec/controllers/admin/hooks_controller_spec.rb
+++ b/spec/controllers/admin/hooks_controller_spec.rb
@@ -11,11 +11,13 @@ describe Admin::HooksController do
it 'sets all parameters' do
hook_params = {
enable_ssl_verification: true,
+ token: "TEST TOKEN",
+ url: "http://example.com",
+
push_events: true,
tag_push_events: true,
repository_update_events: true,
- token: "TEST TOKEN",
- url: "http://example.com"
+ merge_requests_events: true
}
post :create, hook: hook_params
diff --git a/spec/controllers/admin/projects_controller_spec.rb b/spec/controllers/admin/projects_controller_spec.rb
index d5a3c250f31..cc200b9fed9 100644
--- a/spec/controllers/admin/projects_controller_spec.rb
+++ b/spec/controllers/admin/projects_controller_spec.rb
@@ -31,5 +31,15 @@ describe Admin::ProjectsController do
expect(response.body).not_to match(pending_delete_project.name)
expect(response.body).to match(project.name)
end
+
+ it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do
+ get :index
+
+ control_count = ActiveRecord::QueryRecorder.new { get :index }.count
+
+ create(:project)
+
+ expect { get :index }.not_to exceed_query_limit(control_count)
+ end
end
end
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index f044a068938..f350641a643 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -13,7 +13,7 @@ describe Admin::UsersController do
let!(:issue) { create(:issue, author: user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'deletes user and ghosts their contributions' do
diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb
index 73fff6eb5ca..fb6d82d7de3 100644
--- a/spec/controllers/autocomplete_controller_spec.rb
+++ b/spec/controllers/autocomplete_controller_spec.rb
@@ -109,15 +109,17 @@ describe AutocompleteController do
end
context 'limited users per page' do
- let(:per_page) { 2 }
-
before do
+ 25.times do
+ create(:user)
+ end
+
sign_in(user)
- get(:users, per_page: per_page)
+ get(:users)
end
it { expect(json_response).to be_kind_of(Array) }
- it { expect(json_response.size).to eq(per_page) }
+ it { expect(json_response.size).to eq(20) }
end
context 'unauthenticated user' do
@@ -244,7 +246,7 @@ describe AutocompleteController do
expect(json_response.size).to eq(1)
expect(json_response.first['id']).to eq authorized_project.id
- expect(json_response.first['name_with_namespace']).to eq authorized_project.name_with_namespace
+ expect(json_response.first['name_with_namespace']).to eq authorized_project.full_name
end
end
end
@@ -265,7 +267,7 @@ describe AutocompleteController do
expect(json_response.size).to eq(1)
expect(json_response.first['id']).to eq authorized_search_project.id
- expect(json_response.first['name_with_namespace']).to eq authorized_search_project.name_with_namespace
+ expect(json_response.first['name_with_namespace']).to eq authorized_search_project.full_name
end
end
end
diff --git a/spec/controllers/boards/issues_controller_spec.rb b/spec/controllers/boards/issues_controller_spec.rb
index 44d504d5852..4770e187db6 100644
--- a/spec/controllers/boards/issues_controller_spec.rb
+++ b/spec/controllers/boards/issues_controller_spec.rb
@@ -13,8 +13,8 @@ describe Boards::IssuesController do
let!(:list2) { create(:list, board: board, label: development, position: 1) }
before do
- project.team << [user, :master]
- project.team << [guest, :guest]
+ project.add_master(user)
+ project.add_guest(guest)
end
describe 'GET index' do
@@ -86,6 +86,7 @@ describe Boards::IssuesController do
context 'with unauthorized user' do
before do
+ allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_issue, project).and_return(false)
end
@@ -221,7 +222,7 @@ describe Boards::IssuesController do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
end
it 'returns a forbidden 403 response' do
diff --git a/spec/controllers/boards/lists_controller_spec.rb b/spec/controllers/boards/lists_controller_spec.rb
index a2b432af23a..71d45a22d91 100644
--- a/spec/controllers/boards/lists_controller_spec.rb
+++ b/spec/controllers/boards/lists_controller_spec.rb
@@ -7,8 +7,8 @@ describe Boards::ListsController do
let(:guest) { create(:user) }
before do
- project.team << [user, :master]
- project.team << [guest, :guest]
+ project.add_master(user)
+ project.add_guest(guest)
end
describe 'GET index' do
diff --git a/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb b/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb
new file mode 100644
index 00000000000..27f558e1b5d
--- /dev/null
+++ b/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb
@@ -0,0 +1,146 @@
+require 'spec_helper'
+
+describe ControllerWithCrossProjectAccessCheck do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in user
+ end
+
+ render_views
+
+ context 'When reading cross project is not allowed' do
+ before do
+ allow(Ability).to receive(:allowed).and_call_original
+ allow(Ability).to receive(:allowed?)
+ .with(user, :read_cross_project, :global)
+ .and_return(false)
+ end
+
+ describe '#requires_cross_project_access' do
+ controller(ApplicationController) do
+ # `described_class` is not available in this context
+ include ControllerWithCrossProjectAccessCheck # rubocop:disable RSpec/DescribedClass
+
+ requires_cross_project_access :index, show: false,
+ unless: -> { unless_condition },
+ if: -> { if_condition }
+
+ def index
+ render nothing: true
+ end
+
+ def show
+ render nothing: true
+ end
+
+ def unless_condition
+ false
+ end
+
+ def if_condition
+ true
+ end
+ end
+
+ it 'renders a 404 with trying to access a cross project page' do
+ message = "This page is unavailable because you are not allowed to read "\
+ "information across multiple projects."
+
+ get :index
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(response.body).to match(/#{message}/)
+ end
+
+ it 'is skipped when the `if` condition returns false' do
+ expect(controller).to receive(:if_condition).and_return(false)
+
+ get :index
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'is skipped when the `unless` condition returns true' do
+ expect(controller).to receive(:unless_condition).and_return(true)
+
+ get :index
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'correctly renders an action that does not require cross project access' do
+ get :show, id: 'nothing'
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ describe '#skip_cross_project_access_check' do
+ controller(ApplicationController) do
+ # `described_class` is not available in this context
+ include ControllerWithCrossProjectAccessCheck # rubocop:disable RSpec/DescribedClass
+
+ requires_cross_project_access
+
+ skip_cross_project_access_check index: true, show: false,
+ unless: -> { unless_condition },
+ if: -> { if_condition }
+
+ def index
+ render nothing: true
+ end
+
+ def show
+ render nothing: true
+ end
+
+ def edit
+ render nothing: true
+ end
+
+ def unless_condition
+ false
+ end
+
+ def if_condition
+ true
+ end
+ end
+
+ it 'renders a success when the check is skipped' do
+ get :index
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'is executed when the `if` condition returns false' do
+ expect(controller).to receive(:if_condition).and_return(false)
+
+ get :index
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'is executed when the `unless` condition returns true' do
+ expect(controller).to receive(:unless_condition).and_return(true)
+
+ get :index
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'does not skip the check on an action that is not skipped' do
+ get :show, id: 'hello'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'does not skip the check on an action that was not defined to skip' do
+ get :edit, id: 'hello'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/concerns/issuable_collections_spec.rb b/spec/controllers/concerns/issuable_collections_spec.rb
index d7825364ed5..c1f42bbb9d7 100644
--- a/spec/controllers/concerns/issuable_collections_spec.rb
+++ b/spec/controllers/concerns/issuable_collections_spec.rb
@@ -8,6 +8,10 @@ describe IssuableCollections do
def self.helper_method(name); end
include IssuableCollections
+
+ def finder_type
+ IssuesFinder
+ end
end
controller = klass.new
diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb
new file mode 100644
index 00000000000..f4c99ea4064
--- /dev/null
+++ b/spec/controllers/concerns/send_file_upload_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+describe SendFileUpload do
+ let(:uploader_class) do
+ Class.new(GitlabUploader) do
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.uploads
+
+ private
+
+ # user/:id
+ def dynamic_segment
+ File.join(model.class.to_s.underscore, model.id.to_s)
+ end
+ end
+ end
+
+ let(:controller_class) do
+ Class.new do
+ include SendFileUpload
+ end
+ end
+
+ let(:object) { build_stubbed(:user) }
+ let(:uploader) { uploader_class.new(object, :file) }
+
+ describe '#send_upload' do
+ let(:controller) { controller_class.new }
+ let(:temp_file) { Tempfile.new('test') }
+
+ subject { controller.send_upload(uploader) }
+
+ before do
+ FileUtils.touch(temp_file)
+ end
+
+ after do
+ FileUtils.rm_f(temp_file)
+ end
+
+ context 'when local file is used' do
+ before do
+ uploader.store!(temp_file)
+ end
+
+ it 'sends a file' do
+ expect(controller).to receive(:send_file).with(uploader.path, anything)
+
+ subject
+ end
+ end
+
+ context 'when remote file is used' do
+ before do
+ stub_uploads_object_storage(uploader: uploader_class)
+ uploader.object_store = ObjectStorage::Store::REMOTE
+ uploader.store!(temp_file)
+ end
+
+ context 'and proxying is enabled' do
+ before do
+ allow(Gitlab.config.uploads.object_store).to receive(:proxy_download) { true }
+ end
+
+ it 'sends a file' do
+ headers = double
+ expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-url:/)
+ expect(controller).to receive(:headers) { headers }
+ expect(controller).to receive(:head).with(:ok)
+
+ subject
+ end
+ end
+
+ context 'and proxying is disabled' do
+ before do
+ allow(Gitlab.config.uploads.object_store).to receive(:proxy_download) { false }
+ end
+
+ it 'sends a file' do
+ expect(controller).to receive(:redirect_to).with(/#{uploader.path}/)
+
+ subject
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/dashboard/groups_controller_spec.rb b/spec/controllers/dashboard/groups_controller_spec.rb
index fb9d3efbac0..7f2eaf95165 100644
--- a/spec/controllers/dashboard/groups_controller_spec.rb
+++ b/spec/controllers/dashboard/groups_controller_spec.rb
@@ -20,4 +20,24 @@ describe Dashboard::GroupsController do
expect(assigns(:groups)).to contain_exactly(member_of_group)
end
+
+ context 'when rendering an expanded hierarchy with public groups you are not a member of', :nested_groups do
+ let!(:top_level_result) { create(:group, name: 'chef-top') }
+ let!(:top_level_a) { create(:group, name: 'top-a') }
+ let!(:sub_level_result_a) { create(:group, name: 'chef-sub-a', parent: top_level_a) }
+ let!(:other_group) { create(:group, name: 'other') }
+
+ before do
+ top_level_result.add_master(user)
+ top_level_a.add_master(user)
+ end
+
+ it 'renders only groups the user is a member of when searching hierarchy correctly' do
+ get :index, filter: 'chef', format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ all_groups = [top_level_result, top_level_a, sub_level_result_a]
+ expect(assigns(:groups)).to contain_exactly(*all_groups)
+ end
+ end
end
diff --git a/spec/controllers/dashboard/milestones_controller_spec.rb b/spec/controllers/dashboard/milestones_controller_spec.rb
index 2f3d7be9abe..60547db82b6 100644
--- a/spec/controllers/dashboard/milestones_controller_spec.rb
+++ b/spec/controllers/dashboard/milestones_controller_spec.rb
@@ -17,7 +17,7 @@ describe Dashboard::MilestonesController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
it_behaves_like 'milestone tabs'
diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb
index f9faa4fa59a..b4a731fd3a3 100644
--- a/spec/controllers/dashboard/todos_controller_spec.rb
+++ b/spec/controllers/dashboard/todos_controller_spec.rb
@@ -8,7 +8,7 @@ describe Dashboard::TodosController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
describe 'GET #index' do
diff --git a/spec/controllers/dashboard_controller_spec.rb b/spec/controllers/dashboard_controller_spec.rb
index 566d8515198..3458d679107 100644
--- a/spec/controllers/dashboard_controller_spec.rb
+++ b/spec/controllers/dashboard_controller_spec.rb
@@ -5,15 +5,17 @@ describe DashboardController do
let(:project) { create(:project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
describe 'GET issues' do
it_behaves_like 'issuables list meta-data', :issue, :issues
+ it_behaves_like 'issuables requiring filter', :issues
end
describe 'GET merge requests' do
it_behaves_like 'issuables list meta-data', :merge_request, :merge_requests
+ it_behaves_like 'issuables requiring filter', :merge_requests
end
end
diff --git a/spec/controllers/groups/boards_controller_spec.rb b/spec/controllers/groups/boards_controller_spec.rb
new file mode 100644
index 00000000000..0f5bde62006
--- /dev/null
+++ b/spec/controllers/groups/boards_controller_spec.rb
@@ -0,0 +1,136 @@
+require 'spec_helper'
+
+describe Groups::BoardsController do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ before do
+ group.add_master(user)
+ sign_in(user)
+ end
+
+ describe 'GET index' do
+ it 'creates a new board when group does not have one' do
+ expect { list_boards }.to change(group.boards, :count).by(1)
+ end
+
+ context 'when format is HTML' do
+ it 'renders template' do
+ list_boards
+
+ expect(response).to render_template :index
+ expect(response.content_type).to eq 'text/html'
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
+ allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
+ end
+
+ it 'returns a not found 404 response' do
+ list_boards
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(response.content_type).to eq 'text/html'
+ end
+ end
+ end
+
+ context 'when format is JSON' do
+ it 'return an array with one group board' do
+ create(:board, group: group)
+
+ list_boards format: :json
+
+ parsed_response = JSON.parse(response.body)
+
+ expect(response).to match_response_schema('boards')
+ expect(parsed_response.length).to eq 1
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
+ allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
+ end
+
+ it 'returns a not found 404 response' do
+ list_boards format: :json
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(response.content_type).to eq 'application/json'
+ end
+ end
+ end
+
+ def list_boards(format: :html)
+ get :index, group_id: group, format: format
+ end
+ end
+
+ describe 'GET show' do
+ let!(:board) { create(:board, group: group) }
+
+ context 'when format is HTML' do
+ it 'renders template' do
+ read_board board: board
+
+ expect(response).to render_template :show
+ expect(response.content_type).to eq 'text/html'
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
+ allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
+ end
+
+ it 'returns a not found 404 response' do
+ read_board board: board
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(response.content_type).to eq 'text/html'
+ end
+ end
+ end
+
+ context 'when format is JSON' do
+ it 'returns project board' do
+ read_board board: board, format: :json
+
+ expect(response).to match_response_schema('board')
+ end
+
+ context 'with unauthorized user' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
+ allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
+ end
+
+ it 'returns a not found 404 response' do
+ read_board board: board, format: :json
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(response.content_type).to eq 'application/json'
+ end
+ end
+ end
+
+ context 'when board does not belong to group' do
+ it 'returns a not found 404 response' do
+ another_board = create(:board)
+
+ read_board board: another_board
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ def read_board(board:, format: :html)
+ get :show, group_id: group,
+ id: board.to_param,
+ format: format
+ end
+ end
+end
diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb
index cb1b460fc0e..22d3076c269 100644
--- a/spec/controllers/groups/children_controller_spec.rb
+++ b/spec/controllers/groups/children_controller_spec.rb
@@ -160,6 +160,30 @@ describe Groups::ChildrenController do
expect(json_response).to eq([])
end
+ it 'succeeds if multiple pages contain matching subgroups' do
+ create(:group, parent: group, name: 'subgroup-filter-1')
+ create(:group, parent: group, name: 'subgroup-filter-2')
+
+ # Creating the group-to-nest first so it would be loaded into the
+ # relation first before it's parents, this is what would cause the
+ # crash in: https://gitlab.com/gitlab-org/gitlab-ce/issues/40785.
+ #
+ # If we create the parent groups first, those would be loaded into the
+ # collection first, and the pagination would cut off the actual search
+ # result. In this case the hierarchy can be rendered without crashing,
+ # it's just incomplete.
+ group_to_nest = create(:group, parent: group, name: 'subsubgroup-filter-3')
+ subgroup = create(:group, parent: group)
+ 3.times do |i|
+ subgroup = create(:group, parent: subgroup)
+ end
+ group_to_nest.update!(parent: subgroup)
+
+ get :index, group_id: group.to_param, filter: 'filter', per_page: 3, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
it 'includes pagination headers' do
2.times { |i| create(:group, :public, parent: public_subgroup, name: "filterme#{i}") }
diff --git a/spec/controllers/groups/labels_controller_spec.rb b/spec/controllers/groups/labels_controller_spec.rb
index da54aa9054c..185b6b4ce57 100644
--- a/spec/controllers/groups/labels_controller_spec.rb
+++ b/spec/controllers/groups/labels_controller_spec.rb
@@ -1,8 +1,9 @@
require 'spec_helper'
describe Groups::LabelsController do
- let(:group) { create(:group) }
- let(:user) { create(:user) }
+ set(:group) { create(:group) }
+ set(:user) { create(:user) }
+ set(:project) { create(:project, namespace: group) }
before do
group.add_owner(user)
@@ -10,6 +11,34 @@ describe Groups::LabelsController do
sign_in(user)
end
+ describe 'GET #index' do
+ set(:label_1) { create(:label, project: project, title: 'label_1') }
+ set(:group_label_1) { create(:group_label, group: group, title: 'group_label_1') }
+
+ it 'returns group and project labels by default' do
+ get :index, group_id: group, format: :json
+
+ label_ids = json_response.map {|label| label['title']}
+ expect(label_ids).to match_array([label_1.title, group_label_1.title])
+ end
+
+ context 'with ancestor group', :nested_groups do
+ set(:subgroup) { create(:group, parent: group) }
+ set(:subgroup_label_1) { create(:group_label, group: subgroup, title: 'subgroup_label_1') }
+
+ before do
+ subgroup.add_owner(user)
+ end
+
+ it 'returns ancestor group labels', :nested_groups do
+ get :index, group_id: subgroup, include_ancestor_groups: true, only_group_labels: true, format: :json
+
+ label_ids = json_response.map {|label| label['title']}
+ expect(label_ids).to match_array([group_label_1.title, subgroup_label_1.title])
+ end
+ end
+ end
+
describe 'POST #toggle_subscription' do
it 'allows user to toggle subscription on group labels' do
label = create(:group_label, group: group)
diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb
index c1aba46be04..733386500ca 100644
--- a/spec/controllers/groups/milestones_controller_spec.rb
+++ b/spec/controllers/groups/milestones_controller_spec.rb
@@ -28,7 +28,7 @@ describe Groups::MilestonesController do
before do
sign_in(user)
group.add_owner(user)
- project.team << [user, :master]
+ project.add_master(user)
end
describe '#index' do
diff --git a/spec/controllers/groups/uploads_controller_spec.rb b/spec/controllers/groups/uploads_controller_spec.rb
index 67a11e56e94..6a1869d1a48 100644
--- a/spec/controllers/groups/uploads_controller_spec.rb
+++ b/spec/controllers/groups/uploads_controller_spec.rb
@@ -6,5 +6,7 @@ describe Groups::UploadsController do
{ group_id: model }
end
- it_behaves_like 'handle uploads'
+ it_behaves_like 'handle uploads' do
+ let(:uploader_class) { NamespaceFileUploader }
+ end
end
diff --git a/spec/controllers/groups/variables_controller_spec.rb b/spec/controllers/groups/variables_controller_spec.rb
index 8ea98cd9e8f..39a36b92bb4 100644
--- a/spec/controllers/groups/variables_controller_spec.rb
+++ b/spec/controllers/groups/variables_controller_spec.rb
@@ -9,48 +9,27 @@ describe Groups::VariablesController do
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" }
+ describe 'GET #show' do
+ let!(:variable) { create(:ci_group_variable, group: group) }
- expect(response).to render_template("groups/variables/show")
- end
+ subject do
+ get :show, group_id: group, format: :json
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
+ include_examples 'GET #show lists all variables'
+ 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 }
+ describe 'PATCH #update' do
+ let!(:variable) { create(:ci_group_variable, group: group) }
+ let(:owner) { group }
- expect(response).to have_gitlab_http_status(200)
- expect(response).to render_template :show
- end
+ subject do
+ patch :update,
+ group_id: group,
+ variables_attributes: variables_attributes,
+ format: :json
end
+
+ include_examples 'PATCH #update updates variables'
end
end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index a9cfd964dd5..8688fb33f0d 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -85,6 +85,30 @@ describe GroupsController do
end
end
+ describe 'GET #activity' do
+ render_views
+
+ before do
+ sign_in(user)
+ project
+ end
+
+ context 'as json' do
+ it 'includes all projects in event feed' do
+ 3.times do
+ project = create(:project, group: group)
+ create(:event, project: project)
+ end
+
+ get :activity, id: group.to_param, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['count']).to eq(3)
+ expect(assigns(:projects).limit_value).to be_nil
+ end
+ end
+ end
+
describe 'POST #create' do
context 'when creating subgroups', :nested_groups do
[true, false].each do |can_create_group_status|
@@ -472,4 +496,87 @@ describe GroupsController do
"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
+
+ describe 'PUT transfer', :postgresql do
+ before do
+ sign_in(user)
+ end
+
+ context 'when transfering to a subgroup goes right' do
+ let(:new_parent_group) { create(:group, :public) }
+ let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
+ let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) }
+
+ before do
+ put :transfer,
+ id: group.to_param,
+ new_parent_group_id: new_parent_group.id
+ end
+
+ it 'should return a notice' do
+ expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
+ end
+
+ it 'should redirect to the new path' do
+ expect(response).to redirect_to("/#{new_parent_group.path}/#{group.path}")
+ end
+ end
+
+ context 'when converting to a root group goes right' do
+ let(:group) { create(:group, :public, :nested) }
+ let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
+
+ before do
+ put :transfer,
+ id: group.to_param,
+ new_parent_group_id: ''
+ end
+
+ it 'should return a notice' do
+ expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
+ end
+
+ it 'should redirect to the new path' do
+ expect(response).to redirect_to("/#{group.path}")
+ end
+ end
+
+ context 'When the transfer goes wrong' do
+ let(:new_parent_group) { create(:group, :public) }
+ let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
+ let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) }
+
+ before do
+ allow_any_instance_of(::Groups::TransferService).to receive(:proceed_to_transfer).and_raise(Gitlab::UpdatePathError, 'namespace directory cannot be moved')
+
+ put :transfer,
+ id: group.to_param,
+ new_parent_group_id: new_parent_group.id
+ end
+
+ it 'should return an alert' do
+ expect(flash[:alert]).to eq "Transfer failed: namespace directory cannot be moved"
+ end
+
+ it 'should redirect to the current path' do
+ expect(response).to render_template(:edit)
+ end
+ end
+
+ context 'when the user is not allowed to transfer the group' do
+ let(:new_parent_group) { create(:group, :public) }
+ let!(:group_member) { create(:group_member, :guest, group: group, user: user) }
+ let!(:new_parent_group_member) { create(:group_member, :guest, group: new_parent_group, user: user) }
+
+ before do
+ put :transfer,
+ id: group.to_param,
+ new_parent_group_id: new_parent_group.id
+ end
+
+ it 'should be denied' do
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
end
diff --git a/spec/controllers/health_check_controller_spec.rb b/spec/controllers/health_check_controller_spec.rb
index 2cead1770c9..387ca46ef6f 100644
--- a/spec/controllers/health_check_controller_spec.rb
+++ b/spec/controllers/health_check_controller_spec.rb
@@ -5,7 +5,7 @@ describe HealthCheckController do
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(:token) { Gitlab::CurrentSettings.health_check_access_token }
let(:whitelisted_ip) { '127.0.0.1' }
let(:not_whitelisted_ip) { '127.0.0.2' }
diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb
index 95946def5f9..542eddc2d16 100644
--- a/spec/controllers/health_controller_spec.rb
+++ b/spec/controllers/health_controller_spec.rb
@@ -4,7 +4,7 @@ describe HealthController do
include StubENV
let(:json_response) { JSON.parse(response.body) }
- let(:token) { current_application_settings.health_check_access_token }
+ let(:token) { Gitlab::CurrentSettings.health_check_access_token }
let(:whitelisted_ip) { '127.0.0.1' }
let(:not_whitelisted_ip) { '127.0.0.2' }
diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb
index f75048f422c..21d59c62613 100644
--- a/spec/controllers/help_controller_spec.rb
+++ b/spec/controllers/help_controller_spec.rb
@@ -68,7 +68,7 @@ describe HelpController do
context 'when requested file exists' do
it 'renders the raw file' do
get :show,
- path: 'user/project/img/labels_filter',
+ path: 'user/project/img/labels_default',
format: :png
expect(response).to be_success
expect(response.content_type).to eq 'image/png'
diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb
index e8707760a5a..2be46049aab 100644
--- a/spec/controllers/import/bitbucket_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_controller_spec.rb
@@ -84,20 +84,42 @@ describe Import::BitbucketController do
double(slug: "vim", owner: bitbucket_username, name: 'vim')
end
+ let(:project) { create(:project) }
+
before do
allow_any_instance_of(Bitbucket::Client).to receive(:repo).and_return(bitbucket_repo)
allow_any_instance_of(Bitbucket::Client).to receive(:user).and_return(bitbucket_user)
assign_session_tokens
end
+ it 'returns 200 response when the project is imported successfully' do
+ allow(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params)
+ .and_return(double(execute: project))
+
+ post :create, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns 422 response when the project could not be imported' do
+ allow(Gitlab::BitbucketImport::ProjectCreator)
+ .to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params)
+ .and_return(double(execute: build(:project)))
+
+ post :create, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -107,9 +129,9 @@ describe Import::BitbucketController 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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -120,7 +142,7 @@ describe Import::BitbucketController do
allow(controller).to receive(:current_user).and_return(user)
allow(user).to receive(:can?).and_return(false)
- post :create, format: :js
+ post :create, format: :json
end
end
end
@@ -143,9 +165,9 @@ describe Import::BitbucketController do
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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -154,7 +176,7 @@ describe Import::BitbucketController do
expect(Gitlab::BitbucketImport::ProjectCreator)
.not_to receive(:new)
- post :create, format: :js
+ post :create, format: :json
end
end
end
@@ -163,17 +185,17 @@ describe Import::BitbucketController 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))
+ .to receive(:new).and_return(double(execute: project))
- expect { post :create, format: :js }.to change(Namespace, :count).by(1)
+ expect { post :create, format: :json }.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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -184,23 +206,23 @@ describe Import::BitbucketController do
it "doesn't create the namespace" do
expect(Gitlab::BitbucketImport::ProjectCreator)
- .to receive(:new).and_return(double(execute: true))
+ .to receive(:new).and_return(double(execute: project))
- expect { post :create, format: :js }.not_to change(Namespace, :count)
+ expect { post :create, format: :json }.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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
end
end
- context 'user has chosen an existing nested namespace and name for the project' do
+ context 'user has chosen an existing nested namespace and name for the project', :postgresql do
let(:parent_namespace) { create(:group, name: 'foo', owner: user) }
let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) }
let(:test_name) { 'test_name' }
@@ -212,63 +234,77 @@ describe Import::BitbucketController do
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))
+ .and_return(double(execute: project))
- post :create, { target_namespace: nested_namespace.full_path, new_name: test_name, format: :js }
+ post :create, { target_namespace: nested_namespace.full_path, new_name: test_name, format: :json }
end
end
- context 'user has chosen a non-existent nested namespaces and name for the project' do
+ context 'user has chosen a non-existent nested namespaces and name for the project', :postgresql 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))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
+ post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :json }
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))
+ .and_return(double(execute: project))
- expect { post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js } }
+ expect { post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :json } }
.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))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
+ post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :json }
expect(Namespace.find_by_path_or_name('bar').parent.path).to eq('foo')
end
end
- context 'user has chosen existent and non-existent nested namespaces and name for the project' do
+ context 'user has chosen existent and non-existent nested namespaces and name for the project', :postgresql do
let(:test_name) { 'test_name' }
let!(:parent_namespace) { create(:group, name: 'foo', owner: user) }
+ before do
+ parent_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, kind_of(Namespace), user, access_params)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js }
+ post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :json }
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))
+ .and_return(double(execute: project))
- expect { post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js } }
+ expect { post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :json } }
.to change { Namespace.count }.by(2)
end
end
+
+ context 'when user can not create projects in the chosen namespace' do
+ it 'returns 422 response' do
+ other_namespace = create(:group, name: 'other_namespace')
+
+ post :create, { target_namespace: other_namespace.name, format: :json }
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+ end
end
end
diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb
index faf1e6f63ea..e958be077c2 100644
--- a/spec/controllers/import/gitlab_controller_spec.rb
+++ b/spec/controllers/import/gitlab_controller_spec.rb
@@ -57,6 +57,7 @@ describe Import::GitlabController do
end
describe "POST create" do
+ let(:project) { create(:project) }
let(:gitlab_username) { user.username }
let(:gitlab_user) do
{ username: gitlab_username }.with_indifferent_access
@@ -75,14 +76,34 @@ describe Import::GitlabController do
assign_session_token
end
+ it 'returns 200 response when the project is imported successfully' do
+ allow(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
+ .and_return(double(execute: project))
+
+ post :create, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns 422 response when the project could not be imported' do
+ allow(Gitlab::GitlabImport::ProjectCreator)
+ .to receive(:new).with(gitlab_repo, user.namespace, user, access_params)
+ .and_return(double(execute: build(:project)))
+
+ post :create, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -92,9 +113,9 @@ describe Import::GitlabController 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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
end
@@ -118,9 +139,9 @@ describe Import::GitlabController do
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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -129,7 +150,7 @@ describe Import::GitlabController do
expect(Gitlab::GitlabImport::ProjectCreator)
.not_to receive(:new)
- post :create, format: :js
+ post :create, format: :json
end
end
end
@@ -138,17 +159,17 @@ describe Import::GitlabController 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))
+ .to receive(:new).and_return(double(execute: project))
- expect { post :create, format: :js }.to change(Namespace, :count).by(1)
+ expect { post :create, format: :json }.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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -159,22 +180,22 @@ describe Import::GitlabController do
it "doesn't create the namespace" do
expect(Gitlab::GitlabImport::ProjectCreator)
- .to receive(:new).and_return(double(execute: true))
+ .to receive(:new).and_return(double(execute: project))
- expect { post :create, format: :js }.not_to change(Namespace, :count)
+ expect { post :create, format: :json }.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))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
end
- context 'user has chosen an existing nested namespace for the project' do
+ context 'user has chosen an existing nested namespace for the project', :postgresql do
let(:parent_namespace) { create(:group, name: 'foo', owner: user) }
let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) }
@@ -185,64 +206,78 @@ describe Import::GitlabController do
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))
+ .and_return(double(execute: project))
- post :create, { target_namespace: nested_namespace.full_path, format: :js }
+ post :create, { target_namespace: nested_namespace.full_path, format: :json }
end
end
- context 'user has chosen a non-existent nested namespaces for the project' do
+ context 'user has chosen a non-existent nested namespaces for the project', :postgresql 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))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/bar', format: :js }
+ post :create, { target_namespace: 'foo/bar', format: :json }
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))
+ .and_return(double(execute: project))
- expect { post :create, { target_namespace: 'foo/bar', format: :js } }
+ expect { post :create, { target_namespace: 'foo/bar', format: :json } }
.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))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/bar', format: :js }
+ post :create, { target_namespace: 'foo/bar', format: :json }
expect(Namespace.find_by_path_or_name('bar').parent.path).to eq('foo')
end
end
- context 'user has chosen existent and non-existent nested namespaces and name for the project' do
+ context 'user has chosen existent and non-existent nested namespaces and name for the project', :postgresql do
let(:test_name) { 'test_name' }
let!(:parent_namespace) { create(:group, name: 'foo', owner: user) }
+ before do
+ parent_namespace.add_owner(user)
+ end
+
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))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/foobar/bar', format: :js }
+ post :create, { target_namespace: 'foo/foobar/bar', format: :json }
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))
+ .and_return(double(execute: project))
- expect { post :create, { target_namespace: 'foo/foobar/bar', format: :js } }
+ expect { post :create, { target_namespace: 'foo/foobar/bar', format: :json } }
.to change { Namespace.count }.by(2)
end
end
+
+ context 'when user can not create projects in the chosen namespace' do
+ it 'returns 422 response' do
+ other_namespace = create(:group, name: 'other_namespace')
+
+ post :create, { target_namespace: other_namespace.name, format: :json }
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+ end
end
end
end
diff --git a/spec/controllers/import/gitlab_projects_controller_spec.rb b/spec/controllers/import/gitlab_projects_controller_spec.rb
new file mode 100644
index 00000000000..8759d3c0b97
--- /dev/null
+++ b/spec/controllers/import/gitlab_projects_controller_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Import::GitlabProjectsController do
+ set(:namespace) { create(:namespace) }
+ set(:user) { namespace.owner }
+ let(:file) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'POST create' do
+ context 'with an invalid path' do
+ it 'redirects with an error' do
+ post :create, namespace_id: namespace.id, path: '/test', file: file
+
+ expect(flash[:alert]).to start_with('Project could not be imported')
+ expect(response).to have_gitlab_http_status(302)
+ end
+
+ it 'redirects with an error when a relative path is used' do
+ post :create, namespace_id: namespace.id, path: '../test', file: file
+
+ expect(flash[:alert]).to start_with('Project could not be imported')
+ expect(response).to have_gitlab_http_status(302)
+ end
+ end
+
+ context 'with a valid path' do
+ it 'redirects to the new project path' do
+ post :create, namespace_id: namespace.id, path: 'test', file: file
+
+ expect(flash[:notice]).to include('is being imported')
+ expect(response).to have_gitlab_http_status(302)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/notification_settings_controller_spec.rb b/spec/controllers/notification_settings_controller_spec.rb
index 9014b8b5084..e133950e684 100644
--- a/spec/controllers/notification_settings_controller_spec.rb
+++ b/spec/controllers/notification_settings_controller_spec.rb
@@ -6,7 +6,7 @@ describe NotificationSettingsController do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
describe '#create' do
diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb
index b38652e7ab9..1195f44f37d 100644
--- a/spec/controllers/oauth/applications_controller_spec.rb
+++ b/spec/controllers/oauth/applications_controller_spec.rb
@@ -16,8 +16,7 @@ describe Oauth::ApplicationsController do
end
it 'redirects back to profile page if OAuth applications are disabled' do
- settings = double(user_oauth_applications?: false)
- allow_any_instance_of(Gitlab::CurrentSettings).to receive(:current_application_settings).and_return(settings)
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:user_oauth_applications?).and_return(false)
get :index
diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb
index 004b463e745..149b690ff70 100644
--- a/spec/controllers/oauth/authorizations_controller_spec.rb
+++ b/spec/controllers/oauth/authorizations_controller_spec.rb
@@ -34,6 +34,8 @@ describe Oauth::AuthorizationsController do
end
context 'with valid params' do
+ render_views
+
it 'returns 200 code and renders view' do
get :new, params
diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb
new file mode 100644
index 00000000000..5f0e8c5eca9
--- /dev/null
+++ b/spec/controllers/omniauth_callbacks_controller_spec.rb
@@ -0,0 +1,128 @@
+require 'spec_helper'
+
+describe OmniauthCallbacksController do
+ include LoginHelpers
+
+ let(:user) { create(:omniauth_user, extern_uid: extern_uid, provider: provider) }
+
+ before do
+ mock_auth_hash(provider.to_s, extern_uid, user.email)
+ stub_omniauth_provider(provider, context: request)
+ end
+
+ context 'when the user is on the last sign in attempt' do
+ let(:extern_uid) { 'my-uid' }
+
+ before do
+ user.update(failed_attempts: User.maximum_attempts.pred)
+ subject.response = ActionDispatch::Response.new
+ end
+
+ context 'when using a form based provider' do
+ let(:provider) { :ldap }
+
+ it 'locks the user when sign in fails' do
+ allow(subject).to receive(:params).and_return(ActionController::Parameters.new(username: user.username))
+ request.env['omniauth.error.strategy'] = OmniAuth::Strategies::LDAP.new(nil)
+
+ subject.send(:failure)
+
+ expect(user.reload).to be_access_locked
+ end
+ end
+
+ context 'when using a button based provider' do
+ let(:provider) { :github }
+
+ it 'does not lock the user when sign in fails' do
+ request.env['omniauth.error.strategy'] = OmniAuth::Strategies::GitHub.new(nil)
+
+ subject.send(:failure)
+
+ expect(user.reload).not_to be_access_locked
+ end
+ end
+ end
+
+ context 'strategies' do
+ context 'github' do
+ let(:extern_uid) { 'my-uid' }
+ let(:provider) { :github }
+
+ it 'allows sign in' do
+ post provider
+
+ expect(request.env['warden']).to be_authenticated
+ end
+
+ shared_context 'sign_up' do
+ let(:user) { double(email: 'new@example.com') }
+
+ before do
+ stub_omniauth_setting(block_auto_created_users: false)
+ end
+ end
+
+ context 'sign up' do
+ include_context 'sign_up'
+
+ it 'is allowed' do
+ post provider
+
+ expect(request.env['warden']).to be_authenticated
+ end
+ end
+
+ context 'when OAuth is disabled' do
+ before do
+ stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
+ settings = Gitlab::CurrentSettings.current_application_settings
+ settings.update(disabled_oauth_sign_in_sources: [provider.to_s])
+ end
+
+ it 'prevents login via POST' do
+ post provider
+
+ expect(request.env['warden']).not_to be_authenticated
+ end
+
+ it 'shows warning when attempting login' do
+ post provider
+
+ expect(response).to redirect_to new_user_session_path
+ expect(flash[:alert]).to eq('Signing in using GitHub has been disabled')
+ end
+
+ it 'allows linking the disabled provider' do
+ user.identities.destroy_all
+ sign_in(user)
+
+ expect { post provider }.to change { user.reload.identities.count }.by(1)
+ end
+
+ context 'sign up' do
+ include_context 'sign_up'
+
+ it 'is prevented' do
+ post provider
+
+ expect(request.env['warden']).not_to be_authenticated
+ end
+ end
+ end
+ end
+
+ context 'auth0' do
+ let(:extern_uid) { '' }
+ let(:provider) { :auth0 }
+
+ it 'does not allow sign in without extern_uid' do
+ post 'auth0'
+
+ expect(request.env['warden']).not_to be_authenticated
+ expect(response.status).to eq(302)
+ expect(controller).to set_flash[:alert].to('Wrong extern UID provided. Make sure Auth0 is configured correctly.')
+ end
+ end
+ end
+end
diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb
index d380978b86e..de6ef919221 100644
--- a/spec/controllers/profiles_controller_spec.rb
+++ b/spec/controllers/profiles_controller_spec.rb
@@ -69,9 +69,8 @@ describe ProfilesController, :request_store do
describe 'PUT update_username' do
let(:namespace) { user.namespace }
- let(:project) { create(:project_empty_repo, namespace: namespace) }
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:new_username) { 'renamedtosomethingelse' }
+ let(:new_username) { generate(:username) }
it 'allows username change' do
sign_in(user)
@@ -85,16 +84,68 @@ describe ProfilesController, :request_store do
expect(user.username).to eq(new_username)
end
- it 'moves dependent projects to new namespace' do
+ it 'updates a username using JSON request' do
sign_in(user)
put :update_username,
- user: { username: new_username }
+ user: { username: new_username },
+ format: :json
- user.reload
+ expect(response.status).to eq(200)
+ expect(json_response['message']).to eq('Username successfully changed')
+ end
- expect(response.status).to eq(302)
- expect(gitlab_shell.exists?(project.repository_storage_path, "#{new_username}/#{project.path}.git")).to be_truthy
+ it 'renders an error message when the username was not updated' do
+ sign_in(user)
+
+ put :update_username,
+ user: { username: 'invalid username.git' },
+ format: :json
+
+ expect(response.status).to eq(422)
+ expect(json_response['message']).to match(/Username change failed/)
+ end
+
+ it 'raises a correct error when the username is missing' do
+ sign_in(user)
+
+ expect { put :update_username, user: { gandalf: 'you shall not pass' } }
+ .to raise_error(ActionController::ParameterMissing)
+ end
+
+ context 'with legacy storage' do
+ it 'moves dependent projects to new namespace' do
+ project = create(:project_empty_repo, :legacy_storage, namespace: namespace)
+
+ sign_in(user)
+
+ put :update_username,
+ user: { username: new_username }
+
+ user.reload
+
+ expect(response.status).to eq(302)
+ expect(gitlab_shell.exists?(project.repository_storage_path, "#{new_username}/#{project.path}.git")).to be_truthy
+ end
+ end
+
+ context 'with hashed storage' do
+ it 'keeps repository location unchanged on disk' do
+ project = create(:project_empty_repo, namespace: namespace)
+
+ before_disk_path = project.disk_path
+
+ sign_in(user)
+
+ put :update_username,
+ user: { username: new_username }
+
+ user.reload
+
+ expect(response.status).to eq(302)
+ expect(gitlab_shell.exists?(project.repository_storage_path, "#{project.disk_path}.git")).to be_truthy
+ expect(before_disk_path).to eq(project.disk_path)
+ end
end
end
end
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
index d1051741430..4ea6f869aa3 100644
--- a/spec/controllers/projects/artifacts_controller_spec.rb
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::ArtifactsController do
- set(:user) { create(:user) }
+ let(:user) { project.owner }
set(:project) { create(:project, :repository, :public) }
let(:pipeline) do
@@ -15,14 +15,12 @@ describe Projects::ArtifactsController do
let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
before do
- project.add_developer(user)
-
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
+ expect(controller).to receive(:send_file).with(job.artifacts_file.path, hash_including(disposition: 'attachment')).and_call_original
get :download, namespace_id: project.namespace, project_id: project, job_id: job
end
@@ -113,20 +111,56 @@ describe Projects::ArtifactsController do
end
describe 'GET raw' do
+ subject { get(:raw, namespace_id: project.namespace, project_id: project, job_id: job, path: path) }
+
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'
+ let(:path) { 'ci_artifacts.txt' }
+
+ shared_examples 'a valid file' do
+ it 'serves the file using workhorse' do
+ subject
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(send_data).to start_with('artifacts-entry:')
- send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]
+ expect(params.keys).to eq(%w(Archive Entry))
+ expect(params['Archive']).to start_with(archive_path)
+ # On object storage, the URL can end with a query string
+ expect(params['Archive']).to match(/build_artifacts.zip(\?[^?]+)?$/)
+ expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt'))
+ end
- expect(send_data).to start_with('artifacts-entry:')
+ def send_data
+ response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]
+ end
- base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
- params = JSON.parse(Base64.urlsafe_decode64(base64_params))
+ def params
+ @params ||= begin
+ base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
+ JSON.parse(Base64.urlsafe_decode64(base64_params))
+ end
+ end
+ end
- 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'))
+ context 'when using local file storage' do
+ it_behaves_like 'a valid file' do
+ let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
+ let(:store) { ObjectStorage::Store::LOCAL }
+ let(:archive_path) { JobArtifactUploader.root }
+ end
+ end
+
+ context 'when using remote file storage' do
+ before do
+ stub_artifacts_object_storage
+ end
+
+ it_behaves_like 'a valid file' do
+ let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: job) }
+ let!(:job) { create(:ci_build, :success, pipeline: pipeline) }
+ let(:store) { ObjectStorage::Store::REMOTE }
+ let(:archive_path) { 'https://' }
+ end
end
end
end
diff --git a/spec/controllers/projects/avatars_controller_spec.rb b/spec/controllers/projects/avatars_controller_spec.rb
index f5ea097af8b..6a41c4d23ea 100644
--- a/spec/controllers/projects/avatars_controller_spec.rb
+++ b/spec/controllers/projects/avatars_controller_spec.rb
@@ -1,15 +1,21 @@
require 'spec_helper'
describe Projects::AvatarsController do
- let(:project) { create(:project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
+ let(:project) { create(:project, :repository, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
let(:user) { create(:user) }
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
controller.instance_variable_set(:@project, project)
end
+ it 'GET #show' do
+ get :show, namespace_id: project.namespace.id, project_id: project.id
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
it 'removes avatar from DB by calling destroy' do
delete :destroy, namespace_id: project.namespace.id, project_id: project.id
expect(project.avatar.present?).to be_falsey
diff --git a/spec/controllers/projects/blame_controller_spec.rb b/spec/controllers/projects/blame_controller_spec.rb
index 54282aa4001..88d4f4e9cd0 100644
--- a/spec/controllers/projects/blame_controller_spec.rb
+++ b/spec/controllers/projects/blame_controller_spec.rb
@@ -7,7 +7,7 @@ describe Projects::BlameController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
controller.instance_variable_set(:@project, project)
end
diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb
index 6a1c07b4a0b..00a7df6ccc8 100644
--- a/spec/controllers/projects/blob_controller_spec.rb
+++ b/spec/controllers/projects/blob_controller_spec.rb
@@ -89,7 +89,7 @@ describe Projects::BlobController do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -147,7 +147,7 @@ describe Projects::BlobController do
let(:developer) { create(:user) }
before do
- project.team << [developer, :developer]
+ project.add_developer(developer)
sign_in(developer)
get :edit, default_params
end
@@ -161,7 +161,7 @@ describe Projects::BlobController do
let(:master) { create(:user) }
before do
- project.team << [master, :master]
+ project.add_master(master)
sign_in(master)
get :edit, default_params
end
@@ -190,7 +190,7 @@ describe Projects::BlobController do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb
index d6ccb92c54b..4d765229bde 100644
--- a/spec/controllers/projects/boards_controller_spec.rb
+++ b/spec/controllers/projects/boards_controller_spec.rb
@@ -5,7 +5,7 @@ describe Projects::BoardsController do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -55,6 +55,16 @@ describe Projects::BoardsController do
end
end
+ context 'issues are disabled' do
+ let(:project) { create(:project, :issues_disabled) }
+
+ it 'returns a not found 404 response' do
+ list_boards
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
def list_boards(format: :html)
get :index, namespace_id: project.namespace,
project_id: project,
diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb
index d731200f70f..16fb377b002 100644
--- a/spec/controllers/projects/branches_controller_spec.rb
+++ b/spec/controllers/projects/branches_controller_spec.rb
@@ -6,8 +6,8 @@ describe Projects::BranchesController do
let(:developer) { create(:user) }
before do
- project.team << [user, :master]
- project.team << [user, :developer]
+ project.add_master(user)
+ project.add_developer(user)
allow(project).to receive(:branches).and_return(['master', 'foo/bar/baz'])
allow(project).to receive(:tags).and_return(['v1.0.0', 'v2.0.0'])
@@ -148,6 +148,20 @@ describe Projects::BranchesController do
end
end
+ context 'when create branch service fails' do
+ let(:branch) { "./invalid-branch-name" }
+
+ it "doesn't post a system note" do
+ expect(SystemNoteService).not_to receive(:new_issue_branch)
+
+ post :create,
+ namespace_id: project.namespace,
+ project_id: project,
+ branch_name: branch,
+ issue_iid: issue.iid
+ end
+ end
+
context 'without issue feature access' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
@@ -384,6 +398,22 @@ describe Projects::BranchesController do
end
end
+ # We need :request_store because Gitaly only counts the queries whenever
+ # `RequestStore.active?` in GitalyClient.enforce_gitaly_request_limits
+ # And the main goal of this test is making sure TooManyInvocationsError
+ # was not raised whenever the cache is enabled yet cold.
+ context 'when cache is enabled yet cold', :request_store do
+ it 'return with a status 200' do
+ get :index,
+ namespace_id: project.namespace,
+ project_id: project,
+ state: 'all',
+ format: :html
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
context 'when branch contains an invalid UTF-8 sequence' do
before do
project.repository.create_branch("wrong-\xE5-utf8-sequence")
@@ -393,10 +423,43 @@ describe Projects::BranchesController do
get :index,
namespace_id: project.namespace,
project_id: project,
+ state: 'all',
format: :html
expect(response).to have_gitlab_http_status(200)
end
end
+
+ context 'when deprecated sort/search/page parameters are specified' do
+ it 'returns with a status 301 when sort specified' do
+ get :index,
+ namespace_id: project.namespace,
+ project_id: project,
+ sort: 'updated_asc',
+ format: :html
+
+ expect(response).to redirect_to project_branches_filtered_path(project, state: 'all')
+ end
+
+ it 'returns with a status 301 when search specified' do
+ get :index,
+ namespace_id: project.namespace,
+ project_id: project,
+ search: 'feature',
+ format: :html
+
+ expect(response).to redirect_to project_branches_filtered_path(project, state: 'all')
+ end
+
+ it 'returns with a status 301 when page specified' do
+ get :index,
+ namespace_id: project.namespace,
+ project_id: project,
+ page: 2,
+ format: :html
+
+ expect(response).to redirect_to project_branches_filtered_path(project, state: 'all')
+ end
+ end
end
end
diff --git a/spec/controllers/projects/ci/lints_controller_spec.rb b/spec/controllers/projects/ci/lints_controller_spec.rb
new file mode 100644
index 00000000000..1249a5528a9
--- /dev/null
+++ b/spec/controllers/projects/ci/lints_controller_spec.rb
@@ -0,0 +1,123 @@
+require 'spec_helper'
+
+describe Projects::Ci::LintsController do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #show' do
+ context 'with enough privileges' do
+ before do
+ project.add_developer(user)
+
+ get :show, namespace_id: project.namespace, project_id: project
+ end
+
+ it 'should be success' do
+ expect(response).to be_success
+ end
+
+ it 'should render show page' do
+ expect(response).to render_template :show
+ end
+
+ it 'should retrieve project' do
+ expect(assigns(:project)).to eq(project)
+ end
+ end
+
+ context 'without enough privileges' do
+ before do
+ project.add_guest(user)
+
+ get :show, namespace_id: project.namespace, project_id: project
+ end
+
+ it 'should respond with 404' do
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+
+ describe 'POST #create' do
+ let(:remote_file_path) { 'https://gitlab.com/gitlab-org/gitlab-ce/blob/1234/.gitlab-ci-1.yml' }
+
+ let(:remote_file_content) do
+ <<~HEREDOC
+ before_script:
+ - apt-get update -qq && apt-get install -y -qq sqlite3 libsqlite3-dev nodejs
+ - ruby -v
+ - which ruby
+ - gem install bundler --no-ri --no-rdoc
+ - bundle install --jobs $(nproc) "${FLAGS[@]}"
+ HEREDOC
+ end
+
+ let(:content) do
+ <<~HEREDOC
+ include:
+ - #{remote_file_path}
+
+ rubocop:
+ script:
+ - bundle exec rubocop
+ HEREDOC
+ end
+
+ context 'with a valid gitlab-ci.yml' do
+ before do
+ WebMock.stub_request(:get, remote_file_path).to_return(body: remote_file_content)
+ project.add_developer(user)
+
+ post :create, namespace_id: project.namespace, project_id: project, content: content
+ end
+
+ it 'should be success' do
+ expect(response).to be_success
+ end
+
+ it 'render show page' do
+ expect(response).to render_template :show
+ end
+
+ it 'should retrieve project' do
+ expect(assigns(:project)).to eq(project)
+ end
+ end
+
+ context 'with an invalid gitlab-ci.yml' do
+ let(:content) do
+ <<~HEREDOC
+ rubocop:
+ scriptt:
+ - bundle exec rubocop
+ HEREDOC
+ end
+
+ before do
+ project.add_developer(user)
+
+ post :create, namespace_id: project.namespace, project_id: project, content: content
+ end
+
+ it 'should assign errors' do
+ expect(assigns[:error]).to eq('jobs:rubocop config contains unknown keys: scriptt')
+ end
+ end
+
+ context 'without enough privileges' do
+ before do
+ project.add_guest(user)
+
+ post :create, namespace_id: project.namespace, project_id: project, content: content
+ end
+
+ it 'should respond with 404' do
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/clusters/applications_controller_spec.rb b/spec/controllers/projects/clusters/applications_controller_spec.rb
index 8b460646059..99fdff5f846 100644
--- a/spec/controllers/projects/clusters/applications_controller_spec.rb
+++ b/spec/controllers/projects/clusters/applications_controller_spec.rb
@@ -52,7 +52,7 @@ describe Projects::Clusters::ApplicationsController do
context 'when application is already installing' do
before do
- create(:cluster_applications_helm, :installing, cluster: cluster)
+ create(:clusters_applications_helm, :installing, cluster: cluster)
end
it 'returns 400' do
diff --git a/spec/controllers/projects/clusters/gcp_controller_spec.rb b/spec/controllers/projects/clusters/gcp_controller_spec.rb
index ee7928beb7e..e14ba29fa70 100644
--- a/spec/controllers/projects/clusters/gcp_controller_spec.rb
+++ b/spec/controllers/projects/clusters/gcp_controller_spec.rb
@@ -17,7 +17,6 @@ describe Projects::Clusters::GcpController do
context 'when omniauth has been configured' do
let(:key) { 'secret-key' }
-
let(:session_key_for_redirect_uri) do
GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(key)
end
@@ -78,6 +77,8 @@ describe Projects::Clusters::GcpController do
end
it 'has new object' do
+ expect(controller).to receive(:authorize_google_project_billing)
+
go
expect(assigns(:cluster)).to be_an_instance_of(Clusters::Cluster)
@@ -136,9 +137,16 @@ describe Projects::Clusters::GcpController do
context 'when access token is valid' do
before do
stub_google_api_validate_token
+ allow_any_instance_of(described_class).to receive(:authorize_google_project_billing)
end
- context 'when creates a cluster on gke' do
+ context 'when google project billing is enabled' do
+ before do
+ redis_double = double
+ allow(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis_double)
+ allow(redis_double).to receive(:get).with(CheckGcpProjectBillingWorker.redis_shared_state_key_for('token')).and_return('true')
+ end
+
it 'creates a new cluster' do
expect(ClusterProvisionWorker).to receive(:perform_async)
expect { go }.to change { Clusters::Cluster.count }
@@ -148,6 +156,15 @@ describe Projects::Clusters::GcpController do
expect(project.clusters.first).to be_kubernetes
end
end
+
+ context 'when google project billing is not enabled' do
+ it 'renders the cluster form with an error' do
+ go
+
+ expect(response).to set_flash.now[:alert]
+ expect(response).to render_template('new')
+ end
+ end
end
context 'when access token is expired' do
diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb
index a3b13647c92..82b20e12850 100644
--- a/spec/controllers/projects/clusters_controller_spec.rb
+++ b/spec/controllers/projects/clusters_controller_spec.rb
@@ -18,7 +18,7 @@ describe Projects::ClustersController do
context 'when project has one or more clusters' do
let(:project) { create(:project) }
let!(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
- let!(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, projects: [project]) }
+ let!(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, :production_environment, projects: [project]) }
it 'lists available clusters' do
go
@@ -32,7 +32,7 @@ describe Projects::ClustersController do
before do
allow(Clusters::Cluster).to receive(:paginates_per).and_return(1)
- create_list(:cluster, 2, :provided_by_gcp, projects: [project])
+ create_list(:cluster, 2, :provided_by_gcp, :production_environment, projects: [project])
get :index, namespace_id: project.namespace, project_id: project, page: last_page
end
@@ -91,6 +91,12 @@ describe Projects::ClustersController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('cluster_status')
end
+
+ it 'invokes schedule_status_update on each application' do
+ expect_any_instance_of(Clusters::Applications::Ingress).to receive(:schedule_status_update)
+
+ go
+ end
end
describe 'security' do
@@ -177,7 +183,7 @@ describe Projects::ClustersController do
cluster.reload
expect(response).to redirect_to(project_cluster_path(project, cluster))
- expect(flash[:notice]).to eq('Cluster was successfully updated.')
+ expect(flash[:notice]).to eq('Kubernetes cluster was successfully updated.')
expect(cluster.enabled).to be_falsey
end
@@ -276,7 +282,7 @@ describe Projects::ClustersController do
cluster.reload
expect(response).to redirect_to(project_cluster_path(project, cluster))
- expect(flash[:notice]).to eq('Cluster was successfully updated.')
+ expect(flash[:notice]).to eq('Kubernetes cluster was successfully updated.')
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
expect(cluster.platform_kubernetes.namespace).to eq('my-namespace')
@@ -336,7 +342,7 @@ describe Projects::ClustersController do
.and change { Clusters::Providers::Gcp.count }.by(-1)
expect(response).to redirect_to(project_clusters_path(project))
- expect(flash[:notice]).to eq('Cluster integration was successfully removed.')
+ expect(flash[:notice]).to eq('Kubernetes cluster integration was successfully removed.')
end
end
@@ -349,7 +355,7 @@ describe Projects::ClustersController do
.and change { Clusters::Providers::Gcp.count }.by(-1)
expect(response).to redirect_to(project_clusters_path(project))
- expect(flash[:notice]).to eq('Cluster integration was successfully removed.')
+ expect(flash[:notice]).to eq('Kubernetes cluster integration was successfully removed.')
end
end
end
@@ -364,7 +370,7 @@ describe Projects::ClustersController do
.and change { Clusters::Providers::Gcp.count }.by(0)
expect(response).to redirect_to(project_clusters_path(project))
- expect(flash[:notice]).to eq('Cluster integration was successfully removed.')
+ expect(flash[:notice]).to eq('Kubernetes cluster integration was successfully removed.')
end
end
end
diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb
index c459d732507..55ed276f96b 100644
--- a/spec/controllers/projects/commits_controller_spec.rb
+++ b/spec/controllers/projects/commits_controller_spec.rb
@@ -6,7 +6,7 @@ describe Projects::CommitsController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
describe "GET show" do
@@ -41,15 +41,21 @@ describe Projects::CommitsController do
context "when the ref name ends in .atom" do
context "when the ref does not exist with the suffix" do
- it "renders as atom" do
+ before do
get(:show,
namespace_id: project.namespace,
project_id: project,
id: "master.atom")
+ end
+ it "renders as atom" do
expect(response).to be_success
expect(response.content_type).to eq('application/atom+xml')
end
+
+ it 'renders summary with type=html' do
+ expect(response.body).to include('<summary type="html">')
+ end
end
context "when the ref exists with the suffix" do
diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb
index fe5818da0bc..046ce027965 100644
--- a/spec/controllers/projects/compare_controller_spec.rb
+++ b/spec/controllers/projects/compare_controller_spec.rb
@@ -8,7 +8,7 @@ describe Projects::CompareController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
it 'compare shows some diffs' do
diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb
index 6fae52edbad..5516c95d044 100644
--- a/spec/controllers/projects/cycle_analytics_controller_spec.rb
+++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb
@@ -6,7 +6,7 @@ describe Projects::CycleAnalyticsController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'cycle analytics not set up flag' do
@@ -27,7 +27,7 @@ describe Projects::CycleAnalyticsController do
milestone = create(:milestone, project: project, created_at: 5.days.ago)
issue.update(milestone: milestone)
- create_merge_request_closing_issue(issue)
+ create_merge_request_closing_issue(user, project, issue)
end
it 'is false' do
diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb
index c3208357694..97db69427e9 100644
--- a/spec/controllers/projects/deploy_keys_controller_spec.rb
+++ b/spec/controllers/projects/deploy_keys_controller_spec.rb
@@ -5,7 +5,7 @@ describe Projects::DeployKeysController do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -48,7 +48,7 @@ describe Projects::DeployKeysController do
end
before do
- project2.team << [user, :developer]
+ project2.add_developer(user)
end
it 'returns json in a correct format' do
diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb
index 3164fd5c143..6c67dfde63a 100644
--- a/spec/controllers/projects/deployments_controller_spec.rb
+++ b/spec/controllers/projects/deployments_controller_spec.rb
@@ -8,7 +8,7 @@ describe Projects::DeploymentsController do
let(:environment) { create(:environment, name: 'production', project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -129,10 +129,10 @@ describe Projects::DeploymentsController do
end
context 'when metrics are enabled' do
- let(:prometheus_service) { double('prometheus_service') }
+ let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
before do
- allow(deployment.project).to receive(:prometheus_service).and_return(prometheus_service)
+ allow(deployment).to receive(:prometheus_adapter).and_return(prometheus_adapter)
end
context 'when environment has no metrics' do
diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb
index 3bf676637a2..53647749a60 100644
--- a/spec/controllers/projects/discussions_controller_spec.rb
+++ b/spec/controllers/projects/discussions_controller_spec.rb
@@ -16,6 +16,53 @@ describe Projects::DiscussionsController do
}
end
+ describe 'GET show' do
+ before do
+ sign_in user
+ end
+
+ context 'when user is not authorized to read the MR' do
+ it 'returns 404' do
+ get :show, request_params, format: :json
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'when user is authorized to read the MR' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'returns status 200' do
+ get :show, request_params, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns status 404 if MR does not exists' do
+ merge_request.destroy!
+
+ get :show, request_params, format: :json
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'when user is authorized but note is LegacyDiffNote' do
+ before do
+ project.add_developer(user)
+ note.update!(type: 'LegacyDiffNote')
+ end
+
+ it 'returns status 200' do
+ get :show, request_params, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+ end
+
describe 'POST resolve' do
before do
sign_in user
@@ -31,7 +78,7 @@ describe Projects::DiscussionsController do
context "when the user is authorized to resolve the discussion" do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context "when the discussion is not resolvable" do
@@ -71,6 +118,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200)
end
+
+ context "when vue_mr_discussions cookie is present" do
+ before do
+ allow(controller).to receive(:cookies).and_return(vue_mr_discussions: 'true')
+ end
+
+ it "renders discussion with serializer" do
+ expect_any_instance_of(DiscussionSerializer).to receive(:represent)
+ .with(instance_of(Discussion), { context: instance_of(described_class) })
+
+ post :resolve, request_params
+ end
+ end
end
end
end
@@ -92,7 +152,7 @@ describe Projects::DiscussionsController do
context "when the user is authorized to resolve the discussion" do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context "when the discussion is not resolvable" do
@@ -119,6 +179,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200)
end
+
+ context "when vue_mr_discussions cookie is present" do
+ before do
+ allow(controller).to receive(:cookies).and_return({ vue_mr_discussions: 'true' })
+ end
+
+ it "renders discussion with serializer" do
+ expect_any_instance_of(DiscussionSerializer).to receive(:represent)
+ .with(instance_of(Discussion), { context: instance_of(described_class) })
+
+ delete :unresolve, request_params
+ end
+ end
end
end
end
diff --git a/spec/controllers/projects/find_file_controller_spec.rb b/spec/controllers/projects/find_file_controller_spec.rb
index 6a5433bcc9c..505fe82851a 100644
--- a/spec/controllers/projects/find_file_controller_spec.rb
+++ b/spec/controllers/projects/find_file_controller_spec.rb
@@ -7,7 +7,7 @@ describe Projects::FindFileController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
controller.instance_variable_set(:@project, project)
end
diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb
index 1bedb8ebdff..c4b32dc3a09 100644
--- a/spec/controllers/projects/forks_controller_spec.rb
+++ b/spec/controllers/projects/forks_controller_spec.rb
@@ -51,7 +51,7 @@ describe Projects::ForksController do
context 'when user is a member of the Project' do
before do
- forked_project.team << [project.creator, :developer]
+ forked_project.add_developer(project.creator)
end
it 'sees the project listed' do
diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb
index 5af03ae118c..c3605555fe7 100644
--- a/spec/controllers/projects/graphs_controller_spec.rb
+++ b/spec/controllers/projects/graphs_controller_spec.rb
@@ -6,7 +6,7 @@ describe Projects::GraphsController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET languages' do
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb
index f8c792cd0f0..5bfc3d31401 100644
--- a/spec/controllers/projects/group_links_controller_spec.rb
+++ b/spec/controllers/projects/group_links_controller_spec.rb
@@ -7,7 +7,7 @@ describe Projects::GroupLinksController do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/hooks_controller_spec.rb b/spec/controllers/projects/hooks_controller_spec.rb
index 07174660f46..2d473d5bf52 100644
--- a/spec/controllers/projects/hooks_controller_spec.rb
+++ b/spec/controllers/projects/hooks_controller_spec.rb
@@ -5,7 +5,7 @@ describe Projects::HooksController do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -18,4 +18,30 @@ describe Projects::HooksController do
)
end
end
+
+ describe '#create' do
+ it 'sets all parameters' do
+ hook_params = {
+ enable_ssl_verification: true,
+ token: "TEST TOKEN",
+ url: "http://example.com",
+
+ push_events: true,
+ tag_push_events: true,
+ merge_requests_events: true,
+ issues_events: true,
+ confidential_issues_events: true,
+ note_events: true,
+ job_events: true,
+ pipeline_events: true,
+ wiki_page_events: true
+ }
+
+ post :create, namespace_id: project.namespace, project_id: project, hook: hook_params
+
+ expect(response).to have_http_status(302)
+ expect(ProjectHook.all.size).to eq(1)
+ expect(ProjectHook.first).to have_attributes(hook_params)
+ end
+ end
end
diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb
index 2a5ec6d584b..7fb4c1b7425 100644
--- a/spec/controllers/projects/imports_controller_spec.rb
+++ b/spec/controllers/projects/imports_controller_spec.rb
@@ -9,7 +9,7 @@ describe Projects::ImportsController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
it 'renders template' do
@@ -30,7 +30,7 @@ describe Projects::ImportsController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
context 'when import is in progress' do
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index a2ef937609b..01b5506b64b 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -37,7 +37,7 @@ describe Projects::IssuesController do
context 'internal issue tracker' do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it_behaves_like "issuables list meta-data", :issue
@@ -69,7 +69,7 @@ describe Projects::IssuesController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
allow(Kaminari.config).to receive(:default_per_page).and_return(1)
end
@@ -102,6 +102,18 @@ describe Projects::IssuesController do
expect(response).to redirect_to(namespace_project_issues_path(page: last_page, state: controller.params[:state], scope: controller.params[:scope]))
end
+
+ it 'does not use pagination if disabled' do
+ allow(controller).to receive(:pagination_disabled?).and_return(true)
+
+ get :index,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ page: (last_page + 1).to_param
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(assigns(:issues).size).to eq(2)
+ end
end
end
@@ -116,7 +128,7 @@ describe Projects::IssuesController do
context 'internal issue tracker' do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'builds a new issue' do
@@ -127,7 +139,7 @@ describe Projects::IssuesController do
it 'fills in an issue for a merge request' do
project_with_repository = create(:project, :repository)
- project_with_repository.team << [user, :developer]
+ project_with_repository.add_developer(user)
mr = create(:merge_request_with_diff_notes, source_project: project_with_repository)
get :new, namespace_id: project_with_repository.namespace, project_id: project_with_repository, merge_request_to_resolve_discussions_of: mr.iid
@@ -153,7 +165,7 @@ describe Projects::IssuesController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
external = double
allow(project).to receive(:external_issue_tracker).and_return(external)
@@ -301,6 +313,53 @@ describe Projects::IssuesController do
end
end
+ describe 'GET #realtime_changes' do
+ def go(id:)
+ get :realtime_changes,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: id
+ end
+
+ context 'when an issue was edited' do
+ before do
+ project.add_developer(user)
+
+ issue.update!(last_edited_by: user, last_edited_at: issue.created_at + 1.minute)
+
+ sign_in(user)
+ end
+
+ it 'returns last edited time' do
+ go(id: issue.iid)
+
+ data = JSON.parse(response.body)
+
+ expect(data).to include('updated_at')
+ expect(data['updated_at']).to eq(issue.last_edited_at.to_time.iso8601)
+ end
+ end
+
+ context 'when an issue was edited by a deleted user' do
+ let(:deleted_user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+
+ 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_gitlab_http_status(200)
+ end
+ end
+ end
+
describe 'Confidential Issues' do
let(:project) { create(:project_empty_repo, :public) }
let(:assignee) { create(:assignee) }
@@ -329,7 +388,7 @@ describe Projects::IssuesController do
it 'does not list confidential issues for project members with guest role' do
sign_in(member)
- project.team << [member, :guest]
+ project.add_guest(member)
get_issues
@@ -354,7 +413,7 @@ describe Projects::IssuesController do
it 'lists confidential issues for project members' do
sign_in(member)
- project.team << [member, :developer]
+ project.add_developer(member)
get_issues
@@ -394,7 +453,7 @@ describe Projects::IssuesController do
it 'returns 404 for project members with guest role' do
sign_in(member)
- project.team << [member, :guest]
+ project.add_guest(member)
go(id: unescaped_parameter_value.to_param)
expect(response).to have_gitlab_http_status :not_found
@@ -416,7 +475,7 @@ describe Projects::IssuesController do
it "returns #{http_status[:success]} for project members" do
sign_in(member)
- project.team << [member, :developer]
+ project.add_developer(member)
go(id: unescaped_parameter_value.to_param)
expect(response).to have_gitlab_http_status http_status[:success]
@@ -450,7 +509,7 @@ describe Projects::IssuesController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it_behaves_like 'restricted action', success: 200
@@ -589,25 +648,6 @@ describe Projects::IssuesController do
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_gitlab_http_status(200)
- end
- end
end
describe 'GET #edit' do
@@ -638,7 +678,7 @@ describe Projects::IssuesController do
def post_new_issue(issue_attrs = {}, additional_params = {})
sign_in(user)
project = create(:project, :public)
- project.team << [user, :developer]
+ project.add_developer(user)
post :create, {
namespace_id: project.namespace.to_param,
@@ -655,7 +695,7 @@ describe Projects::IssuesController do
let(:project) { merge_request.source_project }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
end
@@ -829,7 +869,7 @@ describe Projects::IssuesController do
def post_spam
admin = create(:admin)
create(:user_agent_detail, subject: issue)
- project.team << [admin, :master]
+ project.add_master(admin)
sign_in(admin)
post :mark_as_spam, {
namespace_id: project.namespace,
@@ -934,7 +974,7 @@ describe Projects::IssuesController do
it 'returns discussion json' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
- expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes individual_note])
+ expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolved])
end
context 'with cross-reference system note', :request_store do
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index 7490f8fefce..b9a979044fe 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -1,7 +1,9 @@
+# coding: utf-8
require 'spec_helper'
describe Projects::JobsController do
include ApiHelpers
+ include HttpIOHelpers
let(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_pipeline, project: project) }
@@ -137,8 +139,8 @@ describe Projects::JobsController do
it 'exposes needed information' do
expect(response).to have_gitlab_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['raw_path']).to match(%r{jobs/\d+/raw\z})
+ expect(json_response.dig('merge_request', 'path')).to match(%r{merge_requests/\d+\z})
expect(json_response['new_issue_path'])
.to include('/issues/new')
end
@@ -159,8 +161,19 @@ describe Projects::JobsController do
get_trace
end
+ context 'when job has a trace artifact' do
+ let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) }
+
+ it 'returns a trace' do
+ expect(response).to have_gitlab_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(job.trace.html)
+ end
+ end
+
context 'when job has a trace' do
- let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) }
it 'returns a trace' do
expect(response).to have_gitlab_http_status(:ok)
@@ -182,7 +195,7 @@ describe Projects::JobsController do
end
context 'when job has a trace with ANSI sequence and Unicode' do
- let(:job) { create(:ci_build, :unicode_trace, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :unicode_trace_live, pipeline: pipeline) }
it 'returns a trace with Unicode' do
expect(response).to have_gitlab_http_status(:ok)
@@ -192,6 +205,41 @@ describe Projects::JobsController do
end
end
+ context 'when trace artifact is in ObjectStorage' do
+ let!(:job) { create(:ci_build, :success, :trace_artifact, pipeline: pipeline) }
+
+ before do
+ allow_any_instance_of(JobArtifactUploader).to receive(:file_storage?) { false }
+ allow_any_instance_of(JobArtifactUploader).to receive(:url) { remote_trace_url }
+ allow_any_instance_of(JobArtifactUploader).to receive(:size) { remote_trace_size }
+ end
+
+ context 'when there are no network issues' do
+ before do
+ stub_remote_trace_206
+
+ get_trace
+ end
+
+ it 'returns a trace' do
+ expect(response).to have_gitlab_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(job.trace.html)
+ end
+ end
+
+ context 'when there is a network issue' do
+ before do
+ stub_remote_trace_500
+ end
+
+ it 'returns a trace' do
+ expect { get_trace }.to raise_error(Gitlab::Ci::Trace::HttpIO::FailedToGetChunkError)
+ end
+ end
+ end
+
def get_trace
get :trace, namespace_id: project.namespace,
project_id: project,
@@ -374,14 +422,14 @@ describe Projects::JobsController do
let(:role) { :master }
before do
- project.team << [user, role]
+ project.add_role(user, role)
sign_in(user)
post_erase
end
context 'when job is erasable' do
- let(:job) { create(:ci_build, :erasable, :trace, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline) }
it 'redirects to the erased job page' do
expect(response).to have_gitlab_http_status(:found)
@@ -408,7 +456,7 @@ describe Projects::JobsController do
context 'when user is developer' do
let(:role) { :developer }
- let(:job) { create(:ci_build, :erasable, :trace, pipeline: pipeline, user: triggered_by) }
+ let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline, user: triggered_by) }
context 'when triggered by same user' do
let(:triggered_by) { user }
@@ -435,32 +483,73 @@ describe Projects::JobsController do
end
describe 'GET raw' do
- before do
- get_raw
+ subject do
+ post :raw, namespace_id: project.namespace,
+ project_id: project,
+ id: job.id
+ end
+
+ context 'when job has a trace artifact' do
+ let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) }
+
+ it 'returns a trace' do
+ response = subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.content_type).to eq 'text/plain; charset=utf-8'
+ expect(response.body).to eq job.job_artifacts_trace.open.read
+ end
end
context 'when job has a trace file' do
- let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) }
it 'send a trace file' do
+ response = subject
+
expect(response).to have_gitlab_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 has a trace in database' do
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+
+ before do
+ job.update_column(:trace, 'Sample trace')
+ end
+
+ it 'send a trace file' do
+ response = subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.content_type).to eq 'text/plain; charset=utf-8'
+ expect(response.body).to eq 'Sample 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_gitlab_http_status(:not_found)
+ response = subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq ''
end
end
- def get_raw
- post :raw, namespace_id: project.namespace,
- project_id: project,
- id: job.id
+ context 'when the trace artifact is in ObjectStorage' do
+ let!(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) }
+
+ before do
+ allow_any_instance_of(JobArtifactUploader).to receive(:file_storage?) { false }
+ end
+
+ it 'redirect to the trace file url' do
+ expect(subject).to redirect_to(job.job_artifacts_trace.file.url)
+ end
end
end
end
diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb
index cf83f2f3265..452d7e23983 100644
--- a/spec/controllers/projects/labels_controller_spec.rb
+++ b/spec/controllers/projects/labels_controller_spec.rb
@@ -6,7 +6,7 @@ describe Projects::LabelsController do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/mattermosts_controller_spec.rb b/spec/controllers/projects/mattermosts_controller_spec.rb
index 33d48ff94d1..c5ac0be27bb 100644
--- a/spec/controllers/projects/mattermosts_controller_spec.rb
+++ b/spec/controllers/projects/mattermosts_controller_spec.rb
@@ -5,7 +5,7 @@ describe Projects::MattermostsController do
let!(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/merge_requests/creations_controller_spec.rb b/spec/controllers/projects/merge_requests/creations_controller_spec.rb
index 7fdddc02fd3..24310b847e8 100644
--- a/spec/controllers/projects/merge_requests/creations_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/creations_controller_spec.rb
@@ -4,27 +4,42 @@ describe Projects::MergeRequests::CreationsController do
let(:project) { create(:project, :repository) }
let(:user) { project.owner }
let(:fork_project) { create(:forked_project_with_submodules) }
+ let(:get_diff_params) do
+ {
+ namespace_id: fork_project.namespace.to_param,
+ project_id: fork_project,
+ merge_request: {
+ source_branch: 'remove-submodule',
+ target_branch: 'master'
+ }
+ }
+ end
before do
- fork_project.team << [user, :master]
-
+ fork_project.add_master(user)
+ Projects::ForkService.new(project, user).execute(fork_project)
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'
- }
+ get :new, get_diff_params
+
+ expect(response).to be_success
+ end
+ end
+ end
+
+ describe 'GET diffs' do
+ context 'when merge request cannot be created' do
+ it 'does not assign diffs var' do
+ allow_any_instance_of(MergeRequest).to receive(:can_be_created).and_return(false)
+
+ get :diffs, get_diff_params.merge(format: 'json')
expect(response).to be_success
+ expect(assigns[:diffs]).to be_nil
end
end
end
@@ -37,14 +52,7 @@ describe Projects::MergeRequests::CreationsController do
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
+ get :pipelines, get_diff_params.merge(format: 'json')
expect(response).to be_ok
expect(json_response).to have_key 'pipelines'
@@ -86,7 +94,7 @@ describe Projects::MergeRequests::CreationsController do
let(:other_project) { create(:project, :repository) }
before do
- other_project.team << [user, :master]
+ other_project.add_master(user)
end
context 'when the path exists in the diff' do
@@ -117,4 +125,66 @@ describe Projects::MergeRequests::CreationsController do
end
end
end
+
+ describe 'GET #branch_to' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ end
+
+ it 'fetches the commit if a user has access' do
+ expect(Ability).to receive(:allowed?).with(user, :read_project, project) { true }
+
+ get :branch_to,
+ namespace_id: fork_project.namespace,
+ project_id: fork_project,
+ target_project_id: project.id,
+ ref: 'master'
+
+ expect(assigns(:commit)).not_to be_nil
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'does not load the commit when the user cannot read the project' do
+ expect(Ability).to receive(:allowed?).with(user, :read_project, project) { false }
+
+ get :branch_to,
+ namespace_id: fork_project.namespace,
+ project_id: fork_project,
+ target_project_id: project.id,
+ ref: 'master'
+
+ expect(assigns(:commit)).to be_nil
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ describe 'GET #update_branches' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ end
+
+ it 'lists the branches of another fork if the user has access' do
+ expect(Ability).to receive(:allowed?).with(user, :read_project, project) { true }
+
+ get :update_branches,
+ namespace_id: fork_project.namespace,
+ project_id: fork_project,
+ target_project_id: project.id
+
+ expect(assigns(:target_branches)).not_to be_empty
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'does not list branches when the user cannot read the project' do
+ expect(Ability).to receive(:allowed?).with(user, :read_project, project) { false }
+
+ get :update_branches,
+ namespace_id: fork_project.namespace,
+ project_id: fork_project,
+ target_project_id: project.id
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(assigns(:target_branches)).to eq([])
+ 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
index ba97ccfbbd4..5d297c654bf 100644
--- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
@@ -151,7 +151,7 @@ describe Projects::MergeRequests::DiffsController do
let(:other_project) { create(:project) }
before do
- other_project.team << [user, :master]
+ other_project.add_master(user)
diff_for_path(old_path: existing_path, new_path: existing_path, project_id: other_project)
end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 45c424af8c4..c8cc6b374f6 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -684,4 +684,62 @@ describe Projects::MergeRequestsController do
format: :json
end
end
+
+ describe 'POST #rebase' do
+ let(:viewer) { user }
+
+ def post_rebase
+ post :rebase, namespace_id: project.namespace, project_id: project, id: merge_request
+ end
+
+ def expect_rebase_worker_for(user)
+ expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id)
+ end
+
+ context 'successfully' do
+ it 'enqeues a RebaseWorker' do
+ expect_rebase_worker_for(viewer)
+
+ post_rebase
+
+ expect(response.status).to eq(200)
+ end
+ end
+
+ context 'with a forked project' do
+ let(:fork_project) { create(:project, :repository, forked_from_project: project) }
+ let(:fork_owner) { fork_project.owner }
+
+ before do
+ merge_request.update!(source_project: fork_project)
+ fork_project.add_reporter(user)
+ end
+
+ context 'user cannot push to source branch' do
+ it 'returns 404' do
+ expect_rebase_worker_for(viewer).never
+
+ post_rebase
+
+ expect(response.status).to eq(404)
+ end
+ end
+
+ context 'user can push to source branch' do
+ before do
+ project.add_reporter(fork_owner)
+
+ sign_in(fork_owner)
+ end
+
+ it 'returns 200' do
+ expect_rebase_worker_for(fork_owner)
+
+ post_rebase
+
+ expect(response.status).to eq(200)
+ end
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb
index 209979e642d..548c5ef36e7 100644
--- a/spec/controllers/projects/milestones_controller_spec.rb
+++ b/spec/controllers/projects/milestones_controller_spec.rb
@@ -11,7 +11,7 @@ describe Projects::MilestonesController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
controller.instance_variable_set(:@project, project)
end
@@ -20,14 +20,23 @@ describe Projects::MilestonesController do
describe "#show" do
render_views
- def view_milestone
- get :show, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid
+ def view_milestone(options = {})
+ params = { namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid }
+ get :show, params.merge(options)
end
it 'shows milestone page' do
view_milestone
expect(response).to have_gitlab_http_status(200)
+ expect(response.content_type).to eq 'text/html'
+ end
+
+ it 'returns milestone json' do
+ view_milestone format: :json
+
+ expect(response).to have_http_status(404)
+ expect(response.content_type).to eq 'application/json'
end
end
@@ -98,10 +107,8 @@ describe Projects::MilestonesController do
it 'shows group milestone' do
post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid
- group_milestone = assigns(:milestone)
-
- expect(response).to redirect_to(group_milestone_path(project.group, group_milestone.iid))
- expect(flash[:notice]).to eq('Milestone has been promoted to group milestone.')
+ expect(flash[:notice]).to eq("#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, milestone.iid)}\">group milestone</a>.")
+ expect(response).to redirect_to(project_milestones_path(project))
end
end
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index 37e9f863fc4..de132dfaa21 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -32,7 +32,7 @@ describe Projects::NotesController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'passes last_fetched_at from headers to NotesFinder' do
@@ -351,7 +351,7 @@ describe Projects::NotesController do
before do
sign_in(note.author)
- project.team << [note.author, :developer]
+ project.add_developer(note.author)
end
it "updates the note" do
@@ -372,7 +372,7 @@ describe Projects::NotesController do
context 'user is the author of a note' do
before do
sign_in(note.author)
- project.team << [note.author, :developer]
+ project.add_developer(note.author)
end
it "returns status 200 for html" do
@@ -389,7 +389,7 @@ describe Projects::NotesController do
context 'user is not the author of a note' do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it "returns status 404" do
@@ -403,7 +403,7 @@ describe Projects::NotesController 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
@@ -445,7 +445,7 @@ describe Projects::NotesController do
context "when the user is authorized to resolve the note" do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context "when the note is not resolvable" do
@@ -506,7 +506,7 @@ describe Projects::NotesController do
context "when the user is authorized to resolve the note" do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context "when the note is not resolvable" do
diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb
index 4705c50de7e..11f54eef531 100644
--- a/spec/controllers/projects/pages_controller_spec.rb
+++ b/spec/controllers/projects/pages_controller_spec.rb
@@ -65,4 +65,41 @@ describe Projects::PagesController do
end
end
end
+
+ describe 'PATCH update' do
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ project: { pages_https_only: false }
+ }
+ end
+
+ let(:update_service) { double(execute: { status: :success }) }
+
+ before do
+ allow(Projects::UpdateService).to receive(:new) { update_service }
+ end
+
+ it 'returns 302 status' do
+ patch :update, request_params
+
+ expect(response).to have_gitlab_http_status(:found)
+ end
+
+ it 'redirects back to the pages settings' do
+ patch :update, request_params
+
+ expect(response).to redirect_to(project_pages_path(project))
+ end
+
+ it 'calls the update service' do
+ expect(Projects::UpdateService)
+ .to receive(:new)
+ .with(project, user, request_params[:project])
+ .and_return(update_service)
+
+ patch :update, request_params
+ end
+ end
end
diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb
index e9e7d357d9c..d4058a5c515 100644
--- a/spec/controllers/projects/pages_domains_controller_spec.rb
+++ b/spec/controllers/projects/pages_domains_controller_spec.rb
@@ -13,7 +13,7 @@ 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)
+ build(:pages_domain, domain: 'my.otherdomain.com').slice(:key, :certificate, :domain)
end
before do
@@ -46,8 +46,107 @@ describe Projects::PagesDomainsController do
post(:create, request_params.merge(pages_domain: pages_domain_params))
end.to change { PagesDomain.count }.by(1)
+ created_domain = PagesDomain.reorder(:id).last
+
+ expect(created_domain).to be_present
+ expect(response).to redirect_to(project_pages_domain_path(project, created_domain))
+ end
+ end
+
+ describe 'GET edit' do
+ it "displays the 'edit' page" do
+ get(:edit, request_params.merge(id: pages_domain.domain))
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to render_template('edit')
+ end
+ end
+
+ describe 'PATCH update' do
+ before do
+ controller.instance_variable_set(:@domain, pages_domain)
+ end
+
+ let(:pages_domain_params) do
+ attributes_for(:pages_domain).slice(:key, :certificate)
+ end
+
+ let(:params) do
+ request_params.merge(id: pages_domain.domain, pages_domain: pages_domain_params)
+ end
+
+ it 'updates the domain' do
+ expect(pages_domain)
+ .to receive(:update)
+ .with(pages_domain_params)
+ .and_return(true)
+
+ patch(:update, params)
+ end
+
+ it 'redirects to the project page' do
+ patch(:update, params)
+
+ expect(flash[:notice]).to eq 'Domain was updated'
expect(response).to redirect_to(project_pages_path(project))
end
+
+ context 'the domain is invalid' do
+ it 'renders the edit action' do
+ allow(pages_domain).to receive(:update).and_return(false)
+
+ patch(:update, params)
+
+ expect(response).to render_template('edit')
+ end
+ end
+
+ context 'the parameters include the domain' do
+ it 'renders 400 Bad Request' do
+ expect(pages_domain)
+ .to receive(:update)
+ .with(hash_not_including(:domain))
+ .and_return(true)
+
+ patch(:update, params.deep_merge(pages_domain: { domain: 'abc' }))
+ end
+ end
+ end
+
+ describe 'POST verify' do
+ let(:params) { request_params.merge(id: pages_domain.domain) }
+
+ def stub_service
+ service = double(:service)
+
+ expect(VerifyPagesDomainService).to receive(:new) { service }
+
+ service
+ end
+
+ it 'handles verification success' do
+ expect(stub_service).to receive(:execute).and_return(status: :success)
+
+ post :verify, params
+
+ expect(response).to redirect_to project_pages_domain_path(project, pages_domain)
+ expect(flash[:notice]).to eq('Successfully verified domain ownership')
+ end
+
+ it 'handles verification failure' do
+ expect(stub_service).to receive(:execute).and_return(status: :failed)
+
+ post :verify, params
+
+ expect(response).to redirect_to project_pages_domain_path(project, pages_domain)
+ expect(flash[:alert]).to eq('Failed to verify domain ownership')
+ end
+
+ it 'returns a 404 response for an unknown domain' do
+ post :verify, request_params.merge(id: 'unknown-domain')
+
+ expect(response).to have_gitlab_http_status(404)
+ end
end
describe 'DELETE destroy' do
diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
index 966ffdf6996..3506305f755 100644
--- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb
+++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb
@@ -80,7 +80,7 @@ describe Projects::PipelineSchedulesController do
context 'when variables_attributes has one variable' do
let(:schedule) do
basic_param.merge({
- variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
+ variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }]
})
end
@@ -101,7 +101,8 @@ describe Projects::PipelineSchedulesController do
context 'when variables_attributes has two variables and duplicated' do
let(:schedule) do
basic_param.merge({
- variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }]
+ variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' },
+ { key: 'AAA', secret_value: 'BBB123' }]
})
end
@@ -152,7 +153,7 @@ describe Projects::PipelineSchedulesController do
context 'when params include one variable' do
let(:schedule) do
basic_param.merge({
- variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
+ variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }]
})
end
@@ -169,7 +170,8 @@ describe Projects::PipelineSchedulesController do
context 'when params include two duplicated variables' do
let(:schedule) do
basic_param.merge({
- variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }]
+ variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' },
+ { key: 'AAA', secret_value: 'BBB123' }]
})
end
@@ -194,7 +196,7 @@ describe Projects::PipelineSchedulesController do
context 'when adds a new variable' do
let(:schedule) do
basic_param.merge({
- variables_attributes: [{ key: 'AAA', value: 'AAA123' }]
+ variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }]
})
end
@@ -209,7 +211,7 @@ describe Projects::PipelineSchedulesController do
context 'when adds a new duplicated variable' do
let(:schedule) do
basic_param.merge({
- variables_attributes: [{ key: 'CCC', value: 'AAA123' }]
+ variables_attributes: [{ key: 'CCC', secret_value: 'AAA123' }]
})
end
@@ -224,7 +226,7 @@ describe Projects::PipelineSchedulesController do
context 'when updates a variable' do
let(:schedule) do
basic_param.merge({
- variables_attributes: [{ id: pipeline_schedule_variable.id, value: 'new_value' }]
+ variables_attributes: [{ id: pipeline_schedule_variable.id, secret_value: 'new_value' }]
})
end
@@ -252,7 +254,7 @@ describe Projects::PipelineSchedulesController do
let(:schedule) do
basic_param.merge({
variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true },
- { key: 'CCC', value: 'CCC123' }]
+ { key: 'CCC', secret_value: 'CCC123' }]
})
end
diff --git a/spec/controllers/projects/pipelines_settings_controller_spec.rb b/spec/controllers/projects/pipelines_settings_controller_spec.rb
index 1cc488bef32..694896b6bcf 100644
--- a/spec/controllers/projects/pipelines_settings_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_settings_controller_spec.rb
@@ -11,60 +11,11 @@ describe Projects::PipelinesSettingsController do
sign_in(user)
end
- describe 'PATCH update' do
- subject do
- patch :update,
- namespace_id: project.namespace.to_param,
- project_id: project,
- project: {
- auto_devops_attributes: params
- }
- end
-
- context 'when updating the auto_devops settings' do
- let(:params) { { enabled: '', domain: 'mepmep.md' } }
-
- it 'redirects to the settings page' do
- subject
-
- expect(response).to have_gitlab_http_status(302)
- expect(flash[:notice]).to eq("Pipelines settings for '#{project.name}' were successfully updated.")
- end
-
- context 'following the instance default' do
- let(:params) { { enabled: '' } }
-
- it 'allows enabled to be set to nil' do
- subject
- project_auto_devops.reload
-
- expect(project_auto_devops.enabled).to be_nil
- end
- end
-
- context 'when run_auto_devops_pipeline is true' do
- before do
- expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(true)
- end
-
- it 'queues a CreatePipelineWorker' do
- expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
-
- subject
- end
- end
-
- context 'when run_auto_devops_pipeline is not true' do
- before do
- expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(false)
- end
-
- it 'does not queue a CreatePipelineWorker' do
- expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, :web, any_args)
+ describe 'GET show' do
+ it 'redirects with 302 status code' do
+ get :show, namespace_id: project.namespace, project_id: project
- subject
- end
- end
+ expect(response).to have_gitlab_http_status(302)
end
end
end
diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb
index 290dba0610a..46b08a03b19 100644
--- a/spec/controllers/projects/project_members_controller_spec.rb
+++ b/spec/controllers/projects/project_members_controller_spec.rb
@@ -21,7 +21,7 @@ describe Projects::ProjectMembersController do
context 'when user does not have enough rights' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'returns 404' do
@@ -37,7 +37,7 @@ describe Projects::ProjectMembersController do
context 'when user has enough rights' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'adds user to members' do
@@ -106,7 +106,7 @@ describe Projects::ProjectMembersController do
context 'when member is found' do
context 'when user does not have enough rights' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'returns 404' do
@@ -121,7 +121,7 @@ describe Projects::ProjectMembersController do
context 'when user has enough rights' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it '[HTML] removes user from members' do
@@ -164,7 +164,7 @@ describe Projects::ProjectMembersController do
context 'when member is found' do
context 'and is not an owner' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'removes user from members' do
@@ -181,7 +181,7 @@ describe Projects::ProjectMembersController do
let(:project) { create(:project, namespace: user.namespace) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'cannot remove himself from the project' do
@@ -248,7 +248,7 @@ describe Projects::ProjectMembersController do
context 'when member is found' do
context 'when user does not have enough rights' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'returns 404' do
@@ -263,7 +263,7 @@ describe Projects::ProjectMembersController do
context 'when user has enough rights' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'adds user to members' do
@@ -285,8 +285,8 @@ describe Projects::ProjectMembersController do
let(:member) { create(:user) }
before do
- project.team << [user, :master]
- another_project.team << [member, :guest]
+ project.add_master(user)
+ another_project.add_guest(member)
sign_in(user)
end
@@ -300,7 +300,7 @@ describe Projects::ProjectMembersController do
context 'when user can access source project members' do
before do
- another_project.team << [user, :guest]
+ another_project.add_guest(user)
end
include_context 'import applied'
@@ -332,7 +332,7 @@ describe Projects::ProjectMembersController do
context 'when creating owner' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -348,7 +348,7 @@ describe Projects::ProjectMembersController do
context 'when create master' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/prometheus/metrics_controller_spec.rb b/spec/controllers/projects/prometheus/metrics_controller_spec.rb
new file mode 100644
index 00000000000..b2b245dba90
--- /dev/null
+++ b/spec/controllers/projects/prometheus/metrics_controller_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe Projects::Prometheus::MetricsController do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ describe 'GET #active_common' do
+ before do
+ allow(controller).to receive(:prometheus_adapter).and_return(prometheus_adapter)
+ end
+
+ context 'when prometheus metrics are enabled' do
+ context 'when data is not present' do
+ before do
+ allow(prometheus_adapter).to receive(:query).with(:matched_metrics).and_return({})
+ end
+
+ it 'returns no content response' do
+ get :active_common, project_params(format: :json)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+ end
+
+ context 'when data is available' do
+ let(:sample_response) { { some_data: 1 } }
+
+ before do
+ allow(prometheus_adapter).to receive(:query).with(:matched_metrics).and_return(sample_response)
+ end
+
+ it 'returns no content response' do
+ get :active_common, project_params(format: :json)
+
+ expect(response).to have_gitlab_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_common, project_params
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+ end
+
+ describe '#prometheus_adapter' do
+ before do
+ allow(controller).to receive(:project).and_return(project)
+ end
+
+ it 'calls prometheus adapter service' do
+ expect_any_instance_of(::Prometheus::AdapterService).to receive(:prometheus_adapter)
+
+ subject.__send__(:prometheus_adapter)
+ end
+ end
+
+ def project_params(opts = {})
+ opts.reverse_merge(namespace_id: project.namespace, project_id: project)
+ end
+end
diff --git a/spec/controllers/projects/prometheus_controller_spec.rb b/spec/controllers/projects/prometheus_controller_spec.rb
deleted file mode 100644
index bbfe78d305a..00000000000
--- a/spec/controllers/projects/prometheus_controller_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-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_gitlab_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_gitlab_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_gitlab_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/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb
index 80be135b5d8..096e29bc39f 100644
--- a/spec/controllers/projects/protected_branches_controller_spec.rb
+++ b/spec/controllers/projects/protected_branches_controller_spec.rb
@@ -1,6 +1,16 @@
require('spec_helper')
describe Projects::ProtectedBranchesController do
+ let(:project) { create(:project, :repository) }
+ let(:protected_branch) { create(:protected_branch, project: project) }
+ let(:project_params) { { namespace_id: project.namespace.to_param, project_id: project } }
+ let(:base_params) { project_params.merge(id: protected_branch.id) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ end
+
describe "GET #index" do
let(:project) { create(:project_empty_repo, :public) }
@@ -8,4 +18,91 @@ describe Projects::ProtectedBranchesController do
get(:index, namespace_id: project.namespace.to_param, project_id: project)
end
end
+
+ describe "POST #create" do
+ let(:master_access_level) { [{ access_level: Gitlab::Access::MASTER }] }
+ let(:access_level_params) do
+ { merge_access_levels_attributes: master_access_level,
+ push_access_levels_attributes: master_access_level }
+ end
+ let(:create_params) { attributes_for(:protected_branch).merge(access_level_params) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'creates the protected branch rule' do
+ expect do
+ post(:create, project_params.merge(protected_branch: create_params))
+ end.to change(ProtectedBranch, :count).by(1)
+ end
+
+ context 'when a policy restricts rule deletion' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents creation of the protected branch rule" do
+ post(:create, project_params.merge(protected_branch: create_params))
+
+ expect(ProtectedBranch.count).to eq 0
+ end
+ end
+ end
+
+ describe "PUT #update" do
+ let(:update_params) { { name: 'new_name' } }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'updates the protected branch rule' do
+ put(:update, base_params.merge(protected_branch: update_params))
+
+ expect(protected_branch.reload.name).to eq('new_name')
+ expect(json_response["name"]).to eq('new_name')
+ end
+
+ context 'when a policy restricts rule deletion' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents update of the protected branch rule" do
+ old_name = protected_branch.name
+
+ put(:update, base_params.merge(protected_branch: update_params))
+
+ expect(protected_branch.reload.name).to eq(old_name)
+ end
+ end
+ end
+
+ describe "DELETE #destroy" do
+ before do
+ sign_in(user)
+ end
+
+ it "deletes the protected branch rule" do
+ delete(:destroy, base_params)
+
+ expect { ProtectedBranch.find(protected_branch.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ context 'when a policy restricts rule deletion' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents deletion of the protected branch rule" do
+ delete(:destroy, base_params)
+
+ expect(response.status).to eq(403)
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb
index 3a0c3faa7b4..08e2ccf893a 100644
--- a/spec/controllers/projects/raw_controller_spec.rb
+++ b/spec/controllers/projects/raw_controller_spec.rb
@@ -8,10 +8,7 @@ describe Projects::RawController do
let(:id) { 'master/README.md' }
it 'delivers ASCII file' do
- get(:show,
- namespace_id: public_project.namespace.to_param,
- project_id: public_project,
- id: id)
+ get_show(public_project, id)
expect(response).to have_gitlab_http_status(200)
expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8')
@@ -25,10 +22,7 @@ describe Projects::RawController do
let(:id) { 'master/files/images/6049019_460s.jpg' }
it 'sets image content type header' do
- get(:show,
- namespace_id: public_project.namespace.to_param,
- project_id: public_project,
- id: id)
+ get_show(public_project, id)
expect(response).to have_gitlab_http_status(200)
expect(response.header['Content-Type']).to eq('image/jpeg')
@@ -53,22 +47,41 @@ describe Projects::RawController do
end
it 'serves the file' do
- expect(controller).to receive(:send_file).with("#{Gitlab.config.shared.path}/lfs-objects/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: 'lfs_object.iso', disposition: 'attachment')
- get(:show,
- namespace_id: public_project.namespace.to_param,
- project_id: public_project,
- id: id)
+ expect(controller).to receive(:send_file).with("#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: 'lfs_object.iso', disposition: 'attachment')
+ get_show(public_project, id)
expect(response).to have_gitlab_http_status(200)
end
+
+ context 'and lfs uses object storage' do
+ before do
+ lfs_object.file = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png")
+ lfs_object.save!
+ stub_lfs_object_storage
+ lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
+ end
+
+ it 'responds with redirect to file' do
+ get_show(public_project, id)
+
+ expect(response).to have_gitlab_http_status(302)
+ expect(response.location).to include(lfs_object.reload.file.path)
+ end
+
+ it 'sets content disposition' do
+ get_show(public_project, id)
+
+ file_uri = URI.parse(response.location)
+ params = CGI.parse(file_uri.query)
+
+ expect(params["response-content-disposition"].first).to eq 'attachment;filename="lfs_object.iso"'
+ end
+ end
end
context 'when project does not have access' do
it 'does not serve the file' do
- get(:show,
- namespace_id: public_project.namespace.to_param,
- project_id: public_project,
- id: id)
+ get_show(public_project, id)
expect(response).to have_gitlab_http_status(404)
end
@@ -81,10 +94,7 @@ describe Projects::RawController do
end
it 'delivers ASCII file' do
- get(:show,
- namespace_id: public_project.namespace.to_param,
- project_id: public_project,
- id: id)
+ get_show(public_project, id)
expect(response).to have_gitlab_http_status(200)
expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8')
@@ -95,4 +105,10 @@ describe Projects::RawController do
end
end
end
+
+ def get_show(project, id)
+ get(:show, namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: id)
+ end
end
diff --git a/spec/controllers/projects/refs_controller_spec.rb b/spec/controllers/projects/refs_controller_spec.rb
index 748ae040928..ceaffd92623 100644
--- a/spec/controllers/projects/refs_controller_spec.rb
+++ b/spec/controllers/projects/refs_controller_spec.rb
@@ -6,7 +6,7 @@ describe Projects::RefsController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
describe 'GET #logs_tree' do
diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb
index 358f26dfb02..fc1619acec6 100644
--- a/spec/controllers/projects/releases_controller_spec.rb
+++ b/spec/controllers/projects/releases_controller_spec.rb
@@ -7,7 +7,7 @@ describe Projects::ReleasesController do
let!(:tag) { release.tag }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb
index 8b777eb68ca..c3b71458e38 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", ref: 'master'
+ get :archive, namespace_id: project.namespace, project_id: project, id: "master", format: "zip"
expect(response.header["Content-Type"]).to start_with('text/html')
expect(response).to be_redirect
@@ -17,12 +17,25 @@ describe Projects::RepositoriesController do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
end
it "uses Gitlab::Workhorse" do
- get :archive, namespace_id: project.namespace, project_id: project, ref: "master", format: "zip"
+ get :archive, namespace_id: project.namespace, project_id: project, id: "master", format: "zip"
+
+ expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
+ end
+
+ it 'responds with redirect to the short name archive if fully qualified' do
+ get :archive, namespace_id: project.namespace, project_id: project, id: "master/#{project.path}-master", format: "zip"
+
+ expect(assigns(:ref)).to eq("master")
+ expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
+ end
+
+ it 'handles legacy queries with no ref' do
+ get :archive, namespace_id: project.namespace, project_id: project, format: "zip"
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
@@ -33,7 +46,7 @@ describe Projects::RepositoriesController do
end
it "renders Not Found" do
- get :archive, namespace_id: project.namespace, project_id: project, ref: "master", format: "zip"
+ get :archive, namespace_id: project.namespace, project_id: project, id: "master", format: "zip"
expect(response).to have_gitlab_http_status(404)
end
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index a907da2b60f..e4dc61b3a68 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -9,7 +9,7 @@ describe Projects::ServicesController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
describe '#test' do
@@ -23,6 +23,18 @@ describe Projects::ServicesController do
end
end
+ context 'when validations fail' do
+ let(:service_params) { { active: 'true', token: '' } }
+
+ it 'returns error messages in JSON response' do
+ put :test, namespace_id: project.namespace, project_id: project, id: :hipchat, service: service_params
+
+ expect(json_response['message']).to eq "Validations failed."
+ expect(json_response['service_response']).to eq "Token can't be blank"
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
context 'success' do
context 'with empty project' do
let(:project) { create(:project) }
@@ -114,5 +126,41 @@ describe Projects::ServicesController do
expect(flash[:notice]).to eq 'HipChat settings saved, but not activated.'
end
end
+
+ context 'with a deprecated service' do
+ let(:service) { create(:kubernetes_service, project: project) }
+
+ before do
+ put :update,
+ namespace_id: project.namespace, project_id: project, id: service.to_param, service: { namespace: 'updated_namespace' }
+ end
+
+ it 'should not update the service' do
+ service.reload
+ expect(service.namespace).not_to eq('updated_namespace')
+ end
+ end
+ end
+
+ describe "GET #edit" do
+ before do
+ get :edit, namespace_id: project.namespace, project_id: project, id: service_id
+ end
+
+ context 'with approved services' do
+ let(:service_id) { 'jira' }
+
+ it 'should render edit page' do
+ expect(response).to be_success
+ end
+ end
+
+ context 'with a deprecated service' do
+ let(:service_id) { 'kubernetes' }
+
+ it 'should render edit page' do
+ expect(response).to be_success
+ end
+ 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 b8fe0f46f57..7dae9b85d78 100644
--- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb
+++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
@@ -1,11 +1,12 @@
require('spec_helper')
describe Projects::Settings::CiCdController do
- let(:project) { create(:project, :public, :access_requestable) }
- let(:user) { create(:user) }
+ set(:user) { create(:user) }
+ set(:project_auto_devops) { create(:project_auto_devops) }
+ let(:project) { project_auto_devops.project }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -17,4 +18,145 @@ describe Projects::Settings::CiCdController do
expect(response).to render_template(:show)
end
end
+
+ describe '#reset_cache' do
+ before do
+ sign_in(user)
+
+ project.add_master(user)
+
+ allow(ResetProjectCacheService).to receive_message_chain(:new, :execute).and_return(true)
+ end
+
+ subject { post :reset_cache, namespace_id: project.namespace, project_id: project, format: :json }
+
+ it 'calls reset project cache service' do
+ expect(ResetProjectCacheService).to receive_message_chain(:new, :execute)
+
+ subject
+ end
+
+ context 'when service returns successfully' do
+ it 'returns a success header' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when service does not return successfully' do
+ before do
+ allow(ResetProjectCacheService).to receive_message_chain(:new, :execute).and_return(false)
+ end
+
+ it 'returns an error header' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
+ describe 'PATCH update' do
+ let(:params) { { ci_config_path: '' } }
+
+ subject do
+ patch :update,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ project: params
+ end
+
+ it 'redirects to the settings page' do
+ subject
+
+ expect(response).to have_gitlab_http_status(302)
+ expect(flash[:notice]).to eq("Pipelines settings for '#{project.name}' were successfully updated.")
+ end
+
+ context 'when updating the auto_devops settings' do
+ let(:params) { { auto_devops_attributes: { enabled: '', domain: 'mepmep.md' } } }
+
+ context 'following the instance default' do
+ let(:params) { { auto_devops_attributes: { enabled: '' } } }
+
+ it 'allows enabled to be set to nil' do
+ subject
+ project_auto_devops.reload
+
+ expect(project_auto_devops.enabled).to be_nil
+ end
+ end
+
+ context 'when run_auto_devops_pipeline is true' do
+ before do
+ expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(true)
+ end
+
+ context 'when the project repository is empty' do
+ it 'sets a warning flash' do
+ expect(subject).to set_flash[:warning]
+ end
+
+ it 'does not queue a CreatePipelineWorker' do
+ expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
+
+ subject
+ end
+ end
+
+ context 'when the project repository is not empty' do
+ let(:project) { create(:project, :repository) }
+
+ it 'sets a success flash' do
+ allow(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
+
+ expect(subject).to set_flash[:success]
+ end
+
+ it 'queues a CreatePipelineWorker' do
+ expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
+
+ subject
+ end
+ end
+ end
+
+ context 'when run_auto_devops_pipeline is not true' do
+ before do
+ expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(false)
+ end
+
+ it 'does not queue a CreatePipelineWorker' do
+ expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, :web, any_args)
+
+ subject
+ end
+ end
+ end
+
+ context 'when updating general settings' do
+ context 'when build_timeout_human_readable is not specified' do
+ let(:params) { { build_timeout_human_readable: '' } }
+
+ it 'set default timeout' do
+ subject
+
+ project.reload
+ expect(project.build_timeout).to eq(3600)
+ end
+ end
+
+ context 'when build_timeout_human_readable is specified' do
+ let(:params) { { build_timeout_human_readable: '1h 30m' } }
+
+ it 'set specified timeout' do
+ subject
+
+ project.reload
+ expect(project.build_timeout).to eq(5400)
+ end
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/settings/integrations_controller_spec.rb b/spec/controllers/projects/settings/integrations_controller_spec.rb
index 3068837f394..77df9a6f567 100644
--- a/spec/controllers/projects/settings/integrations_controller_spec.rb
+++ b/spec/controllers/projects/settings/integrations_controller_spec.rb
@@ -5,7 +5,7 @@ describe Projects::Settings::IntegrationsController do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb
index 70e7f9ca96e..8fcfa3c9ecd 100644
--- a/spec/controllers/projects/templates_controller_spec.rb
+++ b/spec/controllers/projects/templates_controller_spec.rb
@@ -8,7 +8,7 @@ describe Projects::TemplatesController do
let(:body) { JSON.parse(response.body) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
end
diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb
index 4622e27e60f..1ce7e84bef9 100644
--- a/spec/controllers/projects/todos_controller_spec.rb
+++ b/spec/controllers/projects/todos_controller_spec.rb
@@ -20,7 +20,7 @@ describe Projects::TodosController do
context 'when authorized' do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'creates todo for issue' do
@@ -36,7 +36,7 @@ describe Projects::TodosController do
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(/\/dashboard\/todos\/\d{1}/)
+ expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end
end
@@ -88,7 +88,7 @@ describe Projects::TodosController do
context 'when authorized' do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'creates todo for merge request' do
@@ -104,7 +104,7 @@ describe Projects::TodosController do
expect(response).to have_gitlab_http_status(200)
expect(json_response['count']).to eq 1
- expect(json_response['delete_path']).to match(/\/dashboard\/todos\/\d{1}/)
+ expect(json_response['delete_path']).to match(%r{/dashboard/todos/\d{1}})
end
end
diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb
index 65b821c9486..d3188f054cf 100644
--- a/spec/controllers/projects/tree_controller_spec.rb
+++ b/spec/controllers/projects/tree_controller_spec.rb
@@ -7,7 +7,7 @@ describe Projects::TreeController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
controller.instance_variable_set(:@project, project)
end
diff --git a/spec/controllers/projects/uploads_controller_spec.rb b/spec/controllers/projects/uploads_controller_spec.rb
index d572085661d..eca9baed9c9 100644
--- a/spec/controllers/projects/uploads_controller_spec.rb
+++ b/spec/controllers/projects/uploads_controller_spec.rb
@@ -7,4 +7,12 @@ describe Projects::UploadsController do
end
it_behaves_like 'handle uploads'
+
+ context 'when the URL the old style, without /-/system' do
+ it 'responds with a redirect to the login page' do
+ get :show, namespace_id: 'project', project_id: 'avatar', filename: 'foo.png', secret: 'bar'
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
end
diff --git a/spec/controllers/projects/variables_controller_spec.rb b/spec/controllers/projects/variables_controller_spec.rb
index d065cd00d00..68019743be0 100644
--- a/spec/controllers/projects/variables_controller_spec.rb
+++ b/spec/controllers/projects/variables_controller_spec.rb
@@ -6,53 +6,31 @@ describe Projects::VariablesController do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
- describe 'POST #create' do
- context 'variable is valid' do
- it 'shows a success flash message' do
- post :create, namespace_id: project.namespace.to_param, project_id: project,
- variable: { key: "one", value: "two" }
+ describe 'GET #show' do
+ let!(:variable) { create(:ci_variable, project: project) }
- expect(flash[:notice]).to include 'Variable was successfully created.'
- expect(response).to redirect_to(project_settings_ci_cd_path(project))
- end
+ subject do
+ get :show, namespace_id: project.namespace.to_param, project_id: project, format: :json
end
- context 'variable is invalid' do
- it 'renders show' do
- post :create, namespace_id: project.namespace.to_param, project_id: project,
- variable: { key: "..one", value: "two" }
-
- expect(response).to render_template("projects/variables/show")
- end
- end
+ include_examples 'GET #show lists all variables'
end
- describe 'POST #update' do
- let(:variable) { create(:ci_variable) }
-
- context 'updating a variable with valid characters' do
- before do
- project.variables << variable
- end
-
- it 'shows a success flash message' do
- post :update, namespace_id: project.namespace.to_param, project_id: project,
- id: variable.id, variable: { key: variable.key, value: 'two' }
+ describe 'PATCH #update' do
+ let!(:variable) { create(:ci_variable, project: project) }
+ let(:owner) { project }
- expect(flash[:notice]).to include 'Variable was successfully updated.'
- expect(response).to redirect_to(project_variables_path(project))
- end
-
- it 'renders the action #show if the variable key is invalid' do
- post :update, namespace_id: project.namespace.to_param, project_id: project,
- id: variable.id, variable: { key: '?', value: variable.value }
-
- expect(response).to have_gitlab_http_status(200)
- expect(response).to render_template :show
- end
+ subject do
+ patch :update,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ variables_attributes: variables_attributes,
+ format: :json
end
+
+ include_examples 'PATCH #update updates variables'
end
end
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index e61187fb518..994da3cd159 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -102,7 +102,7 @@ describe ProjectsController do
render_views
before do
- project.team << [user, :developer]
+ project.add_developer(user)
project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
end
@@ -288,62 +288,82 @@ describe ProjectsController do
render_views
let(:admin) { create(:admin) }
- let(:project) { create(:project, :repository) }
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 }
+ shared_examples_for 'updating a project' do
+ context 'when only renaming a project path' do
+ it "sets the repository to the right path after a rename" do
+ original_repository_path = project.repository.path
- expect(project.path).to include 'renamed_path'
- expect(assigns(:repository).path).to include project.path
- expect(response).to have_gitlab_http_status(302)
- end
- end
+ expect { update_project path: 'renamed_path' }
+ .to change { project.reload.path }
+ expect(project.path).to include 'renamed_path'
- 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)
+ if project.hashed_storage?(:repository)
+ expect(assigns(:repository).path).to eq(original_repository_path)
+ else
+ expect(assigns(:repository).path).to include(project.path)
+ end
+
+ expect(response).to have_gitlab_http_status(302)
+ end
end
- it 'does not allow to rename the project' do
- expect { update_project path: 'renamed_path' }
- .not_to change { project.reload.path }
+ 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
- expect(controller).to set_flash[:alert].to(/container registry tags/)
- expect(response).to have_gitlab_http_status(200)
+ 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_gitlab_http_status(200)
+ end
end
- end
- it 'updates Fast Forward Merge attributes' do
- controller.instance_variable_set(:@project, project)
+ it 'updates Fast Forward Merge attributes' do
+ controller.instance_variable_set(:@project, project)
- params = {
- merge_method: :ff
- }
+ params = {
+ merge_method: :ff
+ }
- put :update,
- namespace_id: project.namespace,
- id: project.id,
- project: params
+ put :update,
+ namespace_id: project.namespace,
+ id: project.id,
+ project: params
- expect(response).to have_gitlab_http_status(302)
- params.each do |param, value|
- expect(project.public_send(param)).to eq(value)
+ expect(response).to have_gitlab_http_status(302)
+ params.each do |param, value|
+ expect(project.public_send(param)).to eq(value)
+ end
+ end
+
+ def update_project(**parameters)
+ put :update,
+ namespace_id: project.namespace.path,
+ id: project.path,
+ project: parameters
end
end
- def update_project(**parameters)
- put :update,
- namespace_id: project.namespace.path,
- id: project.path,
- project: parameters
+ context 'hashed storage' do
+ let(:project) { create(:project, :repository) }
+
+ it_behaves_like 'updating a project'
+ end
+
+ context 'legacy storage' do
+ let(:project) { create(:project, :repository, :legacy_storage) }
+
+ it_behaves_like 'updating a project'
end
end
@@ -437,7 +457,7 @@ describe ProjectsController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true)
end
@@ -465,7 +485,7 @@ describe ProjectsController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true)
end
diff --git a/spec/controllers/root_controller_spec.rb b/spec/controllers/root_controller_spec.rb
index b32eb39b1fb..7688538a468 100644
--- a/spec/controllers/root_controller_spec.rb
+++ b/spec/controllers/root_controller_spec.rb
@@ -90,6 +90,30 @@ describe RootController do
end
end
+ context 'who has customized their dashboard setting for assigned issues' do
+ before do
+ user.dashboard = 'issues'
+ end
+
+ it 'redirects to their assigned issues' do
+ get :index
+
+ expect(response).to redirect_to issues_dashboard_path(assignee_id: user.id)
+ end
+ end
+
+ context 'who has customized their dashboard setting for assigned merge requests' do
+ before do
+ user.dashboard = 'merge_requests'
+ end
+
+ it 'redirects to their assigned merge requests' do
+ get :index
+
+ expect(response).to redirect_to merge_requests_dashboard_path(assignee_id: user.id)
+ end
+ end
+
context 'who uses the default dashboard setting' do
it 'renders the default dashboard' do
get :index
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 37f961d0c94..30c06ddf744 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -16,6 +16,32 @@ describe SearchController do
expect(assigns[:search_objects].first).to eq note
end
+ context 'when the user cannot read cross project' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?)
+ .with(user, :read_cross_project, :global) { false }
+ end
+
+ it 'still allows accessing the search page' do
+ get :show
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'still blocks searches without a project_id' do
+ get :show, search: 'hello'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'allows searches with a project_id' do
+ get :show, search: 'hello', project_id: create(:project, :public).id
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
context 'on restricted projects' do
context 'when signed out' do
before do
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index 7e42e43345c..376b229ffc9 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -180,6 +180,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'user', mounted_as: 'avatar', id: user.id, filename: 'image.png'
+
response
end
end
@@ -196,6 +197,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'user', mounted_as: 'avatar', id: user.id, filename: 'image.png'
+
response
end
end
@@ -220,6 +222,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png'
+
response
end
end
@@ -239,6 +242,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png'
+
response
end
end
@@ -265,13 +269,13 @@ describe UploadsController do
context "when the user has access to the project" do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
context "when the user is blocked" do
before do
user.block
- project.team << [user, :master]
+ project.add_master(user)
end
it "redirects to the sign in page" do
@@ -291,6 +295,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png'
+
response
end
end
@@ -322,6 +327,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png'
+
response
end
end
@@ -341,6 +347,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png'
+
response
end
end
@@ -384,6 +391,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png'
+
response
end
end
@@ -420,6 +428,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png'
+
response
end
end
@@ -439,6 +448,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png'
+
response
end
end
@@ -465,13 +475,13 @@ describe UploadsController do
context "when the user has access to the project" do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
context "when the user is blocked" do
before do
user.block
- project.team << [user, :master]
+ project.add_master(user)
end
it "redirects to the sign in page" do
@@ -491,6 +501,7 @@ describe UploadsController do
it_behaves_like 'content not cached without revalidation' do
subject do
get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png'
+
response
end
end
@@ -522,6 +533,7 @@ describe UploadsController do
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
@@ -541,6 +553,7 @@ describe UploadsController do
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
diff --git a/spec/controllers/user_callouts_controller_spec.rb b/spec/controllers/user_callouts_controller_spec.rb
new file mode 100644
index 00000000000..48e2ff75cac
--- /dev/null
+++ b/spec/controllers/user_callouts_controller_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe UserCalloutsController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe "POST #create" do
+ subject { post :create, feature_name: feature_name, format: :json }
+
+ context 'with valid feature name' do
+ let(:feature_name) { UserCallout.feature_names.keys.first }
+
+ context 'when callout entry does not exist' do
+ it 'should create a callout entry with dismissed state' do
+ expect { subject }.to change { UserCallout.count }.by(1)
+ end
+
+ it 'should return success' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when callout entry already exists' do
+ let!(:callout) { create(:user_callout, feature_name: UserCallout.feature_names.keys.first, user: user) }
+
+ it 'should return success' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'with invalid feature name' do
+ let(:feature_name) { 'bogus_feature_name' }
+
+ it 'should return bad request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
index 01ab59aa363..b0acf4a49ac 100644
--- a/spec/controllers/users_controller_spec.rb
+++ b/spec/controllers/users_controller_spec.rb
@@ -74,6 +74,31 @@ describe UsersController do
end
end
end
+
+ context 'json with events' do
+ let(:project) { create(:project) }
+ before do
+ project.add_developer(user)
+ Gitlab::DataBuilder::Push.build_sample(project, user)
+
+ sign_in(user)
+ end
+
+ it 'loads events' do
+ get :show, username: user, format: :json
+
+ expect(assigns(:events)).not_to be_empty
+ end
+
+ it 'hides events if the user cannot read cross project' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+
+ get :show, username: user, format: :json
+
+ expect(assigns(:events)).to be_empty
+ end
+ end
end
describe 'GET #calendar' do
@@ -91,7 +116,7 @@ describe UsersController do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
push_data = Gitlab::DataBuilder::Push.build_sample(project, user)
@@ -117,7 +142,7 @@ describe UsersController do
allow_any_instance_of(User).to receive(:contributed_projects_ids).and_return([project.id])
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'assigns @calendar_date' do
diff --git a/spec/factories/appearances.rb b/spec/factories/appearances.rb
index 5f9c57c0c8d..18c7453bd1b 100644
--- a/spec/factories/appearances.rb
+++ b/spec/factories/appearances.rb
@@ -2,8 +2,21 @@
FactoryBot.define do
factory :appearance do
- title "MepMep"
- description "This is my Community Edition instance"
+ title "GitLab Community Edition"
+ description "Open source software to collaborate on code"
new_project_guidelines "Custom project guidelines"
end
+
+ trait :with_logo do
+ logo { fixture_file_upload('spec/fixtures/dk.png') }
+ end
+
+ trait :with_header_logo do
+ header_logo { fixture_file_upload('spec/fixtures/dk.png') }
+ end
+
+ trait :with_logos do
+ with_logo
+ with_header_logo
+ end
end
diff --git a/spec/factories/badge.rb b/spec/factories/badge.rb
new file mode 100644
index 00000000000..b87ece946cb
--- /dev/null
+++ b/spec/factories/badge.rb
@@ -0,0 +1,14 @@
+FactoryBot.define do
+ trait :base_badge do
+ link_url { generate(:url) }
+ image_url { generate(:url) }
+ end
+
+ factory :project_badge, traits: [:base_badge], class: ProjectBadge do
+ project
+ end
+
+ factory :group_badge, aliases: [:badge], traits: [:base_badge], class: GroupBadge do
+ group
+ end
+end
diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb
index 1e125237ae8..6524bb4830c 100644
--- a/spec/factories/boards.rb
+++ b/spec/factories/boards.rb
@@ -1,6 +1,29 @@
FactoryBot.define do
factory :board do
- project
+ transient do
+ project nil
+ group nil
+ project_id nil
+ group_id nil
+ parent nil
+ end
+
+ after(:build, :stub) do |board, evaluator|
+ if evaluator.group
+ board.group = evaluator.group
+ elsif evaluator.group_id
+ board.group_id = evaluator.group_id
+ elsif evaluator.project
+ board.project = evaluator.project
+ elsif evaluator.project_id
+ board.project_id = evaluator.project_id
+ elsif evaluator.parent
+ id = evaluator.parent.id
+ evaluator.parent.is_a?(Group) ? board.group_id = id : evaluator.project_id = id
+ else
+ board.project = create(:project, :empty_repo)
+ end
+ end
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 dc1d88c92dc..fdacbe6c3f1 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -7,12 +7,10 @@ FactoryBot.define do
stage_idx 0
ref 'master'
tag false
- status 'pending'
- created_at 'Di 29. Okt 09:50:00 CET 2013'
- started_at 'Di 29. Okt 09:51:28 CET 2013'
- finished_at 'Di 29. Okt 09:53:28 CET 2013'
commands 'ls -a'
protected false
+ created_at 'Di 29. Okt 09:50:00 CET 2013'
+ pending
options do
{
@@ -29,27 +27,42 @@ FactoryBot.define do
pipeline factory: :ci_pipeline
+ trait :started do
+ started_at 'Di 29. Okt 09:51:28 CET 2013'
+ end
+
+ trait :finished do
+ started
+ finished_at 'Di 29. Okt 09:53:28 CET 2013'
+ end
+
trait :success do
+ finished
status 'success'
end
trait :failed do
+ finished
status 'failed'
end
trait :canceled do
+ finished
status 'canceled'
end
trait :skipped do
+ started
status 'skipped'
end
trait :running do
+ started
status 'running'
end
trait :pending do
+ queued_at 'Di 29. Okt 09:50:59 CET 2013'
status 'pending'
end
@@ -114,11 +127,6 @@ FactoryBot.define do
build.project ||= build.pipeline.project
end
- factory :ci_not_started_build do
- started_at nil
- finished_at nil
- end
-
trait :tag do
tag true
end
@@ -128,13 +136,19 @@ FactoryBot.define do
coverage_regex '/(d+)/'
end
- trait :trace do
+ trait :trace_live do
after(:create) do |build, evaluator|
build.trace.set('BUILD TRACE')
end
end
- trait :unicode_trace do
+ trait :trace_artifact do
+ after(:create) do |build, evaluator|
+ create(:ci_job_artifact, :trace, job: build)
+ end
+ end
+
+ trait :unicode_trace_live do
after(:create) do |build, evaluator|
trace = File.binread(
File.expand_path(
@@ -167,8 +181,8 @@ FactoryBot.define do
trait :artifacts do
after(:create) do |build|
- create(:ci_job_artifact, :archive, job: build)
- create(:ci_job_artifact, :metadata, job: build)
+ create(:ci_job_artifact, :archive, job: build, expire_at: build.artifacts_expire_at)
+ create(:ci_job_artifact, :metadata, job: build, expire_at: build.artifacts_expire_at)
build.reload
end
end
@@ -224,5 +238,10 @@ FactoryBot.define do
trait :protected do
protected true
end
+
+ trait :script_failure do
+ failed
+ failure_reason 1
+ end
end
end
diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb
index 46afba2953c..3d3287d8168 100644
--- a/spec/factories/ci/job_artifacts.rb
+++ b/spec/factories/ci/job_artifacts.rb
@@ -5,6 +5,10 @@ FactoryBot.define do
job factory: :ci_build
file_type :archive
+ trait :remote_store do
+ file_store JobArtifactUploader::Store::REMOTE
+ end
+
after :build do |artifact|
artifact.project ||= artifact.job.project
end
@@ -26,5 +30,20 @@ FactoryBot.define do
Rails.root.join('spec/fixtures/ci_build_artifacts_metadata.gz'), 'application/x-gzip')
end
end
+
+ trait :trace do
+ file_type :trace
+
+ after(:build) do |artifact, evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/trace/sample_trace'), 'text/plain')
+ end
+ end
+
+ trait :correct_checksum do
+ after(:build) do |artifact, evaluator|
+ artifact.file_sha256 = Digest::SHA256.file(artifact.file.path).hexdigest
+ end
+ end
end
end
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
index d82fa8e34aa..3deca103578 100644
--- a/spec/factories/clusters/applications/helm.rb
+++ b/spec/factories/clusters/applications/helm.rb
@@ -1,5 +1,5 @@
FactoryBot.define do
- factory :cluster_applications_helm, class: Clusters::Applications::Helm do
+ factory :clusters_applications_helm, class: Clusters::Applications::Helm do
cluster factory: %i(cluster provided_by_gcp)
trait :not_installable do
@@ -31,5 +31,9 @@ FactoryBot.define do
installing
updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago
end
+
+ factory :clusters_applications_ingress, class: Clusters::Applications::Ingress
+ factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus
+ factory :clusters_applications_runner, class: Clusters::Applications::Runner
end
end
diff --git a/spec/factories/clusters/applications/ingress.rb b/spec/factories/clusters/applications/ingress.rb
deleted file mode 100644
index 85f54a9d744..00000000000
--- a/spec/factories/clusters/applications/ingress.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-FactoryBot.define do
- factory :cluster_applications_ingress, class: Clusters::Applications::Ingress do
- cluster factory: %i(cluster provided_by_gcp)
-
- trait :not_installable do
- status(-2)
- end
-
- trait :installable do
- status 0
- end
-
- trait :scheduled do
- status 1
- end
-
- trait :installing do
- status 2
- end
-
- trait :installed do
- status 3
- end
-
- trait :errored do
- status(-1)
- status_reason 'something went wrong'
- end
-
- trait :timeouted do
- installing
- updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago
- end
- end
-end
diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb
index 20d5580f0c2..98566f907f9 100644
--- a/spec/factories/clusters/clusters.rb
+++ b/spec/factories/clusters/clusters.rb
@@ -32,5 +32,9 @@ FactoryBot.define do
trait :disabled do
enabled false
end
+
+ trait :production_environment do
+ sequence(:environment_scope) { |n| "production#{n}/*" }
+ end
end
end
diff --git a/spec/factories/commits.rb b/spec/factories/commits.rb
index 84a8bc56640..d5d819d862a 100644
--- a/spec/factories/commits.rb
+++ b/spec/factories/commits.rb
@@ -23,7 +23,7 @@ FactoryBot.define do
end
after(:build) do |commit, evaluator|
- allow(commit).to receive(:author).and_return(evaluator.author || build(:author))
+ allow(commit).to receive(:author).and_return(evaluator.author || build_stubbed(:author))
end
trait :without_author do
diff --git a/spec/factories/deploy_keys_projects.rb b/spec/factories/deploy_keys_projects.rb
index 30a6d468ed3..4350652fb79 100644
--- a/spec/factories/deploy_keys_projects.rb
+++ b/spec/factories/deploy_keys_projects.rb
@@ -2,5 +2,9 @@ FactoryBot.define do
factory :deploy_keys_project do
deploy_key
project
+
+ trait :write_access do
+ can_push true
+ end
end
end
diff --git a/spec/factories/deploy_tokens.rb b/spec/factories/deploy_tokens.rb
new file mode 100644
index 00000000000..5fea4a9d5a6
--- /dev/null
+++ b/spec/factories/deploy_tokens.rb
@@ -0,0 +1,14 @@
+FactoryBot.define do
+ factory :deploy_token do
+ token { SecureRandom.hex(50) }
+ sequence(:name) { |n| "PDT #{n}" }
+ read_repository true
+ read_registry true
+ revoked false
+ expires_at { 5.days.from_now }
+
+ trait :revoked do
+ revoked true
+ end
+ end
+end
diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb
index 9d7d5e56611..cac56695319 100644
--- a/spec/factories/deployments.rb
+++ b/spec/factories/deployments.rb
@@ -3,13 +3,14 @@ FactoryBot.define do
sha '97de212e80737a608d939f648d959671fb0a0142'
ref 'master'
tag false
- user
+ user nil
project nil
deployable factory: :ci_build
environment factory: :environment
after(:build) do |deployment, evaluator|
deployment.project ||= deployment.environment.project
+ deployment.user ||= deployment.project.creator
unless deployment.project.repository_exists?
allow(deployment.project.repository).to receive(:create_ref)
diff --git a/spec/factories/events.rb b/spec/factories/events.rb
index ed275243ac9..5798b81ecad 100644
--- a/spec/factories/events.rb
+++ b/spec/factories/events.rb
@@ -1,7 +1,7 @@
FactoryBot.define do
factory :event do
project
- author factory: :user
+ author(factory: :user) { project.creator }
action Event::JOINED
trait(:created) { action Event::CREATED }
diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb
index 1512f5a0e58..8c531cf5909 100644
--- a/spec/factories/groups.rb
+++ b/spec/factories/groups.rb
@@ -18,7 +18,7 @@ FactoryBot.define do
end
trait :with_avatar do
- avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) }
+ avatar { fixture_file_upload('spec/fixtures/dk.png') }
end
trait :access_requestable do
diff --git a/spec/factories/internal_ids.rb b/spec/factories/internal_ids.rb
new file mode 100644
index 00000000000..fbde07a391a
--- /dev/null
+++ b/spec/factories/internal_ids.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :internal_id do
+ project
+ usage :issues
+ last_value { project.issues.maximum(:iid) || 0 }
+ end
+end
diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb
index 5ed6b017dee..998080a3dd5 100644
--- a/spec/factories/issues.rb
+++ b/spec/factories/issues.rb
@@ -1,8 +1,8 @@
FactoryBot.define do
factory :issue do
title { generate(:title) }
- author
project
+ author { project.creator }
trait :confidential do
confidential true
@@ -14,6 +14,7 @@ FactoryBot.define do
trait :closed do
state :closed
+ closed_at { Time.now }
end
factory :closed_issue, traits: [:closed]
diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb
index e6eb76f71d3..3f0c60f32b7 100644
--- a/spec/factories/keys.rb
+++ b/spec/factories/keys.rb
@@ -5,6 +5,10 @@ FactoryBot.define do
title
key { Spec::Support::Helpers::KeyGeneratorHelper.new(1024).generate + ' dummy@gitlab.com' }
+ factory :key_without_comment do
+ key { Spec::Support::Helpers::KeyGeneratorHelper.new(1024).generate }
+ end
+
factory :deploy_key, class: 'DeployKey'
factory :personal_key do
@@ -15,56 +19,127 @@ FactoryBot.define do
factory :another_deploy_key, class: 'DeployKey'
end
- factory :write_access_key, class: 'DeployKey' do
- can_push true
- end
-
factory :rsa_key_2048 do
key do
- 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O9' \
- '6x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5' \
- '/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7' \
- 'M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaC' \
- 'rzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy0' \
- '5qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz= dummy@gitlab.com'
+ <<~KEY.delete("\n")
+ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC98dbu7gxcbmAvwMqz/6AALhSr1jiX
+ G0UC8FQMvoDt+ciB+uSJhg7KlxinKjYJnPGfhX+q2K+mmCGAmI/D6q7rFxE+bn09O+75
+ qgkTHi+suDVE6KG7L3n0alGd/qSevfomR77Snh6fQPdG6sEAZz3kehcpfVnq5/IuLFq9
+ FBrgmu52Jd4XZLQZKkDq6zYOJ69FUkGf93LZIV/OOaS+f+qkOGPCUkdKl7oEcgpVNY9S
+ RjBCduXnvi2CyQnnJVkBguGL5VlXwFXH+17Whs7oFWmdiG+4jzBRLIMz4EuIW09b8Su5
+ PW6+bBuXOifHA8KG5TMmjs5LYdCMPFnhTyDyO3a1 dummy@gitlab.com
+ KEY
end
factory :rsa_deploy_key_2048, class: 'DeployKey'
end
+ factory :rsa_key_4096 do
+ key do
+ <<~KEY.delete("\n")
+ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDGSD77lLtjmzewiBs6nu2R5nu6oNkrA
+ kH/0co1fHHosKfRr+sWkSTKXOVcL7bhRu+tniGBmB5pn+i1qX7BXtrcnv//bCXWIp+me0
+ 27L4RJa5/Ep077iiTJlzTpcV664xNUXC8mzBr601HR/Z2TzX5DWJvnyqqFkN7qHTYo/+I
+ oKECnKqNzI5SQrAxgi6sbWA5DFQ/nwcqsUSBo5gCCJ/0QPrR19yVV5lJA19EY2LawOb1S
+ JNOFo4mQupSlBZwvERZJ7IqhBTPtQIfrqqz5VJbI13jK3ViZTugIZqydWAhosUyejP3Sd
+ Cj1KMexrvV95tjUtmhVFlph4tKThQO0p9pXKZNCzYsbQTye6O6Hk2rojOJLyFWqNBVKtI
+ 8Ymfu7OQWppRnuUFuhuuS515H1s888bZFMPsC74mPyo0Y7Q9wAoTnQ9Hw6b0J6OfY3PIR
+ VphaCmxh6b7dgSPFdD7TA6j0xk6PCTOIEzBKuc85B3GQc8Nt4sTv6fW8lGeuYWqepW74i
+ geC4qB6U3/3+p3nPdq/bTM1txrhnQsl1r4dv6TLZ51EtHp6sXayp0qd0pRaiavebXFC0i
+ aETLraQpye4FWbBL/8xTjQ/0VPrYVuUCDvDSMIIS3/9g7Kp7ERUDC9jUqOVonm4pTXL9i
+ ItiUBlK7Mob9C4fQIRFnVR00DCmkmVgw== dummy@gitlab.com
+ KEY
+ end
+ end
+
+ factory :rsa_key_5120 do
+ key do
+ <<~KEY.delete("\n")
+ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACgQDxnZP0TucLH3zcrvt75DPNq+xKqOmJk
+ CEzTytKq4S5MDH0nlx+xOZ9WykhwDHXU0iZBJF7yRdLkZweYDJVKnBzr4t7QP5Sw2/ZdL
+ elvUMWGJjuz28x8Z+8NZ+IxL/exDz7itrhCsLupQhGO1obiIwf8xVzzPoxrQ9dxaN4x96
+ 5N+QdQcld8O6xfpSE0p5Y3sRn3kp57aHWoNa/bUGZy0OHLr/ig0uc6EKyWsTmEESOgDyV
+ 94wOyHR0KNGEENyxQt4BwAbEBn3Y41HKqD358KKh+XjbECebrrBFigdDL/eYFIUlstJ07
+ SK/HtYjZbiUZCPs8bJA+SBaLK0pGGqguM2LXRoMeMUZFwKKKS2LpRqjKGj3Qt7qMnp1Sk
+ VhiMnxNqL4nJnDOOVo07xDIPKqIBYO67/cp4Icv3IjKxy6K3EIpLr+iRCxcllpDogxolz
+ FC+pEDVpmEvcrGEv1ON6HcCdk/6Q8Iekr8rYDHpKCU5FF2uBHkqq7yNJ1/+NFC4dgyOo0
+ xCVL4D3DvDKNxFYkrzW4ICt0f5XcMnU10yS/OFXz8JwA3jvuLvMRe5JdFiIjb/l86+TgY
+ yvK8Y8N/UWgSgyjXUCv8nxdvpsxdz5h7HBF8E2DIxCVMC23655e5rp5eJW9EU9X5YFZc3
+ u6uWJ1f1aO+1ViTtqkPrqxovNDD+gVel8Ny6MJ4MvmDKY+eM8beNMSSf1n1Oyh/SvCffh
+ ZpUqrXdTr9qwZEOaC75T74AJ7KBl9VvO3vPLZuJrt38R2OZG/4SlNEUA6bb5TWQLtdor/
+ qpPN5jAskkAUzOh5L/M+dmq2jNn03U9xwORCYPZj+fFM9bL99/0knsV0ypZDZyWH dummy@gitlab.com
+ KEY
+ end
+ end
+
+ factory :rsa_key_8192 do
+ key do
+ <<~KEY.delete("\n")
+ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAEAQC5jMyGtgMOVX4t2GuXkbirJA0Edr+ql
+ OH9grnRBPHPo0Npt6XE6ZN3J3hDULTQo03wmekGw42dxdNSgk+F0GjsUBrMLbqrk485MM
+ e0cUbP4lRXNu4ao87wPVM5fAsD4E3FQiZcI6Df011ZGIL7hGTHt6eafTfr9cJheRyYSu6
+ g06rlnFWbbtSh9oQ7Y6sfDLBcsC9ECcXwe3mwViuQXPIVomZ02EdnBbAhbGHDtA+ZbSvT
+ fraxOMjkxkVvvdjLxXEykpwVuZf8eZ+R/Js8jQ5RKvTZMbfxJNsGEqHD32s43ml4VF549
+ Qz2GJDXF7Cld/n3CT6wvw0mMPM0LnykL2v0CMr44bjIA3KsNEs5MhkcBO8sv5hGfcPhrp
+ m9WwI6gd9vdZVcxarVI+iQS947owvdn4VbEZXynCDqEEv3Zh+FA5p23mf2p7DkG/swiK/
+ IPrjr1wmsiWmwIUsENzJNyJtibKuRsBawC4ZdL797tFilSoTzSpriegSL13joPXz3eOHC
+ Vu4ATHMo3QyLfIFbxrf9PQ79nyOpHoX2YeFXvei3xFkGMundkOqeI+pnJKDyqbiLV7UVl
+ clua11QWNQZf1ZUd0n1wZ1g89de+wl3oJSRbSA5ZpveZEPstcMC/JhogY4JBYsvCT1yHO
+ oNWHo90NZQsUCjNnR+/FVaACtpt2zcPTjjbXvxwCDlT3gXTmTBp/kEZq6u8p+BOlqFgxc
+ P/sdAR8jWTin3Iw/YAcbqNgRHdjMUzJBrPQ5NcK6xFcmkOEQahdJDZs98xozCHkD4Urx6
+ +auTr/uqRYobKoNUNiYqN1n7/dfZjQJJVkHtKd06JTFx+7/SqyfrTKS+/EIf2Hypdy9r9
+ IFR+SWAOi11N/wflS/ZbH95Qt3STifXRecmHzyYGkMOZ+mg3Hi2YU0yn7k+P1jy627xud
+ pT9Ak3HWT5ji8tMyn9udL7m80dYpUiEAxoYZdbSSNCDaKP4ViABnGIeZreIujabI8IdtE
+ IjFQTaF2d5HTYjp28/qf576CFP5L7AGydypipYqZUmsYnay5YVjdm89He3TMD71SwspJl
+ POC4RnM0HS87OE+U0+mVaIe8YYbcjTekpVU9mkqsE/GQ34Egw79VMNNgWq5avOzpT8msC
+ lTJxgfJ1agGgigTvGxUM0FB07+sIdJxxNymAGpLKZ1op8xaJI3o8D86jWgI22za1zxUB5
+ il9U7+KOzaWo9mp3bmhvZWGDwzTXEZhUJYMRby7o6UxSHlA6fKE63JSDD2yhXk4CjsQRN
+ C7Ph9cYSB+Wa3i9Am4rRlJgrF79okmEOMpj1idliHkpIsy/k2CN9Lf2EIHOD4NMuLrSUH
+ 4qJsPUq19ZbGIMdImD3vMS5b dummy@gitlab.com
+ KEY
+ end
+ end
+
factory :dsa_key_2048 do
key do
- 'ssh-dss AAAAB3NzaC1kc3MAAAEBAO/3/NPLA/zSFkMOCaTtGo+uos1flfQ5f038Uk+G' \
- 'Y9AeLGzX+Srhw59GdVXmOQLYBrOt5HdGwqYcmLnE2VurUGmhtfeO5H+3p5pGJbkS0Gxp' \
- 'YH1HRO9lWsncF3Hh1w4lYsDjkclDiSTdfTuN8F4Kb3DXNnVSCieeonp+B25F/CXagyTQ' \
- '/pvNmHFeYgGCVdnBtFdi+xfxaZ8NKdPrGggzokbKHElDZQ4Xo5EpdcyLajgM7nB2r2Rz' \
- 'OrmeaevKi5lV68ehRa9Yyrb7vxvwiwBwOgqR/mnN7Gnaq1jUdmJY+ct04Qwx37f5jvhv' \
- '5gA4U40SGMoiHM8RFIN7Ksz0jsyX73MAAAAVALRWOfjfzHpK7KLz4iqDvvTUAevJAAAB' \
- 'AEa9NZ+6y9iQ5erGsdfLTXFrhSefTG0NhghoO/5IFkSGfd8V7kzTvCHaFrcfpEA5kP8t' \
- 'poeOG0TASB6tgGOxm1Bq4Wncry5RORBPJlAVpDGRcvZ931ddH7IgltEInS6za2uH6F/1' \
- 'M1QfKePSLr6xJ1ZLYfP0Og5KTp1x6yMQvfwV0a+XdA+EPgaJWLWp/pWwKWa0oLUgjsIH' \
- 'MYzuOGh5c708uZrmkzqvgtW2NgXhcIroRgynT3IfI2lP2rqqb3uuuE/qH5UCUFO+Dc3H' \
- 'nAFNeQDT/M25AERdPYBAY5a+iPjIgO+jT7BfmfByT+AZTqZySrCyc7nNZL3YgGLK0l6A' \
- '1GgAAAEBAN9FpFOdIXE+YEZhKl1vPmbcn+b1y5zOl6N4x1B7Q8pD/pLMziWROIS8uLzb' \
- 'aZ0sMIWezHIkxuo1iROMeT+jtCubn7ragaN6AX7nMpxYUH9+mYZZs/fyElt6wCviVhTI' \
- 'zM+u7VdQsnZttOOlQfogHdL+SpeAft0DsfJjlcgQnsLlHQKv6aPqCPYUST2nE7RyW/Ex' \
- 'PrMxLtOWt0/j8RYHbwwqvyeZqBz3ESBgrS9c5tBdBfauwYUV/E7gPLOU3OZFw9ue7o+z' \
- 'wzoTZqW6Xouy5wtWvSLQSLT5XwOslmQz8QMBxD0AQyDfEFGsBCWzmbTgKv9uqrBjubsS' \
- 'Taja+Cf9kMo== dummy@gitlab.com'
+ <<~KEY.delete("\n")
+ ssh-dss AAAAB3NzaC1kc3MAAAEBALEB3sM2kPy6LKLiyL+UlDx2vzuKrzSD2nsW2Kb7
+ 0ivIqDNJu5CbqIQSkjdMzJiocs33ESFqXid6ezOtVdDwXHJQRxKGalW1kBbFAPjtMxlD
+ bf559+7qN2zfCfcQsgTmNAZ7O+wltqJmyLv5i4QqNwPDvyeBvJ4C+770DzlcQtpkflKJ
+ X+O7i8Ylq34h6UTCTnjry+dFVm1xz97LPf7XuzXGZcAG/eGUNQgxQ2bferKnrpYOXx6c
+ ocSRj9W54nrRFMWuDeOspWp4MoYK0FRMfDQYPksUayGUnm1KQTGuDbB0ahRNCOm8b3tf
+ P9Z+vjANAkqenzDuXCpz2PU/Oj6/N/UAAAAhAPOLyut12Mjcp3eUXLe1xSoI5IRXSLso
+ W9no93dcFNprAAABAQCLhpqKY+PNcwbhhPruL+f+uROghHzDwRNX+e231F4wHHeDDomf
+ WyLVFj31XrHdDXZnS9tTTj5D2XWLovSSxYb3H7earTctmktL0lQ3HapujzvOkn+VM0pG
+ s6B3j54+AM3mg50KZdYWxxv+v/lb6oEcsCjfKNyRIx/5pqX6XI3dxl9MMIxrfVWpkNX+
+ FI68v1LVV61DC9PkNyEHU0v9YBOfrTiS21TIlVIZcSFhuDjg52MekfZAnoKaP7YFJNF3
+ fdCrXaU3hYQrwB9XdskBUppwxKGhf7O6SWEZhAEfPA9kgxaWHoJvsDz8aca576UNe7BP
+ mjzo/SLUX+P4uvcaffd+AAABAEqzpmwjzTxB+DV8C+0LnmKf3L/UlQWyGdmhd65rnbkH
+ GgRMAAkoh4GBOEHL5bznNRmO7X/H6g2fR7SEabxfbvb903KI4nbfFF+3QtnwyIbTBAcH
+ 0893D3bi5rsaJcz+c6lBob2En2nThRciefXUk2oPzCQuDyFIyHLJikqRQVcalHCdQ00c
+ /H/JkiJedHNqaeU4TeMk8SM53Brjplj/iiJq+ujc5MlEgACdCwWp0BviFACEoYyFaa3R
+ kc7Xdm9vFpclm9fzgUfPloASA0SkO945in3mIqMfODTb4yRvbjk8If9483fEPgQkczpd
+ ptBz1VAKg8AmRcz1GmBIxs+Stn0= dummy@gitlab.com
+ KEY
end
end
factory :ecdsa_key_256 do
key do
- 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYA' \
- 'AABBBJZmkzTgY0fiCQ+DVReyH/fFwTFz0XoR3RUO0u+199H19KFw7mNPxRSMOVS7tEtO' \
- 'Nj3Q7FcZXfqthHvgAzDiHsc= dummy@gitlab.com'
+ <<~KEY.delete("\n")
+ ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYA
+ AABBBJZmkzTgY0fiCQ+DVReyH/fFwTFz0XoR3RUO0u+199H19KFw7mNPxRSMOVS7tEtO
+ Nj3Q7FcZXfqthHvgAzDiHsc= dummy@gitlab.com
+ KEY
end
end
factory :ed25519_key_256 do
key do
- 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIETnVTgzqC1gatgSlC4zH6aYt2CAQzgJOhDRvf59ohL6 dummy@gitlab.com'
+ <<~KEY.delete("\n")
+ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIETnVTgzqC1gatgSlC4zH6aYt2CAQzgJ
+ OhDRvf59ohL6 dummy@gitlab.com
+ KEY
end
end
end
diff --git a/spec/factories/lfs_file_locks.rb b/spec/factories/lfs_file_locks.rb
new file mode 100644
index 00000000000..b9d24f82b65
--- /dev/null
+++ b/spec/factories/lfs_file_locks.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :lfs_file_lock do
+ user
+ project
+ path 'README.md'
+ end
+end
diff --git a/spec/factories/lfs_objects.rb b/spec/factories/lfs_objects.rb
index 8eb709022ce..eaf3a4ed497 100644
--- a/spec/factories/lfs_objects.rb
+++ b/spec/factories/lfs_objects.rb
@@ -9,4 +9,14 @@ FactoryBot.define do
trait :with_file do
file { fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png") }
end
+
+ # The uniqueness constraint means we can't use the correct OID for all LFS
+ # objects, so the test needs to decide which (if any) object gets it
+ trait :correct_oid do
+ oid 'b804383982bb89b00e828e3f44c038cc991d3d1768009fc39ba8e2c081b9fb75'
+ end
+
+ trait :object_storage do
+ file_store { LfsObjectUploader::Store::REMOTE }
+ end
end
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index 40558c88d15..d26cb0c3417 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -1,9 +1,9 @@
FactoryBot.define do
factory :merge_request do
title { generate(:title) }
- author
association :source_project, :repository, factory: :project
target_project { source_project }
+ author { source_project.creator }
# $ git log --pretty=oneline feature..master
# 5937ac0a7beb003549fc5fd26fc247adbce4a52e Add submodule from gitlab.com
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index 707ecbd6be5..857333f222d 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -6,7 +6,7 @@ FactoryBot.define do
factory :note do
project
note { generate(:title) }
- author
+ author { project&.creator || create(:user) }
on_issue
factory :note_on_commit, traits: [:on_commit]
@@ -16,6 +16,8 @@ FactoryBot.define do
factory :note_on_personal_snippet, traits: [:on_personal_snippet]
factory :system_note, traits: [:system]
+ factory :discussion_note, class: DiscussionNote
+
factory :discussion_note_on_merge_request, traits: [:on_merge_request], class: DiscussionNote do
association :project, :repository
@@ -31,6 +33,8 @@ FactoryBot.define do
factory :discussion_note_on_personal_snippet, traits: [:on_personal_snippet], class: DiscussionNote
+ factory :discussion_note_on_snippet, traits: [:on_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
@@ -96,6 +100,10 @@ FactoryBot.define do
noteable { create(:issue, project: project) }
end
+ trait :on_snippet do
+ noteable { create(:snippet, project: project) }
+ end
+
trait :on_merge_request do
noteable { create(:merge_request, source_project: project) }
end
@@ -122,11 +130,11 @@ FactoryBot.define do
end
trait :with_attachment do
- attachment { fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png") }
+ attachment { fixture_file_upload(Rails.root.join( "spec/fixtures/dk.png"), "image/png") }
end
trait :with_svg_attachment do
- attachment { fixture_file_upload(Rails.root + "spec/fixtures/unsanitized.svg", "image/svg+xml") }
+ attachment { fixture_file_upload(Rails.root.join("spec/fixtures/unsanitized.svg"), "image/svg+xml") }
end
transient do
diff --git a/spec/factories/pages_domains.rb b/spec/factories/pages_domains.rb
index 61b04708da2..20671da016e 100644
--- a/spec/factories/pages_domains.rb
+++ b/spec/factories/pages_domains.rb
@@ -1,9 +1,10 @@
FactoryBot.define do
factory :pages_domain, class: 'PagesDomain' do
- domain 'my.domain.com'
+ sequence(:domain) { |n| "my#{n}.domain.com" }
+ verified_at { Time.now }
+ enabled_until { 1.week.from_now }
- trait :with_certificate do
- certificate '-----BEGIN CERTIFICATE-----
+ certificate '-----BEGIN CERTIFICATE-----
MIICGzCCAYSgAwIBAgIBATANBgkqhkiG9w0BAQUFADAbMRkwFwYDVQQDExB0ZXN0
LWNlcnRpZmljYXRlMB4XDTE2MDIxMjE0MzIwMFoXDTIwMDQxMjE0MzIwMFowGzEZ
MBcGA1UEAxMQdGVzdC1jZXJ0aWZpY2F0ZTCBnzANBgkqhkiG9w0BAQEFAAOBjQAw
@@ -17,10 +18,8 @@ joZp2JHYvNlTPkRJ/J4TcXxBTJmArcQgTIuNoBtC+0A/SwdK4MfTCUY4vNWNdese
5A4K65Nb7Oh1AdQieTBHNXXCdyFsva9/ScfQGEl7p55a52jOPs0StPd7g64uvjlg
YHi2yesCrOvVXt+lgPTd
-----END CERTIFICATE-----'
- end
- trait :with_key do
- key '-----BEGIN PRIVATE KEY-----
+ key '-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKS+CfS9GcRSdYSN
SzyH5QJQBr5umRL6E+KilOV39iYFO/9oHjUdapTRWkrwnNPCp7qaeck4Jr8iv14t
PVNDfNr76eGb6/3YknOAP0QOjLWunoC8kjU+N/JHU52NrUeX3qEy8EKV9LeCDJcB
@@ -36,6 +35,30 @@ EPjGlXIT+aW2XiPmK3ZlCDcWIenE+lmtbOpI159Wpk8BGXs/s/xBAkEAlAY3ymgx
63BDJEwvOb2IaP8lDDxNsXx9XJNVvQbv5n15vNsLHbjslHfAhAbxnLQ1fLhUPqSi
nNp/xedE1YxutQ==
-----END PRIVATE KEY-----'
+
+ trait :disabled do
+ verified_at nil
+ enabled_until nil
+ end
+
+ trait :unverified do
+ verified_at nil
+ end
+
+ trait :reverify do
+ enabled_until { 1.hour.from_now }
+ end
+
+ trait :expired do
+ enabled_until { 1.hour.ago }
+ end
+
+ trait :without_certificate do
+ certificate nil
+ end
+
+ trait :without_key do
+ key nil
end
trait :with_missing_chain do
diff --git a/spec/factories/project_deploy_tokens.rb b/spec/factories/project_deploy_tokens.rb
new file mode 100644
index 00000000000..4866cb58d88
--- /dev/null
+++ b/spec/factories/project_deploy_tokens.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :project_deploy_token do
+ project
+ deploy_token
+ end
+end
diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb
index 493b7bc021c..a448d565e4b 100644
--- a/spec/factories/project_hooks.rb
+++ b/spec/factories/project_hooks.rb
@@ -15,6 +15,7 @@ FactoryBot.define do
issues_events true
confidential_issues_events true
note_events true
+ confidential_note_events true
job_events true
pipeline_events true
wiki_page_events true
diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb
index 89d8248f9f4..db2eb4fc863 100644
--- a/spec/factories/project_wikis.rb
+++ b/spec/factories/project_wikis.rb
@@ -3,7 +3,7 @@ FactoryBot.define do
skip_create
project
- user factory: :user
+ user { project.creator }
initialize_with { new(project, user) }
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index d0f3911f730..1761b6e2a3b 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -81,8 +81,10 @@ FactoryBot.define do
archived true
end
- trait :hashed do
- storage_version Project::LATEST_STORAGE_VERSION
+ storage_version Project::LATEST_STORAGE_VERSION
+
+ trait :legacy_storage do
+ storage_version nil
end
trait :access_requestable do
@@ -90,7 +92,13 @@ FactoryBot.define do
end
trait :with_avatar do
- avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) }
+ avatar { fixture_file_upload('spec/fixtures/dk.png') }
+ end
+
+ trait :with_export do
+ after(:create) do |project, evaluator|
+ ProjectExportWorker.new.perform(project.creator.id, project.id)
+ end
end
trait :broken_storage do
@@ -243,7 +251,8 @@ FactoryBot.define do
project.create_prometheus_service(
active: true,
properties: {
- api_url: 'https://prometheus.example.com'
+ api_url: 'https://prometheus.example.com/',
+ manual_configuration: true
}
)
end
diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb
index 39460834d06..60956511834 100644
--- a/spec/factories/protected_branches.rb
+++ b/spec/factories/protected_branches.rb
@@ -53,6 +53,7 @@ FactoryBot.define do
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
diff --git a/spec/factories/redirect_routes.rb b/spec/factories/redirect_routes.rb
new file mode 100644
index 00000000000..774232d0b34
--- /dev/null
+++ b/spec/factories/redirect_routes.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :redirect_route do
+ sequence(:path) { |n| "redirect#{n}" }
+ source factory: :group
+ end
+end
diff --git a/spec/factories/sent_notifications.rb b/spec/factories/sent_notifications.rb
index 80872067233..b0174dd06b7 100644
--- a/spec/factories/sent_notifications.rb
+++ b/spec/factories/sent_notifications.rb
@@ -1,7 +1,7 @@
FactoryBot.define do
factory :sent_notification do
project
- recipient factory: :user
+ recipient { project.creator }
noteable { create(:issue, project: project) }
reply_key { SentNotification.reply_key }
end
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index 4b0377967c7..0d4fd49bf3a 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -18,6 +18,7 @@ FactoryBot.define do
factory :kubernetes_service do
project
+ type 'KubernetesService'
active true
properties({
api_url: 'https://kubernetes.example.com',
@@ -29,7 +30,8 @@ FactoryBot.define do
project
active true
properties({
- api_url: 'https://prometheus.example.com/'
+ api_url: 'https://prometheus.example.com/',
+ manual_configuration: true
})
end
diff --git a/spec/factories/snippets.rb b/spec/factories/snippets.rb
index 2ab9a56d255..dc12b562108 100644
--- a/spec/factories/snippets.rb
+++ b/spec/factories/snippets.rb
@@ -21,6 +21,7 @@ FactoryBot.define do
factory :project_snippet, parent: :snippet, class: :ProjectSnippet do
project
+ author { project.creator }
end
factory :personal_snippet, parent: :snippet, class: :PersonalSnippet do
diff --git a/spec/factories/subscriptions.rb b/spec/factories/subscriptions.rb
index a4bc4e87b0a..8f7ab74ec70 100644
--- a/spec/factories/subscriptions.rb
+++ b/spec/factories/subscriptions.rb
@@ -1,7 +1,7 @@
FactoryBot.define do
factory :subscription do
- user
project
+ user { project.creator }
subscribable factory: :issue
end
end
diff --git a/spec/factories/timelogs.rb b/spec/factories/timelogs.rb
index af34b0681e2..b45f06b9a0a 100644
--- a/spec/factories/timelogs.rb
+++ b/spec/factories/timelogs.rb
@@ -3,7 +3,7 @@
FactoryBot.define do
factory :timelog do
time_spent 3600
- user
issue
+ user { issue.project.creator }
end
end
diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb
index 6a6de665dd1..94f8caedfa6 100644
--- a/spec/factories/todos.rb
+++ b/spec/factories/todos.rb
@@ -1,8 +1,8 @@
FactoryBot.define do
factory :todo do
project
- author
- user
+ author { project.creator }
+ user { project.creator }
target factory: :issue
action { Todo::ASSIGNED }
diff --git a/spec/factories/uploads.rb b/spec/factories/uploads.rb
index c39500faea1..b45f6f30e40 100644
--- a/spec/factories/uploads.rb
+++ b/spec/factories/uploads.rb
@@ -1,24 +1,51 @@
FactoryBot.define do
factory :upload do
model { build(:project) }
- path { "uploads/-/system/project/avatar/avatar.jpg" }
size 100.kilobytes
uploader "AvatarUploader"
+ mount_point :avatar
+ secret nil
+ store ObjectStorage::Store::LOCAL
- trait :personal_snippet do
- model { build(:personal_snippet) }
+ # we should build a mount agnostic upload by default
+ transient do
+ filename 'myfile.jpg'
+ end
+
+ # this needs to comply with RecordsUpload::Concern#upload_path
+ path { File.join("uploads/-/system", model.class.to_s.underscore, mount_point.to_s, 'avatar.jpg') }
+
+ trait :personal_snippet_upload do
uploader "PersonalFileUploader"
+ path { File.join(secret, filename) }
+ model { build(:personal_snippet) }
+ secret SecureRandom.hex
end
trait :issuable_upload do
- path { "#{SecureRandom.hex}/myfile.jpg" }
uploader "FileUploader"
+ path { File.join(secret, filename) }
+ secret SecureRandom.hex
+ end
+
+ trait :object_storage do
+ store ObjectStorage::Store::REMOTE
end
trait :namespace_upload do
- path { "#{SecureRandom.hex}/myfile.jpg" }
model { build(:group) }
+ path { File.join(secret, filename) }
uploader "NamespaceFileUploader"
+ secret SecureRandom.hex
+ end
+
+ trait :attachment_upload do
+ transient do
+ mount_point :attachment
+ end
+
+ model { build(:note) }
+ uploader "AttachmentUploader"
end
end
end
diff --git a/spec/factories/user_callouts.rb b/spec/factories/user_callouts.rb
new file mode 100644
index 00000000000..528e442c14b
--- /dev/null
+++ b/spec/factories/user_callouts.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :user_callout do
+ feature_name :gke_cluster_integration
+
+ user
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index e62e0b263ca..769fd656e7a 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -38,7 +38,7 @@ FactoryBot.define do
end
trait :with_avatar do
- avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) }
+ avatar { fixture_file_upload('spec/fixtures/dk.png') }
end
trait :two_factor_via_otp do
diff --git a/spec/factories/users_star_projects.rb b/spec/factories/users_star_projects.rb
new file mode 100644
index 00000000000..6afd08a2084
--- /dev/null
+++ b/spec/factories/users_star_projects.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :users_star_project do
+ project
+ user
+ end
+end
diff --git a/spec/features/admin/admin_builds_spec.rb b/spec/features/admin/admin_builds_spec.rb
index e020579f71e..51b42d1b43b 100644
--- a/spec/features/admin/admin_builds_spec.rb
+++ b/spec/features/admin/admin_builds_spec.rb
@@ -21,7 +21,7 @@ describe 'Admin Builds' do
expect(page).to have_selector('.nav-links li.active', text: 'All')
expect(page).to have_selector('.row-content-block', text: 'All jobs')
expect(page.all('.build-link').size).to eq(4)
- expect(page).to have_link 'Cancel all'
+ expect(page).to have_button 'Stop all jobs'
end
end
@@ -31,7 +31,7 @@ describe 'Admin Builds' do
expect(page).to have_selector('.nav-links li.active', text: 'All')
expect(page).to have_content 'No jobs to show'
- expect(page).not_to have_link 'Cancel all'
+ expect(page).not_to have_button 'Stop all jobs'
end
end
end
@@ -51,7 +51,7 @@ describe 'Admin Builds' do
expect(page.find('.build-link')).not_to have_content(build2.id)
expect(page.find('.build-link')).not_to have_content(build3.id)
expect(page.find('.build-link')).not_to have_content(build4.id)
- expect(page).to have_link 'Cancel all'
+ expect(page).to have_button 'Stop all jobs'
end
end
@@ -63,7 +63,7 @@ describe 'Admin Builds' do
expect(page).to have_selector('.nav-links li.active', text: 'Pending')
expect(page).to have_content 'No jobs to show'
- expect(page).not_to have_link 'Cancel all'
+ expect(page).not_to have_button 'Stop all jobs'
end
end
end
@@ -83,7 +83,7 @@ describe 'Admin Builds' do
expect(page.find('.build-link')).not_to have_content(build2.id)
expect(page.find('.build-link')).not_to have_content(build3.id)
expect(page.find('.build-link')).not_to have_content(build4.id)
- expect(page).to have_link 'Cancel all'
+ expect(page).to have_button 'Stop all jobs'
end
end
@@ -95,7 +95,7 @@ describe 'Admin Builds' do
expect(page).to have_selector('.nav-links li.active', text: 'Running')
expect(page).to have_content 'No jobs to show'
- expect(page).not_to have_link 'Cancel all'
+ expect(page).not_to have_button 'Stop all jobs'
end
end
end
@@ -113,7 +113,7 @@ describe 'Admin Builds' do
expect(page.find('.build-link')).not_to have_content(build1.id)
expect(page.find('.build-link')).not_to have_content(build2.id)
expect(page.find('.build-link')).to have_content(build3.id)
- expect(page).to have_link 'Cancel all'
+ expect(page).to have_button 'Stop all jobs'
end
end
@@ -125,7 +125,7 @@ describe 'Admin Builds' 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 all'
+ expect(page).to have_button 'Stop all jobs'
end
end
end
diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb
index 241c7cbc34e..cb96830cb7c 100644
--- a/spec/features/admin/admin_deploy_keys_spec.rb
+++ b/spec/features/admin/admin_deploy_keys_spec.rb
@@ -17,6 +17,16 @@ RSpec.describe 'admin deploy keys' do
end
end
+ it 'shows all the projects the deploy key has write access' do
+ write_key = create(:deploy_keys_project, :write_access, deploy_key: deploy_key)
+
+ visit admin_deploy_keys_path
+
+ page.within(find('.deploy-keys-list', match: :first)) do
+ expect(page).to have_content(write_key.project.full_name)
+ end
+ end
+
describe 'create a new deploy key' do
let(:new_ssh_key) { attributes_for(:key)[:key] }
@@ -28,14 +38,12 @@ RSpec.describe 'admin deploy keys' do
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(current_path).to eq admin_deploy_keys_path
page.within(find('.deploy-keys-list', match: :first)) do
expect(page).to have_content('laptop')
- expect(page).to have_content('Yes')
end
end
end
@@ -48,14 +56,12 @@ RSpec.describe 'admin deploy keys' do
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
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
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 9ea3cfa72c6..9946cc77d1d 100644
--- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb
+++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb
@@ -55,14 +55,19 @@ feature 'Admin disables Git access protocol' do
end
def disable_http_protocol
- visit admin_application_settings_path
- find('#application_setting_enabled_git_access_protocol').find(:xpath, 'option[2]').select_option
- click_on 'Save'
+ switch_git_protocol(2)
end
def disable_ssh_protocol
+ switch_git_protocol(3)
+ end
+
+ def switch_git_protocol(value)
visit admin_application_settings_path
- find('#application_setting_enabled_git_access_protocol').find(:xpath, 'option[3]').select_option
- click_on 'Save'
+
+ page.within('.as-visibility-access') do
+ find('#application_setting_enabled_git_access_protocol').find(:xpath, "option[#{value}]").select_option
+ click_on 'Save'
+ end
end
end
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index a5f22848031..d5e603baeae 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -173,7 +173,7 @@ feature 'Admin Groups' do
visit admin_group_path(group)
- expect(page).to have_content(empty_project.name_with_namespace)
+ expect(page).to have_content(empty_project.full_name)
expect(page).to have_content('Projects shared with')
end
end
diff --git a/spec/features/admin/admin_health_check_spec.rb b/spec/features/admin/admin_health_check_spec.rb
index ac3392b49f9..3693e5882f9 100644
--- a/spec/features/admin/admin_health_check_spec.rb
+++ b/spec/features/admin/admin_health_check_spec.rb
@@ -17,7 +17,7 @@ feature "Admin Health Check", :feature do
page.has_text? 'Health Check'
page.has_text? 'Health information can be retrieved'
- token = current_application_settings.health_check_access_token
+ token = Gitlab::CurrentSettings.health_check_access_token
expect(page).to have_content("Access token is #{token}")
expect(page).to have_selector('#health-check-token', text: token)
@@ -25,7 +25,7 @@ feature "Admin Health Check", :feature do
describe 'reload access token' do
it 'changes the access token' do
- orig_token = current_application_settings.health_check_access_token
+ orig_token = Gitlab::CurrentSettings.health_check_access_token
click_button 'Reset health check access token'
expect(page).to have_content('New health check access token has been generated!')
diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb
index eec44549a03..25ed3bdc88e 100644
--- a/spec/features/admin/admin_hooks_spec.rb
+++ b/spec/features/admin/admin_hooks_spec.rb
@@ -1,11 +1,10 @@
require 'spec_helper'
-describe 'Admin::Hooks', :js do
- before do
- @project = create(:project)
- sign_in(create(:admin))
+describe 'Admin::Hooks' do
+ let(:user) { create(:admin) }
- @system_hook = create(:system_hook)
+ before do
+ sign_in(user)
end
describe 'GET /admin/hooks' do
@@ -13,15 +12,27 @@ describe 'Admin::Hooks', :js do
visit admin_root_path
page.within '.nav-sidebar' do
- click_on 'Hooks'
+ click_on 'System Hooks', match: :first
end
expect(current_path).to eq(admin_hooks_path)
end
it 'has hooks list' do
+ system_hook = create(:system_hook)
+
+ visit admin_hooks_path
+ expect(page).to have_content(system_hook.url)
+ end
+
+ it 'renders plugins list as well' do
+ allow(Gitlab::Plugin).to receive(:files).and_return(['foo.rb', 'bar.clj'])
+
visit admin_hooks_path
- expect(page).to have_content(@system_hook.url)
+
+ expect(page).to have_content('Plugins')
+ expect(page).to have_content('foo.rb')
+ expect(page).to have_content('bar.clj')
end
end
@@ -43,6 +54,10 @@ describe 'Admin::Hooks', :js do
describe 'Update existing hook' do
let(:new_url) { generate(:url) }
+ before do
+ create(:system_hook)
+ end
+
it 'updates existing hook' do
visit admin_hooks_path
@@ -57,7 +72,11 @@ describe 'Admin::Hooks', :js do
end
end
- describe 'Remove existing hook' do
+ describe 'Remove existing hook', :js do
+ before do
+ create(:system_hook)
+ end
+
context 'removes existing hook' do
it 'from hooks list page' do
visit admin_hooks_path
@@ -76,7 +95,8 @@ describe 'Admin::Hooks', :js do
describe 'Test', :js do
before do
- WebMock.stub_request(:post, @system_hook.url)
+ system_hook = create(:system_hook)
+ WebMock.stub_request(:post, system_hook.url)
visit admin_hooks_path
find('.hook-test-button.dropdown').click
@@ -85,4 +105,41 @@ describe 'Admin::Hooks', :js do
it { expect(current_path).to eq(admin_hooks_path) }
end
+
+ context 'Merge request hook' do
+ describe 'New Hook' do
+ let(:url) { generate(:url) }
+
+ it 'adds new hook' do
+ visit admin_hooks_path
+
+ fill_in 'hook_url', with: url
+ uncheck 'Repository update events'
+ check 'Merge request events'
+
+ expect { click_button 'Add system hook' }.to change(SystemHook, :count).by(1)
+ expect(current_path).to eq(admin_hooks_path)
+ expect(page).to have_content(url)
+ end
+ end
+
+ describe 'Test', :js do
+ before do
+ system_hook = create(:system_hook)
+ WebMock.stub_request(:post, system_hook.url)
+ end
+
+ it 'succeeds if the user has a repository with a merge request' do
+ project = create(:project, :repository)
+ create(:project_member, user: user, project: project)
+ create(:merge_request, source_project: project)
+
+ visit admin_hooks_path
+ find('.hook-test-button.dropdown').click
+ click_link 'Merge requests events'
+
+ expect(page).to have_content 'Hook executed successfully'
+ end
+ end
+ end
end
diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb
index 94b12105088..6d8350e99f1 100644
--- a/spec/features/admin/admin_projects_spec.rb
+++ b/spec/features/admin/admin_projects_spec.rb
@@ -58,7 +58,7 @@ describe "Admin::Projects" 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)
+ expect(page).to have_content(project.full_name)
expect(page).to have_content(project.creator.name)
end
end
@@ -88,7 +88,7 @@ describe "Admin::Projects" do
describe 'add admin himself to a project' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'adds admin a to a project as developer', :js do
@@ -110,8 +110,8 @@ describe "Admin::Projects" do
describe 'admin remove himself from a project' do
before do
- project.team << [user, :master]
- project.team << [current_user, :developer]
+ project.add_master(user)
+ project.add_developer(current_user)
end
it 'removes admin from the project' do
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index c1c54177167..8de2e3d199b 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -19,7 +19,7 @@ describe "Admin Runners" do
end
it 'has all necessary texts' do
- expect(page).to have_text "How to setup"
+ expect(page).to have_text "Setup a shared Runner manually"
expect(page).to have_text "Runners with last contact more than a minute ago: 1"
end
@@ -54,7 +54,7 @@ describe "Admin Runners" do
end
it 'has all necessary texts including no runner message' do
- expect(page).to have_text "How to setup"
+ expect(page).to have_text "Setup a shared Runner manually"
expect(page).to have_text "Runners with last contact more than a minute ago: 0"
expect(page).to have_text 'No runners found'
end
@@ -76,8 +76,8 @@ describe "Admin Runners" do
describe 'projects' do
it 'contains project names' do
- expect(page).to have_content(@project1.name_with_namespace)
- expect(page).to have_content(@project2.name_with_namespace)
+ expect(page).to have_content(@project1.full_name)
+ expect(page).to have_content(@project2.full_name)
end
end
@@ -89,8 +89,8 @@ describe "Admin Runners" do
end
it 'contains name of correct project' do
- expect(page).to have_content(@project1.name_with_namespace)
- expect(page).not_to have_content(@project2.name_with_namespace)
+ expect(page).to have_content(@project1.full_name)
+ expect(page).not_to have_content(@project2.full_name)
end
end
@@ -156,7 +156,7 @@ describe "Admin Runners" do
end
describe 'runners registration token' do
- let!(:token) { current_application_settings.runners_registration_token }
+ let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 1218ea52227..846b8040be6 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -10,18 +10,21 @@ feature 'Admin updates settings' do
end
scenario 'Change visibility settings' do
- choose "application_setting_default_project_visibility_20"
- click_button 'Save'
+ page.within('.as-visibility-access') do
+ choose "application_setting_default_project_visibility_20"
+ click_button 'Save changes'
+ end
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'
+ page.within('.as-visibility-access') 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 changes'
+ end
expect(page).to have_content "Application settings saved successfully"
expect(find('#application_setting_visibility_level_0')).not_to be_checked
@@ -29,22 +32,159 @@ feature 'Admin updates settings' do
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
+ scenario 'Change Visibility and Access Controls' do
+ page.within('.as-visibility-access') do
+ uncheck 'Project export enabled'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.project_export_enabled).to be_falsey
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Account and Limit Settings' do
+ page.within('.as-account-limit') do
+ uncheck 'Gravatar enabled'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.gravatar_enabled).to be_falsey
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Sign-in restrictions' do
+ page.within('.as-signin') do
+ fill_in 'Home page URL', with: 'https://about.gitlab.com/'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.home_page_url).to eq "https://about.gitlab.com/"
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Help page' do
+ page.within('.as-help-page') do
+ 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'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.help_page_text).to eq "Example text"
+ expect(Gitlab::CurrentSettings.help_page_hide_commercial_content).to be_truthy
+ expect(Gitlab::CurrentSettings.help_page_support_url).to eq "http://example.com/help"
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Pages settings' do
+ page.within('.as-pages') do
+ fill_in 'Maximum size of pages (MB)', with: 15
+ check 'Require users to prove ownership of custom domains'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.max_pages_size).to eq 15
+ expect(Gitlab::CurrentSettings.pages_domain_verification_enabled?).to be_truthy
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change CI/CD settings' do
+ page.within('.as-ci-cd') do
+ check 'Enabled Auto DevOps (Beta) for projects by default'
+ fill_in 'Auto devops domain', with: 'domain.com'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.auto_devops_enabled?).to be true
+ expect(Gitlab::CurrentSettings.auto_devops_domain).to eq('domain.com')
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Influx settings' do
+ page.within('.as-influx') do
+ check 'Enable InfluxDB Metrics'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.metrics_enabled?).to be true
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Prometheus settings' do
+ page.within('.as-prometheus') do
+ check 'Enable Prometheus Metrics'
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.prometheus_metrics_enabled?).to be true
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Performance bar settings' do
+ group = create(:group)
+
+ page.within('.as-performance-bar') do
+ check 'Enable the Performance Bar'
+ fill_in 'Allowed group', with: group.path
+ click_on 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(find_field('Enable the Performance Bar')).to be_checked
+ expect(find_field('Allowed group').value).to eq group.path
+
+ page.within('.as-performance-bar') do
+ uncheck 'Enable the Performance Bar'
+ click_on 'Save changes'
+ end
+
+ expect(page).to have_content 'Application settings saved successfully'
+ expect(find_field('Enable the Performance Bar')).not_to be_checked
+ expect(find_field('Allowed group').value).to be_nil
+ end
+
+ scenario 'Change Background jobs settings' do
+ page.within('.as-background') do
+ fill_in 'Throttling Factor', with: 1
+ click_button 'Save changes'
+ end
+
+ expect(Gitlab::CurrentSettings.sidekiq_throttling_factor).to eq(1)
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
+ scenario 'Change Spam settings' do
+ page.within('.as-spam') do
+ check 'Enable reCAPTCHA'
+ fill_in 'reCAPTCHA Site Key', with: 'key'
+ fill_in 'reCAPTCHA Private Key', with: 'key'
+ fill_in 'IPs per user', with: 15
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(Gitlab::CurrentSettings.recaptcha_enabled).to be true
+ expect(Gitlab::CurrentSettings.unique_ips_limit_per_user).to eq(15)
+ end
+
+ scenario 'Configure web terminal' do
+ page.within('.as-terminal') do
+ fill_in 'Max session time', with: 15
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(Gitlab::CurrentSettings.terminal_max_session_time).to eq(15)
+ end
+
+ scenario 'Enable outbound requests' do
+ page.within('.as-outbound') do
+ check 'Allow requests to the local network from hooks and services'
+ click_button 'Save changes'
+ end
+
expect(page).to have_content "Application settings saved successfully"
+ expect(Gitlab::CurrentSettings.allow_local_requests_from_hooks_and_services).to be true
end
scenario 'Change Slack Notifications Service template settings' do
@@ -73,18 +213,22 @@ feature 'Admin updates settings' do
context 'sign-in restrictions', :js do
it 'de-activates oauth sign-in source' do
- find('input#application_setting_enabled_oauth_sign_in_sources_[value=gitlab]').send_keys(:return)
+ page.within('.as-signin') do
+ find('input#application_setting_enabled_oauth_sign_in_sources_[value=gitlab]').send_keys(:return)
- expect(find('.btn', text: 'GitLab.com')).not_to have_css('.active')
+ expect(find('.btn', text: 'GitLab.com')).not_to have_css('.active')
+ end
end
end
scenario 'Change Keys settings' do
- select 'Are forbidden', from: 'RSA SSH keys'
- select 'Are allowed', from: 'DSA SSH keys'
- select 'Must be at least 384 bits', from: 'ECDSA SSH keys'
- select 'Are forbidden', from: 'ED25519 SSH keys'
- click_on 'Save'
+ page.within('.as-visibility-access') do
+ select 'Are forbidden', from: 'RSA SSH keys'
+ select 'Are allowed', from: 'DSA SSH keys'
+ select 'Must be at least 384 bits', from: 'ECDSA SSH keys'
+ select 'Are forbidden', from: 'ED25519 SSH keys'
+ click_on 'Save changes'
+ end
forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE.to_s
@@ -95,29 +239,6 @@ feature 'Admin updates settings' do
expect(find_field('ED25519 SSH keys').value).to eq(forbidden)
end
- scenario 'Change Performance Bar settings' do
- group = create(:group)
-
- check 'Enable the Performance Bar'
- fill_in 'Allowed group', with: group.path
-
- click_on 'Save'
-
- expect(page).to have_content 'Application settings saved successfully'
-
- expect(find_field('Enable the Performance Bar')).to be_checked
- expect(find_field('Allowed group').value).to eq group.path
-
- uncheck 'Enable the Performance Bar'
-
- click_on 'Save'
-
- expect(page).to have_content 'Application settings saved successfully'
-
- expect(find_field('Enable the Performance Bar')).not_to be_checked
- expect(find_field('Allowed group').value).to be_nil
- end
-
def check_all_events
page.check('Active')
page.check('Push')
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index a69b428d117..8f0a3611052 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -26,8 +26,8 @@ describe "Admin::Users" do
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
expect(page).to have_link('Block', href: block_admin_user_path(user))
- expect(page).to have_link('Remove user', href: admin_user_path(user))
- expect(page).to have_link('Remove user and contributions', href: admin_user_path(user, hard_delete: true))
+ expect(page).to have_button('Delete user')
+ expect(page).to have_button('Delete user and contributions')
end
describe 'Two-factor Authentication filters' do
@@ -122,8 +122,8 @@ describe "Admin::Users" 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))
+ expect(page).to have_button('Delete user')
+ expect(page).to have_button('Delete user and contributions')
end
describe 'Impersonation' do
@@ -382,7 +382,7 @@ describe "Admin::Users" do
describe 'update user identities' do
before do
- allow(Gitlab::OAuth::Provider).to receive(:providers).and_return([:twitter, :twitter_updated])
+ allow(Gitlab::Auth::OAuth::Provider).to receive(:providers).and_return([:twitter, :twitter_updated])
end
it 'modifies twitter identity' do
diff --git a/spec/features/admin/services/admin_activates_prometheus_spec.rb b/spec/features/admin/services/admin_activates_prometheus_spec.rb
new file mode 100644
index 00000000000..904fe5b406b
--- /dev/null
+++ b/spec/features/admin/services/admin_activates_prometheus_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe 'Admin activates Prometheus' do
+ let(:admin) { create(:user, :admin) }
+
+ before do
+ sign_in(admin)
+
+ visit(admin_application_settings_services_path)
+
+ click_link('Prometheus')
+ end
+
+ it 'activates service' do
+ check('Active')
+ fill_in('API URL', with: 'http://prometheus.example.com')
+ click_button('Save')
+
+ expect(page).to have_content('Application settings saved successfully')
+ end
+end
diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb
index 89c9d377003..fb6c71ce997 100644
--- a/spec/features/atom/dashboard_issues_spec.rb
+++ b/spec/features/atom/dashboard_issues_spec.rb
@@ -8,22 +8,31 @@ describe "Dashboard Issues Feed" do
let!(:project2) { create(:project) }
before do
- project1.team << [user, :master]
- project2.team << [user, :master]
+ project1.add_master(user)
+ project2.add_master(user)
end
describe "atom feed" do
- it "renders atom feed via personal access token" do
+ it "returns 400 if no filter is used" do
personal_access_token = create(:personal_access_token, user: user)
visit issues_dashboard_path(:atom, private_token: personal_access_token.token)
expect(response_headers['Content-Type']).to have_content('application/atom+xml')
+ expect(page.status_code).to eq(400)
+ end
+
+ it "renders atom feed via personal access token" do
+ personal_access_token = create(:personal_access_token, user: user)
+
+ visit issues_dashboard_path(:atom, private_token: personal_access_token.token, assignee_id: user.id)
+
+ 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 via RSS token" do
- visit issues_dashboard_path(:atom, rss_token: user.rss_token)
+ visit issues_dashboard_path(:atom, rss_token: user.rss_token, assignee_id: user.id)
expect(response_headers['Content-Type']).to have_content('application/atom+xml')
expect(body).to have_selector('title', text: "#{user.name} issues")
@@ -44,7 +53,7 @@ describe "Dashboard Issues Feed" do
let!(:issue2) { create(:issue, author: user, assignees: [assignee], project: project2, description: 'test desc') }
it "renders issue fields" do
- visit issues_dashboard_path(:atom, rss_token: user.rss_token)
+ visit issues_dashboard_path(:atom, rss_token: user.rss_token, assignee_id: assignee.id)
entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]")
@@ -67,7 +76,7 @@ describe "Dashboard Issues Feed" do
end
it "renders issue label and milestone info" do
- visit issues_dashboard_path(:atom, rss_token: user.rss_token)
+ visit issues_dashboard_path(:atom, rss_token: user.rss_token, assignee_id: assignee.id)
entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]")
diff --git a/spec/features/atom/dashboard_spec.rb b/spec/features/atom/dashboard_spec.rb
index 2c0c331b6db..c6683bb3bc9 100644
--- a/spec/features/atom/dashboard_spec.rb
+++ b/spec/features/atom/dashboard_spec.rb
@@ -26,7 +26,7 @@ describe "Dashboard Feed" do
let(:note) { create(:note, noteable: issue, author: user, note: 'Bug confirmed', project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
issue_event(issue, user)
note_event(note, user)
visit dashboard_projects_path(:atom, rss_token: user.rss_token)
diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb
index 4102ac0588a..525ce23aa56 100644
--- a/spec/features/atom/issues_spec.rb
+++ b/spec/features/atom/issues_spec.rb
@@ -9,7 +9,7 @@ describe 'Issues Feed' do
let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
group.add_developer(user)
end
diff --git a/spec/features/atom/users_spec.rb b/spec/features/atom/users_spec.rb
index 2b934d81674..2d074c115dd 100644
--- a/spec/features/atom/users_spec.rb
+++ b/spec/features/atom/users_spec.rb
@@ -47,7 +47,7 @@ describe "User Feed" do
let!(:push_event_payload) { create(:push_event_payload, event: push_event) }
before do
- project.team << [user, :master]
+ project.add_master(user)
issue_event(issue, user)
note_event(note, user)
merge_request_event(merge_request, user)
@@ -64,7 +64,7 @@ describe "User Feed" do
end
it 'has XHTML summaries in issue descriptions' do
- expect(body).to match /<hr ?\/>/
+ expect(body).to match %r{<hr ?/>}
end
it 'has XHTML summaries in notes' do
@@ -72,7 +72,7 @@ describe "User Feed" do
end
it 'has XHTML summaries in merge request descriptions' do
- expect(body).to match /Here is the fix: <a[^>]*><img[^>]*\/><\/a>/
+ expect(body).to match %r{Here is the fix: <a[^>]*><img[^>]*/></a>}
end
it 'has push event commit ID' do
diff --git a/spec/features/auto_deploy_spec.rb b/spec/features/auto_deploy_spec.rb
deleted file mode 100644
index 7a395f62511..00000000000
--- a/spec/features/auto_deploy_spec.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-require 'spec_helper'
-
-describe 'Auto deploy' do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
-
- shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do
- context 'when no deployment service is active' do
- before do
- trun_off
- end
-
- it 'does not show a button to set up auto deploy' do
- visit project_path(project)
- expect(page).to have_no_content('Set up auto deploy')
- end
- end
-
- context 'when a deployment service is active' do
- before do
- trun_on
- visit project_path(project)
- end
-
- it 'shows a button to set up auto deploy' do
- expect(page).to have_link('Set up auto deploy')
- end
-
- it 'includes OpenShift as an available template', :js do
- click_link 'Set up auto deploy'
- click_button 'Apply a GitLab CI Yaml template'
-
- within '.gitlab-ci-yml-selector' do
- expect(page).to have_content('OpenShift')
- end
- end
-
- it 'creates a merge request using "auto-deploy" branch', :js do
- click_link 'Set up auto deploy'
- click_button 'Apply a GitLab CI Yaml template'
- within '.gitlab-ci-yml-selector' do
- click_on 'OpenShift'
- end
- wait_for_requests
- click_button 'Commit changes'
-
- expect(page).to have_content('New Merge Request From auto-deploy into master')
- end
- end
- end
-
- context 'when user configured kubernetes from Integration > Kubernetes' do
- before do
- create :kubernetes_service, project: project
- project.team << [user, :master]
- sign_in user
- end
-
- let(:trun_on) { project.deployment_platform.update!(active: true) }
- let(:trun_off) { project.deployment_platform.update!(active: false) }
-
- it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes'
- end
-
- context 'when user configured kubernetes from CI/CD > Clusters' do
- before do
- create(:cluster, :provided_by_gcp, projects: [project])
- project.team << [user, :master]
- sign_in user
- end
-
- let(:trun_on) { project.deployment_platform.cluster.update!(enabled: true) }
- let(:trun_off) { project.deployment_platform.cluster.update!(enabled: false) }
-
- it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes'
- end
-end
diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb
index e4cfcea45a5..18901a954cc 100644
--- a/spec/features/boards/add_issues_modal_spec.rb
+++ b/spec/features/boards/add_issues_modal_spec.rb
@@ -12,7 +12,7 @@ describe 'Issue Boards add issue modal', :js do
let!(:issue2) { create(:issue, project: project, title: 'hij', description: 'klm') }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index e8d779f5772..52ff47d57f9 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -11,8 +11,8 @@ describe 'Issue Boards', :js do
let!(:user2) { create(:user) }
before do
- project.team << [user, :master]
- project.team << [user2, :master]
+ project.add_master(user)
+ project.add_master(user2)
set_cookie('sidebar_collapsed', 'true')
@@ -69,6 +69,7 @@ describe 'Issue Boards', :js do
let!(:backlog) { create(:label, project: project, name: 'Backlog') }
let!(:closed) { create(:label, project: project, name: 'Closed') }
let!(:accepting) { create(:label, project: project, name: 'Accepting Merge Requests') }
+ let!(:a_plus) { create(:label, project: project, name: 'A+') }
let!(:list1) { create(:list, board: board, label: planning, position: 0) }
let!(:list2) { create(:list, board: board, label: development, position: 1) }
@@ -83,6 +84,7 @@ describe 'Issue Boards', :js do
let!(:issue7) { create(:labeled_issue, project: project, title: 'ggg', description: '777', labels: [development], relative_position: 2) }
let!(:issue8) { create(:closed_issue, project: project, title: 'hhh', description: '888') }
let!(:issue9) { create(:labeled_issue, project: project, title: 'iii', description: '999', labels: [planning, testing, bug, accepting], relative_position: 1) }
+ let!(:issue10) { create(:labeled_issue, project: project, title: 'issue +', description: 'A+ great issue', labels: [a_plus]) }
before do
visit project_board_path(project, board)
@@ -341,7 +343,7 @@ describe 'Issue Boards', :js do
wait_for_requests
- click_link 'Create new label'
+ click_link 'Create project label'
fill_in('new_label_name', with: 'Testing New Label')
@@ -400,6 +402,15 @@ describe 'Issue Boards', :js do
wait_for_empty_boards((3..4))
end
+ it 'filters by label with encoded character' do
+ set_filter("label", a_plus.title)
+ click_filter_link(a_plus.title)
+ submit_filter
+
+ wait_for_board_cards(1, 1)
+ wait_for_empty_boards((2..4))
+ end
+
it 'filters by label with space after reload' do
set_filter("label", "\"#{accepting.title}")
click_filter_link(accepting.title)
@@ -551,7 +562,7 @@ describe 'Issue Boards', :js do
let(:user_guest) { create(:user) }
before do
- project.team << [user_guest, :guest]
+ project.add_guest(user_guest)
sign_out(:user)
sign_in(user_guest)
visit project_board_path(project, board)
diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb
index 4cbb48e2e6e..5abd02dbb48 100644
--- a/spec/features/boards/issue_ordering_spec.rb
+++ b/spec/features/boards/issue_ordering_spec.rb
@@ -13,7 +13,7 @@ describe 'Issue Boards', :js do
let!(:issue3) { create(:labeled_issue, project: project, title: 'testing 3', labels: [label], relative_position: 1) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/boards/keyboard_shortcut_spec.rb b/spec/features/boards/keyboard_shortcut_spec.rb
index 435de3861cf..d820a59aa16 100644
--- a/spec/features/boards/keyboard_shortcut_spec.rb
+++ b/spec/features/boards/keyboard_shortcut_spec.rb
@@ -1,20 +1,38 @@
require 'rails_helper'
describe 'Issue Boards shortcut', :js do
- let(:project) { create(:project) }
+ context 'issues are enabled' do
+ let(:project) { create(:project) }
- before do
- create(:board, project: project)
+ before do
+ create(:board, project: project)
- sign_in(create(:admin))
+ sign_in(create(:admin))
- visit project_path(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_requests
+ end
end
- it 'takes user to issue board index' do
- find('body').native.send_keys('gb')
- expect(page).to have_selector('.boards-list')
+ context 'issues are not enabled' do
+ let(:project) { create(:project, :issues_disabled) }
+
+ before do
+ sign_in(create(:admin))
+
+ visit project_path(project)
+ end
+
+ it 'does not take user to the issue board index' do
+ find('body').native.send_keys('gb')
- wait_for_requests
+ expect(page).to have_selector("body[data-page='projects:show']")
+ end
end
end
diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb
index 422d96175f7..5907bb0840f 100644
--- a/spec/features/boards/modal_filter_spec.rb
+++ b/spec/features/boards/modal_filter_spec.rb
@@ -10,7 +10,7 @@ describe 'Issue Boards add issue modal filtering', :js do
let!(:issue1) { create(:issue, project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -76,7 +76,7 @@ describe 'Issue Boards add issue modal filtering', :js do
let!(:issue) { create(:issue, project: project, author: user2) }
before do
- project.team << [user2, :developer]
+ project.add_developer(user2)
visit_board
end
@@ -99,7 +99,7 @@ describe 'Issue Boards add issue modal filtering', :js do
let!(:issue) { create(:issue, project: project, assignees: [user2]) }
before do
- project.team << [user2, :developer]
+ project.add_developer(user2)
visit_board
end
diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb
index 5ac4d87e90b..6769acb7c9c 100644
--- a/spec/features/boards/new_issue_spec.rb
+++ b/spec/features/boards/new_issue_spec.rb
@@ -8,7 +8,7 @@ describe 'Issue Boards new issue', :js do
context 'authorized user' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index 205900615c4..d4c44c1adf9 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -312,12 +312,12 @@ describe 'Issue Boards', :js do
expect(card).not_to have_content(stretch.title)
end
- it 'creates new label' do
+ it 'creates project label' do
click_card(card)
page.within('.labels') do
click_link 'Edit'
- click_link 'Create new label'
+ click_link 'Create project label'
fill_in 'new_label_name', with: 'test label'
first('.suggest-colors-dropdown a').click
click_button 'Create'
@@ -334,14 +334,14 @@ describe 'Issue Boards', :js do
wait_for_requests
page.within('.subscriptions') do
- click_button 'Subscribe'
+ find('.js-issuable-subscribe-button button:not(.is-checked)').click
wait_for_requests
- expect(page).to have_content('Unsubscribe')
+ expect(page).to have_css('.js-issuable-subscribe-button button.is-checked')
end
end
- it 'has "Unsubscribe" button when already subscribed' do
+ it 'has checked subscription toggle when already subscribed' do
create(:subscription, user: user, project: project, subscribable: issue2, subscribed: true)
visit project_board_path(project, board)
wait_for_requests
@@ -350,10 +350,10 @@ describe 'Issue Boards', :js do
wait_for_requests
page.within('.subscriptions') do
- click_button 'Unsubscribe'
+ find('.js-issuable-subscribe-button button.is-checked').click
wait_for_requests
- expect(page).to have_content('Subscribe')
+ expect(page).to have_css('.js-issuable-subscribe-button button:not(.is-checked)')
end
end
end
diff --git a/spec/features/boards/sub_group_project_spec.rb b/spec/features/boards/sub_group_project_spec.rb
index 11a54079f4f..5fdb8044db2 100644
--- a/spec/features/boards/sub_group_project_spec.rb
+++ b/spec/features/boards/sub_group_project_spec.rb
@@ -24,7 +24,7 @@ describe 'Sub-group project issue boards', :js do
page.within '.labels' do
click_link 'Edit'
- click_link 'Create new label'
+ click_link 'Create project label'
end
page.within '.dropdown-new-label' do
diff --git a/spec/features/ci_lint_spec.rb b/spec/features/ci_lint_spec.rb
deleted file mode 100644
index 9bc23baf6cf..00000000000
--- a/spec/features/ci_lint_spec.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require 'spec_helper'
-
-describe 'CI Lint', :js do
- before do
- sign_in(create(:user))
- end
-
- describe 'YAML parsing' do
- before do
- visit ci_lint_path
- # Ace editor updates a hidden textarea and it happens asynchronously
- # `sleep 0.1` is actually needed here because of this
- find('#ci-editor')
- execute_script("ace.edit('ci-editor').setValue(" + yaml_content.to_json + ");")
- sleep 0.1
- click_on 'Validate'
- end
-
- context 'YAML is correct' do
- let(:yaml_content) do
- File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
- end
-
- it 'parses Yaml' do
- within "table" do
- expect(page).to have_content('Job - rspec')
- expect(page).to have_content('Job - spinach')
- expect(page).to have_content('Deploy Job - staging')
- expect(page).to have_content('Deploy Job - production')
- end
- end
- end
-
- context 'YAML is incorrect' do
- let(:yaml_content) { '' }
-
- it 'displays information about an error' do
- expect(page).to have_content('Status: syntax is incorrect')
- expect(page).to have_content('Error: Please provide content of .gitlab-ci.yml')
- end
- end
-
- describe 'YAML revalidate' do
- let(:yaml_content) { 'my yaml content' }
-
- it 'loads previous YAML content after validation' do
- expect(page).to have_field('content', with: 'my yaml content', visible: false, type: 'textarea')
- end
- end
- end
-end
diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb
index 77dcdf89f37..62a2ec55b00 100644
--- a/spec/features/commits_spec.rb
+++ b/spec/features/commits_spec.rb
@@ -26,7 +26,7 @@ describe 'Commits' do
let!(:status) { create(:generic_commit_status, pipeline: pipeline) }
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
describe 'Commit builds' do
@@ -51,7 +51,7 @@ describe 'Commits' do
context 'when logged as developer' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
describe 'Project commits' do
@@ -145,7 +145,7 @@ describe 'Commits' do
context "when logged as reporter" do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
build.update_attributes(legacy_artifacts_file: artifacts_file)
visit pipeline_path(pipeline)
end
@@ -188,13 +188,13 @@ describe 'Commits' do
let(:branch_name) { 'master' }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_commits_path(project, branch_name)
end
it 'includes the committed_date for each commit' do
- commits = project.repository.commits(branch_name)
+ commits = project.repository.commits(branch_name, limit: 40)
commits.each do |commit|
expect(page).to have_content("authored #{commit.authored_date.strftime("%b %d, %Y")}")
diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb
deleted file mode 100644
index 1fcb8d5bc67..00000000000
--- a/spec/features/copy_as_gfm_spec.rb
+++ /dev/null
@@ -1,686 +0,0 @@
-require 'spec_helper'
-
-describe 'Copy as GFM', :js do
- include MarkupHelper
- include RepoHelpers
- include ActionView::Helpers::JavaScriptHelper
-
- before do
- sign_in(create(:admin))
- end
-
- describe 'Copying rendered GFM' do
- before do
- @feat = MarkdownFeature.new
-
- # `markdown` helper expects a `@project` variable
- @project = @feat.project
-
- 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.
- # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM.
- # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle
- # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper.
-
- # These are all in a single `it` for performance reasons.
- it 'works', :aggregate_failures do
- verify(
- 'nesting',
-
- '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**'
- )
-
- verify(
- 'a real world example from the gitlab-ce README',
-
- <<-GFM.strip_heredoc
- # GitLab
-
- [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master)
- [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby)
- [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq)
- [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
-
- ## Canonical source
-
- The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/).
-
- ## Open source software to collaborate on code
-
- 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
-
- - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
-
- - Each project can also have an issue tracker, issue board, and a wiki
-
- - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises
-
- - Completely free and open source (MIT Expat license)
- 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',
-
- '{-Deleted text-}',
- '{+Added text+}'
- )
-
- verify(
- 'TaskListFilter',
-
- '- [ ] Unchecked task',
- '- [x] Checked task',
- '1. [ ] Unchecked numbered task',
- '1. [x] Checked numbered task'
- )
-
- verify(
- 'ReferenceFilter',
-
- # issue reference
- @feat.issue.to_reference,
- # full issue reference
- @feat.issue.to_reference(full: true),
- # issue URL
- project_issue_url(@project, @feat.issue),
- # issue URL with note anchor
- project_issue_url(@project, @feat.issue, anchor: 'note_123'),
- # issue link
- "[Issue](#{project_issue_url(@project, @feat.issue)})",
- # issue link with note anchor
- "[Issue](#{project_issue_url(@project, @feat.issue, anchor: 'note_123')})"
- )
-
- verify(
- 'AutolinkFilter',
-
- 'https://example.com'
- )
-
- verify(
- 'TableOfContentsFilter',
-
- '[[_TOC_]]'
- )
-
- verify(
- 'EmojiFilter',
-
- ':thumbsup:'
- )
-
- verify(
- 'ImageLinkFilter',
-
- '![Image](https://example.com/image.png)'
- )
-
- verify(
- 'VideoLinkFilter',
-
- '![Video](https://example.com/video.mp4)'
- )
-
- verify(
- 'MathFilter: math as converted from GFM to HTML',
-
- '$`c = \pm\sqrt{a^2 + b^2}`$',
-
- # math block
- <<-GFM.strip_heredoc
- ```math
- c = \pm\sqrt{a^2 + b^2}
- ```
- GFM
- )
-
- aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do
- gfm = '$`c = \pm\sqrt{a^2 + b^2}`$'
-
- html = <<-HTML.strip_heredoc
- <span class="katex">
- <span class="katex-mathml">
- <math>
- <semantics>
- <mrow>
- <mi>c</mi>
- <mo>=</mo>
- <mo>±</mo>
- <msqrt>
- <mrow>
- <msup>
- <mi>a</mi>
- <mn>2</mn>
- </msup>
- <mo>+</mo>
- <msup>
- <mi>b</mi>
- <mn>2</mn>
- </msup>
- </mrow>
- </msqrt>
- </mrow>
- <annotation encoding="application/x-tex">c = \\pm\\sqrt{a^2 + b^2}</annotation>
- </semantics>
- </math>
- </span>
- <span class="katex-html" aria-hidden="true">
- <span class="strut" style="height: 0.913389em;"></span>
- <span class="strut bottom" style="height: 1.04em; vertical-align: -0.126611em;"></span>
- <span class="base textstyle uncramped">
- <span class="mord mathit">c</span>
- <span class="mrel">=</span>
- <span class="mord">±</span>
- <span class="sqrt mord"><span class="sqrt-sign" style="top: -0.073389em;">
- <span class="style-wrap reset-textstyle textstyle uncramped">√</span>
- </span>
- <span class="vlist">
- <span class="" style="top: 0em;">
- <span class="fontsize-ensurer reset-size5 size5">
- <span class="" style="font-size: 1em;">​</span>
- </span>
- <span class="mord textstyle cramped">
- <span class="mord">
- <span class="mord mathit">a</span>
- <span class="msupsub">
- <span class="vlist">
- <span class="" style="top: -0.289em; margin-right: 0.05em;">
- <span class="fontsize-ensurer reset-size5 size5">
- <span class="" style="font-size: 0em;">​</span>
- </span>
- <span class="reset-textstyle scriptstyle cramped">
- <span class="mord mathrm">2</span>
- </span>
- </span>
- <span class="baseline-fix">
- <span class="fontsize-ensurer reset-size5 size5">
- <span class="" style="font-size: 0em;">​</span>
- </span>
- ​</span>
- </span>
- </span>
- </span>
- <span class="mbin">+</span>
- <span class="mord">
- <span class="mord mathit">b</span>
- <span class="msupsub">
- <span class="vlist">
- <span class="" style="top: -0.289em; margin-right: 0.05em;">
- <span class="fontsize-ensurer reset-size5 size5">
- <span class="" style="font-size: 0em;">​</span>
- </span>
- <span class="reset-textstyle scriptstyle cramped">
- <span class="mord mathrm">2</span>
- </span>
- </span>
- <span class="baseline-fix">
- <span class="fontsize-ensurer reset-size5 size5">
- <span class="" style="font-size: 0em;">​</span>
- </span>
- ​</span>
- </span>
- </span>
- </span>
- </span>
- </span>
- <span class="" style="top: -0.833389em;">
- <span class="fontsize-ensurer reset-size5 size5">
- <span class="" style="font-size: 1em;">​</span>
- </span>
- <span class="reset-textstyle textstyle uncramped sqrt-line"></span>
- </span>
- <span class="baseline-fix">
- <span class="fontsize-ensurer reset-size5 size5">
- <span class="" style="font-size: 1em;">​</span>
- </span>
- ​</span>
- </span>
- </span>
- </span>
- </span>
- </span>
- HTML
-
- output_gfm = html_to_gfm(html)
- expect(output_gfm.strip).to eq(gfm.strip)
- end
-
- verify(
- 'SanitizationFilter',
-
- <<-GFM.strip_heredoc
- <sub>sub</sub>
-
- <dl>
- <dt>dt</dt>
- <dd>dd</dd>
- </dl>
-
- <kbd>kbd</kbd>
-
- <q>q</q>
-
- <samp>samp</samp>
-
- <var>var</var>
-
- <ruby>ruby</ruby>
-
- <rt>rt</rt>
-
- <rp>rp</rp>
-
- <abbr>abbr</abbr>
-
- <summary>summary</summary>
-
- <details>details</details>
- GFM
- )
-
- verify(
- 'SanitizationFilter',
-
- <<-GFM.strip_heredoc,
- ```
- Plain text
- ```
- GFM
-
- <<-GFM.strip_heredoc,
- ```ruby
- def foo
- bar
- end
- ```
- GFM
-
- <<-GFM.strip_heredoc
- Foo
-
- This is an example of GFM
-
- ```js
- Code goes here
- ```
- GFM
- )
-
- verify(
- 'MarkdownFilter',
-
- "Line with two spaces at the end \nto insert a linebreak",
-
- '`code`',
- '`` code with ` ticks ``',
-
- '> Quote',
-
- # multiline quote
- <<-GFM.strip_heredoc,
- > Multiline
- > Quote
- >
- > With multiple paragraphs
- GFM
-
- '![Image](https://example.com/image.png)',
-
- '# Heading with no anchor link',
-
- '[Link](https://example.com)',
-
- '- List item',
-
- # multiline list item
- <<-GFM.strip_heredoc,
- - Multiline
- List item
- GFM
-
- # nested lists
- <<-GFM.strip_heredoc,
- - Nested
-
- - Lists
- GFM
-
- # list with blockquote
- <<-GFM.strip_heredoc,
- - List
-
- > Blockquote
- GFM
-
- '1. Numbered list item',
-
- # multiline numbered list item
- <<-GFM.strip_heredoc,
- 1. Multiline
- Numbered list item
- GFM
-
- # nested numbered list
- <<-GFM.strip_heredoc,
- 1. Nested
-
- 1. Numbered lists
- GFM
-
- '# Heading',
- '## Heading',
- '### Heading',
- '#### Heading',
- '##### Heading',
- '###### Heading',
-
- '**Bold**',
-
- '_Italics_',
-
- '~~Strikethrough~~',
-
- '2^2',
-
- '-----',
-
- # table
- <<-GFM.strip_heredoc,
- | Centered | Right | Left |
- |:--------:|------:|------|
- | Foo | Bar | **Baz** |
- | Foo | Bar | **Baz** |
- GFM
-
- # table with empty heading
- <<-GFM.strip_heredoc,
- | | x | y |
- |---|---|---|
- | a | 1 | 0 |
- | b | 0 | 1 |
- GFM
- )
- end
-
- alias_method :gfm_to_html, :markdown
-
- def verify(label, *gfms)
- aggregate_failures(label) do
- gfms.each do |gfm|
- html = gfm_to_html(gfm).gsub(/\A&#x000A;|&#x000A;\z/, '')
- output_gfm = html_to_gfm(html)
- expect(output_gfm.strip).to eq(gfm.strip)
- end
- end
- end
-
- # Fake a `current_user` helper
- def current_user
- @feat.user
- end
- end
-
- describe 'Copying code' do
- let(:project) { create(:project, :repository) }
-
- context 'from a diff' do
- shared_examples 'copying code from a diff' do
- context 'selecting one word of text' do
- it 'copies as inline code' do
- verify(
- '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no',
-
- '`RuntimeError`',
-
- target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
- )
- end
- end
-
- context 'selecting one line of text' do
- it 'copies as inline code' do
- verify(
- '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]',
-
- '`raise RuntimeError, "System commands must be given as an array of strings"`',
-
- target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
- )
- end
- end
-
- context 'selecting multiple lines of text' do
- it 'copies as a code block' do
- verify(
- '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
-
- <<-GFM.strip_heredoc,
- ```ruby
- raise RuntimeError, "System commands must be given as an array of strings"
- end
- ```
- GFM
-
- target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
- )
- end
- end
- end
-
- context 'inline diff' do
- before do
- visit project_commit_path(project, sample_commit.id, view: 'inline')
- end
-
- it_behaves_like 'copying code from a diff'
- end
-
- context 'parallel diff' do
- before do
- visit project_commit_path(project, sample_commit.id, view: 'parallel')
- end
-
- it_behaves_like 'copying code from a diff'
-
- context 'selecting code on the left' do
- it 'copies as a code block' do
- verify(
- '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
-
- <<-GFM.strip_heredoc,
- ```ruby
- unless cmd.is_a?(Array)
- raise "System commands must be given as an array of strings"
- end
- ```
- GFM
-
- target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].left-side'
- )
- end
- end
-
- context 'selecting code on the right' do
- it 'copies as a code block' do
- verify(
- '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
-
- <<-GFM.strip_heredoc,
- ```ruby
- unless cmd.is_a?(Array)
- raise RuntimeError, "System commands must be given as an array of strings"
- end
- ```
- GFM
-
- target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].right-side'
- )
- end
- end
- end
- end
-
- context 'from a blob' do
- before do
- visit project_blob_path(project, File.join('master', 'files/ruby/popen.rb'))
- wait_for_requests
- end
-
- context 'selecting one word of text' do
- it 'copies as inline code' do
- verify(
- '.line[id="LC9"] .no',
-
- '`RuntimeError`'
- )
- end
- end
-
- context 'selecting one line of text' do
- it 'copies as inline code' do
- verify(
- '.line[id="LC9"]',
-
- '`raise RuntimeError, "System commands must be given as an array of strings"`'
- )
- end
- end
-
- context 'selecting multiple lines of text' do
- it 'copies as a code block' do
- verify(
- '.line[id="LC9"], .line[id="LC10"]',
-
- <<-GFM.strip_heredoc,
- ```ruby
- raise RuntimeError, "System commands must be given as an array of strings"
- end
- ```
- GFM
- )
- end
- end
- end
-
- context 'from a GFM code block' do
- before do
- visit project_blob_path(project, File.join('markdown', 'doc/api/users.md'))
- wait_for_requests
- end
-
- context 'selecting one word of text' do
- it 'copies as inline code' do
- verify(
- '.line[id="LC27"] .s2',
-
- '`"bio"`'
- )
- end
- end
-
- context 'selecting one line of text' do
- it 'copies as inline code' do
- verify(
- '.line[id="LC27"]',
-
- '`"bio": null,`'
- )
- end
- end
-
- context 'selecting multiple lines of text' do
- it 'copies as a code block with the correct language' do
- verify(
- '.line[id="LC27"], .line[id="LC28"]',
-
- <<-GFM.strip_heredoc,
- ```json
- "bio": null,
- "skype": "",
- ```
- GFM
- )
- end
- end
- end
-
- def verify(selector, gfm, target: nil)
- html = html_for_selector(selector)
- output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target)
- expect(output_gfm.strip).to eq(gfm.strip)
- end
- end
-
- def html_for_selector(selector)
- js = <<-JS.strip_heredoc
- (function(selector) {
- var els = document.querySelectorAll(selector);
- var htmls = _.map(els, function(el) { return el.outerHTML; });
- return htmls.join("\\n");
- })("#{escape_javascript(selector)}")
- JS
- page.evaluate_script(js)
- end
-
- def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil)
- js = <<-JS.strip_heredoc
- (function(html) {
- var transformer = window.CopyAsGFM[#{transformer.inspect}];
-
- var node = document.createElement('div');
- $(html).each(function() { node.appendChild(this) });
-
- var targetSelector = #{target.to_json};
- var target;
- if (targetSelector) {
- target = document.querySelector(targetSelector);
- }
-
- node = transformer(node, target);
- if (!node) return null;
-
- return window.CopyAsGFM.nodeToGFM(node);
- })("#{escape_javascript(html)}")
- JS
- page.evaluate_script(js)
- end
-end
diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb
index 177cd50dd72..ef493db3f11 100644
--- a/spec/features/cycle_analytics_spec.rb
+++ b/spec/features/cycle_analytics_spec.rb
@@ -6,7 +6,7 @@ feature 'Cycle Analytics', :js do
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, commit_message: "References #{issue.to_reference}") }
+ let(:mr) { create_merge_request_closing_issue(user, project, 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
@@ -41,8 +41,8 @@ feature 'Cycle Analytics', :js do
allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
project.add_master(user)
- create_cycle
- deploy_master
+ @build = create_cycle(user, project, issue, mr, milestone, pipeline)
+ deploy_master(user, project)
sign_in(user)
visit project_cycle_analytics_path(project)
@@ -95,7 +95,7 @@ feature 'Cycle Analytics', :js do
before do
user.update_attribute(:preferred_language, 'es')
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_cycle_analytics_path(project)
wait_for_requests
@@ -113,11 +113,12 @@ feature 'Cycle Analytics', :js do
context "as a guest" do
before do
+ project.add_developer(user)
project.add_guest(guest)
allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
- create_cycle
- deploy_master
+ create_cycle(user, project, issue, mr, milestone, pipeline)
+ deploy_master(user, project)
sign_in(guest)
visit project_cycle_analytics_path(project)
@@ -165,16 +166,6 @@ feature 'Cycle Analytics', :js do
expect(find('.stage-events')).to have_content("!#{mr.iid}")
end
- def create_cycle
- issue.update(milestone: milestone)
- pipeline.run
-
- @build = create(:ci_build, pipeline: pipeline, status: :success, author: user)
-
- merge_merge_requests_closing_issue(issue)
- ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash)
- end
-
def click_stage(stage_name)
find('.stage-nav li', text: stage_name).click
wait_for_requests
diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb
index bd115785646..a74a8aac2b2 100644
--- a/spec/features/dashboard/activity_spec.rb
+++ b/spec/features/dashboard/activity_spec.rb
@@ -24,6 +24,7 @@ feature 'Dashboard > Activity' do
end
let(:note) { create(:note, project: project, noteable: merge_request) }
+ let(:milestone) { create(:milestone, :active, project: project, title: '1.0') }
let!(:push_event) do
event = create(:push_event, project: project, author: user)
@@ -54,6 +55,10 @@ feature 'Dashboard > Activity' do
create(:event, :commented, project: project, target: note, author: user)
end
+ let!(:milestone_event) do
+ create(:event, :closed, project: project, target: milestone, author: user)
+ end
+
before do
project.add_master(user)
@@ -68,6 +73,7 @@ feature 'Dashboard > Activity' do
expect(page).to have_content('accepted')
expect(page).to have_content('closed')
expect(page).to have_content('commented on')
+ expect(page).to have_content('closed milestone')
end
end
@@ -107,6 +113,7 @@ feature 'Dashboard > Activity' do
expect(page).not_to have_content('accepted')
expect(page).to have_content('closed')
expect(page).not_to have_content('commented on')
+ expect(page).to have_content('closed milestone')
end
end
diff --git a/spec/features/dashboard/archived_projects_spec.rb b/spec/features/dashboard/archived_projects_spec.rb
index e8d699ff5e0..b36231fd78b 100644
--- a/spec/features/dashboard/archived_projects_spec.rb
+++ b/spec/features/dashboard/archived_projects_spec.rb
@@ -6,8 +6,8 @@ RSpec.describe 'Dashboard Archived Project' do
let(:archived_project) { create(:project, :archived) }
before do
- project.team << [user, :master]
- archived_project.team << [user, :master]
+ project.add_master(user)
+ archived_project.add_master(user)
sign_in(user)
diff --git a/spec/features/dashboard/datetime_on_tooltips_spec.rb b/spec/features/dashboard/datetime_on_tooltips_spec.rb
index 349f9a47112..089c388636d 100644
--- a/spec/features/dashboard/datetime_on_tooltips_spec.rb
+++ b/spec/features/dashboard/datetime_on_tooltips_spec.rb
@@ -8,7 +8,7 @@ feature 'Tooltips on .timeago dates', :js do
context 'on the activity tab' do
before do
- project.team << [user, :master]
+ project.add_master(user)
Event.create( project: project, author_id: user.id, action: Event::JOINED,
updated_at: created_date, created_at: created_date)
@@ -27,7 +27,7 @@ feature 'Tooltips on .timeago dates', :js do
context 'on the snippets tab' do
before do
- project.team << [user, :master]
+ project.add_master(user)
create(:snippet, author: user, updated_at: created_date, created_at: created_date)
sign_in user
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index d92c002b4e7..a71020002dc 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -94,22 +94,14 @@ feature 'Dashboard Groups page', :js do
end
it 'can toggle parent group' do
- # Collapsed by default
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
- expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
-
# expand
click_group_caret(group)
- expect(page).to have_selector("#group-#{group.id} .fa-caret-down")
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
# collapse
click_group_caret(group)
- expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
- expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end
end
diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb
index 8759950e013..bab34ac9346 100644
--- a/spec/features/dashboard/issues_filter_spec.rb
+++ b/spec/features/dashboard/issues_filter_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
feature 'Dashboard Issues filtering', :js do
- include SortingHelper
+ include Spec::Support::Helpers::Features::SortingHelpers
let(:user) { create(:user) }
let(:project) { create(:project) }
@@ -17,6 +17,12 @@ feature 'Dashboard Issues filtering', :js do
visit_issues
end
+ context 'without any filter' do
+ it 'shows error message' do
+ expect(page).to have_content 'Please select at least one filter to see results'
+ end
+ end
+
context 'filtering by milestone' do
it 'shows all issues with no milestone' do
show_milestone_dropdown
@@ -27,15 +33,6 @@ feature 'Dashboard Issues filtering', :js do
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
@@ -68,13 +65,6 @@ feature 'Dashboard Issues filtering', :js 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
@@ -89,15 +79,19 @@ feature 'Dashboard Issues filtering', :js do
end
context 'sorting' do
- it 'shows sorted issues' do
- sorting_by('Created date')
- visit_issues
+ before do
+ visit_issues(assignee_id: user.id)
+ end
+
+ it 'remembers last sorting value' do
+ sort_by('Created date')
+ visit_issues(assignee_id: user.id)
expect(find('.issues-filters')).to have_content('Created date')
end
it 'keeps sorting issues after visiting Projects Issues page' do
- sorting_by('Created date')
+ sort_by('Created date')
visit project_issues_path(project)
expect(find('.issues-filters')).to have_content('Created date')
diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb
index 5b4c00b3c7e..e41a2e4ce09 100644
--- a/spec/features/dashboard/issues_spec.rb
+++ b/spec/features/dashboard/issues_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'Dashboard Issues' do
let!(:other_issue) { create :issue, project: project }
before do
- [project, project_with_issues_disabled].each { |project| project.team << [current_user, :master] }
+ [project, project_with_issues_disabled].each { |project| project.add_master(current_user) }
sign_in(current_user)
visit issues_dashboard_path(assignee_id: current_user.id)
end
@@ -51,15 +51,6 @@ RSpec.describe 'Dashboard Issues' do
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)
@@ -74,8 +65,8 @@ RSpec.describe 'Dashboard Issues' do
find('.new-project-item-select-button').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)
+ expect(page).to have_content(project.full_name)
+ expect(page).not_to have_content(project_with_issues_disabled.full_name)
end
end
@@ -84,8 +75,8 @@ RSpec.describe 'Dashboard Issues' do
wait_for_requests
- project_path = "/#{project.path_with_namespace}"
- project_json = { name: project.name_with_namespace, url: project_path }.to_json
+ project_path = "/#{project.full_path}"
+ project_json = { name: project.full_name, url: project_path }.to_json
# simulate selection, and prevent overlap by dropdown menu
first('.project-item-select', visible: false)
diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb
index 991d360ccaf..0965b745c03 100644
--- a/spec/features/dashboard/merge_requests_spec.rb
+++ b/spec/features/dashboard/merge_requests_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
feature 'Dashboard Merge Requests' do
+ include Spec::Support::Helpers::Features::SortingHelpers
include FilterItemSelectHelper
- include SortingHelper
include ProjectForksHelper
let(:current_user) { create :user }
@@ -28,8 +28,8 @@ feature 'Dashboard Merge Requests' do
find('.new-project-item-select-button').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)
+ expect(page).to have_content(project.full_name)
+ expect(page).not_to have_content(project_with_disabled_merge_requests.full_name)
end
end
end
@@ -44,36 +44,38 @@ feature 'Dashboard 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)
+ create(:merge_request,
+ assignee: current_user,
+ source_project: project,
+ author: create(:user))
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
- )
+ target_project: public_project, source_project: forked_project,
+ author: create(:user))
end
let!(:authored_merge_request) do
create(:merge_request,
- source_branch: 'markdown', author: current_user,
- target_project: project, source_project: project
- )
+ source_branch: 'markdown',
+ source_project: project,
+ author: current_user)
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
- )
+ 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
- )
+ source_project: project,
+ author: create(:user))
end
before do
@@ -101,19 +103,15 @@ feature 'Dashboard Merge Requests' do
expect(page).not_to have_content(other_merge_request.title)
end
- it 'shows all merge requests', :js do
+ it 'shows error message without filter', :js 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)
+ expect(page).to have_content('Please select at least one filter to see results')
end
it 'shows sorted merge requests' do
- sorting_by('Created date')
+ sort_by('Created date')
visit merge_requests_dashboard_path(assignee_id: current_user.id)
@@ -121,7 +119,7 @@ feature 'Dashboard Merge Requests' do
end
it 'keeps sorting merge requests after visiting Projects MR page' do
- sorting_by('Created date')
+ sort_by('Created date')
visit project_merge_requests_path(project)
diff --git a/spec/features/dashboard/milestones_spec.rb b/spec/features/dashboard/milestones_spec.rb
index 41d37376cfb..7787772a958 100644
--- a/spec/features/dashboard/milestones_spec.rb
+++ b/spec/features/dashboard/milestones_spec.rb
@@ -16,7 +16,7 @@ feature 'Dashboard > Milestones' do
let(:project) { create(:project, namespace: user.namespace) }
let!(:milestone) { create(:milestone, project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit dashboard_milestones_path
end
diff --git a/spec/features/dashboard/project_member_activity_index_spec.rb b/spec/features/dashboard/project_member_activity_index_spec.rb
index 8f96899fb4f..6c3093607b0 100644
--- a/spec/features/dashboard/project_member_activity_index_spec.rb
+++ b/spec/features/dashboard/project_member_activity_index_spec.rb
@@ -5,7 +5,7 @@ feature 'Project member activity', :js do
let(:project) { create(:project, :public, name: 'x', namespace: user.namespace) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
def visit_activities_and_wait_with_event(event_type)
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index fbf8b5c0db6..986f864f0b5 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -6,7 +6,7 @@ feature 'Dashboard Projects' do
let(:project2) { create(:project, :public, name: 'Community project') }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
end
@@ -37,6 +37,14 @@ feature 'Dashboard Projects' do
expect(page).to have_xpath("//time[@datetime='#{project.last_repository_updated_at.getutc.iso8601}']")
end
+
+ it 'shows the last_activity_at attribute as the update date' do
+ project.update_attributes!(last_repository_updated_at: 1.hour.ago, last_activity_at: Time.now)
+
+ visit dashboard_projects_path
+
+ expect(page).to have_xpath("//time[@datetime='#{project.last_activity_at.getutc.iso8601}']")
+ end
end
context 'when last_repository_updated_at and last_activity_at are missing' do
diff --git a/spec/features/dashboard/todos/todos_filtering_spec.rb b/spec/features/dashboard/todos/todos_filtering_spec.rb
index ad0f132da8c..7b359b0c651 100644
--- a/spec/features/dashboard/todos/todos_filtering_spec.rb
+++ b/spec/features/dashboard/todos/todos_filtering_spec.rb
@@ -15,8 +15,8 @@ feature 'Dashboard > User filters todos', :js 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]
+ project_1.add_developer(user_1)
+ project_2.add_developer(user_1)
sign_in(user_1)
visit dashboard_todos_path
end
@@ -24,14 +24,14 @@ feature 'Dashboard > User filters todos', :js do
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
+ fill_in 'Search projects', with: project_1.full_name
+ click_link project_1.full_name
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
+ expect(page).to have_content project_1.full_name
+ expect(page).not_to have_content project_2.full_name
end
context 'Author filter' do
@@ -66,8 +66,8 @@ feature 'Dashboard > User filters todos', :js do
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]
+ project_1.add_developer(user_3)
+ project_2.add_developer(user_4)
visit dashboard_todos_path(state: 'done')
diff --git a/spec/features/dashboard/todos/todos_sorting_spec.rb b/spec/features/dashboard/todos/todos_sorting_spec.rb
index b7d39a872b0..10e3ad843fd 100644
--- a/spec/features/dashboard/todos/todos_sorting_spec.rb
+++ b/spec/features/dashboard/todos/todos_sorting_spec.rb
@@ -9,7 +9,7 @@ feature 'Dashboard > User sorts todos' do
let(:label_3) { create(:label, title: 'label_3', project: project, priority: 3) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'sort options' do
diff --git a/spec/features/dashboard/user_filters_projects_spec.rb b/spec/features/dashboard/user_filters_projects_spec.rb
index c352b6ded14..92f4d4b854c 100644
--- a/spec/features/dashboard/user_filters_projects_spec.rb
+++ b/spec/features/dashboard/user_filters_projects_spec.rb
@@ -7,14 +7,14 @@ describe 'Dashboard > User filters projects' do
let(:project2) { create(:project, name: 'Treasure', namespace: user2.namespace) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
describe 'filtering personal projects' do
before do
- project2.team << [user, :developer]
+ project2.add_developer(user)
visit dashboard_projects_path
end
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 1dd7547a7fc..31862b2e8f4 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -112,13 +112,6 @@ feature 'Expand and collapse diffs', :js do
wait_for_requests
end
- it 'makes a request to get the content' do
- ajax_uris = evaluate_script('ajaxUris')
-
- expect(ajax_uris).not_to be_empty
- expect(ajax_uris.first).to include('large_diff.md')
- end
-
it 'shows the diff content' do
expect(large_diff).to have_selector('.code')
expect(large_diff).not_to have_selector('.nothing-here-block')
diff --git a/spec/features/explore/groups_spec.rb b/spec/features/explore/groups_spec.rb
new file mode 100644
index 00000000000..e4ef47d88dd
--- /dev/null
+++ b/spec/features/explore/groups_spec.rb
@@ -0,0 +1,87 @@
+require 'spec_helper'
+
+describe 'Explore Groups', :js do
+ let(:user) { create :user }
+ let(:group) { create :group }
+ let!(:private_project) do
+ create :project, :private, namespace: group do |project|
+ create(:issue, project: internal_project)
+ create(:merge_request, source_project: project, target_project: project)
+ end
+ end
+
+ let!(:internal_project) do
+ create :project, :internal, namespace: group do |project|
+ create(:issue, project: project)
+ create(:merge_request, source_project: project, target_project: project)
+ end
+ end
+
+ let!(:public_project) do
+ create(:project, :public, namespace: group) do |project|
+ create(:issue, project: project)
+ create(:merge_request, source_project: project, target_project: project)
+ end
+ end
+
+ shared_examples 'renders public and internal projects' do
+ it do
+ visit_page
+ expect(page).to have_content(public_project.name)
+ expect(page).to have_content(internal_project.name)
+ expect(page).not_to have_content(private_project.name)
+ end
+ end
+
+ shared_examples 'renders only public project' do
+ it do
+ visit_page
+ expect(page).to have_content(public_project.name)
+ expect(page).not_to have_content(internal_project.name)
+ expect(page).not_to have_content(private_project.name)
+ end
+ end
+
+ shared_examples 'renders group in public groups area' do
+ it do
+ visit explore_groups_path
+ expect(page).to have_content(group.name)
+ end
+ end
+
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
+
+ it_behaves_like 'renders public and internal projects' do
+ subject(:visit_page) { visit group_path(group) }
+ end
+
+ it_behaves_like 'renders public and internal projects' do
+ subject(:visit_page) { visit issues_group_path(group) }
+ end
+
+ it_behaves_like 'renders public and internal projects' do
+ subject(:visit_page) { visit merge_requests_group_path(group) }
+ end
+
+ it_behaves_like 'renders group in public groups area'
+ end
+
+ context 'when signed out' do
+ it_behaves_like 'renders only public project' do
+ subject(:visit_page) { visit group_path(group) }
+ end
+
+ it_behaves_like 'renders only public project' do
+ subject(:visit_page) { visit issues_group_path(group) }
+ end
+
+ it_behaves_like 'renders only public project' do
+ subject(:visit_page) { visit merge_requests_group_path(group) }
+ end
+
+ it_behaves_like 'renders group in public groups area'
+ end
+end
diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb
index f04e13adba7..f8c4db1403c 100644
--- a/spec/features/global_search_spec.rb
+++ b/spec/features/global_search_spec.rb
@@ -5,7 +5,7 @@ feature 'Global search' do
let(:project) { create(:project, namespace: user.namespace) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -22,7 +22,7 @@ feature 'Global search' do
click_button "Go"
select_filter("Issues")
- expect(page).to have_selector('.gl-pagination .page', count: 2)
+ expect(page).to have_selector('.gl-pagination .next')
end
end
end
diff --git a/spec/features/group_variables_spec.rb b/spec/features/group_variables_spec.rb
index e9b375f4c94..f7863807572 100644
--- a/spec/features/group_variables_spec.rb
+++ b/spec/features/group_variables_spec.rb
@@ -3,76 +3,15 @@ require 'spec_helper'
feature 'Group variables', :js do
let(:user) { create(:user) }
let(:group) { create(:group) }
+ let!(:variable) { create(:ci_group_variable, key: 'test_key', value: 'test value', group: group) }
+ let(:page_path) { group_settings_ci_cd_path(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 value'
- 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
+ visit page_path
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
+ it_behaves_like 'variable list'
end
diff --git a/spec/features/groups/activity_spec.rb b/spec/features/groups/activity_spec.rb
index d3b25ec3d6c..7bc809b3104 100644
--- a/spec/features/groups/activity_spec.rb
+++ b/spec/features/groups/activity_spec.rb
@@ -8,11 +8,30 @@ feature 'Group activity page' do
context 'when signed in' do
before do
sign_in(user)
- visit 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"
+ describe 'RSS' do
+ before do
+ visit 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 'when project is in the group', :js do
+ let(:project) { create(:project, :public, namespace: group) }
+
+ before do
+ project.add_master(user)
+
+ visit path
+ end
+
+ it 'renders user joined to project event' do
+ expect(page).to have_content 'joined project'
+ end
+ end
end
context 'when signed out' do
diff --git a/spec/features/groups/empty_states_spec.rb b/spec/features/groups/empty_states_spec.rb
index 243e8536168..04217fec06c 100644
--- a/spec/features/groups/empty_states_spec.rb
+++ b/spec/features/groups/empty_states_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Groups Merge Requests Empty States' do
+feature 'Group empty states' do
let(:group) { create(:group) }
let(:user) { create(:group_member, :developer, user: create(:user), group: group ).user }
@@ -8,62 +8,100 @@ feature 'Groups Merge Requests Empty States' do
sign_in(user)
end
- context 'group has a project' do
- let(:project) { create(:project, namespace: group) }
+ [:issue, :merge_request].each do |issuable|
+ issuable_name = issuable.to_s.humanize.downcase
+ project_relation = issuable == :issue ? :project : :source_project
- before do
- project.add_master(user)
- end
+ context "for #{issuable_name}s" do
+ let(:path) { public_send(:"#{issuable}s_group_path", group) }
- context 'the project has a merge request' do
- before do
- create(:merge_request, source_project: project)
+ context 'group has a project' do
+ let(:project) { create(:project, namespace: group) }
- visit merge_requests_group_path(group)
- end
+ before do
+ project.add_master(user)
+ end
- it 'should not display an empty state' do
- expect(page).not_to have_selector('.empty-state')
- end
- end
+ context "the project has #{issuable_name}s" do
+ before do
+ create(issuable, project_relation => project)
- context 'the project has no merge requests', :js do
- before do
- visit merge_requests_group_path(group)
- end
+ visit path
+ end
- it 'should display an empty state' do
- expect(page).to have_selector('.empty-state')
- end
+ it 'does not display an empty state' do
+ expect(page).not_to have_selector('.empty-state')
+ end
+ end
+
+ context "the project has no #{issuable_name}s", :js do
+ before do
+ visit path
+ end
+
+ it 'displays an empty state' do
+ expect(page).to have_selector('.empty-state')
+ end
+
+ it "shows a new #{issuable_name} button" do
+ within '.empty-state' do
+ expect(page).to have_content("create #{issuable_name}")
+ end
+ end
+
+ it "the new #{issuable_name} button opens a project dropdown" do
+ within '.empty-state' do
+ find('.new-project-item-select-button').click
+ end
- it 'should show a new merge request button' do
- within '.empty-state' do
- expect(page).to have_content('create merge request')
+ expect(page).to have_selector('.ajax-project-dropdown')
+ end
end
end
- it 'the new merge request button opens a project dropdown' do
- within '.empty-state' do
- find('.new-project-item-select-button').click
- end
+ context 'group without a project' do
+ context 'group has a subgroup', :nested_groups do
+ let(:subgroup) { create(:group, parent: group) }
+ let(:subgroup_project) { create(:project, namespace: subgroup) }
- expect(page).to have_selector('.ajax-project-dropdown')
- end
- end
- end
+ context "the project has #{issuable_name}s" do
+ before do
+ create(issuable, project_relation => subgroup_project)
- context 'group without a project' do
- before do
- visit merge_requests_group_path(group)
- end
+ visit path
+ end
- it 'should display an empty state' do
- expect(page).to have_selector('.empty-state')
- end
+ it 'does not display an empty state' do
+ expect(page).not_to have_selector('.empty-state')
+ end
+ end
- it 'should not show a new merge request button' do
- within '.empty-state' do
- expect(page).not_to have_link('create merge request')
+ context "the project has no #{issuable_name}s" do
+ before do
+ visit path
+ end
+
+ it 'displays an empty state' do
+ expect(page).to have_selector('.empty-state')
+ end
+ end
+ end
+
+ context 'group has no subgroups' do
+ before do
+ visit path
+ end
+
+ it 'displays an empty state' do
+ expect(page).to have_selector('.empty-state')
+ end
+
+ it "shows a new #{issuable_name} button" do
+ within '.empty-state' do
+ expect(page).not_to have_link("create #{issuable_name}")
+ end
+ end
+ end
end
end
end
diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb
index b83bad3befb..1ce30015e81 100644
--- a/spec/features/groups/group_settings_spec.rb
+++ b/spec/features/groups/group_settings_spec.rb
@@ -76,6 +76,27 @@ feature 'Edit group settings' do
end
end
end
+
+ describe 'edit group avatar' do
+ before do
+ visit edit_group_path(group)
+
+ attach_file(:group_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif'))
+
+ expect { click_button 'Save group' }.to change { group.reload.avatar? }.to(true)
+ end
+
+ it 'uploads new group avatar' do
+ expect(group.avatar).to be_instance_of AvatarUploader
+ expect(group.avatar.url).to eq "/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif"
+ expect(page).to have_link('Remove avatar')
+ end
+
+ it 'removes group avatar' do
+ expect { click_link 'Remove avatar' }.to change { group.reload.avatar? }.to(false)
+ expect(page).not_to have_link('Remove avatar')
+ end
+ end
end
def update_path(new_group_path)
diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb
index cdf7aceb13c..90bf7ba49f6 100644
--- a/spec/features/groups/issues_spec.rb
+++ b/spec/features/groups/issues_spec.rb
@@ -3,40 +3,73 @@ require 'spec_helper'
feature 'Group issues page' do
include FilteredSearchHelpers
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, group: group)}
let(:path) { issues_group_path(group) }
- let(:issuable) { create(:issue, project: project, title: "this is my created issuable")}
- include_examples 'project features apply to issuables', Issue
+ context 'with shared examples' do
+ let(:issuable) { create(:issue, project: project, title: "this is my created issuable")}
- context 'rss feed' do
- let(:access_level) { ProjectFeature::ENABLED }
+ include_examples 'project features apply to issuables', Issue
- context 'when signed in' do
- let(:user) { user_in_group }
+ context 'rss feed' do
+ let(:access_level) { ProjectFeature::ENABLED }
+
+ context 'when signed in' do
+ let(:user) { user_in_group }
+
+ 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 RSS token"
- it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
+ context 'when signed out' do
+ let(:user) { nil }
+
+ 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
- context 'when signed out' do
- let(:user) { nil }
+ context 'assignee', :js do
+ let(:access_level) { ProjectFeature::ENABLED }
+ let(:user) { user_in_group }
+ let(:user2) { user_outside_group }
+
+ it 'filters by only group users' do
+ filtered_search.set('assignee:')
- it_behaves_like "it has an RSS button without an RSS token"
- it_behaves_like "an autodiscoverable RSS feed without an RSS token"
+ 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
- context 'assignee', :js do
- let(:access_level) { ProjectFeature::ENABLED }
- let(:user) { user_in_group }
- let(:user2) { user_outside_group }
- let(:path) { issues_group_path(group) }
+ context 'issues list', :nested_groups do
+ let(:subgroup) { create(:group, parent: group) }
+ let(:subgroup_project) { create(:project, :public, group: subgroup)}
+ let!(:issue) { create(:issue, project: project, title: 'root group issue') }
+ let!(:subgroup_issue) { create(:issue, project: subgroup_project, title: 'subgroup issue') }
+
+ it 'returns all group and subgroup issues' do
+ visit issues_group_path(group)
+
+ page.within('.issuable-list') do
+ expect(page).to have_selector('li.issue', count: 2)
+ expect(page).to have_content('root group issue')
+ expect(page).to have_content('subgroup issue')
+ end
+ end
+
+ context 'when project is archived' do
+ before do
+ project.archive!
+ end
- it 'filters by only group users' do
- filtered_search.set('assignee:')
+ it 'does not render issue' do
+ visit path
- 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)
+ expect(page).not_to have_content issue.title[0..80]
+ end
end
end
end
diff --git a/spec/features/groups/members/manage_members.rb b/spec/features/groups/members/manage_members_spec.rb
index 21f7b4999ad..21f7b4999ad 100644
--- a/spec/features/groups/members/manage_members.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb
new file mode 100644
index 00000000000..31fbbcf562c
--- /dev/null
+++ b/spec/features/groups/members/search_members_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe 'Search group member' do
+ let(:user) { create :user }
+ let(:member) { create :user }
+
+ let!(:guest_group) do
+ create(:group) do |group|
+ group.add_guest(user)
+ group.add_guest(member)
+ end
+ end
+
+ before do
+ sign_in(user)
+ visit group_group_members_path(guest_group)
+ end
+
+ it 'renders member users' do
+ page.within '.member-search-form' do
+ fill_in 'search', with: member.name
+ find('.member-search-btn').click
+ end
+
+ group_members_list = find(".panel .content-list")
+ expect(group_members_list).to have_content(member.name)
+ expect(group_members_list).not_to have_content(user.name)
+ end
+end
diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb
index 7ce6a61d50c..672ae785c2d 100644
--- a/spec/features/groups/merge_requests_spec.rb
+++ b/spec/features/groups/merge_requests_spec.rb
@@ -5,14 +5,14 @@ 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') }
+ let(:access_level) { ProjectFeature::ENABLED }
+ let(:user) { user_in_group }
include_examples 'project features apply to issuables', MergeRequest
context 'archived issuable' do
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 }
before do
issuable_archived
@@ -36,9 +36,17 @@ feature 'Group merge requests page' do
end
end
+ context 'when merge request assignee to user' do
+ before do
+ issuable.update!(assignee: user)
+
+ visit path
+ end
+
+ it { expect(page).to have_content issuable.title[0..80] }
+ end
+
context 'group filtered search', :js do
- let(:access_level) { ProjectFeature::ENABLED }
- let(:user) { user_in_group }
let(:user2) { user_outside_group }
it 'filters by assignee only group users' do
diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb
index 1b41b3842c8..20337f1d3b0 100644
--- a/spec/features/groups/milestone_spec.rb
+++ b/spec/features/groups/milestone_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-feature 'Group milestones', :js do
+feature 'Group milestones' 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 }
@@ -13,7 +13,7 @@ feature 'Group milestones', :js do
sign_in(user)
end
- context 'create a milestone' do
+ context 'create a milestone', :js do
before do
visit new_group_milestone_path(group)
end
@@ -61,55 +61,132 @@ feature 'Group milestones', :js do
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)
+ context 'when no milestones' do
+ it 'renders no milestones text' do
+ visit group_milestones_path(group)
+ expect(page).to have_content('No milestones to show')
+ end
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
+ context 'when milestones exists' do
+ let!(:other_project) { create(:project_empty_repo, group: group) }
+
+ let!(:active_project_milestone1) do
+ create(
+ :milestone,
+ project: project,
+ state: 'active',
+ title: 'v1.0',
+ due_date: '2114-08-20',
+ description: 'Lorem Ipsum is simply dummy text'
+ )
+ end
+ 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', title: 'GL-113') }
+ let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') }
+ let!(:issue) do
+ create :issue, project: project, assignees: [user], author: user, milestone: active_project_milestone1
+ end
- it 'lists legacy group milestones and group milestones' do
- legacy_milestone = GroupMilestone.build_collection(group, group.projects, { state: 'active' }).first
+ before do
+ visit group_milestones_path(group)
+ end
- 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 '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 'updates milestone' do
- page.within(".milestones #milestone_#{active_group_milestone.id}") do
- click_link('Edit')
+ 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
- page.within('.milestone-form') do
- fill_in 'milestone_title', with: 'new title'
- click_button('Update milestone')
+ 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
- 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
- it 'shows milestone detail and supports its edit' do
- page.within(".milestones #milestone_#{active_group_milestone.id}") do
- click_link(active_group_milestone.title)
+ expect(page).to have_selector('.milestone-form')
end
- page.within('.detail-page-header') do
- click_link('Edit')
+ it 'renders milestones' do
+ expect(page).to have_content('v1.0')
+ expect(page).to have_content('GL-113')
+ expect(page).to have_link(
+ '1 Issue',
+ href: issues_group_path(group, milestone_title: 'v1.0')
+ )
+ expect(page).to have_link(
+ '0 Merge Requests',
+ href: merge_requests_group_path(group, milestone_title: 'v1.0')
+ )
end
- expect(page).to have_selector('.milestone-form')
+ it 'renders group milestone details' do
+ click_link 'v1.0'
+
+ expect(page).to have_content('expires on Aug 20, 2114')
+ expect(page).to have_content('v1.0')
+ expect(page).to have_content('Issues 1 Open: 1 Closed: 0')
+ expect(page).to have_link(issue.title, href: project_issue_path(issue.project, issue))
+ end
+
+ describe 'labels' do
+ before do
+ create(:label, project: project, title: 'bug') do |label|
+ issue.labels << label
+ end
+
+ create(:label, project: project, title: 'feature') do |label|
+ issue.labels << label
+ end
+ end
+
+ it 'renders labels' do
+ click_link 'v1.0'
+
+ page.within('#tab-issues') do
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'feature'
+ end
+ end
+
+ it 'renders labels list', :js do
+ click_link 'v1.0'
+
+ page.within('.content .nav-links') do
+ page.find(:xpath, "//a[@href='#tab-labels']").click
+ end
+
+ page.within('#tab-labels') do
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'feature'
+ end
+ end
+ end
end
end
end
diff --git a/spec/features/groups/settings/group_badges_spec.rb b/spec/features/groups/settings/group_badges_spec.rb
new file mode 100644
index 00000000000..92217294446
--- /dev/null
+++ b/spec/features/groups/settings/group_badges_spec.rb
@@ -0,0 +1,124 @@
+require 'spec_helper'
+
+feature 'Group Badges' do
+ include WaitForRequests
+
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'}
+ let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'}
+ let!(:badge_1) { create(:group_badge, group: group) }
+ let!(:badge_2) { create(:group_badge, group: group) }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+
+ visit(group_settings_badges_path(group))
+ end
+
+ it 'shows a list of badges', :js do
+ page.within '.badge-settings' do
+ wait_for_requests
+
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ expect(rows[0]).to have_content badge_1.link_url
+ expect(rows[1]).to have_content badge_2.link_url
+ end
+ end
+
+ context 'adding a badge', :js do
+ it 'user can preview a badge' do
+ page.within '.badge-settings form' do
+ fill_in 'badge-link-url', with: badge_link_url
+ fill_in 'badge-image-url', with: badge_image_url
+ within '#badge-preview' do
+ expect(find('a')[:href]).to eq badge_link_url
+ expect(find('a img')[:src]).to eq badge_image_url
+ end
+ end
+ end
+
+ it do
+ page.within '.badge-settings' do
+ fill_in 'badge-link-url', with: badge_link_url
+ fill_in 'badge-image-url', with: badge_image_url
+
+ click_button 'Add badge'
+ wait_for_requests
+
+ within '.panel-body' do
+ expect(find('a')[:href]).to eq badge_link_url
+ expect(find('a img')[:src]).to eq badge_image_url
+ end
+ end
+ end
+ end
+
+ context 'editing a badge', :js do
+ it 'form is shown when clicking edit button in list' do
+ page.within '.badge-settings' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ rows[1].find('[aria-label="Edit"]').click
+
+ within 'form' do
+ expect(find('#badge-link-url').value).to eq badge_2.link_url
+ expect(find('#badge-image-url').value).to eq badge_2.image_url
+ end
+ end
+ end
+
+ it 'updates a badge when submitting the edit form' do
+ page.within '.badge-settings' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ rows[1].find('[aria-label="Edit"]').click
+ within 'form' do
+ fill_in 'badge-link-url', with: badge_link_url
+ fill_in 'badge-image-url', with: badge_image_url
+
+ click_button 'Save changes'
+ wait_for_requests
+ end
+
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ expect(rows[1]).to have_content badge_link_url
+ end
+ end
+ end
+
+ context 'deleting a badge', :js do
+ def click_delete_button(badge_row)
+ badge_row.find('[aria-label="Delete"]').click
+ end
+
+ it 'shows a modal when deleting a badge' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+
+ click_delete_button(rows[1])
+
+ expect(find('.modal .modal-title')).to have_content 'Delete badge?'
+ end
+
+ it 'deletes a badge when confirming the modal' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ click_delete_button(rows[1])
+
+ find('.modal .btn-danger').click
+ wait_for_requests
+
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 1
+ expect(rows[0]).to have_content badge_1.link_url
+ end
+ end
+end
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 7fc2b383749..4ffadbbcd35 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -15,14 +15,44 @@ feature 'Group show page' do
end
it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token"
+
+ context 'when group does not exist' do
+ let(:path) { group_path('not-exist') }
+
+ it { expect(status_code).to eq(404) }
+ end
end
context 'when signed out' do
- before do
- visit path
+ describe 'RSS' do
+ before do
+ visit path
+ end
+
+ it_behaves_like "an autodiscoverable RSS feed without an RSS token"
+ end
+
+ context 'when group has a public project', :js do
+ let!(:project) { create(:project, :public, namespace: group) }
+
+ it 'renders public project' do
+ visit path
+
+ expect(page).to have_link group.name
+ expect(page).to have_link project.name
+ end
end
- it_behaves_like "an autodiscoverable RSS feed without an RSS token"
+ context 'when group has a private project', :js do
+ let!(:project) { create(:project, :private, namespace: group) }
+
+ it 'does not render private project' do
+ visit path
+
+ expect(page).to have_link group.name
+ expect(page).not_to have_link project.name
+ end
+ end
end
context 'subgroup support' do
@@ -55,4 +85,20 @@ feature 'Group show page' do
end
end
end
+
+ context 'group has a project with emoji in description', :js do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, description: ':smile:', namespace: group) }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ visit path
+ end
+
+ it 'shows the project info' do
+ expect(page).to have_content(project.title)
+ expect(page).to have_selector('gl-emoji[data-name="smile"]')
+ end
+ end
end
diff --git a/spec/features/groups/user_browse_projects_group_page_spec.rb b/spec/features/groups/user_browse_projects_group_page_spec.rb
new file mode 100644
index 00000000000..e81c3180e78
--- /dev/null
+++ b/spec/features/groups/user_browse_projects_group_page_spec.rb
@@ -0,0 +1,29 @@
+require 'rails_helper'
+
+describe 'User browse group projects page' do
+ let(:user) { create :user }
+ let(:group) { create :group }
+
+ context 'when user is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ context 'when user signed in' do
+ before do
+ sign_in(user)
+ end
+
+ context 'when group has archived project', :js do
+ let!(:project) { create :project, :archived, namespace: group }
+
+ it 'renders projects list' do
+ visit projects_group_path(group)
+
+ expect(page).to have_link project.name
+ expect(page).to have_xpath("//span[@class='label label-warning']", text: 'archived')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb
new file mode 100644
index 00000000000..e4be6193b8b
--- /dev/null
+++ b/spec/features/invites_spec.rb
@@ -0,0 +1,97 @@
+require 'spec_helper'
+
+describe 'Invites' do
+ let(:user) { create(:user) }
+ let(:owner) { create(:user, name: 'John Doe') }
+ let(:group) { create(:group, name: 'Owned') }
+ let(:project) { create(:project, :repository, namespace: group) }
+ let(:invite) { group.group_members.invite.last }
+
+ before do
+ project.add_master(owner)
+ group.add_user(owner, Gitlab::Access::OWNER)
+ group.add_developer('user@example.com', owner)
+ invite.generate_invite_token!
+ end
+
+ context 'when signed out' do
+ before do
+ visit invite_path(invite.raw_invite_token)
+ end
+
+ it 'renders sign in page with sign in notice' do
+ expect(current_path).to eq(new_user_session_path)
+ expect(page).to have_content('To accept this invitation, sign in')
+ end
+
+ it 'sign in and redirects to invitation page' do
+ fill_in 'user_login', with: user.email
+ fill_in 'user_password', with: user.password
+ check 'user_remember_me'
+ click_button 'Sign in'
+
+ expect(current_path).to eq(invite_path(invite.raw_invite_token))
+ expect(page).to have_content(
+ 'You have been invited by John Doe to join group Owned as Developer.'
+ )
+ expect(page).to have_link('Accept invitation')
+ expect(page).to have_link('Decline')
+ end
+ end
+
+ context 'when signed in as an exists member' do
+ before do
+ sign_in(owner)
+ end
+
+ it 'shows message user already a member' do
+ visit invite_path(invite.raw_invite_token)
+ expect(page).to have_content('However, you are already a member of this group.')
+ end
+ end
+
+ describe 'accepting the invitation' do
+ before do
+ sign_in(user)
+ visit invite_path(invite.raw_invite_token)
+ end
+
+ it 'grants access and redirects to group page' do
+ page.click_link 'Accept invitation'
+ expect(current_path).to eq(group_path(group))
+ expect(page).to have_content(
+ 'You have been granted Developer access to group Owned.'
+ )
+ end
+ end
+
+ describe 'declining the application' do
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ visit invite_path(invite.raw_invite_token)
+ end
+
+ it 'declines application and redirects to dashboard' do
+ page.click_link 'Decline'
+ expect(current_path).to eq(dashboard_projects_path)
+ expect(page).to have_content(
+ 'You have declined the invitation to join group Owned.'
+ )
+ end
+ end
+
+ context 'when signed out' do
+ before do
+ visit decline_invite_path(invite.raw_invite_token)
+ end
+
+ it 'declines application and redirects to sign in page' do
+ expect(current_path).to eq(new_user_session_path)
+ expect(page).to have_content(
+ 'You have declined the invitation to join group Owned.'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/features/issuables/discussion_lock_spec.rb b/spec/features/issuables/discussion_lock_spec.rb
index ecbe51a7bc2..7ea29ff252b 100644
--- a/spec/features/issuables/discussion_lock_spec.rb
+++ b/spec/features/issuables/discussion_lock_spec.rb
@@ -14,7 +14,7 @@ describe 'Discussion Lock', :js do
project.add_developer(user)
end
- context 'when the discussion is unlocked' do
+ context 'when the discussion is unlocked' do
it 'the user can lock the issue' do
visit project_issue_path(project, issue)
diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb
index 850b35c4467..1131e1711bf 100644
--- a/spec/features/issues/award_emoji_spec.rb
+++ b/spec/features/issues/award_emoji_spec.rb
@@ -11,7 +11,7 @@ describe 'Awards Emoji' do
context 'authorized user' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb
index fa4d3a55c62..cf283119f36 100644
--- a/spec/features/issues/bulk_assignment_labels_spec.rb
+++ b/spec/features/issues/bulk_assignment_labels_spec.rb
@@ -11,7 +11,7 @@ feature 'Issues > Labels bulk assignment' do
context 'as an allowed user', :js do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
end
@@ -377,6 +377,7 @@ feature 'Issues > Labels bulk assignment' do
items.map do |item|
click_link item
end
+
if unmark
items.map do |item|
# Make sure we are unmarking the item no matter the state it has currently
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 822ba48e005..e0466aaf422 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
@@ -8,7 +8,7 @@ feature 'Resolving all open discussions in a merge request from an issue', :js d
describe 'as a user with access to the project' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_merge_request_path(project, merge_request)
end
@@ -81,7 +81,7 @@ feature 'Resolving all open discussions in a merge request from an issue', :js d
describe 'as a reporter' do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
sign_in user
visit new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
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 f0bed85595c..34beb282bad 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
@@ -8,7 +8,7 @@ feature 'Resolve an open discussion in a merge request by creating an issue' do
describe 'As a user with access to the project' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_merge_request_path(project, merge_request)
end
@@ -65,7 +65,7 @@ feature 'Resolve an open discussion in a merge request by creating an issue' do
describe 'as a reporter' do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
sign_in user
visit new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid,
discussion_to_resolve: discussion.id)
diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
index 2e4a25ee15d..cbd0949c192 100644
--- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
@@ -20,9 +20,9 @@ describe 'Dropdown assignee', :js do
end
before do
- project.team << [user, :master]
- project.team << [user_john, :master]
- project.team << [user_jacob, :master]
+ project.add_master(user)
+ project.add_master(user_john)
+ project.add_master(user_jacob)
sign_in(user)
create(:issue, project: project)
@@ -222,7 +222,7 @@ describe 'Dropdown assignee', :js do
expect(initial_size).to be > 0
new_user = create(:user)
- project.team << [new_user, :master]
+ project.add_master(new_user)
find('.filtered-search-box .clear-search').click
filtered_search.set('assignee')
filtered_search.send_keys(':')
diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb
index 2fb5e7cdba4..70b4f11410d 100644
--- a/spec/features/issues/filtered_search/dropdown_author_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb
@@ -28,9 +28,9 @@ describe 'Dropdown author', :js do
end
before do
- project.team << [user, :master]
- project.team << [user_john, :master]
- project.team << [user_jacob, :master]
+ project.add_master(user)
+ project.add_master(user_john)
+ project.add_master(user_jacob)
sign_in(user)
create(:issue, project: project)
@@ -195,7 +195,7 @@ describe 'Dropdown author', :js do
expect(initial_size).to be > 0
new_user = create(:user)
- project.team << [new_user, :master]
+ project.add_master(new_user)
find('.filtered-search-box .clear-search').click
filtered_search.set('author')
send_keys_to_filtered_search(':')
diff --git a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb
index 8db435634fd..436625a6f7b 100644
--- a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb
@@ -28,7 +28,7 @@ describe 'Dropdown emoji', :js do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
create_list(:award_emoji, 2, user: user, name: 'thumbsup')
create_list(:award_emoji, 1, user: user, name: 'thumbsdown')
create_list(:award_emoji, 3, user: user, name: 'star')
diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
index 0183495a1db..ef40dddfd3a 100644
--- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
@@ -13,7 +13,7 @@ describe 'Dropdown hint', :js do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
create(:issue, project: project)
end
@@ -176,6 +176,7 @@ describe 'Dropdown hint', :js do
it 'reuses existing author text' do
filtered_search.send_keys('author:')
filtered_search.send_keys(:backspace)
+ filtered_search.send_keys(:backspace)
click_hint('author')
expect_tokens([{ name: 'author' }])
@@ -185,6 +186,7 @@ describe 'Dropdown hint', :js do
it 'reuses existing assignee text' do
filtered_search.send_keys('assignee:')
filtered_search.send_keys(:backspace)
+ filtered_search.send_keys(:backspace)
click_hint('assignee')
expect_tokens([{ name: 'assignee' }])
@@ -194,6 +196,7 @@ describe 'Dropdown hint', :js do
it 'reuses existing milestone text' do
filtered_search.send_keys('milestone:')
filtered_search.send_keys(:backspace)
+ filtered_search.send_keys(:backspace)
click_hint('milestone')
expect_tokens([{ name: 'milestone' }])
@@ -203,6 +206,7 @@ describe 'Dropdown hint', :js do
it 'reuses existing label text' do
filtered_search.send_keys('label:')
filtered_search.send_keys(:backspace)
+ filtered_search.send_keys(:backspace)
click_hint('label')
expect_tokens([{ name: 'label' }])
@@ -212,6 +216,7 @@ describe 'Dropdown hint', :js do
it 'reuses existing emoji text' do
filtered_search.send_keys('my-reaction:')
filtered_search.send_keys(:backspace)
+ filtered_search.send_keys(:backspace)
click_hint('my-reaction')
expect_tokens([{ name: 'my-reaction' }])
diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
index 031eb06723a..94710c2f71f 100644
--- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
@@ -29,7 +29,7 @@ describe 'Dropdown milestone', :js do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
create(:issue, project: project)
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index b3c50964810..08ba91a2682 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -22,15 +22,6 @@ describe 'Filter issues', :js do
end
end
- def expect_issues_list_count(open_count, closed_count = 0)
- all_count = open_count + closed_count
-
- expect(page).to have_issuable_counts(open: open_count, closed: closed_count, all: all_count)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: open_count)
- end
- end
-
before do
project.add_master(user)
diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb
index f355cec3ba9..41b9ada988a 100644
--- a/spec/features/issues/filtered_search/recent_searches_spec.rb
+++ b/spec/features/issues/filtered_search/recent_searches_spec.rb
@@ -39,8 +39,8 @@ describe 'Recent searches', :js do
items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
- expect(items[0].text).to eq('label:~qux garply')
- expect(items[1].text).to eq('label:~foo bar')
+ expect(items[0].text).to eq('label: ~qux garply')
+ expect(items[1].text).to eq('label: ~foo bar')
end
it 'saved recent searches are restored last on the list' do
diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb
index 88688422dc7..268590da599 100644
--- a/spec/features/issues/filtered_search/search_bar_spec.rb
+++ b/spec/features/issues/filtered_search/search_bar_spec.rb
@@ -8,7 +8,7 @@ describe 'Search bar', :js do
let(:filtered_search) { find('.filtered-search') }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
create(:issue, project: project)
diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb
index 2db6f9a2982..4625a50b8d9 100644
--- a/spec/features/issues/form_spec.rb
+++ b/spec/features/issues/form_spec.rb
@@ -13,8 +13,8 @@ describe 'New/edit issue', :js do
let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) }
before do
- project.team << [user, :master]
- project.team << [user2, :master]
+ project.add_master(user)
+ project.add_master(user2)
sign_in(user)
end
@@ -189,6 +189,18 @@ describe 'New/edit issue', :js do
expect(find('.js-label-select')).to have_content('Labels')
end
+ it 'clears label search input field when a label is selected' do
+ click_button 'Labels'
+
+ page.within '.dropdown-menu-labels' do
+ search_field = find('input[type="search"]')
+
+ search_field.set(label2.title)
+ click_link label2.title
+ expect(search_field.value).to eq ''
+ end
+ end
+
it 'correctly updates the selected user when changing assignee' do
click_button 'Unassigned'
@@ -214,6 +226,23 @@ describe 'New/edit issue', :js do
expect(page).to have_selector('.atwho-view')
end
+
+ describe 'milestone' do
+ let!(:milestone) { create(:milestone, title: '">&lt;img src=x onerror=alert(document.domain)&gt;', project: project) }
+
+ it 'escapes milestone' do
+ click_button 'Milestone'
+
+ page.within '.issue-milestone' do
+ click_link milestone.title
+ end
+
+ page.within '.js-milestone-select' do
+ expect(page).to have_content milestone.title
+ expect(page).not_to have_selector 'img'
+ end
+ end
+ end
end
context 'edit issue' do
@@ -271,6 +300,18 @@ describe 'New/edit issue', :js do
end
end
+ context 'inline edit' do
+ before do
+ visit project_issue_path(project, issue)
+ end
+
+ it 'opens inline edit form with shortcut' do
+ find('body').send_keys('e')
+
+ expect(page).to have_selector('.detail-page-description form')
+ end
+ end
+
describe 'sub-group project' do
let(:group) { create(:group) }
let(:nested_group_1) { create(:group, parent: group) }
@@ -282,10 +323,10 @@ describe 'New/edit issue', :js do
visit new_project_issue_path(sub_group_project)
end
- it 'creates new label from dropdown' do
+ it 'creates project label from dropdown' do
click_button 'Labels'
- click_link 'Create new label'
+ click_link 'Create project label'
page.within '.dropdown-new-label' do
fill_in 'new_label_name', with: 'test label'
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 6a9a80235c1..f2624f55c86 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -7,7 +7,7 @@ feature 'GFM autocomplete', :js do
let(:issue) { create(:issue, project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_issue_path(project, issue)
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index a9de52bd8d5..27551bb70ee 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -8,6 +8,7 @@ feature 'Issue Sidebar' do
let(:issue) { create(:issue, project: project) }
let!(:user) { create(:user)}
let!(:label) { create(:label, project: project, title: 'bug') }
+ let!(:xss_label) { create(:label, project: project, title: '&lt;script&gt;alert("xss");&lt;&#x2F;script&gt;') }
before do
sign_in(user)
@@ -18,7 +19,7 @@ feature 'Issue Sidebar' do
let(:issue2) { create(:issue, project: project, author: user2) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
visit_issue(project, issue2)
find('.block.assignee .edit-link').click
@@ -78,7 +79,7 @@ feature 'Issue Sidebar' do
context 'as a allowed user' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
visit_issue(project, issue)
end
@@ -99,6 +100,14 @@ feature 'Issue Sidebar' do
restore_window_size
open_issue_sidebar
end
+
+ it 'escapes XSS when viewing issue labels' do
+ page.within('.block.labels') do
+ find('.edit-link').click
+
+ expect(page).to have_content '<script>alert("xss");</script>'
+ end
+ end
end
context 'editing issue labels', :js do
@@ -108,22 +117,22 @@ feature 'Issue Sidebar' do
end
end
- it 'shows option to create a new label' do
+ it 'shows option to create a project label' do
page.within('.block.labels') do
- expect(page).to have_content 'Create new'
+ expect(page).to have_content 'Create project'
end
end
- context 'creating a new label', :js do
+ context 'creating a project label', :js do
before do
page.within('.block.labels') do
- click_link 'Create new'
+ click_link 'Create project'
end
end
it 'shows dropdown switches to "create label" section' do
page.within('.block.labels') do
- expect(page).to have_content 'Create new label'
+ expect(page).to have_content 'Create project label'
end
end
@@ -152,11 +161,55 @@ feature 'Issue Sidebar' do
end
end
end
+
+ context 'interacting with collapsed sidebar', :js do
+ collapsed_sidebar_selector = 'aside.right-sidebar.right-sidebar-collapsed'
+ expanded_sidebar_selector = 'aside.right-sidebar.right-sidebar-expanded'
+ confidentiality_sidebar_block = '.block.confidentiality'
+ lock_sidebar_block = '.block.lock'
+ collapsed_sidebar_block_icon = '.sidebar-collapsed-icon'
+
+ before do
+ resize_screen_sm
+ end
+
+ it 'confidentiality block expands then collapses sidebar' do
+ expect(page).to have_css(collapsed_sidebar_selector)
+
+ page.within(confidentiality_sidebar_block) do
+ find(collapsed_sidebar_block_icon).click
+ end
+
+ expect(page).to have_css(expanded_sidebar_selector)
+
+ page.within(confidentiality_sidebar_block) do
+ page.find('button', text: 'Cancel').click
+ end
+
+ expect(page).to have_css(collapsed_sidebar_selector)
+ end
+
+ it 'lock block expands then collapses sidebar' do
+ expect(page).to have_css(collapsed_sidebar_selector)
+
+ page.within(lock_sidebar_block) do
+ find(collapsed_sidebar_block_icon).click
+ end
+
+ expect(page).to have_css(expanded_sidebar_selector)
+
+ page.within(lock_sidebar_block) do
+ page.find('button', text: 'Cancel').click
+ end
+
+ expect(page).to have_css(collapsed_sidebar_selector)
+ end
+ end
end
context 'as a guest' do
before do
- project.team << [user, :guest]
+ project.add_guest(user)
visit_issue(project, issue)
end
diff --git a/spec/features/issues/keyboard_shortcut_spec.rb b/spec/features/issues/keyboard_shortcut_spec.rb
new file mode 100644
index 00000000000..961de9d3d25
--- /dev/null
+++ b/spec/features/issues/keyboard_shortcut_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+describe 'Issues shortcut', :js do
+ context 'New Issue shortcut' do
+ context 'issues are enabled' do
+ let(:project) { create(:project) }
+
+ before do
+ sign_in(create(:admin))
+
+ visit project_path(project)
+ end
+
+ it 'takes user to the new issue page' do
+ find('body').native.send_keys('i')
+ expect(page).to have_selector('#new_issue')
+ end
+ end
+
+ context 'issues are not enabled' do
+ let(:project) { create(:project, :issues_disabled) }
+
+ before do
+ sign_in(create(:admin))
+
+ visit project_path(project)
+ end
+
+ it 'does not take user to the new issue page' do
+ find('body').native.send_keys('i')
+
+ expect(page).to have_selector("body[data-page='projects:show']")
+ end
+ end
+ end
+end
diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb
index 17035b5501c..3c01ff345fc 100644
--- a/spec/features/issues/move_spec.rb
+++ b/spec/features/issues/move_spec.rb
@@ -13,7 +13,7 @@ feature 'issue move to another project' do
context 'user does not have permission to move issue' do
background do
- old_project.team << [user, :guest]
+ old_project.add_guest(user)
visit issue_path(issue)
end
@@ -31,8 +31,8 @@ feature 'issue move to another project' do
let(:cross_reference) { old_project.to_reference(new_project) }
background do
- old_project.team << [user, :reporter]
- new_project.team << [user, :reporter]
+ old_project.add_reporter(user)
+ new_project.add_reporter(user)
visit issue_path(issue)
end
@@ -50,7 +50,7 @@ feature 'issue move to another project' do
end
scenario 'searching project dropdown', :js do
- new_project_search.team << [user, :reporter]
+ new_project_search.add_reporter(user)
find('.js-move-issue').click
wait_for_requests
@@ -66,14 +66,14 @@ feature 'issue move to another project' do
context 'user does not have permission to move the issue to a project', :js do
let!(:private_project) { create(:project, :private) }
let(:another_project) { create(:project) }
- background { another_project.team << [user, :guest] }
+ background { another_project.add_guest(user) }
scenario 'browsing projects in projects select' do
find('.js-move-issue').click
wait_for_requests
page.within '.js-sidebar-move-issue-block' do
- expect(page).to have_content new_project.name_with_namespace
+ expect(page).to have_content new_project.full_name
end
end
end
diff --git a/spec/features/issues/notes_on_issues_spec.rb b/spec/features/issues/notes_on_issues_spec.rb
index 05c93a19253..f08c73f947c 100644
--- a/spec/features/issues/notes_on_issues_spec.rb
+++ b/spec/features/issues/notes_on_issues_spec.rb
@@ -8,7 +8,7 @@ describe 'Create notes on issues', :js do
let(:note_text) { "Check #{mention.to_reference}" }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit project_issue_path(project, issue)
diff --git a/spec/features/issues/spam_issues_spec.rb b/spec/features/issues/spam_issues_spec.rb
index d25231d624c..73022afbda2 100644
--- a/spec/features/issues/spam_issues_spec.rb
+++ b/spec/features/issues/spam_issues_spec.rb
@@ -9,7 +9,7 @@ describe 'New issue', :js do
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- current_application_settings.update!(
+ Gitlab::CurrentSettings.update!(
akismet_enabled: true,
akismet_api_key: 'testkey',
recaptcha_enabled: true,
@@ -17,7 +17,7 @@ describe 'New issue', :js do
recaptcha_private_key: 'test private key'
)
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/issues/todo_spec.rb b/spec/features/issues/todo_spec.rb
index 29a2d38ae18..8e6493bbd93 100644
--- a/spec/features/issues/todo_spec.rb
+++ b/spec/features/issues/todo_spec.rb
@@ -6,7 +6,7 @@ feature 'Manually create a todo item from issue', :js do
let!(:user) { create(:user)}
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_issue_path(project, issue)
end
diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb
index bcc6e9bab0f..7d6edc171f8 100644
--- a/spec/features/issues/update_issues_spec.rb
+++ b/spec/features/issues/update_issues_spec.rb
@@ -6,7 +6,7 @@ feature 'Multiple issue updating from issues#index', :js do
let!(:user) { create(:user)}
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
index c4c06ed514b..ff2a0e15719 100644
--- a/spec/features/issues/user_uses_slash_commands_spec.rb
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -1,7 +1,7 @@
require 'rails_helper'
feature 'Issues > User uses quick actions', :js do
- include QuickActionsHelpers
+ include Spec::Support::Helpers::Features::NotesHelpers
it_behaves_like 'issuable record that supports quick actions in its description and notes', :issue do
let(:issuable) { create(:issue, project: project) }
@@ -12,7 +12,7 @@ feature 'Issues > User uses quick actions', :js do
let(:project) { create(:project, :public) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_issue_path(project, issue)
end
@@ -36,7 +36,7 @@ feature 'Issues > User uses quick actions', :js do
context 'when the current user can update the due date' do
it 'does not create a note, and sets the due date accordingly' do
- write_note("/due 2016-08-28")
+ add_note("/due 2016-08-28")
expect(page).not_to have_content '/due 2016-08-28'
expect(page).to have_content 'Commands applied'
@@ -50,16 +50,15 @@ feature 'Issues > User uses quick actions', :js do
context 'when the current user cannot update the due date' do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
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
- write_note("/due 2016-08-28")
+ add_note("/due 2016-08-28")
- expect(page).to have_content '/due 2016-08-28'
expect(page).not_to have_content 'Commands applied'
issue.reload
@@ -76,7 +75,7 @@ feature 'Issues > User uses quick actions', :js do
it 'does not create a note, and removes the due date accordingly' do
expect(issue.due_date).to eq Date.new(2016, 8, 28)
- write_note("/remove_due_date")
+ add_note("/remove_due_date")
expect(page).not_to have_content '/remove_due_date'
expect(page).to have_content 'Commands applied'
@@ -90,16 +89,15 @@ feature 'Issues > User uses quick actions', :js do
context 'when the current user cannot update the due date' do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
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
- write_note("/remove_due_date")
+ add_note("/remove_due_date")
- expect(page).to have_content '/remove_due_date'
expect(page).not_to have_content 'Commands applied'
issue.reload
@@ -113,7 +111,7 @@ feature 'Issues > User uses quick actions', :js do
let(:issue) { create(:issue, project: project) }
it 'does not recognize the command nor create a note' do
- write_note("/wip")
+ add_note("/wip")
expect(page).not_to have_content '/wip'
end
@@ -125,7 +123,7 @@ feature 'Issues > User uses quick actions', :js do
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}")
+ add_note("/duplicate ##{original_issue.to_reference}")
expect(page).not_to have_content "/duplicate #{original_issue.to_reference}"
expect(page).to have_content 'Commands applied'
@@ -138,16 +136,15 @@ feature 'Issues > User uses quick actions', :js do
context 'when the current user cannot update the issue' do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
gitlab_sign_out
sign_in(guest)
visit project_issue_path(project, issue)
end
it 'does not create a note, and does not mark the issue as a duplicate' do
- write_note("/duplicate ##{original_issue.to_reference}")
+ add_note("/duplicate ##{original_issue.to_reference}")
- 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}"
@@ -163,13 +160,13 @@ feature 'Issues > User uses quick actions', :js do
let(:target_project) { create(:project, :public) }
before do
- target_project.team << [user, :master]
+ target_project.add_master(user)
sign_in(user)
visit project_issue_path(project, issue)
end
it 'moves the issue' do
- write_note("/move #{target_project.full_path}")
+ add_note("/move #{target_project.full_path}")
expect(page).to have_content 'Commands applied'
expect(issue.reload).to be_closed
@@ -189,7 +186,7 @@ feature 'Issues > User uses quick actions', :js do
end
it 'does not move the issue' do
- write_note("/move #{project_unauthorized.full_path}")
+ add_note("/move #{project_unauthorized.full_path}")
expect(page).not_to have_content 'Commands applied'
expect(issue.reload).to be_open
@@ -203,7 +200,7 @@ feature 'Issues > User uses quick actions', :js do
end
it 'does not move the issue' do
- write_note("/move not/valid")
+ add_note("/move not/valid")
expect(page).not_to have_content 'Commands applied'
expect(issue.reload).to be_open
@@ -220,13 +217,13 @@ feature 'Issues > User uses quick actions', :js do
let(:wontfix_target) { create(:label, project: target_project, title: 'wontfix') }
before do
- target_project.team << [user, :master]
+ target_project.add_master(user)
sign_in(user)
visit project_issue_path(project, issue)
end
it 'applies the commands to both issues and moves the issue' do
- write_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/move #{target_project.full_path}")
+ add_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/move #{target_project.full_path}")
expect(page).to have_content 'Commands applied'
expect(issue.reload).to be_closed
@@ -245,7 +242,7 @@ feature 'Issues > User uses quick actions', :js do
end
it 'moves the issue and applies the commands to both issues' do
- write_note("/move #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"")
+ add_note("/move #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"")
expect(page).to have_content 'Commands applied'
expect(issue.reload).to be_closed
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index d1ff057a0c6..314bd19f586 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -25,7 +25,8 @@ describe 'Issues' do
sign_in(user)
user2 = create(:user)
- project.team << [[user, user2], :developer]
+ project.add_developer(user)
+ project.add_developer(user2)
end
describe 'empty state' do
@@ -383,7 +384,7 @@ describe 'Issues' do
before do
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
- project1.team << [user, :master]
+ project1.add_master(user)
visit namespace_project_issues_path(user.namespace, project1)
end
@@ -491,7 +492,7 @@ describe 'Issues' do
let(:guest) { create(:user) }
before do
- project.team << [[guest], :guest]
+ project.add_guest(guest)
end
it 'shows assignee text', :js do
@@ -552,7 +553,7 @@ describe 'Issues' do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
issue.milestone = milestone
issue.save
end
diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb
new file mode 100644
index 00000000000..3e05e7b7f38
--- /dev/null
+++ b/spec/features/labels_hierarchy_spec.rb
@@ -0,0 +1,305 @@
+require 'spec_helper'
+
+feature 'Labels Hierarchy', :js, :nested_groups do
+ include FilteredSearchHelpers
+
+ let!(:user) { create(:user) }
+ let!(:grandparent) { create(:group) }
+ let!(:parent) { create(:group, parent: grandparent) }
+ let!(:child) { create(:group, parent: parent) }
+ let!(:project_1) { create(:project, namespace: parent) }
+
+ let!(:grandparent_group_label) { create(:group_label, group: grandparent, title: 'Label_1') }
+ let!(:parent_group_label) { create(:group_label, group: parent, title: 'Label_2') }
+ let!(:child_group_label) { create(:group_label, group: child, title: 'Label_3') }
+ let!(:project_label_1) { create(:label, project: project_1, title: 'Label_4') }
+
+ before do
+ grandparent.add_owner(user)
+
+ sign_in(user)
+ end
+
+ shared_examples 'assigning labels from sidebar' do
+ it 'can assign all ancestors labels' do
+ [grandparent_group_label, parent_group_label, project_label_1].each do |label|
+ page.within('.block.labels') do
+ find('.edit-link').click
+ end
+
+ wait_for_requests
+
+ find('a.label-item', text: label.title).click
+ find('.dropdown-menu-close-icon').click
+
+ wait_for_requests
+
+ expect(page).to have_selector('span.label', text: label.title)
+ end
+ end
+
+ it 'does not find child group labels on dropdown' do
+ page.within('.block.labels') do
+ find('.edit-link').click
+ end
+
+ wait_for_requests
+
+ expect(page).not_to have_selector('span.label', text: child_group_label.title)
+ end
+ end
+
+ shared_examples 'filtering by ancestor labels for projects' do |board = false|
+ it 'filters by ancestor labels' do
+ [grandparent_group_label, parent_group_label, project_label_1].each do |label|
+ select_label_on_dropdown(label.title)
+
+ wait_for_requests
+
+ if board
+ expect(page).to have_selector('.card-title') do |card|
+ expect(card).to have_selector('a', text: labeled_issue.title)
+ end
+ else
+ expect_issues_list_count(1)
+ expect(page).to have_selector('span.issue-title-text', text: labeled_issue.title)
+ end
+ end
+ end
+
+ it 'does not filter by descendant group labels' do
+ filtered_search.set("label:")
+
+ wait_for_requests
+
+ expect(page).not_to have_selector('.btn-link', text: child_group_label.title)
+ end
+ end
+
+ shared_examples 'filtering by ancestor labels for groups' do |board = false|
+ let(:project_2) { create(:project, namespace: parent) }
+ let!(:project_label_2) { create(:label, project: project_2, title: 'Label_4') }
+
+ let(:project_3) { create(:project, namespace: child) }
+ let!(:group_label_3) { create(:group_label, group: child, title: 'Label_5') }
+ let!(:project_label_3) { create(:label, project: project_3, title: 'Label_6') }
+
+ let!(:labeled_issue_2) { create(:labeled_issue, project: project_2, labels: [grandparent_group_label, parent_group_label, project_label_2]) }
+ let!(:labeled_issue_3) { create(:labeled_issue, project: project_3, labels: [grandparent_group_label, parent_group_label, group_label_3]) }
+
+ let!(:issue_2) { create(:issue, project: project_2) }
+
+ it 'filters by ancestors and current group labels' do
+ [grandparent_group_label, parent_group_label].each do |label|
+ select_label_on_dropdown(label.title)
+
+ wait_for_requests
+
+ if board
+ expect(page).to have_selector('.card-title') do |card|
+ expect(card).to have_selector('a', text: labeled_issue.title)
+ end
+
+ expect(page).to have_selector('.card-title') do |card|
+ expect(card).to have_selector('a', text: labeled_issue_2.title)
+ end
+ else
+ expect_issues_list_count(3)
+ expect(page).to have_selector('span.issue-title-text', text: labeled_issue.title)
+ expect(page).to have_selector('span.issue-title-text', text: labeled_issue_2.title)
+ expect(page).to have_selector('span.issue-title-text', text: labeled_issue_3.title)
+ end
+ end
+ end
+
+ it 'filters by descendant group labels' do
+ wait_for_requests
+
+ select_label_on_dropdown(group_label_3.title)
+
+ if board
+ expect(page).to have_selector('.card-title') do |card|
+ expect(card).not_to have_selector('a', text: labeled_issue_2.title)
+ end
+
+ expect(page).to have_selector('.card-title') do |card|
+ expect(card).to have_selector('a', text: labeled_issue_3.title)
+ end
+ else
+ expect_issues_list_count(1)
+ expect(page).to have_selector('span.issue-title-text', text: labeled_issue_3.title)
+ end
+ end
+
+ it 'does not filter by descendant group project labels' do
+ filtered_search.set("label:")
+
+ wait_for_requests
+
+ expect(page).not_to have_selector('.btn-link', text: project_label_3.title)
+ end
+ end
+
+ context 'when creating new issuable' do
+ before do
+ visit new_project_issue_path(project_1)
+ end
+
+ it 'should be able to assign ancestor group labels' do
+ fill_in 'issue_title', with: 'new created issue'
+ fill_in 'issue_description', with: 'new issue description'
+
+ find(".js-label-select").click
+ wait_for_requests
+
+ find('a.label-item', text: grandparent_group_label.title).click
+ find('a.label-item', text: parent_group_label.title).click
+ find('a.label-item', text: project_label_1.title).click
+
+ find('.btn-create').click
+
+ expect(page.find('.issue-details h2.title')).to have_content('new created issue')
+ expect(page).to have_selector('span.label', text: grandparent_group_label.title)
+ expect(page).to have_selector('span.label', text: parent_group_label.title)
+ expect(page).to have_selector('span.label', text: project_label_1.title)
+ end
+ end
+
+ context 'issuable sidebar' do
+ let!(:issue) { create(:issue, project: project_1) }
+
+ context 'on issue sidebar' do
+ before do
+ visit project_issue_path(project_1, issue)
+ end
+
+ it_behaves_like 'assigning labels from sidebar'
+ end
+
+ context 'on project board issue sidebar' do
+ let(:board) { create(:board, project: project_1) }
+
+ before do
+ visit project_board_path(project_1, board)
+
+ wait_for_requests
+
+ find('.card').click
+ end
+
+ it_behaves_like 'assigning labels from sidebar'
+ end
+
+ context 'on group board issue sidebar' do
+ let(:board) { create(:board, group: parent) }
+
+ before do
+ visit group_board_path(parent, board)
+
+ wait_for_requests
+
+ find('.card').click
+ end
+
+ it_behaves_like 'assigning labels from sidebar'
+ end
+ end
+
+ context 'issuable filtering' do
+ let!(:labeled_issue) { create(:labeled_issue, project: project_1, labels: [grandparent_group_label, parent_group_label, project_label_1]) }
+ let!(:issue) { create(:issue, project: project_1) }
+
+ context 'on project issuable list' do
+ before do
+ visit project_issues_path(project_1)
+ end
+
+ it_behaves_like 'filtering by ancestor labels for projects'
+
+ it 'does not filter by descendant group labels' do
+ filtered_search.set("label:")
+
+ wait_for_requests
+
+ expect(page).not_to have_selector('.btn-link', text: child_group_label.title)
+ end
+ end
+
+ context 'on group issuable list' do
+ before do
+ visit issues_group_path(parent)
+ end
+
+ it_behaves_like 'filtering by ancestor labels for groups'
+ end
+
+ context 'on project boards filter' do
+ let(:board) { create(:board, project: project_1) }
+
+ before do
+ visit project_board_path(project_1, board)
+ end
+
+ it_behaves_like 'filtering by ancestor labels for projects', true
+ end
+
+ context 'on group boards filter' do
+ let(:board) { create(:board, group: parent) }
+
+ before do
+ visit group_board_path(parent, board)
+ end
+
+ it_behaves_like 'filtering by ancestor labels for groups', true
+ end
+ end
+
+ context 'creating boards lists' do
+ context 'on project boards' do
+ let(:board) { create(:board, project: project_1) }
+
+ before do
+ visit project_board_path(project_1, board)
+ find('.js-new-board-list').click
+ wait_for_requests
+ end
+
+ it 'creates lists from all ancestor labels' do
+ [grandparent_group_label, parent_group_label, project_label_1].each do |label|
+ find('a', text: label.title).click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title)
+ expect(page).to have_selector('.board-title-text', text: parent_group_label.title)
+ expect(page).to have_selector('.board-title-text', text: project_label_1.title)
+ end
+ end
+
+ context 'on group boards' do
+ let(:board) { create(:board, group: parent) }
+
+ before do
+ visit group_board_path(parent, board)
+ find('.js-new-board-list').click
+ wait_for_requests
+ end
+
+ it 'creates lists from all ancestor group labels' do
+ [grandparent_group_label, parent_group_label].each do |label|
+ find('a', text: label.title).click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title)
+ expect(page).to have_selector('.board-title-text', text: parent_group_label.title)
+ end
+
+ it 'does not create lists from descendant groups' do
+ expect(page).not_to have_selector('a', text: child_group_label.title)
+ end
+ end
+ end
+end
diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb
deleted file mode 100644
index 6dfabcc7225..00000000000
--- a/spec/features/login_spec.rb
+++ /dev/null
@@ -1,370 +0,0 @@
-require 'spec_helper'
-
-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
- User.delete_all
-
- user = create(:admin, password_automatically_set: true)
-
- visit root_path
- expect(current_path).to eq edit_user_password_path
- expect(page).to have_content('Please create a password for your new account.')
-
- fill_in 'user_password', with: 'password'
- fill_in 'user_password_confirmation', with: 'password'
- click_button 'Change your password'
-
- expect(current_path).to eq new_user_session_path
- expect(page).to have_content(I18n.t('devise.passwords.updated_not_active'))
-
- fill_in 'user_login', with: user.username
- fill_in 'user_password', with: 'password'
- click_button 'Sign in'
-
- expect(current_path).to eq root_path
- end
-
- it 'does not show flash messages when login page' do
- visit root_path
- expect(page).not_to have_content('You need to sign in or sign up before continuing.')
- end
- end
-
- describe 'with a blocked account' do
- it 'prevents the user from logging in' do
- user = create(:user, :blocked)
-
- gitlab_sign_in(user)
-
- expect(page).to have_content('Your account has been blocked.')
- end
-
- it 'does not update Devise trackable attributes', :clean_gitlab_redis_shared_state do
- user = create(:user, :blocked)
-
- 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
- gitlab_sign_in(User.ghost)
-
- expect(page).to have_content('Invalid Login or password.')
- end
-
- 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
-
- describe 'with two-factor authentication' do
- def enter_code(code)
- fill_in 'user_otp_attempt', with: code
- click_button 'Verify code'
- end
-
- context 'with valid username/password' do
- let(:user) { create(:user, :two_factor) }
-
- before do
- gitlab_sign_in(user, remember: true)
- expect(page).to have_content('Two-Factor Authentication')
- end
-
- it 'does not show a "You are already signed in." error message' do
- enter_code(user.current_otp)
- expect(page).not_to have_content('You are already signed in.')
- end
-
- context 'using one-time code' do
- it 'allows login with valid code' do
- enter_code(user.current_otp)
- expect(current_path).to eq root_path
- end
-
- it 'persists remember_me value via hidden field' do
- field = first('input#user_remember_me', visible: false)
-
- expect(field.value).to eq '1'
- end
-
- it 'blocks login with invalid code' do
- enter_code('foo')
- expect(page).to have_content('Invalid two-factor code')
- end
-
- it 'allows login with invalid code, then valid code' do
- enter_code('foo')
- expect(page).to have_content('Invalid two-factor code')
-
- enter_code(user.current_otp)
- expect(current_path).to eq root_path
- end
- end
-
- context 'using backup code' do
- let(:codes) { user.generate_otp_backup_codes! }
-
- before do
- expect(codes.size).to eq 10
-
- # Ensure the generated codes get saved
- user.save
- end
-
- context 'with valid code' do
- it 'allows login' do
- enter_code(codes.sample)
- expect(current_path).to eq root_path
- end
-
- it 'invalidates the used code' do
- expect { enter_code(codes.sample) }
- .to change { user.reload.otp_backup_codes.size }.by(-1)
- end
- end
-
- context 'with invalid code' do
- it 'blocks login' do
- code = codes.sample
- expect(user.invalidate_otp_backup_code!(code)).to eq true
-
- user.save!
- expect(user.reload.otp_backup_codes.size).to eq 9
-
- enter_code(code)
- expect(page).to have_content('Invalid two-factor code.')
- end
- end
- end
- end
-
- context 'logging in via OAuth' do
- it 'shows 2FA prompt after OAuth login' do
- 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')
- gitlab_sign_in_via('saml', user, 'my-uid')
-
- expect(page).to have_content('Two-Factor Authentication')
- enter_code(user.current_otp)
- expect(current_path).to eq root_path
- end
- end
- end
-
- describe 'without two-factor authentication' do
- let(:user) { create(:user) }
-
- it 'allows basic login' do
- 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
- 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')
-
- gitlab_sign_in(user)
- expect(page).to have_content('Invalid Login or password.')
- end
- end
-
- describe 'with required two-factor authentication enabled' do
- let(:user) { create(:user) }
- # TODO: otp_grace_period_started_at
-
- context 'global setting' do
- before do
- stub_application_setting(require_two_factor_authentication: true)
- end
-
- context 'with grace period defined' do
- before do
- stub_application_setting(two_factor_grace_period: 48)
- gitlab_sign_in(user)
- end
-
- context 'within the grace period' do
- it 'redirects to two-factor configuration page' do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).to have_content('The global settings require you to enable Two-Factor Authentication for your account. You need to do this before ')
- end
-
- it 'allows skipping two-factor configuration', :js do
- expect(current_path).to eq profile_two_factor_auth_path
-
- click_link 'Configure it later'
- expect(current_path).to eq root_path
- end
- end
-
- context 'after the grace period' do
- let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) }
-
- it 'redirects to two-factor configuration page' do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).to have_content(
- 'The global settings require you to enable Two-Factor Authentication for your account.'
- )
- end
-
- it 'disallows skipping two-factor configuration', :js do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).not_to have_link('Configure it later')
- end
- end
- end
-
- context 'without grace period defined' do
- before do
- stub_application_setting(two_factor_grace_period: 0)
- gitlab_sign_in(user)
- end
-
- it 'redirects to two-factor configuration page' do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).to have_content(
- 'The global settings require you to enable Two-Factor Authentication for your account.'
- )
- end
- end
- end
-
- context 'group setting' do
- before do
- group1 = create :group, name: 'Group 1', require_two_factor_authentication: true
- group1.add_user(user, GroupMember::DEVELOPER)
- group2 = create :group, name: 'Group 2', require_two_factor_authentication: true
- group2.add_user(user, GroupMember::DEVELOPER)
- end
-
- context 'with grace period defined' do
- before do
- stub_application_setting(two_factor_grace_period: 48)
- gitlab_sign_in(user)
- end
-
- context 'within the grace period' do
- it 'redirects to two-factor configuration page' do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).to have_content(
- 'The group settings for Group 1 and Group 2 require you to enable ' \
- 'Two-Factor Authentication for your account. You need to do this ' \
- 'before ')
- end
-
- it 'allows skipping two-factor configuration', :js do
- expect(current_path).to eq profile_two_factor_auth_path
-
- click_link 'Configure it later'
- expect(current_path).to eq root_path
- end
- end
-
- context 'after the grace period' do
- let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) }
-
- it 'redirects to two-factor configuration page' do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).to have_content(
- 'The group settings for Group 1 and Group 2 require you to enable ' \
- 'Two-Factor Authentication for your account.'
- )
- end
-
- it 'disallows skipping two-factor configuration', :js do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).not_to have_link('Configure it later')
- end
- end
- end
-
- context 'without grace period defined' do
- before do
- stub_application_setting(two_factor_grace_period: 0)
- gitlab_sign_in(user)
- end
-
- it 'redirects to two-factor configuration page' do
- expect(current_path).to eq profile_two_factor_auth_path
- expect(page).to have_content(
- 'The group settings for Group 1 and Group 2 require you to enable ' \
- 'Two-Factor Authentication for your account.'
- )
- end
- end
- end
- end
-
- describe 'UI tabs and panes' do
- context 'when no defaults are changed' do
- it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness
- end
- end
-
- context 'when signup is disabled' do
- before do
- stub_application_setting(signup_enabled: false)
- end
-
- it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness
- end
- end
-
- context 'when ldap is enabled' do
- before do
- visit new_user_session_path
- allow(page).to receive(:form_based_providers).and_return([:ldapmain])
- allow(page).to receive(:ldap_enabled).and_return(true)
- end
-
- it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness(false)
- end
- end
-
- context 'when crowd is enabled' do
- before do
- visit new_user_session_path
- allow(page).to receive(:form_based_providers).and_return([:crowd])
- allow(page).to receive(:crowd_enabled?).and_return(true)
- end
-
- it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness(false)
- end
- end
-
- def ensure_tab_pane_correctness(visit_path = true)
- if visit_path
- visit new_user_session_path
- end
-
- ensure_tab_pane_counts
- ensure_one_active_tab
- ensure_one_active_pane
- end
-
- def ensure_tab_pane_counts
- tabs_count = page.all('[role="tab"]').size
- expect(page).to have_selector('[role="tabpanel"]', count: tabs_count)
- end
-
- def ensure_one_active_tab
- expect(page).to have_selector('.nav-tabs > li.active', count: 1)
- end
-
- def ensure_one_active_pane
- expect(page).to have_selector('.tab-pane.active', count: 1)
- end
- end
-end
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb
new file mode 100644
index 00000000000..4d897f09b57
--- /dev/null
+++ b/spec/features/markdown/copy_as_gfm_spec.rb
@@ -0,0 +1,782 @@
+require 'spec_helper'
+
+describe 'Copy as GFM', :js do
+ include MarkupHelper
+ include RepoHelpers
+ include ActionView::Helpers::JavaScriptHelper
+
+ before do
+ sign_in(create(:admin))
+ end
+
+ describe 'Copying rendered GFM' do
+ before do
+ @feat = MarkdownFeature.new
+
+ # `markdown` helper expects a `@project` variable
+ @project = @feat.project
+
+ 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.
+ # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM.
+ # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle
+ # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper.
+
+ # These are all in a single `it` for performance reasons.
+ it 'works', :aggregate_failures do
+ verify(
+ 'nesting',
+
+ '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**'
+ )
+
+ verify(
+ 'a real world example from the gitlab-ce README',
+
+ <<-GFM.strip_heredoc
+ # GitLab
+
+ [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master)
+ [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby)
+ [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq)
+ [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
+
+ ## Canonical source
+
+ The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/).
+
+ ## Open source software to collaborate on code
+
+ 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
+
+ - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
+
+ - Each project can also have an issue tracker, issue board, and a wiki
+
+ - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises
+
+ - Completely free and open source (MIT Expat license)
+ 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',
+
+ '{-Deleted text-}',
+ '{+Added text+}'
+ )
+
+ verify(
+ 'TaskListFilter',
+
+ '- [ ] Unchecked task',
+ '- [x] Checked task',
+ '1. [ ] Unchecked numbered task',
+ '1. [x] Checked numbered task'
+ )
+
+ verify(
+ 'ReferenceFilter',
+
+ # issue reference
+ @feat.issue.to_reference,
+ # full issue reference
+ @feat.issue.to_reference(full: true),
+ # issue URL
+ project_issue_url(@project, @feat.issue),
+ # issue URL with note anchor
+ project_issue_url(@project, @feat.issue, anchor: 'note_123'),
+ # issue link
+ "[Issue](#{project_issue_url(@project, @feat.issue)})",
+ # issue link with note anchor
+ "[Issue](#{project_issue_url(@project, @feat.issue, anchor: 'note_123')})"
+ )
+
+ verify(
+ 'AutolinkFilter',
+
+ 'https://example.com'
+ )
+
+ verify(
+ 'TableOfContentsFilter',
+
+ '[[_TOC_]]'
+ )
+
+ verify(
+ 'EmojiFilter',
+
+ ':thumbsup:'
+ )
+
+ verify(
+ 'ImageLinkFilter',
+
+ '![Image](https://example.com/image.png)'
+ )
+
+ verify(
+ 'VideoLinkFilter',
+
+ '![Video](https://example.com/video.mp4)'
+ )
+
+ verify(
+ 'MathFilter: math as converted from GFM to HTML',
+
+ '$`c = \pm\sqrt{a^2 + b^2}`$',
+
+ # math block
+ <<-GFM.strip_heredoc
+ ```math
+ c = \pm\sqrt{a^2 + b^2}
+ ```
+ GFM
+ )
+
+ aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do
+ gfm = '$`c = \pm\sqrt{a^2 + b^2}`$'
+
+ html = <<-HTML.strip_heredoc
+ <span class="katex">
+ <span class="katex-mathml">
+ <math>
+ <semantics>
+ <mrow>
+ <mi>c</mi>
+ <mo>=</mo>
+ <mo>±</mo>
+ <msqrt>
+ <mrow>
+ <msup>
+ <mi>a</mi>
+ <mn>2</mn>
+ </msup>
+ <mo>+</mo>
+ <msup>
+ <mi>b</mi>
+ <mn>2</mn>
+ </msup>
+ </mrow>
+ </msqrt>
+ </mrow>
+ <annotation encoding="application/x-tex">c = \\pm\\sqrt{a^2 + b^2}</annotation>
+ </semantics>
+ </math>
+ </span>
+ <span class="katex-html" aria-hidden="true">
+ <span class="strut" style="height: 0.913389em;"></span>
+ <span class="strut bottom" style="height: 1.04em; vertical-align: -0.126611em;"></span>
+ <span class="base textstyle uncramped">
+ <span class="mord mathit">c</span>
+ <span class="mrel">=</span>
+ <span class="mord">±</span>
+ <span class="sqrt mord"><span class="sqrt-sign" style="top: -0.073389em;">
+ <span class="style-wrap reset-textstyle textstyle uncramped">√</span>
+ </span>
+ <span class="vlist">
+ <span class="" style="top: 0em;">
+ <span class="fontsize-ensurer reset-size5 size5">
+ <span class="" style="font-size: 1em;">​</span>
+ </span>
+ <span class="mord textstyle cramped">
+ <span class="mord">
+ <span class="mord mathit">a</span>
+ <span class="msupsub">
+ <span class="vlist">
+ <span class="" style="top: -0.289em; margin-right: 0.05em;">
+ <span class="fontsize-ensurer reset-size5 size5">
+ <span class="" style="font-size: 0em;">​</span>
+ </span>
+ <span class="reset-textstyle scriptstyle cramped">
+ <span class="mord mathrm">2</span>
+ </span>
+ </span>
+ <span class="baseline-fix">
+ <span class="fontsize-ensurer reset-size5 size5">
+ <span class="" style="font-size: 0em;">​</span>
+ </span>
+ ​</span>
+ </span>
+ </span>
+ </span>
+ <span class="mbin">+</span>
+ <span class="mord">
+ <span class="mord mathit">b</span>
+ <span class="msupsub">
+ <span class="vlist">
+ <span class="" style="top: -0.289em; margin-right: 0.05em;">
+ <span class="fontsize-ensurer reset-size5 size5">
+ <span class="" style="font-size: 0em;">​</span>
+ </span>
+ <span class="reset-textstyle scriptstyle cramped">
+ <span class="mord mathrm">2</span>
+ </span>
+ </span>
+ <span class="baseline-fix">
+ <span class="fontsize-ensurer reset-size5 size5">
+ <span class="" style="font-size: 0em;">​</span>
+ </span>
+ ​</span>
+ </span>
+ </span>
+ </span>
+ </span>
+ </span>
+ <span class="" style="top: -0.833389em;">
+ <span class="fontsize-ensurer reset-size5 size5">
+ <span class="" style="font-size: 1em;">​</span>
+ </span>
+ <span class="reset-textstyle textstyle uncramped sqrt-line"></span>
+ </span>
+ <span class="baseline-fix">
+ <span class="fontsize-ensurer reset-size5 size5">
+ <span class="" style="font-size: 1em;">​</span>
+ </span>
+ ​</span>
+ </span>
+ </span>
+ </span>
+ </span>
+ </span>
+ HTML
+
+ output_gfm = html_to_gfm(html)
+ expect(output_gfm.strip).to eq(gfm.strip)
+ end
+
+ verify(
+ 'MermaidFilter: mermaid as converted from GFM to HTML',
+
+ <<-GFM.strip_heredoc
+ ```mermaid
+ graph TD;
+ A-->B;
+ ```
+ GFM
+ )
+
+ aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do
+ gfm = <<-GFM.strip_heredoc
+ ```mermaid
+ graph TD;
+ A-->B;
+ ```
+ GFM
+
+ html = <<-HTML.strip_heredoc
+ <svg id="mermaidChart1" xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="0 0 87.234375 174" style="max-width:87.234375px;" class="mermaid">
+ <style>
+ .mermaid {
+ /* Flowchart variables */
+ /* Sequence Diagram variables */
+ /* Gantt chart variables */
+ /** Section styling */
+ /* Grid and axis */
+ /* Today line */
+ /* Task styling */
+ /* Default task */
+ /* Specific task settings for the sections*/
+ /* Active task */
+ /* Completed task */
+ /* Tasks on the critical line */
+ }
+ </style>
+ <g>
+ <g class="output">
+ <g class="clusters"></g>
+ <g class="edgePaths">
+ <g class="edgePath" style="opacity: 1;">
+ <path class="path" d="M33.6171875,52L33.6171875,77L33.6171875,102" marker-end="url(#arrowhead65)" style="fill:none"></path>
+ <defs>
+ <marker id="arrowhead65" viewBox="0 0 10 10" refX="9" refY="5" markerUnits="strokeWidth" markerWidth="8" markerHeight="6" orient="auto">
+ <path d="M 0 0 L 10 5 L 0 10 z" class="arrowheadPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path>
+ </marker>
+ </defs>
+ </g>
+ </g>
+ <g class="edgeLabels">
+ <g class="edgeLabel" style="opacity: 1;" transform="">
+ <g transform="translate(0,0)" class="label">
+ <foreignObject width="0" height="0">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;">
+ <span class="edgeLabel"></span>
+ </div>
+ </foreignObject>
+ </g>
+ </g>
+ </g>
+ <g class="nodes">
+ <g class="node" id="A" transform="translate(33.6171875,36)" style="opacity: 1;">
+ <rect rx="0" ry="0" x="-13.6171875" y="-16" width="27.234375" height="32"></rect>
+ <g class="label" transform="translate(0,0)">
+ <g transform="translate(-3.6171875,-6)">
+ <foreignObject width="7.234375" height="12">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;">A</div>
+ </foreignObject>
+ </g>
+ </g>
+ </g>
+ <g class="node" id="B" transform="translate(33.6171875,118)" style="opacity: 1;">
+ <rect rx="0" ry="0" x="-13.6171875" y="-16" width="27.234375" height="32">
+ </rect>
+ <g class="label" transform="translate(0,0)">
+ <g transform="translate(-3.6171875,-6)">
+ <foreignObject width="7.234375" height="12">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;">B</div>
+ </foreignObject>
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+ <text class="source" display="none">graph TD;
+ A--&gt;B;
+ </text>
+ </svg>
+ HTML
+
+ output_gfm = html_to_gfm(html)
+ expect(output_gfm.strip).to eq(gfm.strip)
+ end
+
+ verify(
+ 'SanitizationFilter',
+
+ <<-GFM.strip_heredoc
+ <sub>sub</sub>
+
+ <dl>
+ <dt>dt</dt>
+ <dd>dd</dd>
+ </dl>
+
+ <kbd>kbd</kbd>
+
+ <q>q</q>
+
+ <samp>samp</samp>
+
+ <var>var</var>
+
+ <ruby>ruby</ruby>
+
+ <rt>rt</rt>
+
+ <rp>rp</rp>
+
+ <abbr>abbr</abbr>
+
+ <summary>summary</summary>
+
+ <details>details</details>
+ GFM
+ )
+
+ verify(
+ 'SanitizationFilter',
+
+ <<-GFM.strip_heredoc,
+ ```
+ Plain text
+ ```
+ GFM
+
+ <<-GFM.strip_heredoc,
+ ```ruby
+ def foo
+ bar
+ end
+ ```
+ GFM
+
+ <<-GFM.strip_heredoc
+ Foo
+
+ This is an example of GFM
+
+ ```js
+ Code goes here
+ ```
+ GFM
+ )
+
+ verify(
+ 'MarkdownFilter',
+
+ "Line with two spaces at the end \nto insert a linebreak",
+
+ '`code`',
+ '`` code with ` ticks ``',
+
+ '> Quote',
+
+ # multiline quote
+ <<-GFM.strip_heredoc,
+ > Multiline
+ > Quote
+ >
+ > With multiple paragraphs
+ GFM
+
+ '![Image](https://example.com/image.png)',
+
+ '# Heading with no anchor link',
+
+ '[Link](https://example.com)',
+
+ '- List item',
+
+ # multiline list item
+ <<-GFM.strip_heredoc,
+ - Multiline
+ List item
+ GFM
+
+ # nested lists
+ <<-GFM.strip_heredoc,
+ - Nested
+
+ - Lists
+ GFM
+
+ # list with blockquote
+ <<-GFM.strip_heredoc,
+ - List
+
+ > Blockquote
+ GFM
+
+ '1. Numbered list item',
+
+ # multiline numbered list item
+ <<-GFM.strip_heredoc,
+ 1. Multiline
+ Numbered list item
+ GFM
+
+ # nested numbered list
+ <<-GFM.strip_heredoc,
+ 1. Nested
+
+ 1. Numbered lists
+ GFM
+
+ '# Heading',
+ '## Heading',
+ '### Heading',
+ '#### Heading',
+ '##### Heading',
+ '###### Heading',
+
+ '**Bold**',
+
+ '_Italics_',
+
+ '~~Strikethrough~~',
+
+ '2^2',
+
+ '-----',
+
+ # table
+ <<-GFM.strip_heredoc,
+ | Centered | Right | Left |
+ |:--------:|------:|------|
+ | Foo | Bar | **Baz** |
+ | Foo | Bar | **Baz** |
+ GFM
+
+ # table with empty heading
+ <<-GFM.strip_heredoc,
+ | | x | y |
+ |---|---|---|
+ | a | 1 | 0 |
+ | b | 0 | 1 |
+ GFM
+ )
+ end
+
+ alias_method :gfm_to_html, :markdown
+
+ def verify(label, *gfms)
+ aggregate_failures(label) do
+ gfms.each do |gfm|
+ html = gfm_to_html(gfm).gsub(/\A&#x000A;|&#x000A;\z/, '')
+ output_gfm = html_to_gfm(html)
+ expect(output_gfm.strip).to eq(gfm.strip)
+ end
+ end
+ end
+
+ # Fake a `current_user` helper
+ def current_user
+ @feat.user
+ end
+ end
+
+ describe 'Copying code' do
+ let(:project) { create(:project, :repository) }
+
+ context 'from a diff' do
+ shared_examples 'copying code from a diff' do
+ context 'selecting one word of text' do
+ it 'copies as inline code' do
+ verify(
+ '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no',
+
+ '`RuntimeError`',
+
+ target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
+ )
+ end
+ end
+
+ context 'selecting one line of text' do
+ it 'copies as inline code' do
+ verify(
+ '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]',
+
+ '`raise RuntimeError, "System commands must be given as an array of strings"`',
+
+ target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
+ )
+ end
+ end
+
+ context 'selecting multiple lines of text' do
+ it 'copies as a code block' do
+ verify(
+ '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
+
+ <<-GFM.strip_heredoc,
+ ```ruby
+ raise RuntimeError, "System commands must be given as an array of strings"
+ end
+ ```
+ GFM
+
+ target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'
+ )
+ end
+ end
+ end
+
+ context 'inline diff' do
+ before do
+ visit project_commit_path(project, sample_commit.id, view: 'inline')
+ end
+
+ it_behaves_like 'copying code from a diff'
+ end
+
+ context 'parallel diff' do
+ before do
+ visit project_commit_path(project, sample_commit.id, view: 'parallel')
+ end
+
+ it_behaves_like 'copying code from a diff'
+
+ context 'selecting code on the left' do
+ it 'copies as a code block' do
+ verify(
+ '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
+
+ <<-GFM.strip_heredoc,
+ ```ruby
+ unless cmd.is_a?(Array)
+ raise "System commands must be given as an array of strings"
+ end
+ ```
+ GFM
+
+ target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].left-side'
+ )
+ end
+ end
+
+ context 'selecting code on the right' do
+ it 'copies as a code block' do
+ verify(
+ '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
+
+ <<-GFM.strip_heredoc,
+ ```ruby
+ unless cmd.is_a?(Array)
+ raise RuntimeError, "System commands must be given as an array of strings"
+ end
+ ```
+ GFM
+
+ target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].right-side'
+ )
+ end
+ end
+ end
+ end
+
+ context 'from a blob' do
+ before do
+ visit project_blob_path(project, File.join('master', 'files/ruby/popen.rb'))
+ wait_for_requests
+ end
+
+ context 'selecting one word of text' do
+ it 'copies as inline code' do
+ verify(
+ '.line[id="LC9"] .no',
+
+ '`RuntimeError`'
+ )
+ end
+ end
+
+ context 'selecting one line of text' do
+ it 'copies as inline code' do
+ verify(
+ '.line[id="LC9"]',
+
+ '`raise RuntimeError, "System commands must be given as an array of strings"`'
+ )
+ end
+ end
+
+ context 'selecting multiple lines of text' do
+ it 'copies as a code block' do
+ verify(
+ '.line[id="LC9"], .line[id="LC10"]',
+
+ <<-GFM.strip_heredoc,
+ ```ruby
+ raise RuntimeError, "System commands must be given as an array of strings"
+ end
+ ```
+ GFM
+ )
+ end
+ end
+ end
+
+ context 'from a GFM code block' do
+ before do
+ visit project_blob_path(project, File.join('markdown', 'doc/api/users.md'))
+ wait_for_requests
+ end
+
+ context 'selecting one word of text' do
+ it 'copies as inline code' do
+ verify(
+ '.line[id="LC27"] .s2',
+
+ '`"bio"`'
+ )
+ end
+ end
+
+ context 'selecting one line of text' do
+ it 'copies as inline code' do
+ verify(
+ '.line[id="LC27"]',
+
+ '`"bio": null,`'
+ )
+ end
+ end
+
+ context 'selecting multiple lines of text' do
+ it 'copies as a code block with the correct language' do
+ verify(
+ '.line[id="LC27"], .line[id="LC28"]',
+
+ <<-GFM.strip_heredoc,
+ ```json
+ "bio": null,
+ "skype": "",
+ ```
+ GFM
+ )
+ end
+ end
+ end
+
+ def verify(selector, gfm, target: nil)
+ html = html_for_selector(selector)
+ output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target)
+ expect(output_gfm.strip).to eq(gfm.strip)
+ end
+ end
+
+ def html_for_selector(selector)
+ js = <<-JS.strip_heredoc
+ (function(selector) {
+ var els = document.querySelectorAll(selector);
+ var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; });
+ return htmls.join("\\n");
+ })("#{escape_javascript(selector)}")
+ JS
+ page.evaluate_script(js)
+ end
+
+ def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil)
+ js = <<-JS.strip_heredoc
+ (function(html) {
+ var transformer = window.CopyAsGFM[#{transformer.inspect}];
+
+ var node = document.createElement('div');
+ $(html).each(function() { node.appendChild(this) });
+
+ var targetSelector = #{target.to_json};
+ var target;
+ if (targetSelector) {
+ target = document.querySelector(targetSelector);
+ }
+
+ node = transformer(node, target);
+ if (!node) return null;
+
+ return window.CopyAsGFM.nodeToGFM(node);
+ })("#{escape_javascript(html)}")
+ JS
+ page.evaluate_script(js)
+ end
+end
diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/markdown/gitlab_flavored_markdown_spec.rb
index 3c2186b3598..3c2186b3598 100644
--- a/spec/features/gitlab_flavored_markdown_spec.rb
+++ b/spec/features/markdown/gitlab_flavored_markdown_spec.rb
diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb
new file mode 100644
index 00000000000..f13d78d24e3
--- /dev/null
+++ b/spec/features/markdown/markdown_spec.rb
@@ -0,0 +1,337 @@
+require 'spec_helper'
+require 'erb'
+
+# This feature spec is intended to be a comprehensive exercising of all of
+# GitLab's non-standard Markdown parsing and the integration thereof.
+#
+# These tests should be very high-level. Anything low-level belongs in the specs
+# for the corresponding HTML::Pipeline filter or helper method.
+#
+# The idea is to pass a Markdown document through our entire processing stack.
+#
+# The process looks like this:
+#
+# Raw Markdown
+# -> `markdown` helper
+# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML
+# -> Post-process HTML
+# -> `gfm` helper
+# -> HTML::Pipeline
+# -> SanitizationFilter
+# -> Other filters, depending on pipeline
+# -> `html_safe`
+# -> Template
+#
+# See the MarkdownFeature class for setup details.
+
+describe 'GitLab Markdown' do
+ include Capybara::Node::Matchers
+ include MarkupHelper
+ include MarkdownMatchers
+
+ # Sometimes it can be useful to see the parsed output of the Markdown document
+ # for debugging. Call this method to write the output to
+ # `tmp/capybara/<filename>.html`.
+ def write_markdown(filename = 'markdown_spec')
+ File.open(Rails.root.join("tmp/capybara/#{filename}.html"), 'w') do |file|
+ file.puts @html
+ end
+ end
+
+ def doc(html = @html)
+ @doc ||= Nokogiri::HTML::DocumentFragment.parse(html)
+ end
+
+ # Shared behavior that all pipelines should exhibit
+ shared_examples 'all pipelines' do
+ describe 'Redcarpet extensions' do
+ it 'does not parse emphasis inside of words' do
+ expect(doc.to_html).not_to match('foo<em>bar</em>baz')
+ end
+
+ it 'parses table Markdown' do
+ aggregate_failures do
+ expect(doc).to have_selector('th:contains("Header")')
+ expect(doc).to have_selector('th:contains("Row")')
+ expect(doc).to have_selector('th:contains("Example")')
+ end
+ end
+
+ it 'allows Markdown in tables' do
+ expect(doc.at_css('td:contains("Baz")').children.to_html)
+ .to eq '<strong>Baz</strong>'
+ end
+
+ it 'parses fenced code blocks' do
+ aggregate_failures do
+ expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.c')
+ expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.python')
+ end
+ end
+
+ it 'parses mermaid code block' do
+ aggregate_failures do
+ expect(doc).to have_selector('pre[lang=mermaid] > code.js-render-mermaid')
+ end
+ end
+
+ it 'parses strikethroughs' do
+ expect(doc).to have_selector(%{del:contains("and this text doesn't")})
+ end
+
+ it 'parses superscript' do
+ expect(doc).to have_selector('sup', count: 2)
+ end
+ end
+
+ describe 'SanitizationFilter' do
+ it 'permits b elements' do
+ expect(doc).to have_selector('b:contains("b tag")')
+ end
+
+ it 'permits em elements' do
+ expect(doc).to have_selector('em:contains("em tag")')
+ end
+
+ it 'permits code elements' do
+ expect(doc).to have_selector('code:contains("code tag")')
+ end
+
+ it 'permits kbd elements' do
+ expect(doc).to have_selector('kbd:contains("s")')
+ end
+
+ it 'permits strike elements' do
+ expect(doc).to have_selector('strike:contains(Emoji)')
+ end
+
+ it 'permits img elements' do
+ expect(doc).to have_selector('img[data-src*="smile.png"]')
+ end
+
+ it 'permits br elements' do
+ expect(doc).to have_selector('br')
+ end
+
+ it 'permits hr elements' do
+ expect(doc).to have_selector('hr')
+ end
+
+ it 'permits span elements' do
+ expect(doc).to have_selector('span:contains("span tag")')
+ end
+
+ it 'permits details elements' do
+ expect(doc).to have_selector('details:contains("Hiding the details")')
+ end
+
+ it 'permits summary elements' do
+ expect(doc).to have_selector('details summary:contains("collapsible")')
+ end
+
+ it 'permits style attribute in th elements' do
+ aggregate_failures do
+ expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center'
+ expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right'
+ expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left'
+ end
+ end
+
+ it 'permits style attribute in td elements' do
+ aggregate_failures do
+ expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center'
+ expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right'
+ expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left'
+ end
+ end
+
+ it 'removes `rel` attribute from links' do
+ expect(doc).not_to have_selector('a[rel="bookmark"]')
+ end
+
+ it "removes `href` from `a` elements if it's fishy" do
+ expect(doc).not_to have_selector('a[href*="javascript"]')
+ end
+ end
+
+ describe 'Escaping' do
+ it 'escapes non-tag angle brackets' do
+ table = doc.css('table').last.at_css('tbody')
+ expect(table.at_xpath('.//tr[1]/td[3]').inner_html).to eq '1 &lt; 3 &amp; 5'
+ end
+ end
+
+ 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-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>}
+ end
+ end
+ end
+
+ describe 'ExternalLinkFilter' do
+ it 'adds nofollow to external link' do
+ link = doc.at_css('a:contains("Google")')
+
+ expect(link.attr('rel')).to include('nofollow')
+ end
+
+ it 'adds noreferrer to external link' do
+ link = doc.at_css('a:contains("Google")')
+
+ expect(link.attr('rel')).to include('noreferrer')
+ end
+
+ it 'adds _blank to target attribute for external links' do
+ link = doc.at_css('a:contains("Google")')
+
+ expect(link.attr('target')).to match('_blank')
+ end
+
+ it 'ignores internal link' do
+ link = doc.at_css('a:contains("GitLab Root")')
+
+ expect(link.attr('rel')).not_to match 'nofollow'
+ expect(link.attr('target')).not_to match '_blank'
+ end
+ end
+ end
+
+ before do
+ @feat = MarkdownFeature.new
+
+ # `markdown` helper expects a `@project` and `@group` variable
+ @project = @feat.project
+ @group = @feat.group
+ end
+
+ context 'default pipeline' do
+ before do
+ @html = markdown(@feat.raw_markdown)
+ end
+
+ it_behaves_like 'all pipelines'
+
+ it 'includes RelativeLinkFilter' do
+ expect(doc).to parse_relative_links
+ end
+
+ it 'includes EmojiFilter' do
+ expect(doc).to parse_emoji
+ end
+
+ it 'includes TableOfContentsFilter' do
+ expect(doc).to create_header_links
+ end
+
+ it 'includes AutolinkFilter' do
+ expect(doc).to create_autolinks
+ end
+
+ it 'includes all reference filters' do
+ aggregate_failures do
+ expect(doc).to reference_users
+ expect(doc).to reference_issues
+ expect(doc).to reference_merge_requests
+ expect(doc).to reference_snippets
+ expect(doc).to reference_commit_ranges
+ expect(doc).to reference_commits
+ expect(doc).to reference_labels
+ expect(doc).to reference_milestones
+ end
+ end
+
+ it 'includes TaskListFilter' do
+ expect(doc).to parse_task_lists
+ end
+
+ it 'includes InlineDiffFilter' do
+ expect(doc).to parse_inline_diffs
+ end
+
+ it 'includes VideoLinkFilter' do
+ expect(doc).to parse_video_links
+ end
+
+ it 'includes ColorFilter' do
+ expect(doc).to parse_colors
+ end
+ end
+
+ context 'wiki pipeline' do
+ before do
+ @project_wiki = @feat.project_wiki
+ @project_wiki_page = @feat.project_wiki_page
+
+ file = Gollum::File.new(@project_wiki.wiki)
+ expect(file).to receive(:path).and_return('images/example.jpg')
+ expect(@project_wiki).to receive(:find_file).with('images/example.jpg').and_return(file)
+ allow(@project_wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' }
+
+ @html = markdown(@feat.raw_markdown, { pipeline: :wiki, project_wiki: @project_wiki, page_slug: @project_wiki_page.slug })
+ end
+
+ it_behaves_like 'all pipelines'
+
+ it 'includes RelativeLinkFilter' do
+ expect(doc).not_to parse_relative_links
+ end
+
+ it 'includes EmojiFilter' do
+ expect(doc).to parse_emoji
+ end
+
+ it 'includes TableOfContentsFilter' do
+ expect(doc).to create_header_links
+ end
+
+ it 'includes AutolinkFilter' do
+ expect(doc).to create_autolinks
+ end
+
+ it 'includes all reference filters' do
+ aggregate_failures do
+ expect(doc).to reference_users
+ expect(doc).to reference_issues
+ expect(doc).to reference_merge_requests
+ expect(doc).to reference_snippets
+ expect(doc).to reference_commit_ranges
+ expect(doc).to reference_commits
+ expect(doc).to reference_labels
+ expect(doc).to reference_milestones
+ end
+ end
+
+ it 'includes TaskListFilter' do
+ expect(doc).to parse_task_lists
+ end
+
+ it 'includes GollumTagsFilter' do
+ expect(doc).to parse_gollum_tags
+ end
+
+ it 'includes InlineDiffFilter' do
+ expect(doc).to parse_inline_diffs
+ end
+
+ it 'includes VideoLinkFilter' do
+ expect(doc).to parse_video_links
+ end
+
+ it 'includes ColorFilter' do
+ expect(doc).to parse_colors
+ end
+ end
+
+ # Fake a `current_user` helper
+ def current_user
+ @feat.user
+ end
+end
diff --git a/spec/features/markdown/math_spec.rb b/spec/features/markdown/math_spec.rb
new file mode 100644
index 00000000000..6a23d6b78ab
--- /dev/null
+++ b/spec/features/markdown/math_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe 'Math rendering', :js do
+ it 'renders inline and display math correctly' do
+ description = <<~MATH
+ This math is inline $`a^2+b^2=c^2`$.
+
+ This is on a separate line
+ ```math
+ a^2+b^2=c^2
+ ```
+ MATH
+
+ project = create(:project, :public)
+ issue = create(:issue, project: project, description: description)
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_selector('.katex .mord.mathit', text: 'b')
+ expect(page).to have_selector('.katex-display .mord.mathit', text: 'b')
+ end
+end
diff --git a/spec/features/markdown/mermaid_spec.rb b/spec/features/markdown/mermaid_spec.rb
new file mode 100644
index 00000000000..a25d701ee35
--- /dev/null
+++ b/spec/features/markdown/mermaid_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe 'Mermaid rendering', :js do
+ it 'renders Mermaid diagrams correctly' do
+ description = <<~MERMAID
+ ```mermaid
+ graph TD;
+ A-->B;
+ A-->C;
+ B-->D;
+ C-->D;
+ ```
+ MERMAID
+
+ project = create(:project, :public)
+ issue = create(:issue, project: project, description: description)
+
+ visit project_issue_path(project, issue)
+
+ %w[A B C D].each do |label|
+ expect(page).to have_selector('svg foreignObject', text: label)
+ end
+ end
+end
diff --git a/spec/features/markdown_spec.rb b/spec/features/markdown_spec.rb
deleted file mode 100644
index e285befc66f..00000000000
--- a/spec/features/markdown_spec.rb
+++ /dev/null
@@ -1,329 +0,0 @@
-require 'spec_helper'
-require 'erb'
-
-# This feature spec is intended to be a comprehensive exercising of all of
-# GitLab's non-standard Markdown parsing and the integration thereof.
-#
-# These tests should be very high-level. Anything low-level belongs in the specs
-# for the corresponding HTML::Pipeline filter or helper method.
-#
-# The idea is to pass a Markdown document through our entire processing stack.
-#
-# The process looks like this:
-#
-# Raw Markdown
-# -> `markdown` helper
-# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML
-# -> Post-process HTML
-# -> `gfm` helper
-# -> HTML::Pipeline
-# -> SanitizationFilter
-# -> Other filters, depending on pipeline
-# -> `html_safe`
-# -> Template
-#
-# See the MarkdownFeature class for setup details.
-
-describe 'GitLab Markdown' do
- include Capybara::Node::Matchers
- include MarkupHelper
- include MarkdownMatchers
-
- # Sometimes it can be useful to see the parsed output of the Markdown document
- # for debugging. Call this method to write the output to
- # `tmp/capybara/<filename>.html`.
- def write_markdown(filename = 'markdown_spec')
- File.open(Rails.root.join("tmp/capybara/#{filename}.html"), 'w') do |file|
- file.puts @html
- end
- end
-
- def doc(html = @html)
- @doc ||= Nokogiri::HTML::DocumentFragment.parse(html)
- end
-
- # Shared behavior that all pipelines should exhibit
- shared_examples 'all pipelines' do
- describe 'Redcarpet extensions' do
- it 'does not parse emphasis inside of words' do
- expect(doc.to_html).not_to match('foo<em>bar</em>baz')
- end
-
- it 'parses table Markdown' do
- aggregate_failures do
- expect(doc).to have_selector('th:contains("Header")')
- expect(doc).to have_selector('th:contains("Row")')
- expect(doc).to have_selector('th:contains("Example")')
- end
- end
-
- it 'allows Markdown in tables' do
- expect(doc.at_css('td:contains("Baz")').children.to_html)
- .to eq '<strong>Baz</strong>'
- end
-
- it 'parses fenced code blocks' do
- aggregate_failures do
- expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.c')
- expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.python')
- end
- end
-
- it 'parses mermaid code block' do
- aggregate_failures do
- expect(doc).to have_selector('pre.code.js-render-mermaid')
- end
- end
-
- it 'parses strikethroughs' do
- expect(doc).to have_selector(%{del:contains("and this text doesn't")})
- end
-
- it 'parses superscript' do
- expect(doc).to have_selector('sup', count: 2)
- end
- end
-
- describe 'SanitizationFilter' do
- it 'permits b elements' do
- expect(doc).to have_selector('b:contains("b tag")')
- end
-
- it 'permits em elements' do
- expect(doc).to have_selector('em:contains("em tag")')
- end
-
- it 'permits code elements' do
- expect(doc).to have_selector('code:contains("code tag")')
- end
-
- it 'permits kbd elements' do
- expect(doc).to have_selector('kbd:contains("s")')
- end
-
- it 'permits strike elements' do
- expect(doc).to have_selector('strike:contains(Emoji)')
- end
-
- it 'permits img elements' do
- expect(doc).to have_selector('img[data-src*="smile.png"]')
- end
-
- it 'permits br elements' do
- expect(doc).to have_selector('br')
- end
-
- it 'permits hr elements' do
- expect(doc).to have_selector('hr')
- end
-
- it 'permits span elements' do
- expect(doc).to have_selector('span:contains("span tag")')
- end
-
- it 'permits details elements' do
- expect(doc).to have_selector('details:contains("Hiding the details")')
- end
-
- it 'permits summary elements' do
- expect(doc).to have_selector('details summary:contains("collapsible")')
- end
-
- it 'permits style attribute in th elements' do
- aggregate_failures do
- expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center'
- expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right'
- expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left'
- end
- end
-
- it 'permits style attribute in td elements' do
- aggregate_failures do
- expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center'
- expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right'
- expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left'
- end
- end
-
- it 'removes `rel` attribute from links' do
- expect(doc).not_to have_selector('a[rel="bookmark"]')
- end
-
- it "removes `href` from `a` elements if it's fishy" do
- expect(doc).not_to have_selector('a[href*="javascript"]')
- end
- end
-
- describe 'Escaping' do
- it 'escapes non-tag angle brackets' do
- table = doc.css('table').last.at_css('tbody')
- expect(table.at_xpath('.//tr[1]/td[3]').inner_html).to eq '1 &lt; 3 &amp; 5'
- end
- end
-
- 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-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>}
- end
- end
- end
-
- describe 'ExternalLinkFilter' do
- it 'adds nofollow to external link' do
- link = doc.at_css('a:contains("Google")')
-
- expect(link.attr('rel')).to include('nofollow')
- end
-
- it 'adds noreferrer to external link' do
- link = doc.at_css('a:contains("Google")')
-
- expect(link.attr('rel')).to include('noreferrer')
- end
-
- it 'adds _blank to target attribute for external links' do
- link = doc.at_css('a:contains("Google")')
-
- expect(link.attr('target')).to match('_blank')
- end
-
- it 'ignores internal link' do
- link = doc.at_css('a:contains("GitLab Root")')
-
- expect(link.attr('rel')).not_to match 'nofollow'
- expect(link.attr('target')).not_to match '_blank'
- end
- end
- end
-
- before do
- @feat = MarkdownFeature.new
-
- # `markdown` helper expects a `@project` and `@group` variable
- @project = @feat.project
- @group = @feat.group
- end
-
- context 'default pipeline' do
- before do
- @html = markdown(@feat.raw_markdown)
- end
-
- it_behaves_like 'all pipelines'
-
- it 'includes RelativeLinkFilter' do
- expect(doc).to parse_relative_links
- end
-
- it 'includes EmojiFilter' do
- expect(doc).to parse_emoji
- end
-
- it 'includes TableOfContentsFilter' do
- expect(doc).to create_header_links
- end
-
- it 'includes AutolinkFilter' do
- expect(doc).to create_autolinks
- end
-
- it 'includes all reference filters' do
- aggregate_failures do
- expect(doc).to reference_users
- expect(doc).to reference_issues
- expect(doc).to reference_merge_requests
- expect(doc).to reference_snippets
- expect(doc).to reference_commit_ranges
- expect(doc).to reference_commits
- expect(doc).to reference_labels
- expect(doc).to reference_milestones
- end
- end
-
- it 'includes TaskListFilter' do
- expect(doc).to parse_task_lists
- end
-
- it 'includes InlineDiffFilter' do
- expect(doc).to parse_inline_diffs
- end
-
- it 'includes VideoLinkFilter' do
- expect(doc).to parse_video_links
- end
- end
-
- context 'wiki pipeline' do
- before do
- @project_wiki = @feat.project_wiki
- @project_wiki_page = @feat.project_wiki_page
-
- file = Gollum::File.new(@project_wiki.wiki)
- expect(file).to receive(:path).and_return('images/example.jpg')
- expect(@project_wiki).to receive(:find_file).with('images/example.jpg').and_return(file)
- allow(@project_wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' }
-
- @html = markdown(@feat.raw_markdown, { pipeline: :wiki, project_wiki: @project_wiki, page_slug: @project_wiki_page.slug })
- end
-
- it_behaves_like 'all pipelines'
-
- it 'includes RelativeLinkFilter' do
- expect(doc).not_to parse_relative_links
- end
-
- it 'includes EmojiFilter' do
- expect(doc).to parse_emoji
- end
-
- it 'includes TableOfContentsFilter' do
- expect(doc).to create_header_links
- end
-
- it 'includes AutolinkFilter' do
- expect(doc).to create_autolinks
- end
-
- it 'includes all reference filters' do
- aggregate_failures do
- expect(doc).to reference_users
- expect(doc).to reference_issues
- expect(doc).to reference_merge_requests
- expect(doc).to reference_snippets
- expect(doc).to reference_commit_ranges
- expect(doc).to reference_commits
- expect(doc).to reference_labels
- expect(doc).to reference_milestones
- end
- end
-
- it 'includes TaskListFilter' do
- expect(doc).to parse_task_lists
- end
-
- it 'includes GollumTagsFilter' do
- expect(doc).to parse_gollum_tags
- end
-
- it 'includes InlineDiffFilter' do
- expect(doc).to parse_inline_diffs
- end
-
- it 'includes VideoLinkFilter' do
- expect(doc).to parse_video_links
- end
- end
-
- # Fake a `current_user` helper
- def current_user
- @feat.user
- end
-end
diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb
new file mode 100644
index 00000000000..a3323da1b1f
--- /dev/null
+++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe 'a maintainer edits files on a source-branch of an MR from a fork', :js do
+ include ProjectForksHelper
+ let(:user) { create(:user, username: 'the-maintainer') }
+ let(:target_project) { create(:project, :public, :repository) }
+ let(:author) { create(:user, username: 'mr-authoring-machine') }
+ let(:source_project) { fork_project(target_project, author, repository: true) }
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ target_project: target_project,
+ source_branch: 'fix',
+ target_branch: 'master',
+ author: author,
+ allow_maintainer_to_push: true)
+ end
+
+ before do
+ target_project.add_master(user)
+ sign_in(user)
+
+ visit project_merge_request_path(target_project, merge_request)
+ click_link 'Changes'
+ wait_for_requests
+ first('.js-file-title').click_link 'Edit'
+ wait_for_requests
+ end
+
+ it 'mentions commits will go to the source branch' do
+ expect(page).to have_content('Your changes can be committed to fix because a merge request is open.')
+ end
+
+ it 'allows committing to the source branch' do
+ find('.ace_text-input', visible: false).send_keys('Updated the readme')
+
+ click_button 'Commit changes'
+ wait_for_requests
+
+ expect(page).to have_content('Your changes have been successfully committed')
+ expect(page).to have_content('Updated the readme')
+ end
+end
diff --git a/spec/features/merge_request/user_allows_a_maintainer_to_push_spec.rb b/spec/features/merge_request/user_allows_a_maintainer_to_push_spec.rb
new file mode 100644
index 00000000000..eb41d7de8ed
--- /dev/null
+++ b/spec/features/merge_request/user_allows_a_maintainer_to_push_spec.rb
@@ -0,0 +1,85 @@
+require 'spec_helper'
+
+describe 'create a merge request that allows maintainers to push', :js do
+ include ProjectForksHelper
+ let(:user) { create(:user) }
+ let(:target_project) { create(:project, :public, :repository) }
+ let(:source_project) { fork_project(target_project, user, repository: true, namespace: user.namespace) }
+
+ def visit_new_merge_request
+ visit project_new_merge_request_path(
+ source_project,
+ merge_request: {
+ source_project_id: source_project.id,
+ target_project_id: target_project.id,
+ source_branch: 'fix',
+ target_branch: 'master'
+ })
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ it 'allows setting maintainer push possible' do
+ visit_new_merge_request
+
+ check 'Allow edits from maintainers'
+
+ click_button 'Submit merge request'
+
+ wait_for_requests
+
+ expect(page).to have_content('Allows edits from maintainers')
+ end
+
+ it 'shows a message when one of the projects is private' do
+ source_project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+ visit_new_merge_request
+
+ expect(page).to have_content('Not available for private projects')
+ end
+
+ it 'shows a message when the source branch is protected' do
+ create(:protected_branch, project: source_project, name: 'fix')
+
+ visit_new_merge_request
+
+ expect(page).to have_content('Not available for protected branches')
+ end
+
+ context 'when the merge request is being created within the same project' do
+ let(:source_project) { target_project }
+
+ it 'hides the checkbox if the merge request is being created within the same project' do
+ target_project.add_developer(user)
+
+ visit_new_merge_request
+
+ expect(page).not_to have_content('Allows edits from maintainers')
+ end
+ end
+
+ context 'when a maintainer tries to edit the option' do
+ let(:maintainer) { create(:user) }
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ target_project: target_project,
+ source_branch: 'fixes')
+ end
+
+ before do
+ target_project.add_master(maintainer)
+
+ sign_in(maintainer)
+ end
+
+ it 'it hides the option from maintainers' do
+ visit edit_project_merge_request_path(target_project, merge_request)
+
+ expect(page).not_to have_content('Allows edits from maintainers')
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_assigns_themselves_spec.rb b/spec/features/merge_request/user_assigns_themselves_spec.rb
new file mode 100644
index 00000000000..b6b38186a22
--- /dev/null
+++ b/spec/features/merge_request/user_assigns_themselves_spec.rb
@@ -0,0 +1,49 @@
+require 'rails_helper'
+
+describe 'Merge request > User assigns themselves' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ 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}") }
+
+ context 'logged in as a member of the project' do
+ before do
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'updates related issues', :js do
+ click_link 'Assign yourself to these issues'
+
+ expect(page).to have_content '2 issues have been assigned to you'
+ end
+
+ it 'returns user to the merge request', :js do
+ click_link 'Assign yourself to these issues'
+
+ expect(page).to have_content merge_request.description
+ end
+
+ context 'when related issues are already assigned' do
+ before do
+ [issue1, issue2].each { |issue| issue.update!(assignees: [user]) }
+ end
+
+ it 'does not display if related issues are already assigned' do
+ expect(page).not_to have_content 'Assign yourself'
+ end
+ end
+ end
+
+ context 'logged in as a non-member of the project' do
+ before do
+ sign_in(create(:user))
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not not show assignment link' do
+ expect(page).not_to have_content 'Assign yourself'
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_awards_emoji_spec.rb b/spec/features/merge_request/user_awards_emoji_spec.rb
new file mode 100644
index 00000000000..2f24cfbd9e3
--- /dev/null
+++ b/spec/features/merge_request/user_awards_emoji_spec.rb
@@ -0,0 +1,49 @@
+require 'rails_helper'
+
+describe 'Merge request > User awards emoji', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:merge_request) { create(:merge_request, source_project: project, author: create(:user)) }
+
+ describe 'logged in' do
+ before do
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'adds award to merge request' do
+ first('.js-emoji-btn').click
+ expect(page).to have_selector('.js-emoji-btn.active')
+ expect(first('.js-emoji-btn')).to have_content '1'
+
+ visit project_merge_request_path(project, merge_request)
+ expect(first('.js-emoji-btn')).to have_content '1'
+ end
+
+ it 'removes award from merge request' do
+ first('.js-emoji-btn').click
+ find('.js-emoji-btn.active').click
+ expect(first('.js-emoji-btn')).to have_content '0'
+
+ visit project_merge_request_path(project, merge_request)
+ expect(first('.js-emoji-btn')).to have_content '0'
+ end
+
+ it 'has only one menu on the page' do
+ first('.js-add-award').click
+ expect(page).to have_selector('.emoji-menu')
+
+ expect(page).to have_selector('.emoji-menu', count: 1)
+ end
+ end
+
+ describe 'logged out' do
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not see award menu button' do
+ expect(page).not_to have_selector('.js-award-holder')
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_cherry_picks_spec.rb b/spec/features/merge_request/user_cherry_picks_spec.rb
new file mode 100644
index 00000000000..494096b21c0
--- /dev/null
+++ b/spec/features/merge_request/user_cherry_picks_spec.rb
@@ -0,0 +1,45 @@
+require 'rails_helper'
+
+describe 'Merge request > User cherry-picks', :js do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :repository, namespace: group) }
+ let(:user) { project.creator }
+ let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ context 'Viewing a merged merge request' do
+ before do
+ service = MergeRequests::MergeService.new(project, user)
+
+ perform_enqueued_jobs do
+ service.execute(merge_request)
+ end
+ end
+
+ # Fast-forward merge, or merged before GitLab 8.5.
+ context 'Without a merge commit' do
+ before do
+ merge_request.merge_commit_sha = nil
+ merge_request.save
+ end
+
+ it 'does not show a Cherry-pick button' do
+ visit project_merge_request_path(project, merge_request)
+
+ expect(page).not_to have_link 'Cherry-pick'
+ end
+ end
+
+ context 'With a merge commit' do
+ it 'shows a Cherry-pick button' do
+ visit project_merge_request_path(project, merge_request)
+
+ expect(page).to have_link 'Cherry-pick'
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
new file mode 100644
index 00000000000..7c4fd25bb39
--- /dev/null
+++ b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
@@ -0,0 +1,206 @@
+require 'spec_helper'
+
+feature 'Merge request > User creates image diff notes', :js do
+ include NoteInteractionHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+
+ before do
+ sign_in(user)
+
+ # Stub helper to return any blob file as image from public app folder.
+ # This is necessary to run this specs since we don't display repo images in capybara.
+ allow_any_instance_of(DiffHelper).to receive(:diff_file_blob_raw_url).and_return('/apple-touch-icon.png')
+ allow_any_instance_of(DiffHelper).to receive(:diff_file_old_blob_raw_url).and_return('/favicon.ico')
+ end
+
+ context 'create commit diff notes' do
+ commit_id = '2f63565e7aac07bcdadb654e253078b727143ec4'
+
+ describe 'create a new diff note' do
+ before do
+ visit project_commit_path(project, commit_id)
+ create_image_diff_note
+ end
+
+ it 'shows indicator and avatar badges, and allows collapsing/expanding the discussion notes' do
+ indicator = find('.js-image-badge')
+ badge = find('.image-diff-avatar-link .badge')
+
+ expect(indicator).to have_content('1')
+ expect(badge).to have_content('1')
+
+ find('.js-diff-notes-toggle').click
+
+ expect(page).not_to have_content('image diff test comment')
+
+ find('.js-diff-notes-toggle').click
+
+ expect(page).to have_content('image diff test comment')
+ end
+ end
+
+ describe 'render commit diff notes' do
+ let(:path) { "files/images/6049019_460s.jpg" }
+ let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
+
+ let(:note1_position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ width: 100,
+ height: 100,
+ x: 10,
+ y: 10,
+ position_type: "image",
+ diff_refs: commit.diff_refs
+ )
+ end
+
+ let(:note2_position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ width: 100,
+ height: 100,
+ x: 20,
+ y: 20,
+ position_type: "image",
+ diff_refs: commit.diff_refs
+ )
+ end
+
+ let!(:note1) { create(:diff_note_on_commit, commit_id: commit.id, project: project, position: note1_position, note: 'my note 1') }
+ let!(:note2) { create(:diff_note_on_commit, commit_id: commit.id, project: project, position: note2_position, note: 'my note 2') }
+
+ before do
+ visit project_commit_path(project, commit.id)
+ wait_for_requests
+ end
+
+ it 'render diff indicators within the image diff frame, diff notes, and avatar badge numbers' do
+ expect(page).to have_css('.js-image-badge', count: 2)
+ expect(page).to have_css('.diff-content .note', count: 2)
+ expect(page).to have_css('.image-diff-avatar-link', text: 1)
+ expect(page).to have_css('.image-diff-avatar-link', text: 2)
+ end
+ end
+ end
+
+ %w(inline parallel).each do |view|
+ context "#{view} view" do
+ let(:merge_request) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, author: user) }
+ let(:path) { "files/images/ee_repo_logo.png" }
+
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ width: 100,
+ height: 100,
+ x: 1,
+ y: 1,
+ position_type: "image",
+ diff_refs: merge_request.diff_refs
+ )
+ end
+
+ let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) }
+
+ describe 'creating a new diff note' do
+ before do
+ visit diffs_project_merge_request_path(project, merge_request, view: view)
+ create_image_diff_note
+ end
+
+ it 'shows indicator and avatar badges, and allows collapsing/expanding the discussion notes' do
+ indicator = find('.js-image-badge', match: :first)
+ badge = find('.image-diff-avatar-link .badge', match: :first)
+
+ expect(indicator).to have_content('1')
+ expect(badge).to have_content('1')
+
+ page.all('.js-diff-notes-toggle')[0].click
+ page.all('.js-diff-notes-toggle')[1].click
+
+ expect(page).not_to have_content('image diff test comment')
+
+ page.all('.js-diff-notes-toggle')[0].click
+ page.all('.js-diff-notes-toggle')[1].click
+
+ expect(page).to have_content('image diff test comment')
+ end
+ end
+ end
+ end
+
+ describe 'discussion tab polling' do
+ let(:merge_request) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, author: user) }
+ let(:path) { "files/images/ee_repo_logo.png" }
+
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ width: 100,
+ height: 100,
+ x: 50,
+ y: 50,
+ position_type: "image",
+ diff_refs: merge_request.diff_refs
+ )
+ end
+
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'render diff indicators within the image frame' do
+ diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position)
+
+ wait_for_requests
+
+ expect(page).to have_selector('.image-comment-badge')
+ expect(page).to have_content(diff_note.note)
+ end
+ end
+
+ describe 'image view modes' do
+ before do
+ visit project_commit_path(project, '2f63565e7aac07bcdadb654e253078b727143ec4')
+ end
+
+ it 'resizes image in onion skin view mode' do
+ find('.view-modes-menu .onion-skin').click
+
+ expect(find('.onion-skin-frame')['style']).to match('width: 228px; height: 240px;')
+ end
+
+ it 'resets onion skin view mode opacity when toggling between view modes' do
+ find('.view-modes-menu .onion-skin').click
+
+ # Simulate dragging onion-skin slider
+ drag_and_drop_by(find('.dragger'), -30, 0)
+
+ expect(find('.onion-skin-frame .frame.added', visible: false)['style']).not_to match('opacity: 1;')
+
+ find('.view-modes-menu .swipe').click
+ find('.view-modes-menu .onion-skin').click
+
+ expect(find('.onion-skin-frame .frame.added', visible: false)['style']).to match('opacity: 1;')
+ end
+ end
+
+ def drag_and_drop_by(element, right_by, down_by)
+ page.driver.browser.action.drag_and_drop_by(element.native, right_by, down_by).perform
+ end
+
+ def create_image_diff_note
+ find('.js-add-image-diff-note-button', match: :first).click
+ page.all('.js-add-image-diff-note-button')[0].click
+ find('.diff-content .note-textarea').native.send_keys('image diff test comment')
+ click_button 'Comment'
+ wait_for_requests
+ end
+end
diff --git a/spec/features/merge_request/user_creates_mr_spec.rb b/spec/features/merge_request/user_creates_mr_spec.rb
new file mode 100644
index 00000000000..1ac31de62cb
--- /dev/null
+++ b/spec/features/merge_request/user_creates_mr_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+describe 'Merge request > User creates MR' do
+ it_behaves_like 'a creatable merge request'
+
+ context 'from a forked project' do
+ include ProjectForksHelper
+
+ let(:canonical_project) { create(:project, :public, :repository) }
+
+ let(:source_project) do
+ fork_project(canonical_project, user,
+ repository: true,
+ namespace: user.namespace)
+ end
+
+ context 'to canonical project' do
+ it_behaves_like 'a creatable merge request'
+ end
+
+ context 'to another forked project' do
+ let(:target_project) do
+ fork_project(canonical_project, user,
+ repository: true,
+ namespace: user.namespace)
+ end
+
+ it_behaves_like 'a creatable merge request'
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
new file mode 100644
index 00000000000..e1e70b6d260
--- /dev/null
+++ b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
@@ -0,0 +1,54 @@
+require 'rails_helper'
+
+describe 'Merge request < User customizes merge commit message', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:issue_1) { create(:issue, project: project)}
+ let(:issue_2) { create(:issue, project: project)}
+ let(:merge_request) do
+ create(
+ :merge_request,
+ :simple,
+ source_project: project,
+ description: "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}"
+ )
+ end
+ let(:textbox) { page.find(:css, '.js-commit-message', visible: false) }
+ let(:default_message) do
+ [
+ "Merge branch 'feature' into 'master'",
+ merge_request.title,
+ "Closes #{issue_1.to_reference} and #{issue_2.to_reference}",
+ "See merge request #{merge_request.to_reference(full: true)}"
+ ].join("\n\n")
+ end
+ let(:message_with_description) do
+ [
+ "Merge branch 'feature' into 'master'",
+ merge_request.title,
+ merge_request.description,
+ "See merge request #{merge_request.to_reference(full: true)}"
+ ].join("\n\n")
+ end
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'toggles commit message between message with description and without description' do
+ expect(page).not_to have_selector('.js-commit-message')
+ click_button "Modify commit message"
+ expect(textbox).to be_visible
+ expect(textbox.value).to eq(default_message)
+
+ click_link "Include description in commit message"
+
+ expect(textbox.value).to eq(message_with_description)
+
+ click_link "Don't include description in commit message"
+
+ expect(textbox.value).to eq(default_message)
+ end
+end
diff --git a/spec/features/merge_request/user_edits_mr_spec.rb b/spec/features/merge_request/user_edits_mr_spec.rb
new file mode 100644
index 00000000000..8c9e782aa76
--- /dev/null
+++ b/spec/features/merge_request/user_edits_mr_spec.rb
@@ -0,0 +1,11 @@
+require 'rails_helper'
+
+describe 'Merge request > User edits MR' do
+ it_behaves_like 'an editable merge request'
+
+ context 'for a forked project' do
+ it_behaves_like 'an editable merge request' do
+ let(:source_project) { create(:project, :repository, forked_from_project: target_project) }
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_locks_discussion_spec.rb b/spec/features/merge_request/user_locks_discussion_spec.rb
new file mode 100644
index 00000000000..a68df872334
--- /dev/null
+++ b/spec/features/merge_request/user_locks_discussion_spec.rb
@@ -0,0 +1,49 @@
+require 'rails_helper'
+
+describe 'Merge request > User locks discussion', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when the discussion is locked' do
+ before do
+ merge_request.update_attribute(:discussion_locked, true)
+ end
+
+ context 'when a user is a team member' do
+ before do
+ project.add_developer(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'the user can create a comment' do
+ page.within('.issuable-discussion #notes .js-main-target-form') do
+ fill_in 'note[note]', with: 'Some new comment'
+ click_button 'Comment'
+ end
+
+ wait_for_requests
+
+ expect(find('.issuable-discussion #notes')).to have_content('Some new comment')
+ end
+ end
+
+ context 'when a user is not a team member' do
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'the user can not create a comment' do
+ page.within('.issuable-discussion #notes') do
+ expect(page).not_to have_selector('js-main-target-form')
+ expect(page.find('.disabled-comment'))
+ .to have_content('This merge request is locked. Only project members can comment.')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_merges_immediately_spec.rb b/spec/features/merge_request/user_merges_immediately_spec.rb
new file mode 100644
index 00000000000..b16fc9bfc89
--- /dev/null
+++ b/spec/features/merge_request/user_merges_immediately_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+describe 'Merge requests > User merges immediately', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let!(:merge_request) do
+ create(:merge_request_with_diffs, source_project: project,
+ author: user,
+ title: 'Bug NS-04',
+ head_pipeline: pipeline,
+ source_branch: pipeline.ref)
+ end
+ let(:pipeline) do
+ create(:ci_pipeline, project: project,
+ ref: 'master',
+ sha: project.repository.commit('master').id)
+ end
+
+ context 'when there is active pipeline for merge request' do
+ before do
+ create(:ci_build, pipeline: pipeline)
+ project.add_master(user)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'enables merge immediately' do
+ page.within '.mr-widget-body' do
+ find('.dropdown-toggle').click
+
+ Sidekiq::Testing.fake! do
+ click_link 'Merge immediately'
+
+ expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress')
+
+ wait_for_requests
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
new file mode 100644
index 00000000000..a045791f6b4
--- /dev/null
+++ b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
@@ -0,0 +1,145 @@
+require 'rails_helper'
+
+describe 'Merge request > User merges only if pipeline succeeds', :js do
+ let(:merge_request) { create(:merge_request_with_diffs) }
+ let(:project) { merge_request.target_project }
+
+ before do
+ project.add_master(merge_request.author)
+ sign_in(merge_request.author)
+ end
+
+ context 'project does not have CI enabled' do
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
+ end
+ end
+
+ context 'when project has CI enabled' do
+ let!(:pipeline) do
+ create(:ci_empty_pipeline,
+ project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ status: status, head_pipeline_of: merge_request)
+ end
+
+ context 'when merge requests can only be merged if the pipeline succeeds' do
+ before do
+ project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true)
+ end
+
+ context 'when CI is running' do
+ let(:status) { :running }
+
+ it 'does not allow to merge immediately' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Merge when pipeline succeeds'
+ expect(page).not_to have_button '.js-merge-moment'
+ end
+ end
+
+ context 'when CI failed' do
+ let(:status) { :failed }
+
+ it 'does not allow MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ 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
+
+ context 'when CI canceled' do
+ let(:status) { :canceled }
+
+ it 'does not allow MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ 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
+
+ context 'when CI succeeded' do
+ let(:status) { :success }
+
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
+ end
+ end
+
+ context 'when CI skipped' do
+ let(:status) { :skipped }
+
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
+ end
+ end
+ end
+
+ context 'when merge requests can be merged when the build failed' do
+ before do
+ project.update_attribute(:only_allow_merge_if_pipeline_succeeds, false)
+ end
+
+ context 'when CI is running' do
+ let(:status) { :running }
+
+ it 'allows MR to be merged immediately' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Merge when pipeline succeeds'
+
+ page.find('.js-merge-moment').click
+ expect(page).to have_content 'Merge immediately'
+ end
+ end
+
+ context 'when CI failed' do
+ let(:status) { :failed }
+
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
+ end
+ end
+
+ context 'when CI succeeded' do
+ let(:status) { :success }
+
+ it 'allows MR to be merged' do
+ visit project_merge_request_path(project, merge_request)
+
+ wait_for_requests
+
+ expect(page).to have_button 'Merge'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
new file mode 100644
index 00000000000..db92a3504f3
--- /dev/null
+++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
@@ -0,0 +1,192 @@
+require 'rails_helper'
+
+describe 'Merge request > User merges when pipeline succeeds', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:merge_request) do
+ create(:merge_request_with_diffs, source_project: project,
+ author: user,
+ 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,
+ head_pipeline_of: merge_request)
+ end
+
+ before do
+ project.add_master(user)
+ end
+
+ context 'when there is active pipeline for merge request' do
+ before do
+ create(:ci_build, pipeline: pipeline)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ describe 'enabling Merge when pipeline succeeds' do
+ shared_examples 'Merge when pipeline succeeds activator' 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_selector ".js-cancel-auto-merge"
+ visit project_merge_request_path(project, 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
+ end
+
+ context "when enabled immediately" do
+ it_behaves_like 'Merge when pipeline succeeds activator'
+ end
+
+ context 'when enabled after pipeline status changed' do
+ before do
+ pipeline.run!
+
+ # We depend on merge request widget being reloaded
+ # so we have to wait for asynchronous call to reload it
+ # and have_content expectation handles that.
+ #
+ expect(page).to have_content "Pipeline ##{pipeline.id} running"
+ end
+
+ it_behaves_like 'Merge when pipeline succeeds activator'
+ end
+
+ context 'when enabled after it was previously canceled' do
+ before do
+ click_button "Merge when pipeline succeeds"
+ click_link "Cancel automatic merge"
+ end
+
+ it_behaves_like 'Merge when pipeline succeeds activator'
+ end
+
+ context 'when it was enabled and then canceled' do
+ let(:merge_request) do
+ create(:merge_request_with_diffs,
+ :merge_when_pipeline_succeeds,
+ source_project: project,
+ title: 'Bug NS-04',
+ author: user,
+ merge_user: user,
+ merge_params: { force_remove_source_branch: '1' })
+ end
+
+ before do
+ click_link "Cancel automatic merge"
+ end
+
+ it_behaves_like 'Merge when pipeline succeeds activator'
+ end
+ end
+
+ describe 'enabling Merge when pipeline succeeds via dropdown' do
+ it 'activates the Merge when pipeline succeeds feature' do
+ 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_link "Cancel automatic merge"
+ end
+ end
+ end
+
+ context 'when merge when pipeline succeeds is enabled' do
+ let(:merge_request) do
+ create(:merge_request_with_diffs, :simple, source_project: project,
+ author: user,
+ merge_user: user,
+ title: 'MepMep',
+ merge_when_pipeline_succeeds: true)
+ end
+ let!(:build) do
+ create(:ci_build, pipeline: pipeline)
+ end
+
+ before do
+ sign_in user
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'allows to cancel the automatic merge' do
+ click_link "Cancel automatic merge"
+
+ expect(page).to have_button "Merge when pipeline succeeds"
+
+ refresh
+
+ expect(page).to have_content "canceled the automatic merge"
+ end
+
+ it 'allows to remove source branch' do
+ click_link "Remove source branch"
+
+ expect(page).to have_content "The source branch will be removed"
+ end
+
+ context 'when pipeline succeeds' do
+ before do
+ build.success
+ refresh
+ end
+
+ it 'merges merge request' do
+ expect(page).to have_content 'The changes were merged'
+ expect(merge_request.reload).to be_merged
+ end
+ end
+
+ context 'view merge request with MWPS enabled but automatically merge fails' do
+ before do
+ merge_request.update(
+ merge_user: merge_request.author,
+ merge_error: 'Something went wrong'
+ )
+ refresh
+ 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')
+ end
+ end
+ end
+
+ context 'view merge request with MWPS enabled but automatically merge fails' do
+ before do
+ merge_request.update(
+ merge_user: merge_request.author,
+ merge_error: 'Something went wrong'
+ )
+ refresh
+ 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')
+ end
+ end
+ end
+ end
+
+ context 'when pipeline is not active' do
+ it 'does not allow to enable merge when pipeline succeeds' do
+ visit project_merge_request_path(project, merge_request)
+
+ expect(page).not_to have_link 'Merge when pipeline succeeds'
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb
new file mode 100644
index 00000000000..2b4623d6dc9
--- /dev/null
+++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb
@@ -0,0 +1,281 @@
+require 'rails_helper'
+
+describe 'Merge request > User posts diff notes', :js do
+ include MergeRequestDiffHelpers
+
+ let(:merge_request) { create(:merge_request) }
+ let(:project) { merge_request.source_project }
+ let(:user) { project.creator }
+ let(:comment_button_class) { '.add-diff-note' }
+ let(:notes_holder_input_class) { 'js-temp-notes-holder' }
+ let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' }
+ let(:test_note_comment) { 'this is a test note!' }
+
+ before do
+ set_cookie('sidebar_collapsed', 'true')
+
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ context 'when hovering over a parallel view diff file' do
+ before do
+ 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
+ it 'allows commenting on the left side' do
+ should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'left')
+ end
+
+ it 'does not allow commenting on the right side' do
+ should_not_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'right')
+ end
+ end
+
+ context 'with no line on the left and a new line on the right' do
+ it 'does not allow commenting on the left side' do
+ should_not_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'left')
+ end
+
+ it 'allows commenting on the right side' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'right')
+ end
+ end
+
+ context 'with an old line on the left and a new line on the right' do
+ it 'allows commenting on the left side' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'left')
+ end
+
+ it 'allows commenting on the right side' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'right')
+ end
+ end
+
+ context 'with an unchanged line on the left and an unchanged line on the right' do
+ it 'allows commenting on the left side' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]', match: :first).find(:xpath, '..'), 'left')
+ end
+
+ it 'allows commenting on the right side' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]', match: :first).find(:xpath, '..'), 'right')
+ end
+ end
+
+ context 'with a match line' do
+ it 'does not allow commenting on the left side' do
+ should_not_allow_commenting(find('.match', match: :first).find(:xpath, '..'), 'left')
+ end
+
+ it 'does not allow commenting on the right side' do
+ should_not_allow_commenting(find('.match', match: :first).find(:xpath, '..'), 'right')
+ end
+ end
+
+ context 'with an unfolded line' do
+ before do
+ find('.js-unfold', match: :first).click
+ wait_for_requests
+ end
+
+ # The first `.js-unfold` unfolds upwards, therefore the first
+ # `.line_holder` will be an unfolded line.
+ let(:line_holder) { first('.line_holder[id="1"]') }
+
+ it 'does not allow commenting on the left side' do
+ should_not_allow_commenting(line_holder, 'left')
+ end
+
+ it 'does not allow commenting on the right side' do
+ should_not_allow_commenting(line_holder, 'right')
+ end
+ end
+ end
+
+ context 'when hovering over an inline view diff file' do
+ before do
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
+ end
+
+ context 'after deleteing a note' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
+
+ accept_confirm do
+ first('button.more-actions-toggle').click
+ first('.js-note-delete').click
+ end
+
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
+ end
+ end
+
+ context 'with a new line' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
+ end
+ end
+
+ context 'with an old line' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'))
+ end
+ end
+
+ context 'with an unchanged line' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]'))
+ end
+ end
+
+ context 'with a match line' do
+ it 'does not allow commenting' do
+ should_not_allow_commenting(find('.match', match: :first))
+ end
+ end
+
+ context 'with an unfolded line' do
+ before do
+ find('.js-unfold', match: :first).click
+ wait_for_requests
+ end
+
+ # The first `.js-unfold` unfolds upwards, therefore the first
+ # `.line_holder` will be an unfolded line.
+ let(:line_holder) { first('.line_holder[id="1"]') }
+
+ it 'does not allow commenting' do
+ should_not_allow_commenting line_holder
+ end
+ end
+
+ context 'when hovering over a diff discussion' do
+ before do
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]'))
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not allow commenting' do
+ should_not_allow_commenting(find('.line_holder', match: :first))
+ end
+ end
+ end
+
+ context 'when cancelling the comment addition' do
+ before do
+ visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
+ end
+
+ context 'with a new line' do
+ it 'allows dismissing a comment' do
+ should_allow_dismissing_a_comment(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
+ end
+ end
+ end
+
+ describe 'with muliple note forms' do
+ before do
+ 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
+
+ describe 'posting a note' do
+ it 'adds as discussion' do
+ expect(page).to have_css('.js-temp-notes-holder', count: 2)
+
+ should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'), asset_form_reset: false)
+ expect(page).to have_css('.notes_holder .note', count: 1)
+ expect(page).to have_css('.js-temp-notes-holder', count: 1)
+ expect(page).to have_button('Reply...')
+ end
+ end
+ end
+
+ 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_project_merge_request_path(project, merge_request, view: 'inline')
+ end
+
+ context 'with a new line' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
+ end
+ end
+
+ context 'with an old line' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'))
+ end
+ end
+
+ context 'with an unchanged line' do
+ it 'allows commenting' do
+ should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]'))
+ end
+ end
+
+ context 'with a match line' do
+ it 'does not allow commenting' do
+ should_not_allow_commenting(find('.match', match: :first))
+ end
+ end
+ end
+
+ def should_allow_commenting(line_holder, diff_side = nil, asset_form_reset: true)
+ write_comment_on_line(line_holder, diff_side)
+
+ click_button 'Comment'
+
+ wait_for_requests
+
+ assert_comment_persistence(line_holder, asset_form_reset: asset_form_reset)
+ end
+
+ def should_allow_dismissing_a_comment(line_holder, diff_side = nil)
+ write_comment_on_line(line_holder, diff_side)
+
+ find('.js-close-discussion-note-form').click
+
+ assert_comment_dismissal(line_holder)
+ end
+
+ def should_not_allow_commenting(line_holder, diff_side = nil)
+ line = get_line_components(line_holder, diff_side)
+ line[:content].hover
+ expect(line[:num]).not_to have_css comment_button_class
+ end
+
+ def write_comment_on_line(line_holder, diff_side)
+ click_diff_line(line_holder, diff_side)
+
+ notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath)
+
+ expect(notes_holder_input[:class]).to include(notes_holder_input_class)
+
+ notes_holder_input.fill_in 'note[note]', with: test_note_comment
+ end
+
+ def assert_comment_persistence(line_holder, asset_form_reset:)
+ notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath)
+
+ expect(notes_holder_saved[:class]).not_to include(notes_holder_input_class)
+ expect(notes_holder_saved).to have_content test_note_comment
+
+ assert_form_is_reset if asset_form_reset
+ end
+
+ def assert_comment_dismissal(line_holder)
+ expect(line_holder).not_to have_xpath notes_holder_input_xpath
+ expect(page).not_to have_content test_note_comment
+
+ assert_form_is_reset
+ end
+
+ def assert_form_is_reset
+ expect(page).to have_no_css('.js-temp-notes-holder')
+ end
+end
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
new file mode 100644
index 00000000000..b54addce993
--- /dev/null
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -0,0 +1,168 @@
+require 'rails_helper'
+
+describe 'Merge request > User posts notes', :js do
+ include NoteInteractionHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.creator }
+ let(:merge_request) do
+ create(:merge_request, source_project: project, target_project: project)
+ end
+ let!(:note) do
+ create(:note_on_merge_request, :with_attachment, noteable: merge_request,
+ project: project)
+ end
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ subject { page }
+
+ 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')
+ page.within('.js-main-target-form') do
+ expect(page).not_to have_link('Cancel')
+ end
+ end
+
+ describe 'with text' do
+ before do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: 'This is awesome'
+ end
+ end
+
+ it 'has enable submit button and preview button' do
+ page.within('.js-main-target-form') do
+ expect(page).not_to have_css('.js-comment-button[disabled]')
+ expect(page).to have_css('.js-md-preview-button', visible: true)
+ end
+ end
+ end
+ end
+
+ describe 'when posting a note' do
+ before do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: 'This is awesome!'
+ find('.js-md-preview-button').click
+ click_button 'Comment'
+ end
+ end
+
+ it 'is added and form reset' do
+ is_expected.to have_content('This is awesome!')
+ page.within('.js-main-target-form') do
+ expect(page).to have_no_field('note[note]', with: 'This is awesome!')
+ expect(page).to have_css('.js-md-preview', visible: :hidden)
+ end
+ page.within('.js-main-target-form') do
+ is_expected.to have_css('.js-note-text', visible: true)
+ end
+ end
+ end
+
+ describe 'when previewing a note' do
+ it 'shows the toolbar buttons when editing a note' do
+ page.within('.js-main-target-form') do
+ expect(page).to have_css('.md-header-toolbar.active')
+ end
+ end
+
+ it 'hides the toolbar buttons when previewing a note' do
+ find('.js-md-preview-button').click
+ page.within('.js-main-target-form') do
+ expect(page).not_to have_css('.md-header-toolbar.active')
+ end
+ end
+ end
+
+ describe 'when editing a note' do
+ it 'there should be a hidden edit form' do
+ is_expected.to have_css('.note-edit-form:not(.mr-note-edit-form)', visible: false, count: 1)
+ is_expected.to have_css('.note-edit-form.mr-note-edit-form', visible: false, count: 1)
+ end
+
+ describe 'editing the note' do
+ before do
+ find('.note').hover
+
+ find('.js-note-edit').click
+ end
+
+ it 'shows the note edit form and hide the note body' do
+ page.within("#note_#{note.id}") do
+ expect(find('.current-note-edit-form', visible: true)).to be_visible
+ expect(find('.note-edit-form', visible: true)).to be_visible
+ expect(find(:css, '.note-body > .note-text', visible: false)).not_to be_visible
+ end
+ end
+
+ it 'resets the edit note form textarea with the original content of the note if cancelled' do
+ within('.current-note-edit-form') do
+ fill_in 'note[note]', with: 'Some new content'
+ find('.btn-cancel').click
+ expect(find('.js-note-text', visible: false).text).to eq ''
+ end
+ end
+
+ it 'allows using markdown buttons after saving a note and then trying to edit it again' do
+ page.within('.current-note-edit-form') do
+ fill_in 'note[note]', with: 'This is the new content'
+ find('.btn-save').click
+ end
+
+ wait_for_requests
+ find('.note').hover
+
+ find('.js-note-edit').click
+
+ page.within('.current-note-edit-form') do
+ expect(find('#note_note').value).to eq('This is the new content')
+ find('.js-md:first-child').click
+ expect(find('#note_note').value).to eq('This is the new content****')
+ end
+ end
+
+ it 'appends the edited at time to the note' do
+ page.within('.current-note-edit-form') do
+ fill_in 'note[note]', with: 'Some new content'
+ find('.btn-save').click
+ end
+
+ 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/)
+ end
+ end
+ end
+
+ describe 'deleting attachment on legacy diff note' do
+ before do
+ find('.note').hover
+
+ find('.js-note-edit').click
+ end
+
+ it 'shows the delete link' do
+ page.within('.note-attachment') do
+ is_expected.to have_css('.js-note-attachment-delete')
+ end
+ end
+
+ it 'removes the attachment div and resets the edit form' do
+ accept_confirm { 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_requests
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb
new file mode 100644
index 00000000000..19995559fae
--- /dev/null
+++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb
@@ -0,0 +1,195 @@
+require 'rails_helper'
+
+describe 'Merge request > User resolves conflicts', :js do
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.creator }
+
+ 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|
+ mr.mark_as_unmergeable
+ end
+ end
+
+ shared_examples "conflicts are resolved in Interactive mode" do
+ it 'conflicts are resolved in Interactive mode' do
+ within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do
+ click_button 'Use ours'
+ end
+
+ within find('.files-wrapper .diff-file', text: 'files/ruby/regex.rb') do
+ all('button', text: 'Use ours').each do |button|
+ button.send_keys(:return)
+ end
+ end
+
+ find_button('Commit conflict resolution').send_keys(:return)
+
+ expect(page).to have_content('All merge conflicts were resolved')
+ merge_request.reload_diff
+
+ wait_for_requests
+
+ click_on 'Changes'
+ 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 }")
+ expect(page).to have_selector('.line_content.new', text: "options = { chdir: path }")
+ end
+
+ within find('.diff-file', text: 'files/ruby/regex.rb') do
+ expect(page).to have_selector('.line_content.new', text: "def username_regexp")
+ expect(page).to have_selector('.line_content.new', text: "def project_name_regexp")
+ expect(page).to have_selector('.line_content.new', text: "def path_regexp")
+ expect(page).to have_selector('.line_content.new', text: "def archive_formats_regexp")
+ expect(page).to have_selector('.line_content.new', text: "def git_reference_regexp")
+ expect(page).to have_selector('.line_content.new', text: "def default_regexp")
+ end
+ end
+ end
+
+ shared_examples "conflicts are resolved in Edit inline mode" do
+ it 'conflicts are resolved in Edit inline mode' do
+ expect(find('#conflicts')).to have_content('popen.rb')
+
+ within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do
+ click_button 'Edit inline'
+ wait_for_requests
+ find('.files-wrapper .diff-file pre')
+ 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_requests
+ find('.files-wrapper .diff-file pre')
+ execute_script('ace.edit($(".files-wrapper .diff-file pre")[1]).setValue("Gregor Samsa woke from troubled dreams");')
+ end
+
+ find_button('Commit conflict resolution').send_keys(:return)
+
+ expect(page).to have_content('All merge conflicts were resolved')
+ merge_request.reload_diff
+
+ wait_for_requests
+
+ click_on 'Changes'
+ wait_for_requests
+
+ expect(page).to have_content('One morning')
+ expect(page).to have_content('Gregor Samsa woke from troubled dreams')
+ end
+ end
+
+ context 'can be resolved in the UI' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ context 'the conflicts are resolvable' do
+ let(:merge_request) { create_merge_request('conflict-resolvable') }
+
+ 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: %r{/conflicts\Z})
+ end
+
+ context 'in Inline view mode' do
+ before do
+ click_link('conflicts', href: %r{/conflicts\Z})
+ end
+
+ include_examples "conflicts are resolved in Interactive mode"
+ include_examples "conflicts are resolved in Edit inline mode"
+ end
+
+ context 'in Parallel view mode' do
+ before do
+ click_link('conflicts', href: %r{/conflicts\Z})
+ click_button 'Side-by-side'
+ end
+
+ include_examples "conflicts are resolved in Interactive mode"
+ include_examples "conflicts are resolved in Edit inline mode"
+ end
+ end
+
+ context 'the conflict contain markers' do
+ let(:merge_request) { create_merge_request('conflict-contains-conflict-markers') }
+
+ before do
+ visit project_merge_request_path(project, merge_request)
+ click_link('conflicts', href: %r{/conflicts\Z})
+ end
+
+ it 'conflicts can not be resolved in Interactive mode' do
+ within find('.files-wrapper .diff-file', text: 'files/markdown/ruby-style-guide.md') do
+ expect(page).not_to have_content 'Interactive mode'
+ expect(page).not_to have_content 'Edit inline'
+ end
+ end
+
+ 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_requests
+ find('.files-wrapper .diff-file pre')
+ execute_script('ace.edit($(".files-wrapper .diff-file pre")[0]).setValue("Gregor Samsa woke from troubled dreams");')
+ end
+
+ click_button 'Commit conflict resolution'
+
+ expect(page).to have_content('All merge conflicts were resolved')
+
+ merge_request.reload_diff
+
+ wait_for_requests
+
+ click_on 'Changes'
+ wait_for_requests
+ click_link 'Expand all'
+ wait_for_requests
+
+ expect(page).to have_content('Gregor Samsa woke from troubled dreams')
+ end
+ end
+ end
+
+ UNRESOLVABLE_CONFLICTS = {
+ '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'
+ }.freeze
+
+ UNRESOLVABLE_CONFLICTS.each do |source_branch, description|
+ context description do
+ let(:merge_request) { create_merge_request(source_branch) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not show a link to the conflict resolution page' do
+ expect(page).not_to have_link('conflicts', href: %r{/conflicts\Z})
+ end
+
+ it 'shows an error if the conflicts page is visited directly' do
+ visit current_url + '/conflicts'
+ wait_for_requests
+
+ expect(find('#conflicts')).to have_content('Please try to resolve them locally.')
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
new file mode 100644
index 00000000000..b4ad4b64d8e
--- /dev/null
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -0,0 +1,527 @@
+require 'rails_helper'
+
+describe 'Merge request > User resolves diff notes and discussions', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:guest) { create(:user) }
+ 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" }
+ let(:position) do
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ old_line: nil,
+ new_line: 9,
+ diff_refs: merge_request.diff_refs
+ )
+ end
+
+ context 'no discussions' do
+ before do
+ project.add_master(user)
+ sign_in(user)
+ note.destroy
+ visit_merge_request
+ end
+
+ it 'displays no discussion resolved data' do
+ expect(page).not_to have_content('discussion resolved')
+ expect(page).not_to have_selector('.discussion-next-btn')
+ end
+ end
+
+ context 'as authorized user' do
+ before do
+ project.add_master(user)
+ sign_in(user)
+ visit_merge_request
+ end
+
+ context 'single discussion' do
+ it 'shows text with how many discussions' do
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+
+ it 'allows user to mark a note as resolved' do
+ page.within '.diff-content .note' do
+ find('.line-resolve-btn').click
+
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
+ end
+
+ page.within '.diff-content' do
+ expect(page).to have_selector('.btn', text: 'Unresolve discussion')
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to mark discussion as resolved' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+ end
+
+ expect(page).to have_selector('.discussion-body', visible: false)
+
+ page.within '.diff-content .note' do
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to unresolve discussion' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+ click_button 'Unresolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+
+ describe 'resolved discussion' do
+ before do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+ end
+
+ visit_merge_request
+ end
+
+ describe 'timeline view' do
+ it 'hides when resolve discussion is clicked' do
+ expect(page).to have_selector('.discussion-body', visible: false)
+ end
+
+ it 'shows resolved discussion when toggled' do
+ find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click
+
+ expect(page.find(".line-holder-placeholder")).to be_visible
+ expect(page.find(".timeline-content #note_#{note.id}")).to be_visible
+ end
+ end
+
+ describe 'side-by-side view' do
+ before do
+ page.within('.merge-request-tabs') { click_link 'Changes' }
+ page.find('#parallel-diff-btn').click
+ end
+
+ it 'hides when resolve discussion is clicked' do
+ expect(page).to have_selector('.diffs .diff-file .notes_holder', visible: false)
+ end
+
+ it 'shows resolved discussion when toggled' do
+ find('.diff-comment-avatar-holders').click
+
+ expect(find('.diffs .diff-file .notes_holder')).to be_visible
+ end
+ end
+ end
+
+ it 'allows user to resolve from reply form without a comment' do
+ page.within '.diff-content' do
+ click_button 'Reply...'
+
+ click_button 'Resolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to unresolve from reply form without a comment' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+ sleep 1
+
+ click_button 'Reply...'
+
+ click_button 'Unresolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ expect(page).not_to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to comment & resolve discussion' do
+ page.within '.diff-content' do
+ click_button 'Reply...'
+
+ find('.js-note-text').set 'testing'
+
+ click_button 'Comment & resolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to comment & unresolve discussion' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+
+ click_button 'Reply...'
+
+ find('.js-note-text').set 'testing'
+
+ click_button 'Comment & unresolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+
+ it 'allows user to quickly scroll to next unresolved discussion' do
+ page.within '.line-resolve-all-container' do
+ page.find('.discussion-next-btn').click
+ end
+
+ expect(page.evaluate_script("window.pageYOffset")).to be > 0
+ end
+
+ it 'hides jump to next button when all resolved' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+ end
+
+ expect(page).to have_selector('.discussion-next-btn', visible: false)
+ end
+
+ it 'updates updated text after resolving note' do
+ page.within '.diff-content .note' do
+ find('.line-resolve-btn').click
+ end
+
+ expect(page).to have_content("Resolved by #{user.name}")
+ end
+
+ it 'hides jump to next discussion button' do
+ page.within '.discussion-reply-holder' do
+ expect(page).not_to have_selector('.discussion-next-btn')
+ end
+ end
+ end
+
+ context 'multiple notes' do
+ before do
+ create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: note)
+ visit_merge_request
+ end
+
+ it 'does not mark discussion as resolved when resolving single note' do
+ page.within("#note_#{note.id}") do
+ first('.line-resolve-btn').click
+
+ wait_for_requests
+
+ expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
+ end
+
+ expect(page).to have_content('Last updated')
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+
+ it 'resolves discussion' do
+ page.all('.note .line-resolve-btn').each do |button|
+ button.click
+ end
+
+ expect(page).to have_content('Resolved by')
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ end
+ end
+ end
+
+ context 'muliple discussions' do
+ before do
+ create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request)
+ visit_merge_request
+ end
+
+ it 'shows text with how many discussions' do
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/2 discussions resolved')
+ end
+ end
+
+ it 'allows user to mark a single note as resolved' do
+ click_button('Resolve discussion', match: :first)
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/2 discussions resolved')
+ end
+ end
+
+ it 'allows user to mark all notes as resolved' do
+ page.all('.line-resolve-btn').each do |btn|
+ btn.click
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('2/2 discussions resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user user to mark all discussions as resolved' do
+ page.all('.discussion-reply-holder').each do |reply_holder|
+ page.within reply_holder do
+ click_button 'Resolve discussion'
+ end
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('2/2 discussions resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to quickly scroll to next unresolved discussion' do
+ page.within first('.discussion-reply-holder') do
+ click_button 'Resolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ page.find('.discussion-next-btn').click
+ end
+
+ expect(page.evaluate_script("window.pageYOffset")).to be > 0
+ end
+
+ it 'updates updated text after resolving note' do
+ page.within first('.diff-content .note') do
+ find('.line-resolve-btn').click
+ end
+
+ expect(page).to have_content("Resolved by #{user.name}")
+ end
+
+ it 'shows jump to next discussion button' do
+ expect(page.all('.discussion-reply-holder')).to all(have_selector('.discussion-next-btn'))
+ end
+
+ it 'displays next discussion even if hidden' do
+ page.all('.note-discussion').each do |discussion|
+ page.within discussion do
+ click_button 'Toggle discussion'
+ end
+ end
+
+ page.within('.issuable-discussion #notes') do
+ expect(page).not_to have_selector('.btn', text: 'Resolve discussion')
+ end
+
+ page.within '.line-resolve-all-container' do
+ page.find('.discussion-next-btn').click
+ end
+
+ expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
+ end
+ end
+
+ context 'changes tab' do
+ it 'shows text with how many discussions' do
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+
+ it 'allows user to mark a note as resolved' do
+ page.within '.diff-content .note' do
+ find('.line-resolve-btn').click
+
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+
+ page.within '.diff-content' do
+ expect(page).to have_selector('.btn', text: 'Unresolve discussion')
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to mark discussion as resolved' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+ end
+
+ page.within '.diff-content .note' do
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to unresolve discussion' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+ click_button 'Unresolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+
+ it 'allows user to comment & resolve discussion' do
+ page.within '.diff-content' do
+ click_button 'Reply...'
+
+ find('.js-note-text').set 'testing'
+
+ click_button 'Comment & resolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+
+ it 'allows user to comment & unresolve discussion' do
+ page.within '.diff-content' do
+ click_button 'Resolve discussion'
+
+ click_button 'Reply...'
+
+ find('.js-note-text').set 'testing'
+
+ click_button 'Comment & unresolve discussion'
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+ end
+ end
+
+ context 'as a guest' do
+ before do
+ project.add_guest(guest)
+ sign_in(guest)
+ end
+
+ context 'someone elses merge request' do
+ before do
+ visit_merge_request
+ end
+
+ it 'does not allow user to mark note as resolved' do
+ page.within '.diff-content .note' do
+ expect(page).not_to have_selector('.line-resolve-btn')
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+
+ it 'does not allow user to mark discussion as resolved' do
+ page.within '.diff-content .note' do
+ expect(page).not_to have_selector('.btn', text: 'Resolve discussion')
+ end
+ end
+ end
+
+ context 'guest users merge request' do
+ let(:user) { guest }
+
+ before do
+ visit_merge_request
+ end
+
+ it 'allows user to mark a note as resolved' do
+ page.within '.diff-content .note' do
+ find('.line-resolve-btn').click
+
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+
+ page.within '.diff-content' do
+ expect(page).to have_selector('.btn', text: 'Unresolve discussion')
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('1/1 discussion resolved')
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+ end
+ end
+ end
+
+ context 'unauthorized user' do
+ context 'no resolved comments' do
+ before do
+ visit_merge_request
+ end
+
+ it 'does not allow user to mark note as resolved' do
+ page.within '.diff-content .note' do
+ expect(page).not_to have_selector('.line-resolve-btn')
+ end
+
+ page.within '.line-resolve-all-container' do
+ expect(page).to have_content('0/1 discussion resolved')
+ end
+ end
+ end
+
+ context 'resolved comment' do
+ before do
+ note.resolve!(user)
+ visit_merge_request
+ end
+
+ it 'shows resolved icon' do
+ expect(page).to have_content '1/1 discussion resolved'
+
+ click_button 'Toggle discussion'
+ expect(page).to have_selector('.line-resolve-btn.is-active')
+ end
+
+ it 'does not allow user to click resolve button' do
+ expect(page).to have_selector('.line-resolve-btn.is-disabled')
+ click_button 'Toggle discussion'
+
+ expect(page).to have_selector('.line-resolve-btn.is-disabled')
+ end
+ end
+ end
+
+ def visit_merge_request(mr = nil)
+ mr ||= merge_request
+ visit project_merge_request_path(mr.project, mr)
+ end
+end
diff --git a/spec/features/merge_request/user_resolves_outdated_diff_discussions_spec.rb b/spec/features/merge_request/user_resolves_outdated_diff_discussions_spec.rb
new file mode 100644
index 00000000000..9ba9e8b9585
--- /dev/null
+++ b/spec/features/merge_request/user_resolves_outdated_diff_discussions_spec.rb
@@ -0,0 +1,78 @@
+require 'spec_helper'
+
+feature 'Merge request > User resolves outdated diff discussions', :js do
+ let(:project) { create(:project, :repository, :public) }
+
+ let(:merge_request) do
+ create(:merge_request, source_project: project, source_branch: 'csv', target_branch: 'master')
+ end
+
+ let(:outdated_diff_refs) { project.commit('926c6595b263b2a40da6b17f3e3b7ea08344fad6').diff_refs }
+ let(:current_diff_refs) { merge_request.diff_refs }
+
+ let(:outdated_position) do
+ Gitlab::Diff::Position.new(
+ old_path: 'files/csv/Book1.csv',
+ new_path: 'files/csv/Book1.csv',
+ old_line: nil,
+ new_line: 9,
+ diff_refs: outdated_diff_refs
+ )
+ end
+
+ let(:current_position) do
+ Gitlab::Diff::Position.new(
+ old_path: 'files/csv/Book1.csv',
+ new_path: 'files/csv/Book1.csv',
+ old_line: nil,
+ new_line: 1,
+ diff_refs: current_diff_refs
+ )
+ end
+
+ let!(:outdated_discussion) do
+ create(:diff_note_on_merge_request,
+ project: project,
+ noteable: merge_request,
+ position: outdated_position).to_discussion
+ end
+
+ let!(:current_discussion) do
+ create(:diff_note_on_merge_request,
+ noteable: merge_request,
+ project: project,
+ position: current_position).to_discussion
+ end
+
+ before do
+ sign_in(merge_request.author)
+ end
+
+ context 'when a discussion was resolved by a push' do
+ before do
+ project.update!(resolve_outdated_diff_discussions: true)
+
+ merge_request.update_diff_discussion_positions(
+ old_diff_refs: outdated_diff_refs,
+ new_diff_refs: current_diff_refs,
+ current_user: merge_request.author
+ )
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows that as automatically resolved' do
+ within(".discussion[data-discussion-id='#{outdated_discussion.id}']") do
+ expect(page).to have_css('.discussion-body', visible: false)
+ expect(page).to have_content('Automatically resolved')
+ end
+ end
+
+ it 'does not show that for active discussions' do
+ within(".discussion[data-discussion-id='#{current_discussion.id}']") do
+ expect(page).to have_css('.discussion-body', visible: true)
+ expect(page).not_to have_content('Automatically resolved')
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb b/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb
new file mode 100644
index 00000000000..565e375600b
--- /dev/null
+++ b/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+describe 'Merge request > User scrolls to note on load', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ 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(:resolved_note) { create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: project) }
+ let(:fragment_id) { "#note_#{note.id}" }
+ let(:collapsed_fragment_id) { "#note_#{resolved_note.id}" }
+
+ before do
+ sign_in(user)
+ page.current_window.resize_to(1000, 300)
+ end
+
+ it 'scrolls note into view' do
+ visit "#{project_merge_request_path(project, merge_request)}#{fragment_id}"
+
+ page_height = page.current_window.size[1]
+ page_scroll_y = page.evaluate_script("window.scrollY")
+ fragment_position_top = page.evaluate_script("Math.round($('#{fragment_id}').offset().top)")
+
+ expect(find('.js-toggle-content').visible?).to eq true
+ expect(find(fragment_id).visible?).to eq true
+ expect(fragment_position_top).to be >= page_scroll_y
+ expect(fragment_position_top).to be < (page_scroll_y + page_height)
+ end
+
+ it 'expands collapsed notes' do
+ visit "#{project_merge_request_path(project, merge_request)}#{collapsed_fragment_id}"
+ note_element = find(collapsed_fragment_id)
+ note_container = note_element.ancestor('.js-toggle-container')
+
+ expect(note_element.visible?).to eq true
+ expect(note_container.find('.line_content.noteable_line.old', match: :first).visible?).to eq true
+ end
+end
diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
new file mode 100644
index 00000000000..9c0a04405a6
--- /dev/null
+++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
@@ -0,0 +1,192 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees avatars on diff notes', :js do
+ include NoteInteractionHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ 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
+ Gitlab::Diff::Position.new(
+ old_path: path,
+ new_path: path,
+ old_line: nil,
+ new_line: 9,
+ diff_refs: merge_request.diff_refs
+ )
+ end
+ let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) }
+
+ before do
+ project.add_master(user)
+ sign_in user
+
+ set_cookie('sidebar_collapsed', 'true')
+ end
+
+ context 'discussion tab' do
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not show avatars on discussion tab' do
+ expect(page).not_to have_selector('.js-avatar-container')
+ expect(page).not_to have_selector('.diff-comment-avatar-holders')
+ end
+
+ it 'does not render avatars after commening on discussion tab' do
+ click_button 'Reply...'
+
+ page.within('.js-discussion-note-form') do
+ find('.note-textarea').native.send_keys('Test comment')
+
+ click_button 'Comment'
+ end
+
+ expect(page).to have_content('Test comment')
+ expect(page).not_to have_selector('.js-avatar-container')
+ expect(page).not_to have_selector('.diff-comment-avatar-holders')
+ end
+ end
+
+ context 'commit view' do
+ before do
+ visit project_commit_path(project, merge_request.commits.first.id)
+ end
+
+ it 'does not render avatar after commenting' do
+ first('.diff-line-num').click
+ find('.js-add-diff-note-button').click
+
+ page.within('.js-discussion-note-form') do
+ find('.note-textarea').native.send_keys('test comment')
+
+ click_button 'Comment'
+
+ wait_for_requests
+ end
+
+ visit project_merge_request_path(project, merge_request)
+
+ expect(page).to have_content('test comment')
+ expect(page).not_to have_selector('.js-avatar-container')
+ expect(page).not_to have_selector('.diff-comment-avatar-holders')
+ end
+ end
+
+ %w(inline parallel).each do |view|
+ context "#{view} view" do
+ before do
+ visit diffs_project_merge_request_path(project, merge_request, view: view)
+
+ wait_for_requests
+ end
+
+ it 'shows note avatar' do
+ page.within find_line(position.line_code(project.repository)) do
+ find('.diff-notes-collapse').send_keys(:return)
+
+ expect(page).to have_selector('img.js-diff-comment-avatar', count: 1)
+ end
+ end
+
+ it 'shows comment on note avatar' do
+ page.within find_line(position.line_code(project.repository)) do
+ find('.diff-notes-collapse').send_keys(:return)
+
+ expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
+ end
+ end
+
+ it 'toggles comments when clicking avatar' do
+ page.within find_line(position.line_code(project.repository)) do
+ find('.diff-notes-collapse').send_keys(:return)
+ end
+
+ expect(page).to have_selector('.notes_holder', visible: false)
+
+ page.within find_line(position.line_code(project.repository)) do
+ first('img.js-diff-comment-avatar').click
+ end
+
+ expect(page).to have_selector('.notes_holder')
+ end
+
+ it 'removes avatar when note is deleted' do
+ open_more_actions_dropdown(note)
+
+ page.within find(".note-row-#{note.id}") do
+ accept_confirm { find('.js-note-delete').click }
+ end
+
+ wait_for_requests
+
+ page.within find_line(position.line_code(project.repository)) do
+ expect(page).not_to have_selector('img.js-diff-comment-avatar')
+ end
+ end
+
+ it 'adds avatar when commenting' do
+ click_button 'Reply...'
+
+ page.within '.js-discussion-note-form' do
+ find('.js-note-text').native.send_keys('Test')
+
+ click_button 'Comment'
+
+ wait_for_requests
+ end
+
+ page.within find_line(position.line_code(project.repository)) do
+ find('.diff-notes-collapse').send_keys(:return)
+
+ expect(page).to have_selector('img.js-diff-comment-avatar', count: 2)
+ end
+ end
+
+ it 'adds multiple comments' do
+ 3.times do
+ click_button 'Reply...'
+
+ page.within '.js-discussion-note-form' do
+ find('.js-note-text').native.send_keys('Test')
+ find('.js-comment-button').click
+
+ wait_for_requests
+ end
+ end
+
+ page.within find_line(position.line_code(project.repository)) do
+ find('.diff-notes-collapse').send_keys(:return)
+
+ expect(page).to have_selector('img.js-diff-comment-avatar', count: 3)
+ expect(find('.diff-comments-more-count')).to have_content '+1'
+ end
+ end
+
+ context 'multiple comments' do
+ before do
+ create_list(:diff_note_on_merge_request, 3, project: project, noteable: merge_request, in_reply_to: note)
+ visit diffs_project_merge_request_path(project, merge_request, view: view)
+
+ wait_for_requests
+ end
+
+ it 'shows extra comment count' do
+ page.within find_line(position.line_code(project.repository)) do
+ find('.diff-notes-collapse').send_keys(:return)
+
+ expect(find('.diff-comments-more-count')).to have_content '+1'
+ end
+ end
+ end
+ end
+ end
+
+ def find_line(line_code)
+ line = find("[id='#{line_code}']")
+ line = line.find(:xpath, 'preceding-sibling::*[1][self::td]') if line.tag_name == 'td'
+ line
+ end
+end
diff --git a/spec/features/merge_request/user_sees_closing_issues_message_spec.rb b/spec/features/merge_request/user_sees_closing_issues_message_spec.rb
new file mode 100644
index 00000000000..726f35557a7
--- /dev/null
+++ b/spec/features/merge_request/user_sees_closing_issues_message_spec.rb
@@ -0,0 +1,76 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees closing issues message', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:issue_1) { create(:issue, project: project)}
+ let(:issue_2) { create(:issue, project: project)}
+ let(:merge_request) do
+ create(
+ :merge_request,
+ :simple,
+ source_project: project,
+ description: merge_request_description,
+ title: merge_request_title
+ )
+ end
+ let(:merge_request_description) { 'Merge Request Description' }
+ let(:merge_request_title) { 'Merge Request Title' }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ 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("Closes #{issue_1.to_reference} and #{issue_2.to_reference}")
+ end
+ end
+
+ context 'mentioning issues but not closing them' 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("Mentions #{issue_1.to_reference} and #{issue_2.to_reference}")
+ end
+ end
+
+ context 'closing some issues in title and mentioning, but not closing, others' 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("Closes #{issue_1.to_reference}")
+ expect(page).to have_content("Mentions #{issue_2.to_reference}")
+ end
+ end
+
+ context 'closing issues using title but not mentioning any other issue' 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("Closes #{issue_1.to_reference} and #{issue_2.to_reference}")
+ end
+ end
+
+ context 'mentioning issues using title but not closing them' 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("Mentions #{issue_1.to_reference} and #{issue_2.to_reference}")
+ end
+ end
+
+ context 'closing some issues using title and mentioning, but not closing, others' 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("Closes #{issue_1.to_reference}")
+ expect(page).to have_content("Mentions #{issue_2.to_reference}")
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_deleted_target_branch_spec.rb b/spec/features/merge_request/user_sees_deleted_target_branch_spec.rb
new file mode 100644
index 00000000000..01115318370
--- /dev/null
+++ b/spec/features/merge_request/user_sees_deleted_target_branch_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees deleted target branch', :js do
+ let(:merge_request) { create(:merge_request) }
+ let(:project) { merge_request.project }
+ let(:user) { project.creator }
+
+ before do
+ project.add_master(user)
+ DeleteBranchService.new(project, user).execute('feature')
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows a message about missing target branch' do
+ expect(page).to have_content('Target branch does not exist')
+ end
+
+ it 'does not show link to target branch' do
+ expect(page).not_to have_selector('.mr-widget-body .js-branch-text a')
+ end
+end
diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb
new file mode 100644
index 00000000000..f744d7941f5
--- /dev/null
+++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb
@@ -0,0 +1,56 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees deployment widget', :js do
+ describe 'when deployed to an environment' do
+ let(:user) { create(:user) }
+ let(:project) { merge_request.target_project }
+ let(:merge_request) { create(:merge_request, :merged) }
+ let(:environment) { create(:environment, project: project) }
+ let(:role) { :developer }
+ let(:sha) { project.commit('master').id }
+ let!(:deployment) { create(:deployment, environment: environment, sha: sha) }
+ let!(:manual) { }
+
+ before do
+ project.add_user(user, role)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ wait_for_requests
+ end
+
+ it 'displays that the environment is deployed' do
+ wait_for_requests
+
+ expect(page).to have_content("Deployed to #{environment.name}")
+ expect(find('.js-deploy-time')['data-original-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium))
+ end
+
+ context 'with stop action' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') }
+ let(:deployment) do
+ create(:deployment, environment: environment, ref: merge_request.target_branch,
+ sha: sha, deployable: build, on_stop: 'close_app')
+ end
+
+ before do
+ wait_for_requests
+ end
+
+ it 'does start build when stop button clicked' do
+ accept_confirm { click_button('Stop environment') }
+
+ expect(page).to have_content('close_app')
+ end
+
+ context 'for reporter' do
+ let(:role) { :reporter }
+
+ it 'does not show stop button' do
+ expect(page).not_to have_button('Stop environment')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_diff_spec.rb b/spec/features/merge_request/user_sees_diff_spec.rb
new file mode 100644
index 00000000000..a9063f2bcb3
--- /dev/null
+++ b/spec/features/merge_request/user_sees_diff_spec.rb
@@ -0,0 +1,98 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees diff', :js do
+ include ProjectForksHelper
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ context 'when visit with */* as accept header' do
+ it 'renders the notes' do
+ create :note_on_merge_request, project: project, noteable: merge_request, note: 'Rebasing with master'
+
+ inspect_requests(inject_headers: { 'Accept' => '*/*' }) do
+ visit diffs_project_merge_request_path(project, merge_request)
+ end
+
+ # Load notes and diff through AJAX
+ expect(page).to have_css('.note-text', visible: false, text: 'Rebasing with master')
+ expect(page).to have_css('.diffs.tab-pane.active')
+ 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_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
+ performance only 3 of 3+ files are displayed.")
+ end
+ end
+ end
+
+ context 'when editing file' do
+ let(:author_user) { create(:user) }
+ let(:user) { create(:user) }
+ let(:forked_project) { fork_project(project, author_user, repository: true) }
+ 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
+ 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")
+ end
+ end
+
+ context 'as user who needs to fork' do
+ it 'shows fork/cancel confirmation' do
+ 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
+
+ expect(page).to have_selector('.js-fork-suggestion-button', count: 1)
+ expect(page).to have_selector('.js-cancel-fork-suggestion-button', count: 1)
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_discussions_spec.rb b/spec/features/merge_request/user_sees_discussions_spec.rb
new file mode 100644
index 00000000000..d6e8c8e86ba
--- /dev/null
+++ b/spec/features/merge_request/user_sees_discussions_spec.rb
@@ -0,0 +1,87 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees discussions' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ describe "Diff discussions" do
+ let!(:old_merge_request_diff) { merge_request.merge_request_diffs.create(diff_refs: outdated_diff_refs) }
+ let!(:new_merge_request_diff) { merge_request.merge_request_diffs.create }
+ let!(:outdated_discussion) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position).to_discussion }
+ let!(:active_discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
+ let(:outdated_position) do
+ Gitlab::Diff::Position.new(
+ old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 9,
+ diff_refs: outdated_diff_refs
+ )
+ end
+ let(:outdated_diff_refs) { project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs }
+
+ 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_project_merge_request_path(project, merge_request, anchor: active_discussion.line_code)
+ expect(page).to have_link('the diff', href: path)
+ end
+ end
+ end
+
+ context 'outdated discussions' do
+ it 'shows a link to the outdated diff' do
+ within(".discussion[data-discussion-id='#{outdated_discussion.id}']") do
+ 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
+ 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_request/user_sees_empty_state_spec.rb b/spec/features/merge_request/user_sees_empty_state_spec.rb
new file mode 100644
index 00000000000..a939c7e9001
--- /dev/null
+++ b/spec/features/merge_request/user_sees_empty_state_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees empty state' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ it 'shows an empty state and a "New merge request" button' do
+ visit project_merge_requests_path(project)
+
+ expect(page).to have_selector('.empty-state')
+ 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, source_project: project)
+
+ visit project_merge_requests_path(project)
+ end
+
+ it 'does not show an empty state' do
+ expect(page).not_to have_selector('.empty-state')
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb b/spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb
new file mode 100644
index 00000000000..85df43df38e
--- /dev/null
+++ b/spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb
@@ -0,0 +1,61 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees merge button depending on unresolved discussions', :js do
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.creator }
+ let!(:merge_request) { create(:merge_request_with_diff_notes, source_project: project, author: user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ context 'when project.only_allow_merge_if_all_discussions_are_resolved == true' do
+ before do
+ project.update_column(:only_allow_merge_if_all_discussions_are_resolved, true)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ context 'with unresolved discussions' do
+ it 'does not allow to merge' do
+ expect(page).not_to have_button 'Merge'
+ expect(page).to have_content('There are unresolved discussions.')
+ end
+ end
+
+ context 'with all discussions resolved' do
+ before do
+ merge_request.discussions.each { |d| d.resolve!(user) }
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'allows MR to be merged' do
+ expect(page).to have_button 'Merge'
+ end
+ end
+ end
+
+ context 'when project.only_allow_merge_if_all_discussions_are_resolved == false' do
+ before do
+ project.update_column(:only_allow_merge_if_all_discussions_are_resolved, false)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ context 'with unresolved discussions' do
+ it 'does not allow to merge' do
+ expect(page).to have_button 'Merge'
+ end
+ end
+
+ context 'with all discussions resolved' do
+ before do
+ merge_request.discussions.each { |d| d.resolve!(user) }
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'allows MR to be merged' do
+ expect(page).to have_button 'Merge'
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb
new file mode 100644
index 00000000000..51a65407aec
--- /dev/null
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -0,0 +1,328 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees merge widget', :js do
+ include ProjectForksHelper
+
+ let(:project) { create(:project, :repository) }
+ let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) }
+ let(:user) { project.creator }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:merge_request_in_only_mwps_project) { create(:merge_request, source_project: project_only_mwps) }
+
+ before do
+ project.add_master(user)
+ project_only_mwps.add_master(user)
+ sign_in(user)
+ end
+
+ context 'new merge request' do
+ before do
+ 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_requests
+
+ expect(page).to have_selector('.accept-merge-request')
+ expect(find('.accept-merge-request')['disabled']).not_to be(true)
+ end
+ end
+
+ context 'view merge request' do
+ let!(:environment) { create(:environment, project: project) }
+
+ let!(:deployment) do
+ create(:deployment, environment: environment,
+ ref: 'feature',
+ sha: merge_request.diff_head_sha)
+ end
+
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows environments link' do
+ wait_for_requests
+
+ page.within('.mr-widget-heading') do
+ expect(page).to have_content("Deployed to #{environment.name}")
+ 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_requests
+ expect(page).to have_selector('.accept-merge-request')
+ expect(find('.accept-merge-request')['disabled']).not_to be(true)
+ end
+
+ it 'allows me to merge, see cherry-pick modal and load branches list' do
+ wait_for_requests
+ click_button 'Merge'
+
+ wait_for_requests
+ click_link 'Cherry-pick'
+ page.find('.js-project-refs-dropdown').click
+ wait_for_requests
+
+ expect(page.all('.js-cherry-pick-form .dropdown-content li').size).to be > 1
+ end
+ end
+
+ context 'view merge request with external CI service' do
+ before do
+ create(:service, project: project,
+ active: true,
+ type: 'CiService',
+ category: 'ci')
+
+ 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_requests
+ expect(page).to have_selector('.accept-merge-request.btn-danger')
+ end
+ end
+
+ context 'view merge request with failed GitLab CI pipelines' do
+ before do
+ commit_status = create(:commit_status, project: project, status: 'failed')
+ pipeline = create(:ci_pipeline, project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ status: 'failed',
+ statuses: [commit_status],
+ head_pipeline_of: merge_request)
+ create(:ci_build, :pending, pipeline: pipeline)
+
+ 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_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,
+ head_pipeline_of: merge_request)
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows information about blocked pipeline' do
+ expect(page).to have_content("Pipeline blocked")
+ expect(page).to have_content(
+ "The pipeline for this merge request requires a manual action")
+ expect(page).to have_css('.ci-status-icon-manual')
+ end
+ end
+
+ context 'view merge request with MWBS button' do
+ before do
+ commit_status = create(:commit_status, project: project, status: 'pending')
+ pipeline = create(:ci_pipeline, project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch,
+ status: 'pending',
+ statuses: [commit_status],
+ head_pipeline_of: merge_request)
+ create(:ci_build, :pending, pipeline: pipeline)
+
+ 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_requests
+ expect(page).to have_selector('.accept-merge-request.btn-info')
+ end
+ end
+
+ context 'view merge request where project has CI setup but no CI status' do
+ before do
+ pipeline = create(:ci_pipeline, project: project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch)
+ create(:ci_build, pipeline: pipeline)
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'has pipeline error text' do
+ # Wait for the `ci_status` and `merge_check` requests
+ wait_for_requests
+
+ expect(page).to have_text('Could not connect to the CI server. Please check your settings and try again')
+ end
+ end
+
+ context 'view merge request in project with only-mwps setting enabled but no CI is setup' do
+ before do
+ visit project_merge_request_path(project_only_mwps, merge_request_in_only_mwps_project)
+ end
+
+ it 'should be allowed to merge' do
+ # Wait for the `ci_status` and `merge_check` requests
+ wait_for_requests
+
+ expect(page).to have_selector('.accept-merge-request')
+ expect(find('.accept-merge-request')['disabled']).not_to be(true)
+ 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')
+ 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')
+ end
+ end
+ end
+
+ context 'view merge request where fast-forward merge is not possible' do
+ before do
+ project.update(merge_requests_ff_only_enabled: true)
+
+ merge_request.update(
+ merge_user: merge_request.author,
+ merge_status: :cannot_be_merged
+ )
+
+ 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('Fast-forward merge is not possible')
+ end
+ end
+ end
+
+ context 'merge error' do
+ before do
+ allow_any_instance_of(Repository).to receive(:merge).and_return(false)
+ 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 do
+ let(:fork_project) { create(:project, :public, :repository) }
+ let(:user2) { create(:user) }
+
+ before do
+ project.add_master(user2)
+ 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).not_to have_field('remove-source-branch-input')
+ end
+ end
+
+ context 'user cannot merge project and cannot push to fork', :js do
+ let(:forked_project) { fork_project(project, nil, repository: true) }
+ let(:user2) { create(:user) }
+
+ before do
+ project.add_developer(user2)
+ sign_out(:user)
+ sign_in(user2)
+ merge_request.update(
+ source_project: forked_project,
+ target_project: project,
+ merge_params: { 'force_remove_source_branch' => '1' }
+ )
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'user cannot remove source branch' do
+ expect(page).not_to have_field('remove-source-branch-input')
+ expect(page).to have_content('Removes source branch')
+ 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_request/user_sees_mini_pipeline_graph_spec.rb b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
new file mode 100644
index 00000000000..fd1629746ef
--- /dev/null
+++ b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
@@ -0,0 +1,125 @@
+require 'rails_helper'
+
+describe 'Merge request < User sees mini pipeline graph', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ 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') }
+
+ before do
+ build.run
+ build.trace.set('hello')
+ sign_in(user)
+ visit_merge_request
+ end
+
+ def visit_merge_request(format: :html, serializer: nil)
+ visit project_merge_request_path(project, merge_request, format: format, serializer: serializer)
+ end
+
+ it 'displays 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.join('spec/fixtures/banana_sample.gif'), 'image/gif') }
+ let(:artifacts_file2) { fixture_file_upload(Rails.root.join('spec/fixtures/dk.png'), 'image/png') }
+
+ before do
+ create(:ci_build, :success, :trace_artifact, pipeline: pipeline, legacy_artifacts_file: artifacts_file1)
+ create(:ci_build, :manual, pipeline: pipeline, when: 'manual')
+ end
+
+ it 'avoids repeated database queries' do
+ before = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') }
+
+ create(:ci_build, :success, :trace_artifact, pipeline: pipeline, legacy_artifacts_file: artifacts_file2)
+ create(:ci_build, :manual, pipeline: pipeline, when: 'manual')
+
+ after = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') }
+
+ 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')
+ first('.mini-pipeline-graph-dropdown-toggle')
+ end
+
+ it 'expands when hovered' do
+ find('.mini-pipeline-graph-dropdown-toggle')
+ before_width = evaluate_script("$('.mini-pipeline-graph-dropdown-toggle:visible').outerWidth();")
+
+ toggle.hover
+
+ find('.mini-pipeline-graph-dropdown-toggle')
+ after_width = evaluate_script("$('.mini-pipeline-graph-dropdown-toggle:visible').outerWidth();")
+
+ expect(before_width).to be < after_width
+ end
+
+ it 'shows dropdown caret when hovered' do
+ toggle.hover
+
+ expect(toggle).to have_selector('.fa-caret-down')
+ end
+
+ it 'shows tooltip when hovered' do
+ toggle.hover
+
+ expect(page).to have_selector('.tooltip')
+ end
+ end
+
+ describe 'builds list menu' do
+ let(:toggle) do
+ find('.mini-pipeline-graph-dropdown-toggle')
+ first('.mini-pipeline-graph-dropdown-toggle')
+ end
+
+ before do
+ toggle.click
+ wait_for_requests
+ end
+
+ it 'pens when toggle is clicked' do
+ expect(toggle.find(:xpath, '..')).to have_selector('.mini-pipeline-graph-dropdown-menu')
+ end
+
+ it 'closes when toggle is clicked again' do
+ toggle.click
+
+ expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
+ end
+
+ it 'closes when clicking somewhere else' do
+ find('body').click
+
+ expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
+ end
+
+ describe 'build list build item' do
+ let(:build_item) do
+ find('.mini-pipeline-graph-dropdown-item')
+ first('.mini-pipeline-graph-dropdown-item')
+ end
+
+ it 'visits the build page when clicked' do
+ build_item.click
+ find('.build-page')
+
+ expect(current_path).to eql(project_job_path(project, build))
+ end
+
+ it 'shows tooltip when hovered' do
+ build_item.hover
+
+ expect(page).to have_selector('.tooltip')
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_mr_from_deleted_forked_project_spec.rb b/spec/features/merge_request/user_sees_mr_from_deleted_forked_project_spec.rb
new file mode 100644
index 00000000000..029b66b5e8e
--- /dev/null
+++ b/spec/features/merge_request/user_sees_mr_from_deleted_forked_project_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees MR from deleted forked project', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:fork_project) { create(:project, :public, :repository, forked_from_project: project) }
+ let!(:merge_request) do
+ create(:merge_request_with_diffs, source_project: fork_project,
+ target_project: project,
+ description: 'Test merge request')
+ end
+
+ before do
+ MergeRequests::MergeService.new(project, user).execute(merge_request)
+ fork_project.destroy!
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'user can access merge request' do
+ expect(page).to have_content 'Test merge request'
+ expect(page).to have_content "(removed):#{merge_request.source_branch}"
+ end
+end
diff --git a/spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb b/spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb
new file mode 100644
index 00000000000..c1608be402a
--- /dev/null
+++ b/spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_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".
+describe 'Merge request > User sees MR with deleted source branch', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:user) { project.creator }
+
+ before do
+ merge_request.update!(source_branch: 'this-branch-does-not-exist')
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows a message about missing source branch' do
+ expect(page).to have_content('Source branch does not exist.')
+ end
+
+ it 'still contains Discussion, Commits and Changes tabs' do
+ within '.merge-request-details' do
+ expect(page).to have_content('Discussion')
+ expect(page).to have_content('Commits')
+ expect(page).to have_content('Changes')
+ end
+
+ click_on 'Changes'
+ wait_for_requests
+
+ expect(page).to have_selector('.diffs.tab-pane .nothing-here-block')
+ expect(page).to have_content('Source branch does not exist.')
+ end
+end
diff --git a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
new file mode 100644
index 00000000000..b4cda269852
--- /dev/null
+++ b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
@@ -0,0 +1,35 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees notes from forked project', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:fork_project) { create(:project, :public, :repository, forked_from_project: project) }
+ let!(:merge_request) do
+ create(:merge_request_with_diffs, source_project: fork_project,
+ target_project: project,
+ description: 'Test merge request')
+ end
+
+ before do
+ create(:note_on_commit, note: 'A commit comment',
+ project: fork_project,
+ commit_id: merge_request.commit_shas.first)
+ sign_in(user)
+ end
+
+ it 'user can reply to the comment' do
+ visit project_merge_request_path(project, merge_request)
+
+ expect(page).to have_content('A commit comment')
+
+ page.within('.discussion-notes') do
+ find('.btn-text-field').click
+ find('#note_note').send_keys('A reply comment')
+ find('.comment-btn').click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content('A reply comment')
+ end
+end
diff --git a/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb
new file mode 100644
index 00000000000..d30dcefc6aa
--- /dev/null
+++ b/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees pipelines from forked project', :js do
+ let(:target_project) { create(:project, :public, :repository) }
+ let(:user) { target_project.creator }
+ let(:fork_project) { create(:project, :repository, forked_from_project: target_project) }
+ let!(:merge_request) do
+ create(:merge_request_with_diffs, source_project: fork_project,
+ target_project: target_project,
+ description: 'Test merge request')
+ end
+ let(:pipeline) do
+ create(:ci_pipeline,
+ project: fork_project,
+ sha: merge_request.diff_head_sha,
+ ref: merge_request.source_branch)
+ end
+
+ before do
+ create(:ci_build, pipeline: pipeline, name: 'rspec')
+ create(:ci_build, pipeline: pipeline, name: 'spinach')
+
+ sign_in(user)
+ visit project_merge_request_path(target_project, merge_request)
+ end
+
+ it 'user visits a pipelines page' do
+ page.within('.merge-request-tabs') { click_link 'Pipelines' }
+
+ page.within('.ci-table') do
+ expect(page).to have_content(pipeline.id)
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb
new file mode 100644
index 00000000000..a42c016392b
--- /dev/null
+++ b/spec/features/merge_request/user_sees_pipelines_spec.rb
@@ -0,0 +1,102 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees pipelines', :js do
+ describe 'pipeline tab' do
+ let(:merge_request) { create(:merge_request) }
+ let(:project) { merge_request.target_project }
+ let(:user) { project.creator }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ 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
+ merge_request.update_attribute(:head_pipeline_id, pipeline.id)
+ end
+
+ it 'user visits merge request pipelines tab' do
+ visit project_merge_request_path(project, merge_request)
+
+ expect(page.find('.ci-widget')).to have_content('pending')
+
+ page.within('.merge-request-tabs') do
+ click_link('Pipelines')
+ end
+ wait_for_requests
+
+ expect(page).to have_selector('.stage-cell')
+ end
+
+ it 'pipeline sha does not equal last commit sha' do
+ pipeline.update_attribute(:sha, '19e2e9b4ef76b422ce1154af39a91323ccc57434')
+ visit project_merge_request_path(project, merge_request)
+ wait_for_requests
+
+ expect(page.find('.ci-widget')).to have_content(
+ 'Could not connect to the CI server. Please check your settings and try again')
+ end
+ end
+
+ context 'without pipelines' do
+ before do
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'user visits merge request page' do
+ page.within('.merge-request-tabs') do
+ expect(page).to have_no_link('Pipelines')
+ end
+ end
+ end
+ end
+
+ describe 'race condition' do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+ let(:build_push_data) { { ref: 'feature', checkout_sha: TestEnv::BRANCH_SHA['feature'] } }
+
+ let(:merge_request_params) do
+ { "source_branch" => "feature", "source_project_id" => project.id,
+ "target_branch" => "master", "target_project_id" => project.id, "title" => "A" }
+ end
+
+ before do
+ project.add_master(user)
+ sign_in user
+ end
+
+ context 'when pipeline and merge request were created simultaneously' do
+ before 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
+
+ it '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
+end
diff --git a/spec/features/merge_request/user_sees_system_notes_spec.rb b/spec/features/merge_request/user_sees_system_notes_spec.rb
new file mode 100644
index 00000000000..a00a682757d
--- /dev/null
+++ b/spec/features/merge_request/user_sees_system_notes_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees system notes' do
+ let(:public_project) { create(:project, :public, :repository) }
+ let(:private_project) { create(:project, :private, :repository) }
+ let(:user) { private_project.creator }
+ 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)}") }
+
+ context 'when logged-in as a member of the private project' do
+ before do
+ private_project.add_developer(user)
+ sign_in(user)
+ end
+
+ it 'shows the system note' do
+ visit project_merge_request_path(public_project, merge_request)
+
+ expect(page).to have_css('.system-note')
+ end
+ end
+
+ context 'when not logged-in' do
+ it 'hides the system note' do
+ visit project_merge_request_path(public_project, merge_request)
+
+ expect(page).not_to have_css('.system-note')
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_sees_versions_spec.rb b/spec/features/merge_request/user_sees_versions_spec.rb
new file mode 100644
index 00000000000..3a15d70979a
--- /dev/null
+++ b/spec/features/merge_request/user_sees_versions_spec.rb
@@ -0,0 +1,217 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees versions', :js do
+ let(:merge_request) { create(:merge_request, importing: true) }
+ let(:project) { merge_request.source_project }
+ let(:user) { project.creator }
+ 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') }
+ let!(:params) { {} }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ visit diffs_project_merge_request_path(project, merge_request, params)
+ end
+
+ shared_examples 'allows commenting' do |file_id:, line_code:, comment:|
+ it do
+ diff_file_selector = ".diff-file[id='#{file_id}']"
+ line_code = "#{file_id}_#{line_code}"
+
+ page.within(diff_file_selector) do
+ find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").hover
+ find(".line_holder[id='#{line_code}'] button").click
+
+ page.within("form[data-line-code='#{line_code}']") do
+ fill_in "note[note]", with: comment
+ find(".js-comment-button").click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content(comment)
+ end
+ end
+ end
+
+ describe 'compare with the latest version' do
+ it 'show the latest version of the diff' do
+ page.within '.mr-version-dropdown' do
+ expect(page).to have_content 'latest version'
+ end
+
+ expect(page).to have_content '8 changed files'
+ end
+
+ it_behaves_like 'allows commenting',
+ file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44',
+ line_code: '1_1',
+ comment: 'Typo, please fix.'
+ end
+
+ describe 'switch between versions' do
+ before do
+ page.within '.mr-version-dropdown' do
+ find('.btn-default').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
+
+ it 'shows comments that were last relevant at that version' do
+ expect(page).to have_content '5 changed files'
+ expect(page).to have_content 'Not all comments are displayed'
+
+ position = Gitlab::Diff::Position.new(
+ old_path: ".gitmodules",
+ new_path: ".gitmodules",
+ old_line: nil,
+ new_line: 4,
+ diff_refs: merge_request_diff1.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!
+
+ refresh
+
+ expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']")
+ end
+
+ it_behaves_like 'allows commenting',
+ file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44',
+ line_code: '2_2',
+ comment: 'Typo, please fix.'
+ end
+
+ describe 'compare with older version' do
+ before do
+ page.within '.mr-version-compare-dropdown' do
+ find('.btn-default').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 and shows comments that were last relevant at that version' do
+ expect(page).to have_current_path diffs_project_merge_request_path(
+ project,
+ merge_request.iid,
+ diff_id: merge_request_diff3.id,
+ start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9'
+ )
+ expect(page).to have_content '4 changed files with 15 additions and 6 deletions'
+ expect(page).to have_content 'Not all comments are displayed'
+
+ 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!
+
+ refresh
+ wait_for_requests
+
+ expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']")
+ end
+
+ it 'show diff between new and old version' do
+ expect(page).to have_content '4 changed files with 15 additions and 6 deletions'
+ end
+
+ it 'returns to latest version when "Show latest version" button is clicked' do
+ click_link 'Show latest version'
+ page.within '.mr-version-dropdown' do
+ expect(page).to have_content 'latest version'
+ end
+ expect(page).to have_content '8 changed files'
+ end
+
+ it_behaves_like 'allows commenting',
+ file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44',
+ line_code: '4_4',
+ comment: 'Typo, please fix.'
+ end
+
+ describe 'compare with same version' do
+ before do
+ page.within '.mr-version-compare-dropdown' do
+ find('.btn-default').click
+ click_link 'version 1'
+ end
+ end
+
+ it 'has 0 chages between versions' do
+ page.within '.mr-version-compare-dropdown' do
+ expect(find('.dropdown-toggle')).to have_content 'version 1'
+ end
+
+ page.within '.mr-version-dropdown' do
+ find('.btn-default').click
+ click_link 'version 1'
+ end
+ expect(page).to have_content '0 changed files'
+ end
+ end
+
+ describe 'compare with newer version' do
+ before do
+ page.within '.mr-version-compare-dropdown' do
+ find('.btn-default').click
+ click_link 'version 2'
+ end
+ end
+
+ it 'sets the compared versions to be the same' do
+ page.within '.mr-version-compare-dropdown' do
+ expect(find('.dropdown-toggle')).to have_content 'version 2'
+ end
+
+ page.within '.mr-version-dropdown' do
+ find('.btn-default').click
+ click_link 'version 1'
+ end
+
+ page.within '.mr-version-compare-dropdown' do
+ expect(page).to have_content 'version 1'
+ end
+
+ expect(page).to have_content '0 changed files'
+ end
+ end
+
+ describe 'scoped in a commit' do
+ let(:params) { { commit_id: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d' } }
+
+ before do
+ wait_for_requests
+ end
+
+ it 'should only show diffs from the commit' do
+ diff_commit_ids = find_all('.diff-file [data-commit-id]').map {|diff| diff['data-commit-id']}
+
+ expect(diff_commit_ids).not_to be_empty
+ expect(diff_commit_ids).to all(eq(params[:commit_id]))
+ end
+
+ it_behaves_like 'allows commenting',
+ file_id: '2f6fcd96b88b36ce98c38da085c795a27d92a3dd',
+ line_code: '6_6',
+ comment: 'Typo, please fix.'
+ end
+end
diff --git a/spec/features/merge_request/user_sees_wip_help_message_spec.rb b/spec/features/merge_request/user_sees_wip_help_message_spec.rb
new file mode 100644
index 00000000000..bc25243244e
--- /dev/null
+++ b/spec/features/merge_request/user_sees_wip_help_message_spec.rb
@@ -0,0 +1,59 @@
+require 'rails_helper'
+
+describe 'Merge request > User sees WIP help message' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ context 'with WIP commits' do
+ it 'shows a specific WIP hint' do
+ 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(
+ 'It looks like you have some WIP commits in this branch'
+ )
+ end
+ end
+ end
+
+ context 'without WIP commits' do
+ it 'shows the regular WIP message' do
+ 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(
+ 'It looks like you have some WIP commits in this branch'
+ )
+ expect(page).to have_text(
+ "Start the title with WIP: to prevent a Work In Progress merge \
+request from being merged before it's ready"
+ )
+ end
+ end
+ end
+
+ def within_wip_explanation(&block)
+ page.within '.js-no-wip-explanation' do
+ yield
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
new file mode 100644
index 00000000000..dbca279569a
--- /dev/null
+++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
@@ -0,0 +1,180 @@
+require 'rails_helper'
+
+describe 'Merge request > User selects branches for new MR', :js do
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ it 'selects the source branch sha when a tag with the same name exists' do
+ visit project_merge_requests_path(project)
+
+ page.within '.content' do
+ click_link 'New merge request'
+ end
+ expect(page).to have_content('Source branch')
+ expect(page).to have_content('Target branch')
+
+ first('.js-source-branch').click
+ find('.dropdown-source-branch .dropdown-content a', match: :first).click
+
+ expect(page).to have_content "b83d6e3"
+ end
+
+ it 'selects the target branch sha when a tag with the same name exists' do
+ visit project_merge_requests_path(project)
+
+ page.within '.content' do
+ click_link 'New merge request'
+ end
+
+ expect(page).to have_content('Source branch')
+ expect(page).to have_content('Target branch')
+
+ first('.js-target-branch').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 project_merge_requests_path(project)
+
+ page.within '.content' do
+ click_link 'New merge request'
+ end
+ expect(page).to have_content('Source branch')
+ expect(page).to have_content('Target branch')
+
+ find('.js-source-branch', match: :first).click
+ find('.dropdown-source-branch .dropdown-content a', text: 'orphaned-branch', match: :first).click
+
+ click_button "Compare branches"
+ click_link "Changes"
+
+ expect(page).to have_content "README.md"
+ expect(page).to have_content "wm.png"
+
+ fill_in "merge_request_title", with: "Orphaned MR test"
+ click_button "Submit merge request"
+
+ click_button "Check out branch"
+
+ expect(page).to have_content 'git checkout -b orphaned-branch origin/orphaned-branch'
+ end
+
+ it 'allows filtering multiple dropdowns' do
+ visit project_new_merge_request_path(project)
+
+ first('.js-source-branch').click
+
+ input = find('.dropdown-source-branch .dropdown-input-field')
+ input.click
+ input.send_keys('orphaned-branch')
+
+ find('.dropdown-source-branch .dropdown-content li', match: :first)
+ source_items = all('.dropdown-source-branch .dropdown-content li')
+
+ expect(source_items.count).to eq(1)
+
+ first('.js-target-branch').click
+
+ find('.dropdown-target-branch .dropdown-content li', match: :first)
+ target_items = all('.dropdown-target-branch .dropdown-content li')
+
+ expect(target_items.count).to be > 1
+ end
+
+ 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, :repository)
+
+ visit project_new_merge_request_path(project, merge_request: { target_project_id: private_project.id })
+
+ 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, :repository)
+
+ visit project_new_merge_request_path(project, merge_request: { source_project_id: private_project.id })
+
+ 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 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 project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'fix' })
+
+ click_link 'Changes'
+
+ expect(page).to have_css('a.btn.active', text: 'Inline')
+ expect(page).not_to have_css('a.btn.active', text: 'Side-by-side')
+
+ click_link 'Side-by-side'
+
+ within '.merge-request' do
+ expect(page).not_to have_css('a.btn.active', text: 'Inline')
+ expect(page).to have_css('a.btn.active', text: 'Side-by-side')
+ end
+ end
+
+ it 'does not allow non-existing branches' do
+ 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')
+ expect(page).to have_content('Target branch "non-exist-target" does not exist')
+ end
+
+ context 'when a branch contains commits that both delete and add the same image' do
+ it 'renders the diff successfully' do
+ visit project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'deleted-image-test' })
+
+ click_link "Changes"
+
+ expect(page).to have_content "6049019_460s.jpg"
+ end
+ end
+
+ # Isolates a regression (see #24627)
+ it 'does not show error messages on initial form' do
+ visit 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
+
+ context 'when a new merge request has a pipeline' do
+ let!(:pipeline) do
+ create(:ci_pipeline, sha: project.commit('fix').id,
+ ref: 'fix',
+ project: project)
+ end
+
+ it 'shows pipelines for a new merge request' do
+ 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_requests
+
+ expect(page).to have_content "##{pipeline.id}"
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb
new file mode 100644
index 00000000000..2e95a628013
--- /dev/null
+++ b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb
@@ -0,0 +1,25 @@
+require 'rails_helper'
+
+describe 'Merge request > User toggles whitespace changes', :js do
+ let(:merge_request) { create(:merge_request) }
+ let(:project) { merge_request.project }
+ let(:user) { project.creator }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ visit diffs_project_merge_request_path(project, merge_request)
+ end
+
+ it 'has a button to toggle whitespace changes' do
+ expect(page).to have_content 'Hide whitespace changes'
+ end
+
+ describe 'clicking "Hide whitespace changes" button' do
+ it 'toggles the "Hide whitespace changes" button' do
+ click_link 'Hide whitespace changes'
+
+ expect(page).to have_content 'Show whitespace changes'
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_uses_slash_commands_spec.rb b/spec/features/merge_request/user_uses_slash_commands_spec.rb
new file mode 100644
index 00000000000..7f261b580f7
--- /dev/null
+++ b/spec/features/merge_request/user_uses_slash_commands_spec.rb
@@ -0,0 +1,202 @@
+require 'rails_helper'
+
+describe 'Merge request > User uses quick actions', :js do
+ include Spec::Support::Helpers::Features::NotesHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:guest) { create(:user) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
+
+ 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.add_master(user)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ describe 'time tracking' do
+ it_behaves_like 'issuable time tracker'
+ end
+
+ describe 'toggling the WIP prefix in the title from note' do
+ context 'when the current user can toggle the WIP prefix' do
+ it 'adds the WIP: prefix to the title' do
+ add_note("/wip")
+
+ expect(page).not_to have_content '/wip'
+ expect(page).to have_content 'Commands applied'
+
+ expect(merge_request.reload.work_in_progress?).to eq true
+ end
+
+ it 'removes the WIP: prefix from the title' do
+ merge_request.title = merge_request.wip_title
+ merge_request.save
+ add_note("/wip")
+
+ expect(page).not_to have_content '/wip'
+ expect(page).to have_content 'Commands applied'
+
+ expect(merge_request.reload.work_in_progress?).to eq false
+ end
+ end
+
+ context 'when the current user cannot toggle the WIP prefix' do
+ before do
+ project.add_guest(guest)
+ sign_out(:user)
+ sign_in(guest)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not change the WIP prefix' do
+ add_note("/wip")
+
+ expect(page).not_to have_content '/wip'
+ expect(page).not_to have_content 'Commands applied'
+
+ expect(merge_request.reload.work_in_progress?).to eq false
+ end
+ end
+ end
+
+ describe 'merging the MR from the note' do
+ context 'when the current user can merge the MR' do
+ it 'merges the MR' do
+ add_note("/merge")
+
+ expect(page).to have_content 'Commands applied'
+
+ expect(merge_request.reload).to be_merged
+ end
+ end
+
+ context 'when the head diff changes in the meanwhile' do
+ before do
+ merge_request.source_branch = 'another_branch'
+ merge_request.save
+ end
+
+ it 'does not merge the MR' do
+ add_note("/merge")
+
+ expect(page).not_to have_content 'Your commands have been executed!'
+
+ expect(merge_request.reload).not_to be_merged
+ end
+ end
+
+ context 'when the current user cannot merge the MR' do
+ before do
+ project.add_guest(guest)
+ sign_out(:user)
+ sign_in(guest)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not merge the MR' do
+ add_note("/merge")
+
+ expect(page).not_to have_content 'Your commands have been executed!'
+
+ expect(merge_request.reload).not_to be_merged
+ end
+ end
+ end
+
+ describe 'adding a due date from note' do
+ it 'does not recognize the command nor create a note' do
+ add_note('/due 2016-08-28')
+
+ expect(page).not_to have_content '/due 2016-08-28'
+ end
+ end
+
+ describe '/target_branch command in merge request' do
+ let(:another_project) { create(:project, :public, :repository) }
+ let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } }
+
+ before do
+ sign_out(:user)
+ another_project.add_master(user)
+ sign_in(user)
+ end
+
+ it 'changes target_branch in new merge_request' do
+ 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:"
+ click_button "Submit merge request"
+
+ merge_request = another_project.merge_requests.first
+ expect(merge_request.description).to eq "le feature \nFeature description:"
+ expect(merge_request.target_branch).to eq 'fix'
+ end
+
+ it 'does not change target branch when merge request is edited' do
+ new_merge_request = create(:merge_request, source_project: another_project)
+
+ 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"
+
+ new_merge_request = another_project.merge_requests.first
+ expect(new_merge_request.description).to include('/target_branch')
+ expect(new_merge_request.target_branch).not_to eq('fix')
+ end
+ end
+
+ describe '/target_branch command from note' do
+ context 'when the current user can change target branch' do
+ it 'changes target branch from a note' do
+ add_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.')
+
+ expect(merge_request.reload.target_branch).to eq 'merge-test'
+ end
+
+ it 'does not fail when target branch does not exists' do
+ add_note('/target_branch totally_not_existing_branch')
+
+ expect(page).not_to have_content('/target_branch')
+
+ expect(merge_request.target_branch).to eq 'feature'
+ end
+ end
+
+ context 'when current user can not change target branch' do
+ before do
+ project.add_guest(guest)
+ sign_out(:user)
+ sign_in(guest)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not change target branch' do
+ add_note('/target_branch merge-test')
+
+ expect(page).not_to have_content '/target_branch merge-test'
+
+ expect(merge_request.target_branch).to eq 'feature'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb
deleted file mode 100644
index d49d145f254..00000000000
--- a/spec/features/merge_requests/assign_issues_spec.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require 'rails_helper'
-
-feature 'Merge request issue assignment', :js do
- let(:user) { create(:user) }
- 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}") }
- let(:service) { MergeRequests::AssignIssuesService.new(merge_request, user, user, project) }
-
- before do
- project.team << [user, :developer]
- end
-
- def visit_merge_request(current_user = nil)
- sign_in(current_user || user)
- visit project_merge_request_path(project, merge_request)
- end
-
- context 'logged in as author' do
- it 'updates related issues' do
- visit_merge_request
- click_link "Assign yourself to these issues"
-
- expect(page).to have_content "2 issues have been assigned to you"
- end
-
- it 'returns user to the merge request' do
- visit_merge_request
- click_link "Assign yourself to these issues"
-
- expect(page).to have_content merge_request.description
- end
-
- it "doesn't display if related issues are already assigned" do
- [issue1, issue2].each { |issue| issue.update!(assignees: [user]) }
-
- visit_merge_request
-
- expect(page).not_to have_content "Assign yourself"
- end
- end
-
- context 'not MR author' do
- it "doesn't not show assignment link" do
- visit_merge_request(create(:user))
-
- expect(page).not_to have_content "Assign yourself"
- end
- end
-end
diff --git a/spec/features/merge_requests/award_spec.rb b/spec/features/merge_requests/award_spec.rb
deleted file mode 100644
index a24464f2556..00000000000
--- a/spec/features/merge_requests/award_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-require 'rails_helper'
-
-feature 'Merge request awards', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:merge_request) { create(:merge_request, source_project: project) }
-
- describe 'logged in' do
- before do
- sign_in(user)
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'adds award to merge request' do
- first('.js-emoji-btn').click
- expect(page).to have_selector('.js-emoji-btn.active')
- expect(first('.js-emoji-btn')).to have_content '1'
-
- visit project_merge_request_path(project, merge_request)
- expect(first('.js-emoji-btn')).to have_content '1'
- end
-
- it 'removes award from merge request' do
- first('.js-emoji-btn').click
- find('.js-emoji-btn.active').click
- expect(first('.js-emoji-btn')).to have_content '0'
-
- visit project_merge_request_path(project, merge_request)
- expect(first('.js-emoji-btn')).to have_content '0'
- end
-
- it 'has only one menu on the page' do
- first('.js-add-award').click
- expect(page).to have_selector('.emoji-menu')
-
- expect(page).to have_selector('.emoji-menu', count: 1)
- end
- end
-
- describe 'logged out' do
- before do
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'does not see award menu button' do
- expect(page).not_to have_selector('.js-award-holder')
- end
- end
-end
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
deleted file mode 100644
index fbbfe7942be..00000000000
--- a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-require 'spec_helper'
-
-feature 'Check if mergeable with unresolved discussions', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let!(:merge_request) { create(:merge_request_with_diff_notes, source_project: project, author: user) }
-
- before do
- sign_in user
- project.team << [user, :master]
- end
-
- context 'when project.only_allow_merge_if_all_discussions_are_resolved == true' do
- before do
- project.update_column(:only_allow_merge_if_all_discussions_are_resolved, true)
- end
-
- context 'with unresolved discussions' do
- it 'does not allow to merge' do
- visit_merge_request(merge_request)
-
- expect(page).not_to have_button 'Merge'
- expect(page).to have_content('There are unresolved discussions.')
- end
- end
-
- context 'with all discussions resolved' do
- before do
- merge_request.discussions.each { |d| d.resolve!(user) }
- end
-
- it 'allows MR to be merged' do
- visit_merge_request(merge_request)
-
- expect(page).to have_button 'Merge'
- end
- end
- end
-
- context 'when project.only_allow_merge_if_all_discussions_are_resolved == false' do
- before do
- project.update_column(:only_allow_merge_if_all_discussions_are_resolved, false)
- end
-
- context 'with unresolved discussions' do
- it 'does not allow to merge' do
- visit_merge_request(merge_request)
-
- expect(page).to have_button 'Merge'
- end
- end
-
- context 'with all discussions resolved' do
- before do
- merge_request.discussions.each { |d| d.resolve!(user) }
- end
-
- it 'allows MR to be merged' do
- visit_merge_request(merge_request)
-
- expect(page).to have_button 'Merge'
- end
- end
- end
-
- def visit_merge_request(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
deleted file mode 100644
index 48f370c3ad4..00000000000
--- a/spec/features/merge_requests/cherry_pick_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-require 'spec_helper'
-
-describe 'Cherry-pick Merge Requests', :js do
- let(:user) { create(:user) }
- let(:group) { create(:group) }
- let(:project) { create(:project, :repository, namespace: group) }
- let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user) }
-
- before do
- sign_in user
- project.team << [user, :master]
- end
-
- context "Viewing a merged merge request" do
- before do
- service = MergeRequests::MergeService.new(project, user)
-
- perform_enqueued_jobs do
- service.execute(merge_request)
- end
- end
-
- # Fast-forward merge, or merged before GitLab 8.5.
- context "Without a merge commit" do
- before do
- merge_request.merge_commit_sha = nil
- merge_request.save
- end
-
- it "doesn't show a Cherry-pick button" do
- visit project_merge_request_path(project, merge_request)
-
- expect(page).not_to have_link "Cherry-pick"
- end
- end
-
- context "With a merge commit" do
- it "shows a Cherry-pick button" do
- visit project_merge_request_path(project, merge_request)
-
- expect(page).to have_link "Cherry-pick"
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/closes_issues_spec.rb b/spec/features/merge_requests/closes_issues_spec.rb
deleted file mode 100644
index 4dd4e40f52c..00000000000
--- a/spec/features/merge_requests/closes_issues_spec.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge Request closing issues message', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:issue_1) { create(:issue, project: project)}
- let(:issue_2) { create(:issue, project: project)}
- let(:merge_request) do
- create(
- :merge_request,
- :simple,
- source_project: project,
- description: merge_request_description,
- title: merge_request_title
- )
- end
- let(:merge_request_description) { 'Merge Request Description' }
- let(:merge_request_title) { 'Merge Request Title' }
-
- before do
- project.team << [user, :master]
-
- sign_in user
-
- 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("Closes #{issue_1.to_reference} and #{issue_2.to_reference}")
- end
- end
-
- context 'mentioning issues but not closing them' 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("Mentions #{issue_1.to_reference} and #{issue_2.to_reference}")
- end
- end
-
- context 'closing some issues in title and mentioning, but not closing, others' 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("Closes #{issue_1.to_reference}")
- expect(page).to have_content("Mentions #{issue_2.to_reference}")
- end
- end
-
- context 'closing issues using title but not mentioning any other issue' 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("Closes #{issue_1.to_reference} and #{issue_2.to_reference}")
- end
- end
-
- context 'mentioning issues using title but not closing them' 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("Mentions #{issue_1.to_reference} and #{issue_2.to_reference}")
- end
- end
-
- context 'closing some issues using title and mentioning, but not closing, others' 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("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
deleted file mode 100644
index 4e2963c116d..00000000000
--- a/spec/features/merge_requests/conflicts_spec.rb
+++ /dev/null
@@ -1,196 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge request conflict resolution', :js do
- let(:user) { create(:user) }
- 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|
- mr.mark_as_unmergeable
- end
- end
-
- shared_examples "conflicts are resolved in Interactive mode" do
- it 'conflicts are resolved in Interactive mode' do
- within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do
- click_button 'Use ours'
- end
-
- within find('.files-wrapper .diff-file', text: 'files/ruby/regex.rb') do
- all('button', text: 'Use ours').each do |button|
- button.send_keys(:return)
- end
- end
-
- find_button('Commit conflict resolution').send_keys(:return)
-
- expect(page).to have_content('All merge conflicts were resolved')
- merge_request.reload_diff
-
- wait_for_requests
-
- click_on 'Changes'
- 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 }")
- expect(page).to have_selector('.line_content.new', text: "options = { chdir: path }")
- end
-
- within find('.diff-file', text: 'files/ruby/regex.rb') do
- expect(page).to have_selector('.line_content.new', text: "def username_regexp")
- expect(page).to have_selector('.line_content.new', text: "def project_name_regexp")
- expect(page).to have_selector('.line_content.new', text: "def path_regexp")
- expect(page).to have_selector('.line_content.new', text: "def archive_formats_regexp")
- expect(page).to have_selector('.line_content.new', text: "def git_reference_regexp")
- expect(page).to have_selector('.line_content.new', text: "def default_regexp")
- end
- end
- end
-
- shared_examples "conflicts are resolved in Edit inline mode" do
- it 'conflicts are resolved in Edit inline mode' do
- expect(find('#conflicts')).to have_content('popen.rb')
-
- within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do
- click_button 'Edit inline'
- wait_for_requests
- find('.files-wrapper .diff-file pre')
- 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_requests
- find('.files-wrapper .diff-file pre')
- execute_script('ace.edit($(".files-wrapper .diff-file pre")[1]).setValue("Gregor Samsa woke from troubled dreams");')
- end
-
- find_button('Commit conflict resolution').send_keys(:return)
-
- expect(page).to have_content('All merge conflicts were resolved')
- merge_request.reload_diff
-
- wait_for_requests
-
- click_on 'Changes'
- wait_for_requests
-
- expect(page).to have_content('One morning')
- expect(page).to have_content('Gregor Samsa woke from troubled dreams')
- end
- end
-
- context 'can be resolved in the UI' do
- before do
- project.team << [user, :developer]
- sign_in(user)
- end
-
- context 'the conflicts are resolvable' do
- let(:merge_request) { create_merge_request('conflict-resolvable') }
-
- 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 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"
- end
-
- context 'in Parallel view mode' do
- before do
- click_link('conflicts', href: /\/conflicts\Z/)
- click_button 'Side-by-side'
- end
-
- include_examples "conflicts are resolved in Interactive mode"
- include_examples "conflicts are resolved in Edit inline mode"
- end
- end
-
- context 'the conflict contain markers' do
- let(:merge_request) { create_merge_request('conflict-contains-conflict-markers') }
-
- before do
- visit project_merge_request_path(project, merge_request)
- click_link('conflicts', href: /\/conflicts\Z/)
- end
-
- it 'conflicts can not be resolved in Interactive mode' do
- within find('.files-wrapper .diff-file', text: 'files/markdown/ruby-style-guide.md') do
- expect(page).not_to have_content 'Interactive mode'
- expect(page).not_to have_content 'Edit inline'
- end
- end
-
- 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_requests
- find('.files-wrapper .diff-file pre')
- execute_script('ace.edit($(".files-wrapper .diff-file pre")[0]).setValue("Gregor Samsa woke from troubled dreams");')
- end
-
- click_button 'Commit conflict resolution'
-
- expect(page).to have_content('All merge conflicts were resolved')
-
- merge_request.reload_diff
-
- wait_for_requests
-
- click_on 'Changes'
- wait_for_requests
- click_link 'Expand all'
- wait_for_requests
-
- expect(page).to have_content('Gregor Samsa woke from troubled dreams')
- end
- end
- end
-
- UNRESOLVABLE_CONFLICTS = {
- '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'
- }.freeze
-
- UNRESOLVABLE_CONFLICTS.each do |source_branch, description|
- context description do
- let(:merge_request) { create_merge_request(source_branch) }
-
- before do
- project.team << [user, :developer]
- sign_in(user)
-
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'does not show a link to the conflict resolution page' do
- expect(page).not_to have_link('conflicts', href: /\/conflicts\Z/)
- end
-
- it 'shows an error if the conflicts page is visited directly' do
- visit current_url + '/conflicts'
- wait_for_requests
-
- expect(find('#conflicts')).to have_content('Please try to resolve them locally.')
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/create_new_mr_from_fork_spec.rb b/spec/features/merge_requests/create_new_mr_from_fork_spec.rb
deleted file mode 100644
index 93c40ff6443..00000000000
--- a/spec/features/merge_requests/create_new_mr_from_fork_spec.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-require 'spec_helper'
-
-feature 'Creating a merge request from a fork', :js do
- include ProjectForksHelper
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let!(:source_project) do
- fork_project(project, user,
- repository: true,
- namespace: user.namespace)
- end
-
- before do
- source_project.add_master(user)
-
- sign_in(user)
- end
-
- shared_examples 'create merge request to other project' do
- it 'has all possible target projects' do
- visit project_new_merge_request_path(source_project)
-
- first('.js-target-project').click
-
- within('.dropdown-target-project .dropdown-content') do
- expect(page).to have_content(project.full_path)
- expect(page).to have_content(target_project.full_path)
- expect(page).to have_content(source_project.full_path)
- end
- end
-
- it 'allows creating the merge request to another target project' do
- visit project_merge_requests_path(source_project)
-
- page.within '.content' do
- click_link 'New merge request'
- end
-
- find('.js-source-branch', match: :first).click
- find('.dropdown-source-branch .dropdown-content a', match: :first).click
-
- first('.js-target-project').click
- find('.dropdown-target-project .dropdown-content a', text: target_project.full_path).click
-
- click_button 'Compare branches and continue'
-
- wait_for_requests
-
- expect { click_button 'Submit merge request' }
- .to change { target_project.merge_requests.reload.size }.by(1)
- end
-
- it 'updates the branches when selecting a new target project' do
- target_project_member = target_project.owner
- CreateBranchService.new(target_project, target_project_member)
- .execute('a-brand-new-branch-to-test', 'master')
- visit project_new_merge_request_path(source_project)
-
- first('.js-target-project').click
- find('.dropdown-target-project .dropdown-content a', text: target_project.full_path).click
-
- wait_for_requests
-
- first('.js-target-branch').click
-
- within('.dropdown-target-branch .dropdown-content') do
- expect(page).to have_content('a-brand-new-branch-to-test')
- end
- end
- end
-
- context 'creating to the source of a fork' do
- let!(:target_project) { project }
-
- it_behaves_like('create merge request to other project')
- end
-
- context 'creating to a sibling of a fork' do
- let!(:target_project) do
- other_user = create(:user)
- fork_project(project, other_user,
- repository: true,
- namespace: other_user.namespace)
- end
-
- it_behaves_like('create merge request to other project')
- end
-end
diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb
deleted file mode 100644
index db5ce2d11a8..00000000000
--- a/spec/features/merge_requests/create_new_mr_spec.rb
+++ /dev/null
@@ -1,181 +0,0 @@
-require 'spec_helper'
-
-feature 'Create New Merge Request', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
-
- before do
- project.team << [user, :master]
-
- sign_in user
- end
-
- it 'selects the source branch sha when a tag with the same name exists' do
- visit project_merge_requests_path(project)
-
- page.within '.content' do
- click_link 'New merge request'
- end
- expect(page).to have_content('Source branch')
- expect(page).to have_content('Target branch')
-
- first('.js-source-branch').click
- find('.dropdown-source-branch .dropdown-content a', match: :first).click
-
- expect(page).to have_content "b83d6e3"
- end
-
- it 'selects the target branch sha when a tag with the same name exists' do
- visit project_merge_requests_path(project)
-
- page.within '.content' do
- click_link 'New merge request'
- end
-
- expect(page).to have_content('Source branch')
- expect(page).to have_content('Target branch')
-
- first('.js-target-branch').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 project_merge_requests_path(project)
-
- page.within '.content' do
- click_link 'New merge request'
- end
- expect(page).to have_content('Source branch')
- expect(page).to have_content('Target branch')
-
- find('.js-source-branch', match: :first).click
- find('.dropdown-source-branch .dropdown-content a', text: 'orphaned-branch', match: :first).click
-
- click_button "Compare branches"
- click_link "Changes"
-
- expect(page).to have_content "README.md"
- expect(page).to have_content "wm.png"
-
- fill_in "merge_request_title", with: "Orphaned MR test"
- click_button "Submit merge request"
-
- click_link "Check out branch"
-
- expect(page).to have_content 'git checkout -b orphaned-branch origin/orphaned-branch'
- end
-
- it 'allows filtering multiple dropdowns' do
- visit project_new_merge_request_path(project)
-
- first('.js-source-branch').click
-
- input = find('.dropdown-source-branch .dropdown-input-field')
- input.click
- input.send_keys('orphaned-branch')
-
- find('.dropdown-source-branch .dropdown-content li', match: :first)
- source_items = all('.dropdown-source-branch .dropdown-content li')
-
- expect(source_items.count).to eq(1)
-
- first('.js-target-branch').click
-
- find('.dropdown-target-branch .dropdown-content li', match: :first)
- target_items = all('.dropdown-target-branch .dropdown-content li')
-
- expect(target_items.count).to be > 1
- end
-
- 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, :repository)
-
- visit project_new_merge_request_path(project, merge_request: { target_project_id: private_project.id })
-
- 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, :repository)
-
- visit project_new_merge_request_path(project, merge_request: { source_project_id: private_project.id })
-
- 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 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 project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'fix' })
-
- click_link 'Changes'
-
- expect(page).to have_css('a.btn.active', text: 'Inline')
- expect(page).not_to have_css('a.btn.active', text: 'Side-by-side')
-
- click_link 'Side-by-side'
-
- within '.merge-request' do
- expect(page).not_to have_css('a.btn.active', text: 'Inline')
- expect(page).to have_css('a.btn.active', text: 'Side-by-side')
- end
- end
-
- it 'does not allow non-existing branches' do
- 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')
- expect(page).to have_content('Target branch "non-exist-target" does not exist')
- end
-
- context 'when a branch contains commits that both delete and add the same image' do
- it 'renders the diff successfully' do
- visit project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'deleted-image-test' })
-
- click_link "Changes"
-
- expect(page).to have_content "6049019_460s.jpg"
- end
- end
-
- # Isolates a regression (see #24627)
- it 'does not show error messages on initial form' do
- visit 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
-
- context 'when a new merge request has a pipeline' do
- let!(:pipeline) do
- create(:ci_pipeline, sha: project.commit('fix').id,
- ref: 'fix',
- project: project)
- end
-
- it 'shows pipelines for a new merge request' do
- 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_requests
-
- expect(page).to have_content "##{pipeline.id}"
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb
deleted file mode 100644
index ca2225318cd..00000000000
--- a/spec/features/merge_requests/created_from_fork_spec.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge request created from fork' do
- include ProjectForksHelper
-
- given(:user) { create(:user) }
- given(:project) { create(:project, :public, :repository) }
- given(:forked_project) { fork_project(project, user, repository: true) }
-
- given!(:merge_request) do
- create(:merge_request_with_diffs, source_project: forked_project,
- target_project: project,
- description: 'Test merge request')
- end
-
- background do
- forked_project.team << [user, :master]
- sign_in user
- end
-
- scenario 'user can access merge request' do
- visit_merge_request(merge_request)
-
- 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: forked_project,
- commit_id: merge_request.commit_shas.first)
- end
-
- scenario 'user can reply to the comment', :js 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)
- forked_project.destroy!
- end
-
- scenario 'user can access merge request', :js do
- visit_merge_request(merge_request)
-
- expect(page).to have_content 'Test merge request'
- expect(page).to have_content "(removed):#{merge_request.source_branch}"
- end
- end
-
- context 'pipeline present in source project' do
- given(:pipeline) do
- create(:ci_pipeline,
- project: forked_project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch)
- end
-
- background do
- create(:ci_build, pipeline: pipeline, name: 'rspec')
- create(:ci_build, pipeline: pipeline, name: 'spinach')
- end
-
- scenario 'user visits a pipelines page', :js do
- visit_merge_request(merge_request)
- page.within('.merge-request-tabs') { click_link 'Pipelines' }
-
- page.within('.ci-table') do
- expect(page).to have_content pipeline.id
- end
- end
- end
-
- def visit_merge_request(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
deleted file mode 100644
index 7f69e82af4c..00000000000
--- a/spec/features/merge_requests/deleted_source_branch_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-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', :js do
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request) }
-
- before do
- sign_in user
- merge_request.project.team << [user, :master]
- merge_request.update!(source_branch: 'this-branch-does-not-exist')
- 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 does not exist.'
- )
- end
-
- it 'still contains Discussion, Commits and Changes tabs' do
- within '.merge-request-details' do
- expect(page).to have_content('Discussion')
- expect(page).to have_content('Commits')
- expect(page).to have_content('Changes')
- end
-
- click_on 'Changes'
- wait_for_requests
-
- expect(page).to have_selector('.diffs.tab-pane .nothing-here-block')
- 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
deleted file mode 100644
index 9e816cf041b..00000000000
--- a/spec/features/merge_requests/diff_notes_avatars_spec.rb
+++ /dev/null
@@ -1,194 +0,0 @@
-require 'spec_helper'
-
-feature 'Diff note avatars', :js do
- include NoteInteractionHelpers
-
- let(:user) { create(:user) }
- 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
- Gitlab::Diff::Position.new(
- old_path: path,
- new_path: path,
- old_line: nil,
- new_line: 9,
- diff_refs: merge_request.diff_refs
- )
- end
- let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) }
-
- before do
- project.team << [user, :master]
- sign_in user
-
- set_cookie('sidebar_collapsed', 'true')
- end
-
- context 'discussion tab' do
- before do
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'does not show avatars on discussion tab' do
- expect(page).not_to have_selector('.js-avatar-container')
- expect(page).not_to have_selector('.diff-comment-avatar-holders')
- end
-
- it 'does not render avatars after commening on discussion tab' do
- click_button 'Reply...'
-
- page.within('.js-discussion-note-form') do
- find('.note-textarea').native.send_keys('Test comment')
-
- click_button 'Comment'
- end
-
- expect(page).to have_content('Test comment')
- expect(page).not_to have_selector('.js-avatar-container')
- expect(page).not_to have_selector('.diff-comment-avatar-holders')
- end
- end
-
- context 'commit view' do
- before do
- visit project_commit_path(project, merge_request.commits.first.id)
- end
-
- it 'does not render avatar after commenting' do
- first('.diff-line-num').click
- find('.js-add-diff-note-button').click
-
- page.within('.js-discussion-note-form') do
- find('.note-textarea').native.send_keys('test comment')
-
- click_button 'Comment'
-
- wait_for_requests
- end
-
- visit project_merge_request_path(project, merge_request)
-
- expect(page).to have_content('test comment')
- expect(page).not_to have_selector('.js-avatar-container')
- expect(page).not_to have_selector('.diff-comment-avatar-holders')
- end
- end
-
- %w(inline parallel).each do |view|
- context "#{view} view" do
- before do
- visit diffs_project_merge_request_path(project, merge_request, view: view)
-
- wait_for_requests
- end
-
- it 'shows note avatar' do
- page.within find_line(position.line_code(project.repository)) do
- find('.diff-notes-collapse').send_keys(:return)
-
- expect(page).to have_selector('img.js-diff-comment-avatar', count: 1)
- end
- end
-
- it 'shows comment on note avatar' do
- page.within find_line(position.line_code(project.repository)) do
- find('.diff-notes-collapse').send_keys(:return)
-
- expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
- end
- end
-
- it 'toggles comments when clicking avatar' do
- page.within find_line(position.line_code(project.repository)) do
- find('.diff-notes-collapse').send_keys(:return)
- end
-
- expect(page).to have_selector('.notes_holder', visible: false)
-
- page.within find_line(position.line_code(project.repository)) do
- first('img.js-diff-comment-avatar').click
- end
-
- expect(page).to have_selector('.notes_holder')
- end
-
- it 'removes avatar when note is deleted' do
- open_more_actions_dropdown(note)
-
- page.within find(".note-row-#{note.id}") do
- accept_confirm { find('.js-note-delete').click }
- end
-
- wait_for_requests
-
- page.within find_line(position.line_code(project.repository)) do
- expect(page).not_to have_selector('img.js-diff-comment-avatar')
- end
- end
-
- it 'adds avatar when commenting' do
- click_button 'Reply...'
-
- page.within '.js-discussion-note-form' do
- find('.js-note-text').native.send_keys('Test')
-
- click_button 'Comment'
-
- wait_for_requests
- end
-
- page.within find_line(position.line_code(project.repository)) do
- find('.diff-notes-collapse').send_keys(:return)
-
- expect(page).to have_selector('img.js-diff-comment-avatar', count: 2)
- end
- end
-
- it 'adds multiple comments' do
- 3.times do
- click_button 'Reply...'
-
- page.within '.js-discussion-note-form' do
- find('.js-note-text').native.send_keys('Test')
-
- find('.js-comment-button').click
-
- wait_for_requests
- end
- end
-
- page.within find_line(position.line_code(project.repository)) do
- find('.diff-notes-collapse').send_keys(:return)
-
- expect(page).to have_selector('img.js-diff-comment-avatar', count: 3)
- expect(find('.diff-comments-more-count')).to have_content '+1'
- end
- end
-
- context 'multiple comments' do
- before do
- create_list(:diff_note_on_merge_request, 3, project: project, noteable: merge_request, in_reply_to: note)
-
- visit diffs_project_merge_request_path(project, merge_request, view: view)
-
- wait_for_requests
- end
-
- it 'shows extra comment count' do
- page.within find_line(position.line_code(project.repository)) do
- find('.diff-notes-collapse').send_keys(:return)
-
- expect(find('.diff-comments-more-count')).to have_content '+1'
- end
- end
- end
- end
- end
-
- def find_line(line_code)
- line = find("[id='#{line_code}']")
- line = line.find(:xpath, 'preceding-sibling::*[1][self::td]') if line.tag_name == 'td'
- line
- end
-end
diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb
deleted file mode 100644
index 15d380b1bf4..00000000000
--- a/spec/features/merge_requests/diff_notes_resolve_spec.rb
+++ /dev/null
@@ -1,527 +0,0 @@
-require 'spec_helper'
-
-feature 'Diff notes resolve', :js do
- let(:user) { create(:user) }
- 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" }
- let(:position) do
- Gitlab::Diff::Position.new(
- old_path: path,
- new_path: path,
- old_line: nil,
- new_line: 9,
- diff_refs: merge_request.diff_refs
- )
- end
-
- context 'no discussions' do
- before do
- project.team << [user, :master]
- sign_in user
- note.destroy
- visit_merge_request
- end
-
- it 'displays no discussion resolved data' do
- expect(page).not_to have_content('discussion resolved')
- expect(page).not_to have_selector('.discussion-next-btn')
- end
- end
-
- context 'as authorized user' do
- before do
- project.team << [user, :master]
- sign_in user
- visit_merge_request
- end
-
- context 'single discussion' do
- it 'shows text with how many discussions' do
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
-
- it 'allows user to mark a note as resolved' do
- page.within '.diff-content .note' do
- find('.line-resolve-btn').click
-
- expect(page).to have_selector('.line-resolve-btn.is-active')
- expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
- end
-
- page.within '.diff-content' do
- expect(page).to have_selector('.btn', text: 'Unresolve discussion')
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to mark discussion as resolved' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
- end
-
- page.within '.diff-content .note' do
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to unresolve discussion' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
- click_button 'Unresolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
-
- describe 'resolved discussion' do
- before do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
- end
-
- visit_merge_request
- end
-
- describe 'timeline view' do
- it 'hides when resolve discussion is clicked' do
- expect(page).to have_selector('.discussion-body', visible: false)
- end
-
- it 'shows resolved discussion when toggled' do
- find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click
-
- expect(page.find(".timeline-content #note_#{note.noteable_id}")).to be_visible
- end
- end
-
- describe 'side-by-side view' do
- before do
- page.within('.merge-request-tabs') { click_link 'Changes' }
- page.find('#parallel-diff-btn').click
- end
-
- it 'hides when resolve discussion is clicked' do
- expect(page).to have_selector('.diffs .diff-file .notes_holder', visible: false)
- end
-
- it 'shows resolved discussion when toggled' do
- find('.diff-comment-avatar-holders').click
-
- expect(find('.diffs .diff-file .notes_holder')).to be_visible
- end
- end
- end
-
- it 'allows user to resolve from reply form without a comment' do
- page.within '.diff-content' do
- click_button 'Reply...'
-
- click_button 'Resolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to unresolve from reply form without a comment' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
- sleep 1
-
- click_button 'Reply...'
-
- click_button 'Unresolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- expect(page).not_to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to comment & resolve discussion' do
- page.within '.diff-content' do
- click_button 'Reply...'
-
- find('.js-note-text').set 'testing'
-
- click_button 'Comment & resolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to comment & unresolve discussion' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
-
- click_button 'Reply...'
-
- find('.js-note-text').set 'testing'
-
- click_button 'Comment & unresolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
-
- it 'allows user to quickly scroll to next unresolved discussion' do
- page.within '.line-resolve-all-container' do
- page.find('.discussion-next-btn').click
- end
-
- expect(page.evaluate_script("window.pageYOffset")).to be > 0
- end
-
- it 'hides jump to next button when all resolved' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
- end
-
- expect(page).to have_selector('.discussion-next-btn', visible: false)
- end
-
- it 'updates updated text after resolving note' do
- page.within '.diff-content .note' do
- find('.line-resolve-btn').click
- end
-
- expect(page).to have_content("Resolved by #{user.name}")
- end
-
- it 'hides jump to next discussion button' do
- page.within '.discussion-reply-holder' do
- expect(page).not_to have_selector('.discussion-next-btn')
- end
- end
- end
-
- context 'multiple notes' do
- before do
- create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: note)
- visit_merge_request
- end
-
- it 'does not mark discussion as resolved when resolving single note' do
- page.within("#note_#{note.id}") do
- first('.line-resolve-btn').click
-
- wait_for_requests
-
- expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
- end
-
- expect(page).to have_content('Last updated')
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
-
- it 'resolves discussion' do
- page.all('.note .line-resolve-btn').each do |button|
- button.click
- end
-
- expect(page).to have_content('Resolved by')
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- end
- end
- end
-
- context 'muliple discussions' do
- before do
- create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request)
- visit_merge_request
- end
-
- it 'shows text with how many discussions' do
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/2 discussions resolved')
- end
- end
-
- it 'allows user to mark a single note as resolved' do
- click_button('Resolve discussion', match: :first)
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/2 discussions resolved')
- end
- end
-
- it 'allows user to mark all notes as resolved' do
- page.all('.line-resolve-btn').each do |btn|
- btn.click
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('2/2 discussions resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user user to mark all discussions as resolved' do
- page.all('.discussion-reply-holder').each do |reply_holder|
- page.within reply_holder do
- click_button 'Resolve discussion'
- end
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('2/2 discussions resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to quickly scroll to next unresolved discussion' do
- page.within first('.discussion-reply-holder') do
- click_button 'Resolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- page.find('.discussion-next-btn').click
- end
-
- expect(page.evaluate_script("window.pageYOffset")).to be > 0
- end
-
- it 'updates updated text after resolving note' do
- page.within first('.diff-content .note') do
- find('.line-resolve-btn').click
- end
-
- expect(page).to have_content("Resolved by #{user.name}")
- end
-
- it 'shows jump to next discussion button' do
- page.all('.discussion-reply-holder').each do |holder|
- expect(holder).to have_selector('.discussion-next-btn')
- end
- end
-
- it 'displays next discussion even if hidden' do
- page.all('.note-discussion').each do |discussion|
- page.within discussion do
- click_button 'Toggle discussion'
- end
- end
-
- page.within('.issuable-discussion #notes') do
- expect(page).not_to have_selector('.btn', text: 'Resolve discussion')
- end
-
- page.within '.line-resolve-all-container' do
- page.find('.discussion-next-btn').click
- end
-
- expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
- end
- end
-
- context 'changes tab' do
- it 'shows text with how many discussions' do
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
-
- it 'allows user to mark a note as resolved' do
- page.within '.diff-content .note' do
- find('.line-resolve-btn').click
-
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
-
- page.within '.diff-content' do
- expect(page).to have_selector('.btn', text: 'Unresolve discussion')
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to mark discussion as resolved' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
- end
-
- page.within '.diff-content .note' do
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to unresolve discussion' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
- click_button 'Unresolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
-
- it 'allows user to comment & resolve discussion' do
- page.within '.diff-content' do
- click_button 'Reply...'
-
- find('.js-note-text').set 'testing'
-
- click_button 'Comment & resolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
-
- it 'allows user to comment & unresolve discussion' do
- page.within '.diff-content' do
- click_button 'Resolve discussion'
-
- click_button 'Reply...'
-
- find('.js-note-text').set 'testing'
-
- click_button 'Comment & unresolve discussion'
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
- end
- end
-
- context 'as a guest' do
- let(:guest) { create(:user) }
-
- before do
- project.team << [guest, :guest]
- sign_in guest
- end
-
- context 'someone elses merge request' do
- before do
- visit_merge_request
- end
-
- it 'does not allow user to mark note as resolved' do
- page.within '.diff-content .note' do
- expect(page).not_to have_selector('.line-resolve-btn')
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
-
- it 'does not allow user to mark discussion as resolved' do
- page.within '.diff-content .note' do
- expect(page).not_to have_selector('.btn', text: 'Resolve discussion')
- end
- end
- end
-
- context 'guest users merge request' do
- before do
- mr = create(:merge_request_with_diffs, source_project: project, source_branch: 'markdown', author: guest, title: "Bug")
- create(:diff_note_on_merge_request, project: project, noteable: mr)
- visit_merge_request(mr)
- end
-
- it 'allows user to mark a note as resolved' do
- page.within '.diff-content .note' do
- find('.line-resolve-btn').click
-
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
-
- page.within '.diff-content' do
- expect(page).to have_selector('.btn', text: 'Unresolve discussion')
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('1/1 discussion resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
- end
- end
-
- context 'unauthorized user' do
- context 'no resolved comments' do
- before do
- visit_merge_request
- end
-
- it 'does not allow user to mark note as resolved' do
- page.within '.diff-content .note' do
- expect(page).not_to have_selector('.line-resolve-btn')
- end
-
- page.within '.line-resolve-all-container' do
- expect(page).to have_content('0/1 discussion resolved')
- end
- end
- end
-
- context 'resolved comment' do
- before do
- note.resolve!(user)
- visit_merge_request
- end
-
- it 'shows resolved icon' do
- expect(page).to have_content '1/1 discussion resolved'
-
- click_button 'Toggle discussion'
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
-
- it 'does not allow user to click resolve button' do
- expect(page).to have_selector('.line-resolve-btn.is-disabled')
- click_button 'Toggle discussion'
-
- expect(page).to have_selector('.line-resolve-btn.is-disabled')
- end
- end
- end
-
- def visit_merge_request(mr = nil)
- mr = mr || merge_request
- 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
deleted file mode 100644
index 1bf77296ae6..00000000000
--- a/spec/features/merge_requests/diffs_spec.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-require 'spec_helper'
-
-feature 'Diffs URL', :js do
- include ProjectForksHelper
-
- let(:project) { create(:project, :public, :repository) }
- let(:merge_request) { create(:merge_request, source_project: project) }
-
- context 'when visit with */* as accept header' do
- it 'renders the notes' do
- create :note_on_merge_request, project: project, noteable: merge_request, note: 'Rebasing with master'
-
- inspect_requests(inject_headers: { 'Accept' => '*/*' }) do
- visit diffs_project_merge_request_path(project, merge_request)
- end
-
- # Load notes and diff through AJAX
- expect(page).to have_css('.note-text', visible: false, text: 'Rebasing with master')
- expect(page).to have_css('.diffs.tab-pane.active')
- 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_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
- performance only 3 of 3+ files are displayed.")
- end
- end
- end
-
- context 'when editing file' do
- let(:author_user) { create(:user) }
- let(:user) { create(:user) }
- let(:forked_project) { fork_project(project, author_user, repository: true) }
- 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
- 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")
- end
- end
-
- context 'as user who needs to fork' do
- it 'shows fork/cancel confirmation' do
- 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
-
- expect(page).to have_selector('.js-fork-suggestion-button', count: 1)
- expect(page).to have_selector('.js-cancel-fork-suggestion-button', count: 1)
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/discussion_lock_spec.rb b/spec/features/merge_requests/discussion_lock_spec.rb
deleted file mode 100644
index 7bbd3b1e69e..00000000000
--- a/spec/features/merge_requests/discussion_lock_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-require 'spec_helper'
-
-describe 'Discussion Lock', :js do
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request, source_project: project, author: user) }
- let(:project) { create(:project, :public, :repository) }
-
- before do
- sign_in(user)
- end
-
- context 'when the discussion is locked' do
- before do
- merge_request.update_attribute(:discussion_locked, true)
- end
-
- context 'when a user is a team member' do
- before do
- project.add_developer(user)
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'the user can create a comment' do
- page.within('.issuable-discussion #notes .js-main-target-form') do
- fill_in 'note[note]', with: 'Some new comment'
- click_button 'Comment'
- end
-
- wait_for_requests
-
- expect(find('.issuable-discussion #notes')).to have_content('Some new comment')
- end
- end
-
- context 'when a user is not a team member' do
- before do
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'the user can not create a comment' do
- page.within('.issuable-discussion #notes') do
- expect(page).not_to have_selector('js-main-target-form')
- expect(page.find('.disabled-comment'))
- .to have_content('This merge request is locked. Only project members can comment.')
- end
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/discussion_spec.rb b/spec/features/merge_requests/discussion_spec.rb
deleted file mode 100644
index 05789bbd31d..00000000000
--- a/spec/features/merge_requests/discussion_spec.rb
+++ /dev/null
@@ -1,90 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge Request Discussions' do
- before do
- sign_in(create(:admin))
- end
-
- 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) }
- let!(:new_merge_request_diff) { merge_request.merge_request_diffs.create }
-
- let!(:outdated_discussion) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position).to_discussion }
- let!(:active_discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
-
- let(:outdated_position) do
- Gitlab::Diff::Position.new(
- old_path: "files/ruby/popen.rb",
- new_path: "files/ruby/popen.rb",
- old_line: nil,
- new_line: 9,
- diff_refs: outdated_diff_refs
- )
- end
-
- let(:outdated_diff_refs) { project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs }
-
- 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_project_merge_request_path(project, merge_request, anchor: active_discussion.line_code)
- expect(page).to have_link('the diff', href: path)
- end
- end
- end
-
- context 'outdated discussions' do
- it 'shows a link to the outdated diff' do
- within(".discussion[data-discussion-id='#{outdated_discussion.id}']") do
- 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
deleted file mode 100644
index 4362f8b3fcc..00000000000
--- a/spec/features/merge_requests/edit_mr_spec.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require 'spec_helper'
-
-feature 'Edit Merge Request' do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:merge_request) { create(:merge_request, :simple, source_project: project) }
-
- before do
- project.team << [user, :master]
-
- sign_in user
-
- visit edit_project_merge_request_path(project, merge_request)
- end
-
- context 'editing a MR' do
- it 'has class js-quick-submit in form' do
- expect(page).to have_selector('.js-quick-submit')
- end
-
- it 'warns about version conflict' do
- merge_request.update(title: "New title")
-
- fill_in 'merge_request_title', with: 'bug 345'
- fill_in 'merge_request_description', with: 'bug description'
-
- click_button 'Save changes'
-
- expect(page).to have_content 'Someone edited the merge request the same time you did'
- end
-
- it 'allows to unselect "Remove source branch"', :js 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_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
-
- it 'should preserve description textarea height', :js do
- long_description = %q(
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam ac ornare ligula, ut tempus arcu. Etiam ultricies accumsan dolor vitae faucibus. Donec at elit lacus. Mauris orci ante, aliquam quis lorem eget, convallis faucibus arcu. Aenean at pulvinar lacus. Ut viverra quam massa, molestie ornare tortor dignissim a. Suspendisse tristique pellentesque tellus, id lacinia metus elementum id. Nam tristique, arcu rhoncus faucibus viverra, lacus ipsum sagittis ligula, vitae convallis odio lacus a nibh. Ut tincidunt est purus, ac vestibulum augue maximus in. Suspendisse vel erat et mi ultricies semper. Pellentesque volutpat pellentesque consequat.
-
- Cras congue nec ligula tristique viverra. Curabitur fringilla fringilla fringilla. Donec rhoncus dignissim orci ut accumsan. Ut rutrum urna a rhoncus varius. Maecenas blandit, mauris nec accumsan gravida, augue nibh finibus magna, sed maximus turpis libero nec neque. Suspendisse at semper est. Nunc imperdiet dapibus dui, varius sollicitudin erat luctus non. Sed pellentesque ligula eget posuere facilisis. Donec dictum commodo volutpat. Donec egestas dui ac magna sollicitudin bibendum. Vivamus purus neque, ullamcorper ac feugiat et, tempus sit amet metus. Praesent quis viverra neque. Sed bibendum viverra est, eu aliquam mi ornare vitae. Proin et dapibus ipsum. Nunc tortor diam, malesuada nec interdum vel, placerat quis justo. Ut viverra at erat eu laoreet.
-
- Pellentesque commodo, diam sit amet dignissim condimentum, tortor justo pretium est, non venenatis metus eros ut nunc. Etiam ut neque eget sem dapibus aliquam. Curabitur vel elit lorem. Nulla nec enim elit. Sed ut ex id justo facilisis convallis at ac augue. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Nullam cursus egestas turpis non tristique. Suspendisse in erat sem. Fusce libero elit, fermentum gravida mauris id, auctor iaculis felis. Nullam vulputate tempor laoreet.
-
- Nam tempor et magna sed convallis. Fusce sit amet sollicitudin risus, a ullamcorper lacus. Morbi gravida quis sem eget porttitor. Donec eu egestas mauris, in elementum tortor. Sed eget ex mi. Mauris iaculis tortor ut est auctor, nec dignissim quam sagittis. Suspendisse vel metus non quam suscipit tincidunt. Cras molestie lacus non justo finibus sodales quis vitae erat. In a porttitor nisi, id sollicitudin urna. Ut at felis tellus. Suspendisse potenti.
-
- Maecenas leo ligula, varius at neque vitae, ornare maximus justo. Nullam convallis luctus risus et vulputate. Duis suscipit faucibus iaculis. Etiam quis tortor faucibus, tristique tellus sit amet, sodales neque. Nulla dapibus nisi vel aliquet consequat. Etiam faucibus, metus eget condimentum iaculis, enim urna lobortis sem, id efficitur eros sapien nec nisi. Aenean ut finibus ex.
- )
-
- fill_in 'merge_request_description', with: long_description
-
- height = get_textarea_height
- find('.js-md-preview-button').click
- find('.js-md-write-button').click
- new_height = get_textarea_height
-
- expect(height).to eq(new_height)
- end
-
- def get_textarea_height
- find('#merge_request_description')
- page.evaluate_script('document.getElementById("merge_request_description").offsetHeight')
- end
- end
-end
diff --git a/spec/features/merge_requests/filter_by_labels_spec.rb b/spec/features/merge_requests/filter_by_labels_spec.rb
deleted file mode 100644
index 7adae08e499..00000000000
--- a/spec/features/merge_requests/filter_by_labels_spec.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-require 'rails_helper'
-
-feature 'Merge Request filtering by Labels', :js do
- include FilteredSearchHelpers
- include MergeRequestHelpers
-
- let(:project) { create(:project, :public, :repository) }
- let!(:user) { create(:user) }
- let!(:label) { create(:label, project: project) }
-
- let!(:bug) { create(:label, project: project, title: 'bug') }
- 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: "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
-
- mr2.labels << bug
- mr2.labels << enhancement
-
- mr3.title = "Feature1"
- mr3.labels << feature
-
- project.add_master(user)
- sign_in(user)
-
- visit project_merge_requests_path(project)
- end
-
- context 'filter by label bug' do
- before do
- input_filtered_search('label:~bug')
- end
-
- it 'apply the filter' do
- expect(page).to have_content "Bugfix1"
- expect(page).to have_content "Bugfix2"
- expect(page).not_to have_content "Feature1"
- end
- end
-
- context 'filter by label feature' do
- before do
- input_filtered_search('label:~feature')
- end
-
- it 'applies the filter' do
- expect(page).to have_content "Feature1"
- expect(page).not_to have_content "Bugfix2"
- expect(page).not_to have_content "Bugfix1"
- end
- end
-
- context 'filter by label enhancement' do
- before do
- input_filtered_search('label:~enhancement')
- end
-
- it 'applies the filter' do
- expect(page).to have_content "Bugfix2"
- expect(page).not_to have_content "Feature1"
- expect(page).not_to have_content "Bugfix1"
- end
- end
-
- context 'filter by label enhancement and bug in issues list' do
- before do
- input_filtered_search('label:~bug label:~enhancement')
- end
-
- it 'applies the filters' do
- expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- expect(page).to have_content "Bugfix2"
- expect(page).not_to have_content "Feature1"
- end
- end
-
- context 'filter dropdown' do
- it 'filters by label name' do
- init_label_search
- filtered_search.send_keys('~bug')
-
- page.within '.filter-dropdown' do
- expect(page).not_to have_content 'enhancement'
- expect(page).to have_content 'bug'
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/filter_by_milestone_spec.rb b/spec/features/merge_requests/filter_by_milestone_spec.rb
deleted file mode 100644
index 8b9ff9be943..00000000000
--- a/spec/features/merge_requests/filter_by_milestone_spec.rb
+++ /dev/null
@@ -1,97 +0,0 @@
-require 'rails_helper'
-
-feature 'Merge Request filtering by Milestone' do
- include FilteredSearchHelpers
- include MergeRequestHelpers
-
- let(:project) { create(:project, :public, :repository) }
- let!(:user) { create(:user)}
- let(:milestone) { create(:milestone, project: project) }
-
- def filter_by_milestone(title)
- find(".js-milestone-select").click
- find(".milestone-filter a", text: title).click
- end
-
- before do
- project.team << [user, :master]
- sign_in(user)
- end
-
- scenario 'filters by no Milestone', :js do
- create(:merge_request, :with_diffs, source_project: project)
- create(:merge_request, :simple, source_project: project, milestone: milestone)
-
- visit_merge_requests(project)
- input_filtered_search('milestone:none')
-
- expect_tokens([milestone_token('none', false)])
- expect_filtered_search_input_empty
-
- expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- expect(page).to have_css('.merge-request', count: 1)
- end
-
- context 'filters by upcoming milestone', :js do
- it 'does not show merge requests with no expiry' do
- create(:merge_request, :with_diffs, source_project: project)
- create(:merge_request, :simple, source_project: project, milestone: milestone)
-
- visit_merge_requests(project)
- input_filtered_search('milestone:upcoming')
-
- expect(page).to have_css('.merge-request', count: 0)
- end
-
- it 'shows merge requests in future' do
- milestone = create(:milestone, project: project, due_date: Date.tomorrow)
- create(:merge_request, :with_diffs, source_project: project)
- create(:merge_request, :simple, source_project: project, milestone: milestone)
-
- visit_merge_requests(project)
- input_filtered_search('milestone:upcoming')
-
- expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- expect(page).to have_css('.merge-request', count: 1)
- end
-
- it 'does not show merge requests in past' do
- milestone = create(:milestone, project: project, due_date: Date.yesterday)
- create(:merge_request, :with_diffs, source_project: project)
- create(:merge_request, :simple, source_project: project, milestone: milestone)
-
- visit_merge_requests(project)
- input_filtered_search('milestone:upcoming')
-
- expect(page).to have_css('.merge-request', count: 0)
- end
- end
-
- scenario 'filters by a specific Milestone', :js do
- create(:merge_request, :with_diffs, source_project: project, milestone: milestone)
- create(:merge_request, :simple, source_project: project)
-
- visit_merge_requests(project)
- input_filtered_search("milestone:%'#{milestone.title}'")
-
- expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- expect(page).to have_css('.merge-request', count: 1)
- end
-
- context 'when milestone has single quotes in title' do
- background do
- milestone.update(name: "rock 'n' roll")
- end
-
- scenario 'filters by a specific Milestone', :js do
- create(:merge_request, :with_diffs, source_project: project, milestone: milestone)
- create(:merge_request, :simple, source_project: project)
-
- visit_merge_requests(project)
- input_filtered_search("milestone:%\"#{milestone.title}\"")
-
- expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- expect(page).to have_css('.merge-request', count: 1)
- end
- end
-end
diff --git a/spec/features/merge_requests/filter_merge_requests_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb
deleted file mode 100644
index aac295ab940..00000000000
--- a/spec/features/merge_requests/filter_merge_requests_spec.rb
+++ /dev/null
@@ -1,337 +0,0 @@
-require 'rails_helper'
-
-describe 'Filter merge requests' do
- include FilteredSearchHelpers
- include MergeRequestHelpers
-
- let!(:project) { create(:project, :repository) }
- let!(:group) { create(:group) }
- let!(:user) { create(:user) }
- let!(:milestone) { create(:milestone, project: project) }
- let!(:label) { create(:label, project: project) }
- let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
-
- before do
- project.add_master(user)
- group.add_developer(user)
- sign_in(user)
- create(:merge_request, source_project: project, target_project: 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
- wait_for_requests
-
- expect_tokens([assignee_token(user.name)])
- expect_filtered_search_input_empty
- end
-
- before do
- input_filtered_search(search_query)
-
- expect_mr_list_count(0)
- end
-
- context 'assignee', :js do
- it 'updates to current user' do
- expect_assignee_visual_tokens()
- end
-
- it 'does not change when closed link is clicked' do
- 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 [data-state="all"]').click
-
- expect_assignee_visual_tokens()
- end
- end
- end
-
- describe 'for milestone from mr#index' do
- let(:search_query) { "milestone:%\"#{milestone.title}\"" }
-
- def expect_milestone_visual_tokens
- expect_tokens([milestone_token("\"#{milestone.title}\"")])
- expect_filtered_search_input_empty
- end
-
- before do
- input_filtered_search(search_query)
-
- expect_mr_list_count(0)
- end
-
- context 'milestone', :js do
- it 'updates to current milestone' do
- expect_milestone_visual_tokens()
- end
-
- it 'does not change when closed link is clicked' do
- 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 [data-state="all"]').click
-
- expect_milestone_visual_tokens()
- end
- end
- end
-
- describe 'for label from mr#index', :js do
- it 'filters by no label' do
- input_filtered_search('label:none')
-
- expect_mr_list_count(1)
- expect_tokens([label_token('none', false)])
- expect_filtered_search_input_empty
- end
-
- it 'filters by a label' do
- input_filtered_search("label:~#{label.title}")
-
- expect_mr_list_count(0)
- expect_tokens([label_token(label.title)])
- expect_filtered_search_input_empty
- end
-
- it "filters by `won't fix` and another label" do
- input_filtered_search("label:~\"#{wontfix.title}\" label:~#{label.title}")
-
- expect_mr_list_count(0)
- expect_tokens([label_token("\"#{wontfix.title}\""), label_token(label.title)])
- expect_filtered_search_input_empty
- end
-
- it "filters by `won't fix` label followed by another label after page load" do
- input_filtered_search("label:~\"#{wontfix.title}\"")
-
- expect_mr_list_count(0)
- 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([label_token("\"#{wontfix.title}\""), label_token(label.title)])
- expect_filtered_search_input_empty
- end
- end
-
- describe 'for assignee and label from mr#index' do
- let(:search_query) { "assignee:@#{user.username} label:~#{label.title}" }
-
- before do
- input_filtered_search(search_query)
-
- expect_mr_list_count(0)
- end
-
- context 'assignee and label', :js do
- def expect_assignee_label_visual_tokens
- wait_for_requests
-
- expect_tokens([assignee_token(user.name), label_token(label.title)])
- expect_filtered_search_input_empty
- end
-
- it 'updates to current assignee and label' do
- expect_assignee_label_visual_tokens()
- end
-
- it 'does not change when closed link is clicked' do
- 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 [data-state="all"]').click
-
- expect_assignee_label_visual_tokens()
- end
- end
- end
-
- describe 'filter merge requests by text' do
- before do
- 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)
-
- mr = create(:merge_request,
- title: "Bug 2",
- source_project: project,
- target_project: project,
- source_branch: "fix",
- milestone: milestone,
- author: user,
- assignee: user)
- mr.labels << bug_label
-
- visit project_merge_requests_path(project)
- end
-
- context 'only text', :js do
- it 'filters merge requests by searched text' do
- input_filtered_search('bug')
-
- expect_mr_list_count(2)
- end
-
- it 'does not show any merge requests' do
- input_filtered_search('testing')
-
- page.within '.mr-list' do
- expect(page).not_to have_selector('.merge-request')
- end
- end
- end
-
- context 'filters and searches', :js do
- it 'filters by text and label' do
- input_filtered_search('Bug')
-
- expect_mr_list_count(2)
- expect_filtered_search_input('Bug')
-
- input_filtered_search_keys(' label:~bug')
-
- expect_mr_list_count(1)
- expect_tokens([label_token('bug')])
- expect_filtered_search_input('Bug')
- end
-
- it 'filters by text and milestone' do
- input_filtered_search('Bug')
-
- expect_mr_list_count(2)
- expect_filtered_search_input('Bug')
-
- input_filtered_search_keys(' milestone:%8')
-
- expect_mr_list_count(1)
- expect_tokens([milestone_token('8')])
- expect_filtered_search_input('Bug')
- end
-
- it 'filters by text and assignee' do
- input_filtered_search('Bug')
-
- expect_mr_list_count(2)
- expect_filtered_search_input('Bug')
-
- input_filtered_search_keys(" assignee:@#{user.username}")
-
- expect_mr_list_count(1)
-
- wait_for_requests
-
- expect_tokens([assignee_token(user.name)])
- expect_filtered_search_input('Bug')
- end
-
- it 'filters by text and author' do
- input_filtered_search('Bug')
-
- expect_mr_list_count(2)
- expect_filtered_search_input('Bug')
-
- input_filtered_search_keys(" author:@#{user.username}")
-
- wait_for_requests
-
- expect_mr_list_count(1)
- expect_tokens([author_token(user.name)])
- expect_filtered_search_input('Bug')
- end
- end
- end
-
- 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: "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 project_merge_requests_path(project)
- end
-
- it 'is able to filter and sort merge requests' do
- input_filtered_search('label:~bug')
-
- expect_mr_list_count(2)
-
- click_button 'Created date'
- page.within '.dropdown-menu-sort' do
- click_link 'Priority'
- end
- wait_for_requests
-
- page.within '.mr-list' do
- expect(page).to have_content('Frontend')
- end
- end
- end
-
- describe 'filter by assignee id', :js do
- it 'filter by current user' do
- visit project_merge_requests_path(project, assignee_id: user.id)
-
- wait_for_requests
-
- expect_tokens([assignee_token(user.name)])
- expect_filtered_search_input_empty
- end
-
- it 'filter by new user' do
- new_user = create(:user)
- project.add_developer(new_user)
-
- visit project_merge_requests_path(project, assignee_id: new_user.id)
-
- wait_for_requests
-
- expect_tokens([assignee_token(new_user.name)])
- expect_filtered_search_input_empty
- end
- end
-
- describe 'filter by author id', :js do
- it 'filter by current user' do
- visit project_merge_requests_path(project, author_id: user.id)
-
- wait_for_requests
-
- expect_tokens([author_token(user.name)])
- expect_filtered_search_input_empty
- end
-
- it 'filter by new user' do
- new_user = create(:user)
- project.add_developer(new_user)
-
- visit project_merge_requests_path(project, author_id: new_user.id)
-
- wait_for_requests
-
- expect_tokens([author_token(new_user.name)])
- expect_filtered_search_input_empty
- end
- end
-end
diff --git a/spec/features/merge_requests/filters_generic_behavior_spec.rb b/spec/features/merge_requests/filters_generic_behavior_spec.rb
new file mode 100644
index 00000000000..0e7fac6b409
--- /dev/null
+++ b/spec/features/merge_requests/filters_generic_behavior_spec.rb
@@ -0,0 +1,81 @@
+require 'rails_helper'
+
+describe 'Merge Requests > Filters generic behavior', :js do
+ include FilteredSearchHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:bug) { create(:label, project: project, title: 'bug') }
+ let(:open_mr) { create(:merge_request, title: 'Bugfix1', source_project: project, target_project: project, source_branch: 'bugfix1') }
+ let(:merged_mr) { create(:merge_request, :merged, title: 'Bugfix2', source_project: project, target_project: project, source_branch: 'bugfix2') }
+ let(:closed_mr) { create(:merge_request, :closed, title: 'Feature', source_project: project, target_project: project, source_branch: 'improve/awesome') }
+
+ before do
+ open_mr.labels << bug
+ merged_mr.labels << bug
+ closed_mr.labels << bug
+
+ sign_in(user)
+ visit project_merge_requests_path(project)
+ end
+
+ context 'when filtered by a label' do
+ before do
+ input_filtered_search('label:~bug')
+ end
+
+ describe 'state tabs' do
+ it 'does not change when state tabs are clicked' do
+ expect(page).to have_issuable_counts(open: 1, merged: 1, closed: 1, all: 3)
+ expect(page).to have_content 'Bugfix1'
+ expect(page).not_to have_content 'Bugfix2'
+ expect(page).not_to have_content 'Feature'
+
+ find('.issues-state-filters [data-state="merged"]').click
+
+ expect(page).to have_issuable_counts(open: 1, merged: 1, closed: 1, all: 3)
+ expect(page).not_to have_content 'Bugfix1'
+ expect(page).to have_content 'Bugfix2'
+ expect(page).not_to have_content 'Feature'
+
+ find('.issues-state-filters [data-state="closed"]').click
+
+ expect(page).to have_issuable_counts(open: 1, merged: 1, closed: 1, all: 3)
+ expect(page).not_to have_content 'Bugfix1'
+ expect(page).not_to have_content 'Bugfix2'
+ expect(page).to have_content 'Feature'
+
+ find('.issues-state-filters [data-state="all"]').click
+
+ expect(page).to have_issuable_counts(open: 1, merged: 1, closed: 1, all: 3)
+ expect(page).to have_content 'Bugfix1'
+ expect(page).to have_content 'Bugfix2'
+ expect(page).to have_content 'Feature'
+ end
+ end
+
+ describe 'clear button' do
+ it 'allows user to remove filtered labels' do
+ first('.clear-search').click
+ filtered_search.send_keys(:enter)
+
+ expect(page).to have_issuable_counts(open: 1, merged: 1, closed: 1, all: 3)
+ expect(page).to have_content 'Bugfix1'
+ expect(page).not_to have_content 'Bugfix2'
+ expect(page).not_to have_content 'Feature'
+ end
+ end
+ end
+
+ context 'filter dropdown' do
+ it 'filters by label name' do
+ init_label_search
+ filtered_search.send_keys('~bug')
+
+ page.within '.filter-dropdown' do
+ expect(page).not_to have_content 'enhancement'
+ expect(page).to have_content 'bug'
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb
deleted file mode 100644
index 1dcc1e139a0..00000000000
--- a/spec/features/merge_requests/form_spec.rb
+++ /dev/null
@@ -1,301 +0,0 @@
-require 'rails_helper'
-
-describe 'New/edit merge request', :js do
- include ProjectForksHelper
-
- let!(:project) { create(:project, :public, :repository) }
- let(:forked_project) { fork_project(project, nil, repository: true) }
- 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]
- project.team << [user2, :master]
- end
-
- context 'owned projects' do
- before do
- sign_in(user)
- end
-
- context 'new merge request' do
- before do
- 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'
- })
- end
-
- it 'creates new merge request' do
- click_button 'Assignee'
- page.within '.dropdown-menu-user' do
- click_link user2.name
- end
- expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user2.id.to_s)
- page.within '.js-assignee-search' do
- expect(page).to have_content user2.name
- end
-
- find('a', text: 'Assign to me').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
- end
-
- click_button 'Milestone'
- page.within '.issue-milestone' do
- click_link milestone.title
- end
- expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
- page.within '.js-milestone-select' do
- expect(page).to have_content milestone.title
- end
-
- click_button 'Labels'
- page.within '.dropdown-menu-labels' do
- click_link label.title
- click_link label2.title
- end
- page.within '.js-label-select' do
- expect(page).to have_content label.title
- end
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
-
- click_button 'Submit merge request'
-
- page.within '.issuable-sidebar' do
- page.within '.assignee' do
- expect(page).to have_content user.name
- end
-
- page.within '.milestone' do
- expect(page).to have_content milestone.title
- end
-
- page.within '.labels' do
- expect(page).to have_content label.title
- expect(page).to have_content label2.title
- end
- end
-
- page.within '.breadcrumbs' do
- merge_request = MergeRequest.find_by(source_branch: 'fix')
-
- expect(page).to have_text("Merge Requests #{merge_request.to_reference}")
- 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
- before do
- merge_request = create(:merge_request,
- source_project: project,
- target_project: project,
- source_branch: 'fix',
- target_branch: 'master'
- )
-
- visit edit_project_merge_request_path(project, merge_request)
- end
-
- it 'updates merge request' do
- click_button 'Assignee'
- page.within '.dropdown-menu-user' do
- click_link user.name
- end
- 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
- end
-
- click_button 'Milestone'
- page.within '.issue-milestone' do
- click_link milestone.title
- end
- expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
- page.within '.js-milestone-select' do
- expect(page).to have_content milestone.title
- end
-
- click_button 'Labels'
- page.within '.dropdown-menu-labels' do
- click_link label.title
- click_link label2.title
- end
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
- page.within '.js-label-select' do
- expect(page).to have_content label.title
- end
-
- click_button 'Save changes'
-
- page.within '.issuable-sidebar' do
- page.within '.assignee' do
- expect(page).to have_content user.name
- end
-
- page.within '.milestone' do
- expect(page).to have_content milestone.title
- end
-
- page.within '.labels' do
- expect(page).to have_content label.title
- expect(page).to have_content label2.title
- 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
- forked_project.team << [user, :master]
- sign_in(user)
- end
-
- context 'new merge request' do
- before do
- visit project_new_merge_request_path(
- forked_project,
- merge_request: {
- source_project_id: forked_project.id,
- target_project_id: project.id,
- source_branch: 'fix',
- target_branch: 'master'
- })
- end
-
- it 'creates new merge request' do
- click_button 'Assignee'
- page.within '.dropdown-menu-user' do
- click_link user.name
- end
- 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
- end
-
- click_button 'Milestone'
- page.within '.issue-milestone' do
- click_link milestone.title
- end
- expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
- page.within '.js-milestone-select' do
- expect(page).to have_content milestone.title
- end
-
- click_button 'Labels'
- page.within '.dropdown-menu-labels' do
- click_link label.title
- click_link label2.title
- end
- page.within '.js-label-select' do
- expect(page).to have_content label.title
- end
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
-
- click_button 'Submit merge request'
-
- page.within '.issuable-sidebar' do
- page.within '.assignee' do
- expect(page).to have_content user.name
- end
-
- page.within '.milestone' do
- expect(page).to have_content milestone.title
- end
-
- page.within '.labels' do
- expect(page).to have_content label.title
- expect(page).to have_content label2.title
- end
- end
- end
- end
-
- context 'edit merge request' do
- before do
- merge_request = create(:merge_request,
- source_project: forked_project,
- target_project: project,
- source_branch: 'fix',
- target_branch: 'master'
- )
-
- visit edit_project_merge_request_path(project, merge_request)
- end
-
- it 'should update merge request' do
- click_button 'Assignee'
- page.within '.dropdown-menu-user' do
- click_link user.name
- end
- 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
- end
-
- click_button 'Milestone'
- page.within '.issue-milestone' do
- click_link milestone.title
- end
- expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
- page.within '.js-milestone-select' do
- expect(page).to have_content milestone.title
- end
-
- click_button 'Labels'
- page.within '.dropdown-menu-labels' do
- click_link label.title
- click_link label2.title
- end
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
- expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
- page.within '.js-label-select' do
- expect(page).to have_content label.title
- end
-
- click_button 'Save changes'
-
- page.within '.issuable-sidebar' do
- page.within '.assignee' do
- expect(page).to have_content user.name
- end
-
- page.within '.milestone' do
- expect(page).to have_content milestone.title
- end
-
- page.within '.labels' do
- expect(page).to have_content label.title
- expect(page).to have_content label2.title
- end
- end
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/image_diff_notes_spec.rb b/spec/features/merge_requests/image_diff_notes_spec.rb
deleted file mode 100644
index b53570835cb..00000000000
--- a/spec/features/merge_requests/image_diff_notes_spec.rb
+++ /dev/null
@@ -1,225 +0,0 @@
-require 'spec_helper'
-
-feature 'image diff notes', :js do
- include NoteInteractionHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
-
- before do
- project.team << [user, :master]
- sign_in user
-
- # Stub helper to return any blob file as image from public app folder.
- # This is necessary to run this specs since we don't display repo images in capybara.
- allow_any_instance_of(DiffHelper).to receive(:diff_file_blob_raw_url).and_return('/apple-touch-icon.png')
- allow_any_instance_of(DiffHelper).to receive(:diff_file_old_blob_raw_url).and_return('/favicon.ico')
- end
-
- context 'create commit diff notes' do
- commit_id = '2f63565e7aac07bcdadb654e253078b727143ec4'
-
- describe 'create a new diff note' do
- before do
- visit project_commit_path(project, commit_id)
- create_image_diff_note
- end
-
- it 'shows indicator badge on image diff' do
- indicator = find('.js-image-badge')
-
- expect(indicator).to have_content('1')
- end
-
- it 'shows the avatar badge on the new note' do
- badge = find('.image-diff-avatar-link .badge')
-
- expect(badge).to have_content('1')
- end
-
- it 'allows collapsing/expanding the discussion notes' do
- find('.js-diff-notes-toggle', :first).click
-
- expect(page).not_to have_content('image diff test comment')
-
- find('.js-diff-notes-toggle').click
-
- expect(page).to have_content('image diff test comment')
- end
- end
-
- describe 'render commit diff notes' do
- let(:path) { "files/images/6049019_460s.jpg" }
- let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') }
-
- let(:note1_position) do
- Gitlab::Diff::Position.new(
- old_path: path,
- new_path: path,
- width: 100,
- height: 100,
- x: 10,
- y: 10,
- position_type: "image",
- diff_refs: commit.diff_refs
- )
- end
-
- let(:note2_position) do
- Gitlab::Diff::Position.new(
- old_path: path,
- new_path: path,
- width: 100,
- height: 100,
- x: 20,
- y: 20,
- position_type: "image",
- diff_refs: commit.diff_refs
- )
- end
-
- let!(:note1) { create(:diff_note_on_commit, commit_id: commit.id, project: project, position: note1_position, note: 'my note 1') }
- let!(:note2) { create(:diff_note_on_commit, commit_id: commit.id, project: project, position: note2_position, note: 'my note 2') }
-
- before do
- visit project_commit_path(project, commit.id)
- wait_for_requests
- end
-
- it 'render diff indicators within the image diff frame' do
- expect(page).to have_css('.js-image-badge', count: 2)
- end
-
- it 'shows the diff notes' do
- expect(page).to have_css('.diff-content .note', count: 2)
- end
-
- it 'shows the diff notes with correct avatar badge numbers' do
- expect(page).to have_css('.image-diff-avatar-link', text: 1)
- expect(page).to have_css('.image-diff-avatar-link', text: 2)
- end
- end
- end
-
- %w(inline parallel).each do |view|
- context "#{view} view" do
- let(:merge_request) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, author: user) }
- let(:path) { "files/images/ee_repo_logo.png" }
-
- let(:position) do
- Gitlab::Diff::Position.new(
- old_path: path,
- new_path: path,
- width: 100,
- height: 100,
- x: 1,
- y: 1,
- position_type: "image",
- diff_refs: merge_request.diff_refs
- )
- end
-
- let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) }
-
- describe 'creating a new diff note' do
- before do
- visit diffs_project_merge_request_path(project, merge_request, view: view)
- create_image_diff_note
- end
-
- it 'shows indicator badge on image diff' do
- indicator = find('.js-image-badge', match: :first)
-
- expect(indicator).to have_content('1')
- end
-
- it 'shows the avatar badge on the new note' do
- badge = find('.image-diff-avatar-link .badge', match: :first)
-
- expect(badge).to have_content('1')
- end
-
- it 'allows expanding/collapsing the discussion notes' do
- page.all('.js-diff-notes-toggle')[0].click
- page.all('.js-diff-notes-toggle')[1].click
-
- expect(page).not_to have_content('image diff test comment')
-
- page.all('.js-diff-notes-toggle')[0].click
- page.all('.js-diff-notes-toggle')[1].click
-
- expect(page).to have_content('image diff test comment')
- end
- end
- end
- end
-
- describe 'discussion tab polling', :js do
- let(:merge_request) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, author: user) }
- let(:path) { "files/images/ee_repo_logo.png" }
-
- let(:position) do
- Gitlab::Diff::Position.new(
- old_path: path,
- new_path: path,
- width: 100,
- height: 100,
- x: 50,
- y: 50,
- position_type: "image",
- diff_refs: merge_request.diff_refs
- )
- end
-
- before do
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'render diff indicators within the image frame' do
- diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position)
-
- wait_for_requests
-
- expect(page).to have_selector('.image-comment-badge')
- expect(page).to have_content(diff_note.note)
- end
- end
-
- describe 'image view modes' do
- before do
- visit project_commit_path(project, '2f63565e7aac07bcdadb654e253078b727143ec4')
- end
-
- it 'resizes image in onion skin view mode' do
- find('.view-modes-menu .onion-skin').click
-
- expect(find('.onion-skin-frame')['style']).to match('width: 228px; height: 240px;')
- end
-
- it 'resets onion skin view mode opacity when toggling between view modes' do
- find('.view-modes-menu .onion-skin').click
-
- # Simulate dragging onion-skin slider
- drag_and_drop_by(find('.dragger'), -30, 0)
-
- expect(find('.onion-skin-frame .frame.added', visible: false)['style']).not_to match('opacity: 1;')
-
- find('.view-modes-menu .swipe').click
- find('.view-modes-menu .onion-skin').click
-
- expect(find('.onion-skin-frame .frame.added', visible: false)['style']).to match('opacity: 1;')
- end
- end
-
- def drag_and_drop_by(element, right_by, down_by)
- page.driver.browser.action.drag_and_drop_by(element.native, right_by, down_by).perform
- end
-
- def create_image_diff_note
- find('.js-add-image-diff-note-button', match: :first).click
- page.all('.js-add-image-diff-note-button')[0].click
- find('.diff-content .note-textarea').native.send_keys('image diff test comment')
- click_button 'Comment'
- wait_for_requests
- end
-end
diff --git a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb
deleted file mode 100644
index 82b2b56ef80..00000000000
--- a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-require 'spec_helper'
-
-feature 'Clicking toggle commit message link', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:issue_1) { create(:issue, project: project)}
- let(:issue_2) { create(:issue, project: project)}
- let(:merge_request) do
- create(
- :merge_request,
- :simple,
- source_project: project,
- description: "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}"
- )
- end
- let(:textbox) { page.find(:css, '.js-commit-message', visible: false) }
- let(:default_message) do
- [
- "Merge branch 'feature' into 'master'",
- merge_request.title,
- "Closes #{issue_1.to_reference} and #{issue_2.to_reference}",
- "See merge request #{merge_request.to_reference(full: true)}"
- ].join("\n\n")
- end
- let(:message_with_description) do
- [
- "Merge branch 'feature' into 'master'",
- merge_request.title,
- merge_request.description,
- "See merge request #{merge_request.to_reference(full: true)}"
- ].join("\n\n")
- end
-
- before do
- project.team << [user, :master]
-
- sign_in user
-
- visit project_merge_request_path(project, merge_request)
-
- expect(page).not_to have_selector('.js-commit-message')
- click_button "Modify commit message"
- expect(textbox).to be_visible
- end
-
- it "toggles commit message between message with description and without description " do
- expect(textbox.value).to eq(default_message)
-
- click_link "Include description in commit message"
-
- expect(textbox.value).to eq(message_with_description)
-
- click_link "Don't include description in commit message"
-
- expect(textbox.value).to eq(default_message)
- 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
deleted file mode 100644
index 0b5a595acce..00000000000
--- a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge immediately', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
-
- let!(:merge_request) do
- create(:merge_request_with_diffs, source_project: project,
- author: user,
- title: 'Bug NS-04',
- head_pipeline: pipeline,
- source_branch: pipeline.ref)
- end
-
- let(:pipeline) do
- create(:ci_pipeline, project: project,
- ref: 'master',
- sha: project.repository.commit('master').id)
- end
-
- before do
- project.team << [user, :master]
- end
-
- context 'when there is active pipeline for merge request' do
- background do
- create(:ci_build, pipeline: pipeline)
- end
-
- before do
- 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
-
- Sidekiq::Testing.fake! do
- click_link 'Merge immediately'
-
- expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress')
-
- wait_for_requests
- end
- 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
deleted file mode 100644
index ac46cc1f0e4..00000000000
--- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb
+++ /dev/null
@@ -1,160 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge When Pipeline Succeeds', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
-
- let(:merge_request) do
- create(:merge_request_with_diffs, source_project: project,
- author: user,
- 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,
- head_pipeline_of: merge_request)
- end
-
- before do
- project.add_master(user)
- end
-
- context 'when there is active pipeline for merge request' do
- background do
- create(:ci_build, pipeline: pipeline)
- end
-
- before do
- sign_in user
- visit_merge_request(merge_request)
- end
-
- it 'displays the Merge when pipeline succeeds button' do
- expect(page).to have_button "Merge when pipeline succeeds"
- end
-
- describe 'enabling Merge when pipeline succeeds' do
- shared_examples 'Merge when pipeline succeeds activator' 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_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
- end
-
- context "when enabled immediately" do
- it_behaves_like 'Merge when pipeline succeeds activator'
- end
-
- context 'when enabled after pipeline status changed' do
- before do
- pipeline.run!
-
- # We depend on merge request widget being reloaded
- # so we have to wait for asynchronous call to reload it
- # and have_content expectation handles that.
- #
- expect(page).to have_content "Pipeline ##{pipeline.id} running"
- end
-
- it_behaves_like 'Merge when pipeline succeeds activator'
- end
-
- context 'when enabled after it was previously canceled' do
- before do
- click_button "Merge when pipeline succeeds"
- click_link "Cancel automatic merge"
- end
-
- it_behaves_like 'Merge when pipeline succeeds activator'
- end
-
- context 'when it was enabled and then canceled' do
- let(:merge_request) do
- create(:merge_request_with_diffs,
- :merge_when_pipeline_succeeds,
- source_project: project,
- title: 'Bug NS-04',
- author: user,
- merge_user: user,
- merge_params: { force_remove_source_branch: '1' })
- end
-
- before do
- click_link "Cancel automatic merge"
- end
-
- it_behaves_like 'Merge when pipeline succeeds activator'
- end
- end
-
- describe 'enabling Merge when pipeline succeeds via dropdown' do
- it 'activates the Merge when pipeline succeeds feature' do
- 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_link "Cancel automatic merge"
- end
- end
- end
-
- context 'when merge when pipeline succeeds is enabled' do
- let(:merge_request) do
- create(:merge_request_with_diffs, :simple, source_project: project,
- author: user,
- merge_user: user,
- title: 'MepMep',
- merge_when_pipeline_succeeds: true)
- end
-
- let!(:build) do
- create(:ci_build, pipeline: pipeline)
- end
-
- before do
- sign_in user
- visit_merge_request(merge_request)
- end
-
- it 'allows to cancel the automatic merge' do
- click_link "Cancel automatic merge"
-
- expect(page).to have_button "Merge when pipeline succeeds"
-
- visit_merge_request(merge_request) # refresh the page
- expect(page).to have_content "canceled the automatic merge"
- end
-
- context 'when pipeline succeeds' do
- background { build.success }
-
- it 'merges merge request' do
- visit_merge_request(merge_request) # refresh the page
-
- expect(page).to have_content 'The changes were merged'
- expect(merge_request.reload).to be_merged
- end
- end
- end
-
- context 'when pipeline is not active' do
- it "does not allow to enable merge when pipeline succeeds" do
- visit_merge_request(merge_request)
-
- expect(page).not_to have_link 'Merge when pipeline succeeds'
- end
- end
-
- def visit_merge_request(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
deleted file mode 100644
index a7e7c0eeff6..00000000000
--- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-require 'rails_helper'
-
-feature 'Mini Pipeline Graph', :js do
- let(:user) { create(:user) }
- 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') }
-
- before do
- build.run
-
- sign_in(user)
- visit_merge_request
- end
-
- def visit_merge_request(format: :html, serializer: nil)
- visit project_merge_request_path(project, merge_request, format: format, serializer: serializer)
- 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, legacy_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(format: :json, serializer: 'widget') }
-
- create(:ci_build, pipeline: pipeline, legacy_artifacts_file: artifacts_file2)
- create(:ci_build, pipeline: pipeline, when: 'manual')
-
- after = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') }
-
- 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')
- first('.mini-pipeline-graph-dropdown-toggle')
- end
-
- it 'should expand when hovered' do
- find('.mini-pipeline-graph-dropdown-toggle')
- before_width = evaluate_script("$('.mini-pipeline-graph-dropdown-toggle:visible').outerWidth();")
-
- toggle.hover
-
- find('.mini-pipeline-graph-dropdown-toggle')
- after_width = evaluate_script("$('.mini-pipeline-graph-dropdown-toggle:visible').outerWidth();")
-
- expect(before_width).to be < after_width
- end
-
- it 'should show dropdown caret when hovered' do
- toggle.hover
-
- expect(toggle).to have_selector('.fa-caret-down')
- end
-
- it 'should show tooltip when hovered' do
- toggle.hover
-
- expect(page).to have_selector('.tooltip')
- end
- end
-
- describe 'builds list menu' do
- let(:toggle) do
- find('.mini-pipeline-graph-dropdown-toggle')
- first('.mini-pipeline-graph-dropdown-toggle')
- end
-
- before do
- toggle.click
- wait_for_requests
- end
-
- it 'should open when toggle is clicked' do
- expect(toggle.find(:xpath, '..')).to have_selector('.mini-pipeline-graph-dropdown-menu')
- end
-
- it 'should close when toggle is clicked again' do
- toggle.click
-
- expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
- end
-
- it 'should close when clicking somewhere else' do
- find('body').click
-
- expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
- end
-
- describe 'build list build item' do
- let(:build_item) do
- find('.mini-pipeline-graph-dropdown-item')
- first('.mini-pipeline-graph-dropdown-item')
- end
-
- it 'should visit the build page when clicked' do
- build_item.click
- find('.build-page')
-
- expect(current_path).to eql(project_job_path(project, build))
- end
-
- it 'should show tooltip when hovered' do
- build_item.hover
-
- expect(page).to have_selector('.tooltip')
- end
- end
- end
-end
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
deleted file mode 100644
index 91f207bd339..00000000000
--- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb
+++ /dev/null
@@ -1,150 +0,0 @@
-require 'spec_helper'
-
-feature 'Only allow merge requests to be merged if the pipeline succeeds', :js do
- let(:merge_request) { create(:merge_request_with_diffs) }
- let(:project) { merge_request.target_project }
-
- before do
- sign_in merge_request.author
-
- project.team << [merge_request.author, :master]
- end
-
- context 'project does not have CI enabled', :js do
- it 'allows MR to be merged' do
- visit_merge_request(merge_request)
-
- wait_for_requests
-
- expect(page).to have_button 'Merge'
- end
- end
-
- context 'when project has CI enabled', :js do
- given!(:pipeline) do
- create(:ci_empty_pipeline,
- project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch,
- status: status, head_pipeline_of: merge_request)
- end
-
- context 'when merge requests can only be merged if the pipeline succeeds' do
- before do
- project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true)
- end
-
- context 'when CI is running' do
- given(:status) { :running }
-
- 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 '.js-merge-moment'
- end
- end
-
- context 'when CI failed' do
- given(:status) { :failed }
-
- it 'does not allow MR to be merged' do
- visit_merge_request(merge_request)
-
- 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
-
- context 'when CI canceled' do
- given(:status) { :canceled }
-
- it 'does not allow MR to be merged' do
- visit_merge_request(merge_request)
-
- 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
-
- context 'when CI succeeded' do
- given(:status) { :success }
-
- it 'allows MR to be merged' do
- visit_merge_request(merge_request)
-
- wait_for_requests
-
- expect(page).to have_button 'Merge'
- end
- end
-
- context 'when CI skipped' do
- given(:status) { :skipped }
-
- it 'allows MR to be merged' do
- visit_merge_request(merge_request)
-
- wait_for_requests
-
- expect(page).to have_button 'Merge'
- end
- end
- end
-
- context 'when merge requests can be merged when the build failed' do
- before do
- project.update_attribute(:only_allow_merge_if_pipeline_succeeds, false)
- end
-
- context 'when CI is running' do
- given(:status) { :running }
-
- 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'
-
- page.find('.js-merge-moment').click
- expect(page).to have_content 'Merge immediately'
- end
- end
-
- context 'when CI failed' do
- given(:status) { :failed }
-
- it 'allows MR to be merged' do
- visit_merge_request(merge_request)
-
- wait_for_requests
-
- expect(page).to have_button 'Merge'
- end
- end
-
- context 'when CI succeeded' do
- given(:status) { :success }
-
- it 'allows MR to be merged' do
- visit_merge_request(merge_request)
-
- wait_for_requests
-
- expect(page).to have_button 'Merge'
- end
- end
- end
- end
-
- def visit_merge_request(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
deleted file mode 100644
index 307c860eac4..00000000000
--- a/spec/features/merge_requests/pipelines_spec.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-require 'spec_helper'
-
-feature 'Pipelines for Merge Requests', :js do
- describe 'pipeline tab' do
- given(:user) { create(:user) }
- given(:merge_request) { create(:merge_request) }
- given(:project) { merge_request.target_project }
-
- before do
- project.team << [user, :master]
- sign_in user
- end
-
- 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
- merge_request.update_attribute(:head_pipeline_id, pipeline.id)
- end
-
- scenario 'user visits merge request pipelines tab' do
- visit project_merge_request_path(project, merge_request)
-
- expect(page.find('.ci-widget')).to have_content('pending')
-
- page.within('.merge-request-tabs') do
- click_link('Pipelines')
- end
- wait_for_requests
-
- expect(page).to have_selector('.stage-cell')
- end
-
- scenario 'pipeline sha does not equal last commit sha' do
- pipeline.update_attribute(:sha, '19e2e9b4ef76b422ce1154af39a91323ccc57434')
- visit project_merge_request_path(project, merge_request)
- wait_for_requests
-
- expect(page.find('.ci-widget')).to have_content(
- 'Could not connect to the CI server. Please check your settings and try again')
- end
- end
-
- context 'without pipelines' do
- before do
- visit project_merge_request_path(project, merge_request)
- end
-
- scenario 'user visits merge request page' do
- page.within('.merge-request-tabs') do
- expect(page).to have_no_link('Pipelines')
- end
- end
- end
- end
-
- 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
-
- 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
-end
diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb
deleted file mode 100644
index eed95816bdf..00000000000
--- a/spec/features/merge_requests/reset_filters_spec.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-require 'rails_helper'
-
-feature 'Merge requests filter clear button', :js do
- include FilteredSearchHelpers
- include MergeRequestHelpers
- include IssueHelpers
-
- 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: "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' }
-
- before do
- mr2.labels << bug
- project.team << [user, :developer]
- end
-
- context 'when a milestone filter has been applied' do
- it 'resets the milestone filter' do
- visit_merge_requests(project, milestone_title: milestone.title)
-
- expect(page).to have_css(merge_request_css, count: 1)
- expect(get_filtered_search_placeholder).to eq('')
-
- reset_filters
-
- expect(page).to have_css(merge_request_css, count: 2)
- expect(get_filtered_search_placeholder).to eq(default_placeholder)
- end
- end
-
- context 'when a label filter has been applied' do
- it 'resets the label filter' do
- visit_merge_requests(project, label_name: bug.name)
-
- expect(page).to have_css(merge_request_css, count: 1)
- expect(get_filtered_search_placeholder).to eq('')
-
- reset_filters
-
- expect(page).to have_css(merge_request_css, count: 2)
- expect(get_filtered_search_placeholder).to eq(default_placeholder)
- end
- end
-
- context 'when multiple label filters have been applied' do
- let!(:label) { create(:label, project: project, name: 'Frontend') }
- let(:filter_dropdown) { find("#js-dropdown-label .filter-dropdown") }
-
- before do
- visit_merge_requests(project)
- init_label_search
- end
-
- it 'filters bug label' do
- filtered_search.set('~bug')
-
- filter_dropdown.find('.filter-dropdown-item', text: bug.title).click
- init_label_search
-
- expect(filter_dropdown.find('.filter-dropdown-item', text: bug.title)).to be_visible
- expect(filter_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible
- end
- end
-
- context 'when a text search has been conducted' do
- it 'resets the text search filter' do
- visit_merge_requests(project, search: 'Bug')
-
- expect(page).to have_css(merge_request_css, count: 1)
- expect(get_filtered_search_placeholder).to eq('')
-
- reset_filters
-
- expect(page).to have_css(merge_request_css, count: 2)
- expect(get_filtered_search_placeholder).to eq(default_placeholder)
- end
- end
-
- context 'when author filter has been applied' do
- it 'resets the author filter' do
- visit_merge_requests(project, author_username: user.username)
-
- expect(page).to have_css(merge_request_css, count: 1)
- expect(get_filtered_search_placeholder).to eq('')
-
- reset_filters
-
- expect(page).to have_css(merge_request_css, count: 2)
- expect(get_filtered_search_placeholder).to eq(default_placeholder)
- end
- end
-
- context 'when assignee filter has been applied' do
- it 'resets the assignee filter' do
- visit_merge_requests(project, assignee_username: user.username)
-
- expect(page).to have_css(merge_request_css, count: 1)
- expect(get_filtered_search_placeholder).to eq('')
-
- reset_filters
-
- expect(page).to have_css(merge_request_css, count: 2)
- expect(get_filtered_search_placeholder).to eq(default_placeholder)
- end
- end
-
- context 'when all filters have been applied' do
- it 'clears all filters' do
- visit_merge_requests(project, assignee_username: user.username, author_username: user.username, milestone_title: milestone.title, label_name: bug.name, search: 'Bug')
-
- expect(page).to have_css(merge_request_css, count: 0)
- expect(get_filtered_search_placeholder).to eq('')
-
- reset_filters
-
- expect(page).to have_css(merge_request_css, count: 2)
- expect(get_filtered_search_placeholder).to eq(default_placeholder)
- end
- end
-
- context 'when no filters have been applied' do
- it 'the clear button should not be visible' do
- visit_merge_requests(project)
-
- expect(page).to have_css(merge_request_css, count: 2)
- expect(get_filtered_search_placeholder).to eq(default_placeholder)
- expect(page).not_to have_css(clear_search_css)
- end
- end
-end
diff --git a/spec/features/merge_requests/resolve_outdated_diff_discussions.rb b/spec/features/merge_requests/resolve_outdated_diff_discussions.rb
deleted file mode 100644
index 25abbb469ab..00000000000
--- a/spec/features/merge_requests/resolve_outdated_diff_discussions.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-require 'spec_helper'
-
-feature 'Resolve outdated diff discussions', :js do
- let(:project) { create(:project, :repository, :public) }
-
- let(:merge_request) do
- create(:merge_request, source_project: project, source_branch: 'csv', target_branch: 'master')
- end
-
- let(:outdated_diff_refs) { project.commit('926c6595b263b2a40da6b17f3e3b7ea08344fad6').diff_refs }
- let(:current_diff_refs) { merge_request.diff_refs }
-
- let(:outdated_position) do
- Gitlab::Diff::Position.new(
- old_path: 'files/csv/Book1.csv',
- new_path: 'files/csv/Book1.csv',
- old_line: nil,
- new_line: 9,
- diff_refs: outdated_diff_refs
- )
- end
-
- let(:current_position) do
- Gitlab::Diff::Position.new(
- old_path: 'files/csv/Book1.csv',
- new_path: 'files/csv/Book1.csv',
- old_line: nil,
- new_line: 1,
- diff_refs: current_diff_refs
- )
- end
-
- let!(:outdated_discussion) do
- create(:diff_note_on_merge_request,
- project: project,
- noteable: merge_request,
- position: outdated_position).to_discussion
- end
-
- let!(:current_discussion) do
- create(:diff_note_on_merge_request,
- noteable: merge_request,
- project: project,
- position: current_position).to_discussion
- end
-
- before do
- sign_in(merge_request.author)
- end
-
- context 'when a discussion was resolved by a push' do
- before do
- project.update!(resolve_outdated_diff_discussions: true)
-
- merge_request.update_diff_discussion_positions(
- old_diff_refs: outdated_diff_refs,
- new_diff_refs: current_diff_refs,
- current_user: merge_request.author
- )
-
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'shows that as automatically resolved' do
- within(".discussion[data-discussion-id='#{outdated_discussion.id}']") do
- expect(page).to have_css('.discussion-body', visible: false)
- expect(page).to have_content('Automatically resolved')
- end
- end
-
- it 'does not show that for active discussions' do
- within(".discussion[data-discussion-id='#{current_discussion.id}']") do
- expect(page).to have_css('.discussion-body', visible: true)
- expect(page).not_to have_content('Automatically resolved')
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/target_branch_spec.rb b/spec/features/merge_requests/target_branch_spec.rb
deleted file mode 100644
index bce36e05e57..00000000000
--- a/spec/features/merge_requests/target_branch_spec.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-require 'spec_helper'
-
-describe 'Target branch', :js do
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request) }
- let(:project) { merge_request.project }
-
- def path_to_merge_request
- project_merge_request_path(project, merge_request)
- end
-
- before do
- sign_in user
- project.team << [user, :master]
- end
-
- context 'when branch was deleted' do
- before do
- DeleteBranchService.new(project, user).execute('feature')
- visit path_to_merge_request
- end
-
- it 'shows a message about missing target branch' do
- expect(page).to have_content(
- 'Target branch does not exist'
- )
- end
-
- it 'does not show link to target branch' do
- 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
deleted file mode 100644
index fa3d988b27a..00000000000
--- a/spec/features/merge_requests/toggle_whitespace_changes_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require 'spec_helper'
-
-feature 'Toggle Whitespace Changes', :js do
- before do
- sign_in(create(:admin))
- merge_request = create(:merge_request)
- project = merge_request.source_project
- visit diffs_project_merge_request_path(project, merge_request)
- end
-
- it 'has a button to toggle whitespace changes' do
- expect(page).to have_content 'Hide whitespace changes'
- end
-
- describe 'clicking "Hide whitespace changes" button' do
- it 'toggles the "Hide whitespace changes" button' do
- click_link 'Hide whitespace changes'
-
- expect(page).to have_content 'Show whitespace changes'
- end
- end
-end
diff --git a/spec/features/merge_requests/toggler_behavior_spec.rb b/spec/features/merge_requests/toggler_behavior_spec.rb
deleted file mode 100644
index cd92ad22267..00000000000
--- a/spec/features/merge_requests/toggler_behavior_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-require 'spec_helper'
-
-feature 'toggler_behavior', :js do
- let(:user) { create(:user) }
- 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
- sign_in(create(:admin))
- project = merge_request.source_project
- page.current_window.resize_to(1000, 300)
- visit "#{project_merge_request_path(project, merge_request)}#{fragment_id}"
- end
-
- describe 'scroll position' do
- it 'should be scrolled down to fragment' do
- page_height = page.current_window.size[1]
- page_scroll_y = page.evaluate_script("window.scrollY")
- fragment_position_top = page.evaluate_script("Math.round($('#{fragment_id}').offset().top)")
- expect(find('.js-toggle-content').visible?).to eq true
- expect(find(fragment_id).visible?).to eq true
- expect(fragment_position_top).to be >= page_scroll_y
- expect(fragment_position_top).to be < (page_scroll_y + page_height)
- end
- end
-end
diff --git a/spec/features/merge_requests/update_merge_requests_spec.rb b/spec/features/merge_requests/update_merge_requests_spec.rb
deleted file mode 100644
index c5498563b39..00000000000
--- a/spec/features/merge_requests/update_merge_requests_spec.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-require 'rails_helper'
-
-feature 'Multiple merge requests updating from merge_requests#index' do
- let!(:user) { create(:user)}
- let!(:project) { create(:project, :repository) }
- let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
-
- before do
- project.team << [user, :master]
- sign_in(user)
- end
-
- context 'status', :js do
- describe 'close merge request' do
- before do
- visit project_merge_requests_path(project)
- end
-
- it 'closes merge request' do
- change_status('Closed')
-
- expect(page).to have_selector('.merge-request', count: 0)
- end
- end
-
- describe 'reopen merge request' do
- before do
- merge_request.close
- visit project_merge_requests_path(project, state: 'closed')
- end
-
- it 'reopens merge request' do
- change_status('Open')
-
- expect(page).to have_selector('.merge-request', count: 0)
- end
- end
- end
-
- context 'assignee', :js do
- describe 'set assignee' do
- before do
- visit project_merge_requests_path(project)
- end
-
- it "updates merge request with assignee" do
- change_assignee(user.name)
-
- page.within('.merge-request .controls') do
- expect(find('.author_link')["title"]).to have_content(user.name)
- end
- end
- end
-
- describe 'remove assignee' do
- before do
- merge_request.assignee = user
- merge_request.save
- visit project_merge_requests_path(project)
- end
-
- it "removes assignee from the merge request" do
- change_assignee('Unassigned')
-
- expect(find('.merge-request .controls')).not_to have_css('.author_link')
- end
- end
- end
-
- context 'milestone', :js do
- let(:milestone) { create(:milestone, project: project) }
-
- describe 'set milestone' do
- before do
- visit project_merge_requests_path(project)
- end
-
- it "updates merge request with milestone" do
- change_milestone(milestone.title)
-
- expect(find('.merge-request')).to have_content milestone.title
- end
- end
-
- describe 'unset milestone' do
- before do
- merge_request.milestone = milestone
- merge_request.save
- visit project_merge_requests_path(project)
- end
-
- it "removes milestone from the merge request" do
- change_milestone("No Milestone")
-
- expect(find('.merge-request')).not_to have_content milestone.title
- end
- end
- end
-
- def change_status(text)
- 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)
- click_button 'Edit merge requests'
- find('#check-all-issues').click
- find('.js-update-assignee').click
- wait_for_requests
-
- page.within '.dropdown-menu-user' do
- click_link text
- end
-
- click_update_merge_requests_button
- end
-
- def change_milestone(text)
- 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_requests
- end
-end
diff --git a/spec/features/merge_requests/user_filters_by_assignees_spec.rb b/spec/features/merge_requests/user_filters_by_assignees_spec.rb
new file mode 100644
index 00000000000..d6c770c93f1
--- /dev/null
+++ b/spec/features/merge_requests/user_filters_by_assignees_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+describe 'Merge Requests > User filters by assignees', :js do
+ include FilteredSearchHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+
+ before do
+ create(:merge_request, assignee: user, title: 'Bugfix1', source_project: project, target_project: project, source_branch: 'bugfix1')
+ create(:merge_request, title: 'Bugfix2', source_project: project, target_project: project, source_branch: 'bugfix2')
+
+ sign_in(user)
+ visit project_merge_requests_path(project)
+ end
+
+ context 'filtering by assignee:none' do
+ it 'applies the filter' do
+ input_filtered_search('assignee:none')
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).not_to have_content 'Bugfix1'
+ expect(page).to have_content 'Bugfix2'
+ end
+ end
+
+ context 'filtering by assignee:@username' do
+ it 'applies the filter' do
+ input_filtered_search("assignee:@#{user.username}")
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_content 'Bugfix1'
+ expect(page).not_to have_content 'Bugfix2'
+ end
+ end
+end
diff --git a/spec/features/merge_requests/user_filters_by_labels_spec.rb b/spec/features/merge_requests/user_filters_by_labels_spec.rb
new file mode 100644
index 00000000000..08d741af93d
--- /dev/null
+++ b/spec/features/merge_requests/user_filters_by_labels_spec.rb
@@ -0,0 +1,49 @@
+require 'rails_helper'
+
+describe 'Merge Requests > User filters by labels', :js do
+ include FilteredSearchHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ 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') }
+
+ before do
+ bug_label = create(:label, project: project, title: 'bug')
+ enhancement_label = create(:label, project: project, title: 'enhancement')
+ mr1.labels << bug_label
+ mr2.labels << bug_label << enhancement_label
+
+ sign_in(user)
+ visit project_merge_requests_path(project)
+ end
+
+ context 'filtering by label:none' do
+ it 'applies the filter' do
+ input_filtered_search('label:none')
+
+ expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
+ expect(page).not_to have_content 'Bugfix1'
+ expect(page).not_to have_content 'Bugfix2'
+ end
+ end
+
+ context 'filtering by label:~enhancement' do
+ it 'applies the filter' do
+ input_filtered_search('label:~enhancement')
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_content 'Bugfix2'
+ expect(page).not_to have_content 'Bugfix1'
+ end
+ end
+
+ context 'filtering by label:~enhancement and label:~bug' do
+ it 'applies the filters' do
+ input_filtered_search('label:~bug label:~enhancement')
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_content 'Bugfix2'
+ end
+ end
+end
diff --git a/spec/features/merge_requests/user_filters_by_milestones_spec.rb b/spec/features/merge_requests/user_filters_by_milestones_spec.rb
new file mode 100644
index 00000000000..727a236d980
--- /dev/null
+++ b/spec/features/merge_requests/user_filters_by_milestones_spec.rb
@@ -0,0 +1,62 @@
+require 'rails_helper'
+
+describe 'Merge Requests > User filters by milestones', :js do
+ include FilteredSearchHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let(:milestone) { create(:milestone, project: project) }
+
+ before do
+ create(:merge_request, :with_diffs, source_project: project)
+ create(:merge_request, :simple, source_project: project, milestone: milestone)
+
+ sign_in(user)
+ visit project_merge_requests_path(project)
+ end
+
+ it 'filters by no milestone' do
+ input_filtered_search('milestone:none')
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_css('.merge-request', count: 1)
+ end
+
+ it 'filters by a specific milestone' do
+ input_filtered_search("milestone:%'#{milestone.title}'")
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_css('.merge-request', count: 1)
+ end
+
+ describe 'filters by upcoming milestone' do
+ it 'does not show merge requests with no expiry' do
+ input_filtered_search('milestone:upcoming')
+
+ expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
+ expect(page).to have_css('.merge-request', count: 0)
+ end
+
+ context 'with an upcoming milestone' do
+ let(:milestone) { create(:milestone, project: project, due_date: Date.tomorrow) }
+
+ it 'shows merge requests' do
+ input_filtered_search('milestone:upcoming')
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_css('.merge-request', count: 1)
+ end
+ end
+
+ context 'with a due milestone' do
+ let(:milestone) { create(:milestone, project: project, due_date: Date.yesterday) }
+
+ it 'does not show any merge requests' do
+ input_filtered_search('milestone:upcoming')
+
+ expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
+ expect(page).to have_css('.merge-request', count: 0)
+ end
+ end
+ end
+end
diff --git a/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb b/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb
new file mode 100644
index 00000000000..1615899a047
--- /dev/null
+++ b/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+describe 'Merge requests > User filters by multiple criteria', :js do
+ include FilteredSearchHelpers
+
+ let!(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+ let!(:milestone) { create(:milestone, title: 'v1.1', project: project) }
+ let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
+
+ before do
+ sign_in(user)
+ mr = create(:merge_request, title: 'Bugfix2', author: user, assignee: user, source_project: project, target_project: project, milestone: milestone)
+ mr.labels << wontfix
+
+ visit project_merge_requests_path(project)
+ end
+
+ describe 'filtering by label:~"Won\'t fix" and assignee:~bug' do
+ it 'applies the filters' do
+ input_filtered_search("label:~\"Won't fix\" assignee:@#{user.username}")
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_content 'Bugfix2'
+ expect_filtered_search_input_empty
+ end
+ end
+
+ describe 'filtering by text, author, assignee, milestone, and label' do
+ it 'filters by text, author, assignee, milestone, and label' do
+ input_filtered_search_keys("author:@#{user.username} assignee:@#{user.username} milestone:%\"v1.1\" label:~\"Won't fix\" Bug")
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).to have_content 'Bugfix2'
+ expect_filtered_search_input('Bug')
+ end
+ 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 416a0f78a45..ef7ae490b0f 100644
--- a/spec/features/merge_requests/user_lists_merge_requests_spec.rb
+++ b/spec/features/merge_requests/user_lists_merge_requests_spec.rb
@@ -1,6 +1,6 @@
-require 'spec_helper'
+require 'rails_helper'
-describe 'Projects > Merge requests > User lists merge requests' do
+describe 'Merge requests > User lists merge requests' do
include MergeRequestHelpers
include SortingHelper
diff --git a/spec/features/merge_requests/user_mass_updates_spec.rb b/spec/features/merge_requests/user_mass_updates_spec.rb
new file mode 100644
index 00000000000..199ba7e87ad
--- /dev/null
+++ b/spec/features/merge_requests/user_mass_updates_spec.rb
@@ -0,0 +1,133 @@
+require 'rails_helper'
+
+describe 'Merge requests > User mass updates', :js do
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.creator }
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ context 'status' do
+ describe 'close merge request' do
+ before do
+ visit project_merge_requests_path(project)
+ end
+
+ it 'closes merge request' do
+ change_status('Closed')
+
+ expect(page).to have_selector('.merge-request', count: 0)
+ end
+ end
+
+ describe 'reopen merge request' do
+ before do
+ merge_request.close
+ visit project_merge_requests_path(project, state: 'closed')
+ end
+
+ it 'reopens merge request' do
+ change_status('Open')
+
+ expect(page).to have_selector('.merge-request', count: 0)
+ end
+ end
+ end
+
+ context 'assignee' do
+ describe 'set assignee' do
+ before do
+ visit project_merge_requests_path(project)
+ end
+
+ it 'updates merge request with assignee' do
+ change_assignee(user.name)
+
+ page.within('.merge-request .controls') do
+ expect(find('.author_link')["title"]).to have_content(user.name)
+ end
+ end
+ end
+
+ describe 'remove assignee' do
+ before do
+ merge_request.assignee = user
+ merge_request.save
+ visit project_merge_requests_path(project)
+ end
+
+ it 'removes assignee from the merge request' do
+ change_assignee('Unassigned')
+
+ expect(find('.merge-request .controls')).not_to have_css('.author_link')
+ end
+ end
+ end
+
+ context 'milestone' do
+ let(:milestone) { create(:milestone, project: project) }
+
+ describe 'set milestone' do
+ before do
+ visit project_merge_requests_path(project)
+ end
+
+ it 'updates merge request with milestone' do
+ change_milestone(milestone.title)
+
+ expect(find('.merge-request')).to have_content milestone.title
+ end
+ end
+
+ describe 'unset milestone' do
+ before do
+ merge_request.milestone = milestone
+ merge_request.save
+ visit project_merge_requests_path(project)
+ end
+
+ it 'removes milestone from the merge request' do
+ change_milestone("No Milestone")
+
+ expect(find('.merge-request')).not_to have_content milestone.title
+ end
+ end
+ end
+
+ def change_status(text)
+ 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)
+ click_button 'Edit merge requests'
+ find('#check-all-issues').click
+ find('.js-update-assignee').click
+ wait_for_requests
+
+ page.within '.dropdown-menu-user' do
+ click_link text
+ end
+
+ click_update_merge_requests_button
+ end
+
+ def change_milestone(text)
+ 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_requests
+ end
+end
diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
deleted file mode 100644
index d44eb23d7f4..00000000000
--- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb
+++ /dev/null
@@ -1,282 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge requests > User posts diff notes', :js do
- include MergeRequestDiffHelpers
-
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request) }
- let(:project) { merge_request.source_project }
-
- before do
- set_cookie('sidebar_collapsed', 'true')
-
- project.add_developer(user)
- sign_in(user)
- end
-
- let(:comment_button_class) { '.add-diff-note' }
- let(:notes_holder_input_class) { 'js-temp-notes-holder' }
- let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' }
- let(:test_note_comment) { 'this is a test note!' }
-
- context 'when hovering over a parallel view diff file' do
- before do
- 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
- it 'allows commenting on the left side' do
- should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'left')
- end
-
- it 'does not allow commenting on the right side' do
- should_not_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'right')
- end
- end
-
- context 'with no line on the left and a new line on the right' do
- it 'does not allow commenting on the left side' do
- should_not_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'left')
- end
-
- it 'allows commenting on the right side' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'right')
- end
- end
-
- context 'with an old line on the left and a new line on the right' do
- it 'allows commenting on the left side' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'left')
- end
-
- it 'allows commenting on the right side' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'right')
- end
- end
-
- context 'with an unchanged line on the left and an unchanged line on the right' do
- it 'allows commenting on the left side' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]', match: :first).find(:xpath, '..'), 'left')
- end
-
- it 'allows commenting on the right side' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]', match: :first).find(:xpath, '..'), 'right')
- end
- end
-
- context 'with a match line' do
- it 'does not allow commenting on the left side' do
- should_not_allow_commenting(find('.match', match: :first).find(:xpath, '..'), 'left')
- end
-
- it 'does not allow commenting on the right side' do
- should_not_allow_commenting(find('.match', match: :first).find(:xpath, '..'), 'right')
- end
- end
-
- context 'with an unfolded line' do
- before do
- find('.js-unfold', match: :first).click
- wait_for_requests
- end
-
- # The first `.js-unfold` unfolds upwards, therefore the first
- # `.line_holder` will be an unfolded line.
- let(:line_holder) { first('.line_holder[id="1"]') }
-
- it 'does not allow commenting on the left side' do
- should_not_allow_commenting(line_holder, 'left')
- end
-
- it 'does not allow commenting on the right side' do
- should_not_allow_commenting(line_holder, 'right')
- end
- end
- end
-
- context 'when hovering over an inline view diff file' do
- before do
- visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
- end
-
- context 'after deleteing a note' do
- it 'allows commenting' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
-
- accept_confirm do
- first('button.more-actions-toggle').click
- first('.js-note-delete').click
- end
-
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
- end
- end
-
- context 'with a new line' do
- it 'allows commenting' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
- end
- end
-
- context 'with an old line' do
- it 'allows commenting' do
- should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'))
- end
- end
-
- context 'with an unchanged line' do
- it 'allows commenting' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]'))
- end
- end
-
- context 'with a match line' do
- it 'does not allow commenting' do
- should_not_allow_commenting(find('.match', match: :first))
- end
- end
-
- context 'with an unfolded line' do
- before do
- find('.js-unfold', match: :first).click
- wait_for_requests
- end
-
- # The first `.js-unfold` unfolds upwards, therefore the first
- # `.line_holder` will be an unfolded line.
- let(:line_holder) { first('.line_holder[id="1"]') }
-
- it 'does not allow commenting' do
- should_not_allow_commenting line_holder
- end
- end
-
- context 'when hovering over a diff discussion' do
- before do
- visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]'))
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'does not allow commenting' do
- should_not_allow_commenting(find('.line_holder', match: :first))
- end
- end
- end
-
- context 'when cancelling the comment addition' do
- before do
- visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
- end
-
- context 'with a new line' do
- it 'allows dismissing a comment' do
- should_allow_dismissing_a_comment(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
- end
- end
- end
-
- describe 'with muliple note forms' do
- before do
- 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
-
- describe 'posting a note' do
- it 'adds as discussion' do
- expect(page).to have_css('.js-temp-notes-holder', count: 2)
-
- should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'), asset_form_reset: false)
- expect(page).to have_css('.notes_holder .note', count: 1)
- expect(page).to have_css('.js-temp-notes-holder', count: 1)
- expect(page).to have_button('Reply...')
- end
- end
- end
-
- 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_project_merge_request_path(project, merge_request, view: 'inline')
- end
-
- context 'with a new line' do
- it 'allows commenting' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
- end
- end
-
- context 'with an old line' do
- it 'allows commenting' do
- should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'))
- end
- end
-
- context 'with an unchanged line' do
- it 'allows commenting' do
- should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]'))
- end
- end
-
- context 'with a match line' do
- it 'does not allow commenting' do
- should_not_allow_commenting(find('.match', match: :first))
- end
- end
- end
-
- def should_allow_commenting(line_holder, diff_side = nil, asset_form_reset: true)
- write_comment_on_line(line_holder, diff_side)
-
- click_button 'Comment'
-
- wait_for_requests
-
- assert_comment_persistence(line_holder, asset_form_reset: asset_form_reset)
- end
-
- def should_allow_dismissing_a_comment(line_holder, diff_side = nil)
- write_comment_on_line(line_holder, diff_side)
-
- find('.js-close-discussion-note-form').click
-
- assert_comment_dismissal(line_holder)
- end
-
- def should_not_allow_commenting(line_holder, diff_side = nil)
- line = get_line_components(line_holder, diff_side)
- line[:content].hover
- expect(line[:num]).not_to have_css comment_button_class
- end
-
- def write_comment_on_line(line_holder, diff_side)
- click_diff_line(line_holder, diff_side)
-
- notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath)
-
- expect(notes_holder_input[:class]).to include(notes_holder_input_class)
-
- notes_holder_input.fill_in 'note[note]', with: test_note_comment
- end
-
- def assert_comment_persistence(line_holder, asset_form_reset:)
- notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath)
-
- expect(notes_holder_saved[:class]).not_to include(notes_holder_input_class)
- expect(notes_holder_saved).to have_content test_note_comment
-
- assert_form_is_reset if asset_form_reset
- end
-
- def assert_comment_dismissal(line_holder)
- expect(line_holder).not_to have_xpath notes_holder_input_xpath
- expect(page).not_to have_content test_note_comment
-
- assert_form_is_reset
- end
-
- def assert_form_is_reset
- expect(page).to have_no_css('.js-temp-notes-holder')
- end
-end
diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb
deleted file mode 100644
index e17e9c2ccf5..00000000000
--- a/spec/features/merge_requests/user_posts_notes_spec.rb
+++ /dev/null
@@ -1,166 +0,0 @@
-require 'spec_helper'
-
-describe 'Merge requests > User posts notes', :js do
- include NoteInteractionHelpers
-
- let(:project) { create(:project, :repository) }
- let(:merge_request) do
- create(:merge_request, source_project: project, target_project: project)
- end
- let!(:note) do
- create(:note_on_merge_request, :with_attachment, noteable: merge_request,
- project: project)
- end
-
- before do
- sign_in(create(:admin))
- visit project_merge_request_path(project, merge_request)
- end
-
- subject { page }
-
- 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')
- page.within('.js-main-target-form') do
- expect(page).not_to have_link('Cancel')
- end
- end
-
- describe 'with text' do
- before do
- page.within('.js-main-target-form') do
- fill_in 'note[note]', with: 'This is awesome'
- end
- end
-
- it 'has enable submit button and preview button' do
- page.within('.js-main-target-form') do
- expect(page).not_to have_css('.js-comment-button[disabled]')
- expect(page).to have_css('.js-md-preview-button', visible: true)
- end
- end
- end
- end
-
- describe 'when posting a note' do
- before do
- page.within('.js-main-target-form') do
- fill_in 'note[note]', with: 'This is awesome!'
- find('.js-md-preview-button').click
- click_button 'Comment'
- end
- end
-
- it 'is added and form reset' do
- is_expected.to have_content('This is awesome!')
- page.within('.js-main-target-form') do
- expect(page).to have_no_field('note[note]', with: 'This is awesome!')
- expect(page).to have_css('.js-md-preview', visible: :hidden)
- end
- page.within('.js-main-target-form') do
- is_expected.to have_css('.js-note-text', visible: true)
- end
- end
- end
-
- describe 'when previewing a note' do
- it 'shows the toolbar buttons when editing a note' do
- page.within('.js-main-target-form') do
- expect(page).to have_css('.md-header-toolbar.active')
- end
- end
-
- it 'hides the toolbar buttons when previewing a note' do
- find('.js-md-preview-button').click
- page.within('.js-main-target-form') do
- expect(page).not_to have_css('.md-header-toolbar.active')
- end
- end
- end
-
- describe 'when editing a note' do
- it 'there should be a hidden edit form' do
- is_expected.to have_css('.note-edit-form:not(.mr-note-edit-form)', visible: false, count: 1)
- is_expected.to have_css('.note-edit-form.mr-note-edit-form', visible: false, count: 1)
- end
-
- describe 'editing the note' do
- before do
- find('.note').hover
-
- find('.js-note-edit').click
- end
-
- it 'shows the note edit form and hide the note body' do
- page.within("#note_#{note.id}") do
- expect(find('.current-note-edit-form', visible: true)).to be_visible
- expect(find('.note-edit-form', visible: true)).to be_visible
- expect(find(:css, '.note-body > .note-text', visible: false)).not_to be_visible
- end
- end
-
- it 'resets the edit note form textarea with the original content of the note if cancelled' do
- within('.current-note-edit-form') do
- fill_in 'note[note]', with: 'Some new content'
- find('.btn-cancel').click
- expect(find('.js-note-text', visible: false).text).to eq ''
- end
- end
-
- it 'allows using markdown buttons after saving a note and then trying to edit it again' do
- page.within('.current-note-edit-form') do
- fill_in 'note[note]', with: 'This is the new content'
- find('.btn-save').click
- end
-
- wait_for_requests
- find('.note').hover
-
- find('.js-note-edit').click
-
- page.within('.current-note-edit-form') do
- expect(find('#note_note').value).to eq('This is the new content')
- find('.js-md:first-child').click
- expect(find('#note_note').value).to eq('This is the new content****')
- end
- end
-
- it 'appends the edited at time to the note' do
- page.within('.current-note-edit-form') do
- fill_in 'note[note]', with: 'Some new content'
- find('.btn-save').click
- end
-
- 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/)
- end
- end
- end
-
- describe 'deleting an attachment' do
- before do
- find('.note').hover
-
- find('.js-note-edit').click
- end
-
- it 'shows the delete link' do
- page.within('.note-attachment') do
- is_expected.to have_css('.js-note-attachment-delete')
- end
- end
-
- it 'removes the attachment div and resets the edit form' do
- accept_confirm { 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_requests
- end
- 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
deleted file mode 100644
index 03dc61c2efa..00000000000
--- a/spec/features/merge_requests/user_sees_system_notes_spec.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge requests > User sees system notes' do
- 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)}") }
-
- context 'when logged-in as a member of the private project' do
- before do
- user = create(:user)
- private_project.add_developer(user)
- sign_in(user)
- end
-
- it 'shows the system note' do
- visit project_merge_request_path(public_project, merge_request)
-
- expect(page).to have_css('.system-note')
- end
- end
-
- context 'when not logged-in' do
- it 'hides the system note' do
- visit project_merge_request_path(public_project, merge_request)
-
- expect(page).not_to have_css('.system-note')
- end
- end
-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
deleted file mode 100644
index ee0766f1192..00000000000
--- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb
+++ /dev/null
@@ -1,207 +0,0 @@
-require 'rails_helper'
-
-feature 'Merge Requests > User uses quick actions', :js do
- include QuickActionsHelpers
-
- 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]
- sign_in(user)
- visit project_merge_request_path(project, merge_request)
- end
-
- after do
- 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
- context 'when the current user can toggle the WIP prefix' do
- it 'adds the WIP: prefix to the title' do
- write_note("/wip")
-
- expect(page).not_to have_content '/wip'
- expect(page).to have_content 'Commands applied'
-
- expect(merge_request.reload.work_in_progress?).to eq true
- end
-
- it 'removes the WIP: prefix from the title' do
- merge_request.title = merge_request.wip_title
- merge_request.save
- write_note("/wip")
-
- expect(page).not_to have_content '/wip'
- expect(page).to have_content 'Commands applied'
-
- expect(merge_request.reload.work_in_progress?).to eq false
- end
- end
-
- context 'when the current user cannot toggle the WIP prefix' do
- let(:guest) { create(:user) }
- before do
- project.team << [guest, :guest]
- sign_out(:user)
- sign_in(guest)
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'does not change the WIP prefix' do
- write_note("/wip")
-
- expect(page).not_to have_content '/wip'
- expect(page).not_to have_content 'Commands applied'
-
- expect(merge_request.reload.work_in_progress?).to eq false
- end
- end
- end
-
- describe 'merging the MR from the note' do
- context 'when the current user can merge the MR' do
- it 'merges the MR' do
- write_note("/merge")
-
- expect(page).to have_content 'Commands applied'
-
- expect(merge_request.reload).to be_merged
- end
- end
-
- context 'when the head diff changes in the meanwhile' do
- before do
- merge_request.source_branch = 'another_branch'
- merge_request.save
- end
-
- it 'does not merge the MR' do
- write_note("/merge")
-
- expect(page).not_to have_content 'Your commands have been executed!'
-
- expect(merge_request.reload).not_to be_merged
- end
- end
-
- context 'when the current user cannot merge the MR' do
- let(:guest) { create(:user) }
- before do
- project.team << [guest, :guest]
- sign_out(:user)
- sign_in(guest)
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'does not merge the MR' do
- write_note("/merge")
-
- expect(page).not_to have_content 'Your commands have been executed!'
-
- expect(merge_request.reload).not_to be_merged
- end
- end
- end
-
- describe 'adding a due date from note' do
- it 'does not recognize the command nor create a note' do
- write_note('/due 2016-08-28')
-
- expect(page).not_to have_content '/due 2016-08-28'
- end
- end
-
- describe '/target_branch command in merge request' do
- let(:another_project) { create(:project, :public, :repository) }
- let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } }
-
- before do
- sign_out(:user)
- another_project.team << [user, :master]
- sign_in(user)
- end
-
- it 'changes target_branch in new merge_request' do
- 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:"
- click_button "Submit merge request"
-
- merge_request = another_project.merge_requests.first
- expect(merge_request.description).to eq "le feature \nFeature description:"
- expect(merge_request.target_branch).to eq 'fix'
- end
-
- it 'does not change target branch when merge request is edited' do
- new_merge_request = create(:merge_request, source_project: another_project)
-
- 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"
-
- new_merge_request = another_project.merge_requests.first
- expect(new_merge_request.description).to include('/target_branch')
- expect(new_merge_request.target_branch).not_to eq('fix')
- end
- end
-
- describe '/target_branch command from note' do
- context 'when the current user can change target branch' 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.')
-
- expect(merge_request.reload.target_branch).to eq 'merge-test'
- end
-
- it 'does not fail when target branch does not exists' do
- write_note('/target_branch totally_not_existing_branch')
-
- expect(page).not_to have_content('/target_branch')
-
- expect(merge_request.target_branch).to eq 'feature'
- end
- end
-
- context 'when current user can not change target branch' do
- let(:guest) { create(:user) }
- before do
- project.team << [guest, :guest]
- sign_out(:user)
- sign_in(guest)
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'does not change target branch' do
- write_note('/target_branch merge-test')
-
- expect(page).not_to have_content '/target_branch merge-test'
-
- expect(merge_request.target_branch).to eq 'feature'
- end
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/versions_spec.rb b/spec/features/merge_requests/versions_spec.rb
deleted file mode 100644
index 482f2e51c8b..00000000000
--- a/spec/features/merge_requests/versions_spec.rb
+++ /dev/null
@@ -1,234 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge Request versions', :js do
- let(:merge_request) { create(:merge_request, importing: true) }
- let(:project) { merge_request.source_project }
- let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') }
- let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) }
- let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') }
- let!(:params) { Hash.new }
-
- before do
- sign_in(create(:admin))
- visit diffs_project_merge_request_path(project, merge_request, params)
- end
-
- shared_examples 'allows commenting' do |file_id:, line_code:, comment:|
- it do
- diff_file_selector = ".diff-file[id='#{file_id}']"
- line_code = "#{file_id}_#{line_code}"
-
- page.within(diff_file_selector) do
- find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").hover
- find(".line_holder[id='#{line_code}'] button").click
-
- page.within("form[data-line-code='#{line_code}']") do
- fill_in "note[note]", with: comment
- find(".js-comment-button").click
- end
-
- wait_for_requests
-
- expect(page).to have_content(comment)
- end
- end
- end
-
- describe 'compare with the latest version' do
- it 'show the latest version of the diff' do
- page.within '.mr-version-dropdown' do
- expect(page).to have_content 'latest version'
- end
-
- expect(page).to have_content '8 changed files'
- end
-
- it_behaves_like 'allows commenting',
- file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44',
- line_code: '1_1',
- comment: 'Typo, please fix.'
- end
-
- describe 'switch between versions' do
- before do
- page.within '.mr-version-dropdown' do
- find('.btn-default').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
-
- it 'should show older version' do
- page.within '.mr-version-dropdown' do
- expect(page).to have_content 'version 1'
- end
-
- expect(page).to have_content '5 changed files'
- end
-
- 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: nil,
- new_line: 4,
- diff_refs: merge_request_diff1.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
-
- expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']")
- end
-
- it_behaves_like 'allows commenting',
- file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44',
- line_code: '2_2',
- comment: 'Typo, please fix.'
- end
-
- describe 'compare with older version' do
- before do
- page.within '.mr-version-compare-dropdown' do
- find('.btn-default').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_project_merge_request_path(
- project,
- merge_request.iid,
- diff_id: merge_request_diff3.id,
- start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9'
- )
- end
-
- it 'should have correct value in the compare dropdown' do
- page.within '.mr-version-compare-dropdown' do
- expect(page).to have_content 'version 1'
- end
- end
-
- 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 'show diff between new and old version' do
- expect(page).to have_content '4 changed files with 15 additions and 6 deletions'
- end
-
- it 'should return to latest version when "Show latest version" button is clicked' do
- click_link 'Show latest version'
- page.within '.mr-version-dropdown' do
- expect(page).to have_content 'latest version'
- end
- expect(page).to have_content '8 changed files'
- end
-
- it_behaves_like 'allows commenting',
- file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44',
- line_code: '4_4',
- comment: 'Typo, please fix.'
- end
-
- describe 'compare with same version' do
- before do
- page.within '.mr-version-compare-dropdown' do
- find('.btn-default').click
- click_link 'version 1'
- end
- end
-
- it 'should have 0 chages between versions' do
- page.within '.mr-version-compare-dropdown' do
- expect(find('.dropdown-toggle')).to have_content 'version 1'
- end
-
- page.within '.mr-version-dropdown' do
- find('.btn-default').click
- click_link 'version 1'
- end
- expect(page).to have_content '0 changed files'
- end
- end
-
- describe 'compare with newer version' do
- before do
- page.within '.mr-version-compare-dropdown' do
- find('.btn-default').click
- click_link 'version 2'
- end
- end
-
- it 'should set the compared versions to be the same' do
- page.within '.mr-version-compare-dropdown' do
- expect(find('.dropdown-toggle')).to have_content 'version 2'
- end
-
- page.within '.mr-version-dropdown' do
- find('.btn-default').click
- click_link 'version 1'
- end
-
- page.within '.mr-version-compare-dropdown' do
- expect(page).to have_content 'version 1'
- end
-
- expect(page).to have_content '0 changed files'
- end
- end
-
- describe 'scoped in a commit' do
- let(:params) { { commit_id: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d' } }
-
- before do
- wait_for_requests
- end
-
- it 'should only show diffs from the commit' do
- diff_commit_ids = find_all('.diff-file [data-commit-id]').map {|diff| diff['data-commit-id']}
-
- expect(diff_commit_ids).not_to be_empty
- expect(diff_commit_ids).to all(eq(params[:commit_id]))
- end
-
- it_behaves_like 'allows commenting',
- file_id: '2f6fcd96b88b36ce98c38da085c795a27d92a3dd',
- line_code: '6_6',
- comment: 'Typo, please fix.'
- end
-end
diff --git a/spec/features/merge_requests/widget_deployments_spec.rb b/spec/features/merge_requests/widget_deployments_spec.rb
deleted file mode 100644
index 72a52c979b3..00000000000
--- a/spec/features/merge_requests/widget_deployments_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-require 'spec_helper'
-
-feature 'Widget Deployments Header', :js do
- describe 'when deployed to an environment' do
- given(:user) { create(:user) }
- given(:project) { merge_request.target_project }
- given(:merge_request) { create(:merge_request, :merged) }
- given(:environment) { create(:environment, project: project) }
- given(:role) { :developer }
- given(:sha) { project.commit('master').id }
- given!(:deployment) { create(:deployment, environment: environment, sha: sha) }
- given!(:manual) { }
-
- background do
- sign_in(user)
- project.team << [user, role]
- visit project_merge_request_path(project, merge_request)
- end
-
- scenario 'displays that the environment is deployed' do
- wait_for_requests
-
- expect(page).to have_content("Deployed to #{environment.name}")
- 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
- given(:pipeline) { create(:ci_pipeline, project: project) }
- given(:build) { create(:ci_build, pipeline: pipeline) }
- given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') }
- given(:deployment) do
- create(:deployment, environment: environment, ref: merge_request.target_branch,
- sha: sha, deployable: build, on_stop: 'close_app')
- end
-
- background do
- wait_for_requests
- end
-
- scenario 'does show stop button' do
- expect(page).to have_button('Stop environment')
- end
-
- scenario 'does start build when stop button clicked' do
- accept_confirm { click_button('Stop environment') }
-
- expect(page).to have_content('close_app')
- end
-
- context 'for reporter' do
- given(:role) { :reporter }
-
- scenario 'does not show stop button' do
- expect(page).not_to have_button('Stop environment')
- end
- end
- end
- end
-end
diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb
deleted file mode 100644
index 3ee094c216e..00000000000
--- a/spec/features/merge_requests/widget_spec.rb
+++ /dev/null
@@ -1,304 +0,0 @@
-require 'rails_helper'
-
-describe 'Merge request', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) }
- let(:merge_request) { create(:merge_request, source_project: project) }
- let(:merge_request_in_only_mwps_project) { create(:merge_request, source_project: project_only_mwps) }
-
- before do
- project.add_master(user)
- project_only_mwps.add_master(user)
- sign_in(user)
- end
-
- context 'new merge request' do
- before do
- 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_requests
-
- expect(page).to have_selector('.accept-merge-request')
- expect(find('.accept-merge-request')['disabled']).not_to be(true)
- end
- end
-
- context 'view merge request' do
- let!(:environment) { create(:environment, project: project) }
-
- let!(:deployment) do
- create(:deployment, environment: environment,
- ref: 'feature',
- sha: merge_request.diff_head_sha)
- end
-
- before do
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'shows environments link' do
- wait_for_requests
-
- page.within('.mr-widget-heading') do
- expect(page).to have_content("Deployed to #{environment.name}")
- 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_requests
- expect(page).to have_selector('.accept-merge-request')
- expect(find('.accept-merge-request')['disabled']).not_to be(true)
- end
-
- it 'allows me to merge, see cherry-pick modal and load branches list' do
- wait_for_requests
- click_button 'Merge'
-
- wait_for_requests
- click_link 'Cherry-pick'
- page.find('.js-project-refs-dropdown').click
- wait_for_requests
-
- expect(page.all('.js-cherry-pick-form .dropdown-content li').size).to be > 1
- end
- end
-
- context 'view merge request with external CI service' do
- before do
- create(:service, project: project,
- active: true,
- type: 'CiService',
- category: 'ci')
-
- 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_requests
- expect(page).to have_selector('.accept-merge-request.btn-danger')
- end
- end
-
- context 'view merge request with failed GitLab CI pipelines' do
- before do
- commit_status = create(:commit_status, project: project, status: 'failed')
- pipeline = create(:ci_pipeline, project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch,
- status: 'failed',
- statuses: [commit_status],
- head_pipeline_of: merge_request)
- create(:ci_build, :pending, pipeline: pipeline)
-
- 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_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,
- head_pipeline_of: merge_request)
-
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'shows information about blocked pipeline' do
- expect(page).to have_content("Pipeline blocked")
- expect(page).to have_content(
- "The pipeline for this merge request requires a manual action")
- expect(page).to have_css('.ci-status-icon-manual')
- end
- end
-
- context 'view merge request with MWBS button' do
- before do
- commit_status = create(:commit_status, project: project, status: 'pending')
- pipeline = create(:ci_pipeline, project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch,
- status: 'pending',
- statuses: [commit_status],
- head_pipeline_of: merge_request)
- create(:ci_build, :pending, pipeline: pipeline)
-
- 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_requests
- expect(page).to have_selector('.accept-merge-request.btn-info')
- end
- end
-
- context 'view merge request where project has CI setup but no CI status' do
- before do
- pipeline = create(:ci_pipeline, project: project,
- sha: merge_request.diff_head_sha,
- ref: merge_request.source_branch)
- create(:ci_build, pipeline: pipeline)
-
- visit project_merge_request_path(project, merge_request)
- end
-
- it 'has pipeline error text' do
- # Wait for the `ci_status` and `merge_check` requests
- wait_for_requests
-
- expect(page).to have_text('Could not connect to the CI server. Please check your settings and try again')
- end
- end
-
- context 'view merge request in project with only-mwps setting enabled but no CI is setup' do
- before do
- visit project_merge_request_path(project_only_mwps, merge_request_in_only_mwps_project)
- end
-
- it 'should be allowed to merge' do
- # Wait for the `ci_status` and `merge_check` requests
- wait_for_requests
-
- expect(page).to have_selector('.accept-merge-request')
- expect(find('.accept-merge-request')['disabled']).not_to be(true)
- 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')
- 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')
- end
- end
- end
-
- context 'view merge request where fast-forward merge is not possible' do
- before do
- project.update(merge_requests_ff_only_enabled: true)
-
- merge_request.update(
- merge_user: merge_request.author,
- merge_status: :cannot_be_merged
- )
-
- 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('Fast-forward merge is not possible')
- end
- end
- end
-
- context 'merge error' do
- before do
- allow_any_instance_of(Repository).to receive(:merge).and_return(false)
- 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 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
deleted file mode 100644
index b422c76249d..00000000000
--- a/spec/features/merge_requests/wip_message_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-require 'spec_helper'
-
-feature 'Work In Progress help message' do
- let!(:project) { create(:project, :public, :repository) }
- let!(:user) { create(:user) }
-
- before do
- project.team << [user, :master]
- sign_in(user)
- end
-
- context 'with WIP commits' do
- it 'shows a specific WIP hint' do
- 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(
- 'It looks like you have some WIP commits in this branch'
- )
- end
- end
- end
-
- context 'without WIP commits' do
- it 'shows the regular WIP message' do
- 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(
- 'It looks like you have some WIP commits in this branch'
- )
- expect(page).to have_text(
- "Start the title with WIP: to prevent a Work In Progress merge \
-request from being merged before it's ready"
- )
- end
- end
- end
-
- def within_wip_explanation(&block)
- page.within '.js-no-wip-explanation' do
- yield
- end
- end
-end
diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb
index 9f24193a2ac..19152bf1f0f 100644
--- a/spec/features/milestone_spec.rb
+++ b/spec/features/milestone_spec.rb
@@ -7,7 +7,7 @@ feature 'Milestone' do
before do
create(:group_member, group: group, user: user)
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -66,15 +66,16 @@ feature 'Milestone' do
end
end
- feature 'Open a milestone' do
+ feature 'Open a milestone', :js do
scenario 'shows total issue time spent correctly when no time has been logged' do
milestone = create(:milestone, project: project, title: 8.7)
visit project_milestone_path(project, milestone)
- page.within('.block.time_spent') do
- expect(page).to have_content 'No time spent'
- expect(page).to have_content 'None'
+ wait_for_requests
+
+ page.within('.time-tracking-no-tracking-pane') do
+ expect(page).to have_content 'No estimate or time spent'
end
end
@@ -89,9 +90,22 @@ feature 'Milestone' do
visit project_milestone_path(project, milestone)
- page.within('.block.time_spent') do
- expect(page).to have_content '3h'
+ wait_for_requests
+
+ page.within('.time-tracking-spend-only-pane') do
+ expect(page).to have_content 'Spent: 3h'
end
end
end
+
+ feature 'Deleting a milestone' do
+ scenario "The delete milestone button does not show for unauthorized users" do
+ create(:milestone, project: project, title: 8.7)
+ sign_out(user)
+
+ visit group_milestones_path(group)
+
+ expect(page).to have_selector('.js-delete-milestone-button', count: 0)
+ end
+ end
end
diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb
index 49d8e52f861..a5e325ee2e3 100644
--- a/spec/features/oauth_login_spec.rb
+++ b/spec/features/oauth_login_spec.rb
@@ -10,8 +10,7 @@ feature 'OAuth Login', :js, :allow_forgery_protection do
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]
+ stub_omniauth_provider(provider)
end
providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2,
diff --git a/spec/features/password_reset_spec.rb b/spec/features/password_reset_spec.rb
index b45972b7f6b..73a526c3d8a 100644
--- a/spec/features/password_reset_spec.rb
+++ b/spec/features/password_reset_spec.rb
@@ -33,6 +33,25 @@ feature 'Password reset' do
end
end
+ describe 'Changing password while logged in' do
+ it 'updates the password' do
+ user = create(:user)
+ token = user.send_reset_password_instructions
+
+ sign_in(user)
+
+ visit(edit_user_password_path(reset_password_token: token))
+
+ fill_in 'New password', with: 'hello1234'
+ fill_in 'Confirm new password', with: 'hello1234'
+
+ click_button 'Change your password'
+
+ expect(page).to have_content(I18n.t('devise.passwords.updated_not_active'))
+ expect(current_path).to eq new_user_session_path
+ end
+ end
+
def forgot_password(user)
visit root_path
click_on 'Forgot your password?'
diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb
index 0848857ed1e..15dcb30cbdd 100644
--- a/spec/features/profile_spec.rb
+++ b/spec/features/profile_spec.rb
@@ -97,9 +97,13 @@ describe 'Profile account page', :js do
end
it 'changes my username' do
- fill_in 'user_username', with: 'new-username'
+ fill_in 'username-change-input', with: 'new-username'
- click_button('Update username')
+ page.find('[data-target="#username-change-confirmation-modal"]').click
+
+ page.within('.modal') do
+ find('.js-modal-primary-action').click
+ end
expect(page).to have_content('new-username')
end
diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb
index 171e061e60e..215b658eb7b 100644
--- a/spec/features/profiles/account_spec.rb
+++ b/spec/features/profiles/account_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-feature 'Profile > Account' do
+feature 'Profile > Account', :js do
given(:user) { create(:user, username: 'foo') }
before do
@@ -43,14 +43,14 @@ feature 'Profile > Account' do
update_username(new_username)
visit new_project_path
expect(current_path).to eq(new_project_path)
- expect(find('.breadcrumbs-sub-title')).to have_content(project.path)
+ expect(find('.breadcrumbs-sub-title')).to have_content('Details')
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('.breadcrumbs-sub-title')).to have_content(project.path)
+ expect(find('.breadcrumbs-sub-title')).to have_content('Details')
end
end
end
@@ -59,6 +59,12 @@ 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'
+
+ fill_in 'username-change-input', with: new_username
+
+ page.find('[data-target="#username-change-confirmation-modal"]').click
+
+ page.within('.modal') do
+ find('.js-modal-primary-action').click
+ end
end
diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb
index 7d5ba3a7328..b04a5422fed 100644
--- a/spec/features/profiles/keys_spec.rb
+++ b/spec/features/profiles/keys_spec.rb
@@ -27,6 +27,7 @@ feature 'Profile > SSH Keys' do
expect(page).to have_content("Title: #{attrs[:title]}")
expect(page).to have_content(attrs[:key])
+ expect(find('.breadcrumbs-sub-title')).to have_link(attrs[:title])
end
context 'when only DSA and ECDSA keys are allowed' do
diff --git a/spec/features/profiles/oauth_applications_spec.rb b/spec/features/profiles/oauth_applications_spec.rb
index d1edeef8da4..7d204f89fba 100644
--- a/spec/features/profiles/oauth_applications_spec.rb
+++ b/spec/features/profiles/oauth_applications_spec.rb
@@ -2,12 +2,20 @@ require 'spec_helper'
describe 'Profile > Applications' do
let(:user) { create(:user) }
+ let(:application) { create(:oauth_application, owner: user) }
before do
sign_in(user)
end
describe 'User manages applications', :js do
+ it 'views an application' do
+ visit oauth_application_path(application)
+
+ expect(page).to have_content("Application: #{application.name}")
+ expect(find('.breadcrumbs-sub-title')).to have_link(application.name)
+ end
+
it 'deletes an application' do
create(:oauth_application, owner: user)
visit oauth_applications_path
diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb
index 4665626f114..f9c6ff90ca1 100644
--- a/spec/features/profiles/password_spec.rb
+++ b/spec/features/profiles/password_spec.rb
@@ -1,6 +1,15 @@
require 'spec_helper'
describe 'Profile > Password' do
+ let(:user) { create(:user) }
+
+ def fill_passwords(password, confirmation)
+ fill_in 'New password', with: password
+ fill_in 'Password confirmation', with: confirmation
+
+ click_button 'Save password'
+ end
+
context 'Password authentication enabled' do
let(:user) { create(:user, password_automatically_set: true) }
@@ -9,13 +18,6 @@ describe 'Profile > Password' do
visit edit_profile_password_path
end
- def fill_passwords(password, confirmation)
- fill_in 'New password', with: password
- fill_in 'Password confirmation', with: confirmation
-
- 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
@@ -73,4 +75,74 @@ describe 'Profile > Password' do
end
end
end
+
+ context 'Change passowrd' do
+ before do
+ sign_in(user)
+ visit(edit_profile_password_path)
+ end
+
+ it 'does not change user passowrd without old one' do
+ page.within '.update-password' do
+ fill_passwords('22233344', '22233344')
+ end
+
+ page.within '.flash-container' do
+ expect(page).to have_content 'You must provide a valid current password'
+ end
+ end
+
+ it 'does not change password with invalid old password' do
+ page.within '.update-password' do
+ fill_in 'user_current_password', with: 'invalid'
+ fill_passwords('password', 'confirmation')
+ end
+
+ page.within '.flash-container' do
+ expect(page).to have_content 'You must provide a valid current password'
+ end
+ end
+
+ it 'changes user password' do
+ page.within '.update-password' do
+ fill_in "user_current_password", with: user.password
+ fill_passwords('22233344', '22233344')
+ end
+
+ expect(current_path).to eq new_user_session_path
+ end
+ end
+
+ context 'when password is expired' do
+ before do
+ sign_in(user)
+
+ user.update_attributes(password_expires_at: 1.hour.ago)
+ user.identities.delete
+ expect(user.ldap_user?).to eq false
+ end
+
+ it 'needs change user password' do
+ visit edit_profile_password_path
+
+ expect(current_path).to eq new_profile_password_path
+
+ fill_in :user_current_password, with: user.password
+ fill_in :user_password, with: '12345678'
+ fill_in :user_password_confirmation, with: '12345678'
+ click_button 'Set new password'
+
+ expect(current_path).to eq new_user_session_path
+ end
+
+ context 'when global require_two_factor_authentication is enabled' do
+ it 'needs change user password' do
+ stub_application_setting(require_two_factor_authentication: true)
+
+ visit profile_path
+
+ expect(current_path).to eq new_profile_password_path
+ end
+ end
+ end
end
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
new file mode 100644
index 00000000000..0b5eacbe916
--- /dev/null
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+describe 'User edit profile' do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ visit(profile_path)
+ end
+
+ it 'changes user profile' do
+ fill_in 'user_skype', with: 'testskype'
+ fill_in 'user_linkedin', with: 'testlinkedin'
+ fill_in 'user_twitter', with: 'testtwitter'
+ fill_in 'user_website_url', with: 'testurl'
+ fill_in 'user_location', with: 'Ukraine'
+ fill_in 'user_bio', with: 'I <3 GitLab'
+ fill_in 'user_organization', with: 'GitLab'
+ click_button 'Update profile settings'
+
+ expect(user.reload).to have_attributes(
+ skype: 'testskype',
+ linkedin: 'testlinkedin',
+ twitter: 'testtwitter',
+ website_url: 'testurl',
+ bio: 'I <3 GitLab',
+ organization: 'GitLab'
+ )
+
+ expect(find('#user_location').value).to eq 'Ukraine'
+ expect(page).to have_content('Profile was successfully updated')
+ end
+
+ context 'user avatar' do
+ before do
+ attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif'))
+ click_button 'Update profile settings'
+ end
+
+ it 'changes user avatar' do
+ expect(page).to have_link('Remove avatar')
+
+ user.reload
+ expect(user.avatar).to be_instance_of AvatarUploader
+ expect(user.avatar.url).to eq "/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif"
+ end
+
+ it 'removes user avatar' do
+ click_link 'Remove avatar'
+
+ user.reload
+
+ expect(user.avatar?).to eq false
+ expect(page).not_to have_link('Remove avatar')
+ expect(page).to have_link('gravatar.com')
+ end
+ end
+end
diff --git a/spec/features/profiles/user_manages_applications_spec.rb b/spec/features/profiles/user_manages_applications_spec.rb
new file mode 100644
index 00000000000..387584fef62
--- /dev/null
+++ b/spec/features/profiles/user_manages_applications_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe 'User manages applications' do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ visit applications_profile_path
+ end
+
+ it 'manages applications' do
+ expect(page).to have_content 'Add new application'
+
+ fill_in :doorkeeper_application_name, with: 'test'
+ fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com'
+ click_on 'Save application'
+
+ expect(page).to have_content 'Application: test'
+ expect(page).to have_content 'Application Id'
+ expect(page).to have_content 'Secret'
+
+ click_on 'Edit'
+
+ expect(page).to have_content 'Edit application'
+ fill_in :doorkeeper_application_name, with: 'test_changed'
+ click_on 'Save application'
+
+ expect(page).to have_content 'test_changed'
+ expect(page).to have_content 'Application Id'
+ expect(page).to have_content 'Secret'
+
+ visit applications_profile_path
+
+ page.within '.oauth-applications' do
+ click_on 'Destroy'
+ end
+ expect(page.find('.oauth-applications')).not_to have_content 'test_changed'
+ end
+end
diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb
index df89918f17a..95953fbcfac 100644
--- a/spec/features/profiles/user_visits_notifications_tab_spec.rb
+++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb
@@ -5,7 +5,7 @@ feature 'User visits the notifications tab', :js do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit(profile_notifications_path)
end
@@ -16,6 +16,6 @@ feature 'User visits the notifications tab', :js do
first('#notifications-button').click
click_link('On mention')
- expect(page).to have_content('On mention')
+ expect(page).to have_selector('#notifications-button', text: 'On mention')
end
end
diff --git a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb
index a50ebb29e01..0f419c3c2c0 100644
--- a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb
+++ b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb
@@ -3,13 +3,28 @@ require 'spec_helper'
describe 'User visits the authentication log' do
let(:user) { create(:user) }
- before do
- sign_in(user)
+ context 'when user signed in' do
+ before do
+ sign_in(user)
+ end
- visit(audit_log_profile_path)
+ it 'shows correct menu item' do
+ visit(audit_log_profile_path)
+
+ expect(page).to have_active_navigation('Authentication log')
+ end
end
- it 'shows correct menu item' do
- expect(page).to have_active_navigation('Authentication log')
+ context 'when user has activity' do
+ before do
+ create(:closed_issue_event, author: user)
+ gitlab_sign_in(user)
+ end
+
+ it 'shows user activity' do
+ visit(audit_log_profile_path)
+
+ expect(page).to have_content 'Signed in with standard authentication'
+ end
end
end
diff --git a/spec/features/profiles/user_visits_profile_spec.rb b/spec/features/profiles/user_visits_profile_spec.rb
index 6601d3039ed..713112477c8 100644
--- a/spec/features/profiles/user_visits_profile_spec.rb
+++ b/spec/features/profiles/user_visits_profile_spec.rb
@@ -5,11 +5,58 @@ describe 'User visits their profile' do
before do
sign_in(user)
-
- visit(profile_path)
end
it 'shows correct menu item' do
+ visit(profile_path)
+
expect(page).to have_active_navigation('Profile')
end
+
+ it 'shows profile info' do
+ visit(profile_path)
+
+ expect(page).to have_content "This information will appear on your profile"
+ end
+
+ context 'when user has groups' do
+ let(:group) do
+ create :group do |group|
+ group.add_owner(user)
+ end
+ end
+
+ let!(:project) do
+ create(:project, :repository, namespace: group) do |project|
+ create(:closed_issue_event, project: project)
+ project.add_master(user)
+ end
+ end
+
+ def click_on_profile_picture
+ find(:css, '.header-user-dropdown-toggle').click
+
+ page.within ".header-user" do
+ click_link "Profile"
+ end
+ end
+
+ it 'shows user groups', :js do
+ visit(profile_path)
+ click_on_profile_picture
+
+ page.within ".cover-block" do
+ expect(page).to have_content user.name
+ expect(page).to have_content user.username
+ end
+
+ page.within ".content" do
+ click_link "Groups"
+ end
+
+ page.within "#groups" do
+ expect(page).to have_content group.name
+ end
+ end
+ end
end
diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb
new file mode 100644
index 00000000000..0ba2224359a
--- /dev/null
+++ b/spec/features/project_variables_spec.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe 'Project variables', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:variable) { create(:ci_variable, key: 'test_key', value: 'test value') }
+ let(:page_path) { project_settings_ci_cd_path(project) }
+
+ before do
+ sign_in(user)
+ project.add_master(user)
+ project.variables << variable
+
+ visit page_path
+ end
+
+ it_behaves_like 'variable list'
+end
diff --git a/spec/features/projects/activity/rss_spec.rb b/spec/features/projects/activity/rss_spec.rb
index 84c2faa2015..2693e539268 100644
--- a/spec/features/projects/activity/rss_spec.rb
+++ b/spec/features/projects/activity/rss_spec.rb
@@ -11,7 +11,7 @@ feature 'Project Activity RSS' do
context 'when signed in' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit path
end
diff --git a/spec/features/projects/actve_tabs_spec.rb b/spec/features/projects/actve_tabs_spec.rb
new file mode 100644
index 00000000000..0bda68b83e7
--- /dev/null
+++ b/spec/features/projects/actve_tabs_spec.rb
@@ -0,0 +1,137 @@
+require 'spec_helper'
+
+describe 'Project active tab' do
+ let(:user) { create :user }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ def click_tab(title)
+ page.within '.sidebar-top-level-items > .active' do
+ click_link(title)
+ end
+ end
+
+ shared_examples 'page has active tab' do |title|
+ it "activates #{title} tab" do
+ expect(page).to have_selector('.sidebar-top-level-items > li.active', count: 1)
+ expect(find('.sidebar-top-level-items > li.active')).to have_content(title)
+ end
+ end
+
+ shared_examples 'page has active sub tab' do |title|
+ it "activates #{title} sub tab" do
+ expect(page).to have_selector('.sidebar-sub-level-items > li.active:not(.fly-out-top-item)', count: 1)
+ expect(find('.sidebar-sub-level-items > li.active:not(.fly-out-top-item)'))
+ .to have_content(title)
+ end
+ end
+
+ context 'on project Home' do
+ before do
+ visit project_path(project)
+ end
+
+ it_behaves_like 'page has active tab', 'Overview'
+ it_behaves_like 'page has active sub tab', 'Details'
+
+ context 'on project Home/Activity' do
+ before do
+ click_tab('Activity')
+ end
+
+ it_behaves_like 'page has active tab', 'Overview'
+ it_behaves_like 'page has active sub tab', 'Activity'
+ end
+ end
+
+ context 'on project Repository' do
+ before do
+ root_ref = project.repository.root_ref
+ visit project_tree_path(project, root_ref)
+ end
+
+ it_behaves_like 'page has active tab', 'Repository'
+
+ %w(Files Commits Graph Compare Charts Branches Tags).each do |sub_menu|
+ context "on project Repository/#{sub_menu}" do
+ before do
+ click_tab(sub_menu)
+ end
+
+ it_behaves_like 'page has active tab', 'Repository'
+ it_behaves_like 'page has active sub tab', sub_menu
+ end
+ end
+ end
+
+ context 'on project Issues' do
+ before do
+ visit project_issues_path(project)
+ end
+
+ it_behaves_like 'page has active tab', 'Issues'
+
+ %w(Milestones Labels).each do |sub_menu|
+ context "on project Issues/#{sub_menu}" do
+ before do
+ click_tab(sub_menu)
+ end
+
+ it_behaves_like 'page has active tab', 'Issues'
+ it_behaves_like 'page has active sub tab', sub_menu
+ end
+ end
+ end
+
+ context 'on project Merge Requests' do
+ before do
+ visit project_merge_requests_path(project)
+ end
+
+ it_behaves_like 'page has active tab', 'Merge Requests'
+ end
+
+ context 'on project Wiki' do
+ before do
+ visit project_wiki_path(project, :home)
+ end
+
+ it_behaves_like 'page has active tab', 'Wiki'
+ end
+
+ context 'on project Members' do
+ before do
+ visit project_project_members_path(project)
+ end
+
+ it_behaves_like 'page has active tab', 'Members'
+ end
+
+ context 'on project Settings' do
+ before do
+ visit edit_project_path(project)
+ end
+
+ context 'on project Settings/Integrations' do
+ before do
+ click_tab('Integrations')
+ end
+
+ it_behaves_like 'page has active tab', 'Settings'
+ it_behaves_like 'page has active sub tab', 'Integrations'
+ end
+
+ context 'on project Settings/Repository' do
+ before do
+ click_tab('Repository')
+ end
+
+ it_behaves_like 'page has active tab', 'Settings'
+ it_behaves_like 'page has active sub tab', 'Repository'
+ end
+ end
+end
diff --git a/spec/features/projects/badges/coverage_spec.rb b/spec/features/projects/badges/coverage_spec.rb
index c68e10a2563..f51001edcd7 100644
--- a/spec/features/projects/badges/coverage_spec.rb
+++ b/spec/features/projects/badges/coverage_spec.rb
@@ -6,7 +6,7 @@ feature 'test coverage badge' do
context 'when user has access to view badge' do
background do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
end
@@ -18,7 +18,7 @@ feature 'test coverage badge' do
show_test_coverage_badge
- expect_coverage_badge('95%')
+ expect_coverage_badge('95.00%')
end
scenario 'user requests coverage badge for specific job' do
@@ -30,7 +30,7 @@ feature 'test coverage badge' do
show_test_coverage_badge(job: 'coverage')
- expect_coverage_badge('85%')
+ expect_coverage_badge('85.00%')
end
scenario 'user requests coverage badge for pipeline without coverage' do
diff --git a/spec/features/projects/badges/list_spec.rb b/spec/features/projects/badges/list_spec.rb
index 68c4a647958..0abef4bc447 100644
--- a/spec/features/projects/badges/list_spec.rb
+++ b/spec/features/projects/badges/list_spec.rb
@@ -4,9 +4,9 @@ feature 'list of badges' do
background do
user = create(:user)
project = create(:project, :repository)
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
- visit project_pipelines_settings_path(project)
+ visit project_settings_ci_cd_path(project)
end
scenario 'user wants to see build status badge' do
diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index 88813d9b5ff..ac82f869f0f 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -509,4 +509,29 @@ feature 'File blob', :js do
end
end
end
+
+ context 'realtime pipelines' do
+ before do
+ Files::CreateService.new(
+ project,
+ project.creator,
+ start_branch: 'feature',
+ branch_name: 'feature',
+ commit_message: "Add ruby file",
+ file_path: 'files/ruby/test.rb',
+ file_content: "# Awesome content"
+ ).execute
+
+ create(:ci_pipeline, status: 'running', project: project, ref: 'feature', sha: project.commit('feature').sha)
+ visit_blob('files/ruby/test.rb', ref: 'feature')
+ end
+
+ it 'should show the realtime pipeline status' do
+ page.within('.commit-actions') do
+ expect(page).to have_css('.ci-status-icon')
+ expect(page).to have_css('.ci-status-icon-running')
+ expect(page).to have_css('.js-ci-status-icon-running')
+ end
+ end
+ end
end
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index 965028a6f90..89d3bd24b89 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -13,16 +13,19 @@ feature 'Editing file blob', :js do
let(:role) { :developer }
before do
- project.team << [user, role]
+ project.add_role(user, role)
sign_in(user)
end
- def edit_and_commit
+ def edit_and_commit(commit_changes: true)
wait_for_requests
find('.js-edit-blob').click
find('#editor')
execute_script('ace.edit("editor").setValue("class NextFeature\nend\n")')
- click_button 'Commit changes'
+
+ if commit_changes
+ click_button 'Commit changes'
+ end
end
context 'from MR diff' do
@@ -39,13 +42,26 @@ feature 'Editing file blob', :js do
context 'from blob file path' do
before do
visit project_blob_path(project, tree_join(branch, file_path))
- edit_and_commit
end
it 'updates content' do
+ edit_and_commit
+
expect(page).to have_content 'successfully committed'
expect(page).to have_content 'NextFeature'
end
+
+ it 'previews content' do
+ edit_and_commit(commit_changes: false)
+ click_link 'Preview changes'
+ wait_for_requests
+
+ old_line_count = page.all('.line_holder.old').size
+ new_line_count = page.all('.line_holder.new').size
+
+ expect(old_line_count).to be > 0
+ expect(new_line_count).to be > 0
+ end
end
end
@@ -55,7 +71,7 @@ feature 'Editing file blob', :js do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
visit project_edit_blob_path(project, tree_join(branch, file_path))
end
@@ -90,7 +106,7 @@ feature 'Editing file blob', :js do
let(:protected_branch) { 'protected-branch' }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
project.repository.add_branch(user, protected_branch, 'master')
create(:protected_branch, project: project, name: protected_branch)
sign_in(user)
@@ -122,7 +138,7 @@ feature 'Editing file blob', :js do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_edit_blob_path(project, tree_join(branch, file_path))
end
diff --git a/spec/features/projects/branches/download_buttons_spec.rb b/spec/features/projects/branches/download_buttons_spec.rb
index 2f407b13c2f..605298ba8ab 100644
--- a/spec/features/projects/branches/download_buttons_spec.rb
+++ b/spec/features/projects/branches/download_buttons_spec.rb
@@ -23,13 +23,13 @@ feature 'Download buttons in branches page' do
background do
sign_in(user)
- project.team << [user, role]
+ project.add_role(user, role)
end
describe 'when checking branches' do
context 'with artifacts' do
before do
- visit project_branches_path(project, search: 'binary-encoding')
+ visit project_branches_filtered_path(project, state: 'all', search: 'binary-encoding')
end
scenario 'shows download artifacts button' do
diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb
index 7a77df83034..2a9d9e6416c 100644
--- a/spec/features/projects/branches_spec.rb
+++ b/spec/features/projects/branches_spec.rb
@@ -8,18 +8,112 @@ describe 'Branches' do
context 'logged in as developer' do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
- describe 'Initial branches page' do
- it 'shows all the branches sorted by last updated by default' do
+ context 'on the projects with 6 active branches and 4 stale branches' do
+ let(:project) { create(:project, :public, :empty_repo) }
+ let(:repository) { project.repository }
+ let(:threshold) { Gitlab::Git::Branch::STALE_BRANCH_THRESHOLD }
+
+ before do
+ # Add 4 stale branches
+ (1..4).reverse_each do |i|
+ Timecop.freeze((threshold + i).ago) { create_file(message: "a commit in stale-#{i}", branch_name: "stale-#{i}") }
+ end
+ # Add 6 active branches
+ (1..6).each do |i|
+ Timecop.freeze((threshold - i).ago) { create_file(message: "a commit in active-#{i}", branch_name: "active-#{i}") }
+ end
+ end
+
+ describe 'Overview page of the branches' do
+ it 'shows the first 5 active branches and the first 4 stale branches sorted by last updated' do
+ visit project_branches_path(project)
+
+ expect(page).to have_content(sorted_branches(repository, count: 5, sort_by: :updated_desc, state: 'active'))
+ expect(page).to have_content(sorted_branches(repository, count: 4, sort_by: :updated_desc, state: 'stale'))
+
+ expect(page).to have_link('Show more active branches', href: project_branches_filtered_path(project, state: 'active'))
+ expect(page).not_to have_content('Show more stale branches')
+ end
+ end
+
+ describe 'Active branches page' do
+ it 'shows 6 active branches sorted by last updated' do
+ visit project_branches_filtered_path(project, state: 'active')
+
+ expect(page).to have_content(sorted_branches(repository, count: 6, sort_by: :updated_desc, state: 'active'))
+ end
+ end
+
+ describe 'Stale branches page' do
+ it 'shows 4 active branches sorted by last updated' do
+ visit project_branches_filtered_path(project, state: 'stale')
+
+ expect(page).to have_content(sorted_branches(repository, count: 4, sort_by: :updated_desc, state: 'stale'))
+ end
+ end
+
+ describe 'All branches page' do
+ it 'shows 10 branches sorted by last updated' do
+ visit project_branches_filtered_path(project, state: 'all')
+
+ expect(page).to have_content(sorted_branches(repository, count: 10, sort_by: :updated_desc))
+ end
+ end
+
+ context 'with branches over more than one page' do
+ before do
+ allow(Kaminari.config).to receive(:default_per_page).and_return(5)
+ end
+
+ it 'shows only default_per_page active branches sorted by last updated' do
+ visit project_branches_filtered_path(project, state: 'active')
+
+ expect(page).to have_content(sorted_branches(repository, count: Kaminari.config.default_per_page, sort_by: :updated_desc, state: 'active'))
+ end
+
+ it 'shows only default_per_page branches sorted by last updated on All branches' do
+ visit project_branches_filtered_path(project, state: 'all')
+
+ expect(page).to have_content(sorted_branches(repository, count: Kaminari.config.default_per_page, sort_by: :updated_desc))
+ end
+ end
+ end
+
+ describe 'Find branches' do
+ it 'shows filtered branches', :js 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)
+ end
+ end
+
+ describe 'Delete unprotected branch on Overview' do
+ it 'removes branch after confirmation', :js do
+ visit project_branches_filtered_path(project, state: 'all')
+
+ expect(all('.all-branches').last).to have_selector('li', count: 20)
+ accept_confirm { find('.js-branch-add-pdf-text-binary .btn-remove').click }
+
+ expect(all('.all-branches').last).to have_selector('li', count: 19)
+ end
+ end
+
+ describe 'All branches page' do
+ it 'shows all the branches sorted by last updated by default' do
+ visit project_branches_filtered_path(project, state: 'all')
+
expect(page).to have_content(sorted_branches(repository, count: 20, sort_by: :updated_desc))
end
it 'sorts the branches by name' do
- visit project_branches_path(project)
+ visit project_branches_filtered_path(project, state: 'all')
click_button "Last updated" # Open sorting dropdown
click_link "Name"
@@ -28,7 +122,7 @@ describe 'Branches' do
end
it 'sorts the branches by oldest updated' do
- visit project_branches_path(project)
+ visit project_branches_filtered_path(project, state: 'all')
click_button "Last updated" # Open sorting dropdown
click_link "Oldest updated"
@@ -41,13 +135,13 @@ describe 'Branches' do
%w(one two three four five).each { |ref| repository.add_branch(user, ref, 'master') }
- expect { visit project_branches_path(project) }.not_to exceed_query_limit(control_count)
+ expect { visit project_branches_filtered_path(project, state: 'all') }.not_to exceed_query_limit(control_count)
end
end
- describe 'Find branches' do
+ describe 'Find branches on All branches' do
it 'shows filtered branches', :js do
- visit project_branches_path(project)
+ visit project_branches_filtered_path(project, state: 'all')
fill_in 'branch-search', with: 'fix'
find('#branch-search').native.send_keys(:enter)
@@ -57,9 +151,9 @@ describe 'Branches' do
end
end
- describe 'Delete unprotected branch' do
+ describe 'Delete unprotected branch on All branches' do
it 'removes branch after confirmation', :js do
- visit project_branches_path(project)
+ visit project_branches_filtered_path(project, state: 'all')
fill_in 'branch-search', with: 'fix'
@@ -73,17 +167,30 @@ describe 'Branches' do
expect(find('.all-branches')).to have_selector('li', count: 0)
end
end
+
+ context 'on project with 0 branch' do
+ let(:project) { create(:project, :public, :empty_repo) }
+ let(:repository) { project.repository }
+
+ describe '0 branches on Overview' do
+ it 'shows warning' do
+ visit project_branches_path(project)
+
+ expect(page).not_to have_selector('.all-branches')
+ end
+ end
+ end
end
context 'logged in as master' do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'Initial branches page' do
it 'shows description for admin' do
- visit project_branches_path(project)
+ visit project_branches_filtered_path(project, state: 'all')
expect(page).to have_content("Protected branches can be managed in project settings")
end
@@ -102,12 +209,18 @@ describe 'Branches' do
end
end
- def sorted_branches(repository, count:, sort_by:)
+ def sorted_branches(repository, count:, sort_by:, state: nil)
+ branches = repository.branches_sorted_by(sort_by)
+ branches = branches.select { |b| state == 'active' ? b.active? : b.stale? } if state
sorted_branches =
- repository.branches_sorted_by(sort_by).first(count).map do |branch|
+ branches.first(count).map do |branch|
Regexp.escape(branch.name)
end
Regexp.new(sorted_branches.join('.*'))
end
+
+ def create_file(message: 'message', branch_name:)
+ repository.create_file(user, generate(:branch), 'content', message: message, branch_name: branch_name)
+ end
end
diff --git a/spec/features/projects/ci/lint_spec.rb b/spec/features/projects/ci/lint_spec.rb
new file mode 100644
index 00000000000..313950072e7
--- /dev/null
+++ b/spec/features/projects/ci/lint_spec.rb
@@ -0,0 +1,74 @@
+require 'spec_helper'
+
+describe 'CI Lint', :js do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit project_ci_lint_path(project)
+ find('#ci-editor')
+ execute_script("ace.edit('ci-editor').setValue(#{yaml_content.to_json});")
+
+ # Ace editor updates a hidden textarea and it happens asynchronously
+ wait_for('YAML content') do
+ find('.ace_content').text.present?
+ end
+ end
+
+ describe 'YAML parsing' do
+ before do
+ click_on 'Validate'
+ end
+
+ context 'YAML is correct' do
+ let(:yaml_content) do
+ File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ end
+
+ it 'parses Yaml' do
+ within "table" do
+ expect(page).to have_content('Job - rspec')
+ expect(page).to have_content('Job - spinach')
+ expect(page).to have_content('Deploy Job - staging')
+ expect(page).to have_content('Deploy Job - production')
+ end
+ end
+ end
+
+ context 'YAML is incorrect' do
+ let(:yaml_content) { 'value: cannot have :' }
+
+ it 'displays information about an error' do
+ expect(page).to have_content('Status: syntax is incorrect')
+ expect(page).to have_selector('.ace_content', text: yaml_content)
+ end
+ end
+
+ describe 'YAML revalidate' do
+ let(:yaml_content) { 'my yaml content' }
+
+ it 'loads previous YAML content after validation' do
+ expect(page).to have_field('content', with: 'my yaml content', visible: false, type: 'textarea')
+ end
+ end
+ end
+
+ describe 'YAML clearing' do
+ before do
+ click_on 'Clear'
+ end
+
+ context 'YAML is present' do
+ let(:yaml_content) do
+ File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ end
+
+ it 'YAML content is cleared' do
+ expect(page).to have_field('content', with: '', visible: false, type: 'textarea')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb
index b34cd061ec6..7b2c57aa652 100644
--- a/spec/features/projects/clusters/applications_spec.rb
+++ b/spec/features/projects/clusters/applications_spec.rb
@@ -22,7 +22,7 @@ feature 'Clusters Applications', :js do
scenario 'user is unable to install applications' do
page.within('.js-cluster-application-row-helm') do
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page.find(:css, '.js-cluster-application-install-button').text).to eq('Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
end
end
end
@@ -33,13 +33,13 @@ feature 'Clusters Applications', :js do
scenario 'user can install applications' do
page.within('.js-cluster-application-row-helm') do
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to be_nil
- expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
end
end
context 'when user installs Helm' do
before do
- allow(ClusterInstallAppWorker).to receive(:perform_async).and_return(nil)
+ allow(ClusterInstallAppWorker).to receive(:perform_async)
page.within('.js-cluster-application-row-helm') do
page.find(:css, '.js-cluster-application-install-button').click
@@ -50,32 +50,35 @@ feature 'Clusters Applications', :js do
page.within('.js-cluster-application-row-helm') do
# FE sends request and gets the response, then the buttons is "Install"
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
Clusters::Cluster.last.application_helm.make_installing!
# FE starts polling and update the buttons to "Installing"
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installing')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
Clusters::Cluster.last.application_helm.make_installed!
expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installed')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installed')
end
- expect(page).to have_content('Helm Tiller was successfully installed on your cluster')
+ expect(page).to have_content('Helm Tiller was successfully installed on your Kubernetes cluster')
end
end
context 'when user installs Ingress' do
context 'when user installs application: Ingress' do
before do
- allow(ClusterInstallAppWorker).to receive(:perform_async).and_return(nil)
+ allow(ClusterInstallAppWorker).to receive(:perform_async)
+ allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
+ allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async)
- create(:cluster_applications_helm, :installed, cluster: cluster)
+ create(:clusters_applications_helm, :installed, cluster: cluster)
page.within('.js-cluster-application-row-ingress') do
+ expect(page).to have_css('.js-cluster-application-install-button:not([disabled])')
page.find(:css, '.js-cluster-application-install-button').click
end
end
@@ -83,22 +86,31 @@ feature 'Clusters Applications', :js do
it 'he sees status transition' do
page.within('.js-cluster-application-row-ingress') do
# FE sends request and gets the response, then the buttons is "Install"
- expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install')
+ expect(page).to have_css('.js-cluster-application-install-button[disabled]')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install')
Clusters::Cluster.last.application_ingress.make_installing!
# FE starts polling and update the buttons to "Installing"
- expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installing')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing')
+ expect(page).to have_css('.js-cluster-application-install-button[disabled]')
+ # The application becomes installed but we keep waiting for external IP address
Clusters::Cluster.last.application_ingress.make_installed!
- expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
- expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installed')
+ expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installed')
+ expect(page).to have_css('.js-cluster-application-install-button[disabled]')
+ expect(page).to have_selector('.js-no-ip-message')
+ expect(page.find('.js-ip-address').value).to eq('?')
+
+ # We receive the external IP address and display
+ Clusters::Cluster.last.application_ingress.update!(external_ip: '192.168.1.100')
+
+ expect(page).not_to have_selector('.js-no-ip-message')
+ expect(page.find('.js-ip-address').value).to eq('192.168.1.100')
end
- expect(page).to have_content('Ingress was successfully installed on your cluster')
+ expect(page).to have_content('Ingress was successfully installed on your Kubernetes cluster')
end
end
end
diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb
index 67b8901f8fb..4d47cdb500c 100644
--- a/spec/features/projects/clusters/gcp_spec.rb
+++ b/spec/features/projects/clusters/gcp_spec.rb
@@ -13,6 +13,8 @@ feature 'Gcp Cluster', :js do
end
context 'when user has signed with Google' do
+ let(:project_id) { 'test-project-1234' }
+
before do
allow_any_instance_of(Projects::Clusters::GcpController)
.to receive(:token_in_session).and_return('token')
@@ -20,105 +22,152 @@ feature 'Gcp Cluster', :js do
.to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
end
- context 'when user does not have a cluster and visits cluster index page' do
+ context 'when user has a GCP project with billing enabled' do
before do
- visit project_clusters_path(project)
-
- click_link 'Add cluster'
- click_link 'Create on GKE'
+ allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
+ allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(true)
end
- context 'when user filled form with valid parameters' do
+ context 'when user does not have a cluster and visits cluster index page' do
before do
- allow_any_instance_of(GoogleApi::CloudPlatform::Client)
- .to receive(:projects_zones_clusters_create) do
- OpenStruct.new(
- self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123',
- status: 'RUNNING'
- )
+ visit project_clusters_path(project)
+
+ click_link 'Add Kubernetes cluster'
+ click_link 'Create on GKE'
+ end
+
+ context 'when user filled form with valid parameters' do
+ before do
+ allow_any_instance_of(GoogleApi::CloudPlatform::Client)
+ .to receive(:projects_zones_clusters_create) do
+ OpenStruct.new(
+ self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123',
+ status: 'RUNNING'
+ )
+ end
+
+ allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
+
+ fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
+ fill_in 'cluster_name', with: 'dev-cluster'
+ click_button 'Create Kubernetes cluster'
end
- allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
+ it 'user sees a cluster details page and creation status' do
+ expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...')
- fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
- fill_in 'cluster_name', with: 'dev-cluster'
- click_button 'Create cluster'
- end
+ Clusters::Cluster.last.provider.make_created!
- it 'user sees a cluster details page and creation status' do
- expect(page).to have_content('Cluster is being created on Google Kubernetes Engine...')
+ expect(page).to have_content('Kubernetes cluster was successfully created on Google Kubernetes Engine')
+ end
- Clusters::Cluster.last.provider.make_created!
+ it 'user sees a error if something worng during creation' do
+ expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...')
- expect(page).to have_content('Cluster was successfully created on Google Kubernetes Engine')
- end
+ Clusters::Cluster.last.provider.make_errored!('Something wrong!')
- it 'user sees a error if something worng during creation' do
- expect(page).to have_content('Cluster is being created on Google Kubernetes Engine...')
+ expect(page).to have_content('Something wrong!')
+ end
+ end
- Clusters::Cluster.last.provider.make_errored!('Something wrong!')
+ context 'when user filled form with invalid parameters' do
+ before do
+ click_button 'Create Kubernetes cluster'
+ end
- expect(page).to have_content('Something wrong!')
+ it 'user sees a validation error' do
+ expect(page).to have_css('#error_explanation')
+ end
end
end
- context 'when user filled form with invalid parameters' do
+ context 'when user does have a cluster and visits cluster page' do
+ let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
+
before do
- click_button 'Create cluster'
+ visit project_cluster_path(project, cluster)
end
- it 'user sees a validation error' do
- expect(page).to have_css('#error_explanation')
+ it 'user sees a cluster details page' do
+ expect(page).to have_button('Save changes')
+ expect(page.find(:css, '.cluster-name').value).to eq(cluster.name)
end
- end
- end
- context 'when user does have a cluster and visits cluster page' do
- let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
+ context 'when user disables the cluster' do
+ before do
+ page.find(:css, '.js-cluster-enable-toggle-area .js-project-feature-toggle').click
+ page.within('#cluster-integration') { click_button 'Save changes' }
+ end
- before do
- visit project_cluster_path(project, cluster)
- end
+ it 'user sees the successful message' do
+ expect(page).to have_content('Kubernetes cluster was successfully updated.')
+ end
+ end
- it 'user sees a cluster details page' do
- expect(page).to have_button('Save')
- expect(page.find(:css, '.cluster-name').value).to eq(cluster.name)
- end
+ context 'when user changes cluster parameters' do
+ before do
+ fill_in 'cluster_platform_kubernetes_attributes_namespace', with: 'my-namespace'
+ page.within('#js-cluster-details') { click_button 'Save changes' }
+ end
- context 'when user disables the cluster' do
- before do
- page.find(:css, '.js-toggle-cluster').click
- click_button 'Save'
+ it 'user sees the successful message' do
+ expect(page).to have_content('Kubernetes cluster was successfully updated.')
+ expect(cluster.reload.platform_kubernetes.namespace).to eq('my-namespace')
+ end
end
- it 'user sees the successful message' do
- expect(page).to have_content('Cluster was successfully updated.')
+ context 'when user destroy the cluster' do
+ before do
+ page.accept_confirm do
+ click_link 'Remove integration'
+ end
+ end
+
+ it 'user sees creation form with the successful message' do
+ expect(page).to have_content('Kubernetes cluster integration was successfully removed.')
+ expect(page).to have_link('Add Kubernetes cluster')
+ end
end
end
+ end
- context 'when user changes cluster parameters' do
- before do
- fill_in 'cluster_platform_kubernetes_attributes_namespace', with: 'my-namespace'
- click_button 'Save changes'
- end
+ context 'when user does not have a GCP project with billing enabled' do
+ before do
+ allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
+ allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(false)
- it 'user sees the successful message' do
- expect(page).to have_content('Cluster was successfully updated.')
- expect(cluster.reload.platform_kubernetes.namespace).to eq('my-namespace')
- end
+ visit project_clusters_path(project)
+
+ click_link 'Add Kubernetes cluster'
+ click_link 'Create on GKE'
+
+ fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
+ fill_in 'cluster_name', with: 'dev-cluster'
+ click_button 'Create Kubernetes cluster'
end
- context 'when user destroy the cluster' do
- before do
- page.accept_confirm do
- click_link 'Remove integration'
- end
- end
+ it 'user sees form with error' do
+ expect(page).to have_content('Please enable billing for one of your projects to be able to create a Kubernetes cluster, then try again.')
+ end
+ end
- it 'user sees creation form with the successful message' do
- expect(page).to have_content('Cluster integration was successfully removed.')
- expect(page).to have_link('Add cluster')
- end
+ context 'when gcp billing status is not in redis' do
+ before do
+ allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
+ allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(nil)
+
+ visit project_clusters_path(project)
+
+ click_link 'Add Kubernetes cluster'
+ click_link 'Create on GKE'
+
+ fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
+ fill_in 'cluster_name', with: 'dev-cluster'
+ click_button 'Create Kubernetes cluster'
+ end
+
+ it 'user sees form with error' do
+ expect(page).to have_content('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
end
end
end
@@ -127,7 +176,7 @@ feature 'Gcp Cluster', :js do
before do
visit project_clusters_path(project)
- click_link 'Add cluster'
+ click_link 'Add Kubernetes cluster'
click_link 'Create on GKE'
end
diff --git a/spec/features/projects/clusters/interchangeability_spec.rb b/spec/features/projects/clusters/interchangeability_spec.rb
index 01f9526608f..3ddb35c755c 100644
--- a/spec/features/projects/clusters/interchangeability_spec.rb
+++ b/spec/features/projects/clusters/interchangeability_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
feature 'Interchangeability between KubernetesService and Platform::Kubernetes' do
- EXCEPT_METHODS = %i[test title description help fields initialize_properties namespace namespace= api_url api_url=].freeze
+ EXCEPT_METHODS = %i[test title description help fields initialize_properties namespace namespace= api_url api_url= deprecated? deprecation_message].freeze
EXCEPT_METHODS_GREP_V = %w[_touched? _changed? _was].freeze
it 'Clusters::Platform::Kubernetes covers core interfaces in KubernetesService' do
diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb
index 414f4acba86..698b64a659c 100644
--- a/spec/features/projects/clusters/user_spec.rb
+++ b/spec/features/projects/clusters/user_spec.rb
@@ -16,8 +16,8 @@ feature 'User Cluster', :js do
before do
visit project_clusters_path(project)
- click_link 'Add cluster'
- click_link 'Add an existing cluster'
+ click_link 'Add Kubernetes cluster'
+ click_link 'Add an existing Kubernetes cluster'
end
context 'when user filled form with valid parameters' do
@@ -25,11 +25,11 @@ feature 'User Cluster', :js do
fill_in 'cluster_name', with: 'dev-cluster'
fill_in 'cluster_platform_kubernetes_attributes_api_url', with: 'http://example.com'
fill_in 'cluster_platform_kubernetes_attributes_token', with: 'my-token'
- click_button 'Add cluster'
+ click_button 'Add Kubernetes cluster'
end
it 'user sees a cluster details page' do
- expect(page).to have_content('Enable cluster integration')
+ expect(page).to have_content('Kubernetes cluster integration')
expect(page.find_field('cluster[name]').value).to eq('dev-cluster')
expect(page.find_field('cluster[platform_kubernetes_attributes][api_url]').value)
.to have_content('http://example.com')
@@ -40,7 +40,7 @@ feature 'User Cluster', :js do
context 'when user filled form with invalid parameters' do
before do
- click_button 'Add cluster'
+ click_button 'Add Kubernetes cluster'
end
it 'user sees a validation error' do
@@ -57,18 +57,18 @@ feature 'User Cluster', :js do
end
it 'user sees a cluster details page' do
- expect(page).to have_button('Save')
+ expect(page).to have_button('Save changes')
end
context 'when user disables the cluster' do
before do
- page.find(:css, '.js-toggle-cluster').click
+ page.find(:css, '.js-cluster-enable-toggle-area .js-project-feature-toggle').click
fill_in 'cluster_name', with: 'dev-cluster'
- click_button 'Save'
+ page.within('#cluster-integration') { click_button 'Save changes' }
end
it 'user sees the successful message' do
- expect(page).to have_content('Cluster was successfully updated.')
+ expect(page).to have_content('Kubernetes cluster was successfully updated.')
end
end
@@ -76,11 +76,11 @@ feature 'User Cluster', :js do
before do
fill_in 'cluster_name', with: 'my-dev-cluster'
fill_in 'cluster_platform_kubernetes_attributes_namespace', with: 'my-namespace'
- click_button 'Save changes'
+ page.within('#js-cluster-details') { click_button 'Save changes' }
end
it 'user sees the successful message' do
- expect(page).to have_content('Cluster was successfully updated.')
+ expect(page).to have_content('Kubernetes cluster was successfully updated.')
expect(cluster.reload.name).to eq('my-dev-cluster')
expect(cluster.reload.platform_kubernetes.namespace).to eq('my-namespace')
end
@@ -94,8 +94,8 @@ feature 'User Cluster', :js do
end
it 'user sees creation form with the successful message' do
- expect(page).to have_content('Cluster integration was successfully removed.')
- expect(page).to have_link('Add cluster')
+ expect(page).to have_content('Kubernetes cluster integration was successfully removed.')
+ expect(page).to have_link('Add Kubernetes cluster')
end
end
end
diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb
index 93929bf6814..bd9f7745cf8 100644
--- a/spec/features/projects/clusters_spec.rb
+++ b/spec/features/projects/clusters_spec.rb
@@ -17,7 +17,7 @@ feature 'Clusters', :js do
end
it 'sees empty state' do
- expect(page).to have_link('Add cluster')
+ expect(page).to have_link('Add Kubernetes cluster')
expect(page).to have_selector('.empty-state')
end
end
@@ -37,13 +37,13 @@ feature 'Clusters', :js do
context 'inline update of cluster' do
it 'user can update cluster' do
- expect(page).to have_selector('.js-toggle-cluster-list')
+ expect(page).to have_selector('.js-project-feature-toggle')
end
context 'with sucessfull request' do
it 'user sees updated cluster' do
expect do
- page.find('.js-toggle-cluster-list').click
+ page.find('.js-project-feature-toggle').click
wait_for_requests
end.to change { cluster.reload.enabled }
@@ -57,7 +57,7 @@ feature 'Clusters', :js do
expect_any_instance_of(Clusters::UpdateService).to receive(:execute).and_call_original
allow_any_instance_of(Clusters::Cluster).to receive(:valid?) { false }
- page.find('.js-toggle-cluster-list').click
+ page.find('.js-project-feature-toggle').click
expect(page).to have_content('Something went wrong on our end.')
expect(page).to have_selector('.is-checked')
@@ -77,4 +77,18 @@ feature 'Clusters', :js do
end
end
end
+
+ context 'when user has not signed in Google' do
+ before do
+ visit project_clusters_path(project)
+
+ click_link 'Add Kubernetes cluster'
+ click_link 'Create on GKE'
+ end
+
+ it 'user sees a login page' do
+ expect(page).to have_css('.signin-with-google')
+ expect(page).to have_link('Google account')
+ end
+ end
end
diff --git a/spec/features/projects/commit/builds_spec.rb b/spec/features/projects/commit/builds_spec.rb
index 79e84a4f0a6..36a746ac83d 100644
--- a/spec/features/projects/commit/builds_spec.rb
+++ b/spec/features/projects/commit/builds_spec.rb
@@ -5,7 +5,7 @@ feature 'project commit pipelines', :js do
background do
user = create(:user)
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb
index c11a95732b2..c4c399e3058 100644
--- a/spec/features/projects/commit/cherry_pick_spec.rb
+++ b/spec/features/projects/commit/cherry_pick_spec.rb
@@ -9,7 +9,7 @@ describe 'Cherry-pick Commits' do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
visit project_commit_path(project, master_pickable_commit.id)
end
diff --git a/spec/features/projects/commits/rss_spec.rb b/spec/features/projects/commits/rss_spec.rb
index db958346f06..0d9c7355ddd 100644
--- a/spec/features/projects/commits/rss_spec.rb
+++ b/spec/features/projects/commits/rss_spec.rb
@@ -7,7 +7,7 @@ feature 'Project Commits RSS' do
context 'when signed in' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit path
end
diff --git a/spec/features/projects/commits/user_browses_commits_spec.rb b/spec/features/projects/commits/user_browses_commits_spec.rb
index 41f3c15a94c..b650c1f4197 100644
--- a/spec/features/projects/commits/user_browses_commits_spec.rb
+++ b/spec/features/projects/commits/user_browses_commits_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'User broweses commits' do
+describe 'User browses commits' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository, namespace: user.namespace) }
@@ -31,6 +31,19 @@ describe 'User broweses commits' do
check_author_link(RepoHelpers.sample_commit.author_email, user)
end
end
+
+ context 'when the blob does not exist' do
+ let(:commit) { create(:commit, project: project) }
+
+ it 'shows a blank label' do
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:blob).and_return(nil)
+ allow_any_instance_of(Gitlab::Diff::File).to receive(:raw_binary?).and_return(true)
+
+ visit(project_commit_path(project, commit))
+
+ expect(find('.diff-file-changes', visible: false)).to have_content('No file name available')
+ end
+ end
end
private
diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb
index 87ffc2a0b90..1fb22fd0e4c 100644
--- a/spec/features/projects/compare_spec.rb
+++ b/spec/features/projects/compare_spec.rb
@@ -5,7 +5,7 @@ describe "Compare", :js do
let(:project) { create(:project, :repository) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_compare_index_path(project, from: "master", to: "master")
end
diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb
index e445758cb5e..886c56e7163 100644
--- a/spec/features/projects/deploy_keys_spec.rb
+++ b/spec/features/projects/deploy_keys_spec.rb
@@ -5,7 +5,7 @@ describe 'Project deploy keys', :js do
let(:project) { create(:project_empty_repo) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
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 36809240f76..bf55917bf4c 100644
--- a/spec/features/projects/developer_views_empty_project_instructions_spec.rb
+++ b/spec/features/projects/developer_views_empty_project_instructions_spec.rb
@@ -5,7 +5,7 @@ feature 'Developer views empty project instructions' do
let(:developer) { create(:user) }
background do
- project.team << [developer, :developer]
+ project.add_developer(developer)
sign_in(developer)
end
diff --git a/spec/features/projects/edit_spec.rb b/spec/features/projects/edit_spec.rb
index 7a372757523..1d4b4d0fdca 100644
--- a/spec/features/projects/edit_spec.rb
+++ b/spec/features/projects/edit_spec.rb
@@ -7,7 +7,7 @@ feature 'Project edit', :js do
context 'feature visibility' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit edit_project_path(project)
diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb
index dfcf97ad495..b233af83eec 100644
--- a/spec/features/projects/environments/environment_spec.rb
+++ b/spec/features/projects/environments/environment_spec.rb
@@ -7,7 +7,7 @@ feature 'Environment' do
background do
sign_in(user)
- project.team << [user, role]
+ project.add_role(user, role)
end
feature 'environment details page' do
@@ -234,7 +234,7 @@ feature 'Environment' do
end
scenario 'user deletes the branch with running environment' do
- visit project_branches_path(project, search: 'feature')
+ visit project_branches_filtered_path(project, state: 'all', search: 'feature')
remove_branch_with_hooks(project, user, 'feature') do
page.within('.js-branch-feature') { find('a.btn-remove').click }
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index 4a05313c14a..5248a783db4 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -6,7 +6,7 @@ feature 'Environments page', :js do
given(:role) { :developer }
background do
- project.team << [user, role]
+ project.add_role(user, role)
sign_in(user)
end
diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb
index 033c45a60bf..b0eb7c5b42a 100644
--- a/spec/features/projects/features_visibility_spec.rb
+++ b/spec/features/projects/features_visibility_spec.rb
@@ -8,7 +8,7 @@ describe 'Edit Project Settings' do
describe 'project features visibility selectors', :js do
before do
- project.team << [member, :master]
+ project.add_master(member)
sign_in(member)
end
@@ -165,7 +165,7 @@ describe 'Edit Project Settings' do
describe 'repository visibility', :js do
before do
- project.team << [member, :master]
+ project.add_master(member)
sign_in(member)
visit edit_project_path(project)
end
@@ -261,7 +261,7 @@ describe 'Edit Project Settings' do
let!(:project) { create(:project, :private) }
before do
- project.team << [member, :guest]
+ project.add_guest(member)
sign_in(member)
visit project_path(project)
end
diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb
index 84197e45dcb..2c38c380d9d 100644
--- a/spec/features/projects/files/browse_files_spec.rb
+++ b/spec/features/projects/files/browse_files_spec.rb
@@ -5,7 +5,7 @@ feature 'user browses project', :js do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_tree_path(project, project.default_branch)
end
diff --git a/spec/features/projects/files/creating_a_file_spec.rb b/spec/features/projects/files/creating_a_file_spec.rb
index e1852a6e544..8d982636525 100644
--- a/spec/features/projects/files/creating_a_file_spec.rb
+++ b/spec/features/projects/files/creating_a_file_spec.rb
@@ -5,7 +5,7 @@ feature 'User wants to create a file' do
let(:user) { create(:user) }
background do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_new_blob_path(project, project.default_branch)
end
diff --git a/spec/features/projects/files/dockerfile_dropdown_spec.rb b/spec/features/projects/files/dockerfile_dropdown_spec.rb
index 3c3a5326538..f4a39e331fd 100644
--- a/spec/features/projects/files/dockerfile_dropdown_spec.rb
+++ b/spec/features/projects/files/dockerfile_dropdown_spec.rb
@@ -5,7 +5,7 @@ feature 'User wants to add a Dockerfile file' do
before do
user = create(:user)
project = create(:project, :repository)
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
diff --git a/spec/features/projects/files/download_buttons_spec.rb b/spec/features/projects/files/download_buttons_spec.rb
index d2382d55c0b..2101627f324 100644
--- a/spec/features/projects/files/download_buttons_spec.rb
+++ b/spec/features/projects/files/download_buttons_spec.rb
@@ -23,7 +23,7 @@ feature 'Download buttons in files tree' do
background do
sign_in(user)
- project.team << [user, role]
+ project.add_role(user, role)
end
describe 'when files tree' do
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 3ab43b3c656..8d32ada5795 100644
--- a/spec/features/projects/files/edit_file_soft_wrap_spec.rb
+++ b/spec/features/projects/files/edit_file_soft_wrap_spec.rb
@@ -4,7 +4,7 @@ feature 'User uses soft wrap whilst editing file', :js do
before do
user = create(:user)
project = create(:project, :repository)
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_new_blob_path(project, 'master', file_name: 'test_file-name')
page.within('.file-editor.code') do
diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb
index 20be968e89f..d874cdbff8d 100644
--- a/spec/features/projects/files/editing_a_file_spec.rb
+++ b/spec/features/projects/files/editing_a_file_spec.rb
@@ -16,7 +16,7 @@ feature 'User wants to edit a file' do
end
background do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_edit_blob_path(project,
File.join(project.default_branch, '.gitignore'))
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 702b99de733..ead9f7e9168 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
@@ -5,7 +5,7 @@ feature 'User views files page' do
let(:project) { create(:forked_project_with_submodules) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_tree_path(project, project.repository.root_ref)
end
diff --git a/spec/features/projects/files/find_file_keyboard_spec.rb b/spec/features/projects/files/find_file_keyboard_spec.rb
index 618725ee781..e9ff06c72d8 100644
--- a/spec/features/projects/files/find_file_keyboard_spec.rb
+++ b/spec/features/projects/files/find_file_keyboard_spec.rb
@@ -5,7 +5,7 @@ feature 'Find file keyboard shortcuts', :js do
let(:project) { create(:project, :repository) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_find_file_path(project, project.repository.root_ref)
diff --git a/spec/features/projects/files/gitignore_dropdown_spec.rb b/spec/features/projects/files/gitignore_dropdown_spec.rb
index 81d68c3d67c..79f3fd09b48 100644
--- a/spec/features/projects/files/gitignore_dropdown_spec.rb
+++ b/spec/features/projects/files/gitignore_dropdown_spec.rb
@@ -4,7 +4,7 @@ feature 'User wants to add a .gitignore file' do
before do
user = create(:user)
project = create(:project, :repository)
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_new_blob_path(project, 'master', file_name: '.gitignore')
end
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 8e58fa7bd56..db6c67b802e 100644
--- a/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
+++ b/spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb
@@ -4,7 +4,7 @@ feature 'User wants to add a .gitlab-ci.yml file' do
before do
user = create(:user)
project = create(:project, :repository)
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_new_blob_path(project, 'master', file_name: '.gitlab-ci.yml')
end
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 6c5b1086ec1..07599600876 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
@@ -6,7 +6,7 @@ feature 'project owner creates a license file', :js do
background do
project.repository.delete_file(project_master, 'LICENSE',
message: 'Remove LICENSE', branch_name: 'master')
- project.team << [project_master, :master]
+ project.add_master(project_master)
sign_in(project_master)
visit project_path(project)
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 8ac9821b879..7f1d1934103 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
@@ -11,7 +11,7 @@ feature 'project owner sees a link to create a license file in empty project', :
scenario 'project master creates a license file from a template' do
visit project_path(project)
- click_on 'LICENSE'
+ click_on 'Add License'
expect(page).to have_content('New file')
expect(current_path).to eq(
diff --git a/spec/features/projects/files/template_selector_menu_spec.rb b/spec/features/projects/files/template_selector_menu_spec.rb
new file mode 100644
index 00000000000..b549a69ddf3
--- /dev/null
+++ b/spec/features/projects/files/template_selector_menu_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+
+feature 'Template selector menu', :js do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in user
+ end
+
+ context 'editing a non-matching file' do
+ before do
+ create_and_edit_file('README.md')
+ end
+
+ scenario 'is not displayed' do
+ check_template_selector_menu_display(false)
+ end
+
+ context 'user toggles preview' do
+ before do
+ click_link 'Preview'
+ end
+
+ scenario 'template selector menu is not displayed' do
+ check_template_selector_menu_display(false)
+ click_link 'Write'
+ check_template_selector_menu_display(false)
+ end
+ end
+ end
+
+ context 'editing a matching file' do
+ before do
+ visit project_edit_blob_path(project, File.join(project.default_branch, 'LICENSE'))
+ end
+
+ scenario 'is displayed' do
+ check_template_selector_menu_display(true)
+ end
+
+ context 'user toggles preview' do
+ before do
+ click_link 'Preview'
+ end
+
+ scenario 'template selector menu is hidden and shown correctly' do
+ check_template_selector_menu_display(false)
+ click_link 'Write'
+ check_template_selector_menu_display(true)
+ end
+ end
+ end
+end
+
+def check_template_selector_menu_display(is_visible)
+ count = is_visible ? 1 : 0
+ expect(page).to have_css('.template-selectors-menu', count: count)
+end
+
+def create_and_edit_file(file_name)
+ visit project_new_blob_path(project, 'master', file_name: file_name)
+ click_button "Commit changes"
+ visit project_edit_blob_path(project, File.join(project.default_branch, file_name))
+end
diff --git a/spec/features/projects/files/template_type_dropdown_spec.rb b/spec/features/projects/files/template_type_dropdown_spec.rb
index f95a60e5194..97408a9c41e 100644
--- a/spec/features/projects/files/template_type_dropdown_spec.rb
+++ b/spec/features/projects/files/template_type_dropdown_spec.rb
@@ -5,7 +5,7 @@ feature 'Template type dropdown selector', :js do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
end
diff --git a/spec/features/projects/files/undo_template_spec.rb b/spec/features/projects/files/undo_template_spec.rb
index 64fe350f3dc..fbf35fb4e1c 100644
--- a/spec/features/projects/files/undo_template_spec.rb
+++ b/spec/features/projects/files/undo_template_spec.rb
@@ -5,7 +5,7 @@ feature 'Template Undo Button', :js do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
end
diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb
index 842840cc04c..1743b1e083f 100644
--- a/spec/features/projects/fork_spec.rb
+++ b/spec/features/projects/fork_spec.rb
@@ -25,6 +25,110 @@ describe 'Project fork' do
expect(page).to have_css('a.disabled', text: 'Fork')
end
+ it 'forks the project' do
+ visit project_path(project)
+
+ click_link 'Fork'
+
+ page.within '.fork-thumbnail-container' do
+ click_link user.name
+ end
+
+ expect(page).to have_content 'Forked from'
+
+ visit project_path(project)
+
+ expect(page).to have_content(/new merge request/i)
+
+ page.within '.nav-sidebar' do
+ first(:link, 'Merge Requests').click
+ end
+
+ expect(page).to have_content(/new merge request/i)
+
+ page.within '#content-body' do
+ click_link('New merge request')
+ end
+
+ expect(current_path).to have_content(/#{user.namespace.name}/i)
+ end
+
+ it 'shows the forked project on the list' do
+ visit project_path(project)
+
+ click_link 'Fork'
+
+ page.within '.fork-thumbnail-container' do
+ click_link user.name
+ end
+
+ visit project_forks_path(project)
+
+ forked_project = user.fork_of(project.reload)
+
+ page.within('.js-projects-list-holder') do
+ expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}")
+ end
+
+ forked_project.update!(path: 'test-crappy-path')
+
+ visit project_forks_path(project)
+
+ page.within('.js-projects-list-holder') do
+ expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}")
+ end
+ end
+
+ context 'when the project is private' do
+ let(:project) { create(:project, :repository) }
+ let(:another_user) { create(:user, name: 'Mike') }
+
+ before do
+ project.add_reporter(user)
+ project.add_reporter(another_user)
+ end
+
+ it 'renders private forks of the project' do
+ visit project_path(project)
+
+ another_project_fork = Projects::ForkService.new(project, another_user).execute
+
+ click_link 'Fork'
+
+ page.within '.fork-thumbnail-container' do
+ click_link user.name
+ end
+
+ visit project_forks_path(project)
+
+ page.within('.js-projects-list-holder') do
+ user_project_fork = user.fork_of(project.reload)
+ expect(page).to have_content("#{user_project_fork.namespace.human_name} / #{user_project_fork.name}")
+ end
+
+ expect(page).not_to have_content("#{another_project_fork.namespace.human_name} / #{another_project_fork.name}")
+ expect(page).to have_content("1 private fork")
+ end
+ end
+
+ context 'when the user already forked the project' do
+ before do
+ create(:project, :repository, name: project.name, namespace: user.namespace)
+ end
+
+ it 'renders error' do
+ visit project_path(project)
+
+ click_link 'Fork'
+
+ page.within '.fork-thumbnail-container' do
+ click_link user.name
+ end
+
+ expect(page).to have_content "Name has already been taken"
+ end
+ end
+
context 'master in group' do
let(:group) { create(:group) }
diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb
new file mode 100644
index 00000000000..57172610aed
--- /dev/null
+++ b/spec/features/projects/graph_spec.rb
@@ -0,0 +1,75 @@
+require 'spec_helper'
+
+describe 'Project Graph', :js do
+ let(:user) { create :user }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
+
+ before do
+ project.add_master(user)
+
+ sign_in(user)
+ end
+
+ shared_examples 'page should have commits graphs' do
+ it 'renders commits' do
+ expect(page).to have_content('Commit statistics for master')
+ expect(page).to have_content('Commits per day of month')
+ end
+ end
+
+ shared_examples 'page should have languages graphs' do
+ it 'renders languages' do
+ expect(page).to have_content(/Ruby 66.* %/)
+ expect(page).to have_content(/JavaScript 22.* %/)
+ end
+ end
+
+ it 'renders graphs' do
+ visit project_graph_path(project, 'master')
+
+ expect(page).to have_selector('.stat-graph', visible: false)
+ end
+
+ context 'commits graph' do
+ before do
+ visit commits_project_graph_path(project, 'master')
+ end
+
+ it_behaves_like 'page should have commits graphs'
+ it_behaves_like 'page should have languages graphs'
+ end
+
+ context 'languages graph' do
+ before do
+ visit languages_project_graph_path(project, 'master')
+ end
+
+ it_behaves_like 'page should have commits graphs'
+ it_behaves_like 'page should have languages graphs'
+ end
+
+ context 'charts graph' do
+ before do
+ visit charts_project_graph_path(project, 'master')
+ end
+
+ it_behaves_like 'page should have commits graphs'
+ it_behaves_like 'page should have languages graphs'
+ end
+
+ context 'when CI enabled' do
+ before do
+ project.enable_ci
+
+ visit ci_project_graph_path(project, 'master')
+ end
+
+ it 'renders CI graphs' do
+ expect(page).to have_content 'Overall'
+ expect(page).to have_content 'Pipelines for last week'
+ expect(page).to have_content 'Pipelines for last month'
+ expect(page).to have_content 'Pipelines for last year'
+ expect(page).to have_content 'Commit duration in minutes for last 30 commits'
+ end
+ end
+end
diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb
index 98c7ef57a51..199682b943c 100644
--- a/spec/features/projects/guest_navigation_menu_spec.rb
+++ b/spec/features/projects/guest_navigation_menu_spec.rb
@@ -5,7 +5,7 @@ describe 'Guest navigation menu' do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
sign_in(guest)
end
diff --git a/spec/features/projects/hook_logs/user_reads_log_spec.rb b/spec/features/projects/hook_logs/user_reads_log_spec.rb
new file mode 100644
index 00000000000..18e975fa653
--- /dev/null
+++ b/spec/features/projects/hook_logs/user_reads_log_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+feature 'Hook logs' do
+ given(:web_hook_log) { create(:web_hook_log, response_body: '<script>') }
+ given(:project) { web_hook_log.web_hook.project }
+ given(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+
+ sign_in(user)
+ end
+
+ scenario 'user reads log without getting XSS' do
+ visit(
+ project_hook_hook_log_path(
+ project, web_hook_log.web_hook, web_hook_log))
+
+ expect(page).to have_content('<script>')
+ 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 461aa39d0ad..6732cf61767 100644
--- a/spec/features/projects/import_export/export_file_spec.rb
+++ b/spec/features/projects/import_export/export_file_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
# Integration test that exports a file using the Import/Export feature
# 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+
+# we'll 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', :js do
include Select2Helper
diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb
index af125e1b9d3..b25f5161748 100644
--- a/spec/features/projects/import_export/import_file_spec.rb
+++ b/spec/features/projects/import_export/import_file_spec.rb
@@ -32,7 +32,7 @@ feature 'Import/Export - project import integration test', :js do
expect(page).to have_content('Import an exported GitLab project')
expect(URI.parse(current_url).query).to eq("namespace_id=#{namespace.id}&path=#{project_path}")
- expect(Gitlab::ImportExport).to receive(:import_upload_path).with(filename: /\A\h{32}_test-project-path\h*\z/).and_call_original
+ expect(Gitlab::ImportExport).to receive(:import_upload_path).with(filename: /\A\h{32}\z/).and_call_original
attach_file('file', file)
click_on 'Import project'
@@ -41,6 +41,7 @@ feature 'Import/Export - project import integration test', :js do
project = Project.last
expect(project).not_to be_nil
+ expect(project.description).to eq("Foo Bar")
expect(project.issues).not_to be_empty
expect(project.merge_requests).not_to be_empty
expect(project_hook_exists?(project)).to be true
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 e76bc6f1220..7d056b0c140 100644
--- a/spec/features/projects/import_export/namespace_export_file_spec.rb
+++ b/spec/features/projects/import_export/namespace_export_file_spec.rb
@@ -1,44 +1,37 @@
require 'spec_helper'
-feature 'Import/Export - Namespace export file cleanup', :js do
- let(:export_path) { "#{Dir.tmpdir}/import_file_spec" }
- let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys }
+describe 'Import/Export - Namespace export file cleanup', :js do
+ let(:export_path) { Dir.mktmpdir('namespace_export_file_spec') }
- let(:project) { create(:project) }
-
- background do
- allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+ before do
+ allow(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
end
after do
FileUtils.rm_rf(export_path, secure: true)
end
- context 'admin user' do
+ shared_examples_for 'handling project exports on namespace change' do
+ let!(:old_export_path) { project.export_path }
+
before do
sign_in(create(:admin))
+
+ setup_export_project
end
context 'moving the namespace' do
- scenario 'removes the export file' do
- setup_export_project
-
- old_export_path = project.export_path.dup
-
+ it 'removes the export file' do
expect(File).to exist(old_export_path)
- project.namespace.update(path: 'new_path')
+ project.namespace.update!(path: build(:namespace).path)
expect(File).not_to exist(old_export_path)
end
end
context 'deleting the namespace' do
- scenario 'removes the export file' do
- setup_export_project
-
- old_export_path = project.export_path.dup
-
+ it 'removes the export file' do
expect(File).to exist(old_export_path)
project.namespace.destroy
@@ -46,17 +39,29 @@ feature 'Import/Export - Namespace export file cleanup', :js do
expect(File).not_to exist(old_export_path)
end
end
+ end
- def setup_export_project
- visit edit_project_path(project)
+ describe 'legacy storage' do
+ let(:project) { create(:project, :legacy_storage) }
- expect(page).to have_content('Export project')
+ it_behaves_like 'handling project exports on namespace change'
+ end
+
+ describe 'hashed storage' do
+ let(:project) { create(:project) }
- find(:link, 'Export project').send_keys(:return)
+ it_behaves_like 'handling project exports on namespace change'
+ end
- visit edit_project_path(project)
+ def setup_export_project
+ visit edit_project_path(project)
- expect(page).to have_content('Download export')
- end
+ expect(page).to have_content('Export project')
+
+ find(:link, 'Export project').send_keys(:return)
+
+ visit edit_project_path(project)
+
+ expect(page).to have_content('Download export')
end
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 0c354298433..ecb7651acad 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_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb
index 4319fc2746c..e26caf1f456 100644
--- a/spec/features/projects/issuable_templates_spec.rb
+++ b/spec/features/projects/issuable_templates_spec.rb
@@ -8,7 +8,7 @@ feature 'issuable templates', :js do
let(:issue_form_location) { '#content-body .issuable-details .detail-page-description' }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
end
@@ -120,7 +120,7 @@ feature 'issuable templates', :js do
background do
sign_out(:user)
- project.team << [fork_user, :developer]
+ project.add_developer(fork_user)
sign_in(fork_user)
diff --git a/spec/features/projects/issues/rss_spec.rb b/spec/features/projects/issues/rss_spec.rb
index 58eeef8c258..ff91aabc311 100644
--- a/spec/features/projects/issues/rss_spec.rb
+++ b/spec/features/projects/issues/rss_spec.rb
@@ -12,7 +12,7 @@ feature 'Project Issues RSS' do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit path
end
diff --git a/spec/features/projects/issues/user_comments_on_issue_spec.rb b/spec/features/projects/issues/user_comments_on_issue_spec.rb
new file mode 100644
index 00000000000..c45fdc7642f
--- /dev/null
+++ b/spec/features/projects/issues/user_comments_on_issue_spec.rb
@@ -0,0 +1,73 @@
+require "spec_helper"
+
+describe "User comments on issue", :js do
+ include Spec::Support::Helpers::Features::NotesHelpers
+
+ let(:project) { create(:project_empty_repo, :public) }
+ let(:issue) { create(:issue, project: project) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_guest(user)
+ sign_in(user)
+
+ visit(project_issue_path(project, issue))
+ end
+
+ context "when adding comments" do
+ it "adds comment" do
+ content = "XML attached"
+ target_form = ".js-main-target-form"
+
+ add_note(content)
+
+ page.within(".note") do
+ expect(page).to have_content(content)
+ end
+
+ page.within(target_form) do
+ find(".error-alert", visible: false)
+ end
+ end
+
+ it "adds comment with code block" do
+ comment = "```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```"
+
+ add_note(comment)
+
+ expect(page).to have_content(comment)
+ end
+ end
+
+ context "when editing comments" do
+ it "edits comment" do
+ add_note("# Comment with a header")
+
+ page.within(".note-body > .note-text") do
+ expect(page).to have_content("Comment with a header").and have_no_css("#comment-with-a-header")
+ end
+
+ page.within(".main-notes-list") do
+ note = find(".note")
+
+ note.hover
+ note.find(".js-note-edit").click
+ end
+
+ expect(page).to have_css(".current-note-edit-form textarea")
+
+ comment = "+1 Awesome!"
+
+ page.within(".current-note-edit-form") do
+ fill_in("note[note]", with: comment)
+ click_button("Save comment")
+ end
+
+ wait_for_requests
+
+ page.within(".note") do
+ expect(page).to have_content(comment)
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/issues/user_creates_issue_spec.rb b/spec/features/projects/issues/user_creates_issue_spec.rb
new file mode 100644
index 00000000000..e76f7c5589d
--- /dev/null
+++ b/spec/features/projects/issues/user_creates_issue_spec.rb
@@ -0,0 +1,87 @@
+require "spec_helper"
+
+describe "User creates issue" do
+ let(:project) { create(:project_empty_repo, :public) }
+ let(:user) { create(:user) }
+
+ context "when signed in as guest" do
+ before do
+ project.add_guest(user)
+ sign_in(user)
+
+ visit(new_project_issue_path(project))
+ end
+
+ it "creates issue" do
+ page.within(".issue-form") do
+ expect(page).to have_no_content("Assign to")
+ .and have_no_content("Labels")
+ .and have_no_content("Milestone")
+ end
+
+ issue_title = "500 error on profile"
+
+ fill_in("Title", with: issue_title)
+ click_button("Submit issue")
+
+ expect(page).to have_content(issue_title)
+ .and have_content(user.name)
+ .and have_content(project.name)
+ end
+ end
+
+ context "when signed in as developer", :js do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit(new_project_issue_path(project))
+ end
+
+ context "when previewing" do
+ it "previews content" do
+ form = first(".gfm-form")
+ textarea = first(".gfm-form textarea")
+
+ page.within(form) do
+ click_link("Preview")
+
+ preview = find(".js-md-preview") # this element is findable only when the "Preview" link is clicked.
+
+ expect(preview).to have_content("Nothing to preview.")
+
+ click_link("Write")
+ fill_in("Description", with: "Bug fixed :smile:")
+ click_link("Preview")
+
+ expect(preview).to have_css("gl-emoji")
+ expect(textarea).not_to be_visible
+ end
+ end
+ end
+
+ context "with labels" do
+ LABEL_TITLES = %w(bug feature enhancement).freeze
+
+ before do
+ LABEL_TITLES.each do |title|
+ create(:label, project: project, title: title)
+ end
+ end
+
+ it "creates issue" do
+ issue_title = "500 error on profile"
+
+ fill_in("Title", with: issue_title)
+ click_button("Label")
+ click_link(LABEL_TITLES.first)
+ click_button("Submit issue")
+
+ expect(page).to have_content(issue_title)
+ .and have_content(user.name)
+ .and have_content(project.name)
+ .and have_content(LABEL_TITLES.first)
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/issues/user_edits_issue_spec.rb b/spec/features/projects/issues/user_edits_issue_spec.rb
new file mode 100644
index 00000000000..1d9c3abc20f
--- /dev/null
+++ b/spec/features/projects/issues/user_edits_issue_spec.rb
@@ -0,0 +1,25 @@
+require "spec_helper"
+
+describe "User edits issue", :js do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:user) { create(:user) }
+ set(:issue) { create(:issue, project: project, author: user) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit(edit_project_issue_path(project, issue))
+ end
+
+ it "previews content" do
+ form = first(".gfm-form")
+
+ page.within(form) do
+ fill_in("Description", with: "Bug fixed :smile:")
+ click_link("Preview")
+ end
+
+ expect(form).to have_link("Write")
+ end
+end
diff --git a/spec/features/projects/issues/user_sorts_issues_spec.rb b/spec/features/projects/issues/user_sorts_issues_spec.rb
new file mode 100644
index 00000000000..c3d63000dac
--- /dev/null
+++ b/spec/features/projects/issues/user_sorts_issues_spec.rb
@@ -0,0 +1,39 @@
+require "spec_helper"
+
+describe "User sorts issues" do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:issue1) { create(:issue, project: project) }
+ set(:issue2) { create(:issue, project: project) }
+ set(:issue3) { create(:issue, project: project) }
+
+ before do
+ create_list(:award_emoji, 2, :upvote, awardable: issue1)
+ create_list(:award_emoji, 2, :downvote, awardable: issue2)
+ create(:award_emoji, :downvote, awardable: issue1)
+ create(:award_emoji, :upvote, awardable: issue2)
+
+ visit(project_issues_path(project))
+ end
+
+ it "sorts by popularity" do
+ find("button.dropdown-toggle").click
+
+ page.within(".content ul.dropdown-menu.dropdown-menu-align-right li") do
+ click_link("Popularity")
+ end
+
+ page.within(".issues-list") do
+ page.within("li.issue:nth-child(1)") do
+ expect(page).to have_content(issue1.title)
+ end
+
+ page.within("li.issue:nth-child(2)") do
+ expect(page).to have_content(issue2.title)
+ end
+
+ page.within("li.issue:nth-child(3)") do
+ expect(page).to have_content(issue3.title)
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/issues/user_toggles_subscription_spec.rb b/spec/features/projects/issues/user_toggles_subscription_spec.rb
new file mode 100644
index 00000000000..117a614b980
--- /dev/null
+++ b/spec/features/projects/issues/user_toggles_subscription_spec.rb
@@ -0,0 +1,28 @@
+require "spec_helper"
+
+describe "User toggles subscription", :js do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:user) { create(:user) }
+ set(:issue) { create(:issue, project: project, author: user) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+
+ visit(project_issue_path(project, issue))
+ end
+
+ it "unsibscribes from issue" do
+ subscription_button = find(".js-issuable-subscribe-button")
+
+ # Check we're subscribed.
+ expect(subscription_button).to have_css("button.is-checked")
+
+ # Toggle subscription.
+ find(".js-issuable-subscribe-button button").click
+ wait_for_requests
+
+ # Check we're unsubscribed.
+ expect(subscription_button).to have_css("button:not(.is-checked)")
+ end
+end
diff --git a/spec/features/projects/issues/user_views_issue_spec.rb b/spec/features/projects/issues/user_views_issue_spec.rb
new file mode 100644
index 00000000000..f7f2cde3d64
--- /dev/null
+++ b/spec/features/projects/issues/user_views_issue_spec.rb
@@ -0,0 +1,16 @@
+require "spec_helper"
+
+describe "User views issue" do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:user) { create(:user) }
+ set(:issue) { create(:issue, project: project, description: "# Description header", author: user) }
+
+ before do
+ project.add_guest(user)
+ sign_in(user)
+
+ visit(project_issue_path(project, issue))
+ end
+
+ it { expect(page).to have_header_with_correct_id_and_link(1, "Description header", "description-header") }
+end
diff --git a/spec/features/projects/issues/user_views_issues_spec.rb b/spec/features/projects/issues/user_views_issues_spec.rb
index d35009b8974..58afb4efb86 100644
--- a/spec/features/projects/issues/user_views_issues_spec.rb
+++ b/spec/features/projects/issues/user_views_issues_spec.rb
@@ -1,56 +1,116 @@
-require 'spec_helper'
+require "spec_helper"
-describe 'User views issues' do
+describe "User views issues" do
+ let!(:closed_issue) { create(:closed_issue, project: project) }
+ let!(:open_issue1) { create(:issue, project: project) }
+ let!(:open_issue2) { create(:issue, project: project) }
set(:user) { create(:user) }
- shared_examples_for 'shows issues' do
- it 'shows issues' do
- expect(page).to have_content(project.name)
- .and have_content(issue1.title)
- .and have_content(issue2.title)
- .and have_no_selector('.js-new-board-list')
+ shared_examples "opens issue from list" do
+ it "opens issue" do
+ click_link(issue.title)
+
+ expect(page).to have_content(issue.title)
end
end
- context 'when project is public' do
- set(:project) { create(:project_empty_repo, :public) }
- set(:issue1) { create(:issue, project: project) }
- set(:issue2) { create(:issue, project: project) }
+ shared_examples "open issues" do
+ context "open issues" do
+ let(:label) { create(:label, project: project, title: "bug") }
- context 'when signed in' do
before do
- project.add_developer(user)
- sign_in(user)
+ open_issue1.labels << label
+
+ visit(project_issues_path(project, state: :opened))
+ end
- visit(project_issues_path(project))
+ it "shows open issues" do
+ expect(page).to have_content(project.name)
+ .and have_content(open_issue1.title)
+ .and have_content(open_issue2.title)
+ .and have_no_content(closed_issue.title)
+ .and have_no_selector(".js-new-board-list")
end
- include_examples 'shows issues'
+ it "opens issues by label" do
+ page.within(".issues-list") do
+ click_link(label.title)
+ end
+
+ expect(page).to have_content(open_issue1.title)
+ .and have_no_content(open_issue2.title)
+ .and have_no_content(closed_issue.title)
+ end
+
+ include_examples "opens issue from list" do
+ let(:issue) { open_issue1 }
+ end
end
+ end
- context 'when not signed in' do
+ shared_examples "closed issues" do
+ context "closed issues" do
before do
- visit(project_issues_path(project))
+ visit(project_issues_path(project, state: :closed))
+ end
+
+ it "shows closed issues" do
+ expect(page).to have_content(project.name)
+ .and have_content(closed_issue.title)
+ .and have_no_content(open_issue1.title)
+ .and have_no_content(open_issue2.title)
+ .and have_no_selector(".js-new-board-list")
end
- include_examples 'shows issues'
+ include_examples "opens issue from list" do
+ let(:issue) { closed_issue }
+ end
end
end
- context 'when project is internal' do
- set(:project) { create(:project_empty_repo, :internal) }
- set(:issue1) { create(:issue, project: project) }
- set(:issue2) { create(:issue, project: project) }
-
- context 'when signed in' do
+ shared_examples "all issues" do
+ context "all issues" do
before do
- project.add_developer(user)
- sign_in(user)
+ visit(project_issues_path(project, state: :all))
+ end
- visit(project_issues_path(project))
+ it "shows all issues" do
+ expect(page).to have_content(project.name)
+ .and have_content(closed_issue.title)
+ .and have_content(open_issue1.title)
+ .and have_content(open_issue2.title)
+ .and have_no_selector(".js-new-board-list")
end
- include_examples 'shows issues'
+ include_examples "opens issue from list" do
+ let(:issue) { closed_issue }
+ end
+ end
+ end
+
+ %w[internal public].each do |visibility|
+ shared_examples "#{visibility} project" do
+ context "when project is #{visibility}" do
+ let(:project) { create(:project_empty_repo, :"#{visibility}") }
+
+ include_examples "open issues"
+ include_examples "closed issues"
+ include_examples "all issues"
+ end
end
end
+
+ context "when signed in as developer" do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ include_examples "public project"
+ include_examples "internal project"
+ end
+
+ context "when not signed in" do
+ include_examples "public project"
+ end
end
diff --git a/spec/features/projects/jobs/user_browses_job_spec.rb b/spec/features/projects/jobs/user_browses_job_spec.rb
index 5d9208ebadd..bff5bbe99af 100644
--- a/spec/features/projects/jobs/user_browses_job_spec.rb
+++ b/spec/features/projects/jobs/user_browses_job_spec.rb
@@ -1,16 +1,15 @@
require 'spec_helper'
describe 'User browses a job', :js do
- let!(:build) { create(:ci_build, :coverage, pipeline: pipeline) }
- let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') }
- let(:project) { create(:project, :repository, namespace: user.namespace) }
let(:user) { create(:user) }
+ let(:user_access_level) { :developer }
+ let(:project) { create(:project, :repository, namespace: user.namespace) }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') }
+ let!(:build) { create(:ci_build, :success, :trace_artifact, :coverage, pipeline: pipeline) }
before do
project.add_master(user)
project.enable_ci
- build.success
- build.trace.set('job trace')
sign_in(user)
@@ -21,7 +20,9 @@ describe 'User browses a job', :js do
expect(page).to have_content("Job ##{build.id}")
expect(page).to have_css('#build-trace')
- accept_confirm { click_link('Erase') }
+ # scroll to the top of the page first
+ execute_script "window.scrollTo(0,0)"
+ accept_confirm { find('.js-erase-link').click }
expect(page).to have_no_css('.artifacts')
expect(build).not_to have_trace
@@ -34,4 +35,26 @@ describe 'User browses a job', :js do
expect(build.project.running_or_pending_build_count).to eq(build.project.builds.running_or_pending.count(:all))
end
+
+ context 'with a failed job' do
+ let!(:build) { create(:ci_build, :failed, :trace_artifact, pipeline: pipeline) }
+
+ it 'displays the failure reason' do
+ within('.builds-container') do
+ build_link = first('.build-job > a')
+ expect(build_link['data-title']).to eq('test - failed <br> (unknown failure)')
+ end
+ end
+ end
+
+ context 'when a failed job has been retried' do
+ let!(:build) { create(:ci_build, :failed, :retried, :trace_artifact, pipeline: pipeline) }
+
+ it 'displays the failure reason and retried label' do
+ within('.builds-container') do
+ build_link = first('.build-job > a')
+ expect(build_link['data-title']).to eq('test - failed <br> (unknown failure) (retried)')
+ end
+ end
+ end
end
diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb
index 767777f3bf9..36ebbeadd4a 100644
--- a/spec/features/projects/jobs/user_browses_jobs_spec.rb
+++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb
@@ -29,4 +29,15 @@ describe 'User browses jobs' do
expect(ci_lint_tool_link[:href]).to end_with(ci_lint_path)
end
end
+
+ context 'with a failed job' do
+ let!(:build) { create(:ci_build, :coverage, :failed, pipeline: pipeline) }
+
+ it 'displays a tooltip with the failure reason' do
+ page.within('.ci-table') do
+ failed_job_link = page.find('.ci-failed')
+ expect(failed_job_link[:title]).to eq('Failed <br> (unknown failure)')
+ end
+ end
+ end
end
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 0b0d5a2dce8..749a1b81872 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -7,7 +7,7 @@ feature 'Jobs' do
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project) }
- let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) }
let(:job2) { create(:ci_build) }
let(:artifacts_file) do
@@ -15,7 +15,7 @@ feature 'Jobs' do
end
before do
- project.team << [user, user_access_level]
+ project.add_role(user, user_access_level)
sign_in(user)
end
@@ -113,7 +113,7 @@ feature 'Jobs' do
describe "GET /:project/jobs/:id" do
context "Job from project" do
- let(:job) { create(:ci_build, :success, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :success, :trace_live, pipeline: pipeline) }
before do
visit project_job_path(project, job)
@@ -136,7 +136,7 @@ feature 'Jobs' do
end
context 'when job is not running', :js do
- let(:job) { create(:ci_build, :success, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :success, :trace_artifact, pipeline: pipeline) }
before do
visit project_job_path(project, job)
@@ -153,7 +153,7 @@ feature 'Jobs' do
end
context 'if job failed' do
- let(:job) { create(:ci_build, :failed, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :failed, :trace_artifact, pipeline: pipeline) }
before do
visit project_job_path(project, job)
@@ -339,7 +339,7 @@ feature 'Jobs' do
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) }
+ let(:job) { create(:ci_build, :success, :trace_artifact, environment: environment.name, deployments: [deployment], pipeline: pipeline) }
it 'shows a link for the job' do
visit project_job_path(project, job)
@@ -349,7 +349,7 @@ feature 'Jobs' do
end
context 'job is complete and not successful' do
- let(:job) { create(:ci_build, :failed, environment: environment.name, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :failed, :trace_artifact, environment: environment.name, pipeline: pipeline) }
it 'shows a link for the job' do
visit project_job_path(project, job)
@@ -360,7 +360,7 @@ feature 'Jobs' do
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) }
+ let(:job) { create(:ci_build, :success, :trace_artifact, environment: environment.name, pipeline: pipeline) }
it 'shows a link to latest deployment' do
visit project_job_path(project, job)
@@ -369,6 +369,101 @@ feature 'Jobs' do
end
end
end
+
+ context 'Playable manual action' do
+ let(:job) { create(:ci_build, :playable, pipeline: pipeline) }
+
+ before do
+ project.add_developer(user)
+ visit project_job_path(project, job)
+ end
+
+ it 'shows manual action empty state' do
+ expect(page).to have_content(job.detailed_status(user).illustration[:title])
+ expect(page).to have_content('This job requires a manual action')
+ expect(page).to have_content('This job depends on a user to trigger its process. Often they are used to deploy code to production environments')
+ expect(page).to have_link('Trigger this manual action')
+ end
+
+ it 'plays manual action and shows pending status', :js do
+ click_link 'Trigger this manual action'
+
+ wait_for_requests
+ expect(page).to have_content('This job has not started yet')
+ expect(page).to have_content('This job is in pending state and is waiting to be picked by a runner')
+ expect(page).to have_content('pending')
+ end
+ end
+
+ context 'Non triggered job' do
+ let(:job) { create(:ci_build, :created, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'shows empty state' do
+ expect(page).to have_content(job.detailed_status(user).illustration[:title])
+ expect(page).to have_content('This job has not been triggered yet')
+ expect(page).to have_content('This job depends on upstream jobs that need to succeed in order for this job to be triggered')
+ end
+ end
+
+ context 'Pending job' do
+ let(:job) { create(:ci_build, :pending, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'shows pending empty state' do
+ expect(page).to have_content(job.detailed_status(user).illustration[:title])
+ expect(page).to have_content('This job has not started yet')
+ expect(page).to have_content('This job is in pending state and is waiting to be picked by a runner')
+ end
+ end
+
+ context 'Canceled job' do
+ context 'with log' do
+ let(:job) { create(:ci_build, :canceled, :trace_artifact, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'renders job log' do
+ expect(page).to have_selector('.js-build-output')
+ end
+ end
+
+ context 'without log' do
+ let(:job) { create(:ci_build, :canceled, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'renders empty state' do
+ expect(page).to have_content(job.detailed_status(user).illustration[:title])
+ expect(page).not_to have_selector('.js-build-output')
+ expect(page).to have_content('This job has been canceled')
+ end
+ end
+ end
+
+ context 'Skipped job' do
+ let(:job) { create(:ci_build, :skipped, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ end
+
+ it 'renders empty state' do
+ expect(page).to have_content(job.detailed_status(user).illustration[:title])
+ expect(page).not_to have_selector('.js-build-output')
+ expect(page).to have_content('This job has been skipped')
+ end
+ end
end
describe "POST /:project/jobs/:id/cancel", :js do
@@ -440,18 +535,34 @@ feature 'Jobs' do
describe 'GET /:project/jobs/:id/raw', :js do
context 'access source' do
context 'job from project' do
- before do
- job.run!
- end
+ context 'when job is running' do
+ before do
+ job.run!
+ end
- it 'sends the right headers' do
- requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do
- visit raw_project_job_path(project, job)
+ it 'sends the right headers' do
+ requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do
+ visit raw_project_job_path(project, job)
+ end
+
+ expect(requests.first.status_code).to eq(200)
+ expect(requests.first.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
+ expect(requests.first.response_headers['X-Sendfile']).to eq(job.trace.send(:current_path))
end
+ end
- expect(requests.first.status_code).to eq(200)
- expect(requests.first.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
- expect(requests.first.response_headers['X-Sendfile']).to eq(job.trace.send(:current_path))
+ context 'when job is complete' do
+ let(:job) { create(:ci_build, :success, :trace_artifact, pipeline: pipeline) }
+
+ it 'sends the right headers' do
+ requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do
+ visit raw_project_job_path(project, job)
+ end
+
+ expect(requests.first.status_code).to eq(200)
+ expect(requests.first.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
+ expect(requests.first.response_headers['X-Sendfile']).to eq(job.job_artifacts_trace.file.path)
+ end
end
end
diff --git a/spec/features/projects/labels/subscription_spec.rb b/spec/features/projects/labels/subscription_spec.rb
index e8c70dec854..70e8d436dcb 100644
--- a/spec/features/projects/labels/subscription_spec.rb
+++ b/spec/features/projects/labels/subscription_spec.rb
@@ -9,7 +9,7 @@ feature 'Labels subscription' do
context 'when signed in' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in user
end
diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb
index d063f5c27b5..ae8b1364ec7 100644
--- a/spec/features/projects/labels/update_prioritization_spec.rb
+++ b/spec/features/projects/labels/update_prioritization_spec.rb
@@ -12,7 +12,7 @@ feature 'Prioritize labels' do
context 'when user belongs to project team' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in user
end
@@ -99,7 +99,7 @@ feature 'Prioritize labels' do
expect(page).to have_content 'wontfix'
# Sort labels
- drag_to(selector: '.js-prioritized-labels', from_index: 1, to_index: 2)
+ drag_to(selector: '.label-list-item', from_index: 1, to_index: 2)
page.within('.prioritized-labels') do
expect(first('li')).to have_content('feature')
diff --git a/spec/features/projects/labels/user_creates_labels_spec.rb b/spec/features/projects/labels/user_creates_labels_spec.rb
new file mode 100644
index 00000000000..9fd7f3ee775
--- /dev/null
+++ b/spec/features/projects/labels/user_creates_labels_spec.rb
@@ -0,0 +1,88 @@
+require "spec_helper"
+
+describe "User creates labels" do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:user) { create(:user) }
+
+ shared_examples_for "label creation" do
+ it "creates new label" do
+ title = "bug"
+
+ create_label(title)
+
+ page.within(".other-labels .manage-labels-list") do
+ expect(page).to have_content(title)
+ end
+ end
+ end
+
+ context "in project" do
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit(new_project_label_path(project))
+ end
+
+ context "when data is valid" do
+ include_examples "label creation"
+ end
+
+ context "when data is invalid" do
+ context "when title is invalid" do
+ it "shows error message" do
+ create_label("")
+
+ page.within(".label-form") do
+ expect(page).to have_content("Title can't be blank")
+ end
+ end
+ end
+
+ context "when color is invalid" do
+ it "shows error message" do
+ create_label("feature", "#12")
+
+ page.within(".label-form") do
+ expect(page).to have_content("Color must be a valid color code")
+ end
+ end
+ end
+ end
+
+ context "when label already exists" do
+ let!(:label) { create(:label, project: project) }
+
+ it "shows error message" do
+ create_label(label.title)
+
+ page.within(".label-form") do
+ expect(page).to have_content("Title has already been taken")
+ end
+ end
+ end
+ end
+
+ context "in another project" do
+ set(:another_project) { create(:project_empty_repo, :public) }
+
+ before do
+ create(:label, project: project, title: "bug") # Create label for `project` (not `another_project`) project.
+
+ another_project.add_master(user)
+ sign_in(user)
+
+ visit(new_project_label_path(another_project))
+ end
+
+ include_examples "label creation"
+ end
+
+ private
+
+ def create_label(title, color = "#F95610")
+ fill_in("Title", with: title)
+ fill_in("Background color", with: color)
+ click_button("Create label")
+ end
+end
diff --git a/spec/features/projects/labels/user_edits_labels_spec.rb b/spec/features/projects/labels/user_edits_labels_spec.rb
new file mode 100644
index 00000000000..d1041ff5c1e
--- /dev/null
+++ b/spec/features/projects/labels/user_edits_labels_spec.rb
@@ -0,0 +1,25 @@
+require "spec_helper"
+
+describe "User edits labels" do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:label) { create(:label, project: project) }
+ set(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit(edit_project_label_path(project, label))
+ end
+
+ it "updates label's title" do
+ new_title = "fix"
+
+ fill_in("Title", with: new_title)
+ click_button("Save changes")
+
+ page.within(".other-labels .manage-labels-list") do
+ expect(page).to have_content(new_title).and have_no_content(label.title)
+ end
+ end
+end
diff --git a/spec/features/projects/labels/user_removes_labels_spec.rb b/spec/features/projects/labels/user_removes_labels_spec.rb
new file mode 100644
index 00000000000..f4fda6de465
--- /dev/null
+++ b/spec/features/projects/labels/user_removes_labels_spec.rb
@@ -0,0 +1,52 @@
+require "spec_helper"
+
+describe "User removes labels" do
+ let(:project) { create(:project_empty_repo, :public) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ context "when one label" do
+ let!(:label) { create(:label, project: project) }
+
+ before do
+ visit(project_labels_path(project))
+ end
+
+ it "removes label" do
+ page.within(".labels") do
+ page.first(".label-list-item") do
+ first(".remove-row").click
+ first(:link, "Delete label").click
+ end
+ end
+
+ expect(page).to have_content("Label was removed").and have_no_content(label.title)
+ end
+ end
+
+ context "when many labels", :js do
+ before do
+ create_list(:label, 3, project: project)
+
+ visit(project_labels_path(project))
+ end
+
+ it "removes all labels" do
+ page.within(".labels") do
+ loop do
+ li = page.first(".label-list-item")
+ break unless li
+
+ li.click_link("Delete")
+ click_link("Delete label")
+ end
+
+ expect(page).to have_content("Generate a default set of labels").and have_content("New label")
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/labels/user_views_labels_spec.rb b/spec/features/projects/labels/user_views_labels_spec.rb
new file mode 100644
index 00000000000..0cbeca4e392
--- /dev/null
+++ b/spec/features/projects/labels/user_views_labels_spec.rb
@@ -0,0 +1,23 @@
+require "spec_helper"
+
+describe "User views labels" do
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:user) { create(:user) }
+
+ LABEL_TITLES = %w[bug enhancement feature].freeze
+
+ before do
+ LABEL_TITLES.each { |title| create(:label, project: project, title: title) }
+
+ project.add_guest(user)
+ sign_in(user)
+
+ visit(project_labels_path(project))
+ end
+
+ it "shows all labels" do
+ page.within('.other-labels .manage-labels-list') do
+ LABEL_TITLES.each { |title| expect(page).to have_content(title) }
+ end
+ end
+end
diff --git a/spec/features/projects/main/download_buttons_spec.rb b/spec/features/projects/main/download_buttons_spec.rb
index 3f2579bb01a..81f08e44cf3 100644
--- a/spec/features/projects/main/download_buttons_spec.rb
+++ b/spec/features/projects/main/download_buttons_spec.rb
@@ -23,7 +23,7 @@ feature 'Download buttons in project main page' do
background do
sign_in(user)
- project.team << [user, role]
+ project.add_role(user, role)
end
describe 'when checking project main page' do
diff --git a/spec/features/projects/main/rss_spec.rb b/spec/features/projects/main/rss_spec.rb
index 7914180b951..3c98c11b490 100644
--- a/spec/features/projects/main/rss_spec.rb
+++ b/spec/features/projects/main/rss_spec.rb
@@ -7,7 +7,7 @@ feature 'Project RSS' do
context 'when signed in' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit path
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 bf0990d675d..e2a48bfd1d4 100644
--- a/spec/features/projects/members/anonymous_user_sees_members_spec.rb
+++ b/spec/features/projects/members/anonymous_user_sees_members_spec.rb
@@ -6,7 +6,7 @@ feature 'Projects > Members > Anonymous user sees members' do
let(:project) { create(:project, :public) }
background do
- project.team << [user, :master]
+ project.add_master(user)
create(:project_group_link, project: project, group: group)
end
diff --git a/spec/features/projects/members/group_members_spec.rb b/spec/features/projects/members/group_members_spec.rb
index c140fece41d..e22b6fa6c43 100644
--- a/spec/features/projects/members/group_members_spec.rb
+++ b/spec/features/projects/members/group_members_spec.rb
@@ -11,7 +11,7 @@ feature 'Projects members' do
let(:group_requester) { create(:user) }
background do
- project.team << [developer, :developer]
+ project.add_developer(developer)
group.add_owner(user)
sign_in(user)
end
diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb
index 7f067aadec6..e6d0c6e00f8 100644
--- a/spec/features/projects/members/groups_with_access_list_spec.rb
+++ b/spec/features/projects/members/groups_with_access_list_spec.rb
@@ -6,7 +6,7 @@ feature 'Projects > Members > Groups with access list', :js do
let(:project) { create(:project, :public) }
background do
- project.team << [user, :master]
+ project.add_master(user)
@group_link = create(:project_group_link, project: project, group: group)
sign_in(user)
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 0f88f4cb1e8..8fe340d3bae 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
@@ -9,7 +9,7 @@ feature 'Projects > Members > Master adds member with expiration date', :js do
let!(:new_member) { create(:user) }
background do
- project.team << [master, :master]
+ project.add_master(master)
sign_in(master)
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 eb3c8034873..1f4eec0a317 100644
--- a/spec/features/projects/members/master_manages_access_requests_spec.rb
+++ b/spec/features/projects/members/master_manages_access_requests_spec.rb
@@ -7,7 +7,7 @@ feature 'Projects > Members > Master manages access requests' do
background do
project.request_access(user)
- project.team << [master, :master]
+ project.add_master(master)
sign_in(master)
end
@@ -25,7 +25,7 @@ feature 'Projects > Members > Master manages access requests' do
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 #{project.name_with_namespace} project was granted"
+ expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.full_name} project was granted"
end
scenario 'master can deny access' do
@@ -36,7 +36,7 @@ feature 'Projects > Members > Master manages access requests' do
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 #{project.name_with_namespace} project was denied"
+ expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.full_name} project was denied"
end
def 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 04806f8fd9e..47911c32a72 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
@@ -5,7 +5,7 @@ feature 'Projects > Members > Member cannot request access to his project' do
let(:project) { create(:project) }
background do
- project.team << [member, :developer]
+ project.add_developer(member)
sign_in(member)
visit project_path(project)
end
diff --git a/spec/features/projects/members/member_leaves_project_spec.rb b/spec/features/projects/members/member_leaves_project_spec.rb
index 1bcf827d33c..e54c2c76975 100644
--- a/spec/features/projects/members/member_leaves_project_spec.rb
+++ b/spec/features/projects/members/member_leaves_project_spec.rb
@@ -5,7 +5,7 @@ feature 'Projects > Members > Member leaves project' do
let(:project) { create(:project, :repository) }
background do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit project_path(project)
end
diff --git a/spec/features/projects/members/share_with_group_spec.rb b/spec/features/projects/members/share_with_group_spec.rb
index 3198798306c..134c8b8bc39 100644
--- a/spec/features/projects/members/share_with_group_spec.rb
+++ b/spec/features/projects/members/share_with_group_spec.rb
@@ -122,7 +122,7 @@ feature 'Project > Members > Share with Group', :js do
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d')
- page.find('body').click
+ click_on 'share-with-group-tab'
find('.btn-create').click
end
@@ -149,6 +149,11 @@ feature 'Project > Members > Share with Group', :js do
create(:group).add_owner(master)
visit project_settings_members_path(project)
+
+ click_link 'Share with group'
+
+ find('.ajax-groups-select.select2-container')
+
execute_script 'GROUP_SELECT_PER_PAGE = 1;'
open_select2 '#link_group_id'
end
diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb
index 4eb36156812..672d5daa3d8 100644
--- a/spec/features/projects/members/user_requests_access_spec.rb
+++ b/spec/features/projects/members/user_requests_access_spec.rb
@@ -21,7 +21,7 @@ feature 'Projects > Members > User requests access', :js do
perform_enqueued_jobs { click_link 'Request Access' }
expect(ActionMailer::Base.deliveries.last.to).to eq [master.notification_email]
- expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.name_with_namespace} project"
+ expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.full_name} project"
expect(project.requesters.exists?(user_id: user)).to be_truthy
expect(page).to have_content 'Your request for access has been queued for review.'
diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb
index 85d518c0cc3..40689964b91 100644
--- a/spec/features/projects/merge_request_button_spec.rb
+++ b/spec/features/projects/merge_request_button_spec.rb
@@ -81,8 +81,8 @@ feature 'Merge Request button' do
context 'on branches page' do
it_behaves_like 'Merge request button only shown when allowed' do
let(:label) { 'Merge request' }
- let(:url) { project_branches_path(project, search: 'feature') }
- let(:fork_url) { project_branches_path(forked_project, search: 'feature') }
+ let(:url) { project_branches_filtered_path(project, state: 'all', search: 'feature') }
+ let(:fork_url) { project_branches_filtered_path(forked_project, state: 'all', search: 'feature') }
end
end
diff --git a/spec/features/projects/merge_requests/list_spec.rb b/spec/features/projects/merge_requests/list_spec.rb
deleted file mode 100644
index a879efef4b5..00000000000
--- a/spec/features/projects/merge_requests/list_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-require 'spec_helper'
-
-feature 'Merge Requests List' do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
-
- background do
- project.team << [user, :developer]
-
- sign_in(user)
- end
-
- scenario 'user does not see create new list button' do
- create(:merge_request, source_project: 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 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 project_merge_requests_path(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 project_merge_requests_path(project)
- end
-
- it 'should not show an empty state' do
- expect(page).not_to have_selector('.empty-state')
- end
- end
-end
diff --git a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb
index 4ca435491cb..f55eb5c6664 100644
--- a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb
+++ b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb
@@ -13,20 +13,18 @@ describe 'User manages subscription', :js do
end
it 'toggles subscription' do
- subscribe_button = find('.js-issuable-subscribe-button')
+ page.within('.js-issuable-subscribe-button') do
+ expect(page).to have_css 'button:not(.is-checked)'
+ find('button:not(.is-checked)').click
- expect(subscribe_button).to have_content('Subscribe')
+ wait_for_requests
- click_on('Subscribe')
+ expect(page).to have_css 'button.is-checked'
+ find('button.is-checked').click
- wait_for_requests
+ wait_for_requests
- expect(subscribe_button).to have_content('Unsubscribe')
-
- click_on('Unsubscribe')
-
- wait_for_requests
-
- expect(subscribe_button).to have_content('Subscribe')
+ expect(page).to have_css 'button:not(.is-checked)'
+ end
end
end
diff --git a/spec/features/projects/milestones/milestones_sorting_spec.rb b/spec/features/projects/milestones/milestones_sorting_spec.rb
index c531b81e04d..b64786d4eec 100644
--- a/spec/features/projects/milestones/milestones_sorting_spec.rb
+++ b/spec/features/projects/milestones/milestones_sorting_spec.rb
@@ -1,7 +1,6 @@
require 'spec_helper'
feature 'Milestones sorting', :js do
- include SortingHelper
let(:user) { create(:user) }
let(:project) { create(:project, name: 'test', namespace: user.namespace) }
diff --git a/spec/features/projects/network_graph_spec.rb b/spec/features/projects/network_graph_spec.rb
new file mode 100644
index 00000000000..9f9a7787093
--- /dev/null
+++ b/spec/features/projects/network_graph_spec.rb
@@ -0,0 +1,108 @@
+require 'spec_helper'
+
+describe 'Project Network Graph', :js do
+ let(:user) { create :user }
+ let(:project) { create :project, :repository, namespace: user.namespace }
+
+ before do
+ sign_in(user)
+
+ # Stub Graph max_size to speed up test (10 commits vs. 650)
+ allow(Network::Graph).to receive(:max_count).and_return(10)
+ end
+
+ context 'when branch is master' do
+ def switch_ref_to(ref_name)
+ first('.js-project-refs-dropdown').click
+
+ page.within '.project-refs-form' do
+ click_link ref_name
+ end
+ end
+
+ def click_show_only_selected_branch_checkbox
+ find('#filter_ref').click
+ end
+
+ before do
+ visit project_network_path(project, 'master')
+ end
+
+ it 'renders project network' do
+ expect(page).to have_selector ".network-graph"
+ expect(page).to have_selector '.dropdown-menu-toggle', text: "master"
+ page.within '.network-graph' do
+ expect(page).to have_content 'master'
+ end
+ end
+
+ it 'switches ref to branch' do
+ switch_ref_to('feature')
+
+ expect(page).to have_selector '.dropdown-menu-toggle', text: 'feature'
+ page.within '.network-graph' do
+ expect(page).to have_content 'feature'
+ end
+ end
+
+ it 'switches ref to tag' do
+ switch_ref_to('v1.0.0')
+
+ expect(page).to have_selector '.dropdown-menu-toggle', text: 'v1.0.0'
+ page.within '.network-graph' do
+ expect(page).to have_content 'v1.0.0'
+ end
+ end
+
+ it 'renders by commit sha of "v1.0.0"' do
+ page.within ".network-form" do
+ fill_in 'extended_sha1', with: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9'
+ find('button').click
+ end
+
+ expect(page).to have_selector ".network-graph"
+ expect(page).to have_selector '.dropdown-menu-toggle', text: "master"
+ page.within '.network-graph' do
+ expect(page).to have_content 'v1.0.0'
+ end
+ end
+
+ it 'filters select tag' do
+ switch_ref_to('v1.0.0')
+
+ expect(page).to have_css 'title', text: 'Graph · v1.0.0', visible: false
+ page.within '.network-graph' do
+ expect(page).to have_content 'Change some files'
+ end
+
+ click_show_only_selected_branch_checkbox
+
+ page.within '.network-graph' do
+ expect(page).not_to have_content 'Change some files'
+ end
+
+ click_show_only_selected_branch_checkbox
+
+ page.within '.network-graph' do
+ expect(page).to have_content 'Change some files'
+ end
+ end
+
+ it 'renders error message when sha commit not exists' do
+ page.within ".network-form" do
+ fill_in 'extended_sha1', with: ';'
+ find('button').click
+ end
+
+ expect(page).to have_selector '.flash-alert', text: "Git revision ';' does not exist."
+ end
+ end
+
+ it 'renders project network with test branch' do
+ visit project_network_path(project, "'test'")
+
+ page.within '.network-graph' do
+ expect(page).to have_content "'test'"
+ end
+ end
+end
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index 6f097ad16c7..a5954fec54b 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -140,9 +140,9 @@ feature 'New project' do
find('#import-project-tab').click
end
- context 'from git repository url' do
+ context 'from git repository url, "Repo by URL"' do
before do
- first('.import_git').click
+ first('.js-import-git-toggle-button').click
end
it 'does not autocomplete sensitive git repo URL' do
@@ -157,15 +157,27 @@ feature 'New project' do
expect(git_import_instructions).to be_visible
expect(git_import_instructions).to have_content 'Git repository URL'
end
+
+ it 'keeps "Import project" tab open after form validation error' do
+ collision_project = create(:project, name: 'test-name-collision', namespace: user.namespace)
+
+ fill_in 'project_import_url', with: collision_project.http_url_to_repo
+ fill_in 'project_path', with: collision_project.path
+
+ click_on 'Create project'
+
+ expect(page).to have_css('#import-project-pane.active')
+ expect(page).not_to have_css('.toggle-import-form.hide')
+ end
end
context 'from GitHub' do
before do
- first('.import_github').click
+ first('.js-import-github').click
end
it 'shows import instructions' do
- expect(page).to have_content('Import Projects from GitHub')
+ expect(page).to have_content('Import repositories from GitHub')
expect(current_path).to eq new_import_github_path
end
end
diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb
index 013ed6f2e58..bdd49f731c7 100644
--- a/spec/features/projects/pages_spec.rb
+++ b/spec/features/projects/pages_spec.rb
@@ -8,7 +8,7 @@ feature 'Pages' do
background do
allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
- project.team << [user, role]
+ project.add_role(user, role)
sign_in(user)
end
@@ -17,6 +17,7 @@ feature 'Pages' do
scenario 'does not see anything to destroy' do
visit project_pages_path(project)
+ expect(page).to have_content('Configure pages')
expect(page).not_to have_link('Remove pages')
expect(page).not_to have_text('Only the project owner can remove pages')
end
@@ -32,14 +33,179 @@ feature 'Pages' do
allow_any_instance_of(Project).to receive(:pages_deployed?) { true }
end
- scenario 'sees "Remove pages" link' do
+ scenario 'renders Access pages' do
visit project_pages_path(project)
- expect(page).to have_link('Remove pages')
+ expect(page).to have_content('Access pages')
+ end
+
+ context 'when support for external domains is disabled' do
+ it 'renders message that support is disabled' do
+ visit project_pages_path(project)
+
+ expect(page).to have_content('Support for domains and certificates is disabled')
+ end
+ end
+
+ context 'when pages are exposed on external HTTP address', :http_pages_enabled do
+ given(:project) { create(:project, pages_https_only: false) }
+
+ shared_examples 'adds new domain' do
+ it 'adds new domain' do
+ visit new_project_pages_domain_path(project)
+
+ fill_in 'Domain', with: 'my.test.domain.com'
+ click_button 'Create New Domain'
+
+ expect(page).to have_content('my.test.domain.com')
+ end
+ end
+
+ it 'allows to add new domain' do
+ visit project_pages_path(project)
+
+ expect(page).to have_content('New Domain')
+ end
+
+ it_behaves_like 'adds new domain'
+
+ context 'when project in group namespace' do
+ it_behaves_like 'adds new domain' do
+ let(:group) { create :group }
+ let(:project) { create(:project, namespace: group, pages_https_only: false) }
+ end
+ end
+
+ context 'when pages domain is added' do
+ before do
+ create(:pages_domain, project: project, domain: 'my.test.domain.com')
+
+ visit new_project_pages_domain_path(project)
+ end
+
+ it 'renders certificates is disabled' do
+ expect(page).to have_content('Support for custom certificates is disabled')
+ end
+
+ it 'does not adds new domain and renders error message' do
+ fill_in 'Domain', with: 'my.test.domain.com'
+ click_button 'Create New Domain'
+
+ expect(page).to have_content('Domain has already been taken')
+ end
+ end
+ end
+
+ context 'when pages are exposed on external HTTPS address', :https_pages_enabled do
+ let(:certificate_pem) do
+ <<~PEM
+ -----BEGIN CERTIFICATE-----
+ MIICGzCCAYSgAwIBAgIBATANBgkqhkiG9w0BAQUFADAbMRkwFwYDVQQDExB0ZXN0
+ LWNlcnRpZmljYXRlMB4XDTE2MDIxMjE0MzIwMFoXDTIwMDQxMjE0MzIwMFowGzEZ
+ MBcGA1UEAxMQdGVzdC1jZXJ0aWZpY2F0ZTCBnzANBgkqhkiG9w0BAQEFAAOBjQAw
+ gYkCgYEApL4J9L0ZxFJ1hI1LPIflAlAGvm6ZEvoT4qKU5Xf2JgU7/2geNR1qlNFa
+ SvCc08Knupp5yTgmvyK/Xi09U0N82vvp4Zvr/diSc4A/RA6Mta6egLySNT438kdT
+ nY2tR5feoTLwQpX0t4IMlwGQGT5h6Of2fKmDxzuwuyffcIHqLdsCAwEAAaNvMG0w
+ DAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUxl9WSxBprB0z0ibJs3rXEk0+95AwCwYD
+ VR0PBAQDAgXgMBEGCWCGSAGG+EIBAQQEAwIGQDAeBglghkgBhvhCAQ0EERYPeGNh
+ IGNlcnRpZmljYXRlMA0GCSqGSIb3DQEBBQUAA4GBAGC4T8SlFHK0yPSa+idGLQFQ
+ joZp2JHYvNlTPkRJ/J4TcXxBTJmArcQgTIuNoBtC+0A/SwdK4MfTCUY4vNWNdese
+ 5A4K65Nb7Oh1AdQieTBHNXXCdyFsva9/ScfQGEl7p55a52jOPs0StPd7g64uvjlg
+ YHi2yesCrOvVXt+lgPTd
+ -----END CERTIFICATE-----
+ PEM
+ end
+
+ let(:certificate_key) do
+ <<~KEY
+ -----BEGIN PRIVATE KEY-----
+ MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKS+CfS9GcRSdYSN
+ SzyH5QJQBr5umRL6E+KilOV39iYFO/9oHjUdapTRWkrwnNPCp7qaeck4Jr8iv14t
+ PVNDfNr76eGb6/3YknOAP0QOjLWunoC8kjU+N/JHU52NrUeX3qEy8EKV9LeCDJcB
+ kBk+Yejn9nypg8c7sLsn33CB6i3bAgMBAAECgYA2D26w80T7WZvazYr86BNMePpd
+ j2mIAqx32KZHzt/lhh40J/SRtX9+Kl0Y7nBoRR5Ja9u/HkAIxNxLiUjwg9r6cpg/
+ uITEF5nMt7lAk391BuI+7VOZZGbJDsq2ulPd6lO+C8Kq/PI/e4kXcIjeH6KwQsuR
+ 5vrXfBZ3sQfflaiN4QJBANBt8JY2LIGQF8o89qwUpRL5vbnKQ4IzZ5+TOl4RLR7O
+ AQpJ81tGuINghO7aunctb6rrcKJrxmEH1whzComybrMCQQDKV49nOBudRBAIgG4K
+ EnLzsRKISUHMZSJiYTYnablof8cKw1JaQduw7zgrUlLwnroSaAGX88+Jw1f5n2Lh
+ Vlg5AkBDdUGnrDLtYBCDEQYZHblrkc7ZAeCllDOWjxUV+uMqlCv8A4Ey6omvY57C
+ m6I8DkWVAQx8VPtozhvHjUw80rZHAkB55HWHAM3h13axKG0htCt7klhPsZHpx6MH
+ EPjGlXIT+aW2XiPmK3ZlCDcWIenE+lmtbOpI159Wpk8BGXs/s/xBAkEAlAY3ymgx
+ 63BDJEwvOb2IaP8lDDxNsXx9XJNVvQbv5n15vNsLHbjslHfAhAbxnLQ1fLhUPqSi
+ nNp/xedE1YxutQ==
+ -----END PRIVATE KEY-----
+ KEY
+ end
+
+ it 'adds new domain with certificate' do
+ visit new_project_pages_domain_path(project)
+
+ fill_in 'Domain', with: 'my.test.domain.com'
+ fill_in 'Certificate (PEM)', with: certificate_pem
+ fill_in 'Key (PEM)', with: certificate_key
+ click_button 'Create New Domain'
+
+ expect(page).to have_content('my.test.domain.com')
+ end
+
+ describe 'updating the certificate for an existing domain' do
+ let!(:domain) do
+ create(:pages_domain, project: project)
+ end
+
+ it 'allows the certificate to be updated' do
+ visit project_pages_path(project)
+
+ within('#content-body') { click_link 'Details' }
+ click_link 'Edit'
+ click_button 'Save Changes'
+
+ expect(page).to have_content('Domain was updated')
+ end
+
+ context 'when the certificate is invalid' do
+ it 'tells the user what the problem is' do
+ visit project_pages_path(project)
+
+ within('#content-body') { click_link 'Details' }
+ click_link 'Edit'
+ fill_in 'Certificate (PEM)', with: 'invalid data'
+ click_button 'Save Changes'
+
+ expect(page).to have_content('Certificate must be a valid PEM certificate')
+ expect(page).to have_content('Certificate misses intermediates')
+ expect(page).to have_content("Key doesn't match the certificate")
+ end
+ end
+ end
end
end
it_behaves_like 'no pages deployed'
+
+ describe 'project settings page' do
+ it 'renders "Pages" tab' do
+ visit edit_project_path(project)
+
+ page.within '.nav-sidebar' do
+ expect(page).to have_link('Pages')
+ end
+ end
+
+ context 'when pages are disabled' do
+ before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(false)
+ end
+
+ it 'does not render "Pages" tab' do
+ visit edit_project_path(project)
+
+ page.within '.nav-sidebar' do
+ expect(page).not_to have_link('Pages')
+ end
+ end
+ end
+ end
end
context 'when the user is not the owner' do
@@ -57,4 +223,118 @@ feature 'Pages' do
it_behaves_like 'no pages deployed'
end
+
+ describe 'HTTPS settings', :js, :https_pages_enabled do
+ background do
+ project.namespace.update(owner: user)
+
+ allow_any_instance_of(Project).to receive(:pages_deployed?) { true }
+ end
+
+ scenario 'tries to change the setting' do
+ visit project_pages_path(project)
+ expect(page).to have_content("Force domains with SSL certificates to use HTTPS")
+
+ uncheck :project_pages_https_only
+
+ click_button 'Save'
+
+ expect(page).to have_text('Your changes have been saved')
+ expect(page).not_to have_checked_field('project_pages_https_only')
+ end
+
+ context 'setting could not be updated' do
+ let(:service) { instance_double('Projects::UpdateService') }
+
+ before do
+ allow(Projects::UpdateService).to receive(:new).and_return(service)
+ allow(service).to receive(:execute).and_return(status: :error)
+ end
+
+ scenario 'tries to change the setting' do
+ visit project_pages_path(project)
+
+ uncheck :project_pages_https_only
+
+ click_button 'Save'
+
+ expect(page).to have_text('Something went wrong on our end')
+ end
+ end
+
+ context 'non-HTTPS domain exists' do
+ given(:project) { create(:project, pages_https_only: false) }
+
+ before do
+ create(:pages_domain, :without_key, :without_certificate, project: project)
+ end
+
+ scenario 'the setting is disabled' do
+ visit project_pages_path(project)
+
+ expect(page).to have_field(:project_pages_https_only, disabled: true)
+ expect(page).not_to have_button('Save')
+ end
+ end
+
+ context 'HTTPS pages are disabled', :https_pages_disabled do
+ scenario 'the setting is unavailable' do
+ visit project_pages_path(project)
+
+ expect(page).not_to have_field(:project_pages_https_only)
+ expect(page).not_to have_content('Force domains with SSL certificates to use HTTPS')
+ expect(page).not_to have_button('Save')
+ end
+ end
+ end
+
+ describe 'Remove page' do
+ context 'when user is the owner' do
+ let(:project) { create :project, :repository }
+
+ before do
+ project.namespace.update(owner: user)
+ end
+
+ context 'when pages are deployed' do
+ let(:pipeline) do
+ commit_sha = project.commit('HEAD').sha
+
+ project.pipelines.create(
+ ref: 'HEAD',
+ sha: commit_sha,
+ source: :push,
+ protected: false
+ )
+ end
+
+ let(:ci_build) do
+ create(
+ :ci_build,
+ project: project,
+ pipeline: pipeline,
+ ref: 'HEAD',
+ legacy_artifacts_file: fixture_file_upload(Rails.root.join('spec/fixtures/pages.zip')),
+ legacy_artifacts_metadata: fixture_file_upload(Rails.root.join('spec/fixtures/pages.zip.meta'))
+ )
+ end
+
+ before do
+ result = Projects::UpdatePagesService.new(project, ci_build).execute
+ expect(result[:status]).to eq(:success)
+ expect(project).to be_pages_deployed
+ end
+
+ it 'removes the pages' do
+ visit project_pages_path(project)
+
+ expect(page).to have_link('Remove pages')
+
+ click_link 'Remove pages'
+
+ expect(project.pages_deployed?).to be_falsey
+ end
+ end
+ end
+ end
end
diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb
index fa2f7a1fd78..065d00d51d4 100644
--- a/spec/features/projects/pipeline_schedules_spec.rb
+++ b/spec/features/projects/pipeline_schedules_spec.rb
@@ -160,19 +160,19 @@ feature 'Pipeline Schedules', :js do
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][][secret_value]"]')[0].set('AAA123')
all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB')
- all('[name="schedule[variables_attributes][][value]"]')[1].set('BBB123')
+ all('[name="schedule[variables_attributes][][secret_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')
+ page.within('.ci-variable-list') do
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA')
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123')
+ expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB')
+ expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123')
end
end
end
@@ -185,16 +185,18 @@ feature 'Pipeline Schedules', :js do
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')
+
+ find('.js-ci-variable-list-section .js-secret-value-reveal-button').click
+ first('.js-ci-variable-input-key').set('foo')
+ first('.js-ci-variable-input-value').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')
+ page.within('.ci-variable-list') do
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('foo')
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('bar')
end
end
end
@@ -207,15 +209,15 @@ feature 'Pipeline Schedules', :js do
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
+ find('.ci-variable-list .ci-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('')
+ page.within('.ci-variable-list') do
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('')
+ expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('')
end
end
end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 3987cea0b4f..990e5c4d9df 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -6,7 +6,7 @@ describe 'Pipeline', :js do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
shared_context 'pipeline builds' do
@@ -115,6 +115,13 @@ describe 'Pipeline', :js do
expect(page).not_to have_content('Retry job')
end
+
+ it 'should include the failure reason' do
+ page.within('#ci-badge-test') do
+ build_link = page.find('.js-pipeline-graph-job-link')
+ expect(build_link['data-original-title']).to eq('test - failed <br> (unknown failure)')
+ end
+ end
end
context 'when pipeline has manual jobs' do
@@ -289,6 +296,15 @@ describe 'Pipeline', :js do
it { expect(build_manual.reload).to be_pending }
end
+
+ context 'failed jobs' do
+ it 'displays a tooltip with the failure reason' do
+ page.within('.ci-table') do
+ failed_job_link = page.find('.ci-failed')
+ expect(failed_job_link[:title]).to eq('Failed <br> (unknown failure)')
+ end
+ end
+ end
end
describe 'GET /:project/pipelines/:id/failures' do
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index b87b47d0e1a..6e63e0f0b49 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -8,7 +8,7 @@ describe 'Pipelines', :js do
before do
sign_in(user)
- project.team << [user, :developer]
+ project.add_developer(user)
end
describe 'GET /:project/pipelines' do
@@ -86,7 +86,22 @@ describe 'Pipelines', :js do
it 'updates content when tab is clicked' do
page.find('.js-pipelines-tab-pending').click
wait_for_requests
- expect(page).to have_content('No pipelines to show.')
+ expect(page).to have_content('There are currently no pending pipelines.')
+ end
+ end
+
+ context 'navigation links' do
+ before do
+ visit project_pipelines_path(project)
+ wait_for_requests
+ end
+
+ it 'renders run pipeline link' do
+ expect(page).to have_link('Run Pipeline')
+ end
+
+ it 'renders ci lint link' do
+ expect(page).to have_link('CI Lint')
end
end
@@ -109,7 +124,8 @@ describe 'Pipelines', :js do
context 'when canceling' do
before do
- accept_confirm { find('.js-pipelines-cancel-button').click }
+ find('.js-pipelines-cancel-button').click
+ find('.js-primary-button').click
wait_for_requests
end
@@ -140,6 +156,7 @@ describe 'Pipelines', :js do
context 'when retrying' do
before do
find('.js-pipelines-retry-button').click
+ find('.js-primary-button').click
wait_for_requests
end
@@ -238,7 +255,8 @@ describe 'Pipelines', :js do
context 'when canceling' do
before do
- accept_alert { find('.js-pipelines-cancel-button').click }
+ find('.js-pipelines-cancel-button').click
+ find('.js-primary-button').click
end
it 'indicates that pipeline was canceled' do
@@ -331,6 +349,18 @@ describe 'Pipelines', :js do
it { expect(page).not_to have_selector('.build-artifacts') }
end
+
+ context 'with trace artifact' do
+ before do
+ create(:ci_build, :success, :trace_artifact, pipeline: pipeline)
+
+ visit_project_pipelines
+ end
+
+ it 'does not show trace artifact as artifacts' do
+ expect(page).not_to have_selector('.build-artifacts')
+ end
+ end
end
context 'mini pipeline graph' do
@@ -365,20 +395,20 @@ describe 'Pipelines', :js do
end
end
- context 'dropdown jobs list' do
- it 'should keep the dropdown open when the user ctr/cmd + clicks in the job name' do
+ context 'for a failed pipeline' do
+ let!(:build) do
+ create(:ci_build, :failed, pipeline: pipeline,
+ stage: 'build',
+ name: 'build')
+ end
+
+ it 'should display the failure reason' do
find('.js-builds-dropdown-button').click
- dropdown_item = find('.mini-pipeline-graph-dropdown-item').native
-
- %i(alt control).each do |meta_key|
- page.driver.browser.action
- .key_down(meta_key)
- .click(dropdown_item)
- .key_up(meta_key)
- .perform
- end
- expect(page).to have_selector('.js-ci-action-icon')
+ within('.js-builds-dropdown-list') do
+ build_element = page.find('.mini-pipeline-graph-dropdown-item')
+ expect(build_element['data-title']).to eq('build - failed <br> (unknown failure)')
+ end
end
end
end
@@ -545,6 +575,54 @@ describe 'Pipelines', :js do
end
end
end
+
+ describe 'Reset runner caches' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ create(:ci_empty_pipeline, status: 'success', project: project, sha: project.commit.id, ref: 'master')
+ project.add_master(user)
+ visit project_pipelines_path(project)
+ end
+
+ it 'has a clear caches button' do
+ expect(page).to have_button 'Clear Runner Caches'
+ end
+
+ describe 'user clicks the button' do
+ context 'when project already has jobs_cache_index' do
+ before do
+ project.update_attributes(jobs_cache_index: 1)
+ end
+
+ it 'increments jobs_cache_index' do
+ click_button 'Clear Runner Caches'
+ wait_for_requests
+ expect(page.find('.flash-notice')).to have_content 'Project cache successfully reset.'
+ end
+ end
+
+ context 'when project does not have jobs_cache_index' do
+ it 'sets jobs_cache_index to 1' do
+ click_button 'Clear Runner Caches'
+ wait_for_requests
+ expect(page.find('.flash-notice')).to have_content 'Project cache successfully reset.'
+ end
+ end
+ end
+ end
+
+ describe 'Empty State' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ visit project_pipelines_path(project)
+ end
+
+ it 'renders empty state' do
+ expect(page).to have_content 'Build with confidence'
+ end
+ end
end
context 'when user is not logged in' do
@@ -555,7 +633,9 @@ describe 'Pipelines', :js do
context 'when project is public' do
let(:project) { create(:project, :public, :repository) }
- it { expect(page).to have_content 'Build with confidence' }
+ context 'without pipelines' do
+ it { expect(page).to have_content 'This project is not currently set up to run pipelines.' }
+ end
end
context 'when project is private' do
diff --git a/spec/features/projects/redirects_spec.rb b/spec/features/projects/redirects_spec.rb
new file mode 100644
index 00000000000..d1d8ca07035
--- /dev/null
+++ b/spec/features/projects/redirects_spec.rb
@@ -0,0 +1,74 @@
+require 'spec_helper'
+
+describe 'Project redirects' do
+ let(:user) { create :user }
+ let(:public_project) { create :project, :public }
+ let(:private_project) { create :project, :private }
+
+ before do
+ allow(Gitlab.config.gitlab).to receive(:host).and_return('www.example.com')
+ end
+
+ it 'shows public project page' do
+ visit project_path(public_project)
+
+ page.within '.breadcrumbs .breadcrumb-item-text' do
+ expect(page).to have_content(public_project.name)
+ end
+ end
+
+ it 'redirects to sign in page when project is private' do
+ visit project_path(private_project)
+
+ expect(current_path).to eq(new_user_session_path)
+ end
+
+ it 'redirects to sign in page when project does not exist' do
+ visit project_path(build(:project, :public))
+
+ expect(current_path).to eq(new_user_session_path)
+ end
+
+ it 'redirects to public project page after signing in' do
+ visit project_path(public_project)
+
+ first(:link, 'Sign in').click
+
+ fill_in 'user_login', with: user.email
+ fill_in 'user_password', with: user.password
+ click_button 'Sign in'
+
+ expect(status_code).to eq(200)
+ expect(current_path).to eq("/#{public_project.full_path}")
+ end
+
+ it 'redirects to private project page after sign in' do
+ visit project_path(private_project)
+
+ owner = private_project.owner
+ fill_in 'user_login', with: owner.email
+ fill_in 'user_password', with: owner.password
+ click_button 'Sign in'
+
+ expect(status_code).to eq(200)
+ expect(current_path).to eq("/#{private_project.full_path}")
+ end
+
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ end
+
+ it 'returns 404 status when project does not exist' do
+ visit project_path(build(:project, :public))
+
+ expect(status_code).to eq(404)
+ end
+
+ it 'returns 404 when project is private' do
+ visit project_path(private_project)
+
+ expect(status_code).to eq(404)
+ end
+ end
+end
diff --git a/spec/features/projects/services/disable_triggers_spec.rb b/spec/features/projects/services/disable_triggers_spec.rb
new file mode 100644
index 00000000000..1a13fe03a67
--- /dev/null
+++ b/spec/features/projects/services/disable_triggers_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+describe 'Disable individual triggers' do
+ let(:project) { create(:project) }
+ let(:user) { project.owner }
+ let(:checkbox_selector) { 'input[type=checkbox][id$=_events]' }
+
+ before do
+ sign_in(user)
+
+ visit(project_settings_integrations_path(project))
+
+ click_link(service_name)
+ end
+
+ context 'service has multiple supported events' do
+ let(:service_name) { 'HipChat' }
+
+ it 'shows trigger checkboxes' do
+ event_count = HipchatService.supported_events.count
+
+ expect(page).to have_content "Trigger"
+ expect(page).to have_css(checkbox_selector, count: event_count)
+ end
+ end
+
+ context 'services only has one supported event' do
+ let(:service_name) { 'Asana' }
+
+ it "doesn't show unnecessary Trigger checkboxes" do
+ expect(page).not_to have_content "Trigger"
+ expect(page).not_to have_css(checkbox_selector)
+ end
+ end
+end
diff --git a/spec/features/projects/services/user_activates_issue_tracker_spec.rb b/spec/features/projects/services/user_activates_issue_tracker_spec.rb
new file mode 100644
index 00000000000..e9502178bd7
--- /dev/null
+++ b/spec/features/projects/services/user_activates_issue_tracker_spec.rb
@@ -0,0 +1,92 @@
+require 'spec_helper'
+
+describe 'User activates issue tracker', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ let(:url) { 'http://tracker.example.com' }
+
+ def fill_form(active = true)
+ check 'Active' if active
+
+ fill_in 'service_project_url', with: url
+ fill_in 'service_issues_url', with: "#{url}/:id"
+ fill_in 'service_new_issue_url', with: url
+ end
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_settings_integrations_path(project)
+ end
+
+ shared_examples 'external issue tracker activation' do |tracker:|
+ describe 'user sets and activates the Service' do
+ context 'when the connection test succeeds' do
+ before do
+ stub_request(:head, url).to_return(headers: { 'Content-Type' => 'application/json' })
+
+ click_link(tracker)
+ fill_form
+ click_button('Test settings and save changes')
+ wait_for_requests
+ end
+
+ it 'activates the service' do
+ expect(page).to have_content("#{tracker} activated.")
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ end
+
+ it 'shows the link in the menu' do
+ page.within('.nav-sidebar') do
+ expect(page).to have_link(tracker, href: url)
+ end
+ end
+ end
+
+ context 'when the connection test fails' do
+ it 'activates the service' do
+ stub_request(:head, url).to_raise(HTTParty::Error)
+
+ click_link(tracker)
+ fill_form
+ click_button('Test settings and save changes')
+ wait_for_requests
+
+ expect(find('.flash-container-page')).to have_content 'Test failed.'
+ expect(find('.flash-container-page')).to have_content 'Save anyway'
+
+ find('.flash-alert .flash-action').click
+ wait_for_requests
+
+ expect(page).to have_content("#{tracker} activated.")
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ end
+ end
+ end
+
+ describe 'user sets the service but keeps it disabled' do
+ before do
+ click_link(tracker)
+ fill_form(false)
+ click_button('Save changes')
+ end
+
+ it 'saves but does not activate the service' do
+ expect(page).to have_content("#{tracker} settings saved, but not activated.")
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ end
+
+ it 'does not show the external tracker link in the menu' do
+ page.within('.nav-sidebar') do
+ expect(page).not_to have_link(tracker, href: url)
+ end
+ end
+ end
+ end
+
+ it_behaves_like 'external issue tracker activation', tracker: 'Redmine'
+ it_behaves_like 'external issue tracker activation', tracker: 'Bugzilla'
+ it_behaves_like 'external issue tracker activation', tracker: 'Custom Issue Tracker'
+end
diff --git a/spec/features/projects/services/user_activates_jira_spec.rb b/spec/features/projects/services/user_activates_jira_spec.rb
index ac78b1dfb1c..429128ec096 100644
--- a/spec/features/projects/services/user_activates_jira_spec.rb
+++ b/spec/features/projects/services/user_activates_jira_spec.rb
@@ -3,7 +3,6 @@ require 'spec_helper'
describe 'User activates Jira', :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' }
@@ -18,7 +17,7 @@ describe 'User activates Jira', :js do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_settings_integrations_path(project)
@@ -26,7 +25,7 @@ describe 'User activates Jira', :js do
describe 'user sets and activates Jira Service' do
context 'when Jira connection test succeeds' do
- it 'activates the JIRA service' do
+ before do
server_info = { key: 'value' }.to_json
WebMock.stub_request(:get, test_url).with(basic_auth: %w(username password)).to_return(body: server_info)
@@ -34,10 +33,18 @@ describe 'User activates Jira', :js do
fill_form
click_button('Test settings and save changes')
wait_for_requests
+ end
+ it 'activates the JIRA service' do
expect(page).to have_content('JIRA activated.')
expect(current_path).to eq(project_settings_integrations_path(project))
end
+
+ it 'shows the JIRA link in the menu' do
+ page.within('.nav-sidebar') do
+ expect(page).to have_link('JIRA', href: url)
+ end
+ end
end
context 'when Jira connection test fails' do
@@ -75,14 +82,20 @@ describe 'User activates Jira', :js do
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')
+ before do
+ click_link('JIRA')
+ fill_form(false)
+ click_button('Save changes')
+ end
- expect(page).to have_content('JIRA settings saved, but not activated.')
- expect(current_path).to eq(project_settings_integrations_path(project))
+ it 'saves but does not activate the JIRA service' do
+ expect(page).to have_content('JIRA settings saved, but not activated.')
+ expect(current_path).to eq(project_settings_integrations_path(project))
+ end
+
+ it 'does not show the JIRA link in the menu' do
+ page.within('.nav-sidebar') do
+ expect(page).not_to have_link('JIRA', href: url)
end
end
end
diff --git a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
index 6f057137867..b2906e315f7 100644
--- a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
+++ b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
@@ -8,7 +8,7 @@ feature 'Setup Mattermost slash commands', :js do
before do
stub_mattermost_setting(enabled: mattermost_enabled)
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit edit_project_service_path(project, service)
end
diff --git a/spec/features/projects/services/user_activates_prometheus_spec.rb b/spec/features/projects/services/user_activates_prometheus_spec.rb
new file mode 100644
index 00000000000..33f884eb148
--- /dev/null
+++ b/spec/features/projects/services/user_activates_prometheus_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe 'User activates Prometheus' do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit(project_settings_integrations_path(project))
+
+ click_link('Prometheus')
+ end
+
+ it 'activates service' do
+ check('Active')
+ fill_in('API URL', with: 'http://prometheus.example.com')
+ click_button('Save changes')
+
+ expect(page).to have_content('Prometheus activated.')
+ end
+end
diff --git a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
index a8baf126269..4a88654210c 100644
--- a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
+++ b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
@@ -6,7 +6,7 @@ feature 'Slack slash commands' do
given(:service) { project.create_slack_slash_commands_service }
background do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit edit_project_service_path(project, service)
end
diff --git a/spec/features/projects/settings/integration_settings_spec.rb b/spec/features/projects/settings/integration_settings_spec.rb
index cbdb7973ac8..f6a1a46df11 100644
--- a/spec/features/projects/settings/integration_settings_spec.rb
+++ b/spec/features/projects/settings/integration_settings_spec.rb
@@ -8,7 +8,7 @@ feature 'Integration settings' do
background do
sign_in(user)
- project.team << [user, role]
+ project.add_role(user, role)
end
context 'for developer' do
diff --git a/spec/features/projects/settings/merge_requests_settings_spec.rb b/spec/features/projects/settings/merge_requests_settings_spec.rb
index ac76c30cc7c..015db603d33 100644
--- a/spec/features/projects/settings/merge_requests_settings_spec.rb
+++ b/spec/features/projects/settings/merge_requests_settings_spec.rb
@@ -5,7 +5,7 @@ feature 'Project settings > Merge Requests', :js do
let(:user) { create(:user) }
background do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb
index 561f08cba00..d0720855564 100644
--- a/spec/features/projects/settings/pipelines_settings_spec.rb
+++ b/spec/features/projects/settings/pipelines_settings_spec.rb
@@ -7,7 +7,7 @@ feature "Pipelines settings" do
background do
sign_in(user)
- project.team << [user, role]
+ project.add_role(user, role)
end
context 'for developer' do
diff --git a/spec/features/projects/settings/project_badges_spec.rb b/spec/features/projects/settings/project_badges_spec.rb
new file mode 100644
index 00000000000..cc3551a4c21
--- /dev/null
+++ b/spec/features/projects/settings/project_badges_spec.rb
@@ -0,0 +1,125 @@
+require 'spec_helper'
+
+feature 'Project Badges' do
+ include WaitForRequests
+
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, namespace: group) }
+ let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'}
+ let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'}
+ let!(:project_badge) { create(:project_badge, project: project) }
+ let!(:group_badge) { create(:group_badge, group: group) }
+
+ before do
+ group.add_master(user)
+ sign_in(user)
+
+ visit(project_settings_badges_path(project))
+ end
+
+ it 'shows a list of badges', :js do
+ page.within '.badge-settings' do
+ wait_for_requests
+
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ expect(rows[0]).to have_content group_badge.link_url
+ expect(rows[1]).to have_content project_badge.link_url
+ end
+ end
+
+ context 'adding a badge', :js do
+ it 'user can preview a badge' do
+ page.within '.badge-settings form' do
+ fill_in 'badge-link-url', with: badge_link_url
+ fill_in 'badge-image-url', with: badge_image_url
+ within '#badge-preview' do
+ expect(find('a')[:href]).to eq badge_link_url
+ expect(find('a img')[:src]).to eq badge_image_url
+ end
+ end
+ end
+
+ it do
+ page.within '.badge-settings' do
+ fill_in 'badge-link-url', with: badge_link_url
+ fill_in 'badge-image-url', with: badge_image_url
+
+ click_button 'Add badge'
+ wait_for_requests
+
+ within '.panel-body' do
+ expect(find('a')[:href]).to eq badge_link_url
+ expect(find('a img')[:src]).to eq badge_image_url
+ end
+ end
+ end
+ end
+
+ context 'editing a badge', :js do
+ it 'form is shown when clicking edit button in list' do
+ page.within '.badge-settings' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ rows[1].find('[aria-label="Edit"]').click
+
+ within 'form' do
+ expect(find('#badge-link-url').value).to eq project_badge.link_url
+ expect(find('#badge-image-url').value).to eq project_badge.image_url
+ end
+ end
+ end
+
+ it 'updates a badge when submitting the edit form' do
+ page.within '.badge-settings' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ rows[1].find('[aria-label="Edit"]').click
+ within 'form' do
+ fill_in 'badge-link-url', with: badge_link_url
+ fill_in 'badge-image-url', with: badge_image_url
+
+ click_button 'Save changes'
+ wait_for_requests
+ end
+
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ expect(rows[1]).to have_content badge_link_url
+ end
+ end
+ end
+
+ context 'deleting a badge', :js do
+ def click_delete_button(badge_row)
+ badge_row.find('[aria-label="Delete"]').click
+ end
+
+ it 'shows a modal when deleting a badge' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+
+ click_delete_button(rows[1])
+
+ expect(find('.modal .modal-title')).to have_content 'Delete badge?'
+ end
+
+ it 'deletes a badge when confirming the modal' do
+ wait_for_requests
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 2
+ click_delete_button(rows[1])
+
+ find('.modal .btn-danger').click
+ wait_for_requests
+
+ rows = all('.panel-body > div')
+ expect(rows.length).to eq 1
+ expect(rows[0]).to have_content group_badge.link_url
+ end
+ end
+end
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index e2a5619c22b..f2c371b7df5 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -6,7 +6,7 @@ feature 'Repository settings' do
let(:role) { :developer }
background do
- project.team << [user, role]
+ project.add_role(user, role)
sign_in(user)
end
@@ -43,7 +43,7 @@ feature 'Repository settings' do
fill_in 'deploy_key_title', with: 'new_deploy_key'
fill_in 'deploy_key_key', with: new_ssh_key
- check 'deploy_key_can_push'
+ check 'deploy_key_deploy_keys_projects_attributes_0_can_push'
click_button 'Add key'
expect(page).to have_content('new_deploy_key')
@@ -57,7 +57,7 @@ feature 'Repository settings' do
find('li', text: private_deploy_key.title).click_link('Edit')
fill_in 'deploy_key_title', with: 'updated_deploy_key'
- check 'deploy_key_can_push'
+ check 'deploy_key_deploy_keys_projects_attributes_0_can_push'
click_button 'Save changes'
expect(page).to have_content('updated_deploy_key')
@@ -66,7 +66,7 @@ feature 'Repository settings' do
scenario 'edit a deploy key from projects user has access to' do
project2 = create(:project_empty_repo)
- project2.team << [user, role]
+ project2.add_role(user, role)
project2.deploy_keys << private_deploy_key
visit project_settings_repository_path(project)
@@ -74,11 +74,9 @@ feature 'Repository settings' do
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
@@ -90,5 +88,32 @@ feature 'Repository settings' do
expect(page).not_to have_content(private_deploy_key.title)
end
end
+
+ context 'Deploy tokens' do
+ let!(:deploy_token) { create(:deploy_token, projects: [project]) }
+
+ before do
+ stub_container_registry_config(enabled: true)
+ visit project_settings_repository_path(project)
+ end
+
+ scenario 'view deploy tokens' do
+ within('.deploy-tokens') do
+ expect(page).to have_content(deploy_token.name)
+ expect(page).to have_content('read_repository')
+ expect(page).to have_content('read_registry')
+ end
+ end
+
+ scenario 'add a new deploy token' do
+ fill_in 'deploy_token_name', with: 'new_deploy_key'
+ fill_in 'deploy_token_expires_at', with: (Date.today + 1.month).to_s
+ check 'deploy_token_read_repository'
+ check 'deploy_token_read_registry'
+ click_button 'Create deploy token'
+
+ expect(page).to have_content('Your new project deploy token has been created')
+ end
+ end
end
end
diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/settings/user_manages_project_members_spec.rb
index 2709047b8de..0a4f57bcd21 100644
--- a/spec/features/projects/settings/user_manages_project_members_spec.rb
+++ b/spec/features/projects/settings/user_manages_project_members_spec.rb
@@ -39,7 +39,7 @@ describe 'User manages project members' do
click_link('Import')
end
- select(project2.name_with_namespace, from: 'source_project_id')
+ select(project2.full_name, from: 'source_project_id')
click_button('Import')
project_member = project.project_members.find_by(user_id: user_mike.id)
diff --git a/spec/features/projects/settings/visibility_settings_spec.rb b/spec/features/projects/settings/visibility_settings_spec.rb
index 1c3b84d0114..06f6702670b 100644
--- a/spec/features/projects/settings/visibility_settings_spec.rb
+++ b/spec/features/projects/settings/visibility_settings_spec.rb
@@ -31,7 +31,7 @@ feature 'Visibility settings', :js do
let(:master_user) { create(:user) }
before do
- project.team << [master_user, :master]
+ project.add_master(master_user)
sign_in(master_user)
visit edit_project_path(project)
end
diff --git a/spec/features/projects/show_project_spec.rb b/spec/features/projects/show_project_spec.rb
index 0b94c9eae5d..e4f13e6cab7 100644
--- a/spec/features/projects/show_project_spec.rb
+++ b/spec/features/projects/show_project_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe 'Project show page', :feature do
+ include DropzoneHelper
+
context 'when project pending delete' do
let(:project) { create(:project, :empty_repo, pending_delete: true) }
@@ -17,4 +19,341 @@ describe 'Project show page', :feature do
expect(page).to have_content("This project was scheduled for deletion, but failed with the following message: #{project.delete_error}")
end
end
+
+ describe 'stat button existence' do
+ # For "New file", "Add License" functionality,
+ # see spec/features/projects/files/project_owner_creates_license_file_spec.rb
+ # see spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
+
+ let(:user) { create(:user) }
+
+ describe 'empty project' do
+ let(:project) { create(:project, :public, :empty_repo) }
+ let(:presenter) { project.present(current_user: user) }
+
+ describe 'as a normal user' do
+ before do
+ sign_in(user)
+
+ visit project_path(project)
+ end
+
+ it 'no Auto DevOps button if can not manage pipelines' do
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Enable Auto DevOps')
+ expect(page).not_to have_link('Auto DevOps enabled')
+ end
+ end
+
+ it '"Auto DevOps enabled" button not linked' do
+ project.create_auto_devops!(enabled: true)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).to have_text('Auto DevOps enabled')
+ end
+ end
+ end
+
+ describe 'as a master' do
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_path(project)
+ end
+
+ it '"New file" button linked to new file page' do
+ page.within('.project-stats') do
+ expect(page).to have_link('New file', href: project_new_blob_path(project, project.default_branch || 'master'))
+ end
+ end
+
+ it '"Add Readme" button linked to new file populated for a readme' do
+ page.within('.project-stats') do
+ expect(page).to have_link('Add Readme', href: presenter.add_readme_path)
+ end
+ end
+
+ it '"Add License" button linked to new file populated for a license' do
+ page.within('.project-stats') do
+ expect(page).to have_link('Add License', href: presenter.add_license_path)
+ end
+ end
+
+ describe 'Auto DevOps button' do
+ it '"Enable Auto DevOps" button linked to settings page' do
+ page.within('.project-stats') do
+ expect(page).to have_link('Enable Auto DevOps', href: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings'))
+ end
+ end
+
+ it '"Auto DevOps enabled" anchor linked to settings page' do
+ project.create_auto_devops!(enabled: true)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).to have_link('Auto DevOps enabled', href: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings'))
+ end
+ end
+ end
+
+ describe 'Kubernetes cluster button' do
+ it '"Add Kubernetes cluster" button linked to clusters page' do
+ page.within('.project-stats') do
+ expect(page).to have_link('Add Kubernetes cluster', href: new_project_cluster_path(project))
+ end
+ end
+
+ it '"Kubernetes cluster" anchor linked to cluster page' do
+ cluster = create(:cluster, :provided_by_gcp, projects: [project])
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).to have_link('Kubernetes configured', href: project_cluster_path(project, cluster))
+ end
+ end
+ end
+ end
+ end
+
+ describe 'populated project' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:presenter) { project.present(current_user: user) }
+
+ describe 'as a normal user' do
+ before do
+ sign_in(user)
+
+ visit project_path(project)
+ end
+
+ it 'no Auto DevOps button if can not manage pipelines' do
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Enable Auto DevOps')
+ expect(page).not_to have_link('Auto DevOps enabled')
+ end
+ end
+
+ it '"Auto DevOps enabled" button not linked' do
+ project.create_auto_devops!(enabled: true)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).to have_text('Auto DevOps enabled')
+ end
+ end
+
+ it 'no Kubernetes cluster button if can not manage clusters' do
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Add Kubernetes cluster')
+ expect(page).not_to have_link('Kubernetes configured')
+ end
+ end
+ end
+
+ describe 'as a master' do
+ before do
+ allow_any_instance_of(AutoDevopsHelper).to receive(:show_auto_devops_callout?).and_return(false)
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_path(project)
+ end
+
+ it 'no "Add Changelog" button if the project already has a changelog' do
+ expect(project.repository.changelog).not_to be_nil
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Add Changelog')
+ end
+ end
+
+ it 'no "Add License" button if the project already has a license' do
+ expect(project.repository.license_blob).not_to be_nil
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Add License')
+ end
+ end
+
+ it 'no "Add Contribution guide" button if the project already has a contribution guide' do
+ expect(project.repository.contribution_guide).not_to be_nil
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Add Contribution guide')
+ end
+ end
+
+ describe 'GitLab CI configuration button' do
+ it '"Set up CI/CD" button linked to new file populated for a .gitlab-ci.yml' do
+ expect(project.repository.gitlab_ci_yml).to be_nil
+
+ page.within('.project-stats') do
+ expect(page).to have_link('Set up CI/CD', href: presenter.add_ci_yml_path)
+ end
+ end
+
+ it 'no "Set up CI/CD" button if the project already has a .gitlab-ci.yml' do
+ 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
+
+ expect(project.repository.gitlab_ci_yml).not_to be_nil
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Set up CI/CD')
+ end
+ end
+
+ it 'no "Set up CI/CD" button if the project has Auto DevOps enabled' do
+ project.create_auto_devops!(enabled: true)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Set up CI/CD')
+ end
+ end
+ end
+
+ describe 'Auto DevOps button' do
+ it '"Enable Auto DevOps" button linked to settings page' do
+ page.within('.project-stats') do
+ expect(page).to have_link('Enable Auto DevOps', href: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings'))
+ end
+ end
+
+ it '"Enable Auto DevOps" button linked to settings page' do
+ project.create_auto_devops!(enabled: true)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).to have_link('Auto DevOps enabled', href: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings'))
+ end
+ end
+
+ it 'no Auto DevOps button if Auto DevOps callout is shown' do
+ allow_any_instance_of(AutoDevopsHelper).to receive(:show_auto_devops_callout?).and_return(true)
+
+ visit project_path(project)
+
+ expect(page).to have_selector('.js-autodevops-banner')
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Enable Auto DevOps')
+ expect(page).not_to have_link('Auto DevOps enabled')
+ end
+ end
+
+ it 'no "Enable Auto DevOps" button when .gitlab-ci.yml already exists' do
+ 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
+
+ expect(project.repository.gitlab_ci_yml).not_to be_nil
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Enable Auto DevOps')
+ expect(page).not_to have_link('Auto DevOps enabled')
+ end
+ end
+ end
+
+ describe 'Kubernetes cluster button' do
+ it '"Add Kubernetes cluster" button linked to clusters page' do
+ page.within('.project-stats') do
+ expect(page).to have_link('Add Kubernetes cluster', href: new_project_cluster_path(project))
+ end
+ end
+
+ it '"Kubernetes cluster" button linked to cluster page' do
+ cluster = create(:cluster, :provided_by_gcp, projects: [project])
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).to have_link('Kubernetes configured', href: project_cluster_path(project, cluster))
+ end
+ end
+ end
+
+ describe '"Set up Koding" button' do
+ it 'no "Set up Koding" button if Koding disabled' do
+ stub_application_setting(koding_enabled?: false)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Set up Koding')
+ end
+ end
+
+ it 'no "Set up Koding" button if the project already has a .koding.yml' do
+ stub_application_setting(koding_enabled?: true)
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:koding_url).and_return('http://koding.example.com')
+ expect(project.repository.changelog).not_to be_nil
+ allow_any_instance_of(Repository).to receive(:koding_yml).and_return(project.repository.changelog)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).not_to have_link('Set up Koding')
+ end
+ end
+
+ it '"Set up Koding" button linked to new file populated for a .koding.yml' do
+ stub_application_setting(koding_enabled?: true)
+
+ visit project_path(project)
+
+ page.within('.project-stats') do
+ expect(page).to have_link('Set up Koding', href: presenter.add_koding_stack_path)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe 'dropzone', :js do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_path(project)
+ end
+
+ it 'can upload files' do
+ find('.add-to-tree').click
+ click_link 'Upload file'
+ drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
+
+ expect(find('.dz-filename')).to have_content('doc_sample.txt')
+ end
+ end
end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
index e4215291f99..3466a3dfb77 100644
--- a/spec/features/projects/snippets/create_snippet_spec.rb
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -16,7 +16,7 @@ feature 'Create Snippet', :js do
context 'when a user is authenticated' do
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_snippets_path(project)
diff --git a/spec/features/projects/snippets/show_spec.rb b/spec/features/projects/snippets/show_spec.rb
index 08dc7cf6c5b..216f2af7c88 100644
--- a/spec/features/projects/snippets/show_spec.rb
+++ b/spec/features/projects/snippets/show_spec.rb
@@ -6,7 +6,7 @@ feature 'Project snippet', :js do
let(:snippet) { create(:project_snippet, project: project, file_name: file_name, content: content) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/projects/tags/download_buttons_spec.rb b/spec/features/projects/tags/download_buttons_spec.rb
index d38a5b1324b..b62498194c4 100644
--- a/spec/features/projects/tags/download_buttons_spec.rb
+++ b/spec/features/projects/tags/download_buttons_spec.rb
@@ -24,7 +24,7 @@ feature 'Download buttons in tags page' do
background do
sign_in(user)
- project.team << [user, role]
+ project.add_role(user, role)
end
describe 'when checking tags' do
diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb
index 3f6d16c8acf..d96c7e655ba 100644
--- a/spec/features/projects/tree/create_directory_spec.rb
+++ b/spec/features/projects/tree/create_directory_spec.rb
@@ -8,13 +8,11 @@ feature 'Multi-file editor new directory', :js do
project.add_master(user)
sign_in(user)
- set_cookie('new_repo', 'true')
-
visit project_tree_path(project, :master)
wait_for_requests
- click_link('Multi Edit')
+ click_link('Web IDE')
wait_for_requests
end
@@ -46,8 +44,6 @@ feature 'Multi-file editor new directory', :js do
wait_for_requests
- find('.multi-file-commit-panel-collapse-btn').click
-
fill_in('commit-message', with: 'commit message ide')
click_button('Commit')
diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb
index ba71eef07f4..a4cbd5cf766 100644
--- a/spec/features/projects/tree/create_file_spec.rb
+++ b/spec/features/projects/tree/create_file_spec.rb
@@ -8,13 +8,11 @@ feature 'Multi-file editor new file', :js do
project.add_master(user)
sign_in(user)
- set_cookie('new_repo', 'true')
-
- visit project_tree_path(project, :master)
+ visit project_path(project)
wait_for_requests
- click_link('Multi Edit')
+ click_link('Web IDE')
wait_for_requests
end
@@ -36,8 +34,6 @@ feature 'Multi-file editor new file', :js do
wait_for_requests
- find('.multi-file-commit-panel-collapse-btn').click
-
fill_in('commit-message', with: 'commit message ide')
click_button('Commit')
diff --git a/spec/features/projects/tree/rss_spec.rb b/spec/features/projects/tree/rss_spec.rb
index 4f2e0a76a65..6407370ac0d 100644
--- a/spec/features/projects/tree/rss_spec.rb
+++ b/spec/features/projects/tree/rss_spec.rb
@@ -7,7 +7,7 @@ feature 'Project Tree RSS' do
context 'when signed in' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit path
end
diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb
index c8a17871508..c4b3fb9d171 100644
--- a/spec/features/projects/tree/tree_show_spec.rb
+++ b/spec/features/projects/tree/tree_show_spec.rb
@@ -25,4 +25,18 @@ feature 'Projects tree' do
expect(page).to have_selector('.label-lfs', text: 'LFS')
end
end
+
+ context 'web IDE', :js do
+ before do
+ visit project_tree_path(project, File.join('master', 'bar'))
+
+ click_link 'Web IDE'
+
+ find('.ide-file-list')
+ end
+
+ it 'opens folder in IDE' do
+ expect(page).to have_selector('.is-open', text: 'bar')
+ end
+ end
end
diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb
index 9fbb1dbd0e8..8e53ae15700 100644
--- a/spec/features/projects/tree/upload_file_spec.rb
+++ b/spec/features/projects/tree/upload_file_spec.rb
@@ -10,13 +10,11 @@ feature 'Multi-file editor upload file', :js do
project.add_master(user)
sign_in(user)
- set_cookie('new_repo', 'true')
-
visit project_tree_path(project, :master)
wait_for_requests
- click_link('Multi Edit')
+ click_link('Web IDE')
wait_for_requests
end
diff --git a/spec/features/projects/user_browses_files_spec.rb b/spec/features/projects/user_browses_files_spec.rb
index f5e4d7f5130..62e6419cc42 100644
--- a/spec/features/projects/user_browses_files_spec.rb
+++ b/spec/features/projects/user_browses_files_spec.rb
@@ -15,7 +15,7 @@ describe 'User browses files' do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/projects/user_creates_directory_spec.rb b/spec/features/projects/user_creates_directory_spec.rb
index 052cb3188c5..00e48f6fabd 100644
--- a/spec/features/projects/user_creates_directory_spec.rb
+++ b/spec/features/projects/user_creates_directory_spec.rb
@@ -11,7 +11,7 @@ feature 'User creates a directory', :js do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
sign_in(user)
visit project_tree_path(project, 'master')
end
@@ -63,7 +63,7 @@ feature 'User creates a directory', :js do
context 'when an user does not have write access' do
before do
- project2.team << [user, :reporter]
+ project2.add_reporter(user)
visit(project2_tree_path_root_ref)
end
diff --git a/spec/features/projects/user_creates_files_spec.rb b/spec/features/projects/user_creates_files_spec.rb
index d84b91ddc32..8993533676b 100644
--- a/spec/features/projects/user_creates_files_spec.rb
+++ b/spec/features/projects/user_creates_files_spec.rb
@@ -12,7 +12,7 @@ describe 'User creates files' do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -33,7 +33,7 @@ describe 'User creates files' do
context 'when an user does not have write access' do
before do
- project2.team << [user, :reporter]
+ project2.add_reporter(user)
visit(project2_tree_path_root_ref)
end
@@ -131,15 +131,22 @@ describe 'User creates files' do
context 'when an user does not have write access' do
before do
- project2.team << [user, :reporter]
+ project2.add_reporter(user)
visit(project2_tree_path_root_ref)
- end
- it 'creates and commit new file in forked project', :js do
find('.add-to-tree').click
click_link('New file')
+ end
+
+ it 'shows a message saying the file will be committed in a fork' do
+ message = "A new branch will be created in your fork and a new merge request will be started."
+ expect(page).to have_content(message)
+ end
+
+ it 'creates and commit new file in forked project', :js do
expect(page).to have_selector('.file-editor')
+ expect(page).to have_content
find('#editor')
execute_script("ace.edit('editor').setValue('*.rbca')")
diff --git a/spec/features/projects/user_deletes_files_spec.rb b/spec/features/projects/user_deletes_files_spec.rb
index 9e4e92ec076..9d55197e719 100644
--- a/spec/features/projects/user_deletes_files_spec.rb
+++ b/spec/features/projects/user_deletes_files_spec.rb
@@ -17,7 +17,7 @@ describe 'User deletes files' do
context 'when an user has write access' do
before do
- project.team << [user, :master]
+ project.add_master(user)
visit(project_tree_path_root_ref)
end
@@ -37,7 +37,7 @@ describe 'User deletes files' do
context 'when an user does not have write access' do
before do
- project2.team << [user, :reporter]
+ project2.add_reporter(user)
visit(project2_tree_path_root_ref)
end
diff --git a/spec/features/projects/user_edits_files_spec.rb b/spec/features/projects/user_edits_files_spec.rb
index d26ee653415..05c2be473da 100644
--- a/spec/features/projects/user_edits_files_spec.rb
+++ b/spec/features/projects/user_edits_files_spec.rb
@@ -14,7 +14,7 @@ describe 'User edits files' do
context 'when an user has write access' do
before do
- project.team << [user, :master]
+ project.add_master(user)
visit(project_tree_path_root_ref)
end
@@ -33,7 +33,9 @@ describe 'User edits files' 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')
+ page.within '.content' do
+ expect(page).not_to have_link('edit')
+ end
end
it 'commits an edited file', :js do
@@ -87,7 +89,7 @@ describe 'User edits files' do
context 'when an user does not have write access' do
before do
- project2.team << [user, :reporter]
+ project2.add_reporter(user)
visit(project2_tree_path_root_ref)
end
diff --git a/spec/features/projects/user_replaces_files_spec.rb b/spec/features/projects/user_replaces_files_spec.rb
index 245b6aa285b..74872403b35 100644
--- a/spec/features/projects/user_replaces_files_spec.rb
+++ b/spec/features/projects/user_replaces_files_spec.rb
@@ -19,7 +19,7 @@ describe 'User replaces files' do
context 'when an user has write access' do
before do
- project.team << [user, :master]
+ project.add_master(user)
visit(project_tree_path_root_ref)
end
@@ -45,7 +45,7 @@ describe 'User replaces files' do
context 'when an user does not have write access' do
before do
- project2.team << [user, :reporter]
+ project2.add_reporter(user)
visit(project2_tree_path_root_ref)
end
diff --git a/spec/features/projects/user_uploads_files_spec.rb b/spec/features/projects/user_uploads_files_spec.rb
index ae51901adc6..75898afcda9 100644
--- a/spec/features/projects/user_uploads_files_spec.rb
+++ b/spec/features/projects/user_uploads_files_spec.rb
@@ -14,7 +14,7 @@ describe 'User uploads files' do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
@@ -50,7 +50,7 @@ describe 'User uploads files' do
context 'when an user does not have write access' do
before do
- project2.team << [user, :reporter]
+ project2.add_reporter(user)
visit(project2_tree_path_root_ref)
end
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index 337baaf4dcd..006c15d60c5 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -13,7 +13,7 @@ feature 'Projects > Wiki > User previews markdown changes', :js do
end
background do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
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 949d90a50ff..ef1bb712846 100644
--- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
@@ -1,154 +1,234 @@
require 'spec_helper'
describe 'User updates wiki page' do
- let(:user) { create(:user) }
-
- before do
- project.add_master(user)
- sign_in(user)
- end
-
- context 'when wiki is empty' do
+ shared_examples 'wiki page user update' do
+ let(:user) { create(:user) }
before do
- visit(project_wikis_path(project))
+ project.add_master(user)
+ sign_in(user)
end
- context 'in a user namespace' do
- let(:project) { create(:project, namespace: user.namespace) }
+ context 'when wiki is empty' do
+ before do
+ visit(project_wikis_path(project))
+ end
+
+ context 'in a user namespace' do
+ let(:project) { create(:project, namespace: user.namespace) }
+
+ it 'redirects back to the home edit page' do
+ page.within(:css, '.wiki-form .form-actions') do
+ click_on('Cancel')
+ end
- it 'redirects back to the home edit page' do
- page.within(:css, '.wiki-form .form-actions') do
- click_on('Cancel')
+ expect(current_path).to eq project_wiki_path(project, :home)
end
- expect(current_path).to eq project_wiki_path(project, :home)
+ it 'updates a page that has a path', :js do
+ click_on('New page')
+
+ page.within('#modal-new-wiki') do
+ fill_in(:new_wiki_path, with: 'one/two/three-test')
+ click_on('Create page')
+ end
+
+ page.within '.wiki-form' do
+ fill_in(:wiki_content, with: 'wiki content')
+ click_on('Create page')
+ end
+
+ expect(current_path).to include('one/two/three-test')
+ expect(find('.wiki-pages')).to have_content('Three')
+
+ first(:link, text: 'Three').click
+
+ expect(find('.nav-text')).to have_content('Three')
+
+ click_on('Edit')
+
+ expect(current_path).to include('one/two/three-test')
+ expect(page).to have_content('Edit Page')
+
+ fill_in('Content', with: 'Updated Wiki Content')
+ click_on('Save changes')
+
+ expect(page).to have_content('Updated Wiki Content')
+ end
end
+ end
+
+ context 'when wiki is not empty' do
+ let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) }
+ let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: 'home', content: 'Home page' }) }
+
+ before do
+ visit(project_wikis_path(project))
+ end
+
+ context 'in a user namespace' do
+ let(:project) { create(:project, namespace: user.namespace) }
+
+ it 'updates a page' do
+ click_link('Edit')
- it 'updates a page that has a path', :js do
- click_on('New page')
+ # Commit message field should have correct value.
+ expect(page).to have_field('wiki[message]', with: 'Update home')
- page.within('#modal-new-wiki') do
- fill_in(:new_wiki_path, with: 'one/two/three-test')
- click_on('Create page')
+ fill_in(:wiki_content, with: 'My awesome wiki!')
+ click_button('Save changes')
+
+ expect(page).to have_content('Home')
+ expect(page).to have_content("Last edited by #{user.name}")
+ expect(page).to have_content('My awesome wiki!')
end
- page.within '.wiki-form' do
- fill_in(:wiki_content, with: 'wiki content')
- click_on('Create page')
+ it 'shows a validation error message' do
+ click_link('Edit')
+
+ fill_in(:wiki_content, with: '')
+ click_button('Save changes')
+
+ expect(page).to have_selector('.wiki-form')
+ expect(page).to have_content('Edit Page')
+ expect(page).to have_content('The form contains the following error:')
+ expect(page).to have_content("Content can't be blank")
+ expect(find('textarea#wiki_content').value).to eq('')
end
- expect(current_path).to include('one/two/three-test')
- expect(find('.wiki-pages')).to have_content('Three')
+ it 'shows the autocompletion dropdown', :js do
+ click_link('Edit')
- first(:link, text: 'Three').click
+ find('#wiki_content').native.send_keys('')
+ fill_in(:wiki_content, with: '@')
- expect(find('.nav-text')).to have_content('Three')
+ expect(page).to have_selector('.atwho-view')
+ end
- click_on('Edit')
+ it 'shows the error message' do
+ click_link('Edit')
- expect(current_path).to include('one/two/three-test')
- expect(page).to have_content('Edit Page')
+ wiki_page.update(content: 'Update')
+
+ click_button('Save changes')
+
+ expect(page).to have_content('Someone edited the page the same time you did.')
+ end
- fill_in('Content', with: 'Updated Wiki Content')
- click_on('Save changes')
+ it 'updates a page' do
+ click_on('Edit')
+ fill_in('Content', with: 'Updated Wiki Content')
+ click_on('Save changes')
- expect(page).to have_content('Updated Wiki Content')
+ expect(page).to have_content('Updated Wiki Content')
+ end
+
+ it 'cancels edititng of a page' do
+ click_on('Edit')
+
+ page.within(:css, '.wiki-form .form-actions') do
+ click_on('Cancel')
+ end
+
+ expect(current_path).to eq(project_wiki_path(project, wiki_page))
+ end
end
- end
- end
- context 'when wiki is not empty' do
- let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) }
- let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: 'home', content: 'Home page' }) }
+ context 'in a group namespace' do
+ let(:project) { create(:project, namespace: create(:group, :public)) }
- before do
- visit(project_wikis_path(project))
- end
+ it 'updates a page' do
+ click_link('Edit')
- context 'in a user namespace' do
- let(:project) { create(:project, namespace: user.namespace) }
+ # Commit message field should have correct value.
+ expect(page).to have_field('wiki[message]', with: 'Update home')
- it 'updates a page' do
- click_link('Edit')
+ fill_in(:wiki_content, with: 'My awesome wiki!')
- # Commit message field should have correct value.
- expect(page).to have_field('wiki[message]', with: 'Update home')
+ click_button('Save changes')
- fill_in(:wiki_content, with: 'My awesome wiki!')
- click_button('Save changes')
+ expect(page).to have_content('Home')
+ expect(page).to have_content("Last edited by #{user.name}")
+ expect(page).to have_content('My awesome wiki!')
+ end
+ end
+ end
+
+ context 'when the page is in a subdir' do
+ let!(:project) { create(:project, namespace: user.namespace) }
+ let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) }
+ let(:page_name) { 'page_name' }
+ let(:page_dir) { "foo/bar/#{page_name}" }
+ let!(:wiki_page) { create(:wiki_page, wiki: project_wiki, attrs: { title: page_dir, content: 'Home page' }) }
- expect(page).to have_content('Home')
- expect(page).to have_content("Last edited by #{user.name}")
- expect(page).to have_content('My awesome wiki!')
+ before do
+ visit(project_wiki_edit_path(project, wiki_page))
end
- it 'shows a validation error message' do
- click_link('Edit')
+ it 'moves the page to the root folder', :skip_gitaly_mock do
+ fill_in(:wiki_title, with: "/#{page_name}")
- fill_in(:wiki_content, with: '')
click_button('Save changes')
- expect(page).to have_selector('.wiki-form')
- expect(page).to have_content('Edit Page')
- expect(page).to have_content('The form contains the following error:')
- expect(page).to have_content("Content can't be blank")
- expect(find('textarea#wiki_content').value).to eq('')
+ expect(current_path).to eq(project_wiki_path(project, page_name))
end
- it 'shows the autocompletion dropdown', :js do
- click_link('Edit')
+ it 'moves the page to other dir' do
+ new_page_dir = "foo1/bar1/#{page_name}"
- find('#wiki_content').native.send_keys('')
- fill_in(:wiki_content, with: '@')
+ fill_in(:wiki_title, with: new_page_dir)
- expect(page).to have_selector('.atwho-view')
+ click_button('Save changes')
+
+ expect(current_path).to eq(project_wiki_path(project, new_page_dir))
end
- it 'shows the error message' do
- click_link('Edit')
+ it 'remains in the same place if title has not changed' do
+ original_path = project_wiki_path(project, wiki_page)
- wiki_page.update(content: 'Update')
+ fill_in(:wiki_title, with: page_name)
click_button('Save changes')
- expect(page).to have_content('Someone edited the page the same time you did.')
+ expect(current_path).to eq(original_path)
end
- it 'updates a page' do
- click_on('Edit')
- fill_in('Content', with: 'Updated Wiki Content')
- click_on('Save changes')
+ it 'can be moved to a different dir with a different name' do
+ new_page_dir = "foo1/bar1/new_page_name"
- expect(page).to have_content('Updated Wiki Content')
+ fill_in(:wiki_title, with: new_page_dir)
+
+ click_button('Save changes')
+
+ expect(current_path).to eq(project_wiki_path(project, new_page_dir))
end
- it 'cancels edititng of a page' do
- click_on('Edit')
+ it 'can be renamed and moved to the root folder' do
+ new_name = 'new_page_name'
- page.within(:css, '.wiki-form .form-actions') do
- click_on('Cancel')
- end
+ fill_in(:wiki_title, with: "/#{new_name}")
- expect(current_path).to eq(project_wiki_path(project, wiki_page))
- end
- end
+ click_button('Save changes')
- context 'in a group namespace' do
- let(:project) { create(:project, namespace: create(:group, :public)) }
+ expect(current_path).to eq(project_wiki_path(project, new_name))
+ end
- it 'updates a page' do
- click_link('Edit')
+ it 'squishes the title before creating the page' do
+ new_page_dir = " foo1 / bar1 / #{page_name} "
- # Commit message field should have correct value.
- expect(page).to have_field('wiki[message]', with: 'Update home')
+ fill_in(:wiki_title, with: new_page_dir)
- fill_in(:wiki_content, with: 'My awesome wiki!')
click_button('Save changes')
- expect(page).to have_content('Home')
- expect(page).to have_content("Last edited by #{user.name}")
- expect(page).to have_content('My awesome wiki!')
+ expect(current_path).to eq(project_wiki_path(project, "foo1/bar1/#{page_name}"))
end
end
end
+
+ context 'when Gitaly is enabled' do
+ it_behaves_like 'wiki page user update'
+ end
+
+ context 'when Gitaly is disabled', :skip_gitaly_mock do
+ it_behaves_like 'wiki page user update'
+ end
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 ebb3bd044c1..2682b62fa04 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
@@ -4,7 +4,7 @@ describe 'Projects > Wiki > User views wiki in project page' do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/projects/wiki/user_views_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_page_spec.rb
index ff325aeadd3..306e382119a 100644
--- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_page_spec.rb
@@ -1,145 +1,155 @@
require 'spec_helper'
describe 'User views a wiki page' do
- let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
- let(:wiki_page) do
- create(:wiki_page,
- wiki: project.wiki,
- attrs: { title: 'home', content: 'Look at this [image](image.jpg)\n\n ![alt text](image.jpg)' })
- end
-
- before do
- project.add_master(user)
- sign_in(user)
- end
+ shared_examples 'wiki page user view' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, namespace: user.namespace) }
+ let(:wiki_page) do
+ create(:wiki_page,
+ wiki: project.wiki,
+ attrs: { title: 'home', content: 'Look at this [image](image.jpg)\n\n ![alt text](image.jpg)' })
+ end
- context 'when wiki is empty' do
before do
- visit(project_wikis_path(project))
+ project.add_master(user)
+ sign_in(user)
+ end
- click_on('New page')
+ context 'when wiki is empty' do
+ before do
+ visit(project_wikis_path(project))
- page.within('#modal-new-wiki') do
- fill_in(:new_wiki_path, with: 'one/two/three-test')
- click_on('Create page')
- end
+ click_on('New page')
- page.within('.wiki-form') do
- fill_in(:wiki_content, with: 'wiki content')
- click_on('Create page')
+ page.within('#modal-new-wiki') do
+ fill_in(:new_wiki_path, with: 'one/two/three-test')
+ click_on('Create page')
+ end
+
+ page.within('.wiki-form') do
+ fill_in(:wiki_content, with: 'wiki content')
+ click_on('Create page')
+ end
end
- end
- it 'shows the history of a page that has a path', :js do
- expect(current_path).to include('one/two/three-test')
+ it 'shows the history of a page that has a path', :js do
+ expect(current_path).to include('one/two/three-test')
- first(:link, text: 'Three').click
- click_on('Page history')
+ first(:link, text: 'Three').click
+ click_on('Page history')
- expect(current_path).to include('one/two/three-test')
+ expect(current_path).to include('one/two/three-test')
- page.within(:css, '.nav-text') do
- expect(page).to have_content('History')
+ page.within(:css, '.nav-text') do
+ expect(page).to have_content('History')
+ end
end
- end
- it 'shows an old version of a page', :js do
- expect(current_path).to include('one/two/three-test')
- expect(find('.wiki-pages')).to have_content('Three')
+ it 'shows an old version of a page', :js do
+ expect(current_path).to include('one/two/three-test')
+ expect(find('.wiki-pages')).to have_content('Three')
- first(:link, text: 'Three').click
+ first(:link, text: 'Three').click
- expect(find('.nav-text')).to have_content('Three')
+ expect(find('.nav-text')).to have_content('Three')
- click_on('Edit')
+ click_on('Edit')
- expect(current_path).to include('one/two/three-test')
- expect(page).to have_content('Edit Page')
+ expect(current_path).to include('one/two/three-test')
+ expect(page).to have_content('Edit Page')
- fill_in('Content', with: 'Updated Wiki Content')
+ fill_in('Content', with: 'Updated Wiki Content')
- click_on('Save changes')
- click_on('Page history')
+ click_on('Save changes')
+ click_on('Page history')
- page.within(:css, '.nav-text') do
- expect(page).to have_content('History')
- end
+ page.within(:css, '.nav-text') do
+ expect(page).to have_content('History')
+ end
- find('a[href*="?version_id"]')
+ find('a[href*="?version_id"]')
+ end
end
- end
- context 'when a page does not have history' do
- before do
- visit(project_wiki_path(project, wiki_page))
- end
+ context 'when a page does not have history' do
+ before do
+ visit(project_wiki_path(project, wiki_page))
+ end
- it 'shows all the pages' do
- expect(page).to have_content(user.name)
- expect(find('.wiki-pages')).to have_content(wiki_page.title.capitalize)
- end
+ it 'shows all the pages' do
+ expect(page).to have_content(user.name)
+ expect(find('.wiki-pages')).to have_content(wiki_page.title.capitalize)
+ end
- it 'shows a file stored in a page' do
- gollum_file_double = double('Gollum::File',
- mime_type: 'image/jpeg',
- name: 'images/image.jpg',
- path: 'images/image.jpg',
- raw_data: '')
- wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double)
+ it 'shows a file stored in a page' do
+ gollum_file_double = double('Gollum::File',
+ mime_type: 'image/jpeg',
+ name: 'images/image.jpg',
+ path: 'images/image.jpg',
+ raw_data: '')
+ wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double)
- allow(wiki_file).to receive(:mime_type).and_return('image/jpeg')
- allow_any_instance_of(ProjectWiki).to receive(:find_file).with('image.jpg', nil).and_return(wiki_file)
+ allow(wiki_file).to receive(:mime_type).and_return('image/jpeg')
+ allow_any_instance_of(ProjectWiki).to receive(:find_file).with('image.jpg', nil).and_return(wiki_file)
- expect(page).to have_xpath('//img[@data-src="image.jpg"]')
- expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg")
+ expect(page).to have_xpath('//img[@data-src="image.jpg"]')
+ expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg")
- click_on('image')
+ click_on('image')
- expect(current_path).to match('wikis/image.jpg')
- expect(page).not_to have_xpath('/html') # Page should render the image which means there is no html involved
- end
+ expect(current_path).to match('wikis/image.jpg')
+ expect(page).not_to have_xpath('/html') # Page should render the image which means there is no html involved
+ end
- it 'shows the creation page if file does not exist' do
- expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg")
+ it 'shows the creation page if file does not exist' do
+ expect(page).to have_link('image', href: "#{project.wiki.wiki_base_path}/image.jpg")
- click_on('image')
+ click_on('image')
- expect(current_path).to match('wikis/image.jpg')
- expect(page).to have_content('New Wiki Page')
- expect(page).to have_content('Create page')
+ expect(current_path).to match('wikis/image.jpg')
+ expect(page).to have_content('New Wiki Page')
+ expect(page).to have_content('Create page')
+ end
end
- end
- context 'when a page has history' do
- before do
- wiki_page.update(message: 'updated home', content: 'updated [some link](other-page)')
- end
+ context 'when a page has history' do
+ before do
+ wiki_page.update(message: 'updated home', content: 'updated [some link](other-page)')
+ end
- it 'shows the page history' do
- visit(project_wiki_path(project, wiki_page))
+ it 'shows the page history' do
+ visit(project_wiki_path(project, wiki_page))
- expect(page).to have_selector('a.btn', text: 'Edit')
+ expect(page).to have_selector('a.btn', text: 'Edit')
- click_on('Page history')
+ click_on('Page history')
- expect(page).to have_content(user.name)
- expect(page).to have_content("#{user.username} created page: home")
- expect(page).to have_content('updated home')
+ expect(page).to have_content(user.name)
+ expect(page).to have_content("#{user.username} created page: home")
+ expect(page).to have_content('updated home')
+ end
+
+ it 'does not show the "Edit" button' do
+ visit(project_wiki_path(project, wiki_page, version_id: wiki_page.versions.last.id))
+
+ expect(page).not_to have_selector('a.btn', text: 'Edit')
+ end
end
- it 'does not show the "Edit" button' do
- visit(project_wiki_path(project, wiki_page, version_id: wiki_page.versions.last.id))
+ it 'opens a default wiki page', :js do
+ visit(project_path(project))
- expect(page).not_to have_selector('a.btn', text: 'Edit')
+ find('.shortcuts-wiki').click
+
+ expect(page).to have_content('Home · Create Page')
end
end
- it 'opens a default wiki page', :js do
- visit(project_path(project))
-
- find('.shortcuts-wiki').click
+ context 'when Gitaly is enabled' do
+ it_behaves_like 'wiki page user view'
+ end
- expect(page).to have_content('Home · Create Page')
+ context 'when Gitaly is disabled', :skip_gitaly_mock do
+ it_behaves_like 'wiki page user view'
end
end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 63e6051b571..cfe979a8647 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -25,6 +25,24 @@ feature 'Project' do
end
end
+ describe 'shows tip about push to create git command' do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in user
+ visit new_project_path
+ end
+
+ it 'shows the command in a popover', :js do
+ page.within '.profile-settings-sidebar' do
+ click_link 'Show command'
+ end
+
+ expect(page).to have_css('.popover .push-to-create-popover #push_to_create_tip')
+ expect(page).to have_content 'Private projects can be created in your personal namespace with:'
+ end
+ end
+
describe 'description' do
let(:project) { create(:project, :repository) }
let(:path) { project_path(project) }
@@ -128,18 +146,18 @@ feature 'Project' do
end
describe 'removal', :js do
- let(:user) { create(:user, username: 'test', name: 'test') }
- let(:project) { create(:project, namespace: user.namespace, name: 'project1') }
+ let(:user) { create(:user) }
+ let(:project) { create(:project, namespace: user.namespace) }
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
visit edit_project_path(project)
end
it 'removes a project' do
- expect { remove_with_confirm('Remove project', project.path) }.to change {Project.count}.by(-1)
- expect(page).to have_content "Project 'test / project1' is in the process of being deleted."
+ expect { remove_with_confirm('Remove project', project.path) }.to change { Project.count }.by(-1)
+ expect(page).to have_content "Project '#{project.full_name}' is in the process of being deleted."
expect(Project.all.count).to be_zero
expect(project.issues).to be_empty
expect(project.merge_requests).to be_empty
@@ -151,7 +169,7 @@ feature 'Project' do
let(:project) { create(:forked_project_with_submodules) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_path(project)
end
@@ -180,7 +198,7 @@ feature 'Project' do
let(:project) { create(:project, :repository) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in user
visit project_path(project)
end
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
index a4084818284..43cabd3b9f2 100644
--- a/spec/features/protected_branches_spec.rb
+++ b/spec/features/protected_branches_spec.rb
@@ -142,7 +142,10 @@ feature 'Protected Branches', :js do
set_protected_branch_name('*-stable')
click_on "Protect"
- within(".protected-branches-list") { expect(page).to have_content("2 matching branches") }
+ within(".protected-branches-list") do
+ expect(page).to have_content("Protected branch (2)")
+ expect(page).to have_content("2 matching branches")
+ end
end
it "displays all the branches matching the wildcard" do
diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb
index 8cc6f17b8d9..efccaeaff6c 100644
--- a/spec/features/protected_tags_spec.rb
+++ b/spec/features/protected_tags_spec.rb
@@ -65,7 +65,10 @@ feature 'Protected Tags', :js do
set_protected_tag_name('*-stable')
click_on "Protect"
- within(".protected-tags-list") { expect(page).to have_content("2 matching tags") }
+ within(".protected-tags-list") do
+ expect(page).to have_content("Protected tag (2)")
+ expect(page).to have_content("2 matching tags")
+ end
end
it "displays all the tags matching the wildcard" do
diff --git a/spec/features/read_only_spec.rb b/spec/features/read_only_spec.rb
new file mode 100644
index 00000000000..8bfaf558466
--- /dev/null
+++ b/spec/features/read_only_spec.rb
@@ -0,0 +1,25 @@
+require 'rails_helper'
+
+describe 'read-only message' do
+ set(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'shows read-only banner when database is read-only' do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+
+ visit root_dashboard_path
+
+ expect(page).to have_content('You are on a read-only GitLab instance.')
+ end
+
+ it 'does not show read-only banner when database is able to read-write' do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(false)
+
+ visit root_dashboard_path
+
+ expect(page).not_to have_content('You are on a read-only GitLab instance.')
+ end
+end
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index c7f0e342809..df65c2d2f83 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -7,6 +7,20 @@ feature 'Runners' do
sign_in(user)
end
+ context 'when user opens runners page' do
+ given(:project) { create(:project) }
+
+ background do
+ project.add_master(user)
+ end
+
+ scenario 'user can see a button to install runners on kubernetes clusters' do
+ visit runners_path(project)
+
+ expect(page).to have_link('Install Runner on Kubernetes', href: project_clusters_path(project))
+ end
+ end
+
context 'when a project has enabled shared_runners' do
given(:project) { create(:project) }
@@ -33,6 +47,26 @@ feature 'Runners' do
expect(page).to have_content(specific_runner.platform)
end
+ scenario 'user can pause and resume the specific runner' do
+ visit runners_path(project)
+
+ within '.activated-specific-runners' do
+ expect(page).to have_content('Pause')
+ end
+
+ click_on 'Pause'
+
+ within '.activated-specific-runners' do
+ expect(page).to have_content('Resume')
+ end
+
+ click_on 'Resume'
+
+ within '.activated-specific-runners' do
+ expect(page).to have_content('Pause')
+ end
+ end
+
scenario 'user removes an activated specific runner if this is last project for that runners' do
visit runners_path(project)
diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb
index 77212fb105b..9e089c5a6cb 100644
--- a/spec/features/search/user_searches_for_code_spec.rb
+++ b/spec/features/search/user_searches_for_code_spec.rb
@@ -35,7 +35,7 @@ describe 'User searches for code' do
find('.js-search-project-dropdown').click
page.within('.project-filter') do
- click_link(project.name_with_namespace)
+ click_link(project.full_name)
end
fill_in('dashboard_search', with: 'rspec')
diff --git a/spec/features/search/user_searches_for_issues_spec.rb b/spec/features/search/user_searches_for_issues_spec.rb
index ef9553f2a91..d6120ff8517 100644
--- a/spec/features/search/user_searches_for_issues_spec.rb
+++ b/spec/features/search/user_searches_for_issues_spec.rb
@@ -34,7 +34,7 @@ describe 'User searches for issues', :js do
find('.js-search-project-dropdown').click
page.within('.project-filter') do
- click_link(project.name_with_namespace)
+ click_link(project.full_name)
end
fill_in('dashboard_search', with: issue1.title)
diff --git a/spec/features/search/user_searches_for_merge_requests_spec.rb b/spec/features/search/user_searches_for_merge_requests_spec.rb
index 3b6739aecbd..68e2f7a857d 100644
--- a/spec/features/search/user_searches_for_merge_requests_spec.rb
+++ b/spec/features/search/user_searches_for_merge_requests_spec.rb
@@ -33,7 +33,7 @@ describe 'User searches for merge requests', :js do
find('.js-search-project-dropdown').click
page.within('.project-filter') do
- click_link(project.name_with_namespace)
+ click_link(project.full_name)
end
fill_in('dashboard_search', with: merge_request1.title)
diff --git a/spec/features/search/user_searches_for_milestones_spec.rb b/spec/features/search/user_searches_for_milestones_spec.rb
index 6e197aee498..fc6cd81eb68 100644
--- a/spec/features/search/user_searches_for_milestones_spec.rb
+++ b/spec/features/search/user_searches_for_milestones_spec.rb
@@ -33,7 +33,7 @@ describe 'User searches for milestones', :js do
find('.js-search-project-dropdown').click
page.within('.project-filter') do
- click_link(project.name_with_namespace)
+ click_link(project.full_name)
end
fill_in('dashboard_search', with: milestone1.title)
diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb
index 00af625dc86..7934779058f 100644
--- a/spec/features/search/user_searches_for_wiki_pages_spec.rb
+++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb
@@ -18,7 +18,7 @@ describe 'User searches for wiki pages', :js do
find('.js-search-project-dropdown').click
page.within('.project-filter') do
- click_link(project.name_with_namespace)
+ click_link(project.full_name)
end
fill_in('dashboard_search', with: 'content')
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index 5ddea36add5..a9128104b87 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -9,49 +9,25 @@ describe 'User uses header search field' do
before do
project.add_reporter(user)
sign_in(user)
-
- visit(project_path(project))
- end
-
- it 'starts searching by pressing the enter key', :js do
- fill_in('search', with: 'gitlab')
- find('#search').native.send_keys(:enter)
-
- page.within('.breadcrumbs-sub-title') do
- expect(page).to have_content('Search')
- end
end
- it 'contains location badge' do
- expect(page).to have_selector('.has-location-badge')
- end
-
- context 'when clicking the search field', :js do
+ context 'when user is in a global scope', :js do
before do
+ visit(root_path)
page.find('#search').click
end
- it 'shows category search dropdown' do
- expect(page).to have_selector('.dropdown-header', text: /#{project.name}/i)
- end
-
context 'when clicking issues' do
- let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
-
it 'shows assigned issues' do
- find('.dropdown-menu').click_link('Issues assigned to me')
+ find('.search-input-container .dropdown-menu').click_link('Issues assigned to me')
- expect(page).to have_selector('.filtered-search')
- expect_tokens([assignee_token(user.name)])
- expect_filtered_search_input_empty
+ expect(find('.js-assignee-search')).to have_content(user.name)
end
it 'shows created issues' do
- find('.dropdown-menu').click_link("Issues I've created")
+ find('.search-input-container .dropdown-menu').click_link("Issues I've created")
- expect(page).to have_selector('.filtered-search')
- expect_tokens([author_token(user.name)])
- expect_filtered_search_input_empty
+ expect(find('.js-author-search')).to have_content(user.name)
end
end
@@ -59,32 +35,97 @@ describe 'User uses header search field' do
let!(:merge_request) { create(:merge_request, source_project: project, author: user, assignee: user) }
it 'shows assigned merge requests' do
- find('.dropdown-menu').click_link('Merge requests assigned to me')
+ find('.search-input-container .dropdown-menu').click_link('Merge requests assigned to me')
- expect(page).to have_selector('.merge-requests-holder')
- expect_tokens([assignee_token(user.name)])
- expect_filtered_search_input_empty
+ expect(find('.js-assignee-search')).to have_content(user.name)
end
it 'shows created merge requests' do
- find('.dropdown-menu').click_link("Merge requests I've created")
+ find('.search-input-container .dropdown-menu').click_link("Merge requests I've created")
- expect(page).to have_selector('.merge-requests-holder')
- expect_tokens([author_token(user.name)])
- expect_filtered_search_input_empty
+ expect(find('.js-author-search')).to have_content(user.name)
end
end
end
- context 'when entering text into the search field', :js do
+ context 'when user is in a project scope' do
before do
- page.within('.search-input-wrap') do
- fill_in('search', with: project.name[0..3])
+ visit(project_path(project))
+ end
+
+ it 'starts searching by pressing the enter key', :js do
+ fill_in('search', with: 'gitlab')
+ find('#search').native.send_keys(:enter)
+
+ page.within('.breadcrumbs-sub-title') do
+ expect(page).to have_content('Search')
end
end
- it 'does not display the category search dropdown' do
- expect(page).not_to have_selector('.dropdown-header', text: /#{project.name}/i)
+ it 'contains location badge' do
+ expect(page).to have_selector('.has-location-badge')
+ end
+
+ context 'when clicking the search field', :js do
+ before do
+ page.find('#search').click
+ end
+
+ it 'shows category search dropdown' do
+ expect(page).to have_selector('.dropdown-header', text: /#{project.name}/i)
+ end
+
+ context 'when clicking issues' do
+ let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
+
+ it 'shows assigned issues' do
+ find('.dropdown-menu').click_link('Issues assigned to me')
+
+ expect(page).to have_selector('.filtered-search')
+ expect_tokens([assignee_token(user.name)])
+ expect_filtered_search_input_empty
+ end
+
+ it 'shows created issues' do
+ find('.dropdown-menu').click_link("Issues I've created")
+
+ expect(page).to have_selector('.filtered-search')
+ expect_tokens([author_token(user.name)])
+ expect_filtered_search_input_empty
+ end
+ end
+
+ context 'when clicking merge requests' do
+ let!(:merge_request) { create(:merge_request, source_project: project, author: user, assignee: user) }
+
+ it 'shows assigned merge requests' do
+ find('.dropdown-menu').click_link('Merge requests assigned to me')
+
+ expect(page).to have_selector('.merge-requests-holder')
+ expect_tokens([assignee_token(user.name)])
+ expect_filtered_search_input_empty
+ end
+
+ it 'shows created merge requests' do
+ find('.dropdown-menu').click_link("Merge requests I've created")
+
+ expect(page).to have_selector('.merge-requests-holder')
+ expect_tokens([author_token(user.name)])
+ expect_filtered_search_input_empty
+ end
+ end
+ end
+
+ context 'when entering text into the search field', :js do
+ before do
+ page.within('.search-input-wrap') do
+ fill_in('search', with: project.name[0..3])
+ end
+ end
+
+ it 'does not display the category search dropdown' do
+ expect(page).not_to have_selector('.dropdown-header', text: /#{project.name}/i)
+ end
end
end
end
diff --git a/spec/features/search/user_uses_search_filters_spec.rb b/spec/features/search/user_uses_search_filters_spec.rb
index aa883c964d2..66afe163447 100644
--- a/spec/features/search/user_uses_search_filters_spec.rb
+++ b/spec/features/search/user_uses_search_filters_spec.rb
@@ -31,7 +31,7 @@ describe 'User uses search filters', :js do
wait_for_requests
- expect(page).to have_link(group_project.name_with_namespace)
+ expect(page).to have_link(group_project.full_name)
end
end
end
@@ -43,10 +43,10 @@ describe 'User uses search filters', :js do
wait_for_requests
- click_link(project.name_with_namespace)
+ click_link(project.full_name)
end
- expect(find('.js-search-project-dropdown')).to have_content(project.name_with_namespace)
+ expect(find('.js-search-project-dropdown')).to have_content(project.full_name)
end
end
end
diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb
index 8efa5b58141..6088b831c14 100644
--- a/spec/features/signed_commits_spec.rb
+++ b/spec/features/signed_commits_spec.rb
@@ -5,7 +5,7 @@ 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]
+ project.add_master(user)
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
@@ -36,7 +36,7 @@ describe 'GPG signed commits', :js do
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]
+ project.add_master(user)
sign_in(user)
@@ -86,7 +86,7 @@ describe 'GPG signed commits', :js do
before do
user = create :user
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb
deleted file mode 100644
index 917fad74ef1..00000000000
--- a/spec/features/signup_spec.rb
+++ /dev/null
@@ -1,103 +0,0 @@
-require 'spec_helper'
-
-feature 'Signup' do
- describe 'signup with no errors' do
- context "when sending confirmation email" do
- 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)
-
- visit root_path
-
- fill_in 'new_user_name', with: user.name
- fill_in 'new_user_username', with: user.username
- fill_in 'new_user_email', with: user.email
- fill_in 'new_user_email_confirmation', with: user.email
- fill_in 'new_user_password', with: user.password
- click_button "Register"
-
- expect(current_path).to eq users_almost_there_path
- expect(page).to have_content("Please check your email to confirm your account")
- end
- end
-
- context "when sigining up with different cased emails" do
- it "creates the user successfully" do
- user = build(:user)
-
- visit root_path
-
- fill_in 'new_user_name', with: user.name
- fill_in 'new_user_username', with: user.username
- fill_in 'new_user_email', with: user.email
- fill_in 'new_user_email_confirmation', with: user.email.capitalize
- fill_in 'new_user_password', with: user.password
- click_button "Register"
-
- expect(current_path).to eq dashboard_projects_path
- expect(page).to have_content("Welcome! You have signed up successfully.")
- end
- end
-
- context "when not sending confirmation email" do
- before do
- stub_application_setting(send_user_confirmation_email: false)
- end
-
- it 'creates the user account and goes to dashboard' do
- user = build(:user)
-
- visit root_path
-
- fill_in 'new_user_name', with: user.name
- fill_in 'new_user_username', with: user.username
- fill_in 'new_user_email', with: user.email
- fill_in 'new_user_email_confirmation', with: user.email
- fill_in 'new_user_password', with: user.password
- click_button "Register"
-
- expect(current_path).to eq dashboard_projects_path
- expect(page).to have_content("Welcome! You have signed up successfully.")
- end
- end
- end
-
- describe 'signup with errors' do
- it "displays the errors" do
- existing_user = create(:user)
- user = build(:user)
-
- visit root_path
-
- fill_in 'new_user_name', with: user.name
- fill_in 'new_user_username', with: user.username
- fill_in 'new_user_email', with: existing_user.email
- fill_in 'new_user_password', with: user.password
- click_button "Register"
-
- expect(current_path).to eq user_registration_path
- expect(page).to have_content("errors prohibited this user from being saved")
- expect(page).to have_content("Email has already been taken")
- expect(page).to have_content("Email confirmation doesn't match")
- end
-
- it 'does not redisplay the password' do
- existing_user = create(:user)
- user = build(:user)
-
- visit root_path
-
- fill_in 'new_user_name', with: user.name
- fill_in 'new_user_username', with: user.username
- fill_in 'new_user_email', with: existing_user.email
- fill_in 'new_user_password', with: user.password
- click_button "Register"
-
- expect(current_path).to eq user_registration_path
- expect(page.body).not_to match(/#{user.password}/)
- end
- end
-end
diff --git a/spec/features/tags/master_creates_tag_spec.rb b/spec/features/tags/master_creates_tag_spec.rb
index 1f8bd8d681e..8a8f6933fa5 100644
--- a/spec/features/tags/master_creates_tag_spec.rb
+++ b/spec/features/tags/master_creates_tag_spec.rb
@@ -5,7 +5,7 @@ feature 'Master creates tag' do
let(:project) { create(:project, :repository, namespace: user.namespace) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
end
diff --git a/spec/features/tags/master_deletes_tag_spec.rb b/spec/features/tags/master_deletes_tag_spec.rb
index dfda664d673..c0b4fa52526 100644
--- a/spec/features/tags/master_deletes_tag_spec.rb
+++ b/spec/features/tags/master_deletes_tag_spec.rb
@@ -5,7 +5,7 @@ feature 'Master deletes tag' do
let(:project) { create(:project, :repository, namespace: user.namespace) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_tags_path(project)
end
diff --git a/spec/features/tags/master_updates_tag_spec.rb b/spec/features/tags/master_updates_tag_spec.rb
index b93ad44dfd3..1c370a99b13 100644
--- a/spec/features/tags/master_updates_tag_spec.rb
+++ b/spec/features/tags/master_updates_tag_spec.rb
@@ -5,7 +5,7 @@ feature 'Master updates tag' do
let(:project) { create(:project, :repository, namespace: user.namespace) }
before do
- project.team << [user, :master]
+ project.add_master(user)
sign_in(user)
visit project_tags_path(project)
end
diff --git a/spec/features/tags/master_views_tags_spec.rb b/spec/features/tags/master_views_tags_spec.rb
index 4662367d843..b625e7065cc 100644
--- a/spec/features/tags/master_views_tags_spec.rb
+++ b/spec/features/tags/master_views_tags_spec.rb
@@ -13,7 +13,7 @@ feature 'Master views tags' do
before do
visit project_path(project)
- click_on 'README'
+ click_on 'Add Readme'
fill_in :commit_message, with: 'Add a README file', visible: true
click_button 'Commit changes'
visit project_tags_path(project)
diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb
index bc472e74997..19784120108 100644
--- a/spec/features/triggers_spec.rb
+++ b/spec/features/triggers_spec.rb
@@ -10,9 +10,9 @@ feature 'Triggers', :js do
sign_in(user)
@project = create(:project)
- @project.team << [user, :master]
- @project.team << [user2, :master]
- @project.team << [guest_user, :guest]
+ @project.add_master(user)
+ @project.add_master(user2)
+ @project.add_guest(guest_user)
visit project_settings_ci_cd_path(@project)
end
diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb
index c9afef2a8de..fb65b570dd6 100644
--- a/spec/features/u2f_spec.rb
+++ b/spec/features/u2f_spec.rb
@@ -1,10 +1,6 @@
require 'spec_helper'
feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
- 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")
@@ -264,7 +260,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
end
it "deletes u2f registrations" do
- visit profile_account_path
+ visit profile_two_factor_auth_path
expect do
accept_confirm { click_on "Disable" }
end.to change { U2fRegistration.count }.by(-1)
diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb
index 670e8dda916..e069c2fddd1 100644
--- a/spec/features/user_can_display_performance_bar_spec.rb
+++ b/spec/features/user_can_display_performance_bar_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
describe 'User can display performance bar', :js do
- shared_examples 'performance bar is disabled' do
+ shared_examples 'performance bar cannot be displayed' do
it 'does not show the performance bar by default' do
- expect(page).not_to have_css('#peek')
+ expect(page).not_to have_css('#js-peek')
end
context 'when user press `pb`' do
@@ -12,14 +12,14 @@ describe 'User can display performance bar', :js do
end
it 'does not show the performance bar by default' do
- expect(page).not_to have_css('#peek')
+ expect(page).not_to have_css('#js-peek')
end
end
end
- shared_examples 'performance bar is enabled' do
+ shared_examples 'performance bar can be displayed' do
it 'does not show the performance bar by default' do
- expect(page).not_to have_css('#peek')
+ expect(page).not_to have_css('#js-peek')
end
context 'when user press `pb`' do
@@ -28,11 +28,23 @@ describe 'User can display performance bar', :js do
end
it 'shows the performance bar' do
- expect(page).to have_css('#peek')
+ expect(page).to have_css('#js-peek')
end
end
end
+ shared_examples 'performance bar is enabled by default in development' do
+ before do
+ allow(Rails.env).to receive(:development?).and_return(true)
+ end
+
+ it 'shows the performance bar by default' do
+ refresh # Because we're stubbing Rails.env after the 1st visit to root_path
+
+ expect(page).to have_css('#js-peek')
+ end
+ end
+
let(:group) { create(:group) }
context 'when user is logged-out' do
@@ -45,7 +57,7 @@ describe 'User can display performance bar', :js do
stub_application_setting(performance_bar_allowed_group_id: nil)
end
- it_behaves_like 'performance bar is disabled'
+ it_behaves_like 'performance bar cannot be displayed'
end
context 'when the performance_bar feature is enabled' do
@@ -53,7 +65,7 @@ describe 'User can display performance bar', :js do
stub_application_setting(performance_bar_allowed_group_id: group.id)
end
- it_behaves_like 'performance bar is disabled'
+ it_behaves_like 'performance bar cannot be displayed'
end
end
@@ -72,7 +84,8 @@ describe 'User can display performance bar', :js do
stub_application_setting(performance_bar_allowed_group_id: nil)
end
- it_behaves_like 'performance bar is disabled'
+ it_behaves_like 'performance bar cannot be displayed'
+ it_behaves_like 'performance bar is enabled by default in development'
end
context 'when the performance_bar feature is enabled' do
@@ -80,7 +93,8 @@ describe 'User can display performance bar', :js do
stub_application_setting(performance_bar_allowed_group_id: group.id)
end
- it_behaves_like 'performance bar is enabled'
+ it_behaves_like 'performance bar is enabled by default in development'
+ it_behaves_like 'performance bar can be displayed'
end
end
end
diff --git a/spec/features/user_sorts_things_spec.rb b/spec/features/user_sorts_things_spec.rb
new file mode 100644
index 00000000000..69ebdddaeec
--- /dev/null
+++ b/spec/features/user_sorts_things_spec.rb
@@ -0,0 +1,57 @@
+require "spec_helper"
+
+# The main goal of this spec is not to check whether the sorting UI works, but
+# to check if the sorting option set by user is being kept persisted while going through pages.
+# The `it`s are named here by convention `starting point -> some pages -> final point`.
+# All those specs are moved out to this spec intentionally to keep them all in one place.
+describe "User sorts things" do
+ include Spec::Support::Helpers::Features::SortingHelpers
+ include Helpers::DashboardHelper
+
+ set(:project) { create(:project_empty_repo, :public) }
+ set(:current_user) { create(:user) } # Using `current_user` instead of just `user` because of the hardoced call in `assigned_mrs_dashboard_path` which is used below.
+ set(:issue) { create(:issue, project: project, author: current_user) }
+ set(:merge_request) { create(:merge_request, target_project: project, source_project: project, author: current_user) }
+
+ before do
+ project.add_developer(current_user)
+ sign_in(current_user)
+ end
+
+ it "issues -> project home page -> issues" do
+ sort_option = "Last updated"
+
+ visit(project_issues_path(project))
+
+ sort_by(sort_option)
+
+ visit(project_path(project))
+ visit(project_issues_path(project))
+
+ expect(find(".issues-filters")).to have_content(sort_option)
+ end
+
+ it "issues -> merge requests" do
+ sort_option = "Last updated"
+
+ visit(project_issues_path(project))
+
+ sort_by(sort_option)
+
+ visit(project_merge_requests_path(project))
+
+ expect(find(".issues-filters")).to have_content(sort_option)
+ end
+
+ it "merge requests -> dashboard merge requests" do
+ sort_option = "Last updated"
+
+ visit(project_merge_requests_path(project))
+
+ sort_by(sort_option)
+
+ visit(assigned_mrs_dashboard_path)
+
+ expect(find(".issues-filters")).to have_content(sort_option)
+ end
+end
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
new file mode 100644
index 00000000000..bc75dc5d19b
--- /dev/null
+++ b/spec/features/users/login_spec.rb
@@ -0,0 +1,402 @@
+require 'spec_helper'
+
+feature 'Login' do
+ scenario 'Successful user signin invalidates password reset token' do
+ user = create(:user)
+
+ expect(user.reset_password_token).to be_nil
+
+ visit new_user_password_path
+ fill_in 'user_email', with: user.email
+ click_button 'Reset password'
+
+ user.reload
+ expect(user.reset_password_token).not_to be_nil
+
+ find('a[href="#login-pane"]').click
+ gitlab_sign_in(user)
+ expect(current_path).to eq root_path
+
+ user.reload
+ expect(user.reset_password_token).to be_nil
+ end
+
+ 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
+ User.delete_all
+
+ user = create(:admin, password_automatically_set: true)
+
+ visit root_path
+ expect(current_path).to eq edit_user_password_path
+ expect(page).to have_content('Please create a password for your new account.')
+
+ fill_in 'user_password', with: 'password'
+ fill_in 'user_password_confirmation', with: 'password'
+ click_button 'Change your password'
+
+ expect(current_path).to eq new_user_session_path
+ expect(page).to have_content(I18n.t('devise.passwords.updated_not_active'))
+
+ fill_in 'user_login', with: user.username
+ fill_in 'user_password', with: 'password'
+ click_button 'Sign in'
+
+ expect(current_path).to eq root_path
+ end
+
+ it 'does not show flash messages when login page' do
+ visit root_path
+ expect(page).not_to have_content('You need to sign in or sign up before continuing.')
+ end
+ end
+
+ describe 'with a blocked account' do
+ it 'prevents the user from logging in' do
+ user = create(:user, :blocked)
+
+ gitlab_sign_in(user)
+
+ expect(page).to have_content('Your account has been blocked.')
+ end
+
+ it 'does not update Devise trackable attributes', :clean_gitlab_redis_shared_state do
+ user = create(:user, :blocked)
+
+ 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
+ gitlab_sign_in(User.ghost)
+
+ expect(page).to have_content('Invalid Login or password.')
+ end
+
+ 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
+
+ describe 'with two-factor authentication' do
+ def enter_code(code)
+ fill_in 'user_otp_attempt', with: code
+ click_button 'Verify code'
+ end
+
+ context 'with valid username/password' do
+ let(:user) { create(:user, :two_factor) }
+
+ before do
+ gitlab_sign_in(user, remember: true)
+ expect(page).to have_content('Two-Factor Authentication')
+ end
+
+ it 'does not show a "You are already signed in." error message' do
+ enter_code(user.current_otp)
+ expect(page).not_to have_content('You are already signed in.')
+ end
+
+ context 'using one-time code' do
+ it 'allows login with valid code' do
+ enter_code(user.current_otp)
+ expect(current_path).to eq root_path
+ end
+
+ it 'persists remember_me value via hidden field' do
+ field = first('input#user_remember_me', visible: false)
+
+ expect(field.value).to eq '1'
+ end
+
+ it 'blocks login with invalid code' do
+ enter_code('foo')
+ expect(page).to have_content('Invalid two-factor code')
+ end
+
+ it 'allows login with invalid code, then valid code' do
+ enter_code('foo')
+ expect(page).to have_content('Invalid two-factor code')
+
+ enter_code(user.current_otp)
+ expect(current_path).to eq root_path
+ end
+ end
+
+ context 'using backup code' do
+ let(:codes) { user.generate_otp_backup_codes! }
+
+ before do
+ expect(codes.size).to eq 10
+
+ # Ensure the generated codes get saved
+ user.save
+ end
+
+ context 'with valid code' do
+ it 'allows login' do
+ enter_code(codes.sample)
+ expect(current_path).to eq root_path
+ end
+
+ it 'invalidates the used code' do
+ expect { enter_code(codes.sample) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
+ end
+
+ it 'invalidates backup codes twice in a row' do
+ random_code = codes.delete(codes.sample)
+ expect { enter_code(random_code) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
+
+ gitlab_sign_out
+ gitlab_sign_in(user)
+
+ expect { enter_code(codes.sample) }
+ .to change { user.reload.otp_backup_codes.size }.by(-1)
+ end
+ end
+
+ context 'with invalid code' do
+ it 'blocks login' do
+ code = codes.sample
+ expect(user.invalidate_otp_backup_code!(code)).to eq true
+
+ user.save!
+ expect(user.reload.otp_backup_codes.size).to eq 9
+
+ enter_code(code)
+ expect(page).to have_content('Invalid two-factor code.')
+ end
+ end
+ end
+ end
+
+ context 'logging in via OAuth' do
+ it 'shows 2FA prompt after OAuth login' do
+ 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')
+ gitlab_sign_in_via('saml', user, 'my-uid')
+
+ expect(page).to have_content('Two-Factor Authentication')
+ enter_code(user.current_otp)
+ expect(current_path).to eq root_path
+ end
+ end
+ end
+
+ describe 'without two-factor authentication' do
+ let(:user) { create(:user) }
+
+ it 'allows basic login' do
+ 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
+ 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')
+
+ gitlab_sign_in(user)
+ expect(page).to have_content('Invalid Login or password.')
+ end
+ end
+
+ describe 'with required two-factor authentication enabled' do
+ let(:user) { create(:user) }
+ # TODO: otp_grace_period_started_at
+
+ context 'global setting' do
+ before do
+ stub_application_setting(require_two_factor_authentication: true)
+ end
+
+ context 'with grace period defined' do
+ before do
+ stub_application_setting(two_factor_grace_period: 48)
+ gitlab_sign_in(user)
+ end
+
+ context 'within the grace period' do
+ it 'redirects to two-factor configuration page' do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content('The global settings require you to enable Two-Factor Authentication for your account. You need to do this before ')
+ end
+
+ it 'allows skipping two-factor configuration', :js do
+ expect(current_path).to eq profile_two_factor_auth_path
+
+ click_link 'Configure it later'
+ expect(current_path).to eq root_path
+ end
+ end
+
+ context 'after the grace period' do
+ let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) }
+
+ it 'redirects to two-factor configuration page' do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content(
+ 'The global settings require you to enable Two-Factor Authentication for your account.'
+ )
+ end
+
+ it 'disallows skipping two-factor configuration', :js do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).not_to have_link('Configure it later')
+ end
+ end
+ end
+
+ context 'without grace period defined' do
+ before do
+ stub_application_setting(two_factor_grace_period: 0)
+ gitlab_sign_in(user)
+ end
+
+ it 'redirects to two-factor configuration page' do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content(
+ 'The global settings require you to enable Two-Factor Authentication for your account.'
+ )
+ end
+ end
+ end
+
+ context 'group setting' do
+ before do
+ group1 = create :group, name: 'Group 1', require_two_factor_authentication: true
+ group1.add_user(user, GroupMember::DEVELOPER)
+ group2 = create :group, name: 'Group 2', require_two_factor_authentication: true
+ group2.add_user(user, GroupMember::DEVELOPER)
+ end
+
+ context 'with grace period defined' do
+ before do
+ stub_application_setting(two_factor_grace_period: 48)
+ gitlab_sign_in(user)
+ end
+
+ context 'within the grace period' do
+ it 'redirects to two-factor configuration page' do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content(
+ 'The group settings for Group 1 and Group 2 require you to enable ' \
+ 'Two-Factor Authentication for your account. You need to do this ' \
+ 'before ')
+ end
+
+ it 'allows skipping two-factor configuration', :js do
+ expect(current_path).to eq profile_two_factor_auth_path
+
+ click_link 'Configure it later'
+ expect(current_path).to eq root_path
+ end
+ end
+
+ context 'after the grace period' do
+ let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) }
+
+ it 'redirects to two-factor configuration page' do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content(
+ 'The group settings for Group 1 and Group 2 require you to enable ' \
+ 'Two-Factor Authentication for your account.'
+ )
+ end
+
+ it 'disallows skipping two-factor configuration', :js do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).not_to have_link('Configure it later')
+ end
+ end
+ end
+
+ context 'without grace period defined' do
+ before do
+ stub_application_setting(two_factor_grace_period: 0)
+ gitlab_sign_in(user)
+ end
+
+ it 'redirects to two-factor configuration page' do
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content(
+ 'The group settings for Group 1 and Group 2 require you to enable ' \
+ 'Two-Factor Authentication for your account.'
+ )
+ end
+ end
+ end
+ end
+
+ describe 'UI tabs and panes' do
+ context 'when no defaults are changed' do
+ it 'correctly renders tabs and panes' do
+ ensure_tab_pane_correctness
+ end
+ end
+
+ context 'when signup is disabled' do
+ before do
+ stub_application_setting(signup_enabled: false)
+ end
+
+ it 'correctly renders tabs and panes' do
+ ensure_tab_pane_correctness
+ end
+ end
+
+ context 'when ldap is enabled' do
+ before do
+ visit new_user_session_path
+ allow(page).to receive(:form_based_providers).and_return([:ldapmain])
+ allow(page).to receive(:ldap_enabled).and_return(true)
+ end
+
+ it 'correctly renders tabs and panes' do
+ ensure_tab_pane_correctness(false)
+ end
+ end
+
+ context 'when crowd is enabled' do
+ before do
+ visit new_user_session_path
+ allow(page).to receive(:form_based_providers).and_return([:crowd])
+ allow(page).to receive(:crowd_enabled?).and_return(true)
+ end
+
+ it 'correctly renders tabs and panes' do
+ ensure_tab_pane_correctness(false)
+ end
+ end
+
+ def ensure_tab_pane_correctness(visit_path = true)
+ if visit_path
+ visit new_user_session_path
+ end
+
+ ensure_tab_pane_counts
+ ensure_one_active_tab
+ ensure_one_active_pane
+ end
+
+ def ensure_tab_pane_counts
+ tabs_count = page.all('[role="tab"]').size
+ expect(page).to have_selector('[role="tabpanel"]', count: tabs_count)
+ end
+
+ def ensure_one_active_tab
+ expect(page).to have_selector('.nav-tabs > li.active', count: 1)
+ end
+
+ def ensure_one_active_pane
+ expect(page).to have_selector('.tab-pane.active', count: 1)
+ end
+ end
+end
diff --git a/spec/features/logout_spec.rb b/spec/features/users/logout_spec.rb
index 635729efa53..635729efa53 100644
--- a/spec/features/logout_spec.rb
+++ b/spec/features/users/logout_spec.rb
diff --git a/spec/features/users/projects_spec.rb b/spec/features/users/projects_spec.rb
deleted file mode 100644
index f079771cee1..00000000000
--- a/spec/features/users/projects_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-require 'spec_helper'
-
-describe 'Projects tab on a user profile', :js do
- let(:user) { create(:user) }
- 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)
-
- sign_in(user)
-
- visit user_path(user)
-
- page.within('.user-profile-nav') do
- click_link('Personal projects')
- end
-
- wait_for_requests
- end
-
- it 'paginates results' do
- expect(page).to have_content(project2.name)
-
- click_link('Next')
-
- expect(page).to have_content(project.name)
- end
-end
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
new file mode 100644
index 00000000000..b5bbb2c0ea5
--- /dev/null
+++ b/spec/features/users/show_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe 'User page' do
+ let(:user) { create(:user) }
+
+ it 'shows all the tabs' do
+ visit(user_path(user))
+
+ page.within '.nav-links' do
+ expect(page).to have_link('Activity')
+ expect(page).to have_link('Groups')
+ expect(page).to have_link('Contributed projects')
+ expect(page).to have_link('Personal projects')
+ expect(page).to have_link('Snippets')
+ end
+ end
+end
diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb
new file mode 100644
index 00000000000..5d539f0ccbe
--- /dev/null
+++ b/spec/features/users/signup_spec.rb
@@ -0,0 +1,135 @@
+require 'spec_helper'
+
+describe 'Signup' do
+ let(:new_user) { build_stubbed(:user) }
+
+ describe 'username validation', :js do
+ before do
+ visit root_path
+ click_link 'Register'
+ end
+
+ it 'does not show an error border if the username is available' do
+ fill_in 'new_user_username', with: 'new-user'
+ wait_for_requests
+
+ expect(find('.username')).not_to have_css '.gl-field-error-outline'
+ end
+
+ it 'does not show an error border if the username contains dots (.)' do
+ fill_in 'new_user_username', with: 'new.user.username'
+ wait_for_requests
+
+ expect(find('.username')).not_to have_css '.gl-field-error-outline'
+ end
+
+ it 'shows an error border if the username already exists' do
+ existing_user = create(:user)
+
+ fill_in 'new_user_username', with: existing_user.username
+ wait_for_requests
+
+ expect(find('.username')).to have_css '.gl-field-error-outline'
+ end
+
+ it 'shows an error border if the username contains special characters' do
+ fill_in 'new_user_username', with: 'new$user!username'
+ wait_for_requests
+
+ expect(find('.username')).to have_css '.gl-field-error-outline'
+ end
+ end
+
+ context 'with no errors' do
+ context "when sending confirmation email" do
+ before do
+ stub_application_setting(send_user_confirmation_email: true)
+ end
+
+ it 'creates the user account and sends a confirmation email' do
+ visit root_path
+
+ fill_in 'new_user_name', with: new_user.name
+ fill_in 'new_user_username', with: new_user.username
+ fill_in 'new_user_email', with: new_user.email
+ fill_in 'new_user_email_confirmation', with: new_user.email
+ fill_in 'new_user_password', with: new_user.password
+
+ expect { click_button 'Register' }.to change { User.count }.by(1)
+
+ expect(current_path).to eq users_almost_there_path
+ expect(page).to have_content("Please check your email to confirm your account")
+ end
+ end
+
+ context "when sigining up with different cased emails" do
+ it "creates the user successfully" do
+ visit root_path
+
+ fill_in 'new_user_name', with: new_user.name
+ fill_in 'new_user_username', with: new_user.username
+ fill_in 'new_user_email', with: new_user.email
+ fill_in 'new_user_email_confirmation', with: new_user.email.capitalize
+ fill_in 'new_user_password', with: new_user.password
+ click_button "Register"
+
+ expect(current_path).to eq dashboard_projects_path
+ expect(page).to have_content("Welcome! You have signed up successfully.")
+ end
+ end
+
+ context "when not sending confirmation email" do
+ before do
+ stub_application_setting(send_user_confirmation_email: false)
+ end
+
+ it 'creates the user account and goes to dashboard' do
+ visit root_path
+
+ fill_in 'new_user_name', with: new_user.name
+ fill_in 'new_user_username', with: new_user.username
+ fill_in 'new_user_email', with: new_user.email
+ fill_in 'new_user_email_confirmation', with: new_user.email
+ fill_in 'new_user_password', with: new_user.password
+ click_button "Register"
+
+ expect(current_path).to eq dashboard_projects_path
+ expect(page).to have_content("Welcome! You have signed up successfully.")
+ end
+ end
+ end
+
+ context 'with errors' do
+ it "displays the errors" do
+ existing_user = create(:user)
+
+ visit root_path
+
+ fill_in 'new_user_name', with: new_user.name
+ fill_in 'new_user_username', with: new_user.username
+ fill_in 'new_user_email', with: existing_user.email
+ fill_in 'new_user_password', with: new_user.password
+ click_button "Register"
+
+ expect(current_path).to eq user_registration_path
+ expect(page).to have_content("errors prohibited this user from being saved")
+ expect(page).to have_content("Email has already been taken")
+ expect(page).to have_content("Email confirmation doesn't match")
+ end
+
+ it 'does not redisplay the password' do
+ existing_user = create(:user)
+
+ visit root_path
+
+ fill_in 'new_user_name', with: new_user.name
+ fill_in 'new_user_username', with: new_user.username
+ fill_in 'new_user_email', with: existing_user.email
+ fill_in 'new_user_password', with: new_user.password
+ click_button "Register"
+
+ expect(current_path).to eq user_registration_path
+ expect(page.body).not_to match(/#{new_user.password}/)
+ end
+ end
+end
diff --git a/spec/features/users/user_browses_projects_on_user_page_spec.rb b/spec/features/users/user_browses_projects_on_user_page_spec.rb
new file mode 100644
index 00000000000..a70637c8370
--- /dev/null
+++ b/spec/features/users/user_browses_projects_on_user_page_spec.rb
@@ -0,0 +1,129 @@
+require 'spec_helper'
+
+describe 'Users > User browses projects on user page', :js do
+ let!(:user) { create :user }
+ let!(:private_project) do
+ create :project, :private, name: 'private', namespace: user.namespace do |project|
+ project.add_master(user)
+ end
+ end
+
+ let!(:internal_project) do
+ create :project, :internal, name: 'internal', namespace: user.namespace do |project|
+ project.add_master(user)
+ end
+ end
+
+ let!(:public_project) do
+ create :project, :public, name: 'public', namespace: user.namespace do |project|
+ project.add_master(user)
+ end
+ end
+
+ def click_nav_link(name)
+ page.within '.nav-links' do
+ click_link name
+ end
+ end
+
+ it 'paginates projects', :js do
+ project = create(:project, namespace: user.namespace)
+ project2 = create(:project, namespace: user.namespace)
+ allow(Project).to receive(:default_per_page).and_return(1)
+
+ sign_in(user)
+
+ visit user_path(user)
+
+ page.within('.user-profile-nav') do
+ click_link('Personal projects')
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content(project2.name)
+
+ click_link('Next')
+
+ expect(page).to have_content(project.name)
+ end
+
+ context 'when not signed in' do
+ it 'renders user public project' do
+ visit user_path(user)
+ click_nav_link('Personal projects')
+
+ expect(page).to have_css('.tab-content #projects.active')
+ expect(title).to start_with(user.name)
+
+ expect(page).to have_content(public_project.name)
+ expect(page).not_to have_content(private_project.name)
+ expect(page).not_to have_content(internal_project.name)
+ end
+ end
+
+ context 'when signed in as another user' do
+ let(:another_user) { create :user }
+
+ before do
+ sign_in(another_user)
+ end
+
+ it 'renders user public and internal projects' do
+ visit user_path(user)
+ click_nav_link('Personal projects')
+
+ expect(title).to start_with(user.name)
+
+ expect(page).not_to have_content(private_project.name)
+ expect(page).to have_content(public_project.name)
+ expect(page).to have_content(internal_project.name)
+ end
+ end
+
+ context 'when signed in as user' do
+ before do
+ sign_in(user)
+ end
+
+ describe 'personal projects' do
+ it 'renders all user projects' do
+ visit user_path(user)
+ click_nav_link('Personal projects')
+
+ expect(title).to start_with(user.name)
+
+ expect(page).to have_content(private_project.name)
+ expect(page).to have_content(public_project.name)
+ expect(page).to have_content(internal_project.name)
+ end
+ end
+
+ describe 'contributed projects' do
+ context 'when user has contributions' do
+ let(:contributed_project) do
+ create :project, :public, :empty_repo
+ end
+
+ before do
+ Issues::CreateService.new(contributed_project, user, { title: 'Bug in old browser' }).execute
+ event = create(:push_event, project: contributed_project, author: user)
+ create(:push_event_payload, event: event, commit_count: 3)
+ end
+
+ it 'renders contributed project' do
+ visit user_path(user)
+
+ expect(title).to start_with(user.name)
+ expect(page).to have_css('.js-contrib-calendar')
+
+ click_nav_link('Contributed projects')
+
+ page.within '#contributed' do
+ expect(page).to have_content(contributed_project.name)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb
deleted file mode 100644
index a9973cdf214..00000000000
--- a/spec/features/users_spec.rb
+++ /dev/null
@@ -1,114 +0,0 @@
-require 'spec_helper'
-
-feature 'Users', :js 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
- visit new_user_session_path
- click_link 'Register'
- fill_in 'new_user_name', with: 'Name Surname'
- fill_in 'new_user_username', with: 'Great'
- fill_in 'new_user_email', with: 'name@mail.com'
- fill_in 'new_user_email_confirmation', with: 'name@mail.com'
- fill_in 'new_user_password', with: 'password1234'
- expect { click_button 'Register' }.to change { User.count }.by(1)
- end
-
- scenario 'Successful user signin invalidates password reset token' do
- expect(user.reset_password_token).to be_nil
-
- visit new_user_password_path
- fill_in 'user_email', with: user.email
- click_button 'Reset password'
-
- user.reload
- expect(user.reset_password_token).not_to be_nil
-
- find('a[href="#login-pane"]').click
- gitlab_sign_in(user)
- expect(current_path).to eq root_path
-
- user.reload
- expect(user.reset_password_token).to be_nil
- end
-
- scenario 'Should show one error if email is already taken' do
- visit new_user_session_path
- click_link 'Register'
- fill_in 'new_user_name', with: 'Another user name'
- fill_in 'new_user_username', with: 'anotheruser'
- fill_in 'new_user_email', with: user.email
- fill_in 'new_user_email_confirmation', with: user.email
- fill_in 'new_user_password', with: '12341234'
- expect { click_button 'Register' }.to change { User.count }.by(0)
- expect(page).to have_text('Email has already been taken')
- expect(number_of_errors_on_page(page)).to be(1), 'errors on page:\n #{errors_on_page page}'
- end
-
- describe 'redirect alias routes' do
- before do
- expect(user).to be_persisted
- end
-
- scenario '/u/user1 redirects to user page' do
- visit '/u/user1'
-
- expect(current_path).to eq user_path(user)
- expect(page).to have_text(user.name)
- end
-
- scenario '/u/user1/groups redirects to user groups page' do
- visit '/u/user1/groups'
-
- expect(current_path).to eq user_groups_path(user)
- end
-
- scenario '/u/user1/projects redirects to user projects page' do
- visit '/u/user1/projects'
-
- expect(current_path).to eq user_projects_path(user)
- end
- end
-
- feature 'username validation' do
- let(:loading_icon) { '.fa.fa-spinner' }
- let(:username_input) { 'new_user_username' }
-
- 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_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_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_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_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")
- end
-
- def number_of_errors_on_page(page)
- page.find('#error_explanation').find('ul').all('li').count
- end
-end
diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb
deleted file mode 100644
index dde60c83536..00000000000
--- a/spec/features/variables_spec.rb
+++ /dev/null
@@ -1,145 +0,0 @@
-require 'spec_helper'
-
-describe 'Project variables', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:variable) { create(:ci_variable, key: 'test_key', value: 'test value') }
-
- before do
- sign_in(user)
- project.team << [user, :master]
- project.variables << variable
-
- visit project_settings_ci_cd_path(project)
- end
-
- it 'shows list of variables' do
- page.within('.variables-table') do
- expect(page).to have_content(variable.key)
- end
- end
-
- 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('Variable was successfully created.')
- page.within('.variables-table') do
- expect(page).to have_content('key')
- expect(page).to have_content('No')
- end
- end
-
- it 'adds empty variable' do
- fill_in('variable_key', with: 'new_key')
- fill_in('variable_value', with: '')
- click_button('Add new variable')
-
- 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')
- click_button('Add new variable')
-
- page.within('.variables-table') do
- expect(page).to have_content('key')
- expect(page).to have_content('******')
- end
-
- click_button('Reveal values')
-
- page.within('.variables-table') do
- expect(page).to have_content('key')
- expect(page).to have_content('key value')
- end
-
- click_button('Hide values')
-
- page.within('.variables-table') do
- expect(page).to have_content('key')
- expect(page).to have_content('******')
- end
- end
-
- it 'deletes variable' do
- page.within('.variables-table') do
- accept_confirm { click_on 'Remove' }
- end
-
- expect(page).not_to have_selector('variables-table')
- end
-
- it 'edits variable' do
- page.within('.variables-table') do
- click_on 'Update'
- end
-
- expect(page).to have_content('Update variable')
- fill_in('variable_key', with: 'key')
- fill_in('variable_value', with: 'key value')
- click_button('Save variable')
-
- expect(page).to have_content('Variable was successfully updated.')
- expect(project.variables(true).first.value).to eq('key value')
- end
-
- it 'edits variable with empty value' do
- page.within('.variables-table') do
- click_on 'Update'
- end
-
- expect(page).to have_content('Update variable')
- fill_in('variable_value', with: '')
- click_button('Save variable')
-
- expect(page).to have_content('Variable was successfully updated.')
- 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 0789d3a9b44..650f7229647 100644
--- a/spec/finders/access_requests_finder_spec.rb
+++ b/spec/finders/access_requests_finder_spec.rb
@@ -51,7 +51,7 @@ describe AccessRequestsFinder do
context 'when current user can see access requests' do
before do
- project.team << [user, :master]
+ project.add_master(user)
group.add_owner(user)
end
@@ -78,7 +78,7 @@ describe AccessRequestsFinder do
context 'when current user can see access requests' do
before do
- project.team << [user, :master]
+ project.add_master(user)
group.add_owner(user)
end
diff --git a/spec/finders/clusters_finder_spec.rb b/spec/finders/clusters_finder_spec.rb
index c10efac2432..da529e0670f 100644
--- a/spec/finders/clusters_finder_spec.rb
+++ b/spec/finders/clusters_finder_spec.rb
@@ -6,7 +6,7 @@ describe ClustersFinder do
describe '#execute' do
let(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
- let(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, projects: [project]) }
+ let(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, :production_environment, projects: [project]) }
subject { described_class.new(project, user, scope).execute }
diff --git a/spec/finders/concerns/finder_methods_spec.rb b/spec/finders/concerns/finder_methods_spec.rb
new file mode 100644
index 00000000000..a4ad331f613
--- /dev/null
+++ b/spec/finders/concerns/finder_methods_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe FinderMethods do
+ let(:finder_class) do
+ Class.new do
+ include FinderMethods
+
+ attr_reader :current_user
+
+ def initialize(user)
+ @current_user = user
+ end
+
+ def execute
+ Project.all
+ end
+ end
+ end
+
+ let(:user) { create(:user) }
+ let(:finder) { finder_class.new(user) }
+ let(:authorized_project) { create(:project) }
+ let(:unauthorized_project) { create(:project) }
+
+ before do
+ authorized_project.add_developer(user)
+ end
+
+ describe '#find_by!' do
+ it 'returns the project if the user has access' do
+ expect(finder.find_by!(id: authorized_project.id)).to eq(authorized_project)
+ end
+
+ it 'raises not found when the project is not found' do
+ expect { finder.find_by!(id: 0) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'raises not found the user does not have access' do
+ expect { finder.find_by!(id: unauthorized_project.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe '#find' do
+ it 'returns the project if the user has access' do
+ expect(finder.find(authorized_project.id)).to eq(authorized_project)
+ end
+
+ it 'raises not found when the project is not found' do
+ expect { finder.find(0) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'raises not found the user does not have access' do
+ expect { finder.find(unauthorized_project.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe '#find_by' do
+ it 'returns the project if the user has access' do
+ expect(finder.find_by(id: authorized_project.id)).to eq(authorized_project)
+ end
+
+ it 'returns nil when the project is not found' do
+ expect(finder.find_by(id: 0)).to be_nil
+ end
+
+ it 'returns nil when the user does not have access' do
+ expect(finder.find_by(id: unauthorized_project.id)).to be_nil
+ end
+ end
+end
diff --git a/spec/finders/concerns/finder_with_cross_project_access_spec.rb b/spec/finders/concerns/finder_with_cross_project_access_spec.rb
new file mode 100644
index 00000000000..c784fb87972
--- /dev/null
+++ b/spec/finders/concerns/finder_with_cross_project_access_spec.rb
@@ -0,0 +1,118 @@
+require 'spec_helper'
+
+describe FinderWithCrossProjectAccess do
+ let(:finder_class) do
+ Class.new do
+ prepend FinderWithCrossProjectAccess
+ include FinderMethods
+
+ requires_cross_project_access if: -> { requires_access? }
+
+ attr_reader :current_user
+
+ def initialize(user)
+ @current_user = user
+ end
+
+ def execute
+ Issue.all
+ end
+ end
+ end
+
+ let(:user) { create(:user) }
+ subject(:finder) { finder_class.new(user) }
+ let!(:result) { create(:issue) }
+
+ before do
+ result.project.add_master(user)
+ end
+
+ def expect_access_check_on_result
+ expect(finder).not_to receive(:requires_access?)
+ expect(Ability).to receive(:allowed?).with(user, :read_issue, result).and_call_original
+ end
+
+ context 'when the user cannot read cross project' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project)
+ .and_return(false)
+ end
+
+ describe '#execute' do
+ it 'returns a issue if the check is disabled' do
+ expect(finder).to receive(:requires_access?).and_return(false)
+
+ expect(finder.execute).to include(result)
+ end
+
+ it 'returns an empty relation when the check is enabled' do
+ expect(finder).to receive(:requires_access?).and_return(true)
+
+ expect(finder.execute).to be_empty
+ end
+
+ it 'only queries once when check is enabled' do
+ expect(finder).to receive(:requires_access?).and_return(true)
+
+ expect { finder.execute }.not_to exceed_query_limit(1)
+ end
+
+ it 'only queries once when check is disabled' do
+ expect(finder).to receive(:requires_access?).and_return(false)
+
+ expect { finder.execute }.not_to exceed_query_limit(1)
+ end
+ end
+
+ describe '#find' do
+ it 'checks the accessibility of the subject directly' do
+ expect_access_check_on_result
+
+ finder.find(result.id)
+ end
+
+ it 'returns the issue' do
+ expect(finder.find(result.id)).to eq(result)
+ end
+ end
+
+ describe '#find_by' do
+ it 'checks the accessibility of the subject directly' do
+ expect_access_check_on_result
+
+ finder.find_by(id: result.id)
+ end
+ end
+
+ describe '#find_by!' do
+ it 'checks the accessibility of the subject directly' do
+ expect_access_check_on_result
+
+ finder.find_by!(id: result.id)
+ end
+
+ it 're-enables the check after the find failed' do
+ finder.find_by!(id: 9999) rescue ActiveRecord::RecordNotFound
+
+ expect(finder.instance_variable_get(:@should_skip_cross_project_check))
+ .to eq(false)
+ end
+ end
+ end
+
+ context 'when the user can read cross project' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project)
+ .and_return(true)
+ end
+
+ it 'returns the result' do
+ expect(finder).not_to receive(:requires_access?)
+
+ expect(finder.execute).to include(result)
+ end
+ end
+end
diff --git a/spec/finders/events_finder_spec.rb b/spec/finders/events_finder_spec.rb
index 18d6c0cfd74..62968e83292 100644
--- a/spec/finders/events_finder_spec.rb
+++ b/spec/finders/events_finder_spec.rb
@@ -26,6 +26,14 @@ describe EventsFinder do
expect(events).not_to include(opened_merge_request_event)
end
+
+ it 'returns nothing when the current user cannot read cross project' do
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+
+ events = described_class.new(source: user, current_user: user).execute
+
+ expect(events).to be_empty
+ end
end
context 'when targeting a project' do
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
index ae050f36b4a..375bcc9087e 100644
--- a/spec/finders/group_descendants_finder_spec.rb
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -35,6 +35,15 @@ describe GroupDescendantsFinder do
expect(finder.execute).to contain_exactly(project)
end
+ it 'does not include projects shared with the group' do
+ project = create(:project, namespace: group)
+ other_project = create(:project)
+ other_project.project_group_links.create(group: group,
+ group_access: ProjectGroupLink::MASTER)
+
+ expect(finder.execute).to contain_exactly(project)
+ end
+
context 'when archived is `true`' do
let(:params) { { archived: 'true' } }
@@ -189,6 +198,17 @@ describe GroupDescendantsFinder do
expect(finder.execute).to contain_exactly(subgroup, matching_project)
end
+ context 'with a small page size' do
+ let(:params) { { filter: 'test', per_page: 1 } }
+
+ it 'contains all the ancestors of a matching subgroup regardless the page size' do
+ subgroup = create(:group, :private, parent: group)
+ matching = create(:group, :private, name: 'testgroup', parent: subgroup)
+
+ expect(finder.execute).to contain_exactly(subgroup, matching)
+ end
+ end
+
it 'does not include the parent itself' do
group.update!(name: 'test')
diff --git a/spec/finders/group_projects_finder_spec.rb b/spec/finders/group_projects_finder_spec.rb
index c6d257bc479..be80ee7d767 100644
--- a/spec/finders/group_projects_finder_spec.rb
+++ b/spec/finders/group_projects_finder_spec.rb
@@ -2,6 +2,7 @@ require 'spec_helper'
describe GroupProjectsFinder do
let(:group) { create(:group) }
+ let(:subgroup) { create(:group, parent: group) }
let(:current_user) { create(:user) }
let(:options) { {} }
@@ -12,6 +13,8 @@ describe GroupProjectsFinder do
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') }
+ let!(:subgroup_project) { create(:project, :public, path: '6', group: subgroup) }
+ let!(:subgroup_private_project) { create(:project, :private, path: '7', group: subgroup) }
before do
shared_project_1.project_group_links.create(group_access: Gitlab::Access::MASTER, group: group)
@@ -35,17 +38,37 @@ describe GroupProjectsFinder do
context "only owned" do
let(:options) { { only_owned: true } }
- it { is_expected.to match_array([private_project, public_project]) }
+ context 'with subgroups projects', :nested_groups do
+ before do
+ options[:include_subgroups] = true
+ end
+
+ it { is_expected.to match_array([private_project, public_project, subgroup_project, subgroup_private_project]) }
+ end
+
+ context 'without subgroups projects' do
+ it { is_expected.to match_array([private_project, public_project]) }
+ end
end
context "all" do
- it { is_expected.to match_array([shared_project_3, shared_project_2, shared_project_1, private_project, public_project]) }
+ context 'with subgroups projects', :nested_groups do
+ before do
+ options[:include_subgroups] = true
+ end
+
+ it { is_expected.to match_array([shared_project_3, shared_project_2, shared_project_1, private_project, public_project, subgroup_project, subgroup_private_project]) }
+ end
+
+ context 'without subgroups projects' do
+ it { is_expected.to match_array([shared_project_3, shared_project_2, shared_project_1, private_project, public_project]) }
+ end
end
end
describe 'without group member current_user' do
before do
- shared_project_2.team << [current_user, Gitlab::Access::MASTER]
+ shared_project_2.add_master(current_user)
current_user.reload
end
@@ -70,10 +93,21 @@ describe GroupProjectsFinder do
context "without external user" do
before do
- private_project.team << [current_user, Gitlab::Access::MASTER]
+ private_project.add_master(current_user)
+ subgroup_private_project.add_master(current_user)
end
- it { is_expected.to match_array([private_project, public_project]) }
+ context 'with subgroups projects', :nested_groups do
+ before do
+ options[:include_subgroups] = true
+ end
+
+ it { is_expected.to match_array([private_project, public_project, subgroup_project, subgroup_private_project]) }
+ end
+
+ context 'without subgroups projects' do
+ it { is_expected.to match_array([private_project, public_project]) }
+ end
end
context "with external user" do
@@ -81,12 +115,32 @@ describe GroupProjectsFinder do
current_user.update_attributes(external: true)
end
- it { is_expected.to eq([public_project]) }
+ context 'with subgroups projects', :nested_groups do
+ before do
+ options[:include_subgroups] = true
+ end
+
+ it { is_expected.to match_array([public_project, subgroup_project]) }
+ end
+
+ context 'without subgroups projects' do
+ it { is_expected.to eq([public_project]) }
+ end
end
end
context "all" do
- it { is_expected.to match_array([shared_project_3, shared_project_2, shared_project_1, public_project]) }
+ context 'with subgroups projects', :nested_groups do
+ before do
+ options[:include_subgroups] = true
+ end
+
+ it { is_expected.to match_array([shared_project_3, shared_project_2, shared_project_1, public_project, subgroup_project]) }
+ end
+
+ context 'without subgroups projects' do
+ it { is_expected.to match_array([shared_project_3, shared_project_2, shared_project_1, public_project]) }
+ end
end
end
@@ -100,7 +154,17 @@ describe GroupProjectsFinder do
context "only owned" do
let(:options) { { only_owned: true } }
- it { is_expected.to eq([public_project]) }
+ context 'with subgroups projects', :nested_groups do
+ before do
+ options[:include_subgroups] = true
+ end
+
+ it { is_expected.to match_array([public_project, subgroup_project]) }
+ end
+
+ context 'without subgroups projects' do
+ it { is_expected.to eq([public_project]) }
+ end
end
end
end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 47b173dea0a..45439640ea3 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -3,13 +3,17 @@ require 'spec_helper'
describe IssuesFinder do
set(:user) { create(:user) }
set(:user2) { create(:user) }
- set(:project1) { create(:project) }
+ set(:group) { create(:group) }
+ set(:subgroup) { create(:group, parent: group) }
+ set(:project1) { create(:project, group: group) }
set(:project2) { create(:project) }
+ set(:project3) { create(:project, group: subgroup) }
set(:milestone) { create(:milestone, project: project1) }
set(:label) { create(:label, project: project2) }
- 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) }
+ set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab', created_at: 1.week.ago, updated_at: 1.week.ago) }
+ set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab', created_at: 1.week.from_now, updated_at: 1.week.from_now) }
+ set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki', created_at: 2.weeks.from_now, updated_at: 2.weeks.from_now) }
+ set(:issue4) { create(:issue, project: project3) }
set(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: issue1) }
set(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: issue2) }
set(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: issue3) }
@@ -22,13 +26,15 @@ describe IssuesFinder do
let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute }
before(:context) do
- project1.team << [user, :master]
- project2.team << [user, :developer]
- project2.team << [user2, :developer]
+ project1.add_master(user)
+ project2.add_developer(user)
+ project2.add_developer(user2)
+ project3.add_developer(user)
issue1
issue2
issue3
+ issue4
award_emoji1
award_emoji2
@@ -39,7 +45,7 @@ describe IssuesFinder do
let(:scope) { 'all' }
it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3)
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4)
end
context 'filtering by assignee ID' do
@@ -50,6 +56,26 @@ describe IssuesFinder do
end
end
+ context 'filtering by group_id' do
+ let(:params) { { group_id: group.id } }
+
+ context 'when include_subgroup param not set' do
+ it 'returns all group issues' do
+ expect(issues).to contain_exactly(issue1)
+ end
+ end
+
+ context 'when include_subgroup param is true', :nested_groups do
+ before do
+ params[:include_subgroups] = true
+ end
+
+ it 'returns all group and subgroup issues' do
+ expect(issues).to contain_exactly(issue1, issue4)
+ end
+ end
+ end
+
context 'filtering by author ID' do
let(:params) { { author_id: user2.id } }
@@ -87,7 +113,7 @@ describe IssuesFinder do
let(:params) { { milestone_title: Milestone::None.title } }
it 'returns issues with no milestone' do
- expect(issues).to contain_exactly(issue2, issue3)
+ expect(issues).to contain_exactly(issue2, issue3, issue4)
end
end
@@ -185,7 +211,7 @@ describe IssuesFinder do
let(:params) { { label_name: Label::None.title } }
it 'returns issues with no labels' do
- expect(issues).to contain_exactly(issue1, issue3)
+ expect(issues).to contain_exactly(issue1, issue3, issue4)
end
end
@@ -210,7 +236,7 @@ describe IssuesFinder do
let(:params) { { state: 'opened' } }
it 'returns only opened issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3)
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4)
end
end
@@ -226,7 +252,7 @@ describe IssuesFinder do
let(:params) { { state: 'all' } }
it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, closed_issue)
+ expect(issues).to contain_exactly(issue1, issue2, issue3, closed_issue, issue4)
end
end
@@ -234,7 +260,7 @@ describe IssuesFinder do
let(:params) { { state: 'invalid_state' } }
it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, closed_issue)
+ expect(issues).to contain_exactly(issue1, issue2, issue3, closed_issue, issue4)
end
end
end
@@ -249,12 +275,46 @@ describe IssuesFinder do
end
context 'through created_before' do
- let(:params) { { created_before: issue1.created_at + 1.second } }
+ let(:params) { { created_before: issue1.created_at } }
it 'returns issues created on or before the given date' do
expect(issues).to contain_exactly(issue1)
end
end
+
+ context 'through created_after and created_before' do
+ let(:params) { { created_after: issue2.created_at, created_before: issue3.created_at } }
+
+ it 'returns issues created between the given dates' do
+ expect(issues).to contain_exactly(issue2, issue3)
+ end
+ end
+ end
+
+ context 'filtering by updated_at' do
+ context 'through updated_after' do
+ let(:params) { { updated_after: issue3.updated_at } }
+
+ it 'returns issues updated on or after the given date' do
+ expect(issues).to contain_exactly(issue3)
+ end
+ end
+
+ context 'through updated_before' do
+ let(:params) { { updated_before: issue1.updated_at } }
+
+ it 'returns issues updated on or before the given date' do
+ expect(issues).to contain_exactly(issue1)
+ end
+ end
+
+ context 'through updated_after and updated_before' do
+ let(:params) { { updated_after: issue2.updated_at, updated_before: issue3.updated_at } }
+
+ it 'returns issues updated between the given dates' do
+ expect(issues).to contain_exactly(issue2, issue3)
+ end
+ end
end
context 'filtering by reaction name' do
@@ -338,7 +398,7 @@ describe IssuesFinder do
end
it "doesn't return issues if feature disabled" do
- [project1, project2].each do |project|
+ [project1, project2, project3].each do |project|
project.project_feature.update!(issues_access_level: ProjectFeature::DISABLED)
end
@@ -351,7 +411,7 @@ describe IssuesFinder do
it 'returns the number of rows for the default state' do
finder = described_class.new(user)
- expect(finder.row_count).to eq(3)
+ expect(finder.row_count).to eq(4)
end
it 'returns the number of rows for a given state' do
diff --git a/spec/finders/labels_finder_spec.rb b/spec/finders/labels_finder_spec.rb
index afa2a40ed2a..899d0d22819 100644
--- a/spec/finders/labels_finder_spec.rb
+++ b/spec/finders/labels_finder_spec.rb
@@ -5,6 +5,8 @@ describe LabelsFinder do
let(:group_1) { create(:group) }
let(:group_2) { create(:group) }
let(:group_3) { create(:group) }
+ let(:private_group_1) { create(:group, :private) }
+ let(:private_subgroup_1) { create(:group, :private, parent: private_group_1) }
let(:project_1) { create(:project, namespace: group_1) }
let(:project_2) { create(:project, namespace: group_2) }
@@ -20,6 +22,8 @@ describe LabelsFinder do
let!(:group_label_1) { create(:group_label, group: group_1, title: 'Label 1 (group)') }
let!(:group_label_2) { create(:group_label, group: group_1, title: 'Group Label 2') }
let!(:group_label_3) { create(:group_label, group: group_2, title: 'Group Label 3') }
+ let!(:private_group_label_1) { create(:group_label, group: private_group_1, title: 'Private Group Label 1') }
+ let!(:private_subgroup_label_1) { create(:group_label, group: private_subgroup_1, title: 'Private Sub Group Label 1') }
let(:user) { create(:user) }
@@ -27,7 +31,7 @@ describe LabelsFinder do
create(:label, project: project_3, title: 'Label 3')
create(:group_label, group: group_3, title: 'Group Label 4')
- project_1.team << [user, :developer]
+ project_1.add_developer(user)
end
context 'with no filter' do
@@ -56,9 +60,89 @@ describe LabelsFinder do
expect(finder.execute).to eq [group_label_2, group_label_1, project_label_5]
end
+
+ context 'when only_group_labels is true' do
+ it 'returns only group labels' do
+ group_1.add_developer(user)
+
+ finder = described_class.new(user, group_id: group_1.id, only_group_labels: true)
+
+ expect(finder.execute).to eq [group_label_2, group_label_1]
+ end
+ end
+
+ context 'when group has no projects' do
+ let(:empty_group) { create(:group) }
+ let!(:empty_group_label_1) { create(:group_label, group: empty_group, title: 'Label 1 (empty group)') }
+ let!(:empty_group_label_2) { create(:group_label, group: empty_group, title: 'Label 2 (empty group)') }
+
+ before do
+ empty_group.add_developer(user)
+ end
+
+ context 'when only group labels is false' do
+ it 'returns group labels' do
+ finder = described_class.new(user, group_id: empty_group.id)
+
+ expect(finder.execute).to eq [empty_group_label_1, empty_group_label_2]
+ end
+ end
+ end
+
+ context 'when including labels from group ancestors', :nested_groups do
+ it 'returns labels from group and its ancestors' do
+ private_group_1.add_developer(user)
+ private_subgroup_1.add_developer(user)
+
+ finder = described_class.new(user, group_id: private_subgroup_1.id, only_group_labels: true, include_ancestor_groups: true)
+
+ expect(finder.execute).to eq [private_group_label_1, private_subgroup_label_1]
+ end
+
+ it 'ignores labels from groups which user can not read' do
+ private_subgroup_1.add_developer(user)
+
+ finder = described_class.new(user, group_id: private_subgroup_1.id, only_group_labels: true, include_ancestor_groups: true)
+
+ expect(finder.execute).to eq [private_subgroup_label_1]
+ end
+ end
+
+ context 'when including labels from group descendants', :nested_groups do
+ it 'returns labels from group and its descendants' do
+ private_group_1.add_developer(user)
+ private_subgroup_1.add_developer(user)
+
+ finder = described_class.new(user, group_id: private_group_1.id, only_group_labels: true, include_descendant_groups: true)
+
+ expect(finder.execute).to eq [private_group_label_1, private_subgroup_label_1]
+ end
+
+ it 'ignores labels from groups which user can not read' do
+ private_subgroup_1.add_developer(user)
+
+ finder = described_class.new(user, group_id: private_group_1.id, only_group_labels: true, include_descendant_groups: true)
+
+ expect(finder.execute).to eq [private_subgroup_label_1]
+ end
+ end
end
- context 'filtering by project_id' do
+ context 'filtering by project_id', :nested_groups do
+ context 'when include_ancestor_groups is true' do
+ let!(:sub_project) { create(:project, namespace: private_subgroup_1 ) }
+ let!(:project_label) { create(:label, project: sub_project, title: 'Label 5') }
+ let(:finder) { described_class.new(user, project_id: sub_project.id, include_ancestor_groups: true) }
+
+ before do
+ private_group_1.add_developer(user)
+ end
+
+ it 'returns all ancestor labels' do
+ expect(finder.execute).to match_array([private_subgroup_label_1, private_group_label_1, project_label])
+ end
+ end
+
it 'returns labels available for the project' do
finder = described_class.new(user, project_id: project_1.id)
@@ -73,7 +157,7 @@ describe LabelsFinder do
# project_3 has a label associated to it, which we don't want coming
# back when we ask for the isolated project's labels
- project_3.team << [admin, :reporter]
+ project_3.add_reporter(admin)
finder = described_class.new(admin, project_id: isolated_project.id)
expect(finder.execute).to be_empty
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 883bdf3746a..c8a43ddf410 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -6,31 +6,36 @@ describe MergeRequestsFinder do
let(:user) { create :user }
let(:user2) { create :user }
- let(:project1) { create(:project, :public) }
+ let(:group) { create(:group) }
+ let(:subgroup) { create(:group, parent: group) }
+ let(:project1) { create(:project, :public, group: group) }
let(:project2) { fork_project(project1, user) }
let(:project3) do
p = fork_project(project1, user)
p.update!(archived: true)
p
end
+ let(:project4) { create(:project, :public, group: subgroup) }
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') }
+ let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') }
let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2) }
let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3) }
+ let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4) }
before do
- project1.team << [user, :master]
- project2.team << [user, :developer]
- project3.team << [user, :developer]
- project2.team << [user2, :developer]
+ project1.add_master(user)
+ project2.add_developer(user)
+ project3.add_developer(user)
+ project2.add_developer(user2)
+ project4.add_developer(user)
end
describe "#execute" do
it 'filters by scope' do
params = { scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute
- expect(merge_requests.size).to eq(3)
+ expect(merge_requests.size).to eq(4)
end
it 'filters by project' do
@@ -39,10 +44,26 @@ describe MergeRequestsFinder do
expect(merge_requests.size).to eq(1)
end
+ it 'filters by group' do
+ params = { group_id: group.id }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests.size).to eq(2)
+ end
+
+ it 'filters by group including subgroups', :nested_groups do
+ params = { group_id: group.id, include_subgroups: true }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests.size).to eq(3)
+ end
+
it 'filters by non_archived' do
params = { non_archived: true }
merge_requests = described_class.new(user, params).execute
- expect(merge_requests.size).to eq(3)
+ expect(merge_requests.size).to eq(4)
end
it 'filters by iid' do
@@ -53,6 +74,22 @@ describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request1)
end
+ it 'filters by source branch' do
+ params = { source_branch: merge_request2.source_branch }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(merge_request2)
+ end
+
+ it 'filters by target branch' do
+ params = { target_branch: merge_request2.target_branch }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(merge_request2)
+ end
+
context 'filtering by group milestone' do
let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) }
@@ -72,33 +109,36 @@ describe MergeRequestsFinder do
end
end
- context 'with created_after and created_before params' do
- let(:project4) { create(:project, forked_from_project: project1) }
+ context 'filtering by created_at/updated_at' do
+ let(:new_project) { 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)
+ updated_at: 1.week.from_now,
+ source_project: new_project,
+ target_project: new_project)
end
let!(:old_merge_request) do
create(:merge_request,
:simple,
author: user,
+ source_branch: 'feature_1',
created_at: 1.week.ago,
- source_project: project4,
- target_project: project4)
+ updated_at: 1.week.ago,
+ source_project: new_project,
+ target_project: new_project)
end
before do
- project4.add_master(user)
+ new_project.add_master(user)
end
it 'filters by created_after' do
- params = { project_id: project1.id, created_after: new_merge_request.created_at }
+ params = { project_id: new_project.id, created_after: new_merge_request.created_at }
merge_requests = described_class.new(user, params).execute
@@ -106,12 +146,52 @@ describe MergeRequestsFinder do
end
it 'filters by created_before' do
- params = { project_id: project4.id, created_before: old_merge_request.created_at + 1.second }
+ params = { project_id: new_project.id, created_before: old_merge_request.created_at }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(old_merge_request)
end
+
+ it 'filters by created_after and created_before' do
+ params = {
+ project_id: new_project.id,
+ created_after: old_merge_request.created_at,
+ created_before: new_merge_request.created_at
+ }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(old_merge_request, new_merge_request)
+ end
+
+ it 'filters by updated_after' do
+ params = { project_id: new_project.id, updated_after: new_merge_request.updated_at }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(new_merge_request)
+ end
+
+ it 'filters by updated_before' do
+ params = { project_id: new_project.id, updated_before: old_merge_request.updated_at }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(old_merge_request)
+ end
+
+ it 'filters by updated_after and updated_before' do
+ params = {
+ project_id: new_project.id,
+ updated_after: old_merge_request.updated_at,
+ updated_before: new_merge_request.updated_at
+ }
+
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(old_merge_request, new_merge_request)
+ end
end
end
@@ -119,7 +199,7 @@ describe MergeRequestsFinder do
it 'returns the number of rows for the default state' do
finder = described_class.new(user)
- expect(finder.row_count).to eq(3)
+ expect(finder.row_count).to eq(4)
end
it 'returns the number of rows for a given state' do
diff --git a/spec/finders/milestones_finder_spec.rb b/spec/finders/milestones_finder_spec.rb
index 8ae08656e01..656d120311a 100644
--- a/spec/finders/milestones_finder_spec.rb
+++ b/spec/finders/milestones_finder_spec.rb
@@ -21,10 +21,19 @@ describe MilestonesFinder do
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
+ context 'milestones for groups and project' do
+ let(:result) do
+ described_class.new(project_ids: [project_1.id, project_2.id], group_ids: group.id, state: 'all').execute
+ end
+
+ it 'returns milestones for groups and projects' do
+ expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4)
+ end
- expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4)
+ it 'orders milestones by due date' do
+ expect(result.first).to eq(milestone_1)
+ expect(result.second).to eq(milestone_3)
+ end
end
context 'with filters' do
@@ -62,29 +71,11 @@ describe MilestonesFinder do
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
+ describe '#find_by' do
+ it 'finds a single milestone' do
+ finder = described_class.new(project_ids: [project_1.id], state: 'all')
- 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)
+ expect(finder.find_by(iid: milestone_3.iid)).to eq(milestone_3)
end
end
end
diff --git a/spec/finders/move_to_project_finder_spec.rb b/spec/finders/move_to_project_finder_spec.rb
index e577083a2d0..74639d4147f 100644
--- a/spec/finders/move_to_project_finder_spec.rb
+++ b/spec/finders/move_to_project_finder_spec.rb
@@ -15,39 +15,39 @@ describe MoveToProjectFinder do
describe '#execute' do
context 'filter' do
it 'does not return projects under Gitlab::Access::REPORTER' do
- guest_project.team << [user, :guest]
+ guest_project.add_guest(user)
expect(subject.execute(project)).to be_empty
end
it 'returns projects equal or above Gitlab::Access::REPORTER ordered by id in descending order' do
- reporter_project.team << [user, :reporter]
- developer_project.team << [user, :developer]
- master_project.team << [user, :master]
+ reporter_project.add_reporter(user)
+ developer_project.add_developer(user)
+ master_project.add_master(user)
expect(subject.execute(project).to_a).to eq([master_project, developer_project, reporter_project])
end
it 'does not include the source project' do
- project.team << [user, :reporter]
+ project.add_reporter(user)
expect(subject.execute(project).to_a).to be_empty
end
it 'does not return archived projects' do
- reporter_project.team << [user, :reporter]
+ reporter_project.add_reporter(user)
reporter_project.archive!
other_reporter_project = create(:project)
- other_reporter_project.team << [user, :reporter]
+ other_reporter_project.add_reporter(user)
expect(subject.execute(project).to_a).to eq([other_reporter_project])
end
it 'does not return projects for which issues are disabled' do
- reporter_project.team << [user, :reporter]
+ reporter_project.add_reporter(user)
reporter_project.update_attributes(issues_enabled: false)
other_reporter_project = create(:project)
- other_reporter_project.team << [user, :reporter]
+ other_reporter_project.add_reporter(user)
expect(subject.execute(project).to_a).to eq([other_reporter_project])
end
@@ -55,9 +55,9 @@ describe MoveToProjectFinder do
it 'returns a page of projects ordered by id in descending order' do
stub_const 'MoveToProjectFinder::PAGE_SIZE', 2
- reporter_project.team << [user, :reporter]
- developer_project.team << [user, :developer]
- master_project.team << [user, :master]
+ reporter_project.add_reporter(user)
+ developer_project.add_developer(user)
+ master_project.add_master(user)
expect(subject.execute(project).to_a).to eq([master_project, developer_project])
end
@@ -65,9 +65,9 @@ describe MoveToProjectFinder do
it 'returns projects after the given offset id' do
stub_const 'MoveToProjectFinder::PAGE_SIZE', 2
- reporter_project.team << [user, :reporter]
- developer_project.team << [user, :developer]
- master_project.team << [user, :master]
+ reporter_project.add_reporter(user)
+ developer_project.add_developer(user)
+ master_project.add_master(user)
expect(subject.execute(project, search: nil, offset_id: master_project.id).to_a).to eq([developer_project, reporter_project])
expect(subject.execute(project, search: nil, offset_id: developer_project.id).to_a).to eq([reporter_project])
@@ -84,10 +84,10 @@ describe MoveToProjectFinder do
it 'returns projects matching a search query' do
foo_project = create(:project)
- foo_project.team << [user, :master]
+ foo_project.add_master(user)
wadus_project = create(:project, name: 'wadus')
- wadus_project.team << [user, :master]
+ wadus_project.add_master(user)
expect(subject.execute(project).to_a).to eq([wadus_project, foo_project])
expect(subject.execute(project, search: 'wadus').to_a).to eq([wadus_project])
diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb
index 900fa2b12d1..f1ae2c7ab65 100644
--- a/spec/finders/notes_finder_spec.rb
+++ b/spec/finders/notes_finder_spec.rb
@@ -5,7 +5,7 @@ describe NotesFinder do
let(:project) { create(:project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe '#execute' do
@@ -75,6 +75,18 @@ describe NotesFinder do
end
end
+ context 'for target type' do
+ let(:project) { create(:project, :repository) }
+ let!(:note1) { create :note_on_issue, project: project }
+ let!(:note2) { create :note_on_commit, project: project }
+
+ it 'finds only notes for the selected type' do
+ notes = described_class.new(project, user, target_type: 'issue').execute
+
+ expect(notes).to eq([note1])
+ end
+ end
+
context 'for target' do
let(:project) { create(:project, :repository) }
let(:note1) { create :note_on_commit, project: project }
@@ -147,7 +159,7 @@ describe NotesFinder do
it 'raises an error for project members with guest role' do
user = create(:user)
- project.team << [user, :guest]
+ project.add_guest(user)
expect { described_class.new(project, user, params).execute }.to raise_error(ActiveRecord::RecordNotFound)
end
@@ -189,7 +201,7 @@ describe NotesFinder do
it "does not return notes with matching content for project members with guest role" do
user = create(:user)
- project.team << [user, :guest]
+ project.add_guest(user)
expect(described_class.new(confidential_note.project, user, search: confidential_note.note).execute).to be_empty
end
diff --git a/spec/finders/personal_projects_finder_spec.rb b/spec/finders/personal_projects_finder_spec.rb
index d0113ba87df..5e52898e9c0 100644
--- a/spec/finders/personal_projects_finder_spec.rb
+++ b/spec/finders/personal_projects_finder_spec.rb
@@ -15,7 +15,7 @@ describe PersonalProjectsFinder do
end
before do
- private_project.team << [current_user, Gitlab::Access::DEVELOPER]
+ private_project.add_developer(current_user)
end
describe 'without a current user' do
diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb
index 7ae7b7d2140..1ae0bd988f2 100644
--- a/spec/finders/snippets_finder_spec.rb
+++ b/spec/finders/snippets_finder_spec.rb
@@ -1,57 +1,8 @@
require 'spec_helper'
describe SnippetsFinder do
- let(:user) { create :user }
- let(:user1) { create :user }
- let(:group) { create :group, :public }
-
- let(:project1) { create(:project, :public, group: group) }
- let(:project2) { create(:project, :private, group: group) }
-
- 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 = 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 = 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
+ include Gitlab::Allowable
+ using RSpec::Parameterized::TableSyntax
context 'filter by visibility' do
let!(:snippet1) { create(:personal_snippet, :private) }
@@ -67,6 +18,7 @@ describe SnippetsFinder do
end
context 'filter by scope' do
+ let(:user) { create :user }
let!(:snippet1) { create(:personal_snippet, :private, author: user) }
let!(:snippet2) { create(:personal_snippet, :internal, author: user) }
let!(:snippet3) { create(:personal_snippet, :public, author: user) }
@@ -84,7 +36,7 @@ describe SnippetsFinder do
expect(snippets).not_to include(snippet2, snippet3)
end
- it "returns all snippets for 'are_interna;' scope" do
+ it "returns all snippets for 'are_internal' scope" do
snippets = described_class.new(user, scope: :are_internal).execute
expect(snippets).to include(snippet2)
@@ -100,6 +52,8 @@ describe SnippetsFinder do
end
context 'filter by author' do
+ let(:user) { create :user }
+ let(:user1) { create :user }
let!(:snippet1) { create(:personal_snippet, :private, author: user) }
let!(:snippet2) { create(:personal_snippet, :internal, author: user) }
let!(:snippet3) { create(:personal_snippet, :public, author: user) }
@@ -147,6 +101,10 @@ describe SnippetsFinder do
end
context 'filter by project' do
+ let(:user) { create :user }
+ let(:group) { create :group, :public }
+ let(:project1) { create(:project, :public, group: group) }
+
before do
@snippet1 = create(:project_snippet, :private, project: project1)
@snippet2 = create(:project_snippet, :internal, project: project1)
@@ -188,7 +146,7 @@ describe SnippetsFinder do
end
it "returns all snippets for project members" do
- project1.team << [user, :developer]
+ project1.add_developer(user)
snippets = described_class.new(user, project: project1).execute
@@ -196,11 +154,34 @@ describe SnippetsFinder do
end
it "returns private snippets for project members" do
- project1.team << [user, :developer]
+ project1.add_developer(user)
snippets = described_class.new(user, project: project1, visibility: Snippet::PRIVATE).execute
expect(snippets).to include(@snippet1)
end
end
+
+ describe '#execute' do
+ let(:project) { create(:project, :public) }
+ let!(:project_snippet) { create(:project_snippet, :public, project: project) }
+ let!(:personal_snippet) { create(:personal_snippet, :public) }
+ let(:user) { create(:user) }
+ subject(:finder) { described_class.new(user) }
+
+ it 'returns project- and personal snippets' do
+ expect(finder.execute).to contain_exactly(project_snippet, personal_snippet)
+ end
+
+ context 'when the user cannot read cross project' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+ end
+
+ it 'returns only personal snippets when the user cannot read cross project' do
+ expect(finder.execute).to contain_exactly(personal_snippet)
+ end
+ end
+ end
end
diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb
index 884ce22091e..9747b9402a7 100644
--- a/spec/finders/todos_finder_spec.rb
+++ b/spec/finders/todos_finder_spec.rb
@@ -2,12 +2,13 @@ require 'spec_helper'
describe TodosFinder do
describe '#execute' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:finder) { described_class }
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, namespace: group) }
+ let(:finder) { described_class }
before do
- project.team << [user, :developer]
+ group.add_developer(user)
end
describe '#sort' do
@@ -34,17 +35,20 @@ describe TodosFinder do
end
it "sorts by priority" do
+ project_2 = create(:project)
+
label_1 = create(:label, title: 'label_1', project: project, priority: 1)
label_2 = create(:label, title: 'label_2', project: project, priority: 2)
label_3 = create(:label, title: 'label_3', project: project, priority: 3)
+ label_1_2 = create(:label, title: 'label_1', project: project_2, priority: 1)
issue_1 = create(:issue, title: 'issue_1', project: project)
issue_2 = create(:issue, title: 'issue_2', project: project)
issue_3 = create(:issue, title: 'issue_3', project: project)
issue_4 = create(:issue, title: 'issue_4', project: project)
- merge_request_1 = create(:merge_request, source_project: project)
+ merge_request_1 = create(:merge_request, source_project: project_2)
- merge_request_1.labels << label_1
+ merge_request_1.labels << label_1_2
# Covers the case where Todo has more than one label
issue_3.labels << label_1
@@ -57,15 +61,14 @@ describe TodosFinder do
todo_2 = create(:todo, user: user, project: project, target: issue_2)
todo_3 = create(:todo, user: user, project: project, target: issue_3, created_at: 2.hours.ago)
todo_4 = create(:todo, user: user, project: project, target: issue_1)
- todo_5 = create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago)
+ todo_5 = create(:todo, user: user, project: project_2, target: merge_request_1, created_at: 1.hour.ago)
+
+ project_2.add_developer(user)
todos = finder.new(user, { sort: 'priority' }).execute
- expect(todos.first).to eq(todo_3)
- expect(todos.second).to eq(todo_5)
- expect(todos.third).to eq(todo_4)
- expect(todos.fourth).to eq(todo_2)
- expect(todos.fifth).to eq(todo_1)
+ puts todos.to_sql
+ expect(todos).to eq([todo_3, todo_5, todo_4, todo_2, todo_1])
end
end
end
diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb
new file mode 100644
index 00000000000..3ca0f7c3c89
--- /dev/null
+++ b/spec/finders/user_recent_events_finder_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe UserRecentEventsFinder do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:project_owner) { project.creator }
+ let!(:event) { create(:event, project: project, author: project_owner) }
+
+ subject(:finder) { described_class.new(user, project_owner) }
+
+ describe '#execute' do
+ it 'does not include the event when a user does not have access to the project' do
+ expect(finder.execute).to be_empty
+ end
+
+ context 'when the user has access to a project' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'includes the event' do
+ expect(finder.execute).to include(event)
+ end
+
+ it 'does not include the event if the user cannot read cross project' do
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+ expect(finder.execute).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json
index 489d563be2b..d27c12e43f2 100644
--- a/spec/fixtures/api/schemas/cluster_status.json
+++ b/spec/fixtures/api/schemas/cluster_status.json
@@ -30,7 +30,8 @@
]
}
},
- "status_reason": { "type": ["string", "null"] }
+ "status_reason": { "type": ["string", "null"] },
+ "external_ip": { "type": ["string", "null"] }
},
"required" : [ "name", "status" ]
}
diff --git a/spec/fixtures/api/schemas/deployment.json b/spec/fixtures/api/schemas/deployment.json
new file mode 100644
index 00000000000..536e6475c23
--- /dev/null
+++ b/spec/fixtures/api/schemas/deployment.json
@@ -0,0 +1,45 @@
+{
+ "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"
+}
diff --git a/spec/fixtures/api/schemas/deployments.json b/spec/fixtures/api/schemas/deployments.json
index 1112f23aab2..7bf50e4f859 100644
--- a/spec/fixtures/api/schemas/deployments.json
+++ b/spec/fixtures/api/schemas/deployments.json
@@ -3,49 +3,7 @@
"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"
+ "$ref": "deployment.json"
},
"minItems": 1,
"type": "array"
diff --git a/spec/fixtures/api/schemas/entities/issue.json b/spec/fixtures/api/schemas/entities/issue.json
index 3d3329a3406..38467b4ca20 100644
--- a/spec/fixtures/api/schemas/entities/issue.json
+++ b/spec/fixtures/api/schemas/entities/issue.json
@@ -28,7 +28,6 @@
"confidential": { "type": "boolean" },
"discussion_locked": { "type": ["boolean", "null"] },
"updated_by_id": { "type": ["string", "null"] },
- "deleted_at": { "type": ["string", "null"] },
"time_estimate": { "type": "integer" },
"total_time_spent": { "type": "integer" },
"human_time_estimate": { "type": ["integer", "null"] },
diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json
index 995f13381ad..46031961cca 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_basic.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json
@@ -9,9 +9,11 @@
"human_time_estimate": { "type": ["string", "null"] },
"human_total_time_spent": { "type": ["string", "null"] },
"merge_error": { "type": ["string", "null"] },
+ "rebase_in_progress": { "type": "boolean" },
"assignee_id": { "type": ["integer", "null"] },
"subscribed": { "type": ["boolean", "null"] },
- "participants": { "type": "array" }
+ "participants": { "type": "array" },
+ "allow_maintainer_to_push": { "type": "boolean"}
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_metrics.json b/spec/fixtures/api/schemas/entities/merge_request_metrics.json
new file mode 100644
index 00000000000..3fa767f85df
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/merge_request_metrics.json
@@ -0,0 +1,21 @@
+{
+ "type": "object",
+ "required": ["closed_at", "merged_at", "closed_by", "merged_by"],
+ "properties" : {
+ "closed_at": { "type": ["datetime", "null"] },
+ "merged_at": { "type": ["datetime", "null"] },
+ "closed_by": {
+ "oneOf": [
+ { "type": "null" },
+ { "$ref": "user.json" }
+ ]
+ },
+ "merged_by": {
+ "oneOf": [
+ { "type": "null" },
+ { "$ref": "user.json" }
+ ]
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json
index 342890c3dee..a622bf88b13 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_widget.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json
@@ -13,7 +13,6 @@
"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"] },
@@ -31,8 +30,13 @@
"source_project_id": { "type": "integer" },
"target_branch": { "type": "string" },
"target_project_id": { "type": "integer" },
- "merge_event": { "type": ["object", "null"] },
- "closed_event": { "type": ["object", "null"] },
+ "allow_maintainer_to_push": { "type": "boolean"},
+ "metrics": {
+ "oneOf": [
+ { "type": "null" },
+ { "$ref": "merge_request_metrics.json" }
+ ]
+ },
"author": { "type": ["object", "null"] },
"merge_user": { "type": ["object", "null"] },
"diff_head_sha": { "type": ["string", "null"] },
@@ -72,7 +76,9 @@
"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"] }
+ "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] },
+ "can_create_note": { "type": "boolean" },
+ "can_update": { "type": "boolean" }
},
"additionalProperties": false
},
@@ -99,7 +105,12 @@
"remove_source_branch": { "type": ["boolean", "null"] },
"merge_ongoing": { "type": "boolean" },
"ff_only_enabled": { "type": ["boolean", false] },
- "should_be_rebased": { "type": "boolean" }
+ "should_be_rebased": { "type": "boolean" },
+ "create_note_path": { "type": ["string", "null"] },
+ "rebase_commit_sha": { "type": ["string", "null"] },
+ "rebase_in_progress": { "type": "boolean" },
+ "can_push_to_source_branch": { "type": "boolean" },
+ "rebase_path": { "type": ["string", "null"] }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/entities/user.json b/spec/fixtures/api/schemas/entities/user.json
new file mode 100644
index 00000000000..6482e0eedd2
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/user.json
@@ -0,0 +1,17 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "state",
+ "avatar_url",
+ "web_url",
+ "path"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "state": { "type": "string" },
+ "avatar_url": { "type": "string" },
+ "web_url": { "type": "string" },
+ "path": { "type": "string" }
+ }
+}
diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json
index b579e32c9aa..8833825e3fb 100644
--- a/spec/fixtures/api/schemas/issue.json
+++ b/spec/fixtures/api/schemas/issue.json
@@ -15,6 +15,8 @@
"relative_position": { "type": "integer" },
"issue_sidebar_endpoint": { "type": "string" },
"toggle_subscription_endpoint": { "type": "string" },
+ "reference_path": { "type": "string" },
+ "real_path": { "type": "string" },
"project": {
"id": { "type": "integer" },
"path": { "type": "string" }
diff --git a/spec/fixtures/api/schemas/public_api/v4/blobs.json b/spec/fixtures/api/schemas/public_api/v4/blobs.json
new file mode 100644
index 00000000000..a812815838f
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/blobs.json
@@ -0,0 +1,19 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties" : {
+ "basename": { "type": "string" },
+ "data": { "type": "string" },
+ "filename": { "type": ["string"] },
+ "id": { "type": ["string", "null"] },
+ "project_id": { "type": "integer" },
+ "ref": { "type": "string" },
+ "startline": { "type": "integer" }
+ },
+ "required": [
+ "basename", "data", "filename", "id", "ref", "startline", "project_id"
+ ],
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/board.json b/spec/fixtures/api/schemas/public_api/v4/board.json
new file mode 100644
index 00000000000..d667f1d631c
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/board.json
@@ -0,0 +1,86 @@
+{
+ "type": "object",
+ "required" : [
+ "id",
+ "project",
+ "lists"
+ ],
+ "properties" : {
+ "id": { "type": "integer" },
+ "project": {
+ "type": ["object", "null"],
+ "required": [
+ "id",
+ "avatar_url",
+ "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"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "avatar_url": { "type": ["string", "null"] },
+ "description": { "type": ["string", "null"] },
+ "default_branch": { "type": ["string", "null"] },
+ "tag_list": { "type": "array" },
+ "ssh_url_to_repo": { "type": "string" },
+ "http_url_to_repo": { "type": "string" },
+ "web_url": { "type": "string" },
+ "name": { "type": "string" },
+ "name_with_namespace": { "type": "string" },
+ "path": { "type": "string" },
+ "path_with_namespace": { "type": "string" },
+ "star_count": { "type": "integer" },
+ "forks_count": { "type": "integer" },
+ "created_at": { "type": "date" },
+ "last_activity_at": { "type": "date" }
+ },
+ "additionalProperties": false
+ },
+ "lists": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required" : [
+ "id",
+ "label",
+ "position"
+ ],
+ "properties" : {
+ "id": { "type": "integer" },
+ "label": {
+ "type": ["object", "null"],
+ "required": [
+ "id",
+ "color",
+ "description",
+ "name"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "color": {
+ "type": "string",
+ "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
+ },
+ "description": { "type": ["string", "null"] },
+ "name": { "type": "string" }
+ }
+ },
+ "position": { "type": ["integer", "null"] }
+ },
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/boards.json b/spec/fixtures/api/schemas/public_api/v4/boards.json
new file mode 100644
index 00000000000..117564ef77a
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/boards.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "board.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/commit/detail.json b/spec/fixtures/api/schemas/public_api/v4/commit/detail.json
index 88a3cad62f6..477e776a804 100644
--- a/spec/fixtures/api/schemas/public_api/v4/commit/detail.json
+++ b/spec/fixtures/api/schemas/public_api/v4/commit/detail.json
@@ -4,9 +4,9 @@
{ "$ref": "basic.json" },
{
"required" : [
- "stats",
"status",
- "last_pipeline"
+ "last_pipeline",
+ "project_id"
],
"properties": {
"stats": { "$ref": "../commit_stats.json" },
@@ -16,7 +16,8 @@
{ "type": "null" },
{ "$ref": "../pipeline/basic.json" }
]
- }
+ },
+ "project_id": { "type": "integer" }
}
}
]
diff --git a/spec/fixtures/api/schemas/public_api/v4/commits_details.json b/spec/fixtures/api/schemas/public_api/v4/commits_details.json
new file mode 100644
index 00000000000..1f5b1ad86ef
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/commits_details.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "commit/detail.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/issue.json b/spec/fixtures/api/schemas/public_api/v4/issue.json
new file mode 100644
index 00000000000..147f53239e0
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/issue.json
@@ -0,0 +1,96 @@
+{
+ "type": "object",
+ "properties" : {
+ "id": { "type": "integer" },
+ "iid": { "type": "integer" },
+ "project_id": { "type": "integer" },
+ "title": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "state": { "type": "string" },
+ "discussion_locked": { "type": ["boolean", "null"] },
+ "closed_at": { "type": "date" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "labels": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "milestone": {
+ "type": ["object", "null"],
+ "properties": {
+ "id": { "type": "integer" },
+ "iid": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
+ "title": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "state": { "type": "string" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "due_date": { "type": "date" },
+ "start_date": { "type": "date" }
+ },
+ "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": {
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "id": { "type": "integer" },
+ "state": { "type": "string" },
+ "avatar_url": { "type": "uri" },
+ "web_url": { "type": "uri" }
+ },
+ "additionalProperties": false
+ },
+ "author": {
+ "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
+ },
+ "user_notes_count": { "type": "integer" },
+ "upvotes": { "type": "integer" },
+ "downvotes": { "type": "integer" },
+ "due_date": { "type": ["date", "null"] },
+ "confidential": { "type": "boolean" },
+ "web_url": { "type": "uri" },
+ "time_stats": {
+ "time_estimate": { "type": "integer" },
+ "total_time_spent": { "type": "integer" },
+ "human_time_estimate": { "type": ["string", "null"] },
+ "human_total_time_spent": { "type": ["string", "null"] }
+ }
+ },
+ "required": [
+ "id", "iid", "project_id", "title", "description",
+ "state", "created_at", "updated_at", "labels",
+ "milestone", "assignees", "author", "user_notes_count",
+ "upvotes", "downvotes", "due_date", "confidential",
+ "web_url"
+ ]
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json
index 5c08dbc3b96..c76806705e8 100644
--- a/spec/fixtures/api/schemas/public_api/v4/issues.json
+++ b/spec/fixtures/api/schemas/public_api/v4/issues.json
@@ -3,98 +3,7 @@
"items": {
"type": "object",
"properties" : {
- "id": { "type": "integer" },
- "iid": { "type": "integer" },
- "project_id": { "type": "integer" },
- "title": { "type": "string" },
- "description": { "type": ["string", "null"] },
- "state": { "type": "string" },
- "discussion_locked": { "type": ["boolean", "null"] },
- "closed_at": { "type": "date" },
- "created_at": { "type": "date" },
- "updated_at": { "type": "date" },
- "labels": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "milestone": {
- "type": "object",
- "properties": {
- "id": { "type": "integer" },
- "iid": { "type": "integer" },
- "project_id": { "type": ["integer", "null"] },
- "group_id": { "type": ["integer", "null"] },
- "title": { "type": "string" },
- "description": { "type": ["string", "null"] },
- "state": { "type": "string" },
- "created_at": { "type": "date" },
- "updated_at": { "type": "date" },
- "due_date": { "type": "date" },
- "start_date": { "type": "date" }
- },
- "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": {
- "name": { "type": "string" },
- "username": { "type": "string" },
- "id": { "type": "integer" },
- "state": { "type": "string" },
- "avatar_url": { "type": "uri" },
- "web_url": { "type": "uri" }
- },
- "additionalProperties": false
- },
- "author": {
- "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
- },
- "user_notes_count": { "type": "integer" },
- "upvotes": { "type": "integer" },
- "downvotes": { "type": "integer" },
- "due_date": { "type": ["date", "null"] },
- "confidential": { "type": "boolean" },
- "web_url": { "type": "uri" },
- "time_stats": {
- "time_estimate": { "type": "integer" },
- "total_time_spent": { "type": "integer" },
- "human_time_estimate": { "type": ["string", "null"] },
- "human_total_time_spent": { "type": ["string", "null"] }
- }
- },
- "required": [
- "id", "iid", "project_id", "title", "description",
- "state", "created_at", "updated_at", "labels",
- "milestone", "assignees", "author", "user_notes_count",
- "upvotes", "downvotes", "due_date", "confidential",
- "web_url"
- ],
- "additionalProperties": false
+ "$ref": "./issue.json"
+ }
}
}
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 034509091a5..0dc2eabec5d 100644
--- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
+++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
@@ -28,7 +28,7 @@
"additionalProperties": false
},
"assignee": {
- "type": "object",
+ "type": ["object", "null"],
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
@@ -80,7 +80,8 @@
"total_time_spent": { "type": "integer" },
"human_time_estimate": { "type": ["string", "null"] },
"human_total_time_spent": { "type": ["string", "null"] }
- }
+ },
+ "allow_maintainer_to_push": { "type": ["boolean", "null"] }
},
"required": [
"id", "iid", "project_id", "title", "description",
diff --git a/spec/fixtures/api/schemas/public_api/v4/milestones.json b/spec/fixtures/api/schemas/public_api/v4/milestones.json
new file mode 100644
index 00000000000..c3c42b6ee60
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/milestones.json
@@ -0,0 +1,24 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties" : {
+ "id": { "type": "integer" },
+ "iid": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
+ "title": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "state": { "type": "string" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "start_date": { "type": "date" },
+ "due_date": { "type": "date" }
+ },
+ "required": [
+ "id", "iid", "title", "description", "state",
+ "state", "created_at", "updated_at", "start_date", "due_date"
+ ],
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/notes.json b/spec/fixtures/api/schemas/public_api/v4/notes.json
new file mode 100644
index 00000000000..4c4ca3b582f
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/notes.json
@@ -0,0 +1,35 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties" : {
+ "id": { "type": "integer" },
+ "type": { "type": ["string", "null"] },
+ "body": { "type": "string" },
+ "attachment": { "type": ["string", "null"] },
+ "author": {
+ "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
+ },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "system": { "type": "boolean" },
+ "noteable_id": { "type": "integer" },
+ "noteable_iid": { "type": "integer" },
+ "noteable_type": { "type": "string" }
+ },
+ "required": [
+ "id", "body", "attachment", "author", "created_at", "updated_at",
+ "system", "noteable_id", "noteable_type"
+ ],
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json
index 4ba6422406c..ed8ed9085c0 100644
--- a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json
+++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json
@@ -3,6 +3,10 @@
"properties": {
"domain": { "type": "string" },
"url": { "type": "uri" },
+ "project_id": { "type": "integer" },
+ "verified": { "type": "boolean" },
+ "verification_code": { "type": ["string", "null"] },
+ "enabled_until": { "type": ["date", "null"] },
"certificate_expiration": {
"type": "object",
"properties": {
@@ -13,6 +17,6 @@
"additionalProperties": false
}
},
- "required": ["domain", "url"],
+ "required": ["domain", "url", "project_id", "verified", "verification_code", "enabled_until"],
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json
index 08db8d47050..b57d544f896 100644
--- a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json
+++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json
@@ -3,6 +3,9 @@
"properties": {
"domain": { "type": "string" },
"url": { "type": "uri" },
+ "verified": { "type": "boolean" },
+ "verification_code": { "type": ["string", "null"] },
+ "enabled_until": { "type": ["date", "null"] },
"certificate": {
"type": "object",
"properties": {
@@ -15,6 +18,6 @@
"additionalProperties": false
}
},
- "required": ["domain", "url"],
+ "required": ["domain", "url", "verified", "verification_code", "enabled_until"],
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/pipelines.json b/spec/fixtures/api/schemas/public_api/v4/pipelines.json
new file mode 100644
index 00000000000..8b08a00f708
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/pipelines.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "pipeline/basic.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/project/export_status.json b/spec/fixtures/api/schemas/public_api/v4/project/export_status.json
new file mode 100644
index 00000000000..81c8815caf6
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/project/export_status.json
@@ -0,0 +1,24 @@
+{
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "identity.json"
+ },
+ {
+ "required": [
+ "export_status"
+ ],
+ "properties": {
+ "export_status": {
+ "type": "string",
+ "enum": [
+ "none",
+ "started",
+ "finished",
+ "after_export_action"
+ ]
+ }
+ }
+ }
+ ]
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/project/identity.json b/spec/fixtures/api/schemas/public_api/v4/project/identity.json
new file mode 100644
index 00000000000..e35ab023d44
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/project/identity.json
@@ -0,0 +1,21 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "description",
+ "name",
+ "name_with_namespace",
+ "path",
+ "path_with_namespace",
+ "created_at"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "description": { "type": ["string", "null"] },
+ "name": { "type": "string" },
+ "name_with_namespace": { "type": "string" },
+ "path": { "type": "string" },
+ "path_with_namespace": { "type": "string" },
+ "created_at": { "type": "date" }
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/projects.json b/spec/fixtures/api/schemas/public_api/v4/projects.json
new file mode 100644
index 00000000000..d89eeea89a5
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/projects.json
@@ -0,0 +1,36 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties" : {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "name_with_namespace": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "path": { "type": "string" },
+ "path_with_namespace": { "type": "string" },
+ "created_at": { "type": "date" },
+ "default_branch": { "type": ["string", "null"] },
+ "tag_list": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "ssh_url_to_repo": { "type": "string" },
+ "http_url_to_repo": { "type": "string" },
+ "web_url": { "type": "string" },
+ "avatar_url": { "type": ["string", "null"] },
+ "star_count": { "type": "integer" },
+ "forks_count": { "type": "integer" },
+ "last_activity_at": { "type": "date" }
+ },
+ "required": [
+ "id", "name", "name_with_namespace", "description", "path",
+ "path_with_namespace", "created_at", "default_branch", "tag_list",
+ "ssh_url_to_repo", "http_url_to_repo", "web_url", "avatar_url",
+ "star_count", "last_activity_at"
+ ],
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/snippets.json b/spec/fixtures/api/schemas/public_api/v4/snippets.json
new file mode 100644
index 00000000000..e37e9704649
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/snippets.json
@@ -0,0 +1,33 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties" : {
+ "id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "title": { "type": "string" },
+ "file_name": { "type": ["string", "null"] },
+ "description": { "type": ["string", "null"] },
+ "web_url": { "type": "string" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "author": {
+ "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", "title", "file_name", "description", "web_url",
+ "created_at", "updated_at", "author"
+ ],
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/tag.json b/spec/fixtures/api/schemas/public_api/v4/tag.json
index 52cfe86aeeb..10d4edb7ffb 100644
--- a/spec/fixtures/api/schemas/public_api/v4/tag.json
+++ b/spec/fixtures/api/schemas/public_api/v4/tag.json
@@ -10,6 +10,7 @@
"name": { "type": "string" },
"message": { "type": ["string", "null"] },
"commit": { "$ref": "commit/basic.json" },
+ "target": { "type": "string" },
"release": {
"oneOf": [
{ "type": "null" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/user/basic.json b/spec/fixtures/api/schemas/public_api/v4/user/basic.json
index 9f69d31971c..2d815be32a6 100644
--- a/spec/fixtures/api/schemas/public_api/v4/user/basic.json
+++ b/spec/fixtures/api/schemas/public_api/v4/user/basic.json
@@ -1,13 +1,17 @@
{
- "type": "object",
+ "type": ["object", "null"],
"required": [
"id",
+ "name",
+ "username",
"state",
"avatar_url",
"web_url"
],
"properties": {
"id": { "type": "integer" },
+ "name": { "type": "string" },
+ "username": { "type": "string" },
"state": { "type": "string" },
"avatar_url": { "type": "string" },
"web_url": { "type": "string" }
diff --git a/spec/fixtures/api/schemas/variable.json b/spec/fixtures/api/schemas/variable.json
new file mode 100644
index 00000000000..6f6b044115b
--- /dev/null
+++ b/spec/fixtures/api/schemas/variable.json
@@ -0,0 +1,17 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "key",
+ "value",
+ "protected"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "key": { "type": "string" },
+ "value": { "type": "string" },
+ "protected": { "type": "boolean" },
+ "environment_scope": { "type": "string", "optional": true }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/variables.json b/spec/fixtures/api/schemas/variables.json
new file mode 100644
index 00000000000..8002f39a7b8
--- /dev/null
+++ b/spec/fixtures/api/schemas/variables.json
@@ -0,0 +1,11 @@
+{
+ "type": "object",
+ "required": ["variables"],
+ "properties": {
+ "variables": {
+ "type": "array",
+ "items": { "$ref": "variable.json" }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/emails/attachment.eml b/spec/fixtures/emails/attachment.eml
index f25c3d1a449..b3a30b3221b 100644
--- a/spec/fixtures/emails/attachment.eml
+++ b/spec/fixtures/emails/attachment.eml
@@ -91,7 +91,7 @@ x #ccc solid;padding-left:1ex"><div>
adding=3D"0" border=3D"0"><tbody>
<tr>
<td style=3D"vertical-align:top;width:55px">
- <img src=3D"http://www.gravatar.com/avatar/42776c4982dff1fa45ee8248=
+ <img src=3D"https://www.gravatar.com/avatar/42776c4982dff1fa45ee8248=
532f8ad0.png?s=3D45&amp;r=3Dpg&amp;d=3Didenticon" title=3D"Neil" style=3D"m=
ax-width:694px" width=3D"45" height=3D"45">
</td>
@@ -121,7 +121,7 @@ nk">@eviltrout</a> Any idea why it showed up in suggested topics? </p>
<div style=3D"color:#666">
<p>To respond, reply to this email or visit <a href=3D"http://meta.disc=
ourse.org/t/spam-post-pops-back-up-in-suggested-topics/11005/5" style=3D"co=
-lor:#666" target=3D"_blank">http://meta.discourse.org/t/spam-post-pops-back=
+lor:#666" target=3D"_blank">https://meta.discourse.org/t/spam-post-pops-back=
-up-in-suggested-topics/11005/5</a> in your browser.</p>
</div>
@@ -132,12 +132,12 @@ lor:#666" target=3D"_blank">http://meta.discourse.org/t/spam-post-pops-back=
lpadding=3D"0" border=3D"0"><tbody>
<tr>
<td style=3D"vertical-align:top;width:55px">
- <img src=3D"http://www.gravatar.com/avatar/42776c4982dff1fa45ee8248=
+ <img src=3D"https://www.gravatar.com/avatar/42776c4982dff1fa45ee8248=
532f8ad0.png?s=3D45&amp;r=3Dpg&amp;d=3Didenticon" title=3D"Neil" style=3D"m=
ax-width:694px" width=3D"45" height=3D"45">
</td>
<td>
- <a href=3D"http://meta.discourse.org/users/neil" style=3D"font-size=
+ <a href=3D"https://meta.discourse.org/users/neil" style=3D"font-size=
:13px;font-family:&#39;lucida grande&#39;,tahoma,verdana,arial,sans-serif;c=
olor:#3b5998;text-decoration:none;font-weight:bold" target=3D"_blank">Neil<=
/a><br>
@@ -155,12 +155,12 @@ vember 19</span>
adding=3D"0" border=3D"0"><tbody>
<tr>
<td style=3D"vertical-align:top;width:55px">
- <img src=3D"http://www.gravatar.com/avatar/5120fc4e345db0d1a9648882=
+ <img src=3D"https://www.gravatar.com/avatar/5120fc4e345db0d1a9648882=
72073819.png?s=3D45&amp;r=3Dpg&amp;d=3Didenticon" title=3D"riking" style=3D=
"max-width:694px" width=3D"45" height=3D"45">
</td>
<td>
- <a href=3D"http://meta.discourse.org/users/riking" style=3D"font-si=
+ <a href=3D"https://meta.discourse.org/users/riking" style=3D"font-si=
ze:13px;font-family:&#39;lucida grande&#39;,tahoma,verdana,arial,sans-serif=
;color:#3b5998;text-decoration:none;font-weight:bold" target=3D"_blank">rik=
ing</a><br>
@@ -173,7 +173,7 @@ vember 19</span>
<td style=3D"padding-top:5px" colspan=3D"2">
<p style=3D"margin-top:0"><u></u></p><div>
<div></div>
-<img width=3D"20" height=3D"20" src=3D"http://www.gravatar.com/avatar/51d62=
+<img width=3D"20" height=3D"20" src=3D"https://www.gravatar.com/avatar/51d62=
3f33f8b83095db84ff35e15dbe8.png?s=3D40&amp;r=3Dpg&amp;d=3Didenticon" style=
=3D"max-width:694px">codinghorror:</div>
<blockquote><p style=3D"margin-top:0">I can&#39;t even find that topic by n=
@@ -193,12 +193,12 @@ uld be invisible to me, and not showing up in Suggested Topics.</p>
adding=3D"0" border=3D"0"><tbody>
<tr>
<td style=3D"vertical-align:top;width:55px">
- <img src=3D"http://www.gravatar.com/avatar/51d623f33f8b83095db84ff3=
+ <img src=3D"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff3=
5e15dbe8.png?s=3D45&amp;r=3Dpg&amp;d=3Didenticon" title=3D"codinghorror" st=
yle=3D"max-width:694px" width=3D"45" height=3D"45">
</td>
<td>
- <a href=3D"http://meta.discourse.org/users/codinghorror" style=3D"f=
+ <a href=3D"https://meta.discourse.org/users/codinghorror" style=3D"f=
ont-size:13px;font-family:&#39;lucida grande&#39;,tahoma,verdana,arial,sans=
-serif;color:#3b5998;text-decoration:none;font-weight:bold" target=3D"_blan=
k">codinghorror</a><br>
@@ -219,12 +219,12 @@ rout" target=3D"_blank">@eviltrout</a>? I can&#39;t even find that topic by=
adding=3D"0" border=3D"0"><tbody>
<tr>
<td style=3D"vertical-align:top;width:55px">
- <img src=3D"http://www.gravatar.com/avatar/5120fc4e345db0d1a9648882=
+ <img src=3D"https://www.gravatar.com/avatar/5120fc4e345db0d1a9648882=
72073819.png?s=3D45&amp;r=3Dpg&amp;d=3Didenticon" title=3D"riking" style=3D=
"max-width:694px" width=3D"45" height=3D"45">
</td>
<td>
- <a href=3D"http://meta.discourse.org/users/riking" style=3D"font-si=
+ <a href=3D"https://meta.discourse.org/users/riking" style=3D"font-si=
ze:13px;font-family:&#39;lucida grande&#39;,tahoma,verdana,arial,sans-serif=
;color:#3b5998;text-decoration:none;font-weight:bold" target=3D"_blank">rik=
ing</a><br>
@@ -241,7 +241,7 @@ lar spam post, and it was promptly deleted/hidden, but it just popped up in=
<p style=3D"margin-top:0"></p>
<div><a href=3D"//cdn.discourse.org/uploads/meta_discourse/2158/50b8b49557c=
-b249e.png" target=3D"_blank"><img src=3D"http://cdn.discourse.org/uploads/m=
+b249e.png" target=3D"_blank"><img src=3D"https://cdn.discourse.org/uploads/m=
eta_discourse/_optimized/ab1/c92/acd2c33402_584x134.png" width=3D"584" heig=
ht=3D"134" style=3D"max-width:694px"><div>
@@ -257,12 +257,12 @@ ht=3D"134" style=3D"max-width:694px"><div>
<div style=3D"color:#666">
<p>To respond, reply to this email or visit <a href=3D"http://meta.discours=
e.org/t/spam-post-pops-back-up-in-suggested-topics/11005/5" style=3D"color:=
-#666" target=3D"_blank">http://meta.discourse.org/t/spam-post-pops-back-up-=
+#666" target=3D"_blank">https://meta.discourse.org/t/spam-post-pops-back-up-=
in-suggested-topics/11005/5</a> in your browser.</p>
</div>
<div style=3D"color:#666">
-<p>To unsubscribe from these emails, visit your <a href=3D"http://meta.disc=
+<p>To unsubscribe from these emails, visit your <a href=3D"https://meta.disc=
ourse.org/user_preferences" style=3D"color:#666" target=3D"_blank">user pre=
ferences</a>.</p>
</div>
diff --git a/spec/fixtures/emails/update_commands_only_reply.eml b/spec/fixtures/emails/update_commands_only_reply.eml
new file mode 100644
index 00000000000..bb0d2b0e03a
--- /dev/null
+++ b/spec/fixtures/emails/update_commands_only_reply.eml
@@ -0,0 +1,38 @@
+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 <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
+Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@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>
+To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo
+Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
+In-Reply-To: <issue_1@localhost>
+References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>
+Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'
+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
+
+/close
+
+On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta
+<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote:
+>
+>
+>
+> eviltrout posted in 'Adventure Time Sux' on Discourse Meta:
+>
+> ---
+> hey guys everyone knows adventure time sucks!
+>
+> ---
+> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3
+>
+> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences).
+>
diff --git a/spec/fixtures/exported-project.gz b/spec/fixtures/exported-project.gz
new file mode 100644
index 00000000000..352384f16c8
--- /dev/null
+++ b/spec/fixtures/exported-project.gz
Binary files differ
diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb
index 71abb6da607..da32a46675f 100644
--- a/spec/fixtures/markdown.md.erb
+++ b/spec/fixtures/markdown.md.erb
@@ -280,6 +280,18 @@ However the wrapping tags cannot be mixed as such:
![My Video](/assets/videos/gitlab-demo.mp4)
+### Colors
+
+`#F00`
+`#F00A`
+`#FF0000`
+`#FF0000AA`
+`RGB(0,255,0)`
+`RGB(0%,100%,0%)`
+`RGBA(0,255,0,0.7)`
+`HSL(540,70%,50%)`
+`HSLA(540,70%,50%,0.7)`
+
### Mermaid
> If this is not rendered correctly, see
diff --git a/spec/fixtures/trace/sample_trace b/spec/fixtures/trace/sample_trace
new file mode 100644
index 00000000000..55fcb9d2756
--- /dev/null
+++ b/spec/fixtures/trace/sample_trace
@@ -0,0 +1,1185 @@
+Running with gitlab-runner 10.4.0 (857480b6)
+ on docker-auto-scale-com (9a6801bd)
+Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6 ...
+Starting service postgres:9.2 ...
+Pulling docker image postgres:9.2 ...
+Using docker image postgres:9.2 ID=sha256:18cdbca56093c841d28e629eb8acd4224afe0aa4c57c839351fc181888b8a470 for postgres service...
+Starting service redis:alpine ...
+Pulling docker image redis:alpine ...
+Using docker image redis:alpine ID=sha256:cb1ec54b370d4a91dff57d00f91fd880dc710160a58440adaa133e0f84ae999d for redis service...
+Waiting for services to be up and running...
+Using docker image sha256:3006a02a5a6f0a116358a13bbc46ee46fb2471175efd5b7f9b1c22345ec2a8e9 for predefined container...
+Pulling docker image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6 ...
+Using docker image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.6-golang-1.9-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6 ID=sha256:1f59be408f12738509ffe4177d65e9de6391f32461de83d9d45f58517b30af99 for build container...
+section_start:1517486886:prepare_script
+Running on runner-9a6801bd-project-13083-concurrent-0 via runner-9a6801bd-gsrm-1517484168-a8449153...
+section_end:1517486887:prepare_script
+section_start:1517486887:get_sources
+Fetching changes for 42624-gitaly-bundle-isolation-not-working-in-ci with git depth set to 20...
+Removing .gitlab_shell_secret
+Removing .gitlab_workhorse_secret
+Removing .yarn-cache/
+Removing config/database.yml
+Removing config/gitlab.yml
+Removing config/redis.cache.yml
+Removing config/redis.queues.yml
+Removing config/redis.shared_state.yml
+Removing config/resque.yml
+Removing config/secrets.yml
+Removing coverage/
+Removing knapsack/
+Removing log/api_json.log
+Removing log/application.log
+Removing log/gitaly-test.log
+Removing log/githost.log
+Removing log/grpc.log
+Removing log/test_json.log
+Removing node_modules/
+Removing public/assets/
+Removing rspec_flaky/
+Removing shared/tmp/
+Removing tmp/tests/
+Removing vendor/ruby/
+HEAD is now at 4cea24f Converted todos.js to axios
+From https://gitlab.com/gitlab-org/gitlab-ce
+ * [new branch] 42624-gitaly-bundle-isolation-not-working-in-ci -> origin/42624-gitaly-bundle-isolation-not-working-in-ci
+Checking out f42a5e24 as 42624-gitaly-bundle-isolation-not-working-in-ci...
+Skipping Git submodules setup
+section_end:1517486896:get_sources
+section_start:1517486896:restore_cache
+Checking cache for ruby-2.3.6-with-yarn...
+Downloading cache.zip from http://runners-cache-5-internal.gitlab.com:444/runner/project/13083/ruby-2.3.6-with-yarn
+Successfully extracted cache
+section_end:1517486919:restore_cache
+section_start:1517486919:download_artifacts
+Downloading artifacts for retrieve-tests-metadata (50551658)...
+Downloading artifacts from coordinator... ok  id=50551658 responseStatus=200 OK token=HhF7y_1X
+Downloading artifacts for compile-assets (50551659)...
+Downloading artifacts from coordinator... ok  id=50551659 responseStatus=200 OK token=wTz6JrCP
+Downloading artifacts for setup-test-env (50551660)...
+Downloading artifacts from coordinator... ok  id=50551660 responseStatus=200 OK token=DTGgeVF5
+WARNING: tmp/tests/gitlab-shell/.gitlab_shell_secret: chmod tmp/tests/gitlab-shell/.gitlab_shell_secret: no such file or directory (suppressing repeats)
+section_end:1517486934:download_artifacts
+section_start:1517486934:build_script
+$ bundle --version
+Bundler version 1.16.1
+$ source scripts/utils.sh
+$ source scripts/prepare_build.sh
+The Gemfile's dependencies are satisfied
+Successfully installed knapsack-1.15.0
+1 gem installed
+NOTICE: database "gitlabhq_test" does not exist, skipping
+DROP DATABASE
+CREATE DATABASE
+CREATE ROLE
+GRANT
+-- enable_extension("plpgsql")
+ -> 0.0156s
+-- enable_extension("pg_trgm")
+ -> 0.0156s
+-- create_table("abuse_reports", {:force=>:cascade})
+ -> 0.0119s
+-- create_table("appearances", {:force=>:cascade})
+ -> 0.0065s
+-- create_table("application_settings", {:force=>:cascade})
+ -> 0.0382s
+-- create_table("audit_events", {:force=>:cascade})
+ -> 0.0056s
+-- add_index("audit_events", ["entity_id", "entity_type"], {:name=>"index_audit_events_on_entity_id_and_entity_type", :using=>:btree})
+ -> 0.0040s
+-- create_table("award_emoji", {:force=>:cascade})
+ -> 0.0058s
+-- add_index("award_emoji", ["awardable_type", "awardable_id"], {:name=>"index_award_emoji_on_awardable_type_and_awardable_id", :using=>:btree})
+ -> 0.0068s
+-- add_index("award_emoji", ["user_id", "name"], {:name=>"index_award_emoji_on_user_id_and_name", :using=>:btree})
+ -> 0.0043s
+-- create_table("boards", {:force=>:cascade})
+ -> 0.0049s
+-- add_index("boards", ["project_id"], {:name=>"index_boards_on_project_id", :using=>:btree})
+ -> 0.0056s
+-- create_table("broadcast_messages", {:force=>:cascade})
+ -> 0.0056s
+-- add_index("broadcast_messages", ["starts_at", "ends_at", "id"], {:name=>"index_broadcast_messages_on_starts_at_and_ends_at_and_id", :using=>:btree})
+ -> 0.0041s
+-- create_table("chat_names", {:force=>:cascade})
+ -> 0.0056s
+-- add_index("chat_names", ["service_id", "team_id", "chat_id"], {:name=>"index_chat_names_on_service_id_and_team_id_and_chat_id", :unique=>true, :using=>:btree})
+ -> 0.0039s
+-- add_index("chat_names", ["user_id", "service_id"], {:name=>"index_chat_names_on_user_id_and_service_id", :unique=>true, :using=>:btree})
+ -> 0.0036s
+-- create_table("chat_teams", {:force=>:cascade})
+ -> 0.0068s
+-- add_index("chat_teams", ["namespace_id"], {:name=>"index_chat_teams_on_namespace_id", :unique=>true, :using=>:btree})
+ -> 0.0098s
+-- create_table("ci_build_trace_section_names", {:force=>:cascade})
+ -> 0.0048s
+-- add_index("ci_build_trace_section_names", ["project_id", "name"], {:name=>"index_ci_build_trace_section_names_on_project_id_and_name", :unique=>true, :using=>:btree})
+ -> 0.0035s
+-- create_table("ci_build_trace_sections", {:force=>:cascade})
+ -> 0.0040s
+-- add_index("ci_build_trace_sections", ["build_id", "section_name_id"], {:name=>"index_ci_build_trace_sections_on_build_id_and_section_name_id", :unique=>true, :using=>:btree})
+ -> 0.0035s
+-- add_index("ci_build_trace_sections", ["project_id"], {:name=>"index_ci_build_trace_sections_on_project_id", :using=>:btree})
+ -> 0.0033s
+-- create_table("ci_builds", {:force=>:cascade})
+ -> 0.0062s
+-- add_index("ci_builds", ["auto_canceled_by_id"], {:name=>"index_ci_builds_on_auto_canceled_by_id", :using=>:btree})
+ -> 0.0035s
+-- 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})
+ -> 0.0032s
+-- add_index("ci_builds", ["commit_id", "status", "type"], {:name=>"index_ci_builds_on_commit_id_and_status_and_type", :using=>:btree})
+ -> 0.0032s
+-- add_index("ci_builds", ["commit_id", "type", "name", "ref"], {:name=>"index_ci_builds_on_commit_id_and_type_and_name_and_ref", :using=>:btree})
+ -> 0.0035s
+-- add_index("ci_builds", ["commit_id", "type", "ref"], {:name=>"index_ci_builds_on_commit_id_and_type_and_ref", :using=>:btree})
+ -> 0.0042s
+-- add_index("ci_builds", ["project_id", "id"], {:name=>"index_ci_builds_on_project_id_and_id", :using=>:btree})
+ -> 0.0031s
+-- add_index("ci_builds", ["protected"], {:name=>"index_ci_builds_on_protected", :using=>:btree})
+ -> 0.0031s
+-- add_index("ci_builds", ["runner_id"], {:name=>"index_ci_builds_on_runner_id", :using=>:btree})
+ -> 0.0033s
+-- add_index("ci_builds", ["stage_id"], {:name=>"index_ci_builds_on_stage_id", :using=>:btree})
+ -> 0.0035s
+-- add_index("ci_builds", ["status", "type", "runner_id"], {:name=>"index_ci_builds_on_status_and_type_and_runner_id", :using=>:btree})
+ -> 0.0031s
+-- add_index("ci_builds", ["status"], {:name=>"index_ci_builds_on_status", :using=>:btree})
+ -> 0.0032s
+-- add_index("ci_builds", ["token"], {:name=>"index_ci_builds_on_token", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- add_index("ci_builds", ["updated_at"], {:name=>"index_ci_builds_on_updated_at", :using=>:btree})
+ -> 0.0047s
+-- add_index("ci_builds", ["user_id"], {:name=>"index_ci_builds_on_user_id", :using=>:btree})
+ -> 0.0029s
+-- create_table("ci_group_variables", {:force=>:cascade})
+ -> 0.0055s
+-- add_index("ci_group_variables", ["group_id", "key"], {:name=>"index_ci_group_variables_on_group_id_and_key", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- create_table("ci_job_artifacts", {:force=>:cascade})
+ -> 0.0048s
+-- add_index("ci_job_artifacts", ["job_id", "file_type"], {:name=>"index_ci_job_artifacts_on_job_id_and_file_type", :unique=>true, :using=>:btree})
+ -> 0.0027s
+-- add_index("ci_job_artifacts", ["project_id"], {:name=>"index_ci_job_artifacts_on_project_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("ci_pipeline_schedule_variables", {:force=>:cascade})
+ -> 0.0044s
+-- 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})
+ -> 0.0032s
+-- create_table("ci_pipeline_schedules", {:force=>:cascade})
+ -> 0.0047s
+-- add_index("ci_pipeline_schedules", ["next_run_at", "active"], {:name=>"index_ci_pipeline_schedules_on_next_run_at_and_active", :using=>:btree})
+ -> 0.0029s
+-- add_index("ci_pipeline_schedules", ["project_id"], {:name=>"index_ci_pipeline_schedules_on_project_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("ci_pipeline_variables", {:force=>:cascade})
+ -> 0.0045s
+-- add_index("ci_pipeline_variables", ["pipeline_id", "key"], {:name=>"index_ci_pipeline_variables_on_pipeline_id_and_key", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- create_table("ci_pipelines", {:force=>:cascade})
+ -> 0.0057s
+-- add_index("ci_pipelines", ["auto_canceled_by_id"], {:name=>"index_ci_pipelines_on_auto_canceled_by_id", :using=>:btree})
+ -> 0.0030s
+-- add_index("ci_pipelines", ["pipeline_schedule_id"], {:name=>"index_ci_pipelines_on_pipeline_schedule_id", :using=>:btree})
+ -> 0.0031s
+-- add_index("ci_pipelines", ["project_id", "ref", "status", "id"], {:name=>"index_ci_pipelines_on_project_id_and_ref_and_status_and_id", :using=>:btree})
+ -> 0.0032s
+-- add_index("ci_pipelines", ["project_id", "sha"], {:name=>"index_ci_pipelines_on_project_id_and_sha", :using=>:btree})
+ -> 0.0032s
+-- add_index("ci_pipelines", ["project_id"], {:name=>"index_ci_pipelines_on_project_id", :using=>:btree})
+ -> 0.0035s
+-- add_index("ci_pipelines", ["status"], {:name=>"index_ci_pipelines_on_status", :using=>:btree})
+ -> 0.0032s
+-- add_index("ci_pipelines", ["user_id"], {:name=>"index_ci_pipelines_on_user_id", :using=>:btree})
+ -> 0.0029s
+-- create_table("ci_runner_projects", {:force=>:cascade})
+ -> 0.0035s
+-- add_index("ci_runner_projects", ["project_id"], {:name=>"index_ci_runner_projects_on_project_id", :using=>:btree})
+ -> 0.0029s
+-- add_index("ci_runner_projects", ["runner_id"], {:name=>"index_ci_runner_projects_on_runner_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("ci_runners", {:force=>:cascade})
+ -> 0.0059s
+-- add_index("ci_runners", ["contacted_at"], {:name=>"index_ci_runners_on_contacted_at", :using=>:btree})
+ -> 0.0030s
+-- add_index("ci_runners", ["is_shared"], {:name=>"index_ci_runners_on_is_shared", :using=>:btree})
+ -> 0.0030s
+-- add_index("ci_runners", ["locked"], {:name=>"index_ci_runners_on_locked", :using=>:btree})
+ -> 0.0030s
+-- add_index("ci_runners", ["token"], {:name=>"index_ci_runners_on_token", :using=>:btree})
+ -> 0.0029s
+-- create_table("ci_stages", {:force=>:cascade})
+ -> 0.0046s
+-- add_index("ci_stages", ["pipeline_id", "name"], {:name=>"index_ci_stages_on_pipeline_id_and_name", :using=>:btree})
+ -> 0.0031s
+-- add_index("ci_stages", ["pipeline_id"], {:name=>"index_ci_stages_on_pipeline_id", :using=>:btree})
+ -> 0.0030s
+-- add_index("ci_stages", ["project_id"], {:name=>"index_ci_stages_on_project_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("ci_trigger_requests", {:force=>:cascade})
+ -> 0.0058s
+-- add_index("ci_trigger_requests", ["commit_id"], {:name=>"index_ci_trigger_requests_on_commit_id", :using=>:btree})
+ -> 0.0031s
+-- create_table("ci_triggers", {:force=>:cascade})
+ -> 0.0043s
+-- add_index("ci_triggers", ["project_id"], {:name=>"index_ci_triggers_on_project_id", :using=>:btree})
+ -> 0.0033s
+-- create_table("ci_variables", {:force=>:cascade})
+ -> 0.0059s
+-- 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})
+ -> 0.0031s
+-- create_table("cluster_platforms_kubernetes", {:force=>:cascade})
+ -> 0.0053s
+-- add_index("cluster_platforms_kubernetes", ["cluster_id"], {:name=>"index_cluster_platforms_kubernetes_on_cluster_id", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- create_table("cluster_projects", {:force=>:cascade})
+ -> 0.0032s
+-- add_index("cluster_projects", ["cluster_id"], {:name=>"index_cluster_projects_on_cluster_id", :using=>:btree})
+ -> 0.0035s
+-- add_index("cluster_projects", ["project_id"], {:name=>"index_cluster_projects_on_project_id", :using=>:btree})
+ -> 0.0030s
+-- create_table("cluster_providers_gcp", {:force=>:cascade})
+ -> 0.0051s
+-- add_index("cluster_providers_gcp", ["cluster_id"], {:name=>"index_cluster_providers_gcp_on_cluster_id", :unique=>true, :using=>:btree})
+ -> 0.0034s
+-- create_table("clusters", {:force=>:cascade})
+ -> 0.0052s
+-- add_index("clusters", ["enabled"], {:name=>"index_clusters_on_enabled", :using=>:btree})
+ -> 0.0031s
+-- add_index("clusters", ["user_id"], {:name=>"index_clusters_on_user_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("clusters_applications_helm", {:force=>:cascade})
+ -> 0.0045s
+-- create_table("clusters_applications_ingress", {:force=>:cascade})
+ -> 0.0044s
+-- create_table("clusters_applications_prometheus", {:force=>:cascade})
+ -> 0.0047s
+-- create_table("container_repositories", {:force=>:cascade})
+ -> 0.0050s
+-- add_index("container_repositories", ["project_id", "name"], {:name=>"index_container_repositories_on_project_id_and_name", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- add_index("container_repositories", ["project_id"], {:name=>"index_container_repositories_on_project_id", :using=>:btree})
+ -> 0.0032s
+-- create_table("conversational_development_index_metrics", {:force=>:cascade})
+ -> 0.0076s
+-- create_table("deploy_keys_projects", {:force=>:cascade})
+ -> 0.0037s
+-- add_index("deploy_keys_projects", ["project_id"], {:name=>"index_deploy_keys_projects_on_project_id", :using=>:btree})
+ -> 0.0032s
+-- create_table("deployments", {:force=>:cascade})
+ -> 0.0049s
+-- add_index("deployments", ["created_at"], {:name=>"index_deployments_on_created_at", :using=>:btree})
+ -> 0.0034s
+-- add_index("deployments", ["environment_id", "id"], {:name=>"index_deployments_on_environment_id_and_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("deployments", ["environment_id", "iid", "project_id"], {:name=>"index_deployments_on_environment_id_and_iid_and_project_id", :using=>:btree})
+ -> 0.0029s
+-- add_index("deployments", ["project_id", "iid"], {:name=>"index_deployments_on_project_id_and_iid", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- create_table("emails", {:force=>:cascade})
+ -> 0.0046s
+-- add_index("emails", ["confirmation_token"], {:name=>"index_emails_on_confirmation_token", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- add_index("emails", ["email"], {:name=>"index_emails_on_email", :unique=>true, :using=>:btree})
+ -> 0.0035s
+-- add_index("emails", ["user_id"], {:name=>"index_emails_on_user_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("environments", {:force=>:cascade})
+ -> 0.0052s
+-- add_index("environments", ["project_id", "name"], {:name=>"index_environments_on_project_id_and_name", :unique=>true, :using=>:btree})
+ -> 0.0031s
+-- add_index("environments", ["project_id", "slug"], {:name=>"index_environments_on_project_id_and_slug", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- create_table("events", {:force=>:cascade})
+ -> 0.0046s
+-- add_index("events", ["action"], {:name=>"index_events_on_action", :using=>:btree})
+ -> 0.0032s
+-- add_index("events", ["author_id"], {:name=>"index_events_on_author_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("events", ["project_id", "id"], {:name=>"index_events_on_project_id_and_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("events", ["target_type", "target_id"], {:name=>"index_events_on_target_type_and_target_id", :using=>:btree})
+ -> 0.0027s
+-- create_table("feature_gates", {:force=>:cascade})
+ -> 0.0046s
+-- add_index("feature_gates", ["feature_key", "key", "value"], {:name=>"index_feature_gates_on_feature_key_and_key_and_value", :unique=>true, :using=>:btree})
+ -> 0.0031s
+-- create_table("features", {:force=>:cascade})
+ -> 0.0041s
+-- add_index("features", ["key"], {:name=>"index_features_on_key", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- create_table("fork_network_members", {:force=>:cascade})
+ -> 0.0033s
+-- add_index("fork_network_members", ["fork_network_id"], {:name=>"index_fork_network_members_on_fork_network_id", :using=>:btree})
+ -> 0.0033s
+-- add_index("fork_network_members", ["project_id"], {:name=>"index_fork_network_members_on_project_id", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("fork_networks", {:force=>:cascade})
+ -> 0.0049s
+-- add_index("fork_networks", ["root_project_id"], {:name=>"index_fork_networks_on_root_project_id", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("forked_project_links", {:force=>:cascade})
+ -> 0.0032s
+-- add_index("forked_project_links", ["forked_to_project_id"], {:name=>"index_forked_project_links_on_forked_to_project_id", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- create_table("gcp_clusters", {:force=>:cascade})
+ -> 0.0074s
+-- add_index("gcp_clusters", ["project_id"], {:name=>"index_gcp_clusters_on_project_id", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- create_table("gpg_key_subkeys", {:force=>:cascade})
+ -> 0.0042s
+-- add_index("gpg_key_subkeys", ["fingerprint"], {:name=>"index_gpg_key_subkeys_on_fingerprint", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- add_index("gpg_key_subkeys", ["gpg_key_id"], {:name=>"index_gpg_key_subkeys_on_gpg_key_id", :using=>:btree})
+ -> 0.0032s
+-- add_index("gpg_key_subkeys", ["keyid"], {:name=>"index_gpg_key_subkeys_on_keyid", :unique=>true, :using=>:btree})
+ -> 0.0027s
+-- create_table("gpg_keys", {:force=>:cascade})
+ -> 0.0042s
+-- add_index("gpg_keys", ["fingerprint"], {:name=>"index_gpg_keys_on_fingerprint", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- add_index("gpg_keys", ["primary_keyid"], {:name=>"index_gpg_keys_on_primary_keyid", :unique=>true, :using=>:btree})
+ -> 0.0026s
+-- add_index("gpg_keys", ["user_id"], {:name=>"index_gpg_keys_on_user_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("gpg_signatures", {:force=>:cascade})
+ -> 0.0054s
+-- add_index("gpg_signatures", ["commit_sha"], {:name=>"index_gpg_signatures_on_commit_sha", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- add_index("gpg_signatures", ["gpg_key_id"], {:name=>"index_gpg_signatures_on_gpg_key_id", :using=>:btree})
+ -> 0.0026s
+-- add_index("gpg_signatures", ["gpg_key_primary_keyid"], {:name=>"index_gpg_signatures_on_gpg_key_primary_keyid", :using=>:btree})
+ -> 0.0029s
+-- add_index("gpg_signatures", ["gpg_key_subkey_id"], {:name=>"index_gpg_signatures_on_gpg_key_subkey_id", :using=>:btree})
+ -> 0.0032s
+-- add_index("gpg_signatures", ["project_id"], {:name=>"index_gpg_signatures_on_project_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("group_custom_attributes", {:force=>:cascade})
+ -> 0.0044s
+-- add_index("group_custom_attributes", ["group_id", "key"], {:name=>"index_group_custom_attributes_on_group_id_and_key", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- add_index("group_custom_attributes", ["key", "value"], {:name=>"index_group_custom_attributes_on_key_and_value", :using=>:btree})
+ -> 0.0028s
+-- create_table("identities", {:force=>:cascade})
+ -> 0.0043s
+-- add_index("identities", ["user_id"], {:name=>"index_identities_on_user_id", :using=>:btree})
+ -> 0.0034s
+-- create_table("issue_assignees", {:id=>false, :force=>:cascade})
+ -> 0.0013s
+-- add_index("issue_assignees", ["issue_id", "user_id"], {:name=>"index_issue_assignees_on_issue_id_and_user_id", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- add_index("issue_assignees", ["user_id"], {:name=>"index_issue_assignees_on_user_id", :using=>:btree})
+ -> 0.0029s
+-- create_table("issue_metrics", {:force=>:cascade})
+ -> 0.0032s
+-- add_index("issue_metrics", ["issue_id"], {:name=>"index_issue_metrics", :using=>:btree})
+ -> 0.0029s
+-- create_table("issues", {:force=>:cascade})
+ -> 0.0051s
+-- add_index("issues", ["author_id"], {:name=>"index_issues_on_author_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("issues", ["confidential"], {:name=>"index_issues_on_confidential", :using=>:btree})
+ -> 0.0029s
+-- add_index("issues", ["description"], {:name=>"index_issues_on_description_trigram", :using=>:gin, :opclasses=>{"description"=>"gin_trgm_ops"}})
+ -> 0.0022s
+-- add_index("issues", ["milestone_id"], {:name=>"index_issues_on_milestone_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("issues", ["moved_to_id"], {:name=>"index_issues_on_moved_to_id", :where=>"(moved_to_id IS NOT NULL)", :using=>:btree})
+ -> 0.0030s
+-- add_index("issues", ["project_id", "created_at", "id", "state"], {:name=>"index_issues_on_project_id_and_created_at_and_id_and_state", :using=>:btree})
+ -> 0.0039s
+-- add_index("issues", ["project_id", "due_date", "id", "state"], {:name=>"idx_issues_on_project_id_and_due_date_and_id_and_state_partial", :where=>"(due_date IS NOT NULL)", :using=>:btree})
+ -> 0.0031s
+-- add_index("issues", ["project_id", "iid"], {:name=>"index_issues_on_project_id_and_iid", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- add_index("issues", ["project_id", "updated_at", "id", "state"], {:name=>"index_issues_on_project_id_and_updated_at_and_id_and_state", :using=>:btree})
+ -> 0.0035s
+-- add_index("issues", ["relative_position"], {:name=>"index_issues_on_relative_position", :using=>:btree})
+ -> 0.0030s
+-- add_index("issues", ["state"], {:name=>"index_issues_on_state", :using=>:btree})
+ -> 0.0027s
+-- add_index("issues", ["title"], {:name=>"index_issues_on_title_trigram", :using=>:gin, :opclasses=>{"title"=>"gin_trgm_ops"}})
+ -> 0.0021s
+-- add_index("issues", ["updated_at"], {:name=>"index_issues_on_updated_at", :using=>:btree})
+ -> 0.0030s
+-- add_index("issues", ["updated_by_id"], {:name=>"index_issues_on_updated_by_id", :where=>"(updated_by_id IS NOT NULL)", :using=>:btree})
+ -> 0.0028s
+-- create_table("keys", {:force=>:cascade})
+ -> 0.0048s
+-- add_index("keys", ["fingerprint"], {:name=>"index_keys_on_fingerprint", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- add_index("keys", ["user_id"], {:name=>"index_keys_on_user_id", :using=>:btree})
+ -> 0.0029s
+-- create_table("label_links", {:force=>:cascade})
+ -> 0.0041s
+-- add_index("label_links", ["label_id"], {:name=>"index_label_links_on_label_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("label_links", ["target_id", "target_type"], {:name=>"index_label_links_on_target_id_and_target_type", :using=>:btree})
+ -> 0.0028s
+-- create_table("label_priorities", {:force=>:cascade})
+ -> 0.0031s
+-- add_index("label_priorities", ["priority"], {:name=>"index_label_priorities_on_priority", :using=>:btree})
+ -> 0.0028s
+-- add_index("label_priorities", ["project_id", "label_id"], {:name=>"index_label_priorities_on_project_id_and_label_id", :unique=>true, :using=>:btree})
+ -> 0.0027s
+-- create_table("labels", {:force=>:cascade})
+ -> 0.0046s
+-- add_index("labels", ["group_id", "project_id", "title"], {:name=>"index_labels_on_group_id_and_project_id_and_title", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- add_index("labels", ["project_id"], {:name=>"index_labels_on_project_id", :using=>:btree})
+ -> 0.0032s
+-- add_index("labels", ["template"], {:name=>"index_labels_on_template", :where=>"template", :using=>:btree})
+ -> 0.0027s
+-- add_index("labels", ["title"], {:name=>"index_labels_on_title", :using=>:btree})
+ -> 0.0030s
+-- add_index("labels", ["type", "project_id"], {:name=>"index_labels_on_type_and_project_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("lfs_objects", {:force=>:cascade})
+ -> 0.0040s
+-- add_index("lfs_objects", ["oid"], {:name=>"index_lfs_objects_on_oid", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- create_table("lfs_objects_projects", {:force=>:cascade})
+ -> 0.0035s
+-- add_index("lfs_objects_projects", ["project_id"], {:name=>"index_lfs_objects_projects_on_project_id", :using=>:btree})
+ -> 0.0025s
+-- create_table("lists", {:force=>:cascade})
+ -> 0.0033s
+-- add_index("lists", ["board_id", "label_id"], {:name=>"index_lists_on_board_id_and_label_id", :unique=>true, :using=>:btree})
+ -> 0.0026s
+-- add_index("lists", ["label_id"], {:name=>"index_lists_on_label_id", :using=>:btree})
+ -> 0.0026s
+-- create_table("members", {:force=>:cascade})
+ -> 0.0046s
+-- add_index("members", ["access_level"], {:name=>"index_members_on_access_level", :using=>:btree})
+ -> 0.0028s
+-- add_index("members", ["invite_token"], {:name=>"index_members_on_invite_token", :unique=>true, :using=>:btree})
+ -> 0.0027s
+-- add_index("members", ["requested_at"], {:name=>"index_members_on_requested_at", :using=>:btree})
+ -> 0.0025s
+-- add_index("members", ["source_id", "source_type"], {:name=>"index_members_on_source_id_and_source_type", :using=>:btree})
+ -> 0.0027s
+-- add_index("members", ["user_id"], {:name=>"index_members_on_user_id", :using=>:btree})
+ -> 0.0026s
+-- create_table("merge_request_diff_commits", {:id=>false, :force=>:cascade})
+ -> 0.0027s
+-- 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})
+ -> 0.0032s
+-- add_index("merge_request_diff_commits", ["sha"], {:name=>"index_merge_request_diff_commits_on_sha", :using=>:btree})
+ -> 0.0029s
+-- create_table("merge_request_diff_files", {:id=>false, :force=>:cascade})
+ -> 0.0027s
+-- 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})
+ -> 0.0027s
+-- create_table("merge_request_diffs", {:force=>:cascade})
+ -> 0.0042s
+-- add_index("merge_request_diffs", ["merge_request_id", "id"], {:name=>"index_merge_request_diffs_on_merge_request_id_and_id", :using=>:btree})
+ -> 0.0030s
+-- create_table("merge_request_metrics", {:force=>:cascade})
+ -> 0.0034s
+-- add_index("merge_request_metrics", ["first_deployed_to_production_at"], {:name=>"index_merge_request_metrics_on_first_deployed_to_production_at", :using=>:btree})
+ -> 0.0028s
+-- add_index("merge_request_metrics", ["merge_request_id"], {:name=>"index_merge_request_metrics", :using=>:btree})
+ -> 0.0025s
+-- add_index("merge_request_metrics", ["pipeline_id"], {:name=>"index_merge_request_metrics_on_pipeline_id", :using=>:btree})
+ -> 0.0026s
+-- create_table("merge_requests", {:force=>:cascade})
+ -> 0.0066s
+-- add_index("merge_requests", ["assignee_id"], {:name=>"index_merge_requests_on_assignee_id", :using=>:btree})
+ -> 0.0029s
+-- add_index("merge_requests", ["author_id"], {:name=>"index_merge_requests_on_author_id", :using=>:btree})
+ -> 0.0026s
+-- add_index("merge_requests", ["created_at"], {:name=>"index_merge_requests_on_created_at", :using=>:btree})
+ -> 0.0026s
+-- add_index("merge_requests", ["description"], {:name=>"index_merge_requests_on_description_trigram", :using=>:gin, :opclasses=>{"description"=>"gin_trgm_ops"}})
+ -> 0.0020s
+-- add_index("merge_requests", ["head_pipeline_id"], {:name=>"index_merge_requests_on_head_pipeline_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("merge_requests", ["latest_merge_request_diff_id"], {:name=>"index_merge_requests_on_latest_merge_request_diff_id", :using=>:btree})
+ -> 0.0025s
+-- add_index("merge_requests", ["merge_user_id"], {:name=>"index_merge_requests_on_merge_user_id", :where=>"(merge_user_id IS NOT NULL)", :using=>:btree})
+ -> 0.0029s
+-- add_index("merge_requests", ["milestone_id"], {:name=>"index_merge_requests_on_milestone_id", :using=>:btree})
+ -> 0.0030s
+-- add_index("merge_requests", ["source_branch"], {:name=>"index_merge_requests_on_source_branch", :using=>:btree})
+ -> 0.0026s
+-- add_index("merge_requests", ["source_project_id", "source_branch"], {:name=>"index_merge_requests_on_source_project_and_branch_state_opened", :where=>"((state)::text = 'opened'::text)", :using=>:btree})
+ -> 0.0029s
+-- add_index("merge_requests", ["source_project_id", "source_branch"], {:name=>"index_merge_requests_on_source_project_id_and_source_branch", :using=>:btree})
+ -> 0.0031s
+-- add_index("merge_requests", ["target_branch"], {:name=>"index_merge_requests_on_target_branch", :using=>:btree})
+ -> 0.0028s
+-- add_index("merge_requests", ["target_project_id", "iid"], {:name=>"index_merge_requests_on_target_project_id_and_iid", :unique=>true, :using=>:btree})
+ -> 0.0027s
+-- add_index("merge_requests", ["target_project_id", "merge_commit_sha", "id"], {:name=>"index_merge_requests_on_tp_id_and_merge_commit_sha_and_id", :using=>:btree})
+ -> 0.0029s
+-- add_index("merge_requests", ["title"], {:name=>"index_merge_requests_on_title", :using=>:btree})
+ -> 0.0026s
+-- add_index("merge_requests", ["title"], {:name=>"index_merge_requests_on_title_trigram", :using=>:gin, :opclasses=>{"title"=>"gin_trgm_ops"}})
+ -> 0.0020s
+-- add_index("merge_requests", ["updated_by_id"], {:name=>"index_merge_requests_on_updated_by_id", :where=>"(updated_by_id IS NOT NULL)", :using=>:btree})
+ -> 0.0029s
+-- create_table("merge_requests_closing_issues", {:force=>:cascade})
+ -> 0.0031s
+-- add_index("merge_requests_closing_issues", ["issue_id"], {:name=>"index_merge_requests_closing_issues_on_issue_id", :using=>:btree})
+ -> 0.0026s
+-- add_index("merge_requests_closing_issues", ["merge_request_id"], {:name=>"index_merge_requests_closing_issues_on_merge_request_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("milestones", {:force=>:cascade})
+ -> 0.0044s
+-- add_index("milestones", ["description"], {:name=>"index_milestones_on_description_trigram", :using=>:gin, :opclasses=>{"description"=>"gin_trgm_ops"}})
+ -> 0.0022s
+-- add_index("milestones", ["due_date"], {:name=>"index_milestones_on_due_date", :using=>:btree})
+ -> 0.0033s
+-- add_index("milestones", ["group_id"], {:name=>"index_milestones_on_group_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("milestones", ["project_id", "iid"], {:name=>"index_milestones_on_project_id_and_iid", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- add_index("milestones", ["title"], {:name=>"index_milestones_on_title", :using=>:btree})
+ -> 0.0026s
+-- add_index("milestones", ["title"], {:name=>"index_milestones_on_title_trigram", :using=>:gin, :opclasses=>{"title"=>"gin_trgm_ops"}})
+ -> 0.0021s
+-- create_table("namespaces", {:force=>:cascade})
+ -> 0.0068s
+-- add_index("namespaces", ["created_at"], {:name=>"index_namespaces_on_created_at", :using=>:btree})
+ -> 0.0030s
+-- add_index("namespaces", ["name", "parent_id"], {:name=>"index_namespaces_on_name_and_parent_id", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- add_index("namespaces", ["name"], {:name=>"index_namespaces_on_name_trigram", :using=>:gin, :opclasses=>{"name"=>"gin_trgm_ops"}})
+ -> 0.0020s
+-- add_index("namespaces", ["owner_id"], {:name=>"index_namespaces_on_owner_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("namespaces", ["parent_id", "id"], {:name=>"index_namespaces_on_parent_id_and_id", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- add_index("namespaces", ["path"], {:name=>"index_namespaces_on_path", :using=>:btree})
+ -> 0.0031s
+-- add_index("namespaces", ["path"], {:name=>"index_namespaces_on_path_trigram", :using=>:gin, :opclasses=>{"path"=>"gin_trgm_ops"}})
+ -> 0.0019s
+-- add_index("namespaces", ["require_two_factor_authentication"], {:name=>"index_namespaces_on_require_two_factor_authentication", :using=>:btree})
+ -> 0.0029s
+-- add_index("namespaces", ["type"], {:name=>"index_namespaces_on_type", :using=>:btree})
+ -> 0.0032s
+-- create_table("notes", {:force=>:cascade})
+ -> 0.0055s
+-- add_index("notes", ["author_id"], {:name=>"index_notes_on_author_id", :using=>:btree})
+ -> 0.0029s
+-- add_index("notes", ["commit_id"], {:name=>"index_notes_on_commit_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("notes", ["created_at"], {:name=>"index_notes_on_created_at", :using=>:btree})
+ -> 0.0029s
+-- add_index("notes", ["discussion_id"], {:name=>"index_notes_on_discussion_id", :using=>:btree})
+ -> 0.0029s
+-- add_index("notes", ["line_code"], {:name=>"index_notes_on_line_code", :using=>:btree})
+ -> 0.0029s
+-- add_index("notes", ["note"], {:name=>"index_notes_on_note_trigram", :using=>:gin, :opclasses=>{"note"=>"gin_trgm_ops"}})
+ -> 0.0024s
+-- add_index("notes", ["noteable_id", "noteable_type"], {:name=>"index_notes_on_noteable_id_and_noteable_type", :using=>:btree})
+ -> 0.0029s
+-- add_index("notes", ["noteable_type"], {:name=>"index_notes_on_noteable_type", :using=>:btree})
+ -> 0.0030s
+-- add_index("notes", ["project_id", "noteable_type"], {:name=>"index_notes_on_project_id_and_noteable_type", :using=>:btree})
+ -> 0.0027s
+-- add_index("notes", ["updated_at"], {:name=>"index_notes_on_updated_at", :using=>:btree})
+ -> 0.0026s
+-- create_table("notification_settings", {:force=>:cascade})
+ -> 0.0053s
+-- add_index("notification_settings", ["source_id", "source_type"], {:name=>"index_notification_settings_on_source_id_and_source_type", :using=>:btree})
+ -> 0.0028s
+-- add_index("notification_settings", ["user_id", "source_id", "source_type"], {:name=>"index_notifications_on_user_id_and_source_id_and_source_type", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- add_index("notification_settings", ["user_id"], {:name=>"index_notification_settings_on_user_id", :using=>:btree})
+ -> 0.0031s
+-- create_table("oauth_access_grants", {:force=>:cascade})
+ -> 0.0042s
+-- add_index("oauth_access_grants", ["token"], {:name=>"index_oauth_access_grants_on_token", :unique=>true, :using=>:btree})
+ -> 0.0031s
+-- create_table("oauth_access_tokens", {:force=>:cascade})
+ -> 0.0051s
+-- add_index("oauth_access_tokens", ["refresh_token"], {:name=>"index_oauth_access_tokens_on_refresh_token", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- add_index("oauth_access_tokens", ["resource_owner_id"], {:name=>"index_oauth_access_tokens_on_resource_owner_id", :using=>:btree})
+ -> 0.0025s
+-- add_index("oauth_access_tokens", ["token"], {:name=>"index_oauth_access_tokens_on_token", :unique=>true, :using=>:btree})
+ -> 0.0026s
+-- create_table("oauth_applications", {:force=>:cascade})
+ -> 0.0049s
+-- add_index("oauth_applications", ["owner_id", "owner_type"], {:name=>"index_oauth_applications_on_owner_id_and_owner_type", :using=>:btree})
+ -> 0.0030s
+-- add_index("oauth_applications", ["uid"], {:name=>"index_oauth_applications_on_uid", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- create_table("oauth_openid_requests", {:force=>:cascade})
+ -> 0.0048s
+-- create_table("pages_domains", {:force=>:cascade})
+ -> 0.0052s
+-- add_index("pages_domains", ["domain"], {:name=>"index_pages_domains_on_domain", :unique=>true, :using=>:btree})
+ -> 0.0027s
+-- add_index("pages_domains", ["project_id"], {:name=>"index_pages_domains_on_project_id", :using=>:btree})
+ -> 0.0030s
+-- create_table("personal_access_tokens", {:force=>:cascade})
+ -> 0.0056s
+-- add_index("personal_access_tokens", ["token"], {:name=>"index_personal_access_tokens_on_token", :unique=>true, :using=>:btree})
+ -> 0.0032s
+-- add_index("personal_access_tokens", ["user_id"], {:name=>"index_personal_access_tokens_on_user_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("project_authorizations", {:id=>false, :force=>:cascade})
+ -> 0.0018s
+-- add_index("project_authorizations", ["project_id"], {:name=>"index_project_authorizations_on_project_id", :using=>:btree})
+ -> 0.0033s
+-- add_index("project_authorizations", ["user_id", "project_id", "access_level"], {:name=>"index_project_authorizations_on_user_id_project_id_access_level", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("project_auto_devops", {:force=>:cascade})
+ -> 0.0043s
+-- add_index("project_auto_devops", ["project_id"], {:name=>"index_project_auto_devops_on_project_id", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("project_custom_attributes", {:force=>:cascade})
+ -> 0.0047s
+-- add_index("project_custom_attributes", ["key", "value"], {:name=>"index_project_custom_attributes_on_key_and_value", :using=>:btree})
+ -> 0.0030s
+-- add_index("project_custom_attributes", ["project_id", "key"], {:name=>"index_project_custom_attributes_on_project_id_and_key", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- create_table("project_features", {:force=>:cascade})
+ -> 0.0038s
+-- add_index("project_features", ["project_id"], {:name=>"index_project_features_on_project_id", :using=>:btree})
+ -> 0.0029s
+-- create_table("project_group_links", {:force=>:cascade})
+ -> 0.0036s
+-- add_index("project_group_links", ["group_id"], {:name=>"index_project_group_links_on_group_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("project_group_links", ["project_id"], {:name=>"index_project_group_links_on_project_id", :using=>:btree})
+ -> 0.0030s
+-- create_table("project_import_data", {:force=>:cascade})
+ -> 0.0049s
+-- add_index("project_import_data", ["project_id"], {:name=>"index_project_import_data_on_project_id", :using=>:btree})
+ -> 0.0027s
+-- create_table("project_statistics", {:force=>:cascade})
+ -> 0.0046s
+-- add_index("project_statistics", ["namespace_id"], {:name=>"index_project_statistics_on_namespace_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("project_statistics", ["project_id"], {:name=>"index_project_statistics_on_project_id", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("projects", {:force=>:cascade})
+ -> 0.0090s
+-- add_index("projects", ["ci_id"], {:name=>"index_projects_on_ci_id", :using=>:btree})
+ -> 0.0033s
+-- add_index("projects", ["created_at"], {:name=>"index_projects_on_created_at", :using=>:btree})
+ -> 0.0030s
+-- add_index("projects", ["creator_id"], {:name=>"index_projects_on_creator_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("projects", ["description"], {:name=>"index_projects_on_description_trigram", :using=>:gin, :opclasses=>{"description"=>"gin_trgm_ops"}})
+ -> 0.0022s
+-- add_index("projects", ["last_activity_at"], {:name=>"index_projects_on_last_activity_at", :using=>:btree})
+ -> 0.0032s
+-- add_index("projects", ["last_repository_check_failed"], {:name=>"index_projects_on_last_repository_check_failed", :using=>:btree})
+ -> 0.0030s
+-- add_index("projects", ["last_repository_updated_at"], {:name=>"index_projects_on_last_repository_updated_at", :using=>:btree})
+ -> 0.0031s
+-- add_index("projects", ["name"], {:name=>"index_projects_on_name_trigram", :using=>:gin, :opclasses=>{"name"=>"gin_trgm_ops"}})
+ -> 0.0022s
+-- add_index("projects", ["namespace_id"], {:name=>"index_projects_on_namespace_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("projects", ["path"], {:name=>"index_projects_on_path", :using=>:btree})
+ -> 0.0028s
+-- add_index("projects", ["path"], {:name=>"index_projects_on_path_trigram", :using=>:gin, :opclasses=>{"path"=>"gin_trgm_ops"}})
+ -> 0.0023s
+-- add_index("projects", ["pending_delete"], {:name=>"index_projects_on_pending_delete", :using=>:btree})
+ -> 0.0029s
+-- add_index("projects", ["repository_storage"], {:name=>"index_projects_on_repository_storage", :using=>:btree})
+ -> 0.0026s
+-- add_index("projects", ["runners_token"], {:name=>"index_projects_on_runners_token", :using=>:btree})
+ -> 0.0034s
+-- add_index("projects", ["star_count"], {:name=>"index_projects_on_star_count", :using=>:btree})
+ -> 0.0028s
+-- add_index("projects", ["visibility_level"], {:name=>"index_projects_on_visibility_level", :using=>:btree})
+ -> 0.0027s
+-- create_table("protected_branch_merge_access_levels", {:force=>:cascade})
+ -> 0.0042s
+-- add_index("protected_branch_merge_access_levels", ["protected_branch_id"], {:name=>"index_protected_branch_merge_access", :using=>:btree})
+ -> 0.0029s
+-- create_table("protected_branch_push_access_levels", {:force=>:cascade})
+ -> 0.0037s
+-- add_index("protected_branch_push_access_levels", ["protected_branch_id"], {:name=>"index_protected_branch_push_access", :using=>:btree})
+ -> 0.0030s
+-- create_table("protected_branches", {:force=>:cascade})
+ -> 0.0048s
+-- add_index("protected_branches", ["project_id"], {:name=>"index_protected_branches_on_project_id", :using=>:btree})
+ -> 0.0030s
+-- create_table("protected_tag_create_access_levels", {:force=>:cascade})
+ -> 0.0037s
+-- add_index("protected_tag_create_access_levels", ["protected_tag_id"], {:name=>"index_protected_tag_create_access", :using=>:btree})
+ -> 0.0029s
+-- add_index("protected_tag_create_access_levels", ["user_id"], {:name=>"index_protected_tag_create_access_levels_on_user_id", :using=>:btree})
+ -> 0.0029s
+-- create_table("protected_tags", {:force=>:cascade})
+ -> 0.0051s
+-- add_index("protected_tags", ["project_id"], {:name=>"index_protected_tags_on_project_id", :using=>:btree})
+ -> 0.0034s
+-- create_table("push_event_payloads", {:id=>false, :force=>:cascade})
+ -> 0.0030s
+-- add_index("push_event_payloads", ["event_id"], {:name=>"index_push_event_payloads_on_event_id", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("redirect_routes", {:force=>:cascade})
+ -> 0.0049s
+-- add_index("redirect_routes", ["path"], {:name=>"index_redirect_routes_on_path", :unique=>true, :using=>:btree})
+ -> 0.0031s
+-- add_index("redirect_routes", ["source_type", "source_id"], {:name=>"index_redirect_routes_on_source_type_and_source_id", :using=>:btree})
+ -> 0.0034s
+-- create_table("releases", {:force=>:cascade})
+ -> 0.0043s
+-- add_index("releases", ["project_id", "tag"], {:name=>"index_releases_on_project_id_and_tag", :using=>:btree})
+ -> 0.0032s
+-- add_index("releases", ["project_id"], {:name=>"index_releases_on_project_id", :using=>:btree})
+ -> 0.0030s
+-- create_table("routes", {:force=>:cascade})
+ -> 0.0055s
+-- add_index("routes", ["path"], {:name=>"index_routes_on_path", :unique=>true, :using=>:btree})
+ -> 0.0028s
+-- add_index("routes", ["path"], {:name=>"index_routes_on_path_text_pattern_ops", :using=>:btree, :opclasses=>{"path"=>"varchar_pattern_ops"}})
+ -> 0.0026s
+-- add_index("routes", ["source_type", "source_id"], {:name=>"index_routes_on_source_type_and_source_id", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("sent_notifications", {:force=>:cascade})
+ -> 0.0048s
+-- add_index("sent_notifications", ["reply_key"], {:name=>"index_sent_notifications_on_reply_key", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("services", {:force=>:cascade})
+ -> 0.0091s
+-- add_index("services", ["project_id"], {:name=>"index_services_on_project_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("services", ["template"], {:name=>"index_services_on_template", :using=>:btree})
+ -> 0.0031s
+-- create_table("snippets", {:force=>:cascade})
+ -> 0.0050s
+-- add_index("snippets", ["author_id"], {:name=>"index_snippets_on_author_id", :using=>:btree})
+ -> 0.0030s
+-- add_index("snippets", ["file_name"], {:name=>"index_snippets_on_file_name_trigram", :using=>:gin, :opclasses=>{"file_name"=>"gin_trgm_ops"}})
+ -> 0.0020s
+-- add_index("snippets", ["project_id"], {:name=>"index_snippets_on_project_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("snippets", ["title"], {:name=>"index_snippets_on_title_trigram", :using=>:gin, :opclasses=>{"title"=>"gin_trgm_ops"}})
+ -> 0.0020s
+-- add_index("snippets", ["updated_at"], {:name=>"index_snippets_on_updated_at", :using=>:btree})
+ -> 0.0026s
+-- add_index("snippets", ["visibility_level"], {:name=>"index_snippets_on_visibility_level", :using=>:btree})
+ -> 0.0026s
+-- create_table("spam_logs", {:force=>:cascade})
+ -> 0.0048s
+-- create_table("subscriptions", {:force=>:cascade})
+ -> 0.0041s
+-- add_index("subscriptions", ["subscribable_id", "subscribable_type", "user_id", "project_id"], {:name=>"index_subscriptions_on_subscribable_and_user_id_and_project_id", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- create_table("system_note_metadata", {:force=>:cascade})
+ -> 0.0040s
+-- add_index("system_note_metadata", ["note_id"], {:name=>"index_system_note_metadata_on_note_id", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- create_table("taggings", {:force=>:cascade})
+ -> 0.0047s
+-- add_index("taggings", ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], {:name=>"taggings_idx", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- add_index("taggings", ["taggable_id", "taggable_type", "context"], {:name=>"index_taggings_on_taggable_id_and_taggable_type_and_context", :using=>:btree})
+ -> 0.0025s
+-- create_table("tags", {:force=>:cascade})
+ -> 0.0044s
+-- add_index("tags", ["name"], {:name=>"index_tags_on_name", :unique=>true, :using=>:btree})
+ -> 0.0026s
+-- create_table("timelogs", {:force=>:cascade})
+ -> 0.0033s
+-- add_index("timelogs", ["issue_id"], {:name=>"index_timelogs_on_issue_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("timelogs", ["merge_request_id"], {:name=>"index_timelogs_on_merge_request_id", :using=>:btree})
+ -> 0.0033s
+-- add_index("timelogs", ["user_id"], {:name=>"index_timelogs_on_user_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("todos", {:force=>:cascade})
+ -> 0.0043s
+-- add_index("todos", ["author_id"], {:name=>"index_todos_on_author_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("todos", ["commit_id"], {:name=>"index_todos_on_commit_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("todos", ["note_id"], {:name=>"index_todos_on_note_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("todos", ["project_id"], {:name=>"index_todos_on_project_id", :using=>:btree})
+ -> 0.0027s
+-- add_index("todos", ["target_type", "target_id"], {:name=>"index_todos_on_target_type_and_target_id", :using=>:btree})
+ -> 0.0028s
+-- add_index("todos", ["user_id"], {:name=>"index_todos_on_user_id", :using=>:btree})
+ -> 0.0026s
+-- create_table("trending_projects", {:force=>:cascade})
+ -> 0.0030s
+-- add_index("trending_projects", ["project_id"], {:name=>"index_trending_projects_on_project_id", :using=>:btree})
+ -> 0.0027s
+-- create_table("u2f_registrations", {:force=>:cascade})
+ -> 0.0048s
+-- add_index("u2f_registrations", ["key_handle"], {:name=>"index_u2f_registrations_on_key_handle", :using=>:btree})
+ -> 0.0029s
+-- add_index("u2f_registrations", ["user_id"], {:name=>"index_u2f_registrations_on_user_id", :using=>:btree})
+ -> 0.0028s
+-- create_table("uploads", {:force=>:cascade})
+ -> 0.0044s
+-- add_index("uploads", ["checksum"], {:name=>"index_uploads_on_checksum", :using=>:btree})
+ -> 0.0028s
+-- add_index("uploads", ["model_id", "model_type"], {:name=>"index_uploads_on_model_id_and_model_type", :using=>:btree})
+ -> 0.0027s
+-- add_index("uploads", ["path"], {:name=>"index_uploads_on_path", :using=>:btree})
+ -> 0.0028s
+-- create_table("user_agent_details", {:force=>:cascade})
+ -> 0.0051s
+-- add_index("user_agent_details", ["subject_id", "subject_type"], {:name=>"index_user_agent_details_on_subject_id_and_subject_type", :using=>:btree})
+ -> 0.0028s
+-- create_table("user_custom_attributes", {:force=>:cascade})
+ -> 0.0044s
+-- add_index("user_custom_attributes", ["key", "value"], {:name=>"index_user_custom_attributes_on_key_and_value", :using=>:btree})
+ -> 0.0027s
+-- add_index("user_custom_attributes", ["user_id", "key"], {:name=>"index_user_custom_attributes_on_user_id_and_key", :unique=>true, :using=>:btree})
+ -> 0.0026s
+-- create_table("user_synced_attributes_metadata", {:force=>:cascade})
+ -> 0.0056s
+-- add_index("user_synced_attributes_metadata", ["user_id"], {:name=>"index_user_synced_attributes_metadata_on_user_id", :unique=>true, :using=>:btree})
+ -> 0.0027s
+-- create_table("users", {:force=>:cascade})
+ -> 0.0134s
+-- add_index("users", ["admin"], {:name=>"index_users_on_admin", :using=>:btree})
+ -> 0.0030s
+-- add_index("users", ["confirmation_token"], {:name=>"index_users_on_confirmation_token", :unique=>true, :using=>:btree})
+ -> 0.0029s
+-- add_index("users", ["created_at"], {:name=>"index_users_on_created_at", :using=>:btree})
+ -> 0.0034s
+-- add_index("users", ["email"], {:name=>"index_users_on_email", :unique=>true, :using=>:btree})
+ -> 0.0030s
+-- add_index("users", ["email"], {:name=>"index_users_on_email_trigram", :using=>:gin, :opclasses=>{"email"=>"gin_trgm_ops"}})
+ -> 0.0431s
+-- add_index("users", ["ghost"], {:name=>"index_users_on_ghost", :using=>:btree})
+ -> 0.0051s
+-- add_index("users", ["incoming_email_token"], {:name=>"index_users_on_incoming_email_token", :using=>:btree})
+ -> 0.0044s
+-- add_index("users", ["name"], {:name=>"index_users_on_name", :using=>:btree})
+ -> 0.0044s
+-- add_index("users", ["name"], {:name=>"index_users_on_name_trigram", :using=>:gin, :opclasses=>{"name"=>"gin_trgm_ops"}})
+ -> 0.0034s
+-- add_index("users", ["reset_password_token"], {:name=>"index_users_on_reset_password_token", :unique=>true, :using=>:btree})
+ -> 0.0044s
+-- add_index("users", ["rss_token"], {:name=>"index_users_on_rss_token", :using=>:btree})
+ -> 0.0046s
+-- add_index("users", ["state"], {:name=>"index_users_on_state", :using=>:btree})
+ -> 0.0040s
+-- add_index("users", ["username"], {:name=>"index_users_on_username", :using=>:btree})
+ -> 0.0046s
+-- add_index("users", ["username"], {:name=>"index_users_on_username_trigram", :using=>:gin, :opclasses=>{"username"=>"gin_trgm_ops"}})
+ -> 0.0044s
+-- create_table("users_star_projects", {:force=>:cascade})
+ -> 0.0055s
+-- add_index("users_star_projects", ["project_id"], {:name=>"index_users_star_projects_on_project_id", :using=>:btree})
+ -> 0.0037s
+-- add_index("users_star_projects", ["user_id", "project_id"], {:name=>"index_users_star_projects_on_user_id_and_project_id", :unique=>true, :using=>:btree})
+ -> 0.0044s
+-- create_table("web_hook_logs", {:force=>:cascade})
+ -> 0.0060s
+-- add_index("web_hook_logs", ["web_hook_id"], {:name=>"index_web_hook_logs_on_web_hook_id", :using=>:btree})
+ -> 0.0034s
+-- create_table("web_hooks", {:force=>:cascade})
+ -> 0.0120s
+-- add_index("web_hooks", ["project_id"], {:name=>"index_web_hooks_on_project_id", :using=>:btree})
+ -> 0.0038s
+-- add_index("web_hooks", ["type"], {:name=>"index_web_hooks_on_type", :using=>:btree})
+ -> 0.0036s
+-- add_foreign_key("boards", "projects", {:name=>"fk_f15266b5f9", :on_delete=>:cascade})
+ -> 0.0030s
+-- add_foreign_key("chat_teams", "namespaces", {:on_delete=>:cascade})
+ -> 0.0021s
+-- add_foreign_key("ci_build_trace_section_names", "projects", {:on_delete=>:cascade})
+ -> 0.0022s
+-- add_foreign_key("ci_build_trace_sections", "ci_build_trace_section_names", {:column=>"section_name_id", :name=>"fk_264e112c66", :on_delete=>:cascade})
+ -> 0.0018s
+-- add_foreign_key("ci_build_trace_sections", "ci_builds", {:column=>"build_id", :name=>"fk_4ebe41f502", :on_delete=>:cascade})
+ -> 0.0024s
+-- add_foreign_key("ci_build_trace_sections", "projects", {:on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("ci_builds", "ci_pipelines", {:column=>"auto_canceled_by_id", :name=>"fk_a2141b1522", :on_delete=>:nullify})
+ -> 0.0023s
+-- add_foreign_key("ci_builds", "ci_stages", {:column=>"stage_id", :name=>"fk_3a9eaa254d", :on_delete=>:cascade})
+ -> 0.0020s
+-- add_foreign_key("ci_builds", "projects", {:name=>"fk_befce0568a", :on_delete=>:cascade})
+ -> 0.0024s
+-- add_foreign_key("ci_group_variables", "namespaces", {:column=>"group_id", :name=>"fk_33ae4d58d8", :on_delete=>:cascade})
+ -> 0.0024s
+-- add_foreign_key("ci_job_artifacts", "ci_builds", {:column=>"job_id", :on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("ci_job_artifacts", "projects", {:on_delete=>:cascade})
+ -> 0.0020s
+-- add_foreign_key("ci_pipeline_schedule_variables", "ci_pipeline_schedules", {:column=>"pipeline_schedule_id", :name=>"fk_41c35fda51", :on_delete=>:cascade})
+ -> 0.0027s
+-- add_foreign_key("ci_pipeline_schedules", "projects", {:name=>"fk_8ead60fcc4", :on_delete=>:cascade})
+ -> 0.0022s
+-- add_foreign_key("ci_pipeline_schedules", "users", {:column=>"owner_id", :name=>"fk_9ea99f58d2", :on_delete=>:nullify})
+ -> 0.0025s
+-- add_foreign_key("ci_pipeline_variables", "ci_pipelines", {:column=>"pipeline_id", :name=>"fk_f29c5f4380", :on_delete=>:cascade})
+ -> 0.0018s
+-- add_foreign_key("ci_pipelines", "ci_pipeline_schedules", {:column=>"pipeline_schedule_id", :name=>"fk_3d34ab2e06", :on_delete=>:nullify})
+ -> 0.0019s
+-- add_foreign_key("ci_pipelines", "ci_pipelines", {:column=>"auto_canceled_by_id", :name=>"fk_262d4c2d19", :on_delete=>:nullify})
+ -> 0.0029s
+-- add_foreign_key("ci_pipelines", "projects", {:name=>"fk_86635dbd80", :on_delete=>:cascade})
+ -> 0.0023s
+-- add_foreign_key("ci_runner_projects", "projects", {:name=>"fk_4478a6f1e4", :on_delete=>:cascade})
+ -> 0.0036s
+-- add_foreign_key("ci_stages", "ci_pipelines", {:column=>"pipeline_id", :name=>"fk_fb57e6cc56", :on_delete=>:cascade})
+ -> 0.0017s
+-- add_foreign_key("ci_stages", "projects", {:name=>"fk_2360681d1d", :on_delete=>:cascade})
+ -> 0.0020s
+-- add_foreign_key("ci_trigger_requests", "ci_triggers", {:column=>"trigger_id", :name=>"fk_b8ec8b7245", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("ci_triggers", "projects", {:name=>"fk_e3e63f966e", :on_delete=>:cascade})
+ -> 0.0021s
+-- add_foreign_key("ci_triggers", "users", {:column=>"owner_id", :name=>"fk_e8e10d1964", :on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("ci_variables", "projects", {:name=>"fk_ada5eb64b3", :on_delete=>:cascade})
+ -> 0.0021s
+-- add_foreign_key("cluster_platforms_kubernetes", "clusters", {:on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("cluster_projects", "clusters", {:on_delete=>:cascade})
+ -> 0.0018s
+-- add_foreign_key("cluster_projects", "projects", {:on_delete=>:cascade})
+ -> 0.0020s
+-- add_foreign_key("cluster_providers_gcp", "clusters", {:on_delete=>:cascade})
+ -> 0.0017s
+-- add_foreign_key("clusters", "users", {:on_delete=>:nullify})
+ -> 0.0018s
+-- add_foreign_key("clusters_applications_helm", "clusters", {:on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("container_repositories", "projects")
+ -> 0.0020s
+-- add_foreign_key("deploy_keys_projects", "projects", {:name=>"fk_58a901ca7e", :on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("deployments", "projects", {:name=>"fk_b9a3851b82", :on_delete=>:cascade})
+ -> 0.0021s
+-- add_foreign_key("environments", "projects", {:name=>"fk_d1c8c1da6a", :on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("events", "projects", {:on_delete=>:cascade})
+ -> 0.0020s
+-- add_foreign_key("events", "users", {:column=>"author_id", :name=>"fk_edfd187b6f", :on_delete=>:cascade})
+ -> 0.0020s
+-- add_foreign_key("fork_network_members", "fork_networks", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("fork_network_members", "projects", {:column=>"forked_from_project_id", :name=>"fk_b01280dae4", :on_delete=>:nullify})
+ -> 0.0019s
+-- add_foreign_key("fork_network_members", "projects", {:on_delete=>:cascade})
+ -> 0.0018s
+-- add_foreign_key("fork_networks", "projects", {:column=>"root_project_id", :name=>"fk_e7b436b2b5", :on_delete=>:nullify})
+ -> 0.0018s
+-- add_foreign_key("forked_project_links", "projects", {:column=>"forked_to_project_id", :name=>"fk_434510edb0", :on_delete=>:cascade})
+ -> 0.0018s
+-- add_foreign_key("gcp_clusters", "projects", {:on_delete=>:cascade})
+ -> 0.0029s
+-- add_foreign_key("gcp_clusters", "services", {:on_delete=>:nullify})
+ -> 0.0022s
+-- add_foreign_key("gcp_clusters", "users", {:on_delete=>:nullify})
+ -> 0.0019s
+-- add_foreign_key("gpg_key_subkeys", "gpg_keys", {:on_delete=>:cascade})
+ -> 0.0017s
+-- add_foreign_key("gpg_keys", "users", {:on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("gpg_signatures", "gpg_key_subkeys", {:on_delete=>:nullify})
+ -> 0.0016s
+-- add_foreign_key("gpg_signatures", "gpg_keys", {:on_delete=>:nullify})
+ -> 0.0016s
+-- add_foreign_key("gpg_signatures", "projects", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("group_custom_attributes", "namespaces", {:column=>"group_id", :on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("issue_assignees", "issues", {:name=>"fk_b7d881734a", :on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("issue_assignees", "users", {:name=>"fk_5e0c8d9154", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("issue_metrics", "issues", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("issues", "issues", {:column=>"moved_to_id", :name=>"fk_a194299be1", :on_delete=>:nullify})
+ -> 0.0014s
+-- add_foreign_key("issues", "milestones", {:name=>"fk_96b1dd429c", :on_delete=>:nullify})
+ -> 0.0016s
+-- add_foreign_key("issues", "projects", {:name=>"fk_899c8f3231", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("issues", "users", {:column=>"author_id", :name=>"fk_05f1e72feb", :on_delete=>:nullify})
+ -> 0.0015s
+-- add_foreign_key("issues", "users", {:column=>"updated_by_id", :name=>"fk_ffed080f01", :on_delete=>:nullify})
+ -> 0.0017s
+-- add_foreign_key("label_priorities", "labels", {:on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("label_priorities", "projects", {:on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("labels", "namespaces", {:column=>"group_id", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("labels", "projects", {:name=>"fk_7de4989a69", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("lists", "boards", {:name=>"fk_0d3f677137", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("lists", "labels", {:name=>"fk_7a5553d60f", :on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("members", "users", {:name=>"fk_2e88fb7ce9", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("merge_request_diff_commits", "merge_request_diffs", {:on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("merge_request_diff_files", "merge_request_diffs", {:on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("merge_request_diffs", "merge_requests", {:name=>"fk_8483f3258f", :on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("merge_request_metrics", "ci_pipelines", {:column=>"pipeline_id", :on_delete=>:cascade})
+ -> 0.0017s
+-- add_foreign_key("merge_request_metrics", "merge_requests", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("merge_request_metrics", "users", {:column=>"latest_closed_by_id", :name=>"fk_ae440388cc", :on_delete=>:nullify})
+ -> 0.0015s
+-- add_foreign_key("merge_request_metrics", "users", {:column=>"merged_by_id", :name=>"fk_7f28d925f3", :on_delete=>:nullify})
+ -> 0.0015s
+-- add_foreign_key("merge_requests", "ci_pipelines", {:column=>"head_pipeline_id", :name=>"fk_fd82eae0b9", :on_delete=>:nullify})
+ -> 0.0014s
+-- add_foreign_key("merge_requests", "merge_request_diffs", {:column=>"latest_merge_request_diff_id", :name=>"fk_06067f5644", :on_delete=>:nullify})
+ -> 0.0014s
+-- add_foreign_key("merge_requests", "milestones", {:name=>"fk_6a5165a692", :on_delete=>:nullify})
+ -> 0.0015s
+-- add_foreign_key("merge_requests", "projects", {:column=>"source_project_id", :name=>"fk_3308fe130c", :on_delete=>:nullify})
+ -> 0.0017s
+-- add_foreign_key("merge_requests", "projects", {:column=>"target_project_id", :name=>"fk_a6963e8447", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("merge_requests", "users", {:column=>"assignee_id", :name=>"fk_6149611a04", :on_delete=>:nullify})
+ -> 0.0016s
+-- add_foreign_key("merge_requests", "users", {:column=>"author_id", :name=>"fk_e719a85f8a", :on_delete=>:nullify})
+ -> 0.0017s
+-- add_foreign_key("merge_requests", "users", {:column=>"merge_user_id", :name=>"fk_ad525e1f87", :on_delete=>:nullify})
+ -> 0.0018s
+-- add_foreign_key("merge_requests", "users", {:column=>"updated_by_id", :name=>"fk_641731faff", :on_delete=>:nullify})
+ -> 0.0017s
+-- add_foreign_key("merge_requests_closing_issues", "issues", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("merge_requests_closing_issues", "merge_requests", {:on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("milestones", "namespaces", {:column=>"group_id", :name=>"fk_95650a40d4", :on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("milestones", "projects", {:name=>"fk_9bd0a0c791", :on_delete=>:cascade})
+ -> 0.0017s
+-- add_foreign_key("notes", "projects", {:name=>"fk_99e097b079", :on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("oauth_openid_requests", "oauth_access_grants", {:column=>"access_grant_id", :name=>"fk_oauth_openid_requests_oauth_access_grants_access_grant_id"})
+ -> 0.0014s
+-- add_foreign_key("pages_domains", "projects", {:name=>"fk_ea2f6dfc6f", :on_delete=>:cascade})
+ -> 0.0021s
+-- add_foreign_key("personal_access_tokens", "users")
+ -> 0.0016s
+-- add_foreign_key("project_authorizations", "projects", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("project_authorizations", "users", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("project_auto_devops", "projects", {:on_delete=>:cascade})
+ -> 0.0026s
+-- add_foreign_key("project_custom_attributes", "projects", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("project_features", "projects", {:name=>"fk_18513d9b92", :on_delete=>:cascade})
+ -> 0.0020s
+-- add_foreign_key("project_group_links", "projects", {:name=>"fk_daa8cee94c", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("project_import_data", "projects", {:name=>"fk_ffb9ee3a10", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("project_statistics", "projects", {:on_delete=>:cascade})
+ -> 0.0021s
+-- add_foreign_key("protected_branch_merge_access_levels", "protected_branches", {:name=>"fk_8a3072ccb3", :on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("protected_branch_push_access_levels", "protected_branches", {:name=>"fk_9ffc86a3d9", :on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("protected_branches", "projects", {:name=>"fk_7a9c6d93e7", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("protected_tag_create_access_levels", "namespaces", {:column=>"group_id"})
+ -> 0.0016s
+-- add_foreign_key("protected_tag_create_access_levels", "protected_tags", {:name=>"fk_f7dfda8c51", :on_delete=>:cascade})
+ -> 0.0013s
+-- add_foreign_key("protected_tag_create_access_levels", "users")
+ -> 0.0018s
+-- add_foreign_key("protected_tags", "projects", {:name=>"fk_8e4af87648", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("push_event_payloads", "events", {:name=>"fk_36c74129da", :on_delete=>:cascade})
+ -> 0.0013s
+-- add_foreign_key("releases", "projects", {:name=>"fk_47fe2a0596", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("services", "projects", {:name=>"fk_71cce407f9", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("snippets", "projects", {:name=>"fk_be41fd4bb7", :on_delete=>:cascade})
+ -> 0.0017s
+-- add_foreign_key("subscriptions", "projects", {:on_delete=>:cascade})
+ -> 0.0018s
+-- add_foreign_key("system_note_metadata", "notes", {:name=>"fk_d83a918cb1", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("timelogs", "issues", {:name=>"fk_timelogs_issues_issue_id", :on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("timelogs", "merge_requests", {:name=>"fk_timelogs_merge_requests_merge_request_id", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("todos", "projects", {:name=>"fk_45054f9c45", :on_delete=>:cascade})
+ -> 0.0018s
+-- add_foreign_key("trending_projects", "projects", {:on_delete=>:cascade})
+ -> 0.0015s
+-- add_foreign_key("u2f_registrations", "users")
+ -> 0.0017s
+-- add_foreign_key("user_custom_attributes", "users", {:on_delete=>:cascade})
+ -> 0.0019s
+-- add_foreign_key("user_synced_attributes_metadata", "users", {:on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("users_star_projects", "projects", {:name=>"fk_22cd27ddfc", :on_delete=>:cascade})
+ -> 0.0016s
+-- add_foreign_key("web_hook_logs", "web_hooks", {:on_delete=>:cascade})
+ -> 0.0014s
+-- add_foreign_key("web_hooks", "projects", {:name=>"fk_0c8ca6d9d1", :on_delete=>:cascade})
+ -> 0.0017s
+-- initialize_schema_migrations_table()
+ -> 0.0112s
+$ 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]}_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}
+$ scripts/gitaly-test-spawn
+Gem.path: ["/root/.gem/ruby/2.3.0", "/usr/local/lib/ruby/gems/2.3.0", "/usr/local/bundle"]
+ENV['BUNDLE_GEMFILE']: nil
+ENV['RUBYOPT']: nil
+bundle config in /builds/gitlab-org/gitlab-ce
+scripts/gitaly-test-spawn:10:in `<main>': undefined local variable or method `gitaly_dir' for main:Object (NameError)
+Did you mean? gitaly_dir
+Settings are listed in order of priority. The top value will be used.
+retry
+Set for your local app (/usr/local/bundle/config): 3
+
+path
+Set for your local app (/usr/local/bundle/config): "vendor"
+Set via BUNDLE_PATH: "/usr/local/bundle"
+
+jobs
+Set for your local app (/usr/local/bundle/config): "2"
+
+clean
+Set for your local app (/usr/local/bundle/config): "true"
+
+without
+Set for your local app (/usr/local/bundle/config): [:production]
+
+silence_root_warning
+Set via BUNDLE_SILENCE_ROOT_WARNING: true
+
+app_config
+Set via BUNDLE_APP_CONFIG: "/usr/local/bundle"
+
+install_flags
+Set via BUNDLE_INSTALL_FLAGS: "--without=production --jobs=2 --path=vendor --retry=3 --quiet"
+
+bin
+Set via BUNDLE_BIN: "/usr/local/bundle/bin"
+
+gemfile
+Set via BUNDLE_GEMFILE: "/builds/gitlab-org/gitlab-ce/Gemfile"
+
+section_end:1517486961:build_script
+section_start:1517486961:after_script
+section_end:1517486962:after_script
+section_start:1517486962:upload_artifacts
+Uploading artifacts...
+WARNING: coverage/: no matching files 
+knapsack/: found 5 matching files 
+WARNING: tmp/capybara/: no matching files 
+Uploading artifacts to coordinator... ok  id=50551722 responseStatus=201 Created token=XkN753rp
+section_end:1517486963:upload_artifacts
+ERROR: Job failed: exit code 1
+ \ No newline at end of file
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 5c5d53877a6..43cb0dfe163 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -63,13 +63,29 @@ describe ApplicationHelper do
end
end
- describe 'avatar_icon' do
+ describe 'avatar_icon_for' do
+ let!(:user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: 'bar@example.com') }
+ let(:email) { 'foo@example.com' }
+ let!(:another_user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: email) }
+
+ it 'prefers the user to retrieve the avatar_url' do
+ expect(helper.avatar_icon_for(user, email).to_s)
+ .to eq(user.avatar.url)
+ end
+
+ it 'falls back to email lookup if no user given' do
+ expect(helper.avatar_icon_for(nil, email).to_s)
+ .to eq(another_user.avatar.url)
+ end
+ end
+
+ describe 'avatar_icon_for_email' do
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)
+ expect(helper.avatar_icon_for_email(user.email).to_s)
.to eq(user.avatar.url)
end
end
@@ -78,17 +94,37 @@ describe ApplicationHelper 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_for_email('foo@example.com', 20, 2)
+ end
+ end
+
+ context 'without an email passed' do
+ it 'calls gravatar_icon' do
+ expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
+
+ helper.avatar_icon_for_email(nil, 20, 2)
end
end
end
+ end
+
+ describe 'avatar_icon_for_user' do
+ let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
- describe 'using a user' do
+ context 'with a user object passed' do
it 'returns a relative URL for the avatar' do
- expect(helper.avatar_icon(user).to_s)
+ expect(helper.avatar_icon_for_user(user).to_s)
.to eq(user.avatar.url)
end
end
+
+ context 'without a user object passed' do
+ it 'calls gravatar_icon' do
+ expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
+
+ helper.avatar_icon_for_user(nil, 20, 2)
+ end
+ end
end
describe 'gravatar_icon' do
@@ -100,7 +136,7 @@ describe ApplicationHelper do
end
it 'returns a generic avatar' do
- expect(helper.gravatar_icon(user_email)).to match('no_avatar.png')
+ expect(helper.gravatar_icon(user_email)).to match_asset_path('no_avatar.png')
end
end
@@ -110,14 +146,14 @@ describe ApplicationHelper do
end
it 'returns a generic avatar when email is blank' do
- expect(helper.gravatar_icon('')).to match('no_avatar.png')
+ expect(helper.gravatar_icon('')).to match_asset_path('no_avatar.png')
end
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')
+ .to match('https://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118')
end
it 'uses HTTPs when configured' do
diff --git a/spec/helpers/auto_devops_helper_spec.rb b/spec/helpers/auto_devops_helper_spec.rb
index 5e272af6073..1950c2b129b 100644
--- a/spec/helpers/auto_devops_helper_spec.rb
+++ b/spec/helpers/auto_devops_helper_spec.rb
@@ -82,4 +82,39 @@ describe AutoDevopsHelper do
it { is_expected.to eq(false) }
end
end
+
+ describe '.auto_devops_warning_message' do
+ subject { helper.auto_devops_warning_message(project) }
+
+ context 'when the service is missing' do
+ before do
+ allow(helper).to receive(:missing_auto_devops_service?).and_return(true)
+ end
+
+ context 'when the domain is missing' do
+ before do
+ allow(helper).to receive(:missing_auto_devops_domain?).and_return(true)
+ end
+
+ it { is_expected.to match(/Auto Review Apps and Auto Deploy need a domain name and a .* to work correctly./) }
+ end
+
+ context 'when the domain is not missing' do
+ before do
+ allow(helper).to receive(:missing_auto_devops_domain?).and_return(false)
+ end
+
+ it { is_expected.to match(/Auto Review Apps and Auto Deploy need a .* to work correctly./) }
+ end
+ end
+
+ context 'when the domain is missing' do
+ before do
+ allow(helper).to receive(:missing_auto_devops_service?).and_return(false)
+ allow(helper).to receive(:missing_auto_devops_domain?).and_return(true)
+ end
+
+ it { is_expected.to eq('Auto Review Apps and Auto Deploy need a domain name to work correctly.') }
+ end
+ end
end
diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb
index f44e7ef6843..04c6d259135 100644
--- a/spec/helpers/avatars_helper_spec.rb
+++ b/spec/helpers/avatars_helper_spec.rb
@@ -29,7 +29,7 @@ describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{user.name}'s avatar",
- src: avatar_icon(user, 16),
+ src: avatar_icon_for_user(user, 16),
data: { container: 'body' },
class: 'avatar s16 has-tooltip',
title: user.name
@@ -43,7 +43,7 @@ describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{user.name}'s avatar",
- src: avatar_icon(user, 16),
+ src: avatar_icon_for_user(user, 16),
data: { container: 'body' },
class: "avatar s16 #{options[:css_class]} has-tooltip",
title: user.name
@@ -58,7 +58,7 @@ describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{user.name}'s avatar",
- src: avatar_icon(user, options[:size]),
+ src: avatar_icon_for_user(user, options[:size]),
data: { container: 'body' },
class: "avatar s#{options[:size]} has-tooltip",
title: user.name
@@ -89,7 +89,7 @@ describe AvatarsHelper do
:img,
alt: "#{user.name}'s avatar",
src: LazyImageTagHelper.placeholder_image,
- data: { container: 'body', src: avatar_icon(user, 16) },
+ data: { container: 'body', src: avatar_icon_for_user(user, 16) },
class: "avatar s16 has-tooltip lazy",
title: user.name
)
@@ -104,7 +104,7 @@ describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{user.name}'s avatar",
- src: avatar_icon(user, 16),
+ src: avatar_icon_for_user(user, 16),
data: { container: 'body' },
class: "avatar s16 has-tooltip",
title: user.name
@@ -119,7 +119,7 @@ describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{user.name}'s avatar",
- src: avatar_icon(user, 16),
+ src: avatar_icon_for_user(user, 16),
class: "avatar s16",
title: user.name
)
@@ -137,7 +137,7 @@ describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{user.name}'s avatar",
- src: avatar_icon(user, 16),
+ src: avatar_icon_for_user(user, 16),
data: { container: 'body' },
class: "avatar s16 has-tooltip",
title: user.name
@@ -149,7 +149,7 @@ describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{options[:user_name]}'s avatar",
- src: avatar_icon(options[:user_email], 16),
+ src: avatar_icon_for_email(options[:user_email], 16),
data: { container: 'body' },
class: "avatar s16 has-tooltip",
title: options[:user_name]
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index 04620f6d88c..1fa194fe1b8 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -22,6 +22,13 @@ describe BlobHelper do
expect(result).to eq(%[<pre class="code highlight"><code><span id="LC1" class="line" lang="">:type "assem"))</span></code></pre>])
end
+ it 'returns plaintext for long blobs' do
+ stub_const('Blob::MAXIMUM_TEXT_HIGHLIGHT_SIZE', 1)
+ result = helper.highlight(blob_name, blob_content)
+
+ expect(result).to eq(%[<pre class="code highlight"><code><span id="LC1" class="line" lang="">(make-pathname :defaults name</span>\n<span id="LC2" class="line" lang="">:type "assem"))</span></code></pre>])
+ end
+
it 'highlights single block' do
expected = %Q[<pre class="code highlight"><code><span id="LC1" class="line" lang="common_lisp"><span class="p">(</span><span class="nb">make-pathname</span> <span class="ss">:defaults</span> <span class="nv">name</span></span>
<span id="LC2" class="line" lang="common_lisp"><span class="ss">:type</span> <span class="s">"assem"</span><span class="p">))</span></span></code></pre>]
@@ -79,7 +86,7 @@ describe BlobHelper do
it 'verifies blob is text' do
expect(helper).not_to receive(:blob_text_viewable?)
- button = edit_blob_link(project, 'refs/heads/master', 'README.md')
+ button = edit_blob_button(project, 'refs/heads/master', 'README.md')
expect(button).to start_with('<button')
end
@@ -89,17 +96,17 @@ describe BlobHelper do
expect(project.repository).not_to receive(:blob_at)
- edit_blob_link(project, 'refs/heads/master', 'README.md', blob: blob)
+ edit_blob_button(project, 'refs/heads/master', 'README.md', blob: blob)
end
it 'returns a link with the proper route' do
- link = edit_blob_link(project, 'master', 'README.md')
+ link = edit_blob_button(project, 'master', 'README.md')
expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md")
end
it 'returns a link with the passed link_opts on the expected route' do
- link = edit_blob_link(project, 'master', 'README.md', link_opts: { mr_id: 10 })
+ link = edit_blob_button(project, 'master', 'README.md', link_opts: { mr_id: 10 })
expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md?mr_id=10")
end
diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb
index a3c5ab99c87..b22947911b9 100644
--- a/spec/helpers/boards_helper_spec.rb
+++ b/spec/helpers/boards_helper_spec.rb
@@ -1,6 +1,34 @@
require 'spec_helper'
describe BoardsHelper do
+ describe '#build_issue_link_base' do
+ context 'project board' do
+ it 'returns correct path for project board' do
+ @project = create(:project)
+ @board = create(:board, project: @project)
+
+ expect(build_issue_link_base).to eq("/#{@project.namespace.path}/#{@project.path}/issues")
+ end
+ end
+
+ context 'group board' do
+ let(:base_group) { create(:group, path: 'base') }
+
+ it 'returns correct path for base group' do
+ @board = create(:board, group: base_group)
+
+ expect(build_issue_link_base).to eq('/base/:project_path/issues')
+ end
+
+ it 'returns correct path for subgroup' do
+ subgroup = create(:group, parent: base_group, path: 'sub')
+ @board = create(:board, group: subgroup)
+
+ expect(build_issue_link_base).to eq('/base/sub/:project_path/issues')
+ end
+ end
+ end
+
describe '#board_data' do
let(:user) { create(:user) }
let(:project) { create(:project) }
diff --git a/spec/helpers/dashboard_helper_spec.rb b/spec/helpers/dashboard_helper_spec.rb
new file mode 100644
index 00000000000..7ba24ba2956
--- /dev/null
+++ b/spec/helpers/dashboard_helper_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe DashboardHelper do
+ let(:user) { build(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ allow(helper).to receive(:can?) { true }
+ end
+
+ describe '#dashboard_nav_links' do
+ it 'has all the expected links by default' do
+ menu_items = [:projects, :groups, :activity, :milestones, :snippets]
+
+ expect(helper.dashboard_nav_links).to contain_exactly(*menu_items)
+ end
+
+ it 'does not contain cross project elements when the user cannot read cross project' do
+ expect(helper).to receive(:can?).with(user, :read_cross_project) { false }
+
+ expect(helper.dashboard_nav_links).not_to include(:activity, :milestones)
+ end
+ end
+end
diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb
index f9c31ac61d8..53c010fa0db 100644
--- a/spec/helpers/diff_helper_spec.rb
+++ b/spec/helpers/diff_helper_spec.rb
@@ -135,11 +135,37 @@ 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(%q{abc <span class="idiff left right deletion">'def'</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(%q{abc <span class="idiff left right addition">"def"</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
+
+ context 'when given HTML' do
+ it 'sanitizes it' do
+ old_line = %{test.txt}
+ new_line = %{<img src=x onerror=alert(document.domain)>}
+
+ marked_old_line, marked_new_line = mark_inline_diffs(old_line, new_line)
+
+ expect(marked_old_line).to eq(%q{<span class="idiff left right deletion">test.txt</span>})
+ expect(marked_old_line).to be_html_safe
+ expect(marked_new_line).to eq(%q{<span class="idiff left right addition">&lt;img src=x onerror=alert(document.domain)&gt;</span>})
+ expect(marked_new_line).to be_html_safe
+ end
+
+ it 'sanitizes the entire line, not just the changes' do
+ old_line = %{<img src=x onerror=alert(document.domain)>}
+ new_line = %{<img src=y onerror=alert(document.domain)>}
+
+ marked_old_line, marked_new_line = mark_inline_diffs(old_line, new_line)
+
+ expect(marked_old_line).to eq(%q{&lt;img src=<span class="idiff left right deletion">x</span> onerror=alert(document.domain)&gt;})
+ expect(marked_old_line).to be_html_safe
+ expect(marked_new_line).to eq(%q{&lt;img src=<span class="idiff left right addition">y</span> onerror=alert(document.domain)&gt;})
+ expect(marked_new_line).to be_html_safe
+ end
+ end
end
describe '#parallel_diff_discussions' do
@@ -266,4 +292,14 @@ describe DiffHelper do
end
end
end
+
+ context '#diff_file_path_text' do
+ it 'returns full path by default' do
+ expect(diff_file_path_text(diff_file)).to eq(diff_file.new_path)
+ end
+
+ it 'returns truncated path' do
+ expect(diff_file_path_text(diff_file, max: 10)).to eq("...open.rb")
+ end
+ end
end
diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb
index 8a80b88da5d..fccde8b7eba 100644
--- a/spec/helpers/events_helper_spec.rb
+++ b/spec/helpers/events_helper_spec.rb
@@ -20,5 +20,9 @@ describe EventsHelper do
it 'handles nil values' do
expect(helper.event_commit_title(nil)).to eq('')
end
+
+ it 'does not escape HTML entities' do
+ expect(helper.event_commit_title("foo & bar")).to eq("foo & bar")
+ end
end
end
diff --git a/spec/helpers/explore_helper_spec.rb b/spec/helpers/explore_helper_spec.rb
new file mode 100644
index 00000000000..12651d80e36
--- /dev/null
+++ b/spec/helpers/explore_helper_spec.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe ExploreHelper do
+ let(:user) { build(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ allow(helper).to receive(:can?) { true }
+ end
+
+ describe '#explore_nav_links' do
+ it 'has all the expected links by default' do
+ menu_items = [:projects, :groups, :snippets]
+
+ expect(helper.explore_nav_links).to contain_exactly(*menu_items)
+ end
+ end
+end
diff --git a/spec/helpers/graph_helper_spec.rb b/spec/helpers/graph_helper_spec.rb
index 400635abdde..1f8a38dc697 100644
--- a/spec/helpers/graph_helper_spec.rb
+++ b/spec/helpers/graph_helper_spec.rb
@@ -7,10 +7,10 @@ describe GraphHelper do
let(:graph) { Network::Graph.new(project, 'master', commit, '') }
it 'filters our refs used by GitLab' do
- allow(commit).to receive(:ref_names).and_return(['refs/merge-requests/abc', 'master', 'refs/tmp/xyz'])
self.instance_variable_set(:@graph, graph)
- refs = get_refs(project.repository, commit)
- expect(refs).to eq('master')
+ refs = refs(project.repository, commit)
+
+ expect(refs).to match('master')
end
end
end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index 32432ee1e81..b48c252acd3 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -105,7 +105,7 @@ describe GroupsHelper do
it 'outputs the groups in the correct order' do
expect(helper.group_title(very_deep_nested_group))
- .to match(/<li style="text-indent: 16px;"><a.*>#{deep_nested_group.name}.*<\/li>.*<a.*>#{very_deep_nested_group.name}<\/a>/m)
+ .to match(%r{<li style="text-indent: 16px;"><a.*>#{deep_nested_group.name}.*</li>.*<a.*>#{very_deep_nested_group.name}</a>}m)
end
end
@@ -120,7 +120,7 @@ describe GroupsHelper do
let(:possible_help_texts) do
{
default_help: "This setting will be applied to all subgroups unless overridden by a group owner",
- ancestor_locked_but_you_can_override: /This setting is applied on <a .+>.+<\/a>\. You can override the setting or .+/,
+ ancestor_locked_but_you_can_override: %r{This setting is applied on <a .+>.+</a>\. You can override the setting or .+},
ancestor_locked_so_ask_the_owner: /This setting is applied on .+\. To share projects in this group with another group, ask the owner to override the setting or remove the share with group lock from .+/,
ancestor_locked_and_has_been_overridden: /This setting is applied on .+ and has been overridden on this subgroup/
}
@@ -201,4 +201,39 @@ describe GroupsHelper do
end
end
end
+
+ describe '#group_sidebar_links' do
+ let(:group) { create(:group, :public) }
+ let(:user) { create(:user) }
+ before do
+ allow(helper).to receive(:current_user) { user }
+ allow(helper).to receive(:can?) { true }
+ helper.instance_variable_set(:@group, group)
+ end
+
+ it 'returns all the expected links' do
+ links = [
+ :overview, :activity, :issues, :labels, :milestones, :merge_requests,
+ :group_members, :settings
+ ]
+
+ expect(helper.group_sidebar_links).to include(*links)
+ end
+
+ it 'includes settings when the user can admin the group' do
+ expect(helper).to receive(:current_user) { user }
+ expect(helper).to receive(:can?).with(user, :admin_group, group) { false }
+
+ expect(helper.group_sidebar_links).not_to include(:settings)
+ end
+
+ it 'excludes cross project features when the user cannot read cross project' do
+ cross_project_features = [:activity, :issues, :labels, :milestones,
+ :merge_requests]
+
+ expect(helper).to receive(:can?).with(user, :read_cross_project) { false }
+
+ expect(helper.group_sidebar_links).not_to include(*cross_project_features)
+ end
+ end
end
diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb
index 9afff47f4e9..033155617c6 100644
--- a/spec/helpers/import_helper_spec.rb
+++ b/spec/helpers/import_helper_spec.rb
@@ -27,25 +27,46 @@ describe ImportHelper do
describe '#provider_project_link' do
context 'when provider is "github"' do
+ let(:github_server_url) { nil }
+ let(:provider) { OpenStruct.new(name: 'github', url: github_server_url) }
+
+ before do
+ stub_omniauth_setting(providers: [provider])
+ end
+
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')])
-
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
+ let(:github_server_url) { 'https://github.company.com' }
+
it 'uses custom URL' do
- 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"')
+ end
+ end
+
+ context "when custom URL contains a '/' char at the end" do
+ let(:github_server_url) { 'https://github.company.com/' }
+ it "doesn't render double slash" do
expect(helper.provider_project_link('github', 'octocat/Hello-World'))
.to include('href="https://github.company.com/octocat/Hello-World"')
end
end
+
+ context 'when provider is missing' do
+ it 'uses the default URL' do
+ allow(Gitlab.config.omniauth).to receive(:providers).and_return([])
+
+ expect(helper.provider_project_link('github', 'octocat/Hello-World'))
+ .to include('href="https://github.com/octocat/Hello-World"')
+ end
+ end
end
context 'when provider is "gitea"' do
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index d601cbdb39b..4224cea4652 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -40,22 +40,22 @@ describe IssuablesHelper do
end
it 'returns "Open" when state is :opened' do
- expect(helper.issuables_state_counter_text(:issues, :opened))
+ expect(helper.issuables_state_counter_text(:issues, :opened, true))
.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))
+ expect(helper.issuables_state_counter_text(:issues, :closed, true))
.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))
+ expect(helper.issuables_state_counter_text(:merge_requests, :merged, true))
.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))
+ expect(helper.issuables_state_counter_text(:merge_requests, :all, true))
.to eq('<span>All</span> <span class="badge">42</span>')
end
end
@@ -101,34 +101,13 @@ describe IssuablesHelper do
end
end
- describe '#issuable_filter_present?' do
- it 'returns true when any key is present' do
- allow(helper).to receive(:params).and_return(
- ActionController::Parameters.new(milestone_title: 'Velit consectetur asperiores natus delectus.',
- project_id: 'gitlabhq',
- scope: 'all')
- )
-
- expect(helper.issuable_filter_present?).to be_truthy
- end
-
- it 'returns false when no key is present' do
- allow(helper).to receive(:params).and_return(
- ActionController::Parameters.new(project_id: 'gitlabhq',
- scope: 'all')
- )
-
- 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_issuable) { create(:issue, last_edited_by: user, created_at: 3.days.ago, updated_at: 1.day.ago, last_edited_at: 2.days.ago) }
let(:edited_updated_at_by) do
{
- updatedAt: edited_issuable.updated_at.to_time.iso8601,
+ updatedAt: edited_issuable.last_edited_at.to_time.iso8601,
updatedBy: {
name: user.name,
path: user_path(user)
@@ -142,7 +121,7 @@ describe IssuablesHelper do
context 'when updated by a deleted user' do
let(:edited_updated_at_by) do
{
- updatedAt: edited_issuable.updated_at.to_time.iso8601,
+ updatedAt: edited_issuable.last_edited_at.to_time.iso8601,
updatedBy: {
name: User.ghost.name,
path: user_path(User.ghost)
@@ -173,23 +152,52 @@ describe IssuablesHelper do
@project = issue.project
expected_data = {
- 'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}",
- 'updateEndpoint' => "/#{@project.full_path}/issues/#{issue.iid}.json",
- 'canUpdate' => true,
- 'canDestroy' => true,
- 'issuableRef' => "##{issue.iid}",
- 'markdownPreviewPath' => "/#{@project.full_path}/preview_markdown",
- 'markdownDocsPath' => '/help/user/markdown',
- 'issuableTemplates' => [],
- 'projectPath' => @project.path,
- 'projectNamespace' => @project.namespace.path,
- 'initialTitleHtml' => issue.title,
- 'initialTitleText' => issue.title,
- 'initialDescriptionHtml' => '<p dir="auto">issue text</p>',
- 'initialDescriptionText' => 'issue text',
- 'initialTaskStatus' => '0 of 0 tasks completed'
+ endpoint: "/#{@project.full_path}/issues/#{issue.iid}",
+ updateEndpoint: "/#{@project.full_path}/issues/#{issue.iid}.json",
+ canUpdate: true,
+ canDestroy: true,
+ issuableRef: "##{issue.iid}",
+ markdownPreviewPath: "/#{@project.full_path}/preview_markdown",
+ markdownDocsPath: '/help/user/markdown',
+ issuableTemplates: [],
+ projectPath: @project.path,
+ projectNamespace: @project.namespace.path,
+ initialTitleHtml: issue.title,
+ initialTitleText: issue.title,
+ initialDescriptionHtml: '<p dir="auto">issue text</p>',
+ initialDescriptionText: 'issue text',
+ initialTaskStatus: '0 of 0 tasks completed'
}
- expect(JSON.parse(helper.issuable_initial_data(issue))).to eq(expected_data)
+ expect(helper.issuable_initial_data(issue)).to eq(expected_data)
+ end
+ end
+
+ describe '#selected_labels' do
+ context 'if label_name param is a string' do
+ it 'returns a new label with title' do
+ allow(helper).to receive(:params)
+ .and_return(ActionController::Parameters.new(label_name: 'test label'))
+
+ labels = helper.selected_labels
+
+ expect(labels).to be_an(Array)
+ expect(labels.size).to eq(1)
+ expect(labels.first.title).to eq('test label')
+ end
+ end
+
+ context 'if label_name param is an array' do
+ it 'returns a new label with title for each element' do
+ allow(helper).to receive(:params)
+ .and_return(ActionController::Parameters.new(label_name: ['test label 1', 'test label 2']))
+
+ labels = helper.selected_labels
+
+ expect(labels).to be_an(Array)
+ expect(labels.size).to eq(2)
+ expect(labels.first.title).to eq('test label 1')
+ expect(labels.second.title).to eq('test label 2')
+ end
end
end
end
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index ddf881a7b6f..aeef5352333 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -113,21 +113,6 @@ describe IssuesHelper do
end
end
- describe "milestone_options" do
- it "gets closed milestone from current issue" do
- closed_milestone = create(:closed_milestone, project: project)
- milestone1 = create(:milestone, project: project)
- milestone2 = create(:milestone, project: project)
- issue.update_attributes(milestone_id: closed_milestone.id)
-
- options = milestone_options(issue)
-
- expect(options).to have_selector('option[selected]', text: closed_milestone.title)
- expect(options).to have_selector('option', text: milestone1.title)
- expect(options).to have_selector('option', text: milestone2.title)
- end
- end
-
describe "#link_to_discussions_to_resolve" do
describe "passing only a merge request" do
let(:merge_request) { create(:merge_request) }
diff --git a/spec/helpers/labels_helper_spec.rb b/spec/helpers/labels_helper_spec.rb
index 0286d36952c..a2cda58e5d2 100644
--- a/spec/helpers/labels_helper_spec.rb
+++ b/spec/helpers/labels_helper_spec.rb
@@ -104,7 +104,7 @@ describe LabelsHelper do
context 'with a tooltip argument' do
context 'set to false' do
it 'does not include the has-tooltip class' do
- expect(link_to_label(label, tooltip: false)).not_to match %r{has-tooltip}
+ expect(link_to_label(label, tooltip: false)).not_to match /has-tooltip/
end
end
end
@@ -139,4 +139,76 @@ describe LabelsHelper do
expect(text_color_for_bg('#000')).to eq '#FFFFFF'
end
end
+
+ describe 'create_label_title' do
+ set(:group) { create(:group) }
+
+ context 'with a group as subject' do
+ it 'returns "Create group label"' do
+ expect(create_label_title(group)).to eq 'Create group label'
+ end
+ end
+
+ context 'with a project as subject' do
+ set(:project) { create(:project, namespace: group) }
+
+ it 'returns "Create project label"' do
+ expect(create_label_title(project)).to eq 'Create project label'
+ end
+ end
+
+ context 'with no subject' do
+ it 'returns "Create new label"' do
+ expect(create_label_title(nil)).to eq 'Create new label'
+ end
+ end
+ end
+
+ describe 'manage_labels_title' do
+ set(:group) { create(:group) }
+
+ context 'with a group as subject' do
+ it 'returns "Manage group labels"' do
+ expect(manage_labels_title(group)).to eq 'Manage group labels'
+ end
+ end
+
+ context 'with a project as subject' do
+ set(:project) { create(:project, namespace: group) }
+
+ it 'returns "Manage project labels"' do
+ expect(manage_labels_title(project)).to eq 'Manage project labels'
+ end
+ end
+
+ context 'with no subject' do
+ it 'returns "Manage labels"' do
+ expect(manage_labels_title(nil)).to eq 'Manage labels'
+ end
+ end
+ end
+
+ describe 'view_labels_title' do
+ set(:group) { create(:group) }
+
+ context 'with a group as subject' do
+ it 'returns "View group labels"' do
+ expect(view_labels_title(group)).to eq 'View group labels'
+ end
+ end
+
+ context 'with a project as subject' do
+ set(:project) { create(:project, namespace: group) }
+
+ it 'returns "View project labels"' do
+ expect(view_labels_title(project)).to eq 'View project labels'
+ end
+ end
+
+ context 'with no subject' do
+ it 'returns "View labels"' do
+ expect(view_labels_title(nil)).to eq 'View labels'
+ end
+ end
+ end
end
diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb
index ba0039f3a11..c0dc9293397 100644
--- a/spec/helpers/markup_helper_spec.rb
+++ b/spec/helpers/markup_helper_spec.rb
@@ -11,7 +11,7 @@ describe MarkupHelper do
before do
# Ensure the generated reference links aren't redacted
- project.team << [user, :master]
+ project.add_master(user)
# Helper expects a @project instance variable
helper.instance_variable_set(:@project, project)
diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb
index 45ffbeb27a4..4590904c93d 100644
--- a/spec/helpers/members_helper_spec.rb
+++ b/spec/helpers/members_helper_spec.rb
@@ -12,10 +12,10 @@ describe MembersHelper do
let(:group_member_invite) { build(:group_member, group: group).tap { |m| m.generate_invite_token! } }
let(:group_member_request) { group.request_access(requester) }
- it { expect(remove_member_message(project_member)).to eq "Are you sure you want to remove #{project_member.user.name} from the #{project.name_with_namespace} project?" }
- it { expect(remove_member_message(project_member_invite)).to eq "Are you sure you want to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project?" }
- it { expect(remove_member_message(project_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{project.name_with_namespace} project?" }
- it { expect(remove_member_message(project_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{project.name_with_namespace} project?" }
+ it { expect(remove_member_message(project_member)).to eq "Are you sure you want to remove #{project_member.user.name} from the #{project.full_name} project?" }
+ it { expect(remove_member_message(project_member_invite)).to eq "Are you sure you want to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.full_name} project?" }
+ it { expect(remove_member_message(project_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{project.full_name} project?" }
+ it { expect(remove_member_message(project_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{project.full_name} project?" }
it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group?" }
it { expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group?" }
it { expect(remove_member_message(group_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{group.name} group?" }
@@ -42,7 +42,7 @@ describe MembersHelper do
let(:group) { build_stubbed(:group) }
let(:user) { build_stubbed(:user) }
- it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.name_with_namespace}\" project?" }
+ it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.full_name}\" project?" }
it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" }
end
end
diff --git a/spec/helpers/nav_helper_spec.rb b/spec/helpers/nav_helper_spec.rb
new file mode 100644
index 00000000000..e840c927d59
--- /dev/null
+++ b/spec/helpers/nav_helper_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+describe NavHelper do
+ describe '#header_links' do
+ before do
+ allow(helper).to receive(:session) { {} }
+ end
+
+ context 'when the user is logged in' do
+ let(:user) { build(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ allow(helper).to receive(:can?) { true }
+ end
+
+ it 'has all the expected links by default' do
+ menu_items = [:user_dropdown, :search, :issues, :merge_requests, :todos]
+
+ expect(helper.header_links).to contain_exactly(*menu_items)
+ end
+
+ it 'contains the impersonation link while impersonating' do
+ expect(helper).to receive(:session) { { impersonator_id: 1 } }
+
+ expect(helper.header_links).to include(:admin_impersonation)
+ end
+
+ context 'when the user cannot read cross project' do
+ before do
+ allow(helper).to receive(:can?).with(user, :read_cross_project) { false }
+ end
+
+ it 'does not contain cross project elements when the user cannot read cross project' do
+ expect(helper.header_links).not_to include(:issues, :merge_requests, :todos, :search)
+ end
+
+ it 'shows the search box when the user cannot read cross project and he is visiting a project' do
+ helper.instance_variable_set(:@project, create(:project))
+
+ expect(helper.header_links).to include(:search)
+ end
+ end
+ end
+
+ it 'returns only the sign in and search when the user is not logged in' do
+ allow(helper).to receive(:current_user).and_return(nil)
+ allow(helper).to receive(:can?).with(nil, :read_cross_project) { true }
+
+ expect(helper.header_links).to contain_exactly(:sign_in, :search)
+ end
+ end
+end
diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb
index 36a44f8567a..b992bdb4a5e 100644
--- a/spec/helpers/notes_helper_spec.rb
+++ b/spec/helpers/notes_helper_spec.rb
@@ -17,9 +17,9 @@ describe NotesHelper do
before do
group.add_owner(owner)
- project.team << [master, :master]
- project.team << [reporter, :reporter]
- project.team << [guest, :guest]
+ project.add_master(master)
+ project.add_reporter(reporter)
+ project.add_guest(guest)
end
describe "#notes_max_access_for_users" do
@@ -31,7 +31,7 @@ describe NotesHelper do
it 'handles access in different projects' do
second_project = create(:project)
- second_project.team << [master, :reporter]
+ second_project.add_reporter(master)
other_note = create(:note, author: master, project: second_project)
expect(helper.note_max_access_for_user(master_note)).to eq(Gitlab::Access::MASTER)
diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb
index baf927a9acc..b77114a8152 100644
--- a/spec/helpers/page_layout_helper_spec.rb
+++ b/spec/helpers/page_layout_helper_spec.rb
@@ -50,6 +50,11 @@ describe PageLayoutHelper do
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
expect(helper.favicon).to eq 'favicon-blue.ico'
end
+
+ it 'has yellow favicon for canary' do
+ stub_env('CANARY', 'true')
+ expect(helper.favicon).to eq 'favicon-yellow.ico'
+ end
end
describe 'page_image' do
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index 749aa25e632..c9d2ec8a4ae 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -21,7 +21,9 @@ describe PreferencesHelper do
["Your Projects' Activity", 'project_activity'],
["Starred Projects' Activity", 'starred_project_activity'],
["Your Groups", 'groups'],
- ["Your Todos", 'todos']
+ ["Your Todos", 'todos'],
+ ["Assigned Issues", 'issues'],
+ ["Assigned Merge Requests", 'merge_requests']
]
end
end
@@ -77,103 +79,6 @@ describe PreferencesHelper do
end
end
- describe '#default_project_view' do
- context 'user not signed in' do
- before do
- helper.instance_variable_set(:@project, project)
- stub_user
- end
-
- context 'when repository is empty' do
- let(:project) { create(:project_empty_repo, :public) }
-
- it 'returns activity if user has repository access' do
- allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(true)
-
- expect(helper.default_project_view).to eq('activity')
- end
-
- it 'returns activity if user does not have repository access' do
- allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(false)
-
- expect(helper.default_project_view).to eq('activity')
- end
- end
-
- context 'when repository is not empty' do
- let(:project) { create(:project, :public, :repository) }
-
- it 'returns files and readme if user has repository access' do
- allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(true)
-
- expect(helper.default_project_view).to eq('files')
- end
-
- it 'returns activity if user does not have repository access' do
- allow(helper).to receive(:can?).with(nil, :download_code, project).and_return(false)
-
- expect(helper.default_project_view).to eq('activity')
- end
- end
- end
-
- context 'user signed in' do
- let(:user) { create(:user, :readme) }
- let(:project) { create(:project, :public, :repository) }
-
- before do
- helper.instance_variable_set(:@project, project)
- allow(helper).to receive(:current_user).and_return(user)
- end
-
- context 'when the user is allowed to see the code' do
- it 'returns the project view' do
- allow(helper).to receive(:can?).with(user, :download_code, project).and_return(true)
-
- expect(helper.default_project_view).to eq('readme')
- end
- end
-
- context 'with wikis enabled and the right policy for the user' do
- before do
- project.project_feature.update_attribute(:issues_access_level, 0)
- allow(helper).to receive(:can?).with(user, :download_code, project).and_return(false)
- end
-
- it 'returns wiki if the user has the right policy' do
- allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(true)
-
- expect(helper.default_project_view).to eq('wiki')
- end
-
- it 'returns customize_workflow if the user does not have the right policy' do
- allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(false)
-
- expect(helper.default_project_view).to eq('customize_workflow')
- end
- end
-
- context 'with issues as a feature available' do
- it 'return issues' do
- allow(helper).to receive(:can?).with(user, :download_code, project).and_return(false)
- allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(false)
-
- expect(helper.default_project_view).to eq('projects/issues/issues')
- end
- end
-
- context 'with no activity, no wikies and no issues' do
- it 'returns customize_workflow as default' do
- project.project_feature.update_attribute(:issues_access_level, 0)
- allow(helper).to receive(:can?).with(user, :download_code, project).and_return(false)
- allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(false)
-
- expect(helper.default_project_view).to eq('customize_workflow')
- end
- end
- end
- end
-
def stub_user(messages = {})
if messages.empty?
allow(helper).to receive(:current_user).and_return(nil)
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index ede9d232efd..ce96e90e2d7 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe ProjectsHelper do
+ include ProjectForksHelper
+
describe "#project_status_css_class" do
it "returns appropriate class" do
expect(project_status_css_class("started")).to eq("active")
@@ -10,9 +12,9 @@ describe ProjectsHelper do
end
describe "can_change_visibility_level?" do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project) }
let(:user) { create(:project_member, :reporter, user: create(:user), project: project).user }
- let(:fork_project) { Projects::ForkService.new(project, user).execute }
+ let(:forked_project) { fork_project(project, user) }
it "returns false if there are no appropriate permissions" do
allow(helper).to receive(:can?) { false }
@@ -26,21 +28,29 @@ describe ProjectsHelper do
expect(helper.can_change_visibility_level?(project, user)).to be_truthy
end
+ it 'allows visibility level to be changed if the project is forked' do
+ allow(helper).to receive(:can?).with(user, :change_visibility_level, project) { true }
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ fork_project(project)
+
+ expect(helper.can_change_visibility_level?(project, user)).to be_truthy
+ end
+
context "forks" do
it "returns false if there are permissions and origin project is PRIVATE" do
allow(helper).to receive(:can?) { true }
- project.update visibility_level: Gitlab::VisibilityLevel::PRIVATE
+ project.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- expect(helper.can_change_visibility_level?(fork_project, user)).to be_falsey
+ expect(helper.can_change_visibility_level?(forked_project, user)).to be_falsey
end
it "returns true if there are permissions and origin project is INTERNAL" do
allow(helper).to receive(:can?) { true }
- project.update visibility_level: Gitlab::VisibilityLevel::INTERNAL
+ project.update(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
- expect(helper.can_change_visibility_level?(fork_project, user)).to be_truthy
+ expect(helper.can_change_visibility_level?(forked_project, user)).to be_truthy
end
end
end
@@ -65,6 +75,12 @@ describe ProjectsHelper do
describe "#project_list_cache_key", :clean_gitlab_redis_shared_state do
let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ allow(helper).to receive(:can?).with(user, :read_cross_project) { true }
+ end
it "includes the route" do
expect(helper.project_list_cache_key(project)).to include(project.route.cache_key)
@@ -96,6 +112,10 @@ describe ProjectsHelper do
expect(helper.project_list_cache_key(project).last).to start_with('v')
end
+ it 'includes wether or not the user can read cross project' do
+ expect(helper.project_list_cache_key(project)).to include('cross-project:true')
+ end
+
it "includes the pipeline status when there is a status" do
create(:ci_pipeline, :success, project: project, sha: project.commit.sha)
@@ -205,7 +225,7 @@ describe ProjectsHelper do
let(:expected) { double }
before do
- expect(helper).to receive(:avatar_icon).with(user, 16).and_return(expected)
+ expect(helper).to receive(:avatar_icon_for_user).with(user, 16).and_return(expected)
end
it 'returns image tag for member avatar' do
@@ -254,32 +274,6 @@ describe ProjectsHelper do
end
end
- describe '#license_short_name' do
- let(:project) { create(:project) }
-
- context 'when project.repository has a license_key' do
- it 'returns the nickname of the license if present' do
- allow(project.repository).to receive(:license_key).and_return('agpl-3.0')
-
- expect(helper.license_short_name(project)).to eq('GNU AGPLv3')
- end
-
- it 'returns the name of the license if nickname is not present' do
- allow(project.repository).to receive(:license_key).and_return('mit')
-
- expect(helper.license_short_name(project)).to eq('MIT License')
- end
- end
-
- context 'when project.repository has no license_key but a license_blob' do
- it 'returns LICENSE' do
- allow(project.repository).to receive(:license_key).and_return(nil)
-
- expect(helper.license_short_name(project)).to eq('LICENSE')
- end
- end
- end
-
describe '#sanitized_import_error' do
let(:project) { create(:project, :repository) }
@@ -452,6 +446,22 @@ describe ProjectsHelper do
end
end
+ describe('#push_to_create_project_command') do
+ let(:user) { create(:user, username: 'john') }
+
+ it 'returns the command to push to create project over HTTP' do
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:enabled_git_access_protocol) { 'http' }
+
+ expect(helper.push_to_create_project_command(user)).to eq('git push --set-upstream http://test.host/john/$(git rev-parse --show-toplevel | xargs basename).git $(git rev-parse --abbrev-ref HEAD)')
+ end
+
+ it 'returns the command to push to create project over SSH' do
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:enabled_git_access_protocol) { 'ssh' }
+
+ expect(helper.push_to_create_project_command(user)).to eq('git push --set-upstream git@localhost:john/$(git rev-parse --show-toplevel | xargs basename).git $(git rev-parse --abbrev-ref HEAD)')
+ end
+ end
+
describe '#any_projects?' do
let!(:project) { create(:project) }
diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb
index f55163c26e9..63806ef91f3 100644
--- a/spec/helpers/todos_helper_spec.rb
+++ b/spec/helpers/todos_helper_spec.rb
@@ -26,8 +26,8 @@ describe TodosHelper do
expected_results = [
{ 'id' => '', 'text' => 'Any Project' },
- { 'id' => projects.second.id, 'text' => projects.second.name_with_namespace },
- { 'id' => projects.first.id, 'text' => projects.first.name_with_namespace }
+ { 'id' => projects.second.id, 'text' => projects.second.full_name },
+ { 'id' => projects.first.id, 'text' => projects.first.full_name }
]
expect(JSON.parse(helper.todo_projects_options)).to match_array(expected_results)
diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb
index d3b1be599dd..ffdf6561a53 100644
--- a/spec/helpers/tree_helper_spec.rb
+++ b/spec/helpers/tree_helper_spec.rb
@@ -8,6 +8,7 @@ describe TreeHelper do
describe '.render_tree' do
before do
@id = sha
+ @path = ""
@project = project
@lfs_blob_ids = []
end
@@ -61,5 +62,23 @@ describe TreeHelper do
end
end
end
+
+ context 'when the root path contains a plus character' do
+ let(:root_path) { 'gtk/C++' }
+ let(:tree_item) { double(flat_path: 'gtk/C++/glade') }
+
+ it 'returns the flattened path' do
+ expect(subject).to eq('glade')
+ end
+ end
+ end
+
+ describe '#commit_in_single_accessible_branch' do
+ it 'escapes HTML from the branch name' do
+ helper.instance_variable_set(:@branch_name, "<script>alert('escape me!');</script>")
+ escaped_branch_name = '&lt;script&gt;alert(&#39;escape me!&#39;);&lt;/script&gt;'
+
+ expect(helper.commit_in_single_accessible_branch).to include(escaped_branch_name)
+ end
end
end
diff --git a/spec/helpers/u2f_helper_spec.rb b/spec/helpers/u2f_helper_spec.rb
deleted file mode 100644
index 0d65b4fe0b8..00000000000
--- a/spec/helpers/u2f_helper_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-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/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb
new file mode 100644
index 00000000000..27455705d23
--- /dev/null
+++ b/spec/helpers/user_callouts_helper_spec.rb
@@ -0,0 +1,47 @@
+require "spec_helper"
+
+describe UserCalloutsHelper do
+ let(:user) { create(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ describe '.show_gke_cluster_integration_callout?' do
+ let(:project) { create(:project) }
+
+ subject { helper.show_gke_cluster_integration_callout?(project) }
+
+ context 'when user can create a cluster' do
+ before do
+ allow(helper).to receive(:can?).with(anything, :create_cluster, anything)
+ .and_return(true)
+ end
+
+ context 'when user has not dismissed' do
+ before do
+ allow(helper).to receive(:user_dismissed?).and_return(false)
+ end
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user dismissed' do
+ before do
+ allow(helper).to receive(:user_dismissed?).and_return(true)
+ end
+
+ it { is_expected.to be false }
+ end
+ end
+
+ context 'when user can not create a cluster' do
+ before do
+ allow(helper).to receive(:can?).with(anything, :create_cluster, anything)
+ .and_return(false)
+ end
+
+ it { is_expected.to be false }
+ end
+ end
+end
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index 03f78de8e91..6332217b920 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -14,4 +14,17 @@ describe UsersHelper do
is_expected.to include("title=\"#{user.email}\"")
end
end
+
+ describe '#profile_tabs' do
+ subject(:tabs) { helper.profile_tabs }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ allow(helper).to receive(:can?).and_return(true)
+ end
+
+ it 'includes all the expected tabs' do
+ expect(tabs).to include(:activity, :groups, :contributed, :projects, :snippets)
+ end
+ end
end
diff --git a/spec/helpers/version_check_helper_spec.rb b/spec/helpers/version_check_helper_spec.rb
index fa8cfda3b86..9d4e34abef5 100644
--- a/spec/helpers/version_check_helper_spec.rb
+++ b/spec/helpers/version_check_helper_spec.rb
@@ -4,7 +4,7 @@ describe VersionCheckHelper do
describe '#version_status_badge' do
it 'should return nil if not dev environment and not enabled' do
allow(Rails.env).to receive(:production?) { false }
- allow(helper.current_application_settings).to receive(:version_check_enabled) { false }
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:version_check_enabled) { false }
expect(helper.version_status_badge).to be(nil)
end
@@ -12,7 +12,7 @@ describe VersionCheckHelper do
context 'when production and enabled' do
before do
allow(Rails.env).to receive(:production?) { true }
- allow(helper.current_application_settings).to receive(:version_check_enabled) { true }
+ allow(Gitlab::CurrentSettings.current_application_settings).to receive(:version_check_enabled) { true }
allow_any_instance_of(VersionCheck).to receive(:url) { 'https://version.host.com/check.svg?gitlab_info=xxx' }
@image_tag = helper.version_status_badge
@@ -27,7 +27,7 @@ describe VersionCheckHelper do
end
it 'should have a VersionCheck url as the src' do
- expect(@image_tag).to match(/src="https:\/\/version\.host\.com\/check\.svg\?gitlab_info=xxx"/)
+ expect(@image_tag).to match(%r{src="https://version\.host\.com/check\.svg\?gitlab_info=xxx"})
end
end
end
diff --git a/spec/initializers/6_validations_spec.rb b/spec/initializers/6_validations_spec.rb
index 83283f03940..1dc307ea922 100644
--- a/spec/initializers/6_validations_spec.rb
+++ b/spec/initializers/6_validations_spec.rb
@@ -15,7 +15,7 @@ describe '6_validations' do
describe 'validate_storages_config' do
context 'with correct settings' do
before do
- mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/d' })
+ mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/d'))
end
it 'passes through' do
@@ -25,7 +25,7 @@ describe '6_validations' do
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' })
+ mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c', 'failure_count_threshold' => 'not a number'))
end
it 'throws an error' do
@@ -35,7 +35,7 @@ describe '6_validations' do
context 'with invalid storage names' do
before do
- mock_storages('name with spaces' => { 'path' => 'tmp/tests/paths/a/b/c' })
+ mock_storages('name with spaces' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'))
end
it 'throws an error' do
@@ -67,7 +67,7 @@ describe '6_validations' do
describe 'validate_storages_paths' do
context 'with correct settings' do
before do
- mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/d' })
+ mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/d'))
end
it 'passes through' do
@@ -77,7 +77,7 @@ describe '6_validations' do
context 'with nested storage paths' do
before do
- mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c/d' })
+ mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c/d'))
end
it 'throws an error' do
@@ -87,7 +87,7 @@ describe '6_validations' do
context 'with similar but un-nested storage paths' do
before do
- mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c2' })
+ mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c2'))
end
it 'passes through' do
@@ -97,7 +97,7 @@ describe '6_validations' do
describe 'inaccessible storage' do
before do
- mock_storages('foo' => { 'path' => 'tmp/tests/a/path/that/does/not/exist' })
+ mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/a/path/that/does/not/exist'))
end
it 'passes through with a warning' do
diff --git a/spec/initializers/artifacts_direct_upload_support_spec.rb b/spec/initializers/artifacts_direct_upload_support_spec.rb
new file mode 100644
index 00000000000..bfb71da3388
--- /dev/null
+++ b/spec/initializers/artifacts_direct_upload_support_spec.rb
@@ -0,0 +1,71 @@
+require 'spec_helper'
+
+describe 'Artifacts direct upload support' do
+ subject do
+ load Rails.root.join('config/initializers/artifacts_direct_upload_support.rb')
+ end
+
+ let(:connection) do
+ { provider: provider }
+ end
+
+ before do
+ stub_artifacts_setting(
+ object_store: {
+ enabled: enabled,
+ direct_upload: direct_upload,
+ connection: connection
+ })
+ end
+
+ context 'when object storage is enabled' do
+ let(:enabled) { true }
+
+ context 'when direct upload is enabled' do
+ let(:direct_upload) { true }
+
+ context 'when provider is Google' do
+ let(:provider) { 'Google' }
+
+ it 'succeeds' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when connection is empty' do
+ let(:connection) { nil }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error /object storage provider when 'direct_upload' of artifacts is used/
+ end
+ end
+
+ context 'when other provider is used' do
+ let(:provider) { 'AWS' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error /object storage provider when 'direct_upload' of artifacts is used/
+ end
+ end
+ end
+
+ context 'when direct upload is disabled' do
+ let(:direct_upload) { false }
+ let(:provider) { 'AWS' }
+
+ it 'succeeds' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+
+ context 'when object storage is disabled' do
+ let(:enabled) { false }
+ let(:direct_upload) { false }
+ let(:provider) { 'AWS' }
+
+ it 'succeeds' do
+ expect { subject }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/initializers/fog_google_https_private_urls_spec.rb b/spec/initializers/fog_google_https_private_urls_spec.rb
new file mode 100644
index 00000000000..de3c157ab7b
--- /dev/null
+++ b/spec/initializers/fog_google_https_private_urls_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe 'Fog::Storage::GoogleXML::File' do
+ let(:storage) do
+ Fog.mock!
+ Fog::Storage.new({
+ google_storage_access_key_id: "asdf",
+ google_storage_secret_access_key: "asdf",
+ provider: "Google"
+ })
+ end
+
+ let(:file) do
+ directory = storage.directories.create(key: 'data')
+ directory.files.create(
+ body: 'Hello World!',
+ key: 'hello_world.txt'
+ )
+ end
+
+ it 'delegates to #get_https_url' do
+ expect(file.url(Time.now)).to start_with("https://")
+ end
+end
diff --git a/spec/initializers/gollum_spec.rb b/spec/initializers/gollum_spec.rb
new file mode 100644
index 00000000000..adf824a8947
--- /dev/null
+++ b/spec/initializers/gollum_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+
+describe 'gollum' do
+ let(:project) { create(:project) }
+ let(:user) { project.owner }
+ let(:wiki) { ProjectWiki.new(project, user) }
+ let(:gollum_wiki) { Gollum::Wiki.new(wiki.repository.path) }
+
+ before do
+ create_page(page_name, 'content1')
+ end
+
+ after do
+ destroy_page(page_name)
+ end
+
+ context 'with simple paths' do
+ let(:page_name) { 'page1' }
+
+ it 'returns the entry hash if it matches the file name' do
+ expect(tree_entry(page_name)).not_to be_nil
+ end
+
+ it 'returns nil if the path does not fit completely' do
+ expect(tree_entry("foo/#{page_name}")).to be_nil
+ end
+ end
+
+ context 'with complex paths' do
+ let(:page_name) { '/foo/bar/page2' }
+
+ it 'returns the entry hash if it matches the file name' do
+ expect(tree_entry(page_name)).not_to be_nil
+ end
+
+ it 'returns nil if the path does not fit completely' do
+ expect(tree_entry("foo1/bar/page2")).to be_nil
+ expect(tree_entry("foo/bar1/page2")).to be_nil
+ end
+ end
+
+ def tree_entry(name)
+ gollum_wiki.repo.git.tree_entry(wiki_commits[0].commit, name + '.md')
+ end
+
+ def wiki_commits
+ gollum_wiki.repo.commits
+ end
+
+ def commit_details
+ Gitlab::Git::Wiki::CommitDetails.new(user.name, user.email, "test commit")
+ end
+
+ def create_page(name, content)
+ wiki.wiki.write_page(name, :markdown, content, commit_details)
+ end
+
+ def destroy_page(name)
+ page = wiki.find_page(name).page
+ wiki.delete_page(page, "test commit")
+ end
+end
diff --git a/spec/initializers/grape_route_helpers_fix_spec.rb b/spec/initializers/grape_route_helpers_fix_spec.rb
new file mode 100644
index 00000000000..2cf5924128f
--- /dev/null
+++ b/spec/initializers/grape_route_helpers_fix_spec.rb
@@ -0,0 +1,14 @@
+require 'spec_helper'
+require_relative '../../config/initializers/grape_route_helpers_fix'
+
+describe 'route shadowing' do
+ include GrapeRouteHelpers::NamedRouteMatcher
+
+ it 'does not occur' do
+ path = api_v4_projects_merge_requests_path(id: 1)
+ expect(path).to eq('/api/v4/projects/1/merge_requests')
+
+ path = api_v4_projects_merge_requests_path(id: 1, merge_request_iid: 3)
+ expect(path).to eq('/api/v4/projects/1/merge_requests/3')
+ end
+end
diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb
index a11824d0ac5..57f5adbbc40 100644
--- a/spec/initializers/settings_spec.rb
+++ b/spec/initializers/settings_spec.rb
@@ -1,5 +1,5 @@
require 'spec_helper'
-require_relative '../../config/initializers/1_settings'
+require_relative '../../config/initializers/1_settings' unless defined?(Settings)
describe Settings do
describe '#ldap' do
@@ -24,7 +24,7 @@ describe Settings do
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(described_class.host_without_www('https://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon')).to eq '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'
diff --git a/spec/javascripts/abuse_reports_spec.js b/spec/javascripts/abuse_reports_spec.js
deleted file mode 100644
index 7f6b5873011..00000000000
--- a/spec/javascripts/abuse_reports_spec.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import '~/lib/utils/text_utility';
-import AbuseReports from '~/abuse_reports';
-
-describe('Abuse Reports', () => {
- const FIXTURE = 'abuse_reports/abuse_reports_list.html.raw';
- const MAX_MESSAGE_LENGTH = 500;
-
- let $messages;
-
- const assertMaxLength = $message => expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH);
- const findMessage = searchText => $messages.filter(
- (index, element) => element.innerText.indexOf(searchText) > -1,
- ).first();
-
- preloadFixtures(FIXTURE);
-
- beforeEach(function () {
- loadFixtures(FIXTURE);
- this.abuseReports = new AbuseReports();
- $messages = $('.abuse-reports .message');
- });
-
- it('should truncate long messages', () => {
- const $longMessage = findMessage('LONG MESSAGE');
- expect($longMessage.data('original-message')).toEqual(jasmine.anything());
- assertMaxLength($longMessage);
- });
-
- it('should not truncate short messages', () => {
- const $shortMessage = findMessage('SHORT MESSAGE');
- expect($shortMessage.data('original-message')).not.toEqual(jasmine.anything());
- });
-
- it('should allow clicking a truncated message to expand and collapse the full message', () => {
- const $longMessage = findMessage('LONG MESSAGE');
- $longMessage.click();
- expect($longMessage.data('original-message').length).toEqual($longMessage.text().length);
- $longMessage.click();
- assertMaxLength($longMessage);
- });
-});
diff --git a/spec/javascripts/activities_spec.js b/spec/javascripts/activities_spec.js
index 7a9c539e9d0..909a1bf76bc 100644
--- a/spec/javascripts/activities_spec.js
+++ b/spec/javascripts/activities_spec.js
@@ -1,5 +1,6 @@
/* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow, max-len */
+import $ from 'jquery';
import 'vendor/jquery.endless-scroll';
import Activities from '~/activities';
diff --git a/spec/javascripts/ajax_loading_spinner_spec.js b/spec/javascripts/ajax_loading_spinner_spec.js
index c93b7cc6cac..261375d3a0e 100644
--- a/spec/javascripts/ajax_loading_spinner_spec.js
+++ b/spec/javascripts/ajax_loading_spinner_spec.js
@@ -1,5 +1,4 @@
-import 'jquery';
-import 'jquery-ujs';
+import $ from 'jquery';
import AjaxLoadingSpinner from '~/ajax_loading_spinner';
describe('Ajax Loading Spinner', () => {
@@ -12,7 +11,7 @@ describe('Ajax Loading Spinner', () => {
});
it('change current icon with spinner icon and disable link while waiting ajax response', (done) => {
- spyOn(jQuery, 'ajax').and.callFake((req) => {
+ spyOn($, 'ajax').and.callFake((req) => {
const xhr = new XMLHttpRequest();
const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner');
const icon = ajaxLoadingSpinner.querySelector('i');
@@ -35,7 +34,7 @@ describe('Ajax Loading Spinner', () => {
});
it('use original icon again and enabled the link after complete the ajax request', (done) => {
- spyOn(jQuery, 'ajax').and.callFake((req) => {
+ spyOn($, 'ajax').and.callFake((req) => {
const xhr = new XMLHttpRequest();
const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner');
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js
index 2aa4fb1f6c6..3d7ccf432be 100644
--- a/spec/javascripts/api_spec.js
+++ b/spec/javascripts/api_spec.js
@@ -1,3 +1,5 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Api from '~/api';
describe('Api', () => {
@@ -7,20 +9,17 @@ describe('Api', () => {
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;
+ let mock;
beforeEach(() => {
+ mock = new MockAdapter(axios);
originalGon = window.gon;
window.gon = Object.assign({}, dummyGon);
});
afterEach(() => {
+ mock.restore();
window.gon = originalGon;
});
@@ -36,117 +35,153 @@ describe('Api', () => {
});
describe('group', () => {
- it('fetches a group', (done) => {
+ 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();
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`;
+ mock.onGet(expectedUrl).reply(200, {
+ name: 'test',
});
- Api.group(groupId, (response) => {
- expect(response).toBe(dummyResponse);
+ Api.group(groupId, response => {
+ expect(response.name).toBe('test');
done();
});
});
});
describe('groups', () => {
- it('fetches groups', (done) => {
+ 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);
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ name: 'test',
+ },
+ ]);
+
+ Api.groups(query, options, response => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
done();
});
});
});
describe('namespaces', () => {
- it('fetches namespaces', (done) => {
+ 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);
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ name: 'test',
+ },
+ ]);
+
+ Api.namespaces(query, response => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
done();
});
});
});
describe('projects', () => {
- it('fetches projects with membership when logged in', (done) => {
+ it('fetches projects with membership when logged in', done => {
const query = 'dummy query';
const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
window.gon.current_user_id = 1;
- const expectedData = Object.assign({
- search: query,
- per_page: 20,
- membership: true,
- simple: 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);
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ name: 'test',
+ },
+ ]);
+
+ Api.projects(query, options, response => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
done();
});
});
- it('fetches projects without membership when not logged in', (done) => {
+ it('fetches projects without membership when not logged in', done => {
const query = 'dummy query';
const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
- const expectedData = Object.assign({
- search: query,
- per_page: 20,
- simple: 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();
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ name: 'test',
+ },
+ ]);
+
+ Api.projects(query, options, response => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ done();
});
+ });
+ });
- Api.projects(query, options, (response) => {
- expect(response).toBe(dummyResponse);
- done();
+ describe('mergerequest', () => {
+ it('fetches a merge request', done => {
+ const projectPath = 'abc';
+ const mergeRequestId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`;
+ mock.onGet(expectedUrl).reply(200, {
+ title: 'test',
});
+
+ Api.mergeRequest(projectPath, mergeRequestId)
+ .then(({ data }) => {
+ expect(data.title).toBe('test');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('mergerequest changes', () => {
+ it('fetches the changes of a merge request', done => {
+ const projectPath = 'abc';
+ const mergeRequestId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`;
+ mock.onGet(expectedUrl).reply(200, {
+ title: 'test',
+ });
+
+ Api.mergeRequestChanges(projectPath, mergeRequestId)
+ .then(({ data }) => {
+ expect(data.title).toBe('test');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('mergerequest versions', () => {
+ it('fetches the versions of a merge request', done => {
+ const projectPath = 'abc';
+ const mergeRequestId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`;
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ id: 123,
+ },
+ ]);
+
+ Api.mergeRequestVersions(projectPath, mergeRequestId)
+ .then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].id).toBe(123);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
describe('newLabel', () => {
- it('creates a new label', (done) => {
+ it('creates a new label', done => {
const namespace = 'some namespace';
const project = 'some project';
const labelData = { some: 'data' };
@@ -154,149 +189,153 @@ describe('Api', () => {
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();
+ mock.onPost(expectedUrl).reply(config => {
+ expect(config.data).toBe(JSON.stringify(expectedData));
+
+ return [
+ 200,
+ {
+ name: 'test',
+ },
+ ];
});
- Api.newLabel(namespace, project, labelData, (response) => {
- expect(response).toBe(dummyResponse);
+ Api.newLabel(namespace, project, labelData, response => {
+ expect(response.name).toBe('test');
+ done();
+ });
+ });
+
+ it('creates a group label', done => {
+ const namespace = 'group/subgroup';
+ const labelData = { some: 'data' };
+ const expectedUrl = `${dummyUrlRoot}/groups/${namespace}/-/labels`;
+ const expectedData = {
+ label: labelData,
+ };
+ mock.onPost(expectedUrl).reply(config => {
+ expect(config.data).toBe(JSON.stringify(expectedData));
+
+ return [
+ 200,
+ {
+ name: 'test',
+ },
+ ];
+ });
+
+ Api.newLabel(namespace, undefined, labelData, response => {
+ expect(response.name).toBe('test');
done();
});
});
});
describe('groupProjects', () => {
- it('fetches group projects', (done) => {
+ 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);
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ name: 'test',
+ },
+ ]);
+
+ Api.groupProjects(groupId, query, response => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
done();
});
});
});
describe('licenseText', () => {
- it('fetches a license text', (done) => {
+ 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();
- });
+ mock.onGet(expectedUrl).reply(200, 'test');
- Api.licenseText(licenseKey, data, (response) => {
- expect(response).toBe(dummyResponse);
+ Api.licenseText(licenseKey, data, response => {
+ expect(response).toBe('test');
done();
});
});
});
describe('gitignoreText', () => {
- it('fetches a gitignore text', (done) => {
+ 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);
- });
+ mock.onGet(expectedUrl).reply(200, 'test');
- Api.gitignoreText(gitignoreKey, (response) => {
- expect(response).toBe(dummyResponse);
+ Api.gitignoreText(gitignoreKey, response => {
+ expect(response).toBe('test');
done();
});
});
});
describe('gitlabCiYml', () => {
- it('fetches a .gitlab-ci.yml', (done) => {
+ 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);
- });
+ mock.onGet(expectedUrl).reply(200, 'test');
- Api.gitlabCiYml(gitlabCiYmlKey, (response) => {
- expect(response).toBe(dummyResponse);
+ Api.gitlabCiYml(gitlabCiYmlKey, response => {
+ expect(response).toBe('test');
done();
});
});
});
describe('dockerfileYml', () => {
- it('fetches a Dockerfile', (done) => {
+ 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);
- });
+ mock.onGet(expectedUrl).reply(200, 'test');
- Api.dockerfileYml(dockerfileYmlKey, (response) => {
- expect(response).toBe(dummyResponse);
+ Api.dockerfileYml(dockerfileYmlKey, response => {
+ expect(response).toBe('test');
done();
});
});
});
describe('issueTemplate', () => {
- it('fetches an issue template', (done) => {
+ it('fetches an issue template', done => {
const namespace = 'some namespace';
const project = 'some project';
- const templateKey = 'template key';
+ 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();
- });
+ const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${encodeURIComponent(
+ templateKey,
+ )}`;
+ mock.onGet(expectedUrl).reply(200, 'test');
Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => {
- expect(error).toBe(null);
- expect(response).toBe(dummyResponse);
+ expect(response).toBe('test');
done();
});
});
});
describe('users', () => {
- it('fetches users', (done) => {
+ 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();
- });
+ mock.onGet(expectedUrl).reply(200, [
+ {
+ name: 'test',
+ },
+ ]);
Api.users(query, options)
- .then((response) => {
- expect(response).toBe(dummyResponse);
+ .then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].name).toBe('test');
})
.then(done)
.catch(done.fail);
diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js
index 9f9acc392c2..38ae5b7e00c 100644
--- a/spec/javascripts/autosave_spec.js
+++ b/spec/javascripts/autosave_spec.js
@@ -1,30 +1,27 @@
+import $ from 'jquery';
import Autosave from '~/autosave';
import AccessorUtilities from '~/lib/utils/accessor';
describe('Autosave', () => {
let autosave;
+ const field = $('<textarea></textarea>');
+ const key = 'key';
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', () => {
+ autosave = new Autosave(field, key);
+
expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true);
});
});
describe('restore', () => {
- const key = 'key';
- const field = jasmine.createSpyObj('field', ['trigger']);
-
beforeEach(() => {
autosave = {
field,
@@ -49,24 +46,53 @@ describe('Autosave', () => {
describe('if .isLocalStorageAvailable is `true`', () => {
beforeEach(() => {
autosave.isLocalStorageAvailable = true;
-
- Autosave.prototype.restore.call(autosave);
});
it('should call .getItem', () => {
+ Autosave.prototype.restore.call(autosave);
+
expect(window.localStorage.getItem).toHaveBeenCalledWith(key);
});
+
+ it('triggers jquery event', () => {
+ spyOn(autosave.field, 'trigger').and.callThrough();
+
+ Autosave.prototype.restore.call(autosave);
+
+ expect(
+ field.trigger,
+ ).toHaveBeenCalled();
+ });
+
+ it('triggers native event', (done) => {
+ autosave.field.get(0).addEventListener('change', () => {
+ done();
+ });
+
+ Autosave.prototype.restore.call(autosave);
+ });
+ });
+
+ describe('if field gets deleted from DOM', () => {
+ beforeEach(() => {
+ autosave.field = $('.not-a-real-element');
+ });
+
+ it('does not trigger event', () => {
+ spyOn(field, 'trigger').and.callThrough();
+
+ expect(
+ field.trigger,
+ ).not.toHaveBeenCalled();
+ });
});
});
describe('save', () => {
- const field = jasmine.createSpyObj('field', ['val']);
-
beforeEach(() => {
autosave = jasmine.createSpyObj('autosave', ['reset']);
autosave.field = field;
-
- field.val.and.returnValue('value');
+ field.val('value');
spyOn(window.localStorage, 'setItem');
});
@@ -97,8 +123,6 @@ describe('Autosave', () => {
});
describe('reset', () => {
- const key = 'key';
-
beforeEach(() => {
autosave = {
key,
diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js
index 268b5b83b73..e81055bc08f 100644
--- a/spec/javascripts/awards_handler_spec.js
+++ b/spec/javascripts/awards_handler_spec.js
@@ -1,5 +1,6 @@
/* 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 $ from 'jquery';
import Cookies from 'js-cookie';
import loadAwardsHandler from '~/awards_handler';
@@ -79,7 +80,7 @@ import '~/lib/utils/common_utils';
return expect($emojiMenu.length).toBe(1);
});
});
- return it('should remove emoji menu when body is clicked', function(done) {
+ it('should remove emoji menu when body is clicked', function(done) {
$('.js-add-award').eq(0).click();
return lazyAssert(done, function() {
var $emojiMenu;
@@ -90,6 +91,17 @@ import '~/lib/utils/common_utils';
return expect($('.js-awards-block.current').length).toBe(0);
});
});
+ it('should not remove emoji menu when search is clicked', function(done) {
+ $('.js-add-award').eq(0).click();
+ return lazyAssert(done, function() {
+ var $emojiMenu;
+ $emojiMenu = $('.emoji-menu');
+ $('.emoji-search').click();
+ expect($emojiMenu.length).toBe(1);
+ expect($emojiMenu.hasClass('is-visible')).toBe(true);
+ return expect($('.js-awards-block.current').length).toBe(1);
+ });
+ });
});
describe('::addAwardToEmojiBar', function() {
it('should add emoji to votes block', function() {
@@ -127,7 +139,7 @@ import '~/lib/utils/common_utils';
$thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
$thumbsUpEmoji.attr('data-title', 'sam');
awardsHandler.userAuthored($thumbsUpEmoji);
- return expect($thumbsUpEmoji.data("original-title")).toBe("You cannot vote on your own issue, MR and note");
+ return expect($thumbsUpEmoji.data("originalTitle")).toBe("You cannot vote on your own issue, MR and note");
});
it('should restore tooltip back to initial vote list', function() {
var $thumbsUpEmoji, $votesBlock;
@@ -138,7 +150,7 @@ import '~/lib/utils/common_utils';
awardsHandler.userAuthored($thumbsUpEmoji);
jasmine.clock().tick(2801);
jasmine.clock().uninstall();
- return expect($thumbsUpEmoji.data("original-title")).toBe("sam");
+ return expect($thumbsUpEmoji.data("originalTitle")).toBe("sam");
});
});
describe('::getAwardUrl', function() {
@@ -183,7 +195,7 @@ import '~/lib/utils/common_utils';
$thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
$thumbsUpEmoji.tooltip();
- return expect($thumbsUpEmoji.data("original-title")).toBe('You, sam, jerry, max, and andy');
+ return expect($thumbsUpEmoji.data("originalTitle")).toBe('You, sam, jerry, max, and andy');
});
return it('handles the special case where "You" is not cleanly comma seperated', function() {
var $thumbsUpEmoji, $votesBlock, awardUrl;
@@ -193,7 +205,7 @@ import '~/lib/utils/common_utils';
$thumbsUpEmoji.attr('data-title', 'sam');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
$thumbsUpEmoji.tooltip();
- return expect($thumbsUpEmoji.data("original-title")).toBe('You and sam');
+ return expect($thumbsUpEmoji.data("originalTitle")).toBe('You and sam');
});
});
describe('::removeYouToUserList', function() {
@@ -206,7 +218,7 @@ import '~/lib/utils/common_utils';
$thumbsUpEmoji.addClass('active');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
$thumbsUpEmoji.tooltip();
- return expect($thumbsUpEmoji.data("original-title")).toBe('sam, jerry, max, and andy');
+ return expect($thumbsUpEmoji.data("originalTitle")).toBe('sam, jerry, max, and andy');
});
return it('handles the special case where "You" is not cleanly comma seperated', function() {
var $thumbsUpEmoji, $votesBlock, awardUrl;
@@ -217,7 +229,7 @@ import '~/lib/utils/common_utils';
$thumbsUpEmoji.addClass('active');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
$thumbsUpEmoji.tooltip();
- return expect($thumbsUpEmoji.data("original-title")).toBe('sam');
+ return expect($thumbsUpEmoji.data("originalTitle")).toBe('sam');
});
});
describe('::searchEmojis', () => {
diff --git a/spec/javascripts/badges/components/badge_form_spec.js b/spec/javascripts/badges/components/badge_form_spec.js
new file mode 100644
index 00000000000..dd21ec279cb
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_form_spec.js
@@ -0,0 +1,171 @@
+import Vue from 'vue';
+import store from '~/badges/store';
+import BadgeForm from '~/badges/components/badge_form.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeForm component', () => {
+ const Component = Vue.extend(BadgeForm);
+ let vm;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id="dummy-element"></div>
+ `);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ el: '#dummy-element',
+ store,
+ props: {
+ isEditing: false,
+ },
+ });
+ });
+
+ describe('onCancel', () => {
+ it('calls stopEditing', () => {
+ spyOn(vm, 'stopEditing');
+
+ vm.onCancel();
+
+ expect(vm.stopEditing).toHaveBeenCalled();
+ });
+ });
+
+ describe('onSubmit', () => {
+ describe('if isEditing is true', () => {
+ beforeEach(() => {
+ spyOn(vm, 'saveBadge').and.returnValue(Promise.resolve());
+ store.replaceState({
+ ...store.state,
+ isSaving: false,
+ badgeInEditForm: createDummyBadge(),
+ });
+ vm.isEditing = true;
+ });
+
+ it('returns immediately if imageUrl is empty', () => {
+ store.state.badgeInEditForm.imageUrl = '';
+
+ vm.onSubmit();
+
+ expect(vm.saveBadge).not.toHaveBeenCalled();
+ });
+
+ it('returns immediately if linkUrl is empty', () => {
+ store.state.badgeInEditForm.linkUrl = '';
+
+ vm.onSubmit();
+
+ expect(vm.saveBadge).not.toHaveBeenCalled();
+ });
+
+ it('returns immediately if isSaving is true', () => {
+ store.state.isSaving = true;
+
+ vm.onSubmit();
+
+ expect(vm.saveBadge).not.toHaveBeenCalled();
+ });
+
+ it('calls saveBadge', () => {
+ vm.onSubmit();
+
+ expect(vm.saveBadge).toHaveBeenCalled();
+ });
+ });
+
+ describe('if isEditing is false', () => {
+ beforeEach(() => {
+ spyOn(vm, 'addBadge').and.returnValue(Promise.resolve());
+ store.replaceState({
+ ...store.state,
+ isSaving: false,
+ badgeInAddForm: createDummyBadge(),
+ });
+ vm.isEditing = false;
+ });
+
+ it('returns immediately if imageUrl is empty', () => {
+ store.state.badgeInAddForm.imageUrl = '';
+
+ vm.onSubmit();
+
+ expect(vm.addBadge).not.toHaveBeenCalled();
+ });
+
+ it('returns immediately if linkUrl is empty', () => {
+ store.state.badgeInAddForm.linkUrl = '';
+
+ vm.onSubmit();
+
+ expect(vm.addBadge).not.toHaveBeenCalled();
+ });
+
+ it('returns immediately if isSaving is true', () => {
+ store.state.isSaving = true;
+
+ vm.onSubmit();
+
+ expect(vm.addBadge).not.toHaveBeenCalled();
+ });
+
+ it('calls addBadge', () => {
+ vm.onSubmit();
+
+ expect(vm.addBadge).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('if isEditing is false', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ el: '#dummy-element',
+ store,
+ props: {
+ isEditing: false,
+ },
+ });
+ });
+
+ it('renders one button', () => {
+ const buttons = vm.$el.querySelectorAll('.row-content-block button');
+ expect(buttons.length).toBe(1);
+ const buttonAddElement = buttons[0];
+ expect(buttonAddElement).toBeVisible();
+ expect(buttonAddElement).toHaveText('Add badge');
+ });
+ });
+
+ describe('if isEditing is true', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ el: '#dummy-element',
+ store,
+ props: {
+ isEditing: true,
+ },
+ });
+ });
+
+ it('renders two buttons', () => {
+ const buttons = vm.$el.querySelectorAll('.row-content-block button');
+ expect(buttons.length).toBe(2);
+ const buttonSaveElement = buttons[0];
+ expect(buttonSaveElement).toBeVisible();
+ expect(buttonSaveElement).toHaveText('Save changes');
+ const buttonCancelElement = buttons[1];
+ expect(buttonCancelElement).toBeVisible();
+ expect(buttonCancelElement).toHaveText('Cancel');
+ });
+ });
+});
diff --git a/spec/javascripts/badges/components/badge_list_row_spec.js b/spec/javascripts/badges/components/badge_list_row_spec.js
new file mode 100644
index 00000000000..21bd00d82f0
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_list_row_spec.js
@@ -0,0 +1,97 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
+import store from '~/badges/store';
+import BadgeListRow from '~/badges/components/badge_list_row.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeListRow component', () => {
+ const Component = Vue.extend(BadgeListRow);
+ let badge;
+ let vm;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id="delete-badge-modal" class="modal"></div>
+ <div id="dummy-element"></div>
+ `);
+ store.replaceState({
+ ...store.state,
+ kind: PROJECT_BADGE,
+ });
+ badge = createDummyBadge();
+ vm = mountComponentWithStore(Component, {
+ el: '#dummy-element',
+ store,
+ props: { badge },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders the badge', () => {
+ const badgeElement = vm.$el.querySelector('.project-badge');
+ expect(badgeElement).not.toBeNull();
+ expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
+ });
+
+ it('renders the badge link', () => {
+ expect(vm.$el).toContainText(badge.linkUrl);
+ });
+
+ it('renders the badge kind', () => {
+ expect(vm.$el).toContainText('Project Badge');
+ });
+
+ it('shows edit and delete buttons', () => {
+ const buttons = vm.$el.querySelectorAll('.table-button-footer button');
+ expect(buttons).toHaveLength(2);
+ const buttonEditElement = buttons[0];
+ expect(buttonEditElement).toBeVisible();
+ expect(buttonEditElement).toHaveSpriteIcon('pencil');
+ const buttonDeleteElement = buttons[1];
+ expect(buttonDeleteElement).toBeVisible();
+ expect(buttonDeleteElement).toHaveSpriteIcon('remove');
+ });
+
+ it('calls editBadge when clicking then edit button', () => {
+ spyOn(vm, 'editBadge');
+
+ const editButton = vm.$el.querySelector('.table-button-footer button:first-of-type');
+ editButton.click();
+
+ expect(vm.editBadge).toHaveBeenCalled();
+ });
+
+ it('calls updateBadgeInModal and shows modal when clicking then delete button', done => {
+ spyOn(vm, 'updateBadgeInModal');
+ $('#delete-badge-modal').on('shown.bs.modal', () => done());
+
+ const deleteButton = vm.$el.querySelector('.table-button-footer button:last-of-type');
+ deleteButton.click();
+
+ expect(vm.updateBadgeInModal).toHaveBeenCalled();
+ });
+
+ describe('for a group badge', () => {
+ beforeEach(done => {
+ badge.kind = GROUP_BADGE;
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders the badge kind', () => {
+ expect(vm.$el).toContainText('Group Badge');
+ });
+
+ it('hides edit and delete buttons', () => {
+ const buttons = vm.$el.querySelectorAll('.table-button-footer button');
+ expect(buttons).toHaveLength(0);
+ });
+ });
+});
diff --git a/spec/javascripts/badges/components/badge_list_spec.js b/spec/javascripts/badges/components/badge_list_spec.js
new file mode 100644
index 00000000000..9439c578973
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_list_spec.js
@@ -0,0 +1,88 @@
+import Vue from 'vue';
+import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
+import store from '~/badges/store';
+import BadgeList from '~/badges/components/badge_list.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeList component', () => {
+ const Component = Vue.extend(BadgeList);
+ const numberOfDummyBadges = 3;
+ let vm;
+
+ beforeEach(() => {
+ setFixtures('<div id="dummy-element"></div>');
+ const badges = [];
+ for (let id = 0; id < numberOfDummyBadges; id += 1) {
+ badges.push({ id, ...createDummyBadge() });
+ }
+ store.replaceState({
+ ...store.state,
+ badges,
+ kind: PROJECT_BADGE,
+ isLoading: false,
+ });
+ vm = mountComponentWithStore(Component, {
+ el: '#dummy-element',
+ store,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a header with the badge count', () => {
+ const header = vm.$el.querySelector('.panel-heading');
+ expect(header).toHaveText(new RegExp(`Your badges\\s+${numberOfDummyBadges}`));
+ });
+
+ it('renders a row for each badge', () => {
+ const rows = vm.$el.querySelectorAll('.gl-responsive-table-row');
+ expect(rows).toHaveLength(numberOfDummyBadges);
+ });
+
+ it('renders a message if no badges exist', done => {
+ store.state.badges = [];
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el).toContainText('This project has no badges');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows a loading icon when loading', done => {
+ store.state.isLoading = true;
+
+ Vue.nextTick()
+ .then(() => {
+ const loadingIcon = vm.$el.querySelector('.fa-spinner');
+ expect(loadingIcon).toBeVisible();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('for group badges', () => {
+ beforeEach(done => {
+ store.state.kind = GROUP_BADGE;
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders a message if no badges exist', done => {
+ store.state.badges = [];
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el).toContainText('This group has no badges');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/badges/components/badge_settings_spec.js b/spec/javascripts/badges/components/badge_settings_spec.js
new file mode 100644
index 00000000000..3db02982ad4
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_settings_spec.js
@@ -0,0 +1,109 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import store from '~/badges/store';
+import BadgeSettings from '~/badges/components/badge_settings.vue';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('BadgeSettings component', () => {
+ const Component = Vue.extend(BadgeSettings);
+ let vm;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id="dummy-element"></div>
+ <button
+ id="dummy-modal-button"
+ type="button"
+ data-toggle="modal"
+ data-target="#delete-badge-modal"
+ >Show modal</button>
+ `);
+ vm = mountComponentWithStore(Component, {
+ el: '#dummy-element',
+ store,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('displays modal if button is clicked', done => {
+ const badge = createDummyBadge();
+ store.state.badgeInModal = badge;
+ const modal = vm.$el.querySelector('#delete-badge-modal');
+ const button = document.getElementById('dummy-modal-button');
+
+ $(modal).on('shown.bs.modal', () => {
+ expect(modal).toContainText('Delete badge?');
+ const badgeElement = modal.querySelector('img.project-badge');
+ expect(badgeElement).not.toBe(null);
+ expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
+
+ done();
+ });
+
+ Vue.nextTick()
+ .then(() => {
+ button.click();
+ })
+ .catch(done.fail);
+ });
+
+ it('displays a form to add a badge', () => {
+ const form = vm.$el.querySelector('form:nth-of-type(2)');
+ expect(form).not.toBe(null);
+ const button = form.querySelector('.btn-success');
+ expect(button).not.toBe(null);
+ expect(button).toHaveText(/Add badge/);
+ });
+
+ it('displays badge list', () => {
+ const badgeListElement = vm.$el.querySelector('.panel');
+ expect(badgeListElement).not.toBe(null);
+ expect(badgeListElement).toBeVisible();
+ expect(badgeListElement).toContainText('Your badges');
+ });
+
+ describe('when editing', () => {
+ beforeEach(done => {
+ store.state.isEditing = true;
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays a form to edit a badge', () => {
+ const form = vm.$el.querySelector('form:nth-of-type(1)');
+ expect(form).not.toBe(null);
+ const submitButton = form.querySelector('.btn-success');
+ expect(submitButton).not.toBe(null);
+ expect(submitButton).toHaveText(/Save changes/);
+ const cancelButton = form.querySelector('.btn-cancel');
+ expect(cancelButton).not.toBe(null);
+ expect(cancelButton).toHaveText(/Cancel/);
+ });
+
+ it('displays no badge list', () => {
+ const badgeListElement = vm.$el.querySelector('.panel');
+ expect(badgeListElement).toBeHidden();
+ });
+ });
+
+ describe('methods', () => {
+ describe('onSubmitModal', () => {
+ it('triggers ', () => {
+ spyOn(vm, 'deleteBadge').and.callFake(() => Promise.resolve());
+ const modal = vm.$el.querySelector('#delete-badge-modal');
+ const deleteButton = modal.querySelector('.btn-danger');
+
+ deleteButton.click();
+
+ const badge = store.state.badgeInModal;
+ expect(vm.deleteBadge).toHaveBeenCalledWith(badge);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/badges/components/badge_spec.js b/spec/javascripts/badges/components/badge_spec.js
new file mode 100644
index 00000000000..fd1ecc9cdd8
--- /dev/null
+++ b/spec/javascripts/badges/components/badge_spec.js
@@ -0,0 +1,147 @@
+import Vue from 'vue';
+import Badge from '~/badges/components/badge.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
+
+describe('Badge component', () => {
+ const Component = Vue.extend(Badge);
+ const dummyProps = {
+ imageUrl: DUMMY_IMAGE_URL,
+ linkUrl: `${TEST_HOST}/badge/link/url`,
+ };
+ let vm;
+
+ const findElements = () => {
+ const buttons = vm.$el.querySelectorAll('button');
+ return {
+ badgeImage: vm.$el.querySelector('img.project-badge'),
+ loadingIcon: vm.$el.querySelector('.fa-spinner'),
+ reloadButton: buttons[buttons.length - 1],
+ };
+ };
+
+ const createComponent = (props, el = null) => {
+ vm = mountComponent(Component, props, el);
+ const { badgeImage } = findElements();
+ return new Promise(resolve => badgeImage.addEventListener('load', resolve)).then(() =>
+ Vue.nextTick(),
+ );
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('watchers', () => {
+ describe('imageUrl', () => {
+ it('sets isLoading and resets numRetries and hasError', done => {
+ const props = { ...dummyProps };
+ createComponent(props)
+ .then(() => {
+ expect(vm.isLoading).toBe(false);
+ vm.hasError = true;
+ vm.numRetries = 42;
+
+ vm.imageUrl = `${props.imageUrl}#something/else`;
+
+ return Vue.nextTick();
+ })
+ .then(() => {
+ expect(vm.isLoading).toBe(true);
+ expect(vm.numRetries).toBe(0);
+ expect(vm.hasError).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(done => {
+ createComponent({ ...dummyProps })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('onError resets isLoading and sets hasError', () => {
+ vm.hasError = false;
+ vm.isLoading = true;
+
+ vm.onError();
+
+ expect(vm.hasError).toBe(true);
+ expect(vm.isLoading).toBe(false);
+ });
+
+ it('onLoad sets isLoading', () => {
+ vm.isLoading = true;
+
+ vm.onLoad();
+
+ expect(vm.isLoading).toBe(false);
+ });
+
+ it('reloadImage resets isLoading and hasError and increases numRetries', () => {
+ vm.hasError = true;
+ vm.isLoading = false;
+ vm.numRetries = 0;
+
+ vm.reloadImage();
+
+ expect(vm.hasError).toBe(false);
+ expect(vm.isLoading).toBe(true);
+ expect(vm.numRetries).toBe(1);
+ });
+ });
+
+ describe('behavior', () => {
+ beforeEach(done => {
+ setFixtures('<div id="dummy-element"></div>');
+ createComponent({ ...dummyProps }, '#dummy-element')
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows a badge image after loading', () => {
+ expect(vm.isLoading).toBe(false);
+ expect(vm.hasError).toBe(false);
+ const { badgeImage, loadingIcon, reloadButton } = findElements();
+ expect(badgeImage).toBeVisible();
+ expect(loadingIcon).toBeHidden();
+ expect(reloadButton).toBeHidden();
+ expect(vm.$el.innerText).toBe('');
+ });
+
+ it('shows a loading icon when loading', done => {
+ vm.isLoading = true;
+
+ Vue.nextTick()
+ .then(() => {
+ const { badgeImage, loadingIcon, reloadButton } = findElements();
+ expect(badgeImage).toBeHidden();
+ expect(loadingIcon).toBeVisible();
+ expect(reloadButton).toBeHidden();
+ expect(vm.$el.innerText).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows an error and reload button if loading failed', done => {
+ vm.hasError = true;
+
+ Vue.nextTick()
+ .then(() => {
+ const { badgeImage, loadingIcon, reloadButton } = findElements();
+ expect(badgeImage).toBeHidden();
+ expect(loadingIcon).toBeHidden();
+ expect(reloadButton).toBeVisible();
+ expect(reloadButton).toHaveSpriteIcon('retry');
+ expect(vm.$el.innerText.trim()).toBe('No badge image');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/badges/dummy_badge.js b/spec/javascripts/badges/dummy_badge.js
new file mode 100644
index 00000000000..6aaff21c503
--- /dev/null
+++ b/spec/javascripts/badges/dummy_badge.js
@@ -0,0 +1,23 @@
+import { PROJECT_BADGE } from '~/badges/constants';
+import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
+
+export const createDummyBadge = () => {
+ const id = Math.floor(1000 * Math.random());
+ return {
+ id,
+ imageUrl: `${TEST_HOST}/badges/${id}/image/url`,
+ isDeleting: false,
+ linkUrl: `${TEST_HOST}/badges/${id}/link/url`,
+ kind: PROJECT_BADGE,
+ renderedImageUrl: `${DUMMY_IMAGE_URL}?id=${id}`,
+ renderedLinkUrl: `${TEST_HOST}/badges/${id}/rendered/link/url`,
+ };
+};
+
+export const createDummyBadgeResponse = () => ({
+ image_url: `${TEST_HOST}/badge/image/url`,
+ link_url: `${TEST_HOST}/badge/link/url`,
+ kind: PROJECT_BADGE,
+ rendered_image_url: DUMMY_IMAGE_URL,
+ rendered_link_url: `${TEST_HOST}/rendered/badge/link/url`,
+});
diff --git a/spec/javascripts/badges/store/actions_spec.js b/spec/javascripts/badges/store/actions_spec.js
new file mode 100644
index 00000000000..bb6263c6de4
--- /dev/null
+++ b/spec/javascripts/badges/store/actions_spec.js
@@ -0,0 +1,607 @@
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import actions, { transformBackendBadge } from '~/badges/store/actions';
+import mutationTypes from '~/badges/store/mutation_types';
+import createState from '~/badges/store/state';
+import { TEST_HOST } from 'spec/test_constants';
+import testAction from 'spec/helpers/vuex_action_helper';
+import { createDummyBadge, createDummyBadgeResponse } from '../dummy_badge';
+
+describe('Badges store actions', () => {
+ const dummyEndpointUrl = `${TEST_HOST}/badges/endpoint`;
+ const dummyBadges = [{ ...createDummyBadge(), id: 5 }, { ...createDummyBadge(), id: 6 }];
+
+ let axiosMock;
+ let badgeId;
+ let state;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ state = {
+ ...createState(),
+ apiEndpointUrl: dummyEndpointUrl,
+ badges: dummyBadges,
+ };
+ badgeId = state.badges[0].id;
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
+ describe('requestNewBadge', () => {
+ it('commits REQUEST_NEW_BADGE', done => {
+ testAction(
+ actions.requestNewBadge,
+ null,
+ state,
+ [{ type: mutationTypes.REQUEST_NEW_BADGE }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveNewBadge', () => {
+ it('commits RECEIVE_NEW_BADGE', done => {
+ const newBadge = createDummyBadge();
+ testAction(
+ actions.receiveNewBadge,
+ newBadge,
+ state,
+ [{ type: mutationTypes.RECEIVE_NEW_BADGE, payload: newBadge }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveNewBadgeError', () => {
+ it('commits RECEIVE_NEW_BADGE_ERROR', done => {
+ testAction(
+ actions.receiveNewBadgeError,
+ null,
+ state,
+ [{ type: mutationTypes.RECEIVE_NEW_BADGE_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('addBadge', () => {
+ let badgeInAddForm;
+ let dispatch;
+ let endpointMock;
+
+ beforeEach(() => {
+ endpointMock = axiosMock.onPost(dummyEndpointUrl);
+ dispatch = jasmine.createSpy('dispatch');
+ badgeInAddForm = createDummyBadge();
+ state = {
+ ...state,
+ badgeInAddForm,
+ };
+ });
+
+ it('dispatches requestNewBadge and receiveNewBadge for successful response', done => {
+ const dummyResponse = createDummyBadgeResponse();
+
+ endpointMock.replyOnce(req => {
+ expect(req.data).toBe(
+ JSON.stringify({
+ image_url: badgeInAddForm.imageUrl,
+ link_url: badgeInAddForm.linkUrl,
+ }),
+ );
+ expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]);
+ dispatch.calls.reset();
+ return [200, dummyResponse];
+ });
+
+ const dummyBadge = transformBackendBadge(dummyResponse);
+ actions
+ .addBadge({ state, dispatch })
+ .then(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadge', dummyBadge]]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('dispatches requestNewBadge and receiveNewBadgeError for error response', done => {
+ endpointMock.replyOnce(req => {
+ expect(req.data).toBe(
+ JSON.stringify({
+ image_url: badgeInAddForm.imageUrl,
+ link_url: badgeInAddForm.linkUrl,
+ }),
+ );
+ expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]);
+ dispatch.calls.reset();
+ return [500, ''];
+ });
+
+ actions
+ .addBadge({ state, dispatch })
+ .then(() => done.fail('Expected Ajax call to fail!'))
+ .catch(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadgeError']]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('requestDeleteBadge', () => {
+ it('commits REQUEST_DELETE_BADGE', done => {
+ testAction(
+ actions.requestDeleteBadge,
+ badgeId,
+ state,
+ [{ type: mutationTypes.REQUEST_DELETE_BADGE, payload: badgeId }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveDeleteBadge', () => {
+ it('commits RECEIVE_DELETE_BADGE', done => {
+ testAction(
+ actions.receiveDeleteBadge,
+ badgeId,
+ state,
+ [{ type: mutationTypes.RECEIVE_DELETE_BADGE, payload: badgeId }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveDeleteBadgeError', () => {
+ it('commits RECEIVE_DELETE_BADGE_ERROR', done => {
+ testAction(
+ actions.receiveDeleteBadgeError,
+ badgeId,
+ state,
+ [{ type: mutationTypes.RECEIVE_DELETE_BADGE_ERROR, payload: badgeId }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('deleteBadge', () => {
+ let dispatch;
+ let endpointMock;
+
+ beforeEach(() => {
+ endpointMock = axiosMock.onDelete(`${dummyEndpointUrl}/${badgeId}`);
+ dispatch = jasmine.createSpy('dispatch');
+ });
+
+ it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', done => {
+ endpointMock.replyOnce(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]);
+ dispatch.calls.reset();
+ return [200, ''];
+ });
+
+ actions
+ .deleteBadge({ state, dispatch }, { id: badgeId })
+ .then(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadge', badgeId]]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', done => {
+ endpointMock.replyOnce(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]);
+ dispatch.calls.reset();
+ return [500, ''];
+ });
+
+ actions
+ .deleteBadge({ state, dispatch }, { id: badgeId })
+ .then(() => done.fail('Expected Ajax call to fail!'))
+ .catch(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadgeError', badgeId]]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('editBadge', () => {
+ it('commits START_EDITING', done => {
+ const dummyBadge = createDummyBadge();
+ testAction(
+ actions.editBadge,
+ dummyBadge,
+ state,
+ [{ type: mutationTypes.START_EDITING, payload: dummyBadge }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestLoadBadges', () => {
+ it('commits REQUEST_LOAD_BADGES', done => {
+ const dummyData = 'this is not real data';
+ testAction(
+ actions.requestLoadBadges,
+ dummyData,
+ state,
+ [{ type: mutationTypes.REQUEST_LOAD_BADGES, payload: dummyData }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveLoadBadges', () => {
+ it('commits RECEIVE_LOAD_BADGES', done => {
+ const badges = dummyBadges;
+ testAction(
+ actions.receiveLoadBadges,
+ badges,
+ state,
+ [{ type: mutationTypes.RECEIVE_LOAD_BADGES, payload: badges }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveLoadBadgesError', () => {
+ it('commits RECEIVE_LOAD_BADGES_ERROR', done => {
+ testAction(
+ actions.receiveLoadBadgesError,
+ null,
+ state,
+ [{ type: mutationTypes.RECEIVE_LOAD_BADGES_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('loadBadges', () => {
+ let dispatch;
+ let endpointMock;
+
+ beforeEach(() => {
+ endpointMock = axiosMock.onGet(dummyEndpointUrl);
+ dispatch = jasmine.createSpy('dispatch');
+ });
+
+ it('dispatches requestLoadBadges and receiveLoadBadges for successful response', done => {
+ const dummyData = 'this is just some data';
+ const dummyReponse = [
+ createDummyBadgeResponse(),
+ createDummyBadgeResponse(),
+ createDummyBadgeResponse(),
+ ];
+ endpointMock.replyOnce(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]);
+ dispatch.calls.reset();
+ return [200, dummyReponse];
+ });
+
+ actions
+ .loadBadges({ state, dispatch }, dummyData)
+ .then(() => {
+ const badges = dummyReponse.map(transformBackendBadge);
+ expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadges', badges]]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', done => {
+ const dummyData = 'this is just some data';
+ endpointMock.replyOnce(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]);
+ dispatch.calls.reset();
+ return [500, ''];
+ });
+
+ actions
+ .loadBadges({ state, dispatch }, dummyData)
+ .then(() => done.fail('Expected Ajax call to fail!'))
+ .catch(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadgesError']]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('requestRenderedBadge', () => {
+ it('commits REQUEST_RENDERED_BADGE', done => {
+ testAction(
+ actions.requestRenderedBadge,
+ null,
+ state,
+ [{ type: mutationTypes.REQUEST_RENDERED_BADGE }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveRenderedBadge', () => {
+ it('commits RECEIVE_RENDERED_BADGE', done => {
+ const dummyBadge = createDummyBadge();
+ testAction(
+ actions.receiveRenderedBadge,
+ dummyBadge,
+ state,
+ [{ type: mutationTypes.RECEIVE_RENDERED_BADGE, payload: dummyBadge }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveRenderedBadgeError', () => {
+ it('commits RECEIVE_RENDERED_BADGE_ERROR', done => {
+ testAction(
+ actions.receiveRenderedBadgeError,
+ null,
+ state,
+ [{ type: mutationTypes.RECEIVE_RENDERED_BADGE_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('renderBadge', () => {
+ let dispatch;
+ let endpointMock;
+ let badgeInForm;
+
+ beforeEach(() => {
+ badgeInForm = createDummyBadge();
+ state = {
+ ...state,
+ badgeInAddForm: badgeInForm,
+ };
+ const urlParameters = [
+ `link_url=${encodeURIComponent(badgeInForm.linkUrl)}`,
+ `image_url=${encodeURIComponent(badgeInForm.imageUrl)}`,
+ ].join('&');
+ endpointMock = axiosMock.onGet(`${dummyEndpointUrl}/render?${urlParameters}`);
+ dispatch = jasmine.createSpy('dispatch');
+ });
+
+ it('returns immediately if imageUrl is empty', done => {
+ spyOn(axios, 'get');
+ badgeInForm.imageUrl = '';
+
+ actions
+ .renderBadge({ state, dispatch })
+ .then(() => {
+ expect(axios.get).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns immediately if linkUrl is empty', done => {
+ spyOn(axios, 'get');
+ badgeInForm.linkUrl = '';
+
+ actions
+ .renderBadge({ state, dispatch })
+ .then(() => {
+ expect(axios.get).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('escapes user input', done => {
+ spyOn(axios, 'get').and.callFake(() => Promise.resolve({ data: createDummyBadgeResponse() }));
+ badgeInForm.imageUrl = '&make-sandwhich=true';
+ badgeInForm.linkUrl = '<script>I am dangerous!</script>';
+
+ actions
+ .renderBadge({ state, dispatch })
+ .then(() => {
+ expect(axios.get.calls.count()).toBe(1);
+ const url = axios.get.calls.argsFor(0)[0];
+ expect(url).toMatch(`^${dummyEndpointUrl}/render?`);
+ expect(url).toMatch('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&');
+ expect(url).toMatch('&image_url=%26make-sandwhich%3Dtrue$');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', done => {
+ const dummyReponse = createDummyBadgeResponse();
+ endpointMock.replyOnce(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]);
+ dispatch.calls.reset();
+ return [200, dummyReponse];
+ });
+
+ actions
+ .renderBadge({ state, dispatch })
+ .then(() => {
+ const renderedBadge = transformBackendBadge(dummyReponse);
+ expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadge', renderedBadge]]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', done => {
+ endpointMock.replyOnce(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]);
+ dispatch.calls.reset();
+ return [500, ''];
+ });
+
+ actions
+ .renderBadge({ state, dispatch })
+ .then(() => done.fail('Expected Ajax call to fail!'))
+ .catch(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadgeError']]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('requestUpdatedBadge', () => {
+ it('commits REQUEST_UPDATED_BADGE', done => {
+ testAction(
+ actions.requestUpdatedBadge,
+ null,
+ state,
+ [{ type: mutationTypes.REQUEST_UPDATED_BADGE }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUpdatedBadge', () => {
+ it('commits RECEIVE_UPDATED_BADGE', done => {
+ const updatedBadge = createDummyBadge();
+ testAction(
+ actions.receiveUpdatedBadge,
+ updatedBadge,
+ state,
+ [{ type: mutationTypes.RECEIVE_UPDATED_BADGE, payload: updatedBadge }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUpdatedBadgeError', () => {
+ it('commits RECEIVE_UPDATED_BADGE_ERROR', done => {
+ testAction(
+ actions.receiveUpdatedBadgeError,
+ null,
+ state,
+ [{ type: mutationTypes.RECEIVE_UPDATED_BADGE_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('saveBadge', () => {
+ let badgeInEditForm;
+ let dispatch;
+ let endpointMock;
+
+ beforeEach(() => {
+ badgeInEditForm = createDummyBadge();
+ state = {
+ ...state,
+ badgeInEditForm,
+ };
+ endpointMock = axiosMock.onPut(`${dummyEndpointUrl}/${badgeInEditForm.id}`);
+ dispatch = jasmine.createSpy('dispatch');
+ });
+
+ it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', done => {
+ const dummyResponse = createDummyBadgeResponse();
+
+ endpointMock.replyOnce(req => {
+ expect(req.data).toBe(
+ JSON.stringify({
+ image_url: badgeInEditForm.imageUrl,
+ link_url: badgeInEditForm.linkUrl,
+ }),
+ );
+ expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]);
+ dispatch.calls.reset();
+ return [200, dummyResponse];
+ });
+
+ const updatedBadge = transformBackendBadge(dummyResponse);
+ actions
+ .saveBadge({ state, dispatch })
+ .then(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadge', updatedBadge]]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', done => {
+ endpointMock.replyOnce(req => {
+ expect(req.data).toBe(
+ JSON.stringify({
+ image_url: badgeInEditForm.imageUrl,
+ link_url: badgeInEditForm.linkUrl,
+ }),
+ );
+ expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]);
+ dispatch.calls.reset();
+ return [500, ''];
+ });
+
+ actions
+ .saveBadge({ state, dispatch })
+ .then(() => done.fail('Expected Ajax call to fail!'))
+ .catch(() => {
+ expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadgeError']]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('stopEditing', () => {
+ it('commits STOP_EDITING', done => {
+ testAction(
+ actions.stopEditing,
+ null,
+ state,
+ [{ type: mutationTypes.STOP_EDITING }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('updateBadgeInForm', () => {
+ it('commits UPDATE_BADGE_IN_FORM', done => {
+ const dummyBadge = createDummyBadge();
+ testAction(
+ actions.updateBadgeInForm,
+ dummyBadge,
+ state,
+ [{ type: mutationTypes.UPDATE_BADGE_IN_FORM, payload: dummyBadge }],
+ [],
+ done,
+ );
+ });
+
+ describe('updateBadgeInModal', () => {
+ it('commits UPDATE_BADGE_IN_MODAL', done => {
+ const dummyBadge = createDummyBadge();
+ testAction(
+ actions.updateBadgeInModal,
+ dummyBadge,
+ state,
+ [{ type: mutationTypes.UPDATE_BADGE_IN_MODAL, payload: dummyBadge }],
+ [],
+ done,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/badges/store/mutations_spec.js b/spec/javascripts/badges/store/mutations_spec.js
new file mode 100644
index 00000000000..8d26f83339d
--- /dev/null
+++ b/spec/javascripts/badges/store/mutations_spec.js
@@ -0,0 +1,418 @@
+import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
+import store from '~/badges/store';
+import types from '~/badges/store/mutation_types';
+import createState from '~/badges/store/state';
+import { createDummyBadge } from '../dummy_badge';
+
+describe('Badges store mutations', () => {
+ let dummyBadge;
+
+ beforeEach(() => {
+ dummyBadge = createDummyBadge();
+ store.replaceState(createState());
+ });
+
+ describe('RECEIVE_DELETE_BADGE', () => {
+ beforeEach(() => {
+ const badges = [
+ { ...dummyBadge, id: dummyBadge.id - 1 },
+ dummyBadge,
+ { ...dummyBadge, id: dummyBadge.id + 1 },
+ ];
+
+ store.replaceState({
+ ...store.state,
+ badges,
+ });
+ });
+
+ it('removes deleted badge', () => {
+ const badgeCount = store.state.badges.length;
+
+ store.commit(types.RECEIVE_DELETE_BADGE, dummyBadge.id);
+
+ expect(store.state.badges.length).toBe(badgeCount - 1);
+ expect(store.state.badges.indexOf(dummyBadge)).toBe(-1);
+ });
+ });
+
+ describe('RECEIVE_DELETE_BADGE_ERROR', () => {
+ beforeEach(() => {
+ const badges = [
+ { ...dummyBadge, id: dummyBadge.id - 1, isDeleting: false },
+ { ...dummyBadge, isDeleting: true },
+ { ...dummyBadge, id: dummyBadge.id + 1, isDeleting: true },
+ ];
+
+ store.replaceState({
+ ...store.state,
+ badges,
+ });
+ });
+
+ it('sets isDeleting to false', () => {
+ const badgeCount = store.state.badges.length;
+
+ store.commit(types.RECEIVE_DELETE_BADGE_ERROR, dummyBadge.id);
+
+ expect(store.state.badges.length).toBe(badgeCount);
+ expect(store.state.badges[0].isDeleting).toBe(false);
+ expect(store.state.badges[1].isDeleting).toBe(false);
+ expect(store.state.badges[2].isDeleting).toBe(true);
+ });
+ });
+
+ describe('RECEIVE_LOAD_BADGES', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isLoading: 'not false',
+ });
+ });
+
+ it('sets badges and isLoading to false', () => {
+ const badges = [createDummyBadge()];
+ store.commit(types.RECEIVE_LOAD_BADGES, badges);
+
+ expect(store.state.isLoading).toBe(false);
+ expect(store.state.badges).toBe(badges);
+ });
+ });
+
+ describe('RECEIVE_LOAD_BADGES_ERROR', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isLoading: 'not false',
+ });
+ });
+
+ it('sets isLoading to false', () => {
+ store.commit(types.RECEIVE_LOAD_BADGES_ERROR);
+
+ expect(store.state.isLoading).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_NEW_BADGE', () => {
+ beforeEach(() => {
+ const badges = [
+ { ...dummyBadge, id: dummyBadge.id - 1, kind: GROUP_BADGE },
+ { ...dummyBadge, id: dummyBadge.id + 1, kind: GROUP_BADGE },
+ { ...dummyBadge, id: dummyBadge.id - 1, kind: PROJECT_BADGE },
+ { ...dummyBadge, id: dummyBadge.id + 1, kind: PROJECT_BADGE },
+ ];
+ store.replaceState({
+ ...store.state,
+ badgeInAddForm: createDummyBadge(),
+ badges,
+ isSaving: 'dummy value',
+ renderedBadge: createDummyBadge(),
+ });
+ });
+
+ it('resets the add form', () => {
+ store.commit(types.RECEIVE_NEW_BADGE, dummyBadge);
+
+ expect(store.state.badgeInAddForm).toBe(null);
+ expect(store.state.isSaving).toBe(false);
+ expect(store.state.renderedBadge).toBe(null);
+ });
+
+ it('inserts group badge at correct position', () => {
+ const badgeCount = store.state.badges.length;
+ dummyBadge = { ...dummyBadge, kind: GROUP_BADGE };
+
+ store.commit(types.RECEIVE_NEW_BADGE, dummyBadge);
+
+ expect(store.state.badges.length).toBe(badgeCount + 1);
+ expect(store.state.badges.indexOf(dummyBadge)).toBe(1);
+ });
+
+ it('inserts project badge at correct position', () => {
+ const badgeCount = store.state.badges.length;
+ dummyBadge = { ...dummyBadge, kind: PROJECT_BADGE };
+
+ store.commit(types.RECEIVE_NEW_BADGE, dummyBadge);
+
+ expect(store.state.badges.length).toBe(badgeCount + 1);
+ expect(store.state.badges.indexOf(dummyBadge)).toBe(3);
+ });
+ });
+
+ describe('RECEIVE_NEW_BADGE_ERROR', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isSaving: 'dummy value',
+ });
+ });
+
+ it('sets isSaving to false', () => {
+ store.commit(types.RECEIVE_NEW_BADGE_ERROR);
+
+ expect(store.state.isSaving).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_RENDERED_BADGE', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isRendering: 'dummy value',
+ renderedBadge: 'dummy value',
+ });
+ });
+
+ it('sets renderedBadge', () => {
+ store.commit(types.RECEIVE_RENDERED_BADGE, dummyBadge);
+
+ expect(store.state.isRendering).toBe(false);
+ expect(store.state.renderedBadge).toBe(dummyBadge);
+ });
+ });
+
+ describe('RECEIVE_RENDERED_BADGE_ERROR', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isRendering: 'dummy value',
+ });
+ });
+
+ it('sets isRendering to false', () => {
+ store.commit(types.RECEIVE_RENDERED_BADGE_ERROR);
+
+ expect(store.state.isRendering).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_UPDATED_BADGE', () => {
+ beforeEach(() => {
+ const badges = [
+ { ...dummyBadge, id: dummyBadge.id - 1 },
+ dummyBadge,
+ { ...dummyBadge, id: dummyBadge.id + 1 },
+ ];
+ store.replaceState({
+ ...store.state,
+ badgeInEditForm: createDummyBadge(),
+ badges,
+ isEditing: 'dummy value',
+ isSaving: 'dummy value',
+ renderedBadge: createDummyBadge(),
+ });
+ });
+
+ it('resets the edit form', () => {
+ store.commit(types.RECEIVE_UPDATED_BADGE, dummyBadge);
+
+ expect(store.state.badgeInAddForm).toBe(null);
+ expect(store.state.isSaving).toBe(false);
+ expect(store.state.renderedBadge).toBe(null);
+ });
+
+ it('replaces the updated badge', () => {
+ const badgeCount = store.state.badges.length;
+ const badgeIndex = store.state.badges.indexOf(dummyBadge);
+ const newBadge = { id: dummyBadge.id, dummy: 'value' };
+
+ store.commit(types.RECEIVE_UPDATED_BADGE, newBadge);
+
+ expect(store.state.badges.length).toBe(badgeCount);
+ expect(store.state.badges[badgeIndex]).toBe(newBadge);
+ });
+ });
+
+ describe('RECEIVE_UPDATED_BADGE_ERROR', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isSaving: 'dummy value',
+ });
+ });
+
+ it('sets isSaving to false', () => {
+ store.commit(types.RECEIVE_NEW_BADGE_ERROR);
+
+ expect(store.state.isSaving).toBe(false);
+ });
+ });
+
+ describe('REQUEST_DELETE_BADGE', () => {
+ beforeEach(() => {
+ const badges = [
+ { ...dummyBadge, id: dummyBadge.id - 1, isDeleting: false },
+ { ...dummyBadge, isDeleting: false },
+ { ...dummyBadge, id: dummyBadge.id + 1, isDeleting: true },
+ ];
+
+ store.replaceState({
+ ...store.state,
+ badges,
+ });
+ });
+
+ it('sets isDeleting to true', () => {
+ const badgeCount = store.state.badges.length;
+
+ store.commit(types.REQUEST_DELETE_BADGE, dummyBadge.id);
+
+ expect(store.state.badges.length).toBe(badgeCount);
+ expect(store.state.badges[0].isDeleting).toBe(false);
+ expect(store.state.badges[1].isDeleting).toBe(true);
+ expect(store.state.badges[2].isDeleting).toBe(true);
+ });
+ });
+
+ describe('REQUEST_LOAD_BADGES', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ apiEndpointUrl: 'some endpoint',
+ docsUrl: 'some url',
+ isLoading: 'dummy value',
+ kind: 'some kind',
+ });
+ });
+
+ it('sets isLoading to true and initializes the store', () => {
+ const dummyData = {
+ apiEndpointUrl: 'dummy endpoint',
+ docsUrl: 'dummy url',
+ kind: 'dummy kind',
+ };
+
+ store.commit(types.REQUEST_LOAD_BADGES, dummyData);
+
+ expect(store.state.isLoading).toBe(true);
+ expect(store.state.apiEndpointUrl).toBe(dummyData.apiEndpointUrl);
+ expect(store.state.docsUrl).toBe(dummyData.docsUrl);
+ expect(store.state.kind).toBe(dummyData.kind);
+ });
+ });
+
+ describe('REQUEST_NEW_BADGE', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isSaving: 'dummy value',
+ });
+ });
+
+ it('sets isSaving to true', () => {
+ store.commit(types.REQUEST_NEW_BADGE);
+
+ expect(store.state.isSaving).toBe(true);
+ });
+ });
+
+ describe('REQUEST_RENDERED_BADGE', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isRendering: 'dummy value',
+ });
+ });
+
+ it('sets isRendering to true', () => {
+ store.commit(types.REQUEST_RENDERED_BADGE);
+
+ expect(store.state.isRendering).toBe(true);
+ });
+ });
+
+ describe('REQUEST_UPDATED_BADGE', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ isSaving: 'dummy value',
+ });
+ });
+
+ it('sets isSaving to true', () => {
+ store.commit(types.REQUEST_NEW_BADGE);
+
+ expect(store.state.isSaving).toBe(true);
+ });
+ });
+
+ describe('START_EDITING', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ badgeInEditForm: 'dummy value',
+ isEditing: 'dummy value',
+ renderedBadge: 'dummy value',
+ });
+ });
+
+ it('initializes the edit form', () => {
+ store.commit(types.START_EDITING, dummyBadge);
+
+ expect(store.state.isEditing).toBe(true);
+ expect(store.state.badgeInEditForm).toEqual(dummyBadge);
+ expect(store.state.renderedBadge).toEqual(dummyBadge);
+ });
+ });
+
+ describe('STOP_EDITING', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ badgeInEditForm: 'dummy value',
+ isEditing: 'dummy value',
+ renderedBadge: 'dummy value',
+ });
+ });
+
+ it('resets the edit form', () => {
+ store.commit(types.STOP_EDITING);
+
+ expect(store.state.isEditing).toBe(false);
+ expect(store.state.badgeInEditForm).toBe(null);
+ expect(store.state.renderedBadge).toBe(null);
+ });
+ });
+
+ describe('UPDATE_BADGE_IN_FORM', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ badgeInAddForm: 'dummy value',
+ badgeInEditForm: 'dummy value',
+ });
+ });
+
+ it('sets badgeInEditForm if isEditing is true', () => {
+ store.state.isEditing = true;
+
+ store.commit(types.UPDATE_BADGE_IN_FORM, dummyBadge);
+
+ expect(store.state.badgeInEditForm).toBe(dummyBadge);
+ });
+
+ it('sets badgeInAddForm if isEditing is false', () => {
+ store.state.isEditing = false;
+
+ store.commit(types.UPDATE_BADGE_IN_FORM, dummyBadge);
+
+ expect(store.state.badgeInAddForm).toBe(dummyBadge);
+ });
+ });
+
+ describe('UPDATE_BADGE_IN_MODAL', () => {
+ beforeEach(() => {
+ store.replaceState({
+ ...store.state,
+ badgeInModal: 'dummy value',
+ });
+ });
+
+ it('sets badgeInModal', () => {
+ store.commit(types.UPDATE_BADGE_IN_MODAL, dummyBadge);
+
+ expect(store.state.badgeInModal).toBe(dummyBadge);
+ });
+ });
+});
diff --git a/spec/javascripts/behaviors/autosize_spec.js b/spec/javascripts/behaviors/autosize_spec.js
index 960b731892a..c411c5174fb 100644
--- a/spec/javascripts/behaviors/autosize_spec.js
+++ b/spec/javascripts/behaviors/autosize_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import '~/behaviors/autosize';
function load() {
diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js
index b8155144e2a..efbe09a10a2 100644
--- a/spec/javascripts/behaviors/copy_as_gfm_spec.js
+++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js
@@ -1,4 +1,4 @@
-import { CopyAsGFM } from '~/behaviors/copy_as_gfm';
+import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
describe('CopyAsGFM', () => {
describe('CopyAsGFM.pasteGFM', () => {
diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js
index d5300d9c63d..c37c62c63dd 100644
--- a/spec/javascripts/behaviors/quick_submit_spec.js
+++ b/spec/javascripts/behaviors/quick_submit_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import '~/behaviors/quick_submit';
describe('Quick Submit behavior', () => {
diff --git a/spec/javascripts/behaviors/requires_input_spec.js b/spec/javascripts/behaviors/requires_input_spec.js
index 8287c58ac5a..a434949b9da 100644
--- a/spec/javascripts/behaviors/requires_input_spec.js
+++ b/spec/javascripts/behaviors/requires_input_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import '~/behaviors/requires_input';
describe('requiresInput', () => {
@@ -15,7 +16,7 @@ describe('requiresInput', () => {
});
it('enables submit when no field is required', () => {
- $('*[required=required]').removeAttr('required');
+ $('*[required=required]').prop('required', false);
$('.js-requires-input').requiresInput();
expect(submitButton).not.toBeDisabled();
});
diff --git a/spec/javascripts/behaviors/secret_values_spec.js b/spec/javascripts/behaviors/secret_values_spec.js
index 9eeae474e7d..38d9bba6868 100644
--- a/spec/javascripts/behaviors/secret_values_spec.js
+++ b/spec/javascripts/behaviors/secret_values_spec.js
@@ -1,16 +1,24 @@
import SecretValues from '~/behaviors/secret_values';
-function generateFixtureMarkup(secrets, isRevealed) {
+function generateValueMarkup(
+ secret,
+ valueClass = 'js-secret-value',
+ placeholderClass = 'js-secret-value-placeholder',
+) {
+ return `
+ <div class="${placeholderClass}">
+ ***
+ </div>
+ <div class="hide ${valueClass}">
+ ${secret}
+ </div>
+ `;
+}
+
+function generateFixtureMarkup(secrets, isRevealed, valueClass, placeholderClass) {
return `
<div class="js-secret-container">
- ${secrets.map(secret => `
- <div class="js-secret-value-placeholder">
- ***
- </div>
- <div class="hide js-secret-value">
- ${secret}
- </div>
- `).join('')}
+ ${secrets.map(secret => generateValueMarkup(secret, valueClass, placeholderClass)).join('')}
<button
class="js-secret-value-reveal-button"
data-secret-reveal-status="${isRevealed}"
@@ -21,11 +29,25 @@ function generateFixtureMarkup(secrets, isRevealed) {
`;
}
-function setupSecretFixture(secrets, isRevealed) {
+function setupSecretFixture(
+ secrets,
+ isRevealed,
+ valueClass = 'js-secret-value',
+ placeholderClass = 'js-secret-value-placeholder',
+) {
const wrapper = document.createElement('div');
- wrapper.innerHTML = generateFixtureMarkup(secrets, isRevealed);
-
- const secretValues = new SecretValues(wrapper.querySelector('.js-secret-container'));
+ wrapper.innerHTML = generateFixtureMarkup(
+ secrets,
+ isRevealed,
+ valueClass,
+ placeholderClass,
+ );
+
+ const secretValues = new SecretValues({
+ container: wrapper.querySelector('.js-secret-container'),
+ valueSelector: `.${valueClass}`,
+ placeholderSelector: `.${placeholderClass}`,
+ });
secretValues.init();
return wrapper;
@@ -49,7 +71,7 @@ describe('setupSecretValues', () => {
expect(revealButton.textContent).toEqual('Hide value');
});
- it('should value hidden initially', () => {
+ it('should have value hidden initially', () => {
const wrapper = setupSecretFixture(secrets, false);
const values = wrapper.querySelectorAll('.js-secret-value');
const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
@@ -143,4 +165,64 @@ describe('setupSecretValues', () => {
});
});
});
+
+ describe('with dynamic secrets', () => {
+ const secrets = ['mysecret123', 'happygoat456', 'tanuki789'];
+
+ it('should toggle values and placeholders', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ // Insert the new dynamic row
+ wrapper.querySelector('.js-secret-container').insertAdjacentHTML('afterbegin', generateValueMarkup('foobarbazdynamic'));
+
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+ const values = wrapper.querySelectorAll('.js-secret-value');
+ const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
+
+ revealButton.click();
+
+ expect(values.length).toEqual(4);
+ values.forEach((value) => {
+ expect(value.classList.contains('hide')).toEqual(false);
+ });
+ expect(placeholders.length).toEqual(4);
+ placeholders.forEach((placeholder) => {
+ expect(placeholder.classList.contains('hide')).toEqual(true);
+ });
+
+ revealButton.click();
+
+ expect(values.length).toEqual(4);
+ values.forEach((value) => {
+ expect(value.classList.contains('hide')).toEqual(true);
+ });
+ expect(placeholders.length).toEqual(4);
+ placeholders.forEach((placeholder) => {
+ expect(placeholder.classList.contains('hide')).toEqual(false);
+ });
+ });
+ });
+
+ describe('selector options', () => {
+ const secrets = ['mysecret123'];
+
+ it('should respect `valueSelector` and `placeholderSelector` options', () => {
+ const valueClass = 'js-some-custom-placeholder-selector';
+ const placeholderClass = 'js-some-custom-value-selector';
+
+ const wrapper = setupSecretFixture(secrets, false, valueClass, placeholderClass);
+ const values = wrapper.querySelectorAll(`.${valueClass}`);
+ const placeholders = wrapper.querySelectorAll(`.${placeholderClass}`);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+
+ expect(values.length).toEqual(1);
+ expect(placeholders.length).toEqual(1);
+
+ revealButton.click();
+
+ expect(values.length).toEqual(1);
+ expect(values[0].classList.contains('hide')).toEqual(false);
+ expect(placeholders.length).toEqual(1);
+ expect(placeholders[0].classList.contains('hide')).toEqual(true);
+ });
+ });
});
diff --git a/spec/javascripts/blob/blob_file_dropzone_spec.js b/spec/javascripts/blob/blob_file_dropzone_spec.js
index 47de63e6690..0b1de504435 100644
--- a/spec/javascripts/blob/blob_file_dropzone_spec.js
+++ b/spec/javascripts/blob/blob_file_dropzone_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import BlobFileDropzone from '~/blob/blob_file_dropzone';
describe('BlobFileDropzone', () => {
@@ -27,7 +28,7 @@ describe('BlobFileDropzone', () => {
name: 'some-file.jpg',
type: 'jpg',
};
- const fakeEvent = jQuery.Event('drop', {
+ const fakeEvent = $.Event('drop', {
dataTransfer: { files: [file] },
});
diff --git a/spec/javascripts/blob/notebook/index_spec.js b/spec/javascripts/blob/notebook/index_spec.js
index c3e67550f05..a143fc827d5 100644
--- a/spec/javascripts/blob/notebook/index_spec.js
+++ b/spec/javascripts/blob/notebook/index_spec.js
@@ -1,4 +1,5 @@
-import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import renderNotebook from '~/blob/notebook';
describe('iPython notebook renderer', () => {
@@ -17,8 +18,11 @@ describe('iPython notebook renderer', () => {
});
describe('successful response', () => {
- const response = (request, next) => {
- next(request.respondWith(JSON.stringify({
+ let mock;
+
+ beforeEach((done) => {
+ mock = new MockAdapter(axios);
+ mock.onGet('/test').reply(200, {
cells: [{
cell_type: 'markdown',
source: ['# test'],
@@ -31,13 +35,7 @@ describe('iPython notebook renderer', () => {
],
outputs: [],
}],
- }), {
- status: 200,
- }));
- };
-
- beforeEach((done) => {
- Vue.http.interceptors.push(response);
+ });
renderNotebook();
@@ -47,9 +45,7 @@ describe('iPython notebook renderer', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(
- Vue.http.interceptors, response,
- );
+ mock.restore();
});
it('does not show loading icon', () => {
@@ -86,14 +82,11 @@ describe('iPython notebook renderer', () => {
});
describe('error in JSON response', () => {
- const response = (request, next) => {
- next(request.respondWith('{ "cells": [{"cell_type": "markdown"} }', {
- status: 200,
- }));
- };
+ let mock;
beforeEach((done) => {
- Vue.http.interceptors.push(response);
+ mock = new MockAdapter(axios);
+ mock.onGet('/test').reply(() => Promise.reject({ status: 200, data: '{ "cells": [{"cell_type": "markdown"} }' }));
renderNotebook();
@@ -103,9 +96,7 @@ describe('iPython notebook renderer', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(
- Vue.http.interceptors, response,
- );
+ mock.restore();
});
it('does not show loading icon', () => {
@@ -122,14 +113,11 @@ describe('iPython notebook renderer', () => {
});
describe('error getting file', () => {
- const response = (request, next) => {
- next(request.respondWith('', {
- status: 500,
- }));
- };
+ let mock;
beforeEach((done) => {
- Vue.http.interceptors.push(response);
+ mock = new MockAdapter(axios);
+ mock.onGet('/test').reply(500, '');
renderNotebook();
@@ -139,9 +127,7 @@ describe('iPython notebook renderer', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(
- Vue.http.interceptors, response,
- );
+ mock.restore();
});
it('does not show loading icon', () => {
diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js
index cfa6650d85f..f920c4ca945 100644
--- a/spec/javascripts/blob/viewer/index_spec.js
+++ b/spec/javascripts/blob/viewer/index_spec.js
@@ -1,28 +1,37 @@
/* eslint-disable no-new */
+
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
import BlobViewer from '~/blob/viewer/index';
+import axios from '~/lib/utils/axios_utils';
describe('Blob viewer', () => {
let blob;
+ let mock;
+
preloadFixtures('snippets/show.html.raw');
beforeEach(() => {
+ mock = new MockAdapter(axios);
+
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>',
- });
+ mock.onGet('http://test.host/snippets/1.json?viewer=rich').reply(200, {
+ html: '<div>testing</div>',
+ });
- return d.promise();
+ mock.onGet('http://test.host/snippets/1.json?viewer=simple').reply(200, {
+ html: '<div>testing</div>',
});
+
+ spyOn(axios, 'get').and.callThrough();
});
afterEach(() => {
+ mock.restore();
location.hash = '';
});
@@ -30,7 +39,6 @@ describe('Blob viewer', () => {
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'),
@@ -46,7 +54,6 @@ describe('Blob viewer', () => {
new BlobViewer();
setTimeout(() => {
- expect($.ajax).toHaveBeenCalled();
expect(
document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]')
.classList.contains('hidden'),
@@ -64,12 +71,8 @@ describe('Blob viewer', () => {
});
asyncClick()
+ .then(() => 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');
@@ -122,7 +125,6 @@ describe('Blob viewer', () => {
document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
setTimeout(() => {
- expect($.ajax).toHaveBeenCalled();
expect(
copyButton.classList.contains('disabled'),
).toBeFalsy();
@@ -135,8 +137,6 @@ describe('Blob viewer', () => {
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');
@@ -171,14 +171,14 @@ describe('Blob viewer', () => {
it('sends AJAX request when switching to simple view', () => {
blob.switchToViewer('simple');
- expect($.ajax).toHaveBeenCalled();
+ expect(axios.get).toHaveBeenCalled();
});
it('does not send AJAX request when switching to rich view', () => {
blob.switchToViewer('simple');
blob.switchToViewer('rich');
- expect($.ajax.calls.count()).toBe(1);
+ expect(axios.get.calls.count()).toBe(1);
});
});
});
diff --git a/spec/javascripts/boards/board_blank_state_spec.js b/spec/javascripts/boards/board_blank_state_spec.js
index 2ee3792dd65..664ea202e93 100644
--- a/spec/javascripts/boards/board_blank_state_spec.js
+++ b/spec/javascripts/boards/board_blank_state_spec.js
@@ -1,16 +1,15 @@
/* global BoardService */
-/* global mockBoardService */
import Vue from 'vue';
import '~/boards/stores/boards_store';
-import boardBlankState from '~/boards/components/board_blank_state';
-import './mock_data';
+import BoardBlankState from '~/boards/components/board_blank_state.vue';
+import { mockBoardService } from './mock_data';
describe('Boards blank state', () => {
let vm;
let fail = false;
beforeEach((done) => {
- const Comp = Vue.extend(boardBlankState);
+ const Comp = Vue.extend(BoardBlankState);
gl.issueBoards.BoardsStore.create();
gl.boardService = mockBoardService();
@@ -20,17 +19,15 @@ describe('Boards blank state', () => {
reject();
} else {
resolve({
- json() {
- return [{
- id: 1,
- title: 'To Do',
- label: { id: 1 },
- }, {
- id: 2,
- title: 'Doing',
- label: { id: 2 },
- }];
- },
+ data: [{
+ id: 1,
+ title: 'To Do',
+ label: { id: 1 },
+ }, {
+ id: 2,
+ title: 'Doing',
+ label: { id: 2 },
+ }],
});
}
}));
diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js
index 8f607899b20..13d607a06d2 100644
--- a/spec/javascripts/boards/board_card_spec.js
+++ b/spec/javascripts/boards/board_card_spec.js
@@ -1,26 +1,27 @@
/* global List */
/* global ListAssignee */
/* global ListLabel */
-/* global listObj */
-/* global boardsMockInterceptor */
/* global BoardService */
-/* global mockBoardService */
import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import '~/boards/models/assignee';
import eventHub from '~/boards/eventhub';
+import '~/vue_shared/models/label';
import '~/boards/models/list';
-import '~/boards/models/label';
import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card.vue';
-import './mock_data';
+import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data';
describe('Board card', () => {
let vm;
+ let mock;
beforeEach((done) => {
- Vue.http.interceptors.push(boardsMockInterceptor);
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
@@ -54,7 +55,7 @@ describe('Board card', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
+ mock.restore();
});
it('returns false when detailIssue is empty', () => {
diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js
index 6bd00943a8f..03df6c06691 100644
--- a/spec/javascripts/boards/board_list_spec.js
+++ b/spec/javascripts/boards/board_list_spec.js
@@ -1,30 +1,30 @@
/* global BoardService */
-/* global boardsMockInterceptor */
/* global List */
-/* global listObj */
/* global ListIssue */
-/* global mockBoardService */
import Vue from 'vue';
-import _ from 'underscore';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Sortable from 'vendor/Sortable';
-import BoardList from '~/boards/components/board_list';
+import BoardList from '~/boards/components/board_list.vue';
import eventHub from '~/boards/eventhub';
import '~/boards/mixins/sortable_default_options';
import '~/boards/models/issue';
import '~/boards/models/list';
import '~/boards/stores/boards_store';
-import './mock_data';
+import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data';
window.Sortable = Sortable;
describe('Board list component', () => {
+ let mock;
let component;
beforeEach((done) => {
const el = document.createElement('div');
document.body.appendChild(el);
- Vue.http.interceptors.push(boardsMockInterceptor);
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
@@ -60,7 +60,7 @@ describe('Board list component', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
+ mock.restore();
});
it('renders component', () => {
@@ -154,6 +154,18 @@ describe('Board list component', () => {
});
});
+ it('sets data attribute with invalid id', (done) => {
+ component.showCount = true;
+
+ Vue.nextTick(() => {
+ expect(
+ component.$el.querySelector('.board-list-count').getAttribute('data-issue-id'),
+ ).toBe('-1');
+
+ done();
+ });
+ });
+
it('shows how many more issues to load', (done) => {
component.showCount = true;
component.list.issuesSize = 20;
@@ -172,9 +184,9 @@ describe('Board list component', () => {
component.$refs.list.style.height = '100px';
component.$refs.list.style.overflow = 'scroll';
- for (let i = 0; i < 19; i += 1) {
- const issue = component.list.issues[0];
- issue.id += 1;
+ for (let i = 1; i < 20; i += 1) {
+ const issue = Object.assign({}, component.list.issues[0]);
+ issue.id += i;
component.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 02e6692dda8..d5fbfdeaa91 100644
--- a/spec/javascripts/boards/board_new_issue_spec.js
+++ b/spec/javascripts/boards/board_new_issue_spec.js
@@ -1,24 +1,22 @@
-/* global boardsMockInterceptor */
/* global BoardService */
/* global List */
-/* global listObj */
-/* global mockBoardService */
import Vue from 'vue';
-import boardNewIssue from '~/boards/components/board_new_issue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import boardNewIssue from '~/boards/components/board_new_issue.vue';
import '~/boards/models/list';
-import './mock_data';
+import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data';
describe('Issue boards new issue form', () => {
let vm;
let list;
+ let mock;
let newIssueMock;
const promiseReturn = {
- json() {
- return {
- iid: 100,
- };
+ data: {
+ iid: 100,
},
};
@@ -35,7 +33,9 @@ describe('Issue boards new issue form', () => {
const BoardNewIssueComp = Vue.extend(boardNewIssue);
- Vue.http.interceptors.push(boardsMockInterceptor);
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
+
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
@@ -56,7 +56,10 @@ describe('Issue boards new issue form', () => {
.catch(done.fail);
});
- afterEach(() => vm.$destroy());
+ afterEach(() => {
+ vm.$destroy();
+ mock.restore();
+ });
it('calls submit if submit button is clicked', (done) => {
spyOn(vm, 'submit').and.callFake(e => e.preventDefault());
diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js
index 0e656858182..0cf9e4c9ba1 100644
--- a/spec/javascripts/boards/boards_store_spec.js
+++ b/spec/javascripts/boards/boards_store_spec.js
@@ -1,25 +1,26 @@
/* eslint-disable comma-dangle, one-var, no-unused-vars */
/* global BoardService */
-/* global boardsMockInterceptor */
-/* global listObj */
-/* global listObjDuplicate */
/* global ListIssue */
-/* global mockBoardService */
import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
+import '~/vue_shared/models/label';
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';
+import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
describe('Store', () => {
+ let mock;
+
beforeEach(() => {
- Vue.http.interceptors.push(boardsMockInterceptor);
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
@@ -34,7 +35,7 @@ describe('Store', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
+ mock.restore();
});
it('starts with a blank state', () => {
diff --git a/spec/javascripts/boards/components/board_spec.js b/spec/javascripts/boards/components/board_spec.js
index 8dacac20cad..19346e305cf 100644
--- a/spec/javascripts/boards/components/board_spec.js
+++ b/spec/javascripts/boards/components/board_spec.js
@@ -1,9 +1,8 @@
-/* global mockBoardService */
import Vue from 'vue';
import '~/boards/services/board_service';
import '~/boards/components/board';
import '~/boards/models/list';
-import '../mock_data';
+import { mockBoardService } from '../mock_data';
describe('Board component', () => {
let vm;
diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js
index 7d430ec35e2..be1ea0b57b4 100644
--- a/spec/javascripts/boards/issue_card_spec.js
+++ b/spec/javascripts/boards/issue_card_spec.js
@@ -1,17 +1,16 @@
/* global ListAssignee */
/* global ListLabel */
-/* global listObj */
/* global ListIssue */
import Vue from 'vue';
+import '~/vue_shared/models/label';
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';
+import { listObj } from './mock_data';
describe('Issue card component', () => {
const user = new ListAssignee({
@@ -42,10 +41,15 @@ describe('Issue card component', () => {
confidential: false,
labels: [list.label],
assignees: [],
+ reference_path: '#1',
+ real_path: '/test/1',
});
component = new Vue({
el: document.querySelector('.test-container'),
+ components: {
+ 'issue-card': gl.issueBoards.IssueCardInner,
+ },
data() {
return {
list,
@@ -54,9 +58,6 @@ describe('Issue card component', () => {
rootPath: '/',
};
},
- components: {
- 'issue-card': gl.issueBoards.IssueCardInner,
- },
template: `
<issue-card
:issue="issue"
diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js
index 41dcb19df3c..4a11131b55c 100644
--- a/spec/javascripts/boards/issue_spec.js
+++ b/spec/javascripts/boards/issue_spec.js
@@ -1,16 +1,15 @@
/* eslint-disable comma-dangle */
/* global BoardService */
/* global ListIssue */
-/* global mockBoardService */
import Vue from 'vue';
+import '~/vue_shared/models/label';
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';
+import { mockBoardService } from './mock_data';
describe('Issue model', () => {
let issue;
diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js
index eead396ca7e..d9a1d692949 100644
--- a/spec/javascripts/boards/list_spec.js
+++ b/spec/javascripts/boards/list_spec.js
@@ -1,27 +1,26 @@
/* eslint-disable comma-dangle */
-/* global boardsMockInterceptor */
/* global BoardService */
-/* global mockBoardService */
/* global List */
/* global ListIssue */
-/* global listObj */
-/* global listObjDuplicate */
-
-import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import _ from 'underscore';
+import '~/vue_shared/models/label';
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';
+import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
describe('List model', () => {
let list;
+ let mock;
beforeEach(() => {
- Vue.http.interceptors.push(boardsMockInterceptor);
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
gl.boardService = mockBoardService({
bulkUpdatePath: '/test/issue-boards/board/1/lists',
});
@@ -31,7 +30,7 @@ describe('List model', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
+ mock.restore();
});
it('gets issues when created', (done) => {
@@ -158,10 +157,8 @@ describe('List model', () => {
describe('newIssue', () => {
beforeEach(() => {
spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
- json() {
- return {
- id: 42,
- };
+ data: {
+ id: 42,
},
}));
});
diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js
index 0a93086985e..81f1a97112f 100644
--- a/spec/javascripts/boards/mock_data.js
+++ b/spec/javascripts/boards/mock_data.js
@@ -1,20 +1,18 @@
/* global BoardService */
-/* eslint-disable comma-dangle, no-unused-vars, quote-props */
-
-const listObj = {
- id: _.random(10000),
+export const listObj = {
+ id: 300,
position: 0,
title: 'Test',
list_type: 'label',
label: {
- id: _.random(10000),
+ id: 5000,
title: 'Testing',
color: 'red',
- description: 'testing;'
- }
+ description: 'testing;',
+ },
};
-const listObjDuplicate = {
+export const listObjDuplicate = {
id: listObj.id,
position: 1,
title: 'Test',
@@ -23,45 +21,44 @@ const listObjDuplicate = {
id: listObj.label.id,
title: 'Testing',
color: 'red',
- description: 'testing;'
- }
+ description: 'testing;',
+ },
};
-const BoardsMockData = {
- 'GET': {
- '/test/boards/1{/id}/issues': {
- issues: [{
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [],
- assignees: [],
- }],
- }
+export const BoardsMockData = {
+ GET: {
+ '/test/-/boards/1/lists/300/issues?id=300&page=1&=': {
+ issues: [
+ {
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [],
+ assignees: [],
+ },
+ ],
+ },
},
- 'POST': {
- '/test/boards/1{/id}': listObj
+ POST: {
+ '/test/-/boards/1/lists': listObj,
},
- 'PUT': {
- '/test/issue-boards/board/1/lists{/id}': {}
+ PUT: {
+ '/test/issue-boards/board/1/lists{/id}': {},
+ },
+ DELETE: {
+ '/test/issue-boards/board/1/lists{/id}': {},
},
- 'DELETE': {
- '/test/issue-boards/board/1/lists{/id}': {}
- }
};
-const boardsMockInterceptor = (request, next) => {
- const body = BoardsMockData[request.method][request.url];
-
- next(request.respondWith(JSON.stringify(body), {
- status: 200
- }));
+export const boardsMockInterceptor = config => {
+ const body = BoardsMockData[config.method.toUpperCase()][config.url];
+ return [200, body];
};
-const mockBoardService = (opts = {}) => {
- const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/board';
- const listsEndpoint = opts.listsEndpoint || '/test/boards/1';
+export const mockBoardService = (opts = {}) => {
+ const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/boards.json';
+ const listsEndpoint = opts.listsEndpoint || '/test/-/boards/1/lists';
const bulkUpdatePath = opts.bulkUpdatePath || '';
const boardId = opts.boardId || '1';
@@ -72,9 +69,3 @@ const mockBoardService = (opts = {}) => {
boardId,
});
};
-
-window.listObj = listObj;
-window.listObjDuplicate = listObjDuplicate;
-window.BoardsMockData = BoardsMockData;
-window.boardsMockInterceptor = boardsMockInterceptor;
-window.mockBoardService = mockBoardService;
diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js
index 7eecb58a4c3..797693a21aa 100644
--- a/spec/javascripts/boards/modal_store_spec.js
+++ b/spec/javascripts/boards/modal_store_spec.js
@@ -1,15 +1,14 @@
/* global ListIssue */
+import '~/vue_shared/models/label';
import '~/boards/models/issue';
-import '~/boards/models/label';
import '~/boards/models/list';
import '~/boards/models/assignee';
-import '~/boards/stores/modal_store';
+import Store from '~/boards/stores/modal_store';
describe('Modal store', () => {
let issue;
let issue2;
- const Store = gl.issueBoards.ModalStore;
beforeEach(() => {
// Setup default state
diff --git a/spec/javascripts/boards/utils/query_data_spec.js b/spec/javascripts/boards/utils/query_data_spec.js
new file mode 100644
index 00000000000..922215ffc1d
--- /dev/null
+++ b/spec/javascripts/boards/utils/query_data_spec.js
@@ -0,0 +1,27 @@
+import queryData from '~/boards/utils/query_data';
+
+describe('queryData', () => {
+ it('parses path for label with trailing +', () => {
+ expect(
+ queryData('label_name[]=label%2B', {}),
+ ).toEqual({
+ label_name: ['label+'],
+ });
+ });
+
+ it('parses path for milestone with trailing +', () => {
+ expect(
+ queryData('milestone_title=A%2B', {}),
+ ).toEqual({
+ milestone_title: 'A+',
+ });
+ });
+
+ it('parses path for search terms with spaces', () => {
+ expect(
+ queryData('search=two+words', {}),
+ ).toEqual({
+ search: 'two words',
+ });
+ });
+});
diff --git a/spec/javascripts/bootstrap_jquery_spec.js b/spec/javascripts/bootstrap_jquery_spec.js
index 48994b7c523..0fd6f9dc810 100644
--- a/spec/javascripts/bootstrap_jquery_spec.js
+++ b/spec/javascripts/bootstrap_jquery_spec.js
@@ -1,5 +1,6 @@
/* eslint-disable space-before-function-paren, no-var */
+import $ from 'jquery';
import '~/commons/bootstrap';
(function() {
diff --git a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js
new file mode 100644
index 00000000000..ee457a9c48c
--- /dev/null
+++ b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js
@@ -0,0 +1,213 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list';
+
+const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/variables';
+const HIDE_CLASS = 'hide';
+
+describe('AjaxFormVariableList', () => {
+ preloadFixtures('projects/ci_cd_settings.html.raw');
+ preloadFixtures('projects/ci_cd_settings_with_variables.html.raw');
+
+ let container;
+ let saveButton;
+ let errorBox;
+
+ let mock;
+ let ajaxVariableList;
+
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html.raw');
+ container = document.querySelector('.js-ci-variable-list-section');
+
+ mock = new MockAdapter(axios);
+
+ const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
+ saveButton = ajaxVariableListEl.querySelector('.js-secret-variables-save-button');
+ errorBox = container.querySelector('.js-ci-variable-error-box');
+ ajaxVariableList = new AjaxFormVariableList({
+ container,
+ formField: 'variables',
+ saveButton,
+ errorBox,
+ saveEndpoint: container.dataset.saveEndpoint,
+ });
+
+ spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables').and.callThrough();
+ spyOn(ajaxVariableList.variableList, 'toggleEnableRow').and.callThrough();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('onSaveClicked', () => {
+ it('shows loading spinner while waiting for the request', (done) => {
+ const loadingIcon = saveButton.querySelector('.js-secret-variables-save-loading-icon');
+
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false);
+
+ return [200, {}];
+ });
+
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ ajaxVariableList.onSaveClicked()
+ .then(() => {
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('calls `updateRowsWithPersistedVariables` with the persisted variables', (done) => {
+ const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }];
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {
+ variables: variablesResponse,
+ });
+
+ ajaxVariableList.onSaveClicked()
+ .then(() => {
+ expect(ajaxVariableList.updateRowsWithPersistedVariables)
+ .toHaveBeenCalledWith(variablesResponse);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('hides any previous error box', (done) => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ ajaxVariableList.onSaveClicked()
+ .then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('disables remove buttons while waiting for the request', (done) => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
+ expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false);
+
+ return [200, {}];
+ });
+
+ ajaxVariableList.onSaveClicked()
+ .then(() => {
+ expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('hides secret values', (done) => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {});
+
+ const row = container.querySelector('.js-row:first-child');
+ const valueInput = row.querySelector('.js-ci-variable-input-value');
+ const valuePlaceholder = row.querySelector('.js-secret-value-placeholder');
+
+ valueInput.value = 'bar';
+ $(valueInput).trigger('input');
+
+ expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true);
+ expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false);
+
+ ajaxVariableList.onSaveClicked()
+ .then(() => {
+ expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false);
+ expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows error box with validation errors', (done) => {
+ const validationError = 'some validation error';
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [
+ validationError,
+ ]);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ ajaxVariableList.onSaveClicked()
+ .then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false);
+ expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual(`Validation failed ${validationError}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows flash message when request fails', (done) => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ ajaxVariableList.onSaveClicked()
+ .then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateRowsWithPersistedVariables', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings_with_variables.html.raw');
+ container = document.querySelector('.js-ci-variable-list-section');
+
+ const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
+ saveButton = ajaxVariableListEl.querySelector('.js-secret-variables-save-button');
+ errorBox = container.querySelector('.js-ci-variable-error-box');
+ ajaxVariableList = new AjaxFormVariableList({
+ container,
+ formField: 'variables',
+ saveButton,
+ errorBox,
+ saveEndpoint: container.dataset.saveEndpoint,
+ });
+ });
+
+ it('removes variable that was removed', () => {
+ expect(container.querySelectorAll('.js-row').length).toBe(3);
+
+ container.querySelector('.js-row-remove-button').click();
+
+ expect(container.querySelectorAll('.js-row').length).toBe(3);
+
+ ajaxVariableList.updateRowsWithPersistedVariables([]);
+
+ expect(container.querySelectorAll('.js-row').length).toBe(2);
+ });
+
+ it('updates new variable row with persisted ID', () => {
+ const row = container.querySelector('.js-row:last-child');
+ const idInput = row.querySelector('.js-ci-variable-input-id');
+ const keyInput = row.querySelector('.js-ci-variable-input-key');
+ const valueInput = row.querySelector('.js-ci-variable-input-value');
+
+ keyInput.value = 'foo';
+ $(keyInput).trigger('input');
+ valueInput.value = 'bar';
+ $(valueInput).trigger('input');
+
+ expect(idInput.value).toEqual('');
+
+ ajaxVariableList.updateRowsWithPersistedVariables([{
+ id: 3,
+ key: 'foo',
+ value: 'bar',
+ }]);
+
+ expect(idInput.value).toEqual('3');
+ expect(row.dataset.isPersisted).toEqual('true');
+ });
+ });
+});
diff --git a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js
new file mode 100644
index 00000000000..2fa50975f0f
--- /dev/null
+++ b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js
@@ -0,0 +1,216 @@
+import $ from 'jquery';
+import VariableList from '~/ci_variable_list/ci_variable_list';
+import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+
+const HIDE_CLASS = 'hide';
+
+describe('VariableList', () => {
+ preloadFixtures('pipeline_schedules/edit.html.raw');
+ preloadFixtures('pipeline_schedules/edit_with_variables.html.raw');
+ preloadFixtures('projects/ci_cd_settings.html.raw');
+
+ let $wrapper;
+ let variableList;
+
+ describe('with only key/value inputs', () => {
+ describe('with no variables', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit.html.raw');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ variableList.init();
+ });
+
+ it('should remove the row when clicking the remove button', () => {
+ $wrapper.find('.js-row-remove-button').trigger('click');
+
+ expect($wrapper.find('.js-row').length).toBe(0);
+ });
+
+ it('should add another row when editing the last rows key input', () => {
+ const $row = $wrapper.find('.js-row');
+ $row.find('.js-ci-variable-input-key')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ // Check for the correct default in the new row
+ const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
+ expect($keyInput.val()).toBe('');
+ });
+
+ it('should add another row when editing the last rows value textarea', () => {
+ const $row = $wrapper.find('.js-row');
+ $row.find('.js-ci-variable-input-value')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ // Check for the correct default in the new row
+ const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
+ expect($valueInput.val()).toBe('');
+ });
+
+ it('should remove empty row after blurring', () => {
+ const $row = $wrapper.find('.js-row');
+ $row.find('.js-ci-variable-input-key')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ $row.find('.js-ci-variable-input-key')
+ .val('')
+ .trigger('input')
+ .trigger('blur');
+
+ expect($wrapper.find('.js-row').length).toBe(1);
+ });
+ });
+
+ describe('with persisted variables', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit_with_variables.html.raw');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ variableList.init();
+ });
+
+ it('should have "Reveal values" button initially when there are already variables', () => {
+ expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values');
+ });
+
+ it('should reveal hidden values', () => {
+ const $row = $wrapper.find('.js-row:first-child');
+ const $inputValue = $row.find('.js-ci-variable-input-value');
+ const $placeholder = $row.find('.js-secret-value-placeholder');
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
+
+ // Reveal values
+ $wrapper.find('.js-secret-value-reveal-button').click();
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
+ });
+ });
+ });
+
+ describe('with all inputs(key, value, protected)', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html.raw');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should add another row when editing the last rows protected checkbox', (done) => {
+ const $row = $wrapper.find('.js-row:last-child');
+ $row.find('.ci-variable-protected-item .js-project-feature-toggle').click();
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ // Check for the correct default in the new row
+ const $protectedInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-protected');
+ expect($protectedInput.val()).toBe('false');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('toggleEnableRow method', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit_with_variables.html.raw');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should disable all key inputs', () => {
+ expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
+
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
+ });
+
+ it('should disable all remove buttons', () => {
+ expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
+
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
+ });
+
+ it('should enable all remove buttons', () => {
+ variableList.toggleEnableRow(false);
+ expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
+
+ variableList.toggleEnableRow(true);
+
+ expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
+ });
+
+ it('should enable all key inputs', () => {
+ variableList.toggleEnableRow(false);
+ expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
+
+ variableList.toggleEnableRow(true);
+
+ expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
+ });
+ });
+
+ describe('hideValues', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html.raw');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should hide value input and show placeholder stars', () => {
+ const $row = $wrapper.find('.js-row');
+ const $inputValue = $row.find('.js-ci-variable-input-value');
+ const $placeholder = $row.find('.js-secret-value-placeholder');
+
+ $row.find('.js-ci-variable-input-value')
+ .val('foo')
+ .trigger('input');
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
+
+ variableList.hideValues();
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js
new file mode 100644
index 00000000000..94a0c999d66
--- /dev/null
+++ b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js
@@ -0,0 +1,31 @@
+import $ from 'jquery';
+import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
+
+describe('NativeFormVariableList', () => {
+ preloadFixtures('pipeline_schedules/edit.html.raw');
+
+ let $wrapper;
+
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit.html.raw');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ setupNativeFormVariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ });
+
+ describe('onFormSubmit', () => {
+ it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => {
+ const $row = $wrapper.find('.js-row');
+ expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('schedule[variables_attributes][][key]');
+ expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('schedule[variables_attributes][][secret_value]');
+
+ $wrapper.closest('form').trigger('trigger-submit');
+
+ expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('');
+ expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('');
+ });
+ });
+});
diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js
index f5be9ea0fb2..a5cd247b689 100644
--- a/spec/javascripts/clusters/clusters_bundle_spec.js
+++ b/spec/javascripts/clusters/clusters_bundle_spec.js
@@ -7,7 +7,7 @@ import {
REQUEST_SUCCESS,
REQUEST_FAILURE,
} from '~/clusters/constants';
-import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper';
+import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
describe('Clusters', () => {
let cluster;
@@ -23,16 +23,24 @@ describe('Clusters', () => {
});
describe('toggle', () => {
- it('should update the button and the input field on click', () => {
- cluster.toggleButton.click();
+ it('should update the button and the input field on click', (done) => {
+ const toggleButton = document.querySelector('.js-cluster-enable-toggle-area .js-project-feature-toggle');
+ const toggleInput = document.querySelector('.js-cluster-enable-toggle-area .js-project-feature-toggle-input');
- expect(
- cluster.toggleButton.classList,
- ).not.toContain('is-checked');
+ toggleButton.click();
- expect(
- cluster.toggleInput.getAttribute('value'),
- ).toEqual('false');
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(
+ toggleButton.classList,
+ ).not.toContain('is-checked');
+
+ expect(
+ toggleInput.getAttribute('value'),
+ ).toEqual('false');
+ })
+ .then(done)
+ .catch(done.fail);
});
});
@@ -63,7 +71,8 @@ describe('Clusters', () => {
helm: { status: APPLICATION_INSTALLABLE, title: 'Helm Tiller' },
});
- expect(document.querySelector('.js-cluster-application-notice .flash-text')).toBeNull();
+ const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text');
+ expect(flashMessage).toBeNull();
});
it('shows an alert when something gets newly installed', () => {
@@ -75,8 +84,9 @@ describe('Clusters', () => {
helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' },
});
- expect(document.querySelector('.js-cluster-application-notice .flash-text')).toBeDefined();
- expect(document.querySelector('.js-cluster-application-notice .flash-text').textContent.trim()).toEqual('Helm Tiller was successfully installed on your cluster');
+ const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text');
+ expect(flashMessage).not.toBeNull();
+ expect(flashMessage.textContent.trim()).toEqual('Helm Tiller was successfully installed on your Kubernetes cluster');
});
it('shows an alert when multiple things gets newly installed', () => {
@@ -90,8 +100,9 @@ describe('Clusters', () => {
ingress: { status: APPLICATION_INSTALLED, title: 'Ingress' },
});
- expect(document.querySelector('.js-cluster-application-notice .flash-text')).toBeDefined();
- expect(document.querySelector('.js-cluster-application-notice .flash-text').textContent.trim()).toEqual('Helm Tiller, Ingress was successfully installed on your cluster');
+ const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text');
+ expect(flashMessage).not.toBeNull();
+ expect(flashMessage.textContent.trim()).toEqual('Helm Tiller, Ingress was successfully installed on your Kubernetes cluster');
});
});
diff --git a/spec/javascripts/clusters/clusters_index_spec.js b/spec/javascripts/clusters/clusters_index_spec.js
deleted file mode 100644
index 0a8b63ed5b4..00000000000
--- a/spec/javascripts/clusters/clusters_index_spec.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import setClusterTableToggles from '~/clusters/clusters_index';
-import { setTimeout } from 'core-js/library/web/timers';
-
-describe('Clusters table', () => {
- preloadFixtures('clusters/index_cluster.html.raw');
- let mock;
-
- beforeEach(() => {
- loadFixtures('clusters/index_cluster.html.raw');
- mock = new MockAdapter(axios);
- setClusterTableToggles();
- });
-
- describe('update cluster', () => {
- it('renders loading state while request is made', () => {
- const button = document.querySelector('.js-toggle-cluster-list');
-
- button.click();
-
- expect(button.classList).toContain('is-loading');
- expect(button.getAttribute('disabled')).toEqual('true');
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('shows updated state after sucessfull request', (done) => {
- mock.onPut().reply(200, {}, {});
- const button = document.querySelector('.js-toggle-cluster-list');
- button.click();
-
- expect(button.classList).toContain('is-loading');
-
- setTimeout(() => {
- expect(button.classList).not.toContain('is-loading');
- expect(button.classList).not.toContain('is-checked');
- done();
- }, 0);
- });
-
- it('shows inital state after failed request', (done) => {
- mock.onPut().reply(500, {}, {});
- const button = document.querySelector('.js-toggle-cluster-list');
-
- button.click();
- expect(button.classList).toContain('is-loading');
-
- setTimeout(() => {
- expect(button.classList).not.toContain('is-loading');
- expect(button.classList).toContain('is-checked');
- done();
- }, 0);
- });
- });
-});
diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js
index e671c18e1a5..2c4707bb856 100644
--- a/spec/javascripts/clusters/components/application_row_spec.js
+++ b/spec/javascripts/clusters/components/application_row_spec.js
@@ -12,7 +12,7 @@ import {
REQUEST_FAILURE,
} from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
describe('Application Row', () => {
diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js
index 7460da031c4..d546543d273 100644
--- a/spec/javascripts/clusters/components/applications_spec.js
+++ b/spec/javascripts/clusters/components/applications_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import applications from '~/clusters/components/applications.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Applications', () => {
let vm;
@@ -21,6 +21,7 @@ describe('Applications', () => {
helm: { title: 'Helm Tiller' },
ingress: { title: 'Ingress' },
runner: { title: 'GitLab Runner' },
+ prometheus: { title: 'Prometheus' },
},
});
});
@@ -33,10 +34,79 @@ describe('Applications', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).toBeDefined();
});
- /* * /
+ it('renders a row for Prometheus', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).toBeDefined();
+ });
+
it('renders a row for GitLab Runner', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeDefined();
});
- /* */
+ });
+
+ describe('Ingress application', () => {
+ describe('when installed', () => {
+ describe('with ip address', () => {
+ it('renders ip address with a clipboard button', () => {
+ vm = mountComponent(Applications, {
+ applications: {
+ ingress: {
+ title: 'Ingress',
+ status: 'installed',
+ externalIp: '0.0.0.0',
+ },
+ helm: { title: 'Helm Tiller' },
+ runner: { title: 'GitLab Runner' },
+ prometheus: { title: 'Prometheus' },
+ },
+ });
+
+ expect(
+ vm.$el.querySelector('.js-ip-address').value,
+ ).toEqual('0.0.0.0');
+
+ expect(
+ vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text'),
+ ).toEqual('0.0.0.0');
+ });
+ });
+
+ describe('without ip address', () => {
+ it('renders an input text with a question mark and an alert text', () => {
+ vm = mountComponent(Applications, {
+ applications: {
+ ingress: {
+ title: 'Ingress',
+ status: 'installed',
+ },
+ helm: { title: 'Helm Tiller' },
+ runner: { title: 'GitLab Runner' },
+ prometheus: { title: 'Prometheus' },
+ },
+ });
+
+ expect(
+ vm.$el.querySelector('.js-ip-address').value,
+ ).toEqual('?');
+
+ expect(vm.$el.querySelector('.js-no-ip-message')).not.toBe(null);
+ });
+ });
+ });
+
+ describe('before installing', () => {
+ it('does not render the IP address', () => {
+ vm = mountComponent(Applications, {
+ applications: {
+ helm: { title: 'Helm Tiller' },
+ ingress: { title: 'Ingress' },
+ runner: { title: 'GitLab Runner' },
+ prometheus: { title: 'Prometheus' },
+ },
+ });
+
+ expect(vm.$el.textContent).not.toContain('Ingress IP Address');
+ expect(vm.$el.querySelector('.js-ip-address')).toBe(null);
+ });
+ });
});
});
diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js
index af6b6a73819..6ae7a792329 100644
--- a/spec/javascripts/clusters/services/mock_data.js
+++ b/spec/javascripts/clusters/services/mock_data.js
@@ -18,10 +18,16 @@ const CLUSTERS_MOCK_DATA = {
name: 'ingress',
status: APPLICATION_ERROR,
status_reason: 'Cannot connect',
+ external_ip: null,
}, {
name: 'runner',
status: APPLICATION_INSTALLING,
status_reason: null,
+ },
+ {
+ name: 'prometheus',
+ status: APPLICATION_ERROR,
+ status_reason: 'Cannot connect',
}],
},
},
@@ -30,6 +36,7 @@ const CLUSTERS_MOCK_DATA = {
'/gitlab-org/gitlab-shell/clusters/1/applications/helm': { },
'/gitlab-org/gitlab-shell/clusters/1/applications/ingress': { },
'/gitlab-org/gitlab-shell/clusters/1/applications/runner': { },
+ '/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': { },
},
};
diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js
index cb8b3d38e2e..8028faf2f02 100644
--- a/spec/javascripts/clusters/stores/clusters_store_spec.js
+++ b/spec/javascripts/clusters/stores/clusters_store_spec.js
@@ -58,6 +58,7 @@ describe('Clusters Store', () => {
expect(store.state).toEqual({
helpPath: null,
+ ingressHelpPath: null,
status: mockResponseData.status,
statusReason: mockResponseData.status_reason,
applications: {
@@ -74,6 +75,7 @@ describe('Clusters Store', () => {
statusReason: mockResponseData.applications[1].status_reason,
requestStatus: null,
requestReason: null,
+ externalIp: null,
},
runner: {
title: 'GitLab Runner',
@@ -82,6 +84,13 @@ describe('Clusters Store', () => {
requestStatus: null,
requestReason: null,
},
+ prometheus: {
+ title: 'Prometheus',
+ status: mockResponseData.applications[3].status,
+ statusReason: mockResponseData.applications[3].status_reason,
+ requestStatus: null,
+ requestReason: null,
+ },
},
});
});
diff --git a/spec/javascripts/collapsed_sidebar_todo_spec.js b/spec/javascripts/collapsed_sidebar_todo_spec.js
index 5026eaafaca..2abf52a1676 100644
--- a/spec/javascripts/collapsed_sidebar_todo_spec.js
+++ b/spec/javascripts/collapsed_sidebar_todo_spec.js
@@ -1,10 +1,14 @@
/* eslint-disable no-new */
import _ from 'underscore';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Sidebar from '~/right_sidebar';
+import timeoutPromise from './helpers/set_timeout_promise_helper';
describe('Issuable right sidebar collapsed todo toggle', () => {
const fixtureName = 'issues/open-issue.html.raw';
const jsonFixtureName = 'todos/todos.json';
+ let mock;
preloadFixtures(fixtureName);
preloadFixtures(jsonFixtureName);
@@ -19,19 +23,26 @@ describe('Issuable right sidebar collapsed todo toggle', () => {
document.querySelector('.js-right-sidebar')
.classList.toggle('right-sidebar-collapsed');
- spyOn(jQuery, 'ajax').and.callFake((res) => {
- const d = $.Deferred();
+ mock = new MockAdapter(axios);
+
+ mock.onPost(`${gl.TEST_HOST}/frontend-fixtures/issues-project/todos`).reply(() => {
const response = _.clone(todoData);
- if (res.type === 'DELETE') {
- delete response.delete_path;
- }
+ return [200, response];
+ });
- d.resolve(response);
- return d.promise();
+ mock.onDelete(/(.*)\/dashboard\/todos\/\d+$/).reply(() => {
+ const response = _.clone(todoData);
+ delete response.delete_path;
+
+ return [200, response];
});
});
+ afterEach(() => {
+ mock.restore();
+ });
+
it('shows add todo button', () => {
expect(
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon'),
@@ -52,71 +63,101 @@ describe('Issuable right sidebar collapsed todo toggle', () => {
).toBe('Add todo');
});
- it('toggle todo state', () => {
+ it('toggle todo state', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
- ).not.toBeNull();
+ setTimeout(() => {
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
+ ).not.toBeNull();
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .fa-check-square'),
- ).not.toBeNull();
- });
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .fa-check-square'),
+ ).not.toBeNull();
- it('toggle todo state of expanded todo toggle', () => {
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
-
- expect(
- document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(),
- ).toBe('Mark done');
+ done();
+ });
});
- it('toggles todo button tooltip', () => {
+ it('toggle todo state of expanded todo toggle', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('data-original-title'),
- ).toBe('Mark done');
- });
-
- it('marks todo as done', () => {
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
+ setTimeout(() => {
+ expect(
+ document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(),
+ ).toBe('Mark done');
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
- ).not.toBeNull();
+ done();
+ });
+ });
+ it('toggles todo button tooltip', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
- ).toBeNull();
+ setTimeout(() => {
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('data-original-title'),
+ ).toBe('Mark done');
- expect(
- document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(),
- ).toBe('Add todo');
+ done();
+ });
});
- it('updates aria-label to mark done', () => {
+ it('marks todo as done', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('aria-label'),
- ).toBe('Mark done');
+ timeoutPromise()
+ .then(() => {
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
+ ).not.toBeNull();
+
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
+ })
+ .then(timeoutPromise)
+ .then(() => {
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'),
+ ).toBeNull();
+
+ expect(
+ document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(),
+ ).toBe('Add todo');
+ })
+ .then(done)
+ .catch(done.fail);
});
- it('updates aria-label to add todo', () => {
+ it('updates aria-label to mark done', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('aria-label'),
- ).toBe('Mark done');
+ setTimeout(() => {
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('aria-label'),
+ ).toBe('Mark done');
+ done();
+ });
+ });
+
+ it('updates aria-label to add todo', (done) => {
document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
- expect(
- document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('aria-label'),
- ).toBe('Add todo');
+ timeoutPromise()
+ .then(() => {
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('aria-label'),
+ ).toBe('Mark done');
+
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click();
+ })
+ .then(timeoutPromise)
+ .then(() => {
+ expect(
+ document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('aria-label'),
+ ).toBe('Add todo');
+ })
+ .then(done)
+ .catch(done.fail);
});
});
diff --git a/spec/javascripts/commit/commit_pipeline_status_component_spec.js b/spec/javascripts/commit/commit_pipeline_status_component_spec.js
new file mode 100644
index 00000000000..421fe62a1e7
--- /dev/null
+++ b/spec/javascripts/commit/commit_pipeline_status_component_spec.js
@@ -0,0 +1,104 @@
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('Commit pipeline status component', () => {
+ let vm;
+ let Component;
+ let mock;
+ const mockCiStatus = {
+ details_path: '/root/hello-world/pipelines/1',
+ favicon: 'canceled.ico',
+ group: 'canceled',
+ has_details: true,
+ icon: 'status_canceled',
+ label: 'canceled',
+ text: 'canceled',
+ };
+
+ beforeEach(() => {
+ Component = Vue.extend(commitPipelineStatus);
+ });
+
+ describe('While polling pipeline data succesfully', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet('/dummy/endpoint').reply(() => {
+ const res = Promise.resolve([200, {
+ pipelines: [
+ {
+ details: {
+ status: mockCiStatus,
+ },
+ },
+ ],
+ }]);
+ return res;
+ });
+ vm = mountComponent(Component, {
+ endpoint: '/dummy/endpoint',
+ });
+ });
+
+ afterEach(() => {
+ vm.poll.stop();
+ vm.$destroy();
+ mock.restore();
+ });
+
+ it('shows the loading icon when polling is starting', (done) => {
+ expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.loading-container')).toBe(null);
+ done();
+ });
+ });
+
+ it('contains a ciStatus when the polling is succesful ', (done) => {
+ setTimeout(() => {
+ expect(vm.ciStatus).toEqual(mockCiStatus);
+ done();
+ });
+ });
+
+ it('contains a ci-status icon when polling is succesful', (done) => {
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.ci-status-icon')).not.toBe(null);
+ expect(vm.$el.querySelector('.ci-status-icon').classList).toContain(`ci-status-icon-${mockCiStatus.group}`);
+ done();
+ });
+ });
+ });
+
+ describe('When polling data was not succesful', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet('/dummy/endpoint').reply(() => {
+ const res = Promise.reject([502, { }]);
+ return res;
+ });
+ vm = new Component({
+ props: {
+ endpoint: '/dummy/endpoint',
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.poll.stop();
+ vm.$destroy();
+ mock.restore();
+ });
+
+ it('calls an errorCallback', (done) => {
+ spyOn(vm, 'errorCallback').and.callThrough();
+ vm.$mount();
+ setTimeout(() => {
+ expect(vm.errorCallback.calls.count()).toEqual(1);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js
index 9fc047b1f5e..0afe09d87bc 100644
--- a/spec/javascripts/commit/pipelines/pipelines_spec.js
+++ b/spec/javascripts/commit/pipelines/pipelines_spec.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import Vue from 'vue';
import pipelinesTable from '~/commit/pipelines/pipelines_table.vue';
@@ -9,9 +10,10 @@ describe('Pipelines table in Commits and Merge requests', () => {
preloadFixtures(jsonFixtureName);
beforeEach(() => {
- PipelinesTable = Vue.extend(pipelinesTable);
const pipelines = getJSONFixture(jsonFixtureName).pipelines;
- pipeline = pipelines.find(p => p.id === 1);
+
+ PipelinesTable = Vue.extend(pipelinesTable);
+ pipeline = pipelines.find(p => p.user !== null && p.commit !== null);
});
describe('successful request', () => {
diff --git a/spec/javascripts/commit_merge_requests_spec.js b/spec/javascripts/commit_merge_requests_spec.js
new file mode 100644
index 00000000000..3466ef51ea8
--- /dev/null
+++ b/spec/javascripts/commit_merge_requests_spec.js
@@ -0,0 +1,60 @@
+import * as CommitMergeRequests from '~/commit_merge_requests';
+
+describe('CommitMergeRequests', () => {
+ describe('createContent', () => {
+ it('should return created content', () => {
+ const content1 = CommitMergeRequests.createContent([{ iid: 1, path: '/path1', title: 'foo' }, { iid: 2, path: '/path2', title: 'baz' }])[0];
+ expect(content1.tagName).toEqual('SPAN');
+ expect(content1.childElementCount).toEqual(4);
+
+ const content2 = CommitMergeRequests.createContent([])[0];
+ expect(content2.tagName).toEqual('SPAN');
+ expect(content2.childElementCount).toEqual(0);
+ expect(content2.innerText).toEqual('No related merge requests found');
+ });
+ });
+
+ describe('getHeaderText', () => {
+ it('should return header text', () => {
+ expect(CommitMergeRequests.getHeaderText(0, 1)).toEqual('1 merge request');
+ expect(CommitMergeRequests.getHeaderText(0, 2)).toEqual('2 merge requests');
+ expect(CommitMergeRequests.getHeaderText(1, 1)).toEqual(',');
+ expect(CommitMergeRequests.getHeaderText(1, 2)).toEqual(',');
+ });
+ });
+
+ describe('createHeader', () => {
+ it('should return created header', () => {
+ const header = CommitMergeRequests.createHeader(0, 1)[0];
+ expect(header.tagName).toEqual('SPAN');
+ expect(header.innerText).toEqual('1 merge request');
+ });
+ });
+
+ describe('createItem', () => {
+ it('should return created item', () => {
+ const item = CommitMergeRequests.createItem({ iid: 1, path: '/path', title: 'foo' })[0];
+ expect(item.tagName).toEqual('SPAN');
+ expect(item.childElementCount).toEqual(2);
+ expect(item.children[0].tagName).toEqual('A');
+ expect(item.children[1].tagName).toEqual('SPAN');
+ });
+ });
+
+ describe('createLink', () => {
+ it('should return created link', () => {
+ const link = CommitMergeRequests.createLink({ iid: 1, path: '/path', title: 'foo' })[0];
+ expect(link.tagName).toEqual('A');
+ expect(link.href).toMatch(/\/path$/);
+ expect(link.innerText).toEqual('!1');
+ });
+ });
+
+ describe('createTitle', () => {
+ it('should return created title', () => {
+ const title = CommitMergeRequests.createTitle({ iid: 1, path: '/path', title: 'foo' })[0];
+ expect(title.tagName).toEqual('SPAN');
+ expect(title.innerText).toEqual('foo');
+ });
+ });
+});
diff --git a/spec/javascripts/commits_spec.js b/spec/javascripts/commits_spec.js
index d0176520440..977298b9221 100644
--- a/spec/javascripts/commits_spec.js
+++ b/spec/javascripts/commits_spec.js
@@ -1,7 +1,12 @@
+import $ from 'jquery';
import 'vendor/jquery.endless-scroll';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import CommitsList from '~/commits';
describe('Commits List', () => {
+ let commitsList;
+
beforeEach(() => {
setFixtures(`
<form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/master">
@@ -9,6 +14,7 @@ describe('Commits List', () => {
</form>
<ol id="commits-list"></ol>
`);
+ commitsList = new CommitsList(25);
});
it('should be defined', () => {
@@ -17,7 +23,7 @@ describe('Commits List', () => {
describe('processCommits', () => {
it('should join commit headers', () => {
- CommitsList.$contentList = $(`
+ commitsList.$contentList = $(`
<div>
<li class="commit-header" data-day="2016-09-20">
<span class="day">20 Sep, 2016</span>
@@ -37,36 +43,52 @@ describe('Commits List', () => {
// 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);
+ expect(commitsList.processCommits(data).find('li.commit-header').length).toBe(0);
});
});
describe('on entering input', () => {
let ajaxSpy;
+ let mock;
beforeEach(() => {
- CommitsList.init(25);
- CommitsList.searchField.val('');
+ commitsList.searchField.val('');
spyOn(history, 'replaceState').and.stub();
- ajaxSpy = spyOn(jQuery, 'ajax').and.callFake((req) => {
- req.success({
- data: '<li>Result</li>',
- });
+ mock = new MockAdapter(axios);
+
+ mock.onGet('/h5bp/html5-boilerplate/commits/master').reply(200, {
+ html: '<li>Result</li>',
});
+
+ ajaxSpy = spyOn(axios, 'get').and.callThrough();
});
- it('should save the last search string', () => {
- CommitsList.searchField.val('GitLab');
- CommitsList.filterResults();
- expect(ajaxSpy).toHaveBeenCalled();
- expect(CommitsList.lastSearch).toEqual('GitLab');
+ afterEach(() => {
+ mock.restore();
});
- it('should not make ajax call if the input does not change', () => {
- CommitsList.filterResults();
- expect(ajaxSpy).not.toHaveBeenCalled();
- expect(CommitsList.lastSearch).toEqual('');
+ it('should save the last search string', (done) => {
+ commitsList.searchField.val('GitLab');
+ commitsList.filterResults()
+ .then(() => {
+ expect(ajaxSpy).toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('GitLab');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('should not make ajax call if the input does not change', (done) => {
+ commitsList.filterResults()
+ .then(() => {
+ expect(ajaxSpy).not.toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('');
+
+ done();
+ })
+ .catch(done.fail);
});
});
});
diff --git a/spec/javascripts/create_item_dropdown_spec.js b/spec/javascripts/create_item_dropdown_spec.js
new file mode 100644
index 00000000000..ee26122be12
--- /dev/null
+++ b/spec/javascripts/create_item_dropdown_spec.js
@@ -0,0 +1,184 @@
+import $ from 'jquery';
+import CreateItemDropdown from '~/create_item_dropdown';
+
+const DROPDOWN_ITEM_DATA = [{
+ title: 'one',
+ id: 'one',
+ text: 'one',
+}, {
+ title: 'two',
+ id: 'two',
+ text: 'two',
+}, {
+ title: 'three',
+ id: 'three',
+ text: 'three',
+}];
+
+describe('CreateItemDropdown', () => {
+ preloadFixtures('static/create_item_dropdown.html.raw');
+
+ let $wrapperEl;
+ let createItemDropdown;
+
+ function createItemAndClearInput(text) {
+ // Filter for the new item
+ $wrapperEl.find('.dropdown-input-field')
+ .val(text)
+ .trigger('input');
+
+ // Create the new item
+ const $createButton = $wrapperEl.find('.js-dropdown-create-new-item');
+ $createButton.click();
+
+ // Clear out the filter
+ $wrapperEl.find('.dropdown-input-field')
+ .val('')
+ .trigger('input');
+ }
+
+ beforeEach(() => {
+ loadFixtures('static/create_item_dropdown.html.raw');
+ $wrapperEl = $('.js-create-item-dropdown-fixture-root');
+ });
+
+ afterEach(() => {
+ $wrapperEl.remove();
+ });
+
+ describe('items', () => {
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ });
+ });
+
+ it('should have a dropdown item for each piece of data', () => {
+ // Get the data in the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+ expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length);
+ });
+ });
+
+ describe('created items', () => {
+ const NEW_ITEM_TEXT = 'foobarbaz';
+
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ });
+
+ // Open the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ // Filter for the new item
+ $wrapperEl.find('.dropdown-input-field')
+ .val(NEW_ITEM_TEXT)
+ .trigger('input');
+ });
+
+ it('create new item button should include the filter text', () => {
+ expect($wrapperEl.find('.js-dropdown-create-new-item code').text()).toEqual(NEW_ITEM_TEXT);
+ });
+
+ it('should update the dropdown with the newly created item', () => {
+ // Create the new item
+ const $createButton = $wrapperEl.find('.js-dropdown-create-new-item');
+ $createButton.click();
+
+ expect($wrapperEl.find('.dropdown-toggle-text').text()).toEqual(NEW_ITEM_TEXT);
+ expect($wrapperEl.find('input[name="variable[environment]"]').val()).toEqual(NEW_ITEM_TEXT);
+ });
+
+ it('should include newly created item in dropdown list', () => {
+ createItemAndClearInput(NEW_ITEM_TEXT);
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+ expect($itemEls.length).toEqual(1 + DROPDOWN_ITEM_DATA.length);
+ expect($($itemEls.get(DROPDOWN_ITEM_DATA.length)).text()).toEqual(NEW_ITEM_TEXT);
+ });
+
+ it('should not duplicate an item when trying to create an existing item', () => {
+ createItemAndClearInput(DROPDOWN_ITEM_DATA[0].text);
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+ expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length);
+ });
+ });
+
+ describe('clearDropdown()', () => {
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ });
+ });
+
+ it('should clear all data and filter input', () => {
+ const filterInput = $wrapperEl.find('.dropdown-input-field');
+
+ // Get the data in the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ // Filter for an item
+ filterInput
+ .val('one')
+ .trigger('input');
+
+ const $itemElsAfterFilter = $wrapperEl.find('.js-dropdown-content a');
+ expect($itemElsAfterFilter.length).toEqual(1);
+
+ createItemDropdown.clearDropdown();
+
+ const $itemElsAfterClear = $wrapperEl.find('.js-dropdown-content a');
+ expect($itemElsAfterClear.length).toEqual(0);
+ expect(filterInput.val()).toEqual('');
+ });
+ });
+
+ describe('createNewItemFromValue option', () => {
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ createNewItemFromValue: newValue => ({
+ title: `${newValue}-title`,
+ id: `${newValue}-id`,
+ text: `${newValue}-text`,
+ }),
+ });
+ });
+
+ it('all items go through createNewItemFromValue', () => {
+ // Get the data in the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ createItemAndClearInput('new-item');
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+ expect($itemEls.length).toEqual(1 + DROPDOWN_ITEM_DATA.length);
+ expect($($itemEls[3]).text()).toEqual('new-item-text');
+ expect($wrapperEl.find('.dropdown-toggle-text').text()).toEqual('new-item-title');
+ });
+ });
+});
diff --git a/spec/javascripts/cycle_analytics/banner_spec.js b/spec/javascripts/cycle_analytics/banner_spec.js
index fb6b7fee168..2815bdba0c2 100644
--- a/spec/javascripts/cycle_analytics/banner_spec.js
+++ b/spec/javascripts/cycle_analytics/banner_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import banner from '~/cycle_analytics/components/banner.vue';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Cycle analytics banner', () => {
let vm;
@@ -20,8 +20,9 @@ describe('Cycle analytics banner', () => {
expect(
vm.$el.querySelector('h4').textContent.trim(),
).toEqual('Introducing Cycle Analytics');
+
expect(
- vm.$el.querySelector('p').textContent.trim(),
+ vm.$el.querySelector('p').textContent.trim().replace(/[\r\n]+/g, ' '),
).toContain('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.');
expect(
vm.$el.querySelector('a').textContent.trim(),
diff --git a/spec/javascripts/cycle_analytics/total_time_component_spec.js b/spec/javascripts/cycle_analytics/total_time_component_spec.js
index 31b65fd1cde..691e03cb8a6 100644
--- a/spec/javascripts/cycle_analytics/total_time_component_spec.js
+++ b/spec/javascripts/cycle_analytics/total_time_component_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import component from '~/cycle_analytics/components/total_time_component.vue';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Total time component', () => {
let vm;
@@ -23,7 +23,7 @@ describe('Total time component', () => {
},
});
- expect(vm.$el.textContent.trim()).toEqual('3 days 4 hrs');
+ expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('3 days 4 hrs');
});
it('should render information for hours and minutes', () => {
@@ -34,7 +34,7 @@ describe('Total time component', () => {
},
});
- expect(vm.$el.textContent.trim()).toEqual('4 hrs 35 mins');
+ expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('4 hrs 35 mins');
});
it('should render information for seconds', () => {
@@ -44,7 +44,7 @@ describe('Total time component', () => {
},
});
- expect(vm.$el.textContent.trim()).toEqual('45 s');
+ expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('45 s');
});
});
diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js
index 2e5b65f5610..a8d09202154 100644
--- a/spec/javascripts/datetime_utility_spec.js
+++ b/spec/javascripts/datetime_utility_spec.js
@@ -105,4 +105,68 @@ describe('dateInWords', () => {
it('should return abbreviated month name', () => {
expect(datetimeUtility.dateInWords(date, true)).toEqual('Jul 1, 2016');
});
+
+ it('should return date in words without year', () => {
+ expect(datetimeUtility.dateInWords(date, true, true)).toEqual('Jul 1');
+ });
+});
+
+describe('monthInWords', () => {
+ const date = new Date('2017-01-20');
+
+ it('returns month name from provided date', () => {
+ expect(datetimeUtility.monthInWords(date)).toBe('January');
+ });
+
+ it('returns abbreviated month name from provided date', () => {
+ expect(datetimeUtility.monthInWords(date, true)).toBe('Jan');
+ });
+});
+
+describe('totalDaysInMonth', () => {
+ it('returns number of days in a month for given date', () => {
+ // 1st Feb, 2016 (leap year)
+ expect(datetimeUtility.totalDaysInMonth(new Date(2016, 1, 1))).toBe(29);
+
+ // 1st Feb, 2017
+ expect(datetimeUtility.totalDaysInMonth(new Date(2017, 1, 1))).toBe(28);
+
+ // 1st Jan, 2017
+ expect(datetimeUtility.totalDaysInMonth(new Date(2017, 0, 1))).toBe(31);
+ });
+});
+
+describe('getSundays', () => {
+ it('returns array of dates representing all Sundays of the month', () => {
+ // December, 2017 (it has 5 Sundays)
+ const dateOfSundays = [3, 10, 17, 24, 31];
+ const sundays = datetimeUtility.getSundays(new Date(2017, 11, 1));
+
+ expect(sundays.length).toBe(5);
+ sundays.forEach((sunday, index) => {
+ expect(sunday.getDate()).toBe(dateOfSundays[index]);
+ });
+ });
+});
+
+describe('getTimeframeWindow', () => {
+ it('returns array of dates representing a timeframe based on provided length and date', () => {
+ const date = new Date(2018, 0, 1);
+ const mockTimeframe = [
+ new Date(2017, 9, 1),
+ new Date(2017, 10, 1),
+ new Date(2017, 11, 1),
+ new Date(2018, 0, 1),
+ new Date(2018, 1, 1),
+ new Date(2018, 2, 31),
+ ];
+ const timeframe = datetimeUtility.getTimeframeWindow(6, date);
+
+ expect(timeframe.length).toBe(6);
+ timeframe.forEach((timeframeItem, index) => {
+ expect(timeframeItem.getFullYear() === mockTimeframe[index].getFullYear()).toBeTruthy();
+ expect(timeframeItem.getMonth() === mockTimeframe[index].getMonth()).toBeTruthy();
+ expect(timeframeItem.getDate() === mockTimeframe[index].getDate()).toBeTruthy();
+ });
+ });
});
diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js
index 0ca9290d3d2..b870f87eab9 100644
--- a/spec/javascripts/deploy_keys/components/app_spec.js
+++ b/spec/javascripts/deploy_keys/components/app_spec.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import Vue from 'vue';
import eventHub from '~/deploy_keys/eventhub';
import deployKeysApp from '~/deploy_keys/components/app.vue';
diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js
index 2f28c5bbf01..b7aadf604a4 100644
--- a/spec/javascripts/deploy_keys/components/key_spec.js
+++ b/spec/javascripts/deploy_keys/components/key_spec.js
@@ -53,18 +53,24 @@ describe('Deploy keys key', () => {
).toBe('Remove');
});
- it('shows write access text when key has write access', (done) => {
- vm.deployKey.can_push = true;
+ it('shows write access title when key has write access', (done) => {
+ vm.deployKey.deploy_keys_projects[0].can_push = true;
Vue.nextTick(() => {
expect(
- vm.$el.querySelector('.write-access-allowed'),
- ).not.toBeNull();
-
- expect(
- vm.$el.querySelector('.write-access-allowed').textContent.trim(),
+ vm.$el.querySelector('.deploy-project-label').getAttribute('data-original-title'),
).toBe('Write access allowed');
+ done();
+ });
+ });
+
+ it('does not show write access title when key has write access', (done) => {
+ vm.deployKey.deploy_keys_projects[0].can_push = false;
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.deploy-project-label').getAttribute('data-original-title'),
+ ).toBe('Read access only');
done();
});
});
diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js
index b9d28db74cc..23b69defec6 100644
--- a/spec/javascripts/droplab/constants_spec.js
+++ b/spec/javascripts/droplab/constants_spec.js
@@ -1,39 +1,37 @@
-/* eslint-disable */
-
import * as constants from '~/droplab/constants';
-describe('constants', function () {
- describe('DATA_TRIGGER', function () {
+describe('constants', function() {
+ describe('DATA_TRIGGER', function() {
it('should be `data-dropdown-trigger`', function() {
expect(constants.DATA_TRIGGER).toBe('data-dropdown-trigger');
});
});
- describe('DATA_DROPDOWN', function () {
+ describe('DATA_DROPDOWN', function() {
it('should be `data-dropdown`', function() {
expect(constants.DATA_DROPDOWN).toBe('data-dropdown');
});
});
- describe('SELECTED_CLASS', function () {
+ describe('SELECTED_CLASS', function() {
it('should be `droplab-item-selected`', function() {
expect(constants.SELECTED_CLASS).toBe('droplab-item-selected');
});
});
- describe('ACTIVE_CLASS', function () {
+ describe('ACTIVE_CLASS', function() {
it('should be `droplab-item-active`', function() {
expect(constants.ACTIVE_CLASS).toBe('droplab-item-active');
});
});
- describe('TEMPLATE_REGEX', function () {
+ describe('TEMPLATE_REGEX', function() {
it('should be a handlebars templating syntax regex', function() {
expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g);
});
});
- describe('IGNORE_CLASS', function () {
+ 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 1225fe2cb66..896a04a1a07 100644
--- a/spec/javascripts/droplab/drop_down_spec.js
+++ b/spec/javascripts/droplab/drop_down_spec.js
@@ -1,8 +1,8 @@
import DropDown from '~/droplab/drop_down';
import utils from '~/droplab/utils';
-import { SELECTED_CLASS, IGNORE_CLASS } from '~/droplab/constants';
+import { SELECTED_CLASS } from '~/droplab/constants';
-describe('DropDown', function () {
+describe('DropLab DropDown', function () {
describe('class constructor', function () {
beforeEach(function () {
spyOn(DropDown.prototype, 'getItems');
@@ -128,93 +128,131 @@ describe('DropDown', function () {
beforeEach(function () {
this.classList = jasmine.createSpyObj('classList', ['contains']);
this.list = { dispatchEvent: () => {} };
- this.dropdown = { hide: () => {}, list: this.list, addSelectedClass: () => {} };
- this.event = { preventDefault: () => {}, target: { classList: this.classList } };
+ this.dropdown = {
+ hideOnClick: true,
+ hide: () => {},
+ list: this.list,
+ addSelectedClass: () => {},
+ };
+ this.event = {
+ preventDefault: () => {},
+ target: {
+ classList: this.classList,
+ closest: () => null,
+ },
+ };
this.customEvent = {};
- this.closestElement = {};
+ this.dummyListItem = document.createElement('li');
+ spyOn(this.event.target, 'closest').and.callFake((selector) => {
+ if (selector === 'li') {
+ return this.dummyListItem;
+ }
+
+ return null;
+ });
spyOn(this.dropdown, 'hide');
spyOn(this.dropdown, 'addSelectedClass');
spyOn(this.list, 'dispatchEvent');
spyOn(this.event, 'preventDefault');
spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
- spyOn(utils, 'closest').and.returnValues(this.closestElement, undefined);
this.classList.contains.and.returnValue(false);
+ });
+ it('should call event.target.closest', function () {
DropDown.prototype.clickEvent.call(this.dropdown, this.event);
- });
- it('should call utils.closest', function () {
- expect(utils.closest).toHaveBeenCalledWith(this.event.target, 'LI');
+ expect(this.event.target.closest).toHaveBeenCalledWith('.droplab-item-ignore');
+ expect(this.event.target.closest).toHaveBeenCalledWith('li');
});
it('should call addSelectedClass', function () {
- expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.closestElement);
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+
+ expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.dummyListItem);
});
it('should call .preventDefault', function () {
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+
expect(this.event.preventDefault).toHaveBeenCalled();
});
it('should call .hide', function () {
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+
expect(this.dropdown.hide).toHaveBeenCalled();
});
it('should construct CustomEvent', function () {
- expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', jasmine.any(Object));
- });
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
- it('should call .classList.contains checking for IGNORE_CLASS', function () {
- expect(this.classList.contains).toHaveBeenCalledWith(IGNORE_CLASS);
+ expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', jasmine.any(Object));
});
it('should call .dispatchEvent with the customEvent', function () {
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+
expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent);
});
describe('if the target is a UL element', function () {
beforeEach(function () {
- this.event = { preventDefault: () => {}, target: { tagName: 'UL', classList: this.classList } };
-
- spyOn(this.event, 'preventDefault');
- utils.closest.calls.reset();
+ this.event.target = document.createElement('ul');
- DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+ spyOn(this.event.target, 'closest');
});
it('should return immediately', function () {
- expect(utils.closest).not.toHaveBeenCalled();
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+
+ expect(this.event.target.closest).not.toHaveBeenCalled();
+ expect(this.dropdown.addSelectedClass).not.toHaveBeenCalled();
});
});
- describe('if the target has the IGNORE_CLASS class', function () {
+ describe('if the target has the droplab-item-ignore class', function () {
beforeEach(function () {
- this.event = { preventDefault: () => {}, target: { tagName: 'LI', classList: this.classList } };
+ this.ignoredButton = document.createElement('button');
+ this.ignoredButton.classList.add('droplab-item-ignore');
+ this.event.target = this.ignoredButton;
- spyOn(this.event, 'preventDefault');
- this.classList.contains.and.returnValue(true);
- utils.closest.calls.reset();
+ spyOn(this.ignoredButton, 'closest').and.callThrough();
+ });
+ it('does not select element', function () {
DropDown.prototype.clickEvent.call(this.dropdown, this.event);
- });
- it('should return immediately', function () {
- expect(utils.closest).not.toHaveBeenCalled();
+ expect(this.ignoredButton.closest.calls.count()).toBe(1);
+ expect(this.ignoredButton.closest).toHaveBeenCalledWith('.droplab-item-ignore');
+ expect(this.dropdown.addSelectedClass).not.toHaveBeenCalled();
});
});
describe('if no selected element exists', function () {
beforeEach(function () {
this.event.preventDefault.calls.reset();
- this.clickEvent = DropDown.prototype.clickEvent.call(this.dropdown, this.event);
- });
-
- it('should return undefined', function () {
- expect(this.clickEvent).toBe(undefined);
+ this.dummyListItem = null;
});
it('should return before .preventDefault is called', function () {
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+
expect(this.event.preventDefault).not.toHaveBeenCalled();
+ expect(this.dropdown.addSelectedClass).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if hideOnClick is false', () => {
+ beforeEach(function () {
+ this.dropdown.hideOnClick = false;
+ this.dropdown.hide.calls.reset();
+ });
+
+ it('should not call .hide', function () {
+ DropDown.prototype.clickEvent.call(this.dropdown, this.event);
+
+ expect(this.dropdown.hide).not.toHaveBeenCalled();
});
});
});
@@ -278,20 +316,23 @@ describe('DropDown', function () {
describe('addEvents', function () {
beforeEach(function () {
- this.list = { addEventListener: () => {} };
+ this.list = {
+ addEventListener: () => {},
+ querySelectorAll: () => [],
+ };
this.dropdown = {
list: this.list,
clickEvent: () => {},
closeDropdown: () => {},
eventWrapper: {},
};
+ });
+ it('should call .addEventListener', function () {
spyOn(this.list, 'addEventListener');
DropDown.prototype.addEvents.call(this.dropdown);
- });
- it('should call .addEventListener', function () {
expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function));
expect(this.list.addEventListener).toHaveBeenCalledWith('keyup', jasmine.any(Function));
});
diff --git a/spec/javascripts/environments/emtpy_state_spec.js b/spec/javascripts/environments/emtpy_state_spec.js
index 82de35933f5..10a19af4175 100644
--- a/spec/javascripts/environments/emtpy_state_spec.js
+++ b/spec/javascripts/environments/emtpy_state_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import emptyState from '~/environments/components/empty_state.vue';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('environments empty state', () => {
let vm;
diff --git a/spec/javascripts/environments/environment_item_spec.js b/spec/javascripts/environments/environment_item_spec.js
index 0e141adb628..7a34126eef7 100644
--- a/spec/javascripts/environments/environment_item_spec.js
+++ b/spec/javascripts/environments/environment_item_spec.js
@@ -68,7 +68,7 @@ describe('Environment item', () => {
username: 'root',
id: 1,
state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit: {
@@ -84,7 +84,7 @@ describe('Environment item', () => {
username: 'root',
id: 1,
state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js
index 9bd42863759..0e5e50a59a5 100644
--- a/spec/javascripts/environments/environment_table_spec.js
+++ b/spec/javascripts/environments/environment_table_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import environmentTableComp from '~/environments/components/environments_table.vue';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Environment table', () => {
let Component;
diff --git a/spec/javascripts/environments/environments_app_spec.js b/spec/javascripts/environments/environments_app_spec.js
index d02adb25b4e..e4c3bf2bef1 100644
--- a/spec/javascripts/environments/environments_app_spec.js
+++ b/spec/javascripts/environments/environments_app_spec.js
@@ -1,8 +1,9 @@
+import _ from 'underscore';
import Vue from 'vue';
import environmentsComponent from '~/environments/components/environments_app.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { headersInterceptor } from 'spec/helpers/vue_resource_helper';
import { environment, folder } from './mock_data';
-import { headersInterceptor } from '../helpers/vue_resource_helper';
-import mountComponent from '../helpers/vue_mount_component_helper';
describe('Environment', () => {
const mockData = {
@@ -60,6 +61,7 @@ describe('Environment', () => {
});
describe('with paginated environments', () => {
+ let backupInterceptors;
const environmentsResponseInterceptor = (request, next) => {
next((response) => {
response.headers.set('X-nExt-pAge', '2');
@@ -83,16 +85,16 @@ describe('Environment', () => {
};
beforeEach(() => {
- Vue.http.interceptors.push(environmentsResponseInterceptor);
- Vue.http.interceptors.push(headersInterceptor);
+ backupInterceptors = Vue.http.interceptors;
+ Vue.http.interceptors = [
+ environmentsResponseInterceptor,
+ headersInterceptor,
+ ];
component = mountComponent(EnvironmentsComponent, mockData);
});
afterEach(() => {
- Vue.http.interceptors = _.without(
- Vue.http.interceptors, environmentsResponseInterceptor,
- );
- Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor);
+ Vue.http.interceptors = backupInterceptors;
});
it('should render a table with environments', (done) => {
diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js b/spec/javascripts/environments/folder/environments_folder_view_spec.js
index 4ea4d9d7499..906a1116974 100644
--- a/spec/javascripts/environments/folder/environments_folder_view_spec.js
+++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js
@@ -1,8 +1,9 @@
+import _ from 'underscore';
import Vue from 'vue';
import environmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue';
+import { headersInterceptor } from 'spec/helpers/vue_resource_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { environmentsList } from '../mock_data';
-import { headersInterceptor } from '../../helpers/vue_resource_helper';
-import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Environments Folder View', () => {
let Component;
diff --git a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js
new file mode 100644
index 00000000000..480c138b9db
--- /dev/null
+++ b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js
@@ -0,0 +1,232 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import {
+ getSelector,
+ togglePopover,
+ dismiss,
+ mouseleave,
+ mouseenter,
+ inserted,
+} from '~/feature_highlight/feature_highlight_helper';
+import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+
+describe('feature highlight helper', () => {
+ describe('getSelector', () => {
+ it('returns js-feature-highlight selector', () => {
+ const highlightId = 'highlightId';
+ expect(getSelector(highlightId)).toEqual(`.js-feature-highlight[data-highlight=${highlightId}]`);
+ });
+ });
+
+ describe('togglePopover', () => {
+ describe('togglePopover(true)', () => {
+ it('returns true when popover is shown', () => {
+ const context = {
+ hasClass: () => false,
+ popover: () => {},
+ toggleClass: () => {},
+ };
+
+ expect(togglePopover.call(context, true)).toEqual(true);
+ });
+
+ it('returns false when popover is already shown', () => {
+ const context = {
+ hasClass: () => true,
+ };
+
+ expect(togglePopover.call(context, true)).toEqual(false);
+ });
+
+ it('shows popover', (done) => {
+ const context = {
+ hasClass: () => false,
+ popover: () => {},
+ toggleClass: () => {},
+ };
+
+ spyOn(context, 'popover').and.callFake((method) => {
+ expect(method).toEqual('show');
+ done();
+ });
+
+ togglePopover.call(context, true);
+ });
+
+ it('adds disable-animation and js-popover-show class', (done) => {
+ const context = {
+ hasClass: () => false,
+ popover: () => {},
+ toggleClass: () => {},
+ };
+
+ spyOn(context, 'toggleClass').and.callFake((classNames, show) => {
+ expect(classNames).toEqual('disable-animation js-popover-show');
+ expect(show).toEqual(true);
+ done();
+ });
+
+ togglePopover.call(context, true);
+ });
+ });
+
+ describe('togglePopover(false)', () => {
+ it('returns true when popover is hidden', () => {
+ const context = {
+ hasClass: () => true,
+ popover: () => {},
+ toggleClass: () => {},
+ };
+
+ expect(togglePopover.call(context, false)).toEqual(true);
+ });
+
+ it('returns false when popover is already hidden', () => {
+ const context = {
+ hasClass: () => false,
+ };
+
+ expect(togglePopover.call(context, false)).toEqual(false);
+ });
+
+ it('hides popover', (done) => {
+ const context = {
+ hasClass: () => true,
+ popover: () => {},
+ toggleClass: () => {},
+ };
+
+ spyOn(context, 'popover').and.callFake((method) => {
+ expect(method).toEqual('hide');
+ done();
+ });
+
+ togglePopover.call(context, false);
+ });
+
+ it('removes disable-animation and js-popover-show class', (done) => {
+ const context = {
+ hasClass: () => true,
+ popover: () => {},
+ toggleClass: () => {},
+ };
+
+ spyOn(context, 'toggleClass').and.callFake((classNames, show) => {
+ expect(classNames).toEqual('disable-animation js-popover-show');
+ expect(show).toEqual(false);
+ done();
+ });
+
+ togglePopover.call(context, false);
+ });
+ });
+ });
+
+ describe('dismiss', () => {
+ let mock;
+ const context = {
+ hide: () => {},
+ attr: () => '/-/callouts/dismiss',
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ spyOn(togglePopover, 'call').and.callFake(() => {});
+ spyOn(context, 'hide').and.callFake(() => {});
+ dismiss.call(context);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('calls persistent dismissal endpoint', (done) => {
+ const spy = jasmine.createSpy('dismiss-endpoint-hit');
+ mock.onPost('/-/callouts/dismiss').reply(spy);
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(spy).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('calls hide popover', () => {
+ expect(togglePopover.call).toHaveBeenCalledWith(context, false);
+ });
+
+ it('calls hide', () => {
+ expect(context.hide).toHaveBeenCalled();
+ });
+ });
+
+ describe('mouseleave', () => {
+ it('calls hide popover if .popover:hover is false', () => {
+ const fakeJquery = {
+ length: 0,
+ };
+
+ spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn));
+ spyOn(togglePopover, 'call');
+ mouseleave();
+ expect(togglePopover.call).toHaveBeenCalledWith(jasmine.any(Object), false);
+ });
+
+ it('does not call hide popover if .popover:hover is true', () => {
+ const fakeJquery = {
+ length: 1,
+ };
+
+ spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn));
+ spyOn(togglePopover, 'call');
+ mouseleave();
+ expect(togglePopover.call).not.toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('mouseenter', () => {
+ const context = {};
+
+ it('shows popover', () => {
+ spyOn(togglePopover, 'call').and.returnValue(false);
+ mouseenter.call(context);
+ expect(togglePopover.call).toHaveBeenCalledWith(jasmine.any(Object), true);
+ });
+
+ it('registers mouseleave event if popover is showed', (done) => {
+ spyOn(togglePopover, 'call').and.returnValue(true);
+ spyOn($.fn, 'on').and.callFake((eventName) => {
+ expect(eventName).toEqual('mouseleave');
+ done();
+ });
+ mouseenter.call(context);
+ });
+
+ it('does not register mouseleave event if popover is not showed', () => {
+ spyOn(togglePopover, 'call').and.returnValue(false);
+ const spy = spyOn($.fn, 'on').and.callFake(() => {});
+ mouseenter.call(context);
+ expect(spy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('inserted', () => {
+ it('registers click event callback', (done) => {
+ const context = {
+ getAttribute: () => 'popoverId',
+ dataset: {
+ highlight: 'some-feature',
+ },
+ };
+
+ spyOn($.fn, 'on').and.callFake((event) => {
+ expect(event).toEqual('click');
+ done();
+ });
+ inserted.call(context);
+ });
+ });
+});
diff --git a/spec/javascripts/feature_highlight/feature_highlight_options_spec.js b/spec/javascripts/feature_highlight/feature_highlight_options_spec.js
new file mode 100644
index 00000000000..7f9425d8abe
--- /dev/null
+++ b/spec/javascripts/feature_highlight/feature_highlight_options_spec.js
@@ -0,0 +1,30 @@
+import domContentLoaded from '~/feature_highlight/feature_highlight_options';
+import bp from '~/breakpoints';
+
+describe('feature highlight options', () => {
+ describe('domContentLoaded', () => {
+ it('should not call highlightFeatures when breakpoint is xs', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('xs');
+
+ expect(domContentLoaded()).toBe(false);
+ });
+
+ it('should not call highlightFeatures when breakpoint is sm', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
+
+ expect(domContentLoaded()).toBe(false);
+ });
+
+ it('should not call highlightFeatures when breakpoint is md', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('md');
+
+ expect(domContentLoaded()).toBe(false);
+ });
+
+ it('should call highlightFeatures when breakpoint is lg', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('lg');
+
+ expect(domContentLoaded()).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/feature_highlight/feature_highlight_spec.js b/spec/javascripts/feature_highlight/feature_highlight_spec.js
new file mode 100644
index 00000000000..d2dd39d49d1
--- /dev/null
+++ b/spec/javascripts/feature_highlight/feature_highlight_spec.js
@@ -0,0 +1,143 @@
+import $ from 'jquery';
+import * as featureHighlightHelper from '~/feature_highlight/feature_highlight_helper';
+import * as featureHighlight from '~/feature_highlight/feature_highlight';
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+
+describe('feature highlight', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div>
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" data-dismiss-endpoint="/test" disabled>
+ Trigger
+ </div>
+ </div>
+ <div class="feature-highlight-popover-content">
+ Content
+ <div class="dismiss-feature-highlight">
+ Dismiss
+ </div>
+ </div>
+ `);
+ });
+
+ describe('setupFeatureHighlightPopover', () => {
+ let mock;
+ const selector = '.js-feature-highlight[data-highlight=test]';
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet('/test').reply(200);
+ spyOn(window, 'addEventListener');
+ spyOn(window, 'removeEventListener');
+ featureHighlight.setupFeatureHighlightPopover('test', 0);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('setup popover content', () => {
+ const $popoverContent = $('.feature-highlight-popover-content');
+ const outerHTML = $popoverContent.prop('outerHTML');
+
+ expect($(selector).data('content')).toEqual(outerHTML);
+ });
+
+ it('setup mouseenter', () => {
+ const toggleSpy = spyOn(featureHighlightHelper.togglePopover, 'call');
+ $(selector).trigger('mouseenter');
+
+ expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), true);
+ });
+
+ it('setup debounced mouseleave', (done) => {
+ const toggleSpy = spyOn(featureHighlightHelper.togglePopover, 'call');
+ $(selector).trigger('mouseleave');
+
+ // Even though we've set the debounce to 0ms, setTimeout is needed for the debounce
+ setTimeout(() => {
+ expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), false);
+ done();
+ }, 0);
+ });
+
+ it('setup show.bs.popover', () => {
+ $(selector).trigger('show.bs.popover');
+ expect(window.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function));
+ });
+
+ it('setup hide.bs.popover', () => {
+ $(selector).trigger('hide.bs.popover');
+ expect(window.removeEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function));
+ });
+
+ it('removes disabled attribute', () => {
+ expect($('.js-feature-highlight').is(':disabled')).toEqual(false);
+ });
+
+ it('displays popover', () => {
+ expect(document.querySelector(selector).getAttribute('aria-describedby')).toBeFalsy();
+ $(selector).trigger('mouseenter');
+ expect(document.querySelector(selector).getAttribute('aria-describedby')).toBeTruthy();
+ });
+
+ it('toggles when clicked', () => {
+ $(selector).trigger('mouseenter');
+ const popoverId = $(selector).attr('aria-describedby');
+ const toggleSpy = spyOn(featureHighlightHelper.togglePopover, 'call');
+
+ $(`#${popoverId} .dismiss-feature-highlight`).click();
+
+ expect(toggleSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('findHighestPriorityFeature', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+ });
+
+ it('should pick the highest priority feature highlight', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+
+ expect($('.js-feature-highlight').length).toBeGreaterThan(1);
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
+ });
+
+ it('should work when no priority is set', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" disabled></div>
+ `);
+
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test');
+ });
+
+ it('should pick the highest priority feature highlight when some have no priority set', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test-no-priority1" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-no-priority2" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+
+ expect($('.js-feature-highlight').length).toBeGreaterThan(1);
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
+ });
+ });
+
+ describe('highlightFeatures', () => {
+ it('calls setupFeatureHighlightPopover', () => {
+ expect(featureHighlight.highlightFeatures()).toEqual('test');
+ });
+ });
+});
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 79447787fc9..59bd2650081 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
@@ -1,8 +1,7 @@
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';
+import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue';
+import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
const createComponent = (propsData) => {
const Component = Vue.extend(RecentSearchesDropdownContent);
@@ -19,14 +18,14 @@ const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim();
describe('RecentSearchesDropdownContent', () => {
const propsDataWithoutItems = {
items: [],
- allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
+ allowedKeys: FilteredSearchTokenKeys.getKeys(),
};
const propsDataWithItems = {
items: [
'foo',
'author:@root label:~foo bar',
],
- allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
+ allowedKeys: FilteredSearchTokenKeys.getKeys(),
};
let vm;
diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js
index 02415485d19..c37a964975d 100644
--- a/spec/javascripts/filtered_search/dropdown_user_spec.js
+++ b/spec/javascripts/filtered_search/dropdown_user_spec.js
@@ -1,25 +1,25 @@
-import '~/filtered_search/dropdown_utils';
-import '~/filtered_search/filtered_search_tokenizer';
-import '~/filtered_search/filtered_search_dropdown';
-import '~/filtered_search/dropdown_user';
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import DropdownUser from '~/filtered_search/dropdown_user';
+import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
+import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
describe('Dropdown User', () => {
describe('getSearchInput', () => {
let dropdownUser;
beforeEach(() => {
- spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
- spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
- spyOn(gl.DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
- spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {});
+ spyOn(DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
+ spyOn(DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
+ spyOn(DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
+ spyOn(DropdownUtils, 'getSearchInput').and.callFake(() => {});
- dropdownUser = new gl.DropdownUser({
- tokenKeys: gl.FilteredSearchTokenKeys,
+ dropdownUser = new DropdownUser({
+ tokenKeys: FilteredSearchTokenKeys,
});
});
it('should not return the double quote found in value', () => {
- spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({
+ spyOn(FilteredSearchTokenizer, 'processTokens').and.returnValue({
lastToken: '"johnny appleseed',
});
@@ -27,7 +27,7 @@ describe('Dropdown User', () => {
});
it('should not return the single quote found in value', () => {
- spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({
+ spyOn(FilteredSearchTokenizer, 'processTokens').and.returnValue({
lastToken: '\'larry boy',
});
@@ -37,22 +37,22 @@ describe('Dropdown User', () => {
describe('config AjaxFilter\'s endpoint', () => {
beforeEach(() => {
- spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
- spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
- spyOn(gl.DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
+ spyOn(DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
+ spyOn(DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
+ spyOn(DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
});
it('should return endpoint', () => {
window.gon = {
relative_url_root: '',
};
- const dropdown = new gl.DropdownUser();
+ const dropdown = new DropdownUser();
expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
it('should return endpoint when relative_url_root is undefined', () => {
- const dropdown = new gl.DropdownUser();
+ const dropdown = new DropdownUser();
expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
@@ -61,7 +61,7 @@ describe('Dropdown User', () => {
window.gon = {
relative_url_root: '/gitlab_directory',
};
- const dropdown = new gl.DropdownUser();
+ const dropdown = new DropdownUser();
expect(dropdown.config.AjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json');
});
@@ -82,7 +82,7 @@ describe('Dropdown User', () => {
loadFixtures(fixtureTemplate);
authorFilterDropdownElement = document.querySelector('#js-dropdown-author');
const dummyInput = document.createElement('div');
- dropdown = new gl.DropdownUser({
+ dropdown = new DropdownUser({
dropdown: authorFilterDropdownElement,
input: dummyInput,
});
diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js
index b1b3d43f241..3d6dec19eca 100644
--- a/spec/javascripts/filtered_search/dropdown_utils_spec.js
+++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js
@@ -1,6 +1,6 @@
-import '~/filtered_search/dropdown_utils';
-import '~/filtered_search/filtered_search_tokenizer';
-import '~/filtered_search/filtered_search_dropdown_manager';
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
+import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Dropdown Utils', () => {
@@ -9,25 +9,25 @@ describe('Dropdown Utils', () => {
describe('getEscapedText', () => {
it('should return same word when it has no space', () => {
- const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace');
+ const escaped = DropdownUtils.getEscapedText('textWithoutSpace');
expect(escaped).toBe('textWithoutSpace');
});
it('should escape with double quotes', () => {
- let escaped = gl.DropdownUtils.getEscapedText('text with space');
+ let escaped = DropdownUtils.getEscapedText('text with space');
expect(escaped).toBe('"text with space"');
- escaped = gl.DropdownUtils.getEscapedText('won\'t fix');
+ escaped = DropdownUtils.getEscapedText('won\'t fix');
expect(escaped).toBe('"won\'t fix"');
});
it('should escape with single quotes', () => {
- const escaped = gl.DropdownUtils.getEscapedText('won"t fix');
+ const escaped = DropdownUtils.getEscapedText('won"t fix');
expect(escaped).toBe('\'won"t fix\'');
});
it('should escape with single quotes by default', () => {
- const escaped = gl.DropdownUtils.getEscapedText('won"t\' fix');
+ const escaped = DropdownUtils.getEscapedText('won"t\' fix');
expect(escaped).toBe('\'won"t\' fix\'');
});
});
@@ -49,14 +49,14 @@ describe('Dropdown Utils', () => {
it('should filter without symbol', () => {
input.value = 'roo';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item);
+ const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with symbol', () => {
input.value = '@roo';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item);
+ const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
expect(updatedItem.droplab_hidden).toBe(false);
});
@@ -68,56 +68,56 @@ describe('Dropdown Utils', () => {
it('should filter with double quote', () => {
input.value = '"';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with double quote and symbol', () => {
input.value = '~"';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with double quote and multiple words', () => {
input.value = '"community con';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with double quote, symbol and multiple words', () => {
input.value = '~"community con';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote', () => {
input.value = '\'';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote and symbol', () => {
input.value = '~\'';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote and multiple words', () => {
input.value = '\'community con';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote, symbol and multiple words', () => {
input.value = '~\'community con';
- const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
});
@@ -137,7 +137,7 @@ describe('Dropdown Utils', () => {
`);
input = document.getElementById('test');
- allowedKeys = gl.FilteredSearchTokenKeys.getKeys();
+ allowedKeys = FilteredSearchTokenKeys.getKeys();
});
function config() {
@@ -149,26 +149,26 @@ describe('Dropdown Utils', () => {
it('should filter', () => {
input.value = 'l';
- let updatedItem = gl.DropdownUtils.filterHint(config(), {
+ let updatedItem = DropdownUtils.filterHint(config(), {
hint: 'label',
});
expect(updatedItem.droplab_hidden).toBe(false);
input.value = 'o';
- updatedItem = gl.DropdownUtils.filterHint(config(), {
+ updatedItem = 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(config(), {}, '');
+ const updatedItem = 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(config(), {
+ const updatedItem = DropdownUtils.filterHint(config(), {
hint: 'label',
type: 'array',
});
@@ -177,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(config(), {
+ let updatedItem = DropdownUtils.filterHint(config(), {
hint: 'milestone',
});
expect(updatedItem.droplab_hidden).toBe(true);
- updatedItem = gl.DropdownUtils.filterHint(config(), {
+ updatedItem = DropdownUtils.filterHint(config(), {
hint: 'milestone',
type: 'string',
});
@@ -204,7 +204,7 @@ describe('Dropdown Utils', () => {
color: '#000000',
};
- const updated = gl.DropdownUtils.mergeDuplicateLabels(dataMap, newLabel);
+ const updated = DropdownUtils.mergeDuplicateLabels(dataMap, newLabel);
expect(updated[newLabel.title]).toEqual(newLabel);
});
@@ -214,36 +214,36 @@ describe('Dropdown Utils', () => {
color: '#000000',
};
- const updated = gl.DropdownUtils.mergeDuplicateLabels(dataMap, duplicate);
+ const updated = 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']);
+ const gradient = 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']);
+ const gradient = 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']);
+ const gradient = 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']);
+ const gradient = 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([]);
+ const results = DropdownUtils.duplicateLabelPreprocessing([]);
expect(results.preprocessed).toEqual(true);
});
@@ -255,7 +255,7 @@ describe('Dropdown Utils', () => {
title: 'label2',
color: '#000000',
}];
- const results = gl.DropdownUtils.duplicateLabelPreprocessing(data);
+ const results = DropdownUtils.duplicateLabelPreprocessing(data);
expect(results.length).toEqual(2);
expect(results[0]).toEqual(data[0]);
@@ -270,14 +270,14 @@ describe('Dropdown Utils', () => {
title: 'label',
color: '#000000',
}];
- const results = gl.DropdownUtils.duplicateLabelPreprocessing(data);
+ const results = 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']));
+ expect(results[0].color).toEqual(DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000']));
});
it('should set multiple colored label text color to black', () => {
@@ -288,7 +288,7 @@ describe('Dropdown Utils', () => {
describe('setDataValueIfSelected', () => {
beforeEach(() => {
- spyOn(gl.FilteredSearchDropdownManager, 'addWordToInput')
+ spyOn(FilteredSearchDropdownManager, 'addWordToInput')
.and.callFake(() => {});
});
@@ -297,8 +297,8 @@ describe('Dropdown Utils', () => {
getAttribute: () => 'value',
};
- gl.DropdownUtils.setDataValueIfSelected(null, selected);
- expect(gl.FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
+ DropdownUtils.setDataValueIfSelected(null, selected);
+ expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
});
it('returns true when dataValue exists', () => {
@@ -306,7 +306,7 @@ describe('Dropdown Utils', () => {
getAttribute: () => 'value',
};
- const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
+ const result = DropdownUtils.setDataValueIfSelected(null, selected);
expect(result).toBe(true);
});
@@ -315,7 +315,7 @@ describe('Dropdown Utils', () => {
getAttribute: () => null,
};
- const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
+ const result = DropdownUtils.setDataValueIfSelected(null, selected);
expect(result).toBe(false);
});
});
@@ -325,7 +325,7 @@ describe('Dropdown Utils', () => {
const value = 'label:none ';
it('should return selectionStart when cursor is at the trailing space', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 11,
value,
});
@@ -335,7 +335,7 @@ describe('Dropdown Utils', () => {
});
it('should return input when cursor is at the start of input', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 0,
value,
});
@@ -345,7 +345,7 @@ describe('Dropdown Utils', () => {
});
it('should return input when cursor is at the middle of input', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 7,
value,
});
@@ -355,7 +355,7 @@ describe('Dropdown Utils', () => {
});
it('should return input when cursor is at the end of input', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 10,
value,
});
@@ -369,7 +369,7 @@ describe('Dropdown Utils', () => {
const value = 'label:~"Community Contribution"';
it('should return input when cursor is after the first word', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 17,
value,
});
@@ -379,7 +379,7 @@ describe('Dropdown Utils', () => {
});
it('should return input when cursor is before the second word', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 18,
value,
});
@@ -393,7 +393,7 @@ describe('Dropdown Utils', () => {
const value = 'label:~"Community Contribution';
it('should return entire input when cursor is at the start of input', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 0,
value,
});
@@ -403,7 +403,7 @@ describe('Dropdown Utils', () => {
});
it('should return entire input when cursor is at the end of input', () => {
- const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
selectionStart: 30,
value,
});
@@ -433,7 +433,7 @@ describe('Dropdown Utils', () => {
const valueContainer = authorToken.querySelector('.value-container');
valueContainer.dataset.originalValue = originalValue;
- const searchQuery = gl.DropdownUtils.getSearchQuery();
+ const searchQuery = 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 5c7e9115aac..8c5a0961a02 100644
--- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js
@@ -1,10 +1,9 @@
-import '~/filtered_search/filtered_search_visual_tokens';
-import '~/filtered_search/filtered_search_tokenizer';
-import '~/filtered_search/filtered_search_dropdown_manager';
+import $ from 'jquery';
+import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
describe('Filtered Search Dropdown Manager', () => {
beforeEach(() => {
- spyOn(jQuery, 'ajax');
+ spyOn($, 'ajax');
});
describe('addWordToInput', () => {
@@ -28,7 +27,7 @@ describe('Filtered Search Dropdown Manager', () => {
describe('input has no existing value', () => {
it('should add just tokenName', () => {
- gl.FilteredSearchDropdownManager.addWordToInput('milestone');
+ FilteredSearchDropdownManager.addWordToInput('milestone');
const token = document.querySelector('.tokens-container .js-visual-token');
@@ -38,7 +37,7 @@ describe('Filtered Search Dropdown Manager', () => {
});
it('should add tokenName and tokenValue', () => {
- gl.FilteredSearchDropdownManager.addWordToInput('label');
+ FilteredSearchDropdownManager.addWordToInput('label');
let token = document.querySelector('.tokens-container .js-visual-token');
@@ -46,9 +45,9 @@ describe('Filtered Search Dropdown Manager', () => {
expect(token.querySelector('.name').innerText).toBe('label');
expect(getInputValue()).toBe('');
- gl.FilteredSearchDropdownManager.addWordToInput('label', 'none');
+ FilteredSearchDropdownManager.addWordToInput('label', 'none');
// We have to get that reference again
- // Because gl.FilteredSearchDropdownManager deletes the previous token
+ // Because FilteredSearchDropdownManager deletes the previous token
token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
@@ -61,7 +60,7 @@ describe('Filtered Search Dropdown Manager', () => {
describe('input has existing value', () => {
it('should be able to just add tokenName', () => {
setInputValue('a');
- gl.FilteredSearchDropdownManager.addWordToInput('author');
+ FilteredSearchDropdownManager.addWordToInput('author');
const token = document.querySelector('.tokens-container .js-visual-token');
@@ -71,10 +70,10 @@ describe('Filtered Search Dropdown Manager', () => {
});
it('should replace tokenValue', () => {
- gl.FilteredSearchDropdownManager.addWordToInput('author');
+ FilteredSearchDropdownManager.addWordToInput('author');
setInputValue('roo');
- gl.FilteredSearchDropdownManager.addWordToInput(null, '@root');
+ FilteredSearchDropdownManager.addWordToInput(null, '@root');
const token = document.querySelector('.tokens-container .js-visual-token');
@@ -85,10 +84,10 @@ describe('Filtered Search Dropdown Manager', () => {
});
it('should add tokenValues containing spaces', () => {
- gl.FilteredSearchDropdownManager.addWordToInput('label');
+ FilteredSearchDropdownManager.addWordToInput('label');
setInputValue('"test ');
- gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\'');
+ FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\'');
const token = document.querySelector('.tokens-container .js-visual-token');
diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
index 5111632d681..95d02974bdc 100644
--- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js
@@ -3,17 +3,19 @@ import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searche
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 FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
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 DropdownUtils from '~/filtered_search/dropdown_utils';
+import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
+import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
+import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Filtered Search Manager', () => {
let input;
let manager;
let tokensContainer;
+ const page = 'issues';
const placeholder = 'Search or filter results...';
function dispatchBackspaceEvent(element, eventType) {
@@ -48,21 +50,21 @@ describe('Filtered Search Manager', () => {
</div>
`);
- spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
+ spyOn(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, 'updateDropdownOffset').and.callFake(() => {});
+ spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {});
+ spyOn(FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {});
+ spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {});
spyOn(gl.utils, 'getParameterByName').and.returnValue(null);
- spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough();
+ spyOn(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 = new FilteredSearchManager({ page });
manager.setup();
};
@@ -80,19 +82,19 @@ describe('Filtered Search Manager', () => {
});
it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => {
- manager = new gl.FilteredSearchManager();
+ manager = new FilteredSearchManager({ page });
expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({
isLocalStorageAvailable,
- allowedKeys: gl.FilteredSearchTokenKeys.getKeys(),
+ allowedKeys: FilteredSearchTokenKeys.getKeys(),
});
});
});
describe('setup', () => {
beforeEach(() => {
- manager = new gl.FilteredSearchManager();
+ manager = new FilteredSearchManager({ page });
});
it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => {
@@ -107,7 +109,7 @@ describe('Filtered Search Manager', () => {
describe('searchState', () => {
beforeEach(() => {
- spyOn(gl.FilteredSearchManager.prototype, 'search').and.callFake(() => {});
+ spyOn(FilteredSearchManager.prototype, 'search').and.callFake(() => {});
initializeManager();
});
@@ -133,7 +135,7 @@ describe('Filtered Search Manager', () => {
};
manager.searchState(e);
- expect(gl.FilteredSearchManager.prototype.search).not.toHaveBeenCalled();
+ expect(FilteredSearchManager.prototype.search).not.toHaveBeenCalled();
});
it('should call search when there is state', () => {
@@ -148,7 +150,7 @@ describe('Filtered Search Manager', () => {
};
manager.searchState(e);
- expect(gl.FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened');
+ expect(FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened');
});
});
@@ -250,32 +252,46 @@ describe('Filtered Search Manager', () => {
});
it('removes last token', () => {
- spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
+ spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
+ dispatchBackspaceEvent(input, 'keyup');
dispatchBackspaceEvent(input, 'keyup');
- expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
});
it('sets the input', () => {
- spyOn(gl.FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
+ spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
+ dispatchDeleteEvent(input, 'keyup');
dispatchDeleteEvent(input, 'keyup');
- expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled();
expect(input.value).toEqual('~bug');
});
});
it('does not remove token or change input when there is existing input', () => {
- spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
- spyOn(gl.FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
+ spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
+ spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
input.value = 'text';
dispatchDeleteEvent(input, 'keyup');
- expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
- expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
expect(input.value).toEqual('text');
});
+
+ it('does not remove previous token on single backspace press', () => {
+ spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
+ spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
+
+ input.value = 't';
+ dispatchDeleteEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('t');
+ });
});
describe('removeToken', () => {
@@ -294,7 +310,7 @@ describe('Filtered Search Manager', () => {
describe('unselected token', () => {
beforeEach(() => {
- spyOn(gl.FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough();
+ spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'),
@@ -365,16 +381,16 @@ describe('Filtered Search Manager', () => {
describe('removeSelectedToken', () => {
beforeEach(() => {
- spyOn(gl.FilteredSearchVisualTokens, 'removeSelectedToken').and.callThrough();
- spyOn(gl.FilteredSearchManager.prototype, 'handleInputPlaceholder').and.callThrough();
- spyOn(gl.FilteredSearchManager.prototype, 'toggleClearSearchButton').and.callThrough();
+ spyOn(FilteredSearchVisualTokens, 'removeSelectedToken').and.callThrough();
+ spyOn(FilteredSearchManager.prototype, 'handleInputPlaceholder').and.callThrough();
+ spyOn(FilteredSearchManager.prototype, 'toggleClearSearchButton').and.callThrough();
initializeManager();
});
it('calls FilteredSearchVisualTokens.removeSelectedToken', () => {
manager.removeSelectedToken();
- expect(gl.FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
});
it('calls handleInputPlaceholder', () => {
@@ -406,12 +422,12 @@ describe('Filtered Search Manager', () => {
manager.filteredSearchInput.value = inputValue;
manager.filteredSearchInput.dispatchEvent(new Event('input'));
- expect(gl.DropdownUtils.getSearchQuery()).toEqual(inputValue);
+ expect(DropdownUtils.getSearchQuery()).toEqual(inputValue);
manager.clearSearchButton.click();
expect(manager.filteredSearchInput.value).toEqual('');
- expect(gl.DropdownUtils.getSearchQuery()).toEqual('');
+ expect(DropdownUtils.getSearchQuery()).toEqual('');
});
});
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 69b424c3af5..fbc3926d332 100644
--- a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js
@@ -1,11 +1,11 @@
-import '~/filtered_search/filtered_search_token_keys';
+import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
describe('Filtered Search Token Keys', () => {
describe('get', () => {
let tokenKeys;
beforeEach(() => {
- tokenKeys = gl.FilteredSearchTokenKeys.get();
+ tokenKeys = FilteredSearchTokenKeys.get();
});
it('should return tokenKeys', () => {
@@ -21,7 +21,7 @@ describe('Filtered Search Token Keys', () => {
let conditions;
beforeEach(() => {
- conditions = gl.FilteredSearchTokenKeys.getConditions();
+ conditions = FilteredSearchTokenKeys.getConditions();
});
it('should return conditions', () => {
@@ -35,71 +35,71 @@ describe('Filtered Search Token Keys', () => {
describe('searchByKey', () => {
it('should return null when key not found', () => {
- const tokenKey = gl.FilteredSearchTokenKeys.searchByKey('notakey');
+ const tokenKey = FilteredSearchTokenKeys.searchByKey('notakey');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key', () => {
- const tokenKeys = gl.FilteredSearchTokenKeys.get();
- const result = gl.FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
+ const tokenKeys = FilteredSearchTokenKeys.get();
+ const result = FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchBySymbol', () => {
it('should return null when symbol not found', () => {
- const tokenKey = gl.FilteredSearchTokenKeys.searchBySymbol('notasymbol');
+ const tokenKey = FilteredSearchTokenKeys.searchBySymbol('notasymbol');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by symbol', () => {
- const tokenKeys = gl.FilteredSearchTokenKeys.get();
- const result = gl.FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
+ const tokenKeys = FilteredSearchTokenKeys.get();
+ const result = FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByKeyParam', () => {
it('should return null when key param not found', () => {
- const tokenKey = gl.FilteredSearchTokenKeys.searchByKeyParam('notakeyparam');
+ const tokenKey = FilteredSearchTokenKeys.searchByKeyParam('notakeyparam');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key param', () => {
- const tokenKeys = gl.FilteredSearchTokenKeys.get();
- const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
+ const tokenKeys = FilteredSearchTokenKeys.get();
+ const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
it('should return alternative tokenKey when found by key param', () => {
- const tokenKeys = gl.FilteredSearchTokenKeys.getAlternatives();
- const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
+ const tokenKeys = FilteredSearchTokenKeys.getAlternatives();
+ const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByConditionUrl', () => {
it('should return null when condition url not found', () => {
- const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(null);
+ const condition = FilteredSearchTokenKeys.searchByConditionUrl(null);
expect(condition === null).toBe(true);
});
it('should return condition when found by url', () => {
- const conditions = gl.FilteredSearchTokenKeys.getConditions();
- const result = gl.FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url);
+ const conditions = FilteredSearchTokenKeys.getConditions();
+ const result = FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url);
expect(result).toBe(conditions[0]);
});
});
describe('searchByConditionKeyValue', () => {
it('should return null when condition tokenKey and value not found', () => {
- const condition = gl.FilteredSearchTokenKeys.searchByConditionKeyValue(null, null);
+ const condition = FilteredSearchTokenKeys.searchByConditionKeyValue(null, null);
expect(condition === null).toBe(true);
});
it('should return condition when found by tokenKey and value', () => {
- const conditions = gl.FilteredSearchTokenKeys.getConditions();
- const result = gl.FilteredSearchTokenKeys
+ const conditions = FilteredSearchTokenKeys.getConditions();
+ const result = FilteredSearchTokenKeys
.searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value);
expect(result).toEqual(conditions[0]);
});
diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js
index 585bea9b499..465f5f79931 100644
--- a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js
@@ -1,19 +1,19 @@
-import '~/filtered_search/filtered_search_token_keys';
-import '~/filtered_search/filtered_search_tokenizer';
+import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
+import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
describe('Filtered Search Tokenizer', () => {
- const allowedKeys = gl.FilteredSearchTokenKeys.getKeys();
+ const allowedKeys = FilteredSearchTokenKeys.getKeys();
describe('processTokens', () => {
it('returns for input containing only search value', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys);
+ const results = FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys);
expect(results.searchToken).toBe('searchTerm');
expect(results.tokens.length).toBe(0);
expect(results.lastToken).toBe(results.searchToken);
});
it('returns for input containing only tokens', () => {
- const results = gl.FilteredSearchTokenizer
+ const results = FilteredSearchTokenizer
.processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none', allowedKeys);
expect(results.searchToken).toBe('');
expect(results.tokens.length).toBe(4);
@@ -37,7 +37,7 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns for input starting with search value and ending with tokens', () => {
- const results = gl.FilteredSearchTokenizer
+ const results = FilteredSearchTokenizer
.processTokens('searchTerm anotherSearchTerm milestone:none', allowedKeys);
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(1);
@@ -48,7 +48,7 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns for input starting with tokens and ending with search value', () => {
- const results = gl.FilteredSearchTokenizer
+ const results = FilteredSearchTokenizer
.processTokens('assignee:@user searchTerm', allowedKeys);
expect(results.searchToken).toBe('searchTerm');
@@ -60,7 +60,7 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns for input containing search value wrapped between tokens', () => {
- const results = gl.FilteredSearchTokenizer
+ const results = FilteredSearchTokenizer
.processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none', allowedKeys);
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
@@ -81,7 +81,7 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns for input containing search value in between tokens', () => {
- const results = gl.FilteredSearchTokenizer
+ const results = FilteredSearchTokenizer
.processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing', allowedKeys);
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(3);
@@ -101,14 +101,14 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns search value for invalid tokens', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('fake:token', allowedKeys);
+ const results = 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', allowedKeys);
+ const results = 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');
@@ -118,13 +118,13 @@ describe('Filtered Search Tokenizer', () => {
});
it('returns search value for invalid symbols', () => {
- const results = gl.FilteredSearchTokenizer.processTokens('std::includes', allowedKeys);
+ const results = 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', allowedKeys);
+ const results = 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 2ecb64d84b5..756a654765b 100644
--- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
@@ -1,11 +1,13 @@
+import _ from 'underscore';
import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache';
-import '~/filtered_search/filtered_search_visual_tokens';
+import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
+import DropdownUtils from '~/filtered_search//dropdown_utils';
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Filtered Search Visual Tokens', () => {
- const subject = gl.FilteredSearchVisualTokens;
+ const subject = FilteredSearchVisualTokens;
const findElements = (tokenElement) => {
const tokenNameElement = tokenElement.querySelector('.name');
@@ -126,6 +128,24 @@ describe('Filtered Search Visual Tokens', () => {
});
});
+ describe('getEndpointWithQueryParams', () => {
+ it('returns `endpoint` string as is when second param `endpointQueryParams` is undefined, null or empty string', () => {
+ const endpoint = 'foo/bar/labels.json';
+ expect(subject.getEndpointWithQueryParams(endpoint)).toBe(endpoint);
+ expect(subject.getEndpointWithQueryParams(endpoint, null)).toBe(endpoint);
+ expect(subject.getEndpointWithQueryParams(endpoint, '')).toBe(endpoint);
+ });
+
+ it('returns `endpoint` string with values of `endpointQueryParams`', () => {
+ const endpoint = 'foo/bar/labels.json';
+ const singleQueryParams = '{"foo":"true"}';
+ const multipleQueryParams = '{"foo":"true","bar":"true"}';
+
+ expect(subject.getEndpointWithQueryParams(endpoint, singleQueryParams)).toBe(`${endpoint}?foo=true`);
+ expect(subject.getEndpointWithQueryParams(endpoint, multipleQueryParams)).toBe(`${endpoint}?foo=true&bar=true`);
+ });
+ });
+
describe('unselectTokens', () => {
it('does nothing when there are no tokens', () => {
const beforeHTML = tokensContainer.innerHTML;
@@ -859,25 +879,25 @@ describe('Filtered Search Visual Tokens', () => {
it('does not preprocess more than once', () => {
let labels = [];
- spyOn(gl.DropdownUtils, 'duplicateLabelPreprocessing').and.callFake(() => []);
+ spyOn(DropdownUtils, 'duplicateLabelPreprocessing').and.callFake(() => []);
- labels = gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
- gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
+ labels = FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
+ FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
- expect(gl.DropdownUtils.duplicateLabelPreprocessing.calls.count()).toEqual(1);
+ expect(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);
+ labels = FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
expect(labels.preprocessed).toEqual(true);
});
it('overrides AjaxCache with preprocessed results', () => {
spyOn(AjaxCache, 'override').and.callFake(() => {});
- gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, []);
+ FilteredSearchVisualTokens.preprocessLabel(endpoint, []);
expect(AjaxCache.override.calls.count()).toEqual(1);
});
});
@@ -925,7 +945,7 @@ describe('Filtered Search Visual Tokens', () => {
};
const findLabel = tokenValue => labelData.find(
- label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`,
+ label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`,
);
it('updates the color of a label token', (done) => {
diff --git a/spec/javascripts/fixtures/clusters.rb b/spec/javascripts/fixtures/clusters.rb
index d26ea3febe8..8e74c4f859c 100644
--- a/spec/javascripts/fixtures/clusters.rb
+++ b/spec/javascripts/fixtures/clusters.rb
@@ -31,19 +31,4 @@ describe Projects::ClustersController, '(JavaScript fixtures)', type: :controlle
expect(response).to be_success
store_frontend_fixture(response, example.description)
end
-
- context 'rendering non-empty state' do
- before do
- cluster
- end
-
- it 'clusters/index_cluster.html.raw' do |example|
- get :index,
- namespace_id: namespace,
- project_id: project
-
- expect(response).to be_success
- store_frontend_fixture(response, example.description)
- end
- end
end
diff --git a/spec/javascripts/fixtures/create_item_dropdown.html.haml b/spec/javascripts/fixtures/create_item_dropdown.html.haml
new file mode 100644
index 00000000000..d4d91b93caf
--- /dev/null
+++ b/spec/javascripts/fixtures/create_item_dropdown.html.haml
@@ -0,0 +1,13 @@
+.js-create-item-dropdown-fixture-root
+ %input{ name: 'variable[environment]', type: 'hidden' }
+ = dropdown_tag('some label',
+ options: { toggle_class: 'js-dropdown-menu-toggle',
+ content_class: 'js-dropdown-content',
+ filter: true,
+ dropdown_class: "dropdown-menu-selectable",
+ footer_content: true }) do
+ %ul.dropdown-footer-list
+ %li
+ %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item" }
+ Create wildcard
+ %code
diff --git a/spec/javascripts/fixtures/environments.rb b/spec/javascripts/fixtures/environments.rb
deleted file mode 100644
index d2457d75419..00000000000
--- a/spec/javascripts/fixtures/environments.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-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/gl_dropdown.html.haml b/spec/javascripts/fixtures/gl_dropdown.html.haml
index a20390c08ee..43d57c2c4dc 100644
--- a/spec/javascripts/fixtures/gl_dropdown.html.haml
+++ b/spec/javascripts/fixtures/gl_dropdown.html.haml
@@ -1,7 +1,8 @@
%div
.dropdown.inline
%button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}}
- Projects
+ .dropdown-toggle-text
+ Projects
%i.fa.fa-chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle
.dropdown-menu.dropdown-select.dropdown-menu-selectable
.dropdown-title
diff --git a/spec/javascripts/fixtures/groups.rb b/spec/javascripts/fixtures/groups.rb
new file mode 100644
index 00000000000..35be52fbf97
--- /dev/null
+++ b/spec/javascripts/fixtures/groups.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe 'Groups (JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:group) { create(:group, name: 'frontend-fixtures-group' )}
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('groups/')
+ end
+
+ before do
+ group.add_master(admin)
+ sign_in(admin)
+ end
+
+ describe Groups::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do
+ it 'groups/ci_cd_settings.html.raw' do |example|
+ get :show,
+ group_id: group
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+ end
+end
diff --git a/spec/javascripts/fixtures/jobs.rb b/spec/javascripts/fixtures/jobs.rb
index 87d131dfe28..6d5c6d5334f 100644
--- a/spec/javascripts/fixtures/jobs.rb
+++ b/spec/javascripts/fixtures/jobs.rb
@@ -7,7 +7,7 @@ describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do
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!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, 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') }
diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb
index 3fd16d76f51..ee60489eb7c 100644
--- a/spec/javascripts/fixtures/merge_requests.rb
+++ b/spec/javascripts/fixtures/merge_requests.rb
@@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request)
end
+ it 'merge_requests/discussions.json' do |example|
+ create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
+ render_discussions_json(merge_request, example.description)
+ end
+
+ it 'merge_requests/diff_discussion.json' do |example|
+ create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
+ render_discussions_json(merge_request, example.description)
+ end
+
+ context 'with image diff' do
+ let(:merge_request2) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, title: "Added images") }
+ let(:image_path) { "files/images/ee_repo_logo.png" }
+ let(:image_position) do
+ Gitlab::Diff::Position.new(
+ old_path: image_path,
+ new_path: image_path,
+ width: 100,
+ height: 100,
+ x: 1,
+ y: 1,
+ position_type: "image",
+ diff_refs: merge_request2.diff_refs
+ )
+ end
+
+ it 'merge_requests/image_diff_discussion.json' do |example|
+ create(:diff_note_on_merge_request, project: project, noteable: merge_request2, position: image_position)
+ render_discussions_json(merge_request2, example.description)
+ end
+ end
+
private
+ def render_discussions_json(merge_request, fixture_file_name)
+ get :discussions,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: merge_request.to_param,
+ format: :json
+
+ store_frontend_fixture(response, fixture_file_name)
+ end
+
def render_merge_request(fixture_file_name, merge_request)
get :show,
namespace_id: project.namespace.to_param,
diff --git a/spec/javascripts/fixtures/one_white_pixel.png b/spec/javascripts/fixtures/one_white_pixel.png
new file mode 100644
index 00000000000..073fcf40a18
--- /dev/null
+++ b/spec/javascripts/fixtures/one_white_pixel.png
Binary files differ
diff --git a/spec/javascripts/fixtures/pipeline_schedules.rb b/spec/javascripts/fixtures/pipeline_schedules.rb
new file mode 100644
index 00000000000..56f27ea7df1
--- /dev/null
+++ b/spec/javascripts/fixtures/pipeline_schedules.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ let(:admin) { create(:admin) }
+ let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let(:project) { create(:project, :public, :repository) }
+ let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: admin) }
+ let!(:pipeline_schedule_populated) { create(:ci_pipeline_schedule, project: project, owner: admin) }
+ let!(:pipeline_schedule_variable1) { create(:ci_pipeline_schedule_variable, key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule_populated) }
+ let!(:pipeline_schedule_variable2) { create(:ci_pipeline_schedule_variable, key: 'bar', value: 'barvalue', pipeline_schedule: pipeline_schedule_populated) }
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('pipeline_schedules/')
+ end
+
+ before do
+ sign_in(admin)
+ end
+
+ it 'pipeline_schedules/edit.html.raw' do |example|
+ get :edit,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: pipeline_schedule.id
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+
+ it 'pipeline_schedules/edit_with_variables.html.raw' do |example|
+ get :edit,
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: pipeline_schedule_populated.id
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/fixtures/pipelines.html.haml b/spec/javascripts/fixtures/pipelines.html.haml
index 85ee61f0b54..0161c0550d1 100644
--- a/spec/javascripts/fixtures/pipelines.html.haml
+++ b/spec/javascripts/fixtures/pipelines.html.haml
@@ -7,4 +7,6 @@
"new-pipeline-path" => 'foo',
"can-create-pipeline" => 'true',
"has-ci" => 'foo',
- "ci-lint-path" => 'foo' } }
+ "ci-lint-path" => 'foo',
+ "reset-cache-path" => 'foo' } }
+
diff --git a/spec/javascripts/fixtures/projects.json b/spec/javascripts/fixtures/projects.json
index 1339ee00870..68a150f602a 100644
--- a/spec/javascripts/fixtures/projects.json
+++ b/spec/javascripts/fixtures/projects.json
@@ -14,7 +14,7 @@
"username": "root",
"id": 1,
"state": "active",
- "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon",
+ "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon",
"web_url": "http://localhost:3000/u/root"
},
"name": "test",
diff --git a/spec/javascripts/fixtures/projects.rb b/spec/javascripts/fixtures/projects.rb
index 2a100e7fab5..e8865b04874 100644
--- a/spec/javascripts/fixtures/projects.rb
+++ b/spec/javascripts/fixtures/projects.rb
@@ -1,11 +1,14 @@
require 'spec_helper'
-describe ProjectsController, '(JavaScript fixtures)', type: :controller do
+describe 'Projects (JavaScript fixtures)', type: :controller do
include JavaScriptFixturesHelpers
let(:admin) { create(:admin) }
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, namespace: namespace, path: 'builds-project') }
+ let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2') }
+ let!(:variable1) { create(:ci_variable, project: project_variable_populated) }
+ let!(:variable2) { create(:ci_variable, project: project_variable_populated) }
render_views
@@ -14,6 +17,7 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do
end
before do
+ project.add_master(admin)
sign_in(admin)
end
@@ -21,12 +25,43 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do
remove_repository(project)
end
- it 'projects/dashboard.html.raw' do |example|
- get :show,
- namespace_id: project.namespace.to_param,
- id: project
+ describe ProjectsController, '(JavaScript fixtures)', type: :controller do
+ it 'projects/dashboard.html.raw' do |example|
+ get :show,
+ namespace_id: project.namespace.to_param,
+ id: project
- expect(response).to be_success
- store_frontend_fixture(response, example.description)
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+
+ it 'projects/edit.html.raw' do |example|
+ get :edit,
+ namespace_id: project.namespace.to_param,
+ id: project
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+ end
+
+ describe Projects::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do
+ it 'projects/ci_cd_settings.html.raw' do |example|
+ get :show,
+ namespace_id: project.namespace.to_param,
+ project_id: project
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+
+ it 'projects/ci_cd_settings_with_variables.html.raw' do |example|
+ get :show,
+ namespace_id: project_variable_populated.namespace.to_param,
+ project_id: project_variable_populated
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
end
end
diff --git a/spec/javascripts/fixtures/search.rb b/spec/javascripts/fixtures/search.rb
new file mode 100644
index 00000000000..703cd3d49fa
--- /dev/null
+++ b/spec/javascripts/fixtures/search.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe SearchController, '(JavaScript fixtures)', type: :controller do
+ include JavaScriptFixturesHelpers
+
+ render_views
+
+ before(:all) do
+ clean_frontend_fixtures('search/')
+ end
+
+ it 'search/show.html.raw' do |example|
+ get :show
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/flash_spec.js b/spec/javascripts/flash_spec.js
index 97e3ab682c5..7198dbd4cf2 100644
--- a/spec/javascripts/flash_spec.js
+++ b/spec/javascripts/flash_spec.js
@@ -183,11 +183,15 @@ describe('Flash', () => {
});
it('adds flash element into container', () => {
- flash('test');
+ flash('test', 'alert', document, null, false, true);
expect(
document.querySelector('.flash-alert'),
).not.toBeNull();
+
+ expect(
+ document.body.className,
+ ).toContain('flash-shown');
});
it('adds flash into specified parent', () => {
@@ -220,13 +224,17 @@ describe('Flash', () => {
});
it('removes element after clicking', () => {
- flash('test', 'alert', document, null, false);
+ flash('test', 'alert', document, null, false, true);
document.querySelector('.flash-alert').click();
expect(
document.querySelector('.flash-alert'),
).toBeNull();
+
+ expect(
+ document.body.className,
+ ).not.toContain('flash-shown');
});
describe('with actionConfig', () => {
diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js
index a3fa07d5bc2..eb9330f5e5b 100644
--- a/spec/javascripts/fly_out_nav_spec.js
+++ b/spec/javascripts/fly_out_nav_spec.js
@@ -167,30 +167,26 @@ describe('Fly out sidebar navigation', () => {
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', () => {
+ it('shows sub-items after 0ms if no menu is open', (done) => {
mouseEnterTopItems(el);
expect(
getHideSubItemsInterval(),
).toBe(0);
- jasmine.clock().tick(0);
+ setTimeout(() => {
+ expect(
+ el.querySelector('.sidebar-sub-level-items').style.display,
+ ).toBe('block');
- expect(
- el.querySelector('.sidebar-sub-level-items').style.display,
- ).toBe('block');
+ done();
+ });
});
- it('shows sub-items after 300ms if a menu is currently open', () => {
+ it('shows sub-items after 300ms if a menu is currently open', (done) => {
documentMouseMove({
clientX: el.getBoundingClientRect().left,
clientY: el.getBoundingClientRect().top,
@@ -203,17 +199,19 @@ describe('Fly out sidebar navigation', () => {
clientY: el.getBoundingClientRect().top + 10,
});
- mouseEnterTopItems(el);
+ mouseEnterTopItems(el, 0);
expect(
getHideSubItemsInterval(),
).toBe(300);
- jasmine.clock().tick(300);
+ setTimeout(() => {
+ expect(
+ el.querySelector('.sidebar-sub-level-items').style.display,
+ ).toBe('block');
- expect(
- el.querySelector('.sidebar-sub-level-items').style.display,
- ).toBe('block');
+ done();
+ });
});
});
diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js
index 6f357306ec7..1cb20a1e7ff 100644
--- a/spec/javascripts/gfm_auto_complete_spec.js
+++ b/spec/javascripts/gfm_auto_complete_spec.js
@@ -1,5 +1,6 @@
/* eslint no-param-reassign: "off" */
+import $ from 'jquery';
import GfmAutoComplete from '~/gfm_auto_complete';
import 'vendor/jquery.caret';
@@ -80,13 +81,21 @@ describe('GfmAutoComplete', function () {
});
it('should quote if value contains any non-alphanumeric characters', () => {
- expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label-20"');
+ expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label\\-20"');
expect(beforeInsert(atwhoInstance, '~label 20')).toBe('~"label 20"');
});
it('should quote integer labels', () => {
expect(beforeInsert(atwhoInstance, '~1234')).toBe('~"1234"');
});
+
+ it('should escape Markdown emphasis characters, except in the first character', () => {
+ expect(beforeInsert(atwhoInstance, '@_group')).toEqual('@\\_group');
+ expect(beforeInsert(atwhoInstance, '~_bug')).toEqual('~\\_bug');
+ expect(beforeInsert(atwhoInstance, '~a `bug`')).toEqual('~"a \\`bug\\`"');
+ expect(beforeInsert(atwhoInstance, '~a ~bug')).toEqual('~"a \\~bug"');
+ expect(beforeInsert(atwhoInstance, '~a **bug')).toEqual('~"a \\*\\*bug"');
+ });
});
describe('DefaultOptions.matcher', function () {
@@ -130,16 +139,25 @@ describe('GfmAutoComplete', function () {
});
describe('should not match special sequences', () => {
- const ShouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']);
+ const shouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']);
+ const shouldNotBePrependedBy = ['`'];
flagsUseDefaultMatcher.forEach((atSign) => {
- ShouldNotBeFollowedBy.forEach((followedSymbol) => {
+ shouldNotBeFollowedBy.forEach((followedSymbol) => {
const seq = atSign + followedSymbol;
it(`should not match "${seq}"`, () => {
expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null);
});
});
+
+ shouldNotBePrependedBy.forEach((prependedSymbol) => {
+ const seq = prependedSymbol + atSign;
+
+ it(`should not match "${seq}"`, () => {
+ expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null);
+ });
+ });
});
});
});
diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js
index b13d1bf8dff..5393502196e 100644
--- a/spec/javascripts/gl_dropdown_spec.js
+++ b/spec/javascripts/gl_dropdown_spec.js
@@ -1,5 +1,6 @@
/* eslint-disable comma-dangle, no-param-reassign, no-unused-expressions, max-len */
+import $ from 'jquery';
import '~/gl_dropdown';
import '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
@@ -64,8 +65,8 @@ describe('glDropdown', function describeDropdown() {
});
afterEach(() => {
- $('body').unbind('keydown');
- this.dropdownContainerElement.unbind('keyup');
+ $('body').off('keydown');
+ this.dropdownContainerElement.off('keyup');
});
it('should open on click', () => {
@@ -255,4 +256,29 @@ describe('glDropdown', function describeDropdown() {
});
});
});
+
+ it('should keep selected item after selecting a second time', () => {
+ const options = {
+ isSelectable(item, $el) {
+ return !$el.hasClass('is-active');
+ },
+ toggleLabel(item) {
+ return item && item.id;
+ },
+ };
+ initDropDown.call(this, false, false, options);
+ const $item = $(`${ITEM_SELECTOR}:first() a`, this.$dropdownMenuElement);
+
+ // select item the first time
+ this.dropdownButtonElement.click();
+ $item.click();
+ expect($item).toHaveClass('is-active');
+ // select item the second time
+ this.dropdownButtonElement.click();
+ $item.click();
+ expect($item).toHaveClass('is-active');
+
+ expect($('.dropdown-toggle-text')).toHaveText(this.projectsData[0].id.toString());
+ });
});
+
diff --git a/spec/javascripts/gl_field_errors_spec.js b/spec/javascripts/gl_field_errors_spec.js
index 2779686a6f5..4e93fd91751 100644
--- a/spec/javascripts/gl_field_errors_spec.js
+++ b/spec/javascripts/gl_field_errors_spec.js
@@ -1,5 +1,6 @@
/* eslint-disable space-before-function-paren, arrow-body-style */
+import $ from 'jquery';
import GlFieldErrors from '~/gl_field_errors';
describe('GL Style Field Errors', function() {
diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js
index 5a8009e57fd..74383f901b2 100644
--- a/spec/javascripts/gl_form_spec.js
+++ b/spec/javascripts/gl_form_spec.js
@@ -1,10 +1,9 @@
-import Autosize from 'autosize';
+import $ from 'jquery';
+import autosize from 'autosize';
import GLForm from '~/gl_form';
import '~/lib/utils/text_utility';
import '~/lib/utils/common_utils';
-window.autosize = Autosize;
-
describe('GLForm', () => {
describe('when instantiated', function () {
beforeEach((done) => {
@@ -13,14 +12,12 @@ describe('GLForm', () => {
spyOn($.prototype, 'off').and.returnValue(this.textarea);
spyOn($.prototype, 'on').and.returnValue(this.textarea);
spyOn($.prototype, 'css');
- spyOn(window, 'autosize');
- this.glForm = new GLForm(this.form);
+ this.glForm = new GLForm(this.form, false);
setTimeout(() => {
$.prototype.off.calls.reset();
$.prototype.on.calls.reset();
$.prototype.css.calls.reset();
- window.autosize.calls.reset();
done();
});
});
@@ -43,10 +40,6 @@ describe('GLForm', () => {
expect($.prototype.on).toHaveBeenCalledWith('mouseup.autosize', jasmine.any(Function));
});
- it('should autosize the textarea', () => {
- expect(window.autosize).toHaveBeenCalledWith(jasmine.any(Object));
- });
-
it('should set the resize css property to vertical', () => {
expect($.prototype.css).toHaveBeenCalledWith('resize', 'vertical');
});
@@ -74,7 +67,7 @@ describe('GLForm', () => {
spyOn($.prototype, 'data');
spyOn($.prototype, 'outerHeight').and.returnValue(200);
spyOn(window, 'outerHeight').and.returnValue(400);
- spyOn(window.autosize, 'destroy');
+ spyOn(autosize, 'destroy');
this.glForm.destroyAutosize();
});
@@ -88,7 +81,7 @@ describe('GLForm', () => {
});
it('should call autosize destroy', () => {
- expect(window.autosize.destroy).toHaveBeenCalledWith(this.textarea);
+ expect(autosize.destroy).toHaveBeenCalledWith(this.textarea);
});
it('should set the data-height attribute', () => {
@@ -107,9 +100,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(window.autosize, 'destroy');
+ spyOn(autosize, 'destroy');
expect(this.glForm.destroyAutosize()).toBeUndefined();
- expect(window.autosize.destroy).not.toHaveBeenCalled();
+ expect(autosize.destroy).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/javascripts/gpg_badges_spec.js b/spec/javascripts/gpg_badges_spec.js
index 7a826487bf9..5decb5e6bbd 100644
--- a/spec/javascripts/gpg_badges_spec.js
+++ b/spec/javascripts/gpg_badges_spec.js
@@ -1,6 +1,9 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import GpgBadges from '~/gpg_badges';
describe('GpgBadges', () => {
+ let mock;
const dummyCommitSha = 'n0m0rec0ffee';
const dummyBadgeHtml = 'dummy html';
const dummyResponse = {
@@ -11,38 +14,43 @@ describe('GpgBadges', () => {
};
beforeEach(() => {
+ mock = new MockAdapter(axios);
setFixtures(`
+ <form
+ class="commits-search-form" data-signatures-path="/hello" action="/hello"
+ method="get">
+ <input name="utf8" type="hidden" value="✓">
+ <input type="search" name="search" id="commits-search"class="form-control search-text-input input-short">
+ </form>
<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
- },
- });
+ afterEach(() => {
+ mock.restore();
+ });
- GpgBadges.fetch();
+ it('displays a loading spinner', (done) => {
+ mock.onGet('/hello').reply(200);
- 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);
+ GpgBadges.fetch().then(() => {
+ 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);
+ done();
+ }).catch(done.fail);
});
- it('replaces the loading spinner', () => {
- spyOn($, 'get').and.returnValue({
- done(callback) {
- callback(dummyResponse);
- },
- });
-
- GpgBadges.fetch();
+ it('replaces the loading spinner', (done) => {
+ mock.onGet('/hello').reply(200, dummyResponse);
- expect(document.querySelector('.js-loading-gpg-badge')).toBe(null);
- const parentContainer = document.querySelector('.parent-container');
- expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml);
+ GpgBadges.fetch().then(() => {
+ expect(document.querySelector('.js-loading-gpg-badge')).toBe(null);
+ const parentContainer = document.querySelector('.parent-container');
+ expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml);
+ done();
+ }).catch(done.fail);
});
});
diff --git a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js b/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js
index 6599839a526..d8a8c8cc260 100644
--- a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js
+++ b/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js
@@ -1,7 +1,7 @@
/* eslint-disable quotes, jasmine/no-suite-dupes, vars-on-top, no-var */
import { scaleLinear, scaleTime } from 'd3-scale';
import { timeParse } from 'd3-time-format';
-import { ContributorsGraph, ContributorsMasterGraph } from '~/graphs/stat_graph_contributors_graph';
+import { ContributorsGraph, ContributorsMasterGraph } from '~/pages/projects/graphs/show/stat_graph_contributors_graph';
const d3 = { scaleLinear, scaleTime, timeParse };
diff --git a/spec/javascripts/graphs/stat_graph_contributors_spec.js b/spec/javascripts/graphs/stat_graph_contributors_spec.js
index 962423462e7..e03114c1cc5 100644
--- a/spec/javascripts/graphs/stat_graph_contributors_spec.js
+++ b/spec/javascripts/graphs/stat_graph_contributors_spec.js
@@ -1,5 +1,5 @@
-import ContributorsStatGraph from '~/graphs/stat_graph_contributors';
-import { ContributorsGraph } from '~/graphs/stat_graph_contributors_graph';
+import ContributorsStatGraph from '~/pages/projects/graphs/show/stat_graph_contributors';
+import { ContributorsGraph } from '~/pages/projects/graphs/show/stat_graph_contributors_graph';
import { setLanguage } from '../helpers/locale_helper';
diff --git a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js b/spec/javascripts/graphs/stat_graph_contributors_util_spec.js
index 9b47ab62181..22a9afe1a9d 100644
--- a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js
+++ b/spec/javascripts/graphs/stat_graph_contributors_util_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable quotes, no-var, camelcase, object-property-newline, comma-dangle, max-len, vars-on-top, quote-props */
-import ContributorsStatGraphUtil from '~/graphs/stat_graph_contributors_util';
+import ContributorsStatGraphUtil from '~/pages/projects/graphs/show/stat_graph_contributors_util';
describe("ContributorsStatGraphUtil", function () {
describe("#parse_log", function () {
diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js
index 97e39f6411b..d8428bd0e08 100644
--- a/spec/javascripts/groups/components/app_spec.js
+++ b/spec/javascripts/groups/components/app_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Vue from 'vue';
import * as utils from '~/lib/utils/url_utility';
@@ -129,7 +130,7 @@ describe('AppComponent', () => {
vm.fetchGroups({});
setTimeout(() => {
- expect(vm.isLoading).toBeFalsy();
+ expect(vm.isLoading).toBe(false);
expect($.scrollTo).toHaveBeenCalledWith(0);
expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
done();
@@ -144,10 +145,10 @@ describe('AppComponent', () => {
spyOn(vm, 'updateGroups').and.callThrough();
vm.fetchAllGroups();
- expect(vm.isLoading).toBeTruthy();
+ expect(vm.isLoading).toBe(true);
expect(vm.fetchGroups).toHaveBeenCalled();
setTimeout(() => {
- expect(vm.isLoading).toBeFalsy();
+ expect(vm.isLoading).toBe(false);
expect(vm.updateGroups).toHaveBeenCalled();
done();
}, 0);
@@ -181,7 +182,7 @@ describe('AppComponent', () => {
spyOn($, 'scrollTo');
vm.fetchPage(2, null, null, true);
- expect(vm.isLoading).toBeTruthy();
+ expect(vm.isLoading).toBe(true);
expect(vm.fetchGroups).toHaveBeenCalledWith({
page: 2,
filterGroupsBy: null,
@@ -190,7 +191,7 @@ describe('AppComponent', () => {
archived: true,
});
setTimeout(() => {
- expect(vm.isLoading).toBeFalsy();
+ expect(vm.isLoading).toBe(false);
expect($.scrollTo).toHaveBeenCalledWith(0);
expect(utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
expect(window.history.replaceState).toHaveBeenCalledWith({
@@ -216,7 +217,7 @@ describe('AppComponent', () => {
spyOn(vm.store, 'setGroupChildren');
vm.toggleChildren(groupItem);
- expect(groupItem.isChildrenLoading).toBeTruthy();
+ expect(groupItem.isChildrenLoading).toBe(true);
expect(vm.fetchGroups).toHaveBeenCalledWith({
parentId: groupItem.id,
});
@@ -232,7 +233,7 @@ describe('AppComponent', () => {
vm.toggleChildren(groupItem);
expect(vm.fetchGroups).not.toHaveBeenCalled();
- expect(groupItem.isOpen).toBeTruthy();
+ expect(groupItem.isOpen).toBe(true);
});
it('should collapse group if it is already expanded', () => {
@@ -241,21 +242,51 @@ describe('AppComponent', () => {
vm.toggleChildren(groupItem);
expect(vm.fetchGroups).not.toHaveBeenCalled();
- expect(groupItem.isOpen).toBeFalsy();
+ expect(groupItem.isOpen).toBe(false);
});
it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
vm.toggleChildren(groupItem);
- expect(groupItem.isChildrenLoading).toBeTruthy();
+ expect(groupItem.isChildrenLoading).toBe(true);
setTimeout(() => {
- expect(groupItem.isChildrenLoading).toBeFalsy();
+ expect(groupItem.isChildrenLoading).toBe(false);
done();
}, 0);
});
});
+ describe('showLeaveGroupModal', () => {
+ it('caches candidate group (as props) which is to be left', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ expect(vm.targetGroup).toBe(null);
+ expect(vm.targetParentGroup).toBe(null);
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+ expect(vm.targetGroup).not.toBe(null);
+ expect(vm.targetParentGroup).not.toBe(null);
+ });
+
+ it('updates props which show modal confirmation dialog', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ expect(vm.showModal).toBe(false);
+ expect(vm.groupLeaveConfirmationMessage).toBe('');
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+ expect(vm.showModal).toBe(true);
+ expect(vm.groupLeaveConfirmationMessage).toBe(`Are you sure you want to leave the "${group.fullName}" group?`);
+ });
+ });
+
+ describe('hideLeaveGroupModal', () => {
+ it('hides modal confirmation which is shown before leaving the group', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+ expect(vm.showModal).toBe(true);
+ vm.hideLeaveGroupModal();
+ expect(vm.showModal).toBe(false);
+ });
+ });
+
describe('leaveGroup', () => {
let groupItem;
let childGroupItem;
@@ -265,21 +296,24 @@ describe('AppComponent', () => {
groupItem.children = mockChildren;
childGroupItem = groupItem.children[0];
groupItem.isChildrenLoading = false;
+ vm.targetGroup = childGroupItem;
+ vm.targetParentGroup = groupItem;
});
- it('should leave group and remove group item from tree', (done) => {
+ it('hides modal confirmation leave group and remove group item from tree', (done) => {
const notice = `You left the "${childGroupItem.fullName}" group.`;
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
spyOn($, 'scrollTo');
- vm.leaveGroup(childGroupItem, groupItem);
- expect(childGroupItem.isBeingRemoved).toBeTruthy();
- expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ vm.leaveGroup();
+ expect(vm.showModal).toBe(false);
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath);
setTimeout(() => {
expect($.scrollTo).toHaveBeenCalledWith(0);
- expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem);
+ expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup);
expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
done();
}, 0);
@@ -291,13 +325,13 @@ describe('AppComponent', () => {
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
- vm.leaveGroup(childGroupItem, groupItem);
- expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ vm.leaveGroup();
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
setTimeout(() => {
expect(vm.store.removeGroup).not.toHaveBeenCalled();
expect(window.Flash).toHaveBeenCalledWith(message);
- expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
done();
}, 0);
});
@@ -309,12 +343,12 @@ describe('AppComponent', () => {
spyOn(window, 'Flash');
vm.leaveGroup(childGroupItem, groupItem);
- expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
setTimeout(() => {
expect(vm.store.removeGroup).not.toHaveBeenCalled();
expect(window.Flash).toHaveBeenCalledWith(message);
- expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
done();
}, 0);
});
@@ -346,10 +380,10 @@ describe('AppComponent', () => {
it('should set `isSearchEmpty` prop based on groups count', () => {
vm.updateGroups(mockGroups);
- expect(vm.isSearchEmpty).toBeFalsy();
+ expect(vm.isSearchEmpty).toBe(false);
vm.updateGroups([]);
- expect(vm.isSearchEmpty).toBeTruthy();
+ expect(vm.isSearchEmpty).toBe(true);
});
});
});
@@ -364,7 +398,7 @@ describe('AppComponent', () => {
Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
- expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
newVm.$destroy();
@@ -404,7 +438,7 @@ describe('AppComponent', () => {
Vue.nextTick(() => {
expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
- expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
done();
@@ -439,5 +473,17 @@ describe('AppComponent', () => {
done();
});
});
+
+ it('renders modal confirmation dialog', (done) => {
+ vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
+ vm.showModal = true;
+ Vue.nextTick(() => {
+ const modalDialogEl = vm.$el.querySelector('.modal');
+ expect(modalDialogEl).not.toBe(null);
+ expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+ expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+ done();
+ });
+ });
});
});
diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js
index 618d0022e4f..e3c942597a3 100644
--- a/spec/javascripts/groups/components/group_item_spec.js
+++ b/spec/javascripts/groups/components/group_item_spec.js
@@ -3,10 +3,9 @@ import * as urlUtils from '~/lib/utils/url_utility';
import groupItemComponent from '~/groups/components/group_item.vue';
import groupFolderComponent from '~/groups/components/group_folder.vue';
import eventHub from '~/groups/event_hub';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockParentGroupItem, mockChildren } from '../mock_data';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
const Component = Vue.extend(groupItemComponent);
diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js
index 90e818c1545..793c4909d89 100644
--- a/spec/javascripts/groups/components/groups_spec.js
+++ b/spec/javascripts/groups/components/groups_spec.js
@@ -4,10 +4,9 @@ import groupsComponent from '~/groups/components/groups.vue';
import groupFolderComponent from '~/groups/components/group_folder.vue';
import groupItemComponent from '~/groups/components/group_item.vue';
import eventHub from '~/groups/event_hub';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockGroups, mockPageInfo } from '../mock_data';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
const createComponent = (searchEmpty = false) => {
const Component = Vue.extend(groupsComponent);
diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js
index 7a5c1da4d1d..15fd37ebcd2 100644
--- a/spec/javascripts/groups/components/item_actions_spec.js
+++ b/spec/javascripts/groups/components/item_actions_spec.js
@@ -2,10 +2,9 @@ import Vue from 'vue';
import itemActionsComponent from '~/groups/components/item_actions.vue';
import eventHub from '~/groups/event_hub';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockParentGroupItem, mockChildren } from '../mock_data';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
const Component = Vue.extend(itemActionsComponent);
@@ -26,38 +25,12 @@ describe('ItemActionsComponent', () => {
vm.$destroy();
});
- describe('computed', () => {
- describe('leaveConfirmationMessage', () => {
- it('should return appropriate string for leave group confirmation', () => {
- expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?');
- });
- });
- });
-
describe('methods', () => {
describe('onLeaveGroup', () => {
- it('should change `modalStatus` prop to `true` which shows confirmation dialog', () => {
- expect(vm.modalStatus).toBeFalsy();
- vm.onLeaveGroup();
- expect(vm.modalStatus).toBeTruthy();
- });
- });
-
- describe('leaveGroup', () => {
- it('should change `modalStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => {
- spyOn(eventHub, '$emit');
- vm.modalStatus = true;
- vm.leaveGroup(true);
- expect(vm.modalStatus).toBeFalsy();
- expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup);
- });
-
- it('should change `modalStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => {
+ it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => {
spyOn(eventHub, '$emit');
- vm.modalStatus = true;
- vm.leaveGroup(false);
- expect(vm.modalStatus).toBeFalsy();
- expect(eventHub.$emit).not.toHaveBeenCalled();
+ vm.onLeaveGroup();
+ expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', vm.group, vm.parentGroup);
});
});
});
@@ -78,7 +51,8 @@ describe('ItemActionsComponent', () => {
expect(editBtn.getAttribute('href')).toBe(group.editPath);
expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
expect(editBtn.dataset.originalTitle).toBe('Edit group');
- expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined();
+ expect(editBtn.querySelectorAll('svg use').length).not.toBe(0);
+ expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings');
newVm.$destroy();
});
@@ -94,17 +68,10 @@ describe('ItemActionsComponent', () => {
expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
- expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined();
+ expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0);
+ expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave');
newVm.$destroy();
});
-
- it('should show modal dialog when `modalStatus` is set to `true`', () => {
- vm.modalStatus = true;
- const modalDialogEl = vm.$el.querySelector('.modal');
- expect(modalDialogEl).toBeDefined();
- expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
- expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
- });
});
});
diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js
index 4310a07e6e6..36f838a104f 100644
--- a/spec/javascripts/groups/components/item_caret_spec.js
+++ b/spec/javascripts/groups/components/item_caret_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import itemCaretComponent from '~/groups/components/item_caret.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = (isGroupOpen = false) => {
const Component = Vue.extend(itemCaretComponent);
@@ -16,24 +16,20 @@ describe('ItemCaretComponent', () => {
describe('template', () => {
it('should render component template correctly', () => {
const vm = createComponent();
- vm.$mount();
expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length).toBe(1);
vm.$destroy();
});
it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
const vm = createComponent(true);
- vm.$mount();
- expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1);
- expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0);
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down');
vm.$destroy();
});
it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
const vm = createComponent();
- vm.$mount();
- expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0);
- expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1);
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right');
vm.$destroy();
});
});
diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js
index e200f9f08bd..ee7ee18259e 100644
--- a/spec/javascripts/groups/components/item_stats_spec.js
+++ b/spec/javascripts/groups/components/item_stats_spec.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import itemStatsComponent from '~/groups/components/item_stats.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import {
mockParentGroupItem,
ITEM_TYPE,
@@ -9,8 +10,6 @@ import {
PROJECT_VISIBILITY_TYPE,
} from '../mock_data';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
const createComponent = (item = mockParentGroupItem) => {
const Component = Vue.extend(itemStatsComponent);
@@ -26,7 +25,6 @@ describe('ItemStatsComponent', () => {
Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => {
const item = Object.assign({}, mockParentGroupItem, { visibility });
const vm = createComponent(item);
- vm.$mount();
expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
vm.$destroy();
});
@@ -41,7 +39,6 @@ describe('ItemStatsComponent', () => {
type: ITEM_TYPE.GROUP,
});
const vm = createComponent(item);
- vm.$mount();
expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
vm.$destroy();
});
@@ -54,7 +51,6 @@ describe('ItemStatsComponent', () => {
type: ITEM_TYPE.PROJECT,
});
const vm = createComponent(item);
- vm.$mount();
expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
vm.$destroy();
});
@@ -68,13 +64,11 @@ describe('ItemStatsComponent', () => {
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
vm = createComponent(item);
- vm.$mount();
expect(vm.isProject).toBeTruthy();
vm.$destroy();
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
vm = createComponent(item);
- vm.$mount();
expect(vm.isProject).toBeFalsy();
vm.$destroy();
});
@@ -87,13 +81,11 @@ describe('ItemStatsComponent', () => {
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
vm = createComponent(item);
- vm.$mount();
expect(vm.isGroup).toBeTruthy();
vm.$destroy();
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
vm = createComponent(item);
- vm.$mount();
expect(vm.isGroup).toBeFalsy();
vm.$destroy();
});
@@ -101,57 +93,37 @@ describe('ItemStatsComponent', () => {
});
describe('template', () => {
- it('should render component template correctly', () => {
+ it('renders component container element correctly', () => {
const vm = createComponent();
- vm.$mount();
- const visibilityIconEl = vm.$el.querySelector('.item-visibility');
- expect(vm.$el.classList.contains('.stats')).toBeDefined();
- expect(visibilityIconEl).toBeDefined();
- expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
- expect(visibilityIconEl.querySelector('i.fa')).toBeDefined();
+ expect(vm.$el.classList.contains('stats')).toBeTruthy();
vm.$destroy();
});
- it('should render stat icons if `item.type` is Group', () => {
- const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
- const vm = createComponent(item);
- vm.$mount();
-
- const subgroupIconEl = vm.$el.querySelector('span.number-subgroups');
- expect(subgroupIconEl).toBeDefined();
- expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups');
- expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined();
- expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`);
-
- const projectsIconEl = vm.$el.querySelector('span.number-projects');
- expect(projectsIconEl).toBeDefined();
- expect(projectsIconEl.dataset.originalTitle).toBe('Projects');
- expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined();
- expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`);
-
- const membersIconEl = vm.$el.querySelector('span.number-users');
- expect(membersIconEl).toBeDefined();
- expect(membersIconEl.dataset.originalTitle).toBe('Members');
- expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined();
- expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`);
+ it('renders item visibility icon and tooltip correctly', () => {
+ const vm = createComponent();
+
+ const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+ expect(visibilityIconEl).not.toBe(null);
+ expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+ expect(visibilityIconEl.querySelectorAll('svg').length > 0).toBeTruthy();
vm.$destroy();
});
- it('should render stat icons if `item.type` is Project', () => {
+ it('renders start count and last updated information for project item correctly', () => {
const item = Object.assign({}, mockParentGroupItem, {
type: ITEM_TYPE.PROJECT,
starCount: 4,
});
const vm = createComponent(item);
- vm.$mount();
const projectStarIconEl = vm.$el.querySelector('.project-stars');
- expect(projectStarIconEl).toBeDefined();
- expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined();
- expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`);
+ expect(projectStarIconEl).not.toBe(null);
+ expect(projectStarIconEl.querySelectorAll('svg').length > 0).toBeTruthy();
+ expect(projectStarIconEl.querySelectorAll('.stat-value').length > 0).toBeTruthy();
+ expect(vm.$el.querySelectorAll('.last-updated').length > 0).toBeTruthy();
vm.$destroy();
});
diff --git a/spec/javascripts/groups/components/item_stats_value_spec.js b/spec/javascripts/groups/components/item_stats_value_spec.js
new file mode 100644
index 00000000000..5e35ae4d36c
--- /dev/null
+++ b/spec/javascripts/groups/components/item_stats_value_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+
+import itemStatsValueComponent from '~/groups/components/item_stats_value.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => {
+ const Component = Vue.extend(itemStatsValueComponent);
+
+ return mountComponent(Component, {
+ title,
+ cssClass,
+ iconName,
+ tooltipPlacement,
+ value,
+ });
+};
+
+describe('ItemStatsValueComponent', () => {
+ describe('computed', () => {
+ let vm;
+ const itemConfig = {
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ };
+
+ describe('isValuePresent', () => {
+ it('returns true if non-empty `value` is present', () => {
+ vm = createComponent(Object.assign({}, itemConfig, { value: 10 }));
+ expect(vm.isValuePresent).toBeTruthy();
+ });
+
+ it('returns false if empty `value` is present', () => {
+ vm = createComponent(itemConfig);
+ expect(vm.isValuePresent).toBeFalsy();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ beforeEach(() => {
+ vm = createComponent({
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ value: 10,
+ });
+ });
+
+ it('renders component element correctly', () => {
+ expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length > 0).toBeTruthy();
+ expect(vm.$el.querySelectorAll('.stat-value').length > 0).toBeTruthy();
+ });
+
+ it('renders element tooltip correctly', () => {
+ expect(vm.$el.dataset.originalTitle).toBe('Subgroups');
+ expect(vm.$el.dataset.placement).toBe('left');
+ });
+
+ it('renders element icon correctly', () => {
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder');
+ });
+
+ it('renders value count correctly', () => {
+ expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js
index 528e6ed1b4c..24380689b29 100644
--- a/spec/javascripts/groups/components/item_type_icon_spec.js
+++ b/spec/javascripts/groups/components/item_type_icon_spec.js
@@ -1,10 +1,9 @@
import Vue from 'vue';
import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { ITEM_TYPE } from '../mock_data';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
const Component = Vue.extend(itemTypeIconComponent);
@@ -28,12 +27,12 @@ describe('ItemTypeIconComponent', () => {
vm = createComponent(ITEM_TYPE.GROUP, true);
vm.$mount();
- expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined();
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open');
vm.$destroy();
vm = createComponent(ITEM_TYPE.GROUP);
vm.$mount();
- expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined();
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder');
vm.$destroy();
});
@@ -42,12 +41,12 @@ describe('ItemTypeIconComponent', () => {
vm = createComponent(ITEM_TYPE.PROJECT);
vm.$mount();
- expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1);
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark');
vm.$destroy();
vm = createComponent(ITEM_TYPE.GROUP);
vm.$mount();
- expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0);
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark');
vm.$destroy();
});
});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js
index 6184d671790..8bf6417487d 100644
--- a/spec/javascripts/groups/mock_data.js
+++ b/spec/javascripts/groups/mock_data.js
@@ -18,9 +18,9 @@ export const PROJECT_VISIBILITY_TYPE = {
};
export const VISIBILITY_TYPE_ICON = {
- public: 'fa-globe',
- internal: 'fa-shield',
- private: 'fa-lock',
+ public: 'earth',
+ internal: 'shield',
+ private: 'lock',
};
export const mockParentGroupItem = {
@@ -46,6 +46,7 @@ export const mockParentGroupItem = {
isOpen: true,
isChildrenLoading: false,
isBeingRemoved: false,
+ updatedAt: '2017-04-09T18:40:39.101Z',
};
export const mockRawChildren = [
@@ -69,6 +70,7 @@ export const mockRawChildren = [
subgroup_count: 2,
can_leave: false,
children: [],
+ updated_at: '2017-04-09T18:40:39.101Z',
},
];
@@ -96,6 +98,7 @@ export const mockChildren = [
isOpen: true,
isChildrenLoading: false,
isBeingRemoved: false,
+ updatedAt: '2017-04-09T18:40:39.101Z',
},
];
@@ -119,6 +122,7 @@ export const mockGroups = [
project_count: 2,
subgroup_count: 0,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
},
{
id: 67,
@@ -139,6 +143,7 @@ export const mockGroups = [
project_count: 0,
subgroup_count: 0,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
},
{
id: 54,
@@ -159,6 +164,7 @@ export const mockGroups = [
project_count: 0,
subgroup_count: 1,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
},
{
id: 5,
@@ -179,6 +185,7 @@ export const mockGroups = [
project_count: 1,
subgroup_count: 0,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
},
{
id: 4,
@@ -199,6 +206,7 @@ export const mockGroups = [
project_count: 2,
subgroup_count: 0,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
},
{
id: 3,
@@ -219,6 +227,7 @@ export const mockGroups = [
project_count: 1,
subgroup_count: 0,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
},
{
id: 2,
@@ -239,6 +248,7 @@ export const mockGroups = [
project_count: 4,
subgroup_count: 0,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
},
];
@@ -262,6 +272,7 @@ export const mockSearchedGroups = [
project_count: 1,
subgroup_count: 2,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
children: [
{
id: 57,
@@ -282,6 +293,7 @@ export const mockSearchedGroups = [
project_count: 4,
subgroup_count: 2,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
children: [
{
id: 60,
@@ -302,6 +314,7 @@ export const mockSearchedGroups = [
project_count: 0,
subgroup_count: 1,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
children: [
{
id: 61,
@@ -322,6 +335,7 @@ export const mockSearchedGroups = [
project_count: 2,
subgroup_count: 0,
can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
children: [
{
id: 17,
@@ -336,6 +350,7 @@ export const mockSearchedGroups = [
permission: null,
edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit',
star_count: 0,
+ updated_at: '2017-09-12T06:37:04.925Z',
},
{
id: 16,
@@ -350,6 +365,7 @@ export const mockSearchedGroups = [
permission: null,
edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit',
star_count: 0,
+ updated_at: '2017-04-09T18:41:03.112Z',
},
],
},
diff --git a/spec/javascripts/header_spec.js b/spec/javascripts/header_spec.js
index 2443ffd48f3..16ac438f7ac 100644
--- a/spec/javascripts/header_spec.js
+++ b/spec/javascripts/header_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import initTodoToggle from '~/header';
describe('Header', function () {
diff --git a/spec/javascripts/helpers/class_spec_helper_spec.js b/spec/javascripts/helpers/class_spec_helper_spec.js
index 686b8eaed31..1415ffb7eb3 100644
--- a/spec/javascripts/helpers/class_spec_helper_spec.js
+++ b/spec/javascripts/helpers/class_spec_helper_spec.js
@@ -3,7 +3,7 @@
import './class_spec_helper';
describe('ClassSpecHelper', () => {
- describe('itShouldBeAStaticMethod', function () {
+ describe('itShouldBeAStaticMethod', () => {
beforeEach(() => {
class TestClass {
instanceMethod() { this.prop = 'val'; }
@@ -14,23 +14,5 @@ describe('ClassSpecHelper', () => {
});
ClassSpecHelper.itShouldBeAStaticMethod(ClassSpecHelper, 'itShouldBeAStaticMethod');
-
- it('should have a defined spec', () => {
- expect(ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'staticMethod').description).toBe('should be a static method');
- });
-
- it('should pass for a static method', () => {
- const spec = ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'staticMethod');
- expect(spec.status()).toBe('passed');
- });
-
- it('should fail for an instance method', (done) => {
- const spec = ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'instanceMethod');
- spec.resultCallback = (result) => {
- expect(result.status).toBe('failed');
- done();
- };
- spec.execute();
- });
});
});
diff --git a/spec/javascripts/helpers/user_mock_data_helper.js b/spec/javascripts/helpers/user_mock_data_helper.js
index a9783ea065c..323fee3767e 100644
--- a/spec/javascripts/helpers/user_mock_data_helper.js
+++ b/spec/javascripts/helpers/user_mock_data_helper.js
@@ -4,7 +4,7 @@ export default {
for (let i = 0; i < numberUsers; i = i += 1) {
users.push(
{
- avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ avatar: 'https://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: (i + 1),
name: `GitLab User ${i}`,
username: `gitlab${i}`,
diff --git a/spec/javascripts/helpers/vue_component_helper.js b/spec/javascripts/helpers/vue_component_helper.js
new file mode 100644
index 00000000000..257c9f5526a
--- /dev/null
+++ b/spec/javascripts/helpers/vue_component_helper.js
@@ -0,0 +1,3 @@
+export default function removeBreakLine (data) {
+ return data.replace(/\r?\n|\r/g, ' ');
+}
diff --git a/spec/javascripts/helpers/vue_mount_component_helper.js b/spec/javascripts/helpers/vue_mount_component_helper.js
index 34acdfbfba9..effacbcff4e 100644
--- a/spec/javascripts/helpers/vue_mount_component_helper.js
+++ b/spec/javascripts/helpers/vue_mount_component_helper.js
@@ -3,6 +3,12 @@ export const createComponentWithStore = (Component, store, propsData = {}) => ne
propsData,
});
+export const mountComponentWithStore = (Component, { el, props, store }) =>
+ new Component({
+ store,
+ propsData: props || { },
+ }).$mount(el);
+
export default (Component, props = {}, el = null) => new Component({
propsData: props,
}).$mount(el);
diff --git a/spec/javascripts/helpers/vuex_action_helper.js b/spec/javascripts/helpers/vuex_action_helper.js
index 2d386fe1da5..83f29d1b0c2 100644
--- a/spec/javascripts/helpers/vuex_action_helper.js
+++ b/spec/javascripts/helpers/vuex_action_helper.js
@@ -1,37 +1,71 @@
-/* eslint-disable */
-
/**
- * helper for testing action with expected mutations
+ * helper for testing action with expected mutations inspired in
* https://vuex.vuejs.org/en/testing.html
+ *
+ * @example
+ * testAction(
+ * actions.actionName, // action
+ * { }, // mocked response
+ * state, // state
+ * [
+ * { type: types.MUTATION}
+ * { type: types.MUTATION_1, payload: {}}
+ * ], // mutations
+ * [
+ * { type: 'actionName', payload: {}},
+ * { type: 'actionName1', payload: {}}
+ * ] //actions
+ * done,
+ * );
*/
-export default (action, payload, state, expectedMutations, done) => {
- let count = 0;
+export default (action, payload, state, expectedMutations, expectedActions, done) => {
+ let mutationsCount = 0;
+ let actionsCount = 0;
// mock commit
- const commit = (type, payload) => {
- const mutation = expectedMutations[count];
-
- try {
- expect(mutation.type).to.equal(type);
- if (payload) {
- expect(mutation.payload).to.deep.equal(payload);
- }
- } catch (error) {
- done(error);
+ const commit = (type, mutationPayload) => {
+ const mutation = expectedMutations[mutationsCount];
+
+ expect(mutation.type).toEqual(type);
+
+ if (mutation.payload) {
+ expect(mutation.payload).toEqual(mutationPayload);
}
- count++;
- if (count >= expectedMutations.length) {
+ mutationsCount += 1;
+ if (mutationsCount >= expectedMutations.length) {
+ done();
+ }
+ };
+
+ // mock dispatch
+ const dispatch = (type, actionPayload) => {
+ const actionExpected = expectedActions[actionsCount];
+
+ expect(actionExpected.type).toEqual(type);
+
+ if (actionExpected.payload) {
+ expect(actionExpected.payload).toEqual(actionPayload);
+ }
+
+ actionsCount += 1;
+ if (actionsCount >= expectedActions.length) {
done();
}
};
// call the action with mocked store and arguments
- action({ commit, state }, payload);
+ action({ commit, state, dispatch }, payload);
// check if no mutations should have been dispatched
if (expectedMutations.length === 0) {
- expect(count).to.equal(0);
+ expect(mutationsCount).toEqual(0);
+ done();
+ }
+
+ // check if no mutations should have been dispatched
+ if (expectedActions.length === 0) {
+ expect(actionsCount).toEqual(0);
done();
}
};
diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js
new file mode 100644
index 00000000000..541864e912e
--- /dev/null
+++ b/spec/javascripts/ide/components/changed_file_icon_spec.js
@@ -0,0 +1,46 @@
+import Vue from 'vue';
+import changedFileIcon from '~/ide/components/changed_file_icon.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('IDE changed file icon', () => {
+ let vm;
+
+ beforeEach(() => {
+ const component = Vue.extend(changedFileIcon);
+
+ vm = createComponent(component, {
+ file: {
+ tempFile: false,
+ changed: true,
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('changedIcon', () => {
+ it('equals file-modified when not a temp file and has changes', () => {
+ expect(vm.changedIcon).toBe('file-modified');
+ });
+
+ it('equals file-addition when a temp file', () => {
+ vm.file.tempFile = true;
+
+ expect(vm.changedIcon).toBe('file-addition');
+ });
+ });
+
+ describe('changedIconClass', () => {
+ it('includes multi-file-modified when not a temp file', () => {
+ expect(vm.changedIconClass).toContain('multi-file-modified');
+ });
+
+ it('includes multi-file-addition when a temp file', () => {
+ vm.file.tempFile = true;
+
+ expect(vm.changedIconClass).toContain('multi-file-addition');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js
new file mode 100644
index 00000000000..144e78d14b5
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import commitActions from '~/ide/components/commit_sidebar/actions.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from 'spec/ide/helpers';
+
+describe('IDE commit sidebar actions', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(commitActions);
+
+ vm = createComponentWithStore(Component, store);
+
+ vm.$store.state.currentBranchId = 'master';
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders 3 groups', () => {
+ expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3);
+ });
+
+ it('renders current branch text', () => {
+ expect(vm.$el.textContent).toContain('Commit to master branch');
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js
new file mode 100644
index 00000000000..5b402886b55
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { file } from '../../helpers';
+
+describe('Multi-file editor commit sidebar list collapsed', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(listCollapsed);
+
+ vm = createComponentWithStore(Component, store);
+
+ vm.$store.state.changedFiles.push(file('file1'), file('file2'));
+ vm.$store.state.changedFiles[0].tempFile = true;
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders added & modified files count', () => {
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1');
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js
new file mode 100644
index 00000000000..509434e4300
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js
@@ -0,0 +1,95 @@
+import Vue from 'vue';
+import listItem from '~/ide/components/commit_sidebar/list_item.vue';
+import router from '~/ide/ide_router';
+import store from '~/ide/stores';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { file, resetStore } from '../../helpers';
+
+describe('Multi-file editor commit sidebar list item', () => {
+ let vm;
+ let f;
+
+ beforeEach(() => {
+ const Component = Vue.extend(listItem);
+
+ f = file('test-file');
+
+ store.state.entries[f.path] = f;
+
+ vm = createComponentWithStore(Component, store, {
+ file: f,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(store);
+ });
+
+ it('renders file path', () => {
+ expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path);
+ });
+
+ it('calls discardFileChanges when clicking discard button', () => {
+ spyOn(vm, 'discardFileChanges');
+
+ vm.$el.querySelector('.multi-file-discard-btn').click();
+
+ expect(vm.discardFileChanges).toHaveBeenCalled();
+ });
+
+ it('opens a closed file in the editor when clicking the file path', done => {
+ spyOn(vm, 'openFileInEditor').and.callThrough();
+ spyOn(router, 'push');
+
+ vm.$el.querySelector('.multi-file-commit-list-path').click();
+
+ setTimeout(() => {
+ expect(vm.openFileInEditor).toHaveBeenCalled();
+ expect(router.push).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls updateViewer with diff when clicking file', done => {
+ spyOn(vm, 'openFileInEditor').and.callThrough();
+ spyOn(vm, 'updateViewer').and.callThrough();
+ spyOn(router, 'push');
+
+ vm.$el.querySelector('.multi-file-commit-list-path').click();
+
+ setTimeout(() => {
+ expect(vm.updateViewer).toHaveBeenCalledWith('diff');
+
+ done();
+ });
+ });
+
+ describe('computed', () => {
+ describe('iconName', () => {
+ it('returns modified when not a tempFile', () => {
+ expect(vm.iconName).toBe('file-modified');
+ });
+
+ it('returns addition when not a tempFile', () => {
+ f.tempFile = true;
+
+ expect(vm.iconName).toBe('file-addition');
+ });
+ });
+
+ describe('iconClass', () => {
+ it('returns modified when not a tempFile', () => {
+ expect(vm.iconClass).toContain('multi-file-modified');
+ });
+
+ it('returns addition when not a tempFile', () => {
+ f.tempFile = true;
+
+ expect(vm.iconClass).toContain('multi-file-addition');
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js
new file mode 100644
index 00000000000..a62c0a28340
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { file } from '../../helpers';
+
+describe('Multi-file editor commit sidebar list', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(commitSidebarList);
+
+ vm = createComponentWithStore(Component, store, {
+ title: 'Staged',
+ fileList: [],
+ });
+
+ vm.$store.state.rightPanelCollapsed = false;
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with a list of files', () => {
+ beforeEach(done => {
+ const f = file('file name');
+ f.changed = true;
+ vm.fileList.push(f);
+
+ Vue.nextTick(done);
+ });
+
+ it('renders list', () => {
+ expect(vm.$el.querySelectorAll('li').length).toBe(1);
+ });
+ });
+
+ describe('collapsed', () => {
+ beforeEach(done => {
+ vm.$store.state.rightPanelCollapsed = true;
+
+ Vue.nextTick(done);
+ });
+
+ it('hides list', () => {
+ expect(vm.$el.querySelector('.list-unstyled')).toBeNull();
+ expect(vm.$el.querySelector('.help-block')).toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js
new file mode 100644
index 00000000000..4e8243439f3
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js
@@ -0,0 +1,130 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from 'spec/ide/helpers';
+
+describe('IDE commit sidebar radio group', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '2';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('uses label if present', () => {
+ expect(vm.$el.textContent).toContain('test');
+ });
+
+ it('uses slot if label is not present', done => {
+ vm.$destroy();
+
+ vm = new Vue({
+ components: {
+ radioGroup,
+ },
+ store,
+ template: `
+ <radio-group
+ value="1"
+ >
+ Testing slot
+ </radio-group>
+ `,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ expect(vm.$el.textContent).toContain('Testing slot');
+
+ done();
+ });
+ });
+
+ it('updates store when changing radio button', done => {
+ vm.$el.querySelector('input').dispatchEvent(new Event('change'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.commitAction).toBe('1');
+
+ done();
+ });
+ });
+
+ it('renders helpText tooltip', done => {
+ vm.helpText = 'help text';
+
+ Vue.nextTick(() => {
+ const help = vm.$el.querySelector('.help-block');
+
+ expect(help).not.toBeNull();
+ expect(help.getAttribute('data-original-title')).toBe('help text');
+
+ done();
+ });
+ });
+
+ describe('with input', () => {
+ beforeEach(done => {
+ vm.$destroy();
+
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '1';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ showInput: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders input box when commitAction matches value', () => {
+ expect(vm.$el.querySelector('.form-control')).not.toBeNull();
+ });
+
+ it('hides input when commitAction doesnt match value', done => {
+ store.state.commit.commitAction = '2';
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.form-control')).toBeNull();
+ done();
+ });
+ });
+
+ it('updates branch name in store on input', done => {
+ const input = vm.$el.querySelector('.form-control');
+ input.value = 'testing-123';
+ input.dispatchEvent(new Event('input'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.newBranchName).toBe('testing-123');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js
new file mode 100644
index 00000000000..e17b051f137
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_context_bar_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import ideContextBar from '~/ide/components/ide_context_bar.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+
+describe('Multi-file editor right context bar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideContextBar);
+
+ vm = createComponentWithStore(Component, store, {
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ });
+
+ vm.$store.state.rightPanelCollapsed = false;
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('collapsed', () => {
+ beforeEach(done => {
+ vm.$store.state.rightPanelCollapsed = true;
+
+ Vue.nextTick(done);
+ });
+
+ it('adds collapsed class', () => {
+ expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js
new file mode 100644
index 00000000000..9f6cb459f3b
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_external_links_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import ideExternalLinks from '~/ide/components/ide_external_links.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('ide external links component', () => {
+ let vm;
+ let fakeReferrer;
+ let Component;
+
+ const fakeProjectUrl = '/project/';
+
+ beforeEach(() => {
+ Component = Vue.extend(ideExternalLinks);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('goBackUrl', () => {
+ it('renders the Go Back link with the referrer when present', () => {
+ fakeReferrer = '/example/README.md';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm = createComponent(Component, {
+ projectUrl: fakeProjectUrl,
+ }).$mount();
+
+ expect(vm.goBackUrl).toEqual(fakeReferrer);
+ });
+
+ it('renders the Go Back link with the project url when referrer is not present', () => {
+ fakeReferrer = '';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm = createComponent(Component, {
+ projectUrl: fakeProjectUrl,
+ }).$mount();
+
+ expect(vm.goBackUrl).toEqual(fakeProjectUrl);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_file_buttons_spec.js b/spec/javascripts/ide/components/ide_file_buttons_spec.js
new file mode 100644
index 00000000000..8ac8d1b2acf
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_file_buttons_spec.js
@@ -0,0 +1,61 @@
+import Vue from 'vue';
+import repoFileButtons from '~/ide/components/ide_file_buttons.vue';
+import createVueComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoFileButtons', () => {
+ const activeFile = file();
+ let vm;
+
+ function createComponent() {
+ const RepoFileButtons = Vue.extend(repoFileButtons);
+
+ activeFile.rawPath = 'test';
+ activeFile.blamePath = 'test';
+ activeFile.commitsPath = 'test';
+
+ return createVueComponent(RepoFileButtons, {
+ file: activeFile,
+ });
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders Raw, Blame, History and Permalink', done => {
+ vm = createComponent();
+
+ vm.$nextTick(() => {
+ const raw = vm.$el.querySelector('.raw');
+ const blame = vm.$el.querySelector('.blame');
+ const history = vm.$el.querySelector('.history');
+
+ expect(raw.href).toMatch(`/${activeFile.rawPath}`);
+ expect(raw.getAttribute('data-original-title')).toEqual('Raw');
+ expect(blame.href).toMatch(`/${activeFile.blamePath}`);
+ expect(blame.getAttribute('data-original-title')).toEqual('Blame');
+ expect(history.href).toMatch(`/${activeFile.commitsPath}`);
+ expect(history.getAttribute('data-original-title')).toEqual('History');
+ expect(vm.$el.querySelector('.permalink').getAttribute('data-original-title')).toEqual(
+ 'Permalink',
+ );
+
+ done();
+ });
+ });
+
+ it('renders Download', done => {
+ activeFile.binary = true;
+ vm = createComponent();
+
+ vm.$nextTick(() => {
+ const raw = vm.$el.querySelector('.raw');
+
+ expect(raw.href).toMatch(`/${activeFile.rawPath}`);
+ expect(raw.getAttribute('data-original-title')).toEqual('Download');
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_project_tree_spec.js b/spec/javascripts/ide/components/ide_project_tree_spec.js
new file mode 100644
index 00000000000..657682cb39c
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_project_tree_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import ProjectTree from '~/ide/components/ide_project_tree.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('IDE project tree', () => {
+ const Component = Vue.extend(ProjectTree);
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent(Component, {
+ project: {
+ id: 1,
+ name: 'test',
+ web_url: gl.TEST_HOST,
+ avatar_url: '',
+ branches: [],
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders identicon when projct has no avatar', () => {
+ expect(vm.$el.querySelector('.identicon')).not.toBeNull();
+ });
+
+ it('renders avatar image if project has avatar', done => {
+ vm.project.avatar_url = gl.TEST_HOST;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.identicon')).toBeNull();
+ expect(vm.$el.querySelector('img.avatar')).not.toBeNull();
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js
new file mode 100644
index 00000000000..e0fbc90ca61
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import ideRepoTree from '~/ide/components/ide_repo_tree.vue';
+import createComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('IdeRepoTree', () => {
+ let vm;
+ let tree;
+
+ beforeEach(() => {
+ const IdeRepoTree = Vue.extend(ideRepoTree);
+
+ tree = {
+ tree: [file()],
+ loading: false,
+ };
+
+ vm = createComponent(IdeRepoTree, {
+ tree,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a sidebar', () => {
+ expect(vm.$el.querySelector('.loading-file')).toBeNull();
+ expect(vm.$el.querySelector('.file')).not.toBeNull();
+ });
+
+ it('renders 3 loading files if tree is loading', done => {
+ tree.loading = true;
+
+ vm.$nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('.multi-file-loading-container').length,
+ ).toEqual(3);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js
new file mode 100644
index 00000000000..699dae1ce2f
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_side_bar_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from '../helpers';
+
+describe('IdeSidebar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideSidebar);
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a sidebar', () => {
+ expect(
+ vm.$el.querySelector('.multi-file-commit-panel-inner'),
+ ).not.toBeNull();
+ });
+
+ it('renders loading icon component', done => {
+ vm.$store.state.loading = true;
+
+ vm.$nextTick(() => {
+ expect(
+ vm.$el.querySelector('.multi-file-loading-container'),
+ ).not.toBeNull();
+ expect(
+ vm.$el.querySelectorAll('.multi-file-loading-container').length,
+ ).toBe(3);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js
new file mode 100644
index 00000000000..5bd890094cc
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_spec.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import ide from '~/ide/components/ide.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { file, resetStore } from '../helpers';
+
+describe('ide component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ide);
+
+ vm = createComponentWithStore(Component, store, {
+ emptyStateSvgPath: 'svg',
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('does not render panel right when no files open', () => {
+ expect(vm.$el.querySelector('.panel-right')).toBeNull();
+ });
+
+ it('renders panel right when files are open', done => {
+ vm.$store.state.trees['abcproject/mybranch'] = {
+ tree: [file()],
+ };
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.panel-right')).toBeNull();
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js
new file mode 100644
index 00000000000..e08abe7d849
--- /dev/null
+++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import newDropdown from '~/ide/components/new_dropdown/index.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from '../../helpers';
+
+describe('new dropdown component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const component = Vue.extend(newDropdown);
+
+ vm = createComponentWithStore(component, store, {
+ branch: 'master',
+ path: '',
+ });
+
+ vm.$store.state.currentProjectId = 'abcproject';
+ vm.$store.state.path = '';
+ vm.$store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders new file, upload and new directory links', () => {
+ expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file');
+ expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe(
+ 'Upload file',
+ );
+ expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe(
+ 'New directory',
+ );
+ });
+
+ describe('createNewItem', () => {
+ it('sets modalType to blob when new file is clicked', () => {
+ vm.$el.querySelectorAll('a')[0].click();
+
+ expect(vm.modalType).toBe('blob');
+ });
+
+ it('sets modalType to tree when new directory is clicked', () => {
+ vm.$el.querySelectorAll('a')[2].click();
+
+ expect(vm.modalType).toBe('tree');
+ });
+
+ it('opens modal when link is clicked', done => {
+ vm.$el.querySelectorAll('a')[0].click();
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.modal')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
+
+ describe('hideModal', () => {
+ beforeAll(done => {
+ vm.openModal = true;
+ Vue.nextTick(done);
+ });
+
+ it('closes modal after toggling', done => {
+ vm.hideModal();
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.modal')).toBeNull();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js
new file mode 100644
index 00000000000..a6e1e5a0d35
--- /dev/null
+++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+import modal from '~/ide/components/new_dropdown/modal.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('new file modal component', () => {
+ const Component = Vue.extend(modal);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ ['tree', 'blob'].forEach(type => {
+ describe(type, () => {
+ beforeEach(() => {
+ vm = createComponent(Component, {
+ type,
+ branchId: 'master',
+ path: '',
+ });
+
+ vm.entryName = 'testing';
+ });
+
+ it(`sets modal title as ${type}`, () => {
+ const title = type === 'tree' ? 'directory' : 'file';
+
+ expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(
+ `Create new ${title}`,
+ );
+ });
+
+ it(`sets button label as ${type}`, () => {
+ const title = type === 'tree' ? 'directory' : 'file';
+
+ expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(
+ `Create ${title}`,
+ );
+ });
+
+ it(`sets form label as ${type}`, () => {
+ const title = type === 'tree' ? 'Directory' : 'File';
+
+ expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(
+ `${title} name`,
+ );
+ });
+
+ describe('createEntryInStore', () => {
+ it('$emits create', () => {
+ spyOn(vm, '$emit');
+
+ vm.createEntryInStore();
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ branchId: 'master',
+ name: 'testing',
+ type,
+ });
+ });
+ });
+ });
+ });
+
+ it('focuses field on mount', () => {
+ document.body.innerHTML += '<div class="js-test"></div>';
+
+ vm = createComponent(
+ Component,
+ {
+ type: 'tree',
+ branchId: 'master',
+ path: '',
+ },
+ '.js-test',
+ );
+
+ expect(document.activeElement).toBe(vm.$refs.fieldName);
+
+ vm.$el.remove();
+ });
+});
diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js
new file mode 100644
index 00000000000..2bc5d701601
--- /dev/null
+++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js
@@ -0,0 +1,87 @@
+import Vue from 'vue';
+import upload from '~/ide/components/new_dropdown/upload.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('new dropdown upload', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(upload);
+
+ vm = createComponent(Component, {
+ branchId: 'master',
+ path: '',
+ });
+
+ vm.entryName = 'testing';
+
+ spyOn(vm, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('readFile', () => {
+ beforeEach(() => {
+ spyOn(FileReader.prototype, 'readAsText');
+ spyOn(FileReader.prototype, 'readAsDataURL');
+ });
+
+ it('calls readAsText for text files', () => {
+ const file = {
+ type: 'text/html',
+ };
+
+ vm.readFile(file);
+
+ expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file);
+ });
+
+ it('calls readAsDataURL for non-text files', () => {
+ const file = {
+ type: 'images/png',
+ };
+
+ vm.readFile(file);
+
+ expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
+ });
+ });
+
+ describe('createFile', () => {
+ const target = {
+ result: 'content',
+ };
+ const binaryTarget = {
+ result: 'base64,base64content',
+ };
+ const file = {
+ name: 'file',
+ };
+
+ it('creates new file', () => {
+ vm.createFile(target, file, true);
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: file.name,
+ branchId: 'master',
+ type: 'blob',
+ content: target.result,
+ base64: false,
+ });
+ });
+
+ it('splits content on base64 if binary', () => {
+ vm.createFile(binaryTarget, file, false);
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: file.name,
+ branchId: 'master',
+ type: 'blob',
+ content: binaryTarget.result.split('base64,')[1],
+ base64: true,
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js
new file mode 100644
index 00000000000..113ade269e9
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_commit_section_spec.js
@@ -0,0 +1,173 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import repoCommitSection from '~/ide/components/repo_commit_section.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+import { file, resetStore } from '../helpers';
+
+describe('RepoCommitSection', () => {
+ let vm;
+
+ function createComponent() {
+ const Component = Vue.extend(repoCommitSection);
+
+ vm = createComponentWithStore(Component, store, {
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'commitsvg',
+ });
+
+ vm.$store.state.currentProjectId = 'abcproject';
+ vm.$store.state.currentBranchId = 'master';
+ vm.$store.state.projects.abcproject = {
+ web_url: '',
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+
+ vm.$store.state.rightPanelCollapsed = false;
+ vm.$store.state.currentBranch = 'master';
+ vm.$store.state.changedFiles = [file('file1'), file('file2')];
+ vm.$store.state.changedFiles.forEach(f =>
+ Object.assign(f, {
+ changed: true,
+ content: 'testing',
+ }),
+ );
+
+ return vm.$mount();
+ }
+
+ beforeEach(done => {
+ vm = createComponent();
+
+ spyOn(service, 'getTreeData').and.returnValue(
+ Promise.resolve({
+ headers: {
+ 'page-title': 'test',
+ },
+ json: () =>
+ Promise.resolve({
+ last_commit_path: 'last_commit_path',
+ parent_tree_url: 'parent_tree_url',
+ path: '/',
+ trees: [{ name: 'tree' }],
+ blobs: [{ name: 'blob' }],
+ submodules: [{ name: 'submodule' }],
+ }),
+ }),
+ );
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('empty Stage', () => {
+ it('renders no changes text', () => {
+ resetStore(vm.$store);
+ const Component = Vue.extend(repoCommitSection);
+
+ vm = createComponentWithStore(Component, store, {
+ noChangesStateSvgPath: 'nochangessvg',
+ committedStateSvgPath: 'svg',
+ }).$mount();
+
+ expect(
+ vm.$el.querySelector('.js-empty-state').textContent.trim(),
+ ).toContain('No changes');
+ expect(
+ vm.$el.querySelector('.js-empty-state img').getAttribute('src'),
+ ).toBe('nochangessvg');
+ });
+ });
+
+ it('renders a commit section', () => {
+ const changedFileElements = [
+ ...vm.$el.querySelectorAll('.multi-file-commit-list li'),
+ ];
+ const submitCommit = vm.$el.querySelector('form .btn');
+
+ expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
+ expect(changedFileElements.length).toEqual(2);
+
+ changedFileElements.forEach((changedFile, i) => {
+ expect(changedFile.textContent.trim()).toContain(
+ vm.$store.state.changedFiles[i].path,
+ );
+ });
+
+ expect(submitCommit.disabled).toBeTruthy();
+ expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
+ });
+
+ it('updates commitMessage in store on input', done => {
+ const textarea = vm.$el.querySelector('textarea');
+
+ textarea.value = 'testing commit message';
+
+ textarea.dispatchEvent(new Event('input'));
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(vm.$store.state.commit.commitMessage).toBe(
+ 'testing commit message',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('discard draft button', () => {
+ it('hidden when commitMessage is empty', () => {
+ expect(
+ vm.$el.querySelector('.multi-file-commit-form .btn-default'),
+ ).toBeNull();
+ });
+
+ it('resets commitMessage when clicking discard button', done => {
+ vm.$store.state.commit.commitMessage = 'testing commit message';
+
+ getSetTimeoutPromise()
+ .then(() => {
+ vm.$el.querySelector('.multi-file-commit-form .btn-default').click();
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$store.state.commit.commitMessage).not.toBe(
+ 'testing commit message',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('when submitting', () => {
+ beforeEach(() => {
+ spyOn(vm, 'commitChanges');
+ });
+
+ it('calls commitChanges', done => {
+ vm.$store.state.commit.commitMessage = 'testing commit message';
+
+ getSetTimeoutPromise()
+ .then(() => {
+ vm.$el.querySelector('.multi-file-commit-form .btn-success').click();
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.commitChanges).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js
new file mode 100644
index 00000000000..63a3d2c6cd5
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_editor_spec.js
@@ -0,0 +1,247 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoEditor from '~/ide/components/repo_editor.vue';
+import monacoLoader from '~/ide/monaco_loader';
+import Editor from '~/ide/lib/editor';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { file, resetStore } from '../helpers';
+
+describe('RepoEditor', () => {
+ let vm;
+
+ beforeEach(done => {
+ const f = file();
+ const RepoEditor = Vue.extend(repoEditor);
+
+ vm = createComponentWithStore(RepoEditor, store, {
+ file: f,
+ });
+
+ f.active = true;
+ f.tempFile = true;
+ vm.$store.state.openFiles.push(f);
+ vm.$store.state.entries[f.path] = f;
+ vm.monaco = true;
+
+ vm.$mount();
+
+ monacoLoader(['vs/editor/editor.main'], () => {
+ setTimeout(done, 0);
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+
+ Editor.editorInstance.modelManager.dispose();
+ });
+
+ it('renders an ide container', done => {
+ Vue.nextTick(() => {
+ expect(vm.shouldHideEditor).toBeFalsy();
+
+ done();
+ });
+ });
+
+ it('renders only an edit tab', done => {
+ Vue.nextTick(() => {
+ const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
+ expect(tabs.length).toBe(1);
+ expect(tabs[0].textContent.trim()).toBe('Edit');
+
+ done();
+ });
+ });
+
+ describe('when file is markdown', () => {
+ beforeEach(done => {
+ vm.file.previewMode = {
+ id: 'markdown',
+ previewTitle: 'Preview Markdown',
+ };
+
+ vm.$nextTick(done);
+ });
+
+ it('renders an Edit and a Preview Tab', done => {
+ Vue.nextTick(() => {
+ const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
+ expect(tabs.length).toBe(2);
+ expect(tabs[0].textContent.trim()).toBe('Edit');
+ expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
+
+ done();
+ });
+ });
+ });
+
+ describe('when file is markdown and viewer mode is review', () => {
+ beforeEach(done => {
+ vm.file.previewMode = {
+ id: 'markdown',
+ previewTitle: 'Preview Markdown',
+ };
+ vm.$store.state.viewer = 'diff';
+
+ vm.$nextTick(done);
+ });
+
+ it('renders an Edit and a Preview Tab', done => {
+ Vue.nextTick(() => {
+ const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
+ expect(tabs.length).toBe(2);
+ expect(tabs[0].textContent.trim()).toBe('Review');
+ expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
+
+ done();
+ });
+ });
+ });
+
+ describe('when open file is binary and not raw', () => {
+ beforeEach(done => {
+ vm.file.binary = true;
+
+ vm.$nextTick(done);
+ });
+
+ it('does not render the IDE', () => {
+ expect(vm.shouldHideEditor).toBeTruthy();
+ });
+ });
+
+ describe('createEditorInstance', () => {
+ it('calls createInstance when viewer is editor', done => {
+ spyOn(vm.editor, 'createInstance');
+
+ vm.createEditorInstance();
+
+ vm.$nextTick(() => {
+ expect(vm.editor.createInstance).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls createDiffInstance when viewer is diff', done => {
+ vm.$store.state.viewer = 'diff';
+
+ spyOn(vm.editor, 'createDiffInstance');
+
+ vm.createEditorInstance();
+
+ vm.$nextTick(() => {
+ expect(vm.editor.createDiffInstance).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls createDiffInstance when viewer is a merge request diff', done => {
+ vm.$store.state.viewer = 'mrdiff';
+
+ spyOn(vm.editor, 'createDiffInstance');
+
+ vm.createEditorInstance();
+
+ vm.$nextTick(() => {
+ expect(vm.editor.createDiffInstance).toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+
+ describe('setupEditor', () => {
+ it('creates new model', () => {
+ spyOn(vm.editor, 'createModel').and.callThrough();
+
+ Editor.editorInstance.modelManager.dispose();
+
+ vm.setupEditor();
+
+ expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file);
+ expect(vm.model).not.toBeNull();
+ });
+
+ it('attaches model to editor', () => {
+ spyOn(vm.editor, 'attachModel').and.callThrough();
+
+ Editor.editorInstance.modelManager.dispose();
+
+ vm.setupEditor();
+
+ expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model);
+ });
+
+ it('adds callback methods', () => {
+ spyOn(vm.editor, 'onPositionChange').and.callThrough();
+
+ Editor.editorInstance.modelManager.dispose();
+
+ vm.setupEditor();
+
+ expect(vm.editor.onPositionChange).toHaveBeenCalled();
+ expect(vm.model.events.size).toBe(1);
+ });
+
+ it('updates state when model content changed', done => {
+ vm.model.setValue('testing 123');
+
+ setTimeout(() => {
+ expect(vm.file.content).toBe('testing 123');
+
+ done();
+ });
+ });
+ });
+
+ describe('editor updateDimensions', () => {
+ beforeEach(() => {
+ spyOn(vm.editor, 'updateDimensions').and.callThrough();
+ spyOn(vm.editor, 'updateDiffView');
+ });
+
+ it('calls updateDimensions when rightPanelCollapsed is changed', done => {
+ vm.$store.state.rightPanelCollapsed = true;
+
+ vm.$nextTick(() => {
+ expect(vm.editor.updateDimensions).toHaveBeenCalled();
+ expect(vm.editor.updateDiffView).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls updateDimensions when panelResizing is false', done => {
+ vm.$store.state.panelResizing = true;
+
+ vm
+ .$nextTick()
+ .then(() => {
+ vm.$store.state.panelResizing = false;
+ })
+ .then(vm.$nextTick)
+ .then(() => {
+ expect(vm.editor.updateDimensions).toHaveBeenCalled();
+ expect(vm.editor.updateDiffView).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not call updateDimensions when panelResizing is true', done => {
+ vm.$store.state.panelResizing = true;
+
+ vm.$nextTick(() => {
+ expect(vm.editor.updateDimensions).not.toHaveBeenCalled();
+ expect(vm.editor.updateDiffView).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js
new file mode 100644
index 00000000000..ff391cb4351
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_file_spec.js
@@ -0,0 +1,80 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoFile from '~/ide/components/repo_file.vue';
+import router from '~/ide/ide_router';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoFile', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const RepoFile = Vue.extend(repoFile);
+
+ vm = createComponentWithStore(RepoFile, store, propsData);
+
+ vm.$mount();
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders link, icon and name', () => {
+ createComponent({
+ file: file('t4'),
+ level: 0,
+ });
+
+ const name = vm.$el.querySelector('.ide-file-name');
+
+ expect(name.href).toMatch('');
+ expect(name.textContent.trim()).toEqual(vm.file.name);
+ });
+
+ it('fires clickFile when the link is clicked', done => {
+ spyOn(router, 'push');
+ createComponent({
+ file: file('t3'),
+ level: 0,
+ });
+
+ vm.$el.querySelector('.file-name').click();
+
+ setTimeout(() => {
+ expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`);
+
+ done();
+ });
+ });
+
+ describe('locked file', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file('locked file');
+ f.file_lock = {
+ user: {
+ name: 'testuser',
+ updated_at: new Date(),
+ },
+ };
+
+ createComponent({
+ file: f,
+ level: 0,
+ });
+ });
+
+ it('renders lock icon', () => {
+ expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ });
+
+ it('renders a tooltip', () => {
+ expect(
+ vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset
+ .originalTitle,
+ ).toContain('Locked by testuser');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js
new file mode 100644
index 00000000000..8f9644216bc
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_loading_file_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoLoadingFile from '~/ide/components/repo_loading_file.vue';
+import { resetStore } from '../helpers';
+
+describe('RepoLoadingFile', () => {
+ let vm;
+
+ function createComponent() {
+ const RepoLoadingFile = Vue.extend(repoLoadingFile);
+
+ return new RepoLoadingFile({
+ store,
+ }).$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);
+ });
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders 3 columns of animated LoC', () => {
+ vm = createComponent();
+ const columns = [...vm.$el.querySelectorAll('td')];
+
+ expect(columns.length).toEqual(3);
+ assertColumns(columns);
+ });
+
+ it('renders 1 column of animated LoC if isMini', done => {
+ vm = createComponent();
+ vm.$store.state.leftPanelCollapsed = true;
+ vm.$store.state.openFiles.push('test');
+
+ vm.$nextTick(() => {
+ const columns = [...vm.$el.querySelectorAll('td')];
+
+ expect(columns.length).toEqual(1);
+ assertColumns(columns);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js
new file mode 100644
index 00000000000..8cabc6e8935
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_tab_spec.js
@@ -0,0 +1,165 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoTab from '~/ide/components/repo_tab.vue';
+import router from '~/ide/ide_router';
+import { file, resetStore } from '../helpers';
+
+describe('RepoTab', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const RepoTab = Vue.extend(repoTab);
+
+ return new RepoTab({
+ store,
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a close link and a name link', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+ vm.$store.state.openFiles.push(vm.tab);
+ const close = vm.$el.querySelector('.multi-file-tab-close');
+ const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
+
+ expect(close.innerHTML).toContain('#close');
+ expect(name.textContent.trim()).toEqual(vm.tab.name);
+ });
+
+ it('fires clickFile when the link is clicked', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ spyOn(vm, 'clickFile');
+
+ vm.$el.click();
+
+ expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('calls closeFile when clicking close button', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ spyOn(vm, 'closeFile');
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ expect(vm.closeFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('changes icon on hover', done => {
+ const tab = file();
+ tab.changed = true;
+ vm = createComponent({
+ tab,
+ });
+
+ vm.$el.dispatchEvent(new Event('mouseover'));
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.multi-file-modified')).toBeNull();
+
+ vm.$el.dispatchEvent(new Event('mouseout'));
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ describe('locked file', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file('locked file');
+ f.file_lock = {
+ user: {
+ name: 'testuser',
+ updated_at: new Date(),
+ },
+ };
+
+ vm = createComponent({
+ tab: f,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders lock icon', () => {
+ expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ });
+
+ it('renders a tooltip', () => {
+ expect(
+ vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle,
+ ).toContain('Locked by testuser');
+ });
+ });
+
+ describe('methods', () => {
+ describe('closeTab', () => {
+ it('closes tab if file has changed', done => {
+ const tab = file();
+ tab.changed = true;
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.changedFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+ expect(vm.$store.state.changedFiles.length).toBe(1);
+
+ done();
+ });
+ });
+
+ it('closes tab when clicking close btn', done => {
+ const tab = file('lose');
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js
new file mode 100644
index 00000000000..cb785ba2cd3
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_tabs_spec.js
@@ -0,0 +1,85 @@
+import Vue from 'vue';
+import repoTabs from '~/ide/components/repo_tabs.vue';
+import createComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoTabs', () => {
+ const openedFiles = [file('open1'), file('open2')];
+ const RepoTabs = Vue.extend(repoTabs);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a list of tabs', done => {
+ vm = createComponent(RepoTabs, {
+ files: openedFiles,
+ viewer: 'editor',
+ hasChanges: false,
+ activeFile: file('activeFile'),
+ hasMergeRequest: false,
+ });
+ openedFiles[0].active = true;
+
+ vm.$nextTick(() => {
+ const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
+
+ expect(tabs.length).toEqual(2);
+ expect(tabs[0].classList.contains('active')).toEqual(true);
+ expect(tabs[1].classList.contains('active')).toEqual(false);
+
+ done();
+ });
+ });
+
+ describe('updated', () => {
+ it('sets showShadow as true when scroll width is larger than width', done => {
+ const el = document.createElement('div');
+ el.innerHTML = '<div id="test-app"></div>';
+ document.body.appendChild(el);
+
+ const style = document.createElement('style');
+ style.innerText = `
+ .multi-file-tabs {
+ width: 100px;
+ }
+
+ .multi-file-tabs .list-unstyled {
+ display: flex;
+ overflow-x: auto;
+ }
+ `;
+ document.head.appendChild(style);
+
+ vm = createComponent(
+ RepoTabs,
+ {
+ files: [],
+ viewer: 'editor',
+ hasChanges: false,
+ activeFile: file('activeFile'),
+ hasMergeRequest: false,
+ },
+ '#test-app',
+ );
+
+ vm
+ .$nextTick()
+ .then(() => {
+ expect(vm.showShadow).toEqual(false);
+
+ vm.files = openedFiles;
+ })
+ .then(vm.$nextTick)
+ .then(() => {
+ expect(vm.showShadow).toEqual(true);
+
+ style.remove();
+ el.remove();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js
new file mode 100644
index 00000000000..98db6defc7a
--- /dev/null
+++ b/spec/javascripts/ide/helpers.js
@@ -0,0 +1,22 @@
+import { decorateData } from '~/ide/stores/utils';
+import state from '~/ide/stores/state';
+import commitState from '~/ide/stores/modules/commit/state';
+
+export const resetStore = store => {
+ const newState = {
+ ...state(),
+ commit: commitState(),
+ };
+ store.replaceState(newState);
+};
+
+export const file = (name = 'name', id = name, type = '') =>
+ decorateData({
+ id,
+ type,
+ icon: 'icon',
+ url: 'url',
+ name,
+ path: name,
+ lastCommit: {},
+ });
diff --git a/spec/javascripts/repo/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js
index af12ca15369..af12ca15369 100644
--- a/spec/javascripts/repo/lib/common/disposable_spec.js
+++ b/spec/javascripts/ide/lib/common/disposable_spec.js
diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js
new file mode 100644
index 00000000000..c00d590c580
--- /dev/null
+++ b/spec/javascripts/ide/lib/common/model_manager_spec.js
@@ -0,0 +1,132 @@
+/* global monaco */
+import eventHub from '~/ide/eventhub';
+import monacoLoader from '~/ide/monaco_loader';
+import ModelManager from '~/ide/lib/common/model_manager';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model manager', () => {
+ let instance;
+
+ beforeEach(done => {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ instance = new ModelManager(monaco);
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ });
+
+ describe('addModel', () => {
+ it('caches model', () => {
+ instance.addModel(file());
+
+ expect(instance.models.size).toBe(1);
+ });
+
+ it('caches model by file path', () => {
+ const f = file('path-name');
+ instance.addModel(f);
+
+ expect(instance.models.keys().next().value).toBe(f.key);
+ });
+
+ it('adds model into disposable', () => {
+ spyOn(instance.disposable, 'add').and.callThrough();
+
+ instance.addModel(file());
+
+ expect(instance.disposable.add).toHaveBeenCalled();
+ });
+
+ it('returns cached model', () => {
+ spyOn(instance.models, 'get').and.callThrough();
+
+ instance.addModel(file());
+ instance.addModel(file());
+
+ expect(instance.models.get).toHaveBeenCalled();
+ });
+
+ it('adds eventHub listener', () => {
+ const f = file();
+ spyOn(eventHub, '$on').and.callThrough();
+
+ instance.addModel(f);
+
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ jasmine.anything(),
+ );
+ });
+ });
+
+ describe('hasCachedModel', () => {
+ it('returns false when no models exist', () => {
+ expect(instance.hasCachedModel('path')).toBeFalsy();
+ });
+
+ it('returns true when model exists', () => {
+ const f = file('path-name');
+
+ instance.addModel(f);
+
+ expect(instance.hasCachedModel(f.key)).toBeTruthy();
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns cached model', () => {
+ instance.addModel(file('path-name'));
+
+ expect(instance.getModel('path-name')).not.toBeNull();
+ });
+ });
+
+ describe('removeCachedModel', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file();
+
+ instance.addModel(f);
+ });
+
+ it('clears cached model', () => {
+ instance.removeCachedModel(f);
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ spyOn(eventHub, '$off').and.callThrough();
+
+ instance.removeCachedModel(f);
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ jasmine.anything(),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached models', () => {
+ instance.addModel(file());
+
+ instance.dispose();
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('calls disposable dispose', () => {
+ spyOn(instance.disposable, 'dispose').and.callThrough();
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js
new file mode 100644
index 00000000000..8fc2fccb64c
--- /dev/null
+++ b/spec/javascripts/ide/lib/common/model_spec.js
@@ -0,0 +1,123 @@
+/* global monaco */
+import eventHub from '~/ide/eventhub';
+import monacoLoader from '~/ide/monaco_loader';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model', () => {
+ let model;
+
+ beforeEach(done => {
+ spyOn(eventHub, '$on').and.callThrough();
+
+ monacoLoader(['vs/editor/editor.main'], () => {
+ const f = file('path');
+ f.mrChange = { diff: 'ABC' };
+ f.baseRaw = 'test';
+ model = new Model(monaco, f);
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ model.dispose();
+ });
+
+ it('creates original model & base model & new model', () => {
+ expect(model.originalModel).not.toBeNull();
+ expect(model.model).not.toBeNull();
+ expect(model.baseModel).not.toBeNull();
+ });
+
+ it('adds eventHub listener', () => {
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ jasmine.anything(),
+ );
+ });
+
+ describe('path', () => {
+ it('returns file path', () => {
+ expect(model.path).toBe(model.file.key);
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns model', () => {
+ expect(model.getModel()).toBe(model.model);
+ });
+ });
+
+ describe('getOriginalModel', () => {
+ it('returns original model', () => {
+ expect(model.getOriginalModel()).toBe(model.originalModel);
+ });
+ });
+
+ describe('getBaseModel', () => {
+ it('returns base model', () => {
+ expect(model.getBaseModel()).toBe(model.baseModel);
+ });
+ });
+
+ describe('setValue', () => {
+ it('updates models value', () => {
+ model.setValue('testing 123');
+
+ expect(model.getModel().getValue()).toBe('testing 123');
+ });
+ });
+
+ describe('onChange', () => {
+ it('caches event by path', () => {
+ model.onChange(() => {});
+
+ expect(model.events.size).toBe(1);
+ expect(model.events.keys().next().value).toBe(model.file.key);
+ });
+
+ it('calls callback on change', done => {
+ const spy = jasmine.createSpy();
+ model.onChange(spy);
+
+ model.getModel().setValue('123');
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalledWith(model, jasmine.anything());
+ done();
+ });
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ spyOn(model.disposable, 'dispose').and.callThrough();
+
+ model.dispose();
+
+ expect(model.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('clears events', () => {
+ model.onChange(() => {});
+
+ expect(model.events.size).toBe(1);
+
+ model.dispose();
+
+ expect(model.events.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ spyOn(eventHub, '$off').and.callThrough();
+
+ model.dispose();
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ jasmine.anything(),
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js
new file mode 100644
index 00000000000..aec325e26a9
--- /dev/null
+++ b/spec/javascripts/ide/lib/decorations/controller_spec.js
@@ -0,0 +1,120 @@
+/* global monaco */
+import monacoLoader from '~/ide/monaco_loader';
+import editor from '~/ide/lib/editor';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library decorations controller', () => {
+ let editorInstance;
+ let controller;
+ let model;
+
+ beforeEach(done => {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ editorInstance = editor.create(monaco);
+ editorInstance.createInstance(document.createElement('div'));
+
+ controller = new DecorationsController(editorInstance);
+ model = new Model(monaco, file('path'));
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ model.dispose();
+ editorInstance.dispose();
+ controller.dispose();
+ });
+
+ describe('getAllDecorationsForModel', () => {
+ it('returns empty array when no decorations exist for model', () => {
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations).toEqual([]);
+ });
+
+ it('returns decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
+ });
+ });
+
+ describe('addDecorations', () => {
+ it('caches decorations in a new map', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('does not create new cache model', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ expect(controller.decorations.keys().next().value).toBe('path--path');
+ });
+
+ it('calls decorate method', () => {
+ spyOn(controller, 'decorate');
+
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorate).toHaveBeenCalled();
+ });
+ });
+
+ describe('decorate', () => {
+ it('sets decorations on editor instance', () => {
+ spyOn(controller.editor.instance, 'deltaDecorations');
+
+ controller.decorate(model);
+
+ expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
+ });
+
+ it('caches decorations', () => {
+ spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.keys().next().value).toBe('path--path');
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached decorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.decorations.size).toBe(0);
+ });
+
+ it('clears cached editorDecorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js
new file mode 100644
index 00000000000..ff73240734e
--- /dev/null
+++ b/spec/javascripts/ide/lib/diff/controller_spec.js
@@ -0,0 +1,196 @@
+/* global monaco */
+import monacoLoader from '~/ide/monaco_loader';
+import editor from '~/ide/lib/editor';
+import ModelManager from '~/ide/lib/common/model_manager';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import DirtyDiffController, {
+ getDiffChangeType,
+ getDecorator,
+} from '~/ide/lib/diff/controller';
+import { computeDiff } from '~/ide/lib/diff/diff';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library dirty diff controller', () => {
+ let editorInstance;
+ let controller;
+ let modelManager;
+ let decorationsController;
+ let model;
+
+ beforeEach(done => {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ editorInstance = editor.create(monaco);
+ editorInstance.createInstance(document.createElement('div'));
+
+ modelManager = new ModelManager(monaco);
+ decorationsController = new DecorationsController(editorInstance);
+
+ model = modelManager.addModel(file('path'));
+
+ controller = new DirtyDiffController(modelManager, decorationsController);
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ controller.dispose();
+ model.dispose();
+ decorationsController.dispose();
+ editorInstance.dispose();
+ });
+
+ describe('getDiffChangeType', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDiffChangeType(change)).toBe(type);
+ });
+ });
+ });
+
+ describe('getDecorator', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns with linesDecorationsClassName for ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDecorator(change).options.linesDecorationsClassName).toBe(
+ `dirty-diff dirty-diff-${type}`,
+ );
+ });
+
+ it('returns with line numbers', () => {
+ const change = {
+ lineNumber: 1,
+ endLineNumber: 2,
+ [type]: true,
+ };
+
+ const range = getDecorator(change).range;
+
+ expect(range.startLineNumber).toBe(1);
+ expect(range.endLineNumber).toBe(2);
+ expect(range.startColumn).toBe(1);
+ expect(range.endColumn).toBe(1);
+ });
+ });
+ });
+
+ describe('attachModel', () => {
+ it('adds change event callback', () => {
+ spyOn(model, 'onChange');
+
+ controller.attachModel(model);
+
+ expect(model.onChange).toHaveBeenCalled();
+ });
+
+ it('calls throttledComputeDiff on change', () => {
+ spyOn(controller, 'throttledComputeDiff');
+
+ controller.attachModel(model);
+
+ model.getModel().setValue('123');
+
+ expect(controller.throttledComputeDiff).toHaveBeenCalled();
+ });
+ });
+
+ describe('computeDiff', () => {
+ it('posts to worker', () => {
+ spyOn(controller.dirtyDiffWorker, 'postMessage');
+
+ controller.computeDiff(model);
+
+ expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
+ path: model.path,
+ originalContent: '',
+ newContent: '',
+ });
+ });
+ });
+
+ describe('reDecorate', () => {
+ it('calls decorations controller decorate', () => {
+ spyOn(controller.decorationsController, 'decorate');
+
+ controller.reDecorate(model);
+
+ expect(controller.decorationsController.decorate).toHaveBeenCalledWith(
+ model,
+ );
+ });
+ });
+
+ describe('decorate', () => {
+ it('adds decorations into decorations controller', () => {
+ spyOn(controller.decorationsController, 'addDecorations');
+
+ controller.decorate({ data: { changes: [], path: model.path } });
+
+ expect(
+ controller.decorationsController.addDecorations,
+ ).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything());
+ });
+
+ it('adds decorations into editor', () => {
+ const spy = spyOn(
+ controller.decorationsController.editor.instance,
+ 'deltaDecorations',
+ );
+
+ controller.decorate({
+ data: { changes: computeDiff('123', '1234'), path: model.path },
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ [],
+ [
+ {
+ range: new monaco.Range(1, 1, 1, 1),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
+ },
+ },
+ ],
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ spyOn(controller.disposable, 'dispose').and.callThrough();
+
+ controller.dispose();
+
+ expect(controller.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('terminates worker', () => {
+ spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough();
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
+ });
+
+ it('removes worker event listener', () => {
+ spyOn(
+ controller.dirtyDiffWorker,
+ 'removeEventListener',
+ ).and.callThrough();
+
+ controller.dispose();
+
+ expect(
+ controller.dirtyDiffWorker.removeEventListener,
+ ).toHaveBeenCalledWith('message', jasmine.anything());
+ });
+ });
+});
diff --git a/spec/javascripts/repo/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js
index 57f3ac3d365..57f3ac3d365 100644
--- a/spec/javascripts/repo/lib/diff/diff_spec.js
+++ b/spec/javascripts/ide/lib/diff/diff_spec.js
diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js
new file mode 100644
index 00000000000..d149a883166
--- /dev/null
+++ b/spec/javascripts/ide/lib/editor_options_spec.js
@@ -0,0 +1,11 @@
+import editorOptions from '~/ide/lib/editor_options';
+
+describe('Multi-file editor library editor options', () => {
+ it('returns an array', () => {
+ expect(editorOptions).toEqual(jasmine.any(Array));
+ });
+
+ it('contains readOnly option', () => {
+ expect(editorOptions[0].readOnly).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js
new file mode 100644
index 00000000000..75e6f0f54ec
--- /dev/null
+++ b/spec/javascripts/ide/lib/editor_spec.js
@@ -0,0 +1,271 @@
+/* global monaco */
+import monacoLoader from '~/ide/monaco_loader';
+import editor from '~/ide/lib/editor';
+import { file } from '../helpers';
+
+describe('Multi-file editor library', () => {
+ let instance;
+ let el;
+ let holder;
+
+ beforeEach(done => {
+ el = document.createElement('div');
+ holder = document.createElement('div');
+ el.appendChild(holder);
+
+ document.body.appendChild(el);
+
+ monacoLoader(['vs/editor/editor.main'], () => {
+ instance = editor.create(monaco);
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ instance.dispose();
+
+ el.remove();
+ });
+
+ it('creates instance of editor', () => {
+ expect(editor.editorInstance).not.toBeNull();
+ });
+
+ it('creates instance returns cached instance', () => {
+ expect(editor.create(monaco)).toEqual(instance);
+ });
+
+ describe('createInstance', () => {
+ it('creates editor instance', () => {
+ spyOn(instance.monaco.editor, 'create').and.callThrough();
+
+ instance.createInstance(holder);
+
+ expect(instance.monaco.editor.create).toHaveBeenCalled();
+ });
+
+ it('creates dirty diff controller', () => {
+ instance.createInstance(holder);
+
+ expect(instance.dirtyDiffController).not.toBeNull();
+ });
+
+ it('creates model manager', () => {
+ instance.createInstance(holder);
+
+ expect(instance.modelManager).not.toBeNull();
+ });
+ });
+
+ describe('createDiffInstance', () => {
+ it('creates editor instance', () => {
+ spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough();
+
+ instance.createDiffInstance(holder);
+
+ expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith(holder, {
+ model: null,
+ contextmenu: true,
+ minimap: {
+ enabled: false,
+ },
+ readOnly: true,
+ scrollBeyondLastLine: false,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ renderLineHighlight: 'none',
+ hideCursorInOverviewRuler: true,
+ wordWrap: 'on',
+ renderSideBySide: true,
+ });
+ });
+ });
+
+ describe('createModel', () => {
+ it('calls model manager addModel', () => {
+ spyOn(instance.modelManager, 'addModel');
+
+ instance.createModel('FILE');
+
+ expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE');
+ });
+ });
+
+ describe('attachModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createInstance(document.createElement('div'));
+
+ model = instance.createModel(file());
+ });
+
+ it('sets the current model on the instance', () => {
+ instance.attachModel(model);
+
+ expect(instance.currentModel).toBe(model);
+ });
+
+ it('attaches the model to the current instance', () => {
+ spyOn(instance.instance, 'setModel');
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
+ });
+
+ it('sets original & modified when diff editor', () => {
+ spyOn(instance.instance, 'getEditorType').and.returnValue('vs.editor.IDiffEditor');
+ spyOn(instance.instance, 'setModel');
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+ });
+
+ it('attaches the model to the dirty diff controller', () => {
+ spyOn(instance.dirtyDiffController, 'attachModel');
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model);
+ });
+
+ it('re-decorates with the dirty diff controller', () => {
+ spyOn(instance.dirtyDiffController, 'reDecorate');
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('attachMergeRequestModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createDiffInstance(document.createElement('div'));
+
+ const f = file();
+ f.mrChanges = { diff: 'ABC' };
+ f.baseRaw = 'testing';
+
+ model = instance.createModel(f);
+ });
+
+ it('sets original & modified', () => {
+ spyOn(instance.instance, 'setModel');
+
+ instance.attachMergeRequestModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ });
+ });
+
+ describe('clearEditor', () => {
+ it('resets the editor model', () => {
+ instance.createInstance(document.createElement('div'));
+
+ spyOn(instance.instance, 'setModel');
+
+ instance.clearEditor();
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposble dispose method', () => {
+ spyOn(instance.disposable, 'dispose').and.callThrough();
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('resets instance', () => {
+ instance.createInstance(document.createElement('div'));
+
+ expect(instance.instance).not.toBeNull();
+
+ instance.dispose();
+
+ expect(instance.instance).toBeNull();
+ });
+
+ it('does not dispose modelManager', () => {
+ spyOn(instance.modelManager, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.modelManager.dispose).not.toHaveBeenCalled();
+ });
+
+ it('does not dispose decorationsController', () => {
+ spyOn(instance.decorationsController, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.decorationsController.dispose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('updateDiffView', () => {
+ describe('edit mode', () => {
+ it('does not update options', () => {
+ instance.createInstance(holder);
+
+ spyOn(instance.instance, 'updateOptions');
+
+ instance.updateDiffView();
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('diff mode', () => {
+ beforeEach(() => {
+ instance.createDiffInstance(holder);
+
+ spyOn(instance.instance, 'updateOptions').and.callThrough();
+ });
+
+ it('sets renderSideBySide to false if el is less than 700 pixels', () => {
+ spyOnProperty(instance.instance.getDomNode(), 'offsetWidth').and.returnValue(600);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: false,
+ });
+ });
+
+ it('sets renderSideBySide to false if el is more than 700 pixels', () => {
+ spyOnProperty(instance.instance.getDomNode(), 'offsetWidth').and.returnValue(800);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: true,
+ });
+ });
+ });
+ });
+
+ describe('isDiffEditorType', () => {
+ it('returns true when diff editor', () => {
+ instance.createDiffInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(true);
+ });
+
+ it('returns false when not diff editor', () => {
+ instance.createInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(false);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js
new file mode 100644
index 00000000000..7ab315aa8c8
--- /dev/null
+++ b/spec/javascripts/ide/monaco_loader_spec.js
@@ -0,0 +1,15 @@
+import monacoContext from 'monaco-editor/dev/vs/loader';
+import monacoLoader from '~/ide/monaco_loader';
+
+describe('MonacoLoader', () => {
+ it('calls require.config and exports require', () => {
+ expect(monacoContext.require.getConfig()).toEqual(
+ jasmine.objectContaining({
+ paths: {
+ vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
+ },
+ }),
+ );
+ expect(monacoLoader).toBe(monacoContext.require);
+ });
+});
diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js
new file mode 100644
index 00000000000..479ed7ce49e
--- /dev/null
+++ b/spec/javascripts/ide/stores/actions/file_spec.js
@@ -0,0 +1,573 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import router from '~/ide/ide_router';
+import eventHub from '~/ide/eventhub';
+import { file, resetStore } from '../../helpers';
+
+describe('IDE store file actions', () => {
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('closeFile', () => {
+ let localFile;
+
+ beforeEach(() => {
+ localFile = file('testFile');
+ localFile.active = true;
+ localFile.opened = true;
+ localFile.parentTreeUrl = 'parentTreeUrl';
+
+ store.state.openFiles.push(localFile);
+ store.state.entries[localFile.path] = localFile;
+ });
+
+ it('closes open files', done => {
+ store
+ .dispatch('closeFile', localFile)
+ .then(() => {
+ expect(localFile.opened).toBeFalsy();
+ expect(localFile.active).toBeFalsy();
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('closes file even if file has changes', done => {
+ store.state.changedFiles.push(localFile);
+
+ store
+ .dispatch('closeFile', localFile)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+ expect(store.state.changedFiles.length).toBe(1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('closes file & opens next available file', done => {
+ const f = {
+ ...file('newOpenFile'),
+ url: '/newOpenFile',
+ };
+
+ store.state.openFiles.push(f);
+ store.state.entries[f.path] = f;
+
+ store
+ .dispatch('closeFile', localFile)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(router.push).toHaveBeenCalledWith(`/project${f.url}`);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('removes file if it pending', done => {
+ store.state.openFiles.push({
+ ...localFile,
+ pending: true,
+ });
+
+ store
+ .dispatch('closeFile', localFile)
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('setFileActive', () => {
+ let localFile;
+ let scrollToTabSpy;
+ let oldScrollToTab;
+
+ beforeEach(() => {
+ scrollToTabSpy = jasmine.createSpy('scrollToTab');
+ oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line
+ store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line
+
+ localFile = file('setThisActive');
+
+ store.state.entries[localFile.path] = localFile;
+ });
+
+ afterEach(() => {
+ store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line
+ });
+
+ it('calls scrollToTab', done => {
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(scrollToTabSpy).toHaveBeenCalled();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the file active', done => {
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(localFile.active).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns early if file is already active', done => {
+ localFile.active = true;
+
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(scrollToTabSpy).not.toHaveBeenCalled();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets current active file to not active', done => {
+ const f = file('newActive');
+ store.state.entries[f.path] = f;
+ localFile.active = true;
+ store.state.openFiles.push(localFile);
+
+ store
+ .dispatch('setFileActive', f.path)
+ .then(() => {
+ expect(localFile.active).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('resets location.hash for line highlighting', done => {
+ location.hash = 'test';
+
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(location.hash).not.toBe('test');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getFileData', () => {
+ let localFile;
+
+ beforeEach(() => {
+ spyOn(service, 'getFileData').and.returnValue(
+ Promise.resolve({
+ headers: {
+ 'page-title': 'testing getFileData',
+ },
+ json: () =>
+ Promise.resolve({
+ blame_path: 'blame_path',
+ commits_path: 'commits_path',
+ permalink: 'permalink',
+ raw_path: 'raw_path',
+ binary: false,
+ html: '123',
+ render_error: '',
+ }),
+ }),
+ );
+
+ localFile = file(`newCreate-${Math.random()}`);
+ localFile.url = 'getFileDataURL';
+ store.state.entries[localFile.path] = localFile;
+ });
+
+ it('calls the service', done => {
+ store
+ .dispatch('getFileData', { path: localFile.path })
+ .then(() => {
+ expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the file data', done => {
+ store
+ .dispatch('getFileData', { path: localFile.path })
+ .then(() => {
+ expect(localFile.blamePath).toBe('blame_path');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets document title', done => {
+ store
+ .dispatch('getFileData', { path: localFile.path })
+ .then(() => {
+ expect(document.title).toBe('testing getFileData');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the file as active', done => {
+ store
+ .dispatch('getFileData', { path: localFile.path })
+ .then(() => {
+ expect(localFile.active).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the file not as active if we pass makeFileActive false', done => {
+ store
+ .dispatch('getFileData', { path: localFile.path, makeFileActive: false })
+ .then(() => {
+ expect(localFile.active).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds the file to open files', done => {
+ store
+ .dispatch('getFileData', { path: localFile.path })
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(localFile.name);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getRawFileData', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw'));
+
+ tmpFile = file('tmpFile');
+ store.state.entries[tmpFile.path] = tmpFile;
+ });
+
+ it('calls getRawFileData service method', done => {
+ store
+ .dispatch('getRawFileData', { path: tmpFile.path })
+ .then(() => {
+ expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('updates file raw data', done => {
+ store
+ .dispatch('getRawFileData', { path: tmpFile.path })
+ .then(() => {
+ expect(tmpFile.raw).toBe('raw');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('calls also getBaseRawFileData service method', done => {
+ spyOn(service, 'getBaseRawFileData').and.returnValue(Promise.resolve('baseraw'));
+
+ tmpFile.mrChange = { new_file: false };
+
+ store
+ .dispatch('getRawFileData', { path: tmpFile.path, baseSha: 'SHA' })
+ .then(() => {
+ expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA');
+ expect(tmpFile.baseRaw).toBe('baseraw');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('changeFileContent', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ tmpFile = file('tmpFile');
+ store.state.entries[tmpFile.path] = tmpFile;
+ });
+
+ it('updates file content', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() => {
+ expect(tmpFile.content).toBe('content');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds file into changedFiles array', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds file once into changedFiles array', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() =>
+ store.dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content 123',
+ }),
+ )
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('removes file from changedFiles array if not changed', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() =>
+ store.dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: '',
+ }),
+ )
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('discardFileChanges', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ spyOn(eventHub, '$on');
+
+ tmpFile = file();
+ tmpFile.content = 'testing';
+
+ store.state.changedFiles.push(tmpFile);
+ store.state.entries[tmpFile.path] = tmpFile;
+ });
+
+ it('resets file content', done => {
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(tmpFile.content).not.toBe('testing');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('removes file from changedFiles array', done => {
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('closes temp file', done => {
+ tmpFile.tempFile = true;
+ tmpFile.opened = true;
+
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(tmpFile.opened).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('does not re-open a closed temp file', done => {
+ tmpFile.tempFile = true;
+
+ expect(tmpFile.opened).toBeFalsy();
+
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(tmpFile.opened).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('openPendingTab', () => {
+ let f;
+
+ beforeEach(() => {
+ f = {
+ ...file(),
+ projectId: '123',
+ };
+
+ store.state.entries[f.path] = f;
+ });
+
+ it('makes file pending in openFiles', done => {
+ store
+ .dispatch('openPendingTab', f)
+ .then(() => {
+ expect(store.state.openFiles[0].pending).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns true when opened', done => {
+ store
+ .dispatch('openPendingTab', f)
+ .then(added => {
+ expect(added).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('pushes router URL when added', done => {
+ store.state.currentBranchId = 'master';
+
+ store
+ .dispatch('openPendingTab', f)
+ .then(() => {
+ expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('calls scrollToTab', done => {
+ const scrollToTabSpy = jasmine.createSpy('scrollToTab');
+ const oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line
+ store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line
+
+ store
+ .dispatch('openPendingTab', f)
+ .then(() => {
+ expect(scrollToTabSpy).toHaveBeenCalled();
+ store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns false when passed in file is active & viewer is diff', done => {
+ f.active = true;
+ store.state.openFiles.push(f);
+ store.state.viewer = 'diff';
+
+ store
+ .dispatch('openPendingTab', f)
+ .then(added => {
+ expect(added).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('removePendingTab', () => {
+ let f;
+
+ beforeEach(() => {
+ spyOn(eventHub, '$emit');
+
+ f = {
+ ...file('pendingFile'),
+ pending: true,
+ };
+ });
+
+ it('removes pending file from open files', done => {
+ store.state.openFiles.push(f);
+
+ store
+ .dispatch('removePendingTab', f)
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('emits event to dispose model', done => {
+ store
+ .dispatch('removePendingTab', f)
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.${f.key}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/actions/merge_request_spec.js b/spec/javascripts/ide/stores/actions/merge_request_spec.js
new file mode 100644
index 00000000000..b4ec4a0b173
--- /dev/null
+++ b/spec/javascripts/ide/stores/actions/merge_request_spec.js
@@ -0,0 +1,110 @@
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import { resetStore } from '../../helpers';
+
+describe('IDE store merge request actions', () => {
+ beforeEach(() => {
+ store.state.projects.abcproject = {
+ mergeRequests: {},
+ };
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('getMergeRequestData', () => {
+ beforeEach(() => {
+ spyOn(service, 'getProjectMergeRequestData').and.returnValue(
+ Promise.resolve({ data: { title: 'mergerequest' } }),
+ );
+ });
+
+ it('calls getProjectMergeRequestData service method', done => {
+ store
+ .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 })
+ .then(() => {
+ expect(service.getProjectMergeRequestData).toHaveBeenCalledWith('abcproject', 1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the Merge Request Object', done => {
+ store
+ .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 })
+ .then(() => {
+ expect(store.state.projects.abcproject.mergeRequests['1'].title).toBe('mergerequest');
+ expect(store.state.currentMergeRequestId).toBe(1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getMergeRequestChanges', () => {
+ beforeEach(() => {
+ spyOn(service, 'getProjectMergeRequestChanges').and.returnValue(
+ Promise.resolve({ data: { title: 'mergerequest' } }),
+ );
+
+ store.state.projects.abcproject.mergeRequests['1'] = { changes: [] };
+ });
+
+ it('calls getProjectMergeRequestChanges service method', done => {
+ store
+ .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 })
+ .then(() => {
+ expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith('abcproject', 1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the Merge Request Changes Object', done => {
+ store
+ .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 })
+ .then(() => {
+ expect(store.state.projects.abcproject.mergeRequests['1'].changes.title).toBe(
+ 'mergerequest',
+ );
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getMergeRequestVersions', () => {
+ beforeEach(() => {
+ spyOn(service, 'getProjectMergeRequestVersions').and.returnValue(
+ Promise.resolve({ data: [{ id: 789 }] }),
+ );
+
+ store.state.projects.abcproject.mergeRequests['1'] = { versions: [] };
+ });
+
+ it('calls getProjectMergeRequestVersions service method', done => {
+ store
+ .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 })
+ .then(() => {
+ expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith('abcproject', 1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the Merge Request Versions Object', done => {
+ store
+ .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 })
+ .then(() => {
+ expect(store.state.projects.abcproject.mergeRequests['1'].versions.length).toBe(1);
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js
new file mode 100644
index 00000000000..e0ef57a3966
--- /dev/null
+++ b/spec/javascripts/ide/stores/actions/tree_spec.js
@@ -0,0 +1,166 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import router from '~/ide/ide_router';
+import { file, resetStore } from '../../helpers';
+
+describe('Multi-file store tree actions', () => {
+ let projectTree;
+
+ const basicCallParameters = {
+ endpoint: 'rootEndpoint',
+ projectId: 'abcproject',
+ branch: 'master',
+ branchId: 'master',
+ };
+
+ beforeEach(() => {
+ spyOn(router, 'push');
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ web_url: '',
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('getFiles', () => {
+ beforeEach(() => {
+ spyOn(service, 'getFiles').and.returnValue(
+ Promise.resolve({
+ json: () =>
+ Promise.resolve([
+ 'file.txt',
+ 'folder/fileinfolder.js',
+ 'folder/subfolder/fileinsubfolder.js',
+ ]),
+ }),
+ );
+ });
+
+ it('calls service getFiles', done => {
+ store
+ .dispatch('getFiles', basicCallParameters)
+ .then(() => {
+ expect(service.getFiles).toHaveBeenCalledWith('', 'master');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds data into tree', done => {
+ store
+ .dispatch('getFiles', basicCallParameters)
+ .then(() => {
+ projectTree = store.state.trees['abcproject/master'];
+ expect(projectTree.tree.length).toBe(2);
+ expect(projectTree.tree[0].type).toBe('tree');
+ expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js');
+ expect(projectTree.tree[1].type).toBe('blob');
+ expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob');
+ expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('toggleTreeOpen', () => {
+ let tree;
+
+ beforeEach(() => {
+ tree = file('testing', '1', 'tree');
+ store.state.entries[tree.path] = tree;
+ });
+
+ it('toggles the tree open', done => {
+ store
+ .dispatch('toggleTreeOpen', tree.path)
+ .then(() => {
+ expect(tree.opened).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getLastCommitData', () => {
+ beforeEach(() => {
+ spyOn(service, 'getTreeLastCommit').and.returnValue(
+ Promise.resolve({
+ headers: {
+ 'more-logs-url': null,
+ },
+ json: () =>
+ Promise.resolve([
+ {
+ type: 'tree',
+ file_name: 'testing',
+ commit: {
+ message: 'commit message',
+ authored_date: '123',
+ },
+ },
+ ]),
+ }),
+ );
+
+ store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+
+ projectTree = store.state.trees['abcproject/mybranch'];
+ projectTree.tree.push(file('testing', '1', 'tree'));
+ projectTree.lastCommitPath = 'lastcommitpath';
+ });
+
+ it('calls service with lastCommitPath', done => {
+ store
+ .dispatch('getLastCommitData', projectTree)
+ .then(() => {
+ expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('updates trees last commit data', done => {
+ store
+ .dispatch('getLastCommitData', projectTree)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(projectTree.tree[0].lastCommit.message).toBe('commit message');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('does not update entry if not found', done => {
+ projectTree.tree[0].name = 'a';
+
+ store
+ .dispatch('getLastCommitData', projectTree)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js
new file mode 100644
index 00000000000..cec572f4507
--- /dev/null
+++ b/spec/javascripts/ide/stores/actions_spec.js
@@ -0,0 +1,306 @@
+import * as urlUtils from '~/lib/utils/url_utility';
+import store from '~/ide/stores';
+import router from '~/ide/ide_router';
+import { resetStore, file } from '../helpers';
+
+describe('Multi-file store actions', () => {
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('redirectToUrl', () => {
+ it('calls visitUrl', done => {
+ spyOn(urlUtils, 'visitUrl');
+
+ store
+ .dispatch('redirectToUrl', 'test')
+ .then(() => {
+ expect(urlUtils.visitUrl).toHaveBeenCalledWith('test');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('setInitialData', () => {
+ it('commits initial data', done => {
+ store
+ .dispatch('setInitialData', { canCommit: true })
+ .then(() => {
+ expect(store.state.canCommit).toBeTruthy();
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('discardAllChanges', () => {
+ beforeEach(() => {
+ const f = file('discardAll');
+ f.changed = true;
+
+ store.state.openFiles.push(f);
+ store.state.changedFiles.push(f);
+ store.state.entries[f.path] = f;
+ });
+
+ it('discards changes in file', done => {
+ store
+ .dispatch('discardAllChanges')
+ .then(() => {
+ expect(store.state.openFiles.changed).toBeFalsy();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('removes all files from changedFiles state', done => {
+ store
+ .dispatch('discardAllChanges')
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+ expect(store.state.openFiles.length).toBe(1);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('closeAllFiles', () => {
+ beforeEach(() => {
+ const f = file('closeAll');
+ store.state.openFiles.push(f);
+ store.state.openFiles[0].opened = true;
+ store.state.entries[f.path] = f;
+ });
+
+ it('closes all open files', done => {
+ store
+ .dispatch('closeAllFiles')
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('createTempEntry', () => {
+ beforeEach(() => {
+ document.body.innerHTML += '<div class="flash-container"></div>';
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'mybranch';
+
+ store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+ store.state.projects.abcproject = {
+ web_url: '',
+ };
+ });
+
+ afterEach(() => {
+ document.querySelector('.flash-container').remove();
+ });
+
+ describe('tree', () => {
+ it('creates temp tree', done => {
+ store
+ .dispatch('createTempEntry', {
+ branchId: store.state.currentBranchId,
+ name: 'test',
+ type: 'tree',
+ })
+ .then(() => {
+ const entry = store.state.entries.test;
+
+ expect(entry).not.toBeNull();
+ expect(entry.type).toBe('tree');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('creates new folder inside another tree', done => {
+ const tree = {
+ type: 'tree',
+ name: 'testing',
+ path: 'testing',
+ tree: [],
+ };
+
+ store.state.entries[tree.path] = tree;
+
+ store
+ .dispatch('createTempEntry', {
+ branchId: store.state.currentBranchId,
+ name: 'testing/test',
+ type: 'tree',
+ })
+ .then(() => {
+ expect(tree.tree[0].tempFile).toBeTruthy();
+ expect(tree.tree[0].name).toBe('test');
+ expect(tree.tree[0].type).toBe('tree');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('does not create new tree if already exists', done => {
+ const tree = {
+ type: 'tree',
+ path: 'testing',
+ tempFile: false,
+ tree: [],
+ };
+
+ store.state.entries[tree.path] = tree;
+
+ store
+ .dispatch('createTempEntry', {
+ branchId: store.state.currentBranchId,
+ name: 'testing',
+ type: 'tree',
+ })
+ .then(() => {
+ expect(store.state.entries[tree.path].tempFile).toEqual(false);
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('blob', () => {
+ it('creates temp file', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(f.tempFile).toBeTruthy();
+ expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(
+ 1,
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds tmp file to open files', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(f.name);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds tmp file to changed files', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(store.state.changedFiles.length).toBe(1);
+ expect(store.state.changedFiles[0].name).toBe(f.name);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets tmp file as active', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(f.active).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('creates flash message if file already exists', done => {
+ const f = file('test', '1', 'blob');
+ store.state.trees['abcproject/mybranch'].tree = [f];
+ store.state.entries[f.path] = f;
+
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(() => {
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('popHistoryState', () => {});
+
+ describe('scrollToTab', () => {
+ it('focuses the current active element', done => {
+ document.body.innerHTML +=
+ '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>';
+ const el = document.querySelector('.repo-tab');
+ spyOn(el, 'focus');
+
+ store
+ .dispatch('scrollToTab')
+ .then(() => {
+ setTimeout(() => {
+ expect(el.focus).toHaveBeenCalled();
+
+ document.getElementById('tabs').remove();
+
+ done();
+ });
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateViewer', () => {
+ it('updates viewer state', done => {
+ store
+ .dispatch('updateViewer', 'diff')
+ .then(() => {
+ expect(store.state.viewer).toBe('diff');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js
new file mode 100644
index 00000000000..33733b97dff
--- /dev/null
+++ b/spec/javascripts/ide/stores/getters_spec.js
@@ -0,0 +1,75 @@
+import * as getters from '~/ide/stores/getters';
+import state from '~/ide/stores/state';
+import { file } from '../helpers';
+
+describe('IDE store getters', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ });
+
+ describe('activeFile', () => {
+ it('returns the current active file', () => {
+ localState.openFiles.push(file());
+ localState.openFiles.push(file('active'));
+ localState.openFiles[1].active = true;
+
+ expect(getters.activeFile(localState).name).toBe('active');
+ });
+
+ it('returns undefined if no active files are found', () => {
+ localState.openFiles.push(file());
+ localState.openFiles.push(file('active'));
+
+ expect(getters.activeFile(localState)).toBeNull();
+ });
+ });
+
+ describe('modifiedFiles', () => {
+ it('returns a list of modified files', () => {
+ localState.openFiles.push(file());
+ localState.changedFiles.push(file('changed'));
+ localState.changedFiles[0].changed = true;
+
+ const modifiedFiles = getters.modifiedFiles(localState);
+
+ expect(modifiedFiles.length).toBe(1);
+ expect(modifiedFiles[0].name).toBe('changed');
+ });
+ });
+
+ describe('addedFiles', () => {
+ it('returns a list of added files', () => {
+ localState.openFiles.push(file());
+ localState.changedFiles.push(file('added'));
+ localState.changedFiles[0].changed = true;
+ localState.changedFiles[0].tempFile = true;
+
+ const modifiedFiles = getters.addedFiles(localState);
+
+ expect(modifiedFiles.length).toBe(1);
+ expect(modifiedFiles[0].name).toBe('added');
+ });
+ });
+
+ describe('currentMergeRequest', () => {
+ it('returns Current Merge Request', () => {
+ localState.currentProjectId = 'abcproject';
+ localState.currentMergeRequestId = 1;
+ localState.projects.abcproject = {
+ mergeRequests: {
+ 1: { mergeId: 1 },
+ },
+ };
+
+ expect(getters.currentMergeRequest(localState).mergeId).toBe(1);
+ });
+
+ it('returns null if no active Merge Request was found', () => {
+ localState.currentProjectId = 'otherproject';
+
+ expect(getters.currentMergeRequest(localState)).toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
new file mode 100644
index 00000000000..90ded940227
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
@@ -0,0 +1,505 @@
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import router from '~/ide/ide_router';
+import * as urlUtils from '~/lib/utils/url_utility';
+import eventHub from '~/ide/eventhub';
+import * as consts from '~/ide/stores/modules/commit/constants';
+import { resetStore, file } from 'spec/ide/helpers';
+
+describe('IDE commit module actions', () => {
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('updateCommitMessage', () => {
+ it('updates store with new commit message', done => {
+ store
+ .dispatch('commit/updateCommitMessage', 'testing')
+ .then(() => {
+ expect(store.state.commit.commitMessage).toBe('testing');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('discardDraft', () => {
+ it('resets commit message to blank', done => {
+ store.state.commit.commitMessage = 'testing';
+
+ store
+ .dispatch('commit/discardDraft')
+ .then(() => {
+ expect(store.state.commit.commitMessage).not.toBe('testing');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateCommitAction', () => {
+ it('updates store with new commit action', done => {
+ store
+ .dispatch('commit/updateCommitAction', '1')
+ .then(() => {
+ expect(store.state.commit.commitAction).toBe('1');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateBranchName', () => {
+ it('updates store with new branch name', done => {
+ store
+ .dispatch('commit/updateBranchName', 'branch-name')
+ .then(() => {
+ expect(store.state.commit.newBranchName).toBe('branch-name');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('setLastCommitMessage', () => {
+ beforeEach(() => {
+ Object.assign(store.state, {
+ currentProjectId: 'abcproject',
+ projects: {
+ abcproject: {
+ web_url: 'http://testing',
+ },
+ },
+ });
+ });
+
+ it('updates commit message with short_id', done => {
+ store
+ .dispatch('commit/setLastCommitMessage', { short_id: '123' })
+ .then(() => {
+ expect(store.state.lastCommitMsg).toContain(
+ 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updates commit message with stats', done => {
+ store
+ .dispatch('commit/setLastCommitMessage', {
+ short_id: '123',
+ stats: {
+ additions: '1',
+ deletions: '2',
+ },
+ })
+ .then(() => {
+ expect(store.state.lastCommitMsg).toBe(
+ 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('checkCommitStatus', () => {
+ beforeEach(() => {
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+ });
+
+ it('calls service', done => {
+ spyOn(service, 'getBranchData').and.returnValue(
+ Promise.resolve({
+ data: {
+ commit: { id: '123' },
+ },
+ }),
+ );
+
+ store
+ .dispatch('commit/checkCommitStatus')
+ .then(() => {
+ expect(service.getBranchData).toHaveBeenCalledWith(
+ 'abcproject',
+ 'master',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns true if current ref does not equal returned ID', done => {
+ spyOn(service, 'getBranchData').and.returnValue(
+ Promise.resolve({
+ data: {
+ commit: { id: '123' },
+ },
+ }),
+ );
+
+ store
+ .dispatch('commit/checkCommitStatus')
+ .then(val => {
+ expect(val).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns false if current ref equals returned ID', done => {
+ spyOn(service, 'getBranchData').and.returnValue(
+ Promise.resolve({
+ data: {
+ commit: { id: '1' },
+ },
+ }),
+ );
+
+ store
+ .dispatch('commit/checkCommitStatus')
+ .then(val => {
+ expect(val).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateFilesAfterCommit', () => {
+ const data = {
+ id: '123',
+ message: 'testing commit message',
+ committed_date: '123',
+ committer_name: 'root',
+ };
+ const branch = 'master';
+ let f;
+
+ beforeEach(() => {
+ spyOn(eventHub, '$emit');
+
+ f = file('changedFile');
+ Object.assign(f, {
+ active: true,
+ changed: true,
+ content: 'file content',
+ });
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ web_url: 'web_url',
+ branches: {
+ master: {
+ workingReference: '',
+ },
+ },
+ };
+ store.state.changedFiles.push(f, {
+ ...file('changedFile2'),
+ changed: true,
+ });
+ store.state.openFiles = store.state.changedFiles;
+
+ store.state.changedFiles.forEach(changedFile => {
+ store.state.entries[changedFile.path] = changedFile;
+ });
+ });
+
+ it('updates stores working reference', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(
+ store.state.projects.abcproject.branches.master.workingReference,
+ ).toBe(data.id);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('resets all files changed status', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ store.state.openFiles.forEach(entry => {
+ expect(entry.changed).toBeFalsy();
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('removes all changed files', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('sets files commit data', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(f.lastCommit.message).toBe(data.message);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updates raw content for changed file', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(f.raw).toBe(f.content);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('emits changed event for file', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ `editor.update.model.content.${f.path}`,
+ f.content,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('pushes route to new branch if commitAction is new branch', done => {
+ store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
+
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(router.push).toHaveBeenCalledWith(
+ `/project/abcproject/blob/master/${f.path}`,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('resets stores commit actions', done => {
+ store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
+
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(store.state.commit.commitAction).not.toBe(
+ consts.COMMIT_TO_NEW_BRANCH,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('commitChanges', () => {
+ beforeEach(() => {
+ spyOn(urlUtils, 'visitUrl');
+
+ document.body.innerHTML += '<div class="flash-container"></div>';
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ web_url: 'webUrl',
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+ store.state.changedFiles.push(file('changed'));
+ store.state.changedFiles[0].active = true;
+ store.state.openFiles = store.state.changedFiles;
+
+ store.state.openFiles.forEach(f => {
+ store.state.entries[f.path] = f;
+ });
+
+ store.state.commit.commitAction = '2';
+ store.state.commit.commitMessage = 'testing 123';
+ });
+
+ afterEach(() => {
+ document.querySelector('.flash-container').remove();
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ spyOn(service, 'commit').and.returnValue(
+ Promise.resolve({
+ data: {
+ id: '123456',
+ short_id: '123',
+ message: 'test message',
+ committed_date: 'date',
+ stats: {
+ additions: '1',
+ deletions: '2',
+ },
+ },
+ }),
+ );
+ });
+
+ it('calls service', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(service.commit).toHaveBeenCalledWith('abcproject', {
+ branch: jasmine.anything(),
+ commit_message: 'testing 123',
+ actions: [
+ {
+ action: 'update',
+ file_path: jasmine.anything(),
+ content: jasmine.anything(),
+ encoding: jasmine.anything(),
+ },
+ ],
+ start_branch: 'master',
+ });
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('pushes router to new route', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(router.push).toHaveBeenCalledWith(
+ `/project/${store.state.currentProjectId}/blob/${
+ store.getters['commit/newBranchName']
+ }/changed`,
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets last Commit Msg', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(store.state.lastCommitMsg).toBe(
+ 'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds commit data to changed files', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(store.state.openFiles[0].lastCommit.message).toBe(
+ 'test message',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('redirects to new merge request page', done => {
+ spyOn(eventHub, '$on');
+
+ store.state.commit.commitAction = '3';
+
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(urlUtils.visitUrl).toHaveBeenCalledWith(
+ `webUrl/merge_requests/new?merge_request[source_branch]=${
+ store.getters['commit/newBranchName']
+ }&merge_request[target_branch]=master`,
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('failed', () => {
+ beforeEach(() => {
+ spyOn(service, 'commit').and.returnValue(
+ Promise.resolve({
+ data: {
+ message: 'failed message',
+ },
+ }),
+ );
+ });
+
+ it('shows failed message', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ const alert = document.querySelector('.flash-container');
+
+ expect(alert.textContent.trim()).toBe('failed message');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js
new file mode 100644
index 00000000000..e396284ec2c
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js
@@ -0,0 +1,128 @@
+import commitState from '~/ide/stores/modules/commit/state';
+import * as consts from '~/ide/stores/modules/commit/constants';
+import * as getters from '~/ide/stores/modules/commit/getters';
+
+describe('IDE commit module getters', () => {
+ let state;
+
+ beforeEach(() => {
+ state = commitState();
+ });
+
+ describe('discardDraftButtonDisabled', () => {
+ it('returns true when commitMessage is empty', () => {
+ expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
+ });
+
+ it('returns false when commitMessage is not empty & loading is false', () => {
+ state.commitMessage = 'test';
+ state.submitCommitLoading = false;
+
+ expect(getters.discardDraftButtonDisabled(state)).toBeFalsy();
+ });
+
+ it('returns true when commitMessage is not empty & loading is true', () => {
+ state.commitMessage = 'test';
+ state.submitCommitLoading = true;
+
+ expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
+ });
+ });
+
+ describe('commitButtonDisabled', () => {
+ const localGetters = {
+ discardDraftButtonDisabled: false,
+ };
+ const rootState = {
+ changedFiles: ['a'],
+ };
+
+ it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeFalsy();
+ });
+
+ it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => {
+ rootState.changedFiles.length = 0;
+
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeTruthy();
+ });
+
+ it('returns true when discardDraftButtonDisabled is true', () => {
+ localGetters.discardDraftButtonDisabled = true;
+
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeTruthy();
+ });
+
+ it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
+ localGetters.discardDraftButtonDisabled = false;
+ rootState.changedFiles.length = 0;
+
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeTruthy();
+ });
+ });
+
+ describe('newBranchName', () => {
+ it('includes username, currentBranchId, patch & random number', () => {
+ gon.current_username = 'username';
+
+ const branch = getters.newBranchName(state, null, {
+ currentBranchId: 'testing',
+ });
+
+ expect(branch).toMatch(/username-testing-patch-\d{5}$/);
+ });
+ });
+
+ describe('branchName', () => {
+ const rootState = {
+ currentBranchId: 'master',
+ };
+ const localGetters = {
+ newBranchName: 'newBranchName',
+ };
+
+ beforeEach(() => {
+ Object.assign(state, {
+ newBranchName: 'state-newBranchName',
+ });
+ });
+
+ it('defualts to currentBranchId', () => {
+ expect(getters.branchName(state, null, rootState)).toBe('master');
+ });
+
+ ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach(type => {
+ describe(type, () => {
+ beforeEach(() => {
+ Object.assign(state, {
+ commitAction: consts[type],
+ });
+ });
+
+ it('uses newBranchName when not empty', () => {
+ expect(getters.branchName(state, localGetters, rootState)).toBe(
+ 'state-newBranchName',
+ );
+ });
+
+ it('uses getters newBranchName when state newBranchName is empty', () => {
+ Object.assign(state, {
+ newBranchName: '',
+ });
+
+ expect(getters.branchName(state, localGetters, rootState)).toBe(
+ 'newBranchName',
+ );
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js
new file mode 100644
index 00000000000..5de7a281d34
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js
@@ -0,0 +1,42 @@
+import commitState from '~/ide/stores/modules/commit/state';
+import mutations from '~/ide/stores/modules/commit/mutations';
+
+describe('IDE commit module mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = commitState();
+ });
+
+ describe('UPDATE_COMMIT_MESSAGE', () => {
+ it('updates commitMessage', () => {
+ mutations.UPDATE_COMMIT_MESSAGE(state, 'testing');
+
+ expect(state.commitMessage).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_COMMIT_ACTION', () => {
+ it('updates commitAction', () => {
+ mutations.UPDATE_COMMIT_ACTION(state, 'testing');
+
+ expect(state.commitAction).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_NEW_BRANCH_NAME', () => {
+ it('updates newBranchName', () => {
+ mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing');
+
+ expect(state.newBranchName).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_LOADING', () => {
+ it('updates submitCommitLoading', () => {
+ mutations.UPDATE_LOADING(state, true);
+
+ expect(state.submitCommitLoading).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js
index a7167537ef2..a7167537ef2 100644
--- a/spec/javascripts/repo/stores/mutations/branch_spec.js
+++ b/spec/javascripts/ide/stores/mutations/branch_spec.js
diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js
new file mode 100644
index 00000000000..bf9d5166d0a
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations/file_spec.js
@@ -0,0 +1,272 @@
+import mutations from '~/ide/stores/mutations/file';
+import state from '~/ide/stores/state';
+import { file } from '../../helpers';
+
+describe('IDE store file mutations', () => {
+ let localState;
+ let localFile;
+
+ beforeEach(() => {
+ localState = state();
+ localFile = file();
+
+ localState.entries[localFile.path] = localFile;
+ });
+
+ describe('SET_FILE_ACTIVE', () => {
+ it('sets the file active', () => {
+ mutations.SET_FILE_ACTIVE(localState, {
+ path: localFile.path,
+ active: true,
+ });
+
+ expect(localFile.active).toBeTruthy();
+ });
+
+ it('sets pending tab as not active', () => {
+ localState.openFiles.push({
+ ...localFile,
+ pending: true,
+ active: true,
+ });
+
+ mutations.SET_FILE_ACTIVE(localState, {
+ path: localFile.path,
+ active: true,
+ });
+
+ expect(localState.openFiles[0].active).toBe(false);
+ });
+ });
+
+ describe('TOGGLE_FILE_OPEN', () => {
+ beforeEach(() => {
+ mutations.TOGGLE_FILE_OPEN(localState, localFile.path);
+ });
+
+ it('adds into opened files', () => {
+ expect(localFile.opened).toBeTruthy();
+ expect(localState.openFiles.length).toBe(1);
+ });
+
+ it('removes from opened files', () => {
+ mutations.TOGGLE_FILE_OPEN(localState, localFile.path);
+
+ expect(localFile.opened).toBeFalsy();
+ expect(localState.openFiles.length).toBe(0);
+ });
+ });
+
+ describe('SET_FILE_DATA', () => {
+ it('sets extra file data', () => {
+ mutations.SET_FILE_DATA(localState, {
+ data: {
+ blame_path: 'blame',
+ commits_path: 'commits',
+ permalink: 'permalink',
+ raw_path: 'raw',
+ binary: true,
+ render_error: 'render_error',
+ },
+ file: localFile,
+ });
+
+ expect(localFile.blamePath).toBe('blame');
+ expect(localFile.commitsPath).toBe('commits');
+ expect(localFile.permalink).toBe('permalink');
+ expect(localFile.rawPath).toBe('raw');
+ expect(localFile.binary).toBeTruthy();
+ expect(localFile.renderError).toBe('render_error');
+ expect(localFile.raw).toBeNull();
+ expect(localFile.baseRaw).toBeNull();
+ });
+ });
+
+ describe('SET_FILE_RAW_DATA', () => {
+ it('sets raw data', () => {
+ mutations.SET_FILE_RAW_DATA(localState, {
+ file: localFile,
+ raw: 'testing',
+ });
+
+ expect(localFile.raw).toBe('testing');
+ });
+ });
+
+ describe('SET_FILE_BASE_RAW_DATA', () => {
+ it('sets raw data from base branch', () => {
+ mutations.SET_FILE_BASE_RAW_DATA(localState, {
+ file: localFile,
+ baseRaw: 'testing',
+ });
+
+ expect(localFile.baseRaw).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_FILE_CONTENT', () => {
+ beforeEach(() => {
+ localFile.raw = 'test';
+ });
+
+ it('sets content', () => {
+ mutations.UPDATE_FILE_CONTENT(localState, {
+ path: localFile.path,
+ content: 'test',
+ });
+
+ expect(localFile.content).toBe('test');
+ });
+
+ it('sets changed if content does not match raw', () => {
+ mutations.UPDATE_FILE_CONTENT(localState, {
+ path: localFile.path,
+ content: 'testing',
+ });
+
+ expect(localFile.content).toBe('testing');
+ expect(localFile.changed).toBeTruthy();
+ });
+
+ it('sets changed if file is a temp file', () => {
+ localFile.tempFile = true;
+
+ mutations.UPDATE_FILE_CONTENT(localState, {
+ path: localFile.path,
+ content: '',
+ });
+
+ expect(localFile.changed).toBeTruthy();
+ });
+ });
+
+ describe('SET_FILE_MERGE_REQUEST_CHANGE', () => {
+ it('sets file mr change', () => {
+ mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
+ file: localFile,
+ mrChange: { diff: 'ABC' },
+ });
+
+ expect(localFile.mrChange.diff).toBe('ABC');
+ });
+ });
+
+ describe('DISCARD_FILE_CHANGES', () => {
+ beforeEach(() => {
+ localFile.content = 'test';
+ localFile.changed = true;
+ });
+
+ it('resets content and changed', () => {
+ mutations.DISCARD_FILE_CHANGES(localState, localFile.path);
+
+ expect(localFile.content).toBe('');
+ expect(localFile.changed).toBeFalsy();
+ });
+ });
+
+ describe('ADD_FILE_TO_CHANGED', () => {
+ it('adds file into changed files array', () => {
+ mutations.ADD_FILE_TO_CHANGED(localState, localFile.path);
+
+ expect(localState.changedFiles.length).toBe(1);
+ });
+ });
+
+ describe('REMOVE_FILE_FROM_CHANGED', () => {
+ it('removes files from changed files array', () => {
+ localState.changedFiles.push(localFile);
+
+ mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path);
+
+ expect(localState.changedFiles.length).toBe(0);
+ });
+ });
+
+ describe('TOGGLE_FILE_CHANGED', () => {
+ it('updates file changed status', () => {
+ mutations.TOGGLE_FILE_CHANGED(localState, {
+ file: localFile,
+ changed: true,
+ });
+
+ expect(localFile.changed).toBeTruthy();
+ });
+ });
+
+ describe('SET_FILE_VIEWMODE', () => {
+ it('updates file view mode', () => {
+ mutations.SET_FILE_VIEWMODE(localState, {
+ file: localFile,
+ viewMode: 'preview',
+ });
+
+ expect(localFile.viewMode).toBe('preview');
+ });
+ });
+
+ describe('ADD_PENDING_TAB', () => {
+ beforeEach(() => {
+ const f = {
+ ...file('openFile'),
+ path: 'openFile',
+ active: true,
+ opened: true,
+ };
+
+ localState.entries[f.path] = f;
+ localState.openFiles.push(f);
+ });
+
+ it('adds file into openFiles as pending', () => {
+ mutations.ADD_PENDING_TAB(localState, { file: localFile });
+
+ expect(localState.openFiles.length).toBe(2);
+ expect(localState.openFiles[1].pending).toBe(true);
+ expect(localState.openFiles[1].key).toBe(`pending-${localFile.key}`);
+ });
+
+ it('updates open file to pending', () => {
+ mutations.ADD_PENDING_TAB(localState, { file: localState.openFiles[0] });
+
+ expect(localState.openFiles.length).toBe(1);
+ });
+
+ it('updates pending open file to active', () => {
+ localState.openFiles.push({
+ ...localFile,
+ pending: true,
+ });
+
+ mutations.ADD_PENDING_TAB(localState, { file: localFile });
+
+ expect(localState.openFiles[1].pending).toBe(true);
+ expect(localState.openFiles[1].active).toBe(true);
+ });
+
+ it('sets all openFiles to not active', () => {
+ mutations.ADD_PENDING_TAB(localState, { file: localFile });
+
+ expect(localState.openFiles.length).toBe(2);
+
+ localState.openFiles.forEach(f => {
+ if (f.pending) {
+ expect(f.active).toBe(true);
+ } else {
+ expect(f.active).toBe(false);
+ }
+ });
+ });
+ });
+
+ describe('REMOVE_PENDING_TAB', () => {
+ it('removes pending tab from openFiles', () => {
+ localFile.key = 'testing';
+ localState.openFiles.push(localFile);
+
+ mutations.REMOVE_PENDING_TAB(localState, localFile);
+
+ expect(localState.openFiles.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/mutations/merge_request_spec.js b/spec/javascripts/ide/stores/mutations/merge_request_spec.js
new file mode 100644
index 00000000000..f724bf464f5
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations/merge_request_spec.js
@@ -0,0 +1,65 @@
+import mutations from '~/ide/stores/mutations/merge_request';
+import state from '~/ide/stores/state';
+
+describe('IDE store merge request mutations', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ localState.projects = { abcproject: { mergeRequests: {} } };
+
+ mutations.SET_MERGE_REQUEST(localState, {
+ projectPath: 'abcproject',
+ mergeRequestId: 1,
+ mergeRequest: {
+ title: 'mr',
+ },
+ });
+ });
+
+ describe('SET_CURRENT_MERGE_REQUEST', () => {
+ it('sets current merge request', () => {
+ mutations.SET_CURRENT_MERGE_REQUEST(localState, 2);
+
+ expect(localState.currentMergeRequestId).toBe(2);
+ });
+ });
+
+ describe('SET_MERGE_REQUEST', () => {
+ it('setsmerge request data', () => {
+ const newMr = localState.projects.abcproject.mergeRequests[1];
+
+ expect(newMr.title).toBe('mr');
+ expect(newMr.active).toBeTruthy();
+ });
+ });
+
+ describe('SET_MERGE_REQUEST_CHANGES', () => {
+ it('sets merge request changes', () => {
+ mutations.SET_MERGE_REQUEST_CHANGES(localState, {
+ projectPath: 'abcproject',
+ mergeRequestId: 1,
+ changes: {
+ diff: 'abc',
+ },
+ });
+
+ const newMr = localState.projects.abcproject.mergeRequests[1];
+ expect(newMr.changes.diff).toBe('abc');
+ });
+ });
+
+ describe('SET_MERGE_REQUEST_VERSIONS', () => {
+ it('sets merge request versions', () => {
+ mutations.SET_MERGE_REQUEST_VERSIONS(localState, {
+ projectPath: 'abcproject',
+ mergeRequestId: 1,
+ versions: [{ id: 123 }],
+ });
+
+ const newMr = localState.projects.abcproject.mergeRequests[1];
+ expect(newMr.versions.length).toBe(1);
+ expect(newMr.versions[0].id).toBe(123);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js
new file mode 100644
index 00000000000..e6c085eaff6
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations/tree_spec.js
@@ -0,0 +1,69 @@
+import mutations from '~/ide/stores/mutations/tree';
+import state from '~/ide/stores/state';
+import { file } from '../../helpers';
+
+describe('Multi-file store tree mutations', () => {
+ let localState;
+ let localTree;
+
+ beforeEach(() => {
+ localState = state();
+ localTree = file();
+
+ localState.entries[localTree.path] = localTree;
+ });
+
+ describe('TOGGLE_TREE_OPEN', () => {
+ it('toggles tree open', () => {
+ mutations.TOGGLE_TREE_OPEN(localState, localTree.path);
+
+ expect(localTree.opened).toBeTruthy();
+
+ mutations.TOGGLE_TREE_OPEN(localState, localTree.path);
+
+ expect(localTree.opened).toBeFalsy();
+ });
+ });
+
+ describe('SET_DIRECTORY_DATA', () => {
+ const data = [
+ {
+ name: 'tree',
+ },
+ {
+ name: 'submodule',
+ },
+ {
+ name: 'blob',
+ },
+ ];
+
+ it('adds directory data', () => {
+ localState.trees['project/master'] = {
+ tree: [],
+ };
+
+ mutations.SET_DIRECTORY_DATA(localState, {
+ data,
+ treePath: 'project/master',
+ });
+
+ const tree = localState.trees['project/master'];
+
+ expect(tree.tree.length).toBe(3);
+ expect(tree.tree[0].name).toBe('tree');
+ expect(tree.tree[1].name).toBe('submodule');
+ expect(tree.tree[2].name).toBe('blob');
+ });
+ });
+
+ describe('REMOVE_ALL_CHANGES_FILES', () => {
+ it('removes all files from changedFiles state', () => {
+ localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES'));
+
+ mutations.REMOVE_ALL_CHANGES_FILES(localState);
+
+ expect(localState.changedFiles.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js
new file mode 100644
index 00000000000..38162a470ad
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations_spec.js
@@ -0,0 +1,79 @@
+import mutations from '~/ide/stores/mutations';
+import state from '~/ide/stores/state';
+import { file } from '../helpers';
+
+describe('Multi-file store mutations', () => {
+ let localState;
+ let entry;
+
+ beforeEach(() => {
+ localState = state();
+ entry = file();
+
+ localState.entries[entry.path] = entry;
+ });
+
+ describe('SET_INITIAL_DATA', () => {
+ it('sets all initial data', () => {
+ mutations.SET_INITIAL_DATA(localState, {
+ test: 'test',
+ });
+
+ expect(localState.test).toBe('test');
+ });
+ });
+
+ describe('TOGGLE_LOADING', () => {
+ it('toggles loading of entry', () => {
+ mutations.TOGGLE_LOADING(localState, { entry });
+
+ expect(entry.loading).toBeTruthy();
+
+ mutations.TOGGLE_LOADING(localState, { entry });
+
+ expect(entry.loading).toBeFalsy();
+ });
+
+ it('toggles loading of entry and sets specific value', () => {
+ mutations.TOGGLE_LOADING(localState, { entry });
+
+ expect(entry.loading).toBeTruthy();
+
+ mutations.TOGGLE_LOADING(localState, { entry, forceValue: true });
+
+ expect(entry.loading).toBeTruthy();
+ });
+ });
+
+ describe('SET_LEFT_PANEL_COLLAPSED', () => {
+ it('sets left panel collapsed', () => {
+ mutations.SET_LEFT_PANEL_COLLAPSED(localState, true);
+
+ expect(localState.leftPanelCollapsed).toBeTruthy();
+
+ mutations.SET_LEFT_PANEL_COLLAPSED(localState, false);
+
+ expect(localState.leftPanelCollapsed).toBeFalsy();
+ });
+ });
+
+ describe('SET_RIGHT_PANEL_COLLAPSED', () => {
+ it('sets right panel collapsed', () => {
+ mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true);
+
+ expect(localState.rightPanelCollapsed).toBeTruthy();
+
+ mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false);
+
+ expect(localState.rightPanelCollapsed).toBeFalsy();
+ });
+ });
+
+ describe('UPDATE_VIEWER', () => {
+ it('sets viewer state', () => {
+ mutations.UPDATE_VIEWER(localState, 'diff');
+
+ expect(localState.viewer).toBe('diff');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js
new file mode 100644
index 00000000000..f38ac6dd82f
--- /dev/null
+++ b/spec/javascripts/ide/stores/utils_spec.js
@@ -0,0 +1,66 @@
+import * as utils from '~/ide/stores/utils';
+
+describe('Multi-file store utils', () => {
+ describe('setPageTitle', () => {
+ it('sets the document page title', () => {
+ utils.setPageTitle('test');
+
+ expect(document.title).toBe('test');
+ });
+ });
+
+ describe('findIndexOfFile', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = [
+ {
+ path: '1',
+ },
+ {
+ path: '2',
+ },
+ ];
+ });
+
+ it('finds in the index of an entry by path', () => {
+ const index = utils.findIndexOfFile(localState, {
+ path: '2',
+ });
+
+ expect(index).toBe(1);
+ });
+ });
+
+ describe('findEntry', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = {
+ tree: [
+ {
+ type: 'tree',
+ name: 'test',
+ },
+ {
+ type: 'blob',
+ name: 'file',
+ },
+ ],
+ };
+ });
+
+ it('returns an entry found by name', () => {
+ const foundEntry = utils.findEntry(localState.tree, 'tree', 'test');
+
+ expect(foundEntry.type).toBe('tree');
+ expect(foundEntry.name).toBe('test');
+ });
+
+ it('returns undefined when no entry found', () => {
+ const foundEntry = utils.findEntry(localState.tree, 'blob', 'test');
+
+ expect(foundEntry).toBeUndefined();
+ });
+ });
+});
diff --git a/spec/javascripts/importer_status_spec.js b/spec/javascripts/importer_status_spec.js
new file mode 100644
index 00000000000..0575d02886d
--- /dev/null
+++ b/spec/javascripts/importer_status_spec.js
@@ -0,0 +1,112 @@
+import { ImporterStatus } from '~/importer_status';
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+
+describe('Importer Status', () => {
+ let instance;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('addToImport', () => {
+ const importUrl = '/import_url';
+
+ beforeEach(() => {
+ setFixtures(`
+ <tr id="repo_123">
+ <td class="import-target"></td>
+ <td class="import-actions job-status">
+ <button name="button" type="submit" class="btn btn-import js-add-to-import">
+ </button>
+ </td>
+ </tr>
+ `);
+ spyOn(ImporterStatus.prototype, 'initStatusPage').and.callFake(() => {});
+ spyOn(ImporterStatus.prototype, 'setAutoUpdate').and.callFake(() => {});
+ instance = new ImporterStatus({
+ jobsUrl: '',
+ importUrl,
+ });
+ });
+
+ it('sets table row to active after post request', (done) => {
+ mock.onPost(importUrl).reply(200, {
+ id: 1,
+ full_path: '/full_path',
+ });
+
+ instance.addToImport({
+ currentTarget: document.querySelector('.js-add-to-import'),
+ })
+ .then(() => {
+ expect(document.querySelector('tr').classList.contains('active')).toEqual(true);
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('autoUpdate', () => {
+ const jobsUrl = '/jobs_url';
+
+ beforeEach(() => {
+ const div = document.createElement('div');
+ div.innerHTML = `
+ <div id="project_1">
+ <div class="job-status">
+ </div>
+ </div>
+ `;
+
+ document.body.appendChild(div);
+
+ spyOn(ImporterStatus.prototype, 'initStatusPage').and.callFake(() => {});
+ spyOn(ImporterStatus.prototype, 'setAutoUpdate').and.callFake(() => {});
+ instance = new ImporterStatus({
+ jobsUrl,
+ });
+ });
+
+ function setupMock(importStatus) {
+ mock.onGet(jobsUrl).reply(200, [{
+ id: 1,
+ import_status: importStatus,
+ }]);
+ }
+
+ function expectJobStatus(done, status) {
+ instance.autoUpdate()
+ .then(() => {
+ expect(document.querySelector('#project_1').innerText.trim()).toEqual(status);
+ done();
+ })
+ .catch(done.fail);
+ }
+
+ it('sets the job status to done', (done) => {
+ setupMock('finished');
+ expectJobStatus(done, 'Done');
+ });
+
+ it('sets the job status to scheduled', (done) => {
+ setupMock('scheduled');
+ expectJobStatus(done, 'Scheduled');
+ });
+
+ it('sets the job status to started', (done) => {
+ setupMock('started');
+ expectJobStatus(done, 'Started');
+ });
+
+ it('sets the job status to custom status', (done) => {
+ setupMock('custom status');
+ expectJobStatus(done, 'custom status');
+ });
+ });
+});
diff --git a/spec/javascripts/integrations/integration_settings_form_spec.js b/spec/javascripts/integrations/integration_settings_form_spec.js
index 9033eb9ce02..050b1f2074e 100644
--- a/spec/javascripts/integrations/integration_settings_form_spec.js
+++ b/spec/javascripts/integrations/integration_settings_form_spec.js
@@ -1,3 +1,6 @@
+import $ from 'jquery';
+import MockAdaptor from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
describe('IntegrationSettingsForm', () => {
@@ -109,91 +112,117 @@ describe('IntegrationSettingsForm', () => {
describe('testSettings', () => {
let integrationSettingsForm;
let formData;
+ let mock;
beforeEach(() => {
+ mock = new MockAdaptor(axios);
+
+ spyOn(axios, 'put').and.callThrough();
+
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());
+ afterEach(() => {
+ mock.restore();
+ });
- integrationSettingsForm.testSettings(formData);
+ it('should make an ajax request with provided `formData`', (done) => {
+ integrationSettingsForm.testSettings(formData)
+ .then(() => {
+ expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData);
- expect($.ajax).toHaveBeenCalledWith({
- type: 'PUT',
- url: integrationSettingsForm.testEndPoint,
- data: formData,
- });
+ done();
+ })
+ .catch(done.fail);
});
- it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => {
+ it('should show error Flash with `Save anyway` action if ajax request responds with error in test', (done) => {
const errorMessage = 'Test failed.';
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
-
- integrationSettingsForm.testSettings(formData);
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: true,
+ message: errorMessage,
+ service_response: 'some error',
+ });
- deferred.resolve({ error: true, message: errorMessage, service_response: 'some error' });
+ integrationSettingsForm.testSettings(formData)
+ .then(() => {
+ const $flashContainer = $('.flash-container');
+ expect($flashContainer.find('.flash-text').text().trim()).toEqual('Test failed. some error');
+ expect($flashContainer.find('.flash-action')).toBeDefined();
+ expect($flashContainer.find('.flash-action').text().trim()).toEqual('Save anyway');
- const $flashContainer = $('.flash-container');
- expect($flashContainer.find('.flash-text').text().trim()).toEqual('Test failed. some error');
- expect($flashContainer.find('.flash-action')).toBeDefined();
- expect($flashContainer.find('.flash-action').text().trim()).toEqual('Save anyway');
+ done();
+ })
+ .catch(done.fail);
});
- it('should submit form if ajax request responds without any error in test', () => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
+ it('should submit form if ajax request responds without any error in test', (done) => {
+ spyOn(integrationSettingsForm.$form, 'submit');
- integrationSettingsForm.testSettings(formData);
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: false,
+ });
- spyOn(integrationSettingsForm.$form, 'submit');
- deferred.resolve({ error: false });
+ integrationSettingsForm.testSettings(formData)
+ .then(() => {
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ done();
+ })
+ .catch(done.fail);
});
- 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());
+ it('should submit form when clicked on `Save anyway` action of error Flash', (done) => {
+ spyOn(integrationSettingsForm.$form, 'submit');
- integrationSettingsForm.testSettings(formData);
+ const errorMessage = 'Test failed.';
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: true,
+ message: errorMessage,
+ });
- deferred.resolve({ error: true, message: errorMessage });
+ integrationSettingsForm.testSettings(formData)
+ .then(() => {
+ const $flashAction = $('.flash-container .flash-action');
+ expect($flashAction).toBeDefined();
- const $flashAction = $('.flash-container .flash-action');
- expect($flashAction).toBeDefined();
+ $flashAction.get(0).click();
+ })
+ .then(() => {
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
- spyOn(integrationSettingsForm.$form, 'submit');
- $flashAction.get(0).click();
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ done();
+ })
+ .catch(done.fail);
});
- it('should show error Flash if ajax request failed', () => {
+ it('should show error Flash if ajax request failed', (done) => {
const errorMessage = 'Something went wrong on our end.';
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
- integrationSettingsForm.testSettings(formData);
+ mock.onPut(integrationSettingsForm.testEndPoint).networkError();
- deferred.reject();
+ integrationSettingsForm.testSettings(formData)
+ .then(() => {
+ expect($('.flash-container .flash-text').text().trim()).toEqual(errorMessage);
- expect($('.flash-container .flash-text').text().trim()).toEqual(errorMessage);
+ done();
+ })
+ .catch(done.fail);
});
- it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
-
- integrationSettingsForm.testSettings(formData);
+ it('should always call `toggleSubmitBtnState` with `false` once request is completed', (done) => {
+ mock.onPut(integrationSettingsForm.testEndPoint).networkError();
spyOn(integrationSettingsForm, 'toggleSubmitBtnState');
- deferred.reject();
- expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
+ integrationSettingsForm.testSettings(formData)
+ .then(() => {
+ expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
+
+ done();
+ })
+ .catch(done.fail);
});
});
});
diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js
index 5a9112716f4..57bf746f080 100644
--- a/spec/javascripts/issuable_spec.js
+++ b/spec/javascripts/issuable_spec.js
@@ -1,3 +1,6 @@
+import $ from 'jquery';
+import MockAdaptor from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import IssuableIndex from '~/issuable_index';
describe('Issuable', () => {
@@ -19,6 +22,8 @@ describe('Issuable', () => {
});
describe('resetIncomingEmailToken', () => {
+ let mock;
+
beforeEach(() => {
const element = document.createElement('a');
element.classList.add('incoming-email-token-reset');
@@ -30,14 +35,28 @@ describe('Issuable', () => {
document.body.appendChild(input);
Issuable = new IssuableIndex('issue_');
+
+ mock = new MockAdaptor(axios);
+
+ mock.onPut('foo').reply(200, {
+ new_address: 'testing123',
+ });
});
- it('should send request to reset email token', () => {
- spyOn(jQuery, 'ajax').and.callThrough();
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should send request to reset email token', (done) => {
+ spyOn(axios, 'put').and.callThrough();
document.querySelector('.incoming-email-token-reset').click();
- expect(jQuery.ajax).toHaveBeenCalled();
- expect(jQuery.ajax.calls.argsFor(0)[0].url).toEqual('foo');
+ setTimeout(() => {
+ expect(axios.put).toHaveBeenCalledWith('foo');
+ expect($('#issuable_email').val()).toBe('testing123');
+
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js
index 8ff93c4f918..ba9040524b1 100644
--- a/spec/javascripts/issuable_time_tracker_spec.js
+++ b/spec/javascripts/issuable_time_tracker_spec.js
@@ -1,8 +1,9 @@
/* eslint-disable no-unused-vars, space-before-function-paren, func-call-spacing, no-spaced-func, semi, max-len, quotes, space-infix-ops, padded-blocks */
+import $ from 'jquery';
import Vue from 'vue';
-import timeTracker from '~/sidebar/components/time_tracking/time_tracker';
+import timeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
function initTimeTrackingComponent(opts) {
setFixtures(`
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 7159148f8fa..d5a87b5ce20 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -1,36 +1,40 @@
import Vue from 'vue';
-import '~/render_math';
-import '~/render_gfm';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import '~/behaviors/markdown/render_gfm';
import * as urlUtils from '~/lib/utils/url_utility';
import issuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
+import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
import issueShowData from '../mock_data';
-import setTimeoutPromise from '../../helpers/set_timeout_promise_helper';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
}
+const REALTIME_REQUEST_STACK = [
+ issueShowData.initialRequest,
+ issueShowData.secondRequest,
+];
+
describe('Issuable output', () => {
- let requestData = issueShowData.initialRequest;
+ let mock;
+ let realtimeRequestCount = 0;
+ let vm;
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);
+ mock = new MockAdapter(axios);
+ mock.onGet('/gitlab-org/gitlab-shell/issues/9/realtime_changes/realtime_changes').reply(() => {
+ const res = Promise.resolve([200, REALTIME_REQUEST_STACK[realtimeRequestCount]]);
+ realtimeRequestCount += 1;
+ return res;
+ });
vm = new IssuableDescriptionComponent({
propsData: {
@@ -54,10 +58,10 @@ describe('Issuable output', () => {
});
afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ mock.restore();
+ realtimeRequestCount = 0;
vm.poll.stop();
-
vm.$destroy();
});
@@ -77,7 +81,6 @@ describe('Issuable output', () => {
expect(editedText.querySelector('time')).toBeTruthy();
})
.then(() => {
- requestData = issueShowData.secondRequest;
vm.poll.makeRequest();
})
.then(() => new Promise(resolve => setTimeout(resolve)))
@@ -141,24 +144,19 @@ describe('Issuable output', () => {
spyOn(vm.service, 'getData').and.callThrough();
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
- json() {
- return {
- confidential: false,
- web_url: location.pathname,
- };
+ data: {
+ confidential: false,
+ web_url: location.pathname,
},
});
}));
- vm.updateIssuable();
-
- setTimeout(() => {
- expect(
- vm.service.getData,
- ).toHaveBeenCalled();
-
- done();
- });
+ vm.updateIssuable()
+ .then(() => {
+ expect(vm.service.getData).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
});
it('correctly updates issuable data', (done) => {
@@ -166,29 +164,22 @@ describe('Issuable output', () => {
resolve();
}));
- vm.updateIssuable();
-
- setTimeout(() => {
- expect(
- vm.service.updateIssuable,
- ).toHaveBeenCalledWith(vm.formState);
- expect(
- eventHub.$emit,
- ).toHaveBeenCalledWith('close.form');
-
- done();
- });
+ vm.updateIssuable()
+ .then(() => {
+ expect(vm.service.updateIssuable).toHaveBeenCalledWith(vm.formState);
+ expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
+ })
+ .then(done)
+ .catch(done.fail);
});
it('does not redirect if issue has not moved', (done) => {
spyOn(urlUtils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
- json() {
- return {
- web_url: location.pathname,
- confidential: vm.isConfidential,
- };
+ data: {
+ web_url: location.pathname,
+ confidential: vm.isConfidential,
},
});
}));
@@ -208,11 +199,9 @@ describe('Issuable output', () => {
spyOn(urlUtils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
- json() {
- return {
- web_url: '/testing-issue-move',
- confidential: vm.isConfidential,
- };
+ data: {
+ web_url: '/testing-issue-move',
+ confidential: vm.isConfidential,
},
});
}));
@@ -228,6 +217,39 @@ describe('Issuable output', () => {
});
});
+ describe('shows dialog when issue has unsaved changed', () => {
+ it('confirms on title change', (done) => {
+ vm.showForm = true;
+ vm.state.titleText = 'title has changed';
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ Vue.nextTick(() => {
+ expect(e.returnValue).not.toBeNull();
+ done();
+ });
+ });
+
+ it('confirms on description change', (done) => {
+ vm.showForm = true;
+ vm.state.descriptionText = 'description has changed';
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ Vue.nextTick(() => {
+ expect(e.returnValue).not.toBeNull();
+ done();
+ });
+ });
+
+ it('does nothing when nothing has changed', (done) => {
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ Vue.nextTick(() => {
+ expect(e.returnValue).toBeNull();
+ done();
+ });
+ });
+ });
+
describe('error when updating', () => {
beforeEach(() => {
spyOn(window, 'Flash').and.callThrough();
@@ -283,10 +305,8 @@ describe('Issuable output', () => {
let modal;
const promise = new Promise((resolve) => {
resolve({
- json() {
- return {
- recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
- };
+ data: {
+ recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
},
});
});
@@ -323,8 +343,8 @@ describe('Issuable output', () => {
spyOn(urlUtils, 'visitUrl');
spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
- json() {
- return { web_url: '/test' };
+ data: {
+ web_url: '/test',
},
});
}));
@@ -345,8 +365,8 @@ describe('Issuable output', () => {
spyOn(vm.poll, 'stop').and.callThrough();
spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
- json() {
- return { web_url: '/test' };
+ data: {
+ web_url: '/test',
},
});
}));
@@ -385,22 +405,21 @@ describe('Issuable output', () => {
describe('open form', () => {
it('shows locked warning if form is open & data is different', (done) => {
- Vue.nextTick()
+ vm.$nextTick()
.then(() => {
vm.openForm();
- requestData = issueShowData.secondRequest;
vm.poll.makeRequest();
})
- .then(() => new Promise(resolve => setTimeout(resolve)))
+ // Wait for the request
+ .then(vm.$nextTick)
+ // Wait for the successCallback to update the store state
+ .then(vm.$nextTick)
+ // Wait for the new state to flow to the Vue components
+ .then(vm.$nextTick)
.then(() => {
- expect(
- vm.formState.lockedWarningVisible,
- ).toBeTruthy();
-
- expect(
- vm.$el.querySelector('.alert'),
- ).not.toBeNull();
+ expect(vm.formState.lockedWarningVisible).toEqual(true);
+ 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
index 0da25bdca9c..d96151a8a3a 100644
--- a/spec/javascripts/issue_show/components/description_spec.js
+++ b/spec/javascripts/issue_show/components/description_spec.js
@@ -1,7 +1,8 @@
+import $ from 'jquery';
import Vue from 'vue';
import descriptionComponent from '~/issue_show/components/description.vue';
import * as taskList from '~/task_list';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Description component', () => {
let vm;
diff --git a/spec/javascripts/issue_show/components/fields/description_template_spec.js b/spec/javascripts/issue_show/components/fields/description_template_spec.js
index 2b7ee65094b..30441faf844 100644
--- a/spec/javascripts/issue_show/components/fields/description_template_spec.js
+++ b/spec/javascripts/issue_show/components/fields/description_template_spec.js
@@ -1,7 +1,5 @@
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;
diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js
index 000b53af016..50ce019c32a 100644
--- a/spec/javascripts/issue_show/components/form_spec.js
+++ b/spec/javascripts/issue_show/components/form_spec.js
@@ -1,7 +1,5 @@
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;
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
index eb3111412a7..74b3efb014b 100644
--- a/spec/javascripts/issue_show/mock_data.js
+++ b/spec/javascripts/issue_show/mock_data.js
@@ -19,14 +19,4 @@ export default {
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 2cd2e63b15d..f37426a72d4 100644
--- a/spec/javascripts/issue_spec.js
+++ b/spec/javascripts/issue_spec.js
@@ -1,4 +1,8 @@
/* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */
+
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Issue from '~/issue';
import '~/lib/utils/text_utility';
@@ -68,40 +72,27 @@ describe('Issue', function() {
expect($btn).toHaveText(isIssueInitiallyOpen ? 'Close issue' : 'Reopen issue');
}
- describe('task lists', function() {
- beforeEach(function() {
- loadFixtures('issues/issue-with-task-list.html.raw');
- this.issue = new Issue();
- });
-
- it('submits an ajax request on tasklist:changed', function() {
- spyOn(jQuery, 'ajax').and.callFake(function(req) {
- expect(req.type).toBe('PATCH');
- expect(req.url).toBe(gl.TEST_HOST + '/frontend-fixtures/issues-project/issues/1.json'); // eslint-disable-line prefer-template
- expect(req.data.issue.description).not.toBe(null);
- });
-
- $('.js-task-list-field').trigger('tasklist:changed');
- });
- });
-
[true, false].forEach((isIssueInitiallyOpen) => {
describe(`with ${isIssueInitiallyOpen ? 'open' : 'closed'} issue`, function() {
const action = isIssueInitiallyOpen ? 'close' : 'reopen';
+ let mock;
- function ajaxSpy(req) {
- if (req.url === this.$triggeredButton.attr('href')) {
- expect(req.type).toBe('PUT');
- expectNewBranchButtonState(true, false);
- return this.issueStateDeferred;
- } else if (req.url === Issue.createMrDropdownWrap.dataset.canCreatePath) {
- expect(req.type).toBe('GET');
+ function mockCloseButtonResponseSuccess(url, response) {
+ mock.onPut(url).reply(() => {
expectNewBranchButtonState(true, false);
- return this.canCreateBranchDeferred;
- }
- expect(req.url).toBe('unexpected');
- return null;
+ return [200, response];
+ });
+ }
+
+ function mockCloseButtonResponseError(url) {
+ mock.onPut(url).networkError();
+ }
+
+ function mockCanCreateBranch(canCreateBranch) {
+ mock.onGet(/(.*)\/can_create_branch$/).reply(200, {
+ can_create_branch: canCreateBranch,
+ });
}
beforeEach(function() {
@@ -111,6 +102,11 @@ describe('Issue', function() {
loadFixtures('issues/closed-issue.html.raw');
}
+ mock = new MockAdapter(axios);
+
+ mock.onGet(/(.*)\/related_branches$/).reply(200, {});
+ mock.onGet(/(.*)\/referenced_merge_requests$/).reply(200, {});
+
findElements(isIssueInitiallyOpen);
this.issue = new Issue();
expectIssueState(isIssueInitiallyOpen);
@@ -120,71 +116,89 @@ describe('Issue', function() {
this.$projectIssuesCounter = $('.issue_counter').first();
this.$projectIssuesCounter.text('1,001');
- this.issueStateDeferred = new jQuery.Deferred();
- this.canCreateBranchDeferred = new jQuery.Deferred();
+ spyOn(axios, 'get').and.callThrough();
+ });
- spyOn(jQuery, 'ajax').and.callFake(ajaxSpy.bind(this));
+ afterEach(() => {
+ mock.restore();
+ $('div.flash-alert').remove();
});
- it(`${action}s the issue`, function() {
- this.$triggeredButton.trigger('click');
- this.issueStateDeferred.resolve({
+ it(`${action}s the issue`, function(done) {
+ mockCloseButtonResponseSuccess(this.$triggeredButton.attr('href'), {
id: 34
});
- this.canCreateBranchDeferred.resolve({
- can_create_branch: !isIssueInitiallyOpen
- });
+ mockCanCreateBranch(!isIssueInitiallyOpen);
- expectIssueState(!isIssueInitiallyOpen);
- expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
- expect(this.$projectIssuesCounter.text()).toBe(isIssueInitiallyOpen ? '1,000' : '1,002');
- expectNewBranchButtonState(false, !isIssueInitiallyOpen);
+ this.$triggeredButton.trigger('click');
+
+ setTimeout(() => {
+ expectIssueState(!isIssueInitiallyOpen);
+ expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
+ expect(this.$projectIssuesCounter.text()).toBe(isIssueInitiallyOpen ? '1,000' : '1,002');
+ expectNewBranchButtonState(false, !isIssueInitiallyOpen);
+
+ done();
+ });
});
- it(`fails to ${action} the issue if saved:false`, function() {
- this.$triggeredButton.trigger('click');
- this.issueStateDeferred.resolve({
+ it(`fails to ${action} the issue if saved:false`, function(done) {
+ mockCloseButtonResponseSuccess(this.$triggeredButton.attr('href'), {
saved: false
});
- this.canCreateBranchDeferred.resolve({
- can_create_branch: isIssueInitiallyOpen
- });
+ mockCanCreateBranch(isIssueInitiallyOpen);
- expectIssueState(isIssueInitiallyOpen);
- expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
- expectErrorMessage();
- expect(this.$projectIssuesCounter.text()).toBe('1,001');
- expectNewBranchButtonState(false, isIssueInitiallyOpen);
+ this.$triggeredButton.trigger('click');
+
+ setTimeout(() => {
+ expectIssueState(isIssueInitiallyOpen);
+ expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
+ expectErrorMessage();
+ expect(this.$projectIssuesCounter.text()).toBe('1,001');
+ expectNewBranchButtonState(false, isIssueInitiallyOpen);
+
+ done();
+ });
});
- it(`fails to ${action} the issue if HTTP error occurs`, function() {
+ it(`fails to ${action} the issue if HTTP error occurs`, function(done) {
+ mockCloseButtonResponseError(this.$triggeredButton.attr('href'));
+ mockCanCreateBranch(isIssueInitiallyOpen);
+
this.$triggeredButton.trigger('click');
- this.issueStateDeferred.reject();
- this.canCreateBranchDeferred.resolve({
- can_create_branch: isIssueInitiallyOpen
- });
- expectIssueState(isIssueInitiallyOpen);
- expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
- expectErrorMessage();
- expect(this.$projectIssuesCounter.text()).toBe('1,001');
- expectNewBranchButtonState(false, isIssueInitiallyOpen);
+ setTimeout(() => {
+ expectIssueState(isIssueInitiallyOpen);
+ expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
+ expectErrorMessage();
+ expect(this.$projectIssuesCounter.text()).toBe('1,001');
+ expectNewBranchButtonState(false, isIssueInitiallyOpen);
+
+ done();
+ });
});
it('disables the new branch button if Ajax call fails', function() {
+ mockCloseButtonResponseError(this.$triggeredButton.attr('href'));
+ mock.onGet(/(.*)\/can_create_branch$/).networkError();
+
this.$triggeredButton.trigger('click');
- this.issueStateDeferred.reject();
- this.canCreateBranchDeferred.reject();
expectNewBranchButtonState(false, false);
});
- it('does not trigger Ajax call if new branch button is missing', function() {
+ it('does not trigger Ajax call if new branch button is missing', function(done) {
+ mockCloseButtonResponseError(this.$triggeredButton.attr('href'));
Issue.$btnNewBranch = $();
this.canCreateBranchDeferred = null;
this.$triggeredButton.trigger('click');
- this.issueStateDeferred.reject();
+
+ setTimeout(() => {
+ expect(axios.get).not.toHaveBeenCalled();
+
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/job_spec.js b/spec/javascripts/job_spec.js
index 4f06237deb5..c6bbacf237a 100644
--- a/spec/javascripts/job_spec.js
+++ b/spec/javascripts/job_spec.js
@@ -1,4 +1,7 @@
-import { bytesToKiB } from '~/lib/utils/number_utils';
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import '~/lib/utils/datetime_utility';
import Job from '~/job';
@@ -6,11 +9,32 @@ import '~/breakpoints';
describe('Job', () => {
const JOB_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1`;
+ let mock;
+ let response;
+ let job;
+
+ function waitForPromise() {
+ return new Promise(resolve => requestAnimationFrame(resolve));
+ }
preloadFixtures('builds/build-with-artifacts.html.raw');
beforeEach(() => {
loadFixtures('builds/build-with-artifacts.html.raw');
+
+ spyOn(urlUtils, 'visitUrl');
+
+ response = {};
+
+ mock = new MockAdapter(axios);
+
+ mock.onGet(new RegExp(`${JOB_URL}/trace.json?(.*)`)).reply(() => [200, response]);
+ });
+
+ afterEach(() => {
+ mock.restore();
+
+ clearTimeout(job.timeout);
});
describe('class constructor', () => {
@@ -23,15 +47,19 @@ describe('Job', () => {
});
describe('setup', () => {
- beforeEach(function () {
- this.job = new Job();
+ beforeEach(function (done) {
+ job = new Job();
+
+ waitForPromise()
+ .then(done)
+ .catch(done.fail);
});
it('copies build options', function () {
- expect(this.job.pagePath).toBe(JOB_URL);
- expect(this.job.buildStatus).toBe('success');
- expect(this.job.buildStage).toBe('test');
- expect(this.job.state).toBe('');
+ expect(job.pagePath).toBe(JOB_URL);
+ expect(job.buildStatus).toBe('success');
+ expect(job.buildStage).toBe('test');
+ expect(job.state).toBe('');
});
it('only shows the jobs matching the current stage', () => {
@@ -52,180 +80,166 @@ describe('Job', () => {
expect($('.build-job[data-stage="test"]').is(':visible')).toBe(false);
expect($('.build-job[data-stage="deploy"]').is(':visible')).toBe(false);
});
-
- it('displays the remove date correctly', () => {
- const removeDateElement = document.querySelector('.js-artifacts-remove');
- expect(removeDateElement.innerText.trim()).toBe('1 year remaining');
- });
});
describe('running 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(urlUtils, 'visitUrl');
-
- deferred1.resolve({
+ it('updates the build trace on an interval', function (done) {
+ response = {
html: '<span>Update<span>',
status: 'running',
state: 'newstate',
append: true,
complete: false,
- });
-
- deferred2.resolve();
-
- deferred3.resolve({
- html: '<span>More</span>',
- status: 'running',
- state: 'finalstate',
- append: true,
- complete: true,
- });
-
- this.job = new Job();
-
- expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
- expect(this.job.state).toBe('newstate');
-
- jasmine.clock().tick(4001);
-
- expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/);
- expect(this.job.state).toBe('finalstate');
+ };
+
+ job = new Job();
+
+ waitForPromise()
+ .then(() => {
+ expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
+ expect(job.state).toBe('newstate');
+
+ response = {
+ html: '<span>More</span>',
+ status: 'running',
+ state: 'finalstate',
+ append: true,
+ complete: true,
+ };
+ })
+ .then(() => jasmine.clock().tick(4001))
+ .then(waitForPromise)
+ .then(() => {
+ expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/);
+ expect(job.state).toBe('finalstate');
+ })
+ .then(done)
+ .catch(done.fail);
});
- 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(urlUtils, 'visitUrl');
-
- deferred1.resolve({
+ it('replaces the entire build trace', (done) => {
+ response = {
html: '<span>Update<span>',
status: 'running',
append: false,
complete: false,
- });
-
- deferred2.resolve();
-
- deferred3.resolve({
- html: '<span>Different</span>',
- status: 'running',
- append: false,
- });
-
- this.job = new Job();
-
- expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
-
- jasmine.clock().tick(4001);
-
- expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
- expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
+ };
+
+ job = new Job();
+
+ waitForPromise()
+ .then(() => {
+ expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
+
+ response = {
+ html: '<span>Different</span>',
+ status: 'running',
+ append: false,
+ };
+ })
+ .then(() => jasmine.clock().tick(4001))
+ .then(waitForPromise)
+ .then(() => {
+ expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
+ expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
describe('truncated information', () => {
describe('when size is less than total', () => {
- it('shows information about truncated log', () => {
- spyOn(urlUtils, 'visitUrl');
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
-
- deferred.resolve({
+ it('shows information about truncated log', (done) => {
+ response = {
html: '<span>Update</span>',
status: 'success',
append: false,
size: 50,
total: 100,
- });
+ };
- this.job = new Job();
+ job = new Job();
- expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden');
+ waitForPromise()
+ .then(() => {
+ expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden');
+ })
+ .then(done)
+ .catch(done.fail);
});
- it('shows the size in KiB', () => {
+ it('shows the size in KiB', (done) => {
const size = 50;
- spyOn(urlUtils, 'visitUrl');
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
- deferred.resolve({
+ response = {
html: '<span>Update</span>',
status: 'success',
append: false,
size,
total: 100,
- });
-
- this.job = new Job();
-
- expect(
- document.querySelector('.js-truncated-info-size').textContent.trim(),
- ).toEqual(`${bytesToKiB(size)}`);
+ };
+
+ job = new Job();
+
+ waitForPromise()
+ .then(() => {
+ expect(
+ document.querySelector('.js-truncated-info-size').textContent.trim(),
+ ).toEqual(`${numberToHumanSize(size)}`);
+ })
+ .then(done)
+ .catch(done.fail);
});
- it('shows incremented size', () => {
- const deferred1 = $.Deferred();
- const deferred2 = $.Deferred();
- const deferred3 = $.Deferred();
-
- spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise());
-
- spyOn(urlUtils, 'visitUrl');
-
- deferred1.resolve({
+ it('shows incremented size', (done) => {
+ response = {
html: '<span>Update</span>',
status: 'success',
append: false,
size: 50,
total: 100,
- });
-
- deferred2.resolve();
-
- this.job = new Job();
-
- 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)}`);
+ complete: false,
+ };
+
+ job = new Job();
+
+ waitForPromise()
+ .then(() => {
+ expect(
+ document.querySelector('.js-truncated-info-size').textContent.trim(),
+ ).toEqual(`${numberToHumanSize(50)}`);
+
+ response = {
+ html: '<span>Update</span>',
+ status: 'success',
+ append: true,
+ size: 10,
+ total: 100,
+ complete: true,
+ };
+ })
+ .then(() => jasmine.clock().tick(4001))
+ .then(waitForPromise)
+ .then(() => {
+ expect(
+ document.querySelector('.js-truncated-info-size').textContent.trim(),
+ ).toEqual(`${numberToHumanSize(60)}`);
+ })
+ .then(done)
+ .catch(done.fail);
});
it('renders the raw link', () => {
- const deferred = $.Deferred();
- spyOn(urlUtils, 'visitUrl');
-
- spyOn($, 'ajax').and.returnValue(deferred.promise());
- deferred.resolve({
+ response = {
html: '<span>Update</span>',
status: 'success',
append: false,
size: 50,
total: 100,
- });
+ };
- this.job = new Job();
+ job = new Job();
expect(
document.querySelector('.js-raw-link').textContent.trim(),
@@ -234,50 +248,50 @@ describe('Job', () => {
});
describe('when size is equal than total', () => {
- it('does not show the trunctated information', () => {
- const deferred = $.Deferred();
- spyOn(urlUtils, 'visitUrl');
-
- spyOn($, 'ajax').and.returnValue(deferred.promise());
- deferred.resolve({
+ it('does not show the trunctated information', (done) => {
+ response = {
html: '<span>Update</span>',
status: 'success',
append: false,
size: 100,
total: 100,
- });
+ };
- this.job = new Job();
+ job = new Job();
- expect(document.querySelector('.js-truncated-info').classList).toContain('hidden');
+ waitForPromise()
+ .then(() => {
+ expect(document.querySelector('.js-truncated-info').classList).toContain('hidden');
+ })
+ .then(done)
+ .catch(done.fail);
});
});
});
describe('output trace', () => {
- beforeEach(() => {
- const deferred = $.Deferred();
- spyOn(urlUtils, 'visitUrl');
-
- spyOn($, 'ajax').and.returnValue(deferred.promise());
- deferred.resolve({
+ beforeEach((done) => {
+ response = {
html: '<span>Update</span>',
status: 'success',
append: false,
size: 50,
total: 100,
- });
+ };
+
+ job = new Job();
- this.job = new Job();
+ waitForPromise()
+ .then(done)
+ .catch(done.fail);
});
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();
+ expect(controllers.querySelector('.js-raw-link-controller')).not.toBeNull();
+ expect(controllers.querySelector('.js-scroll-up')).not.toBeNull();
+ expect(controllers.querySelector('.js-scroll-down')).not.toBeNull();
});
it('should render received output', () => {
@@ -290,13 +304,13 @@ describe('Job', () => {
describe('getBuildTrace', () => {
it('should request build trace with state parameter', (done) => {
- spyOn(jQuery, 'ajax').and.callThrough();
+ spyOn(axios, 'get').and.callThrough();
// eslint-disable-next-line no-new
- new Job();
+ job = new Job();
setTimeout(() => {
- expect(jQuery.ajax).toHaveBeenCalledWith(
- { url: `${JOB_URL}/trace.json`, data: { state: '' } },
+ expect(axios.get).toHaveBeenCalledWith(
+ `${JOB_URL}/trace.json`, { params: { state: '' } },
);
done();
}, 0);
diff --git a/spec/javascripts/jobs/header_spec.js b/spec/javascripts/jobs/header_spec.js
index 4a210faa017..0961605ce5c 100644
--- a/spec/javascripts/jobs/header_spec.js
+++ b/spec/javascripts/jobs/header_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import headerComponent from '~/jobs/components/header.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Job details header', () => {
let HeaderComponent;
@@ -30,27 +31,45 @@ describe('Job details header', () => {
email: 'foo@bar.com',
avatar_url: 'link',
},
+ started: '2018-01-08T09:48:27.319Z',
new_issue_path: 'path',
},
isLoading: false,
};
- vm = new HeaderComponent({ propsData: props }).$mount();
+ vm = mountComponent(HeaderComponent, props);
});
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');
+ describe('triggered job', () => {
+ beforeEach(() => {
+ vm = mountComponent(HeaderComponent, props);
+ });
+
+ 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 new issue link', () => {
+ expect(
+ vm.$el.querySelector('.js-new-issue').getAttribute('href'),
+ ).toEqual(props.job.new_issue_path);
+ });
});
- it('should render new issue link', () => {
- expect(
- vm.$el.querySelector('.js-new-issue').getAttribute('href'),
- ).toEqual(props.job.new_issue_path);
+ describe('created job', () => {
+ it('should render created key', () => {
+ props.job.started = false;
+ vm = mountComponent(HeaderComponent, props);
+
+ expect(
+ vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
+ ).toEqual('failed Job #123 created 3 weeks ago by Foo');
+ });
});
});
diff --git a/spec/javascripts/jobs/job_details_mediator_spec.js b/spec/javascripts/jobs/job_details_mediator_spec.js
index 3069a0cd60e..ca5c9cf87e4 100644
--- a/spec/javascripts/jobs/job_details_mediator_spec.js
+++ b/spec/javascripts/jobs/job_details_mediator_spec.js
@@ -12,6 +12,10 @@ describe('JobMediator', () => {
mock = new MockAdapter(axios);
});
+ afterEach(() => {
+ mock.restore();
+ });
+
it('should set defaults', () => {
expect(mediator.store).toBeDefined();
expect(mediator.service).toBeDefined();
@@ -24,10 +28,6 @@ describe('JobMediator', () => {
mock.onGet().reply(200, job, {});
});
- afterEach(() => {
- mock.restore();
- });
-
it('should store received data', (done) => {
mediator.fetchJob();
setTimeout(() => {
diff --git a/spec/javascripts/jobs/mock_data.js b/spec/javascripts/jobs/mock_data.js
index 43532275121..25ca8eb6c0b 100644
--- a/spec/javascripts/jobs/mock_data.js
+++ b/spec/javascripts/jobs/mock_data.js
@@ -37,7 +37,7 @@ export default {
username: 'root',
id: 1,
state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
erase_path: '/root/ci-mock/-/jobs/4757/erase',
@@ -54,7 +54,7 @@ export default {
username: 'root',
id: 1,
state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
active: false,
@@ -107,14 +107,18 @@ export default {
username: 'root',
id: 1,
state: 'active',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ avatar_url: 'https://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',
+ author_gravatar_url: 'https://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',
},
},
+ metadata: {
+ timeout_human_readable: '1m 40s',
+ timeout_source: 'runner',
+ },
merge_request: {
iid: 2,
path: '/root/ci-mock/merge_requests/2',
diff --git a/spec/javascripts/jobs/sidebar_detail_row_spec.js b/spec/javascripts/jobs/sidebar_detail_row_spec.js
index 3ac65709c4a..e6bfb0c4adc 100644
--- a/spec/javascripts/jobs/sidebar_detail_row_spec.js
+++ b/spec/javascripts/jobs/sidebar_detail_row_spec.js
@@ -37,4 +37,25 @@ describe('Sidebar detail row', () => {
vm.$el.textContent.replace(/\s+/g, ' ').trim(),
).toEqual('this is the title: this is the value');
});
+
+ describe('when helpUrl not provided', () => {
+ it('should not render help', () => {
+ expect(vm.$el.querySelector('.help-button')).toBeNull();
+ });
+ });
+
+ describe('when helpUrl provided', () => {
+ beforeEach(() => {
+ vm = new SidebarDetailRow({
+ propsData: {
+ helpUrl: 'help url',
+ value: 'foo',
+ },
+ }).$mount();
+ });
+
+ it('should render help', () => {
+ expect(vm.$el.querySelector('.help-button a').getAttribute('href')).toEqual('help url');
+ });
+ });
});
diff --git a/spec/javascripts/jobs/sidebar_details_block_spec.js b/spec/javascripts/jobs/sidebar_details_block_spec.js
index 95532ef5382..602dae514b1 100644
--- a/spec/javascripts/jobs/sidebar_details_block_spec.js
+++ b/spec/javascripts/jobs/sidebar_details_block_spec.js
@@ -96,6 +96,12 @@ describe('Sidebar details block', () => {
).toEqual('Runner: #1');
});
+ it('should render timeout information', () => {
+ expect(
+ trimWhitespace(vm.$el.querySelector('.js-job-timeout')),
+ ).toEqual('Timeout: 1m 40s (from runner)');
+ });
+
it('should render coverage', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
diff --git a/spec/javascripts/labels_issue_sidebar_spec.js b/spec/javascripts/labels_issue_sidebar_spec.js
index a197b35f6fb..5aafb6ad8f0 100644
--- a/spec/javascripts/labels_issue_sidebar_spec.js
+++ b/spec/javascripts/labels_issue_sidebar_spec.js
@@ -1,4 +1,8 @@
/* eslint-disable no-new */
+
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import IssuableContext from '~/issuable_context';
import LabelsSelect from '~/labels_select';
@@ -10,35 +14,44 @@ import '~/users_select';
(() => {
let saveLabelCount = 0;
+ let mock;
+
describe('Issue dropdown sidebar', () => {
preloadFixtures('static/issue_sidebar_label.html.raw');
beforeEach(() => {
loadFixtures('static/issue_sidebar_label.html.raw');
+
+ mock = new MockAdapter(axios);
+
new IssuableContext('{"id":1,"name":"Administrator","username":"root"}');
new LabelsSelect();
- spyOn(jQuery, 'ajax').and.callFake((req) => {
- const d = $.Deferred();
- let LABELS_DATA = [];
+ mock.onGet('/root/test/labels.json').reply(() => {
+ const labels = Array(10).fill().map((_, i) => ({
+ id: i,
+ title: `test ${i}`,
+ color: '#5CB85C',
+ }));
- if (req.url === '/root/test/labels.json') {
- for (let i = 0; i < 10; i += 1) {
- LABELS_DATA.push({ id: i, title: `test ${i}`, color: '#5CB85C' });
- }
- } else if (req.url === '/root/test/issues/2.json') {
- const tmp = [];
- for (let i = 0; i < saveLabelCount; i += 1) {
- tmp.push({ id: i, title: `test ${i}`, color: '#5CB85C' });
- }
- LABELS_DATA = { labels: tmp };
- }
+ return [200, labels];
+ });
+
+ mock.onPut('/root/test/issues/2.json').reply(() => {
+ const labels = Array(saveLabelCount).fill().map((_, i) => ({
+ id: i,
+ title: `test ${i}`,
+ color: '#5CB85C',
+ }));
- d.resolve(LABELS_DATA);
- return d.promise();
+ return [200, { labels }];
});
});
+ afterEach(() => {
+ mock.restore();
+ });
+
it('changes collapsed tooltip when changing labels when less than 5', (done) => {
saveLabelCount = 5;
$('.edit-link').get(0).click();
diff --git a/spec/javascripts/labels_select_spec.js b/spec/javascripts/labels_select_spec.js
new file mode 100644
index 00000000000..a2b89c0aef5
--- /dev/null
+++ b/spec/javascripts/labels_select_spec.js
@@ -0,0 +1,44 @@
+import $ from 'jquery';
+import LabelsSelect from '~/labels_select';
+
+const mockUrl = '/foo/bar/url';
+
+const mockLabels = [
+ {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ text_color: '#FFFFFF',
+ },
+];
+
+describe('LabelsSelect', () => {
+ describe('getLabelTemplate', () => {
+ const label = mockLabels[0];
+ let $labelEl;
+
+ beforeEach(() => {
+ $labelEl = $(LabelsSelect.getLabelTemplate({
+ labels: mockLabels,
+ issueUpdateURL: mockUrl,
+ }));
+ });
+
+ it('generated label item template has correct label URL', () => {
+ expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label');
+ });
+
+ it('generated label item template has correct label title', () => {
+ expect($labelEl.find('span.label').text()).toBe(label.title);
+ });
+
+ it('generated label item template has label description as title attribute', () => {
+ expect($labelEl.find('span.label').attr('title')).toBe(label.description);
+ });
+
+ it('generated label item template has correct label styles', () => {
+ expect($labelEl.find('span.label').attr('style')).toBe(`background-color: ${label.color}; color: ${label.text_color};`);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js
index 49971bd91e2..7603400b55e 100644
--- a/spec/javascripts/lib/utils/ajax_cache_spec.js
+++ b/spec/javascripts/lib/utils/ajax_cache_spec.js
@@ -1,3 +1,5 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import AjaxCache from '~/lib/utils/ajax_cache';
describe('AjaxCache', () => {
@@ -87,66 +89,53 @@ describe('AjaxCache', () => {
});
describe('retrieve', () => {
- let ajaxSpy;
+ let mock;
beforeEach(() => {
- spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url));
+ mock = new MockAdapter(axios);
+
+ spyOn(axios, 'get').and.callThrough();
+ });
+
+ afterEach(() => {
+ mock.restore();
});
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();
- };
+ mock.onGet(dummyEndpoint).reply(200, dummyResponse);
AjaxCache.retrieve(dummyEndpoint)
.then((data) => {
- expect(data).toBe(dummyResponse);
- expect(AjaxCache.internalStorage[dummyEndpoint]).toBe(dummyResponse);
+ expect(data).toEqual(dummyResponse);
+ expect(AjaxCache.internalStorage[dummyEndpoint]).toEqual(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}`);
+ it('makes no Ajax call if request is pending', (done) => {
+ mock.onGet(dummyEndpoint).reply(200, dummyResponse);
AjaxCache.retrieve(dummyEndpoint)
- .then(unexpectedResponse)
+ .then(done)
.catch(fail);
AjaxCache.retrieve(dummyEndpoint)
- .then(unexpectedResponse)
+ .then(done)
.catch(fail);
- expect($.ajax.calls.count()).toBe(1);
+ expect(axios.get.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();
- };
+ const errorMessage = 'Network Error';
+ mock.onGet(dummyEndpoint).networkError();
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);
+ expect(error.message).toBe(`${dummyEndpoint}: ${errorMessage}`);
+ expect(error.textStatus).toBe(errorMessage);
done();
})
.catch(fail);
@@ -154,7 +143,9 @@ describe('AjaxCache', () => {
it('makes no Ajax call if matching data exists', (done) => {
AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
- ajaxSpy = () => fail(new Error('expected no Ajax call!'));
+ mock.onGet(dummyEndpoint).reply(() => {
+ fail(new Error('expected no Ajax call!'));
+ });
AjaxCache.retrieve(dummyEndpoint)
.then((data) => {
@@ -171,12 +162,7 @@ describe('AjaxCache', () => {
AjaxCache.internalStorage[dummyEndpoint] = oldDummyResponse;
- ajaxSpy = (url) => {
- expect(url).toBe(dummyEndpoint);
- const deferred = $.Deferred();
- deferred.resolve(dummyResponse);
- return deferred.promise();
- };
+ mock.onGet(dummyEndpoint).reply(200, dummyResponse);
// Call without forceRetrieve param
AjaxCache.retrieve(dummyEndpoint)
@@ -189,7 +175,7 @@ describe('AjaxCache', () => {
// Call with forceRetrieve param
AjaxCache.retrieve(dummyEndpoint, true)
.then((data) => {
- expect(data).toBe(dummyResponse);
+ expect(data).toEqual(dummyResponse);
})
.then(done)
.catch(fail);
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js
index 0a9d815f469..27f06573432 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js
+++ b/spec/javascripts/lib/utils/common_utils_spec.js
@@ -1,6 +1,7 @@
/* eslint-disable promise/catch-or-return */
-
+import axios from '~/lib/utils/axios_utils';
import * as commonUtils from '~/lib/utils/common_utils';
+import MockAdapter from 'axios-mock-adapter';
describe('common_utils', () => {
describe('parseUrl', () => {
@@ -165,6 +166,21 @@ describe('common_utils', () => {
});
});
+ describe('objectToQueryString', () => {
+ it('returns empty string when `param` is undefined, null or empty string', () => {
+ expect(commonUtils.objectToQueryString()).toBe('');
+ expect(commonUtils.objectToQueryString('')).toBe('');
+ });
+
+ it('returns query string with values of `params`', () => {
+ const singleQueryParams = { foo: true };
+ const multipleQueryParams = { foo: true, bar: true };
+
+ expect(commonUtils.objectToQueryString(singleQueryParams)).toBe('foo=true');
+ expect(commonUtils.objectToQueryString(multipleQueryParams)).toBe('foo=true&bar=true');
+ });
+ });
+
describe('buildUrlWithCurrentLocation', () => {
it('should build an url with current location and given parameters', () => {
expect(commonUtils.buildUrlWithCurrentLocation()).toEqual(window.location.pathname);
@@ -413,48 +429,48 @@ describe('common_utils', () => {
describe('setCiStatusFavicon', () => {
const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1/status.json`;
+ let mock;
beforeEach(() => {
const favicon = document.createElement('link');
favicon.setAttribute('id', 'favicon');
document.body.appendChild(favicon);
+ mock = new MockAdapter(axios);
});
afterEach(() => {
+ mock.restore();
document.body.removeChild(document.getElementById('favicon'));
});
- it('should reset favicon in case of error', () => {
- const favicon = document.getElementById('favicon');
- spyOn($, 'ajax').and.callFake(function (options) {
- options.error();
- expect(favicon.getAttribute('href')).toEqual('null');
- });
+ it('should reset favicon in case of error', (done) => {
+ mock.onGet(BUILD_URL).networkError();
- commonUtils.setCiStatusFavicon(BUILD_URL);
+ commonUtils.setCiStatusFavicon(BUILD_URL)
+ .then(() => {
+ const favicon = document.getElementById('favicon');
+ expect(favicon.getAttribute('href')).toEqual('null');
+ done();
+ })
+ // Error is already caught in catch() block of setCiStatusFavicon,
+ // It won't throw another error for us to catch
+ .catch(done.fail);
});
- it('should set page favicon to CI status favicon based on provided status', () => {
+ it('should set page favicon to CI status favicon based on provided status', (done) => {
const FAVICON_PATH = '//icon_status_success';
- const favicon = document.getElementById('favicon');
- spyOn($, 'ajax').and.callFake(function (options) {
- options.success({ favicon: FAVICON_PATH });
- expect(favicon.getAttribute('href')).toEqual(FAVICON_PATH);
+ mock.onGet(BUILD_URL).reply(200, {
+ favicon: FAVICON_PATH,
});
- commonUtils.setCiStatusFavicon(BUILD_URL);
- });
- });
-
- describe('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(() => {});
-
- commonUtils.ajaxPost(requestURL, data);
- expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST');
+ commonUtils.setCiStatusFavicon(BUILD_URL)
+ .then(() => {
+ const favicon = document.getElementById('favicon');
+ expect(favicon.getAttribute('href')).toEqual(FAVICON_PATH);
+ done();
+ })
+ .catch(done.fail);
});
});
@@ -479,4 +495,33 @@ describe('common_utils', () => {
expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual('<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>');
});
});
+
+ describe('convertObjectPropsToCamelCase', () => {
+ it('returns new object with camelCase property names by converting object with snake_case names', () => {
+ const snakeRegEx = /(_\w)/g;
+ const mockObj = {
+ id: 1,
+ group_name: 'GitLab.org',
+ absolute_web_url: 'https://gitlab.com/gitlab-org/',
+ };
+ const mappings = {
+ id: 'id',
+ groupName: 'group_name',
+ absoluteWebUrl: 'absolute_web_url',
+ };
+
+ const convertedObj = commonUtils.convertObjectPropsToCamelCase(mockObj);
+
+ Object.keys(convertedObj).forEach((prop) => {
+ expect(snakeRegEx.test(prop)).toBeFalsy();
+ expect(convertedObj[prop]).toBe(mockObj[mappings[prop]]);
+ });
+ });
+
+ it('return empty object if method is called with null or undefined', () => {
+ expect(Object.keys(commonUtils.convertObjectPropsToCamelCase(null)).length).toBe(0);
+ expect(Object.keys(commonUtils.convertObjectPropsToCamelCase()).length).toBe(0);
+ expect(Object.keys(commonUtils.convertObjectPropsToCamelCase({})).length).toBe(0);
+ });
+ });
});
diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js
index a95a7e2a5be..ca0e7c395a0 100644
--- a/spec/javascripts/lib/utils/text_markdown_spec.js
+++ b/spec/javascripts/lib/utils/text_markdown_spec.js
@@ -1,4 +1,4 @@
-import textUtils from '~/lib/utils/text_markdown';
+import { insertMarkdownText } from '~/lib/utils/text_markdown';
describe('init markdown', () => {
let textArea;
@@ -21,7 +21,7 @@ describe('init markdown', () => {
textArea.selectionStart = 0;
textArea.selectionEnd = 0;
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
@@ -32,7 +32,7 @@ describe('init markdown', () => {
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}\n* `);
});
@@ -43,7 +43,7 @@ describe('init markdown', () => {
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
@@ -54,7 +54,7 @@ describe('init markdown', () => {
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js
index 1f46c225071..ae00fb76714 100644
--- a/spec/javascripts/lib/utils/text_utility_spec.js
+++ b/spec/javascripts/lib/utils/text_utility_spec.js
@@ -62,4 +62,30 @@ describe('text_utility', () => {
expect(textUtils.slugify('João')).toEqual('joão');
});
});
+
+ describe('stripHtml', () => {
+ it('replaces html tag with the default replacement', () => {
+ expect(textUtils.stripHtml('This is a text with <p>html</p>.')).toEqual(
+ 'This is a text with html.',
+ );
+ });
+
+ it('replaces html tags with the provided replacement', () => {
+ expect(textUtils.stripHtml('This is a text with <p>html</p>.', ' ')).toEqual(
+ 'This is a text with html .',
+ );
+ });
+ });
+
+ describe('convertToCamelCase', () => {
+ it('converts snake_case string to camelCase string', () => {
+ expect(textUtils.convertToCamelCase('snake_case')).toBe('snakeCase');
+ });
+ });
+
+ describe('convertToSentenceCase', () => {
+ it('converts Sentence Case to Sentence case', () => {
+ expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world');
+ });
+ });
});
diff --git a/spec/javascripts/lib/utils/users_cache_spec.js b/spec/javascripts/lib/utils/users_cache_spec.js
index ec6ea35952b..50371c8c5f6 100644
--- a/spec/javascripts/lib/utils/users_cache_spec.js
+++ b/spec/javascripts/lib/utils/users_cache_spec.js
@@ -92,7 +92,9 @@ describe('UsersCache', () => {
apiSpy = (query, options) => {
expect(query).toBe('');
expect(options).toEqual({ username: dummyUsername });
- return Promise.resolve([dummyUser]);
+ return Promise.resolve({
+ data: [dummyUser],
+ });
};
UsersCache.retrieve(dummyUsername)
diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js
index 89f4b85541d..d2bdc9e160c 100644
--- a/spec/javascripts/line_highlighter_spec.js
+++ b/spec/javascripts/line_highlighter_spec.js
@@ -1,5 +1,6 @@
/* 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 */
+import $ from 'jquery';
import LineHighlighter from '~/line_highlighter';
(function() {
diff --git a/spec/javascripts/matchers.js b/spec/javascripts/matchers.js
new file mode 100644
index 00000000000..7cc5e753c22
--- /dev/null
+++ b/spec/javascripts/matchers.js
@@ -0,0 +1,35 @@
+export default {
+ toHaveSpriteIcon: () => ({
+ compare(element, iconName) {
+ if (!iconName) {
+ throw new Error('toHaveSpriteIcon is missing iconName argument!');
+ }
+
+ if (!(element instanceof HTMLElement)) {
+ throw new Error(`${element} is not a DOM element!`);
+ }
+
+ const iconReferences = [].slice.apply(element.querySelectorAll('svg use'));
+ const matchingIcon = iconReferences.find(reference => reference.getAttribute('xlink:href').endsWith(`#${iconName}`));
+ const result = {
+ pass: !!matchingIcon,
+ };
+
+ if (result.pass) {
+ result.message = `${element.outerHTML} contains the sprite icon "${iconName}"!`;
+ } else {
+ result.message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`;
+
+ const existingIcons = iconReferences.map((reference) => {
+ const iconUrl = reference.getAttribute('xlink:href');
+ return `"${iconUrl.replace(/^.+#/, '')}"`;
+ });
+ if (existingIcons.length > 0) {
+ result.message += ` (only found ${existingIcons.join(',')})`;
+ }
+ }
+
+ return result;
+ },
+ }),
+};
diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js
index e983e4de3fc..dc9dc4d4249 100644
--- a/spec/javascripts/merge_request_notes_spec.js
+++ b/spec/javascripts/merge_request_notes_spec.js
@@ -1,8 +1,9 @@
+import $ from 'jquery';
+import _ from 'underscore';
import 'autosize';
import '~/gl_form';
import '~/lib/utils/text_utility';
-import '~/render_gfm';
-import '~/render_math';
+import '~/behaviors/markdown/render_gfm';
import Notes from '~/notes';
const upArrowKeyCode = 38;
@@ -22,7 +23,7 @@ describe('Merge request notes', () => {
gl.utils.disableButtonIfEmptyField = _.noop;
window.project_uploads_path = 'http://test.host/uploads';
$('body').attr('data-page', 'projects:merge_requests:show');
- window.gon.current_user_id = $('.note:last').data('author-id');
+ window.gon.current_user_id = $('.note:last').data('authorId');
return new Notes('', []);
});
@@ -75,7 +76,7 @@ describe('Merge request notes', () => {
</form>`;
setFixtures(diffsResponse.html + noteFormHtml);
$('body').attr('data-page', 'projects:merge_requests:show');
- window.gon.current_user_id = $('.note:last').data('author-id');
+ window.gon.current_user_id = $('.note:last').data('authorId');
return new Notes('', []);
});
diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js
index 2f02c11482f..74ceff76d37 100644
--- a/spec/javascripts/merge_request_spec.js
+++ b/spec/javascripts/merge_request_spec.js
@@ -1,5 +1,8 @@
/* eslint-disable space-before-function-paren, no-return-assign */
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import MergeRequest from '~/merge_request';
import CloseReopenReportToggle from '~/close_reopen_report_toggle';
import IssuablesHelper from '~/helpers/issuables_helper';
@@ -7,29 +10,49 @@ import IssuablesHelper from '~/helpers/issuables_helper';
(function() {
describe('MergeRequest', function() {
describe('task lists', function() {
+ let mock;
+
preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
beforeEach(function() {
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
+
+ spyOn(axios, 'patch').and.callThrough();
+ mock = new MockAdapter(axios);
+
+ mock.onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`).reply(200, {});
+
return this.merge = new MergeRequest();
});
+
+ afterEach(() => {
+ mock.restore();
+ });
+
it('modifies the Markdown field', function() {
- spyOn(jQuery, 'ajax').and.stub();
+ spyOn($, 'ajax').and.stub();
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() {
- spyOn(jQuery, 'ajax').and.callFake(function(req) {
- expect(req.type).toBe('PATCH');
- expect(req.url).toBe(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`);
- return expect(req.data.merge_request.description).not.toBe(null);
+
+ it('submits an ajax request on tasklist:changed', (done) => {
+ $('.js-task-list-field').trigger('tasklist:changed');
+
+ setTimeout(() => {
+ expect(axios.patch).toHaveBeenCalledWith(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, {
+ merge_request: { description: '- [ ] Task List Item' },
+ });
+ done();
});
- return $('.js-task-list-field').trigger('tasklist:changed');
});
});
describe('class constructor', () => {
+ beforeEach(() => {
+ spyOn($, 'ajax').and.stub();
+ });
+
it('calls .initCloseReopenReport', () => {
spyOn(IssuablesHelper, 'initCloseReopenReport');
@@ -63,8 +86,8 @@ import IssuablesHelper from '~/helpers/issuables_helper';
beforeEach(() => {
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
this.el = document.querySelector('.js-issuable-actions');
- const merge = new MergeRequest();
- merge.hideCloseButton();
+ new MergeRequest(); // eslint-disable-line no-new
+ MergeRequest.hideCloseButton();
});
it('hides the dropdown close item and selects the next item', () => {
@@ -83,8 +106,7 @@ import IssuablesHelper from '~/helpers/issuables_helper';
beforeEach(() => {
loadFixtures('merge_requests/merge_request_of_current_user.html.raw');
this.el = document.querySelector('.js-issuable-actions');
- const merge = new MergeRequest();
- merge.hideCloseButton();
+ MergeRequest.hideCloseButton();
});
it('hides the close button', () => {
diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js
index a6be474805b..79c8cf0ba32 100644
--- a/spec/javascripts/merge_request_tabs_spec.js
+++ b/spec/javascripts/merge_request_tabs_spec.js
@@ -1,5 +1,8 @@
/* eslint-disable no-var, comma-dangle, object-shorthand */
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import MergeRequestTabs from '~/merge_request_tabs';
import '~/commit/pipelines/pipelines_bundle';
@@ -46,7 +49,7 @@ import 'vendor/jquery.scrollTo';
describe('activateTab', function () {
beforeEach(function () {
- spyOn($, 'ajax').and.callFake(function () {});
+ spyOn(axios, 'get').and.returnValue(Promise.resolve({ data: {} }));
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
this.subject = this.class.activateTab;
});
@@ -148,7 +151,7 @@ import 'vendor/jquery.scrollTo';
describe('setCurrentAction', function () {
beforeEach(function () {
- spyOn($, 'ajax').and.callFake(function () {});
+ spyOn(axios, 'get').and.returnValue(Promise.resolve({ data: {} }));
this.subject = this.class.setCurrentAction;
});
@@ -214,13 +217,21 @@ import 'vendor/jquery.scrollTo';
});
describe('tabShown', () => {
+ let mock;
+
beforeEach(function () {
- spyOn($, 'ajax').and.callFake(function (options) {
- options.success({ html: '' });
+ mock = new MockAdapter(axios);
+ mock.onGet(/(.*)\/diffs\.json/).reply(200, {
+ data: { html: '' },
});
+
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
});
+ afterEach(() => {
+ mock.restore();
+ });
+
describe('with "Side-by-side"/parallel diff view', () => {
beforeEach(function () {
this.class.diffViewType = () => 'parallel';
@@ -292,16 +303,20 @@ import 'vendor/jquery.scrollTo';
it('triggers Ajax request to JSON endpoint', function (done) {
const url = '/foo/bar/merge_requests/1/diffs';
- spyOn(this.class, 'ajaxGet').and.callFake((options) => {
- expect(options.url).toEqual(`${url}.json`);
+
+ spyOn(axios, 'get').and.callFake((reqUrl) => {
+ expect(reqUrl).toBe(`${url}.json`);
+
done();
+
+ return Promise.resolve({ data: {} });
});
this.class.loadDiff(url);
});
it('triggers scroll event when diff already loaded', function (done) {
- spyOn(this.class, 'ajaxGet').and.callFake(() => done.fail());
+ spyOn(axios, 'get').and.callFake(done.fail);
spyOn(document, 'dispatchEvent');
this.class.diffsLoaded = true;
@@ -316,6 +331,7 @@ import 'vendor/jquery.scrollTo';
describe('with inline diff', () => {
let noteId;
let noteLineNumId;
+ let mock;
beforeEach(() => {
const diffsResponse = getJSONFixture(inlineChangesTabJsonFixture);
@@ -330,29 +346,40 @@ import 'vendor/jquery.scrollTo';
.attr('href')
.replace('#', '');
- spyOn($, 'ajax').and.callFake(function (options) {
- options.success(diffsResponse);
- });
+ mock = new MockAdapter(axios);
+ mock.onGet(/(.*)\/diffs\.json/).reply(200, diffsResponse);
+ });
+
+ afterEach(() => {
+ mock.restore();
});
describe('with note fragment hash', () => {
- it('should expand and scroll to linked fragment hash #note_xxx', function () {
+ it('should expand and scroll to linked fragment hash #note_xxx', function (done) {
spyOn(urlUtils, 'getLocationHash').and.returnValue(noteId);
this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
- expect(noteId.length).toBeGreaterThan(0);
- expect(Notes.instance.toggleDiffNote).toHaveBeenCalledWith({
- target: jasmine.any(Object),
- lineType: 'old',
- forceShow: true,
+ setTimeout(() => {
+ expect(noteId.length).toBeGreaterThan(0);
+ expect(Notes.instance.toggleDiffNote).toHaveBeenCalledWith({
+ target: jasmine.any(Object),
+ lineType: 'old',
+ forceShow: true,
+ });
+
+ done();
});
});
- it('should gracefully ignore non-existant fragment hash', function () {
+ it('should gracefully ignore non-existant fragment hash', function (done) {
spyOn(urlUtils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist');
this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
- expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled();
+ setTimeout(() => {
+ expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled();
+
+ done();
+ });
});
});
@@ -370,6 +397,7 @@ import 'vendor/jquery.scrollTo';
describe('with parallel diff', () => {
let noteId;
let noteLineNumId;
+ let mock;
beforeEach(() => {
const diffsResponse = getJSONFixture(parallelChangesTabJsonFixture);
@@ -384,30 +412,40 @@ import 'vendor/jquery.scrollTo';
.attr('href')
.replace('#', '');
- spyOn($, 'ajax').and.callFake(function (options) {
- options.success(diffsResponse);
- });
+ mock = new MockAdapter(axios);
+ mock.onGet(/(.*)\/diffs\.json/).reply(200, diffsResponse);
+ });
+
+ afterEach(() => {
+ mock.restore();
});
describe('with note fragment hash', () => {
- it('should expand and scroll to linked fragment hash #note_xxx', function () {
+ it('should expand and scroll to linked fragment hash #note_xxx', function (done) {
spyOn(urlUtils, 'getLocationHash').and.returnValue(noteId);
this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
- expect(noteId.length).toBeGreaterThan(0);
- expect(Notes.instance.toggleDiffNote).toHaveBeenCalledWith({
- target: jasmine.any(Object),
- lineType: 'new',
- forceShow: true,
+ setTimeout(() => {
+ expect(noteId.length).toBeGreaterThan(0);
+ expect(Notes.instance.toggleDiffNote).toHaveBeenCalledWith({
+ target: jasmine.any(Object),
+ lineType: 'new',
+ forceShow: true,
+ });
+
+ done();
});
});
- it('should gracefully ignore non-existant fragment hash', function () {
+ it('should gracefully ignore non-existant fragment hash', function (done) {
spyOn(urlUtils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist');
this.class.loadDiff('/foo/bar/merge_requests/1/diffs');
- expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled();
+ setTimeout(() => {
+ expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled();
+ done();
+ });
});
});
diff --git a/spec/javascripts/mini_pipeline_graph_dropdown_spec.js b/spec/javascripts/mini_pipeline_graph_dropdown_spec.js
index 481b46c3ac6..009b3fd75b7 100644
--- a/spec/javascripts/mini_pipeline_graph_dropdown_spec.js
+++ b/spec/javascripts/mini_pipeline_graph_dropdown_spec.js
@@ -1,7 +1,10 @@
/* eslint-disable no-new */
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
-import '~/flash';
+import timeoutPromise from './helpers/set_timeout_promise_helper';
describe('Mini Pipeline Graph Dropdown', () => {
preloadFixtures('static/mini_dropdown_graph.html.raw');
@@ -27,6 +30,16 @@ describe('Mini Pipeline Graph Dropdown', () => {
});
describe('When dropdown is clicked', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
it('should call getBuildsList', () => {
const getBuildsListSpy = spyOn(
MiniPipelineGraph.prototype,
@@ -41,46 +54,55 @@ describe('Mini Pipeline Graph Dropdown', () => {
});
it('should make a request to the endpoint provided in the html', () => {
- const ajaxSpy = spyOn($, 'ajax').and.callFake(function () {});
+ const ajaxSpy = spyOn(axios, 'get').and.callThrough();
+
+ mock.onGet('foobar').reply(200, {
+ html: '',
+ });
new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents();
document.querySelector('.js-builds-dropdown-button').click();
- expect(ajaxSpy.calls.allArgs()[0][0].url).toEqual('foobar');
+ expect(ajaxSpy.calls.allArgs()[0][0]).toEqual('foobar');
});
- it('should not close when user uses cmd/ctrl + click', () => {
- spyOn($, 'ajax').and.callFake(function (params) {
- params.success({
- html: `<li>
- <a class="mini-pipeline-graph-dropdown-item" href="#">
- <span class="ci-status-icon ci-status-icon-failed"></span>
- <span class="ci-build-text">build</span>
- </a>
- <a class="ci-action-icon-wrapper js-ci-action-icon" href="#"></a>
- </li>`,
- });
+ it('should not close when user uses cmd/ctrl + click', (done) => {
+ mock.onGet('foobar').reply(200, {
+ html: `<li>
+ <a class="mini-pipeline-graph-dropdown-item" href="#">
+ <span class="ci-status-icon ci-status-icon-failed"></span>
+ <span class="ci-build-text">build</span>
+ </a>
+ <a class="ci-action-icon-wrapper js-ci-action-icon" href="#"></a>
+ </li>`,
});
new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents();
document.querySelector('.js-builds-dropdown-button').click();
- document.querySelector('a.mini-pipeline-graph-dropdown-item').click();
-
- expect($('.js-builds-dropdown-list').is(':visible')).toEqual(true);
+ timeoutPromise()
+ .then(() => {
+ document.querySelector('a.mini-pipeline-graph-dropdown-item').click();
+ })
+ .then(timeoutPromise)
+ .then(() => {
+ expect($('.js-builds-dropdown-list').is(':visible')).toEqual(true);
+ })
+ .then(done)
+ .catch(done.fail);
});
- });
- it('should close the dropdown when request returns an error', (done) => {
- spyOn($, 'ajax').and.callFake(options => options.error());
+ it('should close the dropdown when request returns an error', (done) => {
+ mock.onGet('foobar').networkError();
- new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents();
+ new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents();
- document.querySelector('.js-builds-dropdown-button').click();
+ document.querySelector('.js-builds-dropdown-button').click();
- setTimeout(() => {
- expect($('.js-builds-dropdown-tests .dropdown').hasClass('open')).toEqual(false);
- done();
- }, 0);
+ setTimeout(() => {
+ expect($('.js-builds-dropdown-tests .dropdown').hasClass('open')).toEqual(false);
+ done();
+ });
+ });
});
});
diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js
index 9885b8a790f..eba6dcf47c5 100644
--- a/spec/javascripts/monitoring/dashboard_spec.js
+++ b/spec/javascripts/monitoring/dashboard_spec.js
@@ -5,24 +5,36 @@ import axios from '~/lib/utils/axios_utils';
import { metricsGroupsAPIResponse, mockApiEndpoint } from './mock_data';
describe('Dashboard', () => {
- const fixtureName = 'environments/metrics/metrics.html.raw';
let DashboardComponent;
- let component;
- preloadFixtures(fixtureName);
+
+ const propsData = {
+ hasMetrics: false,
+ documentationPath: '/path/to/docs',
+ settingsPath: '/path/to/settings',
+ clustersPath: '/path/to/clusters',
+ tagsPath: '/path/to/tags',
+ projectPath: '/path/to/project',
+ metricsEndpoint: mockApiEndpoint,
+ deploymentEndpoint: null,
+ emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
+ emptyLoadingSvgPath: '/path/to/loading.svg',
+ emptyNoDataSvgPath: '/path/to/no-data.svg',
+ emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
+ };
beforeEach(() => {
- loadFixtures(fixtureName);
+ setFixtures('<div class="prometheus-graphs"></div>');
DashboardComponent = Vue.extend(Dashboard);
});
describe('no metrics are available yet', () => {
it('shows a getting started empty state when no metrics are present', () => {
- component = new DashboardComponent({
- el: document.querySelector('#prometheus-graphs'),
+ const component = new DashboardComponent({
+ el: document.querySelector('.prometheus-graphs'),
+ propsData,
});
- component.$mount();
- expect(component.$el.querySelector('#prometheus-graphs')).toBe(null);
+ expect(component.$el.querySelector('.prometheus-graphs')).toBe(null);
expect(component.state).toEqual('gettingStarted');
});
});
@@ -30,26 +42,52 @@ describe('Dashboard', () => {
describe('requests information to the server', () => {
let mock;
beforeEach(() => {
- document.querySelector('#prometheus-graphs').setAttribute('data-has-metrics', 'true');
mock = new MockAdapter(axios);
- mock.onGet(mockApiEndpoint).reply(200, {
- metricsGroupsAPIResponse,
- });
+ mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
});
afterEach(() => {
- mock.reset();
+ mock.restore();
});
it('shows up a loading state', (done) => {
- component = new DashboardComponent({
- el: document.querySelector('#prometheus-graphs'),
+ const component = new DashboardComponent({
+ el: document.querySelector('.prometheus-graphs'),
+ propsData: { ...propsData, hasMetrics: true },
});
- component.$mount();
+
Vue.nextTick(() => {
expect(component.state).toEqual('loading');
done();
});
});
+
+ it('hides the legend when showLegend is false', (done) => {
+ const component = new DashboardComponent({
+ el: document.querySelector('.prometheus-graphs'),
+ propsData: { ...propsData, hasMetrics: true, showLegend: false },
+ });
+
+ setTimeout(() => {
+ expect(component.showEmptyState).toEqual(false);
+ expect(component.$el.querySelector('.legend-group')).toEqual(null);
+ expect(component.$el.querySelector('.prometheus-graph-group')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('hides the group panels when showPanels is false', (done) => {
+ const component = new DashboardComponent({
+ el: document.querySelector('.prometheus-graphs'),
+ propsData: { ...propsData, hasMetrics: true, showPanels: false },
+ });
+
+ setTimeout(() => {
+ expect(component.showEmptyState).toEqual(false);
+ expect(component.$el.querySelector('.prometheus-panel')).toEqual(null);
+ expect(component.$el.querySelector('.prometheus-graph-group')).toBeTruthy();
+ done();
+ });
+ });
});
});
diff --git a/spec/javascripts/monitoring/dashboard_state_spec.js b/spec/javascripts/monitoring/dashboard_state_spec.js
index 3319eeb3f31..b4c5f4baa78 100644
--- a/spec/javascripts/monitoring/dashboard_state_spec.js
+++ b/spec/javascripts/monitoring/dashboard_state_spec.js
@@ -2,13 +2,22 @@ import Vue from 'vue';
import EmptyState from '~/monitoring/components/empty_state.vue';
import { statePaths } from './mock_data';
-const createComponent = (propsData) => {
+function createComponent(props) {
const Component = Vue.extend(EmptyState);
return new Component({
- propsData,
+ propsData: {
+ ...props,
+ settingsPath: statePaths.settingsPath,
+ clustersPath: statePaths.clustersPath,
+ documentationPath: statePaths.documentationPath,
+ emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
+ emptyLoadingSvgPath: '/path/to/loading.svg',
+ emptyNoDataSvgPath: '/path/to/no-data.svg',
+ emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
+ },
}).$mount();
-};
+}
function getTextFromNode(component, selector) {
return component.$el.querySelector(selector).firstChild.nodeValue.trim();
@@ -19,52 +28,14 @@ describe('EmptyState', () => {
it('currentState', () => {
const component = createComponent({
selectedState: 'gettingStarted',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
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,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
- });
-
- 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,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
- });
-
- 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,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.showButtonDescription).toEqual(true);
@@ -73,11 +44,6 @@ describe('EmptyState', () => {
it('showButtonDescription returns the description without a link for any other state', () => {
const component = createComponent({
selectedState: 'loading',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.showButtonDescription).toEqual(false);
@@ -87,11 +53,6 @@ describe('EmptyState', () => {
it('should show the gettingStarted state', () => {
const component = createComponent({
selectedState: 'gettingStarted',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.$el.querySelector('svg')).toBeDefined();
@@ -103,11 +64,6 @@ describe('EmptyState', () => {
it('should show the loading state', () => {
const component = createComponent({
selectedState: 'loading',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.$el.querySelector('svg')).toBeDefined();
@@ -119,11 +75,6 @@ describe('EmptyState', () => {
it('should show the unableToConnect state', () => {
const component = createComponent({
selectedState: 'unableToConnect',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.$el.querySelector('svg')).toBeDefined();
diff --git a/spec/javascripts/monitoring/graph/axis_spec.js b/spec/javascripts/monitoring/graph/axis_spec.js
new file mode 100644
index 00000000000..c7adba00637
--- /dev/null
+++ b/spec/javascripts/monitoring/graph/axis_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import GraphAxis from '~/monitoring/components/graph/axis.vue';
+import measurements from '~/monitoring/utils/measurements';
+
+const createComponent = propsData => {
+ const Component = Vue.extend(GraphAxis);
+
+ return new Component({
+ propsData,
+ }).$mount();
+};
+
+const defaultValuesComponent = {
+ graphWidth: 500,
+ graphHeight: 300,
+ graphHeightOffset: 120,
+ margin: measurements.large.margin,
+ measurements: measurements.large,
+ yAxisLabel: 'Values',
+ unitOfDisplay: 'MB',
+};
+
+function getTextFromNode(component, selector) {
+ return component.$el.querySelector(selector).firstChild.nodeValue.trim();
+}
+
+describe('Axis', () => {
+ describe('Computed props', () => {
+ it('textTransform', () => {
+ const component = createComponent(defaultValuesComponent);
+
+ expect(component.textTransform).toContain('translate(15, 120) rotate(-90)');
+ });
+
+ it('xPosition', () => {
+ const component = createComponent(defaultValuesComponent);
+
+ expect(component.xPosition).toEqual(180);
+ });
+
+ it('yPosition', () => {
+ const component = createComponent(defaultValuesComponent);
+
+ expect(component.yPosition).toEqual(240);
+ });
+
+ it('rectTransform', () => {
+ const component = createComponent(defaultValuesComponent);
+
+ expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)');
+ });
+ });
+
+ it('has 2 rect-axis-text rect svg elements', () => {
+ const component = createComponent(defaultValuesComponent);
+
+ expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2);
+ });
+
+ it('contains text to signal the usage, title and time with multiple time series', () => {
+ const component = createComponent(defaultValuesComponent);
+
+ expect(getTextFromNode(component, '.y-label-text')).toEqual('Values (MB)');
+ });
+});
diff --git a/spec/javascripts/monitoring/graph/deployment_spec.js b/spec/javascripts/monitoring/graph/deployment_spec.js
index bf6ada8185e..d07db871d69 100644
--- a/spec/javascripts/monitoring/graph/deployment_spec.js
+++ b/spec/javascripts/monitoring/graph/deployment_spec.js
@@ -11,168 +11,38 @@ const createComponent = (propsData) => {
};
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,
- graphWidth: 440,
- 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,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(
- component.refText(reducedDeploymentData[0]),
- ).toContain('f5bcd1');
- });
-
- it('nameDeploymentClass creates a class with the prefix deploy-info-', () => {
+ it('should contain a hidden gradient', () => {
const component = createComponent({
- showDeployInfo: false,
- deploymentData: reducedDeploymentData,
+ showDeployInfo: true,
+ deploymentData,
graphHeight: 300,
graphWidth: 440,
graphHeightOffset: 120,
});
- expect(
- component.nameDeploymentClass(reducedDeploymentData[0]),
- ).toContain('deploy-info');
+ expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull();
});
it('transformDeploymentGroup translates an available deployment', () => {
const component = createComponent({
showDeployInfo: false,
- deploymentData: reducedDeploymentData,
+ deploymentData,
graphHeight: 300,
graphWidth: 440,
graphHeightOffset: 120,
});
expect(
- component.transformDeploymentGroup(reducedDeploymentData[0]),
+ component.transformDeploymentGroup({ xPos: 16 }),
).toContain('translate(11, 20)');
});
- it('hides the deployment flag', () => {
- reducedDeploymentData[0].showDeploymentFlag = false;
- const component = createComponent({
- showDeployInfo: true,
- deploymentData: reducedDeploymentData,
- graphWidth: 440,
- graphHeight: 300,
- graphHeightOffset: 120,
- });
-
- expect(component.$el.querySelector('.js-deploy-info-box')).toBeNull();
- });
-
- it('positions the flag to the left when the xPos is too far right', () => {
- reducedDeploymentData[0].showDeploymentFlag = false;
- reducedDeploymentData[0].xPos = 250;
- const component = createComponent({
- showDeployInfo: true,
- deploymentData: reducedDeploymentData,
- graphWidth: 440,
- graphHeight: 300,
- graphHeightOffset: 120,
- });
-
- expect(
- component.positionFlag(reducedDeploymentData[0]),
- ).toBeLessThan(0);
- });
-
- it('shows the deployment flag', () => {
- reducedDeploymentData[0].showDeploymentFlag = true;
- const component = createComponent({
- showDeployInfo: true,
- deploymentData: reducedDeploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(
- component.$el.querySelector('.js-deploy-info-box').style.display,
- ).not.toEqual('display: none;');
- });
-
- it('contains date, refs and the "deployed" text', () => {
- reducedDeploymentData[0].showDeploymentFlag = true;
- const component = createComponent({
- showDeployInfo: true,
- deploymentData: reducedDeploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(
- component.$el.querySelectorAll('.deploy-info-text'),
- ).toContainText('Deployed');
-
- expect(
- component.$el.querySelectorAll('.deploy-info-text'),
- ).toContainText('Wed, May 31');
-
- expect(
- component.$el.querySelectorAll('.deploy-info-text'),
- ).toContainText(component.refText(reducedDeploymentData[0]));
- });
-
- it('contains a link to the commit contents', () => {
- reducedDeploymentData[0].showDeploymentFlag = true;
- const component = createComponent({
- showDeployInfo: true,
- deploymentData: reducedDeploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(
- component.$el.querySelectorAll('.deploy-info-text-link')[0].parentElement.getAttribute('xlink:href'),
- ).not.toEqual('');
- });
-
- it('should contain a hidden gradient', () => {
- const component = createComponent({
- showDeployInfo: true,
- deploymentData: reducedDeploymentData,
- graphHeight: 300,
- graphWidth: 440,
- graphHeightOffset: 120,
- });
-
- expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull();
- });
-
describe('Computed props', () => {
it('calculatedHeight', () => {
const component = createComponent({
showDeployInfo: true,
- deploymentData: reducedDeploymentData,
+ deploymentData,
graphHeight: 300,
graphWidth: 440,
graphHeightOffset: 120,
diff --git a/spec/javascripts/monitoring/graph/flag_spec.js b/spec/javascripts/monitoring/graph/flag_spec.js
index 8ee1171419d..2d474e9092f 100644
--- a/spec/javascripts/monitoring/graph/flag_spec.js
+++ b/spec/javascripts/monitoring/graph/flag_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import GraphFlag from '~/monitoring/components/graph/flag.vue';
+import { deploymentData } from '../mock_data';
const createComponent = (propsData) => {
const Component = Vue.extend(GraphFlag);
@@ -9,11 +10,6 @@ const createComponent = (propsData) => {
}).$mount();
};
-function getCoordinate(component, selector, coordinate) {
- const coordinateVal = component.$el.querySelector(selector).getAttribute(coordinate);
- return parseInt(coordinateVal, 10);
-}
-
const defaultValuesComponent = {
currentXCoordinate: 200,
currentYCoordinate: 100,
@@ -25,31 +21,111 @@ const defaultValuesComponent = {
graphHeight: 300,
graphHeightOffset: 120,
showFlagContent: true,
+ realPixelRatio: 1,
+ timeSeries: [{
+ values: [{
+ time: new Date('2017-06-04T18:17:33.501Z'),
+ value: '1.49609375',
+ }],
+ }],
+ unitOfDisplay: 'ms',
+ currentDataIndex: 0,
+ legendTitle: 'Average',
+};
+
+const deploymentFlagData = {
+ ...deploymentData[0],
+ ref: deploymentData[0].ref.name,
+ xPos: 10,
+ time: new Date(deploymentData[0].created_at),
};
describe('GraphFlag', () => {
- it('has a line and a circle located at the currentXCoordinate and currentYCoordinate', () => {
- const component = createComponent(defaultValuesComponent);
+ let component;
- expect(getCoordinate(component, '.selected-metric-line', 'x1'))
- .toEqual(component.currentXCoordinate);
- expect(getCoordinate(component, '.selected-metric-line', 'x2'))
- .toEqual(component.currentXCoordinate);
+ it('has a line at the currentXCoordinate', () => {
+ component = createComponent(defaultValuesComponent);
+
+ expect(component.$el.style.left)
+ .toEqual(`${70 + component.currentXCoordinate}px`);
});
- it('has a SVG with the class rect-text-metric at the currentFlagPosition', () => {
- const component = createComponent(defaultValuesComponent);
+ describe('Deployment flag', () => {
+ it('shows a deployment flag when deployment data provided', () => {
+ const deploymentFlagComponent = createComponent({
+ ...defaultValuesComponent,
+ deploymentFlagData,
+ });
+
+ expect(
+ deploymentFlagComponent.$el.querySelector('.popover-title'),
+ ).toContainText('Deployed');
+ });
+
+ it('contains the ref when a tag is available', () => {
+ const deploymentFlagComponent = createComponent({
+ ...defaultValuesComponent,
+ deploymentFlagData: {
+ ...deploymentFlagData,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ tag: true,
+ ref: '1.0',
+ },
+ });
+
+ expect(
+ deploymentFlagComponent.$el.querySelector('.deploy-meta-content'),
+ ).toContainText('f5bcd1d9');
+
+ expect(
+ deploymentFlagComponent.$el.querySelector('.deploy-meta-content'),
+ ).toContainText('1.0');
+ });
+
+ it('does not contain the ref when a tag is unavailable', () => {
+ const deploymentFlagComponent = createComponent({
+ ...defaultValuesComponent,
+ deploymentFlagData: {
+ ...deploymentFlagData,
+ sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ tag: false,
+ ref: '1.0',
+ },
+ });
+
+ expect(
+ deploymentFlagComponent.$el.querySelector('.deploy-meta-content'),
+ ).toContainText('f5bcd1d9');
- const svg = component.$el.querySelector('.rect-text-metric');
- expect(svg.tagName).toEqual('svg');
- expect(parseInt(svg.getAttribute('x'), 10)).toEqual(component.currentFlagPosition);
+ expect(
+ deploymentFlagComponent.$el.querySelector('.deploy-meta-content'),
+ ).not.toContainText('1.0');
+ });
});
describe('Computed props', () => {
- it('calculatedHeight', () => {
- const component = createComponent(defaultValuesComponent);
+ beforeEach(() => {
+ component = createComponent(defaultValuesComponent);
+ });
+
+ it('formatTime', () => {
+ expect(component.formatTime).toMatch(/\d:17PM/);
+ });
+
+ it('formatDate', () => {
+ expect(component.formatDate).toEqual('Sun, Jun 4');
+ });
+
+ it('cursorStyle', () => {
+ expect(component.cursorStyle).toEqual({
+ top: '20px',
+ left: '270px',
+ height: '180px',
+ });
+ });
- expect(component.calculatedHeight).toEqual(180);
+ it('flagOrientation', () => {
+ expect(component.flagOrientation).toEqual('left');
});
});
});
diff --git a/spec/javascripts/monitoring/graph/legend_spec.js b/spec/javascripts/monitoring/graph/legend_spec.js
index 145c8db28d5..abcc51aa077 100644
--- a/spec/javascripts/monitoring/graph/legend_spec.js
+++ b/spec/javascripts/monitoring/graph/legend_spec.js
@@ -1,106 +1,44 @@
import Vue from 'vue';
import GraphLegend from '~/monitoring/components/graph/legend.vue';
-import measurements from '~/monitoring/utils/measurements';
import createTimeSeries from '~/monitoring/utils/multiple_time_series';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
-const createComponent = (propsData) => {
- const Component = Vue.extend(GraphLegend);
-
- return new Component({
- propsData,
- }).$mount();
-};
-
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
-const defaultValuesComponent = {
- graphWidth: 500,
- graphHeight: 300,
- graphHeightOffset: 120,
- margin: measurements.large.margin,
- measurements: measurements.large,
- areaColorRgb: '#f0f0f0',
- legendTitle: 'Title',
- yAxisLabel: 'Values',
- metricUsage: 'Value',
- unitOfDisplay: 'Req/Sec',
- currentDataIndex: 0,
-};
+const defaultValuesComponent = {};
-const timeSeries = createTimeSeries(convertedMetrics[0].queries,
- defaultValuesComponent.graphWidth, defaultValuesComponent.graphHeight,
- defaultValuesComponent.graphHeightOffset);
+const timeSeries = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
defaultValuesComponent.timeSeries = timeSeries;
-function getTextFromNode(component, selector) {
- return component.$el.querySelector(selector).firstChild.nodeValue.trim();
-}
-
-describe('GraphLegend', () => {
- describe('Computed props', () => {
- it('textTransform', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.textTransform).toContain('translate(15, 120) rotate(-90)');
- });
-
- it('xPosition', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.xPosition).toEqual(180);
- });
-
- it('yPosition', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.yPosition).toEqual(240);
- });
-
- it('rectTransform', () => {
- const component = createComponent(defaultValuesComponent);
+describe('Legend Component', () => {
+ let vm;
+ let Legend;
- expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)');
- });
+ beforeEach(() => {
+ Legend = Vue.extend(GraphLegend);
});
- describe('methods', () => {
- it('translateLegendGroup should only change Y direction', () => {
- const component = createComponent(defaultValuesComponent);
-
- const translatedCoordinate = component.translateLegendGroup(1);
- expect(translatedCoordinate.indexOf('translate(0, ')).not.toEqual(-1);
+ describe('View', () => {
+ beforeEach(() => {
+ vm = mountComponent(Legend, {
+ legendTitle: 'legend',
+ timeSeries,
+ currentDataIndex: 0,
+ unitOfDisplay: 'Req/Sec',
+ });
});
- it('formatMetricUsage should contain the unit of display and the current value selected via "currentDataIndex"', () => {
- const component = createComponent(defaultValuesComponent);
+ it('should render the usage, title and time with multiple time series', () => {
+ const titles = vm.$el.querySelectorAll('.legend-metric-title');
- const formattedMetricUsage = component.formatMetricUsage(timeSeries[0]);
- const valueFromSeries = timeSeries[0].values[component.currentDataIndex].value;
- expect(formattedMetricUsage.indexOf(component.unitOfDisplay)).not.toEqual(-1);
- expect(formattedMetricUsage.indexOf(valueFromSeries)).not.toEqual(-1);
+ expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1);
+ expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1);
});
- });
-
- it('has 2 rect-axis-text rect svg elements', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2);
- });
- it('contains text to signal the usage, title and time with multiple time series', () => {
- const component = createComponent(defaultValuesComponent);
- const titles = component.$el.querySelectorAll('.legend-metric-title');
-
- expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1);
- expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1);
- expect(getTextFromNode(component, '.y-label-text')).toEqual(component.yAxisLabel);
- });
-
- it('should contain the same number of legend groups as the timeSeries length', () => {
- const component = createComponent(defaultValuesComponent);
-
- expect(component.$el.querySelectorAll('.legend-group').length).toEqual(component.timeSeries.length);
+ it('should container the same number of rows in the table as time series', () => {
+ expect(vm.$el.querySelectorAll('.prometheus-table tr').length).toEqual(vm.timeSeries.length);
+ });
});
});
diff --git a/spec/javascripts/monitoring/graph/track_info_spec.js b/spec/javascripts/monitoring/graph/track_info_spec.js
new file mode 100644
index 00000000000..d3121d553f9
--- /dev/null
+++ b/spec/javascripts/monitoring/graph/track_info_spec.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import TrackInfo from '~/monitoring/components/graph/track_info.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import createTimeSeries from '~/monitoring/utils/multiple_time_series';
+import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
+
+const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
+const timeSeries = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
+
+describe('TrackInfo component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(TrackInfo);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('Computed props', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, { track: timeSeries[0] });
+ });
+
+ it('summaryMetrics', () => {
+ expect(vm.summaryMetrics).toEqual('Avg: 0.000 · Max: 0.000');
+ });
+ });
+
+ describe('Rendered output', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, { track: timeSeries[0] });
+ });
+
+ it('contains metric tag and the summary metrics', () => {
+ const metricTag = vm.$el.querySelector('strong');
+
+ expect(metricTag.textContent.trim()).toEqual(vm.track.metricTag);
+ expect(vm.$el.textContent).toContain('Avg: 0.000 · Max: 0.000');
+ });
+ });
+});
diff --git a/spec/javascripts/monitoring/graph/track_line_spec.js b/spec/javascripts/monitoring/graph/track_line_spec.js
new file mode 100644
index 00000000000..45106830a67
--- /dev/null
+++ b/spec/javascripts/monitoring/graph/track_line_spec.js
@@ -0,0 +1,52 @@
+import Vue from 'vue';
+import TrackLine from '~/monitoring/components/graph/track_line.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import createTimeSeries from '~/monitoring/utils/multiple_time_series';
+import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
+
+const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
+const timeSeries = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
+
+describe('TrackLine component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(TrackLine);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('Computed props', () => {
+ it('stylizedLine for dashed lineStyles', () => {
+ vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dashed' } });
+
+ expect(vm.stylizedLine).toEqual('6, 3');
+ });
+
+ it('stylizedLine for dotted lineStyles', () => {
+ vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dotted' } });
+
+ expect(vm.stylizedLine).toEqual('3, 3');
+ });
+ });
+
+ describe('Rendered output', () => {
+ it('has an svg with a line', () => {
+ vm = mountComponent(Component, { track: { ...timeSeries[0] } });
+ const svgEl = vm.$el.querySelector('svg');
+ const lineEl = vm.$el.querySelector('svg line');
+
+ expect(svgEl.getAttribute('width')).toEqual('15');
+ expect(svgEl.getAttribute('height')).toEqual('6');
+
+ expect(lineEl.getAttribute('stroke-width')).toEqual('4');
+ expect(lineEl.getAttribute('x1')).toEqual('0');
+ expect(lineEl.getAttribute('x2')).toEqual('15');
+ expect(lineEl.getAttribute('y1')).toEqual('2');
+ expect(lineEl.getAttribute('y2')).toEqual('2');
+ });
+ });
+});
diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js
index b1d69752bad..1213c80ba3a 100644
--- a/spec/javascripts/monitoring/graph_spec.js
+++ b/spec/javascripts/monitoring/graph_spec.js
@@ -2,11 +2,15 @@ import Vue from 'vue';
import Graph from '~/monitoring/components/graph.vue';
import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins';
import eventHub from '~/monitoring/event_hub';
-import { deploymentData, convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from './mock_data';
+import {
+ deploymentData,
+ convertDatesMultipleSeries,
+ singleRowMetricsMultipleSeries,
+} from './mock_data';
const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags';
const projectPath = 'http://test.host/frontend-fixtures/environments-project';
-const createComponent = (propsData) => {
+const createComponent = propsData => {
const Component = Vue.extend(Graph);
return new Component({
@@ -14,7 +18,9 @@ const createComponent = (propsData) => {
}).$mount();
};
-const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
+const convertedMetrics = convertDatesMultipleSeries(
+ singleRowMetricsMultipleSeries,
+);
describe('Graph', () => {
beforeEach(() => {
@@ -31,7 +37,9 @@ describe('Graph', () => {
projectPath,
});
- expect(component.$el.querySelector('.text-center').innerText.trim()).toBe(component.graphData.title);
+ expect(component.$el.querySelector('.text-center').innerText.trim()).toBe(
+ component.graphData.title,
+ );
});
describe('Computed props', () => {
@@ -46,8 +54,9 @@ describe('Graph', () => {
});
const transformedHeight = `${component.graphHeight - 100}`;
- expect(component.axisTransform.indexOf(transformedHeight))
- .not.toEqual(-1);
+ expect(component.axisTransform.indexOf(transformedHeight)).not.toEqual(
+ -1,
+ );
});
it('outerViewBox gets a width and height property based on the DOM size of the element', () => {
@@ -63,11 +72,11 @@ describe('Graph', () => {
const viewBoxArray = component.outerViewBox.split(' ');
expect(typeof component.outerViewBox).toEqual('string');
expect(viewBoxArray[2]).toEqual(component.graphWidth.toString());
- expect(viewBoxArray[3]).toEqual(component.graphHeight.toString());
+ expect(viewBoxArray[3]).toEqual((component.graphHeight - 50).toString());
});
});
- it('sends an event to the eventhub when it has finished resizing', (done) => {
+ it('sends an event to the eventhub when it has finished resizing', done => {
const component = createComponent({
graphData: convertedMetrics[1],
classType: 'col-md-6',
diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js
index 1f4e858e731..50da6da2e07 100644
--- a/spec/javascripts/monitoring/mock_data.js
+++ b/spec/javascripts/monitoring/mock_data.js
@@ -1,2426 +1,647 @@
/* eslint-disable quote-props, indent, comma-dangle */
-const metricsGroupsAPIResponse = {
- 'success': true,
- 'data': [
+export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`;
+
+export const metricsGroupsAPIResponse = {
+ success: true,
+ data: [
{
- 'group': 'Kubernetes',
- 'priority': 1,
- 'metrics': [
- {
- 'title': 'Memory usage',
- 'weight': 1,
- 'queries': [
+ 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: [
{
- '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'
- ]
- ]
- }
- ]
- }
- ]
+ 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': [
+ title: 'CPU usage',
+ weight: 1,
+ queries: [
+ {
+ query_range:
+ 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100',
+ result: [
{
- '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'
- ]
- ]
- }
- ]
- }
- ]
- }
- ]
- }
+ 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'
+ last_update: '2017-05-25T13:18:34.949Z',
};
export default metricsGroupsAPIResponse;
@@ -2430,5887 +651,5891 @@ export const deploymentData = [
id: 111,
iid: 3,
sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
- commitUrl: 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ commitUrl:
+ 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
ref: {
- name: 'master'
+ name: 'master',
},
created_at: '2017-05-31T21:23:37.881Z',
tag: false,
tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false',
- 'last?': true
+ 'last?': true,
},
{
id: 110,
iid: 2,
sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
- commitUrl: 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
+ commitUrl:
+ 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
ref: {
- name: 'master'
+ name: 'master',
},
created_at: '2017-05-30T20:08:04.629Z',
tag: false,
- tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false',
- 'last?': false
+ tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false',
+ 'last?': false,
},
{
id: 109,
iid: 1,
sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2',
- commitUrl: 'http://test.host/frontend-fixtures/environments-project/commit/6511e58faafaa7ad2228990ec57f19d66f7db7c2',
+ commitUrl:
+ 'http://test.host/frontend-fixtures/environments-project/commit/6511e58faafaa7ad2228990ec57f19d66f7db7c2',
ref: {
- name: 'update2-readme'
+ name: 'update2-readme',
},
created_at: '2017-05-30T17:42:38.409Z',
tag: false,
tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false',
- 'last?': false
- }
+ 'last?': false,
+ },
];
export const statePaths = {
settingsPath: '/root/hello-prometheus/services/prometheus/edit',
+ clustersPath: '/root/hello-prometheus/clusters',
documentationPath: '/help/administration/monitoring/prometheus/index.md',
};
export const singleRowMetricsMultipleSeries = [
- {
- 'title': 'Multiple Time Series',
- 'weight': 1,
- 'y_label': 'Request Rates',
- 'queries': [
- {
- 'query_range': 'sum(rate(nginx_responses_total{environment="production"}[2m])) by (status_code)',
- 'label': 'Requests',
- 'unit': 'Req/sec',
- 'result': [
- {
- 'metric': {
- 'status_code': '1xx'
- },
- 'values': [
- {
- 'time': '2017-08-27T11:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T11:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T12:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T13:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T14:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T15:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T16:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T17:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:01:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:02:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:03:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:04:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:05:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:06:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:07:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:08:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:09:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:10:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:11:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:12:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:13:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:14:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:15:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:16:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:17:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:18:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:19:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:20:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:21:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:22:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:23:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:24:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:25:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:26:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:27:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:28:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:29:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:30:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:31:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:32:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:33:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:34:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:35:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:36:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:37:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:38:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:39:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:40:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:41:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:42:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:43:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:44:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:45:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:46:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:47:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:48:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:49:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:50:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:51:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:52:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:53:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:54:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:55:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:56:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:57:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:58:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T18:59:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T19:00:51.462Z',
- 'value': '0'
- },
- {
- 'time': '2017-08-27T19:01:51.462Z',
- 'value': '0'
- }
- ]
- },
- {
- 'metric': {
- 'status_code': '2xx'
- },
- 'values': [
- {
- 'time': '2017-08-27T11:01:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:02:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T11:03:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:04:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:05:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:06:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:07:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:08:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:09:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:10:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:11:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:12:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:13:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:14:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:15:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:16:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:17:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:18:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:19:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:20:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:21:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:22:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:23:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:24:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:25:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:26:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:27:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:28:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:29:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:30:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:31:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:32:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:33:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:34:51.462Z',
- 'value': '1.333320635041571'
- },
- {
- 'time': '2017-08-27T11:35:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:36:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:37:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:38:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:39:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:40:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:41:51.462Z',
- 'value': '1.3333587306424883'
- },
- {
- 'time': '2017-08-27T11:42:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:43:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:44:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:45:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:46:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:47:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:48:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:49:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T11:50:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:51:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:52:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:53:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:54:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:55:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:56:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:57:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T11:58:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T11:59:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:00:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:01:51.462Z',
- 'value': '1.3333460318669703'
- },
- {
- 'time': '2017-08-27T12:02:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:03:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:04:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:05:51.462Z',
- 'value': '1.31427319739812'
- },
- {
- 'time': '2017-08-27T12:06:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:07:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:08:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:09:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:10:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:11:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:12:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:13:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:14:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:15:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:16:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:17:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:18:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:19:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:20:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:21:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:22:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:23:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:24:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:25:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:26:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:27:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:28:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:29:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:30:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:31:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:32:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:33:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:34:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:35:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:36:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:37:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:38:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:39:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:40:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:41:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:42:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:43:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:44:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:45:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:46:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:47:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:48:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:49:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:50:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:51:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:52:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:53:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:54:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T12:55:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:56:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:57:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T12:58:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T12:59:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:00:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:01:51.462Z',
- 'value': '1.295225759754669'
- },
- {
- 'time': '2017-08-27T13:02:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:03:51.462Z',
- 'value': '1.2952627669098458'
- },
- {
- 'time': '2017-08-27T13:04:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:05:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:06:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:07:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:08:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:09:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:10:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:11:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:12:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:13:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:14:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:15:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T13:16:51.462Z',
- 'value': '1.3333587306424883'
- },
- {
- 'time': '2017-08-27T13:17:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:18:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:19:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:20:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:21:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:22:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:23:51.462Z',
- 'value': '1.276190476190476'
- },
- {
- 'time': '2017-08-27T13:24:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T13:25:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:26:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:27:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:28:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:29:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:30:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:31:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:32:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:33:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:34:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:35:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:36:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:37:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:38:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:39:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:40:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:41:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:42:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:43:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:44:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:45:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:46:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T13:47:51.462Z',
- 'value': '1.276190476190476'
- },
- {
- 'time': '2017-08-27T13:48:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:49:51.462Z',
- 'value': '1.295225759754669'
- },
- {
- 'time': '2017-08-27T13:50:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:51:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:52:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:53:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:54:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:55:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:56:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T13:57:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T13:58:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T13:59:51.462Z',
- 'value': '1.295225759754669'
- },
- {
- 'time': '2017-08-27T14:00:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:01:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:02:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:03:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:04:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:05:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:06:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:07:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:08:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:09:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:10:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:11:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:12:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:13:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:14:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:15:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:16:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:17:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:18:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:19:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:20:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:21:51.462Z',
- 'value': '1.3333079369916765'
- },
- {
- 'time': '2017-08-27T14:22:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:23:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:24:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:25:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:26:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:27:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:28:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:29:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:30:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:31:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:32:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:33:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T14:34:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:35:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:36:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:37:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:38:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:39:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:40:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:41:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:42:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:43:51.462Z',
- 'value': '1.276190476190476'
- },
- {
- 'time': '2017-08-27T14:44:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T14:45:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:46:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:47:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:48:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:49:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:50:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:51:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:52:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:53:51.462Z',
- 'value': '1.333320635041571'
- },
- {
- 'time': '2017-08-27T14:54:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:55:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T14:56:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:57:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T14:58:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T14:59:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:00:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:01:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:02:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:03:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:04:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T15:05:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:06:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:07:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:08:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:09:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:10:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:11:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:12:51.462Z',
- 'value': '1.31427319739812'
- },
- {
- 'time': '2017-08-27T15:13:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:14:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:15:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:16:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:17:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:18:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:19:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:20:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:21:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:22:51.462Z',
- 'value': '1.3333460318669703'
- },
- {
- 'time': '2017-08-27T15:23:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:24:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:25:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:26:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:27:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:28:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:29:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:30:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:31:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:32:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:33:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:34:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:35:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:36:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:37:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:38:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:39:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:40:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:41:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:42:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:43:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:44:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:45:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:46:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:47:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:48:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:49:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T15:50:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:51:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:52:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:53:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:54:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:55:51.462Z',
- 'value': '1.3333587306424883'
- },
- {
- 'time': '2017-08-27T15:56:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T15:57:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:58:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T15:59:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:00:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:01:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:02:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:03:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:04:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:05:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:06:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:07:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:08:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:09:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:10:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:11:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:12:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:13:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:14:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:15:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:16:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:17:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:18:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:19:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:20:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:21:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:22:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:23:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:24:51.462Z',
- 'value': '1.295225759754669'
- },
- {
- 'time': '2017-08-27T16:25:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:26:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:27:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:28:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:29:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:30:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:31:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:32:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:33:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:34:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:35:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:36:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:37:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:38:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:39:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:40:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:41:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:42:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:43:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:44:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:45:51.462Z',
- 'value': '1.3142982314117277'
- },
- {
- 'time': '2017-08-27T16:46:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:47:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:48:51.462Z',
- 'value': '1.333320635041571'
- },
- {
- 'time': '2017-08-27T16:49:51.462Z',
- 'value': '1.31427319739812'
- },
- {
- 'time': '2017-08-27T16:50:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:51:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:52:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:53:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:54:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:55:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T16:56:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:57:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T16:58:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T16:59:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:00:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:01:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:02:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:03:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:04:51.462Z',
- 'value': '1.2952504309564854'
- },
- {
- 'time': '2017-08-27T17:05:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:06:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:07:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:08:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:09:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:10:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:11:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:12:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:13:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:14:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:15:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:16:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:17:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:18:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:19:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:20:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:21:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:22:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:23:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:24:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:25:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:26:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:27:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:28:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:29:51.462Z',
- 'value': '1.295225759754669'
- },
- {
- 'time': '2017-08-27T17:30:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:31:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:32:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:33:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:34:51.462Z',
- 'value': '1.295225759754669'
- },
- {
- 'time': '2017-08-27T17:35:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:36:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:37:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:38:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:39:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:40:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:41:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:42:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:43:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:44:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:45:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:46:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:47:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:48:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:49:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:50:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:51:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:52:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:53:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:54:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:55:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T17:56:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:57:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T17:58:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T17:59:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T18:00:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:01:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:02:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:03:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:04:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:05:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:06:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:07:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:08:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:09:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:10:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:11:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:12:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T18:13:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:14:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:15:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:16:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:17:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:18:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:19:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:20:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:21:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:22:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:23:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:24:51.462Z',
- 'value': '1.2571428571428571'
- },
- {
- 'time': '2017-08-27T18:25:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:26:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:27:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:28:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:29:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:30:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:31:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:32:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:33:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:34:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:35:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:36:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:37:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T18:38:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:39:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:40:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:41:51.462Z',
- 'value': '1.580952380952381'
- },
- {
- 'time': '2017-08-27T18:42:51.462Z',
- 'value': '1.7333333333333334'
- },
- {
- 'time': '2017-08-27T18:43:51.462Z',
- 'value': '2.057142857142857'
- },
- {
- 'time': '2017-08-27T18:44:51.462Z',
- 'value': '2.1904761904761902'
- },
- {
- 'time': '2017-08-27T18:45:51.462Z',
- 'value': '1.8285714285714287'
- },
- {
- 'time': '2017-08-27T18:46:51.462Z',
- 'value': '2.1142857142857143'
- },
- {
- 'time': '2017-08-27T18:47:51.462Z',
- 'value': '1.619047619047619'
- },
- {
- 'time': '2017-08-27T18:48:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:49:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:50:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T18:51:51.462Z',
- 'value': '1.2952504309564854'
- },
- {
- 'time': '2017-08-27T18:52:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:53:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:54:51.462Z',
- 'value': '1.3333333333333333'
- },
- {
- 'time': '2017-08-27T18:55:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:56:51.462Z',
- 'value': '1.314285714285714'
- },
- {
- 'time': '2017-08-27T18:57:51.462Z',
- 'value': '1.295238095238095'
- },
- {
- 'time': '2017-08-27T18:58:51.462Z',
- 'value': '1.7142857142857142'
- },
- {
- 'time': '2017-08-27T18:59:51.462Z',
- 'value': '1.7333333333333334'
- },
- {
- 'time': '2017-08-27T19:00:51.462Z',
- 'value': '1.3904761904761904'
- },
- {
- 'time': '2017-08-27T19:01:51.462Z',
- 'value': '1.5047619047619047'
- }
- ]
- },
- ],
- 'when': [
- {
- 'value': 'hundred(s)',
- 'color': 'green',
- },
- ],
- }
- ]
- },
- {
- 'title': 'Throughput',
- 'weight': 1,
- 'y_label': 'Requests / Sec',
- 'queries': [
- {
- 'query_range': 'sum(rate(nginx_requests_total{server_zone!=\'*\', server_zone!=\'_\', container_name!=\'POD\',environment=\'production\'}[2m]))',
- 'label': 'Total',
- 'unit': 'req / sec',
- 'result': [
- {
- 'metric': {
-
- },
- 'values': [
- {
- 'time': '2017-08-27T11:01:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:02:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T11:03:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:04:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:05:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:06:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:07:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:08:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:09:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:10:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:11:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:12:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:13:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:14:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:15:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:16:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:17:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:18:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:19:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:20:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:21:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:22:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:23:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:24:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:25:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:26:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:27:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:28:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:29:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:30:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:31:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:32:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:33:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:34:51.462Z',
- 'value': '0.4952333787297264'
- },
- {
- 'time': '2017-08-27T11:35:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:36:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:37:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:38:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:39:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:40:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:41:51.462Z',
- 'value': '0.49524752852435283'
- },
- {
- 'time': '2017-08-27T11:42:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:43:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:44:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:45:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:46:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:47:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:48:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:49:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T11:50:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:51:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:52:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:53:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:54:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:55:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:56:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:57:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T11:58:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T11:59:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:00:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:01:51.462Z',
- 'value': '0.49524281183630325'
- },
- {
- 'time': '2017-08-27T12:02:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:03:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:04:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:05:51.462Z',
- 'value': '0.4857096599080009'
- },
- {
- 'time': '2017-08-27T12:06:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:07:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:08:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:09:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:10:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:11:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:12:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:13:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:14:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:15:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:16:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:17:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:18:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:19:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:20:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:21:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:22:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:23:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:24:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:25:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:26:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:27:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:28:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:29:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:30:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:31:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:32:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:33:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:34:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:35:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:36:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:37:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:38:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:39:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:40:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:41:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:42:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:43:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:44:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:45:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:46:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:47:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:48:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:49:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:50:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:51:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:52:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:53:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:54:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T12:55:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:56:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:57:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T12:58:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T12:59:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:00:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:01:51.462Z',
- 'value': '0.4761859410862754'
- },
- {
- 'time': '2017-08-27T13:02:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:03:51.462Z',
- 'value': '0.4761995466580315'
- },
- {
- 'time': '2017-08-27T13:04:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:05:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:06:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:07:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:08:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:09:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:10:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:11:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:12:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:13:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:14:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:15:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T13:16:51.462Z',
- 'value': '0.49524752852435283'
- },
- {
- 'time': '2017-08-27T13:17:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:18:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:19:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:20:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:21:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:22:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:23:51.462Z',
- 'value': '0.4666666666666667'
- },
- {
- 'time': '2017-08-27T13:24:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T13:25:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:26:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:27:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:28:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:29:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:30:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:31:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:32:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:33:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:34:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:35:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:36:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:37:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:38:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:39:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:40:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:41:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:42:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:43:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:44:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:45:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:46:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T13:47:51.462Z',
- 'value': '0.4666666666666667'
- },
- {
- 'time': '2017-08-27T13:48:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:49:51.462Z',
- 'value': '0.4761859410862754'
- },
- {
- 'time': '2017-08-27T13:50:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:51:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:52:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:53:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:54:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:55:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:56:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T13:57:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T13:58:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T13:59:51.462Z',
- 'value': '0.4761859410862754'
- },
- {
- 'time': '2017-08-27T14:00:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:01:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:02:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:03:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:04:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:05:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:06:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:07:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:08:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:09:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:10:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:11:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:12:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:13:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:14:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:15:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:16:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:17:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:18:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:19:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:20:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:21:51.462Z',
- 'value': '0.4952286623111941'
- },
- {
- 'time': '2017-08-27T14:22:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:23:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:24:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:25:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:26:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:27:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:28:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:29:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:30:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:31:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:32:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:33:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T14:34:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:35:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:36:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:37:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:38:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:39:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:40:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:41:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:42:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:43:51.462Z',
- 'value': '0.4666666666666667'
- },
- {
- 'time': '2017-08-27T14:44:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T14:45:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:46:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:47:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:48:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:49:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:50:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:51:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:52:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:53:51.462Z',
- 'value': '0.4952333787297264'
- },
- {
- 'time': '2017-08-27T14:54:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:55:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T14:56:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:57:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T14:58:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T14:59:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:00:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:01:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:02:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:03:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:04:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T15:05:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:06:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:07:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:08:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:09:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:10:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:11:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:12:51.462Z',
- 'value': '0.4857096599080009'
- },
- {
- 'time': '2017-08-27T15:13:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:14:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:15:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:16:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:17:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:18:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:19:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:20:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:21:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:22:51.462Z',
- 'value': '0.49524281183630325'
- },
- {
- 'time': '2017-08-27T15:23:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:24:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:25:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:26:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:27:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:28:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:29:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:30:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:31:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:32:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:33:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:34:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:35:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:36:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:37:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:38:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:39:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:40:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:41:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:42:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:43:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:44:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:45:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:46:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:47:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:48:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:49:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T15:50:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:51:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:52:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:53:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:54:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:55:51.462Z',
- 'value': '0.49524752852435283'
- },
- {
- 'time': '2017-08-27T15:56:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T15:57:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:58:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T15:59:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:00:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:01:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:02:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:03:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:04:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:05:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:06:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:07:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:08:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:09:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:10:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:11:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:12:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:13:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:14:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:15:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:16:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:17:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:18:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:19:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:20:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:21:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:22:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:23:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:24:51.462Z',
- 'value': '0.4761859410862754'
- },
- {
- 'time': '2017-08-27T16:25:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:26:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:27:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:28:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:29:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:30:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:31:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:32:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:33:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:34:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:35:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:36:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:37:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:38:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:39:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:40:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:41:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:42:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:43:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:44:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:45:51.462Z',
- 'value': '0.485718911608682'
- },
- {
- 'time': '2017-08-27T16:46:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:47:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:48:51.462Z',
- 'value': '0.4952333787297264'
- },
- {
- 'time': '2017-08-27T16:49:51.462Z',
- 'value': '0.4857096599080009'
- },
- {
- 'time': '2017-08-27T16:50:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:51:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:52:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:53:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:54:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:55:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T16:56:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:57:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T16:58:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T16:59:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:00:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:01:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:02:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:03:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:04:51.462Z',
- 'value': '0.47619501138106085'
- },
- {
- 'time': '2017-08-27T17:05:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:06:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:07:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:08:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:09:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:10:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:11:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:12:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:13:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:14:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:15:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:16:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:17:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:18:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:19:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:20:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:21:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:22:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:23:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:24:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:25:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:26:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:27:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:28:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:29:51.462Z',
- 'value': '0.4761859410862754'
- },
- {
- 'time': '2017-08-27T17:30:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:31:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:32:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:33:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:34:51.462Z',
- 'value': '0.4761859410862754'
- },
- {
- 'time': '2017-08-27T17:35:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:36:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:37:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:38:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:39:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:40:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:41:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:42:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:43:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:44:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:45:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:46:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:47:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:48:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:49:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:50:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:51:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:52:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:53:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:54:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:55:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T17:56:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:57:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T17:58:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T17:59:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T18:00:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:01:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:02:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:03:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:04:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:05:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:06:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:07:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:08:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:09:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:10:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:11:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:12:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T18:13:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:14:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:15:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:16:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:17:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:18:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:19:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:20:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:21:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:22:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:23:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:24:51.462Z',
- 'value': '0.45714285714285713'
- },
- {
- 'time': '2017-08-27T18:25:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:26:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:27:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:28:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:29:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:30:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:31:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:32:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:33:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:34:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:35:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:36:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:37:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T18:38:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:39:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:40:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:41:51.462Z',
- 'value': '0.6190476190476191'
- },
- {
- 'time': '2017-08-27T18:42:51.462Z',
- 'value': '0.6952380952380952'
- },
- {
- 'time': '2017-08-27T18:43:51.462Z',
- 'value': '0.857142857142857'
- },
- {
- 'time': '2017-08-27T18:44:51.462Z',
- 'value': '0.9238095238095239'
- },
- {
- 'time': '2017-08-27T18:45:51.462Z',
- 'value': '0.7428571428571429'
- },
- {
- 'time': '2017-08-27T18:46:51.462Z',
- 'value': '0.8857142857142857'
- },
- {
- 'time': '2017-08-27T18:47:51.462Z',
- 'value': '0.638095238095238'
- },
- {
- 'time': '2017-08-27T18:48:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:49:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:50:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T18:51:51.462Z',
- 'value': '0.47619501138106085'
- },
- {
- 'time': '2017-08-27T18:52:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:53:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:54:51.462Z',
- 'value': '0.4952380952380952'
- },
- {
- 'time': '2017-08-27T18:55:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:56:51.462Z',
- 'value': '0.4857142857142857'
- },
- {
- 'time': '2017-08-27T18:57:51.462Z',
- 'value': '0.47619047619047616'
- },
- {
- 'time': '2017-08-27T18:58:51.462Z',
- 'value': '0.6857142857142856'
- },
- {
- 'time': '2017-08-27T18:59:51.462Z',
- 'value': '0.6952380952380952'
- },
- {
- 'time': '2017-08-27T19:00:51.462Z',
- 'value': '0.5238095238095237'
- },
- {
- 'time': '2017-08-27T19:01:51.462Z',
- 'value': '0.5904761904761905'
- }
- ]
- }
- ]
- }
- ]
- }
+ {
+ title: 'Multiple Time Series',
+ weight: 1,
+ y_label: 'Request Rates',
+ queries: [
+ {
+ query_range:
+ 'sum(rate(nginx_responses_total{environment="production"}[2m])) by (status_code)',
+ label: 'Requests',
+ unit: 'Req/sec',
+ result: [
+ {
+ metric: {
+ status_code: '1xx',
+ },
+ values: [
+ {
+ time: '2017-08-27T11:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T11:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T12:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T13:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T14:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T15:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T16:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T17:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:01:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:02:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:03:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:04:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:05:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:06:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:07:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:08:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:09:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:10:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:11:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:12:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:13:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:14:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:15:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:16:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:17:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:18:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:19:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:20:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:21:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:22:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:23:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:24:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:25:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:26:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:27:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:28:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:29:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:30:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:31:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:32:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:33:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:34:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:35:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:36:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:37:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:38:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:39:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:40:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:41:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:42:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:43:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:44:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:45:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:46:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:47:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:48:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:49:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:50:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:51:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:52:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:53:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:54:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:55:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:56:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:57:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:58:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T18:59:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T19:00:51.462Z',
+ value: '0',
+ },
+ {
+ time: '2017-08-27T19:01:51.462Z',
+ value: '0',
+ },
+ ],
+ },
+ {
+ metric: {
+ status_code: '2xx',
+ },
+ values: [
+ {
+ time: '2017-08-27T11:01:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:02:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T11:03:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:04:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:05:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:06:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:07:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:08:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:09:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:10:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:11:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:12:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:13:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:14:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:15:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:16:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:17:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:18:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:19:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:20:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:21:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:22:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:23:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:24:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:25:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:26:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:27:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:28:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:29:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:30:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:31:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:32:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:33:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:34:51.462Z',
+ value: '1.333320635041571',
+ },
+ {
+ time: '2017-08-27T11:35:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:36:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:37:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:38:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:39:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:40:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:41:51.462Z',
+ value: '1.3333587306424883',
+ },
+ {
+ time: '2017-08-27T11:42:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:43:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:44:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:45:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:46:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:47:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:48:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:49:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T11:50:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:51:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:52:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:53:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:54:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:55:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:56:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:57:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T11:58:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T11:59:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:00:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:01:51.462Z',
+ value: '1.3333460318669703',
+ },
+ {
+ time: '2017-08-27T12:02:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:03:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:04:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:05:51.462Z',
+ value: '1.31427319739812',
+ },
+ {
+ time: '2017-08-27T12:06:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:07:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:08:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:09:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:10:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:11:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:12:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:13:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:14:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:15:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:16:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:17:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:18:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:19:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:20:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:21:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:22:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:23:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:24:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:25:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:26:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:27:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:28:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:29:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:30:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:31:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:32:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:33:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:34:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:35:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:36:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:37:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:38:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:39:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:40:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:41:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:42:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:43:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:44:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:45:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:46:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:47:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:48:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:49:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:50:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:51:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:52:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:53:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:54:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T12:55:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:56:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:57:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T12:58:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T12:59:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:00:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:01:51.462Z',
+ value: '1.295225759754669',
+ },
+ {
+ time: '2017-08-27T13:02:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:03:51.462Z',
+ value: '1.2952627669098458',
+ },
+ {
+ time: '2017-08-27T13:04:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:05:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:06:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:07:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:08:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:09:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:10:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:11:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:12:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:13:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:14:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:15:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T13:16:51.462Z',
+ value: '1.3333587306424883',
+ },
+ {
+ time: '2017-08-27T13:17:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:18:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:19:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:20:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:21:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:22:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:23:51.462Z',
+ value: '1.276190476190476',
+ },
+ {
+ time: '2017-08-27T13:24:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T13:25:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:26:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:27:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:28:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:29:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:30:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:31:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:32:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:33:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:34:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:35:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:36:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:37:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:38:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:39:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:40:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:41:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:42:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:43:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:44:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:45:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:46:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T13:47:51.462Z',
+ value: '1.276190476190476',
+ },
+ {
+ time: '2017-08-27T13:48:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:49:51.462Z',
+ value: '1.295225759754669',
+ },
+ {
+ time: '2017-08-27T13:50:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:51:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:52:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:53:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:54:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:55:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:56:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T13:57:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T13:58:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T13:59:51.462Z',
+ value: '1.295225759754669',
+ },
+ {
+ time: '2017-08-27T14:00:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:01:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:02:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:03:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:04:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:05:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:06:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:07:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:08:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:09:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:10:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:11:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:12:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:13:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:14:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:15:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:16:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:17:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:18:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:19:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:20:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:21:51.462Z',
+ value: '1.3333079369916765',
+ },
+ {
+ time: '2017-08-27T14:22:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:23:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:24:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:25:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:26:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:27:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:28:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:29:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:30:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:31:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:32:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:33:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T14:34:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:35:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:36:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:37:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:38:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:39:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:40:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:41:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:42:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:43:51.462Z',
+ value: '1.276190476190476',
+ },
+ {
+ time: '2017-08-27T14:44:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T14:45:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:46:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:47:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:48:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:49:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:50:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:51:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:52:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:53:51.462Z',
+ value: '1.333320635041571',
+ },
+ {
+ time: '2017-08-27T14:54:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:55:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T14:56:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:57:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T14:58:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T14:59:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:00:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:01:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:02:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:03:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:04:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T15:05:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:06:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:07:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:08:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:09:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:10:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:11:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:12:51.462Z',
+ value: '1.31427319739812',
+ },
+ {
+ time: '2017-08-27T15:13:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:14:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:15:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:16:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:17:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:18:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:19:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:20:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:21:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:22:51.462Z',
+ value: '1.3333460318669703',
+ },
+ {
+ time: '2017-08-27T15:23:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:24:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:25:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:26:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:27:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:28:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:29:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:30:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:31:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:32:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:33:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:34:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:35:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:36:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:37:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:38:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:39:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:40:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:41:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:42:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:43:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:44:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:45:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:46:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:47:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:48:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:49:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T15:50:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:51:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:52:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:53:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:54:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:55:51.462Z',
+ value: '1.3333587306424883',
+ },
+ {
+ time: '2017-08-27T15:56:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T15:57:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:58:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T15:59:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:00:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:01:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:02:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:03:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:04:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:05:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:06:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:07:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:08:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:09:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:10:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:11:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:12:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:13:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:14:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:15:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:16:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:17:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:18:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:19:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:20:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:21:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:22:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:23:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:24:51.462Z',
+ value: '1.295225759754669',
+ },
+ {
+ time: '2017-08-27T16:25:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:26:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:27:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:28:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:29:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:30:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:31:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:32:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:33:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:34:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:35:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:36:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:37:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:38:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:39:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:40:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:41:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:42:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:43:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:44:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:45:51.462Z',
+ value: '1.3142982314117277',
+ },
+ {
+ time: '2017-08-27T16:46:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:47:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:48:51.462Z',
+ value: '1.333320635041571',
+ },
+ {
+ time: '2017-08-27T16:49:51.462Z',
+ value: '1.31427319739812',
+ },
+ {
+ time: '2017-08-27T16:50:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:51:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:52:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:53:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:54:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:55:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T16:56:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:57:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T16:58:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T16:59:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:00:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:01:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:02:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:03:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:04:51.462Z',
+ value: '1.2952504309564854',
+ },
+ {
+ time: '2017-08-27T17:05:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:06:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:07:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:08:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:09:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:10:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:11:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:12:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:13:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:14:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:15:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:16:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:17:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:18:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:19:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:20:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:21:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:22:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:23:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:24:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:25:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:26:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:27:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:28:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:29:51.462Z',
+ value: '1.295225759754669',
+ },
+ {
+ time: '2017-08-27T17:30:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:31:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:32:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:33:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:34:51.462Z',
+ value: '1.295225759754669',
+ },
+ {
+ time: '2017-08-27T17:35:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:36:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:37:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:38:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:39:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:40:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:41:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:42:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:43:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:44:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:45:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:46:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:47:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:48:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:49:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:50:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:51:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:52:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:53:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:54:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:55:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T17:56:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:57:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T17:58:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T17:59:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T18:00:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:01:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:02:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:03:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:04:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:05:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:06:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:07:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:08:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:09:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:10:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:11:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:12:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T18:13:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:14:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:15:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:16:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:17:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:18:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:19:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:20:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:21:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:22:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:23:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:24:51.462Z',
+ value: '1.2571428571428571',
+ },
+ {
+ time: '2017-08-27T18:25:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:26:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:27:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:28:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:29:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:30:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:31:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:32:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:33:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:34:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:35:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:36:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:37:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T18:38:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:39:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:40:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:41:51.462Z',
+ value: '1.580952380952381',
+ },
+ {
+ time: '2017-08-27T18:42:51.462Z',
+ value: '1.7333333333333334',
+ },
+ {
+ time: '2017-08-27T18:43:51.462Z',
+ value: '2.057142857142857',
+ },
+ {
+ time: '2017-08-27T18:44:51.462Z',
+ value: '2.1904761904761902',
+ },
+ {
+ time: '2017-08-27T18:45:51.462Z',
+ value: '1.8285714285714287',
+ },
+ {
+ time: '2017-08-27T18:46:51.462Z',
+ value: '2.1142857142857143',
+ },
+ {
+ time: '2017-08-27T18:47:51.462Z',
+ value: '1.619047619047619',
+ },
+ {
+ time: '2017-08-27T18:48:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:49:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:50:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T18:51:51.462Z',
+ value: '1.2952504309564854',
+ },
+ {
+ time: '2017-08-27T18:52:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:53:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:54:51.462Z',
+ value: '1.3333333333333333',
+ },
+ {
+ time: '2017-08-27T18:55:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:56:51.462Z',
+ value: '1.314285714285714',
+ },
+ {
+ time: '2017-08-27T18:57:51.462Z',
+ value: '1.295238095238095',
+ },
+ {
+ time: '2017-08-27T18:58:51.462Z',
+ value: '1.7142857142857142',
+ },
+ {
+ time: '2017-08-27T18:59:51.462Z',
+ value: '1.7333333333333334',
+ },
+ {
+ time: '2017-08-27T19:00:51.462Z',
+ value: '1.3904761904761904',
+ },
+ {
+ time: '2017-08-27T19:01:51.462Z',
+ value: '1.5047619047619047',
+ },
+ ],
+ },
+ ],
+ when: [
+ {
+ value: 'hundred(s)',
+ color: 'green',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ title: 'Throughput',
+ weight: 1,
+ y_label: 'Requests / Sec',
+ queries: [
+ {
+ query_range:
+ "sum(rate(nginx_requests_total{server_zone!='*', server_zone!='_', container_name!='POD',environment='production'}[2m]))",
+ label: 'Total',
+ unit: 'req / sec',
+ result: [
+ {
+ metric: {},
+ values: [
+ {
+ time: '2017-08-27T11:01:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:02:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T11:03:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:04:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:05:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:06:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:07:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:08:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:09:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:10:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:11:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:12:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:13:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:14:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:15:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:16:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:17:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:18:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:19:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:20:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:21:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:22:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:23:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:24:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:25:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:26:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:27:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:28:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:29:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:30:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:31:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:32:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:33:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:34:51.462Z',
+ value: '0.4952333787297264',
+ },
+ {
+ time: '2017-08-27T11:35:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:36:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:37:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:38:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:39:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:40:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:41:51.462Z',
+ value: '0.49524752852435283',
+ },
+ {
+ time: '2017-08-27T11:42:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:43:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:44:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:45:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:46:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:47:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:48:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:49:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T11:50:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:51:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:52:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:53:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:54:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:55:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:56:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:57:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T11:58:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T11:59:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:00:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:01:51.462Z',
+ value: '0.49524281183630325',
+ },
+ {
+ time: '2017-08-27T12:02:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:03:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:04:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:05:51.462Z',
+ value: '0.4857096599080009',
+ },
+ {
+ time: '2017-08-27T12:06:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:07:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:08:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:09:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:10:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:11:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:12:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:13:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:14:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:15:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:16:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:17:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:18:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:19:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:20:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:21:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:22:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:23:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:24:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:25:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:26:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:27:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:28:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:29:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:30:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:31:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:32:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:33:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:34:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:35:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:36:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:37:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:38:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:39:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:40:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:41:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:42:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:43:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:44:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:45:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:46:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:47:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:48:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:49:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:50:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:51:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:52:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:53:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:54:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T12:55:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:56:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:57:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T12:58:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T12:59:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:00:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:01:51.462Z',
+ value: '0.4761859410862754',
+ },
+ {
+ time: '2017-08-27T13:02:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:03:51.462Z',
+ value: '0.4761995466580315',
+ },
+ {
+ time: '2017-08-27T13:04:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:05:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:06:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:07:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:08:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:09:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:10:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:11:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:12:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:13:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:14:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:15:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T13:16:51.462Z',
+ value: '0.49524752852435283',
+ },
+ {
+ time: '2017-08-27T13:17:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:18:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:19:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:20:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:21:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:22:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:23:51.462Z',
+ value: '0.4666666666666667',
+ },
+ {
+ time: '2017-08-27T13:24:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T13:25:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:26:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:27:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:28:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:29:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:30:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:31:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:32:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:33:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:34:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:35:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:36:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:37:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:38:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:39:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:40:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:41:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:42:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:43:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:44:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:45:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:46:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T13:47:51.462Z',
+ value: '0.4666666666666667',
+ },
+ {
+ time: '2017-08-27T13:48:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:49:51.462Z',
+ value: '0.4761859410862754',
+ },
+ {
+ time: '2017-08-27T13:50:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:51:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:52:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:53:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:54:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:55:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:56:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T13:57:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T13:58:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T13:59:51.462Z',
+ value: '0.4761859410862754',
+ },
+ {
+ time: '2017-08-27T14:00:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:01:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:02:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:03:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:04:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:05:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:06:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:07:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:08:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:09:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:10:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:11:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:12:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:13:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:14:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:15:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:16:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:17:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:18:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:19:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:20:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:21:51.462Z',
+ value: '0.4952286623111941',
+ },
+ {
+ time: '2017-08-27T14:22:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:23:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:24:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:25:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:26:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:27:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:28:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:29:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:30:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:31:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:32:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:33:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T14:34:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:35:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:36:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:37:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:38:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:39:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:40:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:41:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:42:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:43:51.462Z',
+ value: '0.4666666666666667',
+ },
+ {
+ time: '2017-08-27T14:44:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T14:45:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:46:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:47:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:48:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:49:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:50:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:51:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:52:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:53:51.462Z',
+ value: '0.4952333787297264',
+ },
+ {
+ time: '2017-08-27T14:54:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:55:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T14:56:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:57:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T14:58:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T14:59:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:00:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:01:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:02:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:03:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:04:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T15:05:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:06:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:07:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:08:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:09:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:10:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:11:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:12:51.462Z',
+ value: '0.4857096599080009',
+ },
+ {
+ time: '2017-08-27T15:13:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:14:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:15:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:16:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:17:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:18:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:19:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:20:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:21:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:22:51.462Z',
+ value: '0.49524281183630325',
+ },
+ {
+ time: '2017-08-27T15:23:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:24:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:25:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:26:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:27:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:28:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:29:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:30:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:31:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:32:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:33:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:34:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:35:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:36:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:37:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:38:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:39:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:40:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:41:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:42:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:43:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:44:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:45:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:46:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:47:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:48:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:49:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T15:50:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:51:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:52:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:53:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:54:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:55:51.462Z',
+ value: '0.49524752852435283',
+ },
+ {
+ time: '2017-08-27T15:56:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T15:57:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:58:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T15:59:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:00:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:01:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:02:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:03:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:04:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:05:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:06:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:07:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:08:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:09:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:10:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:11:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:12:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:13:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:14:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:15:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:16:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:17:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:18:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:19:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:20:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:21:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:22:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:23:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:24:51.462Z',
+ value: '0.4761859410862754',
+ },
+ {
+ time: '2017-08-27T16:25:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:26:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:27:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:28:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:29:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:30:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:31:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:32:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:33:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:34:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:35:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:36:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:37:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:38:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:39:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:40:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:41:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:42:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:43:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:44:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:45:51.462Z',
+ value: '0.485718911608682',
+ },
+ {
+ time: '2017-08-27T16:46:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:47:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:48:51.462Z',
+ value: '0.4952333787297264',
+ },
+ {
+ time: '2017-08-27T16:49:51.462Z',
+ value: '0.4857096599080009',
+ },
+ {
+ time: '2017-08-27T16:50:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:51:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:52:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:53:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:54:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:55:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T16:56:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:57:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T16:58:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T16:59:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:00:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:01:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:02:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:03:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:04:51.462Z',
+ value: '0.47619501138106085',
+ },
+ {
+ time: '2017-08-27T17:05:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:06:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:07:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:08:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:09:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:10:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:11:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:12:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:13:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:14:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:15:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:16:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:17:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:18:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:19:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:20:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:21:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:22:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:23:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:24:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:25:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:26:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:27:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:28:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:29:51.462Z',
+ value: '0.4761859410862754',
+ },
+ {
+ time: '2017-08-27T17:30:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:31:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:32:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:33:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:34:51.462Z',
+ value: '0.4761859410862754',
+ },
+ {
+ time: '2017-08-27T17:35:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:36:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:37:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:38:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:39:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:40:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:41:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:42:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:43:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:44:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:45:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:46:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:47:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:48:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:49:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:50:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:51:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:52:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:53:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:54:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:55:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T17:56:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:57:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T17:58:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T17:59:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T18:00:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:01:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:02:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:03:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:04:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:05:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:06:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:07:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:08:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:09:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:10:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:11:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:12:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T18:13:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:14:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:15:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:16:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:17:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:18:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:19:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:20:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:21:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:22:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:23:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:24:51.462Z',
+ value: '0.45714285714285713',
+ },
+ {
+ time: '2017-08-27T18:25:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:26:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:27:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:28:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:29:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:30:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:31:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:32:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:33:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:34:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:35:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:36:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:37:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T18:38:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:39:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:40:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:41:51.462Z',
+ value: '0.6190476190476191',
+ },
+ {
+ time: '2017-08-27T18:42:51.462Z',
+ value: '0.6952380952380952',
+ },
+ {
+ time: '2017-08-27T18:43:51.462Z',
+ value: '0.857142857142857',
+ },
+ {
+ time: '2017-08-27T18:44:51.462Z',
+ value: '0.9238095238095239',
+ },
+ {
+ time: '2017-08-27T18:45:51.462Z',
+ value: '0.7428571428571429',
+ },
+ {
+ time: '2017-08-27T18:46:51.462Z',
+ value: '0.8857142857142857',
+ },
+ {
+ time: '2017-08-27T18:47:51.462Z',
+ value: '0.638095238095238',
+ },
+ {
+ time: '2017-08-27T18:48:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:49:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:50:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T18:51:51.462Z',
+ value: '0.47619501138106085',
+ },
+ {
+ time: '2017-08-27T18:52:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:53:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:54:51.462Z',
+ value: '0.4952380952380952',
+ },
+ {
+ time: '2017-08-27T18:55:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:56:51.462Z',
+ value: '0.4857142857142857',
+ },
+ {
+ time: '2017-08-27T18:57:51.462Z',
+ value: '0.47619047619047616',
+ },
+ {
+ time: '2017-08-27T18:58:51.462Z',
+ value: '0.6857142857142856',
+ },
+ {
+ time: '2017-08-27T18:59:51.462Z',
+ value: '0.6952380952380952',
+ },
+ {
+ time: '2017-08-27T19:00:51.462Z',
+ value: '0.5238095238095237',
+ },
+ {
+ time: '2017-08-27T19:01:51.462Z',
+ value: '0.5904761904761905',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
];
export function convertDatesMultipleSeries(multipleSeries) {
const convertedMultiple = multipleSeries;
multipleSeries.forEach((column, index) => {
let convertedResult = [];
- convertedResult = column.queries[0].result.map((resultObj) => {
+ convertedResult = column.queries[0].result.map(resultObj => {
const convertedMetrics = {};
convertedMetrics.values = resultObj.values.map(val => ({
- time: new Date(val.time),
- value: val.value,
+ time: new Date(val.time),
+ value: val.value,
}));
convertedMetrics.metric = resultObj.metric;
return convertedMetrics;
diff --git a/spec/javascripts/namespace_select_spec.js b/spec/javascripts/namespace_select_spec.js
index 9d7625ca269..3b2641f7646 100644
--- a/spec/javascripts/namespace_select_spec.js
+++ b/spec/javascripts/namespace_select_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import NamespaceSelect from '~/namespace_select';
describe('NamespaceSelect', () => {
diff --git a/spec/javascripts/new_branch_spec.js b/spec/javascripts/new_branch_spec.js
index 50a5e4ff056..5e5d8f8f34f 100644
--- a/spec/javascripts/new_branch_spec.js
+++ b/spec/javascripts/new_branch_spec.js
@@ -1,5 +1,6 @@
/* eslint-disable space-before-function-paren, one-var, no-var, one-var-declaration-per-line, no-return-assign, quotes, max-len */
+import $ from 'jquery';
import NewBranchForm from '~/new_branch_form';
(function() {
diff --git a/spec/javascripts/notebook/cells/markdown_spec.js b/spec/javascripts/notebook/cells/markdown_spec.js
index a88e9ed3d99..8f8ba231ae8 100644
--- a/spec/javascripts/notebook/cells/markdown_spec.js
+++ b/spec/javascripts/notebook/cells/markdown_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import MarkdownComponent from '~/notebook/cells/markdown.vue';
-import katex from 'vendor/katex';
+import katex from 'katex';
const Component = Vue.extend(MarkdownComponent);
@@ -42,6 +42,18 @@ describe('Markdown component', () => {
expect(vm.$el.querySelector('.markdown h1')).not.toBeNull();
});
+ it('sanitizes output', (done) => {
+ Object.assign(cell, {
+ source: ['[XSS](data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+Cg==)\n'],
+ });
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('a')).toBeNull();
+
+ done();
+ });
+ });
+
describe('katex', () => {
beforeEach(() => {
json = getJSONFixture('blob/notebook/math.json');
diff --git a/spec/javascripts/notebook/cells/output/html_sanitize_tests.js b/spec/javascripts/notebook/cells/output/html_sanitize_tests.js
new file mode 100644
index 00000000000..d587573fc9e
--- /dev/null
+++ b/spec/javascripts/notebook/cells/output/html_sanitize_tests.js
@@ -0,0 +1,66 @@
+export default {
+ 'protocol-based JS injection: simple, no spaces': {
+ input: '<a href="javascript:alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: simple, spaces before': {
+ input: '<a href="javascript :alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: simple, spaces after': {
+ input: '<a href="javascript: alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: simple, spaces before and after': {
+ input: '<a href="javascript : alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: preceding colon': {
+ input: '<a href=":javascript:alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: UTF-8 encoding': {
+ input: '<a href="javascript&#58;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: long UTF-8 encoding': {
+ input: '<a href="javascript&#0058;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: long UTF-8 encoding without semicolons': {
+ input: '<a href=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041>foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: hex encoding': {
+ input: '<a href="javascript&#x3A;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: long hex encoding': {
+ input: '<a href="javascript&#x003A;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: hex encoding without semicolons': {
+ input: '<a href=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: null char': {
+ input: '<a href=java\0script:alert("XSS")>foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: invalid URL char': {
+ input: '<img src=java\script:alert("XSS")>', // eslint-disable-line no-useless-escape
+ output: '<img>',
+ },
+ 'protocol-based JS injection: Unicode': {
+ input: '<a href="\u0001java\u0003script:alert(\'XSS\')">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: spaces and entities': {
+ input: '<a href=" &#14; javascript:alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'img on error': {
+ input: '<img src="x" onerror="alert(document.domain)" />',
+ output: '<img src="x">',
+ },
+};
diff --git a/spec/javascripts/notebook/cells/output/html_spec.js b/spec/javascripts/notebook/cells/output/html_spec.js
new file mode 100644
index 00000000000..9c5385f2922
--- /dev/null
+++ b/spec/javascripts/notebook/cells/output/html_spec.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import htmlOutput from '~/notebook/cells/output/html.vue';
+import sanitizeTests from './html_sanitize_tests';
+
+describe('html output cell', () => {
+ function createComponent(rawCode) {
+ const Component = Vue.extend(htmlOutput);
+
+ return new Component({
+ propsData: {
+ rawCode,
+ },
+ }).$mount();
+ }
+
+ describe('sanitizes output', () => {
+ Object.keys(sanitizeTests).forEach((key) => {
+ it(key, () => {
+ const test = sanitizeTests[key];
+ const vm = createComponent(test.input);
+ const outputEl = [...vm.$el.querySelectorAll('div')].pop();
+
+ expect(outputEl.innerHTML).toEqual(test.output);
+
+ vm.$destroy();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/comment_form_spec.js b/spec/javascripts/notes/components/comment_form_spec.js
index 20e352dd8bd..224debbeff6 100644
--- a/spec/javascripts/notes/components/comment_form_spec.js
+++ b/spec/javascripts/notes/components/comment_form_spec.js
@@ -1,17 +1,21 @@
+import $ from 'jquery';
import Vue from 'vue';
import Autosize from 'autosize';
import store from '~/notes/stores';
-import issueCommentForm from '~/notes/components/comment_form.vue';
+import CommentForm from '~/notes/components/comment_form.vue';
import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
import { keyboardDownEvent } from '../../issue_show/helpers';
describe('issue_comment_form component', () => {
let vm;
- const Component = Vue.extend(issueCommentForm);
+ const Component = Vue.extend(CommentForm);
let mountComponent;
beforeEach(() => {
- mountComponent = () => new Component({
+ mountComponent = (noteableType = 'issue') => new Component({
+ propsData: {
+ noteableType,
+ },
store,
}).$mount();
});
@@ -136,16 +140,29 @@ describe('issue_comment_form component', () => {
expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
});
+
+ it('inits autosave', () => {
+ expect(vm.autosave).toBeDefined();
+ expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`);
+ });
});
describe('event enter', () => {
- it('should save note when cmd/ctrl+enter is pressed', () => {
+ it('should save note when cmd+enter is pressed', () => {
spyOn(vm, 'handleSave').and.callThrough();
vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, true));
expect(vm.handleSave).toHaveBeenCalled();
});
+
+ it('should save note when ctrl+enter is pressed', () => {
+ spyOn(vm, 'handleSave').and.callThrough();
+ vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
+ vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, false, true));
+
+ expect(vm.handleSave).toHaveBeenCalled();
+ });
});
});
@@ -174,6 +191,29 @@ describe('issue_comment_form component', () => {
done();
});
});
+
+ it('updates button text with noteable type', (done) => {
+ vm.noteableType = 'merge_request';
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close merge request');
+ done();
+ });
+ });
+
+ describe('when clicking close/reopen button', () => {
+ it('should disable button and show a loading spinner', (done) => {
+ const toggleStateButton = vm.$el.querySelector('.js-action-button');
+
+ toggleStateButton.click();
+ Vue.nextTick(() => {
+ expect(toggleStateButton.disabled).toEqual(true);
+ expect(toggleStateButton.querySelector('.js-loading-button-icon')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
});
describe('issue is confidential', () => {
diff --git a/spec/javascripts/notes/components/diff_file_header_spec.js b/spec/javascripts/notes/components/diff_file_header_spec.js
new file mode 100644
index 00000000000..ef6d513444a
--- /dev/null
+++ b/spec/javascripts/notes/components/diff_file_header_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import DiffFileHeader from '~/notes/components/diff_file_header.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const discussionFixture = 'merge_requests/diff_discussion.json';
+
+describe('diff_file_header', () => {
+ let vm;
+ const diffDiscussionMock = getJSONFixture(discussionFixture)[0];
+ const diffFile = convertObjectPropsToCamelCase(diffDiscussionMock.diff_file);
+ const props = {
+ diffFile,
+ };
+ const Component = Vue.extend(DiffFileHeader);
+ const selectors = {
+ get copyButton() {
+ return vm.$el.querySelector('button[data-original-title="Copy file path to clipboard"]');
+ },
+ get fileName() {
+ return vm.$el.querySelector('.file-title-name');
+ },
+ get titleWrapper() {
+ return vm.$refs.titleWrapper;
+ },
+ };
+
+ describe('submodule', () => {
+ beforeEach(() => {
+ props.diffFile.submodule = true;
+ props.diffFile.submoduleLink = '<a href="/bha">Submodule</a>';
+
+ vm = mountComponent(Component, props);
+ });
+
+ it('shows submoduleLink', () => {
+ expect(selectors.fileName.innerHTML).toBe(props.diffFile.submoduleLink);
+ });
+
+ it('has button to copy blob path', () => {
+ expect(selectors.copyButton).toExist();
+ expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.submoduleLink);
+ });
+ });
+
+ describe('changed file', () => {
+ beforeEach(() => {
+ props.diffFile.submodule = false;
+ props.diffFile.discussionPath = 'some/discussion/id';
+
+ vm = mountComponent(Component, props);
+ });
+
+ it('shows file type icon', () => {
+ expect(vm.$el.innerHTML).toContain('fa-file-text-o');
+ });
+
+ it('links to discussion path', () => {
+ expect(selectors.titleWrapper).toExist();
+ expect(selectors.titleWrapper.tagName).toBe('A');
+ expect(selectors.titleWrapper.getAttribute('href')).toBe(props.diffFile.discussionPath);
+ });
+
+ it('shows plain title if no link given', () => {
+ props.diffFile.discussionPath = undefined;
+ vm = mountComponent(Component, props);
+
+ expect(selectors.titleWrapper.tagName).not.toBe('A');
+ expect(selectors.titleWrapper.href).toBeFalsy();
+ });
+
+ it('has button to copy file path', () => {
+ expect(selectors.copyButton).toExist();
+ expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.filePath);
+ });
+
+ it('shows file mode change', (done) => {
+ vm.diffFile = {
+ ...props.diffFile,
+ modeChanged: true,
+ aMode: '100755',
+ bMode: '100644',
+ };
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$refs.fileMode.textContent.trim(),
+ ).toBe('100755 → 100644');
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js
new file mode 100644
index 00000000000..f4ec7132dbd
--- /dev/null
+++ b/spec/javascripts/notes/components/diff_with_note_spec.js
@@ -0,0 +1,64 @@
+import Vue from 'vue';
+import DiffWithNote from '~/notes/components/diff_with_note.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const discussionFixture = 'merge_requests/diff_discussion.json';
+const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json';
+
+describe('diff_with_note', () => {
+ let vm;
+ const diffDiscussionMock = getJSONFixture(discussionFixture)[0];
+ const diffDiscussion = convertObjectPropsToCamelCase(diffDiscussionMock);
+ const Component = Vue.extend(DiffWithNote);
+ const props = {
+ discussion: diffDiscussion,
+ };
+ const selectors = {
+ get container() {
+ return vm.$refs.fileHolder;
+ },
+ get diffTable() {
+ return this.container.querySelector('.diff-content table');
+ },
+ get diffRows() {
+ return this.container.querySelectorAll('.diff-content .line_holder');
+ },
+ get noteRow() {
+ return this.container.querySelector('.diff-content .notes_holder');
+ },
+ };
+
+ describe('text diff', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ });
+
+ it('shows text diff', () => {
+ expect(selectors.container).toHaveClass('text-file');
+ expect(selectors.diffTable).toExist();
+ });
+
+ it('shows diff lines', () => {
+ expect(selectors.diffRows.length).toBe(12);
+ });
+
+ it('shows notes row', () => {
+ expect(selectors.noteRow).toExist();
+ });
+ });
+
+ describe('image diff', () => {
+ beforeEach(() => {
+ const imageDiffDiscussionMock = getJSONFixture(imageDiscussionFixture)[0];
+ props.discussion = convertObjectPropsToCamelCase(imageDiffDiscussionMock);
+ });
+
+ it('shows image diff', () => {
+ vm = mountComponent(Component, props);
+
+ expect(selectors.container).toHaveClass('js-image-file');
+ expect(selectors.diffTable).not.toExist();
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js
index 7c8d6685ee1..0e792eee5e9 100644
--- a/spec/javascripts/notes/components/note_app_spec.js
+++ b/spec/javascripts/notes/components/note_app_spec.js
@@ -1,14 +1,32 @@
+import $ from 'jquery';
+import _ from 'underscore';
import Vue from 'vue';
import notesApp from '~/notes/components/notes_app.vue';
import service from '~/notes/services/notes_service';
+import '~/behaviors/markdown/render_gfm';
import * as mockData from '../mock_data';
-import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper';
+
+const vueMatchers = {
+ toIncludeElement() {
+ return {
+ compare(vm, selector) {
+ const result = {
+ pass: vm.$el.querySelector(selector) !== null,
+ };
+ return result;
+ },
+ };
+ },
+};
describe('note_app', () => {
let mountComponent;
let vm;
beforeEach(() => {
+ jasmine.addMatchers(vueMatchers);
+ $('body').attr('data-page', 'projects:merge_requests:show');
+
const IssueNotesApp = Vue.extend(notesApp);
mountComponent = (data) => {
@@ -103,8 +121,8 @@ describe('note_app', () => {
vm = mountComponent();
});
- it('should render loading icon', () => {
- expect(vm.$el.querySelector('.js-loading')).toBeDefined();
+ it('renders skeleton notes', () => {
+ expect(vm).toIncludeElement('.animation-container');
});
it('should render form', () => {
@@ -117,10 +135,14 @@ describe('note_app', () => {
describe('update note', () => {
describe('individual note', () => {
- beforeEach(() => {
+ beforeEach((done) => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
spyOn(service, 'updateNote').and.callThrough();
vm = mountComponent();
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(done);
+ }, 0);
});
afterEach(() => {
@@ -130,40 +152,32 @@ describe('note_app', () => {
);
});
- it('renders edit form', (done) => {
- setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
- done();
- });
- }, 0);
+ it('renders edit form', () => {
+ expect(vm).toIncludeElement('.js-vue-issue-note-form');
});
it('calls the service to update the note', (done) => {
- getSetTimeoutPromise()
- .then(() => {
- vm.$el.querySelector('.js-note-edit').click();
- })
- .then(Vue.nextTick)
- .then(() => {
- vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
- vm.$el.querySelector('.js-vue-issue-save').click();
-
- expect(service.updateNote).toHaveBeenCalled();
- })
- // Wait for the requests to finish before destroying
- .then(Vue.nextTick)
+ vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ expect(service.updateNote).toHaveBeenCalled();
+ // Wait for the requests to finish before destroying
+ Vue.nextTick()
.then(done)
.catch(done.fail);
});
});
- describe('dicussion note', () => {
- beforeEach(() => {
+ describe('discussion note', () => {
+ beforeEach((done) => {
Vue.http.interceptors.push(mockData.discussionNoteInterceptor);
spyOn(service, 'updateNote').and.callThrough();
vm = mountComponent();
+
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(done);
+ }, 0);
});
afterEach(() => {
@@ -173,30 +187,17 @@ describe('note_app', () => {
);
});
- it('renders edit form', (done) => {
- setTimeout(() => {
- vm.$el.querySelector('.js-note-edit').click();
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
- done();
- });
- }, 0);
+ it('renders edit form', () => {
+ expect(vm).toIncludeElement('.js-vue-issue-note-form');
});
it('updates the note and resets the edit form', (done) => {
- getSetTimeoutPromise()
- .then(() => {
- vm.$el.querySelector('.js-note-edit').click();
- })
- .then(Vue.nextTick)
- .then(() => {
- vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
- vm.$el.querySelector('.js-vue-issue-save').click();
-
- expect(service.updateNote).toHaveBeenCalled();
- })
- // Wait for the requests to finish before destroying
- .then(Vue.nextTick)
+ vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ expect(service.updateNote).toHaveBeenCalled();
+ // Wait for the requests to finish before destroying
+ Vue.nextTick()
.then(done)
.catch(done.fail);
});
diff --git a/spec/javascripts/notes/components/note_body_spec.js b/spec/javascripts/notes/components/note_body_spec.js
index b42e7943b98..0ff804f0e55 100644
--- a/spec/javascripts/notes/components/note_body_spec.js
+++ b/spec/javascripts/notes/components/note_body_spec.js
@@ -30,17 +30,26 @@ describe('issue_note_body component', () => {
expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
});
- it('should be render form if user is editing', (done) => {
- vm.isEditing = true;
+ it('should render awards list', () => {
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull();
+ });
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined();
- done();
+ describe('isEditing', () => {
+ beforeEach((done) => {
+ vm.isEditing = true;
+ Vue.nextTick(done);
});
- });
- it('should render awards list', () => {
- expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined();
- expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined();
+ it('renders edit form', () => {
+ expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull();
+ });
+
+ it('adds autosave', () => {
+ const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`;
+
+ expect(vm.autosave).toExist();
+ expect(vm.autosave.key).toEqual(autosaveKey);
+ });
});
});
diff --git a/spec/javascripts/notes/components/note_form_spec.js b/spec/javascripts/notes/components/note_form_spec.js
index 86e9e2a32a9..f841a408d09 100644
--- a/spec/javascripts/notes/components/note_form_spec.js
+++ b/spec/javascripts/notes/components/note_form_spec.js
@@ -69,13 +69,20 @@ describe('issue_note_form component', () => {
});
describe('enter', () => {
- it('should submit note', () => {
+ it('should save note when cmd+enter is pressed', () => {
spyOn(vm, 'handleUpdate').and.callThrough();
vm.$el.querySelector('textarea').value = 'Foo';
vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true));
expect(vm.handleUpdate).toHaveBeenCalled();
});
+ it('should save note when ctrl+enter is pressed', () => {
+ spyOn(vm, 'handleUpdate').and.callThrough();
+ vm.$el.querySelector('textarea').value = 'Foo';
+ vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, false, true));
+
+ expect(vm.handleUpdate).toHaveBeenCalled();
+ });
});
});
diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/javascripts/notes/components/note_header_spec.js
index 16a76b11321..5636f8d1a9f 100644
--- a/spec/javascripts/notes/components/note_header_spec.js
+++ b/spec/javascripts/notes/components/note_header_spec.js
@@ -32,6 +32,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: false,
noteId: 1394,
+ expanded: true,
},
}).$mount();
});
@@ -68,6 +69,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: true,
noteId: 1395,
+ expanded: true,
},
}).$mount();
});
@@ -76,17 +78,35 @@ describe('note_header component', () => {
expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
});
- it('should toggle the disucssion icon', (done) => {
- expect(
- vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
- ).toEqual(true);
+ it('emits toggle event on click', (done) => {
+ spyOn(vm, '$emit');
vm.$el.querySelector('.js-vue-toggle-button').click();
Vue.nextTick(() => {
+ expect(vm.$emit).toHaveBeenCalledWith('toggleHandler');
+ done();
+ });
+ });
+
+ it('renders up arrow when open', (done) => {
+ vm.expanded = true;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.js-vue-toggle-button i').classList,
+ ).toContain('fa-chevron-up');
+ done();
+ });
+ });
+
+ it('renders down arrow when closed', (done) => {
+ vm.expanded = false;
+
+ Vue.nextTick(() => {
expect(
- vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'),
- ).toEqual(true);
+ vm.$el.querySelector('.js-vue-toggle-button i').classList,
+ ).toContain('fa-chevron-down');
done();
});
});
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index 19504e4f7c8..cda550760fe 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -25,26 +25,34 @@ describe('issue_discussion component', () => {
});
it('should render user avatar', () => {
- expect(vm.$el.querySelector('.user-avatar-link')).toBeDefined();
+ expect(vm.$el.querySelector('.user-avatar-link')).not.toBeNull();
});
it('should render discussion header', () => {
- expect(vm.$el.querySelector('.discussion-header')).toBeDefined();
+ expect(vm.$el.querySelector('.discussion-header')).not.toBeNull();
expect(vm.$el.querySelector('.notes').children.length).toEqual(discussionMock.notes.length);
});
describe('actions', () => {
it('should render reply button', () => {
- expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual('Reply...');
+ expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual(
+ 'Reply...',
+ );
});
- it('should toggle reply form', (done) => {
+ it('should toggle reply form', done => {
vm.$el.querySelector('.js-vue-discussion-reply').click();
Vue.nextTick(() => {
- expect(vm.$refs.noteForm).toBeDefined();
+ expect(vm.$refs.noteForm).not.toBeNull();
expect(vm.isReplying).toEqual(true);
done();
});
});
+
+ it('does not render jump to discussion button', () => {
+ expect(
+ vm.$el.querySelector('*[data-original-title="Jump to next unresolved discussion"]'),
+ ).toBeNull();
+ });
});
});
diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js
index c8a6cb7e612..cfd037633e9 100644
--- a/spec/javascripts/notes/components/noteable_note_spec.js
+++ b/spec/javascripts/notes/components/noteable_note_spec.js
@@ -1,4 +1,5 @@
-
+import $ from 'jquery';
+import _ from 'underscore';
import Vue from 'vue';
import store from '~/notes/stores';
import issueNote from '~/notes/components/noteable_note.vue';
@@ -56,4 +57,25 @@ describe('issue_note', () => {
done();
}, 0);
});
+
+ describe('cancel edit', () => {
+ it('restores content of updated note', (done) => {
+ const noteBody = 'updated note text';
+ vm.updateNote = () => Promise.resolve();
+
+ vm.formUpdateHandler(noteBody, null, $.noop);
+
+ setTimeout(() => {
+ expect(vm.note.note_html).toEqual(noteBody);
+
+ vm.formCancelHandler();
+
+ setTimeout(() => {
+ expect(vm.note.note_html).toEqual(noteBody);
+
+ done();
+ });
+ });
+ });
+ });
});
diff --git a/spec/javascripts/notes/helpers.js b/spec/javascripts/notes/helpers.js
new file mode 100644
index 00000000000..a7663710a56
--- /dev/null
+++ b/spec/javascripts/notes/helpers.js
@@ -0,0 +1,12 @@
+// eslint-disable-next-line import/prefer-default-export
+export const resetStore = (store) => {
+ store.replaceState({
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ });
+};
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index 6b608adff15..24388fba219 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -1,12 +1,14 @@
-/* eslint-disable */
export const notesDataMock = {
discussionsPath: '/gitlab-org/gitlab-ce/issues/26/discussions.json',
- lastFetchedAt: '1501862675',
+ lastFetchedAt: 1501862675,
markdownDocsPath: '/help/user/markdown',
newSessionPath: '/users/sign_in?redirect_to_referer=yes',
notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
quickActionsDocsPath: '/help/user/project/quick_actions',
registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
+ totalNotes: 1,
+ closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close',
+ reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen',
};
export const userDataMock = {
@@ -29,7 +31,6 @@ export const noteableDataMock = {
can_create_note: true,
can_update: true,
},
- deleted_at: null,
description: '',
due_date: null,
human_time_estimate: null,
@@ -41,7 +42,8 @@ export const noteableDataMock = {
milestone: null,
milestone_id: null,
moved_to_id: null,
- preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue',
+ preview_note_path:
+ '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue',
project_id: 2,
state: 'opened',
time_estimate: 0,
@@ -50,6 +52,7 @@ export const noteableDataMock = {
updated_at: '2017-08-04T09:53:01.226Z',
updated_by_id: 1,
web_url: '/gitlab-org/gitlab-ce/issues/26',
+ noteableType: 'issue',
};
export const lastFetchedAt = '1501862675';
@@ -58,466 +61,504 @@ export const individualNote = {
expanded: true,
id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
individual_note: true,
- notes: [{
- id: 1390,
- attachment: {
- url: null,
- filename: null,
- image: false,
- },
- author: {
- id: 1,
- name: 'Root',
- username: 'root',
- state: 'active',
- avatar_url: 'test',
- path: '/root',
+ notes: [
+ {
+ id: 1390,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'test',
+ path: '/root',
+ },
+ created_at: '2017-08-01T17: 09: 33.762Z',
+ updated_at: '2017-08-01T17: 09: 33.762Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'sdfdsaf',
+ note_html: "<p dir='auto'>sdfdsaf</p>",
+ current_user: { can_edit: true },
+ discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ emoji_awardable: true,
+ award_emoji: [
+ { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } },
+ { name: 'art', user: { id: 1, name: 'Root', username: 'root' } },
+ ],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1390',
},
- created_at: '2017-08-01T17: 09: 33.762Z',
- updated_at: '2017-08-01T17: 09: 33.762Z',
- system: false,
- noteable_id: 98,
- noteable_type: 'Issue',
- type: null,
- human_access: 'Owner',
- note: 'sdfdsaf',
- note_html: '<p dir=\'auto\'>sdfdsaf</p>',
- current_user: { can_edit: true },
- discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
- emoji_awardable: true,
- award_emoji: [
- { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } },
- { name: 'art', user: { id: 1, name: 'Root', username: 'root' } },
- ],
- toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji',
- report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1',
- path: '/gitlab-org/gitlab-ce/notes/1390',
- }],
+ ],
reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
};
export const note = {
- "id": 546,
- "attachment": {
- "url": null,
- "filename": null,
- "image": false
+ id: 546,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
},
- "author": {
- "id": 1,
- "name": "Administrator",
- "username": "root",
- "state": "active",
- "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
- "path": "/root"
+ author: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ path: '/root',
},
- "created_at": "2017-08-10T15:24:03.087Z",
- "updated_at": "2017-08-10T15:24:03.087Z",
- "system": false,
- "noteable_id": 67,
- "noteable_type": "Issue",
- "noteable_iid": 7,
- "type": null,
- "human_access": "Owner",
- "note": "Vel id placeat reprehenderit sit numquam.",
- "note_html": "<p dir=\"auto\">Vel id placeat reprehenderit sit numquam.</p>",
- "current_user": {
- "can_edit": true
+ created_at: '2017-08-10T15:24:03.087Z',
+ updated_at: '2017-08-10T15:24:03.087Z',
+ system: false,
+ noteable_id: 67,
+ noteable_type: 'Issue',
+ noteable_iid: 7,
+ type: null,
+ human_access: 'Owner',
+ note: 'Vel id placeat reprehenderit sit numquam.',
+ note_html: '<p dir="auto">Vel id placeat reprehenderit sit numquam.</p>',
+ current_user: {
+ can_edit: true,
},
- "discussion_id": "d3842a451b7f3d9a5dfce329515127b2d29a4cd0",
- "emoji_awardable": true,
- "award_emoji": [{
- "name": "baseball",
- "user": {
- "id": 1,
- "name": "Administrator",
- "username": "root"
- }
- }, {
- "name": "bath_tone3",
- "user": {
- "id": 1,
- "name": "Administrator",
- "username": "root"
- }
- }],
- "toggle_award_path": "/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji",
- "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1",
- "path": "/gitlab-org/gitlab-ce/notes/546"
- }
+ discussion_id: 'd3842a451b7f3d9a5dfce329515127b2d29a4cd0',
+ emoji_awardable: true,
+ award_emoji: [
+ {
+ name: 'baseball',
+ user: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ },
+ },
+ {
+ name: 'bath_tone3',
+ user: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ },
+ },
+ ],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/546',
+};
export const discussionMock = {
id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
expanded: true,
- notes: [{
- id: 1395,
- attachment: {
- url: null,
- filename: null,
- image: false,
- },
- author: {
- id: 1,
- name: 'Root',
- username: 'root',
- state: 'active',
- avatar_url: null,
- path: '/root',
- },
- created_at: '2017-08-02T10:51:58.559Z',
- updated_at: '2017-08-02T10:51:58.559Z',
- system: false,
- noteable_id: 98,
- noteable_type: 'Issue',
- type: 'DiscussionNote',
- human_access: 'Owner',
- note: 'THIS IS A DICUSSSION!',
- note_html: '<p dir=\'auto\'>THIS IS A DICUSSSION!</p>',
- current_user: {
- can_edit: true,
- },
- discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
- emoji_awardable: true,
- award_emoji: [],
- toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji',
- report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1',
- path: '/gitlab-org/gitlab-ce/notes/1395',
- }, {
- id: 1396,
- attachment: {
- url: null,
- filename: null,
- image: false,
- },
- author: {
- id: 1,
- name: 'Root',
- username: 'root',
- state: 'active',
- avatar_url: null,
- path: '/root',
- },
- created_at: '2017-08-02T10:56:50.980Z',
- updated_at: '2017-08-03T14:19:35.691Z',
- system: false,
- noteable_id: 98,
- noteable_type: 'Issue',
- type: 'DiscussionNote',
- human_access: 'Owner',
- note: 'sadfasdsdgdsf',
- note_html: '<p dir=\'auto\'>sadfasdsdgdsf</p>',
- last_edited_at: '2017-08-03T14:19:35.691Z',
- last_edited_by: {
- id: 1,
- name: 'Root',
- username: 'root',
- state: 'active',
- avatar_url: null,
- path: '/root',
- },
- current_user: {
- can_edit: true,
+ notes: [
+ {
+ id: 1395,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:51:58.559Z',
+ updated_at: '2017-08-02T10:51:58.559Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'THIS IS A DICUSSSION!',
+ note_html: "<p dir='auto'>THIS IS A DICUSSSION!</p>",
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1395',
},
- discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
- emoji_awardable: true,
- award_emoji: [],
- toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji',
- report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1',
- path: '/gitlab-org/gitlab-ce/notes/1396',
- }, {
- id: 1437,
- attachment: {
- url: null,
- filename: null,
- image: false,
+ {
+ id: 1396,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:56:50.980Z',
+ updated_at: '2017-08-03T14:19:35.691Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'sadfasdsdgdsf',
+ note_html: "<p dir='auto'>sadfasdsdgdsf</p>",
+ last_edited_at: '2017-08-03T14:19:35.691Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1396',
},
- author: {
- id: 1,
- name: 'Root',
- username: 'root',
- state: 'active',
- avatar_url: null,
- path: '/root',
+ {
+ id: 1437,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-03T18:11:18.780Z',
+ updated_at: '2017-08-04T09:52:31.062Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'adsfasf Should disappear',
+ note_html: "<p dir='auto'>adsfasf Should disappear</p>",
+ last_edited_at: '2017-08-04T09:52:31.062Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1437',
},
- created_at: '2017-08-03T18:11:18.780Z',
- updated_at: '2017-08-04T09:52:31.062Z',
- system: false,
- noteable_id: 98,
- noteable_type: 'Issue',
- type: 'DiscussionNote',
- human_access: 'Owner',
- note: 'adsfasf Should disappear',
- note_html: '<p dir=\'auto\'>adsfasf Should disappear</p>',
- last_edited_at: '2017-08-04T09:52:31.062Z',
- last_edited_by: {
+ ],
+ individual_note: false,
+};
+
+export const loggedOutnoteableData = {
+ id: 98,
+ iid: 26,
+ author_id: 1,
+ description: '',
+ lock_version: 1,
+ milestone_id: null,
+ state: 'opened',
+ title: 'asdsa',
+ updated_by_id: 1,
+ created_at: '2017-02-07T10:11:18.395Z',
+ updated_at: '2017-08-08T10:22:51.564Z',
+ time_estimate: 0,
+ total_time_spent: 0,
+ human_time_estimate: null,
+ human_total_time_spent: null,
+ milestone: null,
+ labels: [],
+ branch_name: null,
+ confidential: false,
+ assignees: [
+ {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: null,
- path: '/root',
+ web_url: 'http://localhost:3000/root',
},
- current_user: {
- can_edit: true,
- },
- discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
- emoji_awardable: true,
- award_emoji: [],
- toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji',
- report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1',
- path: '/gitlab-org/gitlab-ce/notes/1437',
- }],
- individual_note: false,
-};
-
-export const loggedOutnoteableData = {
- "id": 98,
- "iid": 26,
- "author_id": 1,
- "description": "",
- "lock_version": 1,
- "milestone_id": null,
- "state": "opened",
- "title": "asdsa",
- "updated_by_id": 1,
- "created_at": "2017-02-07T10:11:18.395Z",
- "updated_at": "2017-08-08T10:22:51.564Z",
- "deleted_at": null,
- "time_estimate": 0,
- "total_time_spent": 0,
- "human_time_estimate": null,
- "human_total_time_spent": null,
- "milestone": null,
- "labels": [],
- "branch_name": null,
- "confidential": false,
- "assignees": [{
- "id": 1,
- "name": "Root",
- "username": "root",
- "state": "active",
- "avatar_url": null,
- "web_url": "http://localhost:3000/root"
- }],
- "due_date": null,
- "moved_to_id": null,
- "project_id": 2,
- "web_url": "/gitlab-org/gitlab-ce/issues/26",
- "current_user": {
- "can_create_note": false,
- "can_update": false
+ ],
+ due_date: null,
+ moved_to_id: null,
+ project_id: 2,
+ web_url: '/gitlab-org/gitlab-ce/issues/26',
+ current_user: {
+ can_create_note: false,
+ can_update: false,
},
- "create_note_path": "/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue",
- "preview_note_path": "/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue"
-}
+ create_note_path: '/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue',
+ preview_note_path:
+ '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue',
+};
export const INDIVIDUAL_NOTE_RESPONSE_MAP = {
- 'GET': {
- '/gitlab-org/gitlab-ce/issues/26/discussions.json': [{
- "id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
- "reply_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
- "expanded": true,
- "notes": [{
- "id": 1390,
- "attachment": {
- "url": null,
- "filename": null,
- "image": false
- },
- "author": {
- "id": 1,
- "name": "Root",
- "username": "root",
- "state": "active",
- "avatar_url": null,
- "path": "/root"
- },
- "created_at": "2017-08-01T17:09:33.762Z",
- "updated_at": "2017-08-01T17:09:33.762Z",
- "system": false,
- "noteable_id": 98,
- "noteable_type": "Issue",
- "type": null,
- "human_access": "Owner",
- "note": "sdfdsaf",
- "note_html": "\u003cp dir=\"auto\"\u003esdfdsaf\u003c/p\u003e",
- "current_user": {
- "can_edit": true
- },
- "discussion_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
- "emoji_awardable": true,
- "award_emoji": [{
- "name": "baseball",
- "user": {
- "id": 1,
- "name": "Root",
- "username": "root"
- }
- }, {
- "name": "art",
- "user": {
- "id": 1,
- "name": "Root",
- "username": "root"
- }
- }],
- "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji",
- "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1",
- "path": "/gitlab-org/gitlab-ce/notes/1390"
- }],
- "individual_note": true
- }, {
- "id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
- "reply_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
- "expanded": true,
- "notes": [{
- "id": 1391,
- "attachment": {
- "url": null,
- "filename": null,
- "image": false
- },
- "author": {
- "id": 1,
- "name": "Root",
- "username": "root",
- "state": "active",
- "avatar_url": null,
- "path": "/root"
- },
- "created_at": "2017-08-02T10:51:38.685Z",
- "updated_at": "2017-08-02T10:51:38.685Z",
- "system": false,
- "noteable_id": 98,
- "noteable_type": "Issue",
- "type": null,
- "human_access": "Owner",
- "note": "New note!",
- "note_html": "\u003cp dir=\"auto\"\u003eNew note!\u003c/p\u003e",
- "current_user": {
- "can_edit": true
- },
- "discussion_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
- "emoji_awardable": true,
- "award_emoji": [],
- "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji",
- "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1",
- "path": "/gitlab-org/gitlab-ce/notes/1391"
- }],
- "individual_note": true
- }],
+ GET: {
+ '/gitlab-org/gitlab-ce/issues/26/discussions.json': [
+ {
+ id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ expanded: true,
+ notes: [
+ {
+ id: 1390,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-01T17:09:33.762Z',
+ updated_at: '2017-08-01T17:09:33.762Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'sdfdsaf',
+ note_html: '\u003cp dir="auto"\u003esdfdsaf\u003c/p\u003e',
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ emoji_awardable: true,
+ award_emoji: [
+ {
+ name: 'baseball',
+ user: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ },
+ },
+ {
+ name: 'art',
+ user: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ },
+ },
+ ],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1390',
+ },
+ ],
+ individual_note: true,
+ },
+ {
+ id: '70d5c92a4039a36c70100c6691c18c27e4b0a790',
+ reply_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790',
+ expanded: true,
+ notes: [
+ {
+ id: 1391,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:51:38.685Z',
+ updated_at: '2017-08-02T10:51:38.685Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'New note!',
+ note_html: '\u003cp dir="auto"\u003eNew note!\u003c/p\u003e',
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1391',
+ },
+ ],
+ individual_note: true,
+ },
+ ],
'/gitlab-org/gitlab-ce/noteable/issue/98/notes': {
last_fetched_at: 1512900838,
notes: [],
},
},
- 'PUT': {
+ PUT: {
'/gitlab-org/gitlab-ce/notes/1471': {
- "commands_changes": null,
- "valid": true,
- "id": 1471,
- "attachment": null,
- "author": {
- "id": 1,
- "name": "Root",
- "username": "root",
- "state": "active",
- "avatar_url": null,
- "path": "/root"
+ commands_changes: null,
+ valid: true,
+ id: 1471,
+ attachment: null,
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
},
- "created_at": "2017-08-08T16:53:00.666Z",
- "updated_at": "2017-12-10T11:03:21.876Z",
- "system": false,
- "noteable_id": 124,
- "noteable_type": "Issue",
- "noteable_iid": 29,
- "type": "DiscussionNote",
- "human_access": "Owner",
- "note": "Adding a comment",
- "note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e",
- "last_edited_at": "2017-12-10T11:03:21.876Z",
- "last_edited_by": {
- "id": 1,
- "name": 'Root',
- "username": 'root',
- "state": 'active',
- "avatar_url": null,
- "path": '/root',
+ created_at: '2017-08-08T16:53:00.666Z',
+ updated_at: '2017-12-10T11:03:21.876Z',
+ system: false,
+ noteable_id: 124,
+ noteable_type: 'Issue',
+ noteable_iid: 29,
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'Adding a comment',
+ note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e',
+ last_edited_at: '2017-12-10T11:03:21.876Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
},
- "current_user": {
- "can_edit": true
+ current_user: {
+ can_edit: true,
},
- "discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
- "emoji_awardable": true,
- "award_emoji": [],
- "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji",
- "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1",
- "path": "/gitlab-org/gitlab-ce/notes/1471"
+ discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1471',
},
- }
+ },
};
export const DISCUSSION_NOTE_RESPONSE_MAP = {
...INDIVIDUAL_NOTE_RESPONSE_MAP,
- 'GET': {
+ GET: {
...INDIVIDUAL_NOTE_RESPONSE_MAP.GET,
- '/gitlab-org/gitlab-ce/issues/26/discussions.json': [{
- "id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
- "reply_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
- "expanded": true,
- "notes": [{
- "id": 1471,
- "attachment": {
- "url": null,
- "filename": null,
- "image": false
- },
- "author": {
- "id": 1,
- "name": "Root",
- "username": "root",
- "state": "active",
- "avatar_url": null,
- "path": "/root"
- },
- "created_at": "2017-08-08T16:53:00.666Z",
- "updated_at": "2017-08-08T16:53:00.666Z",
- "system": false,
- "noteable_id": 124,
- "noteable_type": "Issue",
- "noteable_iid": 29,
- "type": "DiscussionNote",
- "human_access": "Owner",
- "note": "Adding a comment",
- "note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e",
- "current_user": {
- "can_edit": true
- },
- "discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
- "emoji_awardable": true,
- "award_emoji": [],
- "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji",
- "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1",
- "path": "/gitlab-org/gitlab-ce/notes/1471"
- }],
- "individual_note": false
- }],
+ '/gitlab-org/gitlab-ce/issues/26/discussions.json': [
+ {
+ id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ reply_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ expanded: true,
+ notes: [
+ {
+ id: 1471,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-08T16:53:00.666Z',
+ updated_at: '2017-08-08T16:53:00.666Z',
+ system: false,
+ noteable_id: 124,
+ noteable_type: 'Issue',
+ noteable_iid: 29,
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'Adding a comment',
+ note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e',
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji',
+ report_abuse_path:
+ '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1471',
+ },
+ ],
+ individual_note: false,
+ },
+ ],
},
};
export function individualNoteInterceptor(request, next) {
const body = INDIVIDUAL_NOTE_RESPONSE_MAP[request.method.toUpperCase()][request.url];
- next(request.respondWith(JSON.stringify(body), {
- status: 200,
- }));
+ next(
+ request.respondWith(JSON.stringify(body), {
+ status: 200,
+ }),
+ );
}
export function discussionNoteInterceptor(request, next) {
const body = DISCUSSION_NOTE_RESPONSE_MAP[request.method.toUpperCase()][request.url];
- next(request.respondWith(JSON.stringify(body), {
- status: 200,
- }));
+ next(
+ request.respondWith(JSON.stringify(body), {
+ status: 200,
+ }),
+ );
}
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
index e092320f9a3..520a25cc5c6 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -1,61 +1,274 @@
+import Vue from 'vue';
+import _ from 'underscore';
+import { headersInterceptor } from 'spec/helpers/vue_resource_helper';
import * as actions from '~/notes/stores/actions';
+import store from '~/notes/stores';
import testAction from '../../helpers/vuex_action_helper';
-import { discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data';
+import { resetStore } from '../helpers';
+import {
+ discussionMock,
+ notesDataMock,
+ userDataMock,
+ noteableDataMock,
+ individualNote,
+} from '../mock_data';
describe('Actions Notes Store', () => {
+ afterEach(() => {
+ resetStore(store);
+ });
+
describe('setNotesData', () => {
- it('should set received notes data', (done) => {
- testAction(actions.setNotesData, null, { notesData: {} }, [
- { type: 'SET_NOTES_DATA', payload: notesDataMock },
- ], done);
+ it('should set received notes data', done => {
+ testAction(
+ actions.setNotesData,
+ notesDataMock,
+ { notesData: {} },
+ [{ type: 'SET_NOTES_DATA', payload: notesDataMock }],
+ [],
+ done,
+ );
});
});
describe('setNoteableData', () => {
- it('should set received issue data', (done) => {
- testAction(actions.setNoteableData, null, { noteableData: {} }, [
- { type: 'SET_NOTEABLE_DATA', payload: noteableDataMock },
- ], done);
+ it('should set received issue data', done => {
+ testAction(
+ actions.setNoteableData,
+ noteableDataMock,
+ { noteableData: {} },
+ [{ type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }],
+ [],
+ done,
+ );
});
});
describe('setUserData', () => {
- it('should set received user data', (done) => {
- testAction(actions.setUserData, null, { userData: {} }, [
- { type: 'SET_USER_DATA', payload: userDataMock },
- ], done);
+ it('should set received user data', done => {
+ testAction(
+ actions.setUserData,
+ userDataMock,
+ { userData: {} },
+ [{ type: 'SET_USER_DATA', payload: userDataMock }],
+ [],
+ done,
+ );
});
});
describe('setLastFetchedAt', () => {
- it('should set received timestamp', (done) => {
- testAction(actions.setLastFetchedAt, null, { lastFetchedAt: {} }, [
- { type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' },
- ], done);
+ it('should set received timestamp', done => {
+ testAction(
+ actions.setLastFetchedAt,
+ 'timestamp',
+ { lastFetchedAt: {} },
+ [{ type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }],
+ [],
+ done,
+ );
});
});
describe('setInitialNotes', () => {
- it('should set initial notes', (done) => {
- testAction(actions.setInitialNotes, null, { notes: [] }, [
- { type: 'SET_INITIAL_NOTES', payload: [individualNote] },
- ], done);
+ it('should set initial notes', done => {
+ testAction(
+ actions.setInitialNotes,
+ [individualNote],
+ { notes: [] },
+ [{ type: 'SET_INITIAL_NOTES', payload: [individualNote] }],
+ [],
+ done,
+ );
});
});
describe('setTargetNoteHash', () => {
- it('should set target note hash', (done) => {
- testAction(actions.setTargetNoteHash, null, { notes: [] }, [
- { type: 'SET_TARGET_NOTE_HASH', payload: 'hash' },
- ], done);
+ it('should set target note hash', done => {
+ testAction(
+ actions.setTargetNoteHash,
+ 'hash',
+ { notes: [] },
+ [{ type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }],
+ [],
+ done,
+ );
});
});
describe('toggleDiscussion', () => {
- it('should toggle discussion', (done) => {
- testAction(actions.toggleDiscussion, null, { notes: [discussionMock] }, [
- { type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } },
- ], done);
+ it('should toggle discussion', done => {
+ testAction(
+ actions.toggleDiscussion,
+ { discussionId: discussionMock.id },
+ { notes: [discussionMock] },
+ [{ type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('async methods', () => {
+ const interceptor = (request, next) => {
+ next(
+ request.respondWith(JSON.stringify({}), {
+ status: 200,
+ }),
+ );
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(interceptor);
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ });
+
+ describe('closeIssue', () => {
+ it('sets state as closed', done => {
+ store
+ .dispatch('closeIssue', { notesData: { closeIssuePath: '' } })
+ .then(() => {
+ expect(store.state.noteableData.state).toEqual('closed');
+ expect(store.state.isToggleStateButtonLoading).toEqual(false);
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('reopenIssue', () => {
+ it('sets state as reopened', done => {
+ store
+ .dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } })
+ .then(() => {
+ expect(store.state.noteableData.state).toEqual('reopened');
+ expect(store.state.isToggleStateButtonLoading).toEqual(false);
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('emitStateChangedEvent', () => {
+ it('emits an event on the document', () => {
+ document.addEventListener('issuable_vue_app:change', event => {
+ expect(event.detail.data).toEqual({ id: '1', state: 'closed' });
+ expect(event.detail.isClosed).toEqual(false);
+ });
+
+ store.dispatch('emitStateChangedEvent', { id: '1', state: 'closed' });
+ });
+ });
+
+ describe('toggleStateButtonLoading', () => {
+ it('should set loading as true', done => {
+ testAction(
+ actions.toggleStateButtonLoading,
+ true,
+ {},
+ [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }],
+ [],
+ done,
+ );
+ });
+
+ it('should set loading as false', done => {
+ testAction(
+ actions.toggleStateButtonLoading,
+ false,
+ {},
+ [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleIssueLocalState', () => {
+ it('sets issue state as closed', done => {
+ testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], [], done);
+ });
+
+ it('sets issue state as reopened', done => {
+ testAction(actions.toggleIssueLocalState, 'reopened', {}, [{ type: 'REOPEN_ISSUE' }], [], done);
+ });
+ });
+
+ describe('poll', () => {
+ beforeEach(done => {
+ jasmine.clock().install();
+
+ spyOn(Vue.http, 'get').and.callThrough();
+
+ store
+ .dispatch('setNotesData', notesDataMock)
+ .then(done)
+ .catch(done.fail);
+ });
+
+ afterEach(() => {
+ jasmine.clock().uninstall();
+ });
+
+ it('calls service with last fetched state', done => {
+ const interceptor = (request, next) => {
+ next(
+ request.respondWith(
+ JSON.stringify({
+ notes: [],
+ last_fetched_at: '123456',
+ }),
+ {
+ status: 200,
+ headers: {
+ 'poll-interval': '1000',
+ },
+ },
+ ),
+ );
+ };
+
+ Vue.http.interceptors.push(interceptor);
+ Vue.http.interceptors.push(headersInterceptor);
+
+ store
+ .dispatch('poll')
+ .then(() => new Promise(resolve => requestAnimationFrame(resolve)))
+ .then(() => {
+ expect(Vue.http.get).toHaveBeenCalledWith(jasmine.anything(), {
+ url: jasmine.anything(),
+ method: 'get',
+ headers: {
+ 'X-Last-Fetched-At': undefined,
+ },
+ });
+ expect(store.state.lastFetchedAt).toBe('123456');
+
+ jasmine.clock().tick(1500);
+ })
+ .then(
+ () =>
+ new Promise(resolve => {
+ requestAnimationFrame(resolve);
+ }),
+ )
+ .then(() => {
+ expect(Vue.http.get.calls.count()).toBe(2);
+ expect(Vue.http.get.calls.mostRecent().args[1].headers).toEqual({
+ 'X-Last-Fetched-At': '123456',
+ });
+ })
+ .then(() => store.dispatch('stopPolling'))
+ .then(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor);
+ })
+ .then(done)
+ .catch(done.fail);
});
});
});
diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js
index c5a84b71788..8b2a8d2cd7a 100644
--- a/spec/javascripts/notes/stores/getters_spec.js
+++ b/spec/javascripts/notes/stores/getters_spec.js
@@ -55,4 +55,10 @@ describe('Getters Notes Store', () => {
expect(getters.getCurrentUserLastNote(state)).toEqual(individualNote.notes[0]);
});
});
+
+ describe('openState', () => {
+ it('should return the issue state', () => {
+ expect(getters.openState(state)).toEqual(noteableDataMock.state);
+ });
+ });
});
diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js
index 22d99998a7d..98f101d6bc5 100644
--- a/spec/javascripts/notes/stores/mutation_spec.js
+++ b/spec/javascripts/notes/stores/mutation_spec.js
@@ -1,7 +1,7 @@
import mutations from '~/notes/stores/mutations';
import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data';
-describe('Mutation Notes Store', () => {
+describe('Notes Store mutations', () => {
describe('ADD_NEW_NOTE', () => {
let state;
let noteData;
@@ -101,9 +101,21 @@ describe('Mutation Notes Store', () => {
const state = {
notes: [],
};
+ const legacyNote = {
+ id: 2,
+ individual_note: true,
+ notes: [{
+ note: '1',
+ }, {
+ note: '2',
+ }],
+ };
- mutations.SET_INITIAL_NOTES(state, [note]);
- expect(state.notes).toEqual([note]);
+ mutations.SET_INITIAL_NOTES(state, [note, legacyNote]);
+ expect(state.notes[0].id).toEqual(note.id);
+ expect(state.notes[1].notes[0].note).toBe(legacyNote.notes[0].note);
+ expect(state.notes[2].notes[0].note).toBe(legacyNote.notes[1].note);
+ expect(state.notes.length).toEqual(3);
});
});
@@ -216,4 +228,70 @@ describe('Mutation Notes Store', () => {
expect(state.notes[0].notes[0].note).toEqual('Foo');
});
});
+
+ describe('CLOSE_ISSUE', () => {
+ it('should set issue as closed', () => {
+ const state = {
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: false,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.CLOSE_ISSUE(state);
+ expect(state.noteableData.state).toEqual('closed');
+ });
+ });
+
+ describe('REOPEN_ISSUE', () => {
+ it('should set issue as closed', () => {
+ const state = {
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: false,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.REOPEN_ISSUE(state);
+ expect(state.noteableData.state).toEqual('reopened');
+ });
+ });
+
+ describe('TOGGLE_STATE_BUTTON_LOADING', () => {
+ it('should set isToggleStateButtonLoading as true', () => {
+ const state = {
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: false,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.TOGGLE_STATE_BUTTON_LOADING(state, true);
+ expect(state.isToggleStateButtonLoading).toEqual(true);
+ });
+
+ it('should set isToggleStateButtonLoading as false', () => {
+ const state = {
+ notes: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: true,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.TOGGLE_STATE_BUTTON_LOADING(state, false);
+ expect(state.isToggleStateButtonLoading).toEqual(false);
+ });
+ });
});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 167f074fb9b..ec56ab0e2f0 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -1,25 +1,30 @@
/* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */
+import $ from 'jquery';
+import _ from 'underscore';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import 'autosize';
import '~/gl_form';
import '~/lib/utils/text_utility';
-import '~/render_gfm';
+import '~/behaviors/markdown/render_gfm';
import Notes from '~/notes';
+import timeoutPromise from './helpers/set_timeout_promise_helper';
(function() {
window.gon || (window.gon = {});
window.gl = window.gl || {};
gl.utils = gl.utils || {};
- const htmlEscape = (comment) => {
- const escapedString = comment.replace(/["&'<>]/g, (a) => {
+ const htmlEscape = comment => {
+ const escapedString = comment.replace(/["&'<>]/g, a => {
const escapedToken = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
- '`': '&#x60;'
+ '`': '&#x60;',
}[a];
return escapedToken;
@@ -30,10 +35,11 @@ import Notes from '~/notes';
describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert';
+ const NOTES_POST_PATH = /(.*)\/notes\?html=true$/;
var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(commentsTemplate);
- beforeEach(function () {
+ beforeEach(function() {
loadFixtures(commentsTemplate);
gl.utils.disableButtonIfEmptyField = _.noop;
window.project_uploads_path = 'http://test.host/uploads';
@@ -45,30 +51,68 @@ import Notes from '~/notes';
$('body').removeAttr('data-page');
});
+ describe('addBinding', () => {
+ it('calls postComment when comment button is clicked', () => {
+ spyOn(Notes.prototype, 'postComment');
+ this.notes = new Notes('', []);
+
+ $('.js-comment-button').click();
+
+ expect(Notes.prototype.postComment).toHaveBeenCalled();
+ });
+ });
+
describe('task lists', function() {
+ let mock;
+
beforeEach(function() {
+ spyOn(axios, 'patch').and.callThrough();
+ mock = new MockAdapter(axios);
+
+ mock
+ .onPatch(
+ `${
+ gl.TEST_HOST
+ }/frontend-fixtures/merge-requests-project/merge_requests/1.json`,
+ )
+ .reply(200, {});
+
$('.js-comment-button').on('click', function(e) {
e.preventDefault();
});
this.notes = new Notes('', []);
});
+ afterEach(() => {
+ mock.restore();
+ });
+
it('modifies the Markdown field', function() {
const changeEvent = document.createEvent('HTMLEvents');
changeEvent.initEvent('change', true, true);
- $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent);
+ $('input[type=checkbox]')
+ .attr('checked', true)[1]
+ .dispatchEvent(changeEvent);
- expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item');
+ expect($('.js-task-list-field.original-task-list').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');
- expect(req.url).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1.json');
- return expect(req.data.note).not.toBe(null);
+ it('submits an ajax request on tasklist:changed', function(done) {
+ $('.js-task-list-container').trigger('tasklist:changed');
+
+ setTimeout(() => {
+ expect(axios.patch).toHaveBeenCalledWith(
+ `${
+ gl.TEST_HOST
+ }/frontend-fixtures/merge-requests-project/merge_requests/1.json`,
+ {
+ note: { note: '' },
+ },
+ );
+ done();
});
-
- $('.js-task-list-field.js-note-text').trigger('tasklist:changed');
});
});
@@ -82,10 +126,10 @@ import Notes from '~/notes';
spyOn(this.notes, 'renderNote').and.stub();
$(textarea).data('autosave', {
- reset: function() {}
+ reset: function() {},
});
- $('.js-comment-button').on('click', (e) => {
+ $('.js-comment-button').on('click', e => {
const $form = $(this);
e.preventDefault();
this.notes.addNote($form);
@@ -118,6 +162,7 @@ import Notes from '~/notes';
let noteEntity;
let $form;
let $notesContainer;
+ let mock;
beforeEach(() => {
this.notes = new Notes('', []);
@@ -130,29 +175,39 @@ import Notes from '~/notes';
<div class="note-text">${sampleComment}</div>
</li>`,
note: sampleComment,
- valid: true
+ valid: true,
};
$form = $('form.js-main-target-form');
$notesContainer = $('ul.main-notes-list');
$form.find('textarea.js-note-text').val(sampleComment);
+
+ mock = new MockAdapter(axios);
+ mock.onPost(NOTES_POST_PATH).reply(200, noteEntity);
});
- it('updates note and resets edit form', () => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('updates note and resets edit form', done => {
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);
+ setTimeout(() => {
+ 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();
+ expect(this.notes.revertNoteEditForm).toHaveBeenCalledWith(
+ $targetNote,
+ );
+ expect(this.notes.setupNewNote).toHaveBeenCalled();
+
+ done();
+ });
});
});
@@ -204,17 +259,14 @@ import Notes from '~/notes';
note: 'heya',
html: '<div>heya</div>',
};
- $notesList = jasmine.createSpyObj('$notesList', [
- 'find',
- 'append',
- ]);
+ $notesList = jasmine.createSpyObj('$notesList', ['find', 'append']);
notes = jasmine.createSpyObj('notes', [
'setupNewNote',
'refresh',
'collapseLongCommitList',
'updateNotesCount',
- 'putConflictEditWarningInPlace'
+ 'putConflictEditWarningInPlace',
]);
notes.taskList = jasmine.createSpyObj('tasklist', ['init']);
notes.note_ids = [];
@@ -231,7 +283,10 @@ import Notes from '~/notes';
Notes.isNewNote.and.returnValue(true);
Notes.prototype.renderNote.call(notes, note, null, $notesList);
- expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList);
+ expect(Notes.animateAppendNote).toHaveBeenCalledWith(
+ note.html,
+ $notesList,
+ );
});
});
@@ -246,7 +301,10 @@ import Notes from '~/notes';
Notes.prototype.renderNote.call(notes, note, null, $notesList);
- expect(Notes.animateUpdateNote).toHaveBeenCalledWith(note.html, $note);
+ expect(Notes.animateUpdateNote).toHaveBeenCalledWith(
+ note.html,
+ $note,
+ );
expect(notes.setupNewNote).toHaveBeenCalledWith($newNote);
});
@@ -274,7 +332,10 @@ import Notes from '~/notes';
$notesList.find.and.returnValue($note);
Notes.prototype.renderNote.call(notes, note, null, $notesList);
- expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith(note, $note);
+ expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith(
+ note,
+ $note,
+ );
});
});
});
@@ -284,11 +345,11 @@ import Notes from '~/notes';
it('should consider same note text as the same', () => {
const result = Notes.isUpdatedNote(
{
- note: 'initial'
+ note: 'initial',
},
$(`<div>
<div class="original-note-content">initial</div>
- </div>`)
+ </div>`),
);
expect(result).toEqual(false);
@@ -297,11 +358,11 @@ import Notes from '~/notes';
it('should consider same note with trailing newline as the same', () => {
const result = Notes.isUpdatedNote(
{
- note: 'initial\n'
+ note: 'initial\n',
},
$(`<div>
<div class="original-note-content">initial\n</div>
- </div>`)
+ </div>`),
);
expect(result).toEqual(false);
@@ -310,11 +371,11 @@ import Notes from '~/notes';
it('should consider different notes as different', () => {
const result = Notes.isUpdatedNote(
{
- note: 'foo'
+ note: 'foo',
},
$(`<div>
<div class="original-note-content">bar</div>
- </div>`)
+ </div>`),
);
expect(result).toEqual(true);
@@ -370,7 +431,10 @@ import Notes from '~/notes';
it('should call Notes.animateAppendNote', () => {
Notes.prototype.renderDiscussionNote.call(notes, note, $form);
- expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $('.main-notes-list'));
+ expect(Notes.animateAppendNote).toHaveBeenCalledWith(
+ note.discussion_html,
+ $('.main-notes-list'),
+ );
});
it('should append to row selected with line_code', () => {
@@ -401,7 +465,10 @@ import Notes from '~/notes';
});
it('should call Notes.animateAppendNote', () => {
- expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer);
+ expect(Notes.animateAppendNote).toHaveBeenCalledWith(
+ note.html,
+ discussionContainer,
+ );
});
});
});
@@ -434,9 +501,7 @@ import Notes from '~/notes';
beforeEach(() => {
noteHTML = '<div></div>';
- $note = jasmine.createSpyObj('$note', [
- 'replaceWith'
- ]);
+ $note = jasmine.createSpyObj('$note', ['replaceWith']);
$updatedNote = Notes.animateUpdateNote(noteHTML, $note);
});
@@ -474,12 +539,23 @@ import Notes from '~/notes';
<div class="note-text">${sampleComment}</div>
</li>`,
note: sampleComment,
- valid: true
+ valid: true,
};
let $form;
let $notesContainer;
+ let mock;
+
+ function mockNotesPost() {
+ mock.onPost(NOTES_POST_PATH).reply(200, note);
+ }
+
+ function mockNotesPostError() {
+ mock.onPost(NOTES_POST_PATH).networkError();
+ }
beforeEach(() => {
+ mock = new MockAdapter(axios);
+
this.notes = new Notes('', []);
window.gon.current_username = 'root';
window.gon.current_user_fullname = 'Administrator';
@@ -488,63 +564,129 @@ import Notes from '~/notes';
$form.find('textarea.js-note-text').val(sampleComment);
});
+ afterEach(() => {
+ mock.restore();
+ });
+
it('should show placeholder note while new comment is being posted', () => {
+ mockNotesPost();
+
$('.js-comment-button').click();
- expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true);
+ 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());
+ it('should remove placeholder note when new comment is done posting', done => {
+ mockNotesPost();
+
$('.js-comment-button').click();
- deferred.resolve(note);
- expect($notesContainer.find('.note.being-posted').length).toEqual(0);
+ setTimeout(() => {
+ expect($notesContainer.find('.note.being-posted').length).toEqual(0);
+
+ done();
+ });
});
- 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();
+ describe('postComment', () => {
+ it('disables the submit button', done => {
+ const $submitButton = $form.find('.js-comment-submit-button');
+ expect($submitButton).not.toBeDisabled();
+ const dummyEvent = {
+ preventDefault() {},
+ target: $submitButton,
+ };
+ mock.onPost(NOTES_POST_PATH).replyOnce(() => {
+ expect($submitButton).toBeDisabled();
+ return [200, note];
+ });
- deferred.resolve(note);
- expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true);
+ this.notes
+ .postComment(dummyEvent)
+ .then(() => {
+ expect($submitButton).not.toBeDisabled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
});
- it('should reset Form when new comment is done posting', () => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
+ it('should show actual note element when new comment is done posting', done => {
+ mockNotesPost();
+
$('.js-comment-button').click();
- deferred.resolve(note);
- expect($form.find('textarea.js-note-text').val()).toEqual('');
+ setTimeout(() => {
+ expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(
+ true,
+ );
+
+ done();
+ });
});
- it('should show flash error message when new comment failed to be posted', () => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
+ it('should reset Form when new comment is done posting', done => {
+ mockNotesPost();
+
$('.js-comment-button').click();
- deferred.reject();
- expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true);
+ setTimeout(() => {
+ expect($form.find('textarea.js-note-text').val()).toEqual('');
+
+ done();
+ });
});
- it('should show flash error message when comment failed to be updated', () => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
+ it('should show flash error message when new comment failed to be posted', done => {
+ mockNotesPostError();
+
$('.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();
+ setTimeout(() => {
+ expect(
+ $notesContainer
+ .parent()
+ .find('.flash-container .flash-text')
+ .is(':visible'),
+ ).toEqual(true);
- 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
+ done();
+ });
+ });
+
+ it('should show flash error message when comment failed to be updated', done => {
+ mockNotesPost();
+
+ $('.js-comment-button').click();
+
+ timeoutPromise()
+ .then(() => {
+ const $noteEl = $notesContainer.find(`#note_${note.id}`);
+ $noteEl.find('.js-note-edit').click();
+ $noteEl.find('textarea.js-note-text').val(updatedComment);
+
+ mock.restore();
+
+ mockNotesPostError();
+
+ $noteEl.find('.js-comment-save-button').click();
+ })
+ .then(timeoutPromise)
+ .then(() => {
+ 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
+
+ done();
+ })
+ .catch(done.fail);
});
});
@@ -553,43 +695,56 @@ import Notes from '~/notes';
const note = {
commands_changes: {
assignee_id: 1,
- emoji_award: '100'
+ emoji_award: '100',
},
errors: {
- commands_only: ['Commands applied']
+ commands_only: ['Commands applied'],
},
- valid: false
+ valid: false,
};
let $form;
let $notesContainer;
+ let mock;
beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onPost(NOTES_POST_PATH).reply(200, note);
+
this.notes = new Notes('', []);
window.gon.current_username = 'root';
window.gon.current_user_fullname = 'Administrator';
gl.awardsHandler = {
addAwardToEmojiBar: () => {},
- scrollToAwards: () => {}
+ scrollToAwards: () => {},
};
gl.GfmAutoComplete = {
dataSources: {
- commands: '/root/test-project/autocomplete_sources/commands'
- }
+ 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());
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should remove slash command placeholder when comment with slash commands is done posting', done => {
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
+ expect(
+ $notesContainer.find('.system-note.being-posted').length,
+ ).toEqual(1); // Placeholder shown
+
+ setTimeout(() => {
+ expect(
+ $notesContainer.find('.system-note.being-posted').length,
+ ).toEqual(0); // Placeholder removed
+ done();
+ });
});
});
@@ -602,12 +757,16 @@ import Notes from '~/notes';
<div class="note-text">${sampleComment}</div>
</li>`,
note: sampleComment,
- valid: true
+ valid: true,
};
let $form;
let $notesContainer;
+ let mock;
beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onPost(NOTES_POST_PATH).reply(200, note);
+
this.notes = new Notes('', []);
window.gon.current_username = 'root';
window.gon.current_user_fullname = 'Administrator';
@@ -616,19 +775,31 @@ import Notes from '~/notes';
$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();
+ afterEach(() => {
+ mock.restore();
+ });
- 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();
+ it('should not render a script tag', done => {
+ $('.js-comment-button').click();
- const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`).find('.js-task-list-container');
- expect($updatedNoteEl.find('.note-text').text().trim()).toEqual('');
+ setTimeout(() => {
+ 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('');
+
+ done();
+ });
});
});
@@ -645,7 +816,9 @@ import Notes from '~/notes';
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);
+ const { formData, formContent, formAction } = this.notes.getFormData(
+ $form,
+ );
expect(formData.indexOf(sampleComment) > -1).toBe(true);
expect(formContent).toEqual(sampleComment);
@@ -661,7 +834,9 @@ import Notes from '~/notes';
const { formContent } = this.notes.getFormData($form);
expect(_.escape).toHaveBeenCalledWith(sampleComment);
- expect(formContent).toEqual('&lt;script&gt;alert(&quot;Boom!&quot;);&lt;/script&gt;');
+ expect(formContent).toEqual(
+ '&lt;script&gt;alert(&quot;Boom!&quot;);&lt;/script&gt;',
+ );
});
});
@@ -671,7 +846,8 @@ import Notes from '~/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 sampleComment =
+ '/wip\n/milestone %1.0\n/merge\n/unassign Merging this';
const hasQuickActions = this.notes.hasQuickActions(sampleComment);
expect(hasQuickActions).toBeTruthy();
@@ -695,7 +871,8 @@ import Notes from '~/notes';
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 sampleComment =
+ '/wip\n/milestone %1.0\n/merge\n/unassign Merging this';
const stripedComment = this.notes.stripQuickActions(sampleComment);
expect(stripedComment).toBe('');
@@ -703,7 +880,8 @@ import Notes from '~/notes';
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 sampleComment =
+ '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this';
const stripedComment = this.notes.stripQuickActions(sampleComment);
expect(stripedComment).toBe('Merging this');
@@ -711,7 +889,8 @@ import Notes from '~/notes';
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 sampleComment =
+ 'http://127.0.0.1:3000/root/gitlab-shell/issues/1';
const stripedComment = this.notes.stripQuickActions(sampleComment);
expect(stripedComment).toBe(sampleComment);
@@ -722,7 +901,7 @@ import Notes from '~/notes';
const availableQuickActions = [
{ name: 'close', description: 'Close this issue', params: [] },
{ name: 'title', description: 'Change title', params: [{}] },
- { name: 'estimate', description: 'Set time estimate', params: [{}] }
+ { name: 'estimate', description: 'Set time estimate', params: [{}] },
];
beforeEach(() => {
@@ -731,17 +910,29 @@ import Notes from '~/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');
+ 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');
+ 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');
+ expect(this.notes.getQuickActionDescription(sampleComment)).toBe(
+ 'Applying command',
+ );
});
});
@@ -771,14 +962,35 @@ import Notes from '~/notes';
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);
+ $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', () => {
@@ -787,11 +999,13 @@ import Notes from '~/notes';
uniqueId,
isDiscussionNote: true,
currentUsername,
- currentUserFullname
+ currentUserFullname,
});
expect($tempNote.prop('nodeName')).toEqual('LI');
- expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy();
+ expect(
+ $tempNote.find('.timeline-content').hasClass('discussion'),
+ ).toBeTruthy();
});
it('should return a escaped user name', () => {
@@ -805,7 +1019,12 @@ import Notes from '~/notes';
currentUserAvatar,
});
const $tempNoteHeader = $tempNote.find('.note-header');
- expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual('Foo &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;');
+ expect(
+ $tempNoteHeader
+ .find('.hidden-xs')
+ .text()
+ .trim(),
+ ).toEqual('Foo &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;');
});
});
@@ -828,7 +1047,12 @@ import Notes from '~/notes';
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);
+ expect(
+ $tempNote
+ .find('.timeline-content i')
+ .text()
+ .trim(),
+ ).toEqual(sampleCommandDescription);
});
});
@@ -838,7 +1062,11 @@ import Notes from '~/notes';
});
it('shows a flash message', () => {
- this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline.get(0));
+ this.notes.addFlash(
+ 'Error message',
+ FLASH_TYPE_ALERT,
+ this.notes.parentTimeline.get(0),
+ );
expect($('.flash-alert').is(':visible')).toBeTruthy();
});
@@ -851,7 +1079,11 @@ import Notes from '~/notes';
});
it('hides visible flash message', () => {
- this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline.get(0));
+ this.notes.addFlash(
+ 'Error message 1',
+ FLASH_TYPE_ALERT,
+ this.notes.parentTimeline.get(0),
+ );
this.notes.clearFlash();
@@ -859,4 +1091,4 @@ import Notes from '~/notes';
});
});
});
-}).call(window);
+}.call(window));
diff --git a/spec/javascripts/oauth_remember_me_spec.js b/spec/javascripts/oauth_remember_me_spec.js
index f90e0093d25..8816fe6defb 100644
--- a/spec/javascripts/oauth_remember_me_spec.js
+++ b/spec/javascripts/oauth_remember_me_spec.js
@@ -1,4 +1,5 @@
-import OAuthRememberMe from '~/oauth_remember_me';
+import $ from 'jquery';
+import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me';
describe('OAuthRememberMe', () => {
preloadFixtures('static/oauth_remember_me.html.raw');
diff --git a/spec/javascripts/pager_spec.js b/spec/javascripts/pager_spec.js
index 2fd87754238..b09494f0b77 100644
--- a/spec/javascripts/pager_spec.js
+++ b/spec/javascripts/pager_spec.js
@@ -1,5 +1,6 @@
/* global fixture */
-
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import * as utils from '~/lib/utils/url_utility';
import Pager from '~/pager';
@@ -9,7 +10,6 @@ describe('pager', () => {
beforeEach(() => {
setFixtures('<div class="content_list"></div><div class="loading"></div>');
- spyOn($, 'ajax');
});
afterEach(() => {
@@ -47,39 +47,90 @@ describe('pager', () => {
});
describe('getOld', () => {
+ const urlRegex = /(.*)some_list(.*)$/;
+ let mock;
+
+ function mockSuccess() {
+ mock.onGet(urlRegex).reply(200, {
+ count: 0,
+ html: '',
+ });
+ }
+
+ function mockError() {
+ mock.onGet(urlRegex).networkError();
+ }
+
beforeEach(() => {
setFixtures('<div class="content_list" data-href="/some_list"></div><div class="loading"></div>');
+ spyOn(axios, 'get').and.callThrough();
+
+ mock = new MockAdapter(axios);
+
Pager.init();
});
- it('shows loader while loading next page', () => {
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('shows loader while loading next page', (done) => {
+ mockSuccess();
+
spyOn(Pager.loading, 'show');
Pager.getOld();
- expect(Pager.loading.show).toHaveBeenCalled();
+
+ setTimeout(() => {
+ expect(Pager.loading.show).toHaveBeenCalled();
+
+ done();
+ });
});
- it('hides loader on success', () => {
- spyOn($, 'ajax').and.callFake(options => options.success({}));
+ it('hides loader on success', (done) => {
+ mockSuccess();
+
spyOn(Pager.loading, 'hide');
Pager.getOld();
- expect(Pager.loading.hide).toHaveBeenCalled();
+
+ setTimeout(() => {
+ expect(Pager.loading.hide).toHaveBeenCalled();
+
+ done();
+ });
});
- it('hides loader on error', () => {
- spyOn($, 'ajax').and.callFake(options => options.error());
+ it('hides loader on error', (done) => {
+ mockError();
+
spyOn(Pager.loading, 'hide');
Pager.getOld();
- expect(Pager.loading.hide).toHaveBeenCalled();
+
+ setTimeout(() => {
+ expect(Pager.loading.hide).toHaveBeenCalled();
+
+ done();
+ });
});
- it('sends request to url with offset and limit params', () => {
- spyOn($, 'ajax');
+ it('sends request to url with offset and limit params', (done) => {
Pager.offset = 100;
Pager.limit = 20;
Pager.getOld();
- const [{ data, url }] = $.ajax.calls.argsFor(0);
- expect(data).toBe('limit=20&offset=100');
- expect(url).toBe('/some_list');
+
+ setTimeout(() => {
+ const [url, params] = axios.get.calls.argsFor(0);
+
+ expect(params).toEqual({
+ params: {
+ limit: 20,
+ offset: 100,
+ },
+ });
+ expect(url).toBe('/some_list');
+
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js
new file mode 100644
index 00000000000..b0dc6ccc3d4
--- /dev/null
+++ b/spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js
@@ -0,0 +1,42 @@
+import $ from 'jquery';
+import '~/lib/utils/text_utility';
+import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports';
+
+describe('Abuse Reports', () => {
+ const FIXTURE = 'abuse_reports/abuse_reports_list.html.raw';
+ const MAX_MESSAGE_LENGTH = 500;
+
+ let $messages;
+
+ const assertMaxLength = $message => expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH);
+ const findMessage = searchText => $messages.filter(
+ (index, element) => element.innerText.indexOf(searchText) > -1,
+ ).first();
+
+ preloadFixtures(FIXTURE);
+
+ beforeEach(function () {
+ loadFixtures(FIXTURE);
+ this.abuseReports = new AbuseReports();
+ $messages = $('.abuse-reports .message');
+ });
+
+ it('should truncate long messages', () => {
+ const $longMessage = findMessage('LONG MESSAGE');
+ expect($longMessage.data('originalMessage')).toEqual(jasmine.anything());
+ assertMaxLength($longMessage);
+ });
+
+ it('should not truncate short messages', () => {
+ const $shortMessage = findMessage('SHORT MESSAGE');
+ expect($shortMessage.data('originalMessage')).not.toEqual(jasmine.anything());
+ });
+
+ it('should allow clicking a truncated message to expand and collapse the full message', () => {
+ const $longMessage = findMessage('LONG MESSAGE');
+ $longMessage.click();
+ expect($longMessage.data('originalMessage').length).toEqual($longMessage.text().length);
+ $longMessage.click();
+ assertMaxLength($longMessage);
+ });
+});
diff --git a/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
new file mode 100644
index 00000000000..a6fe9fb65e9
--- /dev/null
+++ b/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+
+import axios from '~/lib/utils/axios_utils';
+import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue';
+import * as urlUtility from '~/lib/utils/url_utility';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('stop_jobs_modal.vue', () => {
+ const props = {
+ url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`,
+ };
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ beforeEach(() => {
+ const Component = Vue.extend(stopJobsModal);
+ vm = mountComponent(Component, props);
+ });
+
+ describe('onSubmit', () => {
+ it('stops jobs and redirects to overview page', (done) => {
+ const responseURL = `${gl.TEST_HOST}/stop_jobs_modal.vue/jobs`;
+ const redirectSpy = spyOn(urlUtility, 'redirectTo');
+ spyOn(axios, 'post').and.callFake((url) => {
+ expect(url).toBe(props.url);
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(redirectSpy).toHaveBeenCalledWith(responseURL);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays error if stopping jobs failed', (done) => {
+ const dummyError = new Error('stopping jobs failed');
+ const redirectSpy = spyOn(urlUtility, 'redirectTo');
+ spyOn(axios, 'post').and.callFake((url) => {
+ expect(url).toBe(props.url);
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .then(done.fail)
+ .catch((error) => {
+ expect(error).toBe(dummyError);
+ expect(redirectSpy).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
new file mode 100644
index 00000000000..a24f8204fe1
--- /dev/null
+++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
@@ -0,0 +1,89 @@
+import Vue from 'vue';
+import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue';
+import eventHub from '~/pages/projects/labels/event_hub';
+import axios from '~/lib/utils/axios_utils';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('Promote label modal', () => {
+ let vm;
+ const Component = Vue.extend(promoteLabelModal);
+ const labelMockData = {
+ labelTitle: 'Documentation',
+ labelColor: '#5cb85c',
+ labelTextColor: '#ffffff',
+ url: `${gl.TEST_HOST}/dummy/promote/labels`,
+ groupName: 'group',
+ };
+
+ describe('Modal title and description', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, labelMockData);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('contains the proper description', () => {
+ expect(vm.text).toContain(`Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`);
+ });
+
+ it('contains a label span with the color', () => {
+ const labelFromTitle = vm.$el.querySelector('.modal-header .label.color-label');
+
+ expect(labelFromTitle.style.backgroundColor).not.toBe(null);
+ expect(labelFromTitle.textContent).toContain(vm.labelTitle);
+ });
+ });
+
+ describe('When requesting a label promotion', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...labelMockData,
+ });
+ spyOn(eventHub, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('redirects when a label is promoted', (done) => {
+ const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
+ spyOn(axios, 'post').and.callFake((url) => {
+ expect(url).toBe(labelMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestStarted', labelMockData.url);
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { labelUrl: labelMockData.url, successful: true });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays an error if promoting a label failed', (done) => {
+ const dummyError = new Error('promoting label failed');
+ dummyError.response = { status: 500 };
+ spyOn(axios, 'post').and.callFake((url) => {
+ expect(url).toBe(labelMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestStarted', labelMockData.url);
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch((error) => {
+ expect(error).toBe(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { labelUrl: labelMockData.url, successful: false });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
new file mode 100644
index 00000000000..6074e06fcec
--- /dev/null
+++ b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
@@ -0,0 +1,95 @@
+import Vue from 'vue';
+
+import axios from '~/lib/utils/axios_utils';
+import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
+import eventHub from '~/pages/milestones/shared/event_hub';
+import * as urlUtility from '~/lib/utils/url_utility';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('delete_milestone_modal.vue', () => {
+ const Component = Vue.extend(deleteMilestoneModal);
+ const props = {
+ issueCount: 1,
+ mergeRequestCount: 2,
+ milestoneId: 3,
+ milestoneTitle: 'my milestone title',
+ milestoneUrl: `${gl.TEST_HOST}/delete_milestone_modal.vue/milestone`,
+ };
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('onSubmit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ spyOn(eventHub, '$emit');
+ });
+
+ it('deletes milestone and redirects to overview page', (done) => {
+ const responseURL = `${gl.TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
+ spyOn(axios, 'delete').and.callFake((url) => {
+ expect(url).toBe(props.milestoneUrl);
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestStarted', props.milestoneUrl);
+ eventHub.$emit.calls.reset();
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+ const redirectSpy = spyOn(urlUtility, 'redirectTo');
+
+ vm.onSubmit()
+ .then(() => {
+ expect(redirectSpy).toHaveBeenCalledWith(responseURL);
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { milestoneUrl: props.milestoneUrl, successful: true });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays error if deleting milestone failed', (done) => {
+ const dummyError = new Error('deleting milestone failed');
+ dummyError.response = { status: 418 };
+ spyOn(axios, 'delete').and.callFake((url) => {
+ expect(url).toBe(props.milestoneUrl);
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestStarted', props.milestoneUrl);
+ eventHub.$emit.calls.reset();
+ return Promise.reject(dummyError);
+ });
+ const redirectSpy = spyOn(urlUtility, 'redirectTo');
+
+ vm.onSubmit()
+ .catch((error) => {
+ expect(error).toBe(dummyError);
+ expect(redirectSpy).not.toHaveBeenCalled();
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { milestoneUrl: props.milestoneUrl, successful: false });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('text', () => {
+ it('contains the issue and milestone count', () => {
+ vm = mountComponent(Component, props);
+ const value = vm.text;
+
+ expect(value).toContain('remove it from 1 issue and 2 merge requests');
+ });
+
+ it('contains neither issue nor milestone count', () => {
+ vm = mountComponent(Component, { ...props,
+ issueCount: 0,
+ mergeRequestCount: 0,
+ });
+
+ const value = vm.text;
+
+ expect(value).toContain('is not currently used');
+ });
+ });
+});
diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
new file mode 100644
index 00000000000..8b220423637
--- /dev/null
+++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue';
+import eventHub from '~/pages/milestones/shared/event_hub';
+import axios from '~/lib/utils/axios_utils';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('Promote milestone modal', () => {
+ let vm;
+ const Component = Vue.extend(promoteMilestoneModal);
+ const milestoneMockData = {
+ milestoneTitle: 'v1.0',
+ url: `${gl.TEST_HOST}/dummy/promote/milestones`,
+ groupName: 'group',
+ };
+
+ describe('Modal title and description', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, milestoneMockData);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('contains the proper description', () => {
+ expect(vm.text).toContain(`Promoting ${milestoneMockData.milestoneTitle} will make it available for all projects inside ${milestoneMockData.groupName}.`);
+ });
+
+ it('contains the correct title', () => {
+ expect(vm.title).toEqual('Promote v1.0 to group milestone?');
+ });
+ });
+
+ describe('When requesting a milestone promotion', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...milestoneMockData,
+ });
+ spyOn(eventHub, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('redirects when a milestone is promoted', (done) => {
+ const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
+ spyOn(axios, 'post').and.callFake((url) => {
+ expect(url).toBe(milestoneMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestStarted', milestoneMockData.url);
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', { milestoneUrl: milestoneMockData.url, successful: true });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays an error if promoting a milestone failed', (done) => {
+ const dummyError = new Error('promoting milestone failed');
+ dummyError.response = { status: 500 };
+ spyOn(axios, 'post').and.callFake((url) => {
+ expect(url).toBe(milestoneMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestStarted', milestoneMockData.url);
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch((error) => {
+ expect(error).toBe(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', { milestoneUrl: milestoneMockData.url, successful: false });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
new file mode 100644
index 00000000000..4655e29eed0
--- /dev/null
+++ b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
@@ -0,0 +1,178 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/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/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
new file mode 100644
index 00000000000..f95a7cef18a
--- /dev/null
+++ b/spec/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -0,0 +1,106 @@
+import Vue from 'vue';
+import Cookies from 'js-cookie';
+import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/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/performance_bar/components/detailed_metric_spec.js b/spec/javascripts/performance_bar/components/detailed_metric_spec.js
new file mode 100644
index 00000000000..c4611dc7662
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/detailed_metric_spec.js
@@ -0,0 +1,80 @@
+import Vue from 'vue';
+import detailedMetric from '~/performance_bar/components/detailed_metric.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('detailedMetric', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when the current request has no details', () => {
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(detailedMetric), {
+ currentRequest: {},
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ });
+ });
+
+ it('does not render the element', () => {
+ expect(vm.$el.innerHTML).toEqual(undefined);
+ });
+ });
+
+ describe('when the current request has details', () => {
+ const requestDetails = [
+ { duration: '100', feature: 'find_commit', request: 'abcdef' },
+ { duration: '23', feature: 'rebase_in_progress', request: '' },
+ ];
+
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(detailedMetric), {
+ currentRequest: {
+ details: {
+ gitaly: {
+ duration: '123ms',
+ calls: '456',
+ details: requestDetails,
+ },
+ },
+ },
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ });
+ });
+
+ it('diplays details', () => {
+ expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456');
+ });
+
+ it('adds a modal with a table of the details', () => {
+ vm.$el
+ .querySelectorAll('.performance-bar-modal td strong')
+ .forEach((duration, index) => {
+ expect(duration.innerText).toContain(requestDetails[index].duration);
+ });
+
+ vm.$el
+ .querySelectorAll('.performance-bar-modal td:nth-child(2)')
+ .forEach((feature, index) => {
+ expect(feature.innerText).toContain(requestDetails[index].feature);
+ });
+
+ vm.$el
+ .querySelectorAll('.performance-bar-modal td:nth-child(3)')
+ .forEach((request, index) => {
+ expect(request.innerText).toContain(requestDetails[index].request);
+ });
+ });
+
+ it('displays the metric name', () => {
+ expect(vm.$el.innerText).toContain('gitaly');
+ });
+ });
+});
diff --git a/spec/javascripts/performance_bar/components/performance_bar_app_spec.js b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js
new file mode 100644
index 00000000000..9ab9ab1c9f4
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js
@@ -0,0 +1,88 @@
+import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
+import performanceBarApp from '~/performance_bar/components/performance_bar_app.vue';
+import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
+import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import MockAdapter from 'axios-mock-adapter';
+
+describe('performance bar', () => {
+ let mock;
+ let vm;
+
+ beforeEach(() => {
+ const store = new PerformanceBarStore();
+
+ mock = new MockAdapter(axios);
+
+ mock.onGet('/-/peek/results').reply(
+ 200,
+ {
+ data: {
+ gc: {
+ invokes: 0,
+ invoke_time: '0.00',
+ use_size: 0,
+ total_size: 0,
+ total_object: 0,
+ gc_time: '0.00',
+ },
+ host: { hostname: 'web-01' },
+ },
+ },
+ {},
+ );
+
+ vm = mountComponent(Vue.extend(performanceBarApp), {
+ store,
+ env: 'development',
+ requestId: '123',
+ peekUrl: '/-/peek/results',
+ profileUrl: '?lineprofiler=true',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ mock.restore();
+ });
+
+ it('sets the class to match the environment', () => {
+ expect(vm.$el.getAttribute('class')).toContain('development');
+ });
+
+ describe('loadRequestDetails', () => {
+ beforeEach(() => {
+ spyOn(vm.store, 'addRequest').and.callThrough();
+ });
+
+ it('does nothing if the request cannot be tracked', () => {
+ spyOn(vm.store, 'canTrackRequest').and.callFake(() => false);
+
+ vm.loadRequestDetails('123', 'https://gitlab.com/');
+
+ expect(vm.store.addRequest).not.toHaveBeenCalled();
+ });
+
+ it('adds the request immediately', () => {
+ vm.loadRequestDetails('123', 'https://gitlab.com/');
+
+ expect(vm.store.addRequest).toHaveBeenCalledWith(
+ '123',
+ 'https://gitlab.com/',
+ );
+ });
+
+ it('makes an HTTP request for the request details', () => {
+ spyOn(PerformanceBarService, 'fetchRequestDetails').and.callThrough();
+
+ vm.loadRequestDetails('456', 'https://gitlab.com/');
+
+ expect(PerformanceBarService.fetchRequestDetails).toHaveBeenCalledWith(
+ '/-/peek/results',
+ '456',
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/performance_bar/components/request_selector_spec.js b/spec/javascripts/performance_bar/components/request_selector_spec.js
new file mode 100644
index 00000000000..6108a29f8c4
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/request_selector_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import requestSelector from '~/performance_bar/components/request_selector.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('request selector', () => {
+ const requests = [
+ { id: '123', url: 'https://gitlab.com/' },
+ {
+ id: '456',
+ url: 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1',
+ },
+ {
+ id: '789',
+ url:
+ 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1.json?serializer=widget',
+ },
+ ];
+
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(requestSelector), {
+ requests,
+ currentRequest: requests[1],
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ function optionText(requestId) {
+ return vm.$el.querySelector(`[value='${requestId}']`).innerText.trim();
+ }
+
+ it('displays the last component of the path', () => {
+ expect(optionText(requests[2].id)).toEqual('1.json?serializer=widget');
+ });
+
+ it('keeps the last two components of the path when the last component is numeric', () => {
+ expect(optionText(requests[1].id)).toEqual('merge_requests/1');
+ });
+
+ it('ignores trailing slashes', () => {
+ expect(optionText(requests[0].id)).toEqual('gitlab.com');
+ });
+});
diff --git a/spec/javascripts/performance_bar/components/simple_metric_spec.js b/spec/javascripts/performance_bar/components/simple_metric_spec.js
new file mode 100644
index 00000000000..98b843e9711
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/simple_metric_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import simpleMetric from '~/performance_bar/components/simple_metric.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('simpleMetric', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when the current request has no details', () => {
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(simpleMetric), {
+ currentRequest: {},
+ metric: 'gitaly',
+ });
+ });
+
+ it('does not display details', () => {
+ expect(vm.$el.innerText).not.toContain('/');
+ });
+
+ it('displays the metric name', () => {
+ expect(vm.$el.innerText).toContain('gitaly');
+ });
+ });
+
+ describe('when the current request has details', () => {
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(simpleMetric), {
+ currentRequest: {
+ details: { gitaly: { duration: '123ms', calls: '456' } },
+ },
+ metric: 'gitaly',
+ });
+ });
+
+ it('diplays details', () => {
+ expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456');
+ });
+
+ it('displays the metric name', () => {
+ expect(vm.$el.innerText).toContain('gitaly');
+ });
+ });
+});
diff --git a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js b/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js
deleted file mode 100644
index 040d14efed2..00000000000
--- a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js
+++ /dev/null
@@ -1,178 +0,0 @@
-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
deleted file mode 100644
index ed481cb60a1..00000000000
--- a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js
+++ /dev/null
@@ -1,106 +0,0 @@
-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
deleted file mode 100644
index 5b316b319a5..00000000000
--- a/spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js
+++ /dev/null
@@ -1,145 +0,0 @@
-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 48620898357..e0ea3649646 100644
--- a/spec/javascripts/pipelines/async_button_spec.js
+++ b/spec/javascripts/pipelines/async_button_spec.js
@@ -13,8 +13,10 @@ describe('Pipelines Async Button', () => {
propsData: {
endpoint: '/foo',
title: 'Foo',
- icon: 'fa fa-foo',
+ icon: 'repeat',
cssClass: 'bar',
+ pipelineId: 123,
+ type: 'explode',
},
}).$mount();
});
@@ -23,8 +25,8 @@ describe('Pipelines Async Button', () => {
expect(component.$el.tagName).toEqual('BUTTON');
});
- it('should render the provided icon', () => {
- expect(component.$el.querySelector('i').getAttribute('class')).toContain('fa fa-foo');
+ it('should render svg icon', () => {
+ expect(component.$el.querySelector('svg')).not.toBeNull();
});
it('should render the provided title', () => {
@@ -38,9 +40,9 @@ describe('Pipelines Async Button', () => {
describe('With confirm dialog', () => {
it('should call the service when confimation is positive', () => {
- spyOn(window, 'confirm').and.returnValue(true);
- eventHub.$on('postAction', (endpoint) => {
- expect(endpoint).toEqual('/foo');
+ eventHub.$on('openConfirmationModal', (data) => {
+ expect(data.pipelineId).toEqual(123);
+ expect(data.type).toEqual('explode');
});
component = new AsyncButtonComponent({
@@ -49,7 +51,8 @@ describe('Pipelines Async Button', () => {
title: 'Foo',
icon: 'fa fa-foo',
cssClass: 'bar',
- confirmActionMessage: 'bar',
+ pipelineId: 123,
+ type: 'explode',
},
}).$mount();
diff --git a/spec/javascripts/pipelines/blank_state_spec.js b/spec/javascripts/pipelines/blank_state_spec.js
new file mode 100644
index 00000000000..b7a9b60d85c
--- /dev/null
+++ b/spec/javascripts/pipelines/blank_state_spec.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import component from '~/pipelines/components/blank_state.vue';
+import mountComponent from '../helpers/vue_mount_component_helper';
+
+describe('Pipelines Blank State', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(component);
+
+ vm = mountComponent(Component,
+ {
+ svgPath: 'foo',
+ message: 'Blank State',
+ },
+ );
+ });
+
+ it('should render svg', () => {
+ expect(vm.$el.querySelector('.svg-content img').getAttribute('src')).toEqual('foo');
+ });
+
+ it('should render message', () => {
+ expect(
+ vm.$el.querySelector('h4').textContent.trim(),
+ ).toEqual('Blank State');
+ });
+});
diff --git a/spec/javascripts/pipelines/empty_state_spec.js b/spec/javascripts/pipelines/empty_state_spec.js
index 6611b74594f..71f77e5f42e 100644
--- a/spec/javascripts/pipelines/empty_state_spec.js
+++ b/spec/javascripts/pipelines/empty_state_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import emptyStateComp from '~/pipelines/components/empty_state.vue';
+import mountComponent from '../helpers/vue_mount_component_helper';
describe('Pipelines Empty State', () => {
let component;
@@ -8,12 +9,15 @@ describe('Pipelines Empty State', () => {
beforeEach(() => {
EmptyStateComponent = Vue.extend(emptyStateComp);
- component = new EmptyStateComponent({
- propsData: {
- helpPagePath: 'foo',
- emptyStateSvgPath: 'foo',
- },
- }).$mount();
+ component = mountComponent(EmptyStateComponent, {
+ helpPagePath: 'foo',
+ emptyStateSvgPath: 'foo',
+ canSetCi: true,
+ });
+ });
+
+ afterEach(() => {
+ component.$destroy();
});
it('should render empty state SVG', () => {
@@ -24,16 +28,16 @@ describe('Pipelines Empty State', () => {
expect(component.$el.querySelector('h4').textContent).toContain('Build with confidence');
expect(
- component.$el.querySelector('p').textContent,
- ).toContain('Continous Integration can help catch bugs by running your tests automatically');
+ component.$el.querySelector('p').innerHTML.trim().replace(/\n+\s+/m, ' ').replace(/\s\s+/g, ' '),
+ ).toContain('Continous Integration can help catch bugs by running your tests automatically,');
expect(
- component.$el.querySelector('p').textContent,
- ).toContain('Continuous Deployment can help you deliver code to your product environment');
+ component.$el.querySelector('p').innerHTML.trim().replace(/\n+\s+/m, ' ').replace(/\s\s+/g, ' '),
+ ).toContain('while Continuous Deployment can help you deliver code to your product environment');
});
it('should render a link with provided help path', () => {
- expect(component.$el.querySelector('.btn-info').getAttribute('href')).toEqual('foo');
- expect(component.$el.querySelector('.btn-info').textContent).toContain('Get started with Pipelines');
+ expect(component.$el.querySelector('.js-get-started-pipelines').getAttribute('href')).toEqual('foo');
+ expect(component.$el.querySelector('.js-get-started-pipelines').textContent).toContain('Get started with Pipelines');
});
});
diff --git a/spec/javascripts/pipelines/error_state_spec.js b/spec/javascripts/pipelines/error_state_spec.js
deleted file mode 100644
index a402857a4d1..00000000000
--- a/spec/javascripts/pipelines/error_state_spec.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import Vue from 'vue';
-import errorStateComp from '~/pipelines/components/error_state.vue';
-
-describe('Pipelines Error State', () => {
- let component;
- let ErrorStateComponent;
-
- beforeEach(() => {
- ErrorStateComponent = Vue.extend(errorStateComp);
-
- component = new ErrorStateComponent({
- propsData: {
- errorStateSvgPath: 'foo',
- },
- }).$mount();
- });
-
- it('should render error state SVG', () => {
- expect(component.$el.querySelector('.svg-content svg')).toBeDefined();
- });
-
- it('should render emtpy state information', () => {
- expect(
- component.$el.querySelector('h4').textContent,
- ).toContain('The API failed to fetch the pipelines');
- });
-});
diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js
index e8fcd4b1a36..581209f215d 100644
--- a/spec/javascripts/pipelines/graph/action_component_spec.js
+++ b/spec/javascripts/pipelines/graph/action_component_spec.js
@@ -1,25 +1,30 @@
import Vue from 'vue';
import actionComponent from '~/pipelines/components/graph/action_component.vue';
+import eventHub from '~/pipelines/event_hub';
+import mountComponent from '../../helpers/vue_mount_component_helper';
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: 'cancel',
- },
- }).$mount();
+ component = mountComponent(ActionComponent, {
+ tooltipText: 'bar',
+ link: 'foo',
+ actionIcon: 'cancel',
+ });
Vue.nextTick(done);
});
- it('should render a link', () => {
- expect(component.$el.getAttribute('href')).toEqual('foo');
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ it('should emit an event with the provided link', () => {
+ eventHub.$on('graphAction', (link) => {
+ expect(link).toEqual('foo');
+ });
});
it('should render the provided title as a bootstrap tooltip', () => {
diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js
index 35e36e9c353..c9677ae209a 100644
--- a/spec/javascripts/pipelines/graph/job_component_spec.js
+++ b/spec/javascripts/pipelines/graph/job_component_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import jobComponent from '~/pipelines/components/graph/job_component.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('pipeline graph job component', () => {
let JobComponent;
@@ -13,6 +13,7 @@ describe('pipeline graph job component', () => {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
+ tooltip: 'passed',
group: 'success',
details_path: '/root/ci-mock/builds/4256',
has_details: true,
@@ -61,14 +62,14 @@ describe('pipeline graph job component', () => {
it('it should render status and name', () => {
component = mountComponent(JobComponent, {
job: {
- id: 4256,
+ id: 4257,
name: 'test',
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
- details_path: '/root/ci-mock/builds/4256',
+ details_path: '/root/ci-mock/builds/4257',
has_details: false,
},
},
@@ -118,7 +119,7 @@ describe('pipeline graph job component', () => {
it('should not render status label when it is not provided', () => {
component = mountComponent(JobComponent, {
job: {
- id: 4256,
+ id: 4258,
name: 'test',
status: {
icon: 'icon_status_success',
@@ -132,11 +133,12 @@ describe('pipeline graph job component', () => {
it('should not render status label when it is provided', () => {
component = mountComponent(JobComponent, {
job: {
- id: 4256,
+ id: 4259,
name: 'test',
status: {
icon: 'icon_status_success',
label: 'success',
+ tooltip: 'success',
},
},
});
diff --git a/spec/javascripts/pipelines/graph/mock_data.js b/spec/javascripts/pipelines/graph/mock_data.js
index b9494f86d74..70eba98e939 100644
--- a/spec/javascripts/pipelines/graph/mock_data.js
+++ b/spec/javascripts/pipelines/graph/mock_data.js
@@ -1,232 +1,261 @@
-/* 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"
+ 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"
+ 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": "retry",
- "title": "Retry",
- "path": "/root/ci-mock/builds/4153/retry",
- "method": "post"
- }
+ 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: '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: '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',
},
- "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": "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',
},
- "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": "retry",
- "title": "Retry",
- "path": "/root/ci-mock/builds/4166/retry",
- "method": "post"
- }
+ {
+ 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: '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: '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: '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: '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',
},
- "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": "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": "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": "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,
},
- "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
+ 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
+ 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"
+ 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"
+ 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"
+ 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
index 063ab53681b..f744f1af5e6 100644
--- a/spec/javascripts/pipelines/graph/stage_column_component_spec.js
+++ b/spec/javascripts/pipelines/graph/stage_column_component_spec.js
@@ -4,18 +4,18 @@ import stageColumnComponent from '~/pipelines/components/graph/stage_column_comp
describe('stage column component', () => {
let component;
const mockJob = {
- id: 4256,
+ id: 4250,
name: 'test',
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
- details_path: '/root/ci-mock/builds/4256',
+ details_path: '/root/ci-mock/builds/4250',
action: {
icon: 'retry',
title: 'Retry',
- path: '/root/ci-mock/builds/4256/retry',
+ path: '/root/ci-mock/builds/4250/retry',
method: 'post',
},
},
@@ -24,10 +24,17 @@ describe('stage column component', () => {
beforeEach(() => {
const StageColumnComponent = Vue.extend(stageColumnComponent);
+ const mockJobs = [];
+ for (let i = 0; i < 3; i += 1) {
+ const mockedJob = Object.assign({}, mockJob);
+ mockedJob.id += i;
+ mockJobs.push(mockedJob);
+ }
+
component = new StageColumnComponent({
propsData: {
title: 'foo',
- jobs: [mockJob, mockJob, mockJob],
+ jobs: mockJobs,
},
}).$mount();
});
diff --git a/spec/javascripts/pipelines/nav_controls_spec.js b/spec/javascripts/pipelines/nav_controls_spec.js
index f1697840fcd..d6232f5c567 100644
--- a/spec/javascripts/pipelines/nav_controls_spec.js
+++ b/spec/javascripts/pipelines/nav_controls_spec.js
@@ -1,93 +1,79 @@
import Vue from 'vue';
import navControlsComp from '~/pipelines/components/nav_controls.vue';
+import mountComponent from '../helpers/vue_mount_component_helper';
describe('Pipelines Nav Controls', () => {
let NavControlsComponent;
+ let component;
beforeEach(() => {
NavControlsComponent = Vue.extend(navControlsComp);
});
+ afterEach(() => {
+ component.$destroy();
+ });
+
it('should render link to create a new pipeline', () => {
const mockData = {
newPipelinePath: 'foo',
- hasCiEnabled: true,
- helpPagePath: 'foo',
ciLintPath: 'foo',
- canCreatePipeline: true,
+ resetCachePath: 'foo',
};
- const component = new NavControlsComponent({
- propsData: mockData,
- }).$mount();
+ component = mountComponent(NavControlsComponent, mockData);
- expect(component.$el.querySelector('.btn-create').textContent).toContain('Run Pipeline');
- expect(component.$el.querySelector('.btn-create').getAttribute('href')).toEqual(mockData.newPipelinePath);
+ expect(component.$el.querySelector('.js-run-pipeline').textContent).toContain('Run Pipeline');
+ expect(component.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(mockData.newPipelinePath);
});
- it('should not render link to create pipeline if no permission is provided', () => {
+ it('should not render link to create pipeline if no path is provided', () => {
const mockData = {
- newPipelinePath: 'foo',
- hasCiEnabled: true,
helpPagePath: 'foo',
ciLintPath: 'foo',
- canCreatePipeline: false,
+ resetCachePath: 'foo',
};
- const component = new NavControlsComponent({
- propsData: mockData,
- }).$mount();
+ component = mountComponent(NavControlsComponent, mockData);
- expect(component.$el.querySelector('.btn-create')).toEqual(null);
+ expect(component.$el.querySelector('.js-run-pipeline')).toEqual(null);
});
it('should render link for CI lint', () => {
const mockData = {
newPipelinePath: 'foo',
- hasCiEnabled: true,
helpPagePath: 'foo',
ciLintPath: 'foo',
- canCreatePipeline: true,
+ resetCachePath: 'foo',
};
- const component = new NavControlsComponent({
- propsData: mockData,
- }).$mount();
+ component = mountComponent(NavControlsComponent, mockData);
- expect(component.$el.querySelector('.btn-default').textContent).toContain('CI Lint');
- expect(component.$el.querySelector('.btn-default').getAttribute('href')).toEqual(mockData.ciLintPath);
+ expect(component.$el.querySelector('.js-ci-lint').textContent.trim()).toContain('CI Lint');
+ expect(component.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(mockData.ciLintPath);
});
- it('should render link to help page when CI is not enabled', () => {
- const mockData = {
- newPipelinePath: 'foo',
- hasCiEnabled: false,
- helpPagePath: 'foo',
- ciLintPath: 'foo',
- canCreatePipeline: true,
- };
+ describe('Reset Runners Cache', () => {
+ beforeEach(() => {
+ const mockData = {
+ newPipelinePath: 'foo',
+ ciLintPath: 'foo',
+ resetCachePath: 'foo',
+ };
- const component = new NavControlsComponent({
- propsData: mockData,
- }).$mount();
+ component = mountComponent(NavControlsComponent, mockData);
+ });
- expect(component.$el.querySelector('.btn-info').textContent).toContain('Get started with Pipelines');
- expect(component.$el.querySelector('.btn-info').getAttribute('href')).toEqual(mockData.helpPagePath);
- });
+ it('should render button for resetting runner caches', () => {
+ expect(component.$el.querySelector('.js-clear-cache').textContent.trim()).toContain('Clear Runner Caches');
+ });
- it('should not render link to help page when CI is enabled', () => {
- const mockData = {
- newPipelinePath: 'foo',
- hasCiEnabled: true,
- helpPagePath: 'foo',
- ciLintPath: 'foo',
- canCreatePipeline: true,
- };
+ it('should emit postAction event when reset runner cache button is clicked', () => {
+ spyOn(component, '$emit');
- const component = new NavControlsComponent({
- propsData: mockData,
- }).$mount();
+ component.$el.querySelector('.js-clear-cache').click();
- expect(component.$el.querySelector('.btn-info')).toEqual(null);
+ expect(component.$emit).toHaveBeenCalledWith('resetRunnersCache', 'foo');
+ });
});
});
diff --git a/spec/javascripts/pipelines/pipeline_details_mediator_spec.js b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js
index 9fec2f61f78..e58a8018ed5 100644
--- a/spec/javascripts/pipelines/pipeline_details_mediator_spec.js
+++ b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js
@@ -1,5 +1,6 @@
+import _ from 'underscore';
import Vue from 'vue';
-import PipelineMediator from '~/pipelines/pipeline_details_mediatior';
+import PipelineMediator from '~/pipelines/pipeline_details_mediator';
describe('PipelineMdediator', () => {
let mediator;
diff --git a/spec/javascripts/pipelines/pipeline_store_spec.js b/spec/javascripts/pipelines/pipeline_store_spec.js
index 85d13445b01..ab2287cc344 100644
--- a/spec/javascripts/pipelines/pipeline_store_spec.js
+++ b/spec/javascripts/pipelines/pipeline_store_spec.js
@@ -8,7 +8,6 @@ describe('Pipeline Store', () => {
});
it('should set defaults', () => {
- expect(store.state).toEqual({ pipeline: {} });
expect(store.state.pipeline).toEqual({});
});
diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js
index 367b42cefb0..7e242eb45e1 100644
--- a/spec/javascripts/pipelines/pipelines_spec.js
+++ b/spec/javascripts/pipelines/pipelines_spec.js
@@ -1,41 +1,386 @@
+import _ from 'underscore';
import Vue from 'vue';
import pipelinesComp from '~/pipelines/components/pipelines.vue';
import Store from '~/pipelines/stores/pipelines_store';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Pipelines', () => {
const jsonFixtureName = 'pipelines/pipelines.json';
- preloadFixtures('static/pipelines.html.raw');
preloadFixtures(jsonFixtureName);
let PipelinesComponent;
let pipelines;
- let component;
+ let vm;
+ const paths = {
+ endpoint: 'twitter/flight/pipelines.json',
+ autoDevopsPath: '/help/topics/autodevops/index.md',
+ helpPagePath: '/help/ci/quick_start/README',
+ emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ ciLintPath: '/ci/lint',
+ resetCachePath: '/twitter/flight/settings/ci_cd/reset_cache',
+ newPipelinePath: '/twitter/flight/pipelines/new',
+ };
+
+ const noPermissions = {
+ endpoint: 'twitter/flight/pipelines.json',
+ autoDevopsPath: '/help/topics/autodevops/index.md',
+ helpPagePath: '/help/ci/quick_start/README',
+ emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ };
beforeEach(() => {
- loadFixtures('static/pipelines.html.raw');
pipelines = getJSONFixture(jsonFixtureName);
PipelinesComponent = Vue.extend(pipelinesComp);
});
afterEach(() => {
- component.$destroy();
+ vm.$destroy();
+ });
+
+ const pipelinesInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify(pipelines), {
+ status: 200,
+ }));
+ };
+
+ const emptyStateInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify({
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ }), {
+ status: 200,
+ }));
+ };
+
+ const errorInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify({}), {
+ status: 500,
+ }));
+ };
+
+ describe('With permission', () => {
+ describe('With pipelines in main tab', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(pipelinesInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, pipelinesInterceptor,
+ );
+ });
+
+ it('renders tabs', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
+ });
+
+ it('renders Run Pipeline link', () => {
+ expect(vm.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(paths.newPipelinePath);
+ });
+
+ it('renders CI Lint link', () => {
+ expect(vm.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(paths.ciLintPath);
+ });
+
+ it('renders Clear Runner Cache button', () => {
+ expect(vm.$el.querySelector('.js-clear-cache').textContent.trim()).toEqual('Clear Runner Caches');
+ });
+
+ it('renders pipelines table', () => {
+ expect(
+ vm.$el.querySelectorAll('.gl-responsive-table-row').length,
+ ).toEqual(pipelines.pipelines.length + 1);
+ });
+ });
+
+ describe('Without pipelines on main tab with CI', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(emptyStateInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, emptyStateInterceptor,
+ );
+ });
+
+ it('renders tabs', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
+ });
+
+ it('renders Run Pipeline link', () => {
+ expect(vm.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(paths.newPipelinePath);
+ });
+
+ it('renders CI Lint link', () => {
+ expect(vm.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(paths.ciLintPath);
+ });
+
+ it('renders Clear Runner Cache button', () => {
+ expect(vm.$el.querySelector('.js-clear-cache').textContent.trim()).toEqual('Clear Runner Caches');
+ });
+
+ it('renders tab empty state', () => {
+ expect(vm.$el.querySelector('.empty-state h4').textContent.trim()).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('Without pipelines nor CI', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(emptyStateInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: false,
+ canCreatePipeline: true,
+ ...paths,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, emptyStateInterceptor,
+ );
+ });
+
+ it('renders empty state', () => {
+ expect(vm.$el.querySelector('.js-empty-state h4').textContent.trim()).toEqual('Build with confidence');
+ expect(vm.$el.querySelector('.js-get-started-pipelines').getAttribute('href')).toEqual(paths.helpPagePath);
+ });
+
+ it('does not render tabs nor buttons', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all')).toBeNull();
+ expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
+ expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
+ expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
+ });
+ });
+
+ describe('When API returns error', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(errorInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: false,
+ canCreatePipeline: true,
+ ...paths,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, errorInterceptor,
+ );
+ });
+
+ it('renders tabs', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
+ });
+
+ it('renders buttons', () => {
+ expect(vm.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual(paths.newPipelinePath);
+ expect(vm.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual(paths.ciLintPath);
+ expect(vm.$el.querySelector('.js-clear-cache').textContent.trim()).toEqual('Clear Runner Caches');
+ });
+
+ it('renders error state', () => {
+ expect(vm.$el.querySelector('.empty-state').textContent.trim()).toContain('There was an error fetching the pipelines.');
+ });
+ });
+ });
+
+ describe('Without permission', () => {
+ describe('With pipelines in main tab', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(pipelinesInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: false,
+ canCreatePipeline: false,
+ ...noPermissions,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, pipelinesInterceptor,
+ );
+ });
+
+ it('renders tabs', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
+ });
+
+ it('does not render buttons', () => {
+ expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
+ expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
+ expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
+ });
+
+ it('renders pipelines table', () => {
+ expect(
+ vm.$el.querySelectorAll('.gl-responsive-table-row').length,
+ ).toEqual(pipelines.pipelines.length + 1);
+ });
+ });
+
+ describe('Without pipelines on main tab with CI', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(emptyStateInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: false,
+ ...noPermissions,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, emptyStateInterceptor,
+ );
+ });
+ it('renders tabs', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
+ });
+
+ it('does not render buttons', () => {
+ expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
+ expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
+ expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
+ });
+
+ it('renders tab empty state', () => {
+ expect(vm.$el.querySelector('.empty-state h4').textContent.trim()).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('Without pipelines nor CI', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(emptyStateInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: false,
+ canCreatePipeline: false,
+ ...noPermissions,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, emptyStateInterceptor,
+ );
+ });
+
+ it('renders empty state without button to set CI', () => {
+ expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toEqual('This project is not currently set up to run pipelines.');
+ expect(vm.$el.querySelector('.js-get-started-pipelines')).toBeNull();
+ });
+
+ it('does not render tabs or buttons', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all')).toBeNull();
+ expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
+ expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
+ expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
+ });
+ });
+
+ describe('When API returns error', () => {
+ beforeEach((done) => {
+ Vue.http.interceptors.push(errorInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: false,
+ canCreatePipeline: true,
+ ...noPermissions,
+ });
+
+ setTimeout(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(
+ Vue.http.interceptors, errorInterceptor,
+ );
+ });
+
+ it('renders tabs', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim()).toContain('All');
+ });
+
+ it('does not renders buttons', () => {
+ expect(vm.$el.querySelector('.js-run-pipeline')).toBeNull();
+ expect(vm.$el.querySelector('.js-ci-lint')).toBeNull();
+ expect(vm.$el.querySelector('.js-clear-cache')).toBeNull();
+ });
+
+ it('renders error state', () => {
+ expect(vm.$el.querySelector('.empty-state').textContent.trim()).toContain('There was an error fetching the pipelines.');
+ });
+ });
});
describe('successfull request', () => {
describe('with pipelines', () => {
- const pipelinesInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify(pipelines), {
- status: 200,
- }));
- };
-
beforeEach(() => {
Vue.http.interceptors.push(pipelinesInterceptor);
- component = mountComponent(PipelinesComponent, {
+ vm = mountComponent(PipelinesComponent, {
store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
});
});
@@ -47,9 +392,9 @@ describe('Pipelines', () => {
it('should render table', (done) => {
setTimeout(() => {
- expect(component.$el.querySelector('.table-holder')).toBeDefined();
+ expect(vm.$el.querySelector('.table-holder')).toBeDefined();
expect(
- component.$el.querySelectorAll('.gl-responsive-table-row').length,
+ vm.$el.querySelectorAll('.gl-responsive-table-row').length,
).toEqual(pipelines.pipelines.length + 1);
done();
});
@@ -58,22 +403,22 @@ describe('Pipelines', () => {
it('should render navigation tabs', (done) => {
setTimeout(() => {
expect(
- component.$el.querySelector('.js-pipelines-tab-pending').textContent.trim(),
+ vm.$el.querySelector('.js-pipelines-tab-pending').textContent.trim(),
).toContain('Pending');
expect(
- component.$el.querySelector('.js-pipelines-tab-all').textContent.trim(),
+ vm.$el.querySelector('.js-pipelines-tab-all').textContent.trim(),
).toContain('All');
expect(
- component.$el.querySelector('.js-pipelines-tab-running').textContent.trim(),
+ vm.$el.querySelector('.js-pipelines-tab-running').textContent.trim(),
).toContain('Running');
expect(
- component.$el.querySelector('.js-pipelines-tab-finished').textContent.trim(),
+ vm.$el.querySelector('.js-pipelines-tab-finished').textContent.trim(),
).toContain('Finished');
expect(
- component.$el.querySelector('.js-pipelines-tab-branches').textContent.trim(),
+ vm.$el.querySelector('.js-pipelines-tab-branches').textContent.trim(),
).toContain('Branches');
expect(
- component.$el.querySelector('.js-pipelines-tab-tags').textContent.trim(),
+ vm.$el.querySelector('.js-pipelines-tab-tags').textContent.trim(),
).toContain('Tags');
done();
});
@@ -81,10 +426,10 @@ describe('Pipelines', () => {
it('should make an API request when using tabs', (done) => {
setTimeout(() => {
- spyOn(component, 'updateContent');
- component.$el.querySelector('.js-pipelines-tab-finished').click();
+ spyOn(vm, 'updateContent');
+ vm.$el.querySelector('.js-pipelines-tab-finished').click();
- expect(component.updateContent).toHaveBeenCalledWith({ scope: 'finished', page: '1' });
+ expect(vm.updateContent).toHaveBeenCalledWith({ scope: 'finished', page: '1' });
done();
});
});
@@ -92,9 +437,9 @@ describe('Pipelines', () => {
describe('with pagination', () => {
it('should make an API request when using pagination', (done) => {
setTimeout(() => {
- spyOn(component, 'updateContent');
+ spyOn(vm, 'updateContent');
// Mock pagination
- component.store.state.pageInfo = {
+ vm.store.state.pageInfo = {
page: 1,
total: 10,
perPage: 2,
@@ -102,9 +447,9 @@ describe('Pipelines', () => {
totalPages: 5,
};
- Vue.nextTick(() => {
- component.$el.querySelector('.js-next-button a').click();
- expect(component.updateContent).toHaveBeenCalledWith({ scope: 'all', page: '2' });
+ vm.$nextTick(() => {
+ vm.$el.querySelector('.js-next-button a').click();
+ expect(vm.updateContent).toHaveBeenCalledWith({ scope: 'all', page: '2' });
done();
});
@@ -112,112 +457,249 @@ describe('Pipelines', () => {
});
});
});
+ });
- describe('without pipelines', () => {
- const emptyInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), {
- status: 200,
- }));
- };
+ describe('methods', () => {
+ beforeEach(() => {
+ spyOn(history, 'pushState').and.stub();
+ });
- beforeEach(() => {
- Vue.http.interceptors.push(emptyInterceptor);
- });
+ describe('updateContent', () => {
+ it('should set given parameters', () => {
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ });
+ vm.updateContent({ scope: 'finished', page: '4' });
- afterEach(() => {
- Vue.http.interceptors = _.without(
- Vue.http.interceptors, emptyInterceptor,
- );
+ expect(vm.page).toEqual('4');
+ expect(vm.scope).toEqual('finished');
+ expect(vm.requestData.scope).toEqual('finished');
+ expect(vm.requestData.page).toEqual('4');
});
+ });
+
+ describe('onChangeTab', () => {
+ it('should set page to 1', () => {
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ });
+ spyOn(vm, 'updateContent');
- it('should render empty state', (done) => {
- component = new PipelinesComponent({
- propsData: {
- store: new Store(),
- },
- }).$mount();
+ vm.onChangeTab('running');
- setTimeout(() => {
- expect(component.$el.querySelector('.empty-state')).not.toBe(null);
- done();
+ expect(vm.updateContent).toHaveBeenCalledWith({ scope: 'running', page: '1' });
+ });
+ });
+
+ describe('onChangePage', () => {
+ it('should update page and keep scope', () => {
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
});
+ spyOn(vm, 'updateContent');
+
+ vm.onChangePage(4);
+
+ expect(vm.updateContent).toHaveBeenCalledWith({ scope: vm.scope, page: '4' });
});
});
});
- describe('unsuccessfull request', () => {
- const errorInterceptor = (request, next) => {
- next(request.respondWith(JSON.stringify([]), {
- status: 500,
- }));
- };
-
+ describe('computed properties', () => {
beforeEach(() => {
- Vue.http.interceptors.push(errorInterceptor);
+ vm = mountComponent(PipelinesComponent, {
+ store: new Store(),
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ });
});
- afterEach(() => {
- Vue.http.interceptors = _.without(
- Vue.http.interceptors, errorInterceptor,
- );
+ describe('tabs', () => {
+ it('returns default tabs', () => {
+ expect(vm.tabs).toEqual([
+ { name: 'All', scope: 'all', count: undefined, isActive: true },
+ { name: 'Pending', scope: 'pending', count: undefined, isActive: false },
+ { name: 'Running', scope: 'running', count: undefined, isActive: false },
+ { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
+ { name: 'Branches', scope: 'branches', isActive: false },
+ { name: 'Tags', scope: 'tags', isActive: false },
+ ]);
+ });
});
- it('should render error state', (done) => {
- component = new PipelinesComponent({
- propsData: {
- store: new Store(),
- },
- }).$mount();
+ describe('emptyTabMessage', () => {
+ it('returns message with scope', (done) => {
+ vm.scope = 'pending';
- setTimeout(() => {
- expect(component.$el.querySelector('.js-pipelines-error-state')).toBeDefined();
- done();
+ vm.$nextTick(() => {
+ expect(vm.emptyTabMessage).toEqual('There are currently no pending pipelines.');
+ done();
+ });
});
- });
- });
- describe('methods', () => {
- beforeEach(() => {
- spyOn(history, 'pushState').and.stub();
+ it('returns message without scope when scope is `all`', () => {
+ expect(vm.emptyTabMessage).toEqual('There are currently no pipelines.');
+ });
});
- describe('updateContent', () => {
- it('should set given parameters', () => {
- component = mountComponent(PipelinesComponent, {
- store: new Store(),
+ describe('stateToRender', () => {
+ it('returns loading state when the app is loading', () => {
+ expect(vm.stateToRender).toEqual('loading');
+ });
+
+ it('returns error state when app has error', (done) => {
+ vm.hasError = true;
+ vm.isLoading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.stateToRender).toEqual('error');
+ done();
+ });
+ });
+
+ it('returns table list when app has pipelines', (done) => {
+ vm.isLoading = false;
+ vm.hasError = false;
+ vm.state.pipelines = pipelines.pipelines;
+
+ vm.$nextTick(() => {
+ expect(vm.stateToRender).toEqual('tableList');
+
+ done();
+ });
+ });
+
+ it('returns empty tab when app does not have pipelines but project has pipelines', (done) => {
+ vm.state.count.all = 10;
+ vm.isLoading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.stateToRender).toEqual('emptyTab');
+
+ done();
});
- component.updateContent({ scope: 'finished', page: '4' });
+ });
+
+ it('returns empty tab when project has CI', (done) => {
+ vm.isLoading = false;
+ vm.$nextTick(() => {
+ expect(vm.stateToRender).toEqual('emptyTab');
- expect(component.page).toEqual('4');
- expect(component.scope).toEqual('finished');
- expect(component.requestData.scope).toEqual('finished');
- expect(component.requestData.page).toEqual('4');
+ done();
+ });
+ });
+
+ it('returns empty state when project does not have pipelines nor CI', (done) => {
+ vm.isLoading = false;
+ vm.hasGitlabCi = false;
+ vm.$nextTick(() => {
+ expect(vm.stateToRender).toEqual('emptyState');
+
+ done();
+ });
});
});
- describe('onChangeTab', () => {
- it('should set page to 1', () => {
- component = mountComponent(PipelinesComponent, {
- store: new Store(),
+ describe('shouldRenderTabs', () => {
+ it('returns true when state is loading & has already made the first request', (done) => {
+ vm.isLoading = true;
+ vm.hasMadeRequest = true;
+
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderTabs).toEqual(true);
+
+ done();
});
- spyOn(component, 'updateContent');
+ });
- component.onChangeTab('running');
+ it('returns true when state is tableList & has already made the first request', (done) => {
+ vm.isLoading = false;
+ vm.state.pipelines = pipelines.pipelines;
+ vm.hasMadeRequest = true;
- expect(component.updateContent).toHaveBeenCalledWith({ scope: 'running', page: '1' });
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderTabs).toEqual(true);
+
+ done();
+ });
+ });
+
+ it('returns true when state is error & has already made the first request', (done) => {
+ vm.isLoading = false;
+ vm.hasError = true;
+ vm.hasMadeRequest = true;
+
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderTabs).toEqual(true);
+
+ done();
+ });
+ });
+
+ it('returns true when state is empty tab & has already made the first request', (done) => {
+ vm.isLoading = false;
+ vm.state.count.all = 10;
+ vm.hasMadeRequest = true;
+
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderTabs).toEqual(true);
+
+ done();
+ });
+ });
+
+ it('returns false when has not made first request', (done) => {
+ vm.hasMadeRequest = false;
+
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderTabs).toEqual(false);
+
+ done();
+ });
+ });
+
+ it('returns false when state is emtpy state', (done) => {
+ vm.isLoading = false;
+ vm.hasMadeRequest = true;
+ vm.hasGitlabCi = false;
+
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderTabs).toEqual(false);
+
+ done();
+ });
});
});
- describe('onChangePage', () => {
- it('should update page and keep scope', () => {
- component = mountComponent(PipelinesComponent, {
- store: new Store(),
+ describe('shouldRenderButtons', () => {
+ it('returns true when it has paths & has made the first request', (done) => {
+ vm.hasMadeRequest = true;
+
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderButtons).toEqual(true);
+
+ done();
});
- spyOn(component, 'updateContent');
+ });
+
+ it('returns false when it has not made the first request', (done) => {
+ vm.hasMadeRequest = false;
- component.onChangePage(4);
+ vm.$nextTick(() => {
+ expect(vm.shouldRenderButtons).toEqual(false);
- expect(component.updateContent).toHaveBeenCalledWith({ scope: component.scope, page: '4' });
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/pipelines/pipelines_table_row_spec.js b/spec/javascripts/pipelines/pipelines_table_row_spec.js
index a9126d2f4e9..de744739e42 100644
--- a/spec/javascripts/pipelines/pipelines_table_row_spec.js
+++ b/spec/javascripts/pipelines/pipelines_table_row_spec.js
@@ -24,9 +24,10 @@ describe('Pipelines Table Row', () => {
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);
+
+ pipeline = pipelines.find(p => p.user !== null && p.commit !== null);
+ pipelineWithoutAuthor = pipelines.find(p => p.user === null && p.commit !== null);
+ pipelineWithoutCommit = pipelines.find(p => p.user === null && p.commit === null);
});
afterEach(() => {
diff --git a/spec/javascripts/pipelines/pipelines_table_spec.js b/spec/javascripts/pipelines/pipelines_table_spec.js
index ca2f9163313..4fc3c08145e 100644
--- a/spec/javascripts/pipelines/pipelines_table_spec.js
+++ b/spec/javascripts/pipelines/pipelines_table_spec.js
@@ -11,9 +11,10 @@ describe('Pipelines Table', () => {
preloadFixtures(jsonFixtureName);
beforeEach(() => {
- PipelinesTableComponent = Vue.extend(pipelinesTableComp);
const pipelines = getJSONFixture(jsonFixtureName).pipelines;
- pipeline = pipelines.find(p => p.id === 1);
+
+ PipelinesTableComponent = Vue.extend(pipelinesTableComp);
+ pipeline = pipelines.find(p => p.user !== null && p.commit !== null);
});
describe('table', () => {
diff --git a/spec/javascripts/pipelines/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js
index 1b96b2e3d51..61c2f783acc 100644
--- a/spec/javascripts/pipelines/stage_spec.js
+++ b/spec/javascripts/pipelines/stage_spec.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import Vue from 'vue';
import stage from '~/pipelines/components/stage.vue';
diff --git a/spec/javascripts/profile/account/components/delete_account_modal_spec.js b/spec/javascripts/profile/account/components/delete_account_modal_spec.js
index 2e94948cfb2..a0939ff5c20 100644
--- a/spec/javascripts/profile/account/components/delete_account_modal_spec.js
+++ b/spec/javascripts/profile/account/components/delete_account_modal_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('DeleteAccountModal component', () => {
const actionUrl = `${gl.TEST_HOST}/delete/user`;
@@ -51,7 +51,7 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredPassword).toBe(input.value);
- expect(submitButton).toHaveClass('disabled');
+ expect(submitButton).toHaveAttr('disabled', 'disabled');
submitButton.click();
expect(form.submit).not.toHaveBeenCalled();
})
@@ -68,7 +68,7 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredPassword).toBe(input.value);
- expect(submitButton).not.toHaveClass('disabled');
+ expect(submitButton).not.toHaveAttr('disabled', 'disabled');
submitButton.click();
expect(form.submit).toHaveBeenCalled();
})
@@ -101,7 +101,7 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredUsername).toBe(input.value);
- expect(submitButton).toHaveClass('disabled');
+ expect(submitButton).toHaveAttr('disabled', 'disabled');
submitButton.click();
expect(form.submit).not.toHaveBeenCalled();
})
@@ -118,7 +118,7 @@ describe('DeleteAccountModal component', () => {
Vue.nextTick()
.then(() => {
expect(vm.enteredUsername).toBe(input.value);
- expect(submitButton).not.toHaveClass('disabled');
+ expect(submitButton).not.toHaveAttr('disabled', 'disabled');
submitButton.click();
expect(form.submit).toHaveBeenCalled();
})
diff --git a/spec/javascripts/profile/account/components/update_username_spec.js b/spec/javascripts/profile/account/components/update_username_spec.js
new file mode 100644
index 00000000000..bac306edf5a
--- /dev/null
+++ b/spec/javascripts/profile/account/components/update_username_spec.js
@@ -0,0 +1,172 @@
+import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+
+import updateUsername from '~/profile/account/components/update_username.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('UpdateUsername component', () => {
+ const rootUrl = gl.TEST_HOST;
+ const actionUrl = `${gl.TEST_HOST}/update/username`;
+ const username = 'hasnoname';
+ const newUsername = 'new_username';
+ let Component;
+ let vm;
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ Component = Vue.extend(updateUsername);
+ vm = mountComponent(Component, {
+ actionUrl,
+ rootUrl,
+ initialUsername: username,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ axiosMock.restore();
+ });
+
+ const findElements = () => {
+ const modalSelector = `#${vm.$options.modalId}`;
+
+ return {
+ input: vm.$el.querySelector(`#${vm.$options.inputId}`),
+ openModalBtn: vm.$el.querySelector(`[data-target="${modalSelector}"]`),
+ modal: vm.$el.querySelector(modalSelector),
+ modalBody: vm.$el.querySelector(`${modalSelector} .modal-body`),
+ modalHeader: vm.$el.querySelector(`${modalSelector} .modal-title`),
+ confirmModalBtn: vm.$el.querySelector(`${modalSelector} .btn-warning`),
+ };
+ };
+
+ it('has a disabled button if the username was not changed', done => {
+ const { input, openModalBtn } = findElements();
+ input.dispatchEvent(new Event('input'));
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.username).toBe(username);
+ expect(vm.newUsername).toBe(username);
+ expect(openModalBtn).toBeDisabled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('has an enabled button which if the username was changed', done => {
+ const { input, openModalBtn } = findElements();
+ input.value = newUsername;
+ input.dispatchEvent(new Event('input'));
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.username).toBe(username);
+ expect(vm.newUsername).toBe(newUsername);
+ expect(openModalBtn).not.toBeDisabled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('confirmation modal contains proper header and body', done => {
+ const { modalBody, modalHeader } = findElements();
+
+ vm.newUsername = newUsername;
+
+ Vue.nextTick()
+ .then(() => {
+ expect(modalHeader.textContent).toContain('Change username?');
+ expect(modalBody.textContent).toContain(
+ `You are going to change the username ${username} to ${newUsername}`,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('confirmation modal should escape usernames properly', done => {
+ const { modalBody } = findElements();
+
+ vm.username = vm.newUsername = '<i>Italic</i>';
+
+ Vue.nextTick()
+ .then(() => {
+ expect(modalBody.innerHTML).toContain('&lt;i&gt;Italic&lt;/i&gt;');
+ expect(modalBody.innerHTML).not.toContain(vm.username);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('executes API call on confirmation button click', done => {
+ const { confirmModalBtn } = findElements();
+
+ axiosMock.onPut(actionUrl).replyOnce(() => [200, { message: 'Username changed' }]);
+ spyOn(axios, 'put').and.callThrough();
+
+ vm.newUsername = newUsername;
+
+ Vue.nextTick()
+ .then(() => {
+ confirmModalBtn.click();
+ expect(axios.put).toHaveBeenCalledWith(actionUrl, { user: { username: newUsername } });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('sets the username after a successful update', done => {
+ const { input, openModalBtn } = findElements();
+
+ axiosMock.onPut(actionUrl).replyOnce(() => {
+ expect(input).toBeDisabled();
+ expect(openModalBtn).toBeDisabled();
+
+ return [200, { message: 'Username changed' }];
+ });
+
+ vm.newUsername = newUsername;
+
+ vm
+ .onConfirm()
+ .then(() => {
+ expect(vm.username).toBe(newUsername);
+ expect(vm.newUsername).toBe(newUsername);
+ expect(input).not.toBeDisabled();
+ expect(input.value).toBe(newUsername);
+ expect(openModalBtn).toBeDisabled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not set the username after a erroneous update', done => {
+ const { input, openModalBtn } = findElements();
+
+ axiosMock.onPut(actionUrl).replyOnce(() => {
+ expect(input).toBeDisabled();
+ expect(openModalBtn).toBeDisabled();
+
+ return [400, { message: 'Invalid username' }];
+ });
+
+ const invalidUsername = 'anything.git';
+ vm.newUsername = invalidUsername;
+
+ vm
+ .onConfirm()
+ .then(() => done.fail('Expected onConfirm to throw!'))
+ .catch(() => {
+ expect(vm.username).toBe(username);
+ expect(vm.newUsername).toBe(invalidUsername);
+ expect(input).not.toBeDisabled();
+ expect(input.value).toBe(invalidUsername);
+ expect(openModalBtn).not.toBeDisabled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/javascripts/project_select_combo_button_spec.js b/spec/javascripts/project_select_combo_button_spec.js
index dda83645c92..1b65f767f96 100644
--- a/spec/javascripts/project_select_combo_button_spec.js
+++ b/spec/javascripts/project_select_combo_button_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import ProjectSelectComboButton from '~/project_select_combo_button';
const fixturePath = 'static/project_select_combo_button.html.raw';
diff --git a/spec/javascripts/projects/project_import_gitlab_project_spec.js b/spec/javascripts/projects/project_import_gitlab_project_spec.js
index 2f1aae109e3..126f73103e0 100644
--- a/spec/javascripts/projects/project_import_gitlab_project_spec.js
+++ b/spec/javascripts/projects/project_import_gitlab_project_spec.js
@@ -10,7 +10,7 @@ describe('Import Gitlab project', () => {
<input class="js-path-name" />
`);
- projectImportGitlab.bindEvents();
+ projectImportGitlab();
});
afterEach(() => {
diff --git a/spec/javascripts/projects/project_new_spec.js b/spec/javascripts/projects/project_new_spec.js
index 850768f0e4f..84515d2bf97 100644
--- a/spec/javascripts/projects/project_new_spec.js
+++ b/spec/javascripts/projects/project_new_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import projectNew from '~/projects/project_new';
describe('New Project', () => {
@@ -6,8 +7,12 @@ describe('New Project', () => {
beforeEach(() => {
setFixtures(`
- <input id="project_import_url" />
- <input id="project_path" />
+ <div class='toggle-import-form'>
+ <div class='import-url-data'>
+ <input id="project_import_url" />
+ <input id="project_path" />
+ </div>
+ </div>
`);
$projectImportUrl = $('#project_import_url');
@@ -23,22 +28,22 @@ describe('New Project', () => {
});
it('does not change project path for disabled $projectImportUrl', () => {
- $projectImportUrl.attr('disabled', true);
+ $projectImportUrl.prop('disabled', true);
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual(dummyImportUrl);
});
describe('for enabled $projectImportUrl', () => {
beforeEach(() => {
- $projectImportUrl.attr('disabled', false);
+ $projectImportUrl.prop('disabled', false);
});
it('does not change project path if it is set by user', () => {
$projectPath.keyup();
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual(dummyImportUrl);
});
@@ -46,7 +51,7 @@ describe('New Project', () => {
it('does not change project path for empty $projectImportUrl', () => {
$projectImportUrl.val('');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual(dummyImportUrl);
});
@@ -54,7 +59,7 @@ describe('New Project', () => {
it('does not change project path for whitespace $projectImportUrl', () => {
$projectImportUrl.val(' ');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual(dummyImportUrl);
});
@@ -62,7 +67,7 @@ describe('New Project', () => {
it('does not change project path for $projectImportUrl without slashes', () => {
$projectImportUrl.val('has-no-slash');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual(dummyImportUrl);
});
@@ -70,7 +75,7 @@ describe('New Project', () => {
it('changes project path to last $projectImportUrl component', () => {
$projectImportUrl.val('/this/is/last');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual('last');
});
@@ -78,7 +83,7 @@ describe('New Project', () => {
it('ignores trailing slashes in $projectImportUrl', () => {
$projectImportUrl.val('/has/trailing/slash/');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual('slash');
});
@@ -86,7 +91,7 @@ describe('New Project', () => {
it('ignores fragment identifier in $projectImportUrl', () => {
$projectImportUrl.val('/this/has/a#fragment-identifier/');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual('a');
});
@@ -94,7 +99,7 @@ describe('New Project', () => {
it('ignores query string in $projectImportUrl', () => {
$projectImportUrl.val('/url/with?query=string');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual('with');
});
@@ -102,7 +107,7 @@ describe('New Project', () => {
it('ignores trailing .git in $projectImportUrl', () => {
$projectImportUrl.val('/repository.git');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual('repository');
});
@@ -110,7 +115,7 @@ describe('New Project', () => {
it('changes project path for HTTPS URL in $projectImportUrl', () => {
$projectImportUrl.val('https://username:password@gitlab.company.com/group/project.git');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual('project');
});
@@ -118,7 +123,7 @@ describe('New Project', () => {
it('changes project path for SSH URL in $projectImportUrl', () => {
$projectImportUrl.val('git@gitlab.com:gitlab-org/gitlab-ce.git');
- projectNew.deriveProjectPathFromUrl($projectImportUrl, $projectPath);
+ projectNew.deriveProjectPathFromUrl($projectImportUrl);
expect($projectPath.val()).toEqual('gitlab-ce');
});
diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js
index 42f0f6fc1af..2054fef790b 100644
--- a/spec/javascripts/projects_dropdown/components/app_spec.js
+++ b/spec/javascripts/projects_dropdown/components/app_spec.js
@@ -6,7 +6,7 @@ import eventHub from '~/projects_dropdown/event_hub';
import ProjectsStore from '~/projects_dropdown/store/projects_store';
import ProjectsService from '~/projects_dropdown/service/projects_service';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { currentSession, mockProject, mockRawProject } from '../mock_data';
const createComponent = () => {
diff --git a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
index fcd0f6a3630..2bafb4e81ca 100644
--- a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
+++ b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockFrequents } from '../mock_data';
const createComponent = () => {
diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js
index edef150dd1e..c193258474e 100644
--- a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js
+++ b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockProject } from '../mock_data';
const createComponent = () => {
diff --git a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js
index 67f8a8946c2..c4b86d77034 100644
--- a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js
+++ b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockProject } from '../mock_data';
const createComponent = () => {
diff --git a/spec/javascripts/projects_dropdown/components/search_spec.js b/spec/javascripts/projects_dropdown/components/search_spec.js
index 24d8a00b254..601264258c2 100644
--- a/spec/javascripts/projects_dropdown/components/search_spec.js
+++ b/spec/javascripts/projects_dropdown/components/search_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import searchComponent from '~/projects_dropdown/components/search.vue';
import eventHub from '~/projects_dropdown/event_hub';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(searchComponent);
diff --git a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
index b24567ffc0c..955ec6a531c 100644
--- a/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
+++ b/spec/javascripts/prometheus_metrics/prometheus_metrics_spec.js
@@ -1,3 +1,5 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
import PANEL_STATE from '~/prometheus_metrics/constants';
import { metrics, missingVarMetrics } from './mock_data';
@@ -83,7 +85,7 @@ describe('PrometheusMetrics', () => {
expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
- expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('12');
+ expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual('3 exporters with 12 metrics were found');
expect($metricsListLi.length).toEqual(metrics.length);
expect($metricsListLi.first().find('.badge').text()).toEqual(`${metrics[0].active_metrics}`);
});
@@ -102,25 +104,38 @@ describe('PrometheusMetrics', () => {
describe('loadActiveMetrics', () => {
let prometheusMetrics;
+ let mock;
+
+ function mockSuccess() {
+ mock.onGet(prometheusMetrics.activeMetricsEndpoint).reply(200, {
+ data: metrics,
+ success: true,
+ });
+ }
+
+ function mockError() {
+ mock.onGet(prometheusMetrics.activeMetricsEndpoint).networkError();
+ }
beforeEach(() => {
+ spyOn(axios, 'get').and.callThrough();
+
prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
});
it('should show loader animation while response is being loaded and hide it when request is complete', (done) => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
+ mockSuccess();
prometheusMetrics.loadActiveMetrics();
expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
- expect($.ajax).toHaveBeenCalledWith({
- url: prometheusMetrics.activeMetricsEndpoint,
- dataType: 'json',
- global: false,
- });
-
- deferred.resolve({ data: metrics, success: true });
+ expect(axios.get).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint);
setTimeout(() => {
expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
@@ -129,14 +144,10 @@ describe('PrometheusMetrics', () => {
});
it('should show empty state if response failed to load', (done) => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
- spyOn(prometheusMetrics, 'populateActiveMetrics');
+ mockError();
prometheusMetrics.loadActiveMetrics();
- deferred.reject();
-
setTimeout(() => {
expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
@@ -145,14 +156,11 @@ describe('PrometheusMetrics', () => {
});
it('should populate metrics list once response is loaded', (done) => {
- const deferred = $.Deferred();
- spyOn($, 'ajax').and.returnValue(deferred.promise());
spyOn(prometheusMetrics, 'populateActiveMetrics');
+ mockSuccess();
prometheusMetrics.loadActiveMetrics();
- deferred.resolve({ data: metrics, success: true });
-
setTimeout(() => {
expect(prometheusMetrics.populateActiveMetrics).toHaveBeenCalledWith(metrics);
done();
diff --git a/spec/javascripts/registry/components/app_spec.js b/spec/javascripts/registry/components/app_spec.js
index 43e7d9e1224..cf1d0625397 100644
--- a/spec/javascripts/registry/components/app_spec.js
+++ b/spec/javascripts/registry/components/app_spec.js
@@ -1,6 +1,7 @@
+import _ from 'underscore';
import Vue from 'vue';
import registry from '~/registry/components/app.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { reposServerResponse } from '../mock_data';
describe('Registry List', () => {
@@ -89,7 +90,7 @@ describe('Registry List', () => {
it('should render empty message', (done) => {
setTimeout(() => {
expect(
- vm.$el.querySelector('p').textContent.trim(),
+ vm.$el.querySelector('p').textContent.trim().replace(/[\r\n]+/g, ' '),
).toEqual('No container images stored for this project. Add one by following the instructions above.');
done();
}, 0);
diff --git a/spec/javascripts/registry/stores/actions_spec.js b/spec/javascripts/registry/stores/actions_spec.js
index 3c9da4f107b..bc4c444655a 100644
--- a/spec/javascripts/registry/stores/actions_spec.js
+++ b/spec/javascripts/registry/stores/actions_spec.js
@@ -29,57 +29,96 @@ describe('Actions Registry Store', () => {
describe('fetchRepos', () => {
beforeEach(() => {
interceptor = (request, next) => {
- next(request.respondWith(JSON.stringify(reposServerResponse), {
- status: 200,
- }));
+ next(
+ request.respondWith(JSON.stringify(reposServerResponse), {
+ status: 200,
+ }),
+ );
};
Vue.http.interceptors.push(interceptor);
});
- it('should set receveived repos', (done) => {
- testAction(actions.fetchRepos, null, mockedState, [
- { type: types.TOGGLE_MAIN_LOADING },
- { type: types.SET_REPOS_LIST, payload: reposServerResponse },
- ], done);
+ it('should set receveived repos', done => {
+ testAction(
+ actions.fetchRepos,
+ null,
+ mockedState,
+ [
+ { type: types.TOGGLE_MAIN_LOADING },
+ { type: types.TOGGLE_MAIN_LOADING },
+ { type: types.SET_REPOS_LIST, payload: reposServerResponse },
+ ],
+ [],
+ done,
+ );
});
});
describe('fetchList', () => {
beforeEach(() => {
interceptor = (request, next) => {
- next(request.respondWith(JSON.stringify(registryServerResponse), {
- status: 200,
- }));
+ next(
+ request.respondWith(JSON.stringify(registryServerResponse), {
+ status: 200,
+ }),
+ );
};
Vue.http.interceptors.push(interceptor);
});
- it('should set received list', (done) => {
+ it('should set received list', done => {
mockedState.repos = parsedReposServerResponse;
- testAction(actions.fetchList, { repo: mockedState.repos[1] }, mockedState, [
- { type: types.TOGGLE_REGISTRY_LIST_LOADING },
- { type: types.SET_REGISTRY_LIST, payload: registryServerResponse },
- ], done);
+ const repo = mockedState.repos[1];
+
+ testAction(
+ actions.fetchList,
+ { repo },
+ mockedState,
+ [
+ { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
+ { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
+ {
+ type: types.SET_REGISTRY_LIST,
+ payload: {
+ repo,
+ resp: registryServerResponse,
+ headers: jasmine.anything(),
+ },
+ },
+ ],
+ [],
+ done,
+ );
});
});
});
describe('setMainEndpoint', () => {
- it('should commit set main endpoint', (done) => {
- testAction(actions.setMainEndpoint, 'endpoint', mockedState, [
- { type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' },
- ], done);
+ it('should commit set main endpoint', done => {
+ testAction(
+ actions.setMainEndpoint,
+ 'endpoint',
+ mockedState,
+ [{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }],
+ [],
+ done,
+ );
});
});
describe('toggleLoading', () => {
- it('should commit toggle main loading', (done) => {
- testAction(actions.toggleLoading, null, mockedState, [
- { type: types.TOGGLE_MAIN_LOADING },
- ], done);
+ it('should commit toggle main loading', done => {
+ testAction(
+ actions.toggleLoading,
+ null,
+ mockedState,
+ [{ type: types.TOGGLE_MAIN_LOADING }],
+ [],
+ done,
+ );
});
});
});
diff --git a/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js
deleted file mode 100644
index c4d3866c922..00000000000
--- a/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue';
-import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
-import { file } from '../../helpers';
-
-describe('Multi-file editor commit sidebar list collapsed', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(listCollapsed);
-
- vm = createComponentWithStore(Component, store);
-
- vm.$store.state.openFiles.push(file(), file());
- vm.$store.state.openFiles[0].tempFile = true;
- vm.$store.state.openFiles.forEach((f) => {
- Object.assign(f, {
- changed: true,
- });
- });
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders added & modified files count', () => {
- expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1');
- });
-});
diff --git a/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js
deleted file mode 100644
index fc7c9ae9dd7..00000000000
--- a/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import Vue from 'vue';
-import listItem from '~/ide/components/commit_sidebar/list_item.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
-import { file } from '../../helpers';
-
-describe('Multi-file editor commit sidebar list item', () => {
- let vm;
- let f;
-
- beforeEach(() => {
- const Component = Vue.extend(listItem);
-
- f = file();
-
- vm = mountComponent(Component, {
- file: f,
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders file path', () => {
- expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path);
- });
-
- describe('computed', () => {
- describe('iconName', () => {
- it('returns modified when not a tempFile', () => {
- expect(vm.iconName).toBe('file-modified');
- });
-
- it('returns addition when not a tempFile', () => {
- f.tempFile = true;
-
- expect(vm.iconName).toBe('file-addition');
- });
- });
-
- describe('iconClass', () => {
- it('returns modified when not a tempFile', () => {
- expect(vm.iconClass).toContain('multi-file-modified');
- });
-
- it('returns addition when not a tempFile', () => {
- f.tempFile = true;
-
- expect(vm.iconClass).toContain('multi-file-addition');
- });
- });
- });
-});
diff --git a/spec/javascripts/repo/components/commit_sidebar/list_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_spec.js
deleted file mode 100644
index cb5240ad118..00000000000
--- a/spec/javascripts/repo/components/commit_sidebar/list_spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
-import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
-import { file } from '../../helpers';
-
-describe('Multi-file editor commit sidebar list', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(commitSidebarList);
-
- vm = createComponentWithStore(Component, store, {
- title: 'Staged',
- fileList: [],
- });
-
- vm.$store.state.rightPanelCollapsed = false;
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('empty file list', () => {
- it('renders no changes text', () => {
- expect(vm.$el.querySelector('.help-block').textContent.trim()).toBe('No changes');
- });
- });
-
- describe('with a list of files', () => {
- beforeEach((done) => {
- const f = file('file name');
- f.changed = true;
- vm.fileList.push(f);
-
- Vue.nextTick(done);
- });
-
- it('renders list', () => {
- expect(vm.$el.querySelectorAll('li').length).toBe(1);
- });
- });
-
- describe('collapsed', () => {
- beforeEach((done) => {
- vm.$store.state.rightPanelCollapsed = true;
-
- Vue.nextTick(done);
- });
-
- it('hides list', () => {
- expect(vm.$el.querySelector('.list-unstyled')).toBeNull();
- expect(vm.$el.querySelector('.help-block')).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/repo/components/ide_context_bar_spec.js b/spec/javascripts/repo/components/ide_context_bar_spec.js
deleted file mode 100644
index 3f8f37d2343..00000000000
--- a/spec/javascripts/repo/components/ide_context_bar_spec.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import ideContextBar from '~/ide/components/ide_context_bar.vue';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-
-describe('Multi-file editor right context bar', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(ideContextBar);
-
- vm = createComponentWithStore(Component, store);
-
- vm.$store.state.rightPanelCollapsed = false;
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('collapsed', () => {
- beforeEach((done) => {
- vm.$store.state.rightPanelCollapsed = true;
-
- Vue.nextTick(done);
- });
-
- it('adds collapsed class', () => {
- expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
- });
-
- it('shows correct icon', () => {
- expect(vm.currentIcon).toBe('angle-double-left');
- });
- });
-
- it('clicking toggle collapse button collapses the bar', () => {
- spyOn(vm, 'setPanelCollapsedStatus').and.returnValue(Promise.resolve());
-
- vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
-
- expect(vm.setPanelCollapsedStatus).toHaveBeenCalledWith({
- side: 'right',
- collapsed: true,
- });
- });
-});
diff --git a/spec/javascripts/repo/components/ide_repo_tree_spec.js b/spec/javascripts/repo/components/ide_repo_tree_spec.js
deleted file mode 100644
index b6f70f585cd..00000000000
--- a/spec/javascripts/repo/components/ide_repo_tree_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import ideRepoTree from '~/ide/components/ide_repo_tree.vue';
-import { file, resetStore } from '../helpers';
-
-describe('IdeRepoTree', () => {
- let vm;
-
- beforeEach(() => {
- const IdeRepoTree = Vue.extend(ideRepoTree);
-
- vm = new IdeRepoTree({
- store,
- propsData: {
- treeId: 'abcproject/mybranch',
- },
- });
-
- vm.$store.state.currentBranch = 'master';
- vm.$store.state.isRoot = true;
- vm.$store.state.trees['abcproject/mybranch'] = {
- tree: [file()],
- };
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders a sidebar', () => {
- const tbody = vm.$el.querySelector('tbody');
-
- expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy();
- 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('renders 5 loading files if tree is loading', (done) => {
- vm.$store.state.loading = true;
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5);
-
- done();
- });
- });
-
- it('renders a prev directory if is not root', (done) => {
- vm.$store.state.isRoot = false;
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/repo/components/ide_side_bar_spec.js b/spec/javascripts/repo/components/ide_side_bar_spec.js
deleted file mode 100644
index 30e45169205..00000000000
--- a/spec/javascripts/repo/components/ide_side_bar_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import ideSidebar from '~/ide/components/ide_side_bar.vue';
-import { resetStore } from '../helpers';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-
-describe('IdeSidebar', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(ideSidebar);
-
- vm = createComponentWithStore(Component, store).$mount();
-
- vm.$store.state.leftPanelCollapsed = false;
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders a sidebar', () => {
- expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
- });
-
- describe('collapsed', () => {
- beforeEach((done) => {
- vm.$store.state.leftPanelCollapsed = true;
-
- Vue.nextTick(done);
- });
-
- it('adds collapsed class', () => {
- expect(vm.$el.classList).toContain('is-collapsed');
- });
-
- it('shows correct icon', () => {
- expect(vm.currentIcon).toBe('angle-double-right');
- });
- });
-});
diff --git a/spec/javascripts/repo/components/ide_spec.js b/spec/javascripts/repo/components/ide_spec.js
deleted file mode 100644
index 20b8dc25dcb..00000000000
--- a/spec/javascripts/repo/components/ide_spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import ide from '~/ide/components/ide.vue';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { file, resetStore } from '../helpers';
-
-describe('ide component', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(ide);
-
- vm = createComponentWithStore(Component, store).$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('does not render panel right when no files open', () => {
- expect(vm.$el.querySelector('.panel-right')).toBeNull();
- });
-
- it('renders panel right when files are open', (done) => {
- vm.$store.state.trees['abcproject/mybranch'] = {
- tree: [file()],
- };
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.panel-right')).toBeNull();
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/repo/components/new_branch_form_spec.js b/spec/javascripts/repo/components/new_branch_form_spec.js
deleted file mode 100644
index cd1d073ec18..00000000000
--- a/spec/javascripts/repo/components/new_branch_form_spec.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import newBranchForm from '~/ide/components/new_branch_form.vue';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
-import { resetStore } from '../helpers';
-
-describe('Multi-file editor new branch form', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(newBranchForm);
-
- vm = createComponentWithStore(Component, store);
-
- vm.$store.state.currentBranch = 'master';
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- describe('template', () => {
- it('renders submit as disabled', () => {
- expect(vm.$el.querySelector('.btn').getAttribute('disabled')).toBe('disabled');
- });
-
- it('enables the submit button when branch is not empty', (done) => {
- vm.branchName = 'testing';
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn').getAttribute('disabled')).toBeNull();
-
- done();
- });
- });
-
- it('displays current branch creating from', (done) => {
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('p').textContent.replace(/\s+/g, ' ').trim()).toBe('Create from: master');
-
- done();
- });
- });
- });
-
- describe('submitNewBranch', () => {
- beforeEach(() => {
- spyOn(vm, 'createNewBranch').and.returnValue(Promise.resolve());
- });
-
- it('sets to loading', () => {
- vm.submitNewBranch();
-
- expect(vm.loading).toBeTruthy();
- });
-
- it('hides current flash element', (done) => {
- vm.$refs.flashContainer.innerHTML = '<div class="flash-alert"></div>';
-
- vm.submitNewBranch();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.flash-alert')).toBeNull();
-
- done();
- });
- });
-
- it('calls createdNewBranch with branchName', () => {
- vm.branchName = 'testing';
-
- vm.submitNewBranch();
-
- expect(vm.createNewBranch).toHaveBeenCalledWith('testing');
- });
- });
-
- describe('submitNewBranch with error', () => {
- beforeEach(() => {
- spyOn(vm, 'createNewBranch').and.returnValue(Promise.reject({
- json: () => Promise.resolve({
- message: 'error message',
- }),
- }));
- });
-
- it('sets loading to false', (done) => {
- vm.loading = true;
-
- vm.submitNewBranch();
-
- setTimeout(() => {
- expect(vm.loading).toBeFalsy();
-
- done();
- });
- });
-
- it('creates flash element', (done) => {
- vm.submitNewBranch();
-
- setTimeout(() => {
- expect(vm.$el.querySelector('.flash-alert')).not.toBeNull();
- expect(vm.$el.querySelector('.flash-alert').textContent.trim()).toBe('error message');
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/repo/components/new_dropdown/index_spec.js b/spec/javascripts/repo/components/new_dropdown/index_spec.js
deleted file mode 100644
index b001c1655b4..00000000000
--- a/spec/javascripts/repo/components/new_dropdown/index_spec.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import newDropdown from '~/ide/components/new_dropdown/index.vue';
-import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
-import { resetStore } from '../../helpers';
-
-describe('new dropdown component', () => {
- let vm;
-
- beforeEach(() => {
- const component = Vue.extend(newDropdown);
-
- vm = createComponentWithStore(component, store, {
- branch: 'master',
- path: '',
- });
-
- vm.$store.state.currentProjectId = 'abcproject';
- vm.$store.state.path = '';
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders new file, upload and new directory links', () => {
- expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file');
- expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe('Upload file');
- expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe('New directory');
- });
-
- describe('createNewItem', () => {
- it('sets modalType to blob when new file is clicked', () => {
- vm.$el.querySelectorAll('a')[0].click();
-
- expect(vm.modalType).toBe('blob');
- });
-
- it('sets modalType to tree when new directory is clicked', () => {
- vm.$el.querySelectorAll('a')[2].click();
-
- expect(vm.modalType).toBe('tree');
- });
-
- it('opens modal when link is clicked', (done) => {
- vm.$el.querySelectorAll('a')[0].click();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.modal')).not.toBeNull();
-
- done();
- });
- });
- });
-
- describe('toggleModalOpen', () => {
- it('closes modal after toggling', (done) => {
- vm.toggleModalOpen();
-
- Vue.nextTick()
- .then(() => {
- expect(vm.$el.querySelector('.modal')).not.toBeNull();
- })
- .then(vm.toggleModalOpen)
- .then(() => {
- expect(vm.$el.querySelector('.modal')).toBeNull();
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/repo/components/new_dropdown/modal_spec.js b/spec/javascripts/repo/components/new_dropdown/modal_spec.js
deleted file mode 100644
index 233cca06ed0..00000000000
--- a/spec/javascripts/repo/components/new_dropdown/modal_spec.js
+++ /dev/null
@@ -1,235 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import service from '~/ide/services';
-import modal from '~/ide/components/new_dropdown/modal.vue';
-import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
-import { file, resetStore } from '../../helpers';
-
-describe('new file modal component', () => {
- const Component = Vue.extend(modal);
- let vm;
- let projectTree;
-
- beforeEach(() => {
- spyOn(service, 'getProjectData').and.returnValue(Promise.resolve({
- data: {
- id: '123',
- },
- }));
-
- spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
- commit: {
- id: '123branch',
- },
- }));
-
- spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({
- headers: {
- 'page-title': 'test',
- },
- json: () => Promise.resolve({
- last_commit_path: 'last_commit_path',
- parent_tree_url: 'parent_tree_url',
- path: '/',
- trees: [{ name: 'tree' }],
- blobs: [{ name: 'blob' }],
- submodules: [{ name: 'submodule' }],
- }),
- }));
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- ['tree', 'blob'].forEach((type) => {
- describe(type, () => {
- beforeEach(() => {
- store.state.projects.abcproject = {
- web_url: '',
- };
- store.state.trees = [];
- store.state.trees['abcproject/mybranch'] = {
- tree: [],
- };
- projectTree = store.state.trees['abcproject/mybranch'];
- store.state.currentProjectId = 'abcproject';
-
- vm = createComponentWithStore(Component, store, {
- type,
- branchId: 'master',
- path: '',
- parent: projectTree,
- });
-
- vm.entryName = 'testing';
-
- vm.$mount();
- });
-
- it(`sets modal title as ${type}`, () => {
- const title = type === 'tree' ? 'directory' : 'file';
-
- expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`);
- });
-
- it(`sets button label as ${type}`, () => {
- const title = type === 'tree' ? 'directory' : 'file';
-
- expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`);
- });
-
- it(`sets form label as ${type}`, () => {
- const title = type === 'tree' ? 'Directory' : 'File';
-
- expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`);
- });
-
- describe('createEntryInStore', () => {
- it('calls createTempEntry', () => {
- spyOn(vm, 'createTempEntry');
-
- vm.createEntryInStore();
-
- expect(vm.createTempEntry).toHaveBeenCalledWith({
- projectId: 'abcproject',
- branchId: 'master',
- parent: projectTree,
- name: 'testing',
- type,
- });
- });
-
- it('sets editMode to true', (done) => {
- vm.createEntryInStore();
-
- setTimeout(() => {
- expect(vm.$store.state.editMode).toBeTruthy();
-
- done();
- });
- });
-
- it('toggles blob view', (done) => {
- vm.createEntryInStore();
-
- setTimeout(() => {
- expect(vm.$store.state.currentBlobView).toBe('repo-editor');
-
- done();
- });
- });
-
- it('opens newly created file', (done) => {
- if (type === 'blob') {
- vm.createEntryInStore();
-
- setTimeout(() => {
- expect(vm.$store.state.openFiles.length).toBe(1);
- expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep');
-
- done();
- });
- } else {
- done();
- }
- });
-
- if (type === 'blob') {
- it('creates new file', (done) => {
- vm.createEntryInStore();
-
- setTimeout(() => {
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe('testing');
- expect(baseTree[0].type).toBe('blob');
- expect(baseTree[0].tempFile).toBeTruthy();
-
- done();
- });
- });
-
- it('does not create temp file when file already exists', (done) => {
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- baseTree.push(file('testing', '1', type));
-
- vm.createEntryInStore();
-
- setTimeout(() => {
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe('testing');
- expect(baseTree[0].type).toBe('blob');
- expect(baseTree[0].tempFile).toBeFalsy();
-
- done();
- });
- });
- } else {
- it('creates new tree', () => {
- vm.createEntryInStore();
-
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe('testing');
- expect(baseTree[0].type).toBe('tree');
- expect(baseTree[0].tempFile).toBeTruthy();
- });
-
- it('creates multiple trees when entryName has slashes', () => {
- vm.entryName = 'app/test';
- vm.createEntryInStore();
-
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe('app');
- });
-
- it('creates tree in existing tree', () => {
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- baseTree.push(file('app', '1', 'tree'));
-
- vm.entryName = 'app/test';
- vm.createEntryInStore();
-
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe('app');
- expect(baseTree[0].tempFile).toBeFalsy();
- expect(baseTree[0].tree[0].tempFile).toBeTruthy();
- expect(baseTree[0].tree[0].name).toBe('test');
- });
-
- it('does not create new tree when already exists', () => {
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- baseTree.push(file('app', '1', 'tree'));
-
- vm.entryName = 'app';
- vm.createEntryInStore();
-
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe('app');
- expect(baseTree[0].tempFile).toBeFalsy();
- expect(baseTree[0].tree.length).toBe(0);
- });
- }
- });
- });
- });
-
- it('focuses field on mount', () => {
- document.body.innerHTML += '<div class="js-test"></div>';
-
- vm = createComponentWithStore(Component, store, {
- type: 'tree',
- projectId: 'abcproject',
- branchId: 'master',
- path: '',
- }).$mount('.js-test');
-
- expect(document.activeElement).toBe(vm.$refs.fieldName);
-
- vm.$el.remove();
- });
-});
diff --git a/spec/javascripts/repo/components/new_dropdown/upload_spec.js b/spec/javascripts/repo/components/new_dropdown/upload_spec.js
deleted file mode 100644
index 788c08e5279..00000000000
--- a/spec/javascripts/repo/components/new_dropdown/upload_spec.js
+++ /dev/null
@@ -1,156 +0,0 @@
-import Vue from 'vue';
-import upload from '~/ide/components/new_dropdown/upload.vue';
-import store from '~/ide/stores';
-import service from '~/ide/services';
-import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
-import { resetStore } from '../../helpers';
-
-describe('new dropdown upload', () => {
- let vm;
- let projectTree;
-
- beforeEach(() => {
- spyOn(service, 'getProjectData').and.returnValue(Promise.resolve({
- data: {
- id: '123',
- },
- }));
-
- spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
- commit: {
- id: '123branch',
- },
- }));
-
- spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({
- headers: {
- 'page-title': 'test',
- },
- json: () => Promise.resolve({
- last_commit_path: 'last_commit_path',
- parent_tree_url: 'parent_tree_url',
- path: '/',
- trees: [{ name: 'tree' }],
- blobs: [{ name: 'blob' }],
- submodules: [{ name: 'submodule' }],
- }),
- }));
-
- const Component = Vue.extend(upload);
-
- store.state.projects.abcproject = {
- web_url: '',
- };
- store.state.currentProjectId = 'abcproject';
- store.state.trees = [];
- store.state.trees['abcproject/mybranch'] = {
- tree: [],
- };
- projectTree = store.state.trees['abcproject/mybranch'];
-
- vm = createComponentWithStore(Component, store, {
- branchId: 'master',
- path: '',
- parent: projectTree,
- });
-
- vm.entryName = 'testing';
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- describe('readFile', () => {
- beforeEach(() => {
- spyOn(FileReader.prototype, 'readAsText');
- spyOn(FileReader.prototype, 'readAsDataURL');
- });
-
- it('calls readAsText for text files', () => {
- const file = {
- type: 'text/html',
- };
-
- vm.readFile(file);
-
- expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file);
- });
-
- it('calls readAsDataURL for non-text files', () => {
- const file = {
- type: 'images/png',
- };
-
- vm.readFile(file);
-
- expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
- });
- });
-
- describe('createFile', () => {
- const target = {
- result: 'content',
- };
- const binaryTarget = {
- result: 'base64,base64content',
- };
- const file = {
- name: 'file',
- };
-
- it('creates new file', (done) => {
- vm.createFile(target, file, true);
-
- vm.$nextTick(() => {
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe(file.name);
- expect(baseTree[0].content).toBe(target.result);
-
- done();
- });
- });
-
- it('creates new file in path', (done) => {
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- const tree = {
- type: 'tree',
- name: 'testing',
- path: 'testing',
- tree: [],
- };
- baseTree.push(tree);
-
- vm.parent = tree;
- vm.createFile(target, file, true);
-
- vm.$nextTick(() => {
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].tree[0].name).toBe(file.name);
- expect(baseTree[0].tree[0].content).toBe(target.result);
- expect(baseTree[0].tree[0].path).toBe(`testing/${file.name}`);
-
- done();
- });
- });
-
- it('splits content on base64 if binary', (done) => {
- vm.createFile(binaryTarget, file, false);
-
- vm.$nextTick(() => {
- const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree;
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].name).toBe(file.name);
- expect(baseTree[0].content).toBe(binaryTarget.result.split('base64,')[1]);
- expect(baseTree[0].base64).toBe(true);
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js
deleted file mode 100644
index cd93fb3ccbf..00000000000
--- a/spec/javascripts/repo/components/repo_commit_section_spec.js
+++ /dev/null
@@ -1,138 +0,0 @@
-import Vue from 'vue';
-import * as urlUtils from '~/lib/utils/url_utility';
-import store from '~/ide/stores';
-import service from '~/ide/services';
-import repoCommitSection from '~/ide/components/repo_commit_section.vue';
-import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper';
-import { file, resetStore } from '../helpers';
-
-describe('RepoCommitSection', () => {
- let vm;
-
- function createComponent() {
- const RepoCommitSection = Vue.extend(repoCommitSection);
-
- const comp = new RepoCommitSection({
- store,
- }).$mount();
-
- comp.$store.state.currentProjectId = 'abcproject';
- comp.$store.state.currentBranchId = 'master';
- comp.$store.state.projects.abcproject = {
- web_url: '',
- branches: {
- master: {
- workingReference: '1',
- },
- },
- };
-
- comp.$store.state.rightPanelCollapsed = false;
- comp.$store.state.currentBranch = 'master';
- comp.$store.state.openFiles = [file(), file()];
- comp.$store.state.openFiles.forEach(f => Object.assign(f, {
- changed: true,
- content: 'testing',
- }));
-
- return comp.$mount();
- }
-
- beforeEach((done) => {
- vm = createComponent();
-
- spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({
- headers: {
- 'page-title': 'test',
- },
- json: () => Promise.resolve({
- last_commit_path: 'last_commit_path',
- parent_tree_url: 'parent_tree_url',
- path: '/',
- trees: [{ name: 'tree' }],
- blobs: [{ name: 'blob' }],
- submodules: [{ name: 'submodule' }],
- }),
- }));
-
- Vue.nextTick(done);
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders a commit section', () => {
- const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')];
- const submitCommit = vm.$el.querySelector('form .btn');
-
- expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
- expect(changedFileElements.length).toEqual(2);
-
- changedFileElements.forEach((changedFile, i) => {
- expect(changedFile.textContent.trim()).toEqual(vm.$store.getters.changedFiles[i].path);
- });
-
- expect(submitCommit.disabled).toBeTruthy();
- expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
- });
-
- describe('when submitting', () => {
- let changedFiles;
-
- beforeEach(() => {
- vm.commitMessage = 'testing';
- changedFiles = JSON.parse(JSON.stringify(vm.$store.getters.changedFiles));
-
- spyOn(service, 'commit').and.returnValue(Promise.resolve({
- short_id: '1',
- stats: {},
- }));
- });
-
- it('allows you to submit', () => {
- expect(vm.$el.querySelector('form .btn').disabled).toBeTruthy();
- });
-
- it('submits commit', (done) => {
- vm.makeCommit();
-
- // Wait for the branch check to finish
- getSetTimeoutPromise()
- .then(() => Vue.nextTick())
- .then(() => {
- const args = service.commit.calls.allArgs()[0];
- const { commit_message, actions, branch: payloadBranch } = args[1];
-
- expect(commit_message).toBe('testing');
- expect(actions.length).toEqual(2);
- expect(payloadBranch).toEqual('master');
- expect(actions[0].action).toEqual('update');
- expect(actions[1].action).toEqual('update');
- expect(actions[0].content).toEqual(changedFiles[0].content);
- expect(actions[1].content).toEqual(changedFiles[1].content);
- expect(actions[0].file_path).toEqual(changedFiles[0].path);
- expect(actions[1].file_path).toEqual(changedFiles[1].path);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('redirects to MR creation page if start new MR checkbox checked', (done) => {
- spyOn(urlUtils, 'visitUrl');
- vm.startNewMR = true;
-
- vm.makeCommit();
-
- getSetTimeoutPromise()
- .then(() => Vue.nextTick())
- .then(() => {
- expect(urlUtils.visitUrl).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js
deleted file mode 100644
index 2895b794506..00000000000
--- a/spec/javascripts/repo/components/repo_edit_button_spec.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoEditButton from '~/ide/components/repo_edit_button.vue';
-import { file, resetStore } from '../helpers';
-
-describe('RepoEditButton', () => {
- let vm;
-
- beforeEach(() => {
- const f = file();
- const RepoEditButton = Vue.extend(repoEditButton);
-
- vm = new RepoEditButton({
- store,
- });
-
- f.active = true;
- vm.$store.dispatch('setInitialData', {
- canCommit: true,
- onTopOfBranch: true,
- });
- vm.$store.state.openFiles.push(f);
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders an edit button', () => {
- vm.$mount();
-
- expect(vm.$el.querySelector('.btn')).not.toBeNull();
- expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
- });
-
- it('renders edit button with cancel text', () => {
- vm.$store.state.editMode = true;
-
- vm.$mount();
-
- expect(vm.$el.querySelector('.btn')).not.toBeNull();
- expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit');
- });
-
- it('toggles edit mode on click', (done) => {
- vm.$mount();
-
- vm.$el.querySelector('.btn').click();
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit');
-
- done();
- });
- });
-
- describe('discardPopupOpen', () => {
- beforeEach(() => {
- vm.$store.state.discardPopupOpen = true;
- vm.$store.state.editMode = true;
- vm.$store.state.openFiles[0].changed = true;
-
- vm.$mount();
- });
-
- it('renders popup', () => {
- expect(vm.$el.querySelector('.modal')).not.toBeNull();
- });
-
- it('removes all changed files', (done) => {
- vm.$el.querySelector('.btn-warning').click();
-
- vm.$nextTick(() => {
- expect(vm.$store.getters.changedFiles.length).toBe(0);
- expect(vm.$el.querySelector('.modal')).toBeNull();
-
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js
deleted file mode 100644
index e7b2ed08acd..00000000000
--- a/spec/javascripts/repo/components/repo_editor_spec.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoEditor from '~/ide/components/repo_editor.vue';
-import monacoLoader from '~/ide/monaco_loader';
-import { file, resetStore } from '../helpers';
-
-describe('RepoEditor', () => {
- let vm;
-
- beforeEach((done) => {
- const f = file();
- const RepoEditor = Vue.extend(repoEditor);
-
- vm = new RepoEditor({
- store,
- });
-
- f.active = true;
- f.tempFile = true;
- vm.$store.state.openFiles.push(f);
- vm.$store.getters.activeFile.html = 'testing';
- vm.monaco = true;
-
- vm.$mount();
-
- monacoLoader(['vs/editor/editor.main'], () => {
- setTimeout(done, 0);
- });
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders an ide container', (done) => {
- Vue.nextTick(() => {
- expect(vm.shouldHideEditor).toBeFalsy();
-
- done();
- });
- });
-
- describe('when open file is binary and not raw', () => {
- beforeEach((done) => {
- vm.$store.getters.activeFile.binary = true;
-
- Vue.nextTick(done);
- });
-
- it('does not render the IDE', () => {
- expect(vm.shouldHideEditor).toBeTruthy();
- });
-
- it('shows activeFile html', () => {
- expect(vm.$el.textContent).toContain('testing');
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js
deleted file mode 100644
index 115569a9117..00000000000
--- a/spec/javascripts/repo/components/repo_file_buttons_spec.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoFileButtons from '~/ide/components/repo_file_buttons.vue';
-import { file, resetStore } from '../helpers';
-
-describe('RepoFileButtons', () => {
- const activeFile = file();
- let vm;
-
- function createComponent() {
- const RepoFileButtons = Vue.extend(repoFileButtons);
-
- activeFile.rawPath = 'test';
- activeFile.blamePath = 'test';
- activeFile.commitsPath = 'test';
- activeFile.active = true;
- store.state.openFiles.push(activeFile);
-
- return new RepoFileButtons({
- store,
- }).$mount();
- }
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => {
- vm = createComponent();
-
- vm.$nextTick(() => {
- const raw = vm.$el.querySelector('.raw');
- const blame = vm.$el.querySelector('.blame');
- const history = vm.$el.querySelector('.history');
-
- expect(raw.href).toMatch(`/${activeFile.rawPath}`);
- expect(raw.textContent.trim()).toEqual('Raw');
- expect(blame.href).toMatch(`/${activeFile.blamePath}`);
- expect(blame.textContent.trim()).toEqual('Blame');
- expect(history.href).toMatch(`/${activeFile.commitsPath}`);
- expect(history.textContent.trim()).toEqual('History');
- expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink');
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js
deleted file mode 100644
index e8b370f97b4..00000000000
--- a/spec/javascripts/repo/components/repo_file_spec.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoFile from '~/ide/components/repo_file.vue';
-import { file, resetStore } from '../helpers';
-
-describe('RepoFile', () => {
- const updated = 'updated';
- let vm;
-
- function createComponent(propsData) {
- const RepoFile = Vue.extend(repoFile);
-
- return new RepoFile({
- store,
- propsData,
- }).$mount();
- }
-
- afterEach(() => {
- resetStore(vm.$store);
- });
-
- it('renders link, icon and name', () => {
- const RepoFile = Vue.extend(repoFile);
- vm = new RepoFile({
- store,
- propsData: {
- file: file(),
- },
- });
- spyOn(vm, 'timeFormated').and.returnValue(updated);
- vm.$mount();
-
- const name = vm.$el.querySelector('.repo-file-name');
- const fileIcon = vm.$el.querySelector('.file-icon');
-
- expect(vm.$el.querySelector(`.${vm.file.icon}`).style.marginLeft).toEqual('0px');
- expect(name.href).toMatch('');
- expect(name.textContent.trim()).toEqual(vm.file.name);
- expect(fileIcon.classList.contains(vm.file.icon)).toBeTruthy();
- expect(fileIcon.style.marginLeft).toEqual(`${vm.file.level * 10}px`);
- });
-
- it('does render if hasFiles is true and is loading tree', () => {
- vm = createComponent({
- file: file(),
- });
-
- expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy();
- });
-
- it('renders a spinner if the file is loading', () => {
- const f = file();
- f.loading = true;
- vm = createComponent({
- file: f,
- });
-
- expect(vm.$el.querySelector('.fa-spin.fa-spinner')).not.toBeNull();
- expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`);
- });
-
- it('does not render commit message and datetime if mini', (done) => {
- vm = createComponent({
- file: file(),
- });
- vm.$store.state.openFiles.push(vm.file);
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
- expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
-
- done();
- });
- });
-
- it('fires clickFile when the link is clicked', () => {
- vm = createComponent({
- file: file(),
- });
-
- spyOn(vm, 'clickFile');
-
- vm.$el.click();
-
- expect(vm.clickFile).toHaveBeenCalledWith(vm.file);
- });
-
- describe('submodule', () => {
- let f;
-
- beforeEach(() => {
- f = file('submodule name', '123456789');
- f.type = 'submodule';
-
- vm = createComponent({
- file: f,
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders submodule short ID', () => {
- expect(vm.$el.querySelector('.commit-sha').textContent.trim()).toBe('12345678');
- });
-
- it('renders ID next to submodule name', () => {
- expect(vm.$el.querySelector('td').textContent.replace(/\s+/g, ' ')).toContain('submodule name @ 12345678');
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js
deleted file mode 100644
index 18366fb89bc..00000000000
--- a/spec/javascripts/repo/components/repo_loading_file_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoLoadingFile from '~/ide/components/repo_loading_file.vue';
-import { resetStore } from '../helpers';
-
-describe('RepoLoadingFile', () => {
- let vm;
-
- function createComponent() {
- const RepoLoadingFile = Vue.extend(repoLoadingFile);
-
- return new RepoLoadingFile({
- store,
- }).$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);
- });
- }
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders 3 columns of animated LoC', () => {
- vm = createComponent();
- const columns = [...vm.$el.querySelectorAll('td')];
-
- expect(columns.length).toEqual(3);
- assertColumns(columns);
- });
-
- it('renders 1 column of animated LoC if isMini', (done) => {
- vm = createComponent();
- vm.$store.state.leftPanelCollapsed = true;
- vm.$store.state.openFiles.push('test');
-
- vm.$nextTick(() => {
- const columns = [...vm.$el.querySelectorAll('td')];
-
- expect(columns.length).toEqual(1);
- assertColumns(columns);
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js
deleted file mode 100644
index ff26cab2262..00000000000
--- a/spec/javascripts/repo/components/repo_prev_directory_spec.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoPrevDirectory from '~/ide/components/repo_prev_directory.vue';
-import { resetStore } from '../helpers';
-
-describe('RepoPrevDirectory', () => {
- let vm;
- const parentLink = 'parent';
- function createComponent() {
- const RepoPrevDirectory = Vue.extend(repoPrevDirectory);
-
- const comp = new RepoPrevDirectory({
- store,
- });
-
- comp.$store.state.parentTreeUrl = parentLink;
-
- return comp.$mount();
- }
-
- beforeEach(() => {
- vm = createComponent();
- });
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders a prev dir link', () => {
- const link = vm.$el.querySelector('a');
-
- expect(link.href).toMatch(`/${parentLink}`);
- expect(link.textContent).toEqual('...');
- });
-
- it('clicking row triggers getTreeData', () => {
- spyOn(vm, 'getTreeData');
-
- vm.$el.querySelector('td').click();
-
- expect(vm.getTreeData).toHaveBeenCalledWith({ endpoint: parentLink });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_preview_spec.js b/spec/javascripts/repo/components/repo_preview_spec.js
deleted file mode 100644
index e90837e4cb2..00000000000
--- a/spec/javascripts/repo/components/repo_preview_spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoPreview from '~/ide/components/repo_preview.vue';
-import { file, resetStore } from '../helpers';
-
-describe('RepoPreview', () => {
- let vm;
-
- function createComponent() {
- const f = file();
- const RepoPreview = Vue.extend(repoPreview);
-
- const comp = new RepoPreview({
- store,
- });
-
- f.active = true;
- f.html = 'test';
-
- comp.$store.state.openFiles.push(f);
-
- return comp.$mount();
- }
-
- afterEach(() => {
- vm.$destroy();
-
- resetStore(vm.$store);
- });
-
- it('renders a div with the activeFile html', () => {
- vm = createComponent();
-
- expect(vm.$el.tagName).toEqual('DIV');
- expect(vm.$el.innerHTML).toContain('test');
- });
-});
diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js
deleted file mode 100644
index 507bca983df..00000000000
--- a/spec/javascripts/repo/components/repo_tab_spec.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoTab from '~/ide/components/repo_tab.vue';
-import { file, resetStore } from '../helpers';
-
-describe('RepoTab', () => {
- let vm;
-
- function createComponent(propsData) {
- const RepoTab = Vue.extend(repoTab);
-
- return new RepoTab({
- store,
- propsData,
- }).$mount();
- }
-
- afterEach(() => {
- resetStore(vm.$store);
- });
-
- it('renders a close link and a name link', () => {
- vm = createComponent({
- tab: file(),
- });
- vm.$store.state.openFiles.push(vm.tab);
- const close = vm.$el.querySelector('.multi-file-tab-close');
- const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
-
- expect(close.querySelector('.fa-times')).toBeTruthy();
- expect(name.textContent.trim()).toEqual(vm.tab.name);
- });
-
- it('fires clickFile when the link is clicked', () => {
- vm = createComponent({
- tab: file(),
- });
-
- spyOn(vm, 'clickFile');
-
- vm.$el.click();
-
- expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
- });
-
- it('calls closeFile when clicking close button', () => {
- vm = createComponent({
- tab: file(),
- });
-
- spyOn(vm, 'closeFile');
-
- vm.$el.querySelector('.multi-file-tab-close').click();
-
- expect(vm.closeFile).toHaveBeenCalledWith({ file: vm.tab });
- });
-
- it('renders an fa-circle icon if tab is changed', () => {
- const tab = file();
- tab.changed = true;
- vm = createComponent({
- tab,
- });
-
- expect(vm.$el.querySelector('.multi-file-tab-close .fa-circle')).not.toBeNull();
- });
-
- describe('methods', () => {
- describe('closeTab', () => {
- it('does not close tab if is changed', (done) => {
- const tab = file();
- tab.changed = true;
- tab.opened = true;
- vm = createComponent({
- tab,
- });
- vm.$store.state.openFiles.push(tab);
- vm.$store.dispatch('setFileActive', tab);
-
- vm.$el.querySelector('.multi-file-tab-close').click();
-
- vm.$nextTick(() => {
- expect(tab.opened).toBeTruthy();
-
- done();
- });
- });
-
- it('closes tab when clicking close btn', (done) => {
- const tab = file('lose');
- tab.opened = true;
- vm = createComponent({
- tab,
- });
- vm.$store.state.openFiles.push(tab);
- vm.$store.dispatch('setFileActive', tab);
-
- vm.$el.querySelector('.multi-file-tab-close').click();
-
- vm.$nextTick(() => {
- expect(tab.opened).toBeFalsy();
-
- done();
- });
- });
- });
- });
-});
diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js
deleted file mode 100644
index 0beaf643793..00000000000
--- a/spec/javascripts/repo/components/repo_tabs_spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import repoTabs from '~/ide/components/repo_tabs.vue';
-import { file, resetStore } from '../helpers';
-
-describe('RepoTabs', () => {
- const openedFiles = [file(), file()];
- let vm;
-
- function createComponent() {
- const RepoTabs = Vue.extend(repoTabs);
-
- return new RepoTabs({
- store,
- }).$mount();
- }
-
- afterEach(() => {
- resetStore(vm.$store);
- });
-
- it('renders a list of tabs', (done) => {
- vm = createComponent();
- openedFiles[0].active = true;
- vm.$store.state.openFiles = openedFiles;
-
- vm.$nextTick(() => {
- const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
-
- expect(tabs.length).toEqual(2);
- expect(tabs[0].classList.contains('active')).toBeTruthy();
- expect(tabs[1].classList.contains('active')).toBeFalsy();
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/repo/helpers.js b/spec/javascripts/repo/helpers.js
deleted file mode 100644
index ac43d221198..00000000000
--- a/spec/javascripts/repo/helpers.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { decorateData } from '~/ide/stores/utils';
-import state from '~/ide/stores/state';
-
-export const resetStore = (store) => {
- store.replaceState(state());
-};
-
-export const file = (name = 'name', id = name, type = '') => decorateData({
- id,
- type,
- icon: 'icon',
- url: 'url',
- name,
- path: name,
- lastCommit: {},
-});
diff --git a/spec/javascripts/repo/lib/common/model_manager_spec.js b/spec/javascripts/repo/lib/common/model_manager_spec.js
deleted file mode 100644
index 563c2e33834..00000000000
--- a/spec/javascripts/repo/lib/common/model_manager_spec.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/* global monaco */
-import monacoLoader from '~/ide/monaco_loader';
-import ModelManager from '~/ide/lib/common/model_manager';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library model manager', () => {
- let instance;
-
- beforeEach((done) => {
- monacoLoader(['vs/editor/editor.main'], () => {
- instance = new ModelManager(monaco);
-
- done();
- });
- });
-
- afterEach(() => {
- instance.dispose();
- });
-
- describe('addModel', () => {
- it('caches model', () => {
- instance.addModel(file());
-
- expect(instance.models.size).toBe(1);
- });
-
- it('caches model by file path', () => {
- instance.addModel(file('path-name'));
-
- expect(instance.models.keys().next().value).toBe('path-name');
- });
-
- it('adds model into disposable', () => {
- spyOn(instance.disposable, 'add').and.callThrough();
-
- instance.addModel(file());
-
- expect(instance.disposable.add).toHaveBeenCalled();
- });
-
- it('returns cached model', () => {
- spyOn(instance.models, 'get').and.callThrough();
-
- instance.addModel(file());
- instance.addModel(file());
-
- expect(instance.models.get).toHaveBeenCalled();
- });
- });
-
- describe('hasCachedModel', () => {
- it('returns false when no models exist', () => {
- expect(instance.hasCachedModel('path')).toBeFalsy();
- });
-
- it('returns true when model exists', () => {
- instance.addModel(file('path-name'));
-
- expect(instance.hasCachedModel('path-name')).toBeTruthy();
- });
- });
-
- describe('dispose', () => {
- it('clears cached models', () => {
- instance.addModel(file());
-
- instance.dispose();
-
- expect(instance.models.size).toBe(0);
- });
-
- it('calls disposable dispose', () => {
- spyOn(instance.disposable, 'dispose').and.callThrough();
-
- instance.dispose();
-
- expect(instance.disposable.dispose).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/javascripts/repo/lib/common/model_spec.js b/spec/javascripts/repo/lib/common/model_spec.js
deleted file mode 100644
index 878a4a3f3fe..00000000000
--- a/spec/javascripts/repo/lib/common/model_spec.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/* global monaco */
-import monacoLoader from '~/ide/monaco_loader';
-import Model from '~/ide/lib/common/model';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library model', () => {
- let model;
-
- beforeEach((done) => {
- monacoLoader(['vs/editor/editor.main'], () => {
- model = new Model(monaco, file('path'));
-
- done();
- });
- });
-
- afterEach(() => {
- model.dispose();
- });
-
- it('creates original model & new model', () => {
- expect(model.originalModel).not.toBeNull();
- expect(model.model).not.toBeNull();
- });
-
- describe('path', () => {
- it('returns file path', () => {
- expect(model.path).toBe('path');
- });
- });
-
- describe('getModel', () => {
- it('returns model', () => {
- expect(model.getModel()).toBe(model.model);
- });
- });
-
- describe('getOriginalModel', () => {
- it('returns original model', () => {
- expect(model.getOriginalModel()).toBe(model.originalModel);
- });
- });
-
- describe('onChange', () => {
- it('caches event by path', () => {
- model.onChange(() => {});
-
- expect(model.events.size).toBe(1);
- expect(model.events.keys().next().value).toBe('path');
- });
-
- it('calls callback on change', (done) => {
- const spy = jasmine.createSpy();
- model.onChange(spy);
-
- model.getModel().setValue('123');
-
- setTimeout(() => {
- expect(spy).toHaveBeenCalledWith(model.getModel(), jasmine.anything());
- done();
- });
- });
- });
-
- describe('dispose', () => {
- it('calls disposable dispose', () => {
- spyOn(model.disposable, 'dispose').and.callThrough();
-
- model.dispose();
-
- expect(model.disposable.dispose).toHaveBeenCalled();
- });
-
- it('clears events', () => {
- model.onChange(() => {});
-
- expect(model.events.size).toBe(1);
-
- model.dispose();
-
- expect(model.events.size).toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/repo/lib/decorations/controller_spec.js b/spec/javascripts/repo/lib/decorations/controller_spec.js
deleted file mode 100644
index fea12d74dca..00000000000
--- a/spec/javascripts/repo/lib/decorations/controller_spec.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/* global monaco */
-import monacoLoader from '~/ide/monaco_loader';
-import editor from '~/ide/lib/editor';
-import DecorationsController from '~/ide/lib/decorations/controller';
-import Model from '~/ide/lib/common/model';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library decorations controller', () => {
- let editorInstance;
- let controller;
- let model;
-
- beforeEach((done) => {
- monacoLoader(['vs/editor/editor.main'], () => {
- editorInstance = editor.create(monaco);
- editorInstance.createInstance(document.createElement('div'));
-
- controller = new DecorationsController(editorInstance);
- model = new Model(monaco, file('path'));
-
- done();
- });
- });
-
- afterEach(() => {
- model.dispose();
- editorInstance.dispose();
- controller.dispose();
- });
-
- describe('getAllDecorationsForModel', () => {
- it('returns empty array when no decorations exist for model', () => {
- const decorations = controller.getAllDecorationsForModel(model);
-
- expect(decorations).toEqual([]);
- });
-
- it('returns decorations by model URL', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- const decorations = controller.getAllDecorationsForModel(model);
-
- expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
- });
- });
-
- describe('addDecorations', () => {
- it('caches decorations in a new map', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- expect(controller.decorations.size).toBe(1);
- });
-
- it('does not create new cache model', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
-
- expect(controller.decorations.size).toBe(1);
- });
-
- it('caches decorations by model URL', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- expect(controller.decorations.size).toBe(1);
- expect(controller.decorations.keys().next().value).toBe('path');
- });
-
- it('calls decorate method', () => {
- spyOn(controller, 'decorate');
-
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- expect(controller.decorate).toHaveBeenCalled();
- });
- });
-
- describe('decorate', () => {
- it('sets decorations on editor instance', () => {
- spyOn(controller.editor.instance, 'deltaDecorations');
-
- controller.decorate(model);
-
- expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
- });
-
- it('caches decorations', () => {
- spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
-
- controller.decorate(model);
-
- expect(controller.editorDecorations.size).toBe(1);
- });
-
- it('caches decorations by model URL', () => {
- spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
-
- controller.decorate(model);
-
- expect(controller.editorDecorations.keys().next().value).toBe('path');
- });
- });
-
- describe('dispose', () => {
- it('clears cached decorations', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- controller.dispose();
-
- expect(controller.decorations.size).toBe(0);
- });
-
- it('clears cached editorDecorations', () => {
- controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
-
- controller.dispose();
-
- expect(controller.editorDecorations.size).toBe(0);
- });
- });
-});
diff --git a/spec/javascripts/repo/lib/diff/controller_spec.js b/spec/javascripts/repo/lib/diff/controller_spec.js
deleted file mode 100644
index 1d55c165260..00000000000
--- a/spec/javascripts/repo/lib/diff/controller_spec.js
+++ /dev/null
@@ -1,176 +0,0 @@
-/* global monaco */
-import monacoLoader from '~/ide/monaco_loader';
-import editor from '~/ide/lib/editor';
-import ModelManager from '~/ide/lib/common/model_manager';
-import DecorationsController from '~/ide/lib/decorations/controller';
-import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller';
-import { computeDiff } from '~/ide/lib/diff/diff';
-import { file } from '../../helpers';
-
-describe('Multi-file editor library dirty diff controller', () => {
- let editorInstance;
- let controller;
- let modelManager;
- let decorationsController;
- let model;
-
- beforeEach((done) => {
- monacoLoader(['vs/editor/editor.main'], () => {
- editorInstance = editor.create(monaco);
- editorInstance.createInstance(document.createElement('div'));
-
- modelManager = new ModelManager(monaco);
- decorationsController = new DecorationsController(editorInstance);
-
- model = modelManager.addModel(file());
-
- controller = new DirtyDiffController(modelManager, decorationsController);
-
- done();
- });
- });
-
- afterEach(() => {
- controller.dispose();
- model.dispose();
- decorationsController.dispose();
- editorInstance.dispose();
- });
-
- describe('getDiffChangeType', () => {
- ['added', 'removed', 'modified'].forEach((type) => {
- it(`returns ${type}`, () => {
- const change = {
- [type]: true,
- };
-
- expect(getDiffChangeType(change)).toBe(type);
- });
- });
- });
-
- describe('getDecorator', () => {
- ['added', 'removed', 'modified'].forEach((type) => {
- it(`returns with linesDecorationsClassName for ${type}`, () => {
- const change = {
- [type]: true,
- };
-
- expect(
- getDecorator(change).options.linesDecorationsClassName,
- ).toBe(`dirty-diff dirty-diff-${type}`);
- });
-
- it('returns with line numbers', () => {
- const change = {
- lineNumber: 1,
- endLineNumber: 2,
- [type]: true,
- };
-
- const range = getDecorator(change).range;
-
- expect(range.startLineNumber).toBe(1);
- expect(range.endLineNumber).toBe(2);
- expect(range.startColumn).toBe(1);
- expect(range.endColumn).toBe(1);
- });
- });
- });
-
- describe('attachModel', () => {
- it('adds change event callback', () => {
- spyOn(model, 'onChange');
-
- controller.attachModel(model);
-
- expect(model.onChange).toHaveBeenCalled();
- });
-
- it('calls throttledComputeDiff on change', () => {
- spyOn(controller, 'throttledComputeDiff');
-
- controller.attachModel(model);
-
- model.getModel().setValue('123');
-
- expect(controller.throttledComputeDiff).toHaveBeenCalled();
- });
- });
-
- describe('computeDiff', () => {
- it('posts to worker', () => {
- spyOn(controller.dirtyDiffWorker, 'postMessage');
-
- controller.computeDiff(model);
-
- expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
- path: model.path,
- originalContent: '',
- newContent: '',
- });
- });
- });
-
- describe('reDecorate', () => {
- it('calls decorations controller decorate', () => {
- spyOn(controller.decorationsController, 'decorate');
-
- controller.reDecorate(model);
-
- expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
- });
- });
-
- describe('decorate', () => {
- it('adds decorations into decorations controller', () => {
- spyOn(controller.decorationsController, 'addDecorations');
-
- controller.decorate({ data: { changes: [], path: 'path' } });
-
- expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith('path', 'dirtyDiff', jasmine.anything());
- });
-
- it('adds decorations into editor', () => {
- const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
-
- controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } });
-
- expect(spy).toHaveBeenCalledWith([], [{
- range: new monaco.Range(
- 1, 1, 1, 1,
- ),
- options: {
- isWholeLine: true,
- linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
- },
- }]);
- });
- });
-
- describe('dispose', () => {
- it('calls disposable dispose', () => {
- spyOn(controller.disposable, 'dispose').and.callThrough();
-
- controller.dispose();
-
- expect(controller.disposable.dispose).toHaveBeenCalled();
- });
-
- it('terminates worker', () => {
- spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough();
-
- controller.dispose();
-
- expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
- });
-
- it('removes worker event listener', () => {
- spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough();
-
- controller.dispose();
-
- expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything());
- });
- });
-});
diff --git a/spec/javascripts/repo/lib/editor_options_spec.js b/spec/javascripts/repo/lib/editor_options_spec.js
deleted file mode 100644
index edbf5450dce..00000000000
--- a/spec/javascripts/repo/lib/editor_options_spec.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import editorOptions from '~/ide/lib/editor_options';
-
-describe('Multi-file editor library editor options', () => {
- it('returns an array', () => {
- expect(editorOptions).toEqual(jasmine.any(Array));
- });
-});
diff --git a/spec/javascripts/repo/lib/editor_spec.js b/spec/javascripts/repo/lib/editor_spec.js
deleted file mode 100644
index 8d51d48a782..00000000000
--- a/spec/javascripts/repo/lib/editor_spec.js
+++ /dev/null
@@ -1,128 +0,0 @@
-/* global monaco */
-import monacoLoader from '~/ide/monaco_loader';
-import editor from '~/ide/lib/editor';
-import { file } from '../helpers';
-
-describe('Multi-file editor library', () => {
- let instance;
-
- beforeEach((done) => {
- monacoLoader(['vs/editor/editor.main'], () => {
- instance = editor.create(monaco);
-
- done();
- });
- });
-
- afterEach(() => {
- instance.dispose();
- });
-
- it('creates instance of editor', () => {
- expect(editor.editorInstance).not.toBeNull();
- });
-
- describe('createInstance', () => {
- let el;
-
- beforeEach(() => {
- el = document.createElement('div');
- });
-
- it('creates editor instance', () => {
- spyOn(instance.monaco.editor, 'create').and.callThrough();
-
- instance.createInstance(el);
-
- expect(instance.monaco.editor.create).toHaveBeenCalled();
- });
-
- it('creates dirty diff controller', () => {
- instance.createInstance(el);
-
- expect(instance.dirtyDiffController).not.toBeNull();
- });
- });
-
- describe('createModel', () => {
- it('calls model manager addModel', () => {
- spyOn(instance.modelManager, 'addModel');
-
- instance.createModel('FILE');
-
- expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE');
- });
- });
-
- describe('attachModel', () => {
- let model;
-
- beforeEach(() => {
- instance.createInstance(document.createElement('div'));
-
- model = instance.createModel(file());
- });
-
- it('sets the current model on the instance', () => {
- instance.attachModel(model);
-
- expect(instance.currentModel).toBe(model);
- });
-
- it('attaches the model to the current instance', () => {
- spyOn(instance.instance, 'setModel');
-
- instance.attachModel(model);
-
- expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
- });
-
- it('attaches the model to the dirty diff controller', () => {
- spyOn(instance.dirtyDiffController, 'attachModel');
-
- instance.attachModel(model);
-
- expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model);
- });
-
- it('re-decorates with the dirty diff controller', () => {
- spyOn(instance.dirtyDiffController, 'reDecorate');
-
- instance.attachModel(model);
-
- expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model);
- });
- });
-
- describe('clearEditor', () => {
- it('resets the editor model', () => {
- instance.createInstance(document.createElement('div'));
-
- spyOn(instance.instance, 'setModel');
-
- instance.clearEditor();
-
- expect(instance.instance.setModel).toHaveBeenCalledWith(null);
- });
- });
-
- describe('dispose', () => {
- it('calls disposble dispose method', () => {
- spyOn(instance.disposable, 'dispose').and.callThrough();
-
- instance.dispose();
-
- expect(instance.disposable.dispose).toHaveBeenCalled();
- });
-
- it('resets instance', () => {
- instance.createInstance(document.createElement('div'));
-
- expect(instance.instance).not.toBeNull();
-
- instance.dispose();
-
- expect(instance.instance).toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/repo/monaco_loader_spec.js b/spec/javascripts/repo/monaco_loader_spec.js
deleted file mode 100644
index b8ac36972aa..00000000000
--- a/spec/javascripts/repo/monaco_loader_spec.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import monacoContext from 'monaco-editor/dev/vs/loader';
-import monacoLoader from '~/ide/monaco_loader';
-
-describe('MonacoLoader', () => {
- it('calls require.config and exports require', () => {
- expect(monacoContext.require.getConfig()).toEqual(jasmine.objectContaining({
- paths: {
- vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
- },
- }));
- expect(monacoLoader).toBe(monacoContext.require);
- });
-});
diff --git a/spec/javascripts/repo/stores/actions/branch_spec.js b/spec/javascripts/repo/stores/actions/branch_spec.js
deleted file mode 100644
index 00d16fd790d..00000000000
--- a/spec/javascripts/repo/stores/actions/branch_spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import store from '~/ide/stores';
-import service from '~/ide/services';
-import { resetStore } from '../../helpers';
-
-describe('Multi-file store branch actions', () => {
- afterEach(() => {
- resetStore(store);
- });
-
- describe('createNewBranch', () => {
- beforeEach(() => {
- spyOn(service, 'createBranch').and.returnValue(Promise.resolve({
- json: () => ({
- name: 'testing',
- }),
- }));
- spyOn(history, 'pushState');
-
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'testing';
- store.state.projects.abcproject = {
- branches: {
- master: {
- workingReference: '1',
- },
- },
- };
- });
-
- it('creates new branch', (done) => {
- store.dispatch('createNewBranch', 'master')
- .then(() => {
- expect(store.state.currentBranchId).toBe('testing');
- expect(service.createBranch).toHaveBeenCalledWith('abcproject', {
- branch: 'master',
- ref: 'testing',
- });
-
- done();
- })
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/actions/file_spec.js b/spec/javascripts/repo/stores/actions/file_spec.js
deleted file mode 100644
index 8ce01d3bf12..00000000000
--- a/spec/javascripts/repo/stores/actions/file_spec.js
+++ /dev/null
@@ -1,424 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import service from '~/ide/services';
-import { file, resetStore } from '../../helpers';
-
-describe('Multi-file store file actions', () => {
- afterEach(() => {
- resetStore(store);
- });
-
- describe('closeFile', () => {
- let localFile;
- let getLastCommitDataSpy;
- let oldGetLastCommitData;
-
- beforeEach(() => {
- getLastCommitDataSpy = jasmine.createSpy('getLastCommitData');
- oldGetLastCommitData = store._actions.getLastCommitData; // eslint-disable-line
- store._actions.getLastCommitData = [getLastCommitDataSpy]; // eslint-disable-line
-
- localFile = file();
- localFile.active = true;
- localFile.opened = true;
- localFile.parentTreeUrl = 'parentTreeUrl';
-
- store.state.openFiles.push(localFile);
- });
-
- afterEach(() => {
- store._actions.getLastCommitData = oldGetLastCommitData; // eslint-disable-line
- });
-
- it('closes open files', (done) => {
- store.dispatch('closeFile', { file: localFile })
- .then(() => {
- expect(localFile.opened).toBeFalsy();
- expect(localFile.active).toBeFalsy();
- expect(store.state.openFiles.length).toBe(0);
-
- done();
- }).catch(done.fail);
- });
-
- it('does not close file if has changed', (done) => {
- localFile.changed = true;
-
- store.dispatch('closeFile', { file: localFile })
- .then(() => {
- expect(localFile.opened).toBeTruthy();
- expect(localFile.active).toBeTruthy();
- expect(store.state.openFiles.length).toBe(1);
-
- done();
- }).catch(done.fail);
- });
-
- it('does not close file if temp file', (done) => {
- localFile.tempFile = true;
-
- store.dispatch('closeFile', { file: localFile })
- .then(() => {
- expect(localFile.opened).toBeTruthy();
- expect(localFile.active).toBeTruthy();
- expect(store.state.openFiles.length).toBe(1);
-
- done();
- }).catch(done.fail);
- });
-
- it('force closes a changed file', (done) => {
- localFile.changed = true;
-
- store.dispatch('closeFile', { file: localFile, force: true })
- .then(() => {
- expect(localFile.opened).toBeFalsy();
- expect(localFile.active).toBeFalsy();
- expect(store.state.openFiles.length).toBe(0);
-
- done();
- }).catch(done.fail);
- });
-
- it('sets next file as active', (done) => {
- const f = file();
- store.state.openFiles.push(f);
-
- expect(f.active).toBeFalsy();
-
- store.dispatch('closeFile', { file: localFile })
- .then(() => {
- expect(f.active).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('calls getLastCommitData', (done) => {
- store.dispatch('closeFile', { file: localFile })
- .then(() => {
- expect(getLastCommitDataSpy).toHaveBeenCalled();
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('setFileActive', () => {
- let scrollToTabSpy;
- let oldScrollToTab;
-
- beforeEach(() => {
- scrollToTabSpy = jasmine.createSpy('scrollToTab');
- oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line
- store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line
- });
-
- afterEach(() => {
- store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line
- });
-
- it('calls scrollToTab', (done) => {
- store.dispatch('setFileActive', file())
- .then(() => {
- expect(scrollToTabSpy).toHaveBeenCalled();
-
- done();
- }).catch(done.fail);
- });
-
- it('sets the file active', (done) => {
- const localFile = file();
-
- store.dispatch('setFileActive', localFile)
- .then(() => {
- expect(localFile.active).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('returns early if file is already active', (done) => {
- const localFile = file();
- localFile.active = true;
-
- store.dispatch('setFileActive', localFile)
- .then(() => {
- expect(scrollToTabSpy).not.toHaveBeenCalled();
-
- done();
- }).catch(done.fail);
- });
-
- it('sets current active file to not active', (done) => {
- const localFile = file();
- localFile.active = true;
- store.state.openFiles.push(localFile);
-
- store.dispatch('setFileActive', file())
- .then(() => {
- expect(localFile.active).toBeFalsy();
-
- done();
- }).catch(done.fail);
- });
-
- it('resets location.hash for line highlighting', (done) => {
- location.hash = 'test';
-
- store.dispatch('setFileActive', file())
- .then(() => {
- expect(location.hash).not.toBe('test');
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('getFileData', () => {
- let localFile = file();
-
- beforeEach(() => {
- spyOn(service, 'getFileData').and.returnValue(Promise.resolve({
- headers: {
- 'page-title': 'testing getFileData',
- },
- json: () => Promise.resolve({
- blame_path: 'blame_path',
- commits_path: 'commits_path',
- permalink: 'permalink',
- raw_path: 'raw_path',
- binary: false,
- html: '123',
- render_error: '',
- }),
- }));
-
- localFile = file();
- localFile.url = 'getFileDataURL';
- });
-
- it('calls the service', (done) => {
- store.dispatch('getFileData', localFile)
- .then(() => {
- expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL');
-
- done();
- }).catch(done.fail);
- });
-
- it('sets the file data', (done) => {
- store.dispatch('getFileData', localFile)
- .then(Vue.nextTick)
- .then(() => {
- expect(localFile.blamePath).toBe('blame_path');
-
- done();
- }).catch(done.fail);
- });
-
- it('sets document title', (done) => {
- store.dispatch('getFileData', localFile)
- .then(() => {
- expect(document.title).toBe('testing getFileData');
-
- done();
- }).catch(done.fail);
- });
-
- it('sets the file as active', (done) => {
- store.dispatch('getFileData', localFile)
- .then(Vue.nextTick)
- .then(() => {
- expect(localFile.active).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('adds the file to open files', (done) => {
- store.dispatch('getFileData', localFile)
- .then(Vue.nextTick)
- .then(() => {
- expect(store.state.openFiles.length).toBe(1);
- expect(store.state.openFiles[0].name).toBe(localFile.name);
-
- done();
- }).catch(done.fail);
- });
-
- it('toggles the file loading', (done) => {
- store.dispatch('getFileData', localFile)
- .then(() => {
- expect(localFile.loading).toBeTruthy();
-
- return Vue.nextTick();
- })
- .then(() => {
- expect(localFile.loading).toBeFalsy();
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('getRawFileData', () => {
- let tmpFile;
-
- beforeEach(() => {
- spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw'));
-
- tmpFile = file();
- });
-
- it('calls getRawFileData service method', (done) => {
- store.dispatch('getRawFileData', tmpFile)
- .then(() => {
- expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile);
-
- done();
- }).catch(done.fail);
- });
-
- it('updates file raw data', (done) => {
- store.dispatch('getRawFileData', tmpFile)
- .then(() => {
- expect(tmpFile.raw).toBe('raw');
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('changeFileContent', () => {
- let tmpFile;
-
- beforeEach(() => {
- tmpFile = file();
- });
-
- it('updates file content', (done) => {
- store.dispatch('changeFileContent', {
- file: tmpFile,
- content: 'content',
- })
- .then(() => {
- expect(tmpFile.content).toBe('content');
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('createTempFile', () => {
- let projectTree;
-
- beforeEach(() => {
- document.body.innerHTML += '<div class="flash-container"></div>';
-
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = {
- branches: {
- master: {
- workingReference: '1',
- },
- },
- };
-
- store.state.trees['abcproject/mybranch'] = {
- tree: [],
- };
-
- projectTree = store.state.trees['abcproject/mybranch'];
- });
-
- afterEach(() => {
- document.querySelector('.flash-container').remove();
- });
-
- it('creates temp file', (done) => {
- store.dispatch('createTempFile', {
- name: 'test',
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- }).then((f) => {
- expect(f.tempFile).toBeTruthy();
- expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1);
-
- done();
- }).catch(done.fail);
- });
-
- it('adds tmp file to open files', (done) => {
- store.dispatch('createTempFile', {
- name: 'test',
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- }).then((f) => {
- expect(store.state.openFiles.length).toBe(1);
- expect(store.state.openFiles[0].name).toBe(f.name);
-
- done();
- }).catch(done.fail);
- });
-
- it('sets tmp file as active', (done) => {
- store.dispatch('createTempFile', {
- name: 'test',
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- }).then((f) => {
- expect(f.active).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('enters edit mode if file is not base64', (done) => {
- store.dispatch('createTempFile', {
- name: 'test',
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- }).then(() => {
- expect(store.state.editMode).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('creates flash message is file already exists', (done) => {
- store.state.trees['abcproject/mybranch'].tree.push(file('test', '1', 'blob'));
-
- store.dispatch('createTempFile', {
- name: 'test',
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- }).then(() => {
- expect(document.querySelector('.flash-alert')).not.toBeNull();
-
- done();
- }).catch(done.fail);
- });
-
- it('increases level of file', (done) => {
- store.state.trees['abcproject/mybranch'].level = 1;
-
- store.dispatch('createTempFile', {
- name: 'test',
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- }).then((f) => {
- expect(f.level).toBe(2);
-
- done();
- }).catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/actions/tree_spec.js b/spec/javascripts/repo/stores/actions/tree_spec.js
deleted file mode 100644
index 65351dbb7d9..00000000000
--- a/spec/javascripts/repo/stores/actions/tree_spec.js
+++ /dev/null
@@ -1,350 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import service from '~/ide/services';
-import { file, resetStore } from '../../helpers';
-
-describe('Multi-file store tree actions', () => {
- let projectTree;
-
- const basicCallParameters = {
- endpoint: 'rootEndpoint',
- projectId: 'abcproject',
- branch: 'master',
- };
-
- beforeEach(() => {
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = {
- web_url: '',
- branches: {
- master: {
- workingReference: '1',
- },
- },
- };
- });
-
- afterEach(() => {
- resetStore(store);
- });
-
- describe('getTreeData', () => {
- beforeEach(() => {
- spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({
- headers: {
- 'page-title': 'test',
- },
- json: () => Promise.resolve({
- last_commit_path: 'last_commit_path',
- parent_tree_url: 'parent_tree_url',
- path: '/',
- trees: [{ name: 'tree' }],
- blobs: [{ name: 'blob' }],
- submodules: [{ name: 'submodule' }],
- }),
- }));
- });
-
- it('calls service getTreeData', (done) => {
- store.dispatch('getTreeData', basicCallParameters)
- .then(() => {
- expect(service.getTreeData).toHaveBeenCalledWith('rootEndpoint');
-
- done();
- }).catch(done.fail);
- });
-
- it('adds data into tree', (done) => {
- store.dispatch('getTreeData', basicCallParameters)
- .then(() => {
- projectTree = store.state.trees['abcproject/master'];
- expect(projectTree.tree.length).toBe(3);
- expect(projectTree.tree[0].type).toBe('tree');
- expect(projectTree.tree[1].type).toBe('submodule');
- expect(projectTree.tree[2].type).toBe('blob');
-
- done();
- }).catch(done.fail);
- });
-
- it('sets parent tree URL', (done) => {
- store.dispatch('getTreeData', basicCallParameters)
- .then(() => {
- expect(store.state.parentTreeUrl).toBe('parent_tree_url');
-
- done();
- }).catch(done.fail);
- });
-
- it('sets last commit path', (done) => {
- store.dispatch('getTreeData', basicCallParameters)
- .then(() => {
- expect(store.state.trees['abcproject/master'].lastCommitPath).toBe('last_commit_path');
-
- done();
- }).catch(done.fail);
- });
-
- it('sets root if not currently at root', (done) => {
- store.state.isInitialRoot = false;
-
- store.dispatch('getTreeData', basicCallParameters)
- .then(() => {
- expect(store.state.isInitialRoot).toBeTruthy();
- expect(store.state.isRoot).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('sets page title', (done) => {
- store.dispatch('getTreeData', basicCallParameters)
- .then(() => {
- expect(document.title).toBe('test');
-
- done();
- }).catch(done.fail);
- });
-
- it('calls getLastCommitData if prevLastCommitPath is not null', (done) => {
- const getLastCommitDataSpy = jasmine.createSpy('getLastCommitData');
- const oldGetLastCommitData = store._actions.getLastCommitData; // eslint-disable-line
- store._actions.getLastCommitData = [getLastCommitDataSpy]; // eslint-disable-line
- store.state.prevLastCommitPath = 'test';
-
- store.dispatch('getTreeData', basicCallParameters)
- .then(() => {
- expect(getLastCommitDataSpy).toHaveBeenCalledWith(projectTree);
-
- store._actions.getLastCommitData = oldGetLastCommitData; // eslint-disable-line
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('toggleTreeOpen', () => {
- let oldGetTreeData;
- let getTreeDataSpy;
- let tree;
-
- beforeEach(() => {
- getTreeDataSpy = jasmine.createSpy('getTreeData');
-
- oldGetTreeData = store._actions.getTreeData; // eslint-disable-line
- store._actions.getTreeData = [getTreeDataSpy]; // eslint-disable-line
-
- tree = {
- projectId: 'abcproject',
- branchId: 'master',
- opened: false,
- tree: [],
- };
- });
-
- afterEach(() => {
- store._actions.getTreeData = oldGetTreeData; // eslint-disable-line
- });
-
- it('toggles the tree open', (done) => {
- store.dispatch('toggleTreeOpen', {
- endpoint: 'test',
- tree,
- }).then(() => {
- expect(tree.opened).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('calls getTreeData if tree is closed', (done) => {
- store.dispatch('toggleTreeOpen', {
- endpoint: 'test',
- tree,
- }).then(() => {
- expect(getTreeDataSpy).toHaveBeenCalledWith({
- projectId: 'abcproject',
- branch: 'master',
- endpoint: 'test',
- tree,
- });
-
- done();
- }).catch(done.fail);
- });
-
- it('resets entries tree', (done) => {
- Object.assign(tree, {
- opened: true,
- tree: ['a'],
- });
-
- store.dispatch('toggleTreeOpen', {
- endpoint: 'test',
- tree,
- }).then(() => {
- expect(tree.tree.length).toBe(0);
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('createTempTree', () => {
- beforeEach(() => {
- store.state.trees['abcproject/mybranch'] = {
- tree: [],
- };
- projectTree = store.state.trees['abcproject/mybranch'];
- });
-
- it('creates temp tree', (done) => {
- store.dispatch('createTempTree', {
- projectId: store.state.currentProjectId,
- branchId: store.state.currentBranchId,
- name: 'test',
- parent: projectTree,
- })
- .then(() => {
- expect(projectTree.tree[0].name).toBe('test');
- expect(projectTree.tree[0].type).toBe('tree');
-
- done();
- }).catch(done.fail);
- });
-
- it('creates new folder inside another tree', (done) => {
- const tree = {
- type: 'tree',
- name: 'testing',
- tree: [],
- };
-
- projectTree.tree.push(tree);
-
- store.dispatch('createTempTree', {
- projectId: store.state.currentProjectId,
- branchId: store.state.currentBranchId,
- name: 'testing/test',
- parent: projectTree,
- })
- .then(() => {
- expect(projectTree.tree[0].name).toBe('testing');
- expect(projectTree.tree[0].tree[0].tempFile).toBeTruthy();
- expect(projectTree.tree[0].tree[0].name).toBe('test');
- expect(projectTree.tree[0].tree[0].type).toBe('tree');
-
- done();
- }).catch(done.fail);
- });
-
- it('does not create new tree if already exists', (done) => {
- const tree = {
- type: 'tree',
- name: 'testing',
- endpoint: 'test',
- tree: [],
- };
-
- projectTree.tree.push(tree);
-
- store.dispatch('createTempTree', {
- projectId: store.state.currentProjectId,
- branchId: store.state.currentBranchId,
- name: 'testing/test',
- parent: projectTree,
- })
- .then(() => {
- expect(projectTree.tree[0].name).toBe('testing');
- expect(projectTree.tree[0].tempFile).toBeUndefined();
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('getLastCommitData', () => {
- beforeEach(() => {
- spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({
- headers: {
- 'more-logs-url': null,
- },
- json: () => Promise.resolve([{
- type: 'tree',
- file_name: 'testing',
- commit: {
- message: 'commit message',
- authored_date: '123',
- },
- }]),
- }));
-
- store.state.trees['abcproject/mybranch'] = {
- tree: [],
- };
-
- projectTree = store.state.trees['abcproject/mybranch'];
- projectTree.tree.push(file('testing', '1', 'tree'));
- projectTree.lastCommitPath = 'lastcommitpath';
- });
-
- it('calls service with lastCommitPath', (done) => {
- store.dispatch('getLastCommitData', projectTree)
- .then(() => {
- expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath');
-
- done();
- }).catch(done.fail);
- });
-
- it('updates trees last commit data', (done) => {
- store.dispatch('getLastCommitData', projectTree)
- .then(Vue.nextTick)
- .then(() => {
- expect(projectTree.tree[0].lastCommit.message).toBe('commit message');
-
- done();
- }).catch(done.fail);
- });
-
- it('does not update entry if not found', (done) => {
- projectTree.tree[0].name = 'a';
-
- store.dispatch('getLastCommitData', projectTree)
- .then(Vue.nextTick)
- .then(() => {
- expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message');
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('updateDirectoryData', () => {
- it('adds data into tree', (done) => {
- const tree = {
- tree: [],
- };
- const data = {
- trees: [{ name: 'tree' }],
- submodules: [{ name: 'submodule' }],
- blobs: [{ name: 'blob' }],
- };
-
- store.dispatch('updateDirectoryData', {
- data,
- tree,
- }).then(() => {
- expect(tree.tree[0].name).toBe('tree');
- expect(tree.tree[0].type).toBe('tree');
- expect(tree.tree[1].name).toBe('submodule');
- expect(tree.tree[1].type).toBe('submodule');
- expect(tree.tree[2].name).toBe('blob');
- expect(tree.tree[2].type).toBe('blob');
-
- done();
- }).catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/actions_spec.js b/spec/javascripts/repo/stores/actions_spec.js
deleted file mode 100644
index 0b0d34f072a..00000000000
--- a/spec/javascripts/repo/stores/actions_spec.js
+++ /dev/null
@@ -1,435 +0,0 @@
-import Vue from 'vue';
-import * as urlUtils from '~/lib/utils/url_utility';
-import store from '~/ide/stores';
-import service from '~/ide/services';
-import { resetStore, file } from '../helpers';
-
-describe('Multi-file store actions', () => {
- afterEach(() => {
- resetStore(store);
- });
-
- describe('redirectToUrl', () => {
- it('calls visitUrl', (done) => {
- spyOn(urlUtils, 'visitUrl');
-
- store.dispatch('redirectToUrl', 'test')
- .then(() => {
- expect(urlUtils.visitUrl).toHaveBeenCalledWith('test');
-
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('setInitialData', () => {
- it('commits initial data', (done) => {
- store.dispatch('setInitialData', { canCommit: true })
- .then(() => {
- expect(store.state.canCommit).toBeTruthy();
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('closeDiscardPopup', () => {
- it('closes the discard popup', (done) => {
- store.dispatch('closeDiscardPopup', false)
- .then(() => {
- expect(store.state.discardPopupOpen).toBeFalsy();
-
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('discardAllChanges', () => {
- beforeEach(() => {
- store.state.openFiles.push(file());
- store.state.openFiles[0].changed = true;
- });
- });
-
- describe('closeAllFiles', () => {
- beforeEach(() => {
- store.state.openFiles.push(file());
- store.state.openFiles[0].opened = true;
- });
-
- it('closes all open files', (done) => {
- store.dispatch('closeAllFiles')
- .then(() => {
- expect(store.state.openFiles.length).toBe(0);
-
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('toggleEditMode', () => {
- it('toggles edit mode', (done) => {
- store.state.editMode = true;
-
- store.dispatch('toggleEditMode')
- .then(() => {
- expect(store.state.editMode).toBeFalsy();
-
- done();
- }).catch(done.fail);
- });
-
- it('sets preview mode', (done) => {
- store.state.currentBlobView = 'repo-editor';
- store.state.editMode = true;
-
- store.dispatch('toggleEditMode')
- .then(Vue.nextTick)
- .then(() => {
- expect(store.state.currentBlobView).toBe('repo-preview');
-
- done();
- }).catch(done.fail);
- });
-
- it('opens discard popup if there are changed files', (done) => {
- store.state.editMode = true;
- store.state.openFiles.push(file());
- store.state.openFiles[0].changed = true;
-
- store.dispatch('toggleEditMode')
- .then(() => {
- expect(store.state.discardPopupOpen).toBeTruthy();
-
- done();
- }).catch(done.fail);
- });
-
- it('can force closed if there are changed files', (done) => {
- store.state.editMode = true;
-
- store.state.openFiles.push(file());
- store.state.openFiles[0].changed = true;
-
- store.dispatch('toggleEditMode', true)
- .then(() => {
- expect(store.state.discardPopupOpen).toBeFalsy();
- expect(store.state.editMode).toBeFalsy();
-
- done();
- }).catch(done.fail);
- });
-
- it('discards file changes', (done) => {
- const f = file();
- store.state.editMode = true;
- store.state.openFiles.push(f);
- f.changed = true;
-
- store.dispatch('toggleEditMode', true)
- .then(Vue.nextTick)
- .then(() => {
- expect(f.changed).toBeFalsy();
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('toggleBlobView', () => {
- it('sets edit mode view if in edit mode', (done) => {
- store.dispatch('toggleBlobView')
- .then(() => {
- expect(store.state.currentBlobView).toBe('repo-editor');
-
- done();
- })
- .catch(done.fail);
- });
-
- it('sets preview mode view if not in edit mode', (done) => {
- store.state.editMode = false;
-
- store.dispatch('toggleBlobView')
- .then(() => {
- expect(store.state.currentBlobView).toBe('repo-preview');
-
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('checkCommitStatus', () => {
- beforeEach(() => {
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = {
- branches: {
- master: {
- workingReference: '1',
- },
- },
- };
- });
-
- it('calls service', (done) => {
- spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
- commit: { id: '123' },
- }));
-
- store.dispatch('checkCommitStatus')
- .then(() => {
- expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master');
-
- done();
- })
- .catch(done.fail);
- });
-
- it('returns true if current ref does not equal returned ID', (done) => {
- spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
- commit: { id: '123' },
- }));
-
- store.dispatch('checkCommitStatus')
- .then((val) => {
- expect(val).toBeTruthy();
-
- done();
- })
- .catch(done.fail);
- });
-
- it('returns false if current ref equals returned ID', (done) => {
- spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
- commit: { id: '1' },
- }));
-
- store.dispatch('checkCommitStatus')
- .then((val) => {
- expect(val).toBeFalsy();
-
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('commitChanges', () => {
- let payload;
-
- beforeEach(() => {
- spyOn(window, 'scrollTo');
-
- document.body.innerHTML += '<div class="flash-container"></div>';
-
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = {
- web_url: 'webUrl',
- branches: {
- master: {
- workingReference: '1',
- },
- },
- };
-
- payload = {
- branch: 'master',
- };
- });
-
- afterEach(() => {
- document.querySelector('.flash-container').remove();
- });
-
- describe('success', () => {
- beforeEach(() => {
- spyOn(service, 'commit').and.returnValue(Promise.resolve({
- id: '123456',
- short_id: '123',
- message: 'test message',
- committed_date: 'date',
- stats: {
- additions: '1',
- deletions: '2',
- },
- }));
- });
-
- it('calls service', (done) => {
- store.dispatch('commitChanges', { payload, newMr: false })
- .then(() => {
- expect(service.commit).toHaveBeenCalledWith('abcproject', payload);
-
- done();
- }).catch(done.fail);
- });
-
- it('shows flash notice', (done) => {
- store.dispatch('commitChanges', { payload, newMr: false })
- .then(() => {
- const alert = document.querySelector('.flash-container');
-
- expect(alert.querySelector('.flash-notice')).not.toBeNull();
- expect(alert.textContent.trim()).toBe(
- 'Your changes have been committed. Commit 123 with 1 additions, 2 deletions.',
- );
-
- done();
- }).catch(done.fail);
- });
-
- it('adds commit data to changed files', (done) => {
- const changedFile = file();
- const f = file();
- changedFile.changed = true;
-
- store.state.openFiles.push(changedFile, f);
-
- store.dispatch('commitChanges', { payload, newMr: false })
- .then(() => {
- expect(changedFile.lastCommit.message).toBe('test message');
- expect(f.lastCommit.message).not.toBe('test message');
-
- done();
- }).catch(done.fail);
- });
-
- it('closes all files', (done) => {
- store.state.openFiles.push(file());
- store.state.openFiles[0].opened = true;
-
- store.dispatch('commitChanges', { payload, newMr: false })
- .then(Vue.nextTick)
- .then(() => {
- expect(store.state.openFiles.length).toBe(0);
-
- done();
- }).catch(done.fail);
- });
-
- it('scrolls to top of page', (done) => {
- store.dispatch('commitChanges', { payload, newMr: false })
- .then(() => {
- expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
-
- done();
- }).catch(done.fail);
- });
-
- it('redirects to new merge request page', (done) => {
- spyOn(urlUtils, 'visitUrl');
-
- store.dispatch('commitChanges', { payload, newMr: true })
- .then(() => {
- expect(urlUtils.visitUrl).toHaveBeenCalledWith('webUrl/merge_requests/new?merge_request%5Bsource_branch%5D=master');
-
- done();
- }).catch(done.fail);
- });
- });
-
- describe('failed', () => {
- beforeEach(() => {
- spyOn(service, 'commit').and.returnValue(Promise.resolve({
- message: 'failed message',
- }));
- });
-
- it('shows failed message', (done) => {
- store.dispatch('commitChanges', { payload, newMr: false })
- .then(() => {
- const alert = document.querySelector('.flash-container');
-
- expect(alert.textContent.trim()).toBe(
- 'failed message',
- );
-
- done();
- }).catch(done.fail);
- });
- });
- });
-
- describe('createTempEntry', () => {
- beforeEach(() => {
- store.state.trees['abcproject/mybranch'] = {
- tree: [],
- };
- store.state.projects.abcproject = {
- web_url: '',
- };
- });
-
- it('creates a temp tree', (done) => {
- const projectTree = store.state.trees['abcproject/mybranch'];
-
- store.dispatch('createTempEntry', {
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- name: 'test',
- type: 'tree',
- })
- .then(() => {
- const baseTree = projectTree.tree;
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].tempFile).toBeTruthy();
- expect(baseTree[0].type).toBe('tree');
-
- done();
- })
- .catch(done.fail);
- });
-
- it('creates temp file', (done) => {
- const projectTree = store.state.trees['abcproject/mybranch'];
-
- store.dispatch('createTempEntry', {
- projectId: 'abcproject',
- branchId: 'mybranch',
- parent: projectTree,
- name: 'test',
- type: 'blob',
- })
- .then(() => {
- const baseTree = projectTree.tree;
- expect(baseTree.length).toBe(1);
- expect(baseTree[0].tempFile).toBeTruthy();
- expect(baseTree[0].type).toBe('blob');
-
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('popHistoryState', () => {
-
- });
-
- describe('scrollToTab', () => {
- it('focuses the current active element', (done) => {
- document.body.innerHTML += '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>';
- const el = document.querySelector('.repo-tab');
- spyOn(el, 'focus');
-
- store.dispatch('scrollToTab')
- .then(() => {
- setTimeout(() => {
- expect(el.focus).toHaveBeenCalled();
-
- document.getElementById('tabs').remove();
-
- done();
- });
- })
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/getters_spec.js b/spec/javascripts/repo/stores/getters_spec.js
deleted file mode 100644
index d0d5934f29a..00000000000
--- a/spec/javascripts/repo/stores/getters_spec.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import * as getters from '~/ide/stores/getters';
-import state from '~/ide/stores/state';
-import { file } from '../helpers';
-
-describe('Multi-file store getters', () => {
- let localState;
-
- beforeEach(() => {
- localState = state();
- });
-
- describe('changedFiles', () => {
- it('returns a list of changed opened files', () => {
- localState.openFiles.push(file());
- localState.openFiles.push(file('changed'));
- localState.openFiles[1].changed = true;
-
- const changedFiles = getters.changedFiles(localState);
-
- expect(changedFiles.length).toBe(1);
- expect(changedFiles[0].name).toBe('changed');
- });
- });
-
- describe('activeFile', () => {
- it('returns the current active file', () => {
- localState.openFiles.push(file());
- localState.openFiles.push(file('active'));
- localState.openFiles[1].active = true;
-
- expect(getters.activeFile(localState).name).toBe('active');
- });
-
- it('returns undefined if no active files are found', () => {
- localState.openFiles.push(file());
- localState.openFiles.push(file('active'));
-
- expect(getters.activeFile(localState)).toBeNull();
- });
- });
-
- describe('activeFileExtension', () => {
- it('returns the file extension for the current active file', () => {
- localState.openFiles.push(file('active'));
- localState.openFiles[0].active = true;
- localState.openFiles[0].path = 'test.js';
-
- expect(getters.activeFileExtension(localState)).toBe('.js');
-
- localState.openFiles[0].path = 'test.es6.js';
-
- expect(getters.activeFileExtension(localState)).toBe('.js');
- });
- });
-
- describe('canEditFile', () => {
- beforeEach(() => {
- localState.onTopOfBranch = true;
- localState.canCommit = true;
-
- localState.openFiles.push(file());
- localState.openFiles[0].active = true;
- });
-
- it('returns true if user can commit and has open files', () => {
- expect(getters.canEditFile(localState)).toBeTruthy();
- });
-
- it('returns false if user can commit and has no open files', () => {
- localState.openFiles = [];
-
- expect(getters.canEditFile(localState)).toBeFalsy();
- });
-
- it('returns false if user can commit and active file is binary', () => {
- localState.openFiles[0].binary = true;
-
- expect(getters.canEditFile(localState)).toBeFalsy();
- });
-
- it('returns false if user cant commit', () => {
- localState.canCommit = false;
-
- expect(getters.canEditFile(localState)).toBeFalsy();
- });
- });
-
- describe('modifiedFiles', () => {
- it('returns a list of modified files', () => {
- localState.openFiles.push(file());
- localState.openFiles.push(file('changed'));
- localState.openFiles[1].changed = true;
-
- const modifiedFiles = getters.modifiedFiles(localState);
-
- expect(modifiedFiles.length).toBe(1);
- expect(modifiedFiles[0].name).toBe('changed');
- });
- });
-
- describe('addedFiles', () => {
- it('returns a list of added files', () => {
- localState.openFiles.push(file());
- localState.openFiles.push(file('added'));
- localState.openFiles[1].changed = true;
- localState.openFiles[1].tempFile = true;
-
- const modifiedFiles = getters.addedFiles(localState);
-
- expect(modifiedFiles.length).toBe(1);
- expect(modifiedFiles[0].name).toBe('added');
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/mutations/file_spec.js b/spec/javascripts/repo/stores/mutations/file_spec.js
deleted file mode 100644
index 947a60587df..00000000000
--- a/spec/javascripts/repo/stores/mutations/file_spec.js
+++ /dev/null
@@ -1,131 +0,0 @@
-import mutations from '~/ide/stores/mutations/file';
-import state from '~/ide/stores/state';
-import { file } from '../../helpers';
-
-describe('Multi-file store file mutations', () => {
- let localState;
- let localFile;
-
- beforeEach(() => {
- localState = state();
- localFile = file();
- });
-
- describe('SET_FILE_ACTIVE', () => {
- it('sets the file active', () => {
- mutations.SET_FILE_ACTIVE(localState, {
- file: localFile,
- active: true,
- });
-
- expect(localFile.active).toBeTruthy();
- });
- });
-
- describe('TOGGLE_FILE_OPEN', () => {
- beforeEach(() => {
- mutations.TOGGLE_FILE_OPEN(localState, localFile);
- });
-
- it('adds into opened files', () => {
- expect(localFile.opened).toBeTruthy();
- expect(localState.openFiles.length).toBe(1);
- });
-
- it('removes from opened files', () => {
- mutations.TOGGLE_FILE_OPEN(localState, localFile);
-
- expect(localFile.opened).toBeFalsy();
- expect(localState.openFiles.length).toBe(0);
- });
- });
-
- describe('SET_FILE_DATA', () => {
- it('sets extra file data', () => {
- mutations.SET_FILE_DATA(localState, {
- data: {
- blame_path: 'blame',
- commits_path: 'commits',
- permalink: 'permalink',
- raw_path: 'raw',
- binary: true,
- html: 'html',
- render_error: 'render_error',
- },
- file: localFile,
- });
-
- expect(localFile.blamePath).toBe('blame');
- expect(localFile.commitsPath).toBe('commits');
- expect(localFile.permalink).toBe('permalink');
- expect(localFile.rawPath).toBe('raw');
- expect(localFile.binary).toBeTruthy();
- expect(localFile.html).toBe('html');
- expect(localFile.renderError).toBe('render_error');
- });
- });
-
- describe('SET_FILE_RAW_DATA', () => {
- it('sets raw data', () => {
- mutations.SET_FILE_RAW_DATA(localState, {
- file: localFile,
- raw: 'testing',
- });
-
- expect(localFile.raw).toBe('testing');
- });
- });
-
- describe('UPDATE_FILE_CONTENT', () => {
- beforeEach(() => {
- localFile.raw = 'test';
- });
-
- it('sets content', () => {
- mutations.UPDATE_FILE_CONTENT(localState, {
- file: localFile,
- content: 'test',
- });
-
- expect(localFile.content).toBe('test');
- });
-
- it('sets changed if content does not match raw', () => {
- mutations.UPDATE_FILE_CONTENT(localState, {
- file: localFile,
- content: 'testing',
- });
-
- expect(localFile.content).toBe('testing');
- expect(localFile.changed).toBeTruthy();
- });
- });
-
- describe('DISCARD_FILE_CHANGES', () => {
- beforeEach(() => {
- localFile.content = 'test';
- localFile.changed = true;
- });
-
- it('resets content and changed', () => {
- mutations.DISCARD_FILE_CHANGES(localState, localFile);
-
- expect(localFile.content).toBe('');
- expect(localFile.changed).toBeFalsy();
- });
- });
-
- describe('CREATE_TMP_FILE', () => {
- it('adds file into parent tree', () => {
- const f = file();
-
- mutations.CREATE_TMP_FILE(localState, {
- file: f,
- parent: localFile,
- });
-
- expect(localFile.tree.length).toBe(1);
- expect(localFile.tree[0].name).toBe(f.name);
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/mutations/tree_spec.js b/spec/javascripts/repo/stores/mutations/tree_spec.js
deleted file mode 100644
index cf1248ba28b..00000000000
--- a/spec/javascripts/repo/stores/mutations/tree_spec.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import mutations from '~/ide/stores/mutations/tree';
-import state from '~/ide/stores/state';
-import { file } from '../../helpers';
-
-describe('Multi-file store tree mutations', () => {
- let localState;
- let localTree;
-
- beforeEach(() => {
- localState = state();
- localTree = file();
- });
-
- describe('TOGGLE_TREE_OPEN', () => {
- it('toggles tree open', () => {
- mutations.TOGGLE_TREE_OPEN(localState, localTree);
-
- expect(localTree.opened).toBeTruthy();
-
- mutations.TOGGLE_TREE_OPEN(localState, localTree);
-
- expect(localTree.opened).toBeFalsy();
- });
- });
-
- describe('SET_DIRECTORY_DATA', () => {
- const data = [{
- name: 'tree',
- },
- {
- name: 'submodule',
- },
- {
- name: 'blob',
- }];
-
- it('adds directory data', () => {
- mutations.SET_DIRECTORY_DATA(localState, {
- data,
- tree: localState,
- });
-
- expect(localState.tree.length).toBe(3);
- expect(localState.tree[0].name).toBe('tree');
- expect(localState.tree[1].name).toBe('submodule');
- expect(localState.tree[2].name).toBe('blob');
- });
- });
-
- describe('SET_PARENT_TREE_URL', () => {
- it('sets the parent tree url', () => {
- mutations.SET_PARENT_TREE_URL(localState, 'test');
-
- expect(localState.parentTreeUrl).toBe('test');
- });
- });
-
- describe('CREATE_TMP_TREE', () => {
- it('adds tree into parent tree', () => {
- const tmpEntry = file();
-
- mutations.CREATE_TMP_TREE(localState, {
- tmpEntry,
- parent: localTree,
- });
-
- expect(localTree.tree.length).toBe(1);
- expect(localTree.tree[0].name).toBe(tmpEntry.name);
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/mutations_spec.js b/spec/javascripts/repo/stores/mutations_spec.js
deleted file mode 100644
index 5fd8ad94972..00000000000
--- a/spec/javascripts/repo/stores/mutations_spec.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import mutations from '~/ide/stores/mutations';
-import state from '~/ide/stores/state';
-import { file } from '../helpers';
-
-describe('Multi-file store mutations', () => {
- let localState;
- let entry;
-
- beforeEach(() => {
- localState = state();
- entry = file();
- });
-
- describe('SET_INITIAL_DATA', () => {
- it('sets all initial data', () => {
- mutations.SET_INITIAL_DATA(localState, {
- test: 'test',
- });
-
- expect(localState.test).toBe('test');
- });
- });
-
- describe('SET_PREVIEW_MODE', () => {
- it('sets currentBlobView to repo-preview', () => {
- mutations.SET_PREVIEW_MODE(localState);
-
- expect(localState.currentBlobView).toBe('repo-preview');
-
- localState.currentBlobView = 'testing';
-
- mutations.SET_PREVIEW_MODE(localState);
-
- expect(localState.currentBlobView).toBe('repo-preview');
- });
- });
-
- describe('SET_EDIT_MODE', () => {
- it('sets currentBlobView to repo-editor', () => {
- mutations.SET_EDIT_MODE(localState);
-
- expect(localState.currentBlobView).toBe('repo-editor');
-
- localState.currentBlobView = 'testing';
-
- mutations.SET_EDIT_MODE(localState);
-
- expect(localState.currentBlobView).toBe('repo-editor');
- });
- });
-
- describe('TOGGLE_LOADING', () => {
- it('toggles loading of entry', () => {
- mutations.TOGGLE_LOADING(localState, entry);
-
- expect(entry.loading).toBeTruthy();
-
- mutations.TOGGLE_LOADING(localState, entry);
-
- expect(entry.loading).toBeFalsy();
- });
- });
-
- describe('TOGGLE_EDIT_MODE', () => {
- it('toggles editMode', () => {
- mutations.TOGGLE_EDIT_MODE(localState);
-
- expect(localState.editMode).toBeFalsy();
-
- mutations.TOGGLE_EDIT_MODE(localState);
-
- expect(localState.editMode).toBeTruthy();
- });
- });
-
- describe('TOGGLE_DISCARD_POPUP', () => {
- it('sets discardPopupOpen', () => {
- mutations.TOGGLE_DISCARD_POPUP(localState, true);
-
- expect(localState.discardPopupOpen).toBeTruthy();
-
- mutations.TOGGLE_DISCARD_POPUP(localState, false);
-
- expect(localState.discardPopupOpen).toBeFalsy();
- });
- });
-
- describe('SET_ROOT', () => {
- it('sets isRoot & initialRoot', () => {
- mutations.SET_ROOT(localState, true);
-
- expect(localState.isRoot).toBeTruthy();
- expect(localState.isInitialRoot).toBeTruthy();
-
- mutations.SET_ROOT(localState, false);
-
- expect(localState.isRoot).toBeFalsy();
- expect(localState.isInitialRoot).toBeFalsy();
- });
- });
-
- describe('SET_LEFT_PANEL_COLLAPSED', () => {
- it('sets left panel collapsed', () => {
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.leftPanelCollapsed).toBeTruthy();
-
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.leftPanelCollapsed).toBeFalsy();
- });
- });
-
- describe('SET_RIGHT_PANEL_COLLAPSED', () => {
- it('sets right panel collapsed', () => {
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.rightPanelCollapsed).toBeTruthy();
-
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.rightPanelCollapsed).toBeFalsy();
- });
- });
-});
diff --git a/spec/javascripts/repo/stores/utils_spec.js b/spec/javascripts/repo/stores/utils_spec.js
deleted file mode 100644
index 89745a2029e..00000000000
--- a/spec/javascripts/repo/stores/utils_spec.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import * as utils from '~/ide/stores/utils';
-import state from '~/ide/stores/state';
-import { file } from '../helpers';
-
-describe('Multi-file store utils', () => {
- describe('setPageTitle', () => {
- it('sets the document page title', () => {
- utils.setPageTitle('test');
-
- expect(document.title).toBe('test');
- });
- });
-
- describe('treeList', () => {
- let localState;
-
- beforeEach(() => {
- localState = state();
- });
-
- it('returns flat tree list', () => {
- localState.trees = [];
- localState.trees['abcproject/mybranch'] = {
- tree: [],
- };
- const baseTree = localState.trees['abcproject/mybranch'].tree;
- baseTree.push(file('1'));
- baseTree[0].tree.push(file('2'));
- baseTree[0].tree[0].tree.push(file('3'));
-
- const treeList = utils.treeList(localState, 'abcproject/mybranch');
-
- expect(treeList.length).toBe(3);
- expect(treeList[1].name).toBe(baseTree[0].tree[0].name);
- expect(treeList[2].name).toBe(baseTree[0].tree[0].tree[0].name);
- });
- });
-
- describe('createTemp', () => {
- it('creates temp tree', () => {
- const tmp = utils.createTemp({
- name: 'test',
- path: 'test',
- type: 'tree',
- level: 0,
- changed: false,
- content: '',
- base64: '',
- });
-
- expect(tmp.tempFile).toBeTruthy();
- expect(tmp.icon).toBe('fa-folder');
- });
-
- it('creates temp file', () => {
- const tmp = utils.createTemp({
- name: 'test',
- path: 'test',
- type: 'blob',
- level: 0,
- changed: false,
- content: '',
- base64: '',
- });
-
- expect(tmp.tempFile).toBeTruthy();
- expect(tmp.icon).toBe('fa-file-text-o');
- });
- });
-
- describe('findIndexOfFile', () => {
- let localState;
-
- beforeEach(() => {
- localState = [{
- path: '1',
- }, {
- path: '2',
- }];
- });
-
- it('finds in the index of an entry by path', () => {
- const index = utils.findIndexOfFile(localState, {
- path: '2',
- });
-
- expect(index).toBe(1);
- });
- });
-
- describe('findEntry', () => {
- let localState;
-
- beforeEach(() => {
- localState = {
- tree: [{
- type: 'tree',
- name: 'test',
- }, {
- type: 'blob',
- name: 'file',
- }],
- };
- });
-
- it('returns an entry found by name', () => {
- const foundEntry = utils.findEntry(localState.tree, 'tree', 'test');
-
- expect(foundEntry.type).toBe('tree');
- expect(foundEntry.name).toBe('test');
- });
-
- it('returns undefined when no entry found', () => {
- const foundEntry = utils.findEntry(localState.tree, 'blob', 'test');
-
- expect(foundEntry).toBeUndefined();
- });
- });
-});
diff --git a/spec/javascripts/right_sidebar_spec.js b/spec/javascripts/right_sidebar_spec.js
index 3267e29585b..80770a61011 100644
--- a/spec/javascripts/right_sidebar_spec.js
+++ b/spec/javascripts/right_sidebar_spec.js
@@ -1,6 +1,9 @@
/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, new-parens, no-return-assign, new-cap, vars-on-top, max-len */
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
import '~/commons/bootstrap';
+import axios from '~/lib/utils/axios_utils';
import Sidebar from '~/right_sidebar';
(function() {
@@ -35,16 +38,23 @@ import Sidebar from '~/right_sidebar';
var fixtureName = 'issues/open-issue.html.raw';
preloadFixtures(fixtureName);
loadJSONFixtures('todos/todos.json');
+ let mock;
beforeEach(function() {
loadFixtures(fixtureName);
- this.sidebar = new Sidebar;
+ mock = new MockAdapter(axios);
+ this.sidebar = new Sidebar();
$aside = $('.right-sidebar');
$page = $('.layout-page');
$icon = $aside.find('i');
$toggle = $aside.find('.js-sidebar-toggle');
return $labelsIcon = $aside.find('.sidebar-collapsed-icon');
});
+
+ afterEach(() => {
+ mock.restore();
+ });
+
it('should expand/collapse the sidebar when arrow is clicked', function() {
assertSidebarState('expanded');
$toggle.click();
@@ -63,20 +73,19 @@ import Sidebar from '~/right_sidebar';
return assertSidebarState('collapsed');
});
- it('should broadcast todo:toggle event when add todo clicked', function() {
+ it('should broadcast todo:toggle event when add todo clicked', function(done) {
var todos = getJSONFixture('todos/todos.json');
- spyOn(jQuery, 'ajax').and.callFake(function() {
- var d = $.Deferred();
- var response = todos;
- d.resolve(response);
- return d.promise();
- });
+ mock.onPost(/(.*)\/todos$/).reply(200, todos);
var todoToggleSpy = spyOnEvent(document, 'todo:toggle');
$('.issuable-sidebar-header .js-issuable-todo').click();
- expect(todoToggleSpy.calls.count()).toEqual(1);
+ setTimeout(() => {
+ expect(todoToggleSpy.calls.count()).toEqual(1);
+
+ done();
+ });
});
it('should not hide collapsed icons', () => {
diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js
index 206f95abc1a..1a27955983d 100644
--- a/spec/javascripts/search_autocomplete_spec.js
+++ b/spec/javascripts/search_autocomplete_spec.js
@@ -1,12 +1,26 @@
/* 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 */
+import $ from 'jquery';
import '~/gl_dropdown';
import SearchAutocomplete from '~/search_autocomplete';
import '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
-(function() {
- var assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget;
+describe('Search autocomplete dropdown', () => {
+ var assertLinks,
+ dashboardIssuesPath,
+ dashboardMRsPath,
+ groupIssuesPath,
+ groupMRsPath,
+ groupName,
+ mockDashboardOptions,
+ mockGroupOptions,
+ mockProjectOptions,
+ projectIssuesPath,
+ projectMRsPath,
+ projectName,
+ userId,
+ widget;
var userName = 'root';
widget = null;
@@ -65,133 +79,126 @@ import * as urlUtils from '~/lib/utils/url_utility';
// Mock `gl` object in window for dashboard specific page. App code will need it.
mockDashboardOptions = function() {
window.gl || (window.gl = {});
- return window.gl.dashboardOptions = {
+ return (window.gl.dashboardOptions = {
issuesPath: dashboardIssuesPath,
- mrPath: dashboardMRsPath
- };
+ mrPath: dashboardMRsPath,
+ });
};
// Mock `gl` object in window for project specific page. App code will need it.
mockProjectOptions = function() {
window.gl || (window.gl = {});
- return window.gl.projectOptions = {
+ return (window.gl.projectOptions = {
'gitlab-ce': {
issuesPath: projectIssuesPath,
mrPath: projectMRsPath,
- projectName: projectName
- }
- };
+ projectName: projectName,
+ },
+ });
};
mockGroupOptions = function() {
window.gl || (window.gl = {});
- return window.gl.groupOptions = {
+ return (window.gl.groupOptions = {
'gitlab-org': {
issuesPath: groupIssuesPath,
mrPath: groupMRsPath,
- projectName: groupName
- }
- };
+ projectName: groupName,
+ },
+ });
};
assertLinks = function(list, issuesPath, mrsPath) {
- var a1, a2, a3, a4, issuesAssignedToMeLink, issuesIHaveCreatedLink, mrsAssignedToMeLink, mrsIHaveCreatedLink;
if (issuesPath) {
- issuesAssignedToMeLink = issuesPath + "/?assignee_username=" + userName;
- issuesIHaveCreatedLink = issuesPath + "/?author_username=" + userName;
- a1 = "a[href='" + issuesAssignedToMeLink + "']";
- a2 = "a[href='" + issuesIHaveCreatedLink + "']";
- expect(list.find(a1).length).toBe(1);
- expect(list.find(a1).text()).toBe('Issues assigned to me');
- expect(list.find(a2).length).toBe(1);
- expect(list.find(a2).text()).toBe("Issues I've created");
+ const issuesAssignedToMeLink = `a[href="${issuesPath}/?assignee_id=${userId}"]`;
+ const issuesIHaveCreatedLink = `a[href="${issuesPath}/?author_id=${userId}"]`;
+ expect(list.find(issuesAssignedToMeLink).length).toBe(1);
+ expect(list.find(issuesAssignedToMeLink).text()).toBe('Issues assigned to me');
+ expect(list.find(issuesIHaveCreatedLink).length).toBe(1);
+ expect(list.find(issuesIHaveCreatedLink).text()).toBe("Issues I've created");
}
- mrsAssignedToMeLink = mrsPath + "/?assignee_username=" + userName;
- mrsIHaveCreatedLink = mrsPath + "/?author_username=" + userName;
- a3 = "a[href='" + mrsAssignedToMeLink + "']";
- a4 = "a[href='" + mrsIHaveCreatedLink + "']";
- expect(list.find(a3).length).toBe(1);
- expect(list.find(a3).text()).toBe('Merge requests assigned to me');
- expect(list.find(a4).length).toBe(1);
- return expect(list.find(a4).text()).toBe("Merge requests I've created");
+ const mrsAssignedToMeLink = `a[href="${mrsPath}/?assignee_id=${userId}"]`;
+ const mrsIHaveCreatedLink = `a[href="${mrsPath}/?author_id=${userId}"]`;
+ expect(list.find(mrsAssignedToMeLink).length).toBe(1);
+ expect(list.find(mrsAssignedToMeLink).text()).toBe('Merge requests assigned to me');
+ expect(list.find(mrsIHaveCreatedLink).length).toBe(1);
+ expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created");
};
- describe('Search autocomplete dropdown', function() {
- preloadFixtures('static/search_autocomplete.html.raw');
- beforeEach(function() {
- loadFixtures('static/search_autocomplete.html.raw');
+ preloadFixtures('static/search_autocomplete.html.raw');
+ beforeEach(function() {
+ loadFixtures('static/search_autocomplete.html.raw');
- // Prevent turbolinks from triggering within gl_dropdown
- spyOn(urlUtils, 'visitUrl').and.returnValue(true);
+ // Prevent turbolinks from triggering within gl_dropdown
+ spyOn(urlUtils, 'visitUrl').and.returnValue(true);
- window.gon = {};
- window.gon.current_user_id = userId;
- window.gon.current_username = userName;
+ window.gon = {};
+ window.gon.current_user_id = userId;
+ window.gon.current_username = userName;
- return widget = new SearchAutocomplete();
- });
+ return (widget = new SearchAutocomplete());
+ });
- afterEach(function() {
- // Undo what we did to the shared <body>
- removeBodyAttributes();
- window.gon = {};
- });
- it('should show Dashboard specific dropdown menu', function() {
- var list;
- addBodyAttributes();
- mockDashboardOptions();
- widget.searchInput.triggerHandler('focus');
- list = widget.wrap.find('.dropdown-menu').find('ul');
- return assertLinks(list, dashboardIssuesPath, dashboardMRsPath);
- });
- it('should show Group specific dropdown menu', function() {
- var list;
- addBodyAttributes('group');
- mockGroupOptions();
- widget.searchInput.triggerHandler('focus');
- list = widget.wrap.find('.dropdown-menu').find('ul');
- return assertLinks(list, groupIssuesPath, groupMRsPath);
- });
- it('should show Project specific dropdown menu', function() {
- var list;
- addBodyAttributes('project');
- mockProjectOptions();
- widget.searchInput.triggerHandler('focus');
- list = widget.wrap.find('.dropdown-menu').find('ul');
- return assertLinks(list, projectIssuesPath, projectMRsPath);
- });
- it('should show only Project mergeRequest dropdown menu items when project issues are disabled', function() {
- addBodyAttributes('project');
- disableProjectIssues();
- mockProjectOptions();
- widget.searchInput.triggerHandler('focus');
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- assertLinks(list, null, projectMRsPath);
- });
- it('should not show category related menu if there is text in the input', function() {
- var link, list;
- addBodyAttributes('project');
- mockProjectOptions();
- widget.searchInput.val('help');
- widget.searchInput.triggerHandler('focus');
- list = widget.wrap.find('.dropdown-menu').find('ul');
- link = "a[href='" + projectIssuesPath + "/?assignee_id=" + userId + "']";
- return expect(list.find(link).length).toBe(0);
- });
- return it('should not submit the search form when selecting an autocomplete row with the keyboard', function() {
- var ENTER = 13;
- var DOWN = 40;
- addBodyAttributes();
- mockDashboardOptions(true);
- var submitSpy = spyOnEvent('form', 'submit');
- widget.searchInput.triggerHandler('focus');
- widget.wrap.trigger($.Event('keydown', { which: DOWN }));
- var enterKeyEvent = $.Event('keydown', { which: ENTER });
- widget.searchInput.trigger(enterKeyEvent);
- // This does not currently catch failing behavior. For security reasons,
- // browsers will not trigger default behavior (form submit, in this
- // example) on JavaScript-created keypresses.
- expect(submitSpy).not.toHaveBeenTriggered();
- });
+ afterEach(function() {
+ // Undo what we did to the shared <body>
+ removeBodyAttributes();
+ window.gon = {};
+ });
+ it('should show Dashboard specific dropdown menu', function() {
+ var list;
+ addBodyAttributes();
+ mockDashboardOptions();
+ widget.searchInput.triggerHandler('focus');
+ list = widget.wrap.find('.dropdown-menu').find('ul');
+ return assertLinks(list, dashboardIssuesPath, dashboardMRsPath);
+ });
+ it('should show Group specific dropdown menu', function() {
+ var list;
+ addBodyAttributes('group');
+ mockGroupOptions();
+ widget.searchInput.triggerHandler('focus');
+ list = widget.wrap.find('.dropdown-menu').find('ul');
+ return assertLinks(list, groupIssuesPath, groupMRsPath);
+ });
+ it('should show Project specific dropdown menu', function() {
+ var list;
+ addBodyAttributes('project');
+ mockProjectOptions();
+ widget.searchInput.triggerHandler('focus');
+ list = widget.wrap.find('.dropdown-menu').find('ul');
+ return assertLinks(list, projectIssuesPath, projectMRsPath);
+ });
+ it('should show only Project mergeRequest dropdown menu items when project issues are disabled', function() {
+ addBodyAttributes('project');
+ disableProjectIssues();
+ mockProjectOptions();
+ widget.searchInput.triggerHandler('focus');
+ const list = widget.wrap.find('.dropdown-menu').find('ul');
+ assertLinks(list, null, projectMRsPath);
+ });
+ it('should not show category related menu if there is text in the input', function() {
+ var link, list;
+ addBodyAttributes('project');
+ mockProjectOptions();
+ widget.searchInput.val('help');
+ widget.searchInput.triggerHandler('focus');
+ list = widget.wrap.find('.dropdown-menu').find('ul');
+ link = "a[href='" + projectIssuesPath + '/?assignee_id=' + userId + "']";
+ return expect(list.find(link).length).toBe(0);
+ });
+ it('should not submit the search form when selecting an autocomplete row with the keyboard', function() {
+ var ENTER = 13;
+ var DOWN = 40;
+ addBodyAttributes();
+ mockDashboardOptions(true);
+ var submitSpy = spyOnEvent('form', 'submit');
+ widget.searchInput.triggerHandler('focus');
+ widget.wrap.trigger($.Event('keydown', { which: DOWN }));
+ var enterKeyEvent = $.Event('keydown', { which: ENTER });
+ widget.searchInput.trigger(enterKeyEvent);
+ // This does not currently catch failing behavior. For security reasons,
+ // browsers will not trigger default behavior (form submit, in this
+ // example) on JavaScript-created keypresses.
+ expect(submitSpy).not.toHaveBeenTriggered();
});
-}).call(window);
+});
diff --git a/spec/javascripts/search_spec.js b/spec/javascripts/search_spec.js
new file mode 100644
index 00000000000..522851c584b
--- /dev/null
+++ b/spec/javascripts/search_spec.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import Api from '~/api';
+import Search from '~/pages/search/show/search';
+
+describe('Search', () => {
+ const fixturePath = 'search/show.html.raw';
+ const searchTerm = 'some search';
+ const fillDropdownInput = (dropdownSelector) => {
+ const dropdownElement = document.querySelector(dropdownSelector).parentNode;
+ const inputElement = dropdownElement.querySelector('.dropdown-input-field');
+ inputElement.value = searchTerm;
+ return inputElement;
+ };
+
+ preloadFixtures(fixturePath);
+
+ beforeEach(() => {
+ loadFixtures(fixturePath);
+ new Search(); // eslint-disable-line no-new
+ });
+
+ it('requests groups from backend when filtering', (done) => {
+ spyOn(Api, 'groups').and.callFake((term) => {
+ expect(term).toBe(searchTerm);
+ done();
+ });
+ const inputElement = fillDropdownInput('.js-search-group-dropdown');
+
+ $(inputElement).trigger('input');
+ });
+
+ it('requests projects from backend when filtering', (done) => {
+ spyOn(Api, 'projects').and.callFake((term) => {
+ expect(term).toBe(searchTerm);
+ done();
+ });
+ const inputElement = fillDropdownInput('.js-search-project-dropdown');
+
+ $(inputElement).trigger('input');
+ });
+});
diff --git a/spec/javascripts/settings_panels_spec.js b/spec/javascripts/settings_panels_spec.js
new file mode 100644
index 00000000000..d433f8c3e07
--- /dev/null
+++ b/spec/javascripts/settings_panels_spec.js
@@ -0,0 +1,29 @@
+import initSettingsPanels from '~/settings_panels';
+
+describe('Settings Panels', () => {
+ preloadFixtures('projects/ci_cd_settings.html.raw');
+
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html.raw');
+ });
+
+ describe('initSettingsPane', () => {
+ afterEach(() => {
+ location.hash = '';
+ });
+
+ it('should expand linked hash fragment panel', () => {
+ location.hash = '#js-general-pipeline-settings';
+
+ const pipelineSettingsPanel = document.querySelector('#js-general-pipeline-settings');
+ // Our test environment automatically expands everything so we need to clear that out first
+ pipelineSettingsPanel.classList.remove('expanded');
+
+ expect(pipelineSettingsPanel.classList.contains('expanded')).toBe(false);
+
+ initSettingsPanels();
+
+ expect(pipelineSettingsPanel.classList.contains('expanded')).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js
index 5d6a885d4cc..b0d714cbefb 100644
--- a/spec/javascripts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/shortcuts_issuable_spec.js
@@ -1,4 +1,5 @@
-import initCopyAsGFM from '~/behaviors/copy_as_gfm';
+import $ from 'jquery';
+import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/shortcuts_issuable';
initCopyAsGFM();
diff --git a/spec/javascripts/shortcuts_spec.js b/spec/javascripts/shortcuts_spec.js
index a2a609edef9..ee92295ef5e 100644
--- a/spec/javascripts/shortcuts_spec.js
+++ b/spec/javascripts/shortcuts_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Shortcuts from '~/shortcuts';
describe('Shortcuts', () => {
diff --git a/spec/javascripts/sidebar/assignee_title_spec.js b/spec/javascripts/sidebar/assignee_title_spec.js
index ac93f918ce4..509edba2036 100644
--- a/spec/javascripts/sidebar/assignee_title_spec.js
+++ b/spec/javascripts/sidebar/assignee_title_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import AssigneeTitle from '~/sidebar/components/assignees/assignee_title';
+import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
describe('AssigneeTitle component', () => {
let component;
diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js
index c9453a21189..4e4343812bd 100644
--- a/spec/javascripts/sidebar/assignees_spec.js
+++ b/spec/javascripts/sidebar/assignees_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import Assignee from '~/sidebar/components/assignees/assignees';
+import Assignee from '~/sidebar/components/assignees/assignees.vue';
import UsersMock from './mock_data';
import UsersMockHelper from '../helpers/user_mock_data_helper';
diff --git a/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js b/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js
index 88a33caf2e3..0c173062835 100644
--- a/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js
+++ b/spec/javascripts/sidebar/confidential_issue_sidebar_spec.js
@@ -62,4 +62,22 @@ describe('Confidential Issue Sidebar Block', () => {
done();
});
});
+
+ it('displays the edit form when opened from collapsed state', (done) => {
+ expect(vm1.edit).toBe(false);
+
+ vm1.$el.querySelector('.sidebar-collapsed-icon').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/lock/edit_form_buttons_spec.js b/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js
index b0ea8ae0206..deeea669de8 100644
--- a/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js
+++ b/spec/javascripts/sidebar/lock/edit_form_buttons_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import editFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('EditFormButtons', () => {
let vm1;
diff --git a/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js b/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js
index 696fca516bc..9abc3daf221 100644
--- a/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js
+++ b/spec/javascripts/sidebar/lock/lock_issue_sidebar_spec.js
@@ -68,4 +68,22 @@ describe('LockIssueSidebar', () => {
done();
});
});
+
+ it('displays the edit form when opened from collapsed state', (done) => {
+ expect(vm1.isLockDialogOpen).toBe(false);
+
+ vm1.$el.querySelector('.sidebar-collapsed-icon').click();
+
+ expect(vm1.isLockDialogOpen).toBe(true);
+
+ setTimeout(() => {
+ expect(
+ vm1.$el
+ .innerHTML
+ .includes('Unlock this issue?'),
+ ).toBe(true);
+
+ done();
+ });
+ });
});
diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js
index 3b094d20838..8b6e8b24f00 100644
--- a/spec/javascripts/sidebar/mock_data.js
+++ b/spec/javascripts/sidebar/mock_data.js
@@ -1,7 +1,5 @@
-/* eslint-disable quote-props*/
-
const RESPONSE_MAP = {
- 'GET': {
+ GET: {
'/gitlab-org/gitlab-shell/issues/5.json': {
id: 45,
iid: 5,
@@ -15,7 +13,6 @@ const RESPONSE_MAP = {
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,
@@ -28,7 +25,8 @@ const RESPONSE_MAP = {
username: 'user0',
id: 22,
state: 'active',
- avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/user0',
},
{
@@ -36,7 +34,8 @@ const RESPONSE_MAP = {
username: 'tajuana',
id: 18,
state: 'active',
- avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/tajuana',
},
{
@@ -44,7 +43,8 @@ const RESPONSE_MAP = {
username: 'michaele.will',
id: 16,
state: 'active',
- avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/michaele.will',
},
],
@@ -73,24 +73,27 @@ const RESPONSE_MAP = {
username: 'user0',
id: 22,
state: 'active',
- avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
- web_url: 'http: //localhost:3001/user0',
+ avatar_url:
+ 'https://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',
+ avatar_url:
+ 'https://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',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3001/michaele.will',
},
],
human_time_estimate: null,
@@ -101,24 +104,27 @@ const RESPONSE_MAP = {
username: 'user0',
id: 22,
state: 'active',
- avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
- web_url: 'http: //localhost:3001/user0',
+ avatar_url:
+ 'https://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',
+ avatar_url:
+ 'https://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',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
+ web_url: 'http://localhost:3001/michaele.will',
},
],
subscribed: true,
@@ -127,20 +133,21 @@ const RESPONSE_MAP = {
},
'/autocomplete/projects?project_id=15': [
{
- 'id': 0,
- 'name_with_namespace': 'No project',
- }, {
- 'id': 20,
- 'name_with_namespace': 'foo / bar',
+ id: 0,
+ name_with_namespace: 'No project',
+ },
+ {
+ id: 20,
+ name_with_namespace: 'foo / bar',
},
],
},
- 'PUT': {
+ PUT: {
'/gitlab-org/gitlab-shell/issues/5.json': {
data: {},
},
},
- 'POST': {
+ POST: {
'/gitlab-org/gitlab-shell/issues/5/move': {
id: 123,
iid: 5,
@@ -153,7 +160,6 @@ const RESPONSE_MAP = {
updated_by_id: 1,
created_at: '2017-06-27T19:54:42.437Z',
updated_at: '2017-08-18T03:39:49.222Z',
- deleted_at: null,
time_estimate: 0,
total_time_spent: 0,
human_time_estimate: null,
@@ -184,7 +190,8 @@ const mockData = {
id: 1,
name: 'Administrator',
username: 'root',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
rootPath: '/',
fullPath: '/gitlab-org/gitlab-shell',
@@ -196,19 +203,21 @@ const mockData = {
human_total_time_spent: null,
},
user: {
- avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ avatar: 'https://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: 1,
name: 'Administrator',
username: 'root',
},
};
-mockData.sidebarMockInterceptor = function (request, next) {
+mockData.sidebarMockInterceptor = function(request, next) {
const body = this.responseMap[request.method.toUpperCase()][request.url];
- next(request.respondWith(JSON.stringify(body), {
- status: 200,
- }));
+ next(
+ request.respondWith(JSON.stringify(body), {
+ status: 200,
+ }),
+ );
}.bind(mockData);
export default mockData;
diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js
index 30cc549c7c0..2a3b60c399c 100644
--- a/spec/javascripts/sidebar/participants_spec.js
+++ b/spec/javascripts/sidebar/participants_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import participants from '~/sidebar/components/participants/participants.vue';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const PARTICIPANT = {
id: 1,
diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js
index b97e24d9dcf..ebaaa6e806b 100644
--- a/spec/javascripts/sidebar/sidebar_assignees_spec.js
+++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js
@@ -1,10 +1,11 @@
+import _ from 'underscore';
import Vue from 'vue';
-import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees';
+import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import Mock from './mock_data';
-import mountComponent from '../helpers/vue_mount_component_helper';
describe('sidebar assignees', () => {
let vm;
diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js
index 9efd109b996..afa18cc127e 100644
--- a/spec/javascripts/sidebar/sidebar_mediator_spec.js
+++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import Vue from 'vue';
import * as urlUtils from '~/lib/utils/url_utility';
import SidebarMediator from '~/sidebar/sidebar_mediator';
diff --git a/spec/javascripts/sidebar/sidebar_move_issue_spec.js b/spec/javascripts/sidebar/sidebar_move_issue_spec.js
index 8b0d51bbcc8..d8e636cbdf0 100644
--- a/spec/javascripts/sidebar/sidebar_move_issue_spec.js
+++ b/spec/javascripts/sidebar/sidebar_move_issue_spec.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+import _ from 'underscore';
import Vue from 'vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
@@ -77,7 +79,7 @@ describe('SidebarMoveIssue', () => {
this.sidebarMoveIssue.onConfirmClicked();
expect(this.mediator.moveIssue).toHaveBeenCalled();
- expect(this.$confirmButton.attr('disabled')).toBe('disabled');
+ expect(this.$confirmButton.prop('disabled')).toBeTruthy();
expect(this.$confirmButton.hasClass('is-loading')).toBe(true);
});
@@ -92,7 +94,7 @@ describe('SidebarMoveIssue', () => {
// Wait for the move issue request to fail
setTimeout(() => {
expect(window.Flash).toHaveBeenCalled();
- expect(this.$confirmButton.attr('disabled')).toBe(undefined);
+ expect(this.$confirmButton.prop('disabled')).toBeFalsy();
expect(this.$confirmButton.hasClass('is-loading')).toBe(false);
done();
});
@@ -119,7 +121,7 @@ describe('SidebarMoveIssue', () => {
this.$content.find('.js-move-issue-dropdown-item').eq(0).trigger('click');
expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(0);
- expect(this.$confirmButton.attr('disabled')).toBe('disabled');
+ expect(this.$confirmButton.prop('disabled')).toBeTruthy();
done();
}, 0);
});
diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js
index ea4eae1e23f..3591f96ff87 100644
--- a/spec/javascripts/sidebar/sidebar_store_spec.js
+++ b/spec/javascripts/sidebar/sidebar_store_spec.js
@@ -6,14 +6,14 @@ const ASSIGNEE = {
id: 2,
name: 'gitlab user 2',
username: 'gitlab2',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
};
const ANOTHER_ASSINEE = {
id: 3,
name: 'gitlab user 3',
username: 'gitlab3',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
};
const PARTICIPANT = {
@@ -38,7 +38,7 @@ describe('Sidebar store', () => {
id: 1,
name: 'Administrator',
username: 'root',
- avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
editable: true,
rootPath: '/',
diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js
index a6113cb0bae..56a2543660b 100644
--- a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js
+++ b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js
@@ -4,7 +4,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import eventHub from '~/sidebar/event_hub';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import Mock from './mock_data';
describe('Sidebar Subscriptions', function () {
diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js
index 9b33dd02fb9..aee8f0acbb9 100644
--- a/spec/javascripts/sidebar/subscriptions_spec.js
+++ b/spec/javascripts/sidebar/subscriptions_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
-import mountComponent from '../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Subscriptions', function () {
let vm;
@@ -20,23 +20,23 @@ describe('Subscriptions', function () {
subscribed: undefined,
});
- expect(vm.$refs.loadingButton.loading).toBe(true);
- expect(vm.$refs.loadingButton.label).toBeUndefined();
+ expect(vm.$refs.toggleButton.isLoading).toBe(true);
+ expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass('is-loading');
});
- it('has "Subscribe" text when currently not subscribed', () => {
+ it('is toggled "off" when currently not subscribed', () => {
vm = mountComponent(Subscriptions, {
subscribed: false,
});
- expect(vm.$refs.loadingButton.label).toBe('Subscribe');
+ expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).not.toHaveClass('is-checked');
});
- it('has "Unsubscribe" text when currently not subscribed', () => {
+ it('is toggled "on" when currently subscribed', () => {
vm = mountComponent(Subscriptions, {
subscribed: true,
});
- expect(vm.$refs.loadingButton.label).toBe('Unsubscribe');
+ expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass('is-checked');
});
});
diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js
index a53e8a94d89..b1b03ef1e09 100644
--- a/spec/javascripts/signin_tabs_memoizer_spec.js
+++ b/spec/javascripts/signin_tabs_memoizer_spec.js
@@ -1,5 +1,5 @@
import AccessorUtilities from '~/lib/utils/accessor';
-import SigninTabsMemoizer from '~/signin_tabs_memoizer';
+import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer';
(() => {
describe('SigninTabsMemoizer', () => {
@@ -53,6 +53,13 @@ import SigninTabsMemoizer from '~/signin_tabs_memoizer';
expect(memo.readData()).toEqual('#standard');
});
+ it('overrides last selected tab with hash tag when given', () => {
+ window.location.hash = '#ldap';
+ createMemoizer();
+
+ expect(memo.readData()).toEqual('#ldap');
+ });
+
describe('class constructor', () => {
beforeEach(() => {
memo = createMemoizer();
diff --git a/spec/javascripts/smart_interval_spec.js b/spec/javascripts/smart_interval_spec.js
index 1c87fcec245..a54219d58c2 100644
--- a/spec/javascripts/smart_interval_spec.js
+++ b/spec/javascripts/smart_interval_spec.js
@@ -1,3 +1,5 @@
+import $ from 'jquery';
+import _ from 'underscore';
import SmartInterval from '~/smart_interval';
describe('SmartInterval', function () {
diff --git a/spec/javascripts/syntax_highlight_spec.js b/spec/javascripts/syntax_highlight_spec.js
index 763a15e710b..0d1fa680e00 100644
--- a/spec/javascripts/syntax_highlight_spec.js
+++ b/spec/javascripts/syntax_highlight_spec.js
@@ -1,5 +1,6 @@
/* eslint-disable space-before-function-paren, no-var, no-return-assign, quotes */
+import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight';
describe('Syntax Highlighter', function() {
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index 8145f905181..72671cbbda7 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -1,13 +1,17 @@
/* eslint-disable jasmine/no-global-setup */
import $ from 'jquery';
-import _ from 'underscore';
-import 'jasmine-jquery';
+import 'vendor/jasmine-jquery';
import '~/commons';
import Vue from 'vue';
import VueResource from 'vue-resource';
import Translate from '~/vue_shared/translate';
+import { getDefaultAdapter } from '~/lib/utils/axios_utils';
+import { FIXTURES_PATH, TEST_HOST } from './test_constants';
+
+import customMatchers from './matchers';
+
const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent);
Vue.config.devtools = !isHeadlessChrome;
Vue.config.productionTip = false;
@@ -28,17 +32,19 @@ Vue.use(VueResource);
Vue.use(Translate);
// enable test fixtures
-jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures';
-jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures';
+jasmine.getFixtures().fixturesPath = FIXTURES_PATH;
+jasmine.getJSONFixtures().fixturesPath = FIXTURES_PATH;
+
+beforeAll(() => jasmine.addMatchers(customMatchers));
// globalize common libraries
window.$ = window.jQuery = $;
-window._ = _;
// stub expected globals
window.gl = window.gl || {};
-window.gl.TEST_HOST = 'http://test.host';
+window.gl.TEST_HOST = TEST_HOST;
window.gon = window.gon || {};
+window.gon.test_env = true;
let hasUnhandledPromiseRejections = false;
@@ -71,6 +77,9 @@ if (testFile) {
testFile = testFile.replace(/\.js$/, '');
}
+const axiosDefaultAdapter = getDefaultAdapter();
+
+// render all of our tests
const testsContext = require.context('.', true, /_spec$/);
testsContext.keys().forEach(function (path) {
try {
@@ -107,6 +116,12 @@ describe('test errors', () => {
it('has no Vue error', () => {
expect(hasVueErrors).toBe(false);
});
+
+ it('restores axios adapter after mocking', () => {
+ if (getDefaultAdapter() !== axiosDefaultAdapter) {
+ fail('axios adapter is not restored! Did you forget a restore() on MockAdapter?');
+ }
+ });
});
// if we're generating coverage reports, make sure to include all files so
@@ -116,7 +131,9 @@ if (process.env.BABEL_ENV === 'coverage') {
// exempt these files from the coverage report
const troubleMakers = [
'./blob_edit/blob_bundle.js',
- './boards/boards_bundle.js',
+ './boards/components/modal/empty_state.js',
+ './boards/components/modal/footer.js',
+ './boards/components/modal/header.js',
'./cycle_analytics/cycle_analytics_bundle.js',
'./cycle_analytics/components/stage_plan_component.js',
'./cycle_analytics/components/stage_staging_component.js',
@@ -127,7 +144,6 @@ if (process.env.BABEL_ENV === 'coverage') {
'./diff_notes/components/resolve_count.js',
'./dispatcher.js',
'./environments/environments_bundle.js',
- './filtered_search/filtered_search_bundle.js',
'./graphs/graphs_bundle.js',
'./issuable/time_tracking/time_tracking_bundle.js',
'./main.js',
@@ -147,6 +163,9 @@ if (process.env.BABEL_ENV === 'coverage') {
describe('Uncovered files', function () {
const sourceFiles = require.context('~', true, /\.js$/);
+
+ $.holdReady(true);
+
sourceFiles.keys().forEach(function (path) {
// ignore if there is a matching spec file
if (testsContext.keys().indexOf(`${path.replace(/\.js$/, '')}_spec`) > -1) {
diff --git a/spec/javascripts/test_constants.js b/spec/javascripts/test_constants.js
new file mode 100644
index 00000000000..df59195e9f6
--- /dev/null
+++ b/spec/javascripts/test_constants.js
@@ -0,0 +1,4 @@
+export const FIXTURES_PATH = '/base/spec/javascripts/fixtures';
+export const TEST_HOST = 'http://test.host';
+
+export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`;
diff --git a/spec/javascripts/todos_spec.js b/spec/javascripts/todos_spec.js
index 59e16f0786e..898bbb3819b 100644
--- a/spec/javascripts/todos_spec.js
+++ b/spec/javascripts/todos_spec.js
@@ -1,5 +1,6 @@
+import $ from 'jquery';
import * as urlUtils from '~/lib/utils/url_utility';
-import Todos from '~/todos';
+import Todos from '~/pages/dashboard/todos/index/todos';
import '~/lib/utils/common_utils';
describe('Todos', () => {
diff --git a/spec/javascripts/toggle_buttons_spec.js b/spec/javascripts/toggle_buttons_spec.js
new file mode 100644
index 00000000000..17d0b94ebe0
--- /dev/null
+++ b/spec/javascripts/toggle_buttons_spec.js
@@ -0,0 +1,121 @@
+import $ from 'jquery';
+import setupToggleButtons from '~/toggle_buttons';
+import getSetTimeoutPromise from './helpers/set_timeout_promise_helper';
+
+function generateMarkup(isChecked = true) {
+ return `
+ <button type="button" class="${isChecked ? 'is-checked' : ''} js-project-feature-toggle">
+ <input type="hidden" class="js-project-feature-toggle-input" value="${isChecked}" />
+ </button>
+ `;
+}
+
+function setupFixture(isChecked, clickCallback) {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = generateMarkup(isChecked);
+
+ setupToggleButtons(wrapper, clickCallback);
+
+ return wrapper;
+}
+
+describe('ToggleButtons', () => {
+ describe('when input value is true', () => {
+ it('should initialize as checked', () => {
+ const wrapper = setupFixture(true);
+
+ expect(wrapper.querySelector('.js-project-feature-toggle').classList.contains('is-checked')).toEqual(true);
+ expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('true');
+ });
+
+ it('should toggle to unchecked when clicked', (done) => {
+ const wrapper = setupFixture(true);
+ const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
+
+ toggleButton.click();
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(toggleButton.classList.contains('is-checked')).toEqual(false);
+ expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('false');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('when input value is false', () => {
+ it('should initialize as unchecked', () => {
+ const wrapper = setupFixture(false);
+
+ expect(wrapper.querySelector('.js-project-feature-toggle').classList.contains('is-checked')).toEqual(false);
+ expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('false');
+ });
+
+ it('should toggle to checked when clicked', (done) => {
+ const wrapper = setupFixture(false);
+ const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
+
+ toggleButton.click();
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(toggleButton.classList.contains('is-checked')).toEqual(true);
+ expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('true');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ it('should emit `trigger-change` event', (done) => {
+ const changeSpy = jasmine.createSpy('changeEventHandler');
+ const wrapper = setupFixture(false);
+ const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
+ const input = wrapper.querySelector('.js-project-feature-toggle-input');
+
+ $(input).on('trigger-change', changeSpy);
+
+ toggleButton.click();
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(changeSpy).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('clickCallback', () => {
+ it('should show loading indicator while waiting', (done) => {
+ const isChecked = true;
+ const clickCallback = (newValue, toggleButton) => {
+ const input = toggleButton.querySelector('.js-project-feature-toggle-input');
+
+ expect(newValue).toEqual(false);
+
+ // Check for the loading state
+ expect(toggleButton.classList.contains('is-checked')).toEqual(false);
+ expect(toggleButton.classList.contains('is-loading')).toEqual(true);
+ expect(toggleButton.disabled).toEqual(true);
+ expect(input.value).toEqual('true');
+
+ // After the callback finishes, check that the loading state is gone
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(toggleButton.classList.contains('is-checked')).toEqual(false);
+ expect(toggleButton.classList.contains('is-loading')).toEqual(false);
+ expect(toggleButton.disabled).toEqual(false);
+ expect(input.value).toEqual('false');
+ })
+ .then(done)
+ .catch(done.fail);
+ };
+
+ const wrapper = setupFixture(isChecked, clickCallback);
+ const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
+
+ toggleButton.click();
+ });
+ });
+});
diff --git a/spec/javascripts/u2f/authenticate_spec.js b/spec/javascripts/u2f/authenticate_spec.js
index 29b15f3a782..39c47a5c06d 100644
--- a/spec/javascripts/u2f/authenticate_spec.js
+++ b/spec/javascripts/u2f/authenticate_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import U2FAuthenticate from '~/u2f/authenticate';
import 'vendor/u2f';
import MockU2FDevice from './mock_u2f_device';
@@ -5,7 +6,7 @@ import MockU2FDevice from './mock_u2f_device';
describe('U2FAuthenticate', () => {
preloadFixtures('u2f/authenticate.html.raw');
- beforeEach(() => {
+ beforeEach((done) => {
loadFixtures('u2f/authenticate.html.raw');
this.u2fDevice = new MockU2FDevice();
this.container = $('#js-authenticate-u2f');
@@ -22,7 +23,7 @@ describe('U2FAuthenticate', () => {
// bypass automatic form submission within renderAuthenticated
spyOn(this.component, 'renderAuthenticated').and.returnValue(true);
- return this.component.start();
+ this.component.start().then(done).catch(done.fail);
});
it('allows authenticating via a U2F device', () => {
@@ -34,7 +35,7 @@ describe('U2FAuthenticate', () => {
expect(this.component.renderAuthenticated).toHaveBeenCalledWith('{"deviceData":"this is data from the device"}');
});
- return describe('errors', () => {
+ describe('errors', () => {
it('displays an error message', () => {
const setupButton = this.container.find('#js-login-u2f-device');
setupButton.trigger('click');
diff --git a/spec/javascripts/u2f/register_spec.js b/spec/javascripts/u2f/register_spec.js
index b0051f11362..136b4cad737 100644
--- a/spec/javascripts/u2f/register_spec.js
+++ b/spec/javascripts/u2f/register_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import U2FRegister from '~/u2f/register';
import 'vendor/u2f';
import MockU2FDevice from './mock_u2f_device';
@@ -5,12 +6,12 @@ import MockU2FDevice from './mock_u2f_device';
describe('U2FRegister', () => {
preloadFixtures('u2f/register.html.raw');
- beforeEach(() => {
+ beforeEach((done) => {
loadFixtures('u2f/register.html.raw');
this.u2fDevice = new MockU2FDevice();
this.container = $('#js-register-u2f');
this.component = new U2FRegister(this.container, $('#js-register-u2f-templates'), {}, 'token');
- return this.component.start();
+ this.component.start().then(done).catch(done.fail);
});
it('allows registering a U2F device', () => {
diff --git a/spec/javascripts/u2f/util_spec.js b/spec/javascripts/u2f/util_spec.js
new file mode 100644
index 00000000000..4187183236f
--- /dev/null
+++ b/spec/javascripts/u2f/util_spec.js
@@ -0,0 +1,45 @@
+import { canInjectU2fApi } from '~/u2f/util';
+
+describe('U2F Utils', () => {
+ describe('canInjectU2fApi', () => {
+ it('returns false for Chrome < 41', () => {
+ const userAgent = 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.28 Safari/537.36';
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns true for Chrome >= 41', () => {
+ const userAgent = 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36';
+ expect(canInjectU2fApi(userAgent)).toBe(true);
+ });
+
+ it('returns false for Opera < 40', () => {
+ const userAgent = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36 OPR/32.0.1948.25';
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns true for Opera >= 40', () => {
+ const userAgent = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 OPR/43.0.2442.991';
+ expect(canInjectU2fApi(userAgent)).toBe(true);
+ });
+
+ it('returns false for Safari', () => {
+ const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.1.1 Safari/603.2.4';
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns false for Chrome on Android', () => {
+ const userAgent = 'Mozilla/5.0 (Linux; Android 7.0; VS988 Build/NRD90U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3145.0 Mobile Safari/537.36';
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns false for Chrome on iOS', () => {
+ const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1';
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns false for Safari on iOS', () => {
+ const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A356 Safari/604.1';
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+ });
+});
diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js
index 9637bd0414a..5f963e8c11e 100644
--- a/spec/javascripts/version_check_image_spec.js
+++ b/spec/javascripts/version_check_image_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import VersionCheckImage from '~/version_check_image';
import ClassSpecHelper from './helpers/class_spec_helper';
diff --git a/spec/javascripts/vue_mr_widget/components/deployment_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_spec.js
new file mode 100644
index 00000000000..ff8d54c029f
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/deployment_spec.js
@@ -0,0 +1,172 @@
+import Vue from 'vue';
+import * as urlUtils from '~/lib/utils/url_utility';
+import deploymentComponent from '~/vue_merge_request_widget/components/deployment.vue';
+import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+
+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);
+
+ return new Component({
+ el: document.createElement('div'),
+ propsData: { deployment: { ...deploymentMockData } },
+ });
+};
+
+describe('Deployment component', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('deployTimeago', () => {
+ it('return formatted date', () => {
+ const readable = getTimeago().format(deploymentMockData.deployed_at);
+ expect(vm.deployTimeago).toEqual(readable);
+ });
+ });
+
+ describe('hasExternalUrls', () => {
+ it('should return true', () => {
+ expect(vm.hasExternalUrls).toEqual(true);
+ });
+
+ it('should return false when deployment has no external_url_formatted', () => {
+ vm.deployment.external_url_formatted = null;
+
+ expect(vm.hasExternalUrls).toEqual(false);
+ });
+
+ it('should return false when deployment has no external_url', () => {
+ vm.deployment.external_url = null;
+
+ expect(vm.hasExternalUrls).toEqual(false);
+ });
+ });
+
+ describe('hasDeploymentTime', () => {
+ it('should return true', () => {
+ expect(vm.hasDeploymentTime).toEqual(true);
+ });
+
+ it('should return false when deployment has no deployed_at', () => {
+ vm.deployment.deployed_at = null;
+
+ expect(vm.hasDeploymentTime).toEqual(false);
+ });
+
+ it('should return false when deployment has no deployed_at_formatted', () => {
+ vm.deployment.deployed_at_formatted = null;
+
+ expect(vm.hasDeploymentTime).toEqual(false);
+ });
+ });
+
+ describe('hasDeploymentMeta', () => {
+ it('should return true', () => {
+ expect(vm.hasDeploymentMeta).toEqual(true);
+ });
+
+ it('should return false when deployment has no url', () => {
+ vm.deployment.url = null;
+
+ expect(vm.hasDeploymentMeta).toEqual(false);
+ });
+
+ it('should return false when deployment has no name', () => {
+ vm.deployment.name = null;
+
+ expect(vm.hasDeploymentMeta).toEqual(false);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('stopEnvironment', () => {
+ const url = '/foo/bar';
+ const returnPromise = () => new Promise((resolve) => {
+ resolve({
+ data: {
+ 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(urlUtils, 'visitUrl').and.returnValue(true);
+ vm = mockStopEnvironment();
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url);
+ setTimeout(() => {
+ expect(urlUtils.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 el;
+
+ beforeEach(() => {
+ vm = createComponent(deploymentMockData);
+ el = vm.$el;
+ });
+
+ it('renders deployment name', () => {
+ expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deploymentMockData.url);
+ expect(el.querySelector('.js-deploy-meta').innerText).toContain(deploymentMockData.name);
+ });
+
+ it('renders external URL', () => {
+ expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deploymentMockData.external_url);
+ expect(el.querySelector('.js-deploy-url').innerText).toContain(deploymentMockData.external_url_formatted);
+ });
+
+ it('renders stop button', () => {
+ expect(el.querySelector('.btn')).not.toBeNull();
+ });
+
+ it('renders deployment time', () => {
+ expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.deployTimeago);
+ });
+
+ it('renders metrics component', () => {
+ expect(el.querySelector('.js-mr-memory-usage')).not.toBeNull();
+ });
+ });
+});
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
index a750bc78f36..db27aa144d6 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js
@@ -1,39 +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 },
- });
-};
+import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetAuthor', () => {
- describe('props', () => {
- it('should have props', () => {
- const authorProp = authorComponent.props.author;
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(authorComponent);
+
+ vm = mountComponent(Component, {
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://localhost:3000/root',
+ avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ },
- expect(authorProp).toBeDefined();
- expect(authorProp.type instanceof Object).toBeTruthy();
- expect(authorProp.required).toBeTruthy();
});
});
- describe('template', () => {
- it('should have correct elements', () => {
- const el = createComponent().$el;
+ afterEach(() => {
+ vm.$destroy();
+ });
- 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);
- });
+ it('renders link with the author web url', () => {
+ expect(vm.$el.getAttribute('href')).toEqual('http://localhost:3000/root');
+ });
+
+ it('renders image with avatar url', () => {
+ expect(
+ vm.$el.querySelector('img').getAttribute('src'),
+ ).toEqual('http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon');
+ });
+
+ it('renders author name', () => {
+ expect(vm.$el.textContent.trim()).toEqual('Administrator');
});
});
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
index 515ddcbb875..6784b498c29 100644
--- 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
@@ -1,61 +1,40 @@
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,
- });
-};
+import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
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();
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(authorTimeComponent);
+
+ vm = mountComponent(Component, {
+ actionText: 'Merged by',
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://localhost:3000/root',
+ avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ },
+ dateTitle: '2017-03-23T23:02:00.807Z',
+ dateReadable: '12 hours ago',
});
});
- describe('components', () => {
- it('should have components', () => {
- expect(authorTimeComponent.components['mr-widget-author']).toBeDefined();
- });
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders provided action text', () => {
+ expect(vm.$el.textContent).toContain('Merged by');
});
- describe('template', () => {
- it('should have correct elements', () => {
- const el = createComponent().$el;
+ it('renders author', () => {
+ expect(vm.$el.textContent).toContain('Administrator');
+ });
- 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);
- });
+ it('renders provided time', () => {
+ expect(vm.$el.querySelector('time').getAttribute('title')).toEqual('2017-03-23T23:02:00.807Z');
+ expect(vm.$el.querySelector('time').textContent.trim()).toEqual('12 hours ago');
});
});
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
deleted file mode 100644
index db7d083065b..00000000000
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js
+++ /dev/null
@@ -1,181 +0,0 @@
-import Vue from 'vue';
-import * as urlUtils from '~/lib/utils/url_utility';
-import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment';
-import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
-import { getTimeago } from '~/lib/utils/datetime_utility';
-
-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 = 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(urlUtils, 'visitUrl').and.returnValue(true);
- vm = mockStopEnvironment();
-
- expect(window.confirm).toHaveBeenCalled();
- expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url);
- setTimeout(() => {
- expect(urlUtils.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
index 06f89fabf42..9b9c9656979 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js
@@ -1,104 +1,251 @@
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 },
- });
-};
+import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetHeader', () => {
- describe('props', () => {
- it('should have props', () => {
- const { mr } = headerComponent.props;
+ let vm;
+ let Component;
- expect(mr.type instanceof Object).toBeTruthy();
- expect(mr.required).toBeTruthy();
- });
+ beforeEach(() => {
+ Component = Vue.extend(headerComponent);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
});
describe('computed', () => {
- let vm;
- beforeEach(() => {
- vm = createComponent({
- divergedCommitsCount: 12,
- sourceBranch: 'mr-widget-refactor',
- sourceBranchLink: '/foo/bar/mr-widget-refactor',
- targetBranch: 'master',
+ describe('shouldShowCommitsBehindText', () => {
+ it('return true when there are divergedCommitsCount', () => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 12,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
+ targetBranch: 'master',
+ statusPath: 'abc',
+ },
+ });
+
+ expect(vm.shouldShowCommitsBehindText).toEqual(true);
+ });
+
+ it('returns false where there are no divergedComits count', () => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 0,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
+ targetBranch: 'master',
+ statusPath: 'abc',
+ },
+ });
+ expect(vm.shouldShowCommitsBehindText).toEqual(false);
});
});
- it('shouldShowCommitsBehindText', () => {
- expect(vm.shouldShowCommitsBehindText).toBeTruthy();
+ describe('commitsText', () => {
+ it('returns singular when there is one commit', () => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 1,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
+ targetBranch: 'master',
+ statusPath: 'abc',
+ },
+ });
- vm.mr.divergedCommitsCount = 0;
- expect(vm.shouldShowCommitsBehindText).toBeFalsy();
- });
+ expect(vm.commitsText).toEqual('1 commit behind');
+ });
- it('commitsText', () => {
- expect(vm.commitsText).toEqual('commits');
+ it('returns plural when there is more than one commit', () => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 2,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>',
+ targetBranch: 'master',
+ statusPath: 'abc',
+ },
+ });
- vm.mr.divergedCommitsCount = 1;
- expect(vm.commitsText).toEqual('commit');
+ expect(vm.commitsText).toEqual('2 commits behind');
+ });
});
});
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;
+ describe('common elements', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 12,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
+ sourceBranchRemoved: false,
+ targetBranchPath: 'foo/bar/commits-path',
+ targetBranchTreePath: 'foo/bar/tree/path',
+ targetBranch: 'master',
+ isOpen: true,
+ emailPatchesPath: '/mr/email-patches',
+ plainDiffPath: '/mr/plainDiffPath',
+ statusPath: 'abc',
+ },
+ });
+ });
+
+ it('renders source branch link', () => {
+ expect(vm.$el.querySelector('.js-source-branch').innerHTML).toEqual(
+ '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
+ );
+ });
+
+ it('renders clipboard button', () => {
+ expect(vm.$el.querySelector('.btn-clipboard')).not.toEqual(null);
+ });
+
+ it('renders target branch', () => {
+ expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master');
+ });
});
- 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);
+ describe('with an open merge request', () => {
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 12,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
+ sourceBranchRemoved: false,
+ targetBranchPath: 'foo/bar/commits-path',
+ targetBranchTreePath: 'foo/bar/tree/path',
+ targetBranch: 'master',
+ isOpen: true,
+ emailPatchesPath: '/mr/email-patches',
+ plainDiffPath: '/mr/plainDiffPath',
+ statusPath: 'abc',
+ },
+ });
+ });
+
+ it('renders checkout branch button with modal trigger', () => {
+ const button = vm.$el.querySelector('.js-check-out-branch');
+
+ expect(button.textContent.trim()).toEqual('Check out branch');
+ expect(button.getAttribute('data-target')).toEqual('#modal_merge_info');
+ expect(button.getAttribute('data-toggle')).toEqual('modal');
+ });
+
+ it('renders web ide button', () => {
+ const button = vm.$el.querySelector('.js-web-ide');
+
+ expect(button.textContent.trim()).toEqual('Web IDE');
+ expect(button.getAttribute('href')).toEqual('undefined/-/ide/projectabc');
+ });
+
+ it('renders download dropdown with links', () => {
+ expect(vm.$el.querySelector('.js-download-email-patches').textContent.trim()).toEqual(
+ 'Email patches',
+ );
+
+ expect(vm.$el.querySelector('.js-download-email-patches').getAttribute('href')).toEqual(
+ '/mr/email-patches',
+ );
+
+ expect(vm.$el.querySelector('.js-download-plain-diff').textContent.trim()).toEqual(
+ 'Plain diff',
+ );
+
+ expect(vm.$el.querySelector('.js-download-plain-diff').getAttribute('href')).toEqual(
+ '/mr/plainDiffPath',
+ );
+ });
+ });
+
+ describe('with a closed merge request', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 12,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
+ sourceBranchRemoved: false,
+ targetBranchPath: 'foo/bar/commits-path',
+ targetBranchTreePath: 'foo/bar/tree/path',
+ targetBranch: 'master',
+ isOpen: false,
+ emailPatchesPath: '/mr/email-patches',
+ plainDiffPath: '/mr/plainDiffPath',
+ statusPath: 'abc',
+ },
+ });
+ });
+
+ it('does not render checkout branch button with modal trigger', () => {
+ const button = vm.$el.querySelector('.js-check-out-branch');
+
+ expect(button).toEqual(null);
+ });
+
+ it('does not render download dropdown with links', () => {
+ expect(vm.$el.querySelector('.js-download-email-patches')).toEqual(null);
+
+ expect(vm.$el.querySelector('.js-download-plain-diff')).toEqual(null);
+ });
});
- 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();
+ describe('without diverged commits', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 0,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
+ sourceBranchRemoved: false,
+ targetBranchPath: 'foo/bar/commits-path',
+ targetBranchTreePath: 'foo/bar/tree/path',
+ targetBranch: 'master',
+ isOpen: true,
+ emailPatchesPath: '/mr/email-patches',
+ plainDiffPath: '/mr/plainDiffPath',
+ statusPath: 'abc',
+ },
+ });
+ });
+
+ it('does not render diverged commits info', () => {
+ expect(vm.$el.querySelector('.diverged-commits-count')).toEqual(null);
});
});
- 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();
+ describe('with diverged commits', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ divergedCommitsCount: 12,
+ sourceBranch: 'mr-widget-refactor',
+ sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
+ sourceBranchRemoved: false,
+ targetBranchPath: 'foo/bar/commits-path',
+ targetBranchTreePath: 'foo/bar/tree/path',
+ targetBranch: 'master',
+ isOpen: true,
+ emailPatchesPath: '/mr/email-patches',
+ plainDiffPath: '/mr/plainDiffPath',
+ statusPath: 'abc',
+ },
+ });
+ });
+
+ it('renders diverged commits info', () => {
+ expect(vm.$el.querySelector('.diverged-commits-count').textContent.trim()).toEqual(
+ '(12 commits behind)',
+ );
});
});
});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js
new file mode 100644
index 00000000000..cee22d5342a
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import maintainerEditComponent from '~/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('RWidgetMaintainerEdit', () => {
+ let Component;
+ let vm;
+
+ beforeEach(() => {
+ Component = Vue.extend(maintainerEditComponent);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when a maintainer is allowed to edit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ maintainerEditAllowed: true,
+ });
+ });
+
+ it('it renders the message', () => {
+ expect(vm.$el.textContent.trim()).toEqual('Allows edits from maintainers');
+ });
+ });
+
+ describe('when a maintainer is not allowed to edit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ maintainerEditAllowed: false,
+ });
+ });
+
+ it('hides the message', () => {
+ expect(vm.$el.textContent.trim()).toEqual('');
+ });
+ });
+});
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
index 2ae3adc1f93..d9c03296857 100644
--- 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
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import memoryUsageComponent from '~/vue_merge_request_widget/components/mr_widget_memory_usage';
+import MemoryUsage from '~/vue_merge_request_widget/components/memory_usage.vue';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
const url = '/root/acets-review-apps/environments/15/deployments/1/metrics';
@@ -23,9 +23,7 @@ const metricsMockData = {
memory_values: [
{
metric: {},
- values: [
- [1493716685, '4.30859375'],
- ],
+ values: [[1493716685, '4.30859375']],
},
],
},
@@ -34,7 +32,7 @@ const metricsMockData = {
};
const createComponent = () => {
- const Component = Vue.extend(memoryUsageComponent);
+ const Component = Vue.extend(MemoryUsage);
return new Component({
el: document.createElement('div'),
@@ -53,7 +51,8 @@ const createComponent = () => {
const messages = {
loadingMetrics: 'Loading deployment statistics',
- hasMetrics: 'Memory usage unchanged from 0MB to 0MB',
+ hasMetrics:
+ '<a href="/root/acets-review-apps/environments/15/metrics"> Memory </a> usage is <b> unchanged </b> at 0MB',
loadFailed: 'Failed to load deployment statistics',
metricsUnavailable: 'Deployment statistics are not available currently',
};
@@ -67,21 +66,9 @@ describe('MemoryUsage', () => {
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();
+ const data = MemoryUsage.data();
expect(Array.isArray(data.memoryMetrics)).toBeTruthy();
expect(data.memoryMetrics.length).toBe(0);
@@ -104,26 +91,26 @@ describe('MemoryUsage', () => {
});
describe('computed', () => {
- describe('memoryChangeType', () => {
- it('should return "increased" if memoryFrom value is less than memoryTo value', () => {
+ describe('memoryChangeMessage', () => {
+ it('should contain "increased" if memoryFrom value is less than memoryTo value', () => {
vm.memoryFrom = 4.28;
vm.memoryTo = 9.13;
- expect(vm.memoryChangeType).toEqual('increased');
+ expect(vm.memoryChangeMessage.indexOf('increased')).not.toEqual('-1');
});
- it('should return "decreased" if memoryFrom value is less than memoryTo value', () => {
+ it('should contain "decreased" if memoryFrom value is less than memoryTo value', () => {
vm.memoryFrom = 9.13;
vm.memoryTo = 4.28;
- expect(vm.memoryChangeType).toEqual('decreased');
+ expect(vm.memoryChangeMessage.indexOf('decreased')).not.toEqual('-1');
});
- it('should return "unchanged" if memoryFrom value equal to memoryTo value', () => {
+ it('should contain "unchanged" if memoryFrom value equal to memoryTo value', () => {
vm.memoryFrom = 1;
vm.memoryTo = 1;
- expect(vm.memoryChangeType).toEqual('unchanged');
+ expect(vm.memoryChangeMessage.indexOf('unchanged')).not.toEqual('-1');
});
});
});
@@ -142,7 +129,13 @@ describe('MemoryUsage', () => {
describe('computeGraphData', () => {
it('should populate sparkline graph', () => {
vm.computeGraphData(metrics, deployment_time);
- const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm;
+ const {
+ hasMetrics,
+ memoryMetrics,
+ deploymentTime,
+ memoryFrom,
+ memoryTo,
+ } = vm;
expect(hasMetrics).toBeTruthy();
expect(memoryMetrics.length > 0).toBeTruthy();
@@ -153,22 +146,26 @@ describe('MemoryUsage', () => {
});
describe('loadMetrics', () => {
- const returnServicePromise = () => new Promise((resolve) => {
- resolve({
- json() {
- return metricsMockData;
- },
+ const returnServicePromise = () =>
+ new Promise(resolve => {
+ resolve({
+ data: metricsMockData,
+ });
});
- });
- it('should load metrics data using MRWidgetService', (done) => {
- spyOn(MRWidgetService, 'fetchMetrics').and.returnValue(returnServicePromise(true));
+ 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);
+ expect(vm.computeGraphData).toHaveBeenCalledWith(
+ metrics,
+ deployment_time,
+ );
done();
}, 333);
});
@@ -181,51 +178,67 @@ describe('MemoryUsage', () => {
expect(el.querySelector('.js-usage-info')).toBeDefined();
});
- it('should show loading metrics message while metrics are being loaded', (done) => {
+ 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);
+ 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) => {
+ 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);
+ expect(el.querySelector('.js-usage-info').innerText).toContain(
+ messages.hasMetrics,
+ );
done();
});
});
- it('should show failure message when metrics loading failed', (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);
+ 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) => {
+ 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);
+ 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
index 4da4fc82c26..367c499daaf 100644
--- 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
@@ -1,51 +1,56 @@
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,
- });
-};
+import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
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('');
- });
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(mergeHelpComponent);
});
- describe('template', () => {
- let vm;
- let el;
+ afterEach(() => {
+ vm.$destroy();
+ });
+ describe('with missing branch', () => {
beforeEach(() => {
- vm = createComponent();
- el = vm.$el;
+ vm = mountComponent(Component, {
+ missingBranch: 'this-is-not-the-branch-you-are-looking-for',
+ });
});
- it('should have the correct elements', () => {
- expect(el.classList.contains('mr-widget-help')).toBeTruthy();
- expect(el.textContent).toContain(text);
+ it('renders missing branch information', () => {
+ expect(
+ vm.$el.textContent.trim().replace(/[\r\n]+/g, ' ').replace(/\s\s+/g, ' '),
+ ).toEqual(
+ 'If the this-is-not-the-branch-you-are-looking-for branch exists in your local repository, you can merge this merge request manually using the command line',
+ );
});
- 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();
- });
+ it('renders button to open help modal', () => {
+ expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-target')).toEqual('#modal_merge_info');
+ expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-toggle')).toEqual('modal');
+ });
+ });
+
+ describe('without missing branch', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component);
+ });
+
+ it('renders information about how to merge manually', () => {
+ expect(
+ vm.$el.textContent.trim().replace(/[\r\n]+/g, ' ').replace(/\s\s+/g, ' '),
+ ).toEqual(
+ 'You can merge this merge request manually using the command line',
+ );
+ });
+
+ it('renders element to open a modal', () => {
+ expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-target')).toEqual('#modal_merge_info');
+ expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-toggle')).toEqual('modal');
});
});
});
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
index d7af956c9c1..ea8007d2029 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mockData from '../mock_data';
describe('MRWidgetPipeline', () => {
@@ -113,6 +113,46 @@ describe('MRWidgetPipeline', () => {
});
});
+ describe('without commit path', () => {
+ beforeEach(() => {
+ const mockCopy = Object.assign({}, mockData);
+ delete mockCopy.pipeline.commit;
+
+ vm = mountComponent(Component, {
+ pipeline: mockCopy.pipeline,
+ hasCi: true,
+ ciStatus: 'success',
+ });
+ });
+
+ it('should render pipeline ID', () => {
+ expect(
+ vm.$el.querySelector('.pipeline-id').textContent.trim(),
+ ).toEqual(`#${mockData.pipeline.id}`);
+ });
+
+ it('should render pipeline status', () => {
+ expect(
+ vm.$el.querySelector('.media-body').textContent.trim(),
+ ).toContain(mockData.pipeline.details.status.label);
+
+ expect(
+ vm.$el.querySelector('.js-commit-link'),
+ ).toBeNull();
+ });
+
+ it('should render pipeline graph', () => {
+ expect(vm.$el.querySelector('.mr-widget-pipeline-graph')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.stage-container').length).toEqual(mockData.pipeline.details.stages.length);
+ });
+
+ it('should render coverage information', () => {
+ expect(
+ vm.$el.querySelector('.media-body').textContent,
+ ).toContain(`Coverage ${mockData.pipeline.coverage}`);
+ });
+ });
+
describe('without coverage', () => {
it('should not render a coverage', () => {
const mockCopy = Object.assign({}, mockData);
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js
new file mode 100644
index 00000000000..b453d180a40
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js
@@ -0,0 +1,115 @@
+import Vue from 'vue';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+import component from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('Merge request widget rebase component', () => {
+ let Component;
+ let vm;
+ beforeEach(() => {
+ Component = Vue.extend(component);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('While rebasing', () => {
+ it('should show progress message', () => {
+ vm = mountComponent(Component, {
+ mr: { rebaseInProgress: true },
+ service: {},
+ });
+
+ expect(
+ vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(),
+ ).toContain('Rebase in progress');
+ });
+ });
+
+ describe('With permissions', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ rebaseInProgress: false,
+ canPushToSourceBranch: true,
+ },
+ service: {},
+ });
+ });
+
+ it('it should render rebase button and warning message', () => {
+ const text = vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim();
+ expect(text).toContain('Fast-forward merge is not possible.');
+ expect(text).toContain('Rebase the source branch onto the target branch or merge target');
+ expect(text).toContain('branch into source branch to allow this merge request to be merged.');
+ });
+
+ it('it should render error message when it fails', (done) => {
+ vm.rebasingError = 'Something went wrong!';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(),
+ ).toContain('Something went wrong!');
+ done();
+ });
+ });
+ });
+
+ describe('Without permissions', () => {
+ it('should render a message explaining user does not have permissions', () => {
+ vm = mountComponent(Component, {
+ mr: {
+ rebaseInProgress: false,
+ canPushToSourceBranch: false,
+ targetBranch: 'foo',
+ },
+ service: {},
+ });
+
+ const text = vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim();
+
+ expect(text).toContain('Fast-forward merge is not possible.');
+ expect(text).toContain('Rebase the source branch onto');
+ expect(text).toContain('foo');
+ expect(text).toContain('to allow this merge request to be merged.');
+ });
+ });
+
+ describe('methods', () => {
+ it('checkRebaseStatus', (done) => {
+ spyOn(eventHub, '$emit');
+ vm = mountComponent(Component, {
+ mr: {},
+ service: {
+ rebase() {
+ return Promise.resolve();
+ },
+ poll() {
+ return Promise.resolve({
+ data: {
+ rebase_in_progress: false,
+ merge_error: null,
+ },
+ });
+ },
+ },
+ });
+
+ vm.rebase();
+
+ // Wait for the rebase request
+ vm.$nextTick()
+ // Wait for the polling request
+ .then(vm.$nextTick())
+ // Wait for the eventHub to be called
+ .then(vm.$nextTick())
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
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
index f86fb6a0b4b..5de6ac4079d 100644
--- 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
@@ -1,117 +1,82 @@
import Vue from 'vue';
-import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links';
+import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-const createComponent = (data) => {
- const Component = Vue.extend(relatedLinksComponent);
+describe('MRWidgetRelatedLinks', () => {
+ let vm;
- return new Component({
- el: document.createElement('div'),
- propsData: data,
- });
-};
+ const createComponent = (data) => {
+ const Component = Vue.extend(relatedLinksComponent);
-describe('MRWidgetRelatedLinks', () => {
- describe('props', () => {
- it('should have props', () => {
- const { relatedLinks } = relatedLinksComponent.props;
+ return mountComponent(Component, data);
+ };
- expect(relatedLinks).toBeDefined();
- expect(relatedLinks.type instanceof Object).toBeTruthy();
- expect(relatedLinks.required).toBeTruthy();
- });
+ afterEach(() => {
+ vm.$destroy();
});
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);
+ it('returns Closes text for open merge request', () => {
+ vm = createComponent({ state: 'open', relatedLinks: {} });
expect(vm.closesText).toEqual('Closes');
});
it('returns correct text for closed merge request', () => {
- data.state = 'closed';
- const vm = createComponent(data);
+ vm = createComponent({ state: 'closed', relatedLinks: {} });
expect(vm.closesText).toEqual('Did not close');
});
it('returns correct tense for merged request', () => {
- data.state = 'merged';
- const vm = createComponent(data);
+ vm = createComponent({ state: 'merged', relatedLinks: {} });
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 closing issues text', () => {
+ vm = createComponent({
+ relatedLinks: {
+ closing: '<a href="#">#23</a> and <a>#42</a>',
+ },
});
+ const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim();
- it('should have only have mentioned issues text', () => {
- const vm = createComponent({
- relatedLinks: {
- mentioned: '<a href="#">#7</a>',
- },
- });
+ expect(content).toContain('Closes #23 and #42');
+ expect(content).not.toContain('Mentions');
+ });
- expect(vm.$el.innerText).toContain('Mentions #7');
- expect(vm.$el.innerText).not.toContain('Closes');
+ it('should have only have mentioned issues text', () => {
+ vm = createComponent({
+ relatedLinks: {
+ mentioned: '<a href="#">#7</a>',
+ },
});
- 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(vm.$el.innerText).toContain('Mentions #7');
+ expect(vm.$el.innerText).not.toContain('Closes');
+ });
- expect(content).toContain('Closes #7');
- expect(content).toContain('Mentions #23 and #42');
+ it('should have closing and mentioned issues at the same time', () => {
+ 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();
- it('should have assing issues link', () => {
- const vm = createComponent({
- relatedLinks: {
- assignToMe: '<a href="#">Assign yourself to these issues</a>',
- },
- });
+ expect(content).toContain('Closes #7');
+ expect(content).toContain('Mentions #23 and #42');
+ });
- expect(vm.$el.innerText).toContain('Assign yourself to these issues');
+ it('should have assing issues link', () => {
+ 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/mr_widget_status_icon_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_status_icon_spec.js
new file mode 100644
index 00000000000..0b25500caf4
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_status_icon_spec.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import mrStatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('MR widget status icon component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(mrStatusIcon);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('while loading', () => {
+ it('renders loading icon', () => {
+ vm = mountComponent(Component, { status: 'loading' });
+ expect(vm.$el.querySelector('.mr-widget-icon i').classList).toContain('fa-spinner');
+ });
+ });
+
+ describe('with status icon', () => {
+ it('renders ci status icon', () => {
+ vm = mountComponent(Component, { status: 'failed' });
+ expect(vm.$el.querySelector('.js-ci-status-icon-failed')).not.toBeNull();
+ });
+ });
+
+ describe('with disabled button', () => {
+ it('renders a disabled button', () => {
+ vm = mountComponent(Component, { status: 'failed', showDisabledButton: true });
+ expect(vm.$el.querySelector('.js-disabled-merge-button').textContent.trim()).toEqual('Merge');
+ });
+ });
+
+ describe('without disabled button', () => {
+ it('does not render a disabled button', () => {
+ vm = mountComponent(Component, { status: 'failed' });
+ expect(vm.$el.querySelector('.js-disabled-merge-button')).toBeNull();
+ });
+ });
+});
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
index 4869fb17d96..e818f87b4c8 100644
--- 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
@@ -1,18 +1,31 @@
import Vue from 'vue';
-import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived';
+import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetArchived', () => {
- describe('template', () => {
- it('should have correct elements', () => {
- const Component = Vue.extend(archivedComponent);
- const el = new Component({
- el: document.createElement('div'),
- }).$el;
+ let vm;
- 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');
- });
+ beforeEach(() => {
+ const Component = Vue.extend(archivedComponent);
+ vm = mountComponent(Component);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a ci status failed icon', () => {
+ expect(vm.$el.querySelector('.ci-status-icon')).not.toBeNull();
+ });
+
+ it('renders a disabled button', () => {
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toEqual('disabled');
+ expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Merge');
+ });
+
+ it('renders information', () => {
+ expect(
+ vm.$el.querySelector('.bold').textContent.trim(),
+ ).toEqual('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
index 6042d7384d5..d069dc3fcc6 100644
--- 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
@@ -1,32 +1,47 @@
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';
+import autoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue';
+import eventHub from '~/vue_merge_request_widget/event_hub';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetAutoMergeFailed', () => {
- describe('props', () => {
- it('should have props', () => {
- const mrProp = autoMergeFailedComponent.props.mr;
+ let vm;
+ const mergeError = 'This is the merge error';
- expect(mrProp.type instanceof Object).toBeTruthy();
- expect(mrProp.required).toBeTruthy();
+ beforeEach(() => {
+ const Component = Vue.extend(autoMergeFailedComponent);
+ vm = mountComponent(Component, {
+ mr: { mergeError },
});
});
- describe('template', () => {
- const Component = Vue.extend(autoMergeFailedComponent);
- const vm = new Component({
- el: document.createElement('div'),
- propsData: {
- mr: { mergeError },
- },
- });
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders failed message', () => {
+ expect(vm.$el.textContent).toContain('This merge request failed to be merged automatically');
+ });
+
+ it('renders merge error provided', () => {
+ expect(vm.$el.innerText).toContain(mergeError);
+ });
+
+ it('render refresh button', () => {
+ expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Refresh');
+ });
+
+ it('emits event and shows loading icon when button is clicked', (done) => {
+ spyOn(eventHub, '$emit');
+ vm.$el.querySelector('button').click();
+
+ expect(eventHub.$emit.calls.argsFor(0)[0]).toEqual('MRWidgetUpdateRequested');
- 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);
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toEqual('disabled');
+ expect(
+ vm.$el.querySelector('button i').classList,
+ ).toContain('fa-spinner');
+ done();
});
});
});
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
index 6b7aa935ad3..658612aad3c 100644
--- 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
@@ -1,19 +1,29 @@
import Vue from 'vue';
-import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking';
+import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetChecking', () => {
- describe('template', () => {
- it('should have correct elements', () => {
- const Component = Vue.extend(checkingComponent);
- const el = new Component({
- el: document.createElement('div'),
- }).$el;
+ let Component;
+ let vm;
- 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();
- });
+ beforeEach(() => {
+ Component = Vue.extend(checkingComponent);
+ vm = mountComponent(Component);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders disabled button', () => {
+ expect(vm.$el.querySelector('button').getAttribute('disabled')).toEqual('disabled');
+ });
+
+ it('renders loading icon', () => {
+ expect(vm.$el.querySelector('.mr-widget-icon i').classList).toContain('fa-spinner');
+ });
+
+ it('renders information about merging', () => {
+ expect(vm.$el.querySelector('.media-body').textContent.trim()).toEqual('Checking ability to merge automatically');
});
});
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
index d23b558f4ea..0e3c134d3ac 100644
--- 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
@@ -1,71 +1,58 @@
import Vue from 'vue';
-import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed';
-
-const mr = {
- targetBranch: 'good-branch',
- targetBranchPath: '/good-branch',
- closedEvent: {
- author: {
- name: 'Fatih Acet',
- username: 'fatihacet',
- },
- updatedAt: 'closedEventUpdatedAt',
- formattedUpdatedAt: '',
- },
- updatedAt: 'mrUpdatedAt',
- closedAt: '1 day ago',
-};
-
-const createComponent = () => {
- const Component = Vue.extend(closedComponent);
-
- return new Component({
- el: document.createElement('div'),
- propsData: { mr },
- });
-};
+import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetClosed', () => {
- describe('props', () => {
- it('should have props', () => {
- const mrProp = closedComponent.props.mr;
-
- expect(mrProp.type instanceof Object).toBeTruthy();
- expect(mrProp.required).toBeTruthy();
- });
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(closedComponent);
+ vm = mountComponent(Component, { mr: {
+ metrics: {
+ mergedBy: {},
+ closedBy: {
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://localhost:3000/root',
+ avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ },
+ mergedAt: 'Jan 24, 2018 1:02pm GMT+0000',
+ closedAt: 'Jan 24, 2018 1:02pm GMT+0000',
+ readableMergedAt: '',
+ readableClosedAt: 'less than a minute ago',
+ },
+ targetBranchPath: '/twitter/flight/commits/so_long_jquery',
+ targetBranch: 'so_long_jquery',
+ } });
});
- describe('components', () => {
- it('should have components added', () => {
- expect(closedComponent.components['mr-widget-author-and-time']).toBeDefined();
- });
+ afterEach(() => {
+ vm.$destroy();
});
- describe('template', () => {
- let vm;
- let el;
+ it('renders warning icon', () => {
+ expect(vm.$el.querySelector('.js-ci-status-icon-warning')).not.toBeNull();
+ });
- beforeEach(() => {
- vm = createComponent();
- el = vm.$el;
- });
+ it('renders closed by information with author and time', () => {
+ expect(
+ vm.$el.querySelector('.js-mr-widget-author').textContent.trim().replace(/\s\s+/g, ' '),
+ ).toContain(
+ 'Closed by Administrator less than a minute ago',
+ );
+ });
- afterEach(() => {
- vm.$destroy();
- });
+ it('links to the user that closed the MR', () => {
+ expect(vm.$el.querySelector('.author-link').getAttribute('href')).toEqual('http://localhost:3000/root');
+ });
- it('should have correct elements', () => {
- expect(el.querySelector('h4').textContent).toContain('Closed by');
- expect(el.querySelector('h4').textContent).toContain(mr.closedEvent.author.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);
- });
+ it('renders information about the changes not being merged', () => {
+ expect(
+ vm.$el.querySelector('.mr-info-list').textContent.trim().replace(/\s\s+/g, ' '),
+ ).toContain('The changes were not merged into so_long_jquery');
+ });
- it('should use closedEvent updatedAt as tooltip title', () => {
- expect(
- el.querySelector('time').getAttribute('title'),
- ).toBe('closedEventUpdatedAt');
- });
+ it('renders link for target branch', () => {
+ expect(vm.$el.querySelector('.label-branch').getAttribute('href')).toEqual('/twitter/flight/commits/so_long_jquery');
});
});
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
index 5d4c7ec09dc..fcbd8169bc7 100644
--- 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
@@ -1,105 +1,87 @@
import Vue from 'vue';
-import conflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
-
-const ConflictsComponent = Vue.extend(conflictsComponent);
-const path = '/conflicts';
+import conflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import removeBreakLine from 'spec/helpers/vue_component_helper';
describe('MRWidgetConflicts', () => {
- describe('props', () => {
- it('should have props', () => {
- const { mr } = conflictsComponent.props;
+ let Component;
+ let vm;
+ const path = '/conflicts';
- expect(mr.type instanceof Object).toBeTruthy();
- expect(mr.required).toBeTruthy();
- });
+ beforeEach(() => {
+ Component = Vue.extend(conflictsComponent);
});
- describe('template', () => {
- describe('when allowed to merge', () => {
- let vm;
-
- beforeEach(() => {
- vm = mountComponent(ConflictsComponent, {
- mr: {
- canMerge: true,
- conflictResolutionPath: path,
- },
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('should tell you about conflicts without bothering other people', () => {
- expect(vm.$el.textContent).toContain('There are merge conflicts');
- expect(vm.$el.textContent).not.toContain('ask someone with write access');
- });
-
- it('should allow you to resolve the conflicts', () => {
- const resolveButton = vm.$el.querySelector('.js-resolve-conflicts-button');
+ afterEach(() => {
+ vm.$destroy();
+ });
- expect(resolveButton.textContent).toContain('Resolve conflicts');
- expect(resolveButton.getAttribute('href')).toEqual(path);
+ describe('when allowed to merge', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ canMerge: true,
+ conflictResolutionPath: path,
+ },
});
+ });
- it('should have merge buttons', () => {
- const mergeButton = vm.$el.querySelector('.js-disabled-merge-button');
- const mergeLocallyButton = vm.$el.querySelector('.js-merge-locally-button');
-
- expect(mergeButton.textContent).toContain('Merge');
- expect(mergeButton.disabled).toBeTruthy();
- expect(mergeButton.classList.contains('btn-success')).toEqual(true);
- expect(mergeLocallyButton.textContent).toContain('Merge locally');
- });
+ it('should tell you about conflicts without bothering other people', () => {
+ expect(vm.$el.textContent).toContain('There are merge conflicts');
+ expect(vm.$el.textContent).not.toContain('ask someone with write access');
});
- describe('when user does not have permission to merge', () => {
- let vm;
+ it('should allow you to resolve the conflicts', () => {
+ const resolveButton = vm.$el.querySelector('.js-resolve-conflicts-button');
- beforeEach(() => {
- vm = mountComponent(ConflictsComponent, {
- mr: {
- canMerge: false,
- },
- });
- });
+ expect(resolveButton.textContent).toContain('Resolve conflicts');
+ expect(resolveButton.getAttribute('href')).toEqual(path);
+ });
- afterEach(() => {
- vm.$destroy();
- });
+ it('should have merge buttons', () => {
+ const mergeButton = vm.$el.querySelector('.js-disabled-merge-button');
+ const mergeLocallyButton = vm.$el.querySelector('.js-merge-locally-button');
- it('should show proper message', () => {
- expect(vm.$el.textContent).toContain('ask someone with write access');
- });
+ expect(mergeButton.textContent).toContain('Merge');
+ expect(mergeButton.disabled).toBeTruthy();
+ expect(mergeButton.classList.contains('btn-success')).toEqual(true);
+ expect(mergeLocallyButton.textContent).toContain('Merge locally');
+ });
+ });
- it('should not have action buttons', () => {
- expect(vm.$el.querySelector('.js-disabled-merge-button')).toBeDefined();
- expect(vm.$el.querySelector('.js-resolve-conflicts-button')).toBeNull();
- expect(vm.$el.querySelector('.js-merge-locally-button')).toBeNull();
+ describe('when user does not have permission to merge', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ canMerge: false,
+ },
});
});
- describe('when fast-forward or semi-linear merge enabled', () => {
- let vm;
+ it('should show proper message', () => {
+ expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toContain('ask someone with write access');
+ });
- beforeEach(() => {
- vm = mountComponent(ConflictsComponent, {
- mr: {
- shouldBeRebased: true,
- },
- });
- });
+ it('should not have action buttons', () => {
+ expect(vm.$el.querySelector('.js-disabled-merge-button')).toBeDefined();
+ expect(vm.$el.querySelector('.js-resolve-conflicts-button')).toBeNull();
+ expect(vm.$el.querySelector('.js-merge-locally-button')).toBeNull();
+ });
+ });
- afterEach(() => {
- vm.$destroy();
+ describe('when fast-forward or semi-linear merge enabled', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ mr: {
+ shouldBeRebased: true,
+ },
});
+ });
- it('should tell you to rebase locally', () => {
- expect(vm.$el.textContent).toContain('Fast-forward merge is not possible.');
- expect(vm.$el.textContent).toContain('To merge this request, first rebase locally');
- });
+ it('should tell you to rebase locally', () => {
+ expect(
+ removeBreakLine(vm.$el.textContent).trim(),
+ ).toContain('Fast-forward merge is not possible. To merge this request, first rebase locally.');
});
});
});
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
index cef365eec8a..dd1d62cd4ed 100644
--- 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
@@ -1,45 +1,37 @@
import Vue from 'vue';
-import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge';
+import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue';
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 },
- });
-};
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetFailedToMerge', () => {
- describe('data', () => {
- it('should have default data', () => {
- const data = failedToMergeComponent.data();
+ let Component;
+ let vm;
+
+ beforeEach(() => {
+ Component = Vue.extend(failedToMergeComponent);
+ spyOn(eventHub, '$emit');
+ vm = mountComponent(Component, { mr: {
+ mergeError: 'Merge error happened.',
+ } });
+ });
- expect(data.timer).toEqual(10);
- expect(data.isRefreshing).toBeFalsy();
- });
+ afterEach(() => {
+ vm.$destroy();
});
describe('computed', () => {
describe('timerText', () => {
it('should return correct timer text', () => {
- const vm = createComponent();
- expect(vm.timerText).toEqual('10 seconds');
+ expect(vm.timerText).toEqual('Refreshing in 10 seconds to show the updated status...');
vm.timer = 1;
- expect(vm.timerText).toEqual('a second');
+ expect(vm.timerText).toEqual('Refreshing in a second to show the updated status...');
});
});
});
describe('created', () => {
it('should disable polling', () => {
- spyOn(eventHub, '$emit');
- createComponent();
-
expect(eventHub.$emit).toHaveBeenCalledWith('DisablePolling');
});
});
@@ -47,13 +39,10 @@ describe('MRWidgetFailedToMerge', () => {
describe('methods', () => {
describe('refresh', () => {
it('should emit event to request component refresh', () => {
- spyOn(eventHub, '$emit');
- const vm = createComponent();
-
- expect(vm.isRefreshing).toBeFalsy();
+ expect(vm.isRefreshing).toEqual(false);
vm.refresh();
- expect(vm.isRefreshing).toBeTruthy();
+ expect(vm.isRefreshing).toEqual(true);
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
expect(eventHub.$emit).toHaveBeenCalledWith('EnablePolling');
});
@@ -61,12 +50,11 @@ describe('MRWidgetFailedToMerge', () => {
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
+ for (let i = 0; i < 10; i += 1) {
expect(vm.timer).toEqual(10 - i);
vm.updateTimer();
}
@@ -76,47 +64,54 @@ describe('MRWidgetFailedToMerge', () => {
});
});
- describe('template', () => {
- let vm;
- let el;
+ describe('while it is refreshing', () => {
+ it('renders Refresing now', (done) => {
+ vm.isRefreshing = true;
- beforeEach(() => {
- vm = createComponent();
- el = vm.$el;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.js-refresh-label').textContent.trim()).toEqual('Refreshing now');
+ done();
+ });
});
+ });
- 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);
+ describe('while it is not regresing', () => {
+ it('renders warning icon and disabled merge button', () => {
+ expect(vm.$el.querySelector('.js-ci-status-icon-warning')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-disabled-merge-button').getAttribute('disabled')).toEqual('disabled');
+ });
+
+ it('renders given error', () => {
+ expect(vm.$el.querySelector('.has-error-message').textContent.trim()).toEqual('Merge error happened..');
});
- it('should just generic merge failed message if merge_error is not available', (done) => {
- vm.mr.mergeError = null;
+ it('renders refresh button', () => {
+ expect(vm.$el.querySelector('.js-refresh-button').textContent.trim()).toEqual('Refresh now');
+ });
- Vue.nextTick(() => {
- expect(el.innerText).toContain('Merge failed.');
- expect(el.innerText).not.toContain('Merge error happened.');
- done();
- });
+ it('renders remaining time', () => {
+ expect(
+ vm.$el.querySelector('.has-custom-error').textContent.trim(),
+ ).toEqual('Refreshing in 10 seconds to show the updated status...');
+ });
+ });
+
+ it('should just generic merge failed message if merge_error is not available', (done) => {
+ vm.mr.mergeError = null;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.innerText).toContain('Merge failed.');
+ expect(vm.$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');
- });
+ it('should show refresh label when refresh requested', (done) => {
+ vm.refresh();
+ Vue.nextTick(() => {
+ expect(vm.$el.innerText).not.toContain('Merge failed. Refreshing');
+ expect(vm.$el.innerText).toContain('Refreshing now');
+ done();
});
});
});
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
deleted file mode 100644
index 237035648cf..00000000000
--- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-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
index 9a71d0b47d7..d47815a5b5a 100644
--- 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
@@ -1,77 +1,46 @@
import Vue from 'vue';
-import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds';
+import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue';
+import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
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 },
- });
-};
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
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();
+ let vm;
+ const targetBranchPath = '/foo/bar';
+ const targetBranch = 'foo';
+ const sha = '1EA2EZ34';
+
+ beforeEach(() => {
+ const Component = Vue.extend(mwpsComponent);
+ spyOn(eventHub, '$emit');
+
+ vm = mountComponent(Component, {
+ mr: {
+ shouldRemoveSourceBranch: false,
+ canRemoveSourceBranch: true,
+ canCancelAutomaticMerge: true,
+ mergeUserId: 1,
+ currentUserId: 1,
+ setToMWPSBy: {},
+ sha,
+ targetBranchPath,
+ targetBranch,
+ },
+ service: new MRWidgetService({}),
});
});
- 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();
- });
+ afterEach(() => {
+ vm.$destroy();
});
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();
@@ -83,15 +52,11 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
});
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();
});
@@ -101,16 +66,12 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
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;
- },
+ data: mrObj,
});
}));
@@ -125,22 +86,16 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
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',
- };
- },
- });
+ spyOn(vm.service, 'merge').and.returnValue(Promise.resolve({
+ data: {
+ status: 'merge_when_pipeline_succeeds',
+ },
}));
vm.removeSourceBranch();
setTimeout(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
- expect(vm.service.mergeResource.save).toHaveBeenCalledWith({
+ expect(vm.service.merge).toHaveBeenCalledWith({
sha,
merge_when_pipeline_succeeds: true,
should_remove_source_branch: true,
@@ -152,31 +107,23 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
});
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();
+ expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
+ expect(vm.$el.innerText).toContain('to be merged automatically when the pipeline succeeds');
+ expect(vm.$el.innerText).toContain('The changes will be merged into');
+ expect(vm.$el.innerText).toContain(targetBranch);
+ expect(vm.$el.innerText).toContain('The source branch will not be removed');
+ expect(vm.$el.querySelector('.js-cancel-auto-merge').innerText).toContain('Cancel automatic merge');
+ expect(vm.$el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeFalsy();
+ expect(vm.$el.querySelector('.js-remove-source-branch').innerText).toContain('Remove source branch');
+ expect(vm.$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();
+ expect(vm.$el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeTruthy();
done();
});
});
@@ -185,7 +132,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
vm.mr.shouldRemoveSourceBranch = true;
Vue.nextTick(() => {
- const normalizedText = el.innerText.replace(/\s+/g, ' ');
+ const normalizedText = vm.$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();
@@ -196,7 +143,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
vm.mr.currentUserId = 4;
Vue.nextTick(() => {
- expect(el.querySelector('.js-remove-source-branch')).toEqual(null);
+ expect(vm.$el.querySelector('.js-remove-source-branch')).toEqual(null);
done();
});
});
@@ -205,7 +152,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
vm.isRemovingSourceBranch = true;
Vue.nextTick(() => {
- expect(el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeTruthy();
+ expect(vm.$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
index 2714e8294fa..c2c92d8ac56 100644
--- 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
@@ -1,105 +1,99 @@
import Vue from 'vue';
-import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged';
+import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-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,
- mergedEvent: {
- author: {},
+describe('MRWidgetMerged', () => {
+ let vm;
+ const targetBranch = 'foo';
+
+ beforeEach(() => {
+ const Component = Vue.extend(mergedComponent);
+ const mr = {
+ isRemovingSourceBranch: false,
+ cherryPickInForkPath: false,
+ canCherryPickInCurrentMR: true,
+ revertInForkPath: false,
+ canRevertInCurrentMR: true,
+ canRemoveSourceBranch: true,
+ sourceBranchRemoved: true,
+ metrics: {
+ mergedBy: {
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://localhost:3000/root',
+ avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ },
+ mergedAt: 'Jan 24, 2018 1:02pm GMT+0000',
+ readableMergedAt: '',
+ closedBy: {},
+ closedAt: 'Jan 24, 2018 1:02pm GMT+0000',
+ readableClosedAt: '',
+ },
updatedAt: 'mergedUpdatedAt',
- formattedUpdatedAt: '',
- },
- updatedAt: 'mrUpdatedAt',
- targetBranch,
- };
-
- const service = {
- removeSourceBranch() {},
- };
-
- return new Component({
- el: document.createElement('div'),
- propsData: { mr, service },
- });
-};
+ targetBranch,
+ };
-describe('MRWidgetMerged', () => {
- describe('props', () => {
- it('should have props', () => {
- const { mr, service } = mergedComponent.props;
+ const service = {
+ removeSourceBranch() {},
+ };
- expect(mr.type instanceof Object).toBeTruthy();
- expect(mr.required).toBeTruthy();
+ spyOn(eventHub, '$emit');
- 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();
- });
+ vm = mountComponent(Component, { mr, service });
});
- describe('data', () => {
- it('should have default data', () => {
- const data = mergedComponent.data();
-
- expect(data.isMakingRequest).toBeFalsy();
- });
+ afterEach(() => {
+ vm.$destroy();
});
describe('computed', () => {
describe('shouldShowRemoveSourceBranch', () => {
- it('should correct value when fields changed', () => {
- const vm = createComponent();
+ it('returns true when sourceBranchRemoved is false', () => {
vm.mr.sourceBranchRemoved = false;
- expect(vm.shouldShowRemoveSourceBranch).toBeTruthy();
+ expect(vm.shouldShowRemoveSourceBranch).toEqual(true);
+ });
+ it('returns false wehn sourceBranchRemoved is true', () => {
vm.mr.sourceBranchRemoved = true;
- expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+ expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
+ });
+ it('returns false when canRemoveSourceBranch is false', () => {
vm.mr.sourceBranchRemoved = false;
vm.mr.canRemoveSourceBranch = false;
- expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+ expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
+ });
+ it('returns false when is making request', () => {
vm.mr.canRemoveSourceBranch = true;
vm.isMakingRequest = true;
- expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+ expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
+ });
+ it('returns true when all are true', () => {
vm.mr.isRemovingSourceBranch = true;
vm.mr.canRemoveSourceBranch = true;
vm.isMakingRequest = true;
- expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+ expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
});
});
+
describe('shouldShowSourceBranchRemoving', () => {
it('should correct value when fields changed', () => {
- const vm = createComponent();
vm.mr.sourceBranchRemoved = false;
- expect(vm.shouldShowSourceBranchRemoving).toBeFalsy();
+ expect(vm.shouldShowSourceBranchRemoving).toEqual(false);
vm.mr.sourceBranchRemoved = true;
- expect(vm.shouldShowRemoveSourceBranch).toBeFalsy();
+ expect(vm.shouldShowRemoveSourceBranch).toEqual(false);
vm.mr.sourceBranchRemoved = false;
vm.isMakingRequest = true;
- expect(vm.shouldShowSourceBranchRemoving).toBeTruthy();
+ expect(vm.shouldShowSourceBranchRemoving).toEqual(true);
vm.isMakingRequest = false;
vm.mr.isRemovingSourceBranch = true;
- expect(vm.shouldShowSourceBranchRemoving).toBeTruthy();
+ expect(vm.shouldShowSourceBranchRemoving).toEqual(true);
});
});
});
@@ -107,14 +101,10 @@ describe('MRWidgetMerged', () => {
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',
- };
+ data: {
+ message: 'Branch was removed',
},
});
}));
@@ -122,7 +112,7 @@ describe('MRWidgetMerged', () => {
vm.removeSourceBranch();
setTimeout(() => {
const args = eventHub.$emit.calls.argsFor(0);
- expect(vm.isMakingRequest).toBeTruthy();
+ expect(vm.isMakingRequest).toEqual(true);
expect(args[0]).toEqual('MRWidgetUpdateRequested');
expect(args[1]).not.toThrow();
done();
@@ -131,53 +121,50 @@ describe('MRWidgetMerged', () => {
});
});
- describe('template', () => {
- let vm;
- let el;
+ it('has merged by information', () => {
+ expect(vm.$el.textContent).toContain('Merged by');
+ expect(vm.$el.textContent).toContain('Administrator');
+ });
- beforeEach(() => {
- vm = createComponent();
- el = vm.$el;
- });
+ it('renders branch information', () => {
+ expect(vm.$el.textContent).toContain('The changes were merged into');
+ expect(vm.$el.textContent).toContain(targetBranch);
+ });
- 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('renders information about branch being removed', () => {
+ expect(vm.$el.textContent).toContain('The source branch has been removed');
+ });
- it('should not show source branch removed text', (done) => {
- vm.mr.sourceBranchRemoved = false;
+ it('shows revert and cherry-pick buttons', () => {
+ expect(vm.$el.textContent).toContain('Revert');
+ expect(vm.$el.textContent).toContain('Cherry-pick');
+ });
- 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 not show source branch removed text', (done) => {
+ vm.mr.sourceBranchRemoved = false;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.innerText).toContain('You can remove source branch now');
+ expect(vm.$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;
+ 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();
- });
+ Vue.nextTick(() => {
+ expect(vm.$el.innerText).toContain('The source branch is being removed');
+ expect(vm.$el.innerText).not.toContain('You can remove source branch now');
+ expect(vm.$el.innerText).not.toContain('The source branch has been removed');
+ done();
});
+ });
- it('should use mergedEvent updatedAt as tooltip title', () => {
- expect(
- el.querySelector('time').getAttribute('title'),
- ).toBe('mergedUpdatedAt');
- });
+ it('should use mergedEvent mergedAt as tooltip title', () => {
+ expect(
+ vm.$el.querySelector('time').getAttribute('title'),
+ ).toBe('Jan 24, 2018 1:02pm GMT+0000');
});
});
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js
new file mode 100644
index 00000000000..d2d219e4bdb
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merging_spec.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import mergingComponent from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('MRWidgetMerging', () => {
+ let vm;
+ beforeEach(() => {
+ const Component = Vue.extend(mergingComponent);
+
+ vm = mountComponent(Component, { mr: {
+ targetBranchPath: '/branch-path',
+ targetBranch: 'branch',
+ } });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders information about merge request being merged', () => {
+ expect(
+ vm.$el.querySelector('.media-body').textContent.trim().replace(/\s\s+/g, ' ').replace(/[\r\n]+/g, ' '),
+ ).toContain('This merge request is in the process of being merged');
+ });
+
+ it('renders branch information', () => {
+ expect(
+ vm.$el.querySelector('.mr-info-list').textContent.trim().replace(/\s\s+/g, ' ').replace(/[\r\n]+/g, ' '),
+ ).toEqual('The changes will be merged into branch');
+ expect(
+ vm.$el.querySelector('a').getAttribute('href'),
+ ).toEqual('/branch-path');
+ });
+});
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
index 720effb5c1c..34f76b39b28 100644
--- 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
@@ -1,38 +1,22 @@
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 },
- });
-};
+import missingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetMissingBranch', () => {
- describe('props', () => {
- it('should have props', () => {
- const mrProp = missingBranchComponent.props.mr;
+ let vm;
- expect(mrProp.type instanceof Object).toBeTruthy();
- expect(mrProp.required).toBeTruthy();
- });
+ beforeEach(() => {
+ const Component = Vue.extend(missingBranchComponent);
+ vm = mountComponent(Component, { mr: { sourceBranchRemoved: true } });
});
- describe('components', () => {
- it('should have components added', () => {
- expect(missingBranchComponent.components['mr-widget-merge-help']).toBeDefined();
- });
+ afterEach(() => {
+ vm.$destroy();
});
describe('computed', () => {
describe('missingBranchName', () => {
it('should return proper branch name', () => {
- const vm = createComponent();
expect(vm.missingBranchName).toEqual('source');
vm.mr.sourceBranchRemoved = false;
@@ -43,7 +27,7 @@ describe('MRWidgetMissingBranch', () => {
describe('template', () => {
it('should have correct elements', () => {
- const el = createComponent().$el;
+ const el = vm.$el;
const content = el.textContent.replace(/\n(\s)+/g, ' ').trim();
expect(el.classList.contains('mr-widget-body')).toBeTruthy();
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
index 33f20ab132d..9f8b96c118b 100644
--- 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
@@ -1,17 +1,24 @@
import Vue from 'vue';
-import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed';
+import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('MRWidgetNotAllowed', () => {
- describe('template', () => {
+ let vm;
+ beforeEach(() => {
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');
- });
+ vm = mountComponent(Component);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders success icon', () => {
+ expect(vm.$el.querySelector('.ci-status-icon-success')).not.toBe(null);
+ });
+
+ it('renders informative text', () => {
+ 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
index a8a02fa6b66..2a762c9336e 100644
--- 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
@@ -1,9 +1,9 @@
import Vue from 'vue';
-import nothingToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge';
+import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing_to_merge.vue';
-describe('MRWidgetNothingToMerge', () => {
+describe('NothingToMerge', () => {
describe('template', () => {
- const Component = Vue.extend(nothingToMergeComponent);
+ const Component = Vue.extend(NothingToMerge);
const newBlobPath = '/foo';
const vm = new Component({
el: document.createElement('div'),
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
index d0702f9f503..894dbe3382f 100644
--- 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
@@ -1,16 +1,26 @@
import Vue from 'vue';
-import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked';
+import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import removeBreakLine from 'spec/helpers/vue_component_helper';
describe('MRWidgetPipelineBlocked', () => {
- describe('template', () => {
+ let vm;
+ beforeEach(() => {
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');
- });
+ vm = mountComponent(Component);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders warning icon', () => {
+ expect(vm.$el.querySelector('.ci-status-icon-warning')).not.toBe(null);
+ });
+
+ it('renders information text', () => {
+ expect(
+ removeBreakLine(vm.$el.textContent).trim(),
+ ).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_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index df3d29ee1f9..58f683fb3e6 100644
--- 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
@@ -170,14 +170,14 @@ describe('MRWidgetReadyToMerge', () => {
expect(vm.iconClass).toEqual('success');
});
- it('shows x for failed status', () => {
+ it('shows warning icon for failed status', () => {
vm.mr.hasCI = true;
- expect(vm.iconClass).toEqual('failed');
+ expect(vm.iconClass).toEqual('warning');
});
- it('shows x for merge not allowed', () => {
+ it('shows warning icon for merge not allowed', () => {
vm.mr.hasCI = true;
- expect(vm.iconClass).toEqual('failed');
+ expect(vm.iconClass).toEqual('warning');
});
});
@@ -292,8 +292,8 @@ describe('MRWidgetReadyToMerge', () => {
describe('handleMergeButtonClick', () => {
const returnPromise = status => new Promise((resolve) => {
resolve({
- json() {
- return { status };
+ data: {
+ status,
},
});
});
@@ -364,12 +364,17 @@ describe('MRWidgetReadyToMerge', () => {
describe('handleMergePolling', () => {
const returnPromise = state => new Promise((resolve) => {
resolve({
- json() {
- return { state, source_branch_exists: true };
+ data: {
+ state,
+ source_branch_exists: true,
},
});
});
+ beforeEach(() => {
+ loadFixtures('merge_requests/merge_request_of_current_user.html.raw');
+ });
+
it('should call start and stop polling when MR merged', (done) => {
spyOn(eventHub, '$emit');
spyOn(vm.service, 'poll').and.returnValue(returnPromise('merged'));
@@ -391,6 +396,47 @@ describe('MRWidgetReadyToMerge', () => {
}, 333);
});
+ it('updates status box', (done) => {
+ spyOn(vm.service, 'poll').and.returnValue(returnPromise('merged'));
+ spyOn(vm, 'initiateRemoveSourceBranchPolling');
+
+ vm.handleMergePolling(() => {}, () => {});
+
+ setTimeout(() => {
+ const statusBox = document.querySelector('.status-box');
+ expect(statusBox.classList.contains('status-box-mr-merged')).toBeTruthy();
+ expect(statusBox.textContent).toContain('Merged');
+
+ done();
+ });
+ });
+
+ it('hides close button', (done) => {
+ spyOn(vm.service, 'poll').and.returnValue(returnPromise('merged'));
+ spyOn(vm, 'initiateRemoveSourceBranchPolling');
+
+ vm.handleMergePolling(() => {}, () => {});
+
+ setTimeout(() => {
+ expect(document.querySelector('.btn-close').classList.contains('hidden')).toBeTruthy();
+
+ done();
+ });
+ });
+
+ it('updates merge request count badge', (done) => {
+ spyOn(vm.service, 'poll').and.returnValue(returnPromise('merged'));
+ spyOn(vm, 'initiateRemoveSourceBranchPolling');
+
+ vm.handleMergePolling(() => {}, () => {});
+
+ setTimeout(() => {
+ expect(document.querySelector('.js-merge-counter').textContent).toBe('0');
+
+ done();
+ });
+ });
+
it('should continue polling until MR is merged', (done) => {
spyOn(vm.service, 'poll').and.returnValue(returnPromise('some_other_state'));
spyOn(vm, 'initiateRemoveSourceBranchPolling');
@@ -422,8 +468,8 @@ describe('MRWidgetReadyToMerge', () => {
describe('handleRemoveBranchPolling', () => {
const returnPromise = state => new Promise((resolve) => {
resolve({
- json() {
- return { source_branch_exists: state };
+ data: {
+ source_branch_exists: state,
},
});
});
@@ -471,13 +517,9 @@ describe('MRWidgetReadyToMerge', () => {
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');
+ expect(checkboxElement).toBeNull();
});
});
@@ -494,7 +536,7 @@ describe('MRWidgetReadyToMerge', () => {
it('should be enabled in rendered output', () => {
const checkboxElement = this.customVm.$el.querySelector('#remove-source-branch-input');
- expect(checkboxElement.getAttribute('disabled')).toBeNull();
+ expect(checkboxElement).not.toBeNull();
});
});
});
@@ -503,12 +545,12 @@ describe('MRWidgetReadyToMerge', () => {
describe('when allowed to merge', () => {
beforeEach(() => {
vm = createComponent({
- mr: { isMergeAllowed: true },
+ mr: { isMergeAllowed: true, canRemoveSourceBranch: true },
});
});
it('shows remove source branch checkbox', () => {
- expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).toBeDefined();
+ expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).not.toBeNull();
});
it('shows modify commit message button', () => {
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
index 4c67504b642..b02af94d03a 100644
--- 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
@@ -1,16 +1,25 @@
import Vue from 'vue';
-import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch';
+import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import removeBreakLine from 'spec/helpers/vue_component_helper';
-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');
- });
+describe('ShaMismatch', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ShaMismatch);
+ vm = mountComponent(Component);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render information message', () => {
+ expect(vm.$el.querySelector('button').disabled).toEqual(true);
+
+ expect(
+ removeBreakLine(vm.$el.textContent).trim(),
+ ).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
index fe87f110354..046968fbc1f 100644
--- 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
@@ -1,10 +1,10 @@
import Vue from 'vue';
-import unresolvedDiscussionsComponent from '~/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions';
+import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue';
-describe('MRWidgetUnresolvedDiscussions', () => {
+describe('UnresolvedDiscussions', () => {
describe('props', () => {
it('should have props', () => {
- const { mr } = unresolvedDiscussionsComponent.props;
+ const { mr } = UnresolvedDiscussions.props;
expect(mr.type instanceof Object).toBeTruthy();
expect(mr.required).toBeTruthy();
@@ -17,7 +17,7 @@ describe('MRWidgetUnresolvedDiscussions', () => {
const path = 'foo/bar';
beforeEach(() => {
- const Component = Vue.extend(unresolvedDiscussionsComponent);
+ const Component = Vue.extend(UnresolvedDiscussions);
const mr = {
createIssueToResolveDiscussionsPath: path,
};
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
index 2cb3aaa6951..98ab61a0367 100644
--- 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
@@ -50,9 +50,7 @@ describe('MRWidgetWIP', () => {
spyOn(eventHub, '$emit');
spyOn(vm.service, 'removeWIP').and.returnValue(new Promise((resolve) => {
resolve({
- json() {
- return mrObj;
- },
+ data: mrObj,
});
}));
diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js
index 1ad7c2d8efa..3fc7663b9c2 100644
--- a/spec/javascripts/vue_mr_widget/mock_data.js
+++ b/spec/javascripts/vue_mr_widget/mock_data.js
@@ -1,213 +1,218 @@
-/* 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
+ 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',
+ 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"
+ 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,
+ metrics: {
+ merged_by: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ web_url: 'http://localhost:3000/root',
},
- "updated_at": "2017-04-07T15:39:25.696Z"
+ merged_at: '2017-04-07T15:39:25.696Z',
+ closed_by: null,
+ closed_at: null,
},
- "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"
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ id: 1,
+ state: 'active',
+ avatar_url: 'https://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"
+ 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:
+ 'https://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"
+ 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": [
+ 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"
+ 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"
+ 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"
+ 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": [
-
+ path: '/root/acets-app/pipelines/172#review',
+ dropdown_path: '/root/acets-app/pipelines/172/stage.json?stage=review',
+ },
],
- "manual_actions": [
+ artifacts: [],
+ manual_actions: [
{
- "name": "stop_review",
- "path": "/root/acets-app/builds/1427/play",
- "playable": false
- }
- ]
+ 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
+ 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
+ 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"
+ 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:
+ 'https://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"
+ author_gravatar_url:
+ 'https://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"
+ 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": ""
+ 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
+ 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",
- "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"
-}
+ 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',
+ 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
index 74b343c573e..e55c7649d40 100644
--- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js
@@ -3,15 +3,12 @@ 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 { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mockData from './mock_data';
-import mountComponent from '../helpers/vue_mount_component_helper';
const returnPromise = data => new Promise((resolve) => {
resolve({
- json() {
- return data;
- },
- body: data,
+ data,
});
});
@@ -84,14 +81,46 @@ describe('mrWidgetOptions', () => {
});
});
- describe('shouldRenderDeployments', () => {
- it('should return false for the initial data', () => {
- expect(vm.shouldRenderDeployments).toBeFalsy();
+ describe('shouldRenderSourceBranchRemovalStatus', () => {
+ beforeEach(() => {
+ vm.mr.state = 'readyToMerge';
});
- it('should return true if there is deployments', () => {
- vm.mr.deployments.push({}, {});
- expect(vm.shouldRenderDeployments).toBeTruthy();
+ it('should return true when cannot remove source branch and branch will be removed', () => {
+ vm.mr.canRemoveSourceBranch = false;
+ vm.mr.shouldRemoveSourceBranch = true;
+
+ expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(true);
+ });
+
+ it('should return false when can remove source branch and branch will be removed', () => {
+ vm.mr.canRemoveSourceBranch = true;
+ vm.mr.shouldRemoveSourceBranch = true;
+
+ expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
+ });
+
+ it('should return false when cannot remove source branch and branch will not be removed', () => {
+ vm.mr.canRemoveSourceBranch = false;
+ vm.mr.shouldRemoveSourceBranch = false;
+
+ expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
+ });
+
+ it('should return false when in merged state', () => {
+ vm.mr.canRemoveSourceBranch = false;
+ vm.mr.shouldRemoveSourceBranch = true;
+ vm.mr.state = 'merged';
+
+ expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
+ });
+
+ it('should return false when in nothing to merge state', () => {
+ vm.mr.canRemoveSourceBranch = false;
+ vm.mr.shouldRemoveSourceBranch = true;
+ vm.mr.state = 'nothingToMerge';
+
+ expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
});
});
});
@@ -149,16 +178,16 @@ describe('mrWidgetOptions', () => {
describe('fetchDeployments', () => {
it('should fetch deployments', (done) => {
- spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ deployment: 1 }]));
+ spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ id: 1 }]));
vm.fetchDeployments();
setTimeout(() => {
expect(vm.service.fetchDeployments).toHaveBeenCalled();
expect(vm.mr.deployments.length).toEqual(1);
- expect(vm.mr.deployments[0].deployment).toEqual(1);
+ expect(vm.mr.deployments[0].id).toBe(1);
done();
- }, 333);
+ });
});
});
@@ -298,6 +327,15 @@ describe('mrWidgetOptions', () => {
expect(notify.notifyMe).not.toHaveBeenCalled();
});
+
+ it('should not notify if no pipeline provided', () => {
+ vm.handleNotification({
+ ...data,
+ pipeline: undefined,
+ });
+
+ expect(notify.notifyMe).not.toHaveBeenCalled();
+ });
});
describe('resumePolling', () => {
@@ -319,33 +357,6 @@ describe('mrWidgetOptions', () => {
});
});
- 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();
- });
- });
-
describe('rendering relatedLinks', () => {
beforeEach((done) => {
vm.mr.relatedLinks = {
@@ -372,4 +383,66 @@ describe('mrWidgetOptions', () => {
});
});
});
+
+ describe('rendering source branch removal status', () => {
+ it('renders when user cannot remove branch and branch should be removed', (done) => {
+ vm.mr.canRemoveSourceBranch = false;
+ vm.mr.shouldRemoveSourceBranch = true;
+ vm.mr.state = 'readyToMerge';
+
+ vm.$nextTick(() => {
+ const tooltip = vm.$el.querySelector('.fa-question-circle');
+
+ expect(vm.$el.textContent).toContain('Removes source branch');
+ expect(tooltip.getAttribute('data-original-title')).toBe(
+ 'A user with write access to the source branch selected this option',
+ );
+
+ done();
+ });
+ });
+
+ it('does not render in merged state', (done) => {
+ vm.mr.canRemoveSourceBranch = false;
+ vm.mr.shouldRemoveSourceBranch = true;
+ vm.mr.state = 'merged';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.textContent).toContain('The source branch has been removed');
+ expect(vm.$el.textContent).not.toContain('Removes source branch');
+
+ done();
+ });
+ });
+ });
+
+ describe('rendering deployments', () => {
+ 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',
+ };
+
+ beforeEach((done) => {
+ vm.mr.deployments.push({
+ ...deploymentMockData,
+ }, {
+ ...deploymentMockData,
+ id: deploymentMockData.id + 1,
+ });
+
+ vm.$nextTick(done);
+ });
+
+ it('renders multiple deployments', () => {
+ expect(vm.$el.querySelectorAll('.deploy-heading').length).toBe(2);
+ });
+ });
});
diff --git a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js
index 8762ce9903b..668742ebaee 100644
--- a/spec/javascripts/vue_shared/components/ci_badge_link_spec.js
+++ b/spec/javascripts/vue_shared/components/ci_badge_link_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import ciBadge from '~/vue_shared/components/ci_badge_link.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('CI Badge Link Component', () => {
let CIBadge;
diff --git a/spec/javascripts/vue_shared/components/clipboard_button_spec.js b/spec/javascripts/vue_shared/components/clipboard_button_spec.js
new file mode 100644
index 00000000000..f598b1afa74
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/clipboard_button_spec.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('clipboard button', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(clipboardButton);
+ vm = mountComponent(Component, {
+ text: 'copy me',
+ title: 'Copy this value into Clipboard!',
+ cssClass: 'btn-danger',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a button for clipboard', () => {
+ expect(vm.$el.tagName).toEqual('BUTTON');
+ expect(vm.$el.getAttribute('data-clipboard-text')).toEqual('copy me');
+ expect(vm.$el.querySelector('i').className).toEqual('fa fa-clipboard');
+ });
+
+ it('should have a tooltip with default values', () => {
+ expect(vm.$el.getAttribute('data-original-title')).toEqual('Copy this value into Clipboard!');
+ expect(vm.$el.getAttribute('data-placement')).toEqual('top');
+ expect(vm.$el.getAttribute('data-container')).toEqual(null);
+ });
+
+ it('should render provided classname', () => {
+ expect(vm.$el.classList).toContain('btn-danger');
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js
index d5754aaa9e7..fdead874209 100644
--- a/spec/javascripts/vue_shared/components/commit_spec.js
+++ b/spec/javascripts/vue_shared/components/commit_spec.js
@@ -10,7 +10,7 @@ describe('Commit component', () => {
CommitComponent = Vue.extend(commitComp);
});
- it('should render a code-fork icon if it does not represent a tag', () => {
+ it('should render a fork icon if it does not represent a tag', () => {
component = new CommitComponent({
propsData: {
tag: false,
@@ -30,7 +30,7 @@ describe('Commit component', () => {
},
}).$mount();
- expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork');
+ expect(component.$el.querySelector('.icon-container').children).toContain('svg');
});
describe('Given all the props', () => {
diff --git a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js
new file mode 100644
index 00000000000..383f0cd29ea
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('ContentViewer', () => {
+ let vm;
+ let mock;
+
+ function createComponent(props) {
+ const ContentViewer = Vue.extend(contentViewer);
+ vm = mountComponent(ContentViewer, props);
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+ if (mock) mock.restore();
+ });
+
+ it('markdown preview renders + loads rendered markdown from server', done => {
+ mock = new MockAdapter(axios);
+ mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).reply(200, {
+ body: '<b>testing</b>',
+ });
+
+ createComponent({
+ path: 'test.md',
+ content: '* Test',
+ projectPath: 'testproject',
+ });
+
+ const previewContainer = vm.$el.querySelector('.md-previewer');
+
+ setTimeout(() => {
+ expect(previewContainer.textContent).toContain('testing');
+
+ done();
+ });
+ });
+
+ it('renders image preview', done => {
+ createComponent({
+ path: 'test.jpg',
+ fileSize: 1024,
+ });
+
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe('test.jpg');
+
+ done();
+ });
+ });
+
+ it('renders fallback download control', done => {
+ createComponent({
+ path: 'test.abc',
+ fileSize: 1024,
+ });
+
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain(
+ 'test.abc (1.00 KiB)',
+ );
+ expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download');
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/deprecated_modal_spec.js b/spec/javascripts/vue_shared/components/deprecated_modal_spec.js
new file mode 100644
index 00000000000..59d4e549a91
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/deprecated_modal_spec.js
@@ -0,0 +1,67 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const modalComponent = Vue.extend(DeprecatedModal);
+
+describe('DeprecatedModal', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('props', () => {
+ describe('without primaryButtonLabel', () => {
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, {
+ primaryButtonLabel: null,
+ });
+ });
+
+ it('does not render a primary button', () => {
+ expect(vm.$el.querySelector('.js-primary-button')).toBeNull();
+ });
+ });
+
+ describe('with id', () => {
+ describe('does not render a primary button', () => {
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, {
+ id: 'my-modal',
+ });
+ });
+
+ it('assigns the id to the modal', () => {
+ expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull();
+ });
+
+ it('does not show the modal immediately', () => {
+ expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show');
+ });
+
+ it('does not show a backdrop', () => {
+ expect(vm.$el.querySelector('modal-backdrop')).toBeNull();
+ });
+ });
+ });
+
+ it('works with data-toggle="modal"', (done) => {
+ setFixtures(`
+ <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
+ <div id="modal-container"></div>
+ `);
+
+ const modalContainer = document.getElementById('modal-container');
+ const modalButton = document.getElementById('modal-button');
+ vm = mountComponent(modalComponent, {
+ id: 'my-modal',
+ }, modalContainer);
+ const modalElement = vm.$el.querySelector('#my-modal');
+ $(modalElement).on('shown.bs.modal', () => done());
+
+ modalButton.click();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/expand_button_spec.js b/spec/javascripts/vue_shared/components/expand_button_spec.js
new file mode 100644
index 00000000000..f19589d3b75
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/expand_button_spec.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import expandButton from '~/vue_shared/components/expand_button.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('expand button', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(expandButton);
+ vm = mountComponent(Component, {
+ slots: {
+ expanded: '<p>Expanded!</p>',
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a collpased button', () => {
+ expect(vm.$el.textContent.trim()).toEqual('...');
+ });
+
+ it('hides expander on click', (done) => {
+ vm.$el.querySelector('button').click();
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('button').getAttribute('style')).toEqual('display: none;');
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/file_icon_spec.js b/spec/javascripts/vue_shared/components/file_icon_spec.js
new file mode 100644
index 00000000000..f7581251bf0
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/file_icon_spec.js
@@ -0,0 +1,83 @@
+import Vue from 'vue';
+import fileIcon from '~/vue_shared/components/file_icon.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('File Icon component', () => {
+ let vm;
+ let FileIcon;
+
+ beforeEach(() => {
+ FileIcon = Vue.extend(fileIcon);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render a span element with an svg', () => {
+ vm = mountComponent(FileIcon, {
+ fileName: 'test.js',
+ });
+
+ expect(vm.$el.tagName).toEqual('SPAN');
+ expect(vm.$el.querySelector('span > svg')).toBeDefined();
+ });
+
+ it('should render a javascript icon based on file ending', () => {
+ vm = mountComponent(FileIcon, {
+ fileName: 'test.js',
+ });
+
+ expect(vm.$el.firstChild.firstChild.getAttribute('xlink:href')).toBe(`${gon.sprite_file_icons}#javascript`);
+ });
+
+ it('should render a image icon based on file ending', () => {
+ vm = mountComponent(FileIcon, {
+ fileName: 'test.png',
+ });
+
+ expect(vm.$el.firstChild.firstChild.getAttribute('xlink:href')).toBe(`${gon.sprite_file_icons}#image`);
+ });
+
+ it('should render a webpack icon based on file namer', () => {
+ vm = mountComponent(FileIcon, {
+ fileName: 'webpack.js',
+ });
+
+ expect(vm.$el.firstChild.firstChild.getAttribute('xlink:href')).toBe(`${gon.sprite_file_icons}#webpack`);
+ });
+
+ it('should render a standard folder icon', () => {
+ vm = mountComponent(FileIcon, {
+ fileName: 'js',
+ folder: true,
+ });
+
+ expect(vm.$el.querySelector('span > svg > use').getAttribute('xlink:href')).toBe(`${gon.sprite_file_icons}#folder`);
+ });
+
+ it('should render a loading icon', () => {
+ vm = mountComponent(FileIcon, {
+ fileName: 'test.js',
+ loading: true,
+ });
+
+ expect(
+ vm.$el.querySelector('i').getAttribute('class'),
+ ).toEqual('fa fa-spin fa-spinner fa-1x');
+ });
+
+ it('should add a special class and a size class', () => {
+ vm = mountComponent(FileIcon, {
+ fileName: 'test.js',
+ cssClasses: 'extraclasses',
+ size: 120,
+ });
+
+ const classList = vm.$el.firstChild.classList;
+ const containsSizeClass = classList.contains('s120');
+ const containsCustomClass = classList.contains('extraclasses');
+ expect(containsSizeClass).toBe(true);
+ expect(containsCustomClass).toBe(true);
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/gl_modal_spec.js b/spec/javascripts/vue_shared/components/gl_modal_spec.js
new file mode 100644
index 00000000000..85cb1b90fc6
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/gl_modal_spec.js
@@ -0,0 +1,193 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const modalComponent = Vue.extend(GlModal);
+
+describe('GlModal', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('props', () => {
+ describe('with id', () => {
+ const props = {
+ id: 'my-modal',
+ };
+
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, props);
+ });
+
+ it('assigns the id to the modal', () => {
+ expect(vm.$el.id).toBe(props.id);
+ });
+ });
+
+ describe('without id', () => {
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, { });
+ });
+
+ it('does not add an id attribute to the modal', () => {
+ expect(vm.$el.hasAttribute('id')).toBe(false);
+ });
+ });
+
+ describe('with headerTitleText', () => {
+ const props = {
+ headerTitleText: 'my title text',
+ };
+
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, props);
+ });
+
+ it('sets the modal title', () => {
+ const modalTitle = vm.$el.querySelector('.modal-title');
+ expect(modalTitle.innerHTML.trim()).toBe(props.headerTitleText);
+ });
+ });
+
+ describe('with footerPrimaryButtonVariant', () => {
+ const props = {
+ footerPrimaryButtonVariant: 'danger',
+ };
+
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, props);
+ });
+
+ it('sets the primary button class', () => {
+ const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type');
+ expect(primaryButton).toHaveClass(`btn-${props.footerPrimaryButtonVariant}`);
+ });
+ });
+
+ describe('with footerPrimaryButtonText', () => {
+ const props = {
+ footerPrimaryButtonText: 'my button text',
+ };
+
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, props);
+ });
+
+ it('sets the primary button text', () => {
+ const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type');
+ expect(primaryButton.innerHTML.trim()).toBe(props.footerPrimaryButtonText);
+ });
+ });
+ });
+
+ it('works with data-toggle="modal"', (done) => {
+ setFixtures(`
+ <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
+ <div id="modal-container"></div>
+ `);
+
+ const modalContainer = document.getElementById('modal-container');
+ const modalButton = document.getElementById('modal-button');
+ vm = mountComponent(modalComponent, {
+ id: 'my-modal',
+ }, modalContainer);
+ $(vm.$el).on('shown.bs.modal', () => done());
+
+ modalButton.click();
+ });
+
+ describe('methods', () => {
+ const dummyEvent = 'not really an event';
+
+ beforeEach(() => {
+ vm = mountComponent(modalComponent, { });
+ spyOn(vm, '$emit');
+ });
+
+ describe('emitCancel', () => {
+ it('emits a cancel event', () => {
+ vm.emitCancel(dummyEvent);
+
+ expect(vm.$emit).toHaveBeenCalledWith('cancel', dummyEvent);
+ });
+ });
+
+ describe('emitSubmit', () => {
+ it('emits a submit event', () => {
+ vm.emitSubmit(dummyEvent);
+
+ expect(vm.$emit).toHaveBeenCalledWith('submit', dummyEvent);
+ });
+ });
+ });
+
+ describe('slots', () => {
+ const slotContent = 'this should go into the slot';
+ const modalWithSlot = (slotName) => {
+ let template;
+ if (slotName) {
+ template = `
+ <gl-modal>
+ <template slot="${slotName}">${slotContent}</template>
+ </gl-modal>
+ `;
+ } else {
+ template = `<gl-modal>${slotContent}</gl-modal>`;
+ }
+
+ return Vue.extend({
+ components: {
+ GlModal,
+ },
+ template,
+ });
+ };
+
+ describe('default slot', () => {
+ beforeEach(() => {
+ vm = mountComponent(modalWithSlot());
+ });
+
+ it('sets the modal body', () => {
+ const modalBody = vm.$el.querySelector('.modal-body');
+ expect(modalBody.innerHTML).toBe(slotContent);
+ });
+ });
+
+ describe('header slot', () => {
+ beforeEach(() => {
+ vm = mountComponent(modalWithSlot('header'));
+ });
+
+ it('sets the modal header', () => {
+ const modalHeader = vm.$el.querySelector('.modal-header');
+ expect(modalHeader.innerHTML).toBe(slotContent);
+ });
+ });
+
+ describe('title slot', () => {
+ beforeEach(() => {
+ vm = mountComponent(modalWithSlot('title'));
+ });
+
+ it('sets the modal title', () => {
+ const modalTitle = vm.$el.querySelector('.modal-title');
+ expect(modalTitle.innerHTML).toBe(slotContent);
+ });
+ });
+
+ describe('footer slot', () => {
+ beforeEach(() => {
+ vm = mountComponent(modalWithSlot('footer'));
+ });
+
+ it('sets the modal footer', () => {
+ const modalFooter = vm.$el.querySelector('.modal-footer');
+ expect(modalFooter.innerHTML).toBe(slotContent);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js
index b4553acb341..65499a2d730 100644
--- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js
+++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import headerCi from '~/vue_shared/components/header_ci_component.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Header CI Component', () => {
let HeaderCi;
@@ -8,7 +9,6 @@ describe('Header CI Component', () => {
beforeEach(() => {
HeaderCi = Vue.extend(headerCi);
-
props = {
status: {
group: 'failed',
@@ -45,54 +45,65 @@ describe('Header CI Component', () => {
],
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);
- });
+ describe('render', () => {
+ beforeEach(() => {
+ vm = mountComponent(HeaderCi, props);
+ });
- it('should render item name and id', () => {
- expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123');
- });
+ 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 timeago date', () => {
- expect(vm.$el.querySelector('time')).toBeDefined();
- });
+ it('should render item name and id', () => {
+ expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123');
+ });
- it('should render user icon and name', () => {
- expect(vm.$el.querySelector('.js-user-link').textContent.trim()).toEqual(props.user.name);
- });
+ it('should render timeago date', () => {
+ expect(vm.$el.querySelector('time')).toBeDefined();
+ });
- 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 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;
+ it('should show loading icon', (done) => {
+ vm.actions[0].isLoading = true;
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.btn .fa-spinner').getAttribute('style')).toBeFalsy();
- done();
+ 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();
});
});
- it('should render sidebar toggle button', () => {
- expect(vm.$el.querySelector('.js-sidebar-build-toggle')).toBeDefined();
+ describe('shouldRenderTriggeredLabel', () => {
+ it('should rendered created keyword when the shouldRenderTriggeredLabel is false', () => {
+ vm = mountComponent(HeaderCi, { ...props, shouldRenderTriggeredLabel: false });
+
+ expect(vm.$el.textContent).toContain('created');
+ expect(vm.$el.textContent).not.toContain('triggered');
+ });
});
});
diff --git a/spec/javascripts/vue_shared/components/icon_spec.js b/spec/javascripts/vue_shared/components/icon_spec.js
index a22b6bd3a67..68d57ebc8f0 100644
--- a/spec/javascripts/vue_shared/components/icon_spec.js
+++ b/spec/javascripts/vue_shared/components/icon_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import Icon from '~/vue_shared/components/icon.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Sprite Icon Component', function () {
describe('Initialization', function () {
diff --git a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js b/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js
index 24484796bf1..e6ed77dbb52 100644
--- a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js
+++ b/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import issueWarning from '~/vue_shared/components/issue/issue_warning.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const IssueWarning = Vue.extend(issueWarning);
diff --git a/spec/javascripts/vue_shared/components/loading_button_spec.js b/spec/javascripts/vue_shared/components/loading_button_spec.js
index 49bf8ee6f7c..51c19cd4080 100644
--- a/spec/javascripts/vue_shared/components/loading_button_spec.js
+++ b/spec/javascripts/vue_shared/components/loading_button_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import loadingButton from '~/vue_shared/components/loading_button.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const LABEL = 'Hello';
diff --git a/spec/javascripts/vue_shared/components/loading_icon_spec.js b/spec/javascripts/vue_shared/components/loading_icon_spec.js
index 1baf3537741..5cd3466f501 100644
--- a/spec/javascripts/vue_shared/components/loading_icon_spec.js
+++ b/spec/javascripts/vue_shared/components/loading_icon_spec.js
@@ -16,7 +16,8 @@ describe('Loading Icon Component', () => {
).toEqual('fa fa-spin fa-spinner fa-1x');
expect(component.$el.tagName).toEqual('DIV');
- expect(component.$el.classList.contains('text-center')).toEqual(true);
+ expect(component.$el.classList).toContain('text-center');
+ expect(component.$el.classList).toContain('loading-container');
});
it('should render accessibility attributes', () => {
diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js
index 24209be83fe..69034975422 100644
--- a/spec/javascripts/vue_shared/components/markdown/field_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Vue from 'vue';
import fieldComponent from '~/vue_shared/components/markdown/field.vue';
@@ -12,14 +13,14 @@ describe('Markdown field component', () => {
beforeEach((done) => {
vm = new Vue({
+ components: {
+ fieldComponent,
+ },
data() {
return {
text: 'testing\n123',
};
},
- components: {
- fieldComponent,
- },
template: `
<field-component
markdown-preview-path="/preview"
diff --git a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
index 818ef0af3c2..3e708f865c8 100644
--- a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('toolbar', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/memory_graph_spec.js b/spec/javascripts/vue_shared/components/memory_graph_spec.js
index d46a3f2328e..73a69df019e 100644
--- a/spec/javascripts/vue_shared/components/memory_graph_spec.js
+++ b/spec/javascripts/vue_shared/components/memory_graph_spec.js
@@ -1,12 +1,12 @@
import Vue from 'vue';
-import memoryGraphComponent from '~/vue_shared/components/memory_graph';
+import MemoryGraph from '~/vue_shared/components/memory_graph.vue';
import { mockMetrics, mockMedian, mockMedianIndex } from './mock_data';
const defaultHeight = '25';
const defaultWidth = '100';
const createComponent = () => {
- const Component = Vue.extend(memoryGraphComponent);
+ const Component = Vue.extend(MemoryGraph);
return new Component({
el: document.createElement('div'),
@@ -32,29 +32,9 @@ describe('MemoryGraph', () => {
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 data = MemoryGraph.data();
const dataValidator = (dataItem, expectedType, defaultVal) => {
expect(typeof dataItem).toBe(expectedType);
expect(dataItem).toBe(defaultVal);
diff --git a/spec/javascripts/vue_shared/components/mock_data.js b/spec/javascripts/vue_shared/components/mock_data.js
index 0d781bdca74..15b56c58c33 100644
--- a/spec/javascripts/vue_shared/components/mock_data.js
+++ b/spec/javascripts/vue_shared/components/mock_data.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
export const mockMetrics = [
[1493716685, '4.30859375'],
[1493716745, '4.30859375'],
diff --git a/spec/javascripts/vue_shared/components/modal_spec.js b/spec/javascripts/vue_shared/components/modal_spec.js
deleted file mode 100644
index 721f4044659..00000000000
--- a/spec/javascripts/vue_shared/components/modal_spec.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-import modal from '~/vue_shared/components/modal.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
-
-describe('Modal', () => {
- it('does not render a primary button if no primaryButtonLabel', () => {
- const modalComponent = Vue.extend(modal);
- const vm = mountComponent(modalComponent);
-
- expect(vm.$el.querySelector('.js-primary-button')).toBeNull();
- });
-});
diff --git a/spec/javascripts/vue_shared/components/navigation_tabs_spec.js b/spec/javascripts/vue_shared/components/navigation_tabs_spec.js
index 78e7d747b92..09fda95d7d3 100644
--- a/spec/javascripts/vue_shared/components/navigation_tabs_spec.js
+++ b/spec/javascripts/vue_shared/components/navigation_tabs_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import navigationTabs from '~/vue_shared/components/navigation_tabs.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('navigation tabs component', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js b/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js
index 7b8e6c330c2..262571efcb8 100644
--- a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js
+++ b/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('placeholder system note component', () => {
let PlaceholderSystemNote;
diff --git a/spec/javascripts/vue_shared/components/panel_resizer_spec.js b/spec/javascripts/vue_shared/components/panel_resizer_spec.js
new file mode 100644
index 00000000000..8efcb54659d
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/panel_resizer_spec.js
@@ -0,0 +1,59 @@
+import Vue from 'vue';
+import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('Panel Resizer component', () => {
+ let vm;
+ let PanelResizer;
+
+ const triggerEvent = (eventName, el = vm.$el, clientX = 0) => {
+ const event = document.createEvent('MouseEvents');
+ event.initMouseEvent(eventName, true, true, window, 1, clientX, 0, clientX, 0, false, false,
+ false, false, 0, null);
+
+ el.dispatchEvent(event);
+ };
+
+ beforeEach(() => {
+ PanelResizer = Vue.extend(panelResizer);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render a div element with the correct classes and styles', () => {
+ vm = mountComponent(PanelResizer, {
+ startSize: 100,
+ side: 'left',
+ });
+
+ expect(vm.$el.tagName).toEqual('DIV');
+ expect(vm.$el.getAttribute('class')).toBe('dragHandle dragleft');
+ expect(vm.$el.getAttribute('style')).toBe('cursor: ew-resize;');
+ });
+
+ it('should render a div element with the correct classes for a right side panel', () => {
+ vm = mountComponent(PanelResizer, {
+ startSize: 100,
+ side: 'right',
+ });
+
+ expect(vm.$el.tagName).toEqual('DIV');
+ expect(vm.$el.getAttribute('class')).toBe('dragHandle dragright');
+ });
+
+ it('drag the resizer', () => {
+ vm = mountComponent(PanelResizer, {
+ startSize: 100,
+ side: 'left',
+ });
+
+ spyOn(vm, '$emit');
+ triggerEvent('mousedown', vm.$el);
+ triggerEvent('mousemove', document);
+ triggerEvent('mouseup', document);
+ expect(vm.$emit.calls.allArgs()).toEqual([['resize-start', 100], ['update:size', 100], ['resize-end', 100]]);
+ expect(vm.size).toBe(100);
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/pikaday_spec.js b/spec/javascripts/vue_shared/components/pikaday_spec.js
index 47af9534737..b349e2a2a81 100644
--- a/spec/javascripts/vue_shared/components/pikaday_spec.js
+++ b/spec/javascripts/vue_shared/components/pikaday_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import datePicker from '~/vue_shared/components/pikaday.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('datePicker', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js b/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js
index cce53193870..8c296af6652 100644
--- a/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import collapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('collapsedCalendarIcon', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
index 20363e78094..9d60f9c758f 100644
--- a/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import collapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('collapsedGroupedDatePicker', () => {
let vm;
@@ -21,22 +21,21 @@ describe('collapsedGroupedDatePicker', () => {
});
});
- it('toggleCollapse events', () => {
- const toggleCollapse = jasmine.createSpy();
-
+ describe('toggleCollapse events', () => {
beforeEach((done) => {
+ spyOn(vm, 'toggleSidebar');
vm.minDate = new Date('07/17/2016');
Vue.nextTick(done);
});
it('should emit when sidebar is toggled', () => {
vm.$el.querySelector('.gutter-toggle').click();
- expect(toggleCollapse).toHaveBeenCalled();
+ expect(vm.toggleSidebar).toHaveBeenCalled();
});
it('should emit when collapsed-calendar-icon is clicked', () => {
vm.$el.querySelector('.sidebar-collapsed-icon').click();
- expect(toggleCollapse).toHaveBeenCalled();
+ expect(vm.toggleSidebar).toHaveBeenCalled();
});
});
diff --git a/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js b/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js
index 926e11b4d30..8840a5a9dbf 100644
--- a/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('sidebarDatePicker', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js
new file mode 100644
index 00000000000..6fe95153204
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js
@@ -0,0 +1,107 @@
+import Vue from 'vue';
+
+import LabelsSelect from '~/labels_select';
+import baseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockConfig, mockLabels } from './mock_data';
+
+const createComponent = (config = mockConfig) => {
+ const Component = Vue.extend(baseComponent);
+
+ return mountComponent(Component, config);
+};
+
+describe('BaseComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hiddenInputName', () => {
+ it('returns correct string when showCreate prop is `true`', () => {
+ expect(vm.hiddenInputName).toBe('issue[label_names][]');
+ });
+
+ it('returns correct string when showCreate prop is `false`', () => {
+ const mockConfigNonEditable = Object.assign({}, mockConfig, { showCreate: false });
+ const vmNonEditable = createComponent(mockConfigNonEditable);
+ expect(vmNonEditable.hiddenInputName).toBe('label_id[]');
+ vmNonEditable.$destroy();
+ });
+ });
+
+ describe('createLabelTitle', () => {
+ it('returns `Create project label` when `isProject` prop is true', () => {
+ expect(vm.createLabelTitle).toBe('Create project label');
+ });
+
+ it('return `Create group label` when `isProject` prop is false', () => {
+ const mockConfigGroup = Object.assign({}, mockConfig, { isProject: false });
+ const vmGroup = createComponent(mockConfigGroup);
+ expect(vmGroup.createLabelTitle).toBe('Create group label');
+ vmGroup.$destroy();
+ });
+ });
+
+ describe('manageLabelsTitle', () => {
+ it('returns `Manage project labels` when `isProject` prop is true', () => {
+ expect(vm.manageLabelsTitle).toBe('Manage project labels');
+ });
+
+ it('return `Manage group labels` when `isProject` prop is false', () => {
+ const mockConfigGroup = Object.assign({}, mockConfig, { isProject: false });
+ const vmGroup = createComponent(mockConfigGroup);
+ expect(vmGroup.manageLabelsTitle).toBe('Manage group labels');
+ vmGroup.$destroy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('handleClick', () => {
+ it('emits onLabelClick event with label and list of labels as params', () => {
+ spyOn(vm, '$emit');
+ vm.handleClick(mockLabels[0]);
+ expect(vm.$emit).toHaveBeenCalledWith('onLabelClick', mockLabels[0]);
+ });
+ });
+ });
+
+ describe('mounted', () => {
+ it('creates LabelsSelect object and assigns it to `labelsDropdon` as prop', () => {
+ expect(vm.labelsDropdown instanceof LabelsSelect).toBe(true);
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with classes `block labels`', () => {
+ expect(vm.$el.classList.contains('block')).toBe(true);
+ expect(vm.$el.classList.contains('labels')).toBe(true);
+ });
+
+ it('renders `.selectbox` element', () => {
+ expect(vm.$el.querySelector('.selectbox')).not.toBeNull();
+ expect(vm.$el.querySelector('.selectbox').getAttribute('style')).toBe('display: none;');
+ });
+
+ it('renders `.dropdown` element', () => {
+ expect(vm.$el.querySelector('.dropdown')).not.toBeNull();
+ });
+
+ it('renders `.dropdown-menu` element', () => {
+ const dropdownMenuEl = vm.$el.querySelector('.dropdown-menu');
+ expect(dropdownMenuEl).not.toBeNull();
+ expect(dropdownMenuEl.querySelector('.dropdown-page-one')).not.toBeNull();
+ expect(dropdownMenuEl.querySelector('.dropdown-content')).not.toBeNull();
+ expect(dropdownMenuEl.querySelector('.dropdown-loading')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
new file mode 100644
index 00000000000..f25c70db125
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+
+import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockConfig, mockLabels } from './mock_data';
+
+const componentConfig = Object.assign({}, mockConfig, {
+ fieldName: 'label_id[]',
+ labels: mockLabels,
+ showExtraOptions: false,
+});
+
+const createComponent = (config = componentConfig) => {
+ const Component = Vue.extend(dropdownButtonComponent);
+
+ return mountComponent(Component, config);
+};
+
+describe('DropdownButtonComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('dropdownToggleText', () => {
+ it('returns text as `Label` when `labels` prop is empty array', () => {
+ const mockEmptyLabels = Object.assign({}, componentConfig, { labels: [] });
+ const vmEmptyLabels = createComponent(mockEmptyLabels);
+ expect(vmEmptyLabels.dropdownToggleText).toBe('Label');
+ vmEmptyLabels.$destroy();
+ });
+
+ it('returns first label name with remaining label count when `labels` prop has more than one item', () => {
+ const mockMoreLabels = Object.assign({}, componentConfig, {
+ labels: mockLabels.concat(mockLabels),
+ });
+ const vmMoreLabels = createComponent(mockMoreLabels);
+ expect(vmMoreLabels.dropdownToggleText).toBe('Foo Label +1 more');
+ vmMoreLabels.$destroy();
+ });
+
+ it('returns first label name when `labels` prop has only one item present', () => {
+ expect(vm.dropdownToggleText).toBe('Foo Label');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element of type `button`', () => {
+ expect(vm.$el.nodeName).toBe('BUTTON');
+ });
+
+ it('renders component container element with required data attributes', () => {
+ expect(vm.$el.dataset.abilityName).toBe(vm.abilityName);
+ expect(vm.$el.dataset.fieldName).toBe(vm.fieldName);
+ expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath);
+ expect(vm.$el.dataset.labels).toBe(vm.labelsPath);
+ expect(vm.$el.dataset.namespacePath).toBe(vm.namespace);
+ expect(vm.$el.dataset.showAny).not.toBeDefined();
+ });
+
+ it('renders dropdown toggle text element', () => {
+ const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
+ expect(dropdownToggleTextEl).not.toBeNull();
+ expect(dropdownToggleTextEl.innerText.trim()).toBe('Foo Label');
+ });
+
+ it('renders dropdown button icon', () => {
+ const dropdownIconEl = vm.$el.querySelector('i.fa');
+ expect(dropdownIconEl).not.toBeNull();
+ expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
new file mode 100644
index 00000000000..ce559fe0335
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
@@ -0,0 +1,94 @@
+import Vue from 'vue';
+
+import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockSuggestedColors } from './mock_data';
+
+const createComponent = (headerTitle) => {
+ const Component = Vue.extend(dropdownCreateLabelComponent);
+
+ return mountComponent(Component, {
+ headerTitle,
+ });
+};
+
+describe('DropdownCreateLabelComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ gon.suggested_label_colors = mockSuggestedColors;
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('created', () => {
+ it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => {
+ expect(vm.suggestedColors.length).toBe(mockSuggestedColors.length);
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with classes `dropdown-page-two dropdown-new-label`', () => {
+ expect(vm.$el.classList.contains('dropdown-page-two', 'dropdown-new-label')).toBe(true);
+ });
+
+ it('renders `Go back` button on component header', () => {
+ const backButtonEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-back');
+ expect(backButtonEl).not.toBe(null);
+ expect(backButtonEl.querySelector('.fa-arrow-left')).not.toBe(null);
+ });
+
+ it('renders component header element as `Create new label` when `headerTitle` prop is not provided', () => {
+ const headerEl = vm.$el.querySelector('.dropdown-title');
+ expect(headerEl.innerText.trim()).toContain('Create new label');
+ });
+
+ it('renders component header element with value of `headerTitle` prop', () => {
+ const headerTitle = 'Create project label';
+ const vmWithHeaderTitle = createComponent(headerTitle);
+ const headerEl = vmWithHeaderTitle.$el.querySelector('.dropdown-title');
+ expect(headerEl.innerText.trim()).toContain(headerTitle);
+ vmWithHeaderTitle.$destroy();
+ });
+
+ it('renders `Close` button on component header', () => {
+ const closeButtonEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-close');
+ expect(closeButtonEl).not.toBe(null);
+ expect(closeButtonEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBe(null);
+ });
+
+ it('renders `Name new label` input element', () => {
+ expect(vm.$el.querySelector('.dropdown-labels-error.js-label-error')).not.toBe(null);
+ expect(vm.$el.querySelector('input#new_label_name.default-dropdown-input')).not.toBe(null);
+ });
+
+ it('renders suggested colors list elements', () => {
+ const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown');
+ expect(colorsListContainerEl).not.toBe(null);
+ expect(colorsListContainerEl.querySelectorAll('a').length).toBe(mockSuggestedColors.length);
+
+ const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0];
+ expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0]);
+ expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 51, 204);');
+ });
+
+ it('renders color input element', () => {
+ expect(vm.$el.querySelector('.dropdown-label-color-input')).not.toBe(null);
+ expect(vm.$el.querySelector('.dropdown-label-color-preview.js-dropdown-label-color-preview')).not.toBe(null);
+ expect(vm.$el.querySelector('input#new_label_color.default-dropdown-input')).not.toBe(null);
+ });
+
+ it('renders component action buttons', () => {
+ const createBtnEl = vm.$el.querySelector('button.js-new-label-btn');
+ const cancelBtnEl = vm.$el.querySelector('button.js-cancel-label-btn');
+ expect(createBtnEl).not.toBe(null);
+ expect(createBtnEl.innerText.trim()).toBe('Create');
+ expect(cancelBtnEl.innerText.trim()).toBe('Cancel');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
new file mode 100644
index 00000000000..debeab25bd6
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
@@ -0,0 +1,68 @@
+import Vue from 'vue';
+
+import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockConfig } from './mock_data';
+
+const createComponent = (
+ labelsWebUrl = mockConfig.labelsWebUrl,
+ createLabelTitle,
+ manageLabelsTitle,
+) => {
+ const Component = Vue.extend(dropdownFooterComponent);
+
+ return mountComponent(Component, {
+ labelsWebUrl,
+ createLabelTitle,
+ manageLabelsTitle,
+ });
+};
+
+describe('DropdownFooterComponent', () => {
+ const createLabelTitle = 'Create project label';
+ const manageLabelsTitle = 'Manage project labels';
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('renders link element with `Create new label` when `createLabelTitle` prop is not provided', () => {
+ const createLabelEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page');
+ expect(createLabelEl).not.toBeNull();
+ expect(createLabelEl.innerText.trim()).toBe('Create new label');
+ });
+
+ it('renders link element with value of `createLabelTitle` prop', () => {
+ const vmWithCreateLabelTitle = createComponent(mockConfig.labelsWebUrl, createLabelTitle);
+ const createLabelEl = vmWithCreateLabelTitle.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page');
+ expect(createLabelEl.innerText.trim()).toBe(createLabelTitle);
+ vmWithCreateLabelTitle.$destroy();
+ });
+
+ it('renders link element with `Manage labels` when `manageLabelsTitle` prop is not provided', () => {
+ const manageLabelsEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-external-link');
+ expect(manageLabelsEl).not.toBeNull();
+ expect(manageLabelsEl.getAttribute('href')).toBe(vm.labelsWebUrl);
+ expect(manageLabelsEl.innerText.trim()).toBe('Manage labels');
+ });
+
+ it('renders link element with value of `manageLabelsTitle` prop', () => {
+ const vmWithManageLabelsTitle = createComponent(
+ mockConfig.labelsWebUrl,
+ createLabelTitle,
+ manageLabelsTitle,
+ );
+ const manageLabelsEl = vmWithManageLabelsTitle.$el.querySelector('.dropdown-footer-list .dropdown-external-link');
+ expect(manageLabelsEl.innerText.trim()).toBe(manageLabelsTitle);
+ vmWithManageLabelsTitle.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
new file mode 100644
index 00000000000..cdf234bb0c4
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+
+import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = () => {
+ const Component = Vue.extend(dropdownHeaderComponent);
+
+ return mountComponent(Component);
+};
+
+describe('DropdownHeaderComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('renders header text element', () => {
+ const headerEl = vm.$el.querySelector('.dropdown-title span');
+ expect(headerEl.innerText.trim()).toBe('Assign labels');
+ });
+
+ it('renders `Close` button element', () => {
+ const closeBtnEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-close');
+ expect(closeBtnEl).not.toBeNull();
+ expect(closeBtnEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js
new file mode 100644
index 00000000000..88733922a59
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+
+import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockLabels } from './mock_data';
+
+const createComponent = (name = 'label_id[]', label = mockLabels[0]) => {
+ const Component = Vue.extend(dropdownHiddenInputComponent);
+
+ return mountComponent(Component, {
+ name,
+ label,
+ });
+};
+
+describe('DropdownHiddenInputComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('renders input element of type `hidden`', () => {
+ expect(vm.$el.nodeName).toBe('INPUT');
+ expect(vm.$el.getAttribute('type')).toBe('hidden');
+ expect(vm.$el.getAttribute('name')).toBe(vm.name);
+ expect(vm.$el.getAttribute('value')).toBe(`${vm.label.id}`);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js
new file mode 100644
index 00000000000..57608d957e7
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+
+import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = () => {
+ const Component = Vue.extend(dropdownSearchInputComponent);
+
+ return mountComponent(Component);
+};
+
+describe('DropdownSearchInputComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('renders input element with type `search`', () => {
+ const inputEl = vm.$el.querySelector('input.dropdown-input-field');
+ expect(inputEl).not.toBeNull();
+ expect(inputEl.getAttribute('type')).toBe('search');
+ });
+
+ it('renders search icon element', () => {
+ expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull();
+ });
+
+ it('renders clear search icon element', () => {
+ expect(vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js
new file mode 100644
index 00000000000..7c3d2711f65
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+
+import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = (canEdit = true) => {
+ const Component = Vue.extend(dropdownTitleComponent);
+
+ return mountComponent(Component, {
+ canEdit,
+ });
+};
+
+describe('DropdownTitleComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('renders title text', () => {
+ expect(vm.$el.classList.contains('title', 'hide-collapsed')).toBe(true);
+ expect(vm.$el.innerText.trim()).toContain('Labels');
+ });
+
+ it('renders spinner icon element', () => {
+ expect(vm.$el.querySelector('.fa-spinner.fa-spin.block-loading')).not.toBeNull();
+ });
+
+ it('renders `Edit` button element', () => {
+ const editBtnEl = vm.$el.querySelector('button.edit-link.js-sidebar-dropdown-toggle');
+ expect(editBtnEl).not.toBeNull();
+ expect(editBtnEl.innerText.trim()).toBe('Edit');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
new file mode 100644
index 00000000000..39040670a87
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
@@ -0,0 +1,74 @@
+import Vue from 'vue';
+
+import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockLabels } from './mock_data';
+
+const createComponent = (labels = mockLabels) => {
+ const Component = Vue.extend(dropdownValueCollapsedComponent);
+
+ return mountComponent(Component, {
+ labels,
+ });
+};
+
+describe('DropdownValueCollapsedComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('labelsList', () => {
+ it('returns empty text when `labels` prop is empty array', () => {
+ const vmEmptyLabels = createComponent([]);
+ expect(vmEmptyLabels.labelsList).toBe('');
+ vmEmptyLabels.$destroy();
+ });
+
+ it('returns labels names separated by coma when `labels` prop has more than one item', () => {
+ const vmMoreLabels = createComponent(mockLabels.concat(mockLabels));
+ expect(vmMoreLabels.labelsList).toBe('Foo Label, Foo Label');
+ vmMoreLabels.$destroy();
+ });
+
+ it('returns labels names separated by coma with remaining labels count and `and more` phrase when `labels` prop has more than five items', () => {
+ const mockMoreLabels = Object.assign([], mockLabels);
+ for (let i = 0; i < 6; i += 1) {
+ mockMoreLabels.unshift(mockLabels[0]);
+ }
+
+ const vmMoreLabels = createComponent(mockMoreLabels);
+ expect(vmMoreLabels.labelsList).toBe('Foo Label, Foo Label, Foo Label, Foo Label, Foo Label, and 2 more');
+ vmMoreLabels.$destroy();
+ });
+
+ it('returns first label name when `labels` prop has only one item present', () => {
+ expect(vm.labelsList).toBe('Foo Label');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with tooltip`', () => {
+ expect(vm.$el.dataset.placement).toBe('left');
+ expect(vm.$el.dataset.container).toBe('body');
+ expect(vm.$el.dataset.originalTitle).toBe(vm.labelsList);
+ });
+
+ it('renders tags icon element', () => {
+ expect(vm.$el.querySelector('.fa-tags')).not.toBeNull();
+ });
+
+ it('renders labels count', () => {
+ expect(vm.$el.querySelector('span').innerText.trim()).toBe(`${vm.labels.length}`);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
new file mode 100644
index 00000000000..4397b00acfa
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
@@ -0,0 +1,94 @@
+import Vue from 'vue';
+
+import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockConfig, mockLabels } from './mock_data';
+
+const createComponent = (
+ labels = mockLabels,
+ labelFilterBasePath = mockConfig.labelFilterBasePath,
+) => {
+ const Component = Vue.extend(dropdownValueComponent);
+
+ return mountComponent(Component, {
+ labels,
+ labelFilterBasePath,
+ });
+};
+
+describe('DropdownValueComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('isEmpty', () => {
+ it('returns true if `labels` prop is empty', () => {
+ const vmEmptyLabels = createComponent([]);
+ expect(vmEmptyLabels.isEmpty).toBe(true);
+ vmEmptyLabels.$destroy();
+ });
+
+ it('returns false if `labels` prop is empty', () => {
+ expect(vm.isEmpty).toBe(false);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('labelFilterUrl', () => {
+ it('returns URL string starting with labelFilterBasePath and encoded label.title', () => {
+ expect(vm.labelFilterUrl({
+ title: 'Foo bar',
+ })).toBe('/gitlab-org/my-project/issues?label_name[]=Foo%20bar');
+ });
+ });
+
+ describe('labelStyle', () => {
+ it('returns object with `color` & `backgroundColor` properties from label.textColor & label.color', () => {
+ const label = {
+ textColor: '#FFFFFF',
+ color: '#BADA55',
+ };
+ const styleObj = vm.labelStyle(label);
+
+ expect(styleObj.color).toBe(label.textColor);
+ expect(styleObj.backgroundColor).toBe(label.color);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with classes `hide-collapsed value issuable-show-labels`', () => {
+ expect(vm.$el.classList.contains('hide-collapsed', 'value', 'issuable-show-labels')).toBe(true);
+ });
+
+ it('render slot content inside component when `labels` prop is empty', () => {
+ const vmEmptyLabels = createComponent([]);
+ expect(vmEmptyLabels.$el.querySelector('.text-secondary').innerText.trim()).toBe(mockConfig.emptyValueText);
+ vmEmptyLabels.$destroy();
+ });
+
+ it('renders label element with filter URL', () => {
+ expect(vm.$el.querySelector('a').getAttribute('href')).toBe('/gitlab-org/my-project/issues?label_name[]=Foo%20Label');
+ });
+
+ it('renders label element with tooltip and styles based on label details', () => {
+ const labelEl = vm.$el.querySelector('a span.label.color-label');
+ expect(labelEl).not.toBeNull();
+ expect(labelEl.dataset.placement).toBe('bottom');
+ expect(labelEl.dataset.container).toBe('body');
+ expect(labelEl.dataset.originalTitle).toBe(mockLabels[0].description);
+ expect(labelEl.getAttribute('style')).toBe('background-color: rgb(186, 218, 85);');
+ expect(labelEl.innerText.trim()).toBe(mockLabels[0].title);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js
new file mode 100644
index 00000000000..3fcb91b6f5e
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js
@@ -0,0 +1,50 @@
+export const mockLabels = [
+ {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ text_color: '#FFFFFF',
+ },
+];
+
+export const mockSuggestedColors = [
+ '#0033CC',
+ '#428BCA',
+ '#44AD8E',
+ '#A8D695',
+ '#5CB85C',
+ '#69D100',
+ '#004E00',
+ '#34495E',
+ '#7F8C8D',
+ '#A295D6',
+ '#5843AD',
+ '#8E44AD',
+ '#FFECDB',
+ '#AD4363',
+ '#D10069',
+ '#CC0033',
+ '#FF0000',
+ '#D9534F',
+ '#D1D100',
+ '#F0AD4E',
+ '#AD8D43',
+];
+
+export const mockConfig = {
+ showCreate: true,
+ isProject: true,
+ abilityName: 'issue',
+ context: {
+ labels: mockLabels,
+ },
+ namespace: 'gitlab-org',
+ updatePath: '/gitlab-org/my-project/issue/1',
+ labelsPath: '/gitlab-org/my-project/labels.json',
+ labelsWebUrl: '/gitlab-org/my-project/labels',
+ labelFilterBasePath: '/gitlab-org/my-project/issues',
+ canEdit: true,
+ suggestedColors: mockSuggestedColors,
+ emptyValueText: 'None',
+};
diff --git a/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js b/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js
index 752a9e89d50..c911a129173 100644
--- a/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import toggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('toggleSidebar', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js b/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js
index a5db0b2c59e..bbd50863069 100644
--- a/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js
+++ b/spec/javascripts/vue_shared/components/skeleton_loading_container_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Skeleton loading container', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js b/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js
new file mode 100644
index 00000000000..de3bf667fb3
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/stacked_progress_bar_spec.js
@@ -0,0 +1,77 @@
+import Vue from 'vue';
+
+import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = (config) => {
+ const Component = Vue.extend(stackedProgressBarComponent);
+ const defaultConfig = Object.assign({}, {
+ successLabel: 'Synced',
+ failureLabel: 'Failed',
+ neutralLabel: 'Out of sync',
+ successCount: 10,
+ failureCount: 5,
+ totalCount: 20,
+ }, config);
+
+ return mountComponent(Component, defaultConfig);
+};
+
+describe('StackedProgressBarComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('neutralCount', () => {
+ it('returns neutralCount based on totalCount, successCount and failureCount', () => {
+ expect(vm.neutralCount).toBe(5); // 20 - 10 - 5
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('getPercent', () => {
+ it('returns percentage from provided count based on `totalCount`', () => {
+ expect(vm.getPercent(10)).toBe(50);
+ });
+ });
+
+ describe('barStyle', () => {
+ it('returns style string based on percentage provided', () => {
+ expect(vm.barStyle(50)).toBe('width: 50%;');
+ });
+ });
+
+ describe('getTooltip', () => {
+ it('returns label string based on label and count provided', () => {
+ expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders container element', () => {
+ expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy();
+ });
+
+ it('renders empty state when count is unavailable', () => {
+ const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 });
+ expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0);
+ vmX.$destroy();
+ });
+
+ it('renders bar elements when count is available', () => {
+ expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0);
+ expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0);
+ expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/javascripts/vue_shared/components/table_pagination_spec.js
index 1465ef5855f..c63f15e5880 100644
--- a/spec/javascripts/vue_shared/components/table_pagination_spec.js
+++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js
@@ -32,7 +32,7 @@ describe('Pagination component', () => {
change: spy,
});
- expect(component.$el.innerHTML).not.toBeDefined();
+ expect(component.$el.childNodes.length).toEqual(0);
});
describe('prev button', () => {
@@ -72,7 +72,6 @@ describe('Pagination component', () => {
});
component.$el.querySelector('.js-previous-button a').click();
-
expect(spy).toHaveBeenCalledWith(1);
});
});
diff --git a/spec/javascripts/vue_shared/components/toggle_button_spec.js b/spec/javascripts/vue_shared/components/toggle_button_spec.js
index 859995d33fa..71952cc39e0 100644
--- a/spec/javascripts/vue_shared/components/toggle_button_spec.js
+++ b/spec/javascripts/vue_shared/components/toggle_button_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import toggleButton from '~/vue_shared/components/toggle_button.vue';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Toggle Button', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
index aa93134f2dd..446f025c127 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { placeholderImage } from '~/lazy_loader';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const DEFAULT_PROPS = {
size: 99,
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
index 8450ad9dbcb..adf80d0c2bb 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import Vue from 'vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
diff --git a/spec/javascripts/vue_shared/directives/tooltip_spec.js b/spec/javascripts/vue_shared/directives/tooltip_spec.js
index b1b3071527b..4a644913e44 100644
--- a/spec/javascripts/vue_shared/directives/tooltip_spec.js
+++ b/spec/javascripts/vue_shared/directives/tooltip_spec.js
@@ -1,3 +1,4 @@
+import $ from 'jquery';
import Vue from 'vue';
import tooltip from '~/vue_shared/directives/tooltip';
diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js
index 45a0bb0650f..7fe3bd92049 100644
--- a/spec/javascripts/zen_mode_spec.js
+++ b/spec/javascripts/zen_mode_spec.js
@@ -1,4 +1,5 @@
-/* global Mousetrap */
+import $ from 'jquery';
+import Mousetrap from 'mousetrap';
import Dropzone from 'dropzone';
import ZenMode from '~/zen_mode';
diff --git a/spec/lib/api/helpers/related_resources_helpers_spec.rb b/spec/lib/api/helpers/related_resources_helpers_spec.rb
new file mode 100644
index 00000000000..b918301f1cb
--- /dev/null
+++ b/spec/lib/api/helpers/related_resources_helpers_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+describe API::Helpers::RelatedResourcesHelpers do
+ subject(:helpers) do
+ Class.new.include(described_class).new
+ end
+
+ describe '#expose_url' do
+ let(:path) { '/api/v4/awesome_endpoint' }
+ subject(:url) { helpers.expose_url(path) }
+
+ def stub_default_url_options(protocol: 'http', host: 'example.com', port: nil)
+ expect(Gitlab::Application.routes).to receive(:default_url_options)
+ .and_return(protocol: protocol, host: host, port: port)
+ end
+
+ it 'respects the protocol if it is HTTP' do
+ stub_default_url_options(protocol: 'http')
+
+ is_expected.to start_with('http://')
+ end
+
+ it 'respects the protocol if it is HTTPS' do
+ stub_default_url_options(protocol: 'https')
+
+ is_expected.to start_with('https://')
+ end
+
+ it 'accepts port to be nil' do
+ stub_default_url_options(port: nil)
+
+ is_expected.to start_with('http://example.com/')
+ end
+
+ it 'includes port if provided' do
+ stub_default_url_options(port: 8080)
+
+ is_expected.to start_with('http://example.com:8080/')
+ end
+ end
+end
diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb
new file mode 100644
index 00000000000..14d055cbcc1
--- /dev/null
+++ b/spec/lib/backup/files_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+
+describe Backup::Files do
+ let(:progress) { StringIO.new }
+ let!(:project) { create(:project) }
+
+ before do
+ allow(progress).to receive(:puts)
+ allow(progress).to receive(:print)
+ allow(FileUtils).to receive(:mkdir_p).and_return(true)
+ allow(FileUtils).to receive(:mv).and_return(true)
+ allow(File).to receive(:exist?).and_return(true)
+ allow(File).to receive(:realpath).with("/var/gitlab-registry").and_return("/var/gitlab-registry")
+ allow(File).to receive(:realpath).with("/var/gitlab-registry/..").and_return("/var")
+
+ 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 '#restore' do
+ subject { described_class.new('registry', '/var/gitlab-registry') }
+ let(:timestamp) { Time.utc(2017, 3, 22) }
+
+ around do |example|
+ Timecop.freeze(timestamp) { example.run }
+ end
+
+ describe 'folders with permission' do
+ before do
+ allow(subject).to receive(:run_pipeline!).and_return(true)
+ allow(subject).to receive(:backup_existing_files).and_return(true)
+ allow(Dir).to receive(:glob).with("/var/gitlab-registry/*", File::FNM_DOTMATCH).and_return(["/var/gitlab-registry/.", "/var/gitlab-registry/..", "/var/gitlab-registry/sample1"])
+ end
+
+ it 'moves all necessary files' do
+ allow(subject).to receive(:backup_existing_files).and_call_original
+ expect(FileUtils).to receive(:mv).with(["/var/gitlab-registry/sample1"], File.join(Gitlab.config.backup.path, "tmp", "registry.#{Time.now.to_i}"))
+ subject.restore
+ end
+
+ it 'raises no errors' do
+ expect { subject.restore }.not_to raise_error
+ end
+
+ it 'calls tar command with unlink' do
+ expect(subject).to receive(:run_pipeline!).with([%w(gzip -cd), %w(tar --unlink-first --recursive-unlink -C /var/gitlab-registry -xf -)], any_args)
+ subject.restore
+ end
+ end
+
+ describe 'folders without permissions' do
+ before do
+ allow(FileUtils).to receive(:mv).and_raise(Errno::EACCES)
+ allow(subject).to receive(:run_pipeline!).and_return(true)
+ end
+
+ it 'shows error message' do
+ expect(subject).to receive(:access_denied_error).with("/var/gitlab-registry")
+ subject.restore
+ end
+ end
+ end
+end
diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb
index b68301a066a..84688845fa5 100644
--- a/spec/lib/backup/manager_spec.rb
+++ b/spec/lib/backup/manager_spec.rb
@@ -194,6 +194,12 @@ describe Backup::Manager do
)
end
+ it 'prints the list of available backups' do
+ expect { subject.unpack }.to raise_error SystemExit
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('1451606400_2016_01_01_1.2.3\n 1451520000_2015_12_31'))
+ end
+
it 'fails the operation and prints an error' do
expect { subject.unpack }.to raise_error SystemExit
expect(progress).to have_received(:puts)
@@ -272,6 +278,10 @@ describe Backup::Manager do
connection.directories.create(key: Gitlab.config.backup.upload.remote_directory)
end
+ after do
+ Fog.unmock!
+ end
+
context 'target path' do
it 'uses the tar filename by default' do
expect_any_instance_of(Fog::Collection).to receive(:create)
diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb
index 6ee3d531d6e..e4c1c9bafc0 100644
--- a/spec/lib/backup/repository_spec.rb
+++ b/spec/lib/backup/repository_spec.rb
@@ -7,6 +7,8 @@ describe Backup::Repository do
before do
allow(progress).to receive(:puts)
allow(progress).to receive(:print)
+ allow(FileUtils).to receive(:mkdir_p).and_return(true)
+ allow(FileUtils).to receive(:mv).and_return(true)
allow_any_instance_of(String).to receive(:color) do |string, _color|
string
@@ -28,15 +30,55 @@ describe Backup::Repository do
end
describe '#restore' do
+ subject { described_class.new }
+
+ let(:timestamp) { Time.utc(2017, 3, 22) }
+ let(:temp_dirs) do
+ Gitlab.config.repositories.storages.map do |name, storage|
+ File.join(storage.legacy_disk_path, '..', 'repositories.old.' + timestamp.to_i.to_s)
+ end
+ end
+
+ around do |example|
+ Timecop.freeze(timestamp) { example.run }
+ end
+
+ after do
+ temp_dirs.each { |path| FileUtils.rm_rf(path) }
+ end
+
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
+ context 'hashed storage' do
+ it 'shows the appropriate error' do
+ subject.restore
+
+ expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} (#{project.disk_path}) - error")
+ end
+ end
+
+ context 'legacy storage' do
+ let!(:project) { create(:project, :legacy_storage) }
+
+ it 'shows the appropriate error' do
+ subject.restore
+
+ expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error")
+ end
+ end
+ end
+
+ describe 'folders without permissions' do
+ before do
+ allow(FileUtils).to receive(:mv).and_raise(Errno::EACCES)
+ end
- expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error")
+ it 'shows error message' do
+ expect(subject).to receive(:access_denied_error)
+ subject.restore
end
end
end
diff --git a/spec/lib/banzai/color_parser_spec.rb b/spec/lib/banzai/color_parser_spec.rb
new file mode 100644
index 00000000000..a1cb0c07b06
--- /dev/null
+++ b/spec/lib/banzai/color_parser_spec.rb
@@ -0,0 +1,90 @@
+require 'spec_helper'
+
+describe Banzai::ColorParser do
+ describe '.parse' do
+ context 'HEX format' do
+ [
+ '#abc', '#ABC',
+ '#d2d2d2', '#D2D2D2',
+ '#123a', '#123A',
+ '#123456aa', '#123456AA'
+ ].each do |color|
+ it "parses the valid hex color #{color}" do
+ expect(subject.parse(color)).to eq(color)
+ end
+ end
+
+ [
+ '#', '#1', '#12', '#12g', '#12G',
+ '#12345', '#r2r2r2', '#R2R2R2', '#1234567',
+ '# 123', '# 1234', '# 123456', '# 12345678',
+ '#1 2 3', '#123 4', '#12 34 56', '#123456 78'
+ ].each do |color|
+ it "does not parse the invalid hex color #{color}" do
+ expect(subject.parse(color)).to be_nil
+ end
+ end
+ end
+
+ context 'RGB format' do
+ [
+ 'rgb(0,0,0)', 'rgb(255,255,255)',
+ 'rgb(0, 0, 0)', 'RGB(0,0,0)',
+ 'rgb(0,0,0,0)', 'rgb(0,0,0,0.0)', 'rgb(0,0,0,.0)',
+ 'rgb(0,0,0, 0)', 'rgb(0,0,0, 0.0)', 'rgb(0,0,0, .0)',
+ 'rgb(0,0,0,1)', 'rgb(0,0,0,1.0)',
+ 'rgba(0,0,0)', 'rgba(0,0,0,0)', 'RGBA(0,0,0)',
+ 'rgb(0%,0%,0%)', 'rgba(0%,0%,0%,0%)'
+ ].each do |color|
+ it "parses the valid rgb color #{color}" do
+ expect(subject.parse(color)).to eq(color)
+ end
+ end
+
+ [
+ 'FOOrgb(0,0,0)', 'rgb(0,0,0)BAR',
+ 'rgb(0,0,-1)', 'rgb(0,0,-0)', 'rgb(0,0,256)',
+ 'rgb(0,0,0,-0.1)', 'rgb(0,0,0,-0.0)', 'rgb(0,0,0,-.1)',
+ 'rgb(0,0,0,1.1)', 'rgb(0,0,0,2)',
+ 'rgba(0,0,0,)', 'rgba(0,0,0,0.)', 'rgba(0,0,0,1.)',
+ 'rgb(0,0,0%)', 'rgb(101%,0%,0%)'
+ ].each do |color|
+ it "does not parse the invalid rgb color #{color}" do
+ expect(subject.parse(color)).to be_nil
+ end
+ end
+ end
+
+ context 'HSL format' do
+ [
+ 'hsl(0,0%,0%)', 'hsl(0,100%,100%)',
+ 'hsl(540,0%,0%)', 'hsl(-720,0%,0%)',
+ 'hsl(0deg,0%,0%)', 'hsl(0DEG,0%,0%)',
+ 'hsl(0, 0%, 0%)', 'HSL(0,0%,0%)',
+ 'hsl(0,0%,0%,0)', 'hsl(0,0%,0%,0.0)', 'hsl(0,0%,0%,.0)',
+ 'hsl(0,0%,0%, 0)', 'hsl(0,0%,0%, 0.0)', 'hsl(0,0%,0%, .0)',
+ 'hsl(0,0%,0%,1)', 'hsl(0,0%,0%,1.0)',
+ 'hsla(0,0%,0%)', 'hsla(0,0%,0%,0)', 'HSLA(0,0%,0%)',
+ 'hsl(1rad,0%,0%)', 'hsl(1.1rad,0%,0%)', 'hsl(.1rad,0%,0%)',
+ 'hsl(-1rad,0%,0%)', 'hsl(1RAD,0%,0%)'
+ ].each do |color|
+ it "parses the valid hsl color #{color}" do
+ expect(subject.parse(color)).to eq(color)
+ end
+ end
+
+ [
+ 'hsl(+0,0%,0%)', 'hsl(0,0,0%)', 'hsl(0,0%,0)', 'hsl(0 deg,0%,0%)',
+ 'hsl(0,-0%,0%)', 'hsl(0,101%,0%)', 'hsl(0,-1%,0%)',
+ 'hsl(0,0%,0%,-0.1)', 'hsl(0,0%,0%,-.1)',
+ 'hsl(0,0%,0%,1.1)', 'hsl(0,0%,0%,2)',
+ 'hsl(0,0%,0%,)', 'hsl(0,0%,0%,0.)', 'hsl(0,0%,0%,1.)',
+ 'hsl(deg,0%,0%)', 'hsl(rad,0%,0%)'
+ ].each do |color|
+ it "does not parse the invalid hsl color #{color}" do
+ expect(subject.parse(color)).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/banzai/commit_renderer_spec.rb b/spec/lib/banzai/commit_renderer_spec.rb
index 84adaebdcbe..e7ebb2a332f 100644
--- a/spec/lib/banzai/commit_renderer_spec.rb
+++ b/spec/lib/banzai/commit_renderer_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Banzai::CommitRenderer do
describe '.render' do
it 'renders a commit description and title' do
- user = double(:user)
+ user = build(:user)
project = create(:project, :repository)
expect(Banzai::ObjectRenderer).to receive(:new).with(project, user).and_call_original
diff --git a/spec/lib/banzai/cross_project_reference_spec.rb b/spec/lib/banzai/cross_project_reference_spec.rb
index 68ca960caab..aadfe7637dd 100644
--- a/spec/lib/banzai/cross_project_reference_spec.rb
+++ b/spec/lib/banzai/cross_project_reference_spec.rb
@@ -14,6 +14,16 @@ describe Banzai::CrossProjectReference do
end
end
+ context 'when no project was referenced in group context' do
+ it 'returns the group from context' do
+ group = double
+
+ allow(self).to receive(:context).and_return({ group: group })
+
+ expect(parent_from_ref(nil)).to eq group
+ end
+ end
+
context 'when referenced project does not exist' do
it 'returns nil' do
expect(parent_from_ref('invalid/reference')).to be_nil
diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb
index b7c2ff03125..a50329473ad 100644
--- a/spec/lib/banzai/filter/autolink_filter_spec.rb
+++ b/spec/lib/banzai/filter/autolink_filter_spec.rb
@@ -4,6 +4,7 @@ describe Banzai::Filter::AutolinkFilter do
include FilterSpecHelper
let(:link) { 'http://about.gitlab.com/' }
+ let(:quotes) { ['"', "'"] }
it 'does nothing when :autolink is false' do
exp = act = link
@@ -15,17 +16,7 @@ describe Banzai::Filter::AutolinkFilter do
expect(filter(act).to_html).to eq exp
end
- context 'when the input contains no links' do
- it 'does not parse_html back the rinku returned value' do
- act = HTML::Pipeline.parse('<p>This text contains no links to autolink</p>')
-
- expect_any_instance_of(described_class).not_to receive(:parse_html)
-
- filter(act).to_html
- end
- end
-
- context 'Rinku schemes' do
+ context 'Various schemes' do
it 'autolinks http' do
doc = filter("See #{link}")
expect(doc.at_css('a').text).to eq link
@@ -56,32 +47,26 @@ describe Banzai::Filter::AutolinkFilter do
expect(doc.at_css('a')['href']).to eq link
end
- it 'accepts link_attr options' do
- doc = filter("See #{link}", link_attr: { class: 'custom' })
+ it 'autolinks multiple URLs' do
+ link1 = 'http://localhost:3000/'
+ link2 = 'http://google.com/'
- expect(doc.at_css('a')['class']).to eq 'custom'
- end
+ doc = filter("See #{link1} and #{link2}")
- described_class::IGNORE_PARENTS.each do |elem|
- it "ignores valid links contained inside '#{elem}' element" do
- exp = act = "<#{elem}>See #{link}</#{elem}>"
- expect(filter(act).to_html).to eq exp
- end
- end
+ found_links = doc.css('a')
- context 'when the input contains link' do
- it 'does parse_html back the rinku returned value' do
- act = HTML::Pipeline.parse("<p>See #{link}</p>")
+ expect(found_links.size).to eq(2)
+ expect(found_links[0].text).to eq(link1)
+ expect(found_links[0]['href']).to eq(link1)
+ expect(found_links[1].text).to eq(link2)
+ expect(found_links[1]['href']).to eq(link2)
+ end
- expect_any_instance_of(described_class).to receive(:parse_html).at_least(:once).and_call_original
+ it 'accepts link_attr options' do
+ doc = filter("See #{link}", link_attr: { class: 'custom' })
- filter(act).to_html
- end
+ expect(doc.at_css('a')['class']).to eq 'custom'
end
- end
-
- context 'other schemes' do
- let(:link) { 'foo://bar.baz/' }
it 'autolinks smb' do
link = 'smb:///Volumes/shared/foo.pdf'
@@ -91,6 +76,21 @@ describe Banzai::Filter::AutolinkFilter do
expect(doc.at_css('a')['href']).to eq link
end
+ it 'autolinks multiple occurences of smb' do
+ link1 = 'smb:///Volumes/shared/foo.pdf'
+ link2 = 'smb:///Volumes/shared/bar.pdf'
+
+ doc = filter("See #{link1} and #{link2}")
+
+ found_links = doc.css('a')
+
+ expect(found_links.size).to eq(2)
+ expect(found_links[0].text).to eq(link1)
+ expect(found_links[0]['href']).to eq(link1)
+ expect(found_links[1].text).to eq(link2)
+ expect(found_links[1]['href']).to eq(link2)
+ end
+
it 'autolinks irc' do
link = 'irc://irc.freenode.net/git'
doc = filter("See #{link}")
@@ -122,14 +122,58 @@ describe Banzai::Filter::AutolinkFilter do
end
it 'does not include trailing punctuation' do
- doc = filter("See #{link}.")
- expect(doc.at_css('a').text).to eq link
+ ['.', ', ok?', '...', '?', '!', ': is that ok?'].each do |trailing_punctuation|
+ doc = filter("See #{link}#{trailing_punctuation}")
+ expect(doc.at_css('a').text).to eq link
+ end
+ end
- doc = filter("See #{link}, ok?")
- expect(doc.at_css('a').text).to eq link
+ it 'includes trailing punctuation when part of a balanced pair' do
+ described_class::PUNCTUATION_PAIRS.each do |close, open|
+ next if open.in?(quotes)
- doc = filter("See #{link}...")
- expect(doc.at_css('a').text).to eq link
+ balanced_link = "#{link}#{open}abc#{close}"
+ balanced_actual = filter("See #{balanced_link}...")
+ unbalanced_link = "#{link}#{close}"
+ unbalanced_actual = filter("See #{unbalanced_link}...")
+
+ expect(balanced_actual.at_css('a').text).to eq(balanced_link)
+ expect(unescape(balanced_actual.to_html)).to eq(Rinku.auto_link("See #{balanced_link}..."))
+ expect(unbalanced_actual.at_css('a').text).to eq(link)
+ expect(unescape(unbalanced_actual.to_html)).to eq(Rinku.auto_link("See #{unbalanced_link}..."))
+ end
+ end
+
+ it 'removes trailing quotes' do
+ quotes.each do |quote|
+ balanced_link = "#{link}#{quote}abc#{quote}"
+ balanced_actual = filter("See #{balanced_link}...")
+ unbalanced_link = "#{link}#{quote}"
+ unbalanced_actual = filter("See #{unbalanced_link}...")
+
+ expect(balanced_actual.at_css('a').text).to eq(balanced_link[0...-1])
+ expect(unescape(balanced_actual.to_html)).to eq(Rinku.auto_link("See #{balanced_link}..."))
+ expect(unbalanced_actual.at_css('a').text).to eq(link)
+ expect(unescape(unbalanced_actual.to_html)).to eq(Rinku.auto_link("See #{unbalanced_link}..."))
+ end
+ end
+
+ it 'removes one closing punctuation mark when the punctuation in the link is unbalanced' do
+ complicated_link = "(#{link}(a'b[c'd]))'"
+ expected_complicated_link = %Q{(<a href="#{link}(a'b[c'd]))">#{link}(a'b[c'd]))</a>'}
+ actual = unescape(filter(complicated_link).to_html)
+
+ expect(actual).to eq(Rinku.auto_link(complicated_link))
+ expect(actual).to eq(expected_complicated_link)
+ end
+
+ it 'does not double-encode HTML entities' do
+ encoded_link = "#{link}?foo=bar&amp;baz=quux"
+ expected_encoded_link = %Q{<a href="#{encoded_link}">#{encoded_link}</a>}
+ actual = unescape(filter(encoded_link).to_html)
+
+ expect(actual).to eq(Rinku.auto_link(encoded_link))
+ expect(actual).to eq(expected_encoded_link)
end
it 'does not include trailing HTML entities' do
@@ -151,4 +195,29 @@ describe Banzai::Filter::AutolinkFilter do
end
end
end
+
+ context 'when the link is inside a tag' do
+ %w[http rdar].each do |protocol|
+ it "renders text after the link correctly for #{protocol}" do
+ doc = filter(ERB::Util.html_escape_once("<#{protocol}://link><another>"))
+
+ expect(doc.children.last.text).to include('<another>')
+ end
+ end
+ end
+
+ # Rinku does not escape these characters in HTML attributes, but content_tag
+ # does. We don't care about that difference for these specs, though.
+ def unescape(html)
+ %w([ ] { }).each do |cgi_escape|
+ html.sub!(CGI.escape(cgi_escape), cgi_escape)
+ end
+
+ quotes.each do |html_escape|
+ html.sub!(CGI.escape_html(html_escape), html_escape)
+ html.sub!(CGI.escape(html_escape), CGI.escape_html(html_escape))
+ end
+
+ html
+ end
end
diff --git a/spec/lib/banzai/filter/color_filter_spec.rb b/spec/lib/banzai/filter/color_filter_spec.rb
new file mode 100644
index 00000000000..a098b037510
--- /dev/null
+++ b/spec/lib/banzai/filter/color_filter_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+describe Banzai::Filter::ColorFilter, lib: true do
+ include FilterSpecHelper
+
+ let(:color) { '#F00' }
+ let(:color_chip_selector) { 'code > span.gfm-color_chip > span' }
+
+ ['#123', '#1234', '#123456', '#12345678',
+ 'rgb(0,0,0)', 'RGB(0, 0, 0)', 'rgba(0,0,0,1)', 'RGBA(0,0,0,0.7)',
+ 'hsl(270,30%,50%)', 'HSLA(270, 30%, 50%, .7)'].each do |color|
+ it "inserts color chip for supported color format #{color}" do
+ content = code_tag(color)
+ doc = filter(content)
+ color_chip = doc.at_css(color_chip_selector)
+
+ expect(color_chip.content).to be_empty
+ expect(color_chip.parent[:class]).to eq 'gfm-color_chip'
+ expect(color_chip[:style]).to eq "background-color: #{color};"
+ end
+ end
+
+ it 'ignores valid color code without backticks(code tags)' do
+ doc = filter(color)
+
+ expect(doc.css('span.gfm-color_chip').size).to be_zero
+ end
+
+ it 'ignores valid color code with prepended space' do
+ content = code_tag(' ' + color)
+ doc = filter(content)
+
+ expect(doc.css(color_chip_selector).size).to be_zero
+ end
+
+ it 'ignores valid color code with appended space' do
+ content = code_tag(color + ' ')
+ doc = filter(content)
+
+ expect(doc.css(color_chip_selector).size).to be_zero
+ end
+
+ it 'ignores valid color code surrounded by spaces' do
+ content = code_tag(' ' + color + ' ')
+ doc = filter(content)
+
+ expect(doc.css(color_chip_selector).size).to be_zero
+ end
+
+ it 'ignores invalid color code' do
+ invalid_color = '#BAR'
+ content = code_tag(invalid_color)
+ doc = filter(content)
+
+ expect(doc.css(color_chip_selector).size).to be_zero
+ end
+
+ def code_tag(string)
+ "<code>#{string}</code>"
+ end
+end
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 935146c17fc..a41a28a56f1 100644
--- a/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb
@@ -53,7 +53,7 @@ describe Banzai::Filter::CommitRangeReferenceFilter do
doc = reference_filter("See (#{reference}.)")
exp = Regexp.escape(range.reference_link_text)
- expect(doc.to_html).to match(/\(<a.+>#{exp}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{exp}</a>\.\)})
end
it 'ignores invalid commit IDs' do
@@ -222,7 +222,7 @@ describe Banzai::Filter::CommitRangeReferenceFilter do
doc = reference_filter("Fixed (#{reference}.)")
exp = Regexp.escape(range.reference_link_text(project))
- expect(doc.to_html).to match(/\(<a.+>#{exp}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{exp}</a>\.\)})
end
it 'ignores invalid commit IDs on the referenced project' do
diff --git a/spec/lib/banzai/filter/commit_reference_filter_spec.rb b/spec/lib/banzai/filter/commit_reference_filter_spec.rb
index 080a5f57da9..b18af806118 100644
--- a/spec/lib/banzai/filter/commit_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/commit_reference_filter_spec.rb
@@ -42,7 +42,7 @@ describe Banzai::Filter::CommitReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{commit.short_id}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{commit.short_id}</a>\.\)})
end
it 'ignores invalid commit IDs' do
@@ -199,12 +199,43 @@ describe Banzai::Filter::CommitReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{commit.reference_link_text(project)}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{commit.reference_link_text(project)}</a>\.\)})
end
it 'ignores invalid commit IDs on the referenced project' do
act = "Committed #{invalidate_reference(reference)}"
- expect(reference_filter(act).to_html).to match(/<a.+>#{Regexp.escape(invalidate_reference(reference))}<\/a>/)
+ expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
+ end
+ end
+
+ context 'URL reference for a commit patch' do
+ let(:namespace) { create(:namespace) }
+ let(:project2) { create(:project, :public, :repository, namespace: namespace) }
+ let(:commit) { project2.commit }
+ let(:link) { urls.project_commit_url(project2, commit.id) }
+ let(:extension) { '.patch' }
+ let(:reference) { link + extension }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.text).to eq("See #{commit.reference_link_text(project)} (patch)")
+ end
+
+ it 'does not link to patch when extension match is after the path' do
+ invalidate_commit_reference = reference_filter("#{link}/builds.patch")
+
+ doc = reference_filter("See (#{invalidate_commit_reference})")
+
+ expect(doc.css('a').first.attr('href')).to eq "#{link}/builds"
+ expect(doc.text).to eq("See (#{commit.reference_link_text(project)} (builds).patch)")
end
end
end
diff --git a/spec/lib/banzai/filter/commit_trailers_filter_spec.rb b/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
new file mode 100644
index 00000000000..1fd145116df
--- /dev/null
+++ b/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
@@ -0,0 +1,171 @@
+require 'spec_helper'
+require 'ffaker'
+
+describe Banzai::Filter::CommitTrailersFilter do
+ include FilterSpecHelper
+ include CommitTrailersSpecHelper
+
+ let(:secondary_email) { create(:email, :confirmed) }
+ let(:user) { create(:user) }
+
+ let(:trailer) { "#{FFaker::Lorem.word}-by:"}
+
+ let(:commit_message) { trailer_line(trailer, user.name, user.email) }
+ let(:commit_message_html) { commit_html(commit_message) }
+
+ context 'detects' do
+ let(:email) { FFaker::Internet.email }
+
+ it 'trailers in the form of *-by and replace users with links' do
+ doc = filter(commit_message_html)
+
+ expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
+ end
+
+ it 'trailers prefixed with whitespaces' do
+ message_html = commit_html("\n\r #{commit_message}")
+
+ doc = filter(message_html)
+
+ expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
+ end
+
+ it 'GitLab users via a secondary email' do
+ _, message_html = build_commit_message(
+ trailer: trailer,
+ name: secondary_email.user.name,
+ email: secondary_email.email
+ )
+
+ doc = filter(message_html)
+
+ expect_to_have_user_link_with_avatar(
+ doc,
+ user: secondary_email.user,
+ trailer: trailer,
+ email: secondary_email.email
+ )
+ end
+
+ it 'non GitLab users and replaces them with mailto links' do
+ _, message_html = build_commit_message(
+ trailer: trailer,
+ name: FFaker::Name.name,
+ email: email
+ )
+
+ doc = filter(message_html)
+
+ expect_to_have_mailto_link(doc, email: email, trailer: trailer)
+ end
+
+ it 'multiple trailers in the same message' do
+ different_trailer = "#{FFaker::Lorem.word}-by:"
+ message = commit_html %(
+ #{commit_message}
+ #{trailer_line(different_trailer, FFaker::Name.name, email)}
+ )
+
+ doc = filter(message)
+
+ expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
+ expect_to_have_mailto_link(doc, email: email, trailer: different_trailer)
+ end
+
+ context 'special names' do
+ where(:name) do
+ [
+ 'John S. Doe',
+ 'L33t H@x0r'
+ ]
+ end
+
+ with_them do
+ it do
+ message, message_html = build_commit_message(
+ trailer: trailer,
+ name: name,
+ email: email
+ )
+
+ doc = filter(message_html)
+
+ expect_to_have_mailto_link(doc, email: email, trailer: trailer)
+ expect(doc.text).to match Regexp.escape(message)
+ end
+ end
+ end
+ end
+
+ context "ignores" do
+ it 'commit messages without trailers' do
+ exp = message = commit_html(FFaker::Lorem.sentence)
+ doc = filter(message)
+
+ expect(doc.to_html).to match Regexp.escape(exp)
+ end
+
+ it 'trailers that are inline the commit message body' do
+ message = commit_html %(
+ #{FFaker::Lorem.sentence} #{commit_message} #{FFaker::Lorem.sentence}
+ )
+
+ doc = filter(message)
+
+ expect(doc.css('a').size).to eq 0
+ end
+ end
+
+ context "structure" do
+ it 'preserves the commit trailer structure' do
+ doc = filter(commit_message_html)
+
+ expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
+ expect(doc.text).to match Regexp.escape(commit_message)
+ end
+
+ it 'preserves the original name used in the commit message' do
+ message, message_html = build_commit_message(
+ trailer: trailer,
+ name: FFaker::Name.name,
+ email: user.email
+ )
+
+ doc = filter(message_html)
+
+ expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
+ expect(doc.text).to match Regexp.escape(message)
+ end
+
+ it 'preserves the original email used in the commit message' do
+ message, message_html = build_commit_message(
+ trailer: trailer,
+ name: secondary_email.user.name,
+ email: secondary_email.email
+ )
+
+ doc = filter(message_html)
+
+ expect_to_have_user_link_with_avatar(
+ doc,
+ user: secondary_email.user,
+ trailer: trailer,
+ email: secondary_email.email
+ )
+ expect(doc.text).to match Regexp.escape(message)
+ end
+
+ it 'only replaces trailer lines not the full commit message' do
+ commit_body = FFaker::Lorem.paragraph
+ message = commit_html %(
+ #{commit_body}
+ #{commit_message}
+ )
+
+ doc = filter(message)
+
+ expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
+ expect(doc.text).to include(commit_body)
+ end
+ end
+end
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 a0d391d981c..d9018a7e4fe 100644
--- a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
@@ -49,7 +49,7 @@ describe Banzai::Filter::ExternalIssueReferenceFilter do
it 'links with adjacent text' do
doc = filter("Issue (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{reference}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{reference}</a>\.\)})
end
it 'includes a title attribute' do
diff --git a/spec/lib/banzai/filter/html_entity_filter_spec.rb b/spec/lib/banzai/filter/html_entity_filter_spec.rb
index 91e18d876d5..1d98fc0d5db 100644
--- a/spec/lib/banzai/filter/html_entity_filter_spec.rb
+++ b/spec/lib/banzai/filter/html_entity_filter_spec.rb
@@ -3,17 +3,12 @@ require 'spec_helper'
describe Banzai::Filter::HtmlEntityFilter do
include FilterSpecHelper
- let(:unescaped) { 'foo <strike attr="foo">&&&</strike>' }
- let(:escaped) { 'foo &lt;strike attr=&quot;foo&quot;&gt;&amp;&amp;&amp;&lt;/strike&gt;' }
+ let(:unescaped) { 'foo <strike attr="foo">&&amp;&</strike>' }
+ let(:escaped) { 'foo &lt;strike attr=&quot;foo&quot;&gt;&amp;&amp;amp;&amp;&lt;/strike&gt;' }
it 'converts common entities to their HTML-escaped equivalents' do
output = filter(unescaped)
expect(output).to eq(escaped)
end
-
- it 'does not double-escape' do
- escaped = ERB::Util.html_escape("Merge branch 'blabla' into 'master'")
- expect(filter(escaped)).to eq(escaped)
- 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 51920869545..c84b98eb225 100644
--- a/spec/lib/banzai/filter/image_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/image_link_filter_spec.rb
@@ -14,7 +14,7 @@ describe Banzai::Filter::ImageLinkFilter do
it 'does not wrap a duplicate link' do
doc = filter(%Q(<a href="/whatever">#{image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')}</a>))
- expect(doc.to_html).to match /^<a href="\/whatever"><img[^>]*><\/a>$/
+ expect(doc.to_html).to match %r{^<a href="/whatever"><img[^>]*></a>$}
end
it 'works with external images' do
@@ -24,6 +24,6 @@ describe Banzai::Filter::ImageLinkFilter do
it 'works with inline images' do
doc = filter(%Q(<p>test #{image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')} inline</p>))
- expect(doc.to_html).to match /^<p>test <a[^>]*><img[^>]*><\/a> inline<\/p>$/
+ expect(doc.to_html).to match %r{^<p>test <a[^>]*><img[^>]*></a> inline</p>$}
end
end
diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb
index cacb33d3372..a5373517ac8 100644
--- a/spec/lib/banzai/filter/issuable_state_filter_spec.rb
+++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb
@@ -8,6 +8,7 @@ describe Banzai::Filter::IssuableStateFilter do
let(:context) { { current_user: user, issuable_state_filter_enabled: true } }
let(:closed_issue) { create_issue(:closed) }
let(:project) { create(:project, :public) }
+ let(:group) { create(:group) }
let(:other_project) { create(:project, :public) }
def create_link(text, data)
@@ -77,6 +78,21 @@ describe Banzai::Filter::IssuableStateFilter do
expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference(other_project)} (closed)")
end
+ it 'handles references from group scopes' do
+ link = create_link(closed_issue.to_reference(other_project), issue: closed_issue.id, reference_type: 'issue')
+ doc = filter(link, context.merge(project: nil, group: group))
+
+ expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference(other_project)} (closed)")
+ end
+
+ it 'skips cross project references if the user cannot read cross project' do
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+ link = create_link(closed_issue.to_reference(other_project), issue: closed_issue.id, reference_type: 'issue')
+ doc = filter(link, context.merge(project: other_project))
+
+ expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference(other_project)}")
+ end
+
it 'does not append state when filter is not enabled' do
link = create_link('text', issue: closed_issue.id, reference_type: 'issue')
context = { current_user: user }
diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
index 3a5f52ea23f..905fbb9434b 100644
--- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
@@ -288,7 +288,7 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(issue.to_reference(project))} \(comment 123\)<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(issue.to_reference(project))} \(comment 123\)</a>\.\)})
end
it 'includes default classes' do
@@ -317,7 +317,7 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference_link}.)")
- expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>Reference</a>\.\)})
end
it 'includes default classes' do
@@ -346,7 +346,7 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference_link}.)")
- expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>Reference</a>\.\)})
end
it 'includes default classes' do
diff --git a/spec/lib/banzai/filter/label_reference_filter_spec.rb b/spec/lib/banzai/filter/label_reference_filter_spec.rb
index 862b1fe3fd3..392905076dc 100644
--- a/spec/lib/banzai/filter/label_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/label_reference_filter_spec.rb
@@ -381,11 +381,11 @@ describe Banzai::Filter::LabelReferenceFilter do
end
it 'has valid link text' do
- expect(result.css('a').first.text).to eq "#{label.name} in #{project2.name_with_namespace}"
+ expect(result.css('a').first.text).to eq "#{label.name} in #{project2.full_name}"
end
it 'has valid text' do
- expect(result.text).to eq "See #{label.name} in #{project2.name_with_namespace}"
+ expect(result.text).to eq "See #{label.name} in #{project2.full_name}"
end
it 'ignores invalid IDs on the referenced label' do
@@ -481,12 +481,12 @@ describe Banzai::Filter::LabelReferenceFilter do
it 'has valid link text' do
expect(result.css('a').first.text)
- .to eq "#{group_label.name} in #{another_project.name_with_namespace}"
+ .to eq "#{group_label.name} in #{another_project.full_name}"
end
it 'has valid text' do
expect(result.text)
- .to eq "See #{group_label.name} in #{another_project.name_with_namespace}"
+ .to eq "See #{group_label.name} in #{another_project.full_name}"
end
it 'ignores invalid IDs on the referenced label' do
@@ -596,6 +596,27 @@ describe Banzai::Filter::LabelReferenceFilter do
end
describe 'group context' do
+ it 'points to the page defined in label_url_method' do
+ group = create(:group)
+ label = create(:group_label, group: group)
+ reference = "~#{label.name}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: group, label_url_method: :group_url } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.group_url(group, label_name: label.name))
+ end
+
+ it 'finds labels also in ancestor groups' do
+ group = create(:group)
+ label = create(:group_label, group: group)
+ subgroup = create(:group, parent: group)
+ reference = "~#{label.name}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: subgroup, label_url_method: :group_url } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.group_url(subgroup, label_name: label.name))
+ end
+
it 'points to referenced project issues page' do
project = create(:project)
label = create(:label, project: project)
@@ -604,6 +625,7 @@ describe Banzai::Filter::LabelReferenceFilter do
result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
expect(result.css('a').first.attr('href')).to eq(urls.project_issues_url(project, label_name: label.name))
+ expect(result.css('a').first.text).to eq "#{label.name} in #{project.full_name}"
end
end
end
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 158844e25ae..a1dd72c498f 100644
--- a/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb
@@ -42,7 +42,7 @@ describe Banzai::Filter::MergeRequestReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Merge (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
end
it 'ignores invalid merge IDs' do
@@ -196,6 +196,41 @@ describe Banzai::Filter::MergeRequestReferenceFilter do
end
end
+ context 'URL reference for a commit' do
+ let(:mr) { create(:merge_request, :with_diffs) }
+ let(:reference) do
+ urls.project_merge_request_url(mr.project, mr) + "/diffs?commit_id=#{mr.diff_head_sha}"
+ end
+ let(:commit) { mr.commits.find { |commit| commit.sha == mr.diff_head_sha } }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.text).to eq("See #{mr.to_reference(full: true)} (#{commit.short_id})")
+ end
+
+ it 'has valid title attribute' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('title')).to eq(commit.title)
+ end
+
+ it 'ignores invalid commit short_ids on link text' do
+ invalidate_commit_reference =
+ urls.project_merge_request_url(mr.project, mr) + "/diffs?commit_id=12345678"
+ doc = reference_filter("See #{invalidate_commit_reference}")
+
+ expect(doc.text).to eq("See #{mr.to_reference(full: true)} (diffs)")
+ end
+ end
+
context 'cross-project URL reference' do
let(:namespace) { create(:namespace, name: 'cross-reference') }
let(:project2) { create(:project, :public, namespace: namespace) }
@@ -211,7 +246,7 @@ describe Banzai::Filter::MergeRequestReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Merge (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(merge.to_reference(project))} \(diffs, comment 123\)<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(merge.to_reference(project))} \(diffs, comment 123\)</a>\.\)})
end
end
diff --git a/spec/lib/banzai/filter/mermaid_filter_spec.rb b/spec/lib/banzai/filter/mermaid_filter_spec.rb
index 532d25e121d..f6474c8936d 100644
--- a/spec/lib/banzai/filter/mermaid_filter_spec.rb
+++ b/spec/lib/banzai/filter/mermaid_filter_spec.rb
@@ -3,9 +3,9 @@ require 'spec_helper'
describe Banzai::Filter::MermaidFilter do
include FilterSpecHelper
- it 'adds `js-render-mermaid` class to the `pre` tag' do
+ it 'adds `js-render-mermaid` class to the `code` tag' do
doc = filter("<pre class='code highlight js-syntax-highlight mermaid' lang='mermaid' v-pre='true'><code>graph TD;\n A--&gt;B;\n</code></pre>")
- result = doc.xpath('descendant-or-self::pre').first
+ result = doc.css('code').first
expect(result[:class]).to include('js-render-mermaid')
end
diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb
index 68643effb66..9a2e521fdcf 100644
--- a/spec/lib/banzai/filter/redactor_filter_spec.rb
+++ b/spec/lib/banzai/filter/redactor_filter_spec.rb
@@ -6,7 +6,7 @@ describe Banzai::Filter::RedactorFilter do
it 'ignores non-GFM links' do
html = %(See <a href="https://google.com/">Google</a>)
- doc = filter(html, current_user: double)
+ doc = filter(html, current_user: build(:user))
expect(doc.css('a').length).to eq 1
end
@@ -46,7 +46,7 @@ describe Banzai::Filter::RedactorFilter do
it 'allows permitted Project references' do
user = create(:user)
project = create(:project)
- project.team << [user, :master]
+ project.add_master(user)
link = reference_link(project: project.id, reference_type: 'test')
doc = filter(link, current_user: user)
@@ -94,7 +94,7 @@ describe Banzai::Filter::RedactorFilter do
it 'removes references for project members with guest role' do
member = create(:user)
project = create(:project, :public)
- project.team << [member, :guest]
+ project.add_guest(member)
issue = create(:issue, :confidential, project: project)
link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue')
@@ -128,7 +128,7 @@ describe Banzai::Filter::RedactorFilter do
it 'allows references for project members' do
member = create(:user)
project = create(:project, :public)
- project.team << [member, :developer]
+ project.add_developer(member)
issue = create(:issue, :confidential, project: project)
link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue')
diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb
index 08beede62db..ba8dc68ceda 100644
--- a/spec/lib/banzai/filter/relative_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb
@@ -5,9 +5,11 @@ describe Banzai::Filter::RelativeLinkFilter do
contexts.reverse_merge!({
commit: commit,
project: project,
+ group: group,
project_wiki: project_wiki,
ref: ref,
- requested_path: requested_path
+ requested_path: requested_path,
+ only_path: only_path
})
described_class.call(doc, contexts)
@@ -25,12 +27,18 @@ describe Banzai::Filter::RelativeLinkFilter do
%(<a href="#{path}">#{path}</a>)
end
+ def nested(element)
+ %(<div>#{element}</div>)
+ end
+
let(:project) { create(:project, :repository) }
+ let(:group) { nil }
let(:project_path) { project.full_path }
let(:ref) { 'markdown' }
let(:commit) { project.commit(ref) }
let(:project_wiki) { nil }
let(:requested_path) { '/' }
+ let(:only_path) { true }
shared_examples :preserve_unchanged do
it 'does not modify any relative URL in anchor' do
@@ -70,6 +78,11 @@ describe Banzai::Filter::RelativeLinkFilter do
expect { filter(act) }.not_to raise_error
end
+ it 'does not raise an exception with a garbled path' do
+ act = link("open(/var/tmp/):%20/location%0Afrom:%20/test")
+ expect { filter(act) }.not_to raise_error
+ end
+
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'])
@@ -204,6 +217,23 @@ describe Banzai::Filter::RelativeLinkFilter do
end
end
+ context 'when ref name contains special chars' do
+ let(:ref) {'mark#\'@],+;-._/#@!$&()+down'}
+
+ it 'correctly escapes the ref' do
+ # Adressable won't escape the '#', so we do this manually
+ ref_escaped = 'mark%23\'@%5D,+;-._/%23@!$&()+down'
+
+ # Stub this method so the branch doesn't actually need to be in the repo
+ allow_any_instance_of(described_class).to receive(:uri_type).and_return(:raw)
+
+ doc = filter(link('files/images/logo-black.png'))
+
+ expect(doc.at_css('a')['href'])
+ .to eq "/#{project_path}/raw/#{ref_escaped}/files/images/logo-black.png"
+ end
+ end
+
context 'when requested path is a directory with space in the repo' do
let(:ref) { 'master' }
let(:commit) { project.commit('38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e') }
@@ -223,4 +253,100 @@ describe Banzai::Filter::RelativeLinkFilter do
let(:commit) { nil } # force filter to use ref instead of commit
include_examples :valid_repository
end
+
+ context 'with a /upload/ URL' do
+ # not needed
+ let(:commit) { nil }
+ let(:ref) { nil }
+ let(:requested_path) { nil }
+ let(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' }
+ let(:relative_path) { "/#{project.full_path}#{upload_path}" }
+
+ context 'to a project upload' do
+ context 'with an absolute URL' do
+ let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
+ let(:only_path) { false }
+
+ it 'rewrites the link correctly' do
+ doc = filter(link(upload_path))
+
+ expect(doc.at_css('a')['href']).to eq(absolute_path)
+ end
+ end
+
+ it 'rebuilds relative URL for a link' do
+ doc = filter(link(upload_path))
+ expect(doc.at_css('a')['href']).to eq(relative_path)
+
+ doc = filter(nested(link(upload_path)))
+ expect(doc.at_css('a')['href']).to eq(relative_path)
+ end
+
+ it 'rebuilds relative URL for an image' do
+ doc = filter(image(upload_path))
+ expect(doc.at_css('img')['src']).to eq(relative_path)
+
+ doc = filter(nested(image(upload_path)))
+ expect(doc.at_css('img')['src']).to eq(relative_path)
+ end
+
+ it 'does not modify absolute URL' do
+ doc = filter(link('http://example.com'))
+ expect(doc.at_css('a')['href']).to eq 'http://example.com'
+ end
+
+ it 'supports unescaped Unicode filenames' do
+ path = '/uploads/한글.png'
+ doc = filter(link(path))
+
+ expect(doc.at_css('a')['href']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
+ end
+
+ it 'supports escaped Unicode filenames' do
+ path = '/uploads/한글.png'
+ escaped = Addressable::URI.escape(path)
+ doc = filter(image(escaped))
+
+ expect(doc.at_css('img')['src']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png")
+ end
+ end
+
+ context 'to a group upload' do
+ let(:upload_link) { link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg') }
+ let(:group) { create(:group) }
+ let(:project) { nil }
+ let(:relative_path) { "/groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" }
+
+ context 'with an absolute URL' do
+ let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
+ let(:only_path) { false }
+
+ it 'rewrites the link correctly' do
+ doc = filter(upload_link)
+
+ expect(doc.at_css('a')['href']).to eq(absolute_path)
+ end
+ end
+
+ it 'rewrites the link correctly' do
+ doc = filter(upload_link)
+
+ expect(doc.at_css('a')['href']).to eq(relative_path)
+ end
+
+ it 'rewrites the link correctly for subgroup' do
+ group.update!(parent: create(:group))
+
+ doc = filter(upload_link)
+
+ expect(doc.at_css('a')['href']).to eq(relative_path)
+ end
+
+ it 'does not modify absolute URL' do
+ doc = filter(link('http://example.com'))
+
+ expect(doc.at_css('a')['href']).to eq 'http://example.com'
+ end
+ end
+ end
end
diff --git a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb
index 3a07a6dc179..e068e02d4fc 100644
--- a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb
@@ -28,7 +28,7 @@ describe Banzai::Filter::SnippetReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Snippet (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
end
it 'ignores invalid snippet IDs' do
@@ -192,13 +192,13 @@ describe Banzai::Filter::SnippetReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("See (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(snippet.to_reference(project))}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(snippet.to_reference(project))}</a>\.\)})
end
it 'ignores invalid snippet IDs on the referenced project' do
act = "See #{invalidate_reference(reference)}"
- expect(reference_filter(act).to_html).to match(/<a.+>#{Regexp.escape(invalidate_reference(reference))}<\/a>/)
+ expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
end
end
diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
index 9f2efa05a01..ef52c572898 100644
--- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
+++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb
@@ -3,35 +3,86 @@ require 'spec_helper'
describe Banzai::Filter::SyntaxHighlightFilter do
include FilterSpecHelper
+ shared_examples "XSS prevention" do |lang|
+ it "escapes HTML tags" do
+ # This is how a script tag inside a code block is presented to this filter
+ # after Markdown rendering.
+ result = filter(%{<pre lang="#{lang}"><code>&lt;script&gt;alert(1)&lt;/script&gt;</code></pre>})
+
+ expect(result.to_html).not_to include("<script>alert(1)</script>")
+ expect(result.to_html).to include("alert(1)")
+ end
+ end
+
context "when no language is specified" do
it "highlights as plaintext" do
result = filter('<pre><code>def fun end</code></pre>')
+
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre>')
end
+
+ include_examples "XSS prevention", ""
end
context "when a valid language is specified" do
it "highlights as that language" do
result = filter('<pre><code lang="ruby">def fun end</code></pre>')
+
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre>')
end
+
+ include_examples "XSS prevention", "ruby"
end
context "when an invalid language is specified" do
it "highlights as plaintext" do
result = filter('<pre><code lang="gnuplot">This is a test</code></pre>')
+
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre>')
end
+
+ include_examples "XSS prevention", "gnuplot"
end
- context "when Rouge formatting fails" do
+ context "languages that should be passed through" do
+ %w(math mermaid plantuml).each do |lang|
+ context "when #{lang} is specified" do
+ it "highlights as plaintext but with the correct language attribute and class" do
+ result = filter(%{<pre><code lang="#{lang}">This is a test</code></pre>})
+
+ expect(result.to_html).to eq(%{<pre class="code highlight js-syntax-highlight #{lang}" lang="#{lang}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>})
+ end
+
+ include_examples "XSS prevention", lang
+ end
+ end
+ end
+
+ context "when Rouge lexing fails" do
before do
- allow_any_instance_of(Rouge::Formatter).to receive(:format).and_raise(StandardError)
+ allow_any_instance_of(Rouge::Lexers::Ruby).to receive(:stream_tokens).and_raise(StandardError)
end
it "highlights as plaintext" do
result = filter('<pre><code lang="ruby">This is a test</code></pre>')
- expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight" lang="" v-pre="true"><code>This is a test</code></pre>')
+
+ expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight" lang="" v-pre="true"><code><span id="LC1" class="line" lang="">This is a test</span></code></pre>')
+ end
+
+ include_examples "XSS prevention", "ruby"
+ end
+
+ context "when Rouge lexing fails after a retry" do
+ before do
+ allow_any_instance_of(Rouge::Lexers::PlainText).to receive(:stream_tokens).and_raise(StandardError)
+ end
+
+ it "does not add highlighting classes" do
+ result = filter('<pre><code>This is a test</code></pre>')
+
+ expect(result.to_html).to eq('<pre><code>This is a test</code></pre>')
end
+
+ include_examples "XSS prevention", "ruby"
end
end
diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb
deleted file mode 100644
index 76bc0c36ab7..00000000000
--- a/spec/lib/banzai/filter/upload_link_filter_spec.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-require 'spec_helper'
-
-describe Banzai::Filter::UploadLinkFilter do
- def filter(doc, contexts = {})
- contexts.reverse_merge!({
- project: project
- })
-
- raw_filter(doc, contexts)
- end
-
- def raw_filter(doc, contexts = {})
- described_class.call(doc, contexts)
- end
-
- def image(path)
- %(<img src="#{path}" />)
- end
-
- def link(path)
- %(<a href="#{path}">#{path}</a>)
- end
-
- def nested_image(path)
- %(<div><img src="#{path}" /></div>)
- end
-
- def nested_link(path)
- %(<div><a href="#{path}">#{path}</a></div>)
- end
-
- let(:project) { create(:project) }
-
- shared_examples :preserve_unchanged do
- it 'does not modify any relative URL in anchor' do
- doc = filter(link('README.md'))
- expect(doc.at_css('a')['href']).to eq 'README.md'
- end
-
- it 'does not modify any relative URL in image' do
- doc = filter(image('files/images/logo-black.png'))
- expect(doc.at_css('img')['src']).to eq 'files/images/logo-black.png'
- end
- end
-
- it 'does not raise an exception on invalid URIs' do
- act = link("://foo")
- expect { filter(act) }.not_to raise_error
- end
-
- 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.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.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.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.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
- end
-
- it 'does not modify absolute URL' do
- doc = filter(link('http://example.com'))
- expect(doc.at_css('a')['href']).to eq 'http://example.com'
- end
-
- it 'supports Unicode filenames' do
- path = '/uploads/한글.png'
- 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)
-
- doc = filter(image(escaped))
- 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
-
- context 'in group context' do
- let(:upload_link) { link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg') }
- let(:group) { create(:group) }
- let(:filter_context) { { project: nil, group: group } }
- let(:relative_path) { "groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" }
-
- it 'rewrites the link correctly' do
- doc = raw_filter(upload_link, filter_context)
-
- expect(doc.at_css('a')['href']).to eq("#{Gitlab.config.gitlab.url}/#{relative_path}")
- end
-
- it 'rewrites the link correctly for subgroup' do
- subgroup = create(:group, parent: group)
- relative_path = "groups/#{subgroup.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
-
- doc = raw_filter(upload_link, { project: nil, group: subgroup })
-
- expect(doc.at_css('a')['href']).to eq("#{Gitlab.config.gitlab.url}/#{relative_path}")
- end
-
- it 'does not modify absolute URL' do
- doc = filter(link('http://example.com'), filter_context)
-
- expect(doc.at_css('a')['href']).to eq 'http://example.com'
- end
- end
-
- context 'when project or group context does not exist' do
- let(:upload_link) { link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg') }
-
- it 'does not raise error' do
- expect { raw_filter(upload_link, project: nil) }.not_to raise_error
- end
-
- it 'does not rewrite link' do
- doc = raw_filter(upload_link, project: nil)
-
- expect(doc.to_html).to eq upload_link
- end
- 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 fc03741976e..2f86a046d28 100644
--- a/spec/lib/banzai/filter/user_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb
@@ -34,11 +34,11 @@ describe Banzai::Filter::UserReferenceFilter do
let(:reference) { User.reference_prefix + 'all' }
before do
- project.team << [project.creator, :developer]
+ project.add_developer(project.creator)
end
it 'supports a special @all mention' do
- project.team << [user, :developer]
+ project.add_developer(user)
doc = reference_filter("Hey #{reference}", author: user)
expect(doc.css('a').length).to eq 1
@@ -47,7 +47,7 @@ describe Banzai::Filter::UserReferenceFilter do
end
it 'includes a data-author attribute when there is an author' do
- project.team << [user, :developer]
+ project.add_developer(user)
doc = reference_filter(reference, author: user)
expect(doc.css('a').first.attr('data-author')).to eq(user.id.to_s)
@@ -146,7 +146,7 @@ describe Banzai::Filter::UserReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Mention me (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>#{reference}<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>#{reference}</a>\.\)})
end
it 'includes default classes' do
@@ -172,7 +172,7 @@ describe Banzai::Filter::UserReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Mention me (#{reference}.)")
- expect(doc.to_html).to match(/\(<a.+>User<\/a>\.\)/)
+ expect(doc.to_html).to match(%r{\(<a.+>User</a>\.\)})
end
it 'includes a data-user attribute' do
diff --git a/spec/lib/banzai/filter/wiki_link_filter_spec.rb b/spec/lib/banzai/filter/wiki_link_filter_spec.rb
index 9596f004052..50d053011b3 100644
--- a/spec/lib/banzai/filter/wiki_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/wiki_link_filter_spec.rb
@@ -10,15 +10,23 @@ describe Banzai::Filter::WikiLinkFilter do
it "doesn't rewrite absolute links" do
filtered_link = filter("<a href='http://example.com:8000/'>Link</a>", project_wiki: wiki).children[0]
+
expect(filtered_link.attribute('href').value).to eq('http://example.com:8000/')
end
+ it "doesn't rewrite links to project uploads" do
+ filtered_link = filter("<a href='/uploads/a.test'>Link</a>", project_wiki: wiki).children[0]
+
+ expect(filtered_link.attribute('href').value).to eq('/uploads/a.test')
+ end
+
describe "invalid links" do
invalid_links = ["http://:8080", "http://", "http://:8080/path"]
invalid_links.each do |invalid_link|
it "doesn't rewrite invalid invalid_links like #{invalid_link}" do
filtered_link = filter("<a href='#{invalid_link}'>Link</a>", project_wiki: wiki).children[0]
+
expect(filtered_link.attribute('href').value).to eq(invalid_link)
end
end
diff --git a/spec/lib/banzai/redactor_spec.rb b/spec/lib/banzai/redactor_spec.rb
index 2424c3fdc66..441f3725985 100644
--- a/spec/lib/banzai/redactor_spec.rb
+++ b/spec/lib/banzai/redactor_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Banzai::Redactor do
- let(:user) { build(:user) }
+ let(:user) { create(:user) }
let(:project) { build(:project) }
let(:redactor) { described_class.new(project, user) }
@@ -40,6 +40,16 @@ describe Banzai::Redactor do
expect(doc.to_html).to eq(original_content)
end
end
+
+ it 'returns <a> tag with original href if it is originally a link reference' do
+ href = 'http://localhost:3000'
+ doc = Nokogiri::HTML
+ .fragment("<a class='gfm' data-reference-type='issue' data-original=#{href} data-link-reference='true'>#{href}</a>")
+
+ redactor.redact([doc])
+
+ expect(doc.to_html).to eq('<a href="http://localhost:3000">http://localhost:3000</a>')
+ end
end
context 'when project is in pending delete' do
@@ -88,6 +98,55 @@ describe Banzai::Redactor do
end
end
+ context 'when the user cannot read cross project' do
+ include ActionView::Helpers::UrlHelper
+ let(:project) { create(:project) }
+ let(:other_project) { create(:project, :public) }
+
+ def create_link(issuable)
+ type = issuable.class.name.underscore.downcase
+ link_to(issuable.to_reference, '',
+ class: 'gfm has-tooltip',
+ title: issuable.title,
+ data: {
+ reference_type: type,
+ "#{type}": issuable.id
+ })
+ end
+
+ before do
+ project.add_developer(user)
+
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global) { false }
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+ end
+
+ it 'skips links to issues within the same project' do
+ issue = create(:issue, project: project)
+ link = create_link(issue)
+ doc = Nokogiri::HTML.fragment(link)
+
+ redactor.redact([doc])
+ result = doc.css('a').last
+
+ expect(result['class']).to include('has-tooltip')
+ expect(result['title']).to eq(issue.title)
+ end
+
+ it 'removes info from a cross project reference' do
+ issue = create(:issue, project: other_project)
+ link = create_link(issue)
+ doc = Nokogiri::HTML.fragment(link)
+
+ redactor.redact([doc])
+ result = doc.css('a').last
+
+ expect(result['class']).not_to include('has-tooltip')
+ expect(result['title']).to be_empty
+ end
+ end
+
describe '#redact_nodes' do
it 'redacts an Array of nodes' do
doc = Nokogiri::HTML.fragment('<a href="foo">foo</a>')
diff --git a/spec/lib/banzai/reference_parser/issue_parser_spec.rb b/spec/lib/banzai/reference_parser/issue_parser_spec.rb
index 4cef3bdb24b..cb7f8b20dda 100644
--- a/spec/lib/banzai/reference_parser/issue_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/issue_parser_spec.rb
@@ -19,19 +19,58 @@ describe Banzai::ReferenceParser::IssueParser do
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])
+ .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([])
+ .with([issue], user)
+ .and_return([])
expect(subject.nodes_visible_to_user(user, [link])).to eq([])
end
+
+ context 'when the user cannot read cross project' do
+ let(:issue) { create(:issue) }
+
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global) { false }
+ end
+
+ it 'returns the nodes when the user can read the issue' do
+ expect(Ability).to receive(:allowed?)
+ .with(user, :read_issue_iid, issue)
+ .and_return(true)
+
+ 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(:allowed?)
+ .with(user, :read_issue_iid, issue)
+ .and_return(false)
+
+ expect(subject.nodes_visible_to_user(user, [link])).to eq([])
+ end
+
+ context 'when the issue is not cross project' do
+ let(:issue) { create(:issue, project: project) }
+
+ it 'does not check `can_read_reference` if the issue is not cross project' do
+ expect(Ability).to receive(:issues_readable_by_user)
+ .with([issue], user)
+ .and_return([])
+
+ expect(subject).not_to receive(:can_read_reference?).with(user, issue)
+
+ expect(subject.nodes_visible_to_user(user, [link])).to eq([])
+ end
+ end
+ end
end
context 'when the link does not have a data-issue attribute' do
@@ -78,4 +117,27 @@ describe Banzai::ReferenceParser::IssueParser do
expect(subject.records_for_nodes(nodes)).to eq({ link => issue })
end
end
+
+ context 'when checking multiple merge requests on another project' do
+ let(:other_project) { create(:project, :public) }
+ let(:other_issue) { create(:issue, project: other_project) }
+
+ let(:control_links) do
+ [issue_link(other_issue)]
+ end
+
+ let(:actual_links) do
+ control_links + [issue_link(create(:issue, project: other_project))]
+ end
+
+ def issue_link(issue)
+ Nokogiri::HTML.fragment(%Q{<a data-issue="#{issue.id}"></a>}).children[0]
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'no N+1 queries'
+ end
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 775749ae3a7..14542342cf6 100644
--- a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
@@ -4,14 +4,13 @@ describe Banzai::ReferenceParser::MergeRequestParser do
include ReferenceParserHelpers
let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request) }
- subject { described_class.new(merge_request.target_project, user) }
+ let(:project) { create(:project, :public) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ 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
- let(:project) { merge_request.target_project }
-
before do
project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
link['data-merge-request'] = merge_request.id.to_s
@@ -40,4 +39,27 @@ describe Banzai::ReferenceParser::MergeRequestParser do
end
end
end
+
+ context 'when checking multiple merge requests on another project' do
+ let(:other_project) { create(:project, :public) }
+ let(:other_merge_request) { create(:merge_request, source_project: other_project) }
+
+ let(:control_links) do
+ [merge_request_link(other_merge_request)]
+ end
+
+ let(:actual_links) do
+ control_links + [merge_request_link(create(:merge_request, :conflict, source_project: other_project))]
+ end
+
+ def merge_request_link(merge_request)
+ Nokogiri::HTML.fragment(%Q{<a data-merge-request="#{merge_request.id}"></a>}).children[0]
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'no N+1 queries'
+ 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 e49726aca6c..b079a3be029 100644
--- a/spec/lib/banzai/reference_parser/user_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/user_parser_spec.rb
@@ -63,8 +63,8 @@ describe Banzai::ReferenceParser::UserParser do
let(:contributor) { create(:user) }
before do
- project.team << [user, :developer]
- project.team << [contributor, :developer]
+ project.add_developer(user)
+ project.add_developer(contributor)
end
it 'returns the members of a project' do
@@ -162,7 +162,7 @@ describe Banzai::ReferenceParser::UserParser 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(:project)
- other_project.team << [user, :developer]
+ other_project.add_developer(user)
link['data-project'] = other_project.id.to_s
link['data-author'] = user.id.to_s
diff --git a/spec/lib/constraints/group_url_constrainer_spec.rb b/spec/lib/constraints/group_url_constrainer_spec.rb
index 4dab58b26a0..ff295068ba9 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 do
+describe Constraints::GroupUrlConstrainer do
let!(:group) { create(:group, path: 'gitlab') }
describe '#matches?' do
diff --git a/spec/lib/constraints/project_url_constrainer_spec.rb b/spec/lib/constraints/project_url_constrainer_spec.rb
index 92331eb2e5d..c96e7ab8495 100644
--- a/spec/lib/constraints/project_url_constrainer_spec.rb
+++ b/spec/lib/constraints/project_url_constrainer_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe ProjectUrlConstrainer do
+describe Constraints::ProjectUrlConstrainer do
let!(:project) { create(:project) }
let!(:namespace) { project.namespace }
diff --git a/spec/lib/constraints/user_url_constrainer_spec.rb b/spec/lib/constraints/user_url_constrainer_spec.rb
index cb3b4ff1391..e2c85bb27bb 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 do
+describe Constraints::UserUrlConstrainer do
let!(:user) { create(:user, username: 'dz') }
describe '#matches?' do
diff --git a/spec/lib/file_size_validator_spec.rb b/spec/lib/file_size_validator_spec.rb
index c44bc1840df..ebd907ecb7f 100644
--- a/spec/lib/file_size_validator_spec.rb
+++ b/spec/lib/file_size_validator_spec.rb
@@ -2,8 +2,8 @@ require 'spec_helper'
describe FileSizeValidator do
let(:validator) { described_class.new(options) }
- let(:attachment) { AttachmentUploader.new }
let(:note) { create(:note) }
+ let(:attachment) { AttachmentUploader.new(note) }
describe 'options uses an integer' do
let(:options) { { maximum: 10, attributes: { attachment: attachment } } }
diff --git a/spec/lib/forever_spec.rb b/spec/lib/forever_spec.rb
new file mode 100644
index 00000000000..cf40c467c72
--- /dev/null
+++ b/spec/lib/forever_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe Forever do
+ describe '.date' do
+ subject { described_class.date }
+
+ context 'when using PostgreSQL' do
+ it 'should return Postgresql future date' do
+ allow(Gitlab::Database).to receive(:postgresql?).and_return(true)
+ expect(subject).to eq(described_class::POSTGRESQL_DATE)
+ end
+ end
+
+ context 'when using MySQL' do
+ it 'should return MySQL future date' do
+ allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
+ expect(subject).to eq(described_class::MYSQL_DATE)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitaly/server_spec.rb b/spec/lib/gitaly/server_spec.rb
new file mode 100644
index 00000000000..ed5d56e91d4
--- /dev/null
+++ b/spec/lib/gitaly/server_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe Gitaly::Server do
+ describe '.all' do
+ let(:storages) { Gitlab.config.repositories.storages }
+
+ it 'includes all storages' do
+ expect(storages.count).to eq(described_class.all.count)
+ expect(storages.keys).to eq(described_class.all.map(&:storage))
+ end
+ end
+
+ subject { described_class.all.first }
+
+ it { is_expected.to respond_to(:server_version) }
+ it { is_expected.to respond_to(:git_binary_version) }
+ it { is_expected.to respond_to(:up_to_date?) }
+ it { is_expected.to respond_to(:address) }
+
+ describe 'request memoization' do
+ context 'when requesting multiple properties', :request_store do
+ it 'uses memoization for the info request' do
+ expect do
+ subject.server_version
+ subject.up_to_date?
+ end.to change { Gitlab::GitalyClient.get_request_count }.by(1)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb
index f668f78c2b8..e1782cff81a 100644
--- a/spec/lib/gitlab/asciidoc_spec.rb
+++ b/spec/lib/gitlab/asciidoc_spec.rb
@@ -48,7 +48,7 @@ module Gitlab
},
'images' => {
input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]',
- output: "<img src=\"https://localhost.com/image.png\" alt=\"Alt text\">"
+ output: "<div>\n<p><span><img src=\"https://localhost.com/image.png\" alt='Alt text\" onerror=\"alert(7)'></span></p>\n</div>"
},
'pre' => {
input: '```mypre"><script>alert(3)</script>',
@@ -95,6 +95,14 @@ module Gitlab
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
+
+ context 'outfilesuffix' do
+ it 'defaults to adoc' do
+ output = render("Inter-document reference <<README.adoc#>>", context)
+
+ expect(output).to include("a href=\"README.adoc\"")
+ end
+ end
end
def render(*args)
diff --git a/spec/lib/gitlab/auth/blocked_user_tracker_spec.rb b/spec/lib/gitlab/auth/blocked_user_tracker_spec.rb
new file mode 100644
index 00000000000..726a3c1c83a
--- /dev/null
+++ b/spec/lib/gitlab/auth/blocked_user_tracker_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::BlockedUserTracker do
+ set(:user) { create(:user) }
+
+ describe '.log_if_user_blocked' do
+ it 'does not log if user failed to login due to undefined reason' do
+ expect_any_instance_of(SystemHooksService).not_to receive(:execute_hooks_for)
+
+ expect(described_class.log_if_user_blocked({})).to be_nil
+ end
+
+ it 'gracefully handles malformed environment variables' do
+ env = { 'warden.options' => 'test' }
+
+ expect(described_class.log_if_user_blocked(env)).to be_nil
+ end
+
+ context 'failed login due to blocked user' do
+ let(:env) do
+ {
+ 'warden.options' => { message: User::BLOCKED_MESSAGE },
+ described_class::ACTIVE_RECORD_REQUEST_PARAMS => { 'user' => { 'login' => user.username } }
+ }
+ end
+
+ subject { described_class.log_if_user_blocked(env) }
+
+ before do
+ expect_any_instance_of(SystemHooksService).to receive(:execute_hooks_for).with(user, :failed_login)
+ end
+
+ it 'logs a blocked user' do
+ user.block!
+
+ expect(subject).to be_truthy
+ end
+
+ it 'logs a blocked user by e-mail' do
+ user.block!
+ env[described_class::ACTIVE_RECORD_REQUEST_PARAMS]['user']['login'] = user.email
+
+ expect(subject).to be_truthy
+ end
+
+ it 'logs a LDAP blocked user' do
+ user.ldap_block!
+
+ expect(subject).to be_truthy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/access_spec.rb b/spec/lib/gitlab/auth/ldap/access_spec.rb
new file mode 100644
index 00000000000..6b251d824f7
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/access_spec.rb
@@ -0,0 +1,186 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::Access do
+ include LdapHelpers
+
+ 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? }
+
+ context 'when the user cannot be found' do
+ before do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(nil)
+ end
+
+ it { is_expected.to be_falsey }
+
+ it 'blocks user in GitLab' do
+ expect(access).to receive(:block_user).with(user, 'does not exist anymore')
+
+ access.allowed?
+ end
+ end
+
+ context 'when the user is found' do
+ let(:ldap_user) { Gitlab::Auth::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
+
+ before do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(ldap_user)
+ end
+
+ context 'and the user is disabled via active directory' do
+ before do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:disabled_via_active_directory?).and_return(true)
+ end
+
+ it { is_expected.to be_falsey }
+
+ it 'blocks user in GitLab' do
+ expect(access).to receive(:block_user).with(user, 'is disabled in Active Directory')
+
+ access.allowed?
+ end
+ end
+
+ context 'and has no disabled flag in active diretory' do
+ before do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:disabled_via_active_directory?).and_return(false)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when auto-created users are blocked' do
+ before do
+ user.block
+ end
+
+ it 'does not unblock user in GitLab' do
+ expect(access).not_to receive(:unblock_user)
+
+ access.allowed?
+
+ expect(user).to be_blocked
+ expect(user).not_to be_ldap_blocked # this block is handled by omniauth not by our internal logic
+ end
+ end
+
+ context 'when auto-created users are not blocked' do
+ before do
+ user.ldap_block
+ end
+
+ it 'unblocks user in GitLab' do
+ expect(access).to receive(:unblock_user).with(user, 'is not disabled anymore')
+
+ access.allowed?
+ end
+ end
+ end
+
+ context 'without ActiveDirectory enabled' do
+ before do
+ allow(Gitlab::Auth::LDAP::Config).to receive(:enabled?).and_return(true)
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive(:active_directory).and_return(false)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when user cannot be found' do
+ before do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(nil)
+ end
+
+ it { is_expected.to be_falsey }
+
+ it 'blocks user in GitLab' do
+ expect(access).to receive(:block_user).with(user, 'does not exist anymore')
+
+ access.allowed?
+ end
+ end
+
+ context 'when user was previously ldap_blocked' do
+ before do
+ user.ldap_block
+ end
+
+ it 'unblocks the user if it exists' do
+ expect(access).to receive(:unblock_user).with(user, 'is available again')
+
+ access.allowed?
+ end
+ end
+ end
+ end
+
+ context 'when the connection fails' do
+ before do
+ raise_ldap_connection_error
+ end
+
+ it 'does not block the user' do
+ access.allowed?
+
+ expect(user.ldap_blocked?).to be_falsey
+ end
+
+ it 'denies access' do
+ expect(access.allowed?).to be_falsey
+ end
+ end
+ end
+
+ describe '#block_user' do
+ before do
+ user.activate
+ allow(Gitlab::AppLogger).to receive(:info)
+
+ access.block_user user, 'reason'
+ end
+
+ it 'blocks the user' do
+ expect(user).to be_blocked
+ expect(user).to be_ldap_blocked
+ end
+
+ it 'logs the reason' do
+ expect(Gitlab::AppLogger).to have_received(:info).with(
+ "LDAP account \"123456\" reason, " \
+ "blocking Gitlab user \"#{user.name}\" (#{user.email})"
+ )
+ end
+ end
+
+ describe '#unblock_user' do
+ before do
+ user.ldap_block
+ allow(Gitlab::AppLogger).to receive(:info)
+
+ access.unblock_user user, 'reason'
+ end
+
+ it 'activates the user' do
+ expect(user).not_to be_blocked
+ expect(user).not_to be_ldap_blocked
+ end
+
+ it 'logs the reason' do
+ Gitlab::AppLogger.info(
+ "LDAP account \"123456\" reason, " \
+ "unblocking Gitlab user \"#{user.name}\" (#{user.email})"
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/adapter_spec.rb b/spec/lib/gitlab/auth/ldap/adapter_spec.rb
new file mode 100644
index 00000000000..3eeaf3862f6
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/adapter_spec.rb
@@ -0,0 +1,164 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::Adapter do
+ include LdapHelpers
+
+ let(:ldap) { double(:ldap) }
+ let(:adapter) { ldap_adapter('ldapmain', ldap) }
+
+ describe '#users' do
+ before do
+ stub_ldap_config(base: 'dc=example,dc=com')
+ end
+
+ it 'searches with the proper options when searching by uid' do
+ # Requires this expectation style to match the filter
+ 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(ldap_attributes)
+ end.and_return({})
+
+ adapter.users('uid', 'johndoe')
+ end
+
+ it 'searches with the proper options when searching by dn' do
+ expect(adapter).to receive(:ldap_search).with(
+ base: 'uid=johndoe,ou=users,dc=example,dc=com',
+ scope: Net::LDAP::SearchScope_BaseObject,
+ attributes: ldap_attributes,
+ filter: nil
+ ).and_return({})
+
+ adapter.users('dn', 'uid=johndoe,ou=users,dc=example,dc=com')
+ end
+
+ it 'searches with the proper options when searching with a limit' do
+ expect(adapter)
+ .to receive(:ldap_search).with(hash_including(size: 100)).and_return({})
+
+ adapter.users('uid', 'johndoe', 100)
+ end
+
+ it 'returns an LDAP::Person if search returns a result' do
+ entry = ldap_user_entry('johndoe')
+ allow(adapter).to receive(:ldap_search).and_return([entry])
+
+ results = adapter.users('uid', 'johndoe')
+
+ expect(results.size).to eq(1)
+ expect(results.first.uid).to eq('johndoe')
+ end
+
+ it 'returns empty array if search entry does not respond to uid' do
+ entry = Net::LDAP::Entry.new
+ entry['dn'] = user_dn('johndoe')
+ allow(adapter).to receive(:ldap_search).and_return([entry])
+
+ results = adapter.users('uid', 'johndoe')
+
+ expect(results).to be_empty
+ end
+
+ 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: ldap_attributes)
+ ).and_return({})
+
+ adapter.users('sAMAccountName', 'johndoe')
+ end
+ end
+
+ describe '#dn_matches_filter?' do
+ subject { adapter.dn_matches_filter?(:dn, :filter) }
+
+ context "when the search result is non-empty" do
+ 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 do
+ allow(adapter).to receive(:ldap_search).and_return([])
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#ldap_search' do
+ subject { adapter.ldap_search(base: :dn, filter: :filter) }
+
+ context "when the search is successful" do
+ context "and the result is non-empty" do
+ 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 do
+ allow(ldap).to receive(:search).and_return([])
+ end
+
+ it { is_expected.to eq [] }
+ end
+ end
+
+ context "when the search encounters an error" do
+ before do
+ allow(ldap).to receive_messages(
+ search: nil,
+ get_operation_result: double(code: 1, message: 'some error')
+ )
+ end
+
+ it { is_expected.to eq [] }
+ end
+
+ context "when the search raises an LDAP exception" do
+ before do
+ allow(adapter).to receive(:renew_connection_adapter).and_return(ldap)
+ allow(ldap).to receive(:search) { raise Net::LDAP::Error, "some error" }
+ allow(Rails.logger).to receive(:warn)
+ end
+
+ context 'retries the operation' do
+ before do
+ stub_const("#{described_class}::MAX_SEARCH_RETRIES", 3)
+ end
+
+ it 'as many times as MAX_SEARCH_RETRIES' do
+ expect(ldap).to receive(:search).exactly(3).times
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::LDAPConnectionError)
+ end
+
+ context 'when no more retries' do
+ before do
+ stub_const("#{described_class}::MAX_SEARCH_RETRIES", 1)
+ end
+
+ it 'raises the exception' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::LDAPConnectionError)
+ end
+
+ it 'logs the error' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::LDAPConnectionError)
+ expect(Rails.logger).to have_received(:warn).with(
+ "LDAP search raised exception Net::LDAP::Error: some error")
+ end
+ end
+ end
+ end
+ end
+
+ def ldap_attributes
+ Gitlab::Auth::LDAP::Person.ldap_attributes(Gitlab::Auth::LDAP::Config.new('ldapmain'))
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/auth_hash_spec.rb b/spec/lib/gitlab/auth/ldap/auth_hash_spec.rb
new file mode 100644
index 00000000000..05541972f87
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/auth_hash_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::AuthHash do
+ include LdapHelpers
+
+ let(:auth_hash) do
+ described_class.new(
+ OmniAuth::AuthHash.new(
+ uid: given_uid,
+ provider: 'ldapmain',
+ info: info,
+ extra: {
+ raw_info: raw_info
+ }
+ )
+ )
+ end
+
+ let(:info) do
+ {
+ name: 'Smith, J.',
+ email: 'johnsmith@example.com',
+ nickname: '123456'
+ }
+ end
+
+ let(:raw_info) do
+ {
+ uid: ['123456'],
+ email: ['johnsmith@example.com'],
+ cn: ['Smith, J.'],
+ fullName: ['John Smith']
+ }
+ end
+
+ context "without overridden attributes" do
+ let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' }
+
+ it "has the correct username" do
+ expect(auth_hash.username).to eq("123456")
+ end
+
+ it "has the correct name" do
+ expect(auth_hash.name).to eq("Smith, J.")
+ end
+ end
+
+ context "with overridden attributes" do
+ let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' }
+
+ let(:attributes) do
+ {
+ 'username' => %w(mail email),
+ 'name' => 'fullName'
+ }
+ end
+
+ before do
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive(:attributes).and_return(attributes)
+ end
+
+ it "has the correct username" do
+ expect(auth_hash.username).to eq("johnsmith@example.com")
+ end
+
+ it "has the correct name" do
+ expect(auth_hash.name).to eq("John Smith")
+ end
+ end
+
+ describe '#uid' do
+ context 'when there is extraneous (but valid) whitespace' do
+ let(:given_uid) { 'uid =john smith , ou = people, dc= example,dc =com' }
+
+ it 'removes the extraneous whitespace' do
+ expect(auth_hash.uid).to eq('uid=john smith,ou=people,dc=example,dc=com')
+ end
+ end
+
+ context 'when there are upper case characters' do
+ let(:given_uid) { 'UID=John Smith,ou=People,dc=example,dc=com' }
+
+ it 'downcases' do
+ expect(auth_hash.uid).to eq('uid=john smith,ou=people,dc=example,dc=com')
+ end
+ end
+ end
+
+ describe '#username' do
+ context 'if lowercase_usernames setting is' do
+ let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' }
+
+ before do
+ raw_info[:uid] = ['JOHN']
+ end
+
+ it 'enabled the username attribute is lower cased' do
+ stub_ldap_config(lowercase_usernames: true)
+
+ expect(auth_hash.username).to eq 'john'
+ end
+
+ it 'disabled the username attribute is not lower cased' do
+ stub_ldap_config(lowercase_usernames: false)
+
+ expect(auth_hash.username).to eq 'JOHN'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/authentication_spec.rb b/spec/lib/gitlab/auth/ldap/authentication_spec.rb
new file mode 100644
index 00000000000..111572d043b
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/authentication_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::Authentication do
+ let(:dn) { 'uid=John Smith, ou=People, dc=example, dc=com' }
+ let(:user) { create(:omniauth_user, extern_uid: Gitlab::Auth::LDAP::Person.normalize_dn(dn)) }
+ let(:login) { 'john' }
+ let(:password) { 'password' }
+
+ describe 'login' do
+ before do
+ allow(Gitlab::Auth::LDAP::Config).to receive(:enabled?).and_return(true)
+ end
+
+ it "finds the user if authentication is successful" do
+ expect(user).not_to be_nil
+
+ # 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)
+
+ expect(described_class.login(login, password)).to be_truthy
+ end
+
+ 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)
+
+ expect(described_class.login(login, password)).to be_falsey
+ end
+
+ it "is false if authentication fails" do
+ expect(user).not_to be_nil
+
+ # 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)
+
+ expect(described_class.login(login, password)).to be_falsey
+ end
+
+ it "fails if ldap is disabled" do
+ allow(Gitlab::Auth::LDAP::Config).to receive(:enabled?).and_return(false)
+ expect(described_class.login(login, password)).to be_falsey
+ end
+
+ it "fails if no login is supplied" do
+ expect(described_class.login('', password)).to be_falsey
+ end
+
+ it "fails if no password is supplied" do
+ expect(described_class.login(login, '')).to be_falsey
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/config_spec.rb b/spec/lib/gitlab/auth/ldap/config_spec.rb
new file mode 100644
index 00000000000..82587e2ba55
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/config_spec.rb
@@ -0,0 +1,373 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::Config do
+ include LdapHelpers
+
+ let(:config) { described_class.new('ldapmain') }
+
+ describe '.servers' do
+ it 'returns empty array if no server information is available' do
+ allow(Gitlab.config).to receive(:ldap).and_return('enabled' => false)
+
+ expect(described_class.servers).to eq []
+ end
+ end
+
+ describe '#initialize' do
+ it 'requires a provider' do
+ expect { described_class.new }.to raise_error ArgumentError
+ end
+
+ it 'works' do
+ expect(config).to be_a described_class
+ end
+
+ it 'raises an error if a unknown provider is used' do
+ expect { described_class.new 'unknown' }.to raise_error(RuntimeError)
+ end
+ end
+
+ describe '#adapter_options' do
+ it 'constructs basic options' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 386,
+ 'encryption' => 'plain'
+ }
+ )
+
+ expect(config.adapter_options).to eq(
+ host: 'ldap.example.com',
+ port: 386,
+ encryption: nil
+ )
+ end
+
+ it 'includes authentication options when auth is configured' do
+ stub_ldap_config(
+ options: {
+ '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 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
+
+ describe '#omniauth_options' do
+ it 'constructs basic options' do
+ stub_ldap_config(
+ options: {
+ 'host' => 'ldap.example.com',
+ 'port' => 386,
+ 'base' => 'ou=users,dc=example,dc=com',
+ 'encryption' => 'plain',
+ 'uid' => 'uid'
+ }
+ )
+
+ expect(config.omniauth_options).to include(
+ host: 'ldap.example.com',
+ port: 386,
+ base: 'ou=users,dc=example,dc=com',
+ encryption: 'plain',
+ filter: '(uid=%{username})'
+ )
+ expect(config.omniauth_options.keys).not_to include(:bind_dn, :password)
+ end
+
+ it 'includes authentication options when auth is configured' do
+ stub_ldap_config(
+ options: {
+ 'uid' => 'sAMAccountName',
+ 'user_filter' => '(memberOf=cn=group1,ou=groups,dc=example,dc=com)',
+ 'bind_dn' => 'uid=admin,dc=example,dc=com',
+ 'password' => 'super_secret'
+ }
+ )
+
+ expect(config.omniauth_options).to include(
+ filter: '(&(sAMAccountName=%{username})(memberOf=cn=group1,ou=groups,dc=example,dc=com))',
+ bind_dn: 'uid=admin,dc=example,dc=com',
+ 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
+ it 'is true when password is set' do
+ stub_ldap_config(
+ options: {
+ 'bind_dn' => 'uid=admin,dc=example,dc=com',
+ 'password' => 'super_secret'
+ }
+ )
+
+ expect(config.has_auth?).to be_truthy
+ end
+
+ it 'is true when bind_dn is set and password is empty' do
+ stub_ldap_config(
+ options: {
+ 'bind_dn' => 'uid=admin,dc=example,dc=com',
+ 'password' => ''
+ }
+ )
+
+ expect(config.has_auth?).to be_truthy
+ end
+
+ it 'is false when password and bind_dn are not set' do
+ stub_ldap_config(options: { 'bind_dn' => nil, 'password' => nil })
+
+ expect(config.has_auth?).to be_falsey
+ end
+ end
+
+ describe '#attributes' do
+ it 'uses default attributes when no custom attributes are configured' do
+ expect(config.attributes).to eq(config.default_attributes)
+ end
+
+ it 'merges the configuration attributes with default attributes' do
+ stub_ldap_config(
+ options: {
+ 'attributes' => {
+ 'username' => %w(sAMAccountName),
+ 'email' => %w(userPrincipalName)
+ }
+ }
+ )
+
+ expect(config.attributes).to include({
+ 'username' => %w(sAMAccountName),
+ 'email' => %w(userPrincipalName),
+ 'name' => 'cn'
+ })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/dn_spec.rb b/spec/lib/gitlab/auth/ldap/dn_spec.rb
new file mode 100644
index 00000000000..f2983a02602
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/dn_spec.rb
@@ -0,0 +1,224 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::DN do
+ using RSpec::Parameterized::TableSyntax
+
+ describe '#normalize_value' do
+ subject { described_class.normalize_value(given) }
+
+ it_behaves_like 'normalizes a DN attribute value'
+
+ context 'when the given DN is malformed' do
+ context 'when ending with a comma' do
+ let(:given) { 'John Smith,' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
+ end
+ end
+
+ context 'when given a BER encoded attribute value with a space in it' do
+ let(:given) { '#aa aa' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the end of an attribute value, but got \"a\"")
+ end
+ end
+
+ context 'when given a BER encoded attribute value with a non-hex character in it' do
+ let(:given) { '#aaXaaa' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the first character of a hex pair, but got \"X\"")
+ end
+ end
+
+ context 'when given a BER encoded attribute value with a non-hex character in it' do
+ let(:given) { '#aaaYaa' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the second character of a hex pair, but got \"Y\"")
+ end
+ end
+
+ context 'when given a hex pair with a non-hex character in it, inside double quotes' do
+ let(:given) { '"Sebasti\\cX\\a1n"' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"X\"")
+ end
+ end
+
+ context 'with an open (as opposed to closed) double quote' do
+ let(:given) { '"James' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
+ end
+ end
+
+ context 'with an invalid escaped hex code' do
+ let(:given) { 'J\ames' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'Invalid escaped hex code "\am"')
+ end
+ end
+
+ context 'with a value ending with the escape character' do
+ let(:given) { 'foo\\' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
+ end
+ end
+ end
+ end
+
+ describe '#to_normalized_s' do
+ subject { described_class.new(given).to_normalized_s }
+
+ it_behaves_like 'normalizes a DN'
+
+ context 'when we do not support the given DN format' do
+ context 'multivalued RDNs' do
+ context 'without extraneous whitespace' do
+ let(:given) { 'uid=john smith+telephonenumber=+1 555-555-5555,ou=people,dc=example,dc=com' }
+
+ it 'raises UnsupportedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::UnsupportedError)
+ end
+ end
+
+ context 'with extraneous whitespace' do
+ context 'around the phone number plus sign' do
+ let(:given) { 'uid = John Smith + telephoneNumber = + 1 555-555-5555 , ou = People,dc=example,dc=com' }
+
+ it 'raises UnsupportedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::UnsupportedError)
+ end
+ end
+
+ context 'not around the phone number plus sign' do
+ let(:given) { 'uid = John Smith + telephoneNumber = +1 555-555-5555 , ou = People,dc=example,dc=com' }
+
+ it 'raises UnsupportedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::UnsupportedError)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when the given DN is malformed' do
+ context 'when ending with a comma' do
+ let(:given) { 'uid=John Smith,' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
+ end
+ end
+
+ context 'when given a BER encoded attribute value with a space in it' do
+ let(:given) { '0.9.2342.19200300.100.1.25=#aa aa' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the end of an attribute value, but got \"a\"")
+ end
+ end
+
+ context 'when given a BER encoded attribute value with a non-hex character in it' do
+ let(:given) { '0.9.2342.19200300.100.1.25=#aaXaaa' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the first character of a hex pair, but got \"X\"")
+ end
+ end
+
+ context 'when given a BER encoded attribute value with a non-hex character in it' do
+ let(:given) { '0.9.2342.19200300.100.1.25=#aaaYaa' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the second character of a hex pair, but got \"Y\"")
+ end
+ end
+
+ context 'when given a hex pair with a non-hex character in it, inside double quotes' do
+ let(:given) { 'uid="Sebasti\\cX\\a1n"' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"X\"")
+ end
+ end
+
+ context 'without a name value pair' do
+ let(:given) { 'John' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
+ end
+ end
+
+ context 'with an open (as opposed to closed) double quote' do
+ let(:given) { 'cn="James' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
+ end
+ end
+
+ context 'with an invalid escaped hex code' do
+ let(:given) { 'cn=J\ames' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'Invalid escaped hex code "\am"')
+ end
+ end
+
+ context 'with a value ending with the escape character' do
+ let(:given) { 'cn=\\' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
+ end
+ end
+
+ context 'with an invalid OID attribute type name' do
+ let(:given) { '1.2.d=Value' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'Unrecognized RDN OID attribute type name character "d"')
+ end
+ end
+
+ context 'with a period in a non-OID attribute type name' do
+ let(:given) { 'd1.2=Value' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'Unrecognized RDN attribute type name character "."')
+ end
+ end
+
+ context 'when starting with non-space, non-alphanumeric character' do
+ let(:given) { ' -uid=John Smith' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'Unrecognized first character of an RDN attribute type name "-"')
+ end
+ end
+
+ context 'when given a UID with an escaped equal sign' do
+ let(:given) { 'uid\\=john' }
+
+ it 'raises MalformedError' do
+ expect { subject }.to raise_error(Gitlab::Auth::LDAP::DN::MalformedError, 'Unrecognized RDN attribute type name character "\\"')
+ end
+ end
+ end
+ end
+
+ def assert_generic_test(test_description, got, expected)
+ test_failure_message = "Failed test description: '#{test_description}'\n\n expected: \"#{expected}\"\n got: \"#{got}\""
+ expect(got).to eq(expected), test_failure_message
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/person_spec.rb b/spec/lib/gitlab/auth/ldap/person_spec.rb
new file mode 100644
index 00000000000..1527fe60fb9
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/person_spec.rb
@@ -0,0 +1,160 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::Person do
+ include LdapHelpers
+
+ let(:entry) { ldap_user_entry('john.doe') }
+
+ before do
+ stub_ldap_config(
+ options: {
+ 'uid' => 'uid',
+ 'attributes' => {
+ 'name' => 'cn',
+ 'email' => %w(mail email userPrincipalName),
+ 'username' => username_attribute
+ }
+ }
+ )
+ end
+ let(:username_attribute) { %w(uid sAMAccountName userid) }
+
+ describe '.normalize_dn' do
+ subject { described_class.normalize_dn(given) }
+
+ it_behaves_like 'normalizes a DN'
+
+ context 'with an exception during normalization' do
+ let(:given) { 'John "Smith,' } # just something that will cause an exception
+
+ it 'returns the given DN unmodified' do
+ expect(subject).to eq(given)
+ end
+ end
+ end
+
+ describe '.normalize_uid' do
+ subject { described_class.normalize_uid(given) }
+
+ it_behaves_like 'normalizes a DN attribute value'
+
+ context 'with an exception during normalization' do
+ let(:given) { 'John "Smith,' } # just something that will cause an exception
+
+ it 'returns the given UID unmodified' do
+ expect(subject).to eq(given)
+ end
+ end
+ end
+
+ describe '.ldap_attributes' do
+ it 'returns a compact and unique array' do
+ stub_ldap_config(
+ options: {
+ 'uid' => nil,
+ 'attributes' => {
+ 'name' => 'cn',
+ 'email' => 'mail',
+ 'username' => %w(uid mail memberof)
+ }
+ }
+ )
+ config = Gitlab::Auth::LDAP::Config.new('ldapmain')
+ ldap_attributes = described_class.ldap_attributes(config)
+
+ expect(ldap_attributes).to match_array(%w(dn uid cn mail memberof))
+ end
+ end
+
+ describe '#name' do
+ it 'uses the configured name attribute and handles values as an array' do
+ name = 'John Doe'
+ entry['cn'] = [name]
+ person = described_class.new(entry, 'ldapmain')
+
+ expect(person.name).to eq(name)
+ end
+ end
+
+ describe '#email' do
+ it 'returns the value of mail, if present' do
+ mail = 'john@example.com'
+ entry['mail'] = mail
+ person = described_class.new(entry, 'ldapmain')
+
+ expect(person.email).to eq([mail])
+ end
+
+ 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 = described_class.new(entry, 'ldapmain')
+
+ expect(person.email).to eq([user_principal_name])
+ end
+ end
+
+ describe '#username' do
+ context 'with default uid username attribute' do
+ let(:username_attribute) { 'uid' }
+
+ it 'returns the proper username value' do
+ attr_value = 'johndoe'
+ entry[username_attribute] = attr_value
+ person = described_class.new(entry, 'ldapmain')
+
+ expect(person.username).to eq(attr_value)
+ end
+ end
+
+ context 'with a different username attribute' do
+ let(:username_attribute) { 'sAMAccountName' }
+
+ it 'returns the proper username value' do
+ attr_value = 'johndoe'
+ entry[username_attribute] = attr_value
+ person = described_class.new(entry, 'ldapmain')
+
+ expect(person.username).to eq(attr_value)
+ end
+ end
+
+ context 'with a non-standard username attribute' do
+ let(:username_attribute) { 'mail' }
+
+ it 'returns the proper username value' do
+ attr_value = 'john.doe@example.com'
+ entry[username_attribute] = attr_value
+ person = described_class.new(entry, 'ldapmain')
+
+ expect(person.username).to eq(attr_value)
+ end
+ end
+
+ context 'if lowercase_usernames setting is' do
+ let(:username_attribute) { 'uid' }
+
+ before do
+ entry[username_attribute] = 'JOHN'
+ @person = described_class.new(entry, 'ldapmain')
+ end
+
+ it 'enabled the username attribute is lower cased' do
+ stub_ldap_config(lowercase_usernames: true)
+
+ expect(@person.username).to eq 'john'
+ end
+
+ it 'disabled the username attribute is not lower cased' do
+ stub_ldap_config(lowercase_usernames: false)
+
+ expect(@person.username).to eq 'JOHN'
+ end
+ end
+ end
+
+ def assert_generic_test(test_description, got, expected)
+ test_failure_message = "Failed test description: '#{test_description}'\n\n expected: #{expected}\n got: #{got}"
+ expect(got).to eq(expected), test_failure_message
+ end
+end
diff --git a/spec/lib/gitlab/auth/ldap/user_spec.rb b/spec/lib/gitlab/auth/ldap/user_spec.rb
new file mode 100644
index 00000000000..cab2169593a
--- /dev/null
+++ b/spec/lib/gitlab/auth/ldap/user_spec.rb
@@ -0,0 +1,241 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::LDAP::User do
+ let(:ldap_user) { described_class.new(auth_hash) }
+ let(:gl_user) { ldap_user.gl_user }
+ let(:info) do
+ {
+ name: 'John',
+ email: 'john@example.com',
+ nickname: 'john'
+ }
+ end
+ let(:auth_hash) do
+ OmniAuth::AuthHash.new(uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain', info: info)
+ end
+ let(:ldap_user_upper_case) { described_class.new(auth_hash_upper_case) }
+ let(:info_upper_case) do
+ {
+ name: 'John',
+ email: 'John@Example.com', # Email address has upper case chars
+ nickname: 'john'
+ }
+ end
+ let(:auth_hash_upper_case) do
+ OmniAuth::AuthHash.new(uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain', info: info_upper_case)
+ end
+
+ describe '#changed?' do
+ it "marks existing ldap user as changed" do
+ create(:omniauth_user, extern_uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain')
+ expect(ldap_user.changed?).to be_truthy
+ end
+
+ it "marks existing non-ldap user if the email matches as changed" do
+ create(:user, email: 'john@example.com')
+ expect(ldap_user.changed?).to be_truthy
+ end
+
+ it "does not mark existing ldap user as changed" do
+ create(:omniauth_user, email: 'john@example.com', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain')
+ expect(ldap_user.changed?).to be_falsey
+ end
+ end
+
+ describe '.find_by_uid_and_provider' do
+ let(:dn) { 'CN=John Åström, CN=Users, DC=Example, DC=com' }
+
+ it 'retrieves the correct user' do
+ special_info = {
+ name: 'John Åström',
+ email: 'john@example.com',
+ nickname: 'jastrom'
+ }
+ special_hash = OmniAuth::AuthHash.new(uid: dn, provider: 'ldapmain', info: special_info)
+ special_chars_user = described_class.new(special_hash)
+ user = special_chars_user.save
+
+ expect(described_class.find_by_uid_and_provider(dn, 'ldapmain')).to eq user
+ end
+ end
+
+ describe 'find or create' do
+ it "finds the user if already existing" do
+ create(:omniauth_user, extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain')
+
+ 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 }
+
+ existing_user.reload
+ expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com'
+ expect(existing_user.ldap_identity.provider).to eql 'ldapmain'
+ end
+
+ 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 }
+
+ existing_user.reload
+ expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com'
+ expect(existing_user.ldap_identity.provider).to eql 'ldapmain'
+ expect(existing_user.id).to eql ldap_user.gl_user.id
+ end
+
+ 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 }
+
+ existing_user.reload
+ expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com'
+ expect(existing_user.ldap_identity.provider).to eql 'ldapmain'
+ expect(existing_user.id).to eql ldap_user.gl_user.id
+ end
+
+ it 'maintains an identity per provider' do
+ existing_user = create(:omniauth_user, email: 'john@example.com', provider: 'twitter')
+ expect(existing_user.identities.count).to be(1)
+
+ ldap_user.save
+ expect(ldap_user.gl_user.identities.count).to be(2)
+
+ # Expect that find_by provider only returns a single instance of an identity and not an Enumerable
+ expect(ldap_user.gl_user.identities.find_by(provider: 'twitter')).to be_instance_of Identity
+ expect(ldap_user.gl_user.identities.find_by(provider: auth_hash.provider)).to be_instance_of Identity
+ end
+
+ it "creates a new user if not found" do
+ expect { ldap_user.save }.to change { User.count }.by(1)
+ end
+
+ context 'when signup is disabled' do
+ before do
+ stub_application_setting signup_enabled: false
+ end
+
+ it 'creates the user' do
+ ldap_user.save
+
+ expect(gl_user).to be_persisted
+ end
+ end
+
+ context 'when user confirmation email is enabled' do
+ before do
+ stub_application_setting send_user_confirmation_email: true
+ end
+
+ it 'creates and confirms the user anyway' do
+ ldap_user.save
+
+ expect(gl_user).to be_persisted
+ expect(gl_user).to be_confirmed
+ end
+ end
+ end
+
+ describe 'updating email' do
+ context "when LDAP sets an email" do
+ it "has a real email" do
+ expect(ldap_user.gl_user.email).to eq(info[:email])
+ end
+
+ it "has email set as synced" do
+ expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
+ end
+
+ it "has email set as read-only" do
+ expect(ldap_user.gl_user.read_only_attribute?(:email)).to be_truthy
+ end
+
+ it "has synced attributes provider set to ldapmain" do
+ expect(ldap_user.gl_user.user_synced_attributes_metadata.provider).to eql 'ldapmain'
+ end
+ end
+
+ context "when LDAP doesn't set an email" do
+ before do
+ info.delete(:email)
+ end
+
+ it "has a temp email" do
+ expect(ldap_user.gl_user.temp_oauth_email?).to be_truthy
+ end
+
+ it "has email set as not synced" do
+ expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
+ end
+
+ it "does not have email set as read-only" do
+ expect(ldap_user.gl_user.read_only_attribute?(:email)).to be_falsey
+ end
+ end
+ end
+
+ describe 'blocking' do
+ def configure_block(value)
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config)
+ .to receive(:block_auto_created_users).and_return(value)
+ end
+
+ context 'signup' do
+ context 'dont block on create' do
+ before do
+ configure_block(false)
+ end
+
+ it do
+ ldap_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create' do
+ before do
+ configure_block(true)
+ end
+
+ it do
+ ldap_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).to be_blocked
+ end
+ end
+ end
+
+ context 'sign-in' do
+ before do
+ ldap_user.save
+ ldap_user.gl_user.activate
+ end
+
+ context 'dont block on create' do
+ before do
+ configure_block(false)
+ end
+
+ it do
+ ldap_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create' do
+ before do
+ configure_block(true)
+ end
+
+ it do
+ ldap_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb b/spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb
new file mode 100644
index 00000000000..40001cea22e
--- /dev/null
+++ b/spec/lib/gitlab/auth/o_auth/auth_hash_spec.rb
@@ -0,0 +1,111 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::OAuth::AuthHash do
+ let(:provider) { 'ldap'.freeze }
+ let(:auth_hash) do
+ described_class.new(
+ OmniAuth::AuthHash.new(
+ provider: provider,
+ uid: uid_ascii,
+ info: info_hash
+ )
+ )
+ end
+
+ let(:uid_raw) do
+ "CN=Onur K\xC3\xBC\xC3\xA7\xC3\xBCk,OU=Test,DC=example,DC=net"
+ end
+ let(:email_raw) { "onur.k\xC3\xBC\xC3\xA7\xC3\xBCk_ABC-123@example.net" }
+ let(:nickname_raw) { "ok\xC3\xBC\xC3\xA7\xC3\xBCk" }
+ let(:first_name_raw) { 'Onur' }
+ let(:last_name_raw) { "K\xC3\xBC\xC3\xA7\xC3\xBCk" }
+ let(:name_raw) { "Onur K\xC3\xBC\xC3\xA7\xC3\xBCk" }
+
+ let(:uid_ascii) { uid_raw.force_encoding(Encoding::ASCII_8BIT) }
+ let(:email_ascii) { email_raw.force_encoding(Encoding::ASCII_8BIT) }
+ let(:nickname_ascii) { nickname_raw.force_encoding(Encoding::ASCII_8BIT) }
+ let(:first_name_ascii) { first_name_raw.force_encoding(Encoding::ASCII_8BIT) }
+ let(:last_name_ascii) { last_name_raw.force_encoding(Encoding::ASCII_8BIT) }
+ let(:name_ascii) { name_raw.force_encoding(Encoding::ASCII_8BIT) }
+
+ let(:uid_utf8) { uid_ascii.force_encoding(Encoding::UTF_8) }
+ let(:email_utf8) { email_ascii.force_encoding(Encoding::UTF_8) }
+ let(:nickname_utf8) { nickname_ascii.force_encoding(Encoding::UTF_8) }
+ let(:name_utf8) { name_ascii.force_encoding(Encoding::UTF_8) }
+
+ let(:info_hash) do
+ {
+ email: email_ascii,
+ first_name: first_name_ascii,
+ last_name: last_name_ascii,
+ name: name_ascii,
+ nickname: nickname_ascii,
+ uid: uid_ascii
+ }
+ end
+
+ context 'defaults' do
+ it { expect(auth_hash.provider).to eq provider }
+ it { expect(auth_hash.uid).to eql uid_utf8 }
+ it { expect(auth_hash.email).to eql email_utf8 }
+ it { expect(auth_hash.username).to eql nickname_utf8 }
+ it { expect(auth_hash.name).to eql name_utf8 }
+ it { expect(auth_hash.password).not_to be_empty }
+ end
+
+ context 'email not provided' do
+ before do
+ info_hash.delete(:email)
+ end
+
+ it 'generates a temp email' do
+ expect( auth_hash.email).to start_with('temp-email-for-oauth')
+ end
+ end
+
+ context 'username not provided' do
+ 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'
+ end
+ end
+
+ context 'name not provided' do
+ before do
+ info_hash.delete(:name)
+ end
+
+ it 'concats first and lastname as the name' do
+ expect(auth_hash.name).to eql name_utf8
+ end
+ end
+
+ context 'auth_hash constructed with ASCII-8BIT encoding' do
+ it 'forces utf8 encoding on uid' do
+ expect(auth_hash.uid.encoding).to eql Encoding::UTF_8
+ end
+
+ it 'forces utf8 encoding on provider' do
+ expect(auth_hash.provider.encoding).to eql Encoding::UTF_8
+ end
+
+ it 'forces utf8 encoding on name' do
+ expect(auth_hash.name.encoding).to eql Encoding::UTF_8
+ end
+
+ it 'forces utf8 encoding on username' do
+ expect(auth_hash.username.encoding).to eql Encoding::UTF_8
+ end
+
+ it 'forces utf8 encoding on email' do
+ expect(auth_hash.email.encoding).to eql Encoding::UTF_8
+ end
+
+ it 'forces utf8 encoding on password' do
+ expect(auth_hash.password.encoding).to eql Encoding::UTF_8
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/o_auth/provider_spec.rb b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
new file mode 100644
index 00000000000..fc35d430917
--- /dev/null
+++ b/spec/lib/gitlab/auth/o_auth/provider_spec.rb
@@ -0,0 +1,42 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::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/auth/o_auth/user_spec.rb b/spec/lib/gitlab/auth/o_auth/user_spec.rb
new file mode 100644
index 00000000000..64f3d09a25b
--- /dev/null
+++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb
@@ -0,0 +1,782 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::OAuth::User do
+ include LdapHelpers
+
+ let(:oauth_user) { described_class.new(auth_hash) }
+ let(:gl_user) { oauth_user.gl_user }
+ let(:uid) { 'my-uid' }
+ let(:dn) { 'uid=user1,ou=people,dc=example' }
+ let(:provider) { 'my-provider' }
+ let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash) }
+ let(:info_hash) do
+ {
+ nickname: '-john+gitlab-ETC%.git@gmail.com',
+ name: 'John',
+ email: 'john@mail.com',
+ address: {
+ locality: 'locality',
+ country: 'country'
+ }
+ }
+ end
+ let(:ldap_user) { Gitlab::Auth::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
+
+ describe '#persisted?' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ it "finds an existing user based on uid and provider (facebook)" do
+ expect( oauth_user.persisted? ).to be_truthy
+ end
+
+ it 'returns false if user is not found in database' do
+ allow(auth_hash).to receive(:uid).and_return('non-existing')
+ expect( oauth_user.persisted? ).to be_falsey
+ end
+ end
+
+ def stub_omniauth_config(messages)
+ allow(Gitlab.config.omniauth).to receive_messages(messages)
+ end
+
+ describe '#save' do
+ let(:provider) { 'twitter' }
+
+ describe 'when account exists on server' do
+ it 'does not mark the user as external' do
+ create(:omniauth_user, extern_uid: 'my-uid', provider: provider)
+ stub_omniauth_config(allow_single_sign_on: [provider], external_providers: [provider])
+
+ oauth_user.save
+
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_falsey
+ end
+ end
+
+ describe 'signup' do
+ context 'when signup is disabled' do
+ before do
+ stub_application_setting signup_enabled: false
+ end
+
+ it 'creates the user' do
+ stub_omniauth_config(allow_single_sign_on: [provider])
+
+ oauth_user.save
+
+ expect(gl_user).to be_persisted
+ end
+ end
+
+ context 'when user confirmation email is enabled' do
+ before do
+ stub_application_setting send_user_confirmation_email: true
+ end
+
+ it 'creates and confirms the user anyway' do
+ stub_omniauth_config(allow_single_sign_on: [provider])
+
+ oauth_user.save
+
+ expect(gl_user).to be_persisted
+ expect(gl_user).to be_confirmed
+ end
+ end
+
+ it 'marks user as having password_automatically_set' do
+ stub_omniauth_config(allow_single_sign_on: [provider], external_providers: [provider])
+
+ oauth_user.save
+
+ expect(gl_user).to be_persisted
+ expect(gl_user).to be_password_automatically_set
+ end
+
+ shared_examples 'to verify compliance with allow_single_sign_on' do
+ context 'provider is marked as external' do
+ it 'marks user as external' do
+ stub_omniauth_config(allow_single_sign_on: [provider], external_providers: [provider])
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_truthy
+ end
+ end
+
+ context 'provider was external, now has been removed' do
+ it 'does not mark external user as internal' do
+ create(:omniauth_user, extern_uid: 'my-uid', provider: provider, external: true)
+ stub_omniauth_config(allow_single_sign_on: [provider], external_providers: ['facebook'])
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_truthy
+ end
+ end
+
+ context 'provider is not external' do
+ context 'when adding a new OAuth identity' do
+ it 'does not promote an external user to internal' do
+ user = create(:user, email: 'john@mail.com', external: true)
+ user.identities.create(provider: provider, extern_uid: uid)
+
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_truthy
+ end
+ end
+ end
+
+ context 'with new allow_single_sign_on enabled syntax' do
+ before do
+ stub_omniauth_config(allow_single_sign_on: [provider])
+ end
+
+ it "creates a user from Omniauth" do
+ oauth_user.save
+
+ expect(gl_user).to be_valid
+ identity = gl_user.identities.first
+ expect(identity.extern_uid).to eql uid
+ expect(identity.provider).to eql provider
+ end
+ end
+
+ context "with old allow_single_sign_on enabled syntax" do
+ before do
+ stub_omniauth_config(allow_single_sign_on: true)
+ end
+
+ it "creates a user from Omniauth" do
+ oauth_user.save
+
+ expect(gl_user).to be_valid
+ identity = gl_user.identities.first
+ expect(identity.extern_uid).to eql uid
+ expect(identity.provider).to eql provider
+ end
+ end
+
+ context 'with new allow_single_sign_on disabled syntax' do
+ before do
+ stub_omniauth_config(allow_single_sign_on: [])
+ end
+
+ it 'throws an error' do
+ expect { oauth_user.save }.to raise_error StandardError
+ end
+ end
+
+ context 'with old allow_single_sign_on disabled (Default)' do
+ before do
+ stub_omniauth_config(allow_single_sign_on: false)
+ end
+
+ it 'throws an error' do
+ expect { oauth_user.save }.to raise_error StandardError
+ end
+ end
+ end
+
+ context "with auto_link_ldap_user disabled (default)" do
+ 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 do
+ stub_omniauth_config(auto_link_ldap_user: true)
+ end
+
+ context "and no LDAP provider defined" do
+ 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 do
+ stub_ldap_config(providers: %w(ldapmain))
+ end
+
+ context "and a corresponding LDAP person" do
+ before do
+ allow(ldap_user).to receive(:uid) { uid }
+ allow(ldap_user).to receive(:username) { uid }
+ allow(ldap_user).to receive(:email) { ['johndoe@example.com', 'john2@example.com'] }
+ allow(ldap_user).to receive(:dn) { dn }
+ end
+
+ context "and no account for the LDAP user" do
+ before do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
+
+ oauth_user.save
+ end
+
+ it "creates a user with dual LDAP and omniauth identities" do
+ expect(gl_user).to be_valid
+ expect(gl_user.username).to eql uid
+ expect(gl_user.email).to eql 'johndoe@example.com'
+ expect(gl_user.identities.length).to be 2
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array(
+ [
+ { provider: 'ldapmain', extern_uid: dn },
+ { provider: 'twitter', extern_uid: uid }
+ ]
+ )
+ end
+
+ it "has email set as synced" do
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
+ end
+
+ it "has email set as read-only" do
+ expect(gl_user.read_only_attribute?(:email)).to be_truthy
+ end
+
+ it "has synced attributes provider set to ldapmain" do
+ expect(gl_user.user_synced_attributes_metadata.provider).to eql 'ldapmain'
+ end
+ end
+
+ context "and LDAP user has an account already" do
+ let!(:existing_user) { create(:omniauth_user, email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
+ it "adds the omniauth identity to the LDAP account" do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
+
+ oauth_user.save
+
+ expect(gl_user).to be_valid
+ expect(gl_user.username).to eql 'john'
+ expect(gl_user.email).to eql 'john@example.com'
+ expect(gl_user.identities.length).to be 2
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array(
+ [
+ { provider: 'ldapmain', extern_uid: dn },
+ { provider: 'twitter', extern_uid: uid }
+ ]
+ )
+ end
+ end
+
+ context 'when an LDAP person is not found by uid' do
+ it 'tries to find an LDAP person by email and adds the omniauth identity to the user' do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(nil)
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_email).and_return(ldap_user)
+
+ oauth_user.save
+
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array(result_identities(dn, uid))
+ end
+
+ context 'when also not found by email' do
+ it 'tries to find an LDAP person by DN and adds the omniauth identity to the user' do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(nil)
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_email).and_return(nil)
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).and_return(ldap_user)
+
+ oauth_user.save
+
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array(result_identities(dn, uid))
+ end
+ end
+ end
+
+ def result_identities(dn, uid)
+ [
+ { provider: 'ldapmain', extern_uid: dn },
+ { provider: 'twitter', extern_uid: uid }
+ ]
+ end
+
+ context 'when there is an LDAP connection error' do
+ before do
+ raise_ldap_connection_error
+ end
+
+ it 'does not save the identity' do
+ oauth_user.save
+
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array([{ provider: 'twitter', extern_uid: uid }])
+ end
+ end
+ end
+
+ context 'and a corresponding LDAP person with a non-default username' do
+ before do
+ allow(ldap_user).to receive(:uid) { uid }
+ allow(ldap_user).to receive(:username) { 'johndoe@example.com' }
+ allow(ldap_user).to receive(:email) { %w(johndoe@example.com john2@example.com) }
+ allow(ldap_user).to receive(:dn) { dn }
+ end
+
+ context 'and no account for the LDAP user' do
+ it 'creates a user favoring the LDAP username and strips email domain' do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
+
+ oauth_user.save
+
+ expect(gl_user).to be_valid
+ expect(gl_user.username).to eql 'johndoe'
+ end
+ end
+ end
+
+ context "and no corresponding LDAP person" do
+ before do
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(nil)
+ end
+
+ include_examples "to verify compliance with allow_single_sign_on"
+ end
+ end
+ end
+ end
+
+ describe 'blocking' do
+ let(:provider) { '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 do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create' do
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).to be_blocked
+ end
+ end
+ end
+
+ context 'signup with linked omniauth and LDAP account' do
+ before do
+ stub_omniauth_config(auto_link_ldap_user: true)
+ allow(ldap_user).to receive(:uid) { uid }
+ allow(ldap_user).to receive(:username) { uid }
+ allow(ldap_user).to receive(:email) { ['johndoe@example.com', 'john2@example.com'] }
+ allow(ldap_user).to receive(:dn) { dn }
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
+ end
+
+ context "and no account for the LDAP user" do
+ context 'dont block on create (LDAP)' do
+ before do
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive_messages(block_auto_created_users: false)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create (LDAP)' do
+ before do
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive_messages(block_auto_created_users: true)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).to be_blocked
+ end
+ end
+ end
+
+ context 'and LDAP user has an account already' do
+ let!(:existing_user) { create(:omniauth_user, email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
+
+ context 'dont block on create (LDAP)' do
+ before do
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive_messages(block_auto_created_users: false)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create (LDAP)' do
+ before do
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive_messages(block_auto_created_users: true)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+ end
+ end
+
+ context 'sign-in' do
+ before do
+ oauth_user.save
+ oauth_user.gl_user.activate
+ end
+
+ context 'dont block on create' do
+ before do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create' do
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'dont block on create (LDAP)' do
+ before do
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive_messages(block_auto_created_users: false)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create (LDAP)' do
+ before do
+ allow_any_instance_of(Gitlab::Auth::LDAP::Config).to receive_messages(block_auto_created_users: true)
+ end
+
+ it do
+ oauth_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+ end
+ end
+ end
+
+ describe 'ensure backwards compatibility with with sync email from provider option' 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')
+ stub_omniauth_config(sync_profile_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 email set as synced" do
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
+ end
+
+ it "has email set as read-only" do
+ expect(gl_user.read_only_attribute?(:email)).to be_truthy
+ end
+
+ it "has synced attributes provider set to my-provider" do
+ expect(gl_user.user_synced_attributes_metadata.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 email set as not synced" do
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
+ end
+
+ it "does not have email set as read-only" do
+ expect(gl_user.read_only_attribute?(:email)).to be_falsey
+ 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
+
+ describe 'updating email with sync profile' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ before do
+ stub_omniauth_config(sync_profile_from_provider: ['my-provider'])
+ stub_omniauth_config(sync_profile_attributes: true)
+ 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 email set as synced" do
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true)
+ end
+
+ it "has email set as read-only" do
+ expect(gl_user.read_only_attribute?(:email)).to be_truthy
+ end
+
+ it "has synced attributes provider set to my-provider" do
+ expect(gl_user.user_synced_attributes_metadata.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 email set as not synced" do
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
+ end
+
+ it "does not have email set as read-only" do
+ expect(gl_user.read_only_attribute?(:email)).to be_falsey
+ end
+ end
+ end
+
+ describe 'updating name' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ before do
+ stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
+ stub_omniauth_setting(sync_profile_attributes: true)
+ end
+
+ context "when provider sets a name" do
+ it "updates the user name" do
+ expect(gl_user.name).to eq(info_hash[:name])
+ end
+ end
+
+ context "when provider doesn't set a name" do
+ before do
+ info_hash.delete(:name)
+ end
+
+ it "does not update the user name" do
+ expect(gl_user.name).not_to eq(info_hash[:name])
+ expect(gl_user.user_synced_attributes_metadata.name_synced).to be(false)
+ end
+ end
+ end
+
+ describe 'updating location' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ before do
+ stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
+ stub_omniauth_setting(sync_profile_attributes: true)
+ end
+
+ context "when provider sets a location" do
+ it "updates the user location" do
+ expect(gl_user.location).to eq(info_hash[:address][:locality] + ', ' + info_hash[:address][:country])
+ expect(gl_user.user_synced_attributes_metadata.location_synced).to be(true)
+ end
+ end
+
+ context "when provider doesn't set a location" do
+ before do
+ info_hash[:address].delete(:country)
+ info_hash[:address].delete(:locality)
+ end
+
+ it "does not update the user location" do
+ expect(gl_user.location).to be_nil
+ expect(gl_user.user_synced_attributes_metadata.location_synced).to be(false)
+ end
+ end
+ end
+
+ describe 'updating user info' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ context "update all info" do
+ before do
+ stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
+ stub_omniauth_setting(sync_profile_attributes: true)
+ end
+
+ it "updates the user email" do
+ expect(gl_user.email).to eq(info_hash[:email])
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true)
+ end
+
+ it "updates the user name" do
+ expect(gl_user.name).to eq(info_hash[:name])
+ expect(gl_user.user_synced_attributes_metadata.name_synced).to be(true)
+ end
+
+ it "updates the user location" do
+ expect(gl_user.location).to eq(info_hash[:address][:locality] + ', ' + info_hash[:address][:country])
+ expect(gl_user.user_synced_attributes_metadata.location_synced).to be(true)
+ end
+
+ it "sets my-provider as the attributes provider" do
+ expect(gl_user.user_synced_attributes_metadata.provider).to eql('my-provider')
+ end
+ end
+
+ context "update only requested info" do
+ before do
+ stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
+ stub_omniauth_setting(sync_profile_attributes: %w(name location))
+ end
+
+ it "updates the user name" do
+ expect(gl_user.name).to eq(info_hash[:name])
+ expect(gl_user.user_synced_attributes_metadata.name_synced).to be(true)
+ end
+
+ it "updates the user location" do
+ expect(gl_user.location).to eq(info_hash[:address][:locality] + ', ' + info_hash[:address][:country])
+ expect(gl_user.user_synced_attributes_metadata.location_synced).to be(true)
+ end
+
+ it "does not update the user email" do
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be(false)
+ end
+ end
+
+ context "update default_scope" do
+ before do
+ stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
+ end
+
+ it "updates the user email" do
+ expect(gl_user.email).to eq(info_hash[:email])
+ expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true)
+ end
+ end
+
+ context "update no info when profile sync is nil" do
+ it "does not have sync_attribute" do
+ expect(gl_user.user_synced_attributes_metadata).to be(nil)
+ end
+
+ it "does not update the user email" do
+ expect(gl_user.email).not_to eq(info_hash[:email])
+ end
+
+ it "does not update the user name" do
+ expect(gl_user.name).not_to eq(info_hash[:name])
+ end
+
+ it "does not update the user location" do
+ expect(gl_user.location).not_to eq(info_hash[:address][:country])
+ end
+
+ it 'does not create associated user synced attributes metadata' do
+ expect(gl_user.user_synced_attributes_metadata).to be_nil
+ end
+ end
+ end
+
+ describe '.find_by_uid_and_provider' do
+ let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
+
+ it 'normalizes extern_uid' do
+ allow(oauth_user.auth_hash).to receive(:uid).and_return('MY-UID')
+ expect(oauth_user.find_user).to eql gl_user
+ end
+ end
+
+ describe '#find_ldap_person' do
+ context 'when LDAP connection fails' do
+ before do
+ raise_ldap_connection_error
+ end
+
+ it 'returns nil' do
+ adapter = Gitlab::Auth::LDAP::Adapter.new('ldapmain')
+ hash = OmniAuth::AuthHash.new(uid: 'whatever', provider: 'ldapmain')
+
+ expect(oauth_user.send(:find_ldap_person, hash, adapter)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/saml/auth_hash_spec.rb b/spec/lib/gitlab/auth/saml/auth_hash_spec.rb
new file mode 100644
index 00000000000..bb950e6bbf8
--- /dev/null
+++ b/spec/lib/gitlab/auth/saml/auth_hash_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::Saml::AuthHash do
+ include LoginHelpers
+
+ let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers) } }
+ subject(:saml_auth_hash) { described_class.new(omniauth_auth_hash) }
+
+ let(:info_hash) do
+ {
+ name: 'John',
+ email: 'john@mail.com'
+ }
+ end
+
+ let(:omniauth_auth_hash) do
+ OmniAuth::AuthHash.new(uid: 'my-uid',
+ provider: 'saml',
+ info: info_hash,
+ extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) } )
+ end
+
+ before do
+ stub_saml_group_config(%w(Developers Freelancers Designers))
+ end
+
+ describe '#groups' do
+ it 'returns array of groups' do
+ expect(saml_auth_hash.groups).to eq(%w(Developers Freelancers))
+ end
+
+ context 'raw info hash attributes empty' do
+ let(:raw_info_attr) { {} }
+
+ it 'returns an empty array' do
+ expect(saml_auth_hash.groups).to be_a(Array)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/saml/user_spec.rb b/spec/lib/gitlab/auth/saml/user_spec.rb
new file mode 100644
index 00000000000..62514ca0688
--- /dev/null
+++ b/spec/lib/gitlab/auth/saml/user_spec.rb
@@ -0,0 +1,403 @@
+require 'spec_helper'
+
+describe Gitlab::Auth::Saml::User do
+ include LdapHelpers
+ include LoginHelpers
+
+ let(:saml_user) { described_class.new(auth_hash) }
+ let(:gl_user) { saml_user.gl_user }
+ let(:uid) { 'my-uid' }
+ let(:dn) { 'uid=user1,ou=people,dc=example' }
+ let(:provider) { 'saml' }
+ let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers Designers) } }
+ let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash, extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) }) }
+ let(:info_hash) do
+ {
+ name: 'John',
+ email: 'john@mail.com'
+ }
+ end
+ let(:ldap_user) { Gitlab::Auth::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
+
+ describe '#save' do
+ before do
+ stub_basic_saml_config
+ end
+
+ describe 'account exists on server' do
+ 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
+ expect(gl_user).to be_valid
+ expect(gl_user).to eq existing_user
+ identity = gl_user.identities.first
+ expect(identity.extern_uid).to eql uid
+ expect(identity.provider).to eql 'saml'
+ end
+ end
+
+ context 'external groups' do
+ context 'are defined' do
+ it 'marks the user as external' do
+ stub_saml_group_config(%w(Freelancers))
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_truthy
+ end
+ end
+
+ 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
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_falsey
+ end
+ end
+
+ context 'user was external, now should not be' do
+ it 'makes user internal' do
+ existing_user.update_attribute('external', true)
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_falsey
+ end
+ end
+ end
+ end
+
+ 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 do
+ stub_omniauth_config(allow_single_sign_on: ['saml'])
+ end
+
+ it 'creates a user from SAML' do
+ saml_user.save
+
+ expect(gl_user).to be_valid
+ identity = gl_user.identities.first
+ expect(identity.extern_uid).to eql uid
+ expect(identity.provider).to eql 'saml'
+ end
+ end
+
+ context 'with allow_single_sign_on default (["saml"])' do
+ 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
+ end
+ end
+
+ context 'with allow_single_sign_on disabled' do
+ before do
+ stub_omniauth_config(allow_single_sign_on: false)
+ end
+
+ it 'throws an error' do
+ expect { saml_user.save }.to raise_error StandardError
+ end
+ end
+ end
+
+ context 'external groups' do
+ context 'are defined' do
+ it 'marks the user as external' do
+ stub_saml_group_config(%w(Freelancers))
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_truthy
+ end
+ end
+
+ context 'are defined but the user does not belong there' do
+ it 'does not mark the user as external' do
+ stub_saml_group_config(%w(Interns))
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user.external).to be_falsey
+ end
+ end
+ end
+
+ context 'with auto_link_ldap_user disabled (default)' do
+ 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 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 do
+ stub_ldap_config(providers: %w(ldapmain))
+ end
+
+ context 'and a corresponding LDAP person' do
+ let(:adapter) { ldap_adapter('ldapmain') }
+
+ before do
+ allow(ldap_user).to receive(:uid) { uid }
+ allow(ldap_user).to receive(:username) { uid }
+ allow(ldap_user).to receive(:email) { %w(john@mail.com john2@example.com) }
+ allow(ldap_user).to receive(:dn) { dn }
+ allow(Gitlab::Auth::LDAP::Adapter).to receive(:new).and_return(adapter)
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).with(uid, adapter).and_return(ldap_user)
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_dn).with(dn, adapter).and_return(ldap_user)
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_email).with('john@mail.com', adapter).and_return(ldap_user)
+ end
+
+ context 'and no account for the LDAP user' do
+ it 'creates a user with dual LDAP and SAML identities' do
+ saml_user.save
+
+ expect(gl_user).to be_valid
+ expect(gl_user.username).to eql uid
+ expect(gl_user.email).to eql 'john@mail.com'
+ expect(gl_user.identities.length).to be 2
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: dn },
+ { provider: 'saml', extern_uid: uid }])
+ end
+ end
+
+ context 'and LDAP user has an account already' do
+ let(:auth_hash_base_attributes) do
+ {
+ uid: uid,
+ provider: provider,
+ info: info_hash,
+ extra: {
+ raw_info: OneLogin::RubySaml::Attributes.new(
+ { 'groups' => %w(Developers Freelancers Designers) }
+ )
+ }
+ }
+ end
+ let(:auth_hash) { OmniAuth::AuthHash.new(auth_hash_base_attributes) }
+ let(:uid_types) { %w(uid dn email) }
+
+ before do
+ create(:omniauth_user,
+ email: 'john@mail.com',
+ extern_uid: dn,
+ provider: 'ldapmain',
+ username: 'john')
+ end
+
+ shared_examples 'find LDAP person' do |uid_type, uid|
+ let(:auth_hash) { OmniAuth::AuthHash.new(auth_hash_base_attributes.merge(uid: extern_uid)) }
+
+ before do
+ nil_types = uid_types - [uid_type]
+
+ nil_types.each do |type|
+ allow(Gitlab::Auth::LDAP::Person).to receive(:"find_by_#{type}").and_return(nil)
+ end
+
+ allow(Gitlab::Auth::LDAP::Person).to receive(:"find_by_#{uid_type}").and_return(ldap_user)
+ end
+
+ it 'adds the omniauth identity to the LDAP account' do
+ identities = [
+ { provider: 'ldapmain', extern_uid: dn },
+ { provider: 'saml', extern_uid: extern_uid }
+ ]
+
+ identities_as_hash = gl_user.identities.map do |id|
+ { provider: id.provider, extern_uid: id.extern_uid }
+ end
+
+ saml_user.save
+
+ expect(gl_user).to be_valid
+ expect(gl_user.username).to eql 'john'
+ expect(gl_user.email).to eql 'john@mail.com'
+ expect(gl_user.identities.length).to be 2
+ expect(identities_as_hash).to match_array(identities)
+ end
+ end
+
+ context 'when uid is an uid' do
+ it_behaves_like 'find LDAP person', 'uid' do
+ let(:extern_uid) { uid }
+ end
+ end
+
+ context 'when uid is a dn' do
+ it_behaves_like 'find LDAP person', 'dn' do
+ let(:extern_uid) { dn }
+ end
+ end
+
+ context 'when uid is an email' do
+ it_behaves_like 'find LDAP person', 'email' do
+ let(:extern_uid) { 'john@mail.com' }
+ end
+ end
+
+ it 'adds the omniauth identity to the LDAP account' do
+ saml_user.save
+
+ expect(gl_user).to be_valid
+ expect(gl_user.username).to eql 'john'
+ expect(gl_user.email).to eql 'john@mail.com'
+ expect(gl_user.identities.length).to be 2
+ identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: dn },
+ { provider: 'saml', extern_uid: uid }])
+ end
+
+ it 'saves successfully on subsequent tries, when both identities are present' do
+ saml_user.save
+ local_saml_user = described_class.new(auth_hash)
+ local_saml_user.save
+
+ expect(local_saml_user.gl_user).to be_valid
+ expect(local_saml_user.gl_user).to be_persisted
+ end
+ end
+
+ context 'user has SAML user, and wants to add their LDAP identity' do
+ it 'adds the LDAP identity to the existing SAML user' do
+ create(:omniauth_user, email: 'john@mail.com', extern_uid: dn, provider: 'saml', username: 'john')
+
+ allow(Gitlab::Auth::LDAP::Person).to receive(:find_by_uid).with(dn, adapter).and_return(ldap_user)
+
+ local_hash = OmniAuth::AuthHash.new(uid: dn, provider: provider, info: info_hash)
+ local_saml_user = described_class.new(local_hash)
+
+ local_saml_user.save
+ local_gl_user = local_saml_user.gl_user
+
+ expect(local_gl_user).to be_valid
+ expect(local_gl_user.identities.length).to be 2
+ identities_as_hash = local_gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
+ expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: dn },
+ { provider: 'saml', extern_uid: dn }])
+ end
+ end
+ end
+ end
+ end
+
+ context 'when signup is disabled' do
+ before do
+ stub_application_setting signup_enabled: false
+ end
+
+ it 'creates the user' do
+ saml_user.save
+
+ expect(gl_user).to be_persisted
+ end
+ end
+
+ context 'when user confirmation email is enabled' do
+ before do
+ stub_application_setting send_user_confirmation_email: true
+ end
+
+ it 'creates and confirms the user anyway' do
+ saml_user.save
+
+ expect(gl_user).to be_persisted
+ expect(gl_user).to be_confirmed
+ end
+ end
+ end
+
+ describe 'blocking' do
+ 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 do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
+
+ it 'does not block the user' do
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create' do
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
+
+ it 'blocks user' do
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).to be_blocked
+ end
+ end
+ end
+
+ context 'sign-in' do
+ before do
+ saml_user.save
+ saml_user.gl_user.activate
+ end
+
+ context 'dont block on create' do
+ before do
+ stub_omniauth_config(block_auto_created_users: false)
+ end
+
+ it do
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+
+ context 'block on create' do
+ before do
+ stub_omniauth_config(block_auto_created_users: true)
+ end
+
+ it do
+ saml_user.save
+ expect(gl_user).to be_valid
+ expect(gl_user).not_to be_blocked
+ end
+ end
+ end
+ end
+ end
+
+ describe '#find_user' do
+ context 'raw info hash attributes empty' do
+ let(:raw_info_attr) { {} }
+
+ it 'does not mark user as external' do
+ stub_saml_group_config(%w(Freelancers))
+
+ expect(saml_user.find_user.external).to be_falsy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/auth/user_auth_finders_spec.rb b/spec/lib/gitlab/auth/user_auth_finders_spec.rb
index 4637816570c..2733eef6611 100644
--- a/spec/lib/gitlab/auth/user_auth_finders_spec.rb
+++ b/spec/lib/gitlab/auth/user_auth_finders_spec.rb
@@ -76,6 +76,16 @@ describe Gitlab::Auth::UserAuthFinders do
expect(find_user_from_rss_token).to be_nil
end
end
+
+ context 'when the request format is empty' do
+ it 'the method call does not modify the original value' do
+ env['action_dispatch.request.formats'] = nil
+
+ find_user_from_rss_token
+
+ expect(env['action_dispatch.request.formats']).to be_nil
+ end
+ end
end
describe '#find_user_from_access_token' do
diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb
index a6fbec295b5..9ccd0b206cc 100644
--- a/spec/lib/gitlab/auth_spec.rb
+++ b/spec/lib/gitlab/auth_spec.rb
@@ -5,7 +5,7 @@ describe Gitlab::Auth do
describe 'constants' do
it 'API_SCOPES contains all scopes for API access' do
- expect(subject::API_SCOPES).to eq %i[api read_user sudo]
+ expect(subject::API_SCOPES).to eq %i[api read_user sudo read_repository]
end
it 'OPENID_SCOPES contains all scopes for OpenID Connect' do
@@ -19,7 +19,7 @@ describe Gitlab::Auth do
it 'optional_scopes contains all non-default scopes' do
stub_container_registry_config(enabled: true)
- expect(subject.optional_scopes).to eq %i[read_user sudo read_registry openid]
+ expect(subject.optional_scopes).to eq %i[read_user sudo read_repository read_registry openid]
end
context 'registry_scopes' do
@@ -136,8 +136,8 @@ describe Gitlab::Auth do
it 'grants deploy key write permissions' do
project = create(:project)
- key = create(:deploy_key, can_push: true)
- create(:deploy_keys_project, deploy_key: key, project: project)
+ key = create(:deploy_key)
+ create(:deploy_keys_project, :write_access, deploy_key: key, project: project)
token = Gitlab::LfsToken.new(key).token
expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: "lfs+deploy-key-#{key.id}")
@@ -146,7 +146,7 @@ describe Gitlab::Auth do
it 'does not grant deploy key write permissions' do
project = create(:project)
- key = create(:deploy_key, can_push: true)
+ key = create(:deploy_key)
token = Gitlab::LfsToken.new(key).token
expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: "lfs+deploy-key-#{key.id}")
@@ -231,7 +231,7 @@ describe Gitlab::Auth do
.to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities))
end
- it 'falls through oauth authentication when the username is oauth2' do
+ it 'fails through oauth authentication when the username is oauth2' do
user = create(
:user,
username: 'oauth2',
@@ -255,6 +255,122 @@ describe Gitlab::Auth do
expect { gl_auth.find_for_git_client('foo', 'bar', project: nil, ip: 'ip') }.to raise_error(Gitlab::Auth::MissingPersonalAccessTokenError)
end
+
+ context 'while using deploy tokens' do
+ let(:project) { create(:project) }
+ let(:auth_failure) { Gitlab::Auth::Result.new(nil, nil) }
+
+ context 'when the deploy token has read_repository as scope' do
+ let(:deploy_token) { create(:deploy_token, read_registry: false, projects: [project]) }
+ let(:login) { deploy_token.username }
+
+ it 'succeeds when login and token are valid' do
+ auth_success = Gitlab::Auth::Result.new(deploy_token, project, :deploy_token, [:download_code])
+
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: login)
+ expect(gl_auth.find_for_git_client(login, deploy_token.token, project: project, ip: 'ip'))
+ .to eq(auth_success)
+ end
+
+ it 'fails when login is not valid' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'random_login')
+ expect(gl_auth.find_for_git_client('random_login', deploy_token.token, project: project, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails when token is not valid' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
+ expect(gl_auth.find_for_git_client(login, '123123', project: project, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails if token is nil' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
+ expect(gl_auth.find_for_git_client(login, nil, project: project, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails if token is not related to project' do
+ another_deploy_token = create(:deploy_token)
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
+ expect(gl_auth.find_for_git_client(login, another_deploy_token.token, project: project, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails if token has been revoked' do
+ deploy_token.revoke!
+
+ expect(deploy_token.revoked?).to be_truthy
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'deploy-token')
+ expect(gl_auth.find_for_git_client('deploy-token', deploy_token.token, project: project, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+ end
+
+ context 'when the deploy token has read_registry as a scope' do
+ let(:deploy_token) { create(:deploy_token, read_repository: false, projects: [project]) }
+ let(:login) { deploy_token.username }
+
+ context 'when registry enabled' do
+ before do
+ stub_container_registry_config(enabled: true)
+ end
+
+ it 'succeeds when login and token are valid' do
+ auth_success = Gitlab::Auth::Result.new(deploy_token, project, :deploy_token, [:read_container_image])
+
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: login)
+ expect(gl_auth.find_for_git_client(login, deploy_token.token, project: nil, ip: 'ip'))
+ .to eq(auth_success)
+ end
+
+ it 'fails when login is not valid' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'random_login')
+ expect(gl_auth.find_for_git_client('random_login', deploy_token.token, project: project, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails when token is not valid' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
+ expect(gl_auth.find_for_git_client(login, '123123', project: project, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails if token is nil' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
+ expect(gl_auth.find_for_git_client(login, nil, project: nil, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails if token is not related to project' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
+ expect(gl_auth.find_for_git_client(login, 'abcdef', project: nil, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+
+ it 'fails if token has been revoked' do
+ deploy_token.revoke!
+
+ expect(deploy_token.revoked?).to be_truthy
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'deploy-token')
+ expect(gl_auth.find_for_git_client('deploy-token', deploy_token.token, project: nil, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+ end
+
+ context 'when registry disabled' do
+ before do
+ stub_container_registry_config(enabled: false)
+ end
+
+ it 'fails when login and token are valid' do
+ expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login)
+ expect(gl_auth.find_for_git_client(login, deploy_token.token, project: nil, ip: 'ip'))
+ .to eq(auth_failure)
+ end
+ end
+ end
+ end
end
describe 'find_with_user_password' do
@@ -309,19 +425,25 @@ describe Gitlab::Auth do
context "with ldap enabled" do
before do
- allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Auth::LDAP::Config).to receive(:enabled?).and_return(true)
end
it "tries to autheticate with db before ldap" do
- expect(Gitlab::LDAP::Authentication).not_to receive(:login)
+ expect(Gitlab::Auth::LDAP::Authentication).not_to receive(:login)
+
+ expect(gl_auth.find_with_user_password(username, password)).to eq(user)
+ end
+
+ it "does not find user by using ldap as fallback to for authentication" do
+ expect(Gitlab::Auth::LDAP::Authentication).to receive(:login).and_return(nil)
- gl_auth.find_with_user_password(username, password)
+ expect(gl_auth.find_with_user_password('ldap_user', 'password')).to be_nil
end
- it "uses ldap as fallback to for authentication" do
- expect(Gitlab::LDAP::Authentication).to receive(:login)
+ it "find new user by using ldap as fallback to for authentication" do
+ expect(Gitlab::Auth::LDAP::Authentication).to receive(:login).and_return(user)
- gl_auth.find_with_user_password('ldap_user', 'password')
+ expect(gl_auth.find_with_user_password('ldap_user', 'password')).to eq(user)
end
end
@@ -336,7 +458,7 @@ describe Gitlab::Auth do
context "with ldap enabled" do
before do
- allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
+ allow(Gitlab::Auth::LDAP::Config).to receive(:enabled?).and_return(true)
end
it "does not find non-ldap user by valid login/password" do
diff --git a/spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb b/spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb
new file mode 100644
index 00000000000..c43ed72038e
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::AddMergeRequestDiffCommitsCount, :migration, schema: 20180105212544 do
+ let(:projects_table) { table(:projects) }
+ let(:merge_requests_table) { table(:merge_requests) }
+ let(:merge_request_diffs_table) { table(:merge_request_diffs) }
+ let(:merge_request_diff_commits_table) { table(:merge_request_diff_commits) }
+
+ let(:project) { projects_table.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce') }
+ let(:merge_request) do
+ merge_requests_table.create!(target_project_id: project.id,
+ target_branch: 'master',
+ source_project_id: project.id,
+ source_branch: 'mr name',
+ title: 'mr name')
+ end
+
+ def create_diff!(name, commits: 0)
+ mr_diff = merge_request_diffs_table.create!(
+ merge_request_id: merge_request.id)
+
+ commits.times do |i|
+ merge_request_diff_commits_table.create!(
+ merge_request_diff_id: mr_diff.id,
+ relative_order: i, sha: i)
+ end
+
+ mr_diff
+ end
+
+ describe '#perform' do
+ it 'migrates diffs that have no commits' do
+ diff = create_diff!('with_multiple_commits', commits: 0)
+
+ subject.perform(diff.id, diff.id)
+
+ expect(diff.reload.commits_count).to eq(0)
+ end
+
+ it 'skips diffs that have commits_count already set' do
+ timestamp = 2.days.ago
+ diff = merge_request_diffs_table.create!(
+ merge_request_id: merge_request.id,
+ commits_count: 0,
+ updated_at: timestamp)
+
+ subject.perform(diff.id, diff.id)
+
+ expect(diff.reload.updated_at).to be_within(1.second).of(timestamp)
+ end
+
+ it 'migrates multiple diffs to the correct values' do
+ diffs = Array.new(3).map.with_index { |_, i| create_diff!(i, commits: 3) }
+
+ subject.perform(diffs.first.id, diffs.last.id)
+
+ diffs.each do |diff|
+ expect(diff.reload.commits_count).to eq(3)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range_spec.rb b/spec/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range_spec.rb
index 5c471cbdeda..9bae7e53b71 100644
--- a/spec/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range_spec.rb
+++ b/spec/lib/gitlab/background_migration/delete_conflicting_redirect_routes_range_spec.rb
@@ -24,17 +24,12 @@ describe Gitlab::BackgroundMigration::DeleteConflictingRedirectRoutesRange, :mig
redirect_routes.create!(source_id: 1, source_type: 'Namespace', path: 'foo5')
end
- it 'deletes the conflicting redirect_routes in the range' do
+ # No-op. See https://gitlab.com/gitlab-com/infrastructure/issues/3460#note_53223252
+ it 'NO-OP: does not delete any redirect_routes' do
expect(redirect_routes.count).to eq(8)
- expect do
- described_class.new.perform(1, 3)
- end.to change { redirect_routes.where("path like 'foo%'").count }.from(5).to(2)
+ described_class.new.perform(1, 5)
- expect do
- described_class.new.perform(4, 5)
- end.to change { redirect_routes.where("path like 'foo%'").count }.from(2).to(0)
-
- expect(redirect_routes.count).to eq(3)
+ expect(redirect_routes.count).to eq(8)
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
index 84d9e635810..007e93c1db6 100644
--- a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
+++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :truncate, :migration, schema: 20171114162227 do
+describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :migration, schema: 20171114162227 do
let(:merge_request_diffs) { table(:merge_request_diffs) }
let(:merge_requests) { table(:merge_requests) }
@@ -10,6 +10,11 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t
let(:merge_request_diff) { MergeRequest.find(merge_request.id).create_merge_request_diff }
let(:updated_merge_request_diff) { MergeRequestDiff.find(merge_request_diff.id) }
+ before do
+ allow_any_instance_of(MergeRequestDiff)
+ .to receive(:commits_count=).and_return(nil)
+ end
+
def diffs_to_hashes(diffs)
diffs.as_json(only: Gitlab::Git::Diff::SERIALIZE_KEYS).map(&:with_indifferent_access)
end
diff --git a/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb b/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb
new file mode 100644
index 00000000000..5ce84c61042
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_build_stage_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::MigrateBuildStage, :migration, schema: 20180212101928 do
+ let(:projects) { table(:projects) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:stages) { table(:ci_stages) }
+ let(:jobs) { table(:ci_builds) }
+
+ STATUSES = { created: 0, pending: 1, running: 2, success: 3,
+ failed: 4, canceled: 5, skipped: 6, manual: 7 }.freeze
+
+ before do
+ projects.create!(id: 123, name: 'gitlab', path: 'gitlab-ce')
+ 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', status: :success)
+ jobs.create!(id: 2, commit_id: 1, project_id: 123,
+ stage_idx: 2, stage: 'build', status: :success)
+ jobs.create!(id: 3, commit_id: 1, project_id: 123,
+ stage_idx: 1, stage: 'test', status: :failed)
+ jobs.create!(id: 4, commit_id: 1, project_id: 123,
+ stage_idx: 1, stage: 'test', status: :success)
+ jobs.create!(id: 5, commit_id: 1, project_id: 123,
+ stage_idx: 3, stage: 'deploy', status: :pending)
+ jobs.create!(id: 6, commit_id: 1, project_id: 123,
+ stage_idx: 3, stage: nil, status: :pending)
+ end
+
+ it 'correctly migrates builds stages' do
+ expect(stages.count).to be_zero
+
+ described_class.new.perform(1, 6)
+
+ expect(stages.count).to eq 3
+ expect(stages.all.pluck(:name)).to match_array %w[test build deploy]
+ expect(jobs.where(stage_id: nil)).to be_one
+ expect(jobs.find_by(stage_id: nil).id).to eq 6
+ expect(stages.all.pluck(:status)).to match_array [STATUSES[:success],
+ STATUSES[:failed],
+ STATUSES[:pending]]
+ end
+
+ it 'recovers from unique constraint violation only twice' do
+ allow(described_class::Migratable::Stage)
+ .to receive(:find_by).and_return(nil)
+
+ expect(described_class::Migratable::Stage)
+ .to receive(:find_by).exactly(3).times
+
+ expect { described_class.new.perform(1, 6) }
+ .to raise_error ActiveRecord::RecordNotUnique
+ end
+
+ context 'when invalid class can be loaded due to single table inheritance' do
+ let(:commit_status) do
+ jobs.create!(id: 7, commit_id: 1, project_id: 123, stage_idx: 4,
+ stage: 'post-deploy', status: :failed)
+ end
+
+ before do
+ commit_status.update_column(:type, 'SomeClass')
+ end
+
+ it 'does ignore single table inheritance type' do
+ expect { described_class.new.perform(1, 7) }.not_to raise_error
+ expect(jobs.find(7)).to have_attributes(stage_id: (a_value > 0))
+ 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
index 7351d45336a..5432d270555 100644
--- 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
@@ -281,6 +281,17 @@ describe Gitlab::BackgroundMigration::MigrateEventsToPushEventPayloads, :migrati
migration.process_event(event)
end
+
+ it 'handles an error gracefully' do
+ event1 = create_push_event(project, author, { commits: [] })
+
+ expect(migration).to receive(:replicate_event).and_call_original
+ expect(migration).to receive(:create_push_event_payload).and_raise(ActiveRecord::InvalidForeignKey, 'invalid foreign key')
+
+ migration.process_event(event1)
+
+ expect(described_class::EventForMigration.all.count).to eq(0)
+ end
end
describe '#replicate_event' do
@@ -335,9 +346,8 @@ describe Gitlab::BackgroundMigration::MigrateEventsToPushEventPayloads, :migrati
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 { migration.create_push_event_payload(event) }.to raise_error(ActiveRecord::InvalidForeignKey)
- expect(payload).to be_nil
expect(PushEventPayload.count).to eq(0)
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
index 7b5a00c6111..021e1d14b18 100644
--- 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
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::BackgroundMigration::MigrateSystemUploadsToNewFolder do
+describe Gitlab::BackgroundMigration::MigrateSystemUploadsToNewFolder, :delete do
let(:migration) { described_class.new }
before do
@@ -8,7 +8,7 @@ describe Gitlab::BackgroundMigration::MigrateSystemUploadsToNewFolder do
end
describe '#perform' do
- it 'renames the path of system-uploads', :truncate do
+ it 'renames the path of system-uploads' do
upload = create(:upload, model: create(:project), path: 'uploads/system/project/avatar.jpg')
migration.perform('uploads/system/', 'uploads/-/system/')
diff --git a/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb
new file mode 100644
index 00000000000..e99257e3481
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_spec.rb
@@ -0,0 +1,130 @@
+require 'rails_helper'
+
+describe Gitlab::BackgroundMigration::PopulateMergeRequestMetricsWithEventsData, :migration, schema: 20171128214150 do
+ # commits_count attribute is added in a next migration
+ before do
+ allow_any_instance_of(MergeRequestDiff)
+ .to receive(:commits_count=).and_return(nil)
+ end
+
+ describe '#perform' do
+ let(:mr_with_event) { create(:merge_request) }
+ let!(:merged_event) { create(:event, :merged, target: mr_with_event) }
+ let!(:closed_event) { create(:event, :closed, target: mr_with_event) }
+
+ before do
+ # Make sure no metrics are created and kept through after_* callbacks.
+ mr_with_event.metrics.destroy!
+ end
+
+ it 'inserts metrics and updates closed and merged events' do
+ subject.perform(mr_with_event.id, mr_with_event.id)
+
+ mr_with_event.reload
+
+ expect(mr_with_event.metrics).to have_attributes(latest_closed_by_id: closed_event.author_id,
+ merged_by_id: merged_event.author_id)
+ expect(mr_with_event.metrics.latest_closed_at.to_s).to eq(closed_event.updated_at.to_s)
+ end
+ end
+
+ describe '#insert_metrics_for_range' do
+ let!(:mrs_without_metrics) { create_list(:merge_request, 3) }
+ let!(:mrs_with_metrics) { create_list(:merge_request, 2) }
+
+ before do
+ # Make sure no metrics are created and kept through after_* callbacks.
+ mrs_without_metrics.each { |m| m.metrics.destroy! }
+ end
+
+ it 'inserts merge_request_metrics for merge_requests without one' do
+ expect { subject.insert_metrics_for_range(MergeRequest.first.id, MergeRequest.last.id) }
+ .to change(MergeRequest::Metrics, :count).from(2).to(5)
+
+ mrs_without_metrics.each do |mr_without_metrics|
+ expect(mr_without_metrics.reload.metrics).to be_present
+ end
+ end
+
+ it 'does not inserts merge_request_metrics for MRs out of given range' do
+ expect { subject.insert_metrics_for_range(mrs_with_metrics.first.id, mrs_with_metrics.last.id) }
+ .not_to change(MergeRequest::Metrics, :count).from(2)
+ end
+ end
+
+ describe '#update_metrics_with_events_data' do
+ context 'closed events data update' do
+ let(:users) { create_list(:user, 3) }
+ let(:mrs_with_event) { create_list(:merge_request, 3) }
+
+ before do
+ create_list(:event, 2, :closed, author: users.first, target: mrs_with_event.first)
+ create_list(:event, 3, :closed, author: users.second, target: mrs_with_event.second)
+ create(:event, :closed, author: users.third, target: mrs_with_event.third)
+ end
+
+ it 'migrates multiple MR metrics with closed event data' do
+ mr_without_event = create(:merge_request)
+ create(:event, :merged)
+
+ subject.update_metrics_with_events_data(mrs_with_event.first.id, mrs_with_event.last.id)
+
+ mrs_with_event.each do |mr_with_event|
+ latest_event = Event.where(action: 3, target: mr_with_event).last
+
+ mr_with_event.metrics.reload
+
+ expect(mr_with_event.metrics.latest_closed_by).to eq(latest_event.author)
+ expect(mr_with_event.metrics.latest_closed_at.to_s).to eq(latest_event.updated_at.to_s)
+ end
+
+ expect(mr_without_event.metrics.reload).to have_attributes(latest_closed_by_id: nil,
+ latest_closed_at: nil)
+ end
+
+ it 'does not updates metrics out of given range' do
+ out_of_range_mr = create(:merge_request)
+ create(:event, :closed, author: users.last, target: out_of_range_mr)
+
+ expect { subject.perform(mrs_with_event.first.id, mrs_with_event.second.id) }
+ .not_to change { out_of_range_mr.metrics.reload.merged_by }
+ .from(nil)
+ end
+ end
+
+ context 'merged events data update' do
+ let(:users) { create_list(:user, 3) }
+ let(:mrs_with_event) { create_list(:merge_request, 3) }
+
+ before do
+ create_list(:event, 2, :merged, author: users.first, target: mrs_with_event.first)
+ create_list(:event, 3, :merged, author: users.second, target: mrs_with_event.second)
+ create(:event, :merged, author: users.third, target: mrs_with_event.third)
+ end
+
+ it 'migrates multiple MR metrics with merged event data' do
+ mr_without_event = create(:merge_request)
+ create(:event, :merged)
+
+ subject.update_metrics_with_events_data(mrs_with_event.first.id, mrs_with_event.last.id)
+
+ mrs_with_event.each do |mr_with_event|
+ latest_event = Event.where(action: Event::MERGED, target: mr_with_event).last
+
+ expect(mr_with_event.metrics.reload.merged_by).to eq(latest_event.author)
+ end
+
+ expect(mr_without_event.metrics.reload).to have_attributes(merged_by_id: nil)
+ end
+
+ it 'does not updates metrics out of given range' do
+ out_of_range_mr = create(:merge_request)
+ create(:event, :merged, author: users.last, target: out_of_range_mr)
+
+ expect { subject.perform(mrs_with_event.first.id, mrs_with_event.second.id) }
+ .not_to change { out_of_range_mr.metrics.reload.merged_by }
+ .from(nil)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_untracked_uploads_dependencies/untracked_file_spec.rb b/spec/lib/gitlab/background_migration/populate_untracked_uploads_dependencies/untracked_file_spec.rb
new file mode 100644
index 00000000000..c76adcbe2f5
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_untracked_uploads_dependencies/untracked_file_spec.rb
@@ -0,0 +1,262 @@
+require 'spec_helper'
+
+# Rollback DB to 10.5 (later than this was originally written for) because it still needs to work.
+describe Gitlab::BackgroundMigration::PopulateUntrackedUploadsDependencies::UntrackedFile, :migration, schema: 20180208183958 do
+ include MigrationsHelpers::TrackUntrackedUploadsHelpers
+
+ let!(:appearances) { table(:appearances) }
+ let!(:namespaces) { table(:namespaces) }
+ let!(:projects) { table(:projects) }
+ let!(:routes) { table(:routes) }
+ let!(:uploads) { table(:uploads) }
+
+ before(:all) do
+ ensure_temporary_tracking_table_exists
+ end
+
+ describe '#upload_path' do
+ def assert_upload_path(file_path, expected_upload_path)
+ untracked_file = create_untracked_file(file_path)
+
+ expect(untracked_file.upload_path).to eq(expected_upload_path)
+ end
+
+ context 'for an appearance logo file path' do
+ it 'returns the file path relative to the CarrierWave root' do
+ assert_upload_path('/-/system/appearance/logo/1/some_logo.jpg', 'uploads/-/system/appearance/logo/1/some_logo.jpg')
+ end
+ end
+
+ context 'for an appearance header_logo file path' do
+ it 'returns the file path relative to the CarrierWave root' do
+ assert_upload_path('/-/system/appearance/header_logo/1/some_logo.jpg', 'uploads/-/system/appearance/header_logo/1/some_logo.jpg')
+ end
+ end
+
+ context 'for a pre-Markdown Note attachment file path' do
+ it 'returns the file path relative to the CarrierWave root' do
+ assert_upload_path('/-/system/note/attachment/1234/some_attachment.pdf', 'uploads/-/system/note/attachment/1234/some_attachment.pdf')
+ end
+ end
+
+ context 'for a user avatar file path' do
+ it 'returns the file path relative to the CarrierWave root' do
+ assert_upload_path('/-/system/user/avatar/1234/avatar.jpg', 'uploads/-/system/user/avatar/1234/avatar.jpg')
+ end
+ end
+
+ context 'for a group avatar file path' do
+ it 'returns the file path relative to the CarrierWave root' do
+ assert_upload_path('/-/system/group/avatar/1234/avatar.jpg', 'uploads/-/system/group/avatar/1234/avatar.jpg')
+ end
+ end
+
+ context 'for a project avatar file path' do
+ it 'returns the file path relative to the CarrierWave root' do
+ assert_upload_path('/-/system/project/avatar/1234/avatar.jpg', 'uploads/-/system/project/avatar/1234/avatar.jpg')
+ end
+ end
+
+ context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
+ it 'returns the file path relative to the project directory in uploads' do
+ project = create_project
+ random_hex = SecureRandom.hex
+
+ assert_upload_path("/#{get_full_path(project)}/#{random_hex}/Some file.jpg", "#{random_hex}/Some file.jpg")
+ end
+ end
+ end
+
+ describe '#uploader' do
+ def assert_uploader(file_path, expected_uploader)
+ untracked_file = create_untracked_file(file_path)
+
+ expect(untracked_file.uploader).to eq(expected_uploader)
+ end
+
+ context 'for an appearance logo file path' do
+ it 'returns AttachmentUploader as a string' do
+ assert_uploader('/-/system/appearance/logo/1/some_logo.jpg', 'AttachmentUploader')
+ end
+ end
+
+ context 'for an appearance header_logo file path' do
+ it 'returns AttachmentUploader as a string' do
+ assert_uploader('/-/system/appearance/header_logo/1/some_logo.jpg', 'AttachmentUploader')
+ end
+ end
+
+ context 'for a pre-Markdown Note attachment file path' do
+ it 'returns AttachmentUploader as a string' do
+ assert_uploader('/-/system/note/attachment/1234/some_attachment.pdf', 'AttachmentUploader')
+ end
+ end
+
+ context 'for a user avatar file path' do
+ it 'returns AvatarUploader as a string' do
+ assert_uploader('/-/system/user/avatar/1234/avatar.jpg', 'AvatarUploader')
+ end
+ end
+
+ context 'for a group avatar file path' do
+ it 'returns AvatarUploader as a string' do
+ assert_uploader('/-/system/group/avatar/1234/avatar.jpg', 'AvatarUploader')
+ end
+ end
+
+ context 'for a project avatar file path' do
+ it 'returns AvatarUploader as a string' do
+ assert_uploader('/-/system/project/avatar/1234/avatar.jpg', 'AvatarUploader')
+ end
+ end
+
+ context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
+ it 'returns FileUploader as a string' do
+ project = create_project
+
+ assert_uploader("/#{get_full_path(project)}/#{SecureRandom.hex}/Some file.jpg", 'FileUploader')
+ end
+ end
+ end
+
+ describe '#model_type' do
+ def assert_model_type(file_path, expected_model_type)
+ untracked_file = create_untracked_file(file_path)
+
+ expect(untracked_file.model_type).to eq(expected_model_type)
+ end
+
+ context 'for an appearance logo file path' do
+ it 'returns Appearance as a string' do
+ assert_model_type('/-/system/appearance/logo/1/some_logo.jpg', 'Appearance')
+ end
+ end
+
+ context 'for an appearance header_logo file path' do
+ it 'returns Appearance as a string' do
+ assert_model_type('/-/system/appearance/header_logo/1/some_logo.jpg', 'Appearance')
+ end
+ end
+
+ context 'for a pre-Markdown Note attachment file path' do
+ it 'returns Note as a string' do
+ assert_model_type('/-/system/note/attachment/1234/some_attachment.pdf', 'Note')
+ end
+ end
+
+ context 'for a user avatar file path' do
+ it 'returns User as a string' do
+ assert_model_type('/-/system/user/avatar/1234/avatar.jpg', 'User')
+ end
+ end
+
+ context 'for a group avatar file path' do
+ it 'returns Namespace as a string' do
+ assert_model_type('/-/system/group/avatar/1234/avatar.jpg', 'Namespace')
+ end
+ end
+
+ context 'for a project avatar file path' do
+ it 'returns Project as a string' do
+ assert_model_type('/-/system/project/avatar/1234/avatar.jpg', 'Project')
+ end
+ end
+
+ context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
+ it 'returns Project as a string' do
+ project = create_project
+
+ assert_model_type("/#{get_full_path(project)}/#{SecureRandom.hex}/Some file.jpg", 'Project')
+ end
+ end
+ end
+
+ describe '#model_id' do
+ def assert_model_id(file_path, expected_model_id)
+ untracked_file = create_untracked_file(file_path)
+
+ expect(untracked_file.model_id).to eq(expected_model_id)
+ end
+
+ context 'for an appearance logo file path' do
+ it 'returns the ID as a string' do
+ assert_model_id('/-/system/appearance/logo/1/some_logo.jpg', 1)
+ end
+ end
+
+ context 'for an appearance header_logo file path' do
+ it 'returns the ID as a string' do
+ assert_model_id('/-/system/appearance/header_logo/1/some_logo.jpg', 1)
+ end
+ end
+
+ context 'for a pre-Markdown Note attachment file path' do
+ it 'returns the ID as a string' do
+ assert_model_id('/-/system/note/attachment/1234/some_attachment.pdf', 1234)
+ end
+ end
+
+ context 'for a user avatar file path' do
+ it 'returns the ID as a string' do
+ assert_model_id('/-/system/user/avatar/1234/avatar.jpg', 1234)
+ end
+ end
+
+ context 'for a group avatar file path' do
+ it 'returns the ID as a string' do
+ assert_model_id('/-/system/group/avatar/1234/avatar.jpg', 1234)
+ end
+ end
+
+ context 'for a project avatar file path' do
+ it 'returns the ID as a string' do
+ assert_model_id('/-/system/project/avatar/1234/avatar.jpg', 1234)
+ end
+ end
+
+ context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
+ it 'returns the ID as a string' do
+ project = create_project
+
+ assert_model_id("/#{get_full_path(project)}/#{SecureRandom.hex}/Some file.jpg", project.id)
+ end
+ end
+ end
+
+ describe '#file_size' do
+ context 'for an appearance logo file path' do
+ let(:appearance) { create_or_update_appearance(logo: true) }
+ let(:untracked_file) { described_class.create!(path: get_uploads(appearance, 'Appearance').first.path) }
+
+ it 'returns the file size' do
+ expect(untracked_file.file_size).to eq(1062)
+ end
+ end
+
+ context 'for a project avatar file path' do
+ let(:project) { create_project(avatar: true) }
+ let(:untracked_file) { described_class.create!(path: get_uploads(project, 'Project').first.path) }
+
+ it 'returns the file size' do
+ expect(untracked_file.file_size).to eq(1062)
+ end
+ end
+
+ context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
+ let(:project) { create_project }
+ let(:untracked_file) { create_untracked_file("/#{get_full_path(project)}/#{get_uploads(project, 'Project').first.path}") }
+
+ before do
+ add_markdown_attachment(project)
+ end
+
+ it 'returns the file size' do
+ expect(untracked_file.file_size).to eq(1062)
+ end
+ end
+ end
+
+ def create_untracked_file(path_relative_to_upload_dir)
+ described_class.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}#{path_relative_to_upload_dir}")
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_untracked_uploads_spec.rb b/spec/lib/gitlab/background_migration/populate_untracked_uploads_spec.rb
index be45c00dfe6..0d2074eed22 100644
--- a/spec/lib/gitlab/background_migration/populate_untracked_uploads_spec.rb
+++ b/spec/lib/gitlab/background_migration/populate_untracked_uploads_spec.rb
@@ -1,59 +1,60 @@
require 'spec_helper'
-describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq do
- include TrackUntrackedUploadsHelpers
+# Rollback DB to 10.5 (later than this was originally written for) because it still needs to work.
+describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq, :migration, schema: 20180208183958 do
+ include MigrationsHelpers::TrackUntrackedUploadsHelpers
subject { described_class.new }
- let!(:untracked_files_for_uploads) { described_class::UntrackedFile }
- let!(:uploads) { described_class::Upload }
+ let!(:appearances) { table(:appearances) }
+ let!(:namespaces) { table(:namespaces) }
+ let!(:notes) { table(:notes) }
+ let!(:projects) { table(:projects) }
+ let!(:routes) { table(:routes) }
+ let!(:untracked_files_for_uploads) { table(:untracked_files_for_uploads) }
+ let!(:uploads) { table(:uploads) }
+ let!(:users) { table(:users) }
before do
- DatabaseCleaner.clean
- drop_temp_table_if_exists
ensure_temporary_tracking_table_exists
uploads.delete_all
end
- after(:all) do
- drop_temp_table_if_exists
- end
-
context 'with untracked files and tracked files in untracked_files_for_uploads' do
- let!(:appearance) { create_or_update_appearance(logo: uploaded_file, header_logo: uploaded_file) }
- let!(:user1) { create(:user, :with_avatar) }
- let!(:user2) { create(:user, :with_avatar) }
- let!(:project1) { create(:project, :with_avatar) }
- let!(:project2) { create(:project, :with_avatar) }
+ let!(:appearance) { create_or_update_appearance(logo: true, header_logo: true) }
+ let!(:user1) { create_user(avatar: true) }
+ let!(:user2) { create_user(avatar: true) }
+ let!(:project1) { create_project(avatar: true) }
+ let!(:project2) { create_project(avatar: true) }
before do
- UploadService.new(project1, uploaded_file, FileUploader).execute # Markdown upload
- UploadService.new(project2, uploaded_file, FileUploader).execute # Markdown upload
+ add_markdown_attachment(project1)
+ add_markdown_attachment(project2)
# File records created by PrepareUntrackedUploads
- untracked_files_for_uploads.create!(path: appearance.uploads.first.path)
- untracked_files_for_uploads.create!(path: appearance.uploads.last.path)
- untracked_files_for_uploads.create!(path: user1.uploads.first.path)
- untracked_files_for_uploads.create!(path: user2.uploads.first.path)
- untracked_files_for_uploads.create!(path: project1.uploads.first.path)
- untracked_files_for_uploads.create!(path: project2.uploads.first.path)
- untracked_files_for_uploads.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{project1.full_path}/#{project1.uploads.last.path}")
- untracked_files_for_uploads.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{project2.full_path}/#{project2.uploads.last.path}")
+ untracked_files_for_uploads.create!(path: get_uploads(appearance, 'Appearance').first.path)
+ untracked_files_for_uploads.create!(path: get_uploads(appearance, 'Appearance').last.path)
+ untracked_files_for_uploads.create!(path: get_uploads(user1, 'User').first.path)
+ untracked_files_for_uploads.create!(path: get_uploads(user2, 'User').first.path)
+ untracked_files_for_uploads.create!(path: get_uploads(project1, 'Project').first.path)
+ untracked_files_for_uploads.create!(path: get_uploads(project2, 'Project').first.path)
+ untracked_files_for_uploads.create!(path: "#{legacy_project_uploads_dir(project1).sub("#{MigrationsHelpers::TrackUntrackedUploadsHelpers::PUBLIC_DIR}/", '')}/#{get_uploads(project1, 'Project').last.path}")
+ untracked_files_for_uploads.create!(path: "#{legacy_project_uploads_dir(project2).sub("#{MigrationsHelpers::TrackUntrackedUploadsHelpers::PUBLIC_DIR}/", '')}/#{get_uploads(project2, 'Project').last.path}")
# Untrack 4 files
- user2.uploads.delete_all
- project2.uploads.delete_all # 2 files: avatar and a Markdown upload
- appearance.uploads.where("path like '%header_logo%'").delete_all
+ get_uploads(user2, 'User').delete_all
+ get_uploads(project2, 'Project').delete_all # 2 files: avatar and a Markdown upload
+ get_uploads(appearance, 'Appearance').where("path like '%header_logo%'").delete_all
end
it 'adds untracked files to the uploads table' do
expect do
- subject.perform(1, untracked_files_for_uploads.last.id)
+ subject.perform(1, untracked_files_for_uploads.reorder(:id).last.id)
end.to change { uploads.count }.from(4).to(8)
- expect(user2.uploads.count).to eq(1)
- expect(project2.uploads.count).to eq(2)
- expect(appearance.uploads.count).to eq(2)
+ expect(get_uploads(user2, 'User').count).to eq(1)
+ expect(get_uploads(project2, 'Project').count).to eq(2)
+ expect(get_uploads(appearance, 'Appearance').count).to eq(2)
end
it 'deletes rows after processing them' do
@@ -67,9 +68,9 @@ describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq do
it 'does not create duplicate uploads of already tracked files' do
subject.perform(1, untracked_files_for_uploads.last.id)
- expect(user1.uploads.count).to eq(1)
- expect(project1.uploads.count).to eq(2)
- expect(appearance.uploads.count).to eq(2)
+ expect(get_uploads(user1, 'User').count).to eq(1)
+ expect(get_uploads(project1, 'Project').count).to eq(2)
+ expect(get_uploads(appearance, 'Appearance').count).to eq(2)
end
it 'uses the start and end batch ids [only 1st half]' do
@@ -81,11 +82,11 @@ describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq do
subject.perform(start_id, end_id)
end.to change { uploads.count }.from(4).to(6)
- expect(user1.uploads.count).to eq(1)
- expect(user2.uploads.count).to eq(1)
- expect(appearance.uploads.count).to eq(2)
- expect(project1.uploads.count).to eq(2)
- expect(project2.uploads.count).to eq(0)
+ expect(get_uploads(user1, 'User').count).to eq(1)
+ expect(get_uploads(user2, 'User').count).to eq(1)
+ expect(get_uploads(appearance, 'Appearance').count).to eq(2)
+ expect(get_uploads(project1, 'Project').count).to eq(2)
+ expect(get_uploads(project2, 'Project').count).to eq(0)
# Only 4 have been either confirmed or added to uploads
expect(untracked_files_for_uploads.count).to eq(4)
@@ -100,11 +101,11 @@ describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq do
subject.perform(start_id, end_id)
end.to change { uploads.count }.from(4).to(6)
- expect(user1.uploads.count).to eq(1)
- expect(user2.uploads.count).to eq(0)
- expect(appearance.uploads.count).to eq(1)
- expect(project1.uploads.count).to eq(2)
- expect(project2.uploads.count).to eq(2)
+ expect(get_uploads(user1, 'User').count).to eq(1)
+ expect(get_uploads(user2, 'User').count).to eq(0)
+ expect(get_uploads(appearance, 'Appearance').count).to eq(1)
+ expect(get_uploads(project1, 'Project').count).to eq(2)
+ expect(get_uploads(project2, 'Project').count).to eq(2)
# Only 4 have been either confirmed or added to uploads
expect(untracked_files_for_uploads.count).to eq(4)
@@ -117,13 +118,13 @@ describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq do
end
it 'drops the temporary tracking table after processing the batch, if there are no untracked rows left' do
- subject.perform(1, untracked_files_for_uploads.last.id)
+ expect(subject).to receive(:drop_temp_table_if_finished)
- expect(ActiveRecord::Base.connection.table_exists?(:untracked_files_for_uploads)).to be_falsey
+ subject.perform(1, untracked_files_for_uploads.last.id)
end
it 'does not block a whole batch because of one bad path' do
- untracked_files_for_uploads.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{project2.full_path}/._7d37bf4c747916390e596744117d5d1a")
+ untracked_files_for_uploads.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{get_full_path(project2)}/._7d37bf4c747916390e596744117d5d1a")
expect(untracked_files_for_uploads.count).to eq(9)
expect(uploads.count).to eq(4)
@@ -134,7 +135,7 @@ describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq do
end
it 'an unparseable path is shown in error output' do
- bad_path = "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{project2.full_path}/._7d37bf4c747916390e596744117d5d1a"
+ bad_path = "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{get_full_path(project2)}/._7d37bf4c747916390e596744117d5d1a"
untracked_files_for_uploads.create!(path: bad_path)
expect(Rails.logger).to receive(:error).with(/Error parsing path "#{bad_path}":/)
@@ -153,367 +154,100 @@ describe Gitlab::BackgroundMigration::PopulateUntrackedUploads, :sidekiq do
describe 'upload outcomes for each path pattern' do
shared_examples_for 'non_markdown_file' do
- let!(:expected_upload_attrs) { model.uploads.first.attributes.slice('path', 'uploader', 'size', 'checksum') }
+ let!(:expected_upload_attrs) { model_uploads.first.attributes.slice('path', 'uploader', 'size', 'checksum') }
let!(:untracked_file) { untracked_files_for_uploads.create!(path: expected_upload_attrs['path']) }
before do
- model.uploads.delete_all
+ model_uploads.delete_all
end
it 'creates an Upload record' do
expect do
subject.perform(1, untracked_files_for_uploads.last.id)
- end.to change { model.reload.uploads.count }.from(0).to(1)
+ end.to change { model_uploads.count }.from(0).to(1)
- expect(model.uploads.first.attributes).to include(expected_upload_attrs)
+ expect(model_uploads.first.attributes).to include(expected_upload_attrs)
end
end
context 'for an appearance logo file path' do
- let(:model) { create_or_update_appearance(logo: uploaded_file) }
+ let(:model) { create_or_update_appearance(logo: true) }
+ let(:model_uploads) { get_uploads(model, 'Appearance') }
it_behaves_like 'non_markdown_file'
end
context 'for an appearance header_logo file path' do
- let(:model) { create_or_update_appearance(header_logo: uploaded_file) }
+ let(:model) { create_or_update_appearance(header_logo: true) }
+ let(:model_uploads) { get_uploads(model, 'Appearance') }
it_behaves_like 'non_markdown_file'
end
context 'for a pre-Markdown Note attachment file path' do
- let(:model) { create(:note, :with_attachment) }
- let!(:expected_upload_attrs) { Upload.where(model_type: 'Note', model_id: model.id).first.attributes.slice('path', 'uploader', 'size', 'checksum') }
+ let(:model) { create_note(attachment: true) }
+ let!(:expected_upload_attrs) { get_uploads(model, 'Note').first.attributes.slice('path', 'uploader', 'size', 'checksum') }
let!(:untracked_file) { untracked_files_for_uploads.create!(path: expected_upload_attrs['path']) }
before do
- Upload.where(model_type: 'Note', model_id: model.id).delete_all
+ get_uploads(model, 'Note').delete_all
end
# Can't use the shared example because Note doesn't have an `uploads` association
it 'creates an Upload record' do
expect do
subject.perform(1, untracked_files_for_uploads.last.id)
- end.to change { Upload.where(model_type: 'Note', model_id: model.id).count }.from(0).to(1)
+ end.to change { get_uploads(model, 'Note').count }.from(0).to(1)
- expect(Upload.where(model_type: 'Note', model_id: model.id).first.attributes).to include(expected_upload_attrs)
+ expect(get_uploads(model, 'Note').first.attributes).to include(expected_upload_attrs)
end
end
context 'for a user avatar file path' do
- let(:model) { create(:user, :with_avatar) }
+ let(:model) { create_user(avatar: true) }
+ let(:model_uploads) { get_uploads(model, 'User') }
it_behaves_like 'non_markdown_file'
end
context 'for a group avatar file path' do
- let(:model) { create(:group, :with_avatar) }
+ let(:model) { create_group(avatar: true) }
+ let(:model_uploads) { get_uploads(model, 'Namespace') }
it_behaves_like 'non_markdown_file'
end
context 'for a project avatar file path' do
- let(:model) { create(:project, :with_avatar) }
+ let(:model) { create_project(avatar: true) }
+ let(:model_uploads) { get_uploads(model, 'Project') }
it_behaves_like 'non_markdown_file'
end
context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
- let(:model) { create(:project) }
+ let(:model) { create_project }
before do
# Upload the file
- UploadService.new(model, uploaded_file, FileUploader).execute
+ add_markdown_attachment(model)
# Create the untracked_files_for_uploads record
- untracked_files_for_uploads.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{model.full_path}/#{model.uploads.first.path}")
+ untracked_files_for_uploads.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}/#{get_full_path(model)}/#{get_uploads(model, 'Project').first.path}")
# Save the expected upload attributes
- @expected_upload_attrs = model.reload.uploads.first.attributes.slice('path', 'uploader', 'size', 'checksum')
+ @expected_upload_attrs = get_uploads(model, 'Project').first.attributes.slice('path', 'uploader', 'size', 'checksum')
# Untrack the file
- model.reload.uploads.delete_all
+ get_uploads(model, 'Project').delete_all
end
it 'creates an Upload record' do
expect do
subject.perform(1, untracked_files_for_uploads.last.id)
- end.to change { model.reload.uploads.count }.from(0).to(1)
-
- expect(model.uploads.first.attributes).to include(@expected_upload_attrs)
- end
- end
- end
-end
-
-describe Gitlab::BackgroundMigration::PopulateUntrackedUploads::UntrackedFile do
- include TrackUntrackedUploadsHelpers
-
- let(:upload_class) { Gitlab::BackgroundMigration::PopulateUntrackedUploads::Upload }
-
- before(:all) do
- ensure_temporary_tracking_table_exists
- end
-
- after(:all) do
- drop_temp_table_if_exists
- end
-
- describe '#upload_path' do
- def assert_upload_path(file_path, expected_upload_path)
- untracked_file = create_untracked_file(file_path)
-
- expect(untracked_file.upload_path).to eq(expected_upload_path)
- end
-
- context 'for an appearance logo file path' do
- it 'returns the file path relative to the CarrierWave root' do
- assert_upload_path('/-/system/appearance/logo/1/some_logo.jpg', 'uploads/-/system/appearance/logo/1/some_logo.jpg')
- end
- end
-
- context 'for an appearance header_logo file path' do
- it 'returns the file path relative to the CarrierWave root' do
- assert_upload_path('/-/system/appearance/header_logo/1/some_logo.jpg', 'uploads/-/system/appearance/header_logo/1/some_logo.jpg')
- end
- end
-
- context 'for a pre-Markdown Note attachment file path' do
- it 'returns the file path relative to the CarrierWave root' do
- assert_upload_path('/-/system/note/attachment/1234/some_attachment.pdf', 'uploads/-/system/note/attachment/1234/some_attachment.pdf')
- end
- end
-
- context 'for a user avatar file path' do
- it 'returns the file path relative to the CarrierWave root' do
- assert_upload_path('/-/system/user/avatar/1234/avatar.jpg', 'uploads/-/system/user/avatar/1234/avatar.jpg')
- end
- end
-
- context 'for a group avatar file path' do
- it 'returns the file path relative to the CarrierWave root' do
- assert_upload_path('/-/system/group/avatar/1234/avatar.jpg', 'uploads/-/system/group/avatar/1234/avatar.jpg')
- end
- end
-
- context 'for a project avatar file path' do
- it 'returns the file path relative to the CarrierWave root' do
- assert_upload_path('/-/system/project/avatar/1234/avatar.jpg', 'uploads/-/system/project/avatar/1234/avatar.jpg')
- end
- end
-
- context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
- it 'returns the file path relative to the project directory in uploads' do
- project = create(:project)
- random_hex = SecureRandom.hex
-
- assert_upload_path("/#{project.full_path}/#{random_hex}/Some file.jpg", "#{random_hex}/Some file.jpg")
- end
- end
- end
-
- describe '#uploader' do
- def assert_uploader(file_path, expected_uploader)
- untracked_file = create_untracked_file(file_path)
-
- expect(untracked_file.uploader).to eq(expected_uploader)
- end
-
- context 'for an appearance logo file path' do
- it 'returns AttachmentUploader as a string' do
- assert_uploader('/-/system/appearance/logo/1/some_logo.jpg', 'AttachmentUploader')
- end
- end
-
- context 'for an appearance header_logo file path' do
- it 'returns AttachmentUploader as a string' do
- assert_uploader('/-/system/appearance/header_logo/1/some_logo.jpg', 'AttachmentUploader')
- end
- end
-
- context 'for a pre-Markdown Note attachment file path' do
- it 'returns AttachmentUploader as a string' do
- assert_uploader('/-/system/note/attachment/1234/some_attachment.pdf', 'AttachmentUploader')
- end
- end
-
- context 'for a user avatar file path' do
- it 'returns AvatarUploader as a string' do
- assert_uploader('/-/system/user/avatar/1234/avatar.jpg', 'AvatarUploader')
- end
- end
-
- context 'for a group avatar file path' do
- it 'returns AvatarUploader as a string' do
- assert_uploader('/-/system/group/avatar/1234/avatar.jpg', 'AvatarUploader')
- end
- end
-
- context 'for a project avatar file path' do
- it 'returns AvatarUploader as a string' do
- assert_uploader('/-/system/project/avatar/1234/avatar.jpg', 'AvatarUploader')
- end
- end
-
- context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
- it 'returns FileUploader as a string' do
- project = create(:project)
-
- assert_uploader("/#{project.full_path}/#{SecureRandom.hex}/Some file.jpg", 'FileUploader')
- end
- end
- end
-
- describe '#model_type' do
- def assert_model_type(file_path, expected_model_type)
- untracked_file = create_untracked_file(file_path)
-
- expect(untracked_file.model_type).to eq(expected_model_type)
- end
+ end.to change { get_uploads(model, 'Project').count }.from(0).to(1)
- context 'for an appearance logo file path' do
- it 'returns Appearance as a string' do
- assert_model_type('/-/system/appearance/logo/1/some_logo.jpg', 'Appearance')
- end
- end
-
- context 'for an appearance header_logo file path' do
- it 'returns Appearance as a string' do
- assert_model_type('/-/system/appearance/header_logo/1/some_logo.jpg', 'Appearance')
- end
- end
-
- context 'for a pre-Markdown Note attachment file path' do
- it 'returns Note as a string' do
- assert_model_type('/-/system/note/attachment/1234/some_attachment.pdf', 'Note')
- end
- end
-
- context 'for a user avatar file path' do
- it 'returns User as a string' do
- assert_model_type('/-/system/user/avatar/1234/avatar.jpg', 'User')
- end
- end
-
- context 'for a group avatar file path' do
- it 'returns Namespace as a string' do
- assert_model_type('/-/system/group/avatar/1234/avatar.jpg', 'Namespace')
- end
- end
-
- context 'for a project avatar file path' do
- it 'returns Project as a string' do
- assert_model_type('/-/system/project/avatar/1234/avatar.jpg', 'Project')
- end
- end
-
- context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
- it 'returns Project as a string' do
- project = create(:project)
-
- assert_model_type("/#{project.full_path}/#{SecureRandom.hex}/Some file.jpg", 'Project')
- end
- end
- end
-
- describe '#model_id' do
- def assert_model_id(file_path, expected_model_id)
- untracked_file = create_untracked_file(file_path)
-
- expect(untracked_file.model_id).to eq(expected_model_id)
- end
-
- context 'for an appearance logo file path' do
- it 'returns the ID as a string' do
- assert_model_id('/-/system/appearance/logo/1/some_logo.jpg', 1)
- end
- end
-
- context 'for an appearance header_logo file path' do
- it 'returns the ID as a string' do
- assert_model_id('/-/system/appearance/header_logo/1/some_logo.jpg', 1)
- end
- end
-
- context 'for a pre-Markdown Note attachment file path' do
- it 'returns the ID as a string' do
- assert_model_id('/-/system/note/attachment/1234/some_attachment.pdf', 1234)
- end
- end
-
- context 'for a user avatar file path' do
- it 'returns the ID as a string' do
- assert_model_id('/-/system/user/avatar/1234/avatar.jpg', 1234)
- end
- end
-
- context 'for a group avatar file path' do
- it 'returns the ID as a string' do
- assert_model_id('/-/system/group/avatar/1234/avatar.jpg', 1234)
- end
- end
-
- context 'for a project avatar file path' do
- it 'returns the ID as a string' do
- assert_model_id('/-/system/project/avatar/1234/avatar.jpg', 1234)
+ expect(get_uploads(model, 'Project').first.attributes).to include(@expected_upload_attrs)
end
end
-
- context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
- it 'returns the ID as a string' do
- project = create(:project)
-
- assert_model_id("/#{project.full_path}/#{SecureRandom.hex}/Some file.jpg", project.id)
- end
- end
- end
-
- describe '#file_size' do
- context 'for an appearance logo file path' do
- let(:appearance) { create_or_update_appearance(logo: uploaded_file) }
- let(:untracked_file) { described_class.create!(path: appearance.uploads.first.path) }
-
- it 'returns the file size' do
- expect(untracked_file.file_size).to eq(35255)
- end
-
- it 'returns the same thing that CarrierWave would return' do
- expect(untracked_file.file_size).to eq(appearance.logo.size)
- end
- end
-
- context 'for a project avatar file path' do
- let(:project) { create(:project, avatar: uploaded_file) }
- let(:untracked_file) { described_class.create!(path: project.uploads.first.path) }
-
- it 'returns the file size' do
- expect(untracked_file.file_size).to eq(35255)
- end
-
- it 'returns the same thing that CarrierWave would return' do
- expect(untracked_file.file_size).to eq(project.avatar.size)
- end
- end
-
- context 'for a project Markdown attachment (notes, issues, MR descriptions) file path' do
- let(:project) { create(:project) }
- let(:untracked_file) { create_untracked_file("/#{project.full_path}/#{project.uploads.first.path}") }
-
- before do
- UploadService.new(project, uploaded_file, FileUploader).execute
- end
-
- it 'returns the file size' do
- expect(untracked_file.file_size).to eq(35255)
- end
-
- it 'returns the same thing that CarrierWave would return' do
- expect(untracked_file.file_size).to eq(project.uploads.first.size)
- end
- end
- end
-
- def create_untracked_file(path_relative_to_upload_dir)
- described_class.create!(path: "#{Gitlab::BackgroundMigration::PrepareUntrackedUploads::RELATIVE_UPLOAD_DIR}#{path_relative_to_upload_dir}")
end
end
diff --git a/spec/lib/gitlab/background_migration/prepare_untracked_uploads_spec.rb b/spec/lib/gitlab/background_migration/prepare_untracked_uploads_spec.rb
index cd3f1a45270..35750d89c35 100644
--- a/spec/lib/gitlab/background_migration/prepare_untracked_uploads_spec.rb
+++ b/spec/lib/gitlab/background_migration/prepare_untracked_uploads_spec.rb
@@ -1,31 +1,16 @@
require 'spec_helper'
-describe Gitlab::BackgroundMigration::PrepareUntrackedUploads, :sidekiq do
- include TrackUntrackedUploadsHelpers
-
- let!(:untracked_files_for_uploads) { described_class::UntrackedFile }
-
- matcher :be_scheduled_migration do |*expected|
- match do |migration|
- BackgroundMigrationWorker.jobs.any? do |job|
- job['args'] == [migration, expected]
- end
- end
-
- failure_message do |migration|
- "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
- end
- end
-
- before do
- DatabaseCleaner.clean
-
- drop_temp_table_if_exists
- end
-
- after do
- drop_temp_table_if_exists
- end
+# Rollback DB to 10.5 (later than this was originally written for) because it still needs to work.
+describe Gitlab::BackgroundMigration::PrepareUntrackedUploads, :sidekiq, :migration, schema: 20180208183958 do
+ include MigrationsHelpers::TrackUntrackedUploadsHelpers
+
+ let!(:untracked_files_for_uploads) { table(:untracked_files_for_uploads) }
+ let!(:appearances) { table(:appearances) }
+ let!(:namespaces) { table(:namespaces) }
+ let!(:projects) { table(:projects) }
+ let!(:routes) { table(:routes) }
+ let!(:uploads) { table(:uploads) }
+ let!(:users) { table(:users) }
around do |example|
# Especially important so the follow-up migration does not get run
@@ -34,50 +19,34 @@ describe Gitlab::BackgroundMigration::PrepareUntrackedUploads, :sidekiq do
end
end
- it 'ensures the untracked_files_for_uploads table exists' do
- expect do
- described_class.new.perform
- end.to change { ActiveRecord::Base.connection.table_exists?(:untracked_files_for_uploads) }.from(false).to(true)
- end
-
- it 'has a path field long enough for really long paths' do
- described_class.new.perform
-
- component = 'a' * 255
-
- long_path = [
- 'uploads',
- component, # project.full_path
- component # filename
- ].flatten.join('/')
+ shared_examples 'prepares the untracked_files_for_uploads table' do
+ context 'when files were uploaded before and after hashed storage was enabled' do
+ let!(:appearance) { create_or_update_appearance(logo: true, header_logo: true) }
+ let!(:user) { create_user(avatar: true) }
+ let!(:project1) { create_project(avatar: true) }
+ let(:project2) { create_project } # instantiate after enabling hashed_storage
- record = untracked_files_for_uploads.create!(path: long_path)
- expect(record.reload.path.size).to eq(519)
- end
+ before do
+ # Markdown upload before enabling hashed_storage
+ add_markdown_attachment(project1)
- context "test bulk insert with ON CONFLICT DO NOTHING or IGNORE" do
- around do |example|
- # If this is CI, we use Postgres 9.2 so this whole context should be
- # skipped since we're unable to use ON CONFLICT DO NOTHING or IGNORE.
- if described_class.new.send(:can_bulk_insert_and_ignore_duplicates?)
- example.run
+ # Markdown upload after enabling hashed_storage
+ add_markdown_attachment(project2, hashed_storage: true)
end
- end
- context 'when files were uploaded before and after hashed storage was enabled' do
- let!(:appearance) { create_or_update_appearance(logo: uploaded_file, header_logo: uploaded_file) }
- let!(:user) { create(:user, :with_avatar) }
- let!(:project1) { create(:project, :with_avatar) }
- let(:project2) { create(:project) } # instantiate after enabling hashed_storage
+ it 'has a path field long enough for really long paths' do
+ described_class.new.perform
- before do
- # Markdown upload before enabling hashed_storage
- UploadService.new(project1, uploaded_file, FileUploader).execute
+ component = 'a' * 255
- stub_application_setting(hashed_storage_enabled: true)
+ long_path = [
+ 'uploads',
+ component, # project.full_path
+ component # filename
+ ].flatten.join('/')
- # Markdown upload after enabling hashed_storage
- UploadService.new(project2, uploaded_file, FileUploader).execute
+ record = untracked_files_for_uploads.create!(path: long_path)
+ expect(record.reload.path.size).to eq(519)
end
it 'adds unhashed files to the untracked_files_for_uploads table' do
@@ -96,14 +65,15 @@ describe Gitlab::BackgroundMigration::PrepareUntrackedUploads, :sidekiq do
it 'does not add hashed files to the untracked_files_for_uploads table' do
described_class.new.perform
- hashed_file_path = project2.uploads.where(uploader: 'FileUploader').first.path
+ hashed_file_path = get_uploads(project2, 'Project').where(uploader: 'FileUploader').first.path
expect(untracked_files_for_uploads.where("path like '%#{hashed_file_path}%'").exists?).to be_falsey
end
it 'correctly schedules the follow-up background migration jobs' do
described_class.new.perform
- expect(described_class::FOLLOW_UP_MIGRATION).to be_scheduled_migration(1, 5)
+ ids = described_class::UntrackedFile.all.order(:id).pluck(:id)
+ expect(described_class::FOLLOW_UP_MIGRATION).to be_scheduled_migration(ids.first, ids.last)
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
end
@@ -126,6 +96,7 @@ describe Gitlab::BackgroundMigration::PrepareUntrackedUploads, :sidekiq do
let(:tmp_file) { Rails.root.join(described_class::ABSOLUTE_UPLOAD_DIR, 'tmp', 'some_file.jpg') }
before do
+ FileUtils.mkdir(File.dirname(tmp_file))
FileUtils.touch(tmp_file)
end
@@ -139,90 +110,14 @@ describe Gitlab::BackgroundMigration::PrepareUntrackedUploads, :sidekiq do
expect(untracked_files_for_uploads.count).to eq(5)
end
end
- end
- end
-
- context 'test bulk insert without ON CONFLICT DO NOTHING or IGNORE' do
- before do
- # If this is CI, we use Postgres 9.2 so this stub has no effect.
- #
- # If this is being run on Postgres 9.5+ or MySQL, then this stub allows us
- # to test the bulk insert functionality without ON CONFLICT DO NOTHING or
- # IGNORE.
- allow_any_instance_of(described_class).to receive(:postgresql_pre_9_5?).and_return(true)
- end
-
- context 'when files were uploaded before and after hashed storage was enabled' do
- let!(:appearance) { create_or_update_appearance(logo: uploaded_file, header_logo: uploaded_file) }
- let!(:user) { create(:user, :with_avatar) }
- let!(:project1) { create(:project, :with_avatar) }
- let(:project2) { create(:project) } # instantiate after enabling hashed_storage
-
- before do
- # Markdown upload before enabling hashed_storage
- UploadService.new(project1, uploaded_file, FileUploader).execute
-
- stub_application_setting(hashed_storage_enabled: true)
-
- # Markdown upload after enabling hashed_storage
- UploadService.new(project2, uploaded_file, FileUploader).execute
- end
- it 'adds unhashed files to the untracked_files_for_uploads table' do
- described_class.new.perform
+ context 'when the last batch size exactly matches the max batch size' do
+ it 'does not raise error' do
+ stub_const("#{described_class}::FIND_BATCH_SIZE", 5)
- expect(untracked_files_for_uploads.count).to eq(5)
- end
-
- it 'adds files with paths relative to CarrierWave.root' do
- described_class.new.perform
- untracked_files_for_uploads.all.each do |file|
- expect(file.path.start_with?('uploads/')).to be_truthy
- end
- end
-
- it 'does not add hashed files to the untracked_files_for_uploads table' do
- described_class.new.perform
-
- hashed_file_path = project2.uploads.where(uploader: 'FileUploader').first.path
- expect(untracked_files_for_uploads.where("path like '%#{hashed_file_path}%'").exists?).to be_falsey
- end
-
- it 'correctly schedules the follow-up background migration jobs' do
- described_class.new.perform
-
- expect(described_class::FOLLOW_UP_MIGRATION).to be_scheduled_migration(1, 5)
- expect(BackgroundMigrationWorker.jobs.size).to eq(1)
- end
-
- # E.g. from a previous failed run of this background migration
- context 'when there is existing data in untracked_files_for_uploads' do
- before do
- described_class.new.perform
- end
-
- it 'does not error or produce duplicates of existing data' do
expect do
described_class.new.perform
- end.not_to change { untracked_files_for_uploads.count }.from(5)
- end
- end
-
- # E.g. The installation is in use at the time of migration, and someone has
- # just uploaded a file
- context 'when there are files in /uploads/tmp' do
- let(:tmp_file) { Rails.root.join(described_class::ABSOLUTE_UPLOAD_DIR, 'tmp', 'some_file.jpg') }
-
- before do
- FileUtils.touch(tmp_file)
- end
-
- after do
- FileUtils.rm(tmp_file)
- end
-
- it 'does not add files from /uploads/tmp' do
- described_class.new.perform
+ end.not_to raise_error
expect(untracked_files_for_uploads.count).to eq(5)
end
@@ -230,13 +125,33 @@ describe Gitlab::BackgroundMigration::PrepareUntrackedUploads, :sidekiq do
end
end
+ # If running on Postgres 9.2 (like on CI), this whole context is skipped
+ # since we're unable to use ON CONFLICT DO NOTHING or IGNORE.
+ context "test bulk insert with ON CONFLICT DO NOTHING or IGNORE", if: described_class.new.send(:can_bulk_insert_and_ignore_duplicates?) do
+ it_behaves_like 'prepares the untracked_files_for_uploads table'
+ end
+
+ # If running on Postgres 9.2 (like on CI), the stubbed method has no effect.
+ #
+ # If running on Postgres 9.5+ or MySQL, then this context effectively tests
+ # the bulk insert functionality without ON CONFLICT DO NOTHING or IGNORE.
+ context 'test bulk insert without ON CONFLICT DO NOTHING or IGNORE' do
+ before do
+ allow_any_instance_of(described_class).to receive(:postgresql_pre_9_5?).and_return(true)
+ end
+
+ it_behaves_like 'prepares the untracked_files_for_uploads table'
+ end
+
# Very new or lightly-used installations that are running this migration
# may not have an upload directory because they have no uploads.
context 'when no files were ever uploaded' do
- it 'does not add to the untracked_files_for_uploads table (and does not raise error)' do
- described_class.new.perform
+ it 'deletes the `untracked_files_for_uploads` table (and does not raise error)' do
+ background_migration = described_class.new
+
+ expect(background_migration).to receive(:drop_temp_table)
- expect(untracked_files_for_uploads.count).to eq(0)
+ background_migration.perform
end
end
end
diff --git a/spec/lib/gitlab/background_migration/set_confidential_note_events_on_services_spec.rb b/spec/lib/gitlab/background_migration/set_confidential_note_events_on_services_spec.rb
new file mode 100644
index 00000000000..6f3fb994f17
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/set_confidential_note_events_on_services_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::SetConfidentialNoteEventsOnServices, :migration, schema: 20180122154930 do
+ let(:services) { table(:services) }
+
+ describe '#perform' do
+ it 'migrates services where note_events is true' do
+ service = services.create(confidential_note_events: nil, note_events: true)
+
+ subject.perform(service.id, service.id)
+
+ expect(service.reload.confidential_note_events).to eq(true)
+ end
+
+ it 'ignores services where note_events is false' do
+ service = services.create(confidential_note_events: nil, note_events: false)
+
+ subject.perform(service.id, service.id)
+
+ expect(service.reload.confidential_note_events).to eq(nil)
+ end
+
+ it 'ignores services where confidential_note_events has already been set' do
+ service = services.create(confidential_note_events: false, note_events: true)
+
+ subject.perform(service.id, service.id)
+
+ expect(service.reload.confidential_note_events).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/set_confidential_note_events_on_webhooks_spec.rb b/spec/lib/gitlab/background_migration/set_confidential_note_events_on_webhooks_spec.rb
new file mode 100644
index 00000000000..82b484b7d5b
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/set_confidential_note_events_on_webhooks_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe Gitlab::BackgroundMigration::SetConfidentialNoteEventsOnWebhooks, :migration, schema: 20180104131052 do
+ let(:web_hooks) { table(:web_hooks) }
+
+ describe '#perform' do
+ it 'migrates hooks where note_events is true' do
+ hook = web_hooks.create(confidential_note_events: nil, note_events: true)
+
+ subject.perform(hook.id, hook.id)
+
+ expect(hook.reload.confidential_note_events).to eq(true)
+ end
+
+ it 'ignores hooks where note_events is false' do
+ hook = web_hooks.create(confidential_note_events: nil, note_events: false)
+
+ subject.perform(hook.id, hook.id)
+
+ expect(hook.reload.confidential_note_events).to eq(nil)
+ end
+
+ it 'ignores hooks where confidential_note_events has already been set' do
+ hook = web_hooks.create(confidential_note_events: false, note_events: true)
+
+ subject.perform(hook.id, hook.id)
+
+ expect(hook.reload.confidential_note_events).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/badge/coverage/template_spec.rb b/spec/lib/gitlab/badge/coverage/template_spec.rb
index 383bae6e087..d9c21a22590 100644
--- a/spec/lib/gitlab/badge/coverage/template_spec.rb
+++ b/spec/lib/gitlab/badge/coverage/template_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::Badge::Coverage::Template do
- let(:badge) { double(entity: 'coverage', status: 90) }
+ let(:badge) { double(entity: 'coverage', status: 90.00) }
let(:template) { described_class.new(badge) }
describe '#key_text' do
@@ -13,7 +13,17 @@ describe Gitlab::Badge::Coverage::Template do
describe '#value_text' do
context 'when coverage is known' do
it 'returns coverage percentage' do
- expect(template.value_text).to eq '90%'
+ expect(template.value_text).to eq '90.00%'
+ end
+ end
+
+ context 'when coverage is known to many digits' do
+ before do
+ allow(badge).to receive(:status).and_return(92.349)
+ end
+
+ it 'returns rounded coverage percentage' do
+ expect(template.value_text).to eq '92.35%'
end
end
@@ -37,7 +47,7 @@ describe Gitlab::Badge::Coverage::Template do
describe '#value_width' do
context 'when coverage is known' do
it 'is narrower when coverage is known' do
- expect(template.value_width).to eq 36
+ expect(template.value_width).to eq 54
end
end
@@ -113,7 +123,7 @@ describe Gitlab::Badge::Coverage::Template do
describe '#width' do
context 'when coverage is known' do
it 'returns the key width plus value width' do
- expect(template.width).to eq 98
+ expect(template.width).to eq 116
end
end
diff --git a/spec/lib/gitlab/bare_repository_import/importer_spec.rb b/spec/lib/gitlab/bare_repository_import/importer_spec.rb
index 8a83e446935..eb4b9d8b12f 100644
--- a/spec/lib/gitlab/bare_repository_import/importer_spec.rb
+++ b/spec/lib/gitlab/bare_repository_import/importer_spec.rb
@@ -8,11 +8,15 @@ describe Gitlab::BareRepositoryImport::Importer, repository: true do
subject(:importer) { described_class.new(admin, bare_repository) }
before do
+ @rainbow = Rainbow.enabled
+ Rainbow.enabled = false
+
allow(described_class).to receive(:log)
end
after do
FileUtils.rm_rf(base_dir)
+ Rainbow.enabled = @rainbow
end
shared_examples 'importing a repository' do
@@ -68,14 +72,24 @@ describe Gitlab::BareRepositoryImport::Importer, repository: true do
expect(Project.find_by_full_path(project_path)).not_to be_nil
end
- it 'creates the Git repo in disk' do
- FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
+ it 'does not schedule an import' do
+ expect_any_instance_of(Project).not_to receive(:import_schedule)
+
+ importer.create_project_if_needed
+ end
+
+ it 'creates the Git repo on disk with the proper symlink for hooks' do
+ create_bare_repository("#{project_path}.git")
importer.create_project_if_needed
project = Project.find_by_full_path(project_path)
+ repo_path = File.join(project.repository_storage_path, project.disk_path + '.git')
+ hook_path = File.join(repo_path, 'hooks')
- expect(File).to exist(File.join(project.repository_storage_path, project.disk_path + '.git'))
+ expect(File).to exist(repo_path)
+ expect(File.symlink?(hook_path)).to be true
+ expect(File.readlink(hook_path)).to eq(Gitlab.config.gitlab_shell.hooks_path)
end
context 'hashed storage enabled' do
@@ -124,20 +138,21 @@ describe Gitlab::BareRepositoryImport::Importer, repository: true do
end
it 'creates the Git repo in disk' do
- FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
+ create_bare_repository("#{project_path}.git")
importer.create_project_if_needed
project = Project.find_by_full_path("#{admin.full_path}/#{project_path}")
expect(File).to exist(File.join(project.repository_storage_path, project.disk_path + '.git'))
+ expect(File).to exist(File.join(project.repository_storage_path, project.disk_path + '.wiki.git'))
end
it 'moves an existing project to the correct path' do
# This is a quick way to get a valid repository instead of copying an
# existing one. Since it's not persisted, the importer will try to
# create the project.
- project = build(:project, :repository)
+ project = build(:project, :legacy_storage, :repository)
original_commit_count = project.repository.commit_count
bare_repo = Gitlab::BareRepositoryImport::Repository.new(project.repository_storage_path, project.repository.path)
@@ -158,8 +173,11 @@ describe Gitlab::BareRepositoryImport::Importer, repository: true do
it_behaves_like 'importing a repository'
it 'creates the Wiki git repo in disk' do
- FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
- FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.wiki.git"))
+ create_bare_repository("#{project_path}.git")
+ create_bare_repository("#{project_path}.wiki.git")
+
+ expect(Projects::CreateService).to receive(:new).with(admin, hash_including(skip_wiki: true,
+ import_type: 'bare_repository')).and_call_original
importer.create_project_if_needed
@@ -182,4 +200,9 @@ describe Gitlab::BareRepositoryImport::Importer, repository: true do
end
end
end
+
+ def create_bare_repository(project_path)
+ repo_path = File.join(base_dir, project_path)
+ Gitlab::Git::Repository.create(repo_path, bare: true)
+ end
end
diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb
index 61b73abcba4..0dc3705825d 100644
--- a/spec/lib/gitlab/bare_repository_import/repository_spec.rb
+++ b/spec/lib/gitlab/bare_repository_import/repository_spec.rb
@@ -1,58 +1,122 @@
require 'spec_helper'
describe ::Gitlab::BareRepositoryImport::Repository do
- let(:project_repo_path) { described_class.new('/full/path/', '/full/path/to/repo.git') }
+ context 'legacy storage' do
+ subject { described_class.new('/full/path/', '/full/path/to/repo.git') }
- it 'stores the repo path' do
- expect(project_repo_path.repo_path).to eq('/full/path/to/repo.git')
- end
+ it 'stores the repo path' do
+ expect(subject.repo_path).to eq('/full/path/to/repo.git')
+ end
- it 'stores the group path' do
- expect(project_repo_path.group_path).to eq('to')
- end
+ it 'stores the group path' do
+ expect(subject.group_path).to eq('to')
+ end
- it 'stores the project name' do
- expect(project_repo_path.project_name).to eq('repo')
- end
+ it 'stores the project name' do
+ expect(subject.project_name).to eq('repo')
+ end
- it 'stores the wiki path' do
- expect(project_repo_path.wiki_path).to eq('/full/path/to/repo.wiki.git')
- end
+ it 'stores the wiki path' do
+ expect(subject.wiki_path).to eq('/full/path/to/repo.wiki.git')
+ end
+
+ describe '#processable?' do
+ it 'returns false if it is a wiki' do
+ subject = described_class.new('/full/path/', '/full/path/to/a/b/my.wiki.git')
+
+ expect(subject).not_to be_processable
+ end
+
+ it 'returns true if group path is missing' do
+ subject = described_class.new('/full/path/', '/full/path/repo.git')
- describe '#wiki?' do
- it 'returns true if it is a wiki' do
- wiki_path = described_class.new('/full/path/', '/full/path/to/a/b/my.wiki.git')
+ expect(subject).to be_processable
+ end
- expect(wiki_path.wiki?).to eq(true)
+ it 'returns true when group path and project name are present' do
+ expect(subject).to be_processable
+ end
end
- it 'returns false if it is not a wiki' do
- expect(project_repo_path.wiki?).to eq(false)
+ describe '#project_full_path' do
+ it 'returns the project full path with trailing slash in the root path' do
+ expect(subject.project_full_path).to eq('to/repo')
+ end
+
+ it 'returns the project full path with no trailing slash in the root path' do
+ subject = described_class.new('/full/path', '/full/path/to/repo.git')
+
+ expect(subject.project_full_path).to eq('to/repo')
+ end
end
end
- describe '#hashed?' do
- it 'returns true if it is a hashed folder' do
- path = described_class.new('/full/path/', '/full/path/@hashed/my.repo.git')
+ context 'hashed storage' do
+ let(:gitlab_shell) { Gitlab::Shell.new }
+ let(:repository_storage) { 'default' }
+ let(:root_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path }
+ let(:hash) { '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b' }
+ let(:hashed_path) { "@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" }
+ let(:repo_path) { File.join(root_path, "#{hashed_path}.git") }
+ let(:wiki_path) { File.join(root_path, "#{hashed_path}.wiki.git") }
- expect(path.hashed?).to eq(true)
+ before do
+ gitlab_shell.create_repository(repository_storage, hashed_path)
+ repository = Rugged::Repository.new(repo_path)
+ repository.config['gitlab.fullpath'] = 'to/repo'
end
- it 'returns false if it is not a hashed folder' do
- expect(project_repo_path.hashed?).to eq(false)
+ after do
+ gitlab_shell.remove_repository(root_path, hashed_path)
end
- end
- describe '#project_full_path' do
- it 'returns the project full path' do
- expect(project_repo_path.repo_path).to eq('/full/path/to/repo.git')
- expect(project_repo_path.project_full_path).to eq('to/repo')
+ subject { described_class.new(root_path, repo_path) }
+
+ it 'stores the repo path' do
+ expect(subject.repo_path).to eq(repo_path)
+ end
+
+ it 'stores the wiki path' do
+ expect(subject.wiki_path).to eq(wiki_path)
+ end
+
+ it 'reads the group path from .git/config' do
+ expect(subject.group_path).to eq('to')
+ end
+
+ it 'reads the project name from .git/config' do
+ expect(subject.project_name).to eq('repo')
end
- it 'with no trailing slash in the root path' do
- repo_path = described_class.new('/full/path', '/full/path/to/repo.git')
+ describe '#processable?' do
+ it 'returns false if it is a wiki' do
+ subject = described_class.new(root_path, wiki_path)
+
+ expect(subject).not_to be_processable
+ end
+
+ it 'returns false when group and project name are missing' do
+ repository = Rugged::Repository.new(repo_path)
+ repository.config.delete('gitlab.fullpath')
+
+ expect(subject).not_to be_processable
+ end
+
+ it 'returns true when group path and project name are present' do
+ expect(subject).to be_processable
+ end
+ end
+
+ describe '#project_full_path' do
+ it 'returns the project full path with trailing slash in the root path' do
+ expect(subject.project_full_path).to eq('to/repo')
+ end
+
+ it 'returns the project full path with no trailing slash in the root path' do
+ subject = described_class.new(root_path[0...-1], repo_path)
- expect(repo_path.project_full_path).to eq('to/repo')
+ expect(subject.project_full_path).to eq('to/repo')
+ 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 a6a1d9e619f..c63120b0b29 100644
--- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
@@ -137,7 +137,7 @@ describe Gitlab::BitbucketImport::Importer do
it 'imports to the project disk_path' do
expect(project.wiki).to receive(:repository_exists?) { false }
expect(importer.gitlab_shell).to receive(:import_repository).with(
- project.repository_storage_path,
+ project.repository_storage,
project.wiki.disk_path,
project.import_url + '/wiki'
)
diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb
index c2bca816aae..48e9902027c 100644
--- a/spec/lib/gitlab/checks/change_access_spec.rb
+++ b/spec/lib/gitlab/checks/change_access_spec.rb
@@ -30,9 +30,10 @@ describe Gitlab::Checks::ChangeAccess do
end
end
- context 'when the user is not allowed to push code' do
+ context 'when the user is not allowed to push to the repo' do
it 'raises an error' do
expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false)
+ expect(user_access).to receive(:can_push_to_branch?).with('master').and_return(false)
expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.')
end
@@ -177,5 +178,44 @@ describe Gitlab::Checks::ChangeAccess do
expect { subject.exec }.not_to raise_error
end
end
+
+ context 'LFS file lock check' do
+ let(:owner) { create(:user) }
+ let!(:lock) { create(:lfs_file_lock, user: owner, project: project, path: 'README') }
+
+ before do
+ allow(project.repository).to receive(:new_commits).and_return(
+ project.repository.commits_between('be93687618e4b132087f430a4d8fc3a609c9b77c', '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51')
+ )
+ end
+
+ context 'with LFS not enabled' do
+ it 'skips the validation' do
+ expect_any_instance_of(Gitlab::Checks::CommitCheck).not_to receive(:validate)
+
+ subject.exec
+ end
+ end
+
+ context 'with LFS enabled' do
+ before do
+ allow(project).to receive(:lfs_enabled?).and_return(true)
+ end
+
+ context 'when change is sent by a different user' do
+ it 'raises an error if the user is not allowed to update the file' do
+ expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, "The path 'README' is locked in Git LFS by #{lock.user.name}")
+ end
+ end
+
+ context 'when change is sent by the author of the lock' do
+ let(:user) { owner }
+
+ it "doesn't raise any error" do
+ expect { subject.exec }.not_to raise_error
+ 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 633e319f46d..a65012d2314 100644
--- a/spec/lib/gitlab/checks/force_push_spec.rb
+++ b/spec/lib/gitlab/checks/force_push_spec.rb
@@ -2,18 +2,20 @@ require 'spec_helper'
describe Gitlab::Checks::ForcePush do
let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository.raw }
context "exit code checking", :skip_gitaly_mock do
it "does not raise a runtime error if the `popen` call to git returns a zero exit code" do
- allow_any_instance_of(Gitlab::Git::RevList).to receive(:popen).and_return(['normal output', 0])
+ allow(repository).to receive(:popen).and_return(['normal output', 0])
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_any_instance_of(Gitlab::Git::RevList).to receive(:popen).and_return(['error', 1])
+ it "raises a GitError error if the `popen` call to git returns a non-zero exit code" do
+ allow(repository).to receive(:popen).and_return(['error', 1])
- expect { described_class.force_push?(project, 'oldrev', 'newrev') }.to raise_error(RuntimeError)
+ expect { described_class.force_push?(project, 'oldrev', 'newrev') }
+ .to raise_error(Gitlab::Git::Repository::GitError)
end
end
end
diff --git a/spec/lib/gitlab/checks/lfs_integrity_spec.rb b/spec/lib/gitlab/checks/lfs_integrity_spec.rb
index 17756621221..7201e4f7bf6 100644
--- a/spec/lib/gitlab/checks/lfs_integrity_spec.rb
+++ b/spec/lib/gitlab/checks/lfs_integrity_spec.rb
@@ -2,23 +2,25 @@ require 'spec_helper'
describe Gitlab::Checks::LfsIntegrity do
include ProjectForksHelper
+
let(:project) { create(:project, :repository) }
- let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
+ let(:repository) { project.repository }
+ let(:newrev) do
+ operations = BareRepoOperations.new(repository.path)
+
+ # Create a commit not pointed at by any ref to emulate being in the
+ # pre-receive hook so that `--not --all` returns some objects
+ operations.commit_tree('8856a329dd38ca86dfb9ce5aa58a16d88cc119bd', "New LFS objects")
+ end
subject { described_class.new(project, newrev) }
describe '#objects_missing?' do
- let(:blob_object) { project.repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') }
-
- before do
- allow_any_instance_of(Gitlab::Git::RevList).to receive(:new_objects) do |&lazy_block|
- lazy_block.call([blob_object.id])
- end
- end
+ let(:blob_object) { repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') }
context 'with LFS not enabled' do
it 'skips integrity check' do
- expect_any_instance_of(Gitlab::Git::RevList).not_to receive(:new_objects)
+ expect_any_instance_of(Gitlab::Git::LfsChanges).not_to receive(:new_pointers)
subject.objects_missing?
end
@@ -33,7 +35,7 @@ describe Gitlab::Checks::LfsIntegrity do
let(:newrev) { nil }
it 'skips integrity check' do
- expect_any_instance_of(Gitlab::Git::RevList).not_to receive(:new_objects)
+ expect_any_instance_of(Gitlab::Git::LfsChanges).not_to receive(:new_pointers)
expect(subject.objects_missing?).to be_falsey
end
diff --git a/spec/lib/gitlab/checks/project_created_spec.rb b/spec/lib/gitlab/checks/project_created_spec.rb
new file mode 100644
index 00000000000..ac02007e111
--- /dev/null
+++ b/spec/lib/gitlab/checks/project_created_spec.rb
@@ -0,0 +1,46 @@
+require 'rails_helper'
+
+describe Gitlab::Checks::ProjectCreated, :clean_gitlab_redis_shared_state do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+
+ describe '.fetch_message' do
+ context 'with a project created message queue' do
+ let(:project_created) { described_class.new(project, user, 'http') }
+
+ before do
+ project_created.add_message
+ end
+
+ it 'returns project created message' do
+ expect(described_class.fetch_message(user.id, project.id)).to eq(project_created.message)
+ end
+
+ it 'deletes the project created message from redis' do
+ expect(Gitlab::Redis::SharedState.with { |redis| redis.get("project_created:#{user.id}:#{project.id}") }).not_to be_nil
+ described_class.fetch_message(user.id, project.id)
+ expect(Gitlab::Redis::SharedState.with { |redis| redis.get("project_created:#{user.id}:#{project.id}") }).to be_nil
+ end
+ end
+
+ context 'with no project created message queue' do
+ it 'returns nil' do
+ expect(described_class.fetch_message(1, 2)).to be_nil
+ end
+ end
+ end
+
+ describe '#add_message' do
+ it 'queues a project created message' do
+ project_created = described_class.new(project, user, 'http')
+
+ expect(project_created.add_message).to eq('OK')
+ end
+
+ it 'handles anonymous push' do
+ project_created = described_class.new(nil, user, 'http')
+
+ expect(project_created.add_message).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/checks/project_moved_spec.rb b/spec/lib/gitlab/checks/project_moved_spec.rb
index fa1575e2177..8e9386b1ba1 100644
--- a/spec/lib/gitlab/checks/project_moved_spec.rb
+++ b/spec/lib/gitlab/checks/project_moved_spec.rb
@@ -4,78 +4,57 @@ describe Gitlab::Checks::ProjectMoved, :clean_gitlab_redis_shared_state do
let(:user) { create(:user) }
let(:project) { create(:project) }
- describe '.fetch_redirct_message' do
+ describe '.fetch_message' do
context 'with a redirect message queue' do
- it 'should return the redirect message' do
- project_moved = described_class.new(project, user, 'foo/bar', 'http')
- project_moved.add_redirect_message
+ it 'returns the redirect message' do
+ project_moved = described_class.new(project, user, 'http', 'foo/bar')
+ project_moved.add_message
- expect(described_class.fetch_redirect_message(user.id, project.id)).to eq(project_moved.redirect_message)
+ expect(described_class.fetch_message(user.id, project.id)).to eq(project_moved.message)
end
- it 'should delete the redirect message from redis' do
- project_moved = described_class.new(project, user, 'foo/bar', 'http')
- project_moved.add_redirect_message
+ it 'deletes the redirect message from redis' do
+ project_moved = described_class.new(project, user, 'http', 'foo/bar')
+ project_moved.add_message
expect(Gitlab::Redis::SharedState.with { |redis| redis.get("redirect_namespace:#{user.id}:#{project.id}") }).not_to be_nil
- described_class.fetch_redirect_message(user.id, project.id)
+ described_class.fetch_message(user.id, project.id)
expect(Gitlab::Redis::SharedState.with { |redis| redis.get("redirect_namespace:#{user.id}:#{project.id}") }).to be_nil
end
end
context 'with no redirect message queue' do
- it 'should return nil' do
- expect(described_class.fetch_redirect_message(1, 2)).to be_nil
+ it 'returns nil' do
+ expect(described_class.fetch_message(1, 2)).to be_nil
end
end
end
- describe '#add_redirect_message' do
- it 'should queue a redirect message' do
- project_moved = described_class.new(project, user, 'foo/bar', 'http')
- expect(project_moved.add_redirect_message).to eq("OK")
+ describe '#add_message' do
+ it 'queues a redirect message' do
+ project_moved = described_class.new(project, user, 'http', 'foo/bar')
+ expect(project_moved.add_message).to eq("OK")
end
- end
- describe '#redirect_message' do
- context 'when the push is rejected' do
- it 'should return a redirect message telling the user to try again' do
- project_moved = described_class.new(project, user, 'foo/bar', 'http')
- message = "Project 'foo/bar' was moved to '#{project.full_path}'." +
- "\n\nPlease update your Git remote:" +
- "\n\n git remote set-url origin #{project.http_url_to_repo} and try again.\n"
+ it 'handles anonymous clones' do
+ project_moved = described_class.new(project, nil, 'http', 'foo/bar')
- expect(project_moved.redirect_message(rejected: true)).to eq(message)
- end
+ expect(project_moved.add_message).to eq(nil)
end
+ end
- context 'when the push is not rejected' do
- it 'should return a redirect message' do
- project_moved = described_class.new(project, user, 'foo/bar', 'http')
- message = "Project 'foo/bar' was moved to '#{project.full_path}'." +
- "\n\nPlease update your Git remote:" +
- "\n\n git remote set-url origin #{project.http_url_to_repo}\n"
+ describe '#message' do
+ it 'returns a redirect message' do
+ project_moved = described_class.new(project, user, 'http', 'foo/bar')
+ message = <<~MSG
+ Project 'foo/bar' was moved to '#{project.full_path}'.
- expect(project_moved.redirect_message).to eq(message)
- end
- end
- end
+ Please update your Git remote:
- describe '#permanent_redirect?' do
- context 'with a permanent RedirectRoute' do
- it 'should return true' do
- project.route.create_redirect('foo/bar', permanent: true)
- project_moved = described_class.new(project, user, 'foo/bar', 'http')
- expect(project_moved.permanent_redirect?).to be_truthy
- end
- end
+ git remote set-url origin #{project.http_url_to_repo}
+ MSG
- context 'without a permanent RedirectRoute' do
- it 'should return false' do
- project.route.create_redirect('foo/bar')
- project_moved = described_class.new(project, user, 'foo/bar', 'http')
- expect(project_moved.permanent_redirect?).to be_falsy
- end
+ expect(project_moved.message).to eq(message)
end
end
end
diff --git a/spec/lib/gitlab/ci/ansi2html_spec.rb b/spec/lib/gitlab/ci/ansi2html_spec.rb
index 33540eab5d6..7549e9941b6 100644
--- a/spec/lib/gitlab/ci/ansi2html_spec.rb
+++ b/spec/lib/gitlab/ci/ansi2html_spec.rb
@@ -120,6 +120,10 @@ describe Gitlab::Ci::Ansi2html do
expect(convert_html("\e[48;5;240mHello")).to eq('<span class="xterm-bg-240">Hello</span>')
end
+ it "can print 256 xterm fg bold colors" do
+ expect(convert_html("\e[38;5;16;1mHello")).to eq('<span class="xterm-fg-16 term-bold">Hello</span>')
+ end
+
it "can print 256 xterm bg colors on normal magenta foreground" do
expect(convert_html("\e[48;5;16;35mHello")).to eq('<span class="term-fg-magenta xterm-bg-16">Hello</span>')
end
@@ -213,11 +217,58 @@ describe Gitlab::Ci::Ansi2html do
"#{section_end[0...-5]}</div>"
end
- it "prints light red" do
- text = "#{section_start}\e[91mHello\e[0m\n#{section_end}"
- html = %{#{section_start_html}<span class="term-fg-l-red">Hello</span><br>#{section_end_html}}
+ shared_examples 'forbidden char in section_name' do
+ it 'ignores sections' do
+ text = "#{section_start}Some text#{section_end}"
+ html = text.gsub("\033[0K", '').gsub('<', '&lt;')
+
+ expect(convert_html(text)).to eq(html)
+ end
+ end
+
+ shared_examples 'a legit section' do
+ let(:text) { "#{section_start}Some text#{section_end}" }
+
+ it 'prints light red' do
+ text = "#{section_start}\e[91mHello\e[0m\n#{section_end}"
+ html = %{#{section_start_html}<span class="term-fg-l-red">Hello</span><br>#{section_end_html}}
+
+ expect(convert_html(text)).to eq(html)
+ end
+
+ it 'begins with a section_start html marker' do
+ expect(convert_html(text)).to start_with(section_start_html)
+ end
+
+ it 'ends with a section_end html marker' do
+ expect(convert_html(text)).to end_with(section_end_html)
+ end
+ end
+
+ it_behaves_like 'a legit section'
+
+ context 'section name includes $' do
+ let(:section_name) { 'my_$ection'}
+
+ it_behaves_like 'forbidden char in section_name'
+ end
+
+ context 'section name includes <' do
+ let(:section_name) { '<a_tag>'}
+
+ it_behaves_like 'forbidden char in section_name'
+ end
+
+ context 'section name contains .-_' do
+ let(:section_name) { 'a.Legit-SeCtIoN_namE' }
+
+ it_behaves_like 'a legit section'
+ end
+
+ it 'do not allow XSS injections' do
+ text = "#{section_start}section_end:1:2<script>alert('XSS Hack!');</script>#{section_end}"
- expect(convert_html(text)).to eq(html)
+ expect(convert_html(text)).not_to include('<script>')
end
end
diff --git a/spec/lib/gitlab/ci/build/policy/variables_spec.rb b/spec/lib/gitlab/ci/build/policy/variables_spec.rb
new file mode 100644
index 00000000000..2ce858836e3
--- /dev/null
+++ b/spec/lib/gitlab/ci/build/policy/variables_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Build::Policy::Variables do
+ set(:project) { create(:project) }
+
+ let(:pipeline) do
+ build(:ci_empty_pipeline, project: project, ref: 'master', source: :push)
+ end
+
+ let(:ci_build) do
+ build(:ci_build, pipeline: pipeline, project: project, ref: 'master')
+ end
+
+ let(:seed) { double('build seed', to_resource: ci_build) }
+
+ before do
+ pipeline.variables.build(key: 'CI_PROJECT_NAME', value: '')
+ end
+
+ describe '#satisfied_by?' do
+ it 'is satisfied by at least one matching statement' do
+ policy = described_class.new(['$CI_PROJECT_ID', '$UNDEFINED'])
+
+ expect(policy).to be_satisfied_by(pipeline, seed)
+ end
+
+ it 'is not satisfied by an overriden empty variable' do
+ policy = described_class.new(['$CI_PROJECT_NAME'])
+
+ expect(policy).not_to be_satisfied_by(pipeline, seed)
+ end
+
+ it 'is satisfied by a truthy pipeline expression' do
+ policy = described_class.new([%($CI_PIPELINE_SOURCE == "push")])
+
+ expect(policy).to be_satisfied_by(pipeline, seed)
+ end
+
+ it 'is not satisfied by a falsy pipeline expression' do
+ policy = described_class.new([%($CI_PIPELINE_SOURCE == "invalid source")])
+
+ expect(policy).not_to be_satisfied_by(pipeline, seed)
+ end
+
+ it 'is satisfied by a truthy expression using undefined variable' do
+ policy = described_class.new(['$UNDEFINED == null'])
+
+ expect(policy).to be_satisfied_by(pipeline, seed)
+ end
+
+ it 'is not satisfied by a falsy expression using undefined variable' do
+ policy = described_class.new(['$UNDEFINED'])
+
+ expect(policy).not_to be_satisfied_by(pipeline, seed)
+ end
+
+ it 'allows to evaluate regular secret variables' do
+ create(:ci_variable, project: project, key: 'SECRET', value: 'my secret')
+
+ policy = described_class.new(["$SECRET == 'my secret'"])
+
+ expect(policy).to be_satisfied_by(pipeline, seed)
+ end
+
+ it 'does not persist neither pipeline nor build' do
+ described_class.new('$VAR').satisfied_by?(pipeline, seed)
+
+ expect(pipeline).not_to be_persisted
+ expect(seed.to_resource).not_to be_persisted
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/step_spec.rb b/spec/lib/gitlab/ci/build/step_spec.rb
index 5a21282712a..cce4efaa069 100644
--- a/spec/lib/gitlab/ci/build/step_spec.rb
+++ b/spec/lib/gitlab/ci/build/step_spec.rb
@@ -5,10 +5,14 @@ describe Gitlab::Ci::Build::Step do
shared_examples 'has correct script' do
subject { described_class.from_commands(job) }
+ before do
+ job.run!
+ end
+
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.timeout).to eq(job.metadata_timeout)
expect(subject.when).to eq('on_success')
expect(subject.allow_failure).to be_falsey
end
@@ -47,6 +51,10 @@ describe Gitlab::Ci::Build::Step do
subject { described_class.from_after_script(job) }
+ before do
+ job.run!
+ end
+
context 'when after_script is empty' do
it 'doesn not fabricate an object' do
is_expected.to be_nil
@@ -59,7 +67,7 @@ describe Gitlab::Ci::Build::Step do
it 'fabricates an object' do
expect(subject.name).to eq(:after_script)
expect(subject.script).to eq(['ls -la', 'date'])
- expect(subject.timeout).to eq(job.timeout)
+ expect(subject.timeout).to eq(job.metadata_timeout)
expect(subject.when).to eq('always')
expect(subject.allow_failure).to be_truthy
end
diff --git a/spec/lib/gitlab/ci/charts_spec.rb b/spec/lib/gitlab/ci/charts_spec.rb
index f8188675013..1668d3bbaac 100644
--- a/spec/lib/gitlab/ci/charts_spec.rb
+++ b/spec/lib/gitlab/ci/charts_spec.rb
@@ -1,6 +1,51 @@
require 'spec_helper'
describe Gitlab::Ci::Charts do
+ context "yearchart" do
+ let(:project) { create(:project) }
+ let(:chart) { Gitlab::Ci::Charts::YearChart.new(project) }
+
+ subject { chart.to }
+
+ it 'goes until the end of the current month (including the whole last day of the month)' do
+ is_expected.to eq(Date.today.end_of_month.end_of_day)
+ end
+
+ it 'starts at the beginning of the current year' do
+ expect(chart.from).to eq(chart.to.years_ago(1).beginning_of_month.beginning_of_day)
+ end
+ end
+
+ context "monthchart" do
+ let(:project) { create(:project) }
+ let(:chart) { Gitlab::Ci::Charts::MonthChart.new(project) }
+
+ subject { chart.to }
+
+ it 'includes the whole current day' do
+ is_expected.to eq(Date.today.end_of_day)
+ end
+
+ it 'starts one month ago' do
+ expect(chart.from).to eq(1.month.ago.beginning_of_day)
+ end
+ end
+
+ context "weekchart" do
+ let(:project) { create(:project) }
+ let(:chart) { Gitlab::Ci::Charts::WeekChart.new(project) }
+
+ subject { chart.to }
+
+ it 'includes the whole current day' do
+ is_expected.to eq(Date.today.end_of_day)
+ end
+
+ it 'starts one week ago' do
+ expect(chart.from).to eq(1.week.ago.beginning_of_day)
+ end
+ end
+
context "pipeline_times" do
let(:project) { create(:project) }
let(:chart) { Gitlab::Ci::Charts::PipelineTime.new(project) }
diff --git a/spec/lib/gitlab/ci/config/entry/key_spec.rb b/spec/lib/gitlab/ci/config/entry/key_spec.rb
index 5d4de60bc8a..3cbf19bea8b 100644
--- a/spec/lib/gitlab/ci/config/entry/key_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/key_spec.rb
@@ -4,6 +4,26 @@ describe Gitlab::Ci::Config::Entry::Key do
let(:entry) { described_class.new(config) }
describe 'validations' do
+ shared_examples 'key with slash' do
+ it 'is invalid' do
+ expect(entry).not_to be_valid
+ end
+
+ it 'reports errors with config value' do
+ expect(entry.errors).to include 'key config cannot contain the "/" character'
+ end
+ end
+
+ shared_examples 'key with only dots' do
+ it 'is invalid' do
+ expect(entry).not_to be_valid
+ end
+
+ it 'reports errors with config value' do
+ expect(entry.errors).to include 'key config cannot be "." or ".."'
+ end
+ end
+
context 'when entry config value is correct' do
let(:config) { 'test' }
@@ -30,6 +50,48 @@ describe Gitlab::Ci::Config::Entry::Key do
end
end
end
+
+ context 'when entry value contains slash' do
+ let(:config) { 'key/with/some/slashes' }
+
+ it_behaves_like 'key with slash'
+ end
+
+ context 'when entry value contains URI encoded slash (%2F)' do
+ let(:config) { 'key%2Fwith%2Fsome%2Fslashes' }
+
+ it_behaves_like 'key with slash'
+ end
+
+ context 'when entry value is a dot' do
+ let(:config) { '.' }
+
+ it_behaves_like 'key with only dots'
+ end
+
+ context 'when entry value is two dots' do
+ let(:config) { '..' }
+
+ it_behaves_like 'key with only dots'
+ end
+
+ context 'when entry value is a URI encoded dot (%2E)' do
+ let(:config) { '%2e' }
+
+ it_behaves_like 'key with only dots'
+ end
+
+ context 'when entry value is two URI encoded dots (%2E)' do
+ let(:config) { '%2E%2e' }
+
+ it_behaves_like 'key with only dots'
+ end
+
+ context 'when entry value is one dot and one URI encoded dot' do
+ let(:config) { '.%2e' }
+
+ it_behaves_like 'key with only dots'
+ end
end
describe '.default' do
diff --git a/spec/lib/gitlab/ci/config/entry/policy_spec.rb b/spec/lib/gitlab/ci/config/entry/policy_spec.rb
index 5e83abf645b..08718c382b9 100644
--- a/spec/lib/gitlab/ci/config/entry/policy_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/policy_spec.rb
@@ -83,6 +83,39 @@ describe Gitlab::Ci::Config::Entry::Policy do
end
end
+ context 'when specifying valid variables expressions policy' do
+ let(:config) { { variables: ['$VAR == null'] } }
+
+ it 'is a correct configuraton' do
+ expect(entry).to be_valid
+ expect(entry.value).to eq(config)
+ end
+ end
+
+ context 'when specifying variables expressions in invalid format' do
+ let(:config) { { variables: '$MY_VAR' } }
+
+ it 'reports an error about invalid format' do
+ expect(entry.errors).to include /should be an array of strings/
+ end
+ end
+
+ context 'when specifying invalid variables expressions statement' do
+ let(:config) { { variables: ['$MY_VAR =='] } }
+
+ it 'reports an error about invalid statement' do
+ expect(entry.errors).to include /invalid expression syntax/
+ end
+ end
+
+ context 'when specifying invalid variables expressions token' do
+ let(:config) { { variables: ['$MY_VAR == 123'] } }
+
+ it 'reports an error about invalid statement' do
+ expect(entry.errors).to include /invalid expression syntax/
+ end
+ end
+
context 'when specifying unknown policy' do
let(:config) { { refs: ['master'], invalid: :something } }
diff --git a/spec/lib/gitlab/ci/config/loader_spec.rb b/spec/lib/gitlab/ci/config/loader_spec.rb
index 2d44b1f60f1..590fc8904c1 100644
--- a/spec/lib/gitlab/ci/config/loader_spec.rb
+++ b/spec/lib/gitlab/ci/config/loader_spec.rb
@@ -38,6 +38,16 @@ describe Gitlab::Ci::Config::Loader do
end
end
+ context 'when there is an unknown alias' do
+ let(:yml) { 'steps: *bad_alias' }
+
+ describe '#initialize' do
+ it 'raises FormatError' do
+ expect { loader }.to raise_error(Gitlab::Ci::Config::Loader::FormatError, 'Unknown alias: bad_alias')
+ end
+ end
+ end
+
context 'when yaml config is empty' do
let(:yml) { '' }
diff --git a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb
index 1b03227d67b..dc12ba076bc 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb
@@ -5,23 +5,23 @@ describe Gitlab::Ci::Pipeline::Chain::Create do
set(:user) { create(:user) }
let(:pipeline) do
- build(:ci_pipeline_with_one_job, project: project,
- ref: 'master')
+ build(:ci_empty_pipeline, project: project, ref: 'master')
end
let(:command) do
Gitlab::Ci::Pipeline::Chain::Command.new(
- project: project,
- current_user: user, seeds_block: nil)
+ project: project, current_user: user)
end
let(:step) { described_class.new(pipeline, command) }
- before do
- step.perform!
- end
-
context 'when pipeline is ready to be saved' do
+ before do
+ pipeline.stages.build(name: 'test', project: project)
+
+ step.perform!
+ end
+
it 'saves a pipeline' do
expect(pipeline).to be_persisted
end
@@ -32,6 +32,7 @@ describe Gitlab::Ci::Pipeline::Chain::Create do
it 'creates stages' do
expect(pipeline.reload.stages).to be_one
+ expect(pipeline.stages.first).to be_persisted
end
end
@@ -40,6 +41,10 @@ describe Gitlab::Ci::Pipeline::Chain::Create do
build(:ci_pipeline, project: project, ref: nil)
end
+ before do
+ step.perform!
+ end
+
it 'breaks the chain' do
expect(step.break?).to be true
end
@@ -49,18 +54,4 @@ describe Gitlab::Ci::Pipeline::Chain::Create do
.to include /Failed to persist the pipeline/
end
end
-
- context 'when there is a seed block present' do
- let(:seeds) { spy('pipeline seeds') }
-
- let(:command) do
- double('command', project: project,
- current_user: user,
- seeds_block: seeds)
- end
-
- it 'executes the block' do
- expect(seeds).to have_received(:call).with(pipeline)
- end
- end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb
new file mode 100644
index 00000000000..8312fa47cfa
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb
@@ -0,0 +1,158 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Chain::Populate do
+ set(:project) { create(:project) }
+ set(:user) { create(:user) }
+
+ let(:pipeline) do
+ build(:ci_pipeline_with_one_job, project: project,
+ ref: 'master',
+ user: user)
+ end
+
+ let(:command) do
+ Gitlab::Ci::Pipeline::Chain::Command.new(
+ project: project,
+ current_user: user,
+ seeds_block: nil)
+ end
+
+ let(:step) { described_class.new(pipeline, command) }
+
+ context 'when pipeline doesn not have seeds block' do
+ before do
+ step.perform!
+ end
+
+ it 'does not persist the pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+
+ it 'does not break the chain' do
+ expect(step.break?).to be false
+ end
+
+ it 'populates pipeline with stages' do
+ expect(pipeline.stages).to be_one
+ expect(pipeline.stages.first).not_to be_persisted
+ end
+
+ it 'populates pipeline with builds' do
+ expect(pipeline.builds).to be_one
+ expect(pipeline.builds.first).not_to be_persisted
+ expect(pipeline.stages.first.builds).to be_one
+ expect(pipeline.stages.first.builds.first).not_to be_persisted
+ end
+
+ it 'correctly assigns user' do
+ expect(pipeline.builds).to all(have_attributes(user: user))
+ end
+ end
+
+ context 'when pipeline is empty' do
+ let(:config) do
+ { rspec: {
+ script: 'ls',
+ only: ['something']
+ } }
+ end
+
+ let(:pipeline) do
+ build(:ci_pipeline, project: project, config: config)
+ end
+
+ before do
+ step.perform!
+ end
+
+ it 'breaks the chain' do
+ expect(step.break?).to be true
+ end
+
+ it 'appends an error about missing stages' do
+ expect(pipeline.errors.to_a)
+ .to include 'No stages / jobs for this pipeline.'
+ end
+ end
+
+ context 'when pipeline has validation errors' do
+ let(:pipeline) do
+ build(:ci_pipeline, project: project, ref: nil)
+ end
+
+ before do
+ step.perform!
+ end
+
+ it 'breaks the chain' do
+ expect(step.break?).to be true
+ end
+
+ it 'appends validation error' do
+ expect(pipeline.errors.to_a)
+ .to include 'Failed to build the pipeline!'
+ end
+ end
+
+ context 'when there is a seed blocks present' do
+ let(:command) do
+ Gitlab::Ci::Pipeline::Chain::Command.new(
+ project: project,
+ current_user: user,
+ seeds_block: seeds_block)
+ end
+
+ context 'when seeds block builds some resources' do
+ let(:seeds_block) do
+ ->(pipeline) { pipeline.variables.build(key: 'VAR', value: '123') }
+ end
+
+ it 'populates pipeline with resources described in the seeds block' do
+ step.perform!
+
+ expect(pipeline).not_to be_persisted
+ expect(pipeline.variables).not_to be_empty
+ expect(pipeline.variables.first).not_to be_persisted
+ expect(pipeline.variables.first.key).to eq 'VAR'
+ expect(pipeline.variables.first.value).to eq '123'
+ end
+ end
+
+ context 'when seeds block tries to persist some resources' do
+ let(:seeds_block) do
+ ->(pipeline) { pipeline.variables.create!(key: 'VAR', value: '123') }
+ end
+
+ it 'raises exception' do
+ expect { step.perform! }.to raise_error(ActiveRecord::RecordNotSaved)
+ end
+ end
+ end
+
+ context 'when pipeline gets persisted during the process' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ it 'raises error' do
+ expect { step.perform! }.to raise_error(described_class::PopulateError)
+ end
+ end
+
+ context 'when using only/except build policies' do
+ let(:config) do
+ { rspec: { script: 'rspec', stage: 'test', only: ['master'] },
+ prod: { script: 'cap prod', stage: 'deploy', only: ['tags'] } }
+ end
+
+ let(:pipeline) do
+ build(:ci_pipeline, ref: 'master', config: config)
+ end
+
+ it 'populates pipeline according to used policies' do
+ step.perform!
+
+ expect(pipeline.stages.size).to eq 1
+ expect(pipeline.builds.size).to eq 1
+ expect(pipeline.builds.first.name).to eq 'rspec'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb
index 5c12c6e6392..c53294d091c 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb
@@ -76,28 +76,6 @@ describe Gitlab::Ci::Pipeline::Chain::Validate::Config do
end
end
- context 'when pipeline has no stages / jobs' do
- let(:config) do
- { rspec: {
- script: 'ls',
- only: ['something']
- } }
- end
-
- let(:pipeline) do
- build(:ci_pipeline, project: project, config: config)
- end
-
- it 'appends an error about missing stages' do
- expect(pipeline.errors.to_a)
- .to include 'No stages / jobs for this pipeline.'
- end
-
- it 'breaks the chain' do
- expect(step.break?).to be true
- end
- end
-
context 'when pipeline contains configuration validation errors' do
let(:config) { { rspec: {} } }
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/equals_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/equals_spec.rb
new file mode 100644
index 00000000000..019a2ed184d
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/equals_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Lexeme::Equals do
+ let(:left) { double('left') }
+ let(:right) { double('right') }
+
+ describe '.build' do
+ it 'creates a new instance of the token' do
+ expect(described_class.build('==', left, right))
+ .to be_a(described_class)
+ end
+ end
+
+ describe '.type' do
+ it 'is an operator' do
+ expect(described_class.type).to eq :operator
+ end
+ end
+
+ describe '#evaluate' do
+ it 'returns false when left and right are not equal' do
+ allow(left).to receive(:evaluate).and_return(1)
+ allow(right).to receive(:evaluate).and_return(2)
+
+ operator = described_class.new(left, right)
+
+ expect(operator.evaluate(VARIABLE: 3)).to eq false
+ end
+
+ it 'returns true when left and right are equal' do
+ allow(left).to receive(:evaluate).and_return(1)
+ allow(right).to receive(:evaluate).and_return(1)
+
+ operator = described_class.new(left, right)
+
+ expect(operator.evaluate(VARIABLE: 3)).to eq true
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/null_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/null_spec.rb
new file mode 100644
index 00000000000..b5a59929e11
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/null_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Lexeme::Null do
+ describe '.build' do
+ it 'creates a new instance of the token' do
+ expect(described_class.build('null'))
+ .to be_a(described_class)
+ end
+ end
+
+ describe '.type' do
+ it 'is a value lexeme' do
+ expect(described_class.type).to eq :value
+ end
+ end
+
+ describe '#evaluate' do
+ it 'always evaluates to `nil`' do
+ expect(described_class.new('null').evaluate).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb
new file mode 100644
index 00000000000..1ccb792d1da
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/string_spec.rb
@@ -0,0 +1,108 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Lexeme::String do
+ describe '.build' do
+ it 'creates a new instance of the token' do
+ expect(described_class.build('"my string"'))
+ .to be_a(described_class)
+ end
+ end
+
+ describe '.type' do
+ it 'is a value lexeme' do
+ expect(described_class.type).to eq :value
+ end
+ end
+
+ describe '.scan' do
+ context 'when using double quotes' do
+ it 'correctly identifies string token' do
+ scanner = StringScanner.new('"some string"')
+
+ token = described_class.scan(scanner)
+
+ expect(token).not_to be_nil
+ expect(token.build.evaluate).to eq 'some string'
+ end
+ end
+
+ context 'when using single quotes' do
+ it 'correctly identifies string token' do
+ scanner = StringScanner.new("'some string 2'")
+
+ token = described_class.scan(scanner)
+
+ expect(token).not_to be_nil
+ expect(token.build.evaluate).to eq 'some string 2'
+ end
+ end
+
+ context 'when there are mixed quotes in the string' do
+ it 'is a greedy scanner for double quotes' do
+ scanner = StringScanner.new('"some string" "and another one"')
+
+ token = described_class.scan(scanner)
+
+ expect(token).not_to be_nil
+ expect(token.build.evaluate).to eq 'some string'
+ end
+
+ it 'is a greedy scanner for single quotes' do
+ scanner = StringScanner.new("'some string' 'and another one'")
+
+ token = described_class.scan(scanner)
+
+ expect(token).not_to be_nil
+ expect(token.build.evaluate).to eq 'some string'
+ end
+
+ it 'allows to use single quotes inside double quotes' do
+ scanner = StringScanner.new(%("some ' string"))
+
+ token = described_class.scan(scanner)
+
+ expect(token).not_to be_nil
+ expect(token.build.evaluate).to eq "some ' string"
+ end
+
+ it 'allow to use double quotes inside single quotes' do
+ scanner = StringScanner.new(%('some " string'))
+
+ token = described_class.scan(scanner)
+
+ expect(token).not_to be_nil
+ expect(token.build.evaluate).to eq 'some " string'
+ end
+
+ it 'allows to use an empty string inside single quotes' do
+ scanner = StringScanner.new(%(''))
+
+ token = described_class.scan(scanner)
+
+ expect(token.build.evaluate).to eq ''
+ end
+
+ it 'allow to use an empty string inside double quotes' do
+ scanner = StringScanner.new(%(""))
+
+ token = described_class.scan(scanner)
+
+ expect(token.build.evaluate).to eq ''
+ end
+ end
+ end
+
+ describe '#evaluate' do
+ it 'returns string value it is is present' do
+ string = described_class.new('my string')
+
+ expect(string.evaluate).to eq 'my string'
+ end
+
+ it 'returns an empty string if it is empty' do
+ string = described_class.new('')
+
+ expect(string.evaluate).to eq ''
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/variable_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/variable_spec.rb
new file mode 100644
index 00000000000..599a5411881
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/variable_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Lexeme::Variable do
+ describe '.build' do
+ it 'creates a new instance of the token' do
+ expect(described_class.build('$VARIABLE'))
+ .to be_a(described_class)
+ end
+ end
+
+ describe '.type' do
+ it 'is a value lexeme' do
+ expect(described_class.type).to eq :value
+ end
+ end
+
+ describe '#evaluate' do
+ it 'returns variable value if it is defined' do
+ variable = described_class.new('VARIABLE')
+
+ expect(variable.evaluate(VARIABLE: 'my variable'))
+ .to eq 'my variable'
+ end
+
+ it 'allows to use a string as a variable key too' do
+ variable = described_class.new('VARIABLE')
+
+ expect(variable.evaluate('VARIABLE' => 'my variable'))
+ .to eq 'my variable'
+ end
+
+ it 'returns nil if it is not defined' do
+ variable = described_class.new('VARIABLE')
+
+ expect(variable.evaluate(OTHER: 'variable')).to be_nil
+ end
+
+ it 'returns an empty string if it is empty' do
+ variable = described_class.new('VARIABLE')
+
+ expect(variable.evaluate(VARIABLE: '')).to eq ''
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb
new file mode 100644
index 00000000000..230ceeb07f8
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexer_spec.rb
@@ -0,0 +1,70 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Lexer do
+ let(:token_class) do
+ Gitlab::Ci::Pipeline::Expression::Token
+ end
+
+ describe '#tokens' do
+ it 'tokenss single value' do
+ tokens = described_class.new('$VARIABLE').tokens
+
+ expect(tokens).to be_one
+ expect(tokens).to all(be_an_instance_of(token_class))
+ end
+
+ it 'does ignore whitespace characters' do
+ tokens = described_class.new("\t$VARIABLE ").tokens
+
+ expect(tokens).to be_one
+ expect(tokens).to all(be_an_instance_of(token_class))
+ end
+
+ it 'tokenss multiple values of the same token' do
+ tokens = described_class.new("$VARIABLE1 $VARIABLE2").tokens
+
+ expect(tokens.size).to eq 2
+ expect(tokens).to all(be_an_instance_of(token_class))
+ end
+
+ it 'tokenss multiple values with different tokens' do
+ tokens = described_class.new('$VARIABLE "text" "value"').tokens
+
+ expect(tokens.size).to eq 3
+ expect(tokens.first.value).to eq '$VARIABLE'
+ expect(tokens.second.value).to eq '"text"'
+ expect(tokens.third.value).to eq '"value"'
+ end
+
+ it 'tokenss tokens and operators' do
+ tokens = described_class.new('$VARIABLE == "text"').tokens
+
+ expect(tokens.size).to eq 3
+ expect(tokens.first.value).to eq '$VARIABLE'
+ expect(tokens.second.value).to eq '=='
+ expect(tokens.third.value).to eq '"text"'
+ end
+
+ it 'limits statement to specified amount of tokens' do
+ lexer = described_class.new("$V1 $V2 $V3 $V4", max_tokens: 3)
+
+ expect { lexer.tokens }
+ .to raise_error described_class::SyntaxError
+ end
+
+ it 'raises syntax error in case of finding unknown tokens' do
+ lexer = described_class.new('$V1 123 $V2')
+
+ expect { lexer.tokens }
+ .to raise_error described_class::SyntaxError
+ end
+ end
+
+ describe '#lexemes' do
+ it 'returns an array of syntax lexemes' do
+ lexer = described_class.new('$VAR "text"')
+
+ expect(lexer.lexemes).to eq %w[variable string]
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb
new file mode 100644
index 00000000000..e8e6f585310
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/parser_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Parser do
+ describe '#tree' do
+ context 'when using operators' do
+ it 'returns a reverse descent parse tree' do
+ expect(described_class.seed('$VAR1 == "123" == $VAR2').tree)
+ .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Equals
+ end
+ end
+
+ context 'when using a single token' do
+ it 'returns a single token instance' do
+ expect(described_class.seed('$VAR').tree)
+ .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Variable
+ end
+ end
+
+ context 'when expression is empty' do
+ it 'returns a null token' do
+ expect(described_class.seed('').tree)
+ .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Null
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
new file mode 100644
index 00000000000..6685bf5385b
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
@@ -0,0 +1,130 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Statement do
+ subject do
+ described_class.new(text, variables)
+ end
+
+ let(:variables) do
+ { 'PRESENT_VARIABLE' => 'my variable',
+ EMPTY_VARIABLE: '' }
+ end
+
+ describe '.new' do
+ context 'when variables are not provided' do
+ it 'allows to properly initializes the statement' do
+ statement = described_class.new('$PRESENT_VARIABLE')
+
+ expect(statement.evaluate).to be_nil
+ end
+ end
+ end
+
+ describe '#parse_tree' do
+ context 'when expression is empty' do
+ let(:text) { '' }
+
+ it 'raises an error' do
+ expect { subject.parse_tree }
+ .to raise_error described_class::StatementError
+ end
+ end
+
+ context 'when expression grammar is incorrect' do
+ table = [
+ '$VAR "text"', # missing operator
+ '== "123"', # invalid left side
+ '"some string"', # only string provided
+ '$VAR ==', # invalid right side
+ '12345', # unknown syntax
+ '' # empty statement
+ ]
+
+ table.each do |syntax|
+ context "when expression grammar is #{syntax.inspect}" do
+ let(:text) { syntax }
+
+ it 'aises a statement error exception' do
+ expect { subject.parse_tree }
+ .to raise_error described_class::StatementError
+ end
+
+ it 'is an invalid statement' do
+ expect(subject).not_to be_valid
+ end
+ end
+ end
+ end
+
+ context 'when expression grammar is correct' do
+ context 'when using an operator' do
+ let(:text) { '$VAR == "value"' }
+
+ it 'returns a reverse descent parse tree' do
+ expect(subject.parse_tree)
+ .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Equals
+ end
+
+ it 'is a valid statement' do
+ expect(subject).to be_valid
+ end
+ end
+
+ context 'when using a single token' do
+ let(:text) { '$PRESENT_VARIABLE' }
+
+ it 'returns a single token instance' do
+ expect(subject.parse_tree)
+ .to be_a Gitlab::Ci::Pipeline::Expression::Lexeme::Variable
+ end
+ end
+ end
+ end
+
+ describe '#evaluate' do
+ statements = [
+ ['$PRESENT_VARIABLE == "my variable"', true],
+ ["$PRESENT_VARIABLE == 'my variable'", true],
+ ['"my variable" == $PRESENT_VARIABLE', true],
+ ['$PRESENT_VARIABLE == null', false],
+ ['$EMPTY_VARIABLE == null', false],
+ ['"" == $EMPTY_VARIABLE', true],
+ ['$EMPTY_VARIABLE', ''],
+ ['$UNDEFINED_VARIABLE == null', true],
+ ['null == $UNDEFINED_VARIABLE', true],
+ ['$PRESENT_VARIABLE', 'my variable'],
+ ['$UNDEFINED_VARIABLE', nil]
+ ]
+
+ statements.each do |expression, value|
+ context "when using expression `#{expression}`" do
+ let(:text) { expression }
+
+ it "evaluates to `#{value.inspect}`" do
+ expect(subject.evaluate).to eq value
+ end
+ end
+ end
+ end
+
+ describe '#truthful?' do
+ statements = [
+ ['$PRESENT_VARIABLE == "my variable"', true],
+ ["$PRESENT_VARIABLE == 'no match'", false],
+ ['$UNDEFINED_VARIABLE == null', true],
+ ['$PRESENT_VARIABLE', true],
+ ['$UNDEFINED_VARIABLE', false],
+ ['$EMPTY_VARIABLE', false]
+ ]
+
+ statements.each do |expression, value|
+ context "when using expression `#{expression}`" do
+ let(:text) { expression }
+
+ it "returns `#{value.inspect}`" do
+ expect(subject.truthful?).to eq value
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/token_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/token_spec.rb
new file mode 100644
index 00000000000..6d7453f0de5
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/expression/token_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Expression::Token do
+ let(:value) { '$VARIABLE' }
+ let(:lexeme) { Gitlab::Ci::Pipeline::Expression::Lexeme::Variable }
+
+ subject { described_class.new(value, lexeme) }
+
+ describe '#value' do
+ it 'returns raw token value' do
+ expect(subject.value).to eq value
+ end
+ end
+
+ describe '#lexeme' do
+ it 'returns raw token lexeme' do
+ expect(subject.lexeme).to eq lexeme
+ end
+ end
+
+ describe '#build' do
+ it 'delegates to lexeme after adding a value' do
+ expect(lexeme).to receive(:build)
+ .with(value, 'some', 'args')
+
+ subject.build('some', 'args')
+ end
+
+ it 'allows passing only required arguments' do
+ expect(subject.build).to be_an_instance_of(lexeme)
+ end
+ end
+
+ describe '#type' do
+ it 'delegates type query to the lexeme' do
+ expect(subject.type).to eq :value
+ end
+ end
+
+ describe '#to_lexeme' do
+ it 'returns raw lexeme syntax component name' do
+ expect(subject.to_lexeme).to eq 'variable'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
new file mode 100644
index 00000000000..fffa727c2ed
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
@@ -0,0 +1,232 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Seed::Build do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ let(:attributes) do
+ { name: 'rspec',
+ ref: 'master',
+ commands: 'rspec' }
+ end
+
+ subject do
+ described_class.new(pipeline, attributes)
+ end
+
+ describe '#attributes' do
+ it 'returns hash attributes of a build' do
+ expect(subject.attributes).to be_a Hash
+ expect(subject.attributes)
+ .to include(:name, :project, :ref, :commands)
+ end
+ end
+
+ describe '#to_resource' do
+ it 'returns a valid build resource' do
+ expect(subject.to_resource).to be_a(::Ci::Build)
+ expect(subject.to_resource).to be_valid
+ end
+
+ it 'memoizes a resource object' do
+ build = subject.to_resource
+
+ expect(build.object_id).to eq subject.to_resource.object_id
+ end
+
+ it 'can not be persisted without explicit assignment' do
+ build = subject.to_resource
+
+ pipeline.save!
+
+ expect(build).not_to be_persisted
+ end
+ end
+
+ describe 'applying only/except policies' do
+ context 'when no branch policy is specified' do
+ let(:attributes) { { name: 'rspec' } }
+
+ it { is_expected.to be_included }
+ end
+
+ context 'when branch policy does not match' do
+ context 'when using only' do
+ let(:attributes) { { name: 'rspec', only: { refs: ['deploy'] } } }
+
+ it { is_expected.not_to be_included }
+ end
+
+ context 'when using except' do
+ let(:attributes) { { name: 'rspec', except: { refs: ['deploy'] } } }
+
+ it { is_expected.to be_included }
+ end
+ end
+
+ context 'when branch regexp policy does not match' do
+ context 'when using only' do
+ let(:attributes) { { name: 'rspec', only: { refs: ['/^deploy$/'] } } }
+
+ it { is_expected.not_to be_included }
+ end
+
+ context 'when using except' do
+ let(:attributes) { { name: 'rspec', except: { refs: ['/^deploy$/'] } } }
+
+ it { is_expected.to be_included }
+ end
+ end
+
+ context 'when branch policy matches' do
+ context 'when using only' do
+ let(:attributes) { { name: 'rspec', only: { refs: %w[deploy master] } } }
+
+ it { is_expected.to be_included }
+ end
+
+ context 'when using except' do
+ let(:attributes) { { name: 'rspec', except: { refs: %w[deploy master] } } }
+
+ it { is_expected.not_to be_included }
+ end
+ end
+
+ context 'when keyword policy matches' do
+ context 'when using only' do
+ let(:attributes) { { name: 'rspec', only: { refs: ['branches'] } } }
+
+ it { is_expected.to be_included }
+ end
+
+ context 'when using except' do
+ let(:attributes) { { name: 'rspec', except: { refs: ['branches'] } } }
+
+ it { is_expected.not_to be_included }
+ end
+ end
+
+ context 'when keyword policy does not match' do
+ context 'when using only' do
+ let(:attributes) { { name: 'rspec', only: { refs: ['tags'] } } }
+
+ it { is_expected.not_to be_included }
+ end
+
+ context 'when using except' do
+ let(:attributes) { { name: 'rspec', except: { refs: ['tags'] } } }
+
+ it { is_expected.to be_included }
+ end
+ end
+
+ context 'when keywords and pipeline source policy matches' do
+ possibilities = [%w[pushes push],
+ %w[web web],
+ %w[triggers trigger],
+ %w[schedules schedule],
+ %w[api api],
+ %w[external external]]
+
+ context 'when using only' do
+ possibilities.each do |keyword, source|
+ context "when using keyword `#{keyword}` and source `#{source}`" do
+ let(:pipeline) do
+ build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source)
+ end
+
+ let(:attributes) { { name: 'rspec', only: { refs: [keyword] } } }
+
+ it { is_expected.to be_included }
+ end
+ end
+ end
+
+ context 'when using except' do
+ possibilities.each do |keyword, source|
+ context "when using keyword `#{keyword}` and source `#{source}`" do
+ let(:pipeline) do
+ build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source)
+ end
+
+ let(:attributes) { { name: 'rspec', except: { refs: [keyword] } } }
+
+ it { is_expected.not_to be_included }
+ end
+ end
+ end
+ end
+
+ context 'when keywords and pipeline source does not match' do
+ possibilities = [%w[pushes web],
+ %w[web push],
+ %w[triggers schedule],
+ %w[schedules external],
+ %w[api trigger],
+ %w[external api]]
+
+ context 'when using only' do
+ possibilities.each do |keyword, source|
+ context "when using keyword `#{keyword}` and source `#{source}`" do
+ let(:pipeline) do
+ build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source)
+ end
+
+ let(:attributes) { { name: 'rspec', only: { refs: [keyword] } } }
+
+ it { is_expected.not_to be_included }
+ end
+ end
+ end
+
+ context 'when using except' do
+ possibilities.each do |keyword, source|
+ context "when using keyword `#{keyword}` and source `#{source}`" do
+ let(:pipeline) do
+ build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source)
+ end
+
+ let(:attributes) { { name: 'rspec', except: { refs: [keyword] } } }
+
+ it { is_expected.to be_included }
+ end
+ end
+ end
+ end
+
+ context 'when repository path matches' do
+ context 'when using only' do
+ let(:attributes) do
+ { name: 'rspec', only: { refs: ["branches@#{pipeline.project_full_path}"] } }
+ end
+
+ it { is_expected.to be_included }
+ end
+
+ context 'when using except' do
+ let(:attributes) do
+ { name: 'rspec', except: { refs: ["branches@#{pipeline.project_full_path}"] } }
+ end
+
+ it { is_expected.not_to be_included }
+ end
+ end
+
+ context 'when repository path does not matches' do
+ context 'when using only' do
+ let(:attributes) do
+ { name: 'rspec', only: { refs: ['branches@fork'] } }
+ end
+
+ it { is_expected.not_to be_included }
+ end
+
+ context 'when using except' do
+ let(:attributes) do
+ { name: 'rspec', except: { refs: ['branches@fork'] } }
+ end
+
+ it { is_expected.to be_included }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb
new file mode 100644
index 00000000000..eb1b285c7bd
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb
@@ -0,0 +1,123 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Pipeline::Seed::Stage do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ let(:attributes) do
+ { name: 'test',
+ index: 0,
+ builds: [{ name: 'rspec' },
+ { name: 'spinach' },
+ { name: 'deploy', only: { refs: ['feature'] } }] }
+ end
+
+ subject do
+ described_class.new(pipeline, attributes)
+ end
+
+ describe '#size' do
+ it 'returns a number of jobs in the stage' do
+ expect(subject.size).to eq 2
+ end
+ end
+
+ describe '#attributes' do
+ it 'returns hash attributes of a stage' do
+ expect(subject.attributes).to be_a Hash
+ expect(subject.attributes).to include(:name, :project)
+ end
+ end
+
+ describe '#included?' do
+ context 'when it contains builds seeds' do
+ let(:attributes) do
+ { name: 'test',
+ index: 0,
+ builds: [{ name: 'deploy', only: { refs: ['master'] } }] }
+ end
+
+ it { is_expected.to be_included }
+ end
+
+ context 'when it does not contain build seeds' do
+ let(:attributes) do
+ { name: 'test',
+ index: 0,
+ builds: [{ name: 'deploy', only: { refs: ['feature'] } }] }
+ end
+
+ it { is_expected.not_to be_included }
+ end
+ end
+
+ describe '#seeds' do
+ it 'returns build seeds' do
+ expect(subject.seeds).to all(be_a Gitlab::Ci::Pipeline::Seed::Build)
+ end
+
+ it 'returns build seeds including valid attributes' do
+ expect(subject.seeds.size).to eq 2
+ expect(subject.seeds.map(&:attributes)).to all(include(ref: 'master'))
+ expect(subject.seeds.map(&:attributes)).to all(include(tag: false))
+ expect(subject.seeds.map(&:attributes)).to all(include(project: pipeline.project))
+ expect(subject.seeds.map(&:attributes))
+ .to all(include(trigger_request: pipeline.trigger_requests.first))
+ end
+
+ context 'when a ref is protected' do
+ before do
+ allow_any_instance_of(Project).to receive(:protected_for?).and_return(true)
+ end
+
+ it 'returns protected builds' do
+ expect(subject.seeds.map(&:attributes)).to all(include(protected: true))
+ end
+ end
+
+ context 'when a ref is not protected' do
+ before do
+ allow_any_instance_of(Project).to receive(:protected_for?).and_return(false)
+ end
+
+ it 'returns unprotected builds' do
+ expect(subject.seeds.map(&:attributes)).to all(include(protected: false))
+ end
+ end
+
+ it 'filters seeds using only/except policies' do
+ expect(subject.seeds.map(&:attributes)).to satisfy do |seeds|
+ seeds.any? { |hash| hash.fetch(:name) == 'rspec' }
+ end
+
+ expect(subject.seeds.map(&:attributes)).not_to satisfy do |seeds|
+ seeds.any? { |hash| hash.fetch(:name) == 'deploy' }
+ end
+ end
+ end
+
+ describe '#to_resource' do
+ it 'builds a valid stage object with all builds' do
+ subject.to_resource.save!
+
+ 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
+
+ it 'can not be persisted without explicit pipeline assignment' do
+ stage = subject.to_resource
+
+ pipeline.save!
+
+ expect(stage).not_to be_persisted
+ expect(pipeline.reload.stages.count).to eq 0
+ expect(pipeline.reload.builds.count).to eq 0
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/stage/seed_spec.rb b/spec/lib/gitlab/ci/stage/seed_spec.rb
deleted file mode 100644
index 3fe8d50c49a..00000000000
--- a/spec/lib/gitlab/ci/stage/seed_spec.rb
+++ /dev/null
@@ -1,83 +0,0 @@
-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 '#size' do
- it 'returns a number of jobs in the stage' do
- expect(subject.size).to eq 2
- end
- 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
-
- context 'when a ref is protected' do
- before do
- allow_any_instance_of(Project).to receive(:protected_for?).and_return(true)
- end
-
- it 'returns protected builds' do
- expect(subject.builds).to all(include(protected: true))
- end
- end
-
- context 'when a ref is unprotected' do
- before do
- allow_any_instance_of(Project).to receive(:protected_for?).and_return(false)
- end
-
- it 'returns unprotected builds' do
- expect(subject.builds).to all(include(protected: false))
- end
- 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
index 8c25f72804b..bdec582b57b 100644
--- a/spec/lib/gitlab/ci/status/build/action_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/action_spec.rb
@@ -37,20 +37,30 @@ describe Gitlab::Ci::Status::Build::Action do
describe '.matches?' do
subject { described_class.matches?(build, user) }
- context 'when build is an action' do
- let(:build) { create(:ci_build, :manual) }
+ context 'when build is playable action' do
+ let(:build) { create(:ci_build, :playable) }
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) }
+ context 'when build is not playable action' do
+ let(:build) { create(:ci_build, :non_playable) }
it 'does not match' do
expect(subject).to be false
end
end
end
+
+ describe '#badge_tooltip' do
+ let(:user) { create(:user) }
+ let(:build) { create(:ci_build, :non_playable) }
+ let(:status) { Gitlab::Ci::Status::Core.new(build, user) }
+
+ it 'returns the status' do
+ expect(subject.badge_tooltip).to eq('created')
+ 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 9cdebaa5cf2..78d6fa65b5a 100644
--- a/spec/lib/gitlab/ci/status/build/cancelable_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/cancelable_spec.rb
@@ -40,6 +40,24 @@ describe Gitlab::Ci::Status::Build::Cancelable do
end
end
+ describe '#status_tooltip' do
+ it 'does not override status status_tooltip' do
+ expect(status).to receive(:status_tooltip)
+
+ subject.status_tooltip
+ end
+ end
+
+ describe '#badge_tooltip' do
+ let(:user) { create(:user) }
+ let(:build) { create(:ci_build) }
+ let(:status) { Gitlab::Ci::Status::Core.new(build, user) }
+
+ it 'returns the status' do
+ expect(subject.badge_tooltip).to eq('pending')
+ end
+ end
+
describe 'action details' do
let(:user) { create(:user) }
let(:build) { create(:ci_build) }
@@ -72,6 +90,10 @@ describe Gitlab::Ci::Status::Build::Cancelable do
describe '#action_title' do
it { expect(subject.action_title).to eq 'Cancel' }
end
+
+ describe '#action_button_title' do
+ it { expect(subject.action_button_title).to eq 'Cancel this job' }
+ end
end
describe '.matches?' do
diff --git a/spec/lib/gitlab/ci/status/build/canceled_spec.rb b/spec/lib/gitlab/ci/status/build/canceled_spec.rb
new file mode 100644
index 00000000000..c6b5cc68770
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/canceled_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Canceled do
+ let(:user) { create(:user) }
+
+ subject do
+ described_class.new(double('subject'))
+ end
+
+ describe '#illustration' do
+ it { expect(subject.illustration).to include(:image, :size, :title) }
+ end
+
+ describe '.matches?' do
+ subject {described_class.matches?(build, user) }
+
+ context 'when build is canceled' do
+ let(:build) { create(:ci_build, :canceled) }
+
+ it 'is a correct match' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when build is not canceled' 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/common_spec.rb b/spec/lib/gitlab/ci/status/build/common_spec.rb
index 03d1f46b517..2cce7a23ea7 100644
--- a/spec/lib/gitlab/ci/status/build/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/common_spec.rb
@@ -18,7 +18,7 @@ describe Gitlab::Ci::Status::Build::Common do
describe '#has_details?' do
context 'when user has access to read build' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it { is_expected.to have_details }
diff --git a/spec/lib/gitlab/ci/status/build/created_spec.rb b/spec/lib/gitlab/ci/status/build/created_spec.rb
new file mode 100644
index 00000000000..8bdfe6ef7a2
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/created_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Created do
+ let(:user) { create(:user) }
+
+ subject do
+ described_class.new(double('subject'))
+ end
+
+ describe '#illustration' do
+ it { expect(subject.illustration).to include(:image, :size, :title, :content) }
+ end
+
+ describe '.matches?' do
+ subject {described_class.matches?(build, user) }
+
+ context 'when build is created' do
+ let(:build) { create(:ci_build, :created) }
+
+ it 'is a correct match' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when build is not created' 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/erased_spec.rb b/spec/lib/gitlab/ci/status/build/erased_spec.rb
new file mode 100644
index 00000000000..0acd271e375
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/erased_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Erased do
+ let(:user) { create(:user) }
+
+ subject do
+ described_class.new(double('subject'))
+ end
+
+ describe '#illustration' do
+ it { expect(subject.illustration).to include(:image, :size, :title) }
+ end
+
+ describe '.matches?' do
+ subject { described_class.matches?(build, user) }
+
+ context 'when build is erased' do
+ let(:build) { create(:ci_build, :success, :erased) }
+
+ it 'is a correct match' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when build is not erased' do
+ let(:build) { create(:ci_build, :success, :trace_artifact) }
+
+ it 'does not match' do
+ expect(subject).to be false
+ end
+ 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 d196bc6a4c2..6d5b73bb01b 100644
--- a/spec/lib/gitlab/ci/status/build/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb
@@ -13,7 +13,7 @@ describe Gitlab::Ci::Status::Build::Factory do
end
context 'when build is successful' do
- let(:build) { create(:ci_build, :success) }
+ let(:build) { create(:ci_build, :success, :trace_artifact) }
it 'matches correct core status' do
expect(factory.core_status).to be_a Gitlab::Ci::Status::Success
@@ -38,6 +38,33 @@ describe Gitlab::Ci::Status::Build::Factory do
end
end
+ context 'when build is erased' do
+ let(:build) { create(:ci_build, :success, :erased) }
+
+ it 'matches correct core status' do
+ expect(factory.core_status).to be_a Gitlab::Ci::Status::Success
+ end
+
+ it 'matches correct extended statuses' do
+ expect(factory.extended_statuses)
+ .to eq [Gitlab::Ci::Status::Build::Erased,
+ Gitlab::Ci::Status::Build::Retryable]
+ end
+
+ it 'fabricates a retryable build status' do
+ expect(status).to be_a Gitlab::Ci::Status::Build::Retryable
+ end
+
+ it 'fabricates status with correct details' do
+ expect(status.text).to eq 'passed'
+ expect(status.icon).to eq 'status_success'
+ expect(status.favicon).to eq 'favicon_status_success'
+ expect(status.label).to eq 'passed'
+ expect(status).to have_details
+ expect(status).to have_action
+ end
+ end
+
context 'when build is failed' do
context 'when build is not allowed to fail' do
let(:build) { create(:ci_build, :failed) }
@@ -48,11 +75,11 @@ describe Gitlab::Ci::Status::Build::Factory do
it 'matches correct extended statuses' do
expect(factory.extended_statuses)
- .to eq [Gitlab::Ci::Status::Build::Retryable]
+ .to eq [Gitlab::Ci::Status::Build::Retryable, Gitlab::Ci::Status::Build::Failed]
end
- it 'fabricates a retryable build status' do
- expect(status).to be_a Gitlab::Ci::Status::Build::Retryable
+ it 'fabricates a failed build status' do
+ expect(status).to be_a Gitlab::Ci::Status::Build::Failed
end
it 'fabricates status with correct details' do
@@ -60,6 +87,7 @@ describe Gitlab::Ci::Status::Build::Factory do
expect(status.icon).to eq 'status_failed'
expect(status.favicon).to eq 'favicon_status_failed'
expect(status.label).to eq 'failed'
+ expect(status.status_tooltip).to eq 'failed <br> (unknown failure)'
expect(status).to have_details
expect(status).to have_action
end
@@ -75,6 +103,7 @@ describe Gitlab::Ci::Status::Build::Factory do
it 'matches correct extended statuses' do
expect(factory.extended_statuses)
.to eq [Gitlab::Ci::Status::Build::Retryable,
+ Gitlab::Ci::Status::Build::Failed,
Gitlab::Ci::Status::Build::FailedAllowed]
end
@@ -104,7 +133,7 @@ describe Gitlab::Ci::Status::Build::Factory do
it 'matches correct extended statuses' do
expect(factory.extended_statuses)
- .to eq [Gitlab::Ci::Status::Build::Retryable]
+ .to eq [Gitlab::Ci::Status::Build::Canceled, Gitlab::Ci::Status::Build::Retryable]
end
it 'fabricates a retryable build status' do
@@ -115,6 +144,7 @@ describe Gitlab::Ci::Status::Build::Factory do
expect(status.text).to eq 'canceled'
expect(status.icon).to eq 'status_canceled'
expect(status.favicon).to eq 'favicon_status_canceled'
+ expect(status.illustration).to include(:image, :size, :title)
expect(status.label).to eq 'canceled'
expect(status).to have_details
expect(status).to have_action
@@ -156,7 +186,7 @@ describe Gitlab::Ci::Status::Build::Factory do
it 'matches correct extended statuses' do
expect(factory.extended_statuses)
- .to eq [Gitlab::Ci::Status::Build::Cancelable]
+ .to eq [Gitlab::Ci::Status::Build::Pending, Gitlab::Ci::Status::Build::Cancelable]
end
it 'fabricates a cancelable build status' do
@@ -167,6 +197,7 @@ describe Gitlab::Ci::Status::Build::Factory do
expect(status.text).to eq 'pending'
expect(status.icon).to eq 'status_pending'
expect(status.favicon).to eq 'favicon_status_pending'
+ expect(status.illustration).to include(:image, :size, :title, :content)
expect(status.label).to eq 'pending'
expect(status).to have_details
expect(status).to have_action
@@ -180,18 +211,19 @@ describe Gitlab::Ci::Status::Build::Factory do
expect(factory.core_status).to be_a Gitlab::Ci::Status::Skipped
end
- it 'does not match extended statuses' do
- expect(factory.extended_statuses).to be_empty
+ it 'matches correct extended statuses' do
+ expect(factory.extended_statuses).to eq [Gitlab::Ci::Status::Build::Skipped]
end
- it 'fabricates a core skipped status' do
- expect(status).to be_a Gitlab::Ci::Status::Skipped
+ it 'fabricates a skipped build status' do
+ expect(status).to be_a Gitlab::Ci::Status::Build::Skipped
end
it 'fabricates status with correct details' do
expect(status.text).to eq 'skipped'
expect(status.icon).to eq 'status_skipped'
expect(status.favicon).to eq 'favicon_status_skipped'
+ expect(status.illustration).to include(:image, :size, :title)
expect(status.label).to eq 'skipped'
expect(status).to have_details
expect(status).not_to have_action
@@ -208,7 +240,8 @@ 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::Manual,
+ Gitlab::Ci::Status::Build::Play,
Gitlab::Ci::Status::Build::Action]
end
@@ -221,6 +254,7 @@ describe Gitlab::Ci::Status::Build::Factory do
expect(status.group).to eq 'manual'
expect(status.icon).to eq 'status_manual'
expect(status.favicon).to eq 'favicon_status_manual'
+ expect(status.illustration).to include(:image, :size, :title, :content)
expect(status.label).to include 'manual play action'
expect(status).to have_details
expect(status.action_path).to include 'play'
@@ -255,7 +289,8 @@ 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::Manual,
+ Gitlab::Ci::Status::Build::Stop,
Gitlab::Ci::Status::Build::Action]
end
diff --git a/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb b/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb
index 99a5a7e4aca..bfaa508785e 100644
--- a/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/failed_allowed_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
describe Gitlab::Ci::Status::Build::FailedAllowed do
let(:status) { double('core status') }
let(:user) { double('user') }
+ let(:build) { create(:ci_build, :failed, :allowed_to_fail) }
subject do
described_class.new(status)
@@ -68,6 +69,28 @@ describe Gitlab::Ci::Status::Build::FailedAllowed do
end
end
+ describe '#badge_tooltip' do
+ let(:user) { create(:user) }
+ let(:failed_status) { Gitlab::Ci::Status::Failed.new(build, user) }
+ let(:build_status) { Gitlab::Ci::Status::Build::Failed.new(failed_status) }
+ let(:status) { described_class.new(build_status) }
+
+ it 'does override badge_tooltip' do
+ expect(status.badge_tooltip).to eq('failed <br> (unknown failure)')
+ end
+ end
+
+ describe '#status_tooltip' do
+ let(:user) { create(:user) }
+ let(:failed_status) { Gitlab::Ci::Status::Failed.new(build, user) }
+ let(:build_status) { Gitlab::Ci::Status::Build::Failed.new(failed_status) }
+ let(:status) { described_class.new(build_status) }
+
+ it 'does override status_tooltip' do
+ expect(status.status_tooltip).to eq 'failed <br> (unknown failure) (allowed to fail)'
+ end
+ end
+
describe '.matches?' do
subject { described_class.matches?(build, user) }
diff --git a/spec/lib/gitlab/ci/status/build/failed_spec.rb b/spec/lib/gitlab/ci/status/build/failed_spec.rb
new file mode 100644
index 00000000000..cadb424ea2c
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/failed_spec.rb
@@ -0,0 +1,83 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Failed do
+ let(:build) { create(:ci_build, :script_failure) }
+ let(:status) { double('core status') }
+ let(:user) { double('user') }
+
+ subject { described_class.new(status) }
+
+ describe '#text' do
+ it 'does not override status text' do
+ expect(status).to receive(:text)
+
+ subject.text
+ end
+ end
+
+ describe '#icon' do
+ it 'does not override status icon' do
+ expect(status).to receive(:icon)
+
+ subject.icon
+ end
+ end
+
+ describe '#group' do
+ it 'does not override status group' do
+ expect(status).to receive(:group)
+
+ subject.group
+ end
+ end
+
+ describe '#favicon' do
+ it 'does not override status label' do
+ expect(status).to receive(:favicon)
+
+ subject.favicon
+ end
+ end
+
+ describe '#label' do
+ it 'does not override label' do
+ expect(status).to receive(:label)
+
+ subject.label
+ end
+ end
+
+ describe '#badge_tooltip' do
+ let(:user) { create(:user) }
+ let(:status) { Gitlab::Ci::Status::Failed.new(build, user) }
+
+ it 'does override badge_tooltip' do
+ expect(subject.badge_tooltip).to eq 'failed <br> (script failure)'
+ end
+ end
+
+ describe '#status_tooltip' do
+ let(:user) { create(:user) }
+ let(:status) { Gitlab::Ci::Status::Failed.new(build, user) }
+
+ it 'does override status_tooltip' do
+ expect(subject.status_tooltip).to eq 'failed <br> (script failure)'
+ end
+ end
+
+ describe '.matches?' do
+ context 'with a failed build' do
+ it 'returns true' do
+ expect(described_class.matches?(build, user)).to be_truthy
+ end
+ end
+
+ context 'with any other type of build' do
+ let(:build) { create(:ci_build, :success) }
+
+ it 'returns false' do
+ expect(described_class.matches?(build, user)).to be_falsy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/build/manual_spec.rb b/spec/lib/gitlab/ci/status/build/manual_spec.rb
new file mode 100644
index 00000000000..6386296f992
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/manual_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Manual do
+ let(:user) { create(:user) }
+
+ subject do
+ build = create(:ci_build, :manual)
+ described_class.new(Gitlab::Ci::Status::Core.new(build, user))
+ end
+
+ describe '#illustration' do
+ it { expect(subject.illustration).to include(:image, :size, :title, :content) }
+ end
+
+ describe '.matches?' do
+ subject {described_class.matches?(build, user) }
+
+ context 'when build is manual' 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/pending_spec.rb b/spec/lib/gitlab/ci/status/build/pending_spec.rb
new file mode 100644
index 00000000000..4cf70828e53
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/pending_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Pending do
+ let(:user) { create(:user) }
+
+ subject do
+ described_class.new(double('subject'))
+ end
+
+ describe '#illustration' do
+ it { expect(subject.illustration).to include(:image, :size, :title, :content) }
+ end
+
+ describe '.matches?' do
+ subject {described_class.matches?(build, user) }
+
+ context 'when build is pending' do
+ let(:build) { create(:ci_build, :pending) }
+
+ it 'is a correct match' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when build is not pending' do
+ let(:build) { create(:ci_build, :success) }
+
+ it 'does not match' do
+ expect(subject).to be false
+ 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 81d5f553fd1..f128c1d4ca4 100644
--- a/spec/lib/gitlab/ci/status/build/play_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/play_spec.rb
@@ -14,6 +14,22 @@ describe Gitlab::Ci::Status::Build::Play do
end
end
+ describe '#status_tooltip' do
+ it 'does not override status status_tooltip' do
+ expect(status).to receive(:status_tooltip)
+
+ subject.status_tooltip
+ end
+ end
+
+ describe '#badge_tooltip' do
+ it 'does not override status badge_tooltip' do
+ expect(status).to receive(:badge_tooltip)
+
+ subject.badge_tooltip
+ end
+ end
+
describe '#has_action?' do
context 'when user is allowed to update build' do
context 'when user is allowed to trigger protected action' do
@@ -53,6 +69,10 @@ describe Gitlab::Ci::Status::Build::Play do
it { expect(subject.action_title).to eq 'Play' }
end
+ describe '#action_button_title' do
+ it { expect(subject.action_button_title).to eq 'Trigger this manual action' }
+ end
+
describe '.matches?' do
subject { described_class.matches?(build, user) }
diff --git a/spec/lib/gitlab/ci/status/build/retried_spec.rb b/spec/lib/gitlab/ci/status/build/retried_spec.rb
new file mode 100644
index 00000000000..ee9acaf1c21
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/retried_spec.rb
@@ -0,0 +1,96 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Retried do
+ let(:build) { create(:ci_build, :retried) }
+ let(:status) { double('core status') }
+ let(:user) { double('user') }
+
+ subject { described_class.new(status) }
+
+ describe '#text' do
+ it 'does not override status text' do
+ expect(status).to receive(:text)
+
+ subject.text
+ end
+ end
+
+ describe '#icon' do
+ it 'does not override status icon' do
+ expect(status).to receive(:icon)
+
+ subject.icon
+ end
+ end
+
+ describe '#group' do
+ it 'does not override status group' do
+ expect(status).to receive(:group)
+
+ subject.group
+ end
+ end
+
+ describe '#favicon' do
+ it 'does not override status label' do
+ expect(status).to receive(:favicon)
+
+ subject.favicon
+ end
+ end
+
+ describe '#label' do
+ it 'does not override status label' do
+ expect(status).to receive(:label)
+
+ subject.label
+ end
+ end
+
+ describe '#badge_tooltip' do
+ let(:user) { create(:user) }
+ let(:build) { create(:ci_build, :retried) }
+ let(:status) { Gitlab::Ci::Status::Success.new(build, user) }
+
+ it 'returns status' do
+ expect(status.badge_tooltip).to eq('pending')
+ end
+ end
+
+ describe '#status_tooltip' do
+ let(:user) { create(:user) }
+
+ context 'with a failed build' do
+ let(:build) { create(:ci_build, :failed, :retried) }
+ let(:failed_status) { Gitlab::Ci::Status::Failed.new(build, user) }
+ let(:status) { Gitlab::Ci::Status::Build::Failed.new(failed_status) }
+
+ it 'does override status_tooltip' do
+ expect(subject.status_tooltip).to eq 'failed <br> (unknown failure) (retried)'
+ end
+ end
+
+ context 'with another build' do
+ let(:build) { create(:ci_build, :retried) }
+ let(:status) { Gitlab::Ci::Status::Success.new(build, user) }
+
+ it 'does override status_tooltip' do
+ expect(subject.status_tooltip).to eq 'passed (retried)'
+ end
+ end
+ end
+
+ describe '.matches?' do
+ subject { described_class.matches?(build, user) }
+
+ context 'with a retried build' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'with a build that has not been retried' do
+ let(:build) { create(:ci_build, :success) }
+
+ it { is_expected.to be_falsy }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/build/retryable_spec.rb b/spec/lib/gitlab/ci/status/build/retryable_spec.rb
index 14d42e0d70f..84d98588f2d 100644
--- a/spec/lib/gitlab/ci/status/build/retryable_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/retryable_spec.rb
@@ -40,6 +40,24 @@ describe Gitlab::Ci::Status::Build::Retryable do
end
end
+ describe '#status_tooltip' do
+ it 'does not override status status_tooltip' do
+ expect(status).to receive(:status_tooltip)
+
+ subject.status_tooltip
+ end
+ end
+
+ describe '#badge_tooltip' do
+ let(:user) { create(:user) }
+ let(:build) { create(:ci_build) }
+ let(:status) { Gitlab::Ci::Status::Core.new(build, user) }
+
+ it 'does return status' do
+ expect(status.badge_tooltip).to eq('pending')
+ end
+ end
+
describe 'action details' do
let(:user) { create(:user) }
let(:build) { create(:ci_build) }
@@ -72,6 +90,10 @@ describe Gitlab::Ci::Status::Build::Retryable do
describe '#action_title' do
it { expect(subject.action_title).to eq 'Retry' }
end
+
+ describe '#action_button_title' do
+ it { expect(subject.action_button_title).to eq 'Retry this job' }
+ end
end
describe '.matches?' do
diff --git a/spec/lib/gitlab/ci/status/build/skipped_spec.rb b/spec/lib/gitlab/ci/status/build/skipped_spec.rb
new file mode 100644
index 00000000000..46f6933025a
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/build/skipped_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Build::Skipped do
+ let(:user) { create(:user) }
+
+ subject do
+ described_class.new(double('subject'))
+ end
+
+ describe '#illustration' do
+ it { expect(subject.illustration).to include(:image, :size, :title) }
+ end
+
+ describe '.matches?' do
+ subject {described_class.matches?(build, user) }
+
+ context 'when build is skipped' do
+ let(:build) { create(:ci_build, :skipped) }
+
+ it 'is a correct match' do
+ expect(subject).to be true
+ end
+ end
+
+ context 'when build is not skipped' 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/stop_spec.rb b/spec/lib/gitlab/ci/status/build/stop_spec.rb
index 18e250772f0..5b7534c96c1 100644
--- a/spec/lib/gitlab/ci/status/build/stop_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/stop_spec.rb
@@ -44,6 +44,10 @@ describe Gitlab::Ci::Status::Build::Stop do
describe '#action_title' do
it { expect(subject.action_title).to eq 'Stop' }
end
+
+ describe '#action_button_title' do
+ it { expect(subject.action_button_title).to eq 'Stop this environment' }
+ end
end
describe '.matches?' do
@@ -77,4 +81,24 @@ describe Gitlab::Ci::Status::Build::Stop do
end
end
end
+
+ describe '#status_tooltip' do
+ it 'does not override status status_tooltip' do
+ expect(status).to receive(:status_tooltip)
+
+ subject.status_tooltip
+ end
+ end
+
+ describe '#badge_tooltip' do
+ let(:user) { create(:user) }
+ let(:build) { create(:ci_build, :playable) }
+ let(:status) { Gitlab::Ci::Status::Core.new(build, user) }
+
+ it 'does not override status badge_tooltip' do
+ expect(status).to receive(:badge_tooltip)
+
+ subject.badge_tooltip
+ end
+ 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 b38fbee2486..40871f86568 100644
--- a/spec/lib/gitlab/ci/status/external/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/external/common_spec.rb
@@ -29,7 +29,7 @@ describe Gitlab::Ci::Status::External::Common do
describe '#has_details?' do
context 'when user has access to read commit status' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it { is_expected.to have_details }
diff --git a/spec/lib/gitlab/ci/status/external/factory_spec.rb b/spec/lib/gitlab/ci/status/external/factory_spec.rb
index c96fd53e730..529d02a3e39 100644
--- a/spec/lib/gitlab/ci/status/external/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/external/factory_spec.rb
@@ -8,7 +8,7 @@ describe Gitlab::Ci::Status::External::Factory do
let(:external_url) { 'http://gitlab.com/status' }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'when external status has a simple core status' do
diff --git a/spec/lib/gitlab/ci/status/pipeline/common_spec.rb b/spec/lib/gitlab/ci/status/pipeline/common_spec.rb
index 4a5b45e7cae..57df8325635 100644
--- a/spec/lib/gitlab/ci/status/pipeline/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/pipeline/common_spec.rb
@@ -18,7 +18,7 @@ describe Gitlab::Ci::Status::Pipeline::Common do
describe '#has_details?' do
context 'when user has access to read pipeline' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it { is_expected.to have_details }
diff --git a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb
index dd754b849b2..defb3fdc0df 100644
--- a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb
@@ -7,7 +7,7 @@ describe Gitlab::Ci::Status::Pipeline::Factory do
let(:factory) { described_class.new(pipeline, user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'when pipeline has a core status' do
diff --git a/spec/lib/gitlab/ci/status/stage/common_spec.rb b/spec/lib/gitlab/ci/status/stage/common_spec.rb
index f5f03ac0395..6ec35f8da7e 100644
--- a/spec/lib/gitlab/ci/status/stage/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/stage/common_spec.rb
@@ -27,7 +27,7 @@ describe Gitlab::Ci::Status::Stage::Common do
context 'when user has permission to read pipeline' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'has details' do
diff --git a/spec/lib/gitlab/ci/status/stage/factory_spec.rb b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
index 432b07e4902..dee4f4efd1b 100644
--- a/spec/lib/gitlab/ci/status/stage/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
@@ -18,7 +18,7 @@ describe Gitlab::Ci::Status::Stage::Factory do
end
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'when stage has a core status' do
diff --git a/spec/lib/gitlab/ci/status/success_warning_spec.rb b/spec/lib/gitlab/ci/status/success_warning_spec.rb
index 4582354e739..6d05545d1d8 100644
--- a/spec/lib/gitlab/ci/status/success_warning_spec.rb
+++ b/spec/lib/gitlab/ci/status/success_warning_spec.rb
@@ -1,8 +1,10 @@
require 'spec_helper'
describe Gitlab::Ci::Status::SuccessWarning do
+ let(:status) { double('status') }
+
subject do
- described_class.new(double('status'))
+ described_class.new(status)
end
describe '#test' do
diff --git a/spec/lib/gitlab/ci/trace/http_io_spec.rb b/spec/lib/gitlab/ci/trace/http_io_spec.rb
new file mode 100644
index 00000000000..5474e2f518c
--- /dev/null
+++ b/spec/lib/gitlab/ci/trace/http_io_spec.rb
@@ -0,0 +1,315 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Trace::HttpIO do
+ include HttpIOHelpers
+
+ let(:http_io) { described_class.new(url, size) }
+ let(:url) { remote_trace_url }
+ let(:size) { remote_trace_size }
+
+ describe '#close' do
+ subject { http_io.close }
+
+ it { is_expected.to be_nil }
+ end
+
+ describe '#binmode' do
+ subject { http_io.binmode }
+
+ it { is_expected.to be_nil }
+ end
+
+ describe '#binmode?' do
+ subject { http_io.binmode? }
+
+ it { is_expected.to be_truthy }
+ end
+
+ describe '#path' do
+ subject { http_io.path }
+
+ it { is_expected.to be_nil }
+ end
+
+ describe '#url' do
+ subject { http_io.url }
+
+ it { is_expected.to eq(url) }
+ end
+
+ describe '#seek' do
+ subject { http_io.seek(pos, where) }
+
+ context 'when moves pos to end of the file' do
+ let(:pos) { 0 }
+ let(:where) { IO::SEEK_END }
+
+ it { is_expected.to eq(size) }
+ end
+
+ context 'when moves pos to middle of the file' do
+ let(:pos) { size / 2 }
+ let(:where) { IO::SEEK_SET }
+
+ it { is_expected.to eq(size / 2) }
+ end
+
+ context 'when moves pos around' do
+ it 'matches the result' do
+ expect(http_io.seek(0)).to eq(0)
+ expect(http_io.seek(100, IO::SEEK_CUR)).to eq(100)
+ expect { http_io.seek(size + 1, IO::SEEK_CUR) }.to raise_error('new position is outside of file')
+ end
+ end
+ end
+
+ describe '#eof?' do
+ subject { http_io.eof? }
+
+ context 'when current pos is at end of the file' do
+ before do
+ http_io.seek(size, IO::SEEK_SET)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when current pos is not at end of the file' do
+ before do
+ http_io.seek(0, IO::SEEK_SET)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#each_line' do
+ subject { http_io.each_line }
+
+ let(:string_io) { StringIO.new(remote_trace_body) }
+
+ before do
+ stub_remote_trace_206
+ end
+
+ it 'yields lines' do
+ expect { |b| http_io.each_line(&b) }.to yield_successive_args(*string_io.each_line.to_a)
+ end
+
+ context 'when buckets on GCS' do
+ context 'when BUFFER_SIZE is larger than file size' do
+ before do
+ stub_remote_trace_200
+ set_larger_buffer_size_than(size)
+ end
+
+ it 'calls get_chunk only once' do
+ expect_any_instance_of(Net::HTTP).to receive(:request).once.and_call_original
+
+ http_io.each_line { |line| }
+ end
+ end
+ end
+ end
+
+ describe '#read' do
+ subject { http_io.read(length) }
+
+ context 'when there are no network issue' do
+ before do
+ stub_remote_trace_206
+ end
+
+ context 'when read whole size' do
+ let(:length) { nil }
+
+ context 'when BUFFER_SIZE is smaller than file size' do
+ before do
+ set_smaller_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(remote_trace_body)
+ end
+ end
+
+ context 'when BUFFER_SIZE is larger than file size' do
+ before do
+ set_larger_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(remote_trace_body)
+ end
+ end
+ end
+
+ context 'when read only first 100 bytes' do
+ let(:length) { 100 }
+
+ context 'when BUFFER_SIZE is smaller than file size' do
+ before do
+ set_smaller_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(remote_trace_body[0, length])
+ end
+ end
+
+ context 'when BUFFER_SIZE is larger than file size' do
+ before do
+ set_larger_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(remote_trace_body[0, length])
+ end
+ end
+ end
+
+ context 'when tries to read oversize' do
+ let(:length) { size + 1000 }
+
+ context 'when BUFFER_SIZE is smaller than file size' do
+ before do
+ set_smaller_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(remote_trace_body)
+ end
+ end
+
+ context 'when BUFFER_SIZE is larger than file size' do
+ before do
+ set_larger_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(remote_trace_body)
+ end
+ end
+ end
+
+ context 'when tries to read 0 bytes' do
+ let(:length) { 0 }
+
+ context 'when BUFFER_SIZE is smaller than file size' do
+ before do
+ set_smaller_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when BUFFER_SIZE is larger than file size' do
+ before do
+ set_larger_buffer_size_than(size)
+ end
+
+ it 'reads a trace' do
+ is_expected.to be_empty
+ end
+ end
+ end
+ end
+
+ context 'when there is anetwork issue' do
+ let(:length) { nil }
+
+ before do
+ stub_remote_trace_500
+ end
+
+ it 'reads a trace' do
+ expect { subject }.to raise_error(Gitlab::Ci::Trace::HttpIO::FailedToGetChunkError)
+ end
+ end
+ end
+
+ describe '#readline' do
+ subject { http_io.readline }
+
+ let(:string_io) { StringIO.new(remote_trace_body) }
+
+ before do
+ stub_remote_trace_206
+ end
+
+ shared_examples 'all line matching' do
+ it 'reads a line' do
+ (0...remote_trace_body.lines.count).each do
+ expect(http_io.readline).to eq(string_io.readline)
+ end
+ end
+ end
+
+ context 'when there is anetwork issue' do
+ let(:length) { nil }
+
+ before do
+ stub_remote_trace_500
+ end
+
+ it 'reads a trace' do
+ expect { subject }.to raise_error(Gitlab::Ci::Trace::HttpIO::FailedToGetChunkError)
+ end
+ end
+
+ context 'when BUFFER_SIZE is smaller than file size' do
+ before do
+ set_smaller_buffer_size_than(size)
+ end
+
+ it_behaves_like 'all line matching'
+ end
+
+ context 'when BUFFER_SIZE is larger than file size' do
+ before do
+ set_larger_buffer_size_than(size)
+ end
+
+ it_behaves_like 'all line matching'
+ end
+
+ context 'when pos is at middle of the file' do
+ before do
+ set_smaller_buffer_size_than(size)
+
+ http_io.seek(size / 2)
+ string_io.seek(size / 2)
+ end
+
+ it 'reads from pos' do
+ expect(http_io.readline).to eq(string_io.readline)
+ end
+ end
+ end
+
+ describe '#write' do
+ subject { http_io.write(nil) }
+
+ it { expect { subject }.to raise_error(NotImplementedError) }
+ end
+
+ describe '#truncate' do
+ subject { http_io.truncate(nil) }
+
+ it { expect { subject }.to raise_error(NotImplementedError) }
+ end
+
+ describe '#flush' do
+ subject { http_io.flush }
+
+ it { expect { subject }.to raise_error(NotImplementedError) }
+ end
+
+ describe '#present?' do
+ subject { http_io.present? }
+
+ it { is_expected.to be_truthy }
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 3546532b9b4..3a9371ed2e8 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -238,11 +238,98 @@ describe Gitlab::Ci::Trace do
end
end
+ describe '#read' do
+ shared_examples 'read successfully with IO' do
+ it 'yields with source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_a(IO)
+ end
+ end
+ end
+
+ shared_examples 'read successfully with StringIO' do
+ it 'yields with source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_a(StringIO)
+ end
+ end
+ end
+
+ shared_examples 'failed to read' do
+ it 'yields without source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_nil
+ end
+ end
+ end
+
+ context 'when trace artifact exists' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it_behaves_like 'read successfully with IO'
+ end
+
+ context 'when current_path (with project_id) exists' do
+ before do
+ expect(trace).to receive(:default_path) { expand_fixture_path('trace/sample_trace') }
+ end
+
+ it_behaves_like 'read successfully with IO'
+ end
+
+ context 'when current_path (with project_ci_id) exists' do
+ before do
+ expect(trace).to receive(:deprecated_path) { expand_fixture_path('trace/sample_trace') }
+ end
+
+ it_behaves_like 'read successfully with IO'
+ end
+
+ context 'when db trace exists' do
+ before do
+ build.send(:write_attribute, :trace, "data")
+ end
+
+ it_behaves_like 'read successfully with StringIO'
+ end
+
+ context 'when no sources exist' do
+ it_behaves_like 'failed to read'
+ end
+ end
+
describe 'trace handling' do
+ subject { trace.exist? }
+
context 'trace does not exist' do
it { expect(trace.exist?).to be(false) }
end
+ context 'when trace artifact exists' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when the trace artifact has been erased' do
+ before do
+ trace.erase!
+ end
+
+ it { is_expected.to be_falsy }
+
+ it 'removes associations' do
+ expect(Ci::JobArtifact.exists?(job_id: build.id, file_type: :trace)).to be_falsy
+ end
+ end
+ end
+
context 'new trace path is used' do
before do
trace.send(:ensure_directory)
@@ -312,4 +399,162 @@ describe Gitlab::Ci::Trace do
end
end
end
+
+ describe '#archive!' do
+ subject { trace.archive! }
+
+ shared_examples 'archive trace file' do
+ it do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ expect(build.job_artifacts_trace.file.filename).to eq('job.log')
+ expect(File.exist?(src_path)).to be_falsy
+ expect(src_checksum)
+ .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest)
+ expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum)
+ end
+ end
+
+ shared_examples 'source trace file stays intact' do |error:|
+ it do
+ expect { subject }.to raise_error(error)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace).to be_nil
+ expect(File.exist?(src_path)).to be_truthy
+ end
+ end
+
+ shared_examples 'archive trace in database' do
+ it do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ expect(build.job_artifacts_trace.file.filename).to eq('job.log')
+ expect(build.old_trace).to be_nil
+ expect(src_checksum)
+ .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest)
+ expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum)
+ end
+ end
+
+ shared_examples 'source trace in database stays intact' do |error:|
+ it do
+ expect { subject }.to raise_error(error)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace).to be_nil
+ expect(build.old_trace).to eq(trace_content)
+ end
+ end
+
+ context 'when job does not have trace artifact' do
+ context 'when trace file stored in default path' do
+ let!(:build) { create(:ci_build, :success, :trace_live) }
+ let!(:src_path) { trace.read { |s| return s.path } }
+ let!(:src_checksum) { Digest::SHA256.file(src_path).hexdigest }
+
+ it_behaves_like 'archive trace file'
+
+ context 'when failed to create clone file' do
+ before do
+ allow(IO).to receive(:copy_stream).and_return(0)
+ end
+
+ it_behaves_like 'source trace file stays intact', error: Gitlab::Ci::Trace::ArchiveError
+ end
+
+ context 'when failed to create job artifact record' do
+ before do
+ allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ it_behaves_like 'source trace file stays intact', error: ActiveRecord::RecordInvalid
+ end
+ end
+
+ context 'when trace is stored in database' do
+ let(:build) { create(:ci_build, :success) }
+ let(:trace_content) { 'Sample trace' }
+ let!(:src_checksum) { Digest::SHA256.hexdigest(trace_content) }
+
+ before do
+ build.update_column(:trace, trace_content)
+ end
+
+ it_behaves_like 'archive trace in database'
+
+ context 'when failed to create clone file' do
+ before do
+ allow(IO).to receive(:copy_stream).and_return(0)
+ end
+
+ it_behaves_like 'source trace in database stays intact', error: Gitlab::Ci::Trace::ArchiveError
+ end
+
+ context 'when failed to create job artifact record' do
+ before do
+ allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid
+ end
+
+ context 'when there is a validation error on Ci::Build' do
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ context "when erase old trace with 'save'" do
+ before do
+ build.send(:write_attribute, :trace, nil)
+ build.save
+ end
+
+ it 'old trace is not deleted' do
+ build.reload
+ expect(build.trace.raw).to eq(trace_content)
+ end
+ end
+
+ it_behaves_like 'archive trace in database'
+ end
+ end
+ end
+
+ context 'when job has trace artifact' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it 'does not archive' do
+ expect_any_instance_of(described_class).not_to receive(:archive_stream!)
+ expect { subject }.to raise_error('Already archived')
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ end
+ end
+
+ context 'when job is not finished yet' do
+ let!(:build) { create(:ci_build, :running, :trace_live) }
+
+ it 'does not archive' do
+ expect_any_instance_of(described_class).not_to receive(:archive_stream!)
+ expect { subject }.to raise_error('Job is not finished yet')
+ expect(build.trace.exist?).to be_truthy
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb
new file mode 100644
index 00000000000..bf9208f1ff4
--- /dev/null
+++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Variables::Collection::Item do
+ let(:variable) do
+ { key: 'VAR', value: 'something', public: true }
+ end
+
+ describe '.fabricate' do
+ it 'supports using a hash' do
+ resource = described_class.fabricate(variable)
+
+ expect(resource).to be_a(described_class)
+ expect(resource).to eq variable
+ end
+
+ it 'supports using an active record resource' do
+ variable = create(:ci_variable, key: 'CI_VAR', value: '123')
+ resource = described_class.fabricate(variable)
+
+ expect(resource).to be_a(described_class)
+ expect(resource).to eq(key: 'CI_VAR', value: '123', public: false)
+ end
+
+ it 'supports using another collection item' do
+ item = described_class.new(**variable)
+
+ resource = described_class.fabricate(item)
+
+ expect(resource).to be_a(described_class)
+ expect(resource).to eq variable
+ expect(resource.object_id).not_to eq item.object_id
+ end
+ end
+
+ describe '#==' do
+ it 'compares a hash representation of a variable' do
+ expect(described_class.new(**variable) == variable).to be true
+ end
+ end
+
+ describe '#[]' do
+ it 'behaves like a hash accessor' do
+ item = described_class.new(**variable)
+
+ expect(item[:key]).to eq 'VAR'
+ end
+ end
+
+ describe '#to_runner_variable' do
+ it 'returns a runner-compatible hash representation' do
+ runner_variable = described_class
+ .new(**variable)
+ .to_runner_variable
+
+ expect(runner_variable).to eq variable
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb
new file mode 100644
index 00000000000..cb2f7718c9c
--- /dev/null
+++ b/spec/lib/gitlab/ci/variables/collection_spec.rb
@@ -0,0 +1,114 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Variables::Collection do
+ describe '.new' do
+ it 'can be initialized with an array' do
+ variable = { key: 'VAR', value: 'value', public: true }
+
+ collection = described_class.new([variable])
+
+ expect(collection.first.to_runner_variable).to eq variable
+ end
+
+ it 'can be initialized without an argument' do
+ expect(subject).to be_none
+ end
+ end
+
+ describe '#append' do
+ it 'appends a hash' do
+ subject.append(key: 'VARIABLE', value: 'something')
+
+ expect(subject).to be_one
+ end
+
+ it 'appends a Ci::Variable' do
+ subject.append(build(:ci_variable))
+
+ expect(subject).to be_one
+ end
+
+ it 'appends an internal resource' do
+ collection = described_class.new([{ key: 'TEST', value: 1 }])
+
+ subject.append(collection.first)
+
+ expect(subject).to be_one
+ end
+
+ it 'returns self' do
+ expect(subject.append(key: 'VAR', value: 'test'))
+ .to eq subject
+ end
+ end
+
+ describe '#concat' do
+ it 'appends all elements from an array' do
+ collection = described_class.new([{ key: 'VAR_1', value: '1' }])
+ variables = [{ key: 'VAR_2', value: '2' }, { key: 'VAR_3', value: '3' }]
+
+ collection.concat(variables)
+
+ expect(collection).to include(key: 'VAR_1', value: '1', public: true)
+ expect(collection).to include(key: 'VAR_2', value: '2', public: true)
+ expect(collection).to include(key: 'VAR_3', value: '3', public: true)
+ end
+
+ it 'appends all elements from other collection' do
+ collection = described_class.new([{ key: 'VAR_1', value: '1' }])
+ additional = described_class.new([{ key: 'VAR_2', value: '2' },
+ { key: 'VAR_3', value: '3' }])
+
+ collection.concat(additional)
+
+ expect(collection).to include(key: 'VAR_1', value: '1', public: true)
+ expect(collection).to include(key: 'VAR_2', value: '2', public: true)
+ expect(collection).to include(key: 'VAR_3', value: '3', public: true)
+ end
+
+ it 'returns self' do
+ expect(subject.concat([key: 'VAR', value: 'test']))
+ .to eq subject
+ end
+ end
+
+ describe '#+' do
+ it 'makes it possible to combine with an array' do
+ collection = described_class.new([{ key: 'TEST', value: 1 }])
+ variables = [{ key: 'TEST', value: 'something' }]
+
+ expect((collection + variables).count).to eq 2
+ end
+
+ it 'makes it possible to combine with another collection' do
+ collection = described_class.new([{ key: 'TEST', value: 1 }])
+ other = described_class.new([{ key: 'TEST', value: 2 }])
+
+ expect((collection + other).count).to eq 2
+ end
+ end
+
+ describe '#to_runner_variables' do
+ it 'creates an array of hashes in a runner-compatible format' do
+ collection = described_class.new([{ key: 'TEST', value: 1 }])
+
+ expect(collection.to_runner_variables)
+ .to eq [{ key: 'TEST', value: 1, public: true }]
+ end
+ end
+
+ describe '#to_hash' do
+ it 'returns regular hash in valid order without duplicates' do
+ collection = described_class.new
+ .append(key: 'TEST1', value: 'test-1')
+ .append(key: 'TEST2', value: 'test-2')
+ .append(key: 'TEST1', value: 'test-3')
+
+ expect(collection.to_hash).to eq('TEST1' => 'test-3',
+ 'TEST2' => 'test-2')
+
+ expect(collection.to_hash).to include(TEST1: 'test-3')
+ expect(collection.to_hash).not_to include(TEST1: 'test-1')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 98880fe9f28..ecb16daec96 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -18,6 +18,34 @@ module Gitlab
describe '#build_attributes' do
subject { described_class.new(config).build_attributes(:rspec) }
+ describe 'attributes list' do
+ let(:config) do
+ YAML.dump(
+ before_script: ['pwd'],
+ rspec: { script: 'rspec' }
+ )
+ end
+
+ it 'returns valid build attributes' do
+ expect(subject).to eq({
+ stage: "test",
+ stage_idx: 1,
+ name: "rspec",
+ commands: "pwd\nrspec",
+ coverage_regex: nil,
+ tag_list: [],
+ options: {
+ before_script: ["pwd"],
+ script: ["rspec"]
+ },
+ allow_failure: false,
+ when: "on_success",
+ environment: nil,
+ yaml_variables: []
+ })
+ end
+ end
+
describe 'coverage entry' do
describe 'code coverage regexp' do
let(:config) do
@@ -105,512 +133,118 @@ module Gitlab
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
+ describe '#stages_attributes' do
+ let(:config) do
+ YAML.dump(
+ rspec: { script: 'rspec', stage: 'test', only: ['branches'] },
+ prod: { script: 'cap prod', stage: 'deploy', only: ['tags'] }
+ )
end
- context 'when kubernetes policy is specified' do
- let(:config) do
- YAML.dump(
- spinach: { stage: 'test', script: 'spinach' },
- production: {
- stage: 'deploy',
- script: 'cap',
- only: { kubernetes: 'active' }
- }
- )
- end
-
- context 'when kubernetes is active' do
- shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do
- it 'returns seeds for kubernetes dependent job' do
- seeds = subject.stage_seeds(pipeline)
-
- expect(seeds.size).to eq 2
- expect(seeds.first.builds.dig(0, :name)).to eq 'spinach'
- expect(seeds.second.builds.dig(0, :name)).to eq 'production'
- end
- end
-
- context 'when user configured kubernetes from Integration > Kubernetes' do
- let(:project) { create(:kubernetes_project) }
- let(:pipeline) { create(:ci_empty_pipeline, project: project) }
-
- it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes'
- end
-
- context 'when user configured kubernetes from CI/CD > Clusters' do
- let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
- let(:project) { cluster.project }
- let(:pipeline) { create(:ci_empty_pipeline, project: project) }
-
- it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes'
- end
- end
-
- context 'when kubernetes is not active' do
- it 'does not return seeds for kubernetes dependent job' do
- seeds = subject.stage_seeds(pipeline)
-
- expect(seeds.size).to eq 1
- expect(seeds.first.builds.dig(0, :name)).to eq 'spinach'
- end
- end
+ let(:attributes) do
+ [{ name: "build",
+ index: 0,
+ builds: [] },
+ { name: "test",
+ index: 1,
+ builds:
+ [{ stage_idx: 1,
+ stage: "test",
+ commands: "rspec",
+ tag_list: [],
+ name: "rspec",
+ allow_failure: false,
+ when: "on_success",
+ environment: nil,
+ coverage_regex: nil,
+ yaml_variables: [],
+ options: { script: ["rspec"] },
+ only: { refs: ["branches"] },
+ except: {} }] },
+ { name: "deploy",
+ index: 2,
+ builds:
+ [{ stage_idx: 2,
+ stage: "deploy",
+ commands: "cap prod",
+ tag_list: [],
+ name: "prod",
+ allow_failure: false,
+ when: "on_success",
+ environment: nil,
+ coverage_regex: nil,
+ yaml_variables: [],
+ options: { script: ["cap prod"] },
+ only: { refs: ["tags"] },
+ except: {} }] }]
+ end
+
+ it 'returns stages seed attributes' do
+ expect(subject.stages_attributes).to eq attributes
end
end
- describe "#pipeline_stage_builds" do
- let(:type) { 'test' }
+ describe 'only / except policies validations' do
+ context 'when `only` has an invalid value' do
+ let(:config) { { rspec: { script: "rspec", type: "test", only: only } } }
+ let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
- it "returns builds if no branch specified" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec" }
- })
+ context 'when it is integer' do
+ let(:only) { 1 }
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "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"]
- },
- allow_failure: false,
- when: "on_success",
- environment: nil,
- yaml_variables: []
- })
- end
-
- describe 'only' do
- it "does not return builds if only has another branch" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", only: ["deploy"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(0)
- end
-
- it "does not return builds if only has regexp with another branch" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", only: ["/^deploy$/"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(0)
- end
-
- it "returns builds if only has specified this branch" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", only: ["master"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1)
- end
-
- it "returns builds if only has a list of branches including specified" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: %w(master deploy) }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1)
- end
-
- it "returns builds if only has a branches keyword specified" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: ["branches"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1)
- end
-
- it "does not return builds if only has a tags keyword" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: ["tags"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0)
- end
-
- 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' }]
-
- possibilities.each do |possibility|
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: [possibility[:keyword]] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(1)
+ it do
+ expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'jobs:rspec:only has to be either an array of conditions or a hash')
end
end
- 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' }]
+ context 'when it is an array of integers' do
+ let(:only) { [1, 1] }
- possibilities.each do |possibility|
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: [possibility[:keyword]] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(0)
+ it do
+ expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'jobs:rspec:only config should be an array of strings or regexps')
end
end
- it "returns builds if only has current repository path" do
- seed_pipeline = pipeline(ref: 'deploy')
+ context 'when it is invalid regex' do
+ let(:only) { ["/*invalid/"] }
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: {
- script: "rspec",
- type: type,
- only: ["branches@#{seed_pipeline.project_full_path}"]
- }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, seed_pipeline).size).to eq(1)
- end
-
- it "does not return builds if only has different repository path" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, only: ["branches@fork"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0)
- end
-
- it "returns build only for specified type" do
- config = YAML.dump({
- 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"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds("deploy", pipeline(ref: "deploy")).size).to eq(2)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "deploy")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("deploy", pipeline(ref: "master")).size).to eq(1)
- end
-
- context 'for invalid value' do
- let(:config) { { rspec: { script: "rspec", type: "test", only: only } } }
- let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
-
- context 'when it is integer' do
- let(:only) { 1 }
-
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only has to be either an array of conditions or a hash')
- end
- end
-
- context 'when it is an array of integers' do
- let(:only) { [1, 1] }
-
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only config should be an array of strings or regexps')
- end
- end
-
- context 'when it is invalid regex' do
- let(:only) { ["/*invalid/"] }
-
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:only config should be an array of strings or regexps')
- end
+ it do
+ expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'jobs:rspec:only config should be an array of strings or regexps')
end
end
end
- describe 'except' do
- it "returns builds if except has another branch" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", except: ["deploy"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1)
- end
-
- it "returns builds if except has regexp with another branch" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", except: ["/^deploy$/"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1)
- end
-
- it "does not return builds if except has specified this branch" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", except: ["master"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(0)
- end
-
- it "does not return builds if except has a list of branches including specified" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: %w(master deploy) }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0)
- end
-
- it "does not return builds if except has a branches keyword specified" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: ["branches"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0)
- end
-
- it "returns builds if except has a tags keyword" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: ["tags"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ context 'when `except` has an invalid value' do
+ let(:config) { { rspec: { script: "rspec", except: except } } }
+ let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1)
- end
-
- 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' }]
-
- possibilities.each do |possibility|
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: [possibility[:keyword]] }
- })
+ context 'when it is integer' do
+ let(:except) { 1 }
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(0)
+ it do
+ expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'jobs:rspec:except has to be either an array of conditions or a hash')
end
end
- 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' }]
-
- possibilities.each do |possibility|
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: [possibility[:keyword]] }
- })
+ context 'when it is an array of integers' do
+ let(:except) { [1, 1] }
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(1)
+ it do
+ expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'jobs:rspec:except config should be an array of strings or regexps')
end
end
- it "does not return builds if except has current repository path" do
- seed_pipeline = pipeline(ref: 'deploy')
-
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: {
- script: "rspec",
- type: type,
- except: ["branches@#{seed_pipeline.project_full_path}"]
- }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, seed_pipeline).size).to eq(0)
- end
-
- it "returns builds if except has different repository path" do
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: type, except: ["branches@fork"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1)
- end
-
- it "returns build except specified type" do
- master_pipeline = pipeline(ref: 'master')
- test_pipeline = pipeline(ref: 'test')
- deploy_pipeline = pipeline(ref: 'deploy')
-
- config = YAML.dump({
- before_script: ["pwd"],
- rspec: { script: "rspec", type: "test", except: ["master", "deploy", "test@#{test_pipeline.project_full_path}"] },
- staging: { script: "deploy", type: "deploy", except: ["master"] },
- production: { script: "deploy", type: "deploy", except: ["master@#{master_pipeline.project_full_path}"] }
- })
-
- config_processor = Gitlab::Ci::YamlProcessor.new(config)
-
- expect(config_processor.pipeline_stage_builds("deploy", deploy_pipeline).size).to eq(2)
- expect(config_processor.pipeline_stage_builds("test", test_pipeline).size).to eq(0)
- expect(config_processor.pipeline_stage_builds("deploy", master_pipeline).size).to eq(0)
- end
-
- context 'for invalid value' do
- let(:config) { { rspec: { script: "rspec", except: except } } }
- let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
-
- context 'when it is integer' do
- let(:except) { 1 }
+ context 'when it is invalid regex' do
+ let(:except) { ["/*invalid/"] }
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:except has to be either an array of conditions or a hash')
- end
- end
-
- context 'when it is an array of integers' do
- let(:except) { [1, 1] }
-
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:except config should be an array of strings or regexps')
- end
- end
-
- context 'when it is invalid regex' do
- let(:except) { ["/*invalid/"] }
-
- it do
- expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
- 'jobs:rspec:except config should be an array of strings or regexps')
- end
+ it do
+ expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'jobs:rspec:except config should be an array of strings or regexps')
end
end
end
@@ -620,7 +254,7 @@ module Gitlab
let(:config_data) { YAML.dump(config) }
let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config_data) }
- subject { config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first }
+ subject { config_processor.stage_builds_attributes('test').first }
describe "before_script" do
context "in global context" do
@@ -703,8 +337,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first).to eq({
stage: "test",
stage_idx: 1,
name: "rspec",
@@ -738,8 +372,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first).to eq({
stage: "test",
stage_idx: 1,
name: "rspec",
@@ -771,8 +405,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first).to eq({
stage: "test",
stage_idx: 1,
name: "rspec",
@@ -800,8 +434,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first).to eq({
stage: "test",
stage_idx: 1,
name: "rspec",
@@ -946,8 +580,8 @@ module Gitlab
})
config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ builds = config_processor.stage_builds_attributes("test")
- builds = config_processor.pipeline_stage_builds("test", pipeline(ref: "master"))
expect(builds.size).to eq(1)
expect(builds.first[:when]).to eq(when_state)
end
@@ -978,8 +612,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first[:options][:cache]).to eq(
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq(
paths: ["logs/", "binaries/"],
untracked: true,
key: 'key',
@@ -997,8 +631,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first[:options][:cache]).to eq(
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq(
paths: ["logs/", "binaries/"],
untracked: true,
key: 'key',
@@ -1017,8 +651,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first[:options][:cache]).to eq(
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq(
paths: ["test/"],
untracked: false,
key: 'local',
@@ -1046,8 +680,8 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1)
- expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first).to eq({
stage: "test",
stage_idx: 1,
name: "rspec",
@@ -1083,8 +717,8 @@ module Gitlab
})
config_processor = Gitlab::Ci::YamlProcessor.new(config)
+ builds = config_processor.stage_builds_attributes("test")
- builds = config_processor.pipeline_stage_builds("test", pipeline(ref: "master"))
expect(builds.size).to eq(1)
expect(builds.first[:options][:artifacts][:when]).to eq(when_state)
end
@@ -1099,7 +733,7 @@ module Gitlab
end
let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
- let(:builds) { processor.pipeline_stage_builds('deploy', pipeline(ref: 'master')) }
+ let(:builds) { processor.stage_builds_attributes('deploy') }
context 'when a production environment is specified' do
let(:environment) { 'production' }
@@ -1256,7 +890,7 @@ module Gitlab
describe "Hidden jobs" do
let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
- subject { config_processor.pipeline_stage_builds("test", pipeline(ref: "master")) }
+ subject { config_processor.stage_builds_attributes("test") }
shared_examples 'hidden_job_handling' do
it "doesn't create jobs that start with dot" do
@@ -1304,7 +938,7 @@ module Gitlab
describe "YAML Alias/Anchor" do
let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
- subject { config_processor.pipeline_stage_builds("build", pipeline(ref: "master")) }
+ subject { config_processor.stage_builds_attributes("build") }
shared_examples 'job_templates_handling' do
it "is correctly supported for jobs" do
@@ -1344,13 +978,13 @@ module Gitlab
context 'when template is a job' do
let(:config) do
- <<EOT
-job1: &JOBTMPL
- stage: build
- script: execute-script-for-job
+ <<~EOT
+ job1: &JOBTMPL
+ stage: build
+ script: execute-script-for-job
-job2: *JOBTMPL
-EOT
+ job2: *JOBTMPL
+ EOT
end
it_behaves_like 'job_templates_handling'
@@ -1358,15 +992,15 @@ EOT
context 'when template is a hidden job' do
let(:config) do
- <<EOT
-.template: &JOBTMPL
- stage: build
- script: execute-script-for-job
+ <<~EOT
+ .template: &JOBTMPL
+ stage: build
+ script: execute-script-for-job
-job1: *JOBTMPL
+ job1: *JOBTMPL
-job2: *JOBTMPL
-EOT
+ job2: *JOBTMPL
+ EOT
end
it_behaves_like 'job_templates_handling'
@@ -1374,18 +1008,18 @@ EOT
context 'when job adds its own keys to a template definition' do
let(:config) do
- <<EOT
-.template: &JOBTMPL
- stage: build
-
-job1:
- <<: *JOBTMPL
- script: execute-script-for-job
-
-job2:
- <<: *JOBTMPL
- script: execute-script-for-job
-EOT
+ <<~EOT
+ .template: &JOBTMPL
+ stage: build
+
+ job1:
+ <<: *JOBTMPL
+ script: execute-script-for-job
+
+ job2:
+ <<: *JOBTMPL
+ script: execute-script-for-job
+ EOT
end
it_behaves_like 'job_templates_handling'
@@ -1394,11 +1028,15 @@ EOT
describe "Error handling" do
it "fails to parse YAML" do
- expect {Gitlab::Ci::YamlProcessor.new("invalid: yaml: test")}.to raise_error(Psych::SyntaxError)
+ expect do
+ Gitlab::Ci::YamlProcessor.new("invalid: yaml: test")
+ end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError)
end
it "indicates that object is invalid" do
- expect {Gitlab::Ci::YamlProcessor.new("invalid_yaml")}.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError)
+ expect do
+ Gitlab::Ci::YamlProcessor.new("invalid_yaml")
+ end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError)
end
it "returns errors if tags parameter is invalid" do
@@ -1673,6 +1311,14 @@ EOT
Gitlab::Ci::YamlProcessor.new(config)
end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec dependencies should be an array of strings")
end
+
+ it 'returns errors if pipeline variables expression is invalid' do
+ config = YAML.dump({ rspec: { script: 'test', only: { variables: ['== null'] } } })
+
+ expect { Gitlab::Ci::YamlProcessor.new(config) }
+ .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'jobs:rspec:only variables invalid expression syntax')
+ end
end
describe "Validate configuration templates" do
@@ -1688,42 +1334,37 @@ EOT
end
describe "#validation_message" do
+ subject { Gitlab::Ci::YamlProcessor.validation_message(content) }
+
context "when the YAML could not be parsed" do
- it "returns an error about invalid configutaion" do
- content = YAML.dump("invalid: yaml: test")
+ let(:content) { YAML.dump("invalid: yaml: test") }
- expect(Gitlab::Ci::YamlProcessor.validation_message(content))
- .to eq "Invalid configuration format"
- end
+ it { is_expected.to eq "Invalid configuration format" }
end
context "when the tags parameter is invalid" do
- it "returns an error about invalid tags" do
- content = YAML.dump({ rspec: { script: "test", tags: "mysql" } })
+ let(:content) { YAML.dump({ rspec: { script: "test", tags: "mysql" } }) }
- expect(Gitlab::Ci::YamlProcessor.validation_message(content))
- .to eq "jobs:rspec tags should be an array of strings"
- end
+ it { is_expected.to eq "jobs:rspec tags should be an array of strings" }
end
context "when YAML content is empty" do
- it "returns an error about missing content" do
- expect(Gitlab::Ci::YamlProcessor.validation_message(''))
- .to eq "Please provide content of .gitlab-ci.yml"
- end
+ let(:content) { '' }
+
+ it { is_expected.to eq "Please provide content of .gitlab-ci.yml" }
end
- context "when the YAML is valid" do
- it "does not return any errors" do
- content = File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ context 'when the YAML contains an unknown alias' do
+ let(:content) { 'steps: *bad_alias' }
- expect(Gitlab::Ci::YamlProcessor.validation_message(content)).to be_nil
- end
+ it { is_expected.to eq "Unknown alias: bad_alias" }
end
- end
- def pipeline(**attributes)
- build_stubbed(:ci_empty_pipeline, **attributes)
+ context "when the YAML is valid" do
+ let(:content) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) }
+
+ it { is_expected.to be_nil }
+ end
end
end
end
diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb
index ef7d766a13d..8d4862932b2 100644
--- a/spec/lib/gitlab/closing_issue_extractor_spec.rb
+++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Gitlab::ClosingIssueExtractor do
let(:project) { create(:project) }
let(:project2) { create(:project) }
- let(:forked_project) { Projects::ForkService.new(project, project.creator).execute }
+ let(:forked_project) { Projects::ForkService.new(project, project2.creator).execute }
let(:issue) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project2) }
let(:reference) { issue.to_reference }
@@ -13,8 +13,9 @@ describe Gitlab::ClosingIssueExtractor do
subject { described_class.new(project, project.creator) }
before do
- project.team << [project.creator, :developer]
- project2.team << [project.creator, :master]
+ project.add_developer(project.creator)
+ project.add_developer(project2.creator)
+ project2.add_master(project.creator)
end
describe "#closed_by_message" do
@@ -297,7 +298,7 @@ describe Gitlab::ClosingIssueExtractor do
context 'with an external issue tracker reference' do
it 'extracts the referenced issue' do
jira_project = create(:jira_project, name: 'JIRA_EXT1')
- jira_project.team << [jira_project.creator, :master]
+ jira_project.add_master(jira_project.creator)
jira_issue = ExternalIssue.new("#{jira_project.name}-1", project: jira_project)
closing_issue_extractor = described_class.new(jira_project, jira_project.creator)
message = "Resolve #{jira_issue.to_reference}"
@@ -364,6 +365,20 @@ describe Gitlab::ClosingIssueExtractor do
.to match_array([issue, other_issue, third_issue])
end
+ it 'allows oxford commas (comma before and) when referencing multiple issues' do
+ message = "Closes #{reference}, #{reference2}, and #{reference3}"
+
+ expect(subject.closed_by_message(message))
+ .to match_array([issue, other_issue, third_issue])
+ end
+
+ it 'allows spaces before commas when referencing multiple issues' do
+ message = "Closes #{reference} , #{reference2} , and #{reference3}"
+
+ 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}"
diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb
index 5944ce8049a..c93912db411 100644
--- a/spec/lib/gitlab/conflict/file_collection_spec.rb
+++ b/spec/lib/gitlab/conflict/file_collection_spec.rb
@@ -10,6 +10,38 @@ describe Gitlab::Conflict::FileCollection do
end
end
+ describe '#cache' do
+ it 'specifies a custom namespace with the merge request commit ids' do
+ our_commit = merge_request.source_branch_head.raw
+ their_commit = merge_request.target_branch_head.raw
+ custom_namespace = "#{our_commit.id}:#{their_commit.id}"
+
+ expect(file_collection.send(:cache).namespace).to include(custom_namespace)
+ end
+ end
+
+ describe '#can_be_resolved_in_ui?' do
+ it 'returns true if conflicts for this collection can be resolved in the UI' do
+ expect(file_collection.can_be_resolved_in_ui?).to be true
+ end
+
+ it "returns false if conflicts for this collection can't be resolved in the UI" do
+ expect(file_collection).to receive(:files).and_raise(Gitlab::Git::Conflict::Resolver::ConflictSideMissing)
+
+ expect(file_collection.can_be_resolved_in_ui?).to be false
+ end
+
+ it 'caches the result' do
+ expect(file_collection).to receive(:files).twice.and_call_original
+
+ expect(file_collection.can_be_resolved_in_ui?).to be true
+
+ expect(file_collection).not_to receive(:files)
+
+ expect(file_collection.can_be_resolved_in_ui?).to be true
+ end
+ end
+
describe '#default_commit_message' do
it 'matches the format of the git CLI commit message' do
expect(file_collection.default_commit_message).to eq(<<EOM.chomp)
diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb
index f1655854486..2c63f3b0455 100644
--- a/spec/lib/gitlab/contributions_calendar_spec.rb
+++ b/spec/lib/gitlab/contributions_calendar_spec.rb
@@ -11,7 +11,7 @@ describe Gitlab::ContributionsCalendar do
end
let(:public_project) do
- create(:project, :public) do |project|
+ create(:project, :public, :repository) do |project|
create(:project_member, user: contributor, project: project)
end
end
@@ -40,13 +40,13 @@ describe Gitlab::ContributionsCalendar do
described_class.new(contributor, current_user)
end
- def create_event(project, day, hour = 0)
+ def create_event(project, day, hour = 0, action = Event::CREATED, target_symbol = :issue)
@targets ||= {}
- @targets[project] ||= create(:issue, project: project, author: contributor)
+ @targets[project] ||= create(target_symbol, project: project, author: contributor)
Event.create!(
project: project,
- action: Event::CREATED,
+ action: action,
target: @targets[project],
author: contributor,
created_at: DateTime.new(day.year, day.month, day.day, hour)
@@ -71,6 +71,19 @@ describe Gitlab::ContributionsCalendar do
expect(calendar(contributor).activity_dates[today]).to eq(2)
end
+ it "counts the diff notes on merge request" do
+ create_event(public_project, today, 0, Event::COMMENTED, :diff_note_on_merge_request)
+
+ expect(calendar(contributor).activity_dates[today]).to eq(1)
+ end
+
+ it "counts the discussions on merge requests and issues" do
+ create_event(public_project, today, 0, Event::COMMENTED, :discussion_note_on_merge_request)
+ create_event(public_project, today, 2, Event::COMMENTED, :discussion_note_on_issue)
+
+ 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)
@@ -118,6 +131,19 @@ describe Gitlab::ContributionsCalendar do
expect(calendar.events_by_date(today)).to contain_exactly(e1)
expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3)
end
+
+ context 'when the user cannot read read cross project' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+ end
+
+ it 'does not return any events' do
+ create_event(public_project, today)
+
+ expect(calendar(user).events_by_date(today)).to be_empty
+ end
+ end
end
describe '#starting_year' do
diff --git a/spec/lib/gitlab/cross_project_access/check_collection_spec.rb b/spec/lib/gitlab/cross_project_access/check_collection_spec.rb
new file mode 100644
index 00000000000..a9e7575240e
--- /dev/null
+++ b/spec/lib/gitlab/cross_project_access/check_collection_spec.rb
@@ -0,0 +1,55 @@
+require 'spec_helper'
+
+describe Gitlab::CrossProjectAccess::CheckCollection do
+ subject(:collection) { described_class.new }
+
+ describe '#add_collection' do
+ it 'merges the checks of 2 collections' do
+ initial_check = double('check')
+ collection.add_check(initial_check)
+
+ other_collection = described_class.new
+ other_check = double('other_check')
+ other_collection.add_check(other_check)
+
+ shared_check = double('shared check')
+ other_collection.add_check(shared_check)
+ collection.add_check(shared_check)
+
+ collection.add_collection(other_collection)
+
+ expect(collection.checks).to contain_exactly(initial_check, shared_check, other_check)
+ end
+ end
+
+ describe '#should_run?' do
+ def fake_check(run, skip)
+ check = double("Check: run=#{run} - skip={skip}")
+ allow(check).to receive(:should_run?).and_return(run)
+ allow(check).to receive(:should_skip?).and_return(skip)
+ allow(check).to receive(:skip).and_return(skip)
+
+ check
+ end
+
+ it 'returns true if one of the check says it should run' do
+ check = fake_check(true, false)
+ other_check = fake_check(false, false)
+
+ collection.add_check(check)
+ collection.add_check(other_check)
+
+ expect(collection.should_run?(double)).to be_truthy
+ end
+
+ it 'returns false if one of the check says it should be skipped' do
+ check = fake_check(true, false)
+ other_check = fake_check(false, true)
+
+ collection.add_check(check)
+ collection.add_check(other_check)
+
+ expect(collection.should_run?(double)).to be_falsey
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cross_project_access/check_info_spec.rb b/spec/lib/gitlab/cross_project_access/check_info_spec.rb
new file mode 100644
index 00000000000..bc9dbf2bece
--- /dev/null
+++ b/spec/lib/gitlab/cross_project_access/check_info_spec.rb
@@ -0,0 +1,111 @@
+require 'spec_helper'
+
+describe Gitlab::CrossProjectAccess::CheckInfo do
+ let(:dummy_controller) { double }
+
+ before do
+ allow(dummy_controller).to receive(:action_name).and_return('index')
+ end
+
+ describe '#should_run?' do
+ it 'runs when an action is defined' do
+ info = described_class.new({ index: true }, nil, nil, false)
+
+ expect(info.should_run?(dummy_controller)).to be_truthy
+ end
+
+ it 'runs when the action is missing' do
+ info = described_class.new({}, nil, nil, false)
+
+ expect(info.should_run?(dummy_controller)).to be_truthy
+ end
+
+ it 'does not run when the action is excluded' do
+ info = described_class.new({ index: false }, nil, nil, false)
+
+ expect(info.should_run?(dummy_controller)).to be_falsy
+ end
+
+ it 'runs when the `if` conditional is true' do
+ info = described_class.new({}, -> { true }, nil, false)
+
+ expect(info.should_run?(dummy_controller)).to be_truthy
+ end
+
+ it 'does not run when the if condition is false' do
+ info = described_class.new({}, -> { false }, nil, false)
+
+ expect(info.should_run?(dummy_controller)).to be_falsy
+ end
+
+ it 'does not run when the `unless` check is true' do
+ info = described_class.new({}, nil, -> { true }, false)
+
+ expect(info.should_run?(dummy_controller)).to be_falsy
+ end
+
+ it 'runs when the `unless` check is false' do
+ info = described_class.new({}, nil, -> { false }, false)
+
+ expect(info.should_run?(dummy_controller)).to be_truthy
+ end
+
+ it 'returns the the oposite of #should_skip? when the check is a skip' do
+ info = described_class.new({}, nil, nil, true)
+
+ expect(info).to receive(:should_skip?).with(dummy_controller).and_return(false)
+ expect(info.should_run?(dummy_controller)).to be_truthy
+ end
+ end
+
+ describe '#should_skip?' do
+ it 'skips when an action is defined' do
+ info = described_class.new({ index: true }, nil, nil, true)
+
+ expect(info.should_skip?(dummy_controller)).to be_truthy
+ end
+
+ it 'does not skip when the action is not defined' do
+ info = described_class.new({}, nil, nil, true)
+
+ expect(info.should_skip?(dummy_controller)).to be_falsy
+ end
+
+ it 'does not skip when the action is excluded' do
+ info = described_class.new({ index: false }, nil, nil, true)
+
+ expect(info.should_skip?(dummy_controller)).to be_falsy
+ end
+
+ it 'skips when the `if` conditional is true' do
+ info = described_class.new({ index: true }, -> { true }, nil, true)
+
+ expect(info.should_skip?(dummy_controller)).to be_truthy
+ end
+
+ it 'does not skip the `if` conditional is false' do
+ info = described_class.new({ index: true }, -> { false }, nil, true)
+
+ expect(info.should_skip?(dummy_controller)).to be_falsy
+ end
+
+ it 'does not skip when the `unless` check is true' do
+ info = described_class.new({ index: true }, nil, -> { true }, true)
+
+ expect(info.should_skip?(dummy_controller)).to be_falsy
+ end
+
+ it 'skips when `unless` check is false' do
+ info = described_class.new({ index: true }, nil, -> { false }, true)
+
+ expect(info.should_skip?(dummy_controller)).to be_truthy
+ end
+
+ it 'returns the the oposite of #should_run? when the check is not a skip' do
+ info = described_class.new({}, nil, nil, false)
+
+ expect(info).to receive(:should_run?).with(dummy_controller).and_return(false)
+ expect(info.should_skip?(dummy_controller)).to be_truthy
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cross_project_access/class_methods_spec.rb b/spec/lib/gitlab/cross_project_access/class_methods_spec.rb
new file mode 100644
index 00000000000..5349685e633
--- /dev/null
+++ b/spec/lib/gitlab/cross_project_access/class_methods_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe Gitlab::CrossProjectAccess::ClassMethods do
+ let(:dummy_class) do
+ Class.new do
+ extend Gitlab::CrossProjectAccess::ClassMethods
+ end
+ end
+ let(:dummy_proc) { lambda { false } }
+
+ describe '#requires_cross_project_access' do
+ it 'creates a correct check when a hash is passed' do
+ expect(Gitlab::CrossProjectAccess)
+ .to receive(:add_check).with(dummy_class,
+ actions: { hello: true, world: false },
+ positive_condition: dummy_proc,
+ negative_condition: dummy_proc)
+
+ dummy_class.requires_cross_project_access(
+ hello: true, world: false, if: dummy_proc, unless: dummy_proc
+ )
+ end
+
+ it 'creates a correct check when an array is passed' do
+ expect(Gitlab::CrossProjectAccess)
+ .to receive(:add_check).with(dummy_class,
+ actions: { hello: true, world: true },
+ positive_condition: nil,
+ negative_condition: nil)
+
+ dummy_class.requires_cross_project_access(:hello, :world)
+ end
+
+ it 'creates a correct check when an array and a hash is passed' do
+ expect(Gitlab::CrossProjectAccess)
+ .to receive(:add_check).with(dummy_class,
+ actions: { hello: true, world: true },
+ positive_condition: dummy_proc,
+ negative_condition: dummy_proc)
+
+ dummy_class.requires_cross_project_access(
+ :hello, :world, if: dummy_proc, unless: dummy_proc
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cross_project_access_spec.rb b/spec/lib/gitlab/cross_project_access_spec.rb
new file mode 100644
index 00000000000..614b0473c7e
--- /dev/null
+++ b/spec/lib/gitlab/cross_project_access_spec.rb
@@ -0,0 +1,84 @@
+require 'spec_helper'
+
+describe Gitlab::CrossProjectAccess do
+ let(:super_class) { Class.new }
+ let(:descendant_class) { Class.new(super_class) }
+ let(:current_instance) { described_class.new }
+
+ before do
+ allow(described_class).to receive(:instance).and_return(current_instance)
+ end
+
+ describe '#add_check' do
+ it 'keeps track of the properties to check' do
+ expect do
+ described_class.add_check(super_class,
+ actions: { index: true },
+ positive_condition: -> { true },
+ negative_condition: -> { false })
+ end.to change { described_class.checks.size }.by(1)
+ end
+
+ it 'builds the check correctly' do
+ check_collection = described_class.add_check(super_class,
+ actions: { index: true },
+ positive_condition: -> { 'positive' },
+ negative_condition: -> { 'negative' })
+
+ check = check_collection.checks.first
+
+ expect(check.actions).to eq(index: true)
+ expect(check.positive_condition.call).to eq('positive')
+ expect(check.negative_condition.call).to eq('negative')
+ end
+
+ it 'merges the checks of a parent class into existing checks of a subclass' do
+ subclass_collection = described_class.add_check(descendant_class)
+
+ expect(subclass_collection).to receive(:add_collection).and_call_original
+
+ described_class.add_check(super_class)
+ end
+
+ it 'merges the existing checks of a superclass into the checks of a subclass' do
+ super_collection = described_class.add_check(super_class)
+ descendant_collection = described_class.add_check(descendant_class)
+
+ expect(descendant_collection.checks).to include(*super_collection.checks)
+ end
+ end
+
+ describe '#find_check' do
+ it 'returns a check when it was defined for a superclass' do
+ expected_check = described_class.add_check(super_class,
+ actions: { index: true },
+ positive_condition: -> { 'positive' },
+ negative_condition: -> { 'negative' })
+
+ expect(described_class.find_check(descendant_class.new))
+ .to eq(expected_check)
+ end
+
+ it 'caches the result for a subclass' do
+ described_class.add_check(super_class,
+ actions: { index: true },
+ positive_condition: -> { 'positive' },
+ negative_condition: -> { 'negative' })
+
+ expect(described_class.instance).to receive(:closest_parent).once.and_call_original
+
+ 2.times { described_class.find_check(descendant_class.new) }
+ end
+
+ it 'returns the checks for the closest class if there are more checks available' do
+ described_class.add_check(super_class,
+ actions: { index: true })
+ expected_check = described_class.add_check(descendant_class,
+ actions: { index: true, show: false })
+
+ check = described_class.find_check(descendant_class.new)
+
+ expect(check).to eq(expected_check)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/current_settings_spec.rb b/spec/lib/gitlab/current_settings_spec.rb
index 492659a82b0..4ddcbd7eb66 100644
--- a/spec/lib/gitlab/current_settings_spec.rb
+++ b/spec/lib/gitlab/current_settings_spec.rb
@@ -8,22 +8,37 @@ describe Gitlab::CurrentSettings do
end
describe '#current_application_settings' do
+ it 'allows keys to be called directly' do
+ db_settings = create(:application_setting,
+ home_page_url: 'http://mydomain.com',
+ signup_enabled: false)
+
+ expect(described_class.home_page_url).to eq(db_settings.home_page_url)
+ expect(described_class.signup_enabled?).to be_falsey
+ expect(described_class.signup_enabled).to be_falsey
+ expect(described_class.metrics_sample_interval).to be(15)
+ end
+
context 'with DB available' do
before do
- allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(true)
+ # For some reason, `allow(described_class).to receive(:connect_to_db?).and_return(true)` causes issues
+ # during the initialization phase of the test suite, so instead let's mock the internals of it
+ allow(ActiveRecord::Base.connection).to receive(:active?).and_return(true)
+ allow(ActiveRecord::Base.connection).to receive(:table_exists?).and_call_original
+ allow(ActiveRecord::Base.connection).to receive(:table_exists?).with('application_settings').and_return(true)
end
it 'attempts to use cached values first' do
expect(ApplicationSetting).to receive(:cached)
- expect(current_application_settings).to be_a(ApplicationSetting)
+ expect(described_class.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.twice
- expect(current_application_settings).to be_a(ApplicationSetting)
+ expect(described_class.current_application_settings).to be_a(ApplicationSetting)
end
it 'falls back to DB if Redis fails' do
@@ -32,14 +47,14 @@ describe Gitlab::CurrentSettings do
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)
+ expect(described_class.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
+ settings = described_class.current_application_settings
expect(settings).to be_a(ApplicationSetting)
expect(settings).to be_persisted
@@ -52,7 +67,7 @@ describe Gitlab::CurrentSettings do
end
it 'returns an in-memory ApplicationSetting object' do
- settings = current_application_settings
+ settings = described_class.current_application_settings
expect(settings).to be_a(OpenStruct)
expect(settings.sign_in_enabled?).to eq(settings.sign_in_enabled)
@@ -63,7 +78,7 @@ describe Gitlab::CurrentSettings do
db_settings = create(:application_setting,
home_page_url: 'http://mydomain.com',
signup_enabled: false)
- settings = current_application_settings
+ settings = described_class.current_application_settings
app_defaults = ApplicationSetting.last
expect(settings).to be_a(OpenStruct)
@@ -80,15 +95,16 @@ describe Gitlab::CurrentSettings do
context 'with DB unavailable' do
before do
- allow_any_instance_of(described_class).to receive(:connect_to_db?).and_return(false)
- allow_any_instance_of(described_class).to receive(:retrieve_settings_from_database_cache?).and_return(nil)
+ # For some reason, `allow(described_class).to receive(:connect_to_db?).and_return(false)` causes issues
+ # during the initialization phase of the test suite, so instead let's mock the internals of it
+ allow(ActiveRecord::Base.connection).to receive(:active?).and_return(false)
end
it 'returns an in-memory ApplicationSetting object' do
expect(ApplicationSetting).not_to receive(:current)
expect(ApplicationSetting).not_to receive(:last)
- expect(current_application_settings).to be_a(OpenStruct)
+ expect(described_class.current_application_settings).to be_a(OpenStruct)
end
end
@@ -101,8 +117,8 @@ describe Gitlab::CurrentSettings do
expect(ApplicationSetting).not_to receive(:current)
expect(ApplicationSetting).not_to receive(:last)
- expect(current_application_settings).to be_a(ApplicationSetting)
- expect(current_application_settings).not_to be_persisted
+ expect(described_class.current_application_settings).to be_a(ApplicationSetting)
+ expect(described_class.current_application_settings).not_to be_persisted
end
end
end
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 3fe0493ed9b..8b07da11c5d 100644
--- a/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb
@@ -41,7 +41,7 @@ describe Gitlab::CycleAnalytics::BaseEventFetcher do
milestone = create(:milestone, project: project)
issue.update(milestone: milestone)
- create_merge_request_closing_issue(issue)
+ create_merge_request_closing_issue(user, project, issue)
end
end
end
diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb
index 28ea7d4c303..397dd4e5d2c 100644
--- a/spec/lib/gitlab/cycle_analytics/events_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb
@@ -122,17 +122,18 @@ describe 'cycle analytics events' do
let(:stage) { :test }
let(:merge_request) { MergeRequest.first }
+
let!(:pipeline) do
create(:ci_pipeline,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha,
- project: context.project,
+ project: project,
head_pipeline_of: merge_request)
end
before do
- create(:ci_build, pipeline: pipeline, status: :success, author: user)
- create(:ci_build, pipeline: pipeline, status: :success, author: user)
+ create(:ci_build, :success, pipeline: pipeline, author: user)
+ create(:ci_build, :success, pipeline: pipeline, author: user)
pipeline.run!
pipeline.succeed!
@@ -219,23 +220,24 @@ describe 'cycle analytics events' do
describe '#staging_events' do
let(:stage) { :staging }
let(:merge_request) { MergeRequest.first }
+
let!(:pipeline) do
create(:ci_pipeline,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha,
- project: context.project,
+ project: project,
head_pipeline_of: merge_request)
end
before do
- create(:ci_build, pipeline: pipeline, status: :success, author: user)
- create(:ci_build, pipeline: pipeline, status: :success, author: user)
+ create(:ci_build, :success, pipeline: pipeline, author: user)
+ create(:ci_build, :success, pipeline: pipeline, author: user)
pipeline.run!
pipeline.succeed!
- merge_merge_requests_closing_issue(context)
- deploy_master
+ merge_merge_requests_closing_issue(user, project, context)
+ deploy_master(user, project)
end
it 'has the name' do
@@ -292,8 +294,8 @@ describe 'cycle analytics events' do
let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
before do
- merge_merge_requests_closing_issue(context)
- deploy_master
+ merge_merge_requests_closing_issue(user, project, context)
+ deploy_master(user, project)
end
it 'has the total time' do
@@ -332,7 +334,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, commit_message: "References #{context.to_reference}")
+ mr = create_merge_request_closing_issue(user, project, 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 2a0dd7be439..6de4bd3dc7c 100644
--- a/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/permissions_spec.rb
@@ -38,7 +38,7 @@ describe Gitlab::CycleAnalytics::Permissions do
context 'user is master' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'has permissions to issue stage' do
@@ -72,7 +72,7 @@ describe Gitlab::CycleAnalytics::Permissions do
context 'user has no build permissions' do
before do
- project.team << [user, :guest]
+ project.add_guest(user)
end
it 'has permissions to issue stage' do
@@ -90,7 +90,7 @@ describe Gitlab::CycleAnalytics::Permissions do
context 'user has no merge request permissions' do
before do
- project.team << [user, :guest]
+ project.add_guest(user)
end
it 'has permissions to issue stage' do
@@ -108,7 +108,7 @@ describe Gitlab::CycleAnalytics::Permissions do
context 'user has no issue permissions' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
end
diff --git a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb
new file mode 100644
index 00000000000..56a316318cb
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb
@@ -0,0 +1,140 @@
+require 'spec_helper'
+
+describe Gitlab::CycleAnalytics::UsageData do
+ describe '#to_json' do
+ before do
+ Timecop.freeze do
+ user = create(:user, :admin)
+ projects = create_list(:project, 2, :repository)
+
+ projects.each_with_index do |project, time|
+ issue = create(:issue, project: project, created_at: (time + 1).hour.ago)
+
+ allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
+
+ milestone = create(:milestone, project: project)
+ mr = create_merge_request_closing_issue(user, project, 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, head_pipeline_of: mr)
+
+ create_cycle(user, project, issue, mr, milestone, pipeline)
+ deploy_master(user, project, environment: 'staging')
+ deploy_master(user, project)
+ end
+ end
+ end
+
+ shared_examples 'a valid usage data result' do
+ it 'returns the aggregated usage data of every selected project' do
+ result = subject.to_json
+
+ expect(result).to have_key(:avg_cycle_analytics)
+
+ CycleAnalytics::STAGES.each do |stage|
+ expect(result[:avg_cycle_analytics]).to have_key(stage)
+
+ stage_values = result[:avg_cycle_analytics][stage]
+ expected_values = expect_values_per_stage[stage]
+
+ expected_values.each_pair do |op, value|
+ expect(stage_values).to have_key(op)
+
+ if op == :missing
+ expect(stage_values[op]).to eq(value)
+ else
+ # delta is used because of git timings that Timecop does not stub
+ expect(stage_values[op].to_i).to be_within(5).of(value.to_i)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when using postgresql', :postgresql do
+ let(:expect_values_per_stage) do
+ {
+ issue: {
+ average: 5400,
+ sd: 2545,
+ missing: 0
+ },
+ plan: {
+ average: 2,
+ sd: 2,
+ missing: 0
+ },
+ code: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ test: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ review: {
+ average: 0,
+ sd: 0,
+ missing: 0
+ },
+ staging: {
+ average: 0,
+ sd: 0,
+ missing: 0
+ },
+ production: {
+ average: 5400,
+ sd: 2545,
+ missing: 0
+ }
+ }
+ end
+
+ it_behaves_like 'a valid usage data result'
+ end
+
+ context 'when using mysql', :mysql do
+ let(:expect_values_per_stage) do
+ {
+ issue: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ plan: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ code: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ test: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ review: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ staging: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ },
+ production: {
+ average: nil,
+ sd: 0,
+ missing: 2
+ }
+ }
+ end
+
+ it_behaves_like 'a valid usage data result'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/data_builder/build_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb
index 91c43f2bdc0..ee91decafad 100644
--- a/spec/lib/gitlab/data_builder/build_spec.rb
+++ b/spec/lib/gitlab/data_builder/build_spec.rb
@@ -16,7 +16,7 @@ describe Gitlab::DataBuilder::Build do
it { expect(data[:build_status]).to eq(build.status) }
it { expect(data[:build_allow_failure]).to eq(false) }
it { expect(data[:project_id]).to eq(build.project.id) }
- it { expect(data[:project_name]).to eq(build.project.name_with_namespace) }
+ it { expect(data[:project_name]).to eq(build.project.full_name) }
context 'commit author_url' do
context 'when no commit present' do
diff --git a/spec/lib/gitlab/data_builder/note_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb
index aaa42566a4d..4f8412108ba 100644
--- a/spec/lib/gitlab/data_builder/note_spec.rb
+++ b/spec/lib/gitlab/data_builder/note_spec.rb
@@ -55,6 +55,14 @@ describe Gitlab::DataBuilder::Note do
.to be > issue.hook_attrs['updated_at']
end
+ context 'with confidential issue' do
+ let(:issue) { create(:issue, project: project, confidential: true) }
+
+ it 'sets event_type to confidential_note' do
+ expect(data[:event_type]).to eq('confidential_note')
+ end
+ end
+
include_examples 'project hook data'
include_examples 'deprecated repository hook data'
end
diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb
index f13041e498c..9ca960502c8 100644
--- a/spec/lib/gitlab/data_builder/pipeline_spec.rb
+++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb
@@ -26,6 +26,7 @@ describe Gitlab::DataBuilder::Pipeline do
it { expect(attributes[:tag]).to eq(pipeline.tag) }
it { expect(attributes[:id]).to eq(pipeline.id) }
it { expect(attributes[:status]).to eq(pipeline.status) }
+ it { expect(attributes[:detailed_status]).to eq('passed') }
it { expect(build_data).to be_a(Hash) }
it { expect(build_data[:id]).to eq(build.id) }
diff --git a/spec/lib/gitlab/database/median_spec.rb b/spec/lib/gitlab/database/median_spec.rb
new file mode 100644
index 00000000000..1b5e30089ce
--- /dev/null
+++ b/spec/lib/gitlab/database/median_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe Gitlab::Database::Median do
+ let(:dummy_class) do
+ Class.new do
+ include Gitlab::Database::Median
+ end
+ end
+
+ subject(:median) { dummy_class.new }
+
+ describe '#median_datetimes' do
+ it 'raises NotSupportedError', :mysql do
+ expect { median.median_datetimes(nil, nil, nil, :project_id) }.to raise_error(dummy_class::NotSupportedError, "partition_column is not supported for MySQL")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 664ba0f7234..280f799f2ab 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -67,17 +67,35 @@ describe Gitlab::Database::MigrationHelpers do
model.add_concurrent_index(:users, :foo, unique: true)
end
+
+ it 'does nothing if the index exists already' do
+ expect(model).to receive(:index_exists?)
+ .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(true)
+ expect(model).not_to receive(:add_index)
+
+ model.add_concurrent_index(:users, :foo, unique: true)
+ end
end
context 'using MySQL' do
- it 'creates a regular index' do
- expect(Gitlab::Database).to receive(:postgresql?).and_return(false)
+ before do
+ allow(Gitlab::Database).to receive(:postgresql?).and_return(false)
+ end
+ it 'creates a regular index' do
expect(model).to receive(:add_index)
.with(:users, :foo, {})
model.add_concurrent_index(:users, :foo)
end
+
+ it 'does nothing if the index exists already' do
+ expect(model).to receive(:index_exists?)
+ .with(:users, :foo, { unique: true }).and_return(true)
+ expect(model).not_to receive(:add_index)
+
+ model.add_concurrent_index(:users, :foo, unique: true)
+ end
end
end
@@ -95,6 +113,7 @@ describe Gitlab::Database::MigrationHelpers do
context 'outside a transaction' do
before do
allow(model).to receive(:transaction_open?).and_return(false)
+ allow(model).to receive(:index_exists?).and_return(true)
end
context 'using PostgreSQL' do
@@ -103,18 +122,41 @@ describe Gitlab::Database::MigrationHelpers do
allow(model).to receive(:disable_statement_timeout)
end
- it 'removes the index concurrently by column name' do
- expect(model).to receive(:remove_index)
- .with(:users, { algorithm: :concurrently, column: :foo })
+ describe 'by column name' do
+ it 'removes the index concurrently' do
+ expect(model).to receive(:remove_index)
+ .with(:users, { algorithm: :concurrently, column: :foo })
- model.remove_concurrent_index(:users, :foo)
+ model.remove_concurrent_index(:users, :foo)
+ end
+
+ it 'does nothing if the index does not exist' do
+ expect(model).to receive(:index_exists?)
+ .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(false)
+ expect(model).not_to receive(:remove_index)
+
+ model.remove_concurrent_index(:users, :foo, unique: true)
+ end
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" })
+ describe 'by index name' do
+ before do
+ allow(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(true)
+ 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")
+ model.remove_concurrent_index_by_name(:users, "index_x_by_y")
+ end
+
+ it 'does nothing if the index does not exist' do
+ expect(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(false)
+ expect(model).not_to receive(:remove_index)
+
+ model.remove_concurrent_index_by_name(:users, "index_x_by_y")
+ end
end
end
@@ -141,6 +183,10 @@ describe Gitlab::Database::MigrationHelpers do
end
describe '#add_concurrent_foreign_key' do
+ before do
+ allow(model).to receive(:foreign_key_exists?).and_return(false)
+ end
+
context 'inside a transaction' do
it 'raises an error' do
expect(model).to receive(:transaction_open?).and_return(true)
@@ -157,14 +203,23 @@ describe Gitlab::Database::MigrationHelpers do
end
context 'using MySQL' do
- it 'creates a regular foreign key' do
+ before do
allow(Gitlab::Database).to receive(:mysql?).and_return(true)
+ end
+ it 'creates a regular foreign key' do
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
+
+ it 'does not create a foreign key if it exists already' do
+ expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true)
+ expect(model).not_to receive(:add_foreign_key)
+
+ model.add_concurrent_foreign_key(:projects, :users, column: :user_id)
+ end
end
context 'using PostgreSQL' do
@@ -189,6 +244,14 @@ describe Gitlab::Database::MigrationHelpers do
column: :user_id,
on_delete: :nullify)
end
+
+ it 'does not create a foreign key if it exists already' do
+ expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true)
+ expect(model).not_to receive(:execute).with(/ADD CONSTRAINT/)
+ expect(model).to receive(:execute).with(/VALIDATE CONSTRAINT/)
+
+ model.add_concurrent_foreign_key(:projects, :users, column: :user_id)
+ end
end
end
end
@@ -203,6 +266,29 @@ describe Gitlab::Database::MigrationHelpers do
end
end
+ describe '#foreign_key_exists?' do
+ before do
+ key = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(:projects, :users, { column: :non_standard_id })
+ allow(model).to receive(:foreign_keys).with(:projects).and_return([key])
+ end
+
+ it 'finds existing foreign keys by column' do
+ expect(model.foreign_key_exists?(:projects, :users, column: :non_standard_id)).to be_truthy
+ end
+
+ it 'finds existing foreign keys by target table only' do
+ expect(model.foreign_key_exists?(:projects, :users)).to be_truthy
+ end
+
+ it 'compares by column name if given' do
+ expect(model.foreign_key_exists?(:projects, :users, column: :user_id)).to be_falsey
+ end
+
+ it 'compares by target if no column given' do
+ expect(model.foreign_key_exists?(:projects, :other_table)).to be_falsey
+ end
+ end
+
describe '#disable_statement_timeout' do
context 'using PostgreSQL' do
it 'disables statement timeouts' do
@@ -902,7 +988,7 @@ describe Gitlab::Database::MigrationHelpers do
describe '#check_trigger_permissions!' do
it 'does nothing when the user has the correct permissions' do
expect { model.check_trigger_permissions!('users') }
- .not_to raise_error(RuntimeError)
+ .not_to raise_error
end
it 'raises RuntimeError when the user does not have the correct permissions' do
@@ -1006,12 +1092,12 @@ describe Gitlab::Database::MigrationHelpers do
context 'with batch_size option' do
it 'queues jobs correctly' do
Sidekiq::Testing.fake! do
- model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.seconds, batch_size: 2)
+ model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, batch_size: 2)
expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id2]])
- expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.seconds.from_now.to_f)
+ expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f)
expect(BackgroundMigrationWorker.jobs[1]['args']).to eq(['FooJob', [id3, id3]])
- expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(20.seconds.from_now.to_f)
+ expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(20.minutes.from_now.to_f)
end
end
end
@@ -1019,10 +1105,10 @@ describe Gitlab::Database::MigrationHelpers do
context 'without batch_size option' do
it 'queues jobs correctly' do
Sidekiq::Testing.fake! do
- model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.seconds)
+ model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes)
expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3]])
- expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.seconds.from_now.to_f)
+ expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f)
end
end
end
@@ -1036,4 +1122,122 @@ describe Gitlab::Database::MigrationHelpers do
end
end
end
+
+ describe '#change_column_type_using_background_migration' do
+ let!(:issue) { create(:issue, :closed, closed_at: Time.zone.now) }
+
+ let(:issue_model) do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = 'issues'
+ include EachBatch
+ end
+ end
+
+ it 'changes the type of a column using a background migration' do
+ expect(model)
+ .to receive(:add_column)
+ .with('issues', 'closed_at_for_type_change', :datetime_with_timezone)
+
+ expect(model)
+ .to receive(:install_rename_triggers)
+ .with('issues', :closed_at, 'closed_at_for_type_change')
+
+ expect(BackgroundMigrationWorker)
+ .to receive(:perform_in)
+ .ordered
+ .with(
+ 10.minutes,
+ 'CopyColumn',
+ ['issues', :closed_at, 'closed_at_for_type_change', issue.id, issue.id]
+ )
+
+ expect(BackgroundMigrationWorker)
+ .to receive(:perform_in)
+ .ordered
+ .with(
+ 1.hour + 10.minutes,
+ 'CleanupConcurrentTypeChange',
+ ['issues', :closed_at, 'closed_at_for_type_change']
+ )
+
+ expect(Gitlab::BackgroundMigration)
+ .to receive(:steal)
+ .ordered
+ .with('CopyColumn')
+
+ expect(Gitlab::BackgroundMigration)
+ .to receive(:steal)
+ .ordered
+ .with('CleanupConcurrentTypeChange')
+
+ model.change_column_type_using_background_migration(
+ issue_model.all,
+ :closed_at,
+ :datetime_with_timezone
+ )
+ end
+ end
+
+ describe '#perform_background_migration_inline?' do
+ it 'returns true in a test environment' do
+ allow(Rails.env)
+ .to receive(:test?)
+ .and_return(true)
+
+ expect(model.perform_background_migration_inline?).to eq(true)
+ end
+
+ it 'returns true in a development environment' do
+ allow(Rails.env)
+ .to receive(:test?)
+ .and_return(false)
+
+ allow(Rails.env)
+ .to receive(:development?)
+ .and_return(true)
+
+ expect(model.perform_background_migration_inline?).to eq(true)
+ end
+
+ it 'returns false in a production environment' do
+ allow(Rails.env)
+ .to receive(:test?)
+ .and_return(false)
+
+ allow(Rails.env)
+ .to receive(:development?)
+ .and_return(false)
+
+ expect(model.perform_background_migration_inline?).to eq(false)
+ end
+ end
+
+ describe '#index_exists_by_name?' do
+ it 'returns true if an index exists' do
+ expect(model.index_exists_by_name?(:projects, 'index_projects_on_path'))
+ .to be_truthy
+ end
+
+ it 'returns false if the index does not exist' do
+ expect(model.index_exists_by_name?(:projects, 'this_does_not_exist'))
+ .to be_falsy
+ end
+
+ context 'when an index with a function exists', :postgresql do
+ before do
+ ActiveRecord::Base.connection.execute(
+ 'CREATE INDEX test_index ON projects (LOWER(path));'
+ )
+ end
+
+ after do
+ 'DROP INDEX IF EXISTS test_index;'
+ end
+
+ it 'returns true if an index exists' do
+ expect(model.index_exists_by_name?(:projects, 'test_index'))
+ .to be_truthy
+ 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
index 596cc435bd9..cc7cb3f23fd 100644
--- 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
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :truncate do
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :delete do
let(:migration) { FakeRenameReservedPathMigrationV1.new }
let(:subject) { described_class.new(['the-path'], migration) }
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
index 1143182531f..b411aaa19da 100644
--- 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
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :truncate do
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :delete do
let(:migration) { FakeRenameReservedPathMigrationV1.new }
let(:subject) { described_class.new(['the-path'], migration) }
let(:namespace) { create(:group, name: 'the-path') }
@@ -94,7 +94,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :
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')
+ create(:project, :repository, :legacy_storage, 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')
@@ -104,7 +104,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :
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')
+ create(:project, :repository, :legacy_storage, namespace: child_namespace, path: 'hello-project')
expected_path = File.join(TestEnv.repos_path, 'hello-group', 'renamed-sub-group', 'hello-project.git')
@@ -115,7 +115,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :
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')
+ create(:project, :repository, :legacy_storage, 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')
@@ -166,7 +166,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :
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")
+ create(:project, :repository, :legacy_storage, 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')
@@ -187,7 +187,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :
end
it 'invalidates the markdown cache of related projects' do
- project = create(:project, namespace: namespace, path: "the-path-project")
+ project = create(:project, :legacy_storage, namespace: namespace, path: "the-path-project")
expect(subject).to receive(:remove_cached_html_for_projects).with([project.id])
@@ -243,7 +243,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :
describe '#revert_renames', :redis do
it 'renames the routes back to the previous values' do
- project = create(:project, :repository, path: 'a-project', namespace: namespace)
+ project = create(:project, :legacy_storage, :repository, path: 'a-project', namespace: namespace)
subject.rename_namespace(namespace)
expect(subject).to receive(:perform_rename)
@@ -261,7 +261,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :
end
it 'moves the repositories back to their original place' do
- project = create(:project, :repository, path: 'a-project', namespace: namespace)
+ project = create(:project, :repository, :legacy_storage, path: 'a-project', namespace: namespace)
project.create_repository
subject.rename_namespace(namespace)
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
index e850b5cd6a4..b4896d69077 100644
--- 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
@@ -1,10 +1,11 @@
require 'spec_helper'
-describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :truncate do
+describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :delete do
let(:migration) { FakeRenameReservedPathMigrationV1.new }
let(:subject) { described_class.new(['the-path'], migration) }
let(:project) do
create(:project,
+ :legacy_storage,
path: 'the-path',
namespace: create(:namespace, path: 'known-parent' ))
end
@@ -17,7 +18,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :tr
describe '#projects_for_paths' do
it 'searches using nested paths' do
namespace = create(:namespace, path: 'hello')
- project = create(:project, path: 'THE-path', namespace: namespace)
+ project = create(:project, :legacy_storage, path: 'THE-path', namespace: namespace)
result_ids = described_class.new(['Hello/the-path'], migration)
.projects_for_paths.map(&:id)
@@ -26,8 +27,8 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :tr
end
it 'includes the correct projects' do
- project = create(:project, path: 'THE-path')
- _other_project = create(:project)
+ project = create(:project, :legacy_storage, path: 'THE-path')
+ _other_project = create(:project, :legacy_storage)
result_ids = subject.projects_for_paths.map(&:id)
@@ -36,7 +37,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :tr
end
describe '#rename_projects' do
- let!(:projects) { create_list(:project, 2, path: 'the-path') }
+ let!(:projects) { create_list(:project, 2, :legacy_storage, path: 'the-path') }
it 'renames each project' do
expect(subject).to receive(:rename_project).twice
@@ -120,7 +121,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :tr
describe '#move_repository' do
let(:known_parent) { create(:namespace, path: 'known-parent') }
- let(:project) { create(:project, :repository, path: 'the-path', namespace: known_parent) }
+ let(:project) { create(:project, :repository, :legacy_storage, 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')
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
index 7695b95dc57..1d31f96159c 100644
--- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb
@@ -13,7 +13,7 @@ shared_examples 'renames child namespaces' do |type|
end
end
-describe Gitlab::Database::RenameReservedPathsMigration::V1, :truncate do
+describe Gitlab::Database::RenameReservedPathsMigration::V1, :delete do
let(:subject) { FakeRenameReservedPathMigrationV1.new }
before do
diff --git a/spec/lib/gitlab/database/sha_attribute_spec.rb b/spec/lib/gitlab/database/sha_attribute_spec.rb
index 62c1d37ea1c..778bfa2cc47 100644
--- a/spec/lib/gitlab/database/sha_attribute_spec.rb
+++ b/spec/lib/gitlab/database/sha_attribute_spec.rb
@@ -19,15 +19,15 @@ describe Gitlab::Database::ShaAttribute do
let(:attribute) { described_class.new }
- describe '#type_cast_from_database' do
+ describe '#deserialize' do
it 'converts the binary SHA to a String' do
- expect(attribute.type_cast_from_database(binary_from_db)).to eq(sha)
+ expect(attribute.deserialize(binary_from_db)).to eq(sha)
end
end
- describe '#type_cast_for_database' do
+ describe '#serialize' do
it 'converts a SHA String to binary data' do
- expect(attribute.type_cast_for_database(sha).to_s).to eq(binary_sha)
+ expect(attribute.serialize(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 b2f13fae73f..1fe1d3926ad 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -287,6 +287,29 @@ describe Gitlab::Database do
end
end
+ describe '.cached_column_exists?' do
+ it 'only retrieves data once' do
+ expect(ActiveRecord::Base.connection).to receive(:columns).once.and_call_original
+
+ 2.times do
+ expect(described_class.cached_column_exists?(:projects, :id)).to be_truthy
+ expect(described_class.cached_column_exists?(:projects, :bogus_column)).to be_falsey
+ end
+ end
+ end
+
+ describe '.cached_table_exists?' do
+ it 'only retrieves data once per table' do
+ expect(ActiveRecord::Base.connection).to receive(:table_exists?).with(:projects).once.and_call_original
+ expect(ActiveRecord::Base.connection).to receive(:table_exists?).with(:bogus_table_name).once.and_call_original
+
+ 2.times do
+ expect(described_class.cached_table_exists?(:projects)).to be_truthy
+ expect(described_class.cached_table_exists?(:bogus_table_name)).to be_falsey
+ end
+ end
+ end
+
describe '#true_value' do
it 'returns correct value for PostgreSQL' do
expect(described_class).to receive(:postgresql?).and_return(true)
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 d81774c8b8f..f48ee8924e8 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
@@ -12,11 +12,25 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do
diff_files
end
- it 'does not files marked as undiffable in .gitattributes' do
+ it 'does not highlight files marked as undiffable in .gitattributes' do
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_files
end
+
+ shared_examples 'initializes a DiffCollection' do
+ it 'returns a valid instance of a DiffCollection' do
+ expect(diff_files).to be_a(Gitlab::Git::DiffCollection)
+ end
+ end
+
+ context 'with Gitaly disabled', :disable_gitaly do
+ it_behaves_like 'initializes a DiffCollection'
+ end
+
+ context 'with Gitaly enabled' do
+ it_behaves_like 'initializes a DiffCollection'
+ end
end
diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb
index ff9acfd08b9..0c2e18c268a 100644
--- a/spec/lib/gitlab/diff/file_spec.rb
+++ b/spec/lib/gitlab/diff/file_spec.rb
@@ -431,4 +431,41 @@ describe Gitlab::Diff::File do
end
end
end
+
+ context 'when neither blob exists' do
+ let(:blank_diff_refs) { Gitlab::Diff::DiffRefs.new(base_sha: Gitlab::Git::BLANK_SHA, head_sha: Gitlab::Git::BLANK_SHA) }
+ let(:diff_file) { described_class.new(diff, diff_refs: blank_diff_refs, repository: project.repository) }
+
+ describe '#blob' do
+ it 'returns a concrete nil so it can be used in boolean expressions' do
+ actual = diff_file.blob && true
+
+ expect(actual).to be_nil
+ end
+ end
+
+ describe '#binary?' do
+ it 'returns false' do
+ expect(diff_file).not_to be_binary
+ end
+ end
+
+ describe '#size' do
+ it 'returns zero' do
+ expect(diff_file.size).to be_zero
+ end
+ end
+
+ describe '#different_type?' do
+ it 'returns false' do
+ expect(diff_file).not_to be_different_type
+ end
+ end
+
+ describe '#content_changed?' do
+ it 'returns false' do
+ expect(diff_file).not_to be_content_changed
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb
index cd602ccab8e..73d60c021c8 100644
--- a/spec/lib/gitlab/diff/highlight_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_spec.rb
@@ -72,6 +72,28 @@ describe Gitlab::Diff::Highlight do
expect(subject[5].text).to eq(code)
expect(subject[5].text).to be_html_safe
end
+
+ context 'when the inline diff marker has an invalid range' do
+ before do
+ allow_any_instance_of(Gitlab::Diff::InlineDiffMarker).to receive(:mark).and_raise(RangeError)
+ end
+
+ it 'keeps the original rich line' do
+ code = %q{+ raise RuntimeError, "System commands must be given as an array of strings"}
+
+ expect(subject[5].text).to eq(code)
+ expect(subject[5].text).not_to be_html_safe
+ end
+
+ it 'reports to Sentry if configured' do
+ allow(Gitlab::Sentry).to receive(:enabled?).and_return(true)
+
+ expect(Gitlab::Sentry).to receive(:context)
+ expect(Raven).to receive(:capture_exception)
+
+ subject
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/email/attachment_uploader_spec.rb b/spec/lib/gitlab/email/attachment_uploader_spec.rb
index f61dbc67ad1..45c690842bc 100644
--- a/spec/lib/gitlab/email/attachment_uploader_spec.rb
+++ b/spec/lib/gitlab/email/attachment_uploader_spec.rb
@@ -2,7 +2,7 @@ require "spec_helper"
describe Gitlab::Email::AttachmentUploader do
describe "#execute" do
- let(:project) { build(:project) }
+ let(:project) { create(:project) }
let(:message_raw) { fixture_file("emails/attachment.eml") }
let(:message) { Mail::Message.new(message_raw) }
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 d0fa16ce4d1..53899e00b53 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -55,8 +55,8 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
- context 'because the note was commands only' do
- let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") }
+ context 'because the note was update commands only' do
+ let!(:email_raw) { fixture_file("emails/update_commands_only_reply.eml") }
context 'and current user cannot update noteable' do
it 'raises a CommandsOnlyNoteError' do
@@ -66,17 +66,14 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
context 'and current user can update noteable' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'does not raise an error' do
- expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
-
# One system note is created for the 'close' event
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
expect(noteable.reload).to be_closed
- expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy
end
end
end
@@ -85,31 +82,29 @@ describe Gitlab::Email::Handler::CreateNoteHandler 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
- it 'post a note and does not update the noteable' do
- expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
-
- # One system note is created for the new note
- expect { receiver.execute }.to change { noteable.notes.count }.by(1)
+ context 'and current user cannot update the noteable' do
+ it 'only executes the commands that the user can perform' do
+ expect { receiver.execute }
+ .to change { noteable.notes.user.count }.by(1)
+ .and change { user.todos_pending_count }.from(0).to(1)
expect(noteable.reload).to be_open
- expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
end
end
context 'and current user can update noteable' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
- it 'post a note and updates the noteable' do
+ it 'posts a note and updates the noteable' do
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
- # One system note is created for the new note, one for the 'close' event
- expect { receiver.execute }.to change { noteable.notes.count }.by(2)
+ expect { receiver.execute }
+ .to change { noteable.notes.user.count }.by(1)
+ .and change { user.todos_pending_count }.from(0).to(1)
expect(noteable.reload).to be_closed
- expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy
end
end
end
diff --git a/spec/lib/gitlab/email/handler_spec.rb b/spec/lib/gitlab/email/handler_spec.rb
index 650b01c4df4..386d73e6115 100644
--- a/spec/lib/gitlab/email/handler_spec.rb
+++ b/spec/lib/gitlab/email/handler_spec.rb
@@ -14,4 +14,28 @@ describe Gitlab::Email::Handler do
expect(described_class.for('email', '')).to be_nil
end
end
+
+ describe 'regexps are set properly' do
+ let(:addresses) do
+ %W(sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX} sent_notification_key path/to/project+merge-request+user_email_token path/to/project+user_email_token)
+ end
+
+ it 'picks each handler at least once' do
+ matched_handlers = addresses.map do |address|
+ described_class.for('email', address).class
+ end
+
+ expect(matched_handlers.uniq).to match_array(Gitlab::Email::Handler::HANDLERS)
+ end
+
+ it 'can pick exactly one handler for each address' do
+ addresses.each do |address|
+ matched_handlers = Gitlab::Email::Handler::HANDLERS.select do |handler|
+ handler.new('email', address).can_handle?
+ end
+
+ expect(matched_handlers.count).to eq(1), "#{address} matches #{matched_handlers.count} handlers: #{matched_handlers}"
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb
index f6e5c55240f..e68c9850f6b 100644
--- a/spec/lib/gitlab/encoding_helper_spec.rb
+++ b/spec/lib/gitlab/encoding_helper_spec.rb
@@ -24,6 +24,11 @@ describe Gitlab::EncodingHelper do
'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 "
+ ],
+ [
+ 'string with detected encoding that is not supported in Ruby',
+ "\xFFe,i\xFF,\xB8oi,'\xB8,\xFF,-",
+ "--broken encoding: IBM420_ltr"
]
].each do |description, test_string, xpect|
it description do
@@ -120,6 +125,24 @@ describe Gitlab::EncodingHelper do
it 'returns empty string on conversion errors' do
expect { ext_class.encode_utf8('') }.not_to raise_error(ArgumentError)
end
+
+ context 'with strings that can be forcefully encoded into utf8' do
+ let(:test_string) do
+ "refs/heads/FixSymbolsTitleDropdown".encode("ASCII-8BIT")
+ end
+ let(:expected_string) do
+ "refs/heads/FixSymbolsTitleDropdown".encode("UTF-8")
+ end
+
+ subject { ext_class.encode_utf8(test_string) }
+
+ it "doesn't use CharlockHolmes if the encoding can be forced into utf_8" do
+ expect(CharlockHolmes::EncodingDetector).not_to receive(:detect)
+
+ expect(subject).to eq(expected_string)
+ expect(subject.encoding.name).to eq('UTF-8')
+ end
+ end
end
describe '#clean' do
@@ -138,6 +161,11 @@ describe Gitlab::EncodingHelper do
'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"
+ ],
+ [
+ 'handles UTF-16BE encoded strings',
+ "\xFE\xFF\x00\x41".force_encoding('ASCII-8BIT'), # An "A" prepended with UTF-16 BOM
+ "\xEF\xBB\xBFA" # An "A" prepended with UTF-8 BOM
]
].each do |description, test_string, xpect|
it description do
@@ -145,4 +173,18 @@ describe Gitlab::EncodingHelper do
end
end
end
+
+ describe 'encode_binary' do
+ [
+ [nil, ""],
+ ["", ""],
+ [" ", " "],
+ %w(a1 a1),
+ ["ç¼–ç ", "\xE7\xBC\x96\xE7\xA0\x81".b]
+ ].each do |input, result|
+ it "encodes #{input.inspect} to #{result.inspect}" do
+ expect(ext_class.encode_binary(input)).to eq(result)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb
index 7322a326b01..aed7d8d81ce 100644
--- a/spec/lib/gitlab/exclusive_lease_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_spec.rb
@@ -73,4 +73,31 @@ describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do
described_class.new(key, timeout: 3600).try_obtain
end
end
+
+ describe '#ttl' do
+ it 'returns the TTL of the Redis key' do
+ lease = described_class.new('kittens', timeout: 100)
+ lease.try_obtain
+
+ expect(lease.ttl <= 100).to eq(true)
+ end
+
+ it 'returns nil when the lease does not exist' do
+ lease = described_class.new('kittens', timeout: 10)
+
+ expect(lease.ttl).to be_nil
+ end
+ end
+
+ describe '.reset_all!' do
+ it 'removes all existing lease keys from redis' do
+ uuid = described_class.new(unique_key, timeout: 3600).try_obtain
+
+ expect(described_class.get_uuid(unique_key)).to eq(uuid)
+
+ described_class.reset_all!
+
+ expect(described_class.get_uuid(unique_key)).to be_falsey
+ end
+ end
end
diff --git a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
index 7dc06c90078..4d2f08f95fc 100644
--- a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
+++ b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb
@@ -10,7 +10,7 @@ describe Gitlab::Gfm::ReferenceRewriter do
let(:text) { 'some text' }
before do
- old_project.team << [user, :reporter]
+ old_project.add_reporter(user)
end
describe '#rewrite' do
diff --git a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
index 39e3b875c49..13df8531b63 100644
--- a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
+++ b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
@@ -17,7 +17,7 @@ describe Gitlab::Gfm::UploadsRewriter do
end
let(:text) do
- "Text and #{image_uploader.to_markdown} and #{zip_uploader.to_markdown}"
+ "Text and #{image_uploader.markdown_link} and #{zip_uploader.markdown_link}"
end
describe '#rewrite' 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.full_path))
- expect(new_paths).to all(include(new_project.full_path))
+ expect(old_paths).to all(include(old_project.disk_path))
+ expect(new_paths).to all(include(new_project.disk_path))
end
it 'does not remove old files' do
diff --git a/spec/lib/gitlab/git/attributes_at_ref_parser_spec.rb b/spec/lib/gitlab/git/attributes_at_ref_parser_spec.rb
new file mode 100644
index 00000000000..5d22dcfb508
--- /dev/null
+++ b/spec/lib/gitlab/git/attributes_at_ref_parser_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe Gitlab::Git::AttributesAtRefParser, seed_helper: true do
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
+
+ subject { described_class.new(repository, 'lfs') }
+
+ it 'loads .gitattributes blob' do
+ repository.raw # Initialize repository in advance since this also checks attributes
+
+ expected_filter = 'filter=lfs diff=lfs merge=lfs'
+ receive_blob = receive(:new).with(a_string_including(expected_filter))
+ expect(Gitlab::Git::AttributesParser).to receive_blob.and_call_original
+
+ subject
+ end
+
+ it 'handles missing blobs' do
+ expect { described_class.new(repository, 'non-existant-branch') }.not_to raise_error
+ end
+
+ describe '#attributes' do
+ it 'returns the attributes as a Hash' do
+ expect(subject.attributes('test.lfs')['filter']).to eq('lfs')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/attributes_parser_spec.rb b/spec/lib/gitlab/git/attributes_parser_spec.rb
new file mode 100644
index 00000000000..323334e99a5
--- /dev/null
+++ b/spec/lib/gitlab/git/attributes_parser_spec.rb
@@ -0,0 +1,153 @@
+require 'spec_helper'
+
+describe Gitlab::Git::AttributesParser, seed_helper: true do
+ let(:attributes_path) { File.join(SEED_STORAGE_PATH, 'with-git-attributes.git', 'info', 'attributes') }
+ let(:data) { File.read(attributes_path) }
+
+ subject { described_class.new(data) }
+
+ describe '#attributes' do
+ context 'using a path with attributes' do
+ it 'returns the attributes as a Hash' do
+ expect(subject.attributes('test.txt')).to eq({ 'text' => true })
+ end
+
+ it 'returns a Hash containing multiple attributes' do
+ 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' })
+ end
+
+ it 'returns a Hash containing attributes for a file in a directory' do
+ expect(subject.attributes('foo/bar.txt')).to eq({ 'foo' => true })
+ 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' })
+ end
+
+ it 'returns a Hash containing the attributes for an absolute path' do
+ expect(subject.attributes('/test.txt')).to eq({ 'text' => true })
+ end
+
+ 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' })
+ end
+
+ it 'returns an empty Hash for a defined path without attributes' do
+ expect(subject.attributes('bla/bla.txt')).to eq({})
+ end
+
+ context 'when the "binary" option is set for a path' do
+ it 'returns true for the "binary" option' do
+ expect(subject.attributes('test.binary')['binary']).to eq(true)
+ end
+
+ it 'returns false for the "diff" option' do
+ expect(subject.attributes('test.binary')['diff']).to eq(false)
+ end
+ end
+ end
+
+ context 'using a path without any attributes' do
+ it 'returns an empty Hash' do
+ expect(subject.attributes('test.foo')).to eq({})
+ end
+ end
+
+ context 'when attributes data is a file handle' do
+ subject do
+ File.open(attributes_path, 'r') do |file_handle|
+ described_class.new(file_handle)
+ end
+ end
+
+ it 'returns the attributes as a Hash' do
+ expect(subject.attributes('test.txt')).to eq({ 'text' => true })
+ end
+ end
+
+ context 'when attributes data is nil' do
+ let(:data) { nil }
+
+ it 'returns an empty Hash' do
+ expect(subject.attributes('test.foo')).to eq({})
+ end
+ end
+ end
+
+ describe '#patterns' do
+ it 'parses a file with entries' do
+ expect(subject.patterns).to be_an_instance_of(Hash)
+ end
+
+ it 'parses an entry that uses a tab to separate the pattern and attributes' do
+ expect(subject.patterns[File.join('/', '*.md')])
+ .to eq({ 'gitlab-language' => 'markdown' })
+ end
+
+ it 'stores patterns in reverse order' do
+ first = subject.patterns.to_a[0]
+
+ expect(first[0]).to eq(File.join('/', 'bla/bla.txt'))
+ end
+
+ # It's a bit hard to test for something _not_ being processed. As such we'll
+ # just test the number of entries.
+ it 'ignores any comments and empty lines' do
+ expect(subject.patterns.length).to eq(10)
+ end
+ end
+
+ describe '#parse_attributes' do
+ it 'parses a boolean attribute' do
+ expect(subject.parse_attributes('text')).to eq({ 'text' => true })
+ end
+
+ it 'parses a negated boolean attribute' do
+ expect(subject.parse_attributes('-text')).to eq({ 'text' => false })
+ end
+
+ it 'parses a key-value pair' do
+ expect(subject.parse_attributes('foo=bar')).to eq({ 'foo' => 'bar' })
+ end
+
+ it 'parses multiple attributes' do
+ input = 'boolean key=value -negated'
+
+ 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' })
+ end
+ end
+
+ describe '#each_line' do
+ it 'iterates over every line in the attributes file' do
+ args = [String] * 14 # the number of lines in the file
+
+ expect { |b| subject.each_line(&b) }.to yield_successive_args(*args)
+ end
+
+ it 'does not yield when the attributes file has an unsupported encoding' do
+ path = File.join(SEED_STORAGE_PATH, 'with-invalid-git-attributes.git', 'info', 'attributes')
+ attrs = described_class.new(File.read(path))
+
+ expect { |b| attrs.each_line(&b) }.not_to yield_control
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/attributes_spec.rb b/spec/lib/gitlab/git/attributes_spec.rb
deleted file mode 100644
index b715fc3410a..00000000000
--- a/spec/lib/gitlab/git/attributes_spec.rb
+++ /dev/null
@@ -1,150 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Git::Attributes, seed_helper: true do
- let(:path) do
- File.join(SEED_STORAGE_PATH, 'with-git-attributes.git')
- end
-
- subject { described_class.new(path) }
-
- describe '#attributes' do
- context 'using a path with attributes' do
- it 'returns the attributes as a Hash' do
- expect(subject.attributes('test.txt')).to eq({ 'text' => true })
- end
-
- it 'returns a Hash containing multiple attributes' do
- 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' })
- end
-
- it 'returns a Hash containing attributes for a file in a directory' do
- expect(subject.attributes('foo/bar.txt')).to eq({ 'foo' => true })
- 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' })
- end
-
- it 'returns a Hash containing the attributes for an absolute path' do
- expect(subject.attributes('/test.txt')).to eq({ 'text' => true })
- end
-
- 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' })
- end
-
- it 'returns an empty Hash for a defined path without attributes' do
- expect(subject.attributes('bla/bla.txt')).to eq({})
- end
-
- context 'when the "binary" option is set for a path' do
- it 'returns true for the "binary" option' do
- expect(subject.attributes('test.binary')['binary']).to eq(true)
- end
-
- it 'returns false for the "diff" option' do
- expect(subject.attributes('test.binary')['diff']).to eq(false)
- end
- end
- end
-
- context 'using a path without any attributes' do
- it 'returns an empty Hash' do
- expect(subject.attributes('test.foo')).to eq({})
- end
- end
- end
-
- describe '#patterns' do
- it 'parses a file with entries' do
- expect(subject.patterns).to be_an_instance_of(Hash)
- 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' })
- end
-
- it 'stores patterns in reverse order' do
- first = subject.patterns.to_a[0]
-
- expect(first[0]).to eq(File.join(path, 'bla/bla.txt'))
- end
-
- # It's a bit hard to test for something _not_ being processed. As such we'll
- # just test the number of entries.
- it 'ignores any comments and empty lines' do
- expect(subject.patterns.length).to eq(10)
- 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(subject.patterns).to eq({})
- end
- end
-
- describe '#parse_attributes' do
- it 'parses a boolean attribute' do
- expect(subject.parse_attributes('text')).to eq({ 'text' => true })
- end
-
- it 'parses a negated boolean attribute' do
- expect(subject.parse_attributes('-text')).to eq({ 'text' => false })
- end
-
- it 'parses a key-value pair' do
- expect(subject.parse_attributes('foo=bar')).to eq({ 'foo' => 'bar' })
- end
-
- it 'parses multiple attributes' do
- input = 'boolean key=value -negated'
-
- 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' })
- end
- end
-
- describe '#each_line' do
- it 'iterates over every line in the attributes file' do
- args = [String] * 14 # the number of lines in the file
-
- expect { |b| subject.each_line(&b) }.to yield_successive_args(*args)
- 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 { |b| subject.each_line(&b) }.not_to yield_control
- end
-
- it 'does not yield when the attributes file has an unsupported encoding' do
- path = File.join(SEED_STORAGE_PATH, 'with-invalid-git-attributes.git')
- attrs = described_class.new(path)
-
- expect { |b| attrs.each_line(&b) }.not_to yield_control
- end
- end
-end
diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb
index c04a9688503..67d898e787e 100644
--- a/spec/lib/gitlab/git/blob_spec.rb
+++ b/spec/lib/gitlab/git/blob_spec.rb
@@ -16,6 +16,18 @@ describe Gitlab::Git::Blob, seed_helper: true do
end
shared_examples 'finding blobs' do
+ context 'nil path' do
+ let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, nil) }
+
+ it { expect(blob).to eq(nil) }
+ end
+
+ context 'blank path' do
+ let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, '') }
+
+ it { expect(blob).to eq(nil) }
+ end
+
context 'file in subdir' do
let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "files/ruby/popen.rb") }
@@ -146,7 +158,7 @@ describe Gitlab::Git::Blob, seed_helper: true do
context 'when sha references a tree' do
it 'returns nil' do
- tree = Gitlab::Git::Commit.find(repository, 'master').tree
+ tree = repository.rugged.rev_parse('master^{tree}')
blob = Gitlab::Git::Blob.raw(repository, tree.oid)
@@ -166,78 +178,81 @@ describe Gitlab::Git::Blob, seed_helper: true do
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) }
+ shared_examples 'loading blobs in batch' do
+ let(:blob_references) do
+ [
+ [SeedRepo::Commit::ID, "files/ruby/popen.rb"],
+ [SeedRepo::Commit::ID, 'six']
+ ]
+ end
- context 'first blob' do
- let(:blob) { subject[0] }
+ subject { described_class.batch(repository, blob_references) }
- 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
+ it { expect(subject.size).to eq(blob_references.size) }
- context 'second blob' do
- let(:blob) { subject[1] }
+ context 'first blob' do
+ let(:blob) { subject[0] }
- 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
+ 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
- end
- context 'limiting' do
- subject { described_class.batch(repository, blob_references, blob_size_limit: blob_size_limit) }
+ context 'second blob' do
+ let(:blob) { subject[1] }
- 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)
+ 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 'positive' do
- let(:blob_size_limit) { 10 }
+ context 'limiting' do
+ subject { described_class.batch(repository, blob_references, blob_size_limit: blob_size_limit) }
- it { expect(subject.first.data.size).to eq(10) }
- 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 }
+ context 'zero' do
+ let(:blob_size_limit) { 0 }
- it { expect(subject.first.data).to eq('') }
- end
+ it 'only loads the metadata' do
+ expect(subject.first.size).not_to be(0)
+ expect(subject.first.data).to eq('')
+ end
+ end
- context 'negative' do
- let(:blob_size_limit) { -1 }
+ 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)
+ 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)
+ expect(subject.first.data.size).to eq(669)
+ end
end
end
end
+
+ context 'when Gitaly list_blobs_by_sha_path feature is enabled' do
+ it_behaves_like 'loading blobs in batch'
+ end
+
+ context 'when Gitaly list_blobs_by_sha_path feature is disabled', :disable_gitaly do
+ it_behaves_like 'loading blobs in batch'
+ end
end
describe '.batch_lfs_pointers' do
- let(:tree_object) { Gitlab::Git::Commit.find(repository, 'master').tree }
+ let(:tree_object) { repository.rugged.rev_parse('master^{tree}') }
let(:non_lfs_blob) do
Gitlab::Git::Blob.find(
@@ -255,29 +270,58 @@ describe Gitlab::Git::Blob, seed_helper: true do
)
end
- it 'returns a list of Gitlab::Git::Blob' do
- blobs = described_class.batch_lfs_pointers(repository, [lfs_blob.id])
+ shared_examples 'fetching batch of LFS pointers' do
+ it 'returns a list of Gitlab::Git::Blob' do
+ blobs = described_class.batch_lfs_pointers(repository, [lfs_blob.id])
- expect(blobs.count).to eq(1)
- expect(blobs).to all( be_a(Gitlab::Git::Blob) )
- end
+ expect(blobs.count).to eq(1)
+ expect(blobs).to all( be_a(Gitlab::Git::Blob) )
+ expect(blobs).to be_an(Array)
+ end
- it 'silently ignores tree objects' do
- blobs = described_class.batch_lfs_pointers(repository, [tree_object.oid])
+ it 'accepts blob IDs as a lazy enumerator' do
+ blobs = described_class.batch_lfs_pointers(repository, [lfs_blob.id].lazy)
- expect(blobs).to eq([])
- end
+ expect(blobs.count).to eq(1)
+ expect(blobs).to all( be_a(Gitlab::Git::Blob) )
+ end
+
+ it 'handles empty list of IDs gracefully' do
+ blobs_1 = described_class.batch_lfs_pointers(repository, [].lazy)
+ blobs_2 = described_class.batch_lfs_pointers(repository, [])
+
+ expect(blobs_1).to eq([])
+ expect(blobs_2).to eq([])
+ end
+
+ it 'silently ignores tree objects' do
+ blobs = described_class.batch_lfs_pointers(repository, [tree_object.oid])
+
+ expect(blobs).to eq([])
+ end
+
+ it 'silently ignores non lfs objects' do
+ blobs = described_class.batch_lfs_pointers(repository, [non_lfs_blob.id])
+
+ expect(blobs).to eq([])
+ end
- it 'silently ignores non lfs objects' do
- blobs = described_class.batch_lfs_pointers(repository, [non_lfs_blob.id])
+ it 'avoids loading large blobs into memory' do
+ # This line could call `lookup` on `repository`, so do here before mocking.
+ non_lfs_blob_id = non_lfs_blob.id
- expect(blobs).to eq([])
+ expect(repository).not_to receive(:lookup)
+
+ described_class.batch_lfs_pointers(repository, [non_lfs_blob_id])
+ end
end
- it 'avoids loading large blobs into memory' do
- expect(repository).not_to receive(:lookup)
+ context 'when Gitaly batch_lfs_pointers is enabled' do
+ it_behaves_like 'fetching batch of LFS pointers'
+ end
- described_class.batch_lfs_pointers(repository, [non_lfs_blob.id])
+ context 'when Gitaly batch_lfs_pointers is disabled', :disable_gitaly do
+ it_behaves_like 'fetching batch of LFS pointers'
end
end
@@ -456,4 +500,33 @@ describe Gitlab::Git::Blob, seed_helper: true do
end
end
end
+
+ describe '#load_all_data!' do
+ let(:full_data) { 'abcd' }
+ let(:blob) { Gitlab::Git::Blob.new(name: 'test', size: 4, data: 'abc') }
+
+ subject { blob.load_all_data!(repository) }
+
+ it 'loads missing data' do
+ expect(Gitlab::GitalyClient).to receive(:migrate)
+ .with(:git_blob_load_all_data).and_return(full_data)
+
+ subject
+
+ expect(blob.data).to eq(full_data)
+ end
+
+ context 'with a fully loaded blob' do
+ let(:blob) { Gitlab::Git::Blob.new(name: 'test', size: 4, data: full_data) }
+
+ it "doesn't perform any loading" do
+ expect(Gitlab::GitalyClient).not_to receive(:migrate)
+ .with(:git_blob_load_all_data)
+
+ subject
+
+ expect(blob.data).to eq(full_data)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/branch_spec.rb b/spec/lib/gitlab/git/branch_spec.rb
index 708870060e7..a19155ed5b0 100644
--- a/spec/lib/gitlab/git/branch_spec.rb
+++ b/spec/lib/gitlab/git/branch_spec.rb
@@ -59,5 +59,69 @@ describe Gitlab::Git::Branch, seed_helper: true do
it { expect(branch.dereferenced_target.sha).to eq(SeedRepo::LastCommit::ID) }
end
+ context 'with active, stale and future branches' do
+ let(:repository) do
+ Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
+ end
+
+ let(:user) { create(:user) }
+ let(:committer) do
+ Gitlab::Git.committer_hash(email: user.email, name: user.name)
+ end
+ let(:params) do
+ parents = [repository.rugged.head.target]
+ tree = parents.first.tree
+
+ {
+ message: 'commit message',
+ author: committer,
+ committer: committer,
+ tree: tree,
+ parents: parents
+ }
+ end
+ let(:stale_sha) { Timecop.freeze(Gitlab::Git::Branch::STALE_BRANCH_THRESHOLD.ago - 5.days) { create_commit } }
+ let(:active_sha) { Timecop.freeze(Gitlab::Git::Branch::STALE_BRANCH_THRESHOLD.ago + 5.days) { create_commit } }
+ let(:future_sha) { Timecop.freeze(100.days.since) { create_commit } }
+
+ before do
+ repository.create_branch('stale-1', stale_sha)
+ repository.create_branch('active-1', active_sha)
+ repository.create_branch('future-1', future_sha)
+ end
+
+ after do
+ ensure_seeds
+ end
+
+ describe 'examine if the branch is active or stale' do
+ let(:stale_branch) { repository.find_branch('stale-1') }
+ let(:active_branch) { repository.find_branch('active-1') }
+ let(:future_branch) { repository.find_branch('future-1') }
+
+ describe '#active?' do
+ it { expect(stale_branch.active?).to be_falsey }
+ it { expect(active_branch.active?).to be_truthy }
+ it { expect(future_branch.active?).to be_truthy }
+ end
+
+ describe '#stale?' do
+ it { expect(stale_branch.stale?).to be_truthy }
+ it { expect(active_branch.stale?).to be_falsey }
+ it { expect(future_branch.stale?).to be_falsey }
+ end
+
+ describe '#state' do
+ it { expect(stale_branch.state).to eq(:stale) }
+ it { expect(active_branch.state).to eq(:active) }
+ it { expect(future_branch.state).to eq(:active) }
+ end
+ end
+ end
+
it { expect(repository.branches.size).to eq(SeedRepo::Repo::BRANCHES.size) }
+
+ def create_commit
+ repository.create_commit(params.merge(committer: committer.merge(time: Time.now)))
+ end
end
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index 5ed639543e0..a05feaac1ca 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -55,7 +55,6 @@ describe Gitlab::Git::Commit, seed_helper: true do
it { expect(@commit.parents).to eq(@gitlab_parents) }
it { expect(@commit.parent_id).to eq(@parents.first.oid) }
it { expect(@commit.no_commit_message).to eq("--no commit message") }
- it { expect(@commit.tree).to eq(@tree) }
after do
# Erase the new commit so other tests get the original repo
@@ -103,6 +102,10 @@ describe Gitlab::Git::Commit, seed_helper: true do
expect(described_class.find(repository, SeedRepo::Commit::ID)).to be_valid_commit
end
+ it "returns an array of parent ids" do
+ expect(described_class.find(repository, SeedRepo::Commit::ID).parent_ids).to be_an(Array)
+ end
+
it "should return valid commit for tag" do
expect(described_class.find(repository, 'v1.0.0').id).to eq('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
end
@@ -389,6 +392,114 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
end
end
+
+ shared_examples 'extracting commit signature' do
+ context 'when the commit is signed' do
+ let(:commit_id) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
+
+ it 'returns signature and signed text' do
+ signature, signed_text = subject
+
+ expected_signature = <<~SIGNATURE
+ -----BEGIN PGP SIGNATURE-----
+ Version: GnuPG/MacGPG2 v2.0.22 (Darwin)
+ Comment: GPGTools - https://gpgtools.org
+
+ iQEcBAABCgAGBQJTDvaZAAoJEGJ8X1ifRn8XfvYIAMuB0yrbTGo1BnOSoDfyrjb0
+ Kw2EyUzvXYL72B63HMdJ+/0tlSDC6zONF3fc+bBD8z+WjQMTbwFNMRbSSy2rKEh+
+ mdRybOP3xBIMGgEph0/kmWln39nmFQBsPRbZBWoU10VfI/ieJdEOgOphszgryRar
+ TyS73dLBGE9y9NIININVaNISet9D9QeXFqc761CGjh4YIghvPpi+YihMWapGka6v
+ hgKhX+hc5rj+7IEE0CXmlbYR8OYvAbAArc5vJD7UTxAY4Z7/l9d6Ydt9GQ25khfy
+ ANFgltYzlR6evLFmDjssiP/mx/ZMN91AL0ueJ9nNGv411Mu2CUW+tDCaQf35mdc=
+ =j51i
+ -----END PGP SIGNATURE-----
+ SIGNATURE
+
+ expect(signature).to eq(expected_signature.chomp)
+ expect(signature).to be_a_binary_string
+
+ expected_signed_text = <<~SIGNED_TEXT
+ tree 22bfa2fbd217df24731f43ff43a4a0f8db759dae
+ parent ae73cb07c9eeaf35924a10f713b364d32b2dd34f
+ author Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> 1393489561 +0200
+ committer Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> 1393489561 +0200
+
+ Feature added
+
+ Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
+ SIGNED_TEXT
+
+ expect(signed_text).to eq(expected_signed_text)
+ expect(signed_text).to be_a_binary_string
+ end
+ end
+
+ context 'when the commit has no signature' do
+ let(:commit_id) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' }
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when the commit cannot be found' do
+ let(:commit_id) { Gitlab::Git::BLANK_SHA }
+
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'when the commit ID is invalid' do
+ let(:commit_id) { '4b4918a572fa86f9771e5ba40fbd48e' }
+
+ it 'raises ArgumentError' do
+ expect { subject }.to raise_error(ArgumentError)
+ end
+ end
+ end
+
+ describe '.extract_signature_lazily' do
+ shared_examples 'loading signatures in batch once' do
+ it 'fetches signatures in batch once' do
+ commit_ids = %w[0b4bc9a49b562e85de7cc9e834518ea6828729b9 4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6]
+ signatures = commit_ids.map do |commit_id|
+ described_class.extract_signature_lazily(repository, commit_id)
+ end
+
+ expect(described_class).to receive(:batch_signature_extraction)
+ .with(repository, commit_ids)
+ .once
+ .and_return({})
+
+ 2.times { signatures.each(&:itself) }
+ end
+ end
+
+ subject { described_class.extract_signature_lazily(repository, commit_id).itself }
+
+ context 'with Gitaly extract_commit_signature_in_batch feature enabled' do
+ it_behaves_like 'extracting commit signature'
+ it_behaves_like 'loading signatures in batch once'
+ end
+
+ context 'with Gitaly extract_commit_signature_in_batch feature disabled', :disable_gitaly do
+ it_behaves_like 'extracting commit signature'
+ it_behaves_like 'loading signatures in batch once'
+ end
+ end
+
+ describe '.extract_signature' do
+ subject { described_class.extract_signature(repository, commit_id) }
+
+ context 'with gitaly' do
+ it_behaves_like 'extracting commit signature'
+ end
+
+ context 'without gitaly', :disable_gitaly do
+ it_behaves_like 'extracting commit signature'
+ end
+ end
end
describe '#init_from_rugged' do
@@ -428,6 +539,11 @@ describe Gitlab::Git::Commit, seed_helper: true do
subject { super().deletions }
it { is_expected.to eq(6) }
end
+
+ describe '#total' do
+ subject { super().total }
+ it { is_expected.to eq(17) }
+ end
end
describe '#stats with gitaly on' do
diff --git a/spec/lib/gitlab/git/conflict/file_spec.rb b/spec/lib/gitlab/git/conflict/file_spec.rb
new file mode 100644
index 00000000000..afed6c32af6
--- /dev/null
+++ b/spec/lib/gitlab/git/conflict/file_spec.rb
@@ -0,0 +1,50 @@
+# coding: utf-8
+require 'spec_helper'
+
+describe Gitlab::Git::Conflict::File do
+ let(:conflict) { { theirs: { path: 'foo', mode: 33188 }, ours: { path: 'foo', mode: 33188 } } }
+ let(:invalid_content) { described_class.new(nil, nil, conflict, "a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }
+ let(:valid_content) { described_class.new(nil, nil, conflict, "Espa\xC3\xB1a".force_encoding(Encoding::ASCII_8BIT)) }
+
+ describe '#lines' do
+ context 'when the content contains non-UTF-8 characters' do
+ it 'raises UnsupportedEncoding' do
+ expect { invalid_content.lines }
+ .to raise_error(described_class::UnsupportedEncoding)
+ end
+ end
+
+ context 'when the content can be converted to UTF-8' do
+ it 'sets lines to the lines' do
+ expect(valid_content.lines).to eq([{
+ full_line: 'España',
+ type: nil,
+ line_obj_index: 0,
+ line_old: 1,
+ line_new: 1
+ }])
+ end
+
+ it 'sets the type to text' do
+ expect(valid_content.type).to eq('text')
+ end
+ end
+ end
+
+ describe '#content' do
+ context 'when the content contains non-UTF-8 characters' do
+ it 'raises UnsupportedEncoding' do
+ expect { invalid_content.content }
+ .to raise_error(described_class::UnsupportedEncoding)
+ end
+ end
+
+ context 'when the content can be converted to UTF-8' do
+ it 'returns a valid UTF-8 string' do
+ expect(valid_content.content).to eq('España')
+ expect(valid_content.content).to be_valid_encoding
+ expect(valid_content.content.encoding).to eq(Encoding::UTF_8)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/conflict/parser_spec.rb b/spec/lib/gitlab/git/conflict/parser_spec.rb
index 7b035a381f1..29a1702a1c6 100644
--- a/spec/lib/gitlab/git/conflict/parser_spec.rb
+++ b/spec/lib/gitlab/git/conflict/parser_spec.rb
@@ -212,13 +212,6 @@ CONFLICT
.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::Git::Conflict::Parser::UnsupportedEncoding)
- end
- end
end
end
end
diff --git a/spec/lib/gitlab/git/env_spec.rb b/spec/lib/gitlab/git/env_spec.rb
deleted file mode 100644
index 03836d49518..00000000000
--- a/spec/lib/gitlab/git/env_spec.rb
+++ /dev/null
@@ -1,134 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Git::Env do
- describe ".set" do
- context 'with RequestStore.store disabled' do
- before do
- allow(RequestStore).to receive(:active?).and_return(false)
- end
-
- it 'does not store anything' do
- described_class.set(GIT_OBJECT_DIRECTORY: 'foo')
-
- expect(described_class.all).to be_empty
- end
- end
-
- context 'with RequestStore.store enabled' do
- before do
- allow(RequestStore).to receive(:active?).and_return(true)
- end
-
- it 'whitelist some `GIT_*` variables and stores them using RequestStore' do
- described_class.set(
- GIT_OBJECT_DIRECTORY: 'foo',
- GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar',
- GIT_EXEC_PATH: 'baz',
- PATH: '~/.bin:/bin')
-
- expect(described_class[:GIT_OBJECT_DIRECTORY]).to eq('foo')
- expect(described_class[:GIT_ALTERNATE_OBJECT_DIRECTORIES]).to eq('bar')
- expect(described_class[:GIT_EXEC_PATH]).to be_nil
- expect(described_class[:bar]).to be_nil
- end
- end
- end
-
- describe ".all" do
- context 'with RequestStore.store enabled' do
- before do
- allow(RequestStore).to receive(:active?).and_return(true)
- described_class.set(
- GIT_OBJECT_DIRECTORY: 'foo',
- GIT_ALTERNATE_OBJECT_DIRECTORIES: ['bar'])
- end
-
- it 'returns an env hash' do
- expect(described_class.all).to eq({
- 'GIT_OBJECT_DIRECTORY' => 'foo',
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => ['bar']
- })
- end
- end
- end
-
- describe ".to_env_hash" do
- context 'with RequestStore.store enabled' do
- using RSpec::Parameterized::TableSyntax
-
- let(:key) { 'GIT_OBJECT_DIRECTORY' }
- subject { described_class.to_env_hash }
-
- where(:input, :output) do
- nil | nil
- 'foo' | 'foo'
- [] | ''
- ['foo'] | 'foo'
- %w[foo bar] | 'foo:bar'
- end
-
- with_them do
- before do
- allow(RequestStore).to receive(:active?).and_return(true)
- described_class.set(key.to_sym => input)
- end
-
- it 'puts the right value in the hash' do
- if output
- expect(subject.fetch(key)).to eq(output)
- else
- expect(subject.has_key?(key)).to eq(false)
- end
- end
- end
- end
- end
-
- describe ".[]" do
- context 'with RequestStore.store enabled' do
- before do
- allow(RequestStore).to receive(:active?).and_return(true)
- end
-
- before do
- described_class.set(
- GIT_OBJECT_DIRECTORY: 'foo',
- GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar')
- end
-
- it 'returns a stored value for an existing key' do
- expect(described_class[:GIT_OBJECT_DIRECTORY]).to eq('foo')
- end
-
- it 'returns nil for an non-existing key' do
- expect(described_class[:foo]).to be_nil
- end
- end
- end
-
- describe 'thread-safety' do
- context 'with RequestStore.store enabled' do
- before do
- allow(RequestStore).to receive(:active?).and_return(true)
- described_class.set(GIT_OBJECT_DIRECTORY: 'foo')
- end
-
- it 'is thread-safe' do
- another_thread = Thread.new do
- described_class.set(GIT_OBJECT_DIRECTORY: 'bar')
-
- Thread.stop
- described_class[:GIT_OBJECT_DIRECTORY]
- end
-
- # Ensure another_thread runs first
- sleep 0.1 until another_thread.stop?
-
- expect(described_class[:GIT_OBJECT_DIRECTORY]).to eq('foo')
-
- another_thread.run
- expect(another_thread.value).to eq('bar')
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/git/gitlab_projects_spec.rb b/spec/lib/gitlab/git/gitlab_projects_spec.rb
index 24da9589458..8b715d717c1 100644
--- a/spec/lib/gitlab/git/gitlab_projects_spec.rb
+++ b/spec/lib/gitlab/git/gitlab_projects_spec.rb
@@ -16,7 +16,7 @@ describe Gitlab::Git::GitlabProjects do
let(:tmp_repos_path) { TestEnv.repos_path }
let(:repo_name) { project.disk_path + '.git' }
let(:tmp_repo_path) { File.join(tmp_repos_path, repo_name) }
- let(:gl_projects) { build_gitlab_projects(tmp_repos_path, repo_name) }
+ let(:gl_projects) { build_gitlab_projects(TestEnv::REPOS_STORAGE, repo_name) }
describe '#initialize' do
it { expect(gl_projects.shard_path).to eq(tmp_repos_path) }
@@ -25,55 +25,10 @@ describe Gitlab::Git::GitlabProjects do
it { expect(gl_projects.logger).to eq(logger) }
end
- describe '#mv_project' do
- let(:new_repo_path) { File.join(tmp_repos_path, 'repo.git') }
-
- it 'moves a repo directory' do
- expect(File.exist?(tmp_repo_path)).to be_truthy
-
- message = "Moving repository from <#{tmp_repo_path}> to <#{new_repo_path}>."
- expect(logger).to receive(:info).with(message)
-
- expect(gl_projects.mv_project('repo.git')).to be_truthy
-
- expect(File.exist?(tmp_repo_path)).to be_falsy
- expect(File.exist?(new_repo_path)).to be_truthy
- end
-
- it "fails if the source path doesn't exist" do
- expected_source_path = File.join(tmp_repos_path, 'bad-src.git')
- expect(logger).to receive(:error).with("mv-project failed: source path <#{expected_source_path}> does not exist.")
-
- result = build_gitlab_projects(tmp_repos_path, 'bad-src.git').mv_project('repo.git')
- expect(result).to be_falsy
- end
-
- it 'fails if the destination path already exists' do
- FileUtils.mkdir_p(File.join(tmp_repos_path, 'already-exists.git'))
-
- expected_distination_path = File.join(tmp_repos_path, 'already-exists.git')
- message = "mv-project failed: destination path <#{expected_distination_path}> already exists."
- expect(logger).to receive(:error).with(message)
-
- expect(gl_projects.mv_project('already-exists.git')).to be_falsy
- end
- end
-
- describe '#rm_project' do
- it 'removes a repo directory' do
- expect(File.exist?(tmp_repo_path)).to be_truthy
- expect(logger).to receive(:info).with("Removing repository <#{tmp_repo_path}>.")
-
- expect(gl_projects.rm_project).to be_truthy
-
- expect(File.exist?(tmp_repo_path)).to be_falsy
- end
- end
-
describe '#push_branches' do
let(:remote_name) { 'remote-name' }
let(:branch_name) { 'master' }
- let(:cmd) { %W(git push -- #{remote_name} #{branch_name}) }
+ let(:cmd) { %W(#{Gitlab.config.git.bin_path} push -- #{remote_name} #{branch_name}) }
let(:force) { false }
subject { gl_projects.push_branches(remote_name, 600, force, [branch_name]) }
@@ -91,7 +46,7 @@ describe Gitlab::Git::GitlabProjects do
end
context 'with --force' do
- let(:cmd) { %W(git push --force -- #{remote_name} #{branch_name}) }
+ let(:cmd) { %W(#{Gitlab.config.git.bin_path} push --force -- #{remote_name} #{branch_name}) }
let(:force) { true }
it 'executes the command' do
@@ -106,10 +61,11 @@ describe Gitlab::Git::GitlabProjects do
let(:remote_name) { 'remote-name' }
let(:branch_name) { 'master' }
let(:force) { false }
+ let(:prune) { true }
let(:tags) { true }
- let(:args) { { force: force, tags: tags }.merge(extra_args) }
+ let(:args) { { force: force, tags: tags, prune: prune }.merge(extra_args) }
let(:extra_args) { {} }
- let(:cmd) { %W(git fetch #{remote_name} --prune --quiet --tags) }
+ let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --tags) }
subject { gl_projects.fetch_remote(remote_name, 600, args) }
@@ -142,7 +98,7 @@ describe Gitlab::Git::GitlabProjects do
context 'with --force' do
let(:force) { true }
- let(:cmd) { %W(git fetch #{remote_name} --prune --quiet --force --tags) }
+ let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --force --tags) }
it 'executes the command with forced option' do
stub_spawn(cmd, 600, tmp_repo_path, {}, success: true)
@@ -153,7 +109,18 @@ describe Gitlab::Git::GitlabProjects do
context 'with --no-tags' do
let(:tags) { false }
- let(:cmd) { %W(git fetch #{remote_name} --prune --quiet --no-tags) }
+ let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --no-tags) }
+
+ it 'executes the command' do
+ stub_spawn(cmd, 600, tmp_repo_path, {}, success: true)
+
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'with no prune' do
+ let(:prune) { false }
+ let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --tags) }
it 'executes the command' do
stub_spawn(cmd, 600, tmp_repo_path, {}, success: true)
@@ -198,94 +165,145 @@ describe Gitlab::Git::GitlabProjects do
describe '#import_project' do
let(:project) { create(:project) }
let(:import_url) { TestEnv.factory_repo_path_bare }
- let(:cmd) { %W(git clone --bare -- #{import_url} #{tmp_repo_path}) }
+ let(:cmd) { %W(#{Gitlab.config.git.bin_path} clone --bare -- #{import_url} #{tmp_repo_path}) }
let(:timeout) { 600 }
subject { gl_projects.import_project(import_url, timeout) }
- context 'success import' do
- it 'imports a repo' do
- expect(File.exist?(File.join(tmp_repo_path, 'HEAD'))).to be_falsy
+ shared_examples 'importing repository' do
+ context 'success import' do
+ it 'imports a repo' do
+ expect(File.exist?(File.join(tmp_repo_path, 'HEAD'))).to be_falsy
- message = "Importing project from <#{import_url}> to <#{tmp_repo_path}>."
- expect(logger).to receive(:info).with(message)
+ is_expected.to be_truthy
- is_expected.to be_truthy
+ expect(File.exist?(File.join(tmp_repo_path, 'HEAD'))).to be_truthy
+ end
+ end
+
+ context 'already exists' do
+ it "doesn't import" do
+ FileUtils.mkdir_p(tmp_repo_path)
- expect(File.exist?(File.join(tmp_repo_path, 'HEAD'))).to be_truthy
+ is_expected.to be_falsy
+ end
end
end
- context 'already exists' do
- it "doesn't import" do
- FileUtils.mkdir_p(tmp_repo_path)
+ context 'when Gitaly import_repository feature is enabled' do
+ it_behaves_like 'importing repository'
+ end
- is_expected.to be_falsy
+ context 'when Gitaly import_repository feature is disabled', :disable_gitaly do
+ describe 'logging' do
+ it 'imports a repo' do
+ message = "Importing project from <#{import_url}> to <#{tmp_repo_path}>."
+ expect(logger).to receive(:info).with(message)
+
+ subject
+ end
end
- end
- context 'timeout' do
- it 'does not import a repo' do
- stub_spawn_timeout(cmd, timeout, nil)
+ context 'timeout' do
+ it 'does not import a repo' do
+ stub_spawn_timeout(cmd, timeout, nil)
- message = "Importing project from <#{import_url}> to <#{tmp_repo_path}> failed."
- expect(logger).to receive(:error).with(message)
+ message = "Importing project from <#{import_url}> to <#{tmp_repo_path}> failed."
+ expect(logger).to receive(:error).with(message)
- is_expected.to be_falsy
+ is_expected.to be_falsy
- expect(gl_projects.output).to eq("Timed out\n")
- expect(File.exist?(File.join(tmp_repo_path, 'HEAD'))).to be_falsy
+ expect(gl_projects.output).to eq("Timed out\n")
+ expect(File.exist?(File.join(tmp_repo_path, 'HEAD'))).to be_falsy
+ end
end
+
+ it_behaves_like 'importing repository'
end
end
describe '#fork_repository' do
+ let(:dest_repos) { TestEnv::REPOS_STORAGE }
let(:dest_repos_path) { tmp_repos_path }
let(:dest_repo_name) { File.join('@hashed', 'aa', 'bb', 'xyz.git') }
let(:dest_repo) { File.join(dest_repos_path, dest_repo_name) }
- let(:dest_namespace) { File.dirname(dest_repo) }
- subject { gl_projects.fork_repository(dest_repos_path, dest_repo_name) }
+ subject { gl_projects.fork_repository(dest_repos, dest_repo_name) }
before do
FileUtils.mkdir_p(dest_repos_path)
+
+ # Undo spec_helper stub that deletes hooks
+ allow_any_instance_of(described_class).to receive(:fork_repository).and_call_original
end
after do
FileUtils.rm_rf(dest_repos_path)
end
- it 'forks the repository' do
- message = "Forking repository from <#{tmp_repo_path}> to <#{dest_repo}>."
- expect(logger).to receive(:info).with(message)
+ shared_examples 'forking a repository' do
+ it 'forks the repository' do
+ is_expected.to be_truthy
- is_expected.to be_truthy
+ expect(File.exist?(dest_repo)).to be_truthy
+ expect(File.exist?(File.join(dest_repo, 'hooks', 'pre-receive'))).to be_truthy
+ expect(File.exist?(File.join(dest_repo, 'hooks', 'post-receive'))).to be_truthy
+ end
+
+ it 'does not fork if a project of the same name already exists' do
+ # create a fake project at the intended destination
+ FileUtils.mkdir_p(dest_repo)
- expect(File.exist?(dest_repo)).to be_truthy
- expect(File.exist?(File.join(dest_repo, 'hooks', 'pre-receive'))).to be_truthy
- expect(File.exist?(File.join(dest_repo, 'hooks', 'post-receive'))).to be_truthy
+ is_expected.to be_falsy
+ end
end
- it 'does not fork if a project of the same name already exists' do
- # create a fake project at the intended destination
- FileUtils.mkdir_p(dest_repo)
+ context 'when Gitaly fork_repository feature is enabled' do
+ it_behaves_like 'forking a repository'
+ end
- # trying to fork again should fail as the repo already exists
- message = "fork-repository failed: destination repository <#{dest_repo}> already exists."
- expect(logger).to receive(:error).with(message)
+ context 'when Gitaly fork_repository feature is disabled', :disable_gitaly do
+ it_behaves_like 'forking a repository'
- is_expected.to be_falsy
- end
+ # We seem to be stuck to having only one working Gitaly storage in tests, changing
+ # that is not very straight-forward so I'm leaving this test here for now till
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/41393 is fixed.
+ context 'different storages' do
+ let(:dest_repos) { 'alternative' }
+ let(:dest_repos_path) { File.join(File.dirname(tmp_repos_path), dest_repos) }
- context 'different storages' do
- let(:dest_repos_path) { File.join(File.dirname(tmp_repos_path), 'alternative') }
+ before do
+ stub_storage_settings(dest_repos => { 'path' => dest_repos_path })
+ end
- it 'forks the repo' do
- is_expected.to be_truthy
+ it 'forks the repo' do
+ is_expected.to be_truthy
- expect(File.exist?(dest_repo)).to be_truthy
- expect(File.exist?(File.join(dest_repo, 'hooks', 'pre-receive'))).to be_truthy
- expect(File.exist?(File.join(dest_repo, 'hooks', 'post-receive'))).to be_truthy
+ expect(File.exist?(dest_repo)).to be_truthy
+ expect(File.exist?(File.join(dest_repo, 'hooks', 'pre-receive'))).to be_truthy
+ expect(File.exist?(File.join(dest_repo, 'hooks', 'post-receive'))).to be_truthy
+ end
+ end
+
+ describe 'log messages' do
+ describe 'successful fork' do
+ it do
+ message = "Forking repository from <#{tmp_repo_path}> to <#{dest_repo}>."
+ expect(logger).to receive(:info).with(message)
+
+ subject
+ end
+ end
+
+ describe 'failed fork due existing destination' do
+ it do
+ FileUtils.mkdir_p(dest_repo)
+ message = "fork-repository failed: destination repository <#{dest_repo}> already exists."
+ expect(logger).to receive(:error).with(message)
+
+ subject
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/git/gitmodules_parser_spec.rb b/spec/lib/gitlab/git/gitmodules_parser_spec.rb
index 143aa2218c9..6fd2b33486b 100644
--- a/spec/lib/gitlab/git/gitmodules_parser_spec.rb
+++ b/spec/lib/gitlab/git/gitmodules_parser_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Gitlab::Git::GitmodulesParser do
it 'should parse a .gitmodules file correctly' do
- parser = described_class.new(<<-'GITMODULES'.strip_heredoc)
+ data = <<~GITMODULES
[submodule "vendor/libgit2"]
path = vendor/libgit2
[submodule "vendor/libgit2"]
@@ -16,6 +16,7 @@ describe Gitlab::Git::GitmodulesParser do
url = https://example.com/another/project
GITMODULES
+ parser = described_class.new(data.gsub("\n", "\r\n"))
modules = parser.parse
expect(modules).to eq({
diff --git a/spec/lib/gitlab/git/hook_env_spec.rb b/spec/lib/gitlab/git/hook_env_spec.rb
new file mode 100644
index 00000000000..e6aa5ad8c90
--- /dev/null
+++ b/spec/lib/gitlab/git/hook_env_spec.rb
@@ -0,0 +1,119 @@
+require 'spec_helper'
+
+describe Gitlab::Git::HookEnv do
+ let(:gl_repository) { 'project-123' }
+
+ describe ".set" do
+ context 'with RequestStore.store disabled' do
+ before do
+ allow(RequestStore).to receive(:active?).and_return(false)
+ end
+
+ it 'does not store anything' do
+ described_class.set(gl_repository, GIT_OBJECT_DIRECTORY_RELATIVE: 'foo')
+
+ expect(described_class.all(gl_repository)).to be_empty
+ end
+ end
+
+ context 'with RequestStore.store enabled' do
+ before do
+ allow(RequestStore).to receive(:active?).and_return(true)
+ end
+
+ it 'whitelist some `GIT_*` variables and stores them using RequestStore' do
+ described_class.set(
+ gl_repository,
+ GIT_OBJECT_DIRECTORY_RELATIVE: 'foo',
+ GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: 'bar',
+ GIT_EXEC_PATH: 'baz',
+ PATH: '~/.bin:/bin')
+
+ git_env = described_class.all(gl_repository)
+
+ expect(git_env[:GIT_OBJECT_DIRECTORY_RELATIVE]).to eq('foo')
+ expect(git_env[:GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE]).to eq('bar')
+ expect(git_env[:GIT_EXEC_PATH]).to be_nil
+ expect(git_env[:PATH]).to be_nil
+ expect(git_env[:bar]).to be_nil
+ end
+ end
+ end
+
+ describe ".all" do
+ context 'with RequestStore.store enabled' do
+ before do
+ allow(RequestStore).to receive(:active?).and_return(true)
+ described_class.set(
+ gl_repository,
+ GIT_OBJECT_DIRECTORY_RELATIVE: 'foo',
+ GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: ['bar'])
+ end
+
+ it 'returns an env hash' do
+ expect(described_class.all(gl_repository)).to eq({
+ 'GIT_OBJECT_DIRECTORY_RELATIVE' => 'foo',
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => ['bar']
+ })
+ end
+ end
+ end
+
+ describe ".to_env_hash" do
+ context 'with RequestStore.store enabled' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:key) { 'GIT_OBJECT_DIRECTORY_RELATIVE' }
+ subject { described_class.to_env_hash(gl_repository) }
+
+ where(:input, :output) do
+ nil | nil
+ 'foo' | 'foo'
+ [] | ''
+ ['foo'] | 'foo'
+ %w[foo bar] | 'foo:bar'
+ end
+
+ with_them do
+ before do
+ allow(RequestStore).to receive(:active?).and_return(true)
+ described_class.set(gl_repository, key.to_sym => input)
+ end
+
+ it 'puts the right value in the hash' do
+ if output
+ expect(subject.fetch(key)).to eq(output)
+ else
+ expect(subject.has_key?(key)).to eq(false)
+ end
+ end
+ end
+ end
+ end
+
+ describe 'thread-safety' do
+ context 'with RequestStore.store enabled' do
+ before do
+ allow(RequestStore).to receive(:active?).and_return(true)
+ described_class.set(gl_repository, GIT_OBJECT_DIRECTORY_RELATIVE: 'foo')
+ end
+
+ it 'is thread-safe' do
+ another_thread = Thread.new do
+ described_class.set(gl_repository, GIT_OBJECT_DIRECTORY_RELATIVE: 'bar')
+
+ Thread.stop
+ described_class.all(gl_repository)[:GIT_OBJECT_DIRECTORY_RELATIVE]
+ end
+
+ # Ensure another_thread runs first
+ sleep 0.1 until another_thread.stop?
+
+ expect(described_class.all(gl_repository)[:GIT_OBJECT_DIRECTORY_RELATIVE]).to eq('foo')
+
+ another_thread.run
+ expect(another_thread.value).to eq('bar')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/info_attributes_spec.rb b/spec/lib/gitlab/git/info_attributes_spec.rb
new file mode 100644
index 00000000000..ea84909c3e0
--- /dev/null
+++ b/spec/lib/gitlab/git/info_attributes_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Gitlab::Git::InfoAttributes, seed_helper: true do
+ let(:path) do
+ File.join(SEED_STORAGE_PATH, 'with-git-attributes.git')
+ end
+
+ subject { described_class.new(path) }
+
+ describe '#attributes' do
+ context 'using a path with attributes' do
+ it 'returns the attributes as a Hash' do
+ expect(subject.attributes('test.txt')).to eq({ 'text' => true })
+ end
+
+ it 'returns an empty Hash for a defined path without attributes' do
+ expect(subject.attributes('bla/bla.txt')).to eq({})
+ end
+ end
+ end
+
+ describe '#parser' do
+ it 'parses a file with entries' do
+ expect(subject.patterns).to be_an_instance_of(Hash)
+ expect(subject.patterns["/*.txt"]).to eq({ 'text' => true })
+ 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(subject.patterns).to eq({})
+ end
+
+ it 'does not parse attributes files with unsupported encoding' do
+ path = File.join(SEED_STORAGE_PATH, 'with-invalid-git-attributes.git')
+ subject = described_class.new(path)
+
+ expect(subject.patterns).to eq({})
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/lfs_changes_spec.rb b/spec/lib/gitlab/git/lfs_changes_spec.rb
index c9007d7d456..d0dd8c6303f 100644
--- a/spec/lib/gitlab/git/lfs_changes_spec.rb
+++ b/spec/lib/gitlab/git/lfs_changes_spec.rb
@@ -7,34 +7,36 @@ describe Gitlab::Git::LfsChanges do
subject { described_class.new(project.repository, newrev) }
- describe 'new_pointers' do
- before do
- allow_any_instance_of(Gitlab::Git::RevList).to receive(:new_objects).and_yield([blob_object_id])
+ describe '#new_pointers' do
+ shared_examples 'new pointers' do
+ it 'filters new objects to find lfs pointers' do
+ expect(subject.new_pointers(not_in: []).first.id).to eq(blob_object_id)
+ end
+
+ it 'limits new_objects using object_limit' do
+ expect(subject.new_pointers(object_limit: 1)).to eq([])
+ end
end
- it 'uses rev-list to find new objects' do
- rev_list = double
- allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list)
-
- expect(rev_list).to receive(:new_objects).and_return([])
-
- subject.new_pointers
+ context 'with gitaly enabled' do
+ it_behaves_like 'new pointers'
end
- it 'filters new objects to find lfs pointers' do
- expect(Gitlab::Git::Blob).to receive(:batch_lfs_pointers).with(project.repository, [blob_object_id])
+ context 'with gitaly disabled', :skip_gitaly_mock do
+ it_behaves_like 'new pointers'
- subject.new_pointers(object_limit: 1)
- end
+ it 'uses rev-list to find new objects' do
+ rev_list = double
+ allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list)
- it 'limits new_objects using object_limit' do
- expect(Gitlab::Git::Blob).to receive(:batch_lfs_pointers).with(project.repository, [])
+ expect(rev_list).to receive(:new_objects).and_return([])
- subject.new_pointers(object_limit: 0)
+ subject.new_pointers
+ end
end
end
- describe 'all_pointers' do
+ describe '#all_pointers', :skip_gitaly_mock do
it 'uses rev-list to find all objects' do
rev_list = double
allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list)
diff --git a/spec/lib/gitlab/git/lfs_pointer_file_spec.rb b/spec/lib/gitlab/git/lfs_pointer_file_spec.rb
new file mode 100644
index 00000000000..d7f76737f3f
--- /dev/null
+++ b/spec/lib/gitlab/git/lfs_pointer_file_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+
+describe Gitlab::Git::LfsPointerFile do
+ let(:data) { "1234\n" }
+
+ subject { described_class.new(data) }
+
+ describe '#size' do
+ it 'counts the bytes' do
+ expect(subject.size).to eq 5
+ end
+
+ it 'handles non ascii data' do
+ expect(described_class.new("ääää").size).to eq 8
+ end
+ end
+
+ describe '#sha256' do
+ it 'hashes the content correctly' do
+ expect(subject.sha256).to eq 'a883dafc480d466ee04e0d6da986bd78eb1fdd2178d04693723da3a8f95d42f4'
+ end
+ end
+
+ describe '#pointer' do
+ it 'starts with the LFS version' do
+ expect(subject.pointer).to start_with('version https://git-lfs.github.com/spec/v1')
+ end
+
+ it 'includes sha256' do
+ expect(subject.pointer).to match(/^oid sha256:[0-9a-fA-F]{64}/)
+ end
+
+ it 'ends with the size' do
+ expect(subject.pointer).to end_with("\nsize 5\n")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 03a9cc488ca..d3ab61746f4 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -1,7 +1,9 @@
+# coding: utf-8
require "spec_helper"
describe Gitlab::Git::Repository, seed_helper: true do
include Gitlab::EncodingHelper
+ using RSpec::Parameterized::TableSyntax
shared_examples 'wrapping gRPC errors' do |gitaly_client_class, gitaly_client_method|
it 'wraps gRPC not found error' do
@@ -18,9 +20,11 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
+ let(:storage_path) { TestEnv.repos_path }
+ let(:user) { build(:user) }
describe '.create_hooks' do
- let(:repo_path) { File.join(TestEnv.repos_path, 'hook-test.git') }
+ let(:repo_path) { File.join(storage_path, 'hook-test.git') }
let(:hooks_dir) { File.join(repo_path, 'hooks') }
let(:target_hooks_dir) { Gitlab.config.gitlab_shell.hooks_path }
let(:existing_target) { File.join(repo_path, 'foobar') }
@@ -116,7 +120,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
describe 'alternates keyword argument' do
context 'with no Git env stored' do
before do
- allow(Gitlab::Git::Env).to receive(:all).and_return({})
+ allow(Gitlab::Git::HookEnv).to receive(:all).and_return({})
end
it "is passed an empty array" do
@@ -128,7 +132,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
context 'with absolute and relative Git object dir envvars stored' do
before do
- allow(Gitlab::Git::Env).to receive(:all).and_return({
+ allow(Gitlab::Git::HookEnv).to receive(:all).and_return({
'GIT_OBJECT_DIRECTORY_RELATIVE' => './objects/foo',
'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => ['./objects/bar', './objects/baz'],
'GIT_OBJECT_DIRECTORY' => 'ignored',
@@ -144,22 +148,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
repository.rugged
end
end
-
- context 'with only absolute Git object dir envvars stored' do
- before do
- allow(Gitlab::Git::Env).to receive(:all).and_return({
- 'GIT_OBJECT_DIRECTORY' => 'foo',
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => %w[bar baz],
- 'GIT_OTHER' => 'another_env'
- })
- end
-
- it "is passed the absolute object dir envvars as is" do
- expect(Rugged::Repository).to receive(:new).with(repository.path, alternates: %w[foo bar baz])
-
- repository.rugged
- end
- end
end
end
@@ -247,7 +235,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
shared_examples 'archive check' do |extenstion|
- it { expect(metadata['ArchivePath']).to match(/tmp\/gitlab-git-test.git\/gitlab-git-test-master-#{SeedRepo::LastCommit::ID}/) }
+ it { expect(metadata['ArchivePath']).to match(%r{tmp/gitlab-git-test.git/gitlab-git-test-master-#{SeedRepo::LastCommit::ID}}) }
it { expect(metadata['ArchivePath']).to end_with extenstion }
end
@@ -259,38 +247,44 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
it 'returns parameterised string for a ref containing slashes' do
- prefix = repository.archive_prefix('test/branch', 'SHA')
+ prefix = repository.archive_prefix('test/branch', 'SHA', append_sha: nil)
expect(prefix).to eq("#{project_name}-test-branch-SHA")
end
it 'returns correct string for a ref containing dots' do
- prefix = repository.archive_prefix('test.branch', 'SHA')
+ prefix = repository.archive_prefix('test.branch', 'SHA', append_sha: nil)
expect(prefix).to eq("#{project_name}-test.branch-SHA")
end
+
+ it 'returns string with sha when append_sha is false' do
+ prefix = repository.archive_prefix('test.branch', 'SHA', append_sha: false)
+
+ expect(prefix).to eq("#{project_name}-test.branch")
+ end
end
describe '#archive' do
- let(:metadata) { repository.archive_metadata('master', '/tmp') }
+ let(:metadata) { repository.archive_metadata('master', '/tmp', append_sha: true) }
it_should_behave_like 'archive check', '.tar.gz'
end
describe '#archive_zip' do
- let(:metadata) { repository.archive_metadata('master', '/tmp', 'zip') }
+ let(:metadata) { repository.archive_metadata('master', '/tmp', 'zip', append_sha: true) }
it_should_behave_like 'archive check', '.zip'
end
describe '#archive_bz2' do
- let(:metadata) { repository.archive_metadata('master', '/tmp', 'tbz2') }
+ let(:metadata) { repository.archive_metadata('master', '/tmp', 'tbz2', append_sha: true) }
it_should_behave_like 'archive check', '.tar.bz2'
end
describe '#archive_fallback' do
- let(:metadata) { repository.archive_metadata('master', '/tmp', 'madeup') }
+ let(:metadata) { repository.archive_metadata('master', '/tmp', 'madeup', append_sha: true) }
it_should_behave_like 'archive check', '.tar.gz'
end
@@ -441,6 +435,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
shared_examples 'simple commit counting' do
it { expect(repository.commit_count("master")).to eq(25) }
it { expect(repository.commit_count("feature")).to eq(9) }
+ it { expect(repository.commit_count("does-not-exist")).to eq(0) }
end
context 'when Gitaly commit_count feature is enabled' do
@@ -559,45 +554,83 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe '#delete_refs' do
- before(:all) do
- @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
+ shared_examples 'deleting refs' do
+ let(:repo) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') }
+
+ after do
+ ensure_seeds
+ end
+
+ it 'deletes the ref' do
+ repo.delete_refs('refs/heads/feature')
+
+ expect(repo.rugged.references['refs/heads/feature']).to be_nil
+ end
+
+ it 'deletes all refs' do
+ refs = %w[refs/heads/wip refs/tags/v1.1.0]
+ repo.delete_refs(*refs)
+
+ refs.each do |ref|
+ expect(repo.rugged.references[ref]).to be_nil
+ end
+ end
+
+ it 'raises an error if it failed' do
+ expect { repo.delete_refs('refs\heads\fix') }.to raise_error(Gitlab::Git::Repository::GitError)
+ end
end
- it 'deletes the ref' do
- @repo.delete_refs('refs/heads/feature')
+ context 'when Gitaly delete_refs feature is enabled' do
+ it_behaves_like 'deleting refs'
+ end
- expect(@repo.rugged.references['refs/heads/feature']).to be_nil
+ context 'when Gitaly delete_refs feature is disabled', :disable_gitaly do
+ it_behaves_like 'deleting refs'
end
+ end
+
+ describe '#branch_names_contains_sha' do
+ shared_examples 'returning the right branches' do
+ let(:head_id) { repository.rugged.head.target.oid }
+ let(:new_branch) { head_id }
+ let(:utf8_branch) { 'branch-é' }
- it 'deletes all refs' do
- refs = %w[refs/heads/wip refs/tags/v1.1.0]
- @repo.delete_refs(*refs)
+ before do
+ repository.create_branch(new_branch, 'master')
+ repository.create_branch(utf8_branch, 'master')
+ end
- refs.each do |ref|
- expect(@repo.rugged.references[ref]).to be_nil
+ after do
+ repository.delete_branch(new_branch)
+ repository.delete_branch(utf8_branch)
end
- end
- it 'raises an error if it failed' do
- expect(@repo).to receive(:popen).and_return(['Error', 1])
+ it 'displays that branch' do
+ expect(repository.branch_names_contains_sha(head_id)).to include('master', new_branch, utf8_branch)
+ end
+ end
- expect do
- @repo.delete_refs('refs/heads/fix')
- end.to raise_error(Gitlab::Git::Repository::GitError)
+ context 'when Gitaly is enabled' do
+ it_behaves_like 'returning the right branches'
end
- after(:all) do
- ensure_seeds
+ context 'when Gitaly is disabled', :disable_gitaly do
+ it_behaves_like 'returning the right branches'
end
end
describe "#refs_hash" do
- let(:refs) { repository.refs_hash }
+ subject { repository.refs_hash }
it "should have as many entries as branches and tags" do
expected_refs = SeedRepo::Repo::BRANCHES + SeedRepo::Repo::TAGS
# We flatten in case a commit is pointed at by more than one branch and/or tag
- expect(refs.values.flatten.size).to eq(expected_refs.size)
+ expect(subject.values.flatten.size).to eq(expected_refs.size)
+ end
+
+ it 'has valid commit ids as keys' do
+ expect(subject.keys).to all( match(Commit::COMMIT_SHA_PATTERN) )
end
end
@@ -641,42 +674,51 @@ describe Gitlab::Git::Repository, seed_helper: true do
subject { new_repository.fetch_repository_as_mirror(repository) }
before do
- Gitlab::Shell.new.add_repository('default', 'my_project')
+ Gitlab::Shell.new.create_repository('default', 'my_project')
end
after do
- Gitlab::Shell.new.remove_repository(TestEnv.repos_path, 'my_project')
+ Gitlab::Shell.new.remove_repository(storage_path, 'my_project')
end
- it 'fetches a repository as a mirror remote' do
- subject
+ shared_examples 'repository mirror fecthing' do
+ it 'fetches a repository as a mirror remote' do
+ subject
- expect(refs(new_repository.path)).to eq(refs(repository.path))
- end
+ expect(refs(new_repository.path)).to eq(refs(repository.path))
+ end
- context 'with keep-around refs' do
- let(:sha) { SeedRepo::Commit::ID }
- let(:keep_around_ref) { "refs/keep-around/#{sha}" }
- let(:tmp_ref) { "refs/tmp/#{SecureRandom.hex}" }
+ context 'with keep-around refs' do
+ let(:sha) { SeedRepo::Commit::ID }
+ let(:keep_around_ref) { "refs/keep-around/#{sha}" }
+ let(:tmp_ref) { "refs/tmp/#{SecureRandom.hex}" }
- before do
- repository.rugged.references.create(keep_around_ref, sha, force: true)
- repository.rugged.references.create(tmp_ref, sha, force: true)
- end
+ before do
+ repository.rugged.references.create(keep_around_ref, sha, force: true)
+ repository.rugged.references.create(tmp_ref, sha, force: true)
+ end
- it 'includes the temporary and keep-around refs' do
- subject
+ it 'includes the temporary and keep-around refs' do
+ subject
- expect(refs(new_repository.path)).to include(keep_around_ref)
- expect(refs(new_repository.path)).to include(tmp_ref)
+ expect(refs(new_repository.path)).to include(keep_around_ref)
+ expect(refs(new_repository.path)).to include(tmp_ref)
+ end
end
end
+
+ context 'with gitaly enabled' do
+ it_behaves_like 'repository mirror fecthing'
+ end
+
+ context 'with gitaly enabled', :skip_gitaly_mock do
+ it_behaves_like 'repository mirror fecthing'
+ end
end
describe '#remote_tags' do
let(:remote_name) { 'upstream' }
let(:target_commit_id) { SeedRepo::Commit::ID }
- let(:user) { create(:user) }
let(:tag_name) { 'v0.0.1' }
let(:tag_message) { 'My tag' }
let(:remote_repository) do
@@ -701,289 +743,264 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
- describe '#remote_exists?' do
- before(:all) do
- @repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
- @repo.add_remote("new_remote", SeedHelper::GITLAB_GIT_TEST_REPO_URL)
- end
-
- it 'returns true for an existing remote' do
- expect(@repo.remote_exists?('new_remote')).to eq(true)
- end
-
- it 'returns false for a non-existing remote' do
- expect(@repo.remote_exists?('foo')).to eq(false)
- end
- end
-
describe "#log" do
- 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_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
- # Erase our commits so other tests get the original repo
- repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged
- repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID)
- end
-
- context "where 'follow' == true" do
- let(:options) { { ref: "master", follow: true } }
+ shared_examples 'repository log' do
+ 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
- context "and 'path' is a directory" do
- it "does not follow renames" do
- log_commits = repository.log(options.merge(path: "encoding"))
+ 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_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
- aggregate_failures do
- expect(log_commits).to include(commit_with_new_name)
- expect(log_commits).to include(rename_commit)
- expect(log_commits).not_to include(commit_with_old_name)
- end
- end
+ after(:context) do
+ # Erase our commits so other tests get the original repo
+ repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged
+ repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID)
end
- context "and 'path' is a file that matches the new filename" do
- context 'without offset' do
- it "follows renames" do
- log_commits = repository.log(options.merge(path: "encoding/CHANGELOG"))
+ context "where 'follow' == true" do
+ let(:options) { { ref: "master", follow: true } }
+
+ context "and 'path' is a directory" do
+ it "does not follow renames" do
+ log_commits = repository.log(options.merge(path: "encoding"))
aggregate_failures do
expect(log_commits).to include(commit_with_new_name)
expect(log_commits).to include(rename_commit)
- expect(log_commits).to include(commit_with_old_name)
+ expect(log_commits).not_to include(commit_with_old_name)
end
end
end
- context 'with offset=1' do
- it "follows renames and skip the latest commit" do
- log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1))
+ context "and 'path' is a file that matches the new filename" do
+ context 'without offset' do
+ it "follows renames" do
+ log_commits = repository.log(options.merge(path: "encoding/CHANGELOG"))
- aggregate_failures do
- expect(log_commits).not_to include(commit_with_new_name)
- expect(log_commits).to include(rename_commit)
- expect(log_commits).to include(commit_with_old_name)
+ aggregate_failures do
+ expect(log_commits).to include(commit_with_new_name)
+ expect(log_commits).to include(rename_commit)
+ expect(log_commits).to include(commit_with_old_name)
+ end
end
end
- end
- context 'with offset=1', 'and limit=1' do
- it "follows renames, skip the latest commit and return only one commit" do
- log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 1))
+ context 'with offset=1' do
+ it "follows renames and skip the latest commit" do
+ log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1))
- expect(log_commits).to contain_exactly(rename_commit)
+ aggregate_failures do
+ expect(log_commits).not_to include(commit_with_new_name)
+ expect(log_commits).to include(rename_commit)
+ expect(log_commits).to include(commit_with_old_name)
+ end
+ end
end
- end
- context 'with offset=1', 'and limit=2' do
- it "follows renames, skip the latest commit and return only two commits" do
- log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 2))
+ context 'with offset=1', 'and limit=1' do
+ it "follows renames, skip the latest commit and return only one commit" do
+ log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 1))
- aggregate_failures do
- expect(log_commits).to contain_exactly(rename_commit, commit_with_old_name)
+ expect(log_commits).to contain_exactly(rename_commit)
end
end
- end
- context 'with offset=2' do
- it "follows renames and skip the latest commit" do
- log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2))
+ context 'with offset=1', 'and limit=2' do
+ it "follows renames, skip the latest commit and return only two commits" do
+ log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 2))
- aggregate_failures do
- expect(log_commits).not_to include(commit_with_new_name)
- expect(log_commits).not_to include(rename_commit)
- expect(log_commits).to include(commit_with_old_name)
+ aggregate_failures do
+ expect(log_commits).to contain_exactly(rename_commit, commit_with_old_name)
+ end
end
end
- end
- context 'with offset=2', 'and limit=1' do
- it "follows renames, skip the two latest commit and return only one commit" do
- log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 1))
+ context 'with offset=2' do
+ it "follows renames and skip the latest commit" do
+ log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2))
+
+ aggregate_failures do
+ expect(log_commits).not_to include(commit_with_new_name)
+ expect(log_commits).not_to include(rename_commit)
+ expect(log_commits).to include(commit_with_old_name)
+ end
+ end
+ end
+
+ context 'with offset=2', 'and limit=1' do
+ it "follows renames, skip the two latest commit and return only one commit" do
+ log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 1))
+
+ expect(log_commits).to contain_exactly(commit_with_old_name)
+ end
+ end
- expect(log_commits).to contain_exactly(commit_with_old_name)
+ context 'with offset=2', 'and limit=2' do
+ it "follows renames, skip the two latest commit and return only one commit" do
+ log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 2))
+
+ aggregate_failures do
+ expect(log_commits).not_to include(commit_with_new_name)
+ expect(log_commits).not_to include(rename_commit)
+ expect(log_commits).to include(commit_with_old_name)
+ end
+ end
end
end
- context 'with offset=2', 'and limit=2' do
- it "follows renames, skip the two latest commit and return only one commit" do
- log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 2))
+ context "and 'path' is a file that matches the old filename" do
+ it "does not follow renames" do
+ log_commits = repository.log(options.merge(path: "CHANGELOG"))
aggregate_failures do
expect(log_commits).not_to include(commit_with_new_name)
- expect(log_commits).not_to include(rename_commit)
+ expect(log_commits).to include(rename_commit)
expect(log_commits).to include(commit_with_old_name)
end
end
end
- end
- context "and 'path' is a file that matches the old filename" do
- it "does not follow renames" do
- log_commits = repository.log(options.merge(path: "CHANGELOG"))
+ context "unknown ref" do
+ it "returns an empty array" do
+ log_commits = repository.log(options.merge(ref: 'unknown'))
- aggregate_failures do
- expect(log_commits).not_to include(commit_with_new_name)
- expect(log_commits).to include(rename_commit)
- expect(log_commits).to include(commit_with_old_name)
+ expect(log_commits).to eq([])
end
end
end
- context "unknown ref" do
- it "returns an empty array" do
- log_commits = repository.log(options.merge(ref: 'unknown'))
-
- expect(log_commits).to eq([])
- end
- end
- end
-
- context "where 'follow' == false" do
- options = { follow: false }
+ context "where 'follow' == false" do
+ options = { follow: false }
- context "and 'path' is a directory" do
- let(:log_commits) do
- repository.log(options.merge(path: "encoding"))
- end
+ context "and 'path' is a directory" do
+ let(:log_commits) do
+ repository.log(options.merge(path: "encoding"))
+ end
- it "should not follow renames" do
- expect(log_commits).to include(commit_with_new_name)
- expect(log_commits).to include(rename_commit)
- expect(log_commits).not_to include(commit_with_old_name)
+ it "does not follow renames" do
+ expect(log_commits).to include(commit_with_new_name)
+ expect(log_commits).to include(rename_commit)
+ expect(log_commits).not_to include(commit_with_old_name)
+ end
end
- end
- context "and 'path' is a file that matches the new filename" do
- let(:log_commits) do
- repository.log(options.merge(path: "encoding/CHANGELOG"))
- end
+ context "and 'path' is a file that matches the new filename" do
+ let(:log_commits) do
+ repository.log(options.merge(path: "encoding/CHANGELOG"))
+ end
- it "should not follow renames" do
- expect(log_commits).to include(commit_with_new_name)
- expect(log_commits).to include(rename_commit)
- expect(log_commits).not_to include(commit_with_old_name)
+ it "does not follow renames" do
+ expect(log_commits).to include(commit_with_new_name)
+ expect(log_commits).to include(rename_commit)
+ expect(log_commits).not_to include(commit_with_old_name)
+ end
end
- end
- context "and 'path' is a file that matches the old filename" do
- let(:log_commits) do
- repository.log(options.merge(path: "CHANGELOG"))
- end
+ context "and 'path' is a file that matches the old filename" do
+ let(:log_commits) do
+ repository.log(options.merge(path: "CHANGELOG"))
+ end
- it "should not follow renames" do
- expect(log_commits).to include(commit_with_old_name)
- expect(log_commits).to include(rename_commit)
- expect(log_commits).not_to include(commit_with_new_name)
+ it "does not follow renames" do
+ expect(log_commits).to include(commit_with_old_name)
+ expect(log_commits).to include(rename_commit)
+ expect(log_commits).not_to include(commit_with_new_name)
+ end
end
- end
- context "and 'path' includes a directory that used to be a file" do
- let(:log_commits) do
- repository.log(options.merge(ref: "refs/heads/fix-blob-path", path: "files/testdir/file.txt"))
- end
+ context "and 'path' includes a directory that used to be a file" do
+ let(:log_commits) do
+ repository.log(options.merge(ref: "refs/heads/fix-blob-path", path: "files/testdir/file.txt"))
+ end
- it "should return a list of commits" do
- expect(log_commits.size).to eq(1)
+ it "returns a list of commits" do
+ expect(log_commits.size).to eq(1)
+ end
end
end
- end
- context "compare results between log_by_walk and log_by_shell" do
- let(:options) { { ref: "master" } }
- let(:commits_by_walk) { repository.log(options).map(&:id) }
- let(:commits_by_shell) { repository.log(options.merge({ disable_walk: true })).map(&:id) }
+ context "where provides 'after' timestamp" do
+ options = { after: Time.iso8601('2014-03-03T20:15:01+00:00') }
- it { expect(commits_by_walk).to eq(commits_by_shell) }
+ it "should returns commits on or after that timestamp" do
+ commits = repository.log(options)
- context "with limit" do
- let(:options) { { ref: "master", limit: 1 } }
-
- it { expect(commits_by_walk).to eq(commits_by_shell) }
+ expect(commits.size).to be > 0
+ expect(commits).to satisfy do |commits|
+ commits.all? { |commit| commit.committed_date >= options[:after] }
+ end
+ end
end
- context "with offset" do
- let(:options) { { ref: "master", offset: 1 } }
-
- it { expect(commits_by_walk).to eq(commits_by_shell) }
- end
+ context "where provides 'before' timestamp" do
+ options = { before: Time.iso8601('2014-03-03T20:15:01+00:00') }
- context "with skip_merges" do
- let(:options) { { ref: "master", skip_merges: true } }
+ it "should returns commits on or before that timestamp" do
+ commits = repository.log(options)
- it { expect(commits_by_walk).to eq(commits_by_shell) }
+ expect(commits.size).to be > 0
+ expect(commits).to satisfy do |commits|
+ commits.all? { |commit| commit.committed_date <= options[:before] }
+ end
+ end
end
- context "with path" do
- let(:options) { { ref: "master", path: "encoding" } }
-
- it { expect(commits_by_walk).to eq(commits_by_shell) }
+ context 'when multiple paths are provided' do
+ let(:options) { { ref: 'master', path: ['PROCESS.md', 'README.md'] } }
- context "with follow" do
- let(:options) { { ref: "master", path: "encoding", follow: true } }
-
- it { expect(commits_by_walk).to eq(commits_by_shell) }
+ def commit_files(commit)
+ commit.rugged_diff_from_parent.deltas.flat_map do |delta|
+ [delta.old_file[:path], delta.new_file[:path]].uniq.compact
+ end
end
- end
- end
- context "where provides 'after' timestamp" do
- options = { after: Time.iso8601('2014-03-03T20:15:01+00:00') }
+ it 'only returns commits matching at least one path' do
+ commits = repository.log(options)
- it "should returns commits on or after that timestamp" do
- commits = repository.log(options)
-
- expect(commits.size).to be > 0
- expect(commits).to satisfy do |commits|
- commits.all? { |commit| commit.committed_date >= options[:after] }
+ expect(commits.size).to be > 0
+ expect(commits).to satisfy do |commits|
+ commits.none? { |commit| (commit_files(commit) & options[:path]).empty? }
+ end
end
end
- end
- context "where provides 'before' timestamp" do
- options = { before: Time.iso8601('2014-03-03T20:15:01+00:00') }
-
- it "should returns commits on or before that timestamp" do
- commits = repository.log(options)
+ context 'limit validation' do
+ where(:limit) do
+ [0, nil, '', 'foo']
+ end
- expect(commits.size).to be > 0
- expect(commits).to satisfy do |commits|
- commits.all? { |commit| commit.committed_date <= options[:before] }
+ with_them do
+ it { expect { repository.log(limit: limit) }.to raise_error(ArgumentError) }
end
end
- end
- context 'when multiple paths are provided' do
- let(:options) { { ref: 'master', path: ['PROCESS.md', 'README.md'] } }
+ context 'with all' do
+ it 'returns a list of commits' do
+ commits = repository.log({ all: true, limit: 50 })
- def commit_files(commit)
- commit.rugged_diff_from_parent.deltas.flat_map do |delta|
- [delta.old_file[:path], delta.new_file[:path]].uniq.compact
+ expect(commits.size).to eq(37)
end
end
+ end
- it 'only returns commits matching at least one path' do
- commits = repository.log(options)
+ context 'when Gitaly find_commits feature is enabled' do
+ it_behaves_like 'repository log'
+ end
- expect(commits.size).to be > 0
- expect(commits).to satisfy do |commits|
- commits.none? { |commit| (commit_files(commit) & options[:path]).empty? }
- end
- end
+ context 'when Gitaly find_commits feature is disabled', :disable_gitaly do
+ it_behaves_like 'repository log'
end
end
@@ -1026,11 +1043,34 @@ describe Gitlab::Git::Repository, seed_helper: true do
it { is_expected.to eq(17) }
end
+ describe '#merge_base' do
+ shared_examples '#merge_base' do
+ where(:from, :to, :result) do
+ '570e7b2abdd848b95f2f578043fc23bd6f6fd24d' | '40f4a7a617393735a95a0bb67b08385bc1e7c66d' | '570e7b2abdd848b95f2f578043fc23bd6f6fd24d'
+ '40f4a7a617393735a95a0bb67b08385bc1e7c66d' | '570e7b2abdd848b95f2f578043fc23bd6f6fd24d' | '570e7b2abdd848b95f2f578043fc23bd6f6fd24d'
+ '40f4a7a617393735a95a0bb67b08385bc1e7c66d' | 'foobar' | nil
+ 'foobar' | '40f4a7a617393735a95a0bb67b08385bc1e7c66d' | nil
+ end
+
+ with_them do
+ it { expect(repository.merge_base(from, to)).to eq(result) }
+ end
+ end
+
+ context 'with gitaly' do
+ it_behaves_like '#merge_base'
+ end
+
+ context 'without gitaly', :skip_gitaly_mock do
+ it_behaves_like '#merge_base'
+ end
+ end
+
describe '#count_commits' do
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') }
+ options = { ref: 'master', after: Time.iso8601('2013-03-03T20:15:01+00:00') }
expect(repository.count_commits(options)).to eq(25)
end
@@ -1038,38 +1078,90 @@ describe Gitlab::Git::Repository, seed_helper: true do
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') }
+ options = { ref: 'feature', before: Time.iso8601('2015-03-03T20:15:01+00:00') }
expect(repository.count_commits(options)).to eq(9)
end
end
+ context 'with max_count' do
+ it 'returns the number of commits with path ' do
+ options = { ref: 'master', max_count: 5 }
+
+ expect(repository.count_commits(options)).to eq(5)
+ end
+ end
+
context 'with path' do
it 'returns the number of commits with path ' do
- options = { ref: 'master', limit: nil, path: "encoding" }
+ options = { ref: 'master', path: 'encoding' }
expect(repository.count_commits(options)).to eq(2)
end
end
+
+ context 'with option :from and option :to' do
+ it 'returns the number of commits ahead for fix-mode..fix-blob-path' do
+ options = { from: 'fix-mode', to: 'fix-blob-path' }
+
+ expect(repository.count_commits(options)).to eq(2)
+ end
+
+ it 'returns the number of commits ahead for fix-blob-path..fix-mode' do
+ options = { from: 'fix-blob-path', to: 'fix-mode' }
+
+ expect(repository.count_commits(options)).to eq(1)
+ end
+
+ context 'with option :left_right' do
+ it 'returns the number of commits for fix-mode...fix-blob-path' do
+ options = { from: 'fix-mode', to: 'fix-blob-path', left_right: true }
+
+ expect(repository.count_commits(options)).to eq([1, 2])
+ end
+
+ context 'with max_count' do
+ it 'returns the number of commits with path ' do
+ options = { from: 'fix-mode', to: 'fix-blob-path', left_right: true, max_count: 1 }
+
+ expect(repository.count_commits(options)).to eq([1, 1])
+ end
+ end
+ end
+ end
+
+ context 'with max_count' do
+ it 'returns the number of commits up to the passed limit' do
+ options = { ref: 'master', max_count: 10, after: Time.iso8601('2013-03-03T20:15:01+00:00') }
+
+ expect(repository.count_commits(options)).to eq(10)
+ end
+ end
+
+ context "with all" do
+ it "returns the number of commits in the whole repository" do
+ options = { all: true }
+
+ expect(repository.count_commits(options)).to eq(34)
+ end
+ end
+
+ context 'without all or ref being specified' do
+ it "raises an ArgumentError" do
+ expect { repository.count_commits({}) }.to raise_error(ArgumentError)
+ 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 do
+ context 'when Gitaly count_commits feature is disabled', :disable_gitaly do
it_behaves_like 'extended commit counting'
end
end
- describe "branch_names_contains" do
- subject { repository.branch_names_contains(SeedRepo::LastCommit::ID) }
-
- it { is_expected.to include('master') }
- it { is_expected.not_to include('feature') }
- it { is_expected.not_to include('fix') }
- end
-
describe '#autocrlf' do
before(:all) do
@repo = Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
@@ -1127,14 +1219,27 @@ describe Gitlab::Git::Repository, seed_helper: true do
context 'when Gitaly find_branch feature is disabled', :skip_gitaly_mock do
it_behaves_like 'finding a branch'
- it 'should reload Rugged::Repository and return master' do
- expect(Rugged::Repository).to receive(:new).twice.and_call_original
+ context 'force_reload is true' do
+ it 'should reload Rugged::Repository' do
+ expect(Rugged::Repository).to receive(:new).twice.and_call_original
- repository.find_branch('master')
- branch = repository.find_branch('master', force_reload: true)
+ repository.find_branch('master')
+ branch = repository.find_branch('master', force_reload: true)
- expect(branch).to be_a_kind_of(Gitlab::Git::Branch)
- expect(branch.name).to eq('master')
+ expect(branch).to be_a_kind_of(Gitlab::Git::Branch)
+ expect(branch.name).to eq('master')
+ end
+ end
+
+ context 'force_reload is false' do
+ it 'should not reload Rugged::Repository' do
+ expect(Rugged::Repository).to receive(:new).once.and_call_original
+
+ branch = repository.find_branch('master', force_reload: false)
+
+ expect(branch).to be_a_kind_of(Gitlab::Git::Branch)
+ expect(branch.name).to eq('master')
+ end
end
end
end
@@ -1241,48 +1346,58 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe '#merged_branch_names' do
- context 'when branch names are passed' do
- it 'only returns the names we are asking' do
- names = repository.merged_branch_names(%w[merge-test])
+ shared_examples 'finding merged branch names' do
+ context 'when branch names are passed' do
+ it 'only returns the names we are asking' do
+ names = repository.merged_branch_names(%w[merge-test])
- expect(names).to contain_exactly('merge-test')
- end
+ expect(names).to contain_exactly('merge-test')
+ end
- it 'does not return unmerged branch names' do
- names = repository.merged_branch_names(%w[feature])
+ it 'does not return unmerged branch names' do
+ names = repository.merged_branch_names(%w[feature])
- expect(names).to be_empty
+ expect(names).to be_empty
+ end
end
- end
- context 'when no root ref is available' do
- it 'returns empty list' do
- project = create(:project, :empty_repo)
+ context 'when no root ref is available' do
+ it 'returns empty list' do
+ project = create(:project, :empty_repo)
- names = project.repository.merged_branch_names(%w[feature])
+ names = project.repository.merged_branch_names(%w[feature])
- expect(names).to be_empty
+ expect(names).to be_empty
+ end
end
- end
- context 'when no branch names are specified' do
- before do
- repository.create_branch('identical', 'master')
- end
+ context 'when no branch names are specified' do
+ before do
+ repository.create_branch('identical', 'master')
+ end
- after do
- ensure_seeds
- end
+ after do
+ ensure_seeds
+ end
- it 'returns all merged branch names except for identical one' do
- names = repository.merged_branch_names
+ it 'returns all merged branch names except for identical one' do
+ names = repository.merged_branch_names
- expect(names).to include('merge-test')
- expect(names).to include('fix-mode')
- expect(names).not_to include('feature')
- expect(names).not_to include('identical')
+ expect(names).to include('merge-test')
+ expect(names).to include('fix-mode')
+ expect(names).not_to include('feature')
+ expect(names).not_to include('identical')
+ end
end
end
+
+ context 'when Gitaly merged_branch_names feature is enabled' do
+ it_behaves_like 'finding merged branch names'
+ end
+
+ context 'when Gitaly merged_branch_names feature is disabled', :disable_gitaly do
+ it_behaves_like 'finding merged branch names'
+ end
end
describe "#ls_files" do
@@ -1316,79 +1431,95 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe "#copy_gitattributes" do
- let(:attributes_path) { File.join(SEED_STORAGE_PATH, TEST_REPO_PATH, 'info/attributes') }
+ shared_examples 'applying git attributes' do
+ let(:attributes_path) { File.join(SEED_STORAGE_PATH, TEST_REPO_PATH, 'info/attributes') }
- it "raises an error with invalid ref" do
- expect { repository.copy_gitattributes("invalid") }.to raise_error(Gitlab::Git::Repository::InvalidRef)
- end
-
- context "with no .gitattrbutes" do
- before do
- repository.copy_gitattributes("master")
+ after do
+ FileUtils.rm_rf(attributes_path) if Dir.exist?(attributes_path)
end
- it "does not have an info/attributes" do
- expect(File.exist?(attributes_path)).to be_falsey
+ it "raises an error with invalid ref" do
+ expect { repository.copy_gitattributes("invalid") }.to raise_error(Gitlab::Git::Repository::InvalidRef)
end
- after do
- FileUtils.rm_rf(attributes_path)
- end
- end
+ context 'when forcing encoding issues' do
+ let(:branch_name) { "ʕ•ᴥ•ʔ" }
- context "with .gitattrbutes" do
- before do
- repository.copy_gitattributes("gitattributes")
- end
+ before do
+ repository.create_branch(branch_name, "master")
+ end
- it "has an info/attributes" do
- expect(File.exist?(attributes_path)).to be_truthy
- end
+ after do
+ repository.rm_branch(branch_name, user: build(:admin))
+ end
- it "has the same content in info/attributes as .gitattributes" do
- contents = File.open(attributes_path, "rb") { |f| f.read }
- expect(contents).to eq("*.md binary\n")
- end
+ it "doesn't raise with a valid unicode ref" do
+ expect { repository.copy_gitattributes(branch_name) }.not_to raise_error
- after do
- FileUtils.rm_rf(attributes_path)
+ repository
+ end
end
- end
- context "with updated .gitattrbutes" do
- before do
- repository.copy_gitattributes("gitattributes")
- repository.copy_gitattributes("gitattributes-updated")
- end
+ context "with no .gitattrbutes" do
+ before do
+ repository.copy_gitattributes("master")
+ end
- it "has an info/attributes" do
- expect(File.exist?(attributes_path)).to be_truthy
+ it "does not have an info/attributes" do
+ expect(File.exist?(attributes_path)).to be_falsey
+ end
end
- it "has the updated content in info/attributes" do
- contents = File.read(attributes_path)
- expect(contents).to eq("*.txt binary\n")
- end
+ context "with .gitattrbutes" do
+ before do
+ repository.copy_gitattributes("gitattributes")
+ end
- after do
- FileUtils.rm_rf(attributes_path)
- end
- end
+ it "has an info/attributes" do
+ expect(File.exist?(attributes_path)).to be_truthy
+ end
- context "with no .gitattrbutes in HEAD but with previous info/attributes" do
- before do
- repository.copy_gitattributes("gitattributes")
- repository.copy_gitattributes("master")
+ it "has the same content in info/attributes as .gitattributes" do
+ contents = File.open(attributes_path, "rb") { |f| f.read }
+ expect(contents).to eq("*.md binary\n")
+ end
end
- it "does not have an info/attributes" do
- expect(File.exist?(attributes_path)).to be_falsey
+ context "with updated .gitattrbutes" do
+ before do
+ repository.copy_gitattributes("gitattributes")
+ repository.copy_gitattributes("gitattributes-updated")
+ end
+
+ it "has an info/attributes" do
+ expect(File.exist?(attributes_path)).to be_truthy
+ end
+
+ it "has the updated content in info/attributes" do
+ contents = File.read(attributes_path)
+ expect(contents).to eq("*.txt binary\n")
+ end
end
- after do
- FileUtils.rm_rf(attributes_path)
+ context "with no .gitattrbutes in HEAD but with previous info/attributes" do
+ before do
+ repository.copy_gitattributes("gitattributes")
+ repository.copy_gitattributes("master")
+ end
+
+ it "does not have an info/attributes" do
+ expect(File.exist?(attributes_path)).to be_falsey
+ end
end
end
+
+ context 'when gitaly is enabled' do
+ it_behaves_like 'applying git attributes'
+ end
+
+ context 'when gitaly is disabled', :disable_gitaly do
+ it_behaves_like 'applying git attributes'
+ end
end
describe '#ref_exists?' do
@@ -1527,7 +1658,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
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: 7.9, label: "HTML", color: "#e34c26", highlight: "#e34c26" },
{ value: 2.51, label: "CoffeeScript", color: "#244776", highlight: "#244776" }
]
@@ -1559,6 +1690,35 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
+ describe '#license_short_name' do
+ shared_examples 'acquiring the Licensee license key' do
+ subject { repository.license_short_name }
+
+ context 'when no license file can be found' do
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository.raw_repository }
+
+ before do
+ project.repository.delete_file(project.owner, 'LICENSE', message: 'remove license', branch_name: 'master')
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when an mit license is found' do
+ it { is_expected.to eq('mit') }
+ end
+ end
+
+ context 'when gitaly is enabled' do
+ it_behaves_like 'acquiring the Licensee license key'
+ end
+
+ context 'when gitaly is disabled', :disable_gitaly do
+ it_behaves_like 'acquiring the Licensee license key'
+ end
+ end
+
describe '#with_repo_branch_commit' do
context 'when comparing with the same repository' do
let(:start_repository) { repository }
@@ -1666,11 +1826,10 @@ describe Gitlab::Git::Repository, seed_helper: true do
shared_examples "user deleting a branch" do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository.raw }
- let(:user) { create(:user) }
let(:branch_name) { "to-be-deleted-soon" }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
repository.create_branch(branch_name)
end
@@ -1707,12 +1866,49 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
+ describe '#write_config' do
+ before do
+ repository.rugged.config["gitlab.fullpath"] = repository.path
+ end
+
+ shared_examples 'writing repo config' do
+ context 'is given a path' do
+ it 'writes it to disk' do
+ repository.write_config(full_path: "not-the/real-path.git")
+
+ config = File.read(File.join(repository.path, "config"))
+
+ expect(config).to include("[gitlab]")
+ expect(config).to include("fullpath = not-the/real-path.git")
+ end
+ end
+
+ context 'it is given an empty path' do
+ it 'does not write it to disk' do
+ repository.write_config(full_path: "")
+
+ config = File.read(File.join(repository.path, "config"))
+
+ expect(config).to include("[gitlab]")
+ expect(config).to include("fullpath = #{repository.path}")
+ end
+ end
+ end
+
+ context "when gitaly_write_config is enabled" do
+ it_behaves_like "writing repo config"
+ end
+
+ context "when gitaly_write_config is disabled", :disable_gitaly do
+ it_behaves_like "writing repo config"
+ end
+ end
+
describe '#merge' do
let(:repository) do
Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
end
let(:source_sha) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' }
- let(:user) { build(:user) }
let(:target_branch) { 'test-merge-target-branch' }
before do
@@ -1734,6 +1930,20 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect(result.repo_created).to eq(false)
expect(result.branch_created).to eq(false)
end
+
+ it 'returns nil if there was a concurrent branch update' do
+ concurrent_update_id = '33f3729a45c02fc67d00adb1b8bca394b0e761d9'
+ result = repository.merge(user, source_sha, target_branch, 'Test merge') do
+ # This ref update should make the merge fail
+ repository.write_ref(Gitlab::Git::BRANCH_REF_PREFIX + target_branch, concurrent_update_id)
+ end
+
+ # This 'nil' signals that the merge was not applied
+ expect(result).to be_nil
+
+ # Our concurrent ref update should not have been undone
+ expect(repository.find_branch(target_branch).target).to eq(concurrent_update_id)
+ end
end
context 'with gitaly' do
@@ -1751,7 +1961,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
let(:branch_head) { '6d394385cf567f80a8fd85055db1ab4c5295806f' }
let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' }
- let(:user) { build(:user) }
let(:target_branch) { 'test-ff-target-branch' }
before do
@@ -1843,6 +2052,406 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
+ describe 'remotes' do
+ let(:repository) do
+ Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
+ end
+ let(:remote_name) { 'my-remote' }
+
+ after do
+ ensure_seeds
+ end
+
+ describe '#add_remote' do
+ let(:url) { 'http://my-repo.git' }
+ let(:mirror_refmap) { '+refs/*:refs/*' }
+
+ it 'creates a new remote via Gitaly' do
+ expect_any_instance_of(Gitlab::GitalyClient::RemoteService)
+ .to receive(:add_remote).with(remote_name, url, mirror_refmap)
+
+ repository.add_remote(remote_name, url, mirror_refmap: mirror_refmap)
+ end
+
+ context 'with Gitaly disabled', :skip_gitaly_mock do
+ it 'creates a new remote via Rugged' do
+ expect_any_instance_of(Rugged::RemoteCollection).to receive(:create)
+ .with(remote_name, url)
+ expect_any_instance_of(Rugged::Config).to receive(:[]=)
+ .with("remote.#{remote_name}.mirror", true)
+ expect_any_instance_of(Rugged::Config).to receive(:[]=)
+ .with("remote.#{remote_name}.prune", true)
+ expect_any_instance_of(Rugged::Config).to receive(:[]=)
+ .with("remote.#{remote_name}.fetch", mirror_refmap)
+
+ repository.add_remote(remote_name, url, mirror_refmap: mirror_refmap)
+ end
+ end
+ end
+
+ describe '#remove_remote' do
+ it 'removes the remote via Gitaly' do
+ expect_any_instance_of(Gitlab::GitalyClient::RemoteService)
+ .to receive(:remove_remote).with(remote_name)
+
+ repository.remove_remote(remote_name)
+ end
+
+ context 'with Gitaly disabled', :skip_gitaly_mock do
+ it 'removes the remote via Rugged' do
+ expect_any_instance_of(Rugged::RemoteCollection).to receive(:delete)
+ .with(remote_name)
+
+ repository.remove_remote(remote_name)
+ end
+ end
+ end
+ end
+
+ describe '#gitlab_projects' do
+ subject { repository.gitlab_projects }
+
+ it { expect(subject.shard_path).to eq(storage_path) }
+ it { expect(subject.repository_relative_path).to eq(repository.relative_path) }
+ end
+
+ describe '#bundle_to_disk' do
+ shared_examples 'bundling to disk' do
+ let(:save_path) { File.join(Dir.tmpdir, "repo-#{SecureRandom.hex}.bundle") }
+
+ after do
+ FileUtils.rm_rf(save_path)
+ end
+
+ it 'saves a bundle to disk' do
+ repository.bundle_to_disk(save_path)
+
+ success = system(
+ *%W(#{Gitlab.config.git.bin_path} -C #{repository.path} bundle verify #{save_path}),
+ [:out, :err] => '/dev/null'
+ )
+ expect(success).to be true
+ end
+ end
+
+ context 'when Gitaly bundle_to_disk feature is enabled' do
+ it_behaves_like 'bundling to disk'
+ end
+
+ context 'when Gitaly bundle_to_disk feature is disabled', :disable_gitaly do
+ it_behaves_like 'bundling to disk'
+ end
+ end
+
+ describe '#create_from_bundle' do
+ shared_examples 'creating repo from bundle' do
+ let(:bundle_path) { File.join(Dir.tmpdir, "repo-#{SecureRandom.hex}.bundle") }
+ let(:project) { create(:project) }
+ let(:imported_repo) { project.repository.raw }
+
+ before do
+ expect(repository.bundle_to_disk(bundle_path)).to be true
+ end
+
+ after do
+ FileUtils.rm_rf(bundle_path)
+ end
+
+ it 'creates a repo from a bundle file' do
+ expect(imported_repo).not_to exist
+
+ result = imported_repo.create_from_bundle(bundle_path)
+
+ expect(result).to be true
+ expect(imported_repo).to exist
+ expect { imported_repo.fsck }.not_to raise_exception
+ end
+
+ it 'creates a symlink to the global hooks dir' do
+ imported_repo.create_from_bundle(bundle_path)
+ hooks_path = File.join(imported_repo.path, 'hooks')
+
+ expect(File.readlink(hooks_path)).to eq(Gitlab.config.gitlab_shell.hooks_path)
+ end
+ end
+
+ context 'when Gitaly create_repo_from_bundle feature is enabled' do
+ it_behaves_like 'creating repo from bundle'
+ end
+
+ context 'when Gitaly create_repo_from_bundle feature is disabled', :disable_gitaly do
+ it_behaves_like 'creating repo from bundle'
+ end
+ end
+
+ describe '#checksum' do
+ shared_examples 'calculating checksum' do
+ it 'calculates the checksum for non-empty repo' do
+ expect(repository.checksum).to eq '54f21be4c32c02f6788d72207fa03ad3bce725e4'
+ end
+
+ it 'returns 0000000000000000000000000000000000000000 for an empty repo' do
+ FileUtils.rm_rf(File.join(storage_path, 'empty-repo.git'))
+
+ system(git_env, *%W(#{Gitlab.config.git.bin_path} init --bare empty-repo.git),
+ chdir: storage_path,
+ out: '/dev/null',
+ err: '/dev/null')
+
+ empty_repo = described_class.new('default', 'empty-repo.git', '')
+
+ expect(empty_repo.checksum).to eq '0000000000000000000000000000000000000000'
+ 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.checksum }.to raise_error(Gitlab::Git::Repository::NoRepository)
+ end
+ end
+
+ context 'when calculate_checksum Gitaly feature is enabled' do
+ it_behaves_like 'calculating checksum'
+ end
+
+ context 'when calculate_checksum Gitaly feature is disabled', :disable_gitaly do
+ it_behaves_like 'calculating checksum'
+
+ describe 'when storage is broken', :broken_storage 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 Gitlab::Git::Repository::Failure error if the `popen` call to git returns a non-zero exit code" do
+ allow(repository).to receive(:popen).and_return(['output', nil])
+
+ expect { repository.checksum }.to raise_error Gitlab::Git::Repository::ChecksumError
+ end
+ end
+ end
+
+ context 'gitlab_projects commands' do
+ let(:gitlab_projects) { repository.gitlab_projects }
+ let(:timeout) { Gitlab.config.gitlab_shell.git_timeout }
+
+ describe '#push_remote_branches' do
+ subject do
+ repository.push_remote_branches('downstream-remote', ['master'])
+ end
+
+ it 'executes the command' do
+ expect(gitlab_projects).to receive(:push_branches)
+ .with('downstream-remote', timeout, true, ['master'])
+ .and_return(true)
+
+ is_expected.to be_truthy
+ end
+
+ it 'raises an error if the command fails' do
+ allow(gitlab_projects).to receive(:output) { 'error' }
+ expect(gitlab_projects).to receive(:push_branches)
+ .with('downstream-remote', timeout, true, ['master'])
+ .and_return(false)
+
+ expect { subject }.to raise_error(Gitlab::Git::CommandError, 'error')
+ end
+ end
+
+ describe '#delete_remote_branches' do
+ subject do
+ repository.delete_remote_branches('downstream-remote', ['master'])
+ end
+
+ it 'executes the command' do
+ expect(gitlab_projects).to receive(:delete_remote_branches)
+ .with('downstream-remote', ['master'])
+ .and_return(true)
+
+ is_expected.to be_truthy
+ end
+
+ it 'raises an error if the command fails' do
+ allow(gitlab_projects).to receive(:output) { 'error' }
+ expect(gitlab_projects).to receive(:delete_remote_branches)
+ .with('downstream-remote', ['master'])
+ .and_return(false)
+
+ expect { subject }.to raise_error(Gitlab::Git::CommandError, 'error')
+ end
+ end
+
+ describe '#delete_remote_branches' do
+ subject do
+ repository.delete_remote_branches('downstream-remote', ['master'])
+ end
+
+ it 'executes the command' do
+ expect(gitlab_projects).to receive(:delete_remote_branches)
+ .with('downstream-remote', ['master'])
+ .and_return(true)
+
+ is_expected.to be_truthy
+ end
+
+ it 'raises an error if the command fails' do
+ allow(gitlab_projects).to receive(:output) { 'error' }
+ expect(gitlab_projects).to receive(:delete_remote_branches)
+ .with('downstream-remote', ['master'])
+ .and_return(false)
+
+ expect { subject }.to raise_error(Gitlab::Git::CommandError, 'error')
+ end
+ end
+
+ describe '#clean_stale_repository_files' do
+ let(:worktree_path) { File.join(repository.path, 'worktrees', 'delete-me') }
+
+ it 'cleans up the files' do
+ repository.with_worktree(worktree_path, 'master', env: ENV) do
+ FileUtils.touch(worktree_path, mtime: Time.now - 8.hours)
+ # git rev-list --all will fail in git 2.16 if HEAD is pointing to a non-existent object,
+ # but the HEAD must be 40 characters long or git will ignore it.
+ File.write(File.join(worktree_path, 'HEAD'), Gitlab::Git::BLANK_SHA)
+
+ # git 2.16 fails with "fatal: bad object HEAD"
+ expect { repository.rev_list(including: :all) }.to raise_error(Gitlab::Git::Repository::GitError)
+
+ repository.clean_stale_repository_files
+
+ expect { repository.rev_list(including: :all) }.not_to raise_error
+ expect(File.exist?(worktree_path)).to be_falsey
+ end
+ end
+
+ it 'increments a counter upon an error' do
+ expect(repository.gitaly_repository_client).to receive(:cleanup).and_raise(Gitlab::Git::CommandError)
+
+ counter = double(:counter)
+
+ expect(counter).to receive(:increment)
+ expect(Gitlab::Metrics).to receive(:counter).with(:failed_repository_cleanup_total,
+ 'Number of failed repository cleanup events').and_return(counter)
+
+ repository.clean_stale_repository_files
+ end
+ end
+
+ describe '#delete_remote_branches' do
+ subject do
+ repository.delete_remote_branches('downstream-remote', ['master'])
+ end
+
+ it 'executes the command' do
+ expect(gitlab_projects).to receive(:delete_remote_branches)
+ .with('downstream-remote', ['master'])
+ .and_return(true)
+
+ is_expected.to be_truthy
+ end
+
+ it 'raises an error if the command fails' do
+ allow(gitlab_projects).to receive(:output) { 'error' }
+ expect(gitlab_projects).to receive(:delete_remote_branches)
+ .with('downstream-remote', ['master'])
+ .and_return(false)
+
+ expect { subject }.to raise_error(Gitlab::Git::CommandError, 'error')
+ end
+ end
+
+ describe '#squash' do
+ let(:squash_id) { '1' }
+ let(:branch_name) { 'fix' }
+ let(:start_sha) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' }
+ let(:end_sha) { '12d65c8dd2b2676fa3ac47d955accc085a37a9c1' }
+
+ subject do
+ opts = {
+ branch: branch_name,
+ start_sha: start_sha,
+ end_sha: end_sha,
+ author: user,
+ message: 'Squash commit message'
+ }
+
+ repository.squash(user, squash_id, opts)
+ end
+
+ context 'sparse checkout', :skip_gitaly_mock do
+ let(:expected_files) { %w(files files/js files/js/application.js) }
+
+ it 'checks out only the files in the diff' do
+ allow(repository).to receive(:with_worktree).and_wrap_original do |m, *args|
+ m.call(*args) do
+ worktree_path = args[0]
+ files_pattern = File.join(worktree_path, '**', '*')
+ expected = expected_files.map do |path|
+ File.expand_path(path, worktree_path)
+ end
+
+ expect(Dir[files_pattern]).to eq(expected)
+ end
+ end
+
+ subject
+ end
+
+ context 'when the diff contains a rename' do
+ let(:repo) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged }
+ let(:end_sha) { new_commit_move_file(repo).oid }
+
+ after do
+ # Erase our commits so other tests get the original repo
+ repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged
+ repo.references.update('refs/heads/master', SeedRepo::LastCommit::ID)
+ end
+
+ it 'does not include the renamed file in the sparse checkout' do
+ allow(repository).to receive(:with_worktree).and_wrap_original do |m, *args|
+ m.call(*args) do
+ worktree_path = args[0]
+ files_pattern = File.join(worktree_path, '**', '*')
+
+ expect(Dir[files_pattern]).not_to include('CHANGELOG')
+ expect(Dir[files_pattern]).not_to include('encoding/CHANGELOG')
+ end
+ end
+
+ subject
+ end
+ end
+ end
+
+ context 'with an ASCII-8BIT diff', :skip_gitaly_mock do
+ let(:diff) { "diff --git a/README.md b/README.md\nindex faaf198..43c5edf 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,4 @@\n-testme\n+✓ testme\n ======\n \n Sample repo for testing gitlab features\n" }
+
+ it 'applies a ASCII-8BIT diff' do
+ allow(repository).to receive(:run_git!).and_call_original
+ allow(repository).to receive(:run_git!).with(%W(diff --binary #{start_sha}...#{end_sha})).and_return(diff.force_encoding('ASCII-8BIT'))
+
+ expect(subject).to match(/\h{40}/)
+ end
+ end
+
+ context 'with trailing whitespace in an invalid patch', :skip_gitaly_mock do
+ let(:diff) { "diff --git a/README.md b/README.md\nindex faaf198..43c5edf 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,4 @@\n-testme\n+ \n ====== \n \n Sample repo for testing gitlab features\n" }
+
+ it 'does not include whitespace warnings in the error' do
+ allow(repository).to receive(:run_git!).and_call_original
+ allow(repository).to receive(:run_git!).with(%W(diff --binary #{start_sha}...#{end_sha})).and_return(diff.force_encoding('ASCII-8BIT'))
+
+ expect { subject }.to raise_error do |error|
+ expect(error).to be_a(described_class::GitError)
+ expect(error.message).not_to include('trailing whitespace')
+ end
+ end
+ end
+ end
+ end
+
def create_remote_branch(repository, remote_name, branch_name, source_branch_name)
source_branch = repository.branches.find { |branch| branch.name == source_branch_name }
rugged = repository.rugged
diff --git a/spec/lib/gitlab/git/rev_list_spec.rb b/spec/lib/gitlab/git/rev_list_spec.rb
index eaf74951b0e..32ec1e029c8 100644
--- a/spec/lib/gitlab/git/rev_list_spec.rb
+++ b/spec/lib/gitlab/git/rev_list_spec.rb
@@ -1,51 +1,31 @@
require 'spec_helper'
describe Gitlab::Git::RevList do
- let(:project) { create(:project, :repository) }
- let(:rev_list) { described_class.new(newrev: 'newrev', path_to_repo: project.repository.path_to_repo) }
- let(:env_hash) do
- {
- 'GIT_OBJECT_DIRECTORY' => 'foo',
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar'
- }
- end
-
- before do
- allow(Gitlab::Git::Env).to receive(:all).and_return(env_hash.symbolize_keys)
- end
+ let(:repository) { create(:project, :repository).repository.raw }
+ let(:rev_list) { described_class.new(repository, newrev: 'newrev') }
def args_for_popen(args_list)
- [
- Gitlab.config.git.bin_path,
- "--git-dir=#{project.repository.path_to_repo}",
- 'rev-list',
- *args_list
- ]
- end
-
- def stub_popen_rev_list(*additional_args, output:)
- args = args_for_popen(additional_args)
-
- expect(rev_list).to receive(:popen).with(args, nil, env_hash)
- .and_return([output, 0])
+ [Gitlab.config.git.bin_path, 'rev-list', *args_list]
end
- def stub_lazy_popen_rev_list(*additional_args, output:)
+ def stub_popen_rev_list(*additional_args, with_lazy_block: true, output:)
params = [
args_for_popen(additional_args),
- nil,
- env_hash,
- hash_including(lazy_block: anything)
+ repository.path,
+ {},
+ hash_including(lazy_block: with_lazy_block ? anything : nil)
]
- expect(rev_list).to receive(:popen).with(*params) do |*_, lazy_block:|
- lazy_block.call(output.split("\n").lazy)
+ expect(repository).to receive(:popen).with(*params) do |*_, lazy_block:|
+ output = lazy_block.call(output.lines.lazy.map(&:chomp)) if with_lazy_block
+
+ [output, 0]
end
end
context "#new_refs" do
it 'calls out to `popen`' do
- stub_popen_rev_list('newrev', '--not', '--all', output: "sha1\nsha2")
+ stub_popen_rev_list('newrev', '--not', '--all', with_lazy_block: false, output: "sha1\nsha2")
expect(rev_list.new_refs).to eq(%w[sha1 sha2])
end
@@ -55,17 +35,26 @@ describe Gitlab::Git::RevList do
it 'fetches list of newly pushed objects using rev-list' do
stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2")
- expect(rev_list.new_objects).to eq(%w[sha1 sha2])
+ expect { |b| rev_list.new_objects(&b) }.to yield_with_args(%w[sha1 sha2])
end
it 'can skip pathless objects' do
stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2 path/to/file")
- expect(rev_list.new_objects(require_path: true)).to eq(%w[sha2])
+ expect { |b| rev_list.new_objects(require_path: true, &b) }.to yield_with_args(%w[sha2])
+ end
+
+ it 'can handle non utf-8 paths' do
+ non_utf_char = [0x89].pack("c*").force_encoding("UTF-8")
+ stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha2 πå†h/†ø/ƒîlé#{non_utf_char}\nsha1")
+
+ rev_list.new_objects(require_path: true) do |object_ids|
+ expect(object_ids.force).to eq(%w[sha2])
+ end
end
it 'can yield a lazy enumerator' do
- stub_lazy_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2")
+ stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2")
rev_list.new_objects do |object_ids|
expect(object_ids).to be_a Enumerator::Lazy
@@ -73,7 +62,7 @@ describe Gitlab::Git::RevList do
end
it 'returns the result of the block when given' do
- stub_lazy_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2")
+ stub_popen_rev_list('newrev', '--not', '--all', '--objects', output: "sha1\nsha2")
objects = rev_list.new_objects do |object_ids|
object_ids.first
@@ -85,13 +74,13 @@ describe Gitlab::Git::RevList do
it 'can accept list of references to exclude' do
stub_popen_rev_list('newrev', '--not', 'master', '--objects', output: "sha1\nsha2")
- expect(rev_list.new_objects(not_in: ['master'])).to eq(%w[sha1 sha2])
+ expect { |b| rev_list.new_objects(not_in: ['master'], &b) }.to yield_with_args(%w[sha1 sha2])
end
it 'handles empty list of references to exclude as listing all known objects' do
stub_popen_rev_list('newrev', '--objects', output: "sha1\nsha2")
- expect(rev_list.new_objects(not_in: [])).to eq(%w[sha1 sha2])
+ expect { |b| rev_list.new_objects(not_in: [], &b) }.to yield_with_args(%w[sha1 sha2])
end
end
@@ -99,15 +88,15 @@ describe Gitlab::Git::RevList do
it 'fetches list of all pushed objects using rev-list' do
stub_popen_rev_list('--all', '--objects', output: "sha1\nsha2")
- expect(rev_list.all_objects).to eq(%w[sha1 sha2])
+ expect { |b| rev_list.all_objects(&b) }.to yield_with_args(%w[sha1 sha2])
end
end
context "#missed_ref" do
- let(:rev_list) { described_class.new(oldrev: 'oldrev', newrev: 'newrev', path_to_repo: project.repository.path_to_repo) }
+ let(:rev_list) { described_class.new(repository, oldrev: 'oldrev', newrev: 'newrev') }
it 'calls out to `popen`' do
- stub_popen_rev_list('--max-count=1', 'oldrev', '^newrev', output: "sha1\nsha2")
+ stub_popen_rev_list('--max-count=1', 'oldrev', '^newrev', with_lazy_block: false, output: "sha1\nsha2")
expect(rev_list.missed_ref).to eq(%w[sha1 sha2])
end
diff --git a/spec/lib/gitlab/git/tree_spec.rb b/spec/lib/gitlab/git/tree_spec.rb
index 86f7bcb8e38..001e406a930 100644
--- a/spec/lib/gitlab/git/tree_spec.rb
+++ b/spec/lib/gitlab/git/tree_spec.rb
@@ -80,22 +80,18 @@ describe Gitlab::Git::Tree, seed_helper: true do
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, '/')
+ shared_examples '#where' do
+ it 'returns an empty array when called with an invalid ref' do
+ expect(described_class.where(repository, 'foobar-does-not-exist')).to eq([])
end
end
- it 'gets the tree entries from GitalyClient' do
- expect_any_instance_of(Gitlab::GitalyClient::CommitService).to receive(:tree_entries)
+ context 'with gitaly' do
+ it_behaves_like '#where'
+ end
- described_class.where(repository, SeedRepo::Commit::ID, '/')
+ context 'without gitaly', :skip_gitaly_mock do
+ it_behaves_like '#where'
end
end
end
diff --git a/spec/lib/gitlab/git/wiki_spec.rb b/spec/lib/gitlab/git/wiki_spec.rb
new file mode 100644
index 00000000000..761f7732036
--- /dev/null
+++ b/spec/lib/gitlab/git/wiki_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Gitlab::Git::Wiki do
+ let(:project) { create(:project) }
+ let(:user) { project.owner }
+ let(:project_wiki) { ProjectWiki.new(project, user) }
+ subject { project_wiki.wiki }
+
+ # Remove skip_gitaly_mock flag when gitaly_find_page when
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/42039 is solved
+ describe '#page', :skip_gitaly_mock do
+ before do
+ create_page('page1', 'content')
+ create_page('foo/page1', 'content foo/page1')
+ end
+
+ after do
+ destroy_page('page1')
+ destroy_page('page1', 'foo')
+ end
+
+ it 'returns the right page' do
+ expect(subject.page(title: 'page1', dir: '').url_path).to eq 'page1'
+ expect(subject.page(title: 'page1', dir: 'foo').url_path).to eq 'foo/page1'
+ end
+ end
+
+ def create_page(name, content)
+ subject.write_page(name, :markdown, content, commit_details(name))
+ end
+
+ def commit_details(name)
+ Gitlab::Git::Wiki::CommitDetails.new(user.name, user.email, "created page #{name}")
+ end
+
+ def destroy_page(title, dir = '')
+ page = subject.page(title: title, dir: dir)
+ project_wiki.delete_page(page, "test commit")
+ end
+end
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index 2db560c2cec..6c625596605 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -5,13 +5,23 @@ describe Gitlab::GitAccess do
let(:actor) { user }
let(:project) { create(:project, :repository) }
+ let(:project_path) { project.path }
+ let(:namespace_path) { project&.namespace&.path }
let(:protocol) { 'ssh' }
let(:authentication_abilities) { %i[read_project download_code push_code] }
let(:redirected_path) { nil }
+ let(:auth_result_type) { nil }
- let(:access) { described_class.new(actor, project, protocol, authentication_abilities: authentication_abilities, redirected_path: redirected_path) }
- let(:push_access_check) { access.check('git-receive-pack', '_any') }
- let(:pull_access_check) { access.check('git-upload-pack', '_any') }
+ let(:access) do
+ described_class.new(actor, project,
+ protocol, authentication_abilities: authentication_abilities,
+ namespace_path: namespace_path, project_path: project_path,
+ redirected_path: redirected_path, auth_result_type: auth_result_type)
+ end
+
+ let(:changes) { '_any' }
+ let(:push_access_check) { access.check('git-receive-pack', changes) }
+ let(:pull_access_check) { access.check('git-upload-pack', changes) }
describe '#check with single protocols allowed' do
def disable_protocol(protocol)
@@ -36,6 +46,7 @@ describe Gitlab::GitAccess do
before do
disable_protocol('http')
+ project.add_master(user)
end
it 'blocks http push and pull' do
@@ -44,6 +55,26 @@ describe Gitlab::GitAccess do
expect { pull_access_check }.to raise_unauthorized('Git access over HTTP is not allowed')
end
end
+
+ context 'when request is made from CI' do
+ let(:auth_result_type) { :build }
+
+ it "doesn't block http pull" do
+ aggregate_failures do
+ expect { pull_access_check }.not_to raise_unauthorized('Git access over HTTP is not allowed')
+ end
+ end
+
+ context 'when legacy CI credentials are used' do
+ let(:auth_result_type) { :ci }
+
+ it "doesn't block http pull" do
+ aggregate_failures do
+ expect { pull_access_check }.not_to raise_unauthorized('Git access over HTTP is not allowed')
+ end
+ end
+ end
+ end
end
end
@@ -51,12 +82,12 @@ describe Gitlab::GitAccess 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(:deploy_key) { create(:deploy_key, user: user) }
let(:actor) { deploy_key }
context 'when the DeployKey has access to the project' do
before do
- deploy_key.projects << project
+ deploy_key.deploy_keys_projects.create(project: project, can_push: true)
end
it 'allows push and pull access' do
@@ -111,7 +142,34 @@ describe Gitlab::GitAccess do
end
it 'does not block pushes with "not found"' do
- expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload])
+ expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload])
+ end
+ end
+
+ context 'when actor is DeployToken' do
+ let(:actor) { create(:deploy_token, projects: [project]) }
+
+ context 'when DeployToken is active and belongs to project' do
+ it 'allows pull access' do
+ expect { pull_access_check }.not_to raise_error
+ end
+
+ it 'blocks the push' do
+ expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload])
+ end
+ end
+
+ context 'when DeployToken does not belong to project' do
+ let(:another_project) { create(:project) }
+ let(:actor) { create(:deploy_token, projects: [another_project]) }
+
+ it 'blocks pull access' do
+ expect { pull_access_check }.to raise_not_found
+ end
+
+ it 'blocks the push' do
+ expect { push_access_check }.to raise_not_found
+ end
end
end
end
@@ -145,6 +203,7 @@ describe Gitlab::GitAccess do
context 'when the project is nil' do
let(:project) { nil }
+ let(:project_path) { "new-project" }
it 'blocks push and pull with "not found"' do
aggregate_failures do
@@ -152,6 +211,42 @@ describe Gitlab::GitAccess do
expect { push_access_check }.to raise_not_found
end
end
+
+ context 'when user is allowed to create project in namespace' do
+ let(:namespace_path) { user.namespace.path }
+ let(:access) do
+ described_class.new(actor, nil,
+ protocol, authentication_abilities: authentication_abilities,
+ project_path: project_path, namespace_path: namespace_path,
+ redirected_path: redirected_path)
+ end
+
+ it 'blocks pull access with "not found"' do
+ expect { pull_access_check }.to raise_not_found
+ end
+
+ it 'allows push access' do
+ expect { push_access_check }.not_to raise_error
+ end
+ end
+
+ context 'when user is not allowed to create project in namespace' do
+ let(:user2) { create(:user) }
+ let(:namespace_path) { user2.namespace.path }
+ let(:access) do
+ described_class.new(actor, nil,
+ protocol, authentication_abilities: authentication_abilities,
+ project_path: project_path, namespace_path: namespace_path,
+ redirected_path: redirected_path)
+ end
+
+ it 'blocks push and pull with "not found"' do
+ aggregate_failures do
+ expect { pull_access_check }.to raise_not_found
+ expect { push_access_check }.to raise_not_found
+ end
+ end
+ end
end
end
@@ -194,14 +289,21 @@ describe Gitlab::GitAccess do
end
shared_examples 'check_project_moved' do
- it 'enqueues a redirected message' do
+ it 'enqueues a redirected message for pushing' do
push_access_check
- expect(Gitlab::Checks::ProjectMoved.fetch_redirect_message(user.id, project.id)).not_to be_nil
+ expect(Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id)).not_to be_nil
+ end
+
+ it 'allows push and pull access' do
+ aggregate_failures do
+ expect { push_access_check }.not_to raise_error
+ expect { pull_access_check }.not_to raise_error
+ end
end
end
- describe '#check_project_moved!', :clean_gitlab_redis_shared_state do
+ describe '#add_project_moved_message!', :clean_gitlab_redis_shared_state do
before do
project.add_master(user)
end
@@ -215,67 +317,69 @@ describe Gitlab::GitAccess do
end
end
- context 'when a permanent redirect and ssh protocol' do
+ context 'with a redirect and ssh protocol' do
let(:redirected_path) { 'some/other-path' }
- before do
- allow_any_instance_of(Gitlab::Checks::ProjectMoved).to receive(:permanent_redirect?).and_return(true)
- end
-
- it 'allows push and pull access' do
- aggregate_failures do
- expect { push_access_check }.not_to raise_error
- end
- end
-
it_behaves_like 'check_project_moved'
end
- context 'with a permanent redirect and http protocol' do
+ context 'with a redirect and http protocol' do
let(:redirected_path) { 'some/other-path' }
let(:protocol) { 'http' }
- before do
- allow_any_instance_of(Gitlab::Checks::ProjectMoved).to receive(:permanent_redirect?).and_return(true)
- end
+ it_behaves_like 'check_project_moved'
+ end
+ end
- it 'allows_push and pull access' do
- aggregate_failures do
- expect { push_access_check }.not_to raise_error
- end
+ describe '#check_authentication_abilities!' do
+ before do
+ project.add_master(user)
+ end
+
+ context 'when download' do
+ let(:authentication_abilities) { [] }
+
+ it 'raises unauthorized with download error' do
+ expect { pull_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_download])
end
- it_behaves_like 'check_project_moved'
- end
+ context 'when authentication abilities include download code' do
+ let(:authentication_abilities) { [:download_code] }
- context 'with a temporal redirect and ssh protocol' do
- let(:redirected_path) { 'some/other-path' }
+ it 'does not raise any errors' do
+ expect { pull_access_check }.not_to raise_error
+ end
+ end
- it 'blocks push and pull access' do
- aggregate_failures do
- expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /Project '#{redirected_path}' was moved to '#{project.full_path}'/)
- expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.ssh_url_to_repo}/)
+ context 'when authentication abilities include build download code' do
+ let(:authentication_abilities) { [:build_download_code] }
- expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /Project '#{redirected_path}' was moved to '#{project.full_path}'/)
- expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.ssh_url_to_repo}/)
+ it 'does not raise any errors' do
+ expect { pull_access_check }.not_to raise_error
end
end
end
- context 'with a temporal redirect and http protocol' do
- let(:redirected_path) { 'some/other-path' }
- let(:protocol) { 'http' }
+ context 'when upload' do
+ let(:authentication_abilities) { [] }
- it 'does not allow to push and pull access' do
- expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/)
- expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/)
+ it 'raises unauthorized with push error' do
+ expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload])
+ end
+
+ context 'when authentication abilities include push code' do
+ let(:authentication_abilities) { [:push_code] }
+
+ it 'does not raise any errors' do
+ expect { push_access_check }.not_to raise_error
+ end
end
end
end
describe '#check_command_disabled!' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
context 'over http' do
@@ -311,6 +415,117 @@ describe Gitlab::GitAccess do
end
end
+ describe '#check_db_accessibility!' do
+ context 'when in a read-only GitLab instance' do
+ before do
+ create(:protected_branch, name: 'feature', project: project)
+ allow(Gitlab::Database).to receive(:read_only?) { true }
+ end
+
+ it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:cannot_push_to_read_only]) }
+ end
+ end
+
+ describe '#ensure_project_on_push!' do
+ let(:access) do
+ described_class.new(actor, project,
+ protocol, authentication_abilities: authentication_abilities,
+ project_path: project_path, namespace_path: namespace_path,
+ redirected_path: redirected_path)
+ end
+
+ context 'when push' do
+ let(:cmd) { 'git-receive-pack' }
+
+ context 'when project does not exist' do
+ let(:project_path) { "nonexistent" }
+ let(:project) { nil }
+
+ context 'when changes is _any' do
+ let(:changes) { '_any' }
+
+ context 'when authentication abilities include push code' do
+ let(:authentication_abilities) { [:push_code] }
+
+ context 'when user can create project in namespace' do
+ let(:namespace_path) { user.namespace.path }
+
+ it 'creates a new project' do
+ expect { access.send(:ensure_project_on_push!, cmd, changes) }.to change { Project.count }.by(1)
+ end
+ end
+
+ context 'when user cannot create project in namespace' do
+ let(:user2) { create(:user) }
+ let(:namespace_path) { user2.namespace.path }
+
+ it 'does not create a new project' do
+ expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count }
+ end
+ end
+ end
+
+ context 'when authentication abilities do not include push code' do
+ let(:authentication_abilities) { [] }
+
+ context 'when user can create project in namespace' do
+ let(:namespace_path) { user.namespace.path }
+
+ it 'does not create a new project' do
+ expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count }
+ end
+ end
+ end
+ end
+
+ context 'when check contains actual changes' do
+ let(:changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" }
+
+ it 'does not create a new project' do
+ expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count }
+ end
+ end
+ end
+
+ context 'when project exists' do
+ let(:changes) { '_any' }
+ let!(:project) { create(:project) }
+
+ it 'does not create a new project' do
+ expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count }
+ end
+ end
+
+ context 'when deploy key is used' do
+ let(:key) { create(:deploy_key, user: user) }
+ let(:actor) { key }
+ let(:project_path) { "nonexistent" }
+ let(:project) { nil }
+ let(:namespace_path) { user.namespace.path }
+ let(:changes) { '_any' }
+
+ it 'does not create a new project' do
+ expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count }
+ end
+ end
+ end
+
+ context 'when pull' do
+ let(:cmd) { 'git-upload-pack' }
+ let(:changes) { '_any' }
+
+ context 'when project does not exist' do
+ let(:project_path) { "new-project" }
+ let(:namespace_path) { user.namespace.path }
+ let(:project) { nil }
+
+ it 'does not create a new project' do
+ expect { access.send(:ensure_project_on_push!, cmd, changes) }.not_to change { Project.count }
+ end
+ end
+ end
+ end
+
describe '#check_download_access!' do
it 'allows masters to pull' do
project.add_master(user)
@@ -331,6 +546,19 @@ describe Gitlab::GitAccess do
expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.')
end
+ context 'when the project repository does not exist' do
+ it 'returns not found' do
+ project.add_guest(user)
+ repo = project.repository
+ FileUtils.rm_rf(repo.path)
+
+ # Sanity check for rm_rf
+ expect(repo.exists?).to eq(false)
+
+ expect { pull_access_check }.to raise_error(Gitlab::GitAccess::NotFoundError, 'A repository for this project does not exist yet.')
+ end
+ end
+
describe 'without access to project' do
context 'pull code' do
it { expect { pull_access_check }.to raise_not_found }
@@ -338,7 +566,9 @@ describe Gitlab::GitAccess do
context 'when project is public' do
let(:public_project) { create(:project, :public, :repository) }
- let(:access) { described_class.new(nil, public_project, 'web', authentication_abilities: []) }
+ let(:project_path) { public_project.path }
+ let(:namespace_path) { public_project.namespace.path }
+ let(:access) { described_class.new(nil, public_project, 'web', authentication_abilities: [:download_code], project_path: project_path, namespace_path: namespace_path) }
context 'when repository is enabled' do
it 'give access to download code' do
@@ -391,6 +621,41 @@ describe Gitlab::GitAccess do
end
end
+ describe 'deploy token permissions' do
+ let(:deploy_token) { create(:deploy_token) }
+ let(:actor) { deploy_token }
+
+ context 'pull code' do
+ context 'when project is authorized' do
+ before do
+ deploy_token.projects << project
+ end
+
+ 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 { pull_access_check }.not_to raise_error }
+ end
+
+ context 'from internal project' do
+ let(:project) { create(:project, :internal, :repository) }
+
+ it { expect { pull_access_check }.to raise_not_found }
+ end
+
+ context 'from private project' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it { expect { pull_access_check }.to raise_not_found }
+ end
+ end
+ end
+ end
+
describe 'build authentication_abilities permissions' do
let(:authentication_abilities) { build_authentication_abilities }
@@ -404,7 +669,7 @@ describe Gitlab::GitAccess do
describe 'reporter user' do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
context 'pull code' do
@@ -417,7 +682,7 @@ describe Gitlab::GitAccess do
context 'when member of the project' do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
context 'pull code' do
@@ -442,6 +707,20 @@ describe Gitlab::GitAccess do
end
end
+ describe 'check LFS integrity' do
+ let(:changes) { ['6f6d7e7ed 570e7b2ab refs/heads/master', '6f6d7e7ed 570e7b2ab refs/heads/feature'] }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'checks LFS integrity only for first change' do
+ expect_any_instance_of(Gitlab::Checks::LfsIntegrity).to receive(:objects_missing?).exactly(1).times
+
+ push_access_check
+ end
+ end
+
describe '#check_push_access!' do
before do
merge_into_protected_branch
@@ -497,7 +776,7 @@ describe Gitlab::GitAccess do
if role == :admin
user.update_attribute(:admin, true)
else
- project.team << [user, role]
+ project.add_role(user, role)
end
aggregate_failures do
@@ -639,17 +918,18 @@ describe Gitlab::GitAccess do
end
end
- context "when in a read-only GitLab instance" do
+ context 'when pushing to a project' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2ab refs/heads/wow" }
+
before do
- create(:protected_branch, name: 'feature', project: project)
- allow(Gitlab::Database).to receive(:read_only?) { true }
+ project.add_developer(user)
end
- # Only check admin; if an admin can't do it, other roles can't either
- matrix = permissions_matrix[:admin].dup
- matrix.each { |key, _| matrix[key] = false }
-
- run_permission_checks(admin: matrix)
+ it 'cleans up the files' do
+ expect(project.repository).to receive(:clean_stale_repository_files).and_call_original
+ expect { push_access_check }.not_to raise_error
+ end
end
end
@@ -658,29 +938,29 @@ describe Gitlab::GitAccess do
context 'when project is authorized' do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
- it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) }
+ it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) }
end
context 'when unauthorized' do
context 'to public project' do
let(:project) { create(:project, :public, :repository) }
- it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) }
+ it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) }
end
context 'to internal project' do
let(:project) { create(:project, :internal, :repository) }
- it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) }
+ it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) }
end
context 'to private project' do
let(:project) { create(:project, :private, :repository) }
- it { expect { push_access_check }.to raise_not_found }
+ it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) }
end
end
end
@@ -696,15 +976,13 @@ describe Gitlab::GitAccess do
end
describe 'deploy key permissions' do
- let(:key) { create(:deploy_key, user: user, can_push: can_push) }
+ let(:key) { create(:deploy_key, user: user) }
let(:actor) { key }
context 'when deploy_key can push' do
- let(:can_push) { true }
-
context 'when project is authorized' do
before do
- key.projects << project
+ key.deploy_keys_projects.create(project: project, can_push: true)
end
it { expect { push_access_check }.not_to raise_error }
@@ -732,11 +1010,9 @@ describe Gitlab::GitAccess do
end
context 'when deploy_key cannot push' do
- let(:can_push) { false }
-
context 'when project is authorized' do
before do
- key.projects << project
+ key.deploy_keys_projects.create(project: project, can_push: false)
end
it { expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:deploy_key_upload]) }
@@ -771,8 +1047,7 @@ describe Gitlab::GitAccess do
end
def raise_not_found
- raise_error(Gitlab::GitAccess::NotFoundError,
- Gitlab::GitAccess::ERROR_MESSAGES[:project_not_found])
+ raise_error(Gitlab::GitAccess::NotFoundError, Gitlab::GitAccess::ERROR_MESSAGES[:project_not_found])
end
def build_authentication_abilities
diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb
index 1056074264a..730ede99fc9 100644
--- a/spec/lib/gitlab/git_access_wiki_spec.rb
+++ b/spec/lib/gitlab/git_access_wiki_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
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(:project) { create(:project, :wiki_repo) }
let(:user) { create(:user) }
let(:changes) { ['6f6d7e7ed 570e7b2ab refs/heads/master'] }
let(:redirected_path) { nil }
@@ -18,7 +18,7 @@ describe Gitlab::GitAccessWiki do
context 'when user can :create_wiki' do
before do
create(:protected_branch, name: 'master', project: project)
- project.team << [user, :developer]
+ project.add_developer(user)
end
subject { access.check('git-receive-pack', changes) }
@@ -41,13 +41,25 @@ describe Gitlab::GitAccessWiki do
subject { access.check('git-upload-pack', '_any') }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'when wiki feature is enabled' do
it 'give access to download wiki code' do
expect { subject }.not_to raise_error
end
+
+ context 'when the wiki repository does not exist' do
+ it 'returns not found' do
+ wiki_repo = project.wiki.repository
+ FileUtils.rm_rf(wiki_repo.path)
+
+ # Sanity check for rm_rf
+ expect(wiki_repo.exists?).to eq(false)
+
+ expect { subject }.to raise_error(Gitlab::GitAccess::NotFoundError, 'A repository for this project does not exist yet.')
+ end
+ end
end
context 'when wiki feature is disabled' do
diff --git a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb
new file mode 100644
index 00000000000..a2770ef2fe4
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::BlobService do
+ let(:project) { create(:project, :repository) }
+ let(:storage_name) { project.repository_storage }
+ let(:relative_path) { project.disk_path + '.git' }
+ let(:repository) { project.repository }
+ let(:client) { described_class.new(repository) }
+
+ describe '#get_new_lfs_pointers' do
+ let(:revision) { 'master' }
+ let(:limit) { 5 }
+ let(:not_in) { ['branch-a', 'branch-b'] }
+ let(:expected_params) do
+ { revision: revision, limit: limit, not_in_refs: not_in, not_in_all: false }
+ end
+
+ subject { client.get_new_lfs_pointers(revision, limit, not_in) }
+
+ it 'sends a get_new_lfs_pointers message' do
+ expect_any_instance_of(Gitaly::BlobService::Stub)
+ .to receive(:get_new_lfs_pointers)
+ .with(gitaly_request_with_params(expected_params), kind_of(Hash))
+ .and_return([])
+
+ subject
+ end
+
+ context 'with not_in = :all' do
+ let(:not_in) { :all }
+ let(:expected_params) do
+ { revision: revision, limit: limit, not_in_refs: [], not_in_all: true }
+ end
+
+ it 'sends the correct message' do
+ expect_any_instance_of(Gitaly::BlobService::Stub)
+ .to receive(:get_new_lfs_pointers)
+ .with(gitaly_request_with_params(expected_params), kind_of(Hash))
+ .and_return([])
+
+ subject
+ end
+ end
+ end
+
+ describe '#get_all_lfs_pointers' do
+ let(:revision) { 'master' }
+
+ subject { client.get_all_lfs_pointers(revision) }
+
+ it 'sends a get_all_lfs_pointers message' do
+ expect_any_instance_of(Gitaly::BlobService::Stub)
+ .to receive(:get_all_lfs_pointers)
+ .with(gitaly_request_with_params(revision: revision), kind_of(Hash))
+ .and_return([])
+
+ subject
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb
new file mode 100644
index 00000000000..9db710e759e
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/blobs_stitcher_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::BlobsStitcher do
+ describe 'enumeration' do
+ it 'combines segregated blob messages together' do
+ messages = [
+ OpenStruct.new(oid: 'abcdef1', path: 'path/to/file', size: 1642, revision: 'f00ba7', mode: 0100644, data: "first-line\n"),
+ OpenStruct.new(oid: '', data: 'second-line'),
+ OpenStruct.new(oid: '', data: '', revision: 'f00ba7', path: 'path/to/non-existent/file'),
+ OpenStruct.new(oid: 'abcdef2', path: 'path/to/another-file', size: 2461, revision: 'f00ba8', mode: 0100644, data: "GIF87a\x90\x01".b)
+ ]
+
+ blobs = described_class.new(messages).to_a
+
+ expect(blobs.size).to be(2)
+
+ expect(blobs[0].id).to eq('abcdef1')
+ expect(blobs[0].mode).to eq('100644')
+ expect(blobs[0].name).to eq('file')
+ expect(blobs[0].path).to eq('path/to/file')
+ expect(blobs[0].size).to eq(1642)
+ expect(blobs[0].commit_id).to eq('f00ba7')
+ expect(blobs[0].data).to eq("first-line\nsecond-line")
+ expect(blobs[0].binary?).to be false
+
+ expect(blobs[1].id).to eq('abcdef2')
+ expect(blobs[1].mode).to eq('100644')
+ expect(blobs[1].name).to eq('another-file')
+ expect(blobs[1].path).to eq('path/to/another-file')
+ expect(blobs[1].size).to eq(2461)
+ expect(blobs[1].commit_id).to eq('f00ba8')
+ expect(blobs[1].data).to eq("GIF87a\x90\x01".b)
+ expect(blobs[1].binary?).to be true
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index b2275119a04..9be3fa633a7 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -113,7 +113,7 @@ describe Gitlab::GitalyClient::CommitService do
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return([])
- client.tree_entries(repository, revision, path)
+ client.tree_entries(repository, revision, path, false)
end
context 'with UTF-8 params strings' do
@@ -126,7 +126,30 @@ describe Gitlab::GitalyClient::CommitService do
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return([])
- client.tree_entries(repository, revision, path)
+ client.tree_entries(repository, revision, path, false)
+ end
+ end
+ end
+
+ describe '#commit_count' do
+ before do
+ expect_any_instance_of(Gitaly::CommitService::Stub)
+ .to receive(:count_commits)
+ .with(gitaly_request_with_path(storage_name, relative_path),
+ kind_of(Hash))
+ .and_return([])
+ end
+
+ it 'sends a commit_count message' do
+ client.commit_count(revision)
+ end
+
+ context 'with UTF-8 params strings' do
+ let(:revision) { "branch\u011F" }
+ let(:path) { "foo/\u011F.txt" }
+
+ it 'handles string encodings correctly' do
+ client.commit_count(revision, path: path)
end
end
end
@@ -143,6 +166,32 @@ describe Gitlab::GitalyClient::CommitService do
described_class.new(repository).find_commit(revision)
end
+
+ describe 'caching', :request_store do
+ let(:commit_dbl) { double(id: 'f01b' * 10) }
+
+ context 'when passed revision is a branch name' do
+ it 'calls Gitaly' do
+ expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).twice.and_return(double(commit: commit_dbl))
+
+ commit = nil
+ 2.times { commit = described_class.new(repository).find_commit('master') }
+
+ expect(commit).to eq(commit_dbl)
+ end
+ end
+
+ context 'when passed revision is a commit ID' do
+ it 'returns a cached commit' do
+ expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).once.and_return(double(commit: commit_dbl))
+
+ commit = nil
+ 2.times { commit = described_class.new(repository).find_commit('f01b' * 10) }
+
+ expect(commit).to eq(commit_dbl)
+ end
+ end
+ end
end
describe '#patch' do
diff --git a/spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb
new file mode 100644
index 00000000000..1c933410bd5
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/conflict_files_stitcher_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::ConflictFilesStitcher do
+ describe 'enumeration' do
+ it 'combines segregated ConflictFile messages together' do
+ target_project = create(:project, :repository)
+ target_repository = target_project.repository.raw
+ target_gitaly_repository = target_repository.gitaly_repository
+
+ our_path_1 = 'our/path/1'
+ their_path_1 = 'their/path/1'
+ our_mode_1 = 0744
+ commit_oid_1 = 'f00'
+ content_1 = 'content of the first file'
+
+ our_path_2 = 'our/path/2'
+ their_path_2 = 'their/path/2'
+ our_mode_2 = 0600
+ commit_oid_2 = 'ba7'
+ content_2 = 'content of the second file'
+
+ header_1 = double(repository: target_gitaly_repository, commit_oid: commit_oid_1,
+ our_path: our_path_1, their_path: their_path_1, our_mode: our_mode_1)
+ header_2 = double(repository: target_gitaly_repository, commit_oid: commit_oid_2,
+ our_path: our_path_2, their_path: their_path_2, our_mode: our_mode_2)
+
+ messages = [
+ double(files: [double(header: header_1), double(header: nil, content: content_1[0..5])]),
+ double(files: [double(header: nil, content: content_1[6..-1])]),
+ double(files: [double(header: header_2)]),
+ double(files: [double(header: nil, content: content_2[0..5]), double(header: nil, content: content_2[6..10])]),
+ double(files: [double(header: nil, content: content_2[11..-1])])
+ ]
+
+ conflict_files = described_class.new(messages).to_a
+
+ expect(conflict_files.size).to be(2)
+
+ expect(conflict_files[0].content).to eq(content_1)
+ expect(conflict_files[0].their_path).to eq(their_path_1)
+ expect(conflict_files[0].our_path).to eq(our_path_1)
+ expect(conflict_files[0].our_mode).to be(our_mode_1)
+ expect(conflict_files[0].repository).to eq(target_repository)
+ expect(conflict_files[0].commit_oid).to eq(commit_oid_1)
+
+ expect(conflict_files[1].content).to eq(content_2)
+ expect(conflict_files[1].their_path).to eq(their_path_2)
+ expect(conflict_files[1].our_path).to eq(our_path_2)
+ expect(conflict_files[1].our_mode).to be(our_mode_2)
+ expect(conflict_files[1].repository).to eq(target_repository)
+ expect(conflict_files[1].commit_oid).to eq(commit_oid_2)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb b/spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb
new file mode 100644
index 00000000000..e4fe01a671f
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/conflicts_service_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::ConflictsService do
+ let(:project) { create(:project, :repository) }
+ let(:target_project) { create(:project, :repository) }
+ let(:source_repository) { project.repository.raw }
+ let(:target_repository) { target_project.repository.raw }
+ let(:target_gitaly_repository) { target_repository.gitaly_repository }
+ let(:our_commit_oid) { 'f00' }
+ let(:their_commit_oid) { 'f44' }
+ let(:client) do
+ described_class.new(target_repository, our_commit_oid, their_commit_oid)
+ end
+
+ describe '#list_conflict_files' do
+ let(:request) do
+ Gitaly::ListConflictFilesRequest.new(
+ repository: target_gitaly_repository, our_commit_oid: our_commit_oid,
+ their_commit_oid: their_commit_oid
+ )
+ end
+
+ it 'sends an RPC request' do
+ expect_any_instance_of(Gitaly::ConflictsService::Stub).to receive(:list_conflict_files)
+ .with(request, kind_of(Hash)).and_return([].to_enum)
+
+ client.list_conflict_files
+ end
+ end
+
+ describe '#resolve_conflicts' do
+ let(:user) { create(:user) }
+ let(:files) do
+ [{ old_path: 'some/path', new_path: 'some/path', content: '' }]
+ end
+ let(:source_branch) { 'master' }
+ let(:target_branch) { 'feature' }
+ let(:commit_message) { 'Solving conflicts' }
+ let(:resolution) do
+ Gitlab::Git::Conflict::Resolution.new(user, files, commit_message)
+ end
+
+ subject do
+ client.resolve_conflicts(source_repository, resolution, source_branch, target_branch)
+ end
+
+ it 'sends an RPC request' do
+ expect_any_instance_of(Gitaly::ConflictsService::Stub).to receive(:resolve_conflicts)
+ .with(kind_of(Enumerator), kind_of(Hash)).and_return(double(resolution_error: ""))
+
+ subject
+ end
+
+ it 'raises a relevant exception if resolution_error is present' do
+ expect_any_instance_of(Gitaly::ConflictsService::Stub).to receive(:resolve_conflicts)
+ .with(kind_of(Enumerator), kind_of(Hash)).and_return(double(resolution_error: "something happened"))
+
+ expect { subject }.to raise_error(Gitlab::Git::Conflict::Resolver::ResolutionError)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/health_check_service_spec.rb b/spec/lib/gitlab/gitaly_client/health_check_service_spec.rb
new file mode 100644
index 00000000000..2c7e5eb5787
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/health_check_service_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::HealthCheckService do
+ let(:project) { create(:project) }
+ let(:storage_name) { project.repository_storage }
+
+ subject { described_class.new(storage_name) }
+
+ describe '#check' do
+ it 'successfully sends a health check request' do
+ expect(Gitlab::GitalyClient).to receive(:call).with(
+ storage_name,
+ :health_check,
+ :check,
+ instance_of(Grpc::Health::V1::HealthCheckRequest),
+ timeout: Gitlab::GitalyClient.fast_timeout).and_call_original
+
+ expect(subject.check).to eq({ success: true })
+ end
+
+ it 'receives an unsuccessful health check request' do
+ expect_any_instance_of(Grpc::Health::V1::Health::Stub)
+ .to receive(:check)
+ .and_return(double(status: false))
+
+ expect(subject.check).to eq({ success: false })
+ end
+
+ it 'gracefully handles gRPC error' do
+ expect(Gitlab::GitalyClient).to receive(:call).with(
+ storage_name,
+ :health_check,
+ :check,
+ instance_of(Grpc::Health::V1::HealthCheckRequest),
+ timeout: Gitlab::GitalyClient.fast_timeout)
+ .and_raise(GRPC::Unavailable.new('Connection refused'))
+
+ expect(subject.check).to eq({ success: false, message: '14:Connection refused' })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb
index d9ec28ab02e..9fbdd73ee0e 100644
--- a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb
@@ -123,4 +123,53 @@ describe Gitlab::GitalyClient::OperationService do
expect(subject.branch_created).to be(false)
end
end
+
+ describe '#user_squash' do
+ let(:branch_name) { 'my-branch' }
+ let(:squash_id) { '1' }
+ let(:start_sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
+ let(:end_sha) { '54cec5282aa9f21856362fe321c800c236a61615' }
+ let(:commit_message) { 'Squash message' }
+ let(:request) do
+ Gitaly::UserSquashRequest.new(
+ repository: repository.gitaly_repository,
+ user: gitaly_user,
+ squash_id: squash_id.to_s,
+ branch: branch_name,
+ start_sha: start_sha,
+ end_sha: end_sha,
+ author: gitaly_user,
+ commit_message: commit_message
+ )
+ end
+ let(:squash_sha) { 'f00' }
+ let(:response) { Gitaly::UserSquashResponse.new(squash_sha: squash_sha) }
+
+ subject do
+ client.user_squash(user, squash_id, branch_name, start_sha, end_sha, user, commit_message)
+ end
+
+ it 'sends a user_squash message and returns the squash sha' do
+ expect_any_instance_of(Gitaly::OperationService::Stub)
+ .to receive(:user_squash).with(request, kind_of(Hash))
+ .and_return(response)
+
+ expect(subject).to eq(squash_sha)
+ end
+
+ context "when git_error is present" do
+ let(:response) do
+ Gitaly::UserSquashResponse.new(git_error: "something failed")
+ end
+
+ it "throws a PreReceive exception" do
+ expect_any_instance_of(Gitaly::OperationService::Stub)
+ .to receive(:user_squash).with(request, kind_of(Hash))
+ .and_return(response)
+
+ expect { subject }.to raise_error(
+ Gitlab::Git::Repository::GitError, "something failed")
+ end
+ 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
index 951e146a30a..257e4c50f2d 100644
--- a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
@@ -112,7 +112,7 @@ describe Gitlab::GitalyClient::RefService do
expect_any_instance_of(Gitaly::RefService::Stub)
.to receive(:delete_refs)
.with(gitaly_request_with_params(except_with_prefix: prefixes), kind_of(Hash))
- .and_return(double('delete_refs_response'))
+ .and_return(double('delete_refs_response', git_error: ""))
client.delete_refs(except_with_prefixes: prefixes)
end
diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
new file mode 100644
index 00000000000..f03c7e3f04b
--- /dev/null
+++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
@@ -0,0 +1,71 @@
+require 'spec_helper'
+
+describe Gitlab::GitalyClient::RemoteService do
+ let(:project) { create(:project) }
+ let(:storage_name) { project.repository_storage }
+ let(:relative_path) { project.disk_path + '.git' }
+ let(:remote_name) { 'my-remote' }
+ let(:client) { described_class.new(project.repository) }
+
+ describe '#add_remote' do
+ let(:url) { 'http://my-repo.git' }
+ let(:mirror_refmap) { :all_refs }
+
+ it 'sends an add_remote message' do
+ expect_any_instance_of(Gitaly::RemoteService::Stub)
+ .to receive(:add_remote)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(:add_remote_response))
+
+ client.add_remote(remote_name, url, mirror_refmap)
+ end
+ end
+
+ describe '#remove_remote' do
+ it 'sends an remove_remote message and returns the result value' do
+ expect_any_instance_of(Gitaly::RemoteService::Stub)
+ .to receive(:remove_remote)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(result: true))
+
+ expect(client.remove_remote(remote_name)).to be(true)
+ end
+ end
+
+ describe '#fetch_internal_remote' do
+ let(:remote_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') }
+
+ it 'sends an fetch_internal_remote message and returns the result value' do
+ expect_any_instance_of(Gitaly::RemoteService::Stub)
+ .to receive(:fetch_internal_remote)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(result: true))
+
+ expect(client.fetch_internal_remote(remote_repository)).to be(true)
+ end
+ end
+
+ describe '#update_remote_mirror' do
+ let(:ref_name) { 'remote_mirror_1' }
+ let(:only_branches_matching) { ['my-branch', 'master'] }
+
+ it 'sends an update_remote_mirror message' do
+ expect_any_instance_of(Gitaly::RemoteService::Stub)
+ .to receive(:update_remote_mirror)
+ .with(kind_of(Enumerator), kind_of(Hash))
+ .and_return(double(:update_remote_mirror_response))
+
+ client.update_remote_mirror(ref_name, only_branches_matching)
+ end
+ end
+
+ describe '.exists?' do
+ context "when the remote doesn't exist" do
+ let(:url) { 'https://gitlab.com/gitlab-org/ik-besta-niet-of-ik-word-geplaagd.git' }
+
+ it 'returns false' do
+ expect(described_class.exists?(url)).to be(false)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
index cbc7ce1c1b0..21592688bf0 100644
--- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
@@ -17,6 +17,16 @@ describe Gitlab::GitalyClient::RepositoryService do
end
end
+ describe '#cleanup' do
+ it 'sends a cleanup message' do
+ expect_any_instance_of(Gitaly::RepositoryService::Stub)
+ .to receive(:cleanup)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+
+ client.cleanup
+ end
+ end
+
describe '#garbage_collect' do
it 'sends a garbage_collect message' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
@@ -84,4 +94,55 @@ describe Gitlab::GitalyClient::RepositoryService do
expect(client.has_local_branches?).to be(true)
end
end
+
+ describe '#fetch_remote' do
+ let(:ssh_auth) { double(:ssh_auth, ssh_import?: true, ssh_key_auth?: false, ssh_known_hosts: nil) }
+ let(:import_url) { 'ssh://example.com' }
+
+ it 'sends a fetch_remote_request message' do
+ expect_any_instance_of(Gitaly::RepositoryService::Stub)
+ .to receive(:fetch_remote)
+ .with(gitaly_request_with_params(no_prune: false), kind_of(Hash))
+ .and_return(double(value: true))
+
+ client.fetch_remote(import_url, ssh_auth: ssh_auth, forced: false, no_tags: false, timeout: 60)
+ end
+ end
+
+ describe '#rebase_in_progress?' do
+ let(:rebase_id) { 1 }
+
+ it 'sends a repository_rebase_in_progress message' do
+ expect_any_instance_of(Gitaly::RepositoryService::Stub)
+ .to receive(:is_rebase_in_progress)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(in_progress: true))
+
+ client.rebase_in_progress?(rebase_id)
+ end
+ end
+
+ describe '#squash_in_progress?' do
+ let(:squash_id) { 1 }
+
+ it 'sends a repository_squash_in_progress message' do
+ expect_any_instance_of(Gitaly::RepositoryService::Stub)
+ .to receive(:is_squash_in_progress)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(in_progress: true))
+
+ client.squash_in_progress?(squash_id)
+ end
+ end
+
+ describe '#calculate_checksum' do
+ it 'sends a calculate_checksum message' do
+ expect_any_instance_of(Gitaly::RepositoryService::Stub)
+ .to receive(:calculate_checksum)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(checksum: 0))
+
+ client.calculate_checksum
+ end
+ end
end
diff --git a/spec/lib/gitlab/gitaly_client/util_spec.rb b/spec/lib/gitlab/gitaly_client/util_spec.rb
index d1e0136f8c1..550db6db6d9 100644
--- a/spec/lib/gitlab/gitaly_client/util_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/util_spec.rb
@@ -7,16 +7,19 @@ describe Gitlab::GitalyClient::Util do
let(:gl_repository) { 'project-1' }
let(:git_object_directory) { '.git/objects' }
let(:git_alternate_object_directory) { ['/dir/one', '/dir/two'] }
+ let(:git_env) do
+ {
+ 'GIT_OBJECT_DIRECTORY_RELATIVE' => git_object_directory,
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => git_alternate_object_directory
+ }
+ end
subject do
described_class.repository(repository_storage, relative_path, gl_repository)
end
it 'creates a Gitaly::Repository with the given data' do
- allow(Gitlab::Git::Env).to receive(:[]).with('GIT_OBJECT_DIRECTORY_RELATIVE')
- .and_return(git_object_directory)
- allow(Gitlab::Git::Env).to receive(:[]).with('GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE')
- .and_return(git_alternate_object_directory)
+ allow(Gitlab::Git::HookEnv).to receive(:all).with(gl_repository).and_return(git_env)
expect(subject).to be_a(Gitaly::Repository)
expect(subject.storage_name).to eq(repository_storage)
diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb
index a871ed0df0e..81bcd8c28ed 100644
--- a/spec/lib/gitlab/gitaly_client_spec.rb
+++ b/spec/lib/gitlab/gitaly_client_spec.rb
@@ -3,6 +3,31 @@ require 'spec_helper'
# 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_class' do
+ it 'returns the gRPC health check stub' do
+ expect(described_class.stub_class(:health_check)).to eq(::Grpc::Health::V1::Health::Stub)
+ end
+
+ it 'returns a Gitaly stub' do
+ expect(described_class.stub_class(:ref_service)).to eq(::Gitaly::RefService::Stub)
+ end
+ end
+
+ describe '.stub_address' do
+ it 'returns the same result after being called multiple times' do
+ address = 'localhost:9876'
+ prefixed_address = "tcp://#{address}"
+
+ allow(Gitlab.config.repositories).to receive(:storages).and_return({
+ 'default' => { 'gitaly_address' => prefixed_address }
+ })
+
+ 2.times do
+ expect(described_class.stub_address('default')).to eq('localhost:9876')
+ end
+ end
+ end
+
describe '.stub' do
# Notice that this is referring to gRPC "stubs", not rspec stubs
before do
@@ -38,20 +63,6 @@ describe Gitlab::GitalyClient, skip_gitaly_mock: true do
end
end
- describe 'encode' do
- [
- [nil, ""],
- ["", ""],
- [" ", " "],
- %w(a1 a1),
- ["ç¼–ç ", "\xE7\xBC\x96\xE7\xA0\x81".b]
- ].each do |input, result|
- it "encodes #{input.inspect} to #{result.inspect}" do
- expect(described_class.encode(input)).to eq result
- end
- end
- end
-
describe 'allow_n_plus_1_calls' do
context 'when RequestStore is enabled', :request_store do
it 'returns the result of the allow_n_plus_1_calls block' do
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb
index d72572cd510..44695acbe7d 100644
--- a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb
@@ -244,7 +244,7 @@ describe Gitlab::GithubImport::Importer::PullRequestsImporter do
it 'returns true when a commit exists' do
expect(project.repository)
- .to receive(:lookup)
+ .to receive(:commit)
.with('123')
.and_return(double(:commit))
@@ -253,9 +253,9 @@ describe Gitlab::GithubImport::Importer::PullRequestsImporter do
it 'returns false when a commit does not exist' do
expect(project.repository)
- .to receive(:lookup)
+ .to receive(:commit)
.with('123')
- .and_raise(Rugged::OdbError)
+ .and_return(nil)
expect(importer.commit_exists?('123')).to eq(false)
end
diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
index 46a57e08963..879b1d9fb0f 100644
--- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
@@ -9,9 +9,10 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
:project,
import_url: 'foo.git',
import_source: 'foo/bar',
- repository_storage_path: 'foo',
+ repository_storage: 'foo',
disk_path: 'foo',
- repository: repository
+ repository: repository,
+ create_wiki: true
)
end
@@ -37,8 +38,12 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
expect(project)
.to receive(:wiki_repository_exists?)
.and_return(false)
+ expect(Gitlab::GitalyClient::RemoteService)
+ .to receive(:exists?)
+ .with("foo.wiki.git")
+ .and_return(true)
- expect(importer.import_wiki?).to eq(true)
+ expect(importer.import_wiki?).to be(true)
end
it 'returns false if the GitHub wiki is disabled' do
@@ -192,7 +197,7 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
expect(importer.import_wiki_repository).to eq(true)
end
- it 'marks the import as failed if an error was raised' do
+ it 'marks the import as failed and creates an empty repo if an error was raised' do
expect(importer.gitlab_shell)
.to receive(:import_repository)
.and_raise(Gitlab::Shell::Error)
@@ -201,6 +206,9 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
.to receive(:fail_import)
.and_return(false)
+ expect(project)
+ .to receive(:create_wiki)
+
expect(importer.import_wiki_repository).to eq(false)
end
end
diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb
index 798ea0bac58..017facd0f5e 100644
--- a/spec/lib/gitlab/google_code_import/importer_spec.rb
+++ b/spec/lib/gitlab/google_code_import/importer_spec.rb
@@ -15,7 +15,7 @@ describe Gitlab::GoogleCodeImport::Importer do
subject { described_class.new(project) }
before do
- project.team << [project.creator, :master]
+ project.add_master(project.creator)
project.create_import_data(data: import_data)
end
diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb
index a6c99bc07d4..8c6d673391b 100644
--- a/spec/lib/gitlab/gpg/commit_spec.rb
+++ b/spec/lib/gitlab/gpg/commit_spec.rb
@@ -38,8 +38,8 @@ describe Gitlab::Gpg::Commit do
end
before do
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
+ allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
+ .with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
@@ -49,7 +49,9 @@ describe Gitlab::Gpg::Commit do
end
it 'returns a valid signature' do
- expect(described_class.new(commit).signature).to have_attributes(
+ signature = described_class.new(commit).signature
+
+ expect(signature).to have_attributes(
commit_sha: commit_sha,
project: project,
gpg_key: gpg_key,
@@ -58,9 +60,31 @@ describe Gitlab::Gpg::Commit do
gpg_key_user_email: GpgHelpers::User1.emails.first,
verification_status: 'verified'
)
+ expect(signature.persisted?).to be_truthy
end
it_behaves_like 'returns the cached signature on second call'
+
+ context 'read-only mode' do
+ before do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+ end
+
+ it 'does not create a cached signature' do
+ signature = described_class.new(commit).signature
+
+ expect(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,
+ verification_status: 'verified'
+ )
+ expect(signature.persisted?).to be_falsey
+ end
+ end
end
context 'commit signed with a subkey' do
@@ -77,8 +101,8 @@ describe Gitlab::Gpg::Commit do
end
before do
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
+ allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
+ .with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User3.signed_commit_signature,
@@ -116,8 +140,8 @@ describe Gitlab::Gpg::Commit do
end
before do
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
+ allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
+ .with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
@@ -151,8 +175,8 @@ describe Gitlab::Gpg::Commit do
end
before do
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
+ allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
+ .with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
@@ -187,8 +211,8 @@ describe Gitlab::Gpg::Commit do
end
before do
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
+ allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
+ .with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
@@ -217,8 +241,8 @@ describe Gitlab::Gpg::Commit do
let!(:commit) { create :commit, project: project, sha: commit_sha }
before do
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
+ allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
+ .with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
index d6000af0ecd..6fbffc38444 100644
--- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
+++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb
@@ -26,8 +26,8 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
before do
allow_any_instance_of(Project).to receive(:commit).and_return(commit)
- allow(Rugged::Commit).to receive(:extract_signature)
- .with(Rugged::Repository, commit_sha)
+ allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
+ .with(Gitlab::Git::Repository, commit_sha)
.and_return(signature)
end
diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb
index 4c1ca4349ea..9dcf272d25e 100644
--- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb
+++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb
@@ -26,7 +26,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do
let(:storages_paths) do
{
- default: { path: tmp_dir }
+ default: Gitlab::GitalyClient::StorageSettings.new('path' => tmp_dir)
}.with_indifferent_access
end
@@ -56,7 +56,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do
context 'storage points to not existing folder' do
let(:storages_paths) do
{
- default: { path: 'tmp/this/path/doesnt/exist' }
+ default: Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/this/path/doesnt/exist')
}.with_indifferent_access
end
@@ -102,7 +102,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do
context 'storage points to not existing folder' do
let(:storages_paths) do
{
- default: { path: 'tmp/this/path/doesnt/exist' }
+ default: Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/this/path/doesnt/exist')
}.with_indifferent_access
end
diff --git a/spec/lib/gitlab/health_checks/gitaly_check_spec.rb b/spec/lib/gitlab/health_checks/gitaly_check_spec.rb
new file mode 100644
index 00000000000..724beefff69
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/gitaly_check_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe Gitlab::HealthChecks::GitalyCheck do
+ let(:result_class) { Gitlab::HealthChecks::Result }
+ let(:repository_storages) { ['default'] }
+
+ before do
+ allow(described_class).to receive(:repository_storages) { repository_storages }
+ end
+
+ describe '#readiness' do
+ subject { described_class.readiness }
+
+ before do
+ expect(Gitlab::GitalyClient::HealthCheckService).to receive(:new).and_return(gitaly_check)
+ end
+
+ context 'Gitaly server is up' do
+ let(:gitaly_check) { double(check: { success: true }) }
+
+ it { is_expected.to eq([result_class.new(true, nil, shard: 'default')]) }
+ end
+
+ context 'Gitaly server is down' do
+ let(:gitaly_check) { double(check: { success: false, message: 'Connection refused' }) }
+
+ it { is_expected.to eq([result_class.new(false, 'Connection refused', shard: 'default')]) }
+ end
+ end
+
+ describe '#metrics' do
+ subject { described_class.metrics }
+
+ before do
+ expect(Gitlab::GitalyClient::HealthCheckService).to receive(:new).and_return(gitaly_check)
+ end
+
+ context 'Gitaly server is up' do
+ let(:gitaly_check) { double(check: { success: true }) }
+
+ it 'provides metrics' do
+ expect(subject).to all(have_attributes(labels: { shard: 'default' }))
+ expect(subject).to include(an_object_having_attributes(name: 'gitaly_health_check_success', value: 1))
+ expect(subject).to include(an_object_having_attributes(name: 'gitaly_health_check_latency_seconds', value: be >= 0))
+ end
+ end
+
+ context 'Gitaly server is down' do
+ let(:gitaly_check) { double(check: { success: false, message: 'Connection refused' }) }
+
+ it 'provides metrics' do
+ expect(subject).to include(an_object_having_attributes(name: 'gitaly_health_check_success', value: 0))
+ expect(subject).to include(an_object_having_attributes(name: 'gitaly_health_check_latency_seconds', value: be >= 0))
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/hook_data/issue_builder_spec.rb b/spec/lib/gitlab/hook_data/issue_builder_spec.rb
index aeacd577d18..506b2c0be20 100644
--- a/spec/lib/gitlab/hook_data/issue_builder_spec.rb
+++ b/spec/lib/gitlab/hook_data/issue_builder_spec.rb
@@ -14,7 +14,6 @@ describe Gitlab::HookData::IssueBuilder do
closed_at
confidential
created_at
- deleted_at
description
due_date
id
diff --git a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb
index 78475403f9e..b61614e4790 100644
--- a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb
+++ b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb
@@ -12,7 +12,6 @@ describe Gitlab::HookData::MergeRequestBuilder do
assignee_id
author_id
created_at
- deleted_at
description
head_pipeline_id
id
diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb
new file mode 100644
index 00000000000..d0dadfa78da
--- /dev/null
+++ b/spec/lib/gitlab/http_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe Gitlab::HTTP do
+ describe 'allow_local_requests_from_hooks_and_services is' do
+ before do
+ WebMock.stub_request(:get, /.*/).to_return(status: 200, body: 'Success')
+ end
+
+ context 'disabled' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(false)
+ end
+
+ it 'deny requests to localhost' do
+ expect { described_class.get('http://localhost:3003') }.to raise_error(Gitlab::HTTP::BlockedUrlError)
+ end
+
+ it 'deny requests to private network' do
+ expect { described_class.get('http://192.168.1.2:3003') }.to raise_error(Gitlab::HTTP::BlockedUrlError)
+ end
+
+ context 'if allow_local_requests set to true' do
+ it 'override the global value and allow requests to localhost or private network' do
+ expect { described_class.get('http://localhost:3003', allow_local_requests: true) }.not_to raise_error
+ end
+ end
+ end
+
+ context 'enabled' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(true)
+ end
+
+ it 'allow requests to localhost' do
+ expect { described_class.get('http://localhost:3003') }.not_to raise_error
+ end
+
+ it 'allow requests to private network' do
+ expect { described_class.get('http://192.168.1.2:3003') }.not_to raise_error
+ end
+
+ context 'if allow_local_requests set to false' do
+ it 'override the global value and ban requests to localhost or private network' do
+ expect { described_class.get('http://localhost:3003', allow_local_requests: false) }.to raise_error(Gitlab::HTTP::BlockedUrlError)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb
new file mode 100644
index 00000000000..ed54d87de4a
--- /dev/null
+++ b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb
@@ -0,0 +1,104 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy do
+ let!(:service) { described_class.new }
+ let!(:project) { create(:project, :with_export) }
+ let(:shared) { project.import_export_shared }
+ let!(:user) { create(:user) }
+
+ describe '#execute' do
+ before do
+ allow(service).to receive(:strategy_execute)
+ end
+
+ it 'returns if project exported file is not found' do
+ allow(project).to receive(:export_project_path).and_return(nil)
+
+ expect(service).not_to receive(:strategy_execute)
+
+ service.execute(user, project)
+ end
+
+ it 'creates a lock file in the export dir' do
+ allow(service).to receive(:delete_after_export_lock)
+
+ service.execute(user, project)
+
+ expect(lock_path_exist?).to be_truthy
+ end
+
+ context 'when the method succeeds' do
+ it 'removes the lock file' do
+ service.execute(user, project)
+
+ expect(lock_path_exist?).to be_falsey
+ end
+ end
+
+ context 'when the method fails' do
+ before do
+ allow(service).to receive(:strategy_execute).and_call_original
+ end
+
+ context 'when validation fails' do
+ before do
+ allow(service).to receive(:invalid?).and_return(true)
+ end
+
+ it 'does not create the lock file' do
+ expect(service).not_to receive(:create_or_update_after_export_lock)
+
+ service.execute(user, project)
+ end
+
+ it 'does not execute main logic' do
+ expect(service).not_to receive(:strategy_execute)
+
+ service.execute(user, project)
+ end
+
+ it 'logs validation errors in shared context' do
+ expect(service).to receive(:log_validation_errors)
+
+ service.execute(user, project)
+ end
+ end
+
+ context 'when an exception is raised' do
+ it 'removes the lock' do
+ expect { service.execute(user, project) }.to raise_error(NotImplementedError)
+
+ expect(lock_path_exist?).to be_falsey
+ end
+ end
+ end
+ end
+
+ describe '#log_validation_errors' do
+ it 'add the message to the shared context' do
+ errors = %w(test_message test_message2)
+
+ allow(service).to receive(:invalid?).and_return(true)
+ allow(service.errors).to receive(:full_messages).and_return(errors)
+
+ expect(shared).to receive(:add_error_message).twice.and_call_original
+
+ service.execute(user, project)
+
+ expect(shared.errors).to eq errors
+ end
+ end
+
+ describe '#to_json' do
+ it 'adds the current strategy class to the serialized attributes' do
+ params = { param1: 1 }
+ result = params.merge(klass: described_class.to_s).to_json
+
+ expect(described_class.new(params).to_json).to eq result
+ end
+ end
+
+ def lock_path_exist?
+ File.exist?(described_class.lock_file_path(project))
+ end
+end
diff --git a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb
new file mode 100644
index 00000000000..5fe57d9987b
--- /dev/null
+++ b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy do
+ let(:example_url) { 'http://www.example.com' }
+ let(:strategy) { subject.new(url: example_url, http_method: 'post') }
+ let!(:project) { create(:project, :with_export) }
+ let!(:user) { build(:user) }
+
+ subject { described_class }
+
+ describe 'validations' do
+ it 'only POST and PUT method allowed' do
+ %w(POST post PUT put).each do |method|
+ expect(subject.new(url: example_url, http_method: method)).to be_valid
+ end
+
+ expect(subject.new(url: example_url, http_method: 'whatever')).not_to be_valid
+ end
+
+ it 'onyl allow urls as upload urls' do
+ expect(subject.new(url: example_url)).to be_valid
+ expect(subject.new(url: 'whatever')).not_to be_valid
+ end
+ end
+
+ describe '#execute' do
+ it 'removes the exported project file after the upload' do
+ allow(strategy).to receive(:send_file)
+ allow(strategy).to receive(:handle_response_error)
+
+ expect(project).to receive(:remove_exported_project_file)
+
+ strategy.execute(user, project)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/after_export_strategy_builder_spec.rb b/spec/lib/gitlab/import_export/after_export_strategy_builder_spec.rb
new file mode 100644
index 00000000000..bf727285a9f
--- /dev/null
+++ b/spec/lib/gitlab/import_export/after_export_strategy_builder_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::AfterExportStrategyBuilder do
+ let!(:strategies_namespace) { 'Gitlab::ImportExport::AfterExportStrategies' }
+
+ describe '.build!' do
+ context 'when klass param is' do
+ it 'null it returns the default strategy' do
+ expect(described_class.build!(nil).class).to eq described_class.default_strategy
+ end
+
+ it 'not a valid class it raises StrategyNotFoundError exception' do
+ expect { described_class.build!('Whatever') }.to raise_error(described_class::StrategyNotFoundError)
+ end
+
+ it 'not a descendant of AfterExportStrategy' do
+ expect { described_class.build!('User') }.to raise_error(described_class::StrategyNotFoundError)
+ end
+ end
+
+ it 'initializes strategy with attributes param' do
+ params = { param1: 1, param2: 2, param3: 3 }
+
+ strategy = described_class.build!("#{strategies_namespace}::DownloadNotificationStrategy", params)
+
+ params.each { |k, v| expect(strategy.public_send(k)).to eq v }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 0ecb50f7110..897a5984782 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -18,6 +18,7 @@ issues:
- metrics
- timelogs
- issue_assignees
+- closed_by
events:
- author
- project
@@ -144,6 +145,9 @@ pipeline_schedule:
- pipelines
pipeline_schedule_variables:
- pipeline_schedule
+deploy_tokens:
+- project_deploy_tokens
+- projects
deploy_keys:
- user
- deploy_keys_projects
@@ -276,6 +280,12 @@ project:
- fork_network_member
- fork_network
- custom_attributes
+- lfs_file_locks
+- project_badges
+- source_of_merge_requests
+- internal_ids
+- project_deploy_tokens
+- deploy_tokens
award_emoji:
- awardable
- user
@@ -290,3 +300,7 @@ push_event_payload:
issue_assignees:
- issue
- assignee
+lfs_file_locks:
+- user
+project_badges:
+- project
diff --git a/spec/lib/gitlab/import_export/avatar_restorer_spec.rb b/spec/lib/gitlab/import_export/avatar_restorer_spec.rb
index a93a921e459..4897d604bc1 100644
--- a/spec/lib/gitlab/import_export/avatar_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/avatar_restorer_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Gitlab::ImportExport::AvatarRestorer do
include UploadHelpers
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') }
+ let(:shared) { project.import_export_shared }
let(:project) { create(:project) }
before do
diff --git a/spec/lib/gitlab/import_export/avatar_saver_spec.rb b/spec/lib/gitlab/import_export/avatar_saver_spec.rb
index 3fb5ddde8b5..f40d4bc2d08 100644
--- a/spec/lib/gitlab/import_export/avatar_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/avatar_saver_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::ImportExport::AvatarSaver do
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') }
+ let(:shared) { project.import_export_shared }
let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
let(:project_with_avatar) { create(:project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
let(:project) { create(:project) }
diff --git a/spec/lib/gitlab/import_export/file_importer_spec.rb b/spec/lib/gitlab/import_export/file_importer_spec.rb
index 162b776e107..58b9fb06cc5 100644
--- a/spec/lib/gitlab/import_export/file_importer_spec.rb
+++ b/spec/lib/gitlab/import_export/file_importer_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::ImportExport::FileImporter do
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') }
+ let(:shared) { Gitlab::ImportExport::Shared.new(nil) }
let(:export_path) { "#{Dir.tmpdir}/file_importer_spec" }
let(:valid_file) { "#{shared.export_path}/valid.json" }
let(:symlink_file) { "#{shared.export_path}/invalid.json" }
@@ -12,30 +12,62 @@ describe Gitlab::ImportExport::FileImporter do
stub_const('Gitlab::ImportExport::FileImporter::MAX_RETRIES', 0)
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
allow_any_instance_of(Gitlab::ImportExport::CommandLineUtil).to receive(:untar_zxf).and_return(true)
-
+ allow_any_instance_of(Gitlab::ImportExport::Shared).to receive(:relative_archive_path).and_return('test')
+ allow(SecureRandom).to receive(:hex).and_return('abcd')
setup_files
-
- described_class.import(archive_file: '', shared: shared)
end
after do
FileUtils.rm_rf(export_path)
end
- it 'removes symlinks in root folder' do
- expect(File.exist?(symlink_file)).to be false
- end
+ context 'normal run' do
+ before do
+ described_class.import(archive_file: '', shared: shared)
+ end
- it 'removes hidden symlinks in root folder' do
- expect(File.exist?(hidden_symlink_file)).to be false
- end
+ it 'removes symlinks in root folder' 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
- it 'removes symlinks in subfolders' do
- expect(File.exist?(subfolder_symlink_file)).to be false
+ it 'does not remove a valid file' do
+ expect(File.exist?(valid_file)).to be true
+ end
+
+ it 'creates the file in the right subfolder' do
+ expect(shared.export_path).to include('test/abcd')
+ end
end
- it 'does not remove a valid file' do
- expect(File.exist?(valid_file)).to be true
+ context 'error' do
+ before do
+ allow_any_instance_of(described_class).to receive(:wait_for_archived_file).and_raise(StandardError)
+ described_class.import(archive_file: '', shared: shared)
+ end
+
+ it 'removes symlinks in root folder' 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
+
+ it 'does not remove a valid file' do
+ expect(File.exist?(valid_file)).to be true
+ end
end
def setup_files
diff --git a/spec/lib/gitlab/import_export/fork_spec.rb b/spec/lib/gitlab/import_export/fork_spec.rb
index cfb15ee7e8b..17e06a6a83f 100644
--- a/spec/lib/gitlab/import_export/fork_spec.rb
+++ b/spec/lib/gitlab/import_export/fork_spec.rb
@@ -7,7 +7,7 @@ describe 'forked project import' do
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.full_path) }
+ let(:shared) { project.import_export_shared }
let(:forked_from_project) { create(:project, :repository) }
let(:forked_project) { fork_project(project_with_repo, nil, repository: true) }
let(:repo_saver) { Gitlab::ImportExport::RepoSaver.new(project: project_with_repo, shared: shared) }
diff --git a/spec/lib/gitlab/import_export/importer_spec.rb b/spec/lib/gitlab/import_export/importer_spec.rb
new file mode 100644
index 00000000000..991e354f499
--- /dev/null
+++ b/spec/lib/gitlab/import_export/importer_spec.rb
@@ -0,0 +1,104 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::Importer do
+ let(:user) { create(:user) }
+ let(:test_path) { "#{Dir.tmpdir}/importer_spec" }
+ let(:shared) { project.import_export_shared }
+ let(:project) { create(:project, import_source: File.join(test_path, 'exported-project.gz')) }
+
+ subject(:importer) { described_class.new(project) }
+
+ before do
+ allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(test_path)
+ FileUtils.mkdir_p(shared.export_path)
+ FileUtils.cp(Rails.root.join('spec', 'fixtures', 'exported-project.gz'), test_path)
+ allow(subject).to receive(:remove_import_file)
+ end
+
+ after do
+ FileUtils.rm_rf(test_path)
+ end
+
+ describe '#execute' do
+ it 'succeeds' do
+ importer.execute
+
+ expect(shared.errors).to be_empty
+ end
+
+ it 'extracts the archive' do
+ expect(Gitlab::ImportExport::FileImporter).to receive(:import).and_call_original
+
+ importer.execute
+ end
+
+ it 'checks the version' do
+ expect(Gitlab::ImportExport::VersionChecker).to receive(:check!).and_call_original
+
+ importer.execute
+ end
+
+ context 'all restores are executed' do
+ [
+ Gitlab::ImportExport::AvatarRestorer,
+ Gitlab::ImportExport::RepoRestorer,
+ Gitlab::ImportExport::WikiRestorer,
+ Gitlab::ImportExport::UploadsRestorer,
+ Gitlab::ImportExport::LfsRestorer,
+ Gitlab::ImportExport::StatisticsRestorer
+ ].each do |restorer|
+ it "calls the #{restorer}" do
+ fake_restorer = double(restorer.to_s)
+
+ expect(fake_restorer).to receive(:restore).and_return(true).at_least(1)
+ expect(restorer).to receive(:new).and_return(fake_restorer).at_least(1)
+
+ importer.execute
+ end
+ end
+
+ it 'restores the ProjectTree' do
+ expect(Gitlab::ImportExport::ProjectTreeRestorer).to receive(:new).and_call_original
+
+ importer.execute
+ end
+ end
+
+ context 'when project successfully restored' do
+ let!(:existing_project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, namespace: user.namespace, name: 'whatever', path: 'whatever') }
+
+ before do
+ restorers = double
+
+ allow(subject).to receive(:import_file).and_return(true)
+ allow(subject).to receive(:check_version!).and_return(true)
+ allow(subject).to receive(:restorers).and_return(restorers)
+ allow(restorers).to receive(:all?).and_return(true)
+ allow(project).to receive(:import_data).and_return(double(data: { 'original_path' => existing_project.path }))
+ end
+
+ context 'when import_data' do
+ context 'has original_path' do
+ it 'overwrites existing project' do
+ expect_any_instance_of(::Projects::OverwriteProjectService).to receive(:execute).with(existing_project)
+
+ subject.execute
+ end
+ end
+
+ context 'has not original_path' do
+ before do
+ allow(project).to receive(:import_data).and_return(double(data: {}))
+ end
+
+ it 'does not call the overwrite service' do
+ expect_any_instance_of(::Projects::OverwriteProjectService).not_to receive(:execute).with(existing_project)
+
+ subject.execute
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/lfs_restorer_spec.rb b/spec/lib/gitlab/import_export/lfs_restorer_spec.rb
new file mode 100644
index 00000000000..70eeb9ee66b
--- /dev/null
+++ b/spec/lib/gitlab/import_export/lfs_restorer_spec.rb
@@ -0,0 +1,75 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::LfsRestorer do
+ include UploadHelpers
+
+ let(:export_path) { "#{Dir.tmpdir}/lfs_object_restorer_spec" }
+ let(:project) { create(:project) }
+ let(:shared) { project.import_export_shared }
+ subject(:restorer) { described_class.new(project: project, shared: shared) }
+
+ before do
+ allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+ FileUtils.mkdir_p(shared.export_path)
+ end
+
+ after do
+ FileUtils.rm_rf(shared.export_path)
+ end
+
+ describe '#restore' do
+ context 'when the archive contains lfs files' do
+ let(:dummy_lfs_file_path) { File.join(shared.export_path, 'lfs-objects', 'dummy') }
+
+ def create_lfs_object_with_content(content)
+ dummy_lfs_file = Tempfile.new('existing')
+ File.write(dummy_lfs_file.path, content)
+ size = dummy_lfs_file.size
+ oid = LfsObject.calculate_oid(dummy_lfs_file.path)
+ LfsObject.create!(oid: oid, size: size, file: dummy_lfs_file)
+ end
+
+ before do
+ FileUtils.mkdir_p(File.dirname(dummy_lfs_file_path))
+ File.write(dummy_lfs_file_path, 'not very large')
+ allow(restorer).to receive(:lfs_file_paths).and_return([dummy_lfs_file_path])
+ end
+
+ it 'creates an lfs object for the project' do
+ expect { restorer.restore }.to change { project.reload.lfs_objects.size }.by(1)
+ end
+
+ it 'assigns the file correctly' do
+ restorer.restore
+
+ expect(project.lfs_objects.first.file.read).to eq('not very large')
+ end
+
+ it 'links an existing LFS object if it existed' do
+ lfs_object = create_lfs_object_with_content('not very large')
+
+ restorer.restore
+
+ expect(project.lfs_objects).to include(lfs_object)
+ end
+
+ it 'succeeds' do
+ expect(restorer.restore).to be_truthy
+ expect(shared.errors).to be_empty
+ end
+
+ it 'stores the upload' do
+ expect_any_instance_of(LfsObjectUploader).to receive(:store!)
+
+ restorer.restore
+ end
+ end
+
+ context 'without any LFS-objects' do
+ it 'succeeds' do
+ expect(restorer.restore).to be_truthy
+ expect(shared.errors).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/lfs_saver_spec.rb b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
new file mode 100644
index 00000000000..9b0e21deb2e
--- /dev/null
+++ b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::LfsSaver do
+ let(:shared) { project.import_export_shared }
+ let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
+ let(:project) { create(:project) }
+
+ subject(:saver) { described_class.new(project: project, shared: shared) }
+
+ before do
+ allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+ FileUtils.mkdir_p(shared.export_path)
+ end
+
+ after do
+ FileUtils.rm_rf(shared.export_path)
+ end
+
+ describe '#save' do
+ context 'when the project has LFS objects locally stored' do
+ let(:lfs_object) { create(:lfs_object, :with_file) }
+
+ before do
+ project.lfs_objects << lfs_object
+ end
+
+ it 'does not cause errors' do
+ saver.save
+
+ expect(shared.errors).to be_empty
+ end
+
+ it 'copies the file in the correct location when there is an lfs object' do
+ saver.save
+
+ expect(File).to exist("#{shared.export_path}/lfs-objects/#{lfs_object.oid}")
+ end
+ end
+
+ context 'when the LFS objects are stored in object storage' do
+ let(:lfs_object) { create(:lfs_object, :object_storage) }
+
+ before do
+ allow(LfsObjectUploader).to receive(:object_store_enabled?).and_return(true)
+ allow(lfs_object.file).to receive(:url).and_return('http://my-object-storage.local')
+ project.lfs_objects << lfs_object
+ end
+
+ it 'downloads the file to include in an archive' do
+ fake_uri = double
+ exported_file_path = "#{shared.export_path}/lfs-objects/#{lfs_object.oid}"
+
+ expect(fake_uri).to receive(:open).and_return(StringIO.new('LFS file content'))
+ expect(URI).to receive(:parse).with('http://my-object-storage.local').and_return(fake_uri)
+
+ saver.save
+
+ expect(File.read(exported_file_path)).to eq('LFS file content')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/project.group.json b/spec/lib/gitlab/import_export/project.group.json
index 82a1fbd2fc5..1a561e81e4a 100644
--- a/spec/lib/gitlab/import_export/project.group.json
+++ b/spec/lib/gitlab/import_export/project.group.json
@@ -54,7 +54,6 @@
"iid": 1,
"updated_by_id": 1,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"lock_version": null,
@@ -134,7 +133,6 @@
"iid": 2,
"updated_by_id": 1,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"lock_version": null,
diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json
index f0752649121..6d63749296e 100644
--- a/spec/lib/gitlab/import_export/project.json
+++ b/spec/lib/gitlab/import_export/project.json
@@ -2,7 +2,6 @@
"description": "Nisi et repellendus ut enim quo accusamus vel magnam.",
"visibility_level": 10,
"archived": false,
- "description_html": "description",
"labels": [
{
"id": 2,
@@ -14,8 +13,7 @@
"template": false,
"description": "",
"type": "ProjectLabel",
- "priorities": [
- ]
+ "priorities": []
},
{
"id": 3,
@@ -44,7 +42,6 @@
{
"id": 40,
"title": "Voluptatem",
- "assignee_id": 1,
"author_id": 22,
"project_id": 5,
"created_at": "2016-06-14T15:02:08.340Z",
@@ -56,14 +53,29 @@
"iid": 10,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"test_ee_field": "test",
"issue_assignees": [
{
"user_id": 1,
- "issue_id": 1
+ "issue_id": 40
+ },
+ {
+ "user_id": 15,
+ "issue_id": 40
+ },
+ {
+ "user_id": 16,
+ "issue_id": 40
+ },
+ {
+ "user_id": 16,
+ "issue_id": 40
+ },
+ {
+ "user_id": 6,
+ "issue_id": 40
}
],
"milestone": {
@@ -161,9 +173,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 352,
@@ -185,9 +195,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 353,
@@ -209,9 +217,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 354,
@@ -233,9 +239,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 355,
@@ -257,9 +261,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 356,
@@ -281,9 +283,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 357,
@@ -305,9 +305,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 358,
@@ -329,16 +327,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 39,
- "title": "Delectus veniam ratione in eos culpa et natus molestiae earum aut.",
- "assignee_id": 20,
+ "title": "Issue without assignees",
"author_id": 22,
"project_id": 5,
"created_at": "2016-06-14T15:02:08.233Z",
@@ -350,9 +345,9 @@
"iid": 9,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
+ "issue_assignees": [],
"milestone": {
"id": 1,
"title": "test milestone",
@@ -397,9 +392,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 360,
@@ -421,9 +414,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 361,
@@ -445,9 +436,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 362,
@@ -469,9 +458,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 363,
@@ -493,9 +480,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 364,
@@ -517,9 +502,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 365,
@@ -541,9 +524,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 366,
@@ -565,16 +546,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 38,
"title": "Quasi adipisci non cupiditate dolorem quo qui earum sed.",
- "assignee_id": 1,
"author_id": 6,
"project_id": 5,
"created_at": "2016-06-14T15:02:08.154Z",
@@ -586,7 +564,6 @@
"iid": 8,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"label_links": [
@@ -631,9 +608,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 368,
@@ -655,9 +630,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 369,
@@ -679,9 +652,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 370,
@@ -703,9 +674,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 371,
@@ -727,9 +696,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 372,
@@ -751,9 +718,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 373,
@@ -775,9 +740,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 374,
@@ -799,16 +762,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 37,
"title": "Cupiditate quo aut ducimus minima molestiae vero numquam possimus.",
- "assignee_id": 15,
"author_id": 20,
"project_id": 5,
"created_at": "2016-06-14T15:02:08.051Z",
@@ -820,7 +780,6 @@
"iid": 7,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"notes": [
@@ -844,9 +803,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 376,
@@ -868,9 +825,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 377,
@@ -892,9 +847,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 378,
@@ -916,9 +869,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 379,
@@ -940,9 +891,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 380,
@@ -964,9 +913,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 381,
@@ -988,9 +935,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 382,
@@ -1012,16 +957,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 36,
"title": "Necessitatibus dolor est enim quia rem suscipit quidem voluptas ullam.",
- "assignee_id": 20,
"author_id": 16,
"project_id": 5,
"created_at": "2016-06-14T15:02:07.958Z",
@@ -1033,7 +975,6 @@
"iid": 6,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"notes": [
@@ -1057,9 +998,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 384,
@@ -1081,9 +1020,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 385,
@@ -1105,9 +1042,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 386,
@@ -1129,9 +1064,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 387,
@@ -1153,9 +1086,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 388,
@@ -1177,9 +1108,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 389,
@@ -1201,9 +1130,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 390,
@@ -1225,16 +1152,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 35,
"title": "Repellat praesentium deserunt maxime incidunt harum porro qui.",
- "assignee_id": 6,
"author_id": 20,
"project_id": 5,
"created_at": "2016-06-14T15:02:07.832Z",
@@ -1246,7 +1170,6 @@
"iid": 5,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"notes": [
@@ -1270,9 +1193,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 392,
@@ -1294,9 +1215,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 393,
@@ -1318,9 +1237,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 394,
@@ -1342,9 +1259,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 395,
@@ -1366,9 +1281,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 396,
@@ -1390,9 +1303,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 397,
@@ -1414,9 +1325,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 398,
@@ -1438,16 +1347,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 34,
"title": "Ullam expedita deserunt libero consequatur quia dolor harum perferendis facere quidem.",
- "assignee_id": 20,
"author_id": 1,
"project_id": 5,
"created_at": "2016-06-14T15:02:07.717Z",
@@ -1459,7 +1365,6 @@
"iid": 4,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"notes": [
@@ -1483,9 +1388,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 400,
@@ -1507,9 +1410,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 401,
@@ -1531,9 +1432,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 402,
@@ -1555,9 +1454,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 403,
@@ -1579,9 +1476,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 404,
@@ -1603,9 +1498,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 405,
@@ -1627,9 +1520,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 406,
@@ -1651,16 +1542,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 33,
"title": "Numquam accusamus eos iste exercitationem magni non inventore.",
- "assignee_id": 15,
"author_id": 26,
"project_id": 5,
"created_at": "2016-06-14T15:02:07.611Z",
@@ -1672,7 +1560,6 @@
"iid": 3,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"notes": [
@@ -1696,9 +1583,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 408,
@@ -1720,9 +1605,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 409,
@@ -1744,9 +1627,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 410,
@@ -1768,9 +1649,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 411,
@@ -1792,9 +1671,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 412,
@@ -1816,9 +1693,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 413,
@@ -1840,9 +1715,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 414,
@@ -1864,16 +1737,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 32,
"title": "Necessitatibus magnam qui at velit consequatur perspiciatis.",
- "assignee_id": 22,
"author_id": 15,
"project_id": 5,
"created_at": "2016-06-14T15:02:07.431Z",
@@ -1885,7 +1755,6 @@
"iid": 2,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"notes": [
@@ -1909,9 +1778,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 416,
@@ -1933,9 +1800,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 417,
@@ -1957,9 +1822,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 418,
@@ -1981,9 +1844,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 419,
@@ -2005,9 +1866,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 420,
@@ -2029,9 +1888,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 421,
@@ -2053,9 +1910,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 422,
@@ -2077,16 +1932,13 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
},
{
"id": 31,
"title": "Libero nam magnam incidunt eaque placeat error et.",
- "assignee_id": 1,
"author_id": 16,
"project_id": 5,
"created_at": "2016-06-14T15:02:07.280Z",
@@ -2098,7 +1950,6 @@
"iid": 1,
"updated_by_id": null,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"notes": [
@@ -2122,9 +1973,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 424,
@@ -2146,9 +1995,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 425,
@@ -2170,9 +2017,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 426,
@@ -2194,9 +2039,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 427,
@@ -2218,9 +2061,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 428,
@@ -2242,9 +2083,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 429,
@@ -2266,9 +2105,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 430,
@@ -2290,9 +2127,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
]
}
@@ -2388,12 +2223,8 @@
]
}
],
- "snippets": [
-
- ],
- "releases": [
-
- ],
+ "snippets": [],
+ "releases": [],
"project_members": [
{
"id": 36,
@@ -2504,7 +2335,6 @@
"merge_when_pipeline_succeeds": true,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 671,
@@ -2526,9 +2356,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 672,
@@ -2550,9 +2378,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 673,
@@ -2574,9 +2400,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 674,
@@ -2598,9 +2422,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 675,
@@ -2622,9 +2444,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 676,
@@ -2646,9 +2466,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 677,
@@ -2670,9 +2488,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 678,
@@ -2694,9 +2510,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -2707,7 +2521,7 @@
"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",
+ "message": "Feature conflcit added\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-08-06T08:35:52.000+02:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2719,7 +2533,7 @@
"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",
+ "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T10:01:38.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2731,7 +2545,7 @@
"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",
+ "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:57:31.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2743,7 +2557,7 @@
"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",
+ "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:54:21.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2755,7 +2569,7 @@
"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",
+ "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:49:50.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2767,7 +2581,7 @@
"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",
+ "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:48:32.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -2845,7 +2659,7 @@
{
"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",
+ "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\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => 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 << stdout.read\n @cmd_output << stderr.read\n",
"new_path": "files/ruby/popen.rb",
"old_path": "files/ruby/popen.rb",
"a_mode": "100644",
@@ -2948,7 +2762,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 679,
@@ -2970,9 +2783,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 680,
@@ -2994,9 +2805,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 681,
@@ -3018,9 +2827,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 682,
@@ -3042,9 +2849,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 683,
@@ -3066,9 +2871,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 684,
@@ -3090,9 +2893,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 685,
@@ -3114,9 +2915,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 686,
@@ -3138,9 +2937,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -3151,7 +2948,7 @@
"merge_request_diff_id": 26,
"sha": "0b4bc9a49b562e85de7cc9e834518ea6828729b9",
"relative_order": 0,
- "message": "Feature added\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Feature added\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:26:01.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -3228,7 +3025,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 777,
@@ -3250,9 +3046,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 778,
@@ -3274,9 +3068,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 779,
@@ -3298,9 +3090,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 780,
@@ -3322,9 +3112,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 781,
@@ -3346,9 +3134,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 782,
@@ -3370,9 +3156,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 783,
@@ -3394,9 +3178,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 784,
@@ -3418,9 +3200,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -3508,7 +3288,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 785,
@@ -3530,9 +3309,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 786,
@@ -3554,9 +3331,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 787,
@@ -3578,9 +3353,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 788,
@@ -3602,9 +3375,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 789,
@@ -3626,9 +3397,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 790,
@@ -3650,9 +3419,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 791,
@@ -3674,9 +3441,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 792,
@@ -3698,9 +3463,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -3891,7 +3654,7 @@
"merge_request_diff_id": 14,
"relative_order": 15,
"sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
- "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T10:01:38.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -3903,7 +3666,7 @@
"merge_request_diff_id": 14,
"relative_order": 16,
"sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
- "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:57:31.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -3915,7 +3678,7 @@
"merge_request_diff_id": 14,
"relative_order": 17,
"sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9",
- "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:54:21.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -3927,7 +3690,7 @@
"merge_request_diff_id": 14,
"relative_order": 18,
"sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08",
- "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:49:50.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -3939,7 +3702,7 @@
"merge_request_diff_id": 14,
"relative_order": 19,
"sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8",
- "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:48:32.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -4030,7 +3793,7 @@
{
"merge_request_diff_id": 14,
"relative_order": 6,
- "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n",
+ "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n",
"new_path": "files/images/wm.svg",
"old_path": "files/images/wm.svg",
"a_mode": "0",
@@ -4056,7 +3819,7 @@
{
"merge_request_diff_id": 14,
"relative_order": 8,
- "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",
+ "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\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => 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 << stdout.read\n @cmd_output << stderr.read\n",
"new_path": "files/ruby/popen.rb",
"old_path": "files/ruby/popen.rb",
"a_mode": "100644",
@@ -4198,7 +3961,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 793,
@@ -4222,7 +3984,7 @@
},
"events": [
{
- "merge_request_diff_id": 14,
+ "merge_request_diff_id": 14,
"id": 529,
"target_type": "Note",
"target_id": 793,
@@ -4254,9 +4016,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 795,
@@ -4278,9 +4038,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 796,
@@ -4302,9 +4060,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 797,
@@ -4326,9 +4082,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 798,
@@ -4350,9 +4104,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 799,
@@ -4374,9 +4126,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 800,
@@ -4398,9 +4148,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -4618,7 +4366,7 @@
{
"merge_request_diff_id": 13,
"relative_order": 2,
- "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n",
+ "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n",
"new_path": "files/images/wm.svg",
"old_path": "files/images/wm.svg",
"a_mode": "0",
@@ -4734,7 +4482,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 801,
@@ -4756,9 +4503,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 802,
@@ -4780,9 +4525,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 803,
@@ -4804,9 +4547,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 804,
@@ -4828,9 +4569,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 805,
@@ -4852,9 +4591,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 806,
@@ -4876,9 +4613,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 807,
@@ -4900,9 +4635,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 808,
@@ -4924,9 +4657,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -5120,7 +4851,7 @@
{
"merge_request_diff_id": 12,
"relative_order": 2,
- "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n",
+ "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n",
"new_path": "files/images/wm.svg",
"old_path": "files/images/wm.svg",
"a_mode": "0",
@@ -5223,7 +4954,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 809,
@@ -5245,9 +4975,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 810,
@@ -5269,9 +4997,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 811,
@@ -5293,9 +5019,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 812,
@@ -5317,9 +5041,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 813,
@@ -5341,9 +5063,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 814,
@@ -5365,9 +5085,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 815,
@@ -5389,9 +5107,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 816,
@@ -5413,18 +5129,14 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
"id": 11,
"state": "empty",
- "merge_request_diff_commits": [
- ],
- "merge_request_diff_files": [
- ],
+ "merge_request_diff_commits": [],
+ "merge_request_diff_files": [],
"merge_request_id": 11,
"created_at": "2016-06-14T15:02:23.772Z",
"updated_at": "2016-06-14T15:02:23.833Z",
@@ -5478,7 +5190,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 817,
@@ -5500,9 +5211,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 818,
@@ -5524,9 +5233,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 819,
@@ -5548,9 +5255,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 820,
@@ -5572,9 +5277,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 821,
@@ -5596,9 +5299,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 822,
@@ -5620,9 +5321,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 823,
@@ -5644,9 +5343,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 824,
@@ -5668,9 +5365,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -5861,7 +5556,7 @@
"merge_request_diff_id": 10,
"relative_order": 16,
"sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e",
- "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T10:01:38.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -5873,7 +5568,7 @@
"merge_request_diff_id": 10,
"relative_order": 17,
"sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d",
- "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:57:31.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -5885,7 +5580,7 @@
"merge_request_diff_id": 10,
"relative_order": 18,
"sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9",
- "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:54:21.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -5897,7 +5592,7 @@
"merge_request_diff_id": 10,
"relative_order": 19,
"sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08",
- "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:49:50.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -5909,7 +5604,7 @@
"merge_request_diff_id": 10,
"relative_order": 20,
"sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8",
- "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
+ "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n",
"authored_date": "2014-02-27T09:48:32.000+01:00",
"author_name": "Dmitriy Zaporozhets",
"author_email": "dmitriy.zaporozhets@gmail.com",
@@ -6000,7 +5695,7 @@
{
"merge_request_diff_id": 10,
"relative_order": 6,
- "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n",
+ "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n+<svg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\">\n+ <!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->\n+ <title>wm</title>\n+ <desc>Created with Sketch.</desc>\n+ <defs>\n+ <path id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"></path>\n+ </defs>\n+ <g id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\">\n+ <path d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\">\n+ <g id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\">\n+ <g id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\">\n+ <path d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"></path>\n+ </g>\n+ <g id=\"g16\">\n+ <g id=\"g18-Clipped\">\n+ <mask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\">\n+ <use xlink:href=\"#path-1\"></use>\n+ </mask>\n+ <g id=\"path22\"></g>\n+ <g id=\"g18\" mask=\"url(#mask-2)\">\n+ <g transform=\"translate(382.736659, 312.879425)\">\n+ <g id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\">\n+ <path d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\">\n+ <path d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\">\n+ <path d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\">\n+ <path d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <path d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <path d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"></path>\n+ <g id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\">\n+ <path d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\">\n+ <path d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path54\"></g>\n+ </g>\n+ <g id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\">\n+ <path d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <g id=\"path62\"></g>\n+ </g>\n+ <g id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\">\n+ <path d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\">\n+ <g id=\"path70\"></g>\n+ </g>\n+ <g id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\">\n+ <path d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\">\n+ <path d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\">\n+ <path d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ <g id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\">\n+ <path d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"></path>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+ </g>\n+</svg>\n\\ No newline at end of file\n",
"new_path": "files/images/wm.svg",
"old_path": "files/images/wm.svg",
"a_mode": "0",
@@ -6026,7 +5721,7 @@
{
"merge_request_diff_id": 10,
"relative_order": 8,
- "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",
+ "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\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => 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 << stdout.read\n @cmd_output << stderr.read\n",
"new_path": "files/ruby/popen.rb",
"old_path": "files/ruby/popen.rb",
"a_mode": "100644",
@@ -6168,7 +5863,6 @@
"merge_when_pipeline_succeeds": false,
"merge_user_id": null,
"merge_commit_sha": null,
- "deleted_at": null,
"notes": [
{
"id": 825,
@@ -6190,9 +5884,7 @@
"author": {
"name": "User 4"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 826,
@@ -6214,9 +5906,7 @@
"author": {
"name": "User 3"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 827,
@@ -6238,9 +5928,7 @@
"author": {
"name": "User 0"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 828,
@@ -6262,9 +5950,7 @@
"author": {
"name": "Ottis Schuster II"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 829,
@@ -6286,9 +5972,7 @@
"author": {
"name": "Rhett Emmerich IV"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 830,
@@ -6310,9 +5994,7 @@
"author": {
"name": "Burdette Bernier"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 831,
@@ -6334,9 +6016,7 @@
"author": {
"name": "Ari Wintheiser"
},
- "events": [
-
- ]
+ "events": []
},
{
"id": 832,
@@ -6358,9 +6038,7 @@
"author": {
"name": "Administrator"
},
- "events": [
-
- ]
+ "events": []
}
],
"merge_request_diff": {
@@ -6465,78 +6143,88 @@
}
}
],
- "statuses": [
- {
- "id": 71,
- "project_id": 5,
- "status": "failed",
- "finished_at": "2016-03-29T06:28:12.630Z",
- "trace": null,
- "created_at": "2016-03-22T15:20:35.772Z",
- "updated_at": "2016-03-29T06:28:12.634Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 36,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 1",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": null
- },
- "artifacts_metadata": {
- "url": null
- },
- "erased_by_id": null,
- "erased_at": null,
- "type": "Ci::Build",
- "token": "abcd"
- },
- {
- "id": 72,
- "project_id": 5,
- "status": "success",
- "finished_at": null,
- "trace": "Porro ea qui ut dolores. Labore ab nemo explicabo aspernatur quis voluptates corporis. Et quasi delectus est sit aperiam perspiciatis asperiores. Repudiandae cum aut consectetur accusantium officia sunt.\n\nQuidem dolore iusto quaerat ut aut inventore et molestiae. Libero voluptates atque nemo qui. Nulla temporibus ipsa similique facere.\n\nAliquam ipsam perferendis qui fugit accusantium omnis id voluptatum. Dignissimos aliquid dicta eos voluptatem assumenda quia. Sed autem natus unde dolor et non nisi et. Consequuntur nihil consequatur rerum est.\n\nSimilique neque est iste ducimus qui fuga cupiditate. Libero autem est aut fuga. Consectetur natus quis non ducimus ut dolore. Magni voluptatibus eius et maxime aut.\n\nAd officiis tempore voluptate vitae corrupti explicabo labore est. Consequatur expedita et sunt nihil aut. Deleniti porro iusto molestiae et beatae.\n\nDeleniti modi nulla qui et labore sequi corrupti. Qui voluptatem assumenda eum cupiditate et. Nesciunt ipsam ut ea possimus eum. Consectetur quidem suscipit atque dolore itaque voluptatibus et cupiditate.",
- "created_at": "2016-03-22T15:20:35.777Z",
- "updated_at": "2016-03-22T15:20:35.777Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 36,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 2",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/72/p5_build_artifacts.zip"
- },
- "artifacts_metadata": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/72/p5_build_artifacts_metadata.gz"
- },
- "erased_by_id": null,
- "erased_at": null
+ "stages": [
+ {
+ "id": 11,
+ "project_id": 5,
+ "pipeline_id": 36,
+ "name": "test",
+ "status": 1,
+ "created_at": "2016-03-22T15:44:44.772Z",
+ "updated_at": "2016-03-29T06:44:44.634Z",
+ "statuses": [
+ {
+ "id": 71,
+ "project_id": 5,
+ "status": "failed",
+ "finished_at": "2016-03-29T06:28:12.630Z",
+ "trace": null,
+ "created_at": "2016-03-22T15:20:35.772Z",
+ "updated_at": "2016-03-29T06:28:12.634Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 36,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 1",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "stage_id": 11,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null,
+ "type": "Ci::Build",
+ "token": "abcd"
+ },
+ {
+ "id": 72,
+ "project_id": 5,
+ "status": "success",
+ "finished_at": null,
+ "trace": "Porro ea qui ut dolores. Labore ab nemo explicabo aspernatur quis voluptates corporis. Et quasi delectus est sit aperiam perspiciatis asperiores. Repudiandae cum aut consectetur accusantium officia sunt.\n\nQuidem dolore iusto quaerat ut aut inventore et molestiae. Libero voluptates atque nemo qui. Nulla temporibus ipsa similique facere.\n\nAliquam ipsam perferendis qui fugit accusantium omnis id voluptatum. Dignissimos aliquid dicta eos voluptatem assumenda quia. Sed autem natus unde dolor et non nisi et. Consequuntur nihil consequatur rerum est.\n\nSimilique neque est iste ducimus qui fuga cupiditate. Libero autem est aut fuga. Consectetur natus quis non ducimus ut dolore. Magni voluptatibus eius et maxime aut.\n\nAd officiis tempore voluptate vitae corrupti explicabo labore est. Consequatur expedita et sunt nihil aut. Deleniti porro iusto molestiae et beatae.\n\nDeleniti modi nulla qui et labore sequi corrupti. Qui voluptatem assumenda eum cupiditate et. Nesciunt ipsam ut ea possimus eum. Consectetur quidem suscipit atque dolore itaque voluptatibus et cupiditate.",
+ "created_at": "2016-03-22T15:20:35.777Z",
+ "updated_at": "2016-03-22T15:20:35.777Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 36,
+ "commands": "$ deploy command",
+ "job_id": null,
+ "name": "test build 2",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "deploy",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "stage_id": 12,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ }
+ ]
+ },
+ {
+ "id": 12,
+ "project_id": 5,
+ "pipeline_id": 36,
+ "name": "deploy",
+ "status": 2,
+ "created_at": "2016-03-22T15:45:45.772Z",
+ "updated_at": "2016-03-29T06:45:45.634Z"
}
]
},
@@ -6556,76 +6244,75 @@
"started_at": null,
"finished_at": null,
"duration": null,
- "statuses": [
- {
- "id": 74,
- "project_id": 5,
- "status": "success",
- "finished_at": null,
- "trace": "Ad ut quod repudiandae iste dolor doloribus. Adipisci consequuntur deserunt omnis quasi eveniet et sed fugit. Aut nemo omnis molestiae impedit ex consequatur ducimus. Voluptatum exercitationem quia aut est et hic dolorem.\n\nQuasi repellendus et eaque magni eum facilis. Dolorem aperiam nam nihil pariatur praesentium ad aliquam. Commodi enim et eos tenetur. Odio voluptatibus laboriosam mollitia rerum exercitationem magnam consequuntur. Tenetur ea vel eum corporis.\n\nVoluptatibus optio in aliquid est voluptates. Ad a ut ab placeat vero blanditiis. Earum aspernatur quia beatae expedita voluptatem dignissimos provident. Quis minima id nemo ut aut est veritatis provident.\n\nRerum voluptatem quidem eius maiores magnam veniam. Voluptatem aperiam aut voluptate et nulla deserunt voluptas. Quaerat aut accusantium laborum est dolorem architecto reiciendis. Aliquam asperiores doloribus omnis maxime enim nesciunt. Eum aut rerum repellendus debitis et ut eius.\n\nQuaerat assumenda ea sit consequatur autem in. Cum eligendi voluptatem quo sed. Ut fuga iusto cupiditate autem sint.\n\nOfficia totam officiis architecto corporis molestiae amet ut. Tempora sed dolorum rerum omnis voluptatem accusantium sit eum. Quia debitis ipsum quidem aliquam inventore sunt consequatur qui.",
- "created_at": "2016-03-22T15:20:35.846Z",
- "updated_at": "2016-03-22T15:20:35.846Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 37,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 2",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/74/p5_build_artifacts.zip"
- },
- "artifacts_metadata": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/74/p5_build_artifacts_metadata.gz"
- },
- "erased_by_id": null,
- "erased_at": null
- },
- {
- "id": 73,
- "project_id": 5,
- "status": "canceled",
- "finished_at": null,
- "trace": null,
- "created_at": "2016-03-22T15:20:35.842Z",
- "updated_at": "2016-03-22T15:20:35.842Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 37,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 1",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": null
- },
- "artifacts_metadata": {
- "url": null
- },
- "erased_by_id": null,
- "erased_at": null
+ "stages": [
+ {
+ "id": 21,
+ "project_id": 5,
+ "pipeline_id": 37,
+ "name": "test",
+ "status": 1,
+ "created_at": "2016-03-22T15:44:44.772Z",
+ "updated_at": "2016-03-29T06:44:44.634Z",
+ "statuses": [
+ {
+ "id": 74,
+ "project_id": 5,
+ "status": "success",
+ "finished_at": null,
+ "trace": "Ad ut quod repudiandae iste dolor doloribus. Adipisci consequuntur deserunt omnis quasi eveniet et sed fugit. Aut nemo omnis molestiae impedit ex consequatur ducimus. Voluptatum exercitationem quia aut est et hic dolorem.\n\nQuasi repellendus et eaque magni eum facilis. Dolorem aperiam nam nihil pariatur praesentium ad aliquam. Commodi enim et eos tenetur. Odio voluptatibus laboriosam mollitia rerum exercitationem magnam consequuntur. Tenetur ea vel eum corporis.\n\nVoluptatibus optio in aliquid est voluptates. Ad a ut ab placeat vero blanditiis. Earum aspernatur quia beatae expedita voluptatem dignissimos provident. Quis minima id nemo ut aut est veritatis provident.\n\nRerum voluptatem quidem eius maiores magnam veniam. Voluptatem aperiam aut voluptate et nulla deserunt voluptas. Quaerat aut accusantium laborum est dolorem architecto reiciendis. Aliquam asperiores doloribus omnis maxime enim nesciunt. Eum aut rerum repellendus debitis et ut eius.\n\nQuaerat assumenda ea sit consequatur autem in. Cum eligendi voluptatem quo sed. Ut fuga iusto cupiditate autem sint.\n\nOfficia totam officiis architecto corporis molestiae amet ut. Tempora sed dolorum rerum omnis voluptatem accusantium sit eum. Quia debitis ipsum quidem aliquam inventore sunt consequatur qui.",
+ "created_at": "2016-03-22T15:20:35.846Z",
+ "updated_at": "2016-03-22T15:20:35.846Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 37,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 2",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ },
+ {
+ "id": 73,
+ "project_id": 5,
+ "status": "canceled",
+ "finished_at": null,
+ "trace": null,
+ "created_at": "2016-03-22T15:20:35.842Z",
+ "updated_at": "2016-03-22T15:20:35.842Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 37,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 1",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ }
+ ]
}
]
},
@@ -6645,76 +6332,75 @@
"started_at": null,
"finished_at": null,
"duration": null,
- "statuses": [
- {
- "id": 76,
- "project_id": 5,
- "status": "success",
- "finished_at": null,
- "trace": "Et rerum quia ea cumque ut modi non. Libero eaque ipsam architecto maiores expedita deleniti. Ratione quia qui est id.\n\nQuod sit officiis sed unde inventore veniam quisquam velit. Ea harum cum quibusdam quisquam minima quo possimus non. Temporibus itaque aliquam aut rerum veritatis at.\n\nMagnam ipsum eius recusandae qui quis sit maiores eum. Et animi iusto aut itaque. Doloribus harum deleniti nobis accusantium et libero.\n\nRerum fuga perferendis magni commodi officiis id repudiandae. Consequatur ratione consequatur suscipit facilis sunt iure est dicta. Qui unde quasi facilis et quae nesciunt. Magnam iste et nobis officiis tenetur. Aspernatur quo et temporibus non in.\n\nNisi rerum velit est ad enim sint molestiae consequuntur. Quaerat nisi nesciunt quasi officiis. Possimus non blanditiis laborum quos.\n\nRerum laudantium facere animi qui. Ipsa est iusto magnam nihil. Enim omnis occaecati non dignissimos ut recusandae eum quasi. Qui maxime dolor et nemo voluptates incidunt quia.",
- "created_at": "2016-03-22T15:20:35.882Z",
- "updated_at": "2016-03-22T15:20:35.882Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 38,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 2",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/76/p5_build_artifacts.zip"
- },
- "artifacts_metadata": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/76/p5_build_artifacts_metadata.gz"
- },
- "erased_by_id": null,
- "erased_at": null
- },
- {
- "id": 75,
- "project_id": 5,
- "status": "failed",
- "finished_at": null,
- "trace": "Sed et iste recusandae dicta corporis. Sunt alias porro fugit sunt. Fugiat omnis nihil dignissimos aperiam explicabo doloremque sit aut. Harum fugit expedita quia rerum ut consequatur laboriosam aliquam.\n\nNatus libero ut ut tenetur earum. Tempora omnis autem omnis et libero dolores illum autem. Deleniti eos sunt mollitia ipsam. Cum dolor repellendus dolorum sequi officia. Ullam sunt in aut pariatur excepturi.\n\nDolor nihil debitis et est eos. Cumque eos eum saepe ducimus autem. Alias architecto consequatur aut pariatur possimus. Aut quos aut incidunt quam velit et. Quas voluptatum ad dolorum dignissimos.\n\nUt voluptates consectetur illo et. Est commodi accusantium vel quo. Eos qui fugiat soluta porro.\n\nRatione possimus alias vel maxime sint totam est repellat. Ipsum corporis eos sint voluptatem eos odit. Temporibus libero nulla harum eligendi labore similique ratione magnam. Suscipit sequi in omnis neque.\n\nLaudantium dolor amet omnis placeat mollitia aut molestiae. Aut rerum similique ipsum quod illo quas unde. Sunt aut veritatis eos omnis porro. Rem veritatis mollitia praesentium dolorem. Consequatur sequi ad cumque earum omnis quia necessitatibus.",
- "created_at": "2016-03-22T15:20:35.864Z",
- "updated_at": "2016-03-22T15:20:35.864Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 38,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 1",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/75/p5_build_artifacts.zip"
- },
- "artifacts_metadata": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/75/p5_build_artifacts_metadata.gz"
- },
- "erased_by_id": null,
- "erased_at": null
+ "stages": [
+ {
+ "id": 22,
+ "project_id": 5,
+ "pipeline_id": 38,
+ "name": "test",
+ "status": 1,
+ "created_at": "2016-03-22T15:44:44.772Z",
+ "updated_at": "2016-03-29T06:44:44.634Z",
+ "statuses": [
+ {
+ "id": 76,
+ "project_id": 5,
+ "status": "success",
+ "finished_at": null,
+ "trace": "Et rerum quia ea cumque ut modi non. Libero eaque ipsam architecto maiores expedita deleniti. Ratione quia qui est id.\n\nQuod sit officiis sed unde inventore veniam quisquam velit. Ea harum cum quibusdam quisquam minima quo possimus non. Temporibus itaque aliquam aut rerum veritatis at.\n\nMagnam ipsum eius recusandae qui quis sit maiores eum. Et animi iusto aut itaque. Doloribus harum deleniti nobis accusantium et libero.\n\nRerum fuga perferendis magni commodi officiis id repudiandae. Consequatur ratione consequatur suscipit facilis sunt iure est dicta. Qui unde quasi facilis et quae nesciunt. Magnam iste et nobis officiis tenetur. Aspernatur quo et temporibus non in.\n\nNisi rerum velit est ad enim sint molestiae consequuntur. Quaerat nisi nesciunt quasi officiis. Possimus non blanditiis laborum quos.\n\nRerum laudantium facere animi qui. Ipsa est iusto magnam nihil. Enim omnis occaecati non dignissimos ut recusandae eum quasi. Qui maxime dolor et nemo voluptates incidunt quia.",
+ "created_at": "2016-03-22T15:20:35.882Z",
+ "updated_at": "2016-03-22T15:20:35.882Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 38,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 2",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ },
+ {
+ "id": 75,
+ "project_id": 5,
+ "status": "failed",
+ "finished_at": null,
+ "trace": "Sed et iste recusandae dicta corporis. Sunt alias porro fugit sunt. Fugiat omnis nihil dignissimos aperiam explicabo doloremque sit aut. Harum fugit expedita quia rerum ut consequatur laboriosam aliquam.\n\nNatus libero ut ut tenetur earum. Tempora omnis autem omnis et libero dolores illum autem. Deleniti eos sunt mollitia ipsam. Cum dolor repellendus dolorum sequi officia. Ullam sunt in aut pariatur excepturi.\n\nDolor nihil debitis et est eos. Cumque eos eum saepe ducimus autem. Alias architecto consequatur aut pariatur possimus. Aut quos aut incidunt quam velit et. Quas voluptatum ad dolorum dignissimos.\n\nUt voluptates consectetur illo et. Est commodi accusantium vel quo. Eos qui fugiat soluta porro.\n\nRatione possimus alias vel maxime sint totam est repellat. Ipsum corporis eos sint voluptatem eos odit. Temporibus libero nulla harum eligendi labore similique ratione magnam. Suscipit sequi in omnis neque.\n\nLaudantium dolor amet omnis placeat mollitia aut molestiae. Aut rerum similique ipsum quod illo quas unde. Sunt aut veritatis eos omnis porro. Rem veritatis mollitia praesentium dolorem. Consequatur sequi ad cumque earum omnis quia necessitatibus.",
+ "created_at": "2016-03-22T15:20:35.864Z",
+ "updated_at": "2016-03-22T15:20:35.864Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 38,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 1",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ }
+ ]
}
]
},
@@ -6734,76 +6420,75 @@
"started_at": null,
"finished_at": null,
"duration": null,
- "statuses": [
- {
- "id": 78,
- "project_id": 5,
- "status": "success",
- "finished_at": null,
- "trace": "Dolorem deserunt quas quia error hic quo cum vel. Natus voluptatem cumque expedita numquam odit. Eos expedita nostrum corporis consequatur est recusandae.\n\nCulpa blanditiis rerum repudiandae alias voluptatem. Velit iusto est ullam consequatur doloribus porro. Corporis voluptas consectetur est veniam et quia quae.\n\nEt aut magni fuga nesciunt officiis molestias. Quaerat et nam necessitatibus qui rerum. Architecto quia officiis voluptatem laborum est recusandae. Quasi ducimus soluta odit necessitatibus labore numquam dignissimos. Quia facere sint temporibus inventore sunt nihil saepe dolorum.\n\nFacere dolores quis dolores a. Est minus nostrum nihil harum. Earum laborum et ipsum unde neque sit nemo. Corrupti est consequatur minima fugit. Illum voluptatem illo error ducimus officia qui debitis.\n\nDignissimos porro a autem harum aut. Aut id reprehenderit et exercitationem. Est et quisquam ipsa temporibus molestiae. Architecto natus dolore qui fugiat incidunt. Autem odit veniam excepturi et voluptatibus culpa ipsum eos.\n\nAmet quo quisquam dignissimos soluta modi dolores. Sint omnis eius optio corporis dolor. Eligendi animi porro quia placeat ut.",
- "created_at": "2016-03-22T15:20:35.927Z",
- "updated_at": "2016-03-22T15:20:35.927Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 39,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 2",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/78/p5_build_artifacts.zip"
- },
- "artifacts_metadata": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/78/p5_build_artifacts_metadata.gz"
- },
- "erased_by_id": null,
- "erased_at": null
- },
- {
- "id": 77,
- "project_id": 5,
- "status": "failed",
- "finished_at": null,
- "trace": "Rerum ut et suscipit est perspiciatis. Inventore debitis cum eius vitae. Ex incidunt id velit aut quo nisi. Laboriosam repellat deserunt eius reiciendis architecto et. Est harum quos nesciunt nisi consectetur.\n\nAlias esse omnis sint officia est consequatur in nobis. Dignissimos dolorum vel eligendi nesciunt dolores sit. Veniam mollitia ducimus et exercitationem molestiae libero sed. Atque omnis debitis laudantium voluptatibus qui. Repellendus tempore est commodi pariatur.\n\nExpedita voluptate illum est alias non. Modi nesciunt ab assumenda laborum nulla consequatur molestias doloremque. Magnam quod officia vel explicabo accusamus ut voluptatem incidunt. Rerum ut aliquid ullam saepe. Est eligendi debitis beatae blanditiis reiciendis.\n\nQui fuga sit dolores libero maiores et suscipit. Consectetur asperiores omnis minima impedit eos fugiat. Similique omnis nisi sed vero inventore ipsum aliquam exercitationem.\n\nBlanditiis magni iure dolorum omnis ratione delectus molestiae. Atque officia dolor voluptatem culpa quod. Incidunt suscipit quidem possimus veritatis non vel. Iusto aliquid et id quia quasi.\n\nVel facere velit blanditiis incidunt cupiditate sed maiores consequuntur. Quasi quia dicta consequuntur et quia voluptatem iste id. Incidunt et rerum fuga esse sint.",
- "created_at": "2016-03-22T15:20:35.905Z",
- "updated_at": "2016-03-22T15:20:35.905Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 39,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 1",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/77/p5_build_artifacts.zip"
- },
- "artifacts_metadata": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/77/p5_build_artifacts_metadata.gz"
- },
- "erased_by_id": null,
- "erased_at": null
+ "stages": [
+ {
+ "id": 23,
+ "project_id": 5,
+ "pipeline_id": 39,
+ "name": "test",
+ "status": 1,
+ "created_at": "2016-03-22T15:44:44.772Z",
+ "updated_at": "2016-03-29T06:44:44.634Z",
+ "statuses": [
+ {
+ "id": 78,
+ "project_id": 5,
+ "status": "success",
+ "finished_at": null,
+ "trace": "Dolorem deserunt quas quia error hic quo cum vel. Natus voluptatem cumque expedita numquam odit. Eos expedita nostrum corporis consequatur est recusandae.\n\nCulpa blanditiis rerum repudiandae alias voluptatem. Velit iusto est ullam consequatur doloribus porro. Corporis voluptas consectetur est veniam et quia quae.\n\nEt aut magni fuga nesciunt officiis molestias. Quaerat et nam necessitatibus qui rerum. Architecto quia officiis voluptatem laborum est recusandae. Quasi ducimus soluta odit necessitatibus labore numquam dignissimos. Quia facere sint temporibus inventore sunt nihil saepe dolorum.\n\nFacere dolores quis dolores a. Est minus nostrum nihil harum. Earum laborum et ipsum unde neque sit nemo. Corrupti est consequatur minima fugit. Illum voluptatem illo error ducimus officia qui debitis.\n\nDignissimos porro a autem harum aut. Aut id reprehenderit et exercitationem. Est et quisquam ipsa temporibus molestiae. Architecto natus dolore qui fugiat incidunt. Autem odit veniam excepturi et voluptatibus culpa ipsum eos.\n\nAmet quo quisquam dignissimos soluta modi dolores. Sint omnis eius optio corporis dolor. Eligendi animi porro quia placeat ut.",
+ "created_at": "2016-03-22T15:20:35.927Z",
+ "updated_at": "2016-03-22T15:20:35.927Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 39,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 2",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ },
+ {
+ "id": 77,
+ "project_id": 5,
+ "status": "failed",
+ "finished_at": null,
+ "trace": "Rerum ut et suscipit est perspiciatis. Inventore debitis cum eius vitae. Ex incidunt id velit aut quo nisi. Laboriosam repellat deserunt eius reiciendis architecto et. Est harum quos nesciunt nisi consectetur.\n\nAlias esse omnis sint officia est consequatur in nobis. Dignissimos dolorum vel eligendi nesciunt dolores sit. Veniam mollitia ducimus et exercitationem molestiae libero sed. Atque omnis debitis laudantium voluptatibus qui. Repellendus tempore est commodi pariatur.\n\nExpedita voluptate illum est alias non. Modi nesciunt ab assumenda laborum nulla consequatur molestias doloremque. Magnam quod officia vel explicabo accusamus ut voluptatem incidunt. Rerum ut aliquid ullam saepe. Est eligendi debitis beatae blanditiis reiciendis.\n\nQui fuga sit dolores libero maiores et suscipit. Consectetur asperiores omnis minima impedit eos fugiat. Similique omnis nisi sed vero inventore ipsum aliquam exercitationem.\n\nBlanditiis magni iure dolorum omnis ratione delectus molestiae. Atque officia dolor voluptatem culpa quod. Incidunt suscipit quidem possimus veritatis non vel. Iusto aliquid et id quia quasi.\n\nVel facere velit blanditiis incidunt cupiditate sed maiores consequuntur. Quasi quia dicta consequuntur et quia voluptatem iste id. Incidunt et rerum fuga esse sint.",
+ "created_at": "2016-03-22T15:20:35.905Z",
+ "updated_at": "2016-03-22T15:20:35.905Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 39,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 1",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ }
+ ]
}
]
},
@@ -6823,76 +6508,75 @@
"started_at": null,
"finished_at": null,
"duration": null,
- "statuses": [
- {
- "id": 79,
- "project_id": 5,
- "status": "failed",
- "finished_at": "2016-03-29T06:28:12.695Z",
- "trace": "Sed culpa est et facere saepe vel id ab. Quas temporibus aut similique dolorem consequatur corporis aut praesentium. Cum officia molestiae sit earum excepturi.\n\nSint possimus aut ratione quia. Quis nesciunt ratione itaque illo. Tenetur est dolor assumenda possimus voluptatem quia minima. Accusamus reprehenderit ut et itaque non reiciendis incidunt.\n\nRerum suscipit quibusdam dolore nam omnis. Consequatur ipsa nihil ut enim blanditiis delectus. Nulla quis hic occaecati mollitia qui placeat. Quo rerum sed perferendis a accusantium consequatur commodi ut. Sit quae et cumque vel eius tempora nostrum.\n\nUllam dolorem et itaque sint est. Ea molestias quia provident dolorem vitae error et et. Ea expedita officiis iste non. Qui vitae odit saepe illum. Dolores enim ratione deserunt tempore expedita amet non neque.\n\nEligendi asperiores voluptatibus omnis repudiandae expedita distinctio qui aliquid. Autem aut doloremque distinctio ab. Nostrum sapiente repudiandae aspernatur ea et quae voluptas. Officiis perspiciatis nisi laudantium asperiores error eligendi ab. Eius quia amet magni omnis exercitationem voluptatum et.\n\nVoluptatem ullam labore quas dicta est ex voluptas. Pariatur ea modi voluptas consequatur dolores perspiciatis similique. Numquam in distinctio perspiciatis ut qui earum. Quidem omnis mollitia facere aut beatae. Ea est iure et voluptatem.",
- "created_at": "2016-03-22T15:20:35.950Z",
- "updated_at": "2016-03-29T06:28:12.696Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 40,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 1",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": null
- },
- "artifacts_metadata": {
- "url": null
- },
- "erased_by_id": null,
- "erased_at": null
- },
- {
- "id": 80,
- "project_id": 5,
- "status": "success",
- "finished_at": null,
- "trace": "Impedit et optio nemo ipsa. Non ad non quis ut sequi laudantium omnis velit. Corporis a enim illo eos. Quia totam tempore inventore ad est.\n\nNihil recusandae cupiditate eaque voluptatem molestias sint. Consequatur id voluptatem cupiditate harum. Consequuntur iusto quaerat reiciendis aut autem libero est. Quisquam dolores veritatis rerum et sint maxime ullam libero. Id quas porro ut perspiciatis rem amet vitae.\n\nNemo inventore minus blanditiis magnam. Modi consequuntur nostrum aut voluptatem ex. Sunt rerum rem optio mollitia qui aliquam officiis officia. Aliquid eos et id aut minus beatae reiciendis.\n\nDolores non in temporibus dicta. Fugiat voluptatem est aspernatur expedita voluptatum nam qui. Quia et eligendi sit quae sint tempore exercitationem eos. Est sapiente corrupti quidem at. Qui magni odio repudiandae saepe tenetur optio dolore.\n\nEos placeat soluta at dolorem adipisci provident. Quo commodi id reprehenderit possimus quo tenetur. Ipsum et quae eligendi laborum. Et qui nesciunt at quasi quidem voluptatem cum rerum. Excepturi non facilis aut sunt vero sed.\n\nQui explicabo ratione ut eligendi recusandae. Quis quasi quas molestiae consequatur voluptatem et voluptatem. Ex repellat saepe occaecati aperiam ea eveniet dignissimos facilis.",
- "created_at": "2016-03-22T15:20:35.966Z",
- "updated_at": "2016-03-22T15:20:35.966Z",
- "started_at": null,
- "runner_id": null,
- "coverage": null,
- "commit_id": 40,
- "commands": "$ build command",
- "job_id": null,
- "name": "test build 2",
- "deploy": false,
- "options": null,
- "allow_failure": false,
- "stage": "test",
- "trigger_request_id": null,
- "stage_idx": 1,
- "tag": null,
- "ref": "master",
- "user_id": null,
- "target_url": null,
- "description": null,
- "artifacts_file": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/80/p5_build_artifacts.zip"
- },
- "artifacts_metadata": {
- "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/80/p5_build_artifacts_metadata.gz"
- },
- "erased_by_id": null,
- "erased_at": null
+ "stages": [
+ {
+ "id": 24,
+ "project_id": 5,
+ "pipeline_id": 40,
+ "name": "test",
+ "status": 1,
+ "created_at": "2016-03-22T15:44:44.772Z",
+ "updated_at": "2016-03-29T06:44:44.634Z",
+ "statuses": [
+ {
+ "id": 79,
+ "project_id": 5,
+ "status": "failed",
+ "finished_at": "2016-03-29T06:28:12.695Z",
+ "trace": "Sed culpa est et facere saepe vel id ab. Quas temporibus aut similique dolorem consequatur corporis aut praesentium. Cum officia molestiae sit earum excepturi.\n\nSint possimus aut ratione quia. Quis nesciunt ratione itaque illo. Tenetur est dolor assumenda possimus voluptatem quia minima. Accusamus reprehenderit ut et itaque non reiciendis incidunt.\n\nRerum suscipit quibusdam dolore nam omnis. Consequatur ipsa nihil ut enim blanditiis delectus. Nulla quis hic occaecati mollitia qui placeat. Quo rerum sed perferendis a accusantium consequatur commodi ut. Sit quae et cumque vel eius tempora nostrum.\n\nUllam dolorem et itaque sint est. Ea molestias quia provident dolorem vitae error et et. Ea expedita officiis iste non. Qui vitae odit saepe illum. Dolores enim ratione deserunt tempore expedita amet non neque.\n\nEligendi asperiores voluptatibus omnis repudiandae expedita distinctio qui aliquid. Autem aut doloremque distinctio ab. Nostrum sapiente repudiandae aspernatur ea et quae voluptas. Officiis perspiciatis nisi laudantium asperiores error eligendi ab. Eius quia amet magni omnis exercitationem voluptatum et.\n\nVoluptatem ullam labore quas dicta est ex voluptas. Pariatur ea modi voluptas consequatur dolores perspiciatis similique. Numquam in distinctio perspiciatis ut qui earum. Quidem omnis mollitia facere aut beatae. Ea est iure et voluptatem.",
+ "created_at": "2016-03-22T15:20:35.950Z",
+ "updated_at": "2016-03-29T06:28:12.696Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 40,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 1",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ },
+ {
+ "id": 80,
+ "project_id": 5,
+ "status": "success",
+ "finished_at": null,
+ "trace": "Impedit et optio nemo ipsa. Non ad non quis ut sequi laudantium omnis velit. Corporis a enim illo eos. Quia totam tempore inventore ad est.\n\nNihil recusandae cupiditate eaque voluptatem molestias sint. Consequatur id voluptatem cupiditate harum. Consequuntur iusto quaerat reiciendis aut autem libero est. Quisquam dolores veritatis rerum et sint maxime ullam libero. Id quas porro ut perspiciatis rem amet vitae.\n\nNemo inventore minus blanditiis magnam. Modi consequuntur nostrum aut voluptatem ex. Sunt rerum rem optio mollitia qui aliquam officiis officia. Aliquid eos et id aut minus beatae reiciendis.\n\nDolores non in temporibus dicta. Fugiat voluptatem est aspernatur expedita voluptatum nam qui. Quia et eligendi sit quae sint tempore exercitationem eos. Est sapiente corrupti quidem at. Qui magni odio repudiandae saepe tenetur optio dolore.\n\nEos placeat soluta at dolorem adipisci provident. Quo commodi id reprehenderit possimus quo tenetur. Ipsum et quae eligendi laborum. Et qui nesciunt at quasi quidem voluptatem cum rerum. Excepturi non facilis aut sunt vero sed.\n\nQui explicabo ratione ut eligendi recusandae. Quis quasi quas molestiae consequatur voluptatem et voluptatem. Ex repellat saepe occaecati aperiam ea eveniet dignissimos facilis.",
+ "created_at": "2016-03-22T15:20:35.966Z",
+ "updated_at": "2016-03-22T15:20:35.966Z",
+ "started_at": null,
+ "runner_id": null,
+ "coverage": null,
+ "commit_id": 40,
+ "commands": "$ build command",
+ "job_id": null,
+ "name": "test build 2",
+ "deploy": false,
+ "options": null,
+ "allow_failure": false,
+ "stage": "test",
+ "trigger_request_id": null,
+ "stage_idx": 1,
+ "tag": null,
+ "ref": "master",
+ "user_id": null,
+ "target_url": null,
+ "description": null,
+ "erased_by_id": null,
+ "erased_at": null
+ }
+ ]
}
]
}
@@ -6902,14 +6586,11 @@
"id": 123,
"token": "cdbfasdf44a5958c83654733449e585",
"project_id": 5,
- "deleted_at": null,
"created_at": "2017-01-16T15:25:28.637Z",
"updated_at": "2017-01-16T15:25:28.637Z"
}
],
- "deploy_keys": [
-
- ],
+ "deploy_keys": [],
"services": [
{
"id": 100,
@@ -6918,9 +6599,7 @@
"created_at": "2016-06-14T15:01:51.315Z",
"updated_at": "2016-06-14T15:01:51.315Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -6962,9 +6641,7 @@
"created_at": "2016-06-14T15:01:51.289Z",
"updated_at": "2016-06-14T15:01:51.289Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -6984,9 +6661,7 @@
"created_at": "2016-06-14T15:01:51.277Z",
"updated_at": "2016-06-14T15:01:51.277Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7006,9 +6681,7 @@
"created_at": "2016-06-14T15:01:51.267Z",
"updated_at": "2016-06-14T15:01:51.267Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7050,10 +6723,8 @@
"project_id": 5,
"created_at": "2016-06-14T15:01:51.232Z",
"updated_at": "2016-06-14T15:01:51.232Z",
- "active": false,
- "properties": {
-
- },
+ "active": true,
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7095,9 +6766,7 @@
"created_at": "2016-06-14T15:01:51.202Z",
"updated_at": "2016-06-14T15:01:51.202Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7117,9 +6786,7 @@
"created_at": "2016-06-14T15:01:51.182Z",
"updated_at": "2016-06-14T15:01:51.182Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7139,9 +6806,7 @@
"created_at": "2016-06-14T15:01:51.166Z",
"updated_at": "2016-06-14T15:01:51.166Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7161,9 +6826,7 @@
"created_at": "2016-06-14T15:01:51.153Z",
"updated_at": "2016-06-14T15:01:51.153Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7183,9 +6846,7 @@
"created_at": "2016-06-14T15:01:51.139Z",
"updated_at": "2016-06-14T15:01:51.139Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7205,9 +6866,7 @@
"created_at": "2016-06-14T15:01:51.125Z",
"updated_at": "2016-06-14T15:01:51.125Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7227,9 +6886,7 @@
"created_at": "2016-06-14T15:01:51.113Z",
"updated_at": "2016-06-14T15:01:51.113Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7249,9 +6906,7 @@
"created_at": "2016-06-14T15:01:51.080Z",
"updated_at": "2016-06-14T15:01:51.080Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7271,9 +6926,7 @@
"created_at": "2016-06-14T15:01:51.067Z",
"updated_at": "2016-06-14T15:01:51.067Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7293,9 +6946,7 @@
"created_at": "2016-06-14T15:01:51.047Z",
"updated_at": "2016-06-14T15:01:51.047Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7315,9 +6966,7 @@
"created_at": "2016-06-14T15:01:51.031Z",
"updated_at": "2016-06-14T15:01:51.031Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7337,9 +6986,7 @@
"created_at": "2016-06-14T15:01:51.031Z",
"updated_at": "2016-06-14T15:01:51.031Z",
"active": false,
- "properties": {
-
- },
+ "properties": {},
"template": false,
"push_events": true,
"issues_events": true,
@@ -7353,9 +7000,7 @@
"type": "JenkinsDeprecatedService"
}
],
- "hooks": [
-
- ],
+ "hooks": [],
"protected_branches": [
{
"id": 1,
@@ -7429,5 +7074,25 @@
"key": "bar",
"value": "bar"
}
+ ],
+ "project_badges": [
+ {
+ "id": 1,
+ "created_at": "2017-10-19T15:36:23.466Z",
+ "updated_at": "2017-10-19T15:36:23.466Z",
+ "project_id": 5,
+ "type": "ProjectBadge",
+ "link_url": "http://www.example.com",
+ "image_url": "http://www.example.com"
+ },
+ {
+ "id": 2,
+ "created_at": "2017-10-19T15:36:23.466Z",
+ "updated_at": "2017-10-19T15:36:23.466Z",
+ "project_id": 5,
+ "type": "ProjectBadge",
+ "link_url": "http://www.example.com",
+ "image_url": "http://www.example.com"
+ }
]
}
diff --git a/spec/lib/gitlab/import_export/project.light.json b/spec/lib/gitlab/import_export/project.light.json
index 02450478a77..5dbf0ed289b 100644
--- a/spec/lib/gitlab/import_export/project.light.json
+++ b/spec/lib/gitlab/import_export/project.light.json
@@ -54,7 +54,6 @@
"iid": 20,
"updated_by_id": 1,
"confidential": false,
- "deleted_at": null,
"due_date": null,
"moved_to_id": null,
"lock_version": null,
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 0ab3afd0074..13a8c9adcee 100644
--- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb
@@ -4,12 +4,17 @@ include ImportExport::CommonUtil
describe Gitlab::ImportExport::ProjectTreeRestorer do
describe 'restore project tree' do
before(:context) do
- @user = create(:user)
+ # Using an admin for import, so we can check assignment of existing members
+ @user = create(:admin)
+ @existing_members = [
+ create(:user, username: 'bernard_willms'),
+ create(:user, username: 'saul_will')
+ ]
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(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project')
+ @shared = @project.import_export_shared
+ allow(@shared).to receive(:export_path).and_return('spec/lib/gitlab/import_export/')
allow_any_instance_of(Repository).to receive(:fetch_ref).and_return(true)
allow_any_instance_of(Gitlab::Git::Repository).to receive(:branch_exists?).and_return(false)
@@ -37,8 +42,8 @@ describe Gitlab::ImportExport::ProjectTreeRestorer 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')
+ it 'has the project description' do
+ expect(Project.find_by_path('project').description).to eq('Nisi et repellendus ut enim quo accusamus vel magnam.')
end
it 'has the same label associated to two issues' do
@@ -63,8 +68,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
expect(issue.reload.updated_at.to_s).to eq('2016-06-14 15:02:47 UTC')
end
- it 'has issue assignees' do
- expect(Issue.where(title: 'Voluptatem').first.issue_assignees).not_to be_empty
+ it 'has multiple issue assignees' do
+ expect(Issue.find_by(title: 'Voluptatem').assignees).to contain_exactly(@user, *@existing_members)
+ expect(Issue.find_by(title: 'Issue without assignees').assignees).to be_empty
end
it 'contains the merge access levels on a protected branch' do
@@ -129,6 +135,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
expect(@project.custom_attributes.count).to eq(2)
end
+ it 'has badges' do
+ expect(@project.project_badges.count).to eq(2)
+ end
+
it 'restores the correct service' do
expect(CustomIssueTrackerService.first).not_to be_nil
end
@@ -179,6 +189,32 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
end
end
end
+
+ context 'when restoring hierarchy of pipeline, stages and jobs' do
+ it 'restores pipelines' do
+ expect(Ci::Pipeline.all.count).to be 5
+ end
+
+ it 'restores pipeline stages' do
+ expect(Ci::Stage.all.count).to be 6
+ end
+
+ it 'correctly restores association between stage and a pipeline' do
+ expect(Ci::Stage.all).to all(have_attributes(pipeline_id: a_value > 0))
+ end
+
+ it 'restores statuses' do
+ expect(CommitStatus.all.count).to be 10
+ end
+
+ it 'correctly restores association between a stage and a job' do
+ expect(CommitStatus.all).to all(have_attributes(stage_id: a_value > 0))
+ end
+
+ it 'correctly restores association between a pipeline and a job' do
+ expect(CommitStatus.all).to all(have_attributes(pipeline_id: a_value > 0))
+ end
+ end
end
end
@@ -210,12 +246,14 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
labels = project.issues.first.labels
expect(labels.where(type: "ProjectLabel").count).to eq(results.fetch(:first_issue_labels, 0))
+ expect(labels.where(type: "ProjectLabel").where.not(group_id: nil).count).to eq(0)
end
end
shared_examples 'restores group correctly' do |**results|
it 'has group label' do
expect(project.group.labels.size).to eq(results.fetch(:labels, 0))
+ expect(project.group.labels.where(type: "GroupLabel").where.not(project_id: nil).count).to eq(0)
end
it 'has group milestone' do
@@ -231,7 +269,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
context 'Light JSON' do
let(:user) { create(:user) }
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: "", project_path: 'path') }
+ let(:shared) { project.import_export_shared }
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 }
@@ -275,6 +313,24 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
end
end
+ context 'when the project has overriden params in import data' do
+ it 'overwrites the params stored in the JSON' do
+ project.create_import_data(data: { override_params: { description: "Overridden" } })
+
+ restored_project_json
+
+ expect(project.description).to eq("Overridden")
+ end
+
+ it 'does not allow setting params that are excluded from import_export settings' do
+ project.create_import_data(data: { override_params: { lfs_enabled: true } })
+
+ restored_project_json
+
+ expect(project.lfs_enabled).to be_nil
+ end
+ end
+
context 'with a project that has a group' do
let!(:project) do
create(:project,
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 6243b6ac9f0..2b8a11ce8f9 100644
--- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb
@@ -2,14 +2,14 @@ require 'spec_helper'
describe Gitlab::ImportExport::ProjectTreeSaver do
describe 'saves the project tree into a json object' do
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
+ let(:shared) { project.import_export_shared }
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 }
before do
- project.team << [user, :master]
+ project.add_master(user)
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
allow_any_instance_of(MergeRequest).to receive(:source_branch_sha).and_return('ABCD')
allow_any_instance_of(MergeRequest).to receive(:target_branch_sha).and_return('DCBA')
@@ -29,8 +29,17 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
project_json(project_tree_saver.full_path)
end
+ context 'with description override' do
+ let(:params) { { description: 'Foo Bar' } }
+ let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared, params: params) }
+
+ it 'overrides the project description' do
+ expect(saved_project_json).to include({ 'description' => params[:description] })
+ end
+ end
+
it 'saves the correct json' do
- expect(saved_project_json).to include({ "visibility_level" => 20 })
+ expect(saved_project_json).to include({ 'description' => 'description', 'visibility_level' => 20 })
end
it 'has milestones' do
@@ -109,12 +118,20 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
expect(saved_project_json['merge_requests'].first['notes'].first['author']).not_to be_empty
end
+ it 'has pipeline stages' do
+ expect(saved_project_json.dig('pipelines', 0, 'stages')).not_to be_empty
+ end
+
it 'has pipeline statuses' do
- expect(saved_project_json['pipelines'].first['statuses']).not_to be_empty
+ expect(saved_project_json.dig('pipelines', 0, 'stages', 0, 'statuses')).not_to be_empty
end
it 'has pipeline builds' do
- expect(saved_project_json['pipelines'].first['statuses'].count { |hash| hash['type'] == 'Ci::Build' }).to eq(1)
+ builds_count = saved_project_json
+ .dig('pipelines', 0, 'stages', 0, 'statuses')
+ .count { |hash| hash['type'] == 'Ci::Build' }
+
+ expect(builds_count).to eq(1)
end
it 'has no when YML attributes but only the DB column' do
@@ -156,6 +173,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
expect(saved_project_json['services'].first['type']).to eq('CustomIssueTrackerService')
end
+ it 'saves the properties for a service' do
+ expect(saved_project_json['services'].first['properties']).to eq('one' => 'value')
+ end
+
it 'has project feature' do
project_feature = saved_project_json['project_feature']
expect(project_feature).not_to be_empty
@@ -168,6 +189,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
expect(saved_project_json['custom_attributes'].count).to eq(2)
end
+ it 'has badges' do
+ expect(saved_project_json['project_badges'].count).to eq(2)
+ 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'")
@@ -220,10 +245,6 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
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
@@ -243,12 +264,12 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
:issues_disabled,
:wiki_enabled,
:builds_private,
+ description: 'description',
issues: [issue],
snippets: [snippet],
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)
@@ -271,11 +292,14 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
commit_id: ci_build.pipeline.sha)
create(:event, :created, target: milestone, project: project, author: user)
- create(:service, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker')
+ create(:service, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker', properties: { one: 'value' })
create(:project_custom_attribute, project: project)
create(:project_custom_attribute, project: project)
+ create(:project_badge, project: project)
+ create(:project_badge, project: project)
+
project
end
diff --git a/spec/lib/gitlab/import_export/reader_spec.rb b/spec/lib/gitlab/import_export/reader_spec.rb
index e9f5273725d..1ef024d3078 100644
--- a/spec/lib/gitlab/import_export/reader_spec.rb
+++ b/spec/lib/gitlab/import_export/reader_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::ImportExport::Reader do
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: '') }
+ let(:shared) { Gitlab::ImportExport::Shared.new(nil) }
let(:test_config) { 'spec/support/import_export/import_export.yml' }
let(:project_tree_hash) do
{
diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb
index f1df44cea75..5c61a5a2044 100644
--- a/spec/lib/gitlab/import_export/relation_factory_spec.rb
+++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb
@@ -29,6 +29,7 @@ describe Gitlab::ImportExport::RelationFactory do
'service_id' => service_id,
'push_events' => true,
'issues_events' => false,
+ 'confidential_issues_events' => false,
'merge_requests_events' => true,
'tag_push_events' => false,
'note_events' => true,
diff --git a/spec/lib/gitlab/import_export/repo_restorer_spec.rb b/spec/lib/gitlab/import_export/repo_restorer_spec.rb
index c49af602a01..dc806d036ff 100644
--- a/spec/lib/gitlab/import_export/repo_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/repo_restorer_spec.rb
@@ -6,7 +6,7 @@ describe Gitlab::ImportExport::RepoRestorer do
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.full_path) }
+ let(:shared) { project.import_export_shared }
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
diff --git a/spec/lib/gitlab/import_export/repo_saver_spec.rb b/spec/lib/gitlab/import_export/repo_saver_spec.rb
index e6ad516deef..187ec8fcfa2 100644
--- a/spec/lib/gitlab/import_export/repo_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/repo_saver_spec.rb
@@ -5,11 +5,11 @@ describe Gitlab::ImportExport::RepoSaver 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(:shared) { project.import_export_shared }
let(:bundler) { described_class.new(project: project, shared: shared) }
before do
- project.team << [user, :master]
+ project.add_master(user)
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
end
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index ec8fa99e0da..f84a777a27f 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -14,8 +14,8 @@ Issue:
- iid
- updated_by_id
- confidential
-- deleted_at
- closed_at
+- closed_by_id
- due_date
- moved_to_id
- lock_version
@@ -159,7 +159,6 @@ MergeRequest:
- merge_when_pipeline_succeeds
- merge_user_id
- merge_commit_sha
-- deleted_at
- in_progress_merge_commit_sha
- lock_version
- milestone_id
@@ -170,6 +169,7 @@ MergeRequest:
- last_edited_by_id
- head_pipeline_id
- discussion_locked
+- allow_maintainer_to_push
MergeRequestDiff:
- id
- state
@@ -180,6 +180,7 @@ MergeRequestDiff:
- real_size
- head_commit_sha
- start_commit_sha
+- commits_count
MergeRequestDiffCommit:
- merge_request_diff_id
- relative_order
@@ -265,7 +266,9 @@ CommitStatus:
- target_url
- description
- artifacts_file
+- artifacts_file_store
- artifacts_metadata
+- artifacts_metadata_store
- erased_by_id
- erased_at
- artifacts_expire_at
@@ -293,7 +296,6 @@ Ci::Trigger:
- id
- token
- project_id
-- deleted_at
- created_at
- updated_at
- owner_id
@@ -309,7 +311,6 @@ Ci::PipelineSchedule:
- project_id
- owner_id
- active
-- deleted_at
- created_at
- updated_at
Clusters::Cluster:
@@ -389,6 +390,7 @@ Service:
- default
- wiki_page_events
- confidential_issues_events
+- confidential_note_events
ProjectHook:
- id
- url
@@ -409,6 +411,7 @@ ProjectHook:
- token
- group_id
- confidential_issues_events
+- confidential_note_events
- repository_update_events
ProtectedBranch:
- id
@@ -459,6 +462,8 @@ Project:
- delete_error
- merge_requests_ff_only_enabled
- merge_requests_rebase_enabled
+- jobs_cache_index
+- pages_https_only
Author:
- name
ProjectFeature:
@@ -532,3 +537,18 @@ ProjectCustomAttribute:
- project_id
- key
- value
+LfsFileLock:
+- id
+- path
+- user_id
+- project_id
+- created_at
+Badge:
+- id
+- link_url
+- image_url
+- project_id
+- group_id
+- created_at
+- updated_at
+- type
diff --git a/spec/lib/gitlab/import_export/uploads_restorer_spec.rb b/spec/lib/gitlab/import_export/uploads_restorer_spec.rb
index 63992ea8ab8..acef97459b8 100644
--- a/spec/lib/gitlab/import_export/uploads_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/uploads_restorer_spec.rb
@@ -3,8 +3,7 @@ require 'spec_helper'
describe Gitlab::ImportExport::UploadsRestorer do
describe 'bundle a project Git repo' do
let(:export_path) { "#{Dir.tmpdir}/uploads_saver_spec" }
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
- let(:uploads_path) { FileUploader.dynamic_path_segment(project) }
+ let(:shared) { project.import_export_shared }
before do
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
@@ -17,7 +16,7 @@ describe Gitlab::ImportExport::UploadsRestorer do
end
describe 'legacy storage' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :legacy_storage) }
subject(:restorer) { described_class.new(project: project, shared: shared) }
@@ -26,16 +25,16 @@ describe Gitlab::ImportExport::UploadsRestorer do
end
it 'copies the uploads to the project path' do
- restorer.restore
+ subject.restore
- uploads = Dir.glob(File.join(uploads_path, '**/*')).map { |file| File.basename(file) }
+ uploads = Dir.glob(File.join(subject.uploads_path, '**/*')).map { |file| File.basename(file) }
expect(uploads).to include('dummy.txt')
end
end
describe 'hashed storage' do
- let(:project) { create(:project, :hashed) }
+ let(:project) { create(:project) }
subject(:restorer) { described_class.new(project: project, shared: shared) }
@@ -44,9 +43,9 @@ describe Gitlab::ImportExport::UploadsRestorer do
end
it 'copies the uploads to the project path' do
- restorer.restore
+ subject.restore
- uploads = Dir.glob(File.join(uploads_path, '**/*')).map { |file| File.basename(file) }
+ uploads = Dir.glob(File.join(subject.uploads_path, '**/*')).map { |file| File.basename(file) }
expect(uploads).to include('dummy.txt')
end
diff --git a/spec/lib/gitlab/import_export/uploads_saver_spec.rb b/spec/lib/gitlab/import_export/uploads_saver_spec.rb
index e8948de1f3a..1304d8fabfc 100644
--- a/spec/lib/gitlab/import_export/uploads_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/uploads_saver_spec.rb
@@ -4,7 +4,7 @@ describe Gitlab::ImportExport::UploadsSaver do
describe 'bundle a project Git repo' do
let(:export_path) { "#{Dir.tmpdir}/uploads_saver_spec" }
let(:file) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') }
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) }
+ let(:shared) { project.import_export_shared }
before do
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
@@ -15,7 +15,7 @@ describe Gitlab::ImportExport::UploadsSaver do
end
describe 'legacy storage' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :legacy_storage) }
subject(:saver) { described_class.new(shared: shared, project: project) }
@@ -30,14 +30,14 @@ describe Gitlab::ImportExport::UploadsSaver do
it 'copies the uploads to the export path' do
saver.save
- uploads = Dir.glob(File.join(shared.export_path, 'uploads', '**/*')).map { |file| File.basename(file) }
+ uploads = Dir.glob(File.join(saver.uploads_export_path, '**/*')).map { |file| File.basename(file) }
expect(uploads).to include('banana_sample.gif')
end
end
describe 'hashed storage' do
- let(:project) { create(:project, :hashed) }
+ let(:project) { create(:project) }
subject(:saver) { described_class.new(shared: shared, project: project) }
@@ -52,7 +52,7 @@ describe Gitlab::ImportExport::UploadsSaver do
it 'copies the uploads to the export path' do
saver.save
- uploads = Dir.glob(File.join(shared.export_path, 'uploads', '**/*')).map { |file| File.basename(file) }
+ uploads = Dir.glob(File.join(saver.uploads_export_path, '**/*')).map { |file| File.basename(file) }
expect(uploads).to include('banana_sample.gif')
end
diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb
index e7d50f75682..49d857d9483 100644
--- a/spec/lib/gitlab/import_export/version_checker_spec.rb
+++ b/spec/lib/gitlab/import_export/version_checker_spec.rb
@@ -2,12 +2,13 @@ require 'spec_helper'
include ImportExport::CommonUtil
describe Gitlab::ImportExport::VersionChecker do
- let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: '') }
+ let(:shared) { Gitlab::ImportExport::Shared.new(nil) }
describe 'bundle a project Git repo' do
let(:version) { Gitlab::ImportExport.version }
before do
+ allow_any_instance_of(Gitlab::ImportExport::Shared).to receive(:relative_archive_path).and_return('')
allow(File).to receive(:open).and_return(version)
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
index 0e55993c8ef..d2bd8ccdf3f 100644
--- a/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb
@@ -5,12 +5,12 @@ describe Gitlab::ImportExport::WikiRepoSaver 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(:shared) { project.import_export_shared }
let(:wiki_bundler) { described_class.new(project: project, shared: shared) }
let!(:project_wiki) { ProjectWiki.new(project, user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
project_wiki.wiki
project_wiki.create_page("index", "test content")
diff --git a/spec/lib/gitlab/import_export/wiki_restorer_spec.rb b/spec/lib/gitlab/import_export/wiki_restorer_spec.rb
new file mode 100644
index 00000000000..5c01ee0ebb8
--- /dev/null
+++ b/spec/lib/gitlab/import_export/wiki_restorer_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+describe Gitlab::ImportExport::WikiRestorer do
+ describe 'restore a wiki Git repo' do
+ let!(:project_with_wiki) { create(:project, :wiki_repo) }
+ let!(:project_without_wiki) { create(:project) }
+ let!(:project) { create(:project) }
+ let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
+ let(:shared) { project.import_export_shared }
+ let(:bundler) { Gitlab::ImportExport::WikiRepoSaver.new(project: project_with_wiki, shared: shared) }
+ let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename) }
+ let(:restorer) do
+ described_class.new(path_to_bundle: bundle_path,
+ shared: shared,
+ project: project.wiki,
+ wiki_enabled: true)
+ end
+
+ before do
+ allow(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+
+ bundler.save
+ end
+
+ after do
+ FileUtils.rm_rf(export_path)
+ Gitlab::Shell.new.remove_repository(project_with_wiki.wiki.repository_storage_path, project_with_wiki.wiki.disk_path)
+ Gitlab::Shell.new.remove_repository(project.wiki.repository_storage_path, project.wiki.disk_path)
+ end
+
+ it 'restores the wiki repo successfully' do
+ expect(restorer.restore).to be true
+ end
+
+ describe "no wiki in the bundle" do
+ let(:bundler) { Gitlab::ImportExport::WikiRepoSaver.new(project: project_without_wiki, shared: shared) }
+
+ it 'creates an empty wiki' do
+ expect(restorer.restore).to be true
+
+ expect(project.wiki_repository_exists?).to be true
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb
new file mode 100644
index 00000000000..6532579b1c9
--- /dev/null
+++ b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb
@@ -0,0 +1,18 @@
+require 'spec_helper'
+
+describe Gitlab::InsecureKeyFingerprint do
+ let(:key) do
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn' \
+ '1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qk' \
+ 'r8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMg' \
+ 'Jw0='
+ end
+
+ let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" }
+
+ describe "#fingerprint" do
+ it "generates the key's fingerprint" do
+ expect(described_class.new(key.split[1]).fingerprint).to eq(fingerprint)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/config_map_spec.rb b/spec/lib/gitlab/kubernetes/config_map_spec.rb
new file mode 100644
index 00000000000..33dfa461202
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/config_map_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::ConfigMap do
+ let(:kubeclient) { double('kubernetes client') }
+ let(:application) { create(:clusters_applications_prometheus) }
+ let(:config_map) { described_class.new(application.name, application.values) }
+ let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
+
+ let(:metadata) do
+ {
+ name: "values-content-configuration-#{application.name}",
+ namespace: namespace,
+ labels: { name: "values-content-configuration-#{application.name}" }
+ }
+ end
+
+ describe '#generate' do
+ let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) }
+ subject { config_map.generate }
+
+ it 'should build a Kubeclient Resource' do
+ is_expected.to eq(resource)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
new file mode 100644
index 00000000000..740466ea5cb
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
@@ -0,0 +1,87 @@
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::Helm::Api do
+ let(:client) { double('kubernetes client') }
+ let(:helm) { described_class.new(client) }
+ let(:gitlab_namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
+ let(:namespace) { Gitlab::Kubernetes::Namespace.new(gitlab_namespace, client) }
+ let(:application) { create(:clusters_applications_prometheus) }
+
+ let(:command) do
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ application.name,
+ chart: application.chart,
+ values: application.values
+ )
+ end
+
+ subject { helm }
+
+ before do
+ allow(Gitlab::Kubernetes::Namespace).to receive(:new).with(gitlab_namespace, client).and_return(namespace)
+ allow(client).to receive(:create_config_map)
+ end
+
+ describe '#initialize' do
+ it 'creates a namespace object' do
+ expect(Gitlab::Kubernetes::Namespace).to receive(:new).with(gitlab_namespace, client)
+
+ subject
+ end
+ end
+
+ describe '#install' do
+ before do
+ allow(client).to receive(:create_pod).and_return(nil)
+ allow(client).to receive(:create_config_map).and_return(nil)
+ allow(namespace).to receive(:ensure_exists!).once
+ end
+
+ it 'ensures the namespace exists before creating the POD' do
+ expect(namespace).to receive(:ensure_exists!).once.ordered
+ expect(client).to receive(:create_pod).once.ordered
+
+ subject.install(command)
+ end
+
+ context 'with a ConfigMap' do
+ let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.values).generate }
+
+ it 'creates a ConfigMap on kubeclient' do
+ expect(client).to receive(:create_config_map).with(resource).once
+
+ subject.install(command)
+ end
+ end
+ end
+
+ describe '#installation_status' do
+ let(:phase) { Gitlab::Kubernetes::Pod::RUNNING }
+ let(:pod) { Kubeclient::Resource.new(status: { phase: phase }) } # partial representation
+
+ it 'fetches POD phase from kubernetes cluster' do
+ expect(client).to receive(:get_pod).with(command.pod_name, gitlab_namespace).once.and_return(pod)
+
+ expect(subject.installation_status(command.pod_name)).to eq(phase)
+ end
+ end
+
+ describe '#installation_log' do
+ let(:log) { 'some output' }
+ let(:response) { RestClient::Response.new(log) }
+
+ it 'fetches POD phase from kubernetes cluster' do
+ expect(client).to receive(:get_pod_log).with(command.pod_name, gitlab_namespace).once.and_return(response)
+
+ expect(subject.installation_log(command.pod_name)).to eq(log)
+ end
+ end
+
+ describe '#delete_installation_pod!' do
+ it 'deletes the POD from kubernetes cluster' do
+ expect(client).to receive(:delete_pod).with(command.pod_name, gitlab_namespace).once
+
+ subject.delete_installation_pod!(command.pod_name)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
new file mode 100644
index 00000000000..3cfdae794f6
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::Helm::BaseCommand do
+ let(:application) { create(:clusters_applications_helm) }
+ let(:base_command) { described_class.new(application.name) }
+
+ describe '#generate_script' do
+ let(:helm_version) { Gitlab::Kubernetes::Helm::HELM_VERSION }
+ let(:command) do
+ <<~HEREDOC
+ set -eo pipefail
+ apk add -U ca-certificates openssl >/dev/null
+ wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v#{helm_version}-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
+ mv /tmp/linux-amd64/helm /usr/bin/
+ HEREDOC
+ end
+
+ subject { base_command.generate_script }
+
+ it 'should return a command that prepares the environment for helm-cli' do
+ expect(subject).to eq(command)
+ end
+ end
+
+ describe '#pod_resource' do
+ subject { base_command.pod_resource }
+
+ it 'should returns a kubeclient resoure with pod content for application' do
+ is_expected.to be_an_instance_of ::Kubeclient::Resource
+ end
+ end
+
+ describe '#config_map?' do
+ subject { base_command.config_map? }
+
+ it { is_expected.to be_falsy }
+ end
+
+ describe '#pod_name' do
+ subject { base_command.pod_name }
+
+ it { is_expected.to eq('install-helm') }
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
new file mode 100644
index 00000000000..e6920b0a76f
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::Helm::InitCommand do
+ let(:application) { create(:clusters_applications_helm) }
+ let(:init_command) { described_class.new(application.name) }
+
+ describe '#generate_script' do
+ let(:command) do
+ <<~MSG.chomp
+ set -eo pipefail
+ apk add -U ca-certificates openssl >/dev/null
+ wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v2.7.0-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
+ mv /tmp/linux-amd64/helm /usr/bin/
+ helm init >/dev/null
+ MSG
+ end
+
+ subject { init_command.generate_script }
+
+ it 'should return the appropriate command' do
+ is_expected.to eq(command)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
new file mode 100644
index 00000000000..137b8f718de
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+describe Gitlab::Kubernetes::Helm::InstallCommand do
+ let(:application) { create(:clusters_applications_prometheus) }
+ let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
+
+ let(:install_command) do
+ described_class.new(
+ application.name,
+ chart: application.chart,
+ values: application.values
+ )
+ end
+
+ describe '#generate_script' do
+ let(:command) do
+ <<~MSG
+ set -eo pipefail
+ apk add -U ca-certificates openssl >/dev/null
+ wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v2.7.0-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
+ mv /tmp/linux-amd64/helm /usr/bin/
+ helm init --client-only >/dev/null
+ helm install #{application.chart} --name #{application.name} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ MSG
+ end
+
+ subject { install_command.generate_script }
+
+ it 'should return appropriate command' do
+ is_expected.to eq(command)
+ end
+
+ context 'with an application with a repository' do
+ let(:ci_runner) { create(:ci_runner) }
+ let(:application) { create(:clusters_applications_runner, runner: ci_runner) }
+ let(:install_command) do
+ described_class.new(
+ application.name,
+ chart: application.chart,
+ values: application.values,
+ repository: application.repository
+ )
+ end
+
+ let(:command) do
+ <<~MSG
+ set -eo pipefail
+ apk add -U ca-certificates openssl >/dev/null
+ wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v2.7.0-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
+ mv /tmp/linux-amd64/helm /usr/bin/
+ helm init --client-only >/dev/null
+ helm repo add #{application.name} #{application.repository}
+ helm install #{application.chart} --name #{application.name} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
+ MSG
+ end
+
+ it 'should return appropriate command' do
+ is_expected.to eq(command)
+ end
+ end
+ end
+
+ describe '#config_map?' do
+ subject { install_command.config_map? }
+
+ it { is_expected.to be_truthy }
+ end
+
+ describe '#config_map_resource' do
+ let(:metadata) do
+ {
+ name: "values-content-configuration-#{application.name}",
+ namespace: namespace,
+ labels: { name: "values-content-configuration-#{application.name}" }
+ }
+ end
+
+ let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values }) }
+
+ subject { install_command.config_map_resource }
+
+ it 'returns a KubeClient resource with config map content for the application' do
+ is_expected.to eq(resource)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
new file mode 100644
index 00000000000..43adc80d576
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
@@ -0,0 +1,84 @@
+require 'rails_helper'
+
+describe Gitlab::Kubernetes::Helm::Pod do
+ describe '#generate' do
+ let(:cluster) { create(:cluster) }
+ let(:app) { create(:clusters_applications_prometheus, cluster: cluster) }
+ let(:command) { app.install_command }
+ let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
+
+ subject { described_class.new(command, namespace) }
+
+ shared_examples 'helm pod' do
+ it 'should generate a Kubeclient::Resource' do
+ expect(subject.generate).to be_a_kind_of(Kubeclient::Resource)
+ end
+
+ it 'should generate the appropriate metadata' do
+ metadata = subject.generate.metadata
+ expect(metadata.name).to eq("install-#{app.name}")
+ expect(metadata.namespace).to eq('gitlab-managed-apps')
+ expect(metadata.labels['gitlab.org/action']).to eq('install')
+ expect(metadata.labels['gitlab.org/application']).to eq(app.name)
+ end
+
+ it 'should generate a container spec' do
+ spec = subject.generate.spec
+ expect(spec.containers.count).to eq(1)
+ end
+
+ it 'should generate the appropriate specifications for the container' do
+ container = subject.generate.spec.containers.first
+ expect(container.name).to eq('helm')
+ expect(container.image).to eq('alpine:3.6')
+ expect(container.env.count).to eq(3)
+ expect(container.env.map(&:name)).to match_array([:HELM_VERSION, :TILLER_NAMESPACE, :COMMAND_SCRIPT])
+ expect(container.command).to match_array(["/bin/sh"])
+ expect(container.args).to match_array(["-c", "$(COMMAND_SCRIPT)"])
+ end
+
+ it 'should include a never restart policy' do
+ spec = subject.generate.spec
+ expect(spec.restartPolicy).to eq('Never')
+ end
+ end
+
+ context 'with a install command' do
+ it_behaves_like 'helm pod'
+
+ it 'should include volumes for the container' do
+ container = subject.generate.spec.containers.first
+ expect(container.volumeMounts.first['name']).to eq('configuration-volume')
+ expect(container.volumeMounts.first['mountPath']).to eq("/data/helm/#{app.name}/config")
+ end
+
+ it 'should include a volume inside the specification' do
+ spec = subject.generate.spec
+ expect(spec.volumes.first['name']).to eq('configuration-volume')
+ end
+
+ it 'should mount configMap specification in the volume' do
+ volume = subject.generate.spec.volumes.first
+ expect(volume.configMap['name']).to eq("values-content-configuration-#{app.name}")
+ expect(volume.configMap['items'].first['key']).to eq('values')
+ expect(volume.configMap['items'].first['path']).to eq('values.yaml')
+ end
+ end
+
+ context 'with a init command' do
+ let(:app) { create(:clusters_applications_helm, cluster: cluster) }
+
+ it_behaves_like 'helm pod'
+
+ it 'should not include volumeMounts inside the container' do
+ container = subject.generate.spec.containers.first
+ expect(container.volumeMounts).to be_nil
+ end
+
+ it 'should not a volume inside the specification' do
+ spec = subject.generate.spec
+ expect(spec.volumes).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm_spec.rb b/spec/lib/gitlab/kubernetes/helm_spec.rb
deleted file mode 100644
index 15f99b0401f..00000000000
--- a/spec/lib/gitlab/kubernetes/helm_spec.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Kubernetes::Helm do
- let(:client) { double('kubernetes client') }
- let(:helm) { described_class.new(client) }
- let(:namespace) { Gitlab::Kubernetes::Namespace.new(described_class::NAMESPACE, client) }
- let(:install_helm) { true }
- let(:chart) { 'stable/a_chart' }
- let(:application_name) { 'app_name' }
- let(:command) { Gitlab::Kubernetes::Helm::InstallCommand.new(application_name, install_helm, chart) }
- subject { helm }
-
- before do
- allow(Gitlab::Kubernetes::Namespace).to receive(:new).with(described_class::NAMESPACE, client).and_return(namespace)
- end
-
- describe '#initialize' do
- it 'creates a namespace object' do
- expect(Gitlab::Kubernetes::Namespace).to receive(:new).with(described_class::NAMESPACE, client)
-
- subject
- end
- end
-
- describe '#install' do
- before do
- allow(client).to receive(:create_pod).and_return(nil)
- allow(namespace).to receive(:ensure_exists!).once
- end
-
- it 'ensures the namespace exists before creating the POD' do
- expect(namespace).to receive(:ensure_exists!).once.ordered
- expect(client).to receive(:create_pod).once.ordered
-
- subject.install(command)
- end
- end
-
- describe '#installation_status' do
- let(:phase) { Gitlab::Kubernetes::Pod::RUNNING }
- let(:pod) { Kubeclient::Resource.new(status: { phase: phase }) } # partial representation
-
- it 'fetches POD phase from kubernetes cluster' do
- expect(client).to receive(:get_pod).with(command.pod_name, described_class::NAMESPACE).once.and_return(pod)
-
- expect(subject.installation_status(command.pod_name)).to eq(phase)
- end
- end
-
- describe '#installation_log' do
- let(:log) { 'some output' }
- let(:response) { RestClient::Response.new(log) }
-
- it 'fetches POD phase from kubernetes cluster' do
- expect(client).to receive(:get_pod_log).with(command.pod_name, described_class::NAMESPACE).once.and_return(response)
-
- expect(subject.installation_log(command.pod_name)).to eq(log)
- end
- end
-
- describe '#delete_installation_pod!' do
- it 'deletes the POD from kubernetes cluster' do
- expect(client).to receive(:delete_pod).with(command.pod_name, described_class::NAMESPACE).once
-
- subject.delete_installation_pod!(command.pod_name)
- end
- end
-
- describe '#helm_init_command' do
- subject { helm.send(:helm_init_command, command) }
-
- context 'when command.install_helm is true' do
- let(:install_helm) { true }
-
- it { is_expected.to eq('helm init >/dev/null') }
- end
-
- context 'when command.install_helm is false' do
- let(:install_helm) { false }
-
- it { is_expected.to eq('helm init --client-only >/dev/null') }
- end
- end
-
- describe '#helm_install_command' do
- subject { helm.send(:helm_install_command, command) }
-
- context 'when command.chart is nil' do
- let(:chart) { nil }
-
- it { is_expected.to be_nil }
- end
-
- context 'when command.chart is set' do
- let(:chart) { 'stable/a_chart' }
-
- it { is_expected.to eq("helm install #{chart} --name #{application_name} --namespace #{namespace.name} >/dev/null")}
- end
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/namespace_spec.rb b/spec/lib/gitlab/kubernetes/namespace_spec.rb
index b3c987f9344..e098612f6fb 100644
--- a/spec/lib/gitlab/kubernetes/namespace_spec.rb
+++ b/spec/lib/gitlab/kubernetes/namespace_spec.rb
@@ -9,7 +9,7 @@ describe Gitlab::Kubernetes::Namespace do
describe '#exists?' do
context 'when namespace do not exits' do
- let(:exception) { ::KubeException.new(404, "namespace #{name} not found", nil) }
+ let(:exception) { ::Kubeclient::HttpError.new(404, "namespace #{name} not found", nil) }
it 'returns false' do
expect(client).to receive(:get_namespace).with(name).once.and_raise(exception)
diff --git a/spec/lib/gitlab/ldap/access_spec.rb b/spec/lib/gitlab/ldap/access_spec.rb
deleted file mode 100644
index 6a47350be81..00000000000
--- a/spec/lib/gitlab/ldap/access_spec.rb
+++ /dev/null
@@ -1,166 +0,0 @@
-require 'spec_helper'
-
-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? }
-
- context 'when the user cannot be found' do
- before do
- allow(Gitlab::LDAP::Person).to receive(:find_by_dn).and_return(nil)
- end
-
- it { is_expected.to be_falsey }
-
- it 'blocks user in GitLab' do
- expect(access).to receive(:block_user).with(user, 'does not exist anymore')
-
- access.allowed?
- end
- end
-
- context 'when the user is found' do
- before do
- allow(Gitlab::LDAP::Person).to receive(:find_by_dn).and_return(:ldap_user)
- end
-
- context 'and the user is disabled via active directory' do
- before do
- allow(Gitlab::LDAP::Person).to receive(:disabled_via_active_directory?).and_return(true)
- end
-
- it { is_expected.to be_falsey }
-
- it 'blocks user in GitLab' do
- expect(access).to receive(:block_user).with(user, 'is disabled in Active Directory')
-
- access.allowed?
- end
- end
-
- context 'and has no disabled flag in active diretory' do
- before do
- allow(Gitlab::LDAP::Person).to receive(:disabled_via_active_directory?).and_return(false)
- end
-
- it { is_expected.to be_truthy }
-
- context 'when auto-created users are blocked' do
- before do
- user.block
- end
-
- it 'does not unblock user in GitLab' do
- expect(access).not_to receive(:unblock_user)
-
- access.allowed?
-
- expect(user).to be_blocked
- expect(user).not_to be_ldap_blocked # this block is handled by omniauth not by our internal logic
- end
- end
-
- context 'when auto-created users are not blocked' do
- before do
- user.ldap_block
- end
-
- it 'unblocks user in GitLab' do
- expect(access).to receive(:unblock_user).with(user, 'is not disabled anymore')
-
- access.allowed?
- end
- end
- end
-
- context 'without ActiveDirectory enabled' do
- before do
- allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
- allow_any_instance_of(Gitlab::LDAP::Config).to receive(:active_directory).and_return(false)
- end
-
- it { is_expected.to be_truthy }
-
- context 'when user cannot be found' do
- before do
- allow(Gitlab::LDAP::Person).to receive(:find_by_dn).and_return(nil)
- end
-
- it { is_expected.to be_falsey }
-
- it 'blocks user in GitLab' do
- expect(access).to receive(:block_user).with(user, 'does not exist anymore')
-
- access.allowed?
- end
- end
-
- context 'when user was previously ldap_blocked' do
- before do
- user.ldap_block
- end
-
- it 'unblocks the user if it exists' do
- expect(access).to receive(:unblock_user).with(user, 'is available again')
-
- access.allowed?
- end
- end
- end
- end
- end
-
- describe '#block_user' do
- before do
- user.activate
- allow(Gitlab::AppLogger).to receive(:info)
-
- access.block_user user, 'reason'
- end
-
- it 'blocks the user' do
- expect(user).to be_blocked
- expect(user).to be_ldap_blocked
- end
-
- it 'logs the reason' do
- expect(Gitlab::AppLogger).to have_received(:info).with(
- "LDAP account \"123456\" reason, " \
- "blocking Gitlab user \"#{user.name}\" (#{user.email})"
- )
- end
- end
-
- describe '#unblock_user' do
- before do
- user.ldap_block
- allow(Gitlab::AppLogger).to receive(:info)
-
- access.unblock_user user, 'reason'
- end
-
- it 'activates the user' do
- expect(user).not_to be_blocked
- expect(user).not_to be_ldap_blocked
- end
-
- it 'logs the reason' do
- Gitlab::AppLogger.info(
- "LDAP account \"123456\" reason, " \
- "unblocking Gitlab user \"#{user.name}\" (#{user.email})"
- )
- end
- end
-end
diff --git a/spec/lib/gitlab/ldap/adapter_spec.rb b/spec/lib/gitlab/ldap/adapter_spec.rb
deleted file mode 100644
index d9ddb4326be..00000000000
--- a/spec/lib/gitlab/ldap/adapter_spec.rb
+++ /dev/null
@@ -1,140 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::LDAP::Adapter do
- include LdapHelpers
-
- let(:ldap) { double(:ldap) }
- let(:adapter) { ldap_adapter('ldapmain', ldap) }
-
- describe '#users' do
- before do
- stub_ldap_config(base: 'dc=example,dc=com')
- end
-
- it 'searches with the proper options when searching by uid' do
- # Requires this expectation style to match the filter
- 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{dn uid cn mail email userPrincipalName})
- end.and_return({})
-
- adapter.users('uid', 'johndoe')
- end
-
- it 'searches with the proper options when searching by dn' do
- expect(adapter).to receive(:ldap_search).with(
- base: 'uid=johndoe,ou=users,dc=example,dc=com',
- scope: Net::LDAP::SearchScope_BaseObject,
- attributes: %w{dn uid cn mail email userPrincipalName},
- filter: nil
- ).and_return({})
-
- adapter.users('dn', 'uid=johndoe,ou=users,dc=example,dc=com')
- end
-
- it 'searches with the proper options when searching with a limit' do
- expect(adapter)
- .to receive(:ldap_search).with(hash_including(size: 100)).and_return({})
-
- adapter.users('uid', 'johndoe', 100)
- end
-
- it 'returns an LDAP::Person if search returns a result' do
- entry = ldap_user_entry('johndoe')
- allow(adapter).to receive(:ldap_search).and_return([entry])
-
- results = adapter.users('uid', 'johndoe')
-
- expect(results.size).to eq(1)
- expect(results.first.uid).to eq('johndoe')
- end
-
- it 'returns empty array if search entry does not respond to uid' do
- entry = Net::LDAP::Entry.new
- entry['dn'] = user_dn('johndoe')
- allow(adapter).to receive(:ldap_search).and_return([entry])
-
- results = adapter.users('uid', 'johndoe')
-
- expect(results).to be_empty
- end
-
- 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{dn sAMAccountName cn mail email userPrincipalName})
- ).and_return({})
-
- adapter.users('sAMAccountName', 'johndoe')
- end
- end
-
- describe '#dn_matches_filter?' do
- subject { adapter.dn_matches_filter?(:dn, :filter) }
-
- context "when the search result is non-empty" do
- 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 do
- allow(adapter).to receive(:ldap_search).and_return([])
- end
-
- it { is_expected.to be_falsey }
- end
- end
-
- describe '#ldap_search' do
- subject { adapter.ldap_search(base: :dn, filter: :filter) }
-
- context "when the search is successful" do
- context "and the result is non-empty" do
- 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 do
- allow(ldap).to receive(:search).and_return([])
- end
-
- it { is_expected.to eq [] }
- end
- end
-
- context "when the search encounters an error" do
- before do
- allow(ldap).to receive_messages(
- search: nil,
- get_operation_result: double(code: 1, message: 'some error')
- )
- end
-
- it { is_expected.to eq [] }
- end
-
- context "when the search raises an LDAP exception" do
- before do
- allow(ldap).to receive(:search) { raise Net::LDAP::Error, "some error" }
- allow(Rails.logger).to receive(:warn)
- end
-
- it { is_expected.to eq [] }
-
- it 'logs the error' do
- subject
- expect(Rails.logger).to have_received(:warn).with(
- "LDAP search raised exception Net::LDAP::Error: some error")
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/ldap/auth_hash_spec.rb b/spec/lib/gitlab/ldap/auth_hash_spec.rb
deleted file mode 100644
index 1785094af10..00000000000
--- a/spec/lib/gitlab/ldap/auth_hash_spec.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::LDAP::AuthHash do
- let(:auth_hash) do
- described_class.new(
- OmniAuth::AuthHash.new(
- uid: given_uid,
- provider: 'ldapmain',
- info: info,
- extra: {
- raw_info: raw_info
- }
- )
- )
- end
-
- let(:info) do
- {
- name: 'Smith, J.',
- email: 'johnsmith@example.com',
- nickname: '123456'
- }
- end
-
- let(:raw_info) do
- {
- uid: ['123456'],
- email: ['johnsmith@example.com'],
- cn: ['Smith, J.'],
- fullName: ['John Smith']
- }
- end
-
- context "without overridden attributes" do
- let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' }
-
- it "has the correct username" do
- expect(auth_hash.username).to eq("123456")
- end
-
- it "has the correct name" do
- expect(auth_hash.name).to eq("Smith, J.")
- end
- end
-
- context "with overridden attributes" do
- let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' }
-
- let(:attributes) do
- {
- 'username' => %w(mail email),
- 'name' => 'fullName'
- }
- end
-
- before do
- allow_any_instance_of(Gitlab::LDAP::Config).to receive(:attributes).and_return(attributes)
- end
-
- it "has the correct username" do
- expect(auth_hash.username).to eq("johnsmith@example.com")
- end
-
- it "has the correct name" do
- expect(auth_hash.name).to eq("John Smith")
- end
- end
-
- describe '#uid' do
- context 'when there is extraneous (but valid) whitespace' do
- let(:given_uid) { 'uid =john smith , ou = people, dc= example,dc =com' }
-
- it 'removes the extraneous whitespace' do
- expect(auth_hash.uid).to eq('uid=john smith,ou=people,dc=example,dc=com')
- end
- end
-
- context 'when there are upper case characters' do
- let(:given_uid) { 'UID=John Smith,ou=People,dc=example,dc=com' }
-
- it 'downcases' do
- expect(auth_hash.uid).to eq('uid=john smith,ou=people,dc=example,dc=com')
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/ldap/authentication_spec.rb b/spec/lib/gitlab/ldap/authentication_spec.rb
deleted file mode 100644
index 9d57a46c12b..00000000000
--- a/spec/lib/gitlab/ldap/authentication_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::LDAP::Authentication do
- let(:dn) { 'uid=John Smith, ou=People, dc=example, dc=com' }
- let(:user) { create(:omniauth_user, extern_uid: Gitlab::LDAP::Person.normalize_dn(dn)) }
- let(:login) { 'john' }
- let(:password) { 'password' }
-
- describe 'login' do
- before do
- allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
- end
-
- it "finds the user if authentication is successful" do
- expect(user).not_to be_nil
-
- # 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)
-
- expect(described_class.login(login, password)).to be_truthy
- end
-
- 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)
-
- expect(described_class.login(login, password)).to be_falsey
- end
-
- it "is false if authentication fails" do
- expect(user).not_to be_nil
-
- # 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)
-
- expect(described_class.login(login, password)).to be_falsey
- end
-
- it "fails if ldap is disabled" do
- allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(false)
- expect(described_class.login(login, password)).to be_falsey
- end
-
- it "fails if no login is supplied" do
- expect(described_class.login('', password)).to be_falsey
- end
-
- it "fails if no password is supplied" do
- expect(described_class.login(login, '')).to be_falsey
- end
- end
-end
diff --git a/spec/lib/gitlab/ldap/config_spec.rb b/spec/lib/gitlab/ldap/config_spec.rb
deleted file mode 100644
index ca2213cd112..00000000000
--- a/spec/lib/gitlab/ldap/config_spec.rb
+++ /dev/null
@@ -1,365 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::LDAP::Config do
- include LdapHelpers
-
- let(:config) { described_class.new('ldapmain') }
-
- describe '#initialize' do
- it 'requires a provider' do
- expect { described_class.new }.to raise_error ArgumentError
- end
-
- it 'works' do
- expect(config).to be_a described_class
- end
-
- it 'raises an error if a unknown provider is used' do
- expect { described_class.new 'unknown' }.to raise_error(RuntimeError)
- end
- end
-
- describe '#adapter_options' do
- it 'constructs basic options' do
- stub_ldap_config(
- options: {
- 'host' => 'ldap.example.com',
- 'port' => 386,
- 'encryption' => 'plain'
- }
- )
-
- expect(config.adapter_options).to eq(
- host: 'ldap.example.com',
- port: 386,
- encryption: nil
- )
- end
-
- it 'includes authentication options when auth is configured' do
- stub_ldap_config(
- options: {
- '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 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
-
- describe '#omniauth_options' do
- it 'constructs basic options' do
- stub_ldap_config(
- options: {
- 'host' => 'ldap.example.com',
- 'port' => 386,
- 'base' => 'ou=users,dc=example,dc=com',
- 'encryption' => 'plain',
- 'uid' => 'uid'
- }
- )
-
- expect(config.omniauth_options).to include(
- host: 'ldap.example.com',
- port: 386,
- base: 'ou=users,dc=example,dc=com',
- encryption: 'plain',
- filter: '(uid=%{username})'
- )
- expect(config.omniauth_options.keys).not_to include(:bind_dn, :password)
- end
-
- it 'includes authentication options when auth is configured' do
- stub_ldap_config(
- options: {
- 'uid' => 'sAMAccountName',
- 'user_filter' => '(memberOf=cn=group1,ou=groups,dc=example,dc=com)',
- 'bind_dn' => 'uid=admin,dc=example,dc=com',
- 'password' => 'super_secret'
- }
- )
-
- expect(config.omniauth_options).to include(
- filter: '(&(sAMAccountName=%{username})(memberOf=cn=group1,ou=groups,dc=example,dc=com))',
- bind_dn: 'uid=admin,dc=example,dc=com',
- 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
- it 'is true when password is set' do
- stub_ldap_config(
- options: {
- 'bind_dn' => 'uid=admin,dc=example,dc=com',
- 'password' => 'super_secret'
- }
- )
-
- expect(config.has_auth?).to be_truthy
- end
-
- it 'is true when bind_dn is set and password is empty' do
- stub_ldap_config(
- options: {
- 'bind_dn' => 'uid=admin,dc=example,dc=com',
- 'password' => ''
- }
- )
-
- expect(config.has_auth?).to be_truthy
- end
-
- it 'is false when password and bind_dn are not set' do
- stub_ldap_config(options: { 'bind_dn' => nil, 'password' => nil })
-
- expect(config.has_auth?).to be_falsey
- end
- end
-
- describe '#attributes' do
- it 'uses default attributes when no custom attributes are configured' do
- expect(config.attributes).to eq(config.default_attributes)
- end
-
- it 'merges the configuration attributes with default attributes' do
- stub_ldap_config(
- options: {
- 'attributes' => {
- 'username' => %w(sAMAccountName),
- 'email' => %w(userPrincipalName)
- }
- }
- )
-
- expect(config.attributes).to include({
- 'username' => %w(sAMAccountName),
- 'email' => %w(userPrincipalName),
- 'name' => 'cn'
- })
- end
- end
-end
diff --git a/spec/lib/gitlab/ldap/dn_spec.rb b/spec/lib/gitlab/ldap/dn_spec.rb
deleted file mode 100644
index 8e21ecdf9ab..00000000000
--- a/spec/lib/gitlab/ldap/dn_spec.rb
+++ /dev/null
@@ -1,224 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::LDAP::DN do
- using RSpec::Parameterized::TableSyntax
-
- describe '#normalize_value' do
- subject { described_class.normalize_value(given) }
-
- it_behaves_like 'normalizes a DN attribute value'
-
- context 'when the given DN is malformed' do
- context 'when ending with a comma' do
- let(:given) { 'John Smith,' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
- end
- end
-
- context 'when given a BER encoded attribute value with a space in it' do
- let(:given) { '#aa aa' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the end of an attribute value, but got \"a\"")
- end
- end
-
- context 'when given a BER encoded attribute value with a non-hex character in it' do
- let(:given) { '#aaXaaa' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the first character of a hex pair, but got \"X\"")
- end
- end
-
- context 'when given a BER encoded attribute value with a non-hex character in it' do
- let(:given) { '#aaaYaa' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair, but got \"Y\"")
- end
- end
-
- context 'when given a hex pair with a non-hex character in it, inside double quotes' do
- let(:given) { '"Sebasti\\cX\\a1n"' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"X\"")
- end
- end
-
- context 'with an open (as opposed to closed) double quote' do
- let(:given) { '"James' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
- end
- end
-
- context 'with an invalid escaped hex code' do
- let(:given) { 'J\ames' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Invalid escaped hex code "\am"')
- end
- end
-
- context 'with a value ending with the escape character' do
- let(:given) { 'foo\\' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
- end
- end
- end
- end
-
- describe '#to_normalized_s' do
- subject { described_class.new(given).to_normalized_s }
-
- it_behaves_like 'normalizes a DN'
-
- context 'when we do not support the given DN format' do
- context 'multivalued RDNs' do
- context 'without extraneous whitespace' do
- let(:given) { 'uid=john smith+telephonenumber=+1 555-555-5555,ou=people,dc=example,dc=com' }
-
- it 'raises UnsupportedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::UnsupportedError)
- end
- end
-
- context 'with extraneous whitespace' do
- context 'around the phone number plus sign' do
- let(:given) { 'uid = John Smith + telephoneNumber = + 1 555-555-5555 , ou = People,dc=example,dc=com' }
-
- it 'raises UnsupportedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::UnsupportedError)
- end
- end
-
- context 'not around the phone number plus sign' do
- let(:given) { 'uid = John Smith + telephoneNumber = +1 555-555-5555 , ou = People,dc=example,dc=com' }
-
- it 'raises UnsupportedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::UnsupportedError)
- end
- end
- end
- end
- end
-
- context 'when the given DN is malformed' do
- context 'when ending with a comma' do
- let(:given) { 'uid=John Smith,' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
- end
- end
-
- context 'when given a BER encoded attribute value with a space in it' do
- let(:given) { '0.9.2342.19200300.100.1.25=#aa aa' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the end of an attribute value, but got \"a\"")
- end
- end
-
- context 'when given a BER encoded attribute value with a non-hex character in it' do
- let(:given) { '0.9.2342.19200300.100.1.25=#aaXaaa' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the first character of a hex pair, but got \"X\"")
- end
- end
-
- context 'when given a BER encoded attribute value with a non-hex character in it' do
- let(:given) { '0.9.2342.19200300.100.1.25=#aaaYaa' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair, but got \"Y\"")
- end
- end
-
- context 'when given a hex pair with a non-hex character in it, inside double quotes' do
- let(:given) { 'uid="Sebasti\\cX\\a1n"' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"X\"")
- end
- end
-
- context 'without a name value pair' do
- let(:given) { 'John' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
- end
- end
-
- context 'with an open (as opposed to closed) double quote' do
- let(:given) { 'cn="James' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
- end
- end
-
- context 'with an invalid escaped hex code' do
- let(:given) { 'cn=J\ames' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Invalid escaped hex code "\am"')
- end
- end
-
- context 'with a value ending with the escape character' do
- let(:given) { 'cn=\\' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly')
- end
- end
-
- context 'with an invalid OID attribute type name' do
- let(:given) { '1.2.d=Value' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized RDN OID attribute type name character "d"')
- end
- end
-
- context 'with a period in a non-OID attribute type name' do
- let(:given) { 'd1.2=Value' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized RDN attribute type name character "."')
- end
- end
-
- context 'when starting with non-space, non-alphanumeric character' do
- let(:given) { ' -uid=John Smith' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized first character of an RDN attribute type name "-"')
- end
- end
-
- context 'when given a UID with an escaped equal sign' do
- let(:given) { 'uid\\=john' }
-
- it 'raises MalformedError' do
- expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized RDN attribute type name character "\\"')
- end
- end
- end
- end
-
- def assert_generic_test(test_description, got, expected)
- test_failure_message = "Failed test description: '#{test_description}'\n\n expected: \"#{expected}\"\n got: \"#{got}\""
- expect(got).to eq(expected), test_failure_message
- end
-end
diff --git a/spec/lib/gitlab/ldap/person_spec.rb b/spec/lib/gitlab/ldap/person_spec.rb
deleted file mode 100644
index d204050ef66..00000000000
--- a/spec/lib/gitlab/ldap/person_spec.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::LDAP::Person do
- include LdapHelpers
-
- let(:entry) { ldap_user_entry('john.doe') }
-
- before do
- stub_ldap_config(
- options: {
- 'attributes' => {
- 'name' => 'cn',
- 'email' => %w(mail email userPrincipalName)
- }
- }
- )
- end
-
- describe '.normalize_dn' do
- subject { described_class.normalize_dn(given) }
-
- it_behaves_like 'normalizes a DN'
-
- context 'with an exception during normalization' do
- let(:given) { 'John "Smith,' } # just something that will cause an exception
-
- it 'returns the given DN unmodified' do
- expect(subject).to eq(given)
- end
- end
- end
-
- describe '.normalize_uid' do
- subject { described_class.normalize_uid(given) }
-
- it_behaves_like 'normalizes a DN attribute value'
-
- context 'with an exception during normalization' do
- let(:given) { 'John "Smith,' } # just something that will cause an exception
-
- it 'returns the given UID unmodified' do
- expect(subject).to eq(given)
- end
- end
- end
-
- describe '#name' do
- it 'uses the configured name attribute and handles values as an array' do
- name = 'John Doe'
- entry['cn'] = [name]
- person = described_class.new(entry, 'ldapmain')
-
- expect(person.name).to eq(name)
- end
- end
-
- describe '#email' do
- it 'returns the value of mail, if present' do
- mail = 'john@example.com'
- entry['mail'] = mail
- person = described_class.new(entry, 'ldapmain')
-
- expect(person.email).to eq([mail])
- end
-
- 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 = described_class.new(entry, 'ldapmain')
-
- expect(person.email).to eq([user_principal_name])
- end
- end
-
- def assert_generic_test(test_description, got, expected)
- test_failure_message = "Failed test description: '#{test_description}'\n\n expected: #{expected}\n got: #{got}"
- expect(got).to eq(expected), test_failure_message
- end
-end
diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb
deleted file mode 100644
index 048caa38fcf..00000000000
--- a/spec/lib/gitlab/ldap/user_spec.rb
+++ /dev/null
@@ -1,241 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::LDAP::User do
- let(:ldap_user) { described_class.new(auth_hash) }
- let(:gl_user) { ldap_user.gl_user }
- let(:info) do
- {
- name: 'John',
- email: 'john@example.com',
- nickname: 'john'
- }
- end
- let(:auth_hash) do
- OmniAuth::AuthHash.new(uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain', info: info)
- end
- let(:ldap_user_upper_case) { described_class.new(auth_hash_upper_case) }
- let(:info_upper_case) do
- {
- name: 'John',
- email: 'John@Example.com', # Email address has upper case chars
- nickname: 'john'
- }
- end
- let(:auth_hash_upper_case) do
- OmniAuth::AuthHash.new(uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain', info: info_upper_case)
- end
-
- describe '#changed?' do
- it "marks existing ldap user as changed" do
- create(:omniauth_user, extern_uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain')
- expect(ldap_user.changed?).to be_truthy
- end
-
- it "marks existing non-ldap user if the email matches as changed" do
- create(:user, email: 'john@example.com')
- expect(ldap_user.changed?).to be_truthy
- end
-
- it "does not mark existing ldap user as changed" do
- create(:omniauth_user, email: 'john@example.com', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain')
- expect(ldap_user.changed?).to be_falsey
- end
- end
-
- describe '.find_by_uid_and_provider' do
- let(:dn) { 'CN=John Åström, CN=Users, DC=Example, DC=com' }
-
- it 'retrieves the correct user' do
- special_info = {
- name: 'John Åström',
- email: 'john@example.com',
- nickname: 'jastrom'
- }
- special_hash = OmniAuth::AuthHash.new(uid: dn, provider: 'ldapmain', info: special_info)
- special_chars_user = described_class.new(special_hash)
- user = special_chars_user.save
-
- expect(described_class.find_by_uid_and_provider(dn, 'ldapmain')).to eq user
- end
- end
-
- describe 'find or create' do
- it "finds the user if already existing" do
- create(:omniauth_user, extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain')
-
- 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 }
-
- existing_user.reload
- expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com'
- expect(existing_user.ldap_identity.provider).to eql 'ldapmain'
- end
-
- 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 }
-
- existing_user.reload
- expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com'
- expect(existing_user.ldap_identity.provider).to eql 'ldapmain'
- expect(existing_user.id).to eql ldap_user.gl_user.id
- end
-
- 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 }
-
- existing_user.reload
- expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com'
- expect(existing_user.ldap_identity.provider).to eql 'ldapmain'
- expect(existing_user.id).to eql ldap_user.gl_user.id
- end
-
- it 'maintains an identity per provider' do
- existing_user = create(:omniauth_user, email: 'john@example.com', provider: 'twitter')
- expect(existing_user.identities.count).to be(1)
-
- ldap_user.save
- expect(ldap_user.gl_user.identities.count).to be(2)
-
- # Expect that find_by provider only returns a single instance of an identity and not an Enumerable
- expect(ldap_user.gl_user.identities.find_by(provider: 'twitter')).to be_instance_of Identity
- expect(ldap_user.gl_user.identities.find_by(provider: auth_hash.provider)).to be_instance_of Identity
- end
-
- it "creates a new user if not found" do
- expect { ldap_user.save }.to change { User.count }.by(1)
- end
-
- context 'when signup is disabled' do
- before do
- stub_application_setting signup_enabled: false
- end
-
- it 'creates the user' do
- ldap_user.save
-
- expect(gl_user).to be_persisted
- end
- end
-
- context 'when user confirmation email is enabled' do
- before do
- stub_application_setting send_user_confirmation_email: true
- end
-
- it 'creates and confirms the user anyway' do
- ldap_user.save
-
- expect(gl_user).to be_persisted
- expect(gl_user).to be_confirmed
- end
- end
- end
-
- describe 'updating email' do
- context "when LDAP sets an email" do
- it "has a real email" do
- expect(ldap_user.gl_user.email).to eq(info[:email])
- end
-
- it "has email set as synced" do
- expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
- end
-
- it "has email set as read-only" do
- expect(ldap_user.gl_user.read_only_attribute?(:email)).to be_truthy
- end
-
- it "has synced attributes provider set to ldapmain" do
- expect(ldap_user.gl_user.user_synced_attributes_metadata.provider).to eql 'ldapmain'
- end
- end
-
- context "when LDAP doesn't set an email" do
- before do
- info.delete(:email)
- end
-
- it "has a temp email" do
- expect(ldap_user.gl_user.temp_oauth_email?).to be_truthy
- end
-
- it "has email set as not synced" do
- expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
- end
-
- it "does not have email set as read-only" do
- expect(ldap_user.gl_user.read_only_attribute?(:email)).to be_falsey
- 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)
- end
-
- context 'signup' do
- context 'dont block on create' do
- before do
- configure_block(false)
- end
-
- it do
- ldap_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create' do
- before do
- configure_block(true)
- end
-
- it do
- ldap_user.save
- expect(gl_user).to be_valid
- expect(gl_user).to be_blocked
- end
- end
- end
-
- context 'sign-in' do
- before do
- ldap_user.save
- ldap_user.gl_user.activate
- end
-
- context 'dont block on create' do
- before do
- configure_block(false)
- end
-
- it do
- ldap_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create' do
- before do
- configure_block(true)
- end
-
- it do
- ldap_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/metrics/method_call_spec.rb b/spec/lib/gitlab/metrics/method_call_spec.rb
index 41a9d1d9c90..d9379cfe674 100644
--- a/spec/lib/gitlab/metrics/method_call_spec.rb
+++ b/spec/lib/gitlab/metrics/method_call_spec.rb
@@ -5,6 +5,10 @@ describe Gitlab::Metrics::MethodCall do
let(:method_call) { described_class.new('Foo#bar', :Foo, '#bar', transaction) }
describe '#measure' do
+ after do
+ described_class.reload_metric!(:gitlab_method_call_duration_seconds)
+ end
+
it 'measures the performance of the supplied block' do
method_call.measure { 'foo' }
@@ -20,8 +24,6 @@ describe Gitlab::Metrics::MethodCall do
context 'prometheus instrumentation is enabled' do
before do
- allow(Feature.get(:prometheus_metrics_method_instrumentation)).to receive(:enabled?).and_call_original
- described_class.measurement_enabled_cache_expires_at.value = Time.now.to_i - 1
Feature.get(:prometheus_metrics_method_instrumentation).enable
end
@@ -31,30 +33,12 @@ describe Gitlab::Metrics::MethodCall do
end
end
- it 'caches subsequent invocations of feature check' do
- 10.times do
- method_call.measure { 'foo' }
- end
-
- expect(Feature.get(:prometheus_metrics_method_instrumentation)).to have_received(:enabled?).once
- end
-
- it 'expires feature check cache after 1 minute' do
- method_call.measure { 'foo' }
-
- Timecop.travel(1.minute.from_now) do
- method_call.measure { 'foo' }
- end
-
- Timecop.travel(1.minute.from_now + 1.second) do
- method_call.measure { 'foo' }
- end
-
- expect(Feature.get(:prometheus_metrics_method_instrumentation)).to have_received(:enabled?).twice
+ it 'metric is not a NullMetric' do
+ expect(described_class).not_to be_instance_of(Gitlab::Metrics::NullMetric)
end
it 'observes the performance of the supplied block' do
- expect(described_class.call_duration_histogram)
+ expect(described_class.gitlab_method_call_duration_seconds)
.to receive(:observe)
.with({ module: :Foo, method: '#bar' }, be_a_kind_of(Numeric))
@@ -64,14 +48,12 @@ describe Gitlab::Metrics::MethodCall do
context 'prometheus instrumentation is disabled' do
before do
- described_class.measurement_enabled_cache_expires_at.value = Time.now.to_i - 1
-
Feature.get(:prometheus_metrics_method_instrumentation).disable
end
- it 'does not observe the performance' do
- expect(described_class.call_duration_histogram)
- .not_to receive(:observe)
+ it 'observes using NullMetric' do
+ expect(described_class.gitlab_method_call_duration_seconds).to be_instance_of(Gitlab::Metrics::NullMetric)
+ expect(described_class.gitlab_method_call_duration_seconds).to receive(:observe)
method_call.measure { 'foo' }
end
@@ -81,12 +63,10 @@ describe Gitlab::Metrics::MethodCall do
context 'when measurement is below threshold' do
before do
allow(method_call).to receive(:above_threshold?).and_return(false)
-
- Feature.get(:prometheus_metrics_method_instrumentation).enable
end
it 'does not observe the performance' do
- expect(described_class.call_duration_histogram)
+ expect(described_class.gitlab_method_call_duration_seconds)
.not_to receive(:observe)
method_call.measure { 'foo' }
@@ -96,7 +76,7 @@ describe Gitlab::Metrics::MethodCall do
describe '#to_metric' do
it 'returns a Metric instance' do
- expect(method_call).to receive(:real_time).and_return(4.0001)
+ expect(method_call).to receive(:real_time).and_return(4.0001).twice
expect(method_call).to receive(:cpu_time).and_return(3.0001)
method_call.measure { 'foo' }
diff --git a/spec/lib/gitlab/metrics/methods_spec.rb b/spec/lib/gitlab/metrics/methods_spec.rb
new file mode 100644
index 00000000000..9d41ed2442b
--- /dev/null
+++ b/spec/lib/gitlab/metrics/methods_spec.rb
@@ -0,0 +1,137 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Methods do
+ subject { Class.new { include Gitlab::Metrics::Methods } }
+
+ shared_context 'metric' do |metric_type, *args|
+ let(:docstring) { 'description' }
+ let(:metric_name) { :sample_metric }
+
+ describe "#define_#{metric_type}" do
+ define_method(:call_define_metric_method) do |**args|
+ subject.__send__("define_#{metric_type}", metric_name, **args)
+ end
+
+ context 'metrics access method not defined' do
+ it "defines metrics accessing method" do
+ expect(subject).not_to respond_to(metric_name)
+
+ call_define_metric_method(docstring: docstring)
+
+ expect(subject).to respond_to(metric_name)
+ end
+ end
+
+ context 'metrics access method defined' do
+ before do
+ call_define_metric_method(docstring: docstring)
+ end
+
+ it 'raises error when trying to redefine method' do
+ expect { call_define_metric_method(docstring: docstring) }.to raise_error(ArgumentError)
+ end
+
+ context 'metric is not cached' do
+ it 'calls fetch_metric' do
+ expect(subject).to receive(:init_metric).with(metric_type, metric_name, docstring: docstring)
+
+ subject.public_send(metric_name)
+ end
+ end
+
+ context 'metric is cached' do
+ before do
+ subject.public_send(metric_name)
+ end
+
+ it 'returns cached metric' do
+ expect(subject).not_to receive(:init_metric)
+
+ subject.public_send(metric_name)
+ end
+ end
+ end
+ end
+
+ describe "#fetch_#{metric_type}" do
+ let(:null_metric) { Gitlab::Metrics::NullMetric.instance }
+
+ define_method(:call_fetch_metric_method) do |**args|
+ subject.__send__("fetch_#{metric_type}", metric_name, **args)
+ end
+
+ context "when #{metric_type} is not cached" do
+ it 'initializes counter metric' do
+ allow(Gitlab::Metrics).to receive(metric_type).and_return(null_metric)
+
+ call_fetch_metric_method(docstring: docstring)
+
+ expect(Gitlab::Metrics).to have_received(metric_type).with(metric_name, docstring, *args)
+ end
+ end
+
+ context "when #{metric_type} is cached" do
+ before do
+ call_fetch_metric_method(docstring: docstring)
+ end
+
+ it 'uses class metric cache' do
+ expect(Gitlab::Metrics).not_to receive(metric_type)
+
+ call_fetch_metric_method(docstring: docstring)
+ end
+
+ context 'when metric is reloaded' do
+ before do
+ subject.reload_metric!(metric_name)
+ end
+
+ it "initializes #{metric_type} metric" do
+ allow(Gitlab::Metrics).to receive(metric_type).and_return(null_metric)
+
+ call_fetch_metric_method(docstring: docstring)
+
+ expect(Gitlab::Metrics).to have_received(metric_type).with(metric_name, docstring, *args)
+ end
+ end
+ end
+
+ context 'when metric is configured with feature' do
+ let(:feature_name) { :some_metric_feature }
+ let(:metric) { call_fetch_metric_method(docstring: docstring, with_feature: feature_name) }
+
+ context 'when feature is enabled' do
+ before do
+ Feature.get(feature_name).enable
+ end
+
+ it "initializes #{metric_type} metric" do
+ allow(Gitlab::Metrics).to receive(metric_type).and_return(null_metric)
+
+ metric
+
+ expect(Gitlab::Metrics).to have_received(metric_type).with(metric_name, docstring, *args)
+ end
+ end
+
+ context 'when feature is disabled' do
+ before do
+ Feature.get(feature_name).disable
+ end
+
+ it "returns NullMetric" do
+ allow(Gitlab::Metrics).to receive(metric_type)
+
+ expect(metric).to be_instance_of(Gitlab::Metrics::NullMetric)
+
+ expect(Gitlab::Metrics).not_to have_received(metric_type)
+ end
+ end
+ end
+ end
+ end
+
+ include_examples 'metric', :counter, {}
+ include_examples 'metric', :gauge, {}, :all
+ include_examples 'metric', :histogram, {}, [0.005, 0.01, 0.1, 1, 10]
+end
diff --git a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
index 375cbf8a9ca..54781dd52fc 100644
--- a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
+++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
@@ -2,6 +2,11 @@ require 'spec_helper'
describe Gitlab::Metrics::Samplers::RubySampler do
let(:sampler) { described_class.new(5) }
+ let(:null_metric) { double('null_metric', set: nil, observe: nil) }
+
+ before do
+ allow(Gitlab::Metrics::NullMetric).to receive(:instance).and_return(null_metric)
+ end
after do
Allocations.stop if Gitlab::Metrics.mri?
@@ -17,12 +22,9 @@ describe Gitlab::Metrics::Samplers::RubySampler do
end
it 'adds a metric containing the memory usage' do
- expect(Gitlab::Metrics::System).to receive(:memory_usage)
- .and_return(9000)
+ expect(Gitlab::Metrics::System).to receive(:memory_usage).and_return(9000)
- expect(sampler.metrics[:memory_usage]).to receive(:set)
- .with({}, 9000)
- .and_call_original
+ expect(sampler.metrics[:memory_usage]).to receive(:set).with({}, 9000)
sampler.sample
end
@@ -31,9 +33,7 @@ describe Gitlab::Metrics::Samplers::RubySampler do
expect(Gitlab::Metrics::System).to receive(:file_descriptor_count)
.and_return(4)
- expect(sampler.metrics[:file_descriptors]).to receive(:set)
- .with({}, 4)
- .and_call_original
+ expect(sampler.metrics[:file_descriptors]).to receive(:set).with({}, 4)
sampler.sample
end
@@ -49,16 +49,14 @@ describe Gitlab::Metrics::Samplers::RubySampler do
it 'adds a metric containing garbage collection time statistics' do
expect(GC::Profiler).to receive(:total_time).and_return(0.24)
- expect(sampler.metrics[:total_time]).to receive(:set)
- .with({}, 240)
- .and_call_original
+ expect(sampler.metrics[:total_time]).to receive(:set).with({}, 240)
sampler.sample
end
it 'adds a metric containing garbage collection statistics' do
GC.stat.keys.each do |key|
- expect(sampler.metrics[key]).to receive(:set).with({}, anything).and_call_original
+ expect(sampler.metrics[key]).to receive(:set).with({}, anything)
end
sampler.sample
diff --git a/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb b/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb
index 6721e02fb85..61eb059a731 100644
--- a/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb
+++ b/spec/lib/gitlab/metrics/sidekiq_metrics_exporter_spec.rb
@@ -38,7 +38,9 @@ describe Gitlab::Metrics::SidekiqMetricsExporter do
expect(::WEBrick::HTTPServer).to have_received(:new).with(
Port: port,
- BindAddress: address
+ BindAddress: address,
+ Logger: anything,
+ AccessLog: anything
)
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 eca75a4fac1..9f3af1acef7 100644
--- a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
@@ -32,7 +32,7 @@ describe Gitlab::Metrics::Subscribers::ActionView do
end
it 'observes view rendering time' do
- expect(subscriber.send(:metric_view_rendering_duration_seconds))
+ expect(described_class.gitlab_view_rendering_duration_seconds)
.to receive(:observe)
.with({ view: 'app/views/x.html.haml' }, 2.1)
diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
index 9b3698fb4a8..4e7bd433a9c 100644
--- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
@@ -25,7 +25,7 @@ describe Gitlab::Metrics::Subscribers::ActiveRecord do
expect(subscriber).to receive(:current_transaction)
.at_least(:once)
.and_return(transaction)
- expect(subscriber.send(:metric_sql_duration_seconds)).to receive(:observe).with({}, 0.002)
+ expect(described_class.send(:gitlab_sql_duration_seconds)).to receive(:observe).with({}, 0.002)
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 58e28592cf9..6795c1ab56b 100644
--- a/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb
@@ -144,7 +144,10 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
end
context 'with a transaction' do
+ let(:metric_cache_misses_total) { double('metric_cache_misses_total', increment: nil) }
+
before do
+ allow(subscriber).to receive(:metric_cache_misses_total).and_return(metric_cache_misses_total)
allow(subscriber).to receive(:current_transaction)
.and_return(transaction)
end
@@ -157,9 +160,9 @@ describe Gitlab::Metrics::Subscribers::RailsCache do
end
it 'increments the cache_read_miss total' do
- expect(subscriber.send(:metric_cache_misses_total)).to receive(:increment).with({})
-
subscriber.cache_generate(event)
+
+ expect(metric_cache_misses_total).to have_received(:increment).with({})
end
end
end
diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb
index 1619fbd88b1..03c185ddc07 100644
--- a/spec/lib/gitlab/metrics_spec.rb
+++ b/spec/lib/gitlab/metrics_spec.rb
@@ -20,7 +20,7 @@ describe Gitlab::Metrics do
context 'prometheus metrics enabled in config' do
before do
- allow(described_class).to receive(:current_application_settings).and_return(prometheus_metrics_enabled: true)
+ allow(Gitlab::CurrentSettings).to receive(:prometheus_metrics_enabled).and_return(true)
end
context 'when metrics folder is present' do
diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb
index 60a134be939..b24c9882c0c 100644
--- a/spec/lib/gitlab/middleware/go_spec.rb
+++ b/spec/lib/gitlab/middleware/go_spec.rb
@@ -3,19 +3,30 @@ require 'spec_helper'
describe Gitlab::Middleware::Go do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
+ let(:env) do
+ {
+ 'rack.input' => '',
+ 'REQUEST_METHOD' => 'GET'
+ }
+ end
describe '#call' do
describe 'when go-get=0' do
+ before do
+ env['QUERY_STRING'] = 'go-get=0'
+ end
+
it 'skips go-import generation' do
- env = { 'rack.input' => '',
- 'QUERY_STRING' => 'go-get=0' }
expect(app).to receive(:call).with(env).and_return('no-go')
middleware.call(env)
end
end
describe 'when go-get=1' do
- let(:current_user) { nil }
+ before do
+ env['QUERY_STRING'] = 'go-get=1'
+ env['PATH_INFO'] = "/#{path}"
+ end
shared_examples 'go-get=1' do |enabled_protocol:|
context 'with simple 2-segment project path' do
@@ -54,21 +65,75 @@ describe Gitlab::Middleware::Go do
project.update_attribute(:visibility_level, Project::PRIVATE)
end
- context 'with access to the project' do
+ shared_examples 'unauthorized' do
+ it 'returns the 2-segment group path' do
+ expect_response_with_path(go, enabled_protocol, group.full_path)
+ end
+ end
+
+ context 'when not authenticated' do
+ it_behaves_like 'unauthorized'
+ end
+
+ context 'when authenticated' do
let(:current_user) { project.creator }
before do
project.team.add_master(current_user)
end
- it 'returns the full project path' do
- expect_response_with_path(go, enabled_protocol, project.full_path)
+ shared_examples 'authenticated' do
+ context 'with access to the project' do
+ it 'returns the full project path' do
+ expect_response_with_path(go, enabled_protocol, project.full_path)
+ end
+ end
+
+ context 'without access to the project' do
+ before do
+ project.team.find_member(current_user).destroy
+ end
+
+ it_behaves_like 'unauthorized'
+ end
end
- end
- context 'without access to the project' do
- it 'returns the 2-segment group path' do
- expect_response_with_path(go, enabled_protocol, group.full_path)
+ context 'using warden' do
+ before do
+ env['warden'] = double(authenticate: current_user)
+ end
+
+ context 'when active' do
+ it_behaves_like 'authenticated'
+ end
+
+ context 'when blocked' do
+ before do
+ current_user.block!
+ end
+
+ it_behaves_like 'unauthorized'
+ end
+ end
+
+ context 'using a personal access token' do
+ let(:personal_access_token) { create(:personal_access_token, user: current_user) }
+
+ before do
+ env['HTTP_PRIVATE_TOKEN'] = personal_access_token.token
+ end
+
+ context 'with api scope' do
+ it_behaves_like 'authenticated'
+ end
+
+ context 'with read_user scope' do
+ before do
+ personal_access_token.update_attribute(:scopes, [:read_user])
+ end
+
+ it_behaves_like 'unauthorized'
+ end
end
end
end
@@ -138,12 +203,6 @@ describe Gitlab::Middleware::Go do
end
def go
- env = {
- 'rack.input' => '',
- 'QUERY_STRING' => 'go-get=1',
- 'PATH_INFO' => "/#{path}",
- 'warden' => double(authenticate: current_user)
- }
middleware.call(env)
end
diff --git a/spec/lib/gitlab/middleware/multipart_spec.rb b/spec/lib/gitlab/middleware/multipart_spec.rb
index 8d925460f01..a2ba91dae80 100644
--- a/spec/lib/gitlab/middleware/multipart_spec.rb
+++ b/spec/lib/gitlab/middleware/multipart_spec.rb
@@ -5,15 +5,17 @@ require 'tempfile'
describe Gitlab::Middleware::Multipart do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
+ let(:original_filename) { 'filename' }
it 'opens top-level files' do
Tempfile.open('top-level') do |tempfile|
- env = post_env({ 'file' => tempfile.path }, { 'file.name' => 'filename' }, Gitlab::Workhorse.secret, 'gitlab-workhorse')
+ env = post_env({ 'file' => tempfile.path }, { 'file.name' => original_filename }, Gitlab::Workhorse.secret, 'gitlab-workhorse')
expect(app).to receive(:call) do |env|
file = Rack::Request.new(env).params['file']
expect(file).to be_a(::UploadedFile)
expect(file.path).to eq(tempfile.path)
+ expect(file.original_filename).to eq(original_filename)
end
middleware.call(env)
@@ -34,13 +36,14 @@ describe Gitlab::Middleware::Multipart do
it 'opens files one level deep' do
Tempfile.open('one-level') do |tempfile|
- in_params = { 'user' => { 'avatar' => { '.name' => 'filename' } } }
+ in_params = { 'user' => { 'avatar' => { '.name' => original_filename } } }
env = post_env({ 'user[avatar]' => tempfile.path }, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse')
expect(app).to receive(:call) do |env|
file = Rack::Request.new(env).params['user']['avatar']
expect(file).to be_a(::UploadedFile)
expect(file.path).to eq(tempfile.path)
+ expect(file.original_filename).to eq(original_filename)
end
middleware.call(env)
@@ -49,13 +52,14 @@ describe Gitlab::Middleware::Multipart do
it 'opens files two levels deep' do
Tempfile.open('two-levels') do |tempfile|
- in_params = { 'project' => { 'milestone' => { 'themesong' => { '.name' => 'filename' } } } }
+ in_params = { 'project' => { 'milestone' => { 'themesong' => { '.name' => original_filename } } } }
env = post_env({ 'project[milestone][themesong]' => tempfile.path }, in_params, Gitlab::Workhorse.secret, 'gitlab-workhorse')
expect(app).to receive(:call) do |env|
file = Rack::Request.new(env).params['project']['milestone']['themesong']
expect(file).to be_a(::UploadedFile)
expect(file.path).to eq(tempfile.path)
+ expect(file.original_filename).to eq(original_filename)
end
middleware.call(env)
diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb
index 07ba11b93a3..39ec2f37a83 100644
--- a/spec/lib/gitlab/middleware/read_only_spec.rb
+++ b/spec/lib/gitlab/middleware/read_only_spec.rb
@@ -11,15 +11,17 @@ describe Gitlab::Middleware::ReadOnly do
RSpec::Matchers.define :disallow_request do
match do |middleware|
- flash = middleware.send(:rack_flash)
- flash['alert'] && flash['alert'].include?('You cannot do writing operations')
+ alert = middleware.env['rack.session'].to_hash
+ .dig('flash', 'flashes', 'alert')
+
+ alert&.include?('You cannot perform write operations')
end
end
RSpec::Matchers.define :disallow_request_in_json do
match do |response|
json_response = JSON.parse(response.body)
- response.body.include?('You cannot do writing operations') && json_response.key?('message')
+ response.body.include?('You cannot perform write operations') && json_response.key?('message')
end
end
@@ -34,10 +36,25 @@ describe Gitlab::Middleware::ReadOnly do
rack.to_app
end
- subject { described_class.new(fake_app) }
+ let(:observe_env) do
+ Module.new do
+ attr_reader :env
+
+ def call(env)
+ @env = env
+ super
+ end
+ end
+ end
let(:request) { Rack::MockRequest.new(rack_stack) }
+ subject do
+ described_class.new(fake_app).tap do |app|
+ app.extend(observe_env)
+ end
+ end
+
context 'normal requests to a read-only Gitlab instance' do
let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'text/plain' }, ['OK']] } }
diff --git a/spec/lib/gitlab/middleware/release_env_spec.rb b/spec/lib/gitlab/middleware/release_env_spec.rb
new file mode 100644
index 00000000000..5e3aa877409
--- /dev/null
+++ b/spec/lib/gitlab/middleware/release_env_spec.rb
@@ -0,0 +1,16 @@
+require 'spec_helper'
+
+describe Gitlab::Middleware::ReleaseEnv do
+ let(:inner_app) { double(:app, call: 'yay') }
+ let(:app) { described_class.new(inner_app) }
+ let(:env) { { 'action_controller.instance' => 'something' } }
+
+ describe '#call' do
+ it 'calls the app and clears the env' do
+ result = app.call(env)
+
+ expect(result).to eq('yay')
+ expect(env).to be_empty
+ end
+ end
+end
diff --git a/spec/lib/gitlab/o_auth/auth_hash_spec.rb b/spec/lib/gitlab/o_auth/auth_hash_spec.rb
deleted file mode 100644
index dbcc200b90b..00000000000
--- a/spec/lib/gitlab/o_auth/auth_hash_spec.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::OAuth::AuthHash do
- let(:provider) { 'ldap'.freeze }
- let(:auth_hash) do
- described_class.new(
- OmniAuth::AuthHash.new(
- provider: provider,
- uid: uid_ascii,
- info: info_hash
- )
- )
- end
-
- let(:uid_raw) do
- "CN=Onur K\xC3\xBC\xC3\xA7\xC3\xBCk,OU=Test,DC=example,DC=net"
- end
- let(:email_raw) { "onur.k\xC3\xBC\xC3\xA7\xC3\xBCk_ABC-123@example.net" }
- let(:nickname_raw) { "ok\xC3\xBC\xC3\xA7\xC3\xBCk" }
- let(:first_name_raw) { 'Onur' }
- let(:last_name_raw) { "K\xC3\xBC\xC3\xA7\xC3\xBCk" }
- let(:name_raw) { "Onur K\xC3\xBC\xC3\xA7\xC3\xBCk" }
-
- let(:uid_ascii) { uid_raw.force_encoding(Encoding::ASCII_8BIT) }
- let(:email_ascii) { email_raw.force_encoding(Encoding::ASCII_8BIT) }
- let(:nickname_ascii) { nickname_raw.force_encoding(Encoding::ASCII_8BIT) }
- let(:first_name_ascii) { first_name_raw.force_encoding(Encoding::ASCII_8BIT) }
- let(:last_name_ascii) { last_name_raw.force_encoding(Encoding::ASCII_8BIT) }
- let(:name_ascii) { name_raw.force_encoding(Encoding::ASCII_8BIT) }
-
- let(:uid_utf8) { uid_ascii.force_encoding(Encoding::UTF_8) }
- let(:email_utf8) { email_ascii.force_encoding(Encoding::UTF_8) }
- let(:nickname_utf8) { nickname_ascii.force_encoding(Encoding::UTF_8) }
- let(:name_utf8) { name_ascii.force_encoding(Encoding::UTF_8) }
-
- let(:info_hash) do
- {
- email: email_ascii,
- first_name: first_name_ascii,
- last_name: last_name_ascii,
- name: name_ascii,
- nickname: nickname_ascii,
- uid: uid_ascii
- }
- end
-
- context 'defaults' do
- it { expect(auth_hash.provider).to eq provider }
- it { expect(auth_hash.uid).to eql uid_utf8 }
- it { expect(auth_hash.email).to eql email_utf8 }
- it { expect(auth_hash.username).to eql nickname_utf8 }
- it { expect(auth_hash.name).to eql name_utf8 }
- it { expect(auth_hash.password).not_to be_empty }
- end
-
- context 'email not provided' do
- before do
- info_hash.delete(:email)
- end
-
- it 'generates a temp email' do
- expect( auth_hash.email).to start_with('temp-email-for-oauth')
- end
- end
-
- context 'username not provided' do
- 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'
- end
- end
-
- context 'name not provided' do
- before do
- info_hash.delete(:name)
- end
-
- it 'concats first and lastname as the name' do
- expect(auth_hash.name).to eql name_utf8
- end
- end
-
- context 'auth_hash constructed with ASCII-8BIT encoding' do
- it 'forces utf8 encoding on uid' do
- expect(auth_hash.uid.encoding).to eql Encoding::UTF_8
- end
-
- it 'forces utf8 encoding on provider' do
- expect(auth_hash.provider.encoding).to eql Encoding::UTF_8
- end
-
- it 'forces utf8 encoding on name' do
- expect(auth_hash.name.encoding).to eql Encoding::UTF_8
- end
-
- it 'forces utf8 encoding on username' do
- expect(auth_hash.username.encoding).to eql Encoding::UTF_8
- end
-
- it 'forces utf8 encoding on email' do
- expect(auth_hash.email.encoding).to eql Encoding::UTF_8
- end
-
- it 'forces utf8 encoding on password' do
- expect(auth_hash.password.encoding).to eql Encoding::UTF_8
- end
- end
-end
diff --git a/spec/lib/gitlab/o_auth/provider_spec.rb b/spec/lib/gitlab/o_auth/provider_spec.rb
deleted file mode 100644
index 30faf107e3f..00000000000
--- a/spec/lib/gitlab/o_auth/provider_spec.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-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
deleted file mode 100644
index 6334bcd0156..00000000000
--- a/spec/lib/gitlab/o_auth/user_spec.rb
+++ /dev/null
@@ -1,706 +0,0 @@
-require 'spec_helper'
-
-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(:dn) { 'uid=user1,ou=people,dc=example' }
- let(:provider) { 'my-provider' }
- let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash) }
- let(:info_hash) do
- {
- nickname: '-john+gitlab-ETC%.git@gmail.com',
- name: 'John',
- email: 'john@mail.com',
- address: {
- locality: 'locality',
- country: 'country'
- }
- }
- end
- let(:ldap_user) { Gitlab::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
-
- describe '#persisted?' do
- let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
-
- it "finds an existing user based on uid and provider (facebook)" do
- expect( oauth_user.persisted? ).to be_truthy
- end
-
- it 'returns false if user is not found in database' do
- allow(auth_hash).to receive(:uid).and_return('non-existing')
- expect( oauth_user.persisted? ).to be_falsey
- end
- 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
-
- let(:provider) { 'twitter' }
-
- describe 'signup' do
- context 'when signup is disabled' do
- before do
- stub_application_setting signup_enabled: false
- end
-
- it 'creates the user' do
- stub_omniauth_config(allow_single_sign_on: ['twitter'])
-
- oauth_user.save
-
- expect(gl_user).to be_persisted
- end
- end
-
- context 'when user confirmation email is enabled' do
- before do
- stub_application_setting send_user_confirmation_email: true
- end
-
- it 'creates and confirms the user anyway' do
- stub_omniauth_config(allow_single_sign_on: ['twitter'])
-
- oauth_user.save
-
- expect(gl_user).to be_persisted
- expect(gl_user).to be_confirmed
- end
- end
-
- it 'marks user as having password_automatically_set' do
- stub_omniauth_config(allow_single_sign_on: ['twitter'], external_providers: ['twitter'])
-
- oauth_user.save
-
- expect(gl_user).to be_persisted
- expect(gl_user).to be_password_automatically_set
- end
-
- shared_examples 'to verify compliance with allow_single_sign_on' do
- context 'provider is marked as external' do
- it 'marks user as external' do
- stub_omniauth_config(allow_single_sign_on: ['twitter'], external_providers: ['twitter'])
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_truthy
- end
- end
-
- context 'provider was external, now has been removed' do
- it 'does not mark external user as internal' do
- create(:omniauth_user, extern_uid: 'my-uid', provider: 'twitter', external: true)
- stub_omniauth_config(allow_single_sign_on: ['twitter'], external_providers: ['facebook'])
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_truthy
- end
- end
-
- context 'provider is not external' do
- context 'when adding a new OAuth identity' do
- it 'does not promote an external user to internal' do
- user = create(:user, email: 'john@mail.com', external: true)
- user.identities.create(provider: provider, extern_uid: uid)
-
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_truthy
- end
- end
- end
-
- context 'with new allow_single_sign_on enabled syntax' do
- before do
- stub_omniauth_config(allow_single_sign_on: ['twitter'])
- end
-
- it "creates a user from Omniauth" do
- oauth_user.save
-
- expect(gl_user).to be_valid
- identity = gl_user.identities.first
- expect(identity.extern_uid).to eql uid
- expect(identity.provider).to eql 'twitter'
- end
- end
-
- context "with old allow_single_sign_on enabled syntax" do
- before do
- stub_omniauth_config(allow_single_sign_on: true)
- end
-
- it "creates a user from Omniauth" do
- oauth_user.save
-
- expect(gl_user).to be_valid
- identity = gl_user.identities.first
- expect(identity.extern_uid).to eql uid
- expect(identity.provider).to eql 'twitter'
- end
- end
-
- context 'with new allow_single_sign_on disabled syntax' do
- before do
- stub_omniauth_config(allow_single_sign_on: [])
- end
-
- it 'throws an error' do
- expect { oauth_user.save }.to raise_error StandardError
- end
- end
-
- context 'with old allow_single_sign_on disabled (Default)' do
- before do
- stub_omniauth_config(allow_single_sign_on: false)
- end
-
- it 'throws an error' do
- expect { oauth_user.save }.to raise_error StandardError
- end
- end
- end
-
- context "with auto_link_ldap_user disabled (default)" do
- 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 do
- stub_omniauth_config(auto_link_ldap_user: true)
- end
-
- context "and no LDAP provider defined" do
- 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 do
- stub_ldap_config(providers: %w(ldapmain))
- end
-
- context "and a corresponding LDAP person" do
- before do
- allow(ldap_user).to receive(:uid) { uid }
- allow(ldap_user).to receive(:username) { uid }
- allow(ldap_user).to receive(:email) { ['johndoe@example.com', 'john2@example.com'] }
- allow(ldap_user).to receive(:dn) { dn }
- end
-
- context "and no account for the LDAP user" do
- before do
- allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
-
- oauth_user.save
- end
-
- it "creates a user with dual LDAP and omniauth identities" do
- expect(gl_user).to be_valid
- expect(gl_user.username).to eql uid
- expect(gl_user.email).to eql 'johndoe@example.com'
- expect(gl_user.identities.length).to be 2
- identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash).to match_array(
- [
- { provider: 'ldapmain', extern_uid: dn },
- { provider: 'twitter', extern_uid: uid }
- ]
- )
- end
-
- it "has email set as synced" do
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
- end
-
- it "has email set as read-only" do
- expect(gl_user.read_only_attribute?(:email)).to be_truthy
- end
-
- it "has synced attributes provider set to ldapmain" do
- expect(gl_user.user_synced_attributes_metadata.provider).to eql 'ldapmain'
- end
- end
-
- context "and LDAP user has an account already" do
- let!(:existing_user) { create(:omniauth_user, email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
- it "adds the omniauth identity to the LDAP account" do
- allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
-
- oauth_user.save
-
- expect(gl_user).to be_valid
- expect(gl_user.username).to eql 'john'
- expect(gl_user.email).to eql 'john@example.com'
- expect(gl_user.identities.length).to be 2
- identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash).to match_array(
- [
- { provider: 'ldapmain', extern_uid: dn },
- { provider: 'twitter', extern_uid: uid }
- ]
- )
- end
- end
-
- context 'when an LDAP person is not found by uid' do
- it 'tries to find an LDAP person by DN and adds the omniauth identity to the user' do
- allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(nil)
- allow(Gitlab::LDAP::Person).to receive(:find_by_dn).and_return(ldap_user)
-
- oauth_user.save
-
- identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash)
- .to match_array(
- [
- { provider: 'ldapmain', extern_uid: dn },
- { provider: 'twitter', extern_uid: uid }
- ]
- )
- end
- end
- end
-
- context "and no corresponding LDAP person" do
- 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
- end
- end
- end
-
- describe 'blocking' do
- let(:provider) { '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 do
- stub_omniauth_config(block_auto_created_users: false)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create' do
- before do
- stub_omniauth_config(block_auto_created_users: true)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).to be_blocked
- end
- end
- end
-
- context 'signup with linked omniauth and LDAP account' do
- before do
- stub_omniauth_config(auto_link_ldap_user: true)
- allow(ldap_user).to receive(:uid) { uid }
- allow(ldap_user).to receive(:username) { uid }
- allow(ldap_user).to receive(:email) { ['johndoe@example.com', 'john2@example.com'] }
- allow(ldap_user).to receive(:dn) { dn }
- allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
- end
-
- context "and no account for the LDAP user" do
- context 'dont block on create (LDAP)' do
- before do
- allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create (LDAP)' do
- before do
- allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).to be_blocked
- end
- end
- end
-
- context 'and LDAP user has an account already' do
- let!(:existing_user) { create(:omniauth_user, email: 'john@example.com', extern_uid: dn, provider: 'ldapmain', username: 'john') }
-
- context 'dont block on create (LDAP)' do
- before do
- allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create (LDAP)' do
- before do
- allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
- end
- end
-
- context 'sign-in' do
- before do
- oauth_user.save
- oauth_user.gl_user.activate
- end
-
- context 'dont block on create' do
- before do
- stub_omniauth_config(block_auto_created_users: false)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create' do
- before do
- stub_omniauth_config(block_auto_created_users: true)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'dont block on create (LDAP)' do
- before do
- allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: false)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create (LDAP)' do
- before do
- allow_any_instance_of(Gitlab::LDAP::Config).to receive_messages(block_auto_created_users: true)
- end
-
- it do
- oauth_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
- end
- end
- end
-
- describe 'ensure backwards compatibility with with sync email from provider option' 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')
- stub_omniauth_config(sync_profile_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 email set as synced" do
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
- end
-
- it "has email set as read-only" do
- expect(gl_user.read_only_attribute?(:email)).to be_truthy
- end
-
- it "has synced attributes provider set to my-provider" do
- expect(gl_user.user_synced_attributes_metadata.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 email set as not synced" do
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
- end
-
- it "does not have email set as read-only" do
- expect(gl_user.read_only_attribute?(:email)).to be_falsey
- 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
-
- describe 'updating email with sync profile' do
- let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
-
- before do
- stub_omniauth_config(sync_profile_from_provider: ['my-provider'])
- stub_omniauth_config(sync_profile_attributes: true)
- 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 email set as synced" do
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true)
- end
-
- it "has email set as read-only" do
- expect(gl_user.read_only_attribute?(:email)).to be_truthy
- end
-
- it "has synced attributes provider set to my-provider" do
- expect(gl_user.user_synced_attributes_metadata.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 email set as not synced" do
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
- end
-
- it "does not have email set as read-only" do
- expect(gl_user.read_only_attribute?(:email)).to be_falsey
- end
- end
- end
-
- describe 'updating name' do
- let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
-
- before do
- stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
- stub_omniauth_setting(sync_profile_attributes: true)
- end
-
- context "when provider sets a name" do
- it "updates the user name" do
- expect(gl_user.name).to eq(info_hash[:name])
- end
- end
-
- context "when provider doesn't set a name" do
- before do
- info_hash.delete(:name)
- end
-
- it "does not update the user name" do
- expect(gl_user.name).not_to eq(info_hash[:name])
- expect(gl_user.user_synced_attributes_metadata.name_synced).to be(false)
- end
- end
- end
-
- describe 'updating location' do
- let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
-
- before do
- stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
- stub_omniauth_setting(sync_profile_attributes: true)
- end
-
- context "when provider sets a location" do
- it "updates the user location" do
- expect(gl_user.location).to eq(info_hash[:address][:locality] + ', ' + info_hash[:address][:country])
- expect(gl_user.user_synced_attributes_metadata.location_synced).to be(true)
- end
- end
-
- context "when provider doesn't set a location" do
- before do
- info_hash[:address].delete(:country)
- info_hash[:address].delete(:locality)
- end
-
- it "does not update the user location" do
- expect(gl_user.location).to be_nil
- expect(gl_user.user_synced_attributes_metadata.location_synced).to be(false)
- end
- end
- end
-
- describe 'updating user info' do
- let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
-
- context "update all info" do
- before do
- stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
- stub_omniauth_setting(sync_profile_attributes: true)
- end
-
- it "updates the user email" do
- expect(gl_user.email).to eq(info_hash[:email])
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true)
- end
-
- it "updates the user name" do
- expect(gl_user.name).to eq(info_hash[:name])
- expect(gl_user.user_synced_attributes_metadata.name_synced).to be(true)
- end
-
- it "updates the user location" do
- expect(gl_user.location).to eq(info_hash[:address][:locality] + ', ' + info_hash[:address][:country])
- expect(gl_user.user_synced_attributes_metadata.location_synced).to be(true)
- end
-
- it "sets my-provider as the attributes provider" do
- expect(gl_user.user_synced_attributes_metadata.provider).to eql('my-provider')
- end
- end
-
- context "update only requested info" do
- before do
- stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
- stub_omniauth_setting(sync_profile_attributes: %w(name location))
- end
-
- it "updates the user name" do
- expect(gl_user.name).to eq(info_hash[:name])
- expect(gl_user.user_synced_attributes_metadata.name_synced).to be(true)
- end
-
- it "updates the user location" do
- expect(gl_user.location).to eq(info_hash[:address][:locality] + ', ' + info_hash[:address][:country])
- expect(gl_user.user_synced_attributes_metadata.location_synced).to be(true)
- end
-
- it "does not update the user email" do
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be(false)
- end
- end
-
- context "update default_scope" do
- before do
- stub_omniauth_setting(sync_profile_from_provider: ['my-provider'])
- end
-
- it "updates the user email" do
- expect(gl_user.email).to eq(info_hash[:email])
- expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true)
- end
- end
-
- context "update no info when profile sync is nil" do
- it "does not have sync_attribute" do
- expect(gl_user.user_synced_attributes_metadata).to be(nil)
- end
-
- it "does not update the user email" do
- expect(gl_user.email).not_to eq(info_hash[:email])
- end
-
- it "does not update the user name" do
- expect(gl_user.name).not_to eq(info_hash[:name])
- end
-
- it "does not update the user location" do
- expect(gl_user.location).not_to eq(info_hash[:address][:country])
- end
- end
- end
-
- describe '.find_by_uid_and_provider' do
- let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
-
- it 'normalizes extern_uid' do
- allow(oauth_user.auth_hash).to receive(:uid).and_return('MY-UID')
- expect(oauth_user.find_user).to eql gl_user
- end
- end
-end
diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb
new file mode 100644
index 00000000000..d808b4d49e0
--- /dev/null
+++ b/spec/lib/gitlab/omniauth_initializer_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe Gitlab::OmniauthInitializer do
+ let(:devise_config) { class_double(Devise) }
+
+ subject { described_class.new(devise_config) }
+
+ describe '#execute' do
+ it 'configures providers from array' do
+ generic_config = { 'name' => 'generic' }
+
+ expect(devise_config).to receive(:omniauth).with(:generic)
+
+ subject.execute([generic_config])
+ end
+
+ it 'allows "args" array for app_id and app_secret' do
+ legacy_config = { 'name' => 'legacy', 'args' => %w(123 abc) }
+
+ expect(devise_config).to receive(:omniauth).with(:legacy, '123', 'abc')
+
+ subject.execute([legacy_config])
+ end
+
+ it 'passes app_id and app_secret as additional arguments' do
+ twitter_config = { 'name' => 'twitter', 'app_id' => '123', 'app_secret' => 'abc' }
+
+ expect(devise_config).to receive(:omniauth).with(:twitter, '123', 'abc')
+
+ subject.execute([twitter_config])
+ end
+
+ it 'passes "args" hash as symbolized hash argument' do
+ hash_config = { 'name' => 'hash', 'args' => { 'custom' => 'format' } }
+
+ expect(devise_config).to receive(:omniauth).with(:hash, custom: 'format')
+
+ subject.execute([hash_config])
+ end
+
+ it 'configures fail_with_empty_uid for shibboleth' do
+ shibboleth_config = { 'name' => 'shibboleth', 'args' => {} }
+
+ expect(devise_config).to receive(:omniauth).with(:shibboleth, fail_with_empty_uid: true)
+
+ subject.execute([shibboleth_config])
+ end
+
+ it 'configures remote_sign_out_handler proc for authentiq' do
+ authentiq_config = { 'name' => 'authentiq', 'args' => {} }
+
+ expect(devise_config).to receive(:omniauth).with(:authentiq, remote_sign_out_handler: an_instance_of(Proc))
+
+ subject.execute([authentiq_config])
+ end
+
+ it 'configures on_single_sign_out proc for cas3' do
+ cas3_config = { 'name' => 'cas3', 'args' => {} }
+
+ expect(devise_config).to receive(:omniauth).with(:cas3, on_single_sign_out: an_instance_of(Proc))
+
+ subject.execute([cas3_config])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 0ae90069b7f..a40330d853f 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -121,7 +121,7 @@ describe Gitlab::PathRegex do
STARTING_WITH_NAMESPACE = %r{^/\*namespace_id/:(project_)?id}
NON_PARAM_PARTS = %r{[^:*][a-z\-_/]*}
ANY_OTHER_PATH_PART = %r{[a-z\-_/:]*}
- WILDCARD_SEGMENT = %r{\*}
+ WILDCARD_SEGMENT = /\*/
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}}
@@ -194,8 +194,8 @@ describe Gitlab::PathRegex do
end
end
- describe '.root_namespace_path_regex' do
- subject { described_class.root_namespace_path_regex }
+ describe '.root_namespace_route_regex' do
+ subject { %r{\A#{described_class.root_namespace_route_regex}/\z} }
it 'rejects top level routes' do
expect(subject).not_to match('admin/')
@@ -318,8 +318,8 @@ describe Gitlab::PathRegex do
end
end
- describe '.project_path_regex' do
- subject { described_class.project_path_regex }
+ describe '.project_route_regex' do
+ subject { %r{\A#{described_class.project_route_regex}/\z} }
it 'accepts top level routes' do
expect(subject).to match('admin/')
diff --git a/spec/lib/gitlab/performance_bar_spec.rb b/spec/lib/gitlab/performance_bar_spec.rb
index b8a2267f1a4..f480376acb4 100644
--- a/spec/lib/gitlab/performance_bar_spec.rb
+++ b/spec/lib/gitlab/performance_bar_spec.rb
@@ -25,6 +25,12 @@ describe Gitlab::PerformanceBar do
expect(described_class.enabled?(nil)).to be_falsy
end
+ it 'returns true when given user is an admin' do
+ user = build_stubbed(:user, :admin)
+
+ expect(described_class.enabled?(user)).to be_truthy
+ end
+
it 'returns false when allowed_group_id is nil' do
expect(described_class).to receive(:allowed_group_id).and_return(nil)
diff --git a/spec/lib/gitlab/plugin_spec.rb b/spec/lib/gitlab/plugin_spec.rb
new file mode 100644
index 00000000000..33dd4f79130
--- /dev/null
+++ b/spec/lib/gitlab/plugin_spec.rb
@@ -0,0 +1,68 @@
+require 'spec_helper'
+
+describe Gitlab::Plugin do
+ describe '.execute' do
+ let(:data) { Gitlab::DataBuilder::Push::SAMPLE_DATA }
+ let(:plugin) { Rails.root.join('plugins', 'test.rb') }
+ let(:tmp_file) { Tempfile.new('plugin-dump') }
+ let(:result) { described_class.execute(plugin.to_s, data) }
+ let(:success) { result.first }
+ let(:message) { result.last }
+
+ let(:plugin_source) do
+ <<~EOS
+ #!/usr/bin/env ruby
+ x = STDIN.read
+ File.write('#{tmp_file.path}', x)
+ EOS
+ end
+
+ before do
+ File.write(plugin, plugin_source)
+ end
+
+ after do
+ FileUtils.rm(plugin)
+ end
+
+ context 'successful execution' do
+ before do
+ File.chmod(0o777, plugin)
+ end
+
+ after do
+ tmp_file.close!
+ end
+
+ it { expect(success).to be true }
+ it { expect(message).to be_empty }
+
+ it 'ensures plugin received data via stdin' do
+ result
+
+ expect(File.read(tmp_file.path)).to eq(data.to_json)
+ end
+ end
+
+ context 'non-executable' do
+ it { expect(success).to be false }
+ it { expect(message).to include('Permission denied') }
+ end
+
+ context 'non-zero exit' do
+ let(:plugin_source) do
+ <<~EOS
+ #!/usr/bin/env ruby
+ exit 1
+ EOS
+ end
+
+ before do
+ File.chmod(0o777, plugin)
+ end
+
+ it { expect(success).to be false }
+ it { expect(message).to be_empty }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/popen/runner_spec.rb b/spec/lib/gitlab/popen/runner_spec.rb
new file mode 100644
index 00000000000..2e2cb4ca28f
--- /dev/null
+++ b/spec/lib/gitlab/popen/runner_spec.rb
@@ -0,0 +1,139 @@
+require 'spec_helper'
+
+describe Gitlab::Popen::Runner do
+ subject { described_class.new }
+
+ describe '#run' do
+ it 'runs the command and returns the result' do
+ run_command
+
+ expect(Gitlab::Popen).to have_received(:popen_with_detail)
+ end
+ end
+
+ describe '#all_success_and_clean?' do
+ it 'returns true when exit status is 0 and stderr is empty' do
+ run_command
+
+ expect(subject).to be_all_success_and_clean
+ end
+
+ it 'returns false when exit status is not 0' do
+ run_command(exitstatus: 1)
+
+ expect(subject).not_to be_all_success_and_clean
+ end
+
+ it 'returns false when exit stderr has something' do
+ run_command(stderr: 'stderr')
+
+ expect(subject).not_to be_all_success_and_clean
+ end
+ end
+
+ describe '#all_success?' do
+ it 'returns true when exit status is 0' do
+ run_command
+
+ expect(subject).to be_all_success
+ end
+
+ it 'returns false when exit status is not 0' do
+ run_command(exitstatus: 1)
+
+ expect(subject).not_to be_all_success
+ end
+
+ it 'returns true' do
+ run_command(stderr: 'stderr')
+
+ expect(subject).to be_all_success
+ end
+ end
+
+ describe '#all_stderr_empty?' do
+ it 'returns true when stderr is empty' do
+ run_command
+
+ expect(subject).to be_all_stderr_empty
+ end
+
+ it 'returns true when exit status is not 0' do
+ run_command(exitstatus: 1)
+
+ expect(subject).to be_all_stderr_empty
+ end
+
+ it 'returns false when exit stderr has something' do
+ run_command(stderr: 'stderr')
+
+ expect(subject).not_to be_all_stderr_empty
+ end
+ end
+
+ describe '#failed_results' do
+ it 'returns [] when everything is passed' do
+ run_command
+
+ expect(subject.failed_results).to be_empty
+ end
+
+ it 'returns the result when exit status is not 0' do
+ result = run_command(exitstatus: 1)
+
+ expect(subject.failed_results).to contain_exactly(result)
+ end
+
+ it 'returns [] when exit stderr has something' do
+ run_command(stderr: 'stderr')
+
+ expect(subject.failed_results).to be_empty
+ end
+ end
+
+ describe '#warned_results' do
+ it 'returns [] when everything is passed' do
+ run_command
+
+ expect(subject.warned_results).to be_empty
+ end
+
+ it 'returns [] when exit status is not 0' do
+ run_command(exitstatus: 1)
+
+ expect(subject.warned_results).to be_empty
+ end
+
+ it 'returns the result when exit stderr has something' do
+ result = run_command(stderr: 'stderr')
+
+ expect(subject.warned_results).to contain_exactly(result)
+ end
+ end
+
+ def run_command(
+ command: 'command',
+ stdout: 'stdout',
+ stderr: '',
+ exitstatus: 0,
+ status: double(exitstatus: exitstatus, success?: exitstatus.zero?),
+ duration: 0.1)
+
+ result =
+ Gitlab::Popen::Result.new(command, stdout, stderr, status, duration)
+
+ allow(Gitlab::Popen)
+ .to receive(:popen_with_detail)
+ .and_return(result)
+
+ subject.run([command]) do |cmd, &run|
+ expect(cmd).to eq(command)
+
+ cmd_result = run.call
+
+ expect(cmd_result).to eq(result)
+ end
+
+ subject.results.first
+ end
+end
diff --git a/spec/lib/gitlab/popen_spec.rb b/spec/lib/gitlab/popen_spec.rb
index b145ca36f26..1dbead16d5b 100644
--- a/spec/lib/gitlab/popen_spec.rb
+++ b/spec/lib/gitlab/popen_spec.rb
@@ -1,11 +1,23 @@
require 'spec_helper'
-describe 'Gitlab::Popen' do
+describe Gitlab::Popen do
let(:path) { Rails.root.join('tmp').to_s }
before do
@klass = Class.new(Object)
- @klass.send(:include, Gitlab::Popen)
+ @klass.send(:include, described_class)
+ end
+
+ describe '.popen_with_detail' do
+ subject { @klass.new.popen_with_detail(cmd) }
+
+ let(:cmd) { %W[#{Gem.ruby} -e $stdout.puts(1);$stderr.puts(2);exit(3)] }
+
+ it { expect(subject.cmd).to eq(cmd) }
+ it { expect(subject.stdout).to eq("1\n") }
+ it { expect(subject.stderr).to eq("2\n") }
+ it { expect(subject.status.exitstatus).to eq(3) }
+ it { expect(subject.duration).to be_kind_of(Numeric) }
end
context 'zero status' do
diff --git a/spec/lib/gitlab/profiler_spec.rb b/spec/lib/gitlab/profiler_spec.rb
new file mode 100644
index 00000000000..548eb28fe4d
--- /dev/null
+++ b/spec/lib/gitlab/profiler_spec.rb
@@ -0,0 +1,187 @@
+require 'spec_helper'
+
+describe Gitlab::Profiler do
+ RSpec::Matchers.define_negated_matcher :not_change, :change
+
+ let(:null_logger) { Logger.new('/dev/null') }
+ let(:private_token) { 'private' }
+
+ describe '.profile' do
+ let(:app) { double(:app) }
+
+ before do
+ allow(ActionDispatch::Integration::Session).to receive(:new).and_return(app)
+ allow(app).to receive(:get)
+ end
+
+ it 'returns a profile result' do
+ expect(described_class.profile('/')).to be_an_instance_of(RubyProf::Profile)
+ end
+
+ it 'uses the custom logger given' do
+ expect(described_class).to receive(:create_custom_logger)
+ .with(null_logger, private_token: anything)
+ .and_call_original
+
+ described_class.profile('/', logger: null_logger)
+ end
+
+ it 'sends a POST request when data is passed' do
+ post_data = '{"a":1}'
+
+ expect(app).to receive(:post).with(anything, post_data, anything)
+
+ described_class.profile('/', post_data: post_data)
+ end
+
+ it 'uses the private_token for auth if given' do
+ expect(app).to receive(:get).with('/', nil, 'Private-Token' => private_token)
+ expect(app).to receive(:get).with('/api/v4/users')
+
+ described_class.profile('/', private_token: private_token)
+ end
+
+ it 'uses the user for auth if given' do
+ user = double(:user)
+ user_token = 'user'
+
+ allow(user).to receive_message_chain(:personal_access_tokens, :active, :pluck, :first).and_return(user_token)
+
+ expect(app).to receive(:get).with('/', nil, 'Private-Token' => user_token)
+ expect(app).to receive(:get).with('/api/v4/users')
+
+ described_class.profile('/', user: user)
+ end
+
+ context 'when providing a user without a personal access token' do
+ it 'raises an error' do
+ user = double(:user)
+ allow(user).to receive_message_chain(:personal_access_tokens, :active, :pluck).and_return([])
+
+ expect { described_class.profile('/', user: user) }.to raise_error('Your user must have a personal_access_token')
+ end
+ end
+
+ it 'uses the private_token for auth if both it and user are set' do
+ user = double(:user)
+ user_token = 'user'
+
+ allow(user).to receive_message_chain(:personal_access_tokens, :active, :pluck, :first).and_return(user_token)
+
+ expect(app).to receive(:get).with('/', nil, 'Private-Token' => private_token)
+ expect(app).to receive(:get).with('/api/v4/users')
+
+ described_class.profile('/', user: user, private_token: private_token)
+ end
+ end
+
+ describe '.create_custom_logger' do
+ it 'does nothing when nil is passed' do
+ expect(described_class.create_custom_logger(nil)).to be_nil
+ end
+
+ context 'the new logger' do
+ let(:custom_logger) do
+ described_class.create_custom_logger(null_logger, private_token: private_token)
+ end
+
+ it 'does not affect the existing logger' do
+ expect(null_logger).not_to receive(:debug)
+ expect(custom_logger).to receive(:debug).and_call_original
+
+ custom_logger.debug('Foo')
+ end
+
+ it 'strips out the private token' do
+ expect(custom_logger).to receive(:add) do |severity, _progname, message|
+ next if message.include?('spec/')
+
+ expect(severity).to eq(Logger::DEBUG)
+ expect(message).to include('public').and include(described_class::FILTERED_STRING)
+ expect(message).not_to include(private_token)
+ end.twice
+
+ custom_logger.debug("public #{private_token}")
+ end
+
+ it 'tracks model load times by model' do
+ custom_logger.debug('This is not a model load')
+ custom_logger.debug('User Load (1.2ms)')
+ custom_logger.debug('User Load (1.3ms)')
+ custom_logger.debug('Project Load (10.4ms)')
+
+ expect(custom_logger.load_times_by_model).to eq('User' => [1.2, 1.3],
+ 'Project' => [10.4])
+ end
+
+ it 'logs the backtrace, ignoring lines as appropriate' do
+ # Skip Rails's backtrace cleaning.
+ allow(Rails.backtrace_cleaner).to receive(:clean, &:itself)
+
+ expect(custom_logger).to receive(:add)
+ .with(Logger::DEBUG,
+ anything,
+ a_string_matching(File.basename(__FILE__)))
+ .twice
+
+ expect(custom_logger).not_to receive(:add).with(Logger::DEBUG,
+ anything,
+ a_string_matching('lib/gitlab/profiler.rb'))
+
+ # Force a part of the backtrace to be in the (ignored) profiler source
+ # file.
+ described_class.with_custom_logger(nil) { custom_logger.debug('Foo') }
+ end
+ end
+ end
+
+ describe '.with_custom_logger' do
+ context 'when the logger is set' do
+ it 'uses the replacement logger for the duration of the block' do
+ expect(null_logger).to receive(:debug).and_call_original
+
+ expect { described_class.with_custom_logger(null_logger) { ActiveRecord::Base.logger.debug('foo') } }
+ .to not_change { ActiveRecord::Base.logger }
+ .and not_change { ActionController::Base.logger }
+ .and not_change { ActiveSupport::LogSubscriber.colorize_logging }
+ end
+
+ it 'returns the result of the block' do
+ expect(described_class.with_custom_logger(null_logger) { 2 }).to eq(2)
+ end
+ end
+
+ context 'when the logger is nil' do
+ it 'returns the result of the block' do
+ expect(described_class.with_custom_logger(nil) { 2 }).to eq(2)
+ end
+
+ it 'does not modify the standard Rails loggers' do
+ expect { described_class.with_custom_logger(nil) { } }
+ .to not_change { ActiveRecord::Base.logger }
+ .and not_change { ActionController::Base.logger }
+ .and not_change { ActiveSupport::LogSubscriber.colorize_logging }
+ end
+ end
+ end
+
+ describe '.log_load_times_by_model' do
+ it 'logs the model, query count, and time by slowest first' do
+ expect(null_logger).to receive(:load_times_by_model).and_return(
+ 'User' => [1.2, 1.3],
+ 'Project' => [10.4]
+ )
+
+ expect(null_logger).to receive(:info).with('Project total (1): 10.4ms')
+ expect(null_logger).to receive(:info).with('User total (2): 2.5ms')
+
+ described_class.log_load_times_by_model(null_logger)
+ end
+
+ it 'does nothing when called with a logger that does not have load times' do
+ expect(null_logger).not_to receive(:info)
+
+ expect(described_class.log_load_times_by_model(null_logger)).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb
index 953cfbb8b88..f3cd6961e94 100644
--- a/spec/lib/gitlab/project_authorizations_spec.rb
+++ b/spec/lib/gitlab/project_authorizations_spec.rb
@@ -15,7 +15,7 @@ describe Gitlab::ProjectAuthorizations do
end
before do
- other_project.team << [user, :reporter]
+ other_project.add_reporter(user)
group.add_developer(user)
end
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index a424f0f5cfe..8351b967133 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -1,3 +1,4 @@
+# coding: utf-8
require 'spec_helper'
describe Gitlab::ProjectSearchResults do
@@ -70,15 +71,6 @@ describe Gitlab::ProjectSearchResults do
subject { described_class.parse_search_result(search_result) }
- it 'can correctly parse filenames including ":"' do
- special_char_result = "\nmaster:testdata/project::function1.yaml-1----\nmaster:testdata/project::function1.yaml:2:test: data1\n"
-
- blob = described_class.parse_search_result(special_char_result)
-
- expect(blob.ref).to eq('master')
- expect(blob.filename).to eq('testdata/project::function1.yaml')
- end
-
it "returns a valid FoundBlob" do
is_expected.to be_an Gitlab::SearchResults::FoundBlob
expect(subject.id).to be_nil
@@ -90,8 +82,82 @@ describe Gitlab::ProjectSearchResults do
expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n")
end
+ context 'when the matching filename contains a colon' do
+ let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" }
+
+ it 'returns a valid FoundBlob' do
+ expect(subject.filename).to eq('testdata/project::function1.yaml')
+ expect(subject.basename).to eq('testdata/project::function1')
+ expect(subject.ref).to eq('master')
+ expect(subject.startline).to eq(1)
+ expect(subject.data).to eq("---\n")
+ end
+ end
+
+ context 'when the matching content contains a number surrounded by colons' do
+ let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" }
+
+ it 'returns a valid FoundBlob' do
+ expect(subject.filename).to eq('testdata/foo.txt')
+ expect(subject.basename).to eq('testdata/foo')
+ expect(subject.ref).to eq('master')
+ expect(subject.startline).to eq(1)
+ expect(subject.data).to eq('blah:9:blah')
+ end
+ end
+
+ context 'when the search result ends with an empty line' do
+ let(:results) { project.repository.search_files_by_content('Role models', 'master') }
+
+ it 'returns a valid FoundBlob that ends with an empty line' do
+ expect(subject.filename).to eq('files/markdown/ruby-style-guide.md')
+ expect(subject.basename).to eq('files/markdown/ruby-style-guide')
+ expect(subject.ref).to eq('master')
+ expect(subject.startline).to eq(1)
+ expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n")
+ end
+ end
+
+ context 'when the search returns non-ASCII data' do
+ context 'with UTF-8' do
+ let(:results) { project.repository.search_files_by_content('файл', 'master') }
+
+ it 'returns results as UTF-8' do
+ expect(subject.filename).to eq('encoding/russian.rb')
+ expect(subject.basename).to eq('encoding/russian')
+ expect(subject.ref).to eq('master')
+ expect(subject.startline).to eq(1)
+ expect(subject.data).to eq("Хороший файл\n")
+ end
+ end
+
+ context 'with UTF-8 in the filename' do
+ let(:results) { project.repository.search_files_by_content('webhook', 'master') }
+
+ it 'returns results as UTF-8' do
+ expect(subject.filename).to eq('encoding/テスト.txt')
+ expect(subject.basename).to eq('encoding/テスト')
+ expect(subject.ref).to eq('master')
+ expect(subject.startline).to eq(3)
+ expect(subject.data).to include('WebHookã®ç¢ºèª')
+ end
+ end
+
+ context 'with ISO-8859-1' do
+ let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) }
+
+ it 'returns results as UTF-8' do
+ expect(subject.filename).to eq('encoding/iso8859.txt')
+ expect(subject.basename).to eq('encoding/iso8859')
+ expect(subject.ref).to eq('master')
+ expect(subject.startline).to eq(1)
+ expect(subject.data).to eq("Äü\n\nfoo\n")
+ end
+ end
+ end
+
context "when filename has extension" do
- let(:search_result) { "master:CONTRIBUTE.md:5:- [Contribute to GitLab](#contribute-to-gitlab)\n" }
+ let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" }
it { expect(subject.path).to eq('CONTRIBUTE.md') }
it { expect(subject.filename).to eq('CONTRIBUTE.md') }
@@ -99,7 +165,7 @@ describe Gitlab::ProjectSearchResults do
end
context "when file under directory" do
- let(:search_result) { "master:a/b/c.md:5:a b c\n" }
+ let(:search_result) { "master:a/b/c.md\x005\x00a b c\n" }
it { expect(subject.path).to eq('a/b/c.md') }
it { expect(subject.filename).to eq('a/b/c.md') }
@@ -144,7 +210,7 @@ describe Gitlab::ProjectSearchResults do
end
it 'finds by content' do
- expect(results).to include("master:Title.md:1:Content\n")
+ expect(results).to include("master:Title.md\x001\x00Content\n")
end
end
@@ -175,11 +241,11 @@ describe Gitlab::ProjectSearchResults do
expect(issues).to include issue
expect(issues).not_to include security_issue_1
expect(issues).not_to include security_issue_2
- expect(results.issues_count).to eq 1
+ expect(results.limited_issues_count).to eq 1
end
it 'does not list project confidential issues for project members with guest role' do
- project.team << [member, :guest]
+ project.add_guest(member)
results = described_class.new(member, project, query)
issues = results.objects('issues')
@@ -187,7 +253,7 @@ describe Gitlab::ProjectSearchResults do
expect(issues).to include issue
expect(issues).not_to include security_issue_1
expect(issues).not_to include security_issue_2
- expect(results.issues_count).to eq 1
+ expect(results.limited_issues_count).to eq 1
end
it 'lists project confidential issues for author' do
@@ -197,7 +263,7 @@ describe Gitlab::ProjectSearchResults do
expect(issues).to include issue
expect(issues).to include security_issue_1
expect(issues).not_to include security_issue_2
- expect(results.issues_count).to eq 2
+ expect(results.limited_issues_count).to eq 2
end
it 'lists project confidential issues for assignee' do
@@ -207,11 +273,11 @@ describe Gitlab::ProjectSearchResults do
expect(issues).to include issue
expect(issues).not_to include security_issue_1
expect(issues).to include security_issue_2
- expect(results.issues_count).to eq 2
+ expect(results.limited_issues_count).to eq 2
end
it 'lists project confidential issues for project members' do
- project.team << [member, :developer]
+ project.add_developer(member)
results = described_class.new(member, project, query)
issues = results.objects('issues')
@@ -219,7 +285,7 @@ describe Gitlab::ProjectSearchResults do
expect(issues).to include issue
expect(issues).to include security_issue_1
expect(issues).to include security_issue_2
- expect(results.issues_count).to eq 3
+ expect(results.limited_issues_count).to eq 3
end
it 'lists all project issues for admin' do
@@ -229,7 +295,7 @@ describe Gitlab::ProjectSearchResults do
expect(issues).to include issue
expect(issues).to include security_issue_1
expect(issues).to include security_issue_2
- expect(results.issues_count).to eq 3
+ expect(results.limited_issues_count).to eq 3
end
end
@@ -262,6 +328,35 @@ describe Gitlab::ProjectSearchResults do
end
end
+ describe '#limited_notes_count' do
+ let(:project) { create(:project, :public) }
+ let(:note) { create(:note_on_issue, project: project) }
+ let(:results) { described_class.new(user, project, note.note) }
+
+ context 'when count_limit is lower than total amount' do
+ before do
+ allow(results).to receive(:count_limit).and_return(1)
+ end
+
+ it 'calls note finder once to get the limited amount of notes' do
+ expect(results).to receive(:notes_finder).once.and_call_original
+ expect(results.limited_notes_count).to eq(1)
+ end
+ end
+
+ context 'when count_limit is higher than total amount' do
+ it 'calls note finder multiple times to get the limited amount of notes' do
+ project = create(:project, :public)
+ note = create(:note_on_issue, project: project)
+
+ results = described_class.new(user, project, note.note)
+
+ expect(results).to receive(:notes_finder).exactly(4).times.and_call_original
+ expect(results.limited_notes_count).to eq(1)
+ end
+ end
+ end
+
# Examples for commit access level test
#
# params:
@@ -290,12 +385,12 @@ describe Gitlab::ProjectSearchResults do
let!(:private_project) { create(:project, :private, :repository, creator: creator, namespace: creator.namespace) }
let(:team_master) do
user = create(:user, username: 'private-project-master')
- private_project.team << [user, :master]
+ private_project.add_master(user)
user
end
let(:team_reporter) do
user = create(:user, username: 'private-project-reporter')
- private_project.team << [user, :reporter]
+ private_project.add_reporter(user)
user
end
diff --git a/spec/lib/gitlab/project_transfer_spec.rb b/spec/lib/gitlab/project_transfer_spec.rb
index 10c5fb148cd..0b9b1f537b5 100644
--- a/spec/lib/gitlab/project_transfer_spec.rb
+++ b/spec/lib/gitlab/project_transfer_spec.rb
@@ -21,30 +21,77 @@ describe Gitlab::ProjectTransfer do
describe '#move_project' do
it "moves project upload to another namespace" do
- FileUtils.mkdir_p(File.join(@root_dir, @namespace_path_was, @project_path))
+ path_to_be_moved = File.join(@root_dir, @namespace_path_was, @project_path)
+ expected_path = File.join(@root_dir, @namespace_path, @project_path)
+ FileUtils.mkdir_p(path_to_be_moved)
+
@project_transfer.move_project(@project_path, @namespace_path_was, @namespace_path)
- expected_path = File.join(@root_dir, @namespace_path, @project_path)
expect(Dir.exist?(expected_path)).to be_truthy
end
end
+ describe '#move_namespace' do
+ context 'when moving namespace from root into another namespace' do
+ it "moves namespace projects' upload" do
+ child_namespace = 'test_child_namespace'
+ path_to_be_moved = File.join(@root_dir, child_namespace, @project_path)
+ expected_path = File.join(@root_dir, @namespace_path, child_namespace, @project_path)
+ FileUtils.mkdir_p(path_to_be_moved)
+
+ @project_transfer.move_namespace(child_namespace, nil, @namespace_path)
+
+ expect(Dir.exist?(expected_path)).to be_truthy
+ end
+ end
+
+ context 'when moving namespace from one parent to another' do
+ it "moves namespace projects' upload" do
+ child_namespace = 'test_child_namespace'
+ path_to_be_moved = File.join(@root_dir, @namespace_path_was, child_namespace, @project_path)
+ expected_path = File.join(@root_dir, @namespace_path, child_namespace, @project_path)
+ FileUtils.mkdir_p(path_to_be_moved)
+
+ @project_transfer.move_namespace(child_namespace, @namespace_path_was, @namespace_path)
+
+ expect(Dir.exist?(expected_path)).to be_truthy
+ end
+ end
+
+ context 'when moving namespace from having a parent to root' do
+ it "moves namespace projects' upload" do
+ child_namespace = 'test_child_namespace'
+ path_to_be_moved = File.join(@root_dir, @namespace_path_was, child_namespace, @project_path)
+ expected_path = File.join(@root_dir, child_namespace, @project_path)
+ FileUtils.mkdir_p(path_to_be_moved)
+
+ @project_transfer.move_namespace(child_namespace, @namespace_path_was, nil)
+
+ expect(Dir.exist?(expected_path)).to be_truthy
+ end
+ end
+ end
+
describe '#rename_project' do
it "renames project" do
- FileUtils.mkdir_p(File.join(@root_dir, @namespace_path, @project_path_was))
+ path_to_be_moved = File.join(@root_dir, @namespace_path, @project_path_was)
+ expected_path = File.join(@root_dir, @namespace_path, @project_path)
+ FileUtils.mkdir_p(path_to_be_moved)
+
@project_transfer.rename_project(@project_path_was, @project_path, @namespace_path)
- expected_path = File.join(@root_dir, @namespace_path, @project_path)
expect(Dir.exist?(expected_path)).to be_truthy
end
end
describe '#rename_namespace' do
it "renames namespace" do
- FileUtils.mkdir_p(File.join(@root_dir, @namespace_path_was, @project_path))
+ path_to_be_moved = File.join(@root_dir, @namespace_path_was, @project_path)
+ expected_path = File.join(@root_dir, @namespace_path, @project_path)
+ FileUtils.mkdir_p(path_to_be_moved)
+
@project_transfer.rename_namespace(@namespace_path_was, @namespace_path)
- expected_path = File.join(@root_dir, @namespace_path, @project_path)
expect(Dir.exist?(expected_path)).to be_truthy
end
end
diff --git a/spec/lib/gitlab/prometheus/queries/matched_metric_query_spec.rb b/spec/lib/gitlab/prometheus/queries/matched_metric_query_spec.rb
new file mode 100644
index 00000000000..420218a695a
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/matched_metric_query_spec.rb
@@ -0,0 +1,134 @@
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::MatchedMetricQuery 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(:common_metrics).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(:common_metrics).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(:common_metrics).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/queries/matched_metrics_query_spec.rb b/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb
deleted file mode 100644
index 2b488101496..00000000000
--- a/spec/lib/gitlab/prometheus/queries/matched_metrics_query_spec.rb
+++ /dev/null
@@ -1,134 +0,0 @@
-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
index de625324092..4c3b8deefb9 100644
--- a/spec/lib/gitlab/prometheus_client_spec.rb
+++ b/spec/lib/gitlab/prometheus_client_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Gitlab::PrometheusClient do
include PrometheusHelpers
- subject { described_class.new(api_url: 'https://prometheus.example.com') }
+ subject { described_class.new(RestClient::Resource.new('https://prometheus.example.com')) }
describe '#ping' do
it 'issues a "query" request to the API endpoint' do
@@ -19,31 +19,41 @@ describe Gitlab::PrometheusClient do
# - 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
+ it 'raises a Gitlab::PrometheusClient::Error error' do
req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'bar!' })
expect { execute_query }
- .to raise_error(Gitlab::PrometheusError, 'bar!')
+ .to raise_error(Gitlab::PrometheusClient::Error, '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
+ it 'raises a Gitlab::PrometheusClient::Error error' do
req_stub = stub_prometheus_request(query_url, status: 400)
expect { execute_query }
- .to raise_error(Gitlab::PrometheusError, 'Bad data received')
+ .to raise_error(Gitlab::PrometheusClient::Error, '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
+ it 'raises a Gitlab::PrometheusClient::Error 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!"}')
+ .to raise_error(Gitlab::PrometheusClient::Error, '500 - {"message":"FAIL!"}')
+ expect(req_stub).to have_been_requested
+ end
+ end
+
+ context 'when request returns non json data' do
+ it 'raises a Gitlab::PrometheusClient::Error error' do
+ req_stub = stub_prometheus_request(query_url, status: 200, body: 'not json')
+
+ expect { execute_query }
+ .to raise_error(Gitlab::PrometheusClient::Error, 'Parsing response failed')
expect(req_stub).to have_been_requested
end
end
@@ -52,28 +62,30 @@ describe Gitlab::PrometheusClient do
describe 'failure to reach a provided prometheus url' do
let(:prometheus_url) {"https://prometheus.invalid.example.com"}
+ subject { described_class.new(RestClient::Resource.new(prometheus_url)) }
+
context 'exceptions are raised' do
- it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do
+ it 'raises a Gitlab::PrometheusClient::Error 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 { subject.send(:get, '/', {}) }
+ .to raise_error(Gitlab::PrometheusClient::Error, "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
+ it 'raises a Gitlab::PrometheusClient::Error 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 { subject.send(:get, '/', {}) }
+ .to raise_error(Gitlab::PrometheusClient::Error, "#{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)
+ it 'raises a Gitlab::PrometheusClient::Error error when a RestClient::Exception is rescued' do
+ req_stub = stub_prometheus_request_with_exception(prometheus_url, RestClient::Exception)
- expect { subject.send(:get, prometheus_url) }
- .to raise_error(Gitlab::PrometheusError, "Network connection error")
+ expect { subject.send(:get, '/', {}) }
+ .to raise_error(Gitlab::PrometheusClient::Error, "Network connection error")
expect(req_stub).to have_been_requested
end
end
diff --git a/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb b/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb
new file mode 100644
index 00000000000..f8faeffb935
--- /dev/null
+++ b/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+
+describe Gitlab::QueryLimiting::ActiveSupportSubscriber do
+ let(:transaction) { instance_double(Gitlab::QueryLimiting::Transaction, increment: true) }
+
+ before do
+ allow(Gitlab::QueryLimiting::Transaction)
+ .to receive(:current)
+ .and_return(transaction)
+ end
+
+ describe '#sql' do
+ it 'increments the number of executed SQL queries' do
+ User.count
+
+ expect(transaction)
+ .to have_received(:increment)
+ .once
+ end
+
+ context 'when the query is actually a rails cache hit' do
+ it 'does not increment the number of executed SQL queries' do
+ ActiveRecord::Base.connection.cache do
+ User.count
+ User.count
+ end
+
+ expect(transaction)
+ .to have_received(:increment)
+ .once
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/query_limiting/middleware_spec.rb b/spec/lib/gitlab/query_limiting/middleware_spec.rb
new file mode 100644
index 00000000000..a04bcdecb4b
--- /dev/null
+++ b/spec/lib/gitlab/query_limiting/middleware_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe Gitlab::QueryLimiting::Middleware do
+ describe '#call' do
+ it 'runs the application with query limiting in place' do
+ middleware = described_class.new(-> (env) { env })
+
+ expect_any_instance_of(Gitlab::QueryLimiting::Transaction)
+ .to receive(:act_upon_results)
+
+ expect(middleware.call({ number: 10 }))
+ .to eq({ number: 10 })
+ end
+ end
+
+ describe '#action_name' do
+ let(:middleware) { described_class.new(-> (env) { env }) }
+
+ context 'using a Rails request' do
+ it 'returns the name of the controller and action' do
+ env = {
+ described_class::CONTROLLER_KEY => double(
+ :controller,
+ action_name: 'show',
+ class: double(:class, name: 'UsersController'),
+ content_type: 'text/html'
+ )
+ }
+
+ expect(middleware.action_name(env)).to eq('UsersController#show')
+ end
+
+ it 'includes the content type if this is not text/html' do
+ env = {
+ described_class::CONTROLLER_KEY => double(
+ :controller,
+ action_name: 'show',
+ class: double(:class, name: 'UsersController'),
+ content_type: 'application/json'
+ )
+ }
+
+ expect(middleware.action_name(env))
+ .to eq('UsersController#show (application/json)')
+ end
+ end
+
+ context 'using a Grape API request' do
+ it 'returns the name of the request method and endpoint path' do
+ env = {
+ described_class::ENDPOINT_KEY => double(
+ :endpoint,
+ route: double(:route, request_method: 'GET', path: '/foo')
+ )
+ }
+
+ expect(middleware.action_name(env)).to eq('GET /foo')
+ end
+
+ it 'returns nil if the route can not be retrieved' do
+ endpoint = double(:endpoint)
+ env = { described_class::ENDPOINT_KEY => endpoint }
+
+ allow(endpoint)
+ .to receive(:route)
+ .and_raise(RuntimeError)
+
+ expect(middleware.action_name(env)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/query_limiting/transaction_spec.rb b/spec/lib/gitlab/query_limiting/transaction_spec.rb
new file mode 100644
index 00000000000..b72b8574174
--- /dev/null
+++ b/spec/lib/gitlab/query_limiting/transaction_spec.rb
@@ -0,0 +1,132 @@
+require 'spec_helper'
+
+describe Gitlab::QueryLimiting::Transaction do
+ after do
+ Thread.current[described_class::THREAD_KEY] = nil
+ end
+
+ describe '.current' do
+ it 'returns nil when there is no transaction' do
+ expect(described_class.current).to be_nil
+ end
+
+ it 'returns the transaction when present' do
+ Thread.current[described_class::THREAD_KEY] = described_class.new
+
+ expect(described_class.current).to be_an_instance_of(described_class)
+ end
+ end
+
+ describe '.run' do
+ it 'runs a transaction and returns it and its return value' do
+ trans, ret = described_class.run do
+ 10
+ end
+
+ expect(trans).to be_an_instance_of(described_class)
+ expect(ret).to eq(10)
+ end
+
+ it 'removes the transaction from the current thread upon completion' do
+ described_class.run do
+ 10
+ end
+
+ expect(Thread.current[described_class::THREAD_KEY]).to be_nil
+ end
+ end
+
+ describe '#act_upon_results' do
+ context 'when the query threshold is not exceeded' do
+ it 'does nothing' do
+ trans = described_class.new
+
+ expect(trans).not_to receive(:raise)
+
+ trans.act_upon_results
+ end
+ end
+
+ context 'when the query threshold is exceeded' do
+ let(:transaction) do
+ trans = described_class.new
+ trans.count = described_class::THRESHOLD + 1
+
+ trans
+ end
+
+ it 'raises an error when this is enabled' do
+ expect { transaction.act_upon_results }
+ .to raise_error(described_class::ThresholdExceededError)
+ end
+ end
+ end
+
+ describe '#increment' do
+ it 'increments the number of executed queries' do
+ transaction = described_class.new
+
+ expect(transaction.count).to be_zero
+
+ transaction.increment
+
+ expect(transaction.count).to eq(1)
+ end
+ end
+
+ describe '#raise_error?' do
+ it 'returns true in a test environment' do
+ transaction = described_class.new
+
+ expect(transaction.raise_error?).to eq(true)
+ end
+
+ it 'returns false in a production environment' do
+ transaction = described_class.new
+
+ expect(Rails.env)
+ .to receive(:test?)
+ .and_return(false)
+
+ expect(transaction.raise_error?).to eq(false)
+ end
+ end
+
+ describe '#threshold_exceeded?' do
+ it 'returns false when the threshold is not exceeded' do
+ transaction = described_class.new
+
+ expect(transaction.threshold_exceeded?).to eq(false)
+ end
+
+ it 'returns true when the threshold is exceeded' do
+ transaction = described_class.new
+ transaction.count = described_class::THRESHOLD + 1
+
+ expect(transaction.threshold_exceeded?).to eq(true)
+ end
+ end
+
+ describe '#error_message' do
+ it 'returns the error message to display when the threshold is exceeded' do
+ transaction = described_class.new
+ transaction.count = max = described_class::THRESHOLD
+
+ expect(transaction.error_message).to eq(
+ "Too many SQL queries were executed: a maximum of #{max} " \
+ "is allowed but #{max} SQL queries were executed"
+ )
+ end
+
+ it 'includes the action name in the error message when present' do
+ transaction = described_class.new
+ transaction.count = max = described_class::THRESHOLD
+ transaction.action = 'UsersController#show'
+
+ expect(transaction.error_message).to eq(
+ "Too many SQL queries were executed in UsersController#show: " \
+ "a maximum of #{max} is allowed but #{max} SQL queries were executed"
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/query_limiting_spec.rb b/spec/lib/gitlab/query_limiting_spec.rb
new file mode 100644
index 00000000000..42877b1e2dd
--- /dev/null
+++ b/spec/lib/gitlab/query_limiting_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe Gitlab::QueryLimiting do
+ describe '.enable?' do
+ it 'returns true in a test environment' do
+ expect(described_class.enable?).to eq(true)
+ end
+
+ it 'returns true in a development environment' do
+ allow(Rails.env).to receive(:development?).and_return(true)
+
+ expect(described_class.enable?).to eq(true)
+ end
+
+ it 'returns false on GitLab.com' do
+ expect(Rails.env).to receive(:development?).and_return(false)
+ expect(Rails.env).to receive(:test?).and_return(false)
+ allow(Gitlab).to receive(:com?).and_return(true)
+
+ expect(described_class.enable?).to eq(false)
+ end
+
+ it 'returns false in a non GitLab.com' do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ expect(Rails.env).to receive(:development?).and_return(false)
+ expect(Rails.env).to receive(:test?).and_return(false)
+
+ expect(described_class.enable?).to eq(false)
+ end
+ end
+
+ describe '.whitelist' do
+ it 'raises ArgumentError when an invalid issue URL is given' do
+ expect { described_class.whitelist('foo') }
+ .to raise_error(ArgumentError)
+ end
+
+ context 'without a transaction' do
+ it 'does nothing' do
+ expect { described_class.whitelist('https://example.com') }
+ .not_to raise_error
+ end
+ end
+
+ context 'with a transaction' do
+ let(:transaction) { Gitlab::QueryLimiting::Transaction.new }
+
+ before do
+ allow(Gitlab::QueryLimiting::Transaction)
+ .to receive(:current)
+ .and_return(transaction)
+ end
+
+ it 'does not increment the number of SQL queries executed in the block' do
+ before = transaction.count
+
+ described_class.whitelist('https://example.com')
+
+ 2.times do
+ User.count
+ end
+
+ expect(transaction.count).to eq(before)
+ end
+ 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
index f44a562dc63..b03c1e23ca3 100644
--- a/spec/lib/gitlab/quick_actions/command_definition_spec.rb
+++ b/spec/lib/gitlab/quick_actions/command_definition_spec.rb
@@ -40,7 +40,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end
describe "#available?" do
- let(:opts) { { go: false } }
+ let(:opts) { OpenStruct.new(go: false) }
context "when the command has a condition block" do
before do
@@ -78,7 +78,7 @@ describe Gitlab::QuickActions::CommandDefinition do
it "doesn't execute the command" do
expect(context).not_to receive(:instance_exec)
- subject.execute(context, {}, nil)
+ subject.execute(context, nil)
expect(context.run).to be false
end
@@ -95,7 +95,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end
it "doesn't execute the command" do
- subject.execute(context, {}, nil)
+ subject.execute(context, nil)
expect(context.run).to be false
end
@@ -109,7 +109,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is provided an argument" do
it "executes the command" do
- subject.execute(context, {}, true)
+ subject.execute(context, true)
expect(context.run).to be true
end
@@ -117,7 +117,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is not provided an argument" do
it "executes the command" do
- subject.execute(context, {}, nil)
+ subject.execute(context, nil)
expect(context.run).to be true
end
@@ -131,7 +131,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is provided an argument" do
it "executes the command" do
- subject.execute(context, {}, true)
+ subject.execute(context, true)
expect(context.run).to be true
end
@@ -139,7 +139,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is not provided an argument" do
it "doesn't execute the command" do
- subject.execute(context, {}, nil)
+ subject.execute(context, nil)
expect(context.run).to be false
end
@@ -153,7 +153,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is provided an argument" do
it "executes the command" do
- subject.execute(context, {}, true)
+ subject.execute(context, true)
expect(context.run).to be true
end
@@ -161,7 +161,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is not provided an argument" do
it "executes the command" do
- subject.execute(context, {}, nil)
+ subject.execute(context, nil)
expect(context.run).to be true
end
@@ -175,7 +175,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end
it 'executes the command passing the parsed param' do
- subject.execute(context, {}, 'something ')
+ subject.execute(context, 'something ')
expect(context.received_arg).to eq('something')
end
@@ -192,7 +192,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end
it 'returns nil' do
- result = subject.explain({}, {}, nil)
+ result = subject.explain({}, nil)
expect(result).to be_nil
end
@@ -204,7 +204,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end
it 'returns this static string' do
- result = subject.explain({}, {}, nil)
+ result = subject.explain({}, nil)
expect(result).to eq 'Explanation'
end
@@ -216,7 +216,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end
it 'invokes the proc' do
- result = subject.explain({}, {}, 'explanation')
+ result = subject.explain({}, 'explanation')
expect(result).to eq 'Dynamic explanation'
end
diff --git a/spec/lib/gitlab/quick_actions/dsl_spec.rb b/spec/lib/gitlab/quick_actions/dsl_spec.rb
index ff59dc48bcb..067a30fd7e2 100644
--- a/spec/lib/gitlab/quick_actions/dsl_spec.rb
+++ b/spec/lib/gitlab/quick_actions/dsl_spec.rb
@@ -76,7 +76,7 @@ describe Gitlab::QuickActions::Dsl do
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.to_h(OpenStruct.new(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
diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb
index 8ec3f55e6de..4139d1c650c 100644
--- a/spec/lib/gitlab/reference_extractor_spec.rb
+++ b/spec/lib/gitlab/reference_extractor_spec.rb
@@ -4,7 +4,7 @@ describe Gitlab::ReferenceExtractor do
let(:project) { create(:project) }
before do
- project.team << [project.creator, :developer]
+ project.add_developer(project.creator)
end
subject { described_class.new(project, project.creator) }
@@ -14,8 +14,8 @@ describe Gitlab::ReferenceExtractor do
@u_bar = create(:user, username: 'bar')
@u_offteam = create(:user, username: 'offteam')
- project.team << [@u_foo, :reporter]
- project.team << [@u_bar, :guest]
+ project.add_guest(@u_foo)
+ project.add_guest(@u_bar)
subject.analyze('@foo, @baduser, @bar, and @offteam')
expect(subject.users).to match_array([@u_foo, @u_bar, @u_offteam])
@@ -26,8 +26,8 @@ describe Gitlab::ReferenceExtractor do
@u_bar = create(:user, username: 'bar')
@u_offteam = create(:user, username: 'offteam')
- project.team << [@u_foo, :reporter]
- project.team << [@u_bar, :guest]
+ project.add_reporter(@u_foo)
+ project.add_reporter(@u_bar)
subject.analyze(%Q{
Inline code: `@foo`
@@ -228,7 +228,7 @@ describe Gitlab::ReferenceExtractor do
let(:issue) { create(:issue, project: other_project) }
before do
- other_project.team << [project.creator, :developer]
+ other_project.add_developer(project.creator)
end
it 'handles project issue references' do
@@ -246,7 +246,7 @@ describe Gitlab::ReferenceExtractor do
let(:text) { "Ref. #{issue.to_reference} and #{label.to_reference}" }
before do
- project.team << [project.creator, :developer]
+ project.add_developer(project.creator)
subject.analyze(text)
end
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index 68a57826647..a1079e54975 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -14,16 +14,22 @@ describe Gitlab::Regex do
it { is_expected.not_to match('?gitlab') }
end
- describe '.environment_slug_regex' do
+ describe '.environment_name_regex' do
subject { described_class.environment_name_regex }
it { is_expected.to match('foo') }
+ it { is_expected.to match('a') }
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-^') }
+ it { is_expected.not_to match('!!()()') }
+ it { is_expected.not_to match('/foo') }
+ it { is_expected.not_to match('foo/') }
+ it { is_expected.not_to match('/foo/') }
+ it { is_expected.not_to match('/') }
end
describe '.environment_slug_regex' do
diff --git a/spec/lib/gitlab/repo_path_spec.rb b/spec/lib/gitlab/repo_path_spec.rb
index 1a925a15e0c..f030f371372 100644
--- a/spec/lib/gitlab/repo_path_spec.rb
+++ b/spec/lib/gitlab/repo_path_spec.rb
@@ -6,11 +6,11 @@ describe ::Gitlab::RepoPath do
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])
+ expect(described_class.parse(project.repository.full_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])
+ expect(described_class.parse(project.wiki.repository.full_path)).to eq([project, true, nil])
end
end
@@ -48,8 +48,8 @@ describe ::Gitlab::RepoPath do
describe '.strip_storage_path' do
before do
allow(Gitlab.config.repositories).to receive(:storages).and_return({
- 'storage1' => { 'path' => '/foo' },
- 'storage2' => { 'path' => '/bar' }
+ 'storage1' => Gitlab::GitalyClient::StorageSettings.new('path' => '/foo'),
+ 'storage2' => Gitlab::GitalyClient::StorageSettings.new('path' => '/bar')
})
end
diff --git a/spec/lib/gitlab/repository_cache_adapter_spec.rb b/spec/lib/gitlab/repository_cache_adapter_spec.rb
new file mode 100644
index 00000000000..85971f2a7ef
--- /dev/null
+++ b/spec/lib/gitlab/repository_cache_adapter_spec.rb
@@ -0,0 +1,76 @@
+require 'spec_helper'
+
+describe Gitlab::RepositoryCacheAdapter do
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
+ let(:cache) { repository.send(:cache) }
+
+ describe '#cache_method_output', :use_clean_rails_memory_store_caching do
+ let(:fallback) { 10 }
+
+ context 'with a non-existing repository' do
+ let(:project) { create(:project) } # No repository
+
+ subject do
+ repository.cache_method_output(:cats, fallback: fallback) do
+ repository.cats_call_stub
+ end
+ end
+
+ it 'returns the fallback value' do
+ expect(subject).to eq(fallback)
+ end
+
+ it 'avoids calling the original method' do
+ expect(repository).not_to receive(:cats_call_stub)
+
+ subject
+ end
+ end
+
+ context 'with a method throwing a non-existing-repository error' do
+ subject do
+ repository.cache_method_output(:cats, fallback: fallback) do
+ raise Gitlab::Git::Repository::NoRepository
+ end
+ end
+
+ it 'returns the fallback value' do
+ expect(subject).to eq(fallback)
+ end
+
+ it 'does not cache the data' do
+ subject
+
+ expect(repository.instance_variable_defined?(:@cats)).to eq(false)
+ expect(cache.exist?(:cats)).to eq(false)
+ end
+ end
+
+ context 'with an existing repository' do
+ it 'caches the output' do
+ object = double
+
+ expect(object).to receive(:number).once.and_return(10)
+
+ 2.times do
+ val = repository.cache_method_output(:cats) { object.number }
+
+ expect(val).to eq(10)
+ end
+
+ expect(repository.send(:cache).exist?(:cats)).to eq(true)
+ expect(repository.instance_variable_get(:@cats)).to eq(10)
+ end
+ end
+ end
+
+ describe '#expire_method_caches' do
+ it 'expires the caches of the given methods' do
+ expect(cache).to receive(:expire).with(:readme)
+ expect(cache).to receive(:expire).with(:gitignore)
+
+ repository.expire_method_caches(%i(readme gitignore))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/repository_cache_spec.rb b/spec/lib/gitlab/repository_cache_spec.rb
new file mode 100644
index 00000000000..fc259cf1208
--- /dev/null
+++ b/spec/lib/gitlab/repository_cache_spec.rb
@@ -0,0 +1,50 @@
+require 'spec_helper'
+
+describe Gitlab::RepositoryCache do
+ let(:backend) { double('backend').as_null_object }
+ let(:project) { create(:project) }
+ let(:repository) { project.repository }
+ let(:namespace) { "#{repository.full_path}:#{project.id}" }
+ let(:cache) { described_class.new(repository, backend: backend) }
+
+ describe '#cache_key' do
+ subject { cache.cache_key(:foo) }
+
+ it 'includes the namespace' do
+ expect(subject).to eq "foo:#{namespace}"
+ end
+
+ context 'with a given namespace' do
+ let(:extra_namespace) { 'my:data' }
+ let(:cache) do
+ described_class.new(repository, extra_namespace: extra_namespace,
+ backend: backend)
+ end
+
+ it 'includes the full namespace' do
+ expect(subject).to eq "foo:#{namespace}:#{extra_namespace}"
+ end
+ end
+ end
+
+ describe '#expire' do
+ it 'expires the given key from the cache' do
+ cache.expire(:foo)
+ expect(backend).to have_received(:delete).with("foo:#{namespace}")
+ end
+ end
+
+ describe '#fetch' do
+ it 'fetches the given key from the cache' do
+ cache.fetch(:bar)
+ expect(backend).to have_received(:fetch).with("bar:#{namespace}")
+ end
+
+ it 'accepts a block' do
+ p = -> {}
+
+ cache.fetch(:baz, &p)
+ expect(backend).to have_received(:fetch).with("baz:#{namespace}", &p)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/saml/auth_hash_spec.rb b/spec/lib/gitlab/saml/auth_hash_spec.rb
deleted file mode 100644
index a555935aea3..00000000000
--- a/spec/lib/gitlab/saml/auth_hash_spec.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Saml::AuthHash do
- include LoginHelpers
-
- let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers) } }
- subject(:saml_auth_hash) { described_class.new(omniauth_auth_hash) }
-
- let(:info_hash) do
- {
- name: 'John',
- email: 'john@mail.com'
- }
- end
-
- let(:omniauth_auth_hash) do
- OmniAuth::AuthHash.new(uid: 'my-uid',
- provider: 'saml',
- info: info_hash,
- extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) } )
- end
-
- before do
- stub_saml_group_config(%w(Developers Freelancers Designers))
- end
-
- describe '#groups' do
- it 'returns array of groups' do
- expect(saml_auth_hash.groups).to eq(%w(Developers Freelancers))
- end
-
- context 'raw info hash attributes empty' do
- let(:raw_info_attr) { {} }
-
- it 'returns an empty array' do
- expect(saml_auth_hash.groups).to be_a(Array)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/saml/user_spec.rb b/spec/lib/gitlab/saml/user_spec.rb
deleted file mode 100644
index 1765980e977..00000000000
--- a/spec/lib/gitlab/saml/user_spec.rb
+++ /dev/null
@@ -1,403 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::Saml::User do
- include LdapHelpers
- include LoginHelpers
-
- let(:saml_user) { described_class.new(auth_hash) }
- let(:gl_user) { saml_user.gl_user }
- let(:uid) { 'my-uid' }
- let(:dn) { 'uid=user1,ou=people,dc=example' }
- let(:provider) { 'saml' }
- let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers Designers) } }
- let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash, extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) }) }
- let(:info_hash) do
- {
- name: 'John',
- email: 'john@mail.com'
- }
- end
- let(:ldap_user) { Gitlab::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
-
- describe '#save' do
- before do
- stub_basic_saml_config
- end
-
- describe 'account exists on server' do
- 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
- expect(gl_user).to be_valid
- expect(gl_user).to eq existing_user
- identity = gl_user.identities.first
- expect(identity.extern_uid).to eql uid
- expect(identity.provider).to eql 'saml'
- end
- end
-
- context 'external groups' do
- context 'are defined' do
- it 'marks the user as external' do
- stub_saml_group_config(%w(Freelancers))
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_truthy
- end
- end
-
- 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
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_falsey
- end
- end
-
- context 'user was external, now should not be' do
- it 'makes user internal' do
- existing_user.update_attribute('external', true)
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_falsey
- end
- end
- end
- end
-
- 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 do
- stub_omniauth_config(allow_single_sign_on: ['saml'])
- end
-
- it 'creates a user from SAML' do
- saml_user.save
-
- expect(gl_user).to be_valid
- identity = gl_user.identities.first
- expect(identity.extern_uid).to eql uid
- expect(identity.provider).to eql 'saml'
- end
- end
-
- context 'with allow_single_sign_on default (["saml"])' do
- 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
- end
- end
-
- context 'with allow_single_sign_on disabled' do
- before do
- stub_omniauth_config(allow_single_sign_on: false)
- end
-
- it 'throws an error' do
- expect { saml_user.save }.to raise_error StandardError
- end
- end
- end
-
- context 'external groups' do
- context 'are defined' do
- it 'marks the user as external' do
- stub_saml_group_config(%w(Freelancers))
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_truthy
- end
- end
-
- context 'are defined but the user does not belong there' do
- it 'does not mark the user as external' do
- stub_saml_group_config(%w(Interns))
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user.external).to be_falsey
- end
- end
- end
-
- context 'with auto_link_ldap_user disabled (default)' do
- 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 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 do
- stub_ldap_config(providers: %w(ldapmain))
- end
-
- context 'and a corresponding LDAP person' do
- let(:adapter) { ldap_adapter('ldapmain') }
-
- before do
- allow(ldap_user).to receive(:uid) { uid }
- allow(ldap_user).to receive(:username) { uid }
- allow(ldap_user).to receive(:email) { %w(john@mail.com john2@example.com) }
- allow(ldap_user).to receive(:dn) { dn }
- allow(Gitlab::LDAP::Adapter).to receive(:new).and_return(adapter)
- allow(Gitlab::LDAP::Person).to receive(:find_by_uid).with(uid, adapter).and_return(ldap_user)
- allow(Gitlab::LDAP::Person).to receive(:find_by_dn).with(dn, adapter).and_return(ldap_user)
- allow(Gitlab::LDAP::Person).to receive(:find_by_email).with('john@mail.com', adapter).and_return(ldap_user)
- end
-
- context 'and no account for the LDAP user' do
- it 'creates a user with dual LDAP and SAML identities' do
- saml_user.save
-
- expect(gl_user).to be_valid
- expect(gl_user.username).to eql uid
- expect(gl_user.email).to eql 'john@mail.com'
- expect(gl_user.identities.length).to be 2
- identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: dn },
- { provider: 'saml', extern_uid: uid }])
- end
- end
-
- context 'and LDAP user has an account already' do
- let(:auth_hash_base_attributes) do
- {
- uid: uid,
- provider: provider,
- info: info_hash,
- extra: {
- raw_info: OneLogin::RubySaml::Attributes.new(
- { 'groups' => %w(Developers Freelancers Designers) }
- )
- }
- }
- end
- let(:auth_hash) { OmniAuth::AuthHash.new(auth_hash_base_attributes) }
- let(:uid_types) { %w(uid dn email) }
-
- before do
- create(:omniauth_user,
- email: 'john@mail.com',
- extern_uid: dn,
- provider: 'ldapmain',
- username: 'john')
- end
-
- shared_examples 'find LDAP person' do |uid_type, uid|
- let(:auth_hash) { OmniAuth::AuthHash.new(auth_hash_base_attributes.merge(uid: extern_uid)) }
-
- before do
- nil_types = uid_types - [uid_type]
-
- nil_types.each do |type|
- allow(Gitlab::LDAP::Person).to receive(:"find_by_#{type}").and_return(nil)
- end
-
- allow(Gitlab::LDAP::Person).to receive(:"find_by_#{uid_type}").and_return(ldap_user)
- end
-
- it 'adds the omniauth identity to the LDAP account' do
- identities = [
- { provider: 'ldapmain', extern_uid: dn },
- { provider: 'saml', extern_uid: extern_uid }
- ]
-
- identities_as_hash = gl_user.identities.map do |id|
- { provider: id.provider, extern_uid: id.extern_uid }
- end
-
- saml_user.save
-
- expect(gl_user).to be_valid
- expect(gl_user.username).to eql 'john'
- expect(gl_user.email).to eql 'john@mail.com'
- expect(gl_user.identities.length).to be 2
- expect(identities_as_hash).to match_array(identities)
- end
- end
-
- context 'when uid is an uid' do
- it_behaves_like 'find LDAP person', 'uid' do
- let(:extern_uid) { uid }
- end
- end
-
- context 'when uid is a dn' do
- it_behaves_like 'find LDAP person', 'dn' do
- let(:extern_uid) { dn }
- end
- end
-
- context 'when uid is an email' do
- it_behaves_like 'find LDAP person', 'email' do
- let(:extern_uid) { 'john@mail.com' }
- end
- end
-
- it 'adds the omniauth identity to the LDAP account' do
- saml_user.save
-
- expect(gl_user).to be_valid
- expect(gl_user.username).to eql 'john'
- expect(gl_user.email).to eql 'john@mail.com'
- expect(gl_user.identities.length).to be 2
- identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: dn },
- { provider: 'saml', extern_uid: uid }])
- end
-
- it 'saves successfully on subsequent tries, when both identities are present' do
- saml_user.save
- local_saml_user = described_class.new(auth_hash)
- local_saml_user.save
-
- expect(local_saml_user.gl_user).to be_valid
- expect(local_saml_user.gl_user).to be_persisted
- end
- end
-
- context 'user has SAML user, and wants to add their LDAP identity' do
- it 'adds the LDAP identity to the existing SAML user' do
- create(:omniauth_user, email: 'john@mail.com', extern_uid: dn, provider: 'saml', username: 'john')
-
- allow(Gitlab::LDAP::Person).to receive(:find_by_uid).with(dn, adapter).and_return(ldap_user)
-
- local_hash = OmniAuth::AuthHash.new(uid: dn, provider: provider, info: info_hash)
- local_saml_user = described_class.new(local_hash)
-
- local_saml_user.save
- local_gl_user = local_saml_user.gl_user
-
- expect(local_gl_user).to be_valid
- expect(local_gl_user.identities.length).to be 2
- identities_as_hash = local_gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } }
- expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: dn },
- { provider: 'saml', extern_uid: dn }])
- end
- end
- end
- end
- end
-
- context 'when signup is disabled' do
- before do
- stub_application_setting signup_enabled: false
- end
-
- it 'creates the user' do
- saml_user.save
-
- expect(gl_user).to be_persisted
- end
- end
-
- context 'when user confirmation email is enabled' do
- before do
- stub_application_setting send_user_confirmation_email: true
- end
-
- it 'creates and confirms the user anyway' do
- saml_user.save
-
- expect(gl_user).to be_persisted
- expect(gl_user).to be_confirmed
- end
- end
- end
-
- describe 'blocking' do
- 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 do
- stub_omniauth_config(block_auto_created_users: false)
- end
-
- it 'does not block the user' do
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create' do
- before do
- stub_omniauth_config(block_auto_created_users: true)
- end
-
- it 'blocks user' do
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user).to be_blocked
- end
- end
- end
-
- context 'sign-in' do
- before do
- saml_user.save
- saml_user.gl_user.activate
- end
-
- context 'dont block on create' do
- before do
- stub_omniauth_config(block_auto_created_users: false)
- end
-
- it do
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
-
- context 'block on create' do
- before do
- stub_omniauth_config(block_auto_created_users: true)
- end
-
- it do
- saml_user.save
- expect(gl_user).to be_valid
- expect(gl_user).not_to be_blocked
- end
- end
- end
- end
- end
-
- describe '#find_user' do
- context 'raw info hash attributes empty' do
- let(:raw_info_attr) { {} }
-
- it 'does not mark user as external' do
- stub_saml_group_config(%w(Freelancers))
-
- expect(saml_user.find_user.external).to be_falsy
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb
index a958baab44f..87288baedb0 100644
--- a/spec/lib/gitlab/search_results_spec.rb
+++ b/spec/lib/gitlab/search_results_spec.rb
@@ -16,30 +16,68 @@ describe Gitlab::SearchResults do
context 'as a user with access' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
- describe '#projects_count' do
- it 'returns the total amount of projects' do
- expect(results.projects_count).to eq(1)
+ describe '#objects' do
+ it 'returns without_counts collection by default' do
+ expect(results.objects('projects')).to be_kind_of(Kaminari::PaginatableWithoutCount)
end
- end
- describe '#issues_count' do
- it 'returns the total amount of issues' do
- expect(results.issues_count).to eq(1)
+ it 'returns with counts collection when requested' do
+ expect(results.objects('projects', 1, false)).not_to be_kind_of(Kaminari::PaginatableWithoutCount)
end
end
- describe '#merge_requests_count' do
- it 'returns the total amount of merge requests' do
- expect(results.merge_requests_count).to eq(1)
+ context "when count_limit is lower than total amount" do
+ before do
+ allow(results).to receive(:count_limit).and_return(1)
+ end
+
+ describe '#limited_projects_count' do
+ it 'returns the limited amount of projects' do
+ create(:project, name: 'foo2')
+
+ expect(results.limited_projects_count).to eq(1)
+ end
+ end
+
+ describe '#limited_merge_requests_count' do
+ it 'returns the limited amount of merge requests' do
+ create(:merge_request, :simple, source_project: project, title: 'foo2')
+
+ expect(results.limited_merge_requests_count).to eq(1)
+ end
+ end
+
+ describe '#limited_milestones_count' do
+ it 'returns the limited amount of milestones' do
+ create(:milestone, project: project, title: 'foo2')
+
+ expect(results.limited_milestones_count).to eq(1)
+ end
+ end
+
+ describe '#limited_issues_count' do
+ it 'runs single SQL query to get the limited amount of issues' do
+ create(:milestone, project: project, title: 'foo2')
+
+ expect(results).to receive(:issues).with(public_only: true).and_call_original
+ expect(results).not_to receive(:issues).with(no_args).and_call_original
+
+ expect(results.limited_issues_count).to eq(1)
+ end
end
end
- describe '#milestones_count' do
- it 'returns the total amount of milestones' do
- expect(results.milestones_count).to eq(1)
+ context "when count_limit is higher than total amount" do
+ describe '#limited_issues_count' do
+ it 'runs multiple queries to get the limited amount of issues' do
+ expect(results).to receive(:issues).with(public_only: true).and_call_original
+ expect(results).to receive(:issues).with(no_args).and_call_original
+
+ expect(results.limited_issues_count).to eq(1)
+ end
end
end
@@ -52,15 +90,36 @@ describe Gitlab::SearchResults do
expect(results.objects('merge_requests')).to include merge_request_2
end
- it 'includes project filter by default' do
- expect(results).to receive(:project_ids_relation).and_call_original
- results.objects('merge_requests')
+ describe '#merge_requests' do
+ it 'includes project filter by default' do
+ expect(results).to receive(:project_ids_relation).and_call_original
+
+ results.objects('merge_requests')
+ end
+
+ it 'it skips project filter if default project context is used' do
+ allow(results).to receive(:default_project_filter).and_return(true)
+
+ expect(results).not_to receive(:project_ids_relation)
+
+ results.objects('merge_requests')
+ end
end
- it 'it skips project filter if default is used' do
- allow(results).to receive(:default_project_filter).and_return(true)
- expect(results).not_to receive(:project_ids_relation)
- results.objects('merge_requests')
+ describe '#issues' do
+ it 'includes project filter by default' do
+ expect(results).to receive(:project_ids_relation).and_call_original
+
+ results.objects('issues')
+ end
+
+ it 'it skips project filter if default project context is used' do
+ allow(results).to receive(:default_project_filter).and_return(true)
+
+ expect(results).not_to receive(:project_ids_relation)
+
+ results.objects('issues')
+ end
end
end
@@ -100,12 +159,12 @@ describe Gitlab::SearchResults do
expect(issues).not_to include security_issue_3
expect(issues).not_to include security_issue_4
expect(issues).not_to include security_issue_5
- expect(results.issues_count).to eq 1
+ expect(results.limited_issues_count).to eq 1
end
it 'does not list confidential issues for project members with guest role' do
- project_1.team << [member, :guest]
- project_2.team << [member, :guest]
+ project_1.add_guest(member)
+ project_2.add_guest(member)
results = described_class.new(member, limit_projects, query)
issues = results.objects('issues')
@@ -116,7 +175,7 @@ describe Gitlab::SearchResults do
expect(issues).not_to include security_issue_3
expect(issues).not_to include security_issue_4
expect(issues).not_to include security_issue_5
- expect(results.issues_count).to eq 1
+ expect(results.limited_issues_count).to eq 1
end
it 'lists confidential issues for author' do
@@ -129,7 +188,7 @@ describe Gitlab::SearchResults do
expect(issues).to include security_issue_3
expect(issues).not_to include security_issue_4
expect(issues).not_to include security_issue_5
- expect(results.issues_count).to eq 3
+ expect(results.limited_issues_count).to eq 3
end
it 'lists confidential issues for assignee' do
@@ -142,12 +201,12 @@ describe Gitlab::SearchResults do
expect(issues).not_to include security_issue_3
expect(issues).to include security_issue_4
expect(issues).not_to include security_issue_5
- expect(results.issues_count).to eq 3
+ expect(results.limited_issues_count).to eq 3
end
it 'lists confidential issues for project members' do
- project_1.team << [member, :developer]
- project_2.team << [member, :developer]
+ project_1.add_developer(member)
+ project_2.add_developer(member)
results = described_class.new(member, limit_projects, query)
issues = results.objects('issues')
@@ -158,7 +217,7 @@ describe Gitlab::SearchResults do
expect(issues).to include security_issue_3
expect(issues).not_to include security_issue_4
expect(issues).not_to include security_issue_5
- expect(results.issues_count).to eq 4
+ expect(results.limited_issues_count).to eq 4
end
it 'lists all issues for admin' do
@@ -171,7 +230,7 @@ describe Gitlab::SearchResults do
expect(issues).to include security_issue_3
expect(issues).to include security_issue_4
expect(issues).not_to include security_issue_5
- expect(results.issues_count).to eq 5
+ expect(results.limited_issues_count).to eq 5
end
end
diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb
index dd779b04741..7ff2c0639ec 100644
--- a/spec/lib/gitlab/shell_spec.rb
+++ b/spec/lib/gitlab/shell_spec.rb
@@ -4,6 +4,7 @@ require 'stringio'
describe Gitlab::Shell do
set(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
let(:gitlab_shell) { described_class.new }
let(:popen_vars) { { 'GIT_TERMINAL_PROMPT' => ENV['GIT_TERMINAL_PROMPT'] } }
let(:gitlab_projects) { double('gitlab_projects') }
@@ -13,13 +14,13 @@ describe Gitlab::Shell do
allow(Project).to receive(:find).and_return(project)
allow(gitlab_shell).to receive(:gitlab_projects)
- .with(project.repository_storage_path, project.disk_path + '.git')
+ .with(project.repository_storage, project.disk_path + '.git')
.and_return(gitlab_projects)
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 :create_repository }
it { is_expected.to respond_to :remove_repository }
it { is_expected.to respond_to :fork_repository }
@@ -51,6 +52,311 @@ describe Gitlab::Shell do
end
end
+ describe '#add_key' do
+ context 'when authorized_keys_enabled is true' 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
+
+ context 'when authorized_keys_enabled is false' do
+ before do
+ stub_application_setting(authorized_keys_enabled: false)
+ end
+
+ it 'does nothing' do
+ expect(gitlab_shell).not_to receive(:gitlab_shell_fast_execute)
+
+ gitlab_shell.add_key('key-123', 'ssh-rsa foobar trailing garbage')
+ end
+ end
+
+ context 'when authorized_keys_enabled is nil' do
+ before do
+ stub_application_setting(authorized_keys_enabled: nil)
+ end
+
+ 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
+ end
+
+ describe '#batch_add_keys' do
+ context 'when authorized_keys_enabled is true' do
+ it 'instantiates KeyAdder' do
+ expect_any_instance_of(Gitlab::Shell::KeyAdder).to receive(:add_key).with('key-123', 'ssh-rsa foobar')
+
+ gitlab_shell.batch_add_keys do |adder|
+ adder.add_key('key-123', 'ssh-rsa foobar')
+ end
+ end
+ end
+
+ context 'when authorized_keys_enabled is false' do
+ before do
+ stub_application_setting(authorized_keys_enabled: false)
+ end
+
+ it 'does nothing' do
+ expect_any_instance_of(Gitlab::Shell::KeyAdder).not_to receive(:add_key)
+
+ gitlab_shell.batch_add_keys do |adder|
+ adder.add_key('key-123', 'ssh-rsa foobar')
+ end
+ end
+ end
+
+ context 'when authorized_keys_enabled is nil' do
+ before do
+ stub_application_setting(authorized_keys_enabled: nil)
+ end
+
+ it 'instantiates KeyAdder' do
+ expect_any_instance_of(Gitlab::Shell::KeyAdder).to receive(:add_key).with('key-123', 'ssh-rsa foobar')
+
+ gitlab_shell.batch_add_keys do |adder|
+ adder.add_key('key-123', 'ssh-rsa foobar')
+ end
+ end
+ end
+ end
+
+ describe '#remove_key' do
+ context 'when authorized_keys_enabled is true' 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, 'rm-key', 'key-123', 'ssh-rsa foobar']
+ )
+
+ gitlab_shell.remove_key('key-123', 'ssh-rsa foobar')
+ end
+ end
+
+ context 'when authorized_keys_enabled is false' do
+ before do
+ stub_application_setting(authorized_keys_enabled: false)
+ end
+
+ it 'does nothing' do
+ expect(gitlab_shell).not_to receive(:gitlab_shell_fast_execute)
+
+ gitlab_shell.remove_key('key-123', 'ssh-rsa foobar')
+ end
+ end
+
+ context 'when authorized_keys_enabled is nil' do
+ before do
+ stub_application_setting(authorized_keys_enabled: nil)
+ end
+
+ 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, 'rm-key', 'key-123', 'ssh-rsa foobar']
+ )
+
+ gitlab_shell.remove_key('key-123', 'ssh-rsa foobar')
+ end
+ end
+
+ context 'when key content is not given' do
+ it 'calls rm-key with only one argument' 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, 'rm-key', 'key-123']
+ )
+
+ gitlab_shell.remove_key('key-123')
+ end
+ end
+ end
+
+ describe '#remove_all_keys' do
+ context 'when authorized_keys_enabled is true' 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, 'clear'])
+
+ gitlab_shell.remove_all_keys
+ end
+ end
+
+ context 'when authorized_keys_enabled is false' do
+ before do
+ stub_application_setting(authorized_keys_enabled: false)
+ end
+
+ it 'does nothing' do
+ expect(gitlab_shell).not_to receive(:gitlab_shell_fast_execute)
+
+ gitlab_shell.remove_all_keys
+ end
+ end
+
+ context 'when authorized_keys_enabled is nil' do
+ before do
+ stub_application_setting(authorized_keys_enabled: nil)
+ end
+
+ 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, 'clear']
+ )
+
+ gitlab_shell.remove_all_keys
+ end
+ end
+ end
+
+ describe '#remove_keys_not_found_in_db' do
+ context 'when keys are in the file that are not in the DB' do
+ before do
+ gitlab_shell.remove_all_keys
+ gitlab_shell.add_key('key-1234', 'ssh-rsa ASDFASDF')
+ gitlab_shell.add_key('key-9876', 'ssh-rsa ASDFASDF')
+ @another_key = create(:key) # this one IS in the DB
+ end
+
+ it 'removes the keys' do
+ expect(find_in_authorized_keys_file(1234)).to be_truthy
+ expect(find_in_authorized_keys_file(9876)).to be_truthy
+ expect(find_in_authorized_keys_file(@another_key.id)).to be_truthy
+ gitlab_shell.remove_keys_not_found_in_db
+ expect(find_in_authorized_keys_file(1234)).to be_falsey
+ expect(find_in_authorized_keys_file(9876)).to be_falsey
+ expect(find_in_authorized_keys_file(@another_key.id)).to be_truthy
+ end
+ end
+
+ context 'when keys there are duplicate keys in the file that are not in the DB' do
+ before do
+ gitlab_shell.remove_all_keys
+ gitlab_shell.add_key('key-1234', 'ssh-rsa ASDFASDF')
+ gitlab_shell.add_key('key-1234', 'ssh-rsa ASDFASDF')
+ end
+
+ it 'removes the keys' do
+ expect(find_in_authorized_keys_file(1234)).to be_truthy
+ gitlab_shell.remove_keys_not_found_in_db
+ expect(find_in_authorized_keys_file(1234)).to be_falsey
+ end
+
+ it 'does not run remove more than once per key (in a batch)' do
+ expect(gitlab_shell).to receive(:remove_key).with('key-1234').once
+ gitlab_shell.remove_keys_not_found_in_db
+ end
+ end
+
+ context 'when keys there are duplicate keys in the file that ARE in the DB' do
+ before do
+ gitlab_shell.remove_all_keys
+ @key = create(:key)
+ gitlab_shell.add_key(@key.shell_id, @key.key)
+ end
+
+ it 'does not remove the key' do
+ gitlab_shell.remove_keys_not_found_in_db
+ expect(find_in_authorized_keys_file(@key.id)).to be_truthy
+ end
+
+ it 'does not need to run a SELECT query for that batch, on account of that key' do
+ expect_any_instance_of(ActiveRecord::Relation).not_to receive(:pluck)
+ gitlab_shell.remove_keys_not_found_in_db
+ end
+ end
+
+ unless ENV['CI'] # Skip in CI, it takes 1 minute
+ context 'when the first batch can be skipped, but the next batch has keys that are not in the DB' do
+ before do
+ gitlab_shell.remove_all_keys
+ 100.times { |i| create(:key) } # first batch is all in the DB
+ gitlab_shell.add_key('key-1234', 'ssh-rsa ASDFASDF')
+ end
+
+ it 'removes the keys not in the DB' do
+ expect(find_in_authorized_keys_file(1234)).to be_truthy
+ gitlab_shell.remove_keys_not_found_in_db
+ expect(find_in_authorized_keys_file(1234)).to be_falsey
+ end
+ end
+ end
+ end
+
+ describe '#batch_read_key_ids' do
+ context 'when there are keys in the authorized_keys file' do
+ before do
+ gitlab_shell.remove_all_keys
+ (1..4).each do |i|
+ gitlab_shell.add_key("key-#{i}", "ssh-rsa ASDFASDF#{i}")
+ end
+ end
+
+ it 'iterates over the key IDs in the file, in batches' do
+ loop_count = 0
+ first_batch = [1, 2]
+ second_batch = [3, 4]
+
+ gitlab_shell.batch_read_key_ids(batch_size: 2) do |batch|
+ expected = (loop_count == 0 ? first_batch : second_batch)
+ expect(batch).to eq(expected)
+ loop_count += 1
+ end
+ end
+ end
+ end
+
+ describe '#list_key_ids' do
+ context 'when there are keys in the authorized_keys file' do
+ before do
+ gitlab_shell.remove_all_keys
+ (1..4).each do |i|
+ gitlab_shell.add_key("key-#{i}", "ssh-rsa ASDFASDF#{i}")
+ end
+ end
+
+ it 'outputs the key IDs in the file, separated by newlines' do
+ ids = []
+ gitlab_shell.list_key_ids do |io|
+ io.each do |line|
+ ids << line
+ end
+ end
+
+ expect(ids).to eq(%W{1\n 2\n 3\n 4\n})
+ end
+ end
+
+ context 'when there are no keys in the authorized_keys file' do
+ before do
+ gitlab_shell.remove_all_keys
+ end
+
+ it 'outputs nothing, not even an empty string' do
+ ids = []
+ gitlab_shell.list_key_ids do |io|
+ io.each do |line|
+ ids << line
+ end
+ end
+
+ expect(ids).to eq([])
+ end
+ end
+ end
+
describe Gitlab::Shell::KeyAdder do
describe '#add_key' do
it 'removes trailing garbage' do
@@ -96,21 +402,10 @@ describe Gitlab::Shell do
allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800)
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 '#add_repository' do
- shared_examples '#add_repository' do
+ describe '#create_repository' do
+ shared_examples '#create_repository' do
let(:repository_storage) { 'default' }
- let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] }
+ let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path }
let(:repo_name) { 'project/path' }
let(:created_path) { File.join(repository_storage_path, repo_name + '.git') }
@@ -119,7 +414,7 @@ describe Gitlab::Shell do
end
it 'creates a repository' do
- expect(gitlab_shell.add_repository(repository_storage, repo_name)).to be_truthy
+ expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_truthy
expect(File.stat(created_path).mode & 0o777).to eq(0o770)
@@ -131,80 +426,90 @@ describe Gitlab::Shell do
it 'returns false when the command fails' do
FileUtils.mkdir_p(File.dirname(created_path))
# This file will block the creation of the repo's .git directory. That
- # should cause #add_repository to fail.
+ # should cause #create_repository to fail.
FileUtils.touch(created_path)
- expect(gitlab_shell.add_repository(repository_storage, repo_name)).to be_falsy
+ expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_falsy
end
end
context 'with gitaly' do
- it_behaves_like '#add_repository'
+ it_behaves_like '#create_repository'
end
context 'without gitaly', :skip_gitaly_mock do
- it_behaves_like '#add_repository'
+ it_behaves_like '#create_repository'
end
end
describe '#remove_repository' do
- subject { gitlab_shell.remove_repository(project.repository_storage_path, project.disk_path) }
+ let!(:project) { create(:project, :repository, :legacy_storage) }
+ let(:disk_path) { "#{project.disk_path}.git" }
it 'returns true when the command succeeds' do
- expect(gitlab_projects).to receive(:rm_project) { true }
+ expect(gitlab_shell.exists?(project.repository_storage_path, disk_path)).to be(true)
- is_expected.to be_truthy
+ expect(gitlab_shell.remove_repository(project.repository_storage_path, project.disk_path)).to be(true)
+
+ expect(gitlab_shell.exists?(project.repository_storage_path, disk_path)).to be(false)
end
- it 'returns false when the command fails' do
- expect(gitlab_projects).to receive(:rm_project) { false }
+ it 'keeps the namespace directory' do
+ gitlab_shell.remove_repository(project.repository_storage_path, project.disk_path)
- is_expected.to be_falsy
+ expect(gitlab_shell.exists?(project.repository_storage_path, disk_path)).to be(false)
+ expect(gitlab_shell.exists?(project.repository_storage_path, project.disk_path.gsub(project.name, ''))).to be(true)
end
end
describe '#mv_repository' do
+ let!(:project2) { create(:project, :repository) }
+
it 'returns true when the command succeeds' do
- expect(gitlab_projects).to receive(:mv_project).with('project/newpath.git') { true }
+ old_path = project2.disk_path
+ new_path = "project/new_path"
+
+ expect(gitlab_shell.exists?(project2.repository_storage_path, "#{old_path}.git")).to be(true)
+ expect(gitlab_shell.exists?(project2.repository_storage_path, "#{new_path}.git")).to be(false)
+
+ expect(gitlab_shell.mv_repository(project2.repository_storage_path, old_path, new_path)).to be_truthy
- expect(gitlab_shell.mv_repository(project.repository_storage_path, project.disk_path, 'project/newpath')).to be_truthy
+ expect(gitlab_shell.exists?(project2.repository_storage_path, "#{old_path}.git")).to be(false)
+ expect(gitlab_shell.exists?(project2.repository_storage_path, "#{new_path}.git")).to be(true)
end
it 'returns false when the command fails' do
- expect(gitlab_projects).to receive(:mv_project).with('project/newpath.git') { false }
-
- expect(gitlab_shell.mv_repository(project.repository_storage_path, project.disk_path, 'project/newpath')).to be_falsy
+ expect(gitlab_shell.mv_repository(project2.repository_storage_path, project2.disk_path, '')).to be_falsy
+ expect(gitlab_shell.exists?(project2.repository_storage_path, "#{project2.disk_path}.git")).to be(true)
end
end
describe '#fork_repository' do
subject do
gitlab_shell.fork_repository(
- project.repository_storage_path,
+ project.repository_storage,
project.disk_path,
- 'new/storage',
+ 'nfs-file05',
'fork/path'
)
end
it 'returns true when the command succeeds' do
- expect(gitlab_projects).to receive(:fork_repository).with('new/storage', 'fork/path.git') { true }
+ expect(gitlab_projects).to receive(:fork_repository).with('nfs-file05', 'fork/path.git') { true }
is_expected.to be_truthy
end
it 'return false when the command fails' do
- expect(gitlab_projects).to receive(:fork_repository).with('new/storage', 'fork/path.git') { false }
+ expect(gitlab_projects).to receive(:fork_repository).with('nfs-file05', 'fork/path.git') { false }
is_expected.to be_falsy
end
end
shared_examples 'fetch_remote' do |gitaly_on|
- let(:repository) { project.repository }
-
- def fetch_remote(ssh_auth = nil)
- gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', ssh_auth: ssh_auth)
+ def fetch_remote(ssh_auth = nil, prune = true)
+ gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', ssh_auth: ssh_auth, prune: prune)
end
def expect_gitlab_projects(fail = false, options = {})
@@ -250,27 +555,33 @@ describe Gitlab::Shell do
end
it 'returns true when the command succeeds' do
- expect_call(false, force: false, tags: true)
+ expect_call(false, force: false, tags: true, prune: true)
expect(fetch_remote).to be_truthy
end
+ it 'returns true when the command succeeds' do
+ expect_call(false, force: false, tags: true, prune: false)
+
+ expect(fetch_remote(nil, false)).to be_truthy
+ end
+
it 'raises an exception when the command fails' do
- expect_call(true, force: false, tags: true)
+ expect_call(true, force: false, tags: true, prune: true)
expect { fetch_remote }.to raise_error(Gitlab::Shell::Error)
end
it 'allows forced and no_tags to be changed' do
- expect_call(false, force: true, tags: false)
+ expect_call(false, force: true, tags: false, prune: true)
- result = gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', forced: true, no_tags: true)
+ result = gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', forced: true, no_tags: true, prune: true)
expect(result).to be_truthy
end
context 'SSH auth' do
it 'passes the SSH key if specified' do
- expect_call(false, force: false, tags: true, ssh_key: 'foo')
+ expect_call(false, force: false, tags: true, prune: true, ssh_key: 'foo')
ssh_auth = build_ssh_auth(ssh_key_auth?: true, ssh_private_key: 'foo')
@@ -278,7 +589,7 @@ describe Gitlab::Shell do
end
it 'does not pass an empty SSH key' do
- expect_call(false, force: false, tags: true)
+ expect_call(false, force: false, tags: true, prune: true)
ssh_auth = build_ssh_auth(ssh_key_auth: true, ssh_private_key: '')
@@ -286,7 +597,7 @@ describe Gitlab::Shell do
end
it 'does not pass the key unless SSH key auth is to be used' do
- expect_call(false, force: false, tags: true)
+ expect_call(false, force: false, tags: true, prune: true)
ssh_auth = build_ssh_auth(ssh_key_auth: false, ssh_private_key: 'foo')
@@ -294,7 +605,7 @@ describe Gitlab::Shell do
end
it 'passes the known_hosts data if specified' do
- expect_call(false, force: false, tags: true, known_hosts: 'foo')
+ expect_call(false, force: false, tags: true, prune: true, known_hosts: 'foo')
ssh_auth = build_ssh_auth(ssh_known_hosts: 'foo')
@@ -302,7 +613,7 @@ describe Gitlab::Shell do
end
it 'does not pass empty known_hosts data' do
- expect_call(false, force: false, tags: true)
+ expect_call(false, force: false, tags: true, prune: true)
ssh_auth = build_ssh_auth(ssh_known_hosts: '')
@@ -310,7 +621,7 @@ describe Gitlab::Shell do
end
it 'does not pass known_hosts data unless SSH is to be used' do
- expect_call(false, force: false, tags: true)
+ expect_call(false, force: false, tags: true, prune: true)
ssh_auth = build_ssh_auth(ssh_import?: false, ssh_known_hosts: 'foo')
@@ -325,6 +636,23 @@ describe Gitlab::Shell do
describe '#fetch_remote gitaly' do
it_should_behave_like 'fetch_remote', true
+
+ context 'gitaly call' do
+ let(:remote_name) { 'remote-name' }
+ let(:ssh_auth) { double(:ssh_auth) }
+
+ subject do
+ gitlab_shell.fetch_remote(repository.raw_repository, remote_name,
+ forced: true, no_tags: true, ssh_auth: ssh_auth)
+ end
+
+ it 'passes the correct params to the gitaly service' do
+ expect(repository.gitaly_repository_client).to receive(:fetch_remote)
+ .with(remote_name, ssh_auth: ssh_auth, forced: true, no_tags: true, prune: true, timeout: timeout)
+
+ subject
+ end
+ end
end
describe '#import_repository' do
@@ -333,7 +661,7 @@ describe Gitlab::Shell do
it 'returns true when the command succeeds' do
expect(gitlab_projects).to receive(:import_project).with(import_url, timeout) { true }
- result = gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, import_url)
+ result = gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url)
expect(result).to be_truthy
end
@@ -343,71 +671,15 @@ describe Gitlab::Shell do
expect(gitlab_projects).to receive(:import_project) { false }
expect do
- gitlab_shell.import_repository(project.repository_storage_path, project.disk_path, import_url)
+ gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url)
end.to raise_error(Gitlab::Shell::Error, "error")
end
end
-
- describe '#push_remote_branches' do
- subject(:result) do
- gitlab_shell.push_remote_branches(
- project.repository_storage_path,
- project.disk_path,
- 'downstream-remote',
- ['master']
- )
- end
-
- it 'executes the command' do
- expect(gitlab_projects).to receive(:push_branches)
- .with('downstream-remote', timeout, true, ['master'])
- .and_return(true)
-
- is_expected.to be_truthy
- end
-
- it 'fails to execute the command' do
- allow(gitlab_projects).to receive(:output) { 'error' }
- expect(gitlab_projects).to receive(:push_branches)
- .with('downstream-remote', timeout, true, ['master'])
- .and_return(false)
-
- expect { result }.to raise_error(Gitlab::Shell::Error, 'error')
- end
- end
-
- describe '#delete_remote_branches' do
- subject(:result) do
- gitlab_shell.delete_remote_branches(
- project.repository_storage_path,
- project.disk_path,
- 'downstream-remote',
- ['master']
- )
- end
-
- it 'executes the command' do
- expect(gitlab_projects).to receive(:delete_remote_branches)
- .with('downstream-remote', ['master'])
- .and_return(true)
-
- is_expected.to be_truthy
- end
-
- it 'fails to execute the command' do
- allow(gitlab_projects).to receive(:output) { 'error' }
- expect(gitlab_projects).to receive(:delete_remote_branches)
- .with('downstream-remote', ['master'])
- .and_return(false)
-
- expect { result }.to raise_error(Gitlab::Shell::Error, 'error')
- end
- end
end
describe 'namespace actions' do
subject { described_class.new }
- let(:storage_path) { Gitlab.config.repositories.storages.default.path }
+ let(:storage_path) { Gitlab.config.repositories.storages.default.legacy_disk_path }
describe '#add_namespace' do
it 'creates a namespace' do
@@ -452,4 +724,12 @@ describe Gitlab::Shell do
end
end
end
+
+ def find_in_authorized_keys_file(key_id)
+ gitlab_shell.batch_read_key_ids do |ids|
+ return true if ids.include?(key_id)
+ end
+
+ false
+ end
end
diff --git a/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb
new file mode 100644
index 00000000000..fed9aeba30c
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe Gitlab::SidekiqLogging::JSONFormatter do
+ let(:hash_input) { { foo: 1, bar: 'test' } }
+ let(:message) { 'This is a test' }
+ let(:timestamp) { Time.now }
+
+ it 'wraps a Hash' do
+ result = subject.call('INFO', timestamp, 'my program', hash_input)
+
+ data = JSON.parse(result)
+ expected_output = hash_input.stringify_keys
+ expected_output['severity'] = 'INFO'
+ expected_output['time'] = timestamp.utc.iso8601(3)
+
+ expect(data).to eq(expected_output)
+ end
+
+ it 'wraps a String' do
+ result = subject.call('DEBUG', timestamp, 'my string', message)
+
+ data = JSON.parse(result)
+ expected_output = {
+ severity: 'DEBUG',
+ time: timestamp.utc.iso8601(3),
+ message: message
+ }
+
+ expect(data).to eq(expected_output.stringify_keys)
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
new file mode 100644
index 00000000000..2421b1e5a1a
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe Gitlab::SidekiqLogging::StructuredLogger do
+ describe '#call' do
+ let(:timestamp) { Time.new('2018-01-01 12:00:00').utc }
+ let(:job) do
+ {
+ "class" => "TestWorker",
+ "args" => [1234, 'hello'],
+ "retry" => false,
+ "queue" => "cronjob:test_queue",
+ "queue_namespace" => "cronjob",
+ "jid" => "da883554ee4fe414012f5f42",
+ "created_at" => timestamp.to_f,
+ "enqueued_at" => timestamp.to_f
+ }
+ end
+ let(:logger) { double() }
+ let(:start_payload) do
+ job.merge(
+ 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: start',
+ 'job_status' => 'start',
+ 'pid' => Process.pid,
+ 'created_at' => timestamp.iso8601(3),
+ 'enqueued_at' => timestamp.iso8601(3)
+ )
+ end
+ let(:end_payload) do
+ start_payload.merge(
+ 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: done: 0.0 sec',
+ 'job_status' => 'done',
+ 'duration' => 0.0,
+ "completed_at" => timestamp.iso8601(3)
+ )
+ end
+ let(:exception_payload) do
+ end_payload.merge(
+ 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: fail: 0.0 sec',
+ 'job_status' => 'fail',
+ 'error' => ArgumentError,
+ 'error_message' => 'some exception'
+ )
+ end
+
+ before do
+ allow(Sidekiq).to receive(:logger).and_return(logger)
+
+ allow(subject).to receive(:current_time).and_return(timestamp.to_f)
+ end
+
+ subject { described_class.new }
+
+ context 'with SIDEKIQ_LOG_ARGUMENTS enabled' do
+ before do
+ stub_env('SIDEKIQ_LOG_ARGUMENTS', '1')
+ end
+
+ it 'logs start and end of job' do
+ Timecop.freeze(timestamp) do
+ expect(logger).to receive(:info).with(start_payload).ordered
+ expect(logger).to receive(:info).with(end_payload).ordered
+ expect(subject).to receive(:log_job_start).and_call_original
+ expect(subject).to receive(:log_job_done).and_call_original
+
+ subject.call(job, 'test_queue') { }
+ end
+ end
+
+ it 'logs an exception in job' do
+ Timecop.freeze(timestamp) do
+ expect(logger).to receive(:info).with(start_payload)
+ # This excludes the exception_backtrace
+ expect(logger).to receive(:warn).with(hash_including(exception_payload))
+ expect(subject).to receive(:log_job_start).and_call_original
+ expect(subject).to receive(:log_job_done).and_call_original
+
+ expect do
+ subject.call(job, 'test_queue') do
+ raise ArgumentError, 'some exception'
+ end
+ end.to raise_error(ArgumentError)
+ end
+ end
+ end
+
+ context 'with SIDEKIQ_LOG_ARGUMENTS disabled' do
+ it 'logs start and end of job' do
+ Timecop.freeze(timestamp) do
+ start_payload.delete('args')
+
+ expect(logger).to receive(:info).with(start_payload).ordered
+ expect(logger).to receive(:info).with(end_payload).ordered
+ expect(subject).to receive(:log_job_start).and_call_original
+ expect(subject).to receive(:log_job_done).and_call_original
+
+ subject.call(job, 'test_queue') { }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb b/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb
deleted file mode 100644
index 8fdbbacd04d..00000000000
--- a/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::SidekiqMiddleware::MemoryKiller do
- subject { described_class.new }
- let(:pid) { 999 }
-
- let(:worker) { double(:worker, class: 'TestWorker') }
- let(:job) { { 'jid' => 123 } }
- let(:queue) { 'test_queue' }
-
- def run
- thread = subject.call(worker, job, queue) { nil }
- thread&.join
- end
-
- before do
- allow(subject).to receive(:get_rss).and_return(10.kilobytes)
- allow(subject).to receive(:pid).and_return(pid)
- end
-
- context 'when MAX_RSS is set to 0' do
- before do
- stub_const("#{described_class}::MAX_RSS", 0)
- end
-
- it 'does nothing' do
- expect(subject).not_to receive(:sleep)
-
- run
- end
- end
-
- context 'when MAX_RSS is exceeded' do
- before do
- stub_const("#{described_class}::MAX_RSS", 5.kilobytes)
- end
-
- it 'sends the STP, TERM and KILL signals at expected times' do
- expect(subject).to receive(:sleep).with(15 * 60).ordered
- expect(Process).to receive(:kill).with('SIGSTP', pid).ordered
-
- expect(subject).to receive(:sleep).with(30).ordered
- expect(Process).to receive(:kill).with('SIGTERM', pid).ordered
-
- expect(subject).to receive(:sleep).with(10).ordered
- expect(Process).to receive(:kill).with('SIGKILL', pid).ordered
-
- run
- end
- end
-
- context 'when MAX_RSS is not exceeded' do
- before do
- stub_const("#{described_class}::MAX_RSS", 15.kilobytes)
- end
-
- it 'does nothing' do
- expect(subject).not_to receive(:sleep)
-
- run
- end
- end
-end
diff --git a/spec/lib/gitlab/sidekiq_middleware/shutdown_spec.rb b/spec/lib/gitlab/sidekiq_middleware/shutdown_spec.rb
new file mode 100644
index 00000000000..0001795c3f0
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_middleware/shutdown_spec.rb
@@ -0,0 +1,88 @@
+require 'spec_helper'
+
+describe Gitlab::SidekiqMiddleware::Shutdown do
+ subject { described_class.new }
+
+ let(:pid) { Process.pid }
+ let(:worker) { double(:worker, class: 'TestWorker') }
+ let(:job) { { 'jid' => 123 } }
+ let(:queue) { 'test_queue' }
+ let(:block) { proc { nil } }
+
+ def run
+ subject.call(worker, job, queue) { block.call }
+ described_class.shutdown_thread&.join
+ end
+
+ def pop_trace
+ subject.trace.pop(true)
+ end
+
+ before do
+ allow(subject).to receive(:get_rss).and_return(10.kilobytes)
+ described_class.clear_shutdown_thread
+ end
+
+ context 'when MAX_RSS is set to 0' do
+ before do
+ stub_const("#{described_class}::MAX_RSS", 0)
+ end
+
+ it 'does nothing' do
+ expect(subject).not_to receive(:sleep)
+
+ run
+ end
+ end
+
+ def expect_shutdown_sequence
+ expect(pop_trace).to eq([:sleep, 15 * 60])
+ expect(pop_trace).to eq([:kill, 'SIGTSTP', pid])
+
+ expect(pop_trace).to eq([:sleep, 30])
+ expect(pop_trace).to eq([:kill, 'SIGTERM', pid])
+
+ expect(pop_trace).to eq([:sleep, 10])
+ expect(pop_trace).to eq([:kill, 'SIGKILL', pid])
+ end
+
+ context 'when MAX_RSS is exceeded' do
+ before do
+ stub_const("#{described_class}::MAX_RSS", 5.kilobytes)
+ end
+
+ it 'sends the TSTP, TERM and KILL signals at expected times' do
+ run
+
+ expect_shutdown_sequence
+ end
+ end
+
+ context 'when MAX_RSS is not exceeded' do
+ before do
+ stub_const("#{described_class}::MAX_RSS", 15.kilobytes)
+ end
+
+ it 'does nothing' do
+ expect(subject).not_to receive(:sleep)
+
+ run
+ end
+ end
+
+ context 'when WantShutdown is raised' do
+ let(:block) { proc { raise described_class::WantShutdown } }
+
+ it 'starts the shutdown sequence and re-raises the exception' do
+ expect { run }.to raise_exception(described_class::WantShutdown)
+
+ # We can't expect 'run' to have joined on the shutdown thread, because
+ # it hit an exception.
+ shutdown_thread = described_class.shutdown_thread
+ expect(shutdown_thread).not_to be_nil
+ shutdown_thread.join
+
+ expect_shutdown_sequence
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb
index 0173a45d480..194cae8c645 100644
--- a/spec/lib/gitlab/slash_commands/command_spec.rb
+++ b/spec/lib/gitlab/slash_commands/command_spec.rb
@@ -3,10 +3,11 @@ require 'spec_helper'
describe Gitlab::SlashCommands::Command do
let(:project) { create(:project) }
let(:user) { create(:user) }
+ let(:chat_name) { double(:chat_name, user: user) }
describe '#execute' do
subject do
- described_class.new(project, user, params).execute
+ described_class.new(project, chat_name, params).execute
end
context 'when no command is available' do
@@ -88,7 +89,7 @@ describe Gitlab::SlashCommands::Command do
end
describe '#match_command' do
- subject { described_class.new(project, user, params).match_command.first }
+ subject { described_class.new(project, chat_name, params).match_command.first }
context 'IssueShow is triggered' do
let(:params) { { text: 'issue show 123' } }
@@ -107,5 +108,10 @@ describe Gitlab::SlashCommands::Command do
it { is_expected.to eq(Gitlab::SlashCommands::IssueSearch) }
end
+
+ context 'IssueMove is triggered' do
+ let(:params) { { text: 'issue move #78291 to gitlab/gitlab-ci' } }
+ it { is_expected.to eq(Gitlab::SlashCommands::IssueMove) }
+ end
end
end
diff --git a/spec/lib/gitlab/slash_commands/deploy_spec.rb b/spec/lib/gitlab/slash_commands/deploy_spec.rb
index 74b5ef4bb26..0d57334aa4c 100644
--- a/spec/lib/gitlab/slash_commands/deploy_spec.rb
+++ b/spec/lib/gitlab/slash_commands/deploy_spec.rb
@@ -4,6 +4,7 @@ describe Gitlab::SlashCommands::Deploy do
describe '#execute' do
let(:project) { create(:project) }
let(:user) { create(:user) }
+ let(:chat_name) { double(:chat_name, user: user) }
let(:regex_match) { described_class.match('deploy staging to production') }
before do
@@ -16,7 +17,7 @@ describe Gitlab::SlashCommands::Deploy do
end
subject do
- described_class.new(project, user).execute(regex_match)
+ described_class.new(project, chat_name).execute(regex_match)
end
context 'if no environment is defined' do
diff --git a/spec/lib/gitlab/slash_commands/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/issue_move_spec.rb
new file mode 100644
index 00000000000..d41441c9472
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/issue_move_spec.rb
@@ -0,0 +1,117 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::IssueMove, service: true do
+ describe '#match' do
+ shared_examples_for 'move command' do |text_command|
+ it 'can be parsed to extract the needed fields' do
+ match_data = described_class.match(text_command)
+
+ expect(match_data['iid']).to eq('123456')
+ expect(match_data['project_path']).to eq('gitlab/gitlab-ci')
+ end
+ end
+
+ it_behaves_like 'move command', 'issue move #123456 to gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci '
+ it_behaves_like 'move command', 'issue move 123456 to gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci '
+ end
+
+ describe '#execute' do
+ set(:user) { create(:user) }
+ set(:issue) { create(:issue) }
+ set(:chat_name) { create(:chat_name, user: user) }
+ set(:project) { issue.project }
+ set(:other_project) { create(:project, namespace: project.namespace) }
+
+ before do
+ [project, other_project].each { |prj| prj.add_master(user) }
+ end
+
+ subject { described_class.new(project, chat_name) }
+
+ def process_message(message)
+ subject.execute(described_class.match(message))
+ end
+
+ context 'when the user can move the issue' do
+ context 'when the move fails' do
+ it 'returns the error message' do
+ message = "issue move #{issue.iid} #{project.full_path}"
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('Cannot move issue'))
+ end
+ end
+
+ context 'when the move succeeds' do
+ let(:message) { "issue move #{issue.iid} #{other_project.full_path}" }
+
+ it 'moves the issue to the new destination' do
+ expect { process_message(message) }.to change { Issue.count }.by(1)
+
+ new_issue = issue.reload.moved_to
+
+ expect(new_issue.state).to eq('opened')
+ expect(new_issue.project_id).to eq(other_project.id)
+ expect(new_issue.author_id).to eq(issue.author_id)
+
+ expect(issue.state).to eq('closed')
+ expect(issue.project_id).to eq(project.id)
+ end
+
+ it 'returns the new issue' do
+ expect(process_message(message))
+ .to include(response_type: :in_channel,
+ attachments: [a_hash_including(title_link: a_string_including(other_project.full_path))])
+ end
+
+ it 'mentions the old issue' do
+ expect(process_message(message))
+ .to include(attachments: [a_hash_including(pretext: a_string_including(project.full_path))])
+ end
+ end
+ end
+
+ context 'when the issue does not exist' do
+ it 'returns not found' do
+ message = "issue move #{issue.iid.succ} #{other_project.full_path}"
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('not found'))
+ end
+ end
+
+ context 'when the target project does not exist' do
+ it 'returns not found' do
+ message = "issue move #{issue.iid} #{other_project.full_path}/foo"
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('not found'))
+ end
+ end
+
+ context 'when the user cannot see the target project' do
+ it 'returns not found' do
+ message = "issue move #{issue.iid} #{other_project.full_path}"
+ other_project.team.truncate
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('not found'))
+ end
+ end
+
+ context 'when the user does not have the required permissions on the target project' do
+ it 'returns the error message' do
+ message = "issue move #{issue.iid} #{other_project.full_path}"
+ other_project.team.truncate
+ other_project.team.add_guest(user)
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('Cannot move issue'))
+ end
+ 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
index 75ae58d0582..8e7df946529 100644
--- a/spec/lib/gitlab/slash_commands/issue_new_spec.rb
+++ b/spec/lib/gitlab/slash_commands/issue_new_spec.rb
@@ -4,14 +4,15 @@ describe Gitlab::SlashCommands::IssueNew do
describe '#execute' do
let(:project) { create(:project) }
let(:user) { create(:user) }
+ let(:chat_name) { double(:chat_name, user: user) }
let(:regex_match) { described_class.match("issue create bird is the word") }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
subject do
- described_class.new(project, user).execute(regex_match)
+ described_class.new(project, chat_name).execute(regex_match)
end
context 'without description' do
diff --git a/spec/lib/gitlab/slash_commands/issue_search_spec.rb b/spec/lib/gitlab/slash_commands/issue_search_spec.rb
index 51f59216413..189e9592f1b 100644
--- a/spec/lib/gitlab/slash_commands/issue_search_spec.rb
+++ b/spec/lib/gitlab/slash_commands/issue_search_spec.rb
@@ -5,11 +5,12 @@ describe Gitlab::SlashCommands::IssueSearch 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(:user) { create(:user) }
+ let(:chat_name) { double(:chat_name, user: user) }
let(:regex_match) { described_class.match("issue search find") }
subject do
- described_class.new(project, user).execute(regex_match)
+ described_class.new(project, chat_name).execute(regex_match)
end
context 'when the user has no access' do
@@ -21,7 +22,7 @@ describe Gitlab::SlashCommands::IssueSearch do
context 'the user has access' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'returns all results' do
diff --git a/spec/lib/gitlab/slash_commands/issue_show_spec.rb b/spec/lib/gitlab/slash_commands/issue_show_spec.rb
index 08c380ca8f1..b1db1638237 100644
--- a/spec/lib/gitlab/slash_commands/issue_show_spec.rb
+++ b/spec/lib/gitlab/slash_commands/issue_show_spec.rb
@@ -5,14 +5,15 @@ describe Gitlab::SlashCommands::IssueShow do
let(:issue) { create(:issue, project: project) }
let(:project) { create(:project) }
let(:user) { issue.author }
+ let(:chat_name) { double(:chat_name, user: user) }
let(:regex_match) { described_class.match("issue show #{issue.iid}") }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
subject do
- described_class.new(project, user).execute(regex_match)
+ described_class.new(project, chat_name).execute(regex_match)
end
context 'the issue exists' do
diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb
new file mode 100644
index 00000000000..58c341a284e
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Presenters::IssueMove do
+ set(:admin) { create(:admin) }
+ set(:project) { create(:project) }
+ set(:other_project) { create(:project) }
+ set(:old_issue) { create(:issue, project: project) }
+ set(:new_issue) { Issues::MoveService.new(project, admin).execute(old_issue, other_project) }
+ let(:attachment) { subject[:attachments].first }
+
+ subject { described_class.new(new_issue).present(old_issue) }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'shows the new issue' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(subject).to have_key(:attachments)
+ expect(attachment[:title]).to start_with(new_issue.title)
+ expect(attachment[:title_link]).to include(other_project.full_path)
+ end
+
+ it 'mentions the old issue and the new issue in the pretext' do
+ expect(attachment[:pretext]).to include(project.full_path)
+ expect(attachment[:pretext]).to include(other_project.full_path)
+ end
+end
diff --git a/spec/lib/gitlab/sql/pattern_spec.rb b/spec/lib/gitlab/sql/pattern_spec.rb
index ef51e3cc8df..5b5052de372 100644
--- a/spec/lib/gitlab/sql/pattern_spec.rb
+++ b/spec/lib/gitlab/sql/pattern_spec.rb
@@ -154,6 +154,12 @@ describe Gitlab::SQL::Pattern do
it 'returns a single equality condition' do
expect(fuzzy_arel_match.to_sql).to match(/title.*I?LIKE 'fo'/)
end
+
+ it 'uses LOWER instead of ILIKE when LOWER is enabled' do
+ rel = Issue.fuzzy_arel_match(:title, query, lower_exact_match: true)
+
+ expect(rel.to_sql).to match(/LOWER\(.*title.*\).*=.*'fo'/)
+ end
end
context 'with two words both equal to 3 chars' do
diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb
index 93d538141ce..a6ea07e8b6d 100644
--- a/spec/lib/gitlab/ssh_public_key_spec.rb
+++ b/spec/lib/gitlab/ssh_public_key_spec.rb
@@ -37,11 +37,60 @@ describe Gitlab::SSHPublicKey, lib: true do
end
end
+ describe '.sanitize(key_content)' do
+ let(:content) { build(:key).key }
+
+ context 'when key has blank space characters' do
+ it 'removes the extra blank space characters' do
+ unsanitized = content.insert(100, "\n")
+ .insert(40, "\r\n")
+ .insert(30, ' ')
+
+ sanitized = described_class.sanitize(unsanitized)
+ _, body = sanitized.split
+
+ expect(sanitized).not_to eq(unsanitized)
+ expect(body).not_to match(/\s/)
+ end
+ end
+
+ context "when key doesn't have blank space characters" do
+ it "doesn't modify the content" do
+ sanitized = described_class.sanitize(content)
+
+ expect(sanitized).to eq(content)
+ end
+ end
+
+ context "when key is invalid" do
+ it 'returns the original content' do
+ unsanitized = "ssh-foo any content=="
+ sanitized = described_class.sanitize(unsanitized)
+
+ expect(sanitized).to eq(unsanitized)
+ end
+ end
+ end
+
describe '#valid?' do
subject { public_key }
context 'with a valid SSH key' do
- it { is_expected.to be_valid }
+ where(:factory) do
+ %i(rsa_key_2048
+ rsa_key_4096
+ rsa_key_5120
+ rsa_key_8192
+ dsa_key_2048
+ ecdsa_key_256
+ ed25519_key_256)
+ end
+
+ with_them do
+ let(:key) { attributes_for(factory)[:key] }
+
+ it { is_expected.to be_valid }
+ end
end
context 'with an invalid SSH key' do
@@ -82,6 +131,9 @@ describe Gitlab::SSHPublicKey, lib: true do
where(:factory, :bits) do
[
[:rsa_key_2048, 2048],
+ [:rsa_key_4096, 4096],
+ [:rsa_key_5120, 5120],
+ [:rsa_key_8192, 8192],
[:dsa_key_2048, 2048],
[:ecdsa_key_256, 256],
[:ed25519_key_256, 256]
@@ -106,8 +158,11 @@ describe Gitlab::SSHPublicKey, lib: true do
where(:factory, :fingerprint) do
[
- [:rsa_key_2048, '2e:ca:dc:e0:37:29:ed:fc:f0:1d:bf:66:d4:cd:51:b1'],
- [:dsa_key_2048, 'bc:c1:a4:be:7e:8c:84:56:b3:58:93:53:c6:80:78:8c'],
+ [:rsa_key_2048, '58:a8:9d:cd:1f:70:f8:5a:d9:e4:24:8e:da:89:e4:fc'],
+ [:rsa_key_4096, 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7'],
+ [:rsa_key_5120, 'fe:fa:3a:4d:7d:51:ec:bf:c7:64:0c:96:d0:17:8a:d0'],
+ [:rsa_key_8192, 'fb:53:7f:e9:2f:f7:17:aa:c8:32:52:06:8e:05:e2:82'],
+ [:dsa_key_2048, 'c8:85:1e:df:44:0f:20:00:3c:66:57:2b:21:10:5a:27'],
[:ecdsa_key_256, '67:a3:a9:7d:b8:e1:15:d4:80:40:21:34:bb:ed:97:38'],
[:ed25519_key_256, 'e6:eb:45:8a:3c:59:35:5f:e9:5b:80:12:be:7e:22:73']
]
diff --git a/spec/lib/gitlab/string_placeholder_replacer_spec.rb b/spec/lib/gitlab/string_placeholder_replacer_spec.rb
new file mode 100644
index 00000000000..7a03ea4154c
--- /dev/null
+++ b/spec/lib/gitlab/string_placeholder_replacer_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Gitlab::StringPlaceholderReplacer do
+ describe '.render_url' do
+ it 'returns the nil if the string is blank' do
+ expect(described_class.replace_string_placeholders(nil, /whatever/)).to be_blank
+ end
+
+ it 'returns the string if the placeholder regex' do
+ expect(described_class.replace_string_placeholders('whatever')).to eq 'whatever'
+ end
+
+ it 'returns the string if no block given' do
+ expect(described_class.replace_string_placeholders('whatever', /whatever/)).to eq 'whatever'
+ end
+
+ context 'when all params are valid' do
+ let(:string) { '%{path}/%{id}/%{branch}' }
+ let(:regex) { /(path|id)/ }
+
+ it 'replaces each placeholders with the block result' do
+ result = described_class.replace_string_placeholders(string, regex) do |arg|
+ 'WHATEVER'
+ end
+
+ expect(result).to eq 'WHATEVER/WHATEVER/%{branch}'
+ end
+
+ it 'does not replace the placeholder if the block result is nil' do
+ result = described_class.replace_string_placeholders(string, regex) do |arg|
+ arg == 'path' ? nil : 'WHATEVER'
+ end
+
+ expect(result).to eq '%{path}/WHATEVER/%{branch}'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb
index d715f9bd641..37b1298b962 100644
--- a/spec/lib/gitlab/string_regex_marker_spec.rb
+++ b/spec/lib/gitlab/string_regex_marker_spec.rb
@@ -2,17 +2,36 @@ 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>}
+ context 'with a single occurrence' 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 match' 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
- 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
+ context 'with multiple occurrences' do
+ let(:raw) { %{a <b> <c> d} }
+ let(:rich) { %{a &lt;b&gt; &lt;c&gt; d}.html_safe }
+
+ subject do
+ described_class.new(raw, rich).mark(/<[a-z]>/) do |text, left:, right:|
+ %{<strong>#{text}</strong>}
+ end
+ end
+
+ it 'marks the matches' do
+ expect(subject).to eq(%{a <strong>&lt;b&gt;</strong> <strong>&lt;c&gt;</strong> d})
+ expect(subject).to be_html_safe
+ end
end
end
end
diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb
index d9b3c2350b1..a3b3dc3be6d 100644
--- a/spec/lib/gitlab/url_blocker_spec.rb
+++ b/spec/lib/gitlab/url_blocker_spec.rb
@@ -2,6 +2,8 @@ require 'spec_helper'
describe Gitlab::UrlBlocker do
describe '#blocked_url?' do
+ let(:valid_ports) { Project::VALID_IMPORT_PORTS }
+
it 'allows imports from configured web host and port' do
import_url = "http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git"
expect(described_class.blocked_url?(import_url)).to be false
@@ -17,7 +19,7 @@ describe Gitlab::UrlBlocker do
end
it 'returns true for bad port' do
- expect(described_class.blocked_url?('https://gitlab.com:25/foo/foo.git')).to be true
+ expect(described_class.blocked_url?('https://gitlab.com:25/foo/foo.git', valid_ports: valid_ports)).to be true
end
it 'returns true for alternative version of 127.0.0.1 (0177.1)' do
@@ -71,6 +73,47 @@ describe Gitlab::UrlBlocker do
it 'returns false for legitimate URL' do
expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git')).to be false
end
+
+ context 'when allow_local_network is' do
+ let(:local_ips) { ['192.168.1.2', '10.0.0.2', '172.16.0.2'] }
+ let(:fake_domain) { 'www.fakedomain.fake' }
+
+ context 'true (default)' do
+ it 'does not block urls from private networks' do
+ local_ips.each do |ip|
+ stub_domain_resolv(fake_domain, ip)
+
+ expect(described_class).not_to be_blocked_url("http://#{fake_domain}")
+
+ unstub_domain_resolv
+
+ expect(described_class).not_to be_blocked_url("http://#{ip}")
+ end
+ end
+ end
+
+ context 'false' do
+ it 'blocks urls from private networks' do
+ local_ips.each do |ip|
+ stub_domain_resolv(fake_domain, ip)
+
+ expect(described_class).to be_blocked_url("http://#{fake_domain}", allow_local_network: false)
+
+ unstub_domain_resolv
+
+ expect(described_class).to be_blocked_url("http://#{ip}", allow_local_network: false)
+ end
+ end
+ end
+
+ def stub_domain_resolv(domain, ip)
+ allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([double(ip_address: ip, ipv4_private?: true)])
+ end
+
+ def unstub_domain_resolv
+ allow(Addrinfo).to receive(:getaddrinfo).and_call_original
+ end
+ end
end
# Resolv does not support resolving UTF-8 domain names
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index b5f2a15ada3..9e6aa109a4b 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -12,6 +12,14 @@ describe Gitlab::UsageData do
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)
+
+ gcp_cluster = create(:cluster, :provided_by_gcp)
+ create(:cluster, :provided_by_user)
+ create(:cluster, :provided_by_user, :disabled)
+ create(:clusters_applications_helm, :installed, cluster: gcp_cluster)
+ create(:clusters_applications_ingress, :installed, cluster: gcp_cluster)
+ create(:clusters_applications_prometheus, :installed, cluster: gcp_cluster)
+ create(:clusters_applications_runner, :installed, cluster: gcp_cluster)
end
subject { described_class.data }
@@ -36,6 +44,7 @@ describe Gitlab::UsageData do
gitlab_shared_runners
git
database
+ avg_cycle_analytics
))
end
@@ -63,6 +72,12 @@ describe Gitlab::UsageData do
clusters
clusters_enabled
clusters_disabled
+ clusters_platforms_gke
+ clusters_platforms_user
+ clusters_applications_helm
+ clusters_applications_ingress
+ clusters_applications_prometheus
+ clusters_applications_runner
in_review_folder
groups
issues
@@ -96,6 +111,15 @@ describe Gitlab::UsageData do
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)
+
+ expect(count_data[:clusters_enabled]).to eq(6)
+ expect(count_data[:clusters_disabled]).to eq(1)
+ expect(count_data[:clusters_platforms_gke]).to eq(1)
+ expect(count_data[:clusters_platforms_user]).to eq(1)
+ expect(count_data[:clusters_applications_helm]).to eq(1)
+ expect(count_data[:clusters_applications_ingress]).to eq(1)
+ expect(count_data[:clusters_applications_prometheus]).to eq(1)
+ expect(count_data[:clusters_applications_runner]).to eq(1)
end
end
@@ -103,9 +127,9 @@ describe Gitlab::UsageData do
subject { described_class.features_usage_data_ce }
it 'gathers feature usage data' do
- expect(subject[:signup]).to eq(current_application_settings.allow_signup?)
+ expect(subject[:signup]).to eq(Gitlab::CurrentSettings.allow_signup?)
expect(subject[:ldap]).to eq(Gitlab.config.ldap.enabled)
- expect(subject[:gravatar]).to eq(current_application_settings.gravatar_enabled?)
+ expect(subject[:gravatar]).to eq(Gitlab::CurrentSettings.gravatar_enabled?)
expect(subject[:omniauth]).to eq(Gitlab.config.omniauth.enabled)
expect(subject[:reply_by_email]).to eq(Gitlab::IncomingEmail.enabled?)
expect(subject[:container_registry]).to eq(Gitlab.config.registry.enabled)
@@ -129,7 +153,7 @@ describe Gitlab::UsageData do
subject { described_class.license_usage_data }
it "gathers license data" do
- expect(subject[:uuid]).to eq(current_application_settings.uuid)
+ expect(subject[:uuid]).to eq(Gitlab::CurrentSettings.uuid)
expect(subject[:version]).to eq(Gitlab::VERSION)
expect(subject[:active_user_count]).to eq(User.active.count)
expect(subject[:recorded_at]).to be_a(Time)
diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb
index cd97416bcc9..40c8286b1b9 100644
--- a/spec/lib/gitlab/user_access_spec.rb
+++ b/spec/lib/gitlab/user_access_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe Gitlab::UserAccess do
+ include ProjectForksHelper
+
let(:access) { described_class.new(user, project: project) }
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
@@ -8,19 +10,19 @@ describe Gitlab::UserAccess 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]
+ project.add_master(user)
expect(access.can_push_to_branch?('random_branch')).to be_truthy
end
it 'returns true if user is a developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
expect(access.can_push_to_branch?('random_branch')).to be_truthy
end
it 'returns false if user is a reporter' do
- project.team << [user, :reporter]
+ project.add_reporter(user)
expect(access.can_push_to_branch?('random_branch')).to be_falsey
end
@@ -31,34 +33,34 @@ describe Gitlab::UserAccess do
let(:project_access) { described_class.new(user, project: empty_project) }
it 'returns true if user is master' do
- empty_project.team << [user, :master]
+ empty_project.add_master(user)
expect(project_access.can_push_to_branch?('master')).to be_truthy
end
it 'returns false if user is developer and project is fully protected' do
- empty_project.team << [user, :developer]
+ empty_project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL)
expect(project_access.can_push_to_branch?('master')).to be_falsey
end
it 'returns false if user is developer and it is not allowed to push new commits but can merge into branch' do
- empty_project.team << [user, :developer]
+ empty_project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
expect(project_access.can_push_to_branch?('master')).to be_falsey
end
it 'returns true if user is developer and project is unprotected' do
- empty_project.team << [user, :developer]
+ empty_project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE)
expect(project_access.can_push_to_branch?('master')).to be_truthy
end
it 'returns true if user is developer and project grants developers permission' do
- empty_project.team << [user, :developer]
+ empty_project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH)
expect(project_access.can_push_to_branch?('master')).to be_truthy
@@ -70,25 +72,25 @@ describe Gitlab::UserAccess do
let(:not_existing_branch) { create :protected_branch, :developers_can_merge, project: project }
it 'returns true if user is a master' do
- project.team << [user, :master]
+ project.add_master(user)
expect(access.can_push_to_branch?(branch.name)).to be_truthy
end
it 'returns false if user is a developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
expect(access.can_push_to_branch?(branch.name)).to be_falsey
end
it 'returns false if user is a reporter' do
- project.team << [user, :reporter]
+ project.add_reporter(user)
expect(access.can_push_to_branch?(branch.name)).to be_falsey
end
it 'returns false if branch does not exist' do
- project.team << [user, :developer]
+ project.add_developer(user)
expect(access.can_push_to_branch?(not_existing_branch.name)).to be_falsey
end
@@ -100,43 +102,76 @@ describe Gitlab::UserAccess do
end
it 'returns true if user is a master' do
- project.team << [user, :master]
+ project.add_master(user)
expect(access.can_push_to_branch?(@branch.name)).to be_truthy
end
it 'returns true if user is a developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
expect(access.can_push_to_branch?(@branch.name)).to be_truthy
end
it 'returns false if user is a reporter' do
- project.team << [user, :reporter]
+ project.add_reporter(user)
expect(access.can_push_to_branch?(@branch.name)).to be_falsey
end
end
+ describe 'allowing pushes to maintainers of forked projects' do
+ let(:canonical_project) { create(:project, :public, :repository) }
+ let(:project) { fork_project(canonical_project, create(:user), repository: true) }
+
+ before do
+ create(
+ :merge_request,
+ target_project: canonical_project,
+ source_project: project,
+ source_branch: 'awesome-feature',
+ allow_maintainer_to_push: true
+ )
+ end
+
+ it 'allows users that have push access to the canonical project to push to the MR branch' do
+ canonical_project.add_developer(user)
+
+ expect(access.can_push_to_branch?('awesome-feature')).to be_truthy
+ end
+
+ it 'does not allow the user to push to other branches' do
+ canonical_project.add_developer(user)
+
+ expect(access.can_push_to_branch?('master')).to be_falsey
+ end
+
+ it 'does not allow the user to push if he does not have push access to the canonical project' do
+ canonical_project.add_guest(user)
+
+ expect(access.can_push_to_branch?('awesome-feature')).to be_falsey
+ end
+ end
+
describe 'merge to protected branch if allowed for developers' do
before do
@branch = create :protected_branch, :developers_can_merge, project: project
end
it 'returns true if user is a master' do
- project.team << [user, :master]
+ project.add_master(user)
expect(access.can_merge_to_branch?(@branch.name)).to be_truthy
end
it 'returns true if user is a developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
expect(access.can_merge_to_branch?(@branch.name)).to be_truthy
end
it 'returns false if user is a reporter' do
- project.team << [user, :reporter]
+ project.add_reporter(user)
expect(access.can_merge_to_branch?(@branch.name)).to be_falsey
end
diff --git a/spec/lib/gitlab/utils/override_spec.rb b/spec/lib/gitlab/utils/override_spec.rb
new file mode 100644
index 00000000000..7c97cee982a
--- /dev/null
+++ b/spec/lib/gitlab/utils/override_spec.rb
@@ -0,0 +1,158 @@
+require 'spec_helper'
+
+describe Gitlab::Utils::Override do
+ let(:base) { Struct.new(:good) }
+
+ let(:derived) { Class.new(base).tap { |m| m.extend described_class } }
+ let(:extension) { Module.new.tap { |m| m.extend described_class } }
+
+ let(:prepending_class) { base.tap { |m| m.prepend extension } }
+ let(:including_class) { base.tap { |m| m.include extension } }
+
+ let(:klass) { subject }
+
+ def good(mod)
+ mod.module_eval do
+ override :good
+ def good
+ super.succ
+ end
+ end
+
+ mod
+ end
+
+ def bad(mod)
+ mod.module_eval do
+ override :bad
+ def bad
+ true
+ end
+ end
+
+ mod
+ end
+
+ shared_examples 'checking as intended' do
+ it 'checks ok for overriding method' do
+ good(subject)
+ result = klass.new(0).good
+
+ expect(result).to eq(1)
+ described_class.verify!
+ end
+
+ it 'raises NotImplementedError when it is not overriding anything' do
+ expect do
+ bad(subject)
+ klass.new(0).bad
+ described_class.verify!
+ end.to raise_error(NotImplementedError)
+ end
+ end
+
+ shared_examples 'nothing happened' do
+ it 'does not complain when it is overriding something' do
+ good(subject)
+ result = klass.new(0).good
+
+ expect(result).to eq(1)
+ described_class.verify!
+ end
+
+ it 'does not complain when it is not overriding anything' do
+ bad(subject)
+ result = klass.new(0).bad
+
+ expect(result).to eq(true)
+ described_class.verify!
+ end
+ end
+
+ before do
+ # Make sure we're not touching the internal cache
+ allow(described_class).to receive(:extensions).and_return({})
+ end
+
+ describe '#override' do
+ context 'when STATIC_VERIFICATION is set' do
+ before do
+ stub_env('STATIC_VERIFICATION', 'true')
+ end
+
+ context 'when subject is a class' do
+ subject { derived }
+
+ it_behaves_like 'checking as intended'
+ end
+
+ context 'when subject is a module, and class is prepending it' do
+ subject { extension }
+ let(:klass) { prepending_class }
+
+ it_behaves_like 'checking as intended'
+ end
+
+ context 'when subject is a module, and class is including it' do
+ subject { extension }
+ let(:klass) { including_class }
+
+ it 'raises NotImplementedError because it is not overriding it' do
+ expect do
+ good(subject)
+ klass.new(0).good
+ described_class.verify!
+ end.to raise_error(NotImplementedError)
+ end
+
+ it 'raises NotImplementedError when it is not overriding anything' do
+ expect do
+ bad(subject)
+ klass.new(0).bad
+ described_class.verify!
+ end.to raise_error(NotImplementedError)
+ end
+ end
+ end
+ end
+
+ context 'when STATIC_VERIFICATION is not set' do
+ before do
+ stub_env('STATIC_VERIFICATION', nil)
+ end
+
+ context 'when subject is a class' do
+ subject { derived }
+
+ it_behaves_like 'nothing happened'
+ end
+
+ context 'when subject is a module, and class is prepending it' do
+ subject { extension }
+ let(:klass) { prepending_class }
+
+ it_behaves_like 'nothing happened'
+ end
+
+ context 'when subject is a module, and class is including it' do
+ subject { extension }
+ let(:klass) { including_class }
+
+ it 'does not complain when it is overriding something' do
+ good(subject)
+ result = klass.new(0).good
+
+ expect(result).to eq(0)
+ described_class.verify!
+ end
+
+ it 'does not complain when it is not overriding anything' do
+ bad(subject)
+ result = klass.new(0).bad
+
+ expect(result).to eq(true)
+ described_class.verify!
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb
index e872a5290c5..71a743495a2 100644
--- a/spec/lib/gitlab/utils_spec.rb
+++ b/spec/lib/gitlab/utils_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::Utils do
- delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, to: :described_class
+ delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, :ensure_array_from_string, to: :described_class
describe '.slugify' do
{
@@ -17,6 +17,22 @@ describe Gitlab::Utils do
end
end
+ describe '.remove_line_breaks' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:original, :expected) do
+ "foo\nbar\nbaz" | "foobarbaz"
+ "foo\r\nbar\r\nbaz" | "foobarbaz"
+ "foobar" | "foobar"
+ end
+
+ with_them do
+ it "replace line breaks with an empty string" do
+ expect(described_class.remove_line_breaks(original)).to eq(expected)
+ end
+ end
+ end
+
describe '.to_boolean' do
it 'accepts booleans' do
expect(to_boolean(true)).to be(true)
@@ -67,4 +83,18 @@ describe Gitlab::Utils do
expect(which('sh', 'PATH' => '/bin')).to eq('/bin/sh')
end
end
+
+ describe '.ensure_array_from_string' do
+ it 'returns the same array if given one' do
+ arr = ['a', 4, true, { test: 1 }]
+
+ expect(ensure_array_from_string(arr)).to eq(arr)
+ end
+
+ it 'turns comma-separated strings into arrays' do
+ str = 'seven, eight, 9, 10'
+
+ expect(ensure_array_from_string(str)).to eq(%w[seven eight 9 10])
+ end
+ end
end
diff --git a/spec/lib/gitlab/verify/job_artifacts_spec.rb b/spec/lib/gitlab/verify/job_artifacts_spec.rb
new file mode 100644
index 00000000000..ec490bdfde2
--- /dev/null
+++ b/spec/lib/gitlab/verify/job_artifacts_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper'
+
+describe Gitlab::Verify::JobArtifacts do
+ include GitlabVerifyHelpers
+
+ it_behaves_like 'Gitlab::Verify::BatchVerifier subclass' do
+ let!(:objects) { create_list(:ci_job_artifact, 3, :archive) }
+ end
+
+ describe '#run_batches' do
+ let(:failures) { collect_failures }
+ let(:failure) { failures[artifact] }
+
+ let!(:artifact) { create(:ci_job_artifact, :archive, :correct_checksum) }
+
+ it 'passes artifacts with the correct file' do
+ expect(failures).to eq({})
+ end
+
+ it 'fails artifacts with a missing file' do
+ FileUtils.rm_f(artifact.file.path)
+
+ expect(failures.keys).to contain_exactly(artifact)
+ expect(failure).to be_a(Errno::ENOENT)
+ expect(failure.to_s).to include(artifact.file.path)
+ end
+
+ it 'fails artifacts with a mismatched checksum' do
+ File.truncate(artifact.file.path, 0)
+
+ expect(failures.keys).to contain_exactly(artifact)
+ expect(failure.to_s).to include('Checksum mismatch')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/verify/lfs_objects_spec.rb b/spec/lib/gitlab/verify/lfs_objects_spec.rb
new file mode 100644
index 00000000000..0f890e2c7ce
--- /dev/null
+++ b/spec/lib/gitlab/verify/lfs_objects_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe Gitlab::Verify::LfsObjects do
+ include GitlabVerifyHelpers
+
+ it_behaves_like 'Gitlab::Verify::BatchVerifier subclass' do
+ let!(:objects) { create_list(:lfs_object, 3, :with_file) }
+ end
+
+ describe '#run_batches' do
+ let(:failures) { collect_failures }
+ let(:failure) { failures[lfs_object] }
+
+ let!(:lfs_object) { create(:lfs_object, :with_file, :correct_oid) }
+
+ it 'passes LFS objects with the correct file' do
+ expect(failures).to eq({})
+ end
+
+ it 'fails LFS objects with a missing file' do
+ FileUtils.rm_f(lfs_object.file.path)
+
+ expect(failures.keys).to contain_exactly(lfs_object)
+ expect(failure).to be_a(Errno::ENOENT)
+ expect(failure.to_s).to include(lfs_object.file.path)
+ end
+
+ it 'fails LFS objects with a mismatched oid' do
+ File.truncate(lfs_object.file.path, 0)
+
+ expect(failures.keys).to contain_exactly(lfs_object)
+ expect(failure.to_s).to include('Checksum mismatch')
+ end
+
+ context 'with remote files' do
+ before do
+ stub_lfs_object_storage
+ end
+
+ it 'skips LFS objects in object storage' do
+ local_failure = create(:lfs_object)
+ create(:lfs_object, :object_storage)
+
+ failures = {}
+ described_class.new(batch_size: 10).run_batches { |_, failed| failures.merge!(failed) }
+
+ expect(failures.keys).to contain_exactly(local_failure)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/verify/uploads_spec.rb b/spec/lib/gitlab/verify/uploads_spec.rb
new file mode 100644
index 00000000000..85768308edc
--- /dev/null
+++ b/spec/lib/gitlab/verify/uploads_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe Gitlab::Verify::Uploads do
+ include GitlabVerifyHelpers
+
+ it_behaves_like 'Gitlab::Verify::BatchVerifier subclass' do
+ let(:projects) { create_list(:project, 3, :with_avatar) }
+ let!(:objects) { projects.flat_map(&:uploads) }
+ end
+
+ describe '#run_batches' do
+ let(:project) { create(:project, :with_avatar) }
+ let(:failures) { collect_failures }
+ let(:failure) { failures[upload] }
+
+ let!(:upload) { project.uploads.first }
+
+ it 'passes uploads with the correct file' do
+ expect(failures).to eq({})
+ end
+
+ it 'fails uploads with a missing file' do
+ FileUtils.rm_f(upload.absolute_path)
+
+ expect(failures.keys).to contain_exactly(upload)
+ expect(failure).to be_a(Errno::ENOENT)
+ expect(failure.to_s).to include(upload.absolute_path)
+ end
+
+ it 'fails uploads with a mismatched checksum' do
+ upload.update!(checksum: 'something incorrect')
+
+ expect(failures.keys).to contain_exactly(upload)
+ expect(failure.to_s).to include('Checksum mismatch')
+ end
+
+ it 'fails uploads with a missing precalculated checksum' do
+ upload.update!(checksum: '')
+
+ expect(failures.keys).to contain_exactly(upload)
+ expect(failure.to_s).to include('Checksum missing')
+ end
+
+ context 'with remote files' do
+ before do
+ stub_uploads_object_storage(AvatarUploader)
+ end
+
+ it 'skips uploads in object storage' do
+ local_failure = create(:upload)
+ create(:upload, :object_storage)
+
+ failures = {}
+ described_class.new(batch_size: 10).run_batches { |_, failed| failures.merge!(failed) }
+
+ expect(failures.keys).to contain_exactly(local_failure)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/visibility_level_spec.rb b/spec/lib/gitlab/visibility_level_spec.rb
index 48a67773de9..2c1146ceff5 100644
--- a/spec/lib/gitlab/visibility_level_spec.rb
+++ b/spec/lib/gitlab/visibility_level_spec.rb
@@ -49,4 +49,40 @@ describe Gitlab::VisibilityLevel do
.to eq([Gitlab::VisibilityLevel::PUBLIC])
end
end
+
+ describe '.allowed_levels' do
+ it 'only includes the levels that arent restricted' do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
+
+ expect(described_class.allowed_levels)
+ .to contain_exactly(described_class::PRIVATE, described_class::PUBLIC)
+ end
+
+ it 'returns all levels when no visibility level was set' do
+ allow(described_class)
+ .to receive_message_chain('current_application_settings.restricted_visibility_levels')
+ .and_return(nil)
+
+ expect(described_class.allowed_levels)
+ .to contain_exactly(described_class::PRIVATE, described_class::INTERNAL, described_class::PUBLIC)
+ end
+ end
+
+ describe '.closest_allowed_level' do
+ it 'picks INTERNAL instead of PUBLIC if public is restricted' do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
+
+ expect(described_class.closest_allowed_level(described_class::PUBLIC))
+ .to eq(described_class::INTERNAL)
+ end
+
+ it 'picks PRIVATE if nothing is available' do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC,
+ Gitlab::VisibilityLevel::INTERNAL,
+ Gitlab::VisibilityLevel::PRIVATE])
+
+ expect(described_class.closest_allowed_level(described_class::PUBLIC))
+ .to eq(described_class::PRIVATE)
+ end
+ end
end
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index 249c77dc636..d64ea72e346 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -16,7 +16,7 @@ describe Gitlab::Workhorse do
let(:ref) { 'master' }
let(:format) { 'zip' }
let(:storage_path) { Gitlab.config.gitlab.repository_downloads_path }
- let(:base_params) { repository.archive_metadata(ref, storage_path, format) }
+ let(:base_params) { repository.archive_metadata(ref, storage_path, format, append_sha: nil) }
let(:gitaly_params) do
base_params.merge(
'GitalyServer' => {
@@ -26,9 +26,14 @@ describe Gitlab::Workhorse do
'GitalyRepository' => repository.gitaly_repository.to_h.deep_stringify_keys
)
end
+ let(:cache_disabled) { false }
subject do
- described_class.send_git_archive(repository, ref: ref, format: format)
+ described_class.send_git_archive(repository, ref: ref, format: format, append_sha: nil)
+ end
+
+ before do
+ allow(described_class).to receive(:git_archive_cache_disabled?).and_return(cache_disabled)
end
context 'when Gitaly workhorse_archive feature is enabled' do
@@ -39,9 +44,18 @@ describe Gitlab::Workhorse do
expect(command).to eq('git-archive')
expect(params).to include(gitaly_params)
end
+
+ context 'when archive caching is disabled' do
+ let(:cache_disabled) { true }
+
+ it 'tells workhorse not to use the cache' do
+ _, _, params = decode_workhorse_header(subject)
+ expect(params).to include({ 'DisableCache' => true })
+ end
+ end
end
- context 'when Gitaly workhorse_archive feature is disabled', :skip_gitaly_mock do
+ context 'when Gitaly workhorse_archive feature is disabled', :disable_gitaly do
it 'sets the header correctly' do
key, command, params = decode_workhorse_header(subject)
@@ -86,7 +100,7 @@ describe Gitlab::Workhorse do
end
end
- context 'when Gitaly workhorse_send_git_patch feature is disabled', :skip_gitaly_mock do
+ context 'when Gitaly workhorse_send_git_patch feature is disabled', :disable_gitaly do
it 'sets the header correctly' do
key, command, params = decode_workhorse_header(subject)
@@ -159,7 +173,7 @@ describe Gitlab::Workhorse do
end
end
- context 'when Gitaly workhorse_send_git_diff feature is disabled', :skip_gitaly_mock do
+ context 'when Gitaly workhorse_send_git_diff feature is disabled', :disable_gitaly do
it 'sets the header correctly' do
key, command, params = decode_workhorse_header(subject)
@@ -261,7 +275,7 @@ describe Gitlab::Workhorse do
describe '.git_http_ok' do
let(:user) { create(:user) }
- let(:repo_path) { repository.path_to_repo }
+ let(:repo_path) { 'ignored but not allowed to be empty in gitlab-workhorse' }
let(:action) { 'info_refs' }
let(:params) do
{
@@ -310,7 +324,7 @@ describe Gitlab::Workhorse do
it 'includes a Repository param' do
repo_param = {
storage_name: 'default',
- relative_path: project.full_path + '.git',
+ relative_path: project.disk_path + '.git',
gl_repository: "project-#{project.id}"
}
@@ -441,7 +455,7 @@ describe Gitlab::Workhorse do
end
end
- context 'when Gitaly workhorse_raw_show feature is disabled', :skip_gitaly_mock do
+ context 'when Gitaly workhorse_raw_show feature is disabled', :disable_gitaly do
it 'sets the header correctly' do
key, command, params = decode_workhorse_header(subject)
@@ -451,4 +465,21 @@ describe Gitlab::Workhorse do
end
end
end
+
+ describe '.send_url' do
+ let(:url) { 'http://example.com' }
+
+ subject { described_class.send_url(url) }
+
+ 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("send-url")
+ expect(params).to eq({
+ 'URL' => url,
+ 'AllowRedirects' => false
+ }.deep_stringify_keys)
+ end
+ end
end
diff --git a/spec/lib/google_api/cloud_platform/client_spec.rb b/spec/lib/google_api/cloud_platform/client_spec.rb
index ecb4034ec8b..db9d9158b29 100644
--- a/spec/lib/google_api/cloud_platform/client_spec.rb
+++ b/spec/lib/google_api/cloud_platform/client_spec.rb
@@ -50,6 +50,30 @@ describe GoogleApi::CloudPlatform::Client do
end
end
+ describe '#projects_list' do
+ subject { client.projects_list }
+ let(:projects) { double }
+
+ before do
+ allow_any_instance_of(Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService)
+ .to receive(:fetch_all).and_return(projects)
+ end
+
+ it { is_expected.to eq(projects) }
+ end
+
+ describe '#projects_get_billing_info' do
+ subject { client.projects_get_billing_info('project') }
+ let(:billing_info) { double }
+
+ before do
+ allow_any_instance_of(Google::Apis::CloudbillingV1::CloudbillingService)
+ .to receive(:get_project_billing_info).and_return(billing_info)
+ end
+
+ it { is_expected.to eq(billing_info) }
+ end
+
describe '#projects_zones_clusters_get' do
subject { client.projects_zones_clusters_get(spy, spy, spy) }
let(:gke_cluster) { double }
@@ -91,6 +115,9 @@ describe GoogleApi::CloudPlatform::Client do
"initial_node_count": cluster_size,
"node_config": {
"machine_type": machine_type
+ },
+ "legacy_abac": {
+ "enabled": true
}
}
} )
diff --git a/spec/lib/mattermost/command_spec.rb b/spec/lib/mattermost/command_spec.rb
index 369e7b181b9..8ba15ae0f38 100644
--- a/spec/lib/mattermost/command_spec.rb
+++ b/spec/lib/mattermost/command_spec.rb
@@ -4,10 +4,11 @@ describe Mattermost::Command do
let(:params) { { 'token' => 'token', team_id: 'abc' } }
before do
- Mattermost::Session.base_uri('http://mattermost.example.com')
+ session = Mattermost::Session.new(nil)
+ session.base_uri = 'http://mattermost.example.com'
allow_any_instance_of(Mattermost::Client).to receive(:with_session)
- .and_yield(Mattermost::Session.new(nil))
+ .and_yield(session)
end
describe '#create' do
diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb
index 3db19d06305..c855643c4d8 100644
--- a/spec/lib/mattermost/session_spec.rb
+++ b/spec/lib/mattermost/session_spec.rb
@@ -15,7 +15,7 @@ describe Mattermost::Session, type: :request do
it { is_expected.to respond_to(:strategy) }
before do
- described_class.base_uri(mattermost_url)
+ subject.base_uri = mattermost_url
end
describe '#with session' do
diff --git a/spec/lib/mattermost/team_spec.rb b/spec/lib/mattermost/team_spec.rb
index e638ad7a2c9..2cfa6802612 100644
--- a/spec/lib/mattermost/team_spec.rb
+++ b/spec/lib/mattermost/team_spec.rb
@@ -2,10 +2,11 @@ require 'spec_helper'
describe Mattermost::Team do
before do
- Mattermost::Session.base_uri('http://mattermost.example.com')
+ session = Mattermost::Session.new(nil)
+ session.base_uri = 'http://mattermost.example.com'
allow_any_instance_of(Mattermost::Client).to receive(:with_session)
- .and_yield(Mattermost::Session.new(nil))
+ .and_yield(session)
end
describe '#all' do
@@ -64,4 +65,108 @@ describe Mattermost::Team do
end
end
end
+
+ describe '#create' do
+ subject { described_class.new(nil).create(name: "devteam", display_name: "Dev Team", type: "O") }
+
+ context 'for a new team' do
+ let(:response) do
+ {
+ "id" => "cuojfcetjty7tb4pxe47pwpndo",
+ "create_at" => 1517688728701,
+ "update_at" => 1517688728701,
+ "delete_at" => 0,
+ "display_name" => "Dev Team",
+ "name" => "devteam",
+ "description" => "",
+ "email" => "admin@example.com",
+ "type" => "O",
+ "company_name" => "",
+ "allowed_domains" => "",
+ "invite_id" => "7mp9d3ayaj833ymmkfnid8js6w",
+ "allow_open_invite" => false
+ }
+ end
+
+ before do
+ stub_request(:post, "http://mattermost.example.com/api/v3/teams/create")
+ .to_return(
+ status: 200,
+ body: response.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the new team' do
+ is_expected.to eq(response)
+ end
+ end
+
+ context 'for existing team' do
+ before do
+ stub_request(:post, 'http://mattermost.example.com/api/v3/teams/create')
+ .to_return(
+ status: 400,
+ headers: { 'Content-Type' => 'application/json' },
+ body: {
+ id: "store.sql_team.save.domain_exists.app_error",
+ message: "A team with that name already exists",
+ detailed_error: "",
+ request_id: "1hsb5bxs97r8bdggayy7n9gxaw",
+ status_code: 400
+ }.to_json
+ )
+ end
+
+ it 'raises an error with message' do
+ expect { subject }.to raise_error(Mattermost::Error, 'A team with that name already exists')
+ end
+ end
+ end
+
+ describe '#delete' do
+ subject { described_class.new(nil).destroy(team_id: "cuojfcetjty7tb4pxe47pwpndo") }
+
+ context 'for an existing team' do
+ let(:response) do
+ {
+ "status" => "OK"
+ }
+ end
+
+ before do
+ stub_request(:delete, "http://mattermost.example.com/api/v4/teams/cuojfcetjty7tb4pxe47pwpndo")
+ .to_return(
+ status: 200,
+ body: response.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns team status' do
+ is_expected.to eq(response)
+ end
+ end
+
+ context 'for an unknown team' do
+ before do
+ stub_request(:delete, "http://mattermost.example.com/api/v4/teams/cuojfcetjty7tb4pxe47pwpndo")
+ .to_return(
+ status: 404,
+ body: {
+ id: "store.sql_team.get.find.app_error",
+ message: "We couldn't find the existing team",
+ detailed_error: "",
+ request_id: "my114ab5nbnui8c9pes4kz8mza",
+ status_code: 404
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'raises an error with message' do
+ expect { subject }.to raise_error(Mattermost::Error, "We couldn't find the existing team")
+ end
+ end
+ end
end
diff --git a/spec/lib/repository_cache_spec.rb b/spec/lib/repository_cache_spec.rb
deleted file mode 100644
index 8b0c7254b5e..00000000000
--- a/spec/lib/repository_cache_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-require 'spec_helper'
-
-describe RepositoryCache do
- let(:project) { create(:project) }
- let(:backend) { double('backend').as_null_object }
- let(:cache) { described_class.new('example', project.id, backend) }
-
- describe '#cache_key' do
- it 'includes the namespace' do
- expect(cache.cache_key(:foo)).to eq "foo:example:#{project.id}"
- end
- end
-
- describe '#expire' do
- it 'expires the given key from the cache' do
- cache.expire(:foo)
- expect(backend).to have_received(:delete).with("foo:example:#{project.id}")
- end
- end
-
- describe '#fetch' do
- it 'fetches the given key from the cache' do
- cache.fetch(:bar)
- expect(backend).to have_received(:fetch).with("bar:example:#{project.id}")
- end
-
- it 'accepts a block' do
- p = -> {}
-
- cache.fetch(:baz, &p)
- expect(backend).to have_received(:fetch).with("baz:example:#{project.id}", &p)
- end
- end
-end
diff --git a/spec/lib/uploaded_file_spec.rb b/spec/lib/uploaded_file_spec.rb
new file mode 100644
index 00000000000..cc99e7e8911
--- /dev/null
+++ b/spec/lib/uploaded_file_spec.rb
@@ -0,0 +1,116 @@
+require 'spec_helper'
+
+describe UploadedFile do
+ describe ".from_params" do
+ let(:temp_dir) { Dir.tmpdir }
+ let(:temp_file) { Tempfile.new("test", temp_dir) }
+ let(:upload_path) { nil }
+
+ subject do
+ described_class.from_params(params, :file, upload_path)
+ end
+
+ before do
+ FileUtils.touch(temp_file)
+ end
+
+ after do
+ FileUtils.rm_f(temp_file)
+ FileUtils.rm_r(upload_path) if upload_path
+ end
+
+ context 'when valid file is specified' do
+ context 'only local path is specified' do
+ let(:params) do
+ { 'file.path' => temp_file.path }
+ end
+
+ it "succeeds" do
+ is_expected.not_to be_nil
+ end
+
+ it "generates filename from path" do
+ expect(subject.original_filename).to eq(::File.basename(temp_file.path))
+ end
+ end
+
+ context 'all parameters are specified' do
+ let(:params) do
+ { 'file.path' => temp_file.path,
+ 'file.name' => 'my_file.txt',
+ 'file.type' => 'my/type',
+ 'file.sha256' => 'sha256',
+ 'file.remote_id' => 'remote_id' }
+ end
+
+ it "succeeds" do
+ is_expected.not_to be_nil
+ end
+
+ it "generates filename from path" do
+ expect(subject.original_filename).to eq('my_file.txt')
+ expect(subject.content_type).to eq('my/type')
+ expect(subject.sha256).to eq('sha256')
+ expect(subject.remote_id).to eq('remote_id')
+ end
+ end
+ end
+
+ context 'when no params are specified' do
+ let(:params) do
+ {}
+ end
+
+ it "does not return an object" do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'when only remote id is specified' do
+ let(:params) do
+ { 'file.remote_id' => 'remote_id' }
+ end
+
+ it "raises an error" do
+ expect { subject }.to raise_error(UploadedFile::InvalidPathError, /file is invalid/)
+ end
+ end
+
+ context 'when verifying allowed paths' do
+ let(:params) do
+ { 'file.path' => temp_file.path }
+ end
+
+ context 'when file is stored in system temporary folder' do
+ let(:temp_dir) { Dir.tmpdir }
+
+ it "succeeds" do
+ is_expected.not_to be_nil
+ end
+ end
+
+ context 'when file is stored in user provided upload path' do
+ let(:upload_path) { Dir.mktmpdir }
+ let(:temp_dir) { upload_path }
+
+ it "succeeds" do
+ is_expected.not_to be_nil
+ end
+ end
+
+ context 'when file is stored outside of user provided upload path' do
+ let!(:generated_dir) { Dir.mktmpdir }
+ let!(:temp_dir) { Dir.mktmpdir }
+
+ before do
+ # We overwrite default temporary path
+ allow(Dir).to receive(:tmpdir).and_return(generated_dir)
+ end
+
+ it "raises an error" do
+ expect { subject }.to raise_error(UploadedFile::InvalidPathError, /insecure path used/)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/mailers/emails/pages_domains_spec.rb b/spec/mailers/emails/pages_domains_spec.rb
new file mode 100644
index 00000000000..fe428ea657d
--- /dev/null
+++ b/spec/mailers/emails/pages_domains_spec.rb
@@ -0,0 +1,71 @@
+require 'spec_helper'
+require 'email_spec'
+
+describe Emails::PagesDomains do
+ include EmailSpec::Matchers
+ include_context 'gitlab email notification'
+
+ set(:project) { create(:project) }
+ set(:domain) { create(:pages_domain, project: project) }
+ set(:user) { project.owner }
+
+ shared_examples 'a pages domain email' do
+ 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 'has the expected content' do
+ aggregate_failures do
+ is_expected.to have_subject(email_subject)
+ is_expected.to have_body_text(project.human_name)
+ is_expected.to have_body_text(domain.domain)
+ is_expected.to have_body_text domain.url
+ is_expected.to have_body_text project_pages_domain_url(project, domain)
+ is_expected.to have_body_text help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ end
+ end
+ end
+
+ describe '#pages_domain_enabled_email' do
+ let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been enabled" }
+
+ subject { Notify.pages_domain_enabled_email(domain, user) }
+
+ it_behaves_like 'a pages domain email'
+
+ it { is_expected.to have_body_text 'has been enabled' }
+ end
+
+ describe '#pages_domain_disabled_email' do
+ let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been disabled" }
+
+ subject { Notify.pages_domain_disabled_email(domain, user) }
+
+ it_behaves_like 'a pages domain email'
+
+ it { is_expected.to have_body_text 'has been disabled' }
+ end
+
+ describe '#pages_domain_verification_succeeded_email' do
+ let(:email_subject) { "#{project.path} | Verification succeeded for GitLab Pages domain '#{domain.domain}'" }
+
+ subject { Notify.pages_domain_verification_succeeded_email(domain, user) }
+
+ it_behaves_like 'a pages domain email'
+
+ it { is_expected.to have_body_text 'successfully verified' }
+ end
+
+ describe '#pages_domain_verification_failed_email' do
+ let(:email_subject) { "#{project.path} | ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'" }
+
+ subject { Notify.pages_domain_verification_failed_email(domain, user) }
+
+ it_behaves_like 'a pages domain email'
+
+ it 'says verification has failed and when the domain is enabled until' do
+ is_expected.to have_body_text 'Verification has failed'
+ is_expected.to have_body_text domain.enabled_until.strftime('%F %T')
+ end
+ end
+end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 4d0a3942996..43e419cd7de 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -71,6 +71,18 @@ describe Notify do
is_expected.to have_html_escaped_body_text issue.description
end
+ it 'does not add a reason header' do
+ is_expected.not_to have_header('X-GitLab-NotificationReason', /.+/)
+ end
+
+ context 'when sent with a reason' do
+ subject { described_class.new_issue_email(issue.assignees.first.id, issue.id, NotificationReason::ASSIGNED) }
+
+ it 'includes the reason in a header' do
+ is_expected.to have_header('X-GitLab-NotificationReason', NotificationReason::ASSIGNED)
+ end
+ end
+
context 'when enabled email_author_in_body' do
before do
stub_application_setting(email_author_in_body: true)
@@ -108,6 +120,14 @@ describe Notify do
is_expected.to have_body_text(project_issue_path(project, issue))
end
end
+
+ context 'when sent with a reason' do
+ subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id, NotificationReason::ASSIGNED) }
+
+ it 'includes the reason in a header' do
+ is_expected.to have_header('X-GitLab-NotificationReason', NotificationReason::ASSIGNED)
+ end
+ end
end
describe 'that have been relabeled' do
@@ -226,6 +246,14 @@ describe Notify do
is_expected.to have_html_escaped_body_text merge_request.description
end
+ context 'when sent with a reason' do
+ subject { described_class.new_merge_request_email(merge_request.assignee_id, merge_request.id, NotificationReason::ASSIGNED) }
+
+ it 'includes the reason in a header' do
+ is_expected.to have_header('X-GitLab-NotificationReason', NotificationReason::ASSIGNED)
+ end
+ end
+
context 'when enabled email_author_in_body' do
before do
stub_application_setting(email_author_in_body: true)
@@ -263,6 +291,27 @@ describe Notify do
is_expected.to have_html_escaped_body_text(assignee.name)
end
end
+
+ context 'when sent with a reason' do
+ subject { described_class.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id, NotificationReason::ASSIGNED) }
+
+ it 'includes the reason in a header' do
+ is_expected.to have_header('X-GitLab-NotificationReason', NotificationReason::ASSIGNED)
+ end
+
+ it 'includes the reason in the footer' do
+ text = EmailsHelper.instance_method(:notification_reason_text).bind(self).call(NotificationReason::ASSIGNED)
+ is_expected.to have_body_text(text)
+
+ new_subject = described_class.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id, NotificationReason::MENTIONED)
+ text = EmailsHelper.instance_method(:notification_reason_text).bind(self).call(NotificationReason::MENTIONED)
+ expect(new_subject).to have_body_text(text)
+
+ new_subject = described_class.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id, nil)
+ text = EmailsHelper.instance_method(:notification_reason_text).bind(self).call(nil)
+ expect(new_subject).to have_body_text(text)
+ end
+ end
end
describe 'that have been relabeled' do
@@ -340,6 +389,48 @@ describe Notify do
end
end
end
+
+ shared_examples 'a push to an existing merge request' do
+ let(:push_user) { create(:user) }
+
+ subject do
+ described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: merge_request.commits, existing_commits: existing_commits)
+ end
+
+ it_behaves_like 'a multiple recipients email'
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { merge_request }
+ end
+ it_behaves_like 'it should show Gmail Actions View Merge request link'
+ it_behaves_like 'an unsubscribeable thread'
+
+ it 'is sent as the push user' do
+ sender = subject.header[:from].addrs[0]
+
+ expect(sender.display_name).to eq(push_user.name)
+ expect(sender.address).to eq(gitlab_sender)
+ end
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(merge_request, reply: true)
+ is_expected.to have_body_text("#{push_user.name} pushed new commits")
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
+ end
+ end
+ end
+
+ describe 'that have new commits' do
+ let(:existing_commits) { [] }
+
+ it_behaves_like 'a push to an existing merge request'
+ end
+
+ describe 'that have new commits on top of an existing one' do
+ let(:existing_commits) { [merge_request.commits.first] }
+
+ it_behaves_like 'a push to an existing merge request'
+ end
end
context 'for issue notes' do
@@ -408,65 +499,36 @@ describe Notify do
it 'has the correct subject and body' do
is_expected.to have_subject("#{project.name} | Project was moved")
- is_expected.to have_html_escaped_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.full_name
is_expected.to have_body_text(project.ssh_url_to_repo)
end
end
describe 'project access requested' do
- context 'for a project in a user namespace' do
- let(:project) do
- create(:project, :public, :access_requestable) do |project|
- project.team << [project.owner, :master, project.owner]
- end
- end
-
- let(:project_member) do
- project.request_access(user)
- project.requesters.find_by(user_id: user.id)
- end
- 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'
- it_behaves_like "a user cannot unsubscribe through footer link"
-
- it 'contains all the useful information' do
- to_emails = subject.header[:to].addrs
- expect(to_emails.size).to eq(1)
- expect(to_emails[0].address).to eq(project.members.owners_and_masters.first.user.notification_email)
-
- 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 project_project_members_url(project)
- is_expected.to have_body_text project_member.human_access
+ let(:project) do
+ create(:project, :public, :access_requestable) do |project|
+ project.add_master(project.owner)
end
end
- 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(:project, :public, :access_requestable, namespace: group) }
- let(:project_member) do
- project.request_access(user)
- project.requesters.find_by(user_id: user.id)
- end
- subject { described_class.member_access_requested_email('project', project_member.id) }
+ let(:project_member) do
+ project.request_access(user)
+ project.requesters.find_by(user_id: user.id)
+ end
+ subject { described_class.member_access_requested_email('project', project_member.id, recipient.notification_email) }
- 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 'contains all the useful information' do
- to_emails = subject.header[:to].addrs
- expect(to_emails.size).to eq(1)
- expect(to_emails[0].address).to eq(group.members.owners_and_masters.first.user.notification_email)
+ it 'contains all the useful information' do
+ to_emails = subject.header[:to].addrs.map(&:address)
+ expect(to_emails).to eq([recipient.notification_email])
- 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 project_project_members_url(project)
- is_expected.to have_body_text project_member.human_access
- end
+ is_expected.to have_subject "Request to join the #{project.full_name} project"
+ is_expected.to have_html_escaped_body_text project.full_name
+ is_expected.to have_body_text project_project_members_url(project)
+ is_expected.to have_body_text project_member.human_access
end
end
@@ -483,8 +545,8 @@ describe Notify do
it_behaves_like "a user cannot unsubscribe through footer link"
it 'contains all the useful information' do
- is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied"
- is_expected.to have_html_escaped_body_text project.name_with_namespace
+ is_expected.to have_subject "Access to the #{project.full_name} project was denied"
+ is_expected.to have_html_escaped_body_text project.full_name
is_expected.to have_body_text project.web_url
end
end
@@ -500,8 +562,8 @@ describe Notify do
it_behaves_like "a user cannot unsubscribe through footer link"
it 'contains all the useful information' do
- is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted"
- is_expected.to have_html_escaped_body_text project.name_with_namespace
+ is_expected.to have_subject "Access to the #{project.full_name} project was granted"
+ is_expected.to have_html_escaped_body_text project.full_name
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.human_access
end
@@ -520,7 +582,7 @@ describe Notify do
end
describe 'project invitation' do
- let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:master) { create(:user).tap { |u| project.add_master(u) } }
let(:project_member) { invite_to_project(project, inviter: master) }
subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) }
@@ -530,8 +592,8 @@ describe Notify do
it_behaves_like "a user cannot unsubscribe through footer link"
it 'contains all the useful information' do
- is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project"
- is_expected.to have_html_escaped_body_text project.name_with_namespace
+ is_expected.to have_subject "Invitation to join the #{project.full_name} project"
+ is_expected.to have_html_escaped_body_text project.full_name
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.human_access
is_expected.to have_body_text project_member.invite_token
@@ -540,7 +602,7 @@ describe Notify do
describe 'project invitation accepted' do
let(:invited_user) { create(:user, name: 'invited user') }
- let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:master) { create(:user).tap { |u| project.add_master(u) } }
let(:project_member) do
invitee = invite_to_project(project, inviter: master)
invitee.accept_invite!(invited_user)
@@ -555,7 +617,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject 'Invitation accepted'
- is_expected.to have_html_escaped_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.full_name
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.invite_email
is_expected.to have_html_escaped_body_text invited_user.name
@@ -563,7 +625,7 @@ describe Notify do
end
describe 'project invitation declined' do
- let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:master) { create(:user).tap { |u| project.add_master(u) } }
let(:project_member) do
invitee = invite_to_project(project, inviter: master)
invitee.decline_invite!
@@ -578,7 +640,7 @@ describe Notify do
it 'contains all the useful information' do
is_expected.to have_subject 'Invitation declined'
- is_expected.to have_html_escaped_body_text project.name_with_namespace
+ is_expected.to have_html_escaped_body_text project.full_name
is_expected.to have_body_text project.web_url
is_expected.to have_body_text project_member.invite_email
end
@@ -910,13 +972,16 @@ describe Notify do
group.request_access(user)
group.requesters.find_by(user_id: user.id)
end
- subject { described_class.member_access_requested_email('group', group_member.id) }
+ subject { described_class.member_access_requested_email('group', group_member.id, recipient.notification_email) }
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 'contains all the useful information' do
+ to_emails = subject.header[:to].addrs.map(&:address)
+ expect(to_emails).to eq([recipient.notification_email])
+
is_expected.to have_subject "Request to join the #{group.name} group"
is_expected.to have_html_escaped_body_text group.name
is_expected.to have_body_text group_group_members_url(group)
@@ -1308,7 +1373,7 @@ describe Notify do
matcher :have_part_with do |expected|
match do |actual|
- actual.body.parts.any? { |part| part.content_type.try(:match, %r(#{expected})) }
+ actual.body.parts.any? { |part| part.content_type.try(:match, /#{expected}/) }
end
end
end
diff --git a/spec/mailers/previews/email_rejection_mailer_preview.rb b/spec/mailers/previews/email_rejection_mailer_preview.rb
new file mode 100644
index 00000000000..639e8471232
--- /dev/null
+++ b/spec/mailers/previews/email_rejection_mailer_preview.rb
@@ -0,0 +1,5 @@
+class EmailRejectionMailerPreview < ActionMailer::Preview
+ def rejection
+ EmailRejectionMailer.rejection("some rejection reason", "From: someone@example.com\nraw email here").message
+ end
+end
diff --git a/spec/mailers/previews/notify_preview.rb b/spec/mailers/previews/notify_preview.rb
index 580f0d56a92..e32fd0bd120 100644
--- a/spec/mailers/previews/notify_preview.rb
+++ b/spec/mailers/previews/notify_preview.rb
@@ -58,14 +58,87 @@ class NotifyPreview < ActionMailer::Preview
end
end
+ def closed_issue_email
+ Notify.closed_issue_email(user.id, issue.id, user.id).message
+ end
+
+ def issue_status_changed_email
+ Notify.issue_status_changed_email(user.id, issue.id, 'closed', user.id).message
+ end
+
+ def closed_merge_request_email
+ Notify.closed_merge_request_email(user.id, issue.id, user.id).message
+ end
+
+ def merge_request_status_email
+ Notify.merge_request_status_email(user.id, merge_request.id, 'closed', user.id).message
+ end
+
+ def merged_merge_request_email
+ Notify.merged_merge_request_email(user.id, merge_request.id, user.id).message
+ end
+
+ def member_access_denied_email
+ Notify.member_access_denied_email('project', project.id, user.id).message
+ end
+
+ def member_access_granted_email
+ Notify.member_access_granted_email('project', user.id).message
+ end
+
+ def member_access_requested_email
+ Notify.member_access_requested_email('group', user.id, 'some@example.com').message
+ end
+
+ def member_invite_accepted_email
+ Notify.member_invite_accepted_email('project', user.id).message
+ end
+
+ def member_invite_declined_email
+ Notify.member_invite_declined_email(
+ 'project',
+ project.id,
+ 'invite@example.com',
+ user.id
+ ).message
+ end
+
+ def member_invited_email
+ Notify.member_invited_email('project', user.id, '1234').message
+ end
+
+ def pages_domain_enabled_email
+ cleanup do
+ pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now)
+
+ Notify.pages_domain_enabled_email(pages_domain, user).message
+ end
+ end
+
+ def pipeline_success_email
+ Notify.pipeline_success_email(pipeline, pipeline.user.try(:email))
+ end
+
+ def pipeline_failed_email
+ Notify.pipeline_failed_email(pipeline, pipeline.user.try(:email))
+ end
+
private
def project
@project ||= Project.find_by_full_path('gitlab-org/gitlab-test')
end
+ def issue
+ @merge_request ||= project.issues.first
+ end
+
def merge_request
- @merge_request ||= project.merge_requests.find_by(source_branch: 'master', target_branch: 'feature')
+ @merge_request ||= project.merge_requests.first
+ end
+
+ def pipeline
+ @pipeline = Ci::Pipeline.last
end
def user
@@ -94,14 +167,4 @@ class NotifyPreview < ActionMailer::Preview
email
end
-
- def pipeline_success_email
- pipeline = Ci::Pipeline.last
- Notify.pipeline_success_email(pipeline, pipeline.user.try(:email))
- end
-
- def pipeline_failed_email
- pipeline = Ci::Pipeline.last
- Notify.pipeline_failed_email(pipeline, pipeline.user.try(:email))
- end
end
diff --git a/spec/mailers/previews/repository_check_mailer_preview.rb b/spec/mailers/previews/repository_check_mailer_preview.rb
new file mode 100644
index 00000000000..19d4eab1805
--- /dev/null
+++ b/spec/mailers/previews/repository_check_mailer_preview.rb
@@ -0,0 +1,5 @@
+class RepositoryCheckMailerPreview < ActionMailer::Preview
+ def notify
+ RepositoryCheckMailer.notify(3).message
+ end
+end
diff --git a/spec/migrations/README.md b/spec/migrations/README.md
index 45cf25b96de..49760fa62b8 100644
--- a/spec/migrations/README.md
+++ b/spec/migrations/README.md
@@ -89,5 +89,5 @@ 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
+a deletion database cleanup strategy. Do not depend on transaction being
present.
diff --git a/spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb b/spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb
new file mode 100644
index 00000000000..4395e2f8264
--- /dev/null
+++ b/spec/migrations/active_record/schedule_set_confidential_note_events_on_services_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180122154930_schedule_set_confidential_note_events_on_services.rb')
+
+describe ScheduleSetConfidentialNoteEventsOnServices, :migration, :sidekiq do
+ let(:services_table) { table(:services) }
+ let(:migration_class) { Gitlab::BackgroundMigration::SetConfidentialNoteEventsOnServices }
+ let(:migration_name) { migration_class.to_s.demodulize }
+
+ let!(:service_1) { services_table.create!(confidential_note_events: nil, note_events: true) }
+ let!(:service_2) { services_table.create!(confidential_note_events: nil, note_events: true) }
+ let!(:service_migrated) { services_table.create!(confidential_note_events: true, note_events: true) }
+ let!(:service_skip) { services_table.create!(confidential_note_events: nil, note_events: false) }
+ let!(:service_new) { services_table.create!(confidential_note_events: false, note_events: true) }
+ let!(:service_4) { services_table.create!(confidential_note_events: nil, note_events: true) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+ end
+
+ it 'schedules background migrations at correct time' do
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(migration_name).to be_scheduled_delayed_migration(20.minutes, service_1.id, service_1.id)
+ expect(migration_name).to be_scheduled_delayed_migration(40.minutes, service_2.id, service_2.id)
+ expect(migration_name).to be_scheduled_delayed_migration(60.minutes, service_4.id, service_4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ end
+ end
+ end
+
+ it 'correctly processes services' do
+ Sidekiq::Testing.inline! do
+ expect(services_table.where(confidential_note_events: nil).count).to eq 4
+ expect(services_table.where(confidential_note_events: true).count).to eq 1
+
+ migrate!
+
+ expect(services_table.where(confidential_note_events: nil).count).to eq 1
+ expect(services_table.where(confidential_note_events: true).count).to eq 4
+ end
+ end
+end
diff --git a/spec/migrations/add_foreign_keys_to_todos_spec.rb b/spec/migrations/add_foreign_keys_to_todos_spec.rb
new file mode 100644
index 00000000000..bf2fa5c0f56
--- /dev/null
+++ b/spec/migrations/add_foreign_keys_to_todos_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20180201110056_add_foreign_keys_to_todos.rb')
+
+describe AddForeignKeysToTodos, :migration do
+ let(:todos) { table(:todos) }
+
+ let(:project) { create(:project) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let(:user) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ context 'add foreign key on user_id' do
+ let!(:todo_with_user) { create_todo(user_id: user.id) }
+ let!(:todo_without_user) { create_todo(user_id: 4711) }
+
+ it 'removes orphaned todos without corresponding user' do
+ expect { migrate! }.to change { Todo.count }.from(2).to(1)
+ end
+
+ it 'does not remove entries with valid user_id' do
+ expect { migrate! }.not_to change { todo_with_user.reload }
+ end
+ end
+
+ context 'add foreign key on author_id' do
+ let!(:todo_with_author) { create_todo(author_id: user.id) }
+ let!(:todo_with_invalid_author) { create_todo(author_id: 4711) }
+
+ it 'removes orphaned todos by author_id' do
+ expect { migrate! }.to change { Todo.count }.from(2).to(1)
+ end
+
+ it 'does not touch author_id for valid entries' do
+ expect { migrate! }.not_to change { todo_with_author.reload }
+ end
+ end
+
+ context 'add foreign key on note_id' do
+ let(:note) { create(:note) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:todo_with_note) { create_todo(note_id: note.id) }
+ let!(:todo_with_invalid_note) { create_todo(note_id: 4711) }
+ let!(:todo_without_note) { create_todo(note_id: nil) }
+
+ it 'deletes todo if note_id is set but does not exist in notes table' do
+ expect { migrate! }.to change { Todo.count }.from(3).to(2)
+ end
+
+ it 'does not touch entry if note_id is nil' do
+ expect { migrate! }.not_to change { todo_without_note.reload }
+ end
+
+ it 'does not touch note_id for valid entries' do
+ expect { migrate! }.not_to change { todo_with_note.reload }
+ end
+ end
+
+ def create_todo(**opts)
+ todos.create!(
+ project_id: project.id,
+ user_id: user.id,
+ author_id: user.id,
+ target_type: '',
+ action: 0,
+ state: '', **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
index 84c2e9f7e52..d8dd7a2fb83 100644
--- a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb
+++ b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb
@@ -1,23 +1,23 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170508170547_add_head_pipeline_for_each_merge_request.rb')
-describe AddHeadPipelineForEachMergeRequest, :truncate do
+describe AddHeadPipelineForEachMergeRequest, :delete do
include ProjectForksHelper
let(:migration) { described_class.new }
- let!(:project) { create(:project) }
+ let!(:project) { create(:project) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
let!(:other_project) { fork_project(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!(:pipeline_1) { create(:ci_pipeline, project: project, ref: "branch_1") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:pipeline_2) { create(:ci_pipeline, project: other_project, ref: "branch_1") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:pipeline_3) { create(:ci_pipeline, project: other_project, ref: "branch_1") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:pipeline_4) { create(:ci_pipeline, project: project, ref: "branch_2") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
- 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") }
+ let!(:mr_1) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_1", target_branch: "target_1") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:mr_2) { create(:merge_request, source_project: other_project, target_project: project, source_branch: "branch_1", target_branch: "target_2") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:mr_3) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_2", target_branch: "master") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:mr_4) { create(:merge_request, source_project: project, target_project: project, source_branch: "branch_3", target_branch: "master") } # rubocop:disable RSpec/FactoriesInMigrationSpecs
context "#up" do
context "when source_project and source_branch of pipeline are the same of merge request" do
diff --git a/spec/migrations/calculate_conv_dev_index_percentages_spec.rb b/spec/migrations/calculate_conv_dev_index_percentages_spec.rb
index 597d8eab51c..19f06810e54 100644
--- a/spec/migrations/calculate_conv_dev_index_percentages_spec.rb
+++ b/spec/migrations/calculate_conv_dev_index_percentages_spec.rb
@@ -3,10 +3,10 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170803090603_calculate_conv_dev_index_percentages.rb')
-describe CalculateConvDevIndexPercentages, truncate: true do
+describe CalculateConvDevIndexPercentages, :delete do
let(:migration) { described_class.new }
let!(:conv_dev_index) do
- create(:conversational_development_index_metric,
+ create(:conversational_development_index_metric, # rubocop:disable RSpec/FactoriesInMigrationSpecs
leader_notes: 0,
instance_milestones: 0,
percentage_issues: 0,
diff --git a/spec/migrations/clean_up_for_members_spec.rb b/spec/migrations/clean_up_for_members_spec.rb
new file mode 100644
index 00000000000..0258860d169
--- /dev/null
+++ b/spec/migrations/clean_up_for_members_spec.rb
@@ -0,0 +1,78 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20171216111734_clean_up_for_members.rb')
+
+describe CleanUpForMembers, :migration do
+ let(:migration) { described_class.new }
+ let!(:group_member) { create_group_member }
+ let!(:unbinded_group_member) { create_group_member }
+ let!(:invited_group_member) { create_group_member(true) }
+ let!(:not_valid_group_member) { create_group_member }
+ let!(:project_member) { create_project_member }
+ let!(:invited_project_member) { create_project_member(true) }
+ let!(:unbinded_project_member) { create_project_member }
+ let!(:not_valid_project_member) { create_project_member }
+
+ it 'removes members without proper user_id' do
+ unbinded_group_member.update_column(:user_id, nil)
+ not_valid_group_member.update_column(:user_id, 9999)
+ unbinded_project_member.update_column(:user_id, nil)
+ not_valid_project_member.update_column(:user_id, 9999)
+
+ migrate!
+
+ expect(Member.all).not_to include(unbinded_group_member, not_valid_group_member, unbinded_project_member, not_valid_project_member)
+ expect(Member.all).to include(group_member, invited_group_member, project_member, invited_project_member)
+ end
+
+ def create_group_member(invited = false)
+ fill_member(GroupMember.new(group: create_group), invited)
+ end
+
+ def create_project_member(invited = false)
+ fill_member(ProjectMember.new(project: create_project), invited)
+ end
+
+ def fill_member(member_object, invited)
+ member_object.tap do |m|
+ m.access_level = 40
+ m.notification_level = 3
+
+ if invited
+ m.user_id = nil
+ m.invite_token = 'xxx'
+ m.invite_email = 'email@email.com'
+ else
+ m.user_id = create_user.id
+ end
+
+ m.save
+ end
+
+ member_object
+ end
+
+ def create_group
+ name = FFaker::Lorem.characters(10)
+
+ Group.create(name: name, path: name.downcase.gsub(/\s/, '_'))
+ end
+
+ def create_project
+ name = FFaker::Lorem.characters(10)
+ creator = create_user
+
+ Project.create(name: name,
+ path: name.downcase.gsub(/\s/, '_'),
+ namespace: creator.namespace,
+ creator: creator)
+ end
+
+ def create_user
+ User.create(email: FFaker::Internet.email,
+ password: '12345678',
+ name: FFaker::Name.name,
+ username: FFaker::Internet.user_name,
+ confirmed_at: Time.now,
+ confirmation_token: nil)
+ end
+end
diff --git a/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb
index b47f3314926..b5980cb9ddb 100644
--- a/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb
+++ b/spec/migrations/cleanup_namespaceless_pending_delete_projects_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170502101023_cleanup_namespaceless_pending_delete_projects.rb')
-describe CleanupNamespacelessPendingDeleteProjects do
+describe CleanupNamespacelessPendingDeleteProjects, :migration, schema: 20180222043024 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_exists).and_return(nil)
@@ -10,9 +10,9 @@ describe CleanupNamespacelessPendingDeleteProjects do
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)
+ create(:project) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:project, pending_delete: true) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ project = build(:project, pending_delete: true, namespace_id: nil) # rubocop:disable RSpec/FactoriesInMigrationSpecs
project.save(validate: false)
expect(NamespacelessProjectDestroyWorker).to receive(:bulk_perform_async).with([[project.id]])
@@ -21,8 +21,8 @@ describe CleanupNamespacelessPendingDeleteProjects do
end
it 'does nothing when no pending delete projects without namespace found' do
- create(:project)
- create(:project, pending_delete: true)
+ create(:project) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:project, pending_delete: true) # rubocop:disable RSpec/FactoriesInMigrationSpecs
expect(NamespacelessProjectDestroyWorker).not_to receive(:bulk_perform_async)
diff --git a/spec/migrations/cleanup_nonexisting_namespace_pending_delete_projects_spec.rb b/spec/migrations/cleanup_nonexisting_namespace_pending_delete_projects_spec.rb
index 7879105a334..8f40ac3e38b 100644
--- a/spec/migrations/cleanup_nonexisting_namespace_pending_delete_projects_spec.rb
+++ b/spec/migrations/cleanup_nonexisting_namespace_pending_delete_projects_spec.rb
@@ -9,11 +9,11 @@ describe CleanupNonexistingNamespacePendingDeleteProjects do
end
describe '#up' do
- set(:some_project) { create(:project) }
+ set(:some_project) { create(:project) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
it 'only cleans up when namespace does not exist' do
- create(:project, pending_delete: true)
- project = build(:project, pending_delete: true, namespace: nil, namespace_id: Namespace.maximum(:id).to_i.succ)
+ create(:project, pending_delete: true) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ project = build(:project, pending_delete: true, namespace: nil, namespace_id: Namespace.maximum(:id).to_i.succ) # rubocop:disable RSpec/FactoriesInMigrationSpecs
project.save(validate: false)
expect(NamespacelessProjectDestroyWorker).to receive(:bulk_perform_async).with([[project.id]])
@@ -22,7 +22,7 @@ describe CleanupNonexistingNamespacePendingDeleteProjects do
end
it 'does nothing when no pending delete projects without namespace found' do
- create(:project, pending_delete: true, namespace: create(:namespace))
+ create(:project, pending_delete: true, namespace: create(:namespace)) # rubocop:disable RSpec/FactoriesInMigrationSpecs
expect(NamespacelessProjectDestroyWorker).not_to receive(:bulk_perform_async)
diff --git a/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb b/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb
index 759e77ac9db..d1bf6bdf9d6 100644
--- a/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb
+++ b/spec/migrations/convert_custom_notification_settings_to_columns_spec.rb
@@ -21,7 +21,7 @@ describe ConvertCustomNotificationSettingsToColumns, :migration do
events[event] = true
end
- user = build(:user).becomes(user_class).tap(&:save!)
+ user = user_class.create!(email: "user-#{SecureRandom.hex}@example.org", username: "user-#{SecureRandom.hex}", encrypted_password: '12345678')
create_params = { user_id: user.id, level: params[:level], events: events }
notification_setting = described_class::NotificationSetting.create(create_params)
@@ -37,7 +37,7 @@ describe ConvertCustomNotificationSettingsToColumns, :migration do
events[event] = true
end
- user = build(:user).becomes(user_class).tap(&:save!)
+ user = user_class.create!(email: "user-#{SecureRandom.hex}@example.org", username: "user-#{SecureRandom.hex}", encrypted_password: '12345678')
create_params = events.merge(user_id: user.id, level: params[:level])
notification_setting = described_class::NotificationSetting.create(create_params)
diff --git a/spec/migrations/delete_conflicting_redirect_routes_spec.rb b/spec/migrations/delete_conflicting_redirect_routes_spec.rb
index 1df2477da51..8a191bd7139 100644
--- a/spec/migrations/delete_conflicting_redirect_routes_spec.rb
+++ b/spec/migrations/delete_conflicting_redirect_routes_spec.rb
@@ -10,9 +10,6 @@ describe DeleteConflictingRedirectRoutes, :migration, :sidekiq do
end
before do
- stub_const("DeleteConflictingRedirectRoutes::BATCH_SIZE", 2)
- stub_const("Gitlab::Database::MigrationHelpers::BACKGROUND_MIGRATION_JOB_BUFFER_SIZE", 2)
-
routes.create!(id: 1, source_id: 1, source_type: 'Namespace', path: 'foo1')
routes.create!(id: 2, source_id: 2, source_type: 'Namespace', path: 'foo2')
routes.create!(id: 3, source_id: 3, source_type: 'Namespace', path: 'foo3')
@@ -32,27 +29,14 @@ describe DeleteConflictingRedirectRoutes, :migration, :sidekiq do
redirect_routes.create!(source_id: 1, source_type: 'Namespace', path: 'foo5')
end
- it 'correctly schedules background migrations' do
+ # No-op. See https://gitlab.com/gitlab-com/infrastructure/issues/3460#note_53223252
+ it 'NO-OP: does not schedule any background migrations' do
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
- expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([described_class::MIGRATION, [1, 2]])
- expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(12.seconds.from_now.to_f)
- expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([described_class::MIGRATION, [3, 4]])
- expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(24.seconds.from_now.to_f)
- expect(BackgroundMigrationWorker.jobs[2]['args']).to eq([described_class::MIGRATION, [5, 5]])
- expect(BackgroundMigrationWorker.jobs[2]['at']).to eq(36.seconds.from_now.to_f)
- expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ expect(BackgroundMigrationWorker.jobs.size).to eq 0
end
end
end
-
- it 'schedules background migrations' do
- Sidekiq::Testing.inline! do
- expect do
- migrate!
- end.to change { redirect_routes.count }.from(8).to(3)
- end
- end
end
diff --git a/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb b/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb
new file mode 100644
index 00000000000..afcaefa0591
--- /dev/null
+++ b/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180216121030_enqueue_verify_pages_domain_workers')
+
+describe EnqueueVerifyPagesDomainWorkers, :sidekiq, :migration do
+ around do |example|
+ Sidekiq::Testing.fake! do
+ example.run
+ end
+ end
+
+ describe '#up' do
+ it 'enqueues a verification worker for every domain' do
+ domains = 1.upto(3).map { |i| PagesDomain.create!(domain: "my#{i}.domain.com") }
+
+ expect { migrate! }.to change(PagesDomainVerificationWorker.jobs, :size).by(3)
+
+ enqueued_ids = PagesDomainVerificationWorker.jobs.map { |job| job['args'] }
+ expected_ids = domains.map { |domain| [domain.id] }
+
+ expect(enqueued_ids).to match_array(expected_ids)
+ end
+ end
+end
diff --git a/spec/migrations/fix_wrongly_renamed_routes_spec.rb b/spec/migrations/fix_wrongly_renamed_routes_spec.rb
index 5ef10b92a3a..543cf55f076 100644
--- a/spec/migrations/fix_wrongly_renamed_routes_spec.rb
+++ b/spec/migrations/fix_wrongly_renamed_routes_spec.rb
@@ -1,29 +1,35 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170518231126_fix_wrongly_renamed_routes.rb')
-describe FixWronglyRenamedRoutes, truncate: true do
+describe FixWronglyRenamedRoutes, :migration do
let(:subject) { described_class.new }
+ let(:namespaces_table) { table(:namespaces) }
+ let(:projects_table) { table(:projects) }
+ let(:routes_table) { table(:routes) }
let(:broken_namespace) do
- namespace = create(:group, name: 'apiis')
- namespace.route.update_attribute(:path, 'api0is')
- namespace
+ namespaces_table.create!(name: 'apiis', path: 'apiis').tap do |namespace|
+ routes_table.create!(source_type: 'Namespace', source_id: namespace.id, name: 'api0is', path: 'api0is')
+ end
end
+ let(:broken_namespace_route) { routes_table.where(source_type: 'Namespace', source_id: broken_namespace.id).first }
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')
+ other_namespace = namespaces_table.create!(name: 'api0', path: 'api0')
+ routes_table.create!(source_type: 'Namespace', source_id: other_namespace.id, name: 'api0', path: 'api0')
expect(subject.wrongly_renamed.map(&:id))
- .to contain_exactly(broken_namespace.route.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')
+ namespaces_table.create!(name: 'uploads-test', path: 'uploads-test').tap do |namespace|
+ routes_table.create!(source_type: 'Namespace', source_id: namespace.id, name: 'uploads-test', path: 'uploads0-test')
+ end
expected_result = [
{ 'namespace_path' => 'apiis', 'path' => 'api0is' },
@@ -36,38 +42,45 @@ describe FixWronglyRenamedRoutes, truncate: true do
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)
+ namespace = namespaces_table.create!(name: 'hello', path: 'hello')
+ namespace_route = routes_table.create!(source_type: 'Namespace', source_id: namespace.id, name: 'hello', path: 'hello')
+ project = projects_table.new(name: 'my-project', path: 'my-project', namespace_id: namespace.id).tap do |project|
+ project.save!(validate: false)
+ end
+ routes_table.create!(source_type: 'Project', source_id: project.id, name: 'my-project', path: 'hello/my-project')
+ _other_namespace = namespaces_table.create!(name: 'hello0', path: 'hello0')
+
+ result = routes_table.where(subject.routes_in_namespace_query('hello'))
+ project_route = routes_table.where(source_type: 'Project', source_id: project.id).first
+
+ 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
+ broken_project =
+ projects_table.new(name: 'broken-project', path: 'broken-project', namespace_id: broken_namespace.id).tap do |project|
+ project.save!(validate: false)
+ routes_table.create!(source_type: 'Project', source_id: project.id, name: 'broken-project', path: 'api0is/broken-project')
+ end
subject.up
- expect(broken_project.route.reload.path).to eq('apiis/broken-project')
- expect(broken_namespace.route.reload.path).to eq('apiis')
+ broken_project_route = routes_table.where(source_type: 'Project', source_id: broken_project.id).first
+
+ expect(broken_project_route.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')
+ namespaces_table.create!(name: 'apiis', path: 'apiis')
+ namespace = namespaces_table.create!(name: 'hello', path: 'api0')
+ namespace_route = routes_table.create!(source_type: 'Namespace', source_id: namespace.id, name: 'hello', path: 'api0')
subject.up
- expect(namespace.route.reload.path).to eq('api0')
+ expect(namespace_route.reload.path).to eq('api0')
end
end
end
diff --git a/spec/migrations/issues_moved_to_id_foreign_key_spec.rb b/spec/migrations/issues_moved_to_id_foreign_key_spec.rb
new file mode 100644
index 00000000000..dd2b08099f2
--- /dev/null
+++ b/spec/migrations/issues_moved_to_id_foreign_key_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20171106151218_issues_moved_to_id_foreign_key.rb')
+
+# The schema version has to be far enough in advance to have the
+# only_mirror_protected_branches column in the projects table to create a
+# project via FactoryBot.
+describe IssuesMovedToIdForeignKey, :migration, schema: 20171114150259 do
+ let!(:issue_first) { create(:issue, moved_to_id: issue_second.id) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:issue_second) { create(:issue, moved_to_id: issue_third.id) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:issue_third) { create(:issue) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ subject { described_class.new }
+
+ it 'removes the orphaned moved_to_id' do
+ subject.down
+
+ issue_third.update_attributes(moved_to_id: 100000)
+
+ subject.up
+
+ expect(issue_first.reload.moved_to_id).to eq(issue_second.id)
+ expect(issue_second.reload.moved_to_id).to eq(issue_third.id)
+ expect(issue_third.reload.moved_to_id).to be_nil
+ end
+end
diff --git a/spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb b/spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb
new file mode 100644
index 00000000000..c18ae3b76d3
--- /dev/null
+++ b/spec/migrations/migrate_create_trace_artifact_sidekiq_queue_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180306074045_migrate_create_trace_artifact_sidekiq_queue.rb')
+
+describe MigrateCreateTraceArtifactSidekiqQueue, :sidekiq, :redis do
+ include Gitlab::Database::MigrationHelpers
+
+ context 'when there are jobs in the queues' do
+ it 'correctly migrates queue when migrating up' do
+ Sidekiq::Testing.disable! do
+ stubbed_worker(queue: 'pipeline_default:create_trace_artifact').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_background:archive_trace').perform_async('Something', [1])
+
+ described_class.new.up
+
+ expect(sidekiq_queue_length('pipeline_default:create_trace_artifact')).to eq 0
+ expect(sidekiq_queue_length('pipeline_background:archive_trace')).to eq 2
+ end
+ end
+
+ it 'does not affect other queues under the same namespace' do
+ Sidekiq::Testing.disable! do
+ stubbed_worker(queue: 'pipeline_default:build_coverage').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_default:build_trace_sections').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_default:pipeline_metrics').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_default:pipeline_notification').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_default:update_head_pipeline_for_merge_request').perform_async('Something', [1])
+
+ described_class.new.up
+
+ expect(sidekiq_queue_length('pipeline_default:build_coverage')).to eq 1
+ expect(sidekiq_queue_length('pipeline_default:build_trace_sections')).to eq 1
+ expect(sidekiq_queue_length('pipeline_default:pipeline_metrics')).to eq 1
+ expect(sidekiq_queue_length('pipeline_default:pipeline_notification')).to eq 1
+ expect(sidekiq_queue_length('pipeline_default:update_head_pipeline_for_merge_request')).to eq 1
+ end
+ end
+
+ it 'correctly migrates queue when migrating down' do
+ Sidekiq::Testing.disable! do
+ stubbed_worker(queue: 'pipeline_background:archive_trace').perform_async('Something', [1])
+
+ described_class.new.down
+
+ expect(sidekiq_queue_length('pipeline_default:create_trace_artifact')).to eq 1
+ expect(sidekiq_queue_length('pipeline_background:archive_trace')).to eq 0
+ end
+ end
+ end
+
+ context 'when there are no jobs in the queues' do
+ it 'does not raise error when migrating up' do
+ expect { described_class.new.up }.not_to raise_error
+ end
+
+ it 'does not raise error when migrating down' do
+ expect { described_class.new.down }.not_to raise_error
+ end
+ end
+
+ def stubbed_worker(queue:)
+ Class.new do
+ include Sidekiq::Worker
+ sidekiq_options queue: queue
+ end
+ end
+end
diff --git a/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb
index 57ee2adaaff..df009cec25c 100644
--- a/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb
+++ b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb
@@ -4,8 +4,24 @@ require Rails.root.join('db', 'post_migrate', '20171013104327_migrate_gcp_cluste
describe MigrateGcpClustersToNewClustersArchitectures, :migration do
let(:projects) { table(:projects) }
let(:project) { projects.create }
- let(:user) { create(:user) }
- let(:service) { create(:kubernetes_service, project_id: project.id) }
+ let(:users) { table(:users) }
+ let(:user) { users.create! }
+ let(:service) { GcpMigrationSpec::KubernetesService.create!(project_id: project.id) }
+
+ module GcpMigrationSpec
+ class KubernetesService < ActiveRecord::Base
+ self.table_name = 'services'
+
+ serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
+
+ default_value_for :active, true
+ default_value_for :type, 'KubernetesService'
+ default_value_for :properties, {
+ api_url: 'https://kubernetes.example.com',
+ token: 'a' * 40
+ }
+ end
+ end
context 'when cluster is being created' do
let(:project_id) { project.id }
@@ -33,7 +49,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
let(:encrypted_gcp_token) { "'encrypted_gcp_token'" }
let(:encrypted_gcp_token_iv) { "'encrypted_gcp_token_iv'" }
- let(:cluster) { Clusters::Cluster.last }
+ let(:cluster) { described_class::Cluster.last }
let(:cluster_id) { cluster.id }
before do
@@ -46,12 +62,12 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
it 'correctly migrate to new clusters architectures' do
migrate!
- expect(Clusters::Cluster.count).to eq(1)
- expect(Clusters::Project.count).to eq(1)
- expect(Clusters::Providers::Gcp.count).to eq(1)
- expect(Clusters::Platforms::Kubernetes.count).to eq(1)
+ expect(described_class::Cluster.count).to eq(1)
+ expect(described_class::ClustersProject.count).to eq(1)
+ expect(described_class::ProvidersGcp.count).to eq(1)
+ expect(described_class::PlatformsKubernetes.count).to eq(1)
- expect(cluster.user).to eq(user)
+ expect(cluster.user_id).to eq(user.id)
expect(cluster.enabled).to be_truthy
expect(cluster.name).to eq(gcp_cluster_name.delete!("'"))
expect(cluster.provider_type).to eq('gcp')
@@ -59,7 +75,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
expect(cluster.project_ids).to include(project.id)
- expect(cluster.provider_gcp.cluster).to eq(cluster)
+ expect(cluster.provider_gcp.cluster_id).to eq(cluster.id)
expect(cluster.provider_gcp.status).to eq(status)
expect(cluster.provider_gcp.status_reason).to eq(tr(status_reason))
expect(cluster.provider_gcp.gcp_project_id).to eq(tr(gcp_project_id))
@@ -71,7 +87,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
expect(cluster.provider_gcp.encrypted_access_token).to eq(tr(encrypted_gcp_token))
expect(cluster.provider_gcp.encrypted_access_token_iv).to eq(tr(encrypted_gcp_token_iv))
- expect(cluster.platform_kubernetes.cluster).to eq(cluster)
+ expect(cluster.platform_kubernetes.cluster_id).to eq(cluster.id)
expect(cluster.platform_kubernetes.api_url).to be_nil
expect(cluster.platform_kubernetes.ca_cert).to be_nil
expect(cluster.platform_kubernetes.namespace).to eq(tr(project_namespace))
@@ -109,7 +125,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
let(:encrypted_gcp_token) { "'encrypted_gcp_token'" }
let(:encrypted_gcp_token_iv) { "'encrypted_gcp_token_iv'" }
- let(:cluster) { Clusters::Cluster.last }
+ let(:cluster) { described_class::Cluster.last }
let(:cluster_id) { cluster.id }
before do
@@ -122,12 +138,12 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
it 'correctly migrate to new clusters architectures' do
migrate!
- expect(Clusters::Cluster.count).to eq(1)
- expect(Clusters::Project.count).to eq(1)
- expect(Clusters::Providers::Gcp.count).to eq(1)
- expect(Clusters::Platforms::Kubernetes.count).to eq(1)
+ expect(described_class::Cluster.count).to eq(1)
+ expect(described_class::ClustersProject.count).to eq(1)
+ expect(described_class::ProvidersGcp.count).to eq(1)
+ expect(described_class::PlatformsKubernetes.count).to eq(1)
- expect(cluster.user).to eq(user)
+ expect(cluster.user_id).to eq(user.id)
expect(cluster.enabled).to be_truthy
expect(cluster.name).to eq(tr(gcp_cluster_name))
expect(cluster.provider_type).to eq('gcp')
@@ -135,7 +151,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
expect(cluster.project_ids).to include(project.id)
- expect(cluster.provider_gcp.cluster).to eq(cluster)
+ expect(cluster.provider_gcp.cluster_id).to eq(cluster.id)
expect(cluster.provider_gcp.status).to eq(status)
expect(cluster.provider_gcp.status_reason).to eq(tr(status_reason))
expect(cluster.provider_gcp.gcp_project_id).to eq(tr(gcp_project_id))
@@ -147,7 +163,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
expect(cluster.provider_gcp.encrypted_access_token).to eq(tr(encrypted_gcp_token))
expect(cluster.provider_gcp.encrypted_access_token_iv).to eq(tr(encrypted_gcp_token_iv))
- expect(cluster.platform_kubernetes.cluster).to eq(cluster)
+ expect(cluster.platform_kubernetes.cluster_id).to eq(cluster.id)
expect(cluster.platform_kubernetes.api_url).to eq('https://' + tr(endpoint))
expect(cluster.platform_kubernetes.ca_cert).to eq(tr(ca_cert))
expect(cluster.platform_kubernetes.namespace).to eq(tr(project_namespace))
diff --git a/spec/migrations/migrate_issues_to_ghost_user_spec.rb b/spec/migrations/migrate_issues_to_ghost_user_spec.rb
index cfd4021fbac..9220b49a736 100644
--- a/spec/migrations/migrate_issues_to_ghost_user_spec.rb
+++ b/spec/migrations/migrate_issues_to_ghost_user_spec.rb
@@ -8,10 +8,10 @@ describe MigrateIssuesToGhostUser, :migration do
let(:users) { table(:users) }
before do
- projects.create!(name: 'gitlab')
+ project = projects.create!(name: 'gitlab', namespace_id: 1)
user = users.create(email: 'test@example.com')
- issues.create(title: 'Issue 1', author_id: nil, project_id: 1)
- issues.create(title: 'Issue 2', author_id: user.id, project_id: 1)
+ issues.create(title: 'Issue 1', author_id: nil, project_id: project.id)
+ issues.create(title: 'Issue 2', author_id: user.id, project_id: project.id)
end
context 'when ghost user exists' do
diff --git a/spec/migrations/migrate_kubernetes_service_to_new_clusters_architectures_spec.rb b/spec/migrations/migrate_kubernetes_service_to_new_clusters_architectures_spec.rb
new file mode 100644
index 00000000000..df0015b6dd3
--- /dev/null
+++ b/spec/migrations/migrate_kubernetes_service_to_new_clusters_architectures_spec.rb
@@ -0,0 +1,312 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20171124104327_migrate_kubernetes_service_to_new_clusters_architectures.rb')
+
+describe MigrateKubernetesServiceToNewClustersArchitectures, :migration do
+ context 'when unique KubernetesService exists' do
+ shared_examples 'KubernetesService migration' do
+ let(:sample_num) { 2 }
+
+ let(:projects) do
+ (1..sample_num).each_with_object([]) do |n, array|
+ array << MigrateKubernetesServiceToNewClustersArchitectures::Project.create!
+ end
+ end
+
+ let!(:kubernetes_services) do
+ projects.map do |project|
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ active: active,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"namespace\":\"prod\",\"api_url\":\"https://kubernetes#{project.id}.com\",\"ca_pem\":\"ca_pem#{project.id}\",\"token\":\"token#{project.id}\"}")
+ end
+ end
+
+ it 'migrates the KubernetesService to Platform::Kubernetes' do
+ expect { migrate! }.to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }.by(sample_num)
+
+ projects.each do |project|
+ project.clusters.last.tap do |cluster|
+ expect(cluster.enabled).to eq(active)
+ expect(cluster.platform_kubernetes.api_url).to eq(project.kubernetes_service.api_url)
+ expect(cluster.platform_kubernetes.ca_cert).to eq(project.kubernetes_service.ca_pem)
+ expect(cluster.platform_kubernetes.token).to eq(project.kubernetes_service.token)
+ expect(project.kubernetes_service).not_to be_active
+ end
+ end
+ end
+ end
+
+ context 'when KubernetesService is active' do
+ let(:active) { true }
+
+ it_behaves_like 'KubernetesService migration'
+ end
+ end
+
+ context 'when unique KubernetesService spawned from Service Template' do
+ let(:sample_num) { 2 }
+
+ let(:projects) do
+ (1..sample_num).each_with_object([]) do |n, array|
+ array << MigrateKubernetesServiceToNewClustersArchitectures::Project.create!
+ end
+ end
+
+ let!(:kubernetes_service_template) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ template: true,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"namespace\":\"prod\",\"api_url\":\"https://sample.kubernetes.com\",\"ca_pem\":\"ca_pem-sample\",\"token\":\"token-sample\"}")
+ end
+
+ let!(:kubernetes_services) do
+ projects.map do |project|
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"namespace\":\"prod\",\"api_url\":\"#{kubernetes_service_template.api_url}\",\"ca_pem\":\"#{kubernetes_service_template.ca_pem}\",\"token\":\"#{kubernetes_service_template.token}\"}")
+ end
+ end
+
+ it 'migrates the KubernetesService to Platform::Kubernetes without template' do
+ expect { migrate! }.to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }.by(sample_num)
+
+ projects.each do |project|
+ project.clusters.last.tap do |cluster|
+ expect(cluster.platform_kubernetes.api_url).to eq(project.kubernetes_service.api_url)
+ expect(cluster.platform_kubernetes.ca_cert).to eq(project.kubernetes_service.ca_pem)
+ expect(cluster.platform_kubernetes.token).to eq(project.kubernetes_service.token)
+ expect(project.kubernetes_service).not_to be_active
+ end
+ end
+ end
+ end
+
+ context 'when managed KubernetesService exists' do
+ let(:project) { MigrateKubernetesServiceToNewClustersArchitectures::Project.create! }
+
+ let(:cluster) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Cluster.create!(
+ projects: [project],
+ name: 'sample-cluster',
+ platform_type: :kubernetes,
+ provider_type: :user,
+ platform_kubernetes_attributes: {
+ api_url: 'https://sample.kubernetes.com',
+ ca_cert: 'ca_pem-sample',
+ token: 'token-sample'
+ } )
+ end
+
+ let!(:kubernetes_service) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ active: cluster.enabled,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"api_url\":\"#{cluster.platform_kubernetes.api_url}\"}")
+ end
+
+ it 'does not migrate the KubernetesService and disables the kubernetes_service' do # Because the corresponding Platform::Kubernetes already exists
+ expect { migrate! }.not_to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }
+
+ kubernetes_service.reload
+ expect(kubernetes_service).not_to be_active
+ end
+ end
+
+ context 'when production cluster has already been existed' do # i.e. There are no environment_scope conflicts
+ let(:project) { MigrateKubernetesServiceToNewClustersArchitectures::Project.create! }
+
+ let(:cluster) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Cluster.create!(
+ projects: [project],
+ name: 'sample-cluster',
+ platform_type: :kubernetes,
+ provider_type: :user,
+ environment_scope: 'production/*',
+ platform_kubernetes_attributes: {
+ api_url: 'https://sample.kubernetes.com',
+ ca_cert: 'ca_pem-sample',
+ token: 'token-sample'
+ } )
+ end
+
+ let!(:kubernetes_service) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ active: true,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"api_url\":\"https://debug.kube.com\"}")
+ end
+
+ it 'migrates the KubernetesService to Platform::Kubernetes' do
+ expect { migrate! }.to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }.by(1)
+
+ kubernetes_service.reload
+ project.clusters.last.tap do |cluster|
+ expect(cluster.environment_scope).to eq('*')
+ expect(cluster.platform_kubernetes.api_url).to eq(kubernetes_service.api_url)
+ expect(cluster.platform_kubernetes.ca_cert).to eq(kubernetes_service.ca_pem)
+ expect(cluster.platform_kubernetes.token).to eq(kubernetes_service.token)
+ expect(kubernetes_service).not_to be_active
+ end
+ end
+ end
+
+ context 'when default cluster has already been existed' do
+ let(:project) { MigrateKubernetesServiceToNewClustersArchitectures::Project.create! }
+
+ let!(:cluster) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Cluster.create!(
+ projects: [project],
+ name: 'sample-cluster',
+ platform_type: :kubernetes,
+ provider_type: :user,
+ environment_scope: '*',
+ platform_kubernetes_attributes: {
+ api_url: 'https://sample.kubernetes.com',
+ ca_cert: 'ca_pem-sample',
+ token: 'token-sample'
+ } )
+ end
+
+ let!(:kubernetes_service) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ active: true,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"api_url\":\"https://debug.kube.com\"}")
+ end
+
+ it 'migrates the KubernetesService to Platform::Kubernetes with dedicated environment_scope' do # Because environment_scope is duplicated
+ expect { migrate! }.to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }.by(1)
+
+ kubernetes_service.reload
+ project.clusters.last.tap do |cluster|
+ expect(cluster.environment_scope).to eq('migrated/*')
+ expect(cluster.platform_kubernetes.api_url).to eq(kubernetes_service.api_url)
+ expect(cluster.platform_kubernetes.ca_cert).to eq(kubernetes_service.ca_pem)
+ expect(cluster.platform_kubernetes.token).to eq(kubernetes_service.token)
+ expect(kubernetes_service).not_to be_active
+ end
+ end
+ end
+
+ context 'when default cluster and migrated cluster has already been existed' do
+ let(:project) { MigrateKubernetesServiceToNewClustersArchitectures::Project.create! }
+
+ let!(:cluster) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Cluster.create!(
+ projects: [project],
+ name: 'sample-cluster',
+ platform_type: :kubernetes,
+ provider_type: :user,
+ environment_scope: '*',
+ platform_kubernetes_attributes: {
+ api_url: 'https://sample.kubernetes.com',
+ ca_cert: 'ca_pem-sample',
+ token: 'token-sample'
+ } )
+ end
+
+ let!(:migrated_cluster) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Cluster.create!(
+ projects: [project],
+ name: 'sample-cluster',
+ platform_type: :kubernetes,
+ provider_type: :user,
+ environment_scope: 'migrated/*',
+ platform_kubernetes_attributes: {
+ api_url: 'https://sample.kubernetes.com',
+ ca_cert: 'ca_pem-sample',
+ token: 'token-sample'
+ } )
+ end
+
+ let!(:kubernetes_service) do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ active: true,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"api_url\":\"https://debug.kube.com\"}")
+ end
+
+ it 'migrates the KubernetesService to Platform::Kubernetes with dedicated environment_scope' do # Because environment_scope is duplicated
+ expect { migrate! }.to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }.by(1)
+
+ kubernetes_service.reload
+ project.clusters.last.tap do |cluster|
+ expect(cluster.environment_scope).to eq('migrated0/*')
+ expect(cluster.platform_kubernetes.api_url).to eq(kubernetes_service.api_url)
+ expect(cluster.platform_kubernetes.ca_cert).to eq(kubernetes_service.ca_pem)
+ expect(cluster.platform_kubernetes.token).to eq(kubernetes_service.token)
+ expect(kubernetes_service).not_to be_active
+ end
+ end
+ end
+
+ context 'when KubernetesService has nullified parameters' do
+ let(:project) { MigrateKubernetesServiceToNewClustersArchitectures::Project.create! }
+
+ before do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ active: false,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{}")
+ end
+
+ it 'does not migrate the KubernetesService and disables the kubernetes_service' do
+ expect { migrate! }.not_to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }
+
+ expect(project.kubernetes_service).not_to be_active
+ end
+ end
+
+ # Platforms::Kubernetes validates `token` reagdless of the activeness,
+ # whereas KubernetesService validates `token` if only it's activated
+ # However, in this migration file, there are no validations because of the re-defined model class
+ # therefore, we should safely add this raw to Platform::Kubernetes
+ context 'when KubernetesService has empty token' do
+ let(:project) { MigrateKubernetesServiceToNewClustersArchitectures::Project.create! }
+
+ before do
+ MigrateKubernetesServiceToNewClustersArchitectures::Service.create!(
+ project: project,
+ active: false,
+ category: 'deployment',
+ type: 'KubernetesService',
+ properties: "{\"namespace\":\"prod\",\"api_url\":\"http://111.111.111.111\",\"ca_pem\":\"a\",\"token\":\"\"}")
+ end
+
+ it 'does not migrate the KubernetesService and disables the kubernetes_service' do
+ expect { migrate! }.to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }.by(1)
+
+ project.clusters.last.tap do |cluster|
+ expect(cluster.environment_scope).to eq('*')
+ expect(cluster.platform_kubernetes.namespace).to eq('prod')
+ expect(cluster.platform_kubernetes.api_url).to eq('http://111.111.111.111')
+ expect(cluster.platform_kubernetes.ca_cert).to eq('a')
+ expect(cluster.platform_kubernetes.token).to be_empty
+ expect(project.kubernetes_service).not_to be_active
+ end
+ end
+ end
+
+ context 'when KubernetesService does not exist' do
+ let!(:project) { MigrateKubernetesServiceToNewClustersArchitectures::Project.create! }
+
+ it 'does not migrate the KubernetesService' do
+ expect { migrate! }.not_to change { MigrateKubernetesServiceToNewClustersArchitectures::Cluster.count }
+ end
+ end
+end
diff --git a/spec/migrations/migrate_old_artifacts_spec.rb b/spec/migrations/migrate_old_artifacts_spec.rb
index 92eb1d9ce86..4187ab149a5 100644
--- a/spec/migrations/migrate_old_artifacts_spec.rb
+++ b/spec/migrations/migrate_old_artifacts_spec.rb
@@ -16,18 +16,18 @@ describe MigrateOldArtifacts do
end
context 'with migratable data' do
- set(:project1) { create(:project, ci_id: 2) }
- set(:project2) { create(:project, ci_id: 3) }
- set(:project3) { create(:project) }
+ set(:project1) { create(:project, ci_id: 2) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ set(:project2) { create(:project, ci_id: 3) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ set(:project3) { create(:project) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
- set(:pipeline1) { create(:ci_empty_pipeline, project: project1) }
- set(:pipeline2) { create(:ci_empty_pipeline, project: project2) }
- set(:pipeline3) { create(:ci_empty_pipeline, project: project3) }
+ set(:pipeline1) { create(:ci_empty_pipeline, project: project1) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ set(:pipeline2) { create(:ci_empty_pipeline, project: project2) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ set(:pipeline3) { create(:ci_empty_pipeline, project: project3) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
- let!(:build_with_legacy_artifacts) { create(:ci_build, pipeline: pipeline1) }
- let!(:build_without_artifacts) { create(:ci_build, pipeline: pipeline1) }
- let!(:build2) { create(:ci_build, pipeline: pipeline2) }
- let!(:build3) { create(:ci_build, pipeline: pipeline3) }
+ let!(:build_with_legacy_artifacts) { create(:ci_build, pipeline: pipeline1) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:build_without_artifacts) { create(:ci_build, pipeline: pipeline1) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:build2) { create(:ci_build, pipeline: pipeline2) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:build3) { create(:ci_build, pipeline: pipeline3) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
before do
setup_builds(build2, build3)
@@ -66,7 +66,7 @@ describe MigrateOldArtifacts do
end
it 'all files do have artifacts' do
- Ci::Build.with_artifacts do |build|
+ Ci::Build.with_artifacts_archive do |build|
expect(build).to have_artifacts
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 e5793a3c0ee..4ee1d255fbd 100644
--- a/spec/migrations/migrate_process_commit_worker_jobs_spec.rb
+++ b/spec/migrations/migrate_process_commit_worker_jobs_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
require Rails.root.join('db', 'migrate', '20161124141322_migrate_process_commit_worker_jobs.rb')
describe MigrateProcessCommitWorkerJobs do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ let(:project) { create(:project, :legacy_storage, :repository) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let(:user) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
let(:commit) { project.commit.raw.rugged_commit }
describe 'Project' do
diff --git a/spec/migrations/migrate_stage_id_reference_in_background_spec.rb b/spec/migrations/migrate_stage_id_reference_in_background_spec.rb
index 9b92f4b70b0..a837498e1b1 100644
--- a/spec/migrations/migrate_stage_id_reference_in_background_spec.rb
+++ b/spec/migrations/migrate_stage_id_reference_in_background_spec.rb
@@ -35,9 +35,9 @@ describe MigrateStageIdReferenceInBackground, :migration, :sidekiq 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(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 1, 2)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 3, 3)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 4, 5)
expect(BackgroundMigrationWorker.jobs.size).to eq 3
end
end
diff --git a/spec/migrations/migrate_stages_statuses_spec.rb b/spec/migrations/migrate_stages_statuses_spec.rb
index 094c9bc604e..ce35276cbf5 100644
--- a/spec/migrations/migrate_stages_statuses_spec.rb
+++ b/spec/migrations/migrate_stages_statuses_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170711145558_migrate_stages_statuses.rb')
-describe MigrateStagesStatuses, :migration do
+describe MigrateStagesStatuses, :sidekiq, :migration do
let(:jobs) { table(:ci_builds) }
let(:stages) { table(:ci_stages) }
let(:pipelines) { table(:ci_pipelines) }
@@ -50,9 +50,9 @@ describe MigrateStagesStatuses, :migration do
Timecop.freeze do
migrate!
- expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes, 1, 1)
- expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes, 2, 2)
- expect(described_class::MIGRATION).to be_scheduled_migration(10.minutes, 3, 3)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 1)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 2, 2)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 3, 3)
expect(BackgroundMigrationWorker.jobs.size).to eq 3
end
end
diff --git a/spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb b/spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb
new file mode 100644
index 00000000000..5e3b20ab4a8
--- /dev/null
+++ b/spec/migrations/migrate_update_head_pipeline_for_merge_request_sidekiq_queue_spec.rb
@@ -0,0 +1,64 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180307012445_migrate_update_head_pipeline_for_merge_request_sidekiq_queue.rb')
+
+describe MigrateUpdateHeadPipelineForMergeRequestSidekiqQueue, :sidekiq, :redis do
+ include Gitlab::Database::MigrationHelpers
+
+ context 'when there are jobs in the queues' do
+ it 'correctly migrates queue when migrating up' do
+ Sidekiq::Testing.disable! do
+ stubbed_worker(queue: 'pipeline_default:update_head_pipeline_for_merge_request').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_processing:update_head_pipeline_for_merge_request').perform_async('Something', [1])
+
+ described_class.new.up
+
+ expect(sidekiq_queue_length('pipeline_default:update_head_pipeline_for_merge_request')).to eq 0
+ expect(sidekiq_queue_length('pipeline_processing:update_head_pipeline_for_merge_request')).to eq 2
+ end
+ end
+
+ it 'does not affect other queues under the same namespace' do
+ Sidekiq::Testing.disable! do
+ stubbed_worker(queue: 'pipeline_default:build_coverage').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_default:build_trace_sections').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_default:pipeline_metrics').perform_async('Something', [1])
+ stubbed_worker(queue: 'pipeline_default:pipeline_notification').perform_async('Something', [1])
+
+ described_class.new.up
+
+ expect(sidekiq_queue_length('pipeline_default:build_coverage')).to eq 1
+ expect(sidekiq_queue_length('pipeline_default:build_trace_sections')).to eq 1
+ expect(sidekiq_queue_length('pipeline_default:pipeline_metrics')).to eq 1
+ expect(sidekiq_queue_length('pipeline_default:pipeline_notification')).to eq 1
+ end
+ end
+
+ it 'correctly migrates queue when migrating down' do
+ Sidekiq::Testing.disable! do
+ stubbed_worker(queue: 'pipeline_processing:update_head_pipeline_for_merge_request').perform_async('Something', [1])
+
+ described_class.new.down
+
+ expect(sidekiq_queue_length('pipeline_default:update_head_pipeline_for_merge_request')).to eq 1
+ expect(sidekiq_queue_length('pipeline_processing:update_head_pipeline_for_merge_request')).to eq 0
+ end
+ end
+ end
+
+ context 'when there are no jobs in the queues' do
+ it 'does not raise error when migrating up' do
+ expect { described_class.new.up }.not_to raise_error
+ end
+
+ it 'does not raise error when migrating down' do
+ expect { described_class.new.down }.not_to raise_error
+ end
+ end
+
+ def stubbed_worker(queue:)
+ Class.new do
+ include Sidekiq::Worker
+ sidekiq_options queue: queue
+ 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 063829be546..99173708190 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,10 +3,10 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170324160416_migrate_user_activities_to_users_last_activity_on.rb')
-describe MigrateUserActivitiesToUsersLastActivityOn, :clean_gitlab_redis_shared_state, :truncate do
+describe MigrateUserActivitiesToUsersLastActivityOn, :clean_gitlab_redis_shared_state, :delete do
let(:migration) { described_class.new }
- let!(:user_active_1) { create(:user) }
- let!(:user_active_2) { create(:user) }
+ let!(:user_active_1) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:user_active_2) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
def record_activity(user, time)
Gitlab::Redis::SharedState.with do |redis|
diff --git a/spec/migrations/migrate_user_project_view_spec.rb b/spec/migrations/migrate_user_project_view_spec.rb
index 5e16769d63a..80468b9d01e 100644
--- a/spec/migrations/migrate_user_project_view_spec.rb
+++ b/spec/migrations/migrate_user_project_view_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170406142253_migrate_user_project_view.rb')
-describe MigrateUserProjectView, :truncate do
+describe MigrateUserProjectView, :delete do
let(:migration) { described_class.new }
- let!(:user) { create(:user, project_view: 'readme') }
+ let!(:user) { create(:user, project_view: 'readme') } # rubocop:disable RSpec/FactoriesInMigrationSpecs
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
index 1a319eccc0d..1f39ad98fb8 100644
--- a/spec/migrations/move_personal_snippets_files_spec.rb
+++ b/spec/migrations/move_personal_snippets_files_spec.rb
@@ -16,14 +16,14 @@ describe MovePersonalSnippetsFiles do
describe "#up" do
let(:snippet) do
- snippet = create(:personal_snippet)
+ snippet = create(:personal_snippet) # rubocop:disable RSpec/FactoriesInMigrationSpecs
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)
+ snippet = create(:snippet) # rubocop:disable RSpec/FactoriesInMigrationSpecs
create_upload('picture.jpg', snippet, create_file: false)
snippet.update(description: markdown_linking_file('picture.jpg', snippet))
snippet
@@ -62,7 +62,7 @@ describe MovePersonalSnippetsFiles 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}")
+ note = create(:note_on_personal_snippet, noteable: snippet, note: "with #{markdown}") # rubocop:disable RSpec/FactoriesInMigrationSpecs
migration.up
@@ -73,14 +73,14 @@ describe MovePersonalSnippetsFiles do
describe "#down" do
let(:snippet) do
- snippet = create(:personal_snippet)
+ snippet = create(:personal_snippet) # rubocop:disable RSpec/FactoriesInMigrationSpecs
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)
+ snippet = create(:personal_snippet) # rubocop:disable RSpec/FactoriesInMigrationSpecs
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
@@ -119,7 +119,7 @@ describe MovePersonalSnippetsFiles 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}")
+ note = create(:note_on_personal_snippet, noteable: snippet, note: "with #{markdown}") # rubocop:disable RSpec/FactoriesInMigrationSpecs
migration.down
@@ -135,7 +135,7 @@ describe MovePersonalSnippetsFiles do
secret = '123456789'
filename = 'hello.jpg'
- snippet = create(:personal_snippet)
+ snippet = create(:personal_snippet) # rubocop:disable RSpec/FactoriesInMigrationSpecs
path_before = "/uploads/personal_snippet/#{snippet.id}/#{secret}/#{filename}"
path_after = "/uploads/system/personal_snippet/#{snippet.id}/#{secret}/#{filename}"
@@ -161,7 +161,7 @@ describe MovePersonalSnippetsFiles do
FileUtils.touch(absolute_path)
end
- create(:upload, model: snippet, path: "#{secret}/#{filename}", uploader: PersonalFileUploader)
+ create(:upload, model: snippet, path: "#{secret}/#{filename}", uploader: PersonalFileUploader) # rubocop:disable RSpec/FactoriesInMigrationSpecs
end
def markdown_linking_file(filename, snippet, in_new_path: false)
diff --git a/spec/migrations/normalize_ldap_extern_uids_spec.rb b/spec/migrations/normalize_ldap_extern_uids_spec.rb
index 262d7742aaf..56a78f52802 100644
--- a/spec/migrations/normalize_ldap_extern_uids_spec.rb
+++ b/spec/migrations/normalize_ldap_extern_uids_spec.rb
@@ -27,11 +27,11 @@ describe NormalizeLdapExternUids, :migration, :sidekiq do
migrate!
expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([described_class::MIGRATION, [1, 2]])
- expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.seconds.from_now.to_f)
+ expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(5.minutes.from_now.to_f)
expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([described_class::MIGRATION, [3, 4]])
- expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(20.seconds.from_now.to_f)
+ expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(10.minutes.from_now.to_f)
expect(BackgroundMigrationWorker.jobs[2]['args']).to eq([described_class::MIGRATION, [5, 5]])
- expect(BackgroundMigrationWorker.jobs[2]['at']).to eq(30.seconds.from_now.to_f)
+ expect(BackgroundMigrationWorker.jobs[2]['at']).to eq(15.minutes.from_now.to_f)
expect(BackgroundMigrationWorker.jobs.size).to eq 3
end
end
diff --git a/spec/migrations/populate_can_push_from_deploy_keys_projects_spec.rb b/spec/migrations/populate_can_push_from_deploy_keys_projects_spec.rb
new file mode 100644
index 00000000000..0ff98933d5c
--- /dev/null
+++ b/spec/migrations/populate_can_push_from_deploy_keys_projects_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20171215113714_populate_can_push_from_deploy_keys_projects.rb')
+
+describe PopulateCanPushFromDeployKeysProjects, :migration do
+ let(:migration) { described_class.new }
+ let(:deploy_keys) { table(:keys) }
+ let(:deploy_keys_projects) { table(:deploy_keys_projects) }
+ let(:projects) { table(:projects) }
+
+ before do
+ deploy_keys.inheritance_column = nil
+
+ projects.create!(id: 1, name: 'gitlab1', path: 'gitlab1')
+ (1..10).each do |index|
+ deploy_keys.create!(id: index, title: 'dummy', type: 'DeployKey', key: Spec::Support::Helpers::KeyGeneratorHelper.new(1024).generate + ' dummy@gitlab.com')
+ deploy_keys_projects.create!(id: index, deploy_key_id: index, project_id: 1)
+ end
+ end
+
+ describe '#up' do
+ it 'migrates can_push from deploy_keys to deploy_keys_projects' do
+ deploy_keys.limit(5).update_all(can_push: true)
+
+ expected = deploy_keys.order(:id).pluck(:id, :can_push)
+
+ migration.up
+
+ expect(deploy_keys_projects.order(:id).pluck(:deploy_key_id, :can_push)).to eq expected
+ end
+ end
+
+ describe '#down' do
+ it 'migrates can_push from deploy_keys_projects to deploy_keys' do
+ deploy_keys_projects.limit(5).update_all(can_push: true)
+
+ expected = deploy_keys_projects.order(:id).pluck(:deploy_key_id, :can_push)
+
+ migration.down
+
+ expect(deploy_keys.order(:id).pluck(:id, :can_push)).to eq expected
+ end
+ end
+end
diff --git a/spec/migrations/remove_dot_git_from_usernames_spec.rb b/spec/migrations/remove_dot_git_from_usernames_spec.rb
index 129374cb38c..f11880a83e9 100644
--- a/spec/migrations/remove_dot_git_from_usernames_spec.rb
+++ b/spec/migrations/remove_dot_git_from_usernames_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
require Rails.root.join('db', 'migrate', '20161226122833_remove_dot_git_from_usernames.rb')
describe RemoveDotGitFromUsernames do
- let(:user) { create(:user) }
+ let(:user) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
let(:migration) { described_class.new }
describe '#up' do
@@ -23,13 +23,15 @@ describe RemoveDotGitFromUsernames do
context 'when new path exists already' do
describe '#up' do
- let(:user2) { create(:user) }
+ let(:user2) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
before do
update_namespace(user, 'test.git')
update_namespace(user2, 'test_git')
- storages = { 'default' => 'tmp/tests/custom_repositories' }
+ default_hash = Gitlab.config.repositories.storages.default.to_h
+ default_hash['path'] = 'tmp/tests/custom_repositories'
+ storages = { 'default' => Gitlab::GitalyClient::StorageSettings.new(default_hash) }
allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
allow(migration).to receive(:route_exists?).with('test_git').and_return(true)
diff --git a/spec/migrations/remove_duplicate_mr_events_spec.rb b/spec/migrations/remove_duplicate_mr_events_spec.rb
index e393374028f..2509ac6afd6 100644
--- a/spec/migrations/remove_duplicate_mr_events_spec.rb
+++ b/spec/migrations/remove_duplicate_mr_events_spec.rb
@@ -1,21 +1,21 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170815060945_remove_duplicate_mr_events.rb')
-describe RemoveDuplicateMrEvents, truncate: true do
+describe RemoveDuplicateMrEvents, :delete 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(:user) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let(:merge_requests) { create_list(:merge_request, 2) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let(:issue) { create(:issue) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
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)
+ create(:event, :created, author: user, target: merge_requests.first), # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:event, :created, author: user, target: merge_requests.first), # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:event, :updated, author: user, target: merge_requests.first), # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:event, :created, author: user, target: merge_requests.second), # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:event, :created, author: user, target: issue), # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:event, :created, author: user, target: issue) # rubocop:disable RSpec/FactoriesInMigrationSpecs
]
end
diff --git a/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb b/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb
new file mode 100644
index 00000000000..441c4295a40
--- /dev/null
+++ b/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180220150310_remove_empty_extern_uid_auth0_identities.rb')
+
+describe RemoveEmptyExternUidAuth0Identities, :migration do
+ let(:identities) { table(:identities) }
+
+ before do
+ identities.create(provider: 'auth0', extern_uid: '')
+ identities.create(provider: 'auth0', extern_uid: 'valid')
+ identities.create(provider: 'github', extern_uid: '')
+
+ migrate!
+ end
+
+ it 'leaves the correct auth0 identity' do
+ expect(identities.where(provider: 'auth0').pluck(:extern_uid)).to eq(['valid'])
+ end
+
+ it 'leaves the correct github identity' do
+ expect(identities.where(provider: 'github').count).to eq(1)
+ end
+end
diff --git a/spec/migrations/remove_empty_fork_networks_spec.rb b/spec/migrations/remove_empty_fork_networks_spec.rb
index cf6ae5cda74..f6d030ab25c 100644
--- a/spec/migrations/remove_empty_fork_networks_spec.rb
+++ b/spec/migrations/remove_empty_fork_networks_spec.rb
@@ -3,15 +3,26 @@ require Rails.root.join('db', 'post_migrate', '20171114104051_remove_empty_fork_
describe RemoveEmptyForkNetworks, :migration do
let!(:fork_networks) { table(:fork_networks) }
+ let!(:projects) { table(:projects) }
+ let!(:fork_network_members) { table(:fork_network_members) }
- let(:deleted_project) { create(:project) }
- let!(:empty_network) { create(:fork_network, id: 1, root_project_id: deleted_project.id) }
- let!(:other_network) { create(:fork_network, id: 2, root_project_id: create(:project).id) }
+ let(:deleted_project) { projects.create! }
+ let!(:empty_network) { fork_networks.create!(id: 1, root_project_id: deleted_project.id) }
+ let!(:other_network) { fork_networks.create!(id: 2, root_project_id: projects.create.id) }
before do
+ fork_network_members.create(fork_network_id: empty_network.id,
+ project_id: empty_network.root_project_id)
+ fork_network_members.create(fork_network_id: other_network.id,
+ project_id: other_network.root_project_id)
+
deleted_project.destroy!
end
+ after do
+ Upload.reset_column_information
+ end
+
it 'deletes only the fork network without members' do
expect(fork_networks.count).to eq(2)
diff --git a/spec/migrations/remove_project_labels_group_id_spec.rb b/spec/migrations/remove_project_labels_group_id_spec.rb
new file mode 100644
index 00000000000..01b09e71d83
--- /dev/null
+++ b/spec/migrations/remove_project_labels_group_id_spec.rb
@@ -0,0 +1,21 @@
+# encoding: utf-8
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180202111106_remove_project_labels_group_id.rb')
+
+describe RemoveProjectLabelsGroupId, :delete do
+ let(:migration) { described_class.new }
+ let(:group) { create(:group) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:project_label) { create(:label, group_id: group.id) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:group_label) { create(:group_label) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ describe '#up' do
+ it 'updates the project labels group ID' do
+ expect { migration.up }.to change { project_label.reload.group_id }.to(nil)
+ end
+
+ it 'keeps the group labels group ID' do
+ expect { migration.up }.not_to change { group_label.reload.group_id }
+ end
+ end
+end
diff --git a/spec/migrations/remove_redundant_pipeline_stages_spec.rb b/spec/migrations/remove_redundant_pipeline_stages_spec.rb
new file mode 100644
index 00000000000..8325f986594
--- /dev/null
+++ b/spec/migrations/remove_redundant_pipeline_stages_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180119121225_remove_redundant_pipeline_stages.rb')
+
+describe RemoveRedundantPipelineStages, :migration do
+ let(:projects) { table(:projects) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:stages) { table(:ci_stages) }
+ let(:builds) { table(:ci_builds) }
+
+ before do
+ projects.create!(id: 123, name: 'gitlab', path: 'gitlab-ce')
+ pipelines.create!(id: 234, project_id: 123, ref: 'master', sha: 'adf43c3a')
+
+ stages.create!(id: 6, project_id: 123, pipeline_id: 234, name: 'build')
+ stages.create!(id: 10, project_id: 123, pipeline_id: 234, name: 'build')
+ stages.create!(id: 21, project_id: 123, pipeline_id: 234, name: 'build')
+ stages.create!(id: 41, project_id: 123, pipeline_id: 234, name: 'test')
+ stages.create!(id: 62, project_id: 123, pipeline_id: 234, name: 'test')
+ stages.create!(id: 102, project_id: 123, pipeline_id: 234, name: 'deploy')
+
+ builds.create!(id: 1, commit_id: 234, project_id: 123, stage_id: 10)
+ builds.create!(id: 2, commit_id: 234, project_id: 123, stage_id: 21)
+ builds.create!(id: 3, commit_id: 234, project_id: 123, stage_id: 21)
+ builds.create!(id: 4, commit_id: 234, project_id: 123, stage_id: 41)
+ builds.create!(id: 5, commit_id: 234, project_id: 123, stage_id: 62)
+ builds.create!(id: 6, commit_id: 234, project_id: 123, stage_id: 102)
+ end
+
+ it 'removes ambiguous stages and preserves builds' do
+ expect(stages.all.count).to eq 6
+ expect(builds.all.count).to eq 6
+
+ migrate!
+
+ expect(stages.all.count).to eq 1
+ expect(builds.all.count).to eq 6
+ expect(builds.all.pluck(:stage_id).compact).to eq [102]
+ end
+
+ it 'retries when incorrectly added index exception is caught' do
+ allow_any_instance_of(described_class)
+ .to receive(:remove_redundant_pipeline_stages!)
+
+ expect_any_instance_of(described_class)
+ .to receive(:remove_outdated_index!)
+ .exactly(100).times.and_call_original
+
+ expect { migrate! }
+ .to raise_error StandardError, /Failed to add an unique index/
+ end
+
+ it 'does not retry when unknown exception is being raised' do
+ allow(subject).to receive(:remove_outdated_index!)
+ expect(subject).to receive(:remove_redundant_pipeline_stages!).once
+ allow(subject).to receive(:add_unique_index!).and_raise(StandardError)
+
+ expect { subject.up(attempts: 3) }.to raise_error StandardError
+ end
+end
diff --git a/spec/migrations/remove_soft_removed_objects_spec.rb b/spec/migrations/remove_soft_removed_objects_spec.rb
new file mode 100644
index 00000000000..fb70c284f5e
--- /dev/null
+++ b/spec/migrations/remove_soft_removed_objects_spec.rb
@@ -0,0 +1,77 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20171207150343_remove_soft_removed_objects.rb')
+
+describe RemoveSoftRemovedObjects, :migration do
+ describe '#up' do
+ it 'removes various soft removed objects' do
+ 5.times do
+ create_with_deleted_at(:issue)
+ end
+
+ regular_issue = create(:issue) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ run_migration
+
+ expect(Issue.count).to eq(1)
+ expect(Issue.first).to eq(regular_issue)
+ end
+
+ it 'removes the temporary indexes once soft removed data has been removed' do
+ migration = described_class.new
+
+ run_migration
+
+ disable_migrations_output do
+ expect(migration.temporary_index_exists?(Issue)).to eq(false)
+ end
+ end
+
+ it 'removes routes of soft removed personal namespaces' do
+ namespace = create_with_deleted_at(:namespace)
+ group = create(:group) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ expect(Route.where(source: namespace).exists?).to eq(true)
+ expect(Route.where(source: group).exists?).to eq(true)
+
+ run_migration
+
+ expect(Route.where(source: namespace).exists?).to eq(false)
+ expect(Route.where(source: group).exists?).to eq(true)
+ end
+
+ it 'schedules the removal of soft removed groups' do
+ group = create_with_deleted_at(:group)
+ admin = create(:user, admin: true) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ expect_any_instance_of(GroupDestroyWorker)
+ .to receive(:perform)
+ .with(group.id, admin.id)
+
+ run_migration
+ end
+
+ it 'does not remove soft removed groups when no admin user could be found' do
+ create_with_deleted_at(:group)
+
+ expect_any_instance_of(GroupDestroyWorker)
+ .not_to receive(:perform)
+
+ run_migration
+ end
+ end
+
+ def run_migration
+ disable_migrations_output do
+ migrate!
+ end
+ end
+
+ def create_with_deleted_at(*args)
+ row = create(*args) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ # We set "deleted_at" this way so we don't run into any column cache issues.
+ row.class.where(id: row.id).update_all(deleted_at: 1.year.ago)
+
+ row
+ 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 ae3a4cb9b29..034e8a6a4e5 100644
--- a/spec/migrations/rename_more_reserved_project_names_spec.rb
+++ b/spec/migrations/rename_more_reserved_project_names_spec.rb
@@ -5,10 +5,10 @@ require Rails.root.join('db', 'post_migrate', '20170313133418_rename_more_reserv
# 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 RenameMoreReservedProjectNames, truncate: true do
+# around this we use the DELETE cleaning strategy.
+describe RenameMoreReservedProjectNames, :delete do
let(:migration) { described_class.new }
- let!(:project) { create(:project) }
+ let!(:project) { create(:project) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
before do
project.path = 'artifacts'
diff --git a/spec/migrations/rename_reserved_project_names_spec.rb b/spec/migrations/rename_reserved_project_names_spec.rb
index 462f4c08d63..592ac2b5fb9 100644
--- a/spec/migrations/rename_reserved_project_names_spec.rb
+++ b/spec/migrations/rename_reserved_project_names_spec.rb
@@ -3,12 +3,16 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20161221153951_rename_reserved_project_names.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 RenameReservedProjectNames, truncate: true do
+# This migration is using factories, which set fields that don't actually
+# exist in the DB schema previous to 20161221153951. Thus we just use the
+# latest schema when testing this migration.
+# This is ok-ish because:
+# 1. This migration is a data migration
+# 2. It only relies on very stable DB fields: routes.id, routes.path, namespaces.id, projects.namespace_id
+# Ideally, the test should not use factories and rely on the `table` helper instead.
+describe RenameReservedProjectNames, :migration, schema: :latest do
let(:migration) { described_class.new }
- let!(:project) { create(:project) }
+ let!(:project) { create(:project) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
before do
project.path = 'projects'
diff --git a/spec/migrations/rename_users_with_renamed_namespace_spec.rb b/spec/migrations/rename_users_with_renamed_namespace_spec.rb
index 1e9aab3d9a1..b8a4dc2b2c0 100644
--- a/spec/migrations/rename_users_with_renamed_namespace_spec.rb
+++ b/spec/migrations/rename_users_with_renamed_namespace_spec.rb
@@ -1,15 +1,15 @@
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20170518200835_rename_users_with_renamed_namespace.rb')
-describe RenameUsersWithRenamedNamespace, truncate: true do
+describe RenameUsersWithRenamedNamespace, :delete 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')
+ other_user = create(:user, username: 'kodingu') # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ other_user1 = create(:user, username: 'api0') # rubocop:disable RSpec/FactoriesInMigrationSpecs
- user = create(:user, username: "Users0")
- user.update_attribute(:username, 'Users')
- user1 = create(:user, username: "import0")
- user1.update_attribute(:username, 'import')
+ user = create(:user, username: "Users0") # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ user.update_column(:username, 'Users')
+ user1 = create(:user, username: "import0") # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ user1.update_column(:username, 'import')
described_class.new.up
diff --git a/spec/migrations/reschedule_builds_stages_migration_spec.rb b/spec/migrations/reschedule_builds_stages_migration_spec.rb
new file mode 100644
index 00000000000..3bfd9dd9f6b
--- /dev/null
+++ b/spec/migrations/reschedule_builds_stages_migration_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180405101928_reschedule_builds_stages_migration')
+
+describe RescheduleBuildsStagesMigration, :sidekiq, :migration do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:pipelines) { table(:ci_pipelines) }
+ let(:stages) { table(:ci_stages) }
+ let(:jobs) { table(:ci_builds) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+
+ namespaces.create(id: 12, name: 'gitlab-org', path: 'gitlab-org')
+ projects.create!(id: 123, namespace_id: 12, name: 'gitlab', path: 'gitlab')
+ pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a')
+ stages.create!(id: 1, project_id: 123, pipeline_id: 1, name: 'test')
+
+ jobs.create!(id: 11, commit_id: 1, project_id: 123, stage_id: nil)
+ jobs.create!(id: 206, commit_id: 1, project_id: 123, stage_id: nil)
+ jobs.create!(id: 3413, commit_id: 1, project_id: 123, stage_id: nil)
+ jobs.create!(id: 4109, commit_id: 1, project_id: 123, stage_id: 1)
+ end
+
+ it 'schedules delayed background migrations in batches in bulk' do
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 11, 11)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 206, 206)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(15.minutes, 3413, 3413)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ end
+ end
+ end
+end
diff --git a/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb b/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb
new file mode 100644
index 00000000000..26489ef58bd
--- /dev/null
+++ b/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20180309121820_reschedule_commits_count_for_merge_request_diff')
+
+describe RescheduleCommitsCountForMergeRequestDiff, :migration, :sidekiq do
+ let(:merge_request_diffs) { table(:merge_request_diffs) }
+ let(:merge_requests) { table(:merge_requests) }
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 1)
+
+ namespaces.create!(id: 1, name: 'gitlab', path: 'gitlab')
+
+ projects.create!(id: 1, namespace_id: 1)
+
+ 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)
+ merge_request_diffs.create!(id: 2, merge_request_id: 1)
+ merge_request_diffs.create!(id: 3, merge_request_id: 1, commits_count: 0)
+ merge_request_diffs.create!(id: 4, merge_request_id: 1)
+ end
+
+ it 'correctly schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 1)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 2, 2)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(15.minutes, 4, 4)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ end
+ end
+ end
+end
diff --git a/spec/migrations/schedule_create_gpg_key_subkeys_from_gpg_keys_spec.rb b/spec/migrations/schedule_create_gpg_key_subkeys_from_gpg_keys_spec.rb
index 0e884a7d910..ed306fb3d62 100644
--- a/spec/migrations/schedule_create_gpg_key_subkeys_from_gpg_keys_spec.rb
+++ b/spec/migrations/schedule_create_gpg_key_subkeys_from_gpg_keys_spec.rb
@@ -2,21 +2,9 @@ require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20171005130944_schedule_create_gpg_key_subkeys_from_gpg_keys')
describe ScheduleCreateGpgKeySubkeysFromGpgKeys, :migration, :sidekiq do
- matcher :be_scheduled_migration do |*expected|
- match do |migration|
- BackgroundMigrationWorker.jobs.any? do |job|
- job['args'] == [migration, expected]
- end
- end
-
- failure_message do |migration|
- "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
- end
- end
-
before do
- create(:gpg_key, id: 1, key: GpgHelpers::User1.public_key)
- create(:gpg_key, id: 2, key: GpgHelpers::User3.public_key)
+ create(:gpg_key, id: 1, key: GpgHelpers::User1.public_key) # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ create(:gpg_key, id: 2, key: GpgHelpers::User3.public_key) # rubocop:disable RSpec/FactoriesInMigrationSpecs
# Delete all subkeys so they can be recreated
GpgKeySubkey.destroy_all
end
diff --git a/spec/migrations/schedule_merge_request_diff_migrations_spec.rb b/spec/migrations/schedule_merge_request_diff_migrations_spec.rb
index 76afb6c19cf..d230f064444 100644
--- a/spec/migrations/schedule_merge_request_diff_migrations_spec.rb
+++ b/spec/migrations/schedule_merge_request_diff_migrations_spec.rb
@@ -24,9 +24,9 @@ describe ScheduleMergeRequestDiffMigrations, :migration, :sidekiq do
Timecop.freeze do
migrate!
- expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes, 1, 1)
- expect(described_class::MIGRATION).to be_scheduled_migration(10.minutes, 2, 2)
- expect(described_class::MIGRATION).to be_scheduled_migration(15.minutes, 4, 4)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 1)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 2, 2)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(15.minutes, 4, 4)
expect(BackgroundMigrationWorker.jobs.size).to eq 3
end
end
diff --git a/spec/migrations/schedule_merge_request_diff_migrations_take_two_spec.rb b/spec/migrations/schedule_merge_request_diff_migrations_take_two_spec.rb
index cf323973384..1aab4ae1650 100644
--- a/spec/migrations/schedule_merge_request_diff_migrations_take_two_spec.rb
+++ b/spec/migrations/schedule_merge_request_diff_migrations_take_two_spec.rb
@@ -24,9 +24,9 @@ describe ScheduleMergeRequestDiffMigrationsTakeTwo, :migration, :sidekiq do
Timecop.freeze do
migrate!
- expect(described_class::MIGRATION).to be_scheduled_migration(10.minutes, 1, 1)
- expect(described_class::MIGRATION).to be_scheduled_migration(20.minutes, 2, 2)
- expect(described_class::MIGRATION).to be_scheduled_migration(30.minutes, 4, 4)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 1, 1)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(20.minutes, 2, 2)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(30.minutes, 4, 4)
expect(BackgroundMigrationWorker.jobs.size).to eq 3
end
end
diff --git a/spec/migrations/schedule_merge_request_latest_merge_request_diff_id_migrations_spec.rb b/spec/migrations/schedule_merge_request_latest_merge_request_diff_id_migrations_spec.rb
index 158d0bc02ed..c9fdbe95d13 100644
--- a/spec/migrations/schedule_merge_request_latest_merge_request_diff_id_migrations_spec.rb
+++ b/spec/migrations/schedule_merge_request_latest_merge_request_diff_id_migrations_spec.rb
@@ -44,9 +44,9 @@ describe ScheduleMergeRequestLatestMergeRequestDiffIdMigrations, :migration, :si
Timecop.freeze do
migrate!
- expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes, merge_request_1.id, merge_request_1.id)
- expect(described_class::MIGRATION).to be_scheduled_migration(10.minutes, merge_request_2.id, merge_request_2.id)
- expect(described_class::MIGRATION).to be_scheduled_migration(15.minutes, merge_request_4.id, merge_request_4.id)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, merge_request_1.id, merge_request_1.id)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, merge_request_2.id, merge_request_2.id)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(15.minutes, merge_request_4.id, merge_request_4.id)
expect(BackgroundMigrationWorker.jobs.size).to eq 3
end
end
diff --git a/spec/migrations/schedule_populate_merge_request_metrics_with_events_data_spec.rb b/spec/migrations/schedule_populate_merge_request_metrics_with_events_data_spec.rb
new file mode 100644
index 00000000000..578440cba20
--- /dev/null
+++ b/spec/migrations/schedule_populate_merge_request_metrics_with_events_data_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20171128214150_schedule_populate_merge_request_metrics_with_events_data.rb')
+
+describe SchedulePopulateMergeRequestMetricsWithEventsData, :migration, :sidekiq do
+ # commits_count attribute is added in a next migration
+ before do
+ allow_any_instance_of(MergeRequestDiff)
+ .to receive(:commits_count=).and_return(nil)
+ end
+
+ let!(:mrs) { create_list(:merge_request, 3) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+
+ it 'correctly schedules background migrations' do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(10.minutes, mrs.first.id, mrs.second.id)
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(20.minutes, mrs.third.id, mrs.third.id)
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb b/spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb
new file mode 100644
index 00000000000..027f4a91c90
--- /dev/null
+++ b/spec/migrations/schedule_set_confidential_note_events_on_webhooks_spec.rb
@@ -0,0 +1,44 @@
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20180104131052_schedule_set_confidential_note_events_on_webhooks.rb')
+
+describe ScheduleSetConfidentialNoteEventsOnWebhooks, :migration, :sidekiq do
+ let(:web_hooks_table) { table(:web_hooks) }
+ let(:migration_class) { Gitlab::BackgroundMigration::SetConfidentialNoteEventsOnWebhooks }
+ let(:migration_name) { migration_class.to_s.demodulize }
+
+ let!(:web_hook_1) { web_hooks_table.create!(confidential_note_events: nil, note_events: true) }
+ let!(:web_hook_2) { web_hooks_table.create!(confidential_note_events: nil, note_events: true) }
+ let!(:web_hook_migrated) { web_hooks_table.create!(confidential_note_events: true, note_events: true) }
+ let!(:web_hook_skip) { web_hooks_table.create!(confidential_note_events: nil, note_events: false) }
+ let!(:web_hook_new) { web_hooks_table.create!(confidential_note_events: false, note_events: true) }
+ let!(:web_hook_4) { web_hooks_table.create!(confidential_note_events: nil, note_events: true) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+ end
+
+ it 'schedules background migrations at correct time' do
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(migration_name).to be_scheduled_delayed_migration(5.minutes, web_hook_1.id, web_hook_1.id)
+ expect(migration_name).to be_scheduled_delayed_migration(10.minutes, web_hook_2.id, web_hook_2.id)
+ expect(migration_name).to be_scheduled_delayed_migration(15.minutes, web_hook_4.id, web_hook_4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ end
+ end
+ end
+
+ it 'correctly processes web hooks' do
+ Sidekiq::Testing.inline! do
+ expect(web_hooks_table.where(confidential_note_events: nil).count).to eq 4
+ expect(web_hooks_table.where(confidential_note_events: true).count).to eq 1
+
+ migrate!
+
+ expect(web_hooks_table.where(confidential_note_events: nil).count).to eq 1
+ expect(web_hooks_table.where(confidential_note_events: true).count).to eq 4
+ end
+ end
+end
diff --git a/spec/migrations/track_untracked_uploads_spec.rb b/spec/migrations/track_untracked_uploads_spec.rb
index 7fe7a140e2f..2fccfb3f12c 100644
--- a/spec/migrations/track_untracked_uploads_spec.rb
+++ b/spec/migrations/track_untracked_uploads_spec.rb
@@ -2,19 +2,7 @@ require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20171103140253_track_untracked_uploads')
describe TrackUntrackedUploads, :migration, :sidekiq do
- include TrackUntrackedUploadsHelpers
-
- matcher :be_scheduled_migration do
- match do |migration|
- BackgroundMigrationWorker.jobs.any? do |job|
- job['args'] == [migration]
- end
- end
-
- failure_message do |migration|
- "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
- end
- end
+ include MigrationsHelpers::TrackUntrackedUploadsHelpers
it 'correctly schedules the follow-up background migration' do
Sidekiq::Testing.fake! do
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
index 6f7a730edff..560409f08de 100644
--- 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
@@ -2,10 +2,10 @@ 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!(:parent_group) { create(:group) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:child_group) { create(:group, parent: parent_group) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:project) { create(:project, :legacy_storage, :empty_repo, namespace: child_group) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:member) { create(:user) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
let(:migration) { described_class.new }
before do
diff --git a/spec/migrations/update_retried_for_ci_build_spec.rb b/spec/migrations/update_retried_for_ci_build_spec.rb
index 3742b4dafe5..637dcbb8e01 100644
--- a/spec/migrations/update_retried_for_ci_build_spec.rb
+++ b/spec/migrations/update_retried_for_ci_build_spec.rb
@@ -1,10 +1,10 @@
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') }
+describe UpdateRetriedForCiBuild, :delete do
+ let(:pipeline) { create(:ci_pipeline) } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:build_old) { create(:ci_build, pipeline: pipeline, name: 'test') } # rubocop:disable RSpec/FactoriesInMigrationSpecs
+ let!(:build_new) { create(:ci_build, pipeline: pipeline, name: 'test') } # rubocop:disable RSpec/FactoriesInMigrationSpecs
before do
described_class.new.up
diff --git a/spec/migrations/update_upload_paths_to_system_spec.rb b/spec/migrations/update_upload_paths_to_system_spec.rb
index d4a1553fb0e..984b428a020 100644
--- a/spec/migrations/update_upload_paths_to_system_spec.rb
+++ b/spec/migrations/update_upload_paths_to_system_spec.rb
@@ -1,53 +1,59 @@
-require "spec_helper"
-require Rails.root.join("db", "post_migrate", "20170317162059_update_upload_paths_to_system.rb")
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20170317162059_update_upload_paths_to_system.rb')
-describe UpdateUploadPathsToSystem do
+describe UpdateUploadPathsToSystem, :migration do
let(:migration) { described_class.new }
+ let(:uploads_table) { table(:uploads) }
+ let(:base_upload_attributes) { { size: 42, uploader: 'John Doe' } }
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")
+ 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('Pipeline', 'uploads/ci_pipeline/avatar.jpg')
+ _upload_with_system_path = create_upload('Project', 'uploads/-/system/project/avatar.jpg')
+ _upload_with_other_path = create_upload('Project', 'thelongsecretforafileupload/avatar.jpg')
+ old_upload = create_upload('Project', 'uploads/project/avatar.jpg')
+ group_upload = create_upload('Namespace', 'uploads/group/avatar.jpg')
- expect(Upload.where(migration.uploads_to_switch_to_new_path)).to contain_exactly(old_upload, group_upload)
+ expect(uploads_table.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")
+ 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('Pipeline', 'uploads/ci_pipeline/avatar.jpg')
+ upload_with_system_path = create_upload('Project', 'uploads/-/system/project/avatar.jpg')
+ _upload_with_other_path = create_upload('Project', 'thelongsecretforafileupload/avatar.jpg')
+ _old_upload = create_upload('Project', 'uploads/project/avatar.jpg')
- expect(Upload.where(migration.uploads_to_switch_to_old_path)).to contain_exactly(upload_with_system_path)
+ expect(uploads_table.where(migration.uploads_to_switch_to_old_path)).to contain_exactly(upload_with_system_path)
end
end
- describe "#up", :truncate do
- it "updates old upload records to the new path" do
- old_upload = create(:upload, model: create(:project), path: "uploads/project/avatar.jpg")
+ describe '#up' do
+ it 'updates old upload records to the new path' do
+ old_upload = create_upload('Project', 'uploads/project/avatar.jpg')
migration.up
- expect(old_upload.reload.path).to eq("uploads/-/system/project/avatar.jpg")
+ expect(old_upload.reload.path).to eq('uploads/-/system/project/avatar.jpg')
end
end
- describe "#down", :truncate 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")
+ describe '#down' do
+ it 'updates the new system patsh to the old paths' do
+ new_upload = create_upload('Project', 'uploads/-/system/project/avatar.jpg')
migration.down
- expect(new_upload.reload.path).to eq("uploads/project/avatar.jpg")
+ expect(new_upload.reload.path).to eq('uploads/project/avatar.jpg')
end
end
+
+ def create_upload(type, path)
+ uploads_table.create(base_upload_attributes.merge(model_type: type, path: path))
+ end
end
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb
index 71aa51e1857..cd175dba6da 100644
--- a/spec/models/ability_spec.rb
+++ b/spec/models/ability_spec.rb
@@ -34,19 +34,19 @@ describe Ability do
end
it 'returns false for a guest user' do
- project.team << [user, :guest]
+ project.add_guest(user)
expect(described_class.can_edit_note?(user, note)).to be_falsy
end
it 'returns false for a developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
expect(described_class.can_edit_note?(user, note)).to be_falsy
end
it 'returns true for a master' do
- project.team << [user, :master]
+ project.add_master(user)
expect(described_class.can_edit_note?(user, note)).to be_truthy
end
@@ -204,6 +204,78 @@ describe Ability do
end
end
+ describe '.merge_requests_readable_by_user' do
+ context 'with an admin' do
+ it 'returns all merge requests' do
+ user = build(:user, admin: true)
+ merge_request = build(:merge_request)
+
+ expect(described_class.merge_requests_readable_by_user([merge_request], user))
+ .to eq([merge_request])
+ end
+ end
+
+ context 'without a user' do
+ it 'returns merge_requests that are publicly visible' do
+ hidden_merge_request = build(:merge_request)
+ visible_merge_request = build(:merge_request, source_project: build(:project, :public))
+
+ merge_requests = described_class
+ .merge_requests_readable_by_user([hidden_merge_request, visible_merge_request])
+
+ expect(merge_requests).to eq([visible_merge_request])
+ end
+ end
+
+ context 'with a user' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:cross_project_merge_request) do
+ create(:merge_request, source_project: create(:project, :public))
+ end
+ let(:other_merge_request) { create(:merge_request) }
+ let(:all_merge_requests) do
+ [merge_request, cross_project_merge_request, other_merge_request]
+ end
+
+ subject(:readable_merge_requests) do
+ described_class.merge_requests_readable_by_user(all_merge_requests, user)
+ end
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns projects visible to the user' do
+ expect(readable_merge_requests).to contain_exactly(merge_request, cross_project_merge_request)
+ end
+
+ context 'when a user cannot read cross project and a filter is passed' do
+ before do
+ allow(described_class).to receive(:allowed?).and_call_original
+ expect(described_class).to receive(:allowed?).with(user, :read_cross_project) { false }
+ end
+
+ subject(:readable_merge_requests) do
+ read_cross_project_filter = -> (merge_requests) do
+ merge_requests.select { |mr| mr.source_project == project }
+ end
+ described_class.merge_requests_readable_by_user(
+ all_merge_requests, user,
+ filters: { read_cross_project: read_cross_project_filter }
+ )
+ end
+
+ it 'returns only MRs of the specified project without checking access on others' do
+ expect(described_class).not_to receive(:allowed?).with(user, :read_merge_request, cross_project_merge_request)
+
+ expect(readable_merge_requests).to contain_exactly(merge_request)
+ end
+ end
+ end
+ end
+
describe '.issues_readable_by_user' do
context 'with an admin user' do
it 'returns all given issues' do
@@ -250,6 +322,29 @@ describe Ability do
expect(issues).to eq([visible_issue])
end
end
+
+ context 'when the user cannot read cross project' do
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue) }
+ let(:other_project_issue) { create(:issue) }
+ let(:project) { issue.project }
+
+ before do
+ project.add_developer(user)
+
+ allow(described_class).to receive(:allowed?).and_call_original
+ allow(described_class).to receive(:allowed?).with(user, :read_cross_project, any_args) { false }
+ end
+
+ it 'excludes issues from other projects whithout checking separatly when passing a scope' do
+ expect(described_class).not_to receive(:allowed?).with(user, :read_issue, other_project_issue)
+
+ filters = { read_cross_project: -> (issues) { issues.where(project: project) } }
+ result = described_class.issues_readable_by_user(Issue.all, user, filters: filters)
+
+ expect(result).to contain_exactly(issue)
+ end
+ end
end
describe '.project_disabled_features_rules' do
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index ef480e7a80a..ae2d34750a7 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -114,6 +114,40 @@ describe ApplicationSetting do
it { expect(setting.repository_storages).to eq(['default']) }
end
+ context 'auto_devops_domain setting' do
+ context 'when auto_devops_enabled? is true' do
+ before do
+ setting.update(auto_devops_enabled: true)
+ end
+
+ it 'can be blank' do
+ setting.update(auto_devops_domain: '')
+
+ expect(setting).to be_valid
+ end
+
+ context 'with a valid value' do
+ before do
+ setting.update(auto_devops_domain: 'domain.com')
+ end
+
+ it 'is valid' do
+ expect(setting).to be_valid
+ end
+ end
+
+ context 'with an invalid value' do
+ before do
+ setting.update(auto_devops_domain: 'definitelynotahostname')
+ end
+
+ it 'is invalid' do
+ expect(setting).to be_invalid
+ end
+ end
+ end
+ end
+
context 'circuitbreaker settings' do
[:circuitbreaker_failure_count_threshold,
:circuitbreaker_check_interval,
diff --git a/spec/models/badge_spec.rb b/spec/models/badge_spec.rb
new file mode 100644
index 00000000000..33dc19e3432
--- /dev/null
+++ b/spec/models/badge_spec.rb
@@ -0,0 +1,94 @@
+require 'spec_helper'
+
+describe Badge do
+ let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' }
+
+ describe 'validations' do
+ # Requires the let variable url_sym
+ shared_examples 'placeholder url' do
+ let(:badge) { build(:badge) }
+
+ it 'allows url with http protocol' do
+ badge[url_sym] = 'http://www.example.com'
+
+ expect(badge).to be_valid
+ end
+
+ it 'allows url with https protocol' do
+ badge[url_sym] = 'https://www.example.com'
+
+ expect(badge).to be_valid
+ end
+
+ it 'cannot be empty' do
+ badge[url_sym] = ''
+
+ expect(badge).not_to be_valid
+ end
+
+ it 'cannot be nil' do
+ badge[url_sym] = nil
+
+ expect(badge).not_to be_valid
+ end
+
+ it 'accept badges placeholders' do
+ badge[url_sym] = placeholder_url
+
+ expect(badge).to be_valid
+ end
+
+ it 'sanitize url' do
+ badge[url_sym] = 'javascript:alert(1)'
+
+ expect(badge).not_to be_valid
+ end
+ end
+
+ context 'link_url format' do
+ let(:url_sym) { :link_url }
+
+ it_behaves_like 'placeholder url'
+ end
+
+ context 'image_url format' do
+ let(:url_sym) { :image_url }
+
+ it_behaves_like 'placeholder url'
+ end
+ end
+
+ shared_examples 'rendered_links' do
+ it 'should use the project information to populate the url placeholders' do
+ stub_project_commit_info(project)
+
+ expect(badge.public_send("rendered_#{method}", project)).to eq "http://www.example.com/#{project.full_path}/#{project.id}/master/whatever"
+ end
+
+ it 'returns the url if the project used is nil' do
+ expect(badge.public_send("rendered_#{method}", nil)).to eq placeholder_url
+ end
+
+ def stub_project_commit_info(project)
+ allow(project).to receive(:commit).and_return(double('Commit', sha: 'whatever'))
+ allow(project).to receive(:default_branch).and_return('master')
+ end
+ end
+
+ context 'methods' do
+ let(:badge) { build(:badge, link_url: placeholder_url, image_url: placeholder_url) }
+ let!(:project) { create(:project) }
+
+ context '#rendered_link_url' do
+ let(:method) { :link_url }
+
+ it_behaves_like 'rendered_links'
+ end
+
+ context '#rendered_image_url' do
+ let(:method) { :image_url }
+
+ it_behaves_like 'rendered_links'
+ end
+ end
+end
diff --git a/spec/models/badges/group_badge_spec.rb b/spec/models/badges/group_badge_spec.rb
new file mode 100644
index 00000000000..ed7f83d0489
--- /dev/null
+++ b/spec/models/badges/group_badge_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe GroupBadge do
+ describe 'associations' do
+ it { is_expected.to belong_to(:group) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:group) }
+ end
+end
diff --git a/spec/models/badges/project_badge_spec.rb b/spec/models/badges/project_badge_spec.rb
new file mode 100644
index 00000000000..0e1a8159cb6
--- /dev/null
+++ b/spec/models/badges/project_badge_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe ProjectBadge do
+ let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ end
+
+ shared_examples 'rendered_links' do
+ it 'should use the badge project information to populate the url placeholders' do
+ stub_project_commit_info(project)
+
+ expect(badge.public_send("rendered_#{method}")).to eq "http://www.example.com/#{project.full_path}/#{project.id}/master/whatever"
+ end
+
+ def stub_project_commit_info(project)
+ allow(project).to receive(:commit).and_return(double('Commit', sha: 'whatever'))
+ allow(project).to receive(:default_branch).and_return('master')
+ end
+ end
+
+ context 'methods' do
+ let(:badge) { build(:project_badge, link_url: placeholder_url, image_url: placeholder_url) }
+ let!(:project) { badge.project }
+
+ context '#rendered_link_url' do
+ let(:method) { :link_url }
+
+ it_behaves_like 'rendered_links'
+ end
+
+ context '#rendered_image_url' do
+ let(:method) { :image_url }
+
+ it_behaves_like 'rendered_links'
+ end
+ end
+end
diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb
index e89e534d914..504bc710b25 100644
--- a/spec/models/chat_name_spec.rb
+++ b/spec/models/chat_name_spec.rb
@@ -14,4 +14,24 @@ describe ChatName do
it { is_expected.to validate_uniqueness_of(:user_id).scoped_to(:service_id) }
it { is_expected.to validate_uniqueness_of(:chat_id).scoped_to(:service_id, :team_id) }
+
+ describe '#update_last_used_at', :clean_gitlab_redis_shared_state do
+ it 'updates the last_used_at timestamp' do
+ expect(subject.last_used_at).to be_nil
+
+ subject.update_last_used_at
+
+ expect(subject.last_used_at).to be_present
+ end
+
+ it 'does not update last_used_at if it was recently updated' do
+ subject.update_last_used_at
+
+ time = subject.last_used_at
+
+ subject.update_last_used_at
+
+ expect(subject.last_used_at).to eq(time)
+ end
+ end
end
diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb
index 4e72d9d748e..0014bbcf9f5 100644
--- a/spec/models/ci/artifact_blob_spec.rb
+++ b/spec/models/ci/artifact_blob_spec.rb
@@ -65,6 +65,19 @@ describe Ci::ArtifactBlob do
expect(url).not_to be_nil
expect(url).to eq("http://#{project.namespace.path}.#{Gitlab.config.pages.host}/-/#{project.path}/-/jobs/#{build.id}/artifacts/#{path}")
end
+
+ context 'when port is configured' do
+ let(:port) { 1234 }
+
+ it 'returns an URL with port number' do
+ allow(Gitlab.config.pages).to receive(:url).and_return("#{Gitlab.config.pages.url}:#{port}")
+
+ url = subject.external_url(build.project, build)
+
+ expect(url).not_to be_nil
+ expect(url).to eq("http://#{project.namespace.path}.#{Gitlab.config.pages.host}:#{port}/-/#{project.path}/-/jobs/#{build.id}/artifacts/#{path}")
+ end
+ end
end
end
diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb
new file mode 100644
index 00000000000..7e75d5a5411
--- /dev/null
+++ b/spec/models/ci/build_metadata_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+describe Ci::BuildMetadata do
+ set(:user) { create(:user) }
+ set(:group) { create(:group, :access_requestable) }
+ set(:project) { create(:project, :repository, group: group, build_timeout: 2000) }
+
+ set(:pipeline) do
+ create(:ci_pipeline, project: project,
+ sha: project.commit.id,
+ ref: project.default_branch,
+ status: 'success')
+ end
+
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:build_metadata) { build.metadata }
+
+ describe '#update_timeout_state' do
+ subject { build_metadata }
+
+ context 'when runner is not assigned to the job' do
+ it "doesn't change timeout value" do
+ expect { subject.update_timeout_state }.not_to change { subject.reload.timeout }
+ end
+
+ it "doesn't change timeout_source value" do
+ expect { subject.update_timeout_state }.not_to change { subject.reload.timeout_source }
+ end
+ end
+
+ context 'when runner is assigned to the job' do
+ before do
+ build.update_attributes(runner: runner)
+ end
+
+ context 'when runner timeout is lower than project timeout' do
+ let(:runner) { create(:ci_runner, maximum_timeout: 1900) }
+
+ it 'sets runner timeout' do
+ expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(1900)
+ end
+
+ it 'sets runner_timeout_source' do
+ expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to('runner_timeout_source')
+ end
+ end
+
+ context 'when runner timeout is higher than project timeout' do
+ let(:runner) { create(:ci_runner, maximum_timeout: 2100) }
+
+ it 'sets project timeout' do
+ expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(2000)
+ end
+
+ it 'sets project_timeout_source' do
+ expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to('project_timeout_source')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 871e8b47650..a12717835b0 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -25,6 +25,13 @@ describe Ci::Build do
it { is_expected.to be_a(ArtifactMigratable) }
+ describe 'associations' do
+ it 'has a bidirectional relationship with projects' do
+ expect(described_class.reflect_on_association(:project).has_inverse?).to eq(:builds)
+ expect(Project.reflect_on_association(:builds).has_inverse?).to eq(:project)
+ end
+ end
+
describe 'callbacks' do
context 'when running after_create callback' do
it 'triggers asynchronous build hooks worker' do
@@ -73,6 +80,42 @@ describe Ci::Build do
end
end
+ describe '.with_artifacts_archive' do
+ subject { described_class.with_artifacts_archive }
+
+ context 'when job does not have an archive' do
+ let!(:job) { create(:ci_build) }
+
+ it 'does not return the job' do
+ is_expected.not_to include(job)
+ end
+ end
+
+ context 'when job has a legacy archive' do
+ let!(:job) { create(:ci_build, :legacy_artifacts) }
+
+ it 'returns the job' do
+ is_expected.to include(job)
+ end
+ end
+
+ context 'when job has a job artifact archive' do
+ let!(:job) { create(:ci_build, :artifacts) }
+
+ it 'returns the job' do
+ is_expected.to include(job)
+ end
+ end
+
+ context 'when job has a job artifact trace' do
+ let!(:job) { create(:ci_build, :trace_artifact) }
+
+ it 'does not return the job' do
+ is_expected.not_to include(job)
+ end
+ end
+ end
+
describe '#actionize' do
context 'when build is a created' do
before do
@@ -155,6 +198,16 @@ describe Ci::Build do
end
context 'when legacy artifacts are used' do
+ let(:build) { create(:ci_build, :legacy_artifacts) }
+
+ subject { build.artifacts? }
+
+ context 'is expired' do
+ let(:build) { create(:ci_build, :legacy_artifacts, :expired) }
+
+ it { is_expected.to be_falsy }
+ end
+
context 'artifacts archive does not exist' do
let(:build) { create(:ci_build) }
@@ -165,13 +218,25 @@ describe Ci::Build do
let(:build) { create(:ci_build, :legacy_artifacts) }
it { is_expected.to be_truthy }
+ end
+ end
+ end
- context 'is expired' do
- let(:build) { create(:ci_build, :legacy_artifacts, :expired) }
+ describe '#browsable_artifacts?' do
+ subject { build.browsable_artifacts? }
- it { is_expected.to be_falsy }
- end
+ context 'artifacts metadata does not exist' do
+ before do
+ build.update_attributes(legacy_artifacts_metadata: nil)
end
+
+ it { is_expected.to be_falsy }
+ end
+
+ context 'artifacts metadata does exists' do
+ let(:build) { create(:ci_build, :artifacts) }
+
+ it { is_expected.to be_truthy }
end
end
@@ -255,6 +320,42 @@ describe Ci::Build do
end
end
+ describe '#cache' do
+ let(:options) { { cache: { key: "key", paths: ["public"], policy: "pull-push" } } }
+
+ subject { build.cache }
+
+ context 'when build has cache' do
+ before do
+ allow(build).to receive(:options).and_return(options)
+ end
+
+ context 'when project has jobs_cache_index' do
+ before do
+ allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1)
+ end
+
+ it { is_expected.to be_an(Array).and all(include(key: "key-1")) }
+ end
+
+ context 'when project does not have jobs_cache_index' do
+ before do
+ allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(nil)
+ end
+
+ it { is_expected.to eq([options[:cache]]) }
+ end
+ end
+
+ context 'when build does not have cache' do
+ before do
+ allow(build).to receive(:options).and_return({})
+ end
+
+ it { is_expected.to eq([nil]) }
+ end
+ end
+
describe '#depends_on_builds' do
let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') }
let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') }
@@ -632,25 +733,25 @@ describe Ci::Build do
context 'build is erasable' do
context 'new artifacts' do
- let!(:build) { create(:ci_build, :trace, :success, :artifacts) }
+ let!(:build) { create(:ci_build, :trace_artifact, :success, :artifacts) }
describe '#erase' do
before do
- build.erase(erased_by: user)
+ build.erase(erased_by: erased_by)
end
context 'erased by user' do
- let!(:user) { create(:user, username: 'eraser') }
+ let!(:erased_by) { create(:user, username: 'eraser') }
include_examples 'erasable'
it 'records user who erased a build' do
- expect(build.erased_by).to eq user
+ expect(build.erased_by).to eq erased_by
end
end
context 'erased by system' do
- let(:user) { nil }
+ let(:erased_by) { nil }
include_examples 'erasable'
@@ -666,7 +767,7 @@ describe Ci::Build do
end
describe '#erased?' do
- let!(:build) { create(:ci_build, :trace, :success, :artifacts) }
+ let!(:build) { create(:ci_build, :trace_artifact, :success, :artifacts) }
subject { build.erased? }
context 'job has not been erased' do
@@ -701,25 +802,25 @@ describe Ci::Build do
context 'old artifacts' do
context 'build is erasable' do
context 'new artifacts' do
- let!(:build) { create(:ci_build, :trace, :success, :legacy_artifacts) }
+ let!(:build) { create(:ci_build, :trace_artifact, :success, :legacy_artifacts) }
describe '#erase' do
before do
- build.erase(erased_by: user)
+ build.erase(erased_by: erased_by)
end
context 'erased by user' do
- let!(:user) { create(:user, username: 'eraser') }
+ let!(:erased_by) { create(:user, username: 'eraser') }
include_examples 'erasable'
it 'records user who erased a build' do
- expect(build.erased_by).to eq user
+ expect(build.erased_by).to eq erased_by
end
end
context 'erased by system' do
- let(:user) { nil }
+ let(:erased_by) { nil }
include_examples 'erasable'
@@ -735,7 +836,7 @@ describe Ci::Build do
end
describe '#erased?' do
- let!(:build) { create(:ci_build, :trace, :success, :legacy_artifacts) }
+ let!(:build) { create(:ci_build, :trace_artifact, :success, :legacy_artifacts) }
subject { build.erased? }
context 'job has not been erased' do
@@ -1170,12 +1271,6 @@ describe Ci::Build do
end
describe 'project settings' do
- describe '#timeout' do
- it 'returns project timeout configuration' do
- expect(build.timeout).to eq(project.build_timeout)
- end
- end
-
describe '#allow_git_fetch' do
it 'return project allow_git_fetch configuration' do
expect(build.allow_git_fetch).to eq(project.build_allow_git_fetch)
@@ -1368,18 +1463,30 @@ describe Ci::Build do
let(:container_registry_enabled) { false }
let(:predefined_variables) do
[
+ { key: 'CI_JOB_ID', value: build.id.to_s, public: true },
+ { key: 'CI_JOB_TOKEN', value: build.token, public: false },
+ { key: 'CI_BUILD_ID', value: build.id.to_s, public: true },
+ { key: 'CI_BUILD_TOKEN', value: build.token, public: false },
+ { 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', value: 'true', public: true },
{ key: 'GITLAB_CI', value: 'true', public: true },
+ { key: 'GITLAB_FEATURES', value: project.namespace.features.join(','), public: true },
{ key: 'CI_SERVER_NAME', value: 'GitLab', public: true },
{ key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true },
{ key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true },
- { key: 'CI_JOB_ID', value: build.id.to_s, public: true },
{ key: 'CI_JOB_NAME', value: 'test', public: true },
{ key: 'CI_JOB_STAGE', value: 'test', public: true },
- { key: 'CI_JOB_TOKEN', value: build.token, public: false },
{ key: 'CI_COMMIT_SHA', value: build.sha, public: true },
{ key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true },
{ key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true },
+ { key: 'CI_BUILD_REF', value: build.sha, public: true },
+ { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true },
+ { key: 'CI_BUILD_REF_NAME', value: build.ref, public: true },
+ { key: 'CI_BUILD_REF_SLUG', value: build.ref_slug, public: true },
+ { key: 'CI_BUILD_NAME', value: 'test', public: true },
+ { key: 'CI_BUILD_STAGE', value: 'test', public: true },
{ 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 },
@@ -1389,9 +1496,7 @@ describe Ci::Build do
{ key: 'CI_PROJECT_VISIBILITY', value: 'private', 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_PIPELINE_SOURCE', value: pipeline.source, public: true }
]
end
@@ -1546,7 +1651,7 @@ describe Ci::Build do
context 'when the branch is protected' do
before do
- create(:protected_branch, project: build.project, name: build.ref)
+ allow(build.project).to receive(:protected_for?).with(build.ref).and_return(true)
end
it { is_expected.to include(protected_variable) }
@@ -1554,7 +1659,7 @@ describe Ci::Build do
context 'when the tag is protected' do
before do
- create(:protected_tag, project: build.project, name: build.ref)
+ allow(build.project).to receive(:protected_for?).with(build.ref).and_return(true)
end
it { is_expected.to include(protected_variable) }
@@ -1591,7 +1696,7 @@ describe Ci::Build do
context 'when the branch is protected' do
before do
- create(:protected_branch, project: build.project, name: build.ref)
+ allow(build.project).to receive(:protected_for?).with(build.ref).and_return(true)
end
it { is_expected.to include(protected_variable) }
@@ -1599,7 +1704,7 @@ describe Ci::Build do
context 'when the tag is protected' do
before do
- create(:protected_tag, project: build.project, name: build.ref)
+ allow(build.project).to receive(:protected_for?).with(build.ref).and_return(true)
end
it { is_expected.to include(protected_variable) }
@@ -1790,39 +1895,6 @@ describe Ci::Build do
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) { [build_pre_var] }
- allow(build).to receive(:yaml_variables) { [build_yaml_var] }
-
- allow_any_instance_of(Project)
- .to receive(:predefined_variables) { [project_pre_var] }
-
- allow_any_instance_of(Project)
- .to receive(:secret_variables_for)
- .with(ref: 'master', environment: nil) do
- [create(:ci_variable, key: 'secret', value: 'value')]
- end
-
- allow_any_instance_of(Ci::Pipeline)
- .to receive(:predefined_variables) { [pipeline_pre_var] }
- end
-
- 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
-
context 'when using auto devops' do
context 'and is enabled' do
before do
@@ -1846,6 +1918,182 @@ describe Ci::Build do
end
end
end
+
+ context 'when pipeline variable overrides build variable' do
+ before do
+ build.yaml_variables = [{ key: 'MYVAR', value: 'myvar', public: true }]
+ pipeline.variables.build(key: 'MYVAR', value: 'pipeline value')
+ end
+
+ it 'overrides YAML variable using a pipeline variable' do
+ variables = subject.reverse.uniq { |variable| variable[:key] }.reverse
+
+ expect(variables)
+ .not_to include(key: 'MYVAR', value: 'myvar', public: true)
+ expect(variables)
+ .to include(key: 'MYVAR', value: 'pipeline value', public: false)
+ end
+ end
+
+ describe 'variables ordering' do
+ context 'when variables hierarchy is stubbed' do
+ let(:build_pre_var) { { key: 'build', value: 'value', public: true } }
+ let(:project_pre_var) { { key: 'project', value: 'value', public: true } }
+ let(:pipeline_pre_var) { { key: 'pipeline', value: 'value', public: true } }
+ let(:build_yaml_var) { { key: 'yaml', value: 'value', public: true } }
+
+ before do
+ allow(build).to receive(:predefined_variables) { [build_pre_var] }
+ allow(build).to receive(:yaml_variables) { [build_yaml_var] }
+ allow(build).to receive(:persisted_variables) { [] }
+
+ allow_any_instance_of(Project)
+ .to receive(:predefined_variables) { [project_pre_var] }
+
+ allow_any_instance_of(Project)
+ .to receive(:secret_variables_for)
+ .with(ref: 'master', environment: nil) do
+ [create(:ci_variable, key: 'secret', value: 'value')]
+ end
+
+ allow_any_instance_of(Ci::Pipeline)
+ .to receive(:predefined_variables) { [pipeline_pre_var] }
+ end
+
+ it 'returns variables in order depending on resource hierarchy' 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
+
+ context 'when build has environment and user-provided variables' do
+ let(:expected_variables) do
+ predefined_variables.map { |variable| variable.fetch(:key) } +
+ %w[YAML_VARIABLE CI_ENVIRONMENT_NAME CI_ENVIRONMENT_SLUG
+ CI_ENVIRONMENT_URL]
+ end
+
+ before do
+ create(:environment, project: build.project,
+ name: 'staging')
+
+ build.yaml_variables = [{ key: 'YAML_VARIABLE',
+ value: 'var',
+ public: true }]
+ build.environment = 'staging'
+ end
+
+ it 'matches explicit variables ordering' do
+ received_variables = subject.map { |variable| variable.fetch(:key) }
+
+ expect(received_variables).to eq expected_variables
+ end
+ end
+ end
+
+ context 'when build has not been persisted yet' do
+ let(:build) do
+ described_class.new(
+ name: 'rspec',
+ stage: 'test',
+ ref: 'feature',
+ project: project,
+ pipeline: pipeline
+ )
+ end
+
+ it 'returns static predefined variables' do
+ expect(build.variables.size).to be >= 28
+ expect(build.variables)
+ .to include(key: 'CI_COMMIT_REF_NAME', value: 'feature', public: true)
+ expect(build).not_to be_persisted
+ end
+ end
+ end
+
+ describe '#scoped_variables' do
+ context 'when build has not been persisted yet' do
+ let(:build) do
+ described_class.new(
+ name: 'rspec',
+ stage: 'test',
+ ref: 'feature',
+ project: project,
+ pipeline: pipeline
+ )
+ end
+
+ it 'does not persist the build' do
+ expect(build).to be_valid
+ expect(build).not_to be_persisted
+
+ build.scoped_variables
+
+ expect(build).not_to be_persisted
+ end
+
+ it 'returns static predefined variables' do
+ keys = %w[CI_JOB_NAME
+ CI_COMMIT_SHA
+ CI_COMMIT_REF_NAME
+ CI_COMMIT_REF_SLUG
+ CI_JOB_STAGE]
+
+ variables = build.scoped_variables
+
+ variables.map { |env| env[:key] }.tap do |names|
+ expect(names).to include(*keys)
+ end
+
+ expect(variables)
+ .to include(key: 'CI_COMMIT_REF_NAME', value: 'feature', public: true)
+ end
+
+ it 'does not return prohibited variables' do
+ keys = %w[CI_JOB_ID
+ CI_JOB_TOKEN
+ CI_BUILD_ID
+ CI_BUILD_TOKEN
+ CI_REGISTRY_USER
+ CI_REGISTRY_PASSWORD
+ CI_REPOSITORY_URL
+ CI_ENVIRONMENT_URL]
+
+ build.scoped_variables.map { |env| env[:key] }.tap do |names|
+ expect(names).not_to include(*keys)
+ end
+ end
+ end
+ end
+
+ describe '#scoped_variables_hash' do
+ context 'when overriding secret variables' do
+ before do
+ project.variables.create!(key: 'MY_VAR', value: 'my value 1')
+ pipeline.variables.create!(key: 'MY_VAR', value: 'my value 2')
+ end
+
+ it 'returns a regular hash created using valid ordering' do
+ expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2')
+ expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1')
+ end
+ end
+
+ context 'when overriding user-provided variables' do
+ before do
+ pipeline.variables.build(key: 'MY_VAR', value: 'pipeline value')
+ build.yaml_variables = [{ key: 'MY_VAR', value: 'myvar', public: true }]
+ end
+
+ it 'returns a hash including variable with higher precedence' do
+ expect(build.scoped_variables_hash).to include('MY_VAR': 'pipeline value')
+ expect(build.scoped_variables_hash).not_to include('MY_VAR': 'myvar')
+ end
+ end
end
describe 'state transition: any => [:pending]' do
@@ -1858,12 +2106,76 @@ describe Ci::Build do
end
end
+ describe 'state transition: pending: :running' do
+ let(:runner) { create(:ci_runner) }
+ let(:job) { create(:ci_build, :pending, runner: runner) }
+
+ before do
+ job.project.update_attribute(:build_timeout, 1800)
+ end
+
+ def run_job_without_exception
+ job.run!
+ rescue StateMachines::InvalidTransition
+ end
+
+ shared_examples 'saves data on transition' do
+ it 'saves timeout' do
+ expect { job.run! }.to change { job.reload.ensure_metadata.timeout }.from(nil).to(expected_timeout)
+ end
+
+ it 'saves timeout_source' do
+ expect { job.run! }.to change { job.reload.ensure_metadata.timeout_source }.from('unknown_timeout_source').to(expected_timeout_source)
+ end
+
+ context 'when Ci::BuildMetadata#update_timeout_state fails update' do
+ before do
+ allow_any_instance_of(Ci::BuildMetadata).to receive(:update_timeout_state).and_return(false)
+ end
+
+ it "doesn't save timeout" do
+ expect { run_job_without_exception }.not_to change { job.reload.ensure_metadata.timeout_source }
+ end
+
+ it "doesn't save timeout_source" do
+ expect { run_job_without_exception }.not_to change { job.reload.ensure_metadata.timeout_source }
+ end
+
+ it 'raises an exception' do
+ expect { job.run! }.to raise_error(StateMachines::InvalidTransition)
+ end
+ end
+ end
+
+ context 'when runner timeout overrides project timeout' do
+ let(:expected_timeout) { 900 }
+ let(:expected_timeout_source) { 'runner_timeout_source' }
+
+ before do
+ runner.update_attribute(:maximum_timeout, 900)
+ end
+
+ it_behaves_like 'saves data on transition'
+ end
+
+ context "when runner timeout doesn't override project timeout" do
+ let(:expected_timeout) { 1800 }
+ let(:expected_timeout_source) { 'project_timeout_source' }
+
+ before do
+ runner.update_attribute(:maximum_timeout, 3600)
+ end
+
+ it_behaves_like 'saves data on transition'
+ end
+ end
+
describe 'state transition: any => [:running]' do
shared_examples 'validation is active' do
context 'when depended job has not been completed yet' do
let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
- it { expect { job.run! }.not_to raise_error(Ci::Build::MissingDependenciesError) }
+ it { expect { job.run! }.not_to raise_error }
end
context 'when artifacts of depended job has been expired' do
@@ -1970,6 +2282,35 @@ describe Ci::Build do
subject.drop!
end
+
+ context 'when retry service raises Gitlab::Access::AccessDeniedError exception' do
+ let(:retry_service) { Ci::RetryBuildService.new(subject.project, subject.user) }
+
+ before do
+ allow_any_instance_of(Ci::RetryBuildService)
+ .to receive(:execute)
+ .with(subject)
+ .and_raise(Gitlab::Access::AccessDeniedError)
+ allow(Rails.logger).to receive(:error)
+ end
+
+ it 'handles raised exception' do
+ expect { subject.drop! }.not_to raise_exception(Gitlab::Access::AccessDeniedError)
+ end
+
+ it 'logs the error' do
+ subject.drop!
+
+ expect(Rails.logger)
+ .to have_received(:error)
+ .with(a_string_matching("Unable to auto-retry job #{subject.id}"))
+ end
+
+ it 'fails the job' do
+ subject.drop!
+ expect(subject.failed?).to be_truthy
+ end
+ end
end
context 'when build is not configured to be retried' do
diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb
index 145189e7469..1b10501701c 100644
--- a/spec/models/ci/group_variable_spec.rb
+++ b/spec/models/ci/group_variable_spec.rb
@@ -5,7 +5,7 @@ describe Ci::GroupVariable 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(:group_id) }
+ it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id).with_message(/\(\w+\) has already been taken/) }
describe '.unprotected' do
subject { described_class.unprotected }
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index 0e18a326c68..1aa28434879 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -12,6 +12,53 @@ describe Ci::JobArtifact do
it { is_expected.to respond_to(:created_at) }
it { is_expected.to respond_to(:updated_at) }
+ it { is_expected.to delegate_method(:open).to(:file) }
+ it { is_expected.to delegate_method(:exists?).to(:file) }
+
+ describe 'callbacks' do
+ subject { create(:ci_job_artifact, :archive) }
+
+ describe '#schedule_background_upload' do
+ context 'when object storage is disabled' do
+ before do
+ stub_artifacts_object_storage(enabled: false)
+ end
+
+ it 'does not schedule the migration' do
+ expect(ObjectStorageUploadWorker).not_to receive(:perform_async)
+
+ subject
+ end
+ end
+
+ context 'when object storage is enabled' do
+ context 'when background upload is enabled' do
+ before do
+ stub_artifacts_object_storage(background_upload: true)
+ end
+
+ it 'schedules the model for migration' do
+ expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with('JobArtifactUploader', described_class.name, :file, kind_of(Numeric))
+
+ subject
+ end
+ end
+
+ context 'when background upload is disabled' do
+ before do
+ stub_artifacts_object_storage(background_upload: false)
+ end
+
+ it 'schedules the model for migration' do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ subject
+ end
+ end
+ end
+ end
+ end
+
describe '#set_size' do
it 'sets the size' do
expect(artifact.size).to eq(106365)
diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb
index 9a278212efc..8ee15f0e734 100644
--- a/spec/models/ci/pipeline_schedule_spec.rb
+++ b/spec/models/ci/pipeline_schedule_spec.rb
@@ -12,7 +12,6 @@ describe Ci::PipelineSchedule do
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
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index a1f63a2534b..dd94515b0a4 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -28,6 +28,13 @@ describe Ci::Pipeline, :mailer do
it { is_expected.to respond_to :short_sha }
it { is_expected.to delegate_method(:full_path).to(:project).with_prefix }
+ describe 'associations' do
+ it 'has a bidirectional relationship with projects' do
+ expect(described_class.reflect_on_association(:project).has_inverse?).to eq(:pipelines)
+ expect(Project.reflect_on_association(:pipelines).has_inverse?).to eq(:project)
+ end
+ end
+
describe '#source' do
context 'when creating new pipeline' do
let(:pipeline) do
@@ -163,12 +170,28 @@ describe Ci::Pipeline, :mailer do
describe '#predefined_variables' do
subject { pipeline.predefined_variables }
- it { is_expected.to be_an(Array) }
+ it 'includes all predefined variables in a valid order' do
+ keys = subject.map { |variable| variable[:key] }
+
+ expect(keys).to eq %w[CI_PIPELINE_ID CI_CONFIG_PATH CI_PIPELINE_SOURCE]
+ end
+ end
+
+ describe '#protected_ref?' do
+ it 'delegates method to project' do
+ expect(pipeline).not_to be_protected_ref
+ end
+ end
+
+ describe '#legacy_trigger' do
+ let(:trigger_request) { create(:ci_trigger_request) }
- it 'includes the defined keys' do
- keys = subject.map { |v| v[:key] }
+ before do
+ pipeline.trigger_requests << trigger_request
+ end
- expect(keys).to include('CI_PIPELINE_ID', 'CI_CONFIG_PATH', 'CI_PIPELINE_SOURCE')
+ it 'returns first trigger request' do
+ expect(pipeline.legacy_trigger).to eq trigger_request
end
end
@@ -210,142 +233,271 @@ describe Ci::Pipeline, :mailer do
end
describe 'pipeline stages' do
- before do
- create(:commit_status, pipeline: pipeline,
- stage: 'build',
- name: 'linux',
- stage_idx: 0,
- status: 'success')
-
- create(:commit_status, pipeline: pipeline,
- stage: 'build',
- name: 'mac',
- stage_idx: 0,
- status: 'failed')
-
- create(:commit_status, pipeline: pipeline,
- stage: 'deploy',
- name: 'staging',
- stage_idx: 2,
- status: 'running')
-
- create(:commit_status, pipeline: pipeline,
- stage: 'test',
- name: 'rspec',
- stage_idx: 1,
- status: 'success')
- end
-
describe '#stage_seeds' do
- let(:pipeline) do
- build(:ci_pipeline, config: { rspec: { script: 'rake' } })
- end
+ let(:pipeline) { build(:ci_pipeline, config: config) }
+ let(:config) { { rspec: { script: 'rake' } } }
it 'returns preseeded stage seeds object' do
- expect(pipeline.stage_seeds).to all(be_a Gitlab::Ci::Stage::Seed)
+ expect(pipeline.stage_seeds)
+ .to all(be_a Gitlab::Ci::Pipeline::Seed::Base)
expect(pipeline.stage_seeds.count).to eq 1
end
- end
- describe '#seeds_size' do
- let(:pipeline) { build(:ci_pipeline_with_one_job) }
+ context 'when no refs policy is specified' do
+ let(:config) do
+ { production: { stage: 'deploy', script: 'cap prod' },
+ rspec: { stage: 'test', script: 'rspec' },
+ spinach: { stage: 'test', script: 'spinach' } }
+ end
- it 'returns number of jobs in stage seeds' do
- expect(pipeline.seeds_size).to eq 1
+ it 'correctly fabricates a stage seeds object' do
+ seeds = pipeline.stage_seeds
+
+ expect(seeds.size).to eq 2
+ expect(seeds.first.attributes[:name]).to eq 'test'
+ expect(seeds.second.attributes[:name]).to eq 'deploy'
+ expect(seeds.dig(0, 0, :name)).to eq 'rspec'
+ expect(seeds.dig(0, 1, :name)).to eq 'spinach'
+ expect(seeds.dig(1, 0, :name)).to eq 'production'
+ end
end
- end
- describe '#legacy_stages' do
- subject { pipeline.legacy_stages }
+ context 'when refs policy is specified' do
+ let(:pipeline) do
+ build(:ci_pipeline, ref: 'feature', tag: true, config: config)
+ end
+
+ let(:config) do
+ { production: { stage: 'deploy', script: 'cap prod', only: ['master'] },
+ spinach: { stage: 'test', script: 'spinach', only: ['tags'] } }
+ end
- context 'stages list' do
- it 'returns ordered list of stages' do
- expect(subject.map(&:name)).to eq(%w[build test deploy])
+ it 'returns stage seeds only assigned to master to master' do
+ seeds = pipeline.stage_seeds
+
+ expect(seeds.size).to eq 1
+ expect(seeds.first.attributes[:name]).to eq 'test'
+ expect(seeds.dig(0, 0, :name)).to eq 'spinach'
end
end
- context 'stages with statuses' do
- let(:statuses) do
- subject.map { |stage| [stage.name, stage.status] }
+ context 'when source policy is specified' do
+ let(:pipeline) { build(:ci_pipeline, source: :schedule, config: config) }
+
+ let(:config) do
+ { production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] },
+ spinach: { stage: 'test', script: 'spinach', only: ['schedules'] } }
+ end
+
+ it 'returns stage seeds only assigned to schedules' do
+ seeds = pipeline.stage_seeds
+
+ expect(seeds.size).to eq 1
+ expect(seeds.first.attributes[:name]).to eq 'test'
+ expect(seeds.dig(0, 0, :name)).to eq 'spinach'
end
+ end
- it 'returns list of stages with correct statuses' do
- expect(statuses).to eq([%w(build failed),
- %w(test success),
- %w(deploy running)])
+ context 'when kubernetes policy is specified' do
+ let(:config) do
+ {
+ spinach: { stage: 'test', script: 'spinach' },
+ production: {
+ stage: 'deploy',
+ script: 'cap',
+ only: { kubernetes: 'active' }
+ }
+ }
end
- context 'when commit status is retried' do
- before do
- create(:commit_status, pipeline: pipeline,
- stage: 'build',
- name: 'mac',
- stage_idx: 0,
- status: 'success')
+ context 'when kubernetes is active' do
+ shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do
+ it 'returns seeds for kubernetes dependent job' do
+ seeds = pipeline.stage_seeds
- pipeline.process!
+ expect(seeds.size).to eq 2
+ expect(seeds.dig(0, 0, :name)).to eq 'spinach'
+ expect(seeds.dig(1, 0, :name)).to eq 'production'
+ end
end
- it 'ignores the previous state' do
- expect(statuses).to eq([%w(build success),
- %w(test success),
- %w(deploy running)])
+ context 'when user configured kubernetes from Integration > Kubernetes' do
+ let(:project) { create(:kubernetes_project) }
+ let(:pipeline) { build(:ci_pipeline, project: project, config: config) }
+
+ it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes'
+ end
+
+ context 'when user configured kubernetes from CI/CD > Clusters' do
+ let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:project) { cluster.project }
+ let(:pipeline) { build(:ci_pipeline, project: project, config: config) }
+
+ it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes'
+ end
+ end
+
+ context 'when kubernetes is not active' do
+ it 'does not return seeds for kubernetes dependent job' do
+ seeds = pipeline.stage_seeds
+
+ expect(seeds.size).to eq 1
+ expect(seeds.dig(0, 0, :name)).to eq 'spinach'
end
end
end
- context 'when there is a stage with warnings' do
- before do
- create(:commit_status, pipeline: pipeline,
- stage: 'deploy',
- name: 'prod:2',
- stage_idx: 2,
- status: 'failed',
- allow_failure: true)
+ context 'when variables policy is specified' do
+ let(:config) do
+ { unit: { script: 'minitest', only: { variables: ['$CI_PIPELINE_SOURCE'] } },
+ feature: { script: 'spinach', only: { variables: ['$UNDEFINED'] } } }
end
- it 'populates stage with correct number of warnings' do
- deploy_stage = pipeline.legacy_stages.third
+ it 'returns stage seeds only when variables expression is truthy' do
+ seeds = pipeline.stage_seeds
- expect(deploy_stage).not_to receive(:statuses)
- expect(deploy_stage).to have_warnings
+ expect(seeds.size).to eq 1
+ expect(seeds.dig(0, 0, :name)).to eq 'unit'
end
end
end
- describe '#stages_count' do
- it 'returns a valid number of stages' do
- expect(pipeline.stages_count).to eq(3)
- end
- end
+ describe '#seeds_size' do
+ context 'when refs policy is specified' do
+ let(:config) do
+ { production: { stage: 'deploy', script: 'cap prod', only: ['master'] },
+ spinach: { stage: 'test', script: 'spinach', only: ['tags'] } }
+ end
+
+ let(:pipeline) do
+ build(:ci_pipeline, ref: 'feature', tag: true, config: config)
+ end
- describe '#stages_names' do
- it 'returns a valid names of stages' do
- expect(pipeline.stages_names).to eq(%w(build test deploy))
+ it 'returns real seeds size' do
+ expect(pipeline.seeds_size).to eq 1
+ end
end
end
- end
- describe '#legacy_stage' do
- subject { pipeline.legacy_stage('test') }
-
- context 'with status in stage' do
+ describe 'legacy stages' do
before do
- create(:commit_status, pipeline: pipeline, stage: 'test')
+ create(:commit_status, pipeline: pipeline,
+ stage: 'build',
+ name: 'linux',
+ stage_idx: 0,
+ status: 'success')
+
+ create(:commit_status, pipeline: pipeline,
+ stage: 'build',
+ name: 'mac',
+ stage_idx: 0,
+ status: 'failed')
+
+ create(:commit_status, pipeline: pipeline,
+ stage: 'deploy',
+ name: 'staging',
+ stage_idx: 2,
+ status: 'running')
+
+ create(:commit_status, pipeline: pipeline,
+ stage: 'test',
+ name: 'rspec',
+ stage_idx: 1,
+ status: 'success')
+ end
+
+ describe '#legacy_stages' do
+ subject { pipeline.legacy_stages }
+
+ context 'stages list' do
+ it 'returns ordered list of stages' do
+ expect(subject.map(&:name)).to eq(%w[build test deploy])
+ end
+ end
+
+ context 'stages with statuses' do
+ let(:statuses) do
+ subject.map { |stage| [stage.name, stage.status] }
+ end
+
+ it 'returns list of stages with correct statuses' do
+ expect(statuses).to eq([%w(build failed),
+ %w(test success),
+ %w(deploy running)])
+ end
+
+ 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
+ expect(statuses).to eq([%w(build success),
+ %w(test success),
+ %w(deploy running)])
+ end
+ end
+ end
+
+ context 'when there is a stage with warnings' do
+ before do
+ create(:commit_status, pipeline: pipeline,
+ stage: 'deploy',
+ name: 'prod:2',
+ stage_idx: 2,
+ status: 'failed',
+ allow_failure: true)
+ end
+
+ it 'populates stage with correct number of warnings' do
+ deploy_stage = pipeline.legacy_stages.third
+
+ expect(deploy_stage).not_to receive(:statuses)
+ expect(deploy_stage).to have_warnings
+ end
+ end
+ end
+
+ describe '#stages_count' do
+ it 'returns a valid number of stages' do
+ expect(pipeline.stages_count).to eq(3)
+ end
end
- it { expect(subject).to be_a Ci::LegacyStage }
- it { expect(subject.name).to eq 'test' }
- it { expect(subject.statuses).not_to be_empty }
+ describe '#stages_names' do
+ it 'returns a valid names of stages' do
+ expect(pipeline.stages_names).to eq(%w(build test deploy))
+ end
+ end
end
- context 'without status in stage' do
- before do
- create(:commit_status, pipeline: pipeline, stage: 'build')
+ 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::LegacyStage }
+ it { expect(subject.name).to eq 'test' }
+ it { expect(subject.statuses).not_to be_empty }
end
- it 'return stage object' do
- is_expected.to be_nil
+ context 'without status in stage' do
+ before do
+ create(:commit_status, pipeline: pipeline, stage: 'build')
+ end
+
+ it 'return stage object' do
+ is_expected.to be_nil
+ end
end
end
end
@@ -584,20 +736,6 @@ describe Ci::Pipeline, :mailer 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? }
@@ -1440,7 +1578,7 @@ describe Ci::Pipeline, :mailer do
end
before do
- project.team << [pipeline.user, Gitlab::Access::DEVELOPER]
+ project.add_developer(pipeline.user)
pipeline.user.global_notification_setting
.update(level: 'custom', failed_pipeline: true, success_pipeline: true)
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index b2b64e6ff48..ab170e6351c 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -95,28 +95,68 @@ describe Ci::Runner do
subject { runner.online? }
- context 'never contacted' do
+ before do
+ allow_any_instance_of(described_class).to receive(:cached_attribute).and_call_original
+ allow_any_instance_of(described_class).to receive(:cached_attribute)
+ .with(:platform).and_return("darwin")
+ end
+
+ context 'no cache value' do
before do
- runner.contacted_at = nil
+ stub_redis_runner_contacted_at(nil)
end
- it { is_expected.to be_falsey }
- end
+ context 'never contacted' do
+ before do
+ runner.contacted_at = nil
+ end
- context 'contacted long time ago time' do
- before do
- runner.contacted_at = 1.year.ago
+ it { is_expected.to be_falsey }
+ end
+
+ context 'contacted long time ago time' do
+ before do
+ runner.contacted_at = 1.year.ago
+ end
+
+ it { is_expected.to be_falsey }
end
- it { is_expected.to be_falsey }
+ context 'contacted 1s ago' do
+ before do
+ runner.contacted_at = 1.second.ago
+ end
+
+ it { is_expected.to be_truthy }
+ end
end
- context 'contacted 1s ago' do
- before do
- runner.contacted_at = 1.second.ago
+ context 'with cache value' do
+ context 'contacted long time ago time' do
+ before do
+ runner.contacted_at = 1.year.ago
+ stub_redis_runner_contacted_at(1.year.ago.to_s)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'contacted 1s ago' do
+ before do
+ runner.contacted_at = 50.minutes.ago
+ stub_redis_runner_contacted_at(1.second.ago.to_s)
+ end
+
+ it { is_expected.to be_truthy }
end
+ end
- it { is_expected.to be_truthy }
+ def stub_redis_runner_contacted_at(value)
+ Gitlab::Redis::SharedState.with do |redis|
+ cache_key = runner.send(:cache_attribute_key)
+ expect(redis).to receive(:get).with(cache_key)
+ .and_return({ contacted_at: value }.to_json).at_least(:once)
+ end
end
end
@@ -361,6 +401,50 @@ describe Ci::Runner do
end
end
+ describe '#update_cached_info' do
+ let(:runner) { create(:ci_runner) }
+
+ subject { runner.update_cached_info(architecture: '18-bit') }
+
+ context 'when database was updated recently' do
+ before do
+ runner.contacted_at = Time.now
+ end
+
+ it 'updates cache' do
+ expect_redis_update
+
+ subject
+ end
+ end
+
+ context 'when database was not updated recently' do
+ before do
+ runner.contacted_at = 2.hours.ago
+ end
+
+ it 'updates database' do
+ expect_redis_update
+
+ expect { subject }.to change { runner.reload.read_attribute(:contacted_at) }
+ .and change { runner.reload.read_attribute(:architecture) }
+ end
+
+ it 'updates cache' do
+ expect_redis_update
+
+ subject
+ end
+ end
+
+ def expect_redis_update
+ Gitlab::Redis::SharedState.with do |redis|
+ redis_key = runner.send(:cache_attribute_key)
+ expect(redis).to receive(:set).with(redis_key, anything, any_args)
+ end
+ end
+ end
+
describe '#destroy' do
let(:runner) { create(:ci_runner) }
diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb
index bd9c837402f..d4b72205203 100644
--- a/spec/models/ci/trigger_spec.rb
+++ b/spec/models/ci/trigger_spec.rb
@@ -69,7 +69,7 @@ describe Ci::Trigger do
context 'and is member of the project' do
before do
- project.team << [owner, :developer]
+ project.add_developer(owner)
end
it { is_expected.to eq(true) }
diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb
index e4ff551151e..875e8b2b682 100644
--- a/spec/models/ci/variable_spec.rb
+++ b/spec/models/ci/variable_spec.rb
@@ -6,7 +6,7 @@ describe Ci::Variable do
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) }
+ it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope).with_message(/\(\w+\) has already been taken/) }
end
describe '.unprotected' do
diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb
index f8855079842..0eb1e3876e2 100644
--- a/spec/models/clusters/applications/helm_spec.rb
+++ b/spec/models/clusters/applications/helm_spec.rb
@@ -1,102 +1,29 @@
require 'rails_helper'
describe Clusters::Applications::Helm do
- it { is_expected.to belong_to(:cluster) }
- it { is_expected.to validate_presence_of(:cluster) }
+ include_examples 'cluster application core specs', :clusters_applications_helm
- describe '#name' do
- it 'is .application_name' do
- expect(subject.name).to eq(described_class.application_name)
- end
-
- it 'is recorded in Clusters::Cluster::APPLICATIONS' do
- expect(Clusters::Cluster::APPLICATIONS[subject.name]).to eq(described_class)
- end
- end
-
- describe '#version' do
- it 'defaults to Gitlab::Kubernetes::Helm::HELM_VERSION' do
- expect(subject.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION)
- end
- end
+ describe '.installed' do
+ subject { described_class.installed }
- describe '#status' do
- let(:cluster) { create(:cluster) }
+ let!(:cluster) { create(:clusters_applications_helm, :installed) }
- subject { described_class.new(cluster: cluster) }
-
- it 'defaults to :not_installable' do
- expect(subject.status_name).to be(:not_installable)
+ before do
+ create(:clusters_applications_helm, :errored)
end
- context 'when platform kubernetes is defined' do
- let(:cluster) { create(:cluster, :provided_by_gcp) }
-
- it 'defaults to :installable' do
- expect(subject.status_name).to be(:installable)
- end
- end
+ it { is_expected.to contain_exactly(cluster) }
end
describe '#install_command' do
- it 'has all the needed information' do
- expect(subject.install_command).to have_attributes(name: subject.name, install_helm: true, chart: nil)
- end
- end
-
- describe 'status state machine' do
- describe '#make_installing' do
- subject { create(:cluster_applications_helm, :scheduled) }
-
- it 'is installing' do
- subject.make_installing!
-
- expect(subject).to be_installing
- end
- end
-
- describe '#make_installed' do
- subject { create(:cluster_applications_helm, :installing) }
-
- it 'is installed' do
- subject.make_installed
-
- expect(subject).to be_installed
- end
- end
-
- describe '#make_errored' do
- subject { create(:cluster_applications_helm, :installing) }
- let(:reason) { 'some errors' }
-
- it 'is errored' do
- subject.make_errored(reason)
-
- expect(subject).to be_errored
- expect(subject.status_reason).to eq(reason)
- end
- end
-
- describe '#make_scheduled' do
- subject { create(:cluster_applications_helm, :installable) }
-
- it 'is scheduled' do
- subject.make_scheduled
-
- expect(subject).to be_scheduled
- end
-
- describe 'when was errored' do
- subject { create(:cluster_applications_helm, :errored) }
+ let(:helm) { create(:clusters_applications_helm) }
- it 'clears #status_reason' do
- expect(subject.status_reason).not_to be_nil
+ subject { helm.install_command }
- subject.make_scheduled!
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InitCommand) }
- expect(subject.status_reason).to be_nil
- end
- end
+ it 'should be initialized with 1 arguments' do
+ expect(subject.name).to eq('helm')
end
end
end
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
index b83472e1944..a47a07d908d 100644
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -1,108 +1,90 @@
require 'rails_helper'
describe Clusters::Applications::Ingress do
- it { is_expected.to belong_to(:cluster) }
- it { is_expected.to validate_presence_of(:cluster) }
+ let(:ingress) { create(:clusters_applications_ingress) }
- describe '#name' do
- it 'is .application_name' do
- expect(subject.name).to eq(described_class.application_name)
- end
+ include_examples 'cluster application core specs', :clusters_applications_ingress
+ include_examples 'cluster application status specs', :cluster_application_ingress
- it 'is recorded in Clusters::Cluster::APPLICATIONS' do
- expect(Clusters::Cluster::APPLICATIONS[subject.name]).to eq(described_class)
- end
+ before do
+ allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
+ allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async)
end
- describe '#status' do
- let(:cluster) { create(:cluster, :provided_by_gcp) }
+ describe '.installed' do
+ subject { described_class.installed }
- subject { described_class.new(cluster: cluster) }
+ let!(:cluster) { create(:clusters_applications_ingress, :installed) }
- it 'defaults to :not_installable' do
- expect(subject.status_name).to be(:not_installable)
+ before do
+ create(:clusters_applications_ingress, :errored)
end
- context 'when application helm is scheduled' do
- before do
- create(:cluster_applications_helm, :scheduled, cluster: cluster)
- end
+ it { is_expected.to contain_exactly(cluster) }
+ end
- it 'defaults to :not_installable' do
- expect(subject.status_name).to be(:not_installable)
- end
+ describe '#make_installed!' do
+ before do
+ application.make_installed!
end
- context 'when application helm is installed' do
- before do
- create(:cluster_applications_helm, :installed, cluster: cluster)
- end
-
- it 'defaults to :installable' do
- expect(subject.status_name).to be(:installable)
- end
- end
- end
+ let(:application) { create(:clusters_applications_ingress, :installing) }
- describe '#install_command' do
- it 'has all the needed information' do
- expect(subject.install_command).to have_attributes(name: subject.name, install_helm: false, chart: subject.chart)
+ it 'schedules a ClusterWaitForIngressIpAddressWorker' do
+ expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_in)
+ .with(Clusters::Applications::Ingress::FETCH_IP_ADDRESS_DELAY, 'ingress', application.id)
end
end
- describe 'status state machine' do
- describe '#make_installing' do
- subject { create(:cluster_applications_ingress, :scheduled) }
-
- it 'is installing' do
- subject.make_installing!
+ describe '#schedule_status_update' do
+ let(:application) { create(:clusters_applications_ingress, :installed) }
- expect(subject).to be_installing
- end
+ before do
+ application.schedule_status_update
end
- describe '#make_installed' do
- subject { create(:cluster_applications_ingress, :installing) }
+ it 'schedules a ClusterWaitForIngressIpAddressWorker' do
+ expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_async)
+ .with('ingress', application.id)
+ end
- it 'is installed' do
- subject.make_installed
+ context 'when the application is not installed' do
+ let(:application) { create(:clusters_applications_ingress, :installing) }
- expect(subject).to be_installed
+ it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
+ expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_async)
end
end
- describe '#make_errored' do
- subject { create(:cluster_applications_ingress, :installing) }
- let(:reason) { 'some errors' }
-
- it 'is errored' do
- subject.make_errored(reason)
+ context 'when there is already an external_ip' do
+ let(:application) { create(:clusters_applications_ingress, :installed, external_ip: '111.222.222.111') }
- expect(subject).to be_errored
- expect(subject.status_reason).to eq(reason)
+ it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do
+ expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in)
end
end
+ end
- describe '#make_scheduled' do
- subject { create(:cluster_applications_ingress, :installable) }
-
- it 'is scheduled' do
- subject.make_scheduled
-
- expect(subject).to be_scheduled
- end
+ describe '#install_command' do
+ subject { ingress.install_command }
- describe 'when was errored' do
- subject { create(:cluster_applications_ingress, :errored) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
- it 'clears #status_reason' do
- expect(subject.status_reason).not_to be_nil
+ it 'should be initialized with ingress arguments' do
+ expect(subject.name).to eq('ingress')
+ expect(subject.chart).to eq('stable/nginx-ingress')
+ expect(subject.values).to eq(ingress.values)
+ end
+ end
- subject.make_scheduled!
+ describe '#values' do
+ subject { ingress.values }
- expect(subject.status_reason).to be_nil
- end
- end
+ it 'should include ingress valid keys' do
+ is_expected.to include('image')
+ is_expected.to include('repository')
+ is_expected.to include('stats')
+ is_expected.to include('podAnnotations')
end
end
end
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
new file mode 100644
index 00000000000..aeca6ee903a
--- /dev/null
+++ b/spec/models/clusters/applications/prometheus_spec.rb
@@ -0,0 +1,119 @@
+require 'rails_helper'
+
+describe Clusters::Applications::Prometheus do
+ include_examples 'cluster application core specs', :clusters_applications_prometheus
+ include_examples 'cluster application status specs', :cluster_application_prometheus
+
+ describe '.installed' do
+ subject { described_class.installed }
+
+ let!(:cluster) { create(:clusters_applications_prometheus, :installed) }
+
+ before do
+ create(:clusters_applications_prometheus, :errored)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
+ describe 'transition to installed' do
+ let(:project) { create(:project) }
+ let(:cluster) { create(:cluster, projects: [project]) }
+ let(:prometheus_service) { double('prometheus_service') }
+
+ subject { create(:clusters_applications_prometheus, :installing, cluster: cluster) }
+
+ before do
+ allow(project).to receive(:find_or_initialize_service).with('prometheus').and_return prometheus_service
+ end
+
+ it 'ensures Prometheus service is activated' do
+ expect(prometheus_service).to receive(:update).with(active: true)
+
+ subject.make_installed
+ end
+ end
+
+ describe '#prometheus_client' do
+ context 'cluster is nil' do
+ it 'returns nil' do
+ expect(subject.cluster).to be_nil
+ expect(subject.prometheus_client).to be_nil
+ end
+ end
+
+ context "cluster doesn't have kubeclient" do
+ let(:cluster) { create(:cluster) }
+ subject { create(:clusters_applications_prometheus, cluster: cluster) }
+
+ it 'returns nil' do
+ expect(subject.prometheus_client).to be_nil
+ end
+ end
+
+ context 'cluster has kubeclient' do
+ let(:kubernetes_url) { 'http://example.com' }
+ let(:k8s_discover_response) do
+ {
+ resources: [
+ {
+ name: 'service',
+ kind: 'Service'
+ }
+ ]
+ }
+ end
+
+ let(:kube_client) { Kubeclient::Client.new(kubernetes_url) }
+
+ let(:cluster) { create(:cluster) }
+ subject { create(:clusters_applications_prometheus, cluster: cluster) }
+
+ before do
+ allow(kube_client.rest_client).to receive(:get).and_return(k8s_discover_response.to_json)
+ allow(subject.cluster).to receive(:kubeclient).and_return(kube_client)
+ end
+
+ it 'creates proxy prometheus rest client' do
+ expect(subject.prometheus_client).to be_instance_of(RestClient::Resource)
+ end
+
+ it 'creates proper url' do
+ expect(subject.prometheus_client.url).to eq('http://example.com/api/v1/proxy/namespaces/gitlab-managed-apps/service/prometheus-prometheus-server:80')
+ end
+
+ it 'copies options and headers from kube client to proxy client' do
+ expect(subject.prometheus_client.options).to eq(kube_client.rest_client.options.merge(headers: kube_client.headers))
+ end
+ end
+ end
+
+ describe '#install_command' do
+ let(:kubeclient) { double('kubernetes client') }
+ let(:prometheus) { create(:clusters_applications_prometheus) }
+
+ subject { prometheus.install_command }
+
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+
+ it 'should be initialized with 3 arguments' do
+ expect(subject.name).to eq('prometheus')
+ expect(subject.chart).to eq('stable/prometheus')
+ expect(subject.values).to eq(prometheus.values)
+ end
+ end
+
+ describe '#values' do
+ let(:prometheus) { create(:clusters_applications_prometheus) }
+
+ subject { prometheus.values }
+
+ it 'should include prometheus valid values' do
+ is_expected.to include('alertmanager')
+ is_expected.to include('kubeStateMetrics')
+ is_expected.to include('nodeExporter')
+ is_expected.to include('pushgateway')
+ is_expected.to include('serverFiles')
+ end
+ end
+end
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
new file mode 100644
index 00000000000..64d995a73c1
--- /dev/null
+++ b/spec/models/clusters/applications/runner_spec.rb
@@ -0,0 +1,111 @@
+require 'rails_helper'
+
+describe Clusters::Applications::Runner do
+ let(:ci_runner) { create(:ci_runner) }
+
+ include_examples 'cluster application core specs', :clusters_applications_runner
+ include_examples 'cluster application status specs', :cluster_application_runner
+
+ it { is_expected.to belong_to(:runner) }
+
+ describe '.installed' do
+ subject { described_class.installed }
+
+ let!(:cluster) { create(:clusters_applications_runner, :installed) }
+
+ before do
+ create(:clusters_applications_runner, :errored)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
+ describe '#install_command' do
+ let(:kubeclient) { double('kubernetes client') }
+ let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) }
+
+ subject { gitlab_runner.install_command }
+
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+
+ it 'should be initialized with 4 arguments' do
+ expect(subject.name).to eq('runner')
+ expect(subject.chart).to eq('runner/gitlab-runner')
+ expect(subject.repository).to eq('https://charts.gitlab.io')
+ expect(subject.values).to eq(gitlab_runner.values)
+ end
+ end
+
+ describe '#values' do
+ let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) }
+
+ subject { gitlab_runner.values }
+
+ it 'should include runner valid values' do
+ is_expected.to include('concurrent')
+ is_expected.to include('checkInterval')
+ is_expected.to include('rbac')
+ is_expected.to include('runners')
+ is_expected.to include('privileged: true')
+ is_expected.to include('image: ubuntu:16.04')
+ is_expected.to include('resources')
+ is_expected.to include("runnerToken: #{ci_runner.token}")
+ is_expected.to include("gitlabUrl: #{Gitlab::Routing.url_helpers.root_url}")
+ end
+
+ context 'without a runner' do
+ let(:project) { create(:project) }
+ let(:cluster) { create(:cluster) }
+ let(:gitlab_runner) { create(:clusters_applications_runner, cluster: cluster) }
+
+ before do
+ cluster.projects << project
+ end
+
+ it 'creates a runner' do
+ expect do
+ subject
+ end.to change { Ci::Runner.count }.by(1)
+ end
+
+ it 'uses the new runner token' do
+ expect(subject).to include("runnerToken: #{gitlab_runner.reload.runner.token}")
+ end
+
+ it 'assigns the new runner to runner' do
+ subject
+ gitlab_runner.reload
+
+ expect(gitlab_runner.runner).not_to be_nil
+ end
+ end
+
+ context 'with duplicated values on vendor/runner/values.yaml' do
+ let(:values) do
+ {
+ "concurrent" => 4,
+ "checkInterval" => 3,
+ "rbac" => {
+ "create" => false
+ },
+ "clusterWideAccess" => false,
+ "runners" => {
+ "privileged" => false,
+ "image" => "ubuntu:16.04",
+ "builds" => {},
+ "services" => {},
+ "helpers" => {}
+ }
+ }
+ end
+
+ before do
+ allow(gitlab_runner).to receive(:chart_values).and_return(values)
+ end
+
+ it 'should overwrite values.yaml' do
+ is_expected.to include("privileged: #{gitlab_runner.privileged}")
+ end
+ end
+ end
+end
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index 2683d21ddbe..b942554d67b 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -5,6 +5,10 @@ describe Clusters::Cluster do
it { is_expected.to have_many(:projects) }
it { is_expected.to have_one(:provider_gcp) }
it { is_expected.to have_one(:platform_kubernetes) }
+ it { is_expected.to have_one(:application_helm) }
+ it { is_expected.to have_one(:application_ingress) }
+ it { is_expected.to have_one(:application_prometheus) }
+ it { is_expected.to have_one(:application_runner) }
it { is_expected.to delegate_method(:status).to(:provider) }
it { is_expected.to delegate_method(:status_reason).to(:provider) }
it { is_expected.to delegate_method(:status_name).to(:provider) }
@@ -35,6 +39,42 @@ describe Clusters::Cluster do
it { is_expected.to contain_exactly(cluster) }
end
+ describe '.user_provided' do
+ subject { described_class.user_provided }
+
+ let!(:cluster) { create(:cluster, :provided_by_user) }
+
+ before do
+ create(:cluster, :provided_by_gcp)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
+ describe '.gcp_provided' do
+ subject { described_class.gcp_provided }
+
+ let!(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ before do
+ create(:cluster, :provided_by_user)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
+ describe '.gcp_installed' do
+ subject { described_class.gcp_installed }
+
+ let!(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ before do
+ create(:cluster, :providing_by_gcp)
+ end
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
describe 'validation' do
subject { cluster.valid? }
@@ -190,11 +230,13 @@ describe Clusters::Cluster do
end
context 'when applications are created' do
- let!(:helm) { create(:cluster_applications_helm, cluster: cluster) }
- let!(:ingress) { create(:cluster_applications_ingress, cluster: cluster) }
+ let!(:helm) { create(:clusters_applications_helm, cluster: cluster) }
+ let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) }
+ let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) }
+ let!(:runner) { create(:clusters_applications_runner, cluster: cluster) }
it 'returns a list of created applications' do
- is_expected.to contain_exactly(helm, ingress)
+ is_expected.to contain_exactly(helm, ingress, prometheus, runner)
end
end
end
diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb
index 53a4e545ff6..add481b8096 100644
--- a/spec/models/clusters/platforms/kubernetes_spec.rb
+++ b/spec/models/clusters/platforms/kubernetes_spec.rb
@@ -252,7 +252,7 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching
stub_kubeclient_pods(status: 500)
end
- it { expect { subject }.to raise_error(KubeException) }
+ it { expect { subject }.to raise_error(Kubeclient::HttpError) }
end
context 'when kubernetes responds with 404s' do
diff --git a/spec/models/commit_range_spec.rb b/spec/models/commit_range_spec.rb
index 38829773599..f2efcd9d0e9 100644
--- a/spec/models/commit_range_spec.rb
+++ b/spec/models/commit_range_spec.rb
@@ -151,11 +151,11 @@ describe CommitRange do
.with(commit1, user)
.and_return(true)
- expect(commit1.has_been_reverted?(user, issue)).to eq(true)
+ expect(commit1.has_been_reverted?(user, issue.notes_with_associations)).to eq(true)
end
- it 'returns false a commit has not been reverted' do
- expect(commit1.has_been_reverted?(user, issue)).to eq(false)
+ it 'returns false if the commit has not been reverted' do
+ expect(commit1.has_been_reverted?(user, issue.notes_with_associations)).to eq(false)
end
end
end
diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb
index cd955a5eb69..959383ff0b7 100644
--- a/spec/models/commit_spec.rb
+++ b/spec/models/commit_spec.rb
@@ -181,7 +181,6 @@ eos
it { is_expected.to respond_to(:parents) }
it { is_expected.to respond_to(:date) }
it { is_expected.to respond_to(:diffs) }
- it { is_expected.to respond_to(:tree) }
it { is_expected.to respond_to(:id) }
it { is_expected.to respond_to(:to_patch) }
end
@@ -193,8 +192,8 @@ eos
let(:commiter) { create :user }
before do
- project.team << [commiter, :developer]
- other_project.team << [commiter, :developer]
+ project.add_developer(commiter)
+ other_project.add_developer(commiter)
end
it 'detects issues that this commit is marked as closing' do
@@ -229,7 +228,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:46Z') }
- it { expect(data[:added]).to eq(["bar/branch-test.txt"]) }
+ it { expect(data[:added]).to contain_exactly("bar/branch-test.txt") }
it { expect(data[:modified]).to eq([]) }
it { expect(data[:removed]).to eq([]) }
end
@@ -440,15 +439,25 @@ eos
end
describe '#uri_type' do
- it 'returns the URI type at the given path' do
- expect(commit.uri_type('files/html')).to be(:tree)
- expect(commit.uri_type('files/images/logo-black.png')).to be(:raw)
- expect(project.commit('video').uri_type('files/videos/intro.mp4')).to be(:raw)
- expect(commit.uri_type('files/js/application.js')).to be(:blob)
+ shared_examples 'URI type' do
+ it 'returns the URI type at the given path' do
+ expect(commit.uri_type('files/html')).to be(:tree)
+ expect(commit.uri_type('files/images/logo-black.png')).to be(:raw)
+ expect(project.commit('video').uri_type('files/videos/intro.mp4')).to be(:raw)
+ expect(commit.uri_type('files/js/application.js')).to be(:blob)
+ end
+
+ it "returns nil if the path doesn't exists" do
+ expect(commit.uri_type('this/path/doesnt/exist')).to be_nil
+ end
+ end
+
+ context 'when Gitaly commit_tree_entry feature is enabled' do
+ it_behaves_like 'URI type'
end
- it "returns nil if the path doesn't exists" do
- expect(commit.uri_type('this/path/doesnt/exist')).to be_nil
+ context 'when Gitaly commit_tree_entry feature is disabled', :disable_gitaly do
+ it_behaves_like 'URI type'
end
end
@@ -514,4 +523,17 @@ eos
expect(described_class.valid_hash?('a' * 41)).to be false
end
end
+
+ describe '#merge_requests' do
+ let!(:project) { create(:project, :repository) }
+ let!(:merge_request1) { create(:merge_request, source_project: project, source_branch: 'master', target_branch: 'feature') }
+ let!(:merge_request2) { create(:merge_request, source_project: project, source_branch: 'merged-target', target_branch: 'feature') }
+ let(:commit1) { merge_request1.merge_request_diff.commits.last }
+ let(:commit2) { merge_request1.merge_request_diff.commits.first }
+
+ it 'returns merge_requests that introduced that commit' do
+ expect(commit1.merge_requests).to contain_exactly(merge_request1, merge_request2)
+ expect(commit2.merge_requests).to contain_exactly(merge_request1)
+ end
+ end
end
diff --git a/spec/models/compare_spec.rb b/spec/models/compare_spec.rb
index 04f3cecae00..8e88bb81162 100644
--- a/spec/models/compare_spec.rb
+++ b/spec/models/compare_spec.rb
@@ -37,33 +37,51 @@ describe Compare do
end
end
- describe '#base_commit' do
- let(:base_commit) { Commit.new(another_sample_commit, project) }
+ describe '#base_commit_sha' do
+ it 'returns @base_sha if it is present' do
+ expect(project).not_to receive(:merge_base_commit)
- it 'returns project merge base commit' do
- expect(project).to receive(:merge_base_commit).with(start_commit.id, head_commit.id).and_return(base_commit)
+ sha = double
+ service = described_class.new(raw_compare, project, base_sha: sha)
- expect(subject.base_commit).to eq(base_commit)
+ expect(service.base_commit_sha).to eq(sha)
+ end
+
+ it 'fetches merge base SHA from repo when @base_sha is nil' do
+ expect(project).to receive(:merge_base_commit)
+ .with(start_commit.id, head_commit.id)
+ .once
+ .and_call_original
+
+ expect(subject.base_commit_sha)
+ .to eq(project.repository.merge_base(start_commit.id, head_commit.id))
+ end
+
+ it 'is memoized on first call' do
+ expect(project).to receive(:merge_base_commit)
+ .with(start_commit.id, head_commit.id)
+ .once
+ .and_call_original
+
+ 3.times { subject.base_commit_sha }
end
it 'returns nil if there is no start_commit' do
expect(subject).to receive(:start_commit).and_return(nil)
- expect(subject.base_commit).to eq(nil)
+ expect(subject.base_commit_sha).to eq(nil)
end
it 'returns nil if there is no head commit' do
expect(subject).to receive(:head_commit).and_return(nil)
- expect(subject.base_commit).to eq(nil)
+ expect(subject.base_commit_sha).to eq(nil)
end
end
describe '#diff_refs' do
- it 'uses base_commit sha as base_sha' do
- expect(subject).to receive(:base_commit).at_least(:once).and_call_original
-
- expect(subject.diff_refs.base_sha).to eq(subject.base_commit.id)
+ it 'uses base_commit_sha sha as base_sha' do
+ expect(subject.diff_refs.base_sha).to eq(subject.base_commit_sha)
end
it 'uses start_commit sha as start_sha' do
diff --git a/spec/models/concerns/avatarable_spec.rb b/spec/models/concerns/avatarable_spec.rb
index cbdc438be0b..3696e6f62fd 100644
--- a/spec/models/concerns/avatarable_spec.rb
+++ b/spec/models/concerns/avatarable_spec.rb
@@ -1,11 +1,11 @@
require 'spec_helper'
describe Avatarable do
- subject { create(:project, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png'))) }
+ set(:project) { create(:project, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png'))) }
let(:gitlab_host) { "https://gitlab.example.com" }
let(:relative_url_root) { "/gitlab" }
- let(:asset_host) { "https://gitlab-assets.example.com" }
+ let(:asset_host) { 'https://gitlab-assets.example.com' }
before do
stub_config_setting(base_url: gitlab_host)
@@ -15,29 +15,32 @@ describe Avatarable do
describe '#avatar_path' do
using RSpec::Parameterized::TableSyntax
- where(:has_asset_host, :visibility_level, :only_path, :avatar_path) do
- true | Project::PRIVATE | true | [gitlab_host, relative_url_root, subject.avatar.url]
- true | Project::PRIVATE | false | [gitlab_host, relative_url_root, subject.avatar.url]
- true | Project::INTERNAL | true | [gitlab_host, relative_url_root, subject.avatar.url]
- true | Project::INTERNAL | false | [gitlab_host, relative_url_root, subject.avatar.url]
- true | Project::PUBLIC | true | [subject.avatar.url]
- true | Project::PUBLIC | false | [asset_host, subject.avatar.url]
- false | Project::PRIVATE | true | [relative_url_root, subject.avatar.url]
- false | Project::PRIVATE | false | [gitlab_host, relative_url_root, subject.avatar.url]
- false | Project::INTERNAL | true | [relative_url_root, subject.avatar.url]
- false | Project::INTERNAL | false | [gitlab_host, relative_url_root, subject.avatar.url]
- false | Project::PUBLIC | true | [relative_url_root, subject.avatar.url]
- false | Project::PUBLIC | false | [gitlab_host, relative_url_root, subject.avatar.url]
+ where(:has_asset_host, :visibility_level, :only_path, :avatar_path_prefix) do
+ true | Project::PRIVATE | true | [gitlab_host, relative_url_root]
+ true | Project::PRIVATE | false | [gitlab_host, relative_url_root]
+ true | Project::INTERNAL | true | [gitlab_host, relative_url_root]
+ true | Project::INTERNAL | false | [gitlab_host, relative_url_root]
+ true | Project::PUBLIC | true | []
+ true | Project::PUBLIC | false | [asset_host]
+ false | Project::PRIVATE | true | [relative_url_root]
+ false | Project::PRIVATE | false | [gitlab_host, relative_url_root]
+ false | Project::INTERNAL | true | [relative_url_root]
+ false | Project::INTERNAL | false | [gitlab_host, relative_url_root]
+ false | Project::PUBLIC | true | [relative_url_root]
+ false | Project::PUBLIC | false | [gitlab_host, relative_url_root]
end
with_them do
before do
- allow(ActionController::Base).to receive(:asset_host).and_return(has_asset_host ? asset_host : nil)
- subject.visibility_level = visibility_level
+ allow(ActionController::Base).to receive(:asset_host) { has_asset_host && asset_host }
+
+ project.visibility_level = visibility_level
end
+ let(:avatar_path) { (avatar_path_prefix + [project.avatar.url]).join }
+
it 'returns the expected avatar path' do
- expect(subject.avatar_path(only_path: only_path)).to eq(avatar_path.join)
+ expect(project.avatar_path(only_path: only_path)).to eq(avatar_path)
end
end
end
diff --git a/spec/models/concerns/chronic_duration_attribute_spec.rb b/spec/models/concerns/chronic_duration_attribute_spec.rb
new file mode 100644
index 00000000000..8847623f705
--- /dev/null
+++ b/spec/models/concerns/chronic_duration_attribute_spec.rb
@@ -0,0 +1,129 @@
+require 'spec_helper'
+
+shared_examples 'ChronicDurationAttribute reader' do
+ it 'contains dynamically created reader method' do
+ expect(subject.class).to be_public_method_defined(virtual_field)
+ end
+
+ it 'outputs chronic duration formatted value' do
+ subject.send("#{source_field}=", 120)
+
+ expect(subject.send(virtual_field)).to eq('2m')
+ end
+
+ context 'when value is set to nil' do
+ it 'outputs nil' do
+ subject.send("#{source_field}=", nil)
+
+ expect(subject.send(virtual_field)).to be_nil
+ end
+ end
+end
+
+shared_examples 'ChronicDurationAttribute writer' do
+ it 'contains dynamically created writer method' do
+ expect(subject.class).to be_public_method_defined("#{virtual_field}=")
+ end
+
+ before do
+ subject.send("#{virtual_field}=", '10m')
+ end
+
+ it 'parses chronic duration input' do
+ expect(subject.send(source_field)).to eq(600)
+ end
+
+ it 'passes validation' do
+ expect(subject.valid?).to be_truthy
+ end
+
+ context 'when negative input is used' do
+ before do
+ subject.send("#{source_field}=", 3600)
+ end
+
+ it "doesn't raise exception" do
+ expect { subject.send("#{virtual_field}=", '-10m') }.not_to raise_error(ChronicDuration::DurationParseError)
+ end
+
+ it "doesn't change value" do
+ expect { subject.send("#{virtual_field}=", '-10m') }.not_to change { subject.send(source_field) }
+ end
+
+ it "doesn't pass validation" do
+ subject.send("#{virtual_field}=", '-10m')
+
+ expect(subject.valid?).to be_falsey
+ expect(subject.errors&.messages).to include(virtual_field => ['is not a correct duration'])
+ end
+ end
+
+ context 'when empty input is used' do
+ before do
+ subject.send("#{virtual_field}=", '')
+ end
+
+ it 'writes default value' do
+ expect(subject.send(source_field)).to eq(default_value)
+ end
+
+ it 'passes validation' do
+ expect(subject.valid?).to be_truthy
+ end
+ end
+
+ context 'when nil input is used' do
+ before do
+ subject.send("#{virtual_field}=", nil)
+ end
+
+ it 'writes default value' do
+ expect(subject.send(source_field)).to eq(default_value)
+ end
+
+ it 'passes validation' do
+ expect(subject.valid?).to be_truthy
+ end
+
+ it "doesn't raise exception" do
+ expect { subject.send("#{virtual_field}=", nil) }.not_to raise_error(NoMethodError)
+ end
+ end
+end
+
+describe 'ChronicDurationAttribute' do
+ context 'when default value is not set' do
+ let(:source_field) {:maximum_timeout}
+ let(:virtual_field) {:maximum_timeout_human_readable}
+ let(:default_value) { nil }
+
+ subject { create(:ci_runner) }
+
+ it_behaves_like 'ChronicDurationAttribute reader'
+ it_behaves_like 'ChronicDurationAttribute writer'
+ end
+
+ context 'when default value is set' do
+ let(:source_field) {:build_timeout}
+ let(:virtual_field) {:build_timeout_human_readable}
+ let(:default_value) { 3600 }
+
+ subject { create(:project) }
+
+ it_behaves_like 'ChronicDurationAttribute reader'
+ it_behaves_like 'ChronicDurationAttribute writer'
+ end
+end
+
+describe 'ChronicDurationAttribute - reader' do
+ let(:source_field) {:timeout}
+ let(:virtual_field) {:timeout_human_readable}
+
+ subject { create(:ci_build).ensure_metadata }
+
+ it "doesn't contain dynamically created writer method" do
+ expect(subject.class).not_to be_public_method_defined("#{virtual_field}=")
+ end
+
+ it_behaves_like 'ChronicDurationAttribute reader'
+end
diff --git a/spec/models/concerns/deployment_platform_spec.rb b/spec/models/concerns/deployment_platform_spec.rb
new file mode 100644
index 00000000000..7bb89fe41dc
--- /dev/null
+++ b/spec/models/concerns/deployment_platform_spec.rb
@@ -0,0 +1,73 @@
+require 'rails_helper'
+
+describe DeploymentPlatform do
+ let(:project) { create(:project) }
+
+ describe '#deployment_platform' do
+ subject { project.deployment_platform }
+
+ context 'with no Kubernetes configuration on CI/CD, no Kubernetes Service and a Kubernetes template configured' do
+ let!(:kubernetes_service) { create(:kubernetes_service, template: true) }
+
+ it 'returns a platform kubernetes' do
+ expect(subject).to be_a_kind_of(Clusters::Platforms::Kubernetes)
+ end
+
+ it 'creates a cluster and a platform kubernetes' do
+ expect { subject }
+ .to change { Clusters::Cluster.count }.by(1)
+ .and change { Clusters::Platforms::Kubernetes.count }.by(1)
+ end
+
+ it 'includes appropriate attributes for Cluster' do
+ cluster = subject.cluster
+ expect(cluster.name).to eq('kubernetes-template')
+ expect(cluster.project).to eq(project)
+ expect(cluster.provider_type).to eq('user')
+ expect(cluster.platform_type).to eq('kubernetes')
+ end
+
+ it 'creates a platform kubernetes' do
+ expect { subject }.to change { Clusters::Platforms::Kubernetes.count }.by(1)
+ end
+
+ it 'copies attributes from Clusters::Platform::Kubernetes template into the new Cluster::Platforms::Kubernetes' do
+ expect(subject.api_url).to eq(kubernetes_service.api_url)
+ expect(subject.ca_pem).to eq(kubernetes_service.ca_pem)
+ expect(subject.token).to eq(kubernetes_service.token)
+ expect(subject.namespace).to eq(kubernetes_service.namespace)
+ end
+ end
+
+ context 'with no Kubernetes configuration on CI/CD, no Kubernetes Service and no Kubernetes template configured' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'when user configured kubernetes from CI/CD > Clusters' do
+ let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
+ let(:platform_kubernetes) { cluster.platform_kubernetes }
+
+ it 'returns the Kubernetes platform' do
+ expect(subject).to eq(platform_kubernetes)
+ end
+ end
+
+ context 'when user configured kubernetes integration from project services' do
+ let!(:kubernetes_service) { create(:kubernetes_service, project: project) }
+
+ it 'returns the Kubernetes service' do
+ expect(subject).to eq(kubernetes_service)
+ end
+ end
+
+ context 'when the cluster creation fails' do
+ let!(:kubernetes_service) { create(:kubernetes_service, template: true) }
+
+ before do
+ allow_any_instance_of(Clusters::Cluster).to receive(:persisted?).and_return(false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+end
diff --git a/spec/models/concerns/discussion_on_diff_spec.rb b/spec/models/concerns/discussion_on_diff_spec.rb
index 2322eb206fb..30572ce9332 100644
--- a/spec/models/concerns/discussion_on_diff_spec.rb
+++ b/spec/models/concerns/discussion_on_diff_spec.rb
@@ -20,6 +20,16 @@ describe DiscussionOnDiff do
expect(truncated_lines).not_to include(be_meta)
end
end
+
+ context "when the diff line does not exist on a legacy diff note" do
+ it "returns an empty array" do
+ legacy_note = LegacyDiffNote.new
+
+ allow(subject).to receive(:first_note).and_return(legacy_note)
+
+ expect(truncated_lines).to eq([])
+ end
+ end
end
describe '#line_code_in_diffs' do
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 4b217df2e8f..05693f067e1 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -34,7 +34,7 @@ describe Issuable do
subject { build(:issue) }
before do
- allow(subject).to receive(:set_iid).and_return(false)
+ allow(InternalId).to receive(:generate_next).and_return(nil)
end
it { is_expected.to validate_presence_of(:project) }
@@ -176,7 +176,7 @@ describe Issuable do
end
end
- describe "#sort" do
+ describe "#sort_by_attribute" do
let(:project) { create(:project) }
context "by milestone due date" do
@@ -193,12 +193,12 @@ describe Issuable do
let!(:issue3) { create(:issue, project: project) }
it "sorts desc" do
- issues = project.issues.sort('milestone_due_desc')
+ issues = project.issues.sort_by_attribute('milestone_due_desc')
expect(issues).to match_array([issue2, issue1, issue, issue3])
end
it "sorts asc" do
- issues = project.issues.sort('milestone_due_asc')
+ issues = project.issues.sort_by_attribute('milestone_due_asc')
expect(issues).to match_array([issue1, issue2, issue, issue3])
end
end
@@ -210,7 +210,7 @@ describe Issuable do
it 'has no duplicates across pages' do
sorted_issue_ids = 1.upto(10).map do |i|
- project.issues.sort('milestone_due_desc').page(i).per(1).first.id
+ project.issues.sort_by_attribute('milestone_due_desc').page(i).per(1).first.id
end
expect(sorted_issue_ids).to eq(sorted_issue_ids.uniq)
diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb
index 8b545aec7f5..c73ea6aa94c 100644
--- a/spec/models/concerns/mentionable_spec.rb
+++ b/spec/models/concerns/mentionable_spec.rb
@@ -62,7 +62,7 @@ describe Issue, "Mentionable" do
context 'when the current user can see the issue' do
before do
- private_project.team << [user, Gitlab::Access::DEVELOPER]
+ private_project.add_developer(user)
end
it 'includes the reference' do
@@ -107,7 +107,7 @@ describe Issue, "Mentionable" do
let(:issues) { create_list(:issue, 2, project: project, author: author) }
before do
- project.team << [author, Gitlab::Access::DEVELOPER]
+ project.add_developer(author)
end
context 'before changes are persisted' do
diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb
index 673c609f534..87bf731340f 100644
--- a/spec/models/concerns/milestoneish_spec.rb
+++ b/spec/models/concerns/milestoneish_spec.rb
@@ -24,8 +24,8 @@ describe Milestone, 'Milestoneish' do
let(:label_3) { create(:label, title: 'label_3', project: project) }
before do
- project.team << [member, :developer]
- project.team << [guest, :guest]
+ project.add_developer(member)
+ project.add_guest(guest)
end
describe '#sorted_issues' do
diff --git a/spec/models/concerns/prometheus_adapter_spec.rb b/spec/models/concerns/prometheus_adapter_spec.rb
new file mode 100644
index 00000000000..f4b9c57e71a
--- /dev/null
+++ b/spec/models/concerns/prometheus_adapter_spec.rb
@@ -0,0 +1,121 @@
+require 'spec_helper'
+
+describe PrometheusAdapter, :use_clean_rails_memory_store_caching do
+ include PrometheusHelpers
+ include ReactiveCachingHelpers
+
+ class TestClass
+ include PrometheusAdapter
+ end
+
+ let(:project) { create(:prometheus_project) }
+ let(:service) { project.prometheus_service }
+
+ let(:described_class) { TestClass }
+ let(:environment_query) { Gitlab::Prometheus::Queries::EnvironmentQuery }
+
+ describe '#query' do
+ describe 'environment' do
+ let(:environment) { build_stubbed(:environment, slug: 'env-slug') }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ context 'with valid data' do
+ subject { service.query(:environment, environment) }
+
+ before do
+ stub_reactive_cache(service, prometheus_data, environment_query, environment.id)
+ end
+
+ it 'returns reactive data' do
+ is_expected.to eq(prometheus_metrics_data)
+ end
+ end
+ end
+
+ describe 'matched_metrics' do
+ let(:matched_metrics_query) { Gitlab::Prometheus::Queries::MatchedMetricQuery }
+ let(:prometheus_client_wrapper) { double(:prometheus_client_wrapper, label_values: nil) }
+
+ context 'with valid data' do
+ subject { service.query(:matched_metrics) }
+
+ before do
+ allow(service).to receive(:prometheus_client_wrapper).and_return(prometheus_client_wrapper)
+ synchronous_reactive_cache(service)
+ end
+
+ it 'returns reactive data' do
+ expect(subject[:success]).to be_truthy
+ expect(subject[:data]).to eq([])
+ end
+ end
+ end
+
+ describe 'deployment' 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.query(:deployment, deployment) }
+
+ before do
+ stub_reactive_cache(service, prometheus_data, deployment_query, deployment.id)
+ end
+
+ it 'returns reactive data' do
+ expect(subject).to eq(prometheus_metrics_data)
+ end
+ end
+ end
+ end
+
+ describe '#calculate_reactive_cache' do
+ let(:environment) { create(:environment, slug: 'env-slug') }
+ before do
+ service.manual_configuration = true
+ service.active = true
+ end
+
+ subject do
+ service.calculate_reactive_cache(environment_query.name, environment.id)
+ end
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ context 'when service is inactive' do
+ before do
+ service.active = false
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when Prometheus responds with valid data' do
+ before do
+ stub_all_prometheus_requests(environment.slug)
+ 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!")
+ end
+
+ it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) }
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/protected_ref_access_spec.rb b/spec/models/concerns/protected_ref_access_spec.rb
new file mode 100644
index 00000000000..a62ca391e25
--- /dev/null
+++ b/spec/models/concerns/protected_ref_access_spec.rb
@@ -0,0 +1,31 @@
+require 'spec_helper'
+
+describe ProtectedRefAccess do
+ subject(:protected_ref_access) do
+ create(:protected_branch, :masters_can_push).push_access_levels.first
+ end
+
+ let(:project) { protected_ref_access.project }
+
+ describe '#check_access' do
+ it 'is always true for admins' do
+ admin = create(:admin)
+
+ expect(protected_ref_access.check_access(admin)).to be_truthy
+ end
+
+ it 'is true for masters' do
+ master = create(:user)
+ project.add_master(master)
+
+ expect(protected_ref_access.check_access(master)).to be_truthy
+ end
+
+ it 'is for developers of the project' do
+ developer = create(:user)
+ project.add_developer(developer)
+
+ expect(protected_ref_access.check_access(developer)).to be_falsy
+ end
+ end
+end
diff --git a/spec/models/concerns/redis_cacheable_spec.rb b/spec/models/concerns/redis_cacheable_spec.rb
new file mode 100644
index 00000000000..3d7963120b6
--- /dev/null
+++ b/spec/models/concerns/redis_cacheable_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe RedisCacheable do
+ let(:model) { double }
+
+ before do
+ model.extend(described_class)
+ allow(model).to receive(:cache_attribute_key).and_return('key')
+ end
+
+ describe '#cached_attribute' do
+ let(:payload) { { attribute: 'value' } }
+
+ subject { model.cached_attribute(payload.keys.first) }
+
+ it 'gets the cache attribute' do
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis).to receive(:get).with('key')
+ .and_return(payload.to_json)
+ end
+
+ expect(subject).to eq(payload.values.first)
+ end
+ end
+
+ describe '#cache_attributes' do
+ let(:values) { { name: 'new_name' } }
+
+ subject { model.cache_attributes(values) }
+
+ it 'sets the cache attributes' do
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis).to receive(:set).with('key', values.to_json, anything)
+ end
+
+ subject
+ end
+ end
+end
diff --git a/spec/models/concerns/resolvable_discussion_spec.rb b/spec/models/concerns/resolvable_discussion_spec.rb
index 1616c2ea985..2a2ef5a304d 100644
--- a/spec/models/concerns/resolvable_discussion_spec.rb
+++ b/spec/models/concerns/resolvable_discussion_spec.rb
@@ -190,7 +190,7 @@ describe Discussion, ResolvableDiscussion do
context "when the signed in user can push to the project" do
before do
- subject.project.team << [current_user, :master]
+ subject.project.add_master(current_user)
end
it "returns true" do
diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb
index 3106207811a..8cb50d7465c 100644
--- a/spec/models/concerns/routable_spec.rb
+++ b/spec/models/concerns/routable_spec.rb
@@ -39,7 +39,7 @@ describe Group, 'Routable' do
create(:group, parent: group, path: 'xyz')
duplicate = build(:project, namespace: group, path: 'xyz')
- expect { duplicate.save! }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Route path has already been taken, Route is invalid')
+ expect { duplicate.save! }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Path has already been taken')
end
end
diff --git a/spec/models/concerns/triggerable_hooks_spec.rb b/spec/models/concerns/triggerable_hooks_spec.rb
new file mode 100644
index 00000000000..621d2d38eae
--- /dev/null
+++ b/spec/models/concerns/triggerable_hooks_spec.rb
@@ -0,0 +1,43 @@
+require 'rails_helper'
+
+RSpec.describe TriggerableHooks do
+ before do
+ class TestableHook < WebHook
+ include TriggerableHooks
+ triggerable_hooks [:push_hooks]
+ end
+ end
+
+ describe 'scopes' do
+ it 'defines a scope for each of the requested triggers' do
+ expect(TestableHook).to respond_to :push_hooks
+ expect(TestableHook).not_to respond_to :tag_push_hooks
+ end
+ end
+
+ describe '.hooks_for' do
+ context 'the model has the required trigger scope' do
+ it 'returns the record' do
+ hook = TestableHook.create!(url: 'http://example.com', push_events: true)
+
+ expect(TestableHook.hooks_for(:push_hooks)).to eq [hook]
+ end
+ end
+
+ context 'the model does not have the required trigger scope' do
+ it 'returns an empty relation' do
+ TestableHook.create!(url: 'http://example.com')
+
+ expect(TestableHook.hooks_for(:tag_push_hooks)).to eq []
+ end
+ end
+
+ context 'the stock scope ".all" is accepted' do
+ it 'returns the record' do
+ hook = TestableHook.create!(url: 'http://example.com')
+
+ expect(TestableHook.hooks_for(:all)).to eq [hook]
+ end
+ end
+ end
+end
diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb
index f2f1928926c..6a6b58fb52b 100644
--- a/spec/models/cycle_analytics/code_spec.rb
+++ b/spec/models/cycle_analytics/code_spec.rb
@@ -18,11 +18,11 @@ describe 'CycleAnalytics#code' do
end]],
end_time_conditions: [["merge request that closes issue is created",
-> (context, data) do
- context.create_merge_request_closing_issue(data[:issue])
+ context.create_merge_request_closing_issue(context.user, context.project, data[:issue])
end]],
post_fn: -> (context, data) do
- context.merge_merge_requests_closing_issue(data[:issue])
- context.deploy_master
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
+ context.deploy_master(context.user, context.project)
end)
context "when a regular merge request (that doesn't close the issue) is created" do
@@ -30,10 +30,10 @@ describe 'CycleAnalytics#code' do
issue = create(:issue, project: project)
create_commit_referencing_issue(issue)
- create_merge_request_closing_issue(issue, message: "Closes nothing")
+ create_merge_request_closing_issue(user, project, issue, message: "Closes nothing")
- merge_merge_requests_closing_issue(issue)
- deploy_master
+ merge_merge_requests_closing_issue(user, project, issue)
+ deploy_master(user, project)
expect(subject[:code].median).to be_nil
end
@@ -50,10 +50,10 @@ describe 'CycleAnalytics#code' do
end]],
end_time_conditions: [["merge request that closes issue is created",
-> (context, data) do
- context.create_merge_request_closing_issue(data[:issue])
+ context.create_merge_request_closing_issue(context.user, context.project, data[:issue])
end]],
post_fn: -> (context, data) do
- context.merge_merge_requests_closing_issue(data[:issue])
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
end)
context "when a regular merge request (that doesn't close the issue) is created" do
@@ -61,9 +61,9 @@ describe 'CycleAnalytics#code' do
issue = create(:issue, project: project)
create_commit_referencing_issue(issue)
- create_merge_request_closing_issue(issue, message: "Closes nothing")
+ create_merge_request_closing_issue(user, project, issue, message: "Closes nothing")
- merge_merge_requests_closing_issue(issue)
+ merge_merge_requests_closing_issue(user, project, issue)
expect(subject[:code].median).to be_nil
end
diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb
index 985e1bf80be..45f1b4fe8a3 100644
--- a/spec/models/cycle_analytics/issue_spec.rb
+++ b/spec/models/cycle_analytics/issue_spec.rb
@@ -26,8 +26,8 @@ describe 'CycleAnalytics#issue' do
end]],
post_fn: -> (context, data) do
if data[:issue].persisted?
- context.create_merge_request_closing_issue(data[:issue].reload)
- context.merge_merge_requests_closing_issue(data[:issue])
+ context.create_merge_request_closing_issue(context.user, context.project, data[:issue].reload)
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
end
end)
@@ -37,8 +37,8 @@ describe 'CycleAnalytics#issue' do
issue = create(:issue, project: project)
issue.update(label_ids: [regular_label.id])
- create_merge_request_closing_issue(issue)
- merge_merge_requests_closing_issue(issue)
+ create_merge_request_closing_issue(user, project, issue)
+ merge_merge_requests_closing_issue(user, project, issue)
expect(subject[:issue].median).to be_nil
end
diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb
index 6fbb2a2d102..d366e2b723a 100644
--- a/spec/models/cycle_analytics/plan_spec.rb
+++ b/spec/models/cycle_analytics/plan_spec.rb
@@ -29,8 +29,8 @@ describe 'CycleAnalytics#plan' do
context.create_commit_referencing_issue(data[:issue], branch_name: data[:branch_name])
end]],
post_fn: -> (context, data) do
- context.create_merge_request_closing_issue(data[:issue], source_branch: data[:branch_name])
- context.merge_merge_requests_closing_issue(data[:issue])
+ context.create_merge_request_closing_issue(context.user, context.project, data[:issue], source_branch: data[:branch_name])
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
end)
context "when a regular label (instead of a list label) is added to the issue" do
@@ -41,8 +41,8 @@ describe 'CycleAnalytics#plan' do
issue.update(label_ids: [label.id])
create_commit_referencing_issue(issue, branch_name: branch_name)
- create_merge_request_closing_issue(issue, source_branch: branch_name)
- merge_merge_requests_closing_issue(issue)
+ create_merge_request_closing_issue(user, project, issue, source_branch: branch_name)
+ merge_merge_requests_closing_issue(user, project, issue)
expect(subject[:issue].median).to be_nil
end
diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb
index f8681c0a2f9..156eb96cfce 100644
--- a/spec/models/cycle_analytics/production_spec.rb
+++ b/spec/models/cycle_analytics/production_spec.rb
@@ -13,11 +13,11 @@ describe 'CycleAnalytics#production' do
data_fn: -> (context) { { issue: context.build(:issue, project: context.project) } },
start_time_conditions: [["issue is created", -> (context, data) { data[:issue].save }]],
before_end_fn: lambda do |context, data|
- context.create_merge_request_closing_issue(data[:issue])
- context.merge_merge_requests_closing_issue(data[:issue])
+ context.create_merge_request_closing_issue(context.user, context.project, data[:issue])
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
end,
end_time_conditions:
- [["merge request that closes issue is deployed to production", -> (context, data) { context.deploy_master }],
+ [["merge request that closes issue is deployed to production", -> (context, data) { context.deploy_master(context.user, context.project) }],
["production deploy happens after merge request is merged (along with other changes)",
lambda do |context, data|
# Make other changes on master
@@ -29,14 +29,14 @@ describe 'CycleAnalytics#production' do
branch_name: 'master')
context.project.repository.commit(sha)
- context.deploy_master
+ context.deploy_master(context.user, context.project)
end]])
context "when a regular merge request (that doesn't close the issue) is merged and deployed" do
it "returns nil" do
merge_request = create(:merge_request)
MergeRequests::MergeService.new(project, user).execute(merge_request)
- deploy_master
+ deploy_master(user, project)
expect(subject[:production].median).to be_nil
end
@@ -45,9 +45,9 @@ describe 'CycleAnalytics#production' do
context "when the deployment happens to a non-production environment" do
it "returns nil" do
issue = create(:issue, project: project)
- merge_request = create_merge_request_closing_issue(issue)
+ merge_request = create_merge_request_closing_issue(user, project, issue)
MergeRequests::MergeService.new(project, user).execute(merge_request)
- deploy_master(environment: 'staging')
+ deploy_master(user, project, environment: 'staging')
expect(subject[:production].median).to be_nil
end
diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb
index 0ac58695b35..0aedfb49cb5 100644
--- a/spec/models/cycle_analytics/review_spec.rb
+++ b/spec/models/cycle_analytics/review_spec.rb
@@ -13,11 +13,11 @@ describe 'CycleAnalytics#review' do
data_fn: -> (context) { { issue: context.create(:issue, project: context.project) } },
start_time_conditions: [["merge request that closes issue is created",
-> (context, data) do
- context.create_merge_request_closing_issue(data[:issue])
+ context.create_merge_request_closing_issue(context.user, context.project, data[:issue])
end]],
end_time_conditions: [["merge request that closes issue is merged",
-> (context, data) do
- context.merge_merge_requests_closing_issue(data[:issue])
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
end]],
post_fn: nil)
diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb
index b66d5623910..0cbda50c688 100644
--- a/spec/models/cycle_analytics/staging_spec.rb
+++ b/spec/models/cycle_analytics/staging_spec.rb
@@ -13,15 +13,15 @@ describe 'CycleAnalytics#staging' do
phase: :staging,
data_fn: lambda do |context|
issue = context.create(:issue, project: context.project)
- { issue: issue, merge_request: context.create_merge_request_closing_issue(issue) }
+ { issue: issue, merge_request: context.create_merge_request_closing_issue(context.user, context.project, issue) }
end,
start_time_conditions: [["merge request that closes issue is merged",
-> (context, data) do
- context.merge_merge_requests_closing_issue(data[:issue])
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
end]],
end_time_conditions: [["merge request that closes issue is deployed to production",
-> (context, data) do
- context.deploy_master
+ context.deploy_master(context.user, context.project)
end],
["production deploy happens after merge request is merged (along with other changes)",
lambda do |context, data|
@@ -34,14 +34,14 @@ describe 'CycleAnalytics#staging' do
branch_name: 'master')
context.project.repository.commit(sha)
- context.deploy_master
+ context.deploy_master(context.user, context.project)
end]])
context "when a regular merge request (that doesn't close the issue) is merged and deployed" do
it "returns nil" do
merge_request = create(:merge_request)
MergeRequests::MergeService.new(project, user).execute(merge_request)
- deploy_master
+ deploy_master(user, project)
expect(subject[:staging].median).to be_nil
end
@@ -50,9 +50,9 @@ describe 'CycleAnalytics#staging' do
context "when the deployment happens to a non-production environment" do
it "returns nil" do
issue = create(:issue, project: project)
- merge_request = create_merge_request_closing_issue(issue)
+ merge_request = create_merge_request_closing_issue(user, project, issue)
MergeRequests::MergeService.new(project, user).execute(merge_request)
- deploy_master(environment: 'staging')
+ deploy_master(user, project, environment: 'staging')
expect(subject[:staging].median).to be_nil
end
diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb
index 690c09bc2dc..e58b8fdff58 100644
--- a/spec/models/cycle_analytics/test_spec.rb
+++ b/spec/models/cycle_analytics/test_spec.rb
@@ -12,26 +12,26 @@ describe 'CycleAnalytics#test' do
phase: :test,
data_fn: lambda do |context|
issue = context.create(:issue, project: context.project)
- merge_request = context.create_merge_request_closing_issue(issue)
+ merge_request = context.create_merge_request_closing_issue(context.user, context.project, issue)
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! }]],
end_time_conditions: [["pipeline is finished", -> (context, data) { data[:pipeline].succeed! }]],
post_fn: -> (context, data) do
- context.merge_merge_requests_closing_issue(data[:issue])
+ context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue])
end)
context "when the pipeline is for a regular merge request (that doesn't close an issue)" do
it "returns nil" do
issue = create(:issue, project: project)
- merge_request = create_merge_request_closing_issue(issue)
+ merge_request = create_merge_request_closing_issue(user, project, issue)
pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha)
pipeline.run!
pipeline.succeed!
- merge_merge_requests_closing_issue(issue)
+ merge_merge_requests_closing_issue(user, project, issue)
expect(subject[:test].median).to be_nil
end
@@ -51,13 +51,13 @@ describe 'CycleAnalytics#test' do
context "when the pipeline is dropped (failed)" do
it "returns nil" do
issue = create(:issue, project: project)
- merge_request = create_merge_request_closing_issue(issue)
+ merge_request = create_merge_request_closing_issue(user, project, issue)
pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha)
pipeline.run!
pipeline.drop!
- merge_merge_requests_closing_issue(issue)
+ merge_merge_requests_closing_issue(user, project, issue)
expect(subject[:test].median).to be_nil
end
@@ -66,13 +66,13 @@ describe 'CycleAnalytics#test' do
context "when the pipeline is cancelled" do
it "returns nil" do
issue = create(:issue, project: project)
- merge_request = create_merge_request_closing_issue(issue)
+ merge_request = create_merge_request_closing_issue(user, project, issue)
pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha)
pipeline.run!
pipeline.cancel!
- merge_merge_requests_closing_issue(issue)
+ merge_merge_requests_closing_issue(user, project, issue)
expect(subject[:test].median).to be_nil
end
diff --git a/spec/models/cycle_analytics_spec.rb b/spec/models/cycle_analytics_spec.rb
new file mode 100644
index 00000000000..0fe24870f02
--- /dev/null
+++ b/spec/models/cycle_analytics_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe CycleAnalytics do
+ let(:project) { create(:project, :repository) }
+ let(:from_date) { 10.days.ago }
+ let(:user) { create(:user, :admin) }
+ let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
+ let(:milestone) { create(:milestone, project: project) }
+ let(:mr) { create_merge_request_closing_issue(user, project, 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) }
+
+ subject { described_class.new(project, from: from_date) }
+
+ describe '#all_medians_per_stage' do
+ before do
+ allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue])
+
+ create_cycle(user, project, issue, mr, milestone, pipeline)
+ deploy_master(user, project)
+ end
+
+ it 'returns every median for each stage for a specific project' do
+ values = described_class::STAGES.each_with_object({}) do |stage_name, hsh|
+ hsh[stage_name] = subject[stage_name].median.presence
+ end
+
+ expect(subject.all_medians_per_stage).to eq(values)
+ end
+ end
+end
diff --git a/spec/models/deploy_key_spec.rb b/spec/models/deploy_key_spec.rb
index 3d7283e2164..41440c6d288 100644
--- a/spec/models/deploy_key_spec.rb
+++ b/spec/models/deploy_key_spec.rb
@@ -17,4 +17,25 @@ describe DeployKey, :mailer do
should_not_email(user)
end
end
+
+ describe '#user' do
+ let(:deploy_key) { create(:deploy_key) }
+ let(:user) { create(:user) }
+
+ context 'when user is set' do
+ before do
+ deploy_key.user = user
+ end
+
+ it 'returns the user' do
+ expect(deploy_key.user).to be(user)
+ end
+ end
+
+ context 'when user is not set' do
+ it 'returns the ghost user' do
+ expect(deploy_key.user).to eq(User.ghost)
+ end
+ end
+ end
end
diff --git a/spec/models/deploy_keys_project_spec.rb b/spec/models/deploy_keys_project_spec.rb
index 0345fefb254..fca3090ff4a 100644
--- a/spec/models/deploy_keys_project_spec.rb
+++ b/spec/models/deploy_keys_project_spec.rb
@@ -8,7 +8,7 @@ describe DeployKeysProject do
describe "Validation" do
it { is_expected.to validate_presence_of(:project_id) }
- it { is_expected.to validate_presence_of(:deploy_key_id) }
+ it { is_expected.to validate_presence_of(:deploy_key) }
end
describe "Destroying" do
diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb
new file mode 100644
index 00000000000..5a15c23def4
--- /dev/null
+++ b/spec/models/deploy_token_spec.rb
@@ -0,0 +1,134 @@
+require 'spec_helper'
+
+describe DeployToken do
+ subject(:deploy_token) { create(:deploy_token) }
+
+ it { is_expected.to have_many :project_deploy_tokens }
+ it { is_expected.to have_many(:projects).through(:project_deploy_tokens) }
+
+ describe '#ensure_token' do
+ it 'should ensure a token' do
+ deploy_token.token = nil
+ deploy_token.save
+
+ expect(deploy_token.token).not_to be_empty
+ end
+ end
+
+ describe '#ensure_at_least_one_scope' do
+ context 'with at least one scope' do
+ it 'should be valid' do
+ is_expected.to be_valid
+ end
+ end
+
+ context 'with no scopes' do
+ it 'should be invalid' do
+ deploy_token = build(:deploy_token, read_repository: false, read_registry: false)
+
+ expect(deploy_token).not_to be_valid
+ expect(deploy_token.errors[:base].first).to eq("Scopes can't be blank")
+ end
+ end
+ end
+
+ describe '#scopes' do
+ context 'with all the scopes' do
+ it 'should return scopes assigned to DeployToken' do
+ expect(deploy_token.scopes).to eq([:read_repository, :read_registry])
+ end
+ end
+
+ context 'with only one scope' do
+ it 'should return scopes assigned to DeployToken' do
+ deploy_token = create(:deploy_token, read_registry: false)
+ expect(deploy_token.scopes).to eq([:read_repository])
+ end
+ end
+ end
+
+ describe '#revoke!' do
+ it 'should update revoke attribute' do
+ deploy_token.revoke!
+ expect(deploy_token.revoked?).to be_truthy
+ end
+ end
+
+ describe "#active?" do
+ context "when it has been revoked" do
+ it 'should return false' do
+ deploy_token.revoke!
+ expect(deploy_token.active?).to be_falsy
+ end
+ end
+
+ context "when it hasn't been revoked" do
+ it 'should return true' do
+ expect(deploy_token.active?).to be_truthy
+ end
+ end
+ end
+
+ describe '#username' do
+ it 'returns a harcoded username' do
+ expect(deploy_token.username).to eq("gitlab+deploy-token-#{deploy_token.id}")
+ end
+ end
+
+ describe '#has_access_to?' do
+ let(:project) { create(:project) }
+
+ subject(:deploy_token) { create(:deploy_token, projects: [project]) }
+
+ context 'when the deploy token has access to the project' do
+ it 'should return true' do
+ expect(deploy_token.has_access_to?(project)).to be_truthy
+ end
+ end
+
+ context 'when the deploy token does not have access to the project' do
+ it 'should return false' do
+ another_project = create(:project)
+ expect(deploy_token.has_access_to?(another_project)).to be_falsy
+ end
+ end
+ end
+
+ describe '#expires_at' do
+ context 'when using Forever.date' do
+ let(:deploy_token) { create(:deploy_token, expires_at: nil) }
+
+ it 'should return nil' do
+ expect(deploy_token.expires_at).to be_nil
+ end
+ end
+
+ context 'when using a personalized date' do
+ let(:expires_at) { Date.today + 5.months }
+ let(:deploy_token) { create(:deploy_token, expires_at: expires_at) }
+
+ it 'should return the personalized date' do
+ expect(deploy_token.expires_at).to eq(expires_at)
+ end
+ end
+ end
+
+ describe '#expires_at=' do
+ context 'when passing nil' do
+ let(:deploy_token) { create(:deploy_token, expires_at: nil) }
+
+ it 'should assign Forever.date' do
+ expect(deploy_token.read_attribute(:expires_at)).to eq(Forever.date)
+ end
+ end
+
+ context 'when passign a value' do
+ let(:expires_at) { Date.today + 5.months }
+ let(:deploy_token) { create(:deploy_token, expires_at: expires_at) }
+
+ it 'should respect the value' do
+ expect(deploy_token.read_attribute(:expires_at)).to eq(expires_at)
+ end
+ end
+ end
+end
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index ba8aa13d5ad..ac30cd27e0c 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -64,6 +64,7 @@ describe Deployment do
describe '#metrics' do
let(:deployment) { create(:deployment) }
+ let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
subject { deployment.metrics }
@@ -76,17 +77,16 @@ describe Deployment do
{
success: true,
metrics: {},
- last_update: 42,
- deployment_time: 1494408956
+ last_update: 42
}
end
before do
- allow(deployment.project).to receive_message_chain(:monitoring_service, :deployment_metrics)
- .with(any_args).and_return(simple_metrics)
+ allow(deployment).to receive(:prometheus_adapter).and_return(prometheus_adapter)
+ allow(prometheus_adapter).to receive(:query).with(:deployment, deployment).and_return(simple_metrics)
end
- it { is_expected.to eq(simple_metrics) }
+ it { is_expected.to eq(simple_metrics.merge({ deployment_time: deployment.created_at.to_i })) }
end
end
@@ -109,11 +109,11 @@ describe Deployment do
}
end
- let(:prometheus_service) { double('prometheus_service') }
+ let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
before do
- allow(project).to receive(:prometheus_service).and_return(prometheus_service)
- allow(prometheus_service).to receive(:additional_deployment_metrics).and_return(simple_metrics)
+ allow(deployment).to receive(:prometheus_adapter).and_return(prometheus_adapter)
+ allow(prometheus_adapter).to receive(:query).with(:additional_metrics_deployment, deployment).and_return(simple_metrics)
end
it { is_expected.to eq(simple_metrics.merge({ deployment_time: deployment.created_at.to_i })) }
diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb
index fa02434b0fd..50b19000799 100644
--- a/spec/models/diff_discussion_spec.rb
+++ b/spec/models/diff_discussion_spec.rb
@@ -47,8 +47,20 @@ describe DiffDiscussion do
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)
+ context 'when commit_id is not present' do
+ 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 commit_id is present' do
+ before do
+ diff_note.update_attribute(:commit_id, 'commit_123')
+ end
+
+ it 'includes the commit_id in the result' do
+ expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff1.id, commit_id: 'commit_123')
+ end
end
end
@@ -70,8 +82,20 @@ describe DiffDiscussion do
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)
+ context 'when commit_id is not present' do
+ 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 commit_id is present' do
+ before do
+ diff_note.update_attribute(:commit_id, 'commit_123')
+ end
+
+ it 'includes the commit_id in the result' do
+ expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff3.id, start_sha: merge_request_diff1.head_commit_sha, commit_id: 'commit_123')
+ end
end
end
@@ -83,8 +107,20 @@ describe DiffDiscussion do
diff_note.save!
end
- it 'returns nil' do
- expect(subject.merge_request_version_params).to be_nil
+ context 'when commit_id is not present' do
+ it 'returns empty hash' do
+ expect(subject.merge_request_version_params).to eq(nil)
+ end
+ end
+
+ context 'when commit_id is present' do
+ before do
+ diff_note.update_attribute(:commit_id, 'commit_123')
+ end
+
+ it 'returns the commit_id' do
+ expect(subject.merge_request_version_params).to eq(commit_id: 'commit_123')
+ end
end
end
end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index 4d0b3245a13..2705421e540 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -112,22 +112,6 @@ describe DiffNote do
end
end
- describe "#for_line?" do
- context "when provided the correct diff line" do
- it "returns true" do
- expect(subject.for_line?(subject.diff_line)).to be true
- end
- end
-
- context "when provided a different diff line" do
- it "returns false" do
- some_line = subject.diff_file.diff_lines.first
-
- expect(subject.for_line?(some_line)).to be false
- end
- end
- end
-
describe "#active?" do
context "when noteable is a commit" do
subject { build(:diff_note_on_commit, project: project, position: position) }
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 6f24a039998..56161bfcc28 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Environment do
- set(:project) { create(:project) }
+ let(:project) { create(:project) }
subject(:environment) { create(:environment, project: project) }
it { is_expected.to belong_to(:project) }
@@ -142,15 +142,15 @@ describe Environment do
let(:commit) { project.commit.parent }
it 'returns deployment id for the environment' do
- expect(environment.first_deployment_for(commit)).to eq deployment1
+ expect(environment.first_deployment_for(commit.id)).to eq deployment1
end
it 'return nil when no deployment is found' do
- expect(environment.first_deployment_for(head_commit)).to eq nil
+ expect(environment.first_deployment_for(head_commit.id)).to eq nil
end
it 'returns a UTF-8 ref' do
- expect(environment.first_deployment_for(commit).ref).to be_utf8
+ expect(environment.first_deployment_for(commit.id).ref).to be_utf8
end
end
@@ -368,6 +368,32 @@ describe Environment do
end
end
+ describe '#deployment_platform' do
+ context 'when there is a deployment platform for environment' do
+ let!(:cluster) do
+ create(:cluster, :provided_by_gcp,
+ environment_scope: '*', projects: [project])
+ end
+
+ it 'finds a deployment platform' do
+ expect(environment.deployment_platform).to eq cluster.platform
+ end
+ end
+
+ context 'when there is no deployment platform for environment' do
+ it 'returns nil' do
+ expect(environment.deployment_platform).to be_nil
+ end
+ end
+
+ it 'checks deployment platforms associated with a project' do
+ expect(project).to receive(:deployment_platform)
+ .with(environment: environment.name)
+
+ environment.deployment_platform
+ end
+ end
+
describe '#terminals' do
subject { environment.terminals }
@@ -452,8 +478,8 @@ describe Environment do
end
it 'returns the metrics from the deployment service' do
- expect(project.monitoring_service)
- .to receive(:environment_metrics).with(environment)
+ expect(environment.prometheus_adapter)
+ .to receive(:query).with(:environment, environment)
.and_return(:fake_metrics)
is_expected.to eq(:fake_metrics)
@@ -508,12 +534,12 @@ describe Environment do
context 'when the environment has additional metrics' do
before do
- allow(environment).to receive(:has_additional_metrics?).and_return(true)
+ allow(environment).to receive(:has_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)
+ expect(environment.prometheus_adapter).to receive(:query)
+ .with(:additional_metrics_environment, environment)
.and_return(:fake_metrics)
is_expected.to eq(:fake_metrics)
@@ -522,46 +548,13 @@ describe Environment do
context 'when the environment does not have metrics' do
before do
- allow(environment).to receive(:has_additional_metrics?).and_return(false)
+ allow(environment).to receive(:has_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
@@ -654,4 +647,12 @@ describe Environment do
end
end
end
+
+ describe '#prometheus_adapter' do
+ it 'calls prometheus adapter service' do
+ expect_any_instance_of(Prometheus::AdapterService).to receive(:prometheus_adapter)
+
+ subject.prometheus_adapter
+ end
+ end
end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index aa7a8342a4c..8ea92410022 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -49,6 +49,22 @@ describe Event do
end
end
end
+
+ describe 'after_create :track_user_interacted_projects' do
+ let(:event) { build(:push_event, project: project, author: project.owner) }
+
+ it 'passes event to UserInteractedProject.track' do
+ expect(UserInteractedProject).to receive(:available?).and_return(true)
+ expect(UserInteractedProject).to receive(:track).with(event)
+ event.save
+ end
+
+ it 'does not call UserInteractedProject.track if its not yet available' do
+ expect(UserInteractedProject).to receive(:available?).and_return(false)
+ expect(UserInteractedProject).not_to receive(:track)
+ event.save
+ end
+ end
end
describe "Push event" do
@@ -125,8 +141,8 @@ describe Event do
let(:event) { described_class.new(project: project, target: target, author_id: author.id) }
before do
- project.team << [member, :developer]
- project.team << [guest, :guest]
+ project.add_developer(member)
+ project.add_guest(guest)
end
context 'commit note event' do
@@ -347,6 +363,22 @@ describe Event do
end
end
+ describe '#target' do
+ it 'eager loads the author of an event target' do
+ create(:closed_issue_event)
+
+ events = described_class.preload(:target).all.to_a
+ count = ActiveRecord::QueryRecorder
+ .new { events.first.target.author }.count
+
+ # This expectation exists to make sure the test doesn't pass when the
+ # author is for some reason not loaded at all.
+ expect(events.first.target.author).to be_an_instance_of(User)
+
+ expect(count).to be_zero
+ end
+ end
+
def create_push_event(project, user)
event = create(:push_event, project: project, author: user)
diff --git a/spec/models/generic_commit_status_spec.rb b/spec/models/generic_commit_status_spec.rb
index 7f1909710d8..673049d1cc4 100644
--- a/spec/models/generic_commit_status_spec.rb
+++ b/spec/models/generic_commit_status_spec.rb
@@ -43,7 +43,7 @@ describe GenericCommitStatus do
context 'when user has ability to see datails' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'details path points to an external URL' do
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 5e82a2988ce..d620943693c 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -18,6 +18,7 @@ describe Group do
it { is_expected.to have_many(:uploads).dependent(:destroy) }
it { is_expected.to have_one(:chat_team) }
it { is_expected.to have_many(:custom_attributes).class_name('GroupCustomAttribute') }
+ it { is_expected.to have_many(:badges).class_name('GroupBadge') }
describe '#members & #requesters' do
let(:requester) { create(:user) }
@@ -41,7 +42,6 @@ describe Group do
describe 'validations' do
it { is_expected.to validate_presence_of :name }
- it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) }
it { is_expected.to validate_presence_of :path }
it { is_expected.not_to validate_presence_of :owner }
it { is_expected.to validate_presence_of :two_factor_grace_period }
@@ -240,7 +240,7 @@ describe Group do
it "is false if avatar is html page" do
group.update_attribute(:avatar, 'uploads/avatar.html')
- expect(group.avatar_type).to eq(["only images allowed"])
+ expect(group.avatar_type).to eq(["file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff"])
end
end
@@ -550,7 +550,7 @@ describe Group do
context 'when the ref is a protected branch' do
before do
- create(:protected_branch, name: 'ref', project: project)
+ allow(project).to receive(:protected_for?).with('ref').and_return(true)
end
it_behaves_like 'ref is protected'
@@ -558,7 +558,7 @@ describe Group do
context 'when the ref is a protected tag' do
before do
- create(:protected_tag, name: 'ref', project: project)
+ allow(project).to receive(:protected_for?).with('ref').and_return(true)
end
it_behaves_like 'ref is protected'
@@ -572,6 +572,10 @@ describe Group do
let(:variable_child_2) { create(:ci_group_variable, group: group_child_2) }
let(:variable_child_3) { create(:ci_group_variable, group: group_child_3) }
+ before do
+ allow(project).to receive(:protected_for?).with('ref').and_return(true)
+ end
+
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]
@@ -582,4 +586,20 @@ describe Group do
end
end
end
+
+ describe '#has_parent?' do
+ context 'when the group has a parent' do
+ it 'should be truthy' do
+ group = create(:group, :nested)
+ expect(group.has_parent?).to be_truthy
+ end
+ end
+
+ context 'when the group has no parent' do
+ it 'should be falsy' do
+ group = create(:group, parent: nil)
+ expect(group.has_parent?).to be_falsy
+ end
+ end
+ end
end
diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb
index 431e3db9f00..8bc45715dcd 100644
--- a/spec/models/hooks/system_hook_spec.rb
+++ b/spec/models/hooks/system_hook_spec.rb
@@ -7,7 +7,8 @@ describe SystemHook do
it 'sets defined default parameters' do
attrs = {
push_events: false,
- repository_update_events: true
+ repository_update_events: true,
+ merge_requests_events: false
}
expect(system_hook).to have_attributes(attrs)
end
@@ -62,7 +63,7 @@ describe SystemHook do
end
it "project_create hook" do
- project.team << [user, :master]
+ project.add_master(user)
expect(WebMock).to have_requested(:post, system_hook.url).with(
body: /user_add_to_team/,
@@ -71,7 +72,7 @@ describe SystemHook do
end
it "project_destroy hook" do
- project.team << [user, :master]
+ project.add_master(user)
project.project_members.destroy_all
expect(WebMock).to have_requested(:post, system_hook.url).with(
diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb
index 388120160ab..ea6d6e53ef5 100644
--- a/spec/models/hooks/web_hook_spec.rb
+++ b/spec/models/hooks/web_hook_spec.rb
@@ -29,6 +29,12 @@ describe WebHook do
expect(hook.url).to eq('https://example.com')
end
end
+
+ describe 'token' do
+ it { is_expected.to allow_value("foobar").for(:token) }
+
+ it { is_expected.not_to allow_values("foo\nbar", "foo\r\nbar").for(:token) }
+ end
end
describe 'execute' do
diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb
index 7c66c98231b..a5ce245c21d 100644
--- a/spec/models/identity_spec.rb
+++ b/spec/models/identity_spec.rb
@@ -70,5 +70,38 @@ describe Identity do
end
end
end
+
+ context 'after_destroy' do
+ let!(:user) { create(:user) }
+ let(:ldap_identity) { create(:identity, provider: 'ldapmain', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', user: user) }
+ let(:ldap_user_synced_attributes) { { provider: 'ldapmain', name_synced: true, email_synced: true } }
+ let(:other_provider_user_synced_attributes) { { provider: 'other', name_synced: true, email_synced: true } }
+
+ describe 'if user synced attributes metadada provider' do
+ context 'matches the identity provider ' do
+ it 'removes the user synced attributes' do
+ user.create_user_synced_attributes_metadata(ldap_user_synced_attributes)
+
+ expect(user.user_synced_attributes_metadata.provider).to eq 'ldapmain'
+
+ ldap_identity.destroy
+
+ expect(user.reload.user_synced_attributes_metadata).to be_nil
+ end
+ end
+
+ context 'does not matche the identity provider' do
+ it 'does not remove the user synced attributes' do
+ user.create_user_synced_attributes_metadata(other_provider_user_synced_attributes)
+
+ expect(user.user_synced_attributes_metadata.provider).to eq 'other'
+
+ ldap_identity.destroy
+
+ expect(user.reload.user_synced_attributes_metadata.provider).to eq 'other'
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb
new file mode 100644
index 00000000000..581fd0293cc
--- /dev/null
+++ b/spec/models/internal_id_spec.rb
@@ -0,0 +1,106 @@
+require 'spec_helper'
+
+describe InternalId do
+ let(:project) { create(:project) }
+ let(:usage) { :issues }
+ let(:issue) { build(:issue, project: project) }
+ let(:scope) { { project: project } }
+ let(:init) { ->(s) { s.project.issues.size } }
+
+ context 'validations' do
+ it { is_expected.to validate_presence_of(:usage) }
+ end
+
+ describe '.generate_next' do
+ subject { described_class.generate_next(issue, scope, usage, init) }
+
+ context 'in the absence of a record' do
+ it 'creates a record if not yet present' do
+ expect { subject }.to change { described_class.count }.from(0).to(1)
+ end
+
+ it 'stores record attributes' do
+ subject
+
+ described_class.first.tap do |record|
+ expect(record.project).to eq(project)
+ expect(record.usage).to eq(usage.to_s)
+ end
+ end
+
+ context 'with existing issues' do
+ before do
+ rand(1..10).times { create(:issue, project: project) }
+ described_class.delete_all
+ end
+
+ it 'calculates last_value values automatically' do
+ expect(subject).to eq(project.issues.size + 1)
+ end
+ end
+
+ context 'with concurrent inserts on table' do
+ it 'looks up the record if it was created concurrently' do
+ args = { **scope, usage: described_class.usages[usage.to_s] }
+ record = double
+ expect(described_class).to receive(:find_by).with(args).and_return(nil) # first call, record not present
+ expect(described_class).to receive(:find_by).with(args).and_return(record) # second call, record was created by another process
+ expect(described_class).to receive(:create!).and_raise(ActiveRecord::RecordNotUnique, 'record not unique')
+ expect(record).to receive(:increment_and_save!)
+
+ subject
+ end
+ end
+ end
+
+ it 'generates a strictly monotone, gapless sequence' do
+ seq = (0..rand(100)).map do
+ described_class.generate_next(issue, scope, usage, init)
+ end
+ normalized = seq.map { |i| i - seq.min }
+
+ expect(normalized).to eq((0..seq.size - 1).to_a)
+ end
+
+ context 'with an insufficient schema version' do
+ before do
+ described_class.reset_column_information
+ expect(ActiveRecord::Migrator).to receive(:current_version).and_return(InternalId::REQUIRED_SCHEMA_VERSION - 1)
+ end
+
+ let(:init) { double('block') }
+
+ it 'calculates next internal ids on the fly' do
+ val = rand(1..100)
+
+ expect(init).to receive(:call).with(issue).and_return(val)
+ expect(subject).to eq(val + 1)
+ end
+ end
+ end
+
+ describe '#increment_and_save!' do
+ let(:id) { create(:internal_id) }
+ subject { id.increment_and_save! }
+
+ it 'returns incremented iid' do
+ value = id.last_value
+
+ expect(subject).to eq(value + 1)
+ end
+
+ it 'saves the record' do
+ subject
+
+ expect(id.changed?).to be_falsey
+ end
+
+ context 'with last_value=nil' do
+ let(:id) { build(:internal_id, last_value: nil) }
+
+ it 'returns 1' do
+ expect(subject).to eq(1)
+ end
+ end
+ end
+end
diff --git a/spec/models/issue_collection_spec.rb b/spec/models/issue_collection_spec.rb
index 34d98a3c975..580a98193af 100644
--- a/spec/models/issue_collection_spec.rb
+++ b/spec/models/issue_collection_spec.rb
@@ -42,7 +42,7 @@ describe IssueCollection do
context 'using a user that has reporter access to the project' do
it 'returns the issues of the project' do
- project.team << [user, :reporter]
+ project.add_reporter(user)
expect(collection.updatable_by_user(user)).to eq([issue1, issue2])
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 0ea287d007a..11154291368 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -9,18 +9,45 @@ describe Issue do
describe 'modules' do
subject { described_class }
- it { is_expected.to include_module(InternalId) }
it { is_expected.to include_module(Issuable) }
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Sortable) }
it { is_expected.to include_module(Taskable) }
+
+ it_behaves_like 'AtomicInternalId' do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(:issue) }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { :issues }
+ end
end
subject { create(:issue) }
- describe "act_as_paranoid" do
- it { is_expected.to have_db_column(:deleted_at) }
- it { is_expected.to have_db_index(:deleted_at) }
+ describe 'callbacks' do
+ describe '#ensure_metrics' do
+ it 'creates metrics after saving' do
+ issue = create(:issue)
+
+ expect(issue.metrics).to be_persisted
+ expect(Issue::Metrics.count).to eq(1)
+ end
+
+ it 'does not create duplicate metrics for an issue' do
+ issue = create(:issue)
+
+ issue.close!
+
+ expect(issue.metrics).to be_persisted
+ expect(Issue::Metrics.count).to eq(1)
+ end
+
+ it 'records current metrics' do
+ expect_any_instance_of(Issue::Metrics).to receive(:record!)
+
+ create(:issue)
+ end
+ end
end
describe '#order_by_position_and_priority' do
@@ -200,27 +227,55 @@ describe Issue do
end
describe '#referenced_merge_requests' do
- it 'returns the referenced merge requests' do
- project = create(:project, :public)
-
- mr1 = create(:merge_request,
- source_project: project,
- source_branch: 'master',
- target_branch: 'feature')
+ let(:project) { create(:project, :public) }
+ let(:issue) do
+ create(:issue, description: merge_request.to_reference, project: project)
+ end
+ let!(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: 'master',
+ target_branch: 'feature')
+ end
+ it 'returns the referenced merge requests' do
mr2 = create(:merge_request,
source_project: project,
source_branch: 'feature',
target_branch: 'master')
- issue = create(:issue, description: mr1.to_reference, project: project)
-
create(:note_on_issue,
noteable: issue,
note: mr2.to_reference,
project_id: project.id)
- expect(issue.referenced_merge_requests).to eq([mr1, mr2])
+ expect(issue.referenced_merge_requests).to eq([merge_request, mr2])
+ end
+
+ it 'returns cross project referenced merge requests' do
+ other_project = create(:project, :public)
+ cross_project_merge_request = create(:merge_request, source_project: other_project)
+ create(:note_on_issue,
+ noteable: issue,
+ note: cross_project_merge_request.to_reference(issue.project),
+ project_id: issue.project.id)
+
+ expect(issue.referenced_merge_requests).to eq([merge_request, cross_project_merge_request])
+ end
+
+ it 'excludes cross project references if the user cannot read cross project' do
+ user = create(:user)
+ allow(Ability).to receive(:allowed?).and_call_original
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+
+ other_project = create(:project, :public)
+ cross_project_merge_request = create(:merge_request, source_project: other_project)
+ create(:note_on_issue,
+ noteable: issue,
+ note: cross_project_merge_request.to_reference(issue.project),
+ project_id: issue.project.id)
+
+ expect(issue.referenced_merge_requests(user)).to eq([merge_request])
end
end
@@ -238,7 +293,7 @@ describe Issue do
let(:issue) { create(:issue, project: project) }
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
it { is_expected.to eq true }
@@ -254,7 +309,7 @@ describe Issue do
context 'destination project allowed' do
before do
- to_project.team << [user, :reporter]
+ to_project.add_reporter(user)
end
it { is_expected.to eq true }
@@ -262,7 +317,7 @@ describe Issue do
context 'destination project not allowed' do
before do
- to_project.team << [user, :guest]
+ to_project.add_guest(user)
end
it { is_expected.to eq false }
@@ -288,7 +343,7 @@ describe Issue do
end
describe '#related_branches' do
- let(:user) { build(:admin) }
+ let(:user) { create(:admin) }
before do
allow(subject.project.repository).to receive(:branch_names)
@@ -550,7 +605,7 @@ describe Issue do
context 'when the user is the project owner' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'returns true for a regular issue' do
@@ -574,7 +629,7 @@ describe Issue do
context 'using a public project' do
before do
- project.team << [user, Gitlab::Access::DEVELOPER]
+ project.add_developer(user)
end
it 'returns true for a regular issue' do
@@ -594,7 +649,7 @@ describe Issue do
let(:project) { create(:project, :internal) }
before do
- project.team << [user, Gitlab::Access::DEVELOPER]
+ project.add_developer(user)
end
it 'returns true for a regular issue' do
@@ -614,7 +669,7 @@ describe Issue do
let(:project) { create(:project, :private) }
before do
- project.team << [user, Gitlab::Access::DEVELOPER]
+ project.add_developer(user)
end
it 'returns true for a regular issue' do
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index 4cd9e3f4f1d..06d26ef89f1 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -1,13 +1,6 @@
require 'spec_helper'
describe Key, :mailer do
- include Gitlab::CurrentSettings
-
- describe 'modules' do
- subject { described_class }
- it { is_expected.to include_module(Gitlab::CurrentSettings) }
- end
-
describe "Associations" do
it { is_expected.to belong_to(:user) }
end
@@ -19,6 +12,9 @@ describe Key, :mailer do
it { is_expected.to validate_presence_of(:key) }
it { is_expected.to validate_length_of(:key).is_at_most(5000) }
it { is_expected.to allow_value(attributes_for(:rsa_key_2048)[:key]).for(:key) }
+ it { is_expected.to allow_value(attributes_for(:rsa_key_4096)[:key]).for(:key) }
+ it { is_expected.to allow_value(attributes_for(:rsa_key_5120)[:key]).for(:key) }
+ it { is_expected.to allow_value(attributes_for(:rsa_key_8192)[:key]).for(:key) }
it { is_expected.to allow_value(attributes_for(:dsa_key_2048)[:key]).for(:key) }
it { is_expected.to allow_value(attributes_for(:ecdsa_key_256)[:key]).for(:key) }
it { is_expected.to allow_value(attributes_for(:ed25519_key_256)[:key]).for(:key) }
@@ -79,16 +75,35 @@ describe Key, :mailer do
expect(build(:key)).to be_valid
end
- it 'accepts a key with newline charecters after stripping them' do
- key = build(:key)
- 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
+
+ where(:factory, :chars, :expected_sections) do
+ [
+ [:key, ["\n", "\r\n"], 3],
+ [:key, [' ', ' '], 3],
+ [:key_without_comment, [' ', ' '], 2]
+ ]
+ end
+
+ with_them do
+ let!(:key) { create(factory) }
+ let!(:original_fingerprint) { key.fingerprint }
+
+ it 'accepts a key with blank space characters after stripping them' do
+ modified_key = key.key.insert(100, chars.first).insert(40, chars.last)
+ _, content = modified_key.split
+
+ key.update!(key: modified_key)
+
+ expect(key).to be_valid
+ expect(key.key.split.size).to eq(expected_sections)
+
+ expect(content).not_to match(/\s/)
+ expect(original_fingerprint).to eq(key.fingerprint)
+ end
+ end
end
context 'validate it meets key restrictions' do
diff --git a/spec/models/lfs_file_lock_spec.rb b/spec/models/lfs_file_lock_spec.rb
new file mode 100644
index 00000000000..ce87b01b49c
--- /dev/null
+++ b/spec/models/lfs_file_lock_spec.rb
@@ -0,0 +1,57 @@
+require 'rails_helper'
+
+describe LfsFileLock do
+ set(:lfs_file_lock) { create(:lfs_file_lock) }
+ subject { lfs_file_lock }
+
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:user) }
+
+ it { is_expected.to validate_presence_of(:project_id) }
+ it { is_expected.to validate_presence_of(:user_id) }
+ it { is_expected.to validate_presence_of(:path) }
+
+ describe '#can_be_unlocked_by?' do
+ let(:developer) { create(:user) }
+ let(:master) { create(:user) }
+
+ before do
+ project = lfs_file_lock.project
+
+ project.add_developer(developer)
+ project.add_master(master)
+ end
+
+ context "when it's forced" do
+ it 'can be unlocked by the author' do
+ user = lfs_file_lock.user
+
+ expect(lfs_file_lock.can_be_unlocked_by?(user, true)).to eq(true)
+ end
+
+ it 'can be unlocked by a master' do
+ expect(lfs_file_lock.can_be_unlocked_by?(master, true)).to eq(true)
+ end
+
+ it "can't be unlocked by other user" do
+ expect(lfs_file_lock.can_be_unlocked_by?(developer, true)).to eq(false)
+ end
+ end
+
+ context "when it isn't forced" do
+ it 'can be unlocked by the author' do
+ user = lfs_file_lock.user
+
+ expect(lfs_file_lock.can_be_unlocked_by?(user)).to eq(true)
+ end
+
+ it "can't be unlocked by a master" do
+ expect(lfs_file_lock.can_be_unlocked_by?(master)).to eq(false)
+ end
+
+ it "can't be unlocked by other user" do
+ expect(lfs_file_lock.can_be_unlocked_by?(developer)).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/models/lfs_object_spec.rb b/spec/models/lfs_object_spec.rb
new file mode 100644
index 00000000000..a182116d637
--- /dev/null
+++ b/spec/models/lfs_object_spec.rb
@@ -0,0 +1,85 @@
+require 'spec_helper'
+
+describe LfsObject do
+ describe '#local_store?' do
+ it 'returns true when file_store is nil' do
+ subject.file_store = nil
+
+ expect(subject.local_store?).to eq true
+ end
+
+ it 'returns true when file_store is equal to LfsObjectUploader::Store::LOCAL' do
+ subject.file_store = LfsObjectUploader::Store::LOCAL
+
+ expect(subject.local_store?).to eq true
+ end
+
+ it 'returns false whe file_store is equal to LfsObjectUploader::Store::REMOTE' do
+ subject.file_store = LfsObjectUploader::Store::REMOTE
+
+ expect(subject.local_store?).to eq false
+ end
+ end
+
+ describe '#schedule_background_upload' do
+ before do
+ stub_lfs_setting(enabled: true)
+ end
+
+ subject { create(:lfs_object, :with_file) }
+
+ context 'when object storage is disabled' do
+ before do
+ stub_lfs_object_storage(enabled: false)
+ end
+
+ it 'does not schedule the migration' do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ subject
+ end
+ end
+
+ context 'when object storage is enabled' do
+ context 'when background upload is enabled' do
+ context 'when is licensed' do
+ before do
+ stub_lfs_object_storage(background_upload: true)
+ end
+
+ it 'schedules the model for migration' do
+ expect(ObjectStorage::BackgroundMoveWorker)
+ .to receive(:perform_async)
+ .with('LfsObjectUploader', described_class.name, :file, kind_of(Numeric))
+ .once
+
+ subject
+ end
+
+ it 'schedules the model for migration once' do
+ expect(ObjectStorage::BackgroundMoveWorker)
+ .to receive(:perform_async)
+ .with('LfsObjectUploader', described_class.name, :file, kind_of(Numeric))
+ .once
+
+ lfs_object = create(:lfs_object)
+ lfs_object.file = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png")
+ lfs_object.save!
+ end
+ end
+ end
+
+ context 'when background upload is disabled' do
+ before do
+ stub_lfs_object_storage(background_upload: false)
+ end
+
+ it 'schedules the model for migration' do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ subject
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 0a017c068ad..c64cdf8f812 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -62,12 +62,12 @@ describe Member do
@owner_user = create(:user).tap { |u| group.add_owner(u) }
@owner = group.members.find_by(user_id: @owner_user.id)
- @master_user = create(:user).tap { |u| project.team << [u, :master] }
+ @master_user = create(:user).tap { |u| project.add_master(u) }
@master = project.members.find_by(user_id: @master_user.id)
@blocked_user = create(:user).tap do |u|
- project.team << [u, :master]
- project.team << [u, :developer]
+ project.add_master(u)
+ project.add_developer(u)
u.block!
end
@@ -488,7 +488,7 @@ describe Member do
member.accept_invite!(user)
end
- it "refreshes user's authorized projects", :truncate do
+ it "refreshes user's authorized projects", :delete do
project = member.source
expect(user.authorized_projects).not_to include(project)
@@ -523,11 +523,11 @@ describe Member do
end
end
- describe "destroying a record", :truncate do
+ describe "destroying a record", :delete do
it "refreshes user's authorized projects" do
project = create(:project, :private)
user = create(:user)
- member = project.team << [user, :reporter]
+ member = project.add_reporter(user)
member.destroy
diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb
index fa3e80ba062..b8b0e63f92e 100644
--- a/spec/models/members/project_member_spec.rb
+++ b/spec/models/members/project_member_spec.rb
@@ -45,14 +45,6 @@ describe ProjectMember do
let(:project) { owner.project }
let(:master) { create(:project_member, project: project) }
- let(:owner_todos) { (0...2).map { create(:todo, user: owner.user, project: project) } }
- let(:master_todos) { (0...3).map { create(:todo, user: master.user, project: project) } }
-
- before do
- owner_todos
- master_todos
- end
-
it "creates an expired event when left due to expiry" do
expired = create(:project_member, project: project, expires_at: Time.now - 6.days)
expired.destroy
@@ -63,21 +55,6 @@ describe ProjectMember do
master.destroy
expect(Event.recent.first.action).to eq(Event::LEFT)
end
-
- it "destroys itself and delete associated todos" do
- expect(owner.user.todos.size).to eq(2)
- expect(master.user.todos.size).to eq(3)
- expect(Todo.count).to eq(5)
-
- master_todo_ids = master_todos.map(&:id)
- master.destroy
-
- expect(owner.user.todos.size).to eq(2)
- expect(Todo.count).to eq(2)
- master_todo_ids.each do |id|
- expect(Todo.exists?(id)).to eq(false)
- end
- end
end
describe '.import_team' do
@@ -88,8 +65,8 @@ describe ProjectMember do
@user_1 = create :user
@user_2 = create :user
- @project_1.team << [@user_1, :developer]
- @project_2.team << [@user_2, :reporter]
+ @project_1.add_developer(@user_1)
+ @project_2.add_reporter(@user_2)
@status = @project_2.team.import(@project_1)
end
@@ -136,8 +113,8 @@ describe ProjectMember do
@user_1 = create :user
@user_2 = create :user
- @project_1.team << [@user_1, :developer]
- @project_2.team << [@user_2, :reporter]
+ @project_1.add_developer(@user_1)
+ @project_2.add_reporter(@user_2)
described_class.truncate_teams([@project_1.id, @project_2.id])
end
diff --git a/spec/models/merge_request/metrics_spec.rb b/spec/models/merge_request/metrics_spec.rb
index 9353d5c3c8a..02ff7839739 100644
--- a/spec/models/merge_request/metrics_spec.rb
+++ b/spec/models/merge_request/metrics_spec.rb
@@ -1,16 +1,11 @@
require 'spec_helper'
describe MergeRequest::Metrics do
- subject { create(:merge_request) }
+ subject { described_class.new }
- describe "when recording the default set of metrics on merge request save" do
- it "records the merge time" do
- time = Time.now
- Timecop.freeze(time) { subject.mark_as_merged }
- metrics = subject.metrics
-
- expect(metrics).to be_present
- expect(metrics.merged_at).to be_like_time(time)
- end
+ describe 'associations' do
+ it { is_expected.to belong_to(:merge_request) }
+ it { is_expected.to belong_to(:latest_closed_by).class_name('User') }
+ it { is_expected.to belong_to(:merged_by).class_name('User') }
end
end
diff --git a/spec/models/merge_request_diff_commit_spec.rb b/spec/models/merge_request_diff_commit_spec.rb
index 7709cf43200..8c01a7ac18f 100644
--- a/spec/models/merge_request_diff_commit_spec.rb
+++ b/spec/models/merge_request_diff_commit_spec.rb
@@ -36,7 +36,7 @@ describe MergeRequestDiffCommit do
"committer_email": "dmitriy.zaporozhets@gmail.com",
"merge_request_diff_id": merge_request_diff_id,
"relative_order": 0,
- "sha": sha_attribute.type_cast_for_database('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
+ "sha": sha_attribute.serialize("5937ac0a7beb003549fc5fd26fc247adbce4a52e")
},
{
"message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n",
@@ -48,7 +48,7 @@ describe MergeRequestDiffCommit do
"committer_email": "dmitriy.zaporozhets@gmail.com",
"merge_request_diff_id": merge_request_diff_id,
"relative_order": 1,
- "sha": sha_attribute.type_cast_for_database('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
+ "sha": sha_attribute.serialize("570e7b2abdd848b95f2f578043fc23bd6f6fd24d")
}
]
end
@@ -79,7 +79,7 @@ describe MergeRequestDiffCommit do
"committer_email": "alejorro70@gmail.com",
"merge_request_diff_id": merge_request_diff_id,
"relative_order": 0,
- "sha": sha_attribute.type_cast_for_database('ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69')
+ "sha": sha_attribute.serialize("ba3343bc4fa403a8dfbfcab7fc1a8c29ee34bd69")
}]
end
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index d556004eccf..b4249d72fc8 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -15,6 +15,28 @@ describe MergeRequestDiff do
it { expect(subject.start_commit_sha).to eq('0b4bc9a49b562e85de7cc9e834518ea6828729b9') }
end
+ describe '.by_commit_sha' do
+ subject(:by_commit_sha) { described_class.by_commit_sha(sha) }
+
+ let!(:merge_request) { create(:merge_request, :with_diffs) }
+
+ context 'with sha contained in' do
+ let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
+
+ it 'returns merge request diffs' do
+ expect(by_commit_sha).to eq([merge_request.merge_request_diff])
+ end
+ end
+
+ context 'with sha not contained in' do
+ let(:sha) { 'b83d6e3' }
+
+ it 'returns empty result' do
+ expect(by_commit_sha).to be_empty
+ end
+ end
+ end
+
describe '#latest' do
let!(:mr) { create(:merge_request, :with_diffs) }
let!(:first_diff) { mr.merge_request_diff }
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index bb63abd167b..f73f44ca0ad 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -17,18 +17,13 @@ describe MergeRequest do
describe 'modules' do
subject { described_class }
- it { is_expected.to include_module(InternalId) }
+ it { is_expected.to include_module(NonatomicInternalId) }
it { is_expected.to include_module(Issuable) }
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Sortable) }
it { is_expected.to include_module(Taskable) }
end
- describe "act_as_paranoid" do
- it { is_expected.to have_db_column(:deleted_at) }
- it { is_expected.to have_db_index(:deleted_at) }
- end
-
describe 'validation' do
it { is_expected.to validate_presence_of(:target_branch) }
it { is_expected.to validate_presence_of(:source_branch) }
@@ -65,6 +60,25 @@ describe MergeRequest do
end
end
+ describe 'callbacks' do
+ describe '#ensure_merge_request_metrics' do
+ it 'creates metrics after saving' do
+ merge_request = create(:merge_request)
+
+ expect(merge_request.metrics).to be_persisted
+ expect(MergeRequest::Metrics.count).to eq(1)
+ end
+
+ it 'does not duplicate metrics for a merge request' do
+ merge_request = create(:merge_request)
+
+ merge_request.mark_as_merged!
+
+ expect(MergeRequest::Metrics.count).to eq(1)
+ end
+ end
+ end
+
describe 'respond to' do
it { is_expected.to respond_to(:unchecked?) }
it { is_expected.to respond_to(:can_be_merged?) }
@@ -73,6 +87,39 @@ describe MergeRequest do
it { is_expected.to respond_to(:merge_when_pipeline_succeeds) }
end
+ describe '.by_commit_sha' do
+ subject(:by_commit_sha) { described_class.by_commit_sha(sha) }
+
+ let!(:merge_request) { create(:merge_request, :with_diffs) }
+
+ context 'with sha contained in latest merge request diff' do
+ let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
+
+ it 'returns merge requests' do
+ expect(by_commit_sha).to eq([merge_request])
+ end
+ end
+
+ context 'with sha contained not in latest merge request diff' do
+ let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
+
+ it 'returns empty requests' do
+ latest_merge_request_diff = merge_request.merge_request_diffs.create
+ latest_merge_request_diff.merge_request_diff_commits.where(sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0').delete_all
+
+ expect(by_commit_sha).to be_empty
+ end
+ end
+
+ context 'with sha not contained in' do
+ let(:sha) { 'b83d6e3' }
+
+ it 'returns empty result' do
+ expect(by_commit_sha).to be_empty
+ end
+ end
+ end
+
describe '.in_projects' do
it 'returns the merge requests for a set of projects' do
expect(described_class.in_projects(Project.all)).to eq([subject])
@@ -195,7 +242,7 @@ describe MergeRequest do
describe '#cache_merge_request_closes_issues!' do
before do
- subject.project.team << [subject.author, :developer]
+ subject.project.add_developer(subject.author)
subject.target_branch = subject.project.default_branch
end
@@ -481,7 +528,7 @@ describe MergeRequest do
let(:commit2) { double('commit2', safe_message: "Fixes #{issue1.to_reference}") }
before do
- subject.project.team << [subject.author, :developer]
+ subject.project.add_developer(subject.author)
allow(subject).to receive(:commits).and_return([commit0, commit1, commit2])
end
@@ -509,7 +556,7 @@ describe MergeRequest do
let(:commit) { double('commit', safe_message: "Fixes #{closing_issue.to_reference}") }
it 'detects issues mentioned in description but not closed' do
- subject.project.team << [subject.author, :developer]
+ subject.project.add_developer(subject.author)
subject.description = "Is related to #{mentioned_issue.to_reference} and #{closing_issue.to_reference}"
allow(subject).to receive(:commits).and_return([commit])
@@ -521,7 +568,7 @@ describe MergeRequest do
context 'when the project has an external issue tracker' do
before do
- subject.project.team << [subject.author, :developer]
+ subject.project.add_developer(subject.author)
commit = double(:commit, safe_message: 'Fixes TEST-3')
create(:jira_service, project: subject.project)
@@ -660,7 +707,7 @@ describe MergeRequest do
it 'includes its closed issues in the body' do
issue = create(:issue, project: subject.project)
- subject.project.team << [subject.author, :developer]
+ subject.project.add_developer(subject.author)
subject.description = "This issue Closes #{issue.to_reference}"
allow(subject.project).to receive(:default_branch)
@@ -1016,6 +1063,103 @@ describe MergeRequest do
end
end
+ describe '#can_be_reverted?' do
+ context 'when there is no merge_commit for the MR' do
+ before do
+ subject.metrics.update!(merged_at: Time.now.utc)
+ end
+
+ it 'returns false' do
+ expect(subject.can_be_reverted?(nil)).to be_falsey
+ end
+ end
+
+ context 'when the MR has been merged' do
+ before do
+ MergeRequests::MergeService
+ .new(subject.target_project, subject.author)
+ .execute(subject)
+ end
+
+ context 'when there is no revert commit' do
+ it 'returns true' do
+ expect(subject.can_be_reverted?(nil)).to be_truthy
+ end
+ end
+
+ context 'when there is no merged_at for the MR' do
+ before do
+ subject.metrics.update!(merged_at: nil)
+ end
+
+ it 'returns true' do
+ expect(subject.can_be_reverted?(nil)).to be_truthy
+ end
+ end
+
+ context 'when there is a revert commit' do
+ let(:current_user) { subject.author }
+ let(:branch) { subject.target_branch }
+ let(:project) { subject.target_project }
+
+ let(:revert_commit_id) do
+ params = {
+ commit: subject.merge_commit,
+ branch_name: branch,
+ start_branch: branch
+ }
+
+ Commits::RevertService.new(project, current_user, params).execute[:result]
+ end
+
+ before do
+ project.add_master(current_user)
+
+ ProcessCommitWorker.new.perform(project.id,
+ current_user.id,
+ project.commit(revert_commit_id).to_hash,
+ project.default_branch == branch)
+ end
+
+ context 'when the revert commit is mentioned in a note after the MR was merged' do
+ it 'returns false' do
+ expect(subject.can_be_reverted?(current_user)).to be_falsey
+ end
+ end
+
+ context 'when there is no merged_at for the MR' do
+ before do
+ subject.metrics.update!(merged_at: nil)
+ end
+
+ it 'returns false' do
+ expect(subject.can_be_reverted?(current_user)).to be_falsey
+ end
+ end
+
+ context 'when the revert commit is mentioned in a note just before the MR was merged' do
+ before do
+ subject.notes.last.update!(created_at: subject.metrics.merged_at - 30.seconds)
+ end
+
+ it 'returns false' do
+ expect(subject.can_be_reverted?(current_user)).to be_falsey
+ end
+ end
+
+ context 'when the revert commit is mentioned in a note long before the MR was merged' do
+ before do
+ subject.notes.last.update!(created_at: subject.metrics.merged_at - 2.minutes)
+ end
+
+ it 'returns true' do
+ expect(subject.can_be_reverted?(current_user)).to be_truthy
+ end
+ end
+ end
+ end
+ end
+
describe '#participants' do
let(:project) { create(:project, :public) }
@@ -1195,6 +1339,10 @@ describe MergeRequest do
it 'returns false' do
expect(subject.mergeable_state?).to be_falsey
end
+
+ it 'returns true when skipping discussions check' do
+ expect(subject.mergeable_state?(skip_discussions_check: true)).to be(true)
+ end
end
end
end
@@ -1395,8 +1543,8 @@ describe MergeRequest do
expect { subject.reload_diff }.to change { subject.merge_request_diffs.count }.by(1)
end
- it "executs diff cache service" do
- expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject)
+ it "executes diff cache service" do
+ expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject, an_instance_of(MergeRequestDiff))
subject.reload_diff
end
@@ -1688,7 +1836,7 @@ describe MergeRequest do
let(:mr_sha) { merge_request.diff_head_sha }
before do
- project.team << [developer, :developer]
+ project.add_developer(developer)
end
context 'when autocomplete_precheck is set to true' do
@@ -1813,6 +1961,17 @@ describe MergeRequest do
expect(subject.merge_request_diff_for(merge_request_diff3.head_commit_sha)).to eq(merge_request_diff3)
end
end
+
+ it 'runs a single query on the initial call, and none afterwards' do
+ expect { subject.merge_request_diff_for(merge_request_diff1.diff_refs) }
+ .not_to exceed_query_limit(1)
+
+ expect { subject.merge_request_diff_for(merge_request_diff2.diff_refs) }
+ .not_to exceed_query_limit(0)
+
+ expect { subject.merge_request_diff_for(merge_request_diff3.head_commit_sha) }
+ .not_to exceed_query_limit(0)
+ end
end
describe '#version_params_for' do
@@ -1884,4 +2043,134 @@ describe MergeRequest do
end
end
end
+
+ describe '#should_be_rebased?' do
+ let(:project) { create(:project, :repository) }
+
+ it 'returns false for the same source and target branches' do
+ merge_request = create(:merge_request, source_project: project, target_project: project)
+
+ expect(merge_request.should_be_rebased?).to be_falsey
+ end
+ end
+
+ describe '#rebase_in_progress?' do
+ shared_examples 'checking whether a rebase is in progress' do
+ let(:repo_path) { subject.source_project.repository.path }
+ let(:rebase_path) { File.join(repo_path, "gitlab-worktree", "rebase-#{subject.id}") }
+
+ before do
+ system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} worktree add --detach #{rebase_path} master))
+ end
+
+ it 'returns true when there is a current rebase directory' do
+ expect(subject.rebase_in_progress?).to be_truthy
+ end
+
+ it 'returns false when there is no rebase directory' do
+ FileUtils.rm_rf(rebase_path)
+
+ expect(subject.rebase_in_progress?).to be_falsey
+ end
+
+ it 'returns false when the rebase directory has expired' do
+ time = 20.minutes.ago.to_time
+ File.utime(time, time, rebase_path)
+
+ expect(subject.rebase_in_progress?).to be_falsey
+ end
+
+ it 'returns false when the source project has been removed' do
+ allow(subject).to receive(:source_project).and_return(nil)
+
+ expect(subject.rebase_in_progress?).to be_falsey
+ end
+ end
+
+ context 'when Gitaly rebase_in_progress is enabled' do
+ it_behaves_like 'checking whether a rebase is in progress'
+ end
+
+ context 'when Gitaly rebase_in_progress is enabled', :disable_gitaly do
+ it_behaves_like 'checking whether a rebase is in progress'
+ end
+ end
+
+ describe '#allow_maintainer_to_push' do
+ let(:merge_request) do
+ build(:merge_request, source_branch: 'fixes', allow_maintainer_to_push: true)
+ end
+
+ it 'is false when pushing by a maintainer is not possible' do
+ expect(merge_request).to receive(:maintainer_push_possible?) { false }
+
+ expect(merge_request.allow_maintainer_to_push).to be_falsy
+ end
+
+ it 'is true when pushing by a maintainer is possible' do
+ expect(merge_request).to receive(:maintainer_push_possible?) { true }
+
+ expect(merge_request.allow_maintainer_to_push).to be_truthy
+ end
+ end
+
+ describe '#maintainer_push_possible?' do
+ let(:merge_request) do
+ build(:merge_request, source_branch: 'fixes')
+ end
+
+ before do
+ allow(ProtectedBranch).to receive(:protected?) { false }
+ end
+
+ it 'does not allow maintainer to push if the source project is the same as the target' do
+ merge_request.target_project = merge_request.source_project = create(:project, :public)
+
+ expect(merge_request.maintainer_push_possible?).to be_falsy
+ end
+
+ it 'allows maintainer to push when both source and target are public' do
+ merge_request.target_project = build(:project, :public)
+ merge_request.source_project = build(:project, :public)
+
+ expect(merge_request.maintainer_push_possible?).to be_truthy
+ end
+
+ it 'is not available for protected branches' do
+ merge_request.target_project = build(:project, :public)
+ merge_request.source_project = build(:project, :public)
+
+ expect(ProtectedBranch).to receive(:protected?)
+ .with(merge_request.source_project, 'fixes')
+ .and_return(true)
+
+ expect(merge_request.maintainer_push_possible?).to be_falsy
+ end
+ end
+
+ describe '#can_allow_maintainer_to_push?' do
+ let(:target_project) { create(:project, :public) }
+ let(:source_project) { fork_project(target_project) }
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ source_branch: 'fixes',
+ target_project: target_project)
+ end
+ let(:user) { create(:user) }
+
+ before do
+ allow(merge_request).to receive(:maintainer_push_possible?) { true }
+ end
+
+ it 'is false if the user does not have push access to the source project' do
+ expect(merge_request.can_allow_maintainer_to_push?(user)).to be_falsy
+ end
+
+ it 'is true when the user has push access to the source project' do
+ source_project.add_developer(user)
+
+ expect(merge_request.can_allow_maintainer_to_push?(user)).to be_truthy
+ end
+ end
end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index b7c6286fd83..62e95a622eb 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -15,7 +15,6 @@ describe Namespace do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
- it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(255) }
it { is_expected.to validate_presence_of(:path) }
@@ -169,112 +168,155 @@ describe Namespace do
end
describe '#move_dir', :request_store do
- let(:namespace) { create(:namespace) }
- let!(:project) { create(:project_empty_repo, namespace: namespace) }
+ shared_examples "namespace restrictions" do
+ context "when any project has container images" do
+ let(:container_repository) { create(:container_repository) }
- it "raises error when directory exists" do
- expect { namespace.move_dir }.to raise_error("namespace directory cannot be moved")
- end
+ before do
+ stub_container_registry_config(enabled: true)
+ stub_container_registry_tags(repository: :any, tags: ['tag'])
- it "moves dir if path changed" do
- namespace.update_attributes(path: namespace.full_path + '_new')
+ create(:project, namespace: namespace, container_repositories: [container_repository])
- expect(gitlab_shell.exists?(project.repository_storage_path, "#{namespace.path}/#{project.path}.git")).to be_truthy
- end
+ allow(namespace).to receive(:path_was).and_return(namespace.path)
+ allow(namespace).to receive(:path).and_return('new_path')
+ end
- context "when any project has container images" do
- let(:container_repository) { create(:container_repository) }
+ it 'raises an error about not movable project' do
+ expect { namespace.move_dir }.to raise_error(/Namespace cannot be moved/)
+ end
+ end
+ end
- before do
- stub_container_registry_config(enabled: true)
- stub_container_registry_tags(repository: :any, tags: ['tag'])
+ context 'legacy storage' do
+ let(:namespace) { create(:namespace) }
+ let!(:project) { create(:project_empty_repo, :legacy_storage, namespace: namespace) }
- create(:project, namespace: namespace, container_repositories: [container_repository])
+ it_behaves_like 'namespace restrictions'
- allow(namespace).to receive(:path_was).and_return(namespace.path)
- allow(namespace).to receive(:path).and_return('new_path')
+ it "raises error when directory exists" do
+ expect { namespace.move_dir }.to raise_error("namespace directory cannot be moved")
end
- it 'raises an error about not movable project' do
- expect { namespace.move_dir }.to raise_error(/Namespace cannot be moved/)
+ it "moves dir if path changed" do
+ namespace.update_attributes(path: namespace.full_path + '_new')
+
+ expect(gitlab_shell.exists?(project.repository_storage_path, "#{namespace.path}/#{project.path}.git")).to be_truthy
end
- end
- context 'with subgroups' 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, FileUploader.base_dir) }
- let(:pages_dir) { File.join(TestEnv.pages_path) }
+ context 'with subgroups', :nested_groups do
+ let(:parent) { create(:group, name: 'parent', path: 'parent') }
+ let(:new_parent) { create(:group, name: 'new_parent', path: 'new_parent') }
+ let(:child) { create(:group, name: 'child', path: 'child', parent: parent) }
+ let!(:project) { create(:project_empty_repo, :legacy_storage, path: 'the-project', namespace: child, skip_disk_validation: true) }
+ let(:uploads_dir) { FileUploader.root }
+ let(:pages_dir) { File.join(TestEnv.pages_path) }
+
+ def expect_project_directories_at(namespace_path)
+ expected_repository_path = File.join(TestEnv.repos_path, namespace_path, 'the-project.git')
+ expected_upload_path = File.join(uploads_dir, namespace_path, 'the-project')
+ expected_pages_path = File.join(pages_dir, namespace_path, 'the-project')
+
+ expect(File.directory?(expected_repository_path)).to be_truthy
+ expect(File.directory?(expected_upload_path)).to be_truthy
+ expect(File.directory?(expected_pages_path)).to be_truthy
+ end
- before do
- FileUtils.mkdir_p(File.join(uploads_dir, 'parent', 'child', 'the-project'))
- FileUtils.mkdir_p(File.join(pages_dir, 'parent', 'child', 'the-project'))
- end
+ before do
+ FileUtils.mkdir_p(File.join(TestEnv.repos_path, "#{project.full_path}.git"))
+ FileUtils.mkdir_p(File.join(uploads_dir, project.full_path))
+ FileUtils.mkdir_p(File.join(pages_dir, project.full_path))
+ end
+
+ context 'renaming child' do
+ it 'correctly moves the repository, uploads and pages' do
+ child.update!(path: 'renamed')
- context 'renaming child' do
- it 'correctly moves the repository, uploads and pages' do
- expected_repository_path = File.join(TestEnv.repos_path, 'parent', 'renamed', 'the-project.git')
- expected_upload_path = File.join(uploads_dir, 'parent', 'renamed', 'the-project')
- expected_pages_path = File.join(pages_dir, 'parent', 'renamed', 'the-project')
+ expect_project_directories_at('parent/renamed')
+ end
+ end
- child.update_attributes!(path: 'renamed')
+ context 'renaming parent' do
+ it 'correctly moves the repository, uploads and pages' do
+ parent.update!(path: 'renamed')
- expect(File.directory?(expected_repository_path)).to be(true)
- expect(File.directory?(expected_upload_path)).to be(true)
- expect(File.directory?(expected_pages_path)).to be(true)
+ expect_project_directories_at('renamed/child')
+ end
end
- end
- context 'renaming parent' do
- it 'correctly moves the repository, uploads and pages' do
- expected_repository_path = File.join(TestEnv.repos_path, 'renamed', 'child', 'the-project.git')
- expected_upload_path = File.join(uploads_dir, 'renamed', 'child', 'the-project')
- expected_pages_path = File.join(pages_dir, 'renamed', 'child', 'the-project')
+ context 'moving from one parent to another' do
+ it 'correctly moves the repository, uploads and pages' do
+ child.update!(parent: new_parent)
+
+ expect_project_directories_at('new_parent/child')
+ end
+ end
+
+ context 'moving from having a parent to root' do
+ it 'correctly moves the repository, uploads and pages' do
+ child.update!(parent: nil)
+
+ expect_project_directories_at('child')
+ end
+ end
- parent.update_attributes!(path: 'renamed')
+ context 'moving from root to having a parent' do
+ it 'correctly moves the repository, uploads and pages' do
+ parent.update!(parent: new_parent)
- expect(File.directory?(expected_repository_path)).to be(true)
- expect(File.directory?(expected_upload_path)).to be(true)
- expect(File.directory?(expected_pages_path)).to be(true)
+ expect_project_directories_at('new_parent/parent/child')
+ end
end
end
end
- end
- 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) }
- let(:deleted_path) { namespace.full_path.gsub(namespace.path, "#{namespace.full_path}+#{namespace.id}+deleted") }
- let(:deleted_path_in_dir) { File.join(repository_storage_path, deleted_path) }
+ context 'hashed storage' do
+ let(:namespace) { create(:namespace) }
+ let!(:project) { create(:project_empty_repo, namespace: namespace) }
- it 'renames its dirs when deleted' do
- allow(GitlabShellWorker).to receive(:perform_in)
+ it_behaves_like 'namespace restrictions'
- namespace.destroy
+ it "repository directory remains unchanged if path changed" do
+ before_disk_path = project.disk_path
+ namespace.update_attributes(path: namespace.full_path + '_new')
- expect(File.exist?(deleted_path_in_dir)).to be(true)
+ expect(before_disk_path).to eq(project.disk_path)
+ expect(gitlab_shell.exists?(project.repository_storage_path, "#{project.disk_path}.git")).to be_truthy
+ end
end
- it 'schedules the namespace for deletion' do
- expect(GitlabShellWorker).to receive(:perform_in).with(5.minutes, :rm_namespace, repository_storage_path, deleted_path)
+ it 'updates project full path in .git/config for each project inside namespace' do
+ parent = create(:group, name: 'mygroup', path: 'mygroup')
+ subgroup = create(:group, name: 'mysubgroup', path: 'mysubgroup', parent: parent)
+ project_in_parent_group = create(:project, :legacy_storage, :repository, namespace: parent, name: 'foo1')
+ hashed_project_in_subgroup = create(:project, :repository, namespace: subgroup, name: 'foo2')
+ legacy_project_in_subgroup = create(:project, :legacy_storage, :repository, namespace: subgroup, name: 'foo3')
+
+ parent.update(path: 'mygroup_new')
+
+ expect(project_rugged(project_in_parent_group).config['gitlab.fullpath']).to eq "mygroup_new/#{project_in_parent_group.path}"
+ expect(project_rugged(hashed_project_in_subgroup).config['gitlab.fullpath']).to eq "mygroup_new/mysubgroup/#{hashed_project_in_subgroup.path}"
+ expect(project_rugged(legacy_project_in_subgroup).config['gitlab.fullpath']).to eq "mygroup_new/mysubgroup/#{legacy_project_in_subgroup.path}"
+ end
- namespace.destroy
+ def project_rugged(project)
+ project.repository.rugged
end
+ end
+
+ describe '#rm_dir', 'callback' do
+ let(:repository_storage_path) { Gitlab.config.repositories.storages.default.legacy_disk_path }
+ let(:path_in_dir) { File.join(repository_storage_path, namespace.full_path) }
+ let(:deleted_path) { namespace.full_path.gsub(namespace.path, "#{namespace.full_path}+#{namespace.id}+deleted") }
+ let(:deleted_path_in_dir) { File.join(repository_storage_path, deleted_path) }
- context 'in sub-groups' do
- 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") }
- let(:deleted_path_in_dir) { File.join(repository_storage_path, deleted_path) }
+ context 'legacy storage' do
+ let!(:project) { create(:project_empty_repo, :legacy_storage, namespace: namespace) }
it 'renames its dirs when deleted' do
allow(GitlabShellWorker).to receive(:perform_in)
- child.destroy
+ namespace.destroy
expect(File.exist?(deleted_path_in_dir)).to be(true)
end
@@ -282,14 +324,57 @@ describe Namespace do
it 'schedules the namespace for deletion' do
expect(GitlabShellWorker).to receive(:perform_in).with(5.minutes, :rm_namespace, repository_storage_path, deleted_path)
- child.destroy
+ namespace.destroy
+ end
+
+ context 'in sub-groups' do
+ let(:parent) { create(:group, path: 'parent') }
+ let(:child) { create(:group, parent: parent, path: 'child') }
+ let!(:project) { create(:project_empty_repo, :legacy_storage, namespace: child) }
+ let(:path_in_dir) { File.join(repository_storage_path, 'parent', 'child') }
+ let(:deleted_path) { File.join('parent', "child+#{child.id}+deleted") }
+ let(:deleted_path_in_dir) { File.join(repository_storage_path, deleted_path) }
+
+ it 'renames its dirs when deleted' do
+ allow(GitlabShellWorker).to receive(:perform_in)
+
+ child.destroy
+
+ expect(File.exist?(deleted_path_in_dir)).to be(true)
+ end
+
+ it 'schedules the namespace for deletion' do
+ expect(GitlabShellWorker).to receive(:perform_in).with(5.minutes, :rm_namespace, repository_storage_path, deleted_path)
+
+ child.destroy
+ end
+ end
+
+ it 'removes the exports folder' do
+ expect(namespace).to receive(:remove_exports!)
+
+ namespace.destroy
end
end
- it 'removes the exports folder' do
- expect(namespace).to receive(:remove_exports!)
+ context 'hashed storage' do
+ let!(:project) { create(:project_empty_repo, namespace: namespace) }
+
+ it 'has no repositories base directories to remove' do
+ allow(GitlabShellWorker).to receive(:perform_in)
+
+ expect(File.exist?(path_in_dir)).to be(false)
+
+ namespace.destroy
+
+ expect(File.exist?(deleted_path_in_dir)).to be(false)
+ end
+
+ it 'removes the exports folder' do
+ expect(namespace).to receive(:remove_exports!)
- namespace.destroy
+ namespace.destroy
+ end
end
end
@@ -392,17 +477,6 @@ describe Namespace do
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)
@@ -475,7 +549,6 @@ describe Namespace do
end
end
- # Note: Group transfers are not yet implemented
context 'when a group is transferred into a root group' do
context 'when the root group "Share with group lock" is enabled' do
let(:root_group) { create(:group, share_with_group_lock: true) }
@@ -560,32 +633,62 @@ describe Namespace do
end
end
- describe "#allowed_path_by_redirects" do
- let(:namespace1) { create(:namespace, path: 'foo') }
+ describe '#remove_exports' do
+ let(:legacy_project) { create(:project, :with_export, :legacy_storage, namespace: namespace) }
+ let(:hashed_project) { create(:project, :with_export, namespace: namespace) }
+ let(:export_path) { Dir.mktmpdir('namespace_remove_exports_spec') }
+ let(:legacy_export) { legacy_project.export_project_path }
+ let(:hashed_export) { hashed_project.export_project_path }
+
+ it 'removes exports for legacy and hashed projects' do
+ allow(Gitlab::ImportExport).to receive(:storage_path) { export_path }
+
+ expect(File.exist?(legacy_export)).to be_truthy
+ expect(File.exist?(hashed_export)).to be_truthy
- context "when the path has been taken before" do
- before do
- namespace1.path = 'bar'
- namespace1.save!
+ namespace.remove_exports!
+
+ expect(File.exist?(legacy_export)).to be_falsy
+ expect(File.exist?(hashed_export)).to be_falsy
+ end
+ end
+
+ describe '#full_path_was' do
+ context 'when the group has no parent' do
+ it 'should return the path was' do
+ group = create(:group, parent: nil)
+ expect(group.full_path_was).to eq(group.path_was)
end
+ end
- it 'should be invalid' do
- namespace2 = build(:group, path: 'foo')
- expect(namespace2).to be_invalid
+ context 'when a parent is assigned to a group with no previous parent' do
+ it 'should return the path was' do
+ group = create(:group, parent: nil)
+
+ parent = create(:group)
+ group.parent = parent
+
+ expect(group.full_path_was).to eq("#{group.path_was}")
end
+ end
+
+ context 'when a parent is removed from the group' do
+ it 'should return the parent full path' do
+ parent = create(:group)
+ group = create(:group, parent: parent)
+ group.parent = nil
- it 'should return an error on path' do
- namespace2 = build(:group, path: 'foo')
- namespace2.valid?
- expect(namespace2.errors.messages[:path].first).to eq('foo has been taken before. Please use another one')
+ expect(group.full_path_was).to eq("#{parent.full_path}/#{group.path}")
end
end
- context "when the path has not been taken before" do
- it 'should be valid' do
- expect(RedirectRoute.count).to eq(0)
- namespace = build(:namespace)
- expect(namespace).to be_valid
+ context 'when changing parents' do
+ it 'should return the previous parent full path' do
+ parent = create(:group)
+ group = create(:group, parent: parent)
+ new_parent = create(:group)
+ group.parent = new_parent
+ expect(group.full_path_was).to eq("#{parent.full_path}/#{group.path}")
end
end
end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index cefbf60b28c..86962cd8d61 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -8,7 +8,7 @@ describe Note do
it { is_expected.to belong_to(:noteable).touch(false) }
it { is_expected.to belong_to(:author).class_name('User') }
- it { is_expected.to have_many(:todos).dependent(:destroy) }
+ it { is_expected.to have_many(:todos) }
end
describe 'modules' do
@@ -17,8 +17,6 @@ describe Note do
it { is_expected.to include_module(Participable) }
it { is_expected.to include_module(Mentionable) }
it { is_expected.to include_module(Awardable) }
-
- it { is_expected.to include_module(Gitlab::CurrentSettings) }
end
describe 'validation' do
@@ -193,9 +191,24 @@ describe Note do
end
end
+ describe "confidential?" do
+ it "delegates to noteable" do
+ issue_note = build(:note, :on_issue)
+ confidential_note = build(:note, noteable: create(:issue, confidential: true))
+
+ expect(issue_note.confidential?).to be_falsy
+ expect(confidential_note.confidential?).to be_truthy
+ end
+
+ it "is falsey when noteable can't be confidential" do
+ commit_note = build(:note_on_commit)
+ expect(commit_note.confidential?).to be_falsy
+ end
+ end
+
describe "cross_reference_not_visible_for?" do
let(:private_user) { create(:user) }
- let(:private_project) { create(:project, namespace: private_user.namespace) { |p| p.team << [private_user, :master] } }
+ let(:private_project) { create(:project, namespace: private_user.namespace) { |p| p.add_master(private_user) } }
let(:private_issue) { create(:issue, project: private_project) }
let(:ext_proj) { create(:project, :public) }
diff --git a/spec/models/notification_recipient_spec.rb b/spec/models/notification_recipient_spec.rb
new file mode 100644
index 00000000000..eda0e1da835
--- /dev/null
+++ b/spec/models/notification_recipient_spec.rb
@@ -0,0 +1,16 @@
+require 'spec_helper'
+
+describe NotificationRecipient do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, namespace: user.namespace) }
+ let(:target) { create(:issue, project: project) }
+
+ subject(:recipient) { described_class.new(user, :watch, target: target, project: project) }
+
+ it 'denies access to a target when cross project access is denied' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ expect(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(false)
+
+ expect(recipient.has_access?).to be_falsy
+ end
+end
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index 7d835511dfb..4b85c5e8720 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -1,6 +1,10 @@
require 'spec_helper'
describe PagesDomain do
+ using RSpec::Parameterized::TableSyntax
+
+ subject(:pages_domain) { described_class.new }
+
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
@@ -14,24 +18,63 @@ describe PagesDomain do
it { is_expected.to validate_uniqueness_of(:domain).case_insensitive }
end
- {
- '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')
+ describe "hostname" do
+ {
+ 'my.domain.com' => true,
+ '123.456.789' => true,
+ '0x12345.com' => true,
+ '0123123' => true,
+ '_foo.com' => false,
+ 'reserved.com' => false,
+ 'a.reserved.com' => false,
+ nil => false
+ }.each do |value, validity|
+ context "domain #{value.inspect} validity" do
+ before do
+ allow(Settings.pages).to receive(:host).and_return('reserved.com')
+ end
+
+ let(:domain) { value }
+
+ it { expect(pages_domain.valid?).to eq(validity) }
+ end
+ end
+ end
+
+ describe "HTTPS-only" do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:domain) { 'my.domain.com' }
+
+ let(:project) do
+ instance_double(Project, pages_https_only?: pages_https_only)
+ end
+
+ let(:pages_domain) do
+ build(:pages_domain, certificate: certificate, key: key).tap do |pd|
+ allow(pd).to receive(:project).and_return(project)
+ pd.valid?
end
+ end
- let(:domain) { value }
+ where(:pages_https_only, :certificate, :key, :errors_on) do
+ attributes = attributes_for(:pages_domain)
+ cert, key = attributes.fetch_values(:certificate, :key)
+
+ true | nil | nil | %i(certificate key)
+ true | cert | nil | %i(key)
+ true | nil | key | %i(certificate key)
+ true | cert | key | []
+ false | nil | nil | []
+ false | cert | nil | %i(key)
+ false | nil | key | %i(key)
+ false | cert | key | []
+ end
- it { expect(pages_domain.valid?).to eq(validity) }
+ with_them do
+ it "is adds the expected errors" do
+ expect(pages_domain.errors.keys).to eq errors_on
+ end
end
end
end
@@ -39,58 +82,86 @@ describe PagesDomain do
describe 'validate certificate' do
subject { domain }
- context 'when only certificate is specified' do
- let(:domain) { build(:pages_domain, :with_certificate) }
+ context 'with matching key' do
+ let(:domain) { build(:pages_domain) }
- it { is_expected.not_to be_valid }
+ it { is_expected.to be_valid }
end
- context 'when only key is specified' do
- let(:domain) { build(:pages_domain, :with_key) }
+ context 'when no certificate is specified' do
+ let(:domain) { build(:pages_domain, :without_certificate) }
it { is_expected.not_to be_valid }
end
- context 'with matching key' do
- let(:domain) { build(:pages_domain, :with_certificate, :with_key) }
+ context 'when no key is specified' do
+ let(:domain) { build(:pages_domain, :without_key) }
- it { is_expected.to be_valid }
+ it { is_expected.not_to be_valid }
end
context 'for not matching key' do
- let(:domain) { build(:pages_domain, :with_missing_chain, :with_key) }
+ let(:domain) { build(:pages_domain, :with_missing_chain) }
it { is_expected.not_to be_valid }
end
end
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:verification_code) }
+ end
+
+ describe '#verification_code' do
+ subject { pages_domain.verification_code }
+
+ it 'is set automatically with 128 bits of SecureRandom data' do
+ expect(SecureRandom).to receive(:hex).with(16) { 'verification code' }
+
+ is_expected.to eq('verification code')
+ end
+ end
+
+ describe '#keyed_verification_code' do
+ subject { pages_domain.keyed_verification_code }
+
+ it { is_expected.to eq("gitlab-pages-verification-code=#{pages_domain.verification_code}") }
+ end
+
+ describe '#verification_domain' do
+ subject { pages_domain.verification_domain }
+
+ it { is_expected.to be_nil }
+
+ it 'is a well-known subdomain if the domain is present' do
+ pages_domain.domain = 'example.com'
+
+ is_expected.to eq('_gitlab-pages-verification-code.example.com')
+ end
+ end
+
describe '#url' do
subject { domain.url }
- context 'without the certificate' do
- let(:domain) { build(:pages_domain) }
+ let(:domain) { build(:pages_domain) }
- it { is_expected.to eq('http://my.domain.com') }
- end
+ it { is_expected.to eq("https://#{domain.domain}") }
- context 'with a certificate' do
- let(:domain) { build(:pages_domain, :with_certificate) }
+ context 'without the certificate' do
+ let(:domain) { build(:pages_domain, :without_certificate) }
- it { is_expected.to eq('https://my.domain.com') }
+ it { is_expected.to eq("http://#{domain.domain}") }
end
end
describe '#has_matching_key?' do
subject { domain.has_matching_key? }
- context 'for matching key' do
- let(:domain) { build(:pages_domain, :with_certificate, :with_key) }
+ let(:domain) { build(:pages_domain) }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
context 'for invalid key' do
- let(:domain) { build(:pages_domain, :with_missing_chain, :with_key) }
+ let(:domain) { build(:pages_domain, :with_missing_chain) }
it { is_expected.to be_falsey }
end
@@ -100,7 +171,7 @@ describe PagesDomain do
subject { domain.has_intermediates? }
context 'for self signed' do
- let(:domain) { build(:pages_domain, :with_certificate) }
+ let(:domain) { build(:pages_domain) }
it { is_expected.to be_truthy }
end
@@ -126,7 +197,7 @@ describe PagesDomain do
subject { domain.expired? }
context 'for valid' do
- let(:domain) { build(:pages_domain, :with_certificate) }
+ let(:domain) { build(:pages_domain) }
it { is_expected.to be_falsey }
end
@@ -139,7 +210,7 @@ describe PagesDomain do
end
describe '#subject' do
- let(:domain) { build(:pages_domain, :with_certificate) }
+ let(:domain) { build(:pages_domain) }
subject { domain.subject }
@@ -147,11 +218,128 @@ describe PagesDomain do
end
describe '#certificate_text' do
- let(:domain) { build(:pages_domain, :with_certificate) }
+ let(:domain) { build(:pages_domain) }
subject { domain.certificate_text }
# We test only existence of output, since the output is long
it { is_expected.not_to be_empty }
end
+
+ describe "#https?" do
+ context "when a certificate is present" do
+ subject { build(:pages_domain) }
+ it { is_expected.to be_https }
+ end
+
+ context "when no certificate is present" do
+ subject { build(:pages_domain, :without_certificate) }
+ it { is_expected.not_to be_https }
+ end
+ end
+
+ describe '#update_daemon' do
+ it 'runs when the domain is created' do
+ domain = build(:pages_domain)
+
+ expect(domain).to receive(:update_daemon)
+
+ domain.save!
+ end
+
+ it 'runs when the domain is destroyed' do
+ domain = create(:pages_domain)
+
+ expect(domain).to receive(:update_daemon)
+
+ domain.destroy!
+ end
+
+ it 'delegates to Projects::UpdatePagesConfigurationService' do
+ service = instance_double('Projects::UpdatePagesConfigurationService')
+ expect(Projects::UpdatePagesConfigurationService).to receive(:new) { service }
+ expect(service).to receive(:execute)
+
+ create(:pages_domain)
+ end
+
+ context 'configuration updates when attributes change' do
+ set(:project1) { create(:project) }
+ set(:project2) { create(:project) }
+ set(:domain) { create(:pages_domain) }
+
+ where(:attribute, :old_value, :new_value, :update_expected) do
+ now = Time.now
+ future = now + 1.day
+
+ :project | nil | :project1 | true
+ :project | :project1 | :project1 | false
+ :project | :project1 | :project2 | true
+ :project | :project1 | nil | true
+
+ # domain can't be set to nil
+ :domain | 'a.com' | 'a.com' | false
+ :domain | 'a.com' | 'b.com' | true
+
+ # verification_code can't be set to nil
+ :verification_code | 'foo' | 'foo' | false
+ :verification_code | 'foo' | 'bar' | false
+
+ :verified_at | nil | now | false
+ :verified_at | now | now | false
+ :verified_at | now | future | false
+ :verified_at | now | nil | false
+
+ :enabled_until | nil | now | true
+ :enabled_until | now | now | false
+ :enabled_until | now | future | false
+ :enabled_until | now | nil | true
+ end
+
+ with_them do
+ it 'runs if a relevant attribute has changed' do
+ a = old_value.is_a?(Symbol) ? send(old_value) : old_value
+ b = new_value.is_a?(Symbol) ? send(new_value) : new_value
+
+ domain.update!(attribute => a)
+
+ if update_expected
+ expect(domain).to receive(:update_daemon)
+ else
+ expect(domain).not_to receive(:update_daemon)
+ end
+
+ domain.update!(attribute => b)
+ end
+ end
+
+ context 'TLS configuration' do
+ set(:domain_without_tls) { create(:pages_domain, :without_certificate, :without_key) }
+ set(:domain) { create(:pages_domain) }
+
+ let(:cert1) { domain.certificate }
+ let(:cert2) { cert1 + ' ' }
+ let(:key1) { domain.key }
+ let(:key2) { key1 + ' ' }
+
+ it 'updates when added' do
+ expect(domain_without_tls).to receive(:update_daemon)
+
+ domain_without_tls.update!(key: key1, certificate: cert1)
+ end
+
+ it 'updates when changed' do
+ expect(domain).to receive(:update_daemon)
+
+ domain.update!(key: key2, certificate: cert2)
+ end
+
+ it 'updates when removed' do
+ expect(domain).to receive(:update_daemon)
+
+ domain.update!(key: nil, certificate: nil)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/project_auto_devops_spec.rb b/spec/models/project_auto_devops_spec.rb
index 12069575866..7545c0797e9 100644
--- a/spec/models/project_auto_devops_spec.rb
+++ b/spec/models/project_auto_devops_spec.rb
@@ -18,20 +18,57 @@ describe ProjectAutoDevops do
context 'when domain is empty' do
let(:auto_devops) { build_stubbed(:project_auto_devops, project: project, domain: '') }
- it { expect(auto_devops).not_to have_domain }
+ context 'when there is an instance domain specified' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return('example.com')
+ end
+
+ it { expect(auto_devops).to have_domain }
+ end
+
+ context 'when there is no instance domain specified' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return(nil)
+ end
+
+ it { expect(auto_devops).not_to have_domain }
+ end
end
end
- describe '#variables' do
+ describe '#predefined_variables' do
let(:auto_devops) { build_stubbed(:project_auto_devops, project: project, domain: domain) }
context 'when domain is defined' do
let(:domain) { 'example.com' }
it 'returns AUTO_DEVOPS_DOMAIN' do
- expect(auto_devops.variables).to include(
- { key: 'AUTO_DEVOPS_DOMAIN', value: 'example.com', public: true })
+ expect(auto_devops.predefined_variables).to include(domain_variable)
end
end
+
+ context 'when domain is not defined' do
+ let(:domain) { nil }
+
+ context 'when there is an instance domain specified' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return('example.com')
+ end
+
+ it { expect(auto_devops.predefined_variables).to include(domain_variable) }
+ end
+
+ context 'when there is no instance domain specified' do
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return(nil)
+ end
+
+ it { expect(auto_devops.predefined_variables).not_to include(domain_variable) }
+ end
+ end
+
+ def domain_variable
+ { key: 'AUTO_DEVOPS_DOMAIN', value: 'example.com', public: true }
+ end
end
end
diff --git a/spec/models/project_deploy_token_spec.rb b/spec/models/project_deploy_token_spec.rb
new file mode 100644
index 00000000000..9e2e40c2e8f
--- /dev/null
+++ b/spec/models/project_deploy_token_spec.rb
@@ -0,0 +1,14 @@
+require 'rails_helper'
+
+RSpec.describe ProjectDeployToken, type: :model do
+ let(:project) { create(:project) }
+ let(:deploy_token) { create(:deploy_token) }
+ subject(:project_deploy_token) { create(:project_deploy_token, project: project, deploy_token: deploy_token) }
+
+ it { is_expected.to belong_to :project }
+ it { is_expected.to belong_to :deploy_token }
+
+ it { is_expected.to validate_presence_of :deploy_token }
+ it { is_expected.to validate_presence_of :project }
+ it { is_expected.to validate_uniqueness_of(:deploy_token_id).scoped_to(:project_id) }
+end
diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb
index de3e86b627f..63c6fbda3f2 100644
--- a/spec/models/project_feature_spec.rb
+++ b/spec/models/project_feature_spec.rb
@@ -37,7 +37,7 @@ describe ProjectFeature do
end
it "returns true when user is a team member" do
- project.team << [user, :developer]
+ project.add_developer(user)
features.each do |feature|
project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE)
diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb
index 41e2ab20d69..1fccf92627a 100644
--- a/spec/models/project_group_link_spec.rb
+++ b/spec/models/project_group_link_spec.rb
@@ -30,7 +30,7 @@ describe ProjectGroupLink do
end
end
- describe "destroying a record", :truncate do
+ describe "destroying a record", :delete do
it "refreshes group users' authorized projects" do
project = create(:project, :private)
group = create(:group)
diff --git a/spec/models/project_services/asana_service_spec.rb b/spec/models/project_services/asana_service_spec.rb
index 04440d890aa..e66109fd98f 100644
--- a/spec/models/project_services/asana_service_spec.rb
+++ b/spec/models/project_services/asana_service_spec.rb
@@ -47,7 +47,7 @@ describe AsanaService do
it 'calls Asana service to create a story' do
data = create_data_for_commits('Message from commit. related to #123456')
- expected_message = "#{data[:user_name]} pushed to branch #{data[:ref]} of #{project.name_with_namespace} ( #{data[:commits][0][:url]} ): #{data[:commits][0][:message]}"
+ expected_message = "#{data[:user_name]} pushed to branch #{data[:ref]} of #{project.full_name} ( #{data[:commits][0][:url]} ): #{data[:commits][0][:message]}"
d1 = double('Asana::Task')
expect(d1).to receive(:add_comment).with(text: expected_message)
diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb
index 23db29cb541..0cd712e2f40 100644
--- a/spec/models/project_services/hipchat_service_spec.rb
+++ b/spec/models/project_services/hipchat_service_spec.rb
@@ -29,7 +29,7 @@ describe HipchatService do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:api_url) { 'https://hipchat.example.com/v2/room/123456/notification?auth_token=verySecret' }
- let(:project_name) { project.name_with_namespace.gsub(/\s/, '') }
+ let(:project_name) { project.full_name.gsub(/\s/, '') }
let(:token) { 'verySecret' }
let(:server_url) { 'https://hipchat.example.com'}
let(:push_sample_data) do
@@ -253,6 +253,21 @@ describe HipchatService do
"<b>#{title}</b>" \
"<pre>issue <strong>note</strong></pre>")
end
+
+ context 'with confidential issue' do
+ before do
+ issue.update!(confidential: true)
+ end
+
+ it 'calls Hipchat API with issue comment' do
+ data = Gitlab::DataBuilder::Note.build(issue_note, user)
+ hipchat.execute(data)
+
+ message = hipchat.send(:create_message, data)
+
+ expect(message).to include("<pre>issue <strong>note</strong></pre>")
+ end
+ end
end
context 'when snippet comment event triggered' do
@@ -303,7 +318,7 @@ describe HipchatService do
message = hipchat.__send__(:create_pipeline_message, data)
project_url = project.web_url
- project_name = project.name_with_namespace.gsub(/\s/, '')
+ project_name = project.full_name.gsub(/\s/, '')
pipeline_attributes = data[:object_attributes]
ref = pipeline_attributes[:ref]
ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index c9b3c6cf602..54ef0be67ff 100644
--- a/spec/models/project_services/jira_service_spec.rb
+++ b/spec/models/project_services/jira_service_spec.rb
@@ -3,6 +3,29 @@ require 'spec_helper'
describe JiraService do
include Gitlab::Routing
+ describe '#options' do
+ let(:service) do
+ described_class.new(
+ project: build_stubbed(:project),
+ active: true,
+ username: 'username',
+ password: 'test',
+ jira_issue_transition_id: 24,
+ url: 'http://jira.test.com/path/'
+ )
+ end
+
+ it 'sets the URL properly' do
+ # jira-ruby gem parses the URI and handles trailing slashes
+ # fine: https://github.com/sumoheavy/jira-ruby/blob/v1.4.1/lib/jira/http_client.rb#L59
+ expect(service.options[:site]).to eq('http://jira.test.com/')
+ end
+
+ it 'leaves out trailing slashes in context' do
+ expect(service.options[:context_path]).to eq('/path')
+ end
+ end
+
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -143,7 +166,6 @@ describe JiraService do
# Creates comment
expect(WebMock).to have_requested(:post, @comment_url)
-
# Creates Remote Link in JIRA issue fields
expect(WebMock).to have_requested(:post, @remote_link_url).with(
body: hash_including(
@@ -151,7 +173,7 @@ describe JiraService do
object: {
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" },
+ icon: { title: "GitLab", url16x16: "http://localhost/favicon.ico" },
status: { resolved: true }
}
)
@@ -182,7 +204,7 @@ describe JiraService do
@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.full_path}\/commit\/#{merge_request.diff_head_sha}/
+ body: %r{#{custom_base_url}/#{project.full_path}/commit/#{merge_request.diff_head_sha}}
).once
end
@@ -197,7 +219,7 @@ describe JiraService do
@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.full_path}\/commit\/#{merge_request.diff_head_sha}/
+ body: %r{#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/#{merge_request.diff_head_sha}}
).once
end
diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb
index f037ee77a94..3be023a48c1 100644
--- a/spec/models/project_services/kubernetes_service_spec.rb
+++ b/spec/models/project_services/kubernetes_service_spec.rb
@@ -52,12 +52,75 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do
context 'when service is inactive' do
before do
+ subject.project = project
subject.active = false
end
it { is_expected.not_to validate_presence_of(:api_url) }
it { is_expected.not_to validate_presence_of(:token) }
end
+
+ context 'with a deprecated service' do
+ let(:kubernetes_service) { create(:kubernetes_service) }
+
+ before do
+ kubernetes_service.update_attribute(:active, false)
+ kubernetes_service.properties[:namespace] = "foo"
+ end
+
+ it 'should not update attributes' do
+ expect(kubernetes_service.save).to be_falsy
+ end
+
+ it 'should include an error with a deprecation message' do
+ kubernetes_service.valid?
+ expect(kubernetes_service.errors[:base].first).to match(/Kubernetes service integration has been deprecated/)
+ end
+ end
+
+ context 'with a non-deprecated service' do
+ let(:kubernetes_service) { create(:kubernetes_service) }
+
+ it 'should update attributes' do
+ kubernetes_service.properties[:namespace] = 'foo'
+ expect(kubernetes_service.save).to be_truthy
+ end
+ end
+
+ context 'with an active and deprecated service' do
+ let(:kubernetes_service) { create(:kubernetes_service) }
+
+ before do
+ kubernetes_service.active = false
+ kubernetes_service.properties[:namespace] = 'foo'
+ kubernetes_service.save
+ end
+
+ it 'should deactive the service' do
+ expect(kubernetes_service.active?).to be_falsy
+ end
+
+ it 'should not include a deprecation message as error' do
+ expect(kubernetes_service.errors.messages.count).to eq(0)
+ end
+
+ it 'should update attributes' do
+ expect(kubernetes_service.properties[:namespace]).to eq("foo")
+ end
+ end
+
+ context 'with a template service' do
+ let(:kubernetes_service) { create(:kubernetes_service, template: true, active: false) }
+
+ before do
+ kubernetes_service.properties[:namespace] = 'foo'
+ end
+
+ it 'should update attributes' do
+ expect(kubernetes_service.save).to be_truthy
+ expect(kubernetes_service.properties[:namespace]).to eq('foo')
+ end
+ end
end
describe '#initialize_properties' do
@@ -307,7 +370,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do
stub_kubeclient_pods(status: 500)
end
- it { expect { subject }.to raise_error(KubeException) }
+ it { expect { subject }.to raise_error(Kubeclient::HttpError) }
end
context 'when kubernetes responds with 404s' do
@@ -318,4 +381,42 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do
it { is_expected.to eq(pods: []) }
end
end
+
+ describe "#deprecated?" do
+ let(:kubernetes_service) { create(:kubernetes_service) }
+
+ context 'with an active kubernetes service' do
+ it 'should return false' do
+ expect(kubernetes_service.deprecated?).to be_falsy
+ end
+ end
+
+ context 'with a inactive kubernetes service' do
+ it 'should return true' do
+ kubernetes_service.update_attribute(:active, false)
+ expect(kubernetes_service.deprecated?).to be_truthy
+ end
+ end
+ end
+
+ describe "#deprecation_message" do
+ let(:kubernetes_service) { create(:kubernetes_service) }
+
+ it 'should indicate the service is deprecated' do
+ expect(kubernetes_service.deprecation_message).to match(/Kubernetes service integration has been deprecated/)
+ end
+
+ context 'if the services is active' do
+ it 'should return a message' do
+ expect(kubernetes_service.deprecation_message).to match(/Your Kubernetes cluster information on this page is still editable/)
+ end
+ end
+
+ context 'if the service is not active' do
+ it 'should return a message' do
+ kubernetes_service.update_attribute(:active, false)
+ expect(kubernetes_service.deprecation_message).to match(/Fields on this page are now uneditable/)
+ end
+ end
+ end
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 522cf15f3ba..05d33cd3874 100644
--- a/spec/models/project_services/mattermost_slash_commands_service_spec.rb
+++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb
@@ -9,10 +9,11 @@ describe MattermostSlashCommandsService do
let(:user) { create(:user) }
before do
- Mattermost::Session.base_uri("http://mattermost.example.com")
+ session = Mattermost::Session.new(nil)
+ session.base_uri = 'http://mattermost.example.com'
allow_any_instance_of(Mattermost::Client).to receive(:with_session)
- .and_yield(Mattermost::Session.new(nil))
+ .and_yield(session)
end
describe '#configure' do
@@ -31,10 +32,10 @@ describe MattermostSlashCommandsService do
url: 'http://trigger.url',
icon_url: 'http://icon.url/icon.png',
auto_complete: true,
- auto_complete_desc: "Perform common operations on: #{project.name_with_namespace}",
+ auto_complete_desc: "Perform common operations on: #{project.full_name}",
auto_complete_hint: '[help]',
- description: "Perform common operations on: #{project.name_with_namespace}",
- display_name: "GitLab / #{project.name_with_namespace}",
+ description: "Perform common operations on: #{project.full_name}",
+ display_name: "GitLab / #{project.full_name}",
method: 'P',
username: 'GitLab'
}.to_json)
diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb
index 6a5d0decfec..733086e258f 100644
--- a/spec/models/project_services/microsoft_teams_service_spec.rb
+++ b/spec/models/project_services/microsoft_teams_service_spec.rb
@@ -92,6 +92,10 @@ describe MicrosoftTeamsService do
service.hook_data(merge_request, 'open')
end
+ before do
+ project.add_developer(user)
+ end
+
it "calls Microsoft Teams API" do
chat_service.execute(merge_sample_data)
diff --git a/spec/models/project_services/pipelines_email_service_spec.rb b/spec/models/project_services/pipelines_email_service_spec.rb
index be07ca2d945..75ae2207910 100644
--- a/spec/models/project_services/pipelines_email_service_spec.rb
+++ b/spec/models/project_services/pipelines_email_service_spec.rb
@@ -37,7 +37,7 @@ describe PipelinesEmailService, :mailer do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'builds test data' do
diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb
index bf39e8d7a39..7afb1b4a8e3 100644
--- a/spec/models/project_services/prometheus_service_spec.rb
+++ b/spec/models/project_services/prometheus_service_spec.rb
@@ -6,24 +6,23 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do
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 }
end
describe 'Validations' do
- context 'when service is active' do
+ context 'when manual_configuration is enabled' do
before do
- subject.active = true
+ subject.manual_configuration = true
end
it { is_expected.to validate_presence_of(:api_url) }
end
- context 'when service is inactive' do
+ context 'when manual configuration is disabled' do
before do
- subject.active = false
+ subject.manual_configuration = false
end
it { is_expected.not_to validate_presence_of(:api_url) }
@@ -31,12 +30,17 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do
end
describe '#test' do
+ before do
+ service.manual_configuration = true
+ end
+
let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) }
context 'success' do
it 'reads the discovery endpoint' do
+ expect(service.test[:result]).to eq('Checked API endpoint')
expect(service.test[:success]).to be_truthy
- expect(req_stub).to have_been_requested
+ expect(req_stub).to have_been_requested.twice
end
end
@@ -50,85 +54,131 @@ describe PrometheusService, :use_clean_rails_memory_store_caching do
end
end
- describe '#environment_metrics' do
- let(:environment) { build_stubbed(:environment, slug: 'env-slug') }
+ describe '#prometheus_client' do
+ context 'manual configuration is enabled' do
+ let(:api_url) { 'http://some_url' }
- around do |example|
- Timecop.freeze { example.run }
+ before do
+ subject.active = true
+ subject.manual_configuration = true
+ subject.api_url = api_url
+ end
+
+ it 'returns rest client from api_url' do
+ expect(subject.prometheus_client.url).to eq(api_url)
+ end
end
- context 'with valid data' do
- subject { service.environment_metrics(environment) }
+ context 'manual configuration is disabled' do
+ let(:api_url) { 'http://some_url' }
before do
- stub_reactive_cache(service, prometheus_data, environment_query, environment.id)
+ subject.manual_configuration = false
+ subject.api_url = api_url
end
- it 'returns reactive data' do
- is_expected.to eq(prometheus_metrics_data)
+ it 'no client provided' do
+ expect(subject.prometheus_client).to be_nil
end
end
end
- describe '#deployment_metrics' do
- let(:deployment) { build_stubbed(:deployment) }
- let(:deployment_query) { Gitlab::Prometheus::Queries::DeploymentQuery }
+ describe '#prometheus_installed?' do
+ context 'clusters with installed prometheus' do
+ let!(:cluster) { create(:cluster, projects: [project]) }
+ let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
- around do |example|
- Timecop.freeze { example.run }
+ it 'returns true' do
+ expect(service.prometheus_installed?).to be(true)
+ end
end
- context 'with valid data' do
- subject { service.deployment_metrics(deployment) }
- let(:fake_deployment_time) { 10 }
+ context 'clusters without prometheus installed' do
+ let(:cluster) { create(:cluster, projects: [project]) }
+ let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) }
- before do
- stub_reactive_cache(service, prometheus_data, deployment_query, deployment.id)
+ it 'returns false' do
+ expect(service.prometheus_installed?).to be(false)
end
+ end
- it 'returns reactive data' do
- expect(deployment).to receive(:created_at).and_return(fake_deployment_time)
+ context 'clusters without prometheus' do
+ let(:cluster) { create(:cluster, projects: [project]) }
- expect(subject).to eq(prometheus_metrics_data.merge(deployment_time: fake_deployment_time))
+ it 'returns false' do
+ expect(service.prometheus_installed?).to be(false)
end
end
- end
-
- describe '#calculate_reactive_cache' do
- let(:environment) { create(:environment, slug: 'env-slug') }
- around do |example|
- Timecop.freeze { example.run }
+ context 'no clusters' do
+ it 'returns false' do
+ expect(service.prometheus_installed?).to be(false)
+ end
end
+ end
- subject do
- service.calculate_reactive_cache(environment_query.to_s, environment.id)
- end
+ describe '#synchronize_service_state before_save callback' do
+ context 'no clusters with prometheus are installed' do
+ context 'when service is inactive' do
+ before do
+ service.active = false
+ end
- context 'when service is inactive' do
- before do
- service.active = false
+ it 'activates service when manual_configuration is enabled' do
+ expect { service.update!(manual_configuration: true) }.to change { service.active }.from(false).to(true)
+ end
+
+ it 'keeps service inactive when manual_configuration is disabled' do
+ expect { service.update!(manual_configuration: false) }.not_to change { service.active }.from(false)
+ end
end
- it { is_expected.to be_nil }
+ context 'when service is active' do
+ before do
+ service.active = true
+ end
+
+ it 'keeps the service active when manual_configuration is enabled' do
+ expect { service.update!(manual_configuration: true) }.not_to change { service.active }.from(true)
+ end
+
+ it 'inactivates the service when manual_configuration is disabled' do
+ expect { service.update!(manual_configuration: false) }.to change { service.active }.from(true).to(false)
+ end
+ end
end
- context 'when Prometheus responds with valid data' do
+ context 'with prometheus installed in the cluster' do
before do
- stub_all_prometheus_requests(environment.slug)
+ allow(service).to receive(:prometheus_installed?).and_return(true)
end
- it { expect(subject.to_json).to eq(prometheus_data.to_json) }
- it { expect(subject.to_json).to eq(prometheus_data.to_json) }
- end
+ context 'when service is inactive' do
+ before do
+ service.active = false
+ end
+
+ it 'activates service when manual_configuration is enabled' do
+ expect { service.update!(manual_configuration: true) }.to change { service.active }.from(false).to(true)
+ end
+
+ it 'activates service when manual_configuration is disabled' do
+ expect { service.update!(manual_configuration: false) }.to change { service.active }.from(false).to(true)
+ end
+ end
- [404, 500].each do |status|
- context "when Prometheus responds with #{status}" do
+ context 'when service is active' do
before do
- stub_all_prometheus_requests(environment.slug, status: status, body: "QUERY FAILED!")
+ service.active = true
end
- it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) }
+ it 'keeps service active when manual_configuration is enabled' do
+ expect { service.update!(manual_configuration: true) }.not_to change { service.active }.from(true)
+ end
+
+ it 'keeps service active when manual_configuration is disabled' do
+ expect { service.update!(manual_configuration: false) }.not_to change { service.active }.from(true)
+ end
end
end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index cbeac2f05d3..2675c2f52c1 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe Project do
+ include ProjectForksHelper
+
describe 'associations' do
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:namespace) }
@@ -80,6 +82,10 @@ describe Project do
it { is_expected.to have_many(:members_and_requesters) }
it { is_expected.to have_many(:clusters) }
it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') }
+ it { is_expected.to have_many(:project_badges).class_name('ProjectBadge') }
+ it { is_expected.to have_many(:lfs_file_locks) }
+ it { is_expected.to have_many(:project_deploy_tokens) }
+ it { is_expected.to have_many(:deploy_tokens).through(:project_deploy_tokens) }
context 'after initialized' do
it "has a project_feature" do
@@ -93,7 +99,7 @@ describe Project do
let(:developer) { create(:user) }
before do
project.request_access(requester)
- project.team << [developer, :developer]
+ project.add_developer(developer)
end
it_behaves_like 'members and requesters associations' do
@@ -117,7 +123,6 @@ describe Project do
it { is_expected.to include_module(Gitlab::ConfigHelper) }
it { is_expected.to include_module(Gitlab::ShellAdapter) }
it { is_expected.to include_module(Gitlab::VisibilityLevel) }
- it { is_expected.to include_module(Gitlab::CurrentSettings) }
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Sortable) }
end
@@ -130,7 +135,6 @@ describe Project do
it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_presence_of(:path) }
- it { is_expected.to validate_uniqueness_of(:path).scoped_to(:namespace_id) }
it { is_expected.to validate_length_of(:path).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(2000) }
@@ -222,14 +226,14 @@ describe Project do
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')
+ expect(project2.errors[:import_url].first).to include('Requests to localhost are not allowed')
end
it "does not allow blocked import_url port" do
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')
+ expect(project2.errors[:import_url].first).to include('Only allowed ports are 22, 80, 443')
end
describe 'project pending deletion' do
@@ -418,14 +422,21 @@ describe Project do
end
describe '#merge_method' do
- it 'returns "ff" merge_method when ff is enabled' do
- project = build(:project, merge_requests_ff_only_enabled: true)
- expect(project.merge_method).to be :ff
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ff, :rebase, :method) do
+ true | true | :ff
+ true | false | :ff
+ false | true | :rebase_merge
+ false | false | :merge
end
- it 'returns "merge" merge_method when ff is disabled' do
- project = build(:project, merge_requests_ff_only_enabled: false)
- expect(project.merge_method).to be :merge
+ with_them do
+ let(:project) { build(:project, merge_requests_rebase_enabled: rebase, merge_requests_ff_only_enabled: ff) }
+
+ subject { project.merge_method }
+
+ it { is_expected.to eq(method) }
end
end
@@ -511,6 +522,20 @@ describe Project do
it 'returns the project\'s last update date if it has no events' do
expect(project.last_activity_date).to eq(project.updated_at)
end
+
+ it 'returns the most recent timestamp' do
+ project.update_attributes(updated_at: nil,
+ last_activity_at: timestamp,
+ last_repository_updated_at: timestamp - 1.hour)
+
+ expect(project.last_activity_date).to eq(timestamp)
+
+ project.update_attributes(updated_at: timestamp,
+ last_activity_at: timestamp - 1.hour,
+ last_repository_updated_at: nil)
+
+ expect(project.last_activity_date).to eq(timestamp)
+ end
end
end
@@ -520,7 +545,7 @@ describe Project do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'with default issues tracker' do
@@ -899,7 +924,7 @@ describe Project do
it 'is false if avatar is html page' do
project.update_attribute(:avatar, 'uploads/avatar.html')
- expect(project.avatar_type).to eq(['only images allowed'])
+ expect(project.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff'])
end
end
@@ -1078,8 +1103,8 @@ describe Project do
before do
storages = {
- 'default' => { 'path' => 'tmp/tests/repositories' },
- 'picked' => { 'path' => 'tmp/tests/repositories' }
+ 'default' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/repositories'),
+ 'picked' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/repositories')
}
allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
end
@@ -1242,6 +1267,34 @@ describe Project do
end
end
+ describe '#pages_group_url' do
+ let(:group) { create :group, name: group_name }
+ let(:project) { create :project, namespace: group, name: project_name }
+ let(:domain) { 'Example.com' }
+ let(:port) { 1234 }
+
+ subject { project.pages_group_url }
+
+ before do
+ allow(Settings.pages).to receive(:host).and_return(domain)
+ allow(Gitlab.config.pages).to receive(:url).and_return("http://example.com:#{port}")
+ end
+
+ context 'group page' do
+ let(:group_name) { 'Group' }
+ let(:project_name) { 'group.example.com' }
+
+ it { is_expected.to eq("http://group.example.com:#{port}") }
+ end
+
+ context 'project page' do
+ let(:group_name) { 'Group' }
+ let(:project_name) { 'Project' }
+
+ it { is_expected.to eq("http://group.example.com:#{port}") }
+ end
+ end
+
describe '.search' do
let(:project) { create(:project, description: 'kitten mittens') }
@@ -1355,7 +1408,7 @@ describe Project do
context 'using a regular repository' do
it 'creates the repository' do
- expect(shell).to receive(:add_repository)
+ expect(shell).to receive(:create_repository)
.with(project.repository_storage, project.disk_path)
.and_return(true)
@@ -1365,7 +1418,7 @@ describe Project do
end
it 'adds an error if the repository could not be created' do
- expect(shell).to receive(:add_repository)
+ expect(shell).to receive(:create_repository)
.with(project.repository_storage, project.disk_path)
.and_return(false)
@@ -1379,7 +1432,7 @@ describe Project do
context 'using a forked repository' do
it 'does nothing' do
expect(project).to receive(:forked?).and_return(true)
- expect(shell).not_to receive(:add_repository)
+ expect(shell).not_to receive(:create_repository)
project.create_repository
end
@@ -1398,7 +1451,7 @@ describe Project do
allow(project).to receive(:repository_exists?)
.and_return(false)
- allow(shell).to receive(:add_repository)
+ allow(shell).to receive(:create_repository)
.with(project.repository_storage_path, project.disk_path)
.and_return(true)
@@ -1422,7 +1475,7 @@ describe Project do
allow(project).to receive(:repository_exists?)
.and_return(false)
- expect(shell).to receive(:add_repository)
+ expect(shell).to receive(:create_repository)
.with(project.repository_storage, project.disk_path)
.and_return(true)
@@ -1435,38 +1488,45 @@ describe Project do
let(:user) { create(:user) }
it 'returns false when default_branch_protection is in full protection and user is developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL)
expect(project.user_can_push_to_empty_repo?(user)).to be_falsey
end
it 'returns false when default_branch_protection only lets devs merge and user is dev' do
- project.team << [user, :developer]
+ project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
expect(project.user_can_push_to_empty_repo?(user)).to be_falsey
end
it 'returns true when default_branch_protection lets devs push and user is developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH)
expect(project.user_can_push_to_empty_repo?(user)).to be_truthy
end
it 'returns true when default_branch_protection is unprotected and user is developer' do
- project.team << [user, :developer]
+ project.add_developer(user)
stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE)
expect(project.user_can_push_to_empty_repo?(user)).to be_truthy
end
it 'returns true when user is master' do
- project.team << [user, :master]
+ project.add_master(user)
expect(project.user_can_push_to_empty_repo?(user)).to be_truthy
end
+
+ it 'returns false when the repo is not empty' do
+ project.add_master(user)
+ expect(project).to receive(:empty_repo?).and_return(false)
+
+ expect(project.user_can_push_to_empty_repo?(user)).to be_falsey
+ end
end
describe '#container_registry_url' do
@@ -1587,7 +1647,7 @@ describe Project do
before do
allow_any_instance_of(Gitlab::Shell).to receive(:import_repository)
- .with(project.repository_storage_path, project.disk_path, project.import_url)
+ .with(project.repository_storage, project.disk_path, project.import_url)
.and_return(true)
expect_any_instance_of(Repository).to receive(:after_import)
@@ -1740,10 +1800,7 @@ describe Project do
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.disk_path).and_return(import_jid)
+ expect(RepositoryForkWorker).to receive(:perform_async).with(project.id).and_return(import_jid)
expect(project.add_import_job).to eq(import_jid)
end
@@ -1864,9 +1921,8 @@ describe Project do
end
it 'creates the new reference with rugged' do
- expect(project.repository.rugged.references).to receive(:create).with('HEAD',
- "refs/heads/#{project.default_branch}",
- force: true)
+ expect(project.repository.raw_repository).to receive(:write_ref).with('HEAD', "refs/heads/#{project.default_branch}", shell: false)
+
project.change_head(project.default_branch)
end
@@ -1945,6 +2001,10 @@ describe Project do
expect(second_fork.fork_source).to eq(project)
end
+
+ it 'returns nil if it is the root of the fork network' do
+ expect(project.fork_source).to be_nil
+ end
end
describe '#lfs_storage_project' do
@@ -1964,6 +2024,22 @@ describe Project do
expect(forked_project.lfs_storage_project).to eq forked_project
end
end
+
+ describe '#all_lfs_objects' do
+ let(:lfs_object) { create(:lfs_object) }
+
+ before do
+ project.lfs_objects << lfs_object
+ end
+
+ it 'returns the lfs object for a project' do
+ expect(project.all_lfs_objects).to contain_exactly(lfs_object)
+ end
+
+ it 'returns the lfs object for a fork' do
+ expect(forked_project.all_lfs_objects).to contain_exactly(lfs_object)
+ end
+ end
end
describe '#pushes_since_gc' do
@@ -2062,7 +2138,7 @@ describe Project do
create(:ci_variable, :protected, value: 'protected', project: project)
end
- subject { project.secret_variables_for(ref: 'ref') }
+ subject { project.reload.secret_variables_for(ref: 'ref') }
before do
stub_application_setting(
@@ -2083,7 +2159,7 @@ describe Project do
context 'when the ref is a protected branch' do
before do
- create(:protected_branch, name: 'ref', project: project)
+ allow(project).to receive(:protected_for?).with('ref').and_return(true)
end
it_behaves_like 'ref is protected'
@@ -2091,7 +2167,7 @@ describe Project do
context 'when the ref is a protected tag' do
before do
- create(:protected_tag, name: 'ref', project: project)
+ allow(project).to receive(:protected_for?).with('ref').and_return(true)
end
it_behaves_like 'ref is protected'
@@ -2116,6 +2192,8 @@ describe Project do
context 'when the ref is a protected branch' do
before do
+ allow(project).to receive(:repository).and_call_original
+ allow(project).to receive_message_chain(:repository, :branch_exists?).and_return(true)
create(:protected_branch, name: 'ref', project: project)
end
@@ -2126,6 +2204,8 @@ describe Project do
context 'when the ref is a protected tag' do
before do
+ allow(project).to receive_message_chain(:repository, :branch_exists?).and_return(false)
+ allow(project).to receive_message_chain(:repository, :tag_exists?).and_return(true)
create(:protected_tag, name: 'ref', project: project)
end
@@ -2479,7 +2559,8 @@ describe Project do
end
it 'is a no-op when there is no namespace' do
- project.update_column(:namespace_id, nil)
+ project.namespace.delete
+ project.reload
expect_any_instance_of(Projects::UpdatePagesConfigurationService).not_to receive(:execute)
expect_any_instance_of(Gitlab::PagesTransfer).not_to receive(:rename_project)
@@ -2494,6 +2575,71 @@ describe Project do
end
end
+ describe '#remove_export' do
+ let(:legacy_project) { create(:project, :legacy_storage, :with_export) }
+ let(:project) { create(:project, :with_export) }
+
+ it 'removes the exports directory for the project' do
+ expect(File.exist?(project.export_path)).to be_truthy
+
+ allow(FileUtils).to receive(:rm_rf).and_call_original
+ expect(FileUtils).to receive(:rm_rf).with(project.export_path).and_call_original
+ project.remove_exports
+
+ expect(File.exist?(project.export_path)).to be_falsy
+ end
+
+ it 'is a no-op on legacy projects when there is no namespace' do
+ export_path = legacy_project.export_path
+
+ legacy_project.namespace.delete
+ legacy_project.reload
+
+ expect(FileUtils).not_to receive(:rm_rf).with(export_path)
+
+ legacy_project.remove_exports
+
+ expect(File.exist?(export_path)).to be_truthy
+ end
+
+ it 'runs on hashed storage projects when there is no namespace' do
+ export_path = project.export_path
+
+ project.namespace.delete
+ legacy_project.reload
+
+ allow(FileUtils).to receive(:rm_rf).and_call_original
+ expect(FileUtils).to receive(:rm_rf).with(export_path).and_call_original
+
+ project.remove_exports
+
+ expect(File.exist?(export_path)).to be_falsy
+ end
+
+ it 'is run when the project is destroyed' do
+ expect(project).to receive(:remove_exports).and_call_original
+
+ project.destroy
+ end
+ end
+
+ describe '#remove_exported_project_file' do
+ let(:project) { create(:project, :with_export) }
+
+ it 'removes the exported project file' do
+ exported_file = project.export_project_path
+
+ expect(File.exist?(exported_file)).to be_truthy
+
+ allow(FileUtils).to receive(:rm_f).and_call_original
+ expect(FileUtils).to receive(:rm_f).with(exported_file).and_call_original
+
+ project.remove_exported_project_file
+
+ expect(File.exist?(exported_file)).to be_falsy
+ end
+ end
+
describe '#forks_count' do
it 'returns the number of forks' do
project = build(:project)
@@ -2505,7 +2651,7 @@ describe Project do
end
context 'legacy storage' do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :legacy_storage) }
let(:gitlab_shell) { Gitlab::Shell.new }
let(:project_storage) { project.send(:storage) }
@@ -2626,6 +2772,14 @@ describe Project do
project.rename_repo
end
end
+
+ it 'updates project full path in .git/config' do
+ allow(project_storage).to receive(:rename_repo).and_return(true)
+
+ project.rename_repo
+
+ expect(project.repository.rugged.config['gitlab.fullpath']).to eq(project.full_path)
+ end
end
describe '#pages_path' do
@@ -2668,14 +2822,14 @@ describe Project do
end
context 'hashed storage' do
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, skip_disk_validation: true) }
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:hash) { '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b' }
+ let(:hash) { Digest::SHA2.hexdigest(project.id.to_s) }
+ let(:hashed_prefix) { File.join('@hashed', hash[0..1], hash[2..3]) }
+ let(:hashed_path) { File.join(hashed_prefix, hash) }
before do
stub_application_setting(hashed_storage_enabled: true)
- allow(Digest::SHA2).to receive(:hexdigest) { hash }
- allow(project).to receive(:gitlab_shell).and_return(gitlab_shell)
end
describe '#legacy_storage?' do
@@ -2698,21 +2852,21 @@ describe Project do
describe '#base_dir' do
it 'returns base_dir based on hash of project id' do
- expect(project.base_dir).to eq('@hashed/6b/86')
+ expect(project.base_dir).to eq(hashed_prefix)
end
end
describe '#disk_path' do
it 'returns disk_path based on hash of project id' do
- hashed_path = '@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b'
-
expect(project.disk_path).to eq(hashed_path)
end
end
describe '#ensure_storage_path_exists' do
it 'delegates to gitlab_shell to ensure namespace is created' do
- expect(gitlab_shell).to receive(:add_namespace).with(project.repository_storage_path, '@hashed/6b/86')
+ allow(project).to receive(:gitlab_shell).and_return(gitlab_shell)
+
+ expect(gitlab_shell).to receive(:add_namespace).with(project.repository_storage_path, hashed_prefix)
project.ensure_storage_path_exists
end
@@ -2772,7 +2926,7 @@ describe Project do
end
context 'when not rolled out' do
- let(:project) { create(:project, :repository, storage_version: 1) }
+ let(:project) { create(:project, :repository, storage_version: 1, skip_disk_validation: true) }
it 'moves pages folder to new location' do
expect_any_instance_of(Gitlab::UploadsTransfer).to receive(:rename_project)
@@ -2781,6 +2935,12 @@ describe Project do
end
end
end
+
+ it 'updates project full path in .git/config' do
+ project.rename_repo
+
+ expect(project.repository.rugged.config['gitlab.fullpath']).to eq(project.full_path)
+ end
end
describe '#pages_path' do
@@ -2956,18 +3116,40 @@ describe Project do
subject { project.auto_devops_variables }
- context 'when enabled in settings' do
+ context 'when enabled in instance settings' do
before do
stub_application_setting(auto_devops_enabled: true)
end
context 'when domain is empty' do
before do
+ stub_application_setting(auto_devops_domain: nil)
+ end
+
+ it 'variables does not include AUTO_DEVOPS_DOMAIN' do
+ is_expected.not_to include(domain_variable)
+ end
+ end
+
+ context 'when domain is configured' do
+ before do
+ stub_application_setting(auto_devops_domain: 'example.com')
+ end
+
+ it 'variables includes AUTO_DEVOPS_DOMAIN' do
+ is_expected.to include(domain_variable)
+ end
+ end
+ end
+
+ context 'when explicitely enabled' do
+ context 'when domain is empty' do
+ before do
create(:project_auto_devops, project: project, domain: nil)
end
- it 'variables are empty' do
- is_expected.to be_empty
+ it 'variables does not include AUTO_DEVOPS_DOMAIN' do
+ is_expected.not_to include(domain_variable)
end
end
@@ -2976,11 +3158,15 @@ describe Project do
create(:project_auto_devops, project: project, domain: 'example.com')
end
- it "variables are not empty" do
- is_expected.not_to be_empty
+ it 'variables includes AUTO_DEVOPS_DOMAIN' do
+ is_expected.to include(domain_variable)
end
end
end
+
+ def domain_variable
+ { key: 'AUTO_DEVOPS_DOMAIN', value: 'example.com', public: true }
+ end
end
describe '#latest_successful_builds_for' do
@@ -3058,9 +3244,52 @@ describe Project do
expect(project).to receive(:import_finish)
expect(project).to receive(:update_project_counter_caches)
expect(project).to receive(:remove_import_jid)
+ expect(project).to receive(:after_create_default_branch)
+ expect(project).to receive(:refresh_markdown_cache!)
project.after_import
end
+
+ context 'branch protection' do
+ let(:project) { create(:project, :repository) }
+
+ it 'does not protect when branch protection is disabled' do
+ stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE)
+
+ project.after_import
+
+ expect(project.protected_branches).to be_empty
+ end
+
+ it "gives developer access to push when branch protection is set to 'developers can push'" do
+ stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH)
+
+ project.after_import
+
+ expect(project.protected_branches).not_to be_empty
+ expect(project.default_branch).to eq(project.protected_branches.first.name)
+ expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER])
+ end
+
+ it "gives developer access to merge when branch protection is set to 'developers can merge'" do
+ stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
+
+ project.after_import
+
+ expect(project.protected_branches).not_to be_empty
+ expect(project.default_branch).to eq(project.protected_branches.first.name)
+ expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER])
+ end
+
+ it 'protects default branch' do
+ project.after_import
+
+ expect(project.protected_branches).not_to be_empty
+ expect(project.default_branch).to eq(project.protected_branches.first.name)
+ 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])
+ end
+ end
end
describe '#update_project_counter_caches' do
@@ -3123,22 +3352,237 @@ describe Project do
end
end
- describe '#deployment_platform' do
- subject { project.deployment_platform }
+ describe '#write_repository_config' do
+ set(:project) { create(:project, :repository) }
- let(:project) { create(:project) }
+ it 'writes full path in .git/config when key is missing' do
+ project.write_repository_config
+
+ expect(project.repository.rugged.config['gitlab.fullpath']).to eq project.full_path
+ end
+
+ it 'updates full path in .git/config when key is present' do
+ project.write_repository_config(gl_full_path: 'old/path')
+
+ expect { project.write_repository_config }.to change { project.repository.rugged.config['gitlab.fullpath'] }.from('old/path').to(project.full_path)
+ end
+
+ it 'does not raise an error with an empty repository' do
+ project = create(:project_empty_repo)
+
+ expect { project.write_repository_config }.not_to raise_error
+ end
+ end
+
+ describe '#execute_hooks' do
+ it 'executes the projects hooks with the specified scope' do
+ hook1 = create(:project_hook, merge_requests_events: true, tag_push_events: false)
+ hook2 = create(:project_hook, merge_requests_events: false, tag_push_events: true)
+ project = create(:project, hooks: [hook1, hook2])
+
+ expect_any_instance_of(ProjectHook).to receive(:async_execute).once
- context 'when user configured kubernetes from Integration > Kubernetes' do
- let!(:kubernetes_service) { create(:kubernetes_service, project: project) }
+ project.execute_hooks({}, :tag_push_hooks)
+ end
+
+ it 'executes the system hooks with the specified scope' do
+ expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with({ data: 'data' }, :merge_request_hooks)
- it { is_expected.to eq(kubernetes_service) }
+ project = build(:project)
+ project.execute_hooks({ data: 'data' }, :merge_request_hooks)
end
- context 'when user configured kubernetes from CI/CD > Clusters' do
- let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
- let(:platform_kubernetes) { cluster.platform_kubernetes }
+ it 'executes the system hooks when inside a transaction' do
+ allow_any_instance_of(WebHookService).to receive(:execute)
+
+ create(:system_hook, merge_requests_events: true)
+
+ project = build(:project)
+
+ # Ideally, we'd test that `WebHookWorker.jobs.size` increased by 1,
+ # but since the entire spec run takes place in a transaction, we never
+ # actually get to the `after_commit` hook that queues these jobs.
+ expect do
+ project.transaction do
+ project.execute_hooks({ data: 'data' }, :merge_request_hooks)
+ end
+ end.not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError
+ end
+ end
+
+ describe '#badges' do
+ let(:project_group) { create(:group) }
+ let(:project) { create(:project, path: 'avatar', namespace: project_group) }
+
+ before do
+ create_list(:project_badge, 2, project: project)
+ create(:group_badge, group: project_group)
+ end
+
+ it 'returns the project and the project group badges' do
+ create(:group_badge, group: create(:group))
+
+ expect(Badge.count).to eq 4
+ expect(project.badges.count).to eq 3
+ end
+
+ if Group.supports_nested_groups?
+ context 'with nested_groups' do
+ let(:parent_group) { create(:group) }
+
+ before do
+ create_list(:group_badge, 2, group: project_group)
+ project_group.update(parent: parent_group)
+ end
+
+ it 'returns the project and the project nested groups badges' do
+ expect(project.badges.count).to eq 5
+ end
+ end
+ end
+ end
+
+ context 'with cross project merge requests' do
+ let(:user) { create(:user) }
+ let(:target_project) { create(:project, :repository) }
+ let(:project) { fork_project(target_project, nil, repository: true) }
+ let!(:merge_request) do
+ create(
+ :merge_request,
+ target_project: target_project,
+ target_branch: 'target-branch',
+ source_project: project,
+ source_branch: 'awesome-feature-1',
+ allow_maintainer_to_push: true
+ )
+ end
+
+ before do
+ target_project.add_developer(user)
+ end
+
+ describe '#merge_requests_allowing_push_to_user' do
+ it 'returns open merge requests for which the user has developer access to the target project' do
+ expect(project.merge_requests_allowing_push_to_user(user)).to include(merge_request)
+ end
+
+ it 'does not include closed merge requests' do
+ merge_request.close
+
+ expect(project.merge_requests_allowing_push_to_user(user)).to be_empty
+ end
+
+ it 'does not include merge requests for guest users' do
+ guest = create(:user)
+ target_project.add_guest(guest)
+
+ expect(project.merge_requests_allowing_push_to_user(guest)).to be_empty
+ end
+
+ it 'does not include the merge request for other users' do
+ other_user = create(:user)
+
+ expect(project.merge_requests_allowing_push_to_user(other_user)).to be_empty
+ end
+
+ it 'is empty when no user is passed' do
+ expect(project.merge_requests_allowing_push_to_user(nil)).to be_empty
+ end
+ end
+
+ describe '#branch_allows_maintainer_push?' do
+ it 'allows access if the user can merge the merge request' do
+ expect(project.branch_allows_maintainer_push?(user, 'awesome-feature-1'))
+ .to be_truthy
+ end
+
+ it 'does not allow guest users access' do
+ guest = create(:user)
+ target_project.add_guest(guest)
+
+ expect(project.branch_allows_maintainer_push?(guest, 'awesome-feature-1'))
+ .to be_falsy
+ end
+
+ it 'does not allow access to branches for which the merge request was closed' do
+ create(:merge_request, :closed,
+ target_project: target_project,
+ target_branch: 'target-branch',
+ source_project: project,
+ source_branch: 'rejected-feature-1',
+ allow_maintainer_to_push: true)
+
+ expect(project.branch_allows_maintainer_push?(user, 'rejected-feature-1'))
+ .to be_falsy
+ end
+
+ it 'does not allow access if the user cannot merge the merge request' do
+ create(:protected_branch, :masters_can_push, project: target_project, name: 'target-branch')
+
+ expect(project.branch_allows_maintainer_push?(user, 'awesome-feature-1'))
+ .to be_falsy
+ end
+
+ it 'caches the result' do
+ control = ActiveRecord::QueryRecorder.new { project.branch_allows_maintainer_push?(user, 'awesome-feature-1') }
+
+ expect { 3.times { project.branch_allows_maintainer_push?(user, 'awesome-feature-1') } }
+ .not_to exceed_query_limit(control)
+ end
+
+ context 'when the requeststore is active', :request_store do
+ it 'only queries per project across instances' do
+ control = ActiveRecord::QueryRecorder.new { project.branch_allows_maintainer_push?(user, 'awesome-feature-1') }
+
+ expect { 2.times { described_class.find(project.id).branch_allows_maintainer_push?(user, 'awesome-feature-1') } }
+ .not_to exceed_query_limit(control).with_threshold(2)
+ end
+ end
+ end
+ end
+
+ describe "#pages_https_only?" do
+ subject { build(:project) }
+
+ context "when HTTPS pages are disabled" do
+ it { is_expected.not_to be_pages_https_only }
+ end
+
+ context "when HTTPS pages are enabled", :https_pages_enabled do
+ it { is_expected.to be_pages_https_only }
+ end
+ end
+
+ describe "#pages_https_only? validation", :https_pages_enabled do
+ subject(:project) do
+ # set-up dirty object:
+ create(:project, pages_https_only: false).tap do |p|
+ p.pages_https_only = true
+ end
+ end
+
+ context "when no domains are associated" do
+ it { is_expected.to be_valid }
+ end
+
+ context "when domains including keys and certificates are associated" do
+ before do
+ allow(project)
+ .to receive(:pages_domains)
+ .and_return([instance_double(PagesDomain, https?: true)])
+ end
+
+ it { is_expected.to be_valid }
+ end
+
+ context "when domains including no keys or certificates are associated" do
+ before do
+ allow(project)
+ .to receive(:pages_domains)
+ .and_return([instance_double(PagesDomain, https?: false)])
+ end
- it { is_expected.to eq(platform_kubernetes) }
+ it { is_expected.not_to be_valid }
end
end
end
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index e78ed1df821..5cff2af4aca 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -146,6 +146,12 @@ describe ProjectStatistics do
expect(statistics.build_artifacts_size).to be(106365)
end
+
+ it 'calculates related build artifacts by project' do
+ expect(Ci::JobArtifact).to receive(:artifacts_size_for).with(project) { 0 }
+
+ statistics.update_build_artifacts_size
+ end
end
context 'when legacy artifacts are used' do
diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb
index 314824b32da..e07c522800a 100644
--- a/spec/models/project_team_spec.rb
+++ b/spec/models/project_team_spec.rb
@@ -291,8 +291,8 @@ describe ProjectTeam do
group.add_master(master)
group.add_developer(developer)
- members_project.team << [developer, :developer]
- members_project.team << [master, :master]
+ members_project.add_developer(developer)
+ members_project.add_master(master)
create(:project_group_link, project: shared_project, group: group)
end
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index 929086305ba..d87c1ca14f0 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -14,13 +14,13 @@ describe ProjectWiki do
it { is_expected.to delegate_method(:repository_storage_path).to :project }
it { is_expected.to delegate_method(:hashed_storage?).to :project }
- describe "#path_with_namespace" do
+ describe "#full_path" do
it "returns the project path with namespace with the .wiki extension" do
- expect(subject.path_with_namespace).to eq(project.full_path + '.wiki')
+ expect(subject.full_path).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)
+ expect(subject.full_path).to eq(subject.full_path)
end
end
@@ -74,7 +74,7 @@ describe ProjectWiki do
# Create a fresh project which will not have a wiki
project_wiki = described_class.new(create(:project), user)
gitlab_shell = double(:gitlab_shell)
- allow(gitlab_shell).to receive(:add_repository)
+ allow(gitlab_shell).to receive(:create_repository)
allow(project_wiki).to receive(:gitlab_shell).and_return(gitlab_shell)
expect { project_wiki.send(:wiki) }.to raise_exception(ProjectWiki::CouldNotCreateWikiError)
@@ -127,7 +127,7 @@ describe ProjectWiki do
end
after do
- destroy_page(subject.pages.first.page)
+ subject.pages.each { |page| destroy_page(page.page) }
end
it "returns the latest version of the page if it exists" do
@@ -148,6 +148,17 @@ describe ProjectWiki do
page = subject.find_page("index page")
expect(page).to be_a WikiPage
end
+
+ context 'pages with multibyte-character title' do
+ before do
+ create_page("autre pagé", "C'est un génial Gollum Wiki")
+ end
+
+ it "can find a page by slug" do
+ page = subject.find_page("autre pagé")
+ expect(page.title).to eq("autre pagé")
+ end
+ end
end
context 'when Gitaly wiki_find_page is enabled' do
diff --git a/spec/models/push_event_spec.rb b/spec/models/push_event_spec.rb
index ad3c3a406d9..bfe7a30b96a 100644
--- a/spec/models/push_event_spec.rb
+++ b/spec/models/push_event_spec.rb
@@ -63,12 +63,14 @@ describe PushEvent do
let(:event2) { create(:push_event, project: project) }
let(:event3) { create(:push_event, project: project) }
let(:event4) { create(:push_event, project: project) }
+ let(:event5) { create(:push_event, project: project) }
before do
create(:push_event_payload, event: event1, ref: 'foo', action: :created)
create(:push_event_payload, event: event2, ref: 'bar', action: :created)
- create(:push_event_payload, event: event3, ref: 'baz', action: :removed)
- create(:push_event_payload, event: event4, ref: 'baz', ref_type: :tag)
+ create(:push_event_payload, event: event3, ref: 'qux', action: :created)
+ create(:push_event_payload, event: event4, ref: 'baz', action: :removed)
+ create(:push_event_payload, event: event5, ref: 'baz', ref_type: :tag)
project.repository.create_branch('bar', 'master')
@@ -78,6 +80,16 @@ describe PushEvent do
target_project: project,
source_branch: 'bar'
)
+
+ project.repository.create_branch('qux', 'master')
+
+ create(
+ :merge_request,
+ :closed,
+ source_project: project,
+ target_project: project,
+ source_branch: 'qux'
+ )
end
let(:relation) { described_class.without_existing_merge_requests }
@@ -86,16 +98,20 @@ describe PushEvent do
expect(relation).to include(event1)
end
- it 'does not include events that have a corresponding merge request' do
+ it 'does not include events that have a corresponding open merge request' do
expect(relation).not_to include(event2)
end
+ it 'includes events that has corresponding closed/merged merge requests' do
+ expect(relation).to include(event3)
+ end
+
it 'does not include events for removed refs' do
- expect(relation).not_to include(event3)
+ expect(relation).not_to include(event4)
end
it 'does not include events for pushing to tags' do
- expect(relation).not_to include(event4)
+ expect(relation).not_to include(event5)
end
end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 9a68ae086ea..60ab52565cb 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -36,26 +36,49 @@ describe Repository do
end
describe '#branch_names_contains' do
- subject { repository.branch_names_contains(sample_commit.id) }
+ shared_examples '#branch_names_contains' do
+ set(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
- it { is_expected.to include('master') }
- it { is_expected.not_to include('feature') }
- it { is_expected.not_to include('fix') }
+ subject { repository.branch_names_contains(sample_commit.id) }
- describe 'when storage is broken', :broken_storage do
- it 'should raise a storage error' do
- expect_to_raise_storage_error do
- broken_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 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
+
+ context 'when gitaly is enabled' do
+ it_behaves_like '#branch_names_contains'
+ end
+
+ context 'when gitaly is disabled', :skip_gitaly_mock do
+ it_behaves_like '#branch_names_contains'
+ end
end
describe '#tag_names_contains' do
- subject { repository.tag_names_contains(sample_commit.id) }
+ shared_examples '#tag_names_contains' do
+ subject { repository.tag_names_contains(sample_commit.id) }
- it { is_expected.to include('v1.1.0') }
- it { is_expected.not_to include('v1.0.0') }
+ it { is_expected.to include('v1.1.0') }
+ it { is_expected.not_to include('v1.0.0') }
+ end
+
+ context 'when gitaly is enabled' do
+ it_behaves_like '#tag_names_contains'
+ end
+
+ context 'when gitaly is enabled', :skip_gitaly_mock do
+ it_behaves_like '#tag_names_contains'
+ end
end
describe 'tags_sorted_by' do
@@ -219,23 +242,73 @@ describe Repository do
end
describe '#commits' do
- it 'sets follow when path is a single path' do
- expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: true)).and_call_original.twice
+ context 'when neither the all flag nor a ref are specified' do
+ it 'returns every commit from default branch' do
+ expect(repository.commits(limit: 60).size).to eq(37)
+ end
+ end
- repository.commits('master', path: 'README.md')
- repository.commits('master', path: ['README.md'])
+ context 'when ref is passed' do
+ it 'returns every commit from the specified ref' do
+ expect(repository.commits('master', limit: 60).size).to eq(37)
+ end
+
+ context 'when all' do
+ it 'returns every commit from the repository' do
+ expect(repository.commits('master', limit: 60, all: true).size).to eq(60)
+ end
+ end
+
+ context 'with path' do
+ it 'sets follow when it is a single path' do
+ expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: true)).and_call_original.twice
+
+ repository.commits('master', limit: 1, path: 'README.md')
+ repository.commits('master', limit: 1, path: ['README.md'])
+ end
+
+ it 'does not set follow when it is multiple paths' do
+ expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original
+
+ repository.commits('master', limit: 1, path: ['README.md', 'CHANGELOG'])
+ end
+ end
+
+ context 'without path' do
+ it 'does not set follow' do
+ expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original
+
+ repository.commits('master', limit: 1)
+ end
+ end
end
- it 'does not set follow when path is multiple paths' do
- expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original
+ context "when 'all' flag is set" do
+ it 'returns every commit from the repository' do
+ expect(repository.commits(all: true, limit: 60).size).to eq(60)
+ end
+ end
+ end
- repository.commits('master', path: ['README.md', 'CHANGELOG'])
+ describe '#new_commits' do
+ let(:new_refs) do
+ double(:git_rev_list, new_refs: %w[
+ c1acaa58bbcbc3eafe538cb8274ba387047b69f8
+ 5937ac0a7beb003549fc5fd26fc247adbce4a52e
+ ])
end
- it 'does not set follow when there are no paths' do
- expect(Gitlab::Git::Commit).to receive(:where).with(a_hash_including(follow: false)).and_call_original
+ it 'delegates to Gitlab::Git::RevList' do
+ expect(Gitlab::Git::RevList).to receive(:new).with(
+ repository.raw,
+ newrev: 'aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj').and_return(new_refs)
- repository.commits('master')
+ commits = repository.new_commits('aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj')
+
+ expect(commits).to eq([
+ repository.commit('c1acaa58bbcbc3eafe538cb8274ba387047b69f8'),
+ repository.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
+ ])
end
end
@@ -358,28 +431,44 @@ describe Repository do
end
describe '#can_be_merged?' do
- context 'mergeable branches' do
- subject { repository.can_be_merged?('0b4bc9a49b562e85de7cc9e834518ea6828729b9', 'master') }
+ shared_examples 'can be merged' do
+ context 'mergeable branches' do
+ subject { repository.can_be_merged?('0b4bc9a49b562e85de7cc9e834518ea6828729b9', 'master') }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
+ end
- context 'non-mergeable branches' do
- subject { repository.can_be_merged?('bb5206fee213d983da88c47f9cf4cc6caf9c66dc', 'feature') }
+ context 'non-mergeable branches without conflict sides missing' do
+ subject { repository.can_be_merged?('bb5206fee213d983da88c47f9cf4cc6caf9c66dc', 'feature') }
- it { is_expected.to be_falsey }
- end
+ it { is_expected.to be_falsey }
+ end
- context 'non merged branch' do
- subject { repository.merged_to_root_ref?('fix') }
+ context 'non-mergeable branches with conflict sides missing' do
+ subject { repository.can_be_merged?('conflict-missing-side', 'conflict-start') }
- it { is_expected.to be_falsey }
+ it { is_expected.to be_falsey }
+ end
+
+ context 'non merged branch' do
+ subject { repository.merged_to_root_ref?('fix') }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'non existent branch' do
+ subject { repository.merged_to_root_ref?('non_existent_branch') }
+
+ it { is_expected.to be_nil }
+ end
end
- context 'non existent branch' do
- subject { repository.merged_to_root_ref?('non_existent_branch') }
+ context 'when Gitaly can_be_merged feature is enabled' do
+ it_behaves_like 'can be merged'
+ end
- it { is_expected.to be_nil }
+ context 'when Gitaly can_be_merged feature is disabled', :disable_gitaly do
+ it_behaves_like 'can be merged'
end
end
@@ -417,7 +506,7 @@ describe Repository do
expect do
repository.create_dir(user, 'newdir',
message: 'Create newdir', branch_name: 'master')
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
newdir = repository.tree('master', 'newdir')
expect(newdir.path).to eq('newdir')
@@ -431,7 +520,7 @@ describe Repository do
repository.create_dir(user, 'newdir',
message: 'Create newdir', branch_name: 'patch',
start_branch_name: 'master', start_project: forked_project)
- end.to change { repository.commits('master').count }.by(0)
+ end.to change { repository.count_commits(ref: 'master') }.by(0)
expect(repository.branch_exists?('patch')).to be_truthy
expect(forked_project.repository.branch_exists?('patch')).to be_falsy
@@ -448,7 +537,7 @@ describe Repository do
message: 'Add newdir',
branch_name: 'master',
author_email: author_email, author_name: author_name)
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
last_commit = repository.commit
@@ -464,7 +553,7 @@ describe Repository do
repository.create_file(user, 'NEWCHANGELOG', 'Changelog!',
message: 'Create changelog',
branch_name: 'master')
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
blob = repository.blob_at('master', 'NEWCHANGELOG')
@@ -476,7 +565,7 @@ describe Repository 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)
+ end.to change { repository.count_commits(ref: 'master') }.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!')
@@ -500,7 +589,7 @@ describe Repository do
branch_name: 'master',
author_email: author_email,
author_name: author_name)
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
last_commit = repository.commit
@@ -516,7 +605,7 @@ describe Repository do
repository.update_file(user, 'CHANGELOG', 'Changelog!',
message: 'Update changelog',
branch_name: 'master')
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
blob = repository.blob_at('master', 'CHANGELOG')
@@ -529,7 +618,7 @@ describe Repository do
branch_name: 'master',
previous_path: 'LICENSE',
message: 'Changes filename')
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
files = repository.ls_files('master')
@@ -546,7 +635,7 @@ describe Repository do
message: 'Update README',
author_email: author_email,
author_name: author_name)
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
last_commit = repository.commit
@@ -561,7 +650,7 @@ describe Repository do
expect do
repository.delete_file(user, 'README',
message: 'Remove README', branch_name: 'master')
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
expect(repository.blob_at('master', 'README')).to be_nil
end
@@ -572,7 +661,7 @@ describe Repository do
repository.delete_file(user, 'README',
message: 'Remove README', branch_name: 'master',
author_email: author_email, author_name: author_name)
- end.to change { repository.commits('master').count }.by(1)
+ end.to change { repository.count_commits(ref: 'master') }.by(1)
last_commit = repository.commit
@@ -582,38 +671,6 @@ describe Repository do
end
end
- describe '#get_committer_and_author' do
- it 'returns the committer and author data' do
- options = repository.get_committer_and_author(user)
- expect(options[:committer][:email]).to eq(user.email)
- expect(options[:author][:email]).to eq(user.email)
- end
-
- context 'when the email/name are given' do
- it 'returns an object containing the email/name' do
- options = repository.get_committer_and_author(user, email: author_email, name: author_name)
- expect(options[:author][:email]).to eq(author_email)
- expect(options[:author][:name]).to eq(author_name)
- end
- end
-
- context 'when the email is given but the name is not' do
- it 'returns the committer as the author' do
- options = repository.get_committer_and_author(user, email: author_email)
- expect(options[:author][:email]).to eq(user.email)
- expect(options[:author][:name]).to eq(user.name)
- end
- end
-
- context 'when the name is given but the email is not' do
- it 'returns nil' do
- options = repository.get_committer_and_author(user, name: author_name)
- expect(options[:author][:email]).to eq(user.email)
- expect(options[:author][:name]).to eq(user.name)
- end
- end
- end
-
describe "search_files_by_content" do
let(:results) { repository.search_files_by_content('feature', 'master') }
subject { results }
@@ -657,7 +714,7 @@ describe Repository do
subject { results.first }
it { is_expected.to be_an String }
- it { expect(subject.lines[2]).to eq("master:CHANGELOG:190: - Feature: Replace teams with group membership\n") }
+ it { expect(subject.lines[2]).to eq("master:CHANGELOG\x00190\x00 - Feature: Replace teams with group membership\n") }
end
end
@@ -668,6 +725,18 @@ describe Repository do
expect(results.first).to eq('files/html/500.html')
end
+ it 'ignores leading slashes' do
+ results = repository.search_files_by_name('/files', 'master')
+
+ expect(results.first).to eq('files/html/500.html')
+ end
+
+ it 'properly handles when query is only slashes' do
+ results = repository.search_files_by_name('//', 'master')
+
+ expect(results).to match_array([])
+ end
+
it 'properly handles when query is not present' do
results = repository.search_files_by_name('', 'master')
@@ -754,8 +823,7 @@ describe Repository 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(:root_ref).and_raise(Gitlab::Git::Repository::NoRepository)
expect(repository.license_blob).to be_nil
end
@@ -805,12 +873,24 @@ describe Repository do
end
it 'returns nil when the content is not recognizable' do
- repository.create_file(user, 'LICENSE', 'Copyright!',
+ repository.create_file(user, 'LICENSE', 'Gitlab B.V.',
message: 'Add LICENSE', branch_name: 'master')
expect(repository.license_key).to be_nil
end
+ it 'returns nil when the commit SHA does not exist' do
+ allow(repository.head_commit).to receive(:sha).and_return('1' * 40)
+
+ expect(repository.license_key).to be_nil
+ end
+
+ it 'returns nil when master does not exist' do
+ repository.rm_branch(user, 'master')
+
+ expect(repository.license_key).to be_nil
+ end
+
it 'returns the license key' do
repository.create_file(user, 'LICENSE',
Licensee::License.new('mit').content,
@@ -837,7 +917,7 @@ describe Repository do
end
it 'returns nil when the content is not recognizable' do
- repository.create_file(user, 'LICENSE', 'Copyright!',
+ repository.create_file(user, 'LICENSE', 'Gitlab B.V.',
message: 'Add LICENSE', branch_name: 'master')
expect(repository.license).to be_nil
@@ -867,7 +947,7 @@ describe Repository do
end
it 'returns nil for empty repository' do
- allow(repository).to receive(:file_on_head).and_raise(Rugged::ReferenceError)
+ allow(repository).to receive(:root_ref).and_raise(Gitlab::Git::Repository::NoRepository)
expect(repository.gitlab_ci_yml).to be_nil
end
end
@@ -902,7 +982,7 @@ describe Repository do
end
end
- context 'with Gitaly disabled', :skip_gitaly_mock do
+ context 'with Gitaly disabled', :disable_gitaly do
context 'when pre hooks were successful' do
it 'runs without errors' do
hook = double(trigger: [true, nil])
@@ -942,19 +1022,19 @@ describe Repository do
end
describe '#find_branch' do
- it 'loads a branch with a fresh repo' do
- expect(Gitlab::Git::Repository).to receive(:new).twice.and_call_original
+ context 'fresh_repo is true' do
+ it 'delegates the call to raw_repository' do
+ expect(repository.raw_repository).to receive(:find_branch).with('master', true)
- 2.times do
- expect(repository.find_branch('feature')).not_to be_nil
+ repository.find_branch('master', fresh_repo: true)
end
end
- it 'loads a branch with a cached repo' do
- expect(Gitlab::Git::Repository).to receive(:new).once.and_call_original
+ context 'fresh_repo is false' do
+ it 'delegates the call to raw_repository' do
+ expect(repository.raw_repository).to receive(:find_branch).with('master', false)
- 2.times do
- expect(repository.find_branch('feature', fresh_repo: false)).not_to be_nil
+ repository.find_branch('master', fresh_repo: false)
end
end
end
@@ -1112,16 +1192,16 @@ describe Repository do
allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, ''])
end
- it 'expires branch cache' do
- expect(repository).not_to receive(:expire_exists_cache)
- expect(repository).not_to receive(:expire_root_ref_cache)
- expect(repository).not_to receive(:expire_emptiness_caches)
- expect(repository).to receive(:expire_branches_cache)
-
- repository.with_branch(user, 'new-feature') do
+ subject do
+ Gitlab::Git::OperationService.new(git_user, repository.raw_repository).with_branch('new-feature') do
new_rev
end
end
+
+ it 'returns branch_created as true' do
+ expect(subject).not_to be_repo_created
+ expect(subject).to be_branch_created
+ end
end
context 'when repository is empty' do
@@ -1345,7 +1425,6 @@ describe Repository do
it 'expires the caches for an empty repository' do
allow(repository).to receive(:empty?).and_return(true)
- expect(cache).to receive(:expire).with(:empty?)
expect(cache).to receive(:expire).with(:has_visible_content?)
repository.expire_emptiness_caches
@@ -1354,7 +1433,6 @@ describe Repository do
it 'does not expire the cache for a non-empty repository' do
allow(repository).to receive(:empty?).and_return(false)
- expect(cache).not_to receive(:expire).with(:empty?)
expect(cache).not_to receive(:expire).with(:has_visible_content?)
repository.expire_emptiness_caches
@@ -1794,7 +1872,7 @@ describe Repository do
it_behaves_like 'adding tag'
end
- context 'when Gitaly operation_user_add_tag feature is disabled', :skip_gitaly_mock do
+ context 'when Gitaly operation_user_add_tag feature is disabled', :disable_gitaly do
it_behaves_like 'adding tag'
it 'passes commit SHA to pre-receive and update hooks and tag SHA to post-receive hook' do
@@ -1853,7 +1931,7 @@ describe Repository do
end
end
- context 'with gitaly disabled', :skip_gitaly_mock do
+ context 'with gitaly disabled', :disable_gitaly do
it_behaves_like "user deleting a branch"
let(:old_rev) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } # git rev-parse feature
@@ -1919,8 +1997,7 @@ describe Repository do
describe '#avatar' do
it 'returns nil if repo does not exist' do
- expect(repository).to receive(:file_on_head)
- .and_raise(Rugged::ReferenceError)
+ allow(repository).to receive(:root_ref).and_raise(Gitlab::Git::Repository::NoRepository)
expect(repository.avatar).to eq(nil)
end
@@ -2070,15 +2147,6 @@ describe Repository do
end
end
- describe '#expire_method_caches' do
- it 'expires the caches of the given methods' do
- expect_any_instance_of(RepositoryCache).to receive(:expire).with(:readme)
- expect_any_instance_of(RepositoryCache).to receive(:expire).with(:gitignore)
-
- repository.expire_method_caches(%i(readme gitignore))
- end
- end
-
describe '#expire_all_method_caches' do
it 'expires the caches of all methods' do
expect(repository).to receive(:expire_method_caches)
@@ -2215,63 +2283,12 @@ describe Repository do
end
end
- describe '#cache_method_output', :use_clean_rails_memory_store_caching do
- let(:fallback) { 10 }
-
- context 'with a non-existing repository' do
- let(:project) { create(:project) } # No repository
-
- subject do
- repository.cache_method_output(:cats, fallback: fallback) do
- repository.cats_call_stub
- end
- end
-
- it 'returns the fallback value' do
- expect(subject).to eq(fallback)
- end
-
- it 'avoids calling the original method' do
- expect(repository).not_to receive(:cats_call_stub)
-
- subject
- end
- end
-
- context 'with a method throwing a non-existing-repository error' do
- subject do
- repository.cache_method_output(:cats, fallback: fallback) do
- raise Gitlab::Git::Repository::NoRepository
- end
- end
-
- it 'returns the fallback value' do
- expect(subject).to eq(fallback)
- end
-
- it 'does not cache the data' do
- subject
+ describe '#diverging_commit_counts' do
+ it 'returns the commit counts behind and ahead of default branch' do
+ result = repository.diverging_commit_counts(
+ repository.find_branch('fix'))
- expect(repository.instance_variable_defined?(:@cats)).to eq(false)
- expect(repository.send(:cache).exist?(:cats)).to eq(false)
- end
- end
-
- context 'with an existing repository' do
- it 'caches the output' do
- object = double
-
- expect(object).to receive(:number).once.and_return(10)
-
- 2.times do
- val = repository.cache_method_output(:cats) { object.number }
-
- expect(val).to eq(10)
- end
-
- expect(repository.send(:cache).exist?(:cats)).to eq(true)
- expect(repository.instance_variable_get(:@cats)).to eq(10)
- end
+ expect(result).to eq(behind: 29, ahead: 2)
end
end
@@ -2329,7 +2346,7 @@ describe Repository do
let(:commit) { repository.commit }
let(:ancestor) { commit.parents.first }
- context 'with Gitaly enabled' do
+ shared_examples '#ancestor?' do
it 'it is an ancestor' do
expect(repository.ancestor?(ancestor.id, commit.id)).to eq(true)
end
@@ -2343,27 +2360,19 @@ describe Repository do
expect(repository.ancestor?(ancestor.id, nil)).to eq(false)
expect(repository.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.ancestor?(ancestor.id, commit.id)).to eq(true)
+ it 'returns false for invalid commit IDs' do
+ expect(repository.ancestor?(commit.id, Gitlab::Git::BLANK_SHA)).to eq(false)
+ expect(repository.ancestor?( Gitlab::Git::BLANK_SHA, commit.id)).to eq(false)
end
+ end
- it 'it is not an ancestor' do
- expect(repository.ancestor?(commit.id, ancestor.id)).to eq(false)
- end
+ context 'with Gitaly enabled' do
+ it_behaves_like('#ancestor?')
+ end
- it 'returns false on nil-values' do
- expect(repository.ancestor?(nil, commit.id)).to eq(false)
- expect(repository.ancestor?(ancestor.id, nil)).to eq(false)
- expect(repository.ancestor?(nil, nil)).to eq(false)
- end
+ context 'with Gitaly disabled', :skip_gitaly_mock do
+ it_behaves_like('#ancestor?')
end
end
diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb
index ddad6862a63..01238a89a81 100644
--- a/spec/models/route_spec.rb
+++ b/spec/models/route_spec.rb
@@ -19,6 +19,13 @@ describe Route do
end
describe 'callbacks' do
+ context 'before validation' do
+ it 'calls #delete_conflicting_orphaned_routes' do
+ expect(route).to receive(:delete_conflicting_orphaned_routes)
+ route.valid?
+ end
+ end
+
context 'after update' do
it 'calls #create_redirect_for_old_path' do
expect(route).to receive(:create_redirect_for_old_path)
@@ -144,43 +151,31 @@ describe Route do
end
context 'when the source is a Project' do
- it 'creates a temporal RedirectRoute' do
+ it 'creates a RedirectRoute' do
project = create(:project)
route = project.route
redirect_route = route.create_redirect('foo')
- expect(redirect_route.permanent?).to be_falsy
+ expect(redirect_route).not_to be_nil
end
end
context 'when the source is not a project' do
- it 'creates a permanent RedirectRoute' do
- redirect_route = route.create_redirect('foo', permanent: true)
- expect(redirect_route.permanent?).to be_truthy
+ it 'creates a RedirectRoute' do
+ redirect_route = route.create_redirect('foo')
+ expect(redirect_route).not_to be_nil
end
end
end
describe '#delete_conflicting_redirects' do
- context 'with permanent redirect' do
- it 'does not delete the redirect' do
- route.create_redirect("#{route.path}/foo", permanent: true)
+ let(:route) { create(:project).route }
- expect do
- route.delete_conflicting_redirects
- end.not_to change { RedirectRoute.count }
- end
- end
-
- context 'with temporal redirect' do
- let(:route) { create(:project).route }
+ it 'deletes the redirect' do
+ route.create_redirect("#{route.path}/foo")
- it 'deletes the redirect' do
- route.create_redirect("#{route.path}/foo")
-
- expect do
- route.delete_conflicting_redirects
- end.to change { RedirectRoute.count }.by(-1)
- end
+ expect do
+ route.delete_conflicting_redirects
+ end.to change { RedirectRoute.count }.by(-1)
end
context 'when a redirect route with the same path exists' do
@@ -222,31 +217,18 @@ describe Route do
end
describe '#conflicting_redirects' do
+ let(:route) { create(:project).route }
+
it 'returns an ActiveRecord::Relation' do
expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation)
end
- context 'with permanent redirects' do
- it 'does not return anything' do
- route.create_redirect("#{route.path}/foo", permanent: true)
- route.create_redirect("#{route.path}/foo/bar", permanent: true)
- route.create_redirect("#{route.path}/baz/quz", permanent: true)
+ it 'returns the redirect routes' do
+ redirect1 = route.create_redirect("#{route.path}/foo")
+ redirect2 = route.create_redirect("#{route.path}/foo/bar")
+ redirect3 = route.create_redirect("#{route.path}/baz/quz")
- expect(route.conflicting_redirects).to be_empty
- end
- end
-
- context 'with temporal redirects' do
- let(:route) { create(:project).route }
-
- it 'returns the redirect routes' do
- route = create(:project).route
- redirect1 = route.create_redirect("#{route.path}/foo")
- redirect2 = route.create_redirect("#{route.path}/foo/bar")
- redirect3 = route.create_redirect("#{route.path}/baz/quz")
-
- expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3])
- end
+ expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3])
end
context 'when a redirect route with the same path exists' do
@@ -281,40 +263,56 @@ describe Route do
end
end
- describe "#conflicting_redirect_exists?" do
- context 'when a conflicting redirect exists' do
- let(:group1) { create(:group, path: 'foo') }
- let(:group2) { create(:group, path: 'baz') }
+ describe '#delete_conflicting_orphaned_routes' do
+ context 'when there is a conflicting route' do
+ let!(:conflicting_group) { create(:group, path: 'foo') }
+
+ before do
+ route.path = conflicting_group.route.path
+ end
+
+ context 'when the route is orphaned' do
+ let!(:offending_route) { conflicting_group.route }
- it 'should not be saved' do
- group1.path = 'bar'
- group1.save
+ before do
+ Group.delete(conflicting_group) # Orphan the route
+ end
- group2.path = 'foo'
+ it 'deletes the orphaned route' do
+ expect do
+ route.valid?
+ end.to change { described_class.count }.from(2).to(1)
+ end
- expect(group2.save).to be_falsy
+ it 'passes validation, as usual' do
+ expect(route.valid?).to be_truthy
+ end
end
- it 'should return an error on path' do
- group1.path = 'bar'
- group1.save
+ context 'when the route is not orphaned' do
+ it 'does not delete the conflicting route' do
+ expect do
+ route.valid?
+ end.not_to change { described_class.count }
+ end
- group2.path = 'foo'
- group2.valid?
- expect(group2.errors["route.path"].first).to eq('foo has been taken before. Please use another one')
+ it 'fails validation, as usual' do
+ expect(route.valid?).to be_falsey
+ end
end
end
- context 'when a conflicting redirect does not exist' do
- let(:project1) { create(:project, path: 'foo') }
- let(:project2) { create(:project, path: 'baz') }
+ context 'when there are no conflicting routes' do
+ it 'does not delete any routes' do
+ route
- it 'should be saved' do
- project1.path = 'bar'
- project1.save
+ expect do
+ route.valid?
+ end.not_to change { described_class.count }
+ end
- project2.path = 'foo'
- expect(project2.save).to be_truthy
+ it 'passes validation, as usual' do
+ expect(route.valid?).to be_truthy
end
end
end
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index 0f2f906c667..28c908ea425 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -10,6 +10,22 @@ describe Service do
it { is_expected.to validate_presence_of(:type) }
end
+ describe 'Scopes' do
+ describe '.confidential_note_hooks' do
+ it 'includes services where confidential_note_events is true' do
+ create(:service, active: true, confidential_note_events: true)
+
+ expect(described_class.confidential_note_hooks.count).to eq 1
+ end
+
+ it 'excludes services where confidential_note_events is false' do
+ create(:service, active: true, confidential_note_events: false)
+
+ expect(described_class.confidential_note_hooks.count).to eq 0
+ end
+ end
+ end
+
describe "Test Button" do
describe '#can_test?' do
let(:service) { create(:service, project: project) }
@@ -58,6 +74,21 @@ describe Service do
end
describe "Template" do
+ describe '.build_from_template' do
+ context 'when template is invalid' do
+ it 'sets service template to inactive when template is invalid' do
+ project = create(:project)
+ template = JiraService.new(template: true, active: true)
+ template.save(validate: false)
+
+ service = described_class.build_from_template(project.id, template)
+
+ expect(service).to be_valid
+ expect(service.active).to be false
+ end
+ end
+ end
+
describe "for pushover service" do
let!(:service_template) do
PushoverService.create(
@@ -254,4 +285,64 @@ describe Service do
end
end
end
+
+ describe "#deprecated?" do
+ let(:project) { create(:project, :repository) }
+
+ it 'should return false by default' do
+ service = create(:service, project: project)
+ expect(service.deprecated?).to be_falsy
+ end
+ end
+
+ describe "#deprecation_message" do
+ let(:project) { create(:project, :repository) }
+
+ it 'should be empty by default' do
+ service = create(:service, project: project)
+ expect(service.deprecation_message).to be_nil
+ end
+ end
+
+ describe '.find_by_template' do
+ let!(:kubernetes_service) { create(:kubernetes_service, template: true) }
+
+ it 'returns service template' do
+ expect(KubernetesService.find_by_template).to eq(kubernetes_service)
+ end
+ end
+
+ describe '#api_field_names' do
+ let(:fake_service) do
+ Class.new(Service) do
+ def fields
+ [
+ { name: 'token' },
+ { name: 'api_token' },
+ { name: 'key' },
+ { name: 'api_key' },
+ { name: 'password' },
+ { name: 'password_field' },
+ { name: 'safe_field' }
+ ]
+ end
+ end
+ end
+
+ let(:service) do
+ fake_service.new(properties: [
+ { token: 'token-value' },
+ { api_token: 'api_token-value' },
+ { key: 'key-value' },
+ { api_key: 'api_key-value' },
+ { password: 'password-value' },
+ { password_field: 'password_field-value' },
+ { safe_field: 'safe_field-value' }
+ ])
+ end
+
+ it 'filters out sensitive fields' do
+ expect(service.api_field_names).to eq(['safe_field'])
+ end
+ end
end
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index 3e8f3848eca..bd498269798 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -20,6 +20,7 @@ describe Todo do
it { is_expected.to validate_presence_of(:action) }
it { is_expected.to validate_presence_of(:target_type) }
it { is_expected.to validate_presence_of(:user) }
+ it { is_expected.to validate_presence_of(:author) }
context 'for commits' do
subject { described_class.new(target_type: 'Commit') }
diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb
index 345382ea8c7..36b8e5d304f 100644
--- a/spec/models/upload_spec.rb
+++ b/spec/models/upload_spec.rb
@@ -43,49 +43,16 @@ describe Upload do
.to(a_string_matching(/\A\h{64}\z/))
end
end
- end
-
- describe '.remove_path' do
- it 'removes all records at the given path' do
- described_class.create!(
- size: File.size(__FILE__),
- path: __FILE__,
- model: build_stubbed(:user),
- uploader: 'AvatarUploader'
- )
-
- expect { described_class.remove_path(__FILE__) }
- .to change { described_class.count }.from(1).to(0)
- end
- end
- describe '.record' do
- let(:fake_uploader) do
- double(
- file: double(size: 12_345),
- relative_path: 'foo/bar.jpg',
- model: build_stubbed(:user),
- class: 'AvatarUploader'
- )
- end
-
- it 'removes existing paths before creation' do
- expect(described_class).to receive(:remove_path)
- .with(fake_uploader.relative_path)
-
- described_class.record(fake_uploader)
- end
+ describe 'after_destroy' do
+ context 'uploader is FileUploader-based' do
+ subject { create(:upload, :issuable_upload) }
- it 'creates a new record and assigns size, path, model, and uploader' do
- upload = described_class.record(fake_uploader)
+ it 'calls delete_file!' do
+ is_expected.to receive(:delete_file!)
- aggregate_failures do
- expect(upload).to be_persisted
- expect(upload.size).to eq fake_uploader.file.size
- expect(upload.path).to eq fake_uploader.relative_path
- expect(upload.model_id).to eq fake_uploader.model.id
- expect(upload.model_type).to eq fake_uploader.model.class.to_s
- expect(upload.uploader).to eq fake_uploader.class
+ subject.destroy
+ end
end
end
end
@@ -111,27 +78,27 @@ describe Upload do
end
end
- describe '#calculate_checksum' do
- it 'calculates the SHA256 sum' do
- upload = described_class.new(
- path: __FILE__,
- size: described_class::CHECKSUM_THRESHOLD - 1.megabyte
- )
+ describe '#calculate_checksum!' do
+ let(:upload) do
+ described_class.new(path: __FILE__,
+ size: described_class::CHECKSUM_THRESHOLD - 1.megabyte)
+ end
+
+ it 'sets `checksum` to SHA256 sum of the file' do
expected = Digest::SHA256.file(__FILE__).hexdigest
- expect { upload.calculate_checksum }
+ expect { upload.calculate_checksum! }
.to change { upload.checksum }.from(nil).to(expected)
end
- it 'returns nil for a non-existant file' do
- upload = described_class.new(
- path: __FILE__,
- size: described_class::CHECKSUM_THRESHOLD - 1.megabyte
- )
-
+ it 'sets `checksum` to nil for a non-existant file' do
expect(upload).to receive(:exist?).and_return(false)
- expect(upload.calculate_checksum).to be_nil
+ checksum = Digest::SHA256.file(__FILE__).hexdigest
+ upload.checksum = checksum
+
+ expect { upload.calculate_checksum! }
+ .to change { upload.checksum }.from(checksum).to(nil)
end
end
@@ -148,4 +115,10 @@ describe Upload do
expect(upload).not_to exist
end
end
+
+ describe "#uploader_context" do
+ subject { create(:upload, :issuable_upload, secret: 'secret', filename: 'file.txt') }
+
+ it { expect(subject.uploader_context).to match(a_hash_including(secret: 'secret', identifier: 'file.txt')) }
+ end
end
diff --git a/spec/models/user_callout_spec.rb b/spec/models/user_callout_spec.rb
new file mode 100644
index 00000000000..64ba17c81fe
--- /dev/null
+++ b/spec/models/user_callout_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+
+describe UserCallout do
+ let!(:callout) { create(:user_callout) }
+
+ describe 'relationships' do
+ it { is_expected.to belong_to(:user) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:user) }
+
+ it { is_expected.to validate_presence_of(:feature_name) }
+ it { is_expected.to validate_uniqueness_of(:feature_name).scoped_to(:user_id).ignoring_case_sensitivity }
+ end
+end
diff --git a/spec/models/user_interacted_project_spec.rb b/spec/models/user_interacted_project_spec.rb
new file mode 100644
index 00000000000..cb4bb3372d4
--- /dev/null
+++ b/spec/models/user_interacted_project_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe UserInteractedProject do
+ describe '.track' do
+ subject { described_class.track(event) }
+ let(:event) { build(:event) }
+
+ Event::ACTIONS.each do |action|
+ context "for all actions (event types)" do
+ let(:event) { build(:event, action: action) }
+ it 'creates a record' do
+ expect { subject }.to change { described_class.count }.from(0).to(1)
+ end
+ end
+ end
+
+ it 'sets project accordingly' do
+ subject
+ expect(described_class.first.project).to eq(event.project)
+ end
+
+ it 'sets user accordingly' do
+ subject
+ expect(described_class.first.user).to eq(event.author)
+ end
+
+ it 'only creates a record once per user/project' do
+ expect do
+ subject
+ described_class.track(event)
+ end.to change { described_class.count }.from(0).to(1)
+ end
+
+ describe 'with an event without a project' do
+ let(:event) { build(:event, project: nil) }
+
+ it 'ignores the event' do
+ expect { subject }.not_to change { described_class.count }
+ end
+ end
+ end
+
+ describe '.available?' do
+ before do
+ described_class.instance_variable_set('@available_flag', nil)
+ end
+
+ it 'checks schema version and properly caches positive result' do
+ expect(ActiveRecord::Migrator).to receive(:current_version).and_return(described_class::REQUIRED_SCHEMA_VERSION - 1 - rand(1000))
+ expect(described_class.available?).to be_falsey
+ expect(ActiveRecord::Migrator).to receive(:current_version).and_return(described_class::REQUIRED_SCHEMA_VERSION + rand(1000))
+ expect(described_class.available?).to be_truthy
+ expect(ActiveRecord::Migrator).not_to receive(:current_version)
+ expect(described_class.available?).to be_truthy # cached response
+ end
+ end
+
+ it { is_expected.to validate_presence_of(:project_id) }
+ it { is_expected.to validate_presence_of(:user_id) }
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index e58e7588df0..35db7616efb 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1,14 +1,12 @@
require 'spec_helper'
describe User do
- include Gitlab::CurrentSettings
include ProjectForksHelper
describe 'modules' do
subject { described_class }
it { is_expected.to include_module(Gitlab::ConfigHelper) }
- it { is_expected.to include_module(Gitlab::CurrentSettings) }
it { is_expected.to include_module(Referable) }
it { is_expected.to include_module(Sortable) }
it { is_expected.to include_module(TokenAuthenticatable) }
@@ -22,18 +20,19 @@ describe User do
describe 'associations' do
it { is_expected.to have_one(:namespace) }
it { is_expected.to have_many(:snippets).dependent(:destroy) }
- it { is_expected.to have_many(:project_members).dependent(:destroy) }
+ it { is_expected.to have_many(:members) }
+ it { is_expected.to have_many(:project_members) }
+ it { is_expected.to have_many(:group_members) }
it { is_expected.to have_many(:groups) }
it { is_expected.to have_many(:keys).dependent(:destroy) }
- it { is_expected.to have_many(:deploy_keys).dependent(:destroy) }
+ it { is_expected.to have_many(:deploy_keys).dependent(:nullify) }
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(: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) }
it { is_expected.to have_many(:spam_logs).dependent(:destroy) }
- it { is_expected.to have_many(:todos).dependent(:destroy) }
+ it { is_expected.to have_many(:todos) }
it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
it { is_expected.to have_many(:triggers).dependent(:destroy) }
it { is_expected.to have_many(:builds).dependent(:nullify) }
@@ -101,7 +100,7 @@ describe User do
user = build(:user, username: 'dashboard')
expect(user).not_to be_valid
- expect(user.errors.values).to eq [['dashboard is a reserved name']]
+ expect(user.errors.messages[:username]).to eq ['dashboard is a reserved name']
end
it 'allows child names' do
@@ -116,12 +115,6 @@ describe User do
expect(user).to be_valid
end
- it 'validates uniqueness' do
- 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)) }
@@ -132,6 +125,28 @@ describe User do
expect(user.errors.messages[:username].first).to match('cannot be changed if a personal project has container registry tags')
end
end
+
+ context 'when the username is in use by another user' do
+ let(:username) { 'foo' }
+ let!(:other_user) { create(:user, username: username) }
+
+ it 'is invalid' do
+ user = build(:user, username: username)
+
+ expect(user).not_to be_valid
+ expect(user.errors.full_messages).to eq(['Username has already been taken'])
+ end
+ end
+ end
+
+ it 'has a DB-level NOT NULL constraint on projects_limit' do
+ user = create(:user)
+
+ expect(user.persisted?).to eq(true)
+
+ expect do
+ user.update_columns(projects_limit: nil)
+ end.to raise_error(ActiveRecord::StatementInvalid)
end
it { is_expected.to validate_presence_of(:projects_limit) }
@@ -463,6 +478,14 @@ describe User do
user2.update_tracked_fields!(request)
end.to change { user2.reload.current_sign_in_at }
end
+
+ it 'does not write if the DB is in read-only mode' do
+ expect(Gitlab::Database).to receive(:read_only?).and_return(true)
+
+ expect do
+ user.update_tracked_fields!(request)
+ end.not_to change { user.reload.current_sign_in_at }
+ end
end
shared_context 'user keys' do
@@ -548,7 +571,7 @@ describe 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)
+ .and change { user.projects_limit }.to(Gitlab::CurrentSettings.default_projects_limit)
end
end
@@ -760,7 +783,7 @@ describe User do
before do
# add user to project
- project.team << [user, :master]
+ project.add_master(user)
# create invite to projet
create(:project_member, :developer, project: project, invite_token: '1234', invite_email: 'inviteduser1@example.com')
@@ -805,9 +828,16 @@ describe User do
expect(user.can_create_group).to be_falsey
expect(user.theme_id).to eq(1)
end
+
+ it 'does not undo projects_limit setting if it matches old DB default of 10' do
+ # If the real default project limit is 10 then this test is worthless
+ expect(Gitlab.config.gitlab.default_projects_limit).not_to eq(10)
+ user = described_class.new(projects_limit: 10)
+ expect(user.projects_limit).to eq(10)
+ end
end
- context 'when current_application_settings.user_default_external is true' do
+ context 'when Gitlab::CurrentSettings.user_default_external is true' do
before do
stub_application_setting(user_default_external: true)
end
@@ -853,6 +883,14 @@ describe User do
end
end
+ describe '.find_for_database_authentication' do
+ it 'strips whitespace from login' do
+ user = create(:user)
+
+ expect(described_class.find_for_database_authentication({ login: " #{user.username} " })).to eq user
+ end
+ end
+
describe '.find_by_any_email' do
it 'finds by primary email' do
user = create(:user, email: 'foo@example.com')
@@ -947,6 +985,14 @@ describe User do
expect(described_class.search(user3.username.upcase)).to eq([user3])
end
end
+
+ it 'returns no matches for an empty string' do
+ expect(described_class.search('')).to be_empty
+ end
+
+ it 'returns no matches for nil' do
+ expect(described_class.search(nil)).to be_empty
+ end
end
describe '.search_with_secondary_emails' do
@@ -1001,6 +1047,14 @@ describe User do
it 'does not return users with a matching part of secondary email' do
expect(search_with_secondary_emails(email.email[1..4])).not_to include([email.user])
end
+
+ it 'returns no matches for an empty string' do
+ expect(search_with_secondary_emails('')).to be_empty
+ end
+
+ it 'returns no matches for nil' do
+ expect(search_with_secondary_emails(nil)).to be_empty
+ end
end
describe '.find_by_ssh_key_id' do
@@ -1151,7 +1205,7 @@ describe User do
it 'is false if avatar is html page' do
user.update_attribute(:avatar, 'uploads/avatar.html')
- expect(user.avatar_type).to eq(['only images allowed'])
+ expect(user.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff'])
end
end
@@ -1397,44 +1451,50 @@ describe User do
end
end
- describe '#sort' do
+ describe '#sort_by_attribute' do
before do
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'
+ @user = create :user, created_at: Date.today, current_sign_in_at: Date.today, name: 'Alpha'
+ @user1 = create :user, created_at: Date.today - 1, current_sign_in_at: Date.today - 1, name: 'Omega'
+ @user2 = create :user, created_at: Date.today - 2, name: 'Beta'
end
context 'when sort by recent_sign_in' do
- it 'sorts users by the recent sign-in time' do
- expect(described_class.sort('recent_sign_in').first).to eq(@user)
+ let(:users) { described_class.sort_by_attribute('recent_sign_in') }
+
+ it 'sorts users by recent sign-in time' do
+ expect(users.first).to eq(@user)
+ expect(users.second).to eq(@user1)
end
it 'pushes users who never signed in to the end' do
- expect(described_class.sort('recent_sign_in').third).to eq(@user2)
+ expect(users.third).to eq(@user2)
end
end
context 'when sort by oldest_sign_in' do
+ let(:users) { described_class.sort_by_attribute('oldest_sign_in') }
+
it 'sorts users by the oldest sign-in time' do
- expect(described_class.sort('oldest_sign_in').first).to eq(@user1)
+ expect(users.first).to eq(@user1)
+ expect(users.second).to eq(@user)
end
it 'pushes users who never signed in to the end' do
- expect(described_class.sort('oldest_sign_in').third).to eq(@user2)
+ expect(users.third).to eq(@user2)
end
end
it 'sorts users in descending order by their creation time' do
- expect(described_class.sort('created_desc').first).to eq(@user)
+ expect(described_class.sort_by_attribute('created_desc').first).to eq(@user)
end
it 'sorts users in ascending order by their creation time' do
- expect(described_class.sort('created_asc').first).to eq(@user2)
+ expect(described_class.sort_by_attribute('created_asc').first).to eq(@user2)
end
it 'sorts users by id in descending order when nil is passed' do
- expect(described_class.sort(nil).first).to eq(@user2)
+ expect(described_class.sort_by_attribute(nil).first).to eq(@user2)
end
end
@@ -1448,8 +1508,8 @@ describe User do
let!(:merge_event) { create(:event, :created, project: project3, target: merge_request, author: subject) }
before do
- project1.team << [subject, :master]
- project2.team << [subject, :master]
+ project1.add_master(subject)
+ project2.add_master(subject)
end
it "includes IDs for projects the user has pushed to" do
@@ -1524,17 +1584,66 @@ describe User do
describe '#authorized_groups' do
let!(:user) { create(:user) }
let!(:private_group) { create(:group) }
+ let!(:child_group) { create(:group, parent: private_group) }
+
+ let!(:project_group) { create(:group) }
+ let!(:project) { create(:project, group: project_group) }
before do
private_group.add_user(user, Gitlab::Access::MASTER)
+ project.add_master(user)
end
subject { user.authorized_groups }
- it { is_expected.to eq([private_group]) }
+ it { is_expected.to contain_exactly private_group, project_group }
end
- describe '#authorized_projects', :truncate do
+ describe '#membership_groups' do
+ let!(:user) { create(:user) }
+ let!(:parent_group) { create(:group) }
+ let!(:child_group) { create(:group, parent: parent_group) }
+
+ before do
+ parent_group.add_user(user, Gitlab::Access::MASTER)
+ end
+
+ subject { user.membership_groups }
+
+ if Group.supports_nested_groups?
+ it { is_expected.to contain_exactly parent_group, child_group }
+ else
+ it { is_expected.to contain_exactly parent_group }
+ end
+ end
+
+ describe '#authorizations_for_projects' do
+ let!(:user) { create(:user) }
+ subject { Project.where("EXISTS (?)", user.authorizations_for_projects) }
+
+ it 'includes projects that belong to a user, but no other projects' do
+ owned = create(:project, :private, namespace: user.namespace)
+ member = create(:project, :private).tap { |p| p.add_master(user) }
+ other = create(:project)
+
+ expect(subject).to include(owned)
+ expect(subject).to include(member)
+ expect(subject).not_to include(other)
+ end
+
+ it 'includes projects a user has access to, but no other projects' do
+ other_user = create(:user)
+ accessible = create(:project, :private, namespace: other_user.namespace) do |project|
+ project.add_developer(user)
+ end
+ other = create(:project)
+
+ expect(subject).to include(accessible)
+ expect(subject).not_to include(other)
+ end
+ end
+
+ describe '#authorized_projects', :delete do
context 'with a minimum access level' do
it 'includes projects for which the user is an owner' do
user = create(:user)
@@ -1548,7 +1657,7 @@ describe User do
user = create(:user)
project = create(:project, :private)
- project.team << [user, Gitlab::Access::MASTER]
+ project.add_master(user)
expect(user.authorized_projects(Gitlab::Access::REPORTER))
.to contain_exactly(project)
@@ -1567,7 +1676,7 @@ describe User do
user2 = create(:user)
project = create(:project, :private, namespace: user1.namespace)
- project.team << [user2, Gitlab::Access::DEVELOPER]
+ project.add_developer(user2)
expect(user2.authorized_projects).to include(project)
end
@@ -1612,7 +1721,7 @@ describe User do
user2 = create(:user)
project = create(:project, :private, namespace: user1.namespace)
- project.team << [user2, Gitlab::Access::DEVELOPER]
+ project.add_developer(user2)
expect(user2.authorized_projects).to include(project)
@@ -1702,7 +1811,7 @@ describe User do
shared_examples :member do
context 'when the user is a master' do
before do
- add_user(Gitlab::Access::MASTER)
+ add_user(:master)
end
it 'loads' do
@@ -1712,7 +1821,7 @@ describe User do
context 'when the user is a developer' do
before do
- add_user(Gitlab::Access::DEVELOPER)
+ add_user(:developer)
end
it 'does not load' do
@@ -1736,7 +1845,22 @@ describe User do
let(:project) { create(:project) }
def add_user(access)
- project.team << [user, access]
+ project.add_role(user, access)
+ end
+
+ it_behaves_like :member
+ end
+
+ context 'with subgroup with different owner for project runner', :nested_groups do
+ let(:group) { create(:group) }
+ let(:another_user) { create(:user) }
+ let(:subgroup) { create(:group, parent: group) }
+ let(:project) { create(:project, group: subgroup) }
+
+ def add_user(access)
+ group.add_user(user, access)
+ group.add_user(another_user, :owner)
+ subgroup.add_user(another_user, :owner)
end
it_behaves_like :member
@@ -1749,8 +1873,8 @@ describe User do
let(:user) { create(:user) }
before do
- project1.team << [user, :reporter]
- project2.team << [user, :guest]
+ project1.add_reporter(user)
+ project2.add_guest(user)
end
it 'returns the projects when using a single project ID' do
@@ -1892,8 +2016,8 @@ describe User do
let(:user) { create(:user) }
before do
- project1.team << [user, :reporter]
- project2.team << [user, :guest]
+ project1.add_reporter(user)
+ project2.add_guest(user)
user.project_authorizations.delete_all
user.refresh_authorized_projects
@@ -1962,6 +2086,8 @@ describe User do
expect(ghost).to be_ghost
expect(ghost).to be_persisted
+ expect(ghost.namespace).not_to be_nil
+ expect(ghost.namespace).to be_persisted
end
it "does not create a second ghost user if one is already present" do
@@ -2123,6 +2249,20 @@ describe User do
end
end
+ context '#invalidate_personal_projects_count' do
+ let(:user) { build_stubbed(:user) }
+
+ it 'invalidates cache for personal projects counter' do
+ cache_mock = double
+
+ expect(cache_mock).to receive(:delete).with(['users', user.id, 'personal_projects_count'])
+
+ allow(Rails).to receive(:cache).and_return(cache_mock)
+
+ user.invalidate_personal_projects_count
+ end
+ end
+
describe '#allow_password_authentication_for_web?' do
context 'regular user' do
let(:user) { build(:user) }
@@ -2172,11 +2312,9 @@ describe User do
user = build(:user)
projects = double(:projects, count: 1)
- expect(user).to receive(:personal_projects).once.and_return(projects)
+ expect(user).to receive(:personal_projects).and_return(projects)
- 2.times do
- expect(user.personal_projects_count).to eq(1)
- end
+ expect(user.personal_projects_count).to eq(1)
end
end
@@ -2231,17 +2369,17 @@ describe User do
end
context 'when there is a validation error (namespace name taken) while updating namespace' do
- let!(:conflicting_namespace) { create(:group, name: new_username, path: 'quz') }
+ let!(:conflicting_namespace) { create(:group, path: new_username) }
it 'causes the user save to fail' do
expect(user.update_attributes(username: new_username)).to be_falsey
- expect(user.namespace.errors.messages[:name].first).to eq('has already been taken')
+ expect(user.namespace.errors.messages[:path].first).to eq('has already been taken')
end
it 'adds the namespace errors to the user' do
user.update_attributes(username: new_username)
- expect(user.errors.full_messages.first).to eq('Namespace name has already been taken')
+ expect(user.errors.full_messages.first).to eq('Username has already been taken')
end
end
end
@@ -2573,27 +2711,19 @@ describe User do
end
end
- describe "#username_previously_taken?" do
- let(:user1) { create(:user, username: 'foo') }
-
- context 'when the username has been taken before' do
- before do
- user1.username = 'bar'
- user1.save!
- end
+ context 'changing a username' do
+ let(:user) { create(:user, username: 'foo') }
- it 'should raise an ActiveRecord::RecordInvalid exception' do
- user2 = build(:user, username: 'foo')
- expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Path foo has been taken before/)
- end
+ it 'creates a redirect route' do
+ expect { user.update!(username: 'bar') }
+ .to change { RedirectRoute.where(path: 'foo').count }.by(1)
end
- context 'when the username has not been taken before' do
- it 'should be valid' do
- expect(RedirectRoute.count).to eq(0)
- user2 = build(:user, username: 'baz')
- expect(user2).to be_valid
- end
+ it 'deletes the redirect when a user with the old username was created' do
+ user.update!(username: 'bar')
+
+ expect { create(:user, username: 'foo') }
+ .to change { RedirectRoute.where(path: 'foo').count }.by(-1)
end
end
end
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index ea75434e399..b2b7721674c 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -188,51 +188,182 @@ describe WikiPage do
end
end
- describe "#update" do
- before do
- create_page("Update", "content")
- @page = wiki.find_page("Update")
+ describe '#create' do
+ shared_examples 'create method' do
+ context 'with valid attributes' do
+ it 'raises an error if a page with the same path already exists' do
+ create_page('New Page', 'content')
+ create_page('foo/bar', 'content')
+ expect { create_page('New Page', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError
+ expect { create_page('foo/bar', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError
+
+ destroy_page('New Page')
+ destroy_page('bar', 'foo')
+ end
+
+ it 'if the title is preceded by a / it is removed' do
+ create_page('/New Page', 'content')
+
+ expect(wiki.find_page('New Page')).not_to be_nil
+
+ destroy_page('New Page')
+ end
+ end
end
- after do
- destroy_page(@page.title)
+ context 'when Gitaly is enabled' do
+ it_behaves_like 'create method'
end
- context "with valid attributes" do
- it "updates the content of the page" do
- new_content = "new content"
+ context 'when Gitaly is disabled', :skip_gitaly_mock do
+ it_behaves_like 'create method'
+ end
+ end
- @page.update(content: new_content)
+ describe "#update" do
+ shared_examples 'update method' do
+ before do
+ create_page("Update", "content")
@page = wiki.find_page("Update")
+ end
- expect(@page.content).to eq("new content")
+ after do
+ destroy_page(@page.title, @page.directory)
end
- it "updates the title of the page" do
- new_title = "Index v.1.2.4"
+ context "with valid attributes" do
+ it "updates the content of the page" do
+ 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)
- @page.update(title: new_title)
- @page = wiki.find_page(new_title)
+ expect(@page.title).to eq(new_title)
+ end
- expect(@page.title).to eq(new_title)
+ it "returns true" do
+ expect(@page.update(content: "more content")).to be_truthy
+ end
end
- it "returns true" do
- expect(@page.update(content: "more content")).to be_truthy
+ 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
- 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
+ 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
- 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)
+ context 'when renaming a page' do
+ it 'raises an error if the page already exists' do
+ create_page('Existing Page', 'content')
+
+ expect { @page.update(title: 'Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError)
+ expect(@page.title).to eq 'Update'
+ expect(@page.content).to eq 'new_content'
+
+ destroy_page('Existing Page')
+ end
+
+ it 'updates the content and rename the file' do
+ new_title = 'Renamed Page'
+ new_content = 'updated content'
+
+ expect(@page.update(title: new_title, content: new_content)).to be_truthy
+
+ @page = wiki.find_page(new_title)
+
+ expect(@page).not_to be_nil
+ expect(@page.content).to eq new_content
+ end
+ end
+
+ context 'when moving a page' do
+ it 'raises an error if the page already exists' do
+ create_page('foo/Existing Page', 'content')
+
+ expect { @page.update(title: 'foo/Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError)
+ expect(@page.title).to eq 'Update'
+ expect(@page.content).to eq 'new_content'
+
+ destroy_page('Existing Page', 'foo')
+ end
+
+ it 'updates the content and moves the file' do
+ new_title = 'foo/Other Page'
+ new_content = 'new_content'
+
+ expect(@page.update(title: new_title, content: new_content)).to be_truthy
+
+ page = wiki.find_page(new_title)
+
+ expect(page).not_to be_nil
+ expect(page.content).to eq new_content
+ end
+
+ context 'in subdir' do
+ before do
+ create_page('foo/Existing Page', 'content')
+ @page = wiki.find_page('foo/Existing Page')
+ end
+
+ it 'moves the page to the root folder if the title is preceded by /', :skip_gitaly_mock do
+ expect(@page.slug).to eq 'foo/Existing-Page'
+ expect(@page.update(title: '/Existing Page', content: 'new_content')).to be_truthy
+ expect(@page.slug).to eq 'Existing-Page'
+ end
+
+ it 'does nothing if it has the same title' do
+ original_path = @page.slug
+
+ expect(@page.update(title: 'Existing Page', content: 'new_content')).to be_truthy
+ expect(@page.slug).to eq original_path
+ end
+ end
+
+ context 'in root dir' do
+ it 'does nothing if the title is preceded by /' do
+ original_path = @page.slug
+
+ expect(@page.update(title: '/Update', content: 'new_content')).to be_truthy
+ expect(@page.slug).to eq original_path
+ end
+ end
+ end
+
+ context "with invalid attributes" do
+ it 'aborts update if title blank' do
+ expect(@page.update(title: '', content: 'new_content')).to be_falsey
+ expect(@page.content).to eq 'new_content'
+
+ page = wiki.find_page('Update')
+ expect(page.content).to eq 'content'
+
+ @page.title = 'Update'
+ end
end
end
+
+ context 'when Gitaly is enabled' do
+ it_behaves_like 'update method'
+ end
+
+ context 'when Gitaly is disabled', :skip_gitaly_mock do
+ it_behaves_like 'update method'
+ end
end
describe "#destroy" do
@@ -252,18 +383,34 @@ describe WikiPage do
end
describe "#versions" do
- before do
- create_page("Update", "content")
- @page = wiki.find_page("Update")
+ shared_examples 'wiki page versions' do
+ let(:page) { wiki.find_page("Update") }
+
+ before do
+ create_page("Update", "content")
+ end
+
+ after do
+ destroy_page("Update")
+ end
+
+ it "returns an array of all commits for the page" do
+ 3.times { |i| page.update(content: "content #{i}") }
+
+ expect(page.versions.count).to eq(4)
+ end
+
+ it 'returns instances of WikiPageVersion' do
+ expect(page.versions).to all( be_a(Gitlab::Git::WikiPageVersion) )
+ end
end
- after do
- destroy_page("Update")
+ context 'when Gitaly is enabled' do
+ it_behaves_like 'wiki page versions'
end
- it "returns an array of all commits for the page" do
- 3.times { |i| @page.update(content: "content #{i}") }
- expect(@page.versions.count).to eq(4)
+ context 'when Gitaly is disabled', :disable_gitaly do
+ it_behaves_like 'wiki page versions'
end
end
@@ -386,6 +533,27 @@ describe WikiPage do
end
end
+ describe '#formatted_content' do
+ shared_examples 'fetching page formatted content' do
+ it 'returns processed content of the page' do
+ subject.create({ title: "RDoc", content: "*bold*", format: "rdoc" })
+ page = wiki.find_page('RDoc')
+
+ expect(page.formatted_content).to eq("\n<p><strong>bold</strong></p>\n")
+
+ destroy_page('RDoc')
+ end
+ end
+
+ context 'when Gitaly wiki_page_formatted_data is enabled' do
+ it_behaves_like 'fetching page formatted content'
+ end
+
+ context 'when Gitaly wiki_page_formatted_data is disabled', :disable_gitaly do
+ it_behaves_like 'fetching page formatted content'
+ end
+ end
+
private
def remove_temp_repo(path)
@@ -400,8 +568,8 @@ describe WikiPage do
wiki.wiki.write_page(name, :markdown, content, commit_details)
end
- def destroy_page(title)
- page = wiki.wiki.page(title: title)
+ def destroy_page(title, dir = '')
+ page = wiki.wiki.page(title: title, dir: dir)
wiki.delete_page(page, "test commit")
end
diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb
index 298a9d16425..41cf2ef7225 100644
--- a/spec/policies/ci/build_policy_spec.rb
+++ b/spec/policies/ci/build_policy_spec.rb
@@ -57,7 +57,7 @@ describe Ci::BuildPolicy do
context 'team member is a guest' do
before do
- project.team << [user, :guest]
+ project.add_guest(user)
end
context 'when public builds are enabled' do
@@ -77,7 +77,7 @@ describe Ci::BuildPolicy do
context 'team member is a reporter' do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
context 'when public builds are enabled' do
diff --git a/spec/policies/ci/pipeline_schedule_policy_spec.rb b/spec/policies/ci/pipeline_schedule_policy_spec.rb
index 1b0e9fac355..c0c3eda4911 100644
--- a/spec/policies/ci/pipeline_schedule_policy_spec.rb
+++ b/spec/policies/ci/pipeline_schedule_policy_spec.rb
@@ -88,5 +88,19 @@ describe Ci::PipelineSchedulePolicy, :models do
expect(policy).to be_allowed :admin_pipeline_schedule
end
end
+
+ describe 'rules for non-owner of schedule' do
+ let(:owner) { create(:user) }
+
+ before do
+ project.add_master(owner)
+ project.add_master(user)
+ pipeline_schedule.update(owner: owner)
+ end
+
+ it 'includes abilities to take ownership' do
+ expect(policy).to be_allowed :take_ownership_pipeline_schedule
+ end
+ end
end
end
diff --git a/spec/policies/ci/trigger_policy_spec.rb b/spec/policies/ci/trigger_policy_spec.rb
index be40dbb2aa9..14630748c90 100644
--- a/spec/policies/ci/trigger_policy_spec.rb
+++ b/spec/policies/ci/trigger_policy_spec.rb
@@ -45,7 +45,7 @@ describe Ci::TriggerPolicy do
context 'when user is master of the project' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it_behaves_like 'allows to admin and manage trigger'
@@ -53,7 +53,7 @@ describe Ci::TriggerPolicy do
context 'when user is developer of the project' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it_behaves_like 'disallows to admin and manage trigger'
@@ -69,7 +69,7 @@ describe Ci::TriggerPolicy do
context 'when user is master of the project' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it_behaves_like 'allows to admin and manage trigger'
@@ -81,7 +81,7 @@ describe Ci::TriggerPolicy do
context 'when user is master of the project' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it_behaves_like 'allows to manage trigger'
@@ -89,7 +89,7 @@ describe Ci::TriggerPolicy do
context 'when user is developer of the project' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it_behaves_like 'disallows to admin and manage trigger'
diff --git a/spec/policies/deploy_token_policy_spec.rb b/spec/policies/deploy_token_policy_spec.rb
new file mode 100644
index 00000000000..eea287d895e
--- /dev/null
+++ b/spec/policies/deploy_token_policy_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+describe DeployTokenPolicy do
+ let(:current_user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:deploy_token) { create(:deploy_token, projects: [project]) }
+
+ subject { described_class.new(current_user, deploy_token) }
+
+ describe 'creating a deploy key' do
+ context 'when user is master' do
+ before do
+ project.add_master(current_user)
+ end
+
+ it { is_expected.to be_allowed(:create_deploy_token) }
+ end
+
+ context 'when user is not master' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it { is_expected.to be_disallowed(:create_deploy_token) }
+ end
+ end
+
+ describe 'updating a deploy key' do
+ context 'when user is master' do
+ before do
+ project.add_master(current_user)
+ end
+
+ it { is_expected.to be_allowed(:update_deploy_token) }
+ end
+
+ context 'when user is not master' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ it { is_expected.to be_disallowed(:update_deploy_token) }
+ end
+ end
+end
diff --git a/spec/policies/issuable_policy_spec.rb b/spec/policies/issuable_policy_spec.rb
index 2cf669e8191..d1bf98995e7 100644
--- a/spec/policies/issuable_policy_spec.rb
+++ b/spec/policies/issuable_policy_spec.rb
@@ -1,12 +1,14 @@
require 'spec_helper'
describe IssuablePolicy, models: true do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public) }
+ let(:issue) { create(:issue, project: project) }
+ let(:policies) { described_class.new(user, issue) }
+
describe '#rules' do
context 'when discussion is locked for the issuable' do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, discussion_locked: true) }
- let(:policies) { described_class.new(user, issue) }
context 'when the user is not a project member' do
it 'can not create a note' do
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index be4c24c727c..793b724bfca 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -19,10 +19,10 @@ describe IssuePolicy do
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]
+ project.add_guest(guest)
+ project.add_guest(author)
+ project.add_guest(assignee)
+ project.add_reporter(reporter)
group.add_reporter(reporter_from_group_link)
@@ -30,41 +30,41 @@ describe IssuePolicy do
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)
+ expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :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_allowed(:read_issue, :read_issue_iid)
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_allowed(:read_issue, :read_issue_iid)
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)
+ expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :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)
+ expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :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_allowed(:read_issue, :read_issue_iid, :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_allowed(:read_issue, :read_issue_iid)
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_allowed(:read_issue, :read_issue_iid, :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_allowed(:read_issue, :read_issue_iid)
expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
end
@@ -73,37 +73,37 @@ describe IssuePolicy do
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)).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)
+ expect(permissions(non_member, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :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)
+ expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :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)
+ expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :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 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)
+ expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_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_allowed(:read_issue, :read_issue_iid, :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)
+ expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :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_allowed(:read_issue, :read_issue_iid, :update_issue)
expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue)
- expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
end
end
end
@@ -114,8 +114,8 @@ describe IssuePolicy do
let(:issue_no_assignee) { create(:issue, project: project) }
before do
- project.team << [guest, :guest]
- project.team << [reporter, :reporter]
+ project.add_guest(guest)
+ project.add_reporter(reporter)
group.add_reporter(reporter_from_group_link)
@@ -123,36 +123,36 @@ describe IssuePolicy do
end
it 'allows guests to read issues' do
- expect(permissions(guest, issue)).to be_allowed(:read_issue)
+ expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid)
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_allowed(:read_issue, :read_issue_iid)
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)
+ expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :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)
+ expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :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_allowed(:read_issue, :read_issue_iid, :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_allowed(:read_issue, :read_issue_iid)
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_allowed(:read_issue, :read_issue_iid, :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_allowed(:read_issue, :read_issue_iid)
expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue)
end
@@ -161,32 +161,32 @@ describe IssuePolicy do
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)).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)
+ expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :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)
+ expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :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)
+ expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
+ expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_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_allowed(:read_issue, :read_issue_iid, :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)
+ expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :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_allowed(:read_issue, :read_issue_iid, :update_issue)
expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue)
- expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue)
+ expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
end
end
end
diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb
index b70c8646a3d..50bb0899eba 100644
--- a/spec/policies/personal_snippet_policy_spec.rb
+++ b/spec/policies/personal_snippet_policy_spec.rb
@@ -1,5 +1,6 @@
require 'spec_helper'
+# Snippet visibility scenarios are included in more details in spec/support/snippet_visibility.rb
describe PersonalSnippetPolicy do
let(:regular_user) { create(:user) }
let(:external_user) { create(:user, :external) }
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index f2593a1a75c..905d82b3bb1 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -11,10 +11,10 @@ describe ProjectPolicy do
let(:base_guest_permissions) do
%i[
- read_project read_board read_list read_wiki read_issue read_label
- read_milestone read_project_snippet read_project_member
- read_note create_project create_issue create_note
- upload_file
+ read_project read_board read_list read_wiki read_issue
+ read_project_for_iids read_issue_iid read_merge_request_iid read_label
+ read_milestone read_project_snippet read_project_member read_note
+ create_project create_issue create_note upload_file
]
end
@@ -92,7 +92,7 @@ describe ProjectPolicy do
it 'does not include the read_issue permission when the issue author is not a member of the private project' do
project = create(:project, :private)
- issue = create(:issue, project: project)
+ issue = create(:issue, project: project, author: create(:user))
user = issue.author
expect(project.team.member?(issue.author)).to be false
@@ -120,7 +120,7 @@ describe ProjectPolicy do
project.issues_enabled = false
project.save!
- expect_disallowed :read_issue, :create_issue, :update_issue, :admin_issue
+ expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue
end
end
@@ -131,7 +131,7 @@ describe ProjectPolicy do
project.issues_enabled = false
project.save!
- expect_disallowed :read_issue, :create_issue, :update_issue, :admin_issue
+ expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue
end
end
end
@@ -308,4 +308,41 @@ describe ProjectPolicy do
it_behaves_like 'project policies as master'
it_behaves_like 'project policies as owner'
it_behaves_like 'project policies as admin'
+
+ context 'when a public project has merge requests allowing access' do
+ include ProjectForksHelper
+ let(:user) { create(:user) }
+ let(:target_project) { create(:project, :public) }
+ let(:project) { fork_project(target_project) }
+ let!(:merge_request) do
+ create(
+ :merge_request,
+ target_project: target_project,
+ source_project: project,
+ allow_maintainer_to_push: true
+ )
+ end
+ let(:maintainer_abilities) do
+ %w(create_build update_build create_pipeline update_pipeline)
+ end
+
+ subject { described_class.new(user, project) }
+
+ it 'does not allow pushing code' do
+ expect_disallowed(*maintainer_abilities)
+ end
+
+ it 'allows pushing if the user is a member with push access to the target project' do
+ target_project.add_developer(user)
+
+ expect_allowed(*maintainer_abilities)
+ end
+
+ it 'dissallows abilities to a maintainer if the merge request was closed' do
+ target_project.add_developer(user)
+ merge_request.close!
+
+ expect_disallowed(*maintainer_abilities)
+ end
+ end
end
diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb
index f0bf46c480a..4d32e06b553 100644
--- a/spec/policies/project_snippet_policy_spec.rb
+++ b/spec/policies/project_snippet_policy_spec.rb
@@ -1,5 +1,6 @@
require 'spec_helper'
+# Snippet visibility scenarios are included in more details in spec/support/snippet_visibility.rb
describe ProjectSnippetPolicy do
let(:regular_user) { create(:user) }
let(:external_user) { create(:user, :external) }
@@ -87,7 +88,7 @@ describe ProjectSnippetPolicy do
subject { abilities(external_user, :internal) }
before do
- project.team << [external_user, :developer]
+ project.add_developer(external_user)
end
it do
@@ -131,7 +132,7 @@ describe ProjectSnippetPolicy do
subject { abilities(regular_user, :private) }
before do
- project.team << [regular_user, :developer]
+ project.add_developer(regular_user)
end
it do
@@ -144,7 +145,7 @@ describe ProjectSnippetPolicy do
subject { abilities(external_user, :private) }
before do
- project.team << [external_user, :developer]
+ project.add_developer(external_user)
end
it do
diff --git a/spec/policies/protected_branch_policy_spec.rb b/spec/policies/protected_branch_policy_spec.rb
new file mode 100644
index 00000000000..b39de42d721
--- /dev/null
+++ b/spec/policies/protected_branch_policy_spec.rb
@@ -0,0 +1,22 @@
+require 'spec_helper'
+
+describe ProtectedBranchPolicy do
+ let(:user) { create(:user) }
+ let(:name) { 'feature' }
+ let(:protected_branch) { create(:protected_branch, name: name) }
+ let(:project) { protected_branch.project }
+
+ subject { described_class.new(user, protected_branch) }
+
+ it 'branches can be updated via project masters' do
+ project.add_master(user)
+
+ is_expected.to be_allowed(:update_protected_branch)
+ end
+
+ it "branches can't be updated by guests" do
+ project.add_guest(user)
+
+ is_expected.to be_disallowed(:update_protected_branch)
+ end
+end
diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb
index 1a8001be6ab..cc16d0f156b 100644
--- a/spec/presenters/ci/build_presenter_spec.rb
+++ b/spec/presenters/ci/build_presenter_spec.rb
@@ -72,13 +72,44 @@ describe Ci::BuildPresenter do
end
end
- context 'when build is not auto-canceled' do
- before do
- expect(build).to receive(:auto_canceled?).and_return(false)
+ context 'when build failed' do
+ let(:build) { create(:ci_build, :failed, pipeline: pipeline) }
+
+ it 'returns the reason of failure' do
+ status_title = presenter.status_title
+
+ expect(status_title).to eq('Failed <br> (unknown failure)')
end
+ end
+
+ context 'when build has failed && retried' do
+ let(:build) { create(:ci_build, :failed, :retried, pipeline: pipeline) }
- it 'does not have a status title' do
- expect(presenter.status_title).to be_nil
+ it 'does not include retried title' do
+ status_title = presenter.status_title
+
+ expect(status_title).not_to include('(retried)')
+ expect(status_title).to eq('Failed <br> (unknown failure)')
+ end
+ end
+
+ context 'when build has failed and is allowed to' do
+ let(:build) { create(:ci_build, :failed, :allowed_to_fail, pipeline: pipeline) }
+
+ it 'returns the reason of failure' do
+ status_title = presenter.status_title
+
+ expect(status_title).to eq('Failed <br> (unknown failure)')
+ end
+ end
+
+ context 'For any other build' do
+ let(:build) { create(:ci_build, :success, pipeline: pipeline) }
+
+ it 'returns the status' do
+ tooltip_description = presenter.status_title
+
+ expect(tooltip_description).to eq('Success')
end
end
end
@@ -134,4 +165,56 @@ describe Ci::BuildPresenter do
end
end
end
+
+ describe '#tooltip_message' do
+ context 'When build has failed' do
+ let(:build) { create(:ci_build, :script_failure, pipeline: pipeline) }
+
+ it 'returns the reason of failure' do
+ tooltip = subject.tooltip_message
+
+ expect(tooltip).to eq("#{build.name} - failed <br> (script failure)")
+ end
+ end
+
+ context 'When build has failed and retried' do
+ let(:build) { create(:ci_build, :script_failure, :retried, pipeline: pipeline) }
+
+ it 'should include the reason of failure and the retried title' do
+ tooltip = subject.tooltip_message
+
+ expect(tooltip).to eq("#{build.name} - failed <br> (script failure) (retried)")
+ end
+ end
+
+ context 'When build has failed and is allowed to' do
+ let(:build) { create(:ci_build, :script_failure, :allowed_to_fail, pipeline: pipeline) }
+
+ it 'should include the reason of failure' do
+ tooltip = subject.tooltip_message
+
+ expect(tooltip).to eq("#{build.name} - failed <br> (script failure) (allowed to fail)")
+ end
+ end
+
+ context 'For any other build (no retried)' do
+ let(:build) { create(:ci_build, :success, pipeline: pipeline) }
+
+ it 'should include build name and status' do
+ tooltip = subject.tooltip_message
+
+ expect(tooltip).to eq("#{build.name} - passed")
+ end
+ end
+
+ context 'For any other build (retried)' do
+ let(:build) { create(:ci_build, :success, :retried, pipeline: pipeline) }
+
+ it 'should include build name and status' do
+ tooltip = subject.tooltip_message
+
+ expect(tooltip).to eq("#{build.name} - passed (retried)")
+ end
+ end
+ end
end
diff --git a/spec/presenters/ci/group_variable_presenter_spec.rb b/spec/presenters/ci/group_variable_presenter_spec.rb
index d404028405b..cb58a757564 100644
--- a/spec/presenters/ci/group_variable_presenter_spec.rb
+++ b/spec/presenters/ci/group_variable_presenter_spec.rb
@@ -35,29 +35,20 @@ describe Ci::GroupVariablePresenter do
end
describe '#form_path' do
- context 'when variable is persisted' do
- subject { described_class.new(variable).form_path }
+ 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
+ it { is_expected.to eq(group_settings_ci_cd_path(group)) }
end
describe '#edit_path' do
subject { described_class.new(variable).edit_path }
- it { is_expected.to eq(group_variable_path(group, variable)) }
+ it { is_expected.to eq(group_variables_path(group)) }
end
describe '#delete_path' do
subject { described_class.new(variable).delete_path }
- it { is_expected.to eq(group_variable_path(group, variable)) }
+ it { is_expected.to eq(group_variables_path(group)) }
end
end
diff --git a/spec/presenters/ci/variable_presenter_spec.rb b/spec/presenters/ci/variable_presenter_spec.rb
index db62f86edb0..e3ce88372ea 100644
--- a/spec/presenters/ci/variable_presenter_spec.rb
+++ b/spec/presenters/ci/variable_presenter_spec.rb
@@ -35,29 +35,20 @@ describe Ci::VariablePresenter do
end
describe '#form_path' do
- context 'when variable is persisted' do
- subject { described_class.new(variable).form_path }
+ 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
+ it { is_expected.to eq(project_settings_ci_cd_path(project)) }
end
describe '#edit_path' do
subject { described_class.new(variable).edit_path }
- it { is_expected.to eq(project_variable_path(project, variable)) }
+ it { is_expected.to eq(project_variables_path(project)) }
end
describe '#delete_path' do
subject { described_class.new(variable).delete_path }
- it { is_expected.to eq(project_variable_path(project, variable)) }
+ it { is_expected.to eq(project_variables_path(project)) }
end
end
diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb
index f325d1776e4..e3b37739e8e 100644
--- a/spec/presenters/merge_request_presenter_spec.rb
+++ b/spec/presenters/merge_request_presenter_spec.rb
@@ -116,7 +116,7 @@ describe MergeRequestPresenter do
end
before do
- project.team << [user, :developer]
+ project.add_developer(user)
allow(resource.project).to receive(:default_branch)
.and_return(resource.target_branch)
@@ -270,7 +270,7 @@ describe MergeRequestPresenter do
context 'when can create issue and issues enabled' do
it 'returns path' do
allow(project).to receive(:issues_enabled?) { true }
- project.team << [user, :master]
+ project.add_master(user)
is_expected
.to eq("/#{resource.project.full_path}/issues/new?merge_request_to_resolve_discussions_of=#{resource.iid}")
@@ -288,7 +288,7 @@ describe MergeRequestPresenter do
context 'when issues disabled' do
it 'returns nil' do
allow(project).to receive(:issues_enabled?) { false }
- project.team << [user, :master]
+ project.add_master(user)
is_expected.to be_nil
end
@@ -307,7 +307,7 @@ describe MergeRequestPresenter do
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]
+ project.add_master(user)
is_expected
.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}/remove_wip")
@@ -404,4 +404,67 @@ describe MergeRequestPresenter do
.to eq("<a href=\"/#{resource.source_project.full_path}/tree/#{resource.source_branch}\">#{resource.source_branch}</a>")
end
end
+
+ describe '#rebase_path' do
+ before do
+ allow(resource).to receive(:rebase_in_progress?) { rebase_in_progress }
+ allow(resource).to receive(:should_be_rebased?) { should_be_rebased }
+
+ allow_any_instance_of(Gitlab::UserAccess::RequestCacheExtension)
+ .to receive(:can_push_to_branch?)
+ .with(resource.source_branch)
+ .and_return(can_push_to_branch)
+ end
+
+ subject do
+ described_class.new(resource, current_user: user).rebase_path
+ end
+
+ context 'when can rebase' do
+ let(:rebase_in_progress) { false }
+ let(:can_push_to_branch) { true }
+ let(:should_be_rebased) { true }
+
+ before do
+ allow(resource).to receive(:source_branch_exists?) { true }
+ end
+
+ it 'returns path' do
+ is_expected
+ .to eq("/#{project.full_path}/merge_requests/#{resource.iid}/rebase")
+ end
+ end
+
+ context 'when cannot rebase' do
+ context 'when rebase in progress' do
+ let(:rebase_in_progress) { true }
+ let(:can_push_to_branch) { true }
+ let(:should_be_rebased) { true }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'when user cannot merge' do
+ let(:rebase_in_progress) { false }
+ let(:can_push_to_branch) { false }
+ let(:should_be_rebased) { true }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'should not be rebased' do
+ let(:rebase_in_progress) { false }
+ let(:can_push_to_branch) { true }
+ let(:should_be_rebased) { false }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+ end
+ end
end
diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb
new file mode 100644
index 00000000000..55962f345d4
--- /dev/null
+++ b/spec/presenters/project_presenter_spec.rb
@@ -0,0 +1,397 @@
+require 'spec_helper'
+
+describe ProjectPresenter do
+ let(:user) { create(:user) }
+
+ describe '#license_short_name' do
+ let(:project) { create(:project) }
+ let(:presenter) { described_class.new(project, current_user: user) }
+
+ context 'when project.repository has a license_key' do
+ it 'returns the nickname of the license if present' do
+ allow(project.repository).to receive(:license_key).and_return('agpl-3.0')
+
+ expect(presenter.license_short_name).to eq('GNU AGPLv3')
+ end
+
+ it 'returns the name of the license if nickname is not present' do
+ allow(project.repository).to receive(:license_key).and_return('mit')
+
+ expect(presenter.license_short_name).to eq('MIT License')
+ end
+ end
+
+ context 'when project.repository has no license_key but a license_blob' do
+ it 'returns LICENSE' do
+ allow(project.repository).to receive(:license_key).and_return(nil)
+
+ expect(presenter.license_short_name).to eq('LICENSE')
+ end
+ end
+ end
+
+ describe '#default_view' do
+ let(:presenter) { described_class.new(project, current_user: user) }
+
+ context 'user not signed in' do
+ let(:user) { nil }
+
+ context 'when repository is empty' do
+ let(:project) { create(:project_empty_repo, :public) }
+
+ it 'returns activity if user has repository access' do
+ allow(presenter).to receive(:can?).with(nil, :download_code, project).and_return(true)
+
+ expect(presenter.default_view).to eq('activity')
+ end
+
+ it 'returns activity if user does not have repository access' do
+ allow(project).to receive(:can?).with(nil, :download_code, project).and_return(false)
+
+ expect(presenter.default_view).to eq('activity')
+ end
+ end
+
+ context 'when repository is not empty' do
+ let(:project) { create(:project, :public, :repository) }
+
+ it 'returns files and readme if user has repository access' do
+ allow(presenter).to receive(:can?).with(nil, :download_code, project).and_return(true)
+
+ expect(presenter.default_view).to eq('files')
+ end
+
+ it 'returns activity if user does not have repository access' do
+ allow(presenter).to receive(:can?).with(nil, :download_code, project).and_return(false)
+
+ expect(presenter.default_view).to eq('activity')
+ end
+ end
+ end
+
+ context 'user signed in' do
+ let(:user) { create(:user, :readme) }
+ let(:project) { create(:project, :public, :repository) }
+
+ context 'when the user is allowed to see the code' do
+ it 'returns the project view' do
+ allow(presenter).to receive(:can?).with(user, :download_code, project).and_return(true)
+
+ expect(presenter.default_view).to eq('readme')
+ end
+ end
+
+ context 'with wikis enabled and the right policy for the user' do
+ before do
+ project.project_feature.update_attribute(:issues_access_level, 0)
+ allow(presenter).to receive(:can?).with(user, :download_code, project).and_return(false)
+ end
+
+ it 'returns wiki if the user has the right policy' do
+ allow(presenter).to receive(:can?).with(user, :read_wiki, project).and_return(true)
+
+ expect(presenter.default_view).to eq('wiki')
+ end
+
+ it 'returns customize_workflow if the user does not have the right policy' do
+ allow(presenter).to receive(:can?).with(user, :read_wiki, project).and_return(false)
+
+ expect(presenter.default_view).to eq('customize_workflow')
+ end
+ end
+
+ context 'with issues as a feature available' do
+ it 'return issues' do
+ allow(presenter).to receive(:can?).with(user, :download_code, project).and_return(false)
+ allow(presenter).to receive(:can?).with(user, :read_wiki, project).and_return(false)
+
+ expect(presenter.default_view).to eq('projects/issues/issues')
+ end
+ end
+
+ context 'with no activity, no wikies and no issues' do
+ it 'returns customize_workflow as default' do
+ project.project_feature.update_attribute(:issues_access_level, 0)
+ allow(presenter).to receive(:can?).with(user, :download_code, project).and_return(false)
+ allow(presenter).to receive(:can?).with(user, :read_wiki, project).and_return(false)
+
+ expect(presenter.default_view).to eq('customize_workflow')
+ end
+ end
+ end
+ end
+
+ describe '#can_current_user_push_code?' do
+ let(:project) { create(:project, :repository) }
+ let(:presenter) { described_class.new(project, current_user: user) }
+
+ context 'empty repo' do
+ let(:project) { create(:project) }
+
+ it 'returns true if user can push_code' do
+ project.add_developer(user)
+
+ expect(presenter.can_current_user_push_code?).to be(true)
+ end
+
+ it 'returns false if user cannot push_code' do
+ project.add_reporter(user)
+
+ expect(presenter.can_current_user_push_code?).to be(false)
+ end
+ end
+
+ context 'not empty repo' do
+ let(:project) { create(:project, :repository) }
+
+ it 'returns true if user can push to default branch' do
+ project.add_developer(user)
+
+ expect(presenter.can_current_user_push_code?).to be(true)
+ end
+
+ it 'returns false if default branch is protected' do
+ project.add_developer(user)
+ create(:protected_branch, project: project, name: project.default_branch)
+
+ expect(presenter.can_current_user_push_code?).to be(false)
+ end
+ end
+ end
+
+ context 'statistics anchors' do
+ let(:project) { create(:project, :repository) }
+ let(:presenter) { described_class.new(project, current_user: user) }
+
+ describe '#files_anchor_data' do
+ it 'returns files data' do
+ expect(presenter.files_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Files (0 Bytes)',
+ link: presenter.project_tree_path(project)))
+ end
+ end
+
+ describe '#commits_anchor_data' do
+ it 'returns commits data' do
+ expect(presenter.commits_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Commits (0)',
+ link: presenter.project_commits_path(project, project.repository.root_ref)))
+ end
+ end
+
+ describe '#branches_anchor_data' do
+ it 'returns branches data' do
+ expect(presenter.branches_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: "Branches (#{project.repository.branches.size})",
+ link: presenter.project_branches_path(project)))
+ end
+ end
+
+ describe '#tags_anchor_data' do
+ it 'returns tags data' do
+ expect(presenter.tags_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: "Tags (#{project.repository.tags.size})",
+ link: presenter.project_tags_path(project)))
+ end
+ end
+
+ describe '#new_file_anchor_data' do
+ it 'returns new file data if user can push' do
+ project.add_developer(user)
+
+ expect(presenter.new_file_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: "New file",
+ link: presenter.project_new_blob_path(project, 'master'),
+ class_modifier: 'new'))
+ end
+
+ it 'returns nil if user cannot push' do
+ expect(presenter.new_file_anchor_data).to be_nil
+ end
+ end
+
+ describe '#readme_anchor_data' do
+ context 'when user can push and README does not exists' do
+ it 'returns anchor data' do
+ project.add_developer(user)
+ allow(project.repository).to receive(:readme).and_return(nil)
+
+ expect(presenter.readme_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: 'Add Readme',
+ link: presenter.add_readme_path))
+ end
+ end
+
+ context 'when README exists' do
+ it 'returns anchor data' do
+ allow(project.repository).to receive(:readme).and_return(double(name: 'readme'))
+
+ expect(presenter.readme_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Readme',
+ link: presenter.readme_path))
+ end
+ end
+ end
+
+ describe '#changelog_anchor_data' do
+ context 'when user can push and CHANGELOG does not exists' do
+ it 'returns anchor data' do
+ project.add_developer(user)
+ allow(project.repository).to receive(:changelog).and_return(nil)
+
+ expect(presenter.changelog_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: 'Add Changelog',
+ link: presenter.add_changelog_path))
+ end
+ end
+
+ context 'when CHANGELOG exists' do
+ it 'returns anchor data' do
+ allow(project.repository).to receive(:changelog).and_return(double(name: 'foo'))
+
+ expect(presenter.changelog_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Changelog',
+ link: presenter.changelog_path))
+ end
+ end
+ end
+
+ describe '#license_anchor_data' do
+ context 'when user can push and LICENSE does not exists' do
+ it 'returns anchor data' do
+ project.add_developer(user)
+ allow(project.repository).to receive(:license_blob).and_return(nil)
+
+ expect(presenter.license_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: 'Add License',
+ link: presenter.add_license_path))
+ end
+ end
+
+ context 'when LICENSE exists' do
+ it 'returns anchor data' do
+ allow(project.repository).to receive(:license_blob).and_return(double(name: 'foo'))
+
+ expect(presenter.license_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: presenter.license_short_name,
+ link: presenter.license_path))
+ end
+ end
+ end
+
+ describe '#contribution_guide_anchor_data' do
+ context 'when user can push and CONTRIBUTING does not exists' do
+ it 'returns anchor data' do
+ project.add_developer(user)
+ allow(project.repository).to receive(:contribution_guide).and_return(nil)
+
+ expect(presenter.contribution_guide_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: 'Add Contribution guide',
+ link: presenter.add_contribution_guide_path))
+ end
+ end
+
+ context 'when CONTRIBUTING exists' do
+ it 'returns anchor data' do
+ allow(project.repository).to receive(:contribution_guide).and_return(double(name: 'foo'))
+
+ expect(presenter.contribution_guide_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Contribution guide',
+ link: presenter.contribution_guide_path))
+ end
+ end
+ end
+
+ describe '#autodevops_anchor_data' do
+ context 'when Auto Devops is enabled' do
+ it 'returns anchor data' do
+ allow(project).to receive(:auto_devops_enabled?).and_return(true)
+
+ expect(presenter.autodevops_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Auto DevOps enabled',
+ link: nil))
+ end
+ end
+
+ context 'when user can admin pipeline and CI yml does not exists' do
+ it 'returns anchor data' do
+ project.add_master(user)
+ allow(project).to receive(:auto_devops_enabled?).and_return(false)
+ allow(project.repository).to receive(:gitlab_ci_yml).and_return(nil)
+
+ expect(presenter.autodevops_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: 'Enable Auto DevOps',
+ link: presenter.project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings')))
+ end
+ end
+ end
+
+ describe '#kubernetes_cluster_anchor_data' do
+ context 'when user can create Kubernetes cluster' do
+ it 'returns link to cluster if only one exists' do
+ project.add_master(user)
+ cluster = create(:cluster, projects: [project])
+
+ expect(presenter.kubernetes_cluster_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Kubernetes configured',
+ link: presenter.project_cluster_path(project, cluster)))
+ end
+
+ it 'returns link to clusters page if more than one exists' do
+ project.add_master(user)
+ create(:cluster, :production_environment, projects: [project])
+ create(:cluster, projects: [project])
+
+ expect(presenter.kubernetes_cluster_anchor_data).to eq(OpenStruct.new(enabled: true,
+ label: 'Kubernetes configured',
+ link: presenter.project_clusters_path(project)))
+ end
+
+ it 'returns link to create a cluster if no cluster exists' do
+ project.add_master(user)
+
+ expect(presenter.kubernetes_cluster_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: 'Add Kubernetes cluster',
+ link: presenter.new_project_cluster_path(project)))
+ end
+ end
+
+ context 'when user cannot create Kubernetes cluster' do
+ it 'returns nil' do
+ expect(presenter.kubernetes_cluster_anchor_data).to be_nil
+ end
+ end
+ end
+
+ describe '#koding_anchor_data' do
+ it 'returns link to setup Koding if user can push and no koding YML exists' do
+ project.add_developer(user)
+ allow(project.repository).to receive(:koding_yml).and_return(nil)
+ allow(Gitlab::CurrentSettings).to receive(:koding_enabled?).and_return(true)
+
+ expect(presenter.koding_anchor_data).to eq(OpenStruct.new(enabled: false,
+ label: 'Set up Koding',
+ link: presenter.add_koding_stack_path))
+ end
+
+ it 'returns nil if user cannot push' do
+ expect(presenter.koding_anchor_data).to be_nil
+ end
+
+ it 'returns nil if koding is not enabled' do
+ project.add_developer(user)
+ allow(Gitlab::CurrentSettings).to receive(:koding_enabled?).and_return(false)
+
+ expect(presenter.koding_anchor_data).to be_nil
+ end
+
+ it 'returns nil if koding YML already exists' do
+ project.add_developer(user)
+ allow(project.repository).to receive(:koding_yml).and_return(double)
+ allow(Gitlab::CurrentSettings).to receive(:koding_enabled?).and_return(true)
+
+ expect(presenter.koding_anchor_data).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb
index 35ca3635a9d..24389f28b21 100644
--- a/spec/requests/api/access_requests_spec.rb
+++ b/spec/requests/api/access_requests_spec.rb
@@ -8,8 +8,8 @@ describe API::AccessRequests do
set(:project) do
create(:project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
- project.team << [developer, :developer]
- project.team << [master, :master]
+ project.add_developer(developer)
+ project.add_master(master)
project.request_access(access_requester)
end
end
diff --git a/spec/requests/api/applications_spec.rb b/spec/requests/api/applications_spec.rb
new file mode 100644
index 00000000000..f56bc932f40
--- /dev/null
+++ b/spec/requests/api/applications_spec.rb
@@ -0,0 +1,86 @@
+require 'spec_helper'
+
+describe API::Applications, :api do
+ include ApiHelpers
+
+ let(:admin_user) { create(:user, admin: true) }
+ let(:user) { create(:user, admin: false) }
+
+ describe 'POST /applications' do
+ context 'authenticated and authorized user' do
+ it 'creates and returns an OAuth application' do
+ expect do
+ post api('/applications', admin_user), name: 'application_name', redirect_uri: 'http://application.url', scopes: ''
+ end.to change { Doorkeeper::Application.count }.by 1
+
+ application = Doorkeeper::Application.find_by(name: 'application_name', redirect_uri: 'http://application.url')
+
+ expect(response).to have_http_status 201
+ expect(json_response).to be_a Hash
+ expect(json_response['application_id']).to eq application.uid
+ expect(json_response['secret']).to eq application.secret
+ expect(json_response['callback_url']).to eq application.redirect_uri
+ end
+
+ it 'does not allow creating an application with the wrong redirect_uri format' do
+ expect do
+ post api('/applications', admin_user), name: 'application_name', redirect_uri: 'wrong_url_format', scopes: ''
+ end.not_to change { Doorkeeper::Application.count }
+
+ expect(response).to have_http_status 400
+ expect(json_response).to be_a Hash
+ expect(json_response['message']['redirect_uri'][0]).to eq('must be an absolute URI.')
+ end
+
+ it 'does not allow creating an application without a name' do
+ expect do
+ post api('/applications', admin_user), redirect_uri: 'http://application.url', scopes: ''
+ end.not_to change { Doorkeeper::Application.count }
+
+ expect(response).to have_http_status 400
+ expect(json_response).to be_a Hash
+ expect(json_response['error']).to eq('name is missing')
+ end
+
+ it 'does not allow creating an application without a redirect_uri' do
+ expect do
+ post api('/applications', admin_user), name: 'application_name', scopes: ''
+ end.not_to change { Doorkeeper::Application.count }
+
+ expect(response).to have_http_status 400
+ expect(json_response).to be_a Hash
+ expect(json_response['error']).to eq('redirect_uri is missing')
+ end
+
+ it 'does not allow creating an application without scopes' do
+ expect do
+ post api('/applications', admin_user), name: 'application_name', redirect_uri: 'http://application.url'
+ end.not_to change { Doorkeeper::Application.count }
+
+ expect(response).to have_http_status 400
+ expect(json_response).to be_a Hash
+ expect(json_response['error']).to eq('scopes is missing')
+ end
+ end
+
+ context 'authorized user without authorization' do
+ it 'does not create application' do
+ expect do
+ post api('/applications', user), name: 'application_name', redirect_uri: 'http://application.url', scopes: ''
+ end.not_to change { Doorkeeper::Application.count }
+
+ expect(response).to have_http_status 403
+ end
+ end
+
+ context 'non-authenticated user' do
+ it 'does not create application' do
+ expect do
+ post api('/applications'), name: 'application_name', redirect_uri: 'http://application.url'
+ end.not_to change { Doorkeeper::Application.count }
+
+ expect(response).to have_http_status 401
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb
index eaf12f71421..5adfb33677f 100644
--- a/spec/requests/api/award_emoji_spec.rb
+++ b/spec/requests/api/award_emoji_spec.rb
@@ -10,7 +10,7 @@ describe API::AwardEmoji do
set(:note) { create(:note, project: project, noteable: issue) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe "GET /projects/:id/awardable/:awardable_id/award_emoji" do
diff --git a/spec/requests/api/badges_spec.rb b/spec/requests/api/badges_spec.rb
new file mode 100644
index 00000000000..ae64a9ca162
--- /dev/null
+++ b/spec/requests/api/badges_spec.rb
@@ -0,0 +1,367 @@
+require 'spec_helper'
+
+describe API::Badges do
+ let(:master) { create(:user, username: 'master_user') }
+ let(:developer) { create(:user) }
+ let(:access_requester) { create(:user) }
+ let(:stranger) { create(:user) }
+ let(:project_group) { create(:group) }
+ let(:project) { setup_project }
+ let!(:group) { setup_group }
+
+ shared_context 'source helpers' do
+ def get_source(source_type)
+ source_type == 'project' ? project : group
+ end
+ end
+
+ shared_examples 'GET /:sources/:id/badges' do |source_type|
+ include_context 'source helpers'
+
+ let(:source) { get_source(source_type) }
+
+ context "with :sources == #{source_type.pluralize}" do
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) { get api("/#{source_type.pluralize}/#{source.id}/badges", stranger) }
+ end
+
+ %i[master developer access_requester stranger].each do |type|
+ context "when authenticated as a #{type}" do
+ it 'returns 200' do
+ user = public_send(type)
+ badges_count = source_type == 'project' ? 3 : 2
+
+ get api("/#{source_type.pluralize}/#{source.id}/badges", user)
+
+ 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(badges_count)
+ end
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ # Establish baseline
+ get api("/#{source_type.pluralize}/#{source.id}/badges", master)
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api("/#{source_type.pluralize}/#{source.id}/badges", master)
+ end
+
+ project.add_developer(create(:user))
+
+ expect do
+ get api("/#{source_type.pluralize}/#{source.id}/badges", master)
+ end.not_to exceed_query_limit(control)
+ end
+ end
+ end
+
+ shared_examples 'GET /:sources/:id/badges/:badge_id' do |source_type|
+ include_context 'source helpers'
+
+ let(:source) { get_source(source_type) }
+
+ context "with :sources == #{source_type.pluralize}" do
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) { get api("/#{source_type.pluralize}/#{source.id}/badges/#{developer.id}", stranger) }
+ end
+
+ context 'when authenticated as a non-member' do
+ %i[master developer access_requester stranger].each do |type|
+ let(:badge) { source.badges.first }
+
+ context "as a #{type}" do
+ it 'returns 200' do
+ user = public_send(type)
+
+ get api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['id']).to eq(badge.id)
+ expect(json_response['link_url']).to eq(badge.link_url)
+ expect(json_response['rendered_link_url']).to eq(badge.rendered_link_url)
+ expect(json_response['image_url']).to eq(badge.image_url)
+ expect(json_response['rendered_image_url']).to eq(badge.rendered_image_url)
+ expect(json_response['kind']).to eq source_type
+ end
+ end
+ end
+ end
+ end
+ end
+
+ shared_examples 'POST /:sources/:id/badges' do |source_type|
+ include_context 'source helpers'
+
+ let(:source) { get_source(source_type) }
+ let(:example_url) { 'http://www.example.com' }
+ let(:example_url2) { 'http://www.example1.com' }
+
+ context "with :sources == #{source_type.pluralize}" do
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) do
+ post api("/#{source_type.pluralize}/#{source.id}/badges", stranger),
+ link_url: example_url, image_url: example_url2
+ end
+ end
+
+ context 'when authenticated as a non-member or member with insufficient rights' do
+ %i[access_requester stranger developer].each do |type|
+ context "as a #{type}" do
+ it 'returns 403' do
+ user = public_send(type)
+
+ post api("/#{source_type.pluralize}/#{source.id}/badges", user),
+ link_url: example_url, image_url: example_url2
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as a master/owner' do
+ it 'creates a new badge' do
+ expect do
+ post api("/#{source_type.pluralize}/#{source.id}/badges", master),
+ link_url: example_url, image_url: example_url2
+
+ expect(response).to have_gitlab_http_status(201)
+ end.to change { source.badges.count }.by(1)
+
+ expect(json_response['link_url']).to eq(example_url)
+ expect(json_response['image_url']).to eq(example_url2)
+ expect(json_response['kind']).to eq source_type
+ end
+ end
+
+ it 'returns 400 when link_url is not given' do
+ post api("/#{source_type.pluralize}/#{source.id}/badges", master),
+ link_url: example_url
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it 'returns 400 when image_url is not given' do
+ post api("/#{source_type.pluralize}/#{source.id}/badges", master),
+ image_url: example_url2
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it 'returns 400 when link_url or image_url is not valid' do
+ post api("/#{source_type.pluralize}/#{source.id}/badges", master),
+ link_url: 'whatever', image_url: 'whatever'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+ end
+
+ shared_examples 'PUT /:sources/:id/badges/:badge_id' do |source_type|
+ include_context 'source helpers'
+
+ let(:source) { get_source(source_type) }
+
+ context "with :sources == #{source_type.pluralize}" do
+ let(:badge) { source.badges.first }
+ let(:example_url) { 'http://www.example.com' }
+ let(:example_url2) { 'http://www.example1.com' }
+
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) do
+ put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", stranger),
+ link_url: example_url
+ end
+ end
+
+ context 'when authenticated as a non-member or member with insufficient rights' do
+ %i[access_requester stranger developer].each do |type|
+ context "as a #{type}" do
+ it 'returns 403' do
+ user = public_send(type)
+
+ put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user),
+ link_url: example_url
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as a master/owner' do
+ it 'updates the member' do
+ put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master),
+ link_url: example_url, image_url: example_url2
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['link_url']).to eq(example_url)
+ expect(json_response['image_url']).to eq(example_url2)
+ expect(json_response['kind']).to eq source_type
+ end
+ end
+
+ it 'returns 400 when link_url or image_url is not valid' do
+ put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master),
+ link_url: 'whatever', image_url: 'whatever'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+ end
+
+ shared_examples 'DELETE /:sources/:id/badges/:badge_id' do |source_type|
+ include_context 'source helpers'
+
+ let(:source) { get_source(source_type) }
+
+ context "with :sources == #{source_type.pluralize}" do
+ let(:badge) { source.badges.first }
+
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", stranger) }
+ end
+
+ context 'when authenticated as a non-member or member with insufficient rights' do
+ %i[access_requester developer stranger].each do |type|
+ context "as a #{type}" do
+ it 'returns 403' do
+ user = public_send(type)
+
+ delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as a master/owner' do
+ it 'deletes the badge' do
+ expect do
+ delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master)
+
+ expect(response).to have_gitlab_http_status(204)
+ end.to change { source.badges.count }.by(-1)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master) }
+ end
+ end
+
+ it 'returns 404 if badge does not exist' do
+ delete api("/#{source_type.pluralize}/#{source.id}/badges/123", master)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+
+ shared_examples 'GET /:sources/:id/badges/render' do |source_type|
+ include_context 'source helpers'
+
+ let(:source) { get_source(source_type) }
+ let(:example_url) { 'http://www.example.com' }
+ let(:example_url2) { 'http://www.example1.com' }
+
+ context "with :sources == #{source_type.pluralize}" do
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) do
+ get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", stranger)
+ end
+ end
+
+ context 'when authenticated as a non-member or member with insufficient rights' do
+ %i[access_requester stranger developer].each do |type|
+ context "as a #{type}" do
+ it 'returns 403' do
+ user = public_send(type)
+
+ get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", user)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as a master/owner' do
+ it 'gets the rendered badge values' do
+ get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", master)
+
+ expect(response).to have_gitlab_http_status(200)
+
+ expect(json_response.keys).to contain_exactly('link_url', 'rendered_link_url', 'image_url', 'rendered_image_url')
+ expect(json_response['link_url']).to eq(example_url)
+ expect(json_response['image_url']).to eq(example_url2)
+ expect(json_response['rendered_link_url']).to eq(example_url)
+ expect(json_response['rendered_image_url']).to eq(example_url2)
+ end
+ end
+
+ it 'returns 400 when link_url is not given' do
+ get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}", master)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it 'returns 400 when image_url is not given' do
+ get api("/#{source_type.pluralize}/#{source.id}/badges/render?image_url=#{example_url}", master)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it 'returns 400 when link_url or image_url is not valid' do
+ get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=whatever&image_url=whatever", master)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+ end
+
+ context 'when deleting a badge' do
+ context 'and the source is a project' do
+ it 'cannot delete badges owned by the project group' do
+ delete api("/projects/#{project.id}/badges/#{project_group.badges.first.id}", master)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+
+ describe 'Endpoints' do
+ %w(project group).each do |source_type|
+ it_behaves_like 'GET /:sources/:id/badges', source_type
+ it_behaves_like 'GET /:sources/:id/badges/:badge_id', source_type
+ it_behaves_like 'GET /:sources/:id/badges/render', source_type
+ it_behaves_like 'POST /:sources/:id/badges', source_type
+ it_behaves_like 'PUT /:sources/:id/badges/:badge_id', source_type
+ it_behaves_like 'DELETE /:sources/:id/badges/:badge_id', source_type
+ end
+ end
+
+ def setup_project
+ create(:project, :public, :access_requestable, creator_id: master.id, namespace: project_group) do |project|
+ project.add_developer(developer)
+ project.add_master(master)
+ project.request_access(access_requester)
+ project.project_badges << build(:project_badge, project: project)
+ project.project_badges << build(:project_badge, project: project)
+ project_group.badges << build(:group_badge, group: group)
+ end
+ end
+
+ def setup_group
+ create(:group, :public, :access_requestable) do |group|
+ group.add_developer(developer)
+ group.add_owner(master)
+ group.request_access(access_requester)
+ group.badges << build(:group_badge, group: group)
+ group.badges << build(:group_badge, group: group)
+ end
+ end
+end
diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb
index 546a1697e56..92b614b087e 100644
--- a/spec/requests/api/boards_spec.rb
+++ b/spec/requests/api/boards_spec.rb
@@ -6,18 +6,18 @@ describe API::Boards do
set(:non_member) { create(:user) }
set(:guest) { create(:user) }
set(:admin) { create(:user, :admin) }
- set(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
+ set(:board_parent) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
set(:dev_label) do
- create(:label, title: 'Development', color: '#FFAABB', project: project)
+ create(:label, title: 'Development', color: '#FFAABB', project: board_parent)
end
set(:test_label) do
- create(:label, title: 'Testing', color: '#FFAACC', project: project)
+ create(:label, title: 'Testing', color: '#FFAACC', project: board_parent)
end
set(:ux_label) do
- create(:label, title: 'UX', color: '#FF0000', project: project)
+ create(:label, title: 'UX', color: '#FF0000', project: board_parent)
end
set(:dev_list) do
@@ -28,180 +28,56 @@ describe API::Boards do
create(:list, label: test_label, position: 2)
end
- set(:board) do
- create(:board, project: project, lists: [dev_list, test_list])
- end
-
- before do
- project.team << [user, :reporter]
- project.team << [guest, :guest]
- end
-
- describe "GET /projects/:id/boards" do
- let(:base_url) { "/projects/#{project.id}/boards" }
-
- context "when unauthenticated" do
- it "returns authentication error" do
- get api(base_url)
-
- expect(response).to have_gitlab_http_status(401)
- end
- end
-
- context "when authenticated" do
- it "returns the project issue board" do
- get api(base_url, user)
-
- 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.first['id']).to eq(board.id)
- expect(json_response.first['lists']).to be_an Array
- expect(json_response.first['lists'].length).to eq(2)
- expect(json_response.first['lists'].last).to have_key('position')
- end
- end
- end
-
- describe "GET /projects/:id/boards/:board_id/lists" do
- let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
-
- it 'returns issue board lists' do
- get api(base_url, user)
+ set(:milestone) { create(:milestone, project: board_parent) }
+ set(:board_label) { create(:label, project: board_parent) }
+ set(:board) { create(:board, project: board_parent, lists: [dev_list, test_list]) }
- 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(2)
- expect(json_response.first['label']['name']).to eq(dev_label.title)
- end
-
- it 'returns 404 if board not found' do
- get api("/projects/#{project.id}/boards/22343/lists", user)
+ it_behaves_like 'group and project boards', "/projects/:id/boards"
- expect(response).to have_gitlab_http_status(404)
- end
- end
-
- describe "GET /projects/:id/boards/:board_id/lists/:list_id" do
- let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
-
- it 'returns a list' do
- get api("#{base_url}/#{dev_list.id}", user)
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['id']).to eq(dev_list.id)
- expect(json_response['label']['name']).to eq(dev_label.title)
- expect(json_response['position']).to eq(1)
- end
-
- it 'returns 404 if list not found' do
- get api("#{base_url}/5324", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
- end
-
- describe "POST /projects/:id/board/lists" do
- let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
+ describe "POST /projects/:id/boards/lists" do
+ let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}/lists" }
it 'creates a new issue board list for group labels' do
group = create(:group)
group_label = create(:group_label, group: group)
- project.update(group: group)
+ board_parent.update(group: group)
- post api(base_url, user), label_id: group_label.id
+ post api(url, user), label_id: group_label.id
expect(response).to have_gitlab_http_status(201)
expect(json_response['label']['name']).to eq(group_label.title)
expect(json_response['position']).to eq(3)
end
- it 'creates a new issue board list for project labels' do
- post api(base_url, user), label_id: ux_label.id
-
- expect(response).to have_gitlab_http_status(201)
- expect(json_response['label']['name']).to eq(ux_label.title)
- expect(json_response['position']).to eq(3)
- end
-
- it 'returns 400 when creating a new list if label_id is invalid' do
- post api(base_url, user), label_id: 23423
-
- expect(response).to have_gitlab_http_status(400)
- end
-
- it 'returns 403 for project members with guest role' do
- put api("#{base_url}/#{test_list.id}", guest), position: 1
-
- expect(response).to have_gitlab_http_status(403)
- end
- end
-
- describe "PUT /projects/:id/boards/:board_id/lists/:list_id to update only position" do
- let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
-
- it "updates a list" do
- put api("#{base_url}/#{test_list.id}", user),
- position: 1
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['position']).to eq(1)
- end
-
- it "returns 404 error if list id not found" do
- put api("#{base_url}/44444", user),
- position: 1
-
- expect(response).to have_gitlab_http_status(404)
- end
+ it 'creates a new board list for ancestor group labels' do
+ group = create(:group)
+ sub_group = create(:group, parent: group)
+ group_label = create(:group_label, group: group)
+ board_parent.update(group: sub_group)
+ group.add_developer(user)
+ sub_group.add_developer(user)
- it "returns 403 for project members with guest role" do
- put api("#{base_url}/#{test_list.id}", guest),
- position: 1
+ post api(url, user), label_id: group_label.id
- expect(response).to have_gitlab_http_status(403)
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['label']['name']).to eq(group_label.title)
end
end
- describe "DELETE /projects/:id/board/lists/:list_id" do
- let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
-
- it "rejects a non member from deleting a list" do
- delete api("#{base_url}/#{dev_list.id}", non_member)
-
- expect(response).to have_gitlab_http_status(403)
- end
-
- it "rejects a user with guest role from deleting a list" do
- delete api("#{base_url}/#{dev_list.id}", guest)
-
- expect(response).to have_gitlab_http_status(403)
- end
-
- it "returns 404 error if list id not found" do
- delete api("#{base_url}/44444", user)
+ describe "POST /groups/:id/boards/lists", :nested_groups do
+ set(:group) { create(:group) }
+ set(:board_parent) { create(:group, parent: group ) }
+ let(:url) { "/groups/#{board_parent.id}/boards/#{board.id}/lists" }
+ set(:board) { create(:board, group: board_parent) }
- expect(response).to have_gitlab_http_status(404)
- end
-
- context "when the user is project owner" do
- set(:owner) { create(:user) }
-
- before do
- project.update(namespace: owner.namespace)
- end
-
- it "deletes the list if an admin requests it" do
- delete api("#{base_url}/#{dev_list.id}", owner)
+ it 'creates a new board list for ancestor group labels' do
+ group.add_developer(user)
+ group_label = create(:group_label, group: group)
- expect(response).to have_gitlab_http_status(204)
- end
+ post api(url, user), label_id: group_label.id
- it_behaves_like '412 response' do
- let(:request) { api("#{base_url}/#{dev_list.id}", owner) }
- end
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['label']['name']).to eq(group_label.title)
end
end
end
diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb
index e433597f58b..64f51d9843d 100644
--- a/spec/requests/api/branches_spec.rb
+++ b/spec/requests/api/branches_spec.rb
@@ -39,6 +39,27 @@ describe API::Branches do
end
end
+ context 'when search parameter is passed' do
+ context 'and branch exists' do
+ it 'returns correct branches' do
+ get api(route, user), per_page: 100, search: branch_name
+
+ searched_branch_names = json_response.map { |branch| branch['name'] }
+ project_branch_names = project.repository.branch_names.grep(/#{branch_name}/)
+
+ expect(searched_branch_names).to match_array(project_branch_names)
+ end
+ end
+
+ context 'and branch does not exist' do
+ it 'returns an empty array' do
+ get api(route, user), per_page: 100, search: 'no_such_branch_name_entropy_of_jabadabadu'
+
+ expect(json_response).to eq []
+ end
+ end
+ end
+
context 'when unauthenticated', 'and project is public' do
before do
project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb
index ffa17d296e8..f246bb79ab7 100644
--- a/spec/requests/api/commit_statuses_spec.rb
+++ b/spec/requests/api/commit_statuses_spec.rb
@@ -142,6 +142,7 @@ describe API::CommitStatuses do
expect(json_response['ref']).not_to be_empty
expect(json_response['target_url']).to be_nil
expect(json_response['description']).to be_nil
+
if status == 'failed'
expect(CommitStatus.find(json_response['id'])).to be_api_failure
end
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index 0d2bd3207c0..8ad19e3f0f5 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -62,7 +62,7 @@ describe API::Commits do
context "since optional parameter" do
it "returns project commits since provided parameter" do
- commits = project.repository.commits("master")
+ commits = project.repository.commits("master", limit: 2)
after = commits.second.created_at
get api("/projects/#{project_id}/repository/commits?since=#{after.utc.iso8601}", user)
@@ -73,7 +73,7 @@ describe API::Commits do
end
it 'include correct pagination headers' do
- commits = project.repository.commits("master")
+ commits = project.repository.commits("master", limit: 2)
after = commits.second.created_at
commit_count = project.repository.count_commits(ref: 'master', after: after).to_s
@@ -87,12 +87,12 @@ describe API::Commits do
context "until optional parameter" do
it "returns project commits until provided parameter" do
- commits = project.repository.commits("master")
+ commits = project.repository.commits("master", limit: 20)
before = commits.second.created_at
get api("/projects/#{project_id}/repository/commits?until=#{before.utc.iso8601}", user)
- if commits.size >= 20
+ if commits.size == 20
expect(json_response.size).to eq(20)
else
expect(json_response.size).to eq(commits.size - 1)
@@ -103,7 +103,7 @@ describe API::Commits do
end
it 'include correct pagination headers' do
- commits = project.repository.commits("master")
+ commits = project.repository.commits("master", limit: 2)
before = commits.second.created_at
commit_count = project.repository.count_commits(ref: 'master', before: before).to_s
@@ -149,6 +149,18 @@ describe API::Commits do
end
end
+ context 'all optional parameter' do
+ it 'returns all project commits' do
+ commit_count = project.repository.count_commits(all: true)
+
+ get api("/projects/#{project_id}/repository/commits?all=true", user)
+
+ expect(response).to include_pagination_headers
+ expect(response.headers['X-Total']).to eq(commit_count.to_s)
+ expect(response.headers['X-Page']).to eql('1')
+ end
+ end
+
context 'with pagination params' do
let(:page) { 1 }
let(:per_page) { 5 }
@@ -181,7 +193,7 @@ describe API::Commits do
let(:page) { 3 }
it 'returns the third 5 commits' do
- commit = project.repository.commits('HEAD', offset: (page - 1) * per_page).first
+ commit = project.repository.commits('HEAD', limit: per_page, offset: (page - 1) * per_page).first
expect(json_response.size).to eq(per_page)
expect(json_response.first['id']).to eq(commit.id)
@@ -465,6 +477,72 @@ describe API::Commits do
end
end
+ describe 'GET /projects/:id/repository/commits/:sha/refs' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:tag) { project.repository.find_tag('v1.1.0') }
+ let(:commit_id) { tag.dereferenced_target.id }
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/refs" }
+
+ 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
+
+ context 'for a valid commit' do
+ it 'returns all refs with no scope' do
+ get api(route, current_user), per_page: 100
+
+ refs = project.repository.branch_names_contains(commit_id).map {|name| ['branch', name]}
+ refs.concat(project.repository.tag_names_contains(commit_id).map {|name| ['tag', name]})
+
+ 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 { |r| [r['type'], r['name']] }.compact).to eq(refs)
+ end
+
+ it 'returns all refs' do
+ get api(route, current_user), type: 'all', per_page: 100
+
+ refs = project.repository.branch_names_contains(commit_id).map {|name| ['branch', name]}
+ refs.concat(project.repository.tag_names_contains(commit_id).map {|name| ['tag', name]})
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.map { |r| [r['type'], r['name']] }.compact).to eq(refs)
+ end
+
+ it 'returns the branch refs' do
+ get api(route, current_user), type: 'branch', per_page: 100
+
+ refs = project.repository.branch_names_contains(commit_id).map {|name| ['branch', name]}
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.map { |r| [r['type'], r['name']] }.compact).to eq(refs)
+ end
+
+ it 'returns the tag refs' do
+ get api(route, current_user), type: 'tag', per_page: 100
+
+ refs = project.repository.tag_names_contains(commit_id).map {|name| ['tag', name]}
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.map { |r| [r['type'], r['name']] }.compact).to eq(refs)
+ end
+ end
+ end
+
describe 'GET /projects/:id/repository/commits/:sha' do
let(:commit) { project.repository.commit }
let(:commit_id) { commit.id }
@@ -512,6 +590,31 @@ describe API::Commits do
end
end
+ context 'when stat param' do
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}" }
+
+ it 'is not present return stats by default' do
+ get api(route, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to include 'stats'
+ end
+
+ it "is false it does not include stats" do
+ get api(route, user), stats: false
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).not_to include 'stats'
+ end
+
+ it "is true it includes stats" do
+ get api(route, user), stats: true
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to include 'stats'
+ end
+ end
+
context 'when unauthenticated', 'and project is public' do
let(:project) { create(:project, :public, :repository) }
@@ -607,6 +710,7 @@ describe API::Commits do
get api(route, current_user)
expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
expect(json_response.size).to be >= 1
expect(json_response.first.keys).to include 'diff'
end
@@ -1037,4 +1141,33 @@ describe API::Commits do
end
end
end
+
+ describe 'GET /projects/:id/repository/commits/:sha/merge_requests' do
+ let!(:project) { create(:project, :repository, :private) }
+ let!(:merged_mr) { create(:merge_request, source_project: project, source_branch: 'master', target_branch: 'feature') }
+ let(:commit) { merged_mr.merge_request_diff.commits.last }
+
+ it 'returns the correct merge request' do
+ get api("/projects/#{project.id}/repository/commits/#{commit.id}/merge_requests", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response.length).to eq(1)
+ expect(json_response[0]['id']).to eq(merged_mr.id)
+ end
+
+ it 'returns 403 for an unauthorized user' do
+ project.add_guest(user)
+
+ get api("/projects/#{project.id}/repository/commits/#{commit.id}/merge_requests", user)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+
+ it 'responds 404 when the commit does not exist' do
+ get api("/projects/#{project.id}/repository/commits/a7d26f00c35b/merge_requests", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
end
diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb
index 1f1e6ea17e4..ae9c0e9c304 100644
--- a/spec/requests/api/deploy_keys_spec.rb
+++ b/spec/requests/api/deploy_keys_spec.rb
@@ -91,6 +91,10 @@ describe API::DeployKeys do
expect do
post api("/projects/#{project.id}/deploy_keys", admin), key_attrs
end.to change { project.deploy_keys.count }.by(1)
+
+ new_key = project.deploy_keys.last
+ expect(new_key.key).to eq(key_attrs[:key])
+ expect(new_key.user).to eq(admin)
end
it 'returns an existing ssh key when attempting to add a duplicate' do
@@ -110,7 +114,7 @@ describe API::DeployKeys do
end
it 'accepts can_push parameter' do
- key_attrs = attributes_for :write_access_key
+ key_attrs = attributes_for(:another_key).merge(can_push: true)
post api("/projects/#{project.id}/deploy_keys", admin), key_attrs
@@ -160,16 +164,6 @@ describe API::DeployKeys do
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
diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb
index c7977e624ff..51b70fda148 100644
--- a/spec/requests/api/deployments_spec.rb
+++ b/spec/requests/api/deployments_spec.rb
@@ -3,24 +3,65 @@ require 'spec_helper'
describe API::Deployments do
let(:user) { create(:user) }
let(:non_member) { create(:user) }
- let(:project) { deployment.environment.project }
- let!(:deployment) { create(:deployment) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET /projects/:id/deployments' do
+ let(:project) { create(:project) }
+ let!(:deployment_1) { create(:deployment, project: project, iid: 11, ref: 'master', created_at: Time.now) }
+ let!(:deployment_2) { create(:deployment, project: project, iid: 12, ref: 'feature', created_at: 1.day.ago) }
+ let!(:deployment_3) { create(:deployment, project: project, iid: 8, ref: 'feature', created_at: 2.days.ago) }
+
context 'as member of the project' do
- it 'returns projects deployments' do
+ it 'returns projects deployments sorted by id asc' do
get api("/projects/#{project.id}/deployments", user)
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(1)
- expect(json_response.first['iid']).to eq(deployment.iid)
+ expect(json_response.size).to eq(3)
+ expect(json_response.first['iid']).to eq(deployment_1.iid)
expect(json_response.first['sha']).to match /\A\h{40}\z/
+ expect(json_response.second['iid']).to eq(deployment_2.iid)
+ expect(json_response.last['iid']).to eq(deployment_3.iid)
+ end
+
+ describe 'ordering' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:order_by) { nil }
+ let(:sort) { nil }
+
+ subject { get api("/projects/#{project.id}/deployments?order_by=#{order_by}&sort=#{sort}", user) }
+
+ def expect_deployments(ordered_deployments)
+ json_response.each_with_index do |deployment_json, index|
+ expect(deployment_json['id']).to eq(public_send(ordered_deployments[index]).id)
+ end
+ end
+
+ before do
+ subject
+ end
+
+ where(:order_by, :sort, :ordered_deployments) do
+ 'created_at' | 'asc' | [:deployment_3, :deployment_2, :deployment_1]
+ 'created_at' | 'desc' | [:deployment_1, :deployment_2, :deployment_3]
+ 'id' | 'asc' | [:deployment_1, :deployment_2, :deployment_3]
+ 'id' | 'desc' | [:deployment_3, :deployment_2, :deployment_1]
+ 'iid' | 'asc' | [:deployment_3, :deployment_1, :deployment_2]
+ 'iid' | 'desc' | [:deployment_2, :deployment_1, :deployment_3]
+ 'ref' | 'asc' | [:deployment_2, :deployment_3, :deployment_1]
+ 'ref' | 'desc' | [:deployment_1, :deployment_2, :deployment_3]
+ end
+
+ with_them do
+ it 'returns the deployments ordered' do
+ expect_deployments(ordered_deployments)
+ end
+ end
end
end
@@ -34,6 +75,9 @@ describe API::Deployments do
end
describe 'GET /projects/:id/deployments/:deployment_id' do
+ let(:project) { deployment.environment.project }
+ let!(:deployment) { create(:deployment) }
+
context 'as a member of the project' do
it 'returns the projects deployment' do
get api("/projects/#{project.id}/deployments/#{deployment.id}", user)
diff --git a/spec/requests/api/discussions_spec.rb b/spec/requests/api/discussions_spec.rb
new file mode 100644
index 00000000000..4a44b219a67
--- /dev/null
+++ b/spec/requests/api/discussions_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe API::Discussions do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, :public, namespace: user.namespace) }
+ let(:private_user) { create(:user) }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ context "when noteable is an Issue" do
+ let!(:issue) { create(:issue, project: project, author: user) }
+ let!(:issue_note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: user) }
+
+ it_behaves_like "discussions API", 'projects', 'issues', 'iid' do
+ let(:parent) { project }
+ let(:noteable) { issue }
+ let(:note) { issue_note }
+ end
+ end
+
+ context "when noteable is a Snippet" do
+ let!(:snippet) { create(:project_snippet, project: project, author: user) }
+ let!(:snippet_note) { create(:discussion_note_on_snippet, noteable: snippet, project: project, author: user) }
+
+ it_behaves_like "discussions API", 'projects', 'snippets', 'id' do
+ let(:parent) { project }
+ let(:noteable) { snippet }
+ let(:note) { snippet_note }
+ end
+ end
+end
diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb
index 3665cfd7241..53d48a91007 100644
--- a/spec/requests/api/environments_spec.rb
+++ b/spec/requests/api/environments_spec.rb
@@ -7,7 +7,7 @@ describe API::Environments do
let!(:environment) { create(:environment, project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET /projects/:id/environments' do
diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb
index 267058d98ee..c5354c2d639 100644
--- a/spec/requests/api/features_spec.rb
+++ b/spec/requests/api/features_spec.rb
@@ -1,8 +1,8 @@
require 'spec_helper'
describe API::Features do
- let(:user) { create(:user) }
- let(:admin) { create(:admin) }
+ set(:user) { create(:user) }
+ set(:admin) { create(:admin) }
before do
Flipper.unregister_groups
@@ -249,4 +249,43 @@ describe API::Features do
end
end
end
+
+ describe 'DELETE /feature/:name' do
+ let(:feature_name) { 'my_feature' }
+
+ context 'when the user has no access' do
+ it 'returns a 401 for anonymous users' do
+ delete api("/features/#{feature_name}")
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+
+ it 'returns a 403 for users' do
+ delete api("/features/#{feature_name}", user)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+
+ context 'when the user has access' do
+ it 'returns 204 when the value is not set' do
+ delete api("/features/#{feature_name}", admin)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+
+ context 'when the gate value was set' do
+ before do
+ Feature.get(feature_name).enable
+ end
+
+ it 'deletes an enabled feature' do
+ delete api("/features/#{feature_name}", admin)
+
+ expect(response).to have_gitlab_http_status(204)
+ expect(Feature.get(feature_name)).not_to be_enabled
+ end
+ end
+ end
+ end
end
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index 5d8338a3fb7..d8fdfd6dee1 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -14,7 +14,7 @@ describe API::Files do
let(:author_name) { 'John Doe' }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
def route(file_path = nil)
diff --git a/spec/requests/api/group_boards_spec.rb b/spec/requests/api/group_boards_spec.rb
new file mode 100644
index 00000000000..894c94688ba
--- /dev/null
+++ b/spec/requests/api/group_boards_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+
+describe API::GroupBoards do
+ set(:user) { create(:user) }
+ set(:non_member) { create(:user) }
+ set(:guest) { create(:user) }
+ set(:admin) { create(:user, :admin) }
+ set(:board_parent) { create(:group, :public) }
+
+ before do
+ board_parent.add_owner(user)
+ end
+
+ set(:project) { create(:project, :public, namespace: board_parent ) }
+
+ set(:dev_label) do
+ create(:group_label, title: 'Development', color: '#FFAABB', group: board_parent)
+ end
+
+ set(:test_label) do
+ create(:group_label, title: 'Testing', color: '#FFAACC', group: board_parent)
+ end
+
+ set(:ux_label) do
+ create(:group_label, title: 'UX', color: '#FF0000', group: board_parent)
+ end
+
+ set(:dev_list) do
+ create(:list, label: dev_label, position: 1)
+ end
+
+ set(:test_list) do
+ create(:list, label: test_label, position: 2)
+ end
+
+ set(:milestone) { create(:milestone, group: board_parent) }
+ set(:board_label) { create(:group_label, group: board_parent) }
+
+ set(:board) { create(:board, group: board_parent, lists: [dev_list, test_list]) }
+
+ it_behaves_like 'group and project boards', "/groups/:id/boards", false
+
+ describe 'POST /groups/:id/boards/lists' do
+ let(:url) { "/groups/#{board_parent.id}/boards/#{board.id}/lists" }
+
+ it 'does not create lists for child project labels' do
+ project_label = create(:label, project: project)
+
+ post api(url, user), label_id: project_label.id
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+end
diff --git a/spec/requests/api/group_variables_spec.rb b/spec/requests/api/group_variables_spec.rb
index a4f198eb5c9..64fa7dc824c 100644
--- a/spec/requests/api/group_variables_spec.rb
+++ b/spec/requests/api/group_variables_spec.rb
@@ -142,12 +142,12 @@ describe API::GroupVariables do
end
it 'updates variable data' do
- initial_variable = group.variables.first
+ initial_variable = group.variables.reload.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
+ updated_variable = group.variables.reload.first
expect(response).to have_gitlab_http_status(200)
expect(value_before).to eq(variable.value)
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index 6330c140246..bb0034e3237 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -30,6 +30,21 @@ describe API::Groups do
expect(json_response)
.to satisfy_one { |group| group['name'] == group1.name }
end
+
+ it 'avoids N+1 queries' do
+ # Establish baseline
+ get api("/groups", admin)
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api("/groups", admin)
+ end
+
+ create(:group)
+
+ expect do
+ get api("/groups", admin)
+ end.not_to exceed_query_limit(control)
+ end
end
context "when authenticated as user" do
@@ -417,7 +432,7 @@ describe API::Groups do
end
it "only returns projects to which user has access" do
- project3.team << [user3, :developer]
+ project3.add_developer(user3)
get api("/groups/#{group1.id}/projects", user3)
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 0462f494e15..837389451e8 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -68,6 +68,12 @@ describe API::Helpers do
end
it { is_expected.to eq(user) }
+
+ it 'sets the environment with data of the current user' do
+ subject
+
+ expect(env[API::Helpers::API_USER_ENV]).to eq({ user_id: subject.id, username: subject.username })
+ end
end
context "HEAD request" do
diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb
index 3c31980b273..db8c5f963d6 100644
--- a/spec/requests/api/internal_spec.rb
+++ b/spec/requests/api/internal_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe API::Internal do
let(:user) { create(:user) }
let(:key) { create(:key, user: user) }
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :wiki_repo) }
let(:secret_token) { Gitlab::Shell.secret_token }
let(:gl_repository) { "project-#{project.id}" }
let(:reference_counter) { double('ReferenceCounter') }
@@ -147,7 +147,7 @@ describe API::Internal do
describe "POST /internal/lfs_authenticate" do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'user key' do
@@ -192,6 +192,54 @@ describe API::Internal do
end
end
+ describe "GET /internal/authorized_keys" do
+ context "using an existing key's fingerprint" do
+ it "finds the key" do
+ get(api('/internal/authorized_keys'), fingerprint: key.fingerprint, secret_token: secret_token)
+
+ expect(response.status).to eq(200)
+ expect(json_response["key"]).to eq(key.key)
+ end
+ end
+
+ context "non existing key's fingerprint" do
+ it "returns 404" do
+ get(api('/internal/authorized_keys'), fingerprint: "no:t-:va:li:d0", secret_token: secret_token)
+
+ expect(response.status).to eq(404)
+ end
+ end
+
+ context "using a partial fingerprint" do
+ it "returns 404" do
+ get(api('/internal/authorized_keys'), fingerprint: "#{key.fingerprint[0..5]}%", secret_token: secret_token)
+
+ expect(response.status).to eq(404)
+ end
+ end
+
+ context "sending the key" do
+ it "finds the key" do
+ get(api('/internal/authorized_keys'), key: key.key.split[1], secret_token: secret_token)
+
+ expect(response.status).to eq(200)
+ expect(json_response["key"]).to eq(key.key)
+ end
+
+ it "returns 404 with a partial key" do
+ get(api('/internal/authorized_keys'), key: key.key.split[1][0...-3], secret_token: secret_token)
+
+ expect(response.status).to eq(404)
+ end
+
+ it "returns 404 with an not valid base64 string" do
+ get(api('/internal/authorized_keys'), key: "whatever!", secret_token: secret_token)
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+
describe "POST /internal/allowed", :clean_gitlab_redis_shared_state do
context "access granted" do
around do |example|
@@ -199,48 +247,27 @@ describe API::Internal do
end
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'with env passed as a JSON' do
- context 'when relative path envs are not set' do
- it 'sets env in RequestStore' do
- expect(Gitlab::Git::Env).to receive(:set).with({
- 'GIT_OBJECT_DIRECTORY' => 'foo',
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => 'bar'
- })
-
- push(key, project.wiki, env: {
- GIT_OBJECT_DIRECTORY: 'foo',
- GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar'
- }.to_json)
+ let(:gl_repository) { project.gl_repository(is_wiki: true) }
- expect(response).to have_gitlab_http_status(200)
- end
- end
+ it 'sets env in RequestStore' do
+ obj_dir_relative = './objects'
+ alt_obj_dirs_relative = ['./alt-objects-1', './alt-objects-2']
- context 'when relative path envs are set' do
- it 'sets env in RequestStore' do
- obj_dir_relative = './objects'
- alt_obj_dirs_relative = ['./alt-objects-1', './alt-objects-2']
- repo_path = project.wiki.repository.path_to_repo
-
- expect(Gitlab::Git::Env).to receive(:set).with({
- 'GIT_OBJECT_DIRECTORY' => File.join(repo_path, obj_dir_relative),
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => alt_obj_dirs_relative.map { |d| File.join(repo_path, d) },
- 'GIT_OBJECT_DIRECTORY_RELATIVE' => obj_dir_relative,
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => alt_obj_dirs_relative
- })
-
- push(key, project.wiki, env: {
- GIT_OBJECT_DIRECTORY: 'foo',
- GIT_ALTERNATE_OBJECT_DIRECTORIES: 'bar',
- GIT_OBJECT_DIRECTORY_RELATIVE: obj_dir_relative,
- GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: alt_obj_dirs_relative
- }.to_json)
+ expect(Gitlab::Git::HookEnv).to receive(:set).with(gl_repository, {
+ 'GIT_OBJECT_DIRECTORY_RELATIVE' => obj_dir_relative,
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => alt_obj_dirs_relative
+ })
- expect(response).to have_gitlab_http_status(200)
- end
+ push(key, project.wiki, env: {
+ GIT_OBJECT_DIRECTORY_RELATIVE: obj_dir_relative,
+ GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE: alt_obj_dirs_relative
+ }.to_json)
+
+ expect(response).to have_gitlab_http_status(200)
end
end
@@ -269,54 +296,26 @@ describe API::Internal do
end
context "git pull" do
- context "gitaly disabled", :disable_gitaly do
- it "has the correct payload" do
- pull(key, project)
-
- expect(response).to have_gitlab_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
- pull(key, project)
+ it "has the correct payload" do
+ pull(key, project)
- expect(response).to have_gitlab_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
+ expect(response).to have_gitlab_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
context "git push" do
- context "gitaly disabled", :disable_gitaly do
- it "has the correct payload" do
- push(key, project)
-
- expect(response).to have_gitlab_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
+ context 'project as namespace/project' do
+ it do
push(key, project)
expect(response).to have_gitlab_http_status(200)
@@ -332,34 +331,12 @@ describe API::Internal do
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.full_path))
-
- expect(response).to have_gitlab_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.full_path))
-
- expect(response).to have_gitlab_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
end
context "access denied" do
before do
- project.team << [user, :guest]
+ project.add_guest(user)
end
context "git pull" do
@@ -413,7 +390,7 @@ describe API::Internal do
context "archived project" do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
project.archive!
end
@@ -449,6 +426,12 @@ describe API::Internal do
expect(response).to have_gitlab_http_status(200)
expect(json_response["status"]).to be_truthy
+ 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))
end
end
@@ -463,8 +446,10 @@ describe API::Internal do
end
context 'project does not exist' do
- it do
- pull(key, project_with_repo_path('gitlab/notexist'))
+ it 'returns a 200 response with status: false' do
+ project.destroy
+
+ pull(key, project)
expect(response).to have_gitlab_http_status(200)
expect(json_response["status"]).to be_falsey
@@ -527,7 +512,7 @@ describe API::Internal do
context 'web actions are always allowed' do
it 'allows WEB push' do
stub_application_setting(enabled_git_access_protocol: 'ssh')
- project.team << [user, :developer]
+ project.add_developer(user)
push(key, project, 'web')
expect(response.status).to eq(200)
@@ -536,11 +521,12 @@ describe API::Internal do
end
context 'the project path was changed' do
+ let(:project) { create(:project, :repository, :legacy_storage) }
let!(:old_path_to_repo) { project.repository.path_to_repo }
let!(:repository) { project.repository }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
project.path = 'new_path'
project.save!
end
@@ -566,7 +552,7 @@ describe API::Internal do
let(:changes) { URI.escape("#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch") }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'returns link to create new merge request' do
@@ -701,7 +687,7 @@ describe API::Internal do
end
before do
- project.team << [user, :developer]
+ project.add_developer(user)
allow(described_class).to receive(:identify).and_return(user)
allow_any_instance_of(Gitlab::Identifier).to receive(:identify).and_return(user)
end
@@ -774,14 +760,37 @@ describe API::Internal do
context 'with a redirected data' do
it 'returns redirected message on the response' do
- project_moved = Gitlab::Checks::ProjectMoved.new(project, user, 'foo/baz', 'http')
- project_moved.add_redirect_message
+ project_moved = Gitlab::Checks::ProjectMoved.new(project, user, 'http', 'foo/baz')
+ project_moved.add_message
post api("/internal/post_receive"), valid_params
expect(response).to have_gitlab_http_status(200)
expect(json_response["redirected_message"]).to be_present
- expect(json_response["redirected_message"]).to eq(project_moved.redirect_message)
+ expect(json_response["redirected_message"]).to eq(project_moved.message)
+ end
+ end
+
+ context 'with new project data' do
+ it 'returns new project message on the response' do
+ project_created = Gitlab::Checks::ProjectCreated.new(project, user, 'http')
+ project_created.add_message
+
+ post api("/internal/post_receive"), valid_params
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response["project_created_message"]).to be_present
+ expect(json_response["project_created_message"]).to eq(project_created.message)
+ end
+ end
+
+ context 'with an orphaned write deploy key' do
+ it 'does not try to notify that project moved' do
+ allow_any_instance_of(Gitlab::Identifier).to receive(:identify).and_return(nil)
+
+ post api("/internal/post_receive"), valid_params
+
+ expect(response).to have_gitlab_http_status(200)
end
end
end
@@ -802,9 +811,14 @@ describe API::Internal do
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)
+ def gl_repository_for(project_or_wiki)
+ case project_or_wiki
+ when ProjectWiki
+ project_or_wiki.project.gl_repository(is_wiki: true)
+ when Project
+ project_or_wiki.gl_repository(is_wiki: false)
+ else
+ nil
end
end
@@ -812,18 +826,8 @@ describe API::Internal do
post(
api("/internal/allowed"),
key_id: key.id,
- project: project.repository.path_to_repo,
- action: 'git-upload-pack',
- secret_token: secret_token,
- protocol: protocol
- )
- end
-
- def pull_with_path(key, path_to_repo, protocol = 'ssh')
- post(
- api("/internal/allowed"),
- key_id: key.id,
- project: path_to_repo,
+ project: project.full_path,
+ gl_repository: gl_repository_for(project),
action: 'git-upload-pack',
secret_token: secret_token,
protocol: protocol
@@ -835,20 +839,8 @@ describe API::Internal do
api("/internal/allowed"),
changes: 'd14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/master',
key_id: key.id,
- project: project.repository.path_to_repo,
- action: 'git-receive-pack',
- secret_token: secret_token,
- protocol: protocol,
- env: env
- )
- 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,
+ project: project.full_path,
+ gl_repository: gl_repository_for(project),
action: 'git-receive-pack',
secret_token: secret_token,
protocol: protocol,
@@ -861,7 +853,8 @@ describe API::Internal do
api("/internal/allowed"),
ref: 'master',
key_id: key.id,
- project: project.repository.path_to_repo,
+ project: project.full_path,
+ gl_repository: gl_repository_for(project),
action: 'git-upload-archive',
secret_token: secret_token,
protocol: 'ssh'
@@ -873,7 +866,7 @@ describe API::Internal do
api("/internal/lfs_authenticate"),
key_id: key_id,
secret_token: secret_token,
- project: project.repository.path_to_repo
+ project: project.full_path
)
end
end
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index 3f5070a1fd2..90f9c4ad214 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe API::Issues, :mailer do
+describe API::Issues do
set(:user) { create(:user) }
set(:project) do
create(:project, :public, creator_id: user.id, namespace: user.namespace)
@@ -58,8 +58,8 @@ describe API::Issues, :mailer do
let(:no_milestone_title) { URI.escape(Milestone::None.title) }
before(:all) do
- project.team << [user, :reporter]
- project.team << [guest, :guest]
+ project.add_reporter(user)
+ project.add_guest(guest)
end
describe "GET /issues" do
@@ -163,6 +163,42 @@ describe API::Issues, :mailer do
expect(first_issue['id']).to eq(issue.id)
end
+ context 'filtering before a specific date' do
+ let!(:issue2) { create(:issue, project: project, author: user, created_at: Date.new(2000, 1, 1), updated_at: Date.new(2000, 1, 1)) }
+
+ it 'returns issues created before a specific date' do
+ get api('/issues?created_before=2000-01-02T00:00:00.060Z', user)
+
+ expect(json_response.size).to eq(1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+
+ it 'returns issues updated before a specific date' do
+ get api('/issues?updated_before=2000-01-02T00:00:00.060Z', user)
+
+ expect(json_response.size).to eq(1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+ end
+
+ context 'filtering after a specific date' do
+ let!(:issue2) { create(:issue, project: project, author: user, created_at: 1.week.from_now, updated_at: 1.week.from_now) }
+
+ it 'returns issues created after a specific date' do
+ get api("/issues?created_after=#{issue2.created_at}", user)
+
+ expect(json_response.size).to eq(1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+
+ it 'returns issues updated after a specific date' do
+ get api("/issues?updated_after=#{issue2.updated_at}", user)
+
+ expect(json_response.size).to eq(1)
+ expect(first_issue['id']).to eq(issue2.id)
+ end
+ end
+
it 'returns an array of labeled issues' do
get api("/issues", user), labels: label.title
@@ -344,10 +380,34 @@ describe API::Issues, :mailer do
let!(:group_note) { create(:note_on_issue, author: user, project: group_project, noteable: group_issue) }
before do
- group_project.team << [user, :reporter]
+ group_project.add_reporter(user)
end
let(:base_url) { "/groups/#{group.id}/issues" }
+ context 'when group has subgroups', :nested_groups do
+ let(:subgroup_1) { create(:group, parent: group) }
+ let(:subgroup_2) { create(:group, parent: subgroup_1) }
+
+ let(:subgroup_1_project) { create(:project, namespace: subgroup_1) }
+ let(:subgroup_2_project) { create(:project, namespace: subgroup_2) }
+
+ let!(:issue_1) { create(:issue, project: subgroup_1_project) }
+ let!(:issue_2) { create(:issue, project: subgroup_2_project) }
+
+ before do
+ group.add_developer(user)
+ end
+
+ it 'also returns subgroups projects issues' do
+ get api(base_url, user)
+
+ issue_ids = json_response.map { |issue| issue['id'] }
+
+ expect_paginated_array_response(size: 5)
+ expect(issue_ids).to include(issue_1.id, issue_2.id)
+ end
+ end
+
it 'returns all group issues (including opened and closed)' do
get api(base_url, admin)
@@ -847,6 +907,15 @@ describe API::Issues, :mailer do
expect(json_response['assignee']['name']).to eq(user2.name)
expect(json_response['assignees'].first['name']).to eq(user2.name)
end
+
+ it 'creates a new project issue when assignee_id is empty' do
+ post api("/projects/#{project.id}/issues", user),
+ title: 'new issue', assignee_id: ''
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['title']).to eq('new issue')
+ expect(json_response['assignee']).to be_nil
+ end
end
context 'single assignee restrictions' do
@@ -923,18 +992,6 @@ describe API::Issues, :mailer do
expect(json_response['error']).to eq('confidential is invalid')
end
- it "sends notifications for subscribers of newly added labels" do
- label = project.labels.first
- label.toggle_subscription(user2, project)
-
- perform_enqueued_jobs do
- post api("/projects/#{project.id}/issues", user),
- title: 'new issue', labels: label.title
- end
-
- should_email(user2)
- end
-
it "returns a 400 bad request if title not given" do
post api("/projects/#{project.id}/issues", user), labels: 'label, label2'
expect(response).to have_gitlab_http_status(400)
@@ -967,7 +1024,7 @@ describe API::Issues, :mailer do
let(:project) { merge_request.source_project }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
context 'resolving all discussions in a merge request' do
@@ -1237,18 +1294,6 @@ describe API::Issues, :mailer do
expect(json_response['labels']).to eq([label.title])
end
- it "sends notifications for subscribers of newly added labels when issue is updated" do
- label = create(:label, title: 'foo', color: '#FFAABB', project: project)
- label.toggle_subscription(user2, project)
-
- perform_enqueued_jobs do
- put api("/projects/#{project.id}/issues/#{issue.iid}", user),
- title: 'updated title', labels: label.title
- end
-
- should_email(user2)
- end
-
it 'removes all labels' do
put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: ''
@@ -1371,7 +1416,7 @@ describe API::Issues, :mailer do
end
describe '/projects/:id/issues/:issue_iid/move' do
- let!(:target_project) { create(:project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
+ let!(:target_project) { create(:project, 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
@@ -1432,7 +1477,7 @@ describe API::Issues, :mailer do
context 'when source project does not exist' do
it 'returns 404 when trying to move an issue' do
- post api("/projects/123/issues/#{issue.iid}/move", user),
+ post api("/projects/0/issues/#{issue.iid}/move", user),
to_project_id: target_project.id
expect(response).to have_gitlab_http_status(404)
@@ -1443,7 +1488,7 @@ describe API::Issues, :mailer do
context 'when target project does not exist' do
it 'returns 404 when trying to move an issue' do
post api("/projects/#{project.id}/issues/#{issue.iid}/move", user),
- to_project_id: 123
+ to_project_id: 0
expect(response).to have_gitlab_http_status(404)
end
@@ -1582,4 +1627,16 @@ describe API::Issues, :mailer do
expect(json_response).to be_an Array
expect(json_response.length).to eq(size) if size
end
+
+ describe 'GET projects/:id/issues/:issue_iid/participants' do
+ it_behaves_like 'issuable participants endpoint' do
+ let(:entity) { issue }
+ end
+
+ it 'returns 404 if the issue is confidential' do
+ post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/participants", non_member)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
end
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 2a83213e87a..3ffdfdc0e9a 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe API::Jobs do
+ include HttpIOHelpers
+
set(:project) do
create(:project, :repository, public_builds: false)
end
@@ -11,7 +13,7 @@ describe API::Jobs do
ref: project.default_branch)
end
- let!(:job) { create(:ci_build, pipeline: pipeline) }
+ let!(:job) { create(:ci_build, :success, pipeline: pipeline) }
let(:user) { create(:user) }
let(:api_user) { user }
@@ -25,8 +27,10 @@ describe API::Jobs do
describe 'GET /projects/:id/jobs' do
let(:query) { Hash.new }
- before do
- get api("/projects/#{project.id}/jobs", api_user), query
+ before do |example|
+ unless example.metadata[:skip_before_request]
+ get api("/projects/#{project.id}/jobs", api_user), query
+ end
end
context 'authorized user' do
@@ -51,6 +55,23 @@ describe API::Jobs do
expect(json_job['pipeline']['status']).to eq job.pipeline.status
end
+ it 'avoids N+1 queries', :skip_before_request do
+ first_build = create(:ci_build, :artifacts, pipeline: pipeline)
+ first_build.runner = create(:ci_runner)
+ first_build.user = create(:user)
+ first_build.save
+
+ control_count = ActiveRecord::QueryRecorder.new { go }.count
+
+ second_pipeline = create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: project.default_branch)
+ second_build = create(:ci_build, :artifacts, pipeline: second_pipeline)
+ second_build.runner = create(:ci_runner)
+ second_build.user = create(:user)
+ second_build.save
+
+ expect { go }.not_to exceed_query_limit(control_count)
+ end
+
context 'filter project with one scope element' do
let(:query) { { 'scope' => 'pending' } }
@@ -83,12 +104,17 @@ describe API::Jobs do
expect(response).to have_gitlab_http_status(401)
end
end
+
+ def go
+ get api("/projects/#{project.id}/jobs", api_user), query
+ end
end
describe 'GET /projects/:id/pipelines/:pipeline_id/jobs' do
let(:query) { Hash.new }
before do
+ job
get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), query
end
@@ -277,46 +303,101 @@ describe API::Jobs do
end
describe 'GET /projects/:id/jobs/:job_id/artifacts' do
- before do
- get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
+ shared_examples 'downloads artifact' do
+ let(:download_headers) do
+ { 'Content-Transfer-Encoding' => 'binary',
+ 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ end
+
+ it 'returns specific job artifacts' do
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers).to include(download_headers)
+ expect(response.body).to match_file(job.artifacts_file.file.file)
+ end
end
- context 'job with artifacts' do
- let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ context 'normal authentication' do
+ context 'job with artifacts' do
+ context 'when artifacts are stored locally' do
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
- context 'authorized user' do
- let(:download_headers) do
- { 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ before do
+ get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
+ end
+
+ context 'authorized user' do
+ it_behaves_like 'downloads artifact'
+ end
+
+ context 'unauthorized user' do
+ let(:api_user) { nil }
+
+ it 'does not return specific job artifacts' do
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
end
- it 'returns specific job artifacts' do
- expect(response).to have_gitlab_http_status(200)
- expect(response.headers).to include(download_headers)
- expect(response.body).to match_file(job.artifacts_file.file.file)
+ context 'when artifacts are stored remotely' do
+ let(:proxy_download) { false }
+
+ before do
+ stub_artifacts_object_storage(proxy_download: proxy_download)
+ end
+
+ let(:job) { create(:ci_build, pipeline: pipeline) }
+ let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: job) }
+
+ before do
+ job.reload
+
+ get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
+ end
+
+ context 'when proxy download is enabled' do
+ let(:proxy_download) { true }
+
+ it 'responds with the workhorse send-url' do
+ expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("send-url:")
+ end
+ end
+
+ context 'when proxy download is disabled' do
+ it 'returns location redirect' do
+ expect(response).to have_gitlab_http_status(302)
+ end
+ end
+
+ context 'authorized user' do
+ it 'returns the file remote URL' do
+ expect(response).to redirect_to(artifact.file.url)
+ end
+ end
+
+ context 'unauthorized user' do
+ let(:api_user) { nil }
+
+ it 'does not return specific job artifacts' do
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
end
- end
- context 'when anonymous user is accessing private artifacts' do
- let(:api_user) { nil }
+ it 'does not return job artifacts if not uploaded' do
+ get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
- it 'hides artifacts and rejects request' do
- expect(project).to be_private
- expect(response).to have_gitlab_http_status(404)
+ expect(response).to have_gitlab_http_status(:not_found)
end
end
end
-
- it 'does not return job artifacts if not uploaded' do
- expect(response).to have_gitlab_http_status(404)
- end
end
describe 'GET /projects/:id/artifacts/:ref_name/download?job=name' do
let(:api_user) { reporter }
- let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user) }
before do
+ stub_artifacts_object_storage
job.success
end
@@ -373,14 +454,31 @@ describe API::Jobs do
context 'find proper job' do
shared_examples 'a valid file' do
- let(:download_headers) do
- { 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' =>
- "attachment; filename=#{job.artifacts_file.filename}" }
+ context 'when artifacts are stored locally' do
+ let(:download_headers) do
+ { 'Content-Transfer-Encoding' => 'binary',
+ 'Content-Disposition' =>
+ "attachment; filename=#{job.artifacts_file.filename}" }
+ end
+
+ it { expect(response).to have_http_status(:ok) }
+ it { expect(response.headers).to include(download_headers) }
end
- it { expect(response).to have_gitlab_http_status(200) }
- it { expect(response.headers).to include(download_headers) }
+ context 'when artifacts are stored remotely' do
+ let(:job) { create(:ci_build, pipeline: pipeline, user: api_user) }
+ let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: job) }
+
+ before do
+ job.reload
+
+ get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
+ end
+
+ it 'returns location redirect' do
+ expect(response).to have_http_status(:found)
+ end
+ end
end
context 'with regular branch' do
@@ -412,16 +510,43 @@ describe API::Jobs do
end
describe 'GET /projects/:id/jobs/:job_id/trace' do
- let(:job) { create(:ci_build, :trace, pipeline: pipeline) }
-
before do
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_gitlab_http_status(200)
- expect(response.body).to eq(job.trace.raw)
+ context 'when trace is in ObjectStorage' do
+ let!(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) }
+
+ before do
+ stub_remote_trace_206
+ allow_any_instance_of(JobArtifactUploader).to receive(:file_storage?) { false }
+ allow_any_instance_of(JobArtifactUploader).to receive(:url) { remote_trace_url }
+ allow_any_instance_of(JobArtifactUploader).to receive(:size) { remote_trace_size }
+ end
+
+ it 'returns specific job trace' do
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.body).to eq(job.trace.raw)
+ end
+ end
+
+ context 'when trace is artifact' do
+ let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) }
+
+ it 'returns specific job trace' do
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.body).to eq(job.trace.raw)
+ end
+ end
+
+ context 'when trace is file' do
+ let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) }
+
+ it 'returns specific job trace' do
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.body).to eq(job.trace.raw)
+ end
end
end
@@ -443,7 +568,7 @@ describe API::Jobs do
context 'user with :update_build persmission' do
it 'cancels running or pending job' do
expect(response).to have_gitlab_http_status(201)
- expect(project.builds.first.status).to eq('canceled')
+ expect(project.builds.first.status).to eq('success')
end
end
@@ -503,17 +628,17 @@ describe API::Jobs do
let(:role) { :master }
before do
- project.team << [user, role]
+ project.add_role(user, role)
post api("/projects/#{project.id}/jobs/#{job.id}/erase", user)
end
context 'job is erasable' do
- let(:job) { create(:ci_build, :trace, :artifacts, :success, project: project, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace_artifact, :artifacts, :success, project: project, pipeline: pipeline) }
it 'erases job content' do
expect(response).to have_gitlab_http_status(201)
- expect(job).not_to have_trace
+ expect(job.trace.exist?).to be_falsy
expect(job.artifacts_file.exists?).to be_falsy
expect(job.artifacts_metadata.exists?).to be_falsy
end
@@ -527,7 +652,7 @@ describe API::Jobs do
end
context 'job is not erasable' do
- let(:job) { create(:ci_build, :trace, project: project, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :trace_live, project: project, pipeline: pipeline) }
it 'responds with forbidden' do
expect(response).to have_gitlab_http_status(403)
@@ -536,7 +661,7 @@ describe API::Jobs do
context 'when a developer erases a build' do
let(:role) { :developer }
- let(:job) { create(:ci_build, :trace, :artifacts, :success, project: project, pipeline: pipeline, user: owner) }
+ let(:job) { create(:ci_build, :trace_artifact, :artifacts, :success, project: project, pipeline: pipeline, user: owner) }
context 'when the build was created by the developer' do
let(:owner) { user }
@@ -559,7 +684,7 @@ describe API::Jobs do
context 'artifacts did not expire' do
let(:job) do
- create(:ci_build, :trace, :artifacts, :success,
+ create(:ci_build, :trace_artifact, :artifacts, :success,
project: project, pipeline: pipeline, artifacts_expire_at: Time.now + 7.days)
end
diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb
index 3498e5bc8d9..34cbf75f4c1 100644
--- a/spec/requests/api/labels_spec.rb
+++ b/spec/requests/api/labels_spec.rb
@@ -7,7 +7,7 @@ describe API::Labels do
let!(:priority_label) { create(:label, title: 'bug', project: project, priority: 3) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET /projects/:id/labels' do
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 3349e396ab8..ec500838eb2 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -8,8 +8,8 @@ describe API::Members do
let(:project) do
create(:project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
- project.team << [developer, :developer]
- project.team << [master, :master]
+ project.add_developer(developer)
+ project.add_master(master)
project.request_access(access_requester)
end
end
@@ -44,6 +44,21 @@ describe API::Members do
end
end
+ it 'avoids N+1 queries' do
+ # Establish baseline
+ get api("/#{source_type.pluralize}/#{source.id}/members", master)
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api("/#{source_type.pluralize}/#{source.id}/members", master)
+ end
+
+ project.add_developer(create(:user))
+
+ expect do
+ get api("/#{source_type.pluralize}/#{source.id}/members", master)
+ end.not_to exceed_query_limit(control)
+ end
+
it 'does not return invitees' do
create(:"#{source_type}_member", invite_token: '123', invite_email: 'test@abc.com', source: source, user: nil)
@@ -65,6 +80,16 @@ describe API::Members do
expect(json_response.count).to eq(1)
expect(json_response.first['username']).to eq(master.username)
end
+
+ it 'finds all members with no query specified' do
+ get api("/#{source_type.pluralize}/#{source.id}/members", developer), query: ''
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq(2)
+ expect(json_response.map { |u| u['id'] }).to match_array [master.id, developer.id]
+ end
end
end
diff --git a/spec/requests/api/merge_request_diffs_spec.rb b/spec/requests/api/merge_request_diffs_spec.rb
index bf4c8443b23..cb647aee70f 100644
--- a/spec/requests/api/merge_request_diffs_spec.rb
+++ b/spec/requests/api/merge_request_diffs_spec.rb
@@ -8,7 +8,7 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs' do
before do
merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e')
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/versions' do
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 60dbd74d59d..3764aec0c71 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -9,6 +9,7 @@ describe API::MergeRequests do
let(:non_member) { create(:user) }
let!(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace, only_allow_merge_if_pipeline_succeeds: false) }
let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
+ let(:pipeline) { create(:ci_empty_pipeline) }
let(:milestone1) { create(:milestone, title: '0.9', project: project) }
let!(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time) }
let!(:merge_request_closed) { create(:merge_request, state: "closed", milestone: milestone1, author: user, assignee: user, source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) }
@@ -25,7 +26,7 @@ describe API::MergeRequests do
let!(:upvote) { create(:award_emoji, :upvote, awardable: merge_request) }
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
describe 'GET /merge_requests' do
@@ -151,6 +152,62 @@ describe API::MergeRequests do
expect(json_response.first['id']).to eq(merge_request3.id)
end
+ context 'source_branch param' do
+ it 'returns merge requests with the given source branch' do
+ get api('/merge_requests', user), source_branch: merge_request_closed.source_branch, state: 'all'
+
+ expect(json_response.length).to eq(2)
+ expect(json_response.map { |mr| mr['id'] })
+ .to contain_exactly(merge_request_closed.id, merge_request_merged.id)
+ end
+ end
+
+ context 'target_branch param' do
+ it 'returns merge requests with the given target branch' do
+ get api('/merge_requests', user), target_branch: merge_request_closed.target_branch, state: 'all'
+
+ expect(json_response.length).to eq(2)
+ expect(json_response.map { |mr| mr['id'] })
+ .to contain_exactly(merge_request_closed.id, merge_request_merged.id)
+ end
+ end
+
+ it 'returns merge requests created before a specific date' do
+ merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', created_at: Date.new(2000, 1, 1))
+
+ get api('/merge_requests?created_before=2000-01-02T00:00:00.060Z', user)
+
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request2.id)
+ end
+
+ it 'returns merge requests created after a specific date' do
+ merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', created_at: 1.week.from_now)
+
+ get api("/merge_requests?created_after=#{merge_request2.created_at}", user)
+
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request2.id)
+ end
+
+ it 'returns merge requests updated before a specific date' do
+ merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', updated_at: Date.new(2000, 1, 1))
+
+ get api('/merge_requests?updated_before=2000-01-02T00:00:00.060Z', user)
+
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request2.id)
+ end
+
+ it 'returns merge requests updated after a specific date' do
+ merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', updated_at: 1.week.from_now)
+
+ get api("/merge_requests?updated_after=#{merge_request2.updated_at}", user)
+
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['id']).to eq(merge_request2.id)
+ end
+
context 'search params' do
before do
merge_request.update(title: 'Search title', description: 'Search description')
@@ -198,6 +255,8 @@ describe API::MergeRequests do
create(:merge_request, state: 'closed', milestone: milestone1, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time)
+ create(:merge_request, 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)
@@ -424,6 +483,26 @@ describe API::MergeRequests do
expect(response_dates).to eq(response_dates.sort)
end
end
+
+ context 'source_branch param' do
+ it 'returns merge requests with the given source branch' do
+ get api('/merge_requests', user), source_branch: merge_request_closed.source_branch, state: 'all'
+
+ expect(json_response.length).to eq(2)
+ expect(json_response.map { |mr| mr['id'] })
+ .to contain_exactly(merge_request_closed.id, merge_request_merged.id)
+ end
+ end
+
+ context 'target_branch param' do
+ it 'returns merge requests with the given target branch' do
+ get api('/merge_requests', user), target_branch: merge_request_closed.target_branch, state: 'all'
+
+ expect(json_response.length).to eq(2)
+ expect(json_response.map { |mr| mr['id'] })
+ .to contain_exactly(merge_request_closed.id, merge_request_merged.id)
+ end
+ end
end
end
@@ -458,6 +537,45 @@ describe API::MergeRequests do
expect(json_response['changes_count']).to eq(merge_request.merge_request_diff.real_size)
end
+ context 'merge_request_metrics' do
+ before do
+ merge_request.metrics.update!(merged_by: user,
+ latest_closed_by: user,
+ latest_closed_at: 1.hour.ago,
+ merged_at: 2.hours.ago,
+ pipeline: pipeline,
+ latest_build_started_at: 3.hours.ago,
+ latest_build_finished_at: 1.hour.ago,
+ first_deployed_to_production_at: 3.minutes.ago)
+ end
+
+ it 'has fields from merge request metrics' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user)
+
+ expect(json_response).to include('merged_by',
+ 'merged_at',
+ 'closed_by',
+ 'closed_at',
+ 'latest_build_started_at',
+ 'latest_build_finished_at',
+ 'first_deployed_to_production_at',
+ 'pipeline')
+ end
+
+ it 'returns correct values' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.reload.iid}", user)
+
+ expect(json_response['merged_by']['id']).to eq(merge_request.metrics.merged_by_id)
+ expect(Time.parse json_response['merged_at']).to be_like_time(merge_request.metrics.merged_at)
+ expect(json_response['closed_by']['id']).to eq(merge_request.metrics.latest_closed_by_id)
+ expect(Time.parse json_response['closed_at']).to be_like_time(merge_request.metrics.latest_closed_at)
+ expect(json_response['pipeline']['id']).to eq(merge_request.metrics.pipeline_id)
+ expect(Time.parse json_response['latest_build_started_at']).to be_like_time(merge_request.metrics.latest_build_started_at)
+ expect(Time.parse json_response['latest_build_finished_at']).to be_like_time(merge_request.metrics.latest_build_finished_at)
+ expect(Time.parse json_response['first_deployed_to_production_at']).to be_like_time(merge_request.metrics.first_deployed_to_production_at)
+ end
+ end
+
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_gitlab_http_status(404)
@@ -498,6 +616,31 @@ describe API::MergeRequests do
expect(json_response['changes_count']).to eq('5+')
end
end
+
+ context 'for forked projects' do
+ let(:user2) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:forked_project) { fork_project(project, user2, repository: true) }
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: forked_project,
+ target_project: project,
+ source_branch: 'fixes',
+ allow_maintainer_to_push: true)
+ end
+
+ it 'includes the `allow_maintainer_to_push` field' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user)
+
+ expect(json_response['allow_maintainer_to_push']).to be_truthy
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/merge_requests/:merge_request_iid/participants' do
+ it_behaves_like 'issuable participants endpoint' do
+ let(:entity) { merge_request }
+ end
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/commits' do
@@ -545,6 +688,49 @@ describe API::MergeRequests do
end
end
+ describe 'GET /projects/:id/merge_requests/:merge_request_iid/pipelines' do
+ context 'when authorized' do
+ let!(:pipeline) { create(:ci_empty_pipeline, project: project, user: user, ref: merge_request.source_branch, sha: merge_request.diff_head_sha) }
+ let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
+
+ it 'returns a paginated array of corresponding pipelines' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/pipelines")
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq(1)
+ expect(json_response.first['id']).to eq(pipeline.id)
+ end
+
+ it 'exposes basic attributes' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/pipelines")
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pipelines')
+ end
+
+ it 'returns 404 if MR does not exist' do
+ get api("/projects/#{project.id}/merge_requests/777/pipelines")
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'when unauthorized' do
+ it 'returns 403' do
+ project = create(:project, public_builds: false)
+ merge_request = create(:merge_request, :simple, source_project: project)
+ guest = create(:user)
+ project.add_guest(guest)
+
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/pipelines", guest)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+
describe "POST /projects/:id/merge_requests" do
context 'between branches projects' do
it "returns merge_request" do
@@ -648,6 +834,7 @@ describe API::MergeRequests do
context 'forked projects' do
let!(:user2) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
let!(:forked_project) { fork_project(project, user2, repository: true) }
let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) }
@@ -705,16 +892,36 @@ describe API::MergeRequests do
expect(response).to have_gitlab_http_status(400)
end
- context 'when target_branch is specified' do
+ it 'allows setting `allow_maintainer_to_push`' do
+ post api("/projects/#{forked_project.id}/merge_requests", user2),
+ title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master",
+ author: user2, target_project_id: project.id, allow_maintainer_to_push: true
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['allow_maintainer_to_push']).to be_truthy
+ end
+
+ context 'when target_branch and target_project_id is specified' do
+ let(:params) do
+ { title: 'Test merge_request',
+ target_branch: 'master',
+ source_branch: 'markdown',
+ author: user2,
+ target_project_id: unrelated_project.id }
+ end
+
it 'returns 422 if targeting a different fork' do
- post api("/projects/#{forked_project.id}/merge_requests", user2),
- title: 'Test merge_request',
- target_branch: 'master',
- source_branch: 'markdown',
- author: user2,
- target_project_id: unrelated_project.id
+ unrelated_project.add_developer(user2)
+
+ post api("/projects/#{forked_project.id}/merge_requests", user2), params
+
expect(response).to have_gitlab_http_status(422)
end
+
+ it 'returns 403 if targeting a different fork which user can not access' do
+ post api("/projects/#{forked_project.id}/merge_requests", user2), params
+
+ expect(response).to have_gitlab_http_status(403)
+ end
end
it "returns 201 when target_branch is specified and for the same project" do
@@ -730,7 +937,7 @@ describe API::MergeRequests do
let(:developer) { create(:user) }
before do
- project.team << [developer, :developer]
+ project.add_developer(developer)
end
it "denies the deletion of the merge request" do
@@ -808,7 +1015,7 @@ describe API::MergeRequests do
it "returns 401 if user has no permissions to merge" do
user2 = create(:user)
- project.team << [user2, :reporter]
+ project.add_reporter(user2)
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user2)
expect(response).to have_gitlab_http_status(401)
expect(json_response['message']).to eq('401 Unauthorized')
@@ -997,7 +1204,7 @@ describe API::MergeRequests do
project = create(:project, :private)
merge_request = create(:merge_request, :simple, source_project: project)
guest = create(:user)
- project.team << [guest, :guest]
+ project.add_guest(guest)
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/closes_issues", guest)
@@ -1045,7 +1252,7 @@ describe API::MergeRequests do
it 'returns 403 if user has no access to read code' do
guest = create(:user)
- project.team << [guest, :guest]
+ project.add_guest(guest)
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", guest)
@@ -1081,7 +1288,7 @@ describe API::MergeRequests do
it 'returns 403 if user has no access to read code' do
guest = create(:user)
- project.team << [guest, :guest]
+ project.add_guest(guest)
post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", guest)
diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb
index 3bfb4c5506f..dd568c24c72 100644
--- a/spec/requests/api/notes_spec.rb
+++ b/spec/requests/api/notes_spec.rb
@@ -3,117 +3,86 @@ require 'spec_helper'
describe API::Notes do
let(:user) { create(:user) }
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) }
- let!(:issue_note) { create(:note, noteable: issue, project: project, author: user) }
- let!(:merge_request_note) { create(:note, noteable: merge_request, project: project, author: user) }
- let!(:snippet_note) { create(:note, noteable: snippet, project: project, author: user) }
-
- # For testing the cross-reference of a private issue in a public issue
let(:private_user) { create(:user) }
- let(:private_project) do
- 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(:project, :public) }
- let(:ext_issue) { create(:issue, project: ext_proj) }
-
- let!(:cross_reference_note) do
- create :note,
- noteable: ext_issue, project: ext_proj,
- note: "mentioned in issue #{private_issue.to_reference(ext_proj)}",
- system: true
- end
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
- describe "GET /projects/:id/noteable/:noteable_id/notes" do
- context "when noteable is an Issue" do
- context 'sorting' do
- before do
- create_list(:note, 3, noteable: issue, project: project, author: user)
- end
-
- it 'sorts by created_at in descending order by default' do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes", user)
-
- response_dates = json_response.map { |noteable| noteable['created_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort.reverse)
- end
-
- it 'sorts by ascending order when requested' do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes?sort=asc", user)
-
- response_dates = json_response.map { |noteable| noteable['created_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort)
- end
-
- it 'sorts by updated_at in descending order when requested' do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes?order_by=updated_at", user)
-
- response_dates = json_response.map { |noteable| noteable['updated_at'] }
+ context "when noteable is an Issue" do
+ let!(:issue) { create(:issue, project: project, author: user) }
+ let!(:issue_note) { create(:note, noteable: issue, project: project, author: user) }
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort.reverse)
- end
+ it_behaves_like "noteable API", 'projects', 'issues', 'iid' do
+ let(:parent) { project }
+ let(:noteable) { issue }
+ let(:note) { issue_note }
+ end
- it 'sorts by updated_at in ascending order when requested' do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes??order_by=updated_at&sort=asc", user)
+ context 'when user does not have access to create noteable' do
+ let(:private_issue) { create(:issue, project: create(:project, :private)) }
- response_dates = json_response.map { |noteable| noteable['updated_at'] }
+ ##
+ # We are posting to project user has access to, but we use issue id
+ # from a different project, see #15577
+ #
+ before do
+ post api("/projects/#{private_issue.project.id}/issues/#{private_issue.iid}/notes", user),
+ body: 'Hi!'
+ end
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort)
- end
+ it 'responds with resource not found error' do
+ expect(response.status).to eq 404
end
- it "returns an array of issue notes" do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes", user)
+ it 'does not create new note' do
+ expect(private_issue.notes.reload).to be_empty
+ end
+ end
- expect(response).to have_gitlab_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.first['body']).to eq(issue_note.note)
+ context "when referencing other project" do
+ # For testing the cross-reference of a private issue in a public project
+ let(:private_project) do
+ create(:project, namespace: private_user.namespace)
+ .tap { |p| p.add_master(private_user) }
end
+ let(:private_issue) { create(:issue, project: private_project) }
- it "returns a 404 error when issue id not found" do
- get api("/projects/#{project.id}/issues/12345/notes", user)
+ let(:ext_proj) { create(:project, :public) }
+ let(:ext_issue) { create(:issue, project: ext_proj) }
- expect(response).to have_gitlab_http_status(404)
+ let!(:cross_reference_note) do
+ create :note,
+ noteable: ext_issue, project: ext_proj,
+ note: "mentioned in issue #{private_issue.to_reference(ext_proj)}",
+ system: true
end
- context "and current user cannot view the notes" do
- it "returns an empty array" do
- get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user)
-
- expect(response).to have_gitlab_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response).to be_empty
- end
+ describe "GET /projects/:id/noteable/:noteable_id/notes" do
+ context "current user cannot view the notes" do
+ it "returns an empty array" do
+ get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user)
- context "and issue is confidential" do
- before do
- ext_issue.update_attributes(confidential: true)
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response).to be_empty
end
- it "returns 404" do
- get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user)
+ context "issue is confidential" do
+ before do
+ ext_issue.update_attributes(confidential: true)
+ end
- expect(response).to have_gitlab_http_status(404)
+ it "returns 404" do
+ get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
end
end
- context "and current user can view the note" do
+ context "current user can view the note" do
it "returns an empty array" do
get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes", private_user)
@@ -124,172 +93,29 @@ describe API::Notes do
end
end
end
- end
-
- context "when noteable is a Snippet" do
- context 'sorting' do
- before do
- create_list(:note, 3, noteable: snippet, project: project, author: user)
- end
-
- it 'sorts by created_at in descending order by default' do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user)
-
- response_dates = json_response.map { |noteable| noteable['created_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort.reverse)
- end
-
- it 'sorts by ascending order when requested' do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes?sort=asc", user)
-
- response_dates = json_response.map { |noteable| noteable['created_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort)
- end
-
- it 'sorts by updated_at in descending order when requested' do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes?order_by=updated_at", user)
-
- response_dates = json_response.map { |noteable| noteable['updated_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort.reverse)
- end
- it 'sorts by updated_at in ascending order when requested' do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes??order_by=updated_at&sort=asc", user)
+ describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do
+ context "current user cannot view the notes" do
+ it "returns a 404 error" do
+ get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes/#{cross_reference_note.id}", user)
- response_dates = json_response.map { |noteable| noteable['updated_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort)
- end
- end
- it "returns an array of snippet notes" do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user)
-
- expect(response).to have_gitlab_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.first['body']).to eq(snippet_note.note)
- end
-
- it "returns a 404 error when snippet id not found" do
- get api("/projects/#{project.id}/snippets/42/notes", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it "returns 404 when not authorized" do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes", private_user)
-
- expect(response).to have_gitlab_http_status(404)
- end
- end
-
- context "when noteable is a Merge Request" do
- context 'sorting' do
- before do
- create_list(:note, 3, noteable: merge_request, project: project, author: user)
- end
-
- it 'sorts by created_at in descending order by default' do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes", user)
-
- response_dates = json_response.map { |noteable| noteable['created_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort.reverse)
- end
-
- it 'sorts by ascending order when requested' do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes?sort=asc", user)
-
- response_dates = json_response.map { |noteable| noteable['created_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort)
- end
-
- it 'sorts by updated_at in descending order when requested' do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes?order_by=updated_at", user)
-
- response_dates = json_response.map { |noteable| noteable['updated_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort.reverse)
- end
-
- it 'sorts by updated_at in ascending order when requested' do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes??order_by=updated_at&sort=asc", user)
-
- response_dates = json_response.map { |noteable| noteable['updated_at'] }
-
- expect(json_response.length).to eq(4)
- expect(response_dates).to eq(response_dates.sort)
- end
- end
- it "returns an array of merge_requests notes" do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes", user)
-
- expect(response).to have_gitlab_http_status(200)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.first['body']).to eq(merge_request_note.note)
- end
-
- it "returns a 404 error if merge request id not found" do
- get api("/projects/#{project.id}/merge_requests/4444/notes", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it "returns 404 when not authorized" do
- get api("/projects/#{project.id}/merge_requests/4444/notes", private_user)
-
- expect(response).to have_gitlab_http_status(404)
- end
- end
- end
-
- describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do
- context "when noteable is an Issue" do
- it "returns an issue note by id" do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", user)
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['body']).to eq(issue_note.note)
- end
-
- it "returns a 404 error if issue note not found" do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes/12345", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- context "and current user cannot view the note" do
- it "returns a 404 error" do
- get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes/#{cross_reference_note.id}", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- context "when issue is confidential" do
- before do
- issue.update_attributes(confidential: true)
+ expect(response).to have_gitlab_http_status(404)
end
- it "returns 404" do
- get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", private_user)
+ context "when issue is confidential" do
+ before do
+ issue.update_attributes(confidential: true)
+ end
- expect(response).to have_gitlab_http_status(404)
+ it "returns 404" do
+ get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", private_user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
end
end
- context "and current user can view the note" do
+ context "current user can view the note" do
it "returns an issue note by id" do
get api("/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes/#{cross_reference_note.id}", private_user)
@@ -299,132 +125,27 @@ describe API::Notes do
end
end
end
-
- context "when noteable is a Snippet" do
- it "returns a snippet note by id" do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes/#{snippet_note.id}", user)
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['body']).to eq(snippet_note.note)
- end
-
- it "returns a 404 error if snippet note not found" do
- get api("/projects/#{project.id}/snippets/#{snippet.id}/notes/12345", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
- end
end
- describe "POST /projects/:id/noteable/:noteable_id/notes" do
- context "when noteable is an Issue" do
- it "creates a new issue note" do
- post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), body: 'hi!'
-
- expect(response).to have_gitlab_http_status(201)
- expect(json_response['body']).to eq('hi!')
- expect(json_response['author']['username']).to eq(user.username)
- end
-
- it "returns a 400 bad request error if body not given" do
- post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user)
-
- expect(response).to have_gitlab_http_status(400)
- end
-
- it "returns a 401 unauthorized error if user not authenticated" do
- post api("/projects/#{project.id}/issues/#{issue.iid}/notes"), body: 'hi!'
-
- expect(response).to have_gitlab_http_status(401)
- end
-
- context 'when an admin or owner makes the request' do
- it 'accepts the creation date to be set' do
- creation_time = 2.weeks.ago
- post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user),
- body: 'hi!', created_at: creation_time
-
- expect(response).to have_gitlab_http_status(201)
- expect(json_response['body']).to eq('hi!')
- expect(json_response['author']['username']).to eq(user.username)
- expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time)
- end
- end
-
- context 'when the user is posting an award emoji on an issue created by someone else' do
- let(:issue2) { create(:issue, project: project) }
-
- it 'creates a new issue note' do
- post api("/projects/#{project.id}/issues/#{issue2.iid}/notes", user), body: ':+1:'
-
- expect(response).to have_gitlab_http_status(201)
- expect(json_response['body']).to eq(':+1:')
- end
- end
-
- context 'when the user is posting an award emoji on his/her own issue' do
- it 'creates a new issue note' do
- post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), body: ':+1:'
-
- expect(response).to have_gitlab_http_status(201)
- expect(json_response['body']).to eq(':+1:')
- end
- end
- end
-
- context "when noteable is a Snippet" do
- it "creates a new snippet note" do
- post api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user), body: 'hi!'
+ context "when noteable is a Snippet" do
+ let!(:snippet) { create(:project_snippet, project: project, author: user) }
+ let!(:snippet_note) { create(:note, noteable: snippet, project: project, author: user) }
- expect(response).to have_gitlab_http_status(201)
- expect(json_response['body']).to eq('hi!')
- expect(json_response['author']['username']).to eq(user.username)
- end
-
- it "returns a 400 bad request error if body not given" do
- post api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user)
-
- expect(response).to have_gitlab_http_status(400)
- end
-
- it "returns a 401 unauthorized error if user not authenticated" do
- post api("/projects/#{project.id}/snippets/#{snippet.id}/notes"), body: 'hi!'
-
- expect(response).to have_gitlab_http_status(401)
- end
+ it_behaves_like "noteable API", 'projects', 'snippets', 'id' do
+ let(:parent) { project }
+ let(:noteable) { snippet }
+ let(:note) { snippet_note }
end
+ end
- context 'when user does not have access to read the noteable' do
- it 'responds with 404' do
- 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),
- body: 'Foo'
-
- expect(response).to have_gitlab_http_status(404)
- end
- end
-
- context 'when user does not have access to create noteable' do
- let(:private_issue) { create(:issue, project: create(:project, :private)) }
-
- ##
- # We are posting to project user has access to, but we use issue id
- # from a different project, see #15577
- #
- before do
- post api("/projects/#{private_issue.project.id}/issues/#{private_issue.iid}/notes", user),
- body: 'Hi!'
- end
-
- it 'responds with resource not found error' do
- expect(response.status).to eq 404
- end
+ context "when noteable is a Merge Request" do
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) }
+ let!(:merge_request_note) { create(:note, noteable: merge_request, project: project, author: user) }
- it 'does not create new note' do
- expect(private_issue.notes.reload).to be_empty
- end
+ it_behaves_like "noteable API", 'projects', 'merge_requests', 'iid' do
+ let(:parent) { project }
+ let(:noteable) { merge_request }
+ let(:note) { merge_request_note }
end
context 'when the merge request discussion is locked' do
@@ -461,145 +182,4 @@ describe API::Notes do
end
end
end
-
- describe "POST /projects/:id/noteable/:noteable_id/notes to test observer on create" do
- it "creates an activity event when an issue note is created" do
- expect(Event).to receive(:create)
-
- post api("/projects/#{project.id}/issues/#{issue.iid}/notes", user), body: 'hi!'
- end
- end
-
- describe 'PUT /projects/:id/noteable/:noteable_id/notes/:note_id' do
- context 'when noteable is an Issue' do
- it 'returns modified note' do
- put api("/projects/#{project.id}/issues/#{issue.iid}/"\
- "notes/#{issue_note.id}", user), body: 'Hello!'
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['body']).to eq('Hello!')
- end
-
- it 'returns a 404 error when note id not found' do
- put api("/projects/#{project.id}/issues/#{issue.iid}/notes/12345", user),
- body: 'Hello!'
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'returns a 400 bad request error if body not given' do
- put api("/projects/#{project.id}/issues/#{issue.iid}/"\
- "notes/#{issue_note.id}", user)
-
- expect(response).to have_gitlab_http_status(400)
- end
- end
-
- context 'when noteable is a Snippet' do
- it 'returns modified note' do
- put api("/projects/#{project.id}/snippets/#{snippet.id}/"\
- "notes/#{snippet_note.id}", user), body: 'Hello!'
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['body']).to eq('Hello!')
- end
-
- it 'returns a 404 error when note id not found' do
- put api("/projects/#{project.id}/snippets/#{snippet.id}/"\
- "notes/12345", user), body: "Hello!"
-
- expect(response).to have_gitlab_http_status(404)
- end
- end
-
- context 'when noteable is a Merge Request' do
- it 'returns modified note' do
- put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/"\
- "notes/#{merge_request_note.id}", user), body: 'Hello!'
-
- expect(response).to have_gitlab_http_status(200)
- expect(json_response['body']).to eq('Hello!')
- end
-
- it 'returns a 404 error when note id not found' do
- put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/"\
- "notes/12345", user), body: "Hello!"
-
- expect(response).to have_gitlab_http_status(404)
- end
- end
- end
-
- describe 'DELETE /projects/:id/noteable/:noteable_id/notes/:note_id' do
- context 'when noteable is an Issue' do
- it 'deletes a note' do
- delete api("/projects/#{project.id}/issues/#{issue.iid}/"\
- "notes/#{issue_note.id}", user)
-
- expect(response).to have_gitlab_http_status(204)
- # Check if note is really deleted
- delete api("/projects/#{project.id}/issues/#{issue.iid}/"\
- "notes/#{issue_note.id}", user)
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'returns a 404 error when note id not found' do
- delete api("/projects/#{project.id}/issues/#{issue.iid}/notes/12345", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{issue_note.id}", user) }
- end
- end
-
- context 'when noteable is a Snippet' do
- it 'deletes a note' do
- delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\
- "notes/#{snippet_note.id}", user)
-
- expect(response).to have_gitlab_http_status(204)
- # Check if note is really deleted
- delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\
- "notes/#{snippet_note.id}", user)
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'returns a 404 error when note id not found' do
- delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\
- "notes/12345", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project.id}/snippets/#{snippet.id}/notes/#{snippet_note.id}", user) }
- end
- end
-
- context 'when noteable is a Merge Request' do
- it 'deletes a note' do
- delete api("/projects/#{project.id}/merge_requests/"\
- "#{merge_request.iid}/notes/#{merge_request_note.id}", user)
-
- expect(response).to have_gitlab_http_status(204)
- # Check if note is really deleted
- delete api("/projects/#{project.id}/merge_requests/"\
- "#{merge_request.iid}/notes/#{merge_request_note.id}", user)
- expect(response).to have_gitlab_http_status(404)
- end
-
- it 'returns a 404 error when note id not found' do
- delete api("/projects/#{project.id}/merge_requests/"\
- "#{merge_request.iid}/notes/12345", user)
-
- expect(response).to have_gitlab_http_status(404)
- end
-
- it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes/#{merge_request_note.id}", user) }
- end
- end
- end
end
diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb
index d412b045e9f..a9ccbb32666 100644
--- a/spec/requests/api/pages_domains_spec.rb
+++ b/spec/requests/api/pages_domains_spec.rb
@@ -1,21 +1,22 @@
require 'rails_helper'
describe API::PagesDomains do
- set(:project) { create(:project) }
+ set(:project) { create(:project, path: 'my.project', pages_https_only: false) }
set(:user) { create(:user) }
set(:admin) { create(:admin) }
- set(:pages_domain) { create(:pages_domain, domain: 'www.domain.test', project: project) }
- set(:pages_domain_secure) { create(:pages_domain, :with_certificate, :with_key, domain: 'ssl.domain.test', project: project) }
- set(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, :with_key, domain: 'expired.domain.test', project: project) }
+ set(:pages_domain) { create(:pages_domain, :without_key, :without_certificate, domain: 'www.domain.test', project: project) }
+ set(:pages_domain_secure) { create(:pages_domain, domain: 'ssl.domain.test', project: project) }
+ set(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, domain: 'expired.domain.test', project: project) }
- let(:pages_domain_params) { build(:pages_domain, domain: 'www.other-domain.test').slice(:domain) }
- let(:pages_domain_secure_params) { build(:pages_domain, :with_certificate, :with_key, domain: 'ssl.other-domain.test', project: project).slice(:domain, :certificate, :key) }
- let(:pages_domain_secure_key_missmatch_params) {build(:pages_domain, :with_trusted_chain, :with_key, project: project).slice(:domain, :certificate, :key) }
+ let(:pages_domain_params) { build(:pages_domain, :without_key, :without_certificate, domain: 'www.other-domain.test').slice(:domain) }
+ let(:pages_domain_secure_params) { build(:pages_domain, domain: 'ssl.other-domain.test', project: project).slice(:domain, :certificate, :key) }
+ let(:pages_domain_secure_key_missmatch_params) {build(:pages_domain, :with_trusted_chain, project: project).slice(:domain, :certificate, :key) }
let(:pages_domain_secure_missing_chain_params) {build(:pages_domain, :with_missing_chain, project: project).slice(:certificate) }
let(:route) { "/projects/#{project.id}/pages/domains" }
let(:route_domain) { "/projects/#{project.id}/pages/domains/#{pages_domain.domain}" }
+ let(:route_domain_path) { "/projects/#{project.full_path.gsub('/', '%2F')}/pages/domains/#{pages_domain.domain}" }
let(:route_secure_domain) { "/projects/#{project.id}/pages/domains/#{pages_domain_secure.domain}" }
let(:route_expired_domain) { "/projects/#{project.id}/pages/domains/#{pages_domain_expired.domain}" }
let(:route_vacant_domain) { "/projects/#{project.id}/pages/domains/www.vacant-domain.test" }
@@ -46,6 +47,7 @@ describe API::PagesDomains do
expect(json_response).to be_an Array
expect(json_response.size).to eq(3)
expect(json_response.last).to have_key('domain')
+ expect(json_response.last).to have_key('project_id')
expect(json_response.last).to have_key('certificate_expiration')
expect(json_response.last['certificate_expiration']['expired']).to be true
expect(json_response.first).not_to have_key('certificate_expiration')
@@ -143,6 +145,16 @@ describe API::PagesDomains do
expect(json_response['certificate']).to be_nil
end
+ it 'returns pages domain with project path' do
+ get api(route_domain_path, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
+ expect(json_response['domain']).to eq(pages_domain.domain)
+ expect(json_response['url']).to eq(pages_domain.url)
+ expect(json_response['certificate']).to be_nil
+ end
+
it 'returns pages domain with a certificate' do
get api(route_secure_domain, user)
diff --git a/spec/requests/api/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb
index 7ea25059756..91d4d5d3de9 100644
--- a/spec/requests/api/pipeline_schedules_spec.rb
+++ b/spec/requests/api/pipeline_schedules_spec.rb
@@ -17,6 +17,17 @@ describe API::PipelineSchedules do
pipeline_schedule.pipelines << build(:ci_pipeline, project: project)
end
+ def create_pipeline_schedules(count)
+ create_list(:ci_pipeline_schedule, count, 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
+ end
+
it 'returns list of pipeline_schedules' do
get api("/projects/#{project.id}/pipeline_schedules", developer)
@@ -26,18 +37,14 @@ describe API::PipelineSchedules do
end
it 'avoids N + 1 queries' do
+ # We need at least two users to trigger a preload for that relation.
+ create_pipeline_schedules(1)
+
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
+ create_pipeline_schedules(10)
expect do
get api("/projects/#{project.id}/pipeline_schedules", developer)
diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb
index e4dcc9252fa..0736329f9fd 100644
--- a/spec/requests/api/pipelines_spec.rb
+++ b/spec/requests/api/pipelines_spec.rb
@@ -11,7 +11,7 @@ describe API::Pipelines do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET /projects/:id/pipelines ' do
@@ -424,7 +424,7 @@ describe API::Pipelines do
let!(:reporter) { create(:user) }
before do
- project.team << [reporter, :reporter]
+ project.add_reporter(reporter)
end
it 'rejects the action' do
diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb
new file mode 100644
index 00000000000..3834d27d0a9
--- /dev/null
+++ b/spec/requests/api/project_export_spec.rb
@@ -0,0 +1,370 @@
+require 'spec_helper'
+
+describe API::ProjectExport do
+ set(:project) { create(:project) }
+ set(:project_none) { create(:project) }
+ set(:project_started) { create(:project) }
+ set(:project_finished) { create(:project) }
+ set(:project_after_export) { create(:project) }
+ set(:user) { create(:user) }
+ set(:admin) { create(:admin) }
+
+ let(:path) { "/projects/#{project.id}/export" }
+ let(:path_none) { "/projects/#{project_none.id}/export" }
+ let(:path_started) { "/projects/#{project_started.id}/export" }
+ let(:path_finished) { "/projects/#{project_finished.id}/export" }
+ let(:path_after_export) { "/projects/#{project_after_export.id}/export" }
+
+ let(:download_path) { "/projects/#{project.id}/export/download" }
+ let(:download_path_none) { "/projects/#{project_none.id}/export/download" }
+ let(:download_path_started) { "/projects/#{project_started.id}/export/download" }
+ let(:download_path_finished) { "/projects/#{project_finished.id}/export/download" }
+ let(:download_path_export_action) { "/projects/#{project_after_export.id}/export/download" }
+
+ let(:export_path) { "#{Dir.tmpdir}/project_export_spec" }
+
+ before do
+ allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+
+ # simulate exporting work directory
+ FileUtils.mkdir_p File.join(project_started.export_path, 'securerandom-hex')
+
+ # simulate exported
+ FileUtils.mkdir_p project_finished.export_path
+ FileUtils.touch File.join(project_finished.export_path, '_export.tar.gz')
+
+ # simulate in after export action
+ FileUtils.mkdir_p project_after_export.export_path
+ FileUtils.touch File.join(project_after_export.export_path, '_export.tar.gz')
+ FileUtils.touch Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy.lock_file_path(project_after_export)
+ end
+
+ after do
+ FileUtils.rm_rf(export_path, secure: true)
+ end
+
+ shared_examples_for 'when project export is disabled' do
+ before do
+ stub_application_setting(project_export_enabled?: false)
+ end
+
+ it_behaves_like '404 response'
+ end
+
+ describe 'GET /projects/:project_id/export' do
+ shared_examples_for 'get project export status not found' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(path, user) }
+ end
+ end
+
+ shared_examples_for 'get project export status denied' do
+ it_behaves_like '403 response' do
+ let(:request) { get api(path, user) }
+ end
+ end
+
+ shared_examples_for 'get project export status ok' do
+ it 'is none' do
+ get api(path_none, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/project/export_status')
+ expect(json_response['export_status']).to eq('none')
+ end
+
+ it 'is started' do
+ get api(path_started, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/project/export_status')
+ expect(json_response['export_status']).to eq('started')
+ end
+
+ it 'is after_export' do
+ get api(path_after_export, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/project/export_status')
+ expect(json_response['export_status']).to eq('after_export_action')
+ end
+
+ it 'is finished' do
+ get api(path_finished, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/project/export_status')
+ expect(json_response['export_status']).to eq('finished')
+ end
+ end
+
+ it_behaves_like 'when project export is disabled' do
+ let(:request) { get api(path, admin) }
+ end
+
+ context 'when project export is enabled' do
+ context 'when user is an admin' do
+ let(:user) { admin }
+
+ it_behaves_like 'get project export status ok'
+ end
+
+ context 'when user is a master' do
+ before do
+ project.add_master(user)
+ project_none.add_master(user)
+ project_started.add_master(user)
+ project_finished.add_master(user)
+ project_after_export.add_master(user)
+ end
+
+ it_behaves_like 'get project export status ok'
+ end
+
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'get project export status denied'
+ end
+
+ context 'when user is a reporter' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it_behaves_like 'get project export status denied'
+ end
+
+ context 'when user is a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like 'get project export status denied'
+ end
+
+ context 'when user is not a member' do
+ it_behaves_like 'get project export status not found'
+ end
+ end
+ end
+
+ describe 'GET /projects/:project_id/export/download' do
+ shared_examples_for 'get project export download not found' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(download_path, user) }
+ end
+ end
+
+ shared_examples_for 'get project export download denied' do
+ it_behaves_like '403 response' do
+ let(:request) { get api(download_path, user) }
+ end
+ end
+
+ shared_examples_for 'get project export download' do
+ it_behaves_like '404 response' do
+ let(:request) { get api(download_path_none, user) }
+ end
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(download_path_started, user) }
+ end
+
+ it 'downloads' do
+ get api(download_path_finished, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ shared_examples_for 'get project export upload after action' do
+ context 'and is uploading' do
+ it 'downloads' do
+ get api(download_path_export_action, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ context 'when upload complete' do
+ before do
+ FileUtils.rm_rf(project_after_export.export_path)
+ end
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(download_path_export_action, user) }
+ end
+ end
+ end
+
+ shared_examples_for 'get project download by strategy' do
+ context 'when upload strategy set' do
+ it_behaves_like 'get project export upload after action'
+ end
+
+ context 'when download strategy set' do
+ it_behaves_like 'get project export download'
+ end
+ end
+
+ it_behaves_like 'when project export is disabled' do
+ let(:request) { get api(download_path, admin) }
+ end
+
+ context 'when project export is enabled' do
+ context 'when user is an admin' do
+ let(:user) { admin }
+
+ it_behaves_like 'get project download by strategy'
+ end
+
+ context 'when user is a master' do
+ before do
+ project.add_master(user)
+ project_none.add_master(user)
+ project_started.add_master(user)
+ project_finished.add_master(user)
+ project_after_export.add_master(user)
+ end
+
+ it_behaves_like 'get project download by strategy'
+ end
+
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'get project export download denied'
+ end
+
+ context 'when user is a reporter' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it_behaves_like 'get project export download denied'
+ end
+
+ context 'when user is a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like 'get project export download denied'
+ end
+
+ context 'when user is not a member' do
+ it_behaves_like 'get project export download not found'
+ end
+ end
+ end
+
+ describe 'POST /projects/:project_id/export' do
+ shared_examples_for 'post project export start not found' do
+ it_behaves_like '404 response' do
+ let(:request) { post api(path, user) }
+ end
+ end
+
+ shared_examples_for 'post project export start denied' do
+ it_behaves_like '403 response' do
+ let(:request) { post api(path, user) }
+ end
+ end
+
+ shared_examples_for 'post project export start' do
+ context 'with upload strategy' do
+ context 'when params invalid' do
+ it_behaves_like '400 response' do
+ let(:request) { post(api(path, user), 'upload[url]' => 'whatever') }
+ end
+ end
+
+ it 'starts' do
+ allow_any_instance_of(Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy).to receive(:send_file)
+
+ post(api(path, user), 'upload[url]' => 'http://gitlab.com')
+
+ expect(response).to have_gitlab_http_status(202)
+ end
+ end
+
+ context 'with download strategy' do
+ it 'starts' do
+ expect_any_instance_of(Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy).not_to receive(:send_file)
+
+ post api(path, user)
+
+ expect(response).to have_gitlab_http_status(202)
+ end
+ end
+ end
+
+ it_behaves_like 'when project export is disabled' do
+ let(:request) { post api(path, admin) }
+ end
+
+ context 'when project export is enabled' do
+ context 'when user is an admin' do
+ let(:user) { admin }
+
+ it_behaves_like 'post project export start'
+ end
+
+ context 'when user is a master' do
+ before do
+ project.add_master(user)
+ project_none.add_master(user)
+ project_started.add_master(user)
+ project_finished.add_master(user)
+ project_after_export.add_master(user)
+ end
+
+ it_behaves_like 'post project export start'
+ end
+
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'post project export start denied'
+ end
+
+ context 'when user is a reporter' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it_behaves_like 'post project export start denied'
+ end
+
+ context 'when user is a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like 'post project export start denied'
+ end
+
+ context 'when user is not a member' do
+ it_behaves_like 'post project export start not found'
+ end
+
+ context 'when overriding description' do
+ it 'starts' do
+ params = { description: "Foo" }
+
+ expect_any_instance_of(Projects::ImportExport::ExportService).to receive(:execute)
+ post api(path, project.owner), params
+
+ expect(response).to have_gitlab_http_status(202)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb
index f31344a6238..12a183fed1e 100644
--- a/spec/requests/api/project_hooks_spec.rb
+++ b/spec/requests/api/project_hooks_spec.rb
@@ -13,8 +13,8 @@ describe API::ProjectHooks, 'ProjectHooks' do
end
before do
- project.team << [user, :master]
- project.team << [user3, :developer]
+ project.add_master(user)
+ project.add_developer(user3)
end
describe "GET /projects/:id/hooks" do
@@ -28,10 +28,12 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response.count).to eq(1)
expect(json_response.first['url']).to eq("http://example.com")
expect(json_response.first['issues_events']).to eq(true)
+ expect(json_response.first['confidential_issues_events']).to eq(true)
expect(json_response.first['push_events']).to eq(true)
expect(json_response.first['merge_requests_events']).to eq(true)
expect(json_response.first['tag_push_events']).to eq(true)
expect(json_response.first['note_events']).to eq(true)
+ expect(json_response.first['confidential_note_events']).to eq(true)
expect(json_response.first['job_events']).to eq(true)
expect(json_response.first['pipeline_events']).to eq(true)
expect(json_response.first['wiki_page_events']).to eq(true)
@@ -56,10 +58,12 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(response).to have_gitlab_http_status(200)
expect(json_response['url']).to eq(hook.url)
expect(json_response['issues_events']).to eq(hook.issues_events)
+ expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events)
expect(json_response['push_events']).to eq(hook.push_events)
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['confidential_note_events']).to eq(hook.confidential_note_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)
@@ -90,17 +94,19 @@ describe API::ProjectHooks, 'ProjectHooks' do
it "adds hook to project" do
expect do
post api("/projects/#{project.id}/hooks", user),
- url: "http://example.com", issues_events: true, wiki_page_events: true,
+ url: "http://example.com", issues_events: true, confidential_issues_events: true, wiki_page_events: true,
job_events: true
end.to change {project.hooks.count}.by(1)
expect(response).to have_gitlab_http_status(201)
expect(json_response['url']).to eq('http://example.com')
expect(json_response['issues_events']).to eq(true)
+ expect(json_response['confidential_issues_events']).to eq(true)
expect(json_response['push_events']).to eq(true)
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['confidential_note_events']).to eq(nil)
expect(json_response['job_events']).to eq(true)
expect(json_response['pipeline_events']).to eq(false)
expect(json_response['wiki_page_events']).to eq(true)
@@ -144,10 +150,12 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(response).to have_gitlab_http_status(200)
expect(json_response['url']).to eq('http://example.org')
expect(json_response['issues_events']).to eq(hook.issues_events)
+ expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events)
expect(json_response['push_events']).to eq(false)
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['confidential_note_events']).to eq(hook.confidential_note_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)
@@ -206,7 +214,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(:project)
- other_project.team << [test_user, :master]
+ other_project.add_master(test_user)
delete api("/projects/#{other_project.id}/hooks/#{hook.id}", test_user)
expect(response).to have_gitlab_http_status(404)
diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb
new file mode 100644
index 00000000000..f68057a92a1
--- /dev/null
+++ b/spec/requests/api/project_import_spec.rb
@@ -0,0 +1,168 @@
+require 'spec_helper'
+
+describe API::ProjectImport do
+ let(:export_path) { "#{Dir.tmpdir}/project_export_spec" }
+ let(:user) { create(:user) }
+ let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') }
+ let(:namespace) { create(:group) }
+ before do
+ allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+
+ namespace.add_owner(user)
+ end
+
+ after do
+ FileUtils.rm_rf(export_path, secure: true)
+ end
+
+ describe 'POST /projects/import' do
+ it 'schedules an import using a namespace' do
+ stub_import(namespace)
+
+ post api('/projects/import', user), path: 'test-import', file: fixture_file_upload(file), namespace: namespace.id
+
+ expect(response).to have_gitlab_http_status(201)
+ end
+
+ it 'schedules an import using the namespace path' do
+ stub_import(namespace)
+
+ post api('/projects/import', user), path: 'test-import', file: fixture_file_upload(file), namespace: namespace.full_path
+
+ expect(response).to have_gitlab_http_status(201)
+ end
+
+ it 'schedules an import at the user namespace level' do
+ stub_import(user.namespace)
+
+ post api('/projects/import', user), path: 'test-import2', file: fixture_file_upload(file)
+
+ expect(response).to have_gitlab_http_status(201)
+ end
+
+ it 'does not shedule an import for a nampespace that does not exist' do
+ expect_any_instance_of(Project).not_to receive(:import_schedule)
+ expect(::Projects::CreateService).not_to receive(:new)
+
+ post api('/projects/import', user), namespace: 'nonexistent', path: 'test-import2', file: fixture_file_upload(file)
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(json_response['message']).to eq('404 Namespace Not Found')
+ end
+
+ it 'does not schedule an import if the user has no permission to the namespace' do
+ expect_any_instance_of(Project).not_to receive(:import_schedule)
+
+ post(api('/projects/import', create(:user)),
+ path: 'test-import3',
+ file: fixture_file_upload(file),
+ namespace: namespace.full_path)
+
+ expect(response).to have_gitlab_http_status(404)
+ expect(json_response['message']).to eq('404 Namespace Not Found')
+ end
+
+ it 'does not schedule an import if the user uploads no valid file' do
+ expect_any_instance_of(Project).not_to receive(:import_schedule)
+
+ post api('/projects/import', user), path: 'test-import3', file: './random/test'
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['error']).to eq('file is invalid')
+ end
+
+ it 'stores params that can be overridden' do
+ stub_import(namespace)
+ override_params = { 'description' => 'Hello world' }
+
+ post api('/projects/import', user),
+ path: 'test-import',
+ file: fixture_file_upload(file),
+ namespace: namespace.id,
+ override_params: override_params
+ import_project = Project.find(json_response['id'])
+
+ expect(import_project.import_data.data['override_params']).to eq(override_params)
+ end
+
+ it 'does not store params that are not allowed' do
+ stub_import(namespace)
+ override_params = { 'not_allowed' => 'Hello world' }
+
+ post api('/projects/import', user),
+ path: 'test-import',
+ file: fixture_file_upload(file),
+ namespace: namespace.id,
+ override_params: override_params
+ import_project = Project.find(json_response['id'])
+
+ expect(import_project.import_data.data['override_params']).to be_empty
+ end
+
+ it 'correctly overrides params during the import' do
+ override_params = { 'description' => 'Hello world' }
+
+ Sidekiq::Testing.inline! do
+ post api('/projects/import', user),
+ path: 'test-import',
+ file: fixture_file_upload(file),
+ namespace: namespace.id,
+ override_params: override_params
+ end
+ import_project = Project.find(json_response['id'])
+
+ expect(import_project.description).to eq('Hello world')
+ end
+
+ context 'when target path already exists in namespace' do
+ let(:existing_project) { create(:project, namespace: user.namespace) }
+
+ it 'does not schedule an import' do
+ expect_any_instance_of(Project).not_to receive(:import_schedule)
+
+ post api('/projects/import', user), path: existing_project.path, file: fixture_file_upload(file)
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response['message']).to eq('Name has already been taken')
+ end
+
+ context 'when param overwrite is true' do
+ it 'schedules an import' do
+ stub_import(user.namespace)
+
+ post api('/projects/import', user), path: existing_project.path, file: fixture_file_upload(file), overwrite: true
+
+ expect(response).to have_gitlab_http_status(201)
+ end
+ end
+ end
+
+ def stub_import(namespace)
+ expect_any_instance_of(Project).to receive(:import_schedule)
+ expect(::Projects::CreateService).to receive(:new).with(user, hash_including(namespace_id: namespace.id)).and_call_original
+ end
+ end
+
+ describe 'GET /projects/:id/import' do
+ it 'returns the import status' do
+ project = create(:project, import_status: 'started')
+ project.add_master(user)
+
+ get api("/projects/#{project.id}/import", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to include('import_status' => 'started')
+ end
+
+ it 'returns the import status and the error if failed' do
+ project = create(:project, import_status: 'failed', import_error: 'error')
+ project.add_master(user)
+
+ get api("/projects/#{project.id}/import", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to include('import_status' => 'failed',
+ 'import_error' => 'error')
+ end
+ end
+end
diff --git a/spec/requests/api/project_milestones_spec.rb b/spec/requests/api/project_milestones_spec.rb
index 72e1574b55f..6c05c166bd6 100644
--- a/spec/requests/api/project_milestones_spec.rb
+++ b/spec/requests/api/project_milestones_spec.rb
@@ -7,16 +7,56 @@ describe API::ProjectMilestones do
let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it_behaves_like 'group and project milestones', "/projects/:id/milestones" do
let(:route) { "/projects/#{project.id}/milestones" }
end
+ describe 'DELETE /projects/:id/milestones/:milestone_id' do
+ let(:guest) { create(:user) }
+ let(:reporter) { create(:user) }
+
+ before do
+ project.add_reporter(reporter)
+ end
+
+ it 'returns 404 response when the project does not exists' do
+ delete api("/projects/999/milestones/#{milestone.id}", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'returns 404 response when the milestone does not exists' do
+ delete api("/projects/#{project.id}/milestones/999", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it "returns 404 from guest user deleting a milestone" do
+ delete api("/projects/#{project.id}/milestones/#{milestone.id}", guest)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it "rejects a member with reporter access from deleting a milestone" do
+ delete api("/projects/#{project.id}/milestones/#{milestone.id}", reporter)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+
+ it 'deletes the milestone when the user has developer access to the project' do
+ delete api("/projects/#{project.id}/milestones/#{milestone.id}", user)
+
+ expect(project.milestones.find_by_id(milestone.id)).to be_nil
+ expect(response).to have_gitlab_http_status(204)
+ 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)
+ expect(Event).to receive(:create!)
put api("/projects/#{project.id}/milestones/#{milestone.id}", user),
state_event: 'close'
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index e741ac4b7bd..4a2289ca137 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
describe API::ProjectSnippets do
- let(:project) { create(:project, :public) }
- let(:user) { create(:user) }
- let(:admin) { create(:admin) }
+ set(:project) { create(:project, :public) }
+ set(:user) { create(:user) }
+ set(:admin) { create(:admin) }
describe "GET /projects/:project_id/snippets/:id/user_agent_detail" do
let(:snippet) { create(:project_snippet, :public, project: project) }
@@ -18,6 +18,13 @@ describe API::ProjectSnippets do
expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted)
end
+ it 'respects project scoping' do
+ other_project = create(:project)
+
+ get api("/projects/#{other_project.id}/snippets/#{snippet.id}/user_agent_detail", admin)
+ expect(response).to have_gitlab_http_status(404)
+ end
+
it "returns unautorized for non-admin users" do
get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/user_agent_detail", user)
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index a41345da05b..2ec29a79e93 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -2,14 +2,12 @@
require 'spec_helper'
describe API::Projects do
- include Gitlab::CurrentSettings
-
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
let(:admin) { create(:admin) }
let(:project) { create(:project, namespace: user.namespace) }
- let(:project2) { create(:project, path: 'project2', namespace: user.namespace) }
+ let(:project2) { create(:project, namespace: user.namespace) }
let(:snippet) { create(:project_snippet, :public, author: user, project: project, title: 'example') }
let(:project_member) { create(:project_member, :developer, user: user3, project: project) }
let(:user4) { create(:user) }
@@ -150,6 +148,19 @@ describe API::Projects do
expect(json_response.find { |hash| hash['id'] == project.id }.keys).not_to include('open_issues_count')
end
+ context 'and with_issues_enabled=true' do
+ it 'only returns projects with issues enabled' do
+ project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED)
+
+ get api('/projects?with_issues_enabled=true', user)
+
+ expect(response.status).to eq 200
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).not_to include(project.id)
+ end
+ end
+
it "does not include statistics by default" do
get api('/projects', user)
@@ -304,7 +315,7 @@ describe API::Projects do
context 'and with all query parameters' do
let!(:project5) { create(:project, :public, path: 'gitlab5', namespace: create(:namespace)) }
- let!(:project6) { create(:project, :public, path: 'project6', namespace: user.namespace) }
+ let!(:project6) { create(:project, :public, 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') }
@@ -352,6 +363,19 @@ describe API::Projects do
let(:current_user) { user2 }
let(:projects) { [public_project] }
end
+
+ context 'and with_issues_enabled=true' do
+ it 'does not return private issue projects' do
+ project.project_feature.update_attribute(:issues_access_level, ProjectFeature::PRIVATE)
+
+ get api('/projects?with_issues_enabled=true', user2)
+
+ expect(response.status).to eq 200
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).not_to include(project.id)
+ end
+ end
end
context 'when authenticated as admin' do
@@ -428,7 +452,8 @@ describe API::Projects do
only_allow_merge_if_pipeline_succeeds: false,
request_access_enabled: true,
only_allow_merge_if_all_discussions_are_resolved: false,
- ci_config_path: 'a/custom/path'
+ ci_config_path: 'a/custom/path',
+ merge_method: 'ff'
})
post api('/projects', user), project
@@ -436,7 +461,7 @@ describe API::Projects do
expect(response).to have_gitlab_http_status(201)
project.each_pair do |k, v|
- next if %i[has_external_issue_tracker issues_enabled merge_requests_enabled wiki_enabled].include?(k)
+ next if %i[has_external_issue_tracker issues_enabled merge_requests_enabled wiki_enabled storage_version].include?(k)
expect(json_response[k.to_s]).to eq(v)
end
@@ -545,6 +570,22 @@ describe API::Projects do
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_truthy
end
+ it 'sets the merge method of a project to rebase merge' do
+ project = attributes_for(:project, merge_method: 'rebase_merge')
+
+ post api('/projects', user), project
+
+ expect(json_response['merge_method']).to eq('rebase_merge')
+ end
+
+ it 'rejects invalid values for merge_method' do
+ project = attributes_for(:project, merge_method: 'totally_not_valid_method')
+
+ post api('/projects', user), project
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
it 'ignores import_url when it is nil' do
project = attributes_for(:project, import_url: nil)
@@ -598,12 +639,8 @@ describe API::Projects do
end
describe 'POST /projects/user/:id' do
- 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 { post api("/projects/user/#{user.id}", admin), name: 'Foo Project' }.to change { Project.count }.by(1)
expect(response).to have_gitlab_http_status(201)
project = Project.last
@@ -642,8 +679,9 @@ describe API::Projects do
post api("/projects/user/#{user.id}", admin), project
expect(response).to have_gitlab_http_status(201)
+
project.each_pair do |k, v|
- next if %i[has_external_issue_tracker path].include?(k)
+ next if %i[has_external_issue_tracker path storage_version].include?(k)
expect(json_response[k.to_s]).to eq(v)
end
@@ -802,6 +840,7 @@ describe API::Projects do
expect(json_response['shared_with_groups'][0]['group_access_level']).to eq(link.group_access)
expect(json_response['only_allow_merge_if_pipeline_succeeds']).to eq(project.only_allow_merge_if_pipeline_succeeds)
expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved)
+ expect(json_response['merge_method']).to eq(project.merge_method.to_s)
end
it 'returns a project by path name' do
@@ -908,7 +947,7 @@ describe API::Projects do
describe 'permissions' do
context 'all projects' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'contains permission information' do
@@ -923,7 +962,7 @@ describe API::Projects do
context 'personal project' do
it 'sets project access and returns 200' do
- project.team << [user, :master]
+ project.add_master(user)
get api("/projects/#{project.id}", user)
expect(response).to have_gitlab_http_status(200)
@@ -1453,6 +1492,26 @@ describe API::Projects do
expect(json_response[k.to_s]).to eq(v)
end
end
+
+ it 'updates merge_method' do
+ project_param = { merge_method: 'ff' }
+
+ put api("/projects/#{project3.id}", user), project_param
+
+ expect(response).to have_gitlab_http_status(200)
+
+ project_param.each_pair do |k, v|
+ expect(json_response[k.to_s]).to eq(v)
+ end
+ end
+
+ it 'rejects to update merge_method when merge_method is invalid' do
+ project_param = { merge_method: 'invalid' }
+
+ put api("/projects/#{project3.id}", user), project_param
+
+ expect(response).to have_gitlab_http_status(400)
+ end
end
context 'when authenticated as project master' do
@@ -1470,6 +1529,7 @@ describe API::Projects do
wiki_enabled: true,
snippets_enabled: true,
merge_requests_enabled: true,
+ merge_method: 'ff',
description: 'new description' }
put api("/projects/#{project3.id}", user4), project_param
@@ -1539,7 +1599,7 @@ describe API::Projects do
context 'user without archiving rights to the project' do
before do
- project.team << [user3, :developer]
+ project.add_developer(user3)
end
it 'rejects the action' do
@@ -1575,7 +1635,7 @@ describe API::Projects do
context 'user without archiving rights to the project' do
before do
- project.team << [user3, :developer]
+ project.add_developer(user3)
end
it 'rejects the action' do
@@ -1650,7 +1710,7 @@ describe API::Projects do
it 'does not remove a project if not an owner' do
user3 = create(:user)
- project.team << [user3, :developer]
+ project.add_developer(user3)
delete api("/projects/#{project.id}", user3)
expect(response).to have_gitlab_http_status(403)
end
@@ -1697,6 +1757,12 @@ describe API::Projects do
group
end
+ let(:group3) do
+ group = create(:group, name: 'group3_name', parent: group2)
+ group.add_owner(user2)
+ group
+ end
+
before do
project.add_reporter(user2)
end
@@ -1792,6 +1858,15 @@ describe API::Projects do
expect(json_response['namespace']['name']).to eq(group2.name)
end
+ it 'forks to owned subgroup' do
+ full_path = "#{group2.path}/#{group3.path}"
+ post api("/projects/#{project.id}/fork", user2), namespace: full_path
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['namespace']['name']).to eq(group3.name)
+ expect(json_response['namespace']['full_path']).to eq(full_path)
+ end
+
it 'fails to fork to not owned group' do
post api("/projects/#{project.id}/fork", user2), namespace: group.name
diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb
index 10e6a3c07c8..576fde46615 100644
--- a/spec/requests/api/protected_branches_spec.rb
+++ b/spec/requests/api/protected_branches_spec.rb
@@ -80,6 +80,12 @@ describe API::ProtectedBranches do
it_behaves_like 'protected branch'
end
+
+ context 'when protected branch contains a period' do
+ let(:protected_name) { 'my.feature' }
+
+ it_behaves_like 'protected branch'
+ end
end
context 'when authenticated as a guest' do
@@ -187,6 +193,19 @@ describe API::ProtectedBranches do
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER)
end
end
+
+ context 'when a policy restricts rule deletion' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents deletion of the protected branch rule" do
+ post post_endpoint, name: branch_name
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
end
context 'when authenticated as a guest' do
@@ -203,18 +222,20 @@ describe API::ProtectedBranches do
end
describe "DELETE /projects/:id/protected_branches/unprotect/:branch" do
+ let(:delete_endpoint) { api("/projects/#{project.id}/protected_branches/#{branch_name}", user) }
+
before do
project.add_master(user)
end
it "unprotects a single branch" do
- delete api("/projects/#{project.id}/protected_branches/#{branch_name}", user)
+ delete delete_endpoint
expect(response).to have_gitlab_http_status(204)
end
it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project.id}/protected_branches/#{branch_name}", user) }
+ let(:request) { delete_endpoint }
end
it "returns 404 if branch does not exist" do
@@ -223,11 +244,24 @@ describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(404)
end
+ context 'when a policy restricts rule deletion' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents deletion of the protected branch rule" do
+ delete delete_endpoint
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ 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)
+ delete delete_endpoint
expect(response).to have_gitlab_http_status(204)
end
diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb
index 679d391caa5..28d51ac86c6 100644
--- a/spec/requests/api/runner_spec.rb
+++ b/spec/requests/api/runner_spec.rb
@@ -8,6 +8,7 @@ describe API::Runner do
before do
stub_gitlab_calls
stub_application_setting(runners_registration_token: registration_token)
+ allow_any_instance_of(Ci::Runner).to receive(:cache_attributes)
end
describe '/api/v4/runners' do
@@ -108,6 +109,26 @@ describe API::Runner do
end
end
+ context 'when maximum job timeout is specified' do
+ it 'creates runner' do
+ post api('/runners'), token: registration_token,
+ maximum_timeout: 9000
+
+ expect(response).to have_gitlab_http_status 201
+ expect(Ci::Runner.first.maximum_timeout).to eq(9000)
+ end
+
+ context 'when maximum job timeout is empty' do
+ it 'creates runner' do
+ post api('/runners'), token: registration_token,
+ maximum_timeout: ''
+
+ expect(response).to have_gitlab_http_status 201
+ expect(Ci::Runner.first.maximum_timeout).to be_nil
+ end
+ end
+ end
+
%w(name version revision platform architecture).each do |param|
context "when info parameter '#{param}' info is present" do
let(:value) { "#{param}_value" }
@@ -121,6 +142,15 @@ describe API::Runner do
end
end
end
+
+ it "sets the runner's ip_address" do
+ post api('/runners'),
+ { token: registration_token },
+ { 'REMOTE_ADDR' => '123.111.123.111' }
+
+ expect(response).to have_gitlab_http_status 201
+ expect(Ci::Runner.first.ip_address).to eq('123.111.123.111')
+ end
end
describe 'DELETE /api/v4/runners' do
@@ -190,7 +220,7 @@ describe API::Runner do
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
+ let(:job) do
create(:ci_build, :artifacts, :extended_options,
pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, commands: "ls\ndate")
end
@@ -205,6 +235,7 @@ describe API::Runner do
let(:user_agent) { 'gitlab-runner 9.0.0 (9-0-stable; go1.7.4; linux/amd64)' }
before do
+ job
stub_container_registry_config(enabled: false)
end
@@ -329,12 +360,12 @@ describe API::Runner do
let(:expected_steps) do
[{ 'name' => 'script',
'script' => %w(ls date),
- 'timeout' => job.timeout,
+ 'timeout' => job.metadata_timeout,
'when' => 'on_success',
'allow_failure' => false },
{ 'name' => 'after_script',
'script' => %w(ls date),
- 'timeout' => job.timeout,
+ 'timeout' => job.metadata_timeout,
'when' => 'always',
'allow_failure' => true }]
end
@@ -408,7 +439,7 @@ describe API::Runner do
expect { request_job }.to change { runner.reload.contacted_at }
end
- %w(name version revision platform architecture).each do |param|
+ %w(version revision platform architecture).each do |param|
context "when info parameter '#{param}' is present" do
let(:value) { "#{param}_value" }
@@ -421,6 +452,15 @@ describe API::Runner do
end
end
+ it "sets the runner's ip_address" do
+ post api('/jobs/request'),
+ { token: runner.token },
+ { 'User-Agent' => user_agent, 'REMOTE_ADDR' => '123.222.123.222' }
+
+ expect(response).to have_gitlab_http_status 201
+ expect(runner.reload.ip_address).to eq('123.222.123.222')
+ end
+
context 'when concurrently updating a job' do
before do
expect_any_instance_of(Ci::Build).to receive(:run!)
@@ -628,6 +668,41 @@ describe API::Runner do
end
end
end
+
+ describe 'timeout support' do
+ context 'when project specifies job timeout' do
+ let(:project) { create(:project, shared_runners_enabled: false, build_timeout: 1234) }
+
+ it 'contains info about timeout taken from project' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['runner_info']).to include({ 'timeout' => 1234 })
+ end
+
+ context 'when runner specifies lower timeout' do
+ let(:runner) { create(:ci_runner, maximum_timeout: 1000) }
+
+ it 'contains info about timeout overridden by runner' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['runner_info']).to include({ 'timeout' => 1000 })
+ end
+ end
+
+ context 'when runner specifies bigger timeout' do
+ let(:runner) { create(:ci_runner, maximum_timeout: 2000) }
+
+ it 'contains info about timeout not overridden by runner' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['runner_info']).to include({ 'timeout' => 1234 })
+ end
+ end
+ end
+ end
end
def request_job(token = runner.token, **params)
@@ -638,7 +713,7 @@ describe API::Runner do
end
describe 'PUT /api/v4/jobs/:id' do
- let(:job) { create(:ci_build, :pending, :trace, pipeline: pipeline, runner_id: runner.id) }
+ let(:job) { create(:ci_build, :pending, :trace_live, pipeline: pipeline, runner_id: runner.id) }
before do
job.run!
@@ -679,12 +754,18 @@ describe API::Runner do
end
end
- context 'when tace is given' do
- it 'updates a running build' do
- update_job(trace: 'BUILD TRACE UPDATED')
+ context 'when trace is given' do
+ it 'creates a trace artifact' do
+ allow(BuildFinishedWorker).to receive(:perform_async).with(job.id) do
+ ArchiveTraceWorker.new.perform(job.id)
+ end
+ update_job(state: 'success', trace: 'BUILD TRACE UPDATED')
+
+ job.reload
expect(response).to have_gitlab_http_status(200)
- expect(job.reload.trace.raw).to eq 'BUILD TRACE UPDATED'
+ expect(job.trace.raw).to eq 'BUILD TRACE UPDATED'
+ expect(job.job_artifacts_trace.open.read).to eq 'BUILD TRACE UPDATED'
end
end
@@ -713,7 +794,7 @@ describe API::Runner do
end
describe 'PATCH /api/v4/jobs/:id/trace' do
- let(:job) { create(:ci_build, :running, :trace, runner_id: runner.id, pipeline: pipeline) }
+ let(:job) { create(:ci_build, :running, :trace_live, runner_id: runner.id, pipeline: pipeline) }
let(:headers) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token, 'Content-Type' => 'text/plain' } }
let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) }
let(:update_interval) { 10.seconds.to_i }
@@ -774,7 +855,7 @@ describe API::Runner do
context 'when project for the build has been deleted' do
let(:job) do
- create(:ci_build, :running, :trace, runner_id: runner.id, pipeline: pipeline) do |job|
+ create(:ci_build, :running, :trace_live, runner_id: runner.id, pipeline: pipeline) do |job|
job.project.update(pending_delete: true)
end
end
@@ -863,17 +944,59 @@ describe API::Runner do
let(:file_upload2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/gif') }
before do
+ stub_artifacts_object_storage
job.run!
end
describe 'POST /api/v4/jobs/:id/artifacts/authorize' do
context 'when using token as parameter' do
- it 'authorizes posting artifacts to running job' do
- authorize_artifacts_with_token_in_params
+ context 'posting artifacts to running job' do
+ subject do
+ authorize_artifacts_with_token_in_params
+ end
- expect(response).to have_gitlab_http_status(200)
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- expect(json_response['TempPath']).not_to be_nil
+ shared_examples 'authorizes local file' do
+ it 'succeeds' do
+ subject
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response['TempPath']).to eq(JobArtifactUploader.workhorse_local_upload_path)
+ expect(json_response['RemoteObject']).to be_nil
+ end
+ end
+
+ context 'when using local storage' do
+ it_behaves_like 'authorizes local file'
+ end
+
+ context 'when using remote storage' do
+ context 'when direct upload is enabled' do
+ before do
+ stub_artifacts_object_storage(enabled: true, direct_upload: true)
+ end
+
+ it 'succeeds' do
+ subject
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response['TempPath']).to eq(JobArtifactUploader.workhorse_local_upload_path)
+ expect(json_response['RemoteObject']).to have_key('ID')
+ expect(json_response['RemoteObject']).to have_key('GetURL')
+ expect(json_response['RemoteObject']).to have_key('StoreURL')
+ expect(json_response['RemoteObject']).to have_key('DeleteURL')
+ end
+ end
+
+ context 'when direct upload is disabled' do
+ before do
+ stub_artifacts_object_storage(enabled: true, direct_upload: false)
+ end
+
+ it_behaves_like 'authorizes local file'
+ end
+ end
end
it 'fails to post too large artifact' do
@@ -945,7 +1068,7 @@ describe API::Runner do
context 'when artifacts are being stored inside of tmp path' do
before do
# by configuring this path we allow to pass temp file from any path
- allow(JobArtifactUploader).to receive(:artifacts_upload_path).and_return('/')
+ allow(JobArtifactUploader).to receive(:workhorse_upload_path).and_return('/')
end
context 'when job has been erased' do
@@ -969,20 +1092,45 @@ describe API::Runner do
end
end
- context 'when uses regular file post' do
- before do
- upload_artifacts(file_upload, headers_with_token, false)
+ context 'when uses accelerated file post' do
+ context 'for file stored locally' do
+ before do
+ upload_artifacts(file_upload, headers_with_token)
+ end
+
+ it_behaves_like 'successful artifacts upload'
end
- it_behaves_like 'successful artifacts upload'
- end
+ context 'for file stored remotelly' do
+ let!(:fog_connection) do
+ stub_artifacts_object_storage(direct_upload: true)
+ end
- context 'when uses accelerated file post' do
- before do
- upload_artifacts(file_upload, headers_with_token, true)
- end
+ before do
+ fog_connection.directories.get('artifacts').files.create(
+ key: 'tmp/upload/12312300',
+ body: 'content'
+ )
+
+ upload_artifacts(file_upload, headers_with_token,
+ { 'file.remote_id' => remote_id })
+ end
+
+ context 'when valid remote_id is used' do
+ let(:remote_id) { '12312300' }
+
+ it_behaves_like 'successful artifacts upload'
+ end
+
+ context 'when invalid remote_id is used' do
+ let(:remote_id) { 'invalid id' }
- it_behaves_like 'successful artifacts upload'
+ it 'responds with bad request' do
+ expect(response).to have_gitlab_http_status(500)
+ expect(json_response['message']).to eq("Missing file")
+ end
+ end
+ end
end
context 'when using runners token' do
@@ -1075,11 +1223,15 @@ describe API::Runner do
context 'posts artifacts file and metadata file' do
let!(:artifacts) { file_upload }
+ let!(:artifacts_sha256) { Digest::SHA256.file(artifacts.path).hexdigest }
let!(:metadata) { file_upload2 }
+ let!(:metadata_sha256) { Digest::SHA256.file(metadata.path).hexdigest }
let(:stored_artifacts_file) { job.reload.artifacts_file.file }
let(:stored_metadata_file) { job.reload.artifacts_metadata.file }
let(:stored_artifacts_size) { job.reload.artifacts_size }
+ let(:stored_artifacts_sha256) { job.reload.job_artifacts_archive.file_sha256 }
+ let(:stored_metadata_sha256) { job.reload.job_artifacts_metadata.file_sha256 }
before do
post(api("/jobs/#{job.id}/artifacts"), post_data, headers_with_token)
@@ -1089,8 +1241,10 @@ describe API::Runner do
let(:post_data) do
{ 'file.path' => artifacts.path,
'file.name' => artifacts.original_filename,
+ 'file.sha256' => artifacts_sha256,
'metadata.path' => metadata.path,
- 'metadata.name' => metadata.original_filename }
+ 'metadata.name' => metadata.original_filename,
+ 'metadata.sha256' => metadata_sha256 }
end
it 'stores artifacts and artifacts metadata' do
@@ -1098,6 +1252,8 @@ describe API::Runner do
expect(stored_artifacts_file.original_filename).to eq(artifacts.original_filename)
expect(stored_metadata_file.original_filename).to eq(metadata.original_filename)
expect(stored_artifacts_size).to eq(72821)
+ expect(stored_artifacts_sha256).to eq(artifacts_sha256)
+ expect(stored_metadata_sha256).to eq(metadata_sha256)
end
end
@@ -1118,15 +1274,19 @@ describe API::Runner do
end
context 'when artifacts are being stored outside of tmp path' do
+ let(:new_tmpdir) { Dir.mktmpdir }
+
before do
+ # init before overwriting tmp dir
+ file_upload
+
# by configuring this path we allow to pass file from @tmpdir only
# but all temporary files are stored in system tmp directory
- @tmpdir = Dir.mktmpdir
- allow(JobArtifactUploader).to receive(:artifacts_upload_path).and_return(@tmpdir)
+ allow(Dir).to receive(:tmpdir).and_return(new_tmpdir)
end
after do
- FileUtils.remove_entry @tmpdir
+ FileUtils.remove_entry(new_tmpdir)
end
it' "fails to post artifacts for outside of tmp path"' do
@@ -1136,12 +1296,12 @@ describe API::Runner do
end
end
- def upload_artifacts(file, headers = {}, accelerated = true)
- params = if accelerated
- { 'file.path' => file.path, 'file.name' => file.original_filename }
- else
- { 'file' => file }
- end
+ def upload_artifacts(file, headers = {}, params = {})
+ params = params.merge({
+ 'file.path' => file.path,
+ 'file.name' => file.original_filename
+ })
+
post api("/jobs/#{job.id}/artifacts"), params, headers
end
end
@@ -1149,27 +1309,67 @@ describe API::Runner do
describe 'GET /api/v4/jobs/:id/artifacts' do
let(:token) { job.token }
- before do
- download_artifact
- end
-
context 'when job has artifacts' do
- let(:job) { create(:ci_build, :artifacts) }
- let(:download_headers) do
- { 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ let(:job) { create(:ci_build) }
+ let(:store) { JobArtifactUploader::Store::LOCAL }
+
+ before do
+ create(:ci_job_artifact, :archive, file_store: store, job: job)
end
context 'when using job token' do
- it 'download artifacts' do
- expect(response).to have_gitlab_http_status(200)
- expect(response.headers).to include download_headers
+ context 'when artifacts are stored locally' do
+ let(:download_headers) do
+ { 'Content-Transfer-Encoding' => 'binary',
+ 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ end
+
+ before do
+ download_artifact
+ end
+
+ it 'download artifacts' do
+ expect(response).to have_http_status(200)
+ expect(response.headers).to include download_headers
+ end
+ end
+
+ context 'when artifacts are stored remotely' do
+ let(:store) { JobArtifactUploader::Store::REMOTE }
+ let!(:job) { create(:ci_build) }
+
+ context 'when proxy download is being used' do
+ before do
+ download_artifact(direct_download: false)
+ end
+
+ it 'uses workhorse send-url' do
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers).to include(
+ 'Gitlab-Workhorse-Send-Data' => /send-url:/)
+ end
+ end
+
+ context 'when direct download is being used' do
+ before do
+ download_artifact(direct_download: true)
+ end
+
+ it 'receive redirect for downloading artifacts' do
+ expect(response).to have_gitlab_http_status(302)
+ expect(response.headers).to include('Location')
+ end
+ end
end
end
context 'when using runnners token' do
let(:token) { job.project.runners_token }
+ before do
+ download_artifact
+ end
+
it 'responds with forbidden' do
expect(response).to have_gitlab_http_status(403)
end
@@ -1178,12 +1378,16 @@ describe API::Runner do
context 'when job does not has artifacts' do
it 'responds with not found' do
+ download_artifact
+
expect(response).to have_gitlab_http_status(404)
end
end
def download_artifact(params = {}, request_headers = headers)
params = params.merge(token: token)
+ job.reload
+
get api("/jobs/#{job.id}/artifacts"), params, request_headers
end
end
diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb
index ec5cad4f4fd..d30f0cf36e2 100644
--- a/spec/requests/api/runners_spec.rb
+++ b/spec/requests/api/runners_spec.rb
@@ -123,6 +123,7 @@ describe API::Runners do
expect(response).to have_gitlab_http_status(200)
expect(json_response['description']).to eq(shared_runner.description)
+ expect(json_response['maximum_timeout']).to be_nil
end
end
@@ -192,7 +193,8 @@ describe API::Runners do
tag_list: ['ruby2.1', 'pgsql', 'mysql'],
run_untagged: 'false',
locked: 'true',
- access_level: 'ref_protected')
+ access_level: 'ref_protected',
+ maximum_timeout: 1234)
shared_runner.reload
expect(response).to have_gitlab_http_status(200)
@@ -204,6 +206,7 @@ describe API::Runners do
expect(shared_runner.ref_protected?).to be_truthy
expect(shared_runner.ensure_runner_queue_value)
.not_to eq(runner_queue_value)
+ expect(shared_runner.maximum_timeout).to eq(1234)
end
end
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
new file mode 100644
index 00000000000..f8d5258a8d9
--- /dev/null
+++ b/spec/requests/api/search_spec.rb
@@ -0,0 +1,318 @@
+require 'spec_helper'
+
+describe API::Search do
+ set(:user) { create(:user) }
+ set(:group) { create(:group) }
+ set(:project) { create(:project, :public, name: 'awesome project', group: group) }
+ set(:repo_project) { create(:project, :public, :repository, group: group) }
+
+ shared_examples 'response is correct' do |schema:, size: 1|
+ it { expect(response).to have_gitlab_http_status(200) }
+ it { expect(response).to match_response_schema(schema) }
+ it { expect(response).to include_limited_pagination_headers }
+ it { expect(json_response.size).to eq(size) }
+ end
+
+ describe 'GET /search' do
+ context 'when user is not authenticated' do
+ it 'returns 401 error' do
+ get api('/search'), scope: 'projects', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+ end
+
+ context 'when scope is not supported' do
+ it 'returns 400 error' do
+ get api('/search', user), scope: 'unsupported', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ context 'when scope is missing' do
+ it 'returns 400 error' do
+ get api('/search', user), search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ context 'with correct params' do
+ context 'for projects scope' do
+ before do
+ get api('/search', user), scope: 'projects', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/projects'
+ end
+
+ context 'for issues scope' do
+ before do
+ create(:issue, project: project, title: 'awesome issue')
+
+ get api('/search', user), scope: 'issues', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
+ end
+
+ context 'for merge_requests scope' do
+ before do
+ create(:merge_request, source_project: repo_project, title: 'awesome mr')
+
+ get api('/search', user), scope: 'merge_requests', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
+ end
+
+ context 'for milestones scope' do
+ before do
+ create(:milestone, project: project, title: 'awesome milestone')
+
+ get api('/search', user), scope: 'milestones', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
+ end
+
+ context 'for snippet_titles scope' do
+ before do
+ create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
+
+ get api('/search', user), scope: 'snippet_titles', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
+ end
+
+ context 'for snippet_blobs scope' do
+ before do
+ create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
+
+ get api('/search', user), scope: 'snippet_blobs', search: 'content'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
+ end
+ end
+ end
+
+ describe "GET /groups/:id/search" do
+ context 'when user is not authenticated' do
+ it 'returns 401 error' do
+ get api("/groups/#{group.id}/search"), scope: 'projects', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+ end
+
+ context 'when scope is not supported' do
+ it 'returns 400 error' do
+ get api("/groups/#{group.id}/search", user), scope: 'unsupported', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ context 'when scope is missing' do
+ it 'returns 400 error' do
+ get api("/groups/#{group.id}/search", user), search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ context 'when group does not exist' do
+ it 'returns 404 error' do
+ get api('/groups/9999/search', user), scope: 'issues', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'when user does can not see the group' do
+ it 'returns 404 error' do
+ private_group = create(:group, :private)
+
+ get api("/groups/#{private_group.id}/search", user), scope: 'issues', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'with correct params' do
+ context 'for projects scope' do
+ before do
+ get api("/groups/#{group.id}/search", user), scope: 'projects', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/projects'
+ end
+
+ context 'for issues scope' do
+ before do
+ create(:issue, project: project, title: 'awesome issue')
+
+ get api("/groups/#{group.id}/search", user), scope: 'issues', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
+ end
+
+ context 'for merge_requests scope' do
+ before do
+ create(:merge_request, source_project: repo_project, title: 'awesome mr')
+
+ get api("/groups/#{group.id}/search", user), scope: 'merge_requests', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
+ end
+
+ context 'for milestones scope' do
+ before do
+ create(:milestone, project: project, title: 'awesome milestone')
+
+ get api("/groups/#{group.id}/search", user), scope: 'milestones', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
+ end
+
+ context 'for milestones scope with group path as id' do
+ before do
+ another_project = create(:project, :public)
+ create(:milestone, project: project, title: 'awesome milestone')
+ create(:milestone, project: another_project, title: 'awesome milestone other project')
+
+ get api("/groups/#{CGI.escape(group.full_path)}/search", user), scope: 'milestones', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
+ end
+ end
+ end
+
+ describe "GET /projects/:id/search" do
+ context 'when user is not authenticated' do
+ it 'returns 401 error' do
+ get api("/projects/#{project.id}/search"), scope: 'issues', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+ end
+
+ context 'when scope is not supported' do
+ it 'returns 400 error' do
+ get api("/projects/#{project.id}/search", user), scope: 'unsupported', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ context 'when scope is missing' do
+ it 'returns 400 error' do
+ get api("/projects/#{project.id}/search", user), search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ context 'when project does not exist' do
+ it 'returns 404 error' do
+ get api('/projects/9999/search', user), scope: 'issues', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'when user does can not see the project' do
+ it 'returns 404 error' do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+ get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'with correct params' do
+ context 'for issues scope' do
+ before do
+ create(:issue, project: project, title: 'awesome issue')
+
+ get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
+ end
+
+ context 'for merge_requests scope' do
+ before do
+ create(:merge_request, source_project: repo_project, title: 'awesome mr')
+
+ get api("/projects/#{repo_project.id}/search", user), scope: 'merge_requests', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
+ end
+
+ context 'for milestones scope' do
+ before do
+ create(:milestone, project: project, title: 'awesome milestone')
+
+ get api("/projects/#{project.id}/search", user), scope: 'milestones', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
+ end
+
+ context 'for notes scope' do
+ before do
+ create(:note_on_merge_request, project: project, note: 'awesome note')
+
+ get api("/projects/#{project.id}/search", user), scope: 'notes', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/notes'
+ end
+
+ context 'for wiki_blobs scope' do
+ before do
+ wiki = create(:project_wiki, project: project)
+ create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" })
+
+ get api("/projects/#{project.id}/search", user), scope: 'wiki_blobs', search: 'awesome'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/blobs'
+ end
+
+ context 'for commits scope' do
+ before do
+ get api("/projects/#{repo_project.id}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details'
+ end
+
+ context 'for commits scope with project path as id' do
+ before do
+ get api("/projects/#{CGI.escape(repo_project.full_path)}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details'
+ end
+
+ context 'for blobs scope' do
+ before do
+ get api("/projects/#{repo_project.id}/search", user), scope: 'blobs', search: 'monitors'
+ end
+
+ it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb
index ba697e2b305..236f8d7faf5 100644
--- a/spec/requests/api/services_spec.rb
+++ b/spec/requests/api/services_spec.rb
@@ -53,6 +53,10 @@ describe API::Services do
describe "DELETE /projects/:id/services/#{service.dasherize}" do
include_context service
+ before do
+ initialize_service(service)
+ end
+
it "deletes #{service}" do
delete api("/projects/#{project.id}/services/#{dashed_service}", user)
@@ -67,9 +71,7 @@ describe API::Services do
# inject some properties into the service
before do
- service_object = project.find_or_initialize_service(service)
- service_object.properties = service_attrs
- service_object.save
+ initialize_service(service)
end
it 'returns authentication error when unauthenticated' do
@@ -81,18 +83,18 @@ describe API::Services do
get api("/projects/#{project.id}/services/#{dashed_service}", admin)
expect(response).to have_gitlab_http_status(200)
- expect(json_response['properties'].keys.map(&:to_sym)).to match_array(service_attrs_list.map)
+ expect(json_response['properties'].keys).to match_array(service_instance.api_field_names)
end
it "returns properties of service #{service} other than passwords when authenticated as project owner" do
get api("/projects/#{project.id}/services/#{dashed_service}", user)
expect(response).to have_gitlab_http_status(200)
- expect(json_response['properties'].keys.map(&:to_sym)).to match_array(service_attrs_list_without_passwords)
+ expect(json_response['properties'].keys).to match_array(service_instance.api_field_names)
end
it "returns error when authenticated but not a project owner" do
- project.team << [user2, :developer]
+ project.add_developer(user2)
get api("/projects/#{project.id}/services/#{dashed_service}", user2)
expect(response).to have_gitlab_http_status(403)
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index 74198c8eb4f..b3e253befc6 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -32,6 +32,27 @@ describe API::Snippets do
expect(json_response).to be_an Array
expect(json_response.size).to eq(0)
end
+
+ it 'returns 404 for non-authenticated' do
+ create(:personal_snippet, :internal)
+
+ get api("/snippets/")
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+
+ it 'does not return snippets related to a project with disable feature visibility' do
+ project = create(:project)
+ create(:project_member, project: project, user: user)
+ public_snippet = create(:personal_snippet, :public, author: user, project: project)
+ project.project_feature.update_attribute(:snippets_access_level, 0)
+
+ get api("/snippets/", user)
+
+ json_response.each do |snippet|
+ expect(snippet["id"]).not_to eq(public_snippet.id)
+ end
+ end
end
describe 'GET /snippets/public' do
diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb
index c7a009e999e..6c57d443cbf 100644
--- a/spec/requests/api/system_hooks_spec.rb
+++ b/spec/requests/api/system_hooks_spec.rb
@@ -36,6 +36,7 @@ describe API::SystemHooks do
expect(json_response.first['url']).to eq(hook.url)
expect(json_response.first['push_events']).to be false
expect(json_response.first['tag_push_events']).to be false
+ expect(json_response.first['merge_requests_events']).to be false
expect(json_response.first['repository_update_events']).to be true
end
end
@@ -67,11 +68,28 @@ describe API::SystemHooks do
end
it 'sets default values for events' do
- post api('/hooks', admin), url: 'http://mep.mep', enable_ssl_verification: true
+ post api('/hooks', admin), url: 'http://mep.mep'
expect(response).to have_gitlab_http_status(201)
expect(json_response['enable_ssl_verification']).to be true
+ expect(json_response['push_events']).to be false
expect(json_response['tag_push_events']).to be false
+ expect(json_response['merge_requests_events']).to be false
+ end
+
+ it 'sets explicit values for events' do
+ post api('/hooks', admin),
+ url: 'http://mep.mep',
+ enable_ssl_verification: false,
+ push_events: true,
+ tag_push_events: true,
+ merge_requests_events: true
+
+ expect(response).to have_http_status(201)
+ expect(json_response['enable_ssl_verification']).to be false
+ expect(json_response['push_events']).to be true
+ expect(json_response['tag_push_events']).to be true
+ expect(json_response['merge_requests_events']).to be true
end
end
diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb
index de1619f33c1..6bb53fdc98d 100644
--- a/spec/requests/api/templates_spec.rb
+++ b/spec/requests/api/templates_spec.rb
@@ -65,7 +65,7 @@ describe API::Templates do
expect(json_response['description']).to include('A short and simple permissive license with conditions')
expect(json_response['conditions']).to eq(%w[include-copyright])
expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use])
- expect(json_response['limitations']).to eq(%w[no-liability])
+ expect(json_response['limitations']).to eq(%w[liability warranty])
expect(json_response['content']).to include('MIT License')
end
end
diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb
index c6063a2e089..2ee8d150dc8 100644
--- a/spec/requests/api/todos_spec.rb
+++ b/spec/requests/api/todos_spec.rb
@@ -13,8 +13,8 @@ describe API::Todos do
let!(:done) { create(:todo, :done, project: project_1, author: author_1, user: john_doe) }
before do
- project_1.team << [john_doe, :developer]
- project_2.team << [john_doe, :developer]
+ project_1.add_developer(john_doe)
+ project_2.add_developer(john_doe)
end
describe 'GET /todos' do
@@ -129,6 +129,12 @@ describe API::Todos do
post api("/todos/#{pending_1.id}/mark_as_done", john_doe)
end
+
+ it 'returns 404 if the todo does not belong to the current user' do
+ post api("/todos/#{pending_1.id}/mark_as_done", author_1)
+
+ expect(response.status).to eq(404)
+ end
end
end
@@ -191,7 +197,7 @@ describe API::Todos do
it 'returns an error if the issuable is not accessible' do
guest = create(:user)
- project_1.team << [guest, :guest]
+ project_1.add_guest(guest)
post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", guest)
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 2428e63e149..f406d2ffb22 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -199,6 +199,24 @@ describe API::Users do
expect(json_response.size).to eq(1)
expect(json_response.first['username']).to eq(user.username)
end
+
+ it 'returns the correct order when sorted by id' do
+ admin
+ user
+
+ get api('/users', admin), { order_by: 'id', sort: 'asc' }
+
+ expect(response).to match_response_schema('public_api/v4/user/admins')
+ expect(json_response.size).to eq(2)
+ expect(json_response.first['id']).to eq(admin.id)
+ expect(json_response.last['id']).to eq(user.id)
+ end
+
+ it 'returns 400 when provided incorrect sort params' do
+ get api('/users', admin), { order_by: 'magic', sort: 'asc' }
+
+ expect(response).to have_gitlab_http_status(400)
+ end
end
end
diff --git a/spec/requests/api/v3/award_emoji_spec.rb b/spec/requests/api/v3/award_emoji_spec.rb
index 0cd8b70007f..6dc430676b0 100644
--- a/spec/requests/api/v3/award_emoji_spec.rb
+++ b/spec/requests/api/v3/award_emoji_spec.rb
@@ -9,7 +9,7 @@ describe API::V3::AwardEmoji do
let!(:downvote) { create(:award_emoji, :downvote, awardable: merge_request, user: user) }
set(:note) { create(:note, project: project, noteable: issue) }
- before { project.team << [user, :master] }
+ before { project.add_master(user) }
describe "GET /projects/:id/awardable/:awardable_id/award_emoji" do
context 'on an issue' do
diff --git a/spec/requests/api/v3/boards_spec.rb b/spec/requests/api/v3/boards_spec.rb
index 14409d25544..dde4f096193 100644
--- a/spec/requests/api/v3/boards_spec.rb
+++ b/spec/requests/api/v3/boards_spec.rb
@@ -27,8 +27,8 @@ describe API::V3::Boards do
end
before do
- project.team << [user, :reporter]
- project.team << [guest, :guest]
+ project.add_reporter(user)
+ project.add_guest(guest)
end
describe "GET /projects/:id/boards" do
diff --git a/spec/requests/api/v3/builds_spec.rb b/spec/requests/api/v3/builds_spec.rb
index a73bb456b52..00f067889a0 100644
--- a/spec/requests/api/v3/builds_spec.rb
+++ b/spec/requests/api/v3/builds_spec.rb
@@ -4,19 +4,23 @@ describe API::V3::Builds do
set(:user) { create(:user) }
let(:api_user) { user }
set(:project) { create(:project, :repository, creator: user, public_builds: false) }
- set(:developer) { create(:project_member, :developer, user: user, project: project) }
- set(:reporter) { create(:project_member, :reporter, project: project) }
- set(:guest) { create(:project_member, :guest, project: project) }
- set(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: project.default_branch) }
- let!(:build) { create(:ci_build, pipeline: pipeline) }
+ 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) }
describe 'GET /projects/:id/builds ' do
let(:query) { '' }
- before do
+ before do |example|
+ build
+
create(:ci_build, :skipped, pipeline: pipeline)
- get v3_api("/projects/#{project.id}/builds?#{query}", api_user)
+ unless example.metadata[:skip_before_request]
+ get v3_api("/projects/#{project.id}/builds?#{query}", api_user)
+ end
end
context 'authorized user' do
@@ -40,6 +44,23 @@ describe API::V3::Builds do
expect(json_build['pipeline']['status']).to eq build.pipeline.status
end
+ it 'avoids N+1 queries', :skip_before_request do
+ first_build = create(:ci_build, :artifacts, pipeline: pipeline)
+ first_build.runner = create(:ci_runner)
+ first_build.user = create(:user)
+ first_build.save
+
+ control_count = ActiveRecord::QueryRecorder.new { go }.count
+
+ second_pipeline = create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: project.default_branch)
+ second_build = create(:ci_build, :artifacts, pipeline: second_pipeline)
+ second_build.runner = create(:ci_runner)
+ second_build.user = create(:user)
+ second_build.save
+
+ expect { go }.not_to exceed_query_limit(control_count)
+ end
+
context 'filter project with one scope element' do
let(:query) { 'scope=pending' }
@@ -84,9 +105,17 @@ describe API::V3::Builds do
expect(response).to have_gitlab_http_status(401)
end
end
+
+ def go
+ get v3_api("/projects/#{project.id}/builds?#{query}", api_user)
+ end
end
describe 'GET /projects/:id/repository/commits/:sha/builds' do
+ before do
+ build
+ end
+
context 'when commit does not exist in repository' do
before do
get v3_api("/projects/#{project.id}/repository/commits/1a271fd1/builds", api_user)
@@ -187,22 +216,36 @@ describe API::V3::Builds do
describe 'GET /projects/:id/builds/:build_id/artifacts' do
before do
+ stub_artifacts_object_storage
get v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user)
end
context 'job with artifacts' do
- let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ context 'when artifacts are stored locally' do
+ let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
- context 'authorized user' do
- let(:download_headers) do
- { 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ context 'authorized user' do
+ let(:download_headers) do
+ { 'Content-Transfer-Encoding' => 'binary',
+ 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' }
+ end
+
+ 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)
+ end
end
+ end
- it 'returns specific job artifacts' do
- expect(response).to have_gitlab_http_status(200)
- expect(response.headers).to include(download_headers)
- expect(response.body).to match_file(build.artifacts_file.file.file)
+ context 'when artifacts are stored remotely' do
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: build) }
+
+ it 'returns location redirect' do
+ get v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user)
+
+ expect(response).to have_gitlab_http_status(302)
end
end
@@ -225,6 +268,7 @@ describe API::V3::Builds do
let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
before do
+ stub_artifacts_object_storage
build.success
end
@@ -280,14 +324,31 @@ describe API::V3::Builds do
context 'find proper job' do
shared_examples 'a valid file' do
- let(:download_headers) do
- { 'Content-Transfer-Encoding' => 'binary',
- 'Content-Disposition' =>
- "attachment; filename=#{build.artifacts_file.filename}" }
+ context 'when artifacts are stored locally' do
+ let(:download_headers) do
+ { 'Content-Transfer-Encoding' => 'binary',
+ 'Content-Disposition' =>
+ "attachment; filename=#{build.artifacts_file.filename}" }
+ end
+
+ it { expect(response).to have_http_status(200) }
+ it { expect(response.headers).to include(download_headers) }
end
- it { expect(response).to have_gitlab_http_status(200) }
- it { expect(response.headers).to include(download_headers) }
+ context 'when artifacts are stored remotely' do
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: build) }
+
+ before do
+ build.reload
+
+ get v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user)
+ end
+
+ it 'returns location redirect' do
+ expect(response).to have_http_status(302)
+ end
+ end
end
context 'with regular branch' do
@@ -319,7 +380,7 @@ describe API::V3::Builds do
end
describe 'GET /projects/:id/builds/:build_id/trace' do
- let(:build) { create(:ci_build, :trace, pipeline: pipeline) }
+ let(:build) { create(:ci_build, :trace_live, pipeline: pipeline) }
before do
get v3_api("/projects/#{project.id}/builds/#{build.id}/trace", api_user)
@@ -414,7 +475,7 @@ describe API::V3::Builds do
end
context 'job is erasable' do
- let(:build) { create(:ci_build, :trace, :artifacts, :success, project: project, pipeline: pipeline) }
+ let(:build) { create(:ci_build, :trace_artifact, :artifacts, :success, project: project, pipeline: pipeline) }
it 'erases job content' do
expect(response.status).to eq 201
@@ -430,7 +491,7 @@ describe API::V3::Builds do
end
context 'job is not erasable' do
- let(:build) { create(:ci_build, :trace, project: project, pipeline: pipeline) }
+ let(:build) { create(:ci_build, :trace_live, project: project, pipeline: pipeline) }
it 'responds with forbidden' do
expect(response.status).to eq 403
@@ -445,7 +506,7 @@ describe API::V3::Builds do
context 'artifacts did not expire' do
let(:build) do
- create(:ci_build, :trace, :artifacts, :success,
+ create(:ci_build, :trace_artifact, :artifacts, :success,
project: project, pipeline: pipeline, artifacts_expire_at: Time.now + 7.days)
end
diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb
index d31c94ddd2c..9ef3b859001 100644
--- a/spec/requests/api/v3/commits_spec.rb
+++ b/spec/requests/api/v3/commits_spec.rb
@@ -9,11 +9,11 @@ describe API::V3::Commits do
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') }
- before { project.team << [user, :reporter] }
+ before { project.add_reporter(user) }
describe "List repository commits" do
context "authorized user" do
- before { project.team << [user2, :reporter] }
+ before { project.add_reporter(user2) }
it "returns project commits" do
commit = project.repository.commit
@@ -36,7 +36,7 @@ describe API::V3::Commits do
context "since optional parameter" do
it "returns project commits since provided parameter" do
- commits = project.repository.commits("master")
+ commits = project.repository.commits("master", limit: 2)
since = commits.second.created_at
get v3_api("/projects/#{project.id}/repository/commits?since=#{since.utc.iso8601}", user)
@@ -49,12 +49,12 @@ describe API::V3::Commits do
context "until optional parameter" do
it "returns project commits until provided parameter" do
- commits = project.repository.commits("master")
+ commits = project.repository.commits("master", limit: 20)
before = commits.second.created_at
get v3_api("/projects/#{project.id}/repository/commits?until=#{before.utc.iso8601}", user)
- if commits.size >= 20
+ if commits.size == 20
expect(json_response.size).to eq(20)
else
expect(json_response.size).to eq(commits.size - 1)
@@ -403,6 +403,33 @@ describe API::V3::Commits do
expect(response).to have_gitlab_http_status(200)
expect(json_response['status']).to eq("created")
end
+
+ context 'when stat param' do
+ let(:project_id) { project.id }
+ let(:commit_id) { project.repository.commit.id }
+ let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}" }
+
+ it 'is not present return stats by default' do
+ get v3_api(route, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to include 'stats'
+ end
+
+ it "is false it does not include stats" do
+ get v3_api(route, user), stats: false
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).not_to include 'stats'
+ end
+
+ it "is true it includes stats" do
+ get v3_api(route, user), stats: true
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to include 'stats'
+ end
+ end
end
context "unauthorized user" do
@@ -415,7 +442,7 @@ describe API::V3::Commits do
describe "Get the diff of a commit" do
context "authorized user" do
- before { project.team << [user2, :reporter] }
+ before { project.add_reporter(user2) }
it "returns the diff of the selected commit" do
get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/diff", user)
@@ -487,7 +514,7 @@ describe API::V3::Commits do
end
it 'returns 400 if you are not allowed to push to the target branch' do
- project.team << [user2, :developer]
+ project.add_developer(user2)
protected_branch = create(:protected_branch, project: project, name: 'feature')
post v3_api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user2), branch: protected_branch.name
diff --git a/spec/requests/api/v3/deploy_keys_spec.rb b/spec/requests/api/v3/deploy_keys_spec.rb
index 785bc1eb4ba..501af587ad4 100644
--- a/spec/requests/api/v3/deploy_keys_spec.rb
+++ b/spec/requests/api/v3/deploy_keys_spec.rb
@@ -107,7 +107,7 @@ describe API::V3::DeployKeys do
end
it 'accepts can_push parameter' do
- key_attrs = attributes_for :write_access_key
+ key_attrs = attributes_for(:another_key).merge(can_push: true)
post v3_api("/projects/#{project.id}/#{path}", admin), key_attrs
diff --git a/spec/requests/api/v3/deployments_spec.rb b/spec/requests/api/v3/deployments_spec.rb
index 90eabda4dac..ac86fbea498 100644
--- a/spec/requests/api/v3/deployments_spec.rb
+++ b/spec/requests/api/v3/deployments_spec.rb
@@ -7,7 +7,7 @@ describe API::V3::Deployments do
let!(:deployment) { create(:deployment) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
shared_examples 'a paginated resources' do
diff --git a/spec/requests/api/v3/environments_spec.rb b/spec/requests/api/v3/environments_spec.rb
index 937250b5219..68be5256b64 100644
--- a/spec/requests/api/v3/environments_spec.rb
+++ b/spec/requests/api/v3/environments_spec.rb
@@ -7,7 +7,7 @@ describe API::V3::Environments do
let!(:environment) { create(:environment, project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
shared_examples 'a paginated resources' do
diff --git a/spec/requests/api/v3/files_spec.rb b/spec/requests/api/v3/files_spec.rb
index 5500c1cf770..26a3d8870a0 100644
--- a/spec/requests/api/v3/files_spec.rb
+++ b/spec/requests/api/v3/files_spec.rb
@@ -27,7 +27,7 @@ describe API::V3::Files do
let(:author_email) { 'user@example.org' }
let(:author_name) { 'John Doe' }
- before { project.team << [user, :developer] }
+ before { project.add_developer(user) }
describe "GET /projects/:id/repository/files" do
let(:route) { "/projects/#{project.id}/repository/files" }
diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb
index 498cb42fad1..a1cdf583de3 100644
--- a/spec/requests/api/v3/groups_spec.rb
+++ b/spec/requests/api/v3/groups_spec.rb
@@ -330,7 +330,7 @@ describe API::V3::Groups do
end
it "only returns projects to which user has access" do
- project3.team << [user3, :developer]
+ project3.add_developer(user3)
get v3_api("/groups/#{group1.id}/projects", user3)
diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb
index 39a47a62f16..11b5469be7b 100644
--- a/spec/requests/api/v3/issues_spec.rb
+++ b/spec/requests/api/v3/issues_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe API::V3::Issues, :mailer do
+describe API::V3::Issues do
set(:user) { create(:user) }
set(:user2) { create(:user) }
set(:non_member) { create(:user) }
@@ -50,8 +50,8 @@ describe API::V3::Issues, :mailer do
let(:no_milestone_title) { URI.escape(Milestone::None.title) }
before do
- project.team << [user, :reporter]
- project.team << [guest, :guest]
+ project.add_reporter(user)
+ project.add_guest(guest)
end
describe "GET /issues" do
@@ -278,7 +278,7 @@ describe API::V3::Issues, :mailer do
let!(:group_note) { create(:note_on_issue, author: user, project: group_project, noteable: group_issue) }
before do
- group_project.team << [user, :reporter]
+ group_project.add_reporter(user)
end
let(:base_url) { "/groups/#{group.id}/issues" }
@@ -780,18 +780,6 @@ describe API::V3::Issues, :mailer do
expect(json_response['error']).to eq('confidential is invalid')
end
- it "sends notifications for subscribers of newly added labels" do
- label = project.labels.first
- label.toggle_subscription(user2, project)
-
- perform_enqueued_jobs do
- post v3_api("/projects/#{project.id}/issues", user),
- title: 'new issue', labels: label.title
- end
-
- should_email(user2)
- end
-
it "returns a 400 bad request if title not given" do
post v3_api("/projects/#{project.id}/issues", user), labels: 'label, label2'
@@ -827,7 +815,7 @@ describe API::V3::Issues, :mailer do
let(:merge_request) { discussion.noteable }
let(:project) { merge_request.source_project }
before do
- project.team << [user, :master]
+ project.add_master(user)
post v3_api("/projects/#{project.id}/issues", user),
title: 'New Issue',
merge_request_for_resolving_discussions: merge_request.iid
@@ -1045,18 +1033,6 @@ describe API::V3::Issues, :mailer do
expect(json_response['labels']).to eq([label.title])
end
- it "sends notifications for subscribers of newly added labels when issue is updated" do
- label = create(:label, title: 'foo', color: '#FFAABB', project: project)
- label.toggle_subscription(user2, project)
-
- perform_enqueued_jobs do
- put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
- title: 'updated title', labels: label.title
- end
-
- should_email(user2)
- end
-
it 'removes all labels' do
put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), labels: ''
@@ -1191,7 +1167,7 @@ describe API::V3::Issues, :mailer do
end
describe '/projects/:id/issues/:issue_id/move' do
- let!(:target_project) { create(:project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
+ let!(:target_project) { create(:project, 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
@@ -1242,7 +1218,7 @@ describe API::V3::Issues, :mailer do
context 'when source project does not exist' do
it 'returns 404 when trying to move an issue' do
- post v3_api("/projects/123/issues/#{issue.id}/move", user),
+ post v3_api("/projects/0/issues/#{issue.id}/move", user),
to_project_id: target_project.id
expect(response).to have_gitlab_http_status(404)
@@ -1253,7 +1229,7 @@ describe API::V3::Issues, :mailer do
context 'when target project does not exist' do
it 'returns 404 when trying to move an issue' do
post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user),
- to_project_id: 123
+ to_project_id: 0
expect(response).to have_gitlab_http_status(404)
end
diff --git a/spec/requests/api/v3/labels_spec.rb b/spec/requests/api/v3/labels_spec.rb
index 1d31213d5ca..cdab4d2bd73 100644
--- a/spec/requests/api/v3/labels_spec.rb
+++ b/spec/requests/api/v3/labels_spec.rb
@@ -7,7 +7,7 @@ describe API::V3::Labels do
let!(:priority_label) { create(:label, title: 'bug', project: project, priority: 3) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET /projects/:id/labels' do
diff --git a/spec/requests/api/v3/members_spec.rb b/spec/requests/api/v3/members_spec.rb
index 68be3d24c26..de4339ecb8b 100644
--- a/spec/requests/api/v3/members_spec.rb
+++ b/spec/requests/api/v3/members_spec.rb
@@ -8,8 +8,8 @@ describe API::V3::Members do
let(:project) do
create(:project, :public, :access_requestable, creator_id: master.id, namespace: master.namespace) do |project|
- project.team << [developer, :developer]
- project.team << [master, :master]
+ project.add_developer(developer)
+ project.add_master(master)
project.request_access(access_requester)
end
end
@@ -58,6 +58,16 @@ describe API::V3::Members do
expect(json_response.count).to eq(1)
expect(json_response.first['username']).to eq(master.username)
end
+
+ it 'finds all members with no query specified' do
+ get v3_api("/#{source_type.pluralize}/#{source.id}/members", developer), query: ''
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq(2)
+ expect(json_response.map { |u| u['id'] }).to match_array [master.id, developer.id]
+ end
end
end
diff --git a/spec/requests/api/v3/merge_request_diffs_spec.rb b/spec/requests/api/v3/merge_request_diffs_spec.rb
index e613036a88d..547c066fadc 100644
--- a/spec/requests/api/v3/merge_request_diffs_spec.rb
+++ b/spec/requests/api/v3/merge_request_diffs_spec.rb
@@ -8,7 +8,7 @@ describe API::V3::MergeRequestDiffs, 'MergeRequestDiffs' do
before do
merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9')
merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e')
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'GET /projects/:id/merge_requests/:merge_request_id/versions' do
diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb
index 2e2b9449429..6b748369f0d 100644
--- a/spec/requests/api/v3/merge_requests_spec.rb
+++ b/spec/requests/api/v3/merge_requests_spec.rb
@@ -14,7 +14,7 @@ describe API::MergeRequests do
let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
describe "GET /projects/:id/merge_requests" do
@@ -371,16 +371,28 @@ describe API::MergeRequests do
expect(response).to have_gitlab_http_status(400)
end
- context 'when target_branch is specified' do
+ context 'when target_branch and target_project_id is specified' do
+ let(:params) do
+ { title: 'Test merge_request',
+ target_branch: 'master',
+ source_branch: 'markdown',
+ author: user2,
+ target_project_id: unrelated_project.id }
+ end
+
it 'returns 422 if targeting a different fork' do
- post v3_api("/projects/#{forked_project.id}/merge_requests", user2),
- title: 'Test merge_request',
- target_branch: 'master',
- source_branch: 'markdown',
- author: user2,
- target_project_id: unrelated_project.id
+ unrelated_project.add_developer(user2)
+
+ post v3_api("/projects/#{forked_project.id}/merge_requests", user2), params
+
expect(response).to have_gitlab_http_status(422)
end
+
+ it 'returns 403 if targeting a different fork which user can not access' do
+ post v3_api("/projects/#{forked_project.id}/merge_requests", user2), params
+
+ expect(response).to have_gitlab_http_status(403)
+ end
end
it "returns 201 when target_branch is specified and for the same project" do
@@ -396,7 +408,7 @@ describe API::MergeRequests do
let(:developer) { create(:user) }
before do
- project.team << [developer, :developer]
+ project.add_developer(developer)
end
it "denies the deletion of the merge request" do
@@ -458,7 +470,7 @@ describe API::MergeRequests do
it "returns 401 if user has no permissions to merge" do
user2 = create(:user)
- project.team << [user2, :reporter]
+ project.add_reporter(user2)
put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user2)
expect(response).to have_gitlab_http_status(401)
expect(json_response['message']).to eq('401 Unauthorized')
@@ -645,7 +657,7 @@ describe API::MergeRequests do
project = create(:project, :private, :repository)
merge_request = create(:merge_request, :simple, source_project: project)
guest = create(:user)
- project.team << [guest, :guest]
+ project.add_guest(guest)
get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", guest)
@@ -675,7 +687,7 @@ describe API::MergeRequests do
it 'returns 403 if user has no access to read code' do
guest = create(:user)
- project.team << [guest, :guest]
+ project.add_guest(guest)
post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", guest)
@@ -705,7 +717,7 @@ describe API::MergeRequests do
it 'returns 403 if user has no access to read code' do
guest = create(:user)
- project.team << [guest, :guest]
+ project.add_guest(guest)
delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", guest)
diff --git a/spec/requests/api/v3/milestones_spec.rb b/spec/requests/api/v3/milestones_spec.rb
index e82f35598a6..6021600e09c 100644
--- a/spec/requests/api/v3/milestones_spec.rb
+++ b/spec/requests/api/v3/milestones_spec.rb
@@ -6,7 +6,7 @@ describe API::V3::Milestones do
let!(:closed_milestone) { create(:closed_milestone, project: project) }
let!(:milestone) { create(:milestone, project: project) }
- before { project.team << [user, :developer] }
+ before { project.add_developer(user) }
describe 'GET /projects/:id/milestones' do
it 'returns project milestones' do
@@ -161,7 +161,7 @@ describe API::V3::Milestones do
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)
+ expect(Event).to receive(:create!)
put v3_api("/projects/#{project.id}/milestones/#{milestone.id}", user),
state_event: 'close'
@@ -200,7 +200,7 @@ describe API::V3::Milestones do
let(:confidential_issue) { create(:issue, confidential: true, project: public_project) }
before do
- public_project.team << [user, :developer]
+ public_project.add_developer(user)
milestone.issues << issue << confidential_issue
end
@@ -215,7 +215,7 @@ describe API::V3::Milestones do
it 'does not return confidential issues to team members with guest role' do
member = create(:user)
- project.team << [member, :guest]
+ project.add_guest(member)
get v3_api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", member)
diff --git a/spec/requests/api/v3/notes_spec.rb b/spec/requests/api/v3/notes_spec.rb
index d3455a4bba4..5532795ab02 100644
--- a/spec/requests/api/v3/notes_spec.rb
+++ b/spec/requests/api/v3/notes_spec.rb
@@ -14,7 +14,7 @@ describe API::V3::Notes do
let(:private_user) { create(:user) }
let(:private_project) do
create(:project, namespace: private_user.namespace)
- .tap { |p| p.team << [private_user, :master] }
+ .tap { |p| p.add_master(private_user) }
end
let(:private_issue) { create(:issue, project: private_project) }
@@ -28,7 +28,7 @@ describe API::V3::Notes do
system: true
end
- before { project.team << [user, :reporter] }
+ before { project.add_reporter(user) }
describe "GET /projects/:id/noteable/:noteable_id/notes" do
context "when noteable is an Issue" do
@@ -302,7 +302,7 @@ describe API::V3::Notes do
describe "POST /projects/:id/noteable/:noteable_id/notes to test observer on create" do
it "creates an activity event when an issue note is created" do
- expect(Event).to receive(:create)
+ expect(Event).to receive(:create!)
post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: 'hi!'
end
diff --git a/spec/requests/api/v3/pipelines_spec.rb b/spec/requests/api/v3/pipelines_spec.rb
index 1c7d9fe32bb..ea943f22c41 100644
--- a/spec/requests/api/v3/pipelines_spec.rb
+++ b/spec/requests/api/v3/pipelines_spec.rb
@@ -10,7 +10,7 @@ describe API::V3::Pipelines do
ref: project.default_branch)
end
- before { project.team << [user, :master] }
+ before { project.add_master(user) }
shared_examples 'a paginated resources' do
before do
@@ -188,7 +188,7 @@ describe API::V3::Pipelines do
context 'user without proper access rights' do
let!(:reporter) { create(:user) }
- before { project.team << [reporter, :reporter] }
+ before { project.add_reporter(reporter) }
it 'rejects the action' do
post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter)
diff --git a/spec/requests/api/v3/project_hooks_spec.rb b/spec/requests/api/v3/project_hooks_spec.rb
index 00f59744a31..8f6a2330d25 100644
--- a/spec/requests/api/v3/project_hooks_spec.rb
+++ b/spec/requests/api/v3/project_hooks_spec.rb
@@ -13,8 +13,8 @@ describe API::ProjectHooks, 'ProjectHooks' do
end
before do
- project.team << [user, :master]
- project.team << [user3, :developer]
+ project.add_master(user)
+ project.add_developer(user3)
end
describe "GET /projects/:id/hooks" do
@@ -27,6 +27,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response.count).to eq(1)
expect(json_response.first['url']).to eq("http://example.com")
expect(json_response.first['issues_events']).to eq(true)
+ expect(json_response.first['confidential_issues_events']).to eq(true)
expect(json_response.first['push_events']).to eq(true)
expect(json_response.first['merge_requests_events']).to eq(true)
expect(json_response.first['tag_push_events']).to eq(true)
@@ -54,6 +55,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(response).to have_gitlab_http_status(200)
expect(json_response['url']).to eq(hook.url)
expect(json_response['issues_events']).to eq(hook.issues_events)
+ expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events)
expect(json_response['push_events']).to eq(hook.push_events)
expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events)
expect(json_response['tag_push_events']).to eq(hook.tag_push_events)
@@ -87,12 +89,13 @@ 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, build_events: true
+ url: "http://example.com", issues_events: true, confidential_issues_events: true, wiki_page_events: true, build_events: true
end.to change {project.hooks.count}.by(1)
expect(response).to have_gitlab_http_status(201)
expect(json_response['url']).to eq('http://example.com')
expect(json_response['issues_events']).to eq(true)
+ expect(json_response['confidential_issues_events']).to eq(true)
expect(json_response['push_events']).to eq(true)
expect(json_response['merge_requests_events']).to eq(false)
expect(json_response['tag_push_events']).to eq(false)
@@ -139,6 +142,7 @@ describe API::ProjectHooks, 'ProjectHooks' do
expect(response).to have_gitlab_http_status(200)
expect(json_response['url']).to eq('http://example.org')
expect(json_response['issues_events']).to eq(hook.issues_events)
+ expect(json_response['confidential_issues_events']).to eq(hook.confidential_issues_events)
expect(json_response['push_events']).to eq(false)
expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events)
expect(json_response['tag_push_events']).to eq(hook.tag_push_events)
@@ -205,7 +209,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(:project)
- other_project.team << [test_user, :master]
+ other_project.add_master(test_user)
delete v3_api("/projects/#{other_project.id}/hooks/#{hook.id}", test_user)
expect(response).to have_gitlab_http_status(404)
diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb
index 27288b98d1c..4c25bd935c6 100644
--- a/spec/requests/api/v3/projects_spec.rb
+++ b/spec/requests/api/v3/projects_spec.rb
@@ -1,14 +1,12 @@
require 'spec_helper'
describe API::V3::Projects do
- include Gitlab::CurrentSettings
-
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
let(:admin) { create(:admin) }
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(:project2) { create(:project, 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, :developer, user: user3, project: project) }
let(:user4) { create(:user) }
@@ -403,7 +401,7 @@ describe API::V3::Projects do
post v3_api('/projects', user), project
project.each_pair do |k, v|
- next if %i[has_external_issue_tracker issues_enabled merge_requests_enabled wiki_enabled].include?(k)
+ next if %i[storage_version has_external_issue_tracker issues_enabled merge_requests_enabled wiki_enabled].include?(k)
expect(json_response[k.to_s]).to eq(v)
end
@@ -547,7 +545,7 @@ describe API::V3::Projects do
expect(response).to have_gitlab_http_status(201)
project.each_pair do |k, v|
- next if %i[has_external_issue_tracker path].include?(k)
+ next if %i[storage_version has_external_issue_tracker path].include?(k)
expect(json_response[k.to_s]).to eq(v)
end
@@ -753,7 +751,7 @@ describe API::V3::Projects do
describe 'permissions' do
context 'all projects' do
- before { project.team << [user, :master] }
+ before { project.add_master(user) }
it 'contains permission information' do
get v3_api("/projects", user)
@@ -767,7 +765,7 @@ describe API::V3::Projects do
context 'personal project' do
it 'sets project access and returns 200' do
- project.team << [user, :master]
+ project.add_master(user)
get v3_api("/projects/#{project.id}", user)
expect(response).to have_gitlab_http_status(200)
@@ -1362,7 +1360,7 @@ describe API::V3::Projects do
context 'user without archiving rights to the project' do
before do
- project.team << [user3, :developer]
+ project.add_developer(user3)
end
it 'rejects the action' do
@@ -1398,7 +1396,7 @@ describe API::V3::Projects do
context 'user without archiving rights to the project' do
before do
- project.team << [user3, :developer]
+ project.add_developer(user3)
end
it 'rejects the action' do
@@ -1466,7 +1464,7 @@ describe API::V3::Projects do
it 'does not remove a project if not an owner' do
user3 = create(:user)
- project.team << [user3, :developer]
+ project.add_developer(user3)
delete v3_api("/projects/#{project.id}", user3)
expect(response).to have_gitlab_http_status(403)
end
diff --git a/spec/requests/api/v3/services_spec.rb b/spec/requests/api/v3/services_spec.rb
index 8f212ab6be6..c69a7d58ca6 100644
--- a/spec/requests/api/v3/services_spec.rb
+++ b/spec/requests/api/v3/services_spec.rb
@@ -10,6 +10,10 @@ describe API::V3::Services do
describe "DELETE /projects/:id/services/#{service.dasherize}" do
include_context service
+ before do
+ initialize_service(service)
+ end
+
it "deletes #{service}" do
delete v3_api("/projects/#{project.id}/services/#{dashed_service}", user)
diff --git a/spec/requests/api/v3/templates_spec.rb b/spec/requests/api/v3/templates_spec.rb
index 38a8994eb79..1a637f3cf96 100644
--- a/spec/requests/api/v3/templates_spec.rb
+++ b/spec/requests/api/v3/templates_spec.rb
@@ -57,7 +57,7 @@ describe API::V3::Templates do
expect(json_response['description']).to include('A short and simple permissive license with conditions')
expect(json_response['conditions']).to eq(%w[include-copyright])
expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use])
- expect(json_response['limitations']).to eq(%w[no-liability])
+ expect(json_response['limitations']).to eq(%w[liability warranty])
expect(json_response['content']).to include('MIT License')
end
end
diff --git a/spec/requests/api/v3/todos_spec.rb b/spec/requests/api/v3/todos_spec.rb
index 8f5c3fbf8dd..ea648e3917f 100644
--- a/spec/requests/api/v3/todos_spec.rb
+++ b/spec/requests/api/v3/todos_spec.rb
@@ -12,8 +12,8 @@ describe API::V3::Todos do
let!(:done) { create(:todo, :done, project: project_1, author: author_1, user: john_doe) }
before do
- project_1.team << [john_doe, :developer]
- project_2.team << [john_doe, :developer]
+ project_1.add_developer(john_doe)
+ project_2.add_developer(john_doe)
end
describe 'DELETE /todos/:id' do
@@ -38,6 +38,12 @@ describe API::V3::Todos do
delete v3_api("/todos/#{pending_1.id}", john_doe)
end
+
+ it 'returns 404 if the todo does not belong to the current user' do
+ delete v3_api("/todos/#{pending_1.id}", author_1)
+
+ expect(response.status).to eq(404)
+ end
end
end
diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb
index 79ee6c126f6..62215ea3d7d 100644
--- a/spec/requests/api/variables_spec.rb
+++ b/spec/requests/api/variables_spec.rb
@@ -122,12 +122,12 @@ describe API::Variables do
describe 'PUT /projects/:id/variables/:key' do
context 'authorized user with proper permissions' do
it 'updates variable data' do
- initial_variable = project.variables.first
+ initial_variable = project.variables.reload.first
value_before = initial_variable.value
put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP', protected: true
- updated_variable = project.variables.first
+ updated_variable = project.variables.reload.first
expect(response).to have_gitlab_http_status(200)
expect(value_before).to eq(variable.value)
diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb
index 65bd001e491..fb0806ff9f1 100644
--- a/spec/requests/api/wikis_spec.rb
+++ b/spec/requests/api/wikis_spec.rb
@@ -12,6 +12,8 @@ require 'spec_helper'
describe API::Wikis do
let(:user) { create(:user) }
+ let(:group) { create(:group).tap { |g| g.add_owner(user) } }
+ let(:project_wiki) { create(:project_wiki, project: project, user: user) }
let(:payload) { { content: 'content', format: 'rdoc', title: 'title' } }
let(:expected_keys_with_content) { %w(content format slug title) }
let(:expected_keys_without_content) { %w(format slug title) }
@@ -19,8 +21,8 @@ describe API::Wikis do
shared_examples_for 'returns list of wiki pages' do
context 'when wiki has pages' do
let!(:pages) do
- [create(:wiki_page, wiki: project.wiki, attrs: { title: 'page1', content: 'content of page1' }),
- create(:wiki_page, wiki: project.wiki, attrs: { title: 'page2', content: 'content of page2' })]
+ [create(:wiki_page, wiki: project_wiki, attrs: { title: 'page1', content: 'content of page1' }),
+ create(:wiki_page, wiki: project_wiki, attrs: { title: 'page2', content: 'content of page2' })]
end
it 'returns the list of wiki pages without content' do
@@ -445,7 +447,7 @@ describe API::Wikis do
end
describe 'PUT /projects/:id/wikis/:slug' do
- let(:page) { create(:wiki_page, wiki: project.wiki) }
+ let(:page) { create(:wiki_page, wiki: project_wiki) }
let(:payload) { { title: 'new title', content: 'new content' } }
let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" }
@@ -568,10 +570,20 @@ describe API::Wikis do
end
end
end
+
+ context 'when wiki belongs to a group project' do
+ let(:project) { create(:project, namespace: group) }
+
+ before do
+ put(api(url, user), payload)
+ end
+
+ include_examples 'updates wiki page'
+ end
end
describe 'DELETE /projects/:id/wikis/:slug' do
- let(:page) { create(:wiki_page, wiki: project.wiki) }
+ let(:page) { create(:wiki_page, wiki: project_wiki) }
let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" }
context 'when wiki is disabled' do
@@ -675,5 +687,15 @@ describe API::Wikis do
end
end
end
+
+ context 'when wiki belongs to a group project' do
+ let(:project) { create(:project, namespace: group) }
+
+ before do
+ delete(api(url, user))
+ end
+
+ include_examples '204 No Content'
+ end
end
end
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index fa02fffc82a..494db30e8e0 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -107,15 +107,39 @@ describe 'Git HTTP requests' do
let(:user) { create(:user) }
context "when the project doesn't exist" do
- let(:path) { 'doesnt/exist.git' }
+ context "when namespace doesn't exist" do
+ let(:path) { 'doesnt/exist.git' }
- it_behaves_like 'pulls require Basic HTTP Authentication'
- it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'pulls require Basic HTTP Authentication'
+ it_behaves_like 'pushes require Basic HTTP Authentication'
- 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_gitlab_http_status(:not_found)
+ 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_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ context 'when namespace exists' do
+ let(:path) { "#{user.namespace.path}/new-project.git"}
+
+ context 'when authenticated' do
+ it 'creates a new project under the existing namespace' do
+ expect do
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end.to change { user.projects.count }.by(1)
+ end
+
+ it 'rejects push with 422 Unprocessable Entity when project is invalid' do
+ path = "#{user.namespace.path}/new.git"
+
+ push_get(path, user: user.username, password: user.password)
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
end
@@ -126,7 +150,7 @@ describe 'Git HTTP requests' do
let(:path) { "/#{wiki.repository.full_path}.git" }
context "when the project is public" do
- let(:project) { create(:project, :repository, :public, :wiki_enabled) }
+ let(:project) { create(:project, :wiki_repo, :public, :wiki_enabled) }
it_behaves_like 'pushes require Basic HTTP Authentication'
@@ -139,7 +163,7 @@ describe 'Git HTTP requests' do
download(path) do |response|
json_body = ActiveSupport::JSON.decode(response.body)
- expect(json_body['RepoPath']).to include(wiki.repository.full_path)
+ expect(json_body['Repository']['relative_path']).to eq(wiki.repository.relative_path)
end
end
end
@@ -149,11 +173,11 @@ describe 'Git HTTP requests' do
context 'and as a developer on the team' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'but the repo is disabled' do
- let(:project) { create(:project, :repository, :public, :repository_disabled, :wiki_enabled) }
+ let(:project) { create(:project, :wiki_repo, :public, :repository_disabled, :wiki_enabled) }
it_behaves_like 'pulls are allowed'
it_behaves_like 'pushes are allowed'
@@ -174,7 +198,7 @@ describe 'Git HTTP requests' do
end
context "when the project is private" do
- let(:project) { create(:project, :repository, :private, :wiki_enabled) }
+ let(:project) { create(:project, :wiki_repo, :private, :wiki_enabled) }
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
@@ -182,11 +206,11 @@ describe 'Git HTTP requests' do
context 'when authenticated' do
context 'and as a developer on the team' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'but the repo is disabled' do
- let(:project) { create(:project, :repository, :private, :repository_disabled, :wiki_enabled) }
+ let(:project) { create(:project, :wiki_repo, :private, :repository_disabled, :wiki_enabled) }
it 'allows clones' do
download(path, user: user.username, password: user.password) do |response|
@@ -240,7 +264,7 @@ describe 'Git HTTP requests' do
context 'as a developer on the team' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it_behaves_like 'pulls are allowed'
@@ -320,20 +344,11 @@ describe 'Git HTTP requests' do
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:
- git remote set-url origin #{project.http_url_to_repo} and try again.
- MSG
- end
-
- it 'downloads get status 404 with "project was moved" message' do
+ it 'downloads get status 200 for redirects' do
clone_get(path, {})
- expect(response).to have_gitlab_http_status(:not_found)
- expect(response.body).to match(project_moved_message)
+
+ expect(response).to have_gitlab_http_status(:ok)
end
end
end
@@ -365,13 +380,13 @@ describe 'Git HTTP requests' do
context "when the user has access to the project" do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
context "when the user is blocked" do
it "rejects pulls with 401 Unauthorized" do
user.block
- project.team << [user, :master]
+ project.add_master(user)
download(path, env) do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
@@ -434,7 +449,7 @@ describe 'Git HTTP requests' do
let(:path) { "#{project.full_path}.git" }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
context 'when username and password are provided' do
@@ -482,8 +497,8 @@ describe 'Git HTTP requests' do
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)
+ allow(Gitlab::Auth::LDAP::Config).to receive(:enabled?).and_return(true)
+ allow_any_instance_of(Gitlab::Auth::LDAP::Authentication)
.to receive(:login).and_return(nil)
end
@@ -535,20 +550,19 @@ describe 'Git HTTP requests' do
Please update your Git remote:
- git remote set-url origin #{project.http_url_to_repo} 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
+ it 'downloads get status 200' do
clone_get(path, env)
- expect(response).to have_gitlab_http_status(:not_found)
- expect(response.body).to match(project_moved_message)
+
+ expect(response).to have_gitlab_http_status(:ok)
end
it 'uploads get status 404 with "project was moved" message' do
upload(path, env) do |response|
- expect(response).to have_gitlab_http_status(:not_found)
- expect(response.body).to match(project_moved_message)
+ expect(response).to have_gitlab_http_status(:ok)
end
end
end
@@ -573,7 +587,7 @@ describe 'Git HTTP requests' do
context "when a gitlab ci token is provided" do
let(:project) { create(:project, :repository) }
let(:build) { create(:ci_build, :running) }
- let(:other_project) { create(:project) }
+ let(:other_project) { create(:project, :repository) }
before do
build.update!(project: project) # can't associate it on factory create
@@ -596,7 +610,7 @@ describe 'Git HTTP requests' do
push_get(path, env)
expect(response).to have_gitlab_http_status(:forbidden)
- expect(response.body).to eq(git_access_error(:upload))
+ expect(response.body).to eq(git_access_error(:auth_upload))
end
# We are "authenticated" as CI using a valid token here. But we are
@@ -612,7 +626,7 @@ describe 'Git HTTP requests' do
context 'and build created by' do
before do
build.update(user: user)
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
shared_examples 'can download code only' do
@@ -624,10 +638,10 @@ describe 'Git HTTP requests' do
context 'when the repo does not exist' do
let(:project) { create(:project) }
- it 'rejects pulls with 403 Forbidden' do
+ it 'rejects pulls with 404 Not Found' do
clone_get path, env
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:not_found)
expect(response.body).to eq(git_access_error(:no_repo))
end
end
@@ -636,7 +650,7 @@ describe 'Git HTTP requests' do
push_get path, env
expect(response).to have_gitlab_http_status(:forbidden)
- expect(response.body).to eq(git_access_error(:upload))
+ expect(response.body).to eq(git_access_error(:auth_upload))
end
end
@@ -771,9 +785,9 @@ describe 'Git HTTP requests' do
let(:path) { 'doesnt/exist.git' }
before do
- allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true)
- allow(Gitlab::LDAP::Authentication).to receive(:login).and_return(nil)
- allow(Gitlab::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user)
+ allow(Gitlab::Auth::OAuth::Provider).to receive(:enabled?).and_return(true)
+ allow_any_instance_of(Gitlab::Auth::LDAP::Authentication).to receive(:login).and_return(nil)
+ allow_any_instance_of(Gitlab::Auth::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user)
end
it_behaves_like 'pulls require Basic HTTP Authentication'
@@ -795,7 +809,7 @@ describe 'Git HTTP requests' do
context 'and the user is on the team' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it "responds with status 200" do
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index c597623bc4d..f80abb06fca 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -63,7 +63,7 @@ describe 'Git LFS API and storage' do
context 'with LFS disabled globally' do
before do
- project.team << [user, :master]
+ project.add_master(user)
allow(Gitlab.config.lfs).to receive(:enabled).and_return(false)
end
@@ -106,7 +106,7 @@ describe 'Git LFS API and storage' do
context 'with LFS enabled globally' do
before do
- project.team << [user, :master]
+ project.add_master(user)
enable_lfs
end
@@ -191,10 +191,12 @@ describe 'Git LFS API and storage' do
describe 'when fetching lfs object' do
let(:project) { create(:project) }
let(:update_permissions) { }
+ let(:before_get) { }
before do
enable_lfs
update_permissions
+ before_get
get "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", nil, headers
end
@@ -234,11 +236,43 @@ describe 'Git LFS API and storage' do
context 'and does have project access' do
let(:update_permissions) do
- project.team << [user, :master]
+ project.add_master(user)
project.lfs_objects << lfs_object
end
it_behaves_like 'responds with a file'
+
+ context 'when LFS uses object storage' do
+ context 'when proxy download is enabled' do
+ let(:before_get) do
+ stub_lfs_object_storage(proxy_download: true)
+ lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
+ end
+
+ it 'responds with redirect' do
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'responds with the workhorse send-url' do
+ expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("send-url:")
+ end
+ end
+
+ context 'when proxy download is disabled' do
+ let(:before_get) do
+ stub_lfs_object_storage(proxy_download: false)
+ lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
+ end
+
+ it 'responds with redirect' do
+ expect(response).to have_gitlab_http_status(302)
+ end
+
+ it 'responds with the file location' do
+ expect(response.location).to include(lfs_object.reload.file.path)
+ end
+ end
+ end
end
end
@@ -259,7 +293,7 @@ describe 'Git LFS API and storage' do
context 'when user allowed' do
let(:update_permissions) do
- project.team << [user, :master]
+ project.add_master(user)
project.lfs_objects << lfs_object
end
@@ -295,7 +329,7 @@ describe 'Git LFS API and storage' do
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:update_permissions) do
- project.team << [user, :reporter]
+ project.add_reporter(user)
project.lfs_objects << lfs_object
end
@@ -517,7 +551,7 @@ describe 'Git LFS API and storage' do
let(:authorization) { authorize_user }
let(:update_user_permissions) do
- project.team << [user, role]
+ project.add_role(user, role)
end
it_behaves_like 'an authorized requests' do
@@ -553,7 +587,7 @@ describe 'Git LFS API and storage' do
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:update_user_permissions) do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
it_behaves_like 'an authorized requests'
@@ -673,7 +707,7 @@ describe 'Git LFS API and storage' do
let(:authorization) { authorize_user }
let(:update_user_permissions) do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'when pushing an lfs object that already exists' do
@@ -781,11 +815,11 @@ describe 'Git LFS API and storage' do
end
context 'when deploy key has project push access' do
- let(:key) { create(:deploy_key, can_push: true) }
+ let(:key) { create(:deploy_key) }
let(:authorization) { authorize_deploy_key }
let(:update_user_permissions) do
- project.deploy_keys << key
+ project.deploy_keys_projects.create(deploy_key: key, can_push: true)
end
it_behaves_like 'pushes new LFS objects'
@@ -795,7 +829,7 @@ describe 'Git LFS API and storage' do
context 'when user is not authenticated' do
context 'when user has push access' do
let(:update_user_permissions) do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'responds with status 401' do
@@ -840,7 +874,7 @@ describe 'Git LFS API and storage' do
before do
allow(Gitlab::Database).to receive(:read_only?) { true }
- project.team << [user, :master]
+ project.add_master(user)
enable_lfs
end
@@ -935,7 +969,7 @@ describe 'Git LFS API and storage' do
describe 'when user has push access to the project' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'and the request bypassed workhorse' do
@@ -945,22 +979,61 @@ describe 'Git LFS API and storage' do
end
context 'and request is sent by gitlab-workhorse to authorize the request' do
- before do
- put_authorize
+ shared_examples 'a valid response' do
+ before do
+ put_authorize
+ end
+
+ it 'responds with status 200' do
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'uses the gitlab-workhorse content type' do
+ expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
end
- it 'responds with status 200' do
- expect(response).to have_gitlab_http_status(200)
+ shared_examples 'a local file' do
+ it_behaves_like 'a valid response' do
+ it 'responds with status 200, location of lfs store and object details' do
+ expect(json_response['TempPath']).to eq(LfsObjectUploader.workhorse_local_upload_path)
+ expect(json_response['RemoteObject']).to be_nil
+ expect(json_response['LfsOid']).to eq(sample_oid)
+ expect(json_response['LfsSize']).to eq(sample_size)
+ end
+ end
end
- it 'uses the gitlab-workhorse content type' do
- expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ context 'when using local storage' do
+ it_behaves_like 'a local file'
end
- it 'responds with status 200, location of lfs store and object details' do
- expect(json_response['StoreLFSPath']).to eq("#{Gitlab.config.shared.path}/lfs-objects/tmp/upload")
- expect(json_response['LfsOid']).to eq(sample_oid)
- expect(json_response['LfsSize']).to eq(sample_size)
+ context 'when using remote storage' do
+ context 'when direct upload is enabled' do
+ before do
+ stub_lfs_object_storage(enabled: true, direct_upload: true)
+ end
+
+ it_behaves_like 'a valid response' do
+ it 'responds with status 200, location of lfs remote store and object details' do
+ expect(json_response['TempPath']).to eq(LfsObjectUploader.workhorse_local_upload_path)
+ expect(json_response['RemoteObject']).to have_key('ID')
+ expect(json_response['RemoteObject']).to have_key('GetURL')
+ expect(json_response['RemoteObject']).to have_key('StoreURL')
+ expect(json_response['RemoteObject']).to have_key('DeleteURL')
+ expect(json_response['LfsOid']).to eq(sample_oid)
+ expect(json_response['LfsSize']).to eq(sample_size)
+ end
+ end
+ end
+
+ context 'when direct upload is disabled' do
+ before do
+ stub_lfs_object_storage(enabled: true, direct_upload: false)
+ end
+
+ it_behaves_like 'a local file'
+ end
end
end
@@ -978,14 +1051,98 @@ describe 'Git LFS API and storage' do
end
end
+ context 'and workhorse requests upload finalize for a new lfs object' do
+ before do
+ lfs_object.destroy
+ end
+
+ context 'with object storage disabled' do
+ it "doesn't attempt to migrate file to object storage" do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ put_finalize(with_tempfile: true)
+ end
+ end
+
+ context 'with object storage enabled' do
+ context 'and direct upload enabled' do
+ let!(:fog_connection) do
+ stub_lfs_object_storage(direct_upload: true)
+ end
+
+ ['123123', '../../123123'].each do |remote_id|
+ context "with invalid remote_id: #{remote_id}" do
+ subject do
+ put_finalize(with_tempfile: true, args: {
+ 'file.remote_id' => remote_id
+ })
+ end
+
+ it 'responds with status 403' do
+ subject
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+
+ context 'with valid remote_id' do
+ before do
+ fog_connection.directories.get('lfs-objects').files.create(
+ key: 'tmp/upload/12312300',
+ body: 'content'
+ )
+ end
+
+ subject do
+ put_finalize(with_tempfile: true, args: {
+ 'file.remote_id' => '12312300',
+ 'file.name' => 'name'
+ })
+ end
+
+ it 'responds with status 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'schedules migration of file to object storage' do
+ subject
+
+ expect(LfsObject.last.projects).to include(project)
+ end
+
+ it 'have valid file' do
+ subject
+
+ expect(LfsObject.last.file_store).to eq(ObjectStorage::Store::REMOTE)
+ expect(LfsObject.last.file).to be_exists
+ end
+ end
+ end
+
+ context 'and background upload enabled' do
+ before do
+ stub_lfs_object_storage(background_upload: true)
+ end
+
+ it 'schedules migration of file to object storage' do
+ expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with('LfsObjectUploader', 'LfsObject', :file, kind_of(Numeric))
+
+ put_finalize(with_tempfile: true)
+ end
+ end
+ end
+ end
+
context 'invalid tempfiles' do
- it 'rejects slashes in the tempfile name (path traversal' do
- put_finalize('foo/bar')
- expect(response).to have_gitlab_http_status(403)
+ before do
+ lfs_object.destroy
end
- it 'rejects tempfile names that do not start with the oid' do
- put_finalize("foo#{sample_oid}")
+ it 'rejects slashes in the tempfile name (path traversal)' do
+ put_finalize('../bar', with_tempfile: true)
expect(response).to have_gitlab_http_status(403)
end
end
@@ -993,7 +1150,7 @@ describe 'Git LFS API and storage' do
describe 'and user does not have push access' do
before do
- project.team << [user, :reporter]
+ project.add_reporter(user)
end
it_behaves_like 'forbidden'
@@ -1010,7 +1167,7 @@ describe 'Git LFS API and storage' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
put_authorize
end
@@ -1062,7 +1219,7 @@ describe 'Git LFS API and storage' do
describe 'when user has push access to the project' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'and request is sent by gitlab-workhorse to authorize the request' do
@@ -1075,7 +1232,7 @@ describe 'Git LFS API and storage' do
end
it 'with location of lfs store and object details' do
- expect(json_response['StoreLFSPath']).to eq("#{Gitlab.config.shared.path}/lfs-objects/tmp/upload")
+ expect(json_response['TempPath']).to eq(LfsObjectUploader.workhorse_local_upload_path)
expect(json_response['LfsOid']).to eq(sample_oid)
expect(json_response['LfsSize']).to eq(sample_size)
end
@@ -1149,7 +1306,7 @@ describe 'Git LFS API and storage' do
let(:authorization) { authorize_user }
before do
- second_project.team << [user, :master]
+ second_project.add_master(user)
upstream_project.lfs_objects << lfs_object
end
@@ -1177,9 +1334,25 @@ describe 'Git LFS API and storage' do
put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}/authorize", nil, authorize_headers
end
- def put_finalize(lfs_tmp = lfs_tmp_file)
- put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", nil,
- headers.merge('X-Gitlab-Lfs-Tmp' => lfs_tmp).compact
+ def put_finalize(lfs_tmp = lfs_tmp_file, with_tempfile: false, args: {})
+ upload_path = LfsObjectUploader.workhorse_local_upload_path
+ file_path = upload_path + '/' + lfs_tmp if lfs_tmp
+
+ if with_tempfile
+ FileUtils.mkdir_p(upload_path)
+ FileUtils.touch(file_path)
+ end
+
+ extra_args = {
+ 'file.path' => file_path,
+ 'file.name' => File.basename(file_path)
+ }
+
+ put_finalize_with_args(args.merge(extra_args).compact)
+ end
+
+ def put_finalize_with_args(args)
+ put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", args, headers
end
def lfs_tmp_file
@@ -1208,7 +1381,7 @@ describe 'Git LFS API and storage' do
end
def post_lfs_json(url, body = nil, headers = nil)
- post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => 'application/vnd.git-lfs+json'))
+ post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE))
end
def json_response
diff --git a/spec/requests/lfs_locks_api_spec.rb b/spec/requests/lfs_locks_api_spec.rb
new file mode 100644
index 00000000000..e44a11a7232
--- /dev/null
+++ b/spec/requests/lfs_locks_api_spec.rb
@@ -0,0 +1,159 @@
+require 'spec_helper'
+
+describe 'Git LFS File Locking API' do
+ include WorkhorseHelpers
+
+ let(:project) { create(:project) }
+ let(:master) { create(:user) }
+ let(:developer) { create(:user) }
+ let(:guest) { create(:user) }
+ let(:path) { 'README.md' }
+ let(:headers) do
+ {
+ 'Authorization' => authorization
+ }.compact
+ end
+
+ shared_examples 'unauthorized request' do
+ context 'when user is not authorized' do
+ let(:authorization) { authorize_user(guest) }
+
+ it 'returns a forbidden 403 response' do
+ post_lfs_json url, body, headers
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+
+ project.add_developer(master)
+ project.add_developer(developer)
+ project.add_guest(guest)
+ end
+
+ describe 'Create File Lock endpoint' do
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" }
+ let(:authorization) { authorize_user(developer) }
+ let(:body) { { path: path } }
+
+ include_examples 'unauthorized request'
+
+ context 'with an existent lock' do
+ before do
+ lock_file('README.md', developer)
+ end
+
+ it 'return an error message' do
+ post_lfs_json url, body, headers
+
+ expect(response).to have_gitlab_http_status(409)
+
+ expect(json_response.keys).to match_array(%w(lock message documentation_url))
+ expect(json_response['message']).to match(/already locked/)
+ end
+
+ it 'returns the existen lock' do
+ post_lfs_json url, body, headers
+
+ expect(json_response['lock']['path']).to eq('README.md')
+ end
+ end
+
+ context 'without an existent lock' do
+ it 'creates the lock' do
+ post_lfs_json url, body, headers
+
+ expect(response).to have_gitlab_http_status(201)
+
+ expect(json_response['lock'].keys).to match_array(%w(id path locked_at owner))
+ end
+ end
+ end
+
+ describe 'Listing File Locks endpoint' do
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" }
+ let(:authorization) { authorize_user(developer) }
+
+ include_examples 'unauthorized request'
+
+ it 'returns the list of locked files' do
+ lock_file('README.md', developer)
+ lock_file('README', developer)
+
+ do_get url, nil, headers
+
+ expect(response).to have_gitlab_http_status(200)
+
+ expect(json_response['locks'].size).to eq(2)
+ expect(json_response['locks'].first.keys).to match_array(%w(id path locked_at owner))
+ end
+ end
+
+ describe 'List File Locks for verification endpoint' do
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/verify" }
+ let(:authorization) { authorize_user(developer) }
+
+ include_examples 'unauthorized request'
+
+ it 'returns the list of locked files grouped by owner' do
+ lock_file('README.md', master)
+ lock_file('README', developer)
+
+ post_lfs_json url, nil, headers
+
+ expect(response).to have_gitlab_http_status(200)
+
+ expect(json_response['ours'].size).to eq(1)
+ expect(json_response['ours'].first['path']).to eq('README')
+ expect(json_response['theirs'].size).to eq(1)
+ expect(json_response['theirs'].first['path']).to eq('README.md')
+ end
+ end
+
+ describe 'Delete File Lock endpoint' do
+ let!(:lock) { lock_file('README.md', developer) }
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/#{lock[:id]}/unlock" }
+ let(:authorization) { authorize_user(developer) }
+
+ include_examples 'unauthorized request'
+
+ context 'with an existent lock' do
+ it 'deletes the lock' do
+ post_lfs_json url, nil, headers
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns the deleted lock' do
+ post_lfs_json url, nil, headers
+
+ expect(json_response['lock'].keys).to match_array(%w(id path locked_at owner))
+ end
+ end
+ end
+
+ def lock_file(path, author)
+ result = Lfs::LockFileService.new(project, author, { path: path }).execute
+
+ result[:lock]
+ end
+
+ def authorize_user(user)
+ ActionController::HttpAuthentication::Basic.encode_credentials(user.username, user.password)
+ end
+
+ def post_lfs_json(url, body = nil, headers = nil)
+ post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE))
+ end
+
+ def do_get(url, params = nil, headers = nil)
+ get(url, (params || {}), (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE))
+ end
+
+ def json_response
+ @json_response ||= JSON.parse(response.body)
+ end
+end
diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb
index 1a5ad9b04e4..6bed8e812c0 100644
--- a/spec/requests/openid_connect_spec.rb
+++ b/spec/requests/openid_connect_spec.rb
@@ -65,13 +65,23 @@ describe 'OpenID Connect requests' do
)
end
- let(:public_email) { build :email, email: 'public@example.com' }
- let(:private_email) { build :email, email: 'private@example.com' }
+ let!(:public_email) { build :email, email: 'public@example.com' }
+ let!(:private_email) { build :email, email: 'private@example.com' }
- it 'includes all user information' do
+ let!(:group1) { create :group }
+ let!(:group2) { create :group }
+ let!(:group3) { create :group, parent: group2 }
+ let!(:group4) { create :group, parent: group3 }
+
+ before do
+ group1.add_user(user, GroupMember::OWNER)
+ group3.add_user(user, Gitlab::Access::DEVELOPER)
+ end
+
+ it 'includes all user information and group memberships' do
request_user_info
- expect(json_response).to eq({
+ expect(json_response).to match(a_hash_including({
'sub' => hashed_subject,
'name' => 'Alice',
'nickname' => 'alice',
@@ -79,8 +89,13 @@ describe 'OpenID Connect requests' do
'email_verified' => true,
'website' => 'https://example.com',
'profile' => 'http://localhost/alice',
- 'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png"
- })
+ 'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png",
+ 'groups' => anything
+ }))
+
+ expected_groups = [group1.full_path, group3.full_path]
+ expected_groups << group4.full_path if Group.supports_nested_groups?
+ expect(json_response['groups']).to match_array(expected_groups)
end
end
diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb
index 286d8a884a4..bcc3e3a2678 100644
--- a/spec/requests/projects/cycle_analytics_events_spec.rb
+++ b/spec/requests/projects/cycle_analytics_events_spec.rb
@@ -7,7 +7,7 @@ describe 'cycle analytics events' do
describe 'GET /:namespace/:project/cycle_analytics/events/issues' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
3.times do |count|
Timecop.freeze(Time.now + count.days) do
@@ -15,7 +15,7 @@ describe 'cycle analytics events' do
end
end
- deploy_master
+ deploy_master(user, project)
login_as(user)
end
@@ -23,7 +23,7 @@ describe 'cycle analytics events' do
it 'lists the issue events' do
get project_cycle_analytics_issue_path(project, format: :json)
- first_issue_iid = project.issues.sort(:created_desc).pluck(:iid).first.to_s
+ first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['iid']).to eq(first_issue_iid)
@@ -32,7 +32,7 @@ describe 'cycle analytics events' do
it 'lists the plan events' do
get project_cycle_analytics_plan_path(project, format: :json)
- first_mr_short_sha = project.merge_requests.sort(:created_asc).first.commits.first.short_id
+ first_mr_short_sha = project.merge_requests.sort_by_attribute(:created_asc).first.commits.first.short_id
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['short_sha']).to eq(first_mr_short_sha)
@@ -43,7 +43,7 @@ describe 'cycle analytics events' do
expect(json_response['events']).not_to be_empty
- first_mr_iid = project.merge_requests.sort(:created_desc).pluck(:iid).first.to_s
+ first_mr_iid = project.merge_requests.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events'].first['iid']).to eq(first_mr_iid)
end
@@ -58,7 +58,7 @@ describe 'cycle analytics events' do
it 'lists the review events' do
get project_cycle_analytics_review_path(project, format: :json)
- first_mr_iid = project.merge_requests.sort(:created_desc).pluck(:iid).first.to_s
+ first_mr_iid = project.merge_requests.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['iid']).to eq(first_mr_iid)
@@ -74,7 +74,7 @@ describe 'cycle analytics events' do
it 'lists the production events' do
get project_cycle_analytics_production_path(project, format: :json)
- first_issue_iid = project.issues.sort(:created_desc).pluck(:iid).first.to_s
+ first_issue_iid = project.issues.sort_by_attribute(:created_desc).pluck(:iid).first.to_s
expect(json_response['events']).not_to be_empty
expect(json_response['events'].first['iid']).to eq(first_issue_iid)
@@ -119,7 +119,7 @@ describe 'cycle analytics events' do
def create_cycle
milestone = create(:milestone, project: project)
issue.update(milestone: milestone)
- mr = create_merge_request_closing_issue(issue, commit_message: "References #{issue.to_reference}")
+ mr = create_merge_request_closing_issue(user, project, 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, head_pipeline_of: mr)
pipeline.run
@@ -127,7 +127,7 @@ describe 'cycle analytics events' do
create(:ci_build, pipeline: pipeline, status: :success, author: user)
create(:ci_build, pipeline: pipeline, status: :success, author: user)
- merge_merge_requests_closing_issue(issue)
+ merge_merge_requests_closing_issue(user, project, issue)
ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash)
end
diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb
index 0fec14d0cce..b18e922b063 100644
--- a/spec/requests/rack_attack_global_spec.rb
+++ b/spec/requests/rack_attack_global_spec.rb
@@ -22,6 +22,7 @@ describe 'Rack Attack global throttles' do
let(:url_that_does_not_require_authentication) { '/users/sign_in' }
let(:url_that_requires_authentication) { '/dashboard/snippets' }
+ let(:url_api_internal) { '/api/v4/internal/check' }
let(:api_partial_url) { '/todos' }
around do |example|
@@ -172,6 +173,15 @@ describe 'Rack Attack global throttles' do
get url_that_does_not_require_authentication
expect(response).to have_http_status 200
end
+
+ context 'when the request is to the api internal endpoints' do
+ it 'allows requests over the rate limit' do
+ (1 + requests_per_period).times do
+ get url_api_internal, secret_token: Gitlab::Shell.secret_token
+ expect(response).to have_http_status 200
+ end
+ end
+ end
end
context 'when the throttle is disabled' do
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index fb1281a6b42..e1b4e618092 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -164,20 +164,36 @@ describe 'project routing' do
# archive_project_repository GET /:project_id/repository/archive(.:format) projects/repositories#archive
# 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/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/master/archive.zip')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'zip', ref: 'master')
+ expect(get('/gitlab/gitlabhq/-/archive/master/archive.zip')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'zip', id: 'master/archive')
end
it 'to #archive format:tar.bz2' do
- 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')
+ expect(get('/gitlab/gitlabhq/-/archive/master/archive.tar.bz2')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.bz2', id: 'master/archive')
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')
+ expect(get('/gitlab/gitlabhq/-/archive/improve/awesome/gitlabhq-improve-awesome.tar.gz')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.gz', id: 'improve/awesome/gitlabhq-improve-awesome')
+ end
+
+ it 'to #archive_alternative' do
+ expect(get('/gitlab/gitlabhq/repository/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', append_sha: true)
+ end
+
+ it 'to #archive_deprecated' do
+ expect(get('/gitlab/gitlabhq/repository/master/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', append_sha: true)
+ end
+
+ it 'to #archive_deprecated format:zip' do
+ expect(get('/gitlab/gitlabhq/repository/master/archive.zip')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'zip', id: 'master', append_sha: true)
+ end
+
+ it 'to #archive_deprecated format:tar.bz2' do
+ expect(get('/gitlab/gitlabhq/repository/master/archive.tar.bz2')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.bz2', id: 'master', append_sha: true)
+ end
+
+ it 'to #archive_deprecated with "/" in route' do
+ expect(get('/gitlab/gitlabhq/repository/improve/awesome/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'improve/awesome', append_sha: true)
end
end
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index 91aefa84d0e..9345671a1a7 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -9,7 +9,7 @@ require 'spec_helper'
# user_calendar_activities GET /u/:username/calendar_activities(.:format)
describe UsersController, "routing" do
it "to #show" do
- allow_any_instance_of(UserUrlConstrainer).to receive(:matches?).and_return(true)
+ allow_any_instance_of(::Constraints::UserUrlConstrainer).to receive(:matches?).and_return(true)
expect(get("/User")).to route_to('users#show', username: 'User')
end
@@ -37,6 +37,22 @@ describe UsersController, "routing" do
it "to #calendar_activities" do
expect(get("/users/User/calendar_activities")).to route_to('users#calendar_activities', username: 'User')
end
+
+ describe 'redirect alias routes' do
+ include RSpec::Rails::RequestExampleGroup
+
+ it '/u/user1 redirects to /user1' do
+ expect(get("/u/user1")).to redirect_to('/user1')
+ end
+
+ it '/u/user1/groups redirects to /user1/groups' do
+ expect(get("/u/user1/groups")).to redirect_to('/users/user1/groups')
+ end
+
+ it '/u/user1/projects redirects to /user1/projects' do
+ expect(get("/u/user1/projects")).to redirect_to('/users/user1/projects')
+ end
+ end
end
# search GET /search(.:format) search#show
@@ -194,7 +210,7 @@ describe Profiles::KeysController, "routing" do
# get all the ssh-keys of a user
it "to #get_keys" do
- allow_any_instance_of(UserUrlConstrainer).to receive(:matches?).and_return(true)
+ allow_any_instance_of(::Constraints::UserUrlConstrainer).to receive(:matches?).and_return(true)
expect(get("/foo.keys")).to route_to('profiles/keys#get_keys', username: 'foo')
end
diff --git a/spec/rubocop/cop/active_record_dependent_spec.rb b/spec/rubocop/cop/active_record_dependent_spec.rb
deleted file mode 100644
index 599a032bfc5..00000000000
--- a/spec/rubocop/cop/active_record_dependent_spec.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index b94b25cecd0..00000000000
--- a/spec/rubocop/cop/active_record_serialize_spec.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-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/custom_error_class_spec.rb b/spec/rubocop/cop/custom_error_class_spec.rb
deleted file mode 100644
index 381d7871a40..00000000000
--- a/spec/rubocop/cop/custom_error_class_spec.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-require 'spec_helper'
-
-require 'rubocop'
-require 'rubocop/rspec/support'
-
-require_relative '../../../rubocop/cop/custom_error_class'
-
-describe RuboCop::Cop::CustomErrorClass do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- context 'when a class has a body' do
- it 'does nothing' do
- inspect_source(cop, 'class CustomError < StandardError; def foo; end; end')
-
- expect(cop.offenses).to be_empty
- end
- end
-
- context 'when a class has no explicit superclass' do
- it 'does nothing' do
- inspect_source(cop, 'class CustomError; end')
-
- expect(cop.offenses).to be_empty
- end
- end
-
- context 'when a class has a superclass that does not end in Error' do
- it 'does nothing' do
- inspect_source(cop, 'class CustomError < BasicObject; end')
-
- expect(cop.offenses).to be_empty
- end
- end
-
- context 'when a class is empty and inherits from a class ending in Error' do
- context 'when the class is on a single line' do
- let(:source) do
- <<-SOURCE
- module Foo
- class CustomError < Bar::Baz::BaseError; end
- end
- SOURCE
- end
-
- let(:expected) do
- <<-EXPECTED
- module Foo
- CustomError = Class.new(Bar::Baz::BaseError)
- end
- EXPECTED
- end
-
- it 'registers an offense' do
- expected_highlights = source.split("\n")[1].strip
-
- inspect_source(cop, source)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([2])
- expect(cop.highlights).to contain_exactly(expected_highlights)
- end
- end
-
- it 'autocorrects to the right version' do
- autocorrected = autocorrect_source(cop, source, 'foo/custom_error.rb')
-
- expect(autocorrected).to eq(expected)
- end
- end
-
- context 'when the class is on multiple lines' do
- let(:source) do
- <<-SOURCE
- module Foo
- class CustomError < Bar::Baz::BaseError
- end
- end
- SOURCE
- end
-
- let(:expected) do
- <<-EXPECTED
- module Foo
- CustomError = Class.new(Bar::Baz::BaseError)
- end
- EXPECTED
- end
-
- it 'registers an offense' do
- expected_highlights = source.split("\n")[1..2].join("\n").strip
-
- inspect_source(cop, source)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([2])
- expect(cop.highlights).to contain_exactly(expected_highlights)
- end
- end
-
- it 'autocorrects to the right version' do
- autocorrected = autocorrect_source(cop, source, 'foo/custom_error.rb')
-
- expect(autocorrected).to eq(expected)
- end
- end
- end
-end
diff --git a/spec/rubocop/cop/gem_fetcher_spec.rb b/spec/rubocop/cop/gem_fetcher_spec.rb
deleted file mode 100644
index c07f6a831dc..00000000000
--- a/spec/rubocop/cop/gem_fetcher_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-require 'spec_helper'
-
-require 'rubocop'
-require 'rubocop/rspec/support'
-
-require_relative '../../../rubocop/cop/gem_fetcher'
-
-describe RuboCop::Cop::GemFetcher do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- context 'in Gemfile' do
- before do
- allow(cop).to receive(:gemfile?).and_return(true)
- end
-
- it 'registers an offense when a gem uses `git`' do
- inspect_source(cop, 'gem "foo", git: "https://gitlab.com/foo/bar.git"')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- expect(cop.highlights).to eq(['git: "https://gitlab.com/foo/bar.git"'])
- end
- end
-
- it 'registers an offense when a gem uses `github`' do
- inspect_source(cop, 'gem "foo", github: "foo/bar.git"')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- expect(cop.highlights).to eq(['github: "foo/bar.git"'])
- end
- end
- end
-
- context 'outside of Gemfile' do
- it 'registers no offense' do
- inspect_source(cop, 'gem "foo", git: "https://gitlab.com/foo/bar.git"')
-
- expect(cop.offenses.size).to eq(0)
- end
- end
-end
diff --git a/spec/rubocop/cop/gitlab/has_many_through_scope_spec.rb b/spec/rubocop/cop/gitlab/has_many_through_scope_spec.rb
new file mode 100644
index 00000000000..6d769c8e6fd
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/has_many_through_scope_spec.rb
@@ -0,0 +1,74 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/gitlab/has_many_through_scope'
+
+describe RuboCop::Cop::Gitlab::HasManyThroughScope do # rubocop:disable RSpec/FilePath
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ context 'in a model file' do
+ before do
+ allow(cop).to receive(:in_model?).and_return(true)
+ end
+
+ context 'when the model does not use has_many :through' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<-RUBY)
+ class User < ActiveRecord::Base
+ has_many :tags, source: 'UserTag'
+ end
+ RUBY
+ end
+ end
+
+ context 'when the model uses has_many :through' do
+ context 'when the association has no scope defined' do
+ it 'registers an offense on the association' do
+ expect_offense(<<-RUBY)
+ class User < ActiveRecord::Base
+ has_many :tags, through: :user_tags
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ RUBY
+ end
+ end
+
+ context 'when the association has a scope defined' do
+ context 'when the scope does not disable auto-loading' do
+ it 'registers an offense on the scope' do
+ expect_offense(<<-RUBY)
+ class User < ActiveRecord::Base
+ has_many :tags, -> { where(active: true) }, through: :user_tags
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ RUBY
+ end
+ end
+
+ context 'when the scope has auto_include(false)' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<-RUBY)
+ class User < ActiveRecord::Base
+ has_many :tags, -> { where(active: true).auto_include(false).reorder(nil) }, through: :user_tags
+ end
+ RUBY
+ end
+ end
+ end
+ end
+ end
+
+ context 'outside of a migration spec file' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<-RUBY)
+ class User < ActiveRecord::Base
+ has_many :tags, through: :user_tags
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/httparty_spec.rb b/spec/rubocop/cop/gitlab/httparty_spec.rb
new file mode 100644
index 00000000000..510839a21d7
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/httparty_spec.rb
@@ -0,0 +1,74 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/gitlab/httparty'
+
+describe RuboCop::Cop::Gitlab::HTTParty do # rubocop:disable RSpec/FilePath
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ shared_examples('registering include offense') do |options|
+ let(:offending_lines) { options[:offending_lines] }
+
+ it 'registers an offense when the class includes HTTParty' do
+ inspect_source(source)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(offending_lines.size)
+ expect(cop.offenses.map(&:line)).to eq(offending_lines)
+ end
+ end
+ end
+
+ shared_examples('registering call offense') do |options|
+ let(:offending_lines) { options[:offending_lines] }
+
+ it 'registers an offense when the class calls HTTParty' do
+ inspect_source(source)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(offending_lines.size)
+ expect(cop.offenses.map(&:line)).to eq(offending_lines)
+ end
+ end
+ end
+
+ context 'when source is a regular module' do
+ it_behaves_like 'registering include offense', offending_lines: [2] do
+ let(:source) do
+ <<~RUBY
+ module M
+ include HTTParty
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when source is a regular class' do
+ it_behaves_like 'registering include offense', offending_lines: [2] do
+ let(:source) do
+ <<~RUBY
+ class Foo
+ include HTTParty
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when HTTParty is called' do
+ it_behaves_like 'registering call offense', offending_lines: [3] do
+ let(:source) do
+ <<~RUBY
+ class Foo
+ def bar
+ HTTParty.get('http://example.com')
+ end
+ end
+ RUBY
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb
index 1fd40653f79..8e2d5f70353 100644
--- a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb
+++ b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb
@@ -12,7 +12,7 @@ describe RuboCop::Cop::Gitlab::ModuleWithInstanceVariables do
let(:offending_lines) { options[:offending_lines] }
it 'registers an offense when instance variable is used in a module' do
- inspect_source(cop, source)
+ inspect_source(source)
aggregate_failures do
expect(cop.offenses.size).to eq(offending_lines.size)
@@ -23,7 +23,7 @@ describe RuboCop::Cop::Gitlab::ModuleWithInstanceVariables do
shared_examples('not registering offense') do
it 'does not register offenses' do
- inspect_source(cop, source)
+ inspect_source(source)
expect(cop.offenses).to be_empty
end
diff --git a/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb
new file mode 100644
index 00000000000..21fc4584654
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb
@@ -0,0 +1,100 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../../rubocop/cop/gitlab/predicate_memoization'
+
+describe RuboCop::Cop::Gitlab::PredicateMemoization do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ shared_examples('registering offense') do |options|
+ let(:offending_lines) { options[:offending_lines] }
+
+ it 'registers an offense when a predicate method is memoizing via ivar' do
+ inspect_source(source)
+
+ aggregate_failures do
+ expect(cop.offenses.size).to eq(offending_lines.size)
+ expect(cop.offenses.map(&:line)).to eq(offending_lines)
+ end
+ end
+ end
+
+ shared_examples('not registering offense') do
+ it 'does not register offenses' do
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+ end
+
+ context 'when source is a predicate method memoizing via ivar' do
+ it_behaves_like 'registering offense', offending_lines: [3] do
+ let(:source) do
+ <<~RUBY
+ class C
+ def really?
+ @really ||= true
+ end
+ end
+ RUBY
+ end
+ end
+
+ it_behaves_like 'registering offense', offending_lines: [4] do
+ let(:source) do
+ <<~RUBY
+ class C
+ def really?
+ value = true
+ @really ||= value
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when source is a predicate method using ivar with assignment' do
+ it_behaves_like 'not registering offense' do
+ let(:source) do
+ <<~RUBY
+ class C
+ def really?
+ @really = true
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when source is a predicate method using local with ||=' do
+ it_behaves_like 'not registering offense' do
+ let(:source) do
+ <<~RUBY
+ class C
+ def really?
+ really ||= true
+ end
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when source is a regular method memoizing via ivar' do
+ it_behaves_like 'not registering offense' do
+ let(:source) do
+ <<~RUBY
+ class C
+ def really
+ @really ||= true
+ end
+ end
+ RUBY
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/in_batches_spec.rb b/spec/rubocop/cop/in_batches_spec.rb
deleted file mode 100644
index 072481984c6..00000000000
--- a/spec/rubocop/cop/in_batches_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-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/include_sidekiq_worker_spec.rb b/spec/rubocop/cop/include_sidekiq_worker_spec.rb
index 7f406535dda..f5109287876 100644
--- a/spec/rubocop/cop/include_sidekiq_worker_spec.rb
+++ b/spec/rubocop/cop/include_sidekiq_worker_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
+
require 'rubocop'
require 'rubocop/rspec/support'
+
require_relative '../../../rubocop/cop/include_sidekiq_worker'
describe RuboCop::Cop::IncludeSidekiqWorker do
@@ -13,7 +15,7 @@ describe RuboCop::Cop::IncludeSidekiqWorker do
let(:correct_source) { 'include ApplicationWorker' }
it 'registers an offense ' do
- inspect_source(cop, source)
+ inspect_source(source)
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -23,7 +25,7 @@ describe RuboCop::Cop::IncludeSidekiqWorker do
end
it 'autocorrects to the right version' do
- autocorrected = autocorrect_source(cop, source)
+ autocorrected = autocorrect_source(source)
expect(autocorrected).to eq(correct_source)
end
diff --git a/spec/rubocop/cop/line_break_after_guard_clauses_spec.rb b/spec/rubocop/cop/line_break_after_guard_clauses_spec.rb
deleted file mode 100644
index 8899dc85384..00000000000
--- a/spec/rubocop/cop/line_break_after_guard_clauses_spec.rb
+++ /dev/null
@@ -1,160 +0,0 @@
-require 'spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
-require_relative '../../../rubocop/cop/line_break_after_guard_clauses'
-
-describe RuboCop::Cop::LineBreakAfterGuardClauses do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- shared_examples 'examples with guard clause' do |title|
- %w[if unless].each do |conditional|
- it "flags violation for #{title} #{conditional} without line breaks" do
- source = <<~RUBY
- #{title} #{conditional} condition
- do_stuff
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses.size).to eq(1)
- offense = cop.offenses.first
-
- expect(offense.line).to eq(1)
- expect(cop.highlights).to eq(["#{title} #{conditional} condition"])
- expect(offense.message).to eq('Add a line break after guard clauses')
- end
-
- it "doesn't flag violation for #{title} #{conditional} with line break" do
- source = <<~RUBY
- #{title} #{conditional} condition
-
- do_stuff
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} on multiple lines without line break" do
- source = <<~RUBY
- #{conditional} condition
- #{title}
- end
- do_stuff
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} without line breaks when followed by end keyword" do
- source = <<~RUBY
- def test
- #{title} #{conditional} condition
- end
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} without line breaks when followed by elsif keyword" do
- source = <<~RUBY
- if model
- #{title} #{conditional} condition
- elsif
- do_something
- end
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} without line breaks when followed by else keyword" do
- source = <<~RUBY
- if model
- #{title} #{conditional} condition
- else
- do_something
- end
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} without line breaks when followed by when keyword" do
- source = <<~RUBY
- case model
- when condition_a
- #{title} #{conditional} condition
- when condition_b
- do_something
- end
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} without line breaks when followed by rescue keyword" do
- source = <<~RUBY
- begin
- #{title} #{conditional} condition
- rescue StandardError
- do_something
- end
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} without line breaks when followed by ensure keyword" do
- source = <<~RUBY
- def foo
- #{title} #{conditional} condition
- ensure
- do_something
- end
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} #{conditional} without line breaks when followed by another guard clause" do
- source = <<~RUBY
- #{title} #{conditional} condition
- #{title} #{conditional} condition
-
- do_stuff
- RUBY
- inspect_source(cop, source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "autocorrects #{title} #{conditional} guard clauses without line break" do
- source = <<~RUBY
- #{title} #{conditional} condition
- do_stuff
- RUBY
- autocorrected = autocorrect_source(cop, source)
-
- expected_source = <<~RUBY
- #{title} #{conditional} condition
-
- do_stuff
- RUBY
- expect(autocorrected).to eql(expected_source)
- end
- end
- end
-
- %w[return fail raise next break throw].each do |example|
- it_behaves_like 'examples with guard clause', example
- end
-end
diff --git a/spec/rubocop/cop/line_break_around_conditional_block_spec.rb b/spec/rubocop/cop/line_break_around_conditional_block_spec.rb
new file mode 100644
index 00000000000..7ddf9141fcd
--- /dev/null
+++ b/spec/rubocop/cop/line_break_around_conditional_block_spec.rb
@@ -0,0 +1,411 @@
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/line_break_around_conditional_block'
+
+describe RuboCop::Cop::LineBreakAroundConditionalBlock do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ shared_examples 'examples with conditional' do |conditional|
+ it "flags violation for #{conditional} without line break before" do
+ source = <<~RUBY
+ do_something
+ #{conditional} condition
+ do_something_more
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses.size).to eq(1)
+ offense = cop.offenses.first
+
+ expect(offense.line).to eq(2)
+ expect(cop.highlights).to eq(["#{conditional} condition\n do_something_more\nend"])
+ expect(offense.message).to eq('Add a line break around conditional blocks')
+ end
+
+ it "flags violation for #{conditional} without line break after" do
+ source = <<~RUBY
+ #{conditional} condition
+ do_something
+ end
+ do_something_more
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses.size).to eq(1)
+ offense = cop.offenses.first
+
+ expect(offense.line).to eq(1)
+ expect(cop.highlights).to eq(["#{conditional} condition\n do_something\nend"])
+ expect(offense.message).to eq('Add a line break around conditional blocks')
+ end
+
+ it "doesn't flag violation for #{conditional} with line break before and after" do
+ source = <<~RUBY
+ #{conditional} condition
+ do_something
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a method definition" do
+ source = <<~RUBY
+ def a_method
+ #{conditional} condition
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a class definition" do
+ source = <<~RUBY
+ class Foo
+ #{conditional} condition
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a module definition" do
+ source = <<~RUBY
+ module Foo
+ #{conditional} condition
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a begin definition" do
+ source = <<~RUBY
+ begin
+ #{conditional} condition
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by an assign/begin definition" do
+ source = <<~RUBY
+ @project ||= begin
+ #{conditional} condition
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a block definition" do
+ source = <<~RUBY
+ on_block(param_a) do |item|
+ #{conditional} condition
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a block definition using brackets" do
+ source = <<~RUBY
+ on_block(param_a) { |item|
+ #{conditional} condition
+ do_something
+ end
+ }
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a comment" do
+ source = <<~RUBY
+ # a short comment
+ #{conditional} condition
+ do_something
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by an assignment" do
+ source = <<~RUBY
+ foo =
+ #{conditional} condition
+ do_something
+ else
+ do_something_more
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a multiline comment" do
+ source = <<~RUBY
+ =begin
+ a multiline comment
+ =end
+ #{conditional} condition
+ do_something
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by another conditional" do
+ source = <<~RUBY
+ #{conditional} condition_a
+ #{conditional} condition_b
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by an else" do
+ source = <<~RUBY
+ if condition_a
+ do_something
+ else
+ #{conditional} condition_b
+ do_something_extra
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by an elsif" do
+ source = <<~RUBY
+ if condition_a
+ do_something
+ elsif condition_b
+ #{conditional} condition_c
+ do_something_extra
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by an ensure" do
+ source = <<~RUBY
+ def a_method
+ ensure
+ #{conditional} condition_c
+ do_something_extra
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} preceded by a when" do
+ source = <<~RUBY
+ case field
+ when value
+ #{conditional} condition_c
+ do_something_extra
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} followed by an end" do
+ source = <<~RUBY
+ class Foo
+
+ #{conditional} condition
+ do_something
+ end
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} followed by an else" do
+ source = <<~RUBY
+ #{conditional} condition_a
+ #{conditional} condition_b
+ do_something
+ end
+ else
+ do_something_extra
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} followed by a when" do
+ source = <<~RUBY
+ case
+ when condition_a
+ #{conditional} condition_b
+ do_something
+ end
+ when condition_c
+ do_something_extra
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} followed by an elsif" do
+ source = <<~RUBY
+ if condition_a
+ #{conditional} condition_b
+ do_something
+ end
+ elsif condition_c
+ do_something_extra
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "doesn't flag violation for #{conditional} followed by a rescue" do
+ source = <<~RUBY
+ def a_method
+ #{conditional} condition
+ do_something
+ end
+ rescue
+ do_something_extra
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+
+ it "autocorrects #{conditional} without line break before" do
+ source = <<~RUBY
+ do_something
+ #{conditional} condition
+ do_something_more
+ end
+ RUBY
+ autocorrected = autocorrect_source(source)
+
+ expected_source = <<~RUBY
+ do_something
+
+ #{conditional} condition
+ do_something_more
+ end
+ RUBY
+ expect(autocorrected).to eql(expected_source)
+ end
+
+ it "autocorrects #{conditional} without line break after" do
+ source = <<~RUBY
+ #{conditional} condition
+ do_something
+ end
+ do_something_more
+ RUBY
+ autocorrected = autocorrect_source(source)
+
+ expected_source = <<~RUBY
+ #{conditional} condition
+ do_something
+ end
+
+ do_something_more
+ RUBY
+ expect(autocorrected).to eql(expected_source)
+ end
+
+ it "autocorrects #{conditional} without line break before and after" do
+ source = <<~RUBY
+ do_something
+ #{conditional} condition
+ do_something_more
+ end
+ do_something_extra
+ RUBY
+ autocorrected = autocorrect_source(source)
+
+ expected_source = <<~RUBY
+ do_something
+
+ #{conditional} condition
+ do_something_more
+ end
+
+ do_something_extra
+ RUBY
+ expect(autocorrected).to eql(expected_source)
+ end
+ end
+
+ %w[if unless].each do |example|
+ it_behaves_like 'examples with conditional', example
+ end
+
+ it "doesn't flag violation for if with elsif" do
+ source = <<~RUBY
+ if condition
+ do_something
+ elsif another_condition
+ do_something_more
+ end
+ RUBY
+ inspect_source(source)
+
+ expect(cop.offenses).to be_empty
+ end
+end
diff --git a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
index 7cb24dc5646..1df1fffb94e 100644
--- a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
+++ b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
+
require 'rubocop'
require 'rubocop/rspec/support'
+
require_relative '../../../../rubocop/cop/migration/add_concurrent_foreign_key'
describe RuboCop::Cop::Migration::AddConcurrentForeignKey do
@@ -10,7 +12,7 @@ describe RuboCop::Cop::Migration::AddConcurrentForeignKey do
context 'outside of a migration' do
it 'does not register any offenses' do
- inspect_source(cop, 'def up; add_foreign_key(:projects, :users, column: :user_id); end')
+ inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id); end')
expect(cop.offenses).to be_empty
end
@@ -22,7 +24,7 @@ describe RuboCop::Cop::Migration::AddConcurrentForeignKey do
end
it 'registers an offense when using add_foreign_key' do
- inspect_source(cop, 'def up; add_foreign_key(:projects, :users, column: :user_id); end')
+ inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id); end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
diff --git a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb
index 19a5718b0b1..9c1ebcc0ced 100644
--- a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb
+++ b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb
@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::AddConcurrentIndex do
end
it 'registers an offense when add_concurrent_index is used inside a change method' do
- inspect_source(cop, 'def change; add_concurrent_index :table, :column; end')
+ inspect_source('def change; add_concurrent_index :table, :column; end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -25,7 +25,7 @@ describe RuboCop::Cop::Migration::AddConcurrentIndex do
end
it 'registers no offense when add_concurrent_index is used inside an up method' do
- inspect_source(cop, 'def up; add_concurrent_index :table, :column; end')
+ inspect_source('def up; add_concurrent_index :table, :column; end')
expect(cop.offenses.size).to eq(0)
end
@@ -33,7 +33,7 @@ describe RuboCop::Cop::Migration::AddConcurrentIndex do
context 'outside of migration' do
it 'registers no offense' do
- inspect_source(cop, 'def change; add_concurrent_index :table, :column; end')
+ inspect_source('def change; add_concurrent_index :table, :column; end')
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/migration/add_timestamps_spec.rb b/spec/rubocop/cop/migration/add_timestamps_spec.rb
index 18df62dec3e..3a41c91add2 100644
--- a/spec/rubocop/cop/migration/add_timestamps_spec.rb
+++ b/spec/rubocop/cop/migration/add_timestamps_spec.rb
@@ -53,7 +53,7 @@ describe RuboCop::Cop::Migration::AddTimestamps do
end
it 'registers an offense when the "add_timestamps" method is used' do
- inspect_source(cop, migration_with_add_timestamps)
+ inspect_source(migration_with_add_timestamps)
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -62,7 +62,7 @@ describe RuboCop::Cop::Migration::AddTimestamps do
end
it 'does not register an offense when the "add_timestamps" method is not used' do
- inspect_source(cop, migration_without_add_timestamps)
+ inspect_source(migration_without_add_timestamps)
aggregate_failures do
expect(cop.offenses.size).to eq(0)
@@ -70,7 +70,7 @@ describe RuboCop::Cop::Migration::AddTimestamps do
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)
+ inspect_source(migration_with_add_timestamps_with_timezone)
aggregate_failures do
expect(cop.offenses.size).to eq(0)
@@ -80,9 +80,9 @@ describe RuboCop::Cop::Migration::AddTimestamps do
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)
+ inspect_source(migration_with_add_timestamps)
+ inspect_source(migration_without_add_timestamps)
+ inspect_source(migration_with_add_timestamps_with_timezone)
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/migration/datetime_spec.rb b/spec/rubocop/cop/migration/datetime_spec.rb
index b1dfcf1b048..9e844325371 100644
--- a/spec/rubocop/cop/migration/datetime_spec.rb
+++ b/spec/rubocop/cop/migration/datetime_spec.rb
@@ -67,7 +67,7 @@ describe RuboCop::Cop::Migration::Datetime do
end
it 'registers an offense when the ":datetime" data type is used' do
- inspect_source(cop, migration_with_datetime)
+ inspect_source(migration_with_datetime)
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -77,7 +77,7 @@ describe RuboCop::Cop::Migration::Datetime do
end
it 'registers an offense when the ":timestamp" data type is used' do
- inspect_source(cop, migration_with_timestamp)
+ inspect_source(migration_with_timestamp)
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -87,7 +87,7 @@ describe RuboCop::Cop::Migration::Datetime do
end
it 'does not register an offense when the ":datetime" data type is not used' do
- inspect_source(cop, migration_without_datetime)
+ inspect_source(migration_without_datetime)
aggregate_failures do
expect(cop.offenses.size).to eq(0)
@@ -95,7 +95,7 @@ describe RuboCop::Cop::Migration::Datetime do
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)
+ inspect_source(migration_with_datetime_with_timezone)
aggregate_failures do
expect(cop.offenses.size).to eq(0)
@@ -105,10 +105,10 @@ describe RuboCop::Cop::Migration::Datetime do
context 'outside of migration' do
it 'registers no offense' do
- inspect_source(cop, migration_with_datetime)
- inspect_source(cop, migration_with_timestamp)
- inspect_source(cop, migration_without_datetime)
- inspect_source(cop, migration_with_datetime_with_timezone)
+ inspect_source(migration_with_datetime)
+ inspect_source(migration_with_timestamp)
+ inspect_source(migration_without_datetime)
+ inspect_source(migration_with_datetime_with_timezone)
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/migration/hash_index_spec.rb b/spec/rubocop/cop/migration/hash_index_spec.rb
index 9a8576a19e5..5d53dde9a79 100644
--- a/spec/rubocop/cop/migration/hash_index_spec.rb
+++ b/spec/rubocop/cop/migration/hash_index_spec.rb
@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::HashIndex do
end
it 'registers an offense when creating a hash index' do
- inspect_source(cop, 'def change; add_index :table, :column, using: :hash; end')
+ inspect_source('def change; add_index :table, :column, using: :hash; end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -25,7 +25,7 @@ describe RuboCop::Cop::Migration::HashIndex do
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')
+ inspect_source('def change; add_concurrent_index :table, :column, using: :hash; end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -34,7 +34,7 @@ describe RuboCop::Cop::Migration::HashIndex do
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')
+ inspect_source('def change; t.index :table, :column, using: :hash; end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -45,7 +45,7 @@ describe RuboCop::Cop::Migration::HashIndex do
context 'outside of migration' do
it 'registers no offense' do
- inspect_source(cop, 'def change; index :table, :column, using: :hash; end')
+ inspect_source('def change; index :table, :column, using: :hash; end')
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/migration/remove_column_spec.rb b/spec/rubocop/cop/migration/remove_column_spec.rb
index 89112f01723..f1a64f431bd 100644
--- a/spec/rubocop/cop/migration/remove_column_spec.rb
+++ b/spec/rubocop/cop/migration/remove_column_spec.rb
@@ -21,7 +21,7 @@ describe RuboCop::Cop::Migration::RemoveColumn do
end
it 'registers an offense when remove_column is used in the change method' do
- inspect_source(cop, source('change'))
+ inspect_source(source('change'))
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -30,7 +30,7 @@ describe RuboCop::Cop::Migration::RemoveColumn do
end
it 'registers an offense when remove_column is used in the up method' do
- inspect_source(cop, source('up'))
+ inspect_source(source('up'))
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -39,7 +39,7 @@ describe RuboCop::Cop::Migration::RemoveColumn do
end
it 'registers no offense when remove_column is used in the down method' do
- inspect_source(cop, source('down'))
+ inspect_source(source('down'))
expect(cop.offenses.size).to eq(0)
end
@@ -52,7 +52,7 @@ describe RuboCop::Cop::Migration::RemoveColumn do
end
it 'registers no offense' do
- inspect_source(cop, source)
+ inspect_source(source)
expect(cop.offenses.size).to eq(0)
end
@@ -60,7 +60,7 @@ describe RuboCop::Cop::Migration::RemoveColumn do
context 'outside of a migration' do
it 'registers no offense' do
- inspect_source(cop, source)
+ inspect_source(source)
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb
index a714bf4e5d5..a23d5d022e3 100644
--- a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb
+++ b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb
@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::RemoveConcurrentIndex do
end
it 'registers an offense when remove_concurrent_index is used inside a change method' do
- inspect_source(cop, 'def change; remove_concurrent_index :table, :column; end')
+ inspect_source('def change; remove_concurrent_index :table, :column; end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -25,7 +25,7 @@ describe RuboCop::Cop::Migration::RemoveConcurrentIndex do
end
it 'registers no offense when remove_concurrent_index is used inside an up method' do
- inspect_source(cop, 'def up; remove_concurrent_index :table, :column; end')
+ inspect_source('def up; remove_concurrent_index :table, :column; end')
expect(cop.offenses.size).to eq(0)
end
@@ -33,7 +33,7 @@ describe RuboCop::Cop::Migration::RemoveConcurrentIndex do
context 'outside of migration' do
it 'registers no offense' do
- inspect_source(cop, 'def change; remove_concurrent_index :table, :column; end')
+ inspect_source('def change; remove_concurrent_index :table, :column; end')
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/migration/remove_index_spec.rb b/spec/rubocop/cop/migration/remove_index_spec.rb
index 31923cb7429..bbf2227e512 100644
--- a/spec/rubocop/cop/migration/remove_index_spec.rb
+++ b/spec/rubocop/cop/migration/remove_index_spec.rb
@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::RemoveIndex do
end
it 'registers an offense when remove_index is used' do
- inspect_source(cop, 'def change; remove_index :table, :column; end')
+ inspect_source('def change; remove_index :table, :column; end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -27,7 +27,7 @@ describe RuboCop::Cop::Migration::RemoveIndex do
context 'outside of migration' do
it 'registers no offense' do
- inspect_source(cop, 'def change; remove_index :table, :column; end')
+ inspect_source('def change; remove_index :table, :column; end')
expect(cop.offenses.size).to eq(0)
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
index 3723d635083..ba8cd2c6c4a 100644
--- a/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb
+++ b/spec/rubocop/cop/migration/reversible_add_column_with_default_spec.rb
@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::ReversibleAddColumnWithDefault do
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')
+ inspect_source('def change; add_column_with_default :table, :column, default: false; end')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -25,7 +25,7 @@ describe RuboCop::Cop::Migration::ReversibleAddColumnWithDefault do
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')
+ inspect_source('def up; add_column_with_default :table, :column, default: false; end')
expect(cop.offenses.size).to eq(0)
end
@@ -33,7 +33,7 @@ describe RuboCop::Cop::Migration::ReversibleAddColumnWithDefault do
context 'outside of migration' do
it 'registers no offense' do
- inspect_source(cop, 'def change; add_column_with_default :table, :column, default: false; end')
+ inspect_source('def change; add_column_with_default :table, :column, default: false; end')
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
index 1006594499a..1c4f18fbcc3 100644
--- a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
+++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
@@ -32,7 +32,7 @@ describe RuboCop::Cop::Migration::SaferBooleanColumn do
sources_and_offense.each do |source, offense|
context "given the source \"#{source}\"" do
it "registers the offense matching \"#{offense}\"" do
- inspect_source(cop, source)
+ inspect_source(source)
aggregate_failures do
expect(cop.offenses.first.message).to match(offense)
@@ -49,7 +49,7 @@ describe RuboCop::Cop::Migration::SaferBooleanColumn do
inoffensive_sources.each do |source|
context "given the source \"#{source}\"" do
it "registers no offense" do
- inspect_source(cop, source)
+ inspect_source(source)
aggregate_failures do
expect(cop.offenses).to be_empty
@@ -61,14 +61,14 @@ describe RuboCop::Cop::Migration::SaferBooleanColumn do
end
it 'registers no offense for tables not listed in SMALL_TABLES' do
- inspect_source(cop, "add_column :large_table, :column, :boolean")
+ inspect_source("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")
+ inspect_source("add_column :#{table}, :column, :string")
expect(cop.offenses).to be_empty
end
@@ -77,7 +77,7 @@ describe RuboCop::Cop::Migration::SaferBooleanColumn do
context 'outside of migration' do
it 'registers no offense' do
table = described_class::SMALL_TABLES.sample
- inspect_source(cop, "add_column :#{table}, :column, :boolean")
+ inspect_source("add_column :#{table}, :column, :boolean")
expect(cop.offenses).to be_empty
end
diff --git a/spec/rubocop/cop/migration/timestamps_spec.rb b/spec/rubocop/cop/migration/timestamps_spec.rb
index cdf1423d0c5..685bdb21803 100644
--- a/spec/rubocop/cop/migration/timestamps_spec.rb
+++ b/spec/rubocop/cop/migration/timestamps_spec.rb
@@ -62,7 +62,7 @@ describe RuboCop::Cop::Migration::Timestamps do
end
it 'registers an offense when the "timestamps" method is used' do
- inspect_source(cop, migration_with_timestamps)
+ inspect_source(migration_with_timestamps)
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -71,7 +71,7 @@ describe RuboCop::Cop::Migration::Timestamps do
end
it 'does not register an offense when the "timestamps" method is not used' do
- inspect_source(cop, migration_without_timestamps)
+ inspect_source(migration_without_timestamps)
aggregate_failures do
expect(cop.offenses.size).to eq(0)
@@ -79,7 +79,7 @@ describe RuboCop::Cop::Migration::Timestamps do
end
it 'does not register an offense when the "timestamps_with_timezone" method is used' do
- inspect_source(cop, migration_with_timestamps_with_timezone)
+ inspect_source(migration_with_timestamps_with_timezone)
aggregate_failures do
expect(cop.offenses.size).to eq(0)
@@ -89,9 +89,9 @@ describe RuboCop::Cop::Migration::Timestamps do
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)
+ inspect_source(migration_with_timestamps)
+ inspect_source(migration_without_timestamps)
+ inspect_source(migration_with_timestamps_with_timezone)
expect(cop.offenses.size).to eq(0)
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
index 38b8f439a55..1c8ab0ad5d2 100644
--- a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
+++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
+
require 'rubocop'
require 'rubocop/rspec/support'
+
require_relative '../../../../rubocop/cop/migration/update_column_in_batches'
describe RuboCop::Cop::Migration::UpdateColumnInBatches do
@@ -25,7 +27,7 @@ describe RuboCop::Cop::Migration::UpdateColumnInBatches do
context 'outside of a migration' do
it 'does not register any offenses' do
- inspect_source(cop, migration_code)
+ inspect_source(migration_code)
expect(cop.offenses).to be_empty
end
@@ -49,7 +51,7 @@ describe RuboCop::Cop::Migration::UpdateColumnInBatches do
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)
+ inspect_source(migration_code, @migration_file)
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -72,7 +74,7 @@ describe RuboCop::Cop::Migration::UpdateColumnInBatches do
end
it 'does not register any offenses' do
- inspect_source(cop, migration_code, @migration_file)
+ inspect_source(migration_code, @migration_file)
expect(cop.offenses).to be_empty
end
diff --git a/spec/rubocop/cop/migration/update_large_table_spec.rb b/spec/rubocop/cop/migration/update_large_table_spec.rb
index 17b19e139e4..ef724fc8bad 100644
--- a/spec/rubocop/cop/migration/update_large_table_spec.rb
+++ b/spec/rubocop/cop/migration/update_large_table_spec.rb
@@ -18,7 +18,7 @@ describe RuboCop::Cop::Migration::UpdateLargeTable do
shared_examples 'large tables' do |update_method|
described_class::LARGE_TABLES.each do |table|
it "registers an offense for the #{table} table" do
- inspect_source(cop, "#{update_method} :#{table}, :column, default: true")
+ inspect_source("#{update_method} :#{table}, :column, default: true")
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -37,7 +37,7 @@ describe RuboCop::Cop::Migration::UpdateLargeTable do
end
it 'registers no offense for non-blacklisted tables' do
- inspect_source(cop, "add_column_with_default :table, :column, default: true")
+ inspect_source("add_column_with_default :table, :column, default: true")
expect(cop.offenses).to be_empty
end
@@ -45,7 +45,7 @@ describe RuboCop::Cop::Migration::UpdateLargeTable do
it 'registers no offense for non-blacklisted methods' do
table = described_class::LARGE_TABLES.sample
- inspect_source(cop, "some_other_method :#{table}, :column, default: true")
+ inspect_source("some_other_method :#{table}, :column, default: true")
expect(cop.offenses).to be_empty
end
@@ -55,13 +55,13 @@ describe RuboCop::Cop::Migration::UpdateLargeTable do
let(:table) { described_class::LARGE_TABLES.sample }
it 'registers no offense for add_column_with_default' do
- inspect_source(cop, "add_column_with_default :#{table}, :column, default: true")
+ inspect_source("add_column_with_default :#{table}, :column, default: true")
expect(cop.offenses).to be_empty
end
it 'registers no offense for update_column_in_batches' do
- inspect_source(cop, "add_column_with_default :#{table}, :column, default: true")
+ inspect_source("add_column_with_default :#{table}, :column, default: true")
expect(cop.offenses).to be_empty
end
diff --git a/spec/rubocop/cop/polymorphic_associations_spec.rb b/spec/rubocop/cop/polymorphic_associations_spec.rb
deleted file mode 100644
index 49959aa6419..00000000000
--- a/spec/rubocop/cop/polymorphic_associations_spec.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-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
index bc47b45cad7..84e6eb7d87f 100644
--- a/spec/rubocop/cop/project_path_helper_spec.rb
+++ b/spec/rubocop/cop/project_path_helper_spec.rb
@@ -15,7 +15,7 @@ describe RuboCop::Cop::ProjectPathHelper do
let(:correct_source) { 'edit_project_issue_path(@issue.project, @issue)' }
it 'registers an offense' do
- inspect_source(cop, source)
+ inspect_source(source)
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -25,7 +25,7 @@ describe RuboCop::Cop::ProjectPathHelper do
end
it 'autocorrects to the right version' do
- autocorrected = autocorrect_source(cop, source)
+ autocorrected = autocorrect_source(source)
expect(autocorrected).to eq(correct_source)
end
@@ -33,7 +33,7 @@ describe RuboCop::Cop::ProjectPathHelper do
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)')
+ inspect_source('edit_namespace_project_issue_path(namespace, project)')
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/redirect_with_status_spec.rb b/spec/rubocop/cop/redirect_with_status_spec.rb
deleted file mode 100644
index 5ad63567f84..00000000000
--- a/spec/rubocop/cop/redirect_with_status_spec.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-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/env_assignment_spec.rb b/spec/rubocop/cop/rspec/env_assignment_spec.rb
index 4e859b6f6fa..659633f6467 100644
--- a/spec/rubocop/cop/rspec/env_assignment_spec.rb
+++ b/spec/rubocop/cop/rspec/env_assignment_spec.rb
@@ -17,7 +17,7 @@ describe RuboCop::Cop::RSpec::EnvAssignment do
shared_examples 'an offensive ENV#[]= call' do |content|
it "registers an offense for `#{content}`" do
- inspect_source(cop, content, source_file)
+ inspect_source(content, source_file)
expect(cop.offenses.size).to eq(1)
expect(cop.offenses.map(&:line)).to eq([1])
@@ -27,7 +27,7 @@ describe RuboCop::Cop::RSpec::EnvAssignment do
shared_examples 'an autocorrected ENV#[]= call' do |content, autocorrected_content|
it "registers an offense for `#{content}` and autocorrects it to `#{autocorrected_content}`" do
- autocorrected = autocorrect_source(cop, content, source_file)
+ autocorrected = autocorrect_source(content, source_file)
expect(autocorrected).to eql(autocorrected_content)
end
@@ -51,7 +51,7 @@ describe RuboCop::Cop::RSpec::EnvAssignment do
context 'outside of a spec file' do
it "does not register an offense for `#{OFFENSE_CALL_SINGLE_QUOTES_KEY}` in a non-spec file" do
- inspect_source(cop, OFFENSE_CALL_SINGLE_QUOTES_KEY)
+ inspect_source(OFFENSE_CALL_SINGLE_QUOTES_KEY)
expect(cop.offenses.size).to eq(0)
end
diff --git a/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb
new file mode 100644
index 00000000000..2763f2bda21
--- /dev/null
+++ b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+require 'rubocop'
+require 'rubocop/rspec/support'
+
+require_relative '../../../../rubocop/cop/rspec/factories_in_migration_specs'
+
+describe RuboCop::Cop::RSpec::FactoriesInMigrationSpecs do
+ include CopHelper
+
+ let(:source_file) { 'spec/migrations/foo_spec.rb' }
+
+ subject(:cop) { described_class.new }
+
+ shared_examples 'an offensive factory call' do |namespace|
+ %i[build build_list create create_list].each do |forbidden_method|
+ namespaced_forbidden_method = "#{namespace}#{forbidden_method}(:user)"
+
+ it "registers an offense for #{namespaced_forbidden_method}" do
+ expect_offense(<<-RUBY)
+ describe 'foo' do
+ let(:user) { #{namespaced_forbidden_method} }
+ #{'^' * namespaced_forbidden_method.size} Don't use FactoryBot.#{forbidden_method} in migration specs, use `table` instead.
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'in a migration spec file' do
+ before do
+ allow(cop).to receive(:in_migration_spec?).and_return(true)
+ end
+
+ it_behaves_like 'an offensive factory call', ''
+ it_behaves_like 'an offensive factory call', 'FactoryBot.'
+ end
+
+ context 'outside of a migration spec file' do
+ it "does not register an offense" do
+ expect_no_offenses(<<-RUBY)
+ describe 'foo' do
+ let(:user) { create(:user) }
+ end
+ RUBY
+ 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
deleted file mode 100644
index 6cf0831d3ad..00000000000
--- a/spec/rubocop/cop/rspec/single_line_hook_spec.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-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/rubocop/cop/rspec/verbose_include_metadata_spec.rb b/spec/rubocop/cop/rspec/verbose_include_metadata_spec.rb
deleted file mode 100644
index 278662d32ea..00000000000
--- a/spec/rubocop/cop/rspec/verbose_include_metadata_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-require 'spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
-require_relative '../../../../rubocop/cop/rspec/verbose_include_metadata'
-
-describe RuboCop::Cop::RSpec::VerboseIncludeMetadata do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- let(:source_file) { 'foo_spec.rb' }
-
- # 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, source_file)
- end
-
- shared_examples 'examples with include syntax' do |title|
- it "flags violation for #{title} examples that uses verbose include syntax" do
- inspect_source(cop, "#{title} 'Test', js: true do; end")
-
- expect(cop.offenses.size).to eq(1)
- offense = cop.offenses.first
-
- expect(offense.line).to eq(1)
- expect(cop.highlights).to eq(["#{title} 'Test', js: true"])
- expect(offense.message).to eq('Use `:js` instead of `js: true`.')
- end
-
- it "doesn't flag violation for #{title} examples that uses compact include syntax", :aggregate_failures do
- inspect_source(cop, "#{title} 'Test', :js do; end")
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{title} examples that uses flag: symbol" do
- inspect_source(cop, "#{title} 'Test', flag: :symbol do; end")
-
- expect(cop.offenses).to be_empty
- end
-
- it "autocorrects #{title} examples that uses verbose syntax into compact syntax" do
- autocorrected = autocorrect_source(cop, "#{title} 'Test', js: true do; end", source_file)
-
- expect(autocorrected).to eql("#{title} 'Test', :js do; end")
- end
- end
-
- %w(describe context feature example_group it specify example scenario its).each do |example|
- it_behaves_like 'examples with include syntax', example
- end
-end
diff --git a/spec/rubocop/cop/sidekiq_options_queue_spec.rb b/spec/rubocop/cop/sidekiq_options_queue_spec.rb
index a31de381631..7f237d5ffbb 100644
--- a/spec/rubocop/cop/sidekiq_options_queue_spec.rb
+++ b/spec/rubocop/cop/sidekiq_options_queue_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
+
require 'rubocop'
require 'rubocop/rspec/support'
+
require_relative '../../../rubocop/cop/sidekiq_options_queue'
describe RuboCop::Cop::SidekiqOptionsQueue do
@@ -9,7 +11,7 @@ describe RuboCop::Cop::SidekiqOptionsQueue do
subject(:cop) { described_class.new }
it 'registers an offense when `sidekiq_options` is used with the `queue` option' do
- inspect_source(cop, 'sidekiq_options queue: "some_queue"')
+ inspect_source('sidekiq_options queue: "some_queue"')
aggregate_failures do
expect(cop.offenses.size).to eq(1)
@@ -19,7 +21,7 @@ describe RuboCop::Cop::SidekiqOptionsQueue do
end
it 'does not register an offense when `sidekiq_options` is used with another option' do
- inspect_source(cop, 'sidekiq_options retry: false')
+ inspect_source('sidekiq_options retry: false')
expect(cop.offenses).to be_empty
end
diff --git a/spec/serializers/build_serializer_spec.rb b/spec/serializers/build_serializer_spec.rb
index 9673b11c2a2..98cd15e248b 100644
--- a/spec/serializers/build_serializer_spec.rb
+++ b/spec/serializers/build_serializer_spec.rb
@@ -28,15 +28,31 @@ describe BuildSerializer do
end
describe '#represent_status' do
- context 'when represents only status' do
- let(:resource) { create(:ci_build) }
+ context 'for a failed build' do
+ let(:resource) { create(:ci_build, :failed) }
+ let(:status) { resource.detailed_status(double('user')) }
+
+ subject { serializer.represent_status(resource) }
+
+ it 'serializes only status' do
+ expect(subject[:text]).to eq(status.text)
+ expect(subject[:label]).to eq('failed')
+ expect(subject[:tooltip]).to eq('failed <br> (unknown failure)')
+ expect(subject[:icon]).to eq(status.icon)
+ expect(subject[:favicon]).to match_asset_path("/assets/ci_favicons/#{status.favicon}.ico")
+ end
+ end
+
+ context 'for any other type of build' do
+ let(:resource) { create(:ci_build, :success) }
let(:status) { resource.detailed_status(double('user')) }
subject { serializer.represent_status(resource) }
it 'serializes only status' do
expect(subject[:text]).to eq(status.text)
- expect(subject[:label]).to eq(status.label)
+ expect(subject[:label]).to eq('passed')
+ expect(subject[:tooltip]).to eq('passed')
expect(subject[:icon]).to eq(status.icon)
expect(subject[:favicon]).to match_asset_path("/assets/ci_favicons/#{status.favicon}.ico")
end
diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb
index 87c7b2ad36e..852b6af9f7f 100644
--- a/spec/serializers/cluster_application_entity_spec.rb
+++ b/spec/serializers/cluster_application_entity_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe ClusterApplicationEntity do
describe '#as_json' do
- let(:application) { build(:cluster_applications_helm) }
+ let(:application) { build(:clusters_applications_helm) }
subject { described_class.new(application).as_json }
it 'has name' do
@@ -18,7 +18,7 @@ describe ClusterApplicationEntity do
end
context 'when application is errored' do
- let(:application) { build(:cluster_applications_helm, :errored) }
+ let(:application) { build(:clusters_applications_helm, :errored) }
it 'has corresponded data' do
expect(subject[:status]).to eq(:errored)
@@ -26,5 +26,19 @@ describe ClusterApplicationEntity do
expect(subject[:status_reason]).to eq(application.status_reason)
end
end
+
+ context 'for ingress application' do
+ let(:application) do
+ build(
+ :clusters_applications_ingress,
+ :installed,
+ external_ip: '111.222.111.222'
+ )
+ end
+
+ it 'includes external_ip' do
+ expect(subject[:external_ip]).to eq('111.222.111.222')
+ end
+ end
end
end
diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb
index d3aefa2c9eb..2bd8162d1b7 100644
--- a/spec/serializers/deploy_key_entity_spec.rb
+++ b/spec/serializers/deploy_key_entity_spec.rb
@@ -21,18 +21,21 @@ describe DeployKeyEntity do
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: [
+ deploy_keys_projects: [
{
- id: project.id,
- name: project.name,
- full_path: project_path(project),
- full_name: project.full_name
+ can_push: false,
+ project:
+ {
+ id: project.id,
+ name: project.name,
+ full_path: project_path(project),
+ full_name: project.full_name
+ }
}
]
}
diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb
new file mode 100644
index 00000000000..45d7c703df3
--- /dev/null
+++ b/spec/serializers/diff_file_entity_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe DiffFileEntity do
+ include RepoHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
+ let(:commit) { project.commit(sample_commit.id) }
+ let(:diff_refs) { commit.diff_refs }
+ let(:diff) { commit.raw_diffs.first }
+ let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
+ let(:entity) { described_class.new(diff_file) }
+
+ subject { entity.as_json }
+
+ it 'exposes correct attributes' do
+ expect(subject).to include(
+ :submodule, :submodule_link, :file_path,
+ :deleted_file, :old_path, :new_path, :mode_changed,
+ :a_mode, :b_mode, :text, :old_path_html,
+ :new_path_html
+ )
+ end
+end
diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb
new file mode 100644
index 00000000000..7e19e74ca00
--- /dev/null
+++ b/spec/serializers/discussion_entity_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe DiscussionEntity do
+ include RepoHelpers
+
+ let(:user) { create(:user) }
+ let(:note) { create(:discussion_note_on_merge_request) }
+ let(:discussion) { note.discussion }
+ let(:request) { double('request', note_entity: ProjectNoteEntity) }
+ let(:controller) { double('controller') }
+ let(:entity) { described_class.new(discussion, request: request, context: controller) }
+
+ subject { entity.as_json }
+
+ before do
+ allow(controller).to receive(:render_to_string)
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:noteable).and_return(note.noteable)
+ end
+
+ it 'exposes correct attributes' do
+ expect(subject).to include(
+ :id, :expanded, :notes, :individual_note,
+ :resolvable, :resolved, :resolve_path,
+ :resolve_with_issue_path, :diff_discussion
+ )
+ end
+
+ context 'when diff file is present' do
+ let(:note) { create(:diff_note_on_merge_request) }
+
+ it 'exposes diff file attributes' do
+ expect(subject).to include(:diff_file, :truncated_diff_lines, :image_diff_html)
+ end
+ end
+end
diff --git a/spec/serializers/event_entity_spec.rb b/spec/serializers/event_entity_spec.rb
deleted file mode 100644
index bb54597c967..00000000000
--- a/spec/serializers/event_entity_spec.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-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/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb
index 452754d7a79..505a9eaac5a 100644
--- a/spec/serializers/group_child_entity_spec.rb
+++ b/spec/serializers/group_child_entity_spec.rb
@@ -22,6 +22,7 @@ describe GroupChildEntity do
avatar_url
name
description
+ markdown_description
visibility
type
can_edit
@@ -60,9 +61,10 @@ describe GroupChildEntity do
end
describe 'for a group', :nested_groups do
+ let(:description) { 'Awesomeness' }
let(:object) do
create(:group, :nested, :with_avatar,
- description: 'Awesomeness')
+ description: description)
end
before do
@@ -96,6 +98,14 @@ describe GroupChildEntity do
expect(json[:edit_path]).to eq(edit_group_path(object))
end
+ context 'emoji in description' do
+ let(:description) { ':smile:' }
+
+ it 'has the correct markdown_description' do
+ expect(json[:markdown_description]).to eq('<p dir="auto"><gl-emoji title="smiling face with open mouth and smiling eyes" data-name="smile" data-unicode-version="6.0">😄</gl-emoji></p>')
+ end
+ end
+
it_behaves_like 'group child json'
end
end
diff --git a/spec/serializers/group_variable_entity_spec.rb b/spec/serializers/group_variable_entity_spec.rb
new file mode 100644
index 00000000000..f6de7d01f98
--- /dev/null
+++ b/spec/serializers/group_variable_entity_spec.rb
@@ -0,0 +1,14 @@
+require 'spec_helper'
+
+describe GroupVariableEntity do
+ let(:variable) { create(:ci_group_variable) }
+ let(:entity) { described_class.new(variable) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'contains required fields' do
+ expect(subject).to include(:id, :key, :value, :protected)
+ end
+ end
+end
diff --git a/spec/serializers/job_entity_spec.rb b/spec/serializers/job_entity_spec.rb
index 026360e91a3..24a6f1a2a8a 100644
--- a/spec/serializers/job_entity_spec.rb
+++ b/spec/serializers/job_entity_spec.rb
@@ -38,7 +38,7 @@ describe JobEntity do
it 'contains details' do
expect(subject).to include :status
- expect(subject[:status]).to include :icon, :favicon, :text, :label
+ expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
end
context 'when job is retryable' do
@@ -126,7 +126,29 @@ describe JobEntity do
it 'contains details' do
expect(subject).to include :status
- expect(subject[:status]).to include :icon, :favicon, :text, :label
+ expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
+ end
+ end
+
+ context 'when job failed' do
+ let(:job) { create(:ci_build, :script_failure) }
+
+ describe 'status' do
+ it 'should contain the failure reason inside label' do
+ expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
+ expect(subject[:status][:label]).to eq('failed')
+ expect(subject[:status][:tooltip]).to eq('failed <br> (script failure)')
+ end
+ end
+ end
+
+ context 'when job passed' do
+ let(:job) { create(:ci_build, :success) }
+
+ describe 'status' do
+ it 'should not contain the failure reason inside label' do
+ expect(subject[:status][:label]).to eq('passed')
+ end
end
end
end
diff --git a/spec/serializers/lfs_file_lock_entity_spec.rb b/spec/serializers/lfs_file_lock_entity_spec.rb
new file mode 100644
index 00000000000..5919f473a90
--- /dev/null
+++ b/spec/serializers/lfs_file_lock_entity_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe LfsFileLockEntity do
+ let(:user) { create(:user) }
+ let(:resource) { create(:lfs_file_lock, user: user) }
+
+ let(:request) { double('request', current_user: user) }
+
+ subject { described_class.new(resource, request: request).as_json }
+
+ it 'exposes basic attrs of the lock' do
+ expect(subject).to include(:id, :path, :locked_at)
+ end
+
+ it 'exposes the owner info' do
+ expect(subject).to include(:owner)
+ expect(subject[:owner][:name]).to eq(user.name)
+ end
+end
diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb
index a5924a8589c..d2072198d83 100644
--- a/spec/serializers/merge_request_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_widget_entity_spec.rb
@@ -35,6 +35,81 @@ describe MergeRequestWidgetEntity do
end
end
+ describe 'metrics' do
+ context 'when metrics record exists with merged data' do
+ before do
+ resource.mark_as_merged!
+ resource.metrics.update!(merged_by: user)
+ end
+
+ it 'matches merge request metrics schema' do
+ expect(subject[:metrics].with_indifferent_access)
+ .to match_schema('entities/merge_request_metrics')
+ end
+
+ it 'returns values from metrics record' do
+ expect(subject.dig(:metrics, :merged_by, :id))
+ .to eq(resource.metrics.merged_by_id)
+ end
+ end
+
+ context 'when metrics record exists with closed data' do
+ before do
+ resource.close!
+ resource.metrics.update!(latest_closed_by: user)
+ end
+
+ it 'matches merge request metrics schema' do
+ expect(subject[:metrics].with_indifferent_access)
+ .to match_schema('entities/merge_request_metrics')
+ end
+
+ it 'returns values from metrics record' do
+ expect(subject.dig(:metrics, :closed_by, :id))
+ .to eq(resource.metrics.latest_closed_by_id)
+ end
+ end
+
+ context 'when metrics does not exists' do
+ before do
+ resource.mark_as_merged!
+ resource.metrics.destroy!
+ resource.reload
+ end
+
+ context 'when events exists' do
+ let!(:closed_event) { create(:event, :closed, project: project, target: resource) }
+ let!(:merge_event) { create(:event, :merged, project: project, target: resource) }
+
+ it 'matches merge request metrics schema' do
+ expect(subject[:metrics].with_indifferent_access)
+ .to match_schema('entities/merge_request_metrics')
+ end
+
+ it 'returns values from events record' do
+ expect(subject.dig(:metrics, :merged_by, :id))
+ .to eq(merge_event.author_id)
+
+ expect(subject.dig(:metrics, :closed_by, :id))
+ .to eq(closed_event.author_id)
+
+ expect(subject.dig(:metrics, :merged_at).to_s)
+ .to eq(merge_event.updated_at.to_s)
+
+ expect(subject.dig(:metrics, :closed_at).to_s)
+ .to eq(closed_event.updated_at.to_s)
+ end
+ end
+
+ context 'when events does not exists' do
+ it 'matches merge request metrics schema' do
+ expect(subject[:metrics].with_indifferent_access)
+ .to match_schema('entities/merge_request_metrics')
+ end
+ end
+ end
+ end
+
it 'has email_patches_path' do
expect(subject[:email_patches_path])
.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.patch")
@@ -72,9 +147,9 @@ describe MergeRequestWidgetEntity do
allow(resource).to receive(:diff_head_sha) { 'sha' }
end
- context 'when no diff head commit' do
+ context 'when diff head commit is empty' do
it 'returns nil' do
- allow(resource).to receive(:diff_head_commit) { nil }
+ allow(resource).to receive(:diff_head_sha) { '' }
expect(subject[:diff_head_sha]).to be_nil
end
@@ -82,8 +157,6 @@ describe MergeRequestWidgetEntity do
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
@@ -115,4 +188,20 @@ describe MergeRequestWidgetEntity do
end
end
end
+
+ describe 'when source project is deleted' do
+ let(:project) { create(:project, :repository) }
+ let(:fork_project) { create(:project, :repository, forked_from_project: project) }
+ let(:merge_request) { create(:merge_request, source_project: fork_project, target_project: project) }
+
+ it 'returns a blank rebase_path' do
+ allow(merge_request).to receive(:should_be_rebased?).and_return(true)
+ fork_project.destroy
+ merge_request.reload
+
+ entity = described_class.new(merge_request, request: request).as_json
+
+ expect(entity[:rebase_path]).to be_nil
+ end
+ end
end
diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb
index 3459cc72063..13cda781cda 100644
--- a/spec/serializers/note_entity_spec.rb
+++ b/spec/serializers/note_entity_spec.rb
@@ -10,42 +10,5 @@ describe NoteEntity do
let(:user) { create(:user) }
subject { entity.as_json }
- context 'basic note' do
- it 'exposes correct elements' do
- expect(subject).to include(:type, :author, :human_access, :note, :note_html, :current_user,
- :discussion_id, :emoji_awardable, :award_emoji, :toggle_award_path, :report_abuse_path, :path, :attachment)
- end
-
- it 'does not expose elements for specific notes cases' do
- expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name)
- end
-
- it 'exposes author correctly' do
- expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path)
- end
-
- it 'does not expose web_url for author' do
- expect(subject[:author]).not_to include(:web_url)
- end
- end
-
- context 'when note was edited' do
- before do
- note.update(updated_at: 1.minute.from_now, updated_by: user)
- end
-
- it 'exposes last_edited_at and last_edited_by elements' do
- expect(subject).to include(:last_edited_at, :last_edited_by)
- end
- end
-
- context 'when note is a system note' do
- before do
- note.update(system: true)
- end
-
- it 'exposes system_note_icon_name element' do
- expect(subject).to include(:system_note_icon_name)
- end
- end
+ it_behaves_like 'note entity'
end
diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb
index 248552d1858..2473c561f4b 100644
--- a/spec/serializers/pipeline_entity_spec.rb
+++ b/spec/serializers/pipeline_entity_spec.rb
@@ -30,7 +30,7 @@ describe PipelineEntity do
expect(subject).to include :details
expect(subject[:details])
.to include :duration, :finished_at
- expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
+ expect(subject[:details][:status]).to include :icon, :favicon, :text, :label, :tooltip
end
it 'contains flags' do
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index c38795ad1a1..f51c11b141f 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -117,6 +117,7 @@ describe PipelineSerializer do
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(36)
expect(recorded.cached_count).to eq(0)
end
diff --git a/spec/serializers/project_note_entity_spec.rb b/spec/serializers/project_note_entity_spec.rb
new file mode 100644
index 00000000000..dafd1cf603e
--- /dev/null
+++ b/spec/serializers/project_note_entity_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe ProjectNoteEntity do
+ include Gitlab::Routing
+
+ let(:request) { double('request', current_user: user, noteable: note.noteable) }
+
+ let(:entity) { described_class.new(note, request: request) }
+ let(:note) { create(:note) }
+ let(:user) { create(:user) }
+ subject { entity.as_json }
+
+ it_behaves_like 'note entity'
+
+ it 'exposes project-specific elements' do
+ expect(subject).to include(:human_access, :toggle_award_path, :path)
+ end
+
+ context 'when note is part of resolvable discussion' do
+ before do
+ allow(note).to receive(:part_of_discussion?).and_return(true)
+ allow(note).to receive(:resolvable?).and_return(true)
+ end
+
+ it 'exposes paths to resolve note' do
+ expect(subject).to include(:resolve_path, :resolve_with_issue_path)
+ end
+ end
+end
diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb
index 40e303f7b89..2034c7891ef 100644
--- a/spec/serializers/stage_entity_spec.rb
+++ b/spec/serializers/stage_entity_spec.rb
@@ -26,7 +26,7 @@ describe StageEntity do
end
it 'contains detailed status' do
- expect(subject[:status]).to include :text, :label, :group, :icon
+ expect(subject[:status]).to include :text, :label, :group, :icon, :tooltip
expect(subject[:status][:label]).to eq 'passed'
end
diff --git a/spec/serializers/status_entity_spec.rb b/spec/serializers/status_entity_spec.rb
index 16431ed4188..559475e571c 100644
--- a/spec/serializers/status_entity_spec.rb
+++ b/spec/serializers/status_entity_spec.rb
@@ -16,7 +16,7 @@ describe StatusEntity do
subject { entity.as_json }
it 'contains status details' do
- expect(subject).to include :text, :icon, :favicon, :label, :group
+ expect(subject).to include :text, :icon, :favicon, :label, :group, :tooltip
expect(subject).to include :has_details, :details_path
expect(subject[:favicon]).to match_asset_path('/assets/ci_favicons/favicon_status_success.ico')
end
@@ -25,5 +25,10 @@ describe StatusEntity do
allow(Rails.env).to receive(:development?) { true }
expect(entity.as_json[:favicon]).to match_asset_path('/assets/ci_favicons/dev/favicon_status_success.ico')
end
+
+ it 'contains a canary namespaced favicon if canary env' do
+ stub_env('CANARY', 'true')
+ expect(entity.as_json[:favicon]).to match_asset_path('/assets/ci_favicons/canary/favicon_status_success.ico')
+ end
end
end
diff --git a/spec/serializers/variable_entity_spec.rb b/spec/serializers/variable_entity_spec.rb
new file mode 100644
index 00000000000..effc0022633
--- /dev/null
+++ b/spec/serializers/variable_entity_spec.rb
@@ -0,0 +1,14 @@
+require 'spec_helper'
+
+describe VariableEntity do
+ let(:variable) { create(:ci_variable) }
+ let(:entity) { described_class.new(variable) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'contains required fields' do
+ expect(subject).to include(:id, :key, :value, :protected)
+ end
+ end
+end
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
index 9128280eb5a..290eeae828e 100644
--- a/spec/services/auth/container_registry_authentication_service_spec.rb
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -172,7 +172,7 @@ describe Auth::ContainerRegistryAuthenticationService do
end
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:*" }
+ { scope: "repository:#{project.full_path}:*" }
end
it_behaves_like 'an inaccessible'
@@ -200,7 +200,7 @@ describe Auth::ContainerRegistryAuthenticationService do
end
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:*" }
+ { scope: "repository:#{project.full_path}:*" }
end
it_behaves_like 'an inaccessible'
@@ -239,7 +239,7 @@ describe Auth::ContainerRegistryAuthenticationService do
end
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:*" }
+ { scope: "repository:#{project.full_path}:*" }
end
it_behaves_like 'an inaccessible'
@@ -270,7 +270,7 @@ describe Auth::ContainerRegistryAuthenticationService do
context 'disallow anyone to delete images' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:*" }
+ { scope: "repository:#{project.full_path}:*" }
end
it_behaves_like 'an inaccessible'
@@ -311,7 +311,7 @@ describe Auth::ContainerRegistryAuthenticationService do
context 'disallow anyone to delete images' do
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:*" }
+ { scope: "repository:#{project.full_path}:*" }
end
it_behaves_like 'an inaccessible'
@@ -323,7 +323,7 @@ describe Auth::ContainerRegistryAuthenticationService do
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" }
+ { scope: "repository:#{project.full_path}:pull,push" }
end
it_behaves_like 'an inaccessible'
@@ -333,7 +333,7 @@ describe Auth::ContainerRegistryAuthenticationService do
context 'disallow anyone to delete images' do
let(:current_user) { create(:user, external: true) }
let(:current_params) do
- { scope: "repository:#{project.path_with_namespace}:*" }
+ { scope: "repository:#{project.full_path}:*" }
end
it_behaves_like 'an inaccessible'
@@ -359,7 +359,7 @@ describe Auth::ContainerRegistryAuthenticationService do
context 'allow to delete images' do
let(:current_params) do
- { scope: "repository:#{current_project.path_with_namespace}:*" }
+ { scope: "repository:#{current_project.full_path}:*" }
end
it_behaves_like 'a deletable' do
@@ -398,7 +398,7 @@ describe Auth::ContainerRegistryAuthenticationService do
context 'disallow to delete images' do
let(:current_params) do
- { scope: "repository:#{current_project.path_with_namespace}:*" }
+ { scope: "repository:#{current_project.full_path}:*" }
end
it_behaves_like 'an inaccessible' do
diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb
index db51a524e79..a715261cd6c 100644
--- a/spec/services/boards/create_service_spec.rb
+++ b/spec/services/boards/create_service_spec.rb
@@ -2,34 +2,20 @@ require 'spec_helper'
describe Boards::CreateService do
describe '#execute' do
- let(:project) { create(:project) }
+ context 'when board parent is a project' do
+ let(:parent) { create(:project) }
- subject(:service) { described_class.new(project, double) }
+ subject(:service) { described_class.new(parent, double) }
- context 'when project does not have a board' do
- it 'creates a new board' do
- expect { service.execute }.to change(Board, :count).by(1)
- end
-
- it 'creates the default lists' do
- board = service.execute
-
- expect(board.lists.size).to eq 2
- expect(board.lists.first).to be_backlog
- expect(board.lists.last).to be_closed
- end
+ it_behaves_like 'boards create service'
end
- context 'when project has a board' do
- before do
- create(:board, project: project)
- end
+ context 'when board parent is a group' do
+ let(:parent) { create(:group) }
- it 'does not create a new board' do
- expect(service).to receive(:can_create_board?) { false }
+ subject(:service) { described_class.new(parent, double) }
- expect { service.execute }.not_to change(project.boards, :count)
- end
+ it_behaves_like 'boards create service'
end
end
end
diff --git a/spec/services/boards/issues/create_service_spec.rb b/spec/services/boards/issues/create_service_spec.rb
index 1a56164dba4..f0179e35652 100644
--- a/spec/services/boards/issues/create_service_spec.rb
+++ b/spec/services/boards/issues/create_service_spec.rb
@@ -11,7 +11,7 @@ describe Boards::Issues::CreateService do
subject(:service) { described_class.new(board.parent, project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'delegates the create proceedings to Issues::CreateService' do
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
index 01ee3856c99..27a7bf0e605 100644
--- a/spec/services/boards/issues/list_service_spec.rb
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -2,97 +2,117 @@ require 'spec_helper'
describe Boards::Issues::ListService do
describe '#execute' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:board) { create(:board, project: project) }
-
- let(:bug) { create(:label, project: project, name: 'Bug') }
- let(:development) { create(:label, project: project, name: 'Development') }
- let(:testing) { create(:label, project: project, name: 'Testing') }
- let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
- 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, :opened, project: project) }
-
- let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) }
- let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) }
- let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) }
- let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) }
-
- let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
- let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
- let!(:closed_issue3) { create(:issue, :closed, project: project) }
- let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) }
- let!(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development]) }
-
- before do
- project.team << [user, :developer]
- end
-
- it 'delegates search to IssuesFinder' do
- params = { board_id: board.id, id: list1.id }
-
- expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
+ context 'when parent is a project' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:board) { create(:board, project: project) }
+
+ let(:m1) { create(:milestone, project: project) }
+ let(:m2) { create(:milestone, project: project) }
+
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
+ let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
+ 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, milestone: m1, title: 'Issue 1', labels: [bug]) }
+ let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) }
+ let!(:reopened_issue1) { create(:issue, :opened, project: project, title: 'Issue 3' ) }
+
+ let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) }
+ let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
+ let!(:list1_issue3) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1]) }
+ let!(:list2_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [testing]) }
+
+ let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
+ let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
+ let!(:closed_issue3) { create(:issue, :closed, project: project) }
+ let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) }
+ let!(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development]) }
+
+ let(:parent) { project }
+
+ before do
+ project.add_developer(user)
+ end
- described_class.new(project, user, params).execute
+ it_behaves_like 'issues list service'
end
- context 'issues are ordered by priority' do
- it 'returns opened issues when list_id is missing' do
- params = { board_id: board.id }
+ context 'when parent is a group' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :empty_repo, namespace: group) }
+ let(:project1) { create(:project, :empty_repo, namespace: group) }
- issues = described_class.new(project, user, params).execute
+ let(:m1) { create(:milestone, group: group) }
+ let(:m2) { create(:milestone, group: group) }
- expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
- end
+ let(:bug) { create(:group_label, group: group, name: 'Bug') }
+ let(:development) { create(:group_label, group: group, name: 'Development') }
+ let(:testing) { create(:group_label, group: group, name: 'Testing') }
- it 'returns opened issues when listing issues from Backlog' do
- params = { board_id: board.id, id: backlog.id }
+ let(:p1) { create(:group_label, title: 'P1', group: group) }
+ let(:p2) { create(:group_label, title: 'P2', group: group) }
+ let(:p3) { create(:group_label, title: 'P3', group: group) }
- issues = described_class.new(project, user, params).execute
+ let(:p1_project) { create(:label, title: 'P1_project', project: project, priority: 1) }
+ let(:p2_project) { create(:label, title: 'P2_project', project: project, priority: 2) }
+ let(:p3_project) { create(:label, title: 'P3_project', project: project, priority: 3) }
- expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
- end
+ let(:p1_project1) { create(:label, title: 'P1_project1', project: project1, priority: 1) }
+ let(:p2_project1) { create(:label, title: 'P2_project1', project: project1, priority: 2) }
+ let(:p3_project1) { create(:label, title: 'P3_project1', project: project1, priority: 3) }
- it 'returns closed issues when listing issues from Closed' do
- params = { board_id: board.id, id: closed.id }
+ 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) }
- issues = described_class.new(project, user, params).execute
+ let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
+ let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2, p2_project]) }
+ let!(:reopened_issue1) { create(:issue, state: 'opened', project: project, title: 'Issue 3', closed_at: Time.now ) }
- expect(issues).to eq [closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1]
- end
-
- it 'returns opened issues that have label list applied when listing issues from a label list' do
- params = { board_id: board.id, id: list1.id }
+ let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, p2_project, development]) }
+ let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
+ let!(:list1_issue3) { create(:labeled_issue, project: project1, milestone: m1, labels: [development, p1, p1_project1]) }
+ let!(:list2_issue1) { create(:labeled_issue, project: project1, milestone: m1, labels: [testing]) }
- issues = described_class.new(project, user, params).execute
+ let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
+ let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3, p3_project]) }
+ let!(:closed_issue3) { create(:issue, :closed, project: project1) }
+ let!(:closed_issue4) { create(:labeled_issue, :closed, project: project1, labels: [p1, p1_project1]) }
+ let!(:closed_issue5) { create(:labeled_issue, :closed, project: project1, labels: [development]) }
- expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
+ before do
+ group.add_developer(user)
end
- end
- context 'with list that does not belong to the board' do
- it 'raises an error' do
- list = create(:list)
- service = described_class.new(project, user, board_id: board.id, id: list.id)
+ context 'and group has no parent' do
+ let(:parent) { group }
+ let(:group) { create(:group) }
+ let(:board) { create(:board, group: group) }
- expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
+ it_behaves_like 'issues list service'
end
- end
- context 'with invalid list id' do
- it 'raises an error' do
- service = described_class.new(project, user, board_id: board.id, id: nil)
+ context 'and group is an ancestor', :nested_groups do
+ let(:parent) { create(:group) }
+ let(:group) { create(:group, parent: parent) }
+ let!(:backlog) { create(:backlog_list, board: board) }
+ let(:board) { create(:board, group: parent) }
+
+ before do
+ parent.add_developer(user)
+ end
- expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
+ it_behaves_like 'issues list service'
end
end
end
diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb
index 464ff9f94b3..dd0ad5f11bd 100644
--- a/spec/services/boards/issues/move_service_spec.rb
+++ b/spec/services/boards/issues/move_service_spec.rb
@@ -2,108 +2,53 @@ require 'spec_helper'
describe Boards::Issues::MoveService do
describe '#execute' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:board1) { create(:board, project: project) }
-
- let(:bug) { create(:label, project: project, name: 'Bug') }
- let(:development) { create(:label, project: project, name: 'Development') }
- let(:testing) { create(:label, project: project, name: 'Testing') }
-
- let!(:list1) { create(:list, board: board1, label: development, position: 0) }
- let!(:list2) { create(:list, board: board1, label: testing, position: 1) }
- let!(:closed) { create(:closed_list, board: board1) }
-
- before do
- project.team << [user, :developer]
- end
-
- context 'when moving an issue between lists' do
- let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
- let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
-
- it 'delegates the label changes to Issues::UpdateService' do
- expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
-
- described_class.new(project, user, params).execute(issue)
- end
-
- it 'removes the label from the list it came from and adds the label of the list it goes to' do
- described_class.new(project, user, params).execute(issue)
-
- expect(issue.reload.labels).to contain_exactly(bug, testing)
- end
- end
-
- context 'when moving to closed' do
+ context 'when parent is a project' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:board1) { create(:board, project: project) }
let(:board2) { create(:board, project: project) }
+
+ let(:bug) { create(:label, project: project, name: 'Bug') }
+ let(:development) { create(:label, project: project, name: 'Development') }
+ let(:testing) { create(:label, project: project, name: 'Testing') }
let(:regression) { create(:label, project: project, name: 'Regression') }
- let!(:list3) { create(:list, board: board2, label: regression, position: 1) }
- let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing, regression]) }
- let(:params) { { board_id: board1.id, from_list_id: list2.id, to_list_id: closed.id } }
+ let!(:list1) { create(:list, board: board1, label: development, position: 0) }
+ let!(:list2) { create(:list, board: board1, label: testing, position: 1) }
+ let!(:closed) { create(:closed_list, board: board1) }
- it 'delegates the close proceedings to Issues::CloseService' do
- expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
+ let(:parent) { project }
- described_class.new(project, user, params).execute(issue)
+ before do
+ parent.add_developer(user)
end
- it 'removes all list-labels from project boards and close the issue' do
- described_class.new(project, user, params).execute(issue)
- issue.reload
-
- expect(issue.labels).to contain_exactly(bug)
- expect(issue).to be_closed
- end
+ it_behaves_like 'issues move service'
end
- context 'when moving from closed' do
- let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
- let(:params) { { board_id: board1.id, from_list_id: closed.id, to_list_id: list2.id } }
-
- it 'delegates the re-open proceedings to Issues::ReopenService' do
- expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
-
- described_class.new(project, user, params).execute(issue)
- end
-
- it 'adds the label of the list it goes to and reopen the issue' do
- described_class.new(project, user, params).execute(issue)
- issue.reload
-
- expect(issue.labels).to contain_exactly(bug, testing)
- expect(issue).to be_opened
- end
- end
+ context 'when parent is a group' do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, namespace: group) }
+ let(:board1) { create(:board, group: group) }
+ let(:board2) { create(:board, group: group) }
- context 'when moving to same list' do
- let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
- let(:issue1) { create(:labeled_issue, project: project, labels: [bug, development]) }
- let(:issue2) { create(:labeled_issue, project: project, labels: [bug, development]) }
- let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } }
+ let(:bug) { create(:group_label, group: group, name: 'Bug') }
+ let(:development) { create(:group_label, group: group, name: 'Development') }
+ let(:testing) { create(:group_label, group: group, name: 'Testing') }
+ let(:regression) { create(:group_label, group: group, name: 'Regression') }
- it 'returns false' do
- expect(described_class.new(project, user, params).execute(issue)).to eq false
- end
+ let!(:list1) { create(:list, board: board1, label: development, position: 0) }
+ let!(:list2) { create(:list, board: board1, label: testing, position: 1) }
+ let!(:closed) { create(:closed_list, board: board1) }
- it 'keeps issues labels' do
- described_class.new(project, user, params).execute(issue)
+ let(:parent) { group }
- expect(issue.reload.labels).to contain_exactly(bug, development)
+ before do
+ parent.add_developer(user)
end
- it 'sorts issues' do
- [issue, issue1, issue2].each do |issue|
- issue.move_to_end && issue.save!
- end
-
- params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
-
- described_class.new(project, user, params).execute(issue)
-
- expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
- end
+ it_behaves_like 'issues move service', true
end
end
end
diff --git a/spec/services/boards/list_service_spec.rb b/spec/services/boards/list_service_spec.rb
index 1d0be99fb35..7518e9e9b75 100644
--- a/spec/services/boards/list_service_spec.rb
+++ b/spec/services/boards/list_service_spec.rb
@@ -2,36 +2,20 @@ require 'spec_helper'
describe Boards::ListService do
describe '#execute' do
- let(:project) { create(:project) }
+ context 'when board parent is a project' do
+ let(:parent) { create(:project) }
- subject(:service) { described_class.new(project, double) }
+ subject(:service) { described_class.new(parent, double) }
- context 'when project does not have a board' do
- it 'creates a new project board' do
- expect { service.execute }.to change(project.boards, :count).by(1)
- end
-
- it 'delegates the project board creation to Boards::CreateService' do
- expect_any_instance_of(Boards::CreateService).to receive(:execute).once
-
- service.execute
- end
+ it_behaves_like 'boards list service'
end
- context 'when project has a board' do
- before do
- create(:board, project: project)
- end
-
- it 'does not create a new board' do
- expect { service.execute }.not_to change(project.boards, :count)
- end
- end
+ context 'when board parent is a group' do
+ let(:parent) { create(:group) }
- it 'returns project boards' do
- board = create(:board, project: project)
+ subject(:service) { described_class.new(parent, double) }
- expect(service.execute).to match_array [board]
+ it_behaves_like 'boards list service'
end
end
end
diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb
index 7d0b396cd06..7d3f5f86deb 100644
--- a/spec/services/boards/lists/create_service_spec.rb
+++ b/spec/services/boards/lists/create_service_spec.rb
@@ -2,62 +2,77 @@ require 'spec_helper'
describe Boards::Lists::CreateService do
describe '#execute' do
- let(:project) { create(:project) }
- let(:board) { create(:board, project: project) }
- let(:user) { create(:user) }
- let(:label) { create(:label, project: project, name: 'in-progress') }
+ shared_examples 'creating board lists' do
+ let(:user) { create(:user) }
- subject(:service) { described_class.new(project, user, label_id: label.id) }
+ subject(:service) { described_class.new(parent, user, label_id: label.id) }
- before do
- project.team << [user, :developer]
- end
+ before do
+ parent.add_developer(user)
+ end
- context 'when board lists is empty' do
- it 'creates a new list at beginning of the list' do
- list = service.execute(board)
+ context 'when board lists is empty' do
+ it 'creates a new list at beginning of the list' do
+ list = service.execute(board)
- expect(list.position).to eq 0
+ expect(list.position).to eq 0
+ end
end
- end
- context 'when board lists has the done list' do
- it 'creates a new list at beginning of the list' do
- list = service.execute(board)
+ context 'when board lists has the done list' do
+ it 'creates a new list at beginning of the list' do
+ list = service.execute(board)
- expect(list.position).to eq 0
+ expect(list.position).to eq 0
+ end
end
- end
- context 'when board lists has labels lists' do
- it 'creates a new list at end of the lists' do
- create(:list, board: board, position: 0)
- create(:list, board: board, position: 1)
+ context 'when board lists has labels lists' do
+ it 'creates a new list at end of the lists' do
+ create(:list, board: board, position: 0)
+ create(:list, board: board, position: 1)
- list = service.execute(board)
+ list = service.execute(board)
- expect(list.position).to eq 2
+ expect(list.position).to eq 2
+ end
end
- end
- context 'when board lists has label and done lists' do
- it 'creates a new list at end of the label lists' do
- list1 = create(:list, board: board, position: 0)
+ context 'when board lists has label and done lists' do
+ it 'creates a new list at end of the label lists' do
+ list1 = create(:list, board: board, position: 0)
- list2 = service.execute(board)
+ list2 = service.execute(board)
- expect(list1.reload.position).to eq 0
- expect(list2.reload.position).to eq 1
+ expect(list1.reload.position).to eq 0
+ expect(list2.reload.position).to eq 1
+ end
end
- end
- context 'when provided label does not belongs to the project' do
- it 'raises an error' do
- label = create(:label, name: 'in-development')
- service = described_class.new(project, user, label_id: label.id)
+ context 'when provided label does not belongs to the parent' do
+ it 'raises an error' do
+ label = create(:label, name: 'in-development')
+ service = described_class.new(parent, user, label_id: label.id)
- expect { service.execute(board) }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { service.execute(board) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
end
end
+
+ context 'when board parent is a project' do
+ let(:parent) { create(:project) }
+ let(:board) { create(:board, project: parent) }
+ let(:label) { create(:label, project: parent, name: 'in-progress') }
+
+ it_behaves_like 'creating board lists'
+ end
+
+ context 'when board parent is a group' do
+ let(:parent) { create(:group) }
+ let(:board) { create(:board, group: parent) }
+ let(:label) { create(:group_label, group: parent, name: 'in-progress') }
+
+ it_behaves_like 'creating board lists'
+ end
end
end
diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb
index bd98625b44f..3c4eb6b3fc5 100644
--- a/spec/services/boards/lists/destroy_service_spec.rb
+++ b/spec/services/boards/lists/destroy_service_spec.rb
@@ -2,37 +2,24 @@ require 'spec_helper'
describe Boards::Lists::DestroyService do
describe '#execute' do
- let(:project) { create(:project) }
- let(:board) { create(:board, project: project) }
- let(:user) { create(:user) }
+ context 'when board parent is a project' do
+ let(:project) { create(:project) }
+ let(:board) { create(:board, project: project) }
+ let(:user) { create(:user) }
- context 'when list type is label' do
- it 'removes list from board' do
- list = create(:list, board: board)
- service = described_class.new(project, user)
+ let(:parent) { project }
- expect { service.execute(list) }.to change(board.lists, :count).by(-1)
- end
-
- it 'decrements position of higher lists' do
- development = create(:list, board: board, position: 0)
- review = create(:list, board: board, position: 1)
- staging = create(:list, board: board, position: 2)
- closed = board.closed_list
-
- described_class.new(project, user).execute(development)
-
- expect(review.reload.position).to eq 0
- expect(staging.reload.position).to eq 1
- expect(closed.reload.position).to be_nil
- end
+ it_behaves_like 'lists destroy service'
end
- it 'does not remove list from board when list type is closed' do
- list = board.closed_list
- service = described_class.new(project, user)
+ context 'when board parent is a group' do
+ let(:group) { create(:group) }
+ let(:board) { create(:board, group: group) }
+ let(:user) { create(:user) }
+
+ let(:parent) { group }
- expect { service.execute(list) }.not_to change(board.lists, :count)
+ it_behaves_like 'lists destroy service'
end
end
end
diff --git a/spec/services/boards/lists/generate_service_spec.rb b/spec/services/boards/lists/generate_service_spec.rb
index 592f25059ac..82dbd1ee744 100644
--- a/spec/services/boards/lists/generate_service_spec.rb
+++ b/spec/services/boards/lists/generate_service_spec.rb
@@ -9,7 +9,7 @@ describe Boards::Lists::GenerateService do
subject(:service) { described_class.new(project, user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'when board lists is empty' do
diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb
index b189857e4f4..24e04eed642 100644
--- a/spec/services/boards/lists/list_service_spec.rb
+++ b/spec/services/boards/lists/list_service_spec.rb
@@ -1,33 +1,25 @@
require 'spec_helper'
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
- 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
+ context 'when board parent is a project' 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) }
- it "returns board's lists" do
- expect(service.execute(board)).to eq [backlog_list, list, board.closed_list]
- end
+ it_behaves_like 'lists list service'
end
- 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
+ context 'when board parent is a group' do
+ let(:group) { create(:group) }
+ let(:board) { create(:board, group: group) }
+ let(:label) { create(:group_label, group: group) }
+ let!(:list) { create(:list, board: board, label: label) }
+ let(:service) { described_class.new(group, double) }
- it "returns board's lists" do
- expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
- end
+ it_behaves_like 'lists list service'
end
end
end
diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb
index a9d218bad49..16dfb2ae6af 100644
--- a/spec/services/boards/lists/move_service_spec.rb
+++ b/spec/services/boards/lists/move_service_spec.rb
@@ -2,100 +2,24 @@ require 'spec_helper'
describe Boards::Lists::MoveService do
describe '#execute' do
- let(:project) { create(:project) }
- let(:board) { create(:board, project: project) }
- let(:user) { create(:user) }
+ context 'when board parent is a project' do
+ let(:project) { create(:project) }
+ let(:board) { create(:board, project: project) }
+ let(:user) { create(:user) }
- let!(:planning) { create(:list, board: board, position: 0) }
- let!(:development) { create(:list, board: board, position: 1) }
- let!(:review) { create(:list, board: board, position: 2) }
- let!(:staging) { create(:list, board: board, position: 3) }
- let!(:closed) { create(:closed_list, board: board) }
+ let(:parent) { project }
- context 'when list type is set to label' do
- it 'keeps position of lists when new position is nil' do
- service = described_class.new(project, user, position: nil)
-
- service.execute(planning)
-
- expect(current_list_positions).to eq [0, 1, 2, 3]
- end
-
- it 'keeps position of lists when new positon is equal to old position' do
- service = described_class.new(project, user, position: planning.position)
-
- service.execute(planning)
-
- expect(current_list_positions).to eq [0, 1, 2, 3]
- end
-
- it 'keeps position of lists when new positon is negative' do
- service = described_class.new(project, user, position: -1)
-
- service.execute(planning)
-
- expect(current_list_positions).to eq [0, 1, 2, 3]
- end
-
- it 'keeps position of lists when new positon is equal to number of labels lists' do
- service = described_class.new(project, user, position: board.lists.label.size)
-
- service.execute(planning)
-
- expect(current_list_positions).to eq [0, 1, 2, 3]
- end
-
- it 'keeps position of lists when new positon is greater than number of labels lists' do
- service = described_class.new(project, user, position: board.lists.label.size + 1)
-
- service.execute(planning)
-
- expect(current_list_positions).to eq [0, 1, 2, 3]
- end
-
- it 'increments position of intermediate lists when new positon is equal to first position' do
- service = described_class.new(project, user, position: 0)
-
- service.execute(staging)
-
- expect(current_list_positions).to eq [1, 2, 3, 0]
- end
-
- it 'decrements position of intermediate lists when new positon is equal to last position' do
- service = described_class.new(project, user, position: board.lists.label.last.position)
-
- service.execute(planning)
-
- expect(current_list_positions).to eq [3, 0, 1, 2]
- end
-
- it 'decrements position of intermediate lists when new position is greater than old position' do
- service = described_class.new(project, user, position: 2)
-
- service.execute(planning)
-
- expect(current_list_positions).to eq [2, 0, 1, 3]
- end
-
- it 'increments position of intermediate lists when new position is lower than old position' do
- service = described_class.new(project, user, position: 1)
-
- service.execute(staging)
-
- expect(current_list_positions).to eq [0, 2, 3, 1]
- end
+ it_behaves_like 'lists move service'
end
- it 'keeps position of lists when list type is closed' do
- service = described_class.new(project, user, position: 2)
+ context 'when board parent is a group' do
+ let(:group) { create(:group) }
+ let(:board) { create(:board, group: group) }
+ let(:user) { create(:user) }
- service.execute(closed)
+ let(:parent) { group }
- expect(current_list_positions).to eq [0, 1, 2, 3]
+ it_behaves_like 'lists move service'
end
end
-
- def current_list_positions
- [planning, development, review, staging].map { |list| list.reload.position }
- end
end
diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb
index 79aaac3aeb6..5734b10109a 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 do
+describe ChatNames::FindUserService, :clean_gitlab_redis_shared_state do
describe '#execute' do
let(:service) { create(:service) }
@@ -13,21 +13,30 @@ describe ChatNames::FindUserService do
context 'when existing user is requested' do
let(:params) { { team_id: chat_name.team_id, user_id: chat_name.chat_id } }
- it 'returns the existing user' do
- is_expected.to eq(user)
+ it 'returns the existing chat_name' do
+ is_expected.to eq(chat_name)
end
- it 'updates when last time chat name was used' do
+ it 'updates the last used timestamp if one is not already set' do
expect(chat_name.last_used_at).to be_nil
subject
- initial_last_used = chat_name.reload.last_used_at
- expect(initial_last_used).to be_present
+ expect(chat_name.reload.last_used_at).to be_present
+ end
+
+ it 'only updates an existing timestamp once within a certain time frame' do
+ service = described_class.new(service, params)
+
+ expect(chat_name.last_used_at).to be_nil
+
+ service.execute
+
+ time = chat_name.reload.last_used_at
- Timecop.travel(2.days.from_now) { described_class.new(service, params).execute }
+ service.execute
- expect(chat_name.reload.last_used_at).to be > initial_last_used
+ expect(chat_name.reload.last_used_at).to eq(time)
end
end
diff --git a/spec/services/check_gcp_project_billing_service_spec.rb b/spec/services/check_gcp_project_billing_service_spec.rb
new file mode 100644
index 00000000000..3e68d906e71
--- /dev/null
+++ b/spec/services/check_gcp_project_billing_service_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+describe CheckGcpProjectBillingService do
+ include GoogleApi::CloudPlatformHelpers
+
+ let(:service) { described_class.new }
+ let(:project_id) { 'test-project-1234' }
+
+ describe '#execute' do
+ before do
+ stub_cloud_platform_projects_list(project_id: project_id)
+ end
+
+ subject { service.execute('bogustoken') }
+
+ context 'google account has a billing enabled gcp project' do
+ before do
+ stub_cloud_platform_projects_get_billing_info(project_id, true)
+ end
+
+ it { is_expected.to all(satisfy { |project| project.project_id == project_id }) }
+ end
+
+ context 'google account does not have a billing enabled gcp project' do
+ before do
+ stub_cloud_platform_projects_get_billing_info(project_id, false)
+ end
+
+ it { is_expected.to eq([]) }
+ end
+ end
+end
diff --git a/spec/services/ci/ensure_stage_service_spec.rb b/spec/services/ci/ensure_stage_service_spec.rb
new file mode 100644
index 00000000000..d17e30763d7
--- /dev/null
+++ b/spec/services/ci/ensure_stage_service_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe Ci::EnsureStageService, '#execute' do
+ set(:project) { create(:project) }
+ set(:user) { create(:user) }
+
+ let(:stage) { create(:ci_stage_entity) }
+ let(:job) { build(:ci_build) }
+
+ let(:service) { described_class.new(project, user) }
+
+ context 'when build has a stage assigned' do
+ it 'does not create a new stage' do
+ job.assign_attributes(stage_id: stage.id)
+
+ expect { service.execute(job) }.not_to change { Ci::Stage.count }
+ end
+ end
+
+ context 'when build does not have a stage assigned' do
+ it 'creates a new stage' do
+ job.assign_attributes(stage_id: nil, stage: 'test')
+
+ expect { service.execute(job) }.to change { Ci::Stage.count }.by(1)
+ end
+ end
+
+ context 'when build is invalid' do
+ it 'does not create a new stage' do
+ job.assign_attributes(stage_id: nil, ref: nil)
+
+ expect { service.execute(job) }.not_to change { Ci::Stage.count }
+ end
+ end
+
+ context 'when new stage can not be created because of an exception' do
+ before do
+ allow(Ci::Stage).to receive(:create!)
+ .and_raise(ActiveRecord::RecordNotUnique.new('Duplicates!'))
+ end
+
+ it 'retries up to two times' do
+ job.assign_attributes(stage_id: nil)
+
+ expect(service).to receive(:find_stage).exactly(2).times
+
+ expect { service.execute(job) }
+ .to raise_error(Ci::EnsureStageService::EnsureStageError)
+ end
+ end
+end
diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb
index 0ce41e7c7ee..feb5120bc68 100644
--- a/spec/services/ci/process_pipeline_service_spec.rb
+++ b/spec/services/ci/process_pipeline_service_spec.rb
@@ -9,6 +9,8 @@ describe Ci::ProcessPipelineService, '#execute' do
end
before do
+ stub_ci_pipeline_to_return_yaml_file
+
stub_not_protect_default_branch
project.add_developer(user)
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 97a563c1ce1..aa7cc268dd7 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -370,10 +370,89 @@ module Ci
it_behaves_like 'validation is not active'
end
end
+ end
- def execute(runner)
- described_class.new(runner).execute.build
+ describe '#register_success' do
+ let!(:current_time) { Time.new(2018, 4, 5, 14, 0, 0) }
+ let!(:attempt_counter) { double('Gitlab::Metrics::NullMetric') }
+ let!(:job_queue_duration_seconds) { double('Gitlab::Metrics::NullMetric') }
+
+ before do
+ allow(Time).to receive(:now).and_return(current_time)
+
+ # Stub defaults for any metrics other than the ones we're testing
+ allow(Gitlab::Metrics).to receive(:counter)
+ .with(any_args)
+ .and_return(Gitlab::Metrics::NullMetric.instance)
+ allow(Gitlab::Metrics).to receive(:histogram)
+ .with(any_args)
+ .and_return(Gitlab::Metrics::NullMetric.instance)
+
+ # Stub tested metrics
+ allow(Gitlab::Metrics).to receive(:counter)
+ .with(:job_register_attempts_total, anything)
+ .and_return(attempt_counter)
+ allow(Gitlab::Metrics).to receive(:histogram)
+ .with(:job_queue_duration_seconds, anything, anything, anything)
+ .and_return(job_queue_duration_seconds)
+
+ project.update(shared_runners_enabled: true)
+ pending_job.update(created_at: current_time - 3600, queued_at: current_time - 1800)
end
+
+ shared_examples 'metrics collector' do
+ it 'increments attempt counter' do
+ allow(job_queue_duration_seconds).to receive(:observe)
+ expect(attempt_counter).to receive(:increment)
+
+ execute(runner)
+ end
+
+ it 'counts job queuing time histogram with expected labels' do
+ allow(attempt_counter).to receive(:increment)
+ expect(job_queue_duration_seconds).to receive(:observe)
+ .with({ shared_runner: expected_shared_runner,
+ jobs_running_for_project: expected_jobs_running_for_project_first_job }, 1800)
+
+ execute(runner)
+ end
+
+ context 'when project already has running jobs' do
+ let!(:build2) { create( :ci_build, :running, pipeline: pipeline, runner: shared_runner) }
+ let!(:build3) { create( :ci_build, :running, pipeline: pipeline, runner: shared_runner) }
+
+ it 'counts job queuing time histogram with expected labels' do
+ allow(attempt_counter).to receive(:increment)
+ expect(job_queue_duration_seconds).to receive(:observe)
+ .with({ shared_runner: expected_shared_runner,
+ jobs_running_for_project: expected_jobs_running_for_project_third_job }, 1800)
+
+ execute(runner)
+ end
+ end
+ end
+
+ context 'when shared runner is used' do
+ let(:runner) { shared_runner }
+ let(:expected_shared_runner) { true }
+ let(:expected_jobs_running_for_project_first_job) { 0 }
+ let(:expected_jobs_running_for_project_third_job) { 2 }
+
+ it_behaves_like 'metrics collector'
+ end
+
+ context 'when specific runner is used' do
+ let(:runner) { specific_runner }
+ let(:expected_shared_runner) { false }
+ let(:expected_jobs_running_for_project_first_job) { '+Inf' }
+ let(:expected_jobs_running_for_project_third_job) { '+Inf' }
+
+ it_behaves_like 'metrics collector'
+ end
+ end
+
+ def execute(runner)
+ described_class.new(runner).execute.build
end
end
end
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index a06397a0782..8de0bdf92e2 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -5,7 +5,11 @@ describe Ci::RetryBuildService do
set(:project) { create(:project) }
set(:pipeline) { create(:ci_pipeline, project: project) }
- let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:stage) do
+ Ci::Stage.create!(project: project, pipeline: pipeline, name: 'test')
+ end
+
+ let(:build) { create(:ci_build, pipeline: pipeline, stage_id: stage.id) }
let(:service) do
described_class.new(project, user)
@@ -17,38 +21,39 @@ describe Ci::RetryBuildService do
%i[id status user token coverage trace runner artifacts_expire_at
artifacts_file artifacts_metadata artifacts_size created_at
updated_at started_at finished_at queued_at erased_by
- erased_at auto_canceled_by job_artifacts job_artifacts_archive job_artifacts_metadata].freeze
+ erased_at auto_canceled_by job_artifacts job_artifacts_archive
+ job_artifacts_metadata job_artifacts_trace].freeze
IGNORE_ACCESSORS =
%i[type lock_version target_url base_tags trace_sections
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 retried failure_reason].freeze
+ user_id auto_canceled_by_id retried failure_reason
+ artifacts_file_store artifacts_metadata_store
+ metadata].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(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
let(:build) do
create(:ci_build, :failed, :artifacts, :expired, :erased,
:queued, :coverage, :tags, :allowed_to_fail, :on_tag,
- :triggered, :trace, :teardown_environment,
- description: 'my-job', stage: 'test', pipeline: pipeline,
- auto_canceled_by: create(:ci_empty_pipeline, project: project)) do |build|
- ##
- # TODO, workaround for FactoryBot limitation when having both
- # stage (text) and stage_id (integer) columns in the table.
- build.stage_id = stage.id
- end
+ :triggered, :trace_artifact, :teardown_environment,
+ description: 'my-job', stage: 'test', stage_id: stage.id,
+ pipeline: pipeline, auto_canceled_by: another_pipeline)
+ end
+
+ before do
+ # Make sure that build has both `stage_id` and `stage` because FactoryBot
+ # can reset one of the fields when assigning another. We plan to deprecate
+ # and remove legacy `stage` column in the future.
+ build.update_attributes(stage: 'test', stage_id: stage.id)
end
describe 'clone accessors' do
CLONE_ACCESSORS.each do |attribute|
it "clones #{attribute} build attribute" do
+ expect(build.send(attribute)).not_to be_nil
expect(new_build.send(attribute)).not_to be_nil
expect(new_build.send(attribute)).to eq build.send(attribute)
end
@@ -121,10 +126,12 @@ describe Ci::RetryBuildService do
context 'when there are subsequent builds that are skipped' do
let!(:subsequent_build) do
- create(:ci_build, :skipped, stage_idx: 1, pipeline: pipeline)
+ create(:ci_build, :skipped, stage_idx: 2,
+ pipeline: pipeline,
+ stage: 'deploy')
end
- it 'resumes pipeline processing in subsequent stages' do
+ it 'resumes pipeline processing in a subsequent stage' do
service.execute(build)
expect(subsequent_build.reload).to be_created
diff --git a/spec/services/ci/stop_environments_service_spec.rb b/spec/services/ci/stop_environments_service_spec.rb
index e2a9ed27e87..3fc4e499b0c 100644
--- a/spec/services/ci/stop_environments_service_spec.rb
+++ b/spec/services/ci/stop_environments_service_spec.rb
@@ -15,7 +15,7 @@ describe Ci::StopEnvironmentsService do
context 'when user has permission to stop environment' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'when environment is associated with removed branch' do
@@ -57,7 +57,7 @@ describe Ci::StopEnvironmentsService do
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]
+ project.add_guest(user)
end
it 'does not stop environment' do
@@ -86,7 +86,7 @@ describe Ci::StopEnvironmentsService do
context 'when user has permission to stop environments' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'does not stop environment' do
diff --git a/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb b/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb
new file mode 100644
index 00000000000..bf038595a4d
--- /dev/null
+++ b/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb
@@ -0,0 +1,73 @@
+require 'spec_helper'
+
+describe Clusters::Applications::CheckIngressIpAddressService do
+ let(:application) { create(:clusters_applications_ingress, :installed) }
+ let(:service) { described_class.new(application) }
+ let(:kubeclient) { double(::Kubeclient::Client, get_service: kube_service) }
+ let(:ingress) { [{ ip: '111.222.111.222' }] }
+ let(:exclusive_lease) { instance_double(Gitlab::ExclusiveLease, try_obtain: true) }
+
+ let(:kube_service) do
+ ::Kubeclient::Resource.new(
+ {
+ status: {
+ loadBalancer: {
+ ingress: ingress
+ }
+ }
+ }
+ )
+ end
+
+ subject { service.execute }
+
+ before do
+ allow(application.cluster).to receive(:kubeclient).and_return(kubeclient)
+ allow(Gitlab::ExclusiveLease)
+ .to receive(:new)
+ .with("check_ingress_ip_address_service:#{application.id}", timeout: 15.seconds.to_i)
+ .and_return(exclusive_lease)
+ end
+
+ describe '#execute' do
+ context 'when the ingress ip address is available' do
+ it 'updates the external_ip for the app' do
+ subject
+
+ expect(application.external_ip).to eq('111.222.111.222')
+ end
+ end
+
+ context 'when the ingress ip address is not available' do
+ let(:ingress) { nil }
+
+ it 'does not error' do
+ subject
+ end
+ end
+
+ context 'when the exclusive lease cannot be obtained' do
+ before do
+ allow(exclusive_lease)
+ .to receive(:try_obtain)
+ .and_return(false)
+ end
+
+ it 'does not call kubeclient' do
+ subject
+
+ expect(kubeclient).not_to have_received(:get_service)
+ end
+ end
+
+ context 'when there is already an external_ip' do
+ let(:application) { create(:clusters_applications_ingress, :installed, external_ip: '001.111.002.111') }
+
+ it 'does not call kubeclient' do
+ subject
+
+ expect(kubeclient).not_to have_received(:get_service)
+ end
+ end
+ end
+end
diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb
index 75fc05d36e9..6894c1797b0 100644
--- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb
+++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Clusters::Applications::CheckInstallationProgressService do
RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze
- let(:application) { create(:cluster_applications_helm, :installing) }
+ let(:application) { create(:clusters_applications_helm, :installing) }
let(:service) { described_class.new(application) }
let(:phase) { Gitlab::Kubernetes::Pod::UNKNOWN }
let(:errors) { nil }
@@ -33,7 +33,7 @@ describe Clusters::Applications::CheckInstallationProgressService do
end
context 'when timeouted' do
- let(:application) { create(:cluster_applications_helm, :timeouted) }
+ let(:application) { create(:clusters_applications_helm, :timeouted) }
it_behaves_like 'a terminated installation'
diff --git a/spec/services/clusters/applications/install_service_spec.rb b/spec/services/clusters/applications/install_service_spec.rb
index 054a49ffedf..93199964a0e 100644
--- a/spec/services/clusters/applications/install_service_spec.rb
+++ b/spec/services/clusters/applications/install_service_spec.rb
@@ -2,17 +2,19 @@ require 'spec_helper'
describe Clusters::Applications::InstallService do
describe '#execute' do
- let(:application) { create(:cluster_applications_helm, :scheduled) }
+ let(:application) { create(:clusters_applications_helm, :scheduled) }
+ let!(:install_command) { application.install_command }
let(:service) { described_class.new(application) }
- let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm) }
+ let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm::Api) }
before do
+ allow(service).to receive(:install_command).and_return(install_command)
allow(service).to receive(:helm_api).and_return(helm_client)
end
context 'when there are no errors' do
before do
- expect(helm_client).to receive(:install).with(application.install_command)
+ expect(helm_client).to receive(:install).with(install_command)
allow(ClusterWaitForAppInstallationWorker).to receive(:perform_in).and_return(nil)
end
@@ -32,8 +34,8 @@ describe Clusters::Applications::InstallService do
context 'when k8s cluster communication fails' do
before do
- error = KubeException.new(500, 'system failure', nil)
- expect(helm_client).to receive(:install).with(application.install_command).and_raise(error)
+ error = Kubeclient::HttpError.new(500, 'system failure', nil)
+ expect(helm_client).to receive(:install).with(install_command).and_raise(error)
end
it 'make the application errored' do
@@ -45,7 +47,7 @@ describe Clusters::Applications::InstallService do
end
context 'when application cannot be persisted' do
- let(:application) { build(:cluster_applications_helm, :scheduled) }
+ let(:application) { build(:clusters_applications_helm, :scheduled) }
it 'make the application errored' do
expect(application).to receive(:make_installing!).once.and_raise(ActiveRecord::RecordInvalid)
diff --git a/spec/services/clusters/applications/schedule_installation_service_spec.rb b/spec/services/clusters/applications/schedule_installation_service_spec.rb
index 047a6e44dab..473dbcbb692 100644
--- a/spec/services/clusters/applications/schedule_installation_service_spec.rb
+++ b/spec/services/clusters/applications/schedule_installation_service_spec.rb
@@ -34,7 +34,7 @@ describe Clusters::Applications::ScheduleInstallationService do
end
context 'when installation is already in progress' do
- let(:application) { create(:cluster_applications_helm, :installing) }
+ let(:application) { create(:clusters_applications_helm, :installing) }
let(:cluster) { application.cluster }
it_behaves_like 'a failing service'
diff --git a/spec/services/clusters/create_service_spec.rb b/spec/services/clusters/create_service_spec.rb
index e2e64659dfa..1c2f9c5cf43 100644
--- a/spec/services/clusters/create_service_spec.rb
+++ b/spec/services/clusters/create_service_spec.rb
@@ -82,7 +82,7 @@ describe Clusters::CreateService do
context 'when project has a cluster' do
include_context 'valid params'
- let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
+ let!(:cluster) { create(:cluster, :provided_by_gcp, :production_environment, projects: [project]) }
it 'does not create a cluster' do
expect(ClusterProvisionWorker).not_to receive(:perform_async)
diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb
index 08267d6e6a0..b9bfbb11511 100644
--- a/spec/services/create_deployment_service_spec.rb
+++ b/spec/services/create_deployment_service_spec.rb
@@ -266,7 +266,7 @@ describe CreateDeploymentService do
context "while updating the 'first_deployed_to_production_at' time" do
before do
- merge_request.mark_as_merged
+ merge_request.metrics.update!(merged_at: Time.now)
end
context "for merge requests merged before the current deploy" do
diff --git a/spec/services/delete_branch_service_spec.rb b/spec/services/delete_branch_service_spec.rb
index 19855c9bee2..9c9fba030e7 100644
--- a/spec/services/delete_branch_service_spec.rb
+++ b/spec/services/delete_branch_service_spec.rb
@@ -9,7 +9,7 @@ describe DeleteBranchService do
describe '#execute' do
context 'when user has access to push to repository' do
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'removes the branch' do
diff --git a/spec/services/deploy_tokens/create_service_spec.rb b/spec/services/deploy_tokens/create_service_spec.rb
new file mode 100644
index 00000000000..3a2bbf1ecd1
--- /dev/null
+++ b/spec/services/deploy_tokens/create_service_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+describe DeployTokens::CreateService do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:deploy_token_params) { attributes_for(:deploy_token) }
+
+ describe '#execute' do
+ subject { described_class.new(project, user, deploy_token_params).execute }
+
+ context 'when the deploy token is valid' do
+ it 'should create a new DeployToken' do
+ expect { subject }.to change { DeployToken.count }.by(1)
+ end
+
+ it 'should create a new ProjectDeployToken' do
+ expect { subject }.to change { ProjectDeployToken.count }.by(1)
+ end
+
+ it 'returns a DeployToken' do
+ expect(subject).to be_an_instance_of DeployToken
+ end
+ end
+
+ context 'when expires at date is not passed' do
+ let(:deploy_token_params) { attributes_for(:deploy_token, expires_at: '') }
+
+ it 'should set Forever.date' do
+ expect(subject.read_attribute(:expires_at)).to eq(Forever.date)
+ end
+ end
+
+ context 'when the deploy token is invalid' do
+ let(:deploy_token_params) { attributes_for(:deploy_token, read_repository: false, read_registry: false) }
+
+ it 'should not create a new DeployToken' do
+ expect { subject }.not_to change { DeployToken.count }
+ end
+
+ it 'should not create a new ProjectDeployToken' do
+ expect { subject }.not_to change { ProjectDeployToken.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/discussions/resolve_service_spec.rb b/spec/services/discussions/resolve_service_spec.rb
index ab8df7b74cd..3895a0b3aea 100644
--- a/spec/services/discussions/resolve_service_spec.rb
+++ b/spec/services/discussions/resolve_service_spec.rb
@@ -9,7 +9,7 @@ describe Discussions::ResolveService do
let(:service) { described_class.new(discussion.noteable.project, user, merge_request: merge_request) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it "doesn't resolve discussions the user can't resolve" do
diff --git a/spec/services/files/create_service_spec.rb b/spec/services/files/create_service_spec.rb
new file mode 100644
index 00000000000..abe99b9e794
--- /dev/null
+++ b/spec/services/files/create_service_spec.rb
@@ -0,0 +1,78 @@
+require "spec_helper"
+
+describe Files::CreateService do
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
+ let(:user) { create(:user) }
+ let(:file_content) { 'Test file content' }
+ let(:branch_name) { project.default_branch }
+ let(:start_branch) { branch_name }
+
+ let(:commit_params) do
+ {
+ file_path: file_path,
+ commit_message: "Update File",
+ file_content: file_content,
+ file_content_encoding: "text",
+ start_project: project,
+ start_branch: start_branch,
+ branch_name: branch_name
+ }
+ end
+
+ subject { described_class.new(project, user, commit_params) }
+
+ before do
+ project.add_master(user)
+ end
+
+ describe "#execute" do
+ context 'when file matches LFS filter' do
+ let(:file_path) { 'test_file.lfs' }
+ let(:branch_name) { 'lfs' }
+
+ context 'with LFS disabled' do
+ it 'skips gitattributes check' do
+ expect(repository).not_to receive(:attributes_at)
+
+ subject.execute
+ end
+
+ it "doesn't create LFS pointers" do
+ subject.execute
+
+ blob = repository.blob_at('lfs', file_path)
+
+ expect(blob.data).not_to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE)
+ expect(blob.data).to eq(file_content)
+ end
+ end
+
+ context 'with LFS enabled' do
+ before do
+ allow(project).to receive(:lfs_enabled?).and_return(true)
+ end
+
+ it 'creates an LFS pointer' do
+ subject.execute
+
+ blob = repository.blob_at('lfs', file_path)
+
+ expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE)
+ end
+
+ it "creates an LfsObject with the file's content" do
+ subject.execute
+
+ expect(LfsObject.last.file.read).to eq file_content
+ end
+
+ it 'links the LfsObject to the project' do
+ expect do
+ subject.execute
+ end.to change { project.lfs_objects.count }.by(1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/files/delete_service_spec.rb b/spec/services/files/delete_service_spec.rb
index e9f8f0efe6b..ace5f293097 100644
--- a/spec/services/files/delete_service_spec.rb
+++ b/spec/services/files/delete_service_spec.rb
@@ -37,7 +37,7 @@ describe Files::DeleteService do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe "#execute" do
diff --git a/spec/services/files/multi_service_spec.rb b/spec/services/files/multi_service_spec.rb
index 085a28d267f..59984c10990 100644
--- a/spec/services/files/multi_service_spec.rb
+++ b/spec/services/files/multi_service_spec.rb
@@ -4,28 +4,30 @@ describe Files::MultiService do
subject { described_class.new(project, user, commit_params) }
let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
let(:user) { create(:user) }
let(:branch_name) { project.default_branch }
let(:original_file_path) { 'files/ruby/popen.rb' }
let(:new_file_path) { 'files/ruby/popen.rb' }
+ let(:file_content) { 'New content' }
let(:action) { 'update' }
let!(:original_commit_id) do
Gitlab::Git::Commit.last_for_path(project.repository, branch_name, original_file_path).sha
end
- let(:actions) do
- [
- {
- action: action,
- file_path: new_file_path,
- previous_path: original_file_path,
- content: 'New content',
- last_commit_id: original_commit_id
- }
- ]
+ let(:default_action) do
+ {
+ action: action,
+ file_path: new_file_path,
+ previous_path: original_file_path,
+ content: file_content,
+ last_commit_id: original_commit_id
+ }
end
+ let(:actions) { [default_action] }
+
let(:commit_params) do
{
commit_message: "Update File",
@@ -36,12 +38,12 @@ describe Files::MultiService do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe '#execute' do
context 'with a valid action' do
- it 'returns a hash with the :success status ' do
+ it 'returns a hash with the :success status' do
results = subject.execute
expect(results[:status]).to eq(:success)
@@ -51,7 +53,7 @@ describe Files::MultiService do
context 'with an invalid action' do
let(:action) { 'rename' }
- it 'returns a hash with the :error status ' do
+ it 'returns a hash with the :error status' do
results = subject.execute
expect(results[:status]).to eq(:error)
@@ -110,6 +112,56 @@ describe Files::MultiService do
end
end
+ context 'when creating a file matching an LFS filter' do
+ let(:action) { 'create' }
+ let(:branch_name) { 'lfs' }
+ let(:new_file_path) { 'test_file.lfs' }
+
+ before do
+ allow(project).to receive(:lfs_enabled?).and_return(true)
+ end
+
+ it 'creates an LFS pointer' do
+ subject.execute
+
+ blob = repository.blob_at('lfs', new_file_path)
+
+ expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE)
+ end
+
+ it "creates an LfsObject with the file's content" do
+ subject.execute
+
+ expect(LfsObject.last.file.read).to eq file_content
+ end
+
+ context 'with base64 encoded content' do
+ let(:raw_file_content) { 'Raw content' }
+ let(:file_content) { Base64.encode64(raw_file_content) }
+ let(:actions) { [default_action.merge(encoding: 'base64')] }
+
+ it 'creates an LFS pointer' do
+ subject.execute
+
+ blob = repository.blob_at('lfs', new_file_path)
+
+ expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE)
+ end
+
+ it "creates an LfsObject with the file's content" do
+ subject.execute
+
+ expect(LfsObject.last.file.read).to eq raw_file_content
+ end
+ end
+
+ it 'links the LfsObject to the project' do
+ expect do
+ subject.execute
+ end.to change { project.lfs_objects.count }.by(1)
+ end
+ end
+
context 'when file status validation is skipped' do
let(:action) { 'create' }
let(:new_file_path) { 'files/ruby/new_file.rb' }
diff --git a/spec/services/files/update_service_spec.rb b/spec/services/files/update_service_spec.rb
index 2b4f8cd42ee..16bfbdf3089 100644
--- a/spec/services/files/update_service_spec.rb
+++ b/spec/services/files/update_service_spec.rb
@@ -24,7 +24,7 @@ describe Files::UpdateService do
end
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe "#execute" do
@@ -72,13 +72,15 @@ describe Files::UpdateService do
end
end
- context 'when target branch is different than source branch' do
- let(:branch_name) { "#{project.default_branch}-new" }
+ context 'with gitaly disabled', :skip_gitaly_mock do
+ context 'when target branch is different than source branch' do
+ let(:branch_name) { "#{project.default_branch}-new" }
- it 'fires hooks only once' do
- expect(Gitlab::Git::HooksService).to receive(:new).once.and_call_original
+ it 'fires hooks only once' do
+ expect(Gitlab::Git::HooksService).to receive(:new).once.and_call_original
- subject.execute
+ subject.execute
+ end
end
end
end
diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb
index cc3d4e7da49..26fdf8d4b24 100644
--- a/spec/services/git_push_service_spec.rb
+++ b/spec/services/git_push_service_spec.rb
@@ -11,7 +11,7 @@ describe GitPushService, services: true do
let(:ref) { 'refs/heads/master' }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'Push branches' do
@@ -266,8 +266,8 @@ describe GitPushService, services: true do
let(:commit) { project.commit }
before do
- project.team << [commit_author, :developer]
- project.team << [user, :developer]
+ project.add_developer(commit_author)
+ project.add_developer(user)
allow(commit).to receive_messages(
safe_message: "this commit \n mentions #{issue.to_reference}",
@@ -323,8 +323,8 @@ describe GitPushService, services: true do
let(:commit_time) { Time.now }
before do
- project.team << [commit_author, :developer]
- project.team << [user, :developer]
+ project.add_developer(commit_author)
+ project.add_developer(user)
allow(commit).to receive_messages(
safe_message: "this commit \n mentions #{issue.to_reference}",
@@ -376,7 +376,7 @@ describe GitPushService, services: true do
allow_any_instance_of(ProcessCommitWorker).to receive(:build_commit)
.and_return(closing_commit)
- project.team << [commit_author, :master]
+ project.add_master(commit_author)
end
context "to default branches" do
diff --git a/spec/services/git_tag_push_service_spec.rb b/spec/services/git_tag_push_service_spec.rb
index 05695aa8188..33405d7a7ec 100644
--- a/spec/services/git_tag_push_service_spec.rb
+++ b/spec/services/git_tag_push_service_spec.rb
@@ -35,7 +35,7 @@ describe GitTagPushService do
before do
stub_ci_pipeline_to_return_yaml_file
- project.team << [user, :developer]
+ project.add_developer(user)
end
it "creates a new pipeline" do
diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb
index ac4b9c02ba7..e8216abb08b 100644
--- a/spec/services/groups/destroy_service_spec.rb
+++ b/spec/services/groups/destroy_service_spec.rb
@@ -6,7 +6,7 @@ describe Groups::DestroyService do
let!(:user) { create(:user) }
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
- let!(:project) { create(:project, namespace: group) }
+ let!(:project) { create(:project, :legacy_storage, namespace: group) }
let!(:notification_setting) { create(:notification_setting, source: group)}
let(:gitlab_shell) { Gitlab::Shell.new }
let(:remove_path) { group.path + "+#{group.id}+deleted" }
@@ -141,7 +141,7 @@ describe Groups::DestroyService do
end
context 'legacy storage' do
- let!(:project) { create(:project, :empty_repo, namespace: group) }
+ let!(:project) { create(:project, :legacy_storage, :empty_repo, namespace: group) }
it 'removes repository' do
expect(gitlab_shell.exists?(project.repository_storage_path, "#{project.disk_path}.git")).to be_falsey
@@ -149,7 +149,7 @@ describe Groups::DestroyService do
end
context 'hashed storage' do
- let!(:project) { create(:project, :hashed, :empty_repo, namespace: group) }
+ let!(:project) { create(:project, :empty_repo, namespace: group) }
it 'removes repository' do
expect(gitlab_shell.exists?(project.repository_storage_path, "#{project.disk_path}.git")).to be_falsey
diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb
new file mode 100644
index 00000000000..999677cfaaa
--- /dev/null
+++ b/spec/services/groups/transfer_service_spec.rb
@@ -0,0 +1,414 @@
+require 'rails_helper'
+
+describe Groups::TransferService, :postgresql do
+ let(:user) { create(:user) }
+ let(:new_parent_group) { create(:group, :public) }
+ let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
+ let(:transfer_service) { described_class.new(group, user) }
+
+ shared_examples 'ensuring allowed transfer for a group' do
+ context 'with other database than PostgreSQL' do
+ before do
+ allow(Group).to receive(:supports_nested_groups?).and_return(false)
+ end
+
+ it 'should return false' do
+ expect(transfer_service.execute(new_parent_group)).to be_falsy
+ end
+
+ it 'should add an error on group' do
+ transfer_service.execute(new_parent_group)
+ expect(transfer_service.error).to eq('Transfer failed: Database is not supported.')
+ end
+ end
+
+ context "when there's an exception on Gitlab shell directories" do
+ let(:new_parent_group) { create(:group, :public) }
+
+ before do
+ allow_any_instance_of(described_class).to receive(:update_group_attributes).and_raise(Gitlab::UpdatePathError, 'namespace directory cannot be moved')
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ end
+
+ it 'should return false' do
+ expect(transfer_service.execute(new_parent_group)).to be_falsy
+ end
+
+ it 'should add an error on group' do
+ transfer_service.execute(new_parent_group)
+ expect(transfer_service.error).to eq('Transfer failed: namespace directory cannot be moved')
+ end
+ end
+ end
+
+ describe '#execute' do
+ context 'when transforming a group into a root group' do
+ let!(:group) { create(:group, :public, :nested) }
+
+ it_behaves_like 'ensuring allowed transfer for a group'
+
+ context 'when the group is already a root group' do
+ let(:group) { create(:group, :public) }
+
+ it 'should add an error on group' do
+ transfer_service.execute(nil)
+ expect(transfer_service.error).to eq('Transfer failed: Group is already a root group.')
+ end
+ end
+
+ context 'when the user does not have the right policies' do
+ let!(:group_member) { create(:group_member, :guest, group: group, user: user) }
+
+ it "should return false" do
+ expect(transfer_service.execute(nil)).to be_falsy
+ end
+
+ it "should add an error on group" do
+ transfer_service.execute(new_parent_group)
+ expect(transfer_service.error).to eq("Transfer failed: You don't have enough permissions.")
+ end
+ end
+
+ context 'when there is a group with the same path' do
+ let!(:group) { create(:group, :public, :nested, path: 'not-unique') }
+
+ before do
+ create(:group, path: 'not-unique')
+ end
+
+ it 'should return false' do
+ expect(transfer_service.execute(nil)).to be_falsy
+ end
+
+ it 'should add an error on group' do
+ transfer_service.execute(nil)
+ expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup with the same path.')
+ end
+ end
+
+ context 'when the group is a subgroup and the transfer is valid' do
+ let!(:subgroup1) { create(:group, :private, parent: group) }
+ let!(:subgroup2) { create(:group, :internal, parent: group) }
+ let!(:project1) { create(:project, :repository, :private, namespace: group) }
+
+ before do
+ transfer_service.execute(nil)
+ group.reload
+ end
+
+ it 'should update group attributes' do
+ expect(group.parent).to be_nil
+ end
+
+ it 'should update group children path' do
+ group.children.each do |subgroup|
+ expect(subgroup.full_path).to eq("#{group.path}/#{subgroup.path}")
+ end
+ end
+
+ it 'should update group projects path' do
+ group.projects.each do |project|
+ expect(project.full_path).to eq("#{group.path}/#{project.path}")
+ end
+ end
+ end
+ end
+
+ context 'when transferring a subgroup into another group' do
+ let(:group) { create(:group, :public, :nested) }
+
+ it_behaves_like 'ensuring allowed transfer for a group'
+
+ context 'when the new parent group is the same as the previous parent group' do
+ let(:group) { create(:group, :public, :nested, parent: new_parent_group) }
+
+ it 'should return false' do
+ expect(transfer_service.execute(new_parent_group)).to be_falsy
+ end
+
+ it 'should add an error on group' do
+ transfer_service.execute(new_parent_group)
+ expect(transfer_service.error).to eq('Transfer failed: Group is already associated to the parent group.')
+ end
+ end
+
+ context 'when the user does not have the right policies' do
+ let!(:group_member) { create(:group_member, :guest, group: group, user: user) }
+
+ it "should return false" do
+ expect(transfer_service.execute(new_parent_group)).to be_falsy
+ end
+
+ it "should add an error on group" do
+ transfer_service.execute(new_parent_group)
+ expect(transfer_service.error).to eq("Transfer failed: You don't have enough permissions.")
+ end
+ end
+
+ context 'when the parent has a group with the same path' do
+ before do
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ group.update_attribute(:path, "not-unique")
+ create(:group, path: "not-unique", parent: new_parent_group)
+ end
+
+ it 'should return false' do
+ expect(transfer_service.execute(new_parent_group)).to be_falsy
+ end
+
+ it 'should add an error on group' do
+ transfer_service.execute(new_parent_group)
+ expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup with the same path.')
+ end
+ end
+
+ context 'when the parent group has a project with the same path' do
+ let!(:group) { create(:group, :public, :nested, path: 'foo') }
+
+ before do
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ create(:project, path: 'foo', namespace: new_parent_group)
+ group.update_attribute(:path, 'foo')
+ end
+
+ it 'should return false' do
+ expect(transfer_service.execute(new_parent_group)).to be_falsy
+ end
+
+ it 'should add an error on group' do
+ transfer_service.execute(new_parent_group)
+ expect(transfer_service.error).to eq('Transfer failed: Validation failed: Path has already been taken')
+ end
+ end
+
+ context 'when the group is allowed to be transferred' do
+ before do
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ transfer_service.execute(new_parent_group)
+ end
+
+ context 'when the group has a lower visibility than the parent group' do
+ let(:new_parent_group) { create(:group, :public) }
+ let(:group) { create(:group, :private, :nested) }
+
+ it 'should not update the visibility for the group' do
+ group.reload
+ expect(group.private?).to be_truthy
+ expect(group.visibility_level).not_to eq(new_parent_group.visibility_level)
+ end
+ end
+
+ context 'when the group has a higher visibility than the parent group' do
+ let(:new_parent_group) { create(:group, :private) }
+ let(:group) { create(:group, :public, :nested) }
+
+ it 'should update visibility level based on the parent group' do
+ group.reload
+ expect(group.private?).to be_truthy
+ expect(group.visibility_level).to eq(new_parent_group.visibility_level)
+ end
+ end
+
+ it 'should update visibility for the group based on the parent group' do
+ expect(group.visibility_level).to eq(new_parent_group.visibility_level)
+ end
+
+ it 'should update parent group to the new parent ' do
+ expect(group.parent).to eq(new_parent_group)
+ end
+
+ it 'should return the group as children of the new parent' do
+ expect(new_parent_group.children.count).to eq(1)
+ expect(new_parent_group.children.first).to eq(group)
+ end
+
+ it 'should create a redirect for the group' do
+ expect(group.redirect_routes.count).to eq(1)
+ end
+ end
+
+ context 'when transferring a group with group descendants' do
+ let!(:subgroup1) { create(:group, :private, parent: group) }
+ let!(:subgroup2) { create(:group, :internal, parent: group) }
+
+ before do
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ transfer_service.execute(new_parent_group)
+ end
+
+ it 'should update subgroups path' do
+ new_parent_path = new_parent_group.path
+ group.children.each do |subgroup|
+ expect(subgroup.full_path).to eq("#{new_parent_path}/#{group.path}/#{subgroup.path}")
+ end
+ end
+
+ it 'should create redirects for the subgroups' do
+ expect(group.redirect_routes.count).to eq(1)
+ expect(subgroup1.redirect_routes.count).to eq(1)
+ expect(subgroup2.redirect_routes.count).to eq(1)
+ end
+
+ context 'when the new parent has a higher visibility than the children' do
+ it 'should not update the children visibility' do
+ expect(subgroup1.private?).to be_truthy
+ expect(subgroup2.internal?).to be_truthy
+ end
+ end
+
+ context 'when the new parent has a lower visibility than the children' do
+ let!(:subgroup1) { create(:group, :public, parent: group) }
+ let!(:subgroup2) { create(:group, :public, parent: group) }
+ let(:new_parent_group) { create(:group, :private) }
+
+ it 'should update children visibility to match the new parent' do
+ group.children.each do |subgroup|
+ expect(subgroup.private?).to be_truthy
+ end
+ end
+ end
+ end
+
+ context 'when transferring a group with project descendants' do
+ let!(:project1) { create(:project, :repository, :private, namespace: group) }
+ let!(:project2) { create(:project, :repository, :internal, namespace: group) }
+
+ before do
+ TestEnv.clean_test_path
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ transfer_service.execute(new_parent_group)
+ end
+
+ it 'should update projects path' do
+ new_parent_path = new_parent_group.path
+ group.projects.each do |project|
+ expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.name}")
+ end
+ end
+
+ it 'should create permanent redirects for the projects' do
+ expect(group.redirect_routes.count).to eq(1)
+ expect(project1.redirect_routes.count).to eq(1)
+ expect(project2.redirect_routes.count).to eq(1)
+ end
+
+ context 'when the new parent has a higher visibility than the projects' do
+ it 'should not update projects visibility' do
+ expect(project1.private?).to be_truthy
+ expect(project2.internal?).to be_truthy
+ end
+ end
+
+ context 'when the new parent has a lower visibility than the projects' do
+ let!(:project1) { create(:project, :repository, :public, namespace: group) }
+ let!(:project2) { create(:project, :repository, :public, namespace: group) }
+ let(:new_parent_group) { create(:group, :private) }
+
+ it 'should update projects visibility to match the new parent' do
+ group.projects.each do |project|
+ expect(project.private?).to be_truthy
+ end
+ end
+ end
+ end
+
+ context 'when transferring a group with subgroups & projects descendants' do
+ let!(:project1) { create(:project, :repository, :private, namespace: group) }
+ let!(:project2) { create(:project, :repository, :internal, namespace: group) }
+ let!(:subgroup1) { create(:group, :private, parent: group) }
+ let!(:subgroup2) { create(:group, :internal, parent: group) }
+
+ before do
+ TestEnv.clean_test_path
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ transfer_service.execute(new_parent_group)
+ end
+
+ it 'should update subgroups path' do
+ new_parent_path = new_parent_group.path
+ group.children.each do |subgroup|
+ expect(subgroup.full_path).to eq("#{new_parent_path}/#{group.path}/#{subgroup.path}")
+ end
+ end
+
+ it 'should update projects path' do
+ new_parent_path = new_parent_group.path
+ group.projects.each do |project|
+ expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.name}")
+ end
+ end
+
+ it 'should create redirect for the subgroups and projects' do
+ expect(group.redirect_routes.count).to eq(1)
+ expect(subgroup1.redirect_routes.count).to eq(1)
+ expect(subgroup2.redirect_routes.count).to eq(1)
+ expect(project1.redirect_routes.count).to eq(1)
+ expect(project2.redirect_routes.count).to eq(1)
+ end
+ end
+
+ context 'when transfering a group with nested groups and projects' do
+ let!(:group) { create(:group, :public) }
+ let!(:project1) { create(:project, :repository, :private, namespace: group) }
+ let!(:subgroup1) { create(:group, :private, parent: group) }
+ let!(:nested_subgroup) { create(:group, :private, parent: subgroup1) }
+ let!(:nested_project) { create(:project, :repository, :private, namespace: subgroup1) }
+
+ before do
+ TestEnv.clean_test_path
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ transfer_service.execute(new_parent_group)
+ end
+
+ it 'should update subgroups path' do
+ new_base_path = "#{new_parent_group.path}/#{group.path}"
+ group.children.each do |children|
+ expect(children.full_path).to eq("#{new_base_path}/#{children.path}")
+ end
+
+ new_base_path = "#{new_parent_group.path}/#{group.path}/#{subgroup1.path}"
+ subgroup1.children.each do |children|
+ expect(children.full_path).to eq("#{new_base_path}/#{children.path}")
+ end
+ end
+
+ it 'should update projects path' do
+ new_parent_path = "#{new_parent_group.path}/#{group.path}"
+ subgroup1.projects.each do |project|
+ project_full_path = "#{new_parent_path}/#{project.namespace.path}/#{project.name}"
+ expect(project.full_path).to eq(project_full_path)
+ end
+ end
+
+ it 'should create redirect for the subgroups and projects' do
+ expect(group.redirect_routes.count).to eq(1)
+ expect(project1.redirect_routes.count).to eq(1)
+ expect(subgroup1.redirect_routes.count).to eq(1)
+ expect(nested_subgroup.redirect_routes.count).to eq(1)
+ expect(nested_project.redirect_routes.count).to eq(1)
+ end
+ end
+
+ context 'when updating the group goes wrong' do
+ let!(:subgroup1) { create(:group, :public, parent: group) }
+ let!(:subgroup2) { create(:group, :public, parent: group) }
+ let(:new_parent_group) { create(:group, :private) }
+ let!(:project1) { create(:project, :repository, :public, namespace: group) }
+
+ before do
+ allow(group).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(group))
+ TestEnv.clean_test_path
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ transfer_service.execute(new_parent_group)
+ end
+
+ it 'should restore group and projects visibility' do
+ subgroup1.reload
+ project1.reload
+ expect(subgroup1.public?).to be_truthy
+ expect(project1.public?).to be_truthy
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb
index bdaab88d673..53c85f73cde 100644
--- a/spec/services/issuable/bulk_update_service_spec.rb
+++ b/spec/services/issuable/bulk_update_service_spec.rb
@@ -54,7 +54,7 @@ describe Issuable::BulkUpdateService do
context 'when the new assignee ID is a valid user' do
it 'succeeds' do
new_assignee = create(:user)
- project.team << [new_assignee, :developer]
+ project.add_developer(new_assignee)
result = bulk_update(merge_request, assignee_id: new_assignee.id)
@@ -64,7 +64,7 @@ describe Issuable::BulkUpdateService do
it 'updates the assignee to the user ID passed' do
assignee = create(:user)
- project.team << [assignee, :developer]
+ project.add_developer(assignee)
expect { bulk_update(merge_request, assignee_id: assignee.id) }
.to change { merge_request.reload.assignee }.from(user).to(assignee)
@@ -92,7 +92,7 @@ describe Issuable::BulkUpdateService do
context 'when the new assignee ID is a valid user' do
it 'succeeds' do
new_assignee = create(:user)
- project.team << [new_assignee, :developer]
+ project.add_developer(new_assignee)
result = bulk_update(issue, assignee_ids: [new_assignee.id])
@@ -102,7 +102,7 @@ describe Issuable::BulkUpdateService do
it 'updates the assignee to the user ID passed' do
assignee = create(:user)
- project.team << [assignee, :developer]
+ project.add_developer(assignee)
expect { bulk_update(issue, assignee_ids: [assignee.id]) }
.to change { issue.reload.assignees.first }.from(user).to(assignee)
end
diff --git a/spec/services/issuable/destroy_service_spec.rb b/spec/services/issuable/destroy_service_spec.rb
index 0a3647a814f..8ccbba7fa58 100644
--- a/spec/services/issuable/destroy_service_spec.rb
+++ b/spec/services/issuable/destroy_service_spec.rb
@@ -8,7 +8,7 @@ describe Issuable::DestroyService do
describe '#execute' do
context 'when issuable is an issue' do
- let!(:issue) { create(:issue, project: project, author: user) }
+ let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
it 'destroys the issue' do
expect { service.execute(issue) }.to change { project.issues.count }.by(-1)
@@ -26,10 +26,15 @@ describe Issuable::DestroyService do
expect { service.execute(issue) }
.to change { user.todos_pending_count }.from(1).to(0)
end
+
+ it 'invalidates the issues count cache for the assignees' do
+ expect_any_instance_of(User).to receive(:invalidate_cache_counts).once
+ service.execute(issue)
+ end
end
context 'when issuable is a merge request' do
- let!(:merge_request) { create(:merge_request, target_project: project, source_project: project, author: user) }
+ let!(:merge_request) { create(:merge_request, target_project: project, source_project: project, author: user, assignee: user) }
it 'destroys the merge request' do
expect { service.execute(merge_request) }.to change { project.merge_requests.count }.by(-1)
@@ -41,6 +46,11 @@ describe Issuable::DestroyService do
service.execute(merge_request)
end
+ it 'invalidates the merge request caches for the MR assignee' do
+ expect_any_instance_of(User).to receive(:invalidate_cache_counts).once
+ service.execute(merge_request)
+ end
+
it 'updates the todo caches for users with todos on the merge request' do
create(:todo, target: merge_request, user: user, author: user, project: project)
diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb
index 03f76bd428d..248e7d5a389 100644
--- a/spec/services/issues/build_service_spec.rb
+++ b/spec/services/issues/build_service_spec.rb
@@ -5,7 +5,7 @@ describe Issues::BuildService do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
context 'for a single discussion' do
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 5c27e8fd561..7ae49c06896 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -4,14 +4,14 @@ describe Issues::CloseService do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest) { create(:user) }
- let(:issue) { create(:issue, assignees: [user2]) }
+ let(:issue) { create(:issue, assignees: [user2], author: create(:user)) }
let(:project) { issue.project }
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
before do
- project.team << [user, :master]
- project.team << [user2, :developer]
- project.team << [guest, :guest]
+ project.add_master(user)
+ project.add_developer(user2)
+ project.add_guest(guest)
end
describe '#execute' do
@@ -67,6 +67,10 @@ describe Issues::CloseService do
expect(issue).to be_closed
end
+ it 'records closed user' do
+ expect(issue.closed_by_id).to be(user.id)
+ end
+
it 'sends email to user2 about assign of new issue' do
email = ActionMailer::Base.deliveries.last
expect(email.to.first).to eq(user2.email)
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index d86da244520..79bcdc41fb0 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -13,8 +13,8 @@ describe Issues::CreateService do
let(:labels) { create_pair(:label, project: project) }
before do
- project.team << [user, :master]
- project.team << [assignee, :master]
+ project.add_master(user)
+ project.add_master(assignee)
end
let(:opts) do
@@ -43,7 +43,7 @@ describe Issues::CreateService do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
end
it 'filters out params that cannot be set without the :admin_issue permission' do
@@ -130,7 +130,7 @@ describe Issues::CreateService do
end
it 'invalidates open issues counter for assignees when issue is assigned' do
- project.team << [assignee, :master]
+ project.add_master(assignee)
described_class.new(project, user, opts).execute
@@ -160,7 +160,7 @@ describe Issues::CreateService do
context 'issue create service' do
context 'assignees' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'removes assignee when user id is invalid' do
@@ -180,7 +180,7 @@ describe Issues::CreateService do
end
it 'saves assignee when user id is valid' do
- project.team << [assignee, :master]
+ project.add_master(assignee)
opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] }
issue = described_class.new(project, user, opts).execute
@@ -224,8 +224,8 @@ describe Issues::CreateService do
end
before do
- project.team << [user, :master]
- project.team << [assignee, :master]
+ project.add_master(user)
+ project.add_master(assignee)
end
it 'assigns and sets milestone to issuable from command' do
@@ -242,7 +242,7 @@ describe Issues::CreateService do
let(:project) { merge_request.source_project }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe 'for a single discussion' do
diff --git a/spec/services/issues/fetch_referenced_merge_requests_service_spec.rb b/spec/services/issues/fetch_referenced_merge_requests_service_spec.rb
new file mode 100644
index 00000000000..4e58179f45f
--- /dev/null
+++ b/spec/services/issues/fetch_referenced_merge_requests_service_spec.rb
@@ -0,0 +1,35 @@
+require 'spec_helper.rb'
+
+describe Issues::FetchReferencedMergeRequestsService do
+ let(:project) { create(:project) }
+ let(:issue) { create(:issue, project: project) }
+ let(:other_project) { create(:project) }
+
+ let(:mr) { create(:merge_request, source_project: project, target_project: project, id: 2)}
+ let(:other_mr) { create(:merge_request, source_project: other_project, target_project: other_project, id: 1)}
+
+ let(:user) { create(:user) }
+ let(:service) { described_class.new(project, user) }
+
+ context 'with mentioned merge requests' do
+ it 'returns a list of sorted merge requests' do
+ allow(issue).to receive(:referenced_merge_requests).with(user).and_return([other_mr, mr])
+
+ mrs, closed_by_mrs = service.execute(issue)
+
+ expect(mrs).to match_array([mr, other_mr])
+ expect(closed_by_mrs).to match_array([])
+ end
+ end
+
+ context 'with closed-by merge requests' do
+ it 'returns a list of sorted merge requests' do
+ allow(issue).to receive(:closed_by_merge_requests).with(user).and_return([other_mr, mr])
+
+ mrs, closed_by_mrs = service.execute(issue)
+
+ expect(mrs).to match_array([])
+ expect(closed_by_mrs).to match_array([mr, other_mr])
+ end
+ end
+end
diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb
index f2b35a8fadf..a9aee9e100f 100644
--- a/spec/services/issues/move_service_spec.rb
+++ b/spec/services/issues/move_service_spec.rb
@@ -20,8 +20,8 @@ describe Issues::MoveService do
shared_context 'user can move issue' do
before do
- old_project.team << [user, :reporter]
- new_project.team << [user, :reporter]
+ old_project.add_reporter(user)
+ new_project.add_reporter(user)
labels = Array.new(2) { |x| "label%d" % (x + 1) }
@@ -179,13 +179,15 @@ describe Issues::MoveService do
{ system: true, note: 'Some system note' },
{ system: false, note: 'Some comment 2' }]
end
-
+ let(:award_names) { %w(thumbsup thumbsdown facepalm) }
let(:notes_contents) { notes_params.map { |n| n[:note] } }
before do
note_params = { noteable: old_issue, project: old_project, author: author }
- notes_params.each do |note|
- create(:note, note_params.merge(note))
+ notes_params.each_with_index do |note, index|
+ new_note = create(:note, note_params.merge(note))
+ award_emoji_params = { awardable: new_note, name: award_names[index] }
+ create(:award_emoji, award_emoji_params)
end
end
@@ -199,6 +201,10 @@ describe Issues::MoveService do
expect(all_notes.pluck(:note).first(3)).to eq notes_contents
end
+ it 'creates new emojis for the new notes' do
+ expect(all_notes.map(&:award_emoji).to_a.flatten.map(&:name)).to eq award_names
+ end
+
it 'adds a system note about move after rewritten notes' do
expect(system_notes.last.note).to match /^moved from/
end
@@ -226,6 +232,28 @@ describe Issues::MoveService do
end
end
+ context 'issue with assignee' do
+ let(:assignee) { create(:user) }
+
+ before do
+ old_issue.assignees = [assignee]
+ end
+
+ it 'preserves assignee with access to the new issue' do
+ new_project.add_reporter(assignee)
+
+ new_issue = move_service.execute(old_issue, new_project)
+
+ expect(new_issue.assignees).to eq([assignee])
+ end
+
+ it 'ignores assignee without access to the new issue' do
+ new_issue = move_service.execute(old_issue, new_project)
+
+ expect(new_issue.assignees).to be_empty
+ end
+ end
+
context 'notes with references' do
before do
create(:merge_request, source_project: old_project)
@@ -244,7 +272,7 @@ describe Issues::MoveService do
context 'issue description with uploads' do
let(:uploader) { build(:file_uploader, project: old_project) }
- let(:description) { "Text and #{uploader.to_markdown}" }
+ let(:description) { "Text and #{uploader.markdown_link}" }
include_context 'issue move executed'
@@ -289,6 +317,20 @@ describe Issues::MoveService do
.to raise_error(StandardError, /Cannot move issue/)
end
end
+
+ context 'project issue hooks' do
+ let!(:hook) { create(:project_hook, project: old_project, issues_events: true) }
+
+ it 'executes project issue hooks' do
+ allow_any_instance_of(WebHookService).to receive(:execute)
+
+ # Ideally, we'd test that `WebHookWorker.jobs.size` increased by 1,
+ # but since the entire spec run takes place in a transaction, we never
+ # actually get to the `after_commit` hook that queues these jobs.
+ expect { move_service.execute(old_issue, new_project) }
+ .not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError
+ end
+ end
end
describe 'move permissions' do
@@ -301,7 +343,7 @@ describe Issues::MoveService do
context 'user is reporter only in new project' do
before do
- new_project.team << [user, :reporter]
+ new_project.add_reporter(user)
end
it { expect { move }.to raise_error(StandardError, /permissions/) }
@@ -309,7 +351,7 @@ describe Issues::MoveService do
context 'user is reporter only in old project' do
before do
- old_project.team << [user, :reporter]
+ old_project.add_reporter(user)
end
it { expect { move }.to raise_error(StandardError, /permissions/) }
@@ -317,8 +359,8 @@ describe Issues::MoveService do
context 'user is reporter in one project and guest in another' do
before do
- new_project.team << [user, :guest]
- old_project.team << [user, :reporter]
+ new_project.add_guest(user)
+ old_project.add_reporter(user)
end
it { expect { move }.to raise_error(StandardError, /permissions/) }
@@ -346,8 +388,8 @@ describe Issues::MoveService do
context 'movable issue with no assigned labels' do
before do
- old_project.team << [user, :reporter]
- new_project.team << [user, :reporter]
+ old_project.add_reporter(user)
+ new_project.add_reporter(user)
labels = Array.new(2) { |x| "label%d" % (x + 1) }
diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb
index 48fc98b3b2f..42e5d544f4c 100644
--- a/spec/services/issues/reopen_service_spec.rb
+++ b/spec/services/issues/reopen_service_spec.rb
@@ -8,7 +8,7 @@ describe Issues::ReopenService do
context 'when user is not authorized to reopen issue' do
before do
guest = create(:user)
- project.team << [guest, :guest]
+ project.add_guest(guest)
perform_enqueued_jobs do
described_class.new(project, guest).execute(issue)
@@ -24,7 +24,7 @@ describe Issues::ReopenService do
let(:user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'invalidates counter cache for assignees' do
diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb
index 67a86c50fc1..13accc6ae1b 100644
--- a/spec/services/issues/resolve_discussions_spec.rb
+++ b/spec/services/issues/resolve_discussions_spec.rb
@@ -14,7 +14,7 @@ describe Issues::ResolveDiscussions do
let(:user) { create(:user) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
describe "for resolving discussions" do
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index f07b81e842a..23b1134b5a3 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -13,13 +13,14 @@ describe Issues::UpdateService, :mailer do
create(:issue, title: 'Old title',
description: "for #{user2.to_reference}",
assignee_ids: [user3.id],
- project: project)
+ project: project,
+ author: create(:user))
end
before do
- project.team << [user, :master]
- project.team << [user2, :developer]
- project.team << [user3, :developer]
+ project.add_master(user)
+ project.add_developer(user2)
+ project.add_developer(user3)
end
describe 'execute' do
@@ -96,10 +97,43 @@ describe Issues::UpdateService, :mailer do
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
+ context 'when moving issue between issues from different projects', :nested_groups do
+ let(:group) { create(:group) }
+ let(:subgroup) { create(:group, parent: group) }
+
+ let(:project_1) { create(:project, namespace: group) }
+ let(:project_2) { create(:project, namespace: group) }
+ let(:project_3) { create(:project, namespace: subgroup) }
+
+ let(:issue_1) { create(:issue, project: project_1) }
+ let(:issue_2) { create(:issue, project: project_2) }
+ let(:issue_3) { create(:issue, project: project_3) }
+
+ before do
+ group.add_developer(user)
+ end
+
+ it 'sorts issues as specified by parameters' do
+ # Moving all issues to end here like the last example won't work since
+ # all projects only have the same issue count
+ # so their relative_position will be the same.
+ issue_1.move_to_end
+ issue_2.move_after(issue_1)
+ issue_3.move_after(issue_2)
+ [issue_1, issue_2, issue_3].map(&:save)
+
+ opts[:move_between_ids] = [issue_1.id, issue_2.id]
+ opts[:board_group_id] = group.id
+
+ described_class.new(issue_3.project, user, opts).execute(issue_3)
+ expect(issue_2.relative_position).to be_between(issue_1.relative_position, issue_2.relative_position)
+ end
+ end
+
context 'when current user cannot admin issues in the project' do
let(:guest) { create(:user) }
before do
- project.team << [guest, :guest]
+ project.add_guest(guest)
end
it 'filters out params that cannot be set without the :admin_issue permission' do
@@ -318,7 +352,7 @@ describe Issues::UpdateService, :mailer do
let!(:subscriber) do
create(:user).tap do |u|
label.toggle_subscription(u, project)
- project.team << [u, :developer]
+ project.add_developer(u)
end
end
@@ -556,7 +590,7 @@ describe Issues::UpdateService, :mailer do
context 'valid project' do
before do
- target_project.team << [user, :master]
+ target_project.add_master(user)
end
it 'calls the move service with the proper issue and project' do
diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb
index a781fbc7f7d..68d5660445a 100644
--- a/spec/services/labels/find_or_create_service_spec.rb
+++ b/spec/services/labels/find_or_create_service_spec.rb
@@ -15,47 +15,79 @@ describe Labels::FindOrCreateService do
context 'when acting on behalf of a specific user' do
let(:user) { create(:user) }
- subject(:service) { described_class.new(user, project, params) }
- before do
- project.team << [user, :developer]
- end
- context 'when label does not exist at group level' do
- it 'creates a new label at project level' do
- expect { service.execute }.to change(project.labels, :count).by(1)
+ context 'when finding labels on project level' do
+ subject(:service) { described_class.new(user, project, params) }
+
+ before do
+ project.add_developer(user)
end
- end
- context 'when label exists at group level' do
- it 'returns the group label' do
- group_label = create(:group_label, group: group, title: 'Security')
+ context 'when label does not exist at group level' do
+ it 'creates a new label at project level' do
+ expect { service.execute }.to change(project.labels, :count).by(1)
+ end
+ end
- expect(service.execute).to eq group_label
+ context 'when label exists at group level' do
+ it 'returns the group label' do
+ group_label = create(:group_label, group: group, title: 'Security')
+
+ expect(service.execute).to eq group_label
+ end
+ end
+
+ context 'when label exists at project level' do
+ it 'returns the project label' do
+ project_label = create(:label, project: project, title: 'Security')
+
+ expect(service.execute).to eq project_label
+ end
end
end
- context 'when label does not exist at group level' do
- it 'creates a new label at project leve' do
- expect { service.execute }.to change(project.labels, :count).by(1)
+ context 'when finding labels on group level' do
+ subject(:service) { described_class.new(user, group, params) }
+
+ before do
+ group.add_developer(user)
+ end
+
+ context 'when label does not exist at group level' do
+ it 'creates a new label at group level' do
+ expect { service.execute }.to change(group.labels, :count).by(1)
+ end
+ end
+
+ context 'when label exists at group level' do
+ it 'returns the group label' do
+ group_label = create(:group_label, group: group, title: 'Security')
+
+ expect(service.execute).to eq group_label
+ end
end
end
+ end
+
+ context 'when authorization is not required' do
+ context 'when finding labels on project level' do
+ subject(:service) { described_class.new(nil, project, params) }
- context 'when label exists at project level' do
it 'returns the project label' do
project_label = create(:label, project: project, title: 'Security')
- expect(service.execute).to eq project_label
+ expect(service.execute(skip_authorization: true)).to eq project_label
end
end
- end
- context 'when authorization is not required' do
- subject(:service) { described_class.new(nil, project, params) }
+ context 'when finding labels on group level' do
+ subject(:service) { described_class.new(nil, group, params) }
- it 'returns the project label' do
- project_label = create(:label, project: project, title: 'Security')
+ it 'returns the group label' do
+ group_label = create(:group_label, group: group, title: 'Security')
- expect(service.execute(skip_authorization: true)).to eq project_label
+ expect(service.execute(skip_authorization: true)).to eq group_label
+ end
end
end
end
diff --git a/spec/services/labels/promote_service_spec.rb b/spec/services/labels/promote_service_spec.rb
index 8809b282127..aa9aba6bdff 100644
--- a/spec/services/labels/promote_service_spec.rb
+++ b/spec/services/labels/promote_service_spec.rb
@@ -85,6 +85,19 @@ describe Labels::PromoteService do
change(project_3.labels, :count).by(-1)
end
+ it 'keeps users\' subscriptions' do
+ user2 = create(:user)
+ project_label_1_1.subscriptions.create(user: user, subscribed: true)
+ project_label_2_1.subscriptions.create(user: user, subscribed: true)
+ project_label_3_2.subscriptions.create(user: user, subscribed: true)
+ project_label_2_1.subscriptions.create(user: user2, subscribed: true)
+
+ expect { service.execute(project_label_1_1) }.to change { Subscription.count }.from(4).to(3)
+
+ expect(new_label.subscribed?(user)).to be_truthy
+ expect(new_label.subscribed?(user2)).to be_truthy
+ end
+
it 'recreates priorities' do
service.execute(project_label_1_1)
diff --git a/spec/services/lfs/file_transformer_spec.rb b/spec/services/lfs/file_transformer_spec.rb
new file mode 100644
index 00000000000..e8938338cb7
--- /dev/null
+++ b/spec/services/lfs/file_transformer_spec.rb
@@ -0,0 +1,97 @@
+require "spec_helper"
+
+describe Lfs::FileTransformer do
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository }
+ let(:file_content) { 'Test file content' }
+ let(:branch_name) { 'lfs' }
+ let(:file_path) { 'test_file.lfs' }
+
+ subject { described_class.new(project, branch_name) }
+
+ describe '#new_file' do
+ context 'with lfs disabled' do
+ it 'skips gitattributes check' do
+ expect(repository.raw).not_to receive(:blob_at)
+
+ subject.new_file(file_path, file_content)
+ end
+
+ it 'returns untransformed content' do
+ result = subject.new_file(file_path, file_content)
+
+ expect(result.content).to eq(file_content)
+ end
+
+ it 'returns untransformed encoding' do
+ result = subject.new_file(file_path, file_content, encoding: 'base64')
+
+ expect(result.encoding).to eq('base64')
+ end
+ end
+
+ context 'with lfs enabled' do
+ before do
+ allow(project).to receive(:lfs_enabled?).and_return(true)
+ end
+
+ it 'reuses cached gitattributes' do
+ second_file = 'another_file.lfs'
+
+ expect(repository.raw).to receive(:blob_at).with(branch_name, '.gitattributes').once
+
+ subject.new_file(file_path, file_content)
+ subject.new_file(second_file, file_content)
+ end
+
+ it "creates an LfsObject with the file's content" do
+ subject.new_file(file_path, file_content)
+
+ expect(LfsObject.last.file.read).to eq file_content
+ end
+
+ it 'returns an LFS pointer' do
+ result = subject.new_file(file_path, file_content)
+
+ expect(result.content).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE)
+ end
+
+ it 'returns LFS pointer encoding as text' do
+ result = subject.new_file(file_path, file_content, encoding: 'base64')
+
+ expect(result.encoding).to eq('text')
+ end
+
+ context "when doesn't use LFS" do
+ let(:file_path) { 'other.filetype' }
+
+ it "doesn't create LFS pointers" do
+ new_content = subject.new_file(file_path, file_content).content
+
+ expect(new_content).not_to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE)
+ expect(new_content).to eq(file_content)
+ end
+ end
+
+ it 'links LfsObjects to project' do
+ expect do
+ subject.new_file(file_path, file_content)
+ end.to change { project.lfs_objects.count }.by(1)
+ end
+
+ context 'when LfsObject already exists' do
+ let(:lfs_pointer) { Gitlab::Git::LfsPointerFile.new(file_content) }
+
+ before do
+ create(:lfs_object, oid: lfs_pointer.sha256, size: lfs_pointer.size)
+ end
+
+ it 'links LfsObjects to project' do
+ expect do
+ subject.new_file(file_path, file_content)
+ end.to change { project.lfs_objects.count }.by(1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/lfs/lock_file_service_spec.rb b/spec/services/lfs/lock_file_service_spec.rb
new file mode 100644
index 00000000000..3e58eea2501
--- /dev/null
+++ b/spec/services/lfs/lock_file_service_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+
+describe Lfs::LockFileService do
+ let(:project) { create(:project) }
+ let(:current_user) { create(:user) }
+
+ subject { described_class.new(project, current_user, params) }
+
+ describe '#execute' do
+ let(:params) { { path: 'README.md' } }
+
+ context 'when not authorized' do
+ it "doesn't succeed" do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:http_status]).to eq(403)
+ expect(result[:message]).to eq('You have no permissions')
+ end
+ end
+
+ context 'when authorized' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'with an existent lock' do
+ let!(:lock) { create(:lfs_file_lock, project: project) }
+
+ it "doesn't succeed" do
+ expect(subject.execute[:status]).to eq(:error)
+ end
+
+ it "doesn't create the Lock" do
+ expect do
+ subject.execute
+ end.not_to change { LfsFileLock.count }
+ end
+ end
+
+ context 'without an existent lock' do
+ it "succeeds" do
+ expect(subject.execute[:status]).to eq(:success)
+ end
+
+ it "creates the Lock" do
+ expect do
+ subject.execute
+ end.to change { LfsFileLock.count }.by(1)
+ end
+ end
+
+ context 'when an error is raised' do
+ it "doesn't succeed" do
+ allow_any_instance_of(described_class).to receive(:create_lock!).and_raise(StandardError)
+
+ expect(subject.execute[:status]).to eq(:error)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/lfs/locks_finder_service_spec.rb b/spec/services/lfs/locks_finder_service_spec.rb
new file mode 100644
index 00000000000..e409b77babf
--- /dev/null
+++ b/spec/services/lfs/locks_finder_service_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe Lfs::LocksFinderService do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:params) { {} }
+
+ subject { described_class.new(project, user, params) }
+
+ shared_examples 'no results' do
+ it 'returns an empty list' do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:success)
+ expect(result[:locks]).to be_blank
+ end
+ end
+
+ describe '#execute' do
+ let!(:lock_1) { create(:lfs_file_lock, project: project) }
+ let!(:lock_2) { create(:lfs_file_lock, project: project, path: 'README') }
+
+ context 'find by id' do
+ context 'with results' do
+ let(:params) do
+ { id: lock_1.id }
+ end
+
+ it 'returns the record' do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:success)
+ expect(result[:locks].size).to eq(1)
+ expect(result[:locks].first).to eq(lock_1)
+ end
+ end
+
+ context 'without results' do
+ let(:params) do
+ { id: 123 }
+ end
+
+ include_examples 'no results'
+ end
+ end
+
+ context 'find by path' do
+ context 'with results' do
+ let(:params) do
+ { path: lock_1.path }
+ end
+
+ it 'returns the record' do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:success)
+ expect(result[:locks].size).to eq(1)
+ expect(result[:locks].first).to eq(lock_1)
+ end
+ end
+
+ context 'without results' do
+ let(:params) do
+ { path: 'not-found' }
+ end
+
+ include_examples 'no results'
+ end
+ end
+
+ context 'find all' do
+ context 'with results' do
+ it 'returns all the records' do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:success)
+ expect(result[:locks].size).to eq(2)
+ end
+ end
+
+ context 'without results' do
+ before do
+ LfsFileLock.delete_all
+ end
+
+ include_examples 'no results'
+ end
+ end
+
+ context 'when an error is raised' do
+ it "doesn't succeed" do
+ allow_any_instance_of(described_class).to receive(:find_locks).and_raise(StandardError)
+
+ result = subject.execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:locks]).to be_blank
+ end
+ end
+ end
+end
diff --git a/spec/services/lfs/unlock_file_service_spec.rb b/spec/services/lfs/unlock_file_service_spec.rb
new file mode 100644
index 00000000000..4bea112b9c6
--- /dev/null
+++ b/spec/services/lfs/unlock_file_service_spec.rb
@@ -0,0 +1,105 @@
+require 'spec_helper'
+
+describe Lfs::UnlockFileService do
+ let(:project) { create(:project) }
+ let(:current_user) { create(:user) }
+ let(:lock_author) { create(:user) }
+ let!(:lock) { create(:lfs_file_lock, user: lock_author, project: project) }
+ let(:params) { {} }
+
+ subject { described_class.new(project, current_user, params) }
+
+ describe '#execute' do
+ context 'when not authorized' do
+ it "doesn't succeed" do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:http_status]).to eq(403)
+ expect(result[:message]).to eq('You have no permissions')
+ end
+ end
+
+ context 'when authorized' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'when lock does not exists' do
+ let(:params) { { id: 123 } }
+ it "doesn't succeed" do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:http_status]).to eq(404)
+ end
+ end
+
+ context 'when unlocked by the author' do
+ let(:current_user) { lock_author }
+ let(:params) { { id: lock.id } }
+
+ it "succeeds" do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:success)
+ expect(result[:lock]).to be_present
+ end
+ end
+
+ context 'when unlocked by a different user' do
+ let(:current_user) { create(:user) }
+ let(:params) { { id: lock.id } }
+
+ it "doesn't succeed" do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to match(/is locked by GitLab User #{lock_author.id}/)
+ expect(result[:http_status]).to eq(403)
+ end
+ end
+
+ context 'when forced' do
+ let(:developer) { create(:user) }
+ let(:master) { create(:user) }
+
+ before do
+ project.add_developer(developer)
+ project.add_master(master)
+ end
+
+ context 'by a regular user' do
+ let(:current_user) { developer }
+ let(:params) do
+ { id: lock.id,
+ force: true }
+ end
+
+ it "doesn't succeed" do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to match(/You must have master access/)
+ expect(result[:http_status]).to eq(403)
+ end
+ end
+
+ context 'by a master user' do
+ let(:current_user) { master }
+ let(:params) do
+ { id: lock.id,
+ force: true }
+ end
+
+ it "succeeds" do
+ result = subject.execute
+
+ expect(result[:status]).to eq(:success)
+ expect(result[:lock]).to be_present
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/members/approve_access_request_service_spec.rb b/spec/services/members/approve_access_request_service_spec.rb
index 302c488d6c6..7076571b753 100644
--- a/spec/services/members/approve_access_request_service_spec.rb
+++ b/spec/services/members/approve_access_request_service_spec.rb
@@ -1,70 +1,56 @@
require 'spec_helper'
describe Members::ApproveAccessRequestService do
- let(:user) { create(:user) }
- let(:access_requester) { create(:user) }
let(:project) { create(:project, :public, :access_requestable) }
let(:group) { create(:group, :public, :access_requestable) }
+ let(:current_user) { create(:user) }
+ let(:access_requester_user) { create(:user) }
+ let(:access_requester) { source.requesters.find_by!(user_id: access_requester_user.id) }
let(:opts) { {} }
shared_examples 'a service raising ActiveRecord::RecordNotFound' do
it 'raises ActiveRecord::RecordNotFound' do
- expect { described_class.new(source, user, params).execute(opts) }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { described_class.new(current_user).execute(access_requester, opts) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
it 'raises Gitlab::Access::AccessDeniedError' do
- expect { described_class.new(source, user, params).execute(opts) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ expect { described_class.new(current_user).execute(access_requester, opts) }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
shared_examples 'a service approving an access request' do
it 'succeeds' do
- expect { described_class.new(source, user, params).execute(opts) }.to change { source.requesters.count }.by(-1)
+ expect { described_class.new(current_user).execute(access_requester, opts) }.to change { source.requesters.count }.by(-1)
end
it 'returns a <Source>Member' do
- member = described_class.new(source, user, params).execute(opts)
+ member = described_class.new(current_user).execute(access_requester, opts)
expect(member).to be_a "#{source.class}Member".constantize
expect(member.requested_at).to be_nil
end
context 'with a custom access level' do
- let(:params2) { params.merge(user_id: access_requester.id, access_level: Gitlab::Access::MASTER) }
-
it 'returns a ProjectMember with the custom access level' do
- member = described_class.new(source, user, params2).execute(opts)
+ member = described_class.new(current_user, access_level: Gitlab::Access::MASTER).execute(access_requester, opts)
- expect(member.access_level).to eq Gitlab::Access::MASTER
+ expect(member.access_level).to eq(Gitlab::Access::MASTER)
end
end
end
- context 'when no access requester are found' do
- let(:params) { { user_id: 42 } }
-
- it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do
- let(:source) { project }
- end
-
- it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do
- let(:source) { group }
- end
- end
-
context 'when an access requester is found' do
before do
- project.request_access(access_requester)
- group.request_access(access_requester)
+ project.request_access(access_requester_user)
+ group.request_access(access_requester_user)
end
- let(:params) { { user_id: access_requester.id } }
context 'when current user is nil' do
let(:user) { nil }
- context 'and :force option is not given' do
+ context 'and :ldap option is not given' do
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:source) { project }
end
@@ -74,8 +60,8 @@ describe Members::ApproveAccessRequestService do
end
end
- context 'and :force option is false' do
- let(:opts) { { force: false } }
+ context 'and :skip_authorization option is false' do
+ let(:opts) { { skip_authorization: false } }
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
let(:source) { project }
@@ -86,8 +72,8 @@ describe Members::ApproveAccessRequestService do
end
end
- context 'and :force option is true' do
- let(:opts) { { force: true } }
+ context 'and :skip_authorization option is true' do
+ let(:opts) { { skip_authorization: true } }
it_behaves_like 'a service approving an access request' do
let(:source) { project }
@@ -97,18 +83,6 @@ describe Members::ApproveAccessRequestService do
let(:source) { group }
end
end
-
- context 'and :force param is true' do
- let(:params) { { user_id: access_requester.id, force: true } }
-
- it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
- let(:source) { project }
- end
-
- it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
- let(:source) { group }
- end
- end
end
context 'when current user cannot approve access request to the project' do
@@ -123,8 +97,8 @@ describe Members::ApproveAccessRequestService do
context 'when current user can approve access request to the project' do
before do
- project.team << [user, :master]
- group.add_owner(user)
+ project.add_master(current_user)
+ group.add_owner(current_user)
end
it_behaves_like 'a service approving an access request' do
@@ -134,14 +108,6 @@ describe Members::ApproveAccessRequestService do
it_behaves_like 'a service approving an access request' do
let(:source) { group }
end
-
- context 'when given a :id' do
- let(:params) { { id: project.requesters.find_by!(user_id: access_requester.id).id } }
-
- it_behaves_like 'a service approving an access request' do
- let(:source) { project }
- end
- end
end
end
end
diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb
deleted file mode 100644
index d4ef31c0c74..00000000000
--- a/spec/services/members/authorized_destroy_service_spec.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-require 'spec_helper'
-
-describe Members::AuthorizedDestroyService do
- let(:member_user) { create(:user) }
- let(:project) { create(:project, :public) }
- let(:group) { create(:group, :public) }
- 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, 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
-
- member = group.members.find_by(user_id: member_user.id)
-
- expect { described_class.new(member, member_user).execute }
- .to change { number_of_assigned_issuables(member_user) }.from(4).to(2)
-
- expect(issue.reload.assignee_ids).to be_empty
- expect(merge_request.reload.assignee_id).to be_nil
- end
- end
-
- context 'Project member' do
- it "unassigns issues and merge requests" do
- project.team << [member_user, :developer]
-
- 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)
-
- expect { described_class.new(member, member_user).execute }
- .to change { number_of_assigned_issuables(member_user) }.from(2).to(0)
- end
- end
-end
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 2a793e0eb4d..1831c62d788 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -6,12 +6,12 @@ describe Members::CreateService do
let(:project_user) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
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
+ result = described_class.new(user, params).execute(project)
expect(result[:status]).to eq(:success)
expect(project.users).to include project_user
@@ -19,7 +19,7 @@ describe Members::CreateService do
it 'adds no user to members' do
params = { user_ids: '', access_level: Gitlab::Access::GUEST }
- result = described_class.new(project, user, params).execute
+ result = described_class.new(user, params).execute(project)
expect(result[:status]).to eq(:error)
expect(result[:message]).to be_present
@@ -30,7 +30,7 @@ describe Members::CreateService 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
+ result = described_class.new(user, params).execute(project)
expect(result[:status]).to eq(:error)
expect(result[:message]).to be_present
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index 72f5e27180d..36b6e5a701e 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -1,112 +1,226 @@
require 'spec_helper'
describe Members::DestroyService do
- let(:user) { create(:user) }
+ let(:current_user) { create(:user) }
let(:member_user) { create(:user) }
- let(:project) { create(:project, :public) }
let(:group) { create(:group, :public) }
+ let(:group_project) { create(:project, :public, group: group) }
+ let(:opts) { {} }
shared_examples 'a service raising ActiveRecord::RecordNotFound' do
it 'raises ActiveRecord::RecordNotFound' do
- expect { described_class.new(source, user, params).execute }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { described_class.new(current_user).execute(member) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
it 'raises Gitlab::Access::AccessDeniedError' do
- expect { described_class.new(source, user, params).execute }.to raise_error(Gitlab::Access::AccessDeniedError)
+ expect { described_class.new(current_user).execute(member) }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
shared_examples 'a service destroying a member' do
it 'destroys the member' do
- expect { described_class.new(source, user, params).execute }.to change { source.members.count }.by(-1)
+ expect { described_class.new(current_user).execute(member, opts) }.to change { member.source.members_and_requesters.count }.by(-1)
end
- context 'when the given member is an access requester' do
- before do
- source.members.find_by(user_id: member_user).destroy
- source.update_attributes(request_access_enabled: true)
- source.request_access(member_user)
+ it 'destroys member notification_settings' do
+ if member_user.notification_settings.any?
+ expect { described_class.new(current_user).execute(member, opts) }
+ .to change { member_user.notification_settings.count }.by(-1)
+ else
+ expect { described_class.new(current_user).execute(member, opts) }
+ .not_to change { member_user.notification_settings.count }
end
- let(:access_requester) { source.requesters.find_by(user_id: member_user) }
+ end
+ end
- it_behaves_like 'a service raising ActiveRecord::RecordNotFound'
+ shared_examples 'a service destroying a member with access' do
+ it_behaves_like 'a service destroying a member'
- %i[requesters all].each do |scope|
- context "and #{scope} scope is passed" do
- it 'destroys the access requester' do
- expect { described_class.new(source, user, params).execute(scope) }.to change { source.requesters.count }.by(-1)
- end
+ it 'invalidates cached counts for todos and assigned issues and merge requests', :aggregate_failures do
+ create(:issue, project: group_project, assignees: [member_user])
+ create(:merge_request, source_project: group_project, assignee: member_user)
+ create(:todo, :pending, project: group_project, user: member_user)
+ create(:todo, :done, project: group_project, user: member_user)
- it 'calls Member#after_decline_request' do
- expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(access_requester)
+ expect(member_user.assigned_open_merge_requests_count).to be(1)
+ expect(member_user.assigned_open_issues_count).to be(1)
+ expect(member_user.todos_pending_count).to be(1)
+ expect(member_user.todos_done_count).to be(1)
- described_class.new(source, user, params).execute(scope)
- end
+ described_class.new(current_user).execute(member, opts)
- context 'when current user is the member' do
- it 'does not call Member#after_decline_request' do
- expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(access_requester)
+ expect(member_user.assigned_open_merge_requests_count).to be(0)
+ expect(member_user.assigned_open_issues_count).to be(0)
+ expect(member_user.todos_pending_count).to be(0)
+ expect(member_user.todos_done_count).to be(0)
+ end
+ end
- described_class.new(source, member_user, params).execute(scope)
- end
- end
- end
+ shared_examples 'a service destroying an access requester' do
+ it_behaves_like 'a service destroying a member'
+
+ it 'calls Member#after_decline_request' do
+ expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(member)
+
+ described_class.new(current_user).execute(member)
+ end
+
+ context 'when current user is the member' do
+ it 'does not call Member#after_decline_request' do
+ expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(member)
+
+ described_class.new(member_user).execute(member)
end
end
end
- context 'when no member are found' do
- let(:params) { { user_id: 42 } }
+ context 'with a member with access' do
+ before do
+ group_project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
+ group.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ context 'when current user cannot destroy the given member' do
+ context 'with a project member' do
+ let(:member) { group_project.members.find_by(user_id: member_user.id) }
+
+ before do
+ group_project.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
- it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do
- let(:source) { project }
+ it_behaves_like 'a service destroying a member with access' do
+ let(:opts) { { skip_authorization: true } }
+ end
+ end
+
+ context 'with a group member' do
+ let(:member) { group.members.find_by(user_id: member_user.id) }
+
+ before do
+ group.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
+
+ it_behaves_like 'a service destroying a member with access' do
+ let(:opts) { { skip_authorization: true } }
+ end
+ end
end
- it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do
- let(:source) { group }
+ context 'when current user can destroy the given member' do
+ before do
+ group_project.add_master(current_user)
+ group.add_owner(current_user)
+ end
+
+ context 'with a project member' do
+ let(:member) { group_project.members.find_by(user_id: member_user.id) }
+
+ before do
+ group_project.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service destroying a member with access'
+ end
+
+ context 'with a group member' do
+ let(:member) { group.members.find_by(user_id: member_user.id) }
+
+ before do
+ group.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service destroying a member with access'
+ end
end
end
- context 'when a member is found' do
+ context 'with an access requester' do
before do
- project.team << [member_user, :developer]
- group.add_developer(member_user)
+ group_project.update_attributes(request_access_enabled: true)
+ group.update_attributes(request_access_enabled: true)
+ group_project.request_access(member_user)
+ group.request_access(member_user)
end
- let(:params) { { user_id: member_user.id } }
- context 'when current user cannot destroy the given member' do
+ context 'when current user cannot destroy the given access requester' do
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
- let(:source) { project }
+ let(:member) { group_project.requesters.find_by(user_id: member_user.id) }
+ end
+
+ it_behaves_like 'a service destroying a member' do
+ let(:opts) { { skip_authorization: true } }
+ let(:member) { group_project.requesters.find_by(user_id: member_user.id) }
end
it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
- let(:source) { group }
+ let(:member) { group.requesters.find_by(user_id: member_user.id) }
+ end
+
+ it_behaves_like 'a service destroying a member' do
+ let(:opts) { { skip_authorization: true } }
+ let(:member) { group.requesters.find_by(user_id: member_user.id) }
end
end
- context 'when current user can destroy the given member' do
+ context 'when current user can destroy the given access requester' do
before do
- project.team << [user, :master]
- group.add_owner(user)
+ group_project.add_master(current_user)
+ group.add_owner(current_user)
+ end
+
+ it_behaves_like 'a service destroying an access requester' do
+ let(:member) { group_project.requesters.find_by(user_id: member_user.id) }
+ end
+
+ it_behaves_like 'a service destroying an access requester' do
+ let(:member) { group.requesters.find_by(user_id: member_user.id) }
+ end
+ end
+ end
+
+ context 'with an invited user' do
+ let(:project_invited_member) { create(:project_member, :invited, project: group_project) }
+ let(:group_invited_member) { create(:group_member, :invited, group: group) }
+
+ context 'when current user cannot destroy the given invited user' do
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
+ let(:member) { project_invited_member }
end
it_behaves_like 'a service destroying a member' do
- let(:source) { project }
+ let(:opts) { { skip_authorization: true } }
+ let(:member) { project_invited_member }
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
+ let(:member) { group_invited_member }
end
it_behaves_like 'a service destroying a member' do
- let(:source) { group }
+ let(:opts) { { skip_authorization: true } }
+ let(:member) { group_invited_member }
end
+ end
- context 'when given a :id' do
- let(:params) { { id: project.members.find_by!(user_id: user.id).id } }
+ context 'when current user can destroy the given invited user' do
+ before do
+ group_project.add_master(current_user)
+ group.add_owner(current_user)
+ end
- it 'destroys the member' do
- expect { described_class.new(project, user, params).execute }
- .to change { project.members.count }.by(-1)
- end
+ # Regression spec for issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/32504
+ it_behaves_like 'a service destroying a member' do
+ let(:member) { project_invited_member }
+ end
+
+ it_behaves_like 'a service destroying a member' do
+ let(:member) { group_invited_member }
end
end
end
diff --git a/spec/services/members/request_access_service_spec.rb b/spec/services/members/request_access_service_spec.rb
index 0a704bba521..e93ba5a85c0 100644
--- a/spec/services/members/request_access_service_spec.rb
+++ b/spec/services/members/request_access_service_spec.rb
@@ -5,17 +5,17 @@ describe Members::RequestAccessService do
shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
it 'raises Gitlab::Access::AccessDeniedError' do
- expect { described_class.new(source, user).execute }.to raise_error(Gitlab::Access::AccessDeniedError)
+ expect { described_class.new(user).execute(source) }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
shared_examples 'a service creating a access request' do
it 'succeeds' do
- expect { described_class.new(source, user).execute }.to change { source.requesters.count }.by(1)
+ expect { described_class.new(user).execute(source) }.to change { source.requesters.count }.by(1)
end
it 'returns a <Source>Member' do
- member = described_class.new(source, user).execute
+ member = described_class.new(user).execute(source)
expect(member).to be_a "#{source.class}Member".constantize
expect(member.requested_at).to be_present
diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb
new file mode 100644
index 00000000000..a451272dd1f
--- /dev/null
+++ b/spec/services/members/update_service_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe Members::UpdateService do
+ let(:project) { create(:project, :public) }
+ let(:group) { create(:group, :public) }
+ let(:current_user) { create(:user) }
+ let(:member_user) { create(:user) }
+ let(:permission) { :update }
+ let(:member) { source.members_and_requesters.find_by!(user_id: member_user.id) }
+ let(:params) do
+ { access_level: Gitlab::Access::MASTER }
+ end
+
+ shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do
+ it 'raises Gitlab::Access::AccessDeniedError' do
+ expect { described_class.new(current_user, params).execute(member, permission: permission) }
+ .to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
+
+ shared_examples 'a service updating a member' do
+ it 'updates the member' do
+ updated_member = described_class.new(current_user, params).execute(member, permission: permission)
+
+ expect(updated_member).to be_valid
+ expect(updated_member.access_level).to eq(Gitlab::Access::MASTER)
+ end
+ end
+
+ before do
+ project.add_developer(member_user)
+ group.add_developer(member_user)
+ end
+
+ context 'when current user cannot update the given member' do
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
+ let(:source) { project }
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
+ let(:source) { group }
+ end
+ end
+
+ context 'when current user can update the given member' do
+ before do
+ project.add_master(current_user)
+ group.add_owner(current_user)
+ end
+
+ it_behaves_like 'a service updating a member' do
+ let(:source) { project }
+ end
+
+ it_behaves_like 'a service updating a member' do
+ let(:source) { group }
+ end
+ end
+end
diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb
index fcbe0e5985f..bda6383a346 100644
--- a/spec/services/merge_requests/assign_issues_service_spec.rb
+++ b/spec/services/merge_requests/assign_issues_service_spec.rb
@@ -8,7 +8,7 @@ describe MergeRequests::AssignIssuesService do
let(:service) { described_class.new(project, user, merge_request: merge_request) }
before do
- project.team << [user, :developer]
+ project.add_developer(user)
end
it 'finds unassigned issues fixed in merge request' do
diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb
index b5c92e681fb..6aed481939e 100644
--- a/spec/services/merge_requests/build_service_spec.rb
+++ b/spec/services/merge_requests/build_service_spec.rb
@@ -1,6 +1,7 @@
require 'spec_helper'
describe MergeRequests::BuildService do
+ using RSpec::Parameterized::TableSyntax
include RepoHelpers
let(:project) { create(:project, :repository) }
@@ -14,8 +15,8 @@ describe MergeRequests::BuildService do
let(:target_branch) { 'master' }
let(:merge_request) { service.execute }
let(:compare) { double(:compare, commits: commits) }
- let(:commit_1) { double(:commit_1, safe_message: "Initial commit\n\nCreate the app") }
- let(:commit_2) { double(:commit_2, safe_message: 'This is a bad commit message!') }
+ let(:commit_1) { double(:commit_1, sha: 'f00ba7', safe_message: "Initial commit\n\nCreate the app") }
+ let(:commit_2) { double(:commit_2, sha: 'f00ba7', safe_message: 'This is a bad commit message!') }
let(:commits) { nil }
let(:service) do
@@ -28,7 +29,7 @@ describe MergeRequests::BuildService do
end
before do
- project.team << [user, :guest]
+ project.add_guest(user)
end
def stub_compare
@@ -111,6 +112,7 @@ describe MergeRequests::BuildService do
context 'one commit in the diff' do
let(:commits) { Commit.decorate([commit_1], project) }
+ let(:commit_description) { commit_1.safe_message.split(/\n+/, 2).last }
before do
stub_compare
@@ -125,7 +127,7 @@ describe MergeRequests::BuildService do
end
it 'uses the description of the commit as the description of the merge request' do
- expect(merge_request.description).to eq(commit_1.safe_message.split(/\n+/, 2).last)
+ expect(merge_request.description).to eq(commit_description)
end
context 'merge request already has a description set' do
@@ -148,26 +150,29 @@ describe MergeRequests::BuildService do
end
end
- context 'branch starts with issue IID followed by a hyphen' do
- let(:source_branch) { "#{issue.iid}-fix-issue" }
-
- it 'appends "Closes #$issue-iid" to the description' do
- expect(merge_request.description).to eq("#{commit_1.safe_message.split(/\n+/, 2).last}\n\nCloses ##{issue.iid}")
+ context 'when the source branch matches an issue' do
+ where(:issue_tracker, :source_branch, :closing_message) do
+ :jira | 'FOO-123-fix-issue' | 'Closes FOO-123'
+ :jira | 'fix-issue' | nil
+ :custom_issue_tracker | '123-fix-issue' | 'Closes #123'
+ :custom_issue_tracker | 'fix-issue' | nil
+ :internal | '123-fix-issue' | 'Closes #123'
+ :internal | 'fix-issue' | nil
end
- context 'merge request already has a description set' do
- let(:description) { 'Merge request description' }
-
- it 'appends "Closes #$issue-iid" to the description' do
- expect(merge_request.description).to eq("#{description}\n\nCloses ##{issue.iid}")
+ with_them do
+ before do
+ if issue_tracker == :internal
+ issue.update!(iid: 123)
+ else
+ create(:"#{issue_tracker}_service", project: project)
+ end
end
- end
- context 'commit has no description' do
- let(:commits) { Commit.decorate([commit_2], project) }
+ it 'appends the closing description' do
+ expected_description = [commit_description, closing_message].compact.join("\n\n")
- it 'sets the description to "Closes #$issue-iid"' do
- expect(merge_request.description).to eq("Closes ##{issue.iid}")
+ expect(merge_request.description).to eq(expected_description)
end
end
end
@@ -200,49 +205,62 @@ describe MergeRequests::BuildService do
end
end
- context 'branch starts with GitLab issue IID followed by a hyphen' do
- let(:source_branch) { "#{issue.iid}-fix-issue" }
-
- it 'sets the title to: Resolves "$issue-title"' do
- expect(merge_request.title).to eq("Resolve \"#{issue.title}\"")
+ context 'when the source branch matches an issue' do
+ where(:issue_tracker, :source_branch, :title, :closing_message) do
+ :jira | 'FOO-123-fix-issue' | 'Resolve FOO-123 "Fix issue"' | 'Closes FOO-123'
+ :jira | 'fix-issue' | 'Fix issue' | nil
+ :custom_issue_tracker | '123-fix-issue' | 'Resolve #123 "Fix issue"' | 'Closes #123'
+ :custom_issue_tracker | 'fix-issue' | 'Fix issue' | nil
+ :internal | '123-fix-issue' | 'Resolve "A bug"' | 'Closes #123'
+ :internal | 'fix-issue' | 'Fix issue' | nil
+ :internal | '124-fix-issue' | '124 fix issue' | nil
end
- context 'when issue is not accessible to user' do
+ with_them do
before do
- project.team.truncate
+ if issue_tracker == :internal
+ issue.update!(iid: 123)
+ else
+ create(:"#{issue_tracker}_service", project: project)
+ end
end
- it 'uses branch title as the merge request title' do
- expect(merge_request.title).to eq("#{issue.iid} fix issue")
+ it 'sets the correct title' do
+ expect(merge_request.title).to eq(title)
+ end
+
+ it 'sets the closing description' do
+ expect(merge_request.description).to eq(closing_message)
end
end
+ end
- context 'issue does not exist' do
- let(:source_branch) { "#{issue.iid.succ}-fix-issue" }
+ context 'when the issue is not accessible to user' do
+ let(:source_branch) { "#{issue.iid}-fix-issue" }
- it 'uses the title of the branch as the merge request title' do
- expect(merge_request.title).to eq("#{issue.iid.succ} fix issue")
- end
+ before do
+ project.team.truncate
end
- context 'issue is confidential' do
- let(:issue_confidential) { true }
+ it 'uses branch title as the merge request title' do
+ expect(merge_request.title).to eq("#{issue.iid} fix issue")
+ end
- it 'uses the title of the branch as the merge request title' do
- expect(merge_request.title).to eq("#{issue.iid} fix issue")
- end
+ it 'does not set a description' do
+ expect(merge_request.description).to be_nil
end
end
- context 'branch starts with external issue IID followed by a hyphen' do
- let(:source_branch) { '12345-fix-issue' }
+ context 'when the issue is confidential' do
+ let(:source_branch) { "#{issue.iid}-fix-issue" }
+ let(:issue_confidential) { true }
- before do
- allow(project).to receive(:external_issue_tracker).and_return(true)
+ it 'uses the title of the branch as the merge request title' do
+ expect(merge_request.title).to eq("#{issue.iid} fix issue")
end
- it 'sets the title to: Resolves External Issue $issue-iid' do
- expect(merge_request.title).to eq('Resolve External Issue 12345')
+ it 'does not set a description' do
+ expect(merge_request.description).to be_nil
end
end
end
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index b3886987316..216e0cd4266 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -4,14 +4,14 @@ describe MergeRequests::CloseService do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest) { create(:user) }
- let(:merge_request) { create(:merge_request, assignee: user2) }
+ let(:merge_request) { create(:merge_request, assignee: user2, author: create(:user)) }
let(:project) { merge_request.project }
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) }
before do
- project.team << [user, :master]
- project.team << [user2, :developer]
- project.team << [guest, :guest]
+ project.add_master(user)
+ project.add_developer(user2)
+ project.add_guest(guest)
end
describe '#execute' do
@@ -52,6 +52,19 @@ describe MergeRequests::CloseService do
end
end
+ it 'updates metrics' do
+ metrics = merge_request.metrics
+ metrics_service = double(MergeRequestMetricsService)
+ allow(MergeRequestMetricsService)
+ .to receive(:new)
+ .with(metrics)
+ .and_return(metrics_service)
+
+ expect(metrics_service).to receive(:close)
+
+ described_class.new(project, user, {}).execute(merge_request)
+ end
+
it 'refreshes the number of open merge requests for a valid MR', :use_clean_rails_memory_store_caching do
service = described_class.new(project, user, {})
diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb
index 0b32c51a16f..837b8a56d12 100644
--- a/spec/services/merge_requests/conflicts/list_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/list_service_spec.rb
@@ -32,14 +32,6 @@ describe MergeRequests::Conflicts::ListService do
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_any_instance_of(Rugged::Repository).to receive(:merge_commits).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)
@@ -76,5 +68,39 @@ describe MergeRequests::Conflicts::ListService do
expect(conflicts_service(merge_request).can_be_resolved_in_ui?).to be_truthy
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_any_instance_of(Gitlab::GitalyClient::ConflictsService).to receive(:list_conflict_files).and_raise(GRPC::Unknown)
+
+ expect(service.can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the MR has a missing revision after a force push' do
+ merge_request = create_merge_request('conflict-resolvable')
+ service = conflicts_service(merge_request)
+ allow(merge_request).to receive_message_chain(:target_branch_head, :raw, :id).and_return(Gitlab::Git::BLANK_SHA)
+
+ expect(service.can_be_resolved_in_ui?).to be_falsey
+ end
+
+ context 'with gitaly disabled', :skip_gitaly_mock do
+ it 'returns a falsey value when the MR has a missing ref after a force push' do
+ merge_request = create_merge_request('conflict-resolvable')
+ service = conflicts_service(merge_request)
+ allow_any_instance_of(Rugged::Repository).to receive(:merge_commits).and_raise(Rugged::OdbError)
+
+ expect(service.can_be_resolved_in_ui?).to be_falsey
+ end
+
+ it 'returns a falsey value when the MR has a missing revision after a force push' do
+ merge_request = create_merge_request('conflict-resolvable')
+ service = conflicts_service(merge_request)
+ allow(merge_request).to receive_message_chain(:target_branch_head, :raw, :id).and_return(Gitlab::Git::BLANK_SHA)
+
+ expect(service.can_be_resolved_in_ui?).to be_falsey
+ end
+ 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
index e28d8d7ae5c..cff09237005 100644
--- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb
+++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb
@@ -111,15 +111,6 @@ describe MergeRequests::Conflicts::ResolveService do
described_class.new(merge_request_from_fork).execute(user, params)
end
- it 'gets conflicts from the source project' do
- # REFACTOR NOTE: We used to test that `project.repository.rugged` wasn't
- # used in this case, but since the refactor, for simplification,
- # we always use that repository for read only operations.
- expect(forked_project.repository.rugged).to receive(:merge_commits).and_call_original
-
- subject
- end
-
it 'creates a commit with the message' do
subject
@@ -132,6 +123,17 @@ describe MergeRequests::Conflicts::ResolveService do
expect(merge_request_from_fork.source_branch_head.parents.map(&:id))
.to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813', target_head])
end
+
+ context 'when gitaly is disabled', :skip_gitaly_mock do
+ it 'gets conflicts from the source project' do
+ # REFACTOR NOTE: We used to test that `project.repository.rugged` wasn't
+ # used in this case, but since the refactor, for simplification,
+ # we always use that repository for read only operations.
+ expect(forked_project.repository.rugged).to receive(:merge_commits).and_call_original
+
+ subject
+ 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
index 623b182b205..38d84cf0ceb 100644
--- a/spec/services/merge_requests/create_from_issue_service_spec.rb
+++ b/spec/services/merge_requests/create_from_issue_service_spec.rb
@@ -24,7 +24,7 @@ describe MergeRequests::CreateFromIssueService do
end
it 'delegates issue search to IssuesFinder' do
- expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
+ expect_any_instance_of(IssuesFinder).to receive(:find_by).once.and_call_original
described_class.new(project, user, issue_iid: -1).execute
end
@@ -112,5 +112,24 @@ describe MergeRequests::CreateFromIssueService do
expect(result[:merge_request].assignee).to eq(user)
end
+
+ context 'when ref branch is set' do
+ subject { described_class.new(project, user, issue_iid: issue.iid, ref: 'feature').execute }
+
+ it 'sets the merge request source branch to the new issue branch' do
+ expect(subject[:merge_request].source_branch).to eq(issue.to_branch_name)
+ end
+
+ it 'sets the merge request target branch to the ref branch' do
+ expect(subject[:merge_request].target_branch).to eq('feature')
+ end
+
+ context 'when ref branch does not exist' do
+ it 'does not create a merge request' do
+ expect { described_class.new(project, user, issue_iid: issue.iid, ref: 'nobr').execute }
+ .not_to change { project.merge_requests.count }
+ end
+ end
+ end
end
end
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index a047f891ab2..44a83c436cb 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -21,13 +21,14 @@ describe MergeRequests::CreateService do
let(:merge_request) { service.execute }
before do
- project.team << [user, :master]
- project.team << [assignee, :developer]
+ project.add_master(user)
+ project.add_developer(assignee)
allow(service).to receive(:execute_hooks)
end
it 'creates an MR' do
expect(merge_request).to be_valid
+ expect(merge_request.work_in_progress?).to be(false)
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')
@@ -62,6 +63,40 @@ describe MergeRequests::CreateService do
expect(Event.where(attributes).count).to eq(1)
end
+ describe 'when marked with /wip' do
+ context 'in title and in description' do
+ let(:opts) do
+ {
+ title: 'WIP: Awesome merge_request',
+ description: "well this is not done yet\n/wip",
+ source_branch: 'feature',
+ target_branch: 'master',
+ assignee: assignee
+ }
+ end
+
+ it 'sets MR to WIP' do
+ expect(merge_request.work_in_progress?).to be(true)
+ end
+ end
+
+ context 'in description only' do
+ let(:opts) do
+ {
+ title: 'Awesome merge_request',
+ description: "well this is not done yet\n/wip",
+ source_branch: 'feature',
+ target_branch: 'master',
+ assignee: assignee
+ }
+ end
+
+ it 'sets MR to WIP' do
+ expect(merge_request.work_in_progress?).to be(true)
+ end
+ end
+ end
+
context 'when merge request is assigned to someone' do
let(:opts) do
{
@@ -148,8 +183,8 @@ describe MergeRequests::CreateService do
end
before do
- project.team << [user, :master]
- project.team << [assignee, :master]
+ project.add_master(user)
+ project.add_master(assignee)
end
it 'assigns and sets milestone to issuable from command' do
@@ -165,7 +200,7 @@ describe MergeRequests::CreateService do
let(:assignee) { create(:user) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
it 'removes assignee_id when user id is invalid' do
@@ -185,7 +220,7 @@ describe MergeRequests::CreateService do
end
it 'saves assignee when user id is valid' do
- project.team << [assignee, :master]
+ project.add_master(assignee)
opts = { title: 'Title', description: 'Description', assignee_id: assignee.id }
merge_request = described_class.new(project, user, opts).execute
@@ -205,7 +240,7 @@ describe MergeRequests::CreateService do
end
it 'invalidates open merge request counter for assignees when merge request is assigned' do
- project.team << [assignee, :master]
+ project.add_master(assignee)
described_class.new(project, user, opts).execute
@@ -249,8 +284,8 @@ describe MergeRequests::CreateService do
end
before do
- project.team << [user, :master]
- project.team << [assignee, :developer]
+ project.add_master(user)
+ project.add_developer(assignee)
end
it 'creates a `MergeRequestsClosingIssues` record for each issue' do
@@ -263,5 +298,66 @@ describe MergeRequests::CreateService do
expect(issue_ids).to match_array([first_issue.id, second_issue.id])
end
end
+
+ context 'when source and target projects are different' do
+ let(:target_project) { create(:project) }
+
+ let(:opts) do
+ {
+ title: 'Awesome merge_request',
+ source_branch: 'feature',
+ target_branch: 'master',
+ target_project_id: target_project.id
+ }
+ end
+
+ context 'when user can not access source project' do
+ before do
+ target_project.add_developer(assignee)
+ target_project.add_master(user)
+ end
+
+ it 'raises an error' do
+ expect { described_class.new(project, user, opts).execute }
+ .to raise_error Gitlab::Access::AccessDeniedError
+ end
+ end
+
+ context 'when user can not access target project' do
+ before do
+ target_project.add_developer(assignee)
+ target_project.add_master(user)
+ end
+
+ it 'raises an error' do
+ expect { described_class.new(project, user, opts).execute }
+ .to raise_error Gitlab::Access::AccessDeniedError
+ end
+ end
+ end
+
+ context 'when user sets source project id' do
+ let(:another_project) { create(:project) }
+
+ let(:opts) do
+ {
+ title: 'Awesome merge_request',
+ source_branch: 'feature',
+ target_branch: 'master',
+ source_project_id: another_project.id
+ }
+ end
+
+ before do
+ project.add_developer(assignee)
+ project.add_master(user)
+ end
+
+ it 'ignores source_project_id' do
+ merge_request = described_class.new(project, user, opts).execute
+
+ expect(merge_request.source_project_id).to eq(project.id)
+ end
+ end
end
end
diff --git a/spec/services/merge_requests/ff_merge_service_spec.rb b/spec/services/merge_requests/ff_merge_service_spec.rb
index aaabf3ed2b0..5ef6365fcc9 100644
--- a/spec/services/merge_requests/ff_merge_service_spec.rb
+++ b/spec/services/merge_requests/ff_merge_service_spec.rb
@@ -7,13 +7,14 @@ describe MergeRequests::FfMergeService do
create(:merge_request,
source_branch: 'flatten-dir',
target_branch: 'improve/awesome',
- assignee: user2)
+ assignee: user2,
+ author: create(:user))
end
let(:project) { merge_request.project }
before do
- project.team << [user, :master]
- project.team << [user2, :developer]
+ project.add_master(user)
+ project.add_developer(user2)
end
describe '#execute' do
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 bb46e1dd9ab..57b6165cfb0 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,19 +1,39 @@
require 'spec_helper'
-describe MergeRequests::MergeRequestDiffCacheService do
+describe MergeRequests::MergeRequestDiffCacheService, :use_clean_rails_memory_store_caching do
let(:subject) { described_class.new }
+ let(:merge_request) { create(:merge_request) }
describe '#execute' do
- it 'retrieves the diff files to cache the highlighted result' do
- merge_request = create(:merge_request)
- cache_key = [merge_request.merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::FileCollection::MergeRequestDiff.default_options]
-
- expect(Rails.cache).to receive(:read).with(cache_key).and_return({})
- expect(Rails.cache).to receive(:write).with(cache_key, anything)
+ before do
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)
+ end
+
+ it 'retrieves the diff files to cache the highlighted result' do
+ new_diff = merge_request.merge_request_diff
+ cache_key = new_diff.diffs.cache_key
+
+ expect(Rails.cache).to receive(:read).with(cache_key).and_call_original
+ expect(Rails.cache).to receive(:write).with(cache_key, anything, anything).and_call_original
+
+ subject.execute(merge_request, new_diff)
+ end
+
+ it 'clears the cache for older diffs on the merge request' do
+ old_diff = merge_request.merge_request_diff
+ old_cache_key = old_diff.diffs.cache_key
+
+ subject.execute(merge_request, old_diff)
+
+ new_diff = merge_request.create_merge_request_diff
+ new_cache_key = new_diff.diffs.cache_key
+
+ expect(Rails.cache).to receive(:delete).with(old_cache_key).and_call_original
+ expect(Rails.cache).to receive(:read).with(new_cache_key).and_call_original
+ expect(Rails.cache).to receive(:write).with(new_cache_key, anything, anything).and_call_original
- subject.execute(merge_request)
+ subject.execute(merge_request, new_diff)
end
end
end
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 f17db70faf6..240aa638f79 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
@@ -43,7 +43,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do
it 'creates a system note' do
note = merge_request.notes.last
- expect(note.note).to match /enabled an automatic merge when the pipeline for (\w+\/\w+@)?\h{8}/
+ expect(note.note).to match %r{enabled an automatic merge when the pipeline for (\w+/\w+@)?\h{8}}
end
end
diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb
index d2bd05d921f..70957431942 100644
--- a/spec/services/merge_requests/post_merge_service_spec.rb
+++ b/spec/services/merge_requests/post_merge_service_spec.rb
@@ -6,7 +6,7 @@ describe MergeRequests::PostMergeService do
let(:project) { merge_request.project }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe '#execute' do
@@ -22,5 +22,18 @@ describe MergeRequests::PostMergeService do
expect { service.execute(merge_request) }
.to change { project.open_merge_requests_count }.from(1).to(0)
end
+
+ it 'updates metrics' do
+ metrics = merge_request.metrics
+ metrics_service = double(MergeRequestMetricsService)
+ allow(MergeRequestMetricsService)
+ .to receive(:new)
+ .with(metrics)
+ .and_return(metrics_service)
+
+ expect(metrics_service).to receive(:merge)
+
+ described_class.new(project, user, {}).execute(merge_request)
+ end
end
end
diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb
new file mode 100644
index 00000000000..757c31ab692
--- /dev/null
+++ b/spec/services/merge_requests/rebase_service_spec.rb
@@ -0,0 +1,154 @@
+require 'spec_helper'
+
+describe MergeRequests::RebaseService do
+ include ProjectForksHelper
+
+ let(:user) { create(:user) }
+ let(:merge_request) do
+ create(:merge_request,
+ source_branch: 'feature_conflict',
+ target_branch: 'master')
+ end
+ let(:project) { merge_request.project }
+ let(:repository) { project.repository.raw }
+
+ subject(:service) { described_class.new(project, user, {}) }
+
+ before do
+ project.add_master(user)
+ end
+
+ describe '#execute' do
+ context 'when another rebase is already in progress' do
+ before do
+ allow(merge_request).to receive(:rebase_in_progress?).and_return(true)
+ end
+
+ it 'saves the error message' do
+ subject.execute(merge_request)
+
+ expect(merge_request.reload.merge_error).to eq 'Rebase task canceled: Another rebase is already in progress'
+ end
+
+ it 'returns an error' do
+ expect(service.execute(merge_request)).to match(status: :error,
+ message: described_class::REBASE_ERROR)
+ end
+ end
+
+ context 'when unexpected error occurs', :disable_gitaly do
+ before do
+ allow(repository).to receive(:run_git!).and_raise('Something went wrong')
+ end
+
+ it 'saves a generic error message' do
+ subject.execute(merge_request)
+
+ expect(merge_request.reload.merge_error).to eq described_class::REBASE_ERROR
+ end
+
+ it 'returns an error' do
+ expect(service.execute(merge_request)).to match(status: :error,
+ message: described_class::REBASE_ERROR)
+ end
+ end
+
+ context 'with git command failure', :disable_gitaly do
+ before do
+ allow(repository).to receive(:run_git!).and_raise(Gitlab::Git::Repository::GitError, 'Something went wrong')
+ end
+
+ it 'saves a generic error message' do
+ subject.execute(merge_request)
+
+ expect(merge_request.reload.merge_error).to eq described_class::REBASE_ERROR
+ end
+
+ it 'returns an error' do
+ expect(service.execute(merge_request)).to match(status: :error,
+ message: described_class::REBASE_ERROR)
+ end
+ end
+
+ context 'valid params' do
+ shared_examples 'successful rebase' do
+ before do
+ service.execute(merge_request)
+ end
+
+ it 'rebases source branch' do
+ parent_sha = merge_request.source_project.repository.commit(merge_request.source_branch).parents.first.sha
+ target_branch_sha = merge_request.target_project.repository.commit(merge_request.target_branch).sha
+ expect(parent_sha).to eq(target_branch_sha)
+ end
+
+ it 'records the new SHA on the merge request' do
+ head_sha = merge_request.source_project.repository.commit(merge_request.source_branch).sha
+ expect(merge_request.reload.rebase_commit_sha).to eq(head_sha)
+ end
+
+ it 'logs correct author and commiter' do
+ head_commit = merge_request.source_project.repository.commit(merge_request.source_branch)
+
+ expect(head_commit.author_email).to eq('dmitriy.zaporozhets@gmail.com')
+ expect(head_commit.author_name).to eq('Dmitriy Zaporozhets')
+ expect(head_commit.committer_email).to eq(user.email)
+ expect(head_commit.committer_name).to eq(user.name)
+ end
+ end
+
+ context 'when Gitaly rebase feature is enabled' do
+ it_behaves_like 'successful rebase'
+ end
+
+ context 'when Gitaly rebase feature is disabled', :disable_gitaly do
+ it_behaves_like 'successful rebase'
+ end
+
+ context 'git commands', :disable_gitaly do
+ it 'sets GL_REPOSITORY env variable when calling git commands' do
+ expect(repository).to receive(:popen).exactly(3)
+ .with(anything, anything, hash_including('GL_REPOSITORY'), anything)
+ .and_return(['', 0])
+
+ service.execute(merge_request)
+ end
+ end
+
+ context 'fork' do
+ shared_examples 'successful fork rebase' do
+ let(:forked_project) do
+ fork_project(project, user, repository: true)
+ end
+
+ let(:merge_request_from_fork) do
+ forked_project.repository.create_file(
+ user,
+ 'new-file-to-target',
+ '',
+ message: 'Add new file to target',
+ branch_name: 'master')
+
+ create(:merge_request,
+ source_branch: 'master', source_project: forked_project,
+ target_branch: 'master', target_project: project)
+ end
+
+ it 'rebases source branch' do
+ parent_sha = forked_project.repository.commit(merge_request_from_fork.source_branch).parents.first.sha
+ target_branch_sha = project.repository.commit(merge_request_from_fork.target_branch).sha
+ expect(parent_sha).to eq(target_branch_sha)
+ end
+ end
+
+ context 'when Gitaly rebase feature is enabled' do
+ it_behaves_like 'successful fork rebase'
+ end
+
+ context 'when Gitaly rebase feature is disabled', :disable_gitaly do
+ it_behaves_like 'successful fork rebase'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 61ec4709c59..2536c6e2514 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -24,6 +24,14 @@ describe MergeRequests::RefreshService do
merge_when_pipeline_succeeds: true,
merge_user: @user)
+ @another_merge_request = create(:merge_request,
+ source_project: @project,
+ source_branch: 'master',
+ target_branch: 'test',
+ target_project: @project,
+ merge_when_pipeline_succeeds: true,
+ merge_user: @user)
+
@fork_merge_request = create(:merge_request,
source_project: @fork_project,
source_branch: 'master',
@@ -52,17 +60,25 @@ describe MergeRequests::RefreshService do
context 'push to origin repo source branch' do
let(:refresh_service) { service.new(@project, @user) }
+ let(:notification_service) { spy('notification_service') }
before do
allow(refresh_service).to receive(:execute_hooks)
- refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
- reload_mrs
+ allow(NotificationService).to receive(:new) { notification_service }
end
it 'executes hooks with update action' do
+ refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
+ reload_mrs
+
expect(refresh_service).to have_received(:execute_hooks)
.with(@merge_request, 'update', old_rev: @oldrev)
+ expect(notification_service).to have_received(:push_to_merge_request)
+ .with(@merge_request, @user, new_commits: anything, existing_commits: anything)
+ expect(notification_service).to have_received(:push_to_merge_request)
+ .with(@another_merge_request, @user, new_commits: anything, existing_commits: anything)
+
expect(@merge_request.notes).not_to be_empty
expect(@merge_request).to be_open
expect(@merge_request.merge_when_pipeline_succeeds).to be_falsey
@@ -72,6 +88,34 @@ describe MergeRequests::RefreshService do
expect(@build_failed_todo).to be_done
expect(@fork_build_failed_todo).to be_done
end
+
+ it 'reloads source branch MRs memoization' do
+ refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
+
+ expect { refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') }.to change {
+ refresh_service.instance_variable_get("@source_merge_requests").first.merge_request_diff
+ }
+ end
+
+ context 'when source branch ref does not exists' do
+ before do
+ DeleteBranchService.new(@project, @user).execute(@merge_request.source_branch)
+ end
+
+ it 'closes MRs without source branch ref' do
+ expect { refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') }
+ .to change { @merge_request.reload.state }
+ .from('opened')
+ .to('closed')
+
+ expect(@fork_merge_request.reload).to be_open
+ end
+
+ it 'does not change the merge request diff' do
+ expect { refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') }
+ .not_to change { @merge_request.reload.merge_request_diff }
+ end
+ end
end
context 'when pipeline exists for the source branch' do
@@ -90,11 +134,13 @@ describe MergeRequests::RefreshService do
context 'push to origin repo source branch when an MR was reopened' do
let(:refresh_service) { service.new(@project, @user) }
+ let(:notification_service) { spy('notification_service') }
before do
@merge_request.update(state: :reopened)
allow(refresh_service).to receive(:execute_hooks)
+ allow(NotificationService).to receive(:new) { notification_service }
refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
reload_mrs
end
@@ -102,6 +148,10 @@ describe MergeRequests::RefreshService do
it 'executes hooks with update action' do
expect(refresh_service).to have_received(:execute_hooks)
.with(@merge_request, 'update', old_rev: @oldrev)
+ expect(notification_service).to have_received(:push_to_merge_request)
+ .with(@merge_request, @user, new_commits: anything, existing_commits: anything)
+ expect(notification_service).to have_received(:push_to_merge_request)
+ .with(@another_merge_request, @user, new_commits: anything, existing_commits: anything)
expect(@merge_request.notes).not_to be_empty
expect(@merge_request).to be_open
@@ -300,8 +350,8 @@ describe MergeRequests::RefreshService do
let(:commit) { project.commit }
before do
- project.team << [commit_author, :developer]
- project.team << [user, :developer]
+ project.add_developer(commit_author)
+ project.add_developer(user)
allow(commit).to receive_messages(
safe_message: "Closes #{issue.to_reference}",
@@ -371,37 +421,21 @@ describe MergeRequests::RefreshService do
end
it 'references the commit that caused the Work in Progress status' do
- refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
- allow(refresh_service).to receive(:find_new_commits)
- refresh_service.instance_variable_set("@commits", [
- double(
- id: 'aaaaaaa',
- sha: '38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e',
- short_id: 'aaaaaaa',
- title: 'Fix issue',
- work_in_progress?: false
- ),
- double(
- id: 'bbbbbbb',
- sha: '498214de67004b1da3d820901307bed2a68a8ef6',
- short_id: 'bbbbbbb',
- title: 'fixup! Fix issue',
- work_in_progress?: true,
- to_reference: 'bbbbbbb'
- ),
- double(
- id: 'ccccccc',
- sha: '1b12f15a11fc6e62177bef08f47bc7b5ce50b141',
- short_id: 'ccccccc',
- title: 'fixup! Fix issue',
- work_in_progress?: true,
- to_reference: 'ccccccc'
- )
- ])
- refresh_service.execute(@oldrev, @newrev, 'refs/heads/wip')
- reload_mrs
- expect(@merge_request.notes.last.note).to eq(
- "marked as a **Work In Progress** from bbbbbbb"
+ wip_merge_request = create(:merge_request,
+ source_project: @project,
+ source_branch: 'wip',
+ target_branch: 'master',
+ target_project: @project)
+
+ commits = wip_merge_request.commits
+ oldrev = commits.last.id
+ newrev = commits.first.id
+ wip_commit = wip_merge_request.commits.find(&:work_in_progress?)
+
+ refresh_service.execute(oldrev, newrev, 'refs/heads/wip')
+
+ expect(wip_merge_request.reload.notes.last.note).to eq(
+ "marked as a **Work In Progress** from #{wip_commit.id}"
)
end
diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb
index fa652611c6b..9ee37c51d95 100644
--- a/spec/services/merge_requests/reopen_service_spec.rb
+++ b/spec/services/merge_requests/reopen_service_spec.rb
@@ -4,13 +4,13 @@ describe MergeRequests::ReopenService do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:guest) { create(:user) }
- let(:merge_request) { create(:merge_request, :closed, assignee: user2) }
+ let(:merge_request) { create(:merge_request, :closed, assignee: user2, author: create(:user)) }
let(:project) { merge_request.project }
before do
- project.team << [user, :master]
- project.team << [user2, :developer]
- project.team << [guest, :guest]
+ project.add_master(user)
+ project.add_developer(user2)
+ project.add_guest(guest)
end
describe '#execute' do
@@ -47,6 +47,19 @@ describe MergeRequests::ReopenService do
end
end
+ it 'updates metrics' do
+ metrics = merge_request.metrics
+ service = double(MergeRequestMetricsService)
+ allow(MergeRequestMetricsService)
+ .to receive(:new)
+ .with(metrics)
+ .and_return(service)
+
+ expect(service).to receive(:reopen)
+
+ described_class.new(project, user, {}).execute(merge_request)
+ end
+
it 'refreshes the number of open merge requests for a valid MR' do
service = described_class.new(project, user, {})
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index 7a66b809550..5279ea6164e 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe MergeRequests::UpdateService, :mailer do
+ include ProjectForksHelper
+
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:user2) { create(:user) }
@@ -12,13 +14,14 @@ describe MergeRequests::UpdateService, :mailer do
create(:merge_request, :simple, title: 'Old title',
description: "FYI #{user2.to_reference}",
assignee_id: user3.id,
- source_project: project)
+ source_project: project,
+ author: create(:user))
end
before do
- project.team << [user, :master]
- project.team << [user2, :developer]
- project.team << [user3, :developer]
+ project.add_master(user)
+ project.add_developer(user2)
+ project.add_developer(user3)
end
describe 'execute' do
@@ -356,8 +359,8 @@ describe MergeRequests::UpdateService, :mailer do
let!(:subscriber) { create(:user) { |u| label.toggle_subscription(u, project) } }
before do
- project.team << [non_subscriber, :developer]
- project.team << [subscriber, :developer]
+ project.add_developer(non_subscriber)
+ project.add_developer(subscriber)
end
it 'sends notifications for subscribers of newly added labels' do
@@ -537,5 +540,40 @@ describe MergeRequests::UpdateService, :mailer do
let(:open_issuable) { merge_request }
let(:closed_issuable) { create(:closed_merge_request, source_project: project) }
end
+
+ context 'setting `allow_maintainer_to_push`' do
+ let(:target_project) { create(:project, :public) }
+ let(:source_project) { fork_project(target_project) }
+ let(:user) { create(:user) }
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ source_branch: 'fixes',
+ target_project: target_project)
+ end
+
+ before do
+ allow(ProtectedBranch).to receive(:protected?).with(source_project, 'fixes') { false }
+ end
+
+ it 'does not allow a maintainer of the target project to set `allow_maintainer_to_push`' do
+ target_project.add_developer(user)
+
+ update_merge_request(allow_maintainer_to_push: true, title: 'Updated title')
+
+ expect(merge_request.title).to eq('Updated title')
+ expect(merge_request.allow_maintainer_to_push).to be_falsy
+ end
+
+ it 'is allowed by a user that can push to the source and can update the merge request' do
+ merge_request.update!(assignee: user)
+ source_project.add_developer(user)
+
+ update_merge_request(allow_maintainer_to_push: true, title: 'Updated title')
+
+ expect(merge_request.title).to eq('Updated title')
+ expect(merge_request.allow_maintainer_to_push).to be_truthy
+ end
+ end
end
end
diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb
index 2bdf224804d..adad73f7e11 100644
--- a/spec/services/milestones/close_service_spec.rb
+++ b/spec/services/milestones/close_service_spec.rb
@@ -6,7 +6,7 @@ describe Milestones::CloseService do
let(:milestone) { create(:milestone, title: "Milestone v1.2", project: project) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
describe '#execute' do
diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb
index 8837b91051d..f2a18c7295a 100644
--- a/spec/services/milestones/create_service_spec.rb
+++ b/spec/services/milestones/create_service_spec.rb
@@ -7,7 +7,7 @@ describe Milestones::CreateService do
describe '#execute' do
context "valid params" do
before do
- project.team << [user, :master]
+ project.add_master(user)
opts = {
title: 'v2.1.9',
diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb
index af35e17bfa7..9703780b0e9 100644
--- a/spec/services/milestones/destroy_service_spec.rb
+++ b/spec/services/milestones/destroy_service_spec.rb
@@ -8,7 +8,7 @@ describe Milestones::DestroyService do
let!(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) }
before do
- project.team << [user, :master]
+ project.add_master(user)
end
def service
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 661d26946e7..f5cff66de6d 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -10,7 +10,7 @@ describe Notes::CreateService do
describe '#execute' do
before do
- project.team << [user, :master]
+ project.add_master(user)
end
context "valid params" do
@@ -57,32 +57,55 @@ describe Notes::CreateService do
end
end
- describe 'note with commands' do
- describe '/close, /label, /assign & /milestone' do
- let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) }
+ context 'note with commands' do
+ context 'as a user who can update the target' do
+ context '/close, /label, /assign & /milestone' do
+ let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) }
- it 'saves the note and does not alter the note text' do
- expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original
+ it 'saves the note and does not alter the note text' do
+ expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original
- note = described_class.new(project, user, opts.merge(note: note_text)).execute
+ note = described_class.new(project, user, opts.merge(note: note_text)).execute
- expect(note.note).to eq "HELLO\nWORLD"
+ expect(note.note).to eq "HELLO\nWORLD"
+ end
+ end
+
+ context '/merge with sha option' do
+ let(:note_text) { %(HELLO\n/merge\nWORLD) }
+ let(:params) { opts.merge(note: note_text, merge_request_diff_head_sha: 'sha') }
+
+ it 'saves the note and exectues merge command' do
+ note = described_class.new(project, user, params).execute
+
+ expect(note.note).to eq "HELLO\nWORLD"
+ end
end
end
- describe '/merge with sha option' do
- let(:note_text) { %(HELLO\n/merge\nWORLD) }
- let(:params) { opts.merge(note: note_text, merge_request_diff_head_sha: 'sha') }
+ context 'as a user who cannot update the target' do
+ let(:note_text) { "HELLO\n/todo\n/assign #{user.to_reference}\nWORLD" }
+ let(:note) { described_class.new(project, user, opts.merge(note: note_text)).execute }
- it 'saves the note and exectues merge command' do
- note = described_class.new(project, user, params).execute
+ before do
+ project.team.find_member(user.id).update!(access_level: Gitlab::Access::GUEST)
+ end
+
+ it 'applies commands the user can execute' do
+ expect { note }.to change { user.todos_pending_count }.from(0).to(1)
+ end
+
+ it 'does not apply commands the user cannot execute' do
+ expect { note }.not_to change { issue.assignees }
+ end
+ it 'saves the note' do
expect(note.note).to eq "HELLO\nWORLD"
end
end
end
- describe 'personal snippet note' do
+ context 'personal snippet note' do
subject { described_class.new(nil, user, params).execute }
let(:snippet) { create(:personal_snippet) }
@@ -103,7 +126,7 @@ describe Notes::CreateService do
end
end
- describe 'note with emoji only' do
+ context 'note with emoji only' do
it 'creates regular note' do
opts = {
note: ':smile: ',
diff --git a/spec/services/notes/post_process_service_spec.rb b/spec/services/notes/post_process_service_spec.rb
index a2b3638059f..4e2ab919f0f 100644
--- a/spec/services/notes/post_process_service_spec.rb
+++ b/spec/services/notes/post_process_service_spec.rb
@@ -7,7 +7,7 @@ describe Notes::PostProcessService do
describe '#execute' do
before do
- project.team << [user, :master]
+ project.add_master(user)
note_opts = {
note: 'Awesome comment',
noteable_type: 'Issue',
@@ -23,5 +23,23 @@ describe Notes::PostProcessService do
described_class.new(@note).execute
end
+
+ context 'with a confidential issue' do
+ let(:issue) { create(:issue, :confidential, project: project) }
+
+ it "doesn't call note hooks/services" do
+ expect(project).not_to receive(:execute_hooks).with(anything, :note_hooks)
+ expect(project).not_to receive(:execute_services).with(anything, :note_hooks)
+
+ described_class.new(@note).execute
+ end
+
+ it "calls confidential-note hooks/services" do
+ expect(project).to receive(:execute_hooks).with(anything, :confidential_note_hooks)
+ expect(project).to receive(:execute_services).with(anything, :confidential_note_hooks)
+
+ described_class.new(@note).execute
+ end
+ end
end
end
diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb
index 0280a19098b..b1e218821d2 100644
--- a/spec/services/notes/quick_actions_service_spec.rb
+++ b/spec/services/notes/quick_actions_service_spec.rb
@@ -3,11 +3,11 @@ 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(:master) { create(:user).tap { |u| project.add_master(u) } }
let(:assignee) { create(:user) }
before do
- project.team << [assignee, :master]
+ project.add_master(assignee)
end
end
@@ -165,31 +165,17 @@ describe Notes::QuickActionsService do
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
+ context 'with a note on an issue' do
it 'returns true' do
- expect(described_class.supported?(note, master)).to be_truthy
+ expect(described_class.supported?(note)).to be_truthy
end
+ end
- context 'with a note on a commit' do
- let(:note) { create(:note_on_commit, project: project) }
+ 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
+ it 'returns false' do
+ expect(described_class.supported?(note)).to be_falsy
end
end
end
@@ -201,7 +187,7 @@ describe Notes::QuickActionsService do
service = described_class.new(project, master)
note = create(:note_on_issue, project: project)
- expect(described_class).to receive(:supported?).with(note, master)
+ expect(described_class).to receive(:supported?).with(note)
service.supported?(note)
end
@@ -226,7 +212,7 @@ describe Notes::QuickActionsService do
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(:master) { create(:user).tap { |u| project.add_master(u) } }
let(:assignee) { create(:user) }
let(:master) { create(:user) }
let(:service) { described_class.new(project, master) }
@@ -237,8 +223,8 @@ describe Notes::QuickActionsService do
end
before do
- project.team << [master, :master]
- project.team << [assignee, :master]
+ project.add_master(master)
+ project.add_master(assignee)
end
it 'adds only one assignee from the list' do
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 3210539f3ee..65b1d613998 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -9,9 +9,9 @@ describe Notes::UpdateService do
let(:note) { create(:note, project: project, noteable: issue, author: user, note: "Old note #{user2.to_reference}") }
before do
- project.team << [user, :master]
- project.team << [user2, :developer]
- project.team << [user3, :developer]
+ project.add_master(user)
+ project.add_developer(user2)
+ project.add_developer(user3)
end
describe '#execute' do
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 43e2643f709..f8fa2540804 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
describe NotificationService, :mailer do
+ include EmailSpec::Matchers
+
let(:notification) { described_class.new }
let(:assignee) { create(:user) }
@@ -31,6 +33,20 @@ describe NotificationService, :mailer do
send_notifications(@u_disabled)
should_not_email_anyone
end
+
+ it 'emails new mentions despite being unsubscribed' do
+ send_notifications(@unsubscribed_mentioned)
+
+ should_only_email(@unsubscribed_mentioned)
+ end
+
+ it 'sends the proper notification reason header' do
+ send_notifications(@u_watcher)
+ should_only_email(@u_watcher)
+ email = find_email_for(@u_watcher)
+
+ expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::MENTIONED)
+ end
end
# Next shared examples are intended to test notifications of "participants"
@@ -112,7 +128,7 @@ describe NotificationService, :mailer do
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') }
+ let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') }
before do
build_team(note.project)
@@ -140,7 +156,7 @@ describe NotificationService, :mailer do
add_users_with_subscription(note.project, issue)
reset_delivered_emails!
- expect(SentNotification).to receive(:record).with(issue, any_args).exactly(9).times
+ expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times
notification.new_note(note)
@@ -153,6 +169,7 @@ describe NotificationService, :mailer do
should_email(@watcher_and_subscriber)
should_email(@subscribed_participant)
should_email(@u_custom_off)
+ should_email(@unsubscribed_mentioned)
should_not_email(@u_guest_custom)
should_not_email(@u_guest_watcher)
should_not_email(note.author)
@@ -269,6 +286,7 @@ describe NotificationService, :mailer do
before do
build_team(note.project)
note.project.add_master(note.author)
+ add_users_with_subscription(note.project, issue)
reset_delivered_emails!
end
@@ -276,6 +294,9 @@ describe NotificationService, :mailer do
it 'notifies the team members' do
notification.new_note(note)
+ # Make sure @unsubscribed_mentioned is part of the team
+ expect(note.project.team.members).to include(@unsubscribed_mentioned)
+
# Notify all team members
note.project.team.members.each do |member|
# User with disabled notification should not be notified
@@ -448,7 +469,7 @@ describe NotificationService, :mailer do
context "merge request diff note" do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request, source_project: project, assignee: user) }
+ let(:merge_request) { create(:merge_request, source_project: project, assignee: user, author: create(:user)) }
let(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
before do
@@ -459,11 +480,13 @@ describe NotificationService, :mailer do
describe '#new_note' do
it "records sent notifications" do
- # Ensure create SentNotification by noteable = merge_request 6 times, not noteable = note
+ # 3 SentNotification are sent: the MR assignee and author, and the @u_watcher
expect(SentNotification).to receive(:record_note).with(note, any_args).exactly(3).times.and_call_original
notification.new_note(note)
+ expect(SentNotification.last(3).map(&:recipient).map(&:id))
+ .to contain_exactly(merge_request.assignee.id, merge_request.author.id, @u_watcher.id)
expect(SentNotification.last.in_reply_to_discussion_id).to eq(note.discussion_id)
end
end
@@ -474,7 +497,7 @@ describe NotificationService, :mailer do
let(:group) { create(:group) }
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' }
+ let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' }
before do
build_team(issue.project)
@@ -498,6 +521,7 @@ describe NotificationService, :mailer do
should_email(@u_participant_mentioned)
should_email(@g_global_watcher)
should_email(@g_watcher)
+ should_email(@unsubscribed_mentioned)
should_not_email(@u_mentioned)
should_not_email(@u_participating)
should_not_email(@u_disabled)
@@ -511,12 +535,35 @@ describe NotificationService, :mailer do
should_not_email(issue.assignees.first)
end
+ it 'properly prioritizes notification reason' do
+ # have assignee be both assigned and mentioned
+ issue.update_attribute(:description, "/cc #{assignee.to_reference} #{@u_mentioned.to_reference}")
+
+ notification.new_issue(issue, @u_disabled)
+
+ email = find_email_for(assignee)
+ expect(email).to have_header('X-GitLab-NotificationReason', 'assigned')
+
+ email = find_email_for(@u_mentioned)
+ expect(email).to have_header('X-GitLab-NotificationReason', 'mentioned')
+ end
+
+ it 'adds "assigned" reason for assignees if any' do
+ notification.new_issue(issue, @u_disabled)
+
+ email = find_email_for(assignee)
+
+ expect(email).to have_header('X-GitLab-NotificationReason', 'assigned')
+ end
+
it "emails any mentioned users with the mention level" do
issue.description = @u_mentioned.to_reference
notification.new_issue(issue, @u_disabled)
- should_email(@u_mentioned)
+ email = find_email_for(@u_mentioned)
+ expect(email).not_to be_nil
+ expect(email).to have_header('X-GitLab-NotificationReason', 'mentioned')
end
it "emails the author if they've opted into notifications about their activity" do
@@ -620,6 +667,14 @@ describe NotificationService, :mailer do
should_not_email(@u_lazy_participant)
end
+ it 'adds "assigned" reason for new assignee' do
+ notification.reassigned_issue(issue, @u_disabled, [assignee])
+
+ email = find_email_for(assignee)
+
+ expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::ASSIGNED)
+ end
+
it 'emails previous assignee even if he has the "on mention" notif level' do
issue.assignees = [@u_mentioned]
notification.reassigned_issue(issue, @u_disabled, [@u_watcher])
@@ -910,6 +965,14 @@ describe NotificationService, :mailer do
should_not_email(@u_lazy_participant)
end
+ it 'adds "assigned" reason for assignee, if any' do
+ notification.new_merge_request(merge_request, @u_disabled)
+
+ email = find_email_for(merge_request.assignee)
+
+ expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::ASSIGNED)
+ end
+
it "emails any mentioned users with the mention level" do
merge_request.description = @u_mentioned.to_reference
@@ -924,6 +987,9 @@ describe NotificationService, :mailer do
notification.new_merge_request(merge_request, merge_request.author)
should_email(merge_request.author)
+
+ email = find_email_for(merge_request.author)
+ expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::OWN_ACTIVITY)
end
it "doesn't email the author if they haven't opted into notifications about their activity" do
@@ -1009,6 +1075,14 @@ describe NotificationService, :mailer do
should_not_email(@u_lazy_participant)
end
+ it 'adds "assigned" reason for new assignee' do
+ notification.reassigned_merge_request(merge_request, merge_request.author)
+
+ email = find_email_for(merge_request.assignee)
+
+ expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::ASSIGNED)
+ end
+
it_behaves_like 'participating notifications' do
let(:participant) { create(:user, username: 'user-participant') }
let(:issuable) { merge_request }
@@ -1016,6 +1090,36 @@ describe NotificationService, :mailer do
end
end
+ describe '#push_to_merge_request' do
+ before do
+ update_custom_notification(:push_to_merge_request, @u_guest_custom, resource: project)
+ update_custom_notification(:push_to_merge_request, @u_custom_global)
+ end
+
+ it do
+ notification.push_to_merge_request(merge_request, @u_disabled)
+
+ should_email(merge_request.assignee)
+ should_email(@u_guest_custom)
+ should_email(@u_custom_global)
+ should_email(@u_participant_mentioned)
+ should_email(@subscriber)
+ should_email(@watcher_and_subscriber)
+ should_not_email(@u_watcher)
+ should_not_email(@u_guest_watcher)
+ should_not_email(@unsubscriber)
+ should_not_email(@u_participating)
+ should_not_email(@u_disabled)
+ should_not_email(@u_lazy_participant)
+ end
+
+ it_behaves_like 'participating notifications' do
+ let(:participant) { create(:user, username: 'user-participant') }
+ let(:issuable) { merge_request }
+ let(:notification_trigger) { notification.push_to_merge_request(merge_request, @u_disabled) }
+ end
+ end
+
describe '#relabel_merge_request' do
let(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1', merge_requests: [merge_request]) }
let(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') }
@@ -1245,6 +1349,33 @@ describe NotificationService, :mailer do
end
describe 'GroupMember' do
+ let(:added_user) { create(:user) }
+
+ describe '#new_access_request' do
+ let(:master) { create(:user) }
+ let(:owner) { create(:user) }
+ let(:developer) { create(:user) }
+ let!(:group) do
+ create(:group, :public, :access_requestable) do |group|
+ group.add_owner(owner)
+ group.add_master(master)
+ group.add_developer(developer)
+ end
+ end
+
+ before do
+ reset_delivered_emails!
+ end
+
+ it 'sends notification to group owners_and_masters' do
+ group.request_access(added_user)
+
+ should_email(owner)
+ should_email(master)
+ should_not_email(developer)
+ end
+ end
+
describe '#decline_group_invite' do
let(:creator) { create(:user) }
let(:group) { create(:group) }
@@ -1266,18 +1397,9 @@ describe NotificationService, :mailer do
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!
+ group.add_guest(added_user)
should_only_email(added_user)
end
@@ -1287,7 +1409,7 @@ describe NotificationService, :mailer do
end
it 'does not send a notification' do
- create_member!
+ group.add_guest(added_user)
should_not_email_anyone
end
end
@@ -1295,8 +1417,42 @@ describe NotificationService, :mailer do
end
describe 'ProjectMember' do
+ let(:project) { create(:project) }
+ set(:added_user) { create(:user) }
+
+ describe '#new_access_request' do
+ context 'for a project in a user namespace' do
+ let(:project) do
+ create(:project, :public, :access_requestable) do |project|
+ project.add_master(project.owner)
+ end
+ end
+
+ it 'sends notification to project owners_and_masters' do
+ project.request_access(added_user)
+
+ should_only_email(project.owner)
+ end
+ end
+
+ 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(:project, :public, :access_requestable, namespace: group) }
+
+ before do
+ reset_delivered_emails!
+ end
+
+ it 'sends notification to group owners_and_masters' do
+ project.request_access(added_user)
+
+ should_only_email(group_owner)
+ end
+ end
+ end
+
describe '#decline_group_invite' do
- let(:project) { create(:project) }
let(:member) { create(:user) }
before do
@@ -1313,19 +1469,12 @@ describe NotificationService, :mailer do
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
+ context 'when notifications are disabled' do
before do
create_global_setting_for(added_user, :disabled)
end
@@ -1336,6 +1485,10 @@ describe NotificationService, :mailer do
end
end
end
+
+ def create_member!
+ create(:project_member, user: added_user, project: project)
+ end
end
context 'guest user in private project' do
@@ -1567,6 +1720,78 @@ describe NotificationService, :mailer do
end
end
+ describe 'Pages domains' do
+ set(:project) { create(:project) }
+ set(:domain) { create(:pages_domain, project: project) }
+ set(:u_blocked) { create(:user, :blocked) }
+ set(:u_silence) { create_user_with_notification(:disabled, 'silent', project) }
+ set(:u_owner) { project.owner }
+ set(:u_master1) { create(:user) }
+ set(:u_master2) { create(:user) }
+ set(:u_developer) { create(:user) }
+
+ before do
+ project.add_master(u_blocked)
+ project.add_master(u_silence)
+ project.add_master(u_master1)
+ project.add_master(u_master2)
+ project.add_developer(u_developer)
+
+ reset_delivered_emails!
+ end
+
+ %i[
+ pages_domain_enabled
+ pages_domain_disabled
+ pages_domain_verification_succeeded
+ pages_domain_verification_failed
+ ].each do |sym|
+ describe "##{sym}" do
+ subject(:notify!) { notification.send(sym, domain) }
+
+ it 'emails current watching masters' do
+ expect(Notify).to receive(:"#{sym}_email").at_least(:once).and_call_original
+
+ notify!
+
+ should_only_email(u_master1, u_master2, u_owner)
+ end
+
+ it 'emails nobody if the project is missing' do
+ domain.project = nil
+
+ notify!
+
+ should_not_email_anyone
+ end
+ end
+ end
+
+ describe '#pages_domain_verification_failed' do
+ it 'emails current watching masters' do
+ notification.pages_domain_verification_failed(domain)
+
+ should_only_email(u_master1, u_master2, u_owner)
+ end
+ end
+
+ describe '#pages_domain_enabled' do
+ it 'emails current watching masters' do
+ notification.pages_domain_enabled(domain)
+
+ should_only_email(u_master1, u_master2, u_owner)
+ end
+ end
+
+ describe '#pages_domain_disabled' do
+ it 'emails current watching masters' do
+ notification.pages_domain_disabled(domain)
+
+ should_only_email(u_master1, u_master2, u_owner)
+ end
+ end
+ end
+
def build_team(project)
@u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating)
@@ -1640,6 +1865,7 @@ describe NotificationService, :mailer do
def add_users_with_subscription(project, issuable)
@subscriber = create :user
@unsubscriber = create :user
+ @unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned'
@subscribed_participant = create_global_setting_for(create(:user, username: 'subscribed_participant'), :participating)
@watcher_and_subscriber = create_global_setting_for(create(:user), :watch)
@@ -1647,7 +1873,9 @@ describe NotificationService, :mailer do
project.add_master(@subscriber)
project.add_master(@unsubscriber)
project.add_master(@watcher_and_subscriber)
+ project.add_master(@unsubscribed_mentioned)
+ issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false)
issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true)
issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true)
issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false)
diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb
index 426593be428..f7ff8b80bd7 100644
--- a/spec/services/projects/autocomplete_service_spec.rb
+++ b/spec/services/projects/autocomplete_service_spec.rb
@@ -34,7 +34,7 @@ describe Projects::AutocompleteService do
end
it 'does not list project confidential issues for project members with guest role' do
- project.team << [member, :guest]
+ project.add_guest(member)
autocomplete = described_class.new(project, non_member)
issues = autocomplete.issues.map(&:iid)
@@ -66,7 +66,7 @@ describe Projects::AutocompleteService do
end
it 'lists project confidential issues for project members' do
- project.team << [member, :developer]
+ project.add_developer(member)
autocomplete = described_class.new(project, member)
issues = autocomplete.issues.map(&:iid)
@@ -93,26 +93,27 @@ describe Projects::AutocompleteService 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!(:group_milestone1) { create(:milestone, group: group, due_date: '2017-01-01', title: 'Second Title') }
+ let!(:group_milestone2) { create(:milestone, group: group, due_date: '2017-01-01', title: 'First Title') }
+ let!(:project_milestone) { create(:milestone, project: project, due_date: '2016-01-01') }
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])
+ it 'includes project and group milestones and sorts them correctly' do
+ expect(milestone_titles).to eq([project_milestone.title, group_milestone2.title, group_milestone1.title])
end
it 'does not include closed milestones' do
- group_milestone.close
+ group_milestone1.close
- expect(milestone_titles).to eq([project_milestone.title])
+ expect(milestone_titles).to eq([project_milestone.title, group_milestone2.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])
+ expect(milestone_titles).to eq([group_milestone2.title, group_milestone1.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
index 9919ec254c6..609d678caea 100644
--- a/spec/services/projects/create_from_template_service_spec.rb
+++ b/spec/services/projects/create_from_template_service_spec.rb
@@ -4,8 +4,10 @@ describe Projects::CreateFromTemplateService do
let(:user) { create(:user) }
let(:project_params) do
{
- path: user.to_param,
- template_name: 'rails'
+ path: user.to_param,
+ template_name: 'rails',
+ description: 'project description',
+ visibility_level: Gitlab::VisibilityLevel::PRIVATE
}
end
@@ -22,5 +24,7 @@ describe Projects::CreateFromTemplateService do
expect(project).to be_saved
expect(project.scheduled?).to be(true)
+ expect(project.description).to match('project description')
+ expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index dc89fdebce7..e35f0f6337a 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -28,6 +28,14 @@ describe Projects::CreateService, '#execute' do
end
end
+ describe 'after create actions' do
+ it 'invalidate personal_projects_count caches' do
+ expect(user).to receive(:invalidate_personal_projects_count)
+
+ create_project(user, opts)
+ end
+ end
+
context "admin creates project with other user's namespace_id" do
it 'sets the correct permissions' do
admin = create(:admin)
@@ -70,6 +78,16 @@ describe Projects::CreateService, '#execute' do
opts[:default_branch] = 'master'
expect(create_project(user, opts)).to eq(nil)
end
+
+ it 'sets invalid service as inactive' do
+ create(:service, type: 'JiraService', project: nil, template: true, active: true)
+
+ project = create_project(user, opts)
+ service = project.services.first
+
+ expect(project).to be_persisted
+ expect(service.active).to be false
+ end
end
context 'wiki_enabled creates repository directory' do
@@ -153,7 +171,7 @@ describe Projects::CreateService, '#execute' do
context 'when another repository already exists on disk' do
let(:repository_storage) { 'default' }
- let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] }
+ let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path }
let(:opts) do
{
@@ -164,7 +182,7 @@ describe Projects::CreateService, '#execute' do
context 'with legacy storage' do
before do
- gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing")
+ gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing")
end
after do
@@ -200,7 +218,7 @@ describe Projects::CreateService, '#execute' do
end
before do
- gitlab_shell.add_repository(repository_storage, hashed_path)
+ gitlab_shell.create_repository(repository_storage, hashed_path)
end
after do
@@ -232,14 +250,15 @@ describe Projects::CreateService, '#execute' do
end
context 'when a bad service template is created' do
- it 'reports an error in the imported project' do
+ it 'sets service to be inactive' 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)
+ service = project.services.first
- 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
+ expect(project).to be_persisted
+ expect(service.active).to be false
end
end
@@ -252,6 +271,12 @@ describe Projects::CreateService, '#execute' do
end
end
+ it 'writes project full path to .git/config' do
+ project = create_project(user, opts)
+
+ expect(project.repository.rugged.config['gitlab.fullpath']).to eq project.full_path
+ end
+
def create_project(user, opts)
Projects::CreateService.new(user, opts).execute
end
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index 0bec2054f50..a66e3c5e995 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -66,6 +66,12 @@ describe Projects::DestroyService do
end
it_behaves_like 'deleting the project'
+
+ it 'invalidates personal_project_count cache' do
+ expect(user).to receive(:invalidate_personal_projects_count)
+
+ destroy_project(project, user)
+ end
end
context 'Sidekiq fake' do
@@ -242,6 +248,28 @@ describe Projects::DestroyService do
end
end
+ context '#attempt_restore_repositories' do
+ let(:path) { project.disk_path + '.git' }
+
+ before do
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, path)).to be_truthy
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey
+
+ # Dont run sidekiq to check if renamed repository exists
+ Sidekiq::Testing.fake! { destroy_project(project, user, {}) }
+
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, path)).to be_falsey
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_truthy
+ end
+
+ it 'restores the repositories' do
+ Sidekiq::Testing.fake! { described_class.new(project, user).attempt_repositories_rollback }
+
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, path)).to be_truthy
+ expect(project.gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey
+ end
+ end
+
def destroy_project(project, user, params = {})
if async
Projects::DestroyService.new(project, user, params).async_execute
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index 4057caca2ac..0f7c46367d0 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -105,10 +105,10 @@ describe Projects::ForkService do
context 'repository already exists' do
let(:repository_storage) { 'default' }
- let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] }
+ let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path }
before do
- gitlab_shell.add_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}")
+ gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}")
end
after do
@@ -139,10 +139,10 @@ describe Projects::ForkService do
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
end
- it "creates fork with highest allowed level" do
+ it "creates fork with lowest level" do
forked_project = fork_project(@from_project, @to_user)
- expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC)
+ expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
@@ -209,6 +209,19 @@ describe Projects::ForkService do
expect(to_project.errors[:path]).to eq(['has already been taken'])
end
end
+
+ context 'when the namespace has a lower visibility level than the project' do
+ it 'creates the project with the lower visibility level' do
+ public_project = create(:project, :public)
+ private_group = create(:group, :private)
+ group_owner = create(:user)
+ private_group.add_owner(group_owner)
+
+ forked_project = fork_project(public_project, group_owner, namespace: private_group)
+
+ expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
+ end
+ end
end
end
diff --git a/spec/services/projects/gitlab_projects_import_service_spec.rb b/spec/services/projects/gitlab_projects_import_service_spec.rb
new file mode 100644
index 00000000000..ee1a886f5d6
--- /dev/null
+++ b/spec/services/projects/gitlab_projects_import_service_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe Projects::GitlabProjectsImportService do
+ set(:namespace) { create(:namespace) }
+ let(:path) { 'test-path' }
+ let(:file) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') }
+ let(:overwrite) { false }
+ let(:import_params) { { namespace_id: namespace.id, path: path, file: file, overwrite: overwrite } }
+ subject { described_class.new(namespace.owner, import_params) }
+
+ describe '#execute' do
+ context 'with an invalid path' do
+ let(:path) { '/invalid-path/' }
+
+ it 'returns an invalid project' do
+ project = subject.execute
+
+ expect(project).not_to be_persisted
+ expect(project).not_to be_valid
+ end
+ end
+
+ context 'with a valid path' do
+ it 'creates a project' do
+ project = subject.execute
+
+ expect(project).to be_persisted
+ expect(project).to be_valid
+ end
+ end
+
+ context 'override params' do
+ it 'stores them as import data when passed' do
+ project = described_class
+ .new(namespace.owner, import_params, description: 'Hello')
+ .execute
+
+ expect(project.import_data.data['override_params']['description']).to eq('Hello')
+ end
+ end
+
+ context 'when there is a project with the same path' do
+ let(:existing_project) { create(:project, namespace: namespace) }
+ let(:path) { existing_project.path}
+
+ it 'does not create the project' do
+ project = subject.execute
+
+ expect(project).to be_invalid
+ expect(project).not_to be_persisted
+ end
+
+ context 'when overwrite param is set' do
+ let(:overwrite) { true }
+
+ it 'creates a project in a temporary full_path' do
+ project = subject.execute
+
+ expect(project).to be_valid
+ expect(project).to be_persisted
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb b/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb
index 50e59954f73..fb6d7171ac3 100644
--- a/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migrate_attachments_service_spec.rb
@@ -2,11 +2,11 @@ require 'spec_helper'
describe Projects::HashedStorage::MigrateAttachmentsService do
subject(:service) { described_class.new(project) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :legacy_storage) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
let(:hashed_storage) { Storage::HashedProject.new(project) }
- let!(:upload) { Upload.find_by(path: file_uploader.relative_path) }
+ let!(:upload) { Upload.find_by(path: file_uploader.upload_path) }
let(:file_uploader) { build(:file_uploader, project: project) }
let(:old_path) { File.join(base_path(legacy_storage), upload.path) }
let(:new_path) { File.join(base_path(hashed_storage), upload.path) }
@@ -58,6 +58,6 @@ describe Projects::HashedStorage::MigrateAttachmentsService do
end
def base_path(storage)
- FileUploader.dynamic_path_builder(storage.disk_path)
+ File.join(FileUploader.root, storage.disk_path)
end
end
diff --git a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
index 3a3e47fd9c0..747bd4529a0 100644
--- a/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
+++ b/spec/services/projects/hashed_storage/migrate_repository_service_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Projects::HashedStorage::MigrateRepositoryService do
let(:gitlab_shell) { Gitlab::Shell.new }
- let(:project) { create(:project, :empty_repo, :wiki_repo) }
+ let(:project) { create(:project, :legacy_storage, :repository, :wiki_repo) }
let(:service) { described_class.new(project) }
let(:legacy_storage) { Storage::LegacyProject.new(project) }
let(:hashed_storage) { Storage::HashedProject.new(project) }
@@ -33,6 +33,12 @@ describe Projects::HashedStorage::MigrateRepositoryService do
service.execute
end
+
+ it 'writes project full path to .git/config' do
+ service.execute
+
+ expect(project.repository.rugged.config['gitlab.fullpath']).to eq project.full_path
+ end
end
context 'when one move fails' do
diff --git a/spec/services/projects/hashed_storage_migration_service_spec.rb b/spec/services/projects/hashed_storage_migration_service_spec.rb
index 466f0b5d7c2..e8e18bb3ac0 100644
--- a/spec/services/projects/hashed_storage_migration_service_spec.rb
+++ b/spec/services/projects/hashed_storage_migration_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Projects::HashedStorageMigrationService do
- let(:project) { create(:project, :empty_repo, :wiki_repo) }
+ let(:project) { create(:project, :empty_repo, :wiki_repo, :legacy_storage) }
subject(:service) { described_class.new(project) }
describe '#execute' do
diff --git a/spec/services/projects/import_export/export_service_spec.rb b/spec/services/projects/import_export/export_service_spec.rb
new file mode 100644
index 00000000000..f9e5530bc9d
--- /dev/null
+++ b/spec/services/projects/import_export/export_service_spec.rb
@@ -0,0 +1,128 @@
+require 'spec_helper'
+
+describe Projects::ImportExport::ExportService do
+ describe '#execute' do
+ let!(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:shared) { project.import_export_shared }
+ let(:service) { described_class.new(project, user) }
+ let!(:after_export_strategy) { Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy.new }
+
+ it 'saves the version' do
+ expect(Gitlab::ImportExport::VersionSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ it 'saves the avatar' do
+ expect(Gitlab::ImportExport::AvatarSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ it 'saves the models' do
+ expect(Gitlab::ImportExport::ProjectTreeSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ it 'saves the uploads' do
+ expect(Gitlab::ImportExport::UploadsSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ it 'saves the repo' do
+ # once for the normal repo, once for the wiki
+ expect(Gitlab::ImportExport::RepoSaver).to receive(:new).twice.and_call_original
+
+ service.execute
+ end
+
+ it 'saves the lfs objects' do
+ expect(Gitlab::ImportExport::LfsSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ it 'saves the wiki repo' do
+ expect(Gitlab::ImportExport::WikiRepoSaver).to receive(:new).and_call_original
+
+ service.execute
+ end
+
+ context 'when all saver services succeed' do
+ before do
+ allow(service).to receive(:save_services).and_return(true)
+ end
+
+ it 'saves the project in the file system' do
+ expect(Gitlab::ImportExport::Saver).to receive(:save).with(project: project, shared: shared)
+
+ service.execute
+ end
+
+ it 'calls the after export strategy' do
+ expect(after_export_strategy).to receive(:execute)
+
+ service.execute(after_export_strategy)
+ end
+
+ context 'when after export strategy fails' do
+ before do
+ allow(after_export_strategy).to receive(:execute).and_return(false)
+ end
+
+ after do
+ service.execute(after_export_strategy)
+ end
+
+ it 'removes the remaining exported data' do
+ allow(shared).to receive(:export_path).and_return('whatever')
+ allow(FileUtils).to receive(:rm_rf)
+
+ expect(FileUtils).to receive(:rm_rf).with(shared.export_path)
+ end
+
+ it 'notifies the user' do
+ expect_any_instance_of(NotificationService).to receive(:project_not_exported)
+ end
+
+ it 'notifies logger' do
+ allow(Rails.logger).to receive(:error)
+
+ expect(Rails.logger).to receive(:error)
+ end
+ end
+ end
+
+ context 'when saver services fail' do
+ before do
+ allow(service).to receive(:save_services).and_return(false)
+ end
+
+ after do
+ expect { service.execute }.to raise_error(Gitlab::ImportExport::Error)
+ end
+
+ it 'removes the remaining exported data' do
+ allow(shared).to receive(:export_path).and_return('whatever')
+ allow(FileUtils).to receive(:rm_rf)
+
+ expect(FileUtils).to receive(:rm_rf).with(shared.export_path)
+ end
+
+ it 'notifies the user' do
+ expect_any_instance_of(NotificationService).to receive(:project_not_exported)
+ end
+
+ it 'notifies logger' do
+ expect(Rails.logger).to receive(:error)
+ end
+
+ it 'the after export strategy is not called' do
+ expect(service).not_to receive(:execute_after_export_action)
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb
index bf7facaec99..30c89ebd821 100644
--- a/spec/services/projects/import_service_spec.rb
+++ b/spec/services/projects/import_service_spec.rb
@@ -156,7 +156,7 @@ describe Projects::ImportService do
result = described_class.new(project, user).execute
expect(result[:status]).to eq :error
- expect(result[:message]).to end_with 'Blocked import URL.'
+ expect(result[:message]).to include('Requests to localhost are not allowed')
end
it 'fails with port 25' do
@@ -165,7 +165,7 @@ describe Projects::ImportService do
result = described_class.new(project, user).execute
expect(result[:status]).to eq :error
- expect(result[:message]).to end_with 'Blocked import URL.'
+ expect(result[:message]).to include('Only allowed ports are 22, 80, 443')
end
end
diff --git a/spec/services/projects/move_access_service_spec.rb b/spec/services/projects/move_access_service_spec.rb
new file mode 100644
index 00000000000..a820ebd91f4
--- /dev/null
+++ b/spec/services/projects/move_access_service_spec.rb
@@ -0,0 +1,114 @@
+require 'spec_helper'
+
+describe Projects::MoveAccessService do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:project_with_access) { create(:project, namespace: user.namespace) }
+ let(:master_user) { create(:user) }
+ let(:reporter_user) { create(:user) }
+ let(:developer_user) { create(:user) }
+ let(:master_group) { create(:group) }
+ let(:reporter_group) { create(:group) }
+ let(:developer_group) { create(:group) }
+
+ before do
+ project_with_access.add_master(master_user)
+ project_with_access.add_developer(developer_user)
+ project_with_access.add_reporter(reporter_user)
+ project_with_access.project_group_links.create(group: master_group, group_access: Gitlab::Access::MASTER)
+ project_with_access.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
+ project_with_access.project_group_links.create(group: reporter_group, group_access: Gitlab::Access::REPORTER)
+ end
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ shared_examples 'move the accesses' do
+ it do
+ expect(project_with_access.project_members.count).to eq 4
+ expect(project_with_access.project_group_links.count).to eq 3
+ expect(project_with_access.authorized_users.count).to eq 4
+
+ subject.execute(project_with_access)
+
+ expect(project_with_access.project_members.count).to eq 0
+ expect(project_with_access.project_group_links.count).to eq 0
+ expect(project_with_access.authorized_users.count).to eq 1
+ expect(target_project.project_members.count).to eq 4
+ expect(target_project.project_group_links.count).to eq 3
+ expect(target_project.authorized_users.count).to eq 4
+ end
+
+ it 'rollbacks if an exception is raised' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_groups) }.to raise_error(StandardError)
+
+ expect(project_with_access.project_members.count).to eq 4
+ expect(project_with_access.project_group_links.count).to eq 3
+ expect(project_with_access.authorized_users.count).to eq 4
+ end
+ end
+
+ context 'when both projects are in the same namespace' do
+ let(:target_project) { create(:project, namespace: user.namespace) }
+
+ it 'does not refresh project owner authorized projects' do
+ allow(project_with_access).to receive(:namespace).and_return(user.namespace)
+ expect(project_with_access.namespace).not_to receive(:refresh_project_authorizations)
+ expect(target_project.namespace).not_to receive(:refresh_project_authorizations)
+
+ subject.execute(project_with_access)
+ end
+
+ it_behaves_like 'move the accesses'
+ end
+
+ context 'when projects are in different namespaces' do
+ let(:target_project) { create(:project, namespace: group) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'refreshes both project owner authorized projects' do
+ allow(project_with_access).to receive(:namespace).and_return(user.namespace)
+ expect(user.namespace).to receive(:refresh_project_authorizations).once
+ expect(group).to receive(:refresh_project_authorizations).once
+
+ subject.execute(project_with_access)
+ end
+
+ it_behaves_like 'move the accesses'
+ end
+
+ context 'when remove_remaining_elements is false' do
+ let(:target_project) { create(:project, namespace: user.namespace) }
+ let(:options) { { remove_remaining_elements: false } }
+
+ it 'does not remove remaining memberships' do
+ target_project.add_master(master_user)
+
+ subject.execute(project_with_access, options)
+
+ expect(project_with_access.project_members.count).not_to eq 0
+ end
+
+ it 'does not remove remaining group links' do
+ target_project.project_group_links.create(group: master_group, group_access: Gitlab::Access::MASTER)
+
+ subject.execute(project_with_access, options)
+
+ expect(project_with_access.project_group_links.count).not_to eq 0
+ end
+
+ it 'does not remove remaining authorizations' do
+ target_project.add_developer(developer_user)
+
+ subject.execute(project_with_access, options)
+
+ expect(project_with_access.project_authorizations.count).not_to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/move_deploy_keys_projects_service_spec.rb b/spec/services/projects/move_deploy_keys_projects_service_spec.rb
new file mode 100644
index 00000000000..c548edf39a8
--- /dev/null
+++ b/spec/services/projects/move_deploy_keys_projects_service_spec.rb
@@ -0,0 +1,58 @@
+require 'spec_helper'
+
+describe Projects::MoveDeployKeysProjectsService do
+ let!(:user) { create(:user) }
+ let!(:project_with_deploy_keys) { create(:project, namespace: user.namespace) }
+ let!(:target_project) { create(:project, namespace: user.namespace) }
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ before do
+ create_list(:deploy_keys_project, 2, project: project_with_deploy_keys)
+ end
+
+ it 'moves the user\'s deploy keys from one project to another' do
+ expect(project_with_deploy_keys.deploy_keys_projects.count).to eq 2
+ expect(target_project.deploy_keys_projects.count).to eq 0
+
+ subject.execute(project_with_deploy_keys)
+
+ expect(project_with_deploy_keys.deploy_keys_projects.count).to eq 0
+ expect(target_project.deploy_keys_projects.count).to eq 2
+ end
+
+ it 'does not link existent deploy_keys in the current project' do
+ target_project.deploy_keys << project_with_deploy_keys.deploy_keys.first
+
+ expect(project_with_deploy_keys.deploy_keys_projects.count).to eq 2
+ expect(target_project.deploy_keys_projects.count).to eq 1
+
+ subject.execute(project_with_deploy_keys)
+
+ expect(project_with_deploy_keys.deploy_keys_projects.count).to eq 0
+ expect(target_project.deploy_keys_projects.count).to eq 2
+ end
+
+ it 'rollbacks changes if transaction fails' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_deploy_keys) }.to raise_error(StandardError)
+
+ expect(project_with_deploy_keys.deploy_keys_projects.count).to eq 2
+ expect(target_project.deploy_keys_projects.count).to eq 0
+ end
+
+ context 'when remove_remaining_elements is false' do
+ let(:options) { { remove_remaining_elements: false } }
+
+ it 'does not remove remaining deploy keys projects' do
+ target_project.deploy_keys << project_with_deploy_keys.deploy_keys.first
+
+ subject.execute(project_with_deploy_keys, options)
+
+ expect(project_with_deploy_keys.deploy_keys_projects.count).not_to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/move_forks_service_spec.rb b/spec/services/projects/move_forks_service_spec.rb
new file mode 100644
index 00000000000..f4a5a7f9fc2
--- /dev/null
+++ b/spec/services/projects/move_forks_service_spec.rb
@@ -0,0 +1,96 @@
+require 'spec_helper'
+
+describe Projects::MoveForksService do
+ include ProjectForksHelper
+
+ let!(:user) { create(:user) }
+ let!(:project_with_forks) { create(:project, namespace: user.namespace) }
+ let!(:target_project) { create(:project, namespace: user.namespace) }
+ let!(:lvl1_forked_project_1) { fork_project(project_with_forks, user) }
+ let!(:lvl1_forked_project_2) { fork_project(project_with_forks, user) }
+ let!(:lvl2_forked_project_1_1) { fork_project(lvl1_forked_project_1, user) }
+ let!(:lvl2_forked_project_1_2) { fork_project(lvl1_forked_project_1, user) }
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ context 'when moving a root forked project' do
+ it 'moves the descendant forks' do
+ expect(project_with_forks.forks.count).to eq 2
+ expect(target_project.forks.count).to eq 0
+
+ subject.execute(project_with_forks)
+
+ expect(project_with_forks.forks.count).to eq 0
+ expect(target_project.forks.count).to eq 2
+ expect(lvl1_forked_project_1.forked_from_project).to eq target_project
+ expect(lvl1_forked_project_1.fork_network_member.forked_from_project).to eq target_project
+ expect(lvl1_forked_project_2.forked_from_project).to eq target_project
+ expect(lvl1_forked_project_2.fork_network_member.forked_from_project).to eq target_project
+ end
+
+ it 'updates the fork network' do
+ expect(project_with_forks.fork_network.root_project).to eq project_with_forks
+ expect(project_with_forks.fork_network.fork_network_members.map(&:project)).to include project_with_forks
+
+ subject.execute(project_with_forks)
+
+ expect(target_project.reload.fork_network.root_project).to eq target_project
+ expect(target_project.fork_network.fork_network_members.map(&:project)).not_to include project_with_forks
+ end
+ end
+
+ context 'when moving a intermediate forked project' do
+ it 'moves the descendant forks' do
+ expect(lvl1_forked_project_1.forks.count).to eq 2
+ expect(target_project.forks.count).to eq 0
+
+ subject.execute(lvl1_forked_project_1)
+
+ expect(lvl1_forked_project_1.forks.count).to eq 0
+ expect(target_project.forks.count).to eq 2
+ expect(lvl2_forked_project_1_1.forked_from_project).to eq target_project
+ expect(lvl2_forked_project_1_1.fork_network_member.forked_from_project).to eq target_project
+ expect(lvl2_forked_project_1_2.forked_from_project).to eq target_project
+ expect(lvl2_forked_project_1_2.fork_network_member.forked_from_project).to eq target_project
+ end
+
+ it 'moves the ascendant fork' do
+ subject.execute(lvl1_forked_project_1)
+
+ expect(target_project.forked_from_project).to eq project_with_forks
+ expect(target_project.fork_network_member.forked_from_project).to eq project_with_forks
+ end
+
+ it 'does not update fork network' do
+ subject.execute(lvl1_forked_project_1)
+
+ expect(target_project.reload.fork_network.root_project).to eq project_with_forks
+ end
+ end
+
+ context 'when moving a leaf forked project' do
+ it 'moves the ascendant fork' do
+ subject.execute(lvl2_forked_project_1_1)
+
+ expect(target_project.forked_from_project).to eq lvl1_forked_project_1
+ expect(target_project.fork_network_member.forked_from_project).to eq lvl1_forked_project_1
+ end
+
+ it 'does not update fork network' do
+ subject.execute(lvl2_forked_project_1_1)
+
+ expect(target_project.reload.fork_network.root_project).to eq project_with_forks
+ end
+ end
+
+ it 'rollbacks changes if transaction fails' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_forks) }.to raise_error(StandardError)
+
+ expect(project_with_forks.forks.count).to eq 2
+ expect(target_project.forks.count).to eq 0
+ end
+ end
+end
diff --git a/spec/services/projects/move_lfs_objects_projects_service_spec.rb b/spec/services/projects/move_lfs_objects_projects_service_spec.rb
new file mode 100644
index 00000000000..517a24a982a
--- /dev/null
+++ b/spec/services/projects/move_lfs_objects_projects_service_spec.rb
@@ -0,0 +1,55 @@
+require 'spec_helper'
+
+describe Projects::MoveLfsObjectsProjectsService do
+ let!(:user) { create(:user) }
+ let!(:project_with_lfs_objects) { create(:project, namespace: user.namespace) }
+ let!(:target_project) { create(:project, namespace: user.namespace) }
+
+ subject { described_class.new(target_project, user) }
+
+ before do
+ create_list(:lfs_objects_project, 3, project: project_with_lfs_objects)
+ end
+
+ describe '#execute' do
+ it 'links the lfs objects from existent in source project' do
+ expect(target_project.lfs_objects.count).to eq 0
+
+ subject.execute(project_with_lfs_objects)
+
+ expect(project_with_lfs_objects.reload.lfs_objects.count).to eq 0
+ expect(target_project.reload.lfs_objects.count).to eq 3
+ end
+
+ it 'does not link existent lfs_object in the current project' do
+ target_project.lfs_objects << project_with_lfs_objects.lfs_objects.first(2)
+
+ expect(target_project.lfs_objects.count).to eq 2
+
+ subject.execute(project_with_lfs_objects)
+
+ expect(target_project.lfs_objects.count).to eq 3
+ end
+
+ it 'rollbacks changes if transaction fails' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_lfs_objects) }.to raise_error(StandardError)
+
+ expect(project_with_lfs_objects.lfs_objects.count).to eq 3
+ expect(target_project.lfs_objects.count).to eq 0
+ end
+
+ context 'when remove_remaining_elements is false' do
+ let(:options) { { remove_remaining_elements: false } }
+
+ it 'does not remove remaining lfs objects' do
+ target_project.lfs_objects << project_with_lfs_objects.lfs_objects.first(2)
+
+ subject.execute(project_with_lfs_objects, options)
+
+ expect(project_with_lfs_objects.lfs_objects.count).not_to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/move_notification_settings_service_spec.rb b/spec/services/projects/move_notification_settings_service_spec.rb
new file mode 100644
index 00000000000..24d69eef86a
--- /dev/null
+++ b/spec/services/projects/move_notification_settings_service_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe Projects::MoveNotificationSettingsService do
+ let(:user) { create(:user) }
+ let(:project_with_notifications) { create(:project, namespace: user.namespace) }
+ let(:target_project) { create(:project, namespace: user.namespace) }
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ context 'with notification settings' do
+ before do
+ create_list(:notification_setting, 2, source: project_with_notifications)
+ end
+
+ it 'moves the user\'s notification settings from one project to another' do
+ expect(project_with_notifications.notification_settings.count).to eq 3
+ expect(target_project.notification_settings.count).to eq 1
+
+ subject.execute(project_with_notifications)
+
+ expect(project_with_notifications.notification_settings.count).to eq 0
+ expect(target_project.notification_settings.count).to eq 3
+ end
+
+ it 'rollbacks changes if transaction fails' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_notifications) }.to raise_error(StandardError)
+
+ expect(project_with_notifications.notification_settings.count).to eq 3
+ expect(target_project.notification_settings.count).to eq 1
+ end
+ end
+
+ it 'does not move existent notification settings in the current project' do
+ expect(project_with_notifications.notification_settings.count).to eq 1
+ expect(target_project.notification_settings.count).to eq 1
+ expect(user.notification_settings.count).to eq 2
+
+ subject.execute(project_with_notifications)
+
+ expect(user.notification_settings.count).to eq 1
+ end
+
+ context 'when remove_remaining_elements is false' do
+ let(:options) { { remove_remaining_elements: false } }
+
+ it 'does not remove remaining notification settings' do
+ subject.execute(project_with_notifications, options)
+
+ expect(project_with_notifications.notification_settings.count).not_to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/move_project_authorizations_service_spec.rb b/spec/services/projects/move_project_authorizations_service_spec.rb
new file mode 100644
index 00000000000..f7262b9b887
--- /dev/null
+++ b/spec/services/projects/move_project_authorizations_service_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe Projects::MoveProjectAuthorizationsService do
+ let!(:user) { create(:user) }
+ let(:project_with_users) { create(:project, namespace: user.namespace) }
+ let(:target_project) { create(:project, namespace: user.namespace) }
+ let(:master_user) { create(:user) }
+ let(:reporter_user) { create(:user) }
+ let(:developer_user) { create(:user) }
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ before do
+ project_with_users.add_master(master_user)
+ project_with_users.add_developer(developer_user)
+ project_with_users.add_reporter(reporter_user)
+ end
+
+ it 'moves the authorizations from one project to another' do
+ expect(project_with_users.authorized_users.count).to eq 4
+ expect(target_project.authorized_users.count).to eq 1
+
+ subject.execute(project_with_users)
+
+ expect(project_with_users.authorized_users.count).to eq 0
+ expect(target_project.authorized_users.count).to eq 4
+ end
+
+ it 'does not move existent authorizations to the current project' do
+ target_project.add_master(developer_user)
+ target_project.add_developer(reporter_user)
+
+ expect(project_with_users.authorized_users.count).to eq 4
+ expect(target_project.authorized_users.count).to eq 3
+
+ subject.execute(project_with_users)
+
+ expect(project_with_users.authorized_users.count).to eq 0
+ expect(target_project.authorized_users.count).to eq 4
+ end
+
+ context 'when remove_remaining_elements is false' do
+ let(:options) { { remove_remaining_elements: false } }
+
+ it 'does not remove remaining project authorizations' do
+ target_project.add_master(developer_user)
+ target_project.add_developer(reporter_user)
+
+ subject.execute(project_with_users, options)
+
+ expect(project_with_users.project_authorizations.count).not_to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/move_project_group_links_service_spec.rb b/spec/services/projects/move_project_group_links_service_spec.rb
new file mode 100644
index 00000000000..e3d06e6d3d7
--- /dev/null
+++ b/spec/services/projects/move_project_group_links_service_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe Projects::MoveProjectGroupLinksService do
+ let!(:user) { create(:user) }
+ let(:project_with_groups) { create(:project, namespace: user.namespace) }
+ let(:target_project) { create(:project, namespace: user.namespace) }
+ let(:master_group) { create(:group) }
+ let(:reporter_group) { create(:group) }
+ let(:developer_group) { create(:group) }
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ before do
+ project_with_groups.project_group_links.create(group: master_group, group_access: Gitlab::Access::MASTER)
+ project_with_groups.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
+ project_with_groups.project_group_links.create(group: reporter_group, group_access: Gitlab::Access::REPORTER)
+ end
+
+ it 'moves the group links from one project to another' do
+ expect(project_with_groups.project_group_links.count).to eq 3
+ expect(target_project.project_group_links.count).to eq 0
+
+ subject.execute(project_with_groups)
+
+ expect(project_with_groups.project_group_links.count).to eq 0
+ expect(target_project.project_group_links.count).to eq 3
+ end
+
+ it 'does not move existent group links in the current project' do
+ target_project.project_group_links.create(group: master_group, group_access: Gitlab::Access::MASTER)
+ target_project.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
+
+ expect(project_with_groups.project_group_links.count).to eq 3
+ expect(target_project.project_group_links.count).to eq 2
+
+ subject.execute(project_with_groups)
+
+ expect(project_with_groups.project_group_links.count).to eq 0
+ expect(target_project.project_group_links.count).to eq 3
+ end
+
+ it 'rollbacks changes if transaction fails' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_groups) }.to raise_error(StandardError)
+
+ expect(project_with_groups.project_group_links.count).to eq 3
+ expect(target_project.project_group_links.count).to eq 0
+ end
+
+ context 'when remove_remaining_elements is false' do
+ let(:options) { { remove_remaining_elements: false } }
+
+ it 'does not remove remaining project group links' do
+ target_project.project_group_links.create(group: master_group, group_access: Gitlab::Access::MASTER)
+ target_project.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
+
+ subject.execute(project_with_groups, options)
+
+ expect(project_with_groups.project_group_links.count).not_to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/move_project_members_service_spec.rb b/spec/services/projects/move_project_members_service_spec.rb
new file mode 100644
index 00000000000..9c9a2d2fde1
--- /dev/null
+++ b/spec/services/projects/move_project_members_service_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+
+describe Projects::MoveProjectMembersService do
+ let!(:user) { create(:user) }
+ let(:project_with_users) { create(:project, namespace: user.namespace) }
+ let(:target_project) { create(:project, namespace: user.namespace) }
+ let(:master_user) { create(:user) }
+ let(:reporter_user) { create(:user) }
+ let(:developer_user) { create(:user) }
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ before do
+ project_with_users.add_master(master_user)
+ project_with_users.add_developer(developer_user)
+ project_with_users.add_reporter(reporter_user)
+ end
+
+ it 'moves the members from one project to another' do
+ expect(project_with_users.project_members.count).to eq 4
+ expect(target_project.project_members.count).to eq 1
+
+ subject.execute(project_with_users)
+
+ expect(project_with_users.project_members.count).to eq 0
+ expect(target_project.project_members.count).to eq 4
+ end
+
+ it 'does not move existent members to the current project' do
+ target_project.add_master(developer_user)
+ target_project.add_developer(reporter_user)
+
+ expect(project_with_users.project_members.count).to eq 4
+ expect(target_project.project_members.count).to eq 3
+
+ subject.execute(project_with_users)
+
+ expect(project_with_users.project_members.count).to eq 0
+ expect(target_project.project_members.count).to eq 4
+ end
+
+ it 'rollbacks changes if transaction fails' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_users) }.to raise_error(StandardError)
+
+ expect(project_with_users.project_members.count).to eq 4
+ expect(target_project.project_members.count).to eq 1
+ end
+
+ context 'when remove_remaining_elements is false' do
+ let(:options) { { remove_remaining_elements: false } }
+
+ it 'does not remove remaining project members' do
+ target_project.add_master(developer_user)
+ target_project.add_developer(reporter_user)
+
+ subject.execute(project_with_users, options)
+
+ expect(project_with_users.project_members.count).not_to eq 0
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/move_users_star_projects_service_spec.rb b/spec/services/projects/move_users_star_projects_service_spec.rb
new file mode 100644
index 00000000000..e0545c5a21b
--- /dev/null
+++ b/spec/services/projects/move_users_star_projects_service_spec.rb
@@ -0,0 +1,42 @@
+require 'spec_helper'
+
+describe Projects::MoveUsersStarProjectsService do
+ let!(:user) { create(:user) }
+ let!(:project_with_stars) { create(:project, namespace: user.namespace) }
+ let!(:target_project) { create(:project, namespace: user.namespace) }
+
+ subject { described_class.new(target_project, user) }
+
+ describe '#execute' do
+ before do
+ create_list(:users_star_project, 2, project: project_with_stars)
+ end
+
+ it 'moves the user\'s stars from one project to another' do
+ expect(project_with_stars.users_star_projects.count).to eq 2
+ expect(project_with_stars.star_count).to eq 2
+ expect(target_project.users_star_projects.count).to eq 0
+ expect(target_project.star_count).to eq 0
+
+ subject.execute(project_with_stars)
+ project_with_stars.reload
+ target_project.reload
+
+ expect(project_with_stars.users_star_projects.count).to eq 0
+ expect(project_with_stars.star_count).to eq 0
+ expect(target_project.users_star_projects.count).to eq 2
+ expect(target_project.star_count).to eq 2
+ end
+
+ it 'rollbacks changes if transaction fails' do
+ allow(subject).to receive(:success).and_raise(StandardError)
+
+ expect { subject.execute(project_with_stars) }.to raise_error(StandardError)
+
+ expect(project_with_stars.users_star_projects.count).to eq 2
+ expect(project_with_stars.star_count).to eq 2
+ expect(target_project.users_star_projects.count).to eq 0
+ expect(target_project.star_count).to eq 0
+ end
+ end
+end
diff --git a/spec/services/projects/overwrite_project_service_spec.rb b/spec/services/projects/overwrite_project_service_spec.rb
new file mode 100644
index 00000000000..252c61f4224
--- /dev/null
+++ b/spec/services/projects/overwrite_project_service_spec.rb
@@ -0,0 +1,198 @@
+require 'spec_helper'
+
+describe Projects::OverwriteProjectService do
+ include ProjectForksHelper
+
+ let(:user) { create(:user) }
+ let(:project_from) { create(:project, namespace: user.namespace) }
+ let(:project_to) { create(:project, namespace: user.namespace) }
+ let!(:lvl1_forked_project_1) { fork_project(project_from, user) }
+ let!(:lvl1_forked_project_2) { fork_project(project_from, user) }
+ let!(:lvl2_forked_project_1_1) { fork_project(lvl1_forked_project_1, user) }
+ let!(:lvl2_forked_project_1_2) { fork_project(lvl1_forked_project_1, user) }
+
+ subject { described_class.new(project_to, user) }
+
+ before do
+ allow(project_to).to receive(:import_data).and_return(double(data: { 'original_path' => project_from.path }))
+ end
+
+ describe '#execute' do
+ shared_examples 'overwrite actions' do
+ it 'moves deploy keys' do
+ deploy_keys_count = project_from.deploy_keys_projects.count
+
+ subject.execute(project_from)
+
+ expect(project_to.deploy_keys_projects.count).to eq deploy_keys_count
+ end
+
+ it 'moves notification settings' do
+ notification_count = project_from.notification_settings.count
+
+ subject.execute(project_from)
+
+ expect(project_to.notification_settings.count).to eq notification_count
+ end
+
+ it 'moves users stars' do
+ stars_count = project_from.users_star_projects.count
+
+ subject.execute(project_from)
+ project_to.reload
+
+ expect(project_to.users_star_projects.count).to eq stars_count
+ expect(project_to.star_count).to eq stars_count
+ end
+
+ it 'moves project group links' do
+ group_links_count = project_from.project_group_links.count
+
+ subject.execute(project_from)
+
+ expect(project_to.project_group_links.count).to eq group_links_count
+ end
+
+ it 'moves memberships and authorizations' do
+ members_count = project_from.project_members.count
+ project_authorizations = project_from.project_authorizations.count
+
+ subject.execute(project_from)
+
+ expect(project_to.project_members.count).to eq members_count
+ expect(project_to.project_authorizations.count).to eq project_authorizations
+ end
+
+ context 'moves lfs objects relationships' do
+ before do
+ create_list(:lfs_objects_project, 3, project: project_from)
+ end
+
+ it do
+ lfs_objects_count = project_from.lfs_objects.count
+
+ subject.execute(project_from)
+
+ expect(project_to.lfs_objects.count).to eq lfs_objects_count
+ end
+ end
+
+ it 'removes the original project' do
+ subject.execute(project_from)
+
+ expect { Project.find(project_from.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'renames the project' do
+ subject.execute(project_from)
+
+ expect(project_to.full_path).to eq project_from.full_path
+ end
+ end
+
+ context 'when project does not have any relation' do
+ it_behaves_like 'overwrite actions'
+ end
+
+ context 'when project with elements' do
+ it_behaves_like 'overwrite actions' do
+ let(:master_user) { create(:user) }
+ let(:reporter_user) { create(:user) }
+ let(:developer_user) { create(:user) }
+ let(:master_group) { create(:group) }
+ let(:reporter_group) { create(:group) }
+ let(:developer_group) { create(:group) }
+
+ before do
+ create_list(:deploy_keys_project, 2, project: project_from)
+ create_list(:notification_setting, 2, source: project_from)
+ create_list(:users_star_project, 2, project: project_from)
+ project_from.project_group_links.create(group: master_group, group_access: Gitlab::Access::MASTER)
+ project_from.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
+ project_from.project_group_links.create(group: reporter_group, group_access: Gitlab::Access::REPORTER)
+ project_from.add_master(master_user)
+ project_from.add_developer(developer_user)
+ project_from.add_reporter(reporter_user)
+ end
+ end
+ end
+
+ context 'forks' do
+ context 'when moving a root forked project' do
+ it 'moves the descendant forks' do
+ expect(project_from.forks.count).to eq 2
+ expect(project_to.forks.count).to eq 0
+
+ subject.execute(project_from)
+
+ expect(project_from.forks.count).to eq 0
+ expect(project_to.forks.count).to eq 2
+ expect(lvl1_forked_project_1.forked_from_project).to eq project_to
+ expect(lvl1_forked_project_1.fork_network_member.forked_from_project).to eq project_to
+ expect(lvl1_forked_project_2.forked_from_project).to eq project_to
+ expect(lvl1_forked_project_2.fork_network_member.forked_from_project).to eq project_to
+ end
+
+ it 'updates the fork network' do
+ expect(project_from.fork_network.root_project).to eq project_from
+ expect(project_from.fork_network.fork_network_members.map(&:project)).to include project_from
+
+ subject.execute(project_from)
+
+ expect(project_to.reload.fork_network.root_project).to eq project_to
+ expect(project_to.fork_network.fork_network_members.map(&:project)).not_to include project_from
+ end
+ end
+ context 'when moving a intermediate forked project' do
+ let(:project_to) { create(:project, namespace: lvl1_forked_project_1.namespace) }
+
+ it 'moves the descendant forks' do
+ expect(lvl1_forked_project_1.forks.count).to eq 2
+ expect(project_to.forks.count).to eq 0
+
+ subject.execute(lvl1_forked_project_1)
+
+ expect(lvl1_forked_project_1.forks.count).to eq 0
+ expect(project_to.forks.count).to eq 2
+ expect(lvl2_forked_project_1_1.forked_from_project).to eq project_to
+ expect(lvl2_forked_project_1_1.fork_network_member.forked_from_project).to eq project_to
+ expect(lvl2_forked_project_1_2.forked_from_project).to eq project_to
+ expect(lvl2_forked_project_1_2.fork_network_member.forked_from_project).to eq project_to
+ end
+
+ it 'moves the ascendant fork' do
+ subject.execute(lvl1_forked_project_1)
+
+ expect(project_to.reload.forked_from_project).to eq project_from
+ expect(project_to.fork_network_member.forked_from_project).to eq project_from
+ end
+
+ it 'does not update fork network' do
+ subject.execute(lvl1_forked_project_1)
+
+ expect(project_to.reload.fork_network.root_project).to eq project_from
+ end
+ end
+ end
+
+ context 'if an exception is raised' do
+ it 'rollbacks changes' do
+ updated_at = project_from.updated_at
+
+ allow(subject).to receive(:rename_project).and_raise(StandardError)
+
+ expect { subject.execute(project_from) }.to raise_error(StandardError)
+ expect(Project.find(project_from.id)).not_to be_nil
+ expect(project_from.reload.updated_at.change(usec: 0)).to eq updated_at.change(usec: 0)
+ end
+
+ it 'tries to restore the original project repositories' do
+ allow(subject).to receive(:rename_project).and_raise(StandardError)
+
+ expect(subject).to receive(:attempt_restore_repositories).with(project_from)
+
+ expect { subject.execute(project_from) }.to raise_error(StandardError)
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index 2b1337bee7e..ff9b2372a35 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -4,7 +4,7 @@ describe Projects::TransferService do
let(:gitlab_shell) { Gitlab::Shell.new }
let(:user) { create(:user) }
let(:group) { create(:group) }
- let(:project) { create(:project, :repository, namespace: user.namespace) }
+ let(:project) { create(:project, :repository, :legacy_storage, namespace: user.namespace) }
context 'namespace -> namespace' do
before do
@@ -37,6 +37,12 @@ describe Projects::TransferService do
transfer_project(project, user, group)
end
+ it 'invalidates the user\'s personal_project_count cache' do
+ expect(user).to receive(:invalidate_personal_projects_count)
+
+ 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)
@@ -54,6 +60,12 @@ describe Projects::TransferService do
expect(project.disk_path).not_to eq(old_path)
expect(project.disk_path).to start_with(group.path)
end
+
+ it 'updates project full path in .git/config' do
+ transfer_project(project, user, group)
+
+ expect(project.repository.rugged.config['gitlab.fullpath']).to eq "#{group.full_path}/#{project.path}"
+ end
end
context 'when transfer fails' do
@@ -86,6 +98,12 @@ describe Projects::TransferService do
expect(original_path).to eq current_path
end
+ it 'rolls back project full path in .git/config' do
+ attempt_project_transfer
+
+ expect(project.repository.rugged.config['gitlab.fullpath']).to eq project.full_path
+ end
+
it "doesn't send move notifications" do
expect_any_instance_of(NotificationService).not_to receive(:project_was_moved)
@@ -134,11 +152,12 @@ describe Projects::TransferService do
context 'namespace which contains orphan repository with same projects path name' do
let(:repository_storage) { 'default' }
- let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] }
+ let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path }
before do
group.add_owner(user)
- unless gitlab_shell.add_repository(repository_storage, "#{group.full_path}/#{project.path}")
+
+ unless gitlab_shell.create_repository(repository_storage, "#{group.full_path}/#{project.path}")
raise 'failed to add repository'
end
@@ -201,7 +220,7 @@ describe Projects::TransferService do
end
context 'when hashed storage in use' do
- let(:hashed_project) { create(:project, :repository, :hashed, namespace: user.namespace) }
+ let(:hashed_project) { create(:project, :repository, namespace: user.namespace) }
before do
group.add_owner(user)
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index bfb86284d86..1b6caeab15d 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -21,74 +21,72 @@ describe Projects::UpdatePagesService do
end
context 'legacy artifacts' do
- %w(tar.gz zip).each do |format|
- let(:extension) { format }
+ let(:extension) { 'zip' }
- context "for valid #{format}" do
+ before do
+ build.update_attributes(legacy_artifacts_file: file)
+ build.update_attributes(legacy_artifacts_metadata: metadata)
+ end
+
+ describe 'pages artifacts' do
+ context 'with expiry date' do
before do
- build.update_attributes(legacy_artifacts_file: file)
- build.update_attributes(legacy_artifacts_metadata: metadata)
+ build.artifacts_expire_in = "2 days"
+ build.save!
end
- describe 'pages artifacts' do
- context 'with expiry date' do
- before do
- build.artifacts_expire_in = "2 days"
- end
-
- it "doesn't delete artifacts" do
- expect(execute).to eq(:success)
-
- expect(build.reload.artifacts?).to eq(true)
- end
- end
-
- context 'without expiry date' do
- it "does delete artifacts" do
- expect(execute).to eq(:success)
+ it "doesn't delete artifacts" do
+ expect(execute).to eq(:success)
- expect(build.reload.artifacts?).to eq(false)
- end
- end
+ expect(build.reload.artifacts?).to eq(true)
end
+ end
- it 'succeeds' do
- expect(project.pages_deployed?).to be_falsey
+ context 'without expiry date' do
+ it "does delete artifacts" do
expect(execute).to eq(:success)
- expect(project.pages_deployed?).to be_truthy
- # Check that all expected files are extracted
- %w[index.html zero .hidden/file].each do |filename|
- expect(File.exist?(File.join(project.public_pages_path, filename))).to be_truthy
- end
+ expect(build.reload.artifacts?).to eq(false)
end
+ end
+ end
- it 'limits pages size' do
- stub_application_setting(max_pages_size: 1)
- expect(execute).not_to eq(:success)
- end
+ it 'succeeds' do
+ expect(project.pages_deployed?).to be_falsey
+ expect(execute).to eq(:success)
+ expect(project.pages_deployed?).to be_truthy
- it 'removes pages after destroy' do
- expect(PagesWorker).to receive(:perform_in)
- expect(project.pages_deployed?).to be_falsey
- expect(execute).to eq(:success)
- expect(project.pages_deployed?).to be_truthy
- project.destroy
- expect(project.pages_deployed?).to be_falsey
- end
+ # Check that all expected files are extracted
+ %w[index.html zero .hidden/file].each do |filename|
+ expect(File.exist?(File.join(project.public_pages_path, filename))).to be_truthy
+ end
+ end
- it 'fails if sha on branch is not latest' do
- build.update_attributes(ref: 'feature')
+ it 'limits pages size' do
+ stub_application_setting(max_pages_size: 1)
+ expect(execute).not_to eq(:success)
+ end
- expect(execute).not_to eq(:success)
- end
+ it 'removes pages after destroy' do
+ expect(PagesWorker).to receive(:perform_in)
+ expect(project.pages_deployed?).to be_falsey
+ expect(execute).to eq(:success)
+ expect(project.pages_deployed?).to be_truthy
+ project.destroy
+ expect(project.pages_deployed?).to be_falsey
+ end
- it 'fails for empty file fails' do
- build.update_attributes(legacy_artifacts_file: empty_file)
+ it 'fails if sha on branch is not latest' do
+ build.update_attributes(ref: 'feature')
- expect(execute).not_to eq(:success)
- end
- end
+ expect(execute).not_to eq(:success)
+ end
+
+ it 'fails for empty file fails' do
+ build.update_attributes(legacy_artifacts_file: empty_file)
+
+ expect { execute }
+ .to raise_error(Projects::UpdatePagesService::FailedToExtractError)
end
end
@@ -105,6 +103,7 @@ describe Projects::UpdatePagesService do
context 'with expiry date' do
before do
build.artifacts_expire_in = "2 days"
+ build.save!
end
it "doesn't delete artifacts" do
@@ -157,7 +156,54 @@ describe Projects::UpdatePagesService do
it 'fails for empty file fails' do
build.job_artifacts_archive.update_attributes(file: empty_file)
- expect(execute).not_to eq(:success)
+ expect { execute }
+ .to raise_error(Projects::UpdatePagesService::FailedToExtractError)
+ end
+
+ context 'when timeout happens by DNS error' do
+ before do
+ allow_any_instance_of(described_class)
+ .to receive(:extract_zip_archive!).and_raise(SocketError)
+ end
+
+ it 'raises an error' do
+ expect { execute }.to raise_error(SocketError)
+
+ build.reload
+ expect(deploy_status).to be_failed
+ expect(build.artifacts?).to be_truthy
+ end
+ end
+
+ context 'when failed to extract zip artifacts' do
+ before do
+ allow_any_instance_of(described_class)
+ .to receive(:extract_zip_archive!)
+ .and_raise(Projects::UpdatePagesService::FailedToExtractError)
+ end
+
+ it 'raises an error' do
+ expect { execute }
+ .to raise_error(Projects::UpdatePagesService::FailedToExtractError)
+
+ build.reload
+ expect(deploy_status).to be_failed
+ expect(build.artifacts?).to be_truthy
+ end
+ end
+
+ context 'when missing artifacts metadata' do
+ before do
+ allow(build).to receive(:artifacts_metadata?).and_return(false)
+ end
+
+ it 'does not raise an error and remove artifacts as failed job' do
+ execute
+
+ build.reload
+ expect(deploy_status).to be_failed
+ expect(build.artifacts?).to be_falsey
+ end
end
end
end
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index d887f70efae..f48d466d263 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -61,7 +61,7 @@ describe Projects::UpdateService do
end
end
- context 'When project visibility is higher than parent group' do
+ context 'when project visibility is higher than parent group' do
let(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) }
before do
@@ -123,6 +123,49 @@ describe Projects::UpdateService do
end
end
+ context 'when we update project but not enabling a wiki' do
+ it 'does not try to create an empty wiki' do
+ FileUtils.rm_rf(project.wiki.repository.path)
+
+ result = update_project(project, user, { name: 'test1' })
+
+ expect(result).to eq({ status: :success })
+ expect(project.wiki_repository_exists?).to be false
+ end
+
+ it 'handles empty project feature attributes' do
+ project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
+
+ result = update_project(project, user, { name: 'test1' })
+
+ expect(result).to eq({ status: :success })
+ expect(project.wiki_repository_exists?).to be false
+ end
+ end
+
+ context 'when enabling a wiki' do
+ it 'creates a wiki' do
+ project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
+ FileUtils.rm_rf(project.wiki.repository.path)
+
+ result = update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED })
+
+ expect(result).to eq({ status: :success })
+ expect(project.wiki_repository_exists?).to be true
+ expect(project.wiki_enabled?).to be true
+ end
+
+ it 'logs an error and creates a metric when wiki can not be created' do
+ project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
+
+ expect_any_instance_of(ProjectWiki).to receive(:wiki).and_raise(ProjectWiki::CouldNotCreateWikiError)
+ expect_any_instance_of(described_class).to receive(:log_error).with("Could not create wiki for #{project.full_name}")
+ expect(Gitlab::Metrics).to receive(:counter)
+
+ update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED })
+ end
+ end
+
context 'when updating a project that contains container images' do
before do
stub_container_registry_config(enabled: true)
@@ -147,11 +190,13 @@ describe Projects::UpdateService do
context 'when renaming a project' do
let(:repository_storage) { 'default' }
- let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] }
+ let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path }
context 'with legacy storage' do
+ let(:project) { create(:project, :legacy_storage, :repository, creator: user, namespace: user.namespace) }
+
before do
- gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing")
+ gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing")
end
after do
@@ -196,6 +241,27 @@ describe Projects::UpdateService do
})
end
end
+
+ context 'when updating #pages_https_only', :https_pages_enabled do
+ subject(:call_service) do
+ update_project(project, admin, pages_https_only: false)
+ end
+
+ it 'updates the attribute' do
+ expect { call_service }
+ .to change { project.pages_https_only? }
+ .to(false)
+ end
+
+ it 'calls Projects::UpdatePagesConfigurationService' do
+ expect(Projects::UpdatePagesConfigurationService)
+ .to receive(:new)
+ .with(project)
+ .and_call_original
+
+ call_service
+ end
+ end
end
describe '#run_auto_devops_pipeline?' do
diff --git a/spec/services/prometheus/adapter_service_spec.rb b/spec/services/prometheus/adapter_service_spec.rb
new file mode 100644
index 00000000000..335fc5844aa
--- /dev/null
+++ b/spec/services/prometheus/adapter_service_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Prometheus::AdapterService do
+ let(:project) { create(:project) }
+ subject { described_class.new(project) }
+
+ describe '#prometheus_adapter' do
+ let(:cluster) { create(:cluster, :provided_by_user, environment_scope: '*', projects: [project]) }
+
+ context 'prometheus service can execute queries' do
+ let(:prometheus_service) { double(:prometheus_service, can_query?: true) }
+
+ before do
+ allow(project).to receive(:find_or_initialize_service).with('prometheus').and_return prometheus_service
+ end
+
+ it 'return prometheus service as prometheus adapter' do
+ expect(subject.prometheus_adapter).to eq(prometheus_service)
+ end
+ end
+
+ context "prometheus service can't execute queries" do
+ let(:prometheus_service) { double(:prometheus_service, can_query?: false) }
+
+ context 'with cluster with prometheus installed' do
+ let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
+
+ it 'returns application handling all environments' do
+ expect(subject.prometheus_adapter).to eq(prometheus)
+ end
+ end
+
+ context 'with cluster without prometheus installed' do
+ it 'returns nil' do
+ expect(subject.prometheus_adapter).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb
index 835e83d6dba..786493c3577 100644
--- a/spec/services/protected_branches/create_service_spec.rb
+++ b/spec/services/protected_branches/create_service_spec.rb
@@ -19,5 +19,34 @@ describe ProtectedBranches::CreateService do
expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
expect(project.protected_branches.last.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER])
end
+
+ context 'when user does not have permission' do
+ let(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'creates a new protected branch if we skip authorization step' do
+ expect { service.execute(skip_authorization: true) }.to change(ProtectedBranch, :count).by(1)
+ end
+
+ it 'raises Gitlab::Access:AccessDeniedError' do
+ expect { service.execute }.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
+
+ context 'when a policy restricts rule creation' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents creation of the protected branch rule" do
+ expect do
+ service.execute
+ end.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
end
end
diff --git a/spec/services/protected_branches/destroy_service_spec.rb b/spec/services/protected_branches/destroy_service_spec.rb
new file mode 100644
index 00000000000..4a391b6c25c
--- /dev/null
+++ b/spec/services/protected_branches/destroy_service_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe ProtectedBranches::DestroyService do
+ let(:protected_branch) { create(:protected_branch) }
+ let(:project) { protected_branch.project }
+ let(:user) { project.owner }
+
+ describe '#execute' do
+ subject(:service) { described_class.new(project, user) }
+
+ it 'destroys a protected branch' do
+ service.execute(protected_branch)
+
+ expect(protected_branch).to be_destroyed
+ end
+
+ context 'when a policy restricts rule deletion' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents deletion of the protected branch rule" do
+ expect do
+ service.execute(protected_branch)
+ end.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
+ end
+end
diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb
index 9fa5983db66..3f6f8e09565 100644
--- a/spec/services/protected_branches/update_service_spec.rb
+++ b/spec/services/protected_branches/update_service_spec.rb
@@ -22,5 +22,16 @@ describe ProtectedBranches::UpdateService do
expect { service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
+
+ context 'when a policy restricts rule creation' do
+ before do
+ policy = instance_double(ProtectedBranchPolicy, can?: false)
+ expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
+ end
+
+ it "prevents creation of the protected branch rule" do
+ expect { service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
end
end
diff --git a/spec/services/protected_tags/destroy_service_spec.rb b/spec/services/protected_tags/destroy_service_spec.rb
new file mode 100644
index 00000000000..e12f53a2221
--- /dev/null
+++ b/spec/services/protected_tags/destroy_service_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe ProtectedTags::DestroyService do
+ let(:protected_tag) { create(:protected_tag) }
+ let(:project) { protected_tag.project }
+ let(:user) { project.owner }
+
+ describe '#execute' do
+ subject(:service) { described_class.new(project, user) }
+
+ it 'destroy a protected tag' do
+ service.execute(protected_tag)
+
+ expect(protected_tag).to be_destroyed
+ end
+ end
+end
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index eb46480fa54..f793f55e51b 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -12,7 +12,7 @@ describe QuickActions::InterpretService do
let(:service) { described_class.new(project, developer) }
before do
- project.team << [developer, :developer]
+ project.add_developer(developer)
end
describe '#execute' do
@@ -440,7 +440,7 @@ describe QuickActions::InterpretService do
let(:content) { "/assign @#{developer.username} @#{developer2.username}" }
before do
- project.team << [developer2, :developer]
+ project.add_developer(developer2)
end
context 'Issue' do
@@ -522,6 +522,22 @@ describe QuickActions::InterpretService do
let(:issuable) { merge_request }
end
+ context 'only group milestones available' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:milestone) { create(:milestone, group: group, title: '10.0') }
+
+ 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
+ end
+
it_behaves_like 'remove_milestone command' do
let(:content) { '/remove_milestone' }
let(:issuable) { issue }
diff --git a/spec/services/reset_project_cache_service_spec.rb b/spec/services/reset_project_cache_service_spec.rb
new file mode 100644
index 00000000000..de475d16586
--- /dev/null
+++ b/spec/services/reset_project_cache_service_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe ResetProjectCacheService do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ subject { described_class.new(project, user).execute }
+
+ context 'when project cache_index is nil' do
+ before do
+ project.jobs_cache_index = nil
+ end
+
+ it 'sets project cache_index to one' do
+ expect { subject }.to change { project.reload.jobs_cache_index }.from(nil).to(1)
+ end
+ end
+
+ context 'when project cache_index is a numeric value' do
+ before do
+ project.update_attributes(jobs_cache_index: 1)
+ end
+
+ it 'increments project cache index' do
+ expect { subject }.to change { project.reload.jobs_cache_index }.by(1)
+ end
+ end
+end
diff --git a/spec/services/search/snippet_service_spec.rb b/spec/services/search/snippet_service_spec.rb
index eae9bd4f5cf..8ad162ad66e 100644
--- a/spec/services/search/snippet_service_spec.rb
+++ b/spec/services/search/snippet_service_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Search::SnippetService do
let(:author) { create(:author) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :public) }
let!(:public_snippet) { create(:snippet, :public, content: 'password: XXX') }
let!(:internal_snippet) { create(:snippet, :internal, content: 'password: XXX') }
@@ -33,7 +33,7 @@ describe Search::SnippetService do
it 'returns public, internal snippets and project private snippets for project members' do
member = create(:user)
- project.team << [member, :developer]
+ project.add_developer(member)
search = described_class.new(member, search: 'password')
results = search.execute
diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb
index 46cd10cdc12..51396d34f8f 100644
--- a/spec/services/system_hooks_service_spec.rb
+++ b/spec/services/system_hooks_service_spec.rb
@@ -30,6 +30,7 @@ describe SystemHooksService do
:old_path_with_namespace
)
end
+
it do
project.old_path_with_namespace = 'transfered_from_path'
expect(event_data(project, :transfer)).to include(
@@ -45,18 +46,21 @@ describe SystemHooksService do
:owner_name, :owner_email
)
end
+
it do
expect(event_data(group, :destroy)).to include(
:event_name, :name, :created_at, :updated_at, :path, :group_id,
:owner_name, :owner_email
)
end
+
it do
expect(event_data(group_member, :create)).to include(
:event_name, :created_at, :updated_at, :group_name, :group_path,
:group_id, :user_id, :user_username, :user_name, :user_email, :group_access
)
end
+
it do
expect(event_data(group_member, :destroy)).to include(
:event_name, :created_at, :updated_at, :group_name, :group_path,
@@ -70,6 +74,14 @@ describe SystemHooksService do
expect(data[:project_visibility]).to eq('private')
end
+ it 'handles nil datetime columns' do
+ user.update_attributes(created_at: nil, updated_at: nil)
+ data = event_data(user, :destroy)
+
+ expect(data[:created_at]).to be(nil)
+ expect(data[:updated_at]).to be(nil)
+ end
+
context 'group_rename' do
it 'contains old and new path' do
allow(group).to receive(:path_was).and_return('old-path')
@@ -105,12 +117,25 @@ describe SystemHooksService do
expect(data[:old_username]).to eq(user.username_was)
end
end
+
+ context 'user_failed_login' do
+ it 'contains state of user' do
+ user.ldap_block!
+
+ data = event_data(user, :failed_login)
+
+ expect(data).to include(:event_name, :name, :created_at, :updated_at, :email, :user_id, :username, :state)
+ expect(data[:username]).to eq(user.username)
+ expect(data[:state]).to eq('ldap_blocked')
+ end
+ end
end
context 'event names' do
it { expect(event_name(user, :create)).to eq "user_create" }
it { expect(event_name(user, :destroy)).to eq "user_destroy" }
it { expect(event_name(user, :rename)).to eq 'user_rename' }
+ it { expect(event_name(user, :failed_login)).to eq 'user_failed_login' }
it { expect(event_name(project, :create)).to eq "project_create" }
it { expect(event_name(project, :destroy)).to eq "project_destroy" }
it { expect(event_name(project, :rename)).to eq "project_rename" }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 9025589ae0b..e28b0ea5cf2 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -2,6 +2,7 @@ require 'spec_helper'
describe SystemNoteService do
include Gitlab::Routing
+ include RepoHelpers
set(:group) { create(:group) }
set(:project) { create(:project, :repository, group: group) }
@@ -53,10 +54,11 @@ describe SystemNoteService do
expect(note_lines[0]).to eq "added #{new_commits.size} commits"
end
- it 'adds a message line for each commit' do
- new_commits.each_with_index do |commit, i|
- # Skip the header
- expect(HTMLEntities.new.decode(note_lines[i + 1])).to eq "* #{commit.short_id} - #{commit.title}"
+ it 'adds a message for each commit' do
+ decoded_note_content = HTMLEntities.new.decode(subject.note)
+
+ new_commits.each do |commit|
+ expect(decoded_note_content).to include("<li>#{commit.short_id} - #{commit.title}</li>")
end
end
end
@@ -68,7 +70,7 @@ describe SystemNoteService do
let(:old_commits) { [noteable.commits.last] }
it 'includes the existing commit' do
- expect(summary_line).to eq "* #{old_commits.first.short_id} - 1 commit from branch `feature`"
+ expect(summary_line).to start_with("<ul><li>#{old_commits.first.short_id} - 1 commit from branch <code>feature</code>")
end
end
@@ -78,22 +80,16 @@ describe SystemNoteService do
context 'with oldrev' do
let(:oldrev) { noteable.commits[2].id }
- it 'includes a commit range' do
- expect(summary_line).to start_with "* #{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id}"
- end
-
- it 'includes a commit count' do
- expect(summary_line).to end_with " - 26 commits from branch `feature`"
+ it 'includes a commit range and count' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id} - 26 commits from branch <code>feature</code>")
end
end
context 'without oldrev' do
- it 'includes a commit range' do
- expect(summary_line).to start_with "* #{old_commits[0].short_id}..#{old_commits[-1].short_id}"
- end
-
- it 'includes a commit count' do
- expect(summary_line).to end_with " - 26 commits from branch `feature`"
+ it 'includes a commit range and count' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{old_commits[0].short_id}..#{old_commits[-1].short_id} - 26 commits from branch <code>feature</code>")
end
end
@@ -103,7 +99,7 @@ describe SystemNoteService do
end
it 'includes the project namespace' do
- expect(summary_line).to end_with "`#{noteable.target_project_namespace}:feature`"
+ expect(summary_line).to include("<code>#{noteable.target_project_namespace}:feature</code>")
end
end
end
@@ -157,7 +153,7 @@ describe SystemNoteService do
end
it 'builds a correct phrase when assignee removed' do
- expect(build_note([assignee1], [])).to eq 'removed assignee'
+ expect(build_note([assignee1], [])).to eq "unassigned @#{assignee1.username}"
end
it 'builds a correct phrase when assignees changed' do
@@ -307,7 +303,7 @@ describe SystemNoteService do
end
it "posts the 'merge when pipeline succeeds' system note" do
- expect(subject.note).to match(/enabled an automatic merge when the pipeline for (\w+\/\w+@)?\h{40} succeeds/)
+ expect(subject.note).to match(%r{enabled an automatic merge when the pipeline for (\w+/\w+@)?\h{40} succeeds})
end
end
@@ -692,9 +688,9 @@ describe SystemNoteService do
describe '.new_commit_summary' do
it 'escapes HTML titles' do
commit = double(title: '<pre>This is a test</pre>', short_id: '12345678')
- escaped = '&lt;pre&gt;This is a test&lt;&#x2F;pre&gt;'
+ escaped = '&lt;pre&gt;This is a test&lt;/pre&gt;'
- expect(described_class.new_commit_summary([commit])).to all(match(%r[- #{escaped}]))
+ expect(described_class.new_commit_summary([commit])).to all(match(/- #{escaped}/))
end
end
@@ -726,6 +722,7 @@ describe SystemNoteService do
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!)
@@ -746,7 +743,7 @@ describe SystemNoteService do
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
+ it "creates cross reference when #{type.underscore}_events is true" do
jira_tracker.update("#{type}_events" => true)
expect(cross_reference(type)).to eq(success_message)
@@ -792,7 +789,7 @@ describe SystemNoteService do
object: {
url: project_commit_url(project, commit),
title: "GitLab: Mentioned on commit - #{commit.title}",
- icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ icon: { title: "GitLab", url16x16: "http://localhost/favicon.ico" },
status: { resolved: false }
}
)
@@ -818,7 +815,7 @@ describe SystemNoteService do
object: {
url: project_issue_url(project, issue),
title: "GitLab: Mentioned on issue - #{issue.title}",
- icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ icon: { title: "GitLab", url16x16: "http://localhost/favicon.ico" },
status: { resolved: false }
}
)
@@ -844,7 +841,7 @@ describe SystemNoteService do
object: {
url: project_snippet_url(project, snippet),
title: "GitLab: Mentioned on snippet - #{snippet.title}",
- icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" },
+ icon: { title: "GitLab", url16x16: "http://localhost/favicon.ico" },
status: { resolved: false }
}
)
@@ -1070,17 +1067,32 @@ describe SystemNoteService do
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)
+ context 'when the change_position is valid for the discussion' do
+ 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
- it 'links to the diff in the system note' do
- expect(subject.note).to include('version 1')
+ context 'when the change_position is invalid for the discussion' do
+ let(:change_position) { project.commit(sample_commit.id) }
+
+ 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
- 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))
+ it 'does not create a link' do
+ expect(subject.note).to eq('changed this line in version 1 of the diff')
+ end
end
end
diff --git a/spec/services/test_hooks/system_service_spec.rb b/spec/services/test_hooks/system_service_spec.rb
index ff8b9595538..74d7715e50f 100644
--- a/spec/services/test_hooks/system_service_spec.rb
+++ b/spec/services/test_hooks/system_service_spec.rb
@@ -60,5 +60,25 @@ describe TestHooks::SystemService do
expect(service.execute).to include(success_result)
end
end
+
+ context 'merge_requests_events' do
+ let(:trigger) { 'merge_requests_events' }
+
+ it 'returns error message if the user does not have any repository with a merge request' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure one of your projects has merge requests.' })
+ end
+
+ it 'executes hook' do
+ trigger_key = :merge_request_hooks
+ sample_data = { data: 'sample' }
+ create(:project_member, user: current_user, project: project)
+ 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_key).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 88013acae0a..562b89e6767 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -17,11 +17,11 @@ describe TodoService do
let(:service) { described_class.new }
before do
- project.team << [guest, :guest]
- project.team << [author, :developer]
- project.team << [member, :developer]
- project.team << [john_doe, :developer]
- project.team << [skipped, :developer]
+ project.add_guest(guest)
+ project.add_developer(author)
+ project.add_developer(member)
+ project.add_developer(john_doe)
+ project.add_developer(skipped)
end
describe 'Issues' do
@@ -943,7 +943,8 @@ describe TodoService do
described_class.new.mark_todos_as_done_by_ids(todo, john_doe)
- expect_any_instance_of(TodosFinder).not_to receive(:execute)
+ # Make sure no TodosFinder is inialized to perform counting
+ expect(TodosFinder).not_to receive(:new)
expect(john_doe.todos_done_count).to eq(1)
expect(john_doe.todos_pending_count).to eq(1)
diff --git a/spec/services/update_merge_request_metrics_service_spec.rb b/spec/services/update_merge_request_metrics_service_spec.rb
new file mode 100644
index 00000000000..b5fb999381d
--- /dev/null
+++ b/spec/services/update_merge_request_metrics_service_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+describe MergeRequestMetricsService do
+ let(:metrics) { create(:merge_request).metrics }
+
+ describe '#merge' do
+ it 'updates metrics' do
+ user = create(:user)
+ service = described_class.new(metrics)
+ event = double(Event, author_id: user.id, created_at: Time.now)
+
+ service.merge(event)
+
+ expect(metrics.merged_by).to eq(user)
+ expect(metrics.merged_at).to eq(event.created_at)
+ end
+ end
+
+ describe '#close' do
+ it 'updates metrics' do
+ user = create(:user)
+ service = described_class.new(metrics)
+ event = double(Event, author_id: user.id, created_at: Time.now)
+
+ service.close(event)
+
+ expect(metrics.latest_closed_by).to eq(user)
+ expect(metrics.latest_closed_at).to eq(event.created_at)
+ end
+ end
+
+ describe '#reopen' do
+ it 'updates metrics' do
+ service = described_class.new(metrics)
+
+ service.reopen
+
+ expect(metrics.latest_closed_by).to be_nil
+ expect(metrics.latest_closed_at).to be_nil
+ end
+ end
+end
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 58a5bede3de..11c75ddfcf8 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -15,7 +15,7 @@ describe Users::DestroyService do
expect { user_data['email'].to eq(user.email) }
expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound)
- expect { Namespace.with_deleted.find(namespace.id) }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { Namespace.find(namespace.id) }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'will delete the project' do
@@ -173,7 +173,7 @@ describe Users::DestroyService do
end
context 'legacy storage' do
- let!(:project) { create(:project, :empty_repo, namespace: user.namespace) }
+ let!(:project) { create(:project, :empty_repo, :legacy_storage, namespace: user.namespace) }
it 'removes repository' do
expect(gitlab_shell.exists?(project.repository_storage_path, "#{project.disk_path}.git")).to be_falsey
@@ -181,12 +181,29 @@ describe Users::DestroyService do
end
context 'hashed storage' do
- let!(:project) { create(:project, :empty_repo, :hashed, namespace: user.namespace) }
+ let!(:project) { create(:project, :empty_repo, namespace: user.namespace) }
it 'removes repository' do
expect(gitlab_shell.exists?(project.repository_storage_path, "#{project.disk_path}.git")).to be_falsey
end
end
end
+
+ describe "calls the before/after callbacks" do
+ it 'of project_members' do
+ expect_any_instance_of(ProjectMember).to receive(:run_callbacks).with(:destroy).once
+
+ service.execute(user)
+ end
+
+ it 'of group_members' do
+ group_member = create(:group_member)
+ group_member.group.group_members.create(user: user, access_level: 40)
+
+ expect_any_instance_of(GroupMember).to receive(:run_callbacks).with(:destroy).once
+
+ service.execute(user)
+ end
+ end
end
end
diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb
index f8d4a47b212..a4b7fe4674f 100644
--- a/spec/services/users/update_service_spec.rb
+++ b/spec/services/users/update_service_spec.rb
@@ -21,13 +21,13 @@ describe Users::UpdateService do
end
it 'includes namespace error messages' do
- create(:group, name: 'taken', path: 'something_else')
+ create(:group, path: 'taken')
result = {}
expect do
result = update_user(user, { username: 'taken' })
end.not_to change { user.reload.username }
expect(result[:status]).to eq(:error)
- expect(result[:message]).to eq('Namespace name has already been taken')
+ expect(result[:message]).to eq('Username has already been taken')
end
def update_user(user, opts)
diff --git a/spec/services/verify_pages_domain_service_spec.rb b/spec/services/verify_pages_domain_service_spec.rb
new file mode 100644
index 00000000000..d974cc0226f
--- /dev/null
+++ b/spec/services/verify_pages_domain_service_spec.rb
@@ -0,0 +1,289 @@
+require 'spec_helper'
+
+describe VerifyPagesDomainService do
+ using RSpec::Parameterized::TableSyntax
+ include EmailHelpers
+
+ let(:error_status) { { status: :error, message: "Couldn't verify #{domain.domain}" } }
+
+ subject(:service) { described_class.new(domain) }
+
+ describe '#execute' do
+ context 'verification code recognition (verified domain)' do
+ where(:domain_sym, :code_sym) do
+ :domain | :verification_code
+ :domain | :keyed_verification_code
+
+ :verification_domain | :verification_code
+ :verification_domain | :keyed_verification_code
+ end
+
+ with_them do
+ set(:domain) { create(:pages_domain) }
+
+ let(:domain_name) { domain.send(domain_sym) }
+ let(:verification_code) { domain.send(code_sym) }
+
+ it 'verifies and enables the domain' do
+ stub_resolver(domain_name => ['something else', verification_code])
+
+ expect(service.execute).to eq(status: :success)
+ expect(domain).to be_verified
+ expect(domain).to be_enabled
+ end
+
+ it 'verifies and enables when the code is contained partway through a TXT record' do
+ stub_resolver(domain_name => "something #{verification_code} else")
+
+ expect(service.execute).to eq(status: :success)
+ expect(domain).to be_verified
+ expect(domain).to be_enabled
+ end
+
+ it 'does not verify when the code is not present' do
+ stub_resolver(domain_name => 'something else')
+
+ expect(service.execute).to eq(error_status)
+
+ expect(domain).not_to be_verified
+ expect(domain).to be_enabled
+ end
+ end
+
+ context 'verified domain' do
+ set(:domain) { create(:pages_domain) }
+
+ it 'unverifies (but does not disable) when the right code is not present' do
+ stub_resolver(domain.domain => 'something else')
+
+ expect(service.execute).to eq(error_status)
+ expect(domain).not_to be_verified
+ expect(domain).to be_enabled
+ end
+
+ it 'unverifies (but does not disable) when no records are present' do
+ stub_resolver
+
+ expect(service.execute).to eq(error_status)
+ expect(domain).not_to be_verified
+ expect(domain).to be_enabled
+ end
+ end
+
+ context 'expired domain' do
+ set(:domain) { create(:pages_domain, :expired) }
+
+ it 'verifies and enables when the right code is present' do
+ stub_resolver(domain.domain => domain.keyed_verification_code)
+
+ expect(service.execute).to eq(status: :success)
+
+ expect(domain).to be_verified
+ expect(domain).to be_enabled
+ end
+
+ it 'disables when the right code is not present' do
+ error_status[:message] += '. It is now disabled.'
+
+ stub_resolver
+
+ expect(service.execute).to eq(error_status)
+
+ expect(domain).not_to be_verified
+ expect(domain).not_to be_enabled
+ end
+ end
+
+ context 'invalid domain' do
+ let(:domain) { build(:pages_domain, :expired, :with_missing_chain) }
+
+ before do
+ domain.save(validate: false)
+ end
+
+ it 'can be disabled' do
+ error_status[:message] += '. It is now disabled.'
+
+ stub_resolver
+
+ expect(service.execute).to eq(error_status)
+
+ expect(domain).not_to be_verified
+ expect(domain).not_to be_enabled
+ end
+ end
+ end
+
+ context 'timeout behaviour' do
+ let(:domain) { create(:pages_domain) }
+
+ it 'sets a timeout on the DNS query' do
+ expect(stub_resolver).to receive(:timeouts=).with(described_class::RESOLVER_TIMEOUT_SECONDS)
+
+ service.execute
+ end
+ end
+
+ context 'email notifications' do
+ let(:notification_service) { instance_double('NotificationService') }
+
+ where(:factory, :verification_succeeds, :expected_notification) do
+ nil | true | nil
+ nil | false | :verification_failed
+ :reverify | true | nil
+ :reverify | false | :verification_failed
+ :unverified | true | :verification_succeeded
+ :unverified | false | nil
+ :expired | true | nil
+ :expired | false | :disabled
+ :disabled | true | :enabled
+ :disabled | false | nil
+ end
+
+ with_them do
+ let(:domain) { create(:pages_domain, *[factory].compact) }
+
+ before do
+ allow(service).to receive(:notification_service) { notification_service }
+
+ if verification_succeeds
+ stub_resolver(domain.domain => domain.verification_code)
+ else
+ stub_resolver
+ end
+ end
+
+ it 'sends a notification if appropriate' do
+ if expected_notification
+ expect(notification_service).to receive(:"pages_domain_#{expected_notification}").with(domain)
+ end
+
+ service.execute
+ end
+ end
+
+ context 'pages verification disabled' do
+ let(:domain) { create(:pages_domain, :disabled) }
+
+ before do
+ stub_application_setting(pages_domain_verification_enabled: false)
+ allow(service).to receive(:notification_service) { notification_service }
+ end
+
+ it 'skips email notifications' do
+ expect(notification_service).not_to receive(:pages_domain_enabled)
+
+ service.execute
+ end
+ end
+ end
+
+ context 'pages configuration updates' do
+ context 'enabling a disabled domain' do
+ let(:domain) { create(:pages_domain, :disabled) }
+
+ it 'schedules an update' do
+ stub_resolver(domain.domain => domain.verification_code)
+
+ expect(domain).to receive(:update_daemon)
+
+ service.execute
+ end
+ end
+
+ context 'verifying an enabled domain' do
+ let(:domain) { create(:pages_domain) }
+
+ it 'schedules an update' do
+ stub_resolver(domain.domain => domain.verification_code)
+
+ expect(domain).not_to receive(:update_daemon)
+
+ service.execute
+ end
+ end
+
+ context 'disabling an expired domain' do
+ let(:domain) { create(:pages_domain, :expired) }
+
+ it 'schedules an update' do
+ stub_resolver
+
+ expect(domain).to receive(:update_daemon)
+
+ service.execute
+ end
+ end
+
+ context 'failing to verify a disabled domain' do
+ let(:domain) { create(:pages_domain, :disabled) }
+
+ it 'does not schedule an update' do
+ stub_resolver
+
+ expect(domain).not_to receive(:update_daemon)
+
+ service.execute
+ end
+ end
+ end
+
+ context 'no verification code' do
+ let(:domain) { create(:pages_domain) }
+
+ it 'returns an error' do
+ domain.verification_code = ''
+
+ disallow_resolver!
+
+ expect(service.execute).to eq(status: :error, message: "No verification code set for #{domain.domain}")
+ end
+ end
+
+ context 'pages domain verification is disabled' do
+ let(:domain) { create(:pages_domain, :disabled) }
+
+ before do
+ stub_application_setting(pages_domain_verification_enabled: false)
+ end
+
+ it 'extends domain validity by unconditionally reverifying' do
+ disallow_resolver!
+
+ service.execute
+
+ expect(domain).to be_verified
+ expect(domain).to be_enabled
+ end
+
+ it 'does not shorten any grace period' do
+ grace = Time.now + 1.year
+ domain.update!(enabled_until: grace)
+ disallow_resolver!
+
+ service.execute
+
+ expect(domain.enabled_until).to be_like_time(grace)
+ end
+ end
+ end
+
+ def disallow_resolver!
+ expect(Resolv::DNS).not_to receive(:open)
+ end
+
+ def stub_resolver(stubbed_lookups = {})
+ resolver = instance_double('Resolv::DNS')
+ allow(resolver).to receive(:timeouts=)
+
+ expect(Resolv::DNS).to receive(:open).and_yield(resolver)
+
+ allow(resolver).to receive(:getresources) { [] }
+ stubbed_lookups.each do |domain, records|
+ records = Array(records).map { |txt| Resolv::DNS::Resource::IN::TXT.new(txt) }
+ allow(resolver).to receive(:getresources).with(domain, Resolv::DNS::Resource::IN::TXT) { records }
+ end
+
+ resolver
+ end
+end
diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb
index 21910e69d2e..2ef2e61babc 100644
--- a/spec/services/web_hook_service_spec.rb
+++ b/spec/services/web_hook_service_spec.rb
@@ -14,6 +14,20 @@ describe WebHookService do
end
let(:service_instance) { described_class.new(project_hook, data, :push_hooks) }
+ describe '#initialize' do
+ it 'allow_local_requests is true if hook is a SystemHook' do
+ instance = described_class.new(build(:system_hook), data, :system_hook)
+ expect(instance.request_options[:allow_local_requests]).to be_truthy
+ end
+
+ it 'allow_local_requests is false if hook is not a SystemHook' do
+ %i(project_hook service_hook web_hook_log).each do |hook|
+ instance = described_class.new(build(hook), data, hook)
+ expect(instance.request_options[:allow_local_requests]).to be_falsey
+ end
+ end
+ end
+
describe '#execute' do
before do
project.hooks << [project_hook]
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index f51bb44086b..83664bae046 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -66,6 +66,7 @@ RSpec.configure do |config|
config.include MigrationsHelpers, :migration
config.include StubFeatureFlags
config.include StubENV
+ config.include ExpectOffense
config.infer_spec_type_from_file_location!
@@ -97,13 +98,24 @@ RSpec.configure do |config|
TestEnv.init
end
- config.after(:suite) do
- TestEnv.cleanup
+ config.after(:all) do
+ TestEnv.clean_test_path
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])
+
+ allow_any_instance_of(Gitlab::Git::GitlabProjects).to receive(:fork_repository).and_wrap_original do |m, *args|
+ m.call(*args)
+
+ shard_name, repository_relative_path = args
+ shard_path = Gitlab.config.repositories.storages.fetch(shard_name).legacy_disk_path
+ # We can't leave the hooks in place after a fork, as those would fail in tests
+ # The "internal" API is not available
+ FileUtils.rm_rf(File.join(shard_path, repository_relative_path, 'hooks'))
+ end
+
# Enable all features by default for testing
allow(Feature).to receive(:enabled?) { true }
end
@@ -148,6 +160,22 @@ RSpec.configure do |config|
Sidekiq.redis(&:flushall)
end
+ # The :each scope runs "inside" the example, so this hook ensures the DB is in the
+ # correct state before any examples' before hooks are called. This prevents a
+ # problem where `ScheduleIssuesClosedAtTypeChange` (or any migration that depends
+ # on background migrations being run inline during test setup) can be broken by
+ # altering Sidekiq behavior in an unrelated spec like so:
+ #
+ # around do |example|
+ # Sidekiq::Testing.fake! do
+ # example.run
+ # end
+ # end
+ config.before(:context, :migration) do
+ schema_migrate_down!
+ end
+
+ # Each example may call `migrate!`, so we must ensure we are migrated down every time
config.before(:each, :migration) do
schema_migrate_down!
end
@@ -163,6 +191,34 @@ RSpec.configure do |config|
config.around(:each, :postgresql) do |example|
example.run if Gitlab::Database.postgresql?
end
+
+ config.around(:each, :mysql) do |example|
+ example.run if Gitlab::Database.mysql?
+ end
+
+ # This makes sure the `ApplicationController#can?` method is stubbed with the
+ # original implementation for all view specs.
+ config.before(:each, type: :view) do
+ allow(view).to receive(:can?) do |*args|
+ Ability.allowed?(*args)
+ end
+ end
+
+ config.before(:each, :http_pages_enabled) do |_|
+ allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80'])
+ end
+
+ config.before(:each, :https_pages_enabled) do |_|
+ allow(Gitlab.config.pages).to receive(:external_https).and_return(['1.1.1.1:443'])
+ end
+
+ config.before(:each, :http_pages_disabled) do |_|
+ allow(Gitlab.config.pages).to receive(:external_http).and_return(false)
+ end
+
+ config.before(:each, :https_pages_disabled) do |_|
+ allow(Gitlab.config.pages).to receive(:external_https).and_return(false)
+ end
end
# add simpler way to match asset paths containing digest strings
diff --git a/spec/support/api/boards_shared_examples.rb b/spec/support/api/boards_shared_examples.rb
new file mode 100644
index 00000000000..943c1f6ffd7
--- /dev/null
+++ b/spec/support/api/boards_shared_examples.rb
@@ -0,0 +1,180 @@
+shared_examples_for 'group and project boards' do |route_definition, ee = false|
+ let(:root_url) { route_definition.gsub(":id", board_parent.id.to_s) }
+
+ before do
+ board_parent.add_reporter(user)
+ board_parent.add_guest(guest)
+ end
+
+ def expect_schema_match_for(response, schema_file, ee)
+ if ee
+ expect(response).to match_response_schema(schema_file, dir: "ee")
+ else
+ expect(response).to match_response_schema(schema_file)
+ end
+ end
+
+ describe "GET #{route_definition}" do
+ context "when unauthenticated" do
+ it "returns authentication error" do
+ get api(root_url)
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+ end
+
+ context "when authenticated" do
+ it "returns the issue boards" do
+ get api(root_url, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+
+ expect_schema_match_for(response, 'public_api/v4/boards', ee)
+ end
+
+ describe "GET #{route_definition}/:board_id" do
+ let(:url) { "#{root_url}/#{board.id}" }
+
+ it 'get a single board by id' do
+ get api(url, user)
+
+ expect_schema_match_for(response, 'public_api/v4/board', ee)
+ end
+ end
+ end
+ end
+
+ describe "GET #{route_definition}/:board_id/lists" do
+ let(:url) { "#{root_url}/#{board.id}/lists" }
+
+ it 'returns issue board lists' do
+ get api(url, user)
+
+ 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(2)
+ expect(json_response.first['label']['name']).to eq(dev_label.title)
+ end
+
+ it 'returns 404 if board not found' do
+ get api("#{root_url}/22343/lists", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ describe "GET #{route_definition}/:board_id/lists/:list_id" do
+ let(:url) { "#{root_url}/#{board.id}/lists" }
+
+ it 'returns a list' do
+ get api("#{url}/#{dev_list.id}", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['id']).to eq(dev_list.id)
+ expect(json_response['label']['name']).to eq(dev_label.title)
+ expect(json_response['position']).to eq(1)
+ end
+
+ it 'returns 404 if list not found' do
+ get api("#{url}/5324", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ describe "POST #{route_definition}/lists" do
+ let(:url) { "#{root_url}/#{board.id}/lists" }
+
+ it 'creates a new issue board list for labels' do
+ post api(url, user), label_id: ux_label.id
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['label']['name']).to eq(ux_label.title)
+ expect(json_response['position']).to eq(3)
+ end
+
+ it 'returns 400 when creating a new list if label_id is invalid' do
+ post api(url, user), label_id: 23423
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it 'returns 403 for members with guest role' do
+ put api("#{url}/#{test_list.id}", guest), position: 1
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+
+ describe "PUT #{route_definition}/:board_id/lists/:list_id to update only position" do
+ let(:url) { "#{root_url}/#{board.id}/lists" }
+
+ it "updates a list" do
+ put api("#{url}/#{test_list.id}", user),
+ position: 1
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['position']).to eq(1)
+ end
+
+ it "returns 404 error if list id not found" do
+ put api("#{url}/44444", user),
+ position: 1
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it "returns 403 for members with guest role" do
+ put api("#{url}/#{test_list.id}", guest),
+ position: 1
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+
+ describe "DELETE #{route_definition}/lists/:list_id" do
+ let(:url) { "#{root_url}/#{board.id}/lists" }
+
+ it "rejects a non member from deleting a list" do
+ delete api("#{url}/#{dev_list.id}", non_member)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+
+ it "rejects a user with guest role from deleting a list" do
+ delete api("#{url}/#{dev_list.id}", guest)
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+
+ it "returns 404 error if list id not found" do
+ delete api("#{url}/44444", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ context "when the user is parent owner" do
+ set(:owner) { create(:user) }
+
+ before do
+ if board_parent.try(:namespace)
+ board_parent.update(namespace: owner.namespace)
+ else
+ board.parent.add_owner(owner)
+ end
+ end
+
+ it "deletes the list if an admin requests it" do
+ delete api("#{url}/#{dev_list.id}", owner)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api("#{url}/#{dev_list.id}", owner) }
+ end
+ end
+ end
+end
diff --git a/spec/support/api/milestones_shared_examples.rb b/spec/support/api/milestones_shared_examples.rb
index d9080b02541..0388f110d71 100644
--- a/spec/support/api/milestones_shared_examples.rb
+++ b/spec/support/api/milestones_shared_examples.rb
@@ -258,7 +258,7 @@ shared_examples_for 'group and project milestones' do |route_definition|
# Add public project to the group in context
setup_for_group if context_group
- public_project.team << [user, :developer]
+ public_project.add_developer(user)
milestone.issues << issue << confidential_issue
end
@@ -275,7 +275,7 @@ shared_examples_for 'group and project milestones' do |route_definition|
it 'does not return confidential issues to team members with guest role' do
member = create(:user)
- public_project.team << [member, :guest]
+ public_project.add_guest(member)
get api(issues_route, member)
diff --git a/spec/support/background_migrations_matchers.rb b/spec/support/background_migrations_matchers.rb
index 423c0e4cefc..f4127efc6ae 100644
--- a/spec/support/background_migrations_matchers.rb
+++ b/spec/support/background_migrations_matchers.rb
@@ -1,4 +1,4 @@
-RSpec::Matchers.define :be_scheduled_migration do |delay, *expected|
+RSpec::Matchers.define :be_scheduled_delayed_migration do |delay, *expected|
match do |migration|
BackgroundMigrationWorker.jobs.any? do |job|
job['args'] == [migration, expected] &&
@@ -11,3 +11,16 @@ RSpec::Matchers.define :be_scheduled_migration do |delay, *expected|
'not scheduled in expected time!'
end
end
+
+RSpec::Matchers.define :be_scheduled_migration do |*expected|
+ match do |migration|
+ BackgroundMigrationWorker.jobs.any? do |job|
+ args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args']
+ args == [migration, expected]
+ end
+ end
+
+ failure_message do |migration|
+ "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
+ end
+end
diff --git a/spec/support/bare_repo_operations.rb b/spec/support/bare_repo_operations.rb
index 38d11992dc2..8eeaa37d3c5 100644
--- a/spec/support/bare_repo_operations.rb
+++ b/spec/support/bare_repo_operations.rb
@@ -11,6 +11,14 @@ class BareRepoOperations
@path_to_repo = path_to_repo
end
+ def commit_tree(tree_id, msg, parent: EMPTY_TREE_ID)
+ commit_tree_args = ['commit-tree', tree_id, '-m', msg]
+ commit_tree_args += ['-p', parent] unless parent == EMPTY_TREE_ID
+ commit_id = execute(commit_tree_args)
+
+ commit_id[0]
+ end
+
# Based on https://stackoverflow.com/a/25556917/1856239
def commit_file(file, dst_path, branch = 'master')
head_id = execute(['show', '--format=format:%H', '--no-patch', branch], allow_failure: true)[0] || EMPTY_TREE_ID
@@ -26,11 +34,9 @@ class BareRepoOperations
tree_id = execute(['write-tree'])
- commit_tree_args = ['commit-tree', tree_id[0], '-m', "Add #{dst_path}"]
- commit_tree_args += ['-p', head_id] unless head_id == EMPTY_TREE_ID
- commit_id = execute(commit_tree_args)
+ commit_id = commit_tree(tree_id[0], "Add #{dst_path}", parent: head_id)
- execute(['update-ref', "refs/heads/#{branch}", commit_id[0]])
+ execute(['update-ref', "refs/heads/#{branch}", commit_id])
end
private
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index 935b170a0f6..8603b7f3e2c 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -33,6 +33,9 @@ Capybara.register_driver :chrome do |app|
options.add_argument("disable-gpu")
end
+ # Disable /dev/shm use in CI. See https://gitlab.com/gitlab-org/gitlab-ee/issues/4252
+ options.add_argument("disable-dev-shm-usage") if ENV['CI'] || ENV['CI_SERVER']
+
Capybara::Selenium::Driver.new(
app,
browser: :chrome,
@@ -75,8 +78,10 @@ RSpec.configure do |config|
end
config.after(:example, :js) do |example|
- # prevent localstorage from introducing side effects based on test order
- execute_script("localStorage.clear();")
+ # prevent localStorage from introducing side effects based on test order
+ unless ['', 'about:blank', 'data:,'].include? Capybara.current_session.driver.browser.current_url
+ execute_script("localStorage.clear();")
+ end
# capybara/rspec already calls Capybara.reset_sessions! in an `after` hook,
# but `block_and_wait_for_requests_complete` is called before it so by
diff --git a/spec/support/commit_trailers_spec_helper.rb b/spec/support/commit_trailers_spec_helper.rb
new file mode 100644
index 00000000000..add359946db
--- /dev/null
+++ b/spec/support/commit_trailers_spec_helper.rb
@@ -0,0 +1,41 @@
+module CommitTrailersSpecHelper
+ extend ActiveSupport::Concern
+
+ def expect_to_have_user_link_with_avatar(doc, user:, trailer:, email: nil)
+ wrapper = find_user_wrapper(doc, trailer)
+
+ expect_to_have_links_with_url_and_avatar(wrapper, urls.user_url(user), email || user.email)
+ expect(wrapper.attribute('data-user').value).to eq user.id.to_s
+ end
+
+ def expect_to_have_mailto_link(doc, email:, trailer:)
+ wrapper = find_user_wrapper(doc, trailer)
+
+ expect_to_have_links_with_url_and_avatar(wrapper, "mailto:#{CGI.escape_html(email)}", email)
+ end
+
+ def expect_to_have_links_with_url_and_avatar(doc, url, email)
+ expect(doc).not_to be_nil
+ expect(doc.xpath("a[position()<3 and @href='#{url}']").size).to eq 2
+ expect(doc.xpath("a[position()=3 and @href='mailto:#{CGI.escape_html(email)}']").size).to eq 1
+ expect(doc.css('img').size).to eq 1
+ end
+
+ def find_user_wrapper(doc, trailer)
+ doc.xpath("descendant-or-self::node()[@data-trailer='#{trailer}']").first
+ end
+
+ def build_commit_message(trailer:, name:, email:)
+ message = trailer_line(trailer, name, email)
+
+ [message, commit_html(message)]
+ end
+
+ def trailer_line(trailer, name, email)
+ "#{trailer} #{name} <#{email}>"
+ end
+
+ def commit_html(message)
+ "<pre>#{CGI.escape_html(message)}</pre>"
+ end
+end
diff --git a/spec/support/controllers/githubish_import_controller_shared_examples.rb b/spec/support/controllers/githubish_import_controller_shared_examples.rb
index a0839eefe6c..3321f920666 100644
--- a/spec/support/controllers/githubish_import_controller_shared_examples.rb
+++ b/spec/support/controllers/githubish_import_controller_shared_examples.rb
@@ -92,6 +92,7 @@ end
shared_examples 'a GitHub-ish import controller: POST create' do
let(:user) { create(:user) }
+ let(:project) { create(:project) }
let(:provider_username) { user.username }
let(:provider_user) { OpenStruct.new(login: provider_username) }
let(:provider_repo) do
@@ -107,14 +108,34 @@ shared_examples 'a GitHub-ish import controller: POST create' do
assign_session_token(provider)
end
+ it 'returns 200 response when the project is imported successfully' do
+ allow(Gitlab::LegacyGithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: project))
+
+ post :create, format: :json
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+
+ it 'returns 422 response when the project could not be imported' do
+ allow(Gitlab::LegacyGithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: build(:project)))
+
+ post :create, format: :json
+
+ expect(response).to have_gitlab_http_status(422)
+ end
+
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::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -124,9 +145,9 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it "takes the current user's namespace" do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
end
@@ -151,9 +172,9 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it "takes the existing namespace" do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, provider_repo.name, existing_namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
@@ -163,9 +184,9 @@ shared_examples 'a GitHub-ish import controller: POST create' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
end
@@ -174,17 +195,17 @@ shared_examples 'a GitHub-ish import controller: POST create' do
context "when current user can create namespaces" do
it "creates the namespace" do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
- .to receive(:new).and_return(double(execute: true))
+ .to receive(:new).and_return(double(execute: project))
- expect { post :create, target_namespace: provider_repo.name, format: :js }.to change(Namespace, :count).by(1)
+ expect { post :create, target_namespace: provider_repo.name, format: :json }.to change(Namespace, :count).by(1)
end
it "takes the new namespace" do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, provider_repo.name, an_instance_of(Group), user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, target_namespace: provider_repo.name, format: :js
+ post :create, target_namespace: provider_repo.name, format: :json
end
end
@@ -195,17 +216,17 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it "doesn't create the namespace" do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
- .to receive(:new).and_return(double(execute: true))
+ .to receive(:new).and_return(double(execute: project))
- expect { post :create, format: :js }.not_to change(Namespace, :count)
+ expect { post :create, format: :json }.not_to change(Namespace, :count)
end
it "takes the current user's namespace" do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, format: :js
+ post :create, format: :json
end
end
end
@@ -221,21 +242,21 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it 'takes the selected namespace and name' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, test_namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, { target_namespace: test_namespace.name, new_name: test_name, format: :js }
+ post :create, { target_namespace: test_namespace.name, new_name: test_name, format: :json }
end
it 'takes the selected name and default namespace' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, { new_name: test_name, format: :js }
+ post :create, { new_name: test_name, format: :json }
end
end
- context 'user has chosen an existing nested namespace and name for the project' do
+ context 'user has chosen an existing nested namespace and name for the project', :postgresql do
let(:parent_namespace) { create(:group, name: 'foo', owner: user) }
let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) }
let(:test_name) { 'test_name' }
@@ -247,63 +268,124 @@ shared_examples 'a GitHub-ish import controller: POST create' do
it 'takes the selected namespace and name' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, nested_namespace, user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, { target_namespace: nested_namespace.full_path, new_name: test_name, format: :js }
+ post :create, { target_namespace: nested_namespace.full_path, new_name: test_name, format: :json }
end
end
- context 'user has chosen a non-existent nested namespaces and name for the project' do
+ context 'user has chosen a non-existent nested namespaces and name for the project', :postgresql do
let(:test_name) { 'test_name' }
it 'takes the selected namespace and name' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
+ post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :json }
end
it 'creates the namespaces' do
allow(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- expect { post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js } }
+ expect { post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :json } }
.to change { Namespace.count }.by(2)
end
it 'new namespace has the right parent' do
allow(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :js }
+ post :create, { target_namespace: 'foo/bar', new_name: test_name, format: :json }
expect(Namespace.find_by_path_or_name('bar').parent.path).to eq('foo')
end
end
- context 'user has chosen existent and non-existent nested namespaces and name for the project' do
+ context 'user has chosen existent and non-existent nested namespaces and name for the project', :postgresql do
let(:test_name) { 'test_name' }
let!(:parent_namespace) { create(:group, name: 'foo', owner: user) }
+ before do
+ parent_namespace.add_owner(user)
+ end
+
it 'takes the selected namespace and name' do
expect(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js }
+ post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :json }
end
it 'creates the namespaces' do
allow(Gitlab::LegacyGithubImport::ProjectCreator)
.to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
- .and_return(double(execute: true))
+ .and_return(double(execute: project))
- expect { post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js } }
+ expect { post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :json } }
.to change { Namespace.count }.by(2)
end
+
+ it 'does not create a new namespace under the user namespace' do
+ expect(Gitlab::LegacyGithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: build_stubbed(:project)))
+
+ expect { post :create, { target_namespace: "#{user.namespace_path}/test_group", new_name: test_name, format: :js } }
+ .not_to change { Namespace.count }
+ end
+ end
+
+ context 'user cannot create a subgroup inside a group is not a member of' do
+ let(:test_name) { 'test_name' }
+ let!(:parent_namespace) { create(:group, name: 'foo') }
+
+ it 'does not take the selected namespace and name' do
+ expect(Gitlab::LegacyGithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: build_stubbed(:project)))
+
+ post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js }
+ end
+
+ it 'does not create the namespaces' do
+ allow(Gitlab::LegacyGithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider)
+ .and_return(double(execute: build_stubbed(:project)))
+
+ expect { post :create, { target_namespace: 'foo/foobar/bar', new_name: test_name, format: :js } }
+ .not_to change { Namespace.count }
+ end
+ end
+
+ context 'user can use a group without having permissions to create a group' do
+ let(:test_name) { 'test_name' }
+ let!(:group) { create(:group, name: 'foo') }
+
+ it 'takes the selected namespace and name' do
+ group.add_owner(user)
+ user.update!(can_create_group: false)
+
+ expect(Gitlab::LegacyGithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, test_name, group, user, access_params, type: provider)
+ .and_return(double(execute: build_stubbed(:project)))
+
+ post :create, { target_namespace: 'foo', new_name: test_name, format: :js }
+ end
+ end
+
+ context 'when user can not create projects in the chosen namespace' do
+ it 'returns 422 response' do
+ other_namespace = create(:group, name: 'other_namespace')
+
+ post :create, { target_namespace: other_namespace.name, format: :json }
+
+ expect(response).to have_gitlab_http_status(422)
+ end
end
end
end
diff --git a/spec/support/cookie_helper.rb b/spec/support/cookie_helper.rb
index 224619c899c..5ff7b0b68c9 100644
--- a/spec/support/cookie_helper.rb
+++ b/spec/support/cookie_helper.rb
@@ -2,12 +2,29 @@
#
module CookieHelper
def set_cookie(name, value, options = {})
+ case page.driver
+ when Capybara::RackTest::Driver
+ rack_set_cookie(name, value)
+ else
+ selenium_set_cookie(name, value, options)
+ end
+ end
+
+ def selenium_set_cookie(name, value, options = {})
# Selenium driver will not set cookies for a given domain when the browser is at `about:blank`.
# It also doesn't appear to allow overriding the cookie path. loading `/` is the most inclusive.
visit options.fetch(:path, '/') unless on_a_page?
page.driver.browser.manage.add_cookie(name: name, value: value, **options)
end
+ def rack_set_cookie(name, value)
+ page.driver.browser.set_cookie("#{name}=#{value}")
+ end
+
+ def get_cookie(name)
+ page.driver.browser.manage.cookie_named(name)
+ end
+
private
def on_a_page?
diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb
index 26fd271ce31..73cc64c0b74 100644
--- a/spec/support/cycle_analytics_helpers.rb
+++ b/spec/support/cycle_analytics_helpers.rb
@@ -5,10 +5,16 @@ module CycleAnalyticsHelpers
end
def create_commit(message, project, user, branch_name, count: 1)
- oldrev = project.repository.commit(branch_name).sha
+ repository = project.repository
+ oldrev = repository.commit(branch_name).sha
+
+ if Timecop.frozen? && Gitlab::GitalyClient.feature_enabled?(:operation_user_commit_files)
+ mock_gitaly_multi_action_dates(repository.raw)
+ end
+
commit_shas = Array.new(count) do |index|
- commit_sha = project.repository.create_file(user, generate(:branch), "content", message: message, branch_name: branch_name)
- project.repository.commit(commit_sha)
+ commit_sha = repository.create_file(user, generate(:branch), "content", message: message, branch_name: branch_name)
+ repository.commit(commit_sha)
commit_sha
end
@@ -20,7 +26,19 @@ module CycleAnalyticsHelpers
ref: 'refs/heads/master').execute
end
- def create_merge_request_closing_issue(issue, message: nil, source_branch: nil, commit_message: 'commit message')
+ def create_cycle(user, project, issue, mr, milestone, pipeline)
+ issue.update(milestone: milestone)
+ pipeline.run
+
+ ci_build = create(:ci_build, pipeline: pipeline, status: :success, author: user)
+
+ merge_merge_requests_closing_issue(user, project, issue)
+ ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.to_hash)
+
+ ci_build
+ end
+
+ def create_merge_request_closing_issue(user, project, 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')
@@ -46,19 +64,19 @@ module CycleAnalyticsHelpers
mr
end
- def merge_merge_requests_closing_issue(issue)
+ def merge_merge_requests_closing_issue(user, project, issue)
merge_requests = issue.closed_by_merge_requests(user)
merge_requests.each { |merge_request| MergeRequests::MergeService.new(project, user).execute(merge_request) }
end
- def deploy_master(environment: 'production')
+ def deploy_master(user, project, environment: 'production')
dummy_job =
case environment
when 'production'
- dummy_production_job
+ dummy_production_job(user, project)
when 'staging'
- dummy_staging_job
+ dummy_staging_job(user, project)
else
raise ArgumentError
end
@@ -66,25 +84,24 @@ module CycleAnalyticsHelpers
CreateDeploymentService.new(dummy_job).execute
end
- def dummy_production_job
- @dummy_job ||= new_dummy_job('production')
+ def dummy_production_job(user, project)
+ new_dummy_job(user, project, 'production')
end
- def dummy_staging_job
- @dummy_job ||= new_dummy_job('staging')
+ def dummy_staging_job(user, project)
+ new_dummy_job(user, project, 'staging')
end
- def dummy_pipeline
- @dummy_pipeline ||=
- Ci::Pipeline.new(
- sha: project.repository.commit('master').sha,
- ref: 'master',
- source: :push,
- project: project,
- protected: false)
+ def dummy_pipeline(project)
+ Ci::Pipeline.new(
+ sha: project.repository.commit('master').sha,
+ ref: 'master',
+ source: :push,
+ project: project,
+ protected: false)
end
- def new_dummy_job(environment)
+ def new_dummy_job(user, project, environment)
project.environments.find_or_create_by(name: environment)
Ci::Build.new(
@@ -95,9 +112,28 @@ module CycleAnalyticsHelpers
tag: false,
name: 'dummy',
stage: 'dummy',
- pipeline: dummy_pipeline,
+ pipeline: dummy_pipeline(project),
protected: false)
end
+
+ def mock_gitaly_multi_action_dates(raw_repository)
+ allow(raw_repository).to receive(:multi_action).and_wrap_original do |m, *args|
+ new_date = Time.now
+ branch_update = m.call(*args)
+
+ if branch_update.newrev
+ _, opts = args
+ commit = raw_repository.commit(branch_update.newrev).rugged_commit
+ branch_update.newrev = commit.amend(
+ update_ref: "#{Gitlab::Git::BRANCH_REF_PREFIX}#{opts[:branch_name]}",
+ author: commit.author.merge(time: new_date),
+ committer: commit.committer.merge(time: new_date)
+ )
+ end
+
+ branch_update
+ end
+ end
end
RSpec.configure do |config|
diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb
index edaee03ea6c..5edc5de2a09 100644
--- a/spec/support/db_cleaner.rb
+++ b/spec/support/db_cleaner.rb
@@ -1,10 +1,26 @@
+require 'database_cleaner/active_record/deletion'
+
+module FakeInformationSchema
+ # Work around a bug in DatabaseCleaner when using the deletion strategy:
+ # https://github.com/DatabaseCleaner/database_cleaner/issues/347
+ #
+ # On MySQL, if the information schema is said to exist, we use an inaccurate
+ # row count leading to some tables not being cleaned when they should
+ def information_schema_exists?(_connection)
+ false
+ end
+end
+
+DatabaseCleaner::ActiveRecord::Deletion.prepend(FakeInformationSchema)
+
RSpec.configure do |config|
+ # Ensure all sequences are reset at the start of the suite run
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.append_after(:context) do
- DatabaseCleaner.clean_with(:truncation, cache_tables: false)
+ DatabaseCleaner.clean_with(:deletion, cache_tables: false)
end
config.before(:each) do
@@ -12,15 +28,15 @@ RSpec.configure do |config|
end
config.before(:each, :js) do
- DatabaseCleaner.strategy = :truncation
+ DatabaseCleaner.strategy = :deletion, { cache_tables: false }
end
- config.before(:each, :truncate) do
- DatabaseCleaner.strategy = :truncation
+ config.before(:each, :delete) do
+ DatabaseCleaner.strategy = :deletion, { cache_tables: false }
end
config.before(:each, :migration) do
- DatabaseCleaner.strategy = :truncation, { cache_tables: false }
+ DatabaseCleaner.strategy = :deletion, { cache_tables: false }
end
config.before(:each) do
diff --git a/spec/support/devise_helpers.rb b/spec/support/devise_helpers.rb
index 890a2d9d287..66874e10f38 100644
--- a/spec/support/devise_helpers.rb
+++ b/spec/support/devise_helpers.rb
@@ -2,13 +2,16 @@ 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 = env_from_context(context)
env['devise.mapping'] = Devise.mappings[:user] if env
end
+
+ def env_from_context(context)
+ if context.respond_to?(:env_config)
+ context.env_config
+ elsif context.respond_to?(:env)
+ context.env
+ end
+ end
end
diff --git a/spec/support/email_helpers.rb b/spec/support/email_helpers.rb
index b39052923dd..1fb8252459f 100644
--- a/spec/support/email_helpers.rb
+++ b/spec/support/email_helpers.rb
@@ -30,4 +30,8 @@ module EmailHelpers
def email_recipients(kind: :to)
ActionMailer::Base.deliveries.flat_map(&kind)
end
+
+ def find_email_for(user)
+ ActionMailer::Base.deliveries.find { |d| d.to.include?(user.notification_email) }
+ end
end
diff --git a/spec/support/factory_girl.rb b/spec/support/factory_bot.rb
index c7890e49c66..c7890e49c66 100644
--- a/spec/support/factory_girl.rb
+++ b/spec/support/factory_bot.rb
diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb
index c24940393f9..80604395adf 100644
--- a/spec/support/features/discussion_comments_shared_example.rb
+++ b/spec/support/features/discussion_comments_shared_example.rb
@@ -81,7 +81,10 @@ shared_examples 'discussion comments' do |resource_name|
# on issues page, the menu closes when clicking anywhere, on other pages it will
# remain open if clicking divider or menu padding, but should not change button action
- if resource_name == 'issue'
+ #
+ # if dropdown menu is not toggled (and also not present),
+ # it's "issue-type" dropdown
+ if first(menu_selector).nil?
expect(find(dropdown_selector)).to have_content 'Comment'
find(toggle_selector).click
@@ -107,12 +110,15 @@ shared_examples 'discussion comments' do |resource_name|
end
it 'updates the submit button text and closes the dropdown' do
+ button = find(submit_selector)
+
# on issues page, the submit input is a <button>, on other pages it is <input>
- if resource_name == 'issue'
+ if button.tag_name == 'button'
expect(find(submit_selector)).to have_content 'Start discussion'
else
expect(find(submit_selector).value).to eq 'Start discussion'
end
+
expect(page).not_to have_selector menu_selector
end
@@ -131,6 +137,8 @@ shared_examples 'discussion comments' do |resource_name|
describe 'creating a discussion' do
before do
find(submit_selector).click
+ wait_for_requests
+
find(comments_selector, match: :first)
end
@@ -142,15 +150,17 @@ shared_examples 'discussion comments' do |resource_name|
end
if resource_name == 'merge request'
+ let(:note_id) { find("#{comments_selector} .note", match: :first)['data-note-id'] }
+
it 'shows resolved discussion when toggled' do
click_button "Resolve discussion"
- expect(page).to have_selector('.note-row-1', visible: true)
+ expect(page).to have_selector(".note-row-#{note_id}", visible: true)
refresh
click_button "Toggle discussion"
- expect(page).to have_selector('.note-row-1', visible: true)
+ expect(page).to have_selector(".note-row-#{note_id}", visible: true)
end
end
end
@@ -194,12 +204,15 @@ shared_examples 'discussion comments' do |resource_name|
end
it 'updates the submit button text and closes the dropdown' do
+ button = find(submit_selector)
+
# on issues page, the submit input is a <button>, on other pages it is <input>
- if resource_name == 'issue'
- expect(find(submit_selector)).to have_content 'Comment'
+ if button.tag_name == 'button'
+ expect(button).to have_content 'Comment'
else
- expect(find(submit_selector).value).to eq 'Comment'
+ expect(button.value).to eq 'Comment'
end
+
expect(page).not_to have_selector menu_selector
end
diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb
index 08e21ee2537..1bd6c25100e 100644
--- a/spec/support/features/issuable_slash_commands_shared_examples.rb
+++ b/spec/support/features/issuable_slash_commands_shared_examples.rb
@@ -2,7 +2,7 @@
# It takes a `issuable_type`, and expect an `issuable`.
shared_examples 'issuable record that supports quick actions in its description and notes' do |issuable_type|
- include QuickActionsHelpers
+ include Spec::Support::Helpers::Features::NotesHelpers
let(:master) { create(:user) }
let(:project) do
@@ -19,7 +19,7 @@ shared_examples 'issuable record that supports quick actions in its description
let(:new_url_opts) { {} }
before do
- project.team << [master, :master]
+ project.add_master(master)
gitlab_sign_in(master)
end
@@ -61,7 +61,7 @@ shared_examples 'issuable record that supports quick actions in its description
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\n/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"")
+ add_note("Awesome!\n\n/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"")
expect(page).to have_content 'Awesome!'
expect(page).not_to have_content '/assign @bob'
@@ -82,7 +82,7 @@ shared_examples 'issuable record that supports quick actions 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\n/label ~bug\n\n/milestone %\"ASAP\"")
+ add_note("/assign @bob\n\n/label ~bug\n\n/milestone %\"ASAP\"")
expect(page).not_to have_content '/assign @bob'
expect(page).not_to have_content '/label ~bug'
@@ -105,7 +105,7 @@ shared_examples 'issuable record that supports quick actions in its description
context "when current user can close #{issuable_type}" do
it "closes the #{issuable_type}" do
- write_note("/close")
+ add_note("/close")
expect(page).not_to have_content '/close'
expect(page).to have_content 'Commands applied'
@@ -125,9 +125,8 @@ shared_examples 'issuable record that supports quick actions in its description
end
it "does not close the #{issuable_type}" do
- write_note("/close")
+ add_note("/close")
- expect(page).to have_content '/close'
expect(page).not_to have_content 'Commands applied'
expect(issuable).to be_open
@@ -143,7 +142,7 @@ shared_examples 'issuable record that supports quick actions in its description
context "when current user can reopen #{issuable_type}" do
it "reopens the #{issuable_type}" do
- write_note("/reopen")
+ add_note("/reopen")
expect(page).not_to have_content '/reopen'
expect(page).to have_content 'Commands applied'
@@ -163,9 +162,8 @@ shared_examples 'issuable record that supports quick actions in its description
end
it "does not reopen the #{issuable_type}" do
- write_note("/reopen")
+ add_note("/reopen")
- expect(page).to have_content '/reopen'
expect(page).not_to have_content 'Commands applied'
expect(issuable).to be_closed
@@ -176,7 +174,7 @@ shared_examples 'issuable record that supports quick actions in its description
context "with a note changing the #{issuable_type}'s title" do
context "when current user can change title of #{issuable_type}" do
it "reopens the #{issuable_type}" do
- write_note("/title Awesome new title")
+ add_note("/title Awesome new title")
expect(page).not_to have_content '/title'
expect(page).to have_content 'Commands applied'
@@ -195,10 +193,9 @@ shared_examples 'issuable record that supports quick actions in its description
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
- it "does not reopen the #{issuable_type}" do
- write_note("/title Awesome new title")
+ it "does not change the #{issuable_type} title" do
+ add_note("/title Awesome new title")
- expect(page).to have_content '/title'
expect(page).not_to have_content 'Commands applied'
expect(issuable.reload.title).not_to eq 'Awesome new title'
@@ -208,7 +205,7 @@ shared_examples 'issuable record that supports quick actions in its description
context "with a note marking the #{issuable_type} as todo" do
it "creates a new todo for the #{issuable_type}" do
- write_note("/todo")
+ add_note("/todo")
expect(page).not_to have_content '/todo'
expect(page).to have_content 'Commands applied'
@@ -239,7 +236,7 @@ shared_examples 'issuable record that supports quick actions in its description
expect(todo.author).to eq master
expect(todo.user).to eq master
- write_note("/done")
+ add_note("/done")
expect(page).not_to have_content '/done'
expect(page).to have_content 'Commands applied'
@@ -252,7 +249,7 @@ shared_examples 'issuable record that supports quick actions in its description
it "creates a new todo for the #{issuable_type}" do
expect(issuable.subscribed?(master, project)).to be_falsy
- write_note("/subscribe")
+ add_note("/subscribe")
expect(page).not_to have_content '/subscribe'
expect(page).to have_content 'Commands applied'
@@ -269,7 +266,7 @@ shared_examples 'issuable record that supports quick actions in its description
it "creates a new todo for the #{issuable_type}" do
expect(issuable.subscribed?(master, project)).to be_truthy
- write_note("/unsubscribe")
+ add_note("/unsubscribe")
expect(page).not_to have_content '/unsubscribe'
expect(page).to have_content 'Commands applied'
@@ -280,7 +277,7 @@ shared_examples 'issuable record that supports quick actions in its description
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")
+ add_note("/assign me")
expect(page).not_to have_content '/assign me'
expect(page).to have_content 'Commands applied'
diff --git a/spec/support/features/variable_list_shared_examples.rb b/spec/support/features/variable_list_shared_examples.rb
new file mode 100644
index 00000000000..f7f851eb1eb
--- /dev/null
+++ b/spec/support/features/variable_list_shared_examples.rb
@@ -0,0 +1,271 @@
+shared_examples 'variable list' do
+ it 'shows list of variables' do
+ page.within('.js-ci-variable-list-section') do
+ expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
+ end
+ end
+
+ it 'adds new secret variable' do
+ page.within('.js-ci-variable-list-section .js-row:last-child') do
+ find('.js-ci-variable-input-key').set('key')
+ find('.js-ci-variable-input-value').set('key value')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ # We check the first row because it re-sorts to alphabetical order on refresh
+ page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
+ expect(find('.js-ci-variable-input-key').value).to eq('key')
+ expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key value')
+ end
+ end
+
+ it 'adds empty variable' do
+ page.within('.js-ci-variable-list-section .js-row:last-child') do
+ find('.js-ci-variable-input-key').set('key')
+ find('.js-ci-variable-input-value').set('')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ # We check the first row because it re-sorts to alphabetical order on refresh
+ page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
+ expect(find('.js-ci-variable-input-key').value).to eq('key')
+ expect(find('.js-ci-variable-input-value', visible: false).value).to eq('')
+ end
+ end
+
+ it 'adds new protected variable' do
+ page.within('.js-ci-variable-list-section .js-row:last-child') do
+ find('.js-ci-variable-input-key').set('key')
+ find('.js-ci-variable-input-value').set('key value')
+ find('.ci-variable-protected-item .js-project-feature-toggle').click
+
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ # We check the first row because it re-sorts to alphabetical order on refresh
+ page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
+ expect(find('.js-ci-variable-input-key').value).to eq('key')
+ expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key value')
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ end
+ end
+
+ it 'reveals and hides variables' do
+ page.within('.js-ci-variable-list-section') do
+ expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
+ expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value)
+ expect(page).to have_content('*' * 20)
+
+ click_button('Reveal value')
+
+ expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
+ expect(first('.js-ci-variable-input-value').value).to eq(variable.value)
+ expect(page).not_to have_content('*' * 20)
+
+ click_button('Hide value')
+
+ expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
+ expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value)
+ expect(page).to have_content('*' * 20)
+ end
+ end
+
+ it 'deletes variable' do
+ page.within('.js-ci-variable-list-section') do
+ expect(page).to have_selector('.js-row', count: 2)
+
+ first('.js-row-remove-button').click
+
+ click_button('Save variables')
+ wait_for_requests
+
+ expect(page).to have_selector('.js-row', count: 1)
+ end
+ end
+
+ it 'edits variable' do
+ page.within('.js-ci-variable-list-section') do
+ click_button('Reveal value')
+
+ page.within('.js-row:nth-child(1)') do
+ find('.js-ci-variable-input-key').set('new_key')
+ find('.js-ci-variable-input-value').set('new_value')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ page.within('.js-row:nth-child(1)') do
+ expect(find('.js-ci-variable-input-key').value).to eq('new_key')
+ expect(find('.js-ci-variable-input-value', visible: false).value).to eq('new_value')
+ end
+ end
+ end
+
+ it 'edits variable with empty value' do
+ page.within('.js-ci-variable-list-section') do
+ click_button('Reveal value')
+
+ page.within('.js-row:nth-child(1)') do
+ find('.js-ci-variable-input-key').set('new_key')
+ find('.js-ci-variable-input-value').set('')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ page.within('.js-row:nth-child(1)') do
+ expect(find('.js-ci-variable-input-key').value).to eq('new_key')
+ expect(find('.js-ci-variable-input-value', visible: false).value).to eq('')
+ end
+ end
+ end
+
+ it 'edits variable to be protected' do
+ # Create the unprotected variable
+ page.within('.js-ci-variable-list-section .js-row:last-child') do
+ find('.js-ci-variable-input-key').set('unprotected_key')
+ find('.js-ci-variable-input-value').set('unprotected_value')
+
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ # We check the first row because it re-sorts to alphabetical order on refresh
+ page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
+ find('.ci-variable-protected-item .js-project-feature-toggle').click
+
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ # We check the first row because it re-sorts to alphabetical order on refresh
+ page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
+ expect(find('.js-ci-variable-input-key').value).to eq('unprotected_key')
+ expect(find('.js-ci-variable-input-value', visible: false).value).to eq('unprotected_value')
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ end
+ end
+
+ it 'edits variable to be unprotected' do
+ # Create the protected variable
+ page.within('.js-ci-variable-list-section .js-row:last-child') do
+ find('.js-ci-variable-input-key').set('protected_key')
+ find('.js-ci-variable-input-value').set('protected_value')
+ find('.ci-variable-protected-item .js-project-feature-toggle').click
+
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
+ find('.ci-variable-protected-item .js-project-feature-toggle').click
+
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
+ expect(find('.js-ci-variable-input-key').value).to eq('protected_key')
+ expect(find('.js-ci-variable-input-value', visible: false).value).to eq('protected_value')
+ expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
+ end
+ end
+
+ it 'handles multiple edits and deletion in the middle' do
+ page.within('.js-ci-variable-list-section') do
+ # Create 2 variables
+ page.within('.js-row:last-child') do
+ find('.js-ci-variable-input-key').set('akey')
+ find('.js-ci-variable-input-value').set('akeyvalue')
+ end
+ page.within('.js-row:last-child') do
+ find('.js-ci-variable-input-key').set('zkey')
+ find('.js-ci-variable-input-value').set('zkeyvalue')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ expect(page).to have_selector('.js-row', count: 4)
+
+ # Remove the `akey` variable
+ page.within('.js-row:nth-child(2)') do
+ first('.js-row-remove-button').click
+ end
+
+ # Add another variable
+ page.within('.js-row:last-child') do
+ find('.js-ci-variable-input-key').set('ckey')
+ find('.js-ci-variable-input-value').set('ckeyvalue')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ visit page_path
+
+ # Expect to find 3 variables(4 rows) in alphbetical order
+ expect(page).to have_selector('.js-row', count: 4)
+ row_keys = all('.js-ci-variable-input-key')
+ expect(row_keys[0].value).to eq('ckey')
+ expect(row_keys[1].value).to eq('test_key')
+ expect(row_keys[2].value).to eq('zkey')
+ expect(row_keys[3].value).to eq('')
+ end
+ end
+
+ it 'shows validation error box about duplicate keys' do
+ page.within('.js-ci-variable-list-section .js-row:last-child') do
+ find('.js-ci-variable-input-key').set('samekey')
+ find('.js-ci-variable-input-value').set('value1')
+ end
+ page.within('.js-ci-variable-list-section .js-row:last-child') do
+ find('.js-ci-variable-input-key').set('samekey')
+ find('.js-ci-variable-input-value').set('value2')
+ end
+
+ click_button('Save variables')
+ wait_for_requests
+
+ expect(all('.js-ci-variable-list-section .js-ci-variable-error-box ul li').count).to eq(1)
+
+ # We check the first row because it re-sorts to alphabetical order on refresh
+ page.within('.js-ci-variable-list-section') do
+ expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables have duplicate values \(.+\)/)
+ end
+ end
+end
diff --git a/spec/support/filtered_search_helpers.rb b/spec/support/filtered_search_helpers.rb
index 05021ea9054..5f42ff77fb2 100644
--- a/spec/support/filtered_search_helpers.rb
+++ b/spec/support/filtered_search_helpers.rb
@@ -21,6 +21,29 @@ module FilteredSearchHelpers
end
end
+ # Select a label clicking in the search dropdown instead
+ # of entering label names on the input.
+ def select_label_on_dropdown(label_title)
+ input_filtered_search("label:", submit: false)
+
+ within('#js-dropdown-label') do
+ wait_for_requests
+
+ find('li', text: label_title).click
+ end
+
+ filtered_search.send_keys(:enter)
+ end
+
+ def expect_issues_list_count(open_count, closed_count = 0)
+ all_count = open_count + closed_count
+
+ expect(page).to have_issuable_counts(open: open_count, closed: closed_count, all: all_count)
+ page.within '.issues-list' do
+ expect(page).to have_selector('.issue', count: open_count)
+ end
+ end
+
# Enables input to be added character by character
def input_filtered_search_keys(search_term)
# Add an extra space to engage visual tokens
@@ -61,9 +84,11 @@ module FilteredSearchHelpers
token_emoji = tokens[index][:emoji_name]
expect(el.find('.name')).to have_content(token_name)
+
if token_value
expect(el.find('.value')).to have_content(token_value)
end
+
# gl-emoji content is blank when the emoji unicode is not supported
if token_emoji
selector = %(gl-emoji[data-name="#{token_emoji}"])
diff --git a/spec/support/fixture_helpers.rb b/spec/support/fixture_helpers.rb
index 128aaaf25fe..8854382dc6b 100644
--- a/spec/support/fixture_helpers.rb
+++ b/spec/support/fixture_helpers.rb
@@ -1,12 +1,12 @@
module FixtureHelpers
- def fixture_file(filename)
+ def fixture_file(filename, dir: '')
return '' if filename.blank?
- File.read(expand_fixture_path(filename))
+ File.read(expand_fixture_path(filename, dir: dir))
end
- def expand_fixture_path(filename)
- File.expand_path(Rails.root.join('spec/fixtures/', filename))
+ def expand_fixture_path(filename, dir: '')
+ File.expand_path(Rails.root.join(dir, 'spec', 'fixtures', filename))
end
end
diff --git a/spec/support/generate-seed-repo-rb b/spec/support/generate-seed-repo-rb
index 4ee33f9725b..876b3b8242d 100755
--- a/spec/support/generate-seed-repo-rb
+++ b/spec/support/generate-seed-repo-rb
@@ -24,6 +24,7 @@ def main
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)
diff --git a/spec/support/gitaly.rb b/spec/support/gitaly.rb
index c7e8a39a617..9cf541372b5 100644
--- a/spec/support/gitaly.rb
+++ b/spec/support/gitaly.rb
@@ -1,11 +1,13 @@
RSpec.configure do |config|
config.before(:each) do |example|
if example.metadata[:disable_gitaly]
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(false)
+ # Use 'and_wrap_original' to make sure the arguments are valid
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_wrap_original { |m, *args| m.call(*args) && false }
else
next if example.metadata[:skip_gitaly_mock]
- allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true)
+ # Use 'and_wrap_original' to make sure the arguments are valid
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_wrap_original { |m, *args| m.call(*args) || true }
end
end
end
diff --git a/spec/support/gitlab-git-test.git/packed-refs b/spec/support/gitlab-git-test.git/packed-refs
index 507e4ce785a..ea50e4ad3f6 100644
--- a/spec/support/gitlab-git-test.git/packed-refs
+++ b/spec/support/gitlab-git-test.git/packed-refs
@@ -1,4 +1,4 @@
-# pack-refs with: peeled fully-peeled
+# pack-refs with: peeled fully-peeled sorted
0b4bc9a49b562e85de7cc9e834518ea6828729b9 refs/heads/feature
12d65c8dd2b2676fa3ac47d955accc085a37a9c1 refs/heads/fix
6473c90867124755509e100d0d35ebdc85a0b6ae refs/heads/fix-blob-path
diff --git a/spec/support/gitlab_verify.rb b/spec/support/gitlab_verify.rb
new file mode 100644
index 00000000000..13e2e37624d
--- /dev/null
+++ b/spec/support/gitlab_verify.rb
@@ -0,0 +1,45 @@
+RSpec.shared_examples 'Gitlab::Verify::BatchVerifier subclass' do
+ describe 'batching' do
+ let(:first_batch) { objects[0].id..objects[0].id }
+ let(:second_batch) { objects[1].id..objects[1].id }
+ let(:third_batch) { objects[2].id..objects[2].id }
+
+ it 'iterates through objects in batches' do
+ expect(collect_ranges).to eq([first_batch, second_batch, third_batch])
+ end
+
+ it 'allows the starting ID to be specified' do
+ expect(collect_ranges(start: second_batch.first)).to eq([second_batch, third_batch])
+ end
+
+ it 'allows the finishing ID to be specified' do
+ expect(collect_ranges(finish: second_batch.last)).to eq([first_batch, second_batch])
+ end
+ end
+end
+
+module GitlabVerifyHelpers
+ def collect_ranges(args = {})
+ verifier = described_class.new(args.merge(batch_size: 1))
+
+ collect_results(verifier).map { |range, _| range }
+ end
+
+ def collect_failures
+ verifier = described_class.new(batch_size: 1)
+
+ out = {}
+
+ collect_results(verifier).map { |_, failures| out.merge!(failures) }
+
+ out
+ end
+
+ def collect_results(verifier)
+ out = []
+
+ verifier.run_batches { |*args| out << args }
+
+ out
+ end
+end
diff --git a/spec/support/google_api/cloud_platform_helpers.rb b/spec/support/google_api/cloud_platform_helpers.rb
index 8a073e58db8..2fdbddd40c2 100644
--- a/spec/support/google_api/cloud_platform_helpers.rb
+++ b/spec/support/google_api/cloud_platform_helpers.rb
@@ -10,6 +10,16 @@ module GoogleApi
request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.ago.to_i.to_s
end
+ def stub_cloud_platform_projects_list(options)
+ WebMock.stub_request(:get, cloud_platform_projects_list_url)
+ .to_return(cloud_platform_response(cloud_platform_projects_body(options)))
+ end
+
+ def stub_cloud_platform_projects_get_billing_info(project_id, billing_enabled)
+ WebMock.stub_request(:get, cloud_platform_projects_get_billing_info_url(project_id))
+ .to_return(cloud_platform_response(cloud_platform_projects_billing_info_body(project_id, billing_enabled)))
+ end
+
def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, **options)
WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id))
.to_return(cloud_platform_response(cloud_platform_cluster_body(options)))
@@ -40,6 +50,14 @@ module GoogleApi
.to_return(status: [500, "Internal Server Error"])
end
+ def cloud_platform_projects_list_url
+ "https://cloudresourcemanager.googleapis.com/v1/projects"
+ end
+
+ def cloud_platform_projects_get_billing_info_url(project_id)
+ "https://cloudbilling.googleapis.com/v1/projects/#{project_id}/billingInfo"
+ end
+
def cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)
"https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/clusters/#{cluster_id}"
end
@@ -115,5 +133,32 @@ module GoogleApi
"endTime": options[:endTime] || ''
}
end
+
+ def cloud_platform_projects_body(**options)
+ {
+ "projects": [
+ {
+ "projectNumber": options[:project_number] || "1234",
+ "projectId": options[:project_id] || "test-project-1234",
+ "lifecycleState": "ACTIVE",
+ "name": options[:name] || "test-project",
+ "createTime": "2017-12-16T01:48:29.129Z",
+ "parent": {
+ "type": "organization",
+ "id": "12345"
+ }
+ }
+ ]
+ }
+ end
+
+ def cloud_platform_projects_billing_info_body(project_id, billing_enabled)
+ {
+ "name": "projects/#{project_id}/billingInfo",
+ "projectId": "#{project_id}",
+ "billingAccountName": "account-name",
+ "billingEnabled": billing_enabled
+ }
+ end
end
end
diff --git a/spec/support/helpers/expect_offense.rb b/spec/support/helpers/expect_offense.rb
new file mode 100644
index 00000000000..35718ba90c5
--- /dev/null
+++ b/spec/support/helpers/expect_offense.rb
@@ -0,0 +1,20 @@
+require 'rubocop/rspec/support'
+
+# https://github.com/backus/rubocop-rspec/blob/master/spec/support/expect_offense.rb
+# rubocop-rspec gem extension of RuboCop's ExpectOffense module.
+#
+# This mixin is the same as rubocop's ExpectOffense except the default
+# filename ends with `_spec.rb`
+module ExpectOffense
+ include RuboCop::RSpec::ExpectOffense
+
+ DEFAULT_FILENAME = 'example_spec.rb'.freeze
+
+ def expect_offense(source, filename = DEFAULT_FILENAME)
+ super
+ end
+
+ def expect_no_offenses(source, filename = DEFAULT_FILENAME)
+ super
+ end
+end
diff --git a/spec/support/helpers/features/notes_helpers.rb b/spec/support/helpers/features/notes_helpers.rb
new file mode 100644
index 00000000000..1a1d5853a7a
--- /dev/null
+++ b/spec/support/helpers/features/notes_helpers.rb
@@ -0,0 +1,27 @@
+# These helpers allow you to manipulate with notes.
+#
+# Usage:
+# describe "..." do
+# include Spec::Support::Helpers::Features::NotesHelpers
+# ...
+#
+# add_note("Hello world!")
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module NotesHelpers
+ def add_note(text)
+ Sidekiq::Testing.fake! do
+ page.within(".js-main-target-form") do
+ fill_in("note[note]", with: text)
+ find(".js-comment-submit-button").click
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/features/sorting_helpers.rb b/spec/support/helpers/features/sorting_helpers.rb
new file mode 100644
index 00000000000..50457b64745
--- /dev/null
+++ b/spec/support/helpers/features/sorting_helpers.rb
@@ -0,0 +1,26 @@
+# These helpers allow you to manipulate with sorting features.
+#
+# Usage:
+# describe "..." do
+# include Spec::Support::Helpers::Features::SortingHelpers
+# ...
+#
+# sort_by("Last updated")
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module SortingHelpers
+ def sort_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
+ end
+ end
+ end
+end
diff --git a/spec/support/http_io/http_io_helpers.rb b/spec/support/http_io/http_io_helpers.rb
new file mode 100644
index 00000000000..31e07e720cd
--- /dev/null
+++ b/spec/support/http_io/http_io_helpers.rb
@@ -0,0 +1,64 @@
+module HttpIOHelpers
+ def stub_remote_trace_206
+ WebMock.stub_request(:get, remote_trace_url)
+ .to_return { |request| remote_trace_response(request, 206) }
+ end
+
+ def stub_remote_trace_200
+ WebMock.stub_request(:get, remote_trace_url)
+ .to_return { |request| remote_trace_response(request, 200) }
+ end
+
+ def stub_remote_trace_500
+ WebMock.stub_request(:get, remote_trace_url)
+ .to_return(status: [500, "Internal Server Error"])
+ end
+
+ def remote_trace_url
+ "http://trace.com/trace"
+ end
+
+ def remote_trace_response(request, responce_status)
+ range = request.headers['Range'].match(/bytes=(\d+)-(\d+)/)
+
+ {
+ status: responce_status,
+ headers: remote_trace_response_headers(responce_status, range[1].to_i, range[2].to_i),
+ body: range_trace_body(range[1].to_i, range[2].to_i)
+ }
+ end
+
+ def remote_trace_response_headers(responce_status, from, to)
+ headers = { 'Content-Type' => 'text/plain' }
+
+ if responce_status == 206
+ headers.merge('Content-Range' => "bytes #{from}-#{to}/#{remote_trace_size}")
+ end
+
+ headers
+ end
+
+ def range_trace_body(from, to)
+ remote_trace_body[from..to]
+ end
+
+ def remote_trace_body
+ @remote_trace_body ||= File.read(expand_fixture_path('trace/sample_trace'))
+ end
+
+ def remote_trace_size
+ remote_trace_body.length
+ end
+
+ def set_smaller_buffer_size_than(file_size)
+ blocks = (file_size / 128)
+ new_size = (blocks / 2) * 128
+ stub_const("Gitlab::Ci::Trace::HttpIO::BUFFER_SIZE", new_size)
+ end
+
+ def set_larger_buffer_size_than(file_size)
+ blocks = (file_size / 128)
+ new_size = (blocks * 2) * 128
+ stub_const("Gitlab::Ci::Trace::HttpIO::BUFFER_SIZE", new_size)
+ end
+end
diff --git a/spec/support/issuables_list_metadata_shared_examples.rb b/spec/support/issuables_list_metadata_shared_examples.rb
index 75982432ab4..e61983c60b4 100644
--- a/spec/support/issuables_list_metadata_shared_examples.rb
+++ b/spec/support/issuables_list_metadata_shared_examples.rb
@@ -5,9 +5,9 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
%w[fix improve/awesome].each do |source_branch|
issuable =
if issuable_type == :issue
- create(issuable_type, project: project)
+ create(issuable_type, project: project, author: project.creator)
else
- create(issuable_type, source_project: project, source_branch: source_branch)
+ create(issuable_type, source_project: project, source_branch: source_branch, author: project.creator)
end
@issuable_ids << issuable.id
@@ -16,7 +16,7 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
it "creates indexed meta-data object for issuable notes and votes count" do
if action
- get action
+ get action, author_id: project.creator.id
else
get :index, namespace_id: project.namespace, project_id: project
end
@@ -35,7 +35,7 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
it "doesn't execute any queries with false conditions" do
get_action =
if action
- proc { get action }
+ proc { get action, author_id: project.creator.id }
else
proc { get :index, namespace_id: project2.namespace, project_id: project2 }
end
diff --git a/spec/support/issuables_requiring_filter_shared_examples.rb b/spec/support/issuables_requiring_filter_shared_examples.rb
new file mode 100644
index 00000000000..439ef5ed92e
--- /dev/null
+++ b/spec/support/issuables_requiring_filter_shared_examples.rb
@@ -0,0 +1,15 @@
+shared_examples 'issuables requiring filter' do |action|
+ it "doesn't load any issuables if no filter is set" do
+ expect_any_instance_of(described_class).not_to receive(:issuables_collection)
+
+ get action
+
+ expect(response).to render_template(action)
+ end
+
+ it "loads issuables if at least one filter is set" do
+ expect_any_instance_of(described_class).to receive(:issuables_collection).and_call_original
+
+ get action, author_id: user.id
+ end
+end
diff --git a/spec/support/javascript_fixtures_helpers.rb b/spec/support/javascript_fixtures_helpers.rb
index 923c8080e6c..2197bc9d853 100644
--- a/spec/support/javascript_fixtures_helpers.rb
+++ b/spec/support/javascript_fixtures_helpers.rb
@@ -1,6 +1,5 @@
require 'action_dispatch/testing/test_request'
require 'fileutils'
-require 'gitlab/popen'
module JavaScriptFixturesHelpers
include Gitlab::Popen
diff --git a/spec/support/ldap_helpers.rb b/spec/support/ldap_helpers.rb
index 28d39a32f02..0e87b3d359d 100644
--- a/spec/support/ldap_helpers.rb
+++ b/spec/support/ldap_helpers.rb
@@ -1,13 +1,13 @@
module LdapHelpers
def ldap_adapter(provider = 'ldapmain', ldap = double(:ldap))
- ::Gitlab::LDAP::Adapter.new(provider, ldap)
+ ::Gitlab::Auth::LDAP::Adapter.new(provider, ldap)
end
def user_dn(uid)
"uid=#{uid},ou=users,dc=example,dc=com"
end
- # Accepts a hash of Gitlab::LDAP::Config keys and values.
+ # Accepts a hash of Gitlab::Auth::LDAP::Config keys and values.
#
# Example:
# stub_ldap_config(
@@ -15,21 +15,21 @@ module LdapHelpers
# admin_group: 'my-admin-group'
# )
def stub_ldap_config(messages)
- allow_any_instance_of(::Gitlab::LDAP::Config).to receive_messages(messages)
+ allow_any_instance_of(::Gitlab::Auth::LDAP::Config).to receive_messages(messages)
end
# Stub an LDAP person search and provide the return entry. Specify `nil` for
# `entry` to simulate when an LDAP person is not found
#
# Example:
- # adapter = ::Gitlab::LDAP::Adapter.new('ldapmain', double(:ldap))
+ # adapter = ::Gitlab::Auth::LDAP::Adapter.new('ldapmain', double(:ldap))
# ldap_user_entry = ldap_user_entry('john_doe')
#
# stub_ldap_person_find_by_uid('john_doe', ldap_user_entry, adapter)
def stub_ldap_person_find_by_uid(uid, entry, provider = 'ldapmain')
- return_value = ::Gitlab::LDAP::Person.new(entry, provider) if entry.present?
+ return_value = ::Gitlab::Auth::LDAP::Person.new(entry, provider) if entry.present?
- allow(::Gitlab::LDAP::Person)
+ allow(::Gitlab::Auth::LDAP::Person)
.to receive(:find_by_uid).with(uid, any_args).and_return(return_value)
end
@@ -41,4 +41,9 @@ module LdapHelpers
entry
end
+
+ def raise_ldap_connection_error
+ allow_any_instance_of(Gitlab::Auth::LDAP::Adapter)
+ .to receive(:ldap_search).and_raise(Gitlab::Auth::LDAP::LDAPConnectionError)
+ end
end
diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb
index 50702a0ac88..db34090e971 100644
--- a/spec/support/login_helpers.rb
+++ b/spec/support/login_helpers.rb
@@ -125,14 +125,25 @@ module LoginHelpers
})
end
+ def stub_omniauth_provider(provider, context: Rails.application)
+ env = env_from_context(context)
+
+ set_devise_mapping(context: context)
+ env['omniauth.auth'] = OmniAuth.config.mock_auth[provider]
+ end
+
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)
+ allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [:saml], config_for: mock_saml_config)
stub_omniauth_setting(messages)
+ stub_saml_authorize_path_helpers
+ end
+
+ def stub_saml_authorize_path_helpers
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
@@ -142,10 +153,10 @@ module LoginHelpers
end
def stub_basic_saml_config
- allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', args: {} } })
+ allow(Gitlab::Auth::Saml::Config).to receive_messages({ options: { name: 'saml', args: {} } })
end
def stub_saml_group_config(groups)
- allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', groups_attribute: 'groups', external_groups: groups, args: {} } })
+ allow(Gitlab::Auth::Saml::Config).to receive_messages({ options: { name: 'saml', groups_attribute: 'groups', external_groups: groups, args: {} } })
end
end
diff --git a/spec/support/markdown_feature.rb b/spec/support/markdown_feature.rb
index a0d854d3641..39e94ad53de 100644
--- a/spec/support/markdown_feature.rb
+++ b/spec/support/markdown_feature.rb
@@ -24,7 +24,7 @@ class MarkdownFeature
def project
@project ||= create(:project, :repository, group: group).tap do |project|
- project.team << [user, :master]
+ project.add_master(user)
end
end
@@ -85,7 +85,7 @@ class MarkdownFeature
@xproject ||= begin
group = create(:group, :nested)
create(:project, :repository, namespace: group) do |project|
- project.team << [user, :developer]
+ project.add_developer(user)
end
end
end
diff --git a/spec/support/matchers/access_matchers_for_controller.rb b/spec/support/matchers/access_matchers_for_controller.rb
index cdb62a5deee..42a9ed9ff34 100644
--- a/spec/support/matchers/access_matchers_for_controller.rb
+++ b/spec/support/matchers/access_matchers_for_controller.rb
@@ -43,6 +43,7 @@ module AccessMatchersForController
user = create(:user)
membership.public_send(:"add_#{role}", user)
end
+
user
end
diff --git a/spec/support/matchers/issuable_matchers.rb b/spec/support/matchers/issuable_matchers.rb
new file mode 100644
index 00000000000..f5d9a97051a
--- /dev/null
+++ b/spec/support/matchers/issuable_matchers.rb
@@ -0,0 +1,11 @@
+RSpec::Matchers.define :have_header_with_correct_id_and_link do |level, text, id, parent = ".wiki"|
+ match do |actual|
+ node = find("#{parent} h#{level} a#user-content-#{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
+ expect(find(:xpath, "#{node.path}/..").text).to eq(text)
+ end
+end
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index d12b2757427..ec4ec6f4038 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -190,6 +190,27 @@ module MarkdownMatchers
expect(video['src']).to end_with('/assets/videos/gitlab-demo.mp4')
end
end
+
+ # ColorFilter
+ matcher :parse_colors do
+ set_default_markdown_messages
+
+ match do |actual|
+ color_chips = actual.css('code > span.gfm-color_chip > span')
+
+ expect(color_chips.count).to eq(9)
+
+ [
+ '#F00', '#F00A', '#FF0000', '#FF0000AA', 'RGB(0,255,0)',
+ 'RGB(0%,100%,0%)', 'RGBA(0,255,0,0.7)', 'HSL(540,70%,50%)',
+ 'HSLA(540,70%,50%,0.7)'
+ ].each_with_index do |color, i|
+ parsed_color = Banzai::ColorParser.parse(color)
+ expect(color_chips[i]['style']).to match("background-color: #{parsed_color};")
+ expect(color_chips[i].parent.parent.content).to match(color)
+ end
+ end
+ end
end
# Monkeypatch the matcher DSL so that we can reduce some noisy duplication for
diff --git a/spec/support/matchers/match_ids.rb b/spec/support/matchers/match_ids.rb
new file mode 100644
index 00000000000..d8424405b96
--- /dev/null
+++ b/spec/support/matchers/match_ids.rb
@@ -0,0 +1,24 @@
+RSpec::Matchers.define :match_ids do |*expected|
+ match do |actual|
+ actual_ids = map_ids(actual)
+ expected_ids = map_ids(expected)
+
+ expect(actual_ids).to match_array(expected_ids)
+ end
+
+ description do
+ 'matches elements by ids'
+ end
+
+ def map_ids(elements)
+ elements = elements.flatten if elements.respond_to?(:flatten)
+
+ if elements.respond_to?(:map)
+ elements.map(&:id)
+ elsif elements.respond_to?(:id)
+ [elements.id]
+ else
+ raise ArgumentError, "could not map elements to ids: #{elements}"
+ end
+ end
+end
diff --git a/spec/support/matchers/pagination_matcher.rb b/spec/support/matchers/pagination_matcher.rb
index 60f5e8239a7..9a7697e2bfc 100644
--- a/spec/support/matchers/pagination_matcher.rb
+++ b/spec/support/matchers/pagination_matcher.rb
@@ -3,3 +3,9 @@ RSpec::Matchers.define :include_pagination_headers do |expected|
expect(actual.headers).to include('X-Total', 'X-Total-Pages', 'X-Per-Page', 'X-Page', 'X-Next-Page', 'X-Prev-Page', 'Link')
end
end
+
+RSpec::Matchers.define :include_limited_pagination_headers do |expected|
+ match do |actual|
+ expect(actual.headers).to include('X-Per-Page', 'X-Page', 'X-Next-Page', 'X-Prev-Page', 'Link')
+ end
+end
diff --git a/spec/support/mentionable_shared_examples.rb b/spec/support/mentionable_shared_examples.rb
index 3ac201f1fb1..1685decbe94 100644
--- a/spec/support/mentionable_shared_examples.rb
+++ b/spec/support/mentionable_shared_examples.rb
@@ -53,7 +53,7 @@ shared_context 'mentionable context' do
set_mentionable_text.call(ref_string)
- project.team << [author, :developer]
+ project.add_developer(author)
end
end
diff --git a/spec/support/migrations_helpers.rb b/spec/support/migrations_helpers.rb
index 6522d74ba89..5d6f662e8fe 100644
--- a/spec/support/migrations_helpers.rb
+++ b/spec/support/migrations_helpers.rb
@@ -1,6 +1,9 @@
module MigrationsHelpers
def table(name)
- Class.new(ActiveRecord::Base) { self.table_name = name }
+ Class.new(ActiveRecord::Base) do
+ self.table_name = name
+ self.inheritance_column = :_type_disabled
+ end
end
def migrations_paths
@@ -15,18 +18,27 @@ module MigrationsHelpers
ActiveRecord::Migrator.migrations(migrations_paths)
end
- def reset_column_in_migration_models
+ def clear_schema_cache!
ActiveRecord::Base.connection_pool.connections.each do |conn|
conn.schema_cache.clear!
end
+ end
- described_class.constants.sort.each do |name|
- const = described_class.const_get(name)
+ def reset_column_in_all_models
+ clear_schema_cache!
- if const.is_a?(Class) && const < ActiveRecord::Base
- const.reset_column_information
- end
- end
+ # Reset column information for the most offending classes **after** we
+ # migrated the schema up, otherwise, column information could be
+ # outdated. We have a separate method for this so we can override it in EE.
+ ActiveRecord::Base.descendants.each(&method(:reset_column_information))
+
+ # Without that, we get errors because of missing attributes, e.g.
+ # super: no superclass method `elasticsearch_indexing' for #<ApplicationSetting:0x00007f85628508d8>
+ ApplicationSetting.define_attribute_methods
+ end
+
+ def reset_column_information(klass)
+ klass.reset_column_information
end
def previous_migration
@@ -36,7 +48,13 @@ module MigrationsHelpers
end
def migration_schema_version
- self.class.metadata[:schema] || previous_migration.version
+ metadata_schema = self.class.metadata[:schema]
+
+ if metadata_schema == :latest
+ migrations.last.version
+ else
+ metadata_schema || previous_migration.version
+ end
end
def schema_migrate_down!
@@ -45,15 +63,17 @@ module MigrationsHelpers
migration_schema_version)
end
- reset_column_in_migration_models
+ reset_column_in_all_models
end
def schema_migrate_up!
+ reset_column_in_all_models
+
disable_migrations_output do
ActiveRecord::Migrator.migrate(migrations_paths)
end
- reset_column_in_migration_models
+ reset_column_in_all_models
end
def disable_migrations_output
diff --git a/spec/support/migrations_helpers/track_untracked_uploads_helpers.rb b/spec/support/migrations_helpers/track_untracked_uploads_helpers.rb
new file mode 100644
index 00000000000..016bcfa9b1b
--- /dev/null
+++ b/spec/support/migrations_helpers/track_untracked_uploads_helpers.rb
@@ -0,0 +1,128 @@
+module MigrationsHelpers
+ module TrackUntrackedUploadsHelpers
+ PUBLIC_DIR = File.join(Rails.root, 'tmp', 'tests', 'public')
+ UPLOADS_DIR = File.join(PUBLIC_DIR, 'uploads')
+ SYSTEM_DIR = File.join(UPLOADS_DIR, '-', 'system')
+ UPLOAD_FILENAME = 'image.png'.freeze
+ FIXTURE_FILE_PATH = File.join(Rails.root, 'spec', 'fixtures', 'dk.png')
+ FIXTURE_CHECKSUM = 'b804383982bb89b00e828e3f44c038cc991d3d1768009fc39ba8e2c081b9fb75'.freeze
+
+ def create_or_update_appearance(logo: false, header_logo: false)
+ appearance = appearances.first_or_create(title: 'foo', description: 'bar', logo: (UPLOAD_FILENAME if logo), header_logo: (UPLOAD_FILENAME if header_logo))
+
+ add_upload(appearance, 'Appearance', 'logo', 'AttachmentUploader') if logo
+ add_upload(appearance, 'Appearance', 'header_logo', 'AttachmentUploader') if header_logo
+
+ appearance
+ end
+
+ def create_group(avatar: false)
+ index = unique_index(:group)
+ group = namespaces.create(name: "group#{index}", path: "group#{index}", avatar: (UPLOAD_FILENAME if avatar))
+
+ add_upload(group, 'Group', 'avatar', 'AvatarUploader') if avatar
+
+ group
+ end
+
+ def create_note(attachment: false)
+ note = notes.create(attachment: (UPLOAD_FILENAME if attachment))
+
+ add_upload(note, 'Note', 'attachment', 'AttachmentUploader') if attachment
+
+ note
+ end
+
+ def create_project(avatar: false)
+ group = create_group
+ project = projects.create(namespace_id: group.id, path: "project#{unique_index(:project)}", avatar: (UPLOAD_FILENAME if avatar))
+ routes.create(path: "#{group.path}/#{project.path}", source_id: project.id, source_type: 'Project') # so Project.find_by_full_path works
+
+ add_upload(project, 'Project', 'avatar', 'AvatarUploader') if avatar
+
+ project
+ end
+
+ def create_user(avatar: false)
+ user = users.create(email: "foo#{unique_index(:user)}@bar.com", avatar: (UPLOAD_FILENAME if avatar), projects_limit: 100)
+
+ add_upload(user, 'User', 'avatar', 'AvatarUploader') if avatar
+
+ user
+ end
+
+ def unique_index(name = :unnamed)
+ @unique_index ||= {}
+ @unique_index[name] ||= 0
+ @unique_index[name] += 1
+ end
+
+ def add_upload(model, model_type, attachment_type, uploader)
+ file_path = upload_file_path(model, model_type, attachment_type)
+ path_relative_to_public = file_path.sub("#{PUBLIC_DIR}/", '')
+ create_file(file_path)
+
+ uploads.create!(
+ size: 1062,
+ path: path_relative_to_public,
+ model_id: model.id,
+ model_type: model_type == 'Group' ? 'Namespace' : model_type,
+ uploader: uploader,
+ checksum: FIXTURE_CHECKSUM
+ )
+ end
+
+ def add_markdown_attachment(project, hashed_storage: false)
+ project_dir = hashed_storage ? hashed_project_uploads_dir(project) : legacy_project_uploads_dir(project)
+ attachment_dir = File.join(project_dir, SecureRandom.hex)
+ attachment_file_path = File.join(attachment_dir, UPLOAD_FILENAME)
+ project_attachment_path_relative_to_project = attachment_file_path.sub("#{project_dir}/", '')
+ create_file(attachment_file_path)
+
+ uploads.create!(
+ size: 1062,
+ path: project_attachment_path_relative_to_project,
+ model_id: project.id,
+ model_type: 'Project',
+ uploader: 'FileUploader',
+ checksum: FIXTURE_CHECKSUM
+ )
+ end
+
+ def legacy_project_uploads_dir(project)
+ namespace = namespaces.find_by(id: project.namespace_id)
+ File.join(UPLOADS_DIR, namespace.path, project.path)
+ end
+
+ def hashed_project_uploads_dir(project)
+ File.join(UPLOADS_DIR, '@hashed', 'aa', 'aaaaaaaaaaaa')
+ end
+
+ def upload_file_path(model, model_type, attachment_type)
+ dir = File.join(upload_dir(model_type.downcase, attachment_type.to_s), model.id.to_s)
+ File.join(dir, UPLOAD_FILENAME)
+ end
+
+ def upload_dir(model_type, attachment_type)
+ File.join(SYSTEM_DIR, model_type, attachment_type)
+ end
+
+ def create_file(path)
+ File.delete(path) if File.exist?(path)
+ FileUtils.mkdir_p(File.dirname(path))
+ FileUtils.cp(FIXTURE_FILE_PATH, path)
+ end
+
+ def get_uploads(model, model_type)
+ uploads.where(model_type: model_type, model_id: model.id)
+ end
+
+ def get_full_path(project)
+ routes.find_by(source_id: project.id, source_type: 'Project').path
+ end
+
+ def ensure_temporary_tracking_table_exists
+ Gitlab::BackgroundMigration::PrepareUntrackedUploads.new.send(:ensure_temporary_tracking_table_exists)
+ end
+ end
+end
diff --git a/spec/support/project_forks_helper.rb b/spec/support/project_forks_helper.rb
index d6680735aa1..2c501a2a27c 100644
--- a/spec/support/project_forks_helper.rb
+++ b/spec/support/project_forks_helper.rb
@@ -38,10 +38,6 @@ module ProjectForksHelper
# so we have to explicitely call this method to clear the @exists variable.
# of the instance we're returning here.
forked_project.repository.after_import
-
- # We can't leave the hooks in place after a fork, as those would fail in tests
- # The "internal" API is not available
- FileUtils.rm_rf("#{forked_project.repository.path}/hooks")
end
forked_project
diff --git a/spec/support/prometheus/additional_metrics_shared_examples.rb b/spec/support/prometheus/additional_metrics_shared_examples.rb
index dbbd4ad4d40..c7c3346d39e 100644
--- a/spec/support/prometheus/additional_metrics_shared_examples.rb
+++ b/spec/support/prometheus/additional_metrics_shared_examples.rb
@@ -12,11 +12,12 @@ RSpec.shared_examples 'additional metrics query' do
let(:client) { double('prometheus_client') }
let(:query_result) { described_class.new(client).query(*query_params) }
- let(:environment) { create(:environment, slug: 'environment-slug') }
+ let(:project) { create(:project) }
+ let(:environment) { create(:environment, slug: 'environment-slug', project: project) }
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])])
+ allow(metric_group_class).to receive(:common_metrics).and_return([simple_metric_group(metrics: [simple_metric])])
end
context 'metrics query context' do
@@ -24,13 +25,14 @@ RSpec.shared_examples 'additional metrics query' do
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))
+ expect(subject).to receive(:query_metrics).with(project, hash_including(ci_environment_slug: environment.slug))
subject.query(*query_params)
end
it 'contains environment filter' do
expect(subject).to receive(:query_metrics).with(
+ project,
hash_including(
environment_filter: "container_name!=\"POD\",environment=\"#{environment.slug}\""
)
@@ -48,7 +50,7 @@ RSpec.shared_examples 'additional metrics query' do
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))
+ expect(subject).to receive(:query_metrics).with(project, hash_including(kube_namespace: kube_namespace))
subject.query(*query_params)
end
@@ -72,7 +74,7 @@ RSpec.shared_examples 'additional metrics query' 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: ''))
+ expect(subject).to receive(:query_metrics).with(project, hash_including(kube_namespace: ''))
subject.query(*query_params)
end
@@ -81,7 +83,7 @@ RSpec.shared_examples 'additional metrics query' do
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(metric_group_class).to receive(:common_metrics).and_return([simple_metric_group])
end
context 'some queries return results' do
@@ -117,7 +119,7 @@ RSpec.shared_examples 'additional metrics query' do
let(:metrics) { [simple_metric(queries: [simple_query])] }
before do
- allow(metric_group_class).to receive(:all).and_return(
+ allow(metric_group_class).to receive(:common_metrics).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')])])
diff --git a/spec/support/quick_actions_helpers.rb b/spec/support/quick_actions_helpers.rb
deleted file mode 100644
index 361190aa352..00000000000
--- a/spec/support/quick_actions_helpers.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-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').click
- end
- end
- end
-end
diff --git a/spec/support/reactive_caching_helpers.rb b/spec/support/reactive_caching_helpers.rb
index 34124f02133..e22dd974c6a 100644
--- a/spec/support/reactive_caching_helpers.rb
+++ b/spec/support/reactive_caching_helpers.rb
@@ -13,6 +13,12 @@ module ReactiveCachingHelpers
write_reactive_cache(subject, data, *qualifiers) if data
end
+ def synchronous_reactive_cache(subject)
+ allow(service).to receive(:with_reactive_cache) do |*args, &block|
+ block.call(service.calculate_reactive_cache(*args))
+ end
+ end
+
def read_reactive_cache(subject, *qualifiers)
Rails.cache.read(reactive_cache_key(subject, *qualifiers))
end
diff --git a/spec/support/reference_parser_helpers.rb b/spec/support/reference_parser_helpers.rb
index 01689194eac..5d5e80851e6 100644
--- a/spec/support/reference_parser_helpers.rb
+++ b/spec/support/reference_parser_helpers.rb
@@ -2,4 +2,34 @@ module ReferenceParserHelpers
def empty_html_link
Nokogiri::HTML.fragment('<a></a>').children[0]
end
+
+ shared_examples 'no N+1 queries' do
+ it 'avoids N+1 queries in #nodes_visible_to_user', :request_store do
+ record_queries = lambda do |links|
+ ActiveRecord::QueryRecorder.new do
+ described_class.new(project, user).nodes_visible_to_user(user, links)
+ end
+ end
+
+ control = record_queries.call(control_links)
+ actual = record_queries.call(actual_links)
+
+ expect(actual.count).to be <= control.count
+ expect(actual.cached_count).to be <= control.cached_count
+ end
+
+ it 'avoids N+1 queries in #records_for_nodes', :request_store do
+ record_queries = lambda do |links|
+ ActiveRecord::QueryRecorder.new do
+ described_class.new(project, user).records_for_nodes(links)
+ end
+ end
+
+ control = record_queries.call(control_links)
+ actual = record_queries.call(actual_links)
+
+ expect(actual.count).to be <= control.count
+ expect(actual.cached_count).to be <= control.cached_count
+ end
+ end
end
diff --git a/spec/support/reference_parser_shared_examples.rb b/spec/support/reference_parser_shared_examples.rb
index bd83cb88058..baf8bcc04b8 100644
--- a/spec/support/reference_parser_shared_examples.rb
+++ b/spec/support/reference_parser_shared_examples.rb
@@ -26,7 +26,7 @@ RSpec.shared_examples "referenced feature visibility" do |*related_features|
end
it "creates reference for member" do
- project.team << [user, :developer]
+ project.add_developer(user)
expect(subject.nodes_visible_to_user(user, [link])).to eq([link])
end
diff --git a/spec/support/select2_helper.rb b/spec/support/select2_helper.rb
index 55da961e173..90618ba5b19 100644
--- a/spec/support/select2_helper.rb
+++ b/spec/support/select2_helper.rb
@@ -17,6 +17,7 @@ module Select2Helper
selector = options.fetch(:from)
first(selector, visible: false)
+
if options[:multiple]
execute_script("$('#{selector}').select2('val', ['#{value}']).trigger('change');")
else
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 9399745f900..7b064162726 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
@@ -3,7 +3,7 @@
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(:user) { create(:user).tap { |u| project.add_master(u) } }
let(:assignee) { create(:user) }
let!(:milestone) { create(:milestone, project: project) }
let!(:labels) { create_list(:label, 3, project: project) }
@@ -12,7 +12,7 @@ shared_examples 'new issuable record that supports quick actions' do
let(:issuable) { described_class.new(project, user, params).execute }
before do
- project.team << [assignee, :master]
+ project.add_master(assignee)
end
context 'with labels in command only' do
diff --git a/spec/support/services_shared_context.rb b/spec/support/services_shared_context.rb
index 7457484a932..23f9b46ae0c 100644
--- a/spec/support/services_shared_context.rb
+++ b/spec/support/services_shared_context.rb
@@ -3,13 +3,9 @@ Service.available_services_names.each do |service|
let(:dashed_service) { service.dasherize }
let(:service_method) { "#{service}_service".to_sym }
let(:service_klass) { "#{service}_service".classify.constantize }
- let(:service_fields) { service_klass.new.fields }
+ let(:service_instance) { service_klass.new }
+ let(:service_fields) { service_instance.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}
- end
let(:service_attrs) do
service_attrs_list.inject({}) do |hash, k|
if k =~ /^(token*|.*_token|.*_key)/
@@ -29,5 +25,13 @@ Service.available_services_names.each do |service|
end
end
end
+
+ def initialize_service(service)
+ service_item = project.find_or_initialize_service(service)
+ service_item.properties = service_attrs
+ service_item.active = true if service == "kubernetes"
+ service_item.save
+ service_item
+ end
end
end
diff --git a/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb b/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb
index 935c08221e0..ea7dbade171 100644
--- a/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb
@@ -2,6 +2,8 @@ shared_examples 'handle uploads' do
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') }
+ let(:secret) { FileUploader.generate_secret }
+ let(:uploader_class) { FileUploader }
describe "POST #create" do
context 'when a user is not authorized to upload a file' do
@@ -65,7 +67,12 @@ shared_examples 'handle uploads' do
describe "GET #show" do
let(:show_upload) do
- get :show, params.merge(secret: "123456", filename: "image.jpg")
+ get :show, params.merge(secret: secret, filename: "rails_sample.jpg")
+ end
+
+ before do
+ expect(FileUploader).to receive(:generate_secret).and_return(secret)
+ UploadService.new(model, jpg, uploader_class).execute
end
context "when the model is public" do
@@ -75,19 +82,31 @@ shared_examples 'handle uploads' do
context "when not signed in" do
context "when the file exists" do
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ context "when neither the uploader nor the model exists" do
before do
- allow_any_instance_of(FileUploader).to receive(:file).and_return(jpg)
- allow(jpg).to receive(:exists?).and_return(true)
+ allow_any_instance_of(Upload).to receive(:build_uploader).and_return(nil)
+ allow(controller).to receive(:find_model).and_return(nil)
end
- it "responds with status 200" do
+ it "responds with status 404" do
show_upload
- expect(response).to have_gitlab_http_status(200)
+ expect(response).to have_gitlab_http_status(404)
end
end
context "when the file doesn't exist" do
+ before do
+ allow_any_instance_of(FileUploader).to receive(:exists?).and_return(false)
+ end
+
it "responds with status 404" do
show_upload
@@ -102,11 +121,6 @@ shared_examples 'handle uploads' do
end
context "when the file exists" do
- before do
- allow_any_instance_of(FileUploader).to receive(:file).and_return(jpg)
- allow(jpg).to receive(:exists?).and_return(true)
- end
-
it "responds with status 200" do
show_upload
@@ -115,6 +129,10 @@ shared_examples 'handle uploads' do
end
context "when the file doesn't exist" do
+ before do
+ allow_any_instance_of(FileUploader).to receive(:exists?).and_return(false)
+ end
+
it "responds with status 404" do
show_upload
@@ -131,11 +149,6 @@ shared_examples 'handle uploads' do
context "when not signed in" do
context "when the file exists" do
- before do
- allow_any_instance_of(FileUploader).to receive(:file).and_return(jpg)
- allow(jpg).to receive(:exists?).and_return(true)
- end
-
context "when the file is an image" do
before do
allow_any_instance_of(FileUploader).to receive(:image?).and_return(true)
@@ -149,6 +162,10 @@ shared_examples 'handle uploads' do
end
context "when the file is not an image" do
+ before do
+ allow_any_instance_of(FileUploader).to receive(:image?).and_return(false)
+ end
+
it "redirects to the sign in page" do
show_upload
@@ -158,6 +175,10 @@ shared_examples 'handle uploads' do
end
context "when the file doesn't exist" do
+ before do
+ allow_any_instance_of(FileUploader).to receive(:exists?).and_return(false)
+ end
+
it "redirects to the sign in page" do
show_upload
@@ -177,11 +198,6 @@ shared_examples 'handle uploads' do
end
context "when the file exists" do
- before do
- allow_any_instance_of(FileUploader).to receive(:file).and_return(jpg)
- allow(jpg).to receive(:exists?).and_return(true)
- end
-
it "responds with status 200" do
show_upload
@@ -190,6 +206,10 @@ shared_examples 'handle uploads' do
end
context "when the file doesn't exist" do
+ before do
+ allow_any_instance_of(FileUploader).to receive(:exists?).and_return(false)
+ end
+
it "responds with status 404" do
show_upload
@@ -200,11 +220,6 @@ shared_examples 'handle uploads' do
context "when the user doesn't have access to the model" do
context "when the file exists" do
- before do
- allow_any_instance_of(FileUploader).to receive(:file).and_return(jpg)
- allow(jpg).to receive(:exists?).and_return(true)
- end
-
context "when the file is an image" do
before do
allow_any_instance_of(FileUploader).to receive(:image?).and_return(true)
@@ -218,6 +233,10 @@ shared_examples 'handle uploads' do
end
context "when the file is not an image" do
+ before do
+ allow_any_instance_of(FileUploader).to receive(:image?).and_return(false)
+ end
+
it "responds with status 404" do
show_upload
@@ -227,6 +246,10 @@ shared_examples 'handle uploads' do
end
context "when the file doesn't exist" do
+ before do
+ allow_any_instance_of(FileUploader).to receive(:exists?).and_return(false)
+ end
+
it "responds with status 404" do
show_upload
diff --git a/spec/support/shared_examples/controllers/variables_shared_examples.rb b/spec/support/shared_examples/controllers/variables_shared_examples.rb
new file mode 100644
index 00000000000..b615a8f54cf
--- /dev/null
+++ b/spec/support/shared_examples/controllers/variables_shared_examples.rb
@@ -0,0 +1,123 @@
+shared_examples 'GET #show lists all variables' do
+ it 'renders the variables as json' do
+ subject
+
+ expect(response).to match_response_schema('variables')
+ end
+
+ it 'has only one variable' do
+ subject
+
+ expect(json_response['variables'].count).to eq(1)
+ end
+end
+
+shared_examples 'PATCH #update updates variables' do
+ let(:variable_attributes) do
+ { id: variable.id,
+ key: variable.key,
+ secret_value: variable.value,
+ protected: variable.protected?.to_s }
+ end
+ let(:new_variable_attributes) do
+ { key: 'new_key',
+ secret_value: 'dummy_value',
+ protected: 'false' }
+ end
+
+ context 'with invalid new variable parameters' do
+ let(:variables_attributes) do
+ [
+ variable_attributes.merge(secret_value: 'other_value'),
+ new_variable_attributes.merge(key: '...?')
+ ]
+ end
+
+ it 'does not update the existing variable' do
+ expect { subject }.not_to change { variable.reload.value }
+ end
+
+ it 'does not create the new variable' do
+ expect { subject }.not_to change { owner.variables.count }
+ end
+
+ it 'returns a bad request response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'with duplicate new variable parameters' do
+ let(:variables_attributes) do
+ [
+ new_variable_attributes,
+ new_variable_attributes.merge(secret_value: 'other_value')
+ ]
+ end
+
+ it 'does not update the existing variable' do
+ expect { subject }.not_to change { variable.reload.value }
+ end
+
+ it 'does not create the new variable' do
+ expect { subject }.not_to change { owner.variables.count }
+ end
+
+ it 'returns a bad request response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'with valid new variable parameters' do
+ let(:variables_attributes) do
+ [
+ variable_attributes.merge(secret_value: 'other_value'),
+ new_variable_attributes
+ ]
+ end
+
+ it 'updates the existing variable' do
+ expect { subject }.to change { variable.reload.value }.to('other_value')
+ end
+
+ it 'creates the new variable' do
+ expect { subject }.to change { owner.variables.count }.by(1)
+ end
+
+ it 'returns a successful response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'has all variables in response' do
+ subject
+
+ expect(response).to match_response_schema('variables')
+ end
+ end
+
+ context 'with a deleted variable' do
+ let(:variables_attributes) { [variable_attributes.merge(_destroy: 'true')] }
+
+ it 'destroys the variable' do
+ expect { subject }.to change { owner.variables.count }.by(-1)
+ expect { variable.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+
+ it 'returns a successful response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'has all variables in response' do
+ subject
+
+ expect(response).to match_response_schema('variables')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb
new file mode 100644
index 00000000000..5b0b609f7f2
--- /dev/null
+++ b/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb
@@ -0,0 +1,99 @@
+RSpec.shared_examples 'a creatable merge request' do
+ include WaitForRequests
+
+ let(:user) { create(:user) }
+ let(:user2) { create(:user) }
+ let(:target_project) { create(:project, :public, :repository) }
+ let(:source_project) { target_project }
+ let!(:milestone) { create(:milestone, project: target_project) }
+ let!(:label) { create(:label, project: target_project) }
+ let!(:label2) { create(:label, project: target_project) }
+
+ before do
+ source_project.add_master(user)
+ target_project.add_master(user)
+ target_project.add_master(user2)
+
+ sign_in(user)
+ visit project_new_merge_request_path(
+ target_project,
+ merge_request: {
+ source_project_id: source_project.id,
+ target_project_id: target_project.id,
+ source_branch: 'fix',
+ target_branch: 'master'
+ })
+ end
+
+ it 'creates new merge request', :js do
+ click_button 'Assignee'
+ page.within '.dropdown-menu-user' do
+ click_link user2.name
+ end
+ expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user2.id.to_s)
+ page.within '.js-assignee-search' do
+ expect(page).to have_content user2.name
+ end
+
+ click_link 'Assign to me'
+ 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
+ end
+
+ click_button 'Milestone'
+ page.within '.issue-milestone' do
+ click_link milestone.title
+ end
+ expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ page.within '.js-milestone-select' do
+ expect(page).to have_content milestone.title
+ end
+
+ click_button 'Labels'
+ page.within '.dropdown-menu-labels' do
+ click_link label.title
+ click_link label2.title
+ end
+ page.within '.js-label-select' do
+ expect(page).to have_content label.title
+ end
+ expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
+ expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
+
+ click_button 'Submit merge request'
+
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+ end
+
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
+
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
+ end
+ end
+
+ it 'updates the branches when selecting a new target project' do
+ target_project_member = target_project.owner
+ CreateBranchService.new(target_project, target_project_member)
+ .execute('a-brand-new-branch-to-test', 'master')
+ visit project_new_merge_request_path(source_project)
+
+ first('.js-target-project').click
+ find('.dropdown-target-project .dropdown-content a', text: target_project.full_path).click
+
+ wait_for_requests
+
+ first('.js-target-branch').click
+
+ within('.dropdown-target-branch .dropdown-content') do
+ expect(page).to have_content('a-brand-new-branch-to-test')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
new file mode 100644
index 00000000000..645db41cddc
--- /dev/null
+++ b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
@@ -0,0 +1,140 @@
+RSpec.shared_examples 'an editable merge request' do
+ let(:user) { create(:user) }
+ let(:user2) { create(:user) }
+ let!(:milestone) { create(:milestone, project: target_project) }
+ let!(:label) { create(:label, project: target_project) }
+ let!(:label2) { create(:label, project: target_project) }
+ let(:target_project) { create(:project, :public, :repository) }
+ let(:source_project) { target_project }
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ target_project: target_project,
+ source_branch: 'fix',
+ target_branch: 'master')
+ end
+
+ before do
+ source_project.add_master(user)
+ target_project.add_master(user)
+ target_project.add_master(user2)
+
+ sign_in(user)
+ visit edit_project_merge_request_path(target_project, merge_request)
+ end
+
+ it 'updates merge request', :js do
+ click_button 'Assignee'
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+ 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
+ end
+
+ click_button 'Milestone'
+ page.within '.issue-milestone' do
+ click_link milestone.title
+ end
+ expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s)
+ page.within '.js-milestone-select' do
+ expect(page).to have_content milestone.title
+ end
+
+ click_button 'Labels'
+ page.within '.dropdown-menu-labels' do
+ click_link label.title
+ click_link label2.title
+ end
+ expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s)
+ expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s)
+ page.within '.js-label-select' do
+ expect(page).to have_content label.title
+ end
+
+ click_button 'Save changes'
+
+ page.within '.issuable-sidebar' do
+ page.within '.assignee' do
+ expect(page).to have_content user.name
+ end
+
+ page.within '.milestone' do
+ expect(page).to have_content milestone.title
+ end
+
+ page.within '.labels' do
+ expect(page).to have_content label.title
+ expect(page).to have_content label2.title
+ end
+ end
+ end
+
+ it 'description has autocomplete', :js do
+ find('#merge_request_description').native.send_keys('')
+ fill_in 'merge_request_description', with: '@'
+
+ expect(page).to have_selector('.atwho-view')
+ end
+
+ it 'has class js-quick-submit in form' do
+ expect(page).to have_selector('.js-quick-submit')
+ end
+
+ it 'warns about version conflict' do
+ merge_request.update(title: "New title")
+
+ fill_in 'merge_request_title', with: 'bug 345'
+ fill_in 'merge_request_description', with: 'bug description'
+
+ click_button 'Save changes'
+
+ expect(page).to have_content 'Someone edited the merge request the same time you did'
+ end
+
+ it 'preserves description textarea height', :js do
+ long_description = %q(
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam ac ornare ligula, ut tempus arcu. Etiam ultricies accumsan dolor vitae faucibus. Donec at elit lacus. Mauris orci ante, aliquam quis lorem eget, convallis faucibus arcu. Aenean at pulvinar lacus. Ut viverra quam massa, molestie ornare tortor dignissim a. Suspendisse tristique pellentesque tellus, id lacinia metus elementum id. Nam tristique, arcu rhoncus faucibus viverra, lacus ipsum sagittis ligula, vitae convallis odio lacus a nibh. Ut tincidunt est purus, ac vestibulum augue maximus in. Suspendisse vel erat et mi ultricies semper. Pellentesque volutpat pellentesque consequat.
+
+ Cras congue nec ligula tristique viverra. Curabitur fringilla fringilla fringilla. Donec rhoncus dignissim orci ut accumsan. Ut rutrum urna a rhoncus varius. Maecenas blandit, mauris nec accumsan gravida, augue nibh finibus magna, sed maximus turpis libero nec neque. Suspendisse at semper est. Nunc imperdiet dapibus dui, varius sollicitudin erat luctus non. Sed pellentesque ligula eget posuere facilisis. Donec dictum commodo volutpat. Donec egestas dui ac magna sollicitudin bibendum. Vivamus purus neque, ullamcorper ac feugiat et, tempus sit amet metus. Praesent quis viverra neque. Sed bibendum viverra est, eu aliquam mi ornare vitae. Proin et dapibus ipsum. Nunc tortor diam, malesuada nec interdum vel, placerat quis justo. Ut viverra at erat eu laoreet.
+
+ Pellentesque commodo, diam sit amet dignissim condimentum, tortor justo pretium est, non venenatis metus eros ut nunc. Etiam ut neque eget sem dapibus aliquam. Curabitur vel elit lorem. Nulla nec enim elit. Sed ut ex id justo facilisis convallis at ac augue. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Nullam cursus egestas turpis non tristique. Suspendisse in erat sem. Fusce libero elit, fermentum gravida mauris id, auctor iaculis felis. Nullam vulputate tempor laoreet.
+
+ Nam tempor et magna sed convallis. Fusce sit amet sollicitudin risus, a ullamcorper lacus. Morbi gravida quis sem eget porttitor. Donec eu egestas mauris, in elementum tortor. Sed eget ex mi. Mauris iaculis tortor ut est auctor, nec dignissim quam sagittis. Suspendisse vel metus non quam suscipit tincidunt. Cras molestie lacus non justo finibus sodales quis vitae erat. In a porttitor nisi, id sollicitudin urna. Ut at felis tellus. Suspendisse potenti.
+
+ Maecenas leo ligula, varius at neque vitae, ornare maximus justo. Nullam convallis luctus risus et vulputate. Duis suscipit faucibus iaculis. Etiam quis tortor faucibus, tristique tellus sit amet, sodales neque. Nulla dapibus nisi vel aliquet consequat. Etiam faucibus, metus eget condimentum iaculis, enim urna lobortis sem, id efficitur eros sapien nec nisi. Aenean ut finibus ex.
+ )
+
+ fill_in 'merge_request_description', with: long_description
+
+ height = get_textarea_height
+ find('.js-md-preview-button').click
+ find('.js-md-write-button').click
+ new_height = get_textarea_height
+
+ expect(height).to eq(new_height)
+ end
+
+ context 'when "Remove source branch" is set' do
+ before do
+ merge_request.update!(merge_params: { 'force_remove_source_branch' => '1' })
+ end
+
+ it 'allows to unselect "Remove source branch"', :js do
+ expect(merge_request.merge_params['force_remove_source_branch']).to be_truthy
+
+ visit edit_project_merge_request_path(target_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
+ end
+end
+
+def get_textarea_height
+ page.evaluate_script('document.getElementById("merge_request_description").offsetHeight')
+end
diff --git a/spec/support/shared_examples/models/atomic_internal_id_spec.rb b/spec/support/shared_examples/models/atomic_internal_id_spec.rb
new file mode 100644
index 00000000000..144af4fc475
--- /dev/null
+++ b/spec/support/shared_examples/models/atomic_internal_id_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+shared_examples_for 'AtomicInternalId' do
+ describe '.has_internal_id' do
+ describe 'Module inclusion' do
+ subject { described_class }
+
+ it { is_expected.to include_module(AtomicInternalId) }
+ end
+
+ describe 'Validation' do
+ subject { instance }
+
+ before do
+ allow(InternalId).to receive(:generate_next).and_return(nil)
+ end
+
+ it { is_expected.to validate_presence_of(internal_id_attribute) }
+ it { is_expected.to validate_numericality_of(internal_id_attribute) }
+ end
+
+ describe 'internal id generation' do
+ subject { instance.save! }
+
+ it 'calls InternalId.generate_next and sets internal id attribute' do
+ iid = rand(1..1000)
+
+ expect(InternalId).to receive(:generate_next).with(instance, scope_attrs, usage, any_args).and_return(iid)
+ subject
+ expect(instance.public_send(internal_id_attribute)).to eq(iid)
+ end
+
+ it 'does not overwrite an existing internal id' do
+ instance.public_send("#{internal_id_attribute}=", 4711)
+
+ expect { subject }.not_to change { instance.public_send(internal_id_attribute) }
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb
new file mode 100644
index 00000000000..87d12a784ba
--- /dev/null
+++ b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb
@@ -0,0 +1,70 @@
+shared_examples 'cluster application core specs' do |application_name|
+ it { is_expected.to belong_to(:cluster) }
+ it { is_expected.to validate_presence_of(:cluster) }
+
+ describe '#name' do
+ it 'is .application_name' do
+ expect(subject.name).to eq(described_class.application_name)
+ end
+
+ it 'is recorded in Clusters::Cluster::APPLICATIONS' do
+ expect(Clusters::Cluster::APPLICATIONS[subject.name]).to eq(described_class)
+ end
+ end
+
+ describe 'status state machine' do
+ describe '#make_installing' do
+ subject { create(application_name, :scheduled) }
+
+ it 'is installing' do
+ subject.make_installing!
+
+ expect(subject).to be_installing
+ end
+ end
+
+ describe '#make_installed' do
+ subject { create(application_name, :installing) }
+
+ it 'is installed' do
+ subject.make_installed
+
+ expect(subject).to be_installed
+ end
+ end
+
+ describe '#make_errored' do
+ subject { create(application_name, :installing) }
+ let(:reason) { 'some errors' }
+
+ it 'is errored' do
+ subject.make_errored(reason)
+
+ expect(subject).to be_errored
+ expect(subject.status_reason).to eq(reason)
+ end
+ end
+
+ describe '#make_scheduled' do
+ subject { create(application_name, :installable) }
+
+ it 'is scheduled' do
+ subject.make_scheduled
+
+ expect(subject).to be_scheduled
+ end
+
+ describe 'when was errored' do
+ subject { create(application_name, :errored) }
+
+ it 'clears #status_reason' do
+ expect(subject.status_reason).not_to be_nil
+
+ subject.make_scheduled!
+
+ expect(subject.status_reason).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
new file mode 100644
index 00000000000..765dd32f4ba
--- /dev/null
+++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
@@ -0,0 +1,31 @@
+shared_examples 'cluster application status specs' do |application_name|
+ describe '#status' do
+ let(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ subject { described_class.new(cluster: cluster) }
+
+ it 'sets a default status' do
+ expect(subject.status_name).to be(:not_installable)
+ end
+
+ context 'when application helm is scheduled' do
+ before do
+ create(:clusters_applications_helm, :scheduled, cluster: cluster)
+ end
+
+ it 'defaults to :not_installable' do
+ expect(subject.status_name).to be(:not_installable)
+ end
+ end
+
+ context 'when application is scheduled' do
+ before do
+ create(:clusters_applications_helm, :installed, cluster: cluster)
+ end
+
+ it 'sets a default status' do
+ expect(subject.status_name).to be(:installable)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb
index 4e18804b937..9fc2fbef449 100644
--- a/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb
@@ -17,12 +17,88 @@ shared_examples 'custom attributes endpoints' do |attributable_name|
end
end
- it 'filters by custom attributes' do
- get api("/#{attributable_name}", admin), custom_attributes: { foo: 'foo', bar: 'bar' }
+ context 'with an authorized user' do
+ it 'filters by custom attributes' do
+ get api("/#{attributable_name}", admin), custom_attributes: { foo: 'foo', bar: 'bar' }
- expect(response).to have_gitlab_http_status(200)
- expect(json_response.size).to be 1
- expect(json_response.first['id']).to eq attributable.id
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.size).to be 1
+ expect(json_response.first['id']).to eq attributable.id
+ end
+ end
+ end
+
+ describe "GET /#{attributable_name} with custom attributes" do
+ before do
+ other_attributable
+ end
+
+ context 'with an unauthorized user' do
+ it 'does not include custom attributes' do
+ get api("/#{attributable_name}", user), with_custom_attributes: true
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.size).to be 2
+ expect(json_response.first).not_to include 'custom_attributes'
+ end
+ end
+
+ context 'with an authorized user' do
+ it 'does not include custom attributes by default' do
+ get api("/#{attributable_name}", admin)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.size).to be 2
+ expect(json_response.first).not_to include 'custom_attributes'
+ expect(json_response.second).not_to include 'custom_attributes'
+ end
+
+ it 'includes custom attributes if requested' do
+ get api("/#{attributable_name}", admin), with_custom_attributes: true
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response.size).to be 2
+
+ attributable_response = json_response.find { |r| r['id'] == attributable.id }
+ other_attributable_response = json_response.find { |r| r['id'] == other_attributable.id }
+
+ expect(attributable_response['custom_attributes']).to contain_exactly(
+ { 'key' => 'foo', 'value' => 'foo' },
+ { 'key' => 'bar', 'value' => 'bar' }
+ )
+
+ expect(other_attributable_response['custom_attributes']).to eq []
+ end
+ end
+ end
+
+ describe "GET /#{attributable_name}/:id with custom attributes" do
+ context 'with an unauthorized user' do
+ it 'does not include custom attributes' do
+ get api("/#{attributable_name}/#{attributable.id}", user), with_custom_attributes: true
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).not_to include 'custom_attributes'
+ end
+ end
+
+ context 'with an authorized user' do
+ it 'does not include custom attributes by default' do
+ get api("/#{attributable_name}/#{attributable.id}", admin)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).not_to include 'custom_attributes'
+ end
+
+ it 'includes custom attributes if requested' do
+ get api("/#{attributable_name}/#{attributable.id}", admin), with_custom_attributes: true
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['custom_attributes']).to contain_exactly(
+ { 'key' => 'foo', 'value' => 'foo' },
+ { 'key' => 'bar', 'value' => 'bar' }
+ )
+ end
end
end
@@ -33,14 +109,16 @@ shared_examples 'custom attributes endpoints' do |attributable_name|
it_behaves_like 'an unauthorized API user'
end
- it 'returns all custom attributes' do
- get api("/#{attributable_name}/#{attributable.id}/custom_attributes", admin)
+ context 'with an authorized user' do
+ it 'returns all custom attributes' do
+ get api("/#{attributable_name}/#{attributable.id}/custom_attributes", admin)
- expect(response).to have_gitlab_http_status(200)
- expect(json_response).to contain_exactly(
- { 'key' => 'foo', 'value' => 'foo' },
- { 'key' => 'bar', 'value' => 'bar' }
- )
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to contain_exactly(
+ { 'key' => 'foo', 'value' => 'foo' },
+ { 'key' => 'bar', 'value' => 'bar' }
+ )
+ end
end
end
@@ -51,11 +129,13 @@ shared_examples 'custom attributes endpoints' do |attributable_name|
it_behaves_like 'an unauthorized API user'
end
- it 'returns a single custom attribute' do
- get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin)
+ context 'with an authorized user' do
+ it'returns a single custom attribute' do
+ get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin)
- expect(response).to have_gitlab_http_status(200)
- expect(json_response).to eq({ 'key' => 'foo', 'value' => 'foo' })
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to eq({ 'key' => 'foo', 'value' => 'foo' })
+ end
end
end
@@ -66,24 +146,26 @@ shared_examples 'custom attributes endpoints' do |attributable_name|
it_behaves_like 'an unauthorized API user'
end
- it 'creates a new custom attribute' do
- expect do
- put api("/#{attributable_name}/#{attributable.id}/custom_attributes/new", admin), value: 'new'
- end.to change { attributable.custom_attributes.count }.by(1)
+ context 'with an authorized user' do
+ it 'creates a new custom attribute' do
+ expect do
+ put api("/#{attributable_name}/#{attributable.id}/custom_attributes/new", admin), value: 'new'
+ end.to change { attributable.custom_attributes.count }.by(1)
- expect(response).to have_gitlab_http_status(200)
- expect(json_response).to eq({ 'key' => 'new', 'value' => 'new' })
- expect(attributable.custom_attributes.find_by(key: 'new').value).to eq 'new'
- end
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to eq({ 'key' => 'new', 'value' => 'new' })
+ expect(attributable.custom_attributes.find_by(key: 'new').value).to eq 'new'
+ end
- it 'updates an existing custom attribute' do
- expect do
- put api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin), value: 'new'
- end.not_to change { attributable.custom_attributes.count }
+ it 'updates an existing custom attribute' do
+ expect do
+ put api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin), value: 'new'
+ end.not_to change { attributable.custom_attributes.count }
- expect(response).to have_gitlab_http_status(200)
- expect(json_response).to eq({ 'key' => 'foo', 'value' => 'new' })
- expect(custom_attribute1.reload.value).to eq 'new'
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response).to eq({ 'key' => 'foo', 'value' => 'new' })
+ expect(custom_attribute1.reload.value).to eq 'new'
+ end
end
end
@@ -94,13 +176,15 @@ shared_examples 'custom attributes endpoints' do |attributable_name|
it_behaves_like 'an unauthorized API user'
end
- it 'deletes an existing custom attribute' do
- expect do
- delete api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin)
- end.to change { attributable.custom_attributes.count }.by(-1)
+ context 'with an authorized user' do
+ it 'deletes an existing custom attribute' do
+ expect do
+ delete api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin)
+ end.to change { attributable.custom_attributes.count }.by(-1)
- expect(response).to have_gitlab_http_status(204)
- expect(attributable.custom_attributes.find_by(key: 'foo')).to be_nil
+ expect(response).to have_gitlab_http_status(204)
+ expect(attributable.custom_attributes.find_by(key: 'foo')).to be_nil
+ end
end
end
end
diff --git a/spec/support/shared_examples/requests/api/discussions.rb b/spec/support/shared_examples/requests/api/discussions.rb
new file mode 100644
index 00000000000..b6aeb30d69c
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/discussions.rb
@@ -0,0 +1,169 @@
+shared_examples 'discussions API' do |parent_type, noteable_type, id_name|
+ describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions" do
+ it "returns an array of discussions" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first['id']).to eq(note.discussion_id)
+ end
+
+ it "returns a 404 error when noteable id not found" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/12345/discussions", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it "returns 404 when not authorized" do
+ parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", private_user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id" do
+ it "returns a discussion by id" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions/#{note.discussion_id}", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['id']).to eq(note.discussion_id)
+ expect(json_response['notes'].first['body']).to eq(note.note)
+ end
+
+ it "returns a 404 error if discussion not found" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions/12345", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions" do
+ it "creates a new note" do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), body: 'hi!'
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['notes'].first['body']).to eq('hi!')
+ expect(json_response['notes'].first['author']['username']).to eq(user.username)
+ end
+
+ it "returns a 400 bad request error if body not given" do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it "returns a 401 unauthorized error if user not authenticated" do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions"), body: 'hi!'
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+
+ context 'when an admin or owner makes the request' do
+ it 'accepts the creation date to be set' do
+ creation_time = 2.weeks.ago
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user),
+ body: 'hi!', created_at: creation_time
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['notes'].first['body']).to eq('hi!')
+ expect(json_response['notes'].first['author']['username']).to eq(user.username)
+ expect(Time.parse(json_response['notes'].first['created_at'])).to be_like_time(creation_time)
+ end
+ end
+
+ context 'when user does not have access to read the discussion' do
+ before do
+ parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it 'responds with 404' do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", private_user),
+ body: 'Foo'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+
+ describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes" do
+ it 'adds a new note to the discussion' do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes", user), body: 'Hello!'
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['body']).to eq('Hello!')
+ expect(json_response['type']).to eq('DiscussionNote')
+ end
+
+ it 'returns a 400 bad request error if body not given' do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes", user)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it "returns a 400 bad request error if discussion is individual note" do
+ note.update_attribute(:type, nil)
+
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes", user), body: 'hi!'
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
+ it 'returns modified note' do
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes/#{note.id}", user), body: 'Hello!'
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['body']).to eq('Hello!')
+ end
+
+ it 'returns a 404 error when note id not found' do
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes/12345", user),
+ body: 'Hello!'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'returns a 400 bad request error if body not given' do
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes/#{note.id}", user)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ describe "DELETE /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
+ it 'deletes a note' do
+ delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes/#{note.id}", user)
+
+ expect(response).to have_gitlab_http_status(204)
+ # Check if note is really deleted
+ delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes/#{note.id}", user)
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'returns a 404 error when note id not found' do
+ delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes/12345", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) do
+ api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "discussions/#{note.discussion_id}/notes/#{note.id}", user)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/issuable_participants_examples.rb b/spec/support/shared_examples/requests/api/issuable_participants_examples.rb
new file mode 100644
index 00000000000..96d59e0c472
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/issuable_participants_examples.rb
@@ -0,0 +1,29 @@
+shared_examples 'issuable participants endpoint' do
+ let(:area) { entity.class.name.underscore.pluralize }
+
+ it 'returns participants' do
+ get api("/projects/#{project.id}/#{area}/#{entity.iid}/participants", user)
+
+ 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(entity.participants.size)
+
+ last_participant = entity.participants.last
+ expect(json_response.last['id']).to eq(last_participant.id)
+ expect(json_response.last['name']).to eq(last_participant.name)
+ expect(json_response.last['username']).to eq(last_participant.username)
+ end
+
+ it 'returns a 404 when iid does not exist' do
+ get api("/projects/#{project.id}/#{area}/999/participants", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'returns a 404 when id is used instead of iid' do
+ get api("/projects/#{project.id}/#{area}/#{entity.id}/participants", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/notes.rb b/spec/support/shared_examples/requests/api/notes.rb
new file mode 100644
index 00000000000..79b2196660c
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/notes.rb
@@ -0,0 +1,206 @@
+shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
+ describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do
+ context 'sorting' do
+ before do
+ params = { noteable: noteable, author: user }
+ params[:project] = parent if parent.is_a?(Project)
+
+ create_list(:note, 3, params)
+ end
+
+ it 'sorts by created_at in descending order by default' do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
+
+ response_dates = json_response.map { |note| note['created_at'] }
+
+ expect(json_response.length).to eq(4)
+ expect(response_dates).to eq(response_dates.sort.reverse)
+ end
+
+ it 'sorts by ascending order when requested' do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?sort=asc", user)
+
+ response_dates = json_response.map { |note| note['created_at'] }
+
+ expect(json_response.length).to eq(4)
+ expect(response_dates).to eq(response_dates.sort)
+ end
+
+ it 'sorts by updated_at in descending order when requested' do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at", user)
+
+ response_dates = json_response.map { |note| note['updated_at'] }
+
+ expect(json_response.length).to eq(4)
+ expect(response_dates).to eq(response_dates.sort.reverse)
+ end
+
+ it 'sorts by updated_at in ascending order when requested' do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes?order_by=updated_at&sort=asc", user)
+
+ response_dates = json_response.map { |note| note['updated_at'] }
+
+ expect(json_response.length).to eq(4)
+ expect(response_dates).to eq(response_dates.sort)
+ end
+ end
+
+ it "returns an array of notes" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first['body']).to eq(note.note)
+ end
+
+ it "returns a 404 error when noteable id not found" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/12345/notes", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it "returns 404 when not authorized" do
+ parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do
+ it "returns a note by id" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['body']).to eq(note.note)
+ end
+
+ it "returns a 404 error if note not found" do
+ get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/12345", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do
+ it "creates a new note" do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), body: 'hi!'
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['body']).to eq('hi!')
+ expect(json_response['author']['username']).to eq(user.username)
+ end
+
+ it "returns a 400 bad request error if body not given" do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+
+ it "returns a 401 unauthorized error if user not authenticated" do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes"), body: 'hi!'
+
+ expect(response).to have_gitlab_http_status(401)
+ end
+
+ it "creates an activity event when a note is created" do
+ expect(Event).to receive(:create!)
+
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), body: 'hi!'
+ end
+
+ context 'when an admin or owner makes the request' do
+ it 'accepts the creation date to be set' do
+ creation_time = 2.weeks.ago
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user),
+ body: 'hi!', created_at: creation_time
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['body']).to eq('hi!')
+ expect(json_response['author']['username']).to eq(user.username)
+ expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time)
+ end
+ end
+
+ context 'when the user is posting an award emoji on a noteable created by someone else' do
+ it 'creates a new note' do
+ parent.add_developer(private_user)
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user), body: ':+1:'
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['body']).to eq(':+1:')
+ end
+ end
+
+ context 'when the user is posting an award emoji on his/her own noteable' do
+ it 'creates a new note' do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), body: ':+1:'
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['body']).to eq(':+1:')
+ end
+ end
+
+ context 'when user does not have access to read the noteable' do
+ before do
+ parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it 'responds with 404' do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", private_user),
+ body: 'Foo'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+
+ describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do
+ it 'returns modified note' do
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "notes/#{note.id}", user), body: 'Hello!'
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['body']).to eq('Hello!')
+ end
+
+ it 'returns a 404 error when note id not found' do
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/12345", user),
+ body: 'Hello!'
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'returns a 400 bad request error if body not given' do
+ put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "notes/#{note.id}", user)
+
+ expect(response).to have_gitlab_http_status(400)
+ end
+ end
+
+ describe "DELETE /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do
+ it 'deletes a note' do
+ delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "notes/#{note.id}", user)
+
+ expect(response).to have_gitlab_http_status(204)
+ # Check if note is really deleted
+ delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
+ "notes/#{note.id}", user)
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it 'returns a 404 error when note id not found' do
+ delete api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/12345", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user) }
+ end
+ end
+end
diff --git a/spec/support/shared_examples/serializers/note_entity_examples.rb b/spec/support/shared_examples/serializers/note_entity_examples.rb
new file mode 100644
index 00000000000..9097c8e5513
--- /dev/null
+++ b/spec/support/shared_examples/serializers/note_entity_examples.rb
@@ -0,0 +1,42 @@
+shared_examples 'note entity' do
+ subject { entity.as_json }
+
+ context 'basic note' do
+ it 'exposes correct elements' do
+ expect(subject).to include(:type, :author, :note, :note_html, :current_user,
+ :discussion_id, :emoji_awardable, :award_emoji, :report_abuse_path, :attachment)
+ end
+
+ it 'does not expose elements for specific notes cases' do
+ expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name)
+ end
+
+ it 'exposes author correctly' do
+ expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path)
+ end
+
+ it 'does not expose web_url for author' do
+ expect(subject[:author]).not_to include(:web_url)
+ end
+ end
+
+ context 'when note was edited' do
+ before do
+ note.update(updated_at: 1.minute.from_now, updated_by: user)
+ end
+
+ it 'exposes last_edited_at and last_edited_by elements' do
+ expect(subject).to include(:last_edited_at, :last_edited_by)
+ end
+ end
+
+ context 'when note is a system note' do
+ before do
+ note.update(system: true)
+ end
+
+ it 'exposes system_note_icon_name element' do
+ expect(subject).to include(:system_note_icon_name)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/boards_create_service.rb b/spec/support/shared_examples/services/boards/boards_create_service.rb
new file mode 100644
index 00000000000..5bdc04f660f
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/boards_create_service.rb
@@ -0,0 +1,27 @@
+shared_examples 'boards create service' do
+ context 'when parent does not have a board' do
+ it 'creates a new board' do
+ expect { service.execute }.to change(Board, :count).by(1)
+ end
+
+ it 'creates the default lists' do
+ board = service.execute
+
+ expect(board.lists.size).to eq 2
+ expect(board.lists.first).to be_backlog
+ expect(board.lists.last).to be_closed
+ end
+ end
+
+ context 'when parent has a board' do
+ before do
+ create(:board, parent: parent)
+ end
+
+ it 'does not create a new board' do
+ expect(service).to receive(:can_create_board?) { false }
+
+ expect { service.execute }.not_to change(parent.boards, :count)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/boards_list_service.rb b/spec/support/shared_examples/services/boards/boards_list_service.rb
new file mode 100644
index 00000000000..e0d5a7c61f2
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/boards_list_service.rb
@@ -0,0 +1,29 @@
+shared_examples 'boards list service' do
+ context 'when parent does not have a board' do
+ it 'creates a new parent board' do
+ expect { service.execute }.to change(parent.boards, :count).by(1)
+ end
+
+ it 'delegates the parent board creation to Boards::CreateService' do
+ expect_any_instance_of(Boards::CreateService).to receive(:execute).once
+
+ service.execute
+ end
+ end
+
+ context 'when parent has a board' do
+ before do
+ create(:board, parent: parent)
+ end
+
+ it 'does not create a new board' do
+ expect { service.execute }.not_to change(parent.boards, :count)
+ end
+ end
+
+ it 'returns parent boards' do
+ board = create(:board, parent: parent)
+
+ expect(service.execute).to eq [board]
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/issues_list_service.rb b/spec/support/shared_examples/services/boards/issues_list_service.rb
new file mode 100644
index 00000000000..3e744323cea
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/issues_list_service.rb
@@ -0,0 +1,60 @@
+shared_examples 'issues list service' do
+ it 'delegates search to IssuesFinder' do
+ params = { board_id: board.id, id: list1.id }
+
+ expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
+
+ described_class.new(parent, user, params).execute
+ end
+
+ context 'issues are ordered by priority' do
+ it 'returns opened issues when list_id is missing' do
+ params = { board_id: board.id }
+
+ issues = described_class.new(parent, user, params).execute
+
+ 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(parent, user, params).execute
+
+ expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
+ end
+
+ it 'returns closed issues when listing issues from Closed' do
+ params = { board_id: board.id, id: closed.id }
+
+ issues = described_class.new(parent, user, params).execute
+
+ expect(issues).to eq [closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1]
+ end
+
+ it 'returns opened issues that have label list applied when listing issues from a label list' do
+ params = { board_id: board.id, id: list1.id }
+
+ issues = described_class.new(parent, user, params).execute
+
+ expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
+ end
+ end
+
+ context 'with list that does not belong to the board' do
+ it 'raises an error' do
+ list = create(:list)
+ service = described_class.new(parent, user, board_id: board.id, id: list.id)
+
+ expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context 'with invalid list id' do
+ it 'raises an error' do
+ service = described_class.new(parent, user, board_id: board.id, id: nil)
+
+ expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/issues_move_service.rb b/spec/support/shared_examples/services/boards/issues_move_service.rb
new file mode 100644
index 00000000000..737863ea411
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/issues_move_service.rb
@@ -0,0 +1,100 @@
+shared_examples 'issues move service' do |group|
+ context 'when moving an issue between lists' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
+
+ it 'delegates the label changes to Issues::UpdateService' do
+ expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
+
+ described_class.new(parent, user, params).execute(issue)
+ end
+
+ it 'removes the label from the list it came from and adds the label of the list it goes to' do
+ described_class.new(parent, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, testing)
+ end
+ end
+
+ context 'when moving to closed' do
+ let!(:list3) { create(:list, board: board2, label: regression, position: 1) }
+
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing, regression]) }
+ let(:params) { { board_id: board1.id, from_list_id: list2.id, to_list_id: closed.id } }
+
+ it 'delegates the close proceedings to Issues::CloseService' do
+ expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
+
+ described_class.new(parent, user, params).execute(issue)
+ end
+
+ it 'removes all list-labels from boards and close the issue' do
+ described_class.new(parent, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug)
+ expect(issue).to be_closed
+ end
+ end
+
+ context 'when moving from closed' do
+ let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
+ let(:params) { { board_id: board1.id, from_list_id: closed.id, to_list_id: list2.id } }
+
+ it 'delegates the re-open proceedings to Issues::ReopenService' do
+ expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
+
+ described_class.new(parent, user, params).execute(issue)
+ end
+
+ it 'adds the label of the list it goes to and reopen the issue' do
+ described_class.new(parent, user, params).execute(issue)
+ issue.reload
+
+ expect(issue.labels).to contain_exactly(bug, testing)
+ expect(issue).to be_opened
+ end
+ end
+
+ context 'when moving to same list' do
+ let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:issue1) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:issue2) { create(:labeled_issue, project: project, labels: [bug, development]) }
+ let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } }
+
+ it 'returns false' do
+ expect(described_class.new(parent, user, params).execute(issue)).to eq false
+ end
+
+ it 'keeps issues labels' do
+ described_class.new(parent, user, params).execute(issue)
+
+ expect(issue.reload.labels).to contain_exactly(bug, development)
+ end
+
+ it 'sorts issues' do
+ [issue, issue1, issue2].each do |issue|
+ issue.move_to_end && issue.save!
+ end
+
+ params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
+
+ described_class.new(parent, user, params).execute(issue)
+
+ expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
+ end
+
+ if group
+ context 'when on a group board' do
+ it 'sends the board_group_id parameter' do
+ params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
+
+ match_params = { move_between_ids: [issue1.id, issue2.id], board_group_id: parent.id }
+ expect(Issues::UpdateService).to receive(:new).with(issue.project, user, match_params).and_return(double(execute: build(:issue)))
+
+ described_class.new(parent, user, params).execute(issue)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/lists_destroy_service.rb b/spec/support/shared_examples/services/boards/lists_destroy_service.rb
new file mode 100644
index 00000000000..62b6ffe1836
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/lists_destroy_service.rb
@@ -0,0 +1,30 @@
+shared_examples 'lists destroy service' do
+ context 'when list type is label' do
+ it 'removes list from board' do
+ list = create(:list, board: board)
+ service = described_class.new(parent, user)
+
+ expect { service.execute(list) }.to change(board.lists, :count).by(-1)
+ end
+
+ it 'decrements position of higher lists' do
+ development = create(:list, board: board, position: 0)
+ review = create(:list, board: board, position: 1)
+ staging = create(:list, board: board, position: 2)
+ closed = board.closed_list
+
+ described_class.new(parent, user).execute(development)
+
+ expect(review.reload.position).to eq 0
+ expect(staging.reload.position).to eq 1
+ expect(closed.reload.position).to be_nil
+ end
+ end
+
+ it 'does not remove list from board when list type is closed' do
+ list = board.closed_list
+ service = described_class.new(parent, user)
+
+ expect { service.execute(list) }.not_to change(board.lists, :count)
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/lists_list_service.rb b/spec/support/shared_examples/services/boards/lists_list_service.rb
new file mode 100644
index 00000000000..0a8220111ab
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/lists_list_service.rb
@@ -0,0 +1,23 @@
+shared_examples 'lists list service' do
+ 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
+
+ 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
+
+ it "returns board's lists" do
+ expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/lists_move_service.rb b/spec/support/shared_examples/services/boards/lists_move_service.rb
new file mode 100644
index 00000000000..07c98cb29b7
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/lists_move_service.rb
@@ -0,0 +1,93 @@
+shared_examples 'lists move service' do
+ let!(:planning) { create(:list, board: board, position: 0) }
+ let!(:development) { create(:list, board: board, position: 1) }
+ let!(:review) { create(:list, board: board, position: 2) }
+ let!(:staging) { create(:list, board: board, position: 3) }
+ let!(:closed) { create(:closed_list, board: board) }
+
+ context 'when list type is set to label' do
+ it 'keeps position of lists when new position is nil' do
+ service = described_class.new(parent, user, position: nil)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is equal to old position' do
+ service = described_class.new(parent, user, position: planning.position)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is negative' do
+ service = described_class.new(parent, user, position: -1)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is equal to number of labels lists' do
+ service = described_class.new(parent, user, position: board.lists.label.size)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'keeps position of lists when new positon is greater than number of labels lists' do
+ service = described_class.new(parent, user, position: board.lists.label.size + 1)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ it 'increments position of intermediate lists when new positon is equal to first position' do
+ service = described_class.new(parent, user, position: 0)
+
+ service.execute(staging)
+
+ expect(current_list_positions).to eq [1, 2, 3, 0]
+ end
+
+ it 'decrements position of intermediate lists when new positon is equal to last position' do
+ service = described_class.new(parent, user, position: board.lists.label.last.position)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [3, 0, 1, 2]
+ end
+
+ it 'decrements position of intermediate lists when new position is greater than old position' do
+ service = described_class.new(parent, user, position: 2)
+
+ service.execute(planning)
+
+ expect(current_list_positions).to eq [2, 0, 1, 3]
+ end
+
+ it 'increments position of intermediate lists when new position is lower than old position' do
+ service = described_class.new(parent, user, position: 1)
+
+ service.execute(staging)
+
+ expect(current_list_positions).to eq [0, 2, 3, 1]
+ end
+ end
+
+ it 'keeps position of lists when list type is closed' do
+ service = described_class.new(parent, user, position: 2)
+
+ service.execute(closed)
+
+ expect(current_list_positions).to eq [0, 1, 2, 3]
+ end
+
+ def current_list_positions
+ [planning, development, review, staging].map { |list| list.reload.position }
+ end
+end
diff --git a/spec/support/shared_examples/uploaders/gitlab_uploader_shared_examples.rb b/spec/support/shared_examples/uploaders/gitlab_uploader_shared_examples.rb
new file mode 100644
index 00000000000..934d53e7bba
--- /dev/null
+++ b/spec/support/shared_examples/uploaders/gitlab_uploader_shared_examples.rb
@@ -0,0 +1,48 @@
+shared_examples "matches the method pattern" do |method|
+ let(:target) { subject }
+ let(:args) { nil }
+ let(:pattern) { patterns[method] }
+
+ it do
+ return skip "No pattern provided, skipping." unless pattern
+
+ expect(target.method(method).call(*args)).to match(pattern)
+ end
+end
+
+shared_examples "builds correct paths" do |**patterns|
+ let(:patterns) { patterns }
+
+ before do
+ allow(subject).to receive(:filename).and_return('<filename>')
+ end
+
+ describe "#store_dir" do
+ it_behaves_like "matches the method pattern", :store_dir
+ end
+
+ describe "#cache_dir" do
+ it_behaves_like "matches the method pattern", :cache_dir
+ end
+
+ describe "#work_dir" do
+ it_behaves_like "matches the method pattern", :work_dir
+ end
+
+ describe "#upload_path" do
+ it_behaves_like "matches the method pattern", :upload_path
+ end
+
+ describe ".absolute_path" do
+ it_behaves_like "matches the method pattern", :absolute_path do
+ let(:target) { subject.class }
+ let(:args) { [upload] }
+ end
+ end
+
+ describe ".base_dir" do
+ it_behaves_like "matches the method pattern", :base_dir do
+ let(:target) { subject.class }
+ end
+ end
+end
diff --git a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb
new file mode 100644
index 00000000000..6352f1527cd
--- /dev/null
+++ b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb
@@ -0,0 +1,138 @@
+shared_context 'with storage' do |store, **stub_params|
+ before do
+ subject.object_store = store
+ end
+end
+
+shared_examples "migrates" do |to_store:, from_store: nil|
+ let(:to) { to_store }
+ let(:from) { from_store || subject.object_store }
+
+ def migrate(to)
+ subject.migrate!(to)
+ end
+
+ def checksum
+ Digest::SHA256.hexdigest(subject.read)
+ end
+
+ before do
+ migrate(from)
+ end
+
+ it 'returns corresponding file type' do
+ expect(subject).to be_an(CarrierWave::Uploader::Base)
+ expect(subject).to be_a(ObjectStorage::Concern)
+
+ if from == described_class::Store::REMOTE
+ expect(subject.file).to be_a(CarrierWave::Storage::Fog::File)
+ elsif from == described_class::Store::LOCAL
+ expect(subject.file).to be_a(CarrierWave::SanitizedFile)
+ else
+ raise 'Unexpected file type'
+ end
+ end
+
+ it 'does nothing when migrating to the current store' do
+ expect { migrate(from) }.not_to change { subject.object_store }.from(from)
+ end
+
+ it 'migrate to the specified store' do
+ from_checksum = checksum
+
+ expect { migrate(to) }.to change { subject.object_store }.from(from).to(to)
+ expect(checksum).to eq(from_checksum)
+ end
+
+ it 'removes the original file after the migration' do
+ original_file = subject.file.path
+ migrate(to)
+
+ expect(File.exist?(original_file)).to be_falsey
+ end
+
+ it 'can access to the original file during migration' do
+ file = subject.file
+
+ allow(subject).to receive(:delete_migrated_file) { } # Remove as a callback of :migrate
+ allow(subject).to receive(:record_upload) { } # Remove as a callback of :store (:record_upload)
+
+ expect(file.exists?).to be_truthy
+ expect { migrate(to) }.not_to change { file.exists? }
+ end
+
+ context 'when migrate! is not occupied by another process' do
+ it 'executes migrate!' do
+ expect(subject).to receive(:object_store=).at_least(1)
+
+ migrate(to)
+ end
+
+ it 'executes use_file' do
+ expect(subject).to receive(:unsafe_use_file).once
+
+ subject.use_file
+ end
+ end
+
+ context 'when migrate! is occupied by another process' do
+ let(:exclusive_lease_key) { "object_storage_migrate:#{subject.model.class}:#{subject.model.id}" }
+
+ before do
+ @uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain
+ end
+
+ it 'does not execute migrate!' do
+ expect(subject).not_to receive(:unsafe_migrate!)
+
+ expect { migrate(to) }.to raise_error('exclusive lease already taken')
+ end
+
+ it 'does not execute use_file' do
+ expect(subject).not_to receive(:unsafe_use_file)
+
+ expect { subject.use_file }.to raise_error('exclusive lease already taken')
+ end
+
+ after do
+ Gitlab::ExclusiveLease.cancel(exclusive_lease_key, @uuid)
+ end
+ end
+
+ context 'migration is unsuccessful' do
+ shared_examples "handles gracefully" do |error:|
+ it 'does not update the object_store' do
+ expect { migrate(to) }.to raise_error(error)
+ expect(subject.object_store).to eq(from)
+ end
+
+ it 'does not delete the original file' do
+ expect { migrate(to) }.to raise_error(error)
+ expect(subject.exists?).to be_truthy
+ end
+ end
+
+ context 'when the store is not supported' do
+ let(:to) { -1 } # not a valid store
+
+ include_examples "handles gracefully", error: ObjectStorage::UnknownStoreError
+ end
+
+ context 'upon a fog failure' do
+ before do
+ storage_class = subject.send(:storage_for, to).class
+ expect_any_instance_of(storage_class).to receive(:store!).and_raise("Store failure.")
+ end
+
+ include_examples "handles gracefully", error: "Store failure."
+ end
+
+ context 'upon a database failure' do
+ before do
+ expect(uploader).to receive(:persist_object_store!).and_raise("ActiveRecord failure.")
+ end
+
+ include_examples "handles gracefully", error: "ActiveRecord failure."
+ end
+ end
+end
diff --git a/spec/support/slack_mattermost_notifications_shared_examples.rb b/spec/support/slack_mattermost_notifications_shared_examples.rb
index 17f3a861ba8..07bc3a51fd8 100644
--- a/spec/support/slack_mattermost_notifications_shared_examples.rb
+++ b/spec/support/slack_mattermost_notifications_shared_examples.rb
@@ -4,6 +4,11 @@ RSpec.shared_examples 'slack or mattermost notifications' do
let(:chat_service) { described_class.new }
let(:webhook_url) { 'https://example.gitlab.com/' }
+ def execute_with_options(options)
+ receive(:new).with(webhook_url, options)
+ .and_return(double(:slack_service).as_null_object)
+ end
+
describe "Associations" do
it { is_expected.to belong_to :project }
it { is_expected.to have_one :service_hook }
@@ -33,6 +38,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do
let(:project) { create(:project, :repository) }
let(:username) { 'slack_username' }
let(:channel) { 'slack_channel' }
+ let(:issue_service_options) { { title: 'Awesome issue', description: 'please fix' } }
let(:push_sample_data) do
Gitlab::DataBuilder::Push.build_sample(project, user)
@@ -48,15 +54,11 @@ RSpec.shared_examples 'slack or mattermost notifications' do
WebMock.stub_request(:post, webhook_url)
- opts = {
- title: 'Awesome issue',
- description: 'please fix'
- }
-
- issue_service = Issues::CreateService.new(project, user, opts)
+ issue_service = Issues::CreateService.new(project, user, issue_service_options)
@issue = issue_service.execute
@issues_sample_data = issue_service.hook_data(@issue, 'open')
+ project.add_developer(user)
opts = {
title: 'Awesome merge_request',
description: 'please fix',
@@ -163,6 +165,26 @@ RSpec.shared_examples 'slack or mattermost notifications' do
chat_service.execute(@issues_sample_data)
end
+ context 'for confidential issues' do
+ let(:issue_service_options) { { title: 'Secret', confidential: true } }
+
+ it "uses confidential issue channel" do
+ chat_service.update_attributes(confidential_issue_channel: 'confidential')
+
+ expect(Slack::Notifier).to execute_with_options(channel: 'confidential')
+
+ chat_service.execute(@issues_sample_data)
+ end
+
+ it 'falls back to issue channel' do
+ chat_service.update_attributes(issue_channel: 'fallback_channel')
+
+ expect(Slack::Notifier).to execute_with_options(channel: 'fallback_channel')
+
+ chat_service.execute(@issues_sample_data)
+ end
+ end
+
it "uses the right channel for wiki event" do
chat_service.update_attributes(wiki_page_channel: "random")
@@ -193,6 +215,32 @@ RSpec.shared_examples 'slack or mattermost notifications' do
chat_service.execute(note_data)
end
+
+ context 'for confidential notes' do
+ before do
+ issue_note.noteable.update!(confidential: true)
+ end
+
+ it "uses confidential channel" do
+ chat_service.update_attributes(confidential_note_channel: "confidential")
+
+ note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
+
+ expect(Slack::Notifier).to execute_with_options(channel: 'confidential')
+
+ chat_service.execute(note_data)
+ end
+
+ it 'falls back to note channel' do
+ chat_service.update_attributes(note_channel: "fallback_channel")
+
+ note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
+
+ expect(Slack::Notifier).to execute_with_options(channel: 'fallback_channel')
+
+ chat_service.execute(note_data)
+ end
+ end
end
end
end
@@ -247,8 +295,9 @@ RSpec.shared_examples 'slack or mattermost notifications' do
create(:note_on_issue, project: project, note: "issue note")
end
+ let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) }
+
it "calls Slack API for issue comment events" do
- data = Gitlab::DataBuilder::Note.build(issue_note, user)
chat_service.execute(data)
expect(WebMock).to have_requested(:post, webhook_url).once
@@ -336,6 +385,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do
before do
chat_service.notify_only_default_branch = true
+ WebMock.stub_request(:post, webhook_url)
end
it 'does not call the Slack/Mattermost API for pipeline events' do
@@ -344,6 +394,23 @@ RSpec.shared_examples 'slack or mattermost notifications' do
expect(result).to be_falsy
end
+
+ it 'does not notify push events if they are not for the default branch' do
+ ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}test"
+ push_sample_data = Gitlab::DataBuilder::Push.build(project, user, nil, nil, ref, [])
+
+ chat_service.execute(push_sample_data)
+
+ expect(WebMock).not_to have_requested(:post, webhook_url)
+ end
+
+ it 'notifies about push events for the default branch' do
+ push_sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
+
+ chat_service.execute(push_sample_data)
+
+ expect(WebMock).to have_requested(:post, webhook_url).once
+ end
end
context 'when disabled' do
diff --git a/spec/support/snippet_visibility.rb b/spec/support/snippet_visibility.rb
new file mode 100644
index 00000000000..3a7c69b7877
--- /dev/null
+++ b/spec/support/snippet_visibility.rb
@@ -0,0 +1,322 @@
+RSpec.shared_examples 'snippet visibility' do
+ let!(:author) { create(:user) }
+ let!(:member) { create(:user) }
+ let!(:external) { create(:user, :external) }
+
+ let!(:snippet_type_visibilities) do
+ {
+ public: Snippet::PUBLIC,
+ internal: Snippet::INTERNAL,
+ private: Snippet::PRIVATE
+ }
+ end
+
+ context "For project snippets" do
+ let!(:users) do
+ {
+ unauthenticated: nil,
+ external: external,
+ non_member: create(:user),
+ member: member,
+ author: author
+ }
+ end
+
+ let!(:project_type_visibilities) do
+ {
+ public: Gitlab::VisibilityLevel::PUBLIC,
+ internal: Gitlab::VisibilityLevel::INTERNAL,
+ private: Gitlab::VisibilityLevel::PRIVATE
+ }
+ end
+
+ let(:project_feature_visibilities) do
+ {
+ enabled: ProjectFeature::ENABLED,
+ private: ProjectFeature::PRIVATE,
+ disabled: ProjectFeature::DISABLED
+ }
+ end
+
+ where(:project_type, :feature_visibility, :user_type, :snippet_type, :outcome) do
+ [
+ # Public projects
+ [:public, :enabled, :unauthenticated, :public, true],
+ [:public, :enabled, :unauthenticated, :internal, false],
+ [:public, :enabled, :unauthenticated, :private, false],
+
+ [:public, :enabled, :external, :public, true],
+ [:public, :enabled, :external, :internal, false],
+ [:public, :enabled, :external, :private, false],
+
+ [:public, :enabled, :non_member, :public, true],
+ [:public, :enabled, :non_member, :internal, true],
+ [:public, :enabled, :non_member, :private, false],
+
+ [:public, :enabled, :member, :public, true],
+ [:public, :enabled, :member, :internal, true],
+ [:public, :enabled, :member, :private, true],
+
+ [:public, :enabled, :author, :public, true],
+ [:public, :enabled, :author, :internal, true],
+ [:public, :enabled, :author, :private, true],
+
+ [:public, :private, :unauthenticated, :public, false],
+ [:public, :private, :unauthenticated, :internal, false],
+ [:public, :private, :unauthenticated, :private, false],
+
+ [:public, :private, :external, :public, false],
+ [:public, :private, :external, :internal, false],
+ [:public, :private, :external, :private, false],
+
+ [:public, :private, :non_member, :public, false],
+ [:public, :private, :non_member, :internal, false],
+ [:public, :private, :non_member, :private, false],
+
+ [:public, :private, :member, :public, true],
+ [:public, :private, :member, :internal, true],
+ [:public, :private, :member, :private, true],
+
+ [:public, :private, :author, :public, true],
+ [:public, :private, :author, :internal, true],
+ [:public, :private, :author, :private, true],
+
+ [:public, :disabled, :unauthenticated, :public, false],
+ [:public, :disabled, :unauthenticated, :internal, false],
+ [:public, :disabled, :unauthenticated, :private, false],
+
+ [:public, :disabled, :external, :public, false],
+ [:public, :disabled, :external, :internal, false],
+ [:public, :disabled, :external, :private, false],
+
+ [:public, :disabled, :non_member, :public, false],
+ [:public, :disabled, :non_member, :internal, false],
+ [:public, :disabled, :non_member, :private, false],
+
+ [:public, :disabled, :member, :public, false],
+ [:public, :disabled, :member, :internal, false],
+ [:public, :disabled, :member, :private, false],
+
+ [:public, :disabled, :author, :public, false],
+ [:public, :disabled, :author, :internal, false],
+ [:public, :disabled, :author, :private, false],
+
+ # Internal projects
+ [:internal, :enabled, :unauthenticated, :public, false],
+ [:internal, :enabled, :unauthenticated, :internal, false],
+ [:internal, :enabled, :unauthenticated, :private, false],
+
+ [:internal, :enabled, :external, :public, false],
+ [:internal, :enabled, :external, :internal, false],
+ [:internal, :enabled, :external, :private, false],
+
+ [:internal, :enabled, :non_member, :public, true],
+ [:internal, :enabled, :non_member, :internal, true],
+ [:internal, :enabled, :non_member, :private, false],
+
+ [:internal, :enabled, :member, :public, true],
+ [:internal, :enabled, :member, :internal, true],
+ [:internal, :enabled, :member, :private, true],
+
+ [:internal, :enabled, :author, :public, true],
+ [:internal, :enabled, :author, :internal, true],
+ [:internal, :enabled, :author, :private, true],
+
+ [:internal, :private, :unauthenticated, :public, false],
+ [:internal, :private, :unauthenticated, :internal, false],
+ [:internal, :private, :unauthenticated, :private, false],
+
+ [:internal, :private, :external, :public, false],
+ [:internal, :private, :external, :internal, false],
+ [:internal, :private, :external, :private, false],
+
+ [:internal, :private, :non_member, :public, false],
+ [:internal, :private, :non_member, :internal, false],
+ [:internal, :private, :non_member, :private, false],
+
+ [:internal, :private, :member, :public, true],
+ [:internal, :private, :member, :internal, true],
+ [:internal, :private, :member, :private, true],
+
+ [:internal, :private, :author, :public, true],
+ [:internal, :private, :author, :internal, true],
+ [:internal, :private, :author, :private, true],
+
+ [:internal, :disabled, :unauthenticated, :public, false],
+ [:internal, :disabled, :unauthenticated, :internal, false],
+ [:internal, :disabled, :unauthenticated, :private, false],
+
+ [:internal, :disabled, :external, :public, false],
+ [:internal, :disabled, :external, :internal, false],
+ [:internal, :disabled, :external, :private, false],
+
+ [:internal, :disabled, :non_member, :public, false],
+ [:internal, :disabled, :non_member, :internal, false],
+ [:internal, :disabled, :non_member, :private, false],
+
+ [:internal, :disabled, :member, :public, false],
+ [:internal, :disabled, :member, :internal, false],
+ [:internal, :disabled, :member, :private, false],
+
+ [:internal, :disabled, :author, :public, false],
+ [:internal, :disabled, :author, :internal, false],
+ [:internal, :disabled, :author, :private, false],
+
+ # Private projects
+ [:private, :enabled, :unauthenticated, :public, false],
+ [:private, :enabled, :unauthenticated, :internal, false],
+ [:private, :enabled, :unauthenticated, :private, false],
+
+ [:private, :enabled, :external, :public, true],
+ [:private, :enabled, :external, :internal, true],
+ [:private, :enabled, :external, :private, true],
+
+ [:private, :enabled, :non_member, :public, false],
+ [:private, :enabled, :non_member, :internal, false],
+ [:private, :enabled, :non_member, :private, false],
+
+ [:private, :enabled, :member, :public, true],
+ [:private, :enabled, :member, :internal, true],
+ [:private, :enabled, :member, :private, true],
+
+ [:private, :enabled, :author, :public, true],
+ [:private, :enabled, :author, :internal, true],
+ [:private, :enabled, :author, :private, true],
+
+ [:private, :private, :unauthenticated, :public, false],
+ [:private, :private, :unauthenticated, :internal, false],
+ [:private, :private, :unauthenticated, :private, false],
+
+ [:private, :private, :external, :public, true],
+ [:private, :private, :external, :internal, true],
+ [:private, :private, :external, :private, true],
+
+ [:private, :private, :non_member, :public, false],
+ [:private, :private, :non_member, :internal, false],
+ [:private, :private, :non_member, :private, false],
+
+ [:private, :private, :member, :public, true],
+ [:private, :private, :member, :internal, true],
+ [:private, :private, :member, :private, true],
+
+ [:private, :private, :author, :public, true],
+ [:private, :private, :author, :internal, true],
+ [:private, :private, :author, :private, true],
+
+ [:private, :disabled, :unauthenticated, :public, false],
+ [:private, :disabled, :unauthenticated, :internal, false],
+ [:private, :disabled, :unauthenticated, :private, false],
+
+ [:private, :disabled, :external, :public, false],
+ [:private, :disabled, :external, :internal, false],
+ [:private, :disabled, :external, :private, false],
+
+ [:private, :disabled, :non_member, :public, false],
+ [:private, :disabled, :non_member, :internal, false],
+ [:private, :disabled, :non_member, :private, false],
+
+ [:private, :disabled, :member, :public, false],
+ [:private, :disabled, :member, :internal, false],
+ [:private, :disabled, :member, :private, false],
+
+ [:private, :disabled, :author, :public, false],
+ [:private, :disabled, :author, :internal, false],
+ [:private, :disabled, :author, :private, false]
+ ]
+ end
+
+ with_them do
+ let!(:project) { create(:project, visibility_level: project_type_visibilities[project_type]) }
+ let!(:project_feature) { project.project_feature.update_column(:snippets_access_level, project_feature_visibilities[feature_visibility]) }
+ let!(:user) { users[user_type] }
+ let!(:snippet) { create(:project_snippet, visibility_level: snippet_type_visibilities[snippet_type], project: project, author: author) }
+ let!(:members) do
+ project.add_developer(author)
+ project.add_developer(member)
+ project.add_developer(external) if project.private?
+ end
+
+ context "For #{params[:project_type]} project and #{params[:user_type]} users" do
+ it 'should agree with the read_project_snippet policy' do
+ expect(can?(user, :read_project_snippet, snippet)).to eq(outcome)
+ end
+
+ it 'should return proper outcome' do
+ results = described_class.new(user, project: project).execute
+ expect(results.include?(snippet)).to eq(outcome)
+ end
+ end
+
+ context "Without a given project and #{params[:user_type]} users" do
+ it 'should return proper outcome' do
+ results = described_class.new(user).execute
+ expect(results.include?(snippet)).to eq(outcome)
+ end
+
+ it 'returns no snippets when the user cannot read cross project' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+
+ snippets = described_class.new(user).execute
+
+ expect(snippets).to be_empty
+ end
+ end
+ end
+ end
+
+ context 'For personal snippets' do
+ let!(:users) do
+ {
+ unauthenticated: nil,
+ external: external,
+ non_member: create(:user),
+ author: author
+ }
+ end
+
+ where(:snippet_visibility, :user_type, :outcome) do
+ [
+ [:public, :unauthenticated, true],
+ [:public, :external, true],
+ [:public, :non_member, true],
+ [:public, :author, true],
+
+ [:internal, :unauthenticated, false],
+ [:internal, :external, false],
+ [:internal, :non_member, true],
+ [:internal, :author, true],
+
+ [:private, :unauthenticated, false],
+ [:private, :external, false],
+ [:private, :non_member, false],
+ [:private, :author, true]
+ ]
+ end
+
+ with_them do
+ let!(:user) { users[user_type] }
+ let!(:snippet) { create(:personal_snippet, visibility_level: snippet_type_visibilities[snippet_visibility], author: author) }
+
+ context "For personal and #{params[:snippet_visibility]} snippets with #{params[:user_type]} user" do
+ it 'should agree with read_personal_snippet policy' do
+ expect(can?(user, :read_personal_snippet, snippet)).to eq(outcome)
+ end
+
+ it 'should return proper outcome' do
+ results = described_class.new(user).execute
+ expect(results.include?(snippet)).to eq(outcome)
+ end
+
+ it 'should return personal snippets when the user cannot read cross project' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+
+ results = described_class.new(user).execute
+
+ expect(results.include?(snippet)).to eq(outcome)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/sorting_helper.rb b/spec/support/sorting_helper.rb
deleted file mode 100644
index 577518d726c..00000000000
--- a/spec/support/sorting_helper.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# 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 f9121cce985..21995c89a6e 100644
--- a/spec/support/stored_repositories.rb
+++ b/spec/support/stored_repositories.rb
@@ -4,7 +4,7 @@ RSpec.configure do |config|
end
config.before(:all, :broken_storage) do
- FileUtils.rm_rf Gitlab.config.repositories.storages.broken['path']
+ FileUtils.rm_rf Gitlab.config.repositories.storages.broken.legacy_disk_path
end
config.before(:each, :broken_storage) do
@@ -15,9 +15,7 @@ RSpec.configure do |config|
# Track the maximum number of failures
first_failure = Time.parse("2017-11-14 17:52:30")
last_failure = Time.parse("2017-11-14 18:54:37")
- failure_count = Gitlab::CurrentSettings
- .current_application_settings
- .circuitbreaker_failure_count_threshold + 1
+ failure_count = Gitlab::CurrentSettings.circuitbreaker_failure_count_threshold + 1
cache_key = "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}broken:#{Gitlab::Environment.hostname}"
Gitlab::Git::Storage.redis.with do |redis|
diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb
index 9f08c139322..a75a3eaefcb 100644
--- a/spec/support/stub_configuration.rb
+++ b/spec/support/stub_configuration.rb
@@ -45,13 +45,21 @@ module StubConfiguration
allow(Gitlab.config.lfs).to receive_messages(to_settings(messages))
end
+ def stub_artifacts_setting(messages)
+ allow(Gitlab.config.artifacts).to receive_messages(to_settings(messages))
+ end
+
def stub_storage_settings(messages)
messages.deep_stringify_keys!
# 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 unless storage_settings.key?('path')
+ messages.each do |storage_name, storage_hash|
+ if !storage_hash.key?('path') || storage_hash['path'] == Gitlab::GitalyClient::StorageSettings::Deprecated
+ storage_hash['path'] = TestEnv.repos_path
+ end
+
+ messages[storage_name] = Gitlab::GitalyClient::StorageSettings.new(storage_hash.to_h)
end
allow(Gitlab.config.repositories).to receive(:storages).and_return(Settingslogic.new(messages))
diff --git a/spec/support/stub_env.rb b/spec/support/stub_env.rb
index f621463e621..36b90fc68d6 100644
--- a/spec/support/stub_env.rb
+++ b/spec/support/stub_env.rb
@@ -1,9 +1,8 @@
# Inspired by https://github.com/ljkbennett/stub_env/blob/master/lib/stub_env/helpers.rb
module StubENV
- include Gitlab::CurrentSettings
-
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
diff --git a/spec/support/stub_object_storage.rb b/spec/support/stub_object_storage.rb
new file mode 100644
index 00000000000..6e88641da42
--- /dev/null
+++ b/spec/support/stub_object_storage.rb
@@ -0,0 +1,48 @@
+module StubConfiguration
+ def stub_object_storage_uploader(
+ config:,
+ uploader:,
+ remote_directory:,
+ enabled: true,
+ proxy_download: false,
+ background_upload: false,
+ direct_upload: false
+ )
+ allow(config).to receive(:enabled) { enabled }
+ allow(config).to receive(:proxy_download) { proxy_download }
+ allow(config).to receive(:background_upload) { background_upload }
+ allow(config).to receive(:direct_upload) { direct_upload }
+
+ return unless enabled
+
+ Fog.mock!
+
+ ::Fog::Storage.new(uploader.object_store_credentials).tap do |connection|
+ begin
+ connection.directories.create(key: remote_directory)
+ rescue Excon::Error::Conflict
+ end
+ end
+ end
+
+ def stub_artifacts_object_storage(**params)
+ stub_object_storage_uploader(config: Gitlab.config.artifacts.object_store,
+ uploader: JobArtifactUploader,
+ remote_directory: 'artifacts',
+ **params)
+ end
+
+ def stub_lfs_object_storage(**params)
+ stub_object_storage_uploader(config: Gitlab.config.lfs.object_store,
+ uploader: LfsObjectUploader,
+ remote_directory: 'lfs-objects',
+ **params)
+ end
+
+ def stub_uploads_object_storage(uploader = described_class, **params)
+ stub_object_storage_uploader(config: Gitlab.config.uploads.object_store,
+ uploader: uploader,
+ remote_directory: 'uploads',
+ **params)
+ end
+end
diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb
index ffc051a3fff..d87f265cdf0 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -1,4 +1,5 @@
require 'rspec/mocks'
+require 'toml-rb'
module TestEnv
extend self
@@ -19,7 +20,7 @@ module TestEnv
'improve/awesome' => '5937ac0',
'merged-target' => '21751bf',
'markdown' => '0ed8c6c',
- 'lfs' => 'be93687',
+ 'lfs' => '55bc176',
'master' => 'b83d6e3',
'merge-test' => '5937ac0',
"'test'" => 'e56497b',
@@ -61,6 +62,7 @@ module TestEnv
}.freeze
TMP_TEST_PATH = Rails.root.join('tmp', 'tests', '**')
+ REPOS_STORAGE = 'default'.freeze
# Test environment
#
@@ -89,10 +91,6 @@ module TestEnv
setup_forked_repo
end
- def cleanup
- stop_gitaly
- end
-
def disable_mailer
allow_any_instance_of(NotificationService).to receive(:mailer)
.and_return(double.as_null_object)
@@ -147,6 +145,9 @@ module TestEnv
version: Gitlab::GitalyClient.expected_server_version,
task: "gitlab:gitaly:install[#{gitaly_dir}]") do
+ # Always re-create config, in case it's outdated. This is fast anyway.
+ Gitlab::SetupHelper.create_gitaly_configuration(gitaly_dir, force: true)
+
start_gitaly(gitaly_dir)
end
end
@@ -159,6 +160,8 @@ module TestEnv
spawn_script = Rails.root.join('scripts/gitaly-test-spawn').to_s
@gitaly_pid = Bundler.with_original_env { IO.popen([spawn_script], &:read).to_i }
+ Kernel.at_exit { stop_gitaly }
+
wait_gitaly
end
@@ -215,7 +218,7 @@ module TestEnv
end
def copy_repo(project, bare_repo:, refs:)
- target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.full_path}.git")
+ target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.disk_path}.git")
FileUtils.mkdir_p(target_repo_path)
FileUtils.cp_r("#{File.expand_path(bare_repo)}/.", target_repo_path)
FileUtils.chmod_R 0755, target_repo_path
@@ -223,7 +226,7 @@ module TestEnv
end
def repos_path
- Gitlab.config.repositories.storages.default['path']
+ Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path
end
def backup_path
@@ -235,7 +238,7 @@ module TestEnv
end
def artifacts_path
- Gitlab.config.artifacts.path
+ Gitlab.config.artifacts.storage_path
end
# When no cached assets exist, manually hit the root path to create them
@@ -305,7 +308,7 @@ module TestEnv
# Before we used Git clone's --mirror option, bare repos could end up
# with missing refs, clearing them and retrying should fix the issue.
- cleanup && clean_gitlab_test_path && init unless reset.call
+ clean_gitlab_test_path && init unless reset.call
end
end
@@ -321,6 +324,7 @@ module TestEnv
if component_needs_update?(install_dir, version)
# Cleanup the component entirely to ensure we start fresh
FileUtils.rm_rf(install_dir)
+
unless system('rake', task)
raise ComponentFailedToInstallError
end
@@ -347,6 +351,9 @@ module TestEnv
end
def component_needs_update?(component_folder, expected_version)
+ # Allow local overrides of the component for tests during development
+ return false if Rails.env.test? && File.symlink?(component_folder)
+
version = File.read(File.join(component_folder, 'VERSION')).strip
# Notice that this will always yield true when using branch versions
diff --git a/spec/support/track_untracked_uploads_helpers.rb b/spec/support/track_untracked_uploads_helpers.rb
deleted file mode 100644
index d05eda08201..00000000000
--- a/spec/support/track_untracked_uploads_helpers.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-module TrackUntrackedUploadsHelpers
- def uploaded_file
- fixture_path = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg')
- fixture_file_upload(fixture_path)
- end
-
- def ensure_temporary_tracking_table_exists
- Gitlab::BackgroundMigration::PrepareUntrackedUploads.new.send(:ensure_temporary_tracking_table_exists)
- end
-
- def drop_temp_table_if_exists
- ActiveRecord::Base.connection.drop_table(:untracked_files_for_uploads) if ActiveRecord::Base.connection.table_exists?(:untracked_files_for_uploads)
- end
-
- def create_or_update_appearance(attrs)
- a = Appearance.first_or_initialize(title: 'foo', description: 'bar')
- a.update!(attrs)
- a
- end
-end
diff --git a/spec/support/unique_ip_check_shared_examples.rb b/spec/support/unique_ip_check_shared_examples.rb
index 3d9705c9c05..e5c8ac6a004 100644
--- a/spec/support/unique_ip_check_shared_examples.rb
+++ b/spec/support/unique_ip_check_shared_examples.rb
@@ -9,7 +9,7 @@ shared_context 'unique ips sign in limit' do
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- current_application_settings.update!(
+ Gitlab::CurrentSettings.update!(
unique_ips_limit_enabled: true,
unique_ips_limit_time_window: 10000
)
@@ -34,7 +34,7 @@ end
shared_examples 'user login operation with unique ip limit' do
include_context 'unique ips sign in limit' do
before do
- current_application_settings.update!(unique_ips_limit_per_user: 1)
+ Gitlab::CurrentSettings.update!(unique_ips_limit_per_user: 1)
end
it 'allows user authenticating from the same ip' do
@@ -52,7 +52,7 @@ end
shared_examples 'user login request with unique ip limit' do |success_status = 200|
include_context 'unique ips sign in limit' do
before do
- current_application_settings.update!(unique_ips_limit_per_user: 1)
+ Gitlab::CurrentSettings.update!(unique_ips_limit_per_user: 1)
end
it 'allows user authenticating from the same ip' do
diff --git a/spec/support/updating_mentions_shared_examples.rb b/spec/support/updating_mentions_shared_examples.rb
index 565d3203e4f..5e3f19ba19e 100644
--- a/spec/support/updating_mentions_shared_examples.rb
+++ b/spec/support/updating_mentions_shared_examples.rb
@@ -3,7 +3,7 @@ RSpec.shared_examples 'updating mentions' do |service_class|
let(:service_class) { service_class }
before do
- project.team << [mentioned_user, :developer]
+ project.add_developer(mentioned_user)
end
def update_mentionable(opts)
diff --git a/spec/support/wait_for_requests.rb b/spec/support/wait_for_requests.rb
index f4130d68271..fda0e29f983 100644
--- a/spec/support/wait_for_requests.rb
+++ b/spec/support/wait_for_requests.rb
@@ -53,6 +53,7 @@ module WaitForRequests
wait_until = Time.now + max_wait_time.seconds
loop do
break if yield
+
if Time.now > wait_until
raise "Condition not met: #{condition_name}"
else
diff --git a/spec/tasks/gitlab/artifacts/check_rake_spec.rb b/spec/tasks/gitlab/artifacts/check_rake_spec.rb
new file mode 100644
index 00000000000..d495b08aca0
--- /dev/null
+++ b/spec/tasks/gitlab/artifacts/check_rake_spec.rb
@@ -0,0 +1,34 @@
+require 'rake_helper'
+
+describe 'gitlab:artifacts rake tasks' do
+ describe 'check' do
+ let!(:artifact) { create(:ci_job_artifact, :archive, :correct_checksum) }
+
+ before do
+ Rake.application.rake_require('tasks/gitlab/artifacts/check')
+ stub_env('VERBOSE' => 'true')
+ end
+
+ it 'outputs the integrity check for each batch' do
+ expect { run_rake_task('gitlab:artifacts:check') }.to output(/Failures: 0/).to_stdout
+ end
+
+ it 'errors out about missing files on the file system' do
+ FileUtils.rm_f(artifact.file.path)
+
+ expect { run_rake_task('gitlab:artifacts:check') }.to output(/No such file.*#{Regexp.quote(artifact.file.path)}/).to_stdout
+ end
+
+ it 'errors out about invalid checksum' do
+ artifact.update_column(:file_sha256, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
+
+ expect { run_rake_task('gitlab:artifacts:check') }.to output(/Checksum mismatch/).to_stdout
+ end
+
+ it 'errors out about missing checksum' do
+ artifact.update_column(:file_sha256, nil)
+
+ expect { run_rake_task('gitlab:artifacts:check') }.to output(/Checksum missing/).to_stdout
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
new file mode 100644
index 00000000000..8544fb62b5a
--- /dev/null
+++ b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
@@ -0,0 +1,118 @@
+require 'rake_helper'
+
+describe 'gitlab:artifacts namespace rake task' do
+ before(:context) do
+ Rake.application.rake_require 'tasks/gitlab/artifacts/migrate'
+ end
+
+ let(:object_storage_enabled) { false }
+
+ before do
+ stub_artifacts_object_storage(enabled: object_storage_enabled)
+ end
+
+ subject { run_rake_task('gitlab:artifacts:migrate') }
+
+ context 'legacy artifacts' do
+ describe 'migrate' do
+ let!(:build) { create(:ci_build, :legacy_artifacts, artifacts_file_store: store, artifacts_metadata_store: store) }
+
+ context 'when local storage is used' do
+ let(:store) { ObjectStorage::Store::LOCAL }
+
+ context 'and job does not have file store defined' do
+ let(:object_storage_enabled) { true }
+ let(:store) { nil }
+
+ it "migrates file to remote storage" do
+ subject
+
+ expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::REMOTE)
+ expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::REMOTE)
+ end
+ end
+
+ context 'and remote storage is defined' do
+ let(:object_storage_enabled) { true }
+
+ it "migrates file to remote storage" do
+ subject
+
+ expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::REMOTE)
+ expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::REMOTE)
+ end
+ end
+
+ context 'and remote storage is not defined' do
+ it "fails to migrate to remote storage" do
+ subject
+
+ expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::LOCAL)
+ expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::LOCAL)
+ end
+ end
+ end
+
+ context 'when remote storage is used' do
+ let(:object_storage_enabled) { true }
+
+ let(:store) { ObjectStorage::Store::REMOTE }
+
+ it "file stays on remote storage" do
+ subject
+
+ expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::REMOTE)
+ expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::REMOTE)
+ end
+ end
+ end
+ end
+
+ context 'job artifacts' do
+ let!(:artifact) { create(:ci_job_artifact, :archive, file_store: store) }
+
+ context 'when local storage is used' do
+ let(:store) { ObjectStorage::Store::LOCAL }
+
+ context 'and job does not have file store defined' do
+ let(:object_storage_enabled) { true }
+ let(:store) { nil }
+
+ it "migrates file to remote storage" do
+ subject
+
+ expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
+ end
+ end
+
+ context 'and remote storage is defined' do
+ let(:object_storage_enabled) { true }
+
+ it "migrates file to remote storage" do
+ subject
+
+ expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
+ end
+ end
+
+ context 'and remote storage is not defined' do
+ it "fails to migrate to remote storage" do
+ subject
+
+ expect(artifact.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
+ end
+ end
+ end
+
+ context 'when remote storage is used' do
+ let(:object_storage_enabled) { true }
+ let(:store) { ObjectStorage::Store::REMOTE }
+
+ it "file stays on remote storage" do
+ subject
+
+ expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
+ end
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index b41c3b3958a..0d24782f317 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -165,7 +165,7 @@ describe 'gitlab:app namespace rake task' do
expect(tar_contents).to match('pages.tar.gz')
expect(tar_contents).to match('lfs.tar.gz')
expect(tar_contents).to match('registry.tar.gz')
- expect(tar_contents).not_to match(/^.{4,9}[rwx].* (database.sql.gz|uploads.tar.gz|repositories|builds.tar.gz|pages.tar.gz|artifacts.tar.gz|registry.tar.gz)\/$/)
+ expect(tar_contents).not_to match(%r{^.{4,9}[rwx].* (database.sql.gz|uploads.tar.gz|repositories|builds.tar.gz|pages.tar.gz|artifacts.tar.gz|registry.tar.gz)/$})
end
it 'deletes temp directories' do
@@ -195,14 +195,23 @@ describe 'gitlab:app namespace rake task' do
end
context 'multiple repository storages' do
- let(:gitaly_address) { Gitlab.config.repositories.storages.default.gitaly_address }
+ let(:storage_default) do
+ Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/default_storage'))
+ end
+ let(:test_second_storage) do
+ Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/custom_storage'))
+ end
let(:storages) do
{
- 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage'), 'gitaly_address' => gitaly_address },
- 'test_second_storage' => { 'path' => Settings.absolute('tmp/tests/custom_storage'), 'gitaly_address' => gitaly_address }
+ 'default' => storage_default,
+ 'test_second_storage' => test_second_storage
}
end
+ before(:all) do
+ @default_storage_hash = Gitlab.config.repositories.storages.default.to_h
+ end
+
before do
# We only need a backup of the repositories for this test
stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,registry')
diff --git a/spec/tasks/gitlab/check_rake_spec.rb b/spec/tasks/gitlab/check_rake_spec.rb
index 538ff952bf4..4eda618b6d6 100644
--- a/spec/tasks/gitlab/check_rake_spec.rb
+++ b/spec/tasks/gitlab/check_rake_spec.rb
@@ -11,8 +11,8 @@ describe 'gitlab:ldap:check rake task' do
context 'when LDAP is not enabled' do
it 'does not attempt to bind or search for users' do
- expect(Gitlab::LDAP::Config).not_to receive(:providers)
- expect(Gitlab::LDAP::Adapter).not_to receive(:open)
+ expect(Gitlab::Auth::LDAP::Config).not_to receive(:providers)
+ expect(Gitlab::Auth::LDAP::Adapter).not_to receive(:open)
run_rake_task('gitlab:ldap:check')
end
@@ -23,12 +23,12 @@ describe 'gitlab:ldap:check rake task' do
let(:adapter) { ldap_adapter('ldapmain', ldap) }
before do
- allow(Gitlab::LDAP::Config)
+ allow(Gitlab::Auth::LDAP::Config)
.to receive_messages(
enabled?: true,
providers: ['ldapmain']
)
- allow(Gitlab::LDAP::Adapter).to receive(:open).and_yield(adapter)
+ allow(Gitlab::Auth::LDAP::Adapter).to receive(:open).and_yield(adapter)
allow(adapter).to receive(:users).and_return([])
end
diff --git a/spec/tasks/gitlab/cleanup_rake_spec.rb b/spec/tasks/gitlab/cleanup_rake_spec.rb
index 9e746ceddd6..2bf873c923f 100644
--- a/spec/tasks/gitlab/cleanup_rake_spec.rb
+++ b/spec/tasks/gitlab/cleanup_rake_spec.rb
@@ -6,13 +6,16 @@ describe 'gitlab:cleanup rake tasks' do
end
describe 'cleanup' do
- let(:gitaly_address) { Gitlab.config.repositories.storages.default.gitaly_address }
let(:storages) do
{
- 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage'), 'gitaly_address' => gitaly_address }
+ 'default' => Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/default_storage'))
}
end
+ before(:all) do
+ @default_storage_hash = Gitlab.config.repositories.storages.default.to_h
+ end
+
before do
FileUtils.mkdir(Settings.absolute('tmp/tests/default_storage'))
allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
diff --git a/spec/tasks/gitlab/git_rake_spec.rb b/spec/tasks/gitlab/git_rake_spec.rb
new file mode 100644
index 00000000000..1efaecc63a5
--- /dev/null
+++ b/spec/tasks/gitlab/git_rake_spec.rb
@@ -0,0 +1,41 @@
+require 'rake_helper'
+
+describe 'gitlab:git rake tasks' do
+ before(:all) do
+ @default_storage_hash = Gitlab.config.repositories.storages.default.to_h
+ end
+
+ before do
+ Rake.application.rake_require 'tasks/gitlab/git'
+ storages = { 'default' => Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/default_storage')) }
+
+ FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/1/2/test.git'))
+ allow(Gitlab.config.repositories).to receive(:storages).and_return(storages)
+ allow_any_instance_of(String).to receive(:color) { |string, _color| string }
+
+ stub_warn_user_is_not_gitlab
+ end
+
+ after do
+ FileUtils.rm_rf(Settings.absolute('tmp/tests/default_storage'))
+ end
+
+ describe 'fsck' do
+ it 'outputs the integrity check for a repo' do
+ expect { run_rake_task('gitlab:git:fsck') }.to output(%r{Performed Checking integrity at .*@hashed/1/2/test.git}).to_stdout
+ end
+
+ it 'errors out about config.lock issues' do
+ FileUtils.touch(Settings.absolute('tmp/tests/default_storage/@hashed/1/2/test.git/config.lock'))
+
+ expect { run_rake_task('gitlab:git:fsck') }.to output(/file exists\? ... yes/).to_stdout
+ end
+
+ it 'errors out about ref lock issues' do
+ FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/1/2/test.git/refs/heads'))
+ FileUtils.touch(Settings.absolute('tmp/tests/default_storage/@hashed/1/2/test.git/refs/heads/blah.lock'))
+
+ expect { run_rake_task('gitlab:git:fsck') }.to output(/Ref lock files exist:/).to_stdout
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index 6aba86fdc3c..1e507c0236e 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -76,7 +76,11 @@ describe 'gitlab:gitaly namespace rake task' do
end
context 'when Rails.env is test' do
- let(:command) { %w[make BUNDLE_FLAGS=--no-deployment] }
+ let(:command) do
+ %W[make
+ BUNDLE_FLAGS=--no-deployment
+ BUNDLE_PATH=#{Bundler.bundle_path}]
+ end
before do
allow(Rails.env).to receive(:test?).and_return(true)
@@ -95,14 +99,14 @@ describe 'gitlab:gitaly namespace rake task' do
describe 'storage_config' do
it 'prints storage configuration in a TOML format' do
config = {
- 'default' => {
+ 'default' => Gitlab::GitalyClient::StorageSettings.new(
'path' => '/path/to/default',
'gitaly_address' => 'unix:/path/to/my.socket'
- },
- 'nfs_01' => {
+ ),
+ 'nfs_01' => Gitlab::GitalyClient::StorageSettings.new(
'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)
@@ -128,9 +132,9 @@ describe 'gitlab:gitaly namespace rake task' do
expect { run_rake_task('gitlab:gitaly:storage_config')}
.to output(expected_output).to_stdout
- parsed_output = TOML.parse(expected_output)
+ parsed_output = TomlRB.parse(expected_output)
config.each do |name, params|
- expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params['path'] })
+ expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params.legacy_disk_path })
end
end
end
diff --git a/spec/tasks/gitlab/lfs/check_rake_spec.rb b/spec/tasks/gitlab/lfs/check_rake_spec.rb
new file mode 100644
index 00000000000..2610edf8bac
--- /dev/null
+++ b/spec/tasks/gitlab/lfs/check_rake_spec.rb
@@ -0,0 +1,28 @@
+require 'rake_helper'
+
+describe 'gitlab:lfs rake tasks' do
+ describe 'check' do
+ let!(:lfs_object) { create(:lfs_object, :with_file, :correct_oid) }
+
+ before do
+ Rake.application.rake_require('tasks/gitlab/lfs/check')
+ stub_env('VERBOSE' => 'true')
+ end
+
+ it 'outputs the integrity check for each batch' do
+ expect { run_rake_task('gitlab:lfs:check') }.to output(/Failures: 0/).to_stdout
+ end
+
+ it 'errors out about missing files on the file system' do
+ FileUtils.rm_f(lfs_object.file.path)
+
+ expect { run_rake_task('gitlab:lfs:check') }.to output(/No such file.*#{Regexp.quote(lfs_object.file.path)}/).to_stdout
+ end
+
+ it 'errors out about invalid checksum' do
+ File.truncate(lfs_object.file.path, 0)
+
+ expect { run_rake_task('gitlab:lfs:check') }.to output(/Checksum mismatch/).to_stdout
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/lfs/migrate_rake_spec.rb b/spec/tasks/gitlab/lfs/migrate_rake_spec.rb
new file mode 100644
index 00000000000..66d1a192a96
--- /dev/null
+++ b/spec/tasks/gitlab/lfs/migrate_rake_spec.rb
@@ -0,0 +1,37 @@
+require 'rake_helper'
+
+describe 'gitlab:lfs namespace rake task' do
+ before :all do
+ Rake.application.rake_require 'tasks/gitlab/lfs/migrate'
+ end
+
+ describe 'migrate' do
+ let(:local) { ObjectStorage::Store::LOCAL }
+ let(:remote) { ObjectStorage::Store::REMOTE }
+ let!(:lfs_object) { create(:lfs_object, :with_file, file_store: local) }
+
+ def lfs_migrate
+ run_rake_task('gitlab:lfs:migrate')
+ end
+
+ context 'object storage disabled' do
+ before do
+ stub_lfs_object_storage(enabled: false)
+ end
+
+ it "doesn't migrate files" do
+ expect { lfs_migrate }.not_to change { lfs_object.reload.file_store }
+ end
+ end
+
+ context 'object storage enabled' do
+ before do
+ stub_lfs_object_storage
+ end
+
+ it 'migrates local file to object storage' do
+ expect { lfs_migrate }.to change { lfs_object.reload.file_store }.from(local).to(remote)
+ end
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/shell_rake_spec.rb b/spec/tasks/gitlab/shell_rake_spec.rb
index 65155cb044d..4a756c5742d 100644
--- a/spec/tasks/gitlab/shell_rake_spec.rb
+++ b/spec/tasks/gitlab/shell_rake_spec.rb
@@ -11,7 +11,7 @@ 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'] }
+ storages = Gitlab.config.repositories.storages.values.map(&:legacy_disk_path)
expect(Kernel).to receive(:system).with('bin/install', *storages).and_call_original
expect(Kernel).to receive(:system).with('bin/compile').and_call_original
diff --git a/spec/tasks/gitlab/task_helpers_spec.rb b/spec/tasks/gitlab/task_helpers_spec.rb
index fae5ec35c47..e9322ec4931 100644
--- a/spec/tasks/gitlab/task_helpers_spec.rb
+++ b/spec/tasks/gitlab/task_helpers_spec.rb
@@ -1,5 +1,4 @@
require 'spec_helper'
-require 'tasks/gitlab/task_helpers'
class TestHelpersTest
include Gitlab::TaskHelpers
diff --git a/spec/tasks/gitlab/traces_rake_spec.rb b/spec/tasks/gitlab/traces_rake_spec.rb
new file mode 100644
index 00000000000..bd18e8ffc1e
--- /dev/null
+++ b/spec/tasks/gitlab/traces_rake_spec.rb
@@ -0,0 +1,55 @@
+require 'rake_helper'
+
+describe 'gitlab:traces rake tasks' do
+ before do
+ Rake.application.rake_require 'tasks/gitlab/traces'
+ end
+
+ shared_examples 'passes the job id to worker' do
+ it do
+ expect(ArchiveTraceWorker).to receive(:bulk_perform_async).with([[job.id]])
+
+ run_rake_task('gitlab:traces:archive')
+ end
+ end
+
+ shared_examples 'does not pass the job id to worker' do
+ it do
+ expect(ArchiveTraceWorker).not_to receive(:bulk_perform_async)
+
+ run_rake_task('gitlab:traces:archive')
+ end
+ end
+
+ context 'when trace file stored in default path' do
+ let!(:job) { create(:ci_build, :success, :trace_live) }
+
+ it_behaves_like 'passes the job id to worker'
+ end
+
+ context 'when trace is stored in database' do
+ let!(:job) { create(:ci_build, :success) }
+
+ before do
+ job.update_column(:trace, 'trace in db')
+ end
+
+ it_behaves_like 'passes the job id to worker'
+ end
+
+ context 'when job has trace artifact' do
+ let!(:job) { create(:ci_build, :success) }
+
+ before do
+ create(:ci_job_artifact, :trace, job: job)
+ end
+
+ it_behaves_like 'does not pass the job id to worker'
+ end
+
+ context 'when job is not finished yet' do
+ let!(:build) { create(:ci_build, :running, :trace_live) }
+
+ it_behaves_like 'does not pass the job id to worker'
+ end
+end
diff --git a/spec/tasks/gitlab/uploads/check_rake_spec.rb b/spec/tasks/gitlab/uploads/check_rake_spec.rb
new file mode 100644
index 00000000000..5d597c66133
--- /dev/null
+++ b/spec/tasks/gitlab/uploads/check_rake_spec.rb
@@ -0,0 +1,28 @@
+require 'rake_helper'
+
+describe 'gitlab:uploads rake tasks' do
+ describe 'check' do
+ let!(:upload) { create(:upload, path: Rails.root.join('spec/fixtures/banana_sample.gif')) }
+
+ before do
+ Rake.application.rake_require('tasks/gitlab/uploads/check')
+ stub_env('VERBOSE' => 'true')
+ end
+
+ it 'outputs the integrity check for each batch' do
+ expect { run_rake_task('gitlab:uploads:check') }.to output(/Failures: 0/).to_stdout
+ end
+
+ it 'errors out about missing files on the file system' do
+ missing_upload = create(:upload)
+
+ expect { run_rake_task('gitlab:uploads:check') }.to output(/No such file.*#{Regexp.quote(missing_upload.absolute_path)}/).to_stdout
+ end
+
+ it 'errors out about invalid checksum' do
+ upload.update_column(:checksum, '01a3156db2cf4f67ec823680b40b7302f89ab39179124ad219f94919b8a1769e')
+
+ expect { run_rake_task('gitlab:uploads:check') }.to output(/Checksum mismatch/).to_stdout
+ end
+ end
+end
diff --git a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
new file mode 100644
index 00000000000..6fcfae358ec
--- /dev/null
+++ b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb
@@ -0,0 +1,143 @@
+require 'rake_helper'
+
+describe 'gitlab:uploads:migrate rake tasks' do
+ let(:model_class) { nil }
+ let(:uploader_class) { nil }
+ let(:mounted_as) { nil }
+ let(:batch_size) { 3 }
+
+ before do
+ stub_env('BATCH', batch_size.to_s)
+ stub_uploads_object_storage(uploader_class)
+ Rake.application.rake_require 'tasks/gitlab/uploads/migrate'
+
+ allow(ObjectStorage::MigrateUploadsWorker).to receive(:perform_async)
+ end
+
+ def run
+ args = [uploader_class.to_s, model_class.to_s, mounted_as].compact
+ run_rake_task("gitlab:uploads:migrate", *args)
+ end
+
+ shared_examples 'enqueue jobs in batch' do |batch:|
+ it do
+ expect(ObjectStorage::MigrateUploadsWorker)
+ .to receive(:perform_async).exactly(batch).times
+ .and_return("A fake job.")
+
+ run
+ end
+ end
+
+ context "for AvatarUploader" do
+ let(:uploader_class) { AvatarUploader }
+ let(:mounted_as) { :avatar }
+
+ context "for Project" do
+ let(:model_class) { Project }
+ let!(:projects) { create_list(:project, 10, :with_avatar) }
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+
+ context 'Upload has store = nil' do
+ before do
+ Upload.where(model: projects).update_all(store: nil)
+ end
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+ end
+ end
+
+ context "for Group" do
+ let(:model_class) { Group }
+
+ before do
+ create_list(:group, 10, :with_avatar)
+ end
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+ end
+
+ context "for User" do
+ let(:model_class) { User }
+
+ before do
+ create_list(:user, 10, :with_avatar)
+ end
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+ end
+ end
+
+ context "for AttachmentUploader" do
+ let(:uploader_class) { AttachmentUploader }
+
+ context "for Note" do
+ let(:model_class) { Note }
+ let(:mounted_as) { :attachment }
+
+ before do
+ create_list(:note, 10, :with_attachment)
+ end
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+ end
+
+ context "for Appearance" do
+ let(:model_class) { Appearance }
+ let(:mounted_as) { :logo }
+
+ before do
+ create(:appearance, :with_logos)
+ end
+
+ %i(logo header_logo).each do |mount|
+ it_behaves_like 'enqueue jobs in batch', batch: 1 do
+ let(:mounted_as) { mount }
+ end
+ end
+ end
+ end
+
+ context "for FileUploader" do
+ let(:uploader_class) { FileUploader }
+ let(:model_class) { Project }
+
+ before do
+ create_list(:project, 10) do |model|
+ uploader_class.new(model)
+ .store!(fixture_file_upload('spec/fixtures/doc_sample.txt'))
+ end
+ end
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+ end
+
+ context "for PersonalFileUploader" do
+ let(:uploader_class) { PersonalFileUploader }
+ let(:model_class) { PersonalSnippet }
+
+ before do
+ create_list(:personal_snippet, 10) do |model|
+ uploader_class.new(model)
+ .store!(fixture_file_upload('spec/fixtures/doc_sample.txt'))
+ end
+ end
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+ end
+
+ context "for NamespaceFileUploader" do
+ let(:uploader_class) { NamespaceFileUploader }
+ let(:model_class) { Snippet }
+
+ before do
+ create_list(:snippet, 10) do |model|
+ uploader_class.new(model)
+ .store!(fixture_file_upload('spec/fixtures/doc_sample.txt'))
+ end
+ end
+
+ it_behaves_like 'enqueue jobs in batch', batch: 4
+ end
+end
diff --git a/spec/uploaders/attachment_uploader_spec.rb b/spec/uploaders/attachment_uploader_spec.rb
index 04ee6e9bfad..d302c14efb9 100644
--- a/spec/uploaders/attachment_uploader_spec.rb
+++ b/spec/uploaders/attachment_uploader_spec.rb
@@ -1,28 +1,36 @@
require 'spec_helper'
describe AttachmentUploader do
- let(:uploader) { described_class.new(build_stubbed(:user)) }
+ let(:note) { create(:note, :with_attachment) }
+ let(:uploader) { note.attachment }
+ let(:upload) { create(:upload, :attachment_upload, model: uploader.model) }
- describe "#store_dir" do
- it "stores in the system dir" do
- expect(uploader.store_dir).to start_with("uploads/-/system/user")
- end
+ subject { uploader }
- 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
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[uploads/-/system/note/attachment/],
+ upload_path: %r[uploads/-/system/note/attachment/],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/note/attachment/]
- describe '#move_to_cache' do
- it 'is true' do
- expect(uploader.move_to_cache).to eq(true)
+ context "object_store is REMOTE" do
+ before do
+ stub_uploads_object_storage
end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[note/attachment/],
+ upload_path: %r[note/attachment/]
end
- describe '#move_to_store' do
- it 'is true' do
- expect(uploader.move_to_store).to eq(true)
+ describe "#migrate!" do
+ before do
+ uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/doc_sample.txt')))
+ stub_uploads_object_storage
end
+
+ it_behaves_like "migrates", to_store: described_class::Store::REMOTE
+ it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
end
end
diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb
index 1dc574699d8..b0468bc35ff 100644
--- a/spec/uploaders/avatar_uploader_spec.rb
+++ b/spec/uploaders/avatar_uploader_spec.rb
@@ -1,28 +1,39 @@
require 'spec_helper'
describe AvatarUploader do
- let(:uploader) { described_class.new(build_stubbed(:user)) }
+ let(:model) { build_stubbed(:user) }
+ let(:uploader) { described_class.new(model, :avatar) }
+ let(:upload) { create(:upload, model: model) }
- describe "#store_dir" do
- it "stores in the system dir" do
- expect(uploader.store_dir).to start_with("uploads/-/system/user")
- end
+ subject { uploader }
- 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
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[uploads/-/system/user/avatar/],
+ upload_path: %r[uploads/-/system/user/avatar/],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/user/avatar/]
- describe '#move_to_cache' do
- it 'is false' do
- expect(uploader.move_to_cache).to eq(false)
+ context "object_store is REMOTE" do
+ before do
+ stub_uploads_object_storage
end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[user/avatar/],
+ upload_path: %r[user/avatar/]
end
- describe '#move_to_store' do
- it 'is false' do
- expect(uploader.move_to_store).to eq(false)
+ context "with a file" do
+ let(:project) { create(:project, :with_avatar) }
+ let(:uploader) { project.avatar }
+ let(:upload) { uploader.upload }
+
+ before do
+ stub_uploads_object_storage
end
+
+ it_behaves_like "migrates", to_store: described_class::Store::REMOTE
+ it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
end
end
diff --git a/spec/uploaders/file_mover_spec.rb b/spec/uploaders/file_mover_spec.rb
index 0cf462e9553..68b7e24776d 100644
--- a/spec/uploaders/file_mover_spec.rb
+++ b/spec/uploaders/file_mover_spec.rb
@@ -3,13 +3,13 @@ require 'spec_helper'
describe FileMover do
let(:filename) { 'banana_sample.gif' }
let(:file) { fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) }
+ let(:temp_file_path) { File.join('uploads/-/system/temp', 'secret55', 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)'
+ "test ![banana_sample](/#{temp_file_path}) "\
+ "same ![banana_sample](/#{temp_file_path}) "
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(:file_path) { File.join('uploads/-/system/personal_snippet', snippet.id.to_s, 'secret55', filename) }
let(:snippet) { create(:personal_snippet, description: temp_description) }
subject { described_class.new(file_path, snippet).execute }
@@ -28,14 +28,20 @@ describe FileMover do
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)"
+ "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
+
+ it 'schedules a background migration' do
+ expect_any_instance_of(PersonalFileUploader).to receive(:schedule_background_upload).once
+
+ subject
+ end
end
context 'when update_markdown fails' do
@@ -50,8 +56,8 @@ describe FileMover do
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)"
+ "test ![banana_sample](/uploads/-/system/temp/secret55/banana_sample.gif) "\
+ "same ![banana_sample](/uploads/-/system/temp/secret55/banana_sample.gif) "
)
end
diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb
index fd195d6f9b8..db2810bbe1d 100644
--- a/spec/uploaders/file_uploader_spec.rb
+++ b/spec/uploaders/file_uploader_spec.rb
@@ -1,118 +1,116 @@
require 'spec_helper'
describe FileUploader do
- let(:uploader) { described_class.new(build_stubbed(:project)) }
+ let(:group) { create(:group, name: 'awesome') }
+ let(:project) { create(:project, :legacy_storage, namespace: group, name: 'project') }
+ let(:uploader) { described_class.new(project) }
+ let(:upload) { double(model: project, path: 'secret/foo.jpg') }
+
+ subject { uploader }
+
+ shared_examples 'builds correct legacy storage paths' do
+ include_examples 'builds correct paths',
+ store_dir: %r{awesome/project/\h+},
+ upload_path: %r{\h+/<filename>},
+ absolute_path: %r{#{described_class.root}/awesome/project/secret/foo.jpg}
+ end
context 'legacy storage' do
- let(:project) { build_stubbed(:project) }
-
- describe '.absolute_path' do
- it 'returns the correct absolute path by building it dynamically' do
- upload = double(model: project, path: 'secret/foo.jpg')
+ it_behaves_like 'builds correct legacy storage paths'
- dynamic_segment = project.full_path
+ context 'uses hashed storage' do
+ context 'when rolled out attachments' do
+ let(:project) { build_stubbed(:project, namespace: group, name: 'project') }
- expect(described_class.absolute_path(upload))
- .to end_with("#{dynamic_segment}/secret/foo.jpg")
+ include_examples 'builds correct paths',
+ store_dir: %r{@hashed/\h{2}/\h{2}/\h+},
+ upload_path: %r{\h+/<filename>}
end
- end
- describe "#store_dir" do
- it "stores in the namespace path" do
- uploader = described_class.new(project)
+ context 'when only repositories are rolled out' do
+ let(:project) { build_stubbed(:project, namespace: group, name: 'project', storage_version: Project::HASHED_STORAGE_FEATURES[:repository]) }
- expect(uploader.store_dir).to include(project.full_path)
- expect(uploader.store_dir).not_to include("system")
+ it_behaves_like 'builds correct legacy storage paths'
end
end
end
- context 'hashed storage' do
- context 'when rolled out attachments' do
- let(:project) { build_stubbed(:project, :hashed) }
-
- describe '.absolute_path' do
- it 'returns the correct absolute path by building it dynamically' do
- upload = double(model: project, path: 'secret/foo.jpg')
+ context 'object store is remote' do
+ before do
+ stub_uploads_object_storage
+ end
- dynamic_segment = project.disk_path
+ include_context 'with storage', described_class::Store::REMOTE
- expect(described_class.absolute_path(upload))
- .to end_with("#{dynamic_segment}/secret/foo.jpg")
- end
- end
+ # always use hashed storage path for remote uploads
+ it_behaves_like 'builds correct paths',
+ store_dir: %r{@hashed/\h{2}/\h{2}/\h+},
+ upload_path: %r{@hashed/\h{2}/\h{2}/\h+/\h+/<filename>}
+ end
- describe "#store_dir" do
- it "stores in the namespace path" do
- uploader = described_class.new(project)
+ describe 'initialize' do
+ let(:uploader) { described_class.new(double, secret: 'secret') }
- expect(uploader.store_dir).to include(project.disk_path)
- expect(uploader.store_dir).not_to include("system")
- end
- end
+ it 'accepts a secret parameter' do
+ expect(described_class).not_to receive(:generate_secret)
+ expect(uploader.secret).to eq('secret')
end
+ end
- context 'when only repositories are rolled out' do
- let(:project) { build_stubbed(:project, storage_version: Project::HASHED_STORAGE_FEATURES[:repository]) }
+ describe 'callbacks' do
+ describe '#prune_store_dir after :remove' do
+ before do
+ uploader.store!(fixture_file_upload('spec/fixtures/doc_sample.txt'))
+ end
- describe '.absolute_path' do
- it 'returns the correct absolute path by building it dynamically' do
- upload = double(model: project, path: 'secret/foo.jpg')
+ def store_dir
+ File.expand_path(uploader.store_dir, uploader.root)
+ end
- dynamic_segment = project.full_path
+ it 'is called' do
+ expect(uploader).to receive(:prune_store_dir).once
- expect(described_class.absolute_path(upload))
- .to end_with("#{dynamic_segment}/secret/foo.jpg")
- end
+ uploader.remove!
end
- describe "#store_dir" do
- it "stores in the namespace path" do
- uploader = described_class.new(project)
-
- expect(uploader.store_dir).to include(project.full_path)
- expect(uploader.store_dir).not_to include("system")
- end
+ it 'prune the store directory' do
+ expect { uploader.remove! }
+ .to change { File.exist?(store_dir) }.from(true).to(false)
end
end
end
- describe 'initialize' do
+ describe '#secret' do
it 'generates a secret if none is provided' do
- expect(SecureRandom).to receive(:hex).and_return('secret')
-
- uploader = described_class.new(double)
-
- expect(uploader.secret).to eq 'secret'
- end
-
- it 'accepts a secret parameter' do
- expect(SecureRandom).not_to receive(:hex)
-
- uploader = described_class.new(double, 'secret')
-
- expect(uploader.secret).to eq 'secret'
+ expect(described_class).to receive(:generate_secret).and_return('secret')
+ expect(uploader.secret).to eq('secret')
end
end
- describe '#move_to_cache' do
- it 'is true' do
- expect(uploader.move_to_cache).to eq(true)
+ describe "#migrate!" do
+ before do
+ uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/dk.png')))
+ stub_uploads_object_storage
end
+
+ it_behaves_like "migrates", to_store: described_class::Store::REMOTE
+ it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
end
- describe '#move_to_store' do
- it 'is true' do
- expect(uploader.move_to_store).to eq(true)
+ describe '#upload=' do
+ let(:secret) { SecureRandom.hex }
+ let(:upload) { create(:upload, :issuable_upload, secret: secret, filename: 'file.txt') }
+
+ it 'handles nil' do
+ expect(uploader).not_to receive(:apply_context!)
+
+ uploader.upload = nil
end
- end
- describe '#relative_path' do
- it 'removes the leading dynamic path segment' do
- fixture = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg')
- uploader.store!(fixture_file_upload(fixture))
+ it 'extract the uploader context from it' do
+ expect(uploader).to receive(:apply_context!).with(a_hash_including(secret: secret, identifier: 'file.txt'))
- expect(uploader.relative_path).to match(/\A\h{32}\/rails_sample.jpg\z/)
+ uploader.upload = upload
end
end
end
diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb
index a144b39f74f..4fba122cce1 100644
--- a/spec/uploaders/gitlab_uploader_spec.rb
+++ b/spec/uploaders/gitlab_uploader_spec.rb
@@ -4,7 +4,7 @@ require 'carrierwave/storage/fog'
describe GitlabUploader do
let(:uploader_class) { Class.new(described_class) }
- subject { uploader_class.new }
+ subject { uploader_class.new(double) }
describe '#file_storage?' do
context 'when file storage is used' do
@@ -27,7 +27,7 @@ describe GitlabUploader do
describe '#file_cache_storage?' do
context 'when file storage is used' do
before do
- uploader_class.cache_storage(:file)
+ expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::File }
end
it { is_expected.to be_file_cache_storage }
@@ -35,7 +35,7 @@ describe GitlabUploader do
context 'when is remote storage' do
before do
- uploader_class.cache_storage(:fog)
+ expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::Fog }
end
it { is_expected.not_to be_file_cache_storage }
diff --git a/spec/uploaders/job_artifact_uploader_spec.rb b/spec/uploaders/job_artifact_uploader_spec.rb
index 14fd5f3600f..42036d67f3d 100644
--- a/spec/uploaders/job_artifact_uploader_spec.rb
+++ b/spec/uploaders/job_artifact_uploader_spec.rb
@@ -1,34 +1,64 @@
require 'spec_helper'
describe JobArtifactUploader do
- let(:job_artifact) { create(:ci_job_artifact) }
+ let(:store) { described_class::Store::LOCAL }
+ let(:job_artifact) { create(:ci_job_artifact, file_store: store) }
let(:uploader) { described_class.new(job_artifact, :file) }
- let(:local_path) { Gitlab.config.artifacts.path }
- describe '#store_dir' do
- subject { uploader.store_dir }
+ subject { uploader }
- let(:path) { "#{job_artifact.created_at.utc.strftime('%Y_%m_%d')}/#{job_artifact.project_id}/#{job_artifact.id}" }
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}/\h{64}/\d{4}_\d{1,2}_\d{1,2}/\d+/\d+\z],
+ cache_dir: %r[artifacts/tmp/cache],
+ work_dir: %r[artifacts/tmp/work]
- context 'when using local storage' do
- it { is_expected.to start_with(local_path) }
- it { is_expected.to match(/\h{2}\/\h{2}\/\h{64}\/\d{4}_\d{1,2}_\d{1,2}\/\d+\/\d+\z/) }
- it { is_expected.to end_with(path) }
+ context "object store is REMOTE" do
+ before do
+ stub_artifacts_object_storage
end
- end
- describe '#cache_dir' do
- subject { uploader.cache_dir }
+ include_context 'with storage', described_class::Store::REMOTE
- it { is_expected.to start_with(local_path) }
- it { is_expected.to end_with('/tmp/cache') }
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}/\h{64}/\d{4}_\d{1,2}_\d{1,2}/\d+/\d+\z]
end
- describe '#work_dir' do
- subject { uploader.work_dir }
+ describe '#open' do
+ subject { uploader.open }
+
+ context 'when trace is stored in File storage' do
+ context 'when file exists' do
+ let(:file) do
+ fixture_file_upload(
+ Rails.root.join('spec/fixtures/trace/sample_trace'), 'text/plain')
+ end
+
+ before do
+ uploader.store!(file)
+ end
+
+ it 'returns io stream' do
+ is_expected.to be_a(IO)
+ end
+ end
+
+ context 'when file does not exist' do
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+ end
+
+ context 'when trace is stored in Object storage' do
+ before do
+ allow(uploader).to receive(:file_storage?) { false }
+ allow(uploader).to receive(:url) { 'http://object_storage.com/trace' }
+ end
- it { is_expected.to start_with(local_path) }
- it { is_expected.to end_with('/tmp/work') }
+ it 'returns http io stream' do
+ is_expected.to be_a(Gitlab::Ci::Trace::HttpIO)
+ end
+ end
end
context 'file is stored in valid local_path' do
@@ -43,9 +73,19 @@ describe JobArtifactUploader do
subject { uploader.file.path }
- it { is_expected.to start_with(local_path) }
+ it { is_expected.to start_with("#{uploader.root}/#{uploader.class.base_dir}") }
it { is_expected.to include("/#{job_artifact.created_at.utc.strftime('%Y_%m_%d')}/") }
- it { is_expected.to include("/#{job_artifact.project_id}/") }
+ it { is_expected.to include("/#{job_artifact.job_id}/#{job_artifact.id}/") }
it { is_expected.to end_with("ci_build_artifacts.zip") }
end
+
+ describe "#migrate!" do
+ before do
+ uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/trace/sample_trace')))
+ stub_artifacts_object_storage
+ end
+
+ it_behaves_like "migrates", to_store: described_class::Store::REMOTE
+ it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
+ end
end
diff --git a/spec/uploaders/legacy_artifact_uploader_spec.rb b/spec/uploaders/legacy_artifact_uploader_spec.rb
index efeffb78772..eeb6fd90c9d 100644
--- a/spec/uploaders/legacy_artifact_uploader_spec.rb
+++ b/spec/uploaders/legacy_artifact_uploader_spec.rb
@@ -1,50 +1,35 @@
require 'rails_helper'
describe LegacyArtifactUploader do
- let(:job) { create(:ci_build) }
+ let(:store) { described_class::Store::LOCAL }
+ let(:job) { create(:ci_build, artifacts_file_store: store) }
let(:uploader) { described_class.new(job, :legacy_artifacts_file) }
- let(:local_path) { Gitlab.config.artifacts.path }
+ let(:local_path) { described_class.root }
- describe '.local_store_path' do
- subject { described_class.local_store_path }
+ subject { uploader }
- 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 }
+ # TODO: move to Workhorse::UploadPath
+ describe '.workhorse_upload_path' do
+ subject { described_class.workhorse_upload_path }
it { is_expected.to start_with(local_path) }
- it { is_expected.to end_with('tmp/uploads/') }
+ it { is_expected.to end_with('tmp/uploads') }
end
- describe '#store_dir' do
- subject { uploader.store_dir }
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\d{4}_\d{1,2}/\d+/\d+\z],
+ cache_dir: %r[artifacts/tmp/cache],
+ work_dir: %r[artifacts/tmp/work]
- let(:path) { "#{job.created_at.utc.strftime('%Y_%m')}/#{job.project_id}/#{job.id}" }
-
- context 'when using local storage' do
- it { is_expected.to start_with(local_path) }
- it { is_expected.to end_with(path) }
+ context 'object store is remote' do
+ before do
+ stub_artifacts_object_storage
end
- end
- describe '#cache_dir' do
- subject { uploader.cache_dir }
+ include_context 'with storage', described_class::Store::REMOTE
- it { is_expected.to start_with(local_path) }
- it { is_expected.to end_with('/tmp/cache') }
- end
-
- describe '#work_dir' do
- subject { uploader.work_dir }
-
- it { is_expected.to start_with(local_path) }
- it { is_expected.to end_with('/tmp/work') }
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\d{4}_\d{1,2}/\d+/\d+\z]
end
describe '#filename' do
@@ -69,7 +54,7 @@ describe LegacyArtifactUploader do
subject { uploader.file.path }
- it { is_expected.to start_with(local_path) }
+ it { is_expected.to start_with("#{uploader.root}") }
it { is_expected.to include("/#{job.created_at.utc.strftime('%Y_%m')}/") }
it { is_expected.to include("/#{job.project_id}/") }
it { is_expected.to end_with("ci_build_artifacts.zip") }
diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb
index 7088bc23334..a2fb3886610 100644
--- a/spec/uploaders/lfs_object_uploader_spec.rb
+++ b/spec/uploaders/lfs_object_uploader_spec.rb
@@ -2,39 +2,71 @@ require 'spec_helper'
describe LfsObjectUploader do
let(:lfs_object) { create(:lfs_object, :with_file) }
- let(:uploader) { described_class.new(lfs_object) }
+ let(:uploader) { described_class.new(lfs_object, :file) }
let(:path) { Gitlab.config.lfs.storage_path }
- describe '#move_to_cache' do
- it 'is true' do
- expect(uploader.move_to_cache).to eq(true)
+ subject { uploader }
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}],
+ cache_dir: %r[/lfs-objects/tmp/cache],
+ work_dir: %r[/lfs-objects/tmp/work]
+
+ context "object store is REMOTE" do
+ before do
+ stub_lfs_object_storage
end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}]
end
- describe '#move_to_store' do
- it 'is true' do
- expect(uploader.move_to_store).to eq(true)
+ describe 'migration to object storage' do
+ context 'with object storage disabled' do
+ it "is skipped" do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ lfs_object
+ end
end
- end
- describe '#store_dir' do
- subject { uploader.store_dir }
+ context 'with object storage enabled' do
+ before do
+ stub_lfs_object_storage(background_upload: true)
+ end
+
+ it 'is scheduled to run after creation' do
+ expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with(described_class.name, 'LfsObject', :file, kind_of(Numeric))
- it { is_expected.to start_with(path) }
- it { is_expected.to end_with("#{lfs_object.oid[0, 2]}/#{lfs_object.oid[2, 2]}") }
+ lfs_object
+ end
+ end
end
- describe '#cache_dir' do
- subject { uploader.cache_dir }
+ describe 'remote file' do
+ let(:remote) { described_class::Store::REMOTE }
+ let(:lfs_object) { create(:lfs_object, file_store: remote) }
- it { is_expected.to start_with(path) }
- it { is_expected.to end_with('/tmp/cache') }
- end
+ context 'with object storage enabled' do
+ before do
+ stub_lfs_object_storage
+ end
+
+ it 'can store file remotely' do
+ allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async)
- describe '#work_dir' do
- subject { uploader.work_dir }
+ store_file(lfs_object)
+
+ expect(lfs_object.file_store).to eq remote
+ expect(lfs_object.file.path).not_to be_blank
+ end
+ end
+ end
- it { is_expected.to start_with(path) }
- it { is_expected.to end_with('/tmp/work') }
+ def store_file(lfs_object)
+ lfs_object.file = fixture_file_upload(Rails.root.join("spec/fixtures/dk.png"), "`/png")
+ lfs_object.save!
end
end
diff --git a/spec/uploaders/namespace_file_uploader_spec.rb b/spec/uploaders/namespace_file_uploader_spec.rb
index c6c4500c179..a8ba01d70b8 100644
--- a/spec/uploaders/namespace_file_uploader_spec.rb
+++ b/spec/uploaders/namespace_file_uploader_spec.rb
@@ -1,21 +1,38 @@
require 'spec_helper'
+IDENTIFIER = %r{\h+/\S+}
+
describe NamespaceFileUploader do
let(:group) { build_stubbed(:group) }
let(:uploader) { described_class.new(group) }
+ let(:upload) { create(:upload, :namespace_upload, model: group) }
+
+ subject { uploader }
- describe "#store_dir" do
- it "stores in the namespace id directory" do
- expect(uploader.store_dir).to include(group.id.to_s)
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[uploads/-/system/namespace/\d+],
+ upload_path: IDENTIFIER,
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{IDENTIFIER}]
+
+ context "object_store is REMOTE" do
+ before do
+ stub_uploads_object_storage
end
- end
- describe ".absolute_path" do
- it "stores in thecorrect directory" do
- upload_record = create(:upload, :namespace_upload, model: group)
+ include_context 'with storage', described_class::Store::REMOTE
- expect(described_class.absolute_path(upload_record))
- .to include("-/system/namespace/#{group.id}")
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[namespace/\d+/\h+],
+ upload_path: IDENTIFIER
+ end
+
+ describe "#migrate!" do
+ before do
+ uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/doc_sample.txt')))
+ stub_uploads_object_storage
end
+
+ it_behaves_like "migrates", to_store: described_class::Store::REMOTE
+ it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
end
end
diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb
new file mode 100644
index 00000000000..16455e2517b
--- /dev/null
+++ b/spec/uploaders/object_storage_spec.rb
@@ -0,0 +1,621 @@
+require 'rails_helper'
+require 'carrierwave/storage/fog'
+
+class Implementation < GitlabUploader
+ include ObjectStorage::Concern
+ include ::RecordsUploads::Concern
+ prepend ::ObjectStorage::Extension::RecordsUploads
+
+ storage_options Gitlab.config.uploads
+
+ private
+
+ # user/:id
+ def dynamic_segment
+ File.join(model.class.to_s.underscore, model.id.to_s)
+ end
+end
+
+describe ObjectStorage do
+ let(:uploader_class) { Implementation }
+ let(:object) { build_stubbed(:user) }
+ let(:uploader) { uploader_class.new(object, :file) }
+
+ describe '#object_store=' do
+ before do
+ allow(uploader_class).to receive(:object_store_enabled?).and_return(true)
+ end
+
+ it "reload the local storage" do
+ uploader.object_store = described_class::Store::LOCAL
+ expect(uploader.file_storage?).to be_truthy
+ end
+
+ it "reload the REMOTE storage" do
+ uploader.object_store = described_class::Store::REMOTE
+ expect(uploader.file_storage?).to be_falsey
+ end
+
+ context 'object_store is Store::LOCAL' do
+ before do
+ uploader.object_store = described_class::Store::LOCAL
+ end
+
+ describe '#store_dir' do
+ it 'is the composition of (base_dir, dynamic_segment)' do
+ expect(uploader.store_dir).to start_with("uploads/-/system/user/")
+ end
+ end
+ end
+
+ context 'object_store is Store::REMOTE' do
+ before do
+ uploader.object_store = described_class::Store::REMOTE
+ end
+
+ describe '#store_dir' do
+ it 'is the composition of (dynamic_segment)' do
+ expect(uploader.store_dir).to start_with("user/")
+ end
+ end
+ end
+ end
+
+ describe '#object_store' do
+ subject { uploader.object_store }
+
+ it "delegates to <mount>_store on model" do
+ expect(object).to receive(:file_store)
+
+ subject
+ end
+
+ context 'when store is null' do
+ before do
+ expect(object).to receive(:file_store).and_return(nil)
+ end
+
+ context 'when object storage is enabled' do
+ context 'when direct uploads are enabled' do
+ before do
+ stub_uploads_object_storage(uploader_class, enabled: true, direct_upload: true)
+ end
+
+ it "uses Store::REMOTE" do
+ is_expected.to eq(described_class::Store::REMOTE)
+ end
+ end
+
+ context 'when direct uploads are disabled' do
+ before do
+ stub_uploads_object_storage(uploader_class, enabled: true, direct_upload: false)
+ end
+
+ it "uses Store::LOCAL" do
+ is_expected.to eq(described_class::Store::LOCAL)
+ end
+ end
+ end
+
+ context 'when object storage is disabled' do
+ before do
+ stub_uploads_object_storage(uploader_class, enabled: false)
+ end
+
+ it "uses Store::LOCAL" do
+ is_expected.to eq(described_class::Store::LOCAL)
+ end
+ end
+ end
+
+ context 'when value is set' do
+ before do
+ expect(object).to receive(:file_store).and_return(described_class::Store::REMOTE)
+ end
+
+ it "returns the given value" do
+ is_expected.to eq(described_class::Store::REMOTE)
+ end
+ end
+ end
+
+ describe '#file_cache_storage?' do
+ context 'when file storage is used' do
+ before do
+ expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::File }
+ end
+
+ it { expect(uploader).to be_file_cache_storage }
+ end
+
+ context 'when is remote storage' do
+ before do
+ expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::Fog }
+ end
+
+ it { expect(uploader).not_to be_file_cache_storage }
+ end
+ end
+
+ # this means the model shall include
+ # include RecordsUpload::Concern
+ # prepend ObjectStorage::Extension::RecordsUploads
+ # the object_store persistence is delegated to the `Upload` model.
+ #
+ context 'when persist_object_store? is false' do
+ let(:object) { create(:project, :with_avatar) }
+ let(:uploader) { object.avatar }
+
+ it { expect(object).to be_a(Avatarable) }
+ it { expect(uploader.persist_object_store?).to be_falsey }
+
+ describe 'delegates the object_store logic to the `Upload` model' do
+ it 'sets @upload to the found `upload`' do
+ expect(uploader.upload).to eq(uploader.upload)
+ end
+
+ it 'sets @object_store to the `Upload` value' do
+ expect(uploader.object_store).to eq(uploader.upload.store)
+ end
+ end
+
+ describe '#migrate!' do
+ let(:new_store) { ObjectStorage::Store::REMOTE }
+
+ before do
+ stub_uploads_object_storage(uploader: AvatarUploader)
+ end
+
+ subject { uploader.migrate!(new_store) }
+
+ it 'persist @object_store to the recorded upload' do
+ subject
+
+ expect(uploader.upload.store).to eq(new_store)
+ end
+
+ describe 'fails' do
+ it 'is handled gracefully' do
+ store = uploader.object_store
+ expect_any_instance_of(Upload).to receive(:save!).and_raise("An error")
+
+ expect { subject }.to raise_error("An error")
+ expect(uploader.exists?).to be_truthy
+ expect(uploader.upload.store).to eq(store)
+ end
+ end
+ end
+ end
+
+ # this means the model holds an <mounted_as>_store attribute directly
+ # and do not delegate the object_store persistence to the `Upload` model.
+ #
+ context 'persist_object_store? is true' do
+ context 'when using JobArtifactsUploader' do
+ let(:store) { described_class::Store::LOCAL }
+ let(:object) { create(:ci_job_artifact, :archive, file_store: store) }
+ let(:uploader) { object.file }
+
+ context 'checking described_class' do
+ it "uploader include described_class::Concern" do
+ expect(uploader).to be_a(described_class::Concern)
+ end
+ end
+
+ describe '#use_file' do
+ context 'when file is stored locally' do
+ it "calls a regular path" do
+ expect { |b| uploader.use_file(&b) }.not_to yield_with_args(%r[tmp/cache])
+ end
+ end
+
+ context 'when file is stored remotely' do
+ let(:store) { described_class::Store::REMOTE }
+
+ before do
+ stub_artifacts_object_storage
+ end
+
+ it "calls a cache path" do
+ expect { |b| uploader.use_file(&b) }.to yield_with_args(%r[tmp/cache])
+ end
+ end
+ end
+
+ describe '#migrate!' do
+ subject { uploader.migrate!(new_store) }
+
+ shared_examples "updates the underlying <mounted>_store" do
+ it do
+ subject
+
+ expect(object.file_store).to eq(new_store)
+ end
+ end
+
+ context 'when using the same storage' do
+ let(:new_store) { store }
+
+ it "to not migrate the storage" do
+ subject
+
+ expect(uploader).not_to receive(:store!)
+ expect(uploader.object_store).to eq(store)
+ end
+ end
+
+ context 'when migrating to local storage' do
+ let(:store) { described_class::Store::REMOTE }
+ let(:new_store) { described_class::Store::LOCAL }
+
+ before do
+ stub_artifacts_object_storage
+ end
+
+ include_examples "updates the underlying <mounted>_store"
+
+ it "local file does not exist" do
+ expect(File.exist?(uploader.path)).to eq(false)
+ end
+
+ it "remote file exist" do
+ expect(uploader.file.exists?).to be_truthy
+ end
+
+ it "does migrate the file" do
+ subject
+
+ expect(uploader.object_store).to eq(new_store)
+ expect(File.exist?(uploader.path)).to eq(true)
+ end
+ end
+
+ context 'when migrating to remote storage' do
+ let(:new_store) { described_class::Store::REMOTE }
+ let!(:current_path) { uploader.path }
+
+ it "file does exist" do
+ expect(File.exist?(current_path)).to eq(true)
+ end
+
+ context 'when storage is disabled' do
+ before do
+ stub_artifacts_object_storage(enabled: false)
+ end
+
+ it "to raise an error" do
+ expect { subject }.to raise_error(/Object Storage is not enabled/)
+ end
+ end
+
+ context 'when credentials are set' do
+ before do
+ stub_artifacts_object_storage
+ end
+
+ include_examples "updates the underlying <mounted>_store"
+
+ it "does migrate the file" do
+ subject
+
+ expect(uploader.object_store).to eq(new_store)
+ end
+
+ it "does delete original file" do
+ subject
+
+ expect(File.exist?(current_path)).to eq(false)
+ end
+
+ context 'when subject save fails' do
+ before do
+ expect(uploader).to receive(:persist_object_store!).and_raise(RuntimeError, "exception")
+ end
+
+ it "original file is not removed" do
+ expect { subject }.to raise_error(/exception/)
+
+ expect(File.exist?(current_path)).to eq(true)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe '#fog_directory' do
+ let(:remote_directory) { 'directory' }
+
+ before do
+ allow(uploader_class).to receive(:options) do
+ double(object_store: double(remote_directory: remote_directory))
+ end
+ end
+
+ subject { uploader.fog_directory }
+
+ it { is_expected.to eq(remote_directory) }
+ end
+
+ context 'when file is in use' do
+ def when_file_is_in_use
+ uploader.use_file do
+ yield
+ end
+ end
+
+ it 'cannot migrate' do
+ when_file_is_in_use do
+ expect(uploader).not_to receive(:unsafe_migrate!)
+
+ expect { uploader.migrate!(described_class::Store::REMOTE) }.to raise_error('exclusive lease already taken')
+ end
+ end
+
+ it 'cannot use_file' do
+ when_file_is_in_use do
+ expect(uploader).not_to receive(:unsafe_use_file)
+
+ expect { uploader.use_file }.to raise_error('exclusive lease already taken')
+ end
+ end
+ end
+
+ describe '#fog_credentials' do
+ let(:connection) { Settingslogic.new("provider" => "AWS") }
+
+ before do
+ allow(uploader_class).to receive(:options) do
+ double(object_store: double(connection: connection))
+ end
+ end
+
+ subject { uploader.fog_credentials }
+
+ it { is_expected.to eq(provider: 'AWS') }
+ end
+
+ describe '#fog_public' do
+ subject { uploader.fog_public }
+
+ it { is_expected.to eq(false) }
+ end
+
+ describe '.workhorse_authorize' do
+ subject { uploader_class.workhorse_authorize }
+
+ before do
+ # ensure that we use regular Fog libraries
+ # other tests might call `Fog.mock!` and
+ # it will make tests to fail
+ Fog.unmock!
+ end
+
+ shared_examples 'uses local storage' do
+ it "returns temporary path" do
+ is_expected.to have_key(:TempPath)
+
+ expect(subject[:TempPath]).to start_with(uploader_class.root)
+ expect(subject[:TempPath]).to include(described_class::TMP_UPLOAD_PATH)
+ end
+
+ it "does not return remote store" do
+ is_expected.not_to have_key('RemoteObject')
+ end
+ end
+
+ shared_examples 'uses remote storage' do
+ it "returns remote store" do
+ is_expected.to have_key(:RemoteObject)
+
+ expect(subject[:RemoteObject]).to have_key(:ID)
+ expect(subject[:RemoteObject]).to have_key(:GetURL)
+ expect(subject[:RemoteObject]).to have_key(:DeleteURL)
+ expect(subject[:RemoteObject]).to have_key(:StoreURL)
+ expect(subject[:RemoteObject][:GetURL]).to include(described_class::TMP_UPLOAD_PATH)
+ expect(subject[:RemoteObject][:DeleteURL]).to include(described_class::TMP_UPLOAD_PATH)
+ expect(subject[:RemoteObject][:StoreURL]).to include(described_class::TMP_UPLOAD_PATH)
+ end
+
+ it "does not return local store" do
+ is_expected.not_to have_key('TempPath')
+ end
+ end
+
+ context 'when object storage is disabled' do
+ before do
+ allow(Gitlab.config.uploads.object_store).to receive(:enabled) { false }
+ end
+
+ it_behaves_like 'uses local storage'
+ end
+
+ context 'when object storage is enabled' do
+ before do
+ allow(Gitlab.config.uploads.object_store).to receive(:enabled) { true }
+ end
+
+ context 'when direct upload is enabled' do
+ before do
+ allow(Gitlab.config.uploads.object_store).to receive(:direct_upload) { true }
+ end
+
+ context 'uses AWS' do
+ before do
+ expect(uploader_class).to receive(:object_store_credentials) do
+ { provider: "AWS",
+ aws_access_key_id: "AWS_ACCESS_KEY_ID",
+ aws_secret_access_key: "AWS_SECRET_ACCESS_KEY",
+ region: "eu-central-1" }
+ end
+ end
+
+ it_behaves_like 'uses remote storage' do
+ let(:storage_url) { "https://uploads.s3-eu-central-1.amazonaws.com/" }
+
+ it 'returns links for S3' do
+ expect(subject[:RemoteObject][:GetURL]).to start_with(storage_url)
+ expect(subject[:RemoteObject][:DeleteURL]).to start_with(storage_url)
+ expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url)
+ end
+ end
+ end
+
+ context 'uses Google' do
+ before do
+ expect(uploader_class).to receive(:object_store_credentials) do
+ { provider: "Google",
+ google_storage_access_key_id: 'ACCESS_KEY_ID',
+ google_storage_secret_access_key: 'SECRET_ACCESS_KEY' }
+ end
+ end
+
+ it_behaves_like 'uses remote storage' do
+ let(:storage_url) { "https://storage.googleapis.com/uploads/" }
+
+ it 'returns links for Google Cloud' do
+ expect(subject[:RemoteObject][:GetURL]).to start_with(storage_url)
+ expect(subject[:RemoteObject][:DeleteURL]).to start_with(storage_url)
+ expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url)
+ end
+ end
+ end
+
+ context 'uses GDK/minio' do
+ before do
+ expect(uploader_class).to receive(:object_store_credentials) do
+ { provider: "AWS",
+ aws_access_key_id: "AWS_ACCESS_KEY_ID",
+ aws_secret_access_key: "AWS_SECRET_ACCESS_KEY",
+ endpoint: 'http://127.0.0.1:9000',
+ path_style: true,
+ region: "gdk" }
+ end
+ end
+
+ it_behaves_like 'uses remote storage' do
+ let(:storage_url) { "http://127.0.0.1:9000/uploads/" }
+
+ it 'returns links for S3' do
+ expect(subject[:RemoteObject][:GetURL]).to start_with(storage_url)
+ expect(subject[:RemoteObject][:DeleteURL]).to start_with(storage_url)
+ expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url)
+ end
+ end
+ end
+ end
+
+ context 'when direct upload is disabled' do
+ before do
+ allow(Gitlab.config.uploads.object_store).to receive(:direct_upload) { false }
+ end
+
+ it_behaves_like 'uses local storage'
+ end
+ end
+ end
+
+ describe '#cache!' do
+ subject do
+ uploader.cache!(uploaded_file)
+ end
+
+ context 'when local file is used' do
+ context 'when valid file is used' do
+ let(:uploaded_file) do
+ fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg')
+ end
+
+ it "properly caches the file" do
+ subject
+
+ expect(uploader).to be_exists
+ expect(uploader.path).to start_with(uploader_class.root)
+ expect(uploader.filename).to eq('rails_sample.jpg')
+ end
+ end
+ end
+
+ context 'when remote file is used' do
+ let(:temp_file) { Tempfile.new("test") }
+
+ let!(:fog_connection) do
+ stub_uploads_object_storage(uploader_class)
+ end
+
+ before do
+ FileUtils.touch(temp_file)
+ end
+
+ after do
+ FileUtils.rm_f(temp_file)
+ end
+
+ context 'when valid file is used' do
+ context 'when invalid file is specified' do
+ let(:uploaded_file) do
+ UploadedFile.new(temp_file.path, remote_id: "../test/123123")
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Bad file path/)
+ end
+ end
+
+ context 'when non existing file is specified' do
+ let(:uploaded_file) do
+ UploadedFile.new(temp_file.path, remote_id: "test/123123")
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Missing file/)
+ end
+ end
+
+ context 'when valid file is specified' do
+ let(:uploaded_file) do
+ UploadedFile.new(temp_file.path, filename: "my_file.txt", remote_id: "test/123123")
+ end
+
+ let!(:fog_file) do
+ fog_connection.directories.get('uploads').files.create(
+ key: 'tmp/upload/test/123123',
+ body: 'content'
+ )
+ end
+
+ it 'file to be cached and remote stored' do
+ expect { subject }.not_to raise_error
+
+ expect(uploader).to be_exists
+ expect(uploader).to be_cached
+ expect(uploader.path).not_to be_nil
+ expect(uploader.path).not_to include('tmp/cache')
+ expect(uploader.url).not_to be_nil
+ expect(uploader.path).not_to include('tmp/cache')
+ expect(uploader.object_store).to eq(described_class::Store::REMOTE)
+ end
+
+ context 'when file is stored' do
+ subject do
+ uploader.store!(uploaded_file)
+ end
+
+ it 'file to be remotely stored in permament location' do
+ subject
+
+ expect(uploader).to be_exists
+ expect(uploader).not_to be_cached
+ expect(uploader.path).not_to be_nil
+ expect(uploader.path).not_to include('tmp/upload')
+ expect(uploader.path).not_to include('tmp/cache')
+ expect(uploader.url).to include('/my_file.txt')
+ expect(uploader.object_store).to eq(described_class::Store::REMOTE)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb
index cbafa9f478d..c70521d90dc 100644
--- a/spec/uploaders/personal_file_uploader_spec.rb
+++ b/spec/uploaders/personal_file_uploader_spec.rb
@@ -1,25 +1,39 @@
require 'spec_helper'
+IDENTIFIER = %r{\h+/\S+}
+
describe PersonalFileUploader do
- let(:uploader) { described_class.new(build_stubbed(:project)) }
- let(:snippet) { create(:personal_snippet) }
+ let(:model) { create(:personal_snippet) }
+ let(:uploader) { described_class.new(model) }
+ let(:upload) { create(:upload, :personal_snippet_upload) }
- describe '.absolute_path' do
- it 'returns the correct absolute path by building it dynamically' do
- upload = double(model: snippet, path: 'secret/foo.jpg')
+ subject { uploader }
- dynamic_segment = "personal_snippet/#{snippet.id}"
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[uploads/-/system/personal_snippet/\d+],
+ upload_path: IDENTIFIER,
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{IDENTIFIER}]
- expect(described_class.absolute_path(upload)).to end_with("/-/system/#{dynamic_segment}/secret/foo.jpg")
+ context "object_store is REMOTE" do
+ before do
+ stub_uploads_object_storage
end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[\d+/\h+],
+ upload_path: IDENTIFIER
end
describe '#to_h' do
- it 'returns the hass' do
- uploader = described_class.new(snippet, 'secret')
+ before do
+ subject.instance_variable_set(:@secret, 'secret')
+ end
+ it 'is correct' do
allow(uploader).to receive(:file).and_return(double(extension: 'txt', filename: 'file_name'))
- expected_url = "/uploads/-/system/personal_snippet/#{snippet.id}/secret/file_name"
+ expected_url = "/uploads/-/system/personal_snippet/#{model.id}/secret/file_name"
expect(uploader.to_h).to eq(
alt: 'file_name',
@@ -28,4 +42,14 @@ describe PersonalFileUploader do
)
end
end
+
+ describe "#migrate!" do
+ before do
+ uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/doc_sample.txt')))
+ stub_uploads_object_storage
+ end
+
+ it_behaves_like "migrates", to_store: described_class::Store::REMOTE
+ it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
+ end
end
diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb
index 7ef7fb7d758..9a3e5d83e01 100644
--- a/spec/uploaders/records_uploads_spec.rb
+++ b/spec/uploaders/records_uploads_spec.rb
@@ -3,16 +3,16 @@ require 'rails_helper'
describe RecordsUploads do
let!(:uploader) do
class RecordsUploadsExampleUploader < GitlabUploader
- include RecordsUploads
+ include RecordsUploads::Concern
storage :file
- def model
- FactoryBot.build_stubbed(:user)
+ def dynamic_segment
+ 'co/fe/ee'
end
end
- RecordsUploadsExampleUploader.new
+ RecordsUploadsExampleUploader.new(build_stubbed(:user))
end
def upload_fixture(filename)
@@ -20,48 +20,55 @@ describe RecordsUploads do
end
describe 'callbacks' do
- it 'calls `record_upload` after `store`' do
+ let(:upload) { create(:upload) }
+
+ before do
+ uploader.upload = upload
+ end
+
+ it '#record_upload after `store`' do
expect(uploader).to receive(:record_upload).once
uploader.store!(upload_fixture('doc_sample.txt'))
end
- it 'calls `destroy_upload` after `remove`' do
- expect(uploader).to receive(:destroy_upload).once
-
+ it '#destroy_upload after `remove`' do
uploader.store!(upload_fixture('doc_sample.txt'))
+ expect(uploader).to receive(:destroy_upload).once
uploader.remove!
end
end
describe '#record_upload callback' do
- it 'returns early when not using file storage' do
- allow(uploader).to receive(:file_storage?).and_return(false)
- expect(Upload).not_to receive(:record)
-
- uploader.store!(upload_fixture('rails_sample.jpg'))
+ it 'creates an Upload record after store' do
+ expect { uploader.store!(upload_fixture('rails_sample.jpg')) }.to change { Upload.count }.by(1)
end
- it "returns early when the file doesn't exist" do
- allow(uploader).to receive(:file).and_return(double(exists?: false))
- expect(Upload).not_to receive(:record)
-
+ it 'creates a new record and assigns size, path, model, and uploader' do
uploader.store!(upload_fixture('rails_sample.jpg'))
+
+ upload = uploader.upload
+ aggregate_failures do
+ expect(upload).to be_persisted
+ expect(upload.size).to eq uploader.file.size
+ expect(upload.path).to eq uploader.upload_path
+ expect(upload.model_id).to eq uploader.model.id
+ expect(upload.model_type).to eq uploader.model.class.to_s
+ expect(upload.uploader).to eq uploader.class.to_s
+ end
end
- it 'creates an Upload record after store' do
- expect(Upload).to receive(:record)
- .with(uploader)
+ it "does not create an Upload record when the file doesn't exist" do
+ allow(uploader).to receive(:file).and_return(double(exists?: false))
- uploader.store!(upload_fixture('rails_sample.jpg'))
+ expect { uploader.store!(upload_fixture('rails_sample.jpg')) }.not_to change { Upload.count }
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)
+ allow_any_instance_of(RecordsUploadsExampleUploader).to receive(:model).and_return(nil)
- uploader.store!(upload_fixture('rails_sample.jpg'))
+ expect { uploader.store!(upload_fixture('rails_sample.jpg')) }.not_to change { Upload.count }
end
it 'it destroys Upload records at the same path before recording' do
@@ -72,29 +79,15 @@ describe RecordsUploads do
uploader: uploader.class.to_s
)
+ uploader.upload = existing
uploader.store!(upload_fixture('rails_sample.jpg'))
expect { existing.reload }.to raise_error(ActiveRecord::RecordNotFound)
- expect(Upload.count).to eq 1
+ expect(Upload.count).to eq(1)
end
end
describe '#destroy_upload callback' do
- it 'returns early when not using file storage' do
- uploader.store!(upload_fixture('rails_sample.jpg'))
-
- allow(uploader).to receive(:file_storage?).and_return(false)
- expect(Upload).not_to receive(:remove_path)
-
- uploader.remove!
- end
-
- it 'returns early when file is nil' do
- expect(Upload).not_to receive(:remove_path)
-
- uploader.remove!
- end
-
it 'it destroys Upload records at the same path after removal' do
uploader.store!(upload_fixture('rails_sample.jpg'))
diff --git a/spec/uploaders/workers/object_storage/background_move_worker_spec.rb b/spec/uploaders/workers/object_storage/background_move_worker_spec.rb
new file mode 100644
index 00000000000..b34f427fd8a
--- /dev/null
+++ b/spec/uploaders/workers/object_storage/background_move_worker_spec.rb
@@ -0,0 +1,146 @@
+require 'spec_helper'
+
+describe ObjectStorage::BackgroundMoveWorker do
+ let(:local) { ObjectStorage::Store::LOCAL }
+ let(:remote) { ObjectStorage::Store::REMOTE }
+
+ def perform
+ described_class.perform_async(uploader_class.name, subject_class, file_field, subject_id)
+ end
+
+ context 'for LFS' do
+ let!(:lfs_object) { create(:lfs_object, :with_file, file_store: local) }
+ let(:uploader_class) { LfsObjectUploader }
+ let(:subject_class) { LfsObject }
+ let(:file_field) { :file }
+ let(:subject_id) { lfs_object.id }
+
+ context 'when object storage is enabled' do
+ before do
+ stub_lfs_object_storage(background_upload: true)
+ end
+
+ it 'uploads object to storage' do
+ expect { perform }.to change { lfs_object.reload.file_store }.from(local).to(remote)
+ end
+
+ context 'when background upload is disabled' do
+ before do
+ allow(Gitlab.config.lfs.object_store).to receive(:background_upload) { false }
+ end
+
+ it 'is skipped' do
+ expect { perform }.not_to change { lfs_object.reload.file_store }
+ end
+ end
+ end
+
+ context 'when object storage is disabled' do
+ before do
+ stub_lfs_object_storage(enabled: false)
+ end
+
+ it "doesn't migrate files" do
+ perform
+
+ expect(lfs_object.reload.file_store).to eq(local)
+ end
+ end
+ end
+
+ context 'for legacy artifacts' do
+ let(:build) { create(:ci_build, :legacy_artifacts) }
+ let(:uploader_class) { LegacyArtifactUploader }
+ let(:subject_class) { Ci::Build }
+ let(:file_field) { :artifacts_file }
+ let(:subject_id) { build.id }
+
+ context 'when local storage is used' do
+ let(:store) { local }
+
+ context 'and remote storage is defined' do
+ before do
+ stub_artifacts_object_storage(background_upload: true)
+ end
+
+ it "migrates file to remote storage" do
+ perform
+
+ expect(build.reload.artifacts_file_store).to eq(remote)
+ end
+
+ context 'for artifacts_metadata' do
+ let(:file_field) { :artifacts_metadata }
+
+ it 'migrates metadata to remote storage' do
+ perform
+
+ expect(build.reload.artifacts_metadata_store).to eq(remote)
+ end
+ end
+ end
+ end
+ end
+
+ context 'for job artifacts' do
+ let(:artifact) { create(:ci_job_artifact, :archive) }
+ let(:uploader_class) { JobArtifactUploader }
+ let(:subject_class) { Ci::JobArtifact }
+ let(:file_field) { :file }
+ let(:subject_id) { artifact.id }
+
+ context 'when local storage is used' do
+ let(:store) { local }
+
+ context 'and remote storage is defined' do
+ before do
+ stub_artifacts_object_storage(background_upload: true)
+ end
+
+ it "migrates file to remote storage" do
+ perform
+
+ expect(artifact.reload.file_store).to eq(remote)
+ end
+ end
+ end
+ end
+
+ context 'for uploads' do
+ let!(:project) { create(:project, :with_avatar) }
+ let(:uploader_class) { AvatarUploader }
+ let(:file_field) { :avatar }
+
+ context 'when local storage is used' do
+ let(:store) { local }
+
+ context 'and remote storage is defined' do
+ before do
+ stub_uploads_object_storage(uploader_class, background_upload: true)
+ end
+
+ describe 'supports using the model' do
+ let(:subject_class) { project.class }
+ let(:subject_id) { project.id }
+
+ it "migrates file to remote storage" do
+ perform
+
+ expect(project.reload.avatar.file_storage?).to be_falsey
+ end
+ end
+
+ describe 'supports using the Upload' do
+ let(:subject_class) { Upload }
+ let(:subject_id) { project.avatar.upload.id }
+
+ it "migrates file to remote storage" do
+ perform
+
+ expect(project.reload.avatar.file_storage?).to be_falsey
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb b/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb
new file mode 100644
index 00000000000..7a7dcb71680
--- /dev/null
+++ b/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb
@@ -0,0 +1,119 @@
+require 'spec_helper'
+
+describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
+ shared_context 'sanity_check! fails' do
+ before do
+ expect(described_class).to receive(:sanity_check!).and_raise(described_class::SanityCheckError)
+ end
+ end
+
+ let!(:projects) { create_list(:project, 10, :with_avatar) }
+ let(:uploads) { Upload.all }
+ let(:model_class) { Project }
+ let(:mounted_as) { :avatar }
+ let(:to_store) { ObjectStorage::Store::REMOTE }
+
+ before do
+ stub_uploads_object_storage(AvatarUploader)
+ end
+
+ describe '.enqueue!' do
+ def enqueue!
+ described_class.enqueue!(uploads, Project, mounted_as, to_store)
+ end
+
+ it 'is guarded by .sanity_check!' do
+ expect(described_class).to receive(:perform_async)
+ expect(described_class).to receive(:sanity_check!)
+
+ enqueue!
+ end
+
+ context 'sanity_check! fails' do
+ include_context 'sanity_check! fails'
+
+ it 'does not enqueue a job' do
+ expect(described_class).not_to receive(:perform_async)
+
+ expect { enqueue! }.to raise_error(described_class::SanityCheckError)
+ end
+ end
+ end
+
+ describe '.sanity_check!' do
+ shared_examples 'raises a SanityCheckError' do
+ let(:mount_point) { nil }
+
+ it do
+ expect { described_class.sanity_check!(uploads, model_class, mount_point) }
+ .to raise_error(described_class::SanityCheckError)
+ end
+ end
+
+ context 'uploader types mismatch' do
+ let!(:outlier) { create(:upload, uploader: 'FileUploader') }
+
+ include_examples 'raises a SanityCheckError'
+ end
+
+ context 'model types mismatch' do
+ let!(:outlier) { create(:upload, model_type: 'Potato') }
+
+ include_examples 'raises a SanityCheckError'
+ end
+
+ context 'mount point not found' do
+ include_examples 'raises a SanityCheckError' do
+ let(:mount_point) { :potato }
+ end
+ end
+ end
+
+ describe '#perform' do
+ def perform
+ described_class.new.perform(uploads.ids, model_class.to_s, mounted_as, to_store)
+ rescue ObjectStorage::MigrateUploadsWorker::Report::MigrationFailures
+ # swallow
+ end
+
+ shared_examples 'outputs correctly' do |success: 0, failures: 0|
+ total = success + failures
+
+ if success > 0
+ it 'outputs the reports' do
+ expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
+
+ perform
+ end
+ end
+
+ if failures > 0
+ it 'outputs upload failures' do
+ expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/)
+
+ perform
+ end
+ end
+ end
+
+ it_behaves_like 'outputs correctly', success: 10
+
+ it 'migrates files' do
+ perform
+
+ aggregate_failures do
+ projects.each do |project|
+ expect(project.reload.avatar.upload.local?).to be_falsey
+ end
+ end
+ end
+
+ context 'migration is unsuccessful' do
+ before do
+ allow_any_instance_of(ObjectStorage::Concern).to receive(:migrate!).and_raise(CarrierWave::UploadError, "I am a teapot.")
+ end
+
+ it_behaves_like 'outputs correctly', failures: 10
+ end
+ end
+end
diff --git a/spec/validators/url_placeholder_validator_spec.rb b/spec/validators/url_placeholder_validator_spec.rb
new file mode 100644
index 00000000000..b76d8acdf88
--- /dev/null
+++ b/spec/validators/url_placeholder_validator_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe UrlPlaceholderValidator do
+ let(:validator) { described_class.new(attributes: [:link_url], **options) }
+ let!(:badge) { build(:badge) }
+ let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' }
+
+ subject { validator.validate_each(badge, :link_url, badge.link_url) }
+
+ describe '#validates_each' do
+ context 'with no options' do
+ let(:options) { {} }
+
+ it 'allows http and https protocols by default' do
+ expect(validator.send(:default_options)[:protocols]).to eq %w(http https)
+ end
+
+ it 'checks that the url structure is valid' do
+ badge.link_url = placeholder_url
+
+ subject
+
+ expect(badge.errors.empty?).to be false
+ end
+ end
+
+ context 'with placeholder regex' do
+ let(:options) { { placeholder_regex: /(project_path|project_id|commit_sha|default_branch)/ } }
+
+ it 'checks that the url is valid and obviate placeholders that match regex' do
+ badge.link_url = placeholder_url
+
+ subject
+
+ expect(badge.errors.empty?).to be true
+ end
+ end
+ end
+end
diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb
new file mode 100644
index 00000000000..763dff181d2
--- /dev/null
+++ b/spec/validators/url_validator_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe UrlValidator do
+ let(:validator) { described_class.new(attributes: [:link_url], **options) }
+ let!(:badge) { build(:badge) }
+
+ subject { validator.validate_each(badge, :link_url, badge.link_url) }
+
+ describe '#validates_each' do
+ context 'with no options' do
+ let(:options) { {} }
+
+ it 'allows http and https protocols by default' do
+ expect(validator.send(:default_options)[:protocols]).to eq %w(http https)
+ end
+
+ it 'checks that the url structure is valid' do
+ badge.link_url = 'http://www.google.es/%{whatever}'
+
+ subject
+
+ expect(badge.errors.empty?).to be false
+ end
+ end
+
+ context 'with protocols' do
+ let(:options) { { protocols: %w(http) } }
+
+ it 'allows urls with the defined protocols' do
+ badge.link_url = 'http://www.example.com'
+
+ subject
+
+ expect(badge.errors.empty?).to be true
+ end
+
+ it 'add error if the url protocol does not match the selected ones' do
+ badge.link_url = 'https://www.example.com'
+
+ subject
+
+ expect(badge.errors.empty?).to be false
+ end
+ end
+ end
+end
diff --git a/spec/validators/user_path_validator_spec.rb b/spec/validators/user_path_validator_spec.rb
deleted file mode 100644
index a46089cc24f..00000000000
--- a/spec/validators/user_path_validator_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-require 'spec_helper'
-
-describe UserPathValidator do
- let(:validator) { described_class.new(attributes: [:username]) }
-
- describe '.valid_path?' do
- it 'handles invalid utf8' do
- expect(described_class.valid_path?("a\0weird\255path")).to be_falsey
- end
- end
-
- describe '#validates_each' do
- it 'adds a message when the path is not in the correct format' do
- user = build(:user)
-
- validator.validate_each(user, :username, "Path with spaces, and comma's!")
-
- expect(user.errors[:username]).to include(Gitlab::PathRegex.namespace_format_message)
- end
-
- it 'adds a message when the path is reserved when creating' do
- user = build(:user, username: 'help')
-
- validator.validate_each(user, :username, 'help')
-
- expect(user.errors[:username]).to include('help is a reserved name')
- end
-
- it 'adds a message when the path is reserved when updating' do
- user = create(:user)
- user.username = 'help'
-
- validator.validate_each(user, :username, 'help')
-
- expect(user.errors[:username]).to include('help is a reserved name')
- end
- end
-end
diff --git a/spec/validators/variable_duplicates_validator_spec.rb b/spec/validators/variable_duplicates_validator_spec.rb
new file mode 100644
index 00000000000..0b71a67f94d
--- /dev/null
+++ b/spec/validators/variable_duplicates_validator_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe VariableDuplicatesValidator do
+ let(:validator) { described_class.new(attributes: [:variables], **options) }
+
+ describe '#validate_each' do
+ let(:project) { build(:project) }
+
+ subject { validator.validate_each(project, :variables, project.variables) }
+
+ context 'with no scope' do
+ let(:options) { {} }
+ let(:variables) { build_list(:ci_variable, 2, project: project) }
+
+ before do
+ project.variables << variables
+ end
+
+ it 'does not have any errors' do
+ subject
+
+ expect(project.errors.empty?).to be true
+ end
+
+ context 'with duplicates' do
+ before do
+ project.variables.build(key: variables.first.key, value: 'dummy_value')
+ end
+
+ it 'has a duplicate key error' do
+ subject
+
+ expect(project.errors).to have_key(:variables)
+ end
+ end
+ end
+
+ context 'with a scope attribute' do
+ let(:options) { { scope: :environment_scope } }
+ let(:first_variable) { build(:ci_variable, key: 'test_key', environment_scope: '*', project: project) }
+ let(:second_variable) { build(:ci_variable, key: 'test_key', environment_scope: 'prod', project: project) }
+
+ before do
+ project.variables << first_variable
+ project.variables << second_variable
+ end
+
+ it 'does not have any errors' do
+ subject
+
+ expect(project.errors.empty?).to be true
+ end
+
+ context 'with duplicates' do
+ before do
+ project.variables.build(key: second_variable.key, value: 'dummy_value', environment_scope: second_variable.environment_scope)
+ end
+
+ it 'has a duplicate key error' do
+ subject
+
+ expect(project.errors).to have_key(:variables)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/views/ci/lints/show.html.haml_spec.rb b/spec/views/ci/lints/show.html.haml_spec.rb
deleted file mode 100644
index 7724d54c569..00000000000
--- a/spec/views/ci/lints/show.html.haml_spec.rb
+++ /dev/null
@@ -1,97 +0,0 @@
-require 'spec_helper'
-
-describe 'ci/lints/show' do
- include Devise::Test::ControllerHelpers
-
- describe 'XSS protection' do
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(content)) }
- before do
- assign(:status, true)
- assign(:builds, config_processor.builds)
- assign(:stages, config_processor.stages)
- assign(:jobs, config_processor.jobs)
- end
-
- context 'when builds attrbiutes contain HTML nodes' do
- let(:content) do
- {
- rspec: {
- script: '<h1>rspec</h1>',
- stage: 'test'
- }
- }
- end
-
- it 'does not render HTML elements' do
- render
-
- expect(rendered).not_to have_css('h1', text: 'rspec')
- end
- end
-
- context 'when builds attributes do not contain HTML nodes' do
- let(:content) do
- {
- rspec: {
- script: 'rspec',
- stage: 'test'
- }
- }
- end
-
- it 'shows configuration in the table' do
- render
-
- expect(rendered).to have_css('td pre', text: 'rspec')
- end
- end
- end
-
- let(:content) do
- {
- build_template: {
- script: './build.sh',
- tags: ['dotnet'],
- only: ['test@dude/repo'],
- except: ['deploy'],
- environment: 'testing'
- }
- }
- end
-
- let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(content)) }
-
- context 'when the content is valid' do
- before do
- assign(:status, true)
- assign(:builds, config_processor.builds)
- assign(:stages, config_processor.stages)
- assign(:jobs, config_processor.jobs)
- end
-
- it 'shows the correct values' do
- render
-
- expect(rendered).to have_content('Tag list: dotnet')
- expect(rendered).to have_content('Only policy: refs, test@dude/repo')
- expect(rendered).to have_content('Except policy: refs, deploy')
- expect(rendered).to have_content('Environment: testing')
- expect(rendered).to have_content('When: on_success')
- end
- end
-
- context 'when the content is invalid' do
- before do
- assign(:status, false)
- assign(:error, 'Undefined error')
- end
-
- it 'shows error message' do
- render
-
- expect(rendered).to have_content('Status: syntax is incorrect')
- expect(rendered).to have_content('Error: Undefined error')
- expect(rendered).not_to have_content('Tag list:')
- end
- end
-end
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index c5f455b8948..f28bf430f02 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -12,7 +12,7 @@ describe 'layouts/nav/sidebar/_project' do
end
describe 'issue boards' do
- it 'has boards tab when multiple issue boards available' do
+ it 'has board tab' do
render
expect(rendered).to have_css('a[title="Board"]')
diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb
index 62af946dcab..15fce65979b 100644
--- a/spec/views/projects/_home_panel.html.haml_spec.rb
+++ b/spec/views/projects/_home_panel.html.haml_spec.rb
@@ -1,7 +1,8 @@
require 'spec_helper'
describe 'projects/_home_panel' do
- let(:project) { create(:project, :public) }
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
let(:notification_settings) do
user&.notification_settings_for(project)
@@ -35,4 +36,55 @@ describe 'projects/_home_panel' do
expect(rendered).not_to have_selector('.notification_dropdown')
end
end
+
+ context 'when project' do
+ let!(:user) { create(:user) }
+ let(:badges) { project.badges }
+
+ context 'has no badges' do
+ it 'should not render any badge' do
+ render
+
+ expect(rendered).to have_selector('.project-badges')
+ expect(rendered).not_to have_selector('.project-badges > a')
+ end
+ end
+
+ shared_examples 'show badges' do
+ it 'should render the all badges' do
+ render
+
+ expect(rendered).to have_selector('.project-badges a')
+
+ badges.each do |badge|
+ expect(rendered).to have_link(href: badge.rendered_link_url)
+ end
+ end
+ end
+
+ context 'only has group badges' do
+ before do
+ create(:group_badge, group: project.group)
+ end
+
+ it_behaves_like 'show badges'
+ end
+
+ context 'only has project badges' do
+ before do
+ create(:project_badge, project: project)
+ end
+
+ it_behaves_like 'show badges'
+ end
+
+ context 'has both group and project badges' do
+ before do
+ create(:project_badge, project: project)
+ create(:group_badge, group: project.group)
+ end
+
+ it_behaves_like 'show badges'
+ end
+ end
end
diff --git a/spec/views/projects/buttons/_dropdown.html.haml_spec.rb b/spec/views/projects/buttons/_dropdown.html.haml_spec.rb
new file mode 100644
index 00000000000..d0e692635b9
--- /dev/null
+++ b/spec/views/projects/buttons/_dropdown.html.haml_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe 'projects/buttons/_dropdown' do
+ let(:user) { create(:user) }
+
+ context 'user with all abilities' do
+ before do
+ assign(:project, project)
+
+ allow(view).to receive(:current_user).and_return(user)
+ allow(view).to receive(:can?).and_return(true)
+ end
+
+ context 'empty repository' do
+ let(:project) { create(:project, :empty_repo) }
+
+ it 'has a link to create a new file' do
+ render
+
+ expect(view).to render_template('projects/buttons/_dropdown')
+ expect(rendered).to have_link('New file')
+ end
+
+ it 'does not have a link to create a new branch' do
+ render
+
+ expect(view).to render_template('projects/buttons/_dropdown')
+ expect(rendered).not_to have_link('New branch')
+ end
+
+ it 'does not have a link to create a new tag' do
+ render
+
+ expect(view).to render_template('projects/buttons/_dropdown')
+ expect(rendered).not_to have_link('New tag')
+ end
+ end
+ end
+end
diff --git a/spec/views/projects/ci/lints/show.html.haml_spec.rb b/spec/views/projects/ci/lints/show.html.haml_spec.rb
new file mode 100644
index 00000000000..2f0cd38c14a
--- /dev/null
+++ b/spec/views/projects/ci/lints/show.html.haml_spec.rb
@@ -0,0 +1,99 @@
+require 'spec_helper'
+
+describe 'projects/ci/lints/show' do
+ include Devise::Test::ControllerHelpers
+ let(:project) { create(:project, :repository) }
+ let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(content)) }
+
+ describe 'XSS protection' do
+ before do
+ assign(:project, project)
+ assign(:status, true)
+ assign(:builds, config_processor.builds)
+ assign(:stages, config_processor.stages)
+ assign(:jobs, config_processor.jobs)
+ end
+
+ context 'when builds attrbiutes contain HTML nodes' do
+ let(:content) do
+ {
+ rspec: {
+ script: '<h1>rspec</h1>',
+ stage: 'test'
+ }
+ }
+ end
+
+ it 'does not render HTML elements' do
+ render
+
+ expect(rendered).not_to have_css('h1', text: 'rspec')
+ end
+ end
+
+ context 'when builds attributes do not contain HTML nodes' do
+ let(:content) do
+ {
+ rspec: {
+ script: 'rspec',
+ stage: 'test'
+ }
+ }
+ end
+
+ it 'shows configuration in the table' do
+ render
+
+ expect(rendered).to have_css('td pre', text: 'rspec')
+ end
+ end
+ end
+
+ context 'when the content is valid' do
+ let(:content) do
+ {
+ build_template: {
+ script: './build.sh',
+ tags: ['dotnet'],
+ only: ['test@dude/repo'],
+ except: ['deploy'],
+ environment: 'testing'
+ }
+ }
+ end
+
+ before do
+ assign(:project, project)
+ assign(:status, true)
+ assign(:builds, config_processor.builds)
+ assign(:stages, config_processor.stages)
+ assign(:jobs, config_processor.jobs)
+ end
+
+ it 'shows the correct values' do
+ render
+
+ expect(rendered).to have_content('Tag list: dotnet')
+ expect(rendered).to have_content('Only policy: refs, test@dude/repo')
+ expect(rendered).to have_content('Except policy: refs, deploy')
+ expect(rendered).to have_content('Environment: testing')
+ expect(rendered).to have_content('When: on_success')
+ end
+ end
+
+ context 'when the content is invalid' do
+ before do
+ assign(:project, project)
+ assign(:status, false)
+ assign(:error, 'Undefined error')
+ end
+
+ it 'shows error message' do
+ render
+
+ expect(rendered).to have_content('Status: syntax is incorrect')
+ expect(rendered).to have_content('Error: Undefined error')
+ expect(rendered).not_to have_content('Tag list:')
+ end
+ end
+end
diff --git a/spec/views/projects/diffs/_stats.html.haml_spec.rb b/spec/views/projects/diffs/_stats.html.haml_spec.rb
new file mode 100644
index 00000000000..c7d2f85747c
--- /dev/null
+++ b/spec/views/projects/diffs/_stats.html.haml_spec.rb
@@ -0,0 +1,56 @@
+require 'spec_helper'
+
+describe 'projects/diffs/_stats.html.haml' do
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
+
+ def render_view
+ render partial: "projects/diffs/stats", locals: { diff_files: commit.diffs.diff_files }
+ end
+
+ context 'when the commit contains several changes' do
+ it 'uses plural for additions' do
+ render_view
+
+ expect(rendered).to have_text('additions')
+ end
+
+ it 'uses plural for deletions' do
+ render_view
+ end
+ end
+
+ context 'when the commit contains no addition and no deletions' do
+ let(:commit) { project.commit('4cd80ccab63c82b4bad16faa5193fbd2aa06df40') }
+
+ it 'uses plural for additions' do
+ render_view
+
+ expect(rendered).to have_text('additions')
+ end
+
+ it 'uses plural for deletions' do
+ render_view
+
+ expect(rendered).to have_text('deletions')
+ end
+ end
+
+ context 'when the commit contains exactly one addition and one deletion' do
+ let(:commit) { project.commit('08f22f255f082689c0d7d39d19205085311542bc') }
+
+ it 'uses singular for additions' do
+ render_view
+
+ expect(rendered).to have_text('addition')
+ expect(rendered).not_to have_text('additions')
+ end
+
+ it 'uses singular for deletions' do
+ render_view
+
+ expect(rendered).to have_text('deletion')
+ expect(rendered).not_to have_text('deletions')
+ 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
index 9b293065797..ec435ec3b32 100644
--- a/spec/views/projects/imports/new.html.haml_spec.rb
+++ b/spec/views/projects/imports/new.html.haml_spec.rb
@@ -8,7 +8,7 @@ describe "projects/imports/new.html.haml" do
before do
sign_in(user)
- project.team << [user, :master]
+ project.add_master(user)
end
it "escapes HTML in import errors" do
diff --git a/spec/views/projects/jobs/show.html.haml_spec.rb b/spec/views/projects/jobs/show.html.haml_spec.rb
index 6a67da79ec5..c93152b88e3 100644
--- a/spec/views/projects/jobs/show.html.haml_spec.rb
+++ b/spec/views/projects/jobs/show.html.haml_spec.rb
@@ -1,8 +1,10 @@
require 'spec_helper'
describe 'projects/jobs/show' do
+ let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:builds) { project.builds.present(current_user: user) }
let(:pipeline) do
create(:ci_pipeline, project: project, sha: project.commit.id)
@@ -11,6 +13,7 @@ describe 'projects/jobs/show' do
before do
assign(:build, build.present)
assign(:project, project)
+ assign(:builds, builds)
allow(view).to receive(:can?).and_return(true)
end
@@ -18,7 +21,7 @@ describe 'projects/jobs/show' do
describe 'environment info in job view' do
context 'job with latest deployment' do
let(:build) do
- create(:ci_build, :success, environment: 'staging')
+ create(:ci_build, :success, :trace_artifact, environment: 'staging')
end
before do
@@ -37,11 +40,11 @@ describe 'projects/jobs/show' do
context 'job with outdated deployment' do
let(:build) do
- create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
+ create(:ci_build, :success, :trace_artifact, environment: 'staging', pipeline: pipeline)
end
let(:second_build) do
- create(:ci_build, :success, environment: 'staging', pipeline: pipeline)
+ create(:ci_build, :success, :trace_artifact, environment: 'staging', pipeline: pipeline)
end
let(:environment) do
@@ -67,7 +70,7 @@ describe 'projects/jobs/show' do
context 'job failed to deploy' do
let(:build) do
- create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
+ create(:ci_build, :failed, :trace_artifact, environment: 'staging', pipeline: pipeline)
end
let!(:environment) do
@@ -85,7 +88,7 @@ describe 'projects/jobs/show' do
context 'job will deploy' do
let(:build) do
- create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
+ create(:ci_build, :running, :trace_live, environment: 'staging', pipeline: pipeline)
end
context 'when environment exists' do
@@ -133,7 +136,7 @@ describe 'projects/jobs/show' do
context 'job that failed to deploy and environment has not been created' do
let(:build) do
- create(:ci_build, :failed, environment: 'staging', pipeline: pipeline)
+ create(:ci_build, :failed, :trace_artifact, environment: 'staging', pipeline: pipeline)
end
let!(:environment) do
@@ -151,7 +154,7 @@ describe 'projects/jobs/show' do
context 'job that will deploy and environment has not been created' do
let(:build) do
- create(:ci_build, :running, environment: 'staging', pipeline: pipeline)
+ create(:ci_build, :running, :trace_live, environment: 'staging', pipeline: pipeline)
end
let!(:environment) do
@@ -171,8 +174,9 @@ describe 'projects/jobs/show' do
end
context 'when job is running' do
+ let(:build) { create(:ci_build, :trace_live, :running, pipeline: pipeline) }
+
before do
- build.run!
render
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 3ca67114558..b1c6565c08a 100644
--- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
@@ -28,6 +28,6 @@ describe 'projects/merge_requests/_commits.html.haml' do
commit = merge_request.commits.first # HEAD
href = diffs_project_merge_request_path(target_project, merge_request, commit_id: commit)
- expect(rendered).to have_link(Commit.truncate_sha(commit.sha), href: href)
+ expect(rendered).to have_link(href: href)
end
end
diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb
index 28d54c2fb77..264e0ce0b40 100644
--- a/spec/views/projects/merge_requests/show.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb
@@ -54,6 +54,8 @@ describe 'projects/merge_requests/show.html.haml' do
it 'closes the merge request if the source project does not exist' do
closed_merge_request.update_attributes(state: 'open')
forked_project.destroy
+ # Reload merge request so MergeRequest#source_project turns to `nil`
+ closed_merge_request.reload
render
diff --git a/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb b/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb
new file mode 100644
index 00000000000..6e7d8db99c4
--- /dev/null
+++ b/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb
@@ -0,0 +1,47 @@
+require 'spec_helper'
+
+describe 'projects/pipeline_schedules/_pipeline_schedule' do
+ let(:owner) { create(:user) }
+ let(:master) { create(:user) }
+ let(:project) { create(:project) }
+ let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project) }
+
+ before do
+ assign(:project, project)
+
+ allow(view).to receive(:current_user).and_return(user)
+ allow(view).to receive(:pipeline_schedule).and_return(pipeline_schedule)
+
+ allow(view).to receive(:can?).and_return(true)
+ end
+
+ context 'taking ownership of schedule' do
+ context 'when non-owner is signed in' do
+ let(:user) { master }
+
+ before do
+ allow(view).to receive(:can?).with(master, :take_ownership_pipeline_schedule, pipeline_schedule).and_return(true)
+ end
+
+ it 'non-owner can take ownership of pipeline' do
+ render
+
+ expect(rendered).to have_link('Take ownership')
+ end
+ end
+
+ context 'when owner is signed in' do
+ let(:user) { owner }
+
+ before do
+ allow(view).to receive(:can?).with(owner, :take_ownership_pipeline_schedule, pipeline_schedule).and_return(false)
+ end
+
+ it 'owner cannot take ownership of pipeline' do
+ render
+
+ expect(rendered).not_to have_link('Take ownership')
+ end
+ end
+ end
+end
diff --git a/spec/views/projects/pipelines_settings/_show.html.haml_spec.rb b/spec/views/projects/pipelines_settings/_show.html.haml_spec.rb
deleted file mode 100644
index 95f0be49412..00000000000
--- a/spec/views/projects/pipelines_settings/_show.html.haml_spec.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-require 'spec_helper'
-
-describe 'projects/pipelines_settings/_show' do
- let(:project) { create(:project, :repository) }
-
- before do
- assign :project, project
- end
-
- context 'when kubernetes is not active' do
- context 'when auto devops domain is not defined' do
- it 'shows warning message' do
- render
-
- expect(rendered).to have_css('.settings-message')
- expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name and the')
- expect(rendered).to have_link('Kubernetes service')
- end
- end
-
- context 'when auto devops domain is defined' do
- before do
- project.build_auto_devops(domain: 'example.com')
- end
-
- it 'shows warning message' do
- render
-
- expect(rendered).to have_css('.settings-message')
- expect(rendered).to have_text('Auto Review Apps and Auto Deploy need the')
- expect(rendered).to have_link('Kubernetes service')
- end
- end
- end
-
- context 'when kubernetes is active' do
- before do
- create(:kubernetes_service, project: project)
- end
-
- context 'when auto devops domain is not defined' do
- it 'shows warning message' do
- render
-
- expect(rendered).to have_css('.settings-message')
- expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name to work correctly.')
- end
- end
-
- context 'when auto devops domain is defined' do
- before do
- project.build_auto_devops(domain: 'example.com')
- end
-
- it 'does not show warning message' do
- render
-
- expect(rendered).not_to have_css('.settings-message')
- end
- end
- end
-end
diff --git a/spec/views/projects/services/_form.haml_spec.rb b/spec/views/projects/services/_form.haml_spec.rb
new file mode 100644
index 00000000000..85167bca115
--- /dev/null
+++ b/spec/views/projects/services/_form.haml_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe 'projects/services/_form' do
+ let(:project) { create(:redmine_project) }
+ let(:user) { create(:admin) }
+
+ before do
+ assign(:project, project)
+
+ allow(controller).to receive(:current_user).and_return(user)
+
+ allow(view).to receive_messages(current_user: user,
+ can?: true,
+ current_application_settings: Gitlab::CurrentSettings.current_application_settings)
+ end
+
+ context 'commit_events and merge_request_events' do
+ before do
+ assign(:service, project.redmine_service)
+ end
+
+ it 'display merge_request_events and commit_events descriptions' do
+ allow(RedmineService).to receive(:supported_events).and_return(%w(commit merge_request))
+
+ render
+
+ expect(rendered).to have_content('Event will be triggered when a commit is created/updated')
+ expect(rendered).to have_content('Event will be triggered when a merge request is created/updated/merged')
+ end
+
+ context 'when service is JIRA' do
+ let(:project) { create(:jira_project) }
+
+ before do
+ assign(:service, project.jira_service)
+ end
+
+ it 'display merge_request_events and commit_events descriptions' do
+ render
+
+ expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a commit.')
+ expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a merge request.')
+ end
+ end
+ end
+end
diff --git a/spec/views/projects/settings/ci_cd/_form.html.haml_spec.rb b/spec/views/projects/settings/ci_cd/_form.html.haml_spec.rb
new file mode 100644
index 00000000000..be9a4d9c57c
--- /dev/null
+++ b/spec/views/projects/settings/ci_cd/_form.html.haml_spec.rb
@@ -0,0 +1,62 @@
+require 'spec_helper'
+
+describe 'projects/settings/ci_cd/_form' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ assign :project, project
+ end
+
+ context 'when kubernetes is not active' do
+ context 'when auto devops domain is not defined' do
+ it 'shows warning message' do
+ render
+
+ expect(rendered).to have_css('.settings-message')
+ expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name and a')
+ expect(rendered).to have_link('Kubernetes cluster')
+ end
+ end
+
+ context 'when auto devops domain is defined' do
+ before do
+ project.build_auto_devops(domain: 'example.com')
+ end
+
+ it 'shows warning message' do
+ render
+
+ expect(rendered).to have_css('.settings-message')
+ expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a')
+ expect(rendered).to have_link('Kubernetes cluster')
+ end
+ end
+ end
+
+ context 'when kubernetes is active' do
+ before do
+ create(:kubernetes_service, project: project)
+ end
+
+ context 'when auto devops domain is not defined' do
+ it 'shows warning message' do
+ render
+
+ expect(rendered).to have_css('.settings-message')
+ expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name to work correctly.')
+ end
+ end
+
+ context 'when auto devops domain is defined' do
+ before do
+ project.build_auto_devops(domain: 'example.com')
+ end
+
+ it 'does not show warning message' do
+ render
+
+ expect(rendered).not_to have_css('.settings-message')
+ end
+ end
+ 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 44b32df0395..3b098320ad7 100644
--- a/spec/views/projects/tree/show.html.haml_spec.rb
+++ b/spec/views/projects/tree/show.html.haml_spec.rb
@@ -13,6 +13,7 @@ describe 'projects/tree/show' do
allow(view).to receive(:can?).and_return(true)
allow(view).to receive(:can_collaborate_with_project?).and_return(true)
+ allow(view).to receive_message_chain('user_access.can_push_to_branch?').and_return(true)
allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings)
end
diff --git a/spec/views/shared/notes/_form.html.haml_spec.rb b/spec/views/shared/notes/_form.html.haml_spec.rb
index cae6bee2776..50980718e66 100644
--- a/spec/views/shared/notes/_form.html.haml_spec.rb
+++ b/spec/views/shared/notes/_form.html.haml_spec.rb
@@ -7,7 +7,7 @@ describe 'shared/notes/_form' do
let(:project) { create(:project, :repository) }
before do
- project.team << [user, :master]
+ project.add_master(user)
assign(:project, project)
assign(:note, note)
diff --git a/spec/views/shared/projects/_project.html.haml_spec.rb b/spec/views/shared/projects/_project.html.haml_spec.rb
index f0a4f153699..3b14045e61f 100644
--- a/spec/views/shared/projects/_project.html.haml_spec.rb
+++ b/spec/views/shared/projects/_project.html.haml_spec.rb
@@ -5,6 +5,7 @@ describe 'shared/projects/_project.html.haml' do
before do
allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings)
+ allow(view).to receive(:can?) { true }
end
it 'should render creator avatar if project has a creator' do
diff --git a/spec/workers/archive_trace_worker_spec.rb b/spec/workers/archive_trace_worker_spec.rb
new file mode 100644
index 00000000000..b768588c6e1
--- /dev/null
+++ b/spec/workers/archive_trace_worker_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe ArchiveTraceWorker do
+ describe '#perform' do
+ subject { described_class.new.perform(job&.id) }
+
+ context 'when job is found' do
+ let(:job) { create(:ci_build) }
+
+ it 'executes service' do
+ expect_any_instance_of(Gitlab::Ci::Trace).to receive(:archive!)
+
+ subject
+ end
+ end
+
+ context 'when job is not found' do
+ let(:job) { nil }
+
+ it 'does not execute service' do
+ expect_any_instance_of(Gitlab::Ci::Trace).not_to receive(:archive!)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/workers/authorized_projects_worker_spec.rb b/spec/workers/authorized_projects_worker_spec.rb
index 0d6eb536c33..d095138f6b7 100644
--- a/spec/workers/authorized_projects_worker_spec.rb
+++ b/spec/workers/authorized_projects_worker_spec.rb
@@ -1,79 +1,6 @@
require 'spec_helper'
describe AuthorizedProjectsWorker do
- let(:project) { create(:project) }
-
- def build_args_list(*ids, multiply: 1)
- args_list = ids.map { |id| [id] }
- args_list * multiply
- end
-
- describe '.bulk_perform_and_wait' do
- it 'schedules the ids and waits for the jobs to complete' do
- args_list = build_args_list(project.owner.id)
-
- project.owner.project_authorizations.delete_all
- described_class.bulk_perform_and_wait(args_list)
-
- expect(project.owner.project_authorizations.count).to eq(1)
- end
-
- it 'inlines workloads <= 3 jobs' do
- args_list = build_args_list(project.owner.id, multiply: 3)
- expect(described_class).to receive(:bulk_perform_inline).with(args_list)
-
- described_class.bulk_perform_and_wait(args_list)
- end
-
- it 'runs > 3 jobs using sidekiq' do
- project.owner.project_authorizations.delete_all
-
- expect(described_class).to receive(:bulk_perform_async).and_call_original
-
- args_list = build_args_list(project.owner.id, multiply: 4)
- described_class.bulk_perform_and_wait(args_list)
-
- expect(project.owner.project_authorizations.count).to eq(1)
- end
- end
-
- describe '.bulk_perform_inline' do
- it 'refreshes the authorizations inline' do
- project.owner.project_authorizations.delete_all
-
- expect_any_instance_of(described_class).to receive(:perform).and_call_original
-
- described_class.bulk_perform_inline(build_args_list(project.owner.id))
-
- expect(project.owner.project_authorizations.count).to eq(1)
- end
-
- it 'enqueues jobs if an error is raised' do
- invalid_id = -1
- args_list = build_args_list(project.owner.id, invalid_id)
-
- allow_any_instance_of(described_class).to receive(:perform).with(project.owner.id)
- allow_any_instance_of(described_class).to receive(:perform).with(invalid_id).and_raise(ArgumentError)
- expect(described_class).to receive(:bulk_perform_async).with(build_args_list(invalid_id))
-
- described_class.bulk_perform_inline(args_list)
- end
- end
-
- describe '.bulk_perform_async' do
- it "uses it's respective sidekiq queue" do
- args_list = build_args_list(project.owner.id)
- push_bulk_args = {
- 'class' => described_class,
- 'args' => args_list
- }
-
- expect(Sidekiq::Client).to receive(:push_bulk).with(push_bulk_args).once
-
- described_class.bulk_perform_async(args_list)
- end
- end
-
describe '#perform' do
let(:user) { create(:user) }
@@ -85,12 +12,6 @@ describe AuthorizedProjectsWorker do
job.perform(user.id)
end
- it 'notifies the JobWaiter when done if the key is provided' do
- expect(Gitlab::JobWaiter).to receive(:notify).with('notify-key', job.jid)
-
- job.perform(user.id, 'notify-key')
- end
-
context "when the user is not found" do
it "does nothing" do
expect_any_instance_of(User).not_to receive(:refresh_authorized_projects)
diff --git a/spec/workers/background_migration_worker_spec.rb b/spec/workers/background_migration_worker_spec.rb
index 1c54cf55fa0..d67e7698635 100644
--- a/spec/workers/background_migration_worker_spec.rb
+++ b/spec/workers/background_migration_worker_spec.rb
@@ -1,13 +1,32 @@
require 'spec_helper'
-describe BackgroundMigrationWorker, :sidekiq do
+describe BackgroundMigrationWorker, :sidekiq, :clean_gitlab_redis_shared_state do
+ let(:worker) { described_class.new }
+
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])
+ worker.perform('Foo', [10, 20])
+ end
+
+ it 'reschedules a migration if it was performed recently' do
+ expect(worker)
+ .to receive(:always_perform?)
+ .and_return(false)
+
+ worker.lease_for('Foo').try_obtain
+
+ expect(Gitlab::BackgroundMigration)
+ .not_to receive(:perform)
+
+ expect(described_class)
+ .to receive(:perform_in)
+ .with(a_kind_of(Numeric), 'Foo', [10, 20])
+
+ worker.perform('Foo', [10, 20])
end
end
end
diff --git a/spec/workers/build_finished_worker_spec.rb b/spec/workers/build_finished_worker_spec.rb
index 1a7ffd5cdbf..acd8da11d8d 100644
--- a/spec/workers/build_finished_worker_spec.rb
+++ b/spec/workers/build_finished_worker_spec.rb
@@ -6,17 +6,15 @@ describe BuildFinishedWorker do
let!(:build) { create(:ci_build) }
it 'calculates coverage and calls hooks' do
- expect(BuildCoverageWorker)
+ expect(BuildTraceSectionsWorker)
.to receive(:new).ordered.and_call_original
- expect(BuildHooksWorker)
+ expect(BuildCoverageWorker)
.to receive(:new).ordered.and_call_original
- expect(BuildTraceSectionsWorker)
- .to receive(:perform_async)
- expect_any_instance_of(BuildCoverageWorker)
- .to receive(:perform)
- expect_any_instance_of(BuildHooksWorker)
- .to receive(:perform)
+ expect_any_instance_of(BuildTraceSectionsWorker).to receive(:perform)
+ expect_any_instance_of(BuildCoverageWorker).to receive(:perform)
+ expect(BuildHooksWorker).to receive(:perform_async)
+ expect(ArchiveTraceWorker).to receive(:perform_async)
described_class.new.perform(build.id)
end
diff --git a/spec/workers/check_gcp_project_billing_worker_spec.rb b/spec/workers/check_gcp_project_billing_worker_spec.rb
new file mode 100644
index 00000000000..526ecf75921
--- /dev/null
+++ b/spec/workers/check_gcp_project_billing_worker_spec.rb
@@ -0,0 +1,116 @@
+require 'spec_helper'
+
+describe CheckGcpProjectBillingWorker do
+ describe '.perform' do
+ let(:token) { 'bogustoken' }
+
+ subject { described_class.new.perform('token_key') }
+
+ before do
+ allow(described_class).to receive(:get_billing_state)
+ allow_any_instance_of(described_class).to receive(:update_billing_change_counter)
+ end
+
+ context 'when there is a token in redis' do
+ before do
+ allow(described_class).to receive(:get_session_token).and_return(token)
+ end
+
+ context 'when there is no lease' do
+ before do
+ allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return('randomuuid')
+ end
+
+ it 'calls the service' do
+ expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
+
+ subject
+ end
+
+ it 'stores billing status in redis' do
+ expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
+ expect(described_class).to receive(:set_billing_state).with(token, true)
+
+ subject
+ end
+ end
+
+ context 'when there is a lease' do
+ before do
+ allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return(false)
+ end
+
+ it 'does not call the service' do
+ expect(CheckGcpProjectBillingService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+
+ context 'when there is no token in redis' do
+ before do
+ allow(described_class).to receive(:get_session_token).and_return(nil)
+ end
+
+ it 'does not call the service' do
+ expect(CheckGcpProjectBillingService).not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+
+ describe 'billing change counter' do
+ subject { described_class.new.perform('token_key') }
+
+ before do
+ allow(described_class).to receive(:get_session_token).and_return('bogustoken')
+ allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return('randomuuid')
+ allow(described_class).to receive(:set_billing_state)
+ end
+
+ context 'when previous state was false' do
+ before do
+ expect(described_class).to receive(:get_billing_state).and_return(false)
+ end
+
+ context 'when the current state is false' do
+ before do
+ expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([])
+ end
+
+ it 'increments the billing change counter' do
+ expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment)
+
+ subject
+ end
+ end
+
+ context 'when the current state is true' do
+ before do
+ expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
+ end
+
+ it 'increments the billing change counter' do
+ expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment)
+
+ subject
+ end
+ end
+ end
+
+ context 'when previous state was true' do
+ before do
+ expect(described_class).to receive(:get_billing_state).and_return(true)
+ expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
+ end
+
+ it 'increment the billing change counter' do
+ expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment)
+
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/workers/cluster_wait_for_ingress_ip_address_worker_spec.rb b/spec/workers/cluster_wait_for_ingress_ip_address_worker_spec.rb
new file mode 100644
index 00000000000..2e2e9afd25a
--- /dev/null
+++ b/spec/workers/cluster_wait_for_ingress_ip_address_worker_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+describe ClusterWaitForIngressIpAddressWorker do
+ describe '#perform' do
+ let(:service) { instance_double(Clusters::Applications::CheckIngressIpAddressService, execute: true) }
+ let(:application) { instance_double(Clusters::Applications::Ingress) }
+ let(:worker) { described_class.new }
+
+ before do
+ allow(worker)
+ .to receive(:find_application)
+ .with('ingress', 117)
+ .and_yield(application)
+
+ allow(Clusters::Applications::CheckIngressIpAddressService)
+ .to receive(:new)
+ .with(application)
+ .and_return(service)
+
+ allow(described_class)
+ .to receive(:perform_in)
+ end
+
+ it 'finds the application and calls CheckIngressIpAddressService#execute' do
+ worker.perform('ingress', 117)
+
+ expect(service).to have_received(:execute)
+ end
+ end
+end
diff --git a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
index 68cfe9d5545..615462380e0 100644
--- a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
@@ -25,7 +25,7 @@ describe Gitlab::GithubImport::ObjectImporter do
importer_class = double(:importer_class)
importer_instance = double(:importer_instance)
representation = double(:representation)
- project = double(:project, path_with_namespace: 'foo/bar')
+ project = double(:project, full_path: 'foo/bar')
client = double(:client)
expect(worker)
diff --git a/spec/workers/concerns/pipeline_background_queue_spec.rb b/spec/workers/concerns/pipeline_background_queue_spec.rb
new file mode 100644
index 00000000000..24c0a3c6a20
--- /dev/null
+++ b/spec/workers/concerns/pipeline_background_queue_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe PipelineBackgroundQueue do
+ let(:worker) do
+ Class.new do
+ def self.name
+ 'DummyWorker'
+ end
+
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+ end
+ end
+
+ it 'sets a default object storage queue automatically' do
+ expect(worker.sidekiq_options['queue'])
+ .to eq 'pipeline_background:dummy'
+ end
+end
diff --git a/spec/workers/concerns/waitable_worker_spec.rb b/spec/workers/concerns/waitable_worker_spec.rb
new file mode 100644
index 00000000000..54ab07981a4
--- /dev/null
+++ b/spec/workers/concerns/waitable_worker_spec.rb
@@ -0,0 +1,98 @@
+require 'spec_helper'
+
+describe WaitableWorker do
+ let(:worker) do
+ Class.new do
+ def self.name
+ 'Gitlab::Foo::Bar::DummyWorker'
+ end
+
+ class << self
+ cattr_accessor(:counter) { 0 }
+ end
+
+ include ApplicationWorker
+ prepend WaitableWorker
+
+ # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore
+ # the visibility of prepended modules. See
+ # https://github.com/rspec/rspec-mocks/issues/1231 for more details.
+ def self.bulk_perform_inline(args_list)
+ end
+
+ def perform(i = 0)
+ self.class.counter += i
+ end
+ end
+ end
+
+ subject(:job) { worker.new }
+
+ describe '.bulk_perform_and_wait' do
+ it 'schedules the jobs and waits for them to complete' do
+ worker.bulk_perform_and_wait([[1], [2]])
+
+ expect(worker.counter).to eq(3)
+ end
+
+ it 'inlines workloads <= 3 jobs' do
+ args_list = [[1], [2], [3]]
+ expect(worker).to receive(:bulk_perform_inline).with(args_list).and_call_original
+
+ worker.bulk_perform_and_wait(args_list)
+
+ expect(worker.counter).to eq(6)
+ end
+
+ it 'runs > 3 jobs using sidekiq' do
+ expect(worker).to receive(:bulk_perform_async)
+
+ worker.bulk_perform_and_wait([[1], [2], [3], [4]])
+ end
+ end
+
+ describe '.bulk_perform_inline' do
+ it 'runs the jobs inline' do
+ expect(worker).not_to receive(:bulk_perform_async)
+
+ worker.bulk_perform_inline([[1], [2]])
+
+ expect(worker.counter).to eq(3)
+ end
+
+ it 'enqueues jobs if an error is raised' do
+ expect(worker).to receive(:bulk_perform_async).with([['foo']])
+
+ worker.bulk_perform_inline([[1], ['foo']])
+ end
+ end
+
+ describe '#perform' do
+ shared_examples 'perform' do
+ it 'notifies the JobWaiter when done if the key is provided' do
+ key = Gitlab::JobWaiter.new.key
+ expect(Gitlab::JobWaiter).to receive(:notify).with(key, job.jid)
+
+ job.perform(*args, key)
+ end
+
+ it 'does not notify the JobWaiter when done if no key is provided' do
+ expect(Gitlab::JobWaiter).not_to receive(:notify)
+
+ job.perform(*args)
+ end
+ end
+
+ context 'when the worker takes arguments' do
+ let(:args) { [1] }
+
+ it_behaves_like 'perform'
+ end
+
+ context 'when the worker takes no arguments' do
+ let(:args) { [] }
+
+ it_behaves_like 'perform'
+ end
+ end
+end
diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb
index 47297de738b..74539a7e493 100644
--- a/spec/workers/git_garbage_collect_worker_spec.rb
+++ b/spec/workers/git_garbage_collect_worker_spec.rb
@@ -195,6 +195,12 @@ describe GitGarbageCollectWorker do
expect(File.exist?(bitmap_path(after_packs.first))).to eq(bitmaps_enabled)
end
+
+ it 'cleans up repository after finishing' do
+ expect_any_instance_of(Project).to receive(:cleanup).and_call_original
+
+ subject.perform(project.id, 'gc', lease_key, lease_uuid)
+ end
end
context 'with bitmaps enabled' do
diff --git a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
index 7c8c665a9b3..48e7eaf32fc 100644
--- a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
@@ -5,7 +5,7 @@ describe Gitlab::GithubImport::ImportDiffNoteWorker do
describe '#import' do
it 'imports a diff note' do
- project = double(:project, path_with_namespace: 'foo/bar')
+ project = double(:project, full_path: 'foo/bar')
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
index 4116380ff4d..8cf6ac15919 100644
--- a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
@@ -5,7 +5,7 @@ describe Gitlab::GithubImport::ImportIssueWorker do
describe '#import' do
it 'imports an issue' do
- project = double(:project, path_with_namespace: 'foo/bar')
+ project = double(:project, full_path: 'foo/bar')
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/gitlab/github_import/import_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_note_worker_spec.rb
index 0ca825a722b..677697c02df 100644
--- a/spec/workers/gitlab/github_import/import_note_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_note_worker_spec.rb
@@ -5,7 +5,7 @@ describe Gitlab::GithubImport::ImportNoteWorker do
describe '#import' do
it 'imports a note' do
- project = double(:project, path_with_namespace: 'foo/bar')
+ project = double(:project, full_path: 'foo/bar')
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
index d49f560af42..e287ddbe0d7 100644
--- a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
@@ -5,7 +5,7 @@ describe Gitlab::GithubImport::ImportPullRequestWorker do
describe '#import' do
it 'imports a pull request' do
- project = double(:project, path_with_namespace: 'foo/bar')
+ project = double(:project, full_path: 'foo/bar')
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/gitlab_shell_worker_spec.rb b/spec/workers/gitlab_shell_worker_spec.rb
new file mode 100644
index 00000000000..6b222af454d
--- /dev/null
+++ b/spec/workers/gitlab_shell_worker_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+
+describe GitlabShellWorker do
+ let(:worker) { described_class.new }
+
+ describe '#perform with add_key' do
+ it 'calls add_key on Gitlab::Shell' do
+ expect_any_instance_of(Gitlab::Shell).to receive(:add_key).with('foo', 'bar')
+ worker.perform(:add_key, 'foo', 'bar')
+ end
+ end
+end
diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb
index 303193bab9b..c861a56497e 100644
--- a/spec/workers/merge_worker_spec.rb
+++ b/spec/workers/merge_worker_spec.rb
@@ -8,7 +8,7 @@ describe MergeWorker do
let!(:author) { merge_request.author }
before do
- source_project.team << [author, :master]
+ source_project.add_master(author)
source_project.repository.expire_branches_cache
end
diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb
index ed8cedc0079..479d9396eca 100644
--- a/spec/workers/namespaceless_project_destroy_worker_spec.rb
+++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb
@@ -22,7 +22,9 @@ describe NamespacelessProjectDestroyWorker do
end
end
- context 'project has no namespace' do
+ # Only possible with schema 20180222043024 and lower.
+ # Project#namespace_id has not null constraint since then
+ context 'project has no namespace', :migration, schema: 20180222043024 do
let!(:project) do
project = build(:project, namespace_id: nil)
project.save(validate: false)
diff --git a/spec/workers/new_issue_worker_spec.rb b/spec/workers/new_issue_worker_spec.rb
index 4e15ccc534b..baa8ddb59e5 100644
--- a/spec/workers/new_issue_worker_spec.rb
+++ b/spec/workers/new_issue_worker_spec.rb
@@ -44,8 +44,9 @@ describe NewIssueWorker 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))
+ it 'creates a notification for the mentioned user' do
+ expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id, NotificationReason::MENTIONED)
+ .and_return(double(deliver_later: true))
worker.perform(issue.id, user.id)
end
diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb
index 9e0cbde45b1..c3f29a40d58 100644
--- a/spec/workers/new_merge_request_worker_spec.rb
+++ b/spec/workers/new_merge_request_worker_spec.rb
@@ -46,8 +46,10 @@ describe NewMergeRequestWorker 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))
+ it 'creates a notification for the mentioned user' do
+ expect(Notify).to receive(:new_merge_request_email)
+ .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
+ .and_return(double(deliver_later: true))
worker.perform(merge_request.id, user.id)
end
diff --git a/spec/workers/object_storage_upload_worker_spec.rb b/spec/workers/object_storage_upload_worker_spec.rb
new file mode 100644
index 00000000000..32ddcbe9757
--- /dev/null
+++ b/spec/workers/object_storage_upload_worker_spec.rb
@@ -0,0 +1,108 @@
+require 'spec_helper'
+
+describe ObjectStorageUploadWorker do
+ let(:local) { ObjectStorage::Store::LOCAL }
+ let(:remote) { ObjectStorage::Store::REMOTE }
+
+ def perform
+ described_class.perform_async(uploader_class.name, subject_class, file_field, subject_id)
+ end
+
+ context 'for LFS' do
+ let!(:lfs_object) { create(:lfs_object, :with_file, file_store: local) }
+ let(:uploader_class) { LfsObjectUploader }
+ let(:subject_class) { LfsObject }
+ let(:file_field) { :file }
+ let(:subject_id) { lfs_object.id }
+
+ context 'when object storage is enabled' do
+ before do
+ stub_lfs_object_storage(background_upload: true)
+ end
+
+ it 'uploads object to storage' do
+ expect { perform }.to change { lfs_object.reload.file_store }.from(local).to(remote)
+ end
+
+ context 'when background upload is disabled' do
+ before do
+ allow(Gitlab.config.lfs.object_store).to receive(:background_upload) { false }
+ end
+
+ it 'is skipped' do
+ expect { perform }.not_to change { lfs_object.reload.file_store }
+ end
+ end
+ end
+
+ context 'when object storage is disabled' do
+ before do
+ stub_lfs_object_storage(enabled: false)
+ end
+
+ it "doesn't migrate files" do
+ perform
+
+ expect(lfs_object.reload.file_store).to eq(local)
+ end
+ end
+ end
+
+ context 'for legacy artifacts' do
+ let(:build) { create(:ci_build, :legacy_artifacts) }
+ let(:uploader_class) { LegacyArtifactUploader }
+ let(:subject_class) { Ci::Build }
+ let(:file_field) { :artifacts_file }
+ let(:subject_id) { build.id }
+
+ context 'when local storage is used' do
+ let(:store) { local }
+
+ context 'and remote storage is defined' do
+ before do
+ stub_artifacts_object_storage(background_upload: true)
+ end
+
+ it "migrates file to remote storage" do
+ perform
+
+ expect(build.reload.artifacts_file_store).to eq(remote)
+ end
+
+ context 'for artifacts_metadata' do
+ let(:file_field) { :artifacts_metadata }
+
+ it 'migrates metadata to remote storage' do
+ perform
+
+ expect(build.reload.artifacts_metadata_store).to eq(remote)
+ end
+ end
+ end
+ end
+ end
+
+ context 'for job artifacts' do
+ let(:artifact) { create(:ci_job_artifact, :archive) }
+ let(:uploader_class) { JobArtifactUploader }
+ let(:subject_class) { Ci::JobArtifact }
+ let(:file_field) { :file }
+ let(:subject_id) { artifact.id }
+
+ context 'when local storage is used' do
+ let(:store) { local }
+
+ context 'and remote storage is defined' do
+ before do
+ stub_artifacts_object_storage(background_upload: true)
+ end
+
+ it "migrates file to remote storage" do
+ perform
+
+ expect(artifact.reload.file_store).to eq(remote)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/pages_domain_verification_cron_worker_spec.rb b/spec/workers/pages_domain_verification_cron_worker_spec.rb
new file mode 100644
index 00000000000..8f780428c82
--- /dev/null
+++ b/spec/workers/pages_domain_verification_cron_worker_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+describe PagesDomainVerificationCronWorker do
+ subject(:worker) { described_class.new }
+
+ describe '#perform' do
+ it 'enqueues a PagesDomainVerificationWorker for domains needing verification' do
+ verified = create(:pages_domain)
+ reverify = create(:pages_domain, :reverify)
+ disabled = create(:pages_domain, :disabled)
+
+ [reverify, disabled].each do |domain|
+ expect(PagesDomainVerificationWorker).to receive(:perform_async).with(domain.id)
+ end
+
+ expect(PagesDomainVerificationWorker).not_to receive(:perform_async).with(verified.id)
+
+ worker.perform
+ end
+ end
+end
diff --git a/spec/workers/pages_domain_verification_worker_spec.rb b/spec/workers/pages_domain_verification_worker_spec.rb
new file mode 100644
index 00000000000..372fc95ab4a
--- /dev/null
+++ b/spec/workers/pages_domain_verification_worker_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe PagesDomainVerificationWorker do
+ subject(:worker) { described_class.new }
+
+ let(:domain) { create(:pages_domain) }
+
+ describe '#perform' do
+ it 'does nothing for a non-existent domain' do
+ domain.destroy
+
+ expect(VerifyPagesDomainService).not_to receive(:new)
+
+ expect { worker.perform(domain.id) }.not_to raise_error
+ end
+
+ it 'delegates to VerifyPagesDomainService' do
+ service = double(:service)
+ expected_domain = satisfy { |obj| obj == domain }
+
+ expect(VerifyPagesDomainService).to receive(:new).with(expected_domain) { service }
+ expect(service).to receive(:execute)
+
+ worker.perform(domain.id)
+ end
+ end
+end
diff --git a/spec/workers/plugin_worker_spec.rb b/spec/workers/plugin_worker_spec.rb
new file mode 100644
index 00000000000..9238a8199bc
--- /dev/null
+++ b/spec/workers/plugin_worker_spec.rb
@@ -0,0 +1,25 @@
+require 'spec_helper'
+
+describe PluginWorker do
+ include RepoHelpers
+
+ let(:filename) { 'my_plugin.rb' }
+ let(:data) { { 'event_name' => 'project_create' } }
+
+ subject { described_class.new }
+
+ describe '#perform' do
+ it 'executes Gitlab::Plugin with expected values' do
+ allow(Gitlab::Plugin).to receive(:execute).with(filename, data).and_return([true, ''])
+
+ expect(subject.perform(filename, data)).to be_truthy
+ end
+
+ it 'logs message in case of plugin execution failure' do
+ allow(Gitlab::Plugin).to receive(:execute).with(filename, data).and_return([false, 'permission denied'])
+
+ expect(Gitlab::PluginLogger).to receive(:error)
+ expect(subject.perform(filename, data)).to be_truthy
+ end
+ end
+end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index 5d9b0679796..cd6661f09a1 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -114,6 +114,18 @@ describe PostReceive do
end
end
+ describe '#process_wiki_changes' do
+ let(:gl_repository) { "wiki-#{project.id}" }
+
+ it 'updates project activity' do
+ described_class.new.perform(gl_repository, key_id, base64_changes)
+
+ expect { project.reload }
+ .to change(project, :last_activity_at)
+ .and change(project, :last_repository_updated_at)
+ end
+ end
+
context "webhook" do
it "fetches the correct project" do
expect(Project).to receive(:find_by).with(id: project.id.to_s)
diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb
index 24f8ca67594..ac79d9c0ac1 100644
--- a/spec/workers/process_commit_worker_spec.rb
+++ b/spec/workers/process_commit_worker_spec.rb
@@ -47,12 +47,18 @@ describe ProcessCommitWorker do
describe '#process_commit_message' do
context 'when pushing to the default branch' do
+ before do
+ allow(commit).to receive(:safe_message).and_return("Closes #{issue.to_reference}")
+ end
+
it 'closes issues that should be closed per the commit message' do
- allow(commit).to receive(:safe_message)
- .and_return("Closes #{issue.to_reference}")
+ expect(worker).to receive(:close_issues).with(project, user, user, commit, [issue])
+
+ worker.process_commit_message(project, commit, user, user, true)
+ end
- expect(worker).to receive(:close_issues)
- .with(project, user, user, commit, [issue])
+ it 'creates cross references' do
+ expect(commit).to receive(:create_cross_references!).with(user, [issue])
worker.process_commit_message(project, commit, user, user, true)
end
@@ -60,19 +66,50 @@ 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)
worker.process_commit_message(project, commit, user, user, false)
end
+
+ it 'does not create cross references' do
+ expect(commit).to receive(:create_cross_references!).with(user, [])
+
+ worker.process_commit_message(project, commit, user, user, false)
+ end
end
- it 'creates cross references' do
- expect(commit).to receive(:create_cross_references!)
+ context 'when commit is a merge request merge commit to the default branch' do
+ let(:merge_request) do
+ create(:merge_request,
+ description: "Closes #{issue.to_reference}",
+ source_branch: 'feature-merged',
+ target_branch: 'master',
+ source_project: project)
+ end
+
+ let(:commit) do
+ project.repository.create_branch('feature-merged', 'feature')
- worker.process_commit_message(project, commit, user, user)
+ MergeRequests::MergeService
+ .new(project, merge_request.author)
+ .execute(merge_request)
+
+ merge_request.reload.merge_commit
+ end
+
+ it 'does not close any issues from the commit message' do
+ expect(worker).not_to receive(:close_issues)
+
+ worker.process_commit_message(project, commit, user, user, true)
+ end
+
+ it 'still creates cross references' do
+ expect(commit).to receive(:create_cross_references!).with(user, [])
+
+ worker.process_commit_message(project, commit, user, user, true)
+ end
end
end
@@ -102,8 +139,7 @@ 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)
@@ -113,10 +149,10 @@ 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)/)
+ expect { worker.update_issue_metrics(commit, user) }
+ .not_to make_queries_matching(/WHERE (?:1=0|0=1)/)
end
end
@@ -128,8 +164,9 @@ 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_export_worker_spec.rb b/spec/workers/project_export_worker_spec.rb
new file mode 100644
index 00000000000..8899969c178
--- /dev/null
+++ b/spec/workers/project_export_worker_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe ProjectExportWorker do
+ let!(:user) { create(:user) }
+ let!(:project) { create(:project) }
+
+ subject { described_class.new }
+
+ describe '#perform' do
+ context 'when it succeeds' do
+ it 'calls the ExportService' do
+ expect_any_instance_of(::Projects::ImportExport::ExportService).to receive(:execute)
+
+ subject.perform(user.id, project.id, { 'klass' => 'Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy' })
+ end
+ end
+
+ context 'when it fails' do
+ it 'raises an exception when params are invalid' do
+ expect_any_instance_of(::Projects::ImportExport::ExportService).not_to receive(:execute)
+
+ expect { subject.perform(1234, project.id, {}) }.to raise_exception(ActiveRecord::RecordNotFound)
+ expect { subject.perform(user.id, 1234, {}) }.to raise_exception(ActiveRecord::RecordNotFound)
+ expect { subject.perform(user.id, project.id, { 'klass' => 'Whatever' }) }.to raise_exception(Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError)
+ end
+ end
+ end
+end
diff --git a/spec/workers/rebase_worker_spec.rb b/spec/workers/rebase_worker_spec.rb
new file mode 100644
index 00000000000..20aff020dbb
--- /dev/null
+++ b/spec/workers/rebase_worker_spec.rb
@@ -0,0 +1,27 @@
+require 'spec_helper'
+
+describe RebaseWorker, '#perform' do
+ context 'when rebasing an MR from a fork where upstream has protected branches' do
+ let(:upstream_project) { create(:project, :repository) }
+ let(:fork_project) { create(:project, :repository) }
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: fork_project,
+ source_branch: 'feature_conflict',
+ target_project: upstream_project,
+ target_branch: 'master')
+ end
+
+ before do
+ create(:forked_project_link, forked_to_project: fork_project, forked_from_project: upstream_project)
+ end
+
+ it 'sets the correct project for running hooks' do
+ expect(MergeRequests::RebaseService)
+ .to receive(:new).with(fork_project, merge_request.author).and_call_original
+
+ subject.perform(merge_request, merge_request.author)
+ end
+ end
+end
diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb
index 31598586f59..4b3c1736ea0 100644
--- a/spec/workers/repository_fork_worker_spec.rb
+++ b/spec/workers/repository_fork_worker_spec.rb
@@ -9,62 +9,91 @@ describe RepositoryForkWorker do
describe "#perform" do
let(:project) { create(:project, :repository) }
- let(:fork_project) { create(:project, :repository, :import_scheduled, forked_from_project: project) }
let(:shell) { Gitlab::Shell.new }
+ let(:fork_project) { create(:project, :repository, :import_scheduled, forked_from_project: project) }
- before do
- allow(subject).to receive(:gitlab_shell).and_return(shell)
- end
+ shared_examples 'RepositoryForkWorker performing' do
+ before do
+ allow(subject).to receive(:gitlab_shell).and_return(shell)
+ end
- def perform!
- subject.perform(fork_project.id, '/test/path', project.disk_path)
- end
+ def expect_fork_repository
+ expect(shell).to receive(:fork_repository).with(
+ 'default',
+ project.disk_path,
+ fork_project.repository_storage,
+ fork_project.disk_path
+ )
+ end
- def expect_fork_repository
- expect(shell).to receive(:fork_repository).with(
- '/test/path',
- project.disk_path,
- fork_project.repository_storage_path,
- fork_project.disk_path
- )
- end
+ describe 'when a worker was reset without cleanup' do
+ let(:jid) { '12345678' }
- describe 'when a worker was reset without cleanup' do
- let(:jid) { '12345678' }
+ it 'creates a new repository from a fork' do
+ allow(subject).to receive(:jid).and_return(jid)
- it 'creates a new repository from a fork' do
- allow(subject).to receive(:jid).and_return(jid)
+ expect_fork_repository.and_return(true)
+ perform!
+ end
+ end
+
+ it "creates a new repository from a fork" do
expect_fork_repository.and_return(true)
perform!
end
- end
- it "creates a new repository from a fork" do
- expect_fork_repository.and_return(true)
+ it 'protects the default branch' do
+ expect_fork_repository.and_return(true)
- perform!
- end
+ perform!
+
+ expect(fork_project.protected_branches.first.name).to eq(fork_project.default_branch)
+ end
- it 'flushes various caches' do
- expect_fork_repository.and_return(true)
+ it 'flushes various caches' do
+ expect_fork_repository.and_return(true)
+
+ 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
+
+ perform!
+ end
- expect_any_instance_of(Repository).to receive(:expire_emptiness_caches)
- .and_call_original
+ it "handles bad fork" do
+ error_message = "Unable to fork project #{fork_project.id} for repository #{project.disk_path} -> #{fork_project.disk_path}"
- expect_any_instance_of(Repository).to receive(:expire_exists_cache)
- .and_call_original
+ expect_fork_repository.and_return(false)
- perform!
+ expect { perform! }.to raise_error(StandardError, error_message)
+ end
end
- it "handles bad fork" do
- error_message = "Unable to fork project #{fork_project.id} for repository #{project.full_path} -> #{fork_project.full_path}"
+ context 'only project ID passed' do
+ def perform!
+ subject.perform(fork_project.id)
+ end
- expect_fork_repository.and_return(false)
+ it_behaves_like 'RepositoryForkWorker performing'
+ end
+
+ context 'project ID, storage and repo paths passed' do
+ def perform!
+ subject.perform(fork_project.id, TestEnv.repos_path, project.disk_path)
+ end
+
+ it_behaves_like 'RepositoryForkWorker performing'
- expect { perform! }.to raise_error(StandardError, error_message)
+ it 'logs a message about forking with old-style arguments' do
+ allow(Rails.logger).to receive(:info).with(anything) # To compensate for other logs
+ expect(Rails.logger).to receive(:info).with("Project #{fork_project.id} is being forked using old-style arguments.")
+
+ perform!
+ end
end
end
end
diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb
index 85ac14eb347..2b1a617ee62 100644
--- a/spec/workers/repository_import_worker_spec.rb
+++ b/spec/workers/repository_import_worker_spec.rb
@@ -32,6 +32,7 @@ describe RepositoryImportWorker do
expect_any_instance_of(Projects::ImportService).to receive(:execute)
.and_return({ status: :ok })
+ expect_any_instance_of(Project).to receive(:after_import).and_call_original
expect_any_instance_of(Repository).to receive(:expire_emptiness_caches)
expect_any_instance_of(Project).to receive(:import_finish)
@@ -48,9 +49,22 @@ describe RepositoryImportWorker do
expect do
subject.perform(project.id)
- end.to raise_error(StandardError, error)
+ end.to raise_error(RuntimeError, error)
expect(project.reload.import_jid).not_to be_nil
end
+
+ it 'updates the error on Import/Export' do
+ error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found }
+
+ project.update_attributes(import_jid: '123', import_type: 'gitlab_project')
+ expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error })
+
+ expect do
+ subject.perform(project.id)
+ end.to raise_error(RuntimeError, error)
+
+ expect(project.reload.import_error).not_to be_nil
+ end
end
context 'when using an asynchronous importer' do
diff --git a/spec/workers/storage_migrator_worker_spec.rb b/spec/workers/storage_migrator_worker_spec.rb
index 8619ff2f7da..ff625164142 100644
--- a/spec/workers/storage_migrator_worker_spec.rb
+++ b/spec/workers/storage_migrator_worker_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe StorageMigratorWorker do
subject(:worker) { described_class.new }
- let(:projects) { create_list(:project, 2) }
+ let(:projects) { create_list(:project, 2, :legacy_storage) }
describe '#perform' do
let(:ids) { projects.map(&:id) }
diff --git a/spec/workers/stuck_import_jobs_worker_spec.rb b/spec/workers/stuck_import_jobs_worker_spec.rb
index a82eb54ffe4..069514552b1 100644
--- a/spec/workers/stuck_import_jobs_worker_spec.rb
+++ b/spec/workers/stuck_import_jobs_worker_spec.rb
@@ -2,35 +2,59 @@ require 'spec_helper'
describe StuckImportJobsWorker do
let(:worker) { described_class.new }
- let(:exclusive_lease_uuid) { SecureRandom.uuid }
- before do
- allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(exclusive_lease_uuid)
- end
+ shared_examples 'project import job detection' do
+ context 'when the job has completed' do
+ context 'when the import status was already updated' do
+ before do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids) do
+ project.import_start
+ project.import_finish
- describe 'with started import_status' do
- let(:project) { create(:project, :import_started, import_jid: '123') }
+ [project.import_jid]
+ end
+ end
+
+ it 'does not mark the project as failed' do
+ worker.perform
+
+ expect(project.reload.import_status).to eq('finished')
+ end
+ end
+
+ context 'when the import status was not updated' do
+ before do
+ allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([project.import_jid])
+ 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
+ worker.perform
- expect { worker.perform }.to change { project.reload.import_status }.to('failed')
+ expect(project.reload.import_status).to eq('failed')
+ end
end
end
- describe 'running import' do
- it 'does not mark the project as failed' do
+ context 'when the job is still in Sidekiq' do
+ before do
allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([])
+ end
+ it 'does not mark the project as failed' do
expect { worker.perform }.not_to change { project.reload.import_status }
end
+ end
+ end
- 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
+ describe 'with scheduled import_status' do
+ it_behaves_like 'project import job detection' do
+ let(:project) { create(:project, :import_scheduled, import_jid: '123') }
+ end
+ end
+
+ describe 'with started import_status' do
+ it_behaves_like 'project import job detection' do
+ let(:project) { create(:project, :import_started, import_jid: '123') }
end
end
end
diff --git a/spec/workers/upload_checksum_worker_spec.rb b/spec/workers/upload_checksum_worker_spec.rb
index 911360da66c..9e50ce15871 100644
--- a/spec/workers/upload_checksum_worker_spec.rb
+++ b/spec/workers/upload_checksum_worker_spec.rb
@@ -2,18 +2,31 @@ require 'rails_helper'
describe UploadChecksumWorker do
describe '#perform' do
- it 'rescues ActiveRecord::RecordNotFound' do
- expect { described_class.new.perform(999_999) }.not_to raise_error
+ subject { described_class.new }
+
+ context 'without a valid record' do
+ it 'rescues ActiveRecord::RecordNotFound' do
+ expect { subject.perform(999_999) }.not_to raise_error
+ end
end
- it 'calls calculate_checksum_without_delay and save!' do
- upload = spy
- expect(Upload).to receive(:find).with(999_999).and_return(upload)
+ context 'with a valid record' do
+ let(:upload) { create(:user, :with_avatar).avatar.upload }
+
+ before do
+ expect(Upload).to receive(:find).and_return(upload)
+ allow(upload).to receive(:foreground_checksumable?).and_return(false)
+ end
- described_class.new.perform(999_999)
+ it 'calls calculate_checksum!' do
+ expect(upload).to receive(:calculate_checksum!)
+ subject.perform(upload.id)
+ end
- expect(upload).to have_received(:calculate_checksum)
- expect(upload).to have_received(:save!)
+ it 'calls save!' do
+ expect(upload).to receive(:save!)
+ subject.perform(upload.id)
+ end
end
end
end
diff --git a/vendor/assets/fonts/KaTeX_AMS-Regular.eot b/vendor/assets/fonts/KaTeX_AMS-Regular.eot
deleted file mode 100644
index 784276a3cbf..00000000000
--- a/vendor/assets/fonts/KaTeX_AMS-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_AMS-Regular.ttf b/vendor/assets/fonts/KaTeX_AMS-Regular.ttf
deleted file mode 100644
index 6f1e0be2028..00000000000
--- a/vendor/assets/fonts/KaTeX_AMS-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_AMS-Regular.woff b/vendor/assets/fonts/KaTeX_AMS-Regular.woff
deleted file mode 100644
index 4dded4733b3..00000000000
--- a/vendor/assets/fonts/KaTeX_AMS-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_AMS-Regular.woff2 b/vendor/assets/fonts/KaTeX_AMS-Regular.woff2
deleted file mode 100644
index ea81079c4e2..00000000000
--- a/vendor/assets/fonts/KaTeX_AMS-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.eot b/vendor/assets/fonts/KaTeX_Caligraphic-Bold.eot
deleted file mode 100644
index 1a0db0c568e..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.ttf b/vendor/assets/fonts/KaTeX_Caligraphic-Bold.ttf
deleted file mode 100644
index b94907dad11..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.woff b/vendor/assets/fonts/KaTeX_Caligraphic-Bold.woff
deleted file mode 100644
index 799fa8122ca..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.woff2 b/vendor/assets/fonts/KaTeX_Caligraphic-Bold.woff2
deleted file mode 100644
index 73bb5422878..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Bold.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.eot b/vendor/assets/fonts/KaTeX_Caligraphic-Regular.eot
deleted file mode 100644
index 6cc83d0922c..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.ttf b/vendor/assets/fonts/KaTeX_Caligraphic-Regular.ttf
deleted file mode 100644
index cf51e2021e4..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.woff b/vendor/assets/fonts/KaTeX_Caligraphic-Regular.woff
deleted file mode 100644
index f5e5c623577..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.woff2 b/vendor/assets/fonts/KaTeX_Caligraphic-Regular.woff2
deleted file mode 100644
index dd76d3488d5..00000000000
--- a/vendor/assets/fonts/KaTeX_Caligraphic-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Bold.eot b/vendor/assets/fonts/KaTeX_Fraktur-Bold.eot
deleted file mode 100644
index 1960b106656..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Bold.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Bold.ttf b/vendor/assets/fonts/KaTeX_Fraktur-Bold.ttf
deleted file mode 100644
index 7b0790f1ae8..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Bold.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Bold.woff b/vendor/assets/fonts/KaTeX_Fraktur-Bold.woff
deleted file mode 100644
index dc325713291..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Bold.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Bold.woff2 b/vendor/assets/fonts/KaTeX_Fraktur-Bold.woff2
deleted file mode 100644
index fdc429227ad..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Bold.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Regular.eot b/vendor/assets/fonts/KaTeX_Fraktur-Regular.eot
deleted file mode 100644
index e4e73796aea..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Regular.ttf b/vendor/assets/fonts/KaTeX_Fraktur-Regular.ttf
deleted file mode 100644
index 063bc0263eb..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Regular.woff b/vendor/assets/fonts/KaTeX_Fraktur-Regular.woff
deleted file mode 100644
index c4b18d863f3..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Fraktur-Regular.woff2 b/vendor/assets/fonts/KaTeX_Fraktur-Regular.woff2
deleted file mode 100644
index 4318d938e26..00000000000
--- a/vendor/assets/fonts/KaTeX_Fraktur-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Bold.eot b/vendor/assets/fonts/KaTeX_Main-Bold.eot
deleted file mode 100644
index 80fbd022363..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Bold.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Bold.ttf b/vendor/assets/fonts/KaTeX_Main-Bold.ttf
deleted file mode 100644
index 8e10722afae..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Bold.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Bold.woff b/vendor/assets/fonts/KaTeX_Main-Bold.woff
deleted file mode 100644
index 43b361a6005..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Bold.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Bold.woff2 b/vendor/assets/fonts/KaTeX_Main-Bold.woff2
deleted file mode 100644
index af57a96c148..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Bold.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Italic.eot b/vendor/assets/fonts/KaTeX_Main-Italic.eot
deleted file mode 100644
index fc770166b5e..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Italic.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Italic.ttf b/vendor/assets/fonts/KaTeX_Main-Italic.ttf
deleted file mode 100644
index d124495d7b6..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Italic.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Italic.woff b/vendor/assets/fonts/KaTeX_Main-Italic.woff
deleted file mode 100644
index e623236bc44..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Italic.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Italic.woff2 b/vendor/assets/fonts/KaTeX_Main-Italic.woff2
deleted file mode 100644
index 944e9740bdf..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Italic.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Regular.eot b/vendor/assets/fonts/KaTeX_Main-Regular.eot
deleted file mode 100644
index dc60c090c7a..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Regular.ttf b/vendor/assets/fonts/KaTeX_Main-Regular.ttf
deleted file mode 100644
index da5797ffcce..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Regular.woff b/vendor/assets/fonts/KaTeX_Main-Regular.woff
deleted file mode 100644
index 37db672e821..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Main-Regular.woff2 b/vendor/assets/fonts/KaTeX_Main-Regular.woff2
deleted file mode 100644
index 48820424893..00000000000
--- a/vendor/assets/fonts/KaTeX_Main-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-BoldItalic.eot b/vendor/assets/fonts/KaTeX_Math-BoldItalic.eot
deleted file mode 100644
index 52c8b8c6b40..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-BoldItalic.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-BoldItalic.ttf b/vendor/assets/fonts/KaTeX_Math-BoldItalic.ttf
deleted file mode 100644
index a8b527c7ef6..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-BoldItalic.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-BoldItalic.woff b/vendor/assets/fonts/KaTeX_Math-BoldItalic.woff
deleted file mode 100644
index 8940e0b5801..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-BoldItalic.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-BoldItalic.woff2 b/vendor/assets/fonts/KaTeX_Math-BoldItalic.woff2
deleted file mode 100644
index 15cf56d3408..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-BoldItalic.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Italic.eot b/vendor/assets/fonts/KaTeX_Math-Italic.eot
deleted file mode 100644
index 64c8992c477..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Italic.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Italic.ttf b/vendor/assets/fonts/KaTeX_Math-Italic.ttf
deleted file mode 100644
index 06f39d3a299..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Italic.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Italic.woff b/vendor/assets/fonts/KaTeX_Math-Italic.woff
deleted file mode 100644
index cf3b4b79e5b..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Italic.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Italic.woff2 b/vendor/assets/fonts/KaTeX_Math-Italic.woff2
deleted file mode 100644
index 5f8c4bfa455..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Italic.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Regular.eot b/vendor/assets/fonts/KaTeX_Math-Regular.eot
deleted file mode 100644
index 5521e6a564d..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Regular.ttf b/vendor/assets/fonts/KaTeX_Math-Regular.ttf
deleted file mode 100644
index 73127082370..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Regular.woff b/vendor/assets/fonts/KaTeX_Math-Regular.woff
deleted file mode 100644
index 0e2ebdf18af..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Math-Regular.woff2 b/vendor/assets/fonts/KaTeX_Math-Regular.woff2
deleted file mode 100644
index ebe3d028a34..00000000000
--- a/vendor/assets/fonts/KaTeX_Math-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Bold.eot b/vendor/assets/fonts/KaTeX_SansSerif-Bold.eot
deleted file mode 100644
index 1660e76a2b6..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Bold.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Bold.ttf b/vendor/assets/fonts/KaTeX_SansSerif-Bold.ttf
deleted file mode 100644
index dbeb7b92ab5..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Bold.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Bold.woff b/vendor/assets/fonts/KaTeX_SansSerif-Bold.woff
deleted file mode 100644
index 8f144a8bb31..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Bold.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Bold.woff2 b/vendor/assets/fonts/KaTeX_SansSerif-Bold.woff2
deleted file mode 100644
index 329e85557fa..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Bold.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Italic.eot b/vendor/assets/fonts/KaTeX_SansSerif-Italic.eot
deleted file mode 100644
index 289ae3ff8b7..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Italic.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Italic.ttf b/vendor/assets/fonts/KaTeX_SansSerif-Italic.ttf
deleted file mode 100644
index b3a2f38f224..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Italic.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Italic.woff b/vendor/assets/fonts/KaTeX_SansSerif-Italic.woff
deleted file mode 100644
index bddf7ea6579..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Italic.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Italic.woff2 b/vendor/assets/fonts/KaTeX_SansSerif-Italic.woff2
deleted file mode 100644
index 5fa767bddd6..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Italic.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Regular.eot b/vendor/assets/fonts/KaTeX_SansSerif-Regular.eot
deleted file mode 100644
index 1b38b98a180..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Regular.ttf b/vendor/assets/fonts/KaTeX_SansSerif-Regular.ttf
deleted file mode 100644
index e4712f84775..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Regular.woff b/vendor/assets/fonts/KaTeX_SansSerif-Regular.woff
deleted file mode 100644
index 33be368048f..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_SansSerif-Regular.woff2 b/vendor/assets/fonts/KaTeX_SansSerif-Regular.woff2
deleted file mode 100644
index 4fcb2e29a05..00000000000
--- a/vendor/assets/fonts/KaTeX_SansSerif-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Script-Regular.eot b/vendor/assets/fonts/KaTeX_Script-Regular.eot
deleted file mode 100644
index 7870d7f319b..00000000000
--- a/vendor/assets/fonts/KaTeX_Script-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Script-Regular.ttf b/vendor/assets/fonts/KaTeX_Script-Regular.ttf
deleted file mode 100644
index da4d11308ae..00000000000
--- a/vendor/assets/fonts/KaTeX_Script-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Script-Regular.woff b/vendor/assets/fonts/KaTeX_Script-Regular.woff
deleted file mode 100644
index d6ae79f998a..00000000000
--- a/vendor/assets/fonts/KaTeX_Script-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Script-Regular.woff2 b/vendor/assets/fonts/KaTeX_Script-Regular.woff2
deleted file mode 100644
index 1b43deb45a8..00000000000
--- a/vendor/assets/fonts/KaTeX_Script-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size1-Regular.eot b/vendor/assets/fonts/KaTeX_Size1-Regular.eot
deleted file mode 100644
index 29950f95ff6..00000000000
--- a/vendor/assets/fonts/KaTeX_Size1-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size1-Regular.ttf b/vendor/assets/fonts/KaTeX_Size1-Regular.ttf
deleted file mode 100644
index 194466a655d..00000000000
--- a/vendor/assets/fonts/KaTeX_Size1-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size1-Regular.woff b/vendor/assets/fonts/KaTeX_Size1-Regular.woff
deleted file mode 100644
index 237f271edd1..00000000000
--- a/vendor/assets/fonts/KaTeX_Size1-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size1-Regular.woff2 b/vendor/assets/fonts/KaTeX_Size1-Regular.woff2
deleted file mode 100644
index 39b6f8f746c..00000000000
--- a/vendor/assets/fonts/KaTeX_Size1-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size2-Regular.eot b/vendor/assets/fonts/KaTeX_Size2-Regular.eot
deleted file mode 100644
index b8b0536f967..00000000000
--- a/vendor/assets/fonts/KaTeX_Size2-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size2-Regular.ttf b/vendor/assets/fonts/KaTeX_Size2-Regular.ttf
deleted file mode 100644
index b41b66a638f..00000000000
--- a/vendor/assets/fonts/KaTeX_Size2-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size2-Regular.woff b/vendor/assets/fonts/KaTeX_Size2-Regular.woff
deleted file mode 100644
index 4a3055854ed..00000000000
--- a/vendor/assets/fonts/KaTeX_Size2-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size2-Regular.woff2 b/vendor/assets/fonts/KaTeX_Size2-Regular.woff2
deleted file mode 100644
index 3facec1ab89..00000000000
--- a/vendor/assets/fonts/KaTeX_Size2-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size3-Regular.eot b/vendor/assets/fonts/KaTeX_Size3-Regular.eot
deleted file mode 100644
index 576b864fae6..00000000000
--- a/vendor/assets/fonts/KaTeX_Size3-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size3-Regular.ttf b/vendor/assets/fonts/KaTeX_Size3-Regular.ttf
deleted file mode 100644
index 790ddbbc55f..00000000000
--- a/vendor/assets/fonts/KaTeX_Size3-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size3-Regular.woff b/vendor/assets/fonts/KaTeX_Size3-Regular.woff
deleted file mode 100644
index 3a6d062e660..00000000000
--- a/vendor/assets/fonts/KaTeX_Size3-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size3-Regular.woff2 b/vendor/assets/fonts/KaTeX_Size3-Regular.woff2
deleted file mode 100644
index 2cffafe5018..00000000000
--- a/vendor/assets/fonts/KaTeX_Size3-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size4-Regular.eot b/vendor/assets/fonts/KaTeX_Size4-Regular.eot
deleted file mode 100644
index c2b045fc3db..00000000000
--- a/vendor/assets/fonts/KaTeX_Size4-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size4-Regular.ttf b/vendor/assets/fonts/KaTeX_Size4-Regular.ttf
deleted file mode 100644
index ce660aa7ff9..00000000000
--- a/vendor/assets/fonts/KaTeX_Size4-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size4-Regular.woff b/vendor/assets/fonts/KaTeX_Size4-Regular.woff
deleted file mode 100644
index 7826c6c97a1..00000000000
--- a/vendor/assets/fonts/KaTeX_Size4-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Size4-Regular.woff2 b/vendor/assets/fonts/KaTeX_Size4-Regular.woff2
deleted file mode 100644
index c92189812d9..00000000000
--- a/vendor/assets/fonts/KaTeX_Size4-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Typewriter-Regular.eot b/vendor/assets/fonts/KaTeX_Typewriter-Regular.eot
deleted file mode 100644
index 4c178f484a8..00000000000
--- a/vendor/assets/fonts/KaTeX_Typewriter-Regular.eot
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Typewriter-Regular.ttf b/vendor/assets/fonts/KaTeX_Typewriter-Regular.ttf
deleted file mode 100644
index b0427ad0a56..00000000000
--- a/vendor/assets/fonts/KaTeX_Typewriter-Regular.ttf
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Typewriter-Regular.woff b/vendor/assets/fonts/KaTeX_Typewriter-Regular.woff
deleted file mode 100644
index 78e990488a9..00000000000
--- a/vendor/assets/fonts/KaTeX_Typewriter-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/fonts/KaTeX_Typewriter-Regular.woff2 b/vendor/assets/fonts/KaTeX_Typewriter-Regular.woff2
deleted file mode 100644
index 618de99d480..00000000000
--- a/vendor/assets/fonts/KaTeX_Typewriter-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/vendor/assets/javascripts/Chart.js b/vendor/assets/javascripts/Chart.js
deleted file mode 100644
index c264262ba73..00000000000
--- a/vendor/assets/javascripts/Chart.js
+++ /dev/null
@@ -1,3477 +0,0 @@
-/*!
- * Chart.js
- * http://chartjs.org/
- * Version: 1.0.2
- *
- * Copyright 2015 Nick Downie
- * Released under the MIT license
- * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md
- */
-
-
-(function(){
-
- "use strict";
-
- //Declare root variable - window in the browser, global on the server
- var root = this,
- previous = root.Chart;
-
- //Occupy the global variable of Chart, and create a simple base class
- var Chart = function(context){
- var chart = this;
- this.canvas = context.canvas;
-
- this.ctx = context;
-
- //Variables global to the chart
- var computeDimension = function(element,dimension)
- {
- if (element['offset'+dimension])
- {
- return element['offset'+dimension];
- }
- else
- {
- return document.defaultView.getComputedStyle(element).getPropertyValue(dimension);
- }
- }
-
- var width = this.width = computeDimension(context.canvas,'Width');
- var height = this.height = computeDimension(context.canvas,'Height');
-
- // Firefox requires this to work correctly
- context.canvas.width = width;
- context.canvas.height = height;
-
- var width = this.width = context.canvas.width;
- var height = this.height = context.canvas.height;
- this.aspectRatio = this.width / this.height;
- //High pixel density displays - multiply the size of the canvas height/width by the device pixel ratio, then scale.
- helpers.retinaScale(this);
-
- return this;
- };
- //Globally expose the defaults to allow for user updating/changing
- Chart.defaults = {
- global: {
- // Boolean - Whether to animate the chart
- animation: true,
-
- // Number - Number of animation steps
- animationSteps: 60,
-
- // String - Animation easing effect
- animationEasing: "easeOutQuart",
-
- // Boolean - If we should show the scale at all
- showScale: true,
-
- // Boolean - If we want to override with a hard coded scale
- scaleOverride: false,
-
- // ** Required if scaleOverride is true **
- // Number - The number of steps in a hard coded scale
- scaleSteps: null,
- // Number - The value jump in the hard coded scale
- scaleStepWidth: null,
- // Number - The scale starting value
- scaleStartValue: null,
-
- // String - Colour of the scale line
- scaleLineColor: "rgba(0,0,0,.1)",
-
- // Number - Pixel width of the scale line
- scaleLineWidth: 1,
-
- // Boolean - Whether to show labels on the scale
- scaleShowLabels: true,
-
- // Interpolated JS string - can access value
- scaleLabel: "<%=value%>",
-
- // Boolean - Whether the scale should stick to integers, and not show any floats even if drawing space is there
- scaleIntegersOnly: true,
-
- // Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value
- scaleBeginAtZero: false,
-
- // String - Scale label font declaration for the scale label
- scaleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
-
- // Number - Scale label font size in pixels
- scaleFontSize: 12,
-
- // String - Scale label font weight style
- scaleFontStyle: "normal",
-
- // String - Scale label font colour
- scaleFontColor: "#666",
-
- // Boolean - whether or not the chart should be responsive and resize when the browser does.
- responsive: false,
-
- // Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container
- maintainAspectRatio: true,
-
- // Boolean - Determines whether to draw tooltips on the canvas or not - attaches events to touchmove & mousemove
- showTooltips: true,
-
- // Boolean - Determines whether to draw built-in tooltip or call custom tooltip function
- customTooltips: false,
-
- // Array - Array of string names to attach tooltip events
- tooltipEvents: ["mousemove", "touchstart", "touchmove", "mouseout"],
-
- // String - Tooltip background colour
- tooltipFillColor: "rgba(0,0,0,0.8)",
-
- // String - Tooltip label font declaration for the scale label
- tooltipFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
-
- // Number - Tooltip label font size in pixels
- tooltipFontSize: 14,
-
- // String - Tooltip font weight style
- tooltipFontStyle: "normal",
-
- // String - Tooltip label font colour
- tooltipFontColor: "#fff",
-
- // String - Tooltip title font declaration for the scale label
- tooltipTitleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
-
- // Number - Tooltip title font size in pixels
- tooltipTitleFontSize: 14,
-
- // String - Tooltip title font weight style
- tooltipTitleFontStyle: "bold",
-
- // String - Tooltip title font colour
- tooltipTitleFontColor: "#fff",
-
- // Number - pixel width of padding around tooltip text
- tooltipYPadding: 6,
-
- // Number - pixel width of padding around tooltip text
- tooltipXPadding: 6,
-
- // Number - Size of the caret on the tooltip
- tooltipCaretSize: 8,
-
- // Number - Pixel radius of the tooltip border
- tooltipCornerRadius: 6,
-
- // Number - Pixel offset from point x to tooltip edge
- tooltipXOffset: 10,
-
- // String - Template string for single tooltips
- tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= value %>",
-
- // String - Template string for single tooltips
- multiTooltipTemplate: "<%= value %>",
-
- // String - Colour behind the legend colour block
- multiTooltipKeyBackground: '#fff',
-
- // Function - Will fire on animation progression.
- onAnimationProgress: function(){},
-
- // Function - Will fire on animation completion.
- onAnimationComplete: function(){}
-
- }
- };
-
- //Create a dictionary of chart types, to allow for extension of existing types
- Chart.types = {};
-
- //Global Chart helpers object for utility methods and classes
- var helpers = Chart.helpers = {};
-
- //-- Basic js utility methods
- var each = helpers.each = function(loopable,callback,self){
- var additionalArgs = Array.prototype.slice.call(arguments, 3);
- // Check to see if null or undefined firstly.
- if (loopable){
- if (loopable.length === +loopable.length){
- var i;
- for (i=0; i<loopable.length; i++){
- callback.apply(self,[loopable[i], i].concat(additionalArgs));
- }
- }
- else{
- for (var item in loopable){
- callback.apply(self,[loopable[item],item].concat(additionalArgs));
- }
- }
- }
- },
- clone = helpers.clone = function(obj){
- var objClone = {};
- each(obj,function(value,key){
- if (obj.hasOwnProperty(key)) objClone[key] = value;
- });
- return objClone;
- },
- extend = helpers.extend = function(base){
- each(Array.prototype.slice.call(arguments,1), function(extensionObject) {
- each(extensionObject,function(value,key){
- if (extensionObject.hasOwnProperty(key)) base[key] = value;
- });
- });
- return base;
- },
- merge = helpers.merge = function(base,master){
- //Merge properties in left object over to a shallow clone of object right.
- var args = Array.prototype.slice.call(arguments,0);
- args.unshift({});
- return extend.apply(null, args);
- },
- indexOf = helpers.indexOf = function(arrayToSearch, item){
- if (Array.prototype.indexOf) {
- return arrayToSearch.indexOf(item);
- }
- else{
- for (var i = 0; i < arrayToSearch.length; i++) {
- if (arrayToSearch[i] === item) return i;
- }
- return -1;
- }
- },
- where = helpers.where = function(collection, filterCallback){
- var filtered = [];
-
- helpers.each(collection, function(item){
- if (filterCallback(item)){
- filtered.push(item);
- }
- });
-
- return filtered;
- },
- findNextWhere = helpers.findNextWhere = function(arrayToSearch, filterCallback, startIndex){
- // Default to start of the array
- if (!startIndex){
- startIndex = -1;
- }
- for (var i = startIndex + 1; i < arrayToSearch.length; i++) {
- var currentItem = arrayToSearch[i];
- if (filterCallback(currentItem)){
- return currentItem;
- }
- }
- },
- findPreviousWhere = helpers.findPreviousWhere = function(arrayToSearch, filterCallback, startIndex){
- // Default to end of the array
- if (!startIndex){
- startIndex = arrayToSearch.length;
- }
- for (var i = startIndex - 1; i >= 0; i--) {
- var currentItem = arrayToSearch[i];
- if (filterCallback(currentItem)){
- return currentItem;
- }
- }
- },
- inherits = helpers.inherits = function(extensions){
- //Basic javascript inheritance based on the model created in Backbone.js
- var parent = this;
- var ChartElement = (extensions && extensions.hasOwnProperty("constructor")) ? extensions.constructor : function(){ return parent.apply(this, arguments); };
-
- var Surrogate = function(){ this.constructor = ChartElement;};
- Surrogate.prototype = parent.prototype;
- ChartElement.prototype = new Surrogate();
-
- ChartElement.extend = inherits;
-
- if (extensions) extend(ChartElement.prototype, extensions);
-
- ChartElement.__super__ = parent.prototype;
-
- return ChartElement;
- },
- noop = helpers.noop = function(){},
- uid = helpers.uid = (function(){
- var id=0;
- return function(){
- return "chart-" + id++;
- };
- })(),
- warn = helpers.warn = function(str){
- //Method for warning of errors
- if (window.console && typeof window.console.warn == "function") console.warn(str);
- },
- amd = helpers.amd = (typeof define == 'function' && define.amd),
- //-- Math methods
- isNumber = helpers.isNumber = function(n){
- return !isNaN(parseFloat(n)) && isFinite(n);
- },
- max = helpers.max = function(array){
- return Math.max.apply( Math, array );
- },
- min = helpers.min = function(array){
- return Math.min.apply( Math, array );
- },
- cap = helpers.cap = function(valueToCap,maxValue,minValue){
- if(isNumber(maxValue)) {
- if( valueToCap > maxValue ) {
- return maxValue;
- }
- }
- else if(isNumber(minValue)){
- if ( valueToCap < minValue ){
- return minValue;
- }
- }
- return valueToCap;
- },
- getDecimalPlaces = helpers.getDecimalPlaces = function(num){
- if (num%1!==0 && isNumber(num)){
- return num.toString().split(".")[1].length;
- }
- else {
- return 0;
- }
- },
- toRadians = helpers.radians = function(degrees){
- return degrees * (Math.PI/180);
- },
- // Gets the angle from vertical upright to the point about a centre.
- getAngleFromPoint = helpers.getAngleFromPoint = function(centrePoint, anglePoint){
- var distanceFromXCenter = anglePoint.x - centrePoint.x,
- distanceFromYCenter = anglePoint.y - centrePoint.y,
- radialDistanceFromCenter = Math.sqrt( distanceFromXCenter * distanceFromXCenter + distanceFromYCenter * distanceFromYCenter);
-
-
- var angle = Math.PI * 2 + Math.atan2(distanceFromYCenter, distanceFromXCenter);
-
- //If the segment is in the top left quadrant, we need to add another rotation to the angle
- if (distanceFromXCenter < 0 && distanceFromYCenter < 0){
- angle += Math.PI*2;
- }
-
- return {
- angle: angle,
- distance: radialDistanceFromCenter
- };
- },
- aliasPixel = helpers.aliasPixel = function(pixelWidth){
- return (pixelWidth % 2 === 0) ? 0 : 0.5;
- },
- splineCurve = helpers.splineCurve = function(FirstPoint,MiddlePoint,AfterPoint,t){
- //Props to Rob Spencer at scaled innovation for his post on splining between points
- //http://scaledinnovation.com/analytics/splines/aboutSplines.html
- var d01=Math.sqrt(Math.pow(MiddlePoint.x-FirstPoint.x,2)+Math.pow(MiddlePoint.y-FirstPoint.y,2)),
- d12=Math.sqrt(Math.pow(AfterPoint.x-MiddlePoint.x,2)+Math.pow(AfterPoint.y-MiddlePoint.y,2)),
- fa=t*d01/(d01+d12),// scaling factor for triangle Ta
- fb=t*d12/(d01+d12);
- return {
- inner : {
- x : MiddlePoint.x-fa*(AfterPoint.x-FirstPoint.x),
- y : MiddlePoint.y-fa*(AfterPoint.y-FirstPoint.y)
- },
- outer : {
- x: MiddlePoint.x+fb*(AfterPoint.x-FirstPoint.x),
- y : MiddlePoint.y+fb*(AfterPoint.y-FirstPoint.y)
- }
- };
- },
- calculateOrderOfMagnitude = helpers.calculateOrderOfMagnitude = function(val){
- return Math.floor(Math.log(val) / Math.LN10);
- },
- calculateScaleRange = helpers.calculateScaleRange = function(valuesArray, drawingSize, textSize, startFromZero, integersOnly){
-
- //Set a minimum step of two - a point at the top of the graph, and a point at the base
- var minSteps = 2,
- maxSteps = Math.floor(drawingSize/(textSize * 1.5)),
- skipFitting = (minSteps >= maxSteps);
-
- var maxValue = max(valuesArray),
- minValue = min(valuesArray);
-
- // We need some degree of seperation here to calculate the scales if all the values are the same
- // Adding/minusing 0.5 will give us a range of 1.
- if (maxValue === minValue){
- maxValue += 0.5;
- // So we don't end up with a graph with a negative start value if we've said always start from zero
- if (minValue >= 0.5 && !startFromZero){
- minValue -= 0.5;
- }
- else{
- // Make up a whole number above the values
- maxValue += 0.5;
- }
- }
-
- var valueRange = Math.abs(maxValue - minValue),
- rangeOrderOfMagnitude = calculateOrderOfMagnitude(valueRange),
- graphMax = Math.ceil(maxValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude),
- graphMin = (startFromZero) ? 0 : Math.floor(minValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude),
- graphRange = graphMax - graphMin,
- stepValue = Math.pow(10, rangeOrderOfMagnitude),
- numberOfSteps = Math.round(graphRange / stepValue);
-
- //If we have more space on the graph we'll use it to give more definition to the data
- while((numberOfSteps > maxSteps || (numberOfSteps * 2) < maxSteps) && !skipFitting) {
- if(numberOfSteps > maxSteps){
- stepValue *=2;
- numberOfSteps = Math.round(graphRange/stepValue);
- // Don't ever deal with a decimal number of steps - cancel fitting and just use the minimum number of steps.
- if (numberOfSteps % 1 !== 0){
- skipFitting = true;
- }
- }
- //We can fit in double the amount of scale points on the scale
- else{
- //If user has declared ints only, and the step value isn't a decimal
- if (integersOnly && rangeOrderOfMagnitude >= 0){
- //If the user has said integers only, we need to check that making the scale more granular wouldn't make it a float
- if(stepValue/2 % 1 === 0){
- stepValue /=2;
- numberOfSteps = Math.round(graphRange/stepValue);
- }
- //If it would make it a float break out of the loop
- else{
- break;
- }
- }
- //If the scale doesn't have to be an int, make the scale more granular anyway.
- else{
- stepValue /=2;
- numberOfSteps = Math.round(graphRange/stepValue);
- }
-
- }
- }
-
- if (skipFitting){
- numberOfSteps = minSteps;
- stepValue = graphRange / numberOfSteps;
- }
-
- return {
- steps : numberOfSteps,
- stepValue : stepValue,
- min : graphMin,
- max : graphMin + (numberOfSteps * stepValue)
- };
-
- },
- /* jshint ignore:start */
- // Blows up jshint errors based on the new Function constructor
- //Templating methods
- //Javascript micro templating by John Resig - source at http://ejohn.org/blog/javascript-micro-templating/
- template = helpers.template = function(templateString, valuesObject){
-
- // If templateString is function rather than string-template - call the function for valuesObject
-
- if(templateString instanceof Function){
- return templateString(valuesObject);
- }
-
- var cache = {};
- function tmpl(str, data){
- // Figure out if we're getting a template, or if we need to
- // load the template - and be sure to cache the result.
- var fn = !/\W/.test(str) ?
- cache[str] = cache[str] :
-
- // Generate a reusable function that will serve as a template
- // generator (and which will be cached).
- new Function("obj",
- "var p=[],print=function(){p.push.apply(p,arguments);};" +
-
- // Introduce the data as local variables using with(){}
- "with(obj){p.push('" +
-
- // Convert the template into pure JavaScript
- str
- .replace(/[\r\t\n]/g, " ")
- .split("<%").join("\t")
- .replace(/((^|%>)[^\t]*)'/g, "$1\r")
- .replace(/\t=(.*?)%>/g, "',$1,'")
- .split("\t").join("');")
- .split("%>").join("p.push('")
- .split("\r").join("\\'") +
- "');}return p.join('');"
- );
-
- // Provide some basic currying to the user
- return data ? fn( data ) : fn;
- }
- return tmpl(templateString,valuesObject);
- },
- /* jshint ignore:end */
- generateLabels = helpers.generateLabels = function(templateString,numberOfSteps,graphMin,stepValue){
- var labelsArray = new Array(numberOfSteps);
- if (labelTemplateString){
- each(labelsArray,function(val,index){
- labelsArray[index] = template(templateString,{value: (graphMin + (stepValue*(index+1)))});
- });
- }
- return labelsArray;
- },
- //--Animation methods
- //Easing functions adapted from Robert Penner's easing equations
- //http://www.robertpenner.com/easing/
- easingEffects = helpers.easingEffects = {
- linear: function (t) {
- return t;
- },
- easeInQuad: function (t) {
- return t * t;
- },
- easeOutQuad: function (t) {
- return -1 * t * (t - 2);
- },
- easeInOutQuad: function (t) {
- if ((t /= 1 / 2) < 1) return 1 / 2 * t * t;
- return -1 / 2 * ((--t) * (t - 2) - 1);
- },
- easeInCubic: function (t) {
- return t * t * t;
- },
- easeOutCubic: function (t) {
- return 1 * ((t = t / 1 - 1) * t * t + 1);
- },
- easeInOutCubic: function (t) {
- if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t;
- return 1 / 2 * ((t -= 2) * t * t + 2);
- },
- easeInQuart: function (t) {
- return t * t * t * t;
- },
- easeOutQuart: function (t) {
- return -1 * ((t = t / 1 - 1) * t * t * t - 1);
- },
- easeInOutQuart: function (t) {
- if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t * t;
- return -1 / 2 * ((t -= 2) * t * t * t - 2);
- },
- easeInQuint: function (t) {
- return 1 * (t /= 1) * t * t * t * t;
- },
- easeOutQuint: function (t) {
- return 1 * ((t = t / 1 - 1) * t * t * t * t + 1);
- },
- easeInOutQuint: function (t) {
- if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t * t * t;
- return 1 / 2 * ((t -= 2) * t * t * t * t + 2);
- },
- easeInSine: function (t) {
- return -1 * Math.cos(t / 1 * (Math.PI / 2)) + 1;
- },
- easeOutSine: function (t) {
- return 1 * Math.sin(t / 1 * (Math.PI / 2));
- },
- easeInOutSine: function (t) {
- return -1 / 2 * (Math.cos(Math.PI * t / 1) - 1);
- },
- easeInExpo: function (t) {
- return (t === 0) ? 1 : 1 * Math.pow(2, 10 * (t / 1 - 1));
- },
- easeOutExpo: function (t) {
- return (t === 1) ? 1 : 1 * (-Math.pow(2, -10 * t / 1) + 1);
- },
- easeInOutExpo: function (t) {
- if (t === 0) return 0;
- if (t === 1) return 1;
- if ((t /= 1 / 2) < 1) return 1 / 2 * Math.pow(2, 10 * (t - 1));
- return 1 / 2 * (-Math.pow(2, -10 * --t) + 2);
- },
- easeInCirc: function (t) {
- if (t >= 1) return t;
- return -1 * (Math.sqrt(1 - (t /= 1) * t) - 1);
- },
- easeOutCirc: function (t) {
- return 1 * Math.sqrt(1 - (t = t / 1 - 1) * t);
- },
- easeInOutCirc: function (t) {
- if ((t /= 1 / 2) < 1) return -1 / 2 * (Math.sqrt(1 - t * t) - 1);
- return 1 / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1);
- },
- easeInElastic: function (t) {
- var s = 1.70158;
- var p = 0;
- var a = 1;
- if (t === 0) return 0;
- if ((t /= 1) == 1) return 1;
- if (!p) p = 1 * 0.3;
- if (a < Math.abs(1)) {
- a = 1;
- s = p / 4;
- } else s = p / (2 * Math.PI) * Math.asin(1 / a);
- return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p));
- },
- easeOutElastic: function (t) {
- var s = 1.70158;
- var p = 0;
- var a = 1;
- if (t === 0) return 0;
- if ((t /= 1) == 1) return 1;
- if (!p) p = 1 * 0.3;
- if (a < Math.abs(1)) {
- a = 1;
- s = p / 4;
- } else s = p / (2 * Math.PI) * Math.asin(1 / a);
- return a * Math.pow(2, -10 * t) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) + 1;
- },
- easeInOutElastic: function (t) {
- var s = 1.70158;
- var p = 0;
- var a = 1;
- if (t === 0) return 0;
- if ((t /= 1 / 2) == 2) return 1;
- if (!p) p = 1 * (0.3 * 1.5);
- if (a < Math.abs(1)) {
- a = 1;
- s = p / 4;
- } else s = p / (2 * Math.PI) * Math.asin(1 / a);
- if (t < 1) return -0.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p));
- return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) * 0.5 + 1;
- },
- easeInBack: function (t) {
- var s = 1.70158;
- return 1 * (t /= 1) * t * ((s + 1) * t - s);
- },
- easeOutBack: function (t) {
- var s = 1.70158;
- return 1 * ((t = t / 1 - 1) * t * ((s + 1) * t + s) + 1);
- },
- easeInOutBack: function (t) {
- var s = 1.70158;
- if ((t /= 1 / 2) < 1) return 1 / 2 * (t * t * (((s *= (1.525)) + 1) * t - s));
- return 1 / 2 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2);
- },
- easeInBounce: function (t) {
- return 1 - easingEffects.easeOutBounce(1 - t);
- },
- easeOutBounce: function (t) {
- if ((t /= 1) < (1 / 2.75)) {
- return 1 * (7.5625 * t * t);
- } else if (t < (2 / 2.75)) {
- return 1 * (7.5625 * (t -= (1.5 / 2.75)) * t + 0.75);
- } else if (t < (2.5 / 2.75)) {
- return 1 * (7.5625 * (t -= (2.25 / 2.75)) * t + 0.9375);
- } else {
- return 1 * (7.5625 * (t -= (2.625 / 2.75)) * t + 0.984375);
- }
- },
- easeInOutBounce: function (t) {
- if (t < 1 / 2) return easingEffects.easeInBounce(t * 2) * 0.5;
- return easingEffects.easeOutBounce(t * 2 - 1) * 0.5 + 1 * 0.5;
- }
- },
- //Request animation polyfill - http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/
- requestAnimFrame = helpers.requestAnimFrame = (function(){
- return window.requestAnimationFrame ||
- window.webkitRequestAnimationFrame ||
- window.mozRequestAnimationFrame ||
- window.oRequestAnimationFrame ||
- window.msRequestAnimationFrame ||
- function(callback) {
- return window.setTimeout(callback, 1000 / 60);
- };
- })(),
- cancelAnimFrame = helpers.cancelAnimFrame = (function(){
- return window.cancelAnimationFrame ||
- window.webkitCancelAnimationFrame ||
- window.mozCancelAnimationFrame ||
- window.oCancelAnimationFrame ||
- window.msCancelAnimationFrame ||
- function(callback) {
- return window.clearTimeout(callback, 1000 / 60);
- };
- })(),
- animationLoop = helpers.animationLoop = function(callback,totalSteps,easingString,onProgress,onComplete,chartInstance){
-
- var currentStep = 0,
- easingFunction = easingEffects[easingString] || easingEffects.linear;
-
- var animationFrame = function(){
- currentStep++;
- var stepDecimal = currentStep/totalSteps;
- var easeDecimal = easingFunction(stepDecimal);
-
- callback.call(chartInstance,easeDecimal,stepDecimal, currentStep);
- onProgress.call(chartInstance,easeDecimal,stepDecimal);
- if (currentStep < totalSteps){
- chartInstance.animationFrame = requestAnimFrame(animationFrame);
- } else{
- onComplete.apply(chartInstance);
- }
- };
- requestAnimFrame(animationFrame);
- },
- //-- DOM methods
- getRelativePosition = helpers.getRelativePosition = function(evt){
- var mouseX, mouseY;
- var e = evt.originalEvent || evt,
- canvas = evt.currentTarget || evt.srcElement,
- boundingRect = canvas.getBoundingClientRect();
-
- if (e.touches){
- mouseX = e.touches[0].clientX - boundingRect.left;
- mouseY = e.touches[0].clientY - boundingRect.top;
-
- }
- else{
- mouseX = e.clientX - boundingRect.left;
- mouseY = e.clientY - boundingRect.top;
- }
-
- return {
- x : mouseX,
- y : mouseY
- };
-
- },
- addEvent = helpers.addEvent = function(node,eventType,method){
- if (node.addEventListener){
- node.addEventListener(eventType,method);
- } else if (node.attachEvent){
- node.attachEvent("on"+eventType, method);
- } else {
- node["on"+eventType] = method;
- }
- },
- removeEvent = helpers.removeEvent = function(node, eventType, handler){
- if (node.removeEventListener){
- node.removeEventListener(eventType, handler, false);
- } else if (node.detachEvent){
- node.detachEvent("on"+eventType,handler);
- } else{
- node["on" + eventType] = noop;
- }
- },
- bindEvents = helpers.bindEvents = function(chartInstance, arrayOfEvents, handler){
- // Create the events object if it's not already present
- if (!chartInstance.events) chartInstance.events = {};
-
- each(arrayOfEvents,function(eventName){
- chartInstance.events[eventName] = function(){
- handler.apply(chartInstance, arguments);
- };
- addEvent(chartInstance.chart.canvas,eventName,chartInstance.events[eventName]);
- });
- },
- unbindEvents = helpers.unbindEvents = function (chartInstance, arrayOfEvents) {
- each(arrayOfEvents, function(handler,eventName){
- removeEvent(chartInstance.chart.canvas, eventName, handler);
- });
- },
- getMaximumWidth = helpers.getMaximumWidth = function(domNode){
- var container = domNode.parentNode;
- // TODO = check cross browser stuff with this.
- return container.clientWidth;
- },
- getMaximumHeight = helpers.getMaximumHeight = function(domNode){
- var container = domNode.parentNode;
- // TODO = check cross browser stuff with this.
- return container.clientHeight;
- },
- getMaximumSize = helpers.getMaximumSize = helpers.getMaximumWidth, // legacy support
- retinaScale = helpers.retinaScale = function(chart){
- var ctx = chart.ctx,
- width = chart.canvas.width,
- height = chart.canvas.height;
-
- if (window.devicePixelRatio) {
- ctx.canvas.style.width = width + "px";
- ctx.canvas.style.height = height + "px";
- ctx.canvas.height = height * window.devicePixelRatio;
- ctx.canvas.width = width * window.devicePixelRatio;
- ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
- }
- },
- //-- Canvas methods
- clear = helpers.clear = function(chart){
- chart.ctx.clearRect(0,0,chart.width,chart.height);
- },
- fontString = helpers.fontString = function(pixelSize,fontStyle,fontFamily){
- return fontStyle + " " + pixelSize+"px " + fontFamily;
- },
- longestText = helpers.longestText = function(ctx,font,arrayOfStrings){
- ctx.font = font;
- var longest = 0;
- each(arrayOfStrings,function(string){
- var textWidth = ctx.measureText(string).width;
- longest = (textWidth > longest) ? textWidth : longest;
- });
- return longest;
- },
- drawRoundedRectangle = helpers.drawRoundedRectangle = function(ctx,x,y,width,height,radius){
- ctx.beginPath();
- ctx.moveTo(x + radius, y);
- ctx.lineTo(x + width - radius, y);
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
- ctx.lineTo(x + width, y + height - radius);
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
- ctx.lineTo(x + radius, y + height);
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
- ctx.lineTo(x, y + radius);
- ctx.quadraticCurveTo(x, y, x + radius, y);
- ctx.closePath();
- };
-
-
- //Store a reference to each instance - allowing us to globally resize chart instances on window resize.
- //Destroy method on the chart will remove the instance of the chart from this reference.
- Chart.instances = {};
-
- Chart.Type = function(data,options,chart){
- this.options = options;
- this.chart = chart;
- this.id = uid();
- //Add the chart instance to the global namespace
- Chart.instances[this.id] = this;
-
- // Initialize is always called when a chart type is created
- // By default it is a no op, but it should be extended
- if (options.responsive){
- this.resize();
- }
- this.initialize.call(this,data);
- };
-
- //Core methods that'll be a part of every chart type
- extend(Chart.Type.prototype,{
- initialize : function(){return this;},
- clear : function(){
- clear(this.chart);
- return this;
- },
- stop : function(){
- // Stops any current animation loop occuring
- cancelAnimFrame(this.animationFrame);
- return this;
- },
- resize : function(callback){
- this.stop();
- var canvas = this.chart.canvas,
- newWidth = getMaximumWidth(this.chart.canvas),
- newHeight = this.options.maintainAspectRatio ? newWidth / this.chart.aspectRatio : getMaximumHeight(this.chart.canvas);
-
- canvas.width = this.chart.width = newWidth;
- canvas.height = this.chart.height = newHeight;
-
- retinaScale(this.chart);
-
- if (typeof callback === "function"){
- callback.apply(this, Array.prototype.slice.call(arguments, 1));
- }
- return this;
- },
- reflow : noop,
- render : function(reflow){
- if (reflow){
- this.reflow();
- }
- if (this.options.animation && !reflow){
- helpers.animationLoop(
- this.draw,
- this.options.animationSteps,
- this.options.animationEasing,
- this.options.onAnimationProgress,
- this.options.onAnimationComplete,
- this
- );
- }
- else{
- this.draw();
- this.options.onAnimationComplete.call(this);
- }
- return this;
- },
- generateLegend : function(){
- return template(this.options.legendTemplate,this);
- },
- destroy : function(){
- this.clear();
- unbindEvents(this, this.events);
- var canvas = this.chart.canvas;
-
- // Reset canvas height/width attributes starts a fresh with the canvas context
- canvas.width = this.chart.width;
- canvas.height = this.chart.height;
-
- // < IE9 doesn't support removeProperty
- if (canvas.style.removeProperty) {
- canvas.style.removeProperty('width');
- canvas.style.removeProperty('height');
- } else {
- canvas.style.removeAttribute('width');
- canvas.style.removeAttribute('height');
- }
-
- delete Chart.instances[this.id];
- },
- showTooltip : function(ChartElements, forceRedraw){
- // Only redraw the chart if we've actually changed what we're hovering on.
- if (typeof this.activeElements === 'undefined') this.activeElements = [];
-
- var isChanged = (function(Elements){
- var changed = false;
-
- if (Elements.length !== this.activeElements.length){
- changed = true;
- return changed;
- }
-
- each(Elements, function(element, index){
- if (element !== this.activeElements[index]){
- changed = true;
- }
- }, this);
- return changed;
- }).call(this, ChartElements);
-
- if (!isChanged && !forceRedraw){
- return;
- }
- else{
- this.activeElements = ChartElements;
- }
- this.draw();
- if(this.options.customTooltips){
- this.options.customTooltips(false);
- }
- if (ChartElements.length > 0){
- // If we have multiple datasets, show a MultiTooltip for all of the data points at that index
- if (this.datasets && this.datasets.length > 1) {
- var dataArray,
- dataIndex;
-
- for (var i = this.datasets.length - 1; i >= 0; i--) {
- dataArray = this.datasets[i].points || this.datasets[i].bars || this.datasets[i].segments;
- dataIndex = indexOf(dataArray, ChartElements[0]);
- if (dataIndex !== -1){
- break;
- }
- }
- var tooltipLabels = [],
- tooltipColors = [],
- medianPosition = (function(index) {
-
- // Get all the points at that particular index
- var Elements = [],
- dataCollection,
- xPositions = [],
- yPositions = [],
- xMax,
- yMax,
- xMin,
- yMin;
- helpers.each(this.datasets, function(dataset){
- dataCollection = dataset.points || dataset.bars || dataset.segments;
- if (dataCollection[dataIndex] && dataCollection[dataIndex].hasValue()){
- Elements.push(dataCollection[dataIndex]);
- }
- });
-
- helpers.each(Elements, function(element) {
- xPositions.push(element.x);
- yPositions.push(element.y);
-
-
- //Include any colour information about the element
- tooltipLabels.push(helpers.template(this.options.multiTooltipTemplate, element));
- tooltipColors.push({
- fill: element._saved.fillColor || element.fillColor,
- stroke: element._saved.strokeColor || element.strokeColor
- });
-
- }, this);
-
- yMin = min(yPositions);
- yMax = max(yPositions);
-
- xMin = min(xPositions);
- xMax = max(xPositions);
-
- return {
- x: (xMin > this.chart.width/2) ? xMin : xMax,
- y: (yMin + yMax)/2
- };
- }).call(this, dataIndex);
-
- new Chart.MultiTooltip({
- x: medianPosition.x,
- y: medianPosition.y,
- xPadding: this.options.tooltipXPadding,
- yPadding: this.options.tooltipYPadding,
- xOffset: this.options.tooltipXOffset,
- fillColor: this.options.tooltipFillColor,
- textColor: this.options.tooltipFontColor,
- fontFamily: this.options.tooltipFontFamily,
- fontStyle: this.options.tooltipFontStyle,
- fontSize: this.options.tooltipFontSize,
- titleTextColor: this.options.tooltipTitleFontColor,
- titleFontFamily: this.options.tooltipTitleFontFamily,
- titleFontStyle: this.options.tooltipTitleFontStyle,
- titleFontSize: this.options.tooltipTitleFontSize,
- cornerRadius: this.options.tooltipCornerRadius,
- labels: tooltipLabels,
- legendColors: tooltipColors,
- legendColorBackground : this.options.multiTooltipKeyBackground,
- title: ChartElements[0].label,
- chart: this.chart,
- ctx: this.chart.ctx,
- custom: this.options.customTooltips
- }).draw();
-
- } else {
- each(ChartElements, function(Element) {
- var tooltipPosition = Element.tooltipPosition();
- new Chart.Tooltip({
- x: Math.round(tooltipPosition.x),
- y: Math.round(tooltipPosition.y),
- xPadding: this.options.tooltipXPadding,
- yPadding: this.options.tooltipYPadding,
- fillColor: this.options.tooltipFillColor,
- textColor: this.options.tooltipFontColor,
- fontFamily: this.options.tooltipFontFamily,
- fontStyle: this.options.tooltipFontStyle,
- fontSize: this.options.tooltipFontSize,
- caretHeight: this.options.tooltipCaretSize,
- cornerRadius: this.options.tooltipCornerRadius,
- text: template(this.options.tooltipTemplate, Element),
- chart: this.chart,
- custom: this.options.customTooltips
- }).draw();
- }, this);
- }
- }
- return this;
- },
- toBase64Image : function(){
- return this.chart.canvas.toDataURL.apply(this.chart.canvas, arguments);
- }
- });
-
- Chart.Type.extend = function(extensions){
-
- var parent = this;
-
- var ChartType = function(){
- return parent.apply(this,arguments);
- };
-
- //Copy the prototype object of the this class
- ChartType.prototype = clone(parent.prototype);
- //Now overwrite some of the properties in the base class with the new extensions
- extend(ChartType.prototype, extensions);
-
- ChartType.extend = Chart.Type.extend;
-
- if (extensions.name || parent.prototype.name){
-
- var chartName = extensions.name || parent.prototype.name;
- //Assign any potential default values of the new chart type
-
- //If none are defined, we'll use a clone of the chart type this is being extended from.
- //I.e. if we extend a line chart, we'll use the defaults from the line chart if our new chart
- //doesn't define some defaults of their own.
-
- var baseDefaults = (Chart.defaults[parent.prototype.name]) ? clone(Chart.defaults[parent.prototype.name]) : {};
-
- Chart.defaults[chartName] = extend(baseDefaults,extensions.defaults);
-
- Chart.types[chartName] = ChartType;
-
- //Register this new chart type in the Chart prototype
- Chart.prototype[chartName] = function(data,options){
- var config = merge(Chart.defaults.global, Chart.defaults[chartName], options || {});
- return new ChartType(data,config,this);
- };
- } else{
- warn("Name not provided for this chart, so it hasn't been registered");
- }
- return parent;
- };
-
- Chart.Element = function(configuration){
- extend(this,configuration);
- this.initialize.apply(this,arguments);
- this.save();
- };
- extend(Chart.Element.prototype,{
- initialize : function(){},
- restore : function(props){
- if (!props){
- extend(this,this._saved);
- } else {
- each(props,function(key){
- this[key] = this._saved[key];
- },this);
- }
- return this;
- },
- save : function(){
- this._saved = clone(this);
- delete this._saved._saved;
- return this;
- },
- update : function(newProps){
- each(newProps,function(value,key){
- this._saved[key] = this[key];
- this[key] = value;
- },this);
- return this;
- },
- transition : function(props,ease){
- each(props,function(value,key){
- this[key] = ((value - this._saved[key]) * ease) + this._saved[key];
- },this);
- return this;
- },
- tooltipPosition : function(){
- return {
- x : this.x,
- y : this.y
- };
- },
- hasValue: function(){
- return isNumber(this.value);
- }
- });
-
- Chart.Element.extend = inherits;
-
-
- Chart.Point = Chart.Element.extend({
- display: true,
- inRange: function(chartX,chartY){
- var hitDetectionRange = this.hitDetectionRadius + this.radius;
- return ((Math.pow(chartX-this.x, 2)+Math.pow(chartY-this.y, 2)) < Math.pow(hitDetectionRange,2));
- },
- draw : function(){
- if (this.display){
- var ctx = this.ctx;
- ctx.beginPath();
-
- ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2);
- ctx.closePath();
-
- ctx.strokeStyle = this.strokeColor;
- ctx.lineWidth = this.strokeWidth;
-
- ctx.fillStyle = this.fillColor;
-
- ctx.fill();
- ctx.stroke();
- }
-
-
- //Quick debug for bezier curve splining
- //Highlights control points and the line between them.
- //Handy for dev - stripped in the min version.
-
- // ctx.save();
- // ctx.fillStyle = "black";
- // ctx.strokeStyle = "black"
- // ctx.beginPath();
- // ctx.arc(this.controlPoints.inner.x,this.controlPoints.inner.y, 2, 0, Math.PI*2);
- // ctx.fill();
-
- // ctx.beginPath();
- // ctx.arc(this.controlPoints.outer.x,this.controlPoints.outer.y, 2, 0, Math.PI*2);
- // ctx.fill();
-
- // ctx.moveTo(this.controlPoints.inner.x,this.controlPoints.inner.y);
- // ctx.lineTo(this.x, this.y);
- // ctx.lineTo(this.controlPoints.outer.x,this.controlPoints.outer.y);
- // ctx.stroke();
-
- // ctx.restore();
-
-
-
- }
- });
-
- Chart.Arc = Chart.Element.extend({
- inRange : function(chartX,chartY){
-
- var pointRelativePosition = helpers.getAngleFromPoint(this, {
- x: chartX,
- y: chartY
- });
-
- //Check if within the range of the open/close angle
- var betweenAngles = (pointRelativePosition.angle >= this.startAngle && pointRelativePosition.angle <= this.endAngle),
- withinRadius = (pointRelativePosition.distance >= this.innerRadius && pointRelativePosition.distance <= this.outerRadius);
-
- return (betweenAngles && withinRadius);
- //Ensure within the outside of the arc centre, but inside arc outer
- },
- tooltipPosition : function(){
- var centreAngle = this.startAngle + ((this.endAngle - this.startAngle) / 2),
- rangeFromCentre = (this.outerRadius - this.innerRadius) / 2 + this.innerRadius;
- return {
- x : this.x + (Math.cos(centreAngle) * rangeFromCentre),
- y : this.y + (Math.sin(centreAngle) * rangeFromCentre)
- };
- },
- draw : function(animationPercent){
-
- var easingDecimal = animationPercent || 1;
-
- var ctx = this.ctx;
-
- ctx.beginPath();
-
- ctx.arc(this.x, this.y, this.outerRadius, this.startAngle, this.endAngle);
-
- ctx.arc(this.x, this.y, this.innerRadius, this.endAngle, this.startAngle, true);
-
- ctx.closePath();
- ctx.strokeStyle = this.strokeColor;
- ctx.lineWidth = this.strokeWidth;
-
- ctx.fillStyle = this.fillColor;
-
- ctx.fill();
- ctx.lineJoin = 'bevel';
-
- if (this.showStroke){
- ctx.stroke();
- }
- }
- });
-
- Chart.Rectangle = Chart.Element.extend({
- draw : function(){
- var ctx = this.ctx,
- halfWidth = this.width/2,
- leftX = this.x - halfWidth,
- rightX = this.x + halfWidth,
- top = this.base - (this.base - this.y),
- halfStroke = this.strokeWidth / 2;
-
- // Canvas doesn't allow us to stroke inside the width so we can
- // adjust the sizes to fit if we're setting a stroke on the line
- if (this.showStroke){
- leftX += halfStroke;
- rightX -= halfStroke;
- top += halfStroke;
- }
-
- ctx.beginPath();
-
- ctx.fillStyle = this.fillColor;
- ctx.strokeStyle = this.strokeColor;
- ctx.lineWidth = this.strokeWidth;
-
- // It'd be nice to keep this class totally generic to any rectangle
- // and simply specify which border to miss out.
- ctx.moveTo(leftX, this.base);
- ctx.lineTo(leftX, top);
- ctx.lineTo(rightX, top);
- ctx.lineTo(rightX, this.base);
- ctx.fill();
- if (this.showStroke){
- ctx.stroke();
- }
- },
- height : function(){
- return this.base - this.y;
- },
- inRange : function(chartX,chartY){
- return (chartX >= this.x - this.width/2 && chartX <= this.x + this.width/2) && (chartY >= this.y && chartY <= this.base);
- }
- });
-
- Chart.Tooltip = Chart.Element.extend({
- draw : function(){
-
- var ctx = this.chart.ctx;
-
- ctx.font = fontString(this.fontSize,this.fontStyle,this.fontFamily);
-
- this.xAlign = "center";
- this.yAlign = "above";
-
- //Distance between the actual element.y position and the start of the tooltip caret
- var caretPadding = this.caretPadding = 2;
-
- var tooltipWidth = ctx.measureText(this.text).width + 2*this.xPadding,
- tooltipRectHeight = this.fontSize + 2*this.yPadding,
- tooltipHeight = tooltipRectHeight + this.caretHeight + caretPadding;
-
- if (this.x + tooltipWidth/2 >this.chart.width){
- this.xAlign = "left";
- } else if (this.x - tooltipWidth/2 < 0){
- this.xAlign = "right";
- }
-
- if (this.y - tooltipHeight < 0){
- this.yAlign = "below";
- }
-
-
- var tooltipX = this.x - tooltipWidth/2,
- tooltipY = this.y - tooltipHeight;
-
- ctx.fillStyle = this.fillColor;
-
- // Custom Tooltips
- if(this.custom){
- this.custom(this);
- }
- else{
- switch(this.yAlign)
- {
- case "above":
- //Draw a caret above the x/y
- ctx.beginPath();
- ctx.moveTo(this.x,this.y - caretPadding);
- ctx.lineTo(this.x + this.caretHeight, this.y - (caretPadding + this.caretHeight));
- ctx.lineTo(this.x - this.caretHeight, this.y - (caretPadding + this.caretHeight));
- ctx.closePath();
- ctx.fill();
- break;
- case "below":
- tooltipY = this.y + caretPadding + this.caretHeight;
- //Draw a caret below the x/y
- ctx.beginPath();
- ctx.moveTo(this.x, this.y + caretPadding);
- ctx.lineTo(this.x + this.caretHeight, this.y + caretPadding + this.caretHeight);
- ctx.lineTo(this.x - this.caretHeight, this.y + caretPadding + this.caretHeight);
- ctx.closePath();
- ctx.fill();
- break;
- }
-
- switch(this.xAlign)
- {
- case "left":
- tooltipX = this.x - tooltipWidth + (this.cornerRadius + this.caretHeight);
- break;
- case "right":
- tooltipX = this.x - (this.cornerRadius + this.caretHeight);
- break;
- }
-
- drawRoundedRectangle(ctx,tooltipX,tooltipY,tooltipWidth,tooltipRectHeight,this.cornerRadius);
-
- ctx.fill();
-
- ctx.fillStyle = this.textColor;
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText(this.text, tooltipX + tooltipWidth/2, tooltipY + tooltipRectHeight/2);
- }
- }
- });
-
- Chart.MultiTooltip = Chart.Element.extend({
- initialize : function(){
- this.font = fontString(this.fontSize,this.fontStyle,this.fontFamily);
-
- this.titleFont = fontString(this.titleFontSize,this.titleFontStyle,this.titleFontFamily);
-
- this.height = (this.labels.length * this.fontSize) + ((this.labels.length-1) * (this.fontSize/2)) + (this.yPadding*2) + this.titleFontSize *1.5;
-
- this.ctx.font = this.titleFont;
-
- var titleWidth = this.ctx.measureText(this.title).width,
- //Label has a legend square as well so account for this.
- labelWidth = longestText(this.ctx,this.font,this.labels) + this.fontSize + 3,
- longestTextWidth = max([labelWidth,titleWidth]);
-
- this.width = longestTextWidth + (this.xPadding*2);
-
-
- var halfHeight = this.height/2;
-
- //Check to ensure the height will fit on the canvas
- if (this.y - halfHeight < 0 ){
- this.y = halfHeight;
- } else if (this.y + halfHeight > this.chart.height){
- this.y = this.chart.height - halfHeight;
- }
-
- //Decide whether to align left or right based on position on canvas
- if (this.x > this.chart.width/2){
- this.x -= this.xOffset + this.width;
- } else {
- this.x += this.xOffset;
- }
-
-
- },
- getLineHeight : function(index){
- var baseLineHeight = this.y - (this.height/2) + this.yPadding,
- afterTitleIndex = index-1;
-
- //If the index is zero, we're getting the title
- if (index === 0){
- return baseLineHeight + this.titleFontSize/2;
- } else{
- return baseLineHeight + ((this.fontSize*1.5*afterTitleIndex) + this.fontSize/2) + this.titleFontSize * 1.5;
- }
-
- },
- draw : function(){
- // Custom Tooltips
- if(this.custom){
- this.custom(this);
- }
- else{
- drawRoundedRectangle(this.ctx,this.x,this.y - this.height/2,this.width,this.height,this.cornerRadius);
- var ctx = this.ctx;
- ctx.fillStyle = this.fillColor;
- ctx.fill();
- ctx.closePath();
-
- ctx.textAlign = "left";
- ctx.textBaseline = "middle";
- ctx.fillStyle = this.titleTextColor;
- ctx.font = this.titleFont;
-
- ctx.fillText(this.title,this.x + this.xPadding, this.getLineHeight(0));
-
- ctx.font = this.font;
- helpers.each(this.labels,function(label,index){
- ctx.fillStyle = this.textColor;
- ctx.fillText(label,this.x + this.xPadding + this.fontSize + 3, this.getLineHeight(index + 1));
-
- //A bit gnarly, but clearing this rectangle breaks when using explorercanvas (clears whole canvas)
- //ctx.clearRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize);
- //Instead we'll make a white filled block to put the legendColour palette over.
-
- ctx.fillStyle = this.legendColorBackground;
- ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize);
-
- ctx.fillStyle = this.legendColors[index].fill;
- ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize);
-
-
- },this);
- }
- }
- });
-
- Chart.Scale = Chart.Element.extend({
- initialize : function(){
- this.fit();
- },
- buildYLabels : function(){
- this.yLabels = [];
-
- var stepDecimalPlaces = getDecimalPlaces(this.stepValue);
-
- for (var i=0; i<=this.steps; i++){
- this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)}));
- }
- this.yLabelWidth = (this.display && this.showLabels) ? longestText(this.ctx,this.font,this.yLabels) : 0;
- },
- addXLabel : function(label){
- this.xLabels.push(label);
- this.valuesCount++;
- this.fit();
- },
- removeXLabel : function(){
- this.xLabels.shift();
- this.valuesCount--;
- this.fit();
- },
- // Fitting loop to rotate x Labels and figure out what fits there, and also calculate how many Y steps to use
- fit: function(){
- // First we need the width of the yLabels, assuming the xLabels aren't rotated
-
- // To do that we need the base line at the top and base of the chart, assuming there is no x label rotation
- this.startPoint = (this.display) ? this.fontSize : 0;
- this.endPoint = (this.display) ? this.height - (this.fontSize * 1.5) - 5 : this.height; // -5 to pad labels
-
- // Apply padding settings to the start and end point.
- this.startPoint += this.padding;
- this.endPoint -= this.padding;
-
- // Cache the starting height, so can determine if we need to recalculate the scale yAxis
- var cachedHeight = this.endPoint - this.startPoint,
- cachedYLabelWidth;
-
- // Build the current yLabels so we have an idea of what size they'll be to start
- /*
- * This sets what is returned from calculateScaleRange as static properties of this class:
- *
- this.steps;
- this.stepValue;
- this.min;
- this.max;
- *
- */
- this.calculateYRange(cachedHeight);
-
- // With these properties set we can now build the array of yLabels
- // and also the width of the largest yLabel
- this.buildYLabels();
-
- this.calculateXLabelRotation();
-
- while((cachedHeight > this.endPoint - this.startPoint)){
- cachedHeight = this.endPoint - this.startPoint;
- cachedYLabelWidth = this.yLabelWidth;
-
- this.calculateYRange(cachedHeight);
- this.buildYLabels();
-
- // Only go through the xLabel loop again if the yLabel width has changed
- if (cachedYLabelWidth < this.yLabelWidth){
- this.calculateXLabelRotation();
- }
- }
-
- },
- calculateXLabelRotation : function(){
- //Get the width of each grid by calculating the difference
- //between x offsets between 0 and 1.
-
- this.ctx.font = this.font;
-
- var firstWidth = this.ctx.measureText(this.xLabels[0]).width,
- lastWidth = this.ctx.measureText(this.xLabels[this.xLabels.length - 1]).width,
- firstRotated,
- lastRotated;
-
-
- this.xScalePaddingRight = lastWidth/2 + 3;
- this.xScalePaddingLeft = (firstWidth/2 > this.yLabelWidth + 10) ? firstWidth/2 : this.yLabelWidth + 10;
-
- this.xLabelRotation = 0;
- if (this.display){
- var originalLabelWidth = longestText(this.ctx,this.font,this.xLabels),
- cosRotation,
- firstRotatedWidth;
- this.xLabelWidth = originalLabelWidth;
- //Allow 3 pixels x2 padding either side for label readability
- var xGridWidth = Math.floor(this.calculateX(1) - this.calculateX(0)) - 6;
-
- //Max label rotate should be 90 - also act as a loop counter
- while ((this.xLabelWidth > xGridWidth && this.xLabelRotation === 0) || (this.xLabelWidth > xGridWidth && this.xLabelRotation <= 90 && this.xLabelRotation > 0)){
- cosRotation = Math.cos(toRadians(this.xLabelRotation));
-
- firstRotated = cosRotation * firstWidth;
- lastRotated = cosRotation * lastWidth;
-
- // We're right aligning the text now.
- if (firstRotated + this.fontSize / 2 > this.yLabelWidth + 8){
- this.xScalePaddingLeft = firstRotated + this.fontSize / 2;
- }
- this.xScalePaddingRight = this.fontSize/2;
-
-
- this.xLabelRotation++;
- this.xLabelWidth = cosRotation * originalLabelWidth;
-
- }
- if (this.xLabelRotation > 0){
- this.endPoint -= Math.sin(toRadians(this.xLabelRotation))*originalLabelWidth + 3;
- }
- }
- else{
- this.xLabelWidth = 0;
- this.xScalePaddingRight = this.padding;
- this.xScalePaddingLeft = this.padding;
- }
-
- },
- // Needs to be overidden in each Chart type
- // Otherwise we need to pass all the data into the scale class
- calculateYRange: noop,
- drawingArea: function(){
- return this.startPoint - this.endPoint;
- },
- calculateY : function(value){
- var scalingFactor = this.drawingArea() / (this.min - this.max);
- return this.endPoint - (scalingFactor * (value - this.min));
- },
- calculateX : function(index){
- var isRotated = (this.xLabelRotation > 0),
- // innerWidth = (this.offsetGridLines) ? this.width - offsetLeft - this.padding : this.width - (offsetLeft + halfLabelWidth * 2) - this.padding,
- innerWidth = this.width - (this.xScalePaddingLeft + this.xScalePaddingRight),
- valueWidth = innerWidth/Math.max((this.valuesCount - ((this.offsetGridLines) ? 0 : 1)), 1),
- valueOffset = (valueWidth * index) + this.xScalePaddingLeft;
-
- if (this.offsetGridLines){
- valueOffset += (valueWidth/2);
- }
-
- return Math.round(valueOffset);
- },
- update : function(newProps){
- helpers.extend(this, newProps);
- this.fit();
- },
- draw : function(){
- var ctx = this.ctx,
- yLabelGap = (this.endPoint - this.startPoint) / this.steps,
- xStart = Math.round(this.xScalePaddingLeft);
- if (this.display){
- ctx.fillStyle = this.textColor;
- ctx.font = this.font;
- each(this.yLabels,function(labelString,index){
- var yLabelCenter = this.endPoint - (yLabelGap * index),
- linePositionY = Math.round(yLabelCenter),
- drawHorizontalLine = this.showHorizontalLines;
-
- ctx.textAlign = "right";
- ctx.textBaseline = "middle";
- if (this.showLabels){
- ctx.fillText(labelString,xStart - 10,yLabelCenter);
- }
-
- // This is X axis, so draw it
- if (index === 0 && !drawHorizontalLine){
- drawHorizontalLine = true;
- }
-
- if (drawHorizontalLine){
- ctx.beginPath();
- }
-
- if (index > 0){
- // This is a grid line in the centre, so drop that
- ctx.lineWidth = this.gridLineWidth;
- ctx.strokeStyle = this.gridLineColor;
- } else {
- // This is the first line on the scale
- ctx.lineWidth = this.lineWidth;
- ctx.strokeStyle = this.lineColor;
- }
-
- linePositionY += helpers.aliasPixel(ctx.lineWidth);
-
- if(drawHorizontalLine){
- ctx.moveTo(xStart, linePositionY);
- ctx.lineTo(this.width, linePositionY);
- ctx.stroke();
- ctx.closePath();
- }
-
- ctx.lineWidth = this.lineWidth;
- ctx.strokeStyle = this.lineColor;
- ctx.beginPath();
- ctx.moveTo(xStart - 5, linePositionY);
- ctx.lineTo(xStart, linePositionY);
- ctx.stroke();
- ctx.closePath();
-
- },this);
-
- each(this.xLabels,function(label,index){
- var xPos = this.calculateX(index) + aliasPixel(this.lineWidth),
- // Check to see if line/bar here and decide where to place the line
- linePos = this.calculateX(index - (this.offsetGridLines ? 0.5 : 0)) + aliasPixel(this.lineWidth),
- isRotated = (this.xLabelRotation > 0),
- drawVerticalLine = this.showVerticalLines;
-
- // This is Y axis, so draw it
- if (index === 0 && !drawVerticalLine){
- drawVerticalLine = true;
- }
-
- if (drawVerticalLine){
- ctx.beginPath();
- }
-
- if (index > 0){
- // This is a grid line in the centre, so drop that
- ctx.lineWidth = this.gridLineWidth;
- ctx.strokeStyle = this.gridLineColor;
- } else {
- // This is the first line on the scale
- ctx.lineWidth = this.lineWidth;
- ctx.strokeStyle = this.lineColor;
- }
-
- if (drawVerticalLine){
- ctx.moveTo(linePos,this.endPoint);
- ctx.lineTo(linePos,this.startPoint - 3);
- ctx.stroke();
- ctx.closePath();
- }
-
-
- ctx.lineWidth = this.lineWidth;
- ctx.strokeStyle = this.lineColor;
-
-
- // Small lines at the bottom of the base grid line
- ctx.beginPath();
- ctx.moveTo(linePos,this.endPoint);
- ctx.lineTo(linePos,this.endPoint + 5);
- ctx.stroke();
- ctx.closePath();
-
- ctx.save();
- ctx.translate(xPos,(isRotated) ? this.endPoint + 12 : this.endPoint + 8);
- ctx.rotate(toRadians(this.xLabelRotation)*-1);
- ctx.font = this.font;
- ctx.textAlign = (isRotated) ? "right" : "center";
- ctx.textBaseline = (isRotated) ? "middle" : "top";
- ctx.fillText(label, 0, 0);
- ctx.restore();
- },this);
-
- }
- }
-
- });
-
- Chart.RadialScale = Chart.Element.extend({
- initialize: function(){
- this.size = min([this.height, this.width]);
- this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2);
- },
- calculateCenterOffset: function(value){
- // Take into account half font size + the yPadding of the top value
- var scalingFactor = this.drawingArea / (this.max - this.min);
-
- return (value - this.min) * scalingFactor;
- },
- update : function(){
- if (!this.lineArc){
- this.setScaleSize();
- } else {
- this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2);
- }
- this.buildYLabels();
- },
- buildYLabels: function(){
- this.yLabels = [];
-
- var stepDecimalPlaces = getDecimalPlaces(this.stepValue);
-
- for (var i=0; i<=this.steps; i++){
- this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)}));
- }
- },
- getCircumference : function(){
- return ((Math.PI*2) / this.valuesCount);
- },
- setScaleSize: function(){
- /*
- * Right, this is really confusing and there is a lot of maths going on here
- * The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9
- *
- * Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif
- *
- * Solution:
- *
- * We assume the radius of the polygon is half the size of the canvas at first
- * at each index we check if the text overlaps.
- *
- * Where it does, we store that angle and that index.
- *
- * After finding the largest index and angle we calculate how much we need to remove
- * from the shape radius to move the point inwards by that x.
- *
- * We average the left and right distances to get the maximum shape radius that can fit in the box
- * along with labels.
- *
- * Once we have that, we can find the centre point for the chart, by taking the x text protrusion
- * on each side, removing that from the size, halving it and adding the left x protrusion width.
- *
- * This will mean we have a shape fitted to the canvas, as large as it can be with the labels
- * and position it in the most space efficient manner
- *
- * https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif
- */
-
-
- // Get maximum radius of the polygon. Either half the height (minus the text width) or half the width.
- // Use this to calculate the offset + change. - Make sure L/R protrusion is at least 0 to stop issues with centre points
- var largestPossibleRadius = min([(this.height/2 - this.pointLabelFontSize - 5), this.width/2]),
- pointPosition,
- i,
- textWidth,
- halfTextWidth,
- furthestRight = this.width,
- furthestRightIndex,
- furthestRightAngle,
- furthestLeft = 0,
- furthestLeftIndex,
- furthestLeftAngle,
- xProtrusionLeft,
- xProtrusionRight,
- radiusReductionRight,
- radiusReductionLeft,
- maxWidthRadius;
- this.ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily);
- for (i=0;i<this.valuesCount;i++){
- // 5px to space the text slightly out - similar to what we do in the draw function.
- pointPosition = this.getPointPosition(i, largestPossibleRadius);
- textWidth = this.ctx.measureText(template(this.templateString, { value: this.labels[i] })).width + 5;
- if (i === 0 || i === this.valuesCount/2){
- // If we're at index zero, or exactly the middle, we're at exactly the top/bottom
- // of the radar chart, so text will be aligned centrally, so we'll half it and compare
- // w/left and right text sizes
- halfTextWidth = textWidth/2;
- if (pointPosition.x + halfTextWidth > furthestRight) {
- furthestRight = pointPosition.x + halfTextWidth;
- furthestRightIndex = i;
- }
- if (pointPosition.x - halfTextWidth < furthestLeft) {
- furthestLeft = pointPosition.x - halfTextWidth;
- furthestLeftIndex = i;
- }
- }
- else if (i < this.valuesCount/2) {
- // Less than half the values means we'll left align the text
- if (pointPosition.x + textWidth > furthestRight) {
- furthestRight = pointPosition.x + textWidth;
- furthestRightIndex = i;
- }
- }
- else if (i > this.valuesCount/2){
- // More than half the values means we'll right align the text
- if (pointPosition.x - textWidth < furthestLeft) {
- furthestLeft = pointPosition.x - textWidth;
- furthestLeftIndex = i;
- }
- }
- }
-
- xProtrusionLeft = furthestLeft;
-
- xProtrusionRight = Math.ceil(furthestRight - this.width);
-
- furthestRightAngle = this.getIndexAngle(furthestRightIndex);
-
- furthestLeftAngle = this.getIndexAngle(furthestLeftIndex);
-
- radiusReductionRight = xProtrusionRight / Math.sin(furthestRightAngle + Math.PI/2);
-
- radiusReductionLeft = xProtrusionLeft / Math.sin(furthestLeftAngle + Math.PI/2);
-
- // Ensure we actually need to reduce the size of the chart
- radiusReductionRight = (isNumber(radiusReductionRight)) ? radiusReductionRight : 0;
- radiusReductionLeft = (isNumber(radiusReductionLeft)) ? radiusReductionLeft : 0;
-
- this.drawingArea = largestPossibleRadius - (radiusReductionLeft + radiusReductionRight)/2;
-
- //this.drawingArea = min([maxWidthRadius, (this.height - (2 * (this.pointLabelFontSize + 5)))/2])
- this.setCenterPoint(radiusReductionLeft, radiusReductionRight);
-
- },
- setCenterPoint: function(leftMovement, rightMovement){
-
- var maxRight = this.width - rightMovement - this.drawingArea,
- maxLeft = leftMovement + this.drawingArea;
-
- this.xCenter = (maxLeft + maxRight)/2;
- // Always vertically in the centre as the text height doesn't change
- this.yCenter = (this.height/2);
- },
-
- getIndexAngle : function(index){
- var angleMultiplier = (Math.PI * 2) / this.valuesCount;
- // Start from the top instead of right, so remove a quarter of the circle
-
- return index * angleMultiplier - (Math.PI/2);
- },
- getPointPosition : function(index, distanceFromCenter){
- var thisAngle = this.getIndexAngle(index);
- return {
- x : (Math.cos(thisAngle) * distanceFromCenter) + this.xCenter,
- y : (Math.sin(thisAngle) * distanceFromCenter) + this.yCenter
- };
- },
- draw: function(){
- if (this.display){
- var ctx = this.ctx;
- each(this.yLabels, function(label, index){
- // Don't draw a centre value
- if (index > 0){
- var yCenterOffset = index * (this.drawingArea/this.steps),
- yHeight = this.yCenter - yCenterOffset,
- pointPosition;
-
- // Draw circular lines around the scale
- if (this.lineWidth > 0){
- ctx.strokeStyle = this.lineColor;
- ctx.lineWidth = this.lineWidth;
-
- if(this.lineArc){
- ctx.beginPath();
- ctx.arc(this.xCenter, this.yCenter, yCenterOffset, 0, Math.PI*2);
- ctx.closePath();
- ctx.stroke();
- } else{
- ctx.beginPath();
- for (var i=0;i<this.valuesCount;i++)
- {
- pointPosition = this.getPointPosition(i, this.calculateCenterOffset(this.min + (index * this.stepValue)));
- if (i === 0){
- ctx.moveTo(pointPosition.x, pointPosition.y);
- } else {
- ctx.lineTo(pointPosition.x, pointPosition.y);
- }
- }
- ctx.closePath();
- ctx.stroke();
- }
- }
- if(this.showLabels){
- ctx.font = fontString(this.fontSize,this.fontStyle,this.fontFamily);
- if (this.showLabelBackdrop){
- var labelWidth = ctx.measureText(label).width;
- ctx.fillStyle = this.backdropColor;
- ctx.fillRect(
- this.xCenter - labelWidth/2 - this.backdropPaddingX,
- yHeight - this.fontSize/2 - this.backdropPaddingY,
- labelWidth + this.backdropPaddingX*2,
- this.fontSize + this.backdropPaddingY*2
- );
- }
- ctx.textAlign = 'center';
- ctx.textBaseline = "middle";
- ctx.fillStyle = this.fontColor;
- ctx.fillText(label, this.xCenter, yHeight);
- }
- }
- }, this);
-
- if (!this.lineArc){
- ctx.lineWidth = this.angleLineWidth;
- ctx.strokeStyle = this.angleLineColor;
- for (var i = this.valuesCount - 1; i >= 0; i--) {
- if (this.angleLineWidth > 0){
- var outerPosition = this.getPointPosition(i, this.calculateCenterOffset(this.max));
- ctx.beginPath();
- ctx.moveTo(this.xCenter, this.yCenter);
- ctx.lineTo(outerPosition.x, outerPosition.y);
- ctx.stroke();
- ctx.closePath();
- }
- // Extra 3px out for some label spacing
- var pointLabelPosition = this.getPointPosition(i, this.calculateCenterOffset(this.max) + 5);
- ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily);
- ctx.fillStyle = this.pointLabelFontColor;
-
- var labelsCount = this.labels.length,
- halfLabelsCount = this.labels.length/2,
- quarterLabelsCount = halfLabelsCount/2,
- upperHalf = (i < quarterLabelsCount || i > labelsCount - quarterLabelsCount),
- exactQuarter = (i === quarterLabelsCount || i === labelsCount - quarterLabelsCount);
- if (i === 0){
- ctx.textAlign = 'center';
- } else if(i === halfLabelsCount){
- ctx.textAlign = 'center';
- } else if (i < halfLabelsCount){
- ctx.textAlign = 'left';
- } else {
- ctx.textAlign = 'right';
- }
-
- // Set the correct text baseline based on outer positioning
- if (exactQuarter){
- ctx.textBaseline = 'middle';
- } else if (upperHalf){
- ctx.textBaseline = 'bottom';
- } else {
- ctx.textBaseline = 'top';
- }
-
- ctx.fillText(this.labels[i], pointLabelPosition.x, pointLabelPosition.y);
- }
- }
- }
- }
- });
-
- // Attach global event to resize each chart instance when the browser resizes
- helpers.addEvent(window, "resize", (function(){
- // Basic debounce of resize function so it doesn't hurt performance when resizing browser.
- var timeout;
- return function(){
- clearTimeout(timeout);
- timeout = setTimeout(function(){
- each(Chart.instances,function(instance){
- // If the responsive flag is set in the chart instance config
- // Cascade the resize event down to the chart.
- if (instance.options.responsive){
- instance.resize(instance.render, true);
- }
- });
- }, 50);
- };
- })());
-
-
- if (amd) {
- define(function(){
- return Chart;
- });
- } else if (typeof module === 'object' && module.exports) {
- module.exports = Chart;
- }
-
- root.Chart = Chart;
-
- Chart.noConflict = function(){
- root.Chart = previous;
- return Chart;
- };
-
-}).call(this);
-
-(function(){
- "use strict";
-
- var root = this,
- Chart = root.Chart,
- helpers = Chart.helpers;
-
-
- var defaultConfig = {
- //Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value
- scaleBeginAtZero : true,
-
- //Boolean - Whether grid lines are shown across the chart
- scaleShowGridLines : true,
-
- //String - Colour of the grid lines
- scaleGridLineColor : "rgba(0,0,0,.05)",
-
- //Number - Width of the grid lines
- scaleGridLineWidth : 1,
-
- //Boolean - Whether to show horizontal lines (except X axis)
- scaleShowHorizontalLines: true,
-
- //Boolean - Whether to show vertical lines (except Y axis)
- scaleShowVerticalLines: true,
-
- //Boolean - If there is a stroke on each bar
- barShowStroke : true,
-
- //Number - Pixel width of the bar stroke
- barStrokeWidth : 2,
-
- //Number - Spacing between each of the X value sets
- barValueSpacing : 5,
-
- //Number - Spacing between data sets within X values
- barDatasetSpacing : 1,
-
- //String - A legend template
- legendTemplate : "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; i++){%><li><span style=\"background-color:<%=datasets[i].fillColor%>\"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>"
-
- };
-
-
- Chart.Type.extend({
- name: "Bar",
- defaults : defaultConfig,
- initialize: function(data){
-
- //Expose options as a scope variable here so we can access it in the ScaleClass
- var options = this.options;
-
- this.ScaleClass = Chart.Scale.extend({
- offsetGridLines : true,
- calculateBarX : function(datasetCount, datasetIndex, barIndex){
- //Reusable method for calculating the xPosition of a given bar based on datasetIndex & width of the bar
- var xWidth = this.calculateBaseWidth(),
- xAbsolute = this.calculateX(barIndex) - (xWidth/2),
- barWidth = this.calculateBarWidth(datasetCount);
-
- return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * options.barDatasetSpacing) + barWidth/2;
- },
- calculateBaseWidth : function(){
- return (this.calculateX(1) - this.calculateX(0)) - (2*options.barValueSpacing);
- },
- calculateBarWidth : function(datasetCount){
- //The padding between datasets is to the right of each bar, providing that there are more than 1 dataset
- var baseWidth = this.calculateBaseWidth() - ((datasetCount - 1) * options.barDatasetSpacing);
-
- return (baseWidth / datasetCount);
- }
- });
-
- this.datasets = [];
-
- //Set up tooltip events on the chart
- if (this.options.showTooltips){
- helpers.bindEvents(this, this.options.tooltipEvents, function(evt){
- var activeBars = (evt.type !== 'mouseout') ? this.getBarsAtEvent(evt) : [];
-
- this.eachBars(function(bar){
- bar.restore(['fillColor', 'strokeColor']);
- });
- helpers.each(activeBars, function(activeBar){
- activeBar.fillColor = activeBar.highlightFill;
- activeBar.strokeColor = activeBar.highlightStroke;
- });
- this.showTooltip(activeBars);
- });
- }
-
- //Declare the extension of the default point, to cater for the options passed in to the constructor
- this.BarClass = Chart.Rectangle.extend({
- strokeWidth : this.options.barStrokeWidth,
- showStroke : this.options.barShowStroke,
- ctx : this.chart.ctx
- });
-
- //Iterate through each of the datasets, and build this into a property of the chart
- helpers.each(data.datasets,function(dataset,datasetIndex){
-
- var datasetObject = {
- label : dataset.label || null,
- fillColor : dataset.fillColor,
- strokeColor : dataset.strokeColor,
- bars : []
- };
-
- this.datasets.push(datasetObject);
-
- helpers.each(dataset.data,function(dataPoint,index){
- //Add a new point for each piece of data, passing any required data to draw.
- datasetObject.bars.push(new this.BarClass({
- value : dataPoint,
- label : data.labels[index],
- datasetLabel: dataset.label,
- strokeColor : dataset.strokeColor,
- fillColor : dataset.fillColor,
- highlightFill : dataset.highlightFill || dataset.fillColor,
- highlightStroke : dataset.highlightStroke || dataset.strokeColor
- }));
- },this);
-
- },this);
-
- this.buildScale(data.labels);
-
- this.BarClass.prototype.base = this.scale.endPoint;
-
- this.eachBars(function(bar, index, datasetIndex){
- helpers.extend(bar, {
- width : this.scale.calculateBarWidth(this.datasets.length),
- x: this.scale.calculateBarX(this.datasets.length, datasetIndex, index),
- y: this.scale.endPoint
- });
- bar.save();
- }, this);
-
- this.render();
- },
- update : function(){
- this.scale.update();
- // Reset any highlight colours before updating.
- helpers.each(this.activeElements, function(activeElement){
- activeElement.restore(['fillColor', 'strokeColor']);
- });
-
- this.eachBars(function(bar){
- bar.save();
- });
- this.render();
- },
- eachBars : function(callback){
- helpers.each(this.datasets,function(dataset, datasetIndex){
- helpers.each(dataset.bars, callback, this, datasetIndex);
- },this);
- },
- getBarsAtEvent : function(e){
- var barsArray = [],
- eventPosition = helpers.getRelativePosition(e),
- datasetIterator = function(dataset){
- barsArray.push(dataset.bars[barIndex]);
- },
- barIndex;
-
- for (var datasetIndex = 0; datasetIndex < this.datasets.length; datasetIndex++) {
- for (barIndex = 0; barIndex < this.datasets[datasetIndex].bars.length; barIndex++) {
- if (this.datasets[datasetIndex].bars[barIndex].inRange(eventPosition.x,eventPosition.y)){
- helpers.each(this.datasets, datasetIterator);
- return barsArray;
- }
- }
- }
-
- return barsArray;
- },
- buildScale : function(labels){
- var self = this;
-
- var dataTotal = function(){
- var values = [];
- self.eachBars(function(bar){
- values.push(bar.value);
- });
- return values;
- };
-
- var scaleOptions = {
- templateString : this.options.scaleLabel,
- height : this.chart.height,
- width : this.chart.width,
- ctx : this.chart.ctx,
- textColor : this.options.scaleFontColor,
- fontSize : this.options.scaleFontSize,
- fontStyle : this.options.scaleFontStyle,
- fontFamily : this.options.scaleFontFamily,
- valuesCount : labels.length,
- beginAtZero : this.options.scaleBeginAtZero,
- integersOnly : this.options.scaleIntegersOnly,
- calculateYRange: function(currentHeight){
- var updatedRanges = helpers.calculateScaleRange(
- dataTotal(),
- currentHeight,
- this.fontSize,
- this.beginAtZero,
- this.integersOnly
- );
- helpers.extend(this, updatedRanges);
- },
- xLabels : labels,
- font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily),
- lineWidth : this.options.scaleLineWidth,
- lineColor : this.options.scaleLineColor,
- showHorizontalLines : this.options.scaleShowHorizontalLines,
- showVerticalLines : this.options.scaleShowVerticalLines,
- gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0,
- gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)",
- padding : (this.options.showScale) ? 0 : (this.options.barShowStroke) ? this.options.barStrokeWidth : 0,
- showLabels : this.options.scaleShowLabels,
- display : this.options.showScale
- };
-
- if (this.options.scaleOverride){
- helpers.extend(scaleOptions, {
- calculateYRange: helpers.noop,
- steps: this.options.scaleSteps,
- stepValue: this.options.scaleStepWidth,
- min: this.options.scaleStartValue,
- max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth)
- });
- }
-
- this.scale = new this.ScaleClass(scaleOptions);
- },
- addData : function(valuesArray,label){
- //Map the values array for each of the datasets
- helpers.each(valuesArray,function(value,datasetIndex){
- //Add a new point for each piece of data, passing any required data to draw.
- this.datasets[datasetIndex].bars.push(new this.BarClass({
- value : value,
- label : label,
- x: this.scale.calculateBarX(this.datasets.length, datasetIndex, this.scale.valuesCount+1),
- y: this.scale.endPoint,
- width : this.scale.calculateBarWidth(this.datasets.length),
- base : this.scale.endPoint,
- strokeColor : this.datasets[datasetIndex].strokeColor,
- fillColor : this.datasets[datasetIndex].fillColor
- }));
- },this);
-
- this.scale.addXLabel(label);
- //Then re-render the chart.
- this.update();
- },
- removeData : function(){
- this.scale.removeXLabel();
- //Then re-render the chart.
- helpers.each(this.datasets,function(dataset){
- dataset.bars.shift();
- },this);
- this.update();
- },
- reflow : function(){
- helpers.extend(this.BarClass.prototype,{
- y: this.scale.endPoint,
- base : this.scale.endPoint
- });
- var newScaleProps = helpers.extend({
- height : this.chart.height,
- width : this.chart.width
- });
- this.scale.update(newScaleProps);
- },
- draw : function(ease){
- var easingDecimal = ease || 1;
- this.clear();
-
- var ctx = this.chart.ctx;
-
- this.scale.draw(easingDecimal);
-
- //Draw all the bars for each dataset
- helpers.each(this.datasets,function(dataset,datasetIndex){
- helpers.each(dataset.bars,function(bar,index){
- if (bar.hasValue()){
- bar.base = this.scale.endPoint;
- //Transition then draw
- bar.transition({
- x : this.scale.calculateBarX(this.datasets.length, datasetIndex, index),
- y : this.scale.calculateY(bar.value),
- width : this.scale.calculateBarWidth(this.datasets.length)
- }, easingDecimal).draw();
- }
- },this);
-
- },this);
- }
- });
-
-
-}).call(this);
-
-(function(){
- "use strict";
-
- var root = this,
- Chart = root.Chart,
- //Cache a local reference to Chart.helpers
- helpers = Chart.helpers;
-
- var defaultConfig = {
- //Boolean - Whether we should show a stroke on each segment
- segmentShowStroke : true,
-
- //String - The colour of each segment stroke
- segmentStrokeColor : "#fff",
-
- //Number - The width of each segment stroke
- segmentStrokeWidth : 2,
-
- //The percentage of the chart that we cut out of the middle.
- percentageInnerCutout : 50,
-
- //Number - Amount of animation steps
- animationSteps : 100,
-
- //String - Animation easing effect
- animationEasing : "easeOutBounce",
-
- //Boolean - Whether we animate the rotation of the Doughnut
- animateRotate : true,
-
- //Boolean - Whether we animate scaling the Doughnut from the centre
- animateScale : false,
-
- //String - A legend template
- legendTemplate : "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<segments.length; i++){%><li><span style=\"background-color:<%=segments[i].fillColor%>\"></span><%if(segments[i].label){%><%=segments[i].label%><%}%></li><%}%></ul>"
-
- };
-
-
- Chart.Type.extend({
- //Passing in a name registers this chart in the Chart namespace
- name: "Doughnut",
- //Providing a defaults will also register the deafults in the chart namespace
- defaults : defaultConfig,
- //Initialize is fired when the chart is initialized - Data is passed in as a parameter
- //Config is automatically merged by the core of Chart.js, and is available at this.options
- initialize: function(data){
-
- //Declare segments as a static property to prevent inheriting across the Chart type prototype
- this.segments = [];
- this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2;
-
- this.SegmentArc = Chart.Arc.extend({
- ctx : this.chart.ctx,
- x : this.chart.width/2,
- y : this.chart.height/2
- });
-
- //Set up tooltip events on the chart
- if (this.options.showTooltips){
- helpers.bindEvents(this, this.options.tooltipEvents, function(evt){
- var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : [];
-
- helpers.each(this.segments,function(segment){
- segment.restore(["fillColor"]);
- });
- helpers.each(activeSegments,function(activeSegment){
- activeSegment.fillColor = activeSegment.highlightColor;
- });
- this.showTooltip(activeSegments);
- });
- }
- this.calculateTotal(data);
-
- helpers.each(data,function(datapoint, index){
- this.addData(datapoint, index, true);
- },this);
-
- this.render();
- },
- getSegmentsAtEvent : function(e){
- var segmentsArray = [];
-
- var location = helpers.getRelativePosition(e);
-
- helpers.each(this.segments,function(segment){
- if (segment.inRange(location.x,location.y)) segmentsArray.push(segment);
- },this);
- return segmentsArray;
- },
- addData : function(segment, atIndex, silent){
- var index = atIndex || this.segments.length;
- this.segments.splice(index, 0, new this.SegmentArc({
- value : segment.value,
- outerRadius : (this.options.animateScale) ? 0 : this.outerRadius,
- innerRadius : (this.options.animateScale) ? 0 : (this.outerRadius/100) * this.options.percentageInnerCutout,
- fillColor : segment.color,
- highlightColor : segment.highlight || segment.color,
- showStroke : this.options.segmentShowStroke,
- strokeWidth : this.options.segmentStrokeWidth,
- strokeColor : this.options.segmentStrokeColor,
- startAngle : Math.PI * 1.5,
- circumference : (this.options.animateRotate) ? 0 : this.calculateCircumference(segment.value),
- label : segment.label
- }));
- if (!silent){
- this.reflow();
- this.update();
- }
- },
- calculateCircumference : function(value){
- return (Math.PI*2)*(Math.abs(value) / this.total);
- },
- calculateTotal : function(data){
- this.total = 0;
- helpers.each(data,function(segment){
- this.total += Math.abs(segment.value);
- },this);
- },
- update : function(){
- this.calculateTotal(this.segments);
-
- // Reset any highlight colours before updating.
- helpers.each(this.activeElements, function(activeElement){
- activeElement.restore(['fillColor']);
- });
-
- helpers.each(this.segments,function(segment){
- segment.save();
- });
- this.render();
- },
-
- removeData: function(atIndex){
- var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1;
- this.segments.splice(indexToDelete, 1);
- this.reflow();
- this.update();
- },
-
- reflow : function(){
- helpers.extend(this.SegmentArc.prototype,{
- x : this.chart.width/2,
- y : this.chart.height/2
- });
- this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2;
- helpers.each(this.segments, function(segment){
- segment.update({
- outerRadius : this.outerRadius,
- innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout
- });
- }, this);
- },
- draw : function(easeDecimal){
- var animDecimal = (easeDecimal) ? easeDecimal : 1;
- this.clear();
- helpers.each(this.segments,function(segment,index){
- segment.transition({
- circumference : this.calculateCircumference(segment.value),
- outerRadius : this.outerRadius,
- innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout
- },animDecimal);
-
- segment.endAngle = segment.startAngle + segment.circumference;
-
- segment.draw();
- if (index === 0){
- segment.startAngle = Math.PI * 1.5;
- }
- //Check to see if it's the last segment, if not get the next and update the start angle
- if (index < this.segments.length-1){
- this.segments[index+1].startAngle = segment.endAngle;
- }
- },this);
-
- }
- });
-
- Chart.types.Doughnut.extend({
- name : "Pie",
- defaults : helpers.merge(defaultConfig,{percentageInnerCutout : 0})
- });
-
-}).call(this);
-(function(){
- "use strict";
-
- var root = this,
- Chart = root.Chart,
- helpers = Chart.helpers;
-
- var defaultConfig = {
-
- ///Boolean - Whether grid lines are shown across the chart
- scaleShowGridLines : true,
-
- //String - Colour of the grid lines
- scaleGridLineColor : "rgba(0,0,0,.05)",
-
- //Number - Width of the grid lines
- scaleGridLineWidth : 1,
-
- //Boolean - Whether to show horizontal lines (except X axis)
- scaleShowHorizontalLines: true,
-
- //Boolean - Whether to show vertical lines (except Y axis)
- scaleShowVerticalLines: true,
-
- //Boolean - Whether the line is curved between points
- bezierCurve : true,
-
- //Number - Tension of the bezier curve between points
- bezierCurveTension : 0.4,
-
- //Boolean - Whether to show a dot for each point
- pointDot : true,
-
- //Number - Radius of each point dot in pixels
- pointDotRadius : 4,
-
- //Number - Pixel width of point dot stroke
- pointDotStrokeWidth : 1,
-
- //Number - amount extra to add to the radius to cater for hit detection outside the drawn point
- pointHitDetectionRadius : 20,
-
- //Boolean - Whether to show a stroke for datasets
- datasetStroke : true,
-
- //Number - Pixel width of dataset stroke
- datasetStrokeWidth : 2,
-
- //Boolean - Whether to fill the dataset with a colour
- datasetFill : true,
-
- //String - A legend template
- legendTemplate : "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; i++){%><li><span style=\"background-color:<%=datasets[i].strokeColor%>\"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>"
-
- };
-
-
- Chart.Type.extend({
- name: "Line",
- defaults : defaultConfig,
- initialize: function(data){
- //Declare the extension of the default point, to cater for the options passed in to the constructor
- this.PointClass = Chart.Point.extend({
- strokeWidth : this.options.pointDotStrokeWidth,
- radius : this.options.pointDotRadius,
- display: this.options.pointDot,
- hitDetectionRadius : this.options.pointHitDetectionRadius,
- ctx : this.chart.ctx,
- inRange : function(mouseX){
- return (Math.pow(mouseX-this.x, 2) < Math.pow(this.radius + this.hitDetectionRadius,2));
- }
- });
-
- this.datasets = [];
-
- //Set up tooltip events on the chart
- if (this.options.showTooltips){
- helpers.bindEvents(this, this.options.tooltipEvents, function(evt){
- var activePoints = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : [];
- this.eachPoints(function(point){
- point.restore(['fillColor', 'strokeColor']);
- });
- helpers.each(activePoints, function(activePoint){
- activePoint.fillColor = activePoint.highlightFill;
- activePoint.strokeColor = activePoint.highlightStroke;
- });
- this.showTooltip(activePoints);
- });
- }
-
- //Iterate through each of the datasets, and build this into a property of the chart
- helpers.each(data.datasets,function(dataset){
-
- var datasetObject = {
- label : dataset.label || null,
- fillColor : dataset.fillColor,
- strokeColor : dataset.strokeColor,
- pointColor : dataset.pointColor,
- pointStrokeColor : dataset.pointStrokeColor,
- points : []
- };
-
- this.datasets.push(datasetObject);
-
-
- helpers.each(dataset.data,function(dataPoint,index){
- //Add a new point for each piece of data, passing any required data to draw.
- datasetObject.points.push(new this.PointClass({
- value : dataPoint,
- label : data.labels[index],
- datasetLabel: dataset.label,
- strokeColor : dataset.pointStrokeColor,
- fillColor : dataset.pointColor,
- highlightFill : dataset.pointHighlightFill || dataset.pointColor,
- highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor
- }));
- },this);
-
- this.buildScale(data.labels);
-
-
- this.eachPoints(function(point, index){
- helpers.extend(point, {
- x: this.scale.calculateX(index),
- y: this.scale.endPoint
- });
- point.save();
- }, this);
-
- },this);
-
-
- this.render();
- },
- update : function(){
- this.scale.update();
- // Reset any highlight colours before updating.
- helpers.each(this.activeElements, function(activeElement){
- activeElement.restore(['fillColor', 'strokeColor']);
- });
- this.eachPoints(function(point){
- point.save();
- });
- this.render();
- },
- eachPoints : function(callback){
- helpers.each(this.datasets,function(dataset){
- helpers.each(dataset.points,callback,this);
- },this);
- },
- getPointsAtEvent : function(e){
- var pointsArray = [],
- eventPosition = helpers.getRelativePosition(e);
- helpers.each(this.datasets,function(dataset){
- helpers.each(dataset.points,function(point){
- if (point.inRange(eventPosition.x,eventPosition.y)) pointsArray.push(point);
- });
- },this);
- return pointsArray;
- },
- buildScale : function(labels){
- var self = this;
-
- var dataTotal = function(){
- var values = [];
- self.eachPoints(function(point){
- values.push(point.value);
- });
-
- return values;
- };
-
- var scaleOptions = {
- templateString : this.options.scaleLabel,
- height : this.chart.height,
- width : this.chart.width,
- ctx : this.chart.ctx,
- textColor : this.options.scaleFontColor,
- fontSize : this.options.scaleFontSize,
- fontStyle : this.options.scaleFontStyle,
- fontFamily : this.options.scaleFontFamily,
- valuesCount : labels.length,
- beginAtZero : this.options.scaleBeginAtZero,
- integersOnly : this.options.scaleIntegersOnly,
- calculateYRange : function(currentHeight){
- var updatedRanges = helpers.calculateScaleRange(
- dataTotal(),
- currentHeight,
- this.fontSize,
- this.beginAtZero,
- this.integersOnly
- );
- helpers.extend(this, updatedRanges);
- },
- xLabels : labels,
- font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily),
- lineWidth : this.options.scaleLineWidth,
- lineColor : this.options.scaleLineColor,
- showHorizontalLines : this.options.scaleShowHorizontalLines,
- showVerticalLines : this.options.scaleShowVerticalLines,
- gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0,
- gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)",
- padding: (this.options.showScale) ? 0 : this.options.pointDotRadius + this.options.pointDotStrokeWidth,
- showLabels : this.options.scaleShowLabels,
- display : this.options.showScale
- };
-
- if (this.options.scaleOverride){
- helpers.extend(scaleOptions, {
- calculateYRange: helpers.noop,
- steps: this.options.scaleSteps,
- stepValue: this.options.scaleStepWidth,
- min: this.options.scaleStartValue,
- max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth)
- });
- }
-
-
- this.scale = new Chart.Scale(scaleOptions);
- },
- addData : function(valuesArray,label){
- //Map the values array for each of the datasets
-
- helpers.each(valuesArray,function(value,datasetIndex){
- //Add a new point for each piece of data, passing any required data to draw.
- this.datasets[datasetIndex].points.push(new this.PointClass({
- value : value,
- label : label,
- x: this.scale.calculateX(this.scale.valuesCount+1),
- y: this.scale.endPoint,
- strokeColor : this.datasets[datasetIndex].pointStrokeColor,
- fillColor : this.datasets[datasetIndex].pointColor
- }));
- },this);
-
- this.scale.addXLabel(label);
- //Then re-render the chart.
- this.update();
- },
- removeData : function(){
- this.scale.removeXLabel();
- //Then re-render the chart.
- helpers.each(this.datasets,function(dataset){
- dataset.points.shift();
- },this);
- this.update();
- },
- reflow : function(){
- var newScaleProps = helpers.extend({
- height : this.chart.height,
- width : this.chart.width
- });
- this.scale.update(newScaleProps);
- },
- draw : function(ease){
- var easingDecimal = ease || 1;
- this.clear();
-
- var ctx = this.chart.ctx;
-
- // Some helper methods for getting the next/prev points
- var hasValue = function(item){
- return item.value !== null;
- },
- nextPoint = function(point, collection, index){
- return helpers.findNextWhere(collection, hasValue, index) || point;
- },
- previousPoint = function(point, collection, index){
- return helpers.findPreviousWhere(collection, hasValue, index) || point;
- };
-
- this.scale.draw(easingDecimal);
-
-
- helpers.each(this.datasets,function(dataset){
- var pointsWithValues = helpers.where(dataset.points, hasValue);
-
- //Transition each point first so that the line and point drawing isn't out of sync
- //We can use this extra loop to calculate the control points of this dataset also in this loop
-
- helpers.each(dataset.points, function(point, index){
- if (point.hasValue()){
- point.transition({
- y : this.scale.calculateY(point.value),
- x : this.scale.calculateX(index)
- }, easingDecimal);
- }
- },this);
-
-
- // Control points need to be calculated in a seperate loop, because we need to know the current x/y of the point
- // This would cause issues when there is no animation, because the y of the next point would be 0, so beziers would be skewed
- if (this.options.bezierCurve){
- helpers.each(pointsWithValues, function(point, index){
- var tension = (index > 0 && index < pointsWithValues.length - 1) ? this.options.bezierCurveTension : 0;
- point.controlPoints = helpers.splineCurve(
- previousPoint(point, pointsWithValues, index),
- point,
- nextPoint(point, pointsWithValues, index),
- tension
- );
-
- // Prevent the bezier going outside of the bounds of the graph
-
- // Cap puter bezier handles to the upper/lower scale bounds
- if (point.controlPoints.outer.y > this.scale.endPoint){
- point.controlPoints.outer.y = this.scale.endPoint;
- }
- else if (point.controlPoints.outer.y < this.scale.startPoint){
- point.controlPoints.outer.y = this.scale.startPoint;
- }
-
- // Cap inner bezier handles to the upper/lower scale bounds
- if (point.controlPoints.inner.y > this.scale.endPoint){
- point.controlPoints.inner.y = this.scale.endPoint;
- }
- else if (point.controlPoints.inner.y < this.scale.startPoint){
- point.controlPoints.inner.y = this.scale.startPoint;
- }
- },this);
- }
-
-
- //Draw the line between all the points
- ctx.lineWidth = this.options.datasetStrokeWidth;
- ctx.strokeStyle = dataset.strokeColor;
- ctx.beginPath();
-
- helpers.each(pointsWithValues, function(point, index){
- if (index === 0){
- ctx.moveTo(point.x, point.y);
- }
- else{
- if(this.options.bezierCurve){
- var previous = previousPoint(point, pointsWithValues, index);
-
- ctx.bezierCurveTo(
- previous.controlPoints.outer.x,
- previous.controlPoints.outer.y,
- point.controlPoints.inner.x,
- point.controlPoints.inner.y,
- point.x,
- point.y
- );
- }
- else{
- ctx.lineTo(point.x,point.y);
- }
- }
- }, this);
-
- ctx.stroke();
-
- if (this.options.datasetFill && pointsWithValues.length > 0){
- //Round off the line by going to the base of the chart, back to the start, then fill.
- ctx.lineTo(pointsWithValues[pointsWithValues.length - 1].x, this.scale.endPoint);
- ctx.lineTo(pointsWithValues[0].x, this.scale.endPoint);
- ctx.fillStyle = dataset.fillColor;
- ctx.closePath();
- ctx.fill();
- }
-
- //Now draw the points over the line
- //A little inefficient double looping, but better than the line
- //lagging behind the point positions
- helpers.each(pointsWithValues,function(point){
- point.draw();
- });
- },this);
- }
- });
-
-
-}).call(this);
-
-(function(){
- "use strict";
-
- var root = this,
- Chart = root.Chart,
- //Cache a local reference to Chart.helpers
- helpers = Chart.helpers;
-
- var defaultConfig = {
- //Boolean - Show a backdrop to the scale label
- scaleShowLabelBackdrop : true,
-
- //String - The colour of the label backdrop
- scaleBackdropColor : "rgba(255,255,255,0.75)",
-
- // Boolean - Whether the scale should begin at zero
- scaleBeginAtZero : true,
-
- //Number - The backdrop padding above & below the label in pixels
- scaleBackdropPaddingY : 2,
-
- //Number - The backdrop padding to the side of the label in pixels
- scaleBackdropPaddingX : 2,
-
- //Boolean - Show line for each value in the scale
- scaleShowLine : true,
-
- //Boolean - Stroke a line around each segment in the chart
- segmentShowStroke : true,
-
- //String - The colour of the stroke on each segement.
- segmentStrokeColor : "#fff",
-
- //Number - The width of the stroke value in pixels
- segmentStrokeWidth : 2,
-
- //Number - Amount of animation steps
- animationSteps : 100,
-
- //String - Animation easing effect.
- animationEasing : "easeOutBounce",
-
- //Boolean - Whether to animate the rotation of the chart
- animateRotate : true,
-
- //Boolean - Whether to animate scaling the chart from the centre
- animateScale : false,
-
- //String - A legend template
- legendTemplate : "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<segments.length; i++){%><li><span style=\"background-color:<%=segments[i].fillColor%>\"></span><%if(segments[i].label){%><%=segments[i].label%><%}%></li><%}%></ul>"
- };
-
-
- Chart.Type.extend({
- //Passing in a name registers this chart in the Chart namespace
- name: "PolarArea",
- //Providing a defaults will also register the deafults in the chart namespace
- defaults : defaultConfig,
- //Initialize is fired when the chart is initialized - Data is passed in as a parameter
- //Config is automatically merged by the core of Chart.js, and is available at this.options
- initialize: function(data){
- this.segments = [];
- //Declare segment class as a chart instance specific class, so it can share props for this instance
- this.SegmentArc = Chart.Arc.extend({
- showStroke : this.options.segmentShowStroke,
- strokeWidth : this.options.segmentStrokeWidth,
- strokeColor : this.options.segmentStrokeColor,
- ctx : this.chart.ctx,
- innerRadius : 0,
- x : this.chart.width/2,
- y : this.chart.height/2
- });
- this.scale = new Chart.RadialScale({
- display: this.options.showScale,
- fontStyle: this.options.scaleFontStyle,
- fontSize: this.options.scaleFontSize,
- fontFamily: this.options.scaleFontFamily,
- fontColor: this.options.scaleFontColor,
- showLabels: this.options.scaleShowLabels,
- showLabelBackdrop: this.options.scaleShowLabelBackdrop,
- backdropColor: this.options.scaleBackdropColor,
- backdropPaddingY : this.options.scaleBackdropPaddingY,
- backdropPaddingX: this.options.scaleBackdropPaddingX,
- lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0,
- lineColor: this.options.scaleLineColor,
- lineArc: true,
- width: this.chart.width,
- height: this.chart.height,
- xCenter: this.chart.width/2,
- yCenter: this.chart.height/2,
- ctx : this.chart.ctx,
- templateString: this.options.scaleLabel,
- valuesCount: data.length
- });
-
- this.updateScaleRange(data);
-
- this.scale.update();
-
- helpers.each(data,function(segment,index){
- this.addData(segment,index,true);
- },this);
-
- //Set up tooltip events on the chart
- if (this.options.showTooltips){
- helpers.bindEvents(this, this.options.tooltipEvents, function(evt){
- var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : [];
- helpers.each(this.segments,function(segment){
- segment.restore(["fillColor"]);
- });
- helpers.each(activeSegments,function(activeSegment){
- activeSegment.fillColor = activeSegment.highlightColor;
- });
- this.showTooltip(activeSegments);
- });
- }
-
- this.render();
- },
- getSegmentsAtEvent : function(e){
- var segmentsArray = [];
-
- var location = helpers.getRelativePosition(e);
-
- helpers.each(this.segments,function(segment){
- if (segment.inRange(location.x,location.y)) segmentsArray.push(segment);
- },this);
- return segmentsArray;
- },
- addData : function(segment, atIndex, silent){
- var index = atIndex || this.segments.length;
-
- this.segments.splice(index, 0, new this.SegmentArc({
- fillColor: segment.color,
- highlightColor: segment.highlight || segment.color,
- label: segment.label,
- value: segment.value,
- outerRadius: (this.options.animateScale) ? 0 : this.scale.calculateCenterOffset(segment.value),
- circumference: (this.options.animateRotate) ? 0 : this.scale.getCircumference(),
- startAngle: Math.PI * 1.5
- }));
- if (!silent){
- this.reflow();
- this.update();
- }
- },
- removeData: function(atIndex){
- var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1;
- this.segments.splice(indexToDelete, 1);
- this.reflow();
- this.update();
- },
- calculateTotal: function(data){
- this.total = 0;
- helpers.each(data,function(segment){
- this.total += segment.value;
- },this);
- this.scale.valuesCount = this.segments.length;
- },
- updateScaleRange: function(datapoints){
- var valuesArray = [];
- helpers.each(datapoints,function(segment){
- valuesArray.push(segment.value);
- });
-
- var scaleSizes = (this.options.scaleOverride) ?
- {
- steps: this.options.scaleSteps,
- stepValue: this.options.scaleStepWidth,
- min: this.options.scaleStartValue,
- max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth)
- } :
- helpers.calculateScaleRange(
- valuesArray,
- helpers.min([this.chart.width, this.chart.height])/2,
- this.options.scaleFontSize,
- this.options.scaleBeginAtZero,
- this.options.scaleIntegersOnly
- );
-
- helpers.extend(
- this.scale,
- scaleSizes,
- {
- size: helpers.min([this.chart.width, this.chart.height]),
- xCenter: this.chart.width/2,
- yCenter: this.chart.height/2
- }
- );
-
- },
- update : function(){
- this.calculateTotal(this.segments);
-
- helpers.each(this.segments,function(segment){
- segment.save();
- });
-
- this.reflow();
- this.render();
- },
- reflow : function(){
- helpers.extend(this.SegmentArc.prototype,{
- x : this.chart.width/2,
- y : this.chart.height/2
- });
- this.updateScaleRange(this.segments);
- this.scale.update();
-
- helpers.extend(this.scale,{
- xCenter: this.chart.width/2,
- yCenter: this.chart.height/2
- });
-
- helpers.each(this.segments, function(segment){
- segment.update({
- outerRadius : this.scale.calculateCenterOffset(segment.value)
- });
- }, this);
-
- },
- draw : function(ease){
- var easingDecimal = ease || 1;
- //Clear & draw the canvas
- this.clear();
- helpers.each(this.segments,function(segment, index){
- segment.transition({
- circumference : this.scale.getCircumference(),
- outerRadius : this.scale.calculateCenterOffset(segment.value)
- },easingDecimal);
-
- segment.endAngle = segment.startAngle + segment.circumference;
-
- // If we've removed the first segment we need to set the first one to
- // start at the top.
- if (index === 0){
- segment.startAngle = Math.PI * 1.5;
- }
-
- //Check to see if it's the last segment, if not get the next and update the start angle
- if (index < this.segments.length - 1){
- this.segments[index+1].startAngle = segment.endAngle;
- }
- segment.draw();
- }, this);
- this.scale.draw();
- }
- });
-
-}).call(this);
-(function(){
- "use strict";
-
- var root = this,
- Chart = root.Chart,
- helpers = Chart.helpers;
-
-
-
- Chart.Type.extend({
- name: "Radar",
- defaults:{
- //Boolean - Whether to show lines for each scale point
- scaleShowLine : true,
-
- //Boolean - Whether we show the angle lines out of the radar
- angleShowLineOut : true,
-
- //Boolean - Whether to show labels on the scale
- scaleShowLabels : false,
-
- // Boolean - Whether the scale should begin at zero
- scaleBeginAtZero : true,
-
- //String - Colour of the angle line
- angleLineColor : "rgba(0,0,0,.1)",
-
- //Number - Pixel width of the angle line
- angleLineWidth : 1,
-
- //String - Point label font declaration
- pointLabelFontFamily : "'Arial'",
-
- //String - Point label font weight
- pointLabelFontStyle : "normal",
-
- //Number - Point label font size in pixels
- pointLabelFontSize : 10,
-
- //String - Point label font colour
- pointLabelFontColor : "#666",
-
- //Boolean - Whether to show a dot for each point
- pointDot : true,
-
- //Number - Radius of each point dot in pixels
- pointDotRadius : 3,
-
- //Number - Pixel width of point dot stroke
- pointDotStrokeWidth : 1,
-
- //Number - amount extra to add to the radius to cater for hit detection outside the drawn point
- pointHitDetectionRadius : 20,
-
- //Boolean - Whether to show a stroke for datasets
- datasetStroke : true,
-
- //Number - Pixel width of dataset stroke
- datasetStrokeWidth : 2,
-
- //Boolean - Whether to fill the dataset with a colour
- datasetFill : true,
-
- //String - A legend template
- legendTemplate : "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; i++){%><li><span style=\"background-color:<%=datasets[i].strokeColor%>\"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>"
-
- },
-
- initialize: function(data){
- this.PointClass = Chart.Point.extend({
- strokeWidth : this.options.pointDotStrokeWidth,
- radius : this.options.pointDotRadius,
- display: this.options.pointDot,
- hitDetectionRadius : this.options.pointHitDetectionRadius,
- ctx : this.chart.ctx
- });
-
- this.datasets = [];
-
- this.buildScale(data);
-
- //Set up tooltip events on the chart
- if (this.options.showTooltips){
- helpers.bindEvents(this, this.options.tooltipEvents, function(evt){
- var activePointsCollection = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : [];
-
- this.eachPoints(function(point){
- point.restore(['fillColor', 'strokeColor']);
- });
- helpers.each(activePointsCollection, function(activePoint){
- activePoint.fillColor = activePoint.highlightFill;
- activePoint.strokeColor = activePoint.highlightStroke;
- });
-
- this.showTooltip(activePointsCollection);
- });
- }
-
- //Iterate through each of the datasets, and build this into a property of the chart
- helpers.each(data.datasets,function(dataset){
-
- var datasetObject = {
- label: dataset.label || null,
- fillColor : dataset.fillColor,
- strokeColor : dataset.strokeColor,
- pointColor : dataset.pointColor,
- pointStrokeColor : dataset.pointStrokeColor,
- points : []
- };
-
- this.datasets.push(datasetObject);
-
- helpers.each(dataset.data,function(dataPoint,index){
- //Add a new point for each piece of data, passing any required data to draw.
- var pointPosition;
- if (!this.scale.animation){
- pointPosition = this.scale.getPointPosition(index, this.scale.calculateCenterOffset(dataPoint));
- }
- datasetObject.points.push(new this.PointClass({
- value : dataPoint,
- label : data.labels[index],
- datasetLabel: dataset.label,
- x: (this.options.animation) ? this.scale.xCenter : pointPosition.x,
- y: (this.options.animation) ? this.scale.yCenter : pointPosition.y,
- strokeColor : dataset.pointStrokeColor,
- fillColor : dataset.pointColor,
- highlightFill : dataset.pointHighlightFill || dataset.pointColor,
- highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor
- }));
- },this);
-
- },this);
-
- this.render();
- },
- eachPoints : function(callback){
- helpers.each(this.datasets,function(dataset){
- helpers.each(dataset.points,callback,this);
- },this);
- },
-
- getPointsAtEvent : function(evt){
- var mousePosition = helpers.getRelativePosition(evt),
- fromCenter = helpers.getAngleFromPoint({
- x: this.scale.xCenter,
- y: this.scale.yCenter
- }, mousePosition);
-
- var anglePerIndex = (Math.PI * 2) /this.scale.valuesCount,
- pointIndex = Math.round((fromCenter.angle - Math.PI * 1.5) / anglePerIndex),
- activePointsCollection = [];
-
- // If we're at the top, make the pointIndex 0 to get the first of the array.
- if (pointIndex >= this.scale.valuesCount || pointIndex < 0){
- pointIndex = 0;
- }
-
- if (fromCenter.distance <= this.scale.drawingArea){
- helpers.each(this.datasets, function(dataset){
- activePointsCollection.push(dataset.points[pointIndex]);
- });
- }
-
- return activePointsCollection;
- },
-
- buildScale : function(data){
- this.scale = new Chart.RadialScale({
- display: this.options.showScale,
- fontStyle: this.options.scaleFontStyle,
- fontSize: this.options.scaleFontSize,
- fontFamily: this.options.scaleFontFamily,
- fontColor: this.options.scaleFontColor,
- showLabels: this.options.scaleShowLabels,
- showLabelBackdrop: this.options.scaleShowLabelBackdrop,
- backdropColor: this.options.scaleBackdropColor,
- backdropPaddingY : this.options.scaleBackdropPaddingY,
- backdropPaddingX: this.options.scaleBackdropPaddingX,
- lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0,
- lineColor: this.options.scaleLineColor,
- angleLineColor : this.options.angleLineColor,
- angleLineWidth : (this.options.angleShowLineOut) ? this.options.angleLineWidth : 0,
- // Point labels at the edge of each line
- pointLabelFontColor : this.options.pointLabelFontColor,
- pointLabelFontSize : this.options.pointLabelFontSize,
- pointLabelFontFamily : this.options.pointLabelFontFamily,
- pointLabelFontStyle : this.options.pointLabelFontStyle,
- height : this.chart.height,
- width: this.chart.width,
- xCenter: this.chart.width/2,
- yCenter: this.chart.height/2,
- ctx : this.chart.ctx,
- templateString: this.options.scaleLabel,
- labels: data.labels,
- valuesCount: data.datasets[0].data.length
- });
-
- this.scale.setScaleSize();
- this.updateScaleRange(data.datasets);
- this.scale.buildYLabels();
- },
- updateScaleRange: function(datasets){
- var valuesArray = (function(){
- var totalDataArray = [];
- helpers.each(datasets,function(dataset){
- if (dataset.data){
- totalDataArray = totalDataArray.concat(dataset.data);
- }
- else {
- helpers.each(dataset.points, function(point){
- totalDataArray.push(point.value);
- });
- }
- });
- return totalDataArray;
- })();
-
-
- var scaleSizes = (this.options.scaleOverride) ?
- {
- steps: this.options.scaleSteps,
- stepValue: this.options.scaleStepWidth,
- min: this.options.scaleStartValue,
- max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth)
- } :
- helpers.calculateScaleRange(
- valuesArray,
- helpers.min([this.chart.width, this.chart.height])/2,
- this.options.scaleFontSize,
- this.options.scaleBeginAtZero,
- this.options.scaleIntegersOnly
- );
-
- helpers.extend(
- this.scale,
- scaleSizes
- );
-
- },
- addData : function(valuesArray,label){
- //Map the values array for each of the datasets
- this.scale.valuesCount++;
- helpers.each(valuesArray,function(value,datasetIndex){
- var pointPosition = this.scale.getPointPosition(this.scale.valuesCount, this.scale.calculateCenterOffset(value));
- this.datasets[datasetIndex].points.push(new this.PointClass({
- value : value,
- label : label,
- x: pointPosition.x,
- y: pointPosition.y,
- strokeColor : this.datasets[datasetIndex].pointStrokeColor,
- fillColor : this.datasets[datasetIndex].pointColor
- }));
- },this);
-
- this.scale.labels.push(label);
-
- this.reflow();
-
- this.update();
- },
- removeData : function(){
- this.scale.valuesCount--;
- this.scale.labels.shift();
- helpers.each(this.datasets,function(dataset){
- dataset.points.shift();
- },this);
- this.reflow();
- this.update();
- },
- update : function(){
- this.eachPoints(function(point){
- point.save();
- });
- this.reflow();
- this.render();
- },
- reflow: function(){
- helpers.extend(this.scale, {
- width : this.chart.width,
- height: this.chart.height,
- size : helpers.min([this.chart.width, this.chart.height]),
- xCenter: this.chart.width/2,
- yCenter: this.chart.height/2
- });
- this.updateScaleRange(this.datasets);
- this.scale.setScaleSize();
- this.scale.buildYLabels();
- },
- draw : function(ease){
- var easeDecimal = ease || 1,
- ctx = this.chart.ctx;
- this.clear();
- this.scale.draw();
-
- helpers.each(this.datasets,function(dataset){
-
- //Transition each point first so that the line and point drawing isn't out of sync
- helpers.each(dataset.points,function(point,index){
- if (point.hasValue()){
- point.transition(this.scale.getPointPosition(index, this.scale.calculateCenterOffset(point.value)), easeDecimal);
- }
- },this);
-
-
-
- //Draw the line between all the points
- ctx.lineWidth = this.options.datasetStrokeWidth;
- ctx.strokeStyle = dataset.strokeColor;
- ctx.beginPath();
- helpers.each(dataset.points,function(point,index){
- if (index === 0){
- ctx.moveTo(point.x,point.y);
- }
- else{
- ctx.lineTo(point.x,point.y);
- }
- },this);
- ctx.closePath();
- ctx.stroke();
-
- ctx.fillStyle = dataset.fillColor;
- ctx.fill();
-
- //Now draw the points over the line
- //A little inefficient double looping, but better than the line
- //lagging behind the point positions
- helpers.each(dataset.points,function(point){
- if (point.hasValue()){
- point.draw();
- }
- });
-
- },this);
-
- }
-
- });
-
-
-
-
-
-}).call(this); \ No newline at end of file
diff --git a/vendor/assets/javascripts/jasmine-jquery.js b/vendor/assets/javascripts/jasmine-jquery.js
new file mode 100644
index 00000000000..9b7b245009d
--- /dev/null
+++ b/vendor/assets/javascripts/jasmine-jquery.js
@@ -0,0 +1,851 @@
+/* eslint-disable */
+/*
+ Jasmine JQuery 3.0 patched version from this fork : https://github.com/cmrd-senya/jasmine-jquery/blob/jquery3/lib/jasmine-jquery.js
+*/
+/*!
+Jasmine-jQuery: a set of jQuery helpers for Jasmine tests.
+
+Version 2.1.1
+
+https://github.com/velesin/jasmine-jquery
+
+Copyright (c) 2010-2014 Wojciech Zawistowski, Travis Jeffery
+
+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.
+*/
+
+(function (root, factory) {
+ if (typeof module !== 'undefined' && module.exports) {
+ factory(root, root.jasmine, require('jquery'));
+ } else {
+ factory(root, root.jasmine, root.jQuery);
+ }
+}((function() {return this; })(), function (window, jasmine, $) { "use strict";
+
+ jasmine.spiedEventsKey = function (selector, eventName) {
+ for(var i = 0;; i++) {
+ var key = [eventName, i].toString()
+ if (data.spiedEvents[key] === undefined || data.spiedEvents[key].selector.is($(selector))) {
+ return key
+ }
+ }
+ }
+
+ jasmine.getFixtures = function () {
+ return jasmine.currentFixtures_ = jasmine.currentFixtures_ || new jasmine.Fixtures()
+ }
+
+ jasmine.getStyleFixtures = function () {
+ return jasmine.currentStyleFixtures_ = jasmine.currentStyleFixtures_ || new jasmine.StyleFixtures()
+ }
+
+ jasmine.Fixtures = function () {
+ this.containerId = 'jasmine-fixtures'
+ this.fixturesCache_ = {}
+ this.fixturesPath = 'spec/javascripts/fixtures'
+ }
+
+ jasmine.Fixtures.prototype.set = function (html) {
+ this.cleanUp()
+ return this.createContainer_(html)
+ }
+
+ jasmine.Fixtures.prototype.appendSet= function (html) {
+ this.addToContainer_(html)
+ }
+
+ jasmine.Fixtures.prototype.preload = function () {
+ this.read.apply(this, arguments)
+ }
+
+ jasmine.Fixtures.prototype.load = function () {
+ this.cleanUp()
+ this.createContainer_(this.read.apply(this, arguments))
+ }
+
+ jasmine.Fixtures.prototype.appendLoad = function () {
+ this.addToContainer_(this.read.apply(this, arguments))
+ }
+
+ jasmine.Fixtures.prototype.read = function () {
+ var htmlChunks = []
+ , fixtureUrls = arguments
+
+ for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) {
+ htmlChunks.push(this.getFixtureHtml_(fixtureUrls[urlIndex]))
+ }
+
+ return htmlChunks.join('')
+ }
+
+ jasmine.Fixtures.prototype.clearCache = function () {
+ this.fixturesCache_ = {}
+ }
+
+ jasmine.Fixtures.prototype.cleanUp = function () {
+ $('#' + this.containerId).remove()
+ }
+
+ jasmine.Fixtures.prototype.sandbox = function (attributes) {
+ var attributesToSet = attributes || {}
+ return $('<div id="sandbox" />').attr(attributesToSet)
+ }
+
+ jasmine.Fixtures.prototype.createContainer_ = function (html) {
+ var container = $('<div>')
+ .attr('id', this.containerId)
+ .html(html)
+
+ $(document.body).append(container)
+ return container
+ }
+
+ jasmine.Fixtures.prototype.addToContainer_ = function (html){
+ var container = $(document.body).find('#'+this.containerId).append(html)
+
+ if (!container.length) {
+ this.createContainer_(html)
+ }
+ }
+
+ jasmine.Fixtures.prototype.getFixtureHtml_ = function (url) {
+ if (typeof this.fixturesCache_[url] === 'undefined') {
+ this.loadFixtureIntoCache_(url)
+ }
+ return this.fixturesCache_[url]
+ }
+
+ jasmine.Fixtures.prototype.loadFixtureIntoCache_ = function (relativeUrl) {
+ var self = this
+ , url = this.makeFixtureUrl_(relativeUrl)
+ , htmlText = ''
+ , request = $.ajax({
+ async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded
+ cache: false,
+ url: url,
+ dataType: 'html',
+ success: function (data, status, $xhr) {
+ htmlText = $xhr.responseText
+ }
+ }).fail(function ($xhr, status, err) {
+ throw new Error('Fixture could not be loaded: ' + url + ' (status: ' + status + ', message: ' + err.message + ')')
+ })
+
+ var scripts = $($.parseHTML(htmlText, true)).find('script[src]') || [];
+
+ scripts.each(function(){
+ $.ajax({
+ async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded
+ cache: false,
+ dataType: 'script',
+ url: $(this).attr('src'),
+ success: function (data, status, $xhr) {
+ htmlText += '<script>' + $xhr.responseText + '</script>'
+ },
+ error: function ($xhr, status, err) {
+ throw new Error('Script could not be loaded: ' + url + ' (status: ' + status + ', message: ' + err.message + ')')
+ }
+ });
+ })
+
+ self.fixturesCache_[relativeUrl] = htmlText;
+ }
+
+ jasmine.Fixtures.prototype.makeFixtureUrl_ = function (relativeUrl){
+ return this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl
+ }
+
+ jasmine.Fixtures.prototype.proxyCallTo_ = function (methodName, passedArguments) {
+ return this[methodName].apply(this, passedArguments)
+ }
+
+
+ jasmine.StyleFixtures = function () {
+ this.fixturesCache_ = {}
+ this.fixturesNodes_ = []
+ this.fixturesPath = 'spec/javascripts/fixtures'
+ }
+
+ jasmine.StyleFixtures.prototype.set = function (css) {
+ this.cleanUp()
+ this.createStyle_(css)
+ }
+
+ jasmine.StyleFixtures.prototype.appendSet = function (css) {
+ this.createStyle_(css)
+ }
+
+ jasmine.StyleFixtures.prototype.preload = function () {
+ this.read_.apply(this, arguments)
+ }
+
+ jasmine.StyleFixtures.prototype.load = function () {
+ this.cleanUp()
+ this.createStyle_(this.read_.apply(this, arguments))
+ }
+
+ jasmine.StyleFixtures.prototype.appendLoad = function () {
+ this.createStyle_(this.read_.apply(this, arguments))
+ }
+
+ jasmine.StyleFixtures.prototype.cleanUp = function () {
+ while(this.fixturesNodes_.length) {
+ this.fixturesNodes_.pop().remove()
+ }
+ }
+
+ jasmine.StyleFixtures.prototype.createStyle_ = function (html) {
+ var styleText = $('<div></div>').html(html).text()
+ , style = $('<style>' + styleText + '</style>')
+
+ this.fixturesNodes_.push(style)
+ $('head').append(style)
+ }
+
+ jasmine.StyleFixtures.prototype.clearCache = jasmine.Fixtures.prototype.clearCache
+ jasmine.StyleFixtures.prototype.read_ = jasmine.Fixtures.prototype.read
+ jasmine.StyleFixtures.prototype.getFixtureHtml_ = jasmine.Fixtures.prototype.getFixtureHtml_
+ jasmine.StyleFixtures.prototype.loadFixtureIntoCache_ = jasmine.Fixtures.prototype.loadFixtureIntoCache_
+ jasmine.StyleFixtures.prototype.makeFixtureUrl_ = jasmine.Fixtures.prototype.makeFixtureUrl_
+ jasmine.StyleFixtures.prototype.proxyCallTo_ = jasmine.Fixtures.prototype.proxyCallTo_
+
+ jasmine.getJSONFixtures = function () {
+ return jasmine.currentJSONFixtures_ = jasmine.currentJSONFixtures_ || new jasmine.JSONFixtures()
+ }
+
+ jasmine.JSONFixtures = function () {
+ this.fixturesCache_ = {}
+ this.fixturesPath = 'spec/javascripts/fixtures/json'
+ }
+
+ jasmine.JSONFixtures.prototype.load = function () {
+ this.read.apply(this, arguments)
+ return this.fixturesCache_
+ }
+
+ jasmine.JSONFixtures.prototype.read = function () {
+ var fixtureUrls = arguments
+
+ for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) {
+ this.getFixtureData_(fixtureUrls[urlIndex])
+ }
+
+ return this.fixturesCache_
+ }
+
+ jasmine.JSONFixtures.prototype.clearCache = function () {
+ this.fixturesCache_ = {}
+ }
+
+ jasmine.JSONFixtures.prototype.getFixtureData_ = function (url) {
+ if (!this.fixturesCache_[url]) this.loadFixtureIntoCache_(url)
+ return this.fixturesCache_[url]
+ }
+
+ jasmine.JSONFixtures.prototype.loadFixtureIntoCache_ = function (relativeUrl) {
+ var self = this
+ , url = this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl
+
+ $.ajax({
+ async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded
+ cache: false,
+ dataType: 'json',
+ url: url,
+ success: function (data) {
+ self.fixturesCache_[relativeUrl] = data
+ },
+ error: function ($xhr, status, err) {
+ throw new Error('JSONFixture could not be loaded: ' + url + ' (status: ' + status + ', message: ' + err.message + ')')
+ }
+ })
+ }
+
+ jasmine.JSONFixtures.prototype.proxyCallTo_ = function (methodName, passedArguments) {
+ return this[methodName].apply(this, passedArguments)
+ }
+
+ jasmine.jQuery = function () {}
+
+ jasmine.jQuery.browserTagCaseIndependentHtml = function (html) {
+ return $('<div/>').append(html).html()
+ }
+
+ jasmine.jQuery.elementToString = function (element) {
+ return $(element).map(function () { return this.outerHTML; }).toArray().join(', ')
+ }
+
+ var data = {
+ spiedEvents: {}
+ , handlers: []
+ }
+
+ jasmine.jQuery.events = {
+ spyOn: function (selector, eventName) {
+ var handler = function (e) {
+ var calls = (typeof data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] !== 'undefined') ? data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].calls : 0
+ data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] = {
+ selector: $(selector),
+ args: jasmine.util.argsToArray(arguments),
+ calls: ++calls
+ }
+ }
+
+ $(selector).on(eventName, handler)
+ data.handlers.push(handler)
+
+ return {
+ selector: selector,
+ eventName: eventName,
+ handler: handler,
+ reset: function (){
+ delete data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)]
+ },
+ calls: {
+ count: function () {
+ return data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] ?
+ data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].calls : 0;
+ },
+ any: function () {
+ return data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] ?
+ !!data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].calls : false;
+ }
+ }
+ }
+ },
+
+ args: function (selector, eventName) {
+ var actualArgs = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].args
+
+ if (!actualArgs) {
+ throw "There is no spy for " + eventName + " on " + selector.toString() + ". Make sure to create a spy using spyOnEvent."
+ }
+
+ return actualArgs
+ },
+
+ wasTriggered: function (selector, eventName) {
+ return !!(data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)])
+ },
+
+ wasTriggeredWith: function (selector, eventName, expectedArgs, util, customEqualityTesters) {
+ var actualArgs = jasmine.jQuery.events.args(selector, eventName).slice(1)
+
+ if (Object.prototype.toString.call(expectedArgs) !== '[object Array]')
+ actualArgs = actualArgs[0]
+
+ return util.equals(actualArgs, expectedArgs, customEqualityTesters)
+ },
+
+ wasPrevented: function (selector, eventName) {
+ var spiedEvent = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)]
+ , args = (jasmine.util.isUndefined(spiedEvent)) ? {} : spiedEvent.args
+ , e = args ? args[0] : undefined
+
+ return e && e.isDefaultPrevented()
+ },
+
+ wasStopped: function (selector, eventName) {
+ var spiedEvent = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)]
+ , args = (jasmine.util.isUndefined(spiedEvent)) ? {} : spiedEvent.args
+ , e = args ? args[0] : undefined
+
+ return e && e.isPropagationStopped()
+ },
+
+ cleanUp: function () {
+ data.spiedEvents = {}
+ data.handlers = []
+ }
+ }
+
+ var hasProperty = function (actualValue, expectedValue) {
+ if (expectedValue === undefined)
+ return actualValue !== undefined
+
+ return actualValue === expectedValue
+ }
+
+ beforeEach(function () {
+ jasmine.addMatchers({
+ toHaveClass: function () {
+ return {
+ compare: function (actual, className) {
+ return { pass: $(actual).hasClass(className) }
+ }
+ }
+ },
+
+ toHaveCss: function () {
+ return {
+ compare: function (actual, css) {
+ var stripCharsRegex = /[\s;\"\']/g
+ for (var prop in css) {
+ var value = css[prop]
+ // see issue #147 on gh
+ ;if ((value === 'auto') && ($(actual).get(0).style[prop] === 'auto')) continue
+ var actualStripped = $(actual).css(prop).replace(stripCharsRegex, '')
+ var valueStripped = value.replace(stripCharsRegex, '')
+ if (actualStripped !== valueStripped) return { pass: false }
+ }
+ return { pass: true }
+ }
+ }
+ },
+
+ toBeVisible: function () {
+ return {
+ compare: function (actual) {
+ return { pass: $(actual).is(':visible') }
+ }
+ }
+ },
+
+ toBeHidden: function () {
+ return {
+ compare: function (actual) {
+ return { pass: $(actual).is(':hidden') }
+ }
+ }
+ },
+
+ toBeSelected: function () {
+ return {
+ compare: function (actual) {
+ return { pass: $(actual).is(':selected') }
+ }
+ }
+ },
+
+ toBeChecked: function () {
+ return {
+ compare: function (actual) {
+ return { pass: $(actual).is(':checked') }
+ }
+ }
+ },
+
+ toBeEmpty: function () {
+ return {
+ compare: function (actual) {
+ return { pass: $(actual).is(':empty') }
+ }
+ }
+ },
+
+ toBeInDOM: function () {
+ return {
+ compare: function (actual) {
+ return { pass: $.contains(document.documentElement, $(actual)[0]) }
+ }
+ }
+ },
+
+ toExist: function () {
+ return {
+ compare: function (actual) {
+ return { pass: $(actual).length }
+ }
+ }
+ },
+
+ toHaveLength: function () {
+ return {
+ compare: function (actual, length) {
+ return { pass: $(actual).length === length }
+ }
+ }
+ },
+
+ toHaveAttr: function () {
+ return {
+ compare: function (actual, attributeName, expectedAttributeValue) {
+ return { pass: hasProperty($(actual).attr(attributeName), expectedAttributeValue) }
+ }
+ }
+ },
+
+ toHaveProp: function () {
+ return {
+ compare: function (actual, propertyName, expectedPropertyValue) {
+ return { pass: hasProperty($(actual).prop(propertyName), expectedPropertyValue) }
+ }
+ }
+ },
+
+ toHaveId: function () {
+ return {
+ compare: function (actual, id) {
+ return { pass: $(actual).attr('id') == id }
+ }
+ }
+ },
+
+ toHaveHtml: function () {
+ return {
+ compare: function (actual, html) {
+ return { pass: $(actual).html() == jasmine.jQuery.browserTagCaseIndependentHtml(html) }
+ }
+ }
+ },
+
+ toContainHtml: function () {
+ return {
+ compare: function (actual, html) {
+ var actualHtml = $(actual).html()
+ , expectedHtml = jasmine.jQuery.browserTagCaseIndependentHtml(html)
+
+ return { pass: (actualHtml.indexOf(expectedHtml) >= 0) }
+ }
+ }
+ },
+
+ toHaveText: function () {
+ return {
+ compare: function (actual, text) {
+ var actualText = $(actual).text()
+ var trimmedText = $.trim(actualText)
+
+ if (text && $.isFunction(text.test)) {
+ return { pass: text.test(actualText) || text.test(trimmedText) }
+ } else {
+ return { pass: (actualText == text || trimmedText == text) }
+ }
+ }
+ }
+ },
+
+ toContainText: function () {
+ return {
+ compare: function (actual, text) {
+ var trimmedText = $.trim($(actual).text())
+
+ if (text && $.isFunction(text.test)) {
+ return { pass: text.test(trimmedText) }
+ } else {
+ return { pass: trimmedText.indexOf(text) != -1 }
+ }
+ }
+ }
+ },
+
+ toHaveValue: function () {
+ return {
+ compare: function (actual, value) {
+ return { pass: $(actual).val() === value }
+ }
+ }
+ },
+
+ toHaveData: function () {
+ return {
+ compare: function (actual, key, expectedValue) {
+ return { pass: hasProperty($(actual).data(key), expectedValue) }
+ }
+ }
+ },
+
+ toContainElement: function () {
+ return {
+ compare: function (actual, selector) {
+ return { pass: $(actual).find(selector).length }
+ }
+ }
+ },
+
+ toBeMatchedBy: function () {
+ return {
+ compare: function (actual, selector) {
+ return { pass: $(actual).filter(selector).length }
+ }
+ }
+ },
+
+ toBeDisabled: function () {
+ return {
+ compare: function (actual, selector) {
+ return { pass: $(actual).is(':disabled') }
+ }
+ }
+ },
+
+ toBeFocused: function (selector) {
+ return {
+ compare: function (actual, selector) {
+ return { pass: $(actual)[0] === $(actual)[0].ownerDocument.activeElement }
+ }
+ }
+ },
+
+ toHandle: function () {
+ return {
+ compare: function (actual, event) {
+ if ( !actual || actual.length === 0 ) return { pass: false };
+ var events = $._data($(actual).get(0), "events")
+
+ if (!events || !event || typeof event !== "string") {
+ return { pass: false }
+ }
+
+ var namespaces = event.split(".")
+ , eventType = namespaces.shift()
+ , sortedNamespaces = namespaces.slice(0).sort()
+ , namespaceRegExp = new RegExp("(^|\\.)" + sortedNamespaces.join("\\.(?:.*\\.)?") + "(\\.|$)")
+
+ if (events[eventType] && namespaces.length) {
+ for (var i = 0; i < events[eventType].length; i++) {
+ var namespace = events[eventType][i].namespace
+
+ if (namespaceRegExp.test(namespace))
+ return { pass: true }
+ }
+ } else {
+ return { pass: (events[eventType] && events[eventType].length > 0) }
+ }
+
+ return { pass: false }
+ }
+ }
+ },
+
+ toHandleWith: function () {
+ return {
+ compare: function (actual, eventName, eventHandler) {
+ if ( !actual || actual.length === 0 ) return { pass: false };
+ var normalizedEventName = eventName.split('.')[0]
+ , stack = $._data($(actual).get(0), "events")[normalizedEventName]
+
+ for (var i = 0; i < stack.length; i++) {
+ if (stack[i].handler == eventHandler) return { pass: true }
+ }
+
+ return { pass: false }
+ }
+ }
+ },
+
+ toHaveBeenTriggeredOn: function () {
+ return {
+ compare: function (actual, selector) {
+ var result = { pass: jasmine.jQuery.events.wasTriggered(selector, actual) }
+
+ result.message = result.pass ?
+ "Expected event " + $(actual) + " not to have been triggered on " + selector :
+ "Expected event " + $(actual) + " to have been triggered on " + selector
+
+ return result;
+ }
+ }
+ },
+
+ toHaveBeenTriggered: function (){
+ return {
+ compare: function (actual) {
+ var eventName = actual.eventName
+ , selector = actual.selector
+ , result = { pass: jasmine.jQuery.events.wasTriggered(selector, eventName) }
+
+ result.message = result.pass ?
+ "Expected event " + eventName + " not to have been triggered on " + selector :
+ "Expected event " + eventName + " to have been triggered on " + selector
+
+ return result
+ }
+ }
+ },
+
+ toHaveBeenTriggeredOnAndWith: function (j$, customEqualityTesters) {
+ return {
+ compare: function (actual, selector, expectedArgs) {
+ var wasTriggered = jasmine.jQuery.events.wasTriggered(selector, actual)
+ , result = { pass: wasTriggered && jasmine.jQuery.events.wasTriggeredWith(selector, actual, expectedArgs, j$, customEqualityTesters) }
+
+ if (wasTriggered) {
+ var actualArgs = jasmine.jQuery.events.args(selector, actual, expectedArgs)[1]
+ result.message = result.pass ?
+ "Expected event " + actual + " not to have been triggered with " + jasmine.pp(expectedArgs) + " but it was triggered with " + jasmine.pp(actualArgs) :
+ "Expected event " + actual + " to have been triggered with " + jasmine.pp(expectedArgs) + " but it was triggered with " + jasmine.pp(actualArgs)
+
+ } else {
+ // todo check on this
+ result.message = result.pass ?
+ "Expected event " + actual + " not to have been triggered on " + selector :
+ "Expected event " + actual + " to have been triggered on " + selector
+ }
+
+ return result
+ }
+ }
+ },
+
+ toHaveBeenPreventedOn: function () {
+ return {
+ compare: function (actual, selector) {
+ var result = { pass: jasmine.jQuery.events.wasPrevented(selector, actual) }
+
+ result.message = result.pass ?
+ "Expected event " + actual + " not to have been prevented on " + selector :
+ "Expected event " + actual + " to have been prevented on " + selector
+
+ return result
+ }
+ }
+ },
+
+ toHaveBeenPrevented: function () {
+ return {
+ compare: function (actual) {
+ var eventName = actual.eventName
+ , selector = actual.selector
+ , result = { pass: jasmine.jQuery.events.wasPrevented(selector, eventName) }
+
+ result.message = result.pass ?
+ "Expected event " + eventName + " not to have been prevented on " + selector :
+ "Expected event " + eventName + " to have been prevented on " + selector
+
+ return result
+ }
+ }
+ },
+
+ toHaveBeenStoppedOn: function () {
+ return {
+ compare: function (actual, selector) {
+ var result = { pass: jasmine.jQuery.events.wasStopped(selector, actual) }
+
+ result.message = result.pass ?
+ "Expected event " + actual + " not to have been stopped on " + selector :
+ "Expected event " + actual + " to have been stopped on " + selector
+
+ return result;
+ }
+ }
+ },
+
+ toHaveBeenStopped: function () {
+ return {
+ compare: function (actual) {
+ var eventName = actual.eventName
+ , selector = actual.selector
+ , result = { pass: jasmine.jQuery.events.wasStopped(selector, eventName) }
+
+ result.message = result.pass ?
+ "Expected event " + eventName + " not to have been stopped on " + selector :
+ "Expected event " + eventName + " to have been stopped on " + selector
+
+ return result
+ }
+ }
+ }
+ })
+
+ jasmine.getEnv().addCustomEqualityTester(function(a, b) {
+ if (a && b) {
+ if (a instanceof $ || jasmine.isDomNode(a)) {
+ var $a = $(a)
+
+ if (b instanceof $)
+ return $a.length == b.length && a.is(b)
+
+ return $a.is(b);
+ }
+
+ if (b instanceof $ || jasmine.isDomNode(b)) {
+ var $b = $(b)
+
+ if (a instanceof $)
+ return a.length == $b.length && $b.is(a)
+
+ return $(b).is(a);
+ }
+ }
+ })
+
+ jasmine.getEnv().addCustomEqualityTester(function (a, b) {
+ if (a instanceof $ && b instanceof $ && a.size() == b.size())
+ return a.is(b)
+ })
+ })
+
+ afterEach(function () {
+ jasmine.getFixtures().cleanUp()
+ jasmine.getStyleFixtures().cleanUp()
+ jasmine.jQuery.events.cleanUp()
+ })
+
+ window.readFixtures = function () {
+ return jasmine.getFixtures().proxyCallTo_('read', arguments)
+ }
+
+ window.preloadFixtures = function () {
+ jasmine.getFixtures().proxyCallTo_('preload', arguments)
+ }
+
+ window.loadFixtures = function () {
+ jasmine.getFixtures().proxyCallTo_('load', arguments)
+ }
+
+ window.appendLoadFixtures = function () {
+ jasmine.getFixtures().proxyCallTo_('appendLoad', arguments)
+ }
+
+ window.setFixtures = function (html) {
+ return jasmine.getFixtures().proxyCallTo_('set', arguments)
+ }
+
+ window.appendSetFixtures = function () {
+ jasmine.getFixtures().proxyCallTo_('appendSet', arguments)
+ }
+
+ window.sandbox = function (attributes) {
+ return jasmine.getFixtures().sandbox(attributes)
+ }
+
+ window.spyOnEvent = function (selector, eventName) {
+ return jasmine.jQuery.events.spyOn(selector, eventName)
+ }
+
+ window.preloadStyleFixtures = function () {
+ jasmine.getStyleFixtures().proxyCallTo_('preload', arguments)
+ }
+
+ window.loadStyleFixtures = function () {
+ jasmine.getStyleFixtures().proxyCallTo_('load', arguments)
+ }
+
+ window.appendLoadStyleFixtures = function () {
+ jasmine.getStyleFixtures().proxyCallTo_('appendLoad', arguments)
+ }
+
+ window.setStyleFixtures = function (html) {
+ jasmine.getStyleFixtures().proxyCallTo_('set', arguments)
+ }
+
+ window.appendSetStyleFixtures = function (html) {
+ jasmine.getStyleFixtures().proxyCallTo_('appendSet', arguments)
+ }
+
+ window.loadJSONFixtures = function () {
+ return jasmine.getJSONFixtures().proxyCallTo_('load', arguments)
+ }
+
+ window.getJSONFixture = function (url) {
+ return jasmine.getJSONFixtures().proxyCallTo_('read', arguments)[url]
+ }
+}));
diff --git a/vendor/assets/javascripts/jquery.atwho.js b/vendor/assets/javascripts/jquery.atwho.js
index 0d295ebe5af..e058e13303a 100644
--- a/vendor/assets/javascripts/jquery.atwho.js
+++ b/vendor/assets/javascripts/jquery.atwho.js
@@ -448,7 +448,7 @@ Controller = (function() {
Controller.prototype.insertContentFor = function($li) {
var data, tpl;
tpl = this.getOpt('insertTpl');
- data = $.extend({}, $li.data('item-data'), {
+ data = $.extend({}, $li.data('itemData'), {
'atwho-at': this.at
});
return this.callbacks("tplEval").call(this, tpl, data, "onInsert");
@@ -824,7 +824,7 @@ EditableController = (function(superClass) {
this.$inputor.focus();
}
suffix = (suffix = this.getOpt('suffix')) === "" ? suffix : suffix || "\u00A0";
- data = $li.data('item-data');
+ data = $li.data('itemData');
this.query.el.removeClass('atwho-query').addClass('atwho-inserted').html(content).attr('data-atwho-at-query', "" + data['atwho-at'] + this.query.text);
if (range = this._getRange()) {
range.setEndAfter(this.query.el[0]);
diff --git a/vendor/assets/javascripts/jquery.endless-scroll.js b/vendor/assets/javascripts/jquery.endless-scroll.js
index 38db6b05101..f022d9a5d06 100644
--- a/vendor/assets/javascripts/jquery.endless-scroll.js
+++ b/vendor/assets/javascripts/jquery.endless-scroll.js
@@ -125,4 +125,4 @@
}
};
-})(jQuery); \ No newline at end of file
+})(jQuery);
diff --git a/vendor/assets/javascripts/jquery.waitforimages.js b/vendor/assets/javascripts/jquery.waitforimages.js
deleted file mode 100644
index 95b39c2e074..00000000000
--- a/vendor/assets/javascripts/jquery.waitforimages.js
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * waitForImages 1.4
- * -----------------
- * Provides a callback when all images have loaded in your given selector.
- * http://www.alexanderdickson.com/
- *
- *
- * Copyright (c) 2011 Alex Dickson
- * Licensed under the MIT licenses.
- * See website for more info.
- *
- */
-
-;(function($) {
- // Namespace all events.
- var eventNamespace = 'waitForImages';
-
- // CSS properties which contain references to images.
- $.waitForImages = {
- hasImageProperties: [
- 'backgroundImage',
- 'listStyleImage',
- 'borderImage',
- 'borderCornerImage'
- ]
- };
-
- // Custom selector to find `img` elements that have a valid `src` attribute and have not already loaded.
- $.expr[':'].uncached = function(obj) {
- // Ensure we are dealing with an `img` element with a valid `src` attribute.
- if ( ! $(obj).is('img[src!=""]')) {
- return false;
- }
-
- // Firefox's `complete` property will always be`true` even if the image has not been downloaded.
- // Doing it this way works in Firefox.
- var img = document.createElement('img');
- img.src = obj.src;
- return ! img.complete;
- };
-
- $.fn.waitForImages = function(finishedCallback, eachCallback, waitForAll) {
-
- // Handle options object.
- if ($.isPlainObject(arguments[0])) {
- eachCallback = finishedCallback.each;
- waitForAll = finishedCallback.waitForAll;
- finishedCallback = finishedCallback.finished;
- }
-
- // Handle missing callbacks.
- finishedCallback = finishedCallback || $.noop;
- eachCallback = eachCallback || $.noop;
-
- // Convert waitForAll to Boolean
- waitForAll = !! waitForAll;
-
- // Ensure callbacks are functions.
- if (!$.isFunction(finishedCallback) || !$.isFunction(eachCallback)) {
- throw new TypeError('An invalid callback was supplied.');
- };
-
- return this.each(function() {
- // Build a list of all imgs, dependent on what images will be considered.
- var obj = $(this),
- allImgs = [];
-
- if (waitForAll) {
- // CSS properties which may contain an image.
- var hasImgProperties = $.waitForImages.hasImageProperties || [],
- matchUrl = /url\((['"]?)(.*?)\1\)/g;
-
- // Get all elements, as any one of them could have a background image.
- obj.find('*').each(function() {
- var element = $(this);
-
- // If an `img` element, add it. But keep iterating in case it has a background image too.
- if (element.is('img:uncached')) {
- allImgs.push({
- src: element.attr('src'),
- element: element[0]
- });
- }
-
- $.each(hasImgProperties, function(i, property) {
- var propertyValue = element.css(property);
- // If it doesn't contain this property, skip.
- if ( ! propertyValue) {
- return true;
- }
-
- // Get all url() of this element.
- var match;
- while (match = matchUrl.exec(propertyValue)) {
- allImgs.push({
- src: match[2],
- element: element[0]
- });
- };
- });
- });
- } else {
- // For images only, the task is simpler.
- obj
- .find('img:uncached')
- .each(function() {
- allImgs.push({
- src: this.src,
- element: this
- });
- });
- };
-
- var allImgsLength = allImgs.length,
- allImgsLoaded = 0;
-
- // If no images found, don't bother.
- if (allImgsLength == 0) {
- finishedCallback.call(obj[0]);
- };
-
- $.each(allImgs, function(i, img) {
-
- var image = new Image;
-
- // Handle the image loading and error with the same callback.
- $(image).bind('load.' + eventNamespace + ' error.' + eventNamespace, function(event) {
- allImgsLoaded++;
-
- // If an error occurred with loading the image, set the third argument accordingly.
- eachCallback.call(img.element, allImgsLoaded, allImgsLength, event.type == 'load');
-
- if (allImgsLoaded == allImgsLength) {
- finishedCallback.call(obj[0]);
- return false;
- };
-
- });
-
- image.src = img.src;
- });
- });
- };
-})(jQuery);
diff --git a/vendor/assets/javascripts/katex.js b/vendor/assets/javascripts/katex.js
deleted file mode 100644
index 6b59a3477a7..00000000000
--- a/vendor/assets/javascripts/katex.js
+++ /dev/null
@@ -1,8685 +0,0 @@
-/*
- The MIT License (MIT)
-
- Copyright (c) 2015 Khan Academy
-
- This software also uses portions of the underscore.js project, which is
- MIT licensed with the following copyright:
-
- Copyright (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative
- Reporters & Editors
-
- 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.
- */
-
-/*
- Here is how to build a version of KaTeX that works with gitlab.
-
- The problem is that the standard procedure for changing font location doesn't work for the empty string.
-
- 1. Clone KaTeX. Anything later than 4fb9445a9 (is merged into master) will do.
- 2. make (requires node)
- 3. sed -e 's,fonts/,,' -e 's/url\(([^)]*)\)/url(font-path\1)/g' build/katex.css > build/katex.scss
- 4. Copy build/katex.js to gitlab/vendor/assets/javascripts/katex.js,
- build/katex.scss to gitlab/vendor/assets/stylesheets/katex.scss and
- fonts/* to gitlab/vendor/assets/fonts/.
-*/
-
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.katex = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/* eslint no-console:0 */
-/**
- * This is the main entry point for KaTeX. Here, we expose functions for
- * rendering expressions either to DOM nodes or to markup strings.
- *
- * We also expose the ParseError class to check if errors thrown from KaTeX are
- * errors in the expression, or errors in javascript handling.
- */
-
-var ParseError = require("./src/ParseError");
-var Settings = require("./src/Settings");
-
-var buildTree = require("./src/buildTree");
-var parseTree = require("./src/parseTree");
-var utils = require("./src/utils");
-
-/**
- * Parse and build an expression, and place that expression in the DOM node
- * given.
- */
-var render = function(expression, baseNode, options) {
- utils.clearNode(baseNode);
-
- var settings = new Settings(options);
-
- var tree = parseTree(expression, settings);
- var node = buildTree(tree, expression, settings).toNode();
-
- baseNode.appendChild(node);
-};
-
-// KaTeX's styles don't work properly in quirks mode. Print out an error, and
-// disable rendering.
-if (typeof document !== "undefined") {
- if (document.compatMode !== "CSS1Compat") {
- typeof console !== "undefined" && console.warn(
- "Warning: KaTeX doesn't work in quirks mode. Make sure your " +
- "website has a suitable doctype.");
-
- render = function() {
- throw new ParseError("KaTeX doesn't work in quirks mode.");
- };
- }
-}
-
-/**
- * Parse and build an expression, and return the markup for that.
- */
-var renderToString = function(expression, options) {
- var settings = new Settings(options);
-
- var tree = parseTree(expression, settings);
- return buildTree(tree, expression, settings).toMarkup();
-};
-
-/**
- * Parse an expression and return the parse tree.
- */
-var generateParseTree = function(expression, options) {
- var settings = new Settings(options);
- return parseTree(expression, settings);
-};
-
-module.exports = {
- render: render,
- renderToString: renderToString,
- /**
- * NOTE: This method is not currently recommended for public use.
- * The internal tree representation is unstable and is very likely
- * to change. Use at your own risk.
- */
- __parse: generateParseTree,
- ParseError: ParseError,
-};
-
-},{"./src/ParseError":6,"./src/Settings":8,"./src/buildTree":13,"./src/parseTree":22,"./src/utils":25}],2:[function(require,module,exports){
-/** @flow */
-
-"use strict";
-
-function getRelocatable(re) {
- // In the future, this could use a WeakMap instead of an expando.
- if (!re.__matchAtRelocatable) {
- // Disjunctions are the lowest-precedence operator, so we can make any
- // pattern match the empty string by appending `|()` to it:
- // https://people.mozilla.org/~jorendorff/es6-draft.html#sec-patterns
- var source = re.source + "|()";
-
- // We always make the new regex global.
- var flags = "g" + (re.ignoreCase ? "i" : "") + (re.multiline ? "m" : "") + (re.unicode ? "u" : "")
- // sticky (/.../y) doesn't make sense in conjunction with our relocation
- // logic, so we ignore it here.
- ;
-
- re.__matchAtRelocatable = new RegExp(source, flags);
- }
- return re.__matchAtRelocatable;
-}
-
-function matchAt(re, str, pos) {
- if (re.global || re.sticky) {
- throw new Error("matchAt(...): Only non-global regexes are supported");
- }
- var reloc = getRelocatable(re);
- reloc.lastIndex = pos;
- var match = reloc.exec(str);
- // Last capturing group is our sentinel that indicates whether the regex
- // matched at the given location.
- if (match[match.length - 1] == null) {
- // Original regex matched.
- match.length = match.length - 1;
- return match;
- } else {
- return null;
- }
-}
-
-module.exports = matchAt;
-},{}],3:[function(require,module,exports){
-/**
- * The Lexer class handles tokenizing the input in various ways. Since our
- * parser expects us to be able to backtrack, the lexer allows lexing from any
- * given starting point.
- *
- * Its main exposed function is the `lex` function, which takes a position to
- * lex from and a type of token to lex. It defers to the appropriate `_innerLex`
- * function.
- *
- * The various `_innerLex` functions perform the actual lexing of different
- * kinds.
- */
-
-var matchAt = require("match-at");
-
-var ParseError = require("./ParseError");
-
-// The main lexer class
-function Lexer(input) {
- this.input = input;
- this.pos = 0;
-}
-
-/**
- * The resulting token returned from `lex`.
- *
- * It consists of the token text plus some position information.
- * The position information is essentially a range in an input string,
- * but instead of referencing the bare input string, we refer to the lexer.
- * That way it is possible to attach extra metadata to the input string,
- * like for example a file name or similar.
- *
- * The position information (all three parameters) is optional,
- * so it is OK to construct synthetic tokens if appropriate.
- * Not providing available position information may lead to
- * degraded error reporting, though.
- *
- * @param {string} text the text of this token
- * @param {number=} start the start offset, zero-based inclusive
- * @param {number=} end the end offset, zero-based exclusive
- * @param {Lexer=} lexer the lexer which in turn holds the input string
- */
-function Token(text, start, end, lexer) {
- this.text = text;
- this.start = start;
- this.end = end;
- this.lexer = lexer;
-}
-
-/**
- * Given a pair of tokens (this and endToken), compute a “Token†encompassing
- * the whole input range enclosed by these two.
- *
- * @param {Token} endToken last token of the range, inclusive
- * @param {string} text the text of the newly constructed token
- */
-Token.prototype.range = function(endToken, text) {
- if (endToken.lexer !== this.lexer) {
- return new Token(text); // sorry, no position information available
- }
- return new Token(text, this.start, endToken.end, this.lexer);
-};
-
-/* The following tokenRegex
- * - matches typical whitespace (but not NBSP etc.) using its first group
- * - does not match any control character \x00-\x1f except whitespace
- * - does not match a bare backslash
- * - matches any ASCII character except those just mentioned
- * - does not match the BMP private use area \uE000-\uF8FF
- * - does not match bare surrogate code units
- * - matches any BMP character except for those just described
- * - matches any valid Unicode surrogate pair
- * - matches a backslash followed by one or more letters
- * - matches a backslash followed by any BMP character, including newline
- * Just because the Lexer matches something doesn't mean it's valid input:
- * If there is no matching function or symbol definition, the Parser will
- * still reject the input.
- */
-var tokenRegex = new RegExp(
- "([ \r\n\t]+)|" + // whitespace
- "([!-\\[\\]-\u2027\u202A-\uD7FF\uF900-\uFFFF]" + // single codepoint
- "|[\uD800-\uDBFF][\uDC00-\uDFFF]" + // surrogate pair
- "|\\\\(?:[a-zA-Z]+|[^\uD800-\uDFFF])" + // function name
- ")"
-);
-
-/**
- * This function lexes a single token.
- */
-Lexer.prototype.lex = function() {
- var input = this.input;
- var pos = this.pos;
- if (pos === input.length) {
- return new Token("EOF", pos, pos, this);
- }
- var match = matchAt(tokenRegex, input, pos);
- if (match === null) {
- throw new ParseError(
- "Unexpected character: '" + input[pos] + "'",
- new Token(input[pos], pos, pos + 1, this));
- }
- var text = match[2] || " ";
- var start = this.pos;
- this.pos += match[0].length;
- var end = this.pos;
- return new Token(text, start, end, this);
-};
-
-module.exports = Lexer;
-
-},{"./ParseError":6,"match-at":2}],4:[function(require,module,exports){
-/**
- * This file contains the “gullet†where macros are expanded
- * until only non-macro tokens remain.
- */
-
-var Lexer = require("./Lexer");
-
-function MacroExpander(input, macros) {
- this.lexer = new Lexer(input);
- this.macros = macros;
- this.stack = []; // contains tokens in REVERSE order
- this.discardedWhiteSpace = [];
-}
-
-/**
- * Recursively expand first token, then return first non-expandable token.
- */
-MacroExpander.prototype.nextToken = function() {
- for (;;) {
- if (this.stack.length === 0) {
- this.stack.push(this.lexer.lex());
- }
- var topToken = this.stack.pop();
- var name = topToken.text;
- if (!(name.charAt(0) === "\\" && this.macros.hasOwnProperty(name))) {
- return topToken;
- }
- var expansion = this.macros[name];
- if (typeof expansion === "string") {
- var bodyLexer = new Lexer(expansion);
- expansion = [];
- var tok = bodyLexer.lex();
- while (tok.text !== "EOF") {
- expansion.push(tok);
- tok = bodyLexer.lex();
- }
- expansion.reverse(); // to fit in with stack using push and pop
- this.macros[name] = expansion;
- }
- this.stack = this.stack.concat(expansion);
- }
-};
-
-MacroExpander.prototype.get = function(ignoreSpace) {
- this.discardedWhiteSpace = [];
- var token = this.nextToken();
- if (ignoreSpace) {
- while (token.text === " ") {
- this.discardedWhiteSpace.push(token);
- token = this.nextToken();
- }
- }
- return token;
-};
-
-/**
- * Undo the effect of the preceding call to the get method.
- * A call to this method MUST be immediately preceded and immediately followed
- * by a call to get. Only used during mode switching, i.e. after one token
- * was got in the old mode but should get got again in a new mode
- * with possibly different whitespace handling.
- */
-MacroExpander.prototype.unget = function(token) {
- this.stack.push(token);
- while (this.discardedWhiteSpace.length !== 0) {
- this.stack.push(this.discardedWhiteSpace.pop());
- }
-};
-
-module.exports = MacroExpander;
-
-},{"./Lexer":3}],5:[function(require,module,exports){
-/**
- * This file contains information about the options that the Parser carries
- * around with it while parsing. Data is held in an `Options` object, and when
- * recursing, a new `Options` object can be created with the `.with*` and
- * `.reset` functions.
- */
-
-/**
- * This is the main options class. It contains the style, size, color, and font
- * of the current parse level. It also contains the style and size of the parent
- * parse level, so size changes can be handled efficiently.
- *
- * Each of the `.with*` and `.reset` functions passes its current style and size
- * as the parentStyle and parentSize of the new options class, so parent
- * handling is taken care of automatically.
- */
-function Options(data) {
- this.style = data.style;
- this.color = data.color;
- this.size = data.size;
- this.phantom = data.phantom;
- this.font = data.font;
-
- if (data.parentStyle === undefined) {
- this.parentStyle = data.style;
- } else {
- this.parentStyle = data.parentStyle;
- }
-
- if (data.parentSize === undefined) {
- this.parentSize = data.size;
- } else {
- this.parentSize = data.parentSize;
- }
-}
-
-/**
- * Returns a new options object with the same properties as "this". Properties
- * from "extension" will be copied to the new options object.
- */
-Options.prototype.extend = function(extension) {
- var data = {
- style: this.style,
- size: this.size,
- color: this.color,
- parentStyle: this.style,
- parentSize: this.size,
- phantom: this.phantom,
- font: this.font,
- };
-
- for (var key in extension) {
- if (extension.hasOwnProperty(key)) {
- data[key] = extension[key];
- }
- }
-
- return new Options(data);
-};
-
-/**
- * Create a new options object with the given style.
- */
-Options.prototype.withStyle = function(style) {
- return this.extend({
- style: style,
- });
-};
-
-/**
- * Create a new options object with the given size.
- */
-Options.prototype.withSize = function(size) {
- return this.extend({
- size: size,
- });
-};
-
-/**
- * Create a new options object with the given color.
- */
-Options.prototype.withColor = function(color) {
- return this.extend({
- color: color,
- });
-};
-
-/**
- * Create a new options object with "phantom" set to true.
- */
-Options.prototype.withPhantom = function() {
- return this.extend({
- phantom: true,
- });
-};
-
-/**
- * Create a new options objects with the give font.
- */
-Options.prototype.withFont = function(font) {
- return this.extend({
- font: font,
- });
-};
-
-/**
- * Create a new options object with the same style, size, and color. This is
- * used so that parent style and size changes are handled correctly.
- */
-Options.prototype.reset = function() {
- return this.extend({});
-};
-
-/**
- * A map of color names to CSS colors.
- * TODO(emily): Remove this when we have real macros
- */
-var colorMap = {
- "katex-blue": "#6495ed",
- "katex-orange": "#ffa500",
- "katex-pink": "#ff00af",
- "katex-red": "#df0030",
- "katex-green": "#28ae7b",
- "katex-gray": "gray",
- "katex-purple": "#9d38bd",
- "katex-blueA": "#ccfaff",
- "katex-blueB": "#80f6ff",
- "katex-blueC": "#63d9ea",
- "katex-blueD": "#11accd",
- "katex-blueE": "#0c7f99",
- "katex-tealA": "#94fff5",
- "katex-tealB": "#26edd5",
- "katex-tealC": "#01d1c1",
- "katex-tealD": "#01a995",
- "katex-tealE": "#208170",
- "katex-greenA": "#b6ffb0",
- "katex-greenB": "#8af281",
- "katex-greenC": "#74cf70",
- "katex-greenD": "#1fab54",
- "katex-greenE": "#0d923f",
- "katex-goldA": "#ffd0a9",
- "katex-goldB": "#ffbb71",
- "katex-goldC": "#ff9c39",
- "katex-goldD": "#e07d10",
- "katex-goldE": "#a75a05",
- "katex-redA": "#fca9a9",
- "katex-redB": "#ff8482",
- "katex-redC": "#f9685d",
- "katex-redD": "#e84d39",
- "katex-redE": "#bc2612",
- "katex-maroonA": "#ffbde0",
- "katex-maroonB": "#ff92c6",
- "katex-maroonC": "#ed5fa6",
- "katex-maroonD": "#ca337c",
- "katex-maroonE": "#9e034e",
- "katex-purpleA": "#ddd7ff",
- "katex-purpleB": "#c6b9fc",
- "katex-purpleC": "#aa87ff",
- "katex-purpleD": "#7854ab",
- "katex-purpleE": "#543b78",
- "katex-mintA": "#f5f9e8",
- "katex-mintB": "#edf2df",
- "katex-mintC": "#e0e5cc",
- "katex-grayA": "#f6f7f7",
- "katex-grayB": "#f0f1f2",
- "katex-grayC": "#e3e5e6",
- "katex-grayD": "#d6d8da",
- "katex-grayE": "#babec2",
- "katex-grayF": "#888d93",
- "katex-grayG": "#626569",
- "katex-grayH": "#3b3e40",
- "katex-grayI": "#21242c",
- "katex-kaBlue": "#314453",
- "katex-kaGreen": "#71B307",
-};
-
-/**
- * Gets the CSS color of the current options object, accounting for the
- * `colorMap`.
- */
-Options.prototype.getColor = function() {
- if (this.phantom) {
- return "transparent";
- } else {
- return colorMap[this.color] || this.color;
- }
-};
-
-module.exports = Options;
-
-},{}],6:[function(require,module,exports){
-/**
- * This is the ParseError class, which is the main error thrown by KaTeX
- * functions when something has gone wrong. This is used to distinguish internal
- * errors from errors in the expression that the user provided.
- *
- * If possible, a caller should provide a Token or ParseNode with information
- * about where in the source string the problem occurred.
- *
- * @param {string} message The error message
- * @param {(Token|ParseNode)=} token An object providing position information
- */
-function ParseError(message, token) {
- var error = "KaTeX parse error: " + message;
- var start;
- var end;
-
- if (token && token.lexer && token.start <= token.end) {
- // If we have the input and a position, make the error a bit fancier
-
- // Get the input
- var input = token.lexer.input;
-
- // Prepend some information
- start = token.start;
- end = token.end;
- if (start === input.length) {
- error += " at end of input: ";
- } else {
- error += " at position " + (start + 1) + ": ";
- }
-
- // Underline token in question using combining underscores
- var underlined = input.slice(start, end).replace(/[^]/g, "$&\u0332");
-
- // Extract some context from the input and add it to the error
- var left;
- if (start > 15) {
- left = "…" + input.slice(start - 15, start);
- } else {
- left = input.slice(0, start);
- }
- var right;
- if (end + 15 < input.length) {
- right = input.slice(end, end + 15) + "…";
- } else {
- right = input.slice(end);
- }
- error += left + underlined + right;
- }
-
- // Some hackery to make ParseError a prototype of Error
- // See http://stackoverflow.com/a/8460753
- var self = new Error(error);
- self.name = "ParseError";
- self.__proto__ = ParseError.prototype;
-
- self.position = start;
- return self;
-}
-
-// More hackery
-ParseError.prototype.__proto__ = Error.prototype;
-
-module.exports = ParseError;
-
-},{}],7:[function(require,module,exports){
-/* eslint no-constant-condition:0 */
-var functions = require("./functions");
-var environments = require("./environments");
-var MacroExpander = require("./MacroExpander");
-var symbols = require("./symbols");
-var utils = require("./utils");
-var cjkRegex = require("./unicodeRegexes").cjkRegex;
-
-var parseData = require("./parseData");
-var ParseError = require("./ParseError");
-
-/**
- * This file contains the parser used to parse out a TeX expression from the
- * input. Since TeX isn't context-free, standard parsers don't work particularly
- * well.
- *
- * The strategy of this parser is as such:
- *
- * The main functions (the `.parse...` ones) take a position in the current
- * parse string to parse tokens from. The lexer (found in Lexer.js, stored at
- * this.lexer) also supports pulling out tokens at arbitrary places. When
- * individual tokens are needed at a position, the lexer is called to pull out a
- * token, which is then used.
- *
- * The parser has a property called "mode" indicating the mode that
- * the parser is currently in. Currently it has to be one of "math" or
- * "text", which denotes whether the current environment is a math-y
- * one or a text-y one (e.g. inside \text). Currently, this serves to
- * limit the functions which can be used in text mode.
- *
- * The main functions then return an object which contains the useful data that
- * was parsed at its given point, and a new position at the end of the parsed
- * data. The main functions can call each other and continue the parsing by
- * using the returned position as a new starting point.
- *
- * There are also extra `.handle...` functions, which pull out some reused
- * functionality into self-contained functions.
- *
- * The earlier functions return ParseNodes.
- * The later functions (which are called deeper in the parse) sometimes return
- * ParseFuncOrArgument, which contain a ParseNode as well as some data about
- * whether the parsed object is a function which is missing some arguments, or a
- * standalone object which can be used as an argument to another function.
- */
-
-/**
- * Main Parser class
- */
-function Parser(input, settings) {
- // Create a new macro expander (gullet) and (indirectly via that) also a
- // new lexer (mouth) for this parser (stomach, in the language of TeX)
- this.gullet = new MacroExpander(input, settings.macros);
- // Store the settings for use in parsing
- this.settings = settings;
-}
-
-var ParseNode = parseData.ParseNode;
-
-/**
- * An initial function (without its arguments), or an argument to a function.
- * The `result` argument should be a ParseNode.
- */
-function ParseFuncOrArgument(result, isFunction, token) {
- this.result = result;
- // Is this a function (i.e. is it something defined in functions.js)?
- this.isFunction = isFunction;
- this.token = token;
-}
-
-/**
- * Checks a result to make sure it has the right type, and throws an
- * appropriate error otherwise.
- *
- * @param {boolean=} consume whether to consume the expected token,
- * defaults to true
- */
-Parser.prototype.expect = function(text, consume) {
- if (this.nextToken.text !== text) {
- throw new ParseError(
- "Expected '" + text + "', got '" + this.nextToken.text + "'",
- this.nextToken
- );
- }
- if (consume !== false) {
- this.consume();
- }
-};
-
-/**
- * Considers the current look ahead token as consumed,
- * and fetches the one after that as the new look ahead.
- */
-Parser.prototype.consume = function() {
- this.nextToken = this.gullet.get(this.mode === "math");
-};
-
-Parser.prototype.switchMode = function(newMode) {
- this.gullet.unget(this.nextToken);
- this.mode = newMode;
- this.consume();
-};
-
-/**
- * Main parsing function, which parses an entire input.
- *
- * @return {?Array.<ParseNode>}
- */
-Parser.prototype.parse = function() {
- // Try to parse the input
- this.mode = "math";
- this.consume();
- var parse = this.parseInput();
- return parse;
-};
-
-/**
- * Parses an entire input tree.
- */
-Parser.prototype.parseInput = function() {
- // Parse an expression
- var expression = this.parseExpression(false);
- // If we succeeded, make sure there's an EOF at the end
- this.expect("EOF", false);
- return expression;
-};
-
-var endOfExpression = ["}", "\\end", "\\right", "&", "\\\\", "\\cr"];
-
-/**
- * Parses an "expression", which is a list of atoms.
- *
- * @param {boolean} breakOnInfix Should the parsing stop when we hit infix
- * nodes? This happens when functions have higher precendence
- * than infix nodes in implicit parses.
- *
- * @param {?string} breakOnTokenText The text of the token that the expression
- * should end with, or `null` if something else should end the
- * expression.
- *
- * @return {ParseNode}
- */
-Parser.prototype.parseExpression = function(breakOnInfix, breakOnTokenText) {
- var body = [];
- // Keep adding atoms to the body until we can't parse any more atoms (either
- // we reached the end, a }, or a \right)
- while (true) {
- var lex = this.nextToken;
- if (endOfExpression.indexOf(lex.text) !== -1) {
- break;
- }
- if (breakOnTokenText && lex.text === breakOnTokenText) {
- break;
- }
- if (breakOnInfix && functions[lex.text] && functions[lex.text].infix) {
- break;
- }
- var atom = this.parseAtom();
- if (!atom) {
- if (!this.settings.throwOnError && lex.text[0] === "\\") {
- var errorNode = this.handleUnsupportedCmd();
- body.push(errorNode);
- continue;
- }
-
- break;
- }
- body.push(atom);
- }
- return this.handleInfixNodes(body);
-};
-
-/**
- * Rewrites infix operators such as \over with corresponding commands such
- * as \frac.
- *
- * There can only be one infix operator per group. If there's more than one
- * then the expression is ambiguous. This can be resolved by adding {}.
- *
- * @returns {Array}
- */
-Parser.prototype.handleInfixNodes = function(body) {
- var overIndex = -1;
- var funcName;
-
- for (var i = 0; i < body.length; i++) {
- var node = body[i];
- if (node.type === "infix") {
- if (overIndex !== -1) {
- throw new ParseError(
- "only one infix operator per group",
- node.value.token);
- }
- overIndex = i;
- funcName = node.value.replaceWith;
- }
- }
-
- if (overIndex !== -1) {
- var numerNode;
- var denomNode;
-
- var numerBody = body.slice(0, overIndex);
- var denomBody = body.slice(overIndex + 1);
-
- if (numerBody.length === 1 && numerBody[0].type === "ordgroup") {
- numerNode = numerBody[0];
- } else {
- numerNode = new ParseNode("ordgroup", numerBody, this.mode);
- }
-
- if (denomBody.length === 1 && denomBody[0].type === "ordgroup") {
- denomNode = denomBody[0];
- } else {
- denomNode = new ParseNode("ordgroup", denomBody, this.mode);
- }
-
- var value = this.callFunction(
- funcName, [numerNode, denomNode], null);
- return [new ParseNode(value.type, value, this.mode)];
- } else {
- return body;
- }
-};
-
-// The greediness of a superscript or subscript
-var SUPSUB_GREEDINESS = 1;
-
-/**
- * Handle a subscript or superscript with nice errors.
- */
-Parser.prototype.handleSupSubscript = function(name) {
- var symbolToken = this.nextToken;
- var symbol = symbolToken.text;
- this.consume();
- var group = this.parseGroup();
-
- if (!group) {
- if (!this.settings.throwOnError && this.nextToken.text[0] === "\\") {
- return this.handleUnsupportedCmd();
- } else {
- throw new ParseError(
- "Expected group after '" + symbol + "'",
- symbolToken
- );
- }
- } else if (group.isFunction) {
- // ^ and _ have a greediness, so handle interactions with functions'
- // greediness
- var funcGreediness = functions[group.result].greediness;
- if (funcGreediness > SUPSUB_GREEDINESS) {
- return this.parseFunction(group);
- } else {
- throw new ParseError(
- "Got function '" + group.result + "' with no arguments " +
- "as " + name, symbolToken);
- }
- } else {
- return group.result;
- }
-};
-
-/**
- * Converts the textual input of an unsupported command into a text node
- * contained within a color node whose color is determined by errorColor
- */
-Parser.prototype.handleUnsupportedCmd = function() {
- var text = this.nextToken.text;
- var textordArray = [];
-
- for (var i = 0; i < text.length; i++) {
- textordArray.push(new ParseNode("textord", text[i], "text"));
- }
-
- var textNode = new ParseNode(
- "text",
- {
- body: textordArray,
- type: "text",
- },
- this.mode);
-
- var colorNode = new ParseNode(
- "color",
- {
- color: this.settings.errorColor,
- value: [textNode],
- type: "color",
- },
- this.mode);
-
- this.consume();
- return colorNode;
-};
-
-/**
- * Parses a group with optional super/subscripts.
- *
- * @return {?ParseNode}
- */
-Parser.prototype.parseAtom = function() {
- // The body of an atom is an implicit group, so that things like
- // \left(x\right)^2 work correctly.
- var base = this.parseImplicitGroup();
-
- // In text mode, we don't have superscripts or subscripts
- if (this.mode === "text") {
- return base;
- }
-
- // Note that base may be empty (i.e. null) at this point.
-
- var superscript;
- var subscript;
- while (true) {
- // Lex the first token
- var lex = this.nextToken;
-
- if (lex.text === "\\limits" || lex.text === "\\nolimits") {
- // We got a limit control
- if (!base || base.type !== "op") {
- throw new ParseError(
- "Limit controls must follow a math operator",
- lex);
- } else {
- var limits = lex.text === "\\limits";
- base.value.limits = limits;
- base.value.alwaysHandleSupSub = true;
- }
- this.consume();
- } else if (lex.text === "^") {
- // We got a superscript start
- if (superscript) {
- throw new ParseError("Double superscript", lex);
- }
- superscript = this.handleSupSubscript("superscript");
- } else if (lex.text === "_") {
- // We got a subscript start
- if (subscript) {
- throw new ParseError("Double subscript", lex);
- }
- subscript = this.handleSupSubscript("subscript");
- } else if (lex.text === "'") {
- // We got a prime
- var prime = new ParseNode("textord", "\\prime", this.mode);
-
- // Many primes can be grouped together, so we handle this here
- var primes = [prime];
- this.consume();
- // Keep lexing tokens until we get something that's not a prime
- while (this.nextToken.text === "'") {
- // For each one, add another prime to the list
- primes.push(prime);
- this.consume();
- }
- // Put them into an ordgroup as the superscript
- superscript = new ParseNode("ordgroup", primes, this.mode);
- } else {
- // If it wasn't ^, _, or ', stop parsing super/subscripts
- break;
- }
- }
-
- if (superscript || subscript) {
- // If we got either a superscript or subscript, create a supsub
- return new ParseNode("supsub", {
- base: base,
- sup: superscript,
- sub: subscript,
- }, this.mode);
- } else {
- // Otherwise return the original body
- return base;
- }
-};
-
-// A list of the size-changing functions, for use in parseImplicitGroup
-var sizeFuncs = [
- "\\tiny", "\\scriptsize", "\\footnotesize", "\\small", "\\normalsize",
- "\\large", "\\Large", "\\LARGE", "\\huge", "\\Huge",
-];
-
-// A list of the style-changing functions, for use in parseImplicitGroup
-var styleFuncs = [
- "\\displaystyle", "\\textstyle", "\\scriptstyle", "\\scriptscriptstyle",
-];
-
-/**
- * Parses an implicit group, which is a group that starts at the end of a
- * specified, and ends right before a higher explicit group ends, or at EOL. It
- * is used for functions that appear to affect the current style, like \Large or
- * \textrm, where instead of keeping a style we just pretend that there is an
- * implicit grouping after it until the end of the group. E.g.
- * small text {\Large large text} small text again
- * It is also used for \left and \right to get the correct grouping.
- *
- * @return {?ParseNode}
- */
-Parser.prototype.parseImplicitGroup = function() {
- var start = this.parseSymbol();
-
- if (start == null) {
- // If we didn't get anything we handle, fall back to parseFunction
- return this.parseFunction();
- }
-
- var func = start.result;
- var body;
-
- if (func === "\\left") {
- // If we see a left:
- // Parse the entire left function (including the delimiter)
- var left = this.parseFunction(start);
- // Parse out the implicit body
- body = this.parseExpression(false);
- // Check the next token
- this.expect("\\right", false);
- var right = this.parseFunction();
- return new ParseNode("leftright", {
- body: body,
- left: left.value.value,
- right: right.value.value,
- }, this.mode);
- } else if (func === "\\begin") {
- // begin...end is similar to left...right
- var begin = this.parseFunction(start);
- var envName = begin.value.name;
- if (!environments.hasOwnProperty(envName)) {
- throw new ParseError(
- "No such environment: " + envName, begin.value.nameGroup);
- }
- // Build the environment object. Arguments and other information will
- // be made available to the begin and end methods using properties.
- var env = environments[envName];
- var args = this.parseArguments("\\begin{" + envName + "}", env);
- var context = {
- mode: this.mode,
- envName: envName,
- parser: this,
- positions: args.pop(),
- };
- var result = env.handler(context, args);
- this.expect("\\end", false);
- var endNameToken = this.nextToken;
- var end = this.parseFunction();
- if (end.value.name !== envName) {
- throw new ParseError(
- "Mismatch: \\begin{" + envName + "} matched " +
- "by \\end{" + end.value.name + "}",
- endNameToken);
- }
- result.position = end.position;
- return result;
- } else if (utils.contains(sizeFuncs, func)) {
- // If we see a sizing function, parse out the implict body
- body = this.parseExpression(false);
- return new ParseNode("sizing", {
- // Figure out what size to use based on the list of functions above
- size: "size" + (utils.indexOf(sizeFuncs, func) + 1),
- value: body,
- }, this.mode);
- } else if (utils.contains(styleFuncs, func)) {
- // If we see a styling function, parse out the implict body
- body = this.parseExpression(true);
- return new ParseNode("styling", {
- // Figure out what style to use by pulling out the style from
- // the function name
- style: func.slice(1, func.length - 5),
- value: body,
- }, this.mode);
- } else {
- // Defer to parseFunction if it's not a function we handle
- return this.parseFunction(start);
- }
-};
-
-/**
- * Parses an entire function, including its base and all of its arguments.
- * The base might either have been parsed already, in which case
- * it is provided as an argument, or it's the next group in the input.
- *
- * @param {ParseFuncOrArgument=} baseGroup optional as described above
- * @return {?ParseNode}
- */
-Parser.prototype.parseFunction = function(baseGroup) {
- if (!baseGroup) {
- baseGroup = this.parseGroup();
- }
-
- if (baseGroup) {
- if (baseGroup.isFunction) {
- var func = baseGroup.result;
- var funcData = functions[func];
- if (this.mode === "text" && !funcData.allowedInText) {
- throw new ParseError(
- "Can't use function '" + func + "' in text mode",
- baseGroup.token);
- }
-
- var args = this.parseArguments(func, funcData);
- var token = baseGroup.token;
- var result = this.callFunction(func, args, args.pop(), token);
- return new ParseNode(result.type, result, this.mode);
- } else {
- return baseGroup.result;
- }
- } else {
- return null;
- }
-};
-
-/**
- * Call a function handler with a suitable context and arguments.
- */
-Parser.prototype.callFunction = function(name, args, positions, token) {
- var context = {
- funcName: name,
- parser: this,
- positions: positions,
- token: token,
- };
- return functions[name].handler(context, args);
-};
-
-/**
- * Parses the arguments of a function or environment
- *
- * @param {string} func "\name" or "\begin{name}"
- * @param {{numArgs:number,numOptionalArgs:number|undefined}} funcData
- * @return the array of arguments, with the list of positions as last element
- */
-Parser.prototype.parseArguments = function(func, funcData) {
- var totalArgs = funcData.numArgs + funcData.numOptionalArgs;
- if (totalArgs === 0) {
- return [[this.pos]];
- }
-
- var baseGreediness = funcData.greediness;
- var positions = [this.pos];
- var args = [];
-
- for (var i = 0; i < totalArgs; i++) {
- var nextToken = this.nextToken;
- var argType = funcData.argTypes && funcData.argTypes[i];
- var arg;
- if (i < funcData.numOptionalArgs) {
- if (argType) {
- arg = this.parseGroupOfType(argType, true);
- } else {
- arg = this.parseGroup(true);
- }
- if (!arg) {
- args.push(null);
- positions.push(this.pos);
- continue;
- }
- } else {
- if (argType) {
- arg = this.parseGroupOfType(argType);
- } else {
- arg = this.parseGroup();
- }
- if (!arg) {
- if (!this.settings.throwOnError &&
- this.nextToken.text[0] === "\\") {
- arg = new ParseFuncOrArgument(
- this.handleUnsupportedCmd(this.nextToken.text),
- false);
- } else {
- throw new ParseError(
- "Expected group after '" + func + "'", nextToken);
- }
- }
- }
- var argNode;
- if (arg.isFunction) {
- var argGreediness =
- functions[arg.result].greediness;
- if (argGreediness > baseGreediness) {
- argNode = this.parseFunction(arg);
- } else {
- throw new ParseError(
- "Got function '" + arg.result + "' as " +
- "argument to '" + func + "'", nextToken);
- }
- } else {
- argNode = arg.result;
- }
- args.push(argNode);
- positions.push(this.pos);
- }
-
- args.push(positions);
-
- return args;
-};
-
-
-/**
- * Parses a group when the mode is changing.
- *
- * @return {?ParseFuncOrArgument}
- */
-Parser.prototype.parseGroupOfType = function(innerMode, optional) {
- var outerMode = this.mode;
- // Handle `original` argTypes
- if (innerMode === "original") {
- innerMode = outerMode;
- }
-
- if (innerMode === "color") {
- return this.parseColorGroup(optional);
- }
- if (innerMode === "size") {
- return this.parseSizeGroup(optional);
- }
-
- this.switchMode(innerMode);
- if (innerMode === "text") {
- // text mode is special because it should ignore the whitespace before
- // it
- while (this.nextToken.text === " ") {
- this.consume();
- }
- }
- // By the time we get here, innerMode is one of "text" or "math".
- // We switch the mode of the parser, recurse, then restore the old mode.
- var res = this.parseGroup(optional);
- this.switchMode(outerMode);
- return res;
-};
-
-/**
- * Parses a group, essentially returning the string formed by the
- * brace-enclosed tokens plus some position information.
- *
- * @param {string} modeName Used to describe the mode in error messages
- * @param {boolean=} optional Whether the group is optional or required
- */
-Parser.prototype.parseStringGroup = function(modeName, optional) {
- if (optional && this.nextToken.text !== "[") {
- return null;
- }
- var outerMode = this.mode;
- this.mode = "text";
- this.expect(optional ? "[" : "{");
- var str = "";
- var firstToken = this.nextToken;
- var lastToken = firstToken;
- while (this.nextToken.text !== (optional ? "]" : "}")) {
- if (this.nextToken.text === "EOF") {
- throw new ParseError(
- "Unexpected end of input in " + modeName,
- firstToken.range(this.nextToken, str));
- }
- lastToken = this.nextToken;
- str += lastToken.text;
- this.consume();
- }
- this.mode = outerMode;
- this.expect(optional ? "]" : "}");
- return firstToken.range(lastToken, str);
-};
-
-/**
- * Parses a color description.
- */
-Parser.prototype.parseColorGroup = function(optional) {
- var res = this.parseStringGroup("color", optional);
- if (!res) {
- return null;
- }
- var match = (/^(#[a-z0-9]+|[a-z]+)$/i).exec(res.text);
- if (!match) {
- throw new ParseError("Invalid color: '" + res.text + "'", res);
- }
- return new ParseFuncOrArgument(
- new ParseNode("color", match[0], this.mode),
- false);
-};
-
-/**
- * Parses a size specification, consisting of magnitude and unit.
- */
-Parser.prototype.parseSizeGroup = function(optional) {
- var res = this.parseStringGroup("size", optional);
- if (!res) {
- return null;
- }
- var match = (/(-?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/).exec(res.text);
- if (!match) {
- throw new ParseError("Invalid size: '" + res.text + "'", res);
- }
- var data = {
- number: +(match[1] + match[2]), // sign + magnitude, cast to number
- unit: match[3],
- };
- if (data.unit !== "em" && data.unit !== "ex") {
- throw new ParseError("Invalid unit: '" + data.unit + "'", res);
- }
- return new ParseFuncOrArgument(
- new ParseNode("color", data, this.mode),
- false);
-};
-
-/**
- * If the argument is false or absent, this parses an ordinary group,
- * which is either a single nucleus (like "x") or an expression
- * in braces (like "{x+y}").
- * If the argument is true, it parses either a bracket-delimited expression
- * (like "[x+y]") or returns null to indicate the absence of a
- * bracket-enclosed group.
- *
- * @param {boolean=} optional Whether the group is optional or required
- * @return {?ParseFuncOrArgument}
- */
-Parser.prototype.parseGroup = function(optional) {
- var firstToken = this.nextToken;
- // Try to parse an open brace
- if (this.nextToken.text === (optional ? "[" : "{")) {
- // If we get a brace, parse an expression
- this.consume();
- var expression = this.parseExpression(false, optional ? "]" : null);
- var lastToken = this.nextToken;
- // Make sure we get a close brace
- this.expect(optional ? "]" : "}");
- if (this.mode === "text") {
- this.formLigatures(expression);
- }
- return new ParseFuncOrArgument(
- new ParseNode("ordgroup", expression, this.mode,
- firstToken, lastToken),
- false);
- } else {
- // Otherwise, just return a nucleus, or nothing for an optional group
- return optional ? null : this.parseSymbol();
- }
-};
-
-/**
- * Form ligature-like combinations of characters for text mode.
- * This includes inputs like "--", "---", "``" and "''".
- * The result will simply replace multiple textord nodes with a single
- * character in each value by a single textord node having multiple
- * characters in its value. The representation is still ASCII source.
- *
- * @param {Array.<ParseNode>} group the nodes of this group,
- * list will be moified in place
- */
-Parser.prototype.formLigatures = function(group) {
- var i;
- var n = group.length - 1;
- for (i = 0; i < n; ++i) {
- var a = group[i];
- var v = a.value;
- if (v === "-" && group[i + 1].value === "-") {
- if (i + 1 < n && group[i + 2].value === "-") {
- group.splice(i, 3, new ParseNode(
- "textord", "---", "text", a, group[i + 2]));
- n -= 2;
- } else {
- group.splice(i, 2, new ParseNode(
- "textord", "--", "text", a, group[i + 1]));
- n -= 1;
- }
- }
- if ((v === "'" || v === "`") && group[i + 1].value === v) {
- group.splice(i, 2, new ParseNode(
- "textord", v + v, "text", a, group[i + 1]));
- n -= 1;
- }
- }
-};
-
-/**
- * Parse a single symbol out of the string. Here, we handle both the functions
- * we have defined, as well as the single character symbols
- *
- * @return {?ParseFuncOrArgument}
- */
-Parser.prototype.parseSymbol = function() {
- var nucleus = this.nextToken;
-
- if (functions[nucleus.text]) {
- this.consume();
- // If there exists a function with this name, we return the function and
- // say that it is a function.
- return new ParseFuncOrArgument(
- nucleus.text,
- true, nucleus);
- } else if (symbols[this.mode][nucleus.text]) {
- this.consume();
- // Otherwise if this is a no-argument function, find the type it
- // corresponds to in the symbols map
- return new ParseFuncOrArgument(
- new ParseNode(symbols[this.mode][nucleus.text].group,
- nucleus.text, this.mode, nucleus),
- false, nucleus);
- } else if (this.mode === "text" && cjkRegex.test(nucleus.text)) {
- this.consume();
- return new ParseFuncOrArgument(
- new ParseNode("textord", nucleus.text, this.mode, nucleus),
- false, nucleus);
- } else {
- return null;
- }
-};
-
-Parser.prototype.ParseNode = ParseNode;
-
-module.exports = Parser;
-
-},{"./MacroExpander":4,"./ParseError":6,"./environments":16,"./functions":19,"./parseData":21,"./symbols":23,"./unicodeRegexes":24,"./utils":25}],8:[function(require,module,exports){
-/**
- * This is a module for storing settings passed into KaTeX. It correctly handles
- * default settings.
- */
-
-/**
- * Helper function for getting a default value if the value is undefined
- */
-function get(option, defaultValue) {
- return option === undefined ? defaultValue : option;
-}
-
-/**
- * The main Settings object
- *
- * The current options stored are:
- * - displayMode: Whether the expression should be typeset by default in
- * textstyle or displaystyle (default false)
- */
-function Settings(options) {
- // allow null options
- options = options || {};
- this.displayMode = get(options.displayMode, false);
- this.throwOnError = get(options.throwOnError, true);
- this.errorColor = get(options.errorColor, "#cc0000");
- this.macros = options.macros || {};
-}
-
-module.exports = Settings;
-
-},{}],9:[function(require,module,exports){
-/**
- * This file contains information and classes for the various kinds of styles
- * used in TeX. It provides a generic `Style` class, which holds information
- * about a specific style. It then provides instances of all the different kinds
- * of styles possible, and provides functions to move between them and get
- * information about them.
- */
-
-/**
- * The main style class. Contains a unique id for the style, a size (which is
- * the same for cramped and uncramped version of a style), a cramped flag, and a
- * size multiplier, which gives the size difference between a style and
- * textstyle.
- */
-function Style(id, size, multiplier, cramped) {
- this.id = id;
- this.size = size;
- this.cramped = cramped;
- this.sizeMultiplier = multiplier;
-}
-
-/**
- * Get the style of a superscript given a base in the current style.
- */
-Style.prototype.sup = function() {
- return styles[sup[this.id]];
-};
-
-/**
- * Get the style of a subscript given a base in the current style.
- */
-Style.prototype.sub = function() {
- return styles[sub[this.id]];
-};
-
-/**
- * Get the style of a fraction numerator given the fraction in the current
- * style.
- */
-Style.prototype.fracNum = function() {
- return styles[fracNum[this.id]];
-};
-
-/**
- * Get the style of a fraction denominator given the fraction in the current
- * style.
- */
-Style.prototype.fracDen = function() {
- return styles[fracDen[this.id]];
-};
-
-/**
- * Get the cramped version of a style (in particular, cramping a cramped style
- * doesn't change the style).
- */
-Style.prototype.cramp = function() {
- return styles[cramp[this.id]];
-};
-
-/**
- * HTML class name, like "displaystyle cramped"
- */
-Style.prototype.cls = function() {
- return sizeNames[this.size] + (this.cramped ? " cramped" : " uncramped");
-};
-
-/**
- * HTML Reset class name, like "reset-textstyle"
- */
-Style.prototype.reset = function() {
- return resetNames[this.size];
-};
-
-// IDs of the different styles
-var D = 0;
-var Dc = 1;
-var T = 2;
-var Tc = 3;
-var S = 4;
-var Sc = 5;
-var SS = 6;
-var SSc = 7;
-
-// String names for the different sizes
-var sizeNames = [
- "displaystyle textstyle",
- "textstyle",
- "scriptstyle",
- "scriptscriptstyle",
-];
-
-// Reset names for the different sizes
-var resetNames = [
- "reset-textstyle",
- "reset-textstyle",
- "reset-scriptstyle",
- "reset-scriptscriptstyle",
-];
-
-// Instances of the different styles
-var styles = [
- new Style(D, 0, 1.0, false),
- new Style(Dc, 0, 1.0, true),
- new Style(T, 1, 1.0, false),
- new Style(Tc, 1, 1.0, true),
- new Style(S, 2, 0.7, false),
- new Style(Sc, 2, 0.7, true),
- new Style(SS, 3, 0.5, false),
- new Style(SSc, 3, 0.5, true),
-];
-
-// Lookup tables for switching from one style to another
-var sup = [S, Sc, S, Sc, SS, SSc, SS, SSc];
-var sub = [Sc, Sc, Sc, Sc, SSc, SSc, SSc, SSc];
-var fracNum = [T, Tc, S, Sc, SS, SSc, SS, SSc];
-var fracDen = [Tc, Tc, Sc, Sc, SSc, SSc, SSc, SSc];
-var cramp = [Dc, Dc, Tc, Tc, Sc, Sc, SSc, SSc];
-
-// We only export some of the styles. Also, we don't export the `Style` class so
-// no more styles can be generated.
-module.exports = {
- DISPLAY: styles[D],
- TEXT: styles[T],
- SCRIPT: styles[S],
- SCRIPTSCRIPT: styles[SS],
-};
-
-},{}],10:[function(require,module,exports){
-/* eslint no-console:0 */
-/**
- * This module contains general functions that can be used for building
- * different kinds of domTree nodes in a consistent manner.
- */
-
-var domTree = require("./domTree");
-var fontMetrics = require("./fontMetrics");
-var symbols = require("./symbols");
-var utils = require("./utils");
-
-var greekCapitals = [
- "\\Gamma",
- "\\Delta",
- "\\Theta",
- "\\Lambda",
- "\\Xi",
- "\\Pi",
- "\\Sigma",
- "\\Upsilon",
- "\\Phi",
- "\\Psi",
- "\\Omega",
-];
-
-// The following have to be loaded from Main-Italic font, using class mainit
-var mainitLetters = [
- "\u0131", // dotless i, \imath
- "\u0237", // dotless j, \jmath
- "\u00a3", // \pounds
-];
-
-/**
- * Makes a symbolNode after translation via the list of symbols in symbols.js.
- * Correctly pulls out metrics for the character, and optionally takes a list of
- * classes to be attached to the node.
- */
-var makeSymbol = function(value, style, mode, color, classes) {
- // Replace the value with its replaced value from symbol.js
- if (symbols[mode][value] && symbols[mode][value].replace) {
- value = symbols[mode][value].replace;
- }
-
- var metrics = fontMetrics.getCharacterMetrics(value, style);
-
- var symbolNode;
- if (metrics) {
- symbolNode = new domTree.symbolNode(
- value, metrics.height, metrics.depth, metrics.italic, metrics.skew,
- classes);
- } else {
- // TODO(emily): Figure out a good way to only print this in development
- typeof console !== "undefined" && console.warn(
- "No character metrics for '" + value + "' in style '" +
- style + "'");
- symbolNode = new domTree.symbolNode(value, 0, 0, 0, 0, classes);
- }
-
- if (color) {
- symbolNode.style.color = color;
- }
-
- return symbolNode;
-};
-
-/**
- * Makes a symbol in Main-Regular or AMS-Regular.
- * Used for rel, bin, open, close, inner, and punct.
- */
-var mathsym = function(value, mode, color, classes) {
- // Decide what font to render the symbol in by its entry in the symbols
- // table.
- // Have a special case for when the value = \ because the \ is used as a
- // textord in unsupported command errors but cannot be parsed as a regular
- // text ordinal and is therefore not present as a symbol in the symbols
- // table for text
- if (value === "\\" || symbols[mode][value].font === "main") {
- return makeSymbol(value, "Main-Regular", mode, color, classes);
- } else {
- return makeSymbol(
- value, "AMS-Regular", mode, color, classes.concat(["amsrm"]));
- }
-};
-
-/**
- * Makes a symbol in the default font for mathords and textords.
- */
-var mathDefault = function(value, mode, color, classes, type) {
- if (type === "mathord") {
- return mathit(value, mode, color, classes);
- } else if (type === "textord") {
- return makeSymbol(
- value, "Main-Regular", mode, color, classes.concat(["mathrm"]));
- } else {
- throw new Error("unexpected type: " + type + " in mathDefault");
- }
-};
-
-/**
- * Makes a symbol in the italic math font.
- */
-var mathit = function(value, mode, color, classes) {
- if (/[0-9]/.test(value.charAt(0)) ||
- // glyphs for \imath and \jmath do not exist in Math-Italic so we
- // need to use Main-Italic instead
- utils.contains(mainitLetters, value) ||
- utils.contains(greekCapitals, value)) {
- return makeSymbol(
- value, "Main-Italic", mode, color, classes.concat(["mainit"]));
- } else {
- return makeSymbol(
- value, "Math-Italic", mode, color, classes.concat(["mathit"]));
- }
-};
-
-/**
- * Makes either a mathord or textord in the correct font and color.
- */
-var makeOrd = function(group, options, type) {
- var mode = group.mode;
- var value = group.value;
- if (symbols[mode][value] && symbols[mode][value].replace) {
- value = symbols[mode][value].replace;
- }
-
- var classes = ["mord"];
- var color = options.getColor();
-
- var font = options.font;
- if (font) {
- if (font === "mathit" || utils.contains(mainitLetters, value)) {
- return mathit(value, mode, color, classes);
- } else {
- var fontName = fontMap[font].fontName;
- if (fontMetrics.getCharacterMetrics(value, fontName)) {
- return makeSymbol(
- value, fontName, mode, color, classes.concat([font]));
- } else {
- return mathDefault(value, mode, color, classes, type);
- }
- }
- } else {
- return mathDefault(value, mode, color, classes, type);
- }
-};
-
-/**
- * Calculate the height, depth, and maxFontSize of an element based on its
- * children.
- */
-var sizeElementFromChildren = function(elem) {
- var height = 0;
- var depth = 0;
- var maxFontSize = 0;
-
- if (elem.children) {
- for (var i = 0; i < elem.children.length; i++) {
- if (elem.children[i].height > height) {
- height = elem.children[i].height;
- }
- if (elem.children[i].depth > depth) {
- depth = elem.children[i].depth;
- }
- if (elem.children[i].maxFontSize > maxFontSize) {
- maxFontSize = elem.children[i].maxFontSize;
- }
- }
- }
-
- elem.height = height;
- elem.depth = depth;
- elem.maxFontSize = maxFontSize;
-};
-
-/**
- * Makes a span with the given list of classes, list of children, and color.
- */
-var makeSpan = function(classes, children, color) {
- var span = new domTree.span(classes, children);
-
- sizeElementFromChildren(span);
-
- if (color) {
- span.style.color = color;
- }
-
- return span;
-};
-
-/**
- * Makes a document fragment with the given list of children.
- */
-var makeFragment = function(children) {
- var fragment = new domTree.documentFragment(children);
-
- sizeElementFromChildren(fragment);
-
- return fragment;
-};
-
-/**
- * Makes an element placed in each of the vlist elements to ensure that each
- * element has the same max font size. To do this, we create a zero-width space
- * with the correct font size.
- */
-var makeFontSizer = function(options, fontSize) {
- var fontSizeInner = makeSpan([], [new domTree.symbolNode("\u200b")]);
- fontSizeInner.style.fontSize =
- (fontSize / options.style.sizeMultiplier) + "em";
-
- var fontSizer = makeSpan(
- ["fontsize-ensurer", "reset-" + options.size, "size5"],
- [fontSizeInner]);
-
- return fontSizer;
-};
-
-/**
- * Makes a vertical list by stacking elements and kerns on top of each other.
- * Allows for many different ways of specifying the positioning method.
- *
- * Arguments:
- * - children: A list of child or kern nodes to be stacked on top of each other
- * (i.e. the first element will be at the bottom, and the last at
- * the top). Element nodes are specified as
- * {type: "elem", elem: node}
- * while kern nodes are specified as
- * {type: "kern", size: size}
- * - positionType: The method by which the vlist should be positioned. Valid
- * values are:
- * - "individualShift": The children list only contains elem
- * nodes, and each node contains an extra
- * "shift" value of how much it should be
- * shifted (note that shifting is always
- * moving downwards). positionData is
- * ignored.
- * - "top": The positionData specifies the topmost point of
- * the vlist (note this is expected to be a height,
- * so positive values move up)
- * - "bottom": The positionData specifies the bottommost point
- * of the vlist (note this is expected to be a
- * depth, so positive values move down
- * - "shift": The vlist will be positioned such that its
- * baseline is positionData away from the baseline
- * of the first child. Positive values move
- * downwards.
- * - "firstBaseline": The vlist will be positioned such that
- * its baseline is aligned with the
- * baseline of the first child.
- * positionData is ignored. (this is
- * equivalent to "shift" with
- * positionData=0)
- * - positionData: Data used in different ways depending on positionType
- * - options: An Options object
- *
- */
-var makeVList = function(children, positionType, positionData, options) {
- var depth;
- var currPos;
- var i;
- if (positionType === "individualShift") {
- var oldChildren = children;
- children = [oldChildren[0]];
-
- // Add in kerns to the list of children to get each element to be
- // shifted to the correct specified shift
- depth = -oldChildren[0].shift - oldChildren[0].elem.depth;
- currPos = depth;
- for (i = 1; i < oldChildren.length; i++) {
- var diff = -oldChildren[i].shift - currPos -
- oldChildren[i].elem.depth;
- var size = diff -
- (oldChildren[i - 1].elem.height +
- oldChildren[i - 1].elem.depth);
-
- currPos = currPos + diff;
-
- children.push({type: "kern", size: size});
- children.push(oldChildren[i]);
- }
- } else if (positionType === "top") {
- // We always start at the bottom, so calculate the bottom by adding up
- // all the sizes
- var bottom = positionData;
- for (i = 0; i < children.length; i++) {
- if (children[i].type === "kern") {
- bottom -= children[i].size;
- } else {
- bottom -= children[i].elem.height + children[i].elem.depth;
- }
- }
- depth = bottom;
- } else if (positionType === "bottom") {
- depth = -positionData;
- } else if (positionType === "shift") {
- depth = -children[0].elem.depth - positionData;
- } else if (positionType === "firstBaseline") {
- depth = -children[0].elem.depth;
- } else {
- depth = 0;
- }
-
- // Make the fontSizer
- var maxFontSize = 0;
- for (i = 0; i < children.length; i++) {
- if (children[i].type === "elem") {
- maxFontSize = Math.max(maxFontSize, children[i].elem.maxFontSize);
- }
- }
- var fontSizer = makeFontSizer(options, maxFontSize);
-
- // Create a new list of actual children at the correct offsets
- var realChildren = [];
- currPos = depth;
- for (i = 0; i < children.length; i++) {
- if (children[i].type === "kern") {
- currPos += children[i].size;
- } else {
- var child = children[i].elem;
-
- var shift = -child.depth - currPos;
- currPos += child.height + child.depth;
-
- var childWrap = makeSpan([], [fontSizer, child]);
- childWrap.height -= shift;
- childWrap.depth += shift;
- childWrap.style.top = shift + "em";
-
- realChildren.push(childWrap);
- }
- }
-
- // Add in an element at the end with no offset to fix the calculation of
- // baselines in some browsers (namely IE, sometimes safari)
- var baselineFix = makeSpan(
- ["baseline-fix"], [fontSizer, new domTree.symbolNode("\u200b")]);
- realChildren.push(baselineFix);
-
- var vlist = makeSpan(["vlist"], realChildren);
- // Fix the final height and depth, in case there were kerns at the ends
- // since the makeSpan calculation won't take that in to account.
- vlist.height = Math.max(currPos, vlist.height);
- vlist.depth = Math.max(-depth, vlist.depth);
- return vlist;
-};
-
-// A table of size -> font size for the different sizing functions
-var sizingMultiplier = {
- size1: 0.5,
- size2: 0.7,
- size3: 0.8,
- size4: 0.9,
- size5: 1.0,
- size6: 1.2,
- size7: 1.44,
- size8: 1.73,
- size9: 2.07,
- size10: 2.49,
-};
-
-// A map of spacing functions to their attributes, like size and corresponding
-// CSS class
-var spacingFunctions = {
- "\\qquad": {
- size: "2em",
- className: "qquad",
- },
- "\\quad": {
- size: "1em",
- className: "quad",
- },
- "\\enspace": {
- size: "0.5em",
- className: "enspace",
- },
- "\\;": {
- size: "0.277778em",
- className: "thickspace",
- },
- "\\:": {
- size: "0.22222em",
- className: "mediumspace",
- },
- "\\,": {
- size: "0.16667em",
- className: "thinspace",
- },
- "\\!": {
- size: "-0.16667em",
- className: "negativethinspace",
- },
-};
-
-/**
- * Maps TeX font commands to objects containing:
- * - variant: string used for "mathvariant" attribute in buildMathML.js
- * - fontName: the "style" parameter to fontMetrics.getCharacterMetrics
- */
-// A map between tex font commands an MathML mathvariant attribute values
-var fontMap = {
- // styles
- "mathbf": {
- variant: "bold",
- fontName: "Main-Bold",
- },
- "mathrm": {
- variant: "normal",
- fontName: "Main-Regular",
- },
-
- // "mathit" is missing because it requires the use of two fonts: Main-Italic
- // and Math-Italic. This is handled by a special case in makeOrd which ends
- // up calling mathit.
-
- // families
- "mathbb": {
- variant: "double-struck",
- fontName: "AMS-Regular",
- },
- "mathcal": {
- variant: "script",
- fontName: "Caligraphic-Regular",
- },
- "mathfrak": {
- variant: "fraktur",
- fontName: "Fraktur-Regular",
- },
- "mathscr": {
- variant: "script",
- fontName: "Script-Regular",
- },
- "mathsf": {
- variant: "sans-serif",
- fontName: "SansSerif-Regular",
- },
- "mathtt": {
- variant: "monospace",
- fontName: "Typewriter-Regular",
- },
-};
-
-module.exports = {
- fontMap: fontMap,
- makeSymbol: makeSymbol,
- mathsym: mathsym,
- makeSpan: makeSpan,
- makeFragment: makeFragment,
- makeVList: makeVList,
- makeOrd: makeOrd,
- sizingMultiplier: sizingMultiplier,
- spacingFunctions: spacingFunctions,
-};
-
-},{"./domTree":15,"./fontMetrics":17,"./symbols":23,"./utils":25}],11:[function(require,module,exports){
-/* eslint no-console:0 */
-/**
- * This file does the main work of building a domTree structure from a parse
- * tree. The entry point is the `buildHTML` function, which takes a parse tree.
- * Then, the buildExpression, buildGroup, and various groupTypes functions are
- * called, to produce a final HTML tree.
- */
-
-var ParseError = require("./ParseError");
-var Style = require("./Style");
-
-var buildCommon = require("./buildCommon");
-var delimiter = require("./delimiter");
-var domTree = require("./domTree");
-var fontMetrics = require("./fontMetrics");
-var utils = require("./utils");
-
-var makeSpan = buildCommon.makeSpan;
-
-/**
- * Take a list of nodes, build them in order, and return a list of the built
- * nodes. This function handles the `prev` node correctly, and passes the
- * previous element from the list as the prev of the next element.
- */
-var buildExpression = function(expression, options, prev) {
- var groups = [];
- for (var i = 0; i < expression.length; i++) {
- var group = expression[i];
- groups.push(buildGroup(group, options, prev));
- prev = group;
- }
- return groups;
-};
-
-// List of types used by getTypeOfGroup,
-// see https://github.com/Khan/KaTeX/wiki/Examining-TeX#group-types
-var groupToType = {
- mathord: "mord",
- textord: "mord",
- bin: "mbin",
- rel: "mrel",
- text: "mord",
- open: "mopen",
- close: "mclose",
- inner: "minner",
- genfrac: "mord",
- array: "mord",
- spacing: "mord",
- punct: "mpunct",
- ordgroup: "mord",
- op: "mop",
- katex: "mord",
- overline: "mord",
- underline: "mord",
- rule: "mord",
- leftright: "minner",
- sqrt: "mord",
- accent: "mord",
-};
-
-/**
- * Gets the final math type of an expression, given its group type. This type is
- * used to determine spacing between elements, and affects bin elements by
- * causing them to change depending on what types are around them. This type
- * must be attached to the outermost node of an element as a CSS class so that
- * spacing with its surrounding elements works correctly.
- *
- * Some elements can be mapped one-to-one from group type to math type, and
- * those are listed in the `groupToType` table.
- *
- * Others (usually elements that wrap around other elements) often have
- * recursive definitions, and thus call `getTypeOfGroup` on their inner
- * elements.
- */
-var getTypeOfGroup = function(group) {
- if (group == null) {
- // Like when typesetting $^3$
- return groupToType.mathord;
- } else if (group.type === "supsub") {
- return getTypeOfGroup(group.value.base);
- } else if (group.type === "llap" || group.type === "rlap") {
- return getTypeOfGroup(group.value);
- } else if (group.type === "color") {
- return getTypeOfGroup(group.value.value);
- } else if (group.type === "sizing") {
- return getTypeOfGroup(group.value.value);
- } else if (group.type === "styling") {
- return getTypeOfGroup(group.value.value);
- } else if (group.type === "delimsizing") {
- return groupToType[group.value.delimType];
- } else {
- return groupToType[group.type];
- }
-};
-
-/**
- * Sometimes, groups perform special rules when they have superscripts or
- * subscripts attached to them. This function lets the `supsub` group know that
- * its inner element should handle the superscripts and subscripts instead of
- * handling them itself.
- */
-var shouldHandleSupSub = function(group, options) {
- if (!group) {
- return false;
- } else if (group.type === "op") {
- // Operators handle supsubs differently when they have limits
- // (e.g. `\displaystyle\sum_2^3`)
- return group.value.limits &&
- (options.style.size === Style.DISPLAY.size ||
- group.value.alwaysHandleSupSub);
- } else if (group.type === "accent") {
- return isCharacterBox(group.value.base);
- } else {
- return null;
- }
-};
-
-/**
- * Sometimes we want to pull out the innermost element of a group. In most
- * cases, this will just be the group itself, but when ordgroups and colors have
- * a single element, we want to pull that out.
- */
-var getBaseElem = function(group) {
- if (!group) {
- return false;
- } else if (group.type === "ordgroup") {
- if (group.value.length === 1) {
- return getBaseElem(group.value[0]);
- } else {
- return group;
- }
- } else if (group.type === "color") {
- if (group.value.value.length === 1) {
- return getBaseElem(group.value.value[0]);
- } else {
- return group;
- }
- } else if (group.type === "font") {
- return getBaseElem(group.value.body);
- } else {
- return group;
- }
-};
-
-/**
- * TeXbook algorithms often reference "character boxes", which are simply groups
- * with a single character in them. To decide if something is a character box,
- * we find its innermost group, and see if it is a single character.
- */
-var isCharacterBox = function(group) {
- var baseElem = getBaseElem(group);
-
- // These are all they types of groups which hold single characters
- return baseElem.type === "mathord" ||
- baseElem.type === "textord" ||
- baseElem.type === "bin" ||
- baseElem.type === "rel" ||
- baseElem.type === "inner" ||
- baseElem.type === "open" ||
- baseElem.type === "close" ||
- baseElem.type === "punct";
-};
-
-var makeNullDelimiter = function(options) {
- return makeSpan([
- "sizing", "reset-" + options.size, "size5",
- options.style.reset(), Style.TEXT.cls(),
- "nulldelimiter",
- ]);
-};
-
-/**
- * This is a map of group types to the function used to handle that type.
- * Simpler types come at the beginning, while complicated types come afterwards.
- */
-var groupTypes = {};
-
-groupTypes.mathord = function(group, options, prev) {
- return buildCommon.makeOrd(group, options, "mathord");
-};
-
-groupTypes.textord = function(group, options, prev) {
- return buildCommon.makeOrd(group, options, "textord");
-};
-
-groupTypes.bin = function(group, options, prev) {
- var className = "mbin";
- // Pull out the most recent element. Do some special handling to find
- // things at the end of a \color group. Note that we don't use the same
- // logic for ordgroups (which count as ords).
- var prevAtom = prev;
- while (prevAtom && prevAtom.type === "color") {
- var atoms = prevAtom.value.value;
- prevAtom = atoms[atoms.length - 1];
- }
- // See TeXbook pg. 442-446, Rules 5 and 6, and the text before Rule 19.
- // Here, we determine whether the bin should turn into an ord. We
- // currently only apply Rule 5.
- if (!prev || utils.contains(["mbin", "mopen", "mrel", "mop", "mpunct"],
- getTypeOfGroup(prevAtom))) {
- group.type = "textord";
- className = "mord";
- }
-
- return buildCommon.mathsym(
- group.value, group.mode, options.getColor(), [className]);
-};
-
-groupTypes.rel = function(group, options, prev) {
- return buildCommon.mathsym(
- group.value, group.mode, options.getColor(), ["mrel"]);
-};
-
-groupTypes.open = function(group, options, prev) {
- return buildCommon.mathsym(
- group.value, group.mode, options.getColor(), ["mopen"]);
-};
-
-groupTypes.close = function(group, options, prev) {
- return buildCommon.mathsym(
- group.value, group.mode, options.getColor(), ["mclose"]);
-};
-
-groupTypes.inner = function(group, options, prev) {
- return buildCommon.mathsym(
- group.value, group.mode, options.getColor(), ["minner"]);
-};
-
-groupTypes.punct = function(group, options, prev) {
- return buildCommon.mathsym(
- group.value, group.mode, options.getColor(), ["mpunct"]);
-};
-
-groupTypes.ordgroup = function(group, options, prev) {
- return makeSpan(
- ["mord", options.style.cls()],
- buildExpression(group.value, options.reset())
- );
-};
-
-groupTypes.text = function(group, options, prev) {
- return makeSpan(["text", "mord", options.style.cls()],
- buildExpression(group.value.body, options.reset()));
-};
-
-groupTypes.color = function(group, options, prev) {
- var elements = buildExpression(
- group.value.value,
- options.withColor(group.value.color),
- prev
- );
-
- // \color isn't supposed to affect the type of the elements it contains.
- // To accomplish this, we wrap the results in a fragment, so the inner
- // elements will be able to directly interact with their neighbors. For
- // example, `\color{red}{2 +} 3` has the same spacing as `2 + 3`
- return new buildCommon.makeFragment(elements);
-};
-
-groupTypes.supsub = function(group, options, prev) {
- // Superscript and subscripts are handled in the TeXbook on page
- // 445-446, rules 18(a-f).
-
- // Here is where we defer to the inner group if it should handle
- // superscripts and subscripts itself.
- if (shouldHandleSupSub(group.value.base, options)) {
- return groupTypes[group.value.base.type](group, options, prev);
- }
-
- var base = buildGroup(group.value.base, options.reset());
- var supmid;
- var submid;
- var sup;
- var sub;
-
- if (group.value.sup) {
- sup = buildGroup(group.value.sup,
- options.withStyle(options.style.sup()));
- supmid = makeSpan(
- [options.style.reset(), options.style.sup().cls()], [sup]);
- }
-
- if (group.value.sub) {
- sub = buildGroup(group.value.sub,
- options.withStyle(options.style.sub()));
- submid = makeSpan(
- [options.style.reset(), options.style.sub().cls()], [sub]);
- }
-
- // Rule 18a
- var supShift;
- var subShift;
- if (isCharacterBox(group.value.base)) {
- supShift = 0;
- subShift = 0;
- } else {
- supShift = base.height - fontMetrics.metrics.supDrop;
- subShift = base.depth + fontMetrics.metrics.subDrop;
- }
-
- // Rule 18c
- var minSupShift;
- if (options.style === Style.DISPLAY) {
- minSupShift = fontMetrics.metrics.sup1;
- } else if (options.style.cramped) {
- minSupShift = fontMetrics.metrics.sup3;
- } else {
- minSupShift = fontMetrics.metrics.sup2;
- }
-
- // scriptspace is a font-size-independent size, so scale it
- // appropriately
- var multiplier = Style.TEXT.sizeMultiplier *
- options.style.sizeMultiplier;
- var scriptspace =
- (0.5 / fontMetrics.metrics.ptPerEm) / multiplier + "em";
-
- var supsub;
- if (!group.value.sup) {
- // Rule 18b
- subShift = Math.max(
- subShift, fontMetrics.metrics.sub1,
- sub.height - 0.8 * fontMetrics.metrics.xHeight);
-
- supsub = buildCommon.makeVList([
- {type: "elem", elem: submid},
- ], "shift", subShift, options);
-
- supsub.children[0].style.marginRight = scriptspace;
-
- // Subscripts shouldn't be shifted by the base's italic correction.
- // Account for that by shifting the subscript back the appropriate
- // amount. Note we only do this when the base is a single symbol.
- if (base instanceof domTree.symbolNode) {
- supsub.children[0].style.marginLeft = -base.italic + "em";
- }
- } else if (!group.value.sub) {
- // Rule 18c, d
- supShift = Math.max(supShift, minSupShift,
- sup.depth + 0.25 * fontMetrics.metrics.xHeight);
-
- supsub = buildCommon.makeVList([
- {type: "elem", elem: supmid},
- ], "shift", -supShift, options);
-
- supsub.children[0].style.marginRight = scriptspace;
- } else {
- supShift = Math.max(
- supShift, minSupShift,
- sup.depth + 0.25 * fontMetrics.metrics.xHeight);
- subShift = Math.max(subShift, fontMetrics.metrics.sub2);
-
- var ruleWidth = fontMetrics.metrics.defaultRuleThickness;
-
- // Rule 18e
- if ((supShift - sup.depth) - (sub.height - subShift) <
- 4 * ruleWidth) {
- subShift = 4 * ruleWidth - (supShift - sup.depth) + sub.height;
- var psi = 0.8 * fontMetrics.metrics.xHeight -
- (supShift - sup.depth);
- if (psi > 0) {
- supShift += psi;
- subShift -= psi;
- }
- }
-
- supsub = buildCommon.makeVList([
- {type: "elem", elem: submid, shift: subShift},
- {type: "elem", elem: supmid, shift: -supShift},
- ], "individualShift", null, options);
-
- // See comment above about subscripts not being shifted
- if (base instanceof domTree.symbolNode) {
- supsub.children[0].style.marginLeft = -base.italic + "em";
- }
-
- supsub.children[0].style.marginRight = scriptspace;
- supsub.children[1].style.marginRight = scriptspace;
- }
-
- // We ensure to wrap the supsub vlist in a span.msupsub to reset text-align
- return makeSpan([getTypeOfGroup(group.value.base)],
- [base, makeSpan(["msupsub"], [supsub])]);
-};
-
-groupTypes.genfrac = function(group, options, prev) {
- // Fractions are handled in the TeXbook on pages 444-445, rules 15(a-e).
- // Figure out what style this fraction should be in based on the
- // function used
- var fstyle = options.style;
- if (group.value.size === "display") {
- fstyle = Style.DISPLAY;
- } else if (group.value.size === "text") {
- fstyle = Style.TEXT;
- }
-
- var nstyle = fstyle.fracNum();
- var dstyle = fstyle.fracDen();
-
- var numer = buildGroup(group.value.numer, options.withStyle(nstyle));
- var numerreset = makeSpan([fstyle.reset(), nstyle.cls()], [numer]);
-
- var denom = buildGroup(group.value.denom, options.withStyle(dstyle));
- var denomreset = makeSpan([fstyle.reset(), dstyle.cls()], [denom]);
-
- var ruleWidth;
- if (group.value.hasBarLine) {
- ruleWidth = fontMetrics.metrics.defaultRuleThickness /
- options.style.sizeMultiplier;
- } else {
- ruleWidth = 0;
- }
-
- // Rule 15b
- var numShift;
- var clearance;
- var denomShift;
- if (fstyle.size === Style.DISPLAY.size) {
- numShift = fontMetrics.metrics.num1;
- if (ruleWidth > 0) {
- clearance = 3 * ruleWidth;
- } else {
- clearance = 7 * fontMetrics.metrics.defaultRuleThickness;
- }
- denomShift = fontMetrics.metrics.denom1;
- } else {
- if (ruleWidth > 0) {
- numShift = fontMetrics.metrics.num2;
- clearance = ruleWidth;
- } else {
- numShift = fontMetrics.metrics.num3;
- clearance = 3 * fontMetrics.metrics.defaultRuleThickness;
- }
- denomShift = fontMetrics.metrics.denom2;
- }
-
- var frac;
- if (ruleWidth === 0) {
- // Rule 15c
- var candiateClearance =
- (numShift - numer.depth) - (denom.height - denomShift);
- if (candiateClearance < clearance) {
- numShift += 0.5 * (clearance - candiateClearance);
- denomShift += 0.5 * (clearance - candiateClearance);
- }
-
- frac = buildCommon.makeVList([
- {type: "elem", elem: denomreset, shift: denomShift},
- {type: "elem", elem: numerreset, shift: -numShift},
- ], "individualShift", null, options);
- } else {
- // Rule 15d
- var axisHeight = fontMetrics.metrics.axisHeight;
-
- if ((numShift - numer.depth) - (axisHeight + 0.5 * ruleWidth) <
- clearance) {
- numShift +=
- clearance - ((numShift - numer.depth) -
- (axisHeight + 0.5 * ruleWidth));
- }
-
- if ((axisHeight - 0.5 * ruleWidth) - (denom.height - denomShift) <
- clearance) {
- denomShift +=
- clearance - ((axisHeight - 0.5 * ruleWidth) -
- (denom.height - denomShift));
- }
-
- var mid = makeSpan(
- [options.style.reset(), Style.TEXT.cls(), "frac-line"]);
- // Manually set the height of the line because its height is
- // created in CSS
- mid.height = ruleWidth;
-
- var midShift = -(axisHeight - 0.5 * ruleWidth);
-
- frac = buildCommon.makeVList([
- {type: "elem", elem: denomreset, shift: denomShift},
- {type: "elem", elem: mid, shift: midShift},
- {type: "elem", elem: numerreset, shift: -numShift},
- ], "individualShift", null, options);
- }
-
- // Since we manually change the style sometimes (with \dfrac or \tfrac),
- // account for the possible size change here.
- frac.height *= fstyle.sizeMultiplier / options.style.sizeMultiplier;
- frac.depth *= fstyle.sizeMultiplier / options.style.sizeMultiplier;
-
- // Rule 15e
- var delimSize;
- if (fstyle.size === Style.DISPLAY.size) {
- delimSize = fontMetrics.metrics.delim1;
- } else {
- delimSize = fontMetrics.metrics.getDelim2(fstyle);
- }
-
- var leftDelim;
- var rightDelim;
- if (group.value.leftDelim == null) {
- leftDelim = makeNullDelimiter(options);
- } else {
- leftDelim = delimiter.customSizedDelim(
- group.value.leftDelim, delimSize, true,
- options.withStyle(fstyle), group.mode);
- }
- if (group.value.rightDelim == null) {
- rightDelim = makeNullDelimiter(options);
- } else {
- rightDelim = delimiter.customSizedDelim(
- group.value.rightDelim, delimSize, true,
- options.withStyle(fstyle), group.mode);
- }
-
- return makeSpan(
- ["mord", options.style.reset(), fstyle.cls()],
- [leftDelim, makeSpan(["mfrac"], [frac]), rightDelim],
- options.getColor());
-};
-
-groupTypes.array = function(group, options, prev) {
- var r;
- var c;
- var nr = group.value.body.length;
- var nc = 0;
- var body = new Array(nr);
-
- // Horizontal spacing
- var pt = 1 / fontMetrics.metrics.ptPerEm;
- var arraycolsep = 5 * pt; // \arraycolsep in article.cls
-
- // Vertical spacing
- var baselineskip = 12 * pt; // see size10.clo
- // Default \arraystretch from lttab.dtx
- // TODO(gagern): may get redefined once we have user-defined macros
- var arraystretch = utils.deflt(group.value.arraystretch, 1);
- var arrayskip = arraystretch * baselineskip;
- var arstrutHeight = 0.7 * arrayskip; // \strutbox in ltfsstrc.dtx and
- var arstrutDepth = 0.3 * arrayskip; // \@arstrutbox in lttab.dtx
-
- var totalHeight = 0;
- for (r = 0; r < group.value.body.length; ++r) {
- var inrow = group.value.body[r];
- var height = arstrutHeight; // \@array adds an \@arstrut
- var depth = arstrutDepth; // to each tow (via the template)
-
- if (nc < inrow.length) {
- nc = inrow.length;
- }
-
- var outrow = new Array(inrow.length);
- for (c = 0; c < inrow.length; ++c) {
- var elt = buildGroup(inrow[c], options);
- if (depth < elt.depth) {
- depth = elt.depth;
- }
- if (height < elt.height) {
- height = elt.height;
- }
- outrow[c] = elt;
- }
-
- var gap = 0;
- if (group.value.rowGaps[r]) {
- gap = group.value.rowGaps[r].value;
- switch (gap.unit) {
- case "em":
- gap = gap.number;
- break;
- case "ex":
- gap = gap.number * fontMetrics.metrics.emPerEx;
- break;
- default:
- console.error("Can't handle unit " + gap.unit);
- gap = 0;
- }
- if (gap > 0) { // \@argarraycr
- gap += arstrutDepth;
- if (depth < gap) {
- depth = gap; // \@xargarraycr
- }
- gap = 0;
- }
- }
-
- outrow.height = height;
- outrow.depth = depth;
- totalHeight += height;
- outrow.pos = totalHeight;
- totalHeight += depth + gap; // \@yargarraycr
- body[r] = outrow;
- }
-
- var offset = totalHeight / 2 + fontMetrics.metrics.axisHeight;
- var colDescriptions = group.value.cols || [];
- var cols = [];
- var colSep;
- var colDescrNum;
- for (c = 0, colDescrNum = 0;
- // Continue while either there are more columns or more column
- // descriptions, so trailing separators don't get lost.
- c < nc || colDescrNum < colDescriptions.length;
- ++c, ++colDescrNum) {
-
- var colDescr = colDescriptions[colDescrNum] || {};
-
- var firstSeparator = true;
- while (colDescr.type === "separator") {
- // If there is more than one separator in a row, add a space
- // between them.
- if (!firstSeparator) {
- colSep = makeSpan(["arraycolsep"], []);
- colSep.style.width =
- fontMetrics.metrics.doubleRuleSep + "em";
- cols.push(colSep);
- }
-
- if (colDescr.separator === "|") {
- var separator = makeSpan(
- ["vertical-separator"],
- []);
- separator.style.height = totalHeight + "em";
- separator.style.verticalAlign =
- -(totalHeight - offset) + "em";
-
- cols.push(separator);
- } else {
- throw new ParseError(
- "Invalid separator type: " + colDescr.separator);
- }
-
- colDescrNum++;
- colDescr = colDescriptions[colDescrNum] || {};
- firstSeparator = false;
- }
-
- if (c >= nc) {
- continue;
- }
-
- var sepwidth;
- if (c > 0 || group.value.hskipBeforeAndAfter) {
- sepwidth = utils.deflt(colDescr.pregap, arraycolsep);
- if (sepwidth !== 0) {
- colSep = makeSpan(["arraycolsep"], []);
- colSep.style.width = sepwidth + "em";
- cols.push(colSep);
- }
- }
-
- var col = [];
- for (r = 0; r < nr; ++r) {
- var row = body[r];
- var elem = row[c];
- if (!elem) {
- continue;
- }
- var shift = row.pos - offset;
- elem.depth = row.depth;
- elem.height = row.height;
- col.push({type: "elem", elem: elem, shift: shift});
- }
-
- col = buildCommon.makeVList(col, "individualShift", null, options);
- col = makeSpan(
- ["col-align-" + (colDescr.align || "c")],
- [col]);
- cols.push(col);
-
- if (c < nc - 1 || group.value.hskipBeforeAndAfter) {
- sepwidth = utils.deflt(colDescr.postgap, arraycolsep);
- if (sepwidth !== 0) {
- colSep = makeSpan(["arraycolsep"], []);
- colSep.style.width = sepwidth + "em";
- cols.push(colSep);
- }
- }
- }
- body = makeSpan(["mtable"], cols);
- return makeSpan(["mord"], [body], options.getColor());
-};
-
-groupTypes.spacing = function(group, options, prev) {
- if (group.value === "\\ " || group.value === "\\space" ||
- group.value === " " || group.value === "~") {
- // Spaces are generated by adding an actual space. Each of these
- // things has an entry in the symbols table, so these will be turned
- // into appropriate outputs.
- return makeSpan(
- ["mord", "mspace"],
- [buildCommon.mathsym(group.value, group.mode)]
- );
- } else {
- // Other kinds of spaces are of arbitrary width. We use CSS to
- // generate these.
- return makeSpan(
- ["mord", "mspace",
- buildCommon.spacingFunctions[group.value].className]);
- }
-};
-
-groupTypes.llap = function(group, options, prev) {
- var inner = makeSpan(
- ["inner"], [buildGroup(group.value.body, options.reset())]);
- var fix = makeSpan(["fix"], []);
- return makeSpan(
- ["llap", options.style.cls()], [inner, fix]);
-};
-
-groupTypes.rlap = function(group, options, prev) {
- var inner = makeSpan(
- ["inner"], [buildGroup(group.value.body, options.reset())]);
- var fix = makeSpan(["fix"], []);
- return makeSpan(
- ["rlap", options.style.cls()], [inner, fix]);
-};
-
-groupTypes.op = function(group, options, prev) {
- // Operators are handled in the TeXbook pg. 443-444, rule 13(a).
- var supGroup;
- var subGroup;
- var hasLimits = false;
- if (group.type === "supsub" ) {
- // If we have limits, supsub will pass us its group to handle. Pull
- // out the superscript and subscript and set the group to the op in
- // its base.
- supGroup = group.value.sup;
- subGroup = group.value.sub;
- group = group.value.base;
- hasLimits = true;
- }
-
- // Most operators have a large successor symbol, but these don't.
- var noSuccessor = [
- "\\smallint",
- ];
-
- var large = false;
- if (options.style.size === Style.DISPLAY.size &&
- group.value.symbol &&
- !utils.contains(noSuccessor, group.value.body)) {
-
- // Most symbol operators get larger in displaystyle (rule 13)
- large = true;
- }
-
- var base;
- var baseShift = 0;
- var slant = 0;
- if (group.value.symbol) {
- // If this is a symbol, create the symbol.
- var style = large ? "Size2-Regular" : "Size1-Regular";
- base = buildCommon.makeSymbol(
- group.value.body, style, "math", options.getColor(),
- ["op-symbol", large ? "large-op" : "small-op", "mop"]);
-
- // Shift the symbol so its center lies on the axis (rule 13). It
- // appears that our fonts have the centers of the symbols already
- // almost on the axis, so these numbers are very small. Note we
- // don't actually apply this here, but instead it is used either in
- // the vlist creation or separately when there are no limits.
- baseShift = (base.height - base.depth) / 2 -
- fontMetrics.metrics.axisHeight *
- options.style.sizeMultiplier;
-
- // The slant of the symbol is just its italic correction.
- slant = base.italic;
- } else {
- // Otherwise, this is a text operator. Build the text from the
- // operator's name.
- // TODO(emily): Add a space in the middle of some of these
- // operators, like \limsup
- var output = [];
- for (var i = 1; i < group.value.body.length; i++) {
- output.push(buildCommon.mathsym(group.value.body[i], group.mode));
- }
- base = makeSpan(["mop"], output, options.getColor());
- }
-
- if (hasLimits) {
- // IE 8 clips \int if it is in a display: inline-block. We wrap it
- // in a new span so it is an inline, and works.
- base = makeSpan([], [base]);
-
- var supmid;
- var supKern;
- var submid;
- var subKern;
- // We manually have to handle the superscripts and subscripts. This,
- // aside from the kern calculations, is copied from supsub.
- if (supGroup) {
- var sup = buildGroup(
- supGroup, options.withStyle(options.style.sup()));
- supmid = makeSpan(
- [options.style.reset(), options.style.sup().cls()], [sup]);
-
- supKern = Math.max(
- fontMetrics.metrics.bigOpSpacing1,
- fontMetrics.metrics.bigOpSpacing3 - sup.depth);
- }
-
- if (subGroup) {
- var sub = buildGroup(
- subGroup, options.withStyle(options.style.sub()));
- submid = makeSpan(
- [options.style.reset(), options.style.sub().cls()],
- [sub]);
-
- subKern = Math.max(
- fontMetrics.metrics.bigOpSpacing2,
- fontMetrics.metrics.bigOpSpacing4 - sub.height);
- }
-
- // Build the final group as a vlist of the possible subscript, base,
- // and possible superscript.
- var finalGroup;
- var top;
- var bottom;
- if (!supGroup) {
- top = base.height - baseShift;
-
- finalGroup = buildCommon.makeVList([
- {type: "kern", size: fontMetrics.metrics.bigOpSpacing5},
- {type: "elem", elem: submid},
- {type: "kern", size: subKern},
- {type: "elem", elem: base},
- ], "top", top, options);
-
- // Here, we shift the limits by the slant of the symbol. Note
- // that we are supposed to shift the limits by 1/2 of the slant,
- // but since we are centering the limits adding a full slant of
- // margin will shift by 1/2 that.
- finalGroup.children[0].style.marginLeft = -slant + "em";
- } else if (!subGroup) {
- bottom = base.depth + baseShift;
-
- finalGroup = buildCommon.makeVList([
- {type: "elem", elem: base},
- {type: "kern", size: supKern},
- {type: "elem", elem: supmid},
- {type: "kern", size: fontMetrics.metrics.bigOpSpacing5},
- ], "bottom", bottom, options);
-
- // See comment above about slants
- finalGroup.children[1].style.marginLeft = slant + "em";
- } else if (!supGroup && !subGroup) {
- // This case probably shouldn't occur (this would mean the
- // supsub was sending us a group with no superscript or
- // subscript) but be safe.
- return base;
- } else {
- bottom = fontMetrics.metrics.bigOpSpacing5 +
- submid.height + submid.depth +
- subKern +
- base.depth + baseShift;
-
- finalGroup = buildCommon.makeVList([
- {type: "kern", size: fontMetrics.metrics.bigOpSpacing5},
- {type: "elem", elem: submid},
- {type: "kern", size: subKern},
- {type: "elem", elem: base},
- {type: "kern", size: supKern},
- {type: "elem", elem: supmid},
- {type: "kern", size: fontMetrics.metrics.bigOpSpacing5},
- ], "bottom", bottom, options);
-
- // See comment above about slants
- finalGroup.children[0].style.marginLeft = -slant + "em";
- finalGroup.children[2].style.marginLeft = slant + "em";
- }
-
- return makeSpan(["mop", "op-limits"], [finalGroup]);
- } else {
- if (group.value.symbol) {
- base.style.top = baseShift + "em";
- }
-
- return base;
- }
-};
-
-groupTypes.katex = function(group, options, prev) {
- // The KaTeX logo. The offsets for the K and a were chosen to look
- // good, but the offsets for the T, E, and X were taken from the
- // definition of \TeX in TeX (see TeXbook pg. 356)
- var k = makeSpan(
- ["k"], [buildCommon.mathsym("K", group.mode)]);
- var a = makeSpan(
- ["a"], [buildCommon.mathsym("A", group.mode)]);
-
- a.height = (a.height + 0.2) * 0.75;
- a.depth = (a.height - 0.2) * 0.75;
-
- var t = makeSpan(
- ["t"], [buildCommon.mathsym("T", group.mode)]);
- var e = makeSpan(
- ["e"], [buildCommon.mathsym("E", group.mode)]);
-
- e.height = (e.height - 0.2155);
- e.depth = (e.depth + 0.2155);
-
- var x = makeSpan(
- ["x"], [buildCommon.mathsym("X", group.mode)]);
-
- return makeSpan(
- ["katex-logo", "mord"], [k, a, t, e, x], options.getColor());
-};
-
-groupTypes.overline = function(group, options, prev) {
- // Overlines are handled in the TeXbook pg 443, Rule 9.
-
- // Build the inner group in the cramped style.
- var innerGroup = buildGroup(group.value.body,
- options.withStyle(options.style.cramp()));
-
- var ruleWidth = fontMetrics.metrics.defaultRuleThickness /
- options.style.sizeMultiplier;
-
- // Create the line above the body
- var line = makeSpan(
- [options.style.reset(), Style.TEXT.cls(), "overline-line"]);
- line.height = ruleWidth;
- line.maxFontSize = 1.0;
-
- // Generate the vlist, with the appropriate kerns
- var vlist = buildCommon.makeVList([
- {type: "elem", elem: innerGroup},
- {type: "kern", size: 3 * ruleWidth},
- {type: "elem", elem: line},
- {type: "kern", size: ruleWidth},
- ], "firstBaseline", null, options);
-
- return makeSpan(["overline", "mord"], [vlist], options.getColor());
-};
-
-groupTypes.underline = function(group, options, prev) {
- // Underlines are handled in the TeXbook pg 443, Rule 10.
-
- // Build the inner group.
- var innerGroup = buildGroup(group.value.body, options);
-
- var ruleWidth = fontMetrics.metrics.defaultRuleThickness /
- options.style.sizeMultiplier;
-
- // Create the line above the body
- var line = makeSpan(
- [options.style.reset(), Style.TEXT.cls(), "underline-line"]);
- line.height = ruleWidth;
- line.maxFontSize = 1.0;
-
- // Generate the vlist, with the appropriate kerns
- var vlist = buildCommon.makeVList([
- {type: "kern", size: ruleWidth},
- {type: "elem", elem: line},
- {type: "kern", size: 3 * ruleWidth},
- {type: "elem", elem: innerGroup},
- ], "top", innerGroup.height, options);
-
- return makeSpan(["underline", "mord"], [vlist], options.getColor());
-};
-
-groupTypes.sqrt = function(group, options, prev) {
- // Square roots are handled in the TeXbook pg. 443, Rule 11.
-
- // First, we do the same steps as in overline to build the inner group
- // and line
- var inner = buildGroup(group.value.body,
- options.withStyle(options.style.cramp()));
-
- var ruleWidth = fontMetrics.metrics.defaultRuleThickness /
- options.style.sizeMultiplier;
-
- var line = makeSpan(
- [options.style.reset(), Style.TEXT.cls(), "sqrt-line"], [],
- options.getColor());
- line.height = ruleWidth;
- line.maxFontSize = 1.0;
-
- var phi = ruleWidth;
- if (options.style.id < Style.TEXT.id) {
- phi = fontMetrics.metrics.xHeight;
- }
-
- // Calculate the clearance between the body and line
- var lineClearance = ruleWidth + phi / 4;
-
- var innerHeight =
- (inner.height + inner.depth) * options.style.sizeMultiplier;
- var minDelimiterHeight = innerHeight + lineClearance + ruleWidth;
-
- // Create a \surd delimiter of the required minimum size
- var delim = makeSpan(["sqrt-sign"], [
- delimiter.customSizedDelim("\\surd", minDelimiterHeight,
- false, options, group.mode)],
- options.getColor());
-
- var delimDepth = (delim.height + delim.depth) - ruleWidth;
-
- // Adjust the clearance based on the delimiter size
- if (delimDepth > inner.height + inner.depth + lineClearance) {
- lineClearance =
- (lineClearance + delimDepth - inner.height - inner.depth) / 2;
- }
-
- // Shift the delimiter so that its top lines up with the top of the line
- var delimShift = -(inner.height + lineClearance + ruleWidth) + delim.height;
- delim.style.top = delimShift + "em";
- delim.height -= delimShift;
- delim.depth += delimShift;
-
- // We add a special case here, because even when `inner` is empty, we
- // still get a line. So, we use a simple heuristic to decide if we
- // should omit the body entirely. (note this doesn't work for something
- // like `\sqrt{\rlap{x}}`, but if someone is doing that they deserve for
- // it not to work.
- var body;
- if (inner.height === 0 && inner.depth === 0) {
- body = makeSpan();
- } else {
- body = buildCommon.makeVList([
- {type: "elem", elem: inner},
- {type: "kern", size: lineClearance},
- {type: "elem", elem: line},
- {type: "kern", size: ruleWidth},
- ], "firstBaseline", null, options);
- }
-
- if (!group.value.index) {
- return makeSpan(["sqrt", "mord"], [delim, body]);
- } else {
- // Handle the optional root index
-
- // The index is always in scriptscript style
- var root = buildGroup(
- group.value.index,
- options.withStyle(Style.SCRIPTSCRIPT));
- var rootWrap = makeSpan(
- [options.style.reset(), Style.SCRIPTSCRIPT.cls()],
- [root]);
-
- // Figure out the height and depth of the inner part
- var innerRootHeight = Math.max(delim.height, body.height);
- var innerRootDepth = Math.max(delim.depth, body.depth);
-
- // The amount the index is shifted by. This is taken from the TeX
- // source, in the definition of `\r@@t`.
- var toShift = 0.6 * (innerRootHeight - innerRootDepth);
-
- // Build a VList with the superscript shifted up correctly
- var rootVList = buildCommon.makeVList(
- [{type: "elem", elem: rootWrap}],
- "shift", -toShift, options);
- // Add a class surrounding it so we can add on the appropriate
- // kerning
- var rootVListWrap = makeSpan(["root"], [rootVList]);
-
- return makeSpan(["sqrt", "mord"], [rootVListWrap, delim, body]);
- }
-};
-
-groupTypes.sizing = function(group, options, prev) {
- // Handle sizing operators like \Huge. Real TeX doesn't actually allow
- // these functions inside of math expressions, so we do some special
- // handling.
- var inner = buildExpression(group.value.value,
- options.withSize(group.value.size), prev);
-
- var span = makeSpan(["mord"],
- [makeSpan(["sizing", "reset-" + options.size, group.value.size,
- options.style.cls()],
- inner)]);
-
- // Calculate the correct maxFontSize manually
- var fontSize = buildCommon.sizingMultiplier[group.value.size];
- span.maxFontSize = fontSize * options.style.sizeMultiplier;
-
- return span;
-};
-
-groupTypes.styling = function(group, options, prev) {
- // Style changes are handled in the TeXbook on pg. 442, Rule 3.
-
- // Figure out what style we're changing to.
- var style = {
- "display": Style.DISPLAY,
- "text": Style.TEXT,
- "script": Style.SCRIPT,
- "scriptscript": Style.SCRIPTSCRIPT,
- };
-
- var newStyle = style[group.value.style];
-
- // Build the inner expression in the new style.
- var inner = buildExpression(
- group.value.value, options.withStyle(newStyle), prev);
-
- return makeSpan([options.style.reset(), newStyle.cls()], inner);
-};
-
-groupTypes.font = function(group, options, prev) {
- var font = group.value.font;
- return buildGroup(group.value.body, options.withFont(font), prev);
-};
-
-groupTypes.delimsizing = function(group, options, prev) {
- var delim = group.value.value;
-
- if (delim === ".") {
- // Empty delimiters still count as elements, even though they don't
- // show anything.
- return makeSpan([groupToType[group.value.delimType]]);
- }
-
- // Use delimiter.sizedDelim to generate the delimiter.
- return makeSpan(
- [groupToType[group.value.delimType]],
- [delimiter.sizedDelim(
- delim, group.value.size, options, group.mode)]);
-};
-
-groupTypes.leftright = function(group, options, prev) {
- // Build the inner expression
- var inner = buildExpression(group.value.body, options.reset());
-
- var innerHeight = 0;
- var innerDepth = 0;
-
- // Calculate its height and depth
- for (var i = 0; i < inner.length; i++) {
- innerHeight = Math.max(inner[i].height, innerHeight);
- innerDepth = Math.max(inner[i].depth, innerDepth);
- }
-
- // The size of delimiters is the same, regardless of what style we are
- // in. Thus, to correctly calculate the size of delimiter we need around
- // a group, we scale down the inner size based on the size.
- innerHeight *= options.style.sizeMultiplier;
- innerDepth *= options.style.sizeMultiplier;
-
- var leftDelim;
- if (group.value.left === ".") {
- // Empty delimiters in \left and \right make null delimiter spaces.
- leftDelim = makeNullDelimiter(options);
- } else {
- // Otherwise, use leftRightDelim to generate the correct sized
- // delimiter.
- leftDelim = delimiter.leftRightDelim(
- group.value.left, innerHeight, innerDepth, options,
- group.mode);
- }
- // Add it to the beginning of the expression
- inner.unshift(leftDelim);
-
- var rightDelim;
- // Same for the right delimiter
- if (group.value.right === ".") {
- rightDelim = makeNullDelimiter(options);
- } else {
- rightDelim = delimiter.leftRightDelim(
- group.value.right, innerHeight, innerDepth, options,
- group.mode);
- }
- // Add it to the end of the expression.
- inner.push(rightDelim);
-
- return makeSpan(
- ["minner", options.style.cls()], inner, options.getColor());
-};
-
-groupTypes.rule = function(group, options, prev) {
- // Make an empty span for the rule
- var rule = makeSpan(["mord", "rule"], [], options.getColor());
-
- // Calculate the shift, width, and height of the rule, and account for units
- var shift = 0;
- if (group.value.shift) {
- shift = group.value.shift.number;
- if (group.value.shift.unit === "ex") {
- shift *= fontMetrics.metrics.xHeight;
- }
- }
-
- var width = group.value.width.number;
- if (group.value.width.unit === "ex") {
- width *= fontMetrics.metrics.xHeight;
- }
-
- var height = group.value.height.number;
- if (group.value.height.unit === "ex") {
- height *= fontMetrics.metrics.xHeight;
- }
-
- // The sizes of rules are absolute, so make it larger if we are in a
- // smaller style.
- shift /= options.style.sizeMultiplier;
- width /= options.style.sizeMultiplier;
- height /= options.style.sizeMultiplier;
-
- // Style the rule to the right size
- rule.style.borderRightWidth = width + "em";
- rule.style.borderTopWidth = height + "em";
- rule.style.bottom = shift + "em";
-
- // Record the height and width
- rule.width = width;
- rule.height = height + shift;
- rule.depth = -shift;
-
- return rule;
-};
-
-groupTypes.kern = function(group, options, prev) {
- // Make an empty span for the rule
- var rule = makeSpan(["mord", "rule"], [], options.getColor());
-
- var dimension = 0;
- if (group.value.dimension) {
- dimension = group.value.dimension.number;
- if (group.value.dimension.unit === "ex") {
- dimension *= fontMetrics.metrics.xHeight;
- }
- }
-
- dimension /= options.style.sizeMultiplier;
-
- rule.style.marginLeft = dimension + "em";
-
- return rule;
-};
-
-groupTypes.accent = function(group, options, prev) {
- // Accents are handled in the TeXbook pg. 443, rule 12.
- var base = group.value.base;
-
- var supsubGroup;
- if (group.type === "supsub") {
- // If our base is a character box, and we have superscripts and
- // subscripts, the supsub will defer to us. In particular, we want
- // to attach the superscripts and subscripts to the inner body (so
- // that the position of the superscripts and subscripts won't be
- // affected by the height of the accent). We accomplish this by
- // sticking the base of the accent into the base of the supsub, and
- // rendering that, while keeping track of where the accent is.
-
- // The supsub group is the group that was passed in
- var supsub = group;
- // The real accent group is the base of the supsub group
- group = supsub.value.base;
- // The character box is the base of the accent group
- base = group.value.base;
- // Stick the character box into the base of the supsub group
- supsub.value.base = base;
-
- // Rerender the supsub group with its new base, and store that
- // result.
- supsubGroup = buildGroup(
- supsub, options.reset(), prev);
- }
-
- // Build the base group
- var body = buildGroup(
- base, options.withStyle(options.style.cramp()));
-
- // Calculate the skew of the accent. This is based on the line "If the
- // nucleus is not a single character, let s = 0; otherwise set s to the
- // kern amount for the nucleus followed by the \skewchar of its font."
- // Note that our skew metrics are just the kern between each character
- // and the skewchar.
- var skew;
- if (isCharacterBox(base)) {
- // If the base is a character box, then we want the skew of the
- // innermost character. To do that, we find the innermost character:
- var baseChar = getBaseElem(base);
- // Then, we render its group to get the symbol inside it
- var baseGroup = buildGroup(
- baseChar, options.withStyle(options.style.cramp()));
- // Finally, we pull the skew off of the symbol.
- skew = baseGroup.skew;
- // Note that we now throw away baseGroup, because the layers we
- // removed with getBaseElem might contain things like \color which
- // we can't get rid of.
- // TODO(emily): Find a better way to get the skew
- } else {
- skew = 0;
- }
-
- // calculate the amount of space between the body and the accent
- var clearance = Math.min(body.height, fontMetrics.metrics.xHeight);
-
- // Build the accent
- var accent = buildCommon.makeSymbol(
- group.value.accent, "Main-Regular", "math", options.getColor());
- // Remove the italic correction of the accent, because it only serves to
- // shift the accent over to a place we don't want.
- accent.italic = 0;
-
- // The \vec character that the fonts use is a combining character, and
- // thus shows up much too far to the left. To account for this, we add a
- // specific class which shifts the accent over to where we want it.
- // TODO(emily): Fix this in a better way, like by changing the font
- var vecClass = group.value.accent === "\\vec" ? "accent-vec" : null;
-
- var accentBody = makeSpan(["accent-body", vecClass], [
- makeSpan([], [accent])]);
-
- accentBody = buildCommon.makeVList([
- {type: "elem", elem: body},
- {type: "kern", size: -clearance},
- {type: "elem", elem: accentBody},
- ], "firstBaseline", null, options);
-
- // Shift the accent over by the skew. Note we shift by twice the skew
- // because we are centering the accent, so by adding 2*skew to the left,
- // we shift it to the right by 1*skew.
- accentBody.children[1].style.marginLeft = 2 * skew + "em";
-
- var accentWrap = makeSpan(["mord", "accent"], [accentBody]);
-
- if (supsubGroup) {
- // Here, we replace the "base" child of the supsub with our newly
- // generated accent.
- supsubGroup.children[0] = accentWrap;
-
- // Since we don't rerun the height calculation after replacing the
- // accent, we manually recalculate height.
- supsubGroup.height = Math.max(accentWrap.height, supsubGroup.height);
-
- // Accents should always be ords, even when their innards are not.
- supsubGroup.classes[0] = "mord";
-
- return supsubGroup;
- } else {
- return accentWrap;
- }
-};
-
-groupTypes.phantom = function(group, options, prev) {
- var elements = buildExpression(
- group.value.value,
- options.withPhantom(),
- prev
- );
-
- // \phantom isn't supposed to affect the elements it contains.
- // See "color" for more details.
- return new buildCommon.makeFragment(elements);
-};
-
-/**
- * buildGroup is the function that takes a group and calls the correct groupType
- * function for it. It also handles the interaction of size and style changes
- * between parents and children.
- */
-var buildGroup = function(group, options, prev) {
- if (!group) {
- return makeSpan();
- }
-
- if (groupTypes[group.type]) {
- // Call the groupTypes function
- var groupNode = groupTypes[group.type](group, options, prev);
- var multiplier;
-
- // If the style changed between the parent and the current group,
- // account for the size difference
- if (options.style !== options.parentStyle) {
- multiplier = options.style.sizeMultiplier /
- options.parentStyle.sizeMultiplier;
-
- groupNode.height *= multiplier;
- groupNode.depth *= multiplier;
- }
-
- // If the size changed between the parent and the current group, account
- // for that size difference.
- if (options.size !== options.parentSize) {
- multiplier = buildCommon.sizingMultiplier[options.size] /
- buildCommon.sizingMultiplier[options.parentSize];
-
- groupNode.height *= multiplier;
- groupNode.depth *= multiplier;
- }
-
- return groupNode;
- } else {
- throw new ParseError(
- "Got group of unknown type: '" + group.type + "'");
- }
-};
-
-/**
- * Take an entire parse tree, and build it into an appropriate set of HTML
- * nodes.
- */
-var buildHTML = function(tree, options) {
- // buildExpression is destructive, so we need to make a clone
- // of the incoming tree so that it isn't accidentally changed
- tree = JSON.parse(JSON.stringify(tree));
-
- // Build the expression contained in the tree
- var expression = buildExpression(tree, options);
- var body = makeSpan(["base", options.style.cls()], expression);
-
- // Add struts, which ensure that the top of the HTML element falls at the
- // height of the expression, and the bottom of the HTML element falls at the
- // depth of the expression.
- var topStrut = makeSpan(["strut"]);
- var bottomStrut = makeSpan(["strut", "bottom"]);
-
- topStrut.style.height = body.height + "em";
- bottomStrut.style.height = (body.height + body.depth) + "em";
- // We'd like to use `vertical-align: top` but in IE 9 this lowers the
- // baseline of the box to the bottom of this strut (instead staying in the
- // normal place) so we use an absolute value for vertical-align instead
- bottomStrut.style.verticalAlign = -body.depth + "em";
-
- // Wrap the struts and body together
- var htmlNode = makeSpan(["katex-html"], [topStrut, bottomStrut, body]);
-
- htmlNode.setAttribute("aria-hidden", "true");
-
- return htmlNode;
-};
-
-module.exports = buildHTML;
-
-},{"./ParseError":6,"./Style":9,"./buildCommon":10,"./delimiter":14,"./domTree":15,"./fontMetrics":17,"./utils":25}],12:[function(require,module,exports){
-/**
- * This file converts a parse tree into a cooresponding MathML tree. The main
- * entry point is the `buildMathML` function, which takes a parse tree from the
- * parser.
- */
-
-var buildCommon = require("./buildCommon");
-var fontMetrics = require("./fontMetrics");
-var mathMLTree = require("./mathMLTree");
-var ParseError = require("./ParseError");
-var symbols = require("./symbols");
-var utils = require("./utils");
-
-var makeSpan = buildCommon.makeSpan;
-var fontMap = buildCommon.fontMap;
-
-/**
- * Takes a symbol and converts it into a MathML text node after performing
- * optional replacement from symbols.js.
- */
-var makeText = function(text, mode) {
- if (symbols[mode][text] && symbols[mode][text].replace) {
- text = symbols[mode][text].replace;
- }
-
- return new mathMLTree.TextNode(text);
-};
-
-/**
- * Returns the math variant as a string or null if none is required.
- */
-var getVariant = function(group, options) {
- var font = options.font;
- if (!font) {
- return null;
- }
-
- var mode = group.mode;
- if (font === "mathit") {
- return "italic";
- }
-
- var value = group.value;
- if (utils.contains(["\\imath", "\\jmath"], value)) {
- return null;
- }
-
- if (symbols[mode][value] && symbols[mode][value].replace) {
- value = symbols[mode][value].replace;
- }
-
- var fontName = fontMap[font].fontName;
- if (fontMetrics.getCharacterMetrics(value, fontName)) {
- return fontMap[options.font].variant;
- }
-
- return null;
-};
-
-/**
- * Functions for handling the different types of groups found in the parse
- * tree. Each function should take a parse group and return a MathML node.
- */
-var groupTypes = {};
-
-groupTypes.mathord = function(group, options) {
- var node = new mathMLTree.MathNode(
- "mi",
- [makeText(group.value, group.mode)]);
-
- var variant = getVariant(group, options);
- if (variant) {
- node.setAttribute("mathvariant", variant);
- }
- return node;
-};
-
-groupTypes.textord = function(group, options) {
- var text = makeText(group.value, group.mode);
-
- var variant = getVariant(group, options) || "normal";
-
- var node;
- if (/[0-9]/.test(group.value)) {
- // TODO(kevinb) merge adjacent <mn> nodes
- // do it as a post processing step
- node = new mathMLTree.MathNode("mn", [text]);
- if (options.font) {
- node.setAttribute("mathvariant", variant);
- }
- } else {
- node = new mathMLTree.MathNode("mi", [text]);
- node.setAttribute("mathvariant", variant);
- }
-
- return node;
-};
-
-groupTypes.bin = function(group) {
- var node = new mathMLTree.MathNode(
- "mo", [makeText(group.value, group.mode)]);
-
- return node;
-};
-
-groupTypes.rel = function(group) {
- var node = new mathMLTree.MathNode(
- "mo", [makeText(group.value, group.mode)]);
-
- return node;
-};
-
-groupTypes.open = function(group) {
- var node = new mathMLTree.MathNode(
- "mo", [makeText(group.value, group.mode)]);
-
- return node;
-};
-
-groupTypes.close = function(group) {
- var node = new mathMLTree.MathNode(
- "mo", [makeText(group.value, group.mode)]);
-
- return node;
-};
-
-groupTypes.inner = function(group) {
- var node = new mathMLTree.MathNode(
- "mo", [makeText(group.value, group.mode)]);
-
- return node;
-};
-
-groupTypes.punct = function(group) {
- var node = new mathMLTree.MathNode(
- "mo", [makeText(group.value, group.mode)]);
-
- node.setAttribute("separator", "true");
-
- return node;
-};
-
-groupTypes.ordgroup = function(group, options) {
- var inner = buildExpression(group.value, options);
-
- var node = new mathMLTree.MathNode("mrow", inner);
-
- return node;
-};
-
-groupTypes.text = function(group, options) {
- var inner = buildExpression(group.value.body, options);
-
- var node = new mathMLTree.MathNode("mtext", inner);
-
- return node;
-};
-
-groupTypes.color = function(group, options) {
- var inner = buildExpression(group.value.value, options);
-
- var node = new mathMLTree.MathNode("mstyle", inner);
-
- node.setAttribute("mathcolor", group.value.color);
-
- return node;
-};
-
-groupTypes.supsub = function(group, options) {
- var children = [buildGroup(group.value.base, options)];
-
- if (group.value.sub) {
- children.push(buildGroup(group.value.sub, options));
- }
-
- if (group.value.sup) {
- children.push(buildGroup(group.value.sup, options));
- }
-
- var nodeType;
- if (!group.value.sub) {
- nodeType = "msup";
- } else if (!group.value.sup) {
- nodeType = "msub";
- } else {
- nodeType = "msubsup";
- }
-
- var node = new mathMLTree.MathNode(nodeType, children);
-
- return node;
-};
-
-groupTypes.genfrac = function(group, options) {
- var node = new mathMLTree.MathNode(
- "mfrac",
- [buildGroup(group.value.numer, options),
- buildGroup(group.value.denom, options)]);
-
- if (!group.value.hasBarLine) {
- node.setAttribute("linethickness", "0px");
- }
-
- if (group.value.leftDelim != null || group.value.rightDelim != null) {
- var withDelims = [];
-
- if (group.value.leftDelim != null) {
- var leftOp = new mathMLTree.MathNode(
- "mo", [new mathMLTree.TextNode(group.value.leftDelim)]);
-
- leftOp.setAttribute("fence", "true");
-
- withDelims.push(leftOp);
- }
-
- withDelims.push(node);
-
- if (group.value.rightDelim != null) {
- var rightOp = new mathMLTree.MathNode(
- "mo", [new mathMLTree.TextNode(group.value.rightDelim)]);
-
- rightOp.setAttribute("fence", "true");
-
- withDelims.push(rightOp);
- }
-
- var outerNode = new mathMLTree.MathNode("mrow", withDelims);
-
- return outerNode;
- }
-
- return node;
-};
-
-groupTypes.array = function(group, options) {
- return new mathMLTree.MathNode(
- "mtable", group.value.body.map(function(row) {
- return new mathMLTree.MathNode(
- "mtr", row.map(function(cell) {
- return new mathMLTree.MathNode(
- "mtd", [buildGroup(cell, options)]);
- }));
- }));
-};
-
-groupTypes.sqrt = function(group, options) {
- var node;
- if (group.value.index) {
- node = new mathMLTree.MathNode(
- "mroot", [
- buildGroup(group.value.body, options),
- buildGroup(group.value.index, options),
- ]);
- } else {
- node = new mathMLTree.MathNode(
- "msqrt", [buildGroup(group.value.body, options)]);
- }
-
- return node;
-};
-
-groupTypes.leftright = function(group, options) {
- var inner = buildExpression(group.value.body, options);
-
- if (group.value.left !== ".") {
- var leftNode = new mathMLTree.MathNode(
- "mo", [makeText(group.value.left, group.mode)]);
-
- leftNode.setAttribute("fence", "true");
-
- inner.unshift(leftNode);
- }
-
- if (group.value.right !== ".") {
- var rightNode = new mathMLTree.MathNode(
- "mo", [makeText(group.value.right, group.mode)]);
-
- rightNode.setAttribute("fence", "true");
-
- inner.push(rightNode);
- }
-
- var outerNode = new mathMLTree.MathNode("mrow", inner);
-
- return outerNode;
-};
-
-groupTypes.accent = function(group, options) {
- var accentNode = new mathMLTree.MathNode(
- "mo", [makeText(group.value.accent, group.mode)]);
-
- var node = new mathMLTree.MathNode(
- "mover",
- [buildGroup(group.value.base, options),
- accentNode]);
-
- node.setAttribute("accent", "true");
-
- return node;
-};
-
-groupTypes.spacing = function(group) {
- var node;
-
- if (group.value === "\\ " || group.value === "\\space" ||
- group.value === " " || group.value === "~") {
- node = new mathMLTree.MathNode(
- "mtext", [new mathMLTree.TextNode("\u00a0")]);
- } else {
- node = new mathMLTree.MathNode("mspace");
-
- node.setAttribute(
- "width", buildCommon.spacingFunctions[group.value].size);
- }
-
- return node;
-};
-
-groupTypes.op = function(group) {
- var node;
-
- // TODO(emily): handle big operators using the `largeop` attribute
-
- if (group.value.symbol) {
- // This is a symbol. Just add the symbol.
- node = new mathMLTree.MathNode(
- "mo", [makeText(group.value.body, group.mode)]);
- } else {
- // This is a text operator. Add all of the characters from the
- // operator's name.
- // TODO(emily): Add a space in the middle of some of these
- // operators, like \limsup.
- node = new mathMLTree.MathNode(
- "mi", [new mathMLTree.TextNode(group.value.body.slice(1))]);
- }
-
- return node;
-};
-
-groupTypes.katex = function(group) {
- var node = new mathMLTree.MathNode(
- "mtext", [new mathMLTree.TextNode("KaTeX")]);
-
- return node;
-};
-
-groupTypes.font = function(group, options) {
- var font = group.value.font;
- return buildGroup(group.value.body, options.withFont(font));
-};
-
-groupTypes.delimsizing = function(group) {
- var children = [];
-
- if (group.value.value !== ".") {
- children.push(makeText(group.value.value, group.mode));
- }
-
- var node = new mathMLTree.MathNode("mo", children);
-
- if (group.value.delimType === "open" ||
- group.value.delimType === "close") {
- // Only some of the delimsizing functions act as fences, and they
- // return "open" or "close" delimTypes.
- node.setAttribute("fence", "true");
- } else {
- // Explicitly disable fencing if it's not a fence, to override the
- // defaults.
- node.setAttribute("fence", "false");
- }
-
- return node;
-};
-
-groupTypes.styling = function(group, options) {
- var inner = buildExpression(group.value.value, options);
-
- var node = new mathMLTree.MathNode("mstyle", inner);
-
- var styleAttributes = {
- "display": ["0", "true"],
- "text": ["0", "false"],
- "script": ["1", "false"],
- "scriptscript": ["2", "false"],
- };
-
- var attr = styleAttributes[group.value.style];
-
- node.setAttribute("scriptlevel", attr[0]);
- node.setAttribute("displaystyle", attr[1]);
-
- return node;
-};
-
-groupTypes.sizing = function(group, options) {
- var inner = buildExpression(group.value.value, options);
-
- var node = new mathMLTree.MathNode("mstyle", inner);
-
- // TODO(emily): This doesn't produce the correct size for nested size
- // changes, because we don't keep state of what style we're currently
- // in, so we can't reset the size to normal before changing it. Now
- // that we're passing an options parameter we should be able to fix
- // this.
- node.setAttribute(
- "mathsize", buildCommon.sizingMultiplier[group.value.size] + "em");
-
- return node;
-};
-
-groupTypes.overline = function(group, options) {
- var operator = new mathMLTree.MathNode(
- "mo", [new mathMLTree.TextNode("\u203e")]);
- operator.setAttribute("stretchy", "true");
-
- var node = new mathMLTree.MathNode(
- "mover",
- [buildGroup(group.value.body, options),
- operator]);
- node.setAttribute("accent", "true");
-
- return node;
-};
-
-groupTypes.underline = function(group, options) {
- var operator = new mathMLTree.MathNode(
- "mo", [new mathMLTree.TextNode("\u203e")]);
- operator.setAttribute("stretchy", "true");
-
- var node = new mathMLTree.MathNode(
- "munder",
- [buildGroup(group.value.body, options),
- operator]);
- node.setAttribute("accentunder", "true");
-
- return node;
-};
-
-groupTypes.rule = function(group) {
- // TODO(emily): Figure out if there's an actual way to draw black boxes
- // in MathML.
- var node = new mathMLTree.MathNode("mrow");
-
- return node;
-};
-
-groupTypes.kern = function(group) {
- // TODO(kevin): Figure out if there's a way to add space in MathML
- var node = new mathMLTree.MathNode("mrow");
-
- return node;
-};
-
-groupTypes.llap = function(group, options) {
- var node = new mathMLTree.MathNode(
- "mpadded", [buildGroup(group.value.body, options)]);
-
- node.setAttribute("lspace", "-1width");
- node.setAttribute("width", "0px");
-
- return node;
-};
-
-groupTypes.rlap = function(group, options) {
- var node = new mathMLTree.MathNode(
- "mpadded", [buildGroup(group.value.body, options)]);
-
- node.setAttribute("width", "0px");
-
- return node;
-};
-
-groupTypes.phantom = function(group, options, prev) {
- var inner = buildExpression(group.value.value, options);
- return new mathMLTree.MathNode("mphantom", inner);
-};
-
-/**
- * Takes a list of nodes, builds them, and returns a list of the generated
- * MathML nodes. A little simpler than the HTML version because we don't do any
- * previous-node handling.
- */
-var buildExpression = function(expression, options) {
- var groups = [];
- for (var i = 0; i < expression.length; i++) {
- var group = expression[i];
- groups.push(buildGroup(group, options));
- }
- return groups;
-};
-
-/**
- * Takes a group from the parser and calls the appropriate groupTypes function
- * on it to produce a MathML node.
- */
-var buildGroup = function(group, options) {
- if (!group) {
- return new mathMLTree.MathNode("mrow");
- }
-
- if (groupTypes[group.type]) {
- // Call the groupTypes function
- return groupTypes[group.type](group, options);
- } else {
- throw new ParseError(
- "Got group of unknown type: '" + group.type + "'");
- }
-};
-
-/**
- * Takes a full parse tree and settings and builds a MathML representation of
- * it. In particular, we put the elements from building the parse tree into a
- * <semantics> tag so we can also include that TeX source as an annotation.
- *
- * Note that we actually return a domTree element with a `<math>` inside it so
- * we can do appropriate styling.
- */
-var buildMathML = function(tree, texExpression, options) {
- var expression = buildExpression(tree, options);
-
- // Wrap up the expression in an mrow so it is presented in the semantics
- // tag correctly.
- var wrapper = new mathMLTree.MathNode("mrow", expression);
-
- // Build a TeX annotation of the source
- var annotation = new mathMLTree.MathNode(
- "annotation", [new mathMLTree.TextNode(texExpression)]);
-
- annotation.setAttribute("encoding", "application/x-tex");
-
- var semantics = new mathMLTree.MathNode(
- "semantics", [wrapper, annotation]);
-
- var math = new mathMLTree.MathNode("math", [semantics]);
-
- // You can't style <math> nodes, so we wrap the node in a span.
- return makeSpan(["katex-mathml"], [math]);
-};
-
-module.exports = buildMathML;
-
-},{"./ParseError":6,"./buildCommon":10,"./fontMetrics":17,"./mathMLTree":20,"./symbols":23,"./utils":25}],13:[function(require,module,exports){
-var buildHTML = require("./buildHTML");
-var buildMathML = require("./buildMathML");
-var buildCommon = require("./buildCommon");
-var Options = require("./Options");
-var Settings = require("./Settings");
-var Style = require("./Style");
-
-var makeSpan = buildCommon.makeSpan;
-
-var buildTree = function(tree, expression, settings) {
- settings = settings || new Settings({});
-
- var startStyle = Style.TEXT;
- if (settings.displayMode) {
- startStyle = Style.DISPLAY;
- }
-
- // Setup the default options
- var options = new Options({
- style: startStyle,
- size: "size5",
- });
-
- // `buildHTML` sometimes messes with the parse tree (like turning bins ->
- // ords), so we build the MathML version first.
- var mathMLNode = buildMathML(tree, expression, options);
- var htmlNode = buildHTML(tree, options);
-
- var katexNode = makeSpan(["katex"], [
- mathMLNode, htmlNode,
- ]);
-
- if (settings.displayMode) {
- return makeSpan(["katex-display"], [katexNode]);
- } else {
- return katexNode;
- }
-};
-
-module.exports = buildTree;
-
-},{"./Options":5,"./Settings":8,"./Style":9,"./buildCommon":10,"./buildHTML":11,"./buildMathML":12}],14:[function(require,module,exports){
-/**
- * This file deals with creating delimiters of various sizes. The TeXbook
- * discusses these routines on page 441-442, in the "Another subroutine sets box
- * x to a specified variable delimiter" paragraph.
- *
- * There are three main routines here. `makeSmallDelim` makes a delimiter in the
- * normal font, but in either text, script, or scriptscript style.
- * `makeLargeDelim` makes a delimiter in textstyle, but in one of the Size1,
- * Size2, Size3, or Size4 fonts. `makeStackedDelim` makes a delimiter out of
- * smaller pieces that are stacked on top of one another.
- *
- * The functions take a parameter `center`, which determines if the delimiter
- * should be centered around the axis.
- *
- * Then, there are three exposed functions. `sizedDelim` makes a delimiter in
- * one of the given sizes. This is used for things like `\bigl`.
- * `customSizedDelim` makes a delimiter with a given total height+depth. It is
- * called in places like `\sqrt`. `leftRightDelim` makes an appropriate
- * delimiter which surrounds an expression of a given height an depth. It is
- * used in `\left` and `\right`.
- */
-
-var ParseError = require("./ParseError");
-var Style = require("./Style");
-
-var buildCommon = require("./buildCommon");
-var fontMetrics = require("./fontMetrics");
-var symbols = require("./symbols");
-var utils = require("./utils");
-
-var makeSpan = buildCommon.makeSpan;
-
-/**
- * Get the metrics for a given symbol and font, after transformation (i.e.
- * after following replacement from symbols.js)
- */
-var getMetrics = function(symbol, font) {
- if (symbols.math[symbol] && symbols.math[symbol].replace) {
- return fontMetrics.getCharacterMetrics(
- symbols.math[symbol].replace, font);
- } else {
- return fontMetrics.getCharacterMetrics(
- symbol, font);
- }
-};
-
-/**
- * Builds a symbol in the given font size (note size is an integer)
- */
-var mathrmSize = function(value, size, mode) {
- return buildCommon.makeSymbol(value, "Size" + size + "-Regular", mode);
-};
-
-/**
- * Puts a delimiter span in a given style, and adds appropriate height, depth,
- * and maxFontSizes.
- */
-var styleWrap = function(delim, toStyle, options) {
- var span = makeSpan(
- ["style-wrap", options.style.reset(), toStyle.cls()], [delim]);
-
- var multiplier = toStyle.sizeMultiplier / options.style.sizeMultiplier;
-
- span.height *= multiplier;
- span.depth *= multiplier;
- span.maxFontSize = toStyle.sizeMultiplier;
-
- return span;
-};
-
-/**
- * Makes a small delimiter. This is a delimiter that comes in the Main-Regular
- * font, but is restyled to either be in textstyle, scriptstyle, or
- * scriptscriptstyle.
- */
-var makeSmallDelim = function(delim, style, center, options, mode) {
- var text = buildCommon.makeSymbol(delim, "Main-Regular", mode);
-
- var span = styleWrap(text, style, options);
-
- if (center) {
- var shift =
- (1 - options.style.sizeMultiplier / style.sizeMultiplier) *
- fontMetrics.metrics.axisHeight;
-
- span.style.top = shift + "em";
- span.height -= shift;
- span.depth += shift;
- }
-
- return span;
-};
-
-/**
- * Makes a large delimiter. This is a delimiter that comes in the Size1, Size2,
- * Size3, or Size4 fonts. It is always rendered in textstyle.
- */
-var makeLargeDelim = function(delim, size, center, options, mode) {
- var inner = mathrmSize(delim, size, mode);
-
- var span = styleWrap(
- makeSpan(["delimsizing", "size" + size],
- [inner], options.getColor()),
- Style.TEXT, options);
-
- if (center) {
- var shift = (1 - options.style.sizeMultiplier) *
- fontMetrics.metrics.axisHeight;
-
- span.style.top = shift + "em";
- span.height -= shift;
- span.depth += shift;
- }
-
- return span;
-};
-
-/**
- * Make an inner span with the given offset and in the given font. This is used
- * in `makeStackedDelim` to make the stacking pieces for the delimiter.
- */
-var makeInner = function(symbol, font, mode) {
- var sizeClass;
- // Apply the correct CSS class to choose the right font.
- if (font === "Size1-Regular") {
- sizeClass = "delim-size1";
- } else if (font === "Size4-Regular") {
- sizeClass = "delim-size4";
- }
-
- var inner = makeSpan(
- ["delimsizinginner", sizeClass],
- [makeSpan([], [buildCommon.makeSymbol(symbol, font, mode)])]);
-
- // Since this will be passed into `makeVList` in the end, wrap the element
- // in the appropriate tag that VList uses.
- return {type: "elem", elem: inner};
-};
-
-/**
- * Make a stacked delimiter out of a given delimiter, with the total height at
- * least `heightTotal`. This routine is mentioned on page 442 of the TeXbook.
- */
-var makeStackedDelim = function(delim, heightTotal, center, options, mode) {
- // There are four parts, the top, an optional middle, a repeated part, and a
- // bottom.
- var top;
- var middle;
- var repeat;
- var bottom;
- top = repeat = bottom = delim;
- middle = null;
- // Also keep track of what font the delimiters are in
- var font = "Size1-Regular";
-
- // We set the parts and font based on the symbol. Note that we use
- // '\u23d0' instead of '|' and '\u2016' instead of '\\|' for the
- // repeats of the arrows
- if (delim === "\\uparrow") {
- repeat = bottom = "\u23d0";
- } else if (delim === "\\Uparrow") {
- repeat = bottom = "\u2016";
- } else if (delim === "\\downarrow") {
- top = repeat = "\u23d0";
- } else if (delim === "\\Downarrow") {
- top = repeat = "\u2016";
- } else if (delim === "\\updownarrow") {
- top = "\\uparrow";
- repeat = "\u23d0";
- bottom = "\\downarrow";
- } else if (delim === "\\Updownarrow") {
- top = "\\Uparrow";
- repeat = "\u2016";
- bottom = "\\Downarrow";
- } else if (delim === "[" || delim === "\\lbrack") {
- top = "\u23a1";
- repeat = "\u23a2";
- bottom = "\u23a3";
- font = "Size4-Regular";
- } else if (delim === "]" || delim === "\\rbrack") {
- top = "\u23a4";
- repeat = "\u23a5";
- bottom = "\u23a6";
- font = "Size4-Regular";
- } else if (delim === "\\lfloor") {
- repeat = top = "\u23a2";
- bottom = "\u23a3";
- font = "Size4-Regular";
- } else if (delim === "\\lceil") {
- top = "\u23a1";
- repeat = bottom = "\u23a2";
- font = "Size4-Regular";
- } else if (delim === "\\rfloor") {
- repeat = top = "\u23a5";
- bottom = "\u23a6";
- font = "Size4-Regular";
- } else if (delim === "\\rceil") {
- top = "\u23a4";
- repeat = bottom = "\u23a5";
- font = "Size4-Regular";
- } else if (delim === "(") {
- top = "\u239b";
- repeat = "\u239c";
- bottom = "\u239d";
- font = "Size4-Regular";
- } else if (delim === ")") {
- top = "\u239e";
- repeat = "\u239f";
- bottom = "\u23a0";
- font = "Size4-Regular";
- } else if (delim === "\\{" || delim === "\\lbrace") {
- top = "\u23a7";
- middle = "\u23a8";
- bottom = "\u23a9";
- repeat = "\u23aa";
- font = "Size4-Regular";
- } else if (delim === "\\}" || delim === "\\rbrace") {
- top = "\u23ab";
- middle = "\u23ac";
- bottom = "\u23ad";
- repeat = "\u23aa";
- font = "Size4-Regular";
- } else if (delim === "\\lgroup") {
- top = "\u23a7";
- bottom = "\u23a9";
- repeat = "\u23aa";
- font = "Size4-Regular";
- } else if (delim === "\\rgroup") {
- top = "\u23ab";
- bottom = "\u23ad";
- repeat = "\u23aa";
- font = "Size4-Regular";
- } else if (delim === "\\lmoustache") {
- top = "\u23a7";
- bottom = "\u23ad";
- repeat = "\u23aa";
- font = "Size4-Regular";
- } else if (delim === "\\rmoustache") {
- top = "\u23ab";
- bottom = "\u23a9";
- repeat = "\u23aa";
- font = "Size4-Regular";
- } else if (delim === "\\surd") {
- top = "\ue001";
- bottom = "\u23b7";
- repeat = "\ue000";
- font = "Size4-Regular";
- }
-
- // Get the metrics of the four sections
- var topMetrics = getMetrics(top, font);
- var topHeightTotal = topMetrics.height + topMetrics.depth;
- var repeatMetrics = getMetrics(repeat, font);
- var repeatHeightTotal = repeatMetrics.height + repeatMetrics.depth;
- var bottomMetrics = getMetrics(bottom, font);
- var bottomHeightTotal = bottomMetrics.height + bottomMetrics.depth;
- var middleHeightTotal = 0;
- var middleFactor = 1;
- if (middle !== null) {
- var middleMetrics = getMetrics(middle, font);
- middleHeightTotal = middleMetrics.height + middleMetrics.depth;
- middleFactor = 2; // repeat symmetrically above and below middle
- }
-
- // Calcuate the minimal height that the delimiter can have.
- // It is at least the size of the top, bottom, and optional middle combined.
- var minHeight = topHeightTotal + bottomHeightTotal + middleHeightTotal;
-
- // Compute the number of copies of the repeat symbol we will need
- var repeatCount = Math.ceil(
- (heightTotal - minHeight) / (middleFactor * repeatHeightTotal));
-
- // Compute the total height of the delimiter including all the symbols
- var realHeightTotal =
- minHeight + repeatCount * middleFactor * repeatHeightTotal;
-
- // The center of the delimiter is placed at the center of the axis. Note
- // that in this context, "center" means that the delimiter should be
- // centered around the axis in the current style, while normally it is
- // centered around the axis in textstyle.
- var axisHeight = fontMetrics.metrics.axisHeight;
- if (center) {
- axisHeight *= options.style.sizeMultiplier;
- }
- // Calculate the depth
- var depth = realHeightTotal / 2 - axisHeight;
-
- // Now, we start building the pieces that will go into the vlist
-
- // Keep a list of the inner pieces
- var inners = [];
-
- // Add the bottom symbol
- inners.push(makeInner(bottom, font, mode));
-
- var i;
- if (middle === null) {
- // Add that many symbols
- for (i = 0; i < repeatCount; i++) {
- inners.push(makeInner(repeat, font, mode));
- }
- } else {
- // When there is a middle bit, we need the middle part and two repeated
- // sections
- for (i = 0; i < repeatCount; i++) {
- inners.push(makeInner(repeat, font, mode));
- }
- inners.push(makeInner(middle, font, mode));
- for (i = 0; i < repeatCount; i++) {
- inners.push(makeInner(repeat, font, mode));
- }
- }
-
- // Add the top symbol
- inners.push(makeInner(top, font, mode));
-
- // Finally, build the vlist
- var inner = buildCommon.makeVList(inners, "bottom", depth, options);
-
- return styleWrap(
- makeSpan(["delimsizing", "mult"], [inner], options.getColor()),
- Style.TEXT, options);
-};
-
-// There are three kinds of delimiters, delimiters that stack when they become
-// too large
-var stackLargeDelimiters = [
- "(", ")", "[", "\\lbrack", "]", "\\rbrack",
- "\\{", "\\lbrace", "\\}", "\\rbrace",
- "\\lfloor", "\\rfloor", "\\lceil", "\\rceil",
- "\\surd",
-];
-
-// delimiters that always stack
-var stackAlwaysDelimiters = [
- "\\uparrow", "\\downarrow", "\\updownarrow",
- "\\Uparrow", "\\Downarrow", "\\Updownarrow",
- "|", "\\|", "\\vert", "\\Vert",
- "\\lvert", "\\rvert", "\\lVert", "\\rVert",
- "\\lgroup", "\\rgroup", "\\lmoustache", "\\rmoustache",
-];
-
-// and delimiters that never stack
-var stackNeverDelimiters = [
- "<", ">", "\\langle", "\\rangle", "/", "\\backslash", "\\lt", "\\gt",
-];
-
-// Metrics of the different sizes. Found by looking at TeX's output of
-// $\bigl| // \Bigl| \biggl| \Biggl| \showlists$
-// Used to create stacked delimiters of appropriate sizes in makeSizedDelim.
-var sizeToMaxHeight = [0, 1.2, 1.8, 2.4, 3.0];
-
-/**
- * Used to create a delimiter of a specific size, where `size` is 1, 2, 3, or 4.
- */
-var makeSizedDelim = function(delim, size, options, mode) {
- // < and > turn into \langle and \rangle in delimiters
- if (delim === "<" || delim === "\\lt") {
- delim = "\\langle";
- } else if (delim === ">" || delim === "\\gt") {
- delim = "\\rangle";
- }
-
- // Sized delimiters are never centered.
- if (utils.contains(stackLargeDelimiters, delim) ||
- utils.contains(stackNeverDelimiters, delim)) {
- return makeLargeDelim(delim, size, false, options, mode);
- } else if (utils.contains(stackAlwaysDelimiters, delim)) {
- return makeStackedDelim(
- delim, sizeToMaxHeight[size], false, options, mode);
- } else {
- throw new ParseError("Illegal delimiter: '" + delim + "'");
- }
-};
-
-/**
- * There are three different sequences of delimiter sizes that the delimiters
- * follow depending on the kind of delimiter. This is used when creating custom
- * sized delimiters to decide whether to create a small, large, or stacked
- * delimiter.
- *
- * In real TeX, these sequences aren't explicitly defined, but are instead
- * defined inside the font metrics. Since there are only three sequences that
- * are possible for the delimiters that TeX defines, it is easier to just encode
- * them explicitly here.
- */
-
-// Delimiters that never stack try small delimiters and large delimiters only
-var stackNeverDelimiterSequence = [
- {type: "small", style: Style.SCRIPTSCRIPT},
- {type: "small", style: Style.SCRIPT},
- {type: "small", style: Style.TEXT},
- {type: "large", size: 1},
- {type: "large", size: 2},
- {type: "large", size: 3},
- {type: "large", size: 4},
-];
-
-// Delimiters that always stack try the small delimiters first, then stack
-var stackAlwaysDelimiterSequence = [
- {type: "small", style: Style.SCRIPTSCRIPT},
- {type: "small", style: Style.SCRIPT},
- {type: "small", style: Style.TEXT},
- {type: "stack"},
-];
-
-// Delimiters that stack when large try the small and then large delimiters, and
-// stack afterwards
-var stackLargeDelimiterSequence = [
- {type: "small", style: Style.SCRIPTSCRIPT},
- {type: "small", style: Style.SCRIPT},
- {type: "small", style: Style.TEXT},
- {type: "large", size: 1},
- {type: "large", size: 2},
- {type: "large", size: 3},
- {type: "large", size: 4},
- {type: "stack"},
-];
-
-/**
- * Get the font used in a delimiter based on what kind of delimiter it is.
- */
-var delimTypeToFont = function(type) {
- if (type.type === "small") {
- return "Main-Regular";
- } else if (type.type === "large") {
- return "Size" + type.size + "-Regular";
- } else if (type.type === "stack") {
- return "Size4-Regular";
- }
-};
-
-/**
- * Traverse a sequence of types of delimiters to decide what kind of delimiter
- * should be used to create a delimiter of the given height+depth.
- */
-var traverseSequence = function(delim, height, sequence, options) {
- // Here, we choose the index we should start at in the sequences. In smaller
- // sizes (which correspond to larger numbers in style.size) we start earlier
- // in the sequence. Thus, scriptscript starts at index 3-3=0, script starts
- // at index 3-2=1, text starts at 3-1=2, and display starts at min(2,3-0)=2
- var start = Math.min(2, 3 - options.style.size);
- for (var i = start; i < sequence.length; i++) {
- if (sequence[i].type === "stack") {
- // This is always the last delimiter, so we just break the loop now.
- break;
- }
-
- var metrics = getMetrics(delim, delimTypeToFont(sequence[i]));
- var heightDepth = metrics.height + metrics.depth;
-
- // Small delimiters are scaled down versions of the same font, so we
- // account for the style change size.
-
- if (sequence[i].type === "small") {
- heightDepth *= sequence[i].style.sizeMultiplier;
- }
-
- // Check if the delimiter at this size works for the given height.
- if (heightDepth > height) {
- return sequence[i];
- }
- }
-
- // If we reached the end of the sequence, return the last sequence element.
- return sequence[sequence.length - 1];
-};
-
-/**
- * Make a delimiter of a given height+depth, with optional centering. Here, we
- * traverse the sequences, and create a delimiter that the sequence tells us to.
- */
-var makeCustomSizedDelim = function(delim, height, center, options, mode) {
- if (delim === "<" || delim === "\\lt") {
- delim = "\\langle";
- } else if (delim === ">" || delim === "\\gt") {
- delim = "\\rangle";
- }
-
- // Decide what sequence to use
- var sequence;
- if (utils.contains(stackNeverDelimiters, delim)) {
- sequence = stackNeverDelimiterSequence;
- } else if (utils.contains(stackLargeDelimiters, delim)) {
- sequence = stackLargeDelimiterSequence;
- } else {
- sequence = stackAlwaysDelimiterSequence;
- }
-
- // Look through the sequence
- var delimType = traverseSequence(delim, height, sequence, options);
-
- // Depending on the sequence element we decided on, call the appropriate
- // function.
- if (delimType.type === "small") {
- return makeSmallDelim(delim, delimType.style, center, options, mode);
- } else if (delimType.type === "large") {
- return makeLargeDelim(delim, delimType.size, center, options, mode);
- } else if (delimType.type === "stack") {
- return makeStackedDelim(delim, height, center, options, mode);
- }
-};
-
-/**
- * Make a delimiter for use with `\left` and `\right`, given a height and depth
- * of an expression that the delimiters surround.
- */
-var makeLeftRightDelim = function(delim, height, depth, options, mode) {
- // We always center \left/\right delimiters, so the axis is always shifted
- var axisHeight =
- fontMetrics.metrics.axisHeight * options.style.sizeMultiplier;
-
- // Taken from TeX source, tex.web, function make_left_right
- var delimiterFactor = 901;
- var delimiterExtend = 5.0 / fontMetrics.metrics.ptPerEm;
-
- var maxDistFromAxis = Math.max(
- height - axisHeight, depth + axisHeight);
-
- var totalHeight = Math.max(
- // In real TeX, calculations are done using integral values which are
- // 65536 per pt, or 655360 per em. So, the division here truncates in
- // TeX but doesn't here, producing different results. If we wanted to
- // exactly match TeX's calculation, we could do
- // Math.floor(655360 * maxDistFromAxis / 500) *
- // delimiterFactor / 655360
- // (To see the difference, compare
- // x^{x^{\left(\rule{0.1em}{0.68em}\right)}}
- // in TeX and KaTeX)
- maxDistFromAxis / 500 * delimiterFactor,
- 2 * maxDistFromAxis - delimiterExtend);
-
- // Finally, we defer to `makeCustomSizedDelim` with our calculated total
- // height
- return makeCustomSizedDelim(delim, totalHeight, true, options, mode);
-};
-
-module.exports = {
- sizedDelim: makeSizedDelim,
- customSizedDelim: makeCustomSizedDelim,
- leftRightDelim: makeLeftRightDelim,
-};
-
-},{"./ParseError":6,"./Style":9,"./buildCommon":10,"./fontMetrics":17,"./symbols":23,"./utils":25}],15:[function(require,module,exports){
-/**
- * These objects store the data about the DOM nodes we create, as well as some
- * extra data. They can then be transformed into real DOM nodes with the
- * `toNode` function or HTML markup using `toMarkup`. They are useful for both
- * storing extra properties on the nodes, as well as providing a way to easily
- * work with the DOM.
- *
- * Similar functions for working with MathML nodes exist in mathMLTree.js.
- */
-var unicodeRegexes = require("./unicodeRegexes");
-var utils = require("./utils");
-
-/**
- * Create an HTML className based on a list of classes. In addition to joining
- * with spaces, we also remove null or empty classes.
- */
-var createClass = function(classes) {
- classes = classes.slice();
- for (var i = classes.length - 1; i >= 0; i--) {
- if (!classes[i]) {
- classes.splice(i, 1);
- }
- }
-
- return classes.join(" ");
-};
-
-/**
- * This node represents a span node, with a className, a list of children, and
- * an inline style. It also contains information about its height, depth, and
- * maxFontSize.
- */
-function span(classes, children, height, depth, maxFontSize, style) {
- this.classes = classes || [];
- this.children = children || [];
- this.height = height || 0;
- this.depth = depth || 0;
- this.maxFontSize = maxFontSize || 0;
- this.style = style || {};
- this.attributes = {};
-}
-
-/**
- * Sets an arbitrary attribute on the span. Warning: use this wisely. Not all
- * browsers support attributes the same, and having too many custom attributes
- * is probably bad.
- */
-span.prototype.setAttribute = function(attribute, value) {
- this.attributes[attribute] = value;
-};
-
-/**
- * Convert the span into an HTML node
- */
-span.prototype.toNode = function() {
- var span = document.createElement("span");
-
- // Apply the class
- span.className = createClass(this.classes);
-
- // Apply inline styles
- for (var style in this.style) {
- if (Object.prototype.hasOwnProperty.call(this.style, style)) {
- span.style[style] = this.style[style];
- }
- }
-
- // Apply attributes
- for (var attr in this.attributes) {
- if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
- span.setAttribute(attr, this.attributes[attr]);
- }
- }
-
- // Append the children, also as HTML nodes
- for (var i = 0; i < this.children.length; i++) {
- span.appendChild(this.children[i].toNode());
- }
-
- return span;
-};
-
-/**
- * Convert the span into an HTML markup string
- */
-span.prototype.toMarkup = function() {
- var markup = "<span";
-
- // Add the class
- if (this.classes.length) {
- markup += " class=\"";
- markup += utils.escape(createClass(this.classes));
- markup += "\"";
- }
-
- var styles = "";
-
- // Add the styles, after hyphenation
- for (var style in this.style) {
- if (this.style.hasOwnProperty(style)) {
- styles += utils.hyphenate(style) + ":" + this.style[style] + ";";
- }
- }
-
- if (styles) {
- markup += " style=\"" + utils.escape(styles) + "\"";
- }
-
- // Add the attributes
- for (var attr in this.attributes) {
- if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
- markup += " " + attr + "=\"";
- markup += utils.escape(this.attributes[attr]);
- markup += "\"";
- }
- }
-
- markup += ">";
-
- // Add the markup of the children, also as markup
- for (var i = 0; i < this.children.length; i++) {
- markup += this.children[i].toMarkup();
- }
-
- markup += "</span>";
-
- return markup;
-};
-
-/**
- * This node represents a document fragment, which contains elements, but when
- * placed into the DOM doesn't have any representation itself. Thus, it only
- * contains children and doesn't have any HTML properties. It also keeps track
- * of a height, depth, and maxFontSize.
- */
-function documentFragment(children, height, depth, maxFontSize) {
- this.children = children || [];
- this.height = height || 0;
- this.depth = depth || 0;
- this.maxFontSize = maxFontSize || 0;
-}
-
-/**
- * Convert the fragment into a node
- */
-documentFragment.prototype.toNode = function() {
- // Create a fragment
- var frag = document.createDocumentFragment();
-
- // Append the children
- for (var i = 0; i < this.children.length; i++) {
- frag.appendChild(this.children[i].toNode());
- }
-
- return frag;
-};
-
-/**
- * Convert the fragment into HTML markup
- */
-documentFragment.prototype.toMarkup = function() {
- var markup = "";
-
- // Simply concatenate the markup for the children together
- for (var i = 0; i < this.children.length; i++) {
- markup += this.children[i].toMarkup();
- }
-
- return markup;
-};
-
-var iCombinations = {
- 'î': '\u0131\u0302',
- 'ï': '\u0131\u0308',
- 'í': '\u0131\u0301',
- // 'Ä«': '\u0131\u0304', // enable when we add Extended Latin
- 'ì': '\u0131\u0300',
-};
-
-/**
- * A symbol node contains information about a single symbol. It either renders
- * to a single text node, or a span with a single text node in it, depending on
- * whether it has CSS classes, styles, or needs italic correction.
- */
-function symbolNode(value, height, depth, italic, skew, classes, style) {
- this.value = value || "";
- this.height = height || 0;
- this.depth = depth || 0;
- this.italic = italic || 0;
- this.skew = skew || 0;
- this.classes = classes || [];
- this.style = style || {};
- this.maxFontSize = 0;
-
- // Mark CJK characters with specific classes so that we can specify which
- // fonts to use. This allows us to render these characters with a serif
- // font in situations where the browser would either default to a sans serif
- // or render a placeholder character.
- if (unicodeRegexes.cjkRegex.test(value)) {
- // I couldn't find any fonts that contained Hangul as well as all of
- // the other characters we wanted to test there for it gets its own
- // CSS class.
- if (unicodeRegexes.hangulRegex.test(value)) {
- this.classes.push('hangul_fallback');
- } else {
- this.classes.push('cjk_fallback');
- }
- }
-
- if (/[îïíì]/.test(this.value)) { // add ī when we add Extended Latin
- this.value = iCombinations[this.value];
- }
-}
-
-/**
- * Creates a text node or span from a symbol node. Note that a span is only
- * created if it is needed.
- */
-symbolNode.prototype.toNode = function() {
- var node = document.createTextNode(this.value);
- var span = null;
-
- if (this.italic > 0) {
- span = document.createElement("span");
- span.style.marginRight = this.italic + "em";
- }
-
- if (this.classes.length > 0) {
- span = span || document.createElement("span");
- span.className = createClass(this.classes);
- }
-
- for (var style in this.style) {
- if (this.style.hasOwnProperty(style)) {
- span = span || document.createElement("span");
- span.style[style] = this.style[style];
- }
- }
-
- if (span) {
- span.appendChild(node);
- return span;
- } else {
- return node;
- }
-};
-
-/**
- * Creates markup for a symbol node.
- */
-symbolNode.prototype.toMarkup = function() {
- // TODO(alpert): More duplication than I'd like from
- // span.prototype.toMarkup and symbolNode.prototype.toNode...
- var needsSpan = false;
-
- var markup = "<span";
-
- if (this.classes.length) {
- needsSpan = true;
- markup += " class=\"";
- markup += utils.escape(createClass(this.classes));
- markup += "\"";
- }
-
- var styles = "";
-
- if (this.italic > 0) {
- styles += "margin-right:" + this.italic + "em;";
- }
- for (var style in this.style) {
- if (this.style.hasOwnProperty(style)) {
- styles += utils.hyphenate(style) + ":" + this.style[style] + ";";
- }
- }
-
- if (styles) {
- needsSpan = true;
- markup += " style=\"" + utils.escape(styles) + "\"";
- }
-
- var escaped = utils.escape(this.value);
- if (needsSpan) {
- markup += ">";
- markup += escaped;
- markup += "</span>";
- return markup;
- } else {
- return escaped;
- }
-};
-
-module.exports = {
- span: span,
- documentFragment: documentFragment,
- symbolNode: symbolNode,
-};
-
-},{"./unicodeRegexes":24,"./utils":25}],16:[function(require,module,exports){
-/* eslint no-constant-condition:0 */
-var fontMetrics = require("./fontMetrics");
-var parseData = require("./parseData");
-var ParseError = require("./ParseError");
-
-var ParseNode = parseData.ParseNode;
-
-/**
- * Parse the body of the environment, with rows delimited by \\ and
- * columns delimited by &, and create a nested list in row-major order
- * with one group per cell.
- */
-function parseArray(parser, result) {
- var row = [];
- var body = [row];
- var rowGaps = [];
- while (true) {
- var cell = parser.parseExpression(false, null);
- row.push(new ParseNode("ordgroup", cell, parser.mode));
- var next = parser.nextToken.text;
- if (next === "&") {
- parser.consume();
- } else if (next === "\\end") {
- break;
- } else if (next === "\\\\" || next === "\\cr") {
- var cr = parser.parseFunction();
- rowGaps.push(cr.value.size);
- row = [];
- body.push(row);
- } else {
- throw new ParseError("Expected & or \\\\ or \\end",
- parser.nextToken);
- }
- }
- result.body = body;
- result.rowGaps = rowGaps;
- return new ParseNode(result.type, result, parser.mode);
-}
-
-/*
- * An environment definition is very similar to a function definition:
- * it is declared with a name or a list of names, a set of properties
- * and a handler containing the actual implementation.
- *
- * The properties include:
- * - numArgs: The number of arguments after the \begin{name} function.
- * - argTypes: (optional) Just like for a function
- * - allowedInText: (optional) Whether or not the environment is allowed inside
- * text mode (default false) (not enforced yet)
- * - numOptionalArgs: (optional) Just like for a function
- * A bare number instead of that object indicates the numArgs value.
- *
- * The handler function will receive two arguments
- * - context: information and references provided by the parser
- * - args: an array of arguments passed to \begin{name}
- * The context contains the following properties:
- * - envName: the name of the environment, one of the listed names.
- * - parser: the parser object
- * - lexer: the lexer object
- * - positions: the positions associated with these arguments from args.
- * The handler must return a ParseResult.
- */
-
-function defineEnvironment(names, props, handler) {
- if (typeof names === "string") {
- names = [names];
- }
- if (typeof props === "number") {
- props = { numArgs: props };
- }
- // Set default values of environments
- var data = {
- numArgs: props.numArgs || 0,
- argTypes: props.argTypes,
- greediness: 1,
- allowedInText: !!props.allowedInText,
- numOptionalArgs: props.numOptionalArgs || 0,
- handler: handler,
- };
- for (var i = 0; i < names.length; ++i) {
- module.exports[names[i]] = data;
- }
-}
-
-// Arrays are part of LaTeX, defined in lttab.dtx so its documentation
-// is part of the source2e.pdf file of LaTeX2e source documentation.
-defineEnvironment("array", {
- numArgs: 1,
-}, function(context, args) {
- var colalign = args[0];
- colalign = colalign.value.map ? colalign.value : [colalign];
- var cols = colalign.map(function(node) {
- var ca = node.value;
- if ("lcr".indexOf(ca) !== -1) {
- return {
- type: "align",
- align: ca,
- };
- } else if (ca === "|") {
- return {
- type: "separator",
- separator: "|",
- };
- }
- throw new ParseError(
- "Unknown column alignment: " + node.value,
- node);
- });
- var res = {
- type: "array",
- cols: cols,
- hskipBeforeAndAfter: true, // \@preamble in lttab.dtx
- };
- res = parseArray(context.parser, res);
- return res;
-});
-
-// The matrix environments of amsmath builds on the array environment
-// of LaTeX, which is discussed above.
-defineEnvironment([
- "matrix",
- "pmatrix",
- "bmatrix",
- "Bmatrix",
- "vmatrix",
- "Vmatrix",
-], {
-}, function(context) {
- var delimiters = {
- "matrix": null,
- "pmatrix": ["(", ")"],
- "bmatrix": ["[", "]"],
- "Bmatrix": ["\\{", "\\}"],
- "vmatrix": ["|", "|"],
- "Vmatrix": ["\\Vert", "\\Vert"],
- }[context.envName];
- var res = {
- type: "array",
- hskipBeforeAndAfter: false, // \hskip -\arraycolsep in amsmath
- };
- res = parseArray(context.parser, res);
- if (delimiters) {
- res = new ParseNode("leftright", {
- body: [res],
- left: delimiters[0],
- right: delimiters[1],
- }, context.mode);
- }
- return res;
-});
-
-// A cases environment (in amsmath.sty) is almost equivalent to
-// \def\arraystretch{1.2}%
-// \left\{\begin{array}{@{}l@{\quad}l@{}} … \end{array}\right.
-defineEnvironment("cases", {
-}, function(context) {
- var res = {
- type: "array",
- arraystretch: 1.2,
- cols: [{
- type: "align",
- align: "l",
- pregap: 0,
- postgap: fontMetrics.metrics.quad,
- }, {
- type: "align",
- align: "l",
- pregap: 0,
- postgap: 0,
- }],
- };
- res = parseArray(context.parser, res);
- res = new ParseNode("leftright", {
- body: [res],
- left: "\\{",
- right: ".",
- }, context.mode);
- return res;
-});
-
-// An aligned environment is like the align* environment
-// except it operates within math mode.
-// Note that we assume \nomallineskiplimit to be zero,
-// so that \strut@ is the same as \strut.
-defineEnvironment("aligned", {
-}, function(context) {
- var res = {
- type: "array",
- cols: [],
- };
- res = parseArray(context.parser, res);
- var emptyGroup = new ParseNode("ordgroup", [], context.mode);
- var numCols = 0;
- res.value.body.forEach(function(row) {
- var i;
- for (i = 1; i < row.length; i += 2) {
- row[i].value.unshift(emptyGroup);
- }
- if (numCols < row.length) {
- numCols = row.length;
- }
- });
- for (var i = 0; i < numCols; ++i) {
- var align = "r";
- var pregap = 0;
- if (i % 2 === 1) {
- align = "l";
- } else if (i > 0) {
- pregap = 2; // one \qquad between columns
- }
- res.value.cols[i] = {
- type: "align",
- align: align,
- pregap: pregap,
- postgap: 0,
- };
- }
- return res;
-});
-
-},{"./ParseError":6,"./fontMetrics":17,"./parseData":21}],17:[function(require,module,exports){
-/* eslint no-unused-vars:0 */
-
-var Style = require("./Style");
-var cjkRegex = require("./unicodeRegexes").cjkRegex;
-
-/**
- * This file contains metrics regarding fonts and individual symbols. The sigma
- * and xi variables, as well as the metricMap map contain data extracted from
- * TeX, TeX font metrics, and the TTF files. These data are then exposed via the
- * `metrics` variable and the getCharacterMetrics function.
- */
-
-// These font metrics are extracted from TeX by using
-// \font\a=cmmi10
-// \showthe\fontdimenX\a
-// where X is the corresponding variable number. These correspond to the font
-// parameters of the symbol fonts. In TeX, there are actually three sets of
-// dimensions, one for each of textstyle, scriptstyle, and scriptscriptstyle,
-// but we only use the textstyle ones, and scale certain dimensions accordingly.
-// See the TeXbook, page 441.
-var sigma1 = 0.025;
-var sigma2 = 0;
-var sigma3 = 0;
-var sigma4 = 0;
-var sigma5 = 0.431;
-var sigma6 = 1;
-var sigma7 = 0;
-var sigma8 = 0.677;
-var sigma9 = 0.394;
-var sigma10 = 0.444;
-var sigma11 = 0.686;
-var sigma12 = 0.345;
-var sigma13 = 0.413;
-var sigma14 = 0.363;
-var sigma15 = 0.289;
-var sigma16 = 0.150;
-var sigma17 = 0.247;
-var sigma18 = 0.386;
-var sigma19 = 0.050;
-var sigma20 = 2.390;
-var sigma21 = 1.01;
-var sigma21Script = 0.81;
-var sigma21ScriptScript = 0.71;
-var sigma22 = 0.250;
-
-// These font metrics are extracted from TeX by using
-// \font\a=cmex10
-// \showthe\fontdimenX\a
-// where X is the corresponding variable number. These correspond to the font
-// parameters of the extension fonts (family 3). See the TeXbook, page 441.
-var xi1 = 0;
-var xi2 = 0;
-var xi3 = 0;
-var xi4 = 0;
-var xi5 = 0.431;
-var xi6 = 1;
-var xi7 = 0;
-var xi8 = 0.04;
-var xi9 = 0.111;
-var xi10 = 0.166;
-var xi11 = 0.2;
-var xi12 = 0.6;
-var xi13 = 0.1;
-
-// This value determines how large a pt is, for metrics which are defined in
-// terms of pts.
-// This value is also used in katex.less; if you change it make sure the values
-// match.
-var ptPerEm = 10.0;
-
-// The space between adjacent `|` columns in an array definition. From
-// `\showthe\doublerulesep` in LaTeX.
-var doubleRuleSep = 2.0 / ptPerEm;
-
-/**
- * This is just a mapping from common names to real metrics
- */
-var metrics = {
- xHeight: sigma5,
- quad: sigma6,
- num1: sigma8,
- num2: sigma9,
- num3: sigma10,
- denom1: sigma11,
- denom2: sigma12,
- sup1: sigma13,
- sup2: sigma14,
- sup3: sigma15,
- sub1: sigma16,
- sub2: sigma17,
- supDrop: sigma18,
- subDrop: sigma19,
- axisHeight: sigma22,
- defaultRuleThickness: xi8,
- bigOpSpacing1: xi9,
- bigOpSpacing2: xi10,
- bigOpSpacing3: xi11,
- bigOpSpacing4: xi12,
- bigOpSpacing5: xi13,
- ptPerEm: ptPerEm,
- emPerEx: sigma5 / sigma6,
- doubleRuleSep: doubleRuleSep,
-
- // TODO(alpert): Missing parallel structure here. We should probably add
- // style-specific metrics for all of these.
- delim1: sigma20,
- getDelim2: function(style) {
- if (style.size === Style.TEXT.size) {
- return sigma21;
- } else if (style.size === Style.SCRIPT.size) {
- return sigma21Script;
- } else if (style.size === Style.SCRIPTSCRIPT.size) {
- return sigma21ScriptScript;
- }
- throw new Error("Unexpected style size: " + style.size);
- },
-};
-
-// This map contains a mapping from font name and character code to character
-// metrics, including height, depth, italic correction, and skew (kern from the
-// character to the corresponding \skewchar)
-// This map is generated via `make metrics`. It should not be changed manually.
-var metricMap = require("./fontMetricsData");
-
-// These are very rough approximations. We default to Times New Roman which
-// should have Latin-1 and Cyrillic characters, but may not depending on the
-// operating system. The metrics do not account for extra height from the
-// accents. In the case of Cyrillic characters which have both ascenders and
-// descenders we prefer approximations with ascenders, primarily to prevent
-// the fraction bar or root line from intersecting the glyph.
-// TODO(kevinb) allow union of multiple glyph metrics for better accuracy.
-var extraCharacterMap = {
- // Latin-1
- 'À': 'A',
- 'Ã': 'A',
- 'Â': 'A',
- 'Ã': 'A',
- 'Ä': 'A',
- 'Ã…': 'A',
- 'Æ': 'A',
- 'Ç': 'C',
- 'È': 'E',
- 'É': 'E',
- 'Ê': 'E',
- 'Ë': 'E',
- 'Ì': 'I',
- 'Ã': 'I',
- 'ÃŽ': 'I',
- 'Ã': 'I',
- 'Ã': 'D',
- 'Ñ': 'N',
- 'Ã’': 'O',
- 'Ó': 'O',
- 'Ô': 'O',
- 'Õ': 'O',
- 'Ö': 'O',
- 'Ø': 'O',
- 'Ù': 'U',
- 'Ú': 'U',
- 'Û': 'U',
- 'Ü': 'U',
- 'Ã': 'Y',
- 'Þ': 'o',
- 'ß': 'B',
- 'à': 'a',
- 'á': 'a',
- 'â': 'a',
- 'ã': 'a',
- 'ä': 'a',
- 'Ã¥': 'a',
- 'æ': 'a',
- 'ç': 'c',
- 'è': 'e',
- 'é': 'e',
- 'ê': 'e',
- 'ë': 'e',
- 'ì': 'i',
- 'í': 'i',
- 'î': 'i',
- 'ï': 'i',
- 'ð': 'd',
- 'ñ': 'n',
- 'ò': 'o',
- 'ó': 'o',
- 'ô': 'o',
- 'õ': 'o',
- 'ö': 'o',
- 'ø': 'o',
- 'ù': 'u',
- 'ú': 'u',
- 'û': 'u',
- 'ü': 'u',
- 'ý': 'y',
- 'þ': 'o',
- 'ÿ': 'y',
-
- // Cyrillic
- 'Ð': 'A',
- 'Б': 'B',
- 'Ð’': 'B',
- 'Г': 'F',
- 'Д': 'A',
- 'Е': 'E',
- 'Ж': 'K',
- 'З': '3',
- 'И': 'N',
- 'Й': 'N',
- 'К': 'K',
- 'Л': 'N',
- 'М': 'M',
- 'Ð': 'H',
- 'О': 'O',
- 'П': 'N',
- 'Р': 'P',
- 'С': 'C',
- 'Т': 'T',
- 'У': 'y',
- 'Ф': 'O',
- 'Ð¥': 'X',
- 'Ц': 'U',
- 'Ч': 'h',
- 'Ш': 'W',
- 'Щ': 'W',
- 'Ъ': 'B',
- 'Ы': 'X',
- 'Ь': 'B',
- 'Э': '3',
- 'Ю': 'X',
- 'Я': 'R',
- 'а': 'a',
- 'б': 'b',
- 'в': 'a',
- 'г': 'r',
- 'д': 'y',
- 'е': 'e',
- 'ж': 'm',
- 'з': 'e',
- 'и': 'n',
- 'й': 'n',
- 'к': 'n',
- 'л': 'n',
- 'м': 'm',
- 'н': 'n',
- 'о': 'o',
- 'п': 'n',
- 'Ñ€': 'p',
- 'Ñ': 'c',
- 'Ñ‚': 'o',
- 'у': 'y',
- 'Ñ„': 'b',
- 'Ñ…': 'x',
- 'ц': 'n',
- 'ч': 'n',
- 'ш': 'w',
- 'щ': 'w',
- 'ÑŠ': 'a',
- 'Ñ‹': 'm',
- 'ь': 'a',
- 'Ñ': 'e',
- 'ÑŽ': 'm',
- 'Ñ': 'r',
-};
-
-/**
- * This function is a convenience function for looking up information in the
- * metricMap table. It takes a character as a string, and a style.
- *
- * Note: the `width` property may be undefined if fontMetricsData.js wasn't
- * built using `Make extended_metrics`.
- */
-var getCharacterMetrics = function(character, style) {
- var ch = character.charCodeAt(0);
- if (character[0] in extraCharacterMap) {
- ch = extraCharacterMap[character[0]].charCodeAt(0);
- } else if (cjkRegex.test(character[0])) {
- ch = 'M'.charCodeAt(0);
- }
- var metrics = metricMap[style][ch];
- if (metrics) {
- return {
- depth: metrics[0],
- height: metrics[1],
- italic: metrics[2],
- skew: metrics[3],
- width: metrics[4],
- };
- }
-};
-
-module.exports = {
- metrics: metrics,
- getCharacterMetrics: getCharacterMetrics,
-};
-
-},{"./Style":9,"./fontMetricsData":18,"./unicodeRegexes":24}],18:[function(require,module,exports){
-module.exports = {
- "AMS-Regular": {
- "65": [0, 0.68889, 0, 0],
- "66": [0, 0.68889, 0, 0],
- "67": [0, 0.68889, 0, 0],
- "68": [0, 0.68889, 0, 0],
- "69": [0, 0.68889, 0, 0],
- "70": [0, 0.68889, 0, 0],
- "71": [0, 0.68889, 0, 0],
- "72": [0, 0.68889, 0, 0],
- "73": [0, 0.68889, 0, 0],
- "74": [0.16667, 0.68889, 0, 0],
- "75": [0, 0.68889, 0, 0],
- "76": [0, 0.68889, 0, 0],
- "77": [0, 0.68889, 0, 0],
- "78": [0, 0.68889, 0, 0],
- "79": [0.16667, 0.68889, 0, 0],
- "80": [0, 0.68889, 0, 0],
- "81": [0.16667, 0.68889, 0, 0],
- "82": [0, 0.68889, 0, 0],
- "83": [0, 0.68889, 0, 0],
- "84": [0, 0.68889, 0, 0],
- "85": [0, 0.68889, 0, 0],
- "86": [0, 0.68889, 0, 0],
- "87": [0, 0.68889, 0, 0],
- "88": [0, 0.68889, 0, 0],
- "89": [0, 0.68889, 0, 0],
- "90": [0, 0.68889, 0, 0],
- "107": [0, 0.68889, 0, 0],
- "165": [0, 0.675, 0.025, 0],
- "174": [0.15559, 0.69224, 0, 0],
- "240": [0, 0.68889, 0, 0],
- "295": [0, 0.68889, 0, 0],
- "710": [0, 0.825, 0, 0],
- "732": [0, 0.9, 0, 0],
- "770": [0, 0.825, 0, 0],
- "771": [0, 0.9, 0, 0],
- "989": [0.08167, 0.58167, 0, 0],
- "1008": [0, 0.43056, 0.04028, 0],
- "8245": [0, 0.54986, 0, 0],
- "8463": [0, 0.68889, 0, 0],
- "8487": [0, 0.68889, 0, 0],
- "8498": [0, 0.68889, 0, 0],
- "8502": [0, 0.68889, 0, 0],
- "8503": [0, 0.68889, 0, 0],
- "8504": [0, 0.68889, 0, 0],
- "8513": [0, 0.68889, 0, 0],
- "8592": [-0.03598, 0.46402, 0, 0],
- "8594": [-0.03598, 0.46402, 0, 0],
- "8602": [-0.13313, 0.36687, 0, 0],
- "8603": [-0.13313, 0.36687, 0, 0],
- "8606": [0.01354, 0.52239, 0, 0],
- "8608": [0.01354, 0.52239, 0, 0],
- "8610": [0.01354, 0.52239, 0, 0],
- "8611": [0.01354, 0.52239, 0, 0],
- "8619": [0, 0.54986, 0, 0],
- "8620": [0, 0.54986, 0, 0],
- "8621": [-0.13313, 0.37788, 0, 0],
- "8622": [-0.13313, 0.36687, 0, 0],
- "8624": [0, 0.69224, 0, 0],
- "8625": [0, 0.69224, 0, 0],
- "8630": [0, 0.43056, 0, 0],
- "8631": [0, 0.43056, 0, 0],
- "8634": [0.08198, 0.58198, 0, 0],
- "8635": [0.08198, 0.58198, 0, 0],
- "8638": [0.19444, 0.69224, 0, 0],
- "8639": [0.19444, 0.69224, 0, 0],
- "8642": [0.19444, 0.69224, 0, 0],
- "8643": [0.19444, 0.69224, 0, 0],
- "8644": [0.1808, 0.675, 0, 0],
- "8646": [0.1808, 0.675, 0, 0],
- "8647": [0.1808, 0.675, 0, 0],
- "8648": [0.19444, 0.69224, 0, 0],
- "8649": [0.1808, 0.675, 0, 0],
- "8650": [0.19444, 0.69224, 0, 0],
- "8651": [0.01354, 0.52239, 0, 0],
- "8652": [0.01354, 0.52239, 0, 0],
- "8653": [-0.13313, 0.36687, 0, 0],
- "8654": [-0.13313, 0.36687, 0, 0],
- "8655": [-0.13313, 0.36687, 0, 0],
- "8666": [0.13667, 0.63667, 0, 0],
- "8667": [0.13667, 0.63667, 0, 0],
- "8669": [-0.13313, 0.37788, 0, 0],
- "8672": [-0.064, 0.437, 0, 0],
- "8674": [-0.064, 0.437, 0, 0],
- "8705": [0, 0.825, 0, 0],
- "8708": [0, 0.68889, 0, 0],
- "8709": [0.08167, 0.58167, 0, 0],
- "8717": [0, 0.43056, 0, 0],
- "8722": [-0.03598, 0.46402, 0, 0],
- "8724": [0.08198, 0.69224, 0, 0],
- "8726": [0.08167, 0.58167, 0, 0],
- "8733": [0, 0.69224, 0, 0],
- "8736": [0, 0.69224, 0, 0],
- "8737": [0, 0.69224, 0, 0],
- "8738": [0.03517, 0.52239, 0, 0],
- "8739": [0.08167, 0.58167, 0, 0],
- "8740": [0.25142, 0.74111, 0, 0],
- "8741": [0.08167, 0.58167, 0, 0],
- "8742": [0.25142, 0.74111, 0, 0],
- "8756": [0, 0.69224, 0, 0],
- "8757": [0, 0.69224, 0, 0],
- "8764": [-0.13313, 0.36687, 0, 0],
- "8765": [-0.13313, 0.37788, 0, 0],
- "8769": [-0.13313, 0.36687, 0, 0],
- "8770": [-0.03625, 0.46375, 0, 0],
- "8774": [0.30274, 0.79383, 0, 0],
- "8776": [-0.01688, 0.48312, 0, 0],
- "8778": [0.08167, 0.58167, 0, 0],
- "8782": [0.06062, 0.54986, 0, 0],
- "8783": [0.06062, 0.54986, 0, 0],
- "8785": [0.08198, 0.58198, 0, 0],
- "8786": [0.08198, 0.58198, 0, 0],
- "8787": [0.08198, 0.58198, 0, 0],
- "8790": [0, 0.69224, 0, 0],
- "8791": [0.22958, 0.72958, 0, 0],
- "8796": [0.08198, 0.91667, 0, 0],
- "8806": [0.25583, 0.75583, 0, 0],
- "8807": [0.25583, 0.75583, 0, 0],
- "8808": [0.25142, 0.75726, 0, 0],
- "8809": [0.25142, 0.75726, 0, 0],
- "8812": [0.25583, 0.75583, 0, 0],
- "8814": [0.20576, 0.70576, 0, 0],
- "8815": [0.20576, 0.70576, 0, 0],
- "8816": [0.30274, 0.79383, 0, 0],
- "8817": [0.30274, 0.79383, 0, 0],
- "8818": [0.22958, 0.72958, 0, 0],
- "8819": [0.22958, 0.72958, 0, 0],
- "8822": [0.1808, 0.675, 0, 0],
- "8823": [0.1808, 0.675, 0, 0],
- "8828": [0.13667, 0.63667, 0, 0],
- "8829": [0.13667, 0.63667, 0, 0],
- "8830": [0.22958, 0.72958, 0, 0],
- "8831": [0.22958, 0.72958, 0, 0],
- "8832": [0.20576, 0.70576, 0, 0],
- "8833": [0.20576, 0.70576, 0, 0],
- "8840": [0.30274, 0.79383, 0, 0],
- "8841": [0.30274, 0.79383, 0, 0],
- "8842": [0.13597, 0.63597, 0, 0],
- "8843": [0.13597, 0.63597, 0, 0],
- "8847": [0.03517, 0.54986, 0, 0],
- "8848": [0.03517, 0.54986, 0, 0],
- "8858": [0.08198, 0.58198, 0, 0],
- "8859": [0.08198, 0.58198, 0, 0],
- "8861": [0.08198, 0.58198, 0, 0],
- "8862": [0, 0.675, 0, 0],
- "8863": [0, 0.675, 0, 0],
- "8864": [0, 0.675, 0, 0],
- "8865": [0, 0.675, 0, 0],
- "8872": [0, 0.69224, 0, 0],
- "8873": [0, 0.69224, 0, 0],
- "8874": [0, 0.69224, 0, 0],
- "8876": [0, 0.68889, 0, 0],
- "8877": [0, 0.68889, 0, 0],
- "8878": [0, 0.68889, 0, 0],
- "8879": [0, 0.68889, 0, 0],
- "8882": [0.03517, 0.54986, 0, 0],
- "8883": [0.03517, 0.54986, 0, 0],
- "8884": [0.13667, 0.63667, 0, 0],
- "8885": [0.13667, 0.63667, 0, 0],
- "8888": [0, 0.54986, 0, 0],
- "8890": [0.19444, 0.43056, 0, 0],
- "8891": [0.19444, 0.69224, 0, 0],
- "8892": [0.19444, 0.69224, 0, 0],
- "8901": [0, 0.54986, 0, 0],
- "8903": [0.08167, 0.58167, 0, 0],
- "8905": [0.08167, 0.58167, 0, 0],
- "8906": [0.08167, 0.58167, 0, 0],
- "8907": [0, 0.69224, 0, 0],
- "8908": [0, 0.69224, 0, 0],
- "8909": [-0.03598, 0.46402, 0, 0],
- "8910": [0, 0.54986, 0, 0],
- "8911": [0, 0.54986, 0, 0],
- "8912": [0.03517, 0.54986, 0, 0],
- "8913": [0.03517, 0.54986, 0, 0],
- "8914": [0, 0.54986, 0, 0],
- "8915": [0, 0.54986, 0, 0],
- "8916": [0, 0.69224, 0, 0],
- "8918": [0.0391, 0.5391, 0, 0],
- "8919": [0.0391, 0.5391, 0, 0],
- "8920": [0.03517, 0.54986, 0, 0],
- "8921": [0.03517, 0.54986, 0, 0],
- "8922": [0.38569, 0.88569, 0, 0],
- "8923": [0.38569, 0.88569, 0, 0],
- "8926": [0.13667, 0.63667, 0, 0],
- "8927": [0.13667, 0.63667, 0, 0],
- "8928": [0.30274, 0.79383, 0, 0],
- "8929": [0.30274, 0.79383, 0, 0],
- "8934": [0.23222, 0.74111, 0, 0],
- "8935": [0.23222, 0.74111, 0, 0],
- "8936": [0.23222, 0.74111, 0, 0],
- "8937": [0.23222, 0.74111, 0, 0],
- "8938": [0.20576, 0.70576, 0, 0],
- "8939": [0.20576, 0.70576, 0, 0],
- "8940": [0.30274, 0.79383, 0, 0],
- "8941": [0.30274, 0.79383, 0, 0],
- "8994": [0.19444, 0.69224, 0, 0],
- "8995": [0.19444, 0.69224, 0, 0],
- "9416": [0.15559, 0.69224, 0, 0],
- "9484": [0, 0.69224, 0, 0],
- "9488": [0, 0.69224, 0, 0],
- "9492": [0, 0.37788, 0, 0],
- "9496": [0, 0.37788, 0, 0],
- "9585": [0.19444, 0.68889, 0, 0],
- "9586": [0.19444, 0.74111, 0, 0],
- "9632": [0, 0.675, 0, 0],
- "9633": [0, 0.675, 0, 0],
- "9650": [0, 0.54986, 0, 0],
- "9651": [0, 0.54986, 0, 0],
- "9654": [0.03517, 0.54986, 0, 0],
- "9660": [0, 0.54986, 0, 0],
- "9661": [0, 0.54986, 0, 0],
- "9664": [0.03517, 0.54986, 0, 0],
- "9674": [0.11111, 0.69224, 0, 0],
- "9733": [0.19444, 0.69224, 0, 0],
- "10003": [0, 0.69224, 0, 0],
- "10016": [0, 0.69224, 0, 0],
- "10731": [0.11111, 0.69224, 0, 0],
- "10846": [0.19444, 0.75583, 0, 0],
- "10877": [0.13667, 0.63667, 0, 0],
- "10878": [0.13667, 0.63667, 0, 0],
- "10885": [0.25583, 0.75583, 0, 0],
- "10886": [0.25583, 0.75583, 0, 0],
- "10887": [0.13597, 0.63597, 0, 0],
- "10888": [0.13597, 0.63597, 0, 0],
- "10889": [0.26167, 0.75726, 0, 0],
- "10890": [0.26167, 0.75726, 0, 0],
- "10891": [0.48256, 0.98256, 0, 0],
- "10892": [0.48256, 0.98256, 0, 0],
- "10901": [0.13667, 0.63667, 0, 0],
- "10902": [0.13667, 0.63667, 0, 0],
- "10933": [0.25142, 0.75726, 0, 0],
- "10934": [0.25142, 0.75726, 0, 0],
- "10935": [0.26167, 0.75726, 0, 0],
- "10936": [0.26167, 0.75726, 0, 0],
- "10937": [0.26167, 0.75726, 0, 0],
- "10938": [0.26167, 0.75726, 0, 0],
- "10949": [0.25583, 0.75583, 0, 0],
- "10950": [0.25583, 0.75583, 0, 0],
- "10955": [0.28481, 0.79383, 0, 0],
- "10956": [0.28481, 0.79383, 0, 0],
- "57350": [0.08167, 0.58167, 0, 0],
- "57351": [0.08167, 0.58167, 0, 0],
- "57352": [0.08167, 0.58167, 0, 0],
- "57353": [0, 0.43056, 0.04028, 0],
- "57356": [0.25142, 0.75726, 0, 0],
- "57357": [0.25142, 0.75726, 0, 0],
- "57358": [0.41951, 0.91951, 0, 0],
- "57359": [0.30274, 0.79383, 0, 0],
- "57360": [0.30274, 0.79383, 0, 0],
- "57361": [0.41951, 0.91951, 0, 0],
- "57366": [0.25142, 0.75726, 0, 0],
- "57367": [0.25142, 0.75726, 0, 0],
- "57368": [0.25142, 0.75726, 0, 0],
- "57369": [0.25142, 0.75726, 0, 0],
- "57370": [0.13597, 0.63597, 0, 0],
- "57371": [0.13597, 0.63597, 0, 0],
- },
- "Caligraphic-Regular": {
- "48": [0, 0.43056, 0, 0],
- "49": [0, 0.43056, 0, 0],
- "50": [0, 0.43056, 0, 0],
- "51": [0.19444, 0.43056, 0, 0],
- "52": [0.19444, 0.43056, 0, 0],
- "53": [0.19444, 0.43056, 0, 0],
- "54": [0, 0.64444, 0, 0],
- "55": [0.19444, 0.43056, 0, 0],
- "56": [0, 0.64444, 0, 0],
- "57": [0.19444, 0.43056, 0, 0],
- "65": [0, 0.68333, 0, 0.19445],
- "66": [0, 0.68333, 0.03041, 0.13889],
- "67": [0, 0.68333, 0.05834, 0.13889],
- "68": [0, 0.68333, 0.02778, 0.08334],
- "69": [0, 0.68333, 0.08944, 0.11111],
- "70": [0, 0.68333, 0.09931, 0.11111],
- "71": [0.09722, 0.68333, 0.0593, 0.11111],
- "72": [0, 0.68333, 0.00965, 0.11111],
- "73": [0, 0.68333, 0.07382, 0],
- "74": [0.09722, 0.68333, 0.18472, 0.16667],
- "75": [0, 0.68333, 0.01445, 0.05556],
- "76": [0, 0.68333, 0, 0.13889],
- "77": [0, 0.68333, 0, 0.13889],
- "78": [0, 0.68333, 0.14736, 0.08334],
- "79": [0, 0.68333, 0.02778, 0.11111],
- "80": [0, 0.68333, 0.08222, 0.08334],
- "81": [0.09722, 0.68333, 0, 0.11111],
- "82": [0, 0.68333, 0, 0.08334],
- "83": [0, 0.68333, 0.075, 0.13889],
- "84": [0, 0.68333, 0.25417, 0],
- "85": [0, 0.68333, 0.09931, 0.08334],
- "86": [0, 0.68333, 0.08222, 0],
- "87": [0, 0.68333, 0.08222, 0.08334],
- "88": [0, 0.68333, 0.14643, 0.13889],
- "89": [0.09722, 0.68333, 0.08222, 0.08334],
- "90": [0, 0.68333, 0.07944, 0.13889],
- },
- "Fraktur-Regular": {
- "33": [0, 0.69141, 0, 0],
- "34": [0, 0.69141, 0, 0],
- "38": [0, 0.69141, 0, 0],
- "39": [0, 0.69141, 0, 0],
- "40": [0.24982, 0.74947, 0, 0],
- "41": [0.24982, 0.74947, 0, 0],
- "42": [0, 0.62119, 0, 0],
- "43": [0.08319, 0.58283, 0, 0],
- "44": [0, 0.10803, 0, 0],
- "45": [0.08319, 0.58283, 0, 0],
- "46": [0, 0.10803, 0, 0],
- "47": [0.24982, 0.74947, 0, 0],
- "48": [0, 0.47534, 0, 0],
- "49": [0, 0.47534, 0, 0],
- "50": [0, 0.47534, 0, 0],
- "51": [0.18906, 0.47534, 0, 0],
- "52": [0.18906, 0.47534, 0, 0],
- "53": [0.18906, 0.47534, 0, 0],
- "54": [0, 0.69141, 0, 0],
- "55": [0.18906, 0.47534, 0, 0],
- "56": [0, 0.69141, 0, 0],
- "57": [0.18906, 0.47534, 0, 0],
- "58": [0, 0.47534, 0, 0],
- "59": [0.12604, 0.47534, 0, 0],
- "61": [-0.13099, 0.36866, 0, 0],
- "63": [0, 0.69141, 0, 0],
- "65": [0, 0.69141, 0, 0],
- "66": [0, 0.69141, 0, 0],
- "67": [0, 0.69141, 0, 0],
- "68": [0, 0.69141, 0, 0],
- "69": [0, 0.69141, 0, 0],
- "70": [0.12604, 0.69141, 0, 0],
- "71": [0, 0.69141, 0, 0],
- "72": [0.06302, 0.69141, 0, 0],
- "73": [0, 0.69141, 0, 0],
- "74": [0.12604, 0.69141, 0, 0],
- "75": [0, 0.69141, 0, 0],
- "76": [0, 0.69141, 0, 0],
- "77": [0, 0.69141, 0, 0],
- "78": [0, 0.69141, 0, 0],
- "79": [0, 0.69141, 0, 0],
- "80": [0.18906, 0.69141, 0, 0],
- "81": [0.03781, 0.69141, 0, 0],
- "82": [0, 0.69141, 0, 0],
- "83": [0, 0.69141, 0, 0],
- "84": [0, 0.69141, 0, 0],
- "85": [0, 0.69141, 0, 0],
- "86": [0, 0.69141, 0, 0],
- "87": [0, 0.69141, 0, 0],
- "88": [0, 0.69141, 0, 0],
- "89": [0.18906, 0.69141, 0, 0],
- "90": [0.12604, 0.69141, 0, 0],
- "91": [0.24982, 0.74947, 0, 0],
- "93": [0.24982, 0.74947, 0, 0],
- "94": [0, 0.69141, 0, 0],
- "97": [0, 0.47534, 0, 0],
- "98": [0, 0.69141, 0, 0],
- "99": [0, 0.47534, 0, 0],
- "100": [0, 0.62119, 0, 0],
- "101": [0, 0.47534, 0, 0],
- "102": [0.18906, 0.69141, 0, 0],
- "103": [0.18906, 0.47534, 0, 0],
- "104": [0.18906, 0.69141, 0, 0],
- "105": [0, 0.69141, 0, 0],
- "106": [0, 0.69141, 0, 0],
- "107": [0, 0.69141, 0, 0],
- "108": [0, 0.69141, 0, 0],
- "109": [0, 0.47534, 0, 0],
- "110": [0, 0.47534, 0, 0],
- "111": [0, 0.47534, 0, 0],
- "112": [0.18906, 0.52396, 0, 0],
- "113": [0.18906, 0.47534, 0, 0],
- "114": [0, 0.47534, 0, 0],
- "115": [0, 0.47534, 0, 0],
- "116": [0, 0.62119, 0, 0],
- "117": [0, 0.47534, 0, 0],
- "118": [0, 0.52396, 0, 0],
- "119": [0, 0.52396, 0, 0],
- "120": [0.18906, 0.47534, 0, 0],
- "121": [0.18906, 0.47534, 0, 0],
- "122": [0.18906, 0.47534, 0, 0],
- "8216": [0, 0.69141, 0, 0],
- "8217": [0, 0.69141, 0, 0],
- "58112": [0, 0.62119, 0, 0],
- "58113": [0, 0.62119, 0, 0],
- "58114": [0.18906, 0.69141, 0, 0],
- "58115": [0.18906, 0.69141, 0, 0],
- "58116": [0.18906, 0.47534, 0, 0],
- "58117": [0, 0.69141, 0, 0],
- "58118": [0, 0.62119, 0, 0],
- "58119": [0, 0.47534, 0, 0],
- },
- "Main-Bold": {
- "33": [0, 0.69444, 0, 0],
- "34": [0, 0.69444, 0, 0],
- "35": [0.19444, 0.69444, 0, 0],
- "36": [0.05556, 0.75, 0, 0],
- "37": [0.05556, 0.75, 0, 0],
- "38": [0, 0.69444, 0, 0],
- "39": [0, 0.69444, 0, 0],
- "40": [0.25, 0.75, 0, 0],
- "41": [0.25, 0.75, 0, 0],
- "42": [0, 0.75, 0, 0],
- "43": [0.13333, 0.63333, 0, 0],
- "44": [0.19444, 0.15556, 0, 0],
- "45": [0, 0.44444, 0, 0],
- "46": [0, 0.15556, 0, 0],
- "47": [0.25, 0.75, 0, 0],
- "48": [0, 0.64444, 0, 0],
- "49": [0, 0.64444, 0, 0],
- "50": [0, 0.64444, 0, 0],
- "51": [0, 0.64444, 0, 0],
- "52": [0, 0.64444, 0, 0],
- "53": [0, 0.64444, 0, 0],
- "54": [0, 0.64444, 0, 0],
- "55": [0, 0.64444, 0, 0],
- "56": [0, 0.64444, 0, 0],
- "57": [0, 0.64444, 0, 0],
- "58": [0, 0.44444, 0, 0],
- "59": [0.19444, 0.44444, 0, 0],
- "60": [0.08556, 0.58556, 0, 0],
- "61": [-0.10889, 0.39111, 0, 0],
- "62": [0.08556, 0.58556, 0, 0],
- "63": [0, 0.69444, 0, 0],
- "64": [0, 0.69444, 0, 0],
- "65": [0, 0.68611, 0, 0],
- "66": [0, 0.68611, 0, 0],
- "67": [0, 0.68611, 0, 0],
- "68": [0, 0.68611, 0, 0],
- "69": [0, 0.68611, 0, 0],
- "70": [0, 0.68611, 0, 0],
- "71": [0, 0.68611, 0, 0],
- "72": [0, 0.68611, 0, 0],
- "73": [0, 0.68611, 0, 0],
- "74": [0, 0.68611, 0, 0],
- "75": [0, 0.68611, 0, 0],
- "76": [0, 0.68611, 0, 0],
- "77": [0, 0.68611, 0, 0],
- "78": [0, 0.68611, 0, 0],
- "79": [0, 0.68611, 0, 0],
- "80": [0, 0.68611, 0, 0],
- "81": [0.19444, 0.68611, 0, 0],
- "82": [0, 0.68611, 0, 0],
- "83": [0, 0.68611, 0, 0],
- "84": [0, 0.68611, 0, 0],
- "85": [0, 0.68611, 0, 0],
- "86": [0, 0.68611, 0.01597, 0],
- "87": [0, 0.68611, 0.01597, 0],
- "88": [0, 0.68611, 0, 0],
- "89": [0, 0.68611, 0.02875, 0],
- "90": [0, 0.68611, 0, 0],
- "91": [0.25, 0.75, 0, 0],
- "92": [0.25, 0.75, 0, 0],
- "93": [0.25, 0.75, 0, 0],
- "94": [0, 0.69444, 0, 0],
- "95": [0.31, 0.13444, 0.03194, 0],
- "96": [0, 0.69444, 0, 0],
- "97": [0, 0.44444, 0, 0],
- "98": [0, 0.69444, 0, 0],
- "99": [0, 0.44444, 0, 0],
- "100": [0, 0.69444, 0, 0],
- "101": [0, 0.44444, 0, 0],
- "102": [0, 0.69444, 0.10903, 0],
- "103": [0.19444, 0.44444, 0.01597, 0],
- "104": [0, 0.69444, 0, 0],
- "105": [0, 0.69444, 0, 0],
- "106": [0.19444, 0.69444, 0, 0],
- "107": [0, 0.69444, 0, 0],
- "108": [0, 0.69444, 0, 0],
- "109": [0, 0.44444, 0, 0],
- "110": [0, 0.44444, 0, 0],
- "111": [0, 0.44444, 0, 0],
- "112": [0.19444, 0.44444, 0, 0],
- "113": [0.19444, 0.44444, 0, 0],
- "114": [0, 0.44444, 0, 0],
- "115": [0, 0.44444, 0, 0],
- "116": [0, 0.63492, 0, 0],
- "117": [0, 0.44444, 0, 0],
- "118": [0, 0.44444, 0.01597, 0],
- "119": [0, 0.44444, 0.01597, 0],
- "120": [0, 0.44444, 0, 0],
- "121": [0.19444, 0.44444, 0.01597, 0],
- "122": [0, 0.44444, 0, 0],
- "123": [0.25, 0.75, 0, 0],
- "124": [0.25, 0.75, 0, 0],
- "125": [0.25, 0.75, 0, 0],
- "126": [0.35, 0.34444, 0, 0],
- "168": [0, 0.69444, 0, 0],
- "172": [0, 0.44444, 0, 0],
- "175": [0, 0.59611, 0, 0],
- "176": [0, 0.69444, 0, 0],
- "177": [0.13333, 0.63333, 0, 0],
- "180": [0, 0.69444, 0, 0],
- "215": [0.13333, 0.63333, 0, 0],
- "247": [0.13333, 0.63333, 0, 0],
- "305": [0, 0.44444, 0, 0],
- "567": [0.19444, 0.44444, 0, 0],
- "710": [0, 0.69444, 0, 0],
- "711": [0, 0.63194, 0, 0],
- "713": [0, 0.59611, 0, 0],
- "714": [0, 0.69444, 0, 0],
- "715": [0, 0.69444, 0, 0],
- "728": [0, 0.69444, 0, 0],
- "729": [0, 0.69444, 0, 0],
- "730": [0, 0.69444, 0, 0],
- "732": [0, 0.69444, 0, 0],
- "768": [0, 0.69444, 0, 0],
- "769": [0, 0.69444, 0, 0],
- "770": [0, 0.69444, 0, 0],
- "771": [0, 0.69444, 0, 0],
- "772": [0, 0.59611, 0, 0],
- "774": [0, 0.69444, 0, 0],
- "775": [0, 0.69444, 0, 0],
- "776": [0, 0.69444, 0, 0],
- "778": [0, 0.69444, 0, 0],
- "779": [0, 0.69444, 0, 0],
- "780": [0, 0.63194, 0, 0],
- "824": [0.19444, 0.69444, 0, 0],
- "915": [0, 0.68611, 0, 0],
- "916": [0, 0.68611, 0, 0],
- "920": [0, 0.68611, 0, 0],
- "923": [0, 0.68611, 0, 0],
- "926": [0, 0.68611, 0, 0],
- "928": [0, 0.68611, 0, 0],
- "931": [0, 0.68611, 0, 0],
- "933": [0, 0.68611, 0, 0],
- "934": [0, 0.68611, 0, 0],
- "936": [0, 0.68611, 0, 0],
- "937": [0, 0.68611, 0, 0],
- "8211": [0, 0.44444, 0.03194, 0],
- "8212": [0, 0.44444, 0.03194, 0],
- "8216": [0, 0.69444, 0, 0],
- "8217": [0, 0.69444, 0, 0],
- "8220": [0, 0.69444, 0, 0],
- "8221": [0, 0.69444, 0, 0],
- "8224": [0.19444, 0.69444, 0, 0],
- "8225": [0.19444, 0.69444, 0, 0],
- "8242": [0, 0.55556, 0, 0],
- "8407": [0, 0.72444, 0.15486, 0],
- "8463": [0, 0.69444, 0, 0],
- "8465": [0, 0.69444, 0, 0],
- "8467": [0, 0.69444, 0, 0],
- "8472": [0.19444, 0.44444, 0, 0],
- "8476": [0, 0.69444, 0, 0],
- "8501": [0, 0.69444, 0, 0],
- "8592": [-0.10889, 0.39111, 0, 0],
- "8593": [0.19444, 0.69444, 0, 0],
- "8594": [-0.10889, 0.39111, 0, 0],
- "8595": [0.19444, 0.69444, 0, 0],
- "8596": [-0.10889, 0.39111, 0, 0],
- "8597": [0.25, 0.75, 0, 0],
- "8598": [0.19444, 0.69444, 0, 0],
- "8599": [0.19444, 0.69444, 0, 0],
- "8600": [0.19444, 0.69444, 0, 0],
- "8601": [0.19444, 0.69444, 0, 0],
- "8636": [-0.10889, 0.39111, 0, 0],
- "8637": [-0.10889, 0.39111, 0, 0],
- "8640": [-0.10889, 0.39111, 0, 0],
- "8641": [-0.10889, 0.39111, 0, 0],
- "8656": [-0.10889, 0.39111, 0, 0],
- "8657": [0.19444, 0.69444, 0, 0],
- "8658": [-0.10889, 0.39111, 0, 0],
- "8659": [0.19444, 0.69444, 0, 0],
- "8660": [-0.10889, 0.39111, 0, 0],
- "8661": [0.25, 0.75, 0, 0],
- "8704": [0, 0.69444, 0, 0],
- "8706": [0, 0.69444, 0.06389, 0],
- "8707": [0, 0.69444, 0, 0],
- "8709": [0.05556, 0.75, 0, 0],
- "8711": [0, 0.68611, 0, 0],
- "8712": [0.08556, 0.58556, 0, 0],
- "8715": [0.08556, 0.58556, 0, 0],
- "8722": [0.13333, 0.63333, 0, 0],
- "8723": [0.13333, 0.63333, 0, 0],
- "8725": [0.25, 0.75, 0, 0],
- "8726": [0.25, 0.75, 0, 0],
- "8727": [-0.02778, 0.47222, 0, 0],
- "8728": [-0.02639, 0.47361, 0, 0],
- "8729": [-0.02639, 0.47361, 0, 0],
- "8730": [0.18, 0.82, 0, 0],
- "8733": [0, 0.44444, 0, 0],
- "8734": [0, 0.44444, 0, 0],
- "8736": [0, 0.69224, 0, 0],
- "8739": [0.25, 0.75, 0, 0],
- "8741": [0.25, 0.75, 0, 0],
- "8743": [0, 0.55556, 0, 0],
- "8744": [0, 0.55556, 0, 0],
- "8745": [0, 0.55556, 0, 0],
- "8746": [0, 0.55556, 0, 0],
- "8747": [0.19444, 0.69444, 0.12778, 0],
- "8764": [-0.10889, 0.39111, 0, 0],
- "8768": [0.19444, 0.69444, 0, 0],
- "8771": [0.00222, 0.50222, 0, 0],
- "8776": [0.02444, 0.52444, 0, 0],
- "8781": [0.00222, 0.50222, 0, 0],
- "8801": [0.00222, 0.50222, 0, 0],
- "8804": [0.19667, 0.69667, 0, 0],
- "8805": [0.19667, 0.69667, 0, 0],
- "8810": [0.08556, 0.58556, 0, 0],
- "8811": [0.08556, 0.58556, 0, 0],
- "8826": [0.08556, 0.58556, 0, 0],
- "8827": [0.08556, 0.58556, 0, 0],
- "8834": [0.08556, 0.58556, 0, 0],
- "8835": [0.08556, 0.58556, 0, 0],
- "8838": [0.19667, 0.69667, 0, 0],
- "8839": [0.19667, 0.69667, 0, 0],
- "8846": [0, 0.55556, 0, 0],
- "8849": [0.19667, 0.69667, 0, 0],
- "8850": [0.19667, 0.69667, 0, 0],
- "8851": [0, 0.55556, 0, 0],
- "8852": [0, 0.55556, 0, 0],
- "8853": [0.13333, 0.63333, 0, 0],
- "8854": [0.13333, 0.63333, 0, 0],
- "8855": [0.13333, 0.63333, 0, 0],
- "8856": [0.13333, 0.63333, 0, 0],
- "8857": [0.13333, 0.63333, 0, 0],
- "8866": [0, 0.69444, 0, 0],
- "8867": [0, 0.69444, 0, 0],
- "8868": [0, 0.69444, 0, 0],
- "8869": [0, 0.69444, 0, 0],
- "8900": [-0.02639, 0.47361, 0, 0],
- "8901": [-0.02639, 0.47361, 0, 0],
- "8902": [-0.02778, 0.47222, 0, 0],
- "8968": [0.25, 0.75, 0, 0],
- "8969": [0.25, 0.75, 0, 0],
- "8970": [0.25, 0.75, 0, 0],
- "8971": [0.25, 0.75, 0, 0],
- "8994": [-0.13889, 0.36111, 0, 0],
- "8995": [-0.13889, 0.36111, 0, 0],
- "9651": [0.19444, 0.69444, 0, 0],
- "9657": [-0.02778, 0.47222, 0, 0],
- "9661": [0.19444, 0.69444, 0, 0],
- "9667": [-0.02778, 0.47222, 0, 0],
- "9711": [0.19444, 0.69444, 0, 0],
- "9824": [0.12963, 0.69444, 0, 0],
- "9825": [0.12963, 0.69444, 0, 0],
- "9826": [0.12963, 0.69444, 0, 0],
- "9827": [0.12963, 0.69444, 0, 0],
- "9837": [0, 0.75, 0, 0],
- "9838": [0.19444, 0.69444, 0, 0],
- "9839": [0.19444, 0.69444, 0, 0],
- "10216": [0.25, 0.75, 0, 0],
- "10217": [0.25, 0.75, 0, 0],
- "10815": [0, 0.68611, 0, 0],
- "10927": [0.19667, 0.69667, 0, 0],
- "10928": [0.19667, 0.69667, 0, 0],
- },
- "Main-Italic": {
- "33": [0, 0.69444, 0.12417, 0],
- "34": [0, 0.69444, 0.06961, 0],
- "35": [0.19444, 0.69444, 0.06616, 0],
- "37": [0.05556, 0.75, 0.13639, 0],
- "38": [0, 0.69444, 0.09694, 0],
- "39": [0, 0.69444, 0.12417, 0],
- "40": [0.25, 0.75, 0.16194, 0],
- "41": [0.25, 0.75, 0.03694, 0],
- "42": [0, 0.75, 0.14917, 0],
- "43": [0.05667, 0.56167, 0.03694, 0],
- "44": [0.19444, 0.10556, 0, 0],
- "45": [0, 0.43056, 0.02826, 0],
- "46": [0, 0.10556, 0, 0],
- "47": [0.25, 0.75, 0.16194, 0],
- "48": [0, 0.64444, 0.13556, 0],
- "49": [0, 0.64444, 0.13556, 0],
- "50": [0, 0.64444, 0.13556, 0],
- "51": [0, 0.64444, 0.13556, 0],
- "52": [0.19444, 0.64444, 0.13556, 0],
- "53": [0, 0.64444, 0.13556, 0],
- "54": [0, 0.64444, 0.13556, 0],
- "55": [0.19444, 0.64444, 0.13556, 0],
- "56": [0, 0.64444, 0.13556, 0],
- "57": [0, 0.64444, 0.13556, 0],
- "58": [0, 0.43056, 0.0582, 0],
- "59": [0.19444, 0.43056, 0.0582, 0],
- "61": [-0.13313, 0.36687, 0.06616, 0],
- "63": [0, 0.69444, 0.1225, 0],
- "64": [0, 0.69444, 0.09597, 0],
- "65": [0, 0.68333, 0, 0],
- "66": [0, 0.68333, 0.10257, 0],
- "67": [0, 0.68333, 0.14528, 0],
- "68": [0, 0.68333, 0.09403, 0],
- "69": [0, 0.68333, 0.12028, 0],
- "70": [0, 0.68333, 0.13305, 0],
- "71": [0, 0.68333, 0.08722, 0],
- "72": [0, 0.68333, 0.16389, 0],
- "73": [0, 0.68333, 0.15806, 0],
- "74": [0, 0.68333, 0.14028, 0],
- "75": [0, 0.68333, 0.14528, 0],
- "76": [0, 0.68333, 0, 0],
- "77": [0, 0.68333, 0.16389, 0],
- "78": [0, 0.68333, 0.16389, 0],
- "79": [0, 0.68333, 0.09403, 0],
- "80": [0, 0.68333, 0.10257, 0],
- "81": [0.19444, 0.68333, 0.09403, 0],
- "82": [0, 0.68333, 0.03868, 0],
- "83": [0, 0.68333, 0.11972, 0],
- "84": [0, 0.68333, 0.13305, 0],
- "85": [0, 0.68333, 0.16389, 0],
- "86": [0, 0.68333, 0.18361, 0],
- "87": [0, 0.68333, 0.18361, 0],
- "88": [0, 0.68333, 0.15806, 0],
- "89": [0, 0.68333, 0.19383, 0],
- "90": [0, 0.68333, 0.14528, 0],
- "91": [0.25, 0.75, 0.1875, 0],
- "93": [0.25, 0.75, 0.10528, 0],
- "94": [0, 0.69444, 0.06646, 0],
- "95": [0.31, 0.12056, 0.09208, 0],
- "97": [0, 0.43056, 0.07671, 0],
- "98": [0, 0.69444, 0.06312, 0],
- "99": [0, 0.43056, 0.05653, 0],
- "100": [0, 0.69444, 0.10333, 0],
- "101": [0, 0.43056, 0.07514, 0],
- "102": [0.19444, 0.69444, 0.21194, 0],
- "103": [0.19444, 0.43056, 0.08847, 0],
- "104": [0, 0.69444, 0.07671, 0],
- "105": [0, 0.65536, 0.1019, 0],
- "106": [0.19444, 0.65536, 0.14467, 0],
- "107": [0, 0.69444, 0.10764, 0],
- "108": [0, 0.69444, 0.10333, 0],
- "109": [0, 0.43056, 0.07671, 0],
- "110": [0, 0.43056, 0.07671, 0],
- "111": [0, 0.43056, 0.06312, 0],
- "112": [0.19444, 0.43056, 0.06312, 0],
- "113": [0.19444, 0.43056, 0.08847, 0],
- "114": [0, 0.43056, 0.10764, 0],
- "115": [0, 0.43056, 0.08208, 0],
- "116": [0, 0.61508, 0.09486, 0],
- "117": [0, 0.43056, 0.07671, 0],
- "118": [0, 0.43056, 0.10764, 0],
- "119": [0, 0.43056, 0.10764, 0],
- "120": [0, 0.43056, 0.12042, 0],
- "121": [0.19444, 0.43056, 0.08847, 0],
- "122": [0, 0.43056, 0.12292, 0],
- "126": [0.35, 0.31786, 0.11585, 0],
- "163": [0, 0.69444, 0, 0],
- "305": [0, 0.43056, 0, 0.02778],
- "567": [0.19444, 0.43056, 0, 0.08334],
- "768": [0, 0.69444, 0, 0],
- "769": [0, 0.69444, 0.09694, 0],
- "770": [0, 0.69444, 0.06646, 0],
- "771": [0, 0.66786, 0.11585, 0],
- "772": [0, 0.56167, 0.10333, 0],
- "774": [0, 0.69444, 0.10806, 0],
- "775": [0, 0.66786, 0.11752, 0],
- "776": [0, 0.66786, 0.10474, 0],
- "778": [0, 0.69444, 0, 0],
- "779": [0, 0.69444, 0.1225, 0],
- "780": [0, 0.62847, 0.08295, 0],
- "915": [0, 0.68333, 0.13305, 0],
- "916": [0, 0.68333, 0, 0],
- "920": [0, 0.68333, 0.09403, 0],
- "923": [0, 0.68333, 0, 0],
- "926": [0, 0.68333, 0.15294, 0],
- "928": [0, 0.68333, 0.16389, 0],
- "931": [0, 0.68333, 0.12028, 0],
- "933": [0, 0.68333, 0.11111, 0],
- "934": [0, 0.68333, 0.05986, 0],
- "936": [0, 0.68333, 0.11111, 0],
- "937": [0, 0.68333, 0.10257, 0],
- "8211": [0, 0.43056, 0.09208, 0],
- "8212": [0, 0.43056, 0.09208, 0],
- "8216": [0, 0.69444, 0.12417, 0],
- "8217": [0, 0.69444, 0.12417, 0],
- "8220": [0, 0.69444, 0.1685, 0],
- "8221": [0, 0.69444, 0.06961, 0],
- "8463": [0, 0.68889, 0, 0],
- },
- "Main-Regular": {
- "32": [0, 0, 0, 0],
- "33": [0, 0.69444, 0, 0],
- "34": [0, 0.69444, 0, 0],
- "35": [0.19444, 0.69444, 0, 0],
- "36": [0.05556, 0.75, 0, 0],
- "37": [0.05556, 0.75, 0, 0],
- "38": [0, 0.69444, 0, 0],
- "39": [0, 0.69444, 0, 0],
- "40": [0.25, 0.75, 0, 0],
- "41": [0.25, 0.75, 0, 0],
- "42": [0, 0.75, 0, 0],
- "43": [0.08333, 0.58333, 0, 0],
- "44": [0.19444, 0.10556, 0, 0],
- "45": [0, 0.43056, 0, 0],
- "46": [0, 0.10556, 0, 0],
- "47": [0.25, 0.75, 0, 0],
- "48": [0, 0.64444, 0, 0],
- "49": [0, 0.64444, 0, 0],
- "50": [0, 0.64444, 0, 0],
- "51": [0, 0.64444, 0, 0],
- "52": [0, 0.64444, 0, 0],
- "53": [0, 0.64444, 0, 0],
- "54": [0, 0.64444, 0, 0],
- "55": [0, 0.64444, 0, 0],
- "56": [0, 0.64444, 0, 0],
- "57": [0, 0.64444, 0, 0],
- "58": [0, 0.43056, 0, 0],
- "59": [0.19444, 0.43056, 0, 0],
- "60": [0.0391, 0.5391, 0, 0],
- "61": [-0.13313, 0.36687, 0, 0],
- "62": [0.0391, 0.5391, 0, 0],
- "63": [0, 0.69444, 0, 0],
- "64": [0, 0.69444, 0, 0],
- "65": [0, 0.68333, 0, 0],
- "66": [0, 0.68333, 0, 0],
- "67": [0, 0.68333, 0, 0],
- "68": [0, 0.68333, 0, 0],
- "69": [0, 0.68333, 0, 0],
- "70": [0, 0.68333, 0, 0],
- "71": [0, 0.68333, 0, 0],
- "72": [0, 0.68333, 0, 0],
- "73": [0, 0.68333, 0, 0],
- "74": [0, 0.68333, 0, 0],
- "75": [0, 0.68333, 0, 0],
- "76": [0, 0.68333, 0, 0],
- "77": [0, 0.68333, 0, 0],
- "78": [0, 0.68333, 0, 0],
- "79": [0, 0.68333, 0, 0],
- "80": [0, 0.68333, 0, 0],
- "81": [0.19444, 0.68333, 0, 0],
- "82": [0, 0.68333, 0, 0],
- "83": [0, 0.68333, 0, 0],
- "84": [0, 0.68333, 0, 0],
- "85": [0, 0.68333, 0, 0],
- "86": [0, 0.68333, 0.01389, 0],
- "87": [0, 0.68333, 0.01389, 0],
- "88": [0, 0.68333, 0, 0],
- "89": [0, 0.68333, 0.025, 0],
- "90": [0, 0.68333, 0, 0],
- "91": [0.25, 0.75, 0, 0],
- "92": [0.25, 0.75, 0, 0],
- "93": [0.25, 0.75, 0, 0],
- "94": [0, 0.69444, 0, 0],
- "95": [0.31, 0.12056, 0.02778, 0],
- "96": [0, 0.69444, 0, 0],
- "97": [0, 0.43056, 0, 0],
- "98": [0, 0.69444, 0, 0],
- "99": [0, 0.43056, 0, 0],
- "100": [0, 0.69444, 0, 0],
- "101": [0, 0.43056, 0, 0],
- "102": [0, 0.69444, 0.07778, 0],
- "103": [0.19444, 0.43056, 0.01389, 0],
- "104": [0, 0.69444, 0, 0],
- "105": [0, 0.66786, 0, 0],
- "106": [0.19444, 0.66786, 0, 0],
- "107": [0, 0.69444, 0, 0],
- "108": [0, 0.69444, 0, 0],
- "109": [0, 0.43056, 0, 0],
- "110": [0, 0.43056, 0, 0],
- "111": [0, 0.43056, 0, 0],
- "112": [0.19444, 0.43056, 0, 0],
- "113": [0.19444, 0.43056, 0, 0],
- "114": [0, 0.43056, 0, 0],
- "115": [0, 0.43056, 0, 0],
- "116": [0, 0.61508, 0, 0],
- "117": [0, 0.43056, 0, 0],
- "118": [0, 0.43056, 0.01389, 0],
- "119": [0, 0.43056, 0.01389, 0],
- "120": [0, 0.43056, 0, 0],
- "121": [0.19444, 0.43056, 0.01389, 0],
- "122": [0, 0.43056, 0, 0],
- "123": [0.25, 0.75, 0, 0],
- "124": [0.25, 0.75, 0, 0],
- "125": [0.25, 0.75, 0, 0],
- "126": [0.35, 0.31786, 0, 0],
- "160": [0, 0, 0, 0],
- "168": [0, 0.66786, 0, 0],
- "172": [0, 0.43056, 0, 0],
- "175": [0, 0.56778, 0, 0],
- "176": [0, 0.69444, 0, 0],
- "177": [0.08333, 0.58333, 0, 0],
- "180": [0, 0.69444, 0, 0],
- "215": [0.08333, 0.58333, 0, 0],
- "247": [0.08333, 0.58333, 0, 0],
- "305": [0, 0.43056, 0, 0],
- "567": [0.19444, 0.43056, 0, 0],
- "710": [0, 0.69444, 0, 0],
- "711": [0, 0.62847, 0, 0],
- "713": [0, 0.56778, 0, 0],
- "714": [0, 0.69444, 0, 0],
- "715": [0, 0.69444, 0, 0],
- "728": [0, 0.69444, 0, 0],
- "729": [0, 0.66786, 0, 0],
- "730": [0, 0.69444, 0, 0],
- "732": [0, 0.66786, 0, 0],
- "768": [0, 0.69444, 0, 0],
- "769": [0, 0.69444, 0, 0],
- "770": [0, 0.69444, 0, 0],
- "771": [0, 0.66786, 0, 0],
- "772": [0, 0.56778, 0, 0],
- "774": [0, 0.69444, 0, 0],
- "775": [0, 0.66786, 0, 0],
- "776": [0, 0.66786, 0, 0],
- "778": [0, 0.69444, 0, 0],
- "779": [0, 0.69444, 0, 0],
- "780": [0, 0.62847, 0, 0],
- "824": [0.19444, 0.69444, 0, 0],
- "915": [0, 0.68333, 0, 0],
- "916": [0, 0.68333, 0, 0],
- "920": [0, 0.68333, 0, 0],
- "923": [0, 0.68333, 0, 0],
- "926": [0, 0.68333, 0, 0],
- "928": [0, 0.68333, 0, 0],
- "931": [0, 0.68333, 0, 0],
- "933": [0, 0.68333, 0, 0],
- "934": [0, 0.68333, 0, 0],
- "936": [0, 0.68333, 0, 0],
- "937": [0, 0.68333, 0, 0],
- "8211": [0, 0.43056, 0.02778, 0],
- "8212": [0, 0.43056, 0.02778, 0],
- "8216": [0, 0.69444, 0, 0],
- "8217": [0, 0.69444, 0, 0],
- "8220": [0, 0.69444, 0, 0],
- "8221": [0, 0.69444, 0, 0],
- "8224": [0.19444, 0.69444, 0, 0],
- "8225": [0.19444, 0.69444, 0, 0],
- "8230": [0, 0.12, 0, 0],
- "8242": [0, 0.55556, 0, 0],
- "8407": [0, 0.71444, 0.15382, 0],
- "8463": [0, 0.68889, 0, 0],
- "8465": [0, 0.69444, 0, 0],
- "8467": [0, 0.69444, 0, 0.11111],
- "8472": [0.19444, 0.43056, 0, 0.11111],
- "8476": [0, 0.69444, 0, 0],
- "8501": [0, 0.69444, 0, 0],
- "8592": [-0.13313, 0.36687, 0, 0],
- "8593": [0.19444, 0.69444, 0, 0],
- "8594": [-0.13313, 0.36687, 0, 0],
- "8595": [0.19444, 0.69444, 0, 0],
- "8596": [-0.13313, 0.36687, 0, 0],
- "8597": [0.25, 0.75, 0, 0],
- "8598": [0.19444, 0.69444, 0, 0],
- "8599": [0.19444, 0.69444, 0, 0],
- "8600": [0.19444, 0.69444, 0, 0],
- "8601": [0.19444, 0.69444, 0, 0],
- "8614": [0.011, 0.511, 0, 0],
- "8617": [0.011, 0.511, 0, 0],
- "8618": [0.011, 0.511, 0, 0],
- "8636": [-0.13313, 0.36687, 0, 0],
- "8637": [-0.13313, 0.36687, 0, 0],
- "8640": [-0.13313, 0.36687, 0, 0],
- "8641": [-0.13313, 0.36687, 0, 0],
- "8652": [0.011, 0.671, 0, 0],
- "8656": [-0.13313, 0.36687, 0, 0],
- "8657": [0.19444, 0.69444, 0, 0],
- "8658": [-0.13313, 0.36687, 0, 0],
- "8659": [0.19444, 0.69444, 0, 0],
- "8660": [-0.13313, 0.36687, 0, 0],
- "8661": [0.25, 0.75, 0, 0],
- "8704": [0, 0.69444, 0, 0],
- "8706": [0, 0.69444, 0.05556, 0.08334],
- "8707": [0, 0.69444, 0, 0],
- "8709": [0.05556, 0.75, 0, 0],
- "8711": [0, 0.68333, 0, 0],
- "8712": [0.0391, 0.5391, 0, 0],
- "8715": [0.0391, 0.5391, 0, 0],
- "8722": [0.08333, 0.58333, 0, 0],
- "8723": [0.08333, 0.58333, 0, 0],
- "8725": [0.25, 0.75, 0, 0],
- "8726": [0.25, 0.75, 0, 0],
- "8727": [-0.03472, 0.46528, 0, 0],
- "8728": [-0.05555, 0.44445, 0, 0],
- "8729": [-0.05555, 0.44445, 0, 0],
- "8730": [0.2, 0.8, 0, 0],
- "8733": [0, 0.43056, 0, 0],
- "8734": [0, 0.43056, 0, 0],
- "8736": [0, 0.69224, 0, 0],
- "8739": [0.25, 0.75, 0, 0],
- "8741": [0.25, 0.75, 0, 0],
- "8743": [0, 0.55556, 0, 0],
- "8744": [0, 0.55556, 0, 0],
- "8745": [0, 0.55556, 0, 0],
- "8746": [0, 0.55556, 0, 0],
- "8747": [0.19444, 0.69444, 0.11111, 0],
- "8764": [-0.13313, 0.36687, 0, 0],
- "8768": [0.19444, 0.69444, 0, 0],
- "8771": [-0.03625, 0.46375, 0, 0],
- "8773": [-0.022, 0.589, 0, 0],
- "8776": [-0.01688, 0.48312, 0, 0],
- "8781": [-0.03625, 0.46375, 0, 0],
- "8784": [-0.133, 0.67, 0, 0],
- "8800": [0.215, 0.716, 0, 0],
- "8801": [-0.03625, 0.46375, 0, 0],
- "8804": [0.13597, 0.63597, 0, 0],
- "8805": [0.13597, 0.63597, 0, 0],
- "8810": [0.0391, 0.5391, 0, 0],
- "8811": [0.0391, 0.5391, 0, 0],
- "8826": [0.0391, 0.5391, 0, 0],
- "8827": [0.0391, 0.5391, 0, 0],
- "8834": [0.0391, 0.5391, 0, 0],
- "8835": [0.0391, 0.5391, 0, 0],
- "8838": [0.13597, 0.63597, 0, 0],
- "8839": [0.13597, 0.63597, 0, 0],
- "8846": [0, 0.55556, 0, 0],
- "8849": [0.13597, 0.63597, 0, 0],
- "8850": [0.13597, 0.63597, 0, 0],
- "8851": [0, 0.55556, 0, 0],
- "8852": [0, 0.55556, 0, 0],
- "8853": [0.08333, 0.58333, 0, 0],
- "8854": [0.08333, 0.58333, 0, 0],
- "8855": [0.08333, 0.58333, 0, 0],
- "8856": [0.08333, 0.58333, 0, 0],
- "8857": [0.08333, 0.58333, 0, 0],
- "8866": [0, 0.69444, 0, 0],
- "8867": [0, 0.69444, 0, 0],
- "8868": [0, 0.69444, 0, 0],
- "8869": [0, 0.69444, 0, 0],
- "8872": [0.249, 0.75, 0, 0],
- "8900": [-0.05555, 0.44445, 0, 0],
- "8901": [-0.05555, 0.44445, 0, 0],
- "8902": [-0.03472, 0.46528, 0, 0],
- "8904": [0.005, 0.505, 0, 0],
- "8942": [0.03, 0.9, 0, 0],
- "8943": [-0.19, 0.31, 0, 0],
- "8945": [-0.1, 0.82, 0, 0],
- "8968": [0.25, 0.75, 0, 0],
- "8969": [0.25, 0.75, 0, 0],
- "8970": [0.25, 0.75, 0, 0],
- "8971": [0.25, 0.75, 0, 0],
- "8994": [-0.14236, 0.35764, 0, 0],
- "8995": [-0.14236, 0.35764, 0, 0],
- "9136": [0.244, 0.744, 0, 0],
- "9137": [0.244, 0.744, 0, 0],
- "9651": [0.19444, 0.69444, 0, 0],
- "9657": [-0.03472, 0.46528, 0, 0],
- "9661": [0.19444, 0.69444, 0, 0],
- "9667": [-0.03472, 0.46528, 0, 0],
- "9711": [0.19444, 0.69444, 0, 0],
- "9824": [0.12963, 0.69444, 0, 0],
- "9825": [0.12963, 0.69444, 0, 0],
- "9826": [0.12963, 0.69444, 0, 0],
- "9827": [0.12963, 0.69444, 0, 0],
- "9837": [0, 0.75, 0, 0],
- "9838": [0.19444, 0.69444, 0, 0],
- "9839": [0.19444, 0.69444, 0, 0],
- "10216": [0.25, 0.75, 0, 0],
- "10217": [0.25, 0.75, 0, 0],
- "10222": [0.244, 0.744, 0, 0],
- "10223": [0.244, 0.744, 0, 0],
- "10229": [0.011, 0.511, 0, 0],
- "10230": [0.011, 0.511, 0, 0],
- "10231": [0.011, 0.511, 0, 0],
- "10232": [0.024, 0.525, 0, 0],
- "10233": [0.024, 0.525, 0, 0],
- "10234": [0.024, 0.525, 0, 0],
- "10236": [0.011, 0.511, 0, 0],
- "10815": [0, 0.68333, 0, 0],
- "10927": [0.13597, 0.63597, 0, 0],
- "10928": [0.13597, 0.63597, 0, 0],
- },
- "Math-BoldItalic": {
- "47": [0.19444, 0.69444, 0, 0],
- "65": [0, 0.68611, 0, 0],
- "66": [0, 0.68611, 0.04835, 0],
- "67": [0, 0.68611, 0.06979, 0],
- "68": [0, 0.68611, 0.03194, 0],
- "69": [0, 0.68611, 0.05451, 0],
- "70": [0, 0.68611, 0.15972, 0],
- "71": [0, 0.68611, 0, 0],
- "72": [0, 0.68611, 0.08229, 0],
- "73": [0, 0.68611, 0.07778, 0],
- "74": [0, 0.68611, 0.10069, 0],
- "75": [0, 0.68611, 0.06979, 0],
- "76": [0, 0.68611, 0, 0],
- "77": [0, 0.68611, 0.11424, 0],
- "78": [0, 0.68611, 0.11424, 0],
- "79": [0, 0.68611, 0.03194, 0],
- "80": [0, 0.68611, 0.15972, 0],
- "81": [0.19444, 0.68611, 0, 0],
- "82": [0, 0.68611, 0.00421, 0],
- "83": [0, 0.68611, 0.05382, 0],
- "84": [0, 0.68611, 0.15972, 0],
- "85": [0, 0.68611, 0.11424, 0],
- "86": [0, 0.68611, 0.25555, 0],
- "87": [0, 0.68611, 0.15972, 0],
- "88": [0, 0.68611, 0.07778, 0],
- "89": [0, 0.68611, 0.25555, 0],
- "90": [0, 0.68611, 0.06979, 0],
- "97": [0, 0.44444, 0, 0],
- "98": [0, 0.69444, 0, 0],
- "99": [0, 0.44444, 0, 0],
- "100": [0, 0.69444, 0, 0],
- "101": [0, 0.44444, 0, 0],
- "102": [0.19444, 0.69444, 0.11042, 0],
- "103": [0.19444, 0.44444, 0.03704, 0],
- "104": [0, 0.69444, 0, 0],
- "105": [0, 0.69326, 0, 0],
- "106": [0.19444, 0.69326, 0.0622, 0],
- "107": [0, 0.69444, 0.01852, 0],
- "108": [0, 0.69444, 0.0088, 0],
- "109": [0, 0.44444, 0, 0],
- "110": [0, 0.44444, 0, 0],
- "111": [0, 0.44444, 0, 0],
- "112": [0.19444, 0.44444, 0, 0],
- "113": [0.19444, 0.44444, 0.03704, 0],
- "114": [0, 0.44444, 0.03194, 0],
- "115": [0, 0.44444, 0, 0],
- "116": [0, 0.63492, 0, 0],
- "117": [0, 0.44444, 0, 0],
- "118": [0, 0.44444, 0.03704, 0],
- "119": [0, 0.44444, 0.02778, 0],
- "120": [0, 0.44444, 0, 0],
- "121": [0.19444, 0.44444, 0.03704, 0],
- "122": [0, 0.44444, 0.04213, 0],
- "915": [0, 0.68611, 0.15972, 0],
- "916": [0, 0.68611, 0, 0],
- "920": [0, 0.68611, 0.03194, 0],
- "923": [0, 0.68611, 0, 0],
- "926": [0, 0.68611, 0.07458, 0],
- "928": [0, 0.68611, 0.08229, 0],
- "931": [0, 0.68611, 0.05451, 0],
- "933": [0, 0.68611, 0.15972, 0],
- "934": [0, 0.68611, 0, 0],
- "936": [0, 0.68611, 0.11653, 0],
- "937": [0, 0.68611, 0.04835, 0],
- "945": [0, 0.44444, 0, 0],
- "946": [0.19444, 0.69444, 0.03403, 0],
- "947": [0.19444, 0.44444, 0.06389, 0],
- "948": [0, 0.69444, 0.03819, 0],
- "949": [0, 0.44444, 0, 0],
- "950": [0.19444, 0.69444, 0.06215, 0],
- "951": [0.19444, 0.44444, 0.03704, 0],
- "952": [0, 0.69444, 0.03194, 0],
- "953": [0, 0.44444, 0, 0],
- "954": [0, 0.44444, 0, 0],
- "955": [0, 0.69444, 0, 0],
- "956": [0.19444, 0.44444, 0, 0],
- "957": [0, 0.44444, 0.06898, 0],
- "958": [0.19444, 0.69444, 0.03021, 0],
- "959": [0, 0.44444, 0, 0],
- "960": [0, 0.44444, 0.03704, 0],
- "961": [0.19444, 0.44444, 0, 0],
- "962": [0.09722, 0.44444, 0.07917, 0],
- "963": [0, 0.44444, 0.03704, 0],
- "964": [0, 0.44444, 0.13472, 0],
- "965": [0, 0.44444, 0.03704, 0],
- "966": [0.19444, 0.44444, 0, 0],
- "967": [0.19444, 0.44444, 0, 0],
- "968": [0.19444, 0.69444, 0.03704, 0],
- "969": [0, 0.44444, 0.03704, 0],
- "977": [0, 0.69444, 0, 0],
- "981": [0.19444, 0.69444, 0, 0],
- "982": [0, 0.44444, 0.03194, 0],
- "1009": [0.19444, 0.44444, 0, 0],
- "1013": [0, 0.44444, 0, 0],
- },
- "Math-Italic": {
- "47": [0.19444, 0.69444, 0, 0],
- "65": [0, 0.68333, 0, 0.13889],
- "66": [0, 0.68333, 0.05017, 0.08334],
- "67": [0, 0.68333, 0.07153, 0.08334],
- "68": [0, 0.68333, 0.02778, 0.05556],
- "69": [0, 0.68333, 0.05764, 0.08334],
- "70": [0, 0.68333, 0.13889, 0.08334],
- "71": [0, 0.68333, 0, 0.08334],
- "72": [0, 0.68333, 0.08125, 0.05556],
- "73": [0, 0.68333, 0.07847, 0.11111],
- "74": [0, 0.68333, 0.09618, 0.16667],
- "75": [0, 0.68333, 0.07153, 0.05556],
- "76": [0, 0.68333, 0, 0.02778],
- "77": [0, 0.68333, 0.10903, 0.08334],
- "78": [0, 0.68333, 0.10903, 0.08334],
- "79": [0, 0.68333, 0.02778, 0.08334],
- "80": [0, 0.68333, 0.13889, 0.08334],
- "81": [0.19444, 0.68333, 0, 0.08334],
- "82": [0, 0.68333, 0.00773, 0.08334],
- "83": [0, 0.68333, 0.05764, 0.08334],
- "84": [0, 0.68333, 0.13889, 0.08334],
- "85": [0, 0.68333, 0.10903, 0.02778],
- "86": [0, 0.68333, 0.22222, 0],
- "87": [0, 0.68333, 0.13889, 0],
- "88": [0, 0.68333, 0.07847, 0.08334],
- "89": [0, 0.68333, 0.22222, 0],
- "90": [0, 0.68333, 0.07153, 0.08334],
- "97": [0, 0.43056, 0, 0],
- "98": [0, 0.69444, 0, 0],
- "99": [0, 0.43056, 0, 0.05556],
- "100": [0, 0.69444, 0, 0.16667],
- "101": [0, 0.43056, 0, 0.05556],
- "102": [0.19444, 0.69444, 0.10764, 0.16667],
- "103": [0.19444, 0.43056, 0.03588, 0.02778],
- "104": [0, 0.69444, 0, 0],
- "105": [0, 0.65952, 0, 0],
- "106": [0.19444, 0.65952, 0.05724, 0],
- "107": [0, 0.69444, 0.03148, 0],
- "108": [0, 0.69444, 0.01968, 0.08334],
- "109": [0, 0.43056, 0, 0],
- "110": [0, 0.43056, 0, 0],
- "111": [0, 0.43056, 0, 0.05556],
- "112": [0.19444, 0.43056, 0, 0.08334],
- "113": [0.19444, 0.43056, 0.03588, 0.08334],
- "114": [0, 0.43056, 0.02778, 0.05556],
- "115": [0, 0.43056, 0, 0.05556],
- "116": [0, 0.61508, 0, 0.08334],
- "117": [0, 0.43056, 0, 0.02778],
- "118": [0, 0.43056, 0.03588, 0.02778],
- "119": [0, 0.43056, 0.02691, 0.08334],
- "120": [0, 0.43056, 0, 0.02778],
- "121": [0.19444, 0.43056, 0.03588, 0.05556],
- "122": [0, 0.43056, 0.04398, 0.05556],
- "915": [0, 0.68333, 0.13889, 0.08334],
- "916": [0, 0.68333, 0, 0.16667],
- "920": [0, 0.68333, 0.02778, 0.08334],
- "923": [0, 0.68333, 0, 0.16667],
- "926": [0, 0.68333, 0.07569, 0.08334],
- "928": [0, 0.68333, 0.08125, 0.05556],
- "931": [0, 0.68333, 0.05764, 0.08334],
- "933": [0, 0.68333, 0.13889, 0.05556],
- "934": [0, 0.68333, 0, 0.08334],
- "936": [0, 0.68333, 0.11, 0.05556],
- "937": [0, 0.68333, 0.05017, 0.08334],
- "945": [0, 0.43056, 0.0037, 0.02778],
- "946": [0.19444, 0.69444, 0.05278, 0.08334],
- "947": [0.19444, 0.43056, 0.05556, 0],
- "948": [0, 0.69444, 0.03785, 0.05556],
- "949": [0, 0.43056, 0, 0.08334],
- "950": [0.19444, 0.69444, 0.07378, 0.08334],
- "951": [0.19444, 0.43056, 0.03588, 0.05556],
- "952": [0, 0.69444, 0.02778, 0.08334],
- "953": [0, 0.43056, 0, 0.05556],
- "954": [0, 0.43056, 0, 0],
- "955": [0, 0.69444, 0, 0],
- "956": [0.19444, 0.43056, 0, 0.02778],
- "957": [0, 0.43056, 0.06366, 0.02778],
- "958": [0.19444, 0.69444, 0.04601, 0.11111],
- "959": [0, 0.43056, 0, 0.05556],
- "960": [0, 0.43056, 0.03588, 0],
- "961": [0.19444, 0.43056, 0, 0.08334],
- "962": [0.09722, 0.43056, 0.07986, 0.08334],
- "963": [0, 0.43056, 0.03588, 0],
- "964": [0, 0.43056, 0.1132, 0.02778],
- "965": [0, 0.43056, 0.03588, 0.02778],
- "966": [0.19444, 0.43056, 0, 0.08334],
- "967": [0.19444, 0.43056, 0, 0.05556],
- "968": [0.19444, 0.69444, 0.03588, 0.11111],
- "969": [0, 0.43056, 0.03588, 0],
- "977": [0, 0.69444, 0, 0.08334],
- "981": [0.19444, 0.69444, 0, 0.08334],
- "982": [0, 0.43056, 0.02778, 0],
- "1009": [0.19444, 0.43056, 0, 0.08334],
- "1013": [0, 0.43056, 0, 0.05556],
- },
- "Math-Regular": {
- "65": [0, 0.68333, 0, 0.13889],
- "66": [0, 0.68333, 0.05017, 0.08334],
- "67": [0, 0.68333, 0.07153, 0.08334],
- "68": [0, 0.68333, 0.02778, 0.05556],
- "69": [0, 0.68333, 0.05764, 0.08334],
- "70": [0, 0.68333, 0.13889, 0.08334],
- "71": [0, 0.68333, 0, 0.08334],
- "72": [0, 0.68333, 0.08125, 0.05556],
- "73": [0, 0.68333, 0.07847, 0.11111],
- "74": [0, 0.68333, 0.09618, 0.16667],
- "75": [0, 0.68333, 0.07153, 0.05556],
- "76": [0, 0.68333, 0, 0.02778],
- "77": [0, 0.68333, 0.10903, 0.08334],
- "78": [0, 0.68333, 0.10903, 0.08334],
- "79": [0, 0.68333, 0.02778, 0.08334],
- "80": [0, 0.68333, 0.13889, 0.08334],
- "81": [0.19444, 0.68333, 0, 0.08334],
- "82": [0, 0.68333, 0.00773, 0.08334],
- "83": [0, 0.68333, 0.05764, 0.08334],
- "84": [0, 0.68333, 0.13889, 0.08334],
- "85": [0, 0.68333, 0.10903, 0.02778],
- "86": [0, 0.68333, 0.22222, 0],
- "87": [0, 0.68333, 0.13889, 0],
- "88": [0, 0.68333, 0.07847, 0.08334],
- "89": [0, 0.68333, 0.22222, 0],
- "90": [0, 0.68333, 0.07153, 0.08334],
- "97": [0, 0.43056, 0, 0],
- "98": [0, 0.69444, 0, 0],
- "99": [0, 0.43056, 0, 0.05556],
- "100": [0, 0.69444, 0, 0.16667],
- "101": [0, 0.43056, 0, 0.05556],
- "102": [0.19444, 0.69444, 0.10764, 0.16667],
- "103": [0.19444, 0.43056, 0.03588, 0.02778],
- "104": [0, 0.69444, 0, 0],
- "105": [0, 0.65952, 0, 0],
- "106": [0.19444, 0.65952, 0.05724, 0],
- "107": [0, 0.69444, 0.03148, 0],
- "108": [0, 0.69444, 0.01968, 0.08334],
- "109": [0, 0.43056, 0, 0],
- "110": [0, 0.43056, 0, 0],
- "111": [0, 0.43056, 0, 0.05556],
- "112": [0.19444, 0.43056, 0, 0.08334],
- "113": [0.19444, 0.43056, 0.03588, 0.08334],
- "114": [0, 0.43056, 0.02778, 0.05556],
- "115": [0, 0.43056, 0, 0.05556],
- "116": [0, 0.61508, 0, 0.08334],
- "117": [0, 0.43056, 0, 0.02778],
- "118": [0, 0.43056, 0.03588, 0.02778],
- "119": [0, 0.43056, 0.02691, 0.08334],
- "120": [0, 0.43056, 0, 0.02778],
- "121": [0.19444, 0.43056, 0.03588, 0.05556],
- "122": [0, 0.43056, 0.04398, 0.05556],
- "915": [0, 0.68333, 0.13889, 0.08334],
- "916": [0, 0.68333, 0, 0.16667],
- "920": [0, 0.68333, 0.02778, 0.08334],
- "923": [0, 0.68333, 0, 0.16667],
- "926": [0, 0.68333, 0.07569, 0.08334],
- "928": [0, 0.68333, 0.08125, 0.05556],
- "931": [0, 0.68333, 0.05764, 0.08334],
- "933": [0, 0.68333, 0.13889, 0.05556],
- "934": [0, 0.68333, 0, 0.08334],
- "936": [0, 0.68333, 0.11, 0.05556],
- "937": [0, 0.68333, 0.05017, 0.08334],
- "945": [0, 0.43056, 0.0037, 0.02778],
- "946": [0.19444, 0.69444, 0.05278, 0.08334],
- "947": [0.19444, 0.43056, 0.05556, 0],
- "948": [0, 0.69444, 0.03785, 0.05556],
- "949": [0, 0.43056, 0, 0.08334],
- "950": [0.19444, 0.69444, 0.07378, 0.08334],
- "951": [0.19444, 0.43056, 0.03588, 0.05556],
- "952": [0, 0.69444, 0.02778, 0.08334],
- "953": [0, 0.43056, 0, 0.05556],
- "954": [0, 0.43056, 0, 0],
- "955": [0, 0.69444, 0, 0],
- "956": [0.19444, 0.43056, 0, 0.02778],
- "957": [0, 0.43056, 0.06366, 0.02778],
- "958": [0.19444, 0.69444, 0.04601, 0.11111],
- "959": [0, 0.43056, 0, 0.05556],
- "960": [0, 0.43056, 0.03588, 0],
- "961": [0.19444, 0.43056, 0, 0.08334],
- "962": [0.09722, 0.43056, 0.07986, 0.08334],
- "963": [0, 0.43056, 0.03588, 0],
- "964": [0, 0.43056, 0.1132, 0.02778],
- "965": [0, 0.43056, 0.03588, 0.02778],
- "966": [0.19444, 0.43056, 0, 0.08334],
- "967": [0.19444, 0.43056, 0, 0.05556],
- "968": [0.19444, 0.69444, 0.03588, 0.11111],
- "969": [0, 0.43056, 0.03588, 0],
- "977": [0, 0.69444, 0, 0.08334],
- "981": [0.19444, 0.69444, 0, 0.08334],
- "982": [0, 0.43056, 0.02778, 0],
- "1009": [0.19444, 0.43056, 0, 0.08334],
- "1013": [0, 0.43056, 0, 0.05556],
- },
- "SansSerif-Regular": {
- "33": [0, 0.69444, 0, 0],
- "34": [0, 0.69444, 0, 0],
- "35": [0.19444, 0.69444, 0, 0],
- "36": [0.05556, 0.75, 0, 0],
- "37": [0.05556, 0.75, 0, 0],
- "38": [0, 0.69444, 0, 0],
- "39": [0, 0.69444, 0, 0],
- "40": [0.25, 0.75, 0, 0],
- "41": [0.25, 0.75, 0, 0],
- "42": [0, 0.75, 0, 0],
- "43": [0.08333, 0.58333, 0, 0],
- "44": [0.125, 0.08333, 0, 0],
- "45": [0, 0.44444, 0, 0],
- "46": [0, 0.08333, 0, 0],
- "47": [0.25, 0.75, 0, 0],
- "48": [0, 0.65556, 0, 0],
- "49": [0, 0.65556, 0, 0],
- "50": [0, 0.65556, 0, 0],
- "51": [0, 0.65556, 0, 0],
- "52": [0, 0.65556, 0, 0],
- "53": [0, 0.65556, 0, 0],
- "54": [0, 0.65556, 0, 0],
- "55": [0, 0.65556, 0, 0],
- "56": [0, 0.65556, 0, 0],
- "57": [0, 0.65556, 0, 0],
- "58": [0, 0.44444, 0, 0],
- "59": [0.125, 0.44444, 0, 0],
- "61": [-0.13, 0.37, 0, 0],
- "63": [0, 0.69444, 0, 0],
- "64": [0, 0.69444, 0, 0],
- "65": [0, 0.69444, 0, 0],
- "66": [0, 0.69444, 0, 0],
- "67": [0, 0.69444, 0, 0],
- "68": [0, 0.69444, 0, 0],
- "69": [0, 0.69444, 0, 0],
- "70": [0, 0.69444, 0, 0],
- "71": [0, 0.69444, 0, 0],
- "72": [0, 0.69444, 0, 0],
- "73": [0, 0.69444, 0, 0],
- "74": [0, 0.69444, 0, 0],
- "75": [0, 0.69444, 0, 0],
- "76": [0, 0.69444, 0, 0],
- "77": [0, 0.69444, 0, 0],
- "78": [0, 0.69444, 0, 0],
- "79": [0, 0.69444, 0, 0],
- "80": [0, 0.69444, 0, 0],
- "81": [0.125, 0.69444, 0, 0],
- "82": [0, 0.69444, 0, 0],
- "83": [0, 0.69444, 0, 0],
- "84": [0, 0.69444, 0, 0],
- "85": [0, 0.69444, 0, 0],
- "86": [0, 0.69444, 0.01389, 0],
- "87": [0, 0.69444, 0.01389, 0],
- "88": [0, 0.69444, 0, 0],
- "89": [0, 0.69444, 0.025, 0],
- "90": [0, 0.69444, 0, 0],
- "91": [0.25, 0.75, 0, 0],
- "93": [0.25, 0.75, 0, 0],
- "94": [0, 0.69444, 0, 0],
- "95": [0.35, 0.09444, 0.02778, 0],
- "97": [0, 0.44444, 0, 0],
- "98": [0, 0.69444, 0, 0],
- "99": [0, 0.44444, 0, 0],
- "100": [0, 0.69444, 0, 0],
- "101": [0, 0.44444, 0, 0],
- "102": [0, 0.69444, 0.06944, 0],
- "103": [0.19444, 0.44444, 0.01389, 0],
- "104": [0, 0.69444, 0, 0],
- "105": [0, 0.67937, 0, 0],
- "106": [0.19444, 0.67937, 0, 0],
- "107": [0, 0.69444, 0, 0],
- "108": [0, 0.69444, 0, 0],
- "109": [0, 0.44444, 0, 0],
- "110": [0, 0.44444, 0, 0],
- "111": [0, 0.44444, 0, 0],
- "112": [0.19444, 0.44444, 0, 0],
- "113": [0.19444, 0.44444, 0, 0],
- "114": [0, 0.44444, 0.01389, 0],
- "115": [0, 0.44444, 0, 0],
- "116": [0, 0.57143, 0, 0],
- "117": [0, 0.44444, 0, 0],
- "118": [0, 0.44444, 0.01389, 0],
- "119": [0, 0.44444, 0.01389, 0],
- "120": [0, 0.44444, 0, 0],
- "121": [0.19444, 0.44444, 0.01389, 0],
- "122": [0, 0.44444, 0, 0],
- "126": [0.35, 0.32659, 0, 0],
- "305": [0, 0.44444, 0, 0],
- "567": [0.19444, 0.44444, 0, 0],
- "768": [0, 0.69444, 0, 0],
- "769": [0, 0.69444, 0, 0],
- "770": [0, 0.69444, 0, 0],
- "771": [0, 0.67659, 0, 0],
- "772": [0, 0.60889, 0, 0],
- "774": [0, 0.69444, 0, 0],
- "775": [0, 0.67937, 0, 0],
- "776": [0, 0.67937, 0, 0],
- "778": [0, 0.69444, 0, 0],
- "779": [0, 0.69444, 0, 0],
- "780": [0, 0.63194, 0, 0],
- "915": [0, 0.69444, 0, 0],
- "916": [0, 0.69444, 0, 0],
- "920": [0, 0.69444, 0, 0],
- "923": [0, 0.69444, 0, 0],
- "926": [0, 0.69444, 0, 0],
- "928": [0, 0.69444, 0, 0],
- "931": [0, 0.69444, 0, 0],
- "933": [0, 0.69444, 0, 0],
- "934": [0, 0.69444, 0, 0],
- "936": [0, 0.69444, 0, 0],
- "937": [0, 0.69444, 0, 0],
- "8211": [0, 0.44444, 0.02778, 0],
- "8212": [0, 0.44444, 0.02778, 0],
- "8216": [0, 0.69444, 0, 0],
- "8217": [0, 0.69444, 0, 0],
- "8220": [0, 0.69444, 0, 0],
- "8221": [0, 0.69444, 0, 0],
- },
- "Script-Regular": {
- "65": [0, 0.7, 0.22925, 0],
- "66": [0, 0.7, 0.04087, 0],
- "67": [0, 0.7, 0.1689, 0],
- "68": [0, 0.7, 0.09371, 0],
- "69": [0, 0.7, 0.18583, 0],
- "70": [0, 0.7, 0.13634, 0],
- "71": [0, 0.7, 0.17322, 0],
- "72": [0, 0.7, 0.29694, 0],
- "73": [0, 0.7, 0.19189, 0],
- "74": [0.27778, 0.7, 0.19189, 0],
- "75": [0, 0.7, 0.31259, 0],
- "76": [0, 0.7, 0.19189, 0],
- "77": [0, 0.7, 0.15981, 0],
- "78": [0, 0.7, 0.3525, 0],
- "79": [0, 0.7, 0.08078, 0],
- "80": [0, 0.7, 0.08078, 0],
- "81": [0, 0.7, 0.03305, 0],
- "82": [0, 0.7, 0.06259, 0],
- "83": [0, 0.7, 0.19189, 0],
- "84": [0, 0.7, 0.29087, 0],
- "85": [0, 0.7, 0.25815, 0],
- "86": [0, 0.7, 0.27523, 0],
- "87": [0, 0.7, 0.27523, 0],
- "88": [0, 0.7, 0.26006, 0],
- "89": [0, 0.7, 0.2939, 0],
- "90": [0, 0.7, 0.24037, 0],
- },
- "Size1-Regular": {
- "40": [0.35001, 0.85, 0, 0],
- "41": [0.35001, 0.85, 0, 0],
- "47": [0.35001, 0.85, 0, 0],
- "91": [0.35001, 0.85, 0, 0],
- "92": [0.35001, 0.85, 0, 0],
- "93": [0.35001, 0.85, 0, 0],
- "123": [0.35001, 0.85, 0, 0],
- "125": [0.35001, 0.85, 0, 0],
- "710": [0, 0.72222, 0, 0],
- "732": [0, 0.72222, 0, 0],
- "770": [0, 0.72222, 0, 0],
- "771": [0, 0.72222, 0, 0],
- "8214": [-0.00099, 0.601, 0, 0],
- "8593": [1e-05, 0.6, 0, 0],
- "8595": [1e-05, 0.6, 0, 0],
- "8657": [1e-05, 0.6, 0, 0],
- "8659": [1e-05, 0.6, 0, 0],
- "8719": [0.25001, 0.75, 0, 0],
- "8720": [0.25001, 0.75, 0, 0],
- "8721": [0.25001, 0.75, 0, 0],
- "8730": [0.35001, 0.85, 0, 0],
- "8739": [-0.00599, 0.606, 0, 0],
- "8741": [-0.00599, 0.606, 0, 0],
- "8747": [0.30612, 0.805, 0.19445, 0],
- "8748": [0.306, 0.805, 0.19445, 0],
- "8749": [0.306, 0.805, 0.19445, 0],
- "8750": [0.30612, 0.805, 0.19445, 0],
- "8896": [0.25001, 0.75, 0, 0],
- "8897": [0.25001, 0.75, 0, 0],
- "8898": [0.25001, 0.75, 0, 0],
- "8899": [0.25001, 0.75, 0, 0],
- "8968": [0.35001, 0.85, 0, 0],
- "8969": [0.35001, 0.85, 0, 0],
- "8970": [0.35001, 0.85, 0, 0],
- "8971": [0.35001, 0.85, 0, 0],
- "9168": [-0.00099, 0.601, 0, 0],
- "10216": [0.35001, 0.85, 0, 0],
- "10217": [0.35001, 0.85, 0, 0],
- "10752": [0.25001, 0.75, 0, 0],
- "10753": [0.25001, 0.75, 0, 0],
- "10754": [0.25001, 0.75, 0, 0],
- "10756": [0.25001, 0.75, 0, 0],
- "10758": [0.25001, 0.75, 0, 0],
- },
- "Size2-Regular": {
- "40": [0.65002, 1.15, 0, 0],
- "41": [0.65002, 1.15, 0, 0],
- "47": [0.65002, 1.15, 0, 0],
- "91": [0.65002, 1.15, 0, 0],
- "92": [0.65002, 1.15, 0, 0],
- "93": [0.65002, 1.15, 0, 0],
- "123": [0.65002, 1.15, 0, 0],
- "125": [0.65002, 1.15, 0, 0],
- "710": [0, 0.75, 0, 0],
- "732": [0, 0.75, 0, 0],
- "770": [0, 0.75, 0, 0],
- "771": [0, 0.75, 0, 0],
- "8719": [0.55001, 1.05, 0, 0],
- "8720": [0.55001, 1.05, 0, 0],
- "8721": [0.55001, 1.05, 0, 0],
- "8730": [0.65002, 1.15, 0, 0],
- "8747": [0.86225, 1.36, 0.44445, 0],
- "8748": [0.862, 1.36, 0.44445, 0],
- "8749": [0.862, 1.36, 0.44445, 0],
- "8750": [0.86225, 1.36, 0.44445, 0],
- "8896": [0.55001, 1.05, 0, 0],
- "8897": [0.55001, 1.05, 0, 0],
- "8898": [0.55001, 1.05, 0, 0],
- "8899": [0.55001, 1.05, 0, 0],
- "8968": [0.65002, 1.15, 0, 0],
- "8969": [0.65002, 1.15, 0, 0],
- "8970": [0.65002, 1.15, 0, 0],
- "8971": [0.65002, 1.15, 0, 0],
- "10216": [0.65002, 1.15, 0, 0],
- "10217": [0.65002, 1.15, 0, 0],
- "10752": [0.55001, 1.05, 0, 0],
- "10753": [0.55001, 1.05, 0, 0],
- "10754": [0.55001, 1.05, 0, 0],
- "10756": [0.55001, 1.05, 0, 0],
- "10758": [0.55001, 1.05, 0, 0],
- },
- "Size3-Regular": {
- "40": [0.95003, 1.45, 0, 0],
- "41": [0.95003, 1.45, 0, 0],
- "47": [0.95003, 1.45, 0, 0],
- "91": [0.95003, 1.45, 0, 0],
- "92": [0.95003, 1.45, 0, 0],
- "93": [0.95003, 1.45, 0, 0],
- "123": [0.95003, 1.45, 0, 0],
- "125": [0.95003, 1.45, 0, 0],
- "710": [0, 0.75, 0, 0],
- "732": [0, 0.75, 0, 0],
- "770": [0, 0.75, 0, 0],
- "771": [0, 0.75, 0, 0],
- "8730": [0.95003, 1.45, 0, 0],
- "8968": [0.95003, 1.45, 0, 0],
- "8969": [0.95003, 1.45, 0, 0],
- "8970": [0.95003, 1.45, 0, 0],
- "8971": [0.95003, 1.45, 0, 0],
- "10216": [0.95003, 1.45, 0, 0],
- "10217": [0.95003, 1.45, 0, 0],
- },
- "Size4-Regular": {
- "40": [1.25003, 1.75, 0, 0],
- "41": [1.25003, 1.75, 0, 0],
- "47": [1.25003, 1.75, 0, 0],
- "91": [1.25003, 1.75, 0, 0],
- "92": [1.25003, 1.75, 0, 0],
- "93": [1.25003, 1.75, 0, 0],
- "123": [1.25003, 1.75, 0, 0],
- "125": [1.25003, 1.75, 0, 0],
- "710": [0, 0.825, 0, 0],
- "732": [0, 0.825, 0, 0],
- "770": [0, 0.825, 0, 0],
- "771": [0, 0.825, 0, 0],
- "8730": [1.25003, 1.75, 0, 0],
- "8968": [1.25003, 1.75, 0, 0],
- "8969": [1.25003, 1.75, 0, 0],
- "8970": [1.25003, 1.75, 0, 0],
- "8971": [1.25003, 1.75, 0, 0],
- "9115": [0.64502, 1.155, 0, 0],
- "9116": [1e-05, 0.6, 0, 0],
- "9117": [0.64502, 1.155, 0, 0],
- "9118": [0.64502, 1.155, 0, 0],
- "9119": [1e-05, 0.6, 0, 0],
- "9120": [0.64502, 1.155, 0, 0],
- "9121": [0.64502, 1.155, 0, 0],
- "9122": [-0.00099, 0.601, 0, 0],
- "9123": [0.64502, 1.155, 0, 0],
- "9124": [0.64502, 1.155, 0, 0],
- "9125": [-0.00099, 0.601, 0, 0],
- "9126": [0.64502, 1.155, 0, 0],
- "9127": [1e-05, 0.9, 0, 0],
- "9128": [0.65002, 1.15, 0, 0],
- "9129": [0.90001, 0, 0, 0],
- "9130": [0, 0.3, 0, 0],
- "9131": [1e-05, 0.9, 0, 0],
- "9132": [0.65002, 1.15, 0, 0],
- "9133": [0.90001, 0, 0, 0],
- "9143": [0.88502, 0.915, 0, 0],
- "10216": [1.25003, 1.75, 0, 0],
- "10217": [1.25003, 1.75, 0, 0],
- "57344": [-0.00499, 0.605, 0, 0],
- "57345": [-0.00499, 0.605, 0, 0],
- "57680": [0, 0.12, 0, 0],
- "57681": [0, 0.12, 0, 0],
- "57682": [0, 0.12, 0, 0],
- "57683": [0, 0.12, 0, 0],
- },
- "Typewriter-Regular": {
- "33": [0, 0.61111, 0, 0],
- "34": [0, 0.61111, 0, 0],
- "35": [0, 0.61111, 0, 0],
- "36": [0.08333, 0.69444, 0, 0],
- "37": [0.08333, 0.69444, 0, 0],
- "38": [0, 0.61111, 0, 0],
- "39": [0, 0.61111, 0, 0],
- "40": [0.08333, 0.69444, 0, 0],
- "41": [0.08333, 0.69444, 0, 0],
- "42": [0, 0.52083, 0, 0],
- "43": [-0.08056, 0.53055, 0, 0],
- "44": [0.13889, 0.125, 0, 0],
- "45": [-0.08056, 0.53055, 0, 0],
- "46": [0, 0.125, 0, 0],
- "47": [0.08333, 0.69444, 0, 0],
- "48": [0, 0.61111, 0, 0],
- "49": [0, 0.61111, 0, 0],
- "50": [0, 0.61111, 0, 0],
- "51": [0, 0.61111, 0, 0],
- "52": [0, 0.61111, 0, 0],
- "53": [0, 0.61111, 0, 0],
- "54": [0, 0.61111, 0, 0],
- "55": [0, 0.61111, 0, 0],
- "56": [0, 0.61111, 0, 0],
- "57": [0, 0.61111, 0, 0],
- "58": [0, 0.43056, 0, 0],
- "59": [0.13889, 0.43056, 0, 0],
- "60": [-0.05556, 0.55556, 0, 0],
- "61": [-0.19549, 0.41562, 0, 0],
- "62": [-0.05556, 0.55556, 0, 0],
- "63": [0, 0.61111, 0, 0],
- "64": [0, 0.61111, 0, 0],
- "65": [0, 0.61111, 0, 0],
- "66": [0, 0.61111, 0, 0],
- "67": [0, 0.61111, 0, 0],
- "68": [0, 0.61111, 0, 0],
- "69": [0, 0.61111, 0, 0],
- "70": [0, 0.61111, 0, 0],
- "71": [0, 0.61111, 0, 0],
- "72": [0, 0.61111, 0, 0],
- "73": [0, 0.61111, 0, 0],
- "74": [0, 0.61111, 0, 0],
- "75": [0, 0.61111, 0, 0],
- "76": [0, 0.61111, 0, 0],
- "77": [0, 0.61111, 0, 0],
- "78": [0, 0.61111, 0, 0],
- "79": [0, 0.61111, 0, 0],
- "80": [0, 0.61111, 0, 0],
- "81": [0.13889, 0.61111, 0, 0],
- "82": [0, 0.61111, 0, 0],
- "83": [0, 0.61111, 0, 0],
- "84": [0, 0.61111, 0, 0],
- "85": [0, 0.61111, 0, 0],
- "86": [0, 0.61111, 0, 0],
- "87": [0, 0.61111, 0, 0],
- "88": [0, 0.61111, 0, 0],
- "89": [0, 0.61111, 0, 0],
- "90": [0, 0.61111, 0, 0],
- "91": [0.08333, 0.69444, 0, 0],
- "92": [0.08333, 0.69444, 0, 0],
- "93": [0.08333, 0.69444, 0, 0],
- "94": [0, 0.61111, 0, 0],
- "95": [0.09514, 0, 0, 0],
- "96": [0, 0.61111, 0, 0],
- "97": [0, 0.43056, 0, 0],
- "98": [0, 0.61111, 0, 0],
- "99": [0, 0.43056, 0, 0],
- "100": [0, 0.61111, 0, 0],
- "101": [0, 0.43056, 0, 0],
- "102": [0, 0.61111, 0, 0],
- "103": [0.22222, 0.43056, 0, 0],
- "104": [0, 0.61111, 0, 0],
- "105": [0, 0.61111, 0, 0],
- "106": [0.22222, 0.61111, 0, 0],
- "107": [0, 0.61111, 0, 0],
- "108": [0, 0.61111, 0, 0],
- "109": [0, 0.43056, 0, 0],
- "110": [0, 0.43056, 0, 0],
- "111": [0, 0.43056, 0, 0],
- "112": [0.22222, 0.43056, 0, 0],
- "113": [0.22222, 0.43056, 0, 0],
- "114": [0, 0.43056, 0, 0],
- "115": [0, 0.43056, 0, 0],
- "116": [0, 0.55358, 0, 0],
- "117": [0, 0.43056, 0, 0],
- "118": [0, 0.43056, 0, 0],
- "119": [0, 0.43056, 0, 0],
- "120": [0, 0.43056, 0, 0],
- "121": [0.22222, 0.43056, 0, 0],
- "122": [0, 0.43056, 0, 0],
- "123": [0.08333, 0.69444, 0, 0],
- "124": [0.08333, 0.69444, 0, 0],
- "125": [0.08333, 0.69444, 0, 0],
- "126": [0, 0.61111, 0, 0],
- "127": [0, 0.61111, 0, 0],
- "305": [0, 0.43056, 0, 0],
- "567": [0.22222, 0.43056, 0, 0],
- "768": [0, 0.61111, 0, 0],
- "769": [0, 0.61111, 0, 0],
- "770": [0, 0.61111, 0, 0],
- "771": [0, 0.61111, 0, 0],
- "772": [0, 0.56555, 0, 0],
- "774": [0, 0.61111, 0, 0],
- "776": [0, 0.61111, 0, 0],
- "778": [0, 0.61111, 0, 0],
- "780": [0, 0.56597, 0, 0],
- "915": [0, 0.61111, 0, 0],
- "916": [0, 0.61111, 0, 0],
- "920": [0, 0.61111, 0, 0],
- "923": [0, 0.61111, 0, 0],
- "926": [0, 0.61111, 0, 0],
- "928": [0, 0.61111, 0, 0],
- "931": [0, 0.61111, 0, 0],
- "933": [0, 0.61111, 0, 0],
- "934": [0, 0.61111, 0, 0],
- "936": [0, 0.61111, 0, 0],
- "937": [0, 0.61111, 0, 0],
- "2018": [0, 0.61111, 0, 0],
- "2019": [0, 0.61111, 0, 0],
- "8242": [0, 0.61111, 0, 0],
- },
-};
-
-},{}],19:[function(require,module,exports){
-var utils = require("./utils");
-var ParseError = require("./ParseError");
-
-/* This file contains a list of functions that we parse, identified by
- * the calls to defineFunction.
- *
- * The first argument to defineFunction is a single name or a list of names.
- * All functions named in such a list will share a single implementation.
- *
- * Each declared function can have associated properties, which
- * include the following:
- *
- * - numArgs: The number of arguments the function takes.
- * If this is the only property, it can be passed as a number
- * instead of an element of a properties object.
- * - argTypes: (optional) An array corresponding to each argument of the
- * function, giving the type of argument that should be parsed. Its
- * length should be equal to `numArgs + numOptionalArgs`. Valid
- * types:
- * - "size": A size-like thing, such as "1em" or "5ex"
- * - "color": An html color, like "#abc" or "blue"
- * - "original": The same type as the environment that the
- * function being parsed is in (e.g. used for the
- * bodies of functions like \color where the first
- * argument is special and the second argument is
- * parsed normally)
- * Other possible types (probably shouldn't be used)
- * - "text": Text-like (e.g. \text)
- * - "math": Normal math
- * If undefined, this will be treated as an appropriate length
- * array of "original" strings
- * - greediness: (optional) The greediness of the function to use ungrouped
- * arguments.
- *
- * E.g. if you have an expression
- * \sqrt \frac 1 2
- * since \frac has greediness=2 vs \sqrt's greediness=1, \frac
- * will use the two arguments '1' and '2' as its two arguments,
- * then that whole function will be used as the argument to
- * \sqrt. On the other hand, the expressions
- * \frac \frac 1 2 3
- * and
- * \frac \sqrt 1 2
- * will fail because \frac and \frac have equal greediness
- * and \sqrt has a lower greediness than \frac respectively. To
- * make these parse, we would have to change them to:
- * \frac {\frac 1 2} 3
- * and
- * \frac {\sqrt 1} 2
- *
- * The default value is `1`
- * - allowedInText: (optional) Whether or not the function is allowed inside
- * text mode (default false)
- * - numOptionalArgs: (optional) The number of optional arguments the function
- * should parse. If the optional arguments aren't found,
- * `null` will be passed to the handler in their place.
- * (default 0)
- * - infix: (optional) Must be true if the function is an infix operator.
- *
- * The last argument is that implementation, the handler for the function(s).
- * It is called to handle these functions and their arguments.
- * It receives two arguments:
- * - context contains information and references provided by the parser
- * - args is an array of arguments obtained from TeX input
- * The context contains the following properties:
- * - funcName: the text (i.e. name) of the function, including \
- * - parser: the parser object
- * - lexer: the lexer object
- * - positions: the positions in the overall string of the function
- * and the arguments.
- * The latter three should only be used to produce error messages.
- *
- * The function should return an object with the following keys:
- * - type: The type of element that this is. This is then used in
- * buildHTML/buildMathML to determine which function
- * should be called to build this node into a DOM node
- * Any other data can be added to the object, which will be passed
- * in to the function in buildHTML/buildMathML as `group.value`.
- */
-
-function defineFunction(names, props, handler) {
- if (typeof names === "string") {
- names = [names];
- }
- if (typeof props === "number") {
- props = { numArgs: props };
- }
- // Set default values of functions
- var data = {
- numArgs: props.numArgs,
- argTypes: props.argTypes,
- greediness: (props.greediness === undefined) ? 1 : props.greediness,
- allowedInText: !!props.allowedInText,
- numOptionalArgs: props.numOptionalArgs || 0,
- infix: !!props.infix,
- handler: handler,
- };
- for (var i = 0; i < names.length; ++i) {
- module.exports[names[i]] = data;
- }
-}
-
-// A normal square root
-defineFunction("\\sqrt", {
- numArgs: 1,
- numOptionalArgs: 1,
-}, function(context, args) {
- var index = args[0];
- var body = args[1];
- return {
- type: "sqrt",
- body: body,
- index: index,
- };
-});
-
-// Some non-mathy text
-defineFunction("\\text", {
- numArgs: 1,
- argTypes: ["text"],
- greediness: 2,
-}, function(context, args) {
- var body = args[0];
- // Since the corresponding buildHTML/buildMathML function expects a
- // list of elements, we normalize for different kinds of arguments
- // TODO(emily): maybe this should be done somewhere else
- var inner;
- if (body.type === "ordgroup") {
- inner = body.value;
- } else {
- inner = [body];
- }
-
- return {
- type: "text",
- body: inner,
- };
-});
-
-// A two-argument custom color
-defineFunction("\\color", {
- numArgs: 2,
- allowedInText: true,
- greediness: 3,
- argTypes: ["color", "original"],
-}, function(context, args) {
- var color = args[0];
- var body = args[1];
- // Normalize the different kinds of bodies (see \text above)
- var inner;
- if (body.type === "ordgroup") {
- inner = body.value;
- } else {
- inner = [body];
- }
-
- return {
- type: "color",
- color: color.value,
- value: inner,
- };
-});
-
-// An overline
-defineFunction("\\overline", {
- numArgs: 1,
-}, function(context, args) {
- var body = args[0];
- return {
- type: "overline",
- body: body,
- };
-});
-
-// An underline
-defineFunction("\\underline", {
- numArgs: 1,
-}, function(context, args) {
- var body = args[0];
- return {
- type: "underline",
- body: body,
- };
-});
-
-// A box of the width and height
-defineFunction("\\rule", {
- numArgs: 2,
- numOptionalArgs: 1,
- argTypes: ["size", "size", "size"],
-}, function(context, args) {
- var shift = args[0];
- var width = args[1];
- var height = args[2];
- return {
- type: "rule",
- shift: shift && shift.value,
- width: width.value,
- height: height.value,
- };
-});
-
-defineFunction("\\kern", {
- numArgs: 1,
- argTypes: ["size"],
-}, function(context, args) {
- return {
- type: "kern",
- dimension: args[0].value,
- };
-});
-
-// A KaTeX logo
-defineFunction("\\KaTeX", {
- numArgs: 0,
-}, function(context) {
- return {
- type: "katex",
- };
-});
-
-defineFunction("\\phantom", {
- numArgs: 1,
-}, function(context, args) {
- var body = args[0];
- var inner;
- if (body.type === "ordgroup") {
- inner = body.value;
- } else {
- inner = [body];
- }
-
- return {
- type: "phantom",
- value: inner,
- };
-});
-
-// Extra data needed for the delimiter handler down below
-var delimiterSizes = {
- "\\bigl" : {type: "open", size: 1},
- "\\Bigl" : {type: "open", size: 2},
- "\\biggl": {type: "open", size: 3},
- "\\Biggl": {type: "open", size: 4},
- "\\bigr" : {type: "close", size: 1},
- "\\Bigr" : {type: "close", size: 2},
- "\\biggr": {type: "close", size: 3},
- "\\Biggr": {type: "close", size: 4},
- "\\bigm" : {type: "rel", size: 1},
- "\\Bigm" : {type: "rel", size: 2},
- "\\biggm": {type: "rel", size: 3},
- "\\Biggm": {type: "rel", size: 4},
- "\\big" : {type: "textord", size: 1},
- "\\Big" : {type: "textord", size: 2},
- "\\bigg" : {type: "textord", size: 3},
- "\\Bigg" : {type: "textord", size: 4},
-};
-
-var delimiters = [
- "(", ")", "[", "\\lbrack", "]", "\\rbrack",
- "\\{", "\\lbrace", "\\}", "\\rbrace",
- "\\lfloor", "\\rfloor", "\\lceil", "\\rceil",
- "<", ">", "\\langle", "\\rangle", "\\lt", "\\gt",
- "\\lvert", "\\rvert", "\\lVert", "\\rVert",
- "\\lgroup", "\\rgroup", "\\lmoustache", "\\rmoustache",
- "/", "\\backslash",
- "|", "\\vert", "\\|", "\\Vert",
- "\\uparrow", "\\Uparrow",
- "\\downarrow", "\\Downarrow",
- "\\updownarrow", "\\Updownarrow",
- ".",
-];
-
-var fontAliases = {
- "\\Bbb": "\\mathbb",
- "\\bold": "\\mathbf",
- "\\frak": "\\mathfrak",
-};
-
-// Single-argument color functions
-defineFunction([
- "\\blue", "\\orange", "\\pink", "\\red",
- "\\green", "\\gray", "\\purple",
- "\\blueA", "\\blueB", "\\blueC", "\\blueD", "\\blueE",
- "\\tealA", "\\tealB", "\\tealC", "\\tealD", "\\tealE",
- "\\greenA", "\\greenB", "\\greenC", "\\greenD", "\\greenE",
- "\\goldA", "\\goldB", "\\goldC", "\\goldD", "\\goldE",
- "\\redA", "\\redB", "\\redC", "\\redD", "\\redE",
- "\\maroonA", "\\maroonB", "\\maroonC", "\\maroonD", "\\maroonE",
- "\\purpleA", "\\purpleB", "\\purpleC", "\\purpleD", "\\purpleE",
- "\\mintA", "\\mintB", "\\mintC",
- "\\grayA", "\\grayB", "\\grayC", "\\grayD", "\\grayE",
- "\\grayF", "\\grayG", "\\grayH", "\\grayI",
- "\\kaBlue", "\\kaGreen",
-], {
- numArgs: 1,
- allowedInText: true,
- greediness: 3,
-}, function(context, args) {
- var body = args[0];
- var atoms;
- if (body.type === "ordgroup") {
- atoms = body.value;
- } else {
- atoms = [body];
- }
-
- return {
- type: "color",
- color: "katex-" + context.funcName.slice(1),
- value: atoms,
- };
-});
-
-// There are 2 flags for operators; whether they produce limits in
-// displaystyle, and whether they are symbols and should grow in
-// displaystyle. These four groups cover the four possible choices.
-
-// No limits, not symbols
-defineFunction([
- "\\arcsin", "\\arccos", "\\arctan", "\\arg", "\\cos", "\\cosh",
- "\\cot", "\\coth", "\\csc", "\\deg", "\\dim", "\\exp", "\\hom",
- "\\ker", "\\lg", "\\ln", "\\log", "\\sec", "\\sin", "\\sinh",
- "\\tan", "\\tanh",
-], {
- numArgs: 0,
-}, function(context) {
- return {
- type: "op",
- limits: false,
- symbol: false,
- body: context.funcName,
- };
-});
-
-// Limits, not symbols
-defineFunction([
- "\\det", "\\gcd", "\\inf", "\\lim", "\\liminf", "\\limsup", "\\max",
- "\\min", "\\Pr", "\\sup",
-], {
- numArgs: 0,
-}, function(context) {
- return {
- type: "op",
- limits: true,
- symbol: false,
- body: context.funcName,
- };
-});
-
-// No limits, symbols
-defineFunction([
- "\\int", "\\iint", "\\iiint", "\\oint",
-], {
- numArgs: 0,
-}, function(context) {
- return {
- type: "op",
- limits: false,
- symbol: true,
- body: context.funcName,
- };
-});
-
-// Limits, symbols
-defineFunction([
- "\\coprod", "\\bigvee", "\\bigwedge", "\\biguplus", "\\bigcap",
- "\\bigcup", "\\intop", "\\prod", "\\sum", "\\bigotimes",
- "\\bigoplus", "\\bigodot", "\\bigsqcup", "\\smallint",
-], {
- numArgs: 0,
-}, function(context) {
- return {
- type: "op",
- limits: true,
- symbol: true,
- body: context.funcName,
- };
-});
-
-// Fractions
-defineFunction([
- "\\dfrac", "\\frac", "\\tfrac",
- "\\dbinom", "\\binom", "\\tbinom",
-], {
- numArgs: 2,
- greediness: 2,
-}, function(context, args) {
- var numer = args[0];
- var denom = args[1];
- var hasBarLine;
- var leftDelim = null;
- var rightDelim = null;
- var size = "auto";
-
- switch (context.funcName) {
- case "\\dfrac":
- case "\\frac":
- case "\\tfrac":
- hasBarLine = true;
- break;
- case "\\dbinom":
- case "\\binom":
- case "\\tbinom":
- hasBarLine = false;
- leftDelim = "(";
- rightDelim = ")";
- break;
- default:
- throw new Error("Unrecognized genfrac command");
- }
-
- switch (context.funcName) {
- case "\\dfrac":
- case "\\dbinom":
- size = "display";
- break;
- case "\\tfrac":
- case "\\tbinom":
- size = "text";
- break;
- }
-
- return {
- type: "genfrac",
- numer: numer,
- denom: denom,
- hasBarLine: hasBarLine,
- leftDelim: leftDelim,
- rightDelim: rightDelim,
- size: size,
- };
-});
-
-// Left and right overlap functions
-defineFunction(["\\llap", "\\rlap"], {
- numArgs: 1,
- allowedInText: true,
-}, function(context, args) {
- var body = args[0];
- return {
- type: context.funcName.slice(1),
- body: body,
- };
-});
-
-// Delimiter functions
-defineFunction([
- "\\bigl", "\\Bigl", "\\biggl", "\\Biggl",
- "\\bigr", "\\Bigr", "\\biggr", "\\Biggr",
- "\\bigm", "\\Bigm", "\\biggm", "\\Biggm",
- "\\big", "\\Big", "\\bigg", "\\Bigg",
- "\\left", "\\right",
-], {
- numArgs: 1,
-}, function(context, args) {
- var delim = args[0];
- if (!utils.contains(delimiters, delim.value)) {
- throw new ParseError(
- "Invalid delimiter: '" + delim.value + "' after '" +
- context.funcName + "'", delim);
- }
-
- // \left and \right are caught somewhere in Parser.js, which is
- // why this data doesn't match what is in buildHTML.
- if (context.funcName === "\\left" || context.funcName === "\\right") {
- return {
- type: "leftright",
- value: delim.value,
- };
- } else {
- return {
- type: "delimsizing",
- size: delimiterSizes[context.funcName].size,
- delimType: delimiterSizes[context.funcName].type,
- value: delim.value,
- };
- }
-});
-
-// Sizing functions (handled in Parser.js explicitly, hence no handler)
-defineFunction([
- "\\tiny", "\\scriptsize", "\\footnotesize", "\\small",
- "\\normalsize", "\\large", "\\Large", "\\LARGE", "\\huge", "\\Huge",
-], 0, null);
-
-// Style changing functions (handled in Parser.js explicitly, hence no
-// handler)
-defineFunction([
- "\\displaystyle", "\\textstyle", "\\scriptstyle",
- "\\scriptscriptstyle",
-], 0, null);
-
-defineFunction([
- // styles
- "\\mathrm", "\\mathit", "\\mathbf",
-
- // families
- "\\mathbb", "\\mathcal", "\\mathfrak", "\\mathscr", "\\mathsf",
- "\\mathtt",
-
- // aliases
- "\\Bbb", "\\bold", "\\frak",
-], {
- numArgs: 1,
- greediness: 2,
-}, function(context, args) {
- var body = args[0];
- var func = context.funcName;
- if (func in fontAliases) {
- func = fontAliases[func];
- }
- return {
- type: "font",
- font: func.slice(1),
- body: body,
- };
-});
-
-// Accents
-defineFunction([
- "\\acute", "\\grave", "\\ddot", "\\tilde", "\\bar", "\\breve",
- "\\check", "\\hat", "\\vec", "\\dot",
- // We don't support expanding accents yet
- // "\\widetilde", "\\widehat"
-], {
- numArgs: 1,
-}, function(context, args) {
- var base = args[0];
- return {
- type: "accent",
- accent: context.funcName,
- base: base,
- };
-});
-
-// Infix generalized fractions
-defineFunction(["\\over", "\\choose"], {
- numArgs: 0,
- infix: true,
-}, function(context) {
- var replaceWith;
- switch (context.funcName) {
- case "\\over":
- replaceWith = "\\frac";
- break;
- case "\\choose":
- replaceWith = "\\binom";
- break;
- default:
- throw new Error("Unrecognized infix genfrac command");
- }
- return {
- type: "infix",
- replaceWith: replaceWith,
- token: context.token,
- };
-});
-
-// Row breaks for aligned data
-defineFunction(["\\\\", "\\cr"], {
- numArgs: 0,
- numOptionalArgs: 1,
- argTypes: ["size"],
-}, function(context, args) {
- var size = args[0];
- return {
- type: "cr",
- size: size,
- };
-});
-
-// Environment delimiters
-defineFunction(["\\begin", "\\end"], {
- numArgs: 1,
- argTypes: ["text"],
-}, function(context, args) {
- var nameGroup = args[0];
- if (nameGroup.type !== "ordgroup") {
- throw new ParseError("Invalid environment name", nameGroup);
- }
- var name = "";
- for (var i = 0; i < nameGroup.value.length; ++i) {
- name += nameGroup.value[i].value;
- }
- return {
- type: "environment",
- name: name,
- nameGroup: nameGroup,
- };
-});
-
-},{"./ParseError":6,"./utils":25}],20:[function(require,module,exports){
-/**
- * These objects store data about MathML nodes. This is the MathML equivalent
- * of the types in domTree.js. Since MathML handles its own rendering, and
- * since we're mainly using MathML to improve accessibility, we don't manage
- * any of the styling state that the plain DOM nodes do.
- *
- * The `toNode` and `toMarkup` functions work simlarly to how they do in
- * domTree.js, creating namespaced DOM nodes and HTML text markup respectively.
- */
-
-var utils = require("./utils");
-
-/**
- * This node represents a general purpose MathML node of any type. The
- * constructor requires the type of node to create (for example, `"mo"` or
- * `"mspace"`, corresponding to `<mo>` and `<mspace>` tags).
- */
-function MathNode(type, children) {
- this.type = type;
- this.attributes = {};
- this.children = children || [];
-}
-
-/**
- * Sets an attribute on a MathML node. MathML depends on attributes to convey a
- * semantic content, so this is used heavily.
- */
-MathNode.prototype.setAttribute = function(name, value) {
- this.attributes[name] = value;
-};
-
-/**
- * Converts the math node into a MathML-namespaced DOM element.
- */
-MathNode.prototype.toNode = function() {
- var node = document.createElementNS(
- "http://www.w3.org/1998/Math/MathML", this.type);
-
- for (var attr in this.attributes) {
- if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
- node.setAttribute(attr, this.attributes[attr]);
- }
- }
-
- for (var i = 0; i < this.children.length; i++) {
- node.appendChild(this.children[i].toNode());
- }
-
- return node;
-};
-
-/**
- * Converts the math node into an HTML markup string.
- */
-MathNode.prototype.toMarkup = function() {
- var markup = "<" + this.type;
-
- // Add the attributes
- for (var attr in this.attributes) {
- if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
- markup += " " + attr + "=\"";
- markup += utils.escape(this.attributes[attr]);
- markup += "\"";
- }
- }
-
- markup += ">";
-
- for (var i = 0; i < this.children.length; i++) {
- markup += this.children[i].toMarkup();
- }
-
- markup += "</" + this.type + ">";
-
- return markup;
-};
-
-/**
- * This node represents a piece of text.
- */
-function TextNode(text) {
- this.text = text;
-}
-
-/**
- * Converts the text node into a DOM text node.
- */
-TextNode.prototype.toNode = function() {
- return document.createTextNode(this.text);
-};
-
-/**
- * Converts the text node into HTML markup (which is just the text itself).
- */
-TextNode.prototype.toMarkup = function() {
- return utils.escape(this.text);
-};
-
-module.exports = {
- MathNode: MathNode,
- TextNode: TextNode,
-};
-
-},{"./utils":25}],21:[function(require,module,exports){
-/**
- * The resulting parse tree nodes of the parse tree.
- *
- * It is possible to provide position information, so that a ParseNode can
- * fulfil a role similar to a Token in error reporting.
- * For details on the corresponding properties see Token constructor.
- * Providing such information can lead to better error reporting.
- *
- * @param {string} type type of node, like e.g. "ordgroup"
- * @param {?object} value type-specific representation of the node
- * @param {string} mode parse mode in action for this node,
- * "math" or "text"
- * @param {Token=} firstToken first token of the input for this node,
- * will omit position information if unset
- * @param {Token=} lastToken last token of the input for this node,
- * will default to firstToken if unset
- */
-function ParseNode(type, value, mode, firstToken, lastToken) {
- this.type = type;
- this.value = value;
- this.mode = mode;
- if (firstToken && (!lastToken || lastToken.lexer === firstToken.lexer)) {
- this.lexer = firstToken.lexer;
- this.start = firstToken.start;
- this.end = (lastToken || firstToken).end;
- }
-}
-
-module.exports = {
- ParseNode: ParseNode,
-};
-
-
-},{}],22:[function(require,module,exports){
-/**
- * Provides a single function for parsing an expression using a Parser
- * TODO(emily): Remove this
- */
-
-var Parser = require("./Parser");
-
-/**
- * Parses an expression using a Parser, then returns the parsed result.
- */
-var parseTree = function(toParse, settings) {
- if (!(typeof toParse === 'string' || toParse instanceof String)) {
- throw new TypeError('KaTeX can only parse string typed expression');
- }
- var parser = new Parser(toParse, settings);
-
- return parser.parse();
-};
-
-module.exports = parseTree;
-
-},{"./Parser":7}],23:[function(require,module,exports){
-/**
- * This file holds a list of all no-argument functions and single-character
- * symbols (like 'a' or ';').
- *
- * For each of the symbols, there are three properties they can have:
- * - font (required): the font to be used for this symbol. Either "main" (the
- normal font), or "ams" (the ams fonts).
- * - group (required): the ParseNode group type the symbol should have (i.e.
- "textord", "mathord", etc).
- See https://github.com/Khan/KaTeX/wiki/Examining-TeX#group-types
- * - replace: the character that this symbol or function should be
- * replaced with (i.e. "\phi" has a replace value of "\u03d5", the phi
- * character in the main font).
- *
- * The outermost map in the table indicates what mode the symbols should be
- * accepted in (e.g. "math" or "text").
- */
-
-module.exports = {
- math: {},
- text: {},
-};
-
-function defineSymbol(mode, font, group, replace, name) {
- module.exports[mode][name] = {
- font: font,
- group: group,
- replace: replace,
- };
-}
-
-// Some abbreviations for commonly used strings.
-// This helps minify the code, and also spotting typos using jshint.
-
-// modes:
-var math = "math";
-var text = "text";
-
-// fonts:
-var main = "main";
-var ams = "ams";
-
-// groups:
-var accent = "accent";
-var bin = "bin";
-var close = "close";
-var inner = "inner";
-var mathord = "mathord";
-var op = "op";
-var open = "open";
-var punct = "punct";
-var rel = "rel";
-var spacing = "spacing";
-var textord = "textord";
-
-// Now comes the symbol table
-
-// Relation Symbols
-defineSymbol(math, main, rel, "\u2261", "\\equiv");
-defineSymbol(math, main, rel, "\u227a", "\\prec");
-defineSymbol(math, main, rel, "\u227b", "\\succ");
-defineSymbol(math, main, rel, "\u223c", "\\sim");
-defineSymbol(math, main, rel, "\u22a5", "\\perp");
-defineSymbol(math, main, rel, "\u2aaf", "\\preceq");
-defineSymbol(math, main, rel, "\u2ab0", "\\succeq");
-defineSymbol(math, main, rel, "\u2243", "\\simeq");
-defineSymbol(math, main, rel, "\u2223", "\\mid");
-defineSymbol(math, main, rel, "\u226a", "\\ll");
-defineSymbol(math, main, rel, "\u226b", "\\gg");
-defineSymbol(math, main, rel, "\u224d", "\\asymp");
-defineSymbol(math, main, rel, "\u2225", "\\parallel");
-defineSymbol(math, main, rel, "\u22c8", "\\bowtie");
-defineSymbol(math, main, rel, "\u2323", "\\smile");
-defineSymbol(math, main, rel, "\u2291", "\\sqsubseteq");
-defineSymbol(math, main, rel, "\u2292", "\\sqsupseteq");
-defineSymbol(math, main, rel, "\u2250", "\\doteq");
-defineSymbol(math, main, rel, "\u2322", "\\frown");
-defineSymbol(math, main, rel, "\u220b", "\\ni");
-defineSymbol(math, main, rel, "\u221d", "\\propto");
-defineSymbol(math, main, rel, "\u22a2", "\\vdash");
-defineSymbol(math, main, rel, "\u22a3", "\\dashv");
-defineSymbol(math, main, rel, "\u220b", "\\owns");
-
-// Punctuation
-defineSymbol(math, main, punct, "\u002e", "\\ldotp");
-defineSymbol(math, main, punct, "\u22c5", "\\cdotp");
-
-// Misc Symbols
-defineSymbol(math, main, textord, "\u0023", "\\#");
-defineSymbol(math, main, textord, "\u0026", "\\&");
-defineSymbol(math, main, textord, "\u2135", "\\aleph");
-defineSymbol(math, main, textord, "\u2200", "\\forall");
-defineSymbol(math, main, textord, "\u210f", "\\hbar");
-defineSymbol(math, main, textord, "\u2203", "\\exists");
-defineSymbol(math, main, textord, "\u2207", "\\nabla");
-defineSymbol(math, main, textord, "\u266d", "\\flat");
-defineSymbol(math, main, textord, "\u2113", "\\ell");
-defineSymbol(math, main, textord, "\u266e", "\\natural");
-defineSymbol(math, main, textord, "\u2663", "\\clubsuit");
-defineSymbol(math, main, textord, "\u2118", "\\wp");
-defineSymbol(math, main, textord, "\u266f", "\\sharp");
-defineSymbol(math, main, textord, "\u2662", "\\diamondsuit");
-defineSymbol(math, main, textord, "\u211c", "\\Re");
-defineSymbol(math, main, textord, "\u2661", "\\heartsuit");
-defineSymbol(math, main, textord, "\u2111", "\\Im");
-defineSymbol(math, main, textord, "\u2660", "\\spadesuit");
-
-// Math and Text
-defineSymbol(math, main, textord, "\u2020", "\\dag");
-defineSymbol(math, main, textord, "\u2021", "\\ddag");
-
-// Large Delimiters
-defineSymbol(math, main, close, "\u23b1", "\\rmoustache");
-defineSymbol(math, main, open, "\u23b0", "\\lmoustache");
-defineSymbol(math, main, close, "\u27ef", "\\rgroup");
-defineSymbol(math, main, open, "\u27ee", "\\lgroup");
-
-// Binary Operators
-defineSymbol(math, main, bin, "\u2213", "\\mp");
-defineSymbol(math, main, bin, "\u2296", "\\ominus");
-defineSymbol(math, main, bin, "\u228e", "\\uplus");
-defineSymbol(math, main, bin, "\u2293", "\\sqcap");
-defineSymbol(math, main, bin, "\u2217", "\\ast");
-defineSymbol(math, main, bin, "\u2294", "\\sqcup");
-defineSymbol(math, main, bin, "\u25ef", "\\bigcirc");
-defineSymbol(math, main, bin, "\u2219", "\\bullet");
-defineSymbol(math, main, bin, "\u2021", "\\ddagger");
-defineSymbol(math, main, bin, "\u2240", "\\wr");
-defineSymbol(math, main, bin, "\u2a3f", "\\amalg");
-
-// Arrow Symbols
-defineSymbol(math, main, rel, "\u27f5", "\\longleftarrow");
-defineSymbol(math, main, rel, "\u21d0", "\\Leftarrow");
-defineSymbol(math, main, rel, "\u27f8", "\\Longleftarrow");
-defineSymbol(math, main, rel, "\u27f6", "\\longrightarrow");
-defineSymbol(math, main, rel, "\u21d2", "\\Rightarrow");
-defineSymbol(math, main, rel, "\u27f9", "\\Longrightarrow");
-defineSymbol(math, main, rel, "\u2194", "\\leftrightarrow");
-defineSymbol(math, main, rel, "\u27f7", "\\longleftrightarrow");
-defineSymbol(math, main, rel, "\u21d4", "\\Leftrightarrow");
-defineSymbol(math, main, rel, "\u27fa", "\\Longleftrightarrow");
-defineSymbol(math, main, rel, "\u21a6", "\\mapsto");
-defineSymbol(math, main, rel, "\u27fc", "\\longmapsto");
-defineSymbol(math, main, rel, "\u2197", "\\nearrow");
-defineSymbol(math, main, rel, "\u21a9", "\\hookleftarrow");
-defineSymbol(math, main, rel, "\u21aa", "\\hookrightarrow");
-defineSymbol(math, main, rel, "\u2198", "\\searrow");
-defineSymbol(math, main, rel, "\u21bc", "\\leftharpoonup");
-defineSymbol(math, main, rel, "\u21c0", "\\rightharpoonup");
-defineSymbol(math, main, rel, "\u2199", "\\swarrow");
-defineSymbol(math, main, rel, "\u21bd", "\\leftharpoondown");
-defineSymbol(math, main, rel, "\u21c1", "\\rightharpoondown");
-defineSymbol(math, main, rel, "\u2196", "\\nwarrow");
-defineSymbol(math, main, rel, "\u21cc", "\\rightleftharpoons");
-
-// AMS Negated Binary Relations
-defineSymbol(math, ams, rel, "\u226e", "\\nless");
-defineSymbol(math, ams, rel, "\ue010", "\\nleqslant");
-defineSymbol(math, ams, rel, "\ue011", "\\nleqq");
-defineSymbol(math, ams, rel, "\u2a87", "\\lneq");
-defineSymbol(math, ams, rel, "\u2268", "\\lneqq");
-defineSymbol(math, ams, rel, "\ue00c", "\\lvertneqq");
-defineSymbol(math, ams, rel, "\u22e6", "\\lnsim");
-defineSymbol(math, ams, rel, "\u2a89", "\\lnapprox");
-defineSymbol(math, ams, rel, "\u2280", "\\nprec");
-defineSymbol(math, ams, rel, "\u22e0", "\\npreceq");
-defineSymbol(math, ams, rel, "\u22e8", "\\precnsim");
-defineSymbol(math, ams, rel, "\u2ab9", "\\precnapprox");
-defineSymbol(math, ams, rel, "\u2241", "\\nsim");
-defineSymbol(math, ams, rel, "\ue006", "\\nshortmid");
-defineSymbol(math, ams, rel, "\u2224", "\\nmid");
-defineSymbol(math, ams, rel, "\u22ac", "\\nvdash");
-defineSymbol(math, ams, rel, "\u22ad", "\\nvDash");
-defineSymbol(math, ams, rel, "\u22ea", "\\ntriangleleft");
-defineSymbol(math, ams, rel, "\u22ec", "\\ntrianglelefteq");
-defineSymbol(math, ams, rel, "\u228a", "\\subsetneq");
-defineSymbol(math, ams, rel, "\ue01a", "\\varsubsetneq");
-defineSymbol(math, ams, rel, "\u2acb", "\\subsetneqq");
-defineSymbol(math, ams, rel, "\ue017", "\\varsubsetneqq");
-defineSymbol(math, ams, rel, "\u226f", "\\ngtr");
-defineSymbol(math, ams, rel, "\ue00f", "\\ngeqslant");
-defineSymbol(math, ams, rel, "\ue00e", "\\ngeqq");
-defineSymbol(math, ams, rel, "\u2a88", "\\gneq");
-defineSymbol(math, ams, rel, "\u2269", "\\gneqq");
-defineSymbol(math, ams, rel, "\ue00d", "\\gvertneqq");
-defineSymbol(math, ams, rel, "\u22e7", "\\gnsim");
-defineSymbol(math, ams, rel, "\u2a8a", "\\gnapprox");
-defineSymbol(math, ams, rel, "\u2281", "\\nsucc");
-defineSymbol(math, ams, rel, "\u22e1", "\\nsucceq");
-defineSymbol(math, ams, rel, "\u22e9", "\\succnsim");
-defineSymbol(math, ams, rel, "\u2aba", "\\succnapprox");
-defineSymbol(math, ams, rel, "\u2246", "\\ncong");
-defineSymbol(math, ams, rel, "\ue007", "\\nshortparallel");
-defineSymbol(math, ams, rel, "\u2226", "\\nparallel");
-defineSymbol(math, ams, rel, "\u22af", "\\nVDash");
-defineSymbol(math, ams, rel, "\u22eb", "\\ntriangleright");
-defineSymbol(math, ams, rel, "\u22ed", "\\ntrianglerighteq");
-defineSymbol(math, ams, rel, "\ue018", "\\nsupseteqq");
-defineSymbol(math, ams, rel, "\u228b", "\\supsetneq");
-defineSymbol(math, ams, rel, "\ue01b", "\\varsupsetneq");
-defineSymbol(math, ams, rel, "\u2acc", "\\supsetneqq");
-defineSymbol(math, ams, rel, "\ue019", "\\varsupsetneqq");
-defineSymbol(math, ams, rel, "\u22ae", "\\nVdash");
-defineSymbol(math, ams, rel, "\u2ab5", "\\precneqq");
-defineSymbol(math, ams, rel, "\u2ab6", "\\succneqq");
-defineSymbol(math, ams, rel, "\ue016", "\\nsubseteqq");
-defineSymbol(math, ams, bin, "\u22b4", "\\unlhd");
-defineSymbol(math, ams, bin, "\u22b5", "\\unrhd");
-
-// AMS Negated Arrows
-defineSymbol(math, ams, rel, "\u219a", "\\nleftarrow");
-defineSymbol(math, ams, rel, "\u219b", "\\nrightarrow");
-defineSymbol(math, ams, rel, "\u21cd", "\\nLeftarrow");
-defineSymbol(math, ams, rel, "\u21cf", "\\nRightarrow");
-defineSymbol(math, ams, rel, "\u21ae", "\\nleftrightarrow");
-defineSymbol(math, ams, rel, "\u21ce", "\\nLeftrightarrow");
-
-// AMS Misc
-defineSymbol(math, ams, rel, "\u25b3", "\\vartriangle");
-defineSymbol(math, ams, textord, "\u210f", "\\hslash");
-defineSymbol(math, ams, textord, "\u25bd", "\\triangledown");
-defineSymbol(math, ams, textord, "\u25ca", "\\lozenge");
-defineSymbol(math, ams, textord, "\u24c8", "\\circledS");
-defineSymbol(math, ams, textord, "\u00ae", "\\circledR");
-defineSymbol(math, ams, textord, "\u2221", "\\measuredangle");
-defineSymbol(math, ams, textord, "\u2204", "\\nexists");
-defineSymbol(math, ams, textord, "\u2127", "\\mho");
-defineSymbol(math, ams, textord, "\u2132", "\\Finv");
-defineSymbol(math, ams, textord, "\u2141", "\\Game");
-defineSymbol(math, ams, textord, "\u006b", "\\Bbbk");
-defineSymbol(math, ams, textord, "\u2035", "\\backprime");
-defineSymbol(math, ams, textord, "\u25b2", "\\blacktriangle");
-defineSymbol(math, ams, textord, "\u25bc", "\\blacktriangledown");
-defineSymbol(math, ams, textord, "\u25a0", "\\blacksquare");
-defineSymbol(math, ams, textord, "\u29eb", "\\blacklozenge");
-defineSymbol(math, ams, textord, "\u2605", "\\bigstar");
-defineSymbol(math, ams, textord, "\u2222", "\\sphericalangle");
-defineSymbol(math, ams, textord, "\u2201", "\\complement");
-defineSymbol(math, ams, textord, "\u00f0", "\\eth");
-defineSymbol(math, ams, textord, "\u2571", "\\diagup");
-defineSymbol(math, ams, textord, "\u2572", "\\diagdown");
-defineSymbol(math, ams, textord, "\u25a1", "\\square");
-defineSymbol(math, ams, textord, "\u25a1", "\\Box");
-defineSymbol(math, ams, textord, "\u25ca", "\\Diamond");
-defineSymbol(math, ams, textord, "\u00a5", "\\yen");
-defineSymbol(math, ams, textord, "\u2713", "\\checkmark");
-
-// AMS Hebrew
-defineSymbol(math, ams, textord, "\u2136", "\\beth");
-defineSymbol(math, ams, textord, "\u2138", "\\daleth");
-defineSymbol(math, ams, textord, "\u2137", "\\gimel");
-
-// AMS Greek
-defineSymbol(math, ams, textord, "\u03dd", "\\digamma");
-defineSymbol(math, ams, textord, "\u03f0", "\\varkappa");
-
-// AMS Delimiters
-defineSymbol(math, ams, open, "\u250c", "\\ulcorner");
-defineSymbol(math, ams, close, "\u2510", "\\urcorner");
-defineSymbol(math, ams, open, "\u2514", "\\llcorner");
-defineSymbol(math, ams, close, "\u2518", "\\lrcorner");
-
-// AMS Binary Relations
-defineSymbol(math, ams, rel, "\u2266", "\\leqq");
-defineSymbol(math, ams, rel, "\u2a7d", "\\leqslant");
-defineSymbol(math, ams, rel, "\u2a95", "\\eqslantless");
-defineSymbol(math, ams, rel, "\u2272", "\\lesssim");
-defineSymbol(math, ams, rel, "\u2a85", "\\lessapprox");
-defineSymbol(math, ams, rel, "\u224a", "\\approxeq");
-defineSymbol(math, ams, bin, "\u22d6", "\\lessdot");
-defineSymbol(math, ams, rel, "\u22d8", "\\lll");
-defineSymbol(math, ams, rel, "\u2276", "\\lessgtr");
-defineSymbol(math, ams, rel, "\u22da", "\\lesseqgtr");
-defineSymbol(math, ams, rel, "\u2a8b", "\\lesseqqgtr");
-defineSymbol(math, ams, rel, "\u2251", "\\doteqdot");
-defineSymbol(math, ams, rel, "\u2253", "\\risingdotseq");
-defineSymbol(math, ams, rel, "\u2252", "\\fallingdotseq");
-defineSymbol(math, ams, rel, "\u223d", "\\backsim");
-defineSymbol(math, ams, rel, "\u22cd", "\\backsimeq");
-defineSymbol(math, ams, rel, "\u2ac5", "\\subseteqq");
-defineSymbol(math, ams, rel, "\u22d0", "\\Subset");
-defineSymbol(math, ams, rel, "\u228f", "\\sqsubset");
-defineSymbol(math, ams, rel, "\u227c", "\\preccurlyeq");
-defineSymbol(math, ams, rel, "\u22de", "\\curlyeqprec");
-defineSymbol(math, ams, rel, "\u227e", "\\precsim");
-defineSymbol(math, ams, rel, "\u2ab7", "\\precapprox");
-defineSymbol(math, ams, rel, "\u22b2", "\\vartriangleleft");
-defineSymbol(math, ams, rel, "\u22b4", "\\trianglelefteq");
-defineSymbol(math, ams, rel, "\u22a8", "\\vDash");
-defineSymbol(math, ams, rel, "\u22aa", "\\Vvdash");
-defineSymbol(math, ams, rel, "\u2323", "\\smallsmile");
-defineSymbol(math, ams, rel, "\u2322", "\\smallfrown");
-defineSymbol(math, ams, rel, "\u224f", "\\bumpeq");
-defineSymbol(math, ams, rel, "\u224e", "\\Bumpeq");
-defineSymbol(math, ams, rel, "\u2267", "\\geqq");
-defineSymbol(math, ams, rel, "\u2a7e", "\\geqslant");
-defineSymbol(math, ams, rel, "\u2a96", "\\eqslantgtr");
-defineSymbol(math, ams, rel, "\u2273", "\\gtrsim");
-defineSymbol(math, ams, rel, "\u2a86", "\\gtrapprox");
-defineSymbol(math, ams, bin, "\u22d7", "\\gtrdot");
-defineSymbol(math, ams, rel, "\u22d9", "\\ggg");
-defineSymbol(math, ams, rel, "\u2277", "\\gtrless");
-defineSymbol(math, ams, rel, "\u22db", "\\gtreqless");
-defineSymbol(math, ams, rel, "\u2a8c", "\\gtreqqless");
-defineSymbol(math, ams, rel, "\u2256", "\\eqcirc");
-defineSymbol(math, ams, rel, "\u2257", "\\circeq");
-defineSymbol(math, ams, rel, "\u225c", "\\triangleq");
-defineSymbol(math, ams, rel, "\u223c", "\\thicksim");
-defineSymbol(math, ams, rel, "\u2248", "\\thickapprox");
-defineSymbol(math, ams, rel, "\u2ac6", "\\supseteqq");
-defineSymbol(math, ams, rel, "\u22d1", "\\Supset");
-defineSymbol(math, ams, rel, "\u2290", "\\sqsupset");
-defineSymbol(math, ams, rel, "\u227d", "\\succcurlyeq");
-defineSymbol(math, ams, rel, "\u22df", "\\curlyeqsucc");
-defineSymbol(math, ams, rel, "\u227f", "\\succsim");
-defineSymbol(math, ams, rel, "\u2ab8", "\\succapprox");
-defineSymbol(math, ams, rel, "\u22b3", "\\vartriangleright");
-defineSymbol(math, ams, rel, "\u22b5", "\\trianglerighteq");
-defineSymbol(math, ams, rel, "\u22a9", "\\Vdash");
-defineSymbol(math, ams, rel, "\u2223", "\\shortmid");
-defineSymbol(math, ams, rel, "\u2225", "\\shortparallel");
-defineSymbol(math, ams, rel, "\u226c", "\\between");
-defineSymbol(math, ams, rel, "\u22d4", "\\pitchfork");
-defineSymbol(math, ams, rel, "\u221d", "\\varpropto");
-defineSymbol(math, ams, rel, "\u25c0", "\\blacktriangleleft");
-defineSymbol(math, ams, rel, "\u2234", "\\therefore");
-defineSymbol(math, ams, rel, "\u220d", "\\backepsilon");
-defineSymbol(math, ams, rel, "\u25b6", "\\blacktriangleright");
-defineSymbol(math, ams, rel, "\u2235", "\\because");
-defineSymbol(math, ams, rel, "\u22d8", "\\llless");
-defineSymbol(math, ams, rel, "\u22d9", "\\gggtr");
-defineSymbol(math, ams, bin, "\u22b2", "\\lhd");
-defineSymbol(math, ams, bin, "\u22b3", "\\rhd");
-defineSymbol(math, ams, rel, "\u2242", "\\eqsim");
-defineSymbol(math, main, rel, "\u22c8", "\\Join");
-defineSymbol(math, ams, rel, "\u2251", "\\Doteq");
-
-// AMS Binary Operators
-defineSymbol(math, ams, bin, "\u2214", "\\dotplus");
-defineSymbol(math, ams, bin, "\u2216", "\\smallsetminus");
-defineSymbol(math, ams, bin, "\u22d2", "\\Cap");
-defineSymbol(math, ams, bin, "\u22d3", "\\Cup");
-defineSymbol(math, ams, bin, "\u2a5e", "\\doublebarwedge");
-defineSymbol(math, ams, bin, "\u229f", "\\boxminus");
-defineSymbol(math, ams, bin, "\u229e", "\\boxplus");
-defineSymbol(math, ams, bin, "\u22c7", "\\divideontimes");
-defineSymbol(math, ams, bin, "\u22c9", "\\ltimes");
-defineSymbol(math, ams, bin, "\u22ca", "\\rtimes");
-defineSymbol(math, ams, bin, "\u22cb", "\\leftthreetimes");
-defineSymbol(math, ams, bin, "\u22cc", "\\rightthreetimes");
-defineSymbol(math, ams, bin, "\u22cf", "\\curlywedge");
-defineSymbol(math, ams, bin, "\u22ce", "\\curlyvee");
-defineSymbol(math, ams, bin, "\u229d", "\\circleddash");
-defineSymbol(math, ams, bin, "\u229b", "\\circledast");
-defineSymbol(math, ams, bin, "\u22c5", "\\centerdot");
-defineSymbol(math, ams, bin, "\u22ba", "\\intercal");
-defineSymbol(math, ams, bin, "\u22d2", "\\doublecap");
-defineSymbol(math, ams, bin, "\u22d3", "\\doublecup");
-defineSymbol(math, ams, bin, "\u22a0", "\\boxtimes");
-
-// AMS Arrows
-defineSymbol(math, ams, rel, "\u21e2", "\\dashrightarrow");
-defineSymbol(math, ams, rel, "\u21e0", "\\dashleftarrow");
-defineSymbol(math, ams, rel, "\u21c7", "\\leftleftarrows");
-defineSymbol(math, ams, rel, "\u21c6", "\\leftrightarrows");
-defineSymbol(math, ams, rel, "\u21da", "\\Lleftarrow");
-defineSymbol(math, ams, rel, "\u219e", "\\twoheadleftarrow");
-defineSymbol(math, ams, rel, "\u21a2", "\\leftarrowtail");
-defineSymbol(math, ams, rel, "\u21ab", "\\looparrowleft");
-defineSymbol(math, ams, rel, "\u21cb", "\\leftrightharpoons");
-defineSymbol(math, ams, rel, "\u21b6", "\\curvearrowleft");
-defineSymbol(math, ams, rel, "\u21ba", "\\circlearrowleft");
-defineSymbol(math, ams, rel, "\u21b0", "\\Lsh");
-defineSymbol(math, ams, rel, "\u21c8", "\\upuparrows");
-defineSymbol(math, ams, rel, "\u21bf", "\\upharpoonleft");
-defineSymbol(math, ams, rel, "\u21c3", "\\downharpoonleft");
-defineSymbol(math, ams, rel, "\u22b8", "\\multimap");
-defineSymbol(math, ams, rel, "\u21ad", "\\leftrightsquigarrow");
-defineSymbol(math, ams, rel, "\u21c9", "\\rightrightarrows");
-defineSymbol(math, ams, rel, "\u21c4", "\\rightleftarrows");
-defineSymbol(math, ams, rel, "\u21a0", "\\twoheadrightarrow");
-defineSymbol(math, ams, rel, "\u21a3", "\\rightarrowtail");
-defineSymbol(math, ams, rel, "\u21ac", "\\looparrowright");
-defineSymbol(math, ams, rel, "\u21b7", "\\curvearrowright");
-defineSymbol(math, ams, rel, "\u21bb", "\\circlearrowright");
-defineSymbol(math, ams, rel, "\u21b1", "\\Rsh");
-defineSymbol(math, ams, rel, "\u21ca", "\\downdownarrows");
-defineSymbol(math, ams, rel, "\u21be", "\\upharpoonright");
-defineSymbol(math, ams, rel, "\u21c2", "\\downharpoonright");
-defineSymbol(math, ams, rel, "\u21dd", "\\rightsquigarrow");
-defineSymbol(math, ams, rel, "\u21dd", "\\leadsto");
-defineSymbol(math, ams, rel, "\u21db", "\\Rrightarrow");
-defineSymbol(math, ams, rel, "\u21be", "\\restriction");
-
-defineSymbol(math, main, textord, "\u2018", "`");
-defineSymbol(math, main, textord, "$", "\\$");
-defineSymbol(math, main, textord, "%", "\\%");
-defineSymbol(math, main, textord, "_", "\\_");
-defineSymbol(math, main, textord, "\u2220", "\\angle");
-defineSymbol(math, main, textord, "\u221e", "\\infty");
-defineSymbol(math, main, textord, "\u2032", "\\prime");
-defineSymbol(math, main, textord, "\u25b3", "\\triangle");
-defineSymbol(math, main, textord, "\u0393", "\\Gamma");
-defineSymbol(math, main, textord, "\u0394", "\\Delta");
-defineSymbol(math, main, textord, "\u0398", "\\Theta");
-defineSymbol(math, main, textord, "\u039b", "\\Lambda");
-defineSymbol(math, main, textord, "\u039e", "\\Xi");
-defineSymbol(math, main, textord, "\u03a0", "\\Pi");
-defineSymbol(math, main, textord, "\u03a3", "\\Sigma");
-defineSymbol(math, main, textord, "\u03a5", "\\Upsilon");
-defineSymbol(math, main, textord, "\u03a6", "\\Phi");
-defineSymbol(math, main, textord, "\u03a8", "\\Psi");
-defineSymbol(math, main, textord, "\u03a9", "\\Omega");
-defineSymbol(math, main, textord, "\u00ac", "\\neg");
-defineSymbol(math, main, textord, "\u00ac", "\\lnot");
-defineSymbol(math, main, textord, "\u22a4", "\\top");
-defineSymbol(math, main, textord, "\u22a5", "\\bot");
-defineSymbol(math, main, textord, "\u2205", "\\emptyset");
-defineSymbol(math, ams, textord, "\u2205", "\\varnothing");
-defineSymbol(math, main, mathord, "\u03b1", "\\alpha");
-defineSymbol(math, main, mathord, "\u03b2", "\\beta");
-defineSymbol(math, main, mathord, "\u03b3", "\\gamma");
-defineSymbol(math, main, mathord, "\u03b4", "\\delta");
-defineSymbol(math, main, mathord, "\u03f5", "\\epsilon");
-defineSymbol(math, main, mathord, "\u03b6", "\\zeta");
-defineSymbol(math, main, mathord, "\u03b7", "\\eta");
-defineSymbol(math, main, mathord, "\u03b8", "\\theta");
-defineSymbol(math, main, mathord, "\u03b9", "\\iota");
-defineSymbol(math, main, mathord, "\u03ba", "\\kappa");
-defineSymbol(math, main, mathord, "\u03bb", "\\lambda");
-defineSymbol(math, main, mathord, "\u03bc", "\\mu");
-defineSymbol(math, main, mathord, "\u03bd", "\\nu");
-defineSymbol(math, main, mathord, "\u03be", "\\xi");
-defineSymbol(math, main, mathord, "o", "\\omicron");
-defineSymbol(math, main, mathord, "\u03c0", "\\pi");
-defineSymbol(math, main, mathord, "\u03c1", "\\rho");
-defineSymbol(math, main, mathord, "\u03c3", "\\sigma");
-defineSymbol(math, main, mathord, "\u03c4", "\\tau");
-defineSymbol(math, main, mathord, "\u03c5", "\\upsilon");
-defineSymbol(math, main, mathord, "\u03d5", "\\phi");
-defineSymbol(math, main, mathord, "\u03c7", "\\chi");
-defineSymbol(math, main, mathord, "\u03c8", "\\psi");
-defineSymbol(math, main, mathord, "\u03c9", "\\omega");
-defineSymbol(math, main, mathord, "\u03b5", "\\varepsilon");
-defineSymbol(math, main, mathord, "\u03d1", "\\vartheta");
-defineSymbol(math, main, mathord, "\u03d6", "\\varpi");
-defineSymbol(math, main, mathord, "\u03f1", "\\varrho");
-defineSymbol(math, main, mathord, "\u03c2", "\\varsigma");
-defineSymbol(math, main, mathord, "\u03c6", "\\varphi");
-defineSymbol(math, main, bin, "\u2217", "*");
-defineSymbol(math, main, bin, "+", "+");
-defineSymbol(math, main, bin, "\u2212", "-");
-defineSymbol(math, main, bin, "\u22c5", "\\cdot");
-defineSymbol(math, main, bin, "\u2218", "\\circ");
-defineSymbol(math, main, bin, "\u00f7", "\\div");
-defineSymbol(math, main, bin, "\u00b1", "\\pm");
-defineSymbol(math, main, bin, "\u00d7", "\\times");
-defineSymbol(math, main, bin, "\u2229", "\\cap");
-defineSymbol(math, main, bin, "\u222a", "\\cup");
-defineSymbol(math, main, bin, "\u2216", "\\setminus");
-defineSymbol(math, main, bin, "\u2227", "\\land");
-defineSymbol(math, main, bin, "\u2228", "\\lor");
-defineSymbol(math, main, bin, "\u2227", "\\wedge");
-defineSymbol(math, main, bin, "\u2228", "\\vee");
-defineSymbol(math, main, textord, "\u221a", "\\surd");
-defineSymbol(math, main, open, "(", "(");
-defineSymbol(math, main, open, "[", "[");
-defineSymbol(math, main, open, "\u27e8", "\\langle");
-defineSymbol(math, main, open, "\u2223", "\\lvert");
-defineSymbol(math, main, open, "\u2225", "\\lVert");
-defineSymbol(math, main, close, ")", ")");
-defineSymbol(math, main, close, "]", "]");
-defineSymbol(math, main, close, "?", "?");
-defineSymbol(math, main, close, "!", "!");
-defineSymbol(math, main, close, "\u27e9", "\\rangle");
-defineSymbol(math, main, close, "\u2223", "\\rvert");
-defineSymbol(math, main, close, "\u2225", "\\rVert");
-defineSymbol(math, main, rel, "=", "=");
-defineSymbol(math, main, rel, "<", "<");
-defineSymbol(math, main, rel, ">", ">");
-defineSymbol(math, main, rel, ":", ":");
-defineSymbol(math, main, rel, "\u2248", "\\approx");
-defineSymbol(math, main, rel, "\u2245", "\\cong");
-defineSymbol(math, main, rel, "\u2265", "\\ge");
-defineSymbol(math, main, rel, "\u2265", "\\geq");
-defineSymbol(math, main, rel, "\u2190", "\\gets");
-defineSymbol(math, main, rel, ">", "\\gt");
-defineSymbol(math, main, rel, "\u2208", "\\in");
-defineSymbol(math, main, rel, "\u2209", "\\notin");
-defineSymbol(math, main, rel, "\u2282", "\\subset");
-defineSymbol(math, main, rel, "\u2283", "\\supset");
-defineSymbol(math, main, rel, "\u2286", "\\subseteq");
-defineSymbol(math, main, rel, "\u2287", "\\supseteq");
-defineSymbol(math, ams, rel, "\u2288", "\\nsubseteq");
-defineSymbol(math, ams, rel, "\u2289", "\\nsupseteq");
-defineSymbol(math, main, rel, "\u22a8", "\\models");
-defineSymbol(math, main, rel, "\u2190", "\\leftarrow");
-defineSymbol(math, main, rel, "\u2264", "\\le");
-defineSymbol(math, main, rel, "\u2264", "\\leq");
-defineSymbol(math, main, rel, "<", "\\lt");
-defineSymbol(math, main, rel, "\u2260", "\\ne");
-defineSymbol(math, main, rel, "\u2260", "\\neq");
-defineSymbol(math, main, rel, "\u2192", "\\rightarrow");
-defineSymbol(math, main, rel, "\u2192", "\\to");
-defineSymbol(math, ams, rel, "\u2271", "\\ngeq");
-defineSymbol(math, ams, rel, "\u2270", "\\nleq");
-defineSymbol(math, main, spacing, null, "\\!");
-defineSymbol(math, main, spacing, "\u00a0", "\\ ");
-defineSymbol(math, main, spacing, "\u00a0", "~");
-defineSymbol(math, main, spacing, null, "\\,");
-defineSymbol(math, main, spacing, null, "\\:");
-defineSymbol(math, main, spacing, null, "\\;");
-defineSymbol(math, main, spacing, null, "\\enspace");
-defineSymbol(math, main, spacing, null, "\\qquad");
-defineSymbol(math, main, spacing, null, "\\quad");
-defineSymbol(math, main, spacing, "\u00a0", "\\space");
-defineSymbol(math, main, punct, ",", ",");
-defineSymbol(math, main, punct, ";", ";");
-defineSymbol(math, main, punct, ":", "\\colon");
-defineSymbol(math, ams, bin, "\u22bc", "\\barwedge");
-defineSymbol(math, ams, bin, "\u22bb", "\\veebar");
-defineSymbol(math, main, bin, "\u2299", "\\odot");
-defineSymbol(math, main, bin, "\u2295", "\\oplus");
-defineSymbol(math, main, bin, "\u2297", "\\otimes");
-defineSymbol(math, main, textord, "\u2202", "\\partial");
-defineSymbol(math, main, bin, "\u2298", "\\oslash");
-defineSymbol(math, ams, bin, "\u229a", "\\circledcirc");
-defineSymbol(math, ams, bin, "\u22a1", "\\boxdot");
-defineSymbol(math, main, bin, "\u25b3", "\\bigtriangleup");
-defineSymbol(math, main, bin, "\u25bd", "\\bigtriangledown");
-defineSymbol(math, main, bin, "\u2020", "\\dagger");
-defineSymbol(math, main, bin, "\u22c4", "\\diamond");
-defineSymbol(math, main, bin, "\u22c6", "\\star");
-defineSymbol(math, main, bin, "\u25c3", "\\triangleleft");
-defineSymbol(math, main, bin, "\u25b9", "\\triangleright");
-defineSymbol(math, main, open, "{", "\\{");
-defineSymbol(math, main, close, "}", "\\}");
-defineSymbol(math, main, open, "{", "\\lbrace");
-defineSymbol(math, main, close, "}", "\\rbrace");
-defineSymbol(math, main, open, "[", "\\lbrack");
-defineSymbol(math, main, close, "]", "\\rbrack");
-defineSymbol(math, main, open, "\u230a", "\\lfloor");
-defineSymbol(math, main, close, "\u230b", "\\rfloor");
-defineSymbol(math, main, open, "\u2308", "\\lceil");
-defineSymbol(math, main, close, "\u2309", "\\rceil");
-defineSymbol(math, main, textord, "\\", "\\backslash");
-defineSymbol(math, main, textord, "\u2223", "|");
-defineSymbol(math, main, textord, "\u2223", "\\vert");
-defineSymbol(math, main, textord, "\u2225", "\\|");
-defineSymbol(math, main, textord, "\u2225", "\\Vert");
-defineSymbol(math, main, rel, "\u2191", "\\uparrow");
-defineSymbol(math, main, rel, "\u21d1", "\\Uparrow");
-defineSymbol(math, main, rel, "\u2193", "\\downarrow");
-defineSymbol(math, main, rel, "\u21d3", "\\Downarrow");
-defineSymbol(math, main, rel, "\u2195", "\\updownarrow");
-defineSymbol(math, main, rel, "\u21d5", "\\Updownarrow");
-defineSymbol(math, math, op, "\u2210", "\\coprod");
-defineSymbol(math, math, op, "\u22c1", "\\bigvee");
-defineSymbol(math, math, op, "\u22c0", "\\bigwedge");
-defineSymbol(math, math, op, "\u2a04", "\\biguplus");
-defineSymbol(math, math, op, "\u22c2", "\\bigcap");
-defineSymbol(math, math, op, "\u22c3", "\\bigcup");
-defineSymbol(math, math, op, "\u222b", "\\int");
-defineSymbol(math, math, op, "\u222b", "\\intop");
-defineSymbol(math, math, op, "\u222c", "\\iint");
-defineSymbol(math, math, op, "\u222d", "\\iiint");
-defineSymbol(math, math, op, "\u220f", "\\prod");
-defineSymbol(math, math, op, "\u2211", "\\sum");
-defineSymbol(math, math, op, "\u2a02", "\\bigotimes");
-defineSymbol(math, math, op, "\u2a01", "\\bigoplus");
-defineSymbol(math, math, op, "\u2a00", "\\bigodot");
-defineSymbol(math, math, op, "\u222e", "\\oint");
-defineSymbol(math, math, op, "\u2a06", "\\bigsqcup");
-defineSymbol(math, math, op, "\u222b", "\\smallint");
-defineSymbol(math, main, inner, "\u2026", "\\ldots");
-defineSymbol(math, main, inner, "\u22ef", "\\cdots");
-defineSymbol(math, main, inner, "\u22f1", "\\ddots");
-defineSymbol(math, main, textord, "\u22ee", "\\vdots");
-defineSymbol(math, main, accent, "\u00b4", "\\acute");
-defineSymbol(math, main, accent, "\u0060", "\\grave");
-defineSymbol(math, main, accent, "\u00a8", "\\ddot");
-defineSymbol(math, main, accent, "\u007e", "\\tilde");
-defineSymbol(math, main, accent, "\u00af", "\\bar");
-defineSymbol(math, main, accent, "\u02d8", "\\breve");
-defineSymbol(math, main, accent, "\u02c7", "\\check");
-defineSymbol(math, main, accent, "\u005e", "\\hat");
-defineSymbol(math, main, accent, "\u20d7", "\\vec");
-defineSymbol(math, main, accent, "\u02d9", "\\dot");
-defineSymbol(math, main, mathord, "\u0131", "\\imath");
-defineSymbol(math, main, mathord, "\u0237", "\\jmath");
-
-defineSymbol(text, main, textord, "\u2013", "--");
-defineSymbol(text, main, textord, "\u2014", "---");
-defineSymbol(text, main, textord, "\u2018", "`");
-defineSymbol(text, main, textord, "\u2019", "'");
-defineSymbol(text, main, textord, "\u201c", "``");
-defineSymbol(text, main, textord, "\u201d", "''");
-defineSymbol(math, main, textord, "\u00b0", "\\degree");
-defineSymbol(text, main, textord, "\u00b0", "\\degree");
-defineSymbol(math, main, mathord, "\u00a3", "\\pounds");
-defineSymbol(math, ams, textord, "\u2720", "\\maltese");
-defineSymbol(text, ams, textord, "\u2720", "\\maltese");
-
-defineSymbol(text, main, spacing, "\u00a0", "\\ ");
-defineSymbol(text, main, spacing, "\u00a0", " ");
-defineSymbol(text, main, spacing, "\u00a0", "~");
-
-// There are lots of symbols which are the same, so we add them in afterwards.
-var i;
-var ch;
-
-// All of these are textords in math mode
-var mathTextSymbols = "0123456789/@.\"";
-for (i = 0; i < mathTextSymbols.length; i++) {
- ch = mathTextSymbols.charAt(i);
- defineSymbol(math, main, textord, ch, ch);
-}
-
-// All of these are textords in text mode
-var textSymbols = "0123456789!@*()-=+[]\";:?/.,";
-for (i = 0; i < textSymbols.length; i++) {
- ch = textSymbols.charAt(i);
- defineSymbol(text, main, textord, ch, ch);
-}
-
-// All of these are textords in text mode, and mathords in math mode
-var letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
-for (i = 0; i < letters.length; i++) {
- ch = letters.charAt(i);
- defineSymbol(math, main, mathord, ch, ch);
- defineSymbol(text, main, textord, ch, ch);
-}
-
-// Latin-1 letters
-for (i = 0x00C0; i <= 0x00D6; i++) {
- ch = String.fromCharCode(i);
- defineSymbol(text, main, textord, ch, ch);
-}
-
-for (i = 0x00D8; i <= 0x00F6; i++) {
- ch = String.fromCharCode(i);
- defineSymbol(text, main, textord, ch, ch);
-}
-
-for (i = 0x00F8; i <= 0x00FF; i++) {
- ch = String.fromCharCode(i);
- defineSymbol(text, main, textord, ch, ch);
-}
-
-// Cyrillic
-for (i = 0x0410; i <= 0x044F; i++) {
- ch = String.fromCharCode(i);
- defineSymbol(text, main, textord, ch, ch);
-}
-
-},{}],24:[function(require,module,exports){
-var hangulRegex = /[\uAC00-\uD7AF]/;
-
-// This regex combines
-// - Hiragana: [\u3040-\u309F]
-// - Katakana: [\u30A0-\u30FF]
-// - CJK ideograms: [\u4E00-\u9FAF]
-// - Hangul syllables: [\uAC00-\uD7AF]
-// Notably missing are halfwidth Katakana and Romanji glyphs.
-var cjkRegex =
- /[\u3040-\u309F]|[\u30A0-\u30FF]|[\u4E00-\u9FAF]|[\uAC00-\uD7AF]/;
-
-module.exports = {
- cjkRegex: cjkRegex,
- hangulRegex: hangulRegex,
-};
-
-},{}],25:[function(require,module,exports){
-/**
- * This file contains a list of utility functions which are useful in other
- * files.
- */
-
-/**
- * Provide an `indexOf` function which works in IE8, but defers to native if
- * possible.
- */
-var nativeIndexOf = Array.prototype.indexOf;
-var indexOf = function(list, elem) {
- if (list == null) {
- return -1;
- }
- if (nativeIndexOf && list.indexOf === nativeIndexOf) {
- return list.indexOf(elem);
- }
- var i = 0;
- var l = list.length;
- for (; i < l; i++) {
- if (list[i] === elem) {
- return i;
- }
- }
- return -1;
-};
-
-/**
- * Return whether an element is contained in a list
- */
-var contains = function(list, elem) {
- return indexOf(list, elem) !== -1;
-};
-
-/**
- * Provide a default value if a setting is undefined
- */
-var deflt = function(setting, defaultIfUndefined) {
- return setting === undefined ? defaultIfUndefined : setting;
-};
-
-// hyphenate and escape adapted from Facebook's React under Apache 2 license
-
-var uppercase = /([A-Z])/g;
-var hyphenate = function(str) {
- return str.replace(uppercase, "-$1").toLowerCase();
-};
-
-var ESCAPE_LOOKUP = {
- "&": "&amp;",
- ">": "&gt;",
- "<": "&lt;",
- "\"": "&quot;",
- "'": "&#x27;",
-};
-
-var ESCAPE_REGEX = /[&><"']/g;
-
-function escaper(match) {
- return ESCAPE_LOOKUP[match];
-}
-
-/**
- * Escapes text to prevent scripting attacks.
- *
- * @param {*} text Text value to escape.
- * @return {string} An escaped string.
- */
-function escape(text) {
- return ("" + text).replace(ESCAPE_REGEX, escaper);
-}
-
-/**
- * A function to set the text content of a DOM element in all supported
- * browsers. Note that we don't define this if there is no document.
- */
-var setTextContent;
-if (typeof document !== "undefined") {
- var testNode = document.createElement("span");
- if ("textContent" in testNode) {
- setTextContent = function(node, text) {
- node.textContent = text;
- };
- } else {
- setTextContent = function(node, text) {
- node.innerText = text;
- };
- }
-}
-
-/**
- * A function to clear a node.
- */
-function clearNode(node) {
- setTextContent(node, "");
-}
-
-module.exports = {
- contains: contains,
- deflt: deflt,
- escape: escape,
- hyphenate: hyphenate,
- indexOf: indexOf,
- setTextContent: setTextContent,
- clearNode: clearNode,
-};
-
-},{}]},{},[1])(1)
-}); \ No newline at end of file
diff --git a/vendor/assets/javascripts/peek.js b/vendor/assets/javascripts/peek.js
deleted file mode 100644
index 6a341a3f0fe..00000000000
--- a/vendor/assets/javascripts/peek.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * This is a modified version of https://github.com/peek/peek/blob/master/app/assets/javascripts/peek.js
- *
- * - Removed the dependency on jquery.tipsy
- * - Removed the initializeTipsy and toggleBar functions
- * - Customized updatePerformanceBar to handle SQL queries report specificities
- * - Changed /peek/results to /-/peek/results
- * - Removed the keypress, pjax:end, page:change, and turbolinks:load handlers
- */
-(function($) {
- var fetchRequestResults, getRequestId, peekEnabled, 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]);
- };
- 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('peek:update', fetchRequestResults);
- return $(function() {
- if (peekEnabled()) {
- return $(this).trigger('peek:update');
- }
- });
-})(jQuery);
diff --git a/vendor/assets/stylesheets/katex.scss b/vendor/assets/stylesheets/katex.scss
deleted file mode 100644
index b45836716f2..00000000000
--- a/vendor/assets/stylesheets/katex.scss
+++ /dev/null
@@ -1,977 +0,0 @@
-/*
-The MIT License (MIT)
-
-Copyright (c) 2015 Khan Academy
-
-This software also uses portions of the underscore.js project, which is
-MIT licensed with the following copyright:
-
-Copyright (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative
-Reporters & Editors
-
-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.
-*/
-
-/*
- Here is how to build a version of KaTeX that works with gitlab.
-
- The problem is that the standard procedure for changing font location doesn't work for the empty string.
-
- 1. Clone KaTeX. Anything later than 4fb9445a9 (is merged into master) will do.
- 2. make (requires node)
- 3. sed -e 's,fonts/,,' -e 's/url\(([^)]*)\)/url(font-path\1)/g' build/katex.css > build/katex.scss
- 4. Copy build/katex.js to gitlab/vendor/assets/javascripts/katex.js,
- build/katex.scss to gitlab/vendor/assets/stylesheets/katex.scss and
- fonts/* to gitlab/vendor/assets/fonts/.
-*/
-
-@font-face {
- font-family: 'KaTeX_AMS';
- src: url(font-path('KaTeX_AMS-Regular.eot'));
- src: url(font-path('KaTeX_AMS-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_AMS-Regular.woff2')) format('woff2'), url(font-path('KaTeX_AMS-Regular.woff')) format('woff'), url(font-path('KaTeX_AMS-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Caligraphic';
- src: url(font-path('KaTeX_Caligraphic-Bold.eot'));
- src: url(font-path('KaTeX_Caligraphic-Bold.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Caligraphic-Bold.woff2')) format('woff2'), url(font-path('KaTeX_Caligraphic-Bold.woff')) format('woff'), url(font-path('KaTeX_Caligraphic-Bold.ttf')) format('truetype');
- font-weight: 600;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Caligraphic';
- src: url(font-path('KaTeX_Caligraphic-Regular.eot'));
- src: url(font-path('KaTeX_Caligraphic-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Caligraphic-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Caligraphic-Regular.woff')) format('woff'), url(font-path('KaTeX_Caligraphic-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Fraktur';
- src: url(font-path('KaTeX_Fraktur-Bold.eot'));
- src: url(font-path('KaTeX_Fraktur-Bold.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Fraktur-Bold.woff2')) format('woff2'), url(font-path('KaTeX_Fraktur-Bold.woff')) format('woff'), url(font-path('KaTeX_Fraktur-Bold.ttf')) format('truetype');
- font-weight: 600;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Fraktur';
- src: url(font-path('KaTeX_Fraktur-Regular.eot'));
- src: url(font-path('KaTeX_Fraktur-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Fraktur-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Fraktur-Regular.woff')) format('woff'), url(font-path('KaTeX_Fraktur-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Main';
- src: url(font-path('KaTeX_Main-Bold.eot'));
- src: url(font-path('KaTeX_Main-Bold.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Main-Bold.woff2')) format('woff2'), url(font-path('KaTeX_Main-Bold.woff')) format('woff'), url(font-path('KaTeX_Main-Bold.ttf')) format('truetype');
- font-weight: 600;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Main';
- src: url(font-path('KaTeX_Main-Italic.eot'));
- src: url(font-path('KaTeX_Main-Italic.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Main-Italic.woff2')) format('woff2'), url(font-path('KaTeX_Main-Italic.woff')) format('woff'), url(font-path('KaTeX_Main-Italic.ttf')) format('truetype');
- font-weight: 400;
- font-style: italic;
-}
-@font-face {
- font-family: 'KaTeX_Main';
- src: url(font-path('KaTeX_Main-Regular.eot'));
- src: url(font-path('KaTeX_Main-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Main-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Main-Regular.woff')) format('woff'), url(font-path('KaTeX_Main-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Math';
- src: url(font-path('KaTeX_Math-Italic.eot'));
- src: url(font-path('KaTeX_Math-Italic.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Math-Italic.woff2')) format('woff2'), url(font-path('KaTeX_Math-Italic.woff')) format('woff'), url(font-path('KaTeX_Math-Italic.ttf')) format('truetype');
- font-weight: 400;
- font-style: italic;
-}
-@font-face {
- font-family: 'KaTeX_SansSerif';
- src: url(font-path('KaTeX_SansSerif-Regular.eot'));
- src: url(font-path('KaTeX_SansSerif-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_SansSerif-Regular.woff2')) format('woff2'), url(font-path('KaTeX_SansSerif-Regular.woff')) format('woff'), url(font-path('KaTeX_SansSerif-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Script';
- src: url(font-path('KaTeX_Script-Regular.eot'));
- src: url(font-path('KaTeX_Script-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Script-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Script-Regular.woff')) format('woff'), url(font-path('KaTeX_Script-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Size1';
- src: url(font-path('KaTeX_Size1-Regular.eot'));
- src: url(font-path('KaTeX_Size1-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Size1-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Size1-Regular.woff')) format('woff'), url(font-path('KaTeX_Size1-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Size2';
- src: url(font-path('KaTeX_Size2-Regular.eot'));
- src: url(font-path('KaTeX_Size2-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Size2-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Size2-Regular.woff')) format('woff'), url(font-path('KaTeX_Size2-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Size3';
- src: url(font-path('KaTeX_Size3-Regular.eot'));
- src: url(font-path('KaTeX_Size3-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Size3-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Size3-Regular.woff')) format('woff'), url(font-path('KaTeX_Size3-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Size4';
- src: url(font-path('KaTeX_Size4-Regular.eot'));
- src: url(font-path('KaTeX_Size4-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Size4-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Size4-Regular.woff')) format('woff'), url(font-path('KaTeX_Size4-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-@font-face {
- font-family: 'KaTeX_Typewriter';
- src: url(font-path('KaTeX_Typewriter-Regular.eot'));
- src: url(font-path('KaTeX_Typewriter-Regular.eot#iefix')) format('embedded-opentype'), url(font-path('KaTeX_Typewriter-Regular.woff2')) format('woff2'), url(font-path('KaTeX_Typewriter-Regular.woff')) format('woff'), url(font-path('KaTeX_Typewriter-Regular.ttf')) format('truetype');
- font-weight: 400;
- font-style: normal;
-}
-.katex-display {
- display: block;
- margin: 1em 0;
- text-align: center;
-}
-.katex-display > .katex {
- display: inline-block;
- text-align: initial;
-}
-.katex {
- font: normal 1.21em KaTeX_Main, Times New Roman, serif;
- line-height: 1.2;
- white-space: nowrap;
- text-indent: 0;
-}
-.katex .katex-html {
- display: inline-block;
-}
-.katex .katex-mathml {
- position: absolute;
- clip: rect(1px, 1px, 1px, 1px);
- padding: 0;
- border: 0;
- height: 1px;
- width: 1px;
- overflow: hidden;
-}
-.katex .base {
- display: inline-block;
-}
-.katex .strut {
- display: inline-block;
-}
-.katex .mathit {
- font-family: KaTeX_Math;
- font-style: italic;
-}
-.katex .mathbf {
- font-family: KaTeX_Main;
- font-weight: 600;
-}
-.katex .amsrm {
- font-family: KaTeX_AMS;
-}
-.katex .mathbb {
- font-family: KaTeX_AMS;
-}
-.katex .mathcal {
- font-family: KaTeX_Caligraphic;
-}
-.katex .mathfrak {
- font-family: KaTeX_Fraktur;
-}
-.katex .mathtt {
- font-family: KaTeX_Typewriter;
-}
-.katex .mathscr {
- font-family: KaTeX_Script;
-}
-.katex .mathsf {
- font-family: KaTeX_SansSerif;
-}
-.katex .mainit {
- font-family: KaTeX_Main;
- font-style: italic;
-}
-.katex .textstyle > .mord + .mop {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mord + .mbin {
- margin-left: 0.22222em;
-}
-.katex .textstyle > .mord + .mrel {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .mord + .minner {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mop + .mord {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mop + .mop {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mop + .mrel {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .mop + .minner {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mbin + .mord {
- margin-left: 0.22222em;
-}
-.katex .textstyle > .mbin + .mop {
- margin-left: 0.22222em;
-}
-.katex .textstyle > .mbin + .mopen {
- margin-left: 0.22222em;
-}
-.katex .textstyle > .mbin + .minner {
- margin-left: 0.22222em;
-}
-.katex .textstyle > .mrel + .mord {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .mrel + .mop {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .mrel + .mopen {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .mrel + .minner {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .mclose + .mop {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mclose + .mbin {
- margin-left: 0.22222em;
-}
-.katex .textstyle > .mclose + .mrel {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .mclose + .minner {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mpunct + .mord {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mpunct + .mop {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mpunct + .mrel {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mpunct + .mopen {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mpunct + .mclose {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mpunct + .mpunct {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .mpunct + .minner {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .minner + .mord {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .minner + .mop {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .minner + .mbin {
- margin-left: 0.22222em;
-}
-.katex .textstyle > .minner + .mrel {
- margin-left: 0.27778em;
-}
-.katex .textstyle > .minner + .mopen {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .minner + .mpunct {
- margin-left: 0.16667em;
-}
-.katex .textstyle > .minner + .minner {
- margin-left: 0.16667em;
-}
-.katex .mord + .mop {
- margin-left: 0.16667em;
-}
-.katex .mop + .mord {
- margin-left: 0.16667em;
-}
-.katex .mop + .mop {
- margin-left: 0.16667em;
-}
-.katex .mclose + .mop {
- margin-left: 0.16667em;
-}
-.katex .minner + .mop {
- margin-left: 0.16667em;
-}
-.katex .reset-textstyle.textstyle {
- font-size: 1em;
-}
-.katex .reset-textstyle.scriptstyle {
- font-size: 0.7em;
-}
-.katex .reset-textstyle.scriptscriptstyle {
- font-size: 0.5em;
-}
-.katex .reset-scriptstyle.textstyle {
- font-size: 1.42857em;
-}
-.katex .reset-scriptstyle.scriptstyle {
- font-size: 1em;
-}
-.katex .reset-scriptstyle.scriptscriptstyle {
- font-size: 0.71429em;
-}
-.katex .reset-scriptscriptstyle.textstyle {
- font-size: 2em;
-}
-.katex .reset-scriptscriptstyle.scriptstyle {
- font-size: 1.4em;
-}
-.katex .reset-scriptscriptstyle.scriptscriptstyle {
- font-size: 1em;
-}
-.katex .style-wrap {
- position: relative;
-}
-.katex .vlist {
- display: inline-block;
-}
-.katex .vlist > span {
- display: block;
- height: 0;
- position: relative;
-}
-.katex .vlist > span > span {
- display: inline-block;
-}
-.katex .vlist .baseline-fix {
- display: inline-table;
- table-layout: fixed;
-}
-.katex .msupsub {
- text-align: left;
-}
-.katex .mfrac > span > span {
- text-align: center;
-}
-.katex .mfrac .frac-line {
- width: 100%;
-}
-.katex .mfrac .frac-line:before {
- border-bottom-style: solid;
- border-bottom-width: 1px;
- content: "";
- display: block;
-}
-.katex .mfrac .frac-line:after {
- border-bottom-style: solid;
- border-bottom-width: 0.04em;
- content: "";
- display: block;
- margin-top: -1px;
-}
-.katex .mspace {
- display: inline-block;
-}
-.katex .mspace.negativethinspace {
- margin-left: -0.16667em;
-}
-.katex .mspace.thinspace {
- width: 0.16667em;
-}
-.katex .mspace.mediumspace {
- width: 0.22222em;
-}
-.katex .mspace.thickspace {
- width: 0.27778em;
-}
-.katex .mspace.enspace {
- width: 0.5em;
-}
-.katex .mspace.quad {
- width: 1em;
-}
-.katex .mspace.qquad {
- width: 2em;
-}
-.katex .llap,
-.katex .rlap {
- width: 0;
- position: relative;
-}
-.katex .llap > .inner,
-.katex .rlap > .inner {
- position: absolute;
-}
-.katex .llap > .fix,
-.katex .rlap > .fix {
- display: inline-block;
-}
-.katex .llap > .inner {
- right: 0;
-}
-.katex .rlap > .inner {
- left: 0;
-}
-.katex .katex-logo .a {
- font-size: 0.75em;
- margin-left: -0.32em;
- position: relative;
- top: -0.2em;
-}
-.katex .katex-logo .t {
- margin-left: -0.23em;
-}
-.katex .katex-logo .e {
- margin-left: -0.1667em;
- position: relative;
- top: 0.2155em;
-}
-.katex .katex-logo .x {
- margin-left: -0.125em;
-}
-.katex .rule {
- display: inline-block;
- border: solid 0;
- position: relative;
-}
-.katex .overline .overline-line,
-.katex .underline .underline-line {
- width: 100%;
-}
-.katex .overline .overline-line:before,
-.katex .underline .underline-line:before {
- border-bottom-style: solid;
- border-bottom-width: 1px;
- content: "";
- display: block;
-}
-.katex .overline .overline-line:after,
-.katex .underline .underline-line:after {
- border-bottom-style: solid;
- border-bottom-width: 0.04em;
- content: "";
- display: block;
- margin-top: -1px;
-}
-.katex .sqrt > .sqrt-sign {
- position: relative;
-}
-.katex .sqrt .sqrt-line {
- width: 100%;
-}
-.katex .sqrt .sqrt-line:before {
- border-bottom-style: solid;
- border-bottom-width: 1px;
- content: "";
- display: block;
-}
-.katex .sqrt .sqrt-line:after {
- border-bottom-style: solid;
- border-bottom-width: 0.04em;
- content: "";
- display: block;
- margin-top: -1px;
-}
-.katex .sqrt > .root {
- margin-left: 0.27777778em;
- margin-right: -0.55555556em;
-}
-.katex .sizing,
-.katex .fontsize-ensurer {
- display: inline-block;
-}
-.katex .sizing.reset-size1.size1,
-.katex .fontsize-ensurer.reset-size1.size1 {
- font-size: 1em;
-}
-.katex .sizing.reset-size1.size2,
-.katex .fontsize-ensurer.reset-size1.size2 {
- font-size: 1.4em;
-}
-.katex .sizing.reset-size1.size3,
-.katex .fontsize-ensurer.reset-size1.size3 {
- font-size: 1.6em;
-}
-.katex .sizing.reset-size1.size4,
-.katex .fontsize-ensurer.reset-size1.size4 {
- font-size: 1.8em;
-}
-.katex .sizing.reset-size1.size5,
-.katex .fontsize-ensurer.reset-size1.size5 {
- font-size: 2em;
-}
-.katex .sizing.reset-size1.size6,
-.katex .fontsize-ensurer.reset-size1.size6 {
- font-size: 2.4em;
-}
-.katex .sizing.reset-size1.size7,
-.katex .fontsize-ensurer.reset-size1.size7 {
- font-size: 2.88em;
-}
-.katex .sizing.reset-size1.size8,
-.katex .fontsize-ensurer.reset-size1.size8 {
- font-size: 3.46em;
-}
-.katex .sizing.reset-size1.size9,
-.katex .fontsize-ensurer.reset-size1.size9 {
- font-size: 4.14em;
-}
-.katex .sizing.reset-size1.size10,
-.katex .fontsize-ensurer.reset-size1.size10 {
- font-size: 4.98em;
-}
-.katex .sizing.reset-size2.size1,
-.katex .fontsize-ensurer.reset-size2.size1 {
- font-size: 0.71428571em;
-}
-.katex .sizing.reset-size2.size2,
-.katex .fontsize-ensurer.reset-size2.size2 {
- font-size: 1em;
-}
-.katex .sizing.reset-size2.size3,
-.katex .fontsize-ensurer.reset-size2.size3 {
- font-size: 1.14285714em;
-}
-.katex .sizing.reset-size2.size4,
-.katex .fontsize-ensurer.reset-size2.size4 {
- font-size: 1.28571429em;
-}
-.katex .sizing.reset-size2.size5,
-.katex .fontsize-ensurer.reset-size2.size5 {
- font-size: 1.42857143em;
-}
-.katex .sizing.reset-size2.size6,
-.katex .fontsize-ensurer.reset-size2.size6 {
- font-size: 1.71428571em;
-}
-.katex .sizing.reset-size2.size7,
-.katex .fontsize-ensurer.reset-size2.size7 {
- font-size: 2.05714286em;
-}
-.katex .sizing.reset-size2.size8,
-.katex .fontsize-ensurer.reset-size2.size8 {
- font-size: 2.47142857em;
-}
-.katex .sizing.reset-size2.size9,
-.katex .fontsize-ensurer.reset-size2.size9 {
- font-size: 2.95714286em;
-}
-.katex .sizing.reset-size2.size10,
-.katex .fontsize-ensurer.reset-size2.size10 {
- font-size: 3.55714286em;
-}
-.katex .sizing.reset-size3.size1,
-.katex .fontsize-ensurer.reset-size3.size1 {
- font-size: 0.625em;
-}
-.katex .sizing.reset-size3.size2,
-.katex .fontsize-ensurer.reset-size3.size2 {
- font-size: 0.875em;
-}
-.katex .sizing.reset-size3.size3,
-.katex .fontsize-ensurer.reset-size3.size3 {
- font-size: 1em;
-}
-.katex .sizing.reset-size3.size4,
-.katex .fontsize-ensurer.reset-size3.size4 {
- font-size: 1.125em;
-}
-.katex .sizing.reset-size3.size5,
-.katex .fontsize-ensurer.reset-size3.size5 {
- font-size: 1.25em;
-}
-.katex .sizing.reset-size3.size6,
-.katex .fontsize-ensurer.reset-size3.size6 {
- font-size: 1.5em;
-}
-.katex .sizing.reset-size3.size7,
-.katex .fontsize-ensurer.reset-size3.size7 {
- font-size: 1.8em;
-}
-.katex .sizing.reset-size3.size8,
-.katex .fontsize-ensurer.reset-size3.size8 {
- font-size: 2.1625em;
-}
-.katex .sizing.reset-size3.size9,
-.katex .fontsize-ensurer.reset-size3.size9 {
- font-size: 2.5875em;
-}
-.katex .sizing.reset-size3.size10,
-.katex .fontsize-ensurer.reset-size3.size10 {
- font-size: 3.1125em;
-}
-.katex .sizing.reset-size4.size1,
-.katex .fontsize-ensurer.reset-size4.size1 {
- font-size: 0.55555556em;
-}
-.katex .sizing.reset-size4.size2,
-.katex .fontsize-ensurer.reset-size4.size2 {
- font-size: 0.77777778em;
-}
-.katex .sizing.reset-size4.size3,
-.katex .fontsize-ensurer.reset-size4.size3 {
- font-size: 0.88888889em;
-}
-.katex .sizing.reset-size4.size4,
-.katex .fontsize-ensurer.reset-size4.size4 {
- font-size: 1em;
-}
-.katex .sizing.reset-size4.size5,
-.katex .fontsize-ensurer.reset-size4.size5 {
- font-size: 1.11111111em;
-}
-.katex .sizing.reset-size4.size6,
-.katex .fontsize-ensurer.reset-size4.size6 {
- font-size: 1.33333333em;
-}
-.katex .sizing.reset-size4.size7,
-.katex .fontsize-ensurer.reset-size4.size7 {
- font-size: 1.6em;
-}
-.katex .sizing.reset-size4.size8,
-.katex .fontsize-ensurer.reset-size4.size8 {
- font-size: 1.92222222em;
-}
-.katex .sizing.reset-size4.size9,
-.katex .fontsize-ensurer.reset-size4.size9 {
- font-size: 2.3em;
-}
-.katex .sizing.reset-size4.size10,
-.katex .fontsize-ensurer.reset-size4.size10 {
- font-size: 2.76666667em;
-}
-.katex .sizing.reset-size5.size1,
-.katex .fontsize-ensurer.reset-size5.size1 {
- font-size: 0.5em;
-}
-.katex .sizing.reset-size5.size2,
-.katex .fontsize-ensurer.reset-size5.size2 {
- font-size: 0.7em;
-}
-.katex .sizing.reset-size5.size3,
-.katex .fontsize-ensurer.reset-size5.size3 {
- font-size: 0.8em;
-}
-.katex .sizing.reset-size5.size4,
-.katex .fontsize-ensurer.reset-size5.size4 {
- font-size: 0.9em;
-}
-.katex .sizing.reset-size5.size5,
-.katex .fontsize-ensurer.reset-size5.size5 {
- font-size: 1em;
-}
-.katex .sizing.reset-size5.size6,
-.katex .fontsize-ensurer.reset-size5.size6 {
- font-size: 1.2em;
-}
-.katex .sizing.reset-size5.size7,
-.katex .fontsize-ensurer.reset-size5.size7 {
- font-size: 1.44em;
-}
-.katex .sizing.reset-size5.size8,
-.katex .fontsize-ensurer.reset-size5.size8 {
- font-size: 1.73em;
-}
-.katex .sizing.reset-size5.size9,
-.katex .fontsize-ensurer.reset-size5.size9 {
- font-size: 2.07em;
-}
-.katex .sizing.reset-size5.size10,
-.katex .fontsize-ensurer.reset-size5.size10 {
- font-size: 2.49em;
-}
-.katex .sizing.reset-size6.size1,
-.katex .fontsize-ensurer.reset-size6.size1 {
- font-size: 0.41666667em;
-}
-.katex .sizing.reset-size6.size2,
-.katex .fontsize-ensurer.reset-size6.size2 {
- font-size: 0.58333333em;
-}
-.katex .sizing.reset-size6.size3,
-.katex .fontsize-ensurer.reset-size6.size3 {
- font-size: 0.66666667em;
-}
-.katex .sizing.reset-size6.size4,
-.katex .fontsize-ensurer.reset-size6.size4 {
- font-size: 0.75em;
-}
-.katex .sizing.reset-size6.size5,
-.katex .fontsize-ensurer.reset-size6.size5 {
- font-size: 0.83333333em;
-}
-.katex .sizing.reset-size6.size6,
-.katex .fontsize-ensurer.reset-size6.size6 {
- font-size: 1em;
-}
-.katex .sizing.reset-size6.size7,
-.katex .fontsize-ensurer.reset-size6.size7 {
- font-size: 1.2em;
-}
-.katex .sizing.reset-size6.size8,
-.katex .fontsize-ensurer.reset-size6.size8 {
- font-size: 1.44166667em;
-}
-.katex .sizing.reset-size6.size9,
-.katex .fontsize-ensurer.reset-size6.size9 {
- font-size: 1.725em;
-}
-.katex .sizing.reset-size6.size10,
-.katex .fontsize-ensurer.reset-size6.size10 {
- font-size: 2.075em;
-}
-.katex .sizing.reset-size7.size1,
-.katex .fontsize-ensurer.reset-size7.size1 {
- font-size: 0.34722222em;
-}
-.katex .sizing.reset-size7.size2,
-.katex .fontsize-ensurer.reset-size7.size2 {
- font-size: 0.48611111em;
-}
-.katex .sizing.reset-size7.size3,
-.katex .fontsize-ensurer.reset-size7.size3 {
- font-size: 0.55555556em;
-}
-.katex .sizing.reset-size7.size4,
-.katex .fontsize-ensurer.reset-size7.size4 {
- font-size: 0.625em;
-}
-.katex .sizing.reset-size7.size5,
-.katex .fontsize-ensurer.reset-size7.size5 {
- font-size: 0.69444444em;
-}
-.katex .sizing.reset-size7.size6,
-.katex .fontsize-ensurer.reset-size7.size6 {
- font-size: 0.83333333em;
-}
-.katex .sizing.reset-size7.size7,
-.katex .fontsize-ensurer.reset-size7.size7 {
- font-size: 1em;
-}
-.katex .sizing.reset-size7.size8,
-.katex .fontsize-ensurer.reset-size7.size8 {
- font-size: 1.20138889em;
-}
-.katex .sizing.reset-size7.size9,
-.katex .fontsize-ensurer.reset-size7.size9 {
- font-size: 1.4375em;
-}
-.katex .sizing.reset-size7.size10,
-.katex .fontsize-ensurer.reset-size7.size10 {
- font-size: 1.72916667em;
-}
-.katex .sizing.reset-size8.size1,
-.katex .fontsize-ensurer.reset-size8.size1 {
- font-size: 0.28901734em;
-}
-.katex .sizing.reset-size8.size2,
-.katex .fontsize-ensurer.reset-size8.size2 {
- font-size: 0.40462428em;
-}
-.katex .sizing.reset-size8.size3,
-.katex .fontsize-ensurer.reset-size8.size3 {
- font-size: 0.46242775em;
-}
-.katex .sizing.reset-size8.size4,
-.katex .fontsize-ensurer.reset-size8.size4 {
- font-size: 0.52023121em;
-}
-.katex .sizing.reset-size8.size5,
-.katex .fontsize-ensurer.reset-size8.size5 {
- font-size: 0.57803468em;
-}
-.katex .sizing.reset-size8.size6,
-.katex .fontsize-ensurer.reset-size8.size6 {
- font-size: 0.69364162em;
-}
-.katex .sizing.reset-size8.size7,
-.katex .fontsize-ensurer.reset-size8.size7 {
- font-size: 0.83236994em;
-}
-.katex .sizing.reset-size8.size8,
-.katex .fontsize-ensurer.reset-size8.size8 {
- font-size: 1em;
-}
-.katex .sizing.reset-size8.size9,
-.katex .fontsize-ensurer.reset-size8.size9 {
- font-size: 1.19653179em;
-}
-.katex .sizing.reset-size8.size10,
-.katex .fontsize-ensurer.reset-size8.size10 {
- font-size: 1.43930636em;
-}
-.katex .sizing.reset-size9.size1,
-.katex .fontsize-ensurer.reset-size9.size1 {
- font-size: 0.24154589em;
-}
-.katex .sizing.reset-size9.size2,
-.katex .fontsize-ensurer.reset-size9.size2 {
- font-size: 0.33816425em;
-}
-.katex .sizing.reset-size9.size3,
-.katex .fontsize-ensurer.reset-size9.size3 {
- font-size: 0.38647343em;
-}
-.katex .sizing.reset-size9.size4,
-.katex .fontsize-ensurer.reset-size9.size4 {
- font-size: 0.43478261em;
-}
-.katex .sizing.reset-size9.size5,
-.katex .fontsize-ensurer.reset-size9.size5 {
- font-size: 0.48309179em;
-}
-.katex .sizing.reset-size9.size6,
-.katex .fontsize-ensurer.reset-size9.size6 {
- font-size: 0.57971014em;
-}
-.katex .sizing.reset-size9.size7,
-.katex .fontsize-ensurer.reset-size9.size7 {
- font-size: 0.69565217em;
-}
-.katex .sizing.reset-size9.size8,
-.katex .fontsize-ensurer.reset-size9.size8 {
- font-size: 0.83574879em;
-}
-.katex .sizing.reset-size9.size9,
-.katex .fontsize-ensurer.reset-size9.size9 {
- font-size: 1em;
-}
-.katex .sizing.reset-size9.size10,
-.katex .fontsize-ensurer.reset-size9.size10 {
- font-size: 1.20289855em;
-}
-.katex .sizing.reset-size10.size1,
-.katex .fontsize-ensurer.reset-size10.size1 {
- font-size: 0.20080321em;
-}
-.katex .sizing.reset-size10.size2,
-.katex .fontsize-ensurer.reset-size10.size2 {
- font-size: 0.2811245em;
-}
-.katex .sizing.reset-size10.size3,
-.katex .fontsize-ensurer.reset-size10.size3 {
- font-size: 0.32128514em;
-}
-.katex .sizing.reset-size10.size4,
-.katex .fontsize-ensurer.reset-size10.size4 {
- font-size: 0.36144578em;
-}
-.katex .sizing.reset-size10.size5,
-.katex .fontsize-ensurer.reset-size10.size5 {
- font-size: 0.40160643em;
-}
-.katex .sizing.reset-size10.size6,
-.katex .fontsize-ensurer.reset-size10.size6 {
- font-size: 0.48192771em;
-}
-.katex .sizing.reset-size10.size7,
-.katex .fontsize-ensurer.reset-size10.size7 {
- font-size: 0.57831325em;
-}
-.katex .sizing.reset-size10.size8,
-.katex .fontsize-ensurer.reset-size10.size8 {
- font-size: 0.69477912em;
-}
-.katex .sizing.reset-size10.size9,
-.katex .fontsize-ensurer.reset-size10.size9 {
- font-size: 0.8313253em;
-}
-.katex .sizing.reset-size10.size10,
-.katex .fontsize-ensurer.reset-size10.size10 {
- font-size: 1em;
-}
-.katex .delimsizing.size1 {
- font-family: KaTeX_Size1;
-}
-.katex .delimsizing.size2 {
- font-family: KaTeX_Size2;
-}
-.katex .delimsizing.size3 {
- font-family: KaTeX_Size3;
-}
-.katex .delimsizing.size4 {
- font-family: KaTeX_Size4;
-}
-.katex .delimsizing.mult .delim-size1 > span {
- font-family: KaTeX_Size1;
-}
-.katex .delimsizing.mult .delim-size4 > span {
- font-family: KaTeX_Size4;
-}
-.katex .nulldelimiter {
- display: inline-block;
- width: 0.12em;
-}
-.katex .op-symbol {
- position: relative;
-}
-.katex .op-symbol.small-op {
- font-family: KaTeX_Size1;
-}
-.katex .op-symbol.large-op {
- font-family: KaTeX_Size2;
-}
-.katex .op-limits > .vlist > span {
- text-align: center;
-}
-.katex .accent > .vlist > span {
- text-align: center;
-}
-.katex .accent .accent-body > span {
- width: 0;
-}
-.katex .accent .accent-body.accent-vec > span {
- position: relative;
- left: 0.326em;
-}
-.katex .mtable .vertical-separator {
- display: inline-block;
- margin: 0 -0.025em;
- border-right: 0.05em solid black;
-}
-.katex .mtable .arraycolsep {
- display: inline-block;
-}
-.katex .mtable .col-align-c > .vlist {
- text-align: center;
-}
-.katex .mtable .col-align-l > .vlist {
- text-align: left;
-}
-.katex .mtable .col-align-r > .vlist {
- text-align: right;
-}
diff --git a/vendor/gitignore/Android.gitignore b/vendor/gitignore/Android.gitignore
index addf405e4f5..d57137223ed 100644
--- a/vendor/gitignore/Android.gitignore
+++ b/vendor/gitignore/Android.gitignore
@@ -54,3 +54,10 @@ google-services.json
freeline.py
freeline/
freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
diff --git a/vendor/gitignore/Dart.gitignore b/vendor/gitignore/Dart.gitignore
index 4d2a4d6db7c..7bf00e82cc9 100644
--- a/vendor/gitignore/Dart.gitignore
+++ b/vendor/gitignore/Dart.gitignore
@@ -1,6 +1,7 @@
-# See https://www.dartlang.org/tools/private-files.html
+# See https://www.dartlang.org/guides/libraries/private-files
# Files and directories created by pub
+.dart_tool/
.packages
.pub/
build/
diff --git a/vendor/gitignore/Eagle.gitignore b/vendor/gitignore/Eagle.gitignore
index 9ced1260266..9afc324d6ae 100644
--- a/vendor/gitignore/Eagle.gitignore
+++ b/vendor/gitignore/Eagle.gitignore
@@ -4,6 +4,9 @@
*.s#?
*.b#?
*.l#?
+*.b$?
+*.s$?
+*.l$?
# Eagle project file
# It contains a serial number and references to the file structure
@@ -31,14 +34,19 @@ eagle.epf
*.drl
*.gpi
*.pls
+*.ger
+*.gpi
+*.xln
*.drd
*.drd.*
+*.s#*
+*.b#*
+
*.info
*.eps
# file locks introduced since 7.x
*.lck
-
diff --git a/vendor/gitignore/Global/Eclipse.gitignore b/vendor/gitignore/Global/Eclipse.gitignore
index ce1c12cdb7a..0eb8a5e8571 100644
--- a/vendor/gitignore/Global/Eclipse.gitignore
+++ b/vendor/gitignore/Global/Eclipse.gitignore
@@ -23,6 +23,9 @@ local.properties
# CDT-specific (C/C++ Development Tooling)
.cproject
+# CDT- autotools
+.autotools
+
# Java annotation processor (APT)
.factorypath
diff --git a/vendor/gitignore/Global/JetBrains.gitignore b/vendor/gitignore/Global/JetBrains.gitignore
index 345e61ae3f2..9c01e12b050 100644
--- a/vendor/gitignore/Global/JetBrains.gitignore
+++ b/vendor/gitignore/Global/JetBrains.gitignore
@@ -1,4 +1,4 @@
-# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff:
@@ -9,7 +9,6 @@
# Sensitive or high-churn files:
.idea/**/dataSources/
.idea/**/dataSources.ids
-.idea/**/dataSources.xml
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
@@ -21,6 +20,7 @@
# CMake
cmake-build-debug/
+cmake-build-release/
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
diff --git a/vendor/gitignore/Global/Matlab.gitignore b/vendor/gitignore/Global/Matlab.gitignore
index 7996ad5058e..d87a6bdbeeb 100644
--- a/vendor/gitignore/Global/Matlab.gitignore
+++ b/vendor/gitignore/Global/Matlab.gitignore
@@ -1,8 +1,3 @@
-##---------------------------------------------------
-## Remove autosaves generated by the MATLAB editor
-## We have git for backups!
-##---------------------------------------------------
-
# Windows default autosave extension
*.asv
@@ -12,12 +7,19 @@
# Compiled MEX binaries (all platforms)
*.mex*
-# Simulink Code Generation
+# Packaged app and toolbox files
+*.mlappinstall
+*.mltbx
+
+# Generated helpsearch folders
+helpsearch*/
+
+# Simulink code generation folders
slprj/
sccprj/
-# Session info
-octave-workspace
-
# Simulink autosave extension
*.autosave
+
+# Octave session info
+octave-workspace
diff --git a/vendor/gitignore/Go.gitignore b/vendor/gitignore/Go.gitignore
index ea58090bd21..f1c181ec9c5 100644
--- a/vendor/gitignore/Go.gitignore
+++ b/vendor/gitignore/Go.gitignore
@@ -1,5 +1,6 @@
# Binaries for programs and plugins
*.exe
+*.exe~
*.dll
*.so
*.dylib
diff --git a/vendor/gitignore/Node.gitignore b/vendor/gitignore/Node.gitignore
index 97e28736892..d1bed128fa8 100644
--- a/vendor/gitignore/Node.gitignore
+++ b/vendor/gitignore/Node.gitignore
@@ -57,3 +57,5 @@ typings/
# dotenv environment variables file
.env
+# next.js build output
+.next
diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore
index af2f537516d..b989be6ca15 100644
--- a/vendor/gitignore/Python.gitignore
+++ b/vendor/gitignore/Python.gitignore
@@ -45,6 +45,7 @@ nosetests.xml
coverage.xml
*.cover
.hypothesis/
+.pytest_cache/
# Translations
*.mo
diff --git a/vendor/gitignore/Qt.gitignore b/vendor/gitignore/Qt.gitignore
index 037a1e75790..5291a385b25 100644
--- a/vendor/gitignore/Qt.gitignore
+++ b/vendor/gitignore/Qt.gitignore
@@ -1,5 +1,4 @@
# C++ objects and libs
-
*.slo
*.lo
*.o
@@ -11,7 +10,6 @@
*.dylib
# Qt-es
-
object_script.*.Release
object_script.*.Debug
*_plugin_import.cpp
@@ -35,13 +33,11 @@ Makefile*
target_wrapper.*
# QtCreator
-
*.autosave
-# QtCtreator Qml
+# QtCreator Qml
*.qmlproject.user
*.qmlproject.user.*
-# QtCtreator CMake
+# QtCreator CMake
CMakeLists.txt.user*
-
diff --git a/vendor/gitignore/R.gitignore b/vendor/gitignore/R.gitignore
index fcff087aebb..26fad6fadff 100644
--- a/vendor/gitignore/R.gitignore
+++ b/vendor/gitignore/R.gitignore
@@ -31,3 +31,6 @@ vignettes/*.pdf
# Temporary files created by R markdown
*.utf8.md
*.knit.md
+
+# Shiny token, see https://shiny.rstudio.com/articles/shinyapps.html
+rsconnect/
diff --git a/vendor/gitignore/ROS.gitignore b/vendor/gitignore/ROS.gitignore
index 425641f2c3a..35d74bb771f 100644
--- a/vendor/gitignore/ROS.gitignore
+++ b/vendor/gitignore/ROS.gitignore
@@ -13,6 +13,8 @@ msg/*Feedback.msg
msg/*Goal.msg
msg/*Result.msg
msg/_*.py
+build_isolated/
+devel_isolated/
# Generated by dynamic reconfigure
*.cfgc
diff --git a/vendor/gitignore/Rails.gitignore b/vendor/gitignore/Rails.gitignore
index 42aeb55000a..828ab1d556a 100644
--- a/vendor/gitignore/Rails.gitignore
+++ b/vendor/gitignore/Rails.gitignore
@@ -42,3 +42,7 @@ bower.json
# Ignore Byebug command history file.
.byebug_history
+
+# Ignore node_modules
+node_modules/
+
diff --git a/vendor/gitignore/TeX.gitignore b/vendor/gitignore/TeX.gitignore
index 9bb63365618..78c1c5cd26e 100644
--- a/vendor/gitignore/TeX.gitignore
+++ b/vendor/gitignore/TeX.gitignore
@@ -10,6 +10,7 @@
*.fot
*.cb
*.cb2
+.*.lb
## Intermediate documents:
*.dvi
@@ -109,6 +110,14 @@ acs-*.bib
*.gaux
*.gtex
+# htlatex
+*.4ct
+*.4tc
+*.idv
+*.lg
+*.trc
+*.xref
+
# hyperref
*.brf
diff --git a/vendor/gitignore/Umbraco.gitignore b/vendor/gitignore/Umbraco.gitignore
index b6b0743f62a..10fc2b4d825 100644
--- a/vendor/gitignore/Umbraco.gitignore
+++ b/vendor/gitignore/Umbraco.gitignore
@@ -16,8 +16,11 @@
# Don't ignore Umbraco packages (VisualStudio.gitignore mistakes this for a NuGet packages folder)
# Make sure to include details from VisualStudio.gitignore BEFORE this
-!**/App_Data/[Pp]ackages/
-!**/[Uu]mbraco/[Dd]eveloper/[Pp]ackages
+!**/App_Data/[Pp]ackages/*
+!**/[Uu]mbraco/[Dd]eveloper/[Pp]ackages/*
# ImageProcessor DiskCache
**/App_Data/cache/
+
+# Ignore the Models Builder models out of date flag
+**/App_Data/Models/ood.flag
diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore
index 6217e6c48e9..8e930f59c47 100644
--- a/vendor/gitignore/VisualStudio.gitignore
+++ b/vendor/gitignore/VisualStudio.gitignore
@@ -219,6 +219,10 @@ ClientBin/
*.publishsettings
orleans.codegen.cs
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
@@ -233,6 +237,7 @@ _UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
+ServiceFabricBackup/
# SQL Server files
*.mdf
@@ -254,9 +259,6 @@ FakesAssemblies/
.ntvs_analysis.dat
node_modules/
-# TypeScript v1 declaration files
-typings/
-
# Visual Studio 6 build log
*.plg
@@ -313,3 +315,7 @@ OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
diff --git a/vendor/gitignore/WordPress.gitignore b/vendor/gitignore/WordPress.gitignore
index 97923503c4c..3b181ec0cf2 100644
--- a/vendor/gitignore/WordPress.gitignore
+++ b/vendor/gitignore/WordPress.gitignore
@@ -7,6 +7,7 @@ wp-content/blogs.dir/
wp-content/cache/
wp-content/upgrade/
wp-content/uploads/
+wp-content/mu-plugins/
wp-content/wp-cache-config.php
wp-content/plugins/hello.php
diff --git a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml
index 275487071f3..4223dc18933 100644
--- a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml
@@ -34,13 +34,18 @@ variables:
POSTGRES_ENABLED: "true"
POSTGRES_DB: $CI_ENVIRONMENT_SLUG
+ KUBERNETES_VERSION: 1.8.6
+ HELM_VERSION: 2.6.1
+
stages:
- build
- test
- review
+ - dast
- staging
- canary
- production
+ - performance
- cleanup
build:
@@ -83,16 +88,69 @@ codequality:
artifacts:
paths: [codeclimate.json]
+performance:
+ stage: performance
+ image: docker:latest
+ variables:
+ DOCKER_DRIVER: overlay2
+ allow_failure: true
+ services:
+ - docker:dind
+ script:
+ - setup_docker
+ - performance
+ artifacts:
+ paths:
+ - performance.json
+ - sitespeed-results/
+ only:
+ refs:
+ - branches
+ kubernetes: active
+
sast:
- image: registry.gitlab.com/gitlab-org/gl-sast:latest
+ image: docker:latest
variables:
- POSTGRES_DB: "false"
+ DOCKER_DRIVER: overlay2
allow_failure: true
+ services:
+ - docker:dind
script:
- - sast .
+ - setup_docker
+ - sast
artifacts:
paths: [gl-sast-report.json]
+sast:container:
+ image: docker:latest
+ variables:
+ DOCKER_DRIVER: overlay2
+ allow_failure: true
+ services:
+ - docker:dind
+ script:
+ - setup_docker
+ - sast_container
+ artifacts:
+ paths: [gl-sast-container-report.json]
+
+dast:
+ stage: dast
+ allow_failure: true
+ image: registry.gitlab.com/gitlab-org/security-products/zaproxy
+ variables:
+ POSTGRES_DB: "false"
+ script:
+ - dast
+ artifacts:
+ paths: [gl-dast-report.json]
+ only:
+ refs:
+ - branches
+ kubernetes: active
+ except:
+ - master
+
review:
stage: review
script:
@@ -103,10 +161,13 @@ review:
- install_tiller
- create_secret
- deploy
+ - persist_environment_url
environment:
name: review/$CI_COMMIT_REF_NAME
url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$AUTO_DEVOPS_DOMAIN
on_stop: stop_review
+ artifacts:
+ paths: [environment_url.txt]
only:
refs:
- branches
@@ -201,9 +262,12 @@ production:
- create_secret
- deploy
- delete canary
+ - persist_environment_url
environment:
name: production
url: http://$CI_PROJECT_PATH_SLUG.$AUTO_DEVOPS_DOMAIN
+ artifacts:
+ paths: [environment_url.txt]
# when: manual
only:
refs:
@@ -221,21 +285,49 @@ production:
export CI_APPLICATION_TAG=$CI_COMMIT_SHA
export CI_CONTAINER_NAME=ci_job_build_${CI_JOB_ID}
export TILLER_NAMESPACE=$KUBE_NAMESPACE
+ # Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products
+ export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- function codeclimate() {
- cc_opts="--env CODECLIMATE_CODE="$PWD" \
- --volume "$PWD":/code \
- --volume /var/run/docker.sock:/var/run/docker.sock \
- --volume /tmp/cc:/tmp/cc"
+ function sast_container() {
+ if [[ -n "$CI_REGISTRY_USER" ]]; then
+ echo "Logging to GitLab Container Registry with CI credentials..."
+ docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
+ echo ""
+ fi
- docker run ${cc_opts} codeclimate/codeclimate:0.69.0 init
- docker run ${cc_opts} codeclimate/codeclimate:0.69.0 analyze -f json > codeclimate.json
+ docker run -d --name db arminc/clair-db:latest
+ docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan:v2.0.1
+ apk add -U wget ca-certificates
+ docker pull ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG}
+ wget https://github.com/arminc/clair-scanner/releases/download/v8/clair-scanner_linux_amd64
+ mv clair-scanner_linux_amd64 clair-scanner
+ chmod +x clair-scanner
+ touch clair-whitelist.yml
+ ./clair-scanner -c http://docker:6060 --ip $(hostname -i) -r gl-sast-container-report.json -l clair.log -w clair-whitelist.yml ${CI_APPLICATION_REPOSITORY}:${CI_APPLICATION_TAG} || true
+ }
+
+ function codeclimate() {
+ docker run --env SOURCE_CODE="$PWD" \
+ --volume "$PWD":/code \
+ --volume /var/run/docker.sock:/var/run/docker.sock \
+ "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
}
function sast() {
case "$CI_SERVER_VERSION" in
*-ee)
- /app/bin/run "$@"
+
+ # Deprecation notice for CONFIDENCE_LEVEL variable
+ if [ -z "$SAST_CONFIDENCE_LEVEL" -a "$CONFIDENCE_LEVEL" ]; then
+ SAST_CONFIDENCE_LEVEL="$CONFIDENCE_LEVEL"
+ echo "WARNING: CONFIDENCE_LEVEL is deprecated and MUST be replaced with SAST_CONFIDENCE_LEVEL"
+ fi
+
+ docker run --env SAST_CONFIDENCE_LEVEL="${SAST_CONFIDENCE_LEVEL:-3}" \
+ --env SAST_DISABLE_REMOTE_CHECKS="${SAST_DISABLE_REMOTE_CHECKS:-false}" \
+ --volume "$PWD:/code" \
+ --volume /var/run/docker.sock:/var/run/docker.sock \
+ "registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code
;;
*)
echo "GitLab EE is required"
@@ -272,6 +364,12 @@ production:
replicas="$new_replicas"
fi
+ if [[ "$CI_PROJECT_VISIBILITY" != "public" ]]; then
+ secret_name='gitlab-registry'
+ else
+ secret_name=''
+ fi
+
helm upgrade --install \
--wait \
--set service.enabled="$service_enabled" \
@@ -279,6 +377,7 @@ production:
--set image.repository="$CI_APPLICATION_REPOSITORY" \
--set image.tag="$CI_APPLICATION_TAG" \
--set image.pullPolicy=IfNotPresent \
+ --set image.secrets[0].name="$secret_name" \
--set application.track="$track" \
--set application.database_url="$DATABASE_URL" \
--set service.url="$CI_ENVIRONMENT_URL" \
@@ -301,11 +400,11 @@ production:
apk add glibc-2.23-r3.apk
rm glibc-2.23-r3.apk
- curl https://kubernetes-helm.storage.googleapis.com/helm-v2.6.1-linux-amd64.tar.gz | tar zx
+ curl "https://kubernetes-helm.storage.googleapis.com/helm-v${HELM_VERSION}-linux-amd64.tar.gz" | tar zx
mv linux-amd64/helm /usr/bin/
helm version --client
- curl -L -o /usr/bin/kubectl https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl
+ curl -L -o /usr/bin/kubectl "https://storage.googleapis.com/kubernetes-release/release/v${KUBERNETES_VERSION}/bin/linux/amd64/kubectl"
chmod +x /usr/bin/kubectl
kubectl version --client
}
@@ -407,6 +506,11 @@ production:
}
function create_secret() {
+ echo "Create secret..."
+ if [[ "$CI_PROJECT_VISIBILITY" == "public" ]]; then
+ return
+ fi
+
kubectl create secret -n "$KUBE_NAMESPACE" \
docker-registry gitlab-registry \
--docker-server="$CI_REGISTRY" \
@@ -416,6 +520,37 @@ production:
-o yaml --dry-run | kubectl replace -n "$KUBE_NAMESPACE" --force -f -
}
+ function dast() {
+ export CI_ENVIRONMENT_URL=$(cat environment_url.txt)
+
+ mkdir /zap/wrk/
+ /zap/zap-baseline.py -J gl-dast-report.json -t "$CI_ENVIRONMENT_URL" || true
+ cp /zap/wrk/gl-dast-report.json .
+ }
+
+ function performance() {
+ export CI_ENVIRONMENT_URL=$(cat environment_url.txt)
+
+ mkdir gitlab-exporter
+ wget -O gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/10-5/index.js
+
+ mkdir sitespeed-results
+
+ if [ -f .gitlab-urls.txt ]
+ then
+ sed -i -e 's@^@'"$CI_ENVIRONMENT_URL"'@' .gitlab-urls.txt
+ docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results .gitlab-urls.txt
+ else
+ docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL"
+ fi
+
+ mv sitespeed-results/data/performance.json performance.json
+ }
+
+ function persist_environment_url() {
+ echo $CI_ENVIRONMENT_URL > environment_url.txt
+ }
+
function delete() {
track="${1-stable}"
name="$CI_ENVIRONMENT_SLUG"
diff --git a/vendor/gitlab-ci-yml/Mono.gitlab-ci.yml b/vendor/gitlab-ci-yml/Mono.gitlab-ci.yml
new file mode 100644
index 00000000000..3585f99760f
--- /dev/null
+++ b/vendor/gitlab-ci-yml/Mono.gitlab-ci.yml
@@ -0,0 +1,42 @@
+# This is a simple gitlab continuous integration template (compatible with the shared runner provided on gitlab.com)
+# using the official mono docker image to build a visual studio project.
+#
+# MyProject.sln
+# MyProject\
+# MyProject\
+# MyProject.csproj (console application)
+# MyProject.Test\
+# MyProject.Test.csproj (test library using nuget packages "NUnit" and "NUnit.ConsoleRunner")
+#
+# Please find the full example project here:
+# https://gitlab.com/tobiaskoch/gitlab-ci-example-mono
+
+# see https://hub.docker.com/_/mono/
+image: mono:latest
+
+stages:
+ - test
+ - deploy
+
+before_script:
+ - nuget restore -NonInteractive
+
+release:
+ stage: deploy
+ only:
+ - master
+ artifacts:
+ paths:
+ - build/release/MyProject.exe
+ script:
+ # The output path is relative to the position of the csproj-file
+ - msbuild /p:Configuration="Release" /p:Platform="Any CPU"
+ /p:OutputPath="./../../build/release/" "MyProject.sln"
+
+debug:
+ stage: test
+ script:
+ # The output path is relative to the position of the csproj-file
+ - msbuild /p:Configuration="Debug" /p:Platform="Any CPU"
+ /p:OutputPath="./../../build/debug/" "MyProject.sln"
+ - mono packages/NUnit.ConsoleRunner.3.6.0/tools/nunit3-console.exe build/debug/MyProject.Test.dll \ No newline at end of file
diff --git a/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml b/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
index 1463161a04b..cab087c48c7 100644
--- a/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
@@ -20,4 +20,4 @@ image: "rust:latest"
test:cargo:
script:
- rustc --version && cargo --version # Print version info for debugging
- - cargo test --verbose --jobs 1 --release # Don't parallelise to make errors more readable
+ - cargo test --all --verbose
diff --git a/vendor/ingress/values.yaml b/vendor/ingress/values.yaml
new file mode 100644
index 00000000000..a6b499953bf
--- /dev/null
+++ b/vendor/ingress/values.yaml
@@ -0,0 +1,9 @@
+controller:
+ image:
+ tag: "0.10.2"
+ repository: "quay.io/kubernetes-ingress-controller/nginx-ingress-controller"
+ stats:
+ enabled: true
+ podAnnotations:
+ prometheus.io/scrape: "true"
+ prometheus.io/port: "10254"
diff --git a/vendor/licenses.csv b/vendor/licenses.csv
index b6a5c2f81a0..03115292f02 100644
--- a/vendor/licenses.csv
+++ b/vendor/licenses.csv
@@ -1,5 +1,25 @@
+@babel/code-frame,7.0.0-beta.32,MIT
+@babel/helper-function-name,7.0.0-beta.32,MIT
+@babel/helper-get-function-arity,7.0.0-beta.32,MIT
+@babel/template,7.0.0-beta.32,MIT
+@babel/traverse,7.0.0-beta.32,MIT
+@babel/types,7.0.0-beta.32,MIT
+@gitlab-org/gitlab-svgs,1.8.0,SEE LICENSE IN LICENSE
+@types/jquery,2.0.48,MIT
+JSONStream,1.3.2,MIT
RedCloth,4.3.2,MIT
+abbrev,1.0.9,ISC
+abbrev,1.1.1,ISC
+accepts,1.3.3,MIT
+accepts,1.3.4,MIT
ace-rails-ap,4.1.2,MIT
+acorn,3.3.0,MIT
+acorn,4.0.13,MIT
+acorn,5.1.1,MIT
+acorn,5.4.1,MIT
+acorn-dynamic-import,2.0.2,MIT
+acorn-jsx,3.0.1,MIT
+acorn-node,1.3.0,Apache 2.0
actionmailer,4.2.10,MIT
actionpack,4.2.10,MIT
actionview,4.2.10,MIT
@@ -8,74 +28,577 @@ activemodel,4.2.10,MIT
activerecord,4.2.10,MIT
activesupport,4.2.10,MIT
acts-as-taggable-on,4.0.0,MIT
+address,1.0.3,MIT
addressable,2.5.2,Apache 2.0
+addressparser,1.0.1,MIT
+after,0.8.2,MIT
+agent-base,2.1.1,MIT
+ajv,4.11.8,MIT
+ajv,5.2.2,MIT
+ajv,5.5.2,MIT
+ajv,6.1.1,MIT
+ajv-keywords,1.5.1,MIT
+ajv-keywords,3.1.0,MIT
akismet,2.0.0,MIT
+align-text,0.1.4,MIT
allocations,1.0.5,MIT
+alphanum-sort,1.0.2,MIT
+amdefine,1.0.1,BSD-3-Clause OR MIT
+amqplib,0.5.2,MIT
+ansi-align,2.0.0,ISC
+ansi-escapes,1.4.0,MIT
+ansi-escapes,3.0.0,MIT
+ansi-html,0.0.7,Apache 2.0
+ansi-regex,2.1.1,MIT
+ansi-regex,3.0.0,MIT
+ansi-styles,2.2.1,MIT
+ansi-styles,3.2.0,MIT
+anymatch,1.3.2,ISC
+anymatch,2.0.0,ISC
+append-transform,0.4.0,MIT
+aproba,1.2.0,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-diff,4.0.0,MIT
+arr-flatten,1.1.0,MIT
+arr-union,3.1.0,MIT
+array-filter,0.0.1,MIT
+array-find,1.0.0,MIT
+array-find-index,1.0.2,MIT
+array-flatten,1.1.1,MIT
+array-flatten,2.1.1,MIT
+array-includes,3.0.3,MIT
+array-map,0.0.0,MIT
+array-reduce,0.0.0,MIT
+array-slice,0.2.3,MIT
+array-union,1.0.2,MIT
+array-uniq,1.0.3,MIT
+array-unique,0.2.1,MIT
+array-unique,0.3.2,MIT
+arraybuffer.slice,0.0.7,MIT
+arrify,1.0.1,MIT
asana,0.6.0,MIT
asciidoctor,1.5.3,MIT
asciidoctor-plantuml,0.0.7,MIT
+asn1,0.2.3,MIT
+asn1.js,4.10.1,MIT
+assert,1.4.1,MIT
+assert-plus,0.2.0,MIT
+assert-plus,1.0.0,MIT
asset_sync,2.2.0,MIT
+assign-symbols,1.0.0,MIT
+ast-types,0.11.1,MIT
+astw,2.2.0,MIT
+async,0.9.2,MIT
+async,1.5.2,MIT
+async,2.1.5,MIT
+async,2.4.1,MIT
+async,2.6.0,MIT
+async-each,1.0.1,MIT
+async-limiter,1.0.0,MIT
+asynckit,0.4.0,MIT
+atob,2.0.3,(MIT OR Apache-2.0)
atomic,1.1.99,Apache 2.0
attr_encrypted,3.0.3,MIT
attr_required,1.0.0,MIT
+autoprefixer,6.7.7,MIT
autoprefixer-rails,6.2.3,MIT
+autosize,4.0.0,MIT
+aws-sign2,0.6.0,Apache 2.0
+aws-sign2,0.7.0,Apache 2.0
+aws4,1.6.0,MIT
axiom-types,0.1.1,MIT
+axios,0.15.3,MIT
+axios,0.17.1,MIT
+axios-mock-adapter,1.10.0,MIT
+babel-code-frame,6.26.0,MIT
+babel-core,6.26.0,MIT
+babel-eslint,8.0.2,MIT
+babel-generator,6.26.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.26.0,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.26.0,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,7.1.2,MIT
+babel-messages,6.23.0,MIT
+babel-plugin-check-es2015-constants,6.22.0,MIT
+babel-plugin-istanbul,4.1.5,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
+babel-plugin-syntax-decorators,6.13.0,MIT
+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.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.26.0,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.24.1,MIT
+babel-plugin-transform-es2015-for-of,6.23.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.1,MIT
+babel-plugin-transform-es2015-modules-commonjs,6.26.0,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.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.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.26.0,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.26.0,MIT
+babel-runtime,6.26.0,MIT
+babel-template,6.26.0,MIT
+babel-traverse,6.26.0,MIT
+babel-types,6.26.0,MIT
babosa,1.0.2,MIT
+babylon,6.18.0,MIT
+babylon,7.0.0-beta.32,MIT
+backo2,1.0.2,MIT
+balanced-match,0.4.2,MIT
+balanced-match,1.0.0,MIT
+base,0.11.2,MIT
base32,0.3.2,MIT
-batch-loader,1.1.1,MIT
+base64-arraybuffer,0.1.5,MIT
+base64-js,1.2.3,MIT
+base64id,1.0.0,MIT
+batch,0.6.1,MIT
+batch-loader,1.2.1,MIT
bcrypt,3.1.11,MIT
+bcrypt-pbkdf,1.0.1,New BSD
bcrypt_pbkdf,1.0.0,MIT
+better-assert,1.0.2,MIT
+bfj-node4,5.2.1,MIT
+big.js,3.1.3,MIT
+binary-extensions,1.11.0,MIT
bindata,2.4.1,ruby
+bitsyntax,0.0.4,Unknown
+bl,1.1.2,MIT
+blackst0ne-mermaid,7.1.0-fixed,MIT
+blob,0.0.4,MIT*
+block-stream,0.0.9,ISC
+bluebird,3.5.0,MIT
+bluebird,3.5.1,MIT
+bn.js,4.11.8,MIT
+body-parser,1.18.2,MIT
+bonjour,3.5.0,MIT
+boom,2.10.1,New BSD
+boom,4.3.1,New BSD
+boom,5.2.0,New BSD
bootstrap-sass,3.3.6,MIT
bootstrap_form,2.7.0,MIT
+boxen,1.3.0,MIT
+brace-expansion,1.1.11,MIT
+brace-expansion,1.1.8,MIT
+braces,0.1.5,MIT
+braces,1.8.5,MIT
+braces,2.3.1,MIT
+brorand,1.1.0,MIT
browser,2.2.0,MIT
+browser-pack,6.0.4,MIT
+browser-resolve,1.11.2,MIT
+browserify,14.5.0,MIT
+browserify-aes,1.1.1,MIT
+browserify-cipher,1.0.0,MIT
+browserify-des,1.0.0,MIT
+browserify-rsa,4.0.1,MIT
+browserify-sign,4.0.4,ISC
+browserify-zlib,0.1.4,MIT
+browserify-zlib,0.2.0,MIT
+browserslist,1.7.7,MIT
+buffer,4.9.1,MIT
+buffer,5.1.0,MIT
+buffer-indexof,1.1.0,MIT
+buffer-more-ints,0.0.2,MIT
+buffer-xor,1.0.3,MIT
builder,3.2.3,MIT
+buildmail,4.0.1,MIT
+builtin-modules,1.1.1,MIT
+builtin-status-codes,3.0.0,MIT
+bytes,2.5.0,MIT
+bytes,3.0.0,MIT
+cacache,10.0.4,ISC
+cache-base,1.0.1,MIT
+cached-path-relative,1.0.1,MIT
+caller-path,0.1.0,MIT
+callsite,1.0.0,MIT*
+callsites,0.2.0,MIT
+camelcase,1.2.1,MIT
+camelcase,2.1.1,MIT
+camelcase,3.0.0,MIT
+camelcase,4.1.0,MIT
+camelcase-keys,2.1.0,MIT
+caniuse-api,1.6.1,MIT
+caniuse-db,1.0.30000649,CC-BY-4.0
+capture-stack-trace,1.0.0,MIT
carrierwave,1.2.1,MIT
+caseless,0.11.0,Apache 2.0
+caseless,0.12.0,Apache 2.0
cause,0.1,MIT
+center-align,0.1.3,MIT
+chalk,1.1.3,MIT
+chalk,2.3.0,MIT
+chalk,2.3.1,MIT
+chardet,0.4.2,MIT
charlock_holmes,0.7.5,MIT
+chart.js,1.0.2,MIT
+check-types,7.3.0,MIT
+chokidar,1.7.0,MIT
+chokidar,2.0.2,MIT
+chownr,1.0.1,ISC
chronic,0.10.2,MIT
chronic_duration,0.10.6,MIT
chunky_png,1.3.5,MIT
+cipher-base,1.0.4,MIT
+circular-json,0.3.3,MIT
+circular-json,0.5.1,MIT
citrus,3.0.2,MIT
+clap,1.1.3,MIT
+class-utils,0.3.6,MIT
+classlist-polyfill,1.2.0,Unlicense
+cli-boxes,1.0.0,MIT
+cli-cursor,1.0.2,MIT
+cli-cursor,2.1.0,MIT
+cli-width,2.1.0,ISC
+clipboard,1.7.1,MIT
+cliui,2.1.0,ISC
+cliui,3.2.0,ISC
+clone,1.0.2,MIT
+co,3.0.6,MIT
+co,4.6.0,MIT
+coa,1.0.1,MIT
+code-point-at,1.1.0,MIT
coercible,1.0.0,MIT
+collection-visit,1.0.0,MIT
+color,0.11.4,MIT
+color-convert,1.9.1,MIT
+color-name,1.1.2,MIT
+color-string,0.3.0,MIT
+colormin,1.1.2,MIT
+colors,1.1.2,MIT
+combine-lists,1.0.1,MIT
+combine-source-map,0.7.2,MIT
+combine-source-map,0.8.0,MIT
+combined-stream,1.0.6,MIT
+commander,2.14.1,MIT
+commondir,1.0.1,MIT
+commonmarker,0.17.8,MIT
+component-bind,1.0.0,MIT*
+component-emitter,1.2.1,MIT
+component-inherit,0.0.3,MIT*
+compressible,2.0.11,MIT
+compression,1.7.0,MIT
+compression-webpack-plugin,1.1.7,MIT
+concat-map,0.0.1,MIT
+concat-stream,1.5.2,MIT
+concat-stream,1.6.0,MIT
concurrent-ruby-ext,1.0.5,MIT
+configstore,3.1.1,Simplified BSD
+connect,3.6.6,MIT
+connect-history-api-fallback,1.3.0,MIT
connection_pool,2.2.1,MIT
+console-browserify,1.1.0,MIT
+console-control-strings,1.1.0,ISC
+consolidate,0.14.5,MIT
+constants-browserify,1.0.0,MIT
+contains-path,0.1.0,MIT
+content-disposition,0.5.2,MIT
+content-type,1.0.4,MIT
+convert-source-map,1.1.3,MIT
+convert-source-map,1.5.0,MIT
+cookie,0.3.1,MIT
+cookie-signature,1.0.6,MIT
+copy-concurrently,1.0.5,ISC
+copy-descriptor,0.1.1,MIT
+copy-webpack-plugin,4.4.1,MIT
+core-js,2.3.0,MIT
+core-js,2.4.1,MIT
+core-js,2.5.1,MIT
+core-js,2.5.3,MIT
+core-util-is,1.0.2,MIT
+cosmiconfig,2.1.1,MIT
crack,0.4.3,MIT
+create-ecdh,4.0.0,MIT
+create-error-class,3.0.2,MIT
+create-hash,1.1.3,MIT
+create-hmac,1.1.6,MIT
creole,0.5.0,ruby
+cropper,2.3.0,MIT
+cross-spawn,5.1.0,MIT
+cryptiles,2.0.5,New BSD
+cryptiles,3.1.2,New BSD
+crypto-browserify,3.11.0,MIT
+crypto-browserify,3.12.0,MIT
+crypto-random-string,1.0.0,MIT
+css-color-names,0.0.4,MIT
+css-loader,0.28.9,MIT
+css-selector-tokenizer,0.7.0,MIT
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
+cyclist,0.2.2,MIT*
+d,0.1.1,MIT
+d,1.0.0,MIT
+d3,3.5.17,New BSD
+d3-array,1.2.1,New BSD
+d3-axis,1.0.8,New BSD
+d3-brush,1.0.4,New BSD
+d3-collection,1.0.4,New BSD
+d3-color,1.0.3,New BSD
+d3-dispatch,1.0.3,New BSD
+d3-drag,1.2.1,New BSD
+d3-ease,1.0.3,New BSD
+d3-format,1.2.1,New BSD
+d3-interpolate,1.1.6,New BSD
+d3-path,1.0.5,New BSD
+d3-scale,1.0.7,New BSD
+d3-selection,1.2.0,New BSD
+d3-shape,1.2.0,New BSD
+d3-time,1.0.8,New BSD
+d3-time-format,2.1.1,New BSD
+d3-timer,1.0.7,New BSD
+d3-transition,1.1.1,New BSD
d3_rails,3.5.11,MIT
+dagre-d3-renderer,0.4.24,MIT
+dagre-layout,0.8.0,MIT
+dashdash,1.14.1,MIT
+data-uri-to-buffer,1.2.0,MIT
+date-format,1.2.0,MIT
+date-now,0.1.4,MIT
+de-indent,1.0.2,MIT
+debug,2.2.0,MIT
+debug,2.6.8,MIT
+debug,2.6.9,MIT
+debug,3.1.0,MIT
debugger-ruby_core_source,1.3.8,MIT
+decamelize,1.2.0,MIT
deckar01-task_list,2.0.0,MIT
declarative,0.0.10,MIT
declarative-option,0.1.0,MIT
+decode-uri-component,0.2.0,MIT
+decompress-response,3.3.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
+define-properties,1.1.2,MIT
+define-property,0.2.5,MIT
+define-property,1.0.0,MIT
+define-property,2.0.2,MIT
+defined,1.0.0,MIT
+degenerator,1.0.4,MIT
+del,2.2.2,MIT
+del,3.0.0,MIT
+delayed-stream,1.0.0,MIT
+delegate,3.1.2,MIT
+delegates,1.0.0,MIT
+depd,1.1.1,MIT
+deps-sort,2.0.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-libc,1.0.3,Apache 2.0
+detect-node,2.0.3,ISC
+detect-port-alt,1.1.5,MIT
+detective,4.7.1,MIT
devise,4.2.0,MIT
devise-two-factor,3.0.0,MIT
+di,0.0.1,MIT
+diff,3.4.0,New BSD
diff-lcs,1.3,"MIT,Artistic-2.0,GPL-2.0+"
+diffie-hellman,5.0.2,MIT
diffy,3.1.0,MIT
+dir-glob,2.0.0,MIT
+dns-equal,1.0.0,MIT
+dns-packet,1.2.2,MIT
+dns-txt,2.0.2,MIT
+doctrine,1.5.0,Simplified BSD
+doctrine,2.0.0,Apache 2.0
+document-register-element,1.3.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.1.3,Simplified BSD
+domelementtype,1.3.0,Simplified BSD
+domhandler,2.4.1,Simplified BSD
+domutils,1.6.2,Simplified BSD
doorkeeper,4.2.6,MIT
doorkeeper-openid_connect,1.2.0,MIT
+dot-prop,4.2.0,MIT
+double-ended-queue,2.1.0-0,MIT
+dropzone,4.2.0,MIT
dropzonejs-rails,0.7.2,MIT
+duplexer,0.1.1,MIT
+duplexer2,0.1.4,New BSD
+duplexer3,0.1.4,New BSD
+duplexify,3.5.3,MIT
+ecc-jsbn,0.1.1,MIT
+ee-first,1.1.1,MIT
+ejs,2.5.7,Apache 2.0
+electron-to-chromium,1.3.3,ISC
+elliptic,6.4.0,MIT
email_reply_trimmer,0.1.6,MIT
+emoji-unicode-version,0.2.1,MIT
+emojis-list,2.1.0,MIT
+encodeurl,1.0.2,MIT
encryptor,3.0.0,MIT
+end-of-stream,1.4.0,MIT
+end-of-stream,1.4.1,MIT
+engine.io,3.1.5,MIT
+engine.io-client,3.1.5,MIT
+engine.io-parser,2.1.2,MIT
+enhanced-resolve,0.9.1,MIT
+enhanced-resolve,3.4.1,MIT
+ent,2.2.0,MIT
+entities,1.1.1,Simplified BSD
equalizer,0.0.11,MIT
+errno,0.1.4,MIT
+error-ex,1.3.0,MIT
erubis,2.7.0,MIT
+es-abstract,1.10.0,MIT
+es-to-primitive,1.1.1,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
+es6-set,0.1.5,MIT
+es6-symbol,3.1.1,MIT
+es6-weak-map,2.0.1,MIT
+escape-html,1.0.3,MIT
+escape-string-regexp,1.0.5,MIT
escape_utils,1.1.1,MIT
+escodegen,1.8.1,Simplified BSD
+escodegen,1.9.0,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.3,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
+eslint-plugin-promise,3.5.0,ISC
+eslint-plugin-vue,4.0.1,MIT
+eslint-scope,3.7.1,Simplified BSD
+eslint-visitor-keys,1.0.0,Apache 2.0
+espree,3.5.0,Simplified BSD
+espree,3.5.2,Simplified BSD
+esprima,2.7.3,Simplified BSD
+esprima,3.1.3,Simplified BSD
+esprima,4.0.0,Simplified BSD
+esquery,1.0.0,New BSD
+esrecurse,4.1.0,Simplified BSD
+estraverse,1.9.3,Simplified BSD
+estraverse,4.1.1,Simplified BSD
+estraverse,4.2.0,Simplified BSD
+esutils,2.0.2,Simplified BSD
et-orbi,1.0.3,MIT
+etag,1.8.1,MIT
+eve-raphael,0.5.0,Apache 2.0
+event-emitter,0.3.5,MIT
+event-stream,3.3.4,MIT
+eventemitter3,1.2.0,MIT
+events,1.1.1,MIT
+eventsource,0.1.6,MIT
+evp_bytestokey,1.0.3,MIT
excon,0.57.1,MIT
+execa,0.7.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-brackets,2.1.4,MIT
+expand-range,0.1.1,MIT
+expand-range,1.8.2,MIT
+expand-tilde,2.0.2,MIT
+exports-loader,0.7.0,MIT
+express,4.16.2,MIT
expression_parser,0.9.0,MIT
+extend,3.0.1,MIT
+extend-shallow,2.0.1,MIT
+extend-shallow,3.0.2,MIT
+external-editor,2.1.0,MIT
+extglob,0.3.2,MIT
+extglob,2.0.4,MIT
+extsprintf,1.3.0,MIT
+extsprintf,1.4.0,MIT
faraday,0.12.2,MIT
faraday_middleware,0.11.0.1,MIT
faraday_middleware-multi_json,0.0.6,MIT
+fast-deep-equal,1.0.0,MIT
+fast-json-stable-stringify,2.0.0,MIT
+fast-levenshtein,2.0.6,MIT
+fast_blank,1.0.0,MIT
fast_gettext,1.4.0,"MIT,ruby"
+fastparse,1.1.1,MIT
+faye-websocket,0.10.0,MIT
+faye-websocket,0.11.1,MIT
ffi,1.9.18,New BSD
-flipper,0.10.2,MIT
-flipper-active_record,0.10.2,MIT
+figures,1.7.0,MIT
+figures,2.0.0,MIT
+file-entry-cache,2.0.0,MIT
+file-loader,1.1.8,MIT
+file-uri-to-path,1.0.0,MIT
+filename-regex,2.0.1,MIT
+fileset,2.0.3,MIT
+filesize,3.5.11,New BSD
+filesize,3.6.0,New BSD
+fill-range,2.2.3,MIT
+fill-range,4.0.0,MIT
+finalhandler,1.1.0,MIT
+find-cache-dir,1.0.0,MIT
+find-root,0.1.2,MIT
+find-up,1.1.2,MIT
+find-up,2.1.0,MIT
+flat-cache,1.2.2,MIT
+flatten,1.0.2,MIT
+flipper,0.11.0,MIT
+flipper-active_record,0.11.0,MIT
+flipper-active_support_cache_store,0.11.0,MIT
flowdock,0.7.1,MIT
+flush-write-stream,1.0.2,MIT
fog-aliyun,0.2.0,MIT
fog-aws,1.4.0,MIT
fog-core,1.44.3,MIT
@@ -85,93 +608,497 @@ fog-local,0.3.1,MIT
fog-openstack,0.1.21,MIT
fog-rackspace,0.1.1,MIT
fog-xml,0.1.3,MIT
+follow-redirects,1.0.0,MIT
+follow-redirects,1.2.6,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
+foreach,2.0.5,MIT
+forever-agent,0.6.1,Apache 2.0
+form-data,2.0.0,MIT
+form-data,2.1.4,MIT
+form-data,2.3.2,MIT
formatador,0.2.5,MIT
+forwarded,0.1.2,MIT
+fragment-cache,0.2.1,MIT
+fresh,0.5.2,MIT
+from,0.1.7,MIT
+from2,2.3.0,MIT
+fs-access,1.0.1,MIT
+fs-write-stream-atomic,1.0.10,ISC
+fs.realpath,1.0.0,ISC
+fsevents,1.1.3,MIT
+fstream,1.0.11,ISC
+fstream-ignore,1.0.5,ISC
+ftp,0.3.10,MIT
+function-bind,1.1.1,MIT
+fuzzaldrin-plus,0.5.0,MIT
+gauge,2.7.4,ISC
gemnasium-gitlab-service,0.2.6,MIT
gemojione,3.3.0,MIT
+generate-function,2.0.0,MIT
+generate-object-property,1.2.0,MIT
+get-caller-file,1.0.2,ISC
+get-stdin,4.0.1,MIT
+get-stream,3.0.0,MIT
+get-uri,2.0.1,MIT
+get-value,2.0.6,MIT
get_process_mem,0.2.0,MIT
+getpass,0.1.7,MIT
gettext_i18n_rails,1.8.0,MIT
gettext_i18n_rails_js,1.2.0,MIT
-gitaly-proto,0.59.0,MIT
-github-linguist,4.7.6,MIT
+gitaly-proto,0.88.0,MIT
+github-linguist,5.3.3,MIT
github-markup,1.6.1,MIT
gitlab-flowdock-git-hook,1.0.1,MIT
gitlab-grit,2.8.2,MIT
gitlab-markup,1.6.3,MIT
gitlab_omniauth-ldap,2.0.4,MIT
+glob,5.0.15,ISC
+glob,7.1.1,ISC
+glob,7.1.2,ISC
+glob-base,0.3.0,MIT
+glob-parent,2.0.0,ISC
+glob-parent,3.1.0,ISC
+global-dirs,0.1.1,MIT
+global-modules,1.0.0,MIT
+global-prefix,1.0.2,MIT
globalid,0.4.1,MIT
+globals,10.4.0,MIT
+globals,9.18.0,MIT
+globby,5.0.0,MIT
+globby,6.1.0,MIT
+globby,7.1.1,MIT
gollum-grit_adapter,1.0.1,MIT
gollum-lib,4.2.7,MIT
gollum-rugged_adapter,0.4.4,MIT
gon,6.1.0,MIT
+good-listener,1.2.2,MIT
google-api-client,0.13.6,Apache 2.0
-google-protobuf,3.4.1.1,New BSD
-googleauth,0.5.3,Apache 2.0
+google-protobuf,3.5.1,New BSD
+googleapis-common-protos-types,1.0.1,Apache 2.0
+googleauth,0.6.2,Apache 2.0
+got,6.7.1,MIT
+got,7.1.0,MIT
gpgme,2.0.13,LGPL-2.1+
+graceful-fs,4.1.11,ISC
grape,1.0.0,MIT
grape-entity,0.6.0,MIT
grape-route-helpers,2.1.0,MIT
grape_logging,1.7.0,MIT
-grpc,1.4.5,New BSD
+graphlib,2.1.1,MIT
+grpc,1.10.0,Apache 2.0
+gzip-size,3.0.0,MIT
+gzip-size,4.1.0,MIT
hamlit,2.6.1,MIT
+handle-thing,1.2.5,MIT
+handlebars,4.0.6,MIT
+har-schema,1.0.5,ISC
+har-schema,2.0.0,ISC
+har-validator,2.0.6,ISC
+har-validator,4.2.1,ISC
+har-validator,5.0.3,ISC
+has,1.0.1,MIT
+has-ansi,2.0.0,MIT
+has-binary2,1.0.2,MIT
+has-cors,1.1.0,MIT
+has-flag,1.0.0,MIT
+has-flag,2.0.0,MIT
+has-flag,3.0.0,MIT
+has-symbol-support-x,1.3.0,MIT
+has-to-string-tag-x,1.3.0,MIT
+has-unicode,2.0.1,ISC
+has-value,0.3.1,MIT
+has-value,1.0.0,MIT
+has-values,0.1.4,MIT
+has-values,1.0.0,MIT
+hash-base,2.0.2,MIT
+hash-base,3.0.4,MIT
+hash-sum,1.0.2,MIT
+hash.js,1.1.3,MIT
hashie,3.5.6,MIT
hashie-forbidden_attributes,0.1.1,MIT
+hawk,3.1.3,New BSD
+hawk,6.0.2,New BSD
+he,1.1.1,MIT
health_check,2.6.0,MIT
hipchat,1.5.2,MIT
+hipchat-notifier,1.1.0,MIT
+hmac-drbg,1.0.1,MIT
+hoek,2.16.3,New BSD
+hoek,4.2.1,New BSD
+home-or-tmp,2.0.0,MIT
+homedir-polyfill,1.0.1,MIT
+hosted-git-info,2.2.0,ISC
+hpack.js,2.1.6,MIT
+html-comment-regex,1.1.1,MIT
+html-entities,1.2.0,MIT
html-pipeline,1.11.0,MIT
html2text,0.2.0,MIT
htmlentities,4.3.4,MIT
+htmlescape,1.1.1,MIT
+htmlparser2,3.9.2,MIT
http,0.9.8,MIT
http-cookie,1.0.3,MIT
+http-deceiver,1.2.7,MIT
+http-errors,1.6.2,MIT
http-form_data,1.0.1,MIT
+http-proxy,1.16.2,MIT
+http-proxy-agent,1.0.0,MIT
+http-proxy-middleware,0.17.4,MIT
+http-signature,1.1.1,MIT
+http-signature,1.2.0,MIT
http_parser.rb,0.6.0,MIT
httparty,0.13.7,MIT
httpclient,2.8.2,ruby
+httpntlm,1.6.1,MIT
+httpreq,0.4.24,MIT
+https-browserify,0.0.1,MIT
+https-browserify,1.0.0,MIT
+https-proxy-agent,1.0.0,MIT
i18n,0.9.1,MIT
ice_nine,0.11.2,MIT
+iconv-lite,0.4.15,MIT
+iconv-lite,0.4.19,MIT
+icss-replace-symbols,1.1.0,ISC
+icss-utils,2.1.0,ISC
+ieee754,1.1.8,New BSD
+iferr,0.1.5,MIT
+ignore,3.3.3,MIT
+ignore,3.3.7,MIT
+ignore-by-default,1.0.1,ISC
+immediate,3.0.6,MIT
+import-lazy,2.1.0,MIT
+import-local,1.0.0,MIT
+imports-loader,0.8.0,MIT
+imurmurhash,0.1.4,MIT
+indent-string,2.1.0,MIT
+indexes-of,1.0.1,MIT
+indexof,0.0.1,MIT*
+inflection,1.10.0,MIT
+inflection,1.3.8,MIT
+inflight,1.0.6,ISC
influxdb,0.2.3,MIT
+inherits,2.0.1,ISC
+inherits,2.0.3,ISC
+ini,1.3.5,ISC
+inline-source-map,0.6.2,MIT
+inquirer,0.12.0,MIT
+inquirer,3.3.0,MIT
+insert-module-globals,7.0.1,MIT
+internal-ip,1.2.0,MIT
+interpret,1.0.1,MIT
+invariant,2.2.2,New BSD
+invert-kv,1.0.0,MIT
+ip,1.0.1,MIT
+ip,1.1.5,MIT
+ipaddr.js,1.6.0,MIT
ipaddress,0.8.3,MIT
+is-absolute,0.2.6,MIT
+is-absolute-url,2.1.0,MIT
+is-accessor-descriptor,0.1.6,MIT
+is-accessor-descriptor,1.0.0,MIT
+is-arrayish,0.2.1,MIT
+is-binary-path,1.0.1,MIT
+is-buffer,1.1.5,MIT
+is-buffer,1.1.6,MIT
+is-builtin-module,1.0.0,MIT
+is-callable,1.1.3,MIT
+is-data-descriptor,0.1.4,MIT
+is-data-descriptor,1.0.0,MIT
+is-date-object,1.0.1,MIT
+is-descriptor,0.1.6,MIT
+is-descriptor,1.0.2,MIT
+is-dotfile,1.0.3,MIT
+is-equal-shallow,0.1.3,MIT
+is-extendable,0.1.1,MIT
+is-extendable,1.0.1,MIT
+is-extglob,1.0.0,MIT
+is-extglob,2.1.1,MIT
+is-finite,1.0.2,MIT
+is-fullwidth-code-point,1.0.0,MIT
+is-fullwidth-code-point,2.0.0,MIT
+is-glob,2.0.1,MIT
+is-glob,3.1.0,MIT
+is-glob,4.0.0,MIT
+is-installed-globally,0.1.0,MIT
+is-my-ip-valid,1.0.0,MIT
+is-my-json-valid,2.16.0,MIT
+is-my-json-valid,2.17.2,MIT
+is-npm,1.0.0,MIT
+is-number,0.1.1,MIT
+is-number,2.1.0,MIT
+is-number,3.0.0,MIT
+is-number,4.0.0,MIT
+is-obj,1.0.1,MIT
+is-object,1.0.1,MIT
+is-odd,2.0.0,MIT
+is-path-cwd,1.0.0,MIT
+is-path-in-cwd,1.0.0,MIT
+is-path-inside,1.0.0,MIT
+is-plain-obj,1.1.0,MIT
+is-plain-object,2.0.4,MIT
+is-posix-bracket,0.1.1,MIT
+is-primitive,2.0.0,MIT
+is-promise,2.1.0,MIT
+is-property,1.0.2,MIT
+is-redirect,1.0.0,MIT
+is-regex,1.0.4,MIT
+is-relative,0.2.1,MIT
+is-resolvable,1.0.0,MIT
+is-retry-allowed,1.1.0,MIT
+is-root,1.0.0,MIT
+is-stream,1.1.0,MIT
+is-svg,2.1.0,MIT
+is-symbol,1.0.1,MIT
+is-typedarray,1.0.0,MIT
+is-unc-path,0.1.2,MIT
+is-utf8,0.2.1,MIT
+is-windows,0.2.0,MIT
+is-windows,1.0.2,MIT
+is-wsl,1.1.0,MIT
+isarray,0.0.1,MIT
+isarray,1.0.0,MIT
+isarray,2.0.1,MIT
+isbinaryfile,3.0.2,MIT
+isexe,1.1.2,ISC
+isexe,2.0.0,ISC
+isobject,2.1.0,MIT
+isobject,3.0.1,MIT
+isstream,0.1.2,MIT
+istanbul,0.4.5,New BSD
+istanbul-api,1.2.1,New BSD
+istanbul-lib-coverage,1.1.1,New BSD
+istanbul-lib-hook,1.1.0,New BSD
+istanbul-lib-instrument,1.9.1,New BSD
+istanbul-lib-report,1.1.2,New BSD
+istanbul-lib-source-maps,1.2.2,New BSD
+istanbul-reports,1.1.3,New BSD
+isurl,1.0.0,MIT
+jasmine-core,2.9.0,MIT
+jasmine-jquery,2.1.1,MIT
+jed,1.1.1,MIT
jira-ruby,1.4.1,MIT
+jquery,3.3.1,MIT
jquery-atwho-rails,1.3.2,MIT
jquery-rails,4.3.1,MIT
+jquery-ujs,1.2.2,MIT
+jquery.waitforimages,2.2.0,MIT
+js-base64,2.1.9,New BSD
+js-cookie,2.1.3,MIT
+js-tokens,3.0.2,MIT
+js-yaml,3.7.0,MIT
+js-yaml,3.9.1,MIT
+jsbn,0.1.1,MIT
+jsesc,0.5.0,MIT
+jsesc,1.3.0,MIT
json,1.8.6,ruby
json-jwt,1.7.2,MIT
+json-loader,0.5.7,MIT
+json-schema,0.2.3,BSD
+json-schema-traverse,0.3.1,MIT
+json-stable-stringify,0.0.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
+jsonify,0.0.0,Public Domain
+jsonparse,1.3.1,MIT
+jsonpointer,4.0.1,MIT
+jsprim,1.4.1,MIT
+jszip,3.1.3,(MIT OR GPL-3.0)
+jszip-utils,0.0.2,MIT or GPLv3
jwt,1.5.6,MIT
kaminari,1.0.1,MIT
kaminari-actionview,1.0.1,MIT
kaminari-activerecord,1.0.1,MIT
kaminari-core,1.0.1,MIT
+karma,2.0.0,MIT
+karma-chrome-launcher,2.2.0,MIT
+karma-coverage-istanbul-reporter,1.4.1,MIT
+karma-jasmine,1.1.1,MIT
+karma-mocha-reporter,2.2.5,MIT
+karma-sourcemap-loader,0.3.7,MIT
+karma-webpack,2.0.7,MIT
+katex,0.8.3,MIT
kgio,2.10.0,LGPL-2.1+
+killable,1.0.0,ISC
+kind-of,3.2.2,MIT
+kind-of,4.0.0,MIT
+kind-of,5.1.0,MIT
+kind-of,6.0.2,MIT
kubeclient,2.2.0,MIT
+labeled-stream-splicer,2.0.0,MIT
+latest-version,3.1.0,MIT
+lazy-cache,1.0.4,MIT
+lazy-cache,2.0.2,MIT
+lcid,1.0.0,MIT
+levn,0.3.0,MIT
+lexical-scope,1.2.0,MIT
+libbase64,0.1.0,MIT
+libmime,3.0.0,MIT
+libqp,1.1.0,MIT
licensee,8.7.0,MIT
+lie,3.1.1,MIT
little-plugger,1.1.4,MIT
+load-json-file,1.1.0,MIT
+load-json-file,2.0.0,MIT
+loader-runner,2.3.0,MIT
+loader-utils,0.2.16,MIT
+loader-utils,1.1.0,MIT
locale,2.1.2,"ruby,LGPLv3+"
+locate-path,2.0.0,MIT
+lodash,3.10.1,MIT
+lodash,4.17.4,MIT
+lodash,4.17.5,MIT
+lodash._baseget,3.7.2,MIT
+lodash._topath,3.8.1,MIT
+lodash.camelcase,4.1.1,MIT
+lodash.camelcase,4.3.0,MIT
+lodash.capitalize,4.2.1,MIT
+lodash.clonedeep,4.5.0,MIT
+lodash.cond,4.5.2,MIT
+lodash.deburr,4.1.0,MIT
+lodash.endswith,4.2.1,MIT
+lodash.escaperegexp,4.1.2,MIT
+lodash.get,3.7.0,MIT
+lodash.isarray,3.0.4,MIT
+lodash.isfunction,3.0.9,MIT
+lodash.isstring,4.0.1,MIT
+lodash.kebabcase,4.0.1,MIT
+lodash.memoize,3.0.4,MIT
+lodash.memoize,4.1.2,MIT
+lodash.mergewith,4.6.0,MIT
+lodash.snakecase,4.0.1,MIT
+lodash.startswith,4.2.1,MIT
+lodash.uniq,4.5.0,MIT
+lodash.words,4.2.0,MIT
+log-symbols,2.1.0,MIT
+log4js,2.5.3,Apache 2.0
logging,2.2.2,MIT
+loggly,1.1.1,MIT
+loglevel,1.4.1,MIT
lograge,0.5.1,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,2.6.5,ISC
+lru-cache,4.1.1,ISC
+macaddress,0.2.8,MIT
mail,2.7.0,MIT
mail_room,0.9.1,MIT
+mailcomposer,4.0.1,MIT
+mailgun-js,0.7.15,MIT
+make-dir,1.0.0,MIT
+map-cache,0.2.2,MIT
+map-obj,1.0.1,MIT
+map-stream,0.1.0,Unknown
+map-visit,1.0.0,MIT
+marked,0.3.12,MIT
+match-at,0.1.1,MIT
+math-expression-evaluator,1.2.16,MIT
+md5.js,1.3.4,MIT
+media-typer,0.3.0,MIT
+mem,1.1.0,MIT
memoist,0.16.0,MIT
+memory-fs,0.2.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
+micromatch,3.1.6,MIT
+miller-rabin,4.0.1,MIT
+mime,1.4.1,MIT
+mime,1.6.0,MIT
+mime-db,1.29.0,MIT
+mime-db,1.33.0,MIT
+mime-types,2.1.18,MIT
mime-types,3.1,MIT
mime-types-data,3.2016.0521,MIT
mimemagic,0.3.0,MIT
+mimic-fn,1.1.0,MIT
+mimic-response,1.0.0,MIT
mini_mime,0.1.4,MIT
mini_portile2,2.3.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.10,MIT
+minimist,0.0.8,MIT
+minimist,1.2.0,MIT
+mississippi,2.0.0,Simplified BSD
+mixin-deep,1.3.1,MIT
+mkdirp,0.5.1,MIT
+module-deps,4.1.1,MIT
+moment,2.19.2,MIT
+monaco-editor,0.10.0,MIT
+mousetrap,1.4.6,Apache 2.0
mousetrap-rails,1.4.6,"MIT,Apache"
-multi_json,1.12.2,MIT
+move-concurrently,1.0.1,ISC
+ms,0.7.1,MIT
+ms,2.0.0,MIT
+multi_json,1.13.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,1.0.0,MIT
mustermann-grape,1.0.0,MIT
-mysql2,0.4.5,MIT
+mute-stream,0.0.5,ISC
+mute-stream,0.0.7,ISC
+mysql2,0.4.10,MIT
+name-all-modules-plugin,1.0.1,MIT
+nan,2.8.0,MIT
+nanomatch,1.2.9,MIT
+natural-compare,1.4.0,MIT
+negotiator,0.6.1,MIT
net-ldap,0.16.0,MIT
net-ssh,4.1.0,MIT
+netmask,1.0.6,MIT
netrc,0.11.0,MIT
-nokogiri,1.8.1,MIT
+node-forge,0.6.33,New BSD
+node-libs-browser,1.1.1,MIT
+node-libs-browser,2.0.0,MIT
+node-pre-gyp,0.6.39,New BSD
+node-uuid,1.4.8,MIT
+nodemailer,2.7.2,MIT
+nodemailer-direct-transport,3.3.2,MIT
+nodemailer-fetch,1.6.0,MIT
+nodemailer-shared,1.1.0,MIT
+nodemailer-smtp-pool,2.8.2,MIT
+nodemailer-smtp-transport,2.7.2,MIT
+nodemailer-wellknown,0.1.10,MIT
+nodemon,1.15.1,MIT
+nokogiri,1.8.2,MIT
+nopt,1.0.10,MIT
+nopt,3.0.6,ISC
+nopt,4.0.1,ISC
+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
+npm-run-path,2.0.2,MIT
+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.4.0,MIT
+object-assign,4.1.1,MIT
+object-component,0.0.3,MIT*
+object-copy,0.1.0,MIT
+object-keys,1.0.11,MIT
+object-visit,1.0.1,MIT
+object.omit,2.0.1,MIT
+object.pick,1.3.0,MIT
+obuf,1.1.1,MIT
octokit,4.6.2,MIT
oj,2.17.5,MIT
omniauth,1.4.2,MIT
@@ -192,31 +1119,174 @@ omniauth-saml,1.7.0,MIT
omniauth-shibboleth,1.2.1,MIT
omniauth-twitter,1.2.1,MIT
omniauth_crowd,2.2.3,MIT
+on-finished,2.3.0,MIT
+on-headers,1.0.1,MIT
+once,1.4.0,ISC
+onetime,1.1.0,MIT
+onetime,2.0.1,MIT
+opener,1.4.3,(WTFPL OR MIT)
+opn,5.2.0,MIT
+optimist,0.6.1,MIT
+optionator,0.8.2,MIT
org-ruby,0.9.12,MIT
+original,1.0.0,MIT
orm_adapter,0.5.0,MIT
os,0.9.6,MIT
-paranoia,2.3.1,MIT
+os-browserify,0.2.1,MIT
+os-browserify,0.3.0,MIT
+os-homedir,1.0.2,MIT
+os-locale,1.4.0,MIT
+os-locale,2.1.0,MIT
+os-tmpdir,1.0.2,MIT
+osenv,0.1.5,ISC
+p-cancelable,0.3.0,MIT
+p-finally,1.0.0,MIT
+p-limit,1.1.0,MIT
+p-limit,1.2.0,MIT
+p-locate,2.0.0,MIT
+p-map,1.1.1,MIT
+p-timeout,1.2.0,MIT
+p-try,1.0.0,MIT
+pac-proxy-agent,1.1.0,MIT
+pac-resolver,2.0.0,MIT
+package-json,4.0.1,MIT
+pako,0.2.9,MIT
+pako,1.0.5,(MIT AND Zlib)
+pako,1.0.6,(MIT AND Zlib)
+parallel-transform,1.1.0,MIT
+parents,1.0.1,MIT
+parse-asn1,5.1.0,ISC
+parse-glob,3.0.4,MIT
+parse-json,2.2.0,MIT
+parse-passwd,1.0.0,MIT
+parseqs,0.0.5,MIT
+parseuri,0.0.5,MIT
+parseurl,1.3.2,MIT
+pascalcase,0.1.1,MIT
+path-browserify,0.0.0,MIT
+path-dirname,1.0.2,MIT
+path-exists,2.1.0,MIT
+path-exists,3.0.0,MIT
+path-is-absolute,1.0.1,MIT
+path-is-inside,1.0.2,(WTFPL OR MIT)
+path-key,2.0.1,MIT
+path-parse,1.0.5,MIT
+path-platform,0.11.15,MIT
+path-proxy,1.0.0,MIT
+path-to-regexp,0.1.7,MIT
+path-type,1.1.0,MIT
+path-type,2.0.0,MIT
+path-type,3.0.0,MIT
+pause-stream,0.0.11,Apache 2.0
+pbkdf2,3.0.14,MIT
peek,1.0.1,MIT
peek-gc,0.0.2,MIT
peek-host,1.0.0,MIT
peek-mysql2,1.1.0,MIT
-peek-performance_bar,1.3.0,MIT
+peek-performance_bar,1.3.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
+performance-now,2.1.0,MIT
pg,0.18.4,"BSD,ruby,GPL"
+pify,2.3.0,MIT
+pify,3.0.0,MIT
+pikaday,1.6.1,MIT
+pinkie,2.0.4,MIT
+pinkie-promise,2.0.1,MIT
+pkg-dir,1.0.0,MIT
+pkg-dir,2.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-character-classes,0.1.1,MIT
posix-spawn,0.3.13,MIT
+postcss,5.2.16,MIT
+postcss,6.0.14,MIT
+postcss,6.0.15,MIT
+postcss,6.0.19,MIT
+postcss-calc,5.3.1,MIT
+postcss-colormin,2.2.2,MIT
+postcss-convert-values,2.6.1,MIT
+postcss-discard-comments,2.0.4,MIT
+postcss-discard-duplicates,2.1.0,MIT
+postcss-discard-empty,2.1.0,MIT
+postcss-discard-overridden,0.1.1,MIT
+postcss-discard-unused,2.2.3,MIT
+postcss-filter-plugins,2.0.2,MIT
+postcss-load-config,1.2.0,MIT
+postcss-load-options,1.2.0,MIT
+postcss-load-plugins,2.3.0,MIT
+postcss-merge-idents,2.1.7,MIT
+postcss-merge-longhand,2.0.2,MIT
+postcss-merge-rules,2.1.2,MIT
+postcss-message-helpers,2.0.0,MIT
+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.2.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
+postcss-reduce-idents,2.4.0,MIT
+postcss-reduce-initial,1.0.1,MIT
+postcss-reduce-transforms,1.0.4,MIT
+postcss-selector-parser,2.2.3,MIT
+postcss-svgo,2.1.6,MIT
+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.10.4,New BSD
premailer-rails,1.9.7,MIT
-prometheus-client-mmap,0.7.0.beta43,Apache 2.0
-public_suffix,3.0.0,MIT
+prepend-http,1.0.4,MIT
+preserve,0.2.0,MIT
+prettier,1.8.2,MIT
+prettier,1.9.2,MIT
+prismjs,1.6.0,MIT
+private,0.1.8,MIT
+process,0.11.10,MIT
+process,0.11.9,MIT
+process-nextick-args,1.0.7,MIT
+process-nextick-args,2.0.0,MIT
+progress,1.1.8,MIT
+prometheus-client-mmap,0.9.1,Apache 2.0
+promise-inflight,1.0.1,ISC
+proxy-addr,2.0.3,MIT
+proxy-agent,2.0.0,MIT
+prr,0.0.0,MIT
+ps-tree,1.1.0,MIT
+pseudomap,1.0.2,ISC
+pstree.remy,1.1.0,MIT
+public-encrypt,4.0.0,MIT
+public_suffix,3.0.2,MIT
+pump,2.0.1,MIT
+pumpify,1.4.0,MIT
+punycode,1.3.2,MIT
+punycode,1.4.1,MIT
pyu-ruby-sasl,0.0.3.3,MIT
+q,1.4.1,MIT
+q,1.5.0,MIT
+qjobs,1.2.0,MIT
+qs,6.2.3,New BSD
+qs,6.4.0,New BSD
+qs,6.5.1,New BSD
+query-string,4.3.2,MIT
+querystring,0.2.0,MIT
+querystring-es3,0.2.1,MIT
+querystringify,0.0.4,MIT
+querystringify,1.0.0,MIT
rack,1.6.8,MIT
rack-accept,0.4.5,MIT
rack-attack,4.4.1,MIT
-rack-cors,0.4.0,MIT
+rack-cors,1.0.2,MIT
rack-oauth2,1.2.3,MIT
rack-protection,1.5.3,MIT
rack-proxy,0.6.0,MIT
@@ -230,30 +1300,104 @@ railties,4.2.10,MIT
rainbow,2.2.2,MIT
raindrops,0.18.0,LGPL-2.1+
rake,12.3.0,MIT
+randomatic,1.1.7,MIT
+randombytes,2.0.6,MIT
+randomfill,1.0.4,MIT
+range-parser,1.2.0,MIT
+raphael,2.2.7,MIT
+raven-js,3.22.1,Simplified BSD
+raw-body,2.3.2,MIT
+raw-loader,0.5.1,MIT
+rb-fsevent,0.10.2,MIT
+rb-inotify,0.9.10,MIT
rbnacl,4.0.2,MIT
rbnacl-libsodium,1.0.11,MIT
+rc,1.2.1,(BSD-2-Clause OR MIT OR Apache-2.0)
+rc,1.2.5,(BSD-2-Clause OR MIT OR Apache-2.0)
rdoc,4.2.2,ruby
re2,1.1.1,New BSD
+react-dev-utils,5.0.0,MIT
+react-error-overlay,4.0.0,MIT
+read-only-stream,2.0.0,MIT
+read-pkg,1.1.0,MIT
+read-pkg,2.0.0,MIT
+read-pkg-up,1.0.1,MIT
+read-pkg-up,2.0.0,MIT
+readable-stream,1.1.14,MIT
+readable-stream,2.0.6,MIT
+readable-stream,2.3.3,MIT
+readable-stream,2.3.4,MIT
+readdirp,2.1.0,MIT
+readline2,1.0.1,MIT
recaptcha,3.0.0,MIT
+rechoir,0.6.2,MIT
recursive-open-struct,1.0.0,MIT
+recursive-readdir,2.2.1,MIT
redcarpet,3.4.0,MIT
-redis,3.3.3,MIT
+redent,1.0.0,MIT
+redis,2.8.0,MIT
+redis,3.3.5,MIT
redis-actionpack,5.0.2,MIT
redis-activesupport,5.0.4,MIT
+redis-commands,1.3.1,MIT
redis-namespace,1.5.2,MIT
-redis-rack,2.0.3,MIT
+redis-parser,2.6.0,MIT
+redis-rack,2.0.4,MIT
redis-rails,5.0.2,MIT
redis-store,1.4.1,MIT
+reduce-css-calc,1.3.0,MIT
+reduce-function-call,1.0.2,MIT
+regenerate,1.3.2,MIT
+regenerator-runtime,0.11.0,MIT
+regenerator-transform,0.10.1,BSD
+regex-cache,0.4.4,MIT
+regex-not,1.0.2,MIT
+regexpu-core,1.0.0,MIT
+regexpu-core,2.0.0,MIT
+registry-auth-token,3.3.2,MIT
+registry-url,3.1.0,MIT
+regjsgen,0.2.0,MIT
+regjsparser,0.1.5,Simplified BSD
+remove-trailing-separator,1.1.0,ISC
+repeat-element,1.1.2,MIT
+repeat-string,0.2.2,MIT
+repeat-string,1.6.1,MIT
+repeating,2.0.1,MIT
representable,3.0.4,MIT
+request,2.75.0,Apache 2.0
+request,2.81.0,Apache 2.0
+request,2.83.0,Apache 2.0
request_store,1.3.1,MIT
+requestretry,1.13.0,MIT
+require-all,2.2.0,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.1.7,MIT
+resolve,1.5.0,MIT
+resolve-cwd,2.0.0,MIT
+resolve-dir,1.0.1,MIT
+resolve-from,1.0.1,MIT
+resolve-from,3.0.0,MIT
+resolve-url,0.2.1,MIT
responders,2.3.0,MIT
rest-client,2.0.0,MIT
+restore-cursor,1.0.1,MIT
+restore-cursor,2.0.0,MIT
+ret,0.1.15,MIT
retriable,3.1.1,MIT
+right-align,0.1.3,MIT
+rimraf,2.6.1,ISC
+rimraf,2.6.2,ISC
rinku,2.0.0,ISC
+ripemd160,2.0.1,MIT
rotp,2.1.2,MIT
rouge,2.2.1,MIT
rqrcode,0.7.0,MIT
rqrcode-rails3,0.1.7,MIT
+ruby-enum,0.7.2,MIT
ruby-fogbugz,0.2.1,MIT
ruby-prof,0.16.2,Simplified BSD
ruby-saml,1.4.1,MIT
@@ -262,51 +1406,314 @@ rubyntlm,0.6.2,MIT
rubypants,0.2.0,BSD
rufus-scheduler,3.4.0,MIT
rugged,0.26.0,MIT
+run-async,0.1.0,MIT
+run-async,2.3.0,MIT
+run-queue,1.0.3,ISC
+rx-lite,3.1.2,Apache 2.0
+rx-lite,4.0.8,Apache 2.0
+rx-lite-aggregates,4.0.8,Apache 2.0
+safe-buffer,5.1.1,MIT
+safe-regex,1.1.0,MIT
safe_yaml,1.0.4,MIT
sanitize,2.1.0,MIT
-sass,3.4.22,MIT
+sanitize-html,1.16.3,MIT
+sass,3.5.5,MIT
+sass-listen,4.0.0,MIT
sass-rails,5.0.6,MIT
sawyer,0.8.1,MIT
+sax,1.2.2,ISC
+schema-utils,0.3.0,MIT
+schema-utils,0.4.5,MIT
securecompare,1.0.0,MIT
-seed-fu,2.3.6,MIT
+seed-fu,2.3.7,MIT
+select,1.1.2,MIT
+select-hose,2.0.0,MIT
+select2,3.5.2-browserify,Apache*
select2-rails,3.5.9.3,MIT
+selfsigned,1.10.1,MIT
+semver,5.0.3,ISC
+semver,5.3.0,ISC
+semver,5.5.0,ISC
+semver-diff,2.1.0,MIT
+send,0.16.1,MIT
sentry-raven,2.5.3,Apache 2.0
+serialize-javascript,1.4.0,New BSD
+serve-index,1.9.0,MIT
+serve-static,1.13.1,MIT
+set-blocking,2.0.0,ISC
+set-getter,0.1.0,MIT
+set-immediate-shim,1.0.1,MIT
+set-value,0.4.3,MIT
+set-value,2.0.0,MIT
+setimmediate,1.0.5,MIT
+setprototypeof,1.0.3,ISC
+setprototypeof,1.1.0,ISC
settingslogic,2.0.9,MIT
sexp_processor,4.9.0,MIT
-sidekiq,5.0.4,LGPL
+sha.js,2.4.10,MIT
+shasum,1.0.2,MIT
+shebang-command,1.2.0,MIT
+shebang-regex,1.0.0,MIT
+shell-quote,1.6.1,MIT
+shelljs,0.7.8,New BSD
+sidekiq,5.0.5,LGPL
sidekiq-cron,0.6.0,MIT
sidekiq-limit_fetch,3.4.0,MIT
-signet,0.7.3,Apache 2.0
+signal-exit,3.0.2,ISC
+signet,0.8.1,Apache 2.0
+slack-node,0.2.0,MIT
slack-notifier,1.5.1,MIT
+slash,1.0.0,MIT
+slice-ansi,0.0.4,MIT
+smart-buffer,1.1.15,MIT
+smtp-connection,2.12.0,MIT
+snapdragon,0.8.1,MIT
+snapdragon-node,2.1.1,MIT
+snapdragon-util,3.0.1,MIT
+sntp,1.0.9,BSD
+sntp,2.1.0,BSD
+socket.io,2.0.4,MIT
+socket.io-adapter,1.1.1,MIT
+socket.io-client,2.0.4,MIT
+socket.io-parser,3.1.2,MIT
+sockjs,0.3.19,MIT
+sockjs-client,1.1.4,MIT
+socks,1.1.10,MIT
+socks,1.1.9,MIT
+socks-proxy-agent,2.1.1,MIT
+sort-keys,1.1.2,MIT
+source-list-map,2.0.0,MIT
+source-map,0.2.0,New BSD
+source-map,0.4.4,New BSD
+source-map,0.5.0,New BSD
+source-map,0.5.6,New BSD
+source-map,0.5.7,New BSD
+source-map,0.6.1,New BSD
+source-map-resolve,0.5.1,MIT
+source-map-support,0.4.18,MIT
+source-map-url,0.4.0,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.7,MIT
+spdy-transport,2.0.20,MIT
+split,0.3.3,MIT
+split-string,3.1.0,MIT
+sprintf-js,1.0.3,New BSD
sprockets,3.7.1,MIT
sprockets-rails,3.2.1,MIT
+sql.js,0.4.0,MIT
+srcset,1.0.0,MIT
+sshkey,1.9.0,MIT
+sshpk,1.13.1,MIT
+ssri,5.2.4,ISC
state_machines,0.4.0,MIT
state_machines-activemodel,0.4.0,MIT
state_machines-activerecord,0.4.0,MIT
+static-extend,0.1.2,MIT
+statuses,1.3.1,MIT
+statuses,1.4.0,MIT
+stream-browserify,2.0.1,MIT
+stream-combiner,0.0.4,MIT
+stream-combiner2,1.1.1,MIT
+stream-each,1.2.2,MIT
+stream-http,2.6.3,MIT
+stream-http,2.8.0,MIT
+stream-shift,1.0.0,MIT
+stream-splicer,2.0.0,MIT
+streamroller,0.7.0,MIT
+strict-uri-encode,1.1.0,MIT
+string-width,1.0.2,MIT
+string-width,2.0.0,MIT
+string-width,2.1.1,MIT
+string_decoder,0.10.31,MIT
+string_decoder,1.0.3,MIT
stringex,2.7.1,MIT
+stringstream,0.0.5,MIT
+strip-ansi,3.0.1,MIT
+strip-ansi,4.0.0,MIT
+strip-bom,2.0.0,MIT
+strip-bom,3.0.0,MIT
+strip-eof,1.0.0,MIT
+strip-indent,1.0.1,MIT
+strip-json-comments,2.0.1,MIT
+style-loader,0.20.2,MIT
+subarg,1.0.0,MIT
+supports-color,2.0.0,MIT
+supports-color,3.2.3,MIT
+supports-color,4.2.1,MIT
+supports-color,4.5.0,MIT
+supports-color,5.1.0,MIT
+supports-color,5.2.0,MIT
+svg4everybody,2.1.9,CC0-1.0
+svgo,0.7.2,MIT
+syntax-error,1.4.0,MIT
sys-filesystem,1.1.6,Artistic 2.0
+table,3.8.3,New BSD
+tapable,0.1.10,MIT
+tapable,0.2.8,MIT
+tar,2.2.1,ISC
+tar-pack,3.4.1,Simplified BSD
temple,0.7.7,MIT
+term-size,1.2.0,MIT
+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
+through,2.3.8,MIT
+through2,2.0.3,MIT
+thunkify,2.1.2,MIT
+thunky,0.1.0,MIT*
tilt,2.0.6,MIT
+time-stamp,2.0.0,MIT
+timeago.js,3.0.2,MIT
+timed-out,4.0.1,MIT
+timers-browserify,1.4.2,MIT
+timers-browserify,2.0.4,MIT
+timespan,2.3.0,MIT
timfel-krb5-auth,0.8.3,LGPL
-toml-rb,0.3.15,MIT
+tiny-emitter,2.0.2,MIT
+tmp,0.0.33,MIT
+to-array,0.1.4,MIT
+to-arraybuffer,1.0.1,MIT
+to-fast-properties,1.0.3,MIT
+to-fast-properties,2.0.0,MIT
+to-object-path,0.3.0,MIT
+to-regex,3.0.1,MIT
+to-regex-range,2.1.1,MIT
+toml-rb,1.0.0,MIT
+touch,3.1.0,ISC
+tough-cookie,2.3.3,New BSD
+traverse,0.6.6,MIT
+trim-newlines,1.0.0,MIT
+trim-right,1.0.1,MIT
truncato,0.7.10,MIT
+tryer,1.0.0,MIT
+tryit,1.0.3,MIT
+tsscmp,1.0.5,MIT
+tty-browserify,0.0.0,MIT
+tty-browserify,0.0.1,MIT
+tunnel-agent,0.4.3,Apache 2.0
+tunnel-agent,0.6.0,Apache 2.0
+tweetnacl,0.14.5,Unlicense
+type-check,0.3.2,MIT
+type-is,1.6.16,MIT
+typedarray,0.0.6,MIT
tzinfo,1.2.4,MIT
u2f,0.2.1,MIT
uber,0.1.0,MIT
uglifier,2.7.2,MIT
+uglify-js,2.8.29,Simplified BSD
+uglify-to-browserify,1.0.2,MIT
+uglifyjs-webpack-plugin,0.4.6,MIT
+uid-number,0.0.6,ISC
+ultron,1.1.1,MIT
+umd,3.0.1,MIT
+unc-path-regex,0.1.2,MIT
+undefsafe,2.0.2,MIT
+underscore,1.7.0,MIT
+underscore,1.8.3,MIT
unf,0.1.4,BSD
unf_ext,0.0.7.4,MIT
unicorn,5.1.0,ruby
unicorn-worker-killer,0.4.4,ruby
+union-value,1.0.0,MIT
+uniq,1.0.1,MIT
+uniqid,4.1.1,MIT
+uniqs,2.0.0,MIT
+unique-filename,1.1.0,ISC
+unique-slug,2.0.0,ISC
+unique-string,1.0.0,MIT
+unpipe,1.0.0,MIT
+unset-value,1.0.0,MIT
+unzip-response,2.0.1,MIT
+upath,1.0.2,MIT
+update-notifier,2.3.0,Simplified BSD
+urix,0.1.0,MIT
+url,0.11.0,MIT
+url-loader,0.6.2,MIT
+url-parse,1.0.5,MIT
+url-parse,1.1.9,MIT
+url-parse-lax,1.0.0,MIT
+url-to-options,1.0.1,MIT
url_safe_base64,0.2.2,MIT
+use,2.0.2,MIT
+user-home,2.0.0,MIT
+useragent,2.3.0,MIT
+util,0.10.3,MIT
+util-deprecate,1.0.2,MIT
+utils-merge,1.0.1,MIT
+uuid,3.2.1,MIT
+uws,9.14.0,Zlib
+validate-npm-package-license,3.0.1,Apache 2.0
validates_hostname,1.0.6,MIT
+vary,1.1.1,MIT
+vary,1.1.2,MIT
+vendors,1.0.1,MIT
+verror,1.10.0,MIT
version_sorter,2.1.0,MIT
virtus,1.0.5,MIT
+visibilityjs,1.2.4,MIT
+vm-browserify,0.0.4,MIT
vmstat,2.3.0,MIT
+void-elements,2.0.1,MIT
+vue,2.5.13,MIT
+vue-eslint-parser,2.0.1,MIT
+vue-hot-reload-api,2.2.4,MIT
+vue-loader,14.1.1,MIT
+vue-resource,1.3.5,MIT
+vue-router,3.0.1,MIT
+vue-style-loader,4.0.2,MIT
+vue-template-compiler,2.5.13,MIT
+vue-template-es2015-compiler,1.6.0,MIT
+vuex,3.0.1,MIT
warden,1.2.6,MIT
+watchpack,1.4.0,MIT
+wbuf,1.7.2,MIT
+webpack,3.11.0,MIT
+webpack-bundle-analyzer,2.10.0,MIT
+webpack-dev-middleware,1.12.2,MIT
+webpack-dev-server,2.11.2,MIT
webpack-rails,0.9.10,MIT
+webpack-sources,1.0.1,MIT
+webpack-stats-plugin,0.1.5,MIT
+websocket-driver,0.6.5,MIT
+websocket-extensions,0.1.1,MIT
+when,3.7.8,MIT
+whet.extend,0.9.9,MIT
+which,1.2.12,ISC
+which,1.3.0,ISC
+which-module,1.0.0,ISC
+which-module,2.0.0,ISC
+wide-align,1.1.2,ISC
+widest-line,2.0.0,MIT
wikicloth,0.8.1,MIT
+window-size,0.1.0,MIT
+wordwrap,0.0.2,MIT
+wordwrap,0.0.3,MIT
+wordwrap,1.0.0,MIT
+worker-loader,1.1.0,MIT
+wrap-ansi,2.1.0,MIT
+wrappy,1.0.2,ISC
+write,0.2.1,MIT
+write-file-atomic,2.3.0,ISC
+ws,3.3.3,MIT
+ws,4.0.0,MIT
+xdg-basedir,3.0.0,MIT
xml-simple,1.1.5,ruby
+xmlhttprequest-ssl,1.5.5,MIT
+xregexp,2.0.0,MIT
+xtend,4.0.1,MIT
+y18n,3.2.1,ISC
+y18n,4.0.0,ISC
+yallist,2.1.2,ISC
+yargs,3.10.0,MIT
+yargs,6.6.0,MIT
+yargs,8.0.2,MIT
+yargs-parser,4.2.1,ISC
+yargs-parser,7.0.0,ISC
+yeast,0.1.2,MIT
diff --git a/vendor/project_templates/express.tar.gz b/vendor/project_templates/express.tar.gz
index 7a811e1986b..dcf5e4a0416 100644
--- a/vendor/project_templates/express.tar.gz
+++ 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
index 7db63ecc65f..d4856090ed9 100644
--- a/vendor/project_templates/rails.tar.gz
+++ 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
index 96f51ee804c..6ee7e76f676 100644
--- a/vendor/project_templates/spring.tar.gz
+++ b/vendor/project_templates/spring.tar.gz
Binary files differ
diff --git a/vendor/prometheus/values.yaml b/vendor/prometheus/values.yaml
new file mode 100644
index 00000000000..c432be72163
--- /dev/null
+++ b/vendor/prometheus/values.yaml
@@ -0,0 +1,141 @@
+alertmanager:
+ enabled: false
+
+kubeStateMetrics:
+ enabled: true
+
+nodeExporter:
+ enabled: false
+
+pushgateway:
+ enabled: false
+
+rbac:
+ create: false
+
+server:
+ fullnameOverride: "prometheus-prometheus-server"
+ image:
+ tag: v2.1.0
+
+serverFiles:
+ alerts: {}
+ rules: {}
+
+ prometheus.yml:
+ rule_files:
+ - /etc/config/rules
+ - /etc/config/alerts
+ scrape_configs:
+ - job_name: prometheus
+ static_configs:
+ - targets:
+ - localhost:9090
+ - job_name: kubernetes-cadvisor
+ scheme: https
+ 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: node
+ relabel_configs:
+ - action: labelmap
+ regex: __meta_kubernetes_node_label_(.+)
+ - target_label: __address__
+ replacement: kubernetes.default.svc:443
+ - source_labels:
+ - __meta_kubernetes_node_name
+ regex: "(.+)"
+ target_label: __metrics_path__
+ replacement: "/api/v1/nodes/${1}/proxy/metrics/cadvisor"
+ metric_relabel_configs:
+ - source_labels:
+ - pod_name
+ target_label: environment
+ regex: "(.+)-.+-.+"
+ - job_name: 'kubernetes-service-endpoints'
+ kubernetes_sd_configs:
+ - role: endpoints
+ relabel_configs:
+ - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scrape]
+ action: keep
+ regex: true
+ - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scheme]
+ action: replace
+ target_label: __scheme__
+ regex: (https?)
+ - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_path]
+ action: replace
+ target_label: __metrics_path__
+ regex: (.+)
+ - source_labels: [__address__, __meta_kubernetes_service_annotation_prometheus_io_port]
+ action: replace
+ target_label: __address__
+ regex: (.+)(?::\d+);(\d+)
+ replacement: $1:$2
+ - action: labelmap
+ regex: __meta_kubernetes_service_label_(.+)
+ - source_labels: [__meta_kubernetes_namespace]
+ action: replace
+ target_label: kubernetes_namespace
+ - source_labels: [__meta_kubernetes_service_name]
+ action: replace
+ target_label: kubernetes_name
+ - job_name: kubernetes-nodes
+ scheme: https
+ 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: node
+ relabel_configs:
+ - action: labelmap
+ regex: __meta_kubernetes_node_label_(.+)
+ - target_label: __address__
+ replacement: kubernetes.default.svc:443
+ - source_labels:
+ - __meta_kubernetes_node_name
+ regex: "(.+)"
+ target_label: __metrics_path__
+ replacement: "/api/v1/nodes/${1}/proxy/metrics"
+ metric_relabel_configs:
+ - source_labels:
+ - pod_name
+ target_label: environment
+ regex: "(.+)-.+-.+"
+ - 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
+ 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
diff --git a/vendor/runner/values.yaml b/vendor/runner/values.yaml
new file mode 100644
index 00000000000..e5f95152ac7
--- /dev/null
+++ b/vendor/runner/values.yaml
@@ -0,0 +1,23 @@
+## Configure the maximum number of concurrent jobs
+## - Documentation: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section
+## - Default value: 10
+## - Currently don't support auto-scaling.
+concurrent: 4
+
+## Defines in seconds how often to check GitLab for a new builds
+## - Documentation: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section
+## - Default value: 3
+checkInterval: 3
+
+## For RBAC support
+rbac:
+ create: false
+ clusterWideAccess: false
+
+## Configuration for the Pods that that the runner launches for each new job
+runners:
+ image: ubuntu:16.04
+ builds: {}
+ services: {}
+ helpers: {}
+resources: {}
diff --git a/yarn.lock b/yarn.lock
index 26e2b790f98..243b83f4471 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -54,25 +54,43 @@
lodash "^4.2.0"
to-fast-properties "^2.0.0"
-"@gitlab-org/gitlab-svgs@^1.3.0":
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.3.0.tgz#07f2aa75d6e0e857eaa20c38a3bad7e6c22c420c"
+"@gitlab-org/gitlab-svgs@^1.17.0":
+ version "1.17.0"
+ resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.17.0.tgz#d0c74d9e44c127ccfad16941f352088b86f86c89"
"@types/jquery@^2.0.40":
version "2.0.48"
resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-2.0.48.tgz#3e90d8cde2d29015e5583017f7830cb3975b2eef"
-abbrev@1, abbrev@1.0.x:
+JSONStream@^1.0.3:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.2.tgz#c102371b6ec3a7cf3b847ca00c20bb0fce4c6dea"
+ dependencies:
+ jsonparse "^1.2.0"
+ through ">=2.2.7 <3"
+
+abbrev@1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+
+abbrev@1.0.x:
version "1.0.9"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135"
-accepts@1.3.3, accepts@~1.3.3:
+accepts@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca"
dependencies:
mime-types "~2.1.11"
negotiator "0.6.1"
+accepts@~1.3.4:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f"
+ dependencies:
+ mime-types "~2.1.16"
+ negotiator "0.6.1"
+
acorn-dynamic-import@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4"
@@ -85,6 +103,13 @@ acorn-jsx@^3.0.0:
dependencies:
acorn "^3.0.4"
+acorn-node@^1.2.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.3.0.tgz#5f86d73346743810ef1269b901dbcbded020861b"
+ dependencies:
+ acorn "^5.4.1"
+ xtend "^4.0.1"
+
acorn@^3.0.4:
version "3.3.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
@@ -93,21 +118,40 @@ acorn@^4.0.3:
version "4.0.13"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787"
-acorn@^5.0.0, acorn@^5.0.3, acorn@^5.1.1:
+acorn@^5.0.0, acorn@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.1.tgz#53fe161111f912ab999ee887a90a0bc52822fd75"
+acorn@^5.2.1, acorn@^5.3.0, acorn@^5.4.1:
+ version "5.4.1"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.4.1.tgz#fdc58d9d17f4a4e98d102ded826a9b9759125102"
+
+address@1.0.3, address@^1.0.1:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/address/-/address-1.0.3.tgz#b5f50631f8d6cec8bd20c963963afb55e06cbce9"
+
+addressparser@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746"
+
after@0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
+agent-base@2:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-2.1.1.tgz#d6de10d5af6132d5bd692427d46fc538539094c7"
+ dependencies:
+ extend "~3.0.0"
+ semver "~5.0.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-keywords@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.1.0.tgz#ac2b27939c543e95d2c06e7f7f5c27be4aa543be"
ajv@^4.7.0, ajv@^4.9.1:
version "4.11.8"
@@ -116,7 +160,7 @@ ajv@^4.7.0, ajv@^4.9.1:
co "^4.6.0"
json-stable-stringify "^1.0.1"
-ajv@^5.0.0, ajv@^5.1.5:
+ajv@^5.0.0:
version "5.2.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39"
dependencies:
@@ -125,6 +169,23 @@ ajv@^5.0.0, ajv@^5.1.5:
json-schema-traverse "^0.3.0"
json-stable-stringify "^1.0.1"
+ajv@^5.1.0:
+ version "5.5.2"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965"
+ dependencies:
+ co "^4.6.0"
+ fast-deep-equal "^1.0.0"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.3.0"
+
+ajv@^6.1.0:
+ version "6.1.1"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.1.1.tgz#978d597fbc2b7d0e5a5c3ddeb149a682f2abfa0e"
+ dependencies:
+ fast-deep-equal "^1.0.0"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.3.0"
+
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"
@@ -141,13 +202,29 @@ amdefine@>=0.0.4:
version "1.0.1"
resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
+amqplib@^0.5.2:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.5.2.tgz#d2d7313c7ffaa4d10bcf1e6252de4591b6cc7b63"
+ dependencies:
+ bitsyntax "~0.0.4"
+ bluebird "^3.4.6"
+ buffer-more-ints "0.0.2"
+ readable-stream "1.x >=1.1.9"
+ safe-buffer "^5.0.1"
+
+ansi-align@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
+ dependencies:
+ string-width "^2.0.0"
+
ansi-escapes@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
-ansi-html@0.0.5:
- version "0.0.5"
- resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.5.tgz#0dcaa5a081206866bc240a3b773a184ea3b88b64"
+ansi-escapes@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92"
ansi-html@0.0.7:
version "0.0.7"
@@ -157,11 +234,15 @@ ansi-regex@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+ansi-regex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+
ansi-styles@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
-ansi-styles@^3.1.0:
+ansi-styles@^3.1.0, ansi-styles@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.0.tgz#c159b8d5be0f9e5a6f346dab94f16ce022161b88"
dependencies:
@@ -174,15 +255,22 @@ anymatch@^1.3.0:
micromatch "^2.1.5"
normalize-path "^2.0.0"
+anymatch@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
+ dependencies:
+ micromatch "^3.1.4"
+ normalize-path "^2.1.1"
+
append-transform@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-0.4.0.tgz#d76ebf8ca94d276e247a36bad44a4b74ab611991"
dependencies:
default-require-extensions "^1.0.0"
-aproba@^1.0.3:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.1.2.tgz#45c6629094de4e96f693ef7eab74ae079c240fc1"
+aproba@^1.0.3, aproba@^1.1.1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
are-we-there-yet@~1.1.2:
version "1.1.4"
@@ -203,9 +291,21 @@ arr-diff@^2.0.0:
dependencies:
arr-flatten "^1.0.1"
-arr-flatten@^1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.0.1.tgz#e5ffe54d45e19f32f216e91eb99c8ce892bb604b"
+arr-diff@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
+
+arr-flatten@^1.0.1, arr-flatten@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+
+arr-union@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
+
+array-filter@~0.0.0:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec"
array-find-index@^1.0.1:
version "1.0.2"
@@ -223,6 +323,21 @@ array-flatten@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.1.tgz#426bb9da84090c1838d812c8150af20a8331e296"
+array-includes@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d"
+ dependencies:
+ define-properties "^1.1.2"
+ es-abstract "^1.7.0"
+
+array-map@~0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662"
+
+array-reduce@~0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b"
+
array-slice@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5"
@@ -233,7 +348,7 @@ array-union@^1.0.1:
dependencies:
array-uniq "^1.0.1"
-array-uniq@^1.0.1:
+array-uniq@^1.0.1, array-uniq@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
@@ -241,17 +356,21 @@ array-unique@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
-arraybuffer.slice@0.0.6:
- version "0.0.6"
- resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz#f33b2159f0532a3f3107a272c0ccfbd1ad2979ca"
+array-unique@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
+
+arraybuffer.slice@~0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
arrify@^1.0.0, arrify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
asn1.js@^4.0.0:
- version "4.9.1"
- resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40"
+ version "4.10.1"
+ resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0"
dependencies:
bn.js "^4.0.0"
inherits "^2.0.1"
@@ -269,34 +388,68 @@ assert-plus@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234"
-assert@^1.1.1:
+assert@^1.1.1, assert@^1.4.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91"
dependencies:
util "0.10.3"
+assign-symbols@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
+
+ast-types@0.x.x:
+ version "0.11.1"
+ resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.11.1.tgz#5bb3a8d5ba292c3f4ae94d46df37afc30300b990"
+
+astw@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/astw/-/astw-2.2.0.tgz#7bd41784d32493987aeb239b6b4e1c57a873b917"
+ dependencies:
+ acorn "^4.0.3"
+
async-each@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
-async@1.x, async@^1.4.0, async@^1.4.2, async@^1.5.2:
+async-limiter@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
+
+async@1.x, async@^1.4.0, async@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
-async@2.4.1, async@^2.1.2, async@^2.1.4:
+async@^2.1.2, async@^2.1.4:
version "2.4.1"
resolved "https://registry.yarnpkg.com/async/-/async-2.4.1.tgz#62a56b279c98a11d0987096a01cc3eeb8eb7bbd7"
dependencies:
lodash "^4.14.0"
+async@^2.4.1:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4"
+ dependencies:
+ lodash "^4.14.0"
+
async@~0.9.0:
version "0.9.2"
resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
+async@~2.1.2:
+ version "2.1.5"
+ resolved "https://registry.yarnpkg.com/async/-/async-2.1.5.tgz#e587c68580994ac67fc56ff86d3ac56bdbe810bc"
+ dependencies:
+ lodash "^4.14.0"
+
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+atob@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/atob/-/atob-2.0.3.tgz#19c7a760473774468f20b2d2d03372ad7d4cbf5d"
+
autoprefixer@^6.3.1:
version "6.7.7"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014"
@@ -316,7 +469,11 @@ aws-sign2@~0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
-aws4@^1.2.1:
+aws-sign2@~0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+
+aws4@^1.2.1, aws4@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
@@ -326,6 +483,12 @@ axios-mock-adapter@^1.10.0:
dependencies:
deep-equal "^1.0.1"
+axios@^0.15.3:
+ version "0.15.3"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-0.15.3.tgz#2c9d638b2e191a08ea1d6cc988eadd6ba5bdc053"
+ dependencies:
+ follow-redirects "1.0.0"
+
axios@^0.17.1:
version "0.17.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.17.1.tgz#2d8e3e5d0bdbd7327f91bc814f5c57660f81824d"
@@ -333,7 +496,7 @@ axios@^0.17.1:
follow-redirects "^1.2.5"
is-buffer "^1.1.5"
-babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.26.0:
+babel-code-frame@6.26.0, babel-code-frame@^6.16.0, babel-code-frame@^6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
dependencies:
@@ -949,13 +1112,25 @@ base64-arraybuffer@0.1.5:
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
base64-js@^1.0.2:
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.3.tgz#fb13668233d9614cf5fb4bce95a9ba4096cdf801"
base64id@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
+base@^0.11.1:
+ version "0.11.2"
+ resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
+ dependencies:
+ cache-base "^1.0.1"
+ class-utils "^0.3.5"
+ component-emitter "^1.2.1"
+ define-property "^1.0.0"
+ isobject "^3.0.1"
+ mixin-deep "^1.2.0"
+ pascalcase "^0.1.1"
+
batch@0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
@@ -972,13 +1147,33 @@ better-assert@~1.0.0:
dependencies:
callsite "1.0.0"
+bfj-node4@^5.2.0:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/bfj-node4/-/bfj-node4-5.2.1.tgz#3a6aa2730cf6911ba2afb836c2f88f015d718f3f"
+ dependencies:
+ bluebird "^3.5.1"
+ check-types "^7.3.0"
+ tryer "^1.0.0"
+
big.js@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.1.3.tgz#4cada2193652eb3ca9ec8e55c9015669c9806978"
binary-extensions@^1.0.0:
- version "1.10.0"
- resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0"
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
+
+bitsyntax@~0.0.4:
+ version "0.0.4"
+ resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82"
+ dependencies:
+ buffer-more-ints "0.0.2"
+
+bl@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/bl/-/bl-1.1.2.tgz#fdca871a99713aa00d19e3bbba41c44787a65398"
+ dependencies:
+ readable-stream "~2.0.5"
blackst0ne-mermaid@^7.1.0-fixed:
version "7.1.0-fixed"
@@ -1001,31 +1196,31 @@ 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.1.1, bluebird@^3.3.0:
+bluebird@^3.1.1:
version "3.5.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
+bluebird@^3.3.0, bluebird@^3.4.6, bluebird@^3.5.1:
+ version "3.5.1"
+ resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
+
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"
+ version "4.11.8"
+ resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
-body-parser@^1.16.1:
- version "1.17.2"
- resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.17.2.tgz#f8892abc8f9e627d42aedafbca66bf5ab99104ee"
+body-parser@1.18.2, body-parser@^1.16.1:
+ version "1.18.2"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454"
dependencies:
- bytes "2.4.0"
- content-type "~1.0.2"
- debug "2.6.7"
- depd "~1.1.0"
- http-errors "~1.6.1"
- iconv-lite "0.4.15"
+ bytes "3.0.0"
+ content-type "~1.0.4"
+ debug "2.6.9"
+ depd "~1.1.1"
+ http-errors "~1.6.2"
+ iconv-lite "0.4.19"
on-finished "~2.3.0"
- qs "6.4.0"
- raw-body "~2.2.0"
+ qs "6.5.1"
+ raw-body "2.3.2"
type-is "~1.6.15"
bonjour@^3.5.0:
@@ -1045,17 +1240,48 @@ boom@2.x.x:
dependencies:
hoek "2.x.x"
+boom@4.x.x:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31"
+ dependencies:
+ hoek "4.x.x"
+
+boom@5.x.x:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02"
+ dependencies:
+ hoek "4.x.x"
+
bootstrap-sass@^3.3.6:
version "3.3.6"
resolved "https://registry.yarnpkg.com/bootstrap-sass/-/bootstrap-sass-3.3.6.tgz#363b0d300e868d3e70134c1a742bb17288444fd1"
-brace-expansion@^1.0.0, brace-expansion@^1.1.7, brace-expansion@^1.1.8:
+boxen@^1.2.1:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
+ dependencies:
+ ansi-align "^2.0.0"
+ camelcase "^4.0.0"
+ chalk "^2.0.1"
+ cli-boxes "^1.0.0"
+ string-width "^2.0.0"
+ term-size "^1.2.0"
+ widest-line "^2.0.0"
+
+brace-expansion@^1.0.0, brace-expansion@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292"
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
+brace-expansion@^1.1.7:
+ version "1.1.11"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+ dependencies:
+ balanced-match "^1.0.0"
+ concat-map "0.0.1"
+
braces@^0.1.2:
version "0.1.5"
resolved "https://registry.yarnpkg.com/braces/-/braces-0.1.5.tgz#c085711085291d8b75fdd74eab0f8597280711e6"
@@ -1070,19 +1296,54 @@ braces@^1.8.2:
preserve "^0.2.0"
repeat-element "^1.1.2"
+braces@^2.3.0, braces@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.1.tgz#7086c913b4e5a08dbe37ac0ee6a2500c4ba691bb"
+ dependencies:
+ arr-flatten "^1.1.0"
+ array-unique "^0.3.2"
+ define-property "^1.0.0"
+ extend-shallow "^2.0.1"
+ fill-range "^4.0.0"
+ isobject "^3.0.1"
+ kind-of "^6.0.2"
+ repeat-element "^1.1.2"
+ snapdragon "^0.8.1"
+ snapdragon-node "^2.0.1"
+ split-string "^3.0.2"
+ to-regex "^3.0.1"
+
brorand@^1.0.1:
- version "1.0.7"
- resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.0.7.tgz#6677fa5e4901bdbf9c9ec2a748e28dca407a9bfc"
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
+
+browser-pack@^6.0.1:
+ version "6.0.4"
+ resolved "https://registry.yarnpkg.com/browser-pack/-/browser-pack-6.0.4.tgz#9a73beb3b48f9e36868be007b64400102c04a99f"
+ dependencies:
+ JSONStream "^1.0.3"
+ combine-source-map "~0.8.0"
+ defined "^1.0.0"
+ safe-buffer "^5.1.1"
+ through2 "^2.0.0"
+ umd "^3.0.0"
+
+browser-resolve@^1.11.0, browser-resolve@^1.7.0:
+ version "1.11.2"
+ resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.2.tgz#8ff09b0a2c421718a1051c260b32e48f442938ce"
+ dependencies:
+ resolve "1.1.7"
browserify-aes@^1.0.0, browserify-aes@^1.0.4:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.6.tgz#5e7725dbdef1fd5930d4ebab48567ce451c48a0a"
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.1.1.tgz#38b7ab55edb806ff2dcda1a7f1620773a477c49f"
dependencies:
- buffer-xor "^1.0.2"
+ buffer-xor "^1.0.3"
cipher-base "^1.0.0"
create-hash "^1.1.0"
- evp_bytestokey "^1.0.0"
+ evp_bytestokey "^1.0.3"
inherits "^2.0.1"
+ safe-buffer "^5.0.1"
browserify-cipher@^1.0.0:
version "1.0.0"
@@ -1108,8 +1369,8 @@ browserify-rsa@^4.0.0:
randombytes "^2.0.1"
browserify-sign@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.0.tgz#10773910c3c206d5420a46aad8694f820b85968f"
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298"
dependencies:
bn.js "^4.1.1"
browserify-rsa "^4.0.0"
@@ -1125,6 +1386,64 @@ browserify-zlib@^0.1.4:
dependencies:
pako "~0.2.0"
+browserify-zlib@~0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f"
+ dependencies:
+ pako "~1.0.5"
+
+browserify@^14.5.0:
+ version "14.5.0"
+ resolved "https://registry.yarnpkg.com/browserify/-/browserify-14.5.0.tgz#0bbbce521acd6e4d1d54d8e9365008efb85a9cc5"
+ dependencies:
+ JSONStream "^1.0.3"
+ assert "^1.4.0"
+ browser-pack "^6.0.1"
+ browser-resolve "^1.11.0"
+ browserify-zlib "~0.2.0"
+ buffer "^5.0.2"
+ cached-path-relative "^1.0.0"
+ concat-stream "~1.5.1"
+ console-browserify "^1.1.0"
+ constants-browserify "~1.0.0"
+ crypto-browserify "^3.0.0"
+ defined "^1.0.0"
+ deps-sort "^2.0.0"
+ domain-browser "~1.1.0"
+ duplexer2 "~0.1.2"
+ events "~1.1.0"
+ glob "^7.1.0"
+ has "^1.0.0"
+ htmlescape "^1.1.0"
+ https-browserify "^1.0.0"
+ inherits "~2.0.1"
+ insert-module-globals "^7.0.0"
+ labeled-stream-splicer "^2.0.0"
+ module-deps "^4.0.8"
+ os-browserify "~0.3.0"
+ parents "^1.0.1"
+ path-browserify "~0.0.0"
+ process "~0.11.0"
+ punycode "^1.3.2"
+ querystring-es3 "~0.2.0"
+ read-only-stream "^2.0.0"
+ readable-stream "^2.0.2"
+ resolve "^1.1.4"
+ shasum "^1.0.0"
+ shell-quote "^1.6.1"
+ stream-browserify "^2.0.0"
+ stream-http "^2.0.0"
+ string_decoder "~1.0.0"
+ subarg "^1.0.0"
+ syntax-error "^1.1.1"
+ through2 "^2.0.0"
+ timers-browserify "^1.0.1"
+ tty-browserify "~0.0.0"
+ url "~0.11.0"
+ util "~0.10.1"
+ vm-browserify "~0.0.1"
+ xtend "^4.0.0"
+
browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6:
version "1.7.7"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9"
@@ -1136,7 +1455,11 @@ buffer-indexof@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.0.tgz#f54f647c4f4e25228baa656a2e57e43d5f270982"
-buffer-xor@^1.0.2:
+buffer-more-ints@0.0.2:
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/buffer-more-ints/-/buffer-more-ints-0.0.2.tgz#26b3885d10fa13db7fc01aae3aab870199e0124c"
+
+buffer-xor@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
@@ -1148,6 +1471,25 @@ buffer@^4.3.0:
ieee754 "^1.1.4"
isarray "^1.0.0"
+buffer@^5.0.2:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.1.0.tgz#c913e43678c7cb7c8bd16afbcddb6c5505e8f9fe"
+ dependencies:
+ base64-js "^1.0.2"
+ ieee754 "^1.1.4"
+
+buildmail@4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/buildmail/-/buildmail-4.0.1.tgz#877f7738b78729871c9a105e3b837d2be11a7a72"
+ dependencies:
+ addressparser "1.0.1"
+ libbase64 "0.1.0"
+ libmime "3.0.0"
+ libqp "1.1.0"
+ nodemailer-fetch "1.6.0"
+ nodemailer-shared "1.1.0"
+ punycode "1.4.1"
+
builtin-modules@^1.0.0, builtin-modules@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@@ -1156,14 +1498,50 @@ builtin-status-codes@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
-bytes@2.4.0:
- version "2.4.0"
- resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.4.0.tgz#7d97196f9d5baf7f6935e25985549edd2a6c2339"
-
bytes@2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.5.0.tgz#4c9423ea2d252c270c41b2bdefeff9bb6b62c06a"
+bytes@3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
+
+cacache@^10.0.1:
+ version "10.0.4"
+ resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460"
+ dependencies:
+ bluebird "^3.5.1"
+ chownr "^1.0.1"
+ glob "^7.1.2"
+ graceful-fs "^4.1.11"
+ lru-cache "^4.1.1"
+ mississippi "^2.0.0"
+ mkdirp "^0.5.1"
+ move-concurrently "^1.0.1"
+ promise-inflight "^1.0.1"
+ rimraf "^2.6.2"
+ ssri "^5.2.4"
+ unique-filename "^1.1.0"
+ y18n "^4.0.0"
+
+cache-base@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
+ dependencies:
+ collection-visit "^1.0.0"
+ component-emitter "^1.2.1"
+ get-value "^2.0.6"
+ has-value "^1.0.0"
+ isobject "^3.0.1"
+ set-value "^2.0.0"
+ to-object-path "^0.3.0"
+ union-value "^1.0.0"
+ unset-value "^1.0.0"
+
+cached-path-relative@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7"
+
caller-path@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f"
@@ -1197,7 +1575,7 @@ camelcase@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a"
-camelcase@^4.1.0:
+camelcase@^4.0.0, camelcase@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
@@ -1214,6 +1592,14 @@ caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639:
version "1.0.30000649"
resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000649.tgz#1ee1754a6df235450c8b7cd15e0ebf507221a86a"
+capture-stack-trace@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d"
+
+caseless@~0.11.0:
+ version "0.11.0"
+ resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.11.0.tgz#715b96ea9841593cc33067923f5ec60ebda4f7d7"
+
caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -1235,7 +1621,7 @@ chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
strip-ansi "^3.0.0"
supports-color "^2.0.0"
-chalk@^2.0.0, chalk@^2.3.0:
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba"
dependencies:
@@ -1243,7 +1629,27 @@ chalk@^2.0.0, chalk@^2.3.0:
escape-string-regexp "^1.0.5"
supports-color "^4.0.0"
-chokidar@^1.4.1, chokidar@^1.4.3, chokidar@^1.6.0, chokidar@^1.7.0:
+chalk@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.1.tgz#523fe2678aec7b04e8041909292fe8b17059b796"
+ dependencies:
+ ansi-styles "^3.2.0"
+ escape-string-regexp "^1.0.5"
+ supports-color "^5.2.0"
+
+chardet@^0.4.0:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
+
+chart.js@1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-1.0.2.tgz#ad57d2229cfd8ccf5955147e8121b4911e69dfe7"
+
+check-types@^7.3.0:
+ version "7.3.0"
+ resolved "https://registry.yarnpkg.com/check-types/-/check-types-7.3.0.tgz#468f571a4435c24248f5fd0cb0e8d87c3c341e7d"
+
+chokidar@^1.4.1, chokidar@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
dependencies:
@@ -1258,32 +1664,78 @@ chokidar@^1.4.1, chokidar@^1.4.3, chokidar@^1.6.0, chokidar@^1.7.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"
+chokidar@^2.0.0, chokidar@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.2.tgz#4dc65139eeb2714977735b6a35d06e97b494dfd7"
dependencies:
+ anymatch "^2.0.0"
+ async-each "^1.0.0"
+ braces "^2.3.0"
+ glob-parent "^3.1.0"
inherits "^2.0.1"
+ is-binary-path "^1.0.0"
+ is-glob "^4.0.0"
+ normalize-path "^2.1.1"
+ path-is-absolute "^1.0.0"
+ readdirp "^2.0.0"
+ upath "^1.0.0"
+ optionalDependencies:
+ fsevents "^1.0.0"
+
+chownr@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181"
+
+cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de"
+ dependencies:
+ inherits "^2.0.1"
+ safe-buffer "^5.0.1"
circular-json@^0.3.1:
version "0.3.3"
resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66"
+circular-json@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.5.1.tgz#b8942a09e535863dc21b04417a91971e1d9cd91f"
+
clap@^1.0.9:
version "1.1.3"
resolved "https://registry.yarnpkg.com/clap/-/clap-1.1.3.tgz#b3bd36e93dd4cbfb395a3c26896352445265c05b"
dependencies:
chalk "^1.1.3"
+class-utils@^0.3.5:
+ version "0.3.6"
+ resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
+ dependencies:
+ arr-union "^3.1.0"
+ define-property "^0.2.5"
+ isobject "^3.0.0"
+ static-extend "^0.1.1"
+
classlist-polyfill@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz#935bc2dfd9458a876b279617514638bcaa964a2e"
+cli-boxes@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
+
cli-cursor@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
dependencies:
restore-cursor "^1.0.1"
+cli-cursor@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
+ dependencies:
+ restore-cursor "^2.0.0"
+
cli-width@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a"
@@ -1320,6 +1772,10 @@ co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+co@~3.0.6:
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/co/-/co-3.0.6.tgz#1445f226c5eb956138e68c9ac30167ea7d2e6bda"
+
coa@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.1.tgz#7f959346cfc8719e3f7233cd6852854a7c67d8a3"
@@ -1330,6 +1786,13 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+collection-visit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
+ dependencies:
+ map-visit "^1.0.0"
+ object-visit "^1.0.0"
+
color-convert@^1.3.0, color-convert@^1.9.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed"
@@ -1372,17 +1835,33 @@ combine-lists@^1.0.0:
dependencies:
lodash "^4.5.0"
-combined-stream@^1.0.5, combined-stream@~1.0.5:
- version "1.0.5"
- resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009"
+combine-source-map@~0.7.1:
+ version "0.7.2"
+ resolved "https://registry.yarnpkg.com/combine-source-map/-/combine-source-map-0.7.2.tgz#0870312856b307a87cc4ac486f3a9a62aeccc09e"
dependencies:
- delayed-stream "~1.0.0"
+ convert-source-map "~1.1.0"
+ inline-source-map "~0.6.0"
+ lodash.memoize "~3.0.3"
+ source-map "~0.5.3"
-commander@^2.9.0:
- version "2.9.0"
- resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4"
+combine-source-map@~0.8.0:
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/combine-source-map/-/combine-source-map-0.8.0.tgz#a58d0df042c186fcf822a8e8015f5450d2d79a8b"
dependencies:
- graceful-readlink ">= 1.0.0"
+ convert-source-map "~1.1.0"
+ inline-source-map "~0.6.0"
+ lodash.memoize "~3.0.3"
+ source-map "~0.5.3"
+
+combined-stream@1.0.6, combined-stream@^1.0.5, combined-stream@~1.0.5:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818"
+ dependencies:
+ delayed-stream "~1.0.0"
+
+commander@^2.13.0, commander@^2.9.0:
+ version "2.14.1"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.14.1.tgz#2235123e37af8ca3c65df45b026dbd357b01b9aa"
commondir@^1.0.1:
version "1.0.1"
@@ -1392,11 +1871,7 @@ component-bind@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
-component-emitter@1.1.2:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.1.2.tgz#296594f2753daa63996d2af08d15a95116c9aec3"
-
-component-emitter@1.2.1:
+component-emitter@1.2.1, component-emitter@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
@@ -1410,11 +1885,14 @@ compressible@~2.0.10:
dependencies:
mime-db ">= 1.29.0 < 2"
-compression-webpack-plugin@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-1.0.0.tgz#5c5eb6afd08ca6a5d66006eeef71da17b01bd676"
+compression-webpack-plugin@^1.1.7:
+ version "1.1.7"
+ resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-1.1.7.tgz#b0dfb97cf1d26baab997b584b8c36fe91872abe2"
dependencies:
- async "2.4.1"
+ async "^2.4.1"
+ cacache "^10.0.1"
+ find-cache-dir "^1.0.0"
+ serialize-javascript "^1.4.0"
webpack-sources "^1.0.1"
compression@^1.5.2:
@@ -1433,7 +1911,7 @@ 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.2:
+concat-stream@^1.5.0, concat-stream@^1.5.2:
version "1.6.0"
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7"
dependencies:
@@ -1441,31 +1919,37 @@ concat-stream@^1.5.2:
readable-stream "^2.2.2"
typedarray "^0.0.6"
-configstore@^1.0.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/configstore/-/configstore-1.4.0.tgz#c35781d0501d268c25c54b8b17f6240e8a4fb021"
+concat-stream@~1.5.0, concat-stream@~1.5.1:
+ version "1.5.2"
+ resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266"
+ dependencies:
+ inherits "~2.0.1"
+ readable-stream "~2.0.0"
+ typedarray "~0.0.5"
+
+configstore@^3.0.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.1.tgz#094ee662ab83fad9917678de114faaea8fcdca90"
dependencies:
+ dot-prop "^4.1.0"
graceful-fs "^4.1.2"
- mkdirp "^0.5.0"
- object-assign "^4.0.1"
- os-tmpdir "^1.0.0"
- osenv "^0.1.0"
- uuid "^2.0.1"
- write-file-atomic "^1.1.2"
- xdg-basedir "^2.0.0"
+ make-dir "^1.0.0"
+ unique-string "^1.0.0"
+ write-file-atomic "^2.0.0"
+ xdg-basedir "^3.0.0"
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.6.0:
- version "3.6.3"
- resolved "https://registry.yarnpkg.com/connect/-/connect-3.6.3.tgz#f7320d46a25b4be7b483a2236517f24b1e27e301"
+ version "3.6.6"
+ resolved "https://registry.yarnpkg.com/connect/-/connect-3.6.6.tgz#09eff6c55af7236e137135a72574858b6786f524"
dependencies:
- debug "2.6.8"
- finalhandler "1.0.4"
- parseurl "~1.3.1"
- utils-merge "1.0.0"
+ debug "2.6.9"
+ finalhandler "1.1.0"
+ parseurl "~1.3.2"
+ utils-merge "1.0.1"
console-browserify@^1.1.0:
version "1.1.0"
@@ -1483,7 +1967,7 @@ consolidate@^0.14.0:
dependencies:
bluebird "^3.1.1"
-constants-browserify@^1.0.0:
+constants-browserify@^1.0.0, constants-browserify@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
@@ -1495,14 +1979,18 @@ content-disposition@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4"
-content-type@~1.0.2:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed"
+content-type@~1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
convert-source-map@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5"
+convert-source-map@~1.1.0:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.1.3.tgz#4829c877e9fe49b3161f3bf3673888e204699860"
+
cookie-signature@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
@@ -1511,27 +1999,46 @@ 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"
+copy-concurrently@^1.0.0:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
dependencies:
- bluebird "^2.10.2"
- fs-extra "^0.26.4"
- glob "^6.0.4"
- is-glob "^3.1.0"
+ aproba "^1.1.1"
+ fs-write-stream-atomic "^1.0.8"
+ iferr "^0.1.5"
+ mkdirp "^0.5.1"
+ rimraf "^2.5.4"
+ run-queue "^1.0.0"
+
+copy-descriptor@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
+
+copy-webpack-plugin@^4.4.1:
+ version "4.4.1"
+ resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.4.1.tgz#1e8c366211db6dc2ddee40e5a3e4fc661dd149e8"
+ dependencies:
+ cacache "^10.0.1"
+ find-cache-dir "^1.0.0"
+ globby "^7.1.1"
+ is-glob "^4.0.0"
loader-utils "^0.2.15"
- lodash "^4.3.0"
- minimatch "^3.0.0"
- node-dir "^0.1.10"
+ minimatch "^3.0.4"
+ p-limit "^1.0.0"
+ serialize-javascript "^1.4.0"
-core-js@^2.2.0, core-js@^2.4.1:
- version "2.4.1"
- resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e"
+core-js@^2.2.0:
+ version "2.5.3"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e"
core-js@^2.4.0, core-js@^2.5.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b"
+core-js@^2.4.1:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e"
+
core-js@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65"
@@ -1558,21 +2065,31 @@ create-ecdh@^4.0.0:
bn.js "^4.1.0"
elliptic "^6.0.0"
-create-hash@^1.1.0, create-hash@^1.1.1:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.2.tgz#51210062d7bb7479f6c65bb41a92208b1d61abad"
+create-error-class@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
+ dependencies:
+ capture-stack-trace "^1.0.0"
+
+create-hash@^1.1.0, create-hash@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd"
dependencies:
cipher-base "^1.0.1"
inherits "^2.0.1"
- ripemd160 "^1.0.0"
- sha.js "^2.3.6"
+ ripemd160 "^2.0.0"
+ sha.js "^2.4.0"
-create-hmac@^1.1.0, create-hmac@^1.1.2:
- version "1.1.4"
- resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.4.tgz#d3fb4ba253eb8b3f56e39ea2fbcb8af747bd3170"
+create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06"
dependencies:
+ cipher-base "^1.0.3"
create-hash "^1.1.0"
inherits "^2.0.1"
+ ripemd160 "^2.0.0"
+ safe-buffer "^5.0.1"
+ sha.js "^2.4.8"
cropper@^2.3.0:
version "2.3.0"
@@ -1580,7 +2097,7 @@ cropper@^2.3.0:
dependencies:
jquery ">= 1.9.1"
-cross-spawn@^5.0.1:
+cross-spawn@5.1.0, cross-spawn@^5.0.1:
version "5.1.0"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
dependencies:
@@ -1594,6 +2111,28 @@ cryptiles@2.x.x:
dependencies:
boom "2.x.x"
+cryptiles@3.x.x:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe"
+ dependencies:
+ boom "5.x.x"
+
+crypto-browserify@^3.0.0:
+ version "3.12.0"
+ resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec"
+ dependencies:
+ browserify-cipher "^1.0.0"
+ browserify-sign "^4.0.0"
+ create-ecdh "^4.0.0"
+ create-hash "^1.1.0"
+ create-hmac "^1.1.0"
+ diffie-hellman "^5.0.0"
+ inherits "^2.0.1"
+ pbkdf2 "^3.0.3"
+ public-encrypt "^4.0.0"
+ randombytes "^2.0.0"
+ randomfill "^1.0.3"
+
crypto-browserify@^3.11.0:
version "3.11.0"
resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522"
@@ -1609,34 +2148,32 @@ crypto-browserify@^3.11.0:
public-encrypt "^4.0.0"
randombytes "^2.0.0"
+crypto-random-string@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
+
css-color-names@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
-css-loader@^0.28.0:
- version "0.28.0"
- resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.0.tgz#417cfa9789f8cde59a30ccbf3e4da7a806889bad"
+css-loader@^0.28.9:
+ version "0.28.9"
+ resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.9.tgz#68064b85f4e271d7ce4c48a58300928e535d1c95"
dependencies:
- babel-code-frame "^6.11.0"
+ babel-code-frame "^6.26.0"
css-selector-tokenizer "^0.7.0"
- cssnano ">=2.6.1 <4"
+ cssnano "^3.10.0"
+ icss-utils "^2.1.0"
loader-utils "^1.0.2"
lodash.camelcase "^4.3.0"
- object-assign "^4.0.1"
+ object-assign "^4.1.1"
postcss "^5.0.6"
- postcss-modules-extract-imports "^1.0.0"
- postcss-modules-local-by-default "^1.0.1"
- postcss-modules-scope "^1.0.0"
- postcss-modules-values "^1.1.0"
- source-list-map "^0.1.7"
-
-css-selector-tokenizer@^0.6.0:
- version "0.6.0"
- resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.6.0.tgz#6445f582c7930d241dcc5007a43d6fcb8f073152"
- dependencies:
- cssesc "^0.1.0"
- fastparse "^1.1.1"
- regexpu-core "^1.0.0"
+ postcss-modules-extract-imports "^1.2.0"
+ postcss-modules-local-by-default "^1.2.0"
+ postcss-modules-scope "^1.1.0"
+ postcss-modules-values "^1.3.0"
+ postcss-value-parser "^3.3.0"
+ source-list-map "^2.0.0"
css-selector-tokenizer@^0.7.0:
version "0.7.0"
@@ -1650,7 +2187,7 @@ cssesc@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4"
-"cssnano@>=2.6.1 <4":
+cssnano@^3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38"
dependencies:
@@ -1704,6 +2241,10 @@ custom-event@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
+cyclist@~0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
+
d3-array@^1.2.0, d3-array@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.1.tgz#d1ca33de2f6ac31efadb8e050a021d7e2396d5dc"
@@ -1844,6 +2385,14 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
+data-uri-to-buffer@1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz#77163ea9c20d8641b4707e8f18abdf9a78f34835"
+
+date-format@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/date-format/-/date-format-1.2.0.tgz#615e828e233dd1ab9bb9ae0950e0ceccfa6ecad8"
+
date-now@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
@@ -1852,31 +2401,25 @@ de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
-debug@2.2.0:
+debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@~2.6.4, debug@~2.6.6:
+ version "2.6.9"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ dependencies:
+ ms "2.0.0"
+
+debug@2.2.0, debug@~2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da"
dependencies:
ms "0.7.1"
-debug@2.3.3:
- version "2.3.3"
- resolved "https://registry.yarnpkg.com/debug/-/debug-2.3.3.tgz#40c453e67e6e13c901ddec317af8986cda9eff8c"
- dependencies:
- ms "0.7.2"
-
-debug@2.6.7:
- version "2.6.7"
- resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.7.tgz#92bad1f6d05bbb6bba22cca88bcd0ec894c2861e"
- dependencies:
- ms "2.0.0"
-
-debug@2.6.8, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.6.6, debug@^2.6.8:
+debug@2.6.8, debug@^2.1.1, 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"
-debug@^3.0.1, debug@^3.1.0:
+debug@^3.0.1, debug@^3.1.0, debug@~3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
dependencies:
@@ -1890,13 +2433,17 @@ 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"
+decode-uri-component@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
+
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, deep-equal@~1.0.1:
+deep-equal@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
@@ -1921,10 +2468,37 @@ define-properties@^1.1.2:
foreach "^2.0.5"
object-keys "^1.0.8"
-defined@^1.0.0, defined@~1.0.0:
+define-property@^0.2.5:
+ version "0.2.5"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
+ dependencies:
+ is-descriptor "^0.1.0"
+
+define-property@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
+ dependencies:
+ is-descriptor "^1.0.0"
+
+define-property@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
+ dependencies:
+ is-descriptor "^1.0.2"
+ isobject "^3.0.1"
+
+defined@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
+degenerator@~1.0.2:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095"
+ dependencies:
+ ast-types "0.x.x"
+ escodegen "1.x.x"
+ esprima "3.x.x"
+
del@^2.0.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8"
@@ -1960,14 +2534,19 @@ delegates@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-depd@1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.0.tgz#e1bd82c6aab6ced965b97b88b17ed3e528ca18c3"
-
-depd@1.1.1, depd@~1.1.0, depd@~1.1.1:
+depd@1.1.1, depd@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
+deps-sort@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/deps-sort/-/deps-sort-2.0.0.tgz#091724902e84658260eb910748cccd1af6e21fb5"
+ dependencies:
+ JSONStream "^1.0.3"
+ shasum "^1.0.0"
+ subarg "^1.0.0"
+ through2 "^2.0.0"
+
des.js@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc"
@@ -1985,10 +2564,28 @@ detect-indent@^4.0.0:
dependencies:
repeating "^2.0.0"
+detect-libc@^1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+
detect-node@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.3.tgz#a2033c09cc8e158d37748fbde7507832bd6ce127"
+detect-port-alt@1.1.5:
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.5.tgz#a1aa8fc805a4a5df9b905b7ddc7eed036bcce889"
+ dependencies:
+ address "^1.0.1"
+ debug "^2.6.0"
+
+detective@^4.0.0:
+ version "4.7.1"
+ resolved "https://registry.yarnpkg.com/detective/-/detective-4.7.1.tgz#0eca7314338442febb6d65da54c10bb1c82b246e"
+ dependencies:
+ acorn "^5.2.1"
+ defined "^1.0.0"
+
di@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
@@ -2005,6 +2602,13 @@ diffie-hellman@^5.0.0:
miller-rabin "^4.0.0"
randombytes "^2.0.0"
+dir-glob@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
+ dependencies:
+ arrify "^1.0.1"
+ path-type "^3.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"
@@ -2056,7 +2660,7 @@ dom-serializer@0:
domelementtype "~1.1.1"
entities "~1.1.1"
-domain-browser@^1.1.1:
+domain-browser@^1.1.1, domain-browser@~1.1.0:
version "1.1.7"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc"
@@ -2069,22 +2673,38 @@ domelementtype@~1.1.1:
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b"
domhandler@^2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.3.0.tgz#2de59a0822d5027fabff6f032c2b25a2a8abe738"
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.1.tgz#892e47000a99be55bbf3774ffea0561d8879c259"
dependencies:
domelementtype "1"
domutils@^1.5.1:
- version "1.5.1"
- resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
+ version "1.6.2"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.6.2.tgz#1958cc0b4c9426e9ed367fb1c8e854891b0fa3ff"
dependencies:
dom-serializer "0"
domelementtype "1"
+dot-prop@^4.1.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
+ dependencies:
+ is-obj "^1.0.0"
+
+double-ended-queue@^2.1.0-0:
+ version "2.1.0-0"
+ resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c"
+
dropzone@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/dropzone/-/dropzone-4.2.0.tgz#fbe7acbb9918e0706489072ef663effeef8a79f3"
+duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
+ dependencies:
+ readable-stream "^2.0.2"
+
duplexer3@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
@@ -2093,9 +2713,9 @@ duplexer@^0.1.1, duplexer@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
-duplexify@^3.2.0:
- version "3.5.1"
- resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.1.tgz#4e1516be68838bc90a49994f0b39a6e5960befcd"
+duplexify@^3.4.2, duplexify@^3.5.3:
+ version "3.5.3"
+ resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.3.tgz#8b5818800df92fd0125b27ab896491912858243e"
dependencies:
end-of-stream "^1.0.0"
inherits "^2.0.1"
@@ -2112,22 +2732,25 @@ 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.6:
- version "2.5.6"
- resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88"
+ejs@^2.5.7:
+ version "2.5.7"
+ resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.7.tgz#cc872c168880ae3c7189762fd5ffc00896c9518a"
electron-to-chromium@^1.2.7:
version "1.3.3"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.3.tgz#651eb63fe89f39db70ffc8dbd5d9b66958bc6a0e"
elliptic@^6.0.0:
- version "6.3.3"
- resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.3.3.tgz#5482d9646d54bcb89fd7d994fc9e2e9568876e3f"
+ version "6.4.0"
+ resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df"
dependencies:
bn.js "^4.4.0"
brorand "^1.0.1"
hash.js "^1.0.0"
+ hmac-drbg "^1.0.0"
inherits "^2.0.1"
+ minimalistic-assert "^1.0.0"
+ minimalistic-crypto-utils "^1.0.0"
emoji-unicode-version@^0.2.1:
version "0.2.1"
@@ -2138,8 +2761,8 @@ emojis-list@^2.0.0:
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
encodeurl@~1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20"
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
end-of-stream@^1.0.0:
version "1.4.0"
@@ -2147,44 +2770,50 @@ end-of-stream@^1.0.0:
dependencies:
once "^1.4.0"
-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"
+end-of-stream@^1.1.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
+ dependencies:
+ once "^1.4.0"
+
+engine.io-client@~3.1.0:
+ version "3.1.5"
+ resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.1.5.tgz#85de17666560327ef1817978f6e3f8101ded2c47"
dependencies:
component-emitter "1.2.1"
component-inherit "0.0.3"
- debug "2.3.3"
- engine.io-parser "1.3.2"
+ debug "~3.1.0"
+ engine.io-parser "~2.1.1"
has-cors "1.1.0"
indexof "0.0.1"
- parsejson "0.0.3"
parseqs "0.0.5"
parseuri "0.0.5"
- ws "1.1.2"
- xmlhttprequest-ssl "1.5.3"
+ ws "~3.3.1"
+ xmlhttprequest-ssl "~1.5.4"
yeast "0.1.2"
-engine.io-parser@1.3.2:
- version "1.3.2"
- resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-1.3.2.tgz#937b079f0007d0893ec56d46cb220b8cb435220a"
+engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.2.tgz#4c0f4cff79aaeecbbdcfdea66a823c6085409196"
dependencies:
after "0.8.2"
- arraybuffer.slice "0.0.6"
+ arraybuffer.slice "~0.0.7"
base64-arraybuffer "0.1.5"
blob "0.0.4"
- has-binary "0.1.7"
- wtf-8 "1.0.0"
+ has-binary2 "~1.0.2"
-engine.io@1.8.3:
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.3.tgz#8de7f97895d20d39b85f88eeee777b2bd42b13d4"
+engine.io@~3.1.0:
+ version "3.1.5"
+ resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.1.5.tgz#0e7ef9d690eb0b35597f1d4ad02a26ca2dba3845"
dependencies:
- accepts "1.3.3"
+ accepts "~1.3.4"
base64id "1.0.0"
cookie "0.3.1"
- debug "2.3.3"
- engine.io-parser "1.3.2"
- ws "1.1.2"
+ debug "~3.1.0"
+ engine.io-parser "~2.1.0"
+ ws "~3.3.1"
+ optionalDependencies:
+ uws "~9.14.0"
enhanced-resolve@^3.4.0:
version "3.4.1"
@@ -2223,9 +2852,9 @@ error-ex@^1.2.0:
dependencies:
is-arrayish "^0.2.1"
-es-abstract@^1.5.0:
- version "1.8.2"
- resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.8.2.tgz#25103263dc4decbda60e0c737ca32313518027ee"
+es-abstract@^1.7.0:
+ version "1.10.0"
+ resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.10.0.tgz#1ecb36c197842a00d8ee4c2dfd8646bb97d60864"
dependencies:
es-to-primitive "^1.1.1"
function-bind "^1.1.1"
@@ -2267,7 +2896,7 @@ es6-map@^0.1.3:
es6-symbol "~3.1.1"
event-emitter "~0.3.5"
-es6-promise@^3.0.2, 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"
@@ -2316,6 +2945,17 @@ escodegen@1.8.x:
optionalDependencies:
source-map "~0.2.0"
+escodegen@1.x.x:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.9.0.tgz#9811a2f265dc1cd3894420ee3717064b632b8852"
+ dependencies:
+ esprima "^3.1.3"
+ estraverse "^4.2.0"
+ esutils "^2.0.2"
+ optionator "^0.8.1"
+ optionalDependencies:
+ source-map "~0.5.6"
+
escope@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3"
@@ -2368,7 +3008,7 @@ eslint-plugin-filenames@^1.1.0:
lodash.kebabcase "4.0.1"
lodash.snakecase "4.0.1"
-eslint-plugin-html@^2.0.1:
+eslint-plugin-html@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-2.0.1.tgz#3a829510e82522f1e2e44d55d7661a176121fce1"
dependencies:
@@ -2397,7 +3037,25 @@ eslint-plugin-promise@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-3.5.0.tgz#78fbb6ffe047201627569e85a6c5373af2a68fca"
-eslint@^3.10.1:
+eslint-plugin-vue@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-4.0.1.tgz#afda92cfd7e7363b1fbdb1a772dd63359a9ce96a"
+ dependencies:
+ require-all "^2.2.0"
+ vue-eslint-parser "^2.0.1"
+
+eslint-scope@^3.7.1:
+ version "3.7.1"
+ resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
+ dependencies:
+ esrecurse "^4.1.0"
+ estraverse "^4.1.1"
+
+eslint-visitor-keys@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
+
+eslint@^3.18.0:
version "3.19.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.19.0.tgz#c8fc6201c7f40dd08941b87c085767386a679acc"
dependencies:
@@ -2444,10 +3102,21 @@ espree@^3.4.0:
acorn "^5.1.1"
acorn-jsx "^3.0.0"
+espree@^3.5.2:
+ version "3.5.2"
+ resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.2.tgz#756ada8b979e9dcfcdb30aad8d1a9304a905e1ca"
+ dependencies:
+ acorn "^5.2.1"
+ acorn-jsx "^3.0.0"
+
esprima@2.7.x, esprima@^2.6.0, esprima@^2.7.1:
version "2.7.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
+esprima@3.x.x, esprima@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
+
esprima@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.0.tgz#4499eddcd1110e0b218bacf2fa7f7f59f55ca804"
@@ -2481,9 +3150,9 @@ esutils@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
-etag@~1.8.0:
- version "1.8.0"
- resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051"
+etag@~1.8.1:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
eve-raphael@0.5.0:
version "0.5.0"
@@ -2512,21 +3181,22 @@ eventemitter3@1.x.x:
version "1.2.0"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508"
-events@^1.0.0:
+events@^1.0.0, events@~1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
-eventsource@0.1.6, eventsource@^0.1.3:
+eventsource@0.1.6:
version "0.1.6"
resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232"
dependencies:
original ">=0.0.5"
-evp_bytestokey@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.0.tgz#497b66ad9fef65cd7c08a6180824ba1476b66e53"
+evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02"
dependencies:
- create-hash "^1.1.1"
+ md5.js "^1.3.4"
+ safe-buffer "^5.1.1"
execa@^0.7.0:
version "0.7.0"
@@ -2558,6 +3228,18 @@ expand-brackets@^0.1.4:
dependencies:
is-posix-bracket "^0.1.0"
+expand-brackets@^2.1.4:
+ version "2.1.4"
+ resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
+ dependencies:
+ debug "^2.3.3"
+ define-property "^0.2.5"
+ extend-shallow "^2.0.1"
+ posix-character-classes "^0.1.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
expand-range@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-0.1.1.tgz#4cb8eda0993ca56fa4f41fc42f3cbb4ccadff044"
@@ -2571,64 +3253,114 @@ expand-range@^1.8.1:
dependencies:
fill-range "^2.1.0"
-exports-loader@^0.6.4:
- version "0.6.4"
- resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.4.tgz#d70fc6121975b35fc12830cf52754be2740fc886"
+expand-tilde@^2.0.0, expand-tilde@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
dependencies:
- loader-utils "^1.0.2"
- source-map "0.5.x"
+ homedir-polyfill "^1.0.1"
-express@^4.13.3, express@^4.15.2:
- version "4.15.4"
- resolved "https://registry.yarnpkg.com/express/-/express-4.15.4.tgz#032e2253489cf8fce02666beca3d11ed7a2daed1"
+exports-loader@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.7.0.tgz#84881c784dea6036b8e1cd1dac3da9b6409e21a5"
dependencies:
- accepts "~1.3.3"
+ loader-utils "^1.1.0"
+ source-map "0.5.0"
+
+express@^4.16.2:
+ version "4.16.2"
+ resolved "https://registry.yarnpkg.com/express/-/express-4.16.2.tgz#e35c6dfe2d64b7dca0a5cd4f21781be3299e076c"
+ dependencies:
+ accepts "~1.3.4"
array-flatten "1.1.1"
+ body-parser "1.18.2"
content-disposition "0.5.2"
- content-type "~1.0.2"
+ content-type "~1.0.4"
cookie "0.3.1"
cookie-signature "1.0.6"
- debug "2.6.8"
+ debug "2.6.9"
depd "~1.1.1"
encodeurl "~1.0.1"
escape-html "~1.0.3"
- etag "~1.8.0"
- finalhandler "~1.0.4"
- fresh "0.5.0"
+ etag "~1.8.1"
+ finalhandler "1.1.0"
+ fresh "0.5.2"
merge-descriptors "1.0.1"
methods "~1.1.2"
on-finished "~2.3.0"
- parseurl "~1.3.1"
+ parseurl "~1.3.2"
path-to-regexp "0.1.7"
- proxy-addr "~1.1.5"
- qs "6.5.0"
+ proxy-addr "~2.0.2"
+ qs "6.5.1"
range-parser "~1.2.0"
- send "0.15.4"
- serve-static "1.12.4"
- setprototypeof "1.0.3"
+ safe-buffer "5.1.1"
+ send "0.16.1"
+ serve-static "1.13.1"
+ setprototypeof "1.1.0"
statuses "~1.3.1"
type-is "~1.6.15"
- utils-merge "1.0.0"
- vary "~1.1.1"
+ utils-merge "1.0.1"
+ vary "~1.1.2"
-extend@^3.0.0, extend@~3.0.0:
+extend-shallow@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
+ dependencies:
+ is-extendable "^0.1.0"
+
+extend-shallow@^3.0.0, extend-shallow@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
+ dependencies:
+ assign-symbols "^1.0.0"
+ is-extendable "^1.0.1"
+
+extend@3, extend@^3.0.0, extend@~3.0.0, extend@~3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
+external-editor@^2.0.4:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.1.0.tgz#3d026a21b7f95b5726387d4200ac160d372c3b48"
+ dependencies:
+ chardet "^0.4.0"
+ iconv-lite "^0.4.17"
+ tmp "^0.0.33"
+
extglob@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
dependencies:
is-extglob "^1.0.0"
-extsprintf@1.3.0, extsprintf@^1.2.0:
+extglob@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
+ dependencies:
+ array-unique "^0.3.2"
+ define-property "^1.0.0"
+ expand-brackets "^2.1.4"
+ extend-shallow "^2.0.1"
+ fragment-cache "^0.2.1"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+extsprintf@^1.2.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
+
fast-deep-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
+fast-json-stable-stringify@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
+
fast-levenshtein@~2.0.4:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
@@ -2649,12 +3381,6 @@ faye-websocket@~0.11.0:
dependencies:
websocket-driver ">=0.5.1"
-faye-websocket@~0.7.3:
- version "0.7.3"
- resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.7.3.tgz#cc4074c7f4a4dfd03af54dd65c354b135132ce11"
- dependencies:
- websocket-driver ">=0.3.6"
-
figures@^1.3.5:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
@@ -2662,6 +3388,12 @@ figures@^1.3.5:
escape-string-regexp "^1.0.5"
object-assign "^4.1.0"
+figures@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
+ dependencies:
+ escape-string-regexp "^1.0.5"
+
file-entry-cache@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361"
@@ -2669,15 +3401,20 @@ file-entry-cache@^2.0.0:
flat-cache "^1.2.1"
object-assign "^4.0.1"
-file-loader@^0.11.1:
- version "0.11.1"
- resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-0.11.1.tgz#6b328ee1234a729e4e47d36375dd6d35c0e1db84"
+file-loader@^1.1.8:
+ version "1.1.8"
+ resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-1.1.8.tgz#a62592ed732667d7482dc3268c381c7f0c913086"
dependencies:
loader-utils "^1.0.2"
+ schema-utils "^0.4.5"
+
+file-uri-to-path@1:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
filename-regex@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.0.tgz#996e3e80479b98b9897f15a8a58b3d084e926775"
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
fileset@^2.0.2:
version "2.0.3"
@@ -2686,13 +3423,13 @@ fileset@^2.0.2:
glob "^7.0.3"
minimatch "^3.0.3"
-filesize@3.3.0:
- version "3.3.0"
- resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.3.0.tgz#53149ea3460e3b2e024962a51648aa572cf98122"
+filesize@3.5.11:
+ version "3.5.11"
+ resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.11.tgz#1919326749433bb3cf77368bd158caabcc19e9ee"
-filesize@^3.5.9:
- version "3.5.10"
- resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.10.tgz#fc8fa23ddb4ef9e5e0ab6e1e64f679a24a56761f"
+filesize@^3.5.11:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.0.tgz#22d079615624bb6fd3c04026120628a41b3f4efa"
fill-range@^2.1.0:
version "2.2.3"
@@ -2704,15 +3441,24 @@ fill-range@^2.1.0:
repeat-element "^1.1.2"
repeat-string "^1.5.2"
-finalhandler@1.0.4, finalhandler@~1.0.4:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.4.tgz#18574f2e7c4b98b8ae3b230c21f201f31bdb3fb7"
+fill-range@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
dependencies:
- debug "2.6.8"
+ extend-shallow "^2.0.1"
+ is-number "^3.0.0"
+ repeat-string "^1.6.1"
+ to-regex-range "^2.1.0"
+
+finalhandler@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.0.tgz#ce0b6855b45853e791b2fcc680046d88253dd7f5"
+ dependencies:
+ debug "2.6.9"
encodeurl "~1.0.1"
escape-html "~1.0.3"
on-finished "~2.3.0"
- parseurl "~1.3.1"
+ parseurl "~1.3.2"
statuses "~1.3.1"
unpipe "~1.0.0"
@@ -2754,27 +3500,34 @@ flatten@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
+flush-write-stream@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.2.tgz#c81b90d8746766f1a609a46809946c45dd8ae417"
+ dependencies:
+ inherits "^2.0.1"
+ readable-stream "^2.0.4"
+
+follow-redirects@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.0.0.tgz#8e34298cbd2e176f254effec75a1c78cc849fd37"
+ dependencies:
+ debug "^2.2.0"
+
follow-redirects@^1.2.5:
version "1.2.6"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.6.tgz#4dcdc7e4ab3dd6765a97ff89c3b4c258117c79bf"
dependencies:
debug "^3.1.0"
-for-each@~0.3.2:
- version "0.3.2"
- resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4"
- dependencies:
- is-function "~1.0.0"
-
-for-in@^0.1.5:
- version "0.1.6"
- resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8"
+for-in@^1.0.1, for-in@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
for-own@^0.1.4:
- version "0.1.4"
- resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.4.tgz#0149b41a39088c7515f51ebe1c1386d45f935072"
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
dependencies:
- for-in "^0.1.5"
+ for-in "^1.0.1"
foreach@^2.0.5:
version "2.0.5"
@@ -2784,6 +3537,14 @@ forever-agent@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+form-data@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.0.0.tgz#6f0aebadcc5da16c13e1ecc11137d85f9b883b25"
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.5"
+ mime-types "^2.1.11"
+
form-data@~2.1.1:
version "2.1.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1"
@@ -2792,13 +3553,34 @@ form-data@~2.1.1:
combined-stream "^1.0.5"
mime-types "^2.1.12"
-forwarded@~0.1.0:
- version "0.1.0"
- resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363"
+form-data@~2.3.1:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.2.tgz#4970498be604c20c005d4f5c23aecd21d6b49099"
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "1.0.6"
+ mime-types "^2.1.12"
-fresh@0.5.0:
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e"
+forwarded@~0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+
+fragment-cache@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
+ dependencies:
+ map-cache "^0.2.2"
+
+fresh@0.5.2:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+
+from2@^2.1.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af"
+ dependencies:
+ inherits "^2.0.1"
+ readable-stream "^2.0.0"
from@~0:
version "0.1.7"
@@ -2810,26 +3592,25 @@ fs-access@^1.0.0:
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"
+fs-write-stream-atomic@^1.0.8:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9"
dependencies:
graceful-fs "^4.1.2"
- jsonfile "^2.1.0"
- klaw "^1.0.0"
- path-is-absolute "^1.0.0"
- rimraf "^2.2.8"
+ iferr "^0.1.5"
+ imurmurhash "^0.1.4"
+ readable-stream "1 || 2"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
fsevents@^1.0.0:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.2.tgz#3282b713fb3ad80ede0e9fcf4611b5aa6fc033f4"
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.3.tgz#11f82318f5fe7bb2cd22965a108e9306208216d8"
dependencies:
nan "^2.3.0"
- node-pre-gyp "^0.6.36"
+ node-pre-gyp "^0.6.39"
fstream-ignore@^1.0.5:
version "1.0.5"
@@ -2848,11 +3629,14 @@ fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2:
mkdirp ">=0.5 0"
rimraf "2"
-function-bind@^1.0.2, function-bind@~1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.0.tgz#16176714c801798e4e8f2cf7f7529467bb4a5771"
+ftp@~0.3.10:
+ version "0.3.10"
+ resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d"
+ dependencies:
+ readable-stream "1.1.x"
+ xregexp "2.0.0"
-function-bind@^1.1.1:
+function-bind@^1.0.2, function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -2895,6 +3679,21 @@ get-stream@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+get-uri@2:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-2.0.1.tgz#dbdcacacd8c608a38316869368117697a1631c59"
+ dependencies:
+ data-uri-to-buffer "1"
+ debug "2"
+ extend "3"
+ file-uri-to-path "1"
+ ftp "~0.3.10"
+ readable-stream "2"
+
+get-value@^2.0.3, get-value@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
+
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -2914,6 +3713,13 @@ glob-parent@^2.0.0:
dependencies:
is-glob "^2.0.0"
+glob-parent@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
+ dependencies:
+ is-glob "^3.1.0"
+ path-dirname "^1.0.0"
+
glob@^5.0.15:
version "5.0.15"
resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
@@ -2924,17 +3730,7 @@ 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:
+glob@^7.0.0, glob@^7.0.3:
version "7.1.1"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8"
dependencies:
@@ -2945,7 +3741,7 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1:
once "^1.3.0"
path-is-absolute "^1.0.0"
-glob@~7.1.2:
+glob@^7.0.5, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
dependencies:
@@ -2956,6 +3752,30 @@ glob@~7.1.2:
once "^1.3.0"
path-is-absolute "^1.0.0"
+global-dirs@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
+ dependencies:
+ ini "^1.3.4"
+
+global-modules@1.0.0, global-modules@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
+ dependencies:
+ global-prefix "^1.0.1"
+ is-windows "^1.0.1"
+ resolve-dir "^1.0.0"
+
+global-prefix@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
+ dependencies:
+ expand-tilde "^2.0.2"
+ homedir-polyfill "^1.0.1"
+ ini "^1.3.4"
+ is-windows "^1.0.1"
+ which "^1.2.14"
+
globals@^10.0.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-10.4.0.tgz#5c477388b128a9e4c5c5d01c7a2aca68c68b2da7"
@@ -2985,28 +3805,40 @@ globby@^6.1.0:
pify "^2.0.0"
pinkie-promise "^2.0.0"
+globby@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/globby/-/globby-7.1.1.tgz#fb2ccff9401f8600945dfada97440cca972b8680"
+ dependencies:
+ array-union "^1.0.1"
+ dir-glob "^2.0.0"
+ glob "^7.1.2"
+ ignore "^3.3.5"
+ pify "^3.0.0"
+ slash "^1.0.0"
+
good-listener@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
dependencies:
delegate "^3.1.2"
-got@^3.2.0:
- version "3.3.1"
- resolved "https://registry.yarnpkg.com/got/-/got-3.3.1.tgz#e5d0ed4af55fc3eef4d56007769d98192bcb2eca"
+got@^6.7.1:
+ version "6.7.1"
+ resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
dependencies:
- duplexify "^3.2.0"
- infinity-agent "^2.0.0"
+ create-error-class "^3.0.0"
+ duplexer3 "^0.1.4"
+ get-stream "^3.0.0"
is-redirect "^1.0.0"
+ is-retry-allowed "^1.0.0"
is-stream "^1.0.0"
lowercase-keys "^1.0.0"
- nested-error-stacks "^1.0.0"
- object-assign "^3.0.0"
- prepend-http "^1.0.0"
- read-all-stream "^3.0.0"
- timed-out "^2.0.0"
+ safe-buffer "^5.0.1"
+ timed-out "^4.0.0"
+ unzip-response "^2.0.1"
+ url-parse-lax "^1.0.0"
-got@^7.0.0:
+got@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a"
dependencies:
@@ -3025,26 +3857,29 @@ got@^7.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:
+graceful-fs@^4.1.11, graceful-fs@^4.1.2:
version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
-"graceful-readlink@>= 1.0.0":
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
-
graphlib@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.1.tgz#42352c52ba2f4d035cb566eb91f7395f76ebc951"
dependencies:
lodash "^4.11.1"
-gzip-size@3.0.0, gzip-size@^3.0.0:
+gzip-size@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-3.0.0.tgz#546188e9bdc337f673772f81660464b389dce520"
dependencies:
duplexer "^0.1.1"
+gzip-size@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-4.1.0.tgz#8ae096257eabe7d69c45be2b67c448124ffb517c"
+ dependencies:
+ duplexer "^0.1.1"
+ pify "^3.0.0"
+
handle-thing@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
@@ -3063,6 +3898,19 @@ har-schema@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
+har-schema@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+
+har-validator@~2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d"
+ dependencies:
+ chalk "^1.1.1"
+ commander "^2.9.0"
+ is-my-json-valid "^2.12.4"
+ pinkie-promise "^2.0.0"
+
har-validator@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a"
@@ -3070,17 +3918,24 @@ har-validator@~4.2.1:
ajv "^4.9.1"
har-schema "^1.0.5"
+har-validator@~5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd"
+ dependencies:
+ ajv "^5.1.0"
+ har-schema "^2.0.0"
+
has-ansi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
dependencies:
ansi-regex "^2.0.0"
-has-binary@0.1.7:
- version "0.1.7"
- resolved "https://registry.yarnpkg.com/has-binary/-/has-binary-0.1.7.tgz#68e61eb16210c9545a0a5cce06a873912fe1e68c"
+has-binary2@~1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.2.tgz#e83dba49f0b9be4d026d27365350d9f03f54be98"
dependencies:
- isarray "0.0.1"
+ isarray "2.0.1"
has-cors@1.1.0:
version "1.1.0"
@@ -3094,6 +3949,10 @@ has-flag@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
+has-flag@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+
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"
@@ -3108,23 +3967,64 @@ has-unicode@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
-has@^1.0.1, has@~1.0.1:
+has-value@^0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
+ dependencies:
+ get-value "^2.0.3"
+ has-values "^0.1.4"
+ isobject "^2.0.0"
+
+has-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
+ dependencies:
+ get-value "^2.0.6"
+ has-values "^1.0.0"
+ isobject "^3.0.0"
+
+has-values@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
+
+has-values@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
+ dependencies:
+ is-number "^3.0.0"
+ kind-of "^4.0.0"
+
+has@^1.0.0, has@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28"
dependencies:
function-bind "^1.0.2"
+hash-base@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-2.0.2.tgz#66ea1d856db4e8a5470cadf6fce23ae5244ef2e1"
+ dependencies:
+ inherits "^2.0.1"
+
+hash-base@^3.0.0:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918"
+ dependencies:
+ inherits "^2.0.1"
+ safe-buffer "^5.0.1"
+
hash-sum@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04"
-hash.js@^1.0.0:
- version "1.0.3"
- resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.0.3.tgz#1332ff00156c0a0ffdd8236013d07b77a0451573"
+hash.js@^1.0.0, hash.js@^1.0.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.3.tgz#340dedbe6290187151c1ea1d777a3448935df846"
dependencies:
- inherits "^2.0.1"
+ inherits "^2.0.3"
+ minimalistic-assert "^1.0.0"
-hawk@~3.1.3:
+hawk@3.1.3, hawk@~3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4"
dependencies:
@@ -3133,14 +4033,42 @@ hawk@~3.1.3:
hoek "2.x.x"
sntp "1.x.x"
+hawk@~6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038"
+ dependencies:
+ boom "4.x.x"
+ cryptiles "3.x.x"
+ hoek "4.x.x"
+ sntp "2.x.x"
+
he@^1.1.0, he@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
+hipchat-notifier@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/hipchat-notifier/-/hipchat-notifier-1.1.0.tgz#b6d249755437c191082367799d3ba9a0f23b231e"
+ dependencies:
+ lodash "^4.0.0"
+ request "^2.0.0"
+
+hmac-drbg@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
+ dependencies:
+ hash.js "^1.0.3"
+ minimalistic-assert "^1.0.0"
+ minimalistic-crypto-utils "^1.0.1"
+
hoek@2.x.x:
version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
+hoek@4.x.x:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb"
+
home-or-tmp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@@ -3148,6 +4076,12 @@ home-or-tmp@^2.0.0:
os-homedir "^1.0.0"
os-tmpdir "^1.0.1"
+homedir-polyfill@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz#4c2bbc8a758998feebf5ed68580f76d46768b4bc"
+ dependencies:
+ parse-passwd "^1.0.0"
+
hosted-git-info@^2.1.4:
version "2.2.0"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.2.0.tgz#7a0d097863d886c0fabbdcd37bf1758d8becf8a5"
@@ -3165,11 +4099,15 @@ html-comment-regex@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
-html-entities@1.2.0, html-entities@^1.2.0:
+html-entities@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.0.tgz#41948caf85ce82fed36e4e6a0ed371a6664379e2"
-htmlparser2@^3.8.2:
+htmlescape@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351"
+
+htmlparser2@^3.8.2, htmlparser2@^3.9.0:
version "3.9.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338"
dependencies:
@@ -3184,16 +4122,7 @@ http-deceiver@^1.2.7:
version "1.2.7"
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
-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-errors@~1.6.2:
+http-errors@1.6.2, http-errors@~1.6.1, http-errors@~1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
dependencies:
@@ -3202,6 +4131,14 @@ http-errors@~1.6.2:
setprototypeof "1.0.3"
statuses ">= 1.3.1 < 2"
+http-proxy-agent@1:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-1.0.0.tgz#cc1ce38e453bf984a0f7702d2dd59c73d081284a"
+ dependencies:
+ agent-base "2"
+ debug "2"
+ extend "3"
+
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"
@@ -3226,23 +4163,68 @@ http-signature@~1.1.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
+http-signature@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+ dependencies:
+ assert-plus "^1.0.0"
+ jsprim "^1.2.2"
+ sshpk "^1.7.0"
+
+httpntlm@1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/httpntlm/-/httpntlm-1.6.1.tgz#ad01527143a2e8773cfae6a96f58656bb52a34b2"
+ dependencies:
+ httpreq ">=0.4.22"
+ underscore "~1.7.0"
+
+httpreq@>=0.4.22:
+ version "0.4.24"
+ resolved "https://registry.yarnpkg.com/httpreq/-/httpreq-0.4.24.tgz#4335ffd82cd969668a39465c929ac61d6393627f"
+
https-browserify@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82"
+https-browserify@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
+
+https-proxy-agent@1:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz#35f7da6c48ce4ddbfa264891ac593ee5ff8671e6"
+ dependencies:
+ agent-base "2"
+ debug "2"
+ extend "3"
+
iconv-lite@0.4.15:
version "0.4.15"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb"
-icss-replace-symbols@^1.0.2:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz#cb0b6054eb3af6edc9ab1d62d01933e2d4c8bfa5"
+iconv-lite@0.4.19, iconv-lite@^0.4.17:
+ version "0.4.19"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
+
+icss-replace-symbols@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
+
+icss-utils@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962"
+ dependencies:
+ postcss "^6.0.1"
ieee754@^1.1.4:
version "1.1.8"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
-ignore-by-default@^1.0.0:
+iferr@^0.1.5:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
+
+ignore-by-default@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
@@ -3250,16 +4232,31 @@ ignore@^3.2.0:
version "3.3.3"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.3.tgz#432352e57accd87ab3110e82d3fea0e47812156d"
+ignore@^3.3.5, ignore@^3.3.7:
+ version "3.3.7"
+ resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021"
+
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"
+import-lazy@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
+
+import-local@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc"
+ dependencies:
+ pkg-dir "^2.0.0"
+ resolve-cwd "^2.0.0"
+
+imports-loader@^0.8.0:
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-0.8.0.tgz#030ea51b8ca05977c40a3abfd9b4088fe0be9a69"
dependencies:
loader-utils "^1.0.2"
- source-map "^0.5.6"
+ source-map "^0.6.1"
imurmurhash@^0.1.4:
version "0.1.4"
@@ -3279,9 +4276,13 @@ indexof@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
-infinity-agent@^2.0.0:
- version "2.0.3"
- resolved "https://registry.yarnpkg.com/infinity-agent/-/infinity-agent-2.0.3.tgz#45e0e2ff7a9eb030b27d62b74b3744b7a7ac4216"
+inflection@~1.10.0:
+ version "1.10.0"
+ resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.10.0.tgz#5bffcb1197ad3e81050f8e17e21668087ee9eb2f"
+
+inflection@~1.3.0:
+ version "1.3.8"
+ resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.3.8.tgz#cbd160da9f75b14c3cc63578d4f396784bf3014e"
inflight@^1.0.4:
version "1.0.6"
@@ -3298,9 +4299,34 @@ inherits@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1"
-ini@~1.3.0:
- version "1.3.4"
- resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e"
+ini@^1.3.4, ini@~1.3.0:
+ version "1.3.5"
+ resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+
+inline-source-map@~0.6.0:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/inline-source-map/-/inline-source-map-0.6.2.tgz#f9393471c18a79d1724f863fa38b586370ade2a5"
+ dependencies:
+ source-map "~0.5.3"
+
+inquirer@3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9"
+ dependencies:
+ ansi-escapes "^3.0.0"
+ chalk "^2.0.0"
+ cli-cursor "^2.1.0"
+ cli-width "^2.0.0"
+ external-editor "^2.0.4"
+ figures "^2.0.0"
+ lodash "^4.3.0"
+ mute-stream "0.0.7"
+ run-async "^2.2.0"
+ rx-lite "^4.0.8"
+ rx-lite-aggregates "^4.0.8"
+ string-width "^2.1.0"
+ strip-ansi "^4.0.0"
+ through "^2.3.6"
inquirer@^0.12.0:
version "0.12.0"
@@ -3320,7 +4346,20 @@ inquirer@^0.12.0:
strip-ansi "^3.0.0"
through "^2.3.6"
-internal-ip@^1.2.0:
+insert-module-globals@^7.0.0:
+ version "7.0.1"
+ resolved "https://registry.yarnpkg.com/insert-module-globals/-/insert-module-globals-7.0.1.tgz#c03bf4e01cb086d5b5e5ace8ad0afe7889d638c3"
+ dependencies:
+ JSONStream "^1.0.3"
+ combine-source-map "~0.7.1"
+ concat-stream "~1.5.1"
+ is-buffer "^1.1.0"
+ lexical-scope "^1.2.0"
+ process "~0.11.0"
+ through2 "^2.0.0"
+ xtend "^4.0.0"
+
+internal-ip@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-1.2.0.tgz#ae9fbf93b984878785d50a8de1b356956058cf5c"
dependencies:
@@ -3340,13 +4379,17 @@ invert-kv@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
-ip@^1.1.0, ip@^1.1.5:
+ip@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/ip/-/ip-1.0.1.tgz#c7e356cdea225ae71b36d70f2e71a92ba4e42590"
+
+ip@^1.1.0, ip@^1.1.2, ip@^1.1.4, ip@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
-ipaddr.js@1.4.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.4.0.tgz#296aca878a821816e5b85d0a285a99bcff4582f0"
+ipaddr.js@1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.6.0.tgz#e3fa357b773da619f26e95f049d055c72796f86b"
is-absolute-url@^2.0.0:
version "2.1.0"
@@ -3359,6 +4402,18 @@ is-absolute@^0.2.3:
is-relative "^0.2.1"
is-windows "^0.2.0"
+is-accessor-descriptor@^0.1.6:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-accessor-descriptor@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
+ dependencies:
+ kind-of "^6.0.0"
+
is-arrayish@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -3369,7 +4424,11 @@ is-binary-path@^1.0.0:
dependencies:
binary-extensions "^1.0.0"
-is-buffer@^1.0.2, is-buffer@^1.1.5:
+is-buffer@^1.1.0:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+
+is-buffer@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc"
@@ -3383,13 +4442,41 @@ is-callable@^1.1.1, is-callable@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
+is-data-descriptor@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-data-descriptor@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
+ dependencies:
+ kind-of "^6.0.0"
+
is-date-object@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
-is-dotfile@^1.0.0:
+is-descriptor@^0.1.0:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
+ dependencies:
+ is-accessor-descriptor "^0.1.6"
+ is-data-descriptor "^0.1.4"
+ kind-of "^5.0.0"
+
+is-descriptor@^1.0.0, is-descriptor@^1.0.2:
version "1.0.2"
- resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.2.tgz#2c132383f39199f8edc268ca01b9b007d205cc4d"
+ resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
+ dependencies:
+ is-accessor-descriptor "^1.0.0"
+ is-data-descriptor "^1.0.0"
+ kind-of "^6.0.2"
+
+is-dotfile@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
is-equal-shallow@^0.1.3:
version "0.1.3"
@@ -3397,15 +4484,21 @@ is-equal-shallow@^0.1.3:
dependencies:
is-primitive "^2.0.0"
-is-extendable@^0.1.1:
+is-extendable@^0.1.0, is-extendable@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
+is-extendable@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
+ dependencies:
+ is-plain-object "^2.0.4"
+
is-extglob@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
-is-extglob@^2.1.0:
+is-extglob@^2.1.0, is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -3425,10 +4518,6 @@ is-fullwidth-code-point@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-is-function@~1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5"
-
is-glob@^2.0.0, is-glob@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
@@ -3441,6 +4530,23 @@ is-glob@^3.1.0:
dependencies:
is-extglob "^2.1.0"
+is-glob@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0"
+ dependencies:
+ is-extglob "^2.1.1"
+
+is-installed-globally@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+ dependencies:
+ global-dirs "^0.1.0"
+ is-path-inside "^1.0.0"
+
+is-my-ip-valid@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz#7b351b8e8edd4d3995d4d066680e664d94696824"
+
is-my-json-valid@^2.10.0:
version "2.16.0"
resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.16.0.tgz#f079dd9bfdae65ee2038aae8acbc86ab109e3693"
@@ -3450,6 +4556,16 @@ is-my-json-valid@^2.10.0:
jsonpointer "^4.0.0"
xtend "^4.0.0"
+is-my-json-valid@^2.12.4:
+ version "2.17.2"
+ resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.17.2.tgz#6b2103a288e94ef3de5cf15d29dd85fc4b78d65c"
+ dependencies:
+ generate-function "^2.0.0"
+ generate-object-property "^1.1.0"
+ is-my-ip-valid "^1.0.0"
+ jsonpointer "^4.0.0"
+ xtend "^4.0.0"
+
is-npm@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
@@ -3458,16 +4574,36 @@ is-number@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-0.1.1.tgz#69a7af116963d47206ec9bd9b48a14216f1e3806"
-is-number@^2.0.2, is-number@^2.1.0:
+is-number@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
dependencies:
kind-of "^3.0.2"
+is-number@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-number@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+
+is-obj@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
+
is-object@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
+is-odd@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-2.0.0.tgz#7646624671fd7ea558ccd9a2795182f2958f1b24"
+ dependencies:
+ is-number "^4.0.0"
+
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"
@@ -3488,6 +4624,12 @@ 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"
+is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+ dependencies:
+ isobject "^3.0.1"
+
is-posix-bracket@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
@@ -3496,6 +4638,10 @@ is-primitive@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+is-promise@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
+
is-property@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
@@ -3526,6 +4672,10 @@ 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-root@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-root/-/is-root-1.0.0.tgz#07b6c233bc394cd9d02ba15c966bd6660d6342d5"
+
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"
@@ -3558,7 +4708,15 @@ is-windows@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c"
-isarray@0.0.1:
+is-windows@^1.0.1, is-windows@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
+
+is-wsl@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d"
+
+isarray@0.0.1, isarray@~0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
@@ -3566,6 +4724,10 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+isarray@2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
+
isbinaryfile@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.2.tgz#4a3e974ec0cba9004d3fc6cde7209ea69368a621"
@@ -3574,43 +4736,51 @@ isexe@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0"
+isexe@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+
isobject@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
dependencies:
isarray "1.0.0"
+isobject@^3.0.0, isobject@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
-istanbul-api@^1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.1.1.tgz#d36e2f1560d1a43ce304c4ff7338182de61c8f73"
+istanbul-api@^1.1.14:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.2.1.tgz#0c60a0515eb11c7d65c6b50bba2c6e999acd8620"
dependencies:
async "^2.1.4"
fileset "^2.0.2"
- istanbul-lib-coverage "^1.0.0"
- istanbul-lib-hook "^1.0.0"
- istanbul-lib-instrument "^1.3.0"
- istanbul-lib-report "^1.0.0-alpha.3"
- istanbul-lib-source-maps "^1.1.0"
- istanbul-reports "^1.0.0"
+ istanbul-lib-coverage "^1.1.1"
+ istanbul-lib-hook "^1.1.0"
+ istanbul-lib-instrument "^1.9.1"
+ istanbul-lib-report "^1.1.2"
+ istanbul-lib-source-maps "^1.2.2"
+ istanbul-reports "^1.1.3"
js-yaml "^3.7.0"
mkdirp "^0.5.1"
once "^1.4.0"
-istanbul-lib-coverage@^1.0.0, istanbul-lib-coverage@^1.0.0-alpha, istanbul-lib-coverage@^1.0.0-alpha.0, istanbul-lib-coverage@^1.1.1:
+istanbul-lib-coverage@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.1.tgz#73bfb998885299415c93d38a3e9adf784a77a9da"
-istanbul-lib-hook@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.0.0.tgz#fc5367ee27f59268e8f060b0c7aaf051d9c425c5"
+istanbul-lib-hook@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.1.0.tgz#8538d970372cb3716d53e55523dd54b557a8d89b"
dependencies:
append-transform "^0.4.0"
-istanbul-lib-instrument@^1.3.0, istanbul-lib-instrument@^1.7.5:
+istanbul-lib-instrument@^1.7.5, istanbul-lib-instrument@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.9.1.tgz#250b30b3531e5d3251299fdd64b0b2c9db6b558e"
dependencies:
@@ -3622,29 +4792,28 @@ istanbul-lib-instrument@^1.3.0, istanbul-lib-instrument@^1.7.5:
istanbul-lib-coverage "^1.1.1"
semver "^5.3.0"
-istanbul-lib-report@^1.0.0-alpha.3:
- version "1.0.0-alpha.3"
- resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.0.0-alpha.3.tgz#32d5f6ec7f33ca3a602209e278b2e6ff143498af"
+istanbul-lib-report@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.2.tgz#922be27c13b9511b979bd1587359f69798c1d425"
dependencies:
- async "^1.4.2"
- istanbul-lib-coverage "^1.0.0-alpha"
+ istanbul-lib-coverage "^1.1.1"
mkdirp "^0.5.1"
path-parse "^1.0.5"
- rimraf "^2.4.3"
supports-color "^3.1.2"
-istanbul-lib-source-maps@^1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.1.0.tgz#9d429218f35b823560ea300a96ff0c3bbdab785f"
+istanbul-lib-source-maps@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.2.tgz#750578602435f28a0c04ee6d7d9e0f2960e62c1c"
dependencies:
- istanbul-lib-coverage "^1.0.0-alpha.0"
+ debug "^3.1.0"
+ istanbul-lib-coverage "^1.1.1"
mkdirp "^0.5.1"
- rimraf "^2.4.4"
+ rimraf "^2.6.1"
source-map "^0.5.3"
-istanbul-reports@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.0.1.tgz#9a17176bc4a6cbebdae52b2f15961d52fa623fbc"
+istanbul-reports@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.1.3.tgz#3b9e1e8defb6d18b1d425da8e8b32c5a163f2d10"
dependencies:
handlebars "^4.0.3"
@@ -3674,9 +4843,9 @@ isurl@^1.0.0-alpha5:
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-core@^2.9.0:
+ version "2.9.0"
+ resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.9.0.tgz#bfbb56defcd30789adec5a3fbba8504233289c72"
jasmine-jquery@^2.1.1:
version "2.1.1"
@@ -3692,9 +4861,13 @@ jquery-ujs@1.2.2:
dependencies:
jquery ">=1.8.0"
-"jquery@>= 1.9.1", jquery@>=1.8.0, jquery@^2.2.4:
- version "2.2.4"
- resolved "https://registry.yarnpkg.com/jquery/-/jquery-2.2.4.tgz#2c89d6889b5eac522a7eea32c14521559c6cbf02"
+jquery.waitforimages@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/jquery.waitforimages/-/jquery.waitforimages-2.2.0.tgz#63f23131055a1b060dc913e6d874bcc9b9e6b16b"
+
+"jquery@>= 1.9.1", jquery@>=1.8.0, jquery@^3.2.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
js-base64@^2.1.9:
version "2.1.9"
@@ -3752,11 +4925,17 @@ 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-stable-stringify@~0.0.0:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz#611c23e814db375527df851193db59dd2af27f45"
+ dependencies:
+ jsonify "~0.0.0"
+
+json-stringify-safe@5.0.x, 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"
-json3@3.3.2, json3@^3.3.2:
+json3@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1"
@@ -3764,16 +4943,14 @@ json5@^0.5.0, json5@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
-jsonfile@^2.1.0:
- version "2.4.0"
- resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8"
- optionalDependencies:
- graceful-fs "^4.1.6"
-
jsonify@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
+jsonparse@^1.2.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+
jsonpointer@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9"
@@ -3801,28 +4978,31 @@ 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"
+karma-chrome-launcher@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz#cf1b9d07136cc18fe239327d24654c3dbc368acf"
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"
+karma-coverage-istanbul-reporter@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-1.4.1.tgz#2b42d145ddbb4868d85d52888c495a21c61d873c"
dependencies:
- istanbul-api "^1.1.1"
+ istanbul-api "^1.1.14"
+ minimatch "^3.0.4"
-karma-jasmine@^1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-1.1.0.tgz#22e4c06bf9a182e5294d1f705e3733811b810acf"
+karma-jasmine@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-1.1.1.tgz#6fe840e75a11600c9d91e84b33c458e1c46a3529"
-karma-mocha-reporter@^2.2.2:
- version "2.2.2"
- resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.2.tgz#876de9a287244e54a608591732a98e66611f6abe"
+karma-mocha-reporter@^2.2.5:
+ version "2.2.5"
+ resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz#15120095e8ed819186e47a0b012f3cd741895560"
dependencies:
- chalk "1.1.3"
+ chalk "^2.1.0"
+ log-symbols "^2.1.0"
+ strip-ansi "^4.0.0"
karma-sourcemap-loader@^0.3.7:
version "0.3.7"
@@ -3830,22 +5010,23 @@ karma-sourcemap-loader@^0.3.7:
dependencies:
graceful-fs "^4.1.2"
-karma-webpack@^2.0.4:
- version "2.0.4"
- resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-2.0.4.tgz#3e2d4f48ba94a878e1c66bb8e1ae6128987a175b"
+karma-webpack@2.0.7:
+ version "2.0.7"
+ resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-2.0.7.tgz#dc3a492b478f10e8e3ccb9f58171b623f7070a1f"
dependencies:
async "~0.9.0"
loader-utils "^0.2.5"
lodash "^3.8.0"
- source-map "^0.1.41"
- webpack-dev-middleware "^1.0.11"
+ source-map "^0.5.6"
+ webpack-dev-middleware "^1.12.0"
-karma@^1.7.0:
- version "1.7.0"
- resolved "https://registry.yarnpkg.com/karma/-/karma-1.7.0.tgz#6f7a1a406446fa2e187ec95398698f4cee476269"
+karma@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/karma/-/karma-2.0.0.tgz#a02698dd7f0f05ff5eb66ab8f65582490b512e58"
dependencies:
bluebird "^3.3.0"
body-parser "^1.16.1"
+ browserify "^14.5.0"
chokidar "^1.4.1"
colors "^1.1.0"
combine-lists "^1.0.0"
@@ -3858,8 +5039,8 @@ karma@^1.7.0:
graceful-fs "^4.1.2"
http-proxy "^1.13.0"
isbinaryfile "^3.0.0"
- lodash "^3.8.0"
- log4js "^0.6.31"
+ lodash "^4.17.4"
+ log4js "^2.3.9"
mime "^1.3.4"
minimatch "^3.0.2"
optimist "^0.6.1"
@@ -3867,33 +5048,65 @@ karma@^1.7.0:
range-parser "^1.2.0"
rimraf "^2.6.0"
safe-buffer "^5.0.1"
- socket.io "1.7.3"
- source-map "^0.5.3"
- tmp "0.0.31"
+ socket.io "2.0.4"
+ source-map "^0.6.1"
+ tmp "0.0.33"
useragent "^2.1.12"
-kind-of@^3.0.2:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.1.0.tgz#475d698a5e49ff5e53d14e3e732429dc8bf4cf47"
+katex@^0.8.3:
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/katex/-/katex-0.8.3.tgz#909d99864baf964c3ccae39c4a99a8e0fb9a1bd0"
dependencies:
- is-buffer "^1.0.2"
+ match-at "^0.1.0"
-klaw@^1.0.0:
- version "1.3.1"
- resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439"
- optionalDependencies:
- graceful-fs "^4.1.9"
+killable@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.0.tgz#da8b84bd47de5395878f95d64d02f2449fe05e6b"
-latest-version@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-1.0.1.tgz#72cfc46e3e8d1be651e1ebb54ea9f6ea96f374bb"
+kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
+ dependencies:
+ is-buffer "^1.1.5"
+
+kind-of@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
+ dependencies:
+ is-buffer "^1.1.5"
+
+kind-of@^5.0.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
+
+kind-of@^6.0.0, kind-of@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
+
+labeled-stream-splicer@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/labeled-stream-splicer/-/labeled-stream-splicer-2.0.0.tgz#a52e1d138024c00b86b1c0c91f677918b8ae0a59"
dependencies:
- package-json "^1.0.0"
+ inherits "^2.0.1"
+ isarray "~0.0.1"
+ stream-splicer "^2.0.0"
+
+latest-version@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
+ dependencies:
+ package-json "^4.0.0"
lazy-cache@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
+lazy-cache@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264"
+ dependencies:
+ set-getter "^0.1.0"
+
lcid@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
@@ -3907,6 +5120,28 @@ levn@^0.3.0, levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
+lexical-scope@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/lexical-scope/-/lexical-scope-1.2.0.tgz#fcea5edc704a4b3a8796cdca419c3a0afaf22df4"
+ dependencies:
+ astw "^2.0.0"
+
+libbase64@0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-0.1.0.tgz#62351a839563ac5ff5bd26f12f60e9830bb751e6"
+
+libmime@3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/libmime/-/libmime-3.0.0.tgz#51a1a9e7448ecbd32cda54421675bb21bc093da6"
+ dependencies:
+ iconv-lite "0.4.15"
+ libbase64 "0.1.0"
+ libqp "1.1.0"
+
+libqp@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/libqp/-/libqp-1.1.0.tgz#f5e6e06ad74b794fb5b5b66988bf728ef1dedbe8"
+
lie@~3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
@@ -3960,55 +5195,16 @@ locate-path@^2.0.0:
p-locate "^2.0.0"
path-exists "^3.0.0"
-lodash._baseassign@^3.0.0:
- version "3.2.0"
- resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e"
- dependencies:
- lodash._basecopy "^3.0.0"
- lodash.keys "^3.0.0"
-
-lodash._basecopy@^3.0.0:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36"
-
lodash._baseget@^3.0.0:
version "3.7.2"
resolved "https://registry.yarnpkg.com/lodash._baseget/-/lodash._baseget-3.7.2.tgz#1b6ae1d5facf3c25532350a13c1197cb8bb674f4"
-lodash._bindcallback@^3.0.0:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
-
-lodash._createassigner@^3.0.0:
- version "3.1.1"
- resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11"
- dependencies:
- lodash._bindcallback "^3.0.0"
- lodash._isiterateecall "^3.0.0"
- lodash.restparam "^3.0.0"
-
-lodash._getnative@^3.0.0:
- version "3.9.1"
- resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
-
-lodash._isiterateecall@^3.0.0:
- version "3.0.9"
- resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
-
lodash._topath@^3.0.0:
version "3.8.1"
resolved "https://registry.yarnpkg.com/lodash._topath/-/lodash._topath-3.8.1.tgz#3ec5e2606014f4cb97f755fe6914edd8bfc00eac"
dependencies:
lodash.isarray "^3.0.0"
-lodash.assign@^3.0.0:
- version "3.2.0"
- resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa"
- dependencies:
- lodash._baseassign "^3.0.0"
- lodash._createassigner "^3.0.0"
- lodash.keys "^3.0.0"
-
lodash.camelcase@4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.1.1.tgz#065b3ff08f0b7662f389934c46a5504c90e0b2d8"
@@ -4025,6 +5221,10 @@ lodash.capitalize@^4.0.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz#f826c9b4e2a8511d84e3aca29db05e1a4f3b72a9"
+lodash.clonedeep@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
+
lodash.cond@^4.3.0:
version "4.5.2"
resolved "https://registry.yarnpkg.com/lodash.cond/-/lodash.cond-4.5.2.tgz#f471a1da486be60f6ab955d17115523dd1d255d5"
@@ -4033,12 +5233,13 @@ lodash.deburr@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b"
-lodash.defaults@^3.1.2:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-3.1.2.tgz#c7308b18dbf8bc9372d701a73493c61192bd2e2c"
- dependencies:
- lodash.assign "^3.0.0"
- lodash.restparam "^3.0.0"
+lodash.endswith@^4.2.1:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/lodash.endswith/-/lodash.endswith-4.2.1.tgz#fed59ac1738ed3e236edd7064ec456448b37bc09"
+
+lodash.escaperegexp@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
lodash.get@^3.7.0:
version "3.7.0"
@@ -4047,14 +5248,18 @@ lodash.get@^3.7.0:
lodash._baseget "^3.0.0"
lodash._topath "^3.0.0"
-lodash.isarguments@^3.0.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
-
lodash.isarray@^3.0.0:
version "3.0.4"
resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
+lodash.isfunction@^3.0.8:
+ version "3.0.9"
+ resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051"
+
+lodash.isstring@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
+
lodash.kebabcase@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.0.1.tgz#5e63bc9aa2a5562ff3b97ca7af2f803de1bcb90e"
@@ -4062,21 +5267,17 @@ lodash.kebabcase@4.0.1:
lodash.deburr "^4.0.0"
lodash.words "^4.0.0"
-lodash.keys@^3.0.0:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
- dependencies:
- lodash._getnative "^3.0.0"
- lodash.isarguments "^3.0.0"
- lodash.isarray "^3.0.0"
-
lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
-lodash.restparam@^3.0.0:
- version "3.6.1"
- resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
+lodash.memoize@~3.0.3:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f"
+
+lodash.mergewith@^4.6.0:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55"
lodash.snakecase@4.0.1:
version "4.0.1"
@@ -4085,6 +5286,10 @@ lodash.snakecase@4.0.1:
lodash.deburr "^4.0.0"
lodash.words "^4.0.0"
+lodash.startswith@^4.2.1:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/lodash.startswith/-/lodash.startswith-4.2.1.tgz#c598c4adce188a27e53145731cdc6c0e7177600c"
+
lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@@ -4093,7 +5298,7 @@ lodash.words@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.words/-/lodash.words-4.2.0.tgz#5ecfeaf8ecf8acaa8e0c8386295f1993c9cf4036"
-lodash@4.17.4, lodash@^4.0.0, lodash@^4.11.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.17.4, lodash@^4.11.1, lodash@^4.17.2, lodash@^4.2.0, lodash@^4.3.0:
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
@@ -4101,12 +5306,42 @@ lodash@^3.8.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
-log4js@^0.6.31:
- version "0.6.38"
- resolved "https://registry.yarnpkg.com/log4js/-/log4js-0.6.38.tgz#2c494116695d6fb25480943d3fc872e662a522fd"
+lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.4, lodash@^4.5.0:
+ version "4.17.5"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
+
+log-symbols@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.1.0.tgz#f35fa60e278832b538dc4dddcbb478a45d3e3be6"
+ dependencies:
+ chalk "^2.0.1"
+
+log4js@^2.3.9:
+ version "2.5.3"
+ resolved "https://registry.yarnpkg.com/log4js/-/log4js-2.5.3.tgz#38bb7bde5e9c1c181bd75e8bc128c5cd0409caf1"
dependencies:
- readable-stream "~1.0.2"
- semver "~4.3.3"
+ circular-json "^0.5.1"
+ date-format "^1.2.0"
+ debug "^3.1.0"
+ semver "^5.3.0"
+ streamroller "^0.7.0"
+ optionalDependencies:
+ amqplib "^0.5.2"
+ axios "^0.15.3"
+ hipchat-notifier "^1.1.0"
+ loggly "^1.1.0"
+ mailgun-js "^0.7.0"
+ nodemailer "^2.5.0"
+ redis "^2.7.1"
+ slack-node "~0.2.0"
+
+loggly@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/loggly/-/loggly-1.1.1.tgz#0a0fc1d3fa3a5ec44fdc7b897beba2a4695cebee"
+ dependencies:
+ json-stringify-safe "5.0.x"
+ request "2.75.x"
+ timespan "2.3.x"
loglevel@^1.4.1:
version "1.4.1"
@@ -4133,27 +5368,52 @@ lowercase-keys@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306"
-lru-cache@2.2.x:
- version "2.2.4"
- resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.2.4.tgz#6c658619becf14031d0d0b594b16042ce4dc063d"
-
-lru-cache@^4.0.1, lru-cache@^4.1.1:
+lru-cache@4.1.x, lru-cache@^4.0.1, lru-cache@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55"
dependencies:
pseudomap "^1.0.2"
yallist "^2.1.2"
+lru-cache@~2.6.5:
+ version "2.6.5"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.6.5.tgz#e56d6354148ede8d7707b58d143220fd08df0fd5"
+
macaddress@^0.2.8:
version "0.2.8"
resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12"
+mailcomposer@4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4"
+ dependencies:
+ buildmail "4.0.1"
+ libmime "3.0.0"
+
+mailgun-js@^0.7.0:
+ version "0.7.15"
+ resolved "https://registry.yarnpkg.com/mailgun-js/-/mailgun-js-0.7.15.tgz#ee366a20dac64c3c15c03d6c1b3e0ed795252abb"
+ dependencies:
+ async "~2.1.2"
+ debug "~2.2.0"
+ form-data "~2.1.1"
+ inflection "~1.10.0"
+ is-stream "^1.1.0"
+ path-proxy "~1.0.0"
+ proxy-agent "~2.0.0"
+ q "~1.4.0"
+ tsscmp "~1.0.0"
+
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-cache@^0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
+
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"
@@ -4162,14 +5422,31 @@ map-stream@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194"
-marked@^0.3.6:
- version "0.3.6"
- resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.6.tgz#b2c6c618fccece4ef86c4fc6cb8a7cbf5aeda8d7"
+map-visit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
+ dependencies:
+ object-visit "^1.0.0"
+
+marked@^0.3.12:
+ version "0.3.12"
+ resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.12.tgz#7cf25ff2252632f3fe2406bde258e94eee927519"
+
+match-at@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/match-at/-/match-at-0.1.1.tgz#25d040d291777704d5e6556bbb79230ec2de0540"
math-expression-evaluator@^1.2.14:
version "1.2.16"
resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.16.tgz#b357fa1ca9faefb8e48d10c14ef2bcb2d9f0a7c9"
+md5.js@^1.3.4:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d"
+ dependencies:
+ hash-base "^3.0.0"
+ inherits "^2.0.1"
+
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -4232,9 +5509,27 @@ micromatch@^2.1.5, micromatch@^2.3.11:
parse-glob "^3.0.4"
regex-cache "^0.4.2"
+micromatch@^3.1.4:
+ version "3.1.6"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.6.tgz#8d7c043b48156f408ca07a4715182b79b99420bf"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ braces "^2.3.1"
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ extglob "^2.0.4"
+ fragment-cache "^0.2.1"
+ kind-of "^6.0.2"
+ nanomatch "^1.2.9"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
miller-rabin@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.0.tgz#4a62fb1d42933c05583982f4c716f6fb9e6c6d3d"
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"
dependencies:
bn.js "^4.0.0"
brorand "^1.0.1"
@@ -4243,19 +5538,23 @@ miller-rabin@^4.0.0:
version "1.29.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.29.0.tgz#48d26d235589651704ac5916ca06001914266878"
-mime-db@~1.27.0:
- version "1.27.0"
- resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1"
+mime-db@~1.33.0:
+ version "1.33.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db"
-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"
+mime-types@^2.1.11, mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.16, mime-types@~2.1.17, mime-types@~2.1.18, mime-types@~2.1.7:
+ version "2.1.18"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8"
dependencies:
- mime-db "~1.27.0"
+ mime-db "~1.33.0"
-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"
+mime@1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
+
+mime@^1.3.4, mime@^1.4.1, mime@^1.5.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
mimic-fn@^1.0.0:
version "1.1.0"
@@ -4269,6 +5568,10 @@ minimalistic-assert@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3"
+minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
+
"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
@@ -4281,20 +5584,66 @@ minimatch@3.0.3:
dependencies:
brace-expansion "^1.0.0"
-minimist@0.0.8, minimist@~0.0.1:
+minimist@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-minimist@^1.1.3, minimist@^1.2.0, minimist@~1.2.0:
+minimist@^1.1.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"
+minimist@~0.0.1:
+ version "0.0.10"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
+
+mississippi@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
+ dependencies:
+ concat-stream "^1.5.0"
+ duplexify "^3.4.2"
+ end-of-stream "^1.1.0"
+ flush-write-stream "^1.0.0"
+ from2 "^2.1.0"
+ parallel-transform "^1.1.0"
+ pump "^2.0.1"
+ pumpify "^1.3.3"
+ stream-each "^1.1.0"
+ through2 "^2.0.0"
+
+mixin-deep@^1.2.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
+ dependencies:
+ for-in "^1.0.2"
+ is-extendable "^1.0.1"
+
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"
dependencies:
minimist "0.0.8"
+module-deps@^4.0.8:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-4.1.1.tgz#23215833f1da13fd606ccb8087b44852dcb821fd"
+ dependencies:
+ JSONStream "^1.0.3"
+ browser-resolve "^1.7.0"
+ cached-path-relative "^1.0.0"
+ concat-stream "~1.5.0"
+ defined "^1.0.0"
+ detective "^4.0.0"
+ duplexer2 "^0.1.2"
+ inherits "^2.0.1"
+ parents "^1.0.0"
+ readable-stream "^2.0.2"
+ resolve "^1.1.3"
+ stream-combiner2 "^1.1.1"
+ subarg "^1.0.0"
+ through2 "^2.0.0"
+ xtend "^4.0.0"
+
moment@2.x, moment@^2.18.1:
version "2.19.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.2.tgz#8a7f774c95a64550b4c7ebd496683908f9419dbe"
@@ -4307,14 +5656,21 @@ mousetrap@^1.4.6:
version "1.4.6"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.4.6.tgz#eaca72e22e56d5b769b7555873b688c3332e390a"
+move-concurrently@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
+ dependencies:
+ aproba "^1.1.1"
+ copy-concurrently "^1.0.0"
+ fs-write-stream-atomic "^1.0.8"
+ mkdirp "^0.5.1"
+ rimraf "^2.5.4"
+ run-queue "^1.0.3"
+
ms@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098"
-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"
@@ -4334,13 +5690,34 @@ mute-stream@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"
+mute-stream@0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
+
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.3.0:
- version "2.6.2"
- resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45"
+ version "2.8.0"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.8.0.tgz#ed715f3fe9de02b57a5e6252d90a96675e1f085a"
+
+nanomatch@^1.2.9:
+ version "1.2.9"
+ resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.9.tgz#879f7150cb2dab7a471259066c104eee6e0fa7c2"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ fragment-cache "^0.2.1"
+ is-odd "^2.0.0"
+ is-windows "^1.0.2"
+ kind-of "^6.0.2"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
natural-compare@^1.4.0:
version "1.4.0"
@@ -4350,17 +5727,9 @@ negotiator@0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
-nested-error-stacks@^1.0.0:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-1.0.2.tgz#19f619591519f096769a5ba9a86e6eeec823c3cf"
- 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"
+netmask@~1.0.4:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35"
node-forge@0.6.33:
version "0.6.33"
@@ -4422,35 +5791,88 @@ node-libs-browser@^2.0.0:
util "^0.10.3"
vm-browserify "0.0.4"
-node-pre-gyp@^0.6.36:
- version "0.6.37"
- resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.37.tgz#3c872b236b2e266e4140578fe1ee88f693323a05"
+node-pre-gyp@^0.6.39:
+ version "0.6.39"
+ resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649"
dependencies:
+ detect-libc "^1.0.2"
+ hawk "3.1.3"
mkdirp "^0.5.1"
nopt "^4.0.1"
npmlog "^4.0.2"
rc "^1.1.7"
- request "^2.81.0"
+ request "2.81.0"
rimraf "^2.6.1"
semver "^5.3.0"
- tape "^4.6.3"
tar "^2.2.1"
tar-pack "^3.4.0"
-nodemon@^1.11.0:
- version "1.11.0"
- resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.11.0.tgz#226c562bd2a7b13d3d7518b49ad4828a3623d06c"
+node-uuid@~1.4.7:
+ version "1.4.8"
+ resolved "https://registry.yarnpkg.com/node-uuid/-/node-uuid-1.4.8.tgz#b040eb0923968afabf8d32fb1f17f1167fdab907"
+
+nodemailer-direct-transport@3.3.2:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/nodemailer-direct-transport/-/nodemailer-direct-transport-3.3.2.tgz#e96fafb90358560947e569017d97e60738a50a86"
dependencies:
- chokidar "^1.4.3"
- debug "^2.2.0"
- es6-promise "^3.0.2"
- ignore-by-default "^1.0.0"
- lodash.defaults "^3.1.2"
- minimatch "^3.0.0"
- ps-tree "^1.0.1"
- touch "1.0.0"
- undefsafe "0.0.3"
- update-notifier "0.5.0"
+ nodemailer-shared "1.1.0"
+ smtp-connection "2.12.0"
+
+nodemailer-fetch@1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/nodemailer-fetch/-/nodemailer-fetch-1.6.0.tgz#79c4908a1c0f5f375b73fe888da9828f6dc963a4"
+
+nodemailer-shared@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/nodemailer-shared/-/nodemailer-shared-1.1.0.tgz#cf5994e2fd268d00f5cf0fa767a08169edb07ec0"
+ dependencies:
+ nodemailer-fetch "1.6.0"
+
+nodemailer-smtp-pool@2.8.2:
+ version "2.8.2"
+ resolved "https://registry.yarnpkg.com/nodemailer-smtp-pool/-/nodemailer-smtp-pool-2.8.2.tgz#2eb94d6cf85780b1b4725ce853b9cbd5e8da8c72"
+ dependencies:
+ nodemailer-shared "1.1.0"
+ nodemailer-wellknown "0.1.10"
+ smtp-connection "2.12.0"
+
+nodemailer-smtp-transport@2.7.2:
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/nodemailer-smtp-transport/-/nodemailer-smtp-transport-2.7.2.tgz#03d71c76314f14ac7dbc7bf033a6a6d16d67fb77"
+ dependencies:
+ nodemailer-shared "1.1.0"
+ nodemailer-wellknown "0.1.10"
+ smtp-connection "2.12.0"
+
+nodemailer-wellknown@0.1.10:
+ version "0.1.10"
+ resolved "https://registry.yarnpkg.com/nodemailer-wellknown/-/nodemailer-wellknown-0.1.10.tgz#586db8101db30cb4438eb546737a41aad0cf13d5"
+
+nodemailer@^2.5.0:
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-2.7.2.tgz#f242e649aeeae39b6c7ed740ef7b061c404d30f9"
+ dependencies:
+ libmime "3.0.0"
+ mailcomposer "4.0.1"
+ nodemailer-direct-transport "3.3.2"
+ nodemailer-shared "1.1.0"
+ nodemailer-smtp-pool "2.8.2"
+ nodemailer-smtp-transport "2.7.2"
+ socks "1.1.9"
+
+nodemon@^1.15.1:
+ version "1.15.1"
+ resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.15.1.tgz#54daa72443d8d5a548f130866b92e65cded0ed58"
+ dependencies:
+ chokidar "^2.0.2"
+ debug "^3.1.0"
+ ignore-by-default "^1.0.1"
+ minimatch "^3.0.4"
+ pstree.remy "^1.1.0"
+ semver "^5.4.1"
+ touch "^3.1.0"
+ undefsafe "^2.0.1"
+ update-notifier "^2.3.0"
nopt@3.x:
version "3.0.6"
@@ -4480,7 +5902,7 @@ normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
semver "2 || 3 || 4 || 5"
validate-npm-package-license "^3.0.1"
-normalize-path@^2.0.0, normalize-path@^2.0.1:
+normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
dependencies:
@@ -4526,19 +5948,11 @@ number-is-nan@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-oauth-sign@~0.8.1:
+oauth-sign@~0.8.1, oauth-sign@~0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
-object-assign@4.1.0:
- version "4.1.0"
- resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0"
-
-object-assign@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2"
-
-object-assign@^4.0.1, object-assign@^4.1.0:
+object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@@ -4546,14 +5960,24 @@ object-component@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
-object-inspect@~1.3.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.3.0.tgz#5b1eb8e6742e2ee83342a637034d844928ba2f6d"
+object-copy@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
+ dependencies:
+ copy-descriptor "^0.1.0"
+ define-property "^0.2.5"
+ kind-of "^3.0.3"
object-keys@^1.0.8:
version "1.0.11"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d"
+object-visit@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
+ dependencies:
+ isobject "^3.0.0"
+
object.omit@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
@@ -4561,6 +5985,12 @@ object.omit@^2.0.0:
for-own "^0.1.4"
is-extendable "^0.1.1"
+object.pick@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
+ dependencies:
+ isobject "^3.0.1"
+
obuf@^1.0.0, obuf@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e"
@@ -4575,7 +6005,7 @@ on-headers@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7"
-once@1.x, once@^1.3.0, once@^1.3.3, once@^1.4.0:
+once@1.x, once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
dependencies:
@@ -4585,16 +6015,21 @@ onetime@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
+onetime@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
+ dependencies:
+ mimic-fn "^1.0.0"
+
opener@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8"
-opn@4.0.2:
- version "4.0.2"
- resolved "https://registry.yarnpkg.com/opn/-/opn-4.0.2.tgz#7abc22e644dff63b0a96d5ab7f2790c0f01abc95"
+opn@5.2.0, opn@^5.1.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/opn/-/opn-5.2.0.tgz#71fdf934d6827d676cecbea1531f95d354641225"
dependencies:
- object-assign "^4.0.1"
- pinkie-promise "^2.0.0"
+ is-wsl "^1.1.0"
optimist@^0.6.1:
version "0.6.1"
@@ -4614,10 +6049,6 @@ optionator@^0.8.1, optionator@^0.8.2:
type-check "~0.3.2"
wordwrap "~1.0.0"
-options@>=0.0.5:
- version "0.0.6"
- resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f"
-
original@>=0.0.5:
version "1.0.0"
resolved "https://registry.yarnpkg.com/original/-/original-1.0.0.tgz#9147f93fa1696d04be61e01bd50baeaca656bd3b"
@@ -4628,6 +6059,10 @@ os-browserify@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f"
+os-browserify@~0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
+
os-homedir@^1.0.0, os-homedir@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
@@ -4646,13 +6081,13 @@ os-locale@^2.0.0:
lcid "^1.0.0"
mem "^1.1.0"
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1:
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
-osenv@^0.1.0, osenv@^0.1.4:
- version "0.1.4"
- resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644"
+osenv@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
dependencies:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"
@@ -4665,6 +6100,12 @@ 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.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.2.0.tgz#0e92b6bedcb59f022c13d0f1949dc82d15909f1c"
+ dependencies:
+ p-try "^1.0.0"
+
p-limit@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc"
@@ -4685,12 +6126,42 @@ p-timeout@^1.1.1:
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"
+p-try@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
+
+pac-proxy-agent@1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-1.1.0.tgz#34a385dfdf61d2f0ecace08858c745d3e791fd4d"
+ dependencies:
+ agent-base "2"
+ debug "2"
+ extend "3"
+ get-uri "2"
+ http-proxy-agent "1"
+ https-proxy-agent "1"
+ pac-resolver "~2.0.0"
+ raw-body "2"
+ socks-proxy-agent "2"
+
+pac-resolver@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-2.0.0.tgz#99b88d2f193fbdeefc1c9a529c1f3260ab5277cd"
+ dependencies:
+ co "~3.0.6"
+ degenerator "~1.0.2"
+ ip "1.0.1"
+ netmask "~1.0.4"
+ thunkify "~2.1.1"
+
+package-json@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
dependencies:
- got "^3.2.0"
- registry-url "^3.0.0"
+ got "^6.7.1"
+ registry-auth-token "^3.0.1"
+ registry-url "^3.0.3"
+ semver "^5.1.0"
pako@~0.2.0:
version "0.2.9"
@@ -4700,9 +6171,27 @@ pako@~1.0.2:
version "1.0.5"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.5.tgz#d2205dfe5b9da8af797e7c163db4d1f84e4600bc"
+pako@~1.0.5:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
+
+parallel-transform@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06"
+ dependencies:
+ cyclist "~0.2.2"
+ inherits "^2.0.3"
+ readable-stream "^2.1.5"
+
+parents@^1.0.0, parents@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/parents/-/parents-1.0.1.tgz#fedd4d2bf193a77745fe71e371d73c3307d9c751"
+ dependencies:
+ path-platform "~0.11.15"
+
parse-asn1@^5.0.0:
- version "5.0.0"
- resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.0.0.tgz#35060f6d5015d37628c770f4e091a0b5a278bc23"
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.0.tgz#37c4f9b7ed3ab65c74817b5f2480937fbf97c712"
dependencies:
asn1.js "^4.0.0"
browserify-aes "^1.0.0"
@@ -4725,11 +6214,9 @@ parse-json@^2.2.0:
dependencies:
error-ex "^1.2.0"
-parsejson@0.0.3:
- version "0.0.3"
- resolved "https://registry.yarnpkg.com/parsejson/-/parsejson-0.0.3.tgz#ab7e3759f209ece99437973f7d0f1f64ae0e64ab"
- dependencies:
- better-assert "~1.0.0"
+parse-passwd@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
parseqs@0.0.5:
version "0.0.5"
@@ -4743,14 +6230,22 @@ parseuri@0.0.5:
dependencies:
better-assert "~1.0.0"
-parseurl@~1.3.1:
- version "1.3.1"
- resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.1.tgz#c8ab8c9223ba34888aa64a297b28853bec18da56"
+parseurl@~1.3.1, parseurl@~1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
-path-browserify@0.0.0:
+pascalcase@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
+
+path-browserify@0.0.0, path-browserify@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a"
+path-dirname@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
+
path-exists@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
@@ -4777,6 +6272,16 @@ path-parse@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1"
+path-platform@~0.11.15:
+ version "0.11.15"
+ resolved "https://registry.yarnpkg.com/path-platform/-/path-platform-0.11.15.tgz#e864217f74c36850f0852b78dc7bf7d4a5721bf2"
+
+path-proxy@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/path-proxy/-/path-proxy-1.0.0.tgz#18e8a36859fc9d2f1a53b48dee138543c020de5e"
+ dependencies:
+ inflection "~1.3.0"
+
path-to-regexp@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
@@ -4795,6 +6300,12 @@ path-type@^2.0.0:
dependencies:
pify "^2.0.0"
+path-type@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
+ dependencies:
+ pify "^3.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"
@@ -4802,15 +6313,23 @@ pause-stream@0.0.11:
through "~2.3"
pbkdf2@^3.0.3:
- version "3.0.9"
- resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.9.tgz#f2c4b25a600058b3c3773c086c37dbbee1ffe693"
+ version "3.0.14"
+ resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.14.tgz#a35e13c64799b06ce15320f459c230e68e73bade"
dependencies:
- create-hmac "^1.1.2"
+ create-hash "^1.1.2"
+ create-hmac "^1.1.4"
+ ripemd160 "^2.0.1"
+ safe-buffer "^5.0.1"
+ sha.js "^2.4.8"
performance-now@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
+performance-now@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+
pify@^2.0.0, pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -4865,6 +6384,10 @@ portfinder@^1.0.9:
debug "^2.2.0"
mkdirp "0.5.x"
+posix-character-classes@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
+
postcss-calc@^5.2.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e"
@@ -5010,32 +6533,32 @@ postcss-minify-selectors@^2.0.4:
postcss "^5.0.14"
postcss-selector-parser "^2.0.0"
-postcss-modules-extract-imports@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.0.1.tgz#8fb3fef9a6dd0420d3f6d4353cf1ff73f2b2a341"
+postcss-modules-extract-imports@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85"
dependencies:
- postcss "^5.0.4"
+ postcss "^6.0.1"
-postcss-modules-local-by-default@^1.0.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.1.1.tgz#29a10673fa37d19251265ca2ba3150d9040eb4ce"
+postcss-modules-local-by-default@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069"
dependencies:
- css-selector-tokenizer "^0.6.0"
- postcss "^5.0.4"
+ css-selector-tokenizer "^0.7.0"
+ postcss "^6.0.1"
-postcss-modules-scope@^1.0.0:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.0.2.tgz#ff977395e5e06202d7362290b88b1e8cd049de29"
+postcss-modules-scope@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90"
dependencies:
- css-selector-tokenizer "^0.6.0"
- postcss "^5.0.4"
+ css-selector-tokenizer "^0.7.0"
+ postcss "^6.0.1"
-postcss-modules-values@^1.1.0:
- version "1.2.2"
- resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.2.2.tgz#f0e7d476fe1ed88c5e4c7f97533a3e772ad94ca1"
+postcss-modules-values@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20"
dependencies:
- icss-replace-symbols "^1.0.2"
- postcss "^5.0.14"
+ icss-replace-symbols "^1.1.0"
+ postcss "^6.0.1"
postcss-normalize-charset@^1.1.0:
version "1.1.1"
@@ -5126,6 +6649,22 @@ postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0
source-map "^0.5.6"
supports-color "^3.2.3"
+postcss@^6.0.1:
+ version "6.0.19"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.19.tgz#76a78386f670b9d9494a655bf23ac012effd1555"
+ dependencies:
+ chalk "^2.3.1"
+ source-map "^0.6.1"
+ supports-color "^5.2.0"
+
+postcss@^6.0.14:
+ version "6.0.15"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.15.tgz#f460cd6269fede0d1bf6defff0b934a9845d974d"
+ dependencies:
+ chalk "^2.3.0"
+ source-map "^0.6.1"
+ supports-color "^5.1.0"
+
postcss@^6.0.8:
version "6.0.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.14.tgz#5534c72114739e75d0afcf017db853099f562885"
@@ -5146,6 +6685,10 @@ preserve@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+prettier@1.11.1:
+ version "1.11.1"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.11.1.tgz#61e43fc4cd44e68f2b0dfc2c38cd4bb0fccdcc75"
+
prettier@^1.7.0:
version "1.8.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.8.2.tgz#bff83e7fd573933c607875e5ba3abbdffb96aeb8"
@@ -5164,26 +6707,51 @@ process-nextick-args@~1.0.6:
version "1.0.7"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3"
-process@^0.11.0, process@~0.11.0:
+process-nextick-args@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
+
+process@^0.11.0:
version "0.11.9"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.9.tgz#7bd5ad21aa6253e7da8682264f1e11d11c0318c1"
+process@~0.11.0:
+ version "0.11.10"
+ resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
+
progress@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be"
-proxy-addr@~1.1.5:
- version "1.1.5"
- resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.5.tgz#71c0ee3b102de3f202f3b64f608d173fcba1a918"
+promise-inflight@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
+
+proxy-addr@~2.0.2:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.3.tgz#355f262505a621646b3130a728eb647e22055341"
+ dependencies:
+ forwarded "~0.1.2"
+ ipaddr.js "1.6.0"
+
+proxy-agent@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-2.0.0.tgz#57eb5347aa805d74ec681cb25649dba39c933499"
dependencies:
- forwarded "~0.1.0"
- ipaddr.js "1.4.0"
+ agent-base "2"
+ debug "2"
+ extend "3"
+ http-proxy-agent "1"
+ https-proxy-agent "1"
+ lru-cache "~2.6.5"
+ pac-proxy-agent "1"
+ socks-proxy-agent "2"
prr@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a"
-ps-tree@^1.0.1:
+ps-tree@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014"
dependencies:
@@ -5193,6 +6761,12 @@ pseudomap@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+pstree.remy@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.0.tgz#f2af27265bd3e5b32bbfcc10e80bac55ba78688b"
+ dependencies:
+ ps-tree "^1.1.0"
+
public-encrypt@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6"
@@ -5203,11 +6777,26 @@ public-encrypt@^4.0.0:
parse-asn1 "^5.0.0"
randombytes "^2.0.1"
+pump@^2.0.0, pump@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
+ dependencies:
+ end-of-stream "^1.1.0"
+ once "^1.3.1"
+
+pumpify@^1.3.3:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.4.0.tgz#80b7c5df7e24153d03f0e7ac8a05a5d068bd07fb"
+ dependencies:
+ duplexify "^3.5.3"
+ inherits "^2.0.3"
+ pump "^2.0.0"
+
punycode@1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
-punycode@^1.2.4, punycode@^1.4.1:
+punycode@1.4.1, punycode@^1.2.4, punycode@^1.3.2, punycode@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
@@ -5215,18 +6804,26 @@ q@^1.1.2:
version "1.5.0"
resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
+q@~1.4.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e"
+
qjobs@^1.1.4:
- version "1.1.5"
- resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.1.5.tgz#659de9f2cf8dcc27a1481276f205377272382e73"
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
-qs@6.4.0, qs@~6.4.0:
+qs@6.5.1, qs@~6.5.1:
+ version "6.5.1"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
+
+qs@~6.2.0:
+ version "6.2.3"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.3.tgz#1cfcb25c10a9b2b483053ff39f5dfc9233908cfe"
+
+qs@~6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
-qs@6.5.0:
- version "6.5.0"
- resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49"
-
query-string@^4.1.0:
version "4.3.2"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.2.tgz#ec0fd765f58a50031a3968c2431386f8947a5cdd"
@@ -5234,7 +6831,7 @@ query-string@^4.1.0:
object-assign "^4.1.0"
strict-uri-encode "^1.0.0"
-querystring-es3@^0.2.0:
+querystring-es3@^0.2.0, querystring-es3@~0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@@ -5251,15 +6848,24 @@ querystringify@~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"
+ version "1.1.7"
+ resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c"
dependencies:
- is-number "^2.0.2"
- kind-of "^3.0.2"
+ is-number "^3.0.0"
+ kind-of "^4.0.0"
-randombytes@^2.0.0, randombytes@^2.0.1:
- version "2.0.3"
- resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.3.tgz#674c99760901c3c4112771a31e521dc349cc09ec"
+randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.6.tgz#d302c522948588848a8d300c932b44c24231da80"
+ dependencies:
+ safe-buffer "^5.1.0"
+
+randomfill@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458"
+ dependencies:
+ randombytes "^2.0.5"
+ safe-buffer "^5.1.0"
range-parser@^1.0.3, range-parser@^1.2.0, range-parser@~1.2.0:
version "1.2.0"
@@ -5271,25 +6877,24 @@ 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"
+raven-js@^3.22.1:
+ version "3.22.1"
+ resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.22.1.tgz#1117f00dfefaa427ef6e1a7d50bbb1fb998a24da"
-raw-body@~2.2.0:
- version "2.2.0"
- resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96"
+raw-body@2, raw-body@2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89"
dependencies:
- bytes "2.4.0"
- iconv-lite "0.4.15"
+ bytes "3.0.0"
+ http-errors "1.6.2"
+ iconv-lite "0.4.19"
unpipe "1.0.0"
raw-loader@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa"
-rc@^1.0.1, rc@^1.1.7:
+rc@^1.0.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.1.tgz#2e03e8e42ee450b8cb3dce65be1bf8974e1dfd95"
dependencies:
@@ -5298,27 +6903,47 @@ rc@^1.0.1, rc@^1.1.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
-react-dev-utils@^0.5.2:
- version "0.5.2"
- resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-0.5.2.tgz#50d0b962d3a94b6c2e8f2011ed6468e4124bc410"
+rc@^1.1.6, rc@^1.1.7:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.5.tgz#275cd687f6e3b36cc756baa26dfee80a790301fd"
+ dependencies:
+ deep-extend "~0.4.0"
+ ini "~1.3.0"
+ minimist "^1.2.0"
+ strip-json-comments "~2.0.1"
+
+react-dev-utils@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-5.0.0.tgz#425ac7c9c40c2603bc4f7ab8836c1406e96bb473"
dependencies:
- ansi-html "0.0.5"
+ address "1.0.3"
+ babel-code-frame "6.26.0"
chalk "1.1.3"
+ cross-spawn "5.1.0"
+ detect-port-alt "1.1.5"
escape-string-regexp "1.0.5"
- filesize "3.3.0"
+ filesize "3.5.11"
+ global-modules "1.0.0"
gzip-size "3.0.0"
- html-entities "1.2.0"
- opn "4.0.2"
- recursive-readdir "2.1.1"
- sockjs-client "1.0.1"
+ inquirer "3.3.0"
+ is-root "1.0.0"
+ opn "5.2.0"
+ react-error-overlay "^4.0.0"
+ recursive-readdir "2.2.1"
+ shell-quote "1.6.1"
+ sockjs-client "1.1.4"
strip-ansi "3.0.1"
+ text-table "0.2.0"
-read-all-stream@^3.0.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"
+react-error-overlay@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4"
+
+read-only-stream@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0"
dependencies:
- pinkie-promise "^2.0.0"
- readable-stream "^2.0.0"
+ readable-stream "^2.0.2"
read-pkg-up@^1.0.1:
version "1.0.1"
@@ -5350,7 +6975,28 @@ read-pkg@^2.0.0:
normalize-package-data "^2.3.2"
path-type "^2.0.0"
-readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.1.0, readable-stream@^2.1.4, readable-stream@^2.2.2, readable-stream@^2.2.9:
+"readable-stream@1 || 2", readable-stream@2, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.3.0, readable-stream@^2.3.3:
+ version "2.3.4"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.4.tgz#c946c3f47fa7d8eabc0b6150f4a12f69a4574071"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.3"
+ isarray "~1.0.0"
+ process-nextick-args "~2.0.0"
+ safe-buffer "~5.1.1"
+ string_decoder "~1.0.3"
+ util-deprecate "~1.0.1"
+
+readable-stream@1.1.x, "readable-stream@1.x >=1.1.9":
+ version "1.1.14"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.1"
+ isarray "0.0.1"
+ string_decoder "~0.10.x"
+
+readable-stream@^2.0.0, readable-stream@^2.1.0, readable-stream@^2.2.2, readable-stream@^2.2.9:
version "2.3.3"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
dependencies:
@@ -5362,7 +7008,7 @@ readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.1.0, readable
string_decoder "~1.0.3"
util-deprecate "~1.0.1"
-readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@~2.0.6:
+readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@~2.0.0, 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:
@@ -5373,15 +7019,6 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable
string_decoder "~0.10.x"
util-deprecate "~1.0.1"
-readable-stream@~1.0.2:
- version "1.0.34"
- resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
- dependencies:
- core-util-is "~1.0.0"
- inherits "~2.0.1"
- isarray "0.0.1"
- string_decoder "~0.10.x"
-
readdirp@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78"
@@ -5405,9 +7042,9 @@ rechoir@^0.6.2:
dependencies:
resolve "^1.1.6"
-recursive-readdir@2.1.1:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.1.1.tgz#a01cfc7f7f38a53ec096a096f63a50489c3e297c"
+recursive-readdir@2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.1.tgz#90ef231d0778c5ce093c9a48d74e5c5422d13a99"
dependencies:
minimatch "3.0.3"
@@ -5418,6 +7055,22 @@ redent@^1.0.0:
indent-string "^2.1.0"
strip-indent "^1.0.1"
+redis-commands@^1.2.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.3.1.tgz#81d826f45fa9c8b2011f4cd7a0fe597d241d442b"
+
+redis-parser@^2.6.0:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b"
+
+redis@^2.7.1:
+ version "2.8.0"
+ resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
+ dependencies:
+ double-ended-queue "^2.1.0-0"
+ redis-commands "^1.2.0"
+ redis-parser "^2.6.0"
+
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"
@@ -5449,11 +7102,17 @@ regenerator-transform@^0.10.0:
private "^0.1.6"
regex-cache@^0.4.2:
- version "0.4.3"
- resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.3.tgz#9b1a6c35d4d0dfcef5711ae651e8e9d3d7114145"
+ version "0.4.4"
+ resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
dependencies:
is-equal-shallow "^0.1.3"
- is-primitive "^2.0.0"
+
+regex-not@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
+ dependencies:
+ extend-shallow "^3.0.2"
+ safe-regex "^1.1.0"
regexpu-core@^1.0.0:
version "1.0.0"
@@ -5471,7 +7130,14 @@ regexpu-core@^2.0.0:
regjsgen "^0.2.0"
regjsparser "^0.1.4"
-registry-url@^3.0.0:
+registry-auth-token@^3.0.1:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20"
+ dependencies:
+ rc "^1.1.6"
+ safe-buffer "^5.0.1"
+
+registry-url@^3.0.3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
dependencies:
@@ -5499,23 +7165,43 @@ repeat-string@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-0.2.2.tgz#c7a8d3236068362059a7e4651fc6884e8b1fb4ae"
-repeat-string@^1.5.2:
+repeat-string@^1.5.2, repeat-string@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
-repeating@^1.1.2:
- version "1.1.3"
- resolved "https://registry.yarnpkg.com/repeating/-/repeating-1.1.3.tgz#3d4114218877537494f97f77f9785fab810fa4ac"
- dependencies:
- is-finite "^1.0.0"
-
repeating@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
dependencies:
is-finite "^1.0.0"
-request@^2.81.0:
+request@2.75.x:
+ version "2.75.0"
+ resolved "https://registry.yarnpkg.com/request/-/request-2.75.0.tgz#d2b8268a286da13eaa5d01adf5d18cc90f657d93"
+ dependencies:
+ aws-sign2 "~0.6.0"
+ aws4 "^1.2.1"
+ bl "~1.1.2"
+ caseless "~0.11.0"
+ combined-stream "~1.0.5"
+ extend "~3.0.0"
+ forever-agent "~0.6.1"
+ form-data "~2.0.0"
+ har-validator "~2.0.6"
+ hawk "~3.1.3"
+ http-signature "~1.1.0"
+ is-typedarray "~1.0.0"
+ isstream "~0.1.2"
+ json-stringify-safe "~5.0.1"
+ mime-types "~2.1.7"
+ node-uuid "~1.4.7"
+ oauth-sign "~0.8.1"
+ qs "~6.2.0"
+ stringstream "~0.0.4"
+ tough-cookie "~2.3.0"
+ tunnel-agent "~0.4.1"
+
+request@2.81.0:
version "2.81.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0"
dependencies:
@@ -5542,6 +7228,46 @@ request@^2.81.0:
tunnel-agent "^0.6.0"
uuid "^3.0.0"
+request@^2.0.0, request@^2.74.0:
+ version "2.83.0"
+ resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356"
+ dependencies:
+ aws-sign2 "~0.7.0"
+ aws4 "^1.6.0"
+ caseless "~0.12.0"
+ combined-stream "~1.0.5"
+ extend "~3.0.1"
+ forever-agent "~0.6.1"
+ form-data "~2.3.1"
+ har-validator "~5.0.3"
+ hawk "~6.0.2"
+ http-signature "~1.2.0"
+ is-typedarray "~1.0.0"
+ isstream "~0.1.2"
+ json-stringify-safe "~5.0.1"
+ mime-types "~2.1.17"
+ oauth-sign "~0.8.2"
+ performance-now "^2.1.0"
+ qs "~6.5.1"
+ safe-buffer "^5.1.1"
+ stringstream "~0.0.5"
+ tough-cookie "~2.3.3"
+ tunnel-agent "^0.6.0"
+ uuid "^3.1.0"
+
+requestretry@^1.2.2:
+ version "1.13.0"
+ resolved "https://registry.yarnpkg.com/requestretry/-/requestretry-1.13.0.tgz#213ec1006eeb750e8b8ce54176283d15a8d55d94"
+ dependencies:
+ extend "^3.0.0"
+ lodash "^4.15.0"
+ request "^2.74.0"
+ when "^3.7.7"
+
+require-all@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/require-all/-/require-all-2.2.0.tgz#b4420c233ac0282d0ff49b277fb880a8b5de0894"
+
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@@ -5565,26 +7291,41 @@ requires-port@1.0.x, requires-port@1.x.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
+resolve-cwd@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"
+ dependencies:
+ resolve-from "^3.0.0"
+
+resolve-dir@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
+ dependencies:
+ expand-tilde "^2.0.0"
+ global-modules "^1.0.0"
+
resolve-from@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226"
-resolve@1.1.x:
+resolve-from@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"
+
+resolve-url@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
+
+resolve@1.1.7, resolve@1.1.x:
version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
-resolve@^1.1.6, resolve@^1.2.0, resolve@^1.4.0:
+resolve@^1.1.3, resolve@^1.1.4, resolve@^1.1.6, resolve@^1.2.0, resolve@^1.4.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.5.0.tgz#1f09acce796c9a762579f31b2c1cc4c3cddf9f36"
dependencies:
path-parse "^1.0.5"
-resolve@~1.4.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86"
- dependencies:
- path-parse "^1.0.5"
-
restore-cursor@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
@@ -5592,11 +7333,16 @@ restore-cursor@^1.0.1:
exit-hook "^1.0.0"
onetime "^1.0.0"
-resumer@~0.0.0:
- version "0.0.0"
- resolved "https://registry.yarnpkg.com/resumer/-/resumer-0.0.0.tgz#f1e8f461e4064ba39e82af3cdc2a8c893d076759"
+restore-cursor@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
dependencies:
- through "~2.3.4"
+ onetime "^2.0.0"
+ signal-exit "^3.0.2"
+
+ret@~0.1.10:
+ version "0.1.15"
+ resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
right-align@^0.1.1:
version "0.1.3"
@@ -5604,15 +7350,24 @@ right-align@^0.1.1:
dependencies:
align-text "^0.1.1"
-rimraf@2, rimraf@^2.2.8, rimraf@^2.4.3, rimraf@^2.4.4, rimraf@^2.5.1, rimraf@^2.6.0, rimraf@^2.6.1:
+rimraf@2, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.1, rimraf@^2.6.2:
+ version "2.6.2"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36"
+ dependencies:
+ glob "^7.0.5"
+
+rimraf@^2.2.8:
version "2.6.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d"
dependencies:
glob "^7.0.5"
-ripemd160@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-1.0.1.tgz#93a4bbd4942bc574b69a8fa57c71de10ecca7d6e"
+ripemd160@^2.0.0, ripemd160@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7"
+ dependencies:
+ hash-base "^2.0.0"
+ inherits "^2.0.1"
run-async@^0.1.0:
version "0.1.0"
@@ -5620,17 +7375,53 @@ run-async@^0.1.0:
dependencies:
once "^1.3.0"
+run-async@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
+ dependencies:
+ is-promise "^2.1.0"
+
+run-queue@^1.0.0, run-queue@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47"
+ dependencies:
+ aproba "^1.1.1"
+
+rx-lite-aggregates@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
+ dependencies:
+ rx-lite "*"
+
+rx-lite@*, rx-lite@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
+
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.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
-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"
+safe-regex@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
+ dependencies:
+ ret "~0.1.10"
+
+sanitize-html@^1.16.1:
+ version "1.16.3"
+ resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.16.3.tgz#96c1b44a36ff7312e1c22a14b05274370ac8bd56"
+ dependencies:
+ htmlparser2 "^3.9.0"
+ lodash.clonedeep "^4.5.0"
+ lodash.escaperegexp "^4.1.2"
+ lodash.mergewith "^4.6.0"
+ postcss "^6.0.14"
+ srcset "^1.0.0"
+ xtend "^4.0.0"
sax@~1.2.1:
version "1.2.2"
@@ -5642,6 +7433,13 @@ schema-utils@^0.3.0:
dependencies:
ajv "^5.0.0"
+schema-utils@^0.4.3, schema-utils@^0.4.5:
+ version "0.4.5"
+ resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e"
+ dependencies:
+ ajv "^6.1.0"
+ ajv-keywords "^3.1.0"
+
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
@@ -5666,32 +7464,40 @@ semver-diff@^2.0.0:
dependencies:
semver "^5.0.3"
-"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.3.0:
+"semver@2 || 3 || 4 || 5", semver@^5.0.3:
version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
-semver@~4.3.3:
- version "4.3.6"
- resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da"
+semver@^5.1.0, semver@^5.3.0, semver@^5.4.1:
+ version "5.5.0"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab"
-send@0.15.4:
- version "0.15.4"
- resolved "https://registry.yarnpkg.com/send/-/send-0.15.4.tgz#985faa3e284b0273c793364a35c6737bd93905b9"
+semver@~5.0.1:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-5.0.3.tgz#77466de589cd5d3c95f138aa78bc569a3cb5d27a"
+
+send@0.16.1:
+ version "0.16.1"
+ resolved "https://registry.yarnpkg.com/send/-/send-0.16.1.tgz#a70e1ca21d1382c11d0d9f6231deb281080d7ab3"
dependencies:
- debug "2.6.8"
+ debug "2.6.9"
depd "~1.1.1"
destroy "~1.0.4"
encodeurl "~1.0.1"
escape-html "~1.0.3"
- etag "~1.8.0"
- fresh "0.5.0"
+ etag "~1.8.1"
+ fresh "0.5.2"
http-errors "~1.6.2"
- mime "1.3.4"
+ mime "1.4.1"
ms "2.0.0"
on-finished "~2.3.0"
range-parser "~1.2.0"
statuses "~1.3.1"
+serialize-javascript@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.4.0.tgz#7c958514db6ac2443a8abc062dc9f7886a7f6005"
+
serve-index@^1.7.2:
version "1.9.0"
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.0.tgz#d2b280fc560d616ee81b48bf0fa82abed2485ce7"
@@ -5704,23 +7510,47 @@ serve-index@^1.7.2:
mime-types "~2.1.15"
parseurl "~1.3.1"
-serve-static@1.12.4:
- version "1.12.4"
- resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.4.tgz#9b6aa98eeb7253c4eedc4c1f6fdbca609901a961"
+serve-static@1.13.1:
+ version "1.13.1"
+ resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.1.tgz#4c57d53404a761d8f2e7c1e8a18a47dbf278a719"
dependencies:
encodeurl "~1.0.1"
escape-html "~1.0.3"
- parseurl "~1.3.1"
- send "0.15.4"
+ parseurl "~1.3.2"
+ send "0.16.1"
set-blocking@^2.0.0, set-blocking@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+set-getter@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376"
+ dependencies:
+ to-object-path "^0.3.0"
+
set-immediate-shim@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
+set-value@^0.4.3:
+ version "0.4.3"
+ resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-extendable "^0.1.1"
+ is-plain-object "^2.0.1"
+ to-object-path "^0.3.0"
+
+set-value@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-extendable "^0.1.1"
+ is-plain-object "^2.0.3"
+ split-string "^3.0.1"
+
setimmediate@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
@@ -5729,11 +7559,23 @@ 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"
+setprototypeof@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+
+sha.js@^2.4.0, sha.js@^2.4.8, sha.js@~2.4.4:
+ version "2.4.10"
+ resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.10.tgz#b1fde5cd7d11a5626638a07c604ab909cfa31f9b"
dependencies:
inherits "^2.0.1"
+ safe-buffer "^5.0.1"
+
+shasum@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/shasum/-/shasum-1.0.2.tgz#e7012310d8f417f4deb5712150e5678b87ae565f"
+ dependencies:
+ json-stable-stringify "~0.0.0"
+ sha.js "~2.4.4"
shebang-command@^1.2.0:
version "1.2.0"
@@ -5745,6 +7587,15 @@ shebang-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+shell-quote@1.6.1, shell-quote@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
+ dependencies:
+ array-filter "~0.0.0"
+ array-map "~0.0.0"
+ array-reduce "~0.0.0"
+ jsonify "~0.0.0"
+
shelljs@^0.7.5:
version "0.7.8"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.8.tgz#decbcf874b0d1e5fb72e14b164a9683048e9acb3"
@@ -5753,10 +7604,16 @@ shelljs@^0.7.5:
interpret "^1.0.0"
rechoir "^0.6.2"
-signal-exit@^3.0.0:
+signal-exit@^3.0.0, signal-exit@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+slack-node@~0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/slack-node/-/slack-node-0.2.0.tgz#de4b8dddaa8b793f61dbd2938104fdabf37dfa30"
+ dependencies:
+ requestretry "^1.2.2"
+
slash@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
@@ -5765,9 +7622,43 @@ slice-ansi@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
-slide@^1.1.5:
- version "1.1.6"
- resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
+smart-buffer@^1.0.13, smart-buffer@^1.0.4:
+ version "1.1.15"
+ resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-1.1.15.tgz#7f114b5b65fab3e2a35aa775bb12f0d1c649bf16"
+
+smtp-connection@2.12.0:
+ version "2.12.0"
+ resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1"
+ dependencies:
+ httpntlm "1.6.1"
+ nodemailer-shared "1.1.0"
+
+snapdragon-node@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
+ dependencies:
+ define-property "^1.0.0"
+ isobject "^3.0.0"
+ snapdragon-util "^3.0.1"
+
+snapdragon-util@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
+ dependencies:
+ kind-of "^3.2.0"
+
+snapdragon@^0.8.1:
+ version "0.8.1"
+ resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.1.tgz#e12b5487faded3e3dea0ac91e9400bf75b401370"
+ dependencies:
+ base "^0.11.1"
+ debug "^2.2.0"
+ define-property "^0.2.5"
+ extend-shallow "^2.0.1"
+ map-cache "^0.2.2"
+ source-map "^0.5.6"
+ source-map-resolve "^0.5.0"
+ use "^2.0.0"
sntp@1.x.x:
version "1.0.9"
@@ -5775,60 +7666,52 @@ sntp@1.x.x:
dependencies:
hoek "2.x.x"
-socket.io-adapter@0.5.0:
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz#cb6d4bb8bec81e1078b99677f9ced0046066bb8b"
+sntp@2.x.x:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.1.0.tgz#2c6cec14fedc2222739caf9b5c3d85d1cc5a2cc8"
dependencies:
- debug "2.3.3"
- socket.io-parser "2.3.1"
+ hoek "4.x.x"
+
+socket.io-adapter@~1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
-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"
+socket.io-client@2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.0.4.tgz#0918a552406dc5e540b380dcd97afc4a64332f8e"
dependencies:
backo2 "1.0.2"
+ base64-arraybuffer "0.1.5"
component-bind "1.0.0"
component-emitter "1.2.1"
- debug "2.3.3"
- engine.io-client "1.8.3"
- has-binary "0.1.7"
+ debug "~2.6.4"
+ engine.io-client "~3.1.0"
+ has-cors "1.1.0"
indexof "0.0.1"
object-component "0.0.3"
+ parseqs "0.0.5"
parseuri "0.0.5"
- socket.io-parser "2.3.1"
+ socket.io-parser "~3.1.1"
to-array "0.1.4"
-socket.io-parser@2.3.1:
- version "2.3.1"
- resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-2.3.1.tgz#dd532025103ce429697326befd64005fcfe5b4a0"
- dependencies:
- component-emitter "1.1.2"
- debug "2.2.0"
- isarray "0.0.1"
- json3 "3.3.2"
-
-socket.io@1.7.3:
- version "1.7.3"
- resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.3.tgz#b8af9caba00949e568e369f1327ea9be9ea2461b"
+socket.io-parser@~3.1.1:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.1.2.tgz#dbc2282151fc4faebbe40aeedc0772eba619f7f2"
dependencies:
- debug "2.3.3"
- 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.3"
- socket.io-parser "2.3.1"
+ component-emitter "1.2.1"
+ debug "~2.6.4"
+ has-binary2 "~1.0.2"
+ isarray "2.0.1"
-sockjs-client@1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.0.1.tgz#8943ae05b46547bc2054816c409002cf5e2fe026"
+socket.io@2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.0.4.tgz#c1a4590ceff87ecf13c72652f046f716b29e6014"
dependencies:
- debug "^2.1.0"
- eventsource "^0.1.3"
- faye-websocket "~0.7.3"
- inherits "^2.0.1"
- json3 "^3.3.2"
- url-parse "^1.0.1"
+ debug "~2.6.6"
+ engine.io "~3.1.0"
+ socket.io-adapter "~1.1.0"
+ socket.io-client "2.0.4"
+ socket.io-parser "~3.1.1"
sockjs-client@1.1.4:
version "1.1.4"
@@ -5841,12 +7724,34 @@ sockjs-client@1.1.4:
json3 "^3.3.2"
url-parse "^1.1.8"
-sockjs@0.3.18:
- version "0.3.18"
- resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.18.tgz#d9b289316ca7df77595ef299e075f0f937eb4207"
+sockjs@0.3.19:
+ version "0.3.19"
+ resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.19.tgz#d976bbe800af7bd20ae08598d582393508993c0d"
dependencies:
faye-websocket "^0.10.0"
- uuid "^2.0.2"
+ uuid "^3.0.1"
+
+socks-proxy-agent@2:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-2.1.1.tgz#86ebb07193258637870e13b7bd99f26c663df3d3"
+ dependencies:
+ agent-base "2"
+ extend "3"
+ socks "~1.1.5"
+
+socks@1.1.9:
+ version "1.1.9"
+ resolved "https://registry.yarnpkg.com/socks/-/socks-1.1.9.tgz#628d7e4d04912435445ac0b6e459376cb3e6d691"
+ dependencies:
+ ip "^1.1.2"
+ smart-buffer "^1.0.4"
+
+socks@~1.1.5:
+ version "1.1.10"
+ resolved "https://registry.yarnpkg.com/socks/-/socks-1.1.10.tgz#5b8b7fc7c8f341c53ed056e929b7bf4de8ba7b5a"
+ dependencies:
+ ip "^1.1.4"
+ smart-buffer "^1.0.13"
sort-keys@^1.0.0:
version "1.1.2"
@@ -5854,29 +7759,33 @@ sort-keys@^1.0.0:
dependencies:
is-plain-obj "^1.0.0"
-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@^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-resolve@^0.5.0:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.1.tgz#7ad0f593f2281598e854df80f19aae4b92d7a11a"
+ dependencies:
+ atob "^2.0.0"
+ decode-uri-component "^0.2.0"
+ resolve-url "^0.2.1"
+ source-map-url "^0.4.0"
+ urix "^0.1.0"
+
source-map-support@^0.4.15:
version "0.4.18"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f"
dependencies:
source-map "^0.5.6"
-source-map@0.5.x, 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-url@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
-source-map@^0.1.41:
- version "0.1.43"
- resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346"
- dependencies:
- amdefine ">=0.0.4"
+source-map@0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.0.tgz#0fe96503ac86a5adb5de63f4e412ae4872cdbe86"
source-map@^0.4.4:
version "0.4.4"
@@ -5884,6 +7793,10 @@ source-map@^0.4.4:
dependencies:
amdefine ">=0.0.4"
+source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1:
+ version "0.5.6"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
+
source-map@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
@@ -5894,6 +7807,10 @@ source-map@~0.2.0:
dependencies:
amdefine ">=0.0.4"
+source-map@~0.5.3, source-map@~0.5.6:
+ version "0.5.7"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+
spdx-correct@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40"
@@ -5931,6 +7848,12 @@ spdy@^3.4.1:
select-hose "^2.0.0"
spdy-transport "^2.0.18"
+split-string@^3.0.1, split-string@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
+ dependencies:
+ extend-shallow "^3.0.0"
+
split@0.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f"
@@ -5945,6 +7868,13 @@ sql.js@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445"
+srcset@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/srcset/-/srcset-1.0.0.tgz#a5669de12b42f3b1d5e83ed03c71046fc48f41ef"
+ dependencies:
+ array-uniq "^1.0.2"
+ number-is-nan "^1.0.0"
+
sshpk@^1.7.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3"
@@ -5959,23 +7889,64 @@ sshpk@^1.7.0:
jsbn "~0.1.0"
tweetnacl "~0.14.0"
-"statuses@>= 1.3.1 < 2", statuses@~1.3.1:
+ssri@^5.2.4:
+ version "5.2.4"
+ resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.2.4.tgz#9985e14041e65fc397af96542be35724ac11da52"
+ dependencies:
+ safe-buffer "^5.1.1"
+
+static-extend@^0.1.1:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
+ dependencies:
+ define-property "^0.2.5"
+ object-copy "^0.1.0"
+
+"statuses@>= 1.3.1 < 2":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
+
+statuses@~1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e"
-stream-browserify@^2.0.1:
+stream-browserify@^2.0.0, stream-browserify@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"
dependencies:
inherits "~2.0.1"
readable-stream "^2.0.2"
+stream-combiner2@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/stream-combiner2/-/stream-combiner2-1.1.1.tgz#fb4d8a1420ea362764e21ad4780397bebcb41cbe"
+ dependencies:
+ duplexer2 "~0.1.0"
+ readable-stream "^2.0.2"
+
stream-combiner@~0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14"
dependencies:
duplexer "~0.1.1"
+stream-each@^1.1.0:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd"
+ dependencies:
+ end-of-stream "^1.1.0"
+ stream-shift "^1.0.0"
+
+stream-http@^2.0.0:
+ version "2.8.0"
+ resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.0.tgz#fd86546dac9b1c91aff8fc5d287b98fafb41bc10"
+ dependencies:
+ builtin-status-codes "^3.0.0"
+ inherits "^2.0.1"
+ readable-stream "^2.3.3"
+ to-arraybuffer "^1.0.0"
+ xtend "^4.0.0"
+
stream-http@^2.3.1:
version "2.6.3"
resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.6.3.tgz#4c3ddbf9635968ea2cfd4e48d43de5def2625ac3"
@@ -5990,16 +7961,26 @@ stream-shift@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
+stream-splicer@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/stream-splicer/-/stream-splicer-2.0.0.tgz#1b63be438a133e4b671cc1935197600175910d83"
+ dependencies:
+ inherits "^2.0.1"
+ readable-stream "^2.0.2"
+
+streamroller@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-0.7.0.tgz#a1d1b7cf83d39afb0d63049a5acbf93493bdf64b"
+ dependencies:
+ date-format "^1.2.0"
+ debug "^3.1.0"
+ mkdirp "^0.5.1"
+ readable-stream "^2.3.0"
+
strict-uri-encode@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
-string-length@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/string-length/-/string-length-1.0.1.tgz#56970fb1c38558e9e70b728bf3de269ac45adfac"
- dependencies:
- strip-ansi "^3.0.0"
-
string-width@^1.0.1, string-width@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
@@ -6015,25 +7996,24 @@ string-width@^2.0.0:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^3.0.0"
-string.prototype.trim@~1.1.2:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea"
+string-width@^2.1.0, string-width@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
dependencies:
- define-properties "^1.1.2"
- es-abstract "^1.5.0"
- function-bind "^1.0.2"
+ is-fullwidth-code-point "^2.0.0"
+ strip-ansi "^4.0.0"
string_decoder@^0.10.25, string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
-string_decoder@~1.0.3:
+string_decoder@~1.0.0, string_decoder@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
dependencies:
safe-buffer "~5.1.0"
-stringstream@~0.0.4:
+stringstream@~0.0.4, stringstream@~0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
@@ -6043,6 +8023,12 @@ strip-ansi@3.0.1, strip-ansi@^3.0.0, strip-ansi@^3.0.1:
dependencies:
ansi-regex "^2.0.0"
+strip-ansi@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+ dependencies:
+ ansi-regex "^3.0.0"
+
strip-bom@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
@@ -6067,11 +8053,24 @@ strip-json-comments@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+style-loader@^0.20.2:
+ version "0.20.2"
+ resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.20.2.tgz#851b373c187890331776e9cde359eea9c95ecd00"
+ dependencies:
+ loader-utils "^1.1.0"
+ schema-utils "^0.4.3"
+
+subarg@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2"
+ dependencies:
+ minimist "^1.1.0"
+
supports-color@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
-supports-color@^3.1.0, supports-color@^3.1.1, supports-color@^3.1.2, supports-color@^3.2.3:
+supports-color@^3.1.0, supports-color@^3.1.2, supports-color@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6"
dependencies:
@@ -6089,6 +8088,18 @@ supports-color@^4.2.1:
dependencies:
has-flag "^2.0.0"
+supports-color@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.1.0.tgz#058a021d1b619f7ddf3980d712ea3590ce7de3d5"
+ dependencies:
+ has-flag "^2.0.0"
+
+supports-color@^5.2.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.2.0.tgz#b0d5333b1184dd3666cbe5aa0b45c5ac7ac17a4a"
+ dependencies:
+ has-flag "^3.0.0"
+
svg4everybody@2.1.9:
version "2.1.9"
resolved "https://registry.yarnpkg.com/svg4everybody/-/svg4everybody-2.1.9.tgz#5bd9f6defc133859a044646d4743fabc28db7e2d"
@@ -6105,6 +8116,12 @@ svgo@^0.7.0:
sax "~1.2.1"
whet.extend "~0.9.9"
+syntax-error@^1.1.1:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.4.0.tgz#2d9d4ff5c064acb711594a3e3b95054ad51d907c"
+ dependencies:
+ acorn-node "^1.2.0"
+
table@^3.7.8:
version "3.8.3"
resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"
@@ -6124,27 +8141,9 @@ tapable@^0.2.7:
version "0.2.8"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22"
-tape@^4.6.3:
- version "4.8.0"
- resolved "https://registry.yarnpkg.com/tape/-/tape-4.8.0.tgz#f6a9fec41cc50a1de50fa33603ab580991f6068e"
- dependencies:
- deep-equal "~1.0.1"
- defined "~1.0.0"
- for-each "~0.3.2"
- function-bind "~1.1.0"
- glob "~7.1.2"
- has "~1.0.1"
- inherits "~2.0.3"
- minimist "~1.2.0"
- object-inspect "~1.3.0"
- resolve "~1.4.0"
- resumer "~0.0.0"
- string.prototype.trim "~1.1.2"
- through "~2.3.8"
-
tar-pack@^3.4.0:
- version "3.4.0"
- resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984"
+ version "3.4.1"
+ resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.1.tgz#e1dbc03a9b9d3ba07e896ad027317eb679a10a1f"
dependencies:
debug "^2.2.0"
fstream "^1.0.10"
@@ -6163,6 +8162,12 @@ tar@^2.2.1:
fstream "^1.0.2"
inherits "2"
+term-size@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
+ dependencies:
+ execa "^0.7.0"
+
test-exclude@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-4.1.1.tgz#4d84964b0966b0087ecc334a2ce002d3d9341e26"
@@ -6173,7 +8178,7 @@ test-exclude@^4.1.1:
read-pkg-up "^1.0.1"
require-main-filename "^1.0.1"
-text-table@~0.2.0:
+text-table@0.2.0, text-table@~0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@@ -6189,29 +8194,40 @@ three@^0.84.0:
version "0.84.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.84.0.tgz#95be85a55a0fa002aa625ed559130957dcffd918"
-through@2, through@^2.3.6, through@~2.3, through@~2.3.1, through@~2.3.4, through@~2.3.8:
+through2@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be"
+ dependencies:
+ readable-stream "^2.1.5"
+ xtend "~4.0.1"
+
+through@2, "through@>=2.2.7 <3", 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"
+thunkify@~2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d"
+
thunky@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/thunky/-/thunky-0.1.0.tgz#bf30146824e2b6e67b0f2d7a4ac8beb26908684e"
+time-stamp@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-2.0.0.tgz#95c6a44530e15ba8d6f4a3ecb8c3a3fac46da357"
+
timeago.js@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/timeago.js/-/timeago.js-3.0.2.tgz#32a67e7c0d887ea42ca588d3aae26f77de5e76cc"
dependencies:
"@types/jquery" "^2.0.40"
-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:
+timers-browserify@^1.0.1, timers-browserify@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-1.4.2.tgz#c9c58b575be8407375cb5e2462dacee74359f41d"
dependencies:
@@ -6223,15 +8239,19 @@ timers-browserify@^2.0.2:
dependencies:
setimmediate "^1.0.4"
+timespan@2.3.x:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/timespan/-/timespan-2.3.0.tgz#4902ce040bd13d845c8f59b27e9d59bad6f39929"
+
tiny-emitter@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c"
-tmp@0.0.31, tmp@0.0.x:
- version "0.0.31"
- resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7"
+tmp@0.0.33, tmp@0.0.x, tmp@^0.0.33:
+ version "0.0.33"
+ resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
dependencies:
- os-tmpdir "~1.0.1"
+ os-tmpdir "~1.0.2"
to-array@0.1.4:
version "0.1.4"
@@ -6249,15 +8269,36 @@ to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
-touch@1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/touch/-/touch-1.0.0.tgz#449cbe2dbae5a8c8038e30d71fa0ff464947c4de"
+to-object-path@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
+ dependencies:
+ kind-of "^3.0.2"
+
+to-regex-range@^2.1.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
+ dependencies:
+ is-number "^3.0.0"
+ repeat-string "^1.6.1"
+
+to-regex@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.1.tgz#15358bee4a2c83bd76377ba1dc049d0f18837aae"
+ dependencies:
+ define-property "^0.2.5"
+ extend-shallow "^2.0.1"
+ regex-not "^1.0.0"
+
+touch@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
dependencies:
nopt "~1.0.10"
-tough-cookie@~2.3.0:
- version "2.3.2"
- resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a"
+tough-cookie@~2.3.0, tough-cookie@~2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561"
dependencies:
punycode "^1.4.1"
@@ -6273,20 +8314,36 @@ trim-right@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
+tryer@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.0.tgz#027b69fa823225e551cace3ef03b11f6ab37c1d7"
+
tryit@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb"
+tsscmp@~1.0.0:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.5.tgz#7dc4a33af71581ab4337da91d85ca5427ebd9a97"
+
tty-browserify@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
+tty-browserify@~0.0.0:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811"
+
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
dependencies:
safe-buffer "^5.0.1"
+tunnel-agent@~0.4.1:
+ version "0.4.3"
+ resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb"
+
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
@@ -6298,13 +8355,13 @@ type-check@~0.3.2:
prelude-ls "~1.1.2"
type-is@~1.6.15:
- version "1.6.15"
- resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410"
+ version "1.6.16"
+ resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194"
dependencies:
media-typer "0.3.0"
- mime-types "~2.1.15"
+ mime-types "~2.1.18"
-typedarray@^0.0.6:
+typedarray@^0.0.6, typedarray@~0.0.5:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
@@ -6333,26 +8390,41 @@ uid-number@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
-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"
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
+
+umd@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.1.tgz#8ae556e11011f63c2596708a8837259f01b3d60e"
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"
-undefsafe@0.0.3:
- version "0.0.3"
- resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-0.0.3.tgz#ecca3a03e56b9af17385baac812ac83b994a962f"
+undefsafe@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.2.tgz#225f6b9e0337663e0d8e7cfd686fc2836ccace76"
+ dependencies:
+ debug "^2.2.0"
underscore@^1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
+underscore@~1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.7.0.tgz#6bbaf0877500d36be34ecaa584e0db9fef035209"
+
+union-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"
+ dependencies:
+ arr-union "^3.1.0"
+ get-value "^2.0.6"
+ is-extendable "^0.1.1"
+ set-value "^0.4.3"
+
uniq@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
@@ -6367,28 +8439,73 @@ uniqs@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02"
+unique-filename@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3"
+ dependencies:
+ unique-slug "^2.0.0"
+
+unique-slug@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab"
+ dependencies:
+ imurmurhash "^0.1.4"
+
+unique-string@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
+ dependencies:
+ crypto-random-string "^1.0.0"
+
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
-update-notifier@0.5.0:
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-0.5.0.tgz#07b5dc2066b3627ab3b4f530130f7eddda07a4cc"
+unset-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
dependencies:
- chalk "^1.0.0"
- configstore "^1.0.0"
+ has-value "^0.3.1"
+ isobject "^3.0.0"
+
+unzip-response@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
+
+upath@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/upath/-/upath-1.0.2.tgz#80aaae5395abc5fd402933ae2f58694f0860204c"
+ dependencies:
+ lodash.endswith "^4.2.1"
+ lodash.isfunction "^3.0.8"
+ lodash.isstring "^4.0.1"
+ lodash.startswith "^4.2.1"
+
+update-notifier@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.3.0.tgz#4e8827a6bb915140ab093559d7014e3ebb837451"
+ dependencies:
+ boxen "^1.2.1"
+ chalk "^2.0.1"
+ configstore "^3.0.0"
+ import-lazy "^2.1.0"
+ is-installed-globally "^0.1.0"
is-npm "^1.0.0"
- latest-version "^1.0.0"
- repeating "^1.1.2"
+ latest-version "^3.0.0"
semver-diff "^2.0.0"
- string-length "^1.0.0"
+ xdg-basedir "^3.0.0"
+
+urix@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
-url-loader@^0.5.8:
- version "0.5.8"
- resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.5.8.tgz#b9183b1801e0f847718673673040bc9dc1c715c5"
+url-loader@^0.6.2:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.6.2.tgz#a007a7109620e9d988d14bce677a1decb9a993f7"
dependencies:
loader-utils "^1.0.2"
- mime "1.3.x"
+ mime "^1.4.1"
+ schema-utils "^0.3.0"
url-parse-lax@^1.0.0:
version "1.0.0"
@@ -6403,13 +8520,6 @@ url-parse@1.0.x:
querystringify "0.0.x"
requires-port "1.0.x"
-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"
@@ -6421,13 +8531,21 @@ 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:
+url@^0.11.0, url@~0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
dependencies:
punycode "1.3.2"
querystring "0.2.0"
+use@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/use/-/use-2.0.2.tgz#ae28a0d72f93bf22422a18a2e379993112dec8e8"
+ dependencies:
+ define-property "^0.2.5"
+ isobject "^3.0.0"
+ lazy-cache "^2.0.2"
+
user-home@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f"
@@ -6435,33 +8553,33 @@ user-home@^2.0.0:
os-homedir "^1.0.0"
useragent@^2.1.12:
- version "2.2.1"
- resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.2.1.tgz#cf593ef4f2d175875e8bb658ea92e18a4fd06d8e"
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.3.0.tgz#217f943ad540cb2128658ab23fc960f6a88c9972"
dependencies:
- lru-cache "2.2.x"
+ lru-cache "4.1.x"
tmp "0.0.x"
util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
-util@0.10.3, util@^0.10.3:
+util@0.10.3, util@^0.10.3, util@~0.10.1:
version "0.10.3"
resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9"
dependencies:
inherits "2.0.1"
-utils-merge@1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8"
+utils-merge@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
-uuid@^2.0.1, uuid@^2.0.2:
- version "2.0.3"
- resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
+uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14"
-uuid@^3.0.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
+uws@~9.14.0:
+ version "9.14.0"
+ resolved "https://registry.yarnpkg.com/uws/-/uws-9.14.0.tgz#fac8386befc33a7a3705cbd58dc47b430ca4dd95"
validate-npm-package-license@^3.0.1:
version "3.0.1"
@@ -6474,6 +8592,10 @@ vary@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37"
+vary@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+
vendors@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22"
@@ -6490,7 +8612,7 @@ visibilityjs@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/visibilityjs/-/visibilityjs-1.2.4.tgz#bff8663da62c8c10ad4ee5ae6a1ae6fac4259d63"
-vm-browserify@0.0.4:
+vm-browserify@0.0.4, vm-browserify@~0.0.1:
version "0.0.4"
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73"
dependencies:
@@ -6500,13 +8622,24 @@ void-elements@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
+vue-eslint-parser@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-2.0.1.tgz#30135771c4fad00fdbac4542a2d59f3b1d776834"
+ dependencies:
+ debug "^3.1.0"
+ eslint-scope "^3.7.1"
+ eslint-visitor-keys "^1.0.0"
+ espree "^3.5.2"
+ esquery "^1.0.0"
+ lodash "^4.17.4"
+
vue-hot-reload-api@^2.2.0:
version "2.2.4"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.2.4.tgz#683bd1d026c0d3b3c937d5875679e9a87ec6cd8f"
-vue-loader@^13.5.0:
- version "13.5.0"
- resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-13.5.0.tgz#52f7b3790a267eff80012b77ea187a54586dd5d4"
+vue-loader@^14.1.1:
+ version "14.1.1"
+ resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-14.1.1.tgz#331f197fcea790d6b8662c29b850806e7eb29342"
dependencies:
consolidate "^0.14.0"
hash-sum "^1.0.2"
@@ -6519,29 +8652,29 @@ vue-loader@^13.5.0:
resolve "^1.4.0"
source-map "^0.6.1"
vue-hot-reload-api "^2.2.0"
- vue-style-loader "^3.0.0"
+ vue-style-loader "^4.0.1"
vue-template-es2015-compiler "^1.6.0"
-vue-resource@^1.3.4:
- version "1.3.4"
- resolved "https://registry.yarnpkg.com/vue-resource/-/vue-resource-1.3.4.tgz#9fc0bdf6a2f5cab430129fc99d347b3deae7b099"
+vue-resource@^1.3.5:
+ version "1.3.5"
+ resolved "https://registry.yarnpkg.com/vue-resource/-/vue-resource-1.3.5.tgz#021d8713e9d86a77e83169dfdd8eab6047369a71"
dependencies:
- got "^7.0.0"
+ got "^7.1.0"
vue-router@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
-vue-style-loader@^3.0.0:
- version "3.0.3"
- resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-3.0.3.tgz#623658f81506aef9d121cdc113a4f5c9cac32df7"
+vue-style-loader@^4.0.1:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-4.0.2.tgz#e89aa4702a0c6b9630d8de70b1cbddb06b9ad254"
dependencies:
hash-sum "^1.0.2"
loader-utils "^1.0.2"
-vue-template-compiler@^2.5.8:
- version "2.5.8"
- resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.8.tgz#826ae77e1d5faa7fa5fca554f33872dde38de674"
+vue-template-compiler@^2.5.13:
+ version "2.5.13"
+ resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.5.13.tgz#12a2aa0ecd6158ac5e5f14d294b0993f399c3d38"
dependencies:
de-indent "^1.0.2"
he "^1.1.0"
@@ -6550,9 +8683,9 @@ vue-template-es2015-compiler@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.6.0.tgz#dc42697133302ce3017524356a6c61b7b69b4a18"
-vue@^2.5.8:
- version "2.5.8"
- resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.8.tgz#f855c1c27255184a82225f4bef225473e8faf15b"
+vue@^2.5.13:
+ version "2.5.13"
+ resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.13.tgz#95bd31e20efcf7a7f39239c9aa6787ce8cf578e1"
vuex@^3.0.1:
version "3.0.1"
@@ -6572,58 +8705,64 @@ wbuf@^1.1.0, wbuf@^1.7.2:
dependencies:
minimalistic-assert "^1.0.0"
-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"
+webpack-bundle-analyzer@^2.10.0:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.10.0.tgz#d0646cda342939f6f05eb632a090abbd90317446"
dependencies:
- acorn "^5.0.3"
- chalk "^1.1.3"
- commander "^2.9.0"
- ejs "^2.5.6"
- express "^4.15.2"
- filesize "^3.5.9"
- gzip-size "^3.0.0"
+ acorn "^5.3.0"
+ bfj-node4 "^5.2.0"
+ chalk "^2.3.0"
+ commander "^2.13.0"
+ ejs "^2.5.7"
+ express "^4.16.2"
+ filesize "^3.5.11"
+ gzip-size "^4.1.0"
lodash "^4.17.4"
mkdirp "^0.5.1"
opener "^1.4.3"
- ws "^2.3.1"
+ ws "^4.0.0"
-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"
+webpack-dev-middleware@1.12.2, webpack-dev-middleware@^1.12.0:
+ version "1.12.2"
+ resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.12.2.tgz#f8fc1120ce3b4fc5680ceecb43d777966b21105e"
dependencies:
memory-fs "~0.4.1"
- mime "^1.3.4"
+ mime "^1.5.0"
path-is-absolute "^1.0.0"
range-parser "^1.0.3"
+ time-stamp "^2.0.0"
-webpack-dev-server@^2.6.1:
- version "2.7.1"
- resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.7.1.tgz#21580f5a08cd065c71144cf6f61c345bca59a8b8"
+webpack-dev-server@^2.11.2:
+ version "2.11.2"
+ resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.11.2.tgz#1f4f4c78bf1895378f376815910812daf79a216f"
dependencies:
ansi-html "0.0.7"
+ array-includes "^3.0.3"
bonjour "^3.5.0"
- chokidar "^1.6.0"
+ chokidar "^2.0.0"
compression "^1.5.2"
connect-history-api-fallback "^1.3.0"
+ debug "^3.1.0"
del "^3.0.0"
- express "^4.13.3"
+ express "^4.16.2"
html-entities "^1.2.0"
http-proxy-middleware "~0.17.4"
- internal-ip "^1.2.0"
+ import-local "^1.0.0"
+ internal-ip "1.2.0"
ip "^1.1.5"
+ killable "^1.0.0"
loglevel "^1.4.1"
- opn "4.0.2"
+ opn "^5.1.0"
portfinder "^1.0.9"
selfsigned "^1.9.1"
serve-index "^1.7.2"
- sockjs "0.3.18"
+ sockjs "0.3.19"
sockjs-client "1.1.4"
spdy "^3.4.1"
strip-ansi "^3.0.0"
- supports-color "^3.1.1"
- webpack-dev-middleware "^1.11.0"
- yargs "^6.0.0"
+ supports-color "^5.1.0"
+ webpack-dev-middleware "1.12.2"
+ yargs "6.6.0"
webpack-sources@^1.0.1:
version "1.0.1"
@@ -6636,14 +8775,14 @@ 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.5:
- version "3.5.5"
- resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.5.5.tgz#3226f09fc8b3e435ff781e7af34f82b68b26996c"
+webpack@^3.11.0:
+ version "3.11.0"
+ resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.11.0.tgz#77da451b1d7b4b117adaf41a1a93b5742f24d894"
dependencies:
acorn "^5.0.0"
acorn-dynamic-import "^2.0.0"
- ajv "^5.1.5"
- ajv-keywords "^2.0.0"
+ ajv "^6.1.0"
+ ajv-keywords "^3.1.0"
async "^2.1.2"
enhanced-resolve "^3.4.0"
escope "^3.6.0"
@@ -6663,7 +8802,7 @@ webpack@^3.5.5:
webpack-sources "^1.0.1"
yargs "^8.0.2"
-websocket-driver@>=0.3.6, websocket-driver@>=0.5.1:
+websocket-driver@>=0.5.1:
version "0.6.5"
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36"
dependencies:
@@ -6673,6 +8812,10 @@ websocket-extensions@>=0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.1.tgz#76899499c184b6ef754377c2dbb0cd6cb55d29e7"
+when@^3.7.7:
+ version "3.7.8"
+ resolved "https://registry.yarnpkg.com/when/-/when-3.7.8.tgz#c7130b6a7ea04693e842cdc9e7a1f2aa39a39f82"
+
whet.extend@~0.9.9:
version "0.9.9"
resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"
@@ -6691,12 +8834,24 @@ which@^1.1.1, which@^1.2.1, which@^1.2.9:
dependencies:
isexe "^1.1.1"
+which@^1.2.14:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a"
+ dependencies:
+ isexe "^2.0.0"
+
wide-align@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710"
dependencies:
string-width "^1.0.2"
+widest-line@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.0.tgz#0142a4e8a243f8882c0233aa0e0281aa76152273"
+ dependencies:
+ string-width "^2.1.1"
+
window-size@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
@@ -6731,13 +8886,13 @@ wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
-write-file-atomic@^1.1.2:
- version "1.3.4"
- resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.4.tgz#f807a4f0b1d9e913ae7a48112e6cc3af1991b45f"
+write-file-atomic@^2.0.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.3.0.tgz#1ff61575c2e2a4e8e510d6fa4e243cce183999ab"
dependencies:
graceful-fs "^4.1.11"
imurmurhash "^0.1.4"
- slide "^1.1.5"
+ signal-exit "^3.0.2"
write@^0.2.1:
version "0.2.1"
@@ -6745,35 +8900,35 @@ write@^0.2.1:
dependencies:
mkdirp "^0.5.1"
-ws@1.1.2:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.2.tgz#8a244fa052401e08c9886cf44a85189e1fd4067f"
+ws@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-4.0.0.tgz#bfe1da4c08eeb9780b986e0e4d10eccd7345999f"
dependencies:
- options ">=0.0.5"
- ultron "1.0.x"
+ async-limiter "~1.0.0"
+ safe-buffer "~5.1.0"
+ ultron "~1.1.0"
-ws@^2.3.1:
- version "2.3.1"
- resolved "https://registry.yarnpkg.com/ws/-/ws-2.3.1.tgz#6b94b3e447cb6a363f785eaf94af6359e8e81c80"
+ws@~3.3.1:
+ version "3.3.3"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2"
dependencies:
- safe-buffer "~5.0.1"
+ async-limiter "~1.0.0"
+ safe-buffer "~5.1.0"
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"
+xdg-basedir@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
-xdg-basedir@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
- dependencies:
- os-homedir "^1.0.0"
+xmlhttprequest-ssl@~1.5.4:
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
-xmlhttprequest-ssl@1.5.3:
- version "1.5.3"
- resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d"
+xregexp@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
-xtend@^4.0.0:
+xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
@@ -6781,6 +8936,10 @@ y18n@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
+y18n@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
+
yallist@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
@@ -6797,7 +8956,7 @@ yargs-parser@^7.0.0:
dependencies:
camelcase "^4.1.0"
-yargs@^6.0.0:
+yargs@6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-6.6.0.tgz#782ec21ef403345f830a808ca3d513af56065208"
dependencies: